From f1c0f8b2a46dc5d7df235893f19a71df5b7b0312 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Tue, 20 Oct 2026 15:28:18 +0800 Subject: [PATCH] =?UTF-8?q?docs(10-19):=20=E6=8C=89=20D-58=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E9=AB=98=E7=BA=A7=E6=95=99=E7=A8=8B=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 高级 I 不再强制模板章节:移除 declarative、replay、hooks 的泛化骨架,保留 plugin、saveable 与精简后的 data-stores - 移除「不懂代码就改内置」类中级提示,改为 D-55(新增系统优先插件,必要时才改内置) - 补充 D-58 修订记录 --- .../10-19-READER-REVIEW.md | 42 +++++++++++ docs/advanced/data-stores.md | 67 +++++++---------- docs/advanced/declarative-vs-imperative.md | 52 +++---------- docs/advanced/hooks-in-depth.md | 42 ----------- docs/advanced/index.md | 2 +- docs/advanced/plugin-system.md | 12 ++- docs/advanced/replay-consistency.md | 39 +--------- docs/advanced/saveable-content.md | 75 ++++++------------- 8 files changed, 110 insertions(+), 221 deletions(-) create mode 100644 .planning/phases/10-docs-restructure/10-19-READER-REVIEW.md diff --git a/.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md b/.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md new file mode 100644 index 0000000..ea36ec7 --- /dev/null +++ b/.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md @@ -0,0 +1,42 @@ +# 高级 I 读者审阅 + +## D-58 修订记录 + +**时间:** 2026-10-20 +**依据:** D-58——「改内置还是自己新增」的取舍与「必须提供通用模板章节」只适用于中级教程;高级 I 不强制模板章节,是否提供视内容而定,取舍原则仍是 D-55(新增系统优先用插件,必要时才改内置)。 +**范围:** `docs/advanced/` 七篇;只改文档,不改 `config.ts` 与生产代码;未改渲染端内容。 + +### 模板章节取舍 + +| 篇目 | 决定 | 说明 | +| ----------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `index.md` | 不设模板 | 导览页只给分层、阅读路径与取舍原则,没有需要读者照抄的机制 | +| `declarative-vs-imperative.md` | 移除通用骨架 | 原骨架用 `XxxHandler` / `IXxxHandler` 泛化占位,不对应任何真实接口,照抄也无法落地;保留其中的「扩展点一览」表与 `#region`、类示例约定 | +| `plugin-system.md` | 保留 | 插件是高级 I 的核心机制,读者必须自己实现 `IDataPluginDefine`;骨架含五个成员、注册位置与存档 id 的易错点 | +| `saveable-content.md` | 保留 | `ISaveableContent` 是必须实现的接口;骨架覆盖 `saveState` 深拷贝与 `loadState` 先清空再重建两个易错点;示例精简为「接到插件里」,不再重复整类 | +| `replay-consistency.md` | 移除 | 骨架只是「一个方法 + 一个装饰器」,与后续「给内置方法补录像标记」六步重复;「一个方法只写一个标记」并入常见错误 | +| `data-stores.md` | 保留(精简) | Store 没有逐对象模板;去掉外层 `MyContent` 包装类,只保留「一条原始定义长什么样」的 `addTile` 载荷 | +| `hooks-in-depth.md` | 移除 | 骨架与「做法 A:每个对象一个钩子实例」的代码完全相同;保留做法 A 与两个六步示例 | + +### 移除的中级定位 + +| 篇目 | 移除内容 | 替代 | +| ----------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `index.md` | 「如果你不怎么懂代码,优先改内置已有实现,而不是自己造一套。」 | 判断标准改为改动落在谁的代码里:能由插件或内容定义完成的留在 `src`;必须动内置包时收敛差异 | +| `declarative-vs-imperative.md` | 同句;「日常需求优先改内置」与「能改内置解决的问题,不值得再包一层」 | 小节改为「改内置还是写成插件」,按 D-55:新增系统优先插件,只有必要时才改内置包 | +| `plugin-system.md` | 「如果你不怎么懂代码,建议直接改内置,而不是自己写新的插件」与「新建插件比改内置多出不少工作」 | 小节改为「改现有插件还是新建插件」;「改内置」明确限定为改 `packages` / `packages-user` | +| `saveable-content.md` | 同句;「能改内置解决的问题,不值得再新建一个可存档对象」 | 新增系统优先由插件承载可存档对象;必要时才改内置存档实现 | +| `replay-consistency.md` | 同句 | 保留「绝大多数情况不需要新写类」;整套新系统优先用插件组织 | +| `data-stores.md` | 同句 | 保留「补条目优先、不要扩展 `IDataCommon`」;新的原始数据表先按插件组织 | +| `hooks-in-depth.md` | 同句 | 保留「先改 `src/data/hook/*` 已有类」与「整套新系统优先用插件」 | + +### 保留项 + +- **D-48 六步:** `plugin-system`、`saveable-content`、`data-stores`、`replay-consistency`、`hooks-in-depth` 的实操段落保持「目标 / 改哪里 / 复制什么 / 改什么 / 怎么验证 / 注意」结构。 +- **D-47 占位:** `高级 II · …`、`高级 III · …` 的代码 span 与 `` 注释保留;`replay-consistency` 移除模板后,两处指向移入「改内置还是自己写」。 +- **D-51 / D-52 / D-55:** `#region` 约定、加粗边界、优先插件原则保留。 + +### 构建 + +- `cmd /c "pnpm exec prettier --write docs/advanced/*.md"`:七篇全部 unchanged,已符合格式。 +- `cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"`:`build complete`,无 dead link。 diff --git a/docs/advanced/data-stores.md b/docs/advanced/data-stores.md index 1097ea9..b2b27c1 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -152,40 +152,32 @@ const raw = state.tileStore.getData(num); 但它不在 `IDataCommon` 上:主对象没有把 `enemyStore` 暴露成字段。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。本篇只讲 `IDataCommon` 上的四个 Store;需要怪物原始数据时,先确认当前版本把它接在了哪里,再决定从哪读。 -## 模板:在加载期注册 Store 原始数据 +## 模板:登记一条原始定义 -Store 的注册没有逐对象模板,原因和别的机制不同:插件、可存档对象、光环这些机制都要你实现一个接口,所以能给出「一个空类 + 需改点」的骨架;`addTile` / `addItem` / `addMap` / `addEvent` 不是这样——它们是逐条登记一条原始定义,你不需要实现任何方法,只需要把一条条定义交给它。 +Store 的注册没有「一个空类 + 需改点」那种逐对象模板,原因和别的机制不同:插件、可存档对象、光环这些机制都要你实现一个接口,所以能给出骨架;`addTile` / `addItem` / `addMap` / `addEvent` 不是这样——它们是逐条登记一条原始定义,你不需要实现任何方法,只需要把一条条定义交给它。 -所以这里给的是「把一批登记集中到加载期」的机制骨架: +所以这里的骨架是「一条定义长什么样」。下面以图块为例,复制后按 `[需改]` 修改: ```ts -// [模板] 在加载期集中登记一批原始数据:复制后按 [需改] 修改 -export class MyContent { - // [需改] 接住 state,或者改成由方法参数传入 - constructor(readonly state: IDataCommon) {} - - // [需改] 由插件的 awake / initialize / afterLoad 调用,只调用一次 - register(): void { - this.state.tileStore.addTile({ - // [需改] 图块数字与 id 必须各自唯一 - num: 10001, - id: 'my-custom-tile', - // [需改] 默认事件:优先级 → 事件 id - events: {}, - // [需改] 图块逻辑类型 - type: TileType.Terrain, - pass: { - // [需改] 是否只在事件层生效 - onlyEvents: false, - // [需改] 可离开 / 可进入的方向掩码 - outPass: 0b1111, - inPass: 0b1111 - }, - // [需改] 走上去触发还是撞击触发 - eventPass: false - }); - } -} +// [模板] 登记一条图块定义:复制后按 [需改] 修改 +state.tileStore.addTile({ + // [需改] 图块数字与 id 必须各自唯一 + num: 10001, + id: 'my-custom-tile', + // [需改] 默认事件:优先级 → 事件 id + events: {}, + // [需改] 图块逻辑类型 + type: TileType.Terrain, + pass: { + // [需改] 是否只在事件层生效 + onlyEvents: false, + // [需改] 可离开 / 可进入的方向掩码 + outPass: 0b1111, + inPass: 0b1111 + }, + // [需改] 走上去触发还是撞击触发 + eventPass: false +}); ``` 改什么: @@ -193,11 +185,11 @@ export class MyContent { 1. 数据来源:把写死的 `num` / `id` 换成你内容文件里的条目;内容多时用循环读一遍,而不是手写几十条 `addTile`。 2. 键:`num` 与 `id` 必须唯一。撞了会告警并覆盖,等于悄悄丢掉了另一条定义。 3. 通行掩码:`outPass` / `inPass` 是方向位掩码(上 1、右 2、下 4、左 8),`0b1111` 表示四向都通。 -4. 其它三个 Store:形状相同,把 `addTile` 换成对应调用即可——`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。所以这套骨架不需要为每个 Store 重写一遍。 +4. 其它三个 Store:形状相同,把 `addTile` 换成对应调用即可——`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。所以这条骨架不需要为每个 Store 重写一遍。 注意:`addEvent` 的签名与另外三个不同,它的第一个参数是事件 id,第二个参数才是事件实例。 -顶部通常已有这些类型的 `import`(`IDataCommon`、`TileType`);若报缺少某个名字,再在顶部补对应的 `import`。 +顶部通常已有 `TileType` 这类名字的 `import`;若报缺少某个名字,再在顶部补对应的 `import`。 ## 把一批自定义内容注册进 Store @@ -209,12 +201,11 @@ export class MyContent { ```ts awake(state: ICoreState): void { - // [需改] 这一段就是上一节模板里 register() 的内容 - new MyContent(state).register(); + // 在这里调用 addTile / addItem / addMap / addEvent } ``` -复制什么:只复制 `add*` 调用那一段,粘进上面的时机方法里(或粘进模板类的 `register()` 里)。不含 `import`——插件的文件顶部通常已经有 `IDataCommon` / `ICoreState`,内容类型通常在 `@user/data-common` 里,已经引入过就不要重复粘;若编辑器报缺少某个名字,再在顶部补 `import ...`。 +复制什么:只复制上面的 `add*` 调用,粘进这个时机方法里。不含 `import`——插件的文件顶部通常已经有 `IDataCommon` / `ICoreState`,内容类型通常在 `@user/data-common` 里,已经引入过就不要重复粘;若编辑器报缺少某个名字,再在顶部补 `import ...`。 改什么:把 `num`、`id` 与各字段换成你自己的数据。图块数字要与你的内容对得上,道具的 `num` 要与对应图块数字一致,地图的 `floorId` 要与切楼层用的 id 一致。 @@ -229,8 +220,6 @@ awake(state: ICoreState): void { 扩展 `IDataCommon` 意味着改动内置包,版本升级时要跟着改;而补条目只动你自己的内容,升级时几乎不用管。 -如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。 - ## 常见错误 - 在玩家动作里登记。运行时改定义不会传播到已创建的对象,还会绕过存档与录像。 @@ -280,7 +269,7 @@ console.log(raw, state.tileStore.getType(num)); - 运行时调用 `add*` 会绕过存档、录像与一致性检查,所以只在加载期登记一次。 - 读 Store 随时可以,但不要就地修改返回的对象;`eventStore.getEvent` 返回的是存储的实例。 - `EnemyStore` 不在 `IDataCommon` 上,不要假设主对象能直接取到。 -- Store 注册没有逐对象模板,只有「在加载期集中登记」的机制骨架。 -- 不懂代码时优先在已有位置补条目,不要急着写新类或扩展主对象。 +- Store 注册没有逐对象模板,只有「一条定义长什么样」的骨架。 +- 需要新的原始数据表时优先按插件组织;不要急着扩展主对象。 返回[高级 I 导览](./index.md)。 diff --git a/docs/advanced/declarative-vs-imperative.md b/docs/advanced/declarative-vs-imperative.md index 20b5314..be9116d 100644 --- a/docs/advanced/declarative-vs-imperative.md +++ b/docs/advanced/declarative-vs-imperative.md @@ -194,11 +194,9 @@ calculate(handler) { 可以用一句话概括:一次性动作用命令式,持续生效的规则用声明式。 -## 模板:声明式扩展的通用骨架 +## 扩展点一览 -下面给的是机制本身的通用骨架,不对应任何一种具体效果。具体效果只用来帮助理解,不要把它当成模板照抄。 - -先看各类扩展点分别要写什么、注册到哪里、引擎什么时候调用: +前面五组机制各自要写什么、注册到哪里、引擎什么时候调用,放在一起看更清楚: | 扩展点 | 你要实现的东西 | 注册入口 | 引擎调用时机 | | ---------- | -------------------------------------------- | ---------------------------------------- | ---------------------- | @@ -209,53 +207,21 @@ calculate(handler) { | 可存档对象 | `ISaveableContent.saveState` / `loadState` | `state.addSaveableContent(id, content)` | 存档与读档时 | | 钩子 | 对应钩子族接口的方法 | `addHook(new …)` | 对应动作发生时 | -通用形状可以写成下面这样。复制后按 `[需改]` 标记修改: +这些扩展点的写法都是「实现一个类 + 注册一次」:类里只放引擎会调用的方法,注册放在初始化函数里。示例一律写成 `class` 再 `new`,不要写成 `const obj = { ... }` 这种 plain object。例外是引擎本身就用对象字面量声明的内容(如 `src/content/item.ts` 的 `ItemEffect`),那里沿用现有写法即可。 -```ts -// [模板] 声明式扩展的通用形状:一个类 + 一次注册 + 一个由引擎调用的方法 -export class XxxHandler implements IXxxHandler { - // [模板] 构造时只保存长期引用;不要在这里改游戏状态 - constructor(readonly state: ICoreState) {} +像 `aura.ts` 这种集中放置多个实现的文件,建议用 `//#region {code}-{name}` 到 `//#endregion` 包起来,结构更清晰,后续编辑器也可能据此识别。 - // [需改] 方法名与参数照抄你实现的接口(如 calculate、apply、useEffect) - run(handler: IReadonlyXxxHandler): IXxxResult { - // [需改] 只读输入、算出结果;需要改状态时,放到引擎规定的时机里 - return /* [需改] 结果 */; - } -} +## 改内置还是写成插件 -// [模板] 注册一次,通常写在 initialize.ts 一类初始化函数里 -// [需改] 把 someSystem.useXxx 换成对应系统的注册方法 -state.someSystem.useXxx(new XxxHandler(state)); -``` +判断依据是改动落在谁的代码里,而不是改动大小。 -复制后需要改的地方: - -1. 类名与接口名:`XxxHandler` 与 `IXxxHandler` 换成你实际实现的接口。 -2. 方法名与签名:按接口来写,不同扩展点的方法名不同。 -3. 方法体:只读输入、算出结果;需要改状态时,交给钩子或对应的时机。 -4. 注册入口:按上表找到对应的注册方法,并放进初始化函数里。 -5. 是否需要 `#region`:像 `aura.ts` 这种集中放置多个实现的文件,建议用 `//#region {code}-{name}` 到 `//#endregion` 包起来,结构更清晰,后续编辑器也可能据此识别。 - -示例一律写成 `class` 再 `new`,不要写成 `const obj = { ... }` 这种 plain object。例外是引擎本身就用对象字面量声明的内容(如 `src/content/item.ts` 的 `ItemEffect`),那里沿用现有写法即可。 - -## 改内置还是自己写 - -日常需求优先改内置: - -- 特殊属性与光环:改 `src/data/enemy/special.ts` 与 `src/data/enemy/aura.ts`; -- 伤害公式:改 `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate`; -- 事件处理:改 `src/data/hook/*` 里已有的钩子类。 - -这些内置实现已经注册好,你改方法体或加一条分支就会生效,不需要自己写注册代码。不要为每个效果新建一个文件;光环、伤害计算这类实现本来就集中放在对应文件里。 +日常效果仍然集中在对应的内置实现里:特殊属性与光环改 `src/data/enemy/special.ts` 与 `src/data/enemy/aura.ts`;伤害公式改 `src/data/enemy/calculator.ts` 的 `MainDamageCalculator.calculate`;事件处理改 `src/data/hook/*` 里已有的钩子类。这些实现已经注册好,改方法体或加一条分支就会生效,不需要自己写注册代码。不要为每个效果新建一个文件;光环、伤害计算这类实现本来就集中放在对应文件里。 只有在下面这些情况才需要自己写类: - 你要替换整套策略,例如整条伤害公式都换掉; - 你要新增一组可独立复用的扩展,例如一个新的光环转换器; -- 你要新增一个系统。此时优先通过插件实现,而不是直接改内置包,见[插件系统](./plugin-system.md)。 - -如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。自己写类要同时处理接口签名、注册时机与上下文刷新,改动面比在内置方法里加一个分支大得多,出错也更难排查。 +- 你要新增一个系统。新增系统优先通过插件实现,把改动留在 `src`;只有插件与现有扩展点都覆盖不了时,才去改内置包(`packages` / `packages-user`),见[插件系统](./plugin-system.md)。 ## 常见误区 @@ -301,6 +267,6 @@ if (system && info) { - 声明式不是不写代码,而是把「做什么」与「什么时候做」分开。 - 持续生效的规则用声明式;玩家直接产生的一次性动作用命令式,但要计入录像。 - 计算类扩展必须无副作用;状态修改放到引擎规定的时机里。 -- 改内置优先;确实需要独立扩展时再自己写类,新增系统优先考虑插件。 +- 新增系统优先写成插件;只有必要时才改内置包。 返回[高级 I 导览](./index.md)。 diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md index 8151145..5e23f5b 100644 --- a/docs/advanced/hooks-in-depth.md +++ b/docs/advanced/hooks-in-depth.md @@ -273,46 +273,6 @@ layer.addHook(hook); -## 模板:一个带身份校验的钩子 - -目标:每层一个钩子实例,只对目标图层做事;如果回调回传了图块,再额外核对一次所属图层。 - -改哪里:`src/data/hook/` 下的现有钩子文件,或你自己的插件文件;类写好后,在能拿到目标图层的时机注册。 - -复制什么:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果的完整实现。 - -```ts -// [模板] 每层一个实例的图层钩子 -export class MyLayerHooks implements IMapLayerHooks { - constructor( - readonly state: ICoreState, - readonly layer: IMapLayer - ) {} - - // [需改] 没有 layer 参数;触发者就是 this.layer - onUpdateBlock(block: number, x: number, y: number): void { - // 在这里做事 - } - - // [需改] 动态图块回调回传 tile,可再核对 tile.layer - onCreateDynamic(tile: IDynamicTile): void { - if (tile.layer !== this.layer) return; - // 在这里做事 - } -} -``` - -改什么: - -1. 类名 `MyLayerHooks` 换成你的名字; -2. `readonly layer` 保留,构造时传入目标图层; -3. 方法体换成你的处理; -4. 如果目标钩子不回传身份,不要把这个实例共享给多个图层,改为每层 `new` 一个。 - -怎么验证:见下面的「只处理指定图层」;核心是改目标图层会触发、改别的图层不触发。 - -注意:`readonly layer` 不是为了在 `onUpdateBlock` 里比较一个不存在的参数。身份来自「这个实例注册在哪个图层上」;动态图块方法里的 `tile.layer` 才是额外的运行时校验。 - ## 示例:只处理指定图层 目标:只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。 @@ -404,8 +364,6 @@ map.addHook(new TargetLayerResizeHooks(state, targetLayer)); 如果这是一整套新系统,优先通过插件实现(见[插件系统](./plugin-system.md)),钩子在事件发生时调用它。这样新增逻辑留在 `src` 里,升级内置包时要跟的差异更少。 -如果你不怎么懂代码,建议直接改内置钩子,而不是自己写新的类。内置类已经注册好,改方法体就能生效;自己写类还要处理身份、注册时机与移除,排查面更大。 - ## 常见错误 - 把地图级钩子挂到图层上。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用。反过来,把图层钩子挂到地图上也不会收到图层事件。 diff --git a/docs/advanced/index.md b/docs/advanced/index.md index da8f832..5b184b1 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -66,7 +66,7 @@ | 注册一组内容定义(图块、道具、地图、事件) | 在加载期集中登记,不新建 Store | 现有四个 Store 装不下,且确实需要新的系统级数据表时 | | 给会改状态的方法补录像标记 | 在内置方法上加一行装饰器 | 需要新的录像入口或自定义录像行为时(属于 `高级 II · 自定义录像行为`) | -如果你不怎么懂代码,优先改内置已有实现,而不是自己造一套。在内置类里补几行,出问题时对照原代码就能看出差异;新建插件或新类要同时处理注册、依赖、时机与存档 id,排查面大得多。 +判断标准不是改动多少行,而是改动落在谁的代码里:能由插件或内容定义完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个方法或一条分支上,方便升级时对照。 ## 本层不覆盖什么 diff --git a/docs/advanced/plugin-system.md b/docs/advanced/plugin-system.md index 49ca1ba..b555e79 100644 --- a/docs/advanced/plugin-system.md +++ b/docs/advanced/plugin-system.md @@ -242,7 +242,7 @@ if (holder && holder.status === PluginStatus.Loaded) { 然后在 `list.ts` 里 `new` 出来加进 `dataPlugins`。最终传给 `CoreState` 的插件数组在 `src/data/entry.ts` 里拼成:主插件在前,`dataPlugins` 展开在后。日常只需要改 `list.ts`,不必动 `entry.ts`。 -这是少数应该新建文件与目录的场景:插件本身就是一个独立单元,把它的类、接口和对象放在一起,比全部塞进 `initialize.ts` 更容易维护。除此之外的普通效果仍然优先改内置文件,不要为每个效果新建目录。 +这是少数应该新建文件与目录的场景:插件本身就是一个独立单元,把它的类、接口和对象放在一起,比全部塞进 `initialize.ts` 更容易维护。除此之外的普通效果直接写在对应的内置实现文件里,不要为每个效果新建目录。 ## 模板:一个插件的最小骨架 @@ -374,16 +374,14 @@ async afterLoad(state: ICoreState): Promise { 它同时说明了另一个点:`afterLoad` 拿到的 `state` 与 `awake` 是同一个主对象,`awake` 里存下的引用(如 `this.heroFace`)此时仍然可用。 -## 改内置还是自己写 +## 改现有插件还是新建插件 先分清两种改动: - 只是给现有插件加一点行为,例如给商店多一个查询接口、调整某个系统的默认值——直接改现有插件的代码,在它的类里加方法或改实现; -- 要加一组独立的功能,有自己的状态、自己的管理器、自己的存档内容——新建插件。 +- 要加一组独立的功能,有自己的状态、自己的管理器、自己的存档内容——新建插件。新建的插件同样留在 `src` 里,升级时不用跟着内置包的差异走。 -新建插件比改内置多出不少工作:目录、注册、依赖声明、存档 id、加载时机都要自己定。能改内置解决的问题,不值得再包一层。 - -如果你不怎么懂代码,建议直接改内置,而不是自己写新的插件。在内置类的方法里加几行,出问题时对照原有代码就能看出差异;新建插件则要同时处理注册、依赖与时机,排查面大得多。 +这里说的「改内置」只指改 `packages` / `packages-user` 里的代码。新增系统优先做成插件,正是为了不碰它们;只有现有插件与扩展点都覆盖不了、必须替换内置行为时,才去动内置包。 ## 常见错误 @@ -446,6 +444,6 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin); - `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。 - 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。 - 插件 id 是对外契约,不要随意改名或复用;它与可存档 id 是两套命名。 -- 优先改内置;需要独立成组的功能时再写插件。 +- 新增系统优先写成插件;只有必要时才改内置包。 返回[高级 I 导览](./index.md)。 diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md index e427752..f551d29 100644 --- a/docs/advanced/replay-consistency.md +++ b/docs/advanced/replay-consistency.md @@ -143,36 +143,6 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0); 两个装饰器都按「类方法装饰器」实现,只能写在 `class` 的方法上方。写在普通函数、对象属性、箭头函数上都不会生效。 -## 模板:给方法加录像标记 - -这一节给的是录像标记这类机制的通用骨架,不是某条具体录像指令的示例。某条指令怎么实现、主动录像与被动录像怎么写,属于自定义录像行为,见 `高级 II · 自定义录像行为`;录像格式与沙盒的深层原理见 `高级 III · 录像深层原理`。 - -```ts -// [模板] 给类的方法加录像标记:复制后按 [需改] 修改 -export class MySystem { - // [需改] 方法名换成你真正会改变游戏状态的方法 - @shouldReplay('[需改] 这个方法改变了什么状态') - changeSomething(value: number): void { - // [需改] 真正修改状态的内容 - } - - // [需改] 只在确实不该计入录像的被动路径上加,并把理由写清楚 - @ignoreReplay('[需改] 为什么这段不该计入录像') - async onDelayedSomething(): Promise { - // [需改] 延迟触发、不由玩家动作直接产生的逻辑 - } -} -``` - -改什么: - -1. 标记类型:会改状态的用 `@shouldReplay`;确实被动、不该计入的才用 `@ignoreReplay`。只读方法两个都不要。 -2. 方法名:换成你自己的方法。装饰器必须紧贴方法声明,中间不要留空行。 -3. 消息文本:写清「这个方法改变了什么状态」。它会出现在告警里(形如 `方法名: 消息`),是给人排查的线索,不要照抄示例里的占位文字。 -4. 引入:顶部需要 `import { shouldReplay, ignoreReplay } from '@user/data-common';`。若编辑器报缺少 `shouldReplay`,再补这一行;已经引入过就不要重复粘。 - -注意:一个方法只需要一个标记,两个一起写含义互相矛盾。`@ignoreReplay` 不到万不得已不要用——它的作用范围是整段区间,不是这一个方法。 - ## 给一个内置方法补录像标记 这一节按六步给出可直接照做的改法。 @@ -215,17 +185,16 @@ endReplaySafetyCollection(); 绝大多数情况不需要新写类: - 给一个会改状态的入口补标记——直接在已有实现文件里改,例如 `packages-user/data-base/src/flag/`、`hero/`、`map/` 下对应的文件; -- 只有在确实需要新的录像入口(新的指令、新的主动或被动行为)时,才考虑新写类,而这已经超出「标记」的范围,属于自定义录像行为(`高级 II · 自定义录像行为`)。 +- 只有在确实需要新的录像入口(新的指令、新的主动或被动行为)时,才考虑新写类。这已经超出「标记」的范围,属于自定义录像行为(`高级 II · 自定义录像行为`)。 -补一行装饰器的成本极低,而且出问题时对照原代码就能看出差异;自己写新类则要同时处理录制时机、参数编码与顺序,排查面大得多。 - -如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。 +补一行装饰器的成本极低,对照原代码就能看出差异。需要一整套新系统时,优先用插件组织,把录像入口收敛在插件自己的方法上,见[插件系统](./plugin-system.md)。录像格式与沙盒的深层原理留到 `高级 III · 录像深层原理`。 ## 常见错误 - 给只读方法或计算器加 `@shouldReplay`。它们不产生录像步,只会让告警列表变长,把真正的漏记埋掉。 - 用 `@ignoreReplay` 压告警。它忽略的是整段区间,真正的漏记会一起被放过。 - 以为加了装饰器就会自动记录。装饰器只登记路径,录像步仍要 `record` 或走内置录制入口。 +- 同一个方法写两个标记。`@shouldReplay` 与 `@ignoreReplay` 同时写含义互相矛盾;一个方法只需要一个标记,装饰器要紧贴方法声明。 - 在异步回调里记录却没处理顺序。录像步会落在错误的时点,检查也看不出来。 - 在读档过程中记录。`loadState` 被 `disable` 包裹,记录不会生效;执行器不在这层保护里,反而要更谨慎。 - 把装饰器写在普通函数上。它只支持类的方法。 @@ -261,6 +230,6 @@ endReplaySafetyCollection(); - 检查能发现「区间内一条录像步都没有」,但不管逻辑、参数、顺序与一一对应。 - 收集区间目前只有测试在开启;需要时手动 `beginReplaySafetyCollection` / `endReplaySafetyCollection`。 - 读档期间录像被临时禁用,执行器不在保护范围内。 -- 不懂代码时优先改内置加一行标记,不要急着写新类。 +- 需要新的录像入口时再扩展;一整套新系统优先用插件组织。 返回[高级 I 导览](./index.md)。 diff --git a/docs/advanced/saveable-content.md b/docs/advanced/saveable-content.md index 29bec78..4664a97 100644 --- a/docs/advanced/saveable-content.md +++ b/docs/advanced/saveable-content.md @@ -236,7 +236,7 @@ state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor()); 改哪里:放进你自己的插件目录,通常与插件类放在一起;不需要为它单独新建一套目录。 -复制什么:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果,计数器示例只是用法演示,不要拿示例当模板。 +复制什么:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果,下面的示例只演示怎么把它接到插件里。 ```ts // [模板] 一个可存档对象的最小骨架:复制后按 [需改] 修改 @@ -279,57 +279,26 @@ export class MySaveable implements ISaveableContent { 注意:`saveState` 里不要修改对象状态,也不要返回 `this` 或带方法的类实例。`loadState` 不要假设旧数据还在,先清空再重建,才不会残留上次的状态。 -## 示例:插件里的计数器 +## 示例:把可存档对象接到插件里 -目标:插件维护一组计数器,读档后计数能恢复。 +目标:让上一节模板里的对象跟着插件一起注册,并能在读档后恢复。 -改哪里:你插件的 `plugin.ts` 与它自己的对象文件。下面先给计数器类,再给插件的注册代码。 +改哪里:你插件的 `plugin.ts`。 -复制什么:计数器类: +复制什么:只复制持有与注册的那几行: ```ts -// [需改] 插件里的计数器 -export interface ICounterSave { - readonly values: Map; -} - -export class Counter implements ISaveableContent { - private readonly values: Map = new Map(); - - add(key: string, value: number = 1): void { - this.values.set(key, (this.values.get(key) ?? 0) + value); - } - - get(key: string): number { - return this.values.get(key) ?? 0; - } - - saveState(): ICounterSave { - return { values: structuredClone(this.values) }; - } - - loadState(state: ICounterSave): void { - this.values.clear(); - for (const [key, value] of state.values) { - this.values.set(key, value); - } - } -} -``` - -然后在插件的 `awake` 里创建并注册: - -```ts -// [需改] 插件里注册计数器 -export class CounterPlugin implements IDataPluginDefine { - readonly id = 'example-counter-plugin'; +// [需改] 在插件里持有并注册可存档对象 +export class MyPlugin implements IDataPluginDefine { + readonly id = 'my-plugin'; readonly dependencies: string[] = []; - counter!: Counter; + // [需改] 换成你模板里的可存档对象 + store!: MySaveable; awake(state: ICoreState): void { - this.counter = new Counter(); - state.addSaveableContent('@plugin/example-counter', this.counter); + this.store = new MySaveable(); + state.addSaveableContent('@plugin/my-store', this.store); } async initialize(): Promise {} @@ -338,27 +307,25 @@ export class CounterPlugin implements IDataPluginDefine { } ``` -如果文件顶部报缺少 `ISaveableContent`,再补 `import { ISaveableContent } from '@user/data-common';`;通常插件文件已经引入过相关类型,不要重复粘。 - 改什么: -1. 存档 id:`@plugin/example-counter` 换成你自己的 id,保证唯一,并与插件 id 分开命名。 -2. 键与值:`add(key, value)` 的键换成你的业务键;需要存字符串就改 `Map` 的值类型。 -3. 方法:`add`、`get` 只是演示,按你的需求增删;存档形状 `ICounterSave` 跟着改。 +1. 类名与 id:`MyPlugin` 与 `my-plugin` 换成你的插件名,`id` 全局唯一。 +2. 持有字段:`store` 与 `MySaveable` 换成模板里你的类;对象构造需要 `state` 时,也在这里传入。 +3. 存档 id:`@plugin/my-store` 换成你自己的 id,保证唯一,并与插件 id 分开命名。 4. 注册位置:必须在 `awake` 里注册,且只注册一次。 -怎么验证:启动后调用 `counter.add('kill', 1)`,存档,再 `add` 一次让它变成 2,然后读档。读档后 `counter.get('kill')` 应回到 1。如果还是 2,检查 `loadState` 有没有先 `clear()`;如果读档后 `get` 报「不是函数」,检查是不是把类实例直接塞进了存档。 +文件顶部通常已经有 `IDataPluginDefine` / `ICoreState`;若报缺少 `MySaveable` 或 `ISaveableContent`,再补对应的 `import`,不要重复粘。 -## 改内置还是自己写 +怎么验证:启动后把某个键改到一个可观察的值,存档;再改一次,然后读档。读档后应回到存档时的值。如果没恢复,检查 `loadState` 有没有先 `clear()`;如果读档后调用方法报「不是函数」,检查是不是把类实例直接塞进了存档。 + +## 改内置还是写成插件 先看你要改的东西属于哪一类: - 只是给现有系统补存档,例如某个内置对象漏了一个字段——直接改它的 `saveState` 与 `loadState`,不要在外面再包一层; - 要新增一组独立功能,有自己的状态、自己的管理器、自己的生命周期——做成插件,并给插件里的对象实现 `ISaveableContent`。 -能改内置解决的问题,不值得再新建一个可存档对象。新建意味着多一个 id、多一处注册、多一份读档顺序要维护;出问题时,排查面也更大。 - -如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。在内置类的 `saveState` 里补一个字段、在 `loadState` 里补一行恢复,改动小、对照原代码就能看出差异;自己写新类则要同时处理注册、存档形状、读档顺序与引用解析。 +新增系统优先走插件:可存档对象跟着插件留在 `src`,升级内置包时不必对照差异。只有必须修改内置对象的存档行为时,才动 `packages` / `packages-user` 里的实现。 ## 常见错误 @@ -415,6 +382,6 @@ console.log(content); - 存档与读档按注册顺序执行;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。 - 插件 `afterLoad` 只在初次加载后运行一次,不能替代读档执行器。 - 跨对象引用存 id 或键,读档时按顺序或延迟解析,不要存活对象引用。 -- 不懂代码时优先改内置的 `saveState` / `loadState`,不要急着写新类。 +- 新增系统优先通过插件承载可存档对象;只有必要时才改内置的存档实现。 返回[高级 I 导览](./index.md)。