docs(10-32): 移除高级教程模板/需改标记(D-83)

This commit is contained in:
unanmed 2026-10-24 20:10:45 +08:00
parent f4edda66c2
commit 6846c211c7
11 changed files with 59 additions and 47 deletions

View File

@ -61,3 +61,15 @@
- `mover.md`:新增首节「移动器模型」;删除正文中的使用概率与源码 / 实现差异叙述;保留 D-76 面朝朝向与移动朝向的语义区分。
- `face-handler.md`:首节改为「朝向模型」,补齐五个组件与两条链路;删除源码级与实现 / 注释差异说明;自定义一节改为六边形布局示例(`HexFaceHandler`,六个邻接方向),注册改用字符串 id `hex`;验证段同步改为六方向检查。
- 验证:`prettier --write` 两篇 unchanged;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete in 17.97s`,无 dead link;CRLF 与单 H1 保持。
## 第 3 轮(2026-10-24)
**用户意见:** 高级教程不要写「模板」「[需改]」等标记或模板章节(D-83);提示性内容只保留注释说明下一行做什么;游戏领域的「怪物模板 / 原始模板 / 默认模板」术语保留。
### 执行记录
- 仅修改 `docs/advanced/**/*.md`;未改代码、配置、状态文件,未写 SUMMARY。
- 移除全部 `[需改]` 标记(`mover.md`、`face-handler.md`、`ii/adding-enemy-attribute.md`、`ii/adding-hero-attribute.md`),对应注释改为说明下一行做什么。
- 移除文档模板措辞:`hooks-in-depth.md`、`plugin-system.md`(含两个章节标题与代码注释)、`declarative-vs-imperative.md`、`replay-consistency.md`、`saveable-content.md`、`data-stores.md`;改用「示例」或直接去掉标签。
- 保留游戏领域术语:`怪物模板`、`原始模板`、`默认模板`、`怪物属性模板` 等。
- 验证:`prettier --write` 十篇 unchanged;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete in 18.62s`,无 dead link;CRLF 与单 H1 保持。

View File

@ -16,7 +16,7 @@ Store 是查表用的注册表,不进存档。勇士、地图、Flag 这些游
## 怎么读 Store
五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。它们不是需要你实现的模板——接口已经由 `data-common` 提供,你只需要调用。
五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。它们不是需要你补全的半成品——接口已经由 `data-common` 提供,你只需要调用。
查询接口在找不到内容时不会抛错:取定义一般返回 `null`,换算 id 与数字一般返回 `undefined`,取图块类型返回 `TileType.Unknown`,取默认事件返回空 `Map`。所以读之前先判断结果是否存在,再使用。

View File

