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 c18dc51..4daf692 100644 --- a/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-27-USER-REVIEW.md @@ -90,7 +90,7 @@ | U7-07 | `NoPath` 是正常输出,不是错误;被怪物挡着去不了很正常。当前「NoPath 处理建议」不对,只需说明「找不到可行路径」,无需处理 | `pathfinding.md` | | U7-08 | 假定编辑器已完成:不要写「编辑器就位前改文件、就位后用编辑器」。需要编辑器的地方只写「需要编辑器」标识,不讲没有编辑器时怎么做。所有 `.jsonc` 都由编辑器负责,涉及部分先写占位(长期规范 D-73) | 全部 | -**状态:执行中**(批次 D 已完成 U7-01、U7-03、U7-04;其余待执行) +**状态:第 2 轮已全部处理**(U7-01..U7-08) ### 批次 D(U7-01、U7-03、U7-04)执行记录(2026-10-22) @@ -102,3 +102,13 @@ **验证:** `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 19.04s`、无 dead link。 +### 批次 E(U7-02、U7-05、U7-06、U7-07、U7-08)执行记录(2026-10-22) + +- U7-02:`custom-range.md` 删去「范围对象提到模块级复用;引擎风格不建议在调用处内联 new」与「同样复用模块级范围实例,不要在调用处内联 new」两处说明性注释;代码仍保留模块级常量写法。 +- U7-05:`custom-replay-command.md` 删去位宽整节(`Uint8` / `Uint16` 切换、`setCommandWidth` 示例与格式变更说明),改为一句「指令码默认能容纳 255 个,99.9% 的项目都用不完;若确实可能超过,参考 `高级 III · 录像格式与指令码位宽`」;边界条目同步删除。 +- U7-06:选择框只保留为被动步的概念示例,并注明一般由样板内置、作者通常不需要自己实现;代码示例恢复为技能示例(`UseSkill` / `SkillEffect`、`ReplayUseSkill`、`ReplaySkillEffect`、`DataSkillPlugin`、`SkillSystem`),注册仍在插件 `awake`,`@shouldReplay` 仍在 `apply`。 +- U7-07:`pathfinding.md` 的 `NoPath` 由「错误 / 先检查通行谓词与图层」改为「找不到可行路径,例如被怪物挡住;正常结果,不需要处理」;验证段与边界条目同步。 +- U7-08/D-73:`adding-hero-attribute.md` 与 `adding-enemy-attribute.md` 删除「编辑器就位前 / 接入前 / 在它们实现之前 / 尚未接入」等过渡说法;`core.jsonc`、`enemy.jsonc`、默认模板与装备内容的示例改为 `> **需要编辑器**:...` 占位;编辑器与状态栏 / 图鉴章节只保留「需要编辑器」标识。 + +**验证:** `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 14.56s`、无 dead link。 + diff --git a/docs/advanced/ii/adding-enemy-attribute.md b/docs/advanced/ii/adding-enemy-attribute.md index a03080f..909e12b 100644 --- a/docs/advanced/ii/adding-enemy-attribute.md +++ b/docs/advanced/ii/adding-enemy-attribute.md @@ -2,7 +2,7 @@ 怪物属性和勇士属性一样,不只是数据表里多一列。属性对象要在接口、默认模板、内容数据与伤害计算之间保持一致;只在接口里加字段,读取处会拿到 `undefined`,参与运算后变成 `NaN`。新增属性真正要补的,是这条链路上的每一处。 -本篇以攻速(`atkSpeed`,怪物每回合的伤害按百分比缩放)与护甲(`armor`,怪物受到的伤害按百分比减免)为例,走完「改接口 → 补数据 → 读取与修改 → 接入伤害、光环、查询与最终效果」的数据端流程。它与[新增勇士属性](./adding-hero-attribute.md)构成对照:勇士属性偏重修饰器与装备,怪物属性偏重上下文构建时的计算顺序。编辑器新增属性项与怪物图鉴属于对应端的实现,本篇只留占位。 +本篇以攻速(`atkSpeed`,怪物每回合的伤害按百分比缩放)与护甲(`armor`,怪物受到的伤害按百分比减免)为例,走完「改接口 → 补数据 → 读取与修改 → 接入伤害、光环、查询与最终效果」的数据端流程。它与[新增勇士属性](./adding-hero-attribute.md)构成对照:勇士属性偏重修饰器与装备,怪物属性偏重上下文构建时的计算顺序。编辑器新增属性项与怪物图鉴属于对应端的实现,需要编辑器的部分只留占位。 ## 怪物属性在哪里 @@ -31,9 +31,9 @@ export interface IEnemyAttr { 具体数值来自 `IEnemyRawData.attribute`:怪物模板由 `num`(图块数字)、`attribute`(属性对象)与 `special`(特殊属性)组成。默认模板写在 `src/content/defaults/enemy.jsonc`,实际内容写在 `src/content/enemy.jsonc`。内容文件里每个怪物条目都有一份自己的 `attribute`,不会与默认模板合并。 -默认模板在运行时不会被用来补全内容条目:加载器只是把它以 `enemy-default` 的标识读进配置表,供编辑器/校验端新建条目、核对字段形状;`enemyStore` 构建怪物时只读 `enemy.jsonc` 里的条目。因此它既不是新游戏内容的兜底值,也不会参与读档迁移。编辑器接入前,可以把它当作字段清单与类型示例。 +默认模板在运行时不会被用来补全内容条目:加载器只是把它以 `enemy-default` 的标识读进配置表,供编辑器/校验端新建条目、核对字段形状;`enemyStore` 构建怪物时只读 `enemy.jsonc` 里的条目。因此它既不是新游戏内容的兜底值,也不会参与读档迁移。 -`guard` 与其他字段不同:它是运行时由支援光环写入的集合(`GuardAura.apply` 会把来源怪物视图 `add` 进去),不是内容文件里手填的普通数值。改接口时不要把它当成内容字段,也不要在 `enemy.jsonc` 里补它。 +`guard` 与其他字段不同:它是运行时由支援光环写入的集合(`GuardAura.apply` 会把来源怪物视图 `add` 进去),不是内容文件里手填的普通数值。改接口时不要把它当成内容字段,也不要通过编辑器给它填值。 ## 改接口 @@ -56,53 +56,19 @@ export interface IEnemyAttr { ## 补数据 -接口增加字段后,默认模板与每一条怪物内容都要补齐。默认模板 `src/content/defaults/enemy.jsonc` 的 `attribute` 是单个对象: +接口增加字段后,默认模板与每一条怪物内容都要补齐。默认模板 `src/content/defaults/enemy.jsonc` 的 `attribute` 是单个对象。 -```jsonc -{ - "num": "@required", - "attribute": { - "name": "新怪物", - "hp": 0, - "atk": 0, - "def": 0, - "money": 0, - "exp": 0, - "point": 0, - "atkSpeed": 100, - "armor": 0 - }, - "special": {} -} -``` +> **需要编辑器**:在默认怪物模板的 `attribute` 中补齐 `atkSpeed` 与 `armor`。 -`src/content/enemy.jsonc` 是一个数组,每个怪物条目也要补同样的两个键: +`src/content/enemy.jsonc` 是一个数组,每个怪物条目也要补同样的两个键。 -```jsonc -[ - { - "num": 130, - "attribute": { - "name": "怪王", - "hp": 100, - "atk": 120, - "def": 0, - "money": 10, - "exp": 0, - "point": 0, - "atkSpeed": 100, - "armor": 0 - }, - "special": {} - } -] -``` +> **需要编辑器**:在每条怪物内容的 `attribute` 中补齐 `atkSpeed` 与 `armor`。 -内容对象不会与默认模板合并,缺键就是缺键。读取到 `undefined` 后,`armor / 100` 与 `atkSpeed / 100` 都会变成 `NaN`,伤害与回合数随之失真。新增属性后,最好全局搜索一遍 `"attribute"`,确认每个模板入口都补齐。 +内容对象不会与默认模板合并,缺键就是缺键。读取到 `undefined` 后,`armor / 100` 与 `atkSpeed / 100` 都会变成 `NaN`,伤害与回合数随之失真。新增属性后,确认编辑器里的默认模板与每条怪物内容都补齐了这两个键。 ### 旧存档迁移 -新游戏补 `enemy.jsonc` 与默认模板即可;旧存档不会自动补键。`state.loadState(save, compression)` 读取 `@system/enemy` 时,会把每个脏模板的 `attrs` 原样交给怪物模板,缺失的键不会从内容配置或默认模板回填。迁移点在调用 `loadState` 之前: +新游戏通过编辑器补齐怪物内容与默认模板即可;旧存档不会自动补键。`state.loadState(save, compression)` 读取 `@system/enemy` 时,会把每个脏模板的 `attrs` 原样交给怪物模板,缺失的键不会从内容配置或默认模板回填。迁移点在调用 `loadState` 之前: ```ts import { IEnemyAttr, SaveCompression } from '@user/data-common'; @@ -340,7 +306,7 @@ export class PackAtkSpeedQuery implements IEnemyCommonQueryEffect< state.enemyContext.registerCommonQueryEffect(40, new PackAtkSpeedQuery()); ``` -注册只决定「拥有 code 40 的怪物会执行哪条查询」,**不会让任何怪物自动拥有 40**。常规查询的执行条件是怪物身上有对应特殊属性:在 `src/content/enemy.jsonc` 的 `special` 里写 `"40": ...`,或在运行时用 `enemy.addSpecial(...)` 添加。只注册效果、不给怪物 code,查询一次也不会触发。 +注册只决定「拥有 code 40 的怪物会执行哪条查询」,**不会让任何怪物自动拥有 40**。常规查询的执行条件是怪物身上有对应特殊属性:在编辑器里给怪物挂上特殊属性 40,或在运行时用 `enemy.addSpecial(...)` 添加。只注册效果、不给怪物 code,查询一次也不会触发。 查询效果需要 `registerCommonQueryEffect` 注册;引擎的 `src/data/enemy/initialize.ts` **没有注册任何查询实现**,不注册就不会执行。如果要增删或改写目标拥有的特殊属性,应使用特殊查询 `IEnemySpecialQueryEffect`,写法见[怪物查询](../../intermediate/enemy-query.md),本篇不重复。 @@ -378,12 +344,12 @@ state.enemyContext.registerFinalEffect(new ArmorFinalEffect()); 编辑器需要在怪物属性面板增加攻速与护甲两个输入项;怪物图鉴需要显示它们的当前值。两处都属于对应端的实现,不在本篇范围内: -- `编辑器 · 新增怪物属性项` +- `需要编辑器 · 新增怪物属性项` - `怪物图鉴` -数据端完成后,这两个入口只需要读取计算后怪物的 `atkSpeed` 与 `armor`。在它们实现之前,先用控制台或调试脚本确认数值正确。 +数据端完成后,这两个入口只需要读取计算后怪物的 `atkSpeed` 与 `armor`。 ## 怎么验证 @@ -451,7 +417,7 @@ function verifyEnemyAttribute( ## 边界与注意 -- **接口、内容条目、默认模板都要补键**:接口决定类型,内容条目决定运行时取值,默认模板只供编辑器/校验对照。内容对象不会与默认模板合并,运行时缺键就是 `undefined`。 +- **接口、内容条目、默认模板都要补键**:接口决定类型,内容条目决定运行时取值,默认模板只供编辑器/校验对照;内容条目与默认模板由编辑器维护。内容对象不会与默认模板合并,运行时缺键就是 `undefined`。 - **`guard` 不要写进内容**:它是运行时由支援光环维护的集合,手填会与运行时行为冲突。 - **百分数统一用整数存储**:`20` 表示 20%,计算时再除以 `100`;不要一处存 `0.2`、另一处存 `20`。 - **新增字段会进入怪物存档**:读旧档时缺失的键不会自动补齐,需要按上文「旧存档迁移」在 `state.loadState` 之前给默认值。 @@ -459,6 +425,6 @@ function verifyEnemyAttribute( - **直接改原始怪物要标脏**:`getModifiableEnemy()` 的修改不会自动同步,必须 `markDirty()` 后调用 `state.enemyContext.requestRefresh(view)`,再重新读取计算后怪物与伤害。 - **护甲要夹在有效区间**:计算器先把护甲夹到 `0`–`100`;夹紧后 `≥ 100` 时勇士每回合伤害为 `0`、怪物无法被击败,负值会被夹到 `0`,不会放大伤害。 - **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。 -- **编辑器与图鉴尚未接入**:在对应端实现之前,不要假设属性会自动出现在界面上。 +- **编辑器与图鉴读取计算后怪物**:它们不属于本篇的数据端流程。 返回[高级 II 导览](./index.md)。 diff --git a/docs/advanced/ii/adding-hero-attribute.md b/docs/advanced/ii/adding-hero-attribute.md index 1f40c13..436b619 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`,按百分比减免最终伤害)为例,走完「改接口 → 补默认值与初始值 → 读取与修饰 → 接入伤害计算」的数据端流程。编辑器负责新增属性项与内容配置,状态栏负责显示;这些都属于对应端的实现,需要编辑器的部分只留占位。 ## 新增属性要改哪五处 @@ -10,7 +10,7 @@ | -------- | ---------------------------------------- | -------------------------------------- | | 类型 | `packages-user/data-common/src/types.ts` | 在 `IHeroAttr` 中增加字段 | | 默认值 | `packages-user/data-state/src/shared.ts` | 补齐 `HERO_DEFAULT_ATTRIBUTE` | -| 初始值 | `src/content/core.jsonc` | 补齐 `init.hero.attribute` | +| 初始值 | `src/content/core.jsonc` | 由编辑器补齐 `init.hero.attribute` | | 计算接入 | `src/data/enemy/calculator.ts` 等读取处 | 让属性真正影响伤害或其他运算 | | 显示 | 编辑器与渲染端 | 编辑器属性项、状态栏显示(本篇不实现) | @@ -86,26 +86,11 @@ export const HERO_DEFAULT_ATTRIBUTE: IHeroAttr = { }; ``` -实际进入游戏的初始值来自内容配置 `src/content/core.jsonc` 的 `init.hero.attribute`,由 `src/data/initialize.ts` 的 `afterLoad` 调用 `state.hero.createAttribute(coreConfig.init.hero.attribute)`,直接交给 `HeroAttribute` 构造。它不会与默认值合并,缺键就是缺键;`HERO_DEFAULT_ATTRIBUTE` 不会在运行时兜底: +实际进入游戏的初始值来自内容配置 `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": { - "name": "勇士", - "level": 0, - "hp": 10000, - "atk": 100, - "def": 100, - "mdef": 0, - "mana": 0, - "manamax": 0, - "money": 0, - "exp": 0, - "atkSpeed": 100, - "armor": 0 -} -``` +> **需要编辑器**:在 `core.jsonc` 的 `init.hero.attribute` 中补齐 `atkSpeed` 与 `armor`。 -这两项不是可选项。内容配置通常由编辑器维护;样板里的 `.jsonc` 只保留文件开头那段系统前缀注释,加载器会按前缀切开后直接 `JSON.parse`,所以后面不能再写注释。编辑器就位前可以直接改文件,就位后按编辑器的方式补字段。 +这两项不是可选项。`core.jsonc` 的 `init.hero.attribute` 由编辑器维护,必须补齐这两个键。 若只在接口与默认值里加、忘了内容配置,`getBaseAttribute('atkSpeed')` 会返回 `undefined`,`atkSpeed / 100` 随之变成 `NaN`,伤害与回合数都会失真。 @@ -390,27 +375,9 @@ export class MainDamageCalculator implements IDamageCalculator< ## 与装备和道具的关系 -装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,内容层的装备定义就能直接写(`category: 4` 即 `ItemCategory.Equipment`): +装备的 `value` 与 `percentage` 是按属性名索引的记录,新属性不需要额外注册。只要 `IHeroAttr` 里有这个数值字段,装备定义就能直接配置(`category: 4` 即 `ItemCategory.Equipment`)。 -```jsonc -{ - "num": 120, - "category": 4, - "name": "疾风剑", - "text": "攻击速度 +15%", - "hideInToolbox": false, - "equip": { - "slots": ["weapon"], - "animate": "equip", - "value": {}, - "percentage": { "atkSpeed": 0.15 }, - "loadEvent": null, - "unloadEvent": null - } -} -``` - -内容层是 JSONC,直接写在 `src/content/item.jsonc` 里即可(同样由编辑器维护,开头之外不能写注释)。 +> **需要编辑器**:在装备内容里把 `percentage.atkSpeed` 设为 `0.15`。 装备实例会把这些记录转成 `ValueModifier` 与 `PercentageModifier`,穿戴时挂到勇士属性,卸下时移除。基础攻速 `100` 配上 `percentage.atkSpeed = 0.15`,最终攻速就是 `115`。注意属性本身按百分数存储(`15` 表示 15%),而装备的 `percentage` 是倍率(`0.15` 表示 15%),两者写法不同。 @@ -420,17 +387,17 @@ export class MainDamageCalculator implements IDamageCalculator< 编辑器需要在英雄属性面板增加攻速与护甲两个输入项,并负责维护 `core.jsonc` 里的内容配置;状态栏需要在合适的位置显示它们的当前值。这些都属于对应端的实现,不在本篇范围内: -- `编辑器 · 新增英雄属性项` -- `编辑器 · 内容配置` +- `需要编辑器 · 新增英雄属性项` +- `需要编辑器 · 内容配置` - `渲染端 · 状态栏` -数据端完成后,编辑器与状态栏只需要读取 `getFinalAttribute('atkSpeed')` 与 `getFinalAttribute('armor')`。在它们实现之前,先用控制台或调试脚本确认数值正确。 +数据端完成后,编辑器与状态栏只需要读取 `getFinalAttribute('atkSpeed')` 与 `getFinalAttribute('armor')`。 ## 怎么验证 -先准备一只属性固定的怪物,避免光环、支援或随机因素干扰。把内容配置里的 `atkSpeed` 设为 `100`、`armor` 设为 `0`,读取最终属性,再穿上一件 +15% 攻速的装备,最后观察伤害变化: +先准备一只属性固定的怪物,避免光环、支援或随机因素干扰。在编辑器里把内容配置的 `atkSpeed` 设为 `100`、`armor` 设为 `0`,读取最终属性,再穿上一件 +15% 攻速的装备,最后观察伤害变化: ```ts import { IEnemyAttr } from '@user/data-common'; @@ -462,7 +429,7 @@ function verifyHeroAttribute( - 穿上 +15% 攻速的装备后,最终攻速变为 `115`,同一只怪物的战斗回合数下降; - 把护甲调高后,`damage` 随护甲上升而下降,`turn` 不变。 -若最终攻速是 `NaN`,先检查 `core.jsonc` 的 `init.hero.attribute` 是否补了键;`HERO_DEFAULT_ATTRIBUTE` 只影响类型检查,补了它也不会在运行时兜底。若装备穿上后数值不变,检查装备定义的键名是否与接口字段完全一致。 +若最终攻速是 `NaN`,先检查编辑器里 `core.jsonc` 的 `init.hero.attribute` 是否补了键;`HERO_DEFAULT_ATTRIBUTE` 只影响类型检查,补了它也不会在运行时兜底。若装备穿上后数值不变,检查装备定义的键名是否与接口字段完全一致。 ## 边界与注意 @@ -474,7 +441,7 @@ function verifyHeroAttribute( - **`save = true` 的修饰器要注册类型**:读档按 `type` 查构造器,未注册的修饰器会被静默跳过,数值看起来「读档后消失了」。 - **护甲要夹在有效区间**:计算器先把护甲夹到 `0`–`100`;夹紧后 `≥ 100` 时最终伤害为 `0`,不是治疗,负值会被夹到 `0`,不会放大伤害。 - **攻速改动会影响回合数**:凡是依赖 `turn` 的逻辑(支援、先攻、状态持续时间)都要跟着复核,不能只改伤害数字。 -- **编辑器与状态栏尚未接入**:在对应端实现之前,不要假设属性会自动出现在界面上。 +- **编辑器与状态栏读取最终属性**:它们不属于本篇的数据端流程。 渲染端如何展示这两个属性不在本篇范围内。 diff --git a/docs/advanced/ii/custom-range.md b/docs/advanced/ii/custom-range.md index 8b21d20..9b8f131 100644 --- a/docs/advanced/ii/custom-range.md +++ b/docs/advanced/ii/custom-range.md @@ -157,7 +157,6 @@ export class RingRange extends BaseRange { ```ts // RingRange 即上一节定义的自定义范围类 -// 范围对象提到模块级复用;引擎风格不建议在调用处内联 new const ringRange = new RingRange(); const loc = state.hero.getLocation(); @@ -206,7 +205,6 @@ function collectRing( `scanRange` 只返回范围内的怪物,所以验证要摆好怪物,而不是只看格子。先以勇士为中心,在中心、曼哈顿距离 `1`、距离 `2` 的位置各放一只测试怪,再执行: ```ts -// 同样复用模块级范围实例,不要在调用处内联 new const ringRange = new RingRange(); state.enemyContext.bindHero(state.hero.getAttribute()); diff --git a/docs/advanced/ii/custom-replay-command.md b/docs/advanced/ii/custom-replay-command.md index b3271f8..aac3fcc 100644 --- a/docs/advanced/ii/custom-replay-command.md +++ b/docs/advanced/ii/custom-replay-command.md @@ -2,7 +2,7 @@ 高级 I 的[录像一致性](../replay-consistency.md)讲的是「已有行为该不该记」,回答方式是给方法加 `@shouldReplay`。还有一种情况它覆盖不到:行为本身不在引擎里,录像里没有对应的指令码,回放时根本无从执行。这时需要新增一条录像指令。 -本篇讲一条自定义指令从指令码、指令对象、注册到记录的完整路径,并用选择框讲清主动步与被动步怎么配合。新增指令码会修改 `packages-user` 下的引擎内容,升级时要预期合并成本;如果只是给已有行为补标记,回到[录像一致性](../replay-consistency.md)即可,不必新增指令。 +本篇讲一条自定义指令从指令码、指令对象、注册到记录的完整路径,并用技能给出完整示例。主动步与被动步的配合会先用选择框说明。新增指令码会修改 `packages-user` 下的引擎内容,升级时要预期合并成本;如果只是给已有行为补标记,回到[录像一致性](../replay-consistency.md)即可,不必新增指令。 ## 什么时候需要自定义指令 @@ -22,30 +22,18 @@ export const enum ReplayCode { // ...已有录像码省略 - /** 打开选择框(本篇新增) */ - OpenChoice, - /** 玩家在选择框中的选择,紧跟主动步的被动步(本篇新增) */ - ChoiceSelect + /** 使用技能(本篇新增) */ + UseSkill, + /** 技能特效的被动步,紧跟主动步(本篇新增) */ + SkillEffect } ``` -本篇新增两个码:`OpenChoice` 是主动步,`ChoiceSelect` 是紧跟其后的被动步;两者都要在 `registerCommand` 里注册,否则沙盒按指令码查表时会中止播放。 +本篇新增两个码:`UseSkill` 是主动步,`SkillEffect` 是紧跟其后的被动步;两者都要在 `registerCommand` 里注册,否则沙盒按指令码查表时会中止播放。 新增一项意味着修改 `packages-user` 的引擎内容,而不是只改 `src`。`registerCommand` 收的是普通 `number`,跳过枚举直接写数字也能注册;但引擎之后分配新码时可能撞号,所以正式做法仍然是在枚举末尾追加。 -指令码的取值还受位宽限制。默认的 `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 并被截断。确定要扩容后就不要来回切换。 +指令码默认能容纳 255 个,99.9% 的项目都用不完,本篇不展开位宽细节。若指令码数量确实可能超过 255,参考 `高级 III · 录像格式与指令码位宽`。 ## 指令对象 @@ -69,7 +57,7 @@ function useUint16ReplayCodes(state: ICoreState): void { `ReplayCommandType.Active` 的指令自己执行逻辑,实现 `execute` 即可,是最常见的形式。 -被动步不执行自己的逻辑,它的参数要由知道「什么时候该用它」的那一方来取。最典型的例子是选择框:玩家打开选择框并选择一项。录像无法提前知道这里会弹出什么选择框、有哪些选项,只能等选择框出现后,由选择框系统自己读录像里的选择。 +被动步不执行自己的逻辑,它的参数要由知道「什么时候该用它」的那一方来取。最典型的例子是选择框:玩家打开选择框并选择一项。录像无法提前知道这里会弹出什么选择框、有哪些选项,只能等选择框出现后,由选择框系统自己读录像里的选择。选择框一般由样板内置,作者通常不需要自己实现;这里只用它说明被动步的场景。 回放时,主动步负责让选择框出现并等它结束;选择框系统在框出现后取被动步,用录下来的选择代替玩家点击,然后调用 `next` 推进。这样,选择框什么时候弹、弹出什么,都由当时运行的界面决定,录像只负责回答「玩家选了什么」。 @@ -95,83 +83,30 @@ export interface IReplayPassiveHandler { 忘记 `next` 不会让整条录像停住,只是这一步没有读到下一个录像。主动步返回后,沙盒发现这条被动录像没被消费,会告警并跳过它,然后继续播放后面的录像。 ::: -下面这个选择框系统同时处理正常游玩与回放: +回放链路可以拆成三步: -```ts -import { - ReplayCode, - ReplayCommandResult, - shouldReplay -} from '@user/data-common'; -import { ICoreState } from '@user/data-state'; +1. 玩家入口先 `record` 主动指令,再紧接着 `record` 被动指令——两个录像步必须相邻: -/** 选择框服务:由作者自己的选择框系统实现,引擎不内置 */ -export interface IChoiceService { - /** 打开选择框并返回选择结果;回放时返回录像里的选择 */ - open(choiceId: number): Promise; -} + ```ts + this.state.replaySystem.record(ReplayCode.UseSkill, skillId, x, y); + // 紧随其后的被动步,给渲染端播放技能特效 + this.state.replaySystem.record(ReplayCode.SkillEffect, skillId); + ``` -/** 选择框系统:正常游玩时等玩家点击,回放时读被动录像 */ -export class ChoiceSystem implements IChoiceService { - constructor(private readonly state: ICoreState) {} +2. 回放时,主动指令的 `execute` 在自己的执行期间取被动 handler: - async open(choiceId: number): Promise { - if (!this.canOpen(choiceId)) { - // 不可用时直接提示并返回,不记录录像 - this.state.emitTip('当前不能打开这个选择框'); - return -1; - } - - // 先让选择框出现;数据端只给出选项 id,显示交给渲染端 - this.show(choiceId); - - // 有沙盒说明正在回放,此时没有玩家点击,由选择框系统自己读被动录像 - const sandbox = this.state.replaySystem.sandbox; - if (sandbox) { - const passive = sandbox.getPassive(); - if (!passive) { - // 主动步预期有被动步,但下一步不是被动步;getPassive 已经报错 - return -1; - } - - // 被动步的参数就是玩家当时的选择 - const [selected] = passive.step.params as [number]; - // 用完必须 next,否则再次 getPassive 还是这一步 - await passive.next(ReplayCommandResult.Success); - this.apply(choiceId, selected); - return selected; - } - - // 正常游玩:等待玩家点击 - const selected = await this.waitForPlayer(choiceId); - // 主动步与紧跟其后的被动步必须相邻 - this.state.replaySystem.record(ReplayCode.OpenChoice, choiceId); - this.state.replaySystem.record(ReplayCode.ChoiceSelect, selected); - this.apply(choiceId, selected); - return selected; + ```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); } + ``` - /** 真正修改状态的地方,@shouldReplay 写在这里 */ - @shouldReplay('Applying a choice should be replayed.') - apply(choiceId: number, selected: number): void { - // ...把选择结果写进游戏状态 - } - - private canOpen(choiceId: number): boolean { - // ...判断当前能否打开这个选择框 - return true; - } - - private show(choiceId: number): void { - // ...渲染端弹出选择框 - } - - private waitForPlayer(choiceId: number): Promise { - // ...等待玩家点击并返回选项索引 - return Promise.resolve(0); - } -} -``` +3. `next` 推进读取流并触发 `onStep`;重复调用 `getPassive()` 在推进前会拿到同一个 handler / 同一步。 主动步的 `execute` 返回值决定播放是否继续;被动步的 `next` 只负责推进读取。通常应在 `await passive.next(...)` 之后返回 `Success`。 @@ -181,11 +116,11 @@ export class ChoiceSystem implements IChoiceService { import { ReplayCommandResult, ReplayCommandType } from '@user/data-common'; import { BaseReplayCommand } from './commands'; -/** 玩家在选择框中的选择:被动步 */ -export class ReplayChoiceSelect extends BaseReplayCommand { +/** 技能特效的被动步:参数由主动步通过 getPassive 取走。 */ +export class ReplaySkillEffect extends BaseReplayCommand { readonly type: ReplayCommandType = ReplayCommandType.Passive; - protected readonly name: string = 'choice-select'; + protected readonly name: string = 'skill-effect'; protected readonly paramTypes: readonly string[] = ['number']; async wrappedExecute(): Promise { @@ -195,7 +130,7 @@ export class ReplayChoiceSelect extends BaseReplayCommand { } ``` -渲染端的播放表现按渲染端教程处理,本篇只写数据端。 +渲染端的播放表现按渲染端教程处理,本篇只写数据端。 ## 用基类减少样板 @@ -205,7 +140,7 @@ export class ReplayChoiceSelect extends BaseReplayCommand { - `paramTypes`:参数类型名数组,取值只能是 `'number'`、`'string'`、`'boolean'`、`'bigint'`; - `wrappedExecute(step)`:真正的执行逻辑,不需要再校验参数类型。 -下面这条 `ReplayOpenChoice`:参数为 `[number choiceId]`,`open` 返回负数时判定为失败。 +下面是一条 `ReplayUseSkill`:参数为 `[number skillId, number x, number y]`,技能不可用时返回 `Failed`。 ```ts import { @@ -215,21 +150,32 @@ import { } from '@user/data-common'; import { IStateSystem } from '@user/data-system'; import { BaseReplayCommand } from './commands'; -import { IChoiceService } from './choice'; + +/** 技能服务:由作者自己的技能系统实现,引擎不内置 */ +export interface ISkillService { + /** 判断技能能否在目标点使用 */ + canUse(skillId: number, x: number, y: number): boolean; + /** 执行技能效果;回放时也走这里,注意不要在这里写录像 */ + apply(skillId: number, x: number, y: number): void; +} /** - * 打开选择框。 - * 参数:选择框 id。 + * 使用技能。 + * 参数顺序必须与 paramTypes 一致:技能 id、目标横坐标、目标纵坐标。 */ -export class ReplayOpenChoice extends BaseReplayCommand { +export class ReplayUseSkill extends BaseReplayCommand { readonly type: ReplayCommandType = ReplayCommandType.Active; - protected readonly name: string = 'open-choice'; - protected readonly paramTypes: readonly string[] = ['number']; + protected readonly name: string = 'use-skill'; + protected readonly paramTypes: readonly string[] = [ + 'number', + 'number', + 'number' + ]; constructor( state: IStateSystem, - private readonly choices: IChoiceService + private readonly skills: ISkillService ) { super(state); } @@ -238,15 +184,14 @@ export class ReplayOpenChoice extends BaseReplayCommand { step: IReplayStepHandler ): Promise { // 参数已由基类校验过,这里可以直接断言类型 - const [choiceId] = step.params as [number]; + const [skillId, x, y] = step.params as [number, number, number]; - // 打开选择框;回放时选择框系统会在框出现后读取被动录像 - const selected = await this.choices.open(choiceId); - if (selected < 0) { - // 没有读到选择,后续步骤无法继续 + // 技能不可用时不继续播放,避免后续步骤建立在错误状态上 + if (!this.skills.canUse(skillId, x, y)) { return ReplayCommandResult.Failed; } + this.skills.apply(skillId, x, y); return ReplayCommandResult.Success; } } @@ -254,7 +199,7 @@ export class ReplayOpenChoice extends BaseReplayCommand { `BaseReplayCommand` 的 `execute` 会先校验参数再调用 `wrappedExecute`。因此不要在 `wrappedExecute` 里重复写 `typeof` 判断;参数个数或类型错了,基类已经返回 `Failed`。 -如果打开选择框是异步的,要在 `wrappedExecute` 里 `await` 完再返回 `Success`,否则后续录像步可能在选择结果落地前就开始执行。 +如果技能效果是异步的,要在 `wrappedExecute` 里 `await` 完再返回 `Success`,否则后续录像步可能在效果完成前就开始执行。 ## 注册 @@ -263,31 +208,31 @@ export class ReplayOpenChoice extends BaseReplayCommand { ```ts import { ReplayCode } from '@user/data-common'; import { ICoreState, IDataPluginDefine } from '@user/data-state'; -import { ChoiceSystem, IChoiceService } from './choice'; -import { ReplayChoiceSelect, ReplayOpenChoice } from './commands'; +import { ISkillService, ReplaySkillEffect, ReplayUseSkill } from './commands'; +import { SkillSystem } from './skill'; /** - * 选择框插件:指令随插件一起注册。 + * 技能插件:技能服务与录像指令一起注册。 * 把插件加进 src/data/plugin/list.ts 的 dataPlugins 后,它会随数据端一起初始化。 */ -export class DataChoicePlugin implements IDataPluginDefine { - readonly id: string = 'data-choice-plugin'; +export class DataSkillPlugin implements IDataPluginDefine { + readonly id: string = 'data-skill-plugin'; readonly dataDeps: string[] = []; - /** 选择框服务:主动步通过它打开选择框 */ - choice!: IChoiceService; + /** 技能服务:主动步通过它执行技能 */ + skill!: ISkillService; awake(state: ICoreState): void { - this.choice = new ChoiceSystem(state); + this.skill = new SkillSystem(state); // 注册必须发生在第一次创建沙盒之前;插件的 awake 满足这个时机 state.replaySystem.registerCommand( - ReplayCode.OpenChoice, - new ReplayOpenChoice(state, this.choice) + ReplayCode.UseSkill, + new ReplayUseSkill(state, this.skill) ); state.replaySystem.registerCommand( - ReplayCode.ChoiceSelect, - new ReplayChoiceSelect(state) + ReplayCode.SkillEffect, + new ReplaySkillEffect(state) ); } @@ -301,22 +246,52 @@ export class DataChoicePlugin implements IDataPluginDefine { ## 记录 -注册只让回放认识这条指令,录制仍要在行为入口写入。选择框系统里,`open` 负责记录:先 `record` 主动步,再 `record` 紧跟其后的被动步,两个录像步必须相邻;回放时走沙盒分支,只读被动步,不再记录。 +注册只让回放认识这条指令,录制仍要在行为入口写入。技能系统里,`use` 负责记录:先 `record` 主动步,再 `record` 紧跟其后的被动步,两个录像步必须相邻;回放时由 `ReplayUseSkill` 执行,不再记录。 -`@shouldReplay` 写在 `apply` 上,而不是 `open` 上:真正可能修改状态的是 `apply`,`open` 只是调用它;而且 `open` 里的 `if` 在不可用时会直接提示并返回,不会走到记录。把标记写在 `apply` 上,任何调用 `apply` 的路径都会被检查到。 +`@shouldReplay` 写在 `apply` 上,而不是 `use` 上:真正可能修改状态的是 `apply`,`use` 只是调用它;而且 `use` 里的 `if` 在不可用时会直接返回,不会走到记录。把标记写在 `apply` 上,任何调用 `apply` 的路径都会被检查到。 -把「记录 + 执行」的玩家入口与「只读取 / 只执行」的回放入口分开,可以避免同一次选择被记两遍。如果某个被动触发的路径会调用带记录的入口,用 `disable()` 与 `revert()` 把那段包住,并且必须配对。 +```ts +import { ReplayCode, shouldReplay } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; +import { ISkillService } from './commands'; + +/** 技能系统:玩家入口写录像,回放入口只执行效果 */ +export class SkillSystem implements ISkillService { + constructor(private readonly state: ICoreState) {} + + canUse(skillId: number, x: number, y: number): boolean { + // ...判断技能是否可用 + return true; + } + + use(skillId: number, x: number, y: number): void { + if (!this.canUse(skillId, x, y)) return; + + // 先记录主动步,再记录紧随其后的被动步;两者必须相邻 + this.state.replaySystem.record(ReplayCode.UseSkill, skillId, x, y); + this.state.replaySystem.record(ReplayCode.SkillEffect, skillId); + this.apply(skillId, x, y); + } + + /** 真正修改状态的地方,@shouldReplay 写在这里 */ + @shouldReplay('Applying a skill should be replayed.') + apply(skillId: number, x: number, y: number): void { + // ...真正的技能效果;回放时由 ReplayUseSkill 调用这里 + } +} +``` + +把「记录 + 执行」的玩家入口与「只执行」的回放入口分开,可以避免同一次技能被记两遍。如果某个被动触发的路径会调用带记录的入口,用 `disable()` 与 `revert()` 把那段包住,并且必须配对。 ## 怎么验证 -录一段包含选择框的录像,再用样板内置的沙盒播放验证:回放时选择框会出现,并按录像里的选择自动选中,而不是等你点击;结束后检查选择带来的状态变化(例如 Flag 或道具数量)是否与录制时一致,并确认没有出现「选择录像缺失」的提示。 +录一段包含技能的录像,再用样板内置的沙盒播放验证:回放时技能会按录像里的参数再次执行,而不是等你操作;结束后检查技能带来的状态变化(例如目标怪物掉血、勇士属性变化)是否与录制时一致。 播放时不要禁用录制。沙盒播放本身也是一遍真实运行;禁用录制的话,这一遍不会留下任何录像,既不能重播,也无法拿它和原录像对比。样板的播放入口已经处理好播放流程,作者一般不需要自己写。 ## 边界与注意 - **不要复用已删除的旧指令码**:数值属于录像格式,旧录像会按旧值解码。新语义一律追加新值。 -- **`Uint8` 下不要超过 255**:写入会截断,产生无法回放的录像。需要更多码时切 `Uint16`,并把它当成格式变更处理。 - **参数类型必须与 `paramTypes` 一致**:只支持 `number`、`string`、`boolean`、`bigint`。传对象等无法编码的值会被丢弃,参数个数随之变化,基类会判为失败。 - **被动步必须在主动步期间消费**:`getPassive()` 只在当前主动步执行期间暴露下一步;主动步返回后,未消费的被动步会被沙盒告警并按 code 194 跳过,等待它的代码会一直等下去。取到 handler 后一定要 `next`;忘记 `next` 不会让整条录像停住,只是不会读到下一个录像,沙盒随后会跳过这一步继续播放。 - **`finalize` 返回 `Failed` 会中止播放**:它返回的是 `Promise`,和 `execute` 一样会中断后续步骤,不要在里面吞掉错误。它需要跨步状态时,只能把数据保存在指令对象的成员里;指令对象在注册时创建一次,之后复用。 diff --git a/docs/advanced/ii/pathfinding.md b/docs/advanced/ii/pathfinding.md index e882d7d..cb86059 100644 --- a/docs/advanced/ii/pathfinding.md +++ b/docs/advanced/ii/pathfinding.md @@ -65,12 +65,12 @@ function bindPathfindingLayer(state: ICoreState): void { `IPathfindingResult` 只有两个字段:`status` 与 `path`。`path` 仅在状态为 `Success` 时非空。 -| 状态 | 含义 | 处理建议 | -| -------------------------------- | ------------------------------------------------------------------ | ------------------------------ | -| `PathfindingStatus.Success` | 找到路径 | 读取 `path`,交给移动器 | -| `PathfindingStatus.InvalidInput` | 输入不合法:未绑定移动器、图层、谓词或方向处理器,或起点不在地图内 | 检查绑定与起点坐标 | -| `PathfindingStatus.NoPath` | 从起点出发无法到达目标点 | 不要移动;先检查通行谓词与图层 | -| `PathfindingStatus.TargetUnder` | 目标点就在起点脚下 | 不需要移动 | +| 状态 | 含义 | 处理建议 | +| -------------------------------- | ------------------------------------------------------------------ | ----------------------- | +| `PathfindingStatus.Success` | 找到路径 | 读取 `path`,交给移动器 | +| `PathfindingStatus.InvalidInput` | 输入不合法:未绑定移动器、图层、谓词或方向处理器,或起点不在地图内 | 检查绑定与起点坐标 | +| `PathfindingStatus.NoPath` | 找不到可行路径,例如目标被怪物挡住 | 正常结果,不需要处理 | +| `PathfindingStatus.TargetUnder` | 目标点就在起点脚下 | 不需要移动 | 每一步是 `IPathfindingStep`,包含 `dir`(移动方向)、`from`(出发位置)与 `to`(到达位置)。`from` 与 `to` 都是只读的坐标对象;路径按起点到终点的顺序排列。 @@ -298,7 +298,7 @@ async function verifyPathfinding( } ``` -预期英雄沿最小损失路径到达目标点;目标不可达时 `status` 为 `NoPath`,不会发生移动。若状态是 `InvalidInput`,按顺序检查移动器、图层、通行谓词与方向处理器是否都已绑定。 +预期英雄沿最小损失路径到达目标点;目标不可达时 `status` 为 `NoPath`,不会发生移动,这是正常结果(例如被怪物挡住)。若状态是 `InvalidInput`,按顺序检查移动器、图层、通行谓词与方向处理器是否都已绑定。 ## 边界与注意 @@ -306,7 +306,7 @@ async function verifyPathfinding( - **`moveTo` 与 `teleportTo` 只在 `Success` 时启动**:`NoPath`、`TargetUnder`、`InvalidInput` 都会返回 `null`。 - **上一次移动未结束时返回 `null`**:连续调用 `moveTo` 前先等 `controller.onEnd`,或显式 `interrupt()`。 - **目标点在脚下不会移动**:`find` 在起点与终点相同时直接返回 `TargetUnder`。 -- **目标格必须可达**:图只包含从起点沿可通行边能到达的节点,不可达区域不会进入图。 +- **`NoPath` 是正常结果**:图只包含从起点沿可通行边能到达的节点;目标被怪物挡住或不可达时返回 `NoPath`,不会移动,也不需要特殊处理。 - **谓词的 `shouldHit` 会标记终端节点**:终端节点只能作为路径终点,不能作为中间点;目标点不可达时优先检查 `canPass` 与 `shouldHit` 的组合。 - **换楼层要重新绑定**:`useMapLayer` 绑定的是具体图层对象,旧楼层失效后必须换成新楼层的图层。 - **图层必须属于同一个数据端对象**:`useMapLayer` 会校验图层来源,不属于当前状态时告警并忽略这次绑定。