mirror of
https://github.com/motajs/template.git
synced 2026-10-29 20:30:20 +08:00
docs(10-27): U6 修订批次A(范围/图块/装备)
This commit is contained in:
parent
011759fdd4
commit
5751dd9d72
@ -38,4 +38,16 @@
|
||||
|
||||
## 迭代记录
|
||||
|
||||
(待执行后追加)
|
||||
### 批次 A(U6-01..U6-09)执行记录(2026-10-22)
|
||||
|
||||
- U6-01:`custom-range.md` 的 `new RingRange()` 已提为模块级常量,调用处复用;验证段同步。
|
||||
- U6-02:新增「estimatePointCount 的取舍」小节,强调只需快速估算、不要为精确牺牲性能;正文与边界条目同步。
|
||||
- U6-03/D-70:`static-vs-dynamic-tiles.md` 中孤立的 `num()` 方法体已包进完整 `StaticTile` 类上下文;其余代码块检查为完整函数/类。
|
||||
- U6-04:新增 `point-and-tile-events.md`;把原「事件的两类归属」「事件视图接口」「在代码里操作事件」迁入并扩写(区别、跟随移动、作者可设置、存档恢复);`static-vs-dynamic-tiles.md` 保留简短指引;侧边栏 9→10,`index.md` 同步为十篇。
|
||||
- U6-05:`markPure` / `ref` 在事件文档与图块文档中均只作「样板内部优化接口,一般不要调用」的简短说明。
|
||||
- U6-06:`equipment-system.md` 不再讲 `getModifiers` 的懒构建等实现细节,只说明它用于获取装备修饰器;示例与边界条目相应收敛。
|
||||
- U6-07:改写「槽位写法必须与定义一致/不能混用」;明确索引最精确、名称是粗略描述(同名槽无法指定),并说明 `equip.slots.includes` 的实际判定。
|
||||
- U6-08/D-69:「修饰器与存档的内部实现」移至文档靠后(装备事件之后、验证之前),开头写明「此章节讲解一下……的内部实现」。
|
||||
- U6-09:`equipment-system.md` 明确 `loadEvent` / `unloadEvent` 尚未实现,当前只是数据字段,也没有内置穿戴/卸下钩子。
|
||||
|
||||
**验证:** `pnpm exec prettier --write`(含新文档)→ `pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 构建通过,无死链告警。
|
||||
|
||||
@ -196,6 +196,10 @@ export default defineConfig({
|
||||
text: '静态图块与动态图块',
|
||||
link: '/advanced/ii/static-vs-dynamic-tiles'
|
||||
},
|
||||
{
|
||||
text: '点事件与图块事件',
|
||||
link: '/advanced/ii/point-and-tile-events'
|
||||
},
|
||||
{
|
||||
text: '勇士的装备系统',
|
||||
link: '/advanced/ii/equipment-system'
|
||||
|
||||
@ -57,6 +57,18 @@ export interface IRangeHost {
|
||||
|
||||
`BaseRange` 已经实现了 `inRangeIndex`、`iterate`、`scan`、`autoDetect`。继承它时通常只需要实现三个方法:`estimatePointCount`、`iterateLoc`、`inRange`。
|
||||
|
||||
### estimatePointCount 的取舍
|
||||
|
||||
`autoDetect` 每次调用时都会先执行一遍 `estimatePointCount`,再拿结果和 `list.size` 比较。因此这个估算的耗时会计入每一次范围查询——`scanRange`、光环、地图伤害等所有走 `autoDetect` 的地方都受影响。
|
||||
|
||||
它只需要「尽量快地估出一个大致数量」,不需要精确:
|
||||
|
||||
- 估算结果只决定 `autoDetect` 走 `scan` 还是 `iterate`,不影响最终命中哪些格;
|
||||
- 估大或估小最多让选型变差,不会让结果出错;
|
||||
- 为了准确去遍历格子、读取地图或做复杂计算,反而会让每一次查询都变慢,代价远大于偶尔选错路径。
|
||||
|
||||
建议直接用参数算一个近似值,例如 `RectRange` 用 `w * h`、`ManhattanRange` 用与 `radius` 相关的二次式;不要为了追求精确而牺牲速度。内置范围都是常数时间的估算,可以参考它们的写法。
|
||||
|
||||
## 内置范围与参数
|
||||
|
||||
以下范围都从 `@motajs/common` 导出:
|
||||
@ -133,7 +145,7 @@ export class RingRange extends BaseRange<IRingRangeParam> {
|
||||
|
||||
三个实现各有分工:
|
||||
|
||||
- `estimatePointCount` 只服务 `autoDetect`,返回值偏大或偏小都只会让选型变差,不会改变命中结果;
|
||||
- `estimatePointCount` 只服务 `autoDetect`,返回值偏大或偏小都只会让选型变差,不会改变命中结果;实现时要尽量快,不要为了精确去遍历格子;
|
||||
- `iterateLoc` 决定「枚举时输出哪些格」,必须和 `inRange` 使用同一套判定;
|
||||
- `inRange` 决定「单个点算不算命中」,越界必须先由 `inBound` 收口,不能只比较距离。
|
||||
|
||||
@ -144,8 +156,12 @@ export class RingRange extends BaseRange<IRingRangeParam> {
|
||||
系统会自己调用 `bindHost`,你只需要把范围对象与参数传进去。以怪物上下文为例:
|
||||
|
||||
```ts
|
||||
// RingRange 即上一节定义的自定义范围类
|
||||
// 范围对象提到模块级复用;引擎风格不建议在调用处内联 new
|
||||
const ringRange = new RingRange();
|
||||
|
||||
const loc = state.hero.getLocation();
|
||||
const hits = state.enemyContext.scanRange(new RingRange(), {
|
||||
const hits = state.enemyContext.scanRange(ringRange, {
|
||||
cx: loc.x,
|
||||
cy: loc.y,
|
||||
radius: 2
|
||||
@ -190,11 +206,14 @@ function collectRing(
|
||||
`scanRange` 只返回范围内的怪物,所以验证要摆好怪物,而不是只看格子。先以勇士为中心,在中心、曼哈顿距离 `1`、距离 `2` 的位置各放一只测试怪,再执行:
|
||||
|
||||
```ts
|
||||
// 同样复用模块级范围实例,不要在调用处内联 new
|
||||
const ringRange = new RingRange();
|
||||
|
||||
state.enemyContext.bindHero(state.hero.getAttribute());
|
||||
|
||||
const loc = state.hero.getLocation();
|
||||
const hits = [
|
||||
...state.enemyContext.scanRange(new RingRange(), {
|
||||
...state.enemyContext.scanRange(ringRange, {
|
||||
cx: loc.x,
|
||||
cy: loc.y,
|
||||
radius: 2
|
||||
@ -235,7 +254,7 @@ console.log('环上格子数', cells.length); // 未被地图边缘裁剪时最
|
||||
|
||||
- **越界必须返回 `false`**:`inRange` 先调用 `inBound`,再比较距离。只比较距离会让地图外的坐标也命中。
|
||||
- **`iterateLoc` 与 `inRange` 必须同源**:两者判定不一致时,`scan` 会按范围枚举出一批格,再用另一套规则判断,结果就会漏格或多格。写完自定义范围后,用同一组参数分别跑 `iterateLoc` 与逐点 `inRange` 对照一次。
|
||||
- **`estimatePointCount` 只是估算**:它不参与命中判定,也不需要考虑地图裁剪。估得不准只会让 `autoDetect` 选错路径,不会改变结果。
|
||||
- **`estimatePointCount` 只是估算**:它不参与命中判定,也不需要考虑地图裁剪。估得不准只会让 `autoDetect` 选错路径,不会改变结果;但它每次查询都会执行,必须保持廉价,不要为了准确去遍历格子。
|
||||
- **负半径按绝对值处理**:这是本篇示例的约定;如果你希望负半径表示「无效」,要在 `inRange` 与 `iterateLoc` 里同时改,不能只改一处。
|
||||
- **绑定后的范围实例不要跨地图共用**:`bindHost` 是可变状态,同一个实例先后绑定不同尺寸的宿主时,后一次会覆盖前一次。需要并行处理多张地图时,每张地图各用一个实例,或在每次使用前重新绑定。
|
||||
- **`radius: 0` 是中心单点**:环形范围在半径为 0 时没有「边框」可枚举,按距离恰好为 0 处理,只命中中心。
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
装备也是道具(`ItemCategory.Equipment`),但它和钥匙、药水不是一套流程。道具表只提供装备的原始定义;玩家实际拥有的每一件装备都是一个独立实例,穿在哪一栏又由另一套接口管理。这套拆分让「同一件装备可以有多份、每份强化不同」成为可能。
|
||||
|
||||
本篇讲装备实例仓、实例修饰器、装备槽、存档约定、对比与排序。中级教程的[勇士装备](../../intermediate/hero-equipment.md)已经讲过常用调用,这里补齐实例、修饰器与读档恢复之间的关系。
|
||||
本篇讲装备实例仓、实例修饰器、装备槽、对比、排序与装备事件,最后说明修饰器与存档的内部约定。中级教程的[勇士装备](../../intermediate/hero-equipment.md)已经讲过常用调用,这里补齐实例、修饰器与读档恢复之间的关系。
|
||||
|
||||
## 装备与道具的区别
|
||||
|
||||
@ -25,7 +25,9 @@ export interface IItemEquipData<THero> {
|
||||
}
|
||||
```
|
||||
|
||||
`category` 为 `Equipment` 的道具才会走装备系统。`equip.value` 与 `equip.percentage` 只是新实例的初始值;`loadEvent` 与 `unloadEvent` 目前只是数据字段,引擎不会自动执行,详见下文「装备事件」。`animate` 是渲染端读取的动画 id,动画如何播放属于 `渲染端教程 · 装备动画`。 <!-- TODO(renderer): 装备动画 -->
|
||||
`category` 为 `Equipment` 的道具才会走装备系统。`equip.value` 与 `equip.percentage` 只是新实例的初始值。
|
||||
|
||||
`loadEvent` 与 `unloadEvent` **尚未实现**,详见下文「装备事件」。`animate` 是渲染端读取的动画 id,动画如何播放属于 `渲染端教程 · 装备动画`。 <!-- TODO(renderer): 装备动画 -->
|
||||
|
||||
装备需要实例,是因为「定义」与「持有」不是一回事:两把同样的剑可以分别强化到不同数值,卸下再穿上也要记住各自的状态。实例用自增的 `uid` 区分,装备槽只保存 `uid`。
|
||||
|
||||
@ -70,7 +72,7 @@ function grantEquipment(state: ICoreState, id: string): number {
|
||||
|
||||
- `setValue(name, value)` 与 `getValue(name)`:数值加成,对应 `ValueModifier`;
|
||||
- `setPercentage(name, value)` 与 `getPercentage(name)`:百分比加成,对应 `PercentageModifier`;
|
||||
- `getModifiers()`:输出 `[属性名, 修饰器]`;实例尚未构建时会先调用 `buildModifiers()` 兜底。
|
||||
- `getModifiers()`:用于获取该实例的全部装备修饰器。
|
||||
|
||||
把加成设为 `0` 会删除对应条目与修饰器,而不是留下一个值为 0 的修饰器;读取不存在的属性返回 `0`。
|
||||
|
||||
@ -88,16 +90,9 @@ function strengthenEquipment(state: ICoreState, uid: number): void {
|
||||
equip.setValue('atk', 5);
|
||||
equip.setPercentage('def', 0.1);
|
||||
|
||||
// 遍历该实例产生的全部修饰器
|
||||
// 获取该实例的全部装备修饰器
|
||||
for (const [name, modifier] of equip.getModifiers()) {
|
||||
console.log(
|
||||
'属性',
|
||||
name,
|
||||
'修饰器',
|
||||
modifier.type,
|
||||
'参数',
|
||||
modifier.value
|
||||
);
|
||||
console.log('属性', name, '修饰器', modifier);
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -125,7 +120,9 @@ function strengthenEquipment(state: ICoreState, uid: number): void {
|
||||
| `getEquipped(slot)` | 取指定索引槽上的 `uid` |
|
||||
| `getEquips()` | 按槽位顺序取所有已装备实例,空槽为 `null` |
|
||||
|
||||
`slot` 可以传索引或名称,但写法必须与装备定义里的 `slots` 一致:定义写 `[0]` 就传索引,定义写 `['weapon']` 就传名称。混用会在 `canEquipTo` 的第一道判定就被判成 `CannotEquip`。
|
||||
`slot` 可以传索引,也可以传名称。`hero.equip.slots` 本身是字符串数组,但 `slot` 参数同时接受 `number | string`:索引是最精确的寻址方式,名称只是粗略描述——多个槽同名时,传名称只能落到第一个可用槽,无法指定具体是哪一个。
|
||||
|
||||
装备定义里的 `equip.slots` 决定这件装备能装到哪些槽。`canEquipTo` 的第一道判定是 `equip.slots.includes(slot)`:定义里列了索引就传索引,列了名称就传名称;想让两种写法都可用,就把两种都列进去(如 `[0, 'weapon']`)。
|
||||
|
||||
`autoUnload` 只处理「同一件装备已经戴在另一个槽」的情况:为 `true` 时先从原槽卸下再穿到目标槽;为 `false` 时直接放弃这次穿戴。它不会替你处理目标槽上原有的装备——目标槽被占用时,`equip` 总会替换,并把旧装备的 `uid` 返回。
|
||||
|
||||
@ -158,24 +155,6 @@ function unequipSlot(state: ICoreState, index: number): number | undefined {
|
||||
|
||||
`getEquipped` 与 `unequip` 都只接受索引。按名称操作时,先用 `slots.indexOf(name)` 换成索引;`indexOf` 返回 `-1` 表示该名称不存在。
|
||||
|
||||
## 修饰器与存档的关键约定
|
||||
|
||||
装备修饰器以 `save = false` 加入勇士属性:
|
||||
|
||||
```ts
|
||||
this.attribute.addModifier(name, modifier, false);
|
||||
```
|
||||
|
||||
第三个参数表示不进属性存档。读档时 `HeroAttribute.loadState` 会清空所有修饰器,只重建存档里保存的那些;装备修饰器不在其中,因此读档后的勇士暂时没有任何装备加成。
|
||||
|
||||
恢复发生在 `HeroEquipment.loadState`:它先清空 `equips` 映射,再按存档逐个调用 `equip` 重新穿戴。重新穿戴会把实例的修饰器重新挂到属性上,装备加成就回来了。整个过程在 `replaySystem.disable()` 与 `revert()` 之间进行,不会被记进录像。
|
||||
|
||||
`HeroState.loadState` 的顺序保证这条路径可行:属性 → 位置 → 道具(装备实例仓)→ 装备槽。实例仓先按存档重建,装备槽随后才能按 `uid` 找到它们。
|
||||
|
||||
由此得到一条实践规则:装备加成只能由 `equip` 与 `unequip` 维护。绕过装备槽,直接把装备修饰器 `addModifier(name, modifier)` 到勇士属性上,读档后这些修饰器会被清掉;如果给它们 `save = true`,读档时属性会先恢复一份,重新穿戴又会再加一份,数值翻倍。需要长期存在的非装备修饰器,应该由拥有它的系统自己负责存档,参见[勇士属性](../../intermediate/hero-attribute.md)。
|
||||
|
||||
正确的顺序是:先 `add` 得到实例、改好实例数值,再 `equip` 穿戴。读档时不要手动重挂修饰器,`loadState` 已经处理。
|
||||
|
||||
## 装备对比
|
||||
|
||||
`compareEquip(equipA, equipB, slot)` 返回「装备 A 时属性减装备 B 时属性」的差值。`equipA` 与 `equipB` 传实例 `uid`,传 `-1` 表示该侧没有装备;`slot` 只接受槽位索引。
|
||||
@ -252,7 +231,7 @@ function useAtkSort(state: ICoreState): void {
|
||||
|
||||
## 装备事件
|
||||
|
||||
`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,但它监听的是**修饰器变化**,不是穿戴。`setValue`、`setPercentage`、`buildModifiers` 以及删除条目都会触发它;`equip` 与 `unequip` 本身不会触发。它适合做「装备强化后重算某个系统」这类联动:
|
||||
`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,但它监听的是**修饰器变化**,不是穿戴。`setValue`、`setPercentage` 以及删除条目都会触发它;`equip` 与 `unequip` 本身不会触发。它适合做「装备强化后重算某个系统」这类联动:
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
@ -264,7 +243,7 @@ function watchEquipment(state: ICoreState, uid: number): void {
|
||||
|
||||
const controller = equip.addHook({
|
||||
onChangeModifier(modifiers) {
|
||||
// 改变 setValue / setPercentage / buildModifiers 时触发
|
||||
// 改变 setValue / setPercentage 时触发
|
||||
// modifiers 是实时迭代器,不要保存到回调之外
|
||||
for (const [name, modifier] of modifiers) {
|
||||
console.log('装备属性变化', name, modifier.value);
|
||||
@ -277,13 +256,7 @@ function watchEquipment(state: ICoreState, uid: number): void {
|
||||
}
|
||||
```
|
||||
|
||||
`IItemRawData.equip.loadEvent` 与 `unloadEvent` 不是自动钩子。引擎不会在穿戴或卸下时执行它们,目前它们只是数据字段。**引擎也没有内置「装备穿上/卸下」钩子**;`onChangeModifier` 不能替代它:
|
||||
|
||||
- `HeroEquipsStore.add` 在创建实例后会立即调用 `buildModifiers()`,`loadState` 重建实例后也会调用。因此正常获得的实例在 `equip` 时已经构建完毕,`equip` 只是把已有修饰器挂到属性上,不会触发 `onChangeModifier`;
|
||||
- 只有绕过 `add`、直接 `new EquipmentState(...)` 且从未调用过 `getModifiers()` 时,`equip` 才可能顺带触发一次懒构建;那是兜底副作用,不是稳定的穿戴信号;
|
||||
- 强化或改值同样会触发它,即使没有穿戴。
|
||||
|
||||
需要「穿上后触发」的行为,就在调用 `equip` / `unequip` 的统一入口里处理,或者包装一个自己的装备服务:
|
||||
`IItemRawData.equip.loadEvent` 与 `unloadEvent` **尚未实现**:它们不是自动钩子,引擎不会在穿戴或卸下时执行它们,目前只是数据字段。**引擎也没有内置「装备穿上/卸下」钩子**,`onChangeModifier` 不能替代它。需要「穿上后触发」的行为,就在调用 `equip` / `unequip` 的统一入口里处理,或者包装一个自己的装备服务:
|
||||
|
||||
```ts
|
||||
import { ICoreState } from '@user/data-state';
|
||||
@ -301,7 +274,19 @@ function equipWithCallback(
|
||||
}
|
||||
```
|
||||
|
||||
`onChangeModifier` 在 `buildModifiers()` 时也会触发一次。经 `add` / `loadState` 得到的实例在建立时就已构建,所以之后才 `addHook` 的监听者收不到那次初始构建,只会收到后续变化;对这些实例,`equip` 不会触发再次构建。
|
||||
## 修饰器与存档的内部实现
|
||||
|
||||
此章节讲解一下装备修饰器与存档的内部实现。
|
||||
|
||||
装备修饰器以 `this.attribute.addModifier(name, modifier, false)` 加入勇士属性,第三个参数表示不进属性存档。读档时 `HeroAttribute.loadState` 会清空所有修饰器,只重建存档里保存的那些;装备修饰器不在其中,因此读档后的勇士暂时没有任何装备加成。
|
||||
|
||||
恢复发生在 `HeroEquipment.loadState`:它先清空 `equips` 映射,再按存档逐个调用 `equip` 重新穿戴。重新穿戴会把实例的修饰器重新挂到属性上,装备加成就回来了。整个过程在 `replaySystem.disable()` 与 `revert()` 之间进行,不会被记进录像。
|
||||
|
||||
`HeroState.loadState` 的顺序保证这条路径可行:属性 → 位置 → 道具(装备实例仓)→ 装备槽。实例仓先按存档重建,装备槽随后才能按 `uid` 找到它们。
|
||||
|
||||
由此得到一条实践规则:装备加成只能由 `equip` 与 `unequip` 维护。绕过装备槽,直接把装备修饰器 `addModifier(name, modifier)` 到勇士属性上,读档后这些修饰器会被清掉;如果给它们 `save = true`,读档时属性会先恢复一份,重新穿戴又会再加一份,数值翻倍。需要长期存在的非装备修饰器,应该由拥有它的系统自己负责存档,参见[勇士属性](../../intermediate/hero-attribute.md)。
|
||||
|
||||
正确的顺序是:先 `add` 得到实例、改好实例数值,再 `equip` 穿戴。读档时不要手动重挂修饰器,`loadState` 已经处理。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
@ -361,7 +346,7 @@ function verifyEquipment(state: ICoreState): void {
|
||||
## 边界与注意
|
||||
|
||||
- **`add` 失败返回 `-1`**:图块 id 不存在,或该图块没有道具定义。`add` 不检查 `category`,传非装备道具可能得到无法使用的实例;调用方应先确认它是装备。
|
||||
- **槽位写法必须与定义一致**:定义用索引就传索引,定义用名称就传名称。混用会在 `canEquipTo` 里直接返回 `CannotEquip`。
|
||||
- **索引最精确,名称是粗略描述**:`slot` 可以传索引或名称;同名槽只能靠索引区分。装备定义 `equip.slots` 决定可装备的槽位,`canEquipTo` 会先检查定义里是否包含你传入的写法。
|
||||
- **`autoUnload` 为 `false` 时不会搬移**:同一件装备已经戴在别的槽时,`equip` 会直接放弃,不做任何修改。
|
||||
- **`unequip` 只接受索引**:按名称卸下要先 `slots.indexOf(name)`;`-1` 不会卸下任何东西。
|
||||
- **不要删掉已穿戴的实例**:`delete(uid)` 只移除实例仓里的记录,不会解除装备槽的引用,也不会移除已经挂到属性上的修饰器。先 `unequip` 再 `delete`。
|
||||
@ -370,7 +355,7 @@ function verifyEquipment(state: ICoreState): void {
|
||||
- **`compareEquip` 两侧都为空时**返回 `{}` 并告警(code 195),不会抛错;`slot` 只接受索引。
|
||||
- **`compareEquip` 的基线是当前属性减去目标槽现役装备**:A 或 B 可以就是目标槽上的现役装备,也可以完全未装备;**不要**把它们穿在另一个槽上,否则修饰器会在基线里再算一次。缺失的一侧回退到当前真实最终属性,所以目标槽有装备时,`-1` 得到的是「相对现役装备」而不是「相对空槽」。
|
||||
- **排序器不影响装备槽**:它只决定 `instances()` 与 `instancesOf()` 的展示顺序;`getEquipped` 与 `getEquips` 永远按槽位顺序。
|
||||
- **`getModifiers()` 只在未构建时兜底构建**:正常经 `add` / `loadState` 得到的实例已经构建过,调用它不会再次触发 `onChangeModifier`;它返回的是实时迭代器,不要长期保存到回调之外。
|
||||
- **`getModifiers()` 返回实时迭代器**:不要把它保存到回调之外;需要留存时先转成数组。
|
||||
|
||||
编辑器与状态栏的展示不在本篇范围内。 <!-- TODO(editor): 装备栏与状态栏展示 -->
|
||||
|
||||
|
||||
@ -49,25 +49,26 @@
|
||||
|
||||
## 本层包含什么
|
||||
|
||||
本层一共九篇:这一页导览,加上八篇正文。
|
||||
本层一共十篇:这一页导览,加上九篇正文。
|
||||
|
||||
| 序 | 篇目 | 它能让你做什么 | 前置 |
|
||||
| --- | ----------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------- |
|
||||
| 1 | 高级 II 导览 | 看清定位、前置与阅读路径,决定先读哪篇 | 高级 I |
|
||||
| 2 | [自定义范围对象](./custom-range.md) | 写一个范围对象并绑定宿主,供光环、地图伤害与查询复用 | 高级 I 声明式接口;中级光环、地图伤害 |
|
||||
| 3 | [静态图块与动态图块](./static-vs-dynamic-tiles.md) | 在两种图块之间转换,并安排点事件与图块事件的归属 | 中级地图结构、地图图块 |
|
||||
| 4 | [勇士的装备系统](./equipment-system.md) | 管理装备实例、实例修饰器、装备槽、对比与排序 | 中级勇士装备、勇士属性;高级 I 可存档 |
|
||||
| 5 | [自定义录像指令](./custom-replay-command.md) | 从指令码、指令对象到注册与沙盒回放,新增一条录像指令 | 高级 I 录像一致性;中级录像指令 |
|
||||
| 6 | [自动寻路](./pathfinding.md) | 构建寻路图、求解路径、交给移动器,并处理瞬移与钩子 | 中级地图结构、图块事件 |
|
||||
| 7 | [新增勇士属性](./adding-hero-attribute.md) | 从接口、默认值到伤害计算,完整接入一个勇士属性 | 中级勇士属性;高级 I 可存档、录像一致性 |
|
||||
| 8 | [新增怪物属性](./adding-enemy-attribute.md) | 从接口、数据到伤害、光环、查询与最终效果,接入一个怪物属性 | 中级战斗各篇;本篇的勇士属性对照 |
|
||||
| 9 | [怪物上下文与战斗深入](./enemy-context-and-combat.md) | 理解 buildup 的装配顺序、刷新时机、伤害缓存与各机制的交互 | 中级光环、查询、最终属性、伤害计算 |
|
||||
| 3 | [静态图块与动态图块](./static-vs-dynamic-tiles.md) | 在两种图块之间转换与移动,理解 `keepEvent` 的取舍 | 中级地图结构、地图图块 |
|
||||
| 4 | [点事件与图块事件](./point-and-tile-events.md) | 区分两类事件的归属,安排它们跟随移动与存档恢复 | 静态图块与动态图块 |
|
||||
| 5 | [勇士的装备系统](./equipment-system.md) | 管理装备实例、实例修饰器、装备槽、对比与排序 | 中级勇士装备、勇士属性;高级 I 可存档 |
|
||||
| 6 | [自定义录像指令](./custom-replay-command.md) | 从指令码、指令对象到注册与沙盒回放,新增一条录像指令 | 高级 I 录像一致性;中级录像指令 |
|
||||
| 7 | [自动寻路](./pathfinding.md) | 构建寻路图、求解路径、交给移动器,并处理瞬移与钩子 | 中级地图结构、图块事件 |
|
||||
| 8 | [新增勇士属性](./adding-hero-attribute.md) | 从接口、默认值到伤害计算,完整接入一个勇士属性 | 中级勇士属性;高级 I 可存档、录像一致性 |
|
||||
| 9 | [新增怪物属性](./adding-enemy-attribute.md) | 从接口、数据到伤害、光环、查询与最终效果,接入一个怪物属性 | 中级战斗各篇;本篇的勇士属性对照 |
|
||||
| 10 | [怪物上下文与战斗深入](./enemy-context-and-combat.md) | 理解 buildup 的装配顺序、刷新时机、伤害缓存与各机制的交互 | 中级光环、查询、最终属性、伤害计算 |
|
||||
|
||||
## 阅读路径
|
||||
|
||||
按目标不同,可以走三条路径:
|
||||
|
||||
- **系统定制**:范围对象 → 静态图块与动态图块 → 勇士的装备系统 → 新增勇士属性 → 新增怪物属性 → 怪物上下文与战斗深入。适合改造一整套系统,例如给战斗加一层新的属性与范围规则。
|
||||
- **系统定制**:范围对象 → 静态图块与动态图块 → 点事件与图块事件 → 勇士的装备系统 → 新增勇士属性 → 新增怪物属性 → 怪物上下文与战斗深入。适合改造一整套系统,例如给战斗加一层新的属性与范围规则。
|
||||
- **行为扩展**:自定义录像指令 → 自动寻路。适合给游戏加一种新的可回放行为,或替换移动与选路策略。
|
||||
- **按需查阅**:每篇都单独成立,直接跳到解决当前问题的那一篇即可;篇末的「边界与注意」用于排查改动带来的副作用。
|
||||
|
||||
@ -116,6 +117,7 @@
|
||||
|
||||
- [自定义范围对象](./custom-range.md)
|
||||
- [静态图块与动态图块](./static-vs-dynamic-tiles.md)
|
||||
- [点事件与图块事件](./point-and-tile-events.md)
|
||||
- [勇士的装备系统](./equipment-system.md)
|
||||
- [自定义录像指令](./custom-replay-command.md)
|
||||
- [自动寻路](./pathfinding.md)
|
||||
|
||||
234
docs/advanced/ii/point-and-tile-events.md
Normal file
234
docs/advanced/ii/point-and-tile-events.md
Normal file
@ -0,0 +1,234 @@
|
||||
# 点事件与图块事件
|
||||
|
||||
地图上的事件有两种存放方式:一种挂在**坐标**上,称为点事件(point event);另一种挂在**图块实例**上,称为图块事件(tile event)。平时的读写差别不大,但图块一旦移动或转换,两者就会分道扬镳:点事件留在原坐标,图块事件跟着图块走。
|
||||
|
||||
本篇说明两者的区别、各自的读写接口、跟随移动与存档的行为,最后给出可执行的验证方法。图块的创建、转换与移动见[静态图块与动态图块](./static-vs-dynamic-tiles.md)。
|
||||
|
||||
## 两种事件的区别
|
||||
|
||||
| 项目 | 点事件 | 图块事件 |
|
||||
| ---------- | --------------------------------------------------------------------- | ------------------------------------ |
|
||||
| 归属 | 坐标 | 图块实例 |
|
||||
| 获取方式 | `layer.event(x, y)`、`layer.getPointEvent(x, y)`、`tile.pointEvent()` | `tile.tileEvent()` |
|
||||
| 换图块后 | 仍留在原坐标 | 由 `set(num)` 恢复为该图块的默认事件 |
|
||||
| 图块移动后 | 不跟随 | 跟随图块实例 |
|
||||
| 典型用途 | 「勇士踩进这一格」「进入这一区域」 | 「可推动的石头自带的事件」 |
|
||||
|
||||
点事件属于**坐标**。同一坐标换掉图块、清空图块,点事件都还在。`layer.event(x, y)` 按坐标取可写视图;坐标不在地图内时返回 `null`。`layer.getPointEvent(x, y)` 不是视图,它只是 `event(x, y)?.get()` 的简写,返回只读映射,不能用来修改事件。
|
||||
|
||||
图块事件属于**图块实例**。静态图块与动态图块各自持有一个事件视图,通过 `tile.tileEvent()` 读写。图块实例被销毁时,它的事件也随之消失;静态图块转动态、动态图块转静态时是否保留,由转换接口的 `keepEvent` 决定。
|
||||
|
||||
`tile.pointEvent()` 容易让人误会:它并不拥有事件,只是按图块**当前**的 `locator` 去取那个坐标的点事件视图。动态图块移动后,它读到的是新坐标的点事件,而不是出发地的事件。
|
||||
|
||||
## 跟随移动的区别
|
||||
|
||||
动态图块移动时,移动器只更新它的 `locator` 与图层索引,不会触碰事件:
|
||||
|
||||
- **点事件不跟随**:事件仍按原坐标缓存。图块离开后,原坐标的点事件还在;新坐标的点事件与它无关。`dynamic.pointEvent()` 只是按新坐标重新查一次视图,所以移动后通常读到空事件。
|
||||
- **图块事件跟随**:事件保存在图块实例自己身上,`locator` 变了,事件视图不变。静态转动态时 `keepEvent` 为 `true`(默认)会把源实例的事件复制过来;动态转静态时同理。
|
||||
|
||||
```ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
/**
|
||||
* 给坐标与图块各挂一个事件,再推动图块,观察两者是否跟随。
|
||||
* 假设 (x, y) 有静态图块,右侧一格为空。
|
||||
*/
|
||||
async function moveTileAndObserveEvents(
|
||||
layer: IMapLayer,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<void> {
|
||||
// 点事件挂在坐标上
|
||||
layer.event(x, y)?.set(1, 'point-event');
|
||||
|
||||
const source = layer.getTile(x, y);
|
||||
if (!source) return;
|
||||
|
||||
// 图块事件挂在图块实例上
|
||||
source.tileEvent().set(10, 'tile-event');
|
||||
|
||||
// keepEvent 默认为 true,转换时把图块事件复制到动态实例
|
||||
const dynamic = source.toDynamic();
|
||||
const controller = dynamic.step(FaceDirection.Right);
|
||||
await controller?.onEnd;
|
||||
|
||||
// 图块事件跟着实例走到新坐标
|
||||
console.log('移动后图块事件', dynamic.tileEvent().get(10)); // tile-event
|
||||
// 点事件留在原坐标
|
||||
console.log('原坐标点事件', layer.getPointEvent(x, y)?.get(1)); // point-event
|
||||
// dynamic.pointEvent() 按当前坐标取视图,新坐标没有点事件
|
||||
console.log('新坐标点事件', dynamic.pointEvent()?.get(1)); // undefined
|
||||
}
|
||||
```
|
||||
|
||||
注意 `keepEvent` 为 `false` 时,静态图块的事件不会被复制,但源静态图块实例本身也不会被清空。这个不对称行为在[静态图块与动态图块](./static-vs-dynamic-tiles.md)的「双向转换」里有详细说明。
|
||||
|
||||
## 事件视图接口
|
||||
|
||||
点事件与图块事件共用同一套视图。只读接口是 `IReadonlyEventView`:
|
||||
|
||||
| 成员 | 作用 |
|
||||
| --------- | -------------------------------------------------------------- |
|
||||
| `get()` | 取当前事件映射,键是优先级,值是 `IGameEventStore` 中的事件 id |
|
||||
| `ref()` | 取参考基准映射 |
|
||||
| `dirty()` | 当前事件是否与参考基准不同 |
|
||||
|
||||
可写接口 `ILayerEventView` 在此基础上增加:
|
||||
|
||||
| 成员 | 作用 |
|
||||
| ---------------------- | -------------------------------------------------- |
|
||||
| `set(priority, event)` | 设置指定优先级的事件;同优先级已有事件时覆盖并告警 |
|
||||
| `delete(priority)` | 删除指定优先级的事件 |
|
||||
| `reset()` | 把当前事件恢复为参考基准 |
|
||||
| `clear()` | 清空当前事件 |
|
||||
| `markPure()` | 把当前事件标记为新的参考基准 |
|
||||
|
||||
`set` 的 `event` 参数是**事件 id**,不是事件对象。这个 id 必须已经在 `state.eventStore` 中注册过(`state.eventStore.addEvent(id, event)`);否则执行到这里时找不到对应事件。同一个优先级只能挂一个事件,重复设置会覆盖并告警 code 136。
|
||||
|
||||
`ref()` 与 `markPure()` 是样板内部的优化接口:前者暴露参考基准,后者把当前状态设为新的基准,存档压缩与脏标记靠它们工作。一般不要调用,除非你清楚自己在做什么。日常读写只需要 `get`、`set`、`delete`、`clear`。
|
||||
|
||||
## 设置与复制事件
|
||||
|
||||
给坐标设置点事件:
|
||||
|
||||
```ts
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
/**
|
||||
* 给坐标挂点事件;换图块不会影响它。
|
||||
* eventId 必须已经在 state.eventStore 中注册。
|
||||
*/
|
||||
function setPointEvent(
|
||||
layer: IMapLayer,
|
||||
x: number,
|
||||
y: number,
|
||||
eventId: string
|
||||
): void {
|
||||
const view = layer.event(x, y);
|
||||
if (!view) return;
|
||||
|
||||
// 优先级 5;同一优先级已有事件时覆盖并告警
|
||||
view.set(5, eventId);
|
||||
|
||||
// 换掉这一格的图块,点事件仍属于坐标 (x, y)
|
||||
layer.setBlock(21, x, y);
|
||||
console.log(layer.getPointEvent(x, y)?.get(5)); // eventId
|
||||
}
|
||||
```
|
||||
|
||||
给图块设置图块事件:
|
||||
|
||||
```ts
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
/**
|
||||
* 给静态图块挂图块事件。
|
||||
* 图块事件属于实例;转换时是否复制由 keepEvent 决定。
|
||||
*/
|
||||
function setTileEvent(
|
||||
layer: IMapLayer,
|
||||
x: number,
|
||||
y: number,
|
||||
eventId: string
|
||||
): void {
|
||||
const tile = layer.getTile(x, y);
|
||||
if (!tile) return;
|
||||
|
||||
// 优先级 10;set(num) 会把它重置为该图块的默认事件
|
||||
tile.tileEvent().set(10, eventId);
|
||||
console.log(tile.tileEvent().get(10)); // eventId
|
||||
}
|
||||
```
|
||||
|
||||
把一个图块的事件整体复制到另一个图块:
|
||||
|
||||
```ts
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
/**
|
||||
* 复制图块事件;目标原有的图块事件会被全部清空。
|
||||
* 这里只复制图块事件,点事件不会跟着复制。
|
||||
*/
|
||||
function copyTileEvents(
|
||||
layer: IMapLayer,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number
|
||||
): void {
|
||||
const from = layer.getTile(fromX, fromY);
|
||||
const to = layer.getTile(toX, toY);
|
||||
if (!from || !to) return;
|
||||
|
||||
// 先清空目标,再逐条写入来源的优先级
|
||||
to.syncTileEvent(from);
|
||||
}
|
||||
```
|
||||
|
||||
只想追加一条事件时,直接 `tileEvent().set(priority, eventId)`,不要用 `syncTileEvent`,后者会覆盖目标上其它优先级的事件。
|
||||
|
||||
## 存档与读档
|
||||
|
||||
两类事件都由图层自动保存,作者不需要额外注册存档对象:
|
||||
|
||||
- **点事件**:图层只保存 `dirty()` 为 `true` 的坐标。地图数据自带的点事件在加载地图时已经写进参考基准(并调用 `markPure()`),所以存档里只记录运行时新增或修改的差量。读档时先把已有点事件视图 `reset()` 回基准,再按存档 `clear()` 并逐条 `set()`。
|
||||
- **图块事件**:静态图块只在 `shouldSave()` 为真(即事件偏离默认基准)时写入存档;动态图块总会保存图块数字,事件偏离基准时一并写入。读档时静态/动态图块先 `restoreDefaultEvents()`,再应用存档里的事件。如果存档带自定义事件,读回后 `dirty()` 仍为 `true`,因为应用事件后没有重新 `markPure()`;只有事件与默认基准一致的图块才是干净的。
|
||||
|
||||
事件 id 本身不在存档里展开,存档只记录 id 字符串。因此读档时这些 id 必须仍然能在 `state.eventStore` 中找到;注册名称发生变化,旧存档就会指向不存在的事件。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
下面的函数给同一格设置点事件与图块事件,存档、清空、读档,确认两者都能恢复。它假设 `(x, y)` 有静态图块,且两个事件 id 都已注册。
|
||||
|
||||
```ts
|
||||
import { SaveCompression } from '@user/data-common';
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
/**
|
||||
* 验证点事件与图块事件都能随图层存档恢复。
|
||||
* 假设 (x, y) 有静态图块,两个事件 id 都已注册。
|
||||
*/
|
||||
function verifyEventPersistence(layer: IMapLayer, x: number, y: number): void {
|
||||
// 点事件挂在坐标上,图块事件挂在实例上
|
||||
layer.event(x, y)?.set(1, 'point-event');
|
||||
const tile = layer.getTile(x, y);
|
||||
if (!tile) return;
|
||||
tile.tileEvent().set(10, 'tile-event');
|
||||
|
||||
const snapshot = layer.saveState(SaveCompression.NoCompression);
|
||||
|
||||
// 先清空,确认读档确实把事件写回来
|
||||
layer.event(x, y)?.clear();
|
||||
tile.tileEvent().clear();
|
||||
|
||||
layer.loadState(snapshot, SaveCompression.NoCompression);
|
||||
console.log('读档后点事件', layer.getPointEvent(x, y)?.get(1)); // point-event
|
||||
console.log(
|
||||
'读档后图块事件',
|
||||
layer.getTile(x, y)?.tileEvent().get(10) // tile-event
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
预期现象:
|
||||
|
||||
- 读档后原坐标的点事件恢复为 `point-event`;
|
||||
- 同一格静态图块的图块事件恢复为 `tile-event`;
|
||||
- 如果只设置图块事件、没有设置点事件,那么读档后 `getPointEvent(x, y)?.get(1)` 应为 `undefined`,这是正常的——两者互不替代。
|
||||
|
||||
要观察移动造成的差异,用前面 `moveTileAndObserveEvents` 的例子:图块事件会跟到新坐标,点事件留在原坐标。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- **点事件与图块事件不是同一张表**:同一格可以同时有优先级 `5` 的点事件与优先级 `5` 的图块事件,两者互不冲突;冲突只发生在同一个事件视图内部。
|
||||
- **`getPointEvent` 返回只读映射**:它不能修改事件;要写入请用 `layer.event(x, y)` 或 `tile.pointEvent()`。
|
||||
- **`set(num)` 会重置图块事件**:静态图块与动态图块的 `set(num)` 都会调用 `restoreDefaultEvents()`。想保留自定义事件,应在 `set` 之后再挂,或用 `syncTileEvent` 从别的实例复制。
|
||||
- **`layer.setBlock` 不会重置图块事件**:它只改图块数字;同一坐标缓存着的静态图块实例会继续持有旧事件。要重置事件,用 `tile.set(num)` 或 `tile.restoreDefaultEvents()`。
|
||||
- **`syncTileEvent` 会覆盖目标**:它先清空目标再复制,目标原有的图块事件不会保留。
|
||||
- **动态图块移动后 `pointEvent()` 读的是新坐标**:想确认出发地的事件,直接用 `layer.getPointEvent(oldX, oldY)`,不要用移动后的图块去反查。
|
||||
- **事件 id 必须已注册**:`set` 不会替你检查 `eventStore`,缺注册时会在执行阶段找不到事件;读档也依赖同一个 id 仍然存在。
|
||||
- **`markPure()` 与 `ref()` 是内部接口**:日常读写不要调用;只有实现存档压缩、脏标记一类的底层逻辑时才需要关心它们。
|
||||
|
||||
返回[高级 II 导览](./index.md)。
|
||||
@ -2,15 +2,26 @@
|
||||
|
||||
图层上的图块有两种形态。静态图块是地图数组里的一格,位置固定,读写便宜;动态图块是独立实例,有自己的移动器,可以在一格一格之间移动。地图上看到的石头、箱子、可推动的障碍,通常先以静态图块存在,被推动时转成动态图块,停下后再转回静态。
|
||||
|
||||
本篇说明两者的分工、创建与获取方式、双向转换、事件归属与操作接口,最后给出可执行的验证方法。静态图块的批量读写见[地图图块](../../intermediate/map-tiles.md),图层与楼层的结构见[地图结构](../../intermediate/map-structure.md)。
|
||||
本篇说明两者的分工、创建与获取方式、双向转换,以及事件在转换中的归属,最后给出可执行的验证方法。静态图块的批量读写见[地图图块](../../intermediate/map-tiles.md),图层与楼层的结构见[地图结构](../../intermediate/map-structure.md),两类事件的专门说明见[点事件与图块事件](./point-and-tile-events.md)。
|
||||
|
||||
## 两种图块的分工
|
||||
|
||||
**静态图块**(`IStaticTile`)对应图层数组中的一格。它的位置由坐标决定,`num()` 直接读图层当前格子的图块数字:
|
||||
|
||||
```ts
|
||||
num(): number {
|
||||
return this.layer.getBlock(this.locator.x, this.locator.y);
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
// 这里只保留与本节相关的实现,其余成员从略
|
||||
class StaticTile {
|
||||
constructor(
|
||||
readonly locator: { x: number; y: number },
|
||||
readonly layer: IMapLayer
|
||||
) {}
|
||||
|
||||
// 静态图块没有独立的位置:位置就是它所在的坐标
|
||||
num(): number {
|
||||
return this.layer.getBlock(this.locator.x, this.locator.y);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -147,114 +158,17 @@ async function shiftRight(
|
||||
- 动态图块被转回静态,或被 `delete()` 删除时,会触发图层的 `onDeleteDynamic` 钩子。`delete()` 返回 `Promise<void>`,会等待钩子完成;`toStatic` 只触发钩子,不等待。
|
||||
- `createDynamic` 与 `transferToDynamic` 都会触发 `onCreateDynamic`;`updateDynamicTile` 会触发 `onUpdateDynamicPosition`,移动器在每步落点更新位置时会调用它。
|
||||
|
||||
## 事件的两类归属
|
||||
## 图块事件与点事件
|
||||
|
||||
图块相关的事件分两类,归属完全不同。混淆这两类,是「图块移走了,事件却没跟着走」或「事件跟着图块跑到了不该去的地方」的常见原因。
|
||||
图块相关的事件有两种归属:点事件属于**坐标**,图块事件属于**图块实例**。图块移动时,图块事件跟着实例走,点事件留在原坐标;静态图块转动态、动态图块转静态时,`keepEvent` 决定图块事件是否复制到新实例。两者都可以由作者设置,也都会随存档保存和恢复。
|
||||
|
||||
| 项目 | 点事件(point event) | 图块事件(tile event) |
|
||||
| ---------- | --------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| 归属 | 坐标 | 图块实例 |
|
||||
| 获取方式 | `layer.event(x, y)`、`layer.getPointEvent(x, y)`、`tile.pointEvent()` | `tile.tileEvent()` |
|
||||
| 换图块后 | 仍留在原坐标 | 随实例转换,是否复制由 `keepEvent` 决定 |
|
||||
| 图块移动后 | 不跟随 | 跟随图块实例 |
|
||||
| 典型用途 | 「勇士踩进这一格」「进入这一区域」 | 「这块可推动的石头被推开后,事件仍跟着它」 |
|
||||
事件视图的 `markPure()` 与 `ref()` 是样板内部的优化接口,用于维护参考基准与脏标记,一般不要调用,除非你清楚自己在做什么。
|
||||
|
||||
点事件属于**坐标**。同一坐标换掉图块、清空图块,点事件都还在;动态图块移动到新坐标后,它原来的点事件留在旧坐标。`tile.pointEvent()` 只是按图块**当前**的 `locator` 去取那个坐标的点事件视图,所以动态图块移动后,它读到的是新坐标的点事件,不是出发地的事件。
|
||||
|
||||
图块事件属于**图块实例**。静态图块转动态、动态图块转静态时,`keepEvent` 为 `true` 就把源实例的图块事件复制到目标实例;为 `false` 则目标实例按图块定义重建默认事件。复制是「清空目标后逐条写入」,不会与目标原有事件合并。
|
||||
|
||||
## 事件视图接口
|
||||
|
||||
点事件与图块事件都用同一套事件视图。取可写视图用 `layer.event(x, y)`(返回 `ILayerEventView | null`)或 `tile.tileEvent()`;`layer.getPointEvent(x, y)` 不是视图,它只是 `event(x, y)?.get()` 的简写,返回 `ReadonlyMap<number, string> | null`,不能用来修改点事件。只读接口是 `IReadonlyEventView`:
|
||||
|
||||
| 成员 | 作用 |
|
||||
| --------- | -------------------------------------------------------------- |
|
||||
| `get()` | 取当前事件映射,键是优先级,值是 `IGameEventStore` 中的事件 id |
|
||||
| `ref()` | 取参考基准映射 |
|
||||
| `dirty()` | 当前事件是否与参考基准不同 |
|
||||
|
||||
可写接口 `ILayerEventView` 在此基础上增加:
|
||||
|
||||
| 成员 | 作用 |
|
||||
| ---------------------- | -------------------------------------------------- |
|
||||
| `set(priority, event)` | 设置指定优先级的事件;同优先级已有事件时覆盖并告警 |
|
||||
| `delete(priority)` | 删除指定优先级的事件 |
|
||||
| `reset()` | 把当前事件恢复为参考基准 |
|
||||
| `clear()` | 清空当前事件 |
|
||||
| `markPure()` | 把当前事件标记为新的参考基准 |
|
||||
|
||||
`markPure()` 建立「干净基准」:此后 `dirty()` 只反映相对这一刻的变化。静态图块与动态图块在构造、重置默认事件后都会调用它,所以新建的图块默认是干净的。读档则不一定:`loadState` 先 `restoreDefaultEvents()`,再按存档 `clear()` 并逐条 `set()`,后者不会重新调用 `markPure()`;因此存档里带自定义图块事件时,读回后 `dirty()` 仍为 `true`,只有没有自定义事件的图块才是干净的。存档时只需要保存 `dirty()` 为 `true` 的图块事件;如果事件与基准一致,就没有必要写入存档。
|
||||
|
||||
`set` 的 `event` 参数是**事件 id**,不是事件对象。这个 id 必须已经在 `state.eventStore` 中注册过(`state.eventStore.addEvent(id, event)`);否则执行到这里时找不到对应事件。同一个优先级只能挂一个事件,重复设置会覆盖并告警 code 136。
|
||||
|
||||
## 在代码里操作事件
|
||||
|
||||
### 给静态图块添加并复制图块事件
|
||||
|
||||
```ts
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
function giveTileEvent(
|
||||
layer: IMapLayer,
|
||||
x: number,
|
||||
y: number,
|
||||
eventId: string
|
||||
): void {
|
||||
const tile = layer.getTile(x, y);
|
||||
if (!tile) return;
|
||||
|
||||
// 优先级 10 上挂一个事件;同一个优先级已有事件时会被覆盖
|
||||
tile.tileEvent().set(10, eventId);
|
||||
}
|
||||
|
||||
function copyTileEvent(
|
||||
layer: IMapLayer,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number
|
||||
): void {
|
||||
const from = layer.getTile(fromX, fromY);
|
||||
const to = layer.getTile(toX, toY);
|
||||
if (!from || !to) return;
|
||||
|
||||
// syncTileEvent 会先清空目标,再逐条复制来源的全部图块事件
|
||||
to.syncTileEvent(from);
|
||||
}
|
||||
```
|
||||
|
||||
`syncTileEvent` 复制的是**图块事件**,不是点事件。它会把目标实例现有的图块事件全部清掉,再按来源的优先级逐条写入;如果目标上原本有来源没有的优先级,那些事件会消失。
|
||||
|
||||
### 给坐标设置点事件并验证它不随图块移动
|
||||
|
||||
```ts
|
||||
import { IMapLayer } from '@user/data-base';
|
||||
|
||||
function keepPointEvent(
|
||||
layer: IMapLayer,
|
||||
x: number,
|
||||
y: number,
|
||||
eventId: string
|
||||
): void {
|
||||
const view = layer.event(x, y);
|
||||
if (!view) return;
|
||||
|
||||
// 优先级 5 上挂一个点事件
|
||||
view.set(5, eventId);
|
||||
|
||||
// 换掉这一格的图块,点事件仍然属于坐标 (x, y)
|
||||
layer.setBlock(21, x, y);
|
||||
|
||||
// 读到的仍是刚才设置的事件
|
||||
console.log(layer.getPointEvent(x, y)?.get(5));
|
||||
}
|
||||
```
|
||||
|
||||
如果要把图块本身移走,用 `transferToDynamic` 加 `mover`;图块事件会跟着动态实例走,点事件则留在原坐标。两类事件可以同时存在于同一格,互不覆盖,因为它们存储在不同的视图里。
|
||||
两类事件的区别、读写接口与存档行为,见[点事件与图块事件](./point-and-tile-events.md)。
|
||||
|
||||
## 怎么验证
|
||||
|
||||
下面的函数把「静态 → 动态 → 移动 → 静态」与两类事件放在一次检查里。它假设目标格右侧为空,并且事件 id 已经注册。
|
||||
下面的函数检查「静态 → 动态 → 移动 → 静态」的完整往返。它假设目标格右侧为空。
|
||||
|
||||
```ts
|
||||
import { FaceDirection } from '@user/data-common';
|
||||
@ -267,10 +181,8 @@ async function verifyTileConversion(
|
||||
): Promise<void> {
|
||||
const source = layer.getTile(x, y);
|
||||
if (!source) return;
|
||||
|
||||
// 先挂自定义图块事件与点事件,便于观察转换后的归属
|
||||
source.tileEvent().set(10, 'demo-event');
|
||||
layer.event(x, y)?.set(1, 'demo-point-event');
|
||||
// 记录转换前的图块数字,最后与落点对比
|
||||
const num = source.num();
|
||||
|
||||
const dynamic = source.toDynamic();
|
||||
const controller = dynamic.step(FaceDirection.Right);
|
||||
@ -278,9 +190,7 @@ async function verifyTileConversion(
|
||||
|
||||
const restored = dynamic.toStaticIfSafe();
|
||||
console.log('原位置图块数字', layer.getBlock(x, y)); // 0
|
||||
console.log('落点图块数字', restored?.num()); // 与 source 相同
|
||||
console.log('落点图块事件', restored?.tileEvent().get(10)); // demo-event
|
||||
console.log('原点事件', layer.getPointEvent(x, y)?.get(1)); // demo-point-event
|
||||
console.log('落点图块数字', restored?.num()); // 与 num 相同
|
||||
}
|
||||
```
|
||||
|
||||
@ -288,19 +198,14 @@ async function verifyTileConversion(
|
||||
|
||||
- 原位置的静态图块数字变成 `0`;
|
||||
- 落点出现同数字的静态图块;
|
||||
- 落点图块的图块事件里仍有 `demo-event`,因为 `keepEvent` 默认为 `true`;
|
||||
- 原坐标的点事件里仍有 `demo-point-event`,点事件没有跟着图块移动。
|
||||
- 如果落点已有静态图块,`toStaticIfSafe` 会返回 `null`,落点不会变化,动态图块仍停在落点。想看到完整结果,先把落点清空,或换一个空格测试。
|
||||
|
||||
如果落点已有静态图块,`toStaticIfSafe` 会返回 `null`,落点不会变化,动态图块仍停在落点。想看到完整结果,先把落点清空,或换一个空格测试。
|
||||
图块事件在转换中的复制结果由 `keepEvent` 决定,具体验证见[点事件与图块事件](./point-and-tile-events.md)。
|
||||
|
||||
## 边界与注意
|
||||
|
||||
- **静态图块的 `set()` 会重置默认事件**:`set(num)` 把图块事件恢复为该图块定义的默认事件。想保留自定义事件,应在 `set` 之后再挂,或使用 `syncTileEvent` 从别的实例复制。
|
||||
- **动态图块不写回图层数组**:图层数组只保存静态图块数字。判断某一格有没有动态图块要用 `getDynamicTilesAt` / `iterateDynamicTiles`,不要只读 `getBlock`。
|
||||
- **`keepEvent` 为 `false` 时事件不是「丢了」**:目标图块会回到该图块定义的默认事件,这是设计行为,不是转换失败。需要保留自定义事件就显式传 `true`,并在转换前确认源实例的事件已经设置好。
|
||||
- **`layer.setBlock` 不会重置图块事件**:它只改图块数字;同一坐标缓存着的静态图块实例会继续持有旧事件。要重置事件,用 `tile.set(num)` 或 `tile.restoreDefaultEvents()`,不要以为换了数字事件就自动干净。
|
||||
- **同一格多个动态图块的顺序不保证**:`getDynamicTilesAt` 返回的是集合语义的迭代器;依赖先后顺序时,请先取出数组并自行排序。
|
||||
- **`syncTileEvent` 会覆盖目标**:它先清空目标再复制,目标原有的图块事件不会保留。只想追加一条事件时,用 `tileEvent().set(priority, eventId)`,不要用 `syncTileEvent`。
|
||||
- **点事件与图块事件共用优先级概念但不是同一张表**:同一格可以同时有优先级 `5` 的点事件与优先级 `5` 的图块事件,两者互不冲突;冲突只发生在同一个事件视图内部。
|
||||
|
||||
返回[高级 II 导览](./index.md)。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user