@ -51,7 +51,7 @@ function healHero(hero: IHeroState<IHeroAttr>): void {
### 伤害计算器
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。如果你要替换内置公式,可以参考下面的模板;它只负责声明计算规则,注册那一步由 `useCalculator` 完成:
<strong>你声明什么</strong>:一组属性到伤害结果的换算规则。如果你要替换内置公式,可以参考下面的示例;它只负责声明计算规则,注册那一步由 `useCalculator` 完成:
```ts
import { IEnemyAttr, IHeroAttr } from '@user/data-common';
@ -62,7 +62,7 @@ import {
IReadonlyDamageHandler
} from '@user/data-system';
// 伤害计算器模板:只读输入,返回这次战斗的伤害与回合数
// 伤害计算器:只读输入,返回这次战斗的伤害与回合数
export class MyDamageCalculator implements IDamageCalculator<
IEnemyAttr,
IHeroAttr
@ -84,7 +84,7 @@ export class MyDamageCalculator implements IDamageCalculator<
}
```
模板声明了 `MyDamageCalculator` 类,实现 `IDamageCalculator` 接口。你需要改的是 `calculate` 的方法体,以及返回的 `damage` 与 `turn`;`getCriticalLimit` 只在临界值计算时用到,改公式时按内置实现 `MainDamageCalculator` 的写法保留即可。`calculate` 是日常唯一需要关心的成员:它接收 `handler`,返回 `{ damage, turn }`。内置实现通过 `useCalculator(new ...)` 注册一次。
示例声明了 `MyDamageCalculator` 类,实现 `IDamageCalculator` 接口。`calculate` 是日常唯一需要关心的成员:它接收 `handler`,返回 `{ damage, turn }`;`getCriticalLimit` 只在临界值计算时用到,按内置实现 `MainDamageCalculator` 的写法保留即可。内置实现通过 `useCalculator(new ...)` 注册一次。
<strong>引擎在哪个时机执行</strong>:任何需要伤害数值的地方。显示伤害预览、计算临界值、真正结算战斗,都会调用计算器;同一场战斗里,它可能被反复调用很多次。
@ -207,7 +207,7 @@ export class BadDamageCalculator implements IDamageCalculator<
## 什么时候仍然用命令式
声明式不是万能模板。下面这些场景仍然应该用命令式接口:
声明式不是万能的。下面这些场景仍然应该用命令式接口:
- <strong>玩家直接产生的一次性动作</strong>:走一步、使用一个道具、触发一段一次性剧情。它们的语义就是「现在立刻发生」。
- <strong>只在该时刻生效、且不需要回放的脚本</strong>:如果一段脚本的效果本来就只应该发生一次,写成命令式没有问题。

View File

@ -90,7 +90,7 @@ function targetOf(
const handler = state.faceManager.get<FaceDirection>(FaceGroup.Dir8);
if (!handler) return null;
// [需改] 把 direction 换成你要算的方向
// 把 direction 换算成一步的坐标偏移
const { x, y } = handler.movement(direction);
const loc = state.hero.getLocation();
return { x: loc.x + x, y: loc.y + y };
@ -143,7 +143,7 @@ import { ICoreState } from '@user/data-state';
function bindFacingTiles(state: ICoreState): void {
const binder = state.roleFace;
// [需改] 1 是主图块数字(朝下),2 是同一角色的另一张朝向图块
// 把图块 1 登记为朝下的主图块,再把图块 2 绑成它的朝右图
binder.malloc(1, FaceDirection.Down);
binder.bind(2, 1, FaceDirection.Right);
@ -198,13 +198,13 @@ function bindFacingTiles(state: ICoreState): void {
- `mapMovement` 必须包含 `Unknown` 及其零偏移,调用方靠这张表把每个方向都映射到偏移。
- `dirOf` 匹配不到时返回 `Unknown`,不要抛错;只遍历本组自己的偏移表。
下面以六边形布局为例:只认左右与四个斜向,上下不是邻接方向。偏移量按示例的行列坐标给出,换坐标系时替换成自己的增量。`[需改]` 标出需要按布局替换的部分:
下面以六边形布局为例:只认左右与四个斜向,上下不是邻接方向。偏移量按示例的行列坐标给出,换成别的坐标系时增量随之改变:
```ts
import { IFaceDescriptor, IFaceHandler } from '@motajs/common';
import { FaceDirection } from '@user/data-common';
// [需改] 降级表:六边形只认六个邻接方向,其余方向无法降级
// 降级表:六边形只认六个邻接方向,其余方向无法降级
const HEX_DEGRADE: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Left, FaceDirection.Left],
[FaceDirection.Right, FaceDirection.Right],
@ -214,7 +214,7 @@ const HEX_DEGRADE: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.RightDown, FaceDirection.RightDown]
]);
// [需改] 运动表:六个方向的单步偏移,必须包含 Unknown 的零偏移
// 运动表:六个方向的单步偏移,必须包含 Unknown 的零偏移
const HEX_MOVEMENTS: ReadonlyMap<FaceDirection, IFaceDescriptor> = new Map([
[FaceDirection.Unknown, { x: 0, y: 0 }],
[FaceDirection.Left, { x: -1, y: 0 }],

View File

@ -2,7 +2,7 @@
中级教程的[钩子](../intermediate/hooks.md)回答的是「什么需求该用哪个钩子」;这一篇讲另一件事:怎么把钩子限定到你关心的那个对象上。做法很简单——<strong>建议一个对象一个钩子实例</strong>。共享实例是允许的,但不建议;除非你明确了解钩子的内部实现与后果,否则不要这样做。
钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再给一个完整模板,最后是注册、移除与异步的接口事实。
钩子接口本身不难,容易出错的是「挂上去了,触发的却不只是你想管的那张地图、那个图层」。下面先讲实例与对象的关系,再给一个完整示例,最后是注册、移除与异步的接口事实。
阅读前需要会基础语法:`class`、`new`、`import`、`async` / `await`。本篇只讲数据端,示例都按当前源码里的接口书写。
@ -22,7 +22,7 @@
## 示例:图层级钩子
如果你想给某个图层挂一个只处理它的钩子,可以参考下面的模板:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。
如果你想给某个图层挂一个只处理它的钩子,可以参考下面的示例:钩子类只处理注册它的那个图层,注册函数负责找到目标图层并把实例挂上去。
```ts
import { IMapLayer, IMapLayerHooks } from '@user/data-base';
@ -69,9 +69,9 @@ export function registerGroundHooks(
}
```
这个模板声明了 `GroundBlockHooks` 类,实现 `IMapLayerHooks`;`registerGroundHooks` 负责按 `alias` 找到目标图层,再调用 `layer.addHook` 注册实例。你需要改的是:目标楼层 id 与图层 `alias`、`onUpdateBlock` 的方法体,以及 `registerGroundHooks` 的调用位置(通常放在插件的 `afterLoad`)。
这个示例声明了 `GroundBlockHooks` 类,实现 `IMapLayerHooks`;`registerGroundHooks` 负责按 `alias` 找到目标图层,再调用 `layer.addHook` 注册实例。
模板里的 `readonly layer` 让钩子能访问目标图层本身(读宽高、调用图层方法),不是用来比较一个不存在的参数。`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。
示例里的 `readonly layer` 让钩子能访问目标图层本身(读宽高、调用图层方法),不是用来比较一个不存在的参数。`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。
如果钩子要在构造时保持通用、注册时才确定图层,可以在 `awake` 里取控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`:

View File

@ -158,7 +158,7 @@ export class EnemyAttributeCalculator implements IDamageCalculator<
const monDef = enemy.getAttribute('def');
const monHp = enemy.getAttribute('hp');
// [需改] 护甲是怪物受到的百分比减伤,先夹到 0 到 100
// 护甲是怪物受到的百分比减伤,先夹到 0 到 100
// 夹紧可以避免负伤害,也避免超过 100 后变成「反向治疗」
const armor = Math.min(Math.max(enemy.getAttribute('armor'), 0), 100);
// 勇士每回合伤害:先减怪物防御,再乘护甲系数
@ -171,7 +171,7 @@ export class EnemyAttributeCalculator implements IDamageCalculator<
// 回合数随每回合伤害同步变化:护甲越高,需要的回合越多
const turn = Math.ceil(monHp / heroPerDamage);
// [需改] 攻速缩放怪物每回合伤害;100 表示正常速度
// 攻速缩放怪物每回合伤害;100 表示正常速度
// 用 Math.max 防止负数攻速把伤害变成治疗
const atkSpeed = Math.max(enemy.getAttribute('atkSpeed'), 0);
let enemyPerDamage = Math.max(monAtk - def, 0) * (atkSpeed / 100);
@ -229,7 +229,7 @@ export class ArmorAura implements IAuraView<IEnemyAttr, void> {
handler: IEnemyHandler<IEnemyAttr, IHeroAttr>,
baseEnemy: IReadonlyEnemy<IEnemyAttr>
): void {
// [需改] 修改的是计算后怪物;光环的加成会进入伤害计算
// 修改的是计算后怪物;光环的加成会进入伤害计算
handler.enemy.addAttribute('armor', 10);
// 若光环要按目标原始值算百分比,应读取 baseEnemy,而不是 handler.enemy
@ -275,7 +275,7 @@ export class PackAtkSpeedQuery implements IEnemyCommonQueryEffect<
special: ISpecial<any>,
query: () => IReadonlyEnemyContext<IEnemyAttr, IHeroAttr>
): void {
// [需改] 必须通过 query() 取上下文:
// 必须通过 query() 取上下文:
// 它会把「这只怪物查询过外部状态」登记下来,局部刷新时才会重算它
const ctx = query();
let count = 0;
@ -293,7 +293,7 @@ export class PackAtkSpeedQuery implements IEnemyCommonQueryEffect<
}
}
// [需改] 每有一个同类,攻速提高 5
// 每有一个同类,攻速提高 5
handler.enemy.addAttribute('atkSpeed', count * 5);
}
}
@ -302,7 +302,7 @@ export class PackAtkSpeedQuery implements IEnemyCommonQueryEffect<
注册时把效果绑定到一个特殊属性 `code` 上:
```ts
// [需改] code 换成触发这条查询的特殊属性编号(示例自选 40)
// 把这条查询绑定到特殊属性 40;挂上该属性的怪物才会执行它
state.enemyContext.registerCommonQueryEffect(40, new PackAtkSpeedQuery());
```
@ -326,7 +326,7 @@ export class ArmorFinalEffect implements IEnemyFinalEffect<
readonly priority = 0;
apply(handler: IEnemyHandler<IEnemyAttr, IHeroAttr>): void {
// [需改] 最终阶段只改自身基础属性,不再查询上下文
// 最终阶段只改自身基础属性,不再查询上下文
handler.enemy.addAttribute('armor', 5);
}
}

