diff --git a/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md b/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md index eb0ea07..9b87f12 100644 --- a/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md @@ -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` 构建通过,无死链告警。 diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 48bde40..e193fee 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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' diff --git a/docs/advanced/ii/custom-range.md b/docs/advanced/ii/custom-range.md index 05976f9..8b21d20 100644 --- a/docs/advanced/ii/custom-range.md +++ b/docs/advanced/ii/custom-range.md @@ -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 { 三个实现各有分工: -- `estimatePointCount` 只服务 `autoDetect`,返回值偏大或偏小都只会让选型变差,不会改变命中结果; +- `estimatePointCount` 只服务 `autoDetect`,返回值偏大或偏小都只会让选型变差,不会改变命中结果;实现时要尽量快,不要为了精确去遍历格子; - `iterateLoc` 决定「枚举时输出哪些格」,必须和 `inRange` 使用同一套判定; - `inRange` 决定「单个点算不算命中」,越界必须先由 `inBound` 收口,不能只比较距离。 @@ -144,8 +156,12 @@ export class RingRange extends BaseRange { 系统会自己调用 `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 处理,只命中中心。 diff --git a/docs/advanced/ii/equipment-system.md b/docs/advanced/ii/equipment-system.md index deb9ef5..a383f70 100644 --- a/docs/advanced/ii/equipment-system.md +++ b/docs/advanced/ii/equipment-system.md @@ -2,7 +2,7 @@ 装备也是道具(`ItemCategory.Equipment`),但它和钥匙、药水不是一套流程。道具表只提供装备的原始定义;玩家实际拥有的每一件装备都是一个独立实例,穿在哪一栏又由另一套接口管理。这套拆分让「同一件装备可以有多份、每份强化不同」成为可能。 -本篇讲装备实例仓、实例修饰器、装备槽、存档约定、对比与排序。中级教程的[勇士装备](../../intermediate/hero-equipment.md)已经讲过常用调用,这里补齐实例、修饰器与读档恢复之间的关系。 +本篇讲装备实例仓、实例修饰器、装备槽、对比、排序与装备事件,最后说明修饰器与存档的内部约定。中级教程的[勇士装备](../../intermediate/hero-equipment.md)已经讲过常用调用,这里补齐实例、修饰器与读档恢复之间的关系。 ## 装备与道具的区别 @@ -25,7 +25,9 @@ export interface IItemEquipData { } ``` -`category` 为 `Equipment` 的道具才会走装备系统。`equip.value` 与 `equip.percentage` 只是新实例的初始值;`loadEvent` 与 `unloadEvent` 目前只是数据字段,引擎不会自动执行,详见下文「装备事件」。`animate` 是渲染端读取的动画 id,动画如何播放属于 `渲染端教程 · 装备动画`。 +`category` 为 `Equipment` 的道具才会走装备系统。`equip.value` 与 `equip.percentage` 只是新实例的初始值。 + +`loadEvent` 与 `unloadEvent` **尚未实现**,详见下文「装备事件」。`animate` 是渲染端读取的动画 id,动画如何播放属于 `渲染端教程 · 装备动画`。 装备需要实例,是因为「定义」与「持有」不是一回事:两把同样的剑可以分别强化到不同数值,卸下再穿上也要记住各自的状态。实例用自增的 `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()` 返回实时迭代器**:不要把它保存到回调之外;需要留存时先转成数组。 编辑器与状态栏的展示不在本篇范围内。 diff --git a/docs/advanced/ii/index.md b/docs/advanced/ii/index.md index a8c02c2..a46bf12 100644 --- a/docs/advanced/ii/index.md +++ b/docs/advanced/ii/index.md @@ -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) diff --git a/docs/advanced/ii/point-and-tile-events.md b/docs/advanced/ii/point-and-tile-events.md new file mode 100644 index 0000000..67e5872 --- /dev/null +++ b/docs/advanced/ii/point-and-tile-events.md @@ -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 { + // 点事件挂在坐标上 + 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)。 diff --git a/docs/advanced/ii/static-vs-dynamic-tiles.md b/docs/advanced/ii/static-vs-dynamic-tiles.md index 2d1c74f..5233e7b 100644 --- a/docs/advanced/ii/static-vs-dynamic-tiles.md +++ b/docs/advanced/ii/static-vs-dynamic-tiles.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`,会等待钩子完成;`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 | 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 { 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)。