mirror of
https://github.com/motajs/template.git
synced 2026-10-23 08:01:48 +08:00
docs(10-19): 按 D-58 调整高级教程模板与定位
- 高级 I 不再强制模板章节:移除 declarative、replay、hooks 的泛化骨架,保留 plugin、saveable 与精简后的 data-stores - 移除「不懂代码就改内置」类中级提示,改为 D-55(新增系统优先插件,必要时才改内置) - 补充 D-58 修订记录
This commit is contained in:
parent
e449579f25
commit
f1c0f8b2a4
42
.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md
Normal file
42
.planning/phases/10-docs-restructure/10-19-READER-REVIEW.md
Normal file
@ -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 与 `<!-- TODO(advanced-*) -->` 注释保留;`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。
|
||||
@ -152,40 +152,32 @@ const raw = state.tileStore.getData(num);
|
||||
|
||||
但它<strong>不在 `IDataCommon` 上</strong>:主对象没有把 `enemyStore` 暴露成字段。所以不要按前面四个 Store 的习惯去取,也不要假设它一定存在。本篇只讲 `IDataCommon` 上的四个 Store;需要怪物原始数据时,先确认当前版本把它接在了哪里,再决定从哪读。
|
||||
|
||||
## 模板:在加载期注册 Store 原始数据
|
||||
## 模板:登记一条原始定义
|
||||
|
||||
Store 的注册<strong>没有逐对象模板</strong>,原因和别的机制不同:插件、可存档对象、光环这些机制都要你实现一个接口,所以能给出「一个空类 + 需改点」的骨架;`addTile` / `addItem` / `addMap` / `addEvent` 不是这样——它们是<strong>逐条登记一条原始定义</strong>,你不需要实现任何方法,只需要把一条条定义交给它。
|
||||
Store 的注册没有「一个空类 + 需改点」那种逐对象模板,原因和别的机制不同:插件、可存档对象、光环这些机制都要你实现一个接口,所以能给出骨架;`addTile` / `addItem` / `addMap` / `addEvent` 不是这样——它们是<strong>逐条登记一条原始定义</strong>,你不需要实现任何方法,只需要把一条条定义交给它。
|
||||
|
||||
所以这里给的是「把一批登记集中到加载期」的机制骨架:
|
||||
所以这里的骨架是「一条定义长什么样」。下面以图块为例,复制后按 `[需改]` 修改:
|
||||
|
||||
```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
|
||||
});
|
||||
```
|
||||
|
||||
<strong>改什么</strong>:
|
||||
@ -193,11 +185,11 @@ export class MyContent {
|
||||
1. <strong>数据来源</strong>:把写死的 `num` / `id` 换成你内容文件里的条目;内容多时用循环读一遍,而不是手写几十条 `addTile`。
|
||||
2. <strong>键</strong>:`num` 与 `id` 必须唯一。撞了会告警并覆盖,等于悄悄丢掉了另一条定义。
|
||||
3. <strong>通行掩码</strong>:`outPass` / `inPass` 是方向位掩码(上 1、右 2、下 4、左 8),`0b1111` 表示四向都通。
|
||||
4. <strong>其它三个 Store</strong>:形状相同,把 `addTile` 换成对应调用即可——`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。所以这套骨架不需要为每个 Store 重写一遍。
|
||||
4. <strong>其它三个 Store</strong>:形状相同,把 `addTile` 换成对应调用即可——`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。所以这条骨架不需要为每个 Store 重写一遍。
|
||||
|
||||
<strong>注意</strong>:`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
|
||||
}
|
||||
```
|
||||
|
||||
<strong>复制什么</strong>:只复制 `add*` 调用那一段,粘进上面的时机方法里(或粘进模板类的 `register()` 里)。<strong>不含 `import`</strong>——插件的文件顶部通常已经有 `IDataCommon` / `ICoreState`,内容类型通常在 `@user/data-common` 里,已经引入过就不要重复粘;若编辑器报缺少某个名字,再在顶部补 `import ...`。
|
||||
<strong>复制什么</strong>:只复制上面的 `add*` 调用,粘进这个时机方法里。<strong>不含 `import`</strong>——插件的文件顶部通常已经有 `IDataCommon` / `ICoreState`,内容类型通常在 `@user/data-common` 里,已经引入过就不要重复粘;若编辑器报缺少某个名字,再在顶部补 `import ...`。
|
||||
|
||||
<strong>改什么</strong>:把 `num`、`id` 与各字段换成你自己的数据。图块数字要与你的内容对得上,道具的 `num` 要与对应图块数字一致,地图的 `floorId` 要与切楼层用的 id 一致。
|
||||
|
||||
@ -229,8 +220,6 @@ awake(state: ICoreState): void {
|
||||
|
||||
扩展 `IDataCommon` 意味着改动内置包,版本升级时要跟着改;而补条目只动你自己的内容,升级时几乎不用管。
|
||||
|
||||
<strong>如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。</strong>
|
||||
|
||||
## 常见错误
|
||||
|
||||
- <strong>在玩家动作里登记</strong>。运行时改定义不会传播到已创建的对象,还会绕过存档与录像。
|
||||
@ -280,7 +269,7 @@ console.log(raw, state.tileStore.getType(num));
|
||||
- 运行时调用 `add*` 会绕过存档、录像与一致性检查,所以只在加载期登记一次。
|
||||
- 读 Store 随时可以,但不要就地修改返回的对象;`eventStore.getEvent` 返回的是存储的实例。
|
||||
- `EnemyStore` 不在 `IDataCommon` 上,不要假设主对象能直接取到。
|
||||
- Store 注册没有逐对象模板,只有「在加载期集中登记」的机制骨架。
|
||||
- 不懂代码时优先在已有位置补条目,不要急着写新类或扩展主对象。
|
||||
- Store 注册没有逐对象模板,只有「一条定义长什么样」的骨架。
|
||||
- 需要新的原始数据表时优先按插件组织;不要急着扩展主对象。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
|
||||
@ -194,11 +194,9 @@ calculate(handler) {
|
||||
|
||||
可以用一句话概括:<strong>一次性动作用命令式,持续生效的规则用声明式</strong>。
|
||||
|
||||
## 模板:声明式扩展的通用骨架
|
||||
## 扩展点一览
|
||||
|
||||
下面给的是机制本身的通用骨架,不对应任何一种具体效果。具体效果只用来帮助理解,不要把它当成模板照抄。
|
||||
|
||||
先看各类扩展点分别要写什么、注册到哪里、引擎什么时候调用:
|
||||
前面五组机制各自要写什么、注册到哪里、引擎什么时候调用,放在一起看更清楚:
|
||||
|
||||
| 扩展点 | 你要实现的东西 | 注册入口 | 引擎调用时机 |
|
||||
| ---------- | -------------------------------------------- | ---------------------------------------- | ---------------------- |
|
||||
@ -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<IEnemyAttr, IHeroAttr> {
|
||||
// [模板] 构造时只保存长期引用;不要在这里改游戏状态
|
||||
constructor(readonly state: ICoreState) {}
|
||||
像 `aura.ts` 这种集中放置多个实现的文件,建议用 `//#region {code}-{name}` 到 `//#endregion` 包起来,结构更清晰,后续编辑器也可能据此识别。
|
||||
|
||||
// [需改] 方法名与参数照抄你实现的接口(如 calculate、apply、useEffect)
|
||||
run(handler: IReadonlyXxxHandler<IEnemyAttr, IHeroAttr>): IXxxResult {
|
||||
// [需改] 只读输入、算出结果;需要改状态时,放到引擎规定的时机里
|
||||
return /* [需改] 结果 */;
|
||||
}
|
||||
}
|
||||
## 改内置还是写成插件
|
||||
|
||||
// [模板] 注册一次,通常写在 initialize.ts 一类初始化函数里
|
||||
// [需改] 把 someSystem.useXxx 换成对应系统的注册方法
|
||||
state.someSystem.useXxx(new XxxHandler(state));
|
||||
```
|
||||
判断依据是改动落在谁的代码里,而不是改动大小。
|
||||
|
||||
复制后需要改的地方:
|
||||
|
||||
1. <strong>类名与接口名</strong>:`XxxHandler` 与 `IXxxHandler` 换成你实际实现的接口。
|
||||
2. <strong>方法名与签名</strong>:按接口来写,不同扩展点的方法名不同。
|
||||
3. <strong>方法体</strong>:只读输入、算出结果;需要改状态时,交给钩子或对应的时机。
|
||||
4. <strong>注册入口</strong>:按上表找到对应的注册方法,并放进初始化函数里。
|
||||
5. <strong>是否需要 `#region`</strong>:像 `aura.ts` 这种集中放置多个实现的文件,建议用 `//#region {code}-{name}` 到 `//#endregion` 包起来,结构更清晰,后续编辑器也可能据此识别。
|
||||
|
||||
示例一律写成 `class` 再 `new`,不要写成 `const obj = { ... }` 这种 plain object。例外是引擎本身就用对象字面量声明的内容(如 `src/content/item.ts` 的 `ItemEffect`),那里沿用现有写法即可。
|
||||
|
||||
## 改内置还是自己写
|
||||
|
||||
日常需求优先<strong>改内置</strong>:
|
||||
|
||||
- 特殊属性与光环:改 `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)。
|
||||
|
||||
<strong>如果你不怎么懂代码,建议直接改内置,而不是自己写新的类</strong>。自己写类要同时处理接口签名、注册时机与上下文刷新,改动面比在内置方法里加一个分支大得多,出错也更难排查。
|
||||
- 你要新增一个系统。新增系统优先通过插件实现,把改动留在 `src`;只有插件与现有扩展点都覆盖不了时,才去改内置包(`packages` / `packages-user`),见[插件系统](./plugin-system.md)。
|
||||
|
||||
## 常见误区
|
||||
|
||||
@ -301,6 +267,6 @@ if (system && info) {
|
||||
- 声明式不是不写代码,而是把「做什么」与「什么时候做」分开。
|
||||
- 持续生效的规则用声明式;玩家直接产生的一次性动作用命令式,但要计入录像。
|
||||
- 计算类扩展必须无副作用;状态修改放到引擎规定的时机里。
|
||||
- 改内置优先;确实需要独立扩展时再自己写类,新增系统优先考虑插件。
|
||||
- 新增系统优先写成插件;只有必要时才改内置包。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
|
||||
@ -273,46 +273,6 @@ layer.addHook(hook);
|
||||
<!-- TODO(advanced-II): 钩子的系统级消费与优先级 -->
|
||||
<!-- TODO(advanced-III): 钩子调度原理 -->
|
||||
|
||||
## 模板:一个带身份校验的钩子
|
||||
|
||||
<strong>目标</strong>:每层一个钩子实例,只对目标图层做事;如果回调回传了图块,再额外核对一次所属图层。
|
||||
|
||||
<strong>改哪里</strong>:`src/data/hook/` 下的现有钩子文件,或你自己的插件文件;类写好后,在能拿到目标图层的时机注册。
|
||||
|
||||
<strong>复制什么</strong>:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果的完整实现。
|
||||
|
||||
```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;
|
||||
// 在这里做事
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<strong>改什么</strong>:
|
||||
|
||||
1. 类名 `MyLayerHooks` 换成你的名字;
|
||||
2. `readonly layer` 保留,构造时传入目标图层;
|
||||
3. 方法体换成你的处理;
|
||||
4. 如果目标钩子不回传身份,不要把这个实例共享给多个图层,改为每层 `new` 一个。
|
||||
|
||||
<strong>怎么验证</strong>:见下面的「只处理指定图层」;核心是改目标图层会触发、改别的图层不触发。
|
||||
|
||||
<strong>注意</strong>:`readonly layer` 不是为了在 `onUpdateBlock` 里比较一个不存在的参数。身份来自「这个实例注册在哪个图层上」;动态图块方法里的 `tile.layer` 才是额外的运行时校验。
|
||||
|
||||
## 示例:只处理指定图层
|
||||
|
||||
<strong>目标</strong>:只对 `floor-1` 的 `ground` 图层响应图块更新,其他图层的变化不处理。
|
||||
@ -404,8 +364,6 @@ map.addHook(new TargetLayerResizeHooks(state, targetLayer));
|
||||
|
||||
如果这是一整套新系统,优先通过插件实现(见[插件系统](./plugin-system.md)),钩子在事件发生时调用它。这样新增逻辑留在 `src` 里,升级内置包时要跟的差异更少。
|
||||
|
||||
<strong>如果你不怎么懂代码,建议直接改内置钩子,而不是自己写新的类。</strong>内置类已经注册好,改方法体就能生效;自己写类还要处理身份、注册时机与移除,排查面更大。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- <strong>把地图级钩子挂到图层上</strong>。`layer.addHook` 接收的是 `IMapLayerHooks`,地图级方法名对不上,永远不会被调用。反过来,把图层钩子挂到地图上也不会收到图层事件。
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
| 注册一组内容定义(图块、道具、地图、事件) | 在加载期集中登记,不新建 Store | 现有四个 Store 装不下,且确实需要新的系统级数据表时 |
|
||||
| 给会改状态的方法补录像标记 | 在内置方法上加一行装饰器 | 需要新的录像入口或自定义录像行为时(属于 `高级 II · 自定义录像行为`) |
|
||||
|
||||
<strong>如果你不怎么懂代码,优先改内置已有实现,而不是自己造一套。</strong>在内置类里补几行,出问题时对照原代码就能看出差异;新建插件或新类要同时处理注册、依赖、时机与存档 id,排查面大得多。
|
||||
判断标准不是改动多少行,而是改动落在谁的代码里:能由插件或内容定义完成的,就留在 `src`;必须动内置包时,尽量把差异收敛在一个方法或一条分支上,方便升级时对照。
|
||||
|
||||
## 本层不覆盖什么
|
||||
|
||||
|
||||
@ -242,7 +242,7 @@ if (holder && holder.status === PluginStatus.Loaded) {
|
||||
|
||||
然后在 `list.ts` 里 `new` 出来加进 `dataPlugins`。最终传给 `CoreState` 的插件数组在 `src/data/entry.ts` 里拼成:主插件在前,`dataPlugins` 展开在后。日常只需要改 `list.ts`,不必动 `entry.ts`。
|
||||
|
||||
这是少数<strong>应该新建文件与目录</strong>的场景:插件本身就是一个独立单元,把它的类、接口和对象放在一起,比全部塞进 `initialize.ts` 更容易维护。除此之外的普通效果仍然优先改内置文件,不要为每个效果新建目录。
|
||||
这是少数<strong>应该新建文件与目录</strong>的场景:插件本身就是一个独立单元,把它的类、接口和对象放在一起,比全部塞进 `initialize.ts` 更容易维护。除此之外的普通效果直接写在对应的内置实现文件里,不要为每个效果新建目录。
|
||||
|
||||
## 模板:一个插件的最小骨架
|
||||
|
||||
@ -374,16 +374,14 @@ async afterLoad(state: ICoreState): Promise<void> {
|
||||
|
||||
它同时说明了另一个点:`afterLoad` 拿到的 `state` 与 `awake` 是同一个主对象,`awake` 里存下的引用(如 `this.heroFace`)此时仍然可用。
|
||||
|
||||
## 改内置还是自己写
|
||||
## 改现有插件还是新建插件
|
||||
|
||||
先分清两种改动:
|
||||
|
||||
- 只是给现有插件加一点行为,例如给商店多一个查询接口、调整某个系统的默认值——<strong>直接改现有插件的代码</strong>,在它的类里加方法或改实现;
|
||||
- 要加一组独立的功能,有自己的状态、自己的管理器、自己的存档内容——<strong>新建插件</strong>。
|
||||
- 要加一组独立的功能,有自己的状态、自己的管理器、自己的存档内容——<strong>新建插件</strong>。新建的插件同样留在 `src` 里,升级时不用跟着内置包的差异走。
|
||||
|
||||
新建插件比改内置多出不少工作:目录、注册、依赖声明、存档 id、加载时机都要自己定。能改内置解决的问题,不值得再包一层。
|
||||
|
||||
<strong>如果你不怎么懂代码,建议直接改内置,而不是自己写新的插件</strong>。在内置类的方法里加几行,出问题时对照原有代码就能看出差异;新建插件则要同时处理注册、依赖与时机,排查面大得多。
|
||||
这里说的「改内置」只指改 `packages` / `packages-user` 里的代码。新增系统优先做成插件,正是为了不碰它们;只有现有插件与扩展点都覆盖不了、必须替换内置行为时,才去动内置包。
|
||||
|
||||
## 常见错误
|
||||
|
||||
@ -446,6 +444,6 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
|
||||
- `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。
|
||||
- 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。
|
||||
- 插件 id 是对外契约,不要随意改名或复用;它与可存档 id 是两套命名。
|
||||
- 优先改内置;需要独立成组的功能时再写插件。
|
||||
- 新增系统优先写成插件;只有必要时才改内置包。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
|
||||
@ -143,36 +143,6 @@ Mota.require('@user/data-common').logReplaySafetyDetail(0);
|
||||
|
||||
两个装饰器都按「类方法装饰器」实现,只能写在 `class` 的方法上方。写在普通函数、对象属性、箭头函数上都不会生效。
|
||||
|
||||
## 模板:给方法加录像标记
|
||||
|
||||
这一节给的是<strong>录像标记这类机制的通用骨架</strong>,不是某条具体录像指令的示例。某条指令怎么实现、主动录像与被动录像怎么写,属于自定义录像行为,见 `高级 II · 自定义录像行为`;录像格式与沙盒的深层原理见 `高级 III · 录像深层原理`。
|
||||
|
||||
```ts
|
||||
// [模板] 给类的方法加录像标记:复制后按 [需改] 修改
|
||||
export class MySystem {
|
||||
// [需改] 方法名换成你真正会改变游戏状态的方法
|
||||
@shouldReplay('[需改] 这个方法改变了什么状态')
|
||||
changeSomething(value: number): void {
|
||||
// [需改] 真正修改状态的内容
|
||||
}
|
||||
|
||||
// [需改] 只在确实不该计入录像的被动路径上加,并把理由写清楚
|
||||
@ignoreReplay('[需改] 为什么这段不该计入录像')
|
||||
async onDelayedSomething(): Promise<void> {
|
||||
// [需改] 延迟触发、不由玩家动作直接产生的逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<strong>改什么</strong>:
|
||||
|
||||
1. <strong>标记类型</strong>:会改状态的用 `@shouldReplay`;确实被动、不该计入的才用 `@ignoreReplay`。只读方法两个都不要。
|
||||
2. <strong>方法名</strong>:换成你自己的方法。装饰器必须紧贴方法声明,中间不要留空行。
|
||||
3. <strong>消息文本</strong>:写清「这个方法改变了什么状态」。它会出现在告警里(形如 `方法名: 消息`),是给人排查的线索,不要照抄示例里的占位文字。
|
||||
4. <strong>引入</strong>:顶部需要 `import { shouldReplay, ignoreReplay } from '@user/data-common';`。若编辑器报缺少 `shouldReplay`,再补这一行;已经引入过就不要重复粘。
|
||||
|
||||
<strong>注意</strong>:一个方法只需要一个标记,两个一起写含义互相矛盾。`@ignoreReplay` 不到万不得已不要用——它的作用范围是整段区间,不是这一个方法。
|
||||
|
||||
## 给一个内置方法补录像标记
|
||||
|
||||
这一节按六步给出可直接照做的改法。
|
||||
@ -215,17 +185,16 @@ endReplaySafetyCollection();
|
||||
绝大多数情况不需要新写类:
|
||||
|
||||
- 给一个会改状态的入口补标记——直接在已有实现文件里改,例如 `packages-user/data-base/src/flag/`、`hero/`、`map/` 下对应的文件;
|
||||
- 只有在确实需要新的录像入口(新的指令、新的主动或被动行为)时,才考虑新写类,而这已经超出「标记」的范围,属于自定义录像行为(`高级 II · 自定义录像行为`)。
|
||||
- 只有在确实需要新的录像入口(新的指令、新的主动或被动行为)时,才考虑新写类。这已经超出「标记」的范围,属于自定义录像行为(`高级 II · 自定义录像行为`)。
|
||||
|
||||
补一行装饰器的成本极低,而且出问题时对照原代码就能看出差异;自己写新类则要同时处理录制时机、参数编码与顺序,排查面大得多。
|
||||
|
||||
<strong>如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。</strong>
|
||||
补一行装饰器的成本极低,对照原代码就能看出差异。需要一整套新系统时,优先用插件组织,把录像入口收敛在插件自己的方法上,见[插件系统](./plugin-system.md)。录像格式与沙盒的深层原理留到 `高级 III · 录像深层原理`。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- <strong>给只读方法或计算器加 `@shouldReplay`</strong>。它们不产生录像步,只会让告警列表变长,把真正的漏记埋掉。
|
||||
- <strong>用 `@ignoreReplay` 压告警</strong>。它忽略的是整段区间,真正的漏记会一起被放过。
|
||||
- <strong>以为加了装饰器就会自动记录</strong>。装饰器只登记路径,录像步仍要 `record` 或走内置录制入口。
|
||||
- <strong>同一个方法写两个标记</strong>。`@shouldReplay` 与 `@ignoreReplay` 同时写含义互相矛盾;一个方法只需要一个标记,装饰器要紧贴方法声明。
|
||||
- <strong>在异步回调里记录却没处理顺序</strong>。录像步会落在错误的时点,检查也看不出来。
|
||||
- <strong>在读档过程中记录</strong>。`loadState` 被 `disable` 包裹,记录不会生效;执行器不在这层保护里,反而要更谨慎。
|
||||
- <strong>把装饰器写在普通函数上</strong>。它只支持类的方法。
|
||||
@ -261,6 +230,6 @@ endReplaySafetyCollection();
|
||||
- 检查能发现「区间内一条录像步都没有」,但不管逻辑、参数、顺序与一一对应。
|
||||
- 收集区间目前只有测试在开启;需要时手动 `beginReplaySafetyCollection` / `endReplaySafetyCollection`。
|
||||
- 读档期间录像被临时禁用,执行器不在保护范围内。
|
||||
- 不懂代码时优先改内置加一行标记,不要急着写新类。
|
||||
- 需要新的录像入口时再扩展;一整套新系统优先用插件组织。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
|
||||
@ -236,7 +236,7 @@ state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor());
|
||||
|
||||
<strong>改哪里</strong>:放进你自己的插件目录,通常与插件类放在一起;不需要为它单独新建一套目录。
|
||||
|
||||
<strong>复制什么</strong>:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果,计数器示例只是用法演示,不要拿示例当模板。
|
||||
<strong>复制什么</strong>:下面是机制本身的通用骨架,复制后按 `[需改]` 修改。它不是某个具体效果,下面的示例只演示怎么把它接到插件里。
|
||||
|
||||
```ts
|
||||
// [模板] 一个可存档对象的最小骨架:复制后按 [需改] 修改
|
||||
@ -279,57 +279,26 @@ export class MySaveable implements ISaveableContent<IMySave> {
|
||||
|
||||
<strong>注意</strong>:`saveState` 里不要修改对象状态,也不要返回 `this` 或带方法的类实例。`loadState` 不要假设旧数据还在,先清空再重建,才不会残留上次的状态。
|
||||
|
||||
## 示例:插件里的计数器
|
||||
## 示例:把可存档对象接到插件里
|
||||
|
||||
<strong>目标</strong>:插件维护一组计数器,读档后计数能恢复。
|
||||
<strong>目标</strong>:让上一节模板里的对象跟着插件一起注册,并能在读档后恢复。
|
||||
|
||||
<strong>改哪里</strong>:你插件的 `plugin.ts` 与它自己的对象文件。下面先给计数器类,再给插件的注册代码。
|
||||
<strong>改哪里</strong>:你插件的 `plugin.ts`。
|
||||
|
||||
<strong>复制什么</strong>:计数器类:
|
||||
<strong>复制什么</strong>:只复制持有与注册的那几行:
|
||||
|
||||
```ts
|
||||
// [需改] 插件里的计数器
|
||||
export interface ICounterSave {
|
||||
readonly values: Map<string, number>;
|
||||
}
|
||||
|
||||
export class Counter implements ISaveableContent<ICounterSave> {
|
||||
private readonly values: Map<string, number> = 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<void> {}
|
||||
@ -338,27 +307,25 @@ export class CounterPlugin implements IDataPluginDefine {
|
||||
}
|
||||
```
|
||||
|
||||
如果文件顶部报缺少 `ISaveableContent`,再补 `import { ISaveableContent } from '@user/data-common';`;通常插件文件已经引入过相关类型,不要重复粘。
|
||||
|
||||
<strong>改什么</strong>:
|
||||
|
||||
1. <strong>存档 id</strong>:`@plugin/example-counter` 换成你自己的 id,保证唯一,并与插件 id 分开命名。
|
||||
2. <strong>键与值</strong>:`add(key, value)` 的键换成你的业务键;需要存字符串就改 `Map` 的值类型。
|
||||
3. <strong>方法</strong>:`add`、`get` 只是演示,按你的需求增删;存档形状 `ICounterSave` 跟着改。
|
||||
1. <strong>类名与 id</strong>:`MyPlugin` 与 `my-plugin` 换成你的插件名,`id` 全局唯一。
|
||||
2. <strong>持有字段</strong>:`store` 与 `MySaveable` 换成模板里你的类;对象构造需要 `state` 时,也在这里传入。
|
||||
3. <strong>存档 id</strong>:`@plugin/my-store` 换成你自己的 id,保证唯一,并与插件 id 分开命名。
|
||||
4. <strong>注册位置</strong>:必须在 `awake` 里注册,且只注册一次。
|
||||
|
||||
<strong>怎么验证</strong>:启动后调用 `counter.add('kill', 1)`,存档,再 `add` 一次让它变成 2,然后读档。读档后 `counter.get('kill')` 应回到 1。如果还是 2,检查 `loadState` 有没有先 `clear()`;如果读档后 `get` 报「不是函数」,检查是不是把类实例直接塞进了存档。
|
||||
文件顶部通常已经有 `IDataPluginDefine` / `ICoreState`;若报缺少 `MySaveable` 或 `ISaveableContent`,再补对应的 `import`,不要重复粘。
|
||||
|
||||
## 改内置还是自己写
|
||||
<strong>怎么验证</strong>:启动后把某个键改到一个可观察的值,存档;再改一次,然后读档。读档后应回到存档时的值。如果没恢复,检查 `loadState` 有没有先 `clear()`;如果读档后调用方法报「不是函数」,检查是不是把类实例直接塞进了存档。
|
||||
|
||||
## 改内置还是写成插件
|
||||
|
||||
先看你要改的东西属于哪一类:
|
||||
|
||||
- 只是给现有系统补存档,例如某个内置对象漏了一个字段——<strong>直接改它的 `saveState` 与 `loadState`</strong>,不要在外面再包一层;
|
||||
- 要新增一组独立功能,有自己的状态、自己的管理器、自己的生命周期——做成插件,并给插件里的对象实现 `ISaveableContent`。
|
||||
|
||||
能改内置解决的问题,不值得再新建一个可存档对象。新建意味着多一个 id、多一处注册、多一份读档顺序要维护;出问题时,排查面也更大。
|
||||
|
||||
<strong>如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。</strong>在内置类的 `saveState` 里补一个字段、在 `loadState` 里补一行恢复,改动小、对照原代码就能看出差异;自己写新类则要同时处理注册、存档形状、读档顺序与引用解析。
|
||||
新增系统优先走插件:可存档对象跟着插件留在 `src`,升级内置包时不必对照差异。只有必须修改内置对象的存档行为时,才动 `packages` / `packages-user` 里的实现。
|
||||
|
||||
## 常见错误
|
||||
|
||||
@ -415,6 +382,6 @@ console.log(content);
|
||||
- 存档与读档按注册顺序执行;执行器在该对象 `loadState` 后立即运行,且不被录像禁用包裹。
|
||||
- 插件 `afterLoad` 只在初次加载后运行一次,不能替代读档执行器。
|
||||
- 跨对象引用存 id 或键,读档时按顺序或延迟解析,不要存活对象引用。
|
||||
- 不懂代码时优先改内置的 `saveState` / `loadState`,不要急着写新类。
|
||||
- 新增系统优先通过插件承载可存档对象;只有必要时才改内置的存档实现。
|
||||
|
||||
返回[高级 I 导览](./index.md)。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user