From cc3c4e82778cad03e32565b9a66ef4ae5fafc15c Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Thu, 22 Oct 2026 14:30:48 +0800 Subject: [PATCH] =?UTF-8?q?docs(10-27):=20U6=20=E4=BF=AE=E8=AE=A2=E6=89=B9?= =?UTF-8?q?=E6=AC=A1C=EF=BC=88=E5=AF=BB=E8=B7=AF/=E5=B1=9E=E6=80=A7/?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../10-docs-restructure/10-27-USER-REVIEW.md | 64 ++++++++++------- docs/advanced/ii/adding-enemy-attribute.md | 2 - docs/advanced/ii/adding-hero-attribute.md | 70 +++++++++---------- docs/advanced/ii/enemy-context-and-combat.md | 65 +++-------------- docs/advanced/ii/pathfinding.md | 13 ++-- 5 files changed, 88 insertions(+), 126 deletions(-) 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 875d4ca..8ec6887 100644 --- a/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md @@ -4,36 +4,36 @@ **状态:** 已记录,明日继续执行。修改方式将按 GSD 流程派发子代理(只改 `docs/advanced/ii/*.md`,必要时含 `docs/advanced/*.md`)。 -| # | 用户意见 | 落点 | -|---|----------|------| -| U6-01 | `const hits = state.enemyContext.scanRange(new RingRange() ...)`:把 `new RingRange()` 提出来作为常量;引擎代码风格不建议在此内联 `new` | `custom-range.md` | -| U6-02 | 范围要专门讲 `estimatePointCount`:只要求尽量快地估算大致数量,不要为数量准确牺牲性能(否则自动判断可能因估算太慢而变慢) | `custom-range.md` | -| U6-03 | 有些代码未包裹完整类上下文导致高亮错误,重点关注**类的方法**;适用于后续所有文档(D-70) | 全部 | -| U6-04 | 点事件与图块事件较复杂,**单独开一篇文档**讲;重点:二者区别、跟随移动的区别、二者都可以自己设置且跟随存档 | 新增文档(II) | -| U6-05 | 「事件基准」简单提一句即可:`markPure` 与 `ref` 是样板内部优化接口,一般不要调用,除非清楚自己在做什么 | `static-vs-dynamic-tiles.md` | -| U6-06 | 装备 `getModifier` 的实现细节不用讲,只说它用于获取装备修饰器 | `equipment-system.md` | -| U6-07 | 若 `slots` 定义为字符串,也可以传索引;索引最精确、字符串是粗略描述(同名时无法指定具体槽)。文档写成「不能混用」应改 | `equipment-system.md` | -| U6-08 | 「修饰器与存档的关键约定」讲的是内部实现:章节开头写「此章节讲解一下 XXX 的内部实现」;这类纯内部实现章节应放在文档靠后,前面主要讲使用(D-69) | `equipment-system.md` 及后续 | -| U6-09 | `loadEvent` / `unloadEvent` 尚未实现,文档要说明「还未实现」,待后续实现再改 | `equipment-system.md` | -| U6-10 | 内置录像行为还没做完,不要用内置作为示例;直接用注释写一行省略已有录像码枚举 | `custom-replay-command.md` | -| U6-11 | 被动录像的典型案例:弹出一个选择框,玩家选择内容;录像无法提前知道弹出什么选择框,只能等选择框出现后由选择框系统自己读录像选择——用这个案例讲被动录像 | `custom-replay-command.md` | -| U6-12 | 主动/被动录像的推进部分太复杂,不要讲太细:被动步用完后要调用 `next`,否则再次调用还是这个被动步(可能确实需要在多处调用,但务必 next);主动步实现 `execute` 即可 | `custom-replay-command.md` | -| U6-13 | 讲明「被动步为什么必须跟在主动步之后、且在主动步完成前全部被使用」:若主动步执行完才轮到被动步,被动步未被调用会永远卡住;主动步需要被动步时会报错。实际中被动步一定源于某个主动操作 | `custom-replay-command.md` | -| U6-14 | 录像注册建议写到插件里:安装别人的插件时无需自己再注册录像,只需新增录像码;这是样板推荐写法 | `custom-replay-command.md` | -| U6-15 | 记录处:`apply` 才是应写 `@shouldReplay` 的地方(它才可能修改状态),`use` 只是调用 `apply`,可以不写;且 `use` 里有 `if` 判断,不能用时会直接告警 | `custom-replay-command.md` | -| U6-16 | 沙盒播放不用讲(且原文讲错了):沙盒播放必须启用录像,否则播放结束没有记录任何录像,不能重播也不能验证;样板会内置沙盒播放,作者几乎不需要自己写 | `custom-replay-command.md` | -| U6-17 | 被动步忘记 `next` 不是不推进,只是不读下一个录像;若此时主动录像播完,会继续播放后面的录像 | `custom-replay-command.md` | -| U6-18 | 自动寻路中不要用 `Infinity` 把一个点设为不可达;不可达应由谓词控制,用 `Infinity` 不标准 | `pathfinding.md` | -| U6-19 | 样板所有 `.jsonc` 只允许开头那条带系统前缀注释的注释,后面不允许注释;勇士属性处不要说 `.jsonc` 允许注释。且 `.jsonc` 由编辑器修改,留占位 | `adding-hero-attribute.md`(及 `.jsonc` 相关内容) | -| U6-20 | 新增勇士属性若需存档迁移,建议用 Executor 实现——Executor 本就是为了这个目标而存在 | `adding-hero-attribute.md` | -| U6-21 | 内容层是 JSONC,不经过 TypeScript 的 `Record` 检查;TS 手写 `IItemEquipData` 的 `Record` 细节作者不需要知道,这段不用专门说 | `adding-hero-attribute.md` | -| U6-22 | 怪物上下文不要讲手动更新(样板已基本做好,少数情况才需 `markDirty`);细节过多、难懂,要降低理解难度。刷新只说:若怪物申请了上下文查询,周围怪物更新时它也要更新——这正是必须用 `query()` 而非 `handler.context` 的原因。局部刷新与全量重建章节缩减到现在的一半 | `enemy-context-and-combat.md` | +| # | 用户意见 | 落点 | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| U6-01 | `const hits = state.enemyContext.scanRange(new RingRange() ...)`:把 `new RingRange()` 提出来作为常量;引擎代码风格不建议在此内联 `new` | `custom-range.md` | +| U6-02 | 范围要专门讲 `estimatePointCount`:只要求尽量快地估算大致数量,不要为数量准确牺牲性能(否则自动判断可能因估算太慢而变慢) | `custom-range.md` | +| U6-03 | 有些代码未包裹完整类上下文导致高亮错误,重点关注**类的方法**;适用于后续所有文档(D-70) | 全部 | +| U6-04 | 点事件与图块事件较复杂,**单独开一篇文档**讲;重点:二者区别、跟随移动的区别、二者都可以自己设置且跟随存档 | 新增文档(II) | +| U6-05 | 「事件基准」简单提一句即可:`markPure` 与 `ref` 是样板内部优化接口,一般不要调用,除非清楚自己在做什么 | `static-vs-dynamic-tiles.md` | +| U6-06 | 装备 `getModifier` 的实现细节不用讲,只说它用于获取装备修饰器 | `equipment-system.md` | +| U6-07 | 若 `slots` 定义为字符串,也可以传索引;索引最精确、字符串是粗略描述(同名时无法指定具体槽)。文档写成「不能混用」应改 | `equipment-system.md` | +| U6-08 | 「修饰器与存档的关键约定」讲的是内部实现:章节开头写「此章节讲解一下 XXX 的内部实现」;这类纯内部实现章节应放在文档靠后,前面主要讲使用(D-69) | `equipment-system.md` 及后续 | +| U6-09 | `loadEvent` / `unloadEvent` 尚未实现,文档要说明「还未实现」,待后续实现再改 | `equipment-system.md` | +| U6-10 | 内置录像行为还没做完,不要用内置作为示例;直接用注释写一行省略已有录像码枚举 | `custom-replay-command.md` | +| U6-11 | 被动录像的典型案例:弹出一个选择框,玩家选择内容;录像无法提前知道弹出什么选择框,只能等选择框出现后由选择框系统自己读录像选择——用这个案例讲被动录像 | `custom-replay-command.md` | +| U6-12 | 主动/被动录像的推进部分太复杂,不要讲太细:被动步用完后要调用 `next`,否则再次调用还是这个被动步(可能确实需要在多处调用,但务必 next);主动步实现 `execute` 即可 | `custom-replay-command.md` | +| U6-13 | 讲明「被动步为什么必须跟在主动步之后、且在主动步完成前全部被使用」:若主动步执行完才轮到被动步,被动步未被调用会永远卡住;主动步需要被动步时会报错。实际中被动步一定源于某个主动操作 | `custom-replay-command.md` | +| U6-14 | 录像注册建议写到插件里:安装别人的插件时无需自己再注册录像,只需新增录像码;这是样板推荐写法 | `custom-replay-command.md` | +| U6-15 | 记录处:`apply` 才是应写 `@shouldReplay` 的地方(它才可能修改状态),`use` 只是调用 `apply`,可以不写;且 `use` 里有 `if` 判断,不能用时会直接告警 | `custom-replay-command.md` | +| U6-16 | 沙盒播放不用讲(且原文讲错了):沙盒播放必须启用录像,否则播放结束没有记录任何录像,不能重播也不能验证;样板会内置沙盒播放,作者几乎不需要自己写 | `custom-replay-command.md` | +| U6-17 | 被动步忘记 `next` 不是不推进,只是不读下一个录像;若此时主动录像播完,会继续播放后面的录像 | `custom-replay-command.md` | +| U6-18 | 自动寻路中不要用 `Infinity` 把一个点设为不可达;不可达应由谓词控制,用 `Infinity` 不标准 | `pathfinding.md` | +| U6-19 | 样板所有 `.jsonc` 只允许开头那条带系统前缀注释的注释,后面不允许注释;勇士属性处不要说 `.jsonc` 允许注释。且 `.jsonc` 由编辑器修改,留占位 | `adding-hero-attribute.md`(及 `.jsonc` 相关内容) | +| U6-20 | 新增勇士属性若需存档迁移,建议用 Executor 实现——Executor 本就是为了这个目标而存在 | `adding-hero-attribute.md` | +| U6-21 | 内容层是 JSONC,不经过 TypeScript 的 `Record` 检查;TS 手写 `IItemEquipData` 的 `Record` 细节作者不需要知道,这段不用专门说 | `adding-hero-attribute.md` | +| U6-22 | 怪物上下文不要讲手动更新(样板已基本做好,少数情况才需 `markDirty`);细节过多、难懂,要降低理解难度。刷新只说:若怪物申请了上下文查询,周围怪物更新时它也要更新——这正是必须用 `query()` 而非 `handler.context` 的原因。局部刷新与全量重建章节缩减到现在的一半 | `enemy-context-and-combat.md` | ## 高级 I 待补内容(用户 2026-10-20 提出,待后续补写) -| # | 主题 | 说明 | -|---|------|------| -| AI-GAP-01 | 移动 Mover | 高级 I 未讲,需补写 | +| # | 主题 | 说明 | +| --------- | -------------------- | ------------------- | +| AI-GAP-01 | 移动 Mover | 高级 I 未讲,需补写 | | AI-GAP-02 | FaceHandler 系列接口 | 高级 I 未讲,需补写 | ## 迭代记录 @@ -64,3 +64,13 @@ - U6-17:补充忘记 `next` 不会停住整条播放,只是不读下一个录像;主动步返回后沙盒会告警并跳过该被动步继续播放。 **验证:** `node node_modules/prettier/bin/prettier.cjs --write docs/advanced/ii/custom-replay-command.md` → CRLF、单 H1;`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` 退出 0、`build complete`、无 dead link。 + +### 批次 C(U6-18..U6-22)执行记录(2026-10-22) + +- U6-18:`pathfinding.md` 不再教用 `Infinity` 标记不可进入;损失函数只要求非负有限数,`NaN` / 负数回退为 `1`,不可达统一由通行谓词控制。示例删去 `WALL_TILE` 与 `Infinity` 分支,边界条目同步。 +- U6-19:`adding-hero-attribute.md` 与 `adding-enemy-attribute.md` 的 `.jsonc` 示例删去全部注释;正文不再说「`core.jsonc` 允许注释」,改为说明样板 `.jsonc` 只有文件开头那段系统前缀注释,加载器按前缀切开后直接 `JSON.parse`。编辑器新增 `编辑器 · 内容配置` 占位与 TODO,并同步开头「编辑器负责内容配置」的说法。 +- U6-20:`adding-hero-attribute.md` 的「旧存档迁移」改为推荐 `ISaveableExecutor`:新增示例类 `HeroAttributeSaveExecutor`(`afterLoad` 用 `Object.hasOwn` 判断缺键后 `set`,并说明 `set` 会重算最终值),并给出 `bindSaveableExecuter('@system/hero', ...)` 的绑定位置;删去原先「直接改存档映射更干净」的相反建议。 +- U6-21:`adding-hero-attribute.md` 删除 TS `Record, number>` 的类型检查细节,只保留「内容层是 JSONC,直接写在 `item.jsonc`」与「新属性不需要额外注册」。 +- U6-22:`enemy-context-and-combat.md` 删去 `setFloor` / `resize` / `bindHero` 的手动置脏操作说明,改为「正常换图流程已处理好」;`getComputedEnemy()` 改为「读取时按需刷新」的说法;「局部刷新与全量重建」由 41 行压到 7 行(约 1/6),只保留「日常自动刷新、少数直接改模板才 `markDirty`、新光环需全量重建、`query()` 登记依赖」四点;收束示例与验证示例删去 `requestRefresh`,边界条目同步。 + +**验证:** `node node_modules/prettier/bin/prettier.cjs --write` 三篇文档 → CRLF、单 H1;`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` 退出 0、`build complete in 18.09s`、无 dead link。 diff --git a/docs/advanced/ii/adding-enemy-attribute.md b/docs/advanced/ii/adding-enemy-attribute.md index c8e3f47..a03080f 100644 --- a/docs/advanced/ii/adding-enemy-attribute.md +++ b/docs/advanced/ii/adding-enemy-attribute.md @@ -69,9 +69,7 @@ export interface IEnemyAttr { "money": 0, "exp": 0, "point": 0, - // 攻速以百分数存储:100 是正常速度 "atkSpeed": 100, - // 护甲同样以百分数存储:0 是不减伤 "armor": 0 }, "special": {} diff --git a/docs/advanced/ii/adding-hero-attribute.md b/docs/advanced/ii/adding-hero-attribute.md index beb6e43..1f40c13 100644 --- a/docs/advanced/ii/adding-hero-attribute.md +++ b/docs/advanced/ii/adding-hero-attribute.md @@ -2,7 +2,7 @@ 勇士属性不只是数据表里多一列。一个属性会同时被类型常量、内容配置、修饰器、伤害计算、装备与界面读取;只在接口里加字段,读取处会拿到 `undefined`,参与运算后变成 `NaN`。新增属性真正要补的,是这条链路上的每一处。 -本篇以攻速(`atkSpeed`,每回合造成的伤害按百分比缩放)与护甲(`armor`,按百分比减免最终伤害)为例,走完「改接口 → 补默认值与初始值 → 读取与修饰 → 接入伤害计算」的数据端流程。编辑器新增属性项与状态栏显示属于对应端的实现,本篇只留占位。 +本篇以攻速(`atkSpeed`,每回合造成的伤害按百分比缩放)与护甲(`armor`,按百分比减免最终伤害)为例,走完「改接口 → 补默认值与初始值 → 读取与修饰 → 接入伤害计算」的数据端流程。编辑器负责新增属性项与内容配置,状态栏负责显示;这些都属于对应端的实现,本篇只留占位。 ## 新增属性要改哪五处 @@ -100,47 +100,50 @@ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = { "manamax": 0, "money": 0, "exp": 0, - // 攻速以百分数存储:100 是正常速度 "atkSpeed": 100, - // 护甲同样以百分数存储:0 是不减伤 "armor": 0 } ``` -`core.jsonc` 允许注释,但这两项不是可选项。若只在接口与默认值里加、忘了内容配置,`getBaseAttribute('atkSpeed')` 会返回 `undefined`,`atkSpeed / 100` 随之变成 `NaN`,伤害与回合数都会失真。 +这两项不是可选项。内容配置通常由编辑器维护;样板里的 `.jsonc` 只保留文件开头那段系统前缀注释,加载器会按前缀切开后直接 `JSON.parse`,所以后面不能再写注释。编辑器就位前可以直接改文件,就位后按编辑器的方式补字段。 + +若只在接口与默认值里加、忘了内容配置,`getBaseAttribute('atkSpeed')` 会返回 `undefined`,`atkSpeed / 100` 随之变成 `NaN`,伤害与回合数都会失真。 ### 旧存档迁移 -新游戏只需要改 `core.jsonc`;旧存档不会和它合并。`state.loadState(save, compression)` 会把 `@system/hero` 里的 `attribute.values` 原样交给 `HeroAttribute.loadState`,缺的键不会被补。迁移点在调用 `loadState` 之前,直接改存档映射: +新游戏只需要改内容配置;旧存档不会和它合并。`state.loadState(save, compression)` 会把 `@system/hero` 里的 `attribute.values` 原样交给 `HeroAttribute.loadState`,缺的键不会被补。需要兼容旧档时,**建议实现一个 `ISaveableExecutor` 并绑定到 `@system/hero`**:它在读档完成后执行,正是为这类迁移准备的。 ```ts -import { IHeroAttr, SaveCompression } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; +import { IHeroAttr } from '@user/data-common'; +import { IHeroState } from '@user/data-base'; +import { ISaveableExecutor } 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; +/** + * 读档后补齐旧档缺失的勇士属性键。 + * Executor 在对应可存档对象读档完成后执行,不包在录像禁用范围内。 + */ +export class HeroAttributeSaveExecutor implements ISaveableExecutor< + IHeroState +> { + afterLoad(data: IHeroState): void { + const attribute = data.getModifiableAttribute(); + const values = attribute.toStructured(); - // 旧档没有这两个键;补成与新游戏一致的默认值 - values.atkSpeed ??= 100; - values.armor ??= 0; -} - -/** 读档入口:先迁移,再交给数据端统一读档。 */ -function loadWithMigration( - state: ICoreState, - save: Map -): void { - migrateHeroSave(save); - state.loadState(save, SaveCompression.NoCompression); + // 只在旧档确实缺键时补默认值;set 会顺带重算最终值 + if (!Object.hasOwn(values, 'atkSpeed')) attribute.set('atkSpeed', 100); + if (!Object.hasOwn(values, 'armor')) attribute.set('armor', 0); + } } ``` -也可以给 `'@system/hero'` 绑定一个 `ISaveableExecutor`,在 `afterLoad` 里用 `state.hero.getModifiableAttribute().set('armor', 0)` 补值。但 `afterLoad` 发生在属性读档之后,已经算过一轮最终值,补完还要依赖 `set` 触发重算;直接在 `loadState` 前改映射更干净。 +把执行器绑定在可存档对象注册之后(例如 `initialize` 的 `awake` 里): + +```ts +state.addSaveableContent('@system/hero', state.hero); +state.bindSaveableExecuter('@system/hero', new HeroAttributeSaveExecutor()); +``` + +读档后 `set` 会立刻重算最终值,不需要再手动刷新。新增属性需要存档迁移时,优先用 Executor,而不是在 `loadState` 前后手工改存档映射。 ## 读取与修饰 @@ -398,20 +401,16 @@ export class MainDamageCalculator implements IDamageCalculator< "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` 里。 +内容层是 JSONC,直接写在 `src/content/item.jsonc` 里即可(同样由编辑器维护,开头之外不能写注释)。 装备实例会把这些记录转成 `ValueModifier` 与 `PercentageModifier`,穿戴时挂到勇士属性,卸下时移除。基础攻速 `100` 配上 `percentage.atkSpeed = 0.15`,最终攻速就是 `115`。注意属性本身按百分数存储(`15` 表示 15%),而装备的 `percentage` 是倍率(`0.15` 表示 15%),两者写法不同。 @@ -419,14 +418,15 @@ export class MainDamageCalculator implements IDamageCalculator< ## 编辑器与状态栏 -编辑器需要在英雄属性面板增加攻速与护甲两个输入项;状态栏需要在合适的位置显示它们的当前值。两处都属于对应端的实现,不在本篇范围内: +编辑器需要在英雄属性面板增加攻速与护甲两个输入项,并负责维护 `core.jsonc` 里的内容配置;状态栏需要在合适的位置显示它们的当前值。这些都属于对应端的实现,不在本篇范围内: - `编辑器 · 新增英雄属性项` +- `编辑器 · 内容配置` - `渲染端 · 状态栏` - + -数据端完成后,这两个入口只需要读取 `getFinalAttribute('atkSpeed')` 与 `getFinalAttribute('armor')`。在它们实现之前,先用控制台或调试脚本确认数值正确。 +数据端完成后,编辑器与状态栏只需要读取 `getFinalAttribute('atkSpeed')` 与 `getFinalAttribute('armor')`。在它们实现之前,先用控制台或调试脚本确认数值正确。 ## 怎么验证 diff --git a/docs/advanced/ii/enemy-context-and-combat.md b/docs/advanced/ii/enemy-context-and-combat.md index c69a610..2ebcd37 100644 --- a/docs/advanced/ii/enemy-context-and-combat.md +++ b/docs/advanced/ii/enemy-context-and-combat.md @@ -15,14 +15,7 @@ - `getEnemyByLoc(x, y)` / `getEnemyByLocator(locator)` / `getEnemyLocatorByView(view)`:按位置或视图互查; - `getDamageSystem()` / `getMapDamage()`:取绑定到上下文的伤害系统与地图伤害。 -上下文本身不进入存档。切楼层时由外部调用 `setFloor`;地图尺寸变化时调用 `resize`,它会清空当前上下文中的所有怪物与运行状态,并把 `needUpdate` 置为真。`clear()` 只清空怪物与运行状态(以及无来源全局光环列表),保留转换器、查询、最终效果等注册项;`destroy()` 还会解绑伤害系统、地图伤害并清空所有注册项。 - -**`setFloor` 只改 `floor` 字段,不会把 `needUpdate` 置为真。** 如果只调用 `setFloor`,下一次 `buildup()` 会直接返回,计算后怪物与伤害都不会重建。切楼层时必须同时做一次会置脏的动作: - -- 正常换图流程调用 `resize(新地图宽, 新地图高)`,它会清空旧怪物并置 `needUpdate`,随后重新放入新楼层的怪物; -- 如果尺寸没变、只想让已有视图重算,可以重新 `bindHero(state.hero.getAttribute())`(它也会置 `needUpdate`),或触发任一注册/增删接口,再调用 `buildup()`。 - -不要只写 `setFloor` 就期待上下文刷新。 +上下文本身不进入存档。切楼层与地图尺寸变化由外部维护:`setFloor` 只改 `floor` 字段,`resize` 会清空当前怪物与运行状态,并让下一次 `buildup()` 重建。正常换图流程已经处理好这些,不需要自己手动刷新。`clear()` 只清空怪物与运行状态(以及无来源全局光环列表),保留转换器、查询、最终效果等注册项;`destroy()` 还会解绑伤害系统、地图伤害并清空所有注册项。 ## 三种怪物对象 @@ -30,11 +23,11 @@ - `getBaseEnemy()`:原始模板,只读。它来自怪物数据,是所有计算的起点。 - `getComputedEnemy()`:计算后怪物,只读。光环、查询、最终效果的结果都写在这份副本上;伤害与地图伤害读的也是它。 -- `getModifiableEnemy()`:可修改的原始怪物。它返回的其实是 `getBaseEnemy()` 的可写版本,修改后不会自动同步,必须手动 `markDirty()`。 +- `getModifiableEnemy()`:可修改的原始怪物。它返回的其实是 `getBaseEnemy()` 的可写版本,修改后不会自动同步;这是少数需要手动 `markDirty()` 的情况。 视图内部还有一份「正在计算中的怪物」(`getComputingEnemy()`),只供上下文在构建过程中使用,外部不应调用。 -`getComputedEnemy()` 并不是单纯返回一个字段:它会先调用 `context.requestRefresh(this)`,再返回计算后对象。也就是说,读取计算后怪物本身就是一次「按需刷新」的时机。`reset()` 则把计算后怪物退回原始模板的状态,全量重建开始时会统一调用它: +`getComputedEnemy()` 并不是单纯返回一个字段:读取它本身就会按需刷新这只怪物,然后返回计算后对象。`reset()` 则把计算后怪物退回原始模板的状态,全量重建开始时会统一调用它: ```ts import { IEnemyAttr } from '@user/data-common'; @@ -100,47 +93,13 @@ graph TD ## 局部刷新与全量重建 -全量 `buildup()` 会重算所有怪物。日常更常见的是只改了一只怪物,这时走局部刷新: +全量 `buildup()` 会重算所有怪物。日常不需要手动更新:怪物增删、注册效果、绑定勇士等操作都会自己置脏,读取 `getComputedEnemy()` 或伤害时会自动完成局部刷新。 -- `view.markDirty()` 把视图加入 `dirtyEnemy`,同时让伤害系统删掉这只怪物的缓存; -- `context.requestRefresh(view)` 决定怎么刷新:若 `needTotallyRefresh` 命中该视图,或上下文本身已标记为需要更新,就回到全量 `buildup()`;否则调用 `refreshEnemy(view)`,只重算命中该怪物的光环与查询。 +只有绕过构建流程、直接改原始模板时,才需要 `view.markDirty()`。它把这只怪物加入待刷新集合,同时清掉伤害缓存;之后再读计算后怪物或伤害就会更新。这是少数情况,不要把它当成常态流程。 -`refreshEnemy` 会先 `reset()` 该视图,再按上一次全量构建留下的 `sortedAura` 与特殊查询表逐优先级重算: +局部刷新不会重建光环拓扑:新产生的特殊属性即使能转成光环,也只在开发环境告警(code 101),不会加入排序表。要让新光环参与构建,必须走一次全量 `buildup()`。 -- 对每条 `couldApplySpecial` 的光环重新调用 `applySpecial`,并通过 `refreshSpecialModifier` 就地增删、修改这只怪物的特殊属性;同一优先级下再重跑特殊查询; -- 然后按优先级执行命中的基础光环; -- 最后执行常规查询与最终效果。 - -所以特殊属性与特殊查询都会在这只怪物身上重跑,并不只是基础属性。但它**不重建光环拓扑**:`sortedAura` 与 `convertedAura` 仍是上次 `buildupSpecials()` 的结果。局部刷新期间新产生的特殊属性即使能转换成光环,也只会在开发环境告警(code 101),不会加入排序表;被删除特殊属性对应的旧光环也不会从排序表移除。要让新光环参与构建,必须走一次全量 `buildup()`(通过 `needTotallyRefresh` / `needUpdate` 触发)。 - -`requestedCommonContext` 记录的是「查询过上下文」的怪物:常规查询调用 `query()` 时会登记。局部刷新某只怪物时,上下文会把这些依赖外部状态的怪物一并重算,避免它们读到过期的周围环境。这也是[怪物查询](../../intermediate/enemy-query.md)强调「必须用 `query()` 而不是 `handler.context`」的原因:绕过 `query()` 就不会被登记,周围变化后它不会重算。 - -不能只改字段不刷新。计算后怪物是一份独立副本,伤害系统还会缓存计算结果;改了原始怪物却既不标脏也不申请刷新,后续读到的仍然是旧副本与旧伤害。 - -```ts -import { IEnemyAttr } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; -import { IEnemyView } from '@user/data-system'; - -/** 修改原始怪物后,按正确顺序刷新并重新取伤害。 */ -function modifyAndRefresh( - state: ICoreState, - view: IEnemyView -): void { - // 1. 改原始怪物(getModifiableEnemy 返回的是模板,不会自动同步) - view.getModifiableEnemy().setAttribute('armor', 20); - - // 2. 标脏:加入待刷新集合,并清掉这只怪物的伤害缓存 - view.markDirty(); - - // 3. 申请刷新:没有拓扑变化时只重算这一只 - state.enemyContext.requestRefresh(view); - - // 4. 重新取伤害;此时读到的是新结果 - const info = state.enemyContext.getDamageSystem()?.getDamageInfo(view); - console.log('伤害', info?.damage, '回合', info?.turn); -} -``` +若某只怪物在常规查询里调用了 `query()`,它就会被记为依赖上下文:周围怪物更新时,它也要更新。这正是[怪物查询](../../intermediate/enemy-query.md)强调必须用 `query()` 而不是 `handler.context` 的原因——绕过 `query()` 不会被登记,周围变化后读到的仍是旧值。 ## 伤害系统与缓存 @@ -153,7 +112,7 @@ function modifyAndRefresh( `markDirty(view)` 只删除这一只怪物的缓存,`markAllDirty()` 清空全部缓存。`getCriticalLimit` 只用于临界计算的上界,不参与普通伤害结算;它不会被缓存,也不负责刷新。 -「改了怪物属性,但伤害没变」通常有两个原因:要么改的是原始怪物而没标脏,查询仍在读旧的计算后副本;要么伤害缓存没有被清掉。按上一节的顺序 `markDirty` → `requestRefresh` → `getDamageInfo` 就能排除这两种情况。 +「改了怪物属性,但伤害没变」通常有两个原因:要么改的是原始怪物而没标脏,查询仍在读旧的计算后副本;要么伤害缓存没有被清掉。按 `markDirty` → `getDamageInfo` 的顺序就能排除这两种情况。 光环、查询与最终效果在构建过程中改的是计算后怪物,`buildup()` 结束时已经统一清空缓存,所以它们不需要额外标脏。需要手动标脏的只有「绕过构建流程、直接改原始怪物」的场景。 @@ -242,10 +201,9 @@ function inspectAfterBuildup( const before = ctx.getDamageSystem()?.getDamageInfo(view); console.log('构建后伤害', before?.damage, '回合', before?.turn); - // 直接改模板,再走局部刷新 + // 直接改模板后标脏;再次读取时会自动完成局部刷新 view.getModifiableEnemy().setAttribute('armor', 20); view.markDirty(); - ctx.requestRefresh(view); const after = ctx.getDamageSystem()?.getDamageInfo(view); console.log('刷新后伤害', after?.damage, '回合', after?.turn); @@ -281,7 +239,6 @@ function verifyBuildup(state: ICoreState, view: IEnemyView): void { // 修改模板护甲并标脏,缓存应失效 view.getModifiableEnemy().setAttribute('armor', 50); view.markDirty(); - ctx.requestRefresh(view); const second = damageSystem?.getDamageInfo(view); console.log('修改后伤害', second?.damage); @@ -300,8 +257,8 @@ function verifyBuildup(state: ICoreState, view: IEnemyView): void { - **未绑定勇士时 `buildup()` 会告警并返回**:光环与最终效果可能读取勇士属性,构建前必须 `bindHero`。`initializeEnemy` 已经绑定过。 - **`needUpdate` 为假时 `buildup()` 直接返回**:没有变化就不会重算。注册效果、放置怪物、绑定勇士都会把它置为真。 - **`getModifiableEnemy()` 的修改不会自动同步**:它返回原始模板,改完必须 `markDirty()`。 -- **`needTotallyRefresh` 表示拓扑变化**:特殊属性增删导致光环集合变化时,局部刷新会升级为全量构建。不要在这种情况下强行只改一只。 -- **不要在高频循环里反复全量 `buildup()`**:全量构建会重置并重算所有怪物,开销随怪物数量增长;单只变化优先用 `markDirty` + `requestRefresh`。 +- **新光环需要全量重建**:特殊属性增删会改变光环集合,局部刷新处理不了,下一次读取会回到全量 `buildup()`。 +- **不要在高频循环里反复全量 `buildup()`**:全量构建会重置并重算所有怪物,开销随怪物数量增长;单只变化只需 `markDirty()`,读取时会按需局部刷新。 - **`getComputingEnemy()` 是内部接口**:它暴露正在构建中的可写怪物,外部调用会绕过刷新与脏标记。 - **查询效果不是内置内容**:`initializeEnemy` 只注册光环转换器、最终效果、战斗脚本与钩子,没有注册任何查询。查询需要自己注册后才会参与构建。 - **`buildupSpecials` 在没有光环时提前返回**:它先加入全局光环、转换特殊属性光环,然后执行 `if (sortedAura.size === 0) return;`。如果只注册了特殊查询而场上没有任何光环,这个提前返回会让特殊查询也不执行。需要特殊查询时,至少保证存在一条光环(全局或转换出的都算)。 diff --git a/docs/advanced/ii/pathfinding.md b/docs/advanced/ii/pathfinding.md index aabef70..e882d7d 100644 --- a/docs/advanced/ii/pathfinding.md +++ b/docs/advanced/ii/pathfinding.md @@ -146,28 +146,25 @@ function replacePathCost(state: ICoreState, cost: PathCostFunction): void { 判断空格要用 `block.tile === 0`,不要用 `block.static === null`:图层按坐标缓存静态图块实例,空地上 `layer.getTile()` 同样返回实例。 -返回值必须是有限数或 `Infinity`。`Infinity` 是合法的「不可进入」代价;`NaN` 与负数会被视为非法,告警后回退为 `1`。 +返回值应是非负的有限数。`NaN` 与负数会被视为非法,告警后回退为 `1`。**不可进入的点由通行谓词控制,不要用 `Infinity` 去标记不可达**——那不属于损失函数的职责,也不是标准用法。 ```ts import { PathCostFunction } from '@user/data-system'; /** 沼泽图块的数字,按自己的内容配置替换 */ const SWAMP_TILE = 21; -/** 墙图块的数字 */ -const WALL_TILE = 1; /** - * 进入沼泽花费 3,普通地面花费 1,墙不可进入。 - * 代价只影响选路,不改变一步能否通行;通行仍由 usePassPredicate 决定。 + * 进入沼泽花费 3,普通地面花费 1。 + * 墙等不可进入的格子交给通行谓词排除,损失函数不会看到它们。 */ export const terrainCost: PathCostFunction = block => { - if (block.tile === WALL_TILE) return Infinity; if (block.tile === SWAMP_TILE) return 3; return 1; }; ``` -损失函数参与的是「进入节点的总代价」,起点自身不计费。目标格的代价也会被计入,所以给目标格设置 `Infinity` 会让它变成不可达。若某种图块在通行谓词里已经禁止进入,损失函数根本不会看到它;两者分工不同,不要只改其中一边就期待通行判定变化。 +损失函数参与的是「进入节点的总代价」,起点自身不计费,目标格的代价同样计入。若某种图块在通行谓词里已经禁止进入,损失函数根本不会看到它;两者分工不同,不要只改其中一边就期待通行判定变化。 ## 自定义瞬移策略 @@ -313,7 +310,7 @@ async function verifyPathfinding( - **谓词的 `shouldHit` 会标记终端节点**:终端节点只能作为路径终点,不能作为中间点;目标点不可达时优先检查 `canPass` 与 `shouldHit` 的组合。 - **换楼层要重新绑定**:`useMapLayer` 绑定的是具体图层对象,旧楼层失效后必须换成新楼层的图层。 - **图层必须属于同一个数据端对象**:`useMapLayer` 会校验图层来源,不属于当前状态时告警并忽略这次绑定。 -- **损失函数的非法返回值会回退**:`NaN` 与负数告警后按 `1` 处理;`Infinity` 合法,表示不可进入。 +- **损失函数的非法返回值会回退**:`NaN` 与负数告警后按 `1` 处理;不可达由通行谓词控制,不要用 `Infinity` 标记。 - **策略只影响 `teleportTo`**:`moveTo` 永远逐步移动,不会询问回退策略。 - **瞬移会跳过途经格**:途经的踩踏事件不会触发;需要补触发时用 `onBeforeTeleportTo` 或 `onAfterTeleportTo`。 - **`onAfterTeleportTo` 不是瞬移专用**:它挂在移动控制器结束回调上,逐步移动结束时同样会触发。