From 2d25a32660440560ca529e0473fb3da895941ce5 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 21 Oct 2026 20:09:28 +0800 Subject: [PATCH] =?UTF-8?q?docs(10-26):=20=E4=BF=AE=E5=A4=8D=E9=AB=98?= =?UTF-8?q?=E7=BA=A7=20II=20=E8=AF=BB=E8=80=85=E9=98=BB=E6=96=AD=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=88=E7=AC=AC1=E8=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../10-26-READER-REVIEW.md | 73 ++++++ docs/advanced/ii/adding-enemy-attribute.md | 101 ++++++-- docs/advanced/ii/adding-hero-attribute.md | 243 +++++++++++++++--- docs/advanced/ii/custom-range.md | 42 ++- docs/advanced/ii/custom-replay-command.md | 117 +++++++-- docs/advanced/ii/enemy-context-and-combat.md | 32 ++- docs/advanced/ii/equipment-system.md | 59 +++-- docs/advanced/ii/index.md | 26 +- docs/advanced/ii/pathfinding.md | 28 +- docs/advanced/ii/static-vs-dynamic-tiles.md | 14 +- 10 files changed, 610 insertions(+), 125 deletions(-) create mode 100644 .planning/phases/10-docs-restructure/10-26-READER-REVIEW.md diff --git a/.planning/phases/10-docs-restructure/10-26-READER-REVIEW.md b/.planning/phases/10-docs-restructure/10-26-READER-REVIEW.md new file mode 100644 index 0000000..bfa106a --- /dev/null +++ b/.planning/phases/10-docs-restructure/10-26-READER-REVIEW.md @@ -0,0 +1,73 @@ +# 高级 II 读者审阅 + +## 读者人格(D-68,逐字) + +> 你已经理解了绝大多数常用语法;认真阅读本样板后,你能把一部分简单系统和中等复杂度的系统读明白。你正在读高级 II,想做更深入的定制,但对具体系统实现还不熟悉。 + +**明确未复用高级 I / 中级人格。** + +## 第 1 轮 · 问题清单 + +### 阻断性(12) + +| # | 问题 | 定位 | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| B-01 | 验证预期写成「只输出与勇士曼哈顿距离恰好为 2 的格子,最多 8 格」,但 `scanRange` 只返回范围内的怪物视图;照此无法复现,并会误解 `scanRange` 为枚举空格 | `custom-range.md`「怎么验证」 | +| B-02 | 「静态转动态」称无论是否保留、源静态图块的图块事件都会被清空;`keepEvent=false` 时实际不清空,之后 `setBlock` 可能继承旧事件 | `static-vs-dynamic-tiles.md`「双向转换」 | +| B-03 | 验证代码顺序是 equip → unequip → save → load,注释却写「存档 → 卸下 → 读档」并预期加成恢复;按代码读档后不会有加成 | `equipment-system.md`「怎么验证」 | +| B-04 | 建议用 `onChangeModifier` 做「穿上后触发」;它不是穿戴钩子(强化/改值也触发,无修饰器时穿上不触发) | `equipment-system.md`「装备事件」 | +| B-05 | 被动录像只说「调用 `next(status)`」,未说明它是 `getPassive()` 返回的 `IReplayPassiveHandler` 上的方法,也未给出被动步的记录/排入链路 | `custom-replay-command.md`「主动/被动」「边界」 | +| B-06 | 「新的寻路调用也可以接管当前移动」与「上一次移动未结束时返回 null」矛盾;实际需先等 `onEnd` 或 `interrupt()` | `pathfinding.md`「两种移动方式」「边界」 | +| B-07 | `Dir4FaceHandler` 从 `@motajs/common` 导入,实际应来自 `@user/data-common`;示例无法编译 | `pathfinding.md`「完整流程 → 绑定」 | +| B-08 | 护甲插入点未说清;片段放在 `damage = (turn-1)*enemyPerDamage` 之后,却称保留吸血/支援/先攻/破甲/魔防等分支;照抄会丢分支,顺序也不确定 | `adding-hero-attribute.md`「接入伤害计算」 | +| B-09 | 无来源全局光环写成 `implements IEnemyAuraView<...>`;`IEnemyAuraView` 还要求 `enemy`/`special`/`locator`,应实现 `IAuraView`(需核对源码) | `adding-enemy-attribute.md`「接入光环」、`enemy-context-and-combat.md`「完整收束示例」 | +| B-10 | 称 `setFloor` 会把上下文标记为需要更新;实际只改 `floor`、不置 `needUpdate`,切层后只调 `setFloor` 不会重建 | `enemy-context-and-combat.md`「上下文是什么」 | +| B-11 | 优先级语义不符:常规查询按特殊属性 code 逐个执行、优先级只在同一 code 内排序;删除同/更高优先级光环的实际表现也不是「被拒绝」 | `enemy-context-and-combat.md`「优先级的边界」 | +| B-12 | 旧存档缺新键只说「使用前给默认值」,未给补键位置或迁移入口;内容配置不会与默认值合并,照做可能读到 `undefined` | `adding-hero-attribute.md` / `adding-enemy-attribute.md`「边界与注意」 | + +### 非阻断(19) + +- `static-vs-dynamic-tiles`:`layer.getPointEvent` 返回只读 Map 而非可写视图;`toStatic` 移动中返回 `null` 需先判 `mover.moving`。 +- `custom-replay-command`:`finalize?()` 返回值未写;`reseter.reset(save)` 的 `save` 来源;指令对象跨回放残留状态;指令码切 `Uint16` 的入口与兼容。 +- `pathfinding`:`finder.find(...)` 与 `pathfinding.getPath(...)` 关系未说明;瞬移钩子的注册调用位置;`onAfterTeleportTo` 参数个数。 +- `equipment-system`:`instancesOf`/`count` 参数是 num 还是 id;多件装备百分比是相加还是相乘。 +- `adding-hero-attribute`:`HERO_DEFAULT_ATTRIBUTE` 注释与实际生效时机矛盾;`IItemEquipData.value/percentage` 的 `Record` 字面量可能类型不通过。 +- `adding-enemy-attribute`:查询示例未说明如何让怪物拥有该 code;验证中「把 armor 设为 50」缺少 `setAttribute` + `markDirty/requestRefresh`。 +- `enemy-context-and-combat`:`clear()` 会清空 `globalAuraList`;`buildupSpecials` 无光环时提前返回导致只注册查询也不执行;基础光环范围包含自身时会改自己。 +- `index.md`:高级 III 占位表述;「优先插件,必要时改内置包」与本层多篇直接改 `packages-user` 的张力。 +- `custom-range`:`radius` 需为整数,否则 `iterateLoc` 产生非整数索引。 + +## 迭代记录 + +### 第 1 轮修复(2026-10-21) + +对照当前源码逐条修复,构建通过。 + +**阻断性** + +- B-01 `custom-range.md`:明确 `scanRange` 只筛选上下文中已存在的怪物视图,不枚举空格;验证改为在中心、距离 `1`、距离 `2` 摆测试怪,并补充 `iterateLoc` 检查范围格子。 +- B-02 `static-vs-dynamic-tiles.md`:改为只有 `keepEvent=true` 才清空源静态图块的图块事件;`false` 时源实例保留旧事件。补充 `layer.setBlock` 不重置事件、`StaticTile.set` 才重置。 +- B-03 `equipment-system.md`:验证顺序改为 equip → save → unequip → load,注释与预期同步。 +- B-04 同篇:说明 `onChangeModifier` 不是穿戴钩子,也不保证在 `equip` 时触发;给出在 `equip` / `unequip` 入口统一处理的写法。 +- B-05 `custom-replay-command.md`:补充 `IReplayPassiveHandler.next(status)` 的归属、record → `getPassive()` → `next()` 链路,以及被动指令类。 +- B-06 `pathfinding.md`:改为移动中 `moveTo` / `teleportTo` 返回 `null`,不会接管;需 `await controller.onEnd` 或 `state.pathfinding.interrupt()`。 +- B-07 同篇:`Dir4FaceHandler` 改从 `@user/data-common` 导入。 +- B-08 `adding-hero-attribute.md`:换成保留吸血、支援、先攻、破甲、反击、净化、魔防、固伤、仇恨、自爆的完整计算器;说明攻速插入点与护甲相对 mdef / 固伤 / 仇恨 / 自爆的顺序。 +- B-09 `adding-enemy-attribute.md`、`enemy-context-and-combat.md`:无来源全局光环改为实现 `IAuraView`;说明 `IEnemyAuraView` 额外要求 `enemy` / `special` / `locator`。 +- B-10 `enemy-context-and-combat.md`:改为 `setFloor` 只改 `floor`、不置 `needUpdate`;补充切层必须配合 `resize` 或 `bindHero` 等置脏动作。 +- B-11 同篇:区分特殊光环 / 特殊查询的全局优先级与常规查询的同 code 排序;更正删除语义为「特殊属性先删,光环可能留到本轮」。 +- B-12 `adding-hero-attribute.md`、`adding-enemy-attribute.md`:增加在 `state.loadState` 前迁移 `@system/hero` / `@system/enemy` 的入口;强调内容配置不与默认值合并。 + +**非阻断** + +- `static-vs-dynamic-tiles.md`:`getPointEvent` 返回只读 Map 而非可写视图;`toStatic` 移动检查的现状与使用建议。 +- `custom-replay-command.md`:`finalize` 返回类型、`reset(save)` 的 `save` 来源、指令对象跨回放残留状态、`setCommandWidth` 入口与兼容。 +- `pathfinding.md`:`finder.find` 与 `getPath` 的区别、钩子注册位置、`onAfterTeleportTo` 第二参数。 +- `equipment-system.md`:`instancesOf` / `count` 接受图块数字或 id;多件装备百分比为相加。 +- `adding-hero-attribute.md`:`HERO_DEFAULT_ATTRIBUTE` 的实际语义;`Record` 字面量的类型限制。 +- `adding-enemy-attribute.md`:怪物如何拥有查询 code;验证中的 `setAttribute` + `markDirty` + `requestRefresh`。 +- `enemy-context-and-combat.md`:`clear()` 清空 `globalAuraList`;`buildupSpecials` 无光环时提前返回;光环可命中自身。 +- `index.md`:高级 III 占位表述;优先插件与本层直接改 `packages-user` 的边界。 +- `custom-range.md`:`radius` 必须为整数,示例统一截断。 + +**验证**:prettier 格式化通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 通过(仅 VitePress chunk 体积警告)。 diff --git a/docs/advanced/ii/adding-enemy-attribute.md b/docs/advanced/ii/adding-enemy-attribute.md index 6d760e3..0e19263 100644 --- a/docs/advanced/ii/adding-enemy-attribute.md +++ b/docs/advanced/ii/adding-enemy-attribute.md @@ -100,6 +100,38 @@ export interface IEnemyAttr { 内容对象不会与默认模板合并,缺键就是缺键。读取到 `undefined` 后,`armor / 100` 与 `atkSpeed / 100` 都会变成 `NaN`,伤害与回合数随之失真。新增属性后,最好全局搜索一遍 `"attribute"`,确认每个模板入口都补齐。 +### 旧存档迁移 + +新游戏补 `enemy.jsonc` 与默认模板即可;旧存档不会自动补键。`state.loadState(save, compression)` 读取 `@system/enemy` 时,会把每个脏模板的 `attrs` 原样交给怪物模板,缺失的键不会从内容配置或默认模板回填。迁移点在调用 `loadState` 之前: + +```ts +import { IEnemyAttr, SaveCompression } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; + +/** 在 state.loadState 之前补齐旧档里怪物模板缺失的属性键。 */ +function migrateEnemySave(save: Map): void { + const enemy = save.get('@system/enemy') as + | { modified?: Map }> } + | undefined; + if (!enemy?.modified) return; + + // modified 的键是怪物 code,值是该模板的属性与特殊属性存档 + for (const enemyState of enemy.modified.values()) { + enemyState.attrs ??= {}; + enemyState.attrs.atkSpeed ??= 100; + enemyState.attrs.armor ??= 0; + } +} + +/** 读档入口:先迁移,再交给数据端统一读档。 */ +function loadEnemySave(state: ICoreState, save: Map): void { + migrateEnemySave(save); + state.loadState(save, SaveCompression.NoCompression); +} +``` + +只补 `@system/enemy` 还不够:勇士属性也可能从旧档读入,两个存档键都要按各自的新字段做一次迁移。勇士侧的写法见[新增勇士属性](./adding-hero-attribute.md)的「旧存档迁移」。 + ## 读取与修改 怪物对象有两套写入口,取决于你拿到的是可写对象还是视图: @@ -202,22 +234,22 @@ export class EnemyAttributeCalculator implements IDamageCalculator< ## 接入光环 -光环视图 `IEnemyAuraView` 继承自 `IAuraView`,它的 `apply(handler, baseEnemy)` 在基础属性阶段执行,适合给范围内的怪物加护甲或攻速。`handler.enemy` 是计算后怪物,`baseEnemy` 是未修改的原始怪物:读原始值用后者,写结果用前者。 +光环的公共接口是 `IAuraView`,它的 `apply(handler, baseEnemy)` 在基础属性阶段执行,适合给范围内的怪物加护甲或攻速。`handler.enemy` 是计算后怪物,`baseEnemy` 是未修改的原始怪物:读原始值用后者,写结果用前者。 -下面是一条全场 +10 护甲的基础光环。转换器与视图的完整写法见[怪物光环](../../intermediate/enemy-aura.md),这里只保留视图本身: +`IEnemyAuraView` 是在 `IAuraView` 之上多出 `enemy` / `special` / `locator` 的派生接口,专门表示「由某只怪物的某个特殊属性转换出来的光环」。**无来源的全局光环不是 `IEnemyAuraView`**:它没有来源怪物,只实现 `IAuraView`,再用 `state.enemyContext.addAura(...)` 注册。转换器与特殊光环的完整写法见[怪物光环](../../intermediate/enemy-aura.md),这里只保留无来源全局光环的视图本身: ```ts import { FullRange, IRange } from '@motajs/common'; import { IEnemyAttr, IHeroAttr } from '@user/data-common'; import { IReadonlyEnemy } from '@user/data-base'; import { - IEnemyAuraView, + IAuraView, IEnemyHandler, IEnemySpecialModifier } from '@user/data-system'; -/** 给范围内所有怪物增加 10 点护甲的基础光环。 */ -export class ArmorAura implements IEnemyAuraView { +/** 给范围内所有怪物增加 10 点护甲的无来源全局光环。 */ +export class ArmorAura implements IAuraView { readonly priority = 10; readonly couldApplyBase = true; readonly couldApplySpecial = false; @@ -243,8 +275,13 @@ export class ArmorAura implements IEnemyAuraView { return null; } } + +// 初始化阶段注册一次;同一个实例会一直留在 globalAuraList 里 +state.enemyContext.addAura(new ArmorAura()); ``` +`addAura` 接受的就是 `IAuraView`。如果你写成 `implements IEnemyAuraView`,编译期会要求补 `enemy`、`special`、`locator` 三个字段,而无来源光环根本拿不出这些值。 + 光环修改的是计算后怪物,因此不需要你自己标脏:`buildup()` 结束时会统一清空伤害缓存;局部刷新也会在 `refreshEnemy` 末尾处理。**不要在光环里改 `baseEnemy`**,那会污染原始模板,并让后续重建从被改过的值出发。 ## 接入查询 @@ -303,6 +340,8 @@ export class PackAtkSpeedQuery implements IEnemyCommonQueryEffect< state.enemyContext.registerCommonQueryEffect(40, new PackAtkSpeedQuery()); ``` +注册只决定「拥有 code 40 的怪物会执行哪条查询」,**不会让任何怪物自动拥有 40**。常规查询的执行条件是怪物身上有对应特殊属性:在 `src/content/enemy.jsonc` 的 `special` 里写 `"40": ...`,或在运行时用 `enemy.addSpecial(...)` 添加。只注册效果、不给怪物 code,查询一次也不会触发。 + 查询效果需要 `registerCommonQueryEffect` 注册;引擎的 `src/data/enemy/initialize.ts` **没有注册任何查询实现**,不注册就不会执行。如果要增删或改写目标拥有的特殊属性,应使用特殊查询 `IEnemySpecialQueryEffect`,写法见[怪物查询](../../intermediate/enemy-query.md),本篇不重复。 ## 接入最终效果 @@ -348,38 +387,66 @@ state.enemyContext.registerFinalEffect(new ArmorFinalEffect()); ## 怎么验证 -固定一只怪物与勇士属性,避免光环、支援或随机因素干扰。先把怪物 `armor` 设为 `50`、`atkSpeed` 设为 `100`,打印伤害;再把 `armor` 改为 `0`,确认伤害接近翻倍: +固定一只怪物与勇士属性,避免光环、支援或随机因素干扰。模板护甲不能直接写在 `getComputedEnemy()` 上;要先改可修改怪物,再 `markDirty()` + `requestRefresh()`,最后重新取伤害: ```ts import { IEnemyAttr } from '@user/data-common'; import { ICoreState } from '@user/data-state'; import { IEnemyView } from '@user/data-system'; -/** 打印指定怪物的护甲、攻速与伤害信息。 */ +/** 修改模板属性并立即刷新这只怪物。 */ +function setEnemyAttribute( + state: ICoreState, + view: IEnemyView, + name: 'armor' | 'atkSpeed', + value: number +): void { + // getModifiableEnemy() 返回模板;改完不会自动同步 + view.getModifiableEnemy().setAttribute(name, value); + + // 标脏后申请刷新,伤害缓存也会随之失效 + view.markDirty(); + state.enemyContext.requestRefresh(view); +} + +/** 对比 50 护甲与 0 护甲下的伤害。 */ function verifyEnemyAttribute( state: ICoreState, view: IEnemyView ): void { - const enemy = view.getComputedEnemy(); - console.log('护甲', enemy.getAttribute('armor')); - console.log('攻速', enemy.getAttribute('atkSpeed')); - const damageSystem = state.enemyContext.getDamageSystem(); if (!damageSystem) return; - const info = damageSystem.getDamageInfo(view); - if (!info) return; + setEnemyAttribute(state, view, 'armor', 50); + const highArmor = view.getComputedEnemy(); + const high = damageSystem.getDamageInfo(view); + console.log( + '计算后护甲', + highArmor.getAttribute('armor'), + '伤害', + high?.damage, + '回合', + high?.turn + ); - console.log('伤害', info.damage, '回合', info.turn); + setEnemyAttribute(state, view, 'armor', 0); + const low = damageSystem.getDamageInfo(view); + console.log('0 护甲伤害', low?.damage, '回合', low?.turn); + + setEnemyAttribute(state, view, 'atkSpeed', 120); + const fast = damageSystem.getDamageInfo(view); + console.log('120 攻速伤害', fast?.damage, '回合', fast?.turn); } ``` 预期现象: - `armor` 从 `50` 改为 `0` 后,勇士每回合伤害恢复,回合数减半,总伤害大致翻倍; -- 给该怪物加一条 +10 护甲的光环后,`armor` 变为 `60`,伤害再次下降; +- 计算后护甲可能高于模板值(光环或最终效果会继续叠加),所以打印的是 `getComputedEnemy()` 的结果; - `atkSpeed` 从 `100` 提到 `120` 后,怪物每回合伤害提高 20%,总伤害随之上升。 +如果第二次读取仍是旧伤害,检查是否漏了 `markDirty()` 或 `requestRefresh()`:只改模板不会让伤害缓存失效。 + 若最终值是 `NaN`,先检查接口、默认模板与内容条目是否都补了键;若光环加了护甲但伤害不变,确认 `buildup()` 是否已经执行,以及是否误改了原始怪物而没有标脏。 ## 边界与注意 @@ -387,9 +454,9 @@ function verifyEnemyAttribute( - **接口、默认模板、内容条目都要补键**:三处缺一不可。内容对象不会与默认模板合并,缺键就是 `undefined`。 - **`guard` 不要写进内容**:它是运行时由支援光环维护的集合,手填会与运行时行为冲突。 - **百分数统一用整数存储**:`20` 表示 20%,计算时再除以 `100`;不要一处存 `0.2`、另一处存 `20`。 -- **新增字段会进入怪物存档**:读旧档时缺失的键不会自动补齐,需要在使用前给默认值。 +- **新增字段会进入怪物存档**:读旧档时缺失的键不会自动补齐,需要按上文「旧存档迁移」在 `state.loadState` 之前给默认值。 - **光环、查询、最终效果改的都是计算后怪物**:刷新前不要把它当原始数据;也不要在这三个阶段改 `getBaseEnemy()`。 -- **直接改原始怪物要标脏**:`getModifiableEnemy()` 的修改不会自动同步,必须 `markDirty()` 后重新读取。 +- **直接改原始怪物要标脏**:`getModifiableEnemy()` 的修改不会自动同步,必须 `markDirty()` 后调用 `state.enemyContext.requestRefresh(view)`,再重新读取计算后怪物与伤害。 - **护甲要夹在有效区间**:大于 `100` 会让怪物无法被击败,负数会放大伤害;在计算器入口统一夹一次。 - **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。 - **编辑器与图鉴尚未接入**:在对应端实现之前,不要假设属性会自动出现在界面上。 diff --git a/docs/advanced/ii/adding-hero-attribute.md b/docs/advanced/ii/adding-hero-attribute.md index 6431cf0..6de3642 100644 --- a/docs/advanced/ii/adding-hero-attribute.md +++ b/docs/advanced/ii/adding-hero-attribute.md @@ -59,10 +59,14 @@ export interface IHeroAttr { ## 补默认值与初始值 -`HERO_DEFAULT_ATTRIBUTE` 是属性对象的完整形状。接口增加字段后,这里必须同步补齐: +`HERO_DEFAULT_ATTRIBUTE` 是属性对象的完整形状。接口增加字段后,这里必须同步补齐,否则 `IHeroAttr` 的类型检查会报缺键: ```ts -/** 勇士的初始属性,数值填多少目前都无所谓,因为最终会从旧样板读取,但是必须得填 */ +/** + * 勇士属性的完整默认形状。 + * 注释里的「最终会从旧样板读取」是历史说法:当前运行时不会读取这个常量, + * 它只用于保证接口字段在类型层面齐全;真正的新游戏初始值来自 core.jsonc。 + */ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = { name: '', level: 0, @@ -82,7 +86,7 @@ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = { }; ``` -实际进入游戏的初始值来自内容配置 `src/content/core.jsonc` 的 `init.hero.attribute`,由 `state.hero.createAttribute(...)` 直接交给 `HeroAttribute` 构造。它不会与默认值合并,缺键就是缺键: +实际进入游戏的初始值来自内容配置 `src/content/core.jsonc` 的 `init.hero.attribute`,由 `src/data/initialize.ts` 的 `afterLoad` 调用 `state.hero.createAttribute(coreConfig.init.hero.attribute)`,直接交给 `HeroAttribute` 构造。它不会与默认值合并,缺键就是缺键;`HERO_DEFAULT_ATTRIBUTE` 不会在运行时兜底: ```jsonc "attribute": { @@ -105,6 +109,39 @@ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = { `core.jsonc` 允许注释,但这两项不是可选项。若只在接口与默认值里加、忘了内容配置,`getBaseAttribute('atkSpeed')` 会返回 `undefined`,`atkSpeed / 100` 随之变成 `NaN`,伤害与回合数都会失真。 +### 旧存档迁移 + +新游戏只需要改 `core.jsonc`;旧存档不会和它合并。`state.loadState(save, compression)` 会把 `@system/hero` 里的 `attribute.values` 原样交给 `HeroAttribute.loadState`,缺的键不会被补。迁移点在调用 `loadState` 之前,直接改存档映射: + +```ts +import { IHeroAttr, SaveCompression } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; + +/** 在 state.loadState 之前补齐旧档缺失的勇士属性键。 */ +function migrateHeroSave(save: Map): void { + const hero = save.get('@system/hero') as + | { attribute?: { values?: Partial } } + | undefined; + const values = hero?.attribute?.values; + if (!values) return; + + // 旧档没有这两个键;补成与新游戏一致的默认值 + values.atkSpeed ??= 100; + values.armor ??= 0; +} + +/** 读档入口:先迁移,再交给数据端统一读档。 */ +function loadWithMigration( + state: ICoreState, + save: Map +): void { + migrateHeroSave(save); + state.loadState(save, SaveCompression.NoCompression); +} +``` + +也可以给 `'@system/hero'` 绑定一个 `ISaveableExecutor`,在 `afterLoad` 里用 `state.hero.getModifiableAttribute().set('armor', 0)` 补值。但 `afterLoad` 发生在属性读档之后,已经算过一轮最终值,补完还要依赖 `set` 触发重算;直接在 `loadState` 前改映射更干净。 + ## 读取与修饰 属性对象有两套读取方式: @@ -150,7 +187,7 @@ function addTemporaryAtkSpeed(state: ICoreState): void { - **攻速影响「每回合伤害」**。先算 `atk - monDef`,再乘 `atkSpeed / 100`,最后用缩放后的每回合伤害计算回合数。 - **护甲影响「最终伤害」**。所有回合伤害汇总后,再乘 `1 - armor / 100`,并把护甲夹在 `0` 到 `100` 之间,避免负伤害或反向治疗。 -顺序不能反:攻速要在算回合数之前生效,护甲要在最终伤害上生效。下面保留与这两处改动相关的分支,其余特殊属性逻辑按原版保留: +顺序不能反:攻速要在算回合数之前生效,护甲要在最终伤害上生效。下面给出的是**完整**的 `MainDamageCalculator`,吸血、支援、先攻、破甲、反击、净化、魔防、负伤归零、固伤、仇恨、自爆全部保留,只插入攻速与护甲两处: ```ts import { IEnemyAttr, IHeroAttr } from '@user/data-common'; @@ -160,46 +197,164 @@ import { IEnemyDamageInfoBase, IReadonlyEnemyHandler } from '@user/data-system'; +import { ISpecial9Value } from './special'; /** 带攻速与护甲的勇士伤害计算器。 */ export class MainDamageCalculator implements IDamageCalculator< IEnemyAttr, IHeroAttr > { + /** 当前是否正在计算支援怪的伤害 */ + private inGuard: boolean = false; + calculate( handler: IReadonlyEnemyHandler ): IEnemyDamageInfoBase { - const { enemy, hero } = handler; + const { enemy, locator, hero } = handler; + const hp = hero.getBaseAttribute('hp'); const atk = hero.getFinalAttribute('atk'); const def = hero.getFinalAttribute('def'); + // 支援中魔防只会被计算一次 + const mdef = this.inGuard ? 0 : hero.getFinalAttribute('mdef'); const monAtk = enemy.getAttribute('atk'); const monDef = enemy.getAttribute('def'); - const monHp = enemy.getAttribute('hp'); + let monHp = enemy.getAttribute('hp'); - // [需改] 攻速缩放勇士的每回合伤害,必须发生在计算回合数之前 + // 无敌 + if ( + enemy.hasSpecial(18) && + handler.state.hero.items.itemCount('cross') < 1 + ) { + return { damage: Infinity, turn: 0 }; + } + + let damage = 0; + let heroPerDamage = 0; + let enemyPerDamage = 0; + + // [需改] 攻速缩放勇士每回合伤害。 + // 插入点就是原来的 `heroPerDamage += atk - monDef`: + // 必须在 `turn = Math.ceil(monHp / heroPerDamage)` 之前, + // 也要在下面的 `<= 0` 早退之前,否则攻速为 0 时会除出 Infinity。 const atkSpeed = hero.getFinalAttribute('atkSpeed'); - const heroPerDamage = ((atk - monDef) * atkSpeed) / 100; + heroPerDamage += ((atk - monDef) * atkSpeed) / 100; if (heroPerDamage <= 0) { return { damage: Infinity, turn: 0 }; } - // 回合数随每回合伤害同步变化 - const turn = Math.ceil(monHp / heroPerDamage); + // 吸血 + const vampire = enemy.getSpecial(9); + if (vampire) { + const value = (vampire.value.vampire / 100) * hp; + damage += value; + // 如果吸血加到自身 + if (vampire.value.add) { + monHp += value; + } + } - let enemyPerDamage = monAtk - def; + // 魔攻 + if (enemy.hasSpecial(2)) { + enemyPerDamage = monAtk; + } else { + enemyPerDamage = monAtk - def; + } + + // 连击 + const multiHit = enemy.getSpecial(4); + if (multiHit) { + enemyPerDamage *= multiHit.value; + } if (enemyPerDamage < 0) enemyPerDamage = 0; - // 此处保留原版的吸血、支援、先攻、破甲、反击、魔防等分支 - let damage = (turn - 1) * enemyPerDamage; + // 回合数必须使用缩放后的每回合伤害 + let turn = Math.ceil(monHp / heroPerDamage); - // [需改] 护甲是最终减伤,先夹到 0 到 100,再换算成 0 到 1 的系数 + // 支援 + const guards = enemy.getAttribute('guard'); + if (guards.size > 0 && !this.inGuard) { + this.inGuard = true; + // 打支援怪时当前怪物也会打你,因此回合数与伤害都要累加 + for (const view of guards) { + const extraInfo = this.calculate({ + enemy: view.getComputedEnemy(), + context: handler.context, + locator, + hero, + state: handler.state + }); + turn += extraInfo.turn; + damage += extraInfo.damage; + } + this.inGuard = false; + } + + // 先攻 + if (enemy.hasSpecial(1)) { + damage += enemyPerDamage; + } + + // 破甲 + const breakArmor = enemy.getSpecial(5); + if (breakArmor) { + damage += (breakArmor.value / 100) * def; + } + + // 反击 + const counterAttack = enemy.getSpecial(6); + if (counterAttack) { + // 反击是每回合生效,因此加到 enemyPerDamage 上 + enemyPerDamage += (counterAttack.value / 100) * atk; + } + + // 净化 + const purify = enemy.getSpecial(7); + if (purify) { + damage += purify.value * mdef; + } + + damage += (turn - 1) * enemyPerDamage; + + // 魔防 + damage -= mdef; + + // 未开启负伤时,如果伤害为负,则设为 0 + if ( + !handler.state.flags.getFieldValueDefaults( + 'enableNegativeDamage', + false + ) && + damage < 0 + ) { + damage = 0; + } + + // 固伤,无法被魔防减伤 + const fixedDamage = enemy.getSpecial(20); + if (fixedDamage) { + damage += fixedDamage.value; + } + + // 仇恨,无法被魔防减伤 + if (enemy.hasSpecial(15)) { + damage += handler.state.flags.getFieldValueDefaults('hatred', 0); + } + + // [需改] 护甲按最终伤害百分比减伤。 + // 放在魔防、负伤归零、固伤、仇恨之后,因此这些部分都会被护甲缩放; + // 放在自爆之前,自爆仍然把伤害抬到 hp - 1,保持「必定重创」的语义。 + // 如果你希望固伤/仇恨绕过护甲,把这段移到固伤之前。 const armor = Math.min( Math.max(hero.getFinalAttribute('armor'), 0), 100 ); damage *= 1 - armor / 100; - // 此处保留固伤、仇恨、自爆等分支,它们按原版顺序排在护甲减伤之后 + // 自爆 + if (enemy.hasSpecial(17)) { + damage = Math.max(damage, hp - 1); + } + return { damage: Math.floor(damage), turn }; } @@ -207,37 +362,57 @@ export class MainDamageCalculator implements IDamageCalculator< handler: IReadonlyEnemyHandler, attribute: CriticalableHeroStatus ): number { + switch (attribute) { + case 'atk': { + if (handler.enemy.hasSpecial(3)) { + return Infinity; + } + return ( + handler.enemy.getAttribute('def') + + handler.enemy.getAttribute('hp') + ); + } + } return handler.hero.getFinalAttribute(attribute); } } ``` -原来的 `MainDamageCalculator` 还有支援、先攻、破甲、反击、净化、固伤、自爆等分支;它们的相对位置不变,只是每回合伤害与最终伤害各多了一次缩放。改动时不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲等一次性伤害会漏掉护甲。 +两处插入点的含义: + +- **攻速**:替换的是「每回合伤害」的初值,位置在吸血、连击、支援之前,但仍在 `turn` 计算与 `<= 0` 早退之前。不能等 `turn` 算完再乘——那样回合数不会变,只是把总伤害整体缩小。 +- **护甲**:放在所有伤害分量汇总之后,缩放的是最终伤害。魔防已经先减掉,固伤与仇恨随后加上,所以它们也会被护甲缩放;自爆在其后把下限抬到 `hp - 1`,因此自爆不受护甲影响。不要把护甲乘进 `enemyPerDamage`:那样只减免每回合伤害,先攻、破甲、净化、固伤等一次性伤害会漏掉护甲。 `getCriticalLimit` 也要跟着新属性考虑。它决定临界计算的搜索上界;自定义属性若参与伤害,至少要让读取该属性的方式与计算器一致,避免界面显示一个值、战斗按另一个值结算。 ## 与装备和道具的关系 -装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,装备定义就能直接写: +装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,内容层的装备定义就能直接写(`category: 4` 即 `ItemCategory.Equipment`): -```ts -import { IHeroAttr, IItemEquipData } from '@user/data-common'; - -/** 一件提供 +15% 攻速的武器。 */ -export const attackSpeedEquip: IItemEquipData = { - slots: ['weapon'], - // 动画 id 由内容层定义,这里换成你自己的装备动画 - animate: 'equip', - // 数值加成:攻速只走百分比,不额外加固定值 - value: { atkSpeed: 0 }, - // 百分比加成:沿用装备系统的约定,0.15 表示 15% - percentage: { atkSpeed: 0.15 }, - // 这两个字段目前只是数据,引擎不会自动执行 - loadEvent: null, - unloadEvent: null -}; +```jsonc +{ + "num": 120, + "category": 4, + "name": "疾风剑", + "text": "攻击速度 +15%", + "hideInToolbox": false, + "equip": { + "slots": ["weapon"], + // 动画 id 由内容层定义,这里换成你自己的装备动画 + "animate": "equip", + // 数值加成:攻速只走百分比,不额外加固定值 + "value": {}, + // 百分比加成:沿用装备系统的约定,0.15 表示 15% + "percentage": { "atkSpeed": 0.15 }, + // 这两个字段目前只是数据,引擎不会自动执行 + "loadEvent": null, + "unloadEvent": null + } +} ``` +内容层是 JSONC,不会经过 TypeScript 的 `Record` 检查。如果你要在 TS 里手写 `IItemEquipData` 对象字面量,注意 `value` 与 `percentage` 的类型是 `Record, number>`,它会要求补齐**全部**数值属性键;只写 `{ atkSpeed: 0.15 }` 无法通过类型检查。最省事的做法仍然是写在 `src/content/item.jsonc` 里。 + 装备实例会把这些记录转成 `ValueModifier` 与 `PercentageModifier`,穿戴时挂到勇士属性,卸下时移除。基础攻速 `100` 配上 `percentage.atkSpeed = 0.15`,最终攻速就是 `115`。注意属性本身按百分数存储(`15` 表示 15%),而装备的 `percentage` 是倍率(`0.15` 表示 15%),两者写法不同。 道具效果若要直接改攻速,也可以调用 `state.hero.getModifiableAttribute().add('atkSpeed', value)` 或挂修饰器;道具效果属于内容层,这里不额外展开。 @@ -295,7 +470,7 @@ function verifyHeroAttribute( - **内容配置不会与默认值合并**:`createAttribute` 直接使用传入对象,缺键就是 `undefined`;不要指望默认值兜底。 - **百分数属性统一用百分数存储**:`15` 表示 15%,计算时再除以 `100`。装备的 `percentage` 则是倍率,`0.15` 表示 15%。 - **不要把新属性塞进既有汇总字段**:例如不要把攻速加进 `money` 或 `exp` 的统计逻辑,读这些字段的系统并不知道新属性的语义。 -- **新增字段会影响存档结构**:读旧档时缺失的键不会自动补齐,需要在使用前给默认值;写入新档后,旧版本读到多出的键通常可忽略,但不能假设旧版本认识它。 +- **新增字段会影响存档结构**:读旧档时缺失的键不会自动补齐。迁移入口是在调用 `state.loadState(save, compression)` 之前修改 `@system/hero` 的 `attribute.values`(见上文「旧存档迁移」),不要指望 `core.jsonc` 或 `HERO_DEFAULT_ATTRIBUTE` 兜底;写入新档后,旧版本读到多出的键通常可忽略,但不能假设旧版本认识它。 - **`save = true` 的修饰器要注册类型**:读档按 `type` 查构造器,未注册的修饰器会被静默跳过,数值看起来「读档后消失了」。 - **护甲要夹在有效区间**:大于 `100` 会变成治疗,负数会放大伤害;在计算器入口统一夹一次。 - **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。 diff --git a/docs/advanced/ii/custom-range.md b/docs/advanced/ii/custom-range.md index 1759606..05976f9 100644 --- a/docs/advanced/ii/custom-range.md +++ b/docs/advanced/ii/custom-range.md @@ -93,7 +93,8 @@ export interface IRingRangeParam { export class RingRange extends BaseRange { // autoDetect 用这个估算决定走 iterate 还是 scan,不要求精确 protected estimatePointCount(param: Readonly): number { - const radius = Math.abs(param.radius); + // 半径必须是整数;这里统一截断,避免非整数半径产生非整数索引 + const radius = Math.abs(Math.trunc(param.radius)); // 半径 0 退化为单点;否则曼哈顿距离恰为 radius 的格子有 4 * radius 个 return radius === 0 ? 1 : 4 * radius; } @@ -101,7 +102,7 @@ export class RingRange extends BaseRange { // 必须与下面的 inRange 判定完全一致,否则 scan 会漏格或多格 *iterateLoc(param: Readonly): Iterable { const { width, height } = this.host; - const radius = Math.abs(param.radius); + const radius = Math.abs(Math.trunc(param.radius)); for (let dy = -radius; dy <= radius; dy++) { const y = param.cy + dy; if (y < 0 || y >= height) continue; @@ -121,7 +122,7 @@ export class RingRange extends BaseRange { } inRange(x: number, y: number, param: Readonly): boolean { - const radius = Math.abs(param.radius); + const radius = Math.abs(Math.trunc(param.radius)); return ( this.inBound(x, y) && Math.abs(x - param.cx) + Math.abs(y - param.cy) === radius @@ -136,7 +137,7 @@ export class RingRange extends BaseRange { - `iterateLoc` 决定「枚举时输出哪些格」,必须和 `inRange` 使用同一套判定; - `inRange` 决定「单个点算不算命中」,越界必须先由 `inBound` 收口,不能只比较距离。 -`radius` 统一取绝对值,因此 `-2` 与 `2` 等价。`radius: 0` 时环形退化为中心单点,这与 `inRange` 的 `距离 === 0` 判定一致。 +`radius` 统一取绝对值并截断为整数,因此 `-2` 与 `2` 等价,`2.7` 会按 `2` 处理。**不要依赖非整数半径**:不截断时 `iterateLoc` 会产出非整数索引,`index % width` 也会得到错误坐标。示例用 `Math.trunc` 兜底;如果你希望非整数直接报错而不是被截断,就在三个方法开头换成显式校验。`radius: 0` 时环形退化为中心单点,这与 `inRange` 的 `距离 === 0` 判定一致。 ## 绑定到系统 @@ -158,6 +159,8 @@ for (const [hit, view] of hits) { `scanRange` 内部会先 `range.bindHost(this)`,再用 `autoDetect` 在「遍历怪物列表」与「遍历范围」之间选一条路径。自定义范围不需要知道怪物列表长什么样。 +注意 `scanRange` 的语义是「筛选**当前上下文里已存在的怪物视图**」,不是「枚举范围内的空格」:范围里的空格不会出现在结果里,只有站在范围内的怪物才会。要检查范围本身覆盖了哪些坐标,直接跑 `range.iterateLoc(param)`(先绑定宿主),不要拿 `scanRange` 的结果当格子清单。 + 如果要在自己的函数里复用范围,也可以显式接收宿主: ```ts @@ -184,7 +187,7 @@ function collectRing( 先确认上下文已绑定勇士。正常初始化流程会调用 `state.enemyContext.bindHero(state.hero.getAttribute())`;如果你在最小环境里手动搭上下文,需要自己补这一步。 -把勇士放在一只怪物旁边,执行: +`scanRange` 只返回范围内的怪物,所以验证要摆好怪物,而不是只看格子。先以勇士为中心,在中心、曼哈顿距离 `1`、距离 `2` 的位置各放一只测试怪,再执行: ```ts state.enemyContext.bindHero(state.hero.getAttribute()); @@ -198,12 +201,35 @@ const hits = [ }) ]; -for (const [hit] of hits) { - console.log(`命中 (${hit.x}, ${hit.y})`); +for (const [hit, view] of hits) { + // hit 是怪物所在坐标;view 是该位置的怪物视图 + console.log( + `命中 (${hit.x}, ${hit.y})`, + view.getBaseEnemy().getAttribute('hp') + ); } ``` -预期现象:只输出与勇士曼哈顿距离恰好为 `2` 的格子,最多 8 格;中心与距离为 `1` 的格子不会出现。如果勇士靠近地图边缘,超出地图的格会被裁掉,数量相应减少。场景里没有怪物时,可以先放两只测试怪,或临时用 `collectRing` 直接检查索引数量。 +预期现象: + +- 结果里只有距离恰好为 `2` 的那只怪物;中心与距离 `1` 的怪物不会出现; +- 结果的数量取决于你放在环上的怪物数量,而不是环上的空格数量——环本身最多有 8 格,但 `scanRange` 不会为空格产出条目; +- 如果勇士靠近地图边缘,超出地图的环上格子会被裁掉;如果那里也没有怪物,结果数量相应减少。 + +要单独确认范围覆盖了哪些格子,绕过怪物列表,直接检查 `iterateLoc`: + +```ts +const ring = new RingRange(); +// 上下文有 width / height,可以直接作为宿主 +ring.bindHost(state.enemyContext); + +const loc = state.hero.getLocation(); +const cells = [...ring.iterateLoc({ cx: loc.x, cy: loc.y, radius: 2 })]; + +console.log('环上格子数', cells.length); // 未被地图边缘裁剪时最多 8 +``` + +场景里没有怪物时,`scanRange` 返回空结果是正常的;这时用上面的 `iterateLoc` 或 `collectRing` 检查范围本身。不要用「输出最多 8 格」来判断 `scanRange` 是否正确。 ## 边界与注意 diff --git a/docs/advanced/ii/custom-replay-command.md b/docs/advanced/ii/custom-replay-command.md index a40a3ca..77da7e4 100644 --- a/docs/advanced/ii/custom-replay-command.md +++ b/docs/advanced/ii/custom-replay-command.md @@ -43,17 +43,29 @@ export const enum ReplayCode { 新增一项意味着修改 `packages-user` 的引擎内容,而不是只改 `src`。`registerCommand` 收的是普通 `number`,跳过枚举直接写数字也能注册;但引擎之后分配新码时可能撞号,所以正式做法仍然是在枚举末尾追加。 -指令码的取值还受位宽限制。默认的 `ReplayCommandWidth.Uint8` 只支持 `0–255`,超出后写入会被截断。需要更多指令码时,要切到 `ReplayCommandWidth.Uint16`(`0–65535`);这是录像格式的变更,已保存的录像会带上当时的位宽,不能假设新旧录像可以混用。 +指令码的取值还受位宽限制。默认的 `ReplayCommandWidth.Uint8` 只支持 `0–255`,超出后写入会被截断。需要更多指令码时,在开始录制前切到 `ReplayCommandWidth.Uint16`(`0–65535`): + +```ts +import { ReplayCommandWidth } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; + +/** 在注册/录制超过 255 的指令码之前调用。 */ +function useUint16ReplayCodes(state: ICoreState): void { + state.replaySystem.array.setCommandWidth(ReplayCommandWidth.Uint16); +} +``` + +`setCommandWidth` 会重新编码现有指令;`ReplaySystem.saveState` 会把当前 `commandWidth` 一并存入录像,`loadState` 再按存档里的宽度恢复,所以本系统自己的存读档不需要额外转换。但这是录像格式变更:外部工具或旧版本若固定按 Uint8 解析,就读不了 Uint16 录像;从 Uint16 降回 Uint8 时,大于 255 的指令码会告警 code 154 并被截断。确定要扩容后就不要来回切换。 ## 指令对象 一条指令实现 `IReplayCommand`: -| 成员 | 作用 | -| --------------- | ----------------------------------------------- | -| `type` | `ReplayCommandType.Active` 或 `Passive` | -| `execute(step)` | 执行这一步,返回 `Promise` | -| `finalize?()` | 连续多步同一指令结束时统一收尾;可选 | +| 成员 | 作用 | +| --------------- | ------------------------------------------------------------------------- | +| `type` | `ReplayCommandType.Active` 或 `Passive` | +| `execute(step)` | 执行这一步,返回 `Promise` | +| `finalize?()` | 连续多步同一指令结束时统一收尾,返回 `Promise`;可选 | `step` 提供 `command`(指令码)、`params`(参数列表)与 `index`(读取流给出的步索引)。返回值 `ReplayCommandResult` 有三种: @@ -61,15 +73,71 @@ export const enum ReplayCode { - `Failed`:这一步失败,播放中止; - `Ignored`:失败但可以忽略,控制台输出信息后继续。 -`finalize` 在两种时机被调用:下一步换了另一条指令,或整条录像已经播完。它没有参数,因此需要跨步保存的数据要放在指令对象的成员里。典型用途是「连续多步移动后,统一启动一次动画」:每步只更新目标,最后一步结束时再提交。 +`finalize` 在两种时机被调用:下一步换了另一条指令,或整条录像已经播完。它没有参数,但**有返回值**:`Promise`。返回 `Success` 继续,返回 `Ignored` 记录后继续,返回 `Failed` 会中止播放。需要跨步保存的数据要放在指令对象的成员里。典型用途是「连续多步移动后,统一启动一次动画」:每步只更新目标,最后一步结束时再提交。 ## 主动录像与被动录像 `ReplayCommandType.Active` 的指令自己执行逻辑,是最常见的形式。内置的移动、瞬移、使用道具、装备都是主动录像。 -`ReplayCommandType.Passive` 的指令不执行任何逻辑。沙盒在主动步执行期间把下一步暴露给 `IReplaySandbox.getPassive()`,由外部取走参数,处理完后调用 `next(status)` 才会推进。适合「需要渲染端配合、耗时不可预测」的行为:数据端只负责给出参数,什么时候处理完由外部决定。 +`ReplayCommandType.Passive` 的指令不执行任何逻辑。沙盒在主动步执行期间把下一步暴露给 `IReplaySandbox.getPassive()`,由主动步取走参数,处理完后调用 `next(status)` 才会推进。适合「需要渲染端配合、耗时不可预测」的行为:数据端只负责给出参数,什么时候处理完由外部决定。 -被动步必须紧跟在某个主动步之后,并在那个主动步结束前被取走。没有及时消费的被动步会被沙盒跳过,并输出告警。被动步对应的指令对象仍然要注册,沙盒要靠它判断 `type`;但它的 `execute` 不会被调用。 +`getPassive()` 返回的是 `IReplayPassiveHandler | null`,结构为: + +```ts +export interface IReplayPassiveHandler { + /** 被动步本身,params 与 command 都在这里 */ + readonly step: IReplayStepHandler; + /** 处理完后调用;Success 或 Ignored 会进入下一步,Failed 不推进 */ + next(status: ReplayCommandResult): Promise; +} +``` + +**`next(status)` 在这个 handler 上,不在 `IReplayCommand` 上,也不在 `getPassive()` 的返回值之外**。完整链路是: + +1. 玩家入口先 `record` 主动指令,再紧接着 `record` 被动指令——两个录像步必须相邻: + + ```ts + this.state.replaySystem.record(ReplayCode.UseSkill, skillId, x, y); + // 紧随其后的被动步,给渲染端播放技能特效 + this.state.replaySystem.record(ReplayCode.SkillEffect, skillId); + ``` + +2. 回放时,主动指令的 `execute` 在自己的执行期间取被动 handler: + + ```ts + const passive = this.state.replaySystem.sandbox?.getPassive(); + if (passive) { + // 参数在 passive.step.params;先确认它确实是预期的那条被动指令 + const [effectSkill] = passive.step.params as [number]; + console.log('播放技能特效', effectSkill); + // 渲染端处理完后推进;不调用 next 就不会进入下一步 + await passive.next(ReplayCommandResult.Success); + } + ``` + +3. `next` 推进读取流并触发 `onStep`;重复调用 `getPassive()` 在推进前会拿到同一个 handler / 同一步。 + +被动指令本身只需要声明类型并注册,`execute` 不会被沙盒调用: + +```ts +import { ReplayCommandResult, ReplayCommandType } from '@user/data-common'; +import { BaseReplayCommand } from './commands'; + +/** 技能特效的被动步:参数由主动步通过 getPassive 取走。 */ +export class ReplaySkillEffect extends BaseReplayCommand { + readonly type: ReplayCommandType = ReplayCommandType.Passive; + + protected readonly name: string = 'skill-effect'; + protected readonly paramTypes: readonly string[] = ['number']; + + async wrappedExecute(): Promise { + // 被动步不会进入这里;保留实现只是为了满足基类契约 + return ReplayCommandResult.Success; + } +} +``` + +被动步必须紧跟在某个主动步之后,并在那个主动步的 `execute` 返回前被取走。没有及时消费的被动步不会让播放永远卡住:下一次 `replayStep` 发现它是 `Passive` 时会告警 code 194 并跳过,但这一步对应的效果就丢了。被动步对应的指令对象仍然要注册,沙盒要靠它判断 `type`;它的 `execute` 不会被调用。一个主动步只对应一个紧随其后的被动步,不要连续排两个。 渲染端的播放表现按渲染端教程处理,本篇只写数据端。 @@ -278,20 +346,22 @@ async function playReplay( `reseter` 负责把游戏状态重置到录像的起点;不重置的话,回放会叠加在当前状态上,结果与原始游玩对不上。`createReplaySandbox` 在创建时就会调用一次 `reset`,不是在 `play` 时。 +`reset` 收到的 `save` 来自沙盒配置的 `save` 字段(`IReplaySandboxConfig.save`,可选)。`ReplaySystem.createReplaySandbox` 实际执行的是 `config.reseter.reset(config.save)`;不传 `save` 就是 `reset(undefined)`。如果录像要从某个存档点开始回放,把该存档读出的 `Map` 传给 `save`,`reset` 就用它恢复起点;`ReplayReseter` 里注入的 `restore` 只是项目自己的「新游戏 / 读档」实现。 + 播放控制由 `IReplaySandbox` 提供: -| 成员 | 作用 | -| --------------- | ----------------------------------------------- | -| `play()` | 连续播放 | -| `step()` | 单步播放,返回该步是否成功 | -| `pause()` | 暂停,返回的 Promise 在真正暂停后兑现 | -| `resume()` | 从暂停恢复 | -| `stop()` | 停止播放 | -| `getReplayed()` | 已读取的录像步数量,不含当前正在播放的一步 | -| `pausing` | 是否处于暂停状态 | -| `ended` | 是否已经播放完毕 | -| `playing` | 是否处于播放状态 | -| `getPassive()` | 取下一步的被动录像参数,不是被动步时返回 `null` | +| 成员 | 作用 | +| --------------- | ----------------------------------------------------------- | +| `play()` | 连续播放 | +| `step()` | 单步播放,返回该步是否成功 | +| `pause()` | 暂停,返回的 Promise 在真正暂停后兑现 | +| `resume()` | 从暂停恢复 | +| `stop()` | 停止播放 | +| `getReplayed()` | 已读取的录像步数量,不含当前正在播放的一步 | +| `pausing` | 是否处于暂停状态 | +| `ended` | 是否已经播放完毕 | +| `playing` | 是否处于播放状态 | +| `getPassive()` | 取下一步的 `IReplayPassiveHandler`,不是被动步时返回 `null` | `ended` 由连续播放的循环在结束时置为 `true`。只用 `step()` 一步步走到末尾时,沙盒会完成 `finalize` 并停止推进,但不会因此设置 `ended`;需要观察结束状态时,用 `play()` 或 `resume()` 跑完循环。 @@ -364,8 +434,9 @@ async function verifyUseSkill( - **不要复用已删除的旧指令码**:数值属于录像格式,旧录像会按旧值解码。新语义一律追加新值。 - **`Uint8` 下不要超过 255**:写入会截断,产生无法回放的录像。需要更多码时切 `Uint16`,并把它当成格式变更处理。 - **参数类型必须与 `paramTypes` 一致**:只支持 `number`、`string`、`boolean`、`bigint`。传对象等无法编码的值会被丢弃,参数个数随之变化,基类会判为失败。 -- **被动步忘记 `next` 会卡住**:沙盒不会自己推进;重复调用 `getPassive()` 会得到同一步。被动步没有在主动步期间取走时,会被跳过并告警。 -- **`finalize` 返回 `Failed` 会中止播放**:它和 `execute` 一样会中断后续步骤,不要在里面吞掉错误。 +- **被动步忘记 `next` 不会推进**:沙盒不会自己前进;重复调用 `getPassive()` 会得到同一个 handler / 同一步。被动步没有在主动步期间取走时,下一次 `replayStep` 会告警 code 194 并跳过它——播放不会永远卡住,但这一步的效果丢了。 +- **`finalize` 返回 `Failed` 会中止播放**:它返回的是 `Promise`,和 `execute` 一样会中断后续步骤,不要在里面吞掉错误。 +- **指令对象跨回放复用**:指令对象在注册时创建一次,之后每次回放都用同一个实例。成员变量不会自动清空;如果 `execute` 里累积了跨步状态,要在每次 `execute` 开头重置,或在 `finalize` 末尾清理,否则第二次回放会带着上一次的残留。 - **重复注册不会覆盖**:同一指令码重复注册会告警(code 163)并保留先注册的对象。确认注册只发生一次。 - **未知指令码会中止播放**:沙盒查不到指令时告警并停止,不会跳过继续。 - **播放期间必须禁用录制**:否则回放执行的行为会写回同一条录像,并让读取流过期。用 `disable()` 与 `revert()` 包住播放,且保证配对。 diff --git a/docs/advanced/ii/enemy-context-and-combat.md b/docs/advanced/ii/enemy-context-and-combat.md index 62d71ba..c08e2c5 100644 --- a/docs/advanced/ii/enemy-context-and-combat.md +++ b/docs/advanced/ii/enemy-context-and-combat.md @@ -15,9 +15,14 @@ - `getEnemyByLoc(x, y)` / `getEnemyByLocator(locator)` / `getEnemyLocatorByView(view)`:按位置或视图互查; - `getDamageSystem()` / `getMapDamage()`:取绑定到上下文的伤害系统与地图伤害。 -上下文本身不进入存档。切楼层时由外部调用 `setFloor`;地图尺寸变化时调用 `resize`,它会清空当前上下文中的所有怪物与运行状态。`clear()` 只清空怪物与运行状态,`destroy()` 还会解绑伤害系统、地图伤害并清空所有注册项。 +上下文本身不进入存档。切楼层时由外部调用 `setFloor`;地图尺寸变化时调用 `resize`,它会清空当前上下文中的所有怪物与运行状态,并把 `needUpdate` 置为真。`clear()` 只清空怪物与运行状态(以及无来源全局光环列表),保留转换器、查询、最终效果等注册项;`destroy()` 还会解绑伤害系统、地图伤害并清空所有注册项。 -`resize` 与 `setFloor` 都会把上下文标记为需要更新,下一次读取计算后怪物时会重新构建。不要把它们当成普通的字段赋值。 +**`setFloor` 只改 `floor` 字段,不会把 `needUpdate` 置为真。** 如果只调用 `setFloor`,下一次 `buildup()` 会直接返回,计算后怪物与伤害都不会重建。切楼层时必须同时做一次会置脏的动作: + +- 正常换图流程调用 `resize(新地图宽, 新地图高)`,它会清空旧怪物并置 `needUpdate`,随后重新放入新楼层的怪物; +- 如果尺寸没变、只想让已有视图重算,可以重新 `bindHero(state.hero.getAttribute())`(它也会置 `needUpdate`),或触发任一注册/增删接口,再调用 `buildup()`。 + +不要只写 `setFloor` 就期待上下文刷新。 ## 三种怪物对象 @@ -76,12 +81,17 @@ graph TD ## 优先级的边界 -光环、特殊查询与常规查询共用同一套优先级:数值越大越先执行。高优先级留下的结果,低优先级能看到;反过来不行。 +这里其实有两套顺序,不要混为一谈。 + +**特殊光环与特殊查询**按优先级全局排队:`buildupSpecials` 每轮取当前未处理的最大优先级,先执行该优先级下的 `couldApplySpecial` 光环,再执行同优先级的特殊查询,然后才进入下一个优先级。高优先级留下的特殊属性,低优先级能看到;反过来不行。 + +**常规查询**不参加这个全局队列。`buildupQuery` 是逐只怪物、逐个特殊属性 code 执行的:对怪物身上的每个 `special`,取出 `commonQueryMap.get(special.code)` 上注册的效果,按这些效果自己的 `priority` 从高到低执行。优先级只在**同一个 code 内**排序;不同 code 之间没有全局先后,执行顺序取决于 `enemy.iterateSpecials()` 的遍历顺序。 由效果新增的特殊属性会立刻被转换成光环,但这条新光环只能影响之后的阶段: -- 新光环的优先级**高于**当前阶段时,开发环境会告警并跳过这条新增属性; -- 当前阶段不能删除同优先级或更高优先级的已生效光环,删除会被拒绝并告警; +- 新光环的优先级**高于**当前阶段时,开发环境会告警并跳过这条新增属性(特殊属性也不会被加上); +- 删除特殊属性时,`enemy.deleteSpecial(...)` **总是先执行**;如果对应光环的优先级不低于当前阶段,开发环境会告警并跳过「从排序表移除光环」这一步,于是出现「特殊属性已经删了,但这一轮光环还在」的状态。生产环境没有这个优先级拦截,会直接把光环移除。因此不要把它理解成「删除被拒绝」; +- `modify` 命中同优先级或更高优先级的光环时只会告警,不会回滚已经写入的修改; - 同一优先级内部不保证先后顺序,不要把有依赖关系的两条效果放在同一优先级。 举个例子:优先级 20 的特殊光环给周围怪物加上一条优先级 10 的普通光环,这是成立的——20 先执行,10 在之后的基础光环阶段生效。反过来,优先级 10 的光环想给目标加上优先级 20 的光环则不成立:20 比当前阶段高,开发环境会告警并跳过。设计效果时,先想清楚「它应该在谁之后生效」,再决定优先级。 @@ -166,7 +176,7 @@ import { FullRange, IRange } from '@motajs/common'; import { IEnemyAttr, IHeroAttr } from '@user/data-common'; import { ICoreState } from '@user/data-state'; import { - IEnemyAuraView, + IAuraView, IEnemyFinalEffect, IEnemyHandler, IEnemySpecialModifier, @@ -182,8 +192,8 @@ class GlobalArmorFinal implements IEnemyFinalEffect { } } -/** 全局基础光环:全场怪物 +10 攻速。 */ -class SpeedAura implements IEnemyAuraView { +/** 无来源的全局基础光环:全场怪物 +10 攻速。 */ +class SpeedAura implements IAuraView { readonly priority = 10; readonly couldApplyBase = true; readonly couldApplySpecial = false; @@ -236,7 +246,7 @@ function inspectAfterBuildup( } ``` -`addAura` 添加的是无来源全局光环,不需要转换器。实际游戏里 `initializeEnemy` 已经注册了三个光环转换器,因此基础光环阶段一定会执行;如果你在测试里手建了一个空上下文,记得先注册至少一个转换器或特殊查询,否则这一段会被跳过。 +`addAura` 添加的是无来源全局光环,不需要转换器。但要注意 `buildup()` 的外层开关:它判断的是 `auraConverter.size > 0 || specialQueryEffects.size > 0`,并不看 `globalAuraList`。实际游戏里 `initializeEnemy` 已经注册了三个光环转换器,所以全局光环会正常进入构建;如果你在测试里手建了一个空上下文,只调用 `addAura` 而不注册任何转换器或特殊查询,`buildupSpecials` / `buildupBase` 根本不会被调用,光环看起来像没生效。 ## 怎么验证 @@ -288,7 +298,9 @@ function verifyBuildup(state: ICoreState, view: IEnemyView): void { - **不要在高频循环里反复全量 `buildup()`**:全量构建会重置并重算所有怪物,开销随怪物数量增长;单只变化优先用 `markDirty` + `requestRefresh`。 - **`getComputingEnemy()` 是内部接口**:它暴露正在构建中的可写怪物,外部调用会绕过刷新与脏标记。 - **查询效果不是内置内容**:`initializeEnemy` 只注册光环转换器、最终效果、战斗脚本与钩子,没有注册任何查询。查询需要自己注册后才会参与构建。 +- **`buildupSpecials` 在没有光环时提前返回**:它先加入全局光环、转换特殊属性光环,然后执行 `if (sortedAura.size === 0) return;`。如果只注册了特殊查询而场上没有任何光环,这个提前返回会让特殊查询也不执行。需要特殊查询时,至少保证存在一条光环(全局或转换出的都算)。 +- **光环可以命中自己**:范围包含中心时(`ManhattanRange` 的 `radius: 0`、`FullRange` 等),来源怪物自己也在扫描结果里,`apply` / `applySpecial` 会作用到它自己。设计要求「不影响自身」时,在 `apply` 里跳过 `handler.locator` 与来源位置相同的目标(带来源的 `IEnemyAuraView` 可比较 `aura.locator`),或改用不含中心的范围。 - **开发环境的优先级告警不是致命错误**:它会跳过不合法的增删,但通常说明阶段依赖设计有问题,应该调整优先级而不是忽略告警。 -- **`clear()` 与 `destroy()` 的时机不同**:`clear()` 清空怪物与运行状态,保留注册项;`destroy()` 会连伤害系统、地图伤害与所有注册项一起清掉。销毁后不应继续使用该上下文。 +- **`clear()` 与 `destroy()` 的时机不同**:`clear()` 清空怪物、运行状态与无来源全局光环列表(`globalAuraList`),但保留光环转换器、查询、最终效果等注册项;`destroy()` 会连伤害系统、地图伤害与所有注册项一起清掉。销毁后不应继续使用该上下文。 返回[高级 II 导览](./index.md)。 diff --git a/docs/advanced/ii/equipment-system.md b/docs/advanced/ii/equipment-system.md index c6a3537..2bb820a 100644 --- a/docs/advanced/ii/equipment-system.md +++ b/docs/advanced/ii/equipment-system.md @@ -33,15 +33,15 @@ export interface IItemEquipData { `state.hero.items.equipment` 是 `IHeroEquipsStore`,管理所有已获得的装备实例: -| 成员 | 作用 | -| -------------------- | ----------------------------------------------------------- | -| `add(item)` | 按图块数字或字符串 id 创建实例,返回新 `uid`;失败返回 `-1` | -| `delete(uid)` | 移除实例 | -| `get(uid)` | 取实例,未找到返回 `null` | -| `instancesOf(equip)` | 取某件装备的全部实例,按排序器输出 | -| `instances()` | 取全部实例,按排序器输出 | -| `count(item)` | 数某件装备的实例数量 | -| `useSorter(sorter)` | 设置排序器,传 `null` 恢复按 `uid` 升序 | +| 成员 | 作用 | +| -------------------- | ------------------------------------------------------------- | +| `add(item)` | 按图块数字或字符串 id 创建实例,返回新 `uid`;失败返回 `-1` | +| `delete(uid)` | 移除实例 | +| `get(uid)` | 取实例,未找到返回 `null` | +| `instancesOf(equip)` | 取某件装备的全部实例,参数为图块数字或字符串 id,按排序器输出 | +| `instances()` | 取全部实例,按排序器输出 | +| `count(item)` | 数某件装备的实例数量,参数为图块数字或字符串 id | +| `useSorter(sorter)` | 设置排序器,传 `null` 恢复按 `uid` 升序 | `add` 的参数可以传图块数字或字符串 id,内部先经 `tileStore.num` 解析,再取 `itemStore` 里的定义。因此调用前道具定义必须已经注册。 @@ -62,7 +62,7 @@ function grantEquipment(state: ICoreState, id: string): number { } ``` -拿到 `uid` 后,既可以交给装备槽穿戴,也可以直接改实例的加成。`instancesOf` 与 `count` 都按「同一件装备」聚合,与 `uid` 无关。 +拿到 `uid` 后,既可以交给装备槽穿戴,也可以直接改实例的加成。`instancesOf` 与 `count` 都按「同一件装备」聚合,与 `uid` 无关;两者都接受图块数字或字符串 id,内部同样先经 `tileStore.num` 解析。 ## 装备实例的修饰器 @@ -106,6 +106,8 @@ function strengthenEquipment(state: ICoreState, uid: number): void { 修饰器按 `priority` 从大到小应用,所以百分比先算、数值后加。若基础攻击为 `10`,装备提供 `atk +5` 与 `def +10%`,则攻击为 `15`,防御为 `baseDef * 1.1`。百分比不会把其它装备的数值加成也乘进去。 +多件装备的百分比是**相加**,不是相乘:每件装备的 `PercentageModifier` 都独立按基础值计算 `baseValue * value`,再依次加到当前值上。两件各 `+10%` 的装备得到 `base * 1.2`,不是 `base * 1.21`;数值修饰器则在百分比之后再直接相加。 + 实例的修饰器是活的:穿戴后修改实例数值,`setValue` 会让已挂到属性上的修饰器标脏并立即重算,不需要重新穿戴。 ## 装备槽与装备行为 @@ -238,7 +240,7 @@ function useAtkSort(state: ICoreState): void { ## 装备事件 -`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,在修饰器增加、删除、数值修改时触发。它适合做「装备强化后重算某个系统」这类联动: +`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,但它监听的是**修饰器变化**,不是穿戴。`setValue`、`setPercentage`、`buildModifiers` 以及删除条目都会触发它;`equip` 与 `unequip` 本身不会触发。它适合做「装备强化后重算某个系统」这类联动: ```ts import { ICoreState } from '@user/data-state'; @@ -250,7 +252,7 @@ function watchEquipment(state: ICoreState, uid: number): void { const controller = equip.addHook({ onChangeModifier(modifiers) { - // 每次增加、删除或修改修饰器后都会触发 + // 改变 setValue / setPercentage / buildModifiers 时触发 // modifiers 是实时迭代器,不要保存到回调之外 for (const [name, modifier] of modifiers) { console.log('装备属性变化', name, modifier.value); @@ -263,7 +265,29 @@ function watchEquipment(state: ICoreState, uid: number): void { } ``` -`IItemRawData.equip.loadEvent` 与 `unloadEvent` 不是自动钩子。引擎不会在穿戴或卸下时执行它们,目前它们只是数据字段。需要「穿上后触发」的行为,用 `onChangeModifier`,或在调用 `equip` 与 `unequip` 的地方自行处理。 +`IItemRawData.equip.loadEvent` 与 `unloadEvent` 不是自动钩子。引擎不会在穿戴或卸下时执行它们,目前它们只是数据字段。**引擎也没有内置「装备穿上/卸下」钩子**;`onChangeModifier` 不能替代它: + +- 如果装备实例在 `add` 时已经 `buildModifiers()`,之后 `equip` 只是把已有修饰器挂到属性上,不会触发 `onChangeModifier`; +- 如果实例是在别的时机才首次 `getModifiers()`,`equip` 可能顺带触发一次懒构建,但那是构建副作用,不是稳定的穿戴信号; +- 强化或改值同样会触发它,即使没有穿戴。 + +需要「穿上后触发」的行为,就在调用 `equip` / `unequip` 的统一入口里处理,或者包装一个自己的装备服务: + +```ts +import { ICoreState } from '@user/data-state'; + +/** 在穿戴入口统一处理穿上后的逻辑。 */ +function equipWithCallback( + state: ICoreState, + uid: number, + slot: string | number +): number | undefined { + const replaced = state.hero.equip.equip(uid, slot); + // 只有这里能确定「刚完成一次穿戴」;不要依赖 loadEvent 或 onChangeModifier + console.log('穿上', uid, '替换下', replaced); + return replaced; +} +``` `onChangeModifier` 在 `buildModifiers()` 时也会触发一次。实例在 `add` 时就已经构建过修饰器,所以之后才 `addHook` 的监听者收不到这次初始构建,只会收到后续变化。 @@ -302,11 +326,14 @@ function verifyEquipment(state: ICoreState): void { console.log('穿戴后攻击', attribute.getFinalAttribute('atk')); console.log('穿戴后防御', attribute.getFinalAttribute('def')); + // 先存档,此时装备仍穿在身上,装备槽与实例仓都会写进快照 + const snapshot = state.hero.saveState(SaveCompression.NoCompression); + + // 再卸下,确认修饰器被移除 equipment.unequip(equipment.slots.indexOf('weapon')); console.log('卸下后攻击', attribute.getFinalAttribute('atk')); - // 存档 -> 卸下 -> 读档,确认加成由重新穿戴恢复 - const snapshot = state.hero.saveState(SaveCompression.NoCompression); + // 最后读档:属性先回到基础值,HeroEquipment.loadState 再按快照重新穿戴 state.hero.loadState(snapshot, SaveCompression.NoCompression); console.log('读档后攻击', attribute.getFinalAttribute('atk')); @@ -317,7 +344,7 @@ function verifyEquipment(state: ICoreState): void { } ``` -穿戴后攻击应比基础值多 `5`,防御应为 `baseDef * 1.1`;卸下后两者回到基础值。读档后这两个差值应再次出现。 +穿戴后攻击应比基础值多 `5`,防御应为 `baseDef * 1.1`;卸下后两者回到基础值。因为存档发生在卸下之前,读档后 `HeroEquipment.loadState` 会按快照重新穿戴,这两个差值应再次出现。若把存档放在卸下之后,快照里没有已穿戴的装备,读档后自然不会有加成——这不是读档失败。 ## 边界与注意 diff --git a/docs/advanced/ii/index.md b/docs/advanced/ii/index.md index 2f6855e..a8c02c2 100644 --- a/docs/advanced/ii/index.md +++ b/docs/advanced/ii/index.md @@ -26,7 +26,7 @@ 同一层的篇目可能分散在数据端的不同模块;层与层之间也可能调整主题。判断依据是难度与改动深度,不是模块归属。 -高级 III 目前只有代码 span `高级 III · 原理与架构` 这样的占位标记,没有页面入口。本篇不展开原理与架构,只用占位指出归属。 +高级 III 尚未编写。本层正文只在需要时用 `高级 III · ` 这样的代码 span 或 `` 标出归属,不会写成真实链接;等该层建立后再替换。 ## 前置 @@ -79,18 +79,26 @@ - **高级 III 的原理与架构**:双端分离约束、数据端分层、编辑器识别协议、录像深层原理与性能调优留给高级 III。 - **API 全量参考**:本层只讲改动链路与关键接口,不做签名清单。 -## 优先插件 +## 优先插件与改内置的边界 新增系统优先通过插件承载;只有不得不修改内置系统时,才去改 `packages` 与 `packages-user`。原因是升级成本:`src` 基本不会被上游改动,而内置包会。把改动留在插件里,升级时需要对照的地方少得多。 -| 需求类型 | 优先做法 | 什么情况下才改内置 | -| ------------------------------ | ------------------------------ | -------------------------------------- | -| 新增一套有状态与生命周期的系统 | 写成插件,在插件里装配对象 | 现有插件只差一个接口或默认值时 | -| 给已有系统补一条范围或行为 | 注册自己的范围对象、视图或钩子 | 需要替换整套策略、或改动会破坏内置行为 | -| 新增一个勇士或怪物属性 | 先确认能否用现有修饰器表达 | 属性要参与伤害、光环、查询与存档时 | -| 新增一条可回放的行为 | 先给已有方法补录像标记 | 行为不在引擎里、录像没有对应指令码时 | +本层有几处**只能**改 `packages-user`,因为它们是接口或录像格式本身,插件没有扩展点: -判断标准不是改动多少行,而是改动落在谁的代码里。能由插件或注册项完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个方法或一条分支上。 +- 给 `IHeroAttr` / `IEnemyAttr` 增加字段(类型定义在 `packages-user/data-common/src/types.ts`); +- 追加 `ReplayCode`(录像格式的一部分); +- 补 `HERO_DEFAULT_ATTRIBUTE`,以及需要随接口一起更新的默认值常量。 + +这些改动应在满足需求的前提下尽量小,只加字段、只追加枚举值,不要顺手改内置逻辑。除此之外,本层反复出现的范围、光环、查询、最终效果、录像指令注册与钩子都优先走注册或插件,不直接改内置实现。 + +| 需求类型 | 优先做法 | 什么情况下才改内置 | +| ------------------------------ | ------------------------------ | ----------------------------------------- | +| 新增一套有状态与生命周期的系统 | 写成插件,在插件里装配对象 | 现有插件只差一个接口或默认值时 | +| 给已有系统补一条范围或行为 | 注册自己的范围对象、视图或钩子 | 需要替换整套策略、或改动会破坏内置行为 | +| 新增一个勇士或怪物属性 | 先确认能否用现有修饰器表达 | 接口字段只能加在 `packages-user` 的类型里 | +| 新增一条可回放的行为 | 先给已有方法补录像标记 | 行为不在引擎里、录像没有对应指令码时 | + +判断标准不是改动多少行,而是改动落在谁的代码里。能由插件或注册项完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个字段、一个枚举值或一条分支上。 ## 怎么验证你读懂了 diff --git a/docs/advanced/ii/pathfinding.md b/docs/advanced/ii/pathfinding.md index 8a82ec3..1a34a1e 100644 --- a/docs/advanced/ii/pathfinding.md +++ b/docs/advanced/ii/pathfinding.md @@ -36,7 +36,8 @@ flowchart LR 绑定发生在初始化与换楼层两处。`src/data/initialize.ts` 已经注入通行谓词并把移动器指向勇士,图层先置为 `null`;真正进入楼层后,再把当前楼层的事件层与方向处理器绑上: ```ts -import { Dir4FaceHandler } from '@motajs/common'; +// Dir4FaceHandler / Dir8FaceHandler 都在 @user/data-common,不在 @motajs/common +import { Dir4FaceHandler } from '@user/data-common'; import { ICoreState } from '@user/data-state'; /** @@ -73,6 +74,13 @@ function bindPathfindingLayer(state: ICoreState): void { 每一步是 `IPathfindingStep`,包含 `dir`(移动方向)、`from`(出发位置)与 `to`(到达位置)。`from` 与 `to` 都是只读的坐标对象;路径按起点到终点的顺序排列。 +`finder.find(start, target)` 和 `state.pathfinding.getPath(target)` 的区别只在起点: + +- `finder.find` 要求显式传入起始坐标,不会读取移动器,适合「从任意起点试算」; +- `getPath` 是便捷包装,取 `state.pathfinding.mover.tile` 的当前坐标作为起点,再把结果交给 `finder.find`。没有绑定移动器时它直接返回 `InvalidInput`。 + +两者都依赖同一套图层、通行谓词与方向处理器;换用哪个都不会绕过这些绑定。 + ## 两种移动方式 `moveTo` 总是逐步移动。它先取路径,状态不是 `Success` 时直接返回 `null`;否则把每一步的 `dir` 依次交给移动器,再启动移动器。 @@ -97,7 +105,7 @@ async function walkTo( } ``` -两个方法都返回 `IPathfindingController`:`controller` 是底层移动控制器(可读 `done`、可 `await onEnd`),`path` 是本次使用的寻路结果。需要中止当前移动时调用 `interrupt()`,它会停止底层移动器并等待其结束;新的方向输入或新的寻路调用也可以接管当前移动。 +成功启动时,两个方法都返回 `IPathfindingController`:`controller` 是底层移动控制器(可读 `done`、可 `await onEnd`),`path` 是本次使用的寻路结果。**上一次移动没有结束时,新的 `moveTo` / `teleportTo` 会直接返回 `null`,不会接管当前移动**;底层 `mover.start()` 在移动中同样返回 `null`。想换目标,必须先等 `controller.onEnd`,或调用 `await state.pathfinding.interrupt()` 停止当前移动,再发起新的寻路。 ## 初始化在哪里 @@ -236,14 +244,26 @@ export class MainPathfindingSystemHooks implements IPathfindingSystemHooks { ); } - onAfterTeleportTo(path: IPathfindingResult): void { + onAfterTeleportTo( + path: IPathfindingResult, + system: IPathfindingSystem + ): void { + // 第二个参数同样是寻路系统,可用来判断这次移动绑定的移动器 + void system; // 这个钩子挂在所有移动的结束回调上,逐步移动也会走到这里 console.log('寻路移动结束,共', path.path.length, '步'); } } ``` -钩子实例在 `src/data/hook/initialize.ts` 中注册。需要区分「这次是瞬移还是逐步」时,在 `onBeforeTeleportTo` 里记录标记,再在 `onAfterTeleportTo` 里读取并清除;不要只看结束钩子。 +这段代码直接补进 `src/data/hook/path.ts` 的 `MainPathfindingSystemHooks` 即可(类已存在,方法体是空的)。注册发生在 `src/data/hook/initialize.ts` 的 `initializeHooks`: + +```ts +// initializeHooks 内部 +state.pathfinding.addHook(new MainPathfindingSystemHooks(state)); +``` + +需要区分「这次是瞬移还是逐步」时,在 `onBeforeTeleportTo` 里记录标记,再在 `onAfterTeleportTo` 里读取并清除;不要只看结束钩子。 ## 怎么验证 diff --git a/docs/advanced/ii/static-vs-dynamic-tiles.md b/docs/advanced/ii/static-vs-dynamic-tiles.md index d75ac61..b6585ea 100644 --- a/docs/advanced/ii/static-vs-dynamic-tiles.md +++ b/docs/advanced/ii/static-vs-dynamic-tiles.md @@ -73,7 +73,12 @@ function inspectLayer(layer: IMapLayer): void { 3. 用这个数字创建一个动态图块; 4. 按 `keepEvent` 决定是否把原静态图块的**图块事件**复制到动态图块。 -`keepEvent` 默认是 `true`。保留时,原静态图块当前的图块事件会复制到动态实例,然后原位置的事件被清空;不保留时,动态实例保留它按图块定义重建的默认事件。无论是否保留,静态图块那一格都不再持有原来的图块事件。 +`keepEvent` 默认是 `true`。两种取值对源静态图块的影响并不对称: + +- `true`:先把源静态图块的图块事件复制到动态实例,再调用 `staticTile.tileEvent().clear()` 清空源实例的事件; +- `false`:动态实例按图块定义重建默认事件,但**源静态图块实例完全不被触碰**,它缓存里原来的图块事件仍然保留。 + +因此「不保留」不等于「源事件被清空」。之后如果又往这一格写静态图块,`layer.setBlock(num, x, y)` 不会重置该坐标缓存实例的事件(只有 `StaticTile.set(num)` 会调用 `restoreDefaultEvents()`),旧的图块事件就可能被新图块继承。想确保干净,转换后用 `source.tileEvent().clear()` 或 `source.restoreDefaultEvents()` 显式清一次,之后再用 `tile.set(num)` 换图块。 ```ts import { IMapLayer } from '@user/data-base'; @@ -137,8 +142,8 @@ async function shiftRight( ### 转换的失败条件 - `transferToDynamic` 在坐标不在地图上时返回 `null`,并告警 code 128;目标格为空时仍会创建一个数字为 `0` 的动态图块,并告警 code 127。 -- `toStatic()` 按接口约定,在动态图块坐标不是整数或正在移动时返回 `null`;目标格被占用时会告警 code 129 并覆盖。 -- `toStaticIfSafe()` 在 `toStatic()` 的基础上额外要求目标格为空;目标格被占用时返回 `null`。 +- `toStatic()` 在目标坐标不在地图内时返回 `null`;目标格被占用时会告警 code 129 并覆盖。接口注释还约定「坐标不是整数或正在移动」时应失败,但当前实现没有在 `transferToStatic` 里检查 `mover.moving`,所以不要依赖它替你拦截移动中的转换。需要转换时先 `await controller.onEnd`,或至少判断 `dynamic.mover.moving` 为 `false`。 +- `toStaticIfSafe()` 在 `toStatic()` 的基础上额外要求目标格为空;目标格被占用时返回 `null`。它同样不会检查移动状态。 - 动态图块被转回静态,或被 `delete()` 删除时,会触发图层的 `onDeleteDynamic` 钩子。`delete()` 返回 `Promise`,会等待钩子完成;`toStatic` 只触发钩子,不等待。 - `createDynamic` 与 `transferToDynamic` 都会触发 `onCreateDynamic`;`updateDynamicTile` 会触发 `onUpdateDynamicPosition`,移动器在每步落点更新位置时会调用它。 @@ -160,7 +165,7 @@ async function shiftRight( ## 事件视图接口 -点事件与图块事件都用同一套事件视图。只读接口是 `IReadonlyEventView`: +点事件与图块事件都用同一套事件视图。取可写视图用 `layer.event(x, y)`(返回 `ILayerEventView | null`)或 `tile.tileEvent()`;`layer.getPointEvent(x, y)` 不是视图,它只是 `event(x, y)?.get()` 的简写,返回 `ReadonlyMap | null`,不能用来修改点事件。只读接口是 `IReadonlyEventView`: | 成员 | 作用 | | --------- | -------------------------------------------------------------- | @@ -293,6 +298,7 @@ async function verifyTileConversion( - **静态图块的 `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` 的图块事件,两者互不冲突;冲突只发生在同一个事件视图内部。