View File

@ -220,7 +220,7 @@ export class MainDamageCalculator implements IDamageCalculator<
let heroPerDamage = 0;
let enemyPerDamage = 0;
// [需改] 攻速缩放勇士每回合伤害。
// 攻速缩放勇士每回合伤害。
// 插入点就是原来的 `heroPerDamage += atk - monDef`:
// 必须在 `turn = Math.ceil(monHp / heroPerDamage)` 之前,
// 也要在下面的 `<= 0` 早退之前,否则攻速为 0 时会除出 Infinity。
@ -328,7 +328,7 @@ export class MainDamageCalculator implements IDamageCalculator<
damage += handler.state.flags.getFieldValueDefaults('hatred', 0);
}
// [需改] 护甲按最终伤害百分比减伤。
// 护甲按最终伤害百分比减伤。
// 放在魔防、负伤归零、固伤、仇恨之后,因此这些部分都会被护甲缩放;
// 放在自爆之前:自爆只把低于 hp - 1 的伤害抬到下限,下限本身不缩放。
// 如果你希望固伤/仇恨绕过护甲,把这段移到固伤之前。

View File

@ -41,7 +41,7 @@ import { FaceDirection } from '@user/data-common';
import { ICoreState } from '@user/data-state';
async function heroStepRight(state: ICoreState): Promise<void> {
// [需改] 换成你要的方向与步数
// 向右走一格并开始执行,拿到本次执行的控制器
const controller = state.hero.location.mover
.step(FaceDirection.Right)
.start();
@ -64,7 +64,7 @@ async function pushTileRight(layer: IMapLayer): Promise<void> {
const tile = layer.transferToDynamic(5, 5);
if (!tile) return;
// [需改] 换成你要的方向与步数
// 向右推一格并开始执行,拿到本次执行的控制器
const controller = tile.mover.step(FaceDirection.Right).start();
await controller?.onEnd;
}
@ -133,7 +133,7 @@ async function walkRightThenDown(state: ICoreState): Promise<void> {
const mover = state.hero.location.mover;
const before = state.hero.getLocation();
// [需改] 按需求换成你要的步数与方向
// 链式排出三个步骤:向右两格、转向下、再向下一格
mover
.step(FaceDirection.Right, 2) // 向右两格
.face(FaceDirection.Down) // 只转向,不移动
@ -276,7 +276,7 @@ const { ignoreTerrain, autoSave } = state.hero.location.mover.getConfig();
- `onStepEnd` 计算这一步的落点,返回一个 `ITileLocator`。基类拿它和当前坐标比较,不同才调用 `tile.setPos`。不要在这里自己写坐标。
- `onStepSettled` 在坐标写入之后运行,参数里带着移动前坐标 `before` 与落点 `curr`。这里适合触发进入 / 离开 / 踩踏一类的事件,或者把朝向同步回对象。
最小实现如下,`[需改]` 标出需要按需求替换的部分:
最小实现如下:
```ts
import {
@ -297,7 +297,7 @@ const enum SimpleMoveCode {
Blocked
}
// 只处理四方向与瞬移的最小移动器;T 换成你自己的可移动对象
// 只处理四方向与瞬移的最小移动器;T 是可移动对象类型
export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
constructor(
readonly tile: T,
@ -314,7 +314,7 @@ export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
protected async onStepStart(
step: Readonly<ObjectMoveStep>
): Promise<number> {
// [需改] 在这里决定这一步能不能走
// 跳跃步骤判为不可执行,其余步骤放行
return step.type === ObjectMoveType.Jump
? SimpleMoveCode.Blocked
: SimpleMoveCode.Success;
@ -349,7 +349,7 @@ export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
}
}
// [需改] 把朝向同步给对象;方法体里有写操作,所以要标 @shouldReplay
// 标 @shouldReplay:重放时也要执行这个会写朝向的方法
@shouldReplay('Simple tile facing should be replayed.')
protected async onStepSettled(
before: ITileLocator,
@ -407,7 +407,7 @@ export class NoLeftMoveTopImpl implements IHeroMoveTopImpl {
this.base = new DefaultHeroMoveTopImpl(state);
const inner = this.base.predicate();
this.passPredicate = {
// [需改] 把这里换成你要拦的条件
// 与默认谓词组合:向左的步骤一律不允许
canPass: handler =>
handler.direction !== FaceDirection.Left &&
inner.canPass(handler),

View File

@ -85,9 +85,9 @@ export const clientPlugins: IClientPluginDefine[] = [
数据端插件实现 `IDataPluginDefine`(来自 `@user/data-state`),渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`)。两者都由 `id`、依赖声明与三个时机方法组成;渲染端多一份 `clientDeps`,生命周期方法也多一个依赖参数。
如果你想新增一个插件,可以参考下面两个模板。数据端与渲染端各一份,目录、类名与公开成员换成你自己的,其余结构保持不变。
如果你想新增一个插件,可以参考下面两个示例。数据端与渲染端各一份,结构相同。
### 数据端插件模板
### 数据端插件示例
```ts
// src/data/plugin/my-plugin/types.ts
@ -105,7 +105,7 @@ export interface IMyPlugin extends IDataPluginDefine {
import { ICoreState, IDataPlugin } from '@user/data-state';
import { IMyPlugin } from './types';
// 数据端插件模板
// 数据端插件类;注册表按 id 创建它
export class MyPlugin implements IMyPlugin {
// 插件标识符:在数据端注册表里唯一
readonly id = 'my-plugin';
@ -136,7 +136,7 @@ export class MyPlugin implements IMyPlugin {
}
```
### 渲染端插件模板
### 渲染端插件示例
```ts
import {
@ -146,7 +146,7 @@ import {
} from '@user/client-modules';
import { IDataPlugin } from '@user/data-state';
// 渲染端插件模板:写在 src/client/plugin/my-client/plugin.ts
// 渲染端插件类,文件放在 src/client/plugin/my-client/plugin.ts
export class MyClientPlugin implements IClientPluginDefine {
// 插件标识符:在渲染端注册表里唯一
readonly id = 'my-client-plugin';
@ -180,7 +180,7 @@ export class MyClientPlugin implements IClientPluginDefine {
}
```
两个模板都声明了一个实现插件接口的类:数据端的 `MyPlugin` 实现 `IMyPlugin extends IDataPluginDefine`,渲染端的 `MyClientPlugin` 实现 `IClientPluginDefine`。它们都没有构造器,插件需要的对象要在 `awake` 里自己创建。你需要改的是:`id`(在对应端唯一)、依赖数组(没有就留空)、以及三个时机方法的方法体——`awake` 里装配对象、`initialize` 里做加载前的异步准备、`afterLoad` 里做依赖加载内容的初始化。数据端模板把对外接口放在 `types.ts`,是为了让依赖它的插件能按接口调用;渲染端模板暂时没有单独拆文件。
两个示例都声明了一个实现插件接口的类:数据端的 `MyPlugin` 实现 `IMyPlugin extends IDataPluginDefine`,渲染端的 `MyClientPlugin` 实现 `IClientPluginDefine`。它们都没有构造器,插件需要的对象要在 `awake` 里自己创建。三个时机方法各管一段:`awake` 里装配对象、`initialize` 里做加载前的异步准备、`afterLoad` 里做依赖加载内容的初始化。数据端示例把对外接口放在 `types.ts`,是为了让依赖它的插件能按接口调用;渲染端示例暂时没有单独拆文件。
### 逐项说明

View File

@ -30,7 +30,7 @@
## 给会改状态的方法加标记
标记加在<strong>你自己的、会改状态的方法</strong>上。如果你不确定该把标记写在哪里,可以参考下面的模板:它把一个会改状态的公开方法标成「应计入录像」,其余成员用注释省略。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记:
标记加在<strong>你自己的、会改状态的方法</strong>上。如果你不确定该把标记写在哪里,可以参考下面的示例:它把一个会改状态的公开方法标成「应计入录像」,其余成员用注释省略。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记:
```ts
// shouldReplay 从数据端公共包引入
@ -50,7 +50,7 @@ export class ShopSystem implements IShopSystem {
}
```
这个模板声明了 `ShopSystem` 类,实现 `IShopSystem` 接口;`@shouldReplay` 紧贴在 `buyItem` 方法上方,装饰的是这个方法。你需要改的是:把类与方法换成你真正会改状态的那一个,把 `message` 写成这个方法改变了什么状态;同一个类里还有其它会改状态的入口时,按同样方式逐个标注。
这个示例声明了 `ShopSystem` 类,实现 `IShopSystem` 接口;`@shouldReplay` 紧贴在 `buyItem` 方法上方,装饰的是这个方法。`message` 应当写清这个方法改变了什么状态;同一个类里还有其它会改状态的入口时,按同样方式逐个标注。
装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。

View File

@ -28,7 +28,7 @@
## 写一个可存档对象
可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。如果你有一份自定义状态需要跟着存档走,可以参考下面的模板;模板把两部分一次写完,后面再逐项解释。
可存档内容分两部分:一个实现 `ISaveableContent` 的对象,以及把它注册进存档系统的插件。如果你有一份自定义状态需要跟着存档走,可以参考下面的示例;示例把两部分一次写完,后面再逐项解释。
```ts
// src/data/plugin/my-plugin/state.ts
@ -41,10 +41,10 @@ export interface IMySave {
// 可存档对象:实现 ISaveableContent,泛型参数是存档形状
export class MySaveable implements ISaveableContent<IMySave> {
// 运行时的真实数据;换成你真正要保存的结构
// 运行时的真实数据:用 Map 保存键值对
private readonly values: Map<string, number> = new Map();
// 对外方法按你的需求增删
// 对外方法:按 key 写入数值
setValue(key: string, value: number): void {
this.values.set(key, value);
}
@ -108,9 +108,9 @@ export class MyPlugin implements IMyPlugin {
}
```
模板一共声明了三样东西:`state.ts` 里的 `IMySave` 是存档形状,`MySaveable` 实现 `ISaveableContent<IMySave>`,负责在活对象与存档数据之间转换;`types.ts` 里的 `IMyPlugin` 继承 `IDataPluginDefine`,把可存档对象作为公开成员暴露出去;`plugin.ts` 里的 `MyPlugin` 实现 `IMyPlugin`,在 `awake` 里创建对象并调用 `addSaveableContent` 注册。
示例一共声明了三样东西:`state.ts` 里的 `IMySave` 是存档形状,`MySaveable` 实现 `ISaveableContent<IMySave>`,负责在活对象与存档数据之间转换;`types.ts` 里的 `IMyPlugin` 继承 `IDataPluginDefine`,把可存档对象作为公开成员暴露出去;`plugin.ts` 里的 `MyPlugin` 实现 `IMyPlugin`,在 `awake` 里创建对象并调用 `addSaveableContent` 注册。
你需要改的是:`IMySave` 的字段(只保留重建必需的数据)、`MySaveable` 里真正的状态与对外方法、插件 `id`、注册 id,以及把 `MySaveable` 换成你自己的类。`initialize` / `afterLoad` 在模板里是空的,需要等加载内容时再填。下面解释 `saveState` / `loadState` 的约束,以及注册时要注意什么。
`IMySave` 的字段只保留重建必需的数据,`MySaveable` 里放真正的状态与对外方法,插件 `id` 与注册 id 分别命名。`initialize` / `afterLoad` 在示例里是空的,需要等加载内容时再填。下面解释 `saveState` / `loadState` 的约束,以及注册时要注意什么。
## saveState 与 loadState
@ -203,7 +203,7 @@ export class BadSaveable implements ISaveableContent<BadSaveable> {
### 压缩参数可以省略
`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`,TypeScript 允许实现时少写参数。模板里就没有写 `compression`:调用方仍会按接口传两个实参,多出的那个被忽略。需要按压缩级别改变存法时,再把 `compression: SaveCompression` 加回来。
`ISaveableContent` 声明的是 `saveState(compression)` / `loadState(state, compression)`,TypeScript 允许实现时少写参数。示例里就没有写 `compression`:调用方仍会按接口传两个实参,多出的那个被忽略。需要按压缩级别改变存法时,再把 `compression: SaveCompression` 加回来。
`saveState` 里不要修改对象状态;`loadState` 不要假设旧数据还在。
@ -341,7 +341,7 @@ import { SaveCompression } from '@user/data-common';
import { ICoreState, PluginStatus } from '@user/data-state';
import { IMyPlugin } from './types';
// 验证:存档 → 改状态 → 读档;IMyPlugin / MySaveable 换成你自己的定义
// 验证流程:存档 → 改状态 → 读档,确认恢复为存档时的值
export function verifySaveLoad(state: ICoreState): void {
const holder = state.getDataPlugin<IMyPlugin>('my-plugin');
if (!holder || holder.status !== PluginStatus.Loaded) return;
@ -371,7 +371,7 @@ export function verifySaveLoad(state: ICoreState): void {
## 小结
- 先写可存档对象与插件注册模板,再逐项调整存档形状、返回值与注册时机。
- 先写可存档对象与插件注册,再逐项调整存档形状、返回值与注册时机。
- 只存重建必需的状态;缓存、派生数据与 L2 系统不进存档。
- `ISaveableContent<T>` 的 `T` 是存档形状;`saveState` 必须返回独立于活对象的数据,顶层不会替你 `structuredClone`。
- 存档数据必须能被结构化克隆;类实例会丢原型与方法,读档时要自己重建。