docs(10-16): 核对源码并收口录像与 Store 文档

- 按 replay/func.ts 核对 shouldReplay 的路径登记、ignoreReplay 的整段忽略与 end 的三个提前返回
- 按核心源码说明收集入口目前只有测试调用,正文只描述「区间开启时」的口径
- 按 store 源码核对四个 Store 字段、键、读取返回形状与重复登记告警码
- 修正静态图块现查 / 动态图块缓存的差异,避免「改 Store 不影响已有对象」的过度概括
- prettier 通过,构建 green、build complete、无 dead link
This commit is contained in:
unanmed 2026-10-20 14:36:42 +08:00
parent 25e8dcb28b
commit 30b7d867c6
2 changed files with 63 additions and 63 deletions

View File

@ -10,22 +10,22 @@
先把三个容易混淆的区分摆清楚:
| 区分 | 一边 | 另一边 |
| -------------- | -------------------------------------- | ------------------------------------ |
| 定义与实例 | Store 里存的是「定义」 | 游戏里跑的是按定义构造出来的「实例」 |
| 注册表与存档 | Store 是查表用的注册表,不进存档 | 勇士、地图、Flag 等游戏状态才进存档 |
| 查表与改表 | 运行时读 Store 是正常操作 | 运行时写 Store 会绕过一整套机制 |
| 区分 | 一边 | 另一边 |
| ------------ | -------------------------------- | ------------------------------------ |
| 定义与实例 | Store 里存的是「定义」 | 游戏里跑的是按定义构造出来的「实例」 |
| 注册表与存档 | Store 是查表用的注册表,不进存档 | 勇士、地图、Flag 等游戏状态才进存档 |
| 查表与改表 | 运行时读 Store 是正常操作 | 运行时写 Store 会绕过一整套机制 |
Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之后整个游戏过程都靠它查表。它不是「玩家做了什么」的记录,而是「世界上有什么」的登记。
## 四个 Store 一览
| Store | 类型 | 存什么 | 键 | 常用读取 | 写入 |
| ------------ | --------------------------- | ------------------------ | ---------------- | --------------------------------------------------------------- | ------------------ |
| `tileStore` | `ITileStore` | 图块原始定义 | 图块数字 | `getData` / `getEvent` / `getType` / `num` / `id` / `identity` | `addTile` |
| `itemStore` | `IItemStore<IHeroAttr>` | 道具原始定义 | 道具图块数字 | `getData` / `getCategory` | `addItem` |
| `mapStore` | `IMapStore` | 地图原始数据 | `floorId` | `getMap` | `addMap` |
| `eventStore` | `IGameEventStore` | 事件实例 | 事件 id | `getEvent` | `addEvent` |
| Store | 类型 | 存什么 | 键 | 常用读取 | 写入 |
| ------------ | ----------------------- | ------------ | ------------ | -------------------------------------------------------------- | ---------- |
| `tileStore` | `ITileStore` | 图块原始定义 | 图块数字 | `getData` / `getEvent` / `getType` / `num` / `id` / `identity` | `addTile` |
| `itemStore` | `IItemStore<IHeroAttr>` | 道具原始定义 | 道具图块数字 | `getData` / `getCategory` | `addItem` |
| `mapStore` | `IMapStore` | 地图原始数据 | `floorId` | `getMap` | `addMap` |
| `eventStore` | `IGameEventStore` | 事件实例 | 事件 id | `getEvent` | `addEvent` |
四个 Store 都是 `IDataCommon` 上的只读字段,通过 `state` 直接访问,例如 `state.tileStore`。它们没有各自的「管理器」,也不需要你 `new` 一个出来。
@ -35,14 +35,14 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
以<strong>图块数字</strong>为键,存 `ITileRawData`:
| 字段 | 含义 |
| ----------- | ---------------------------------------------------------- |
| `num` | 图块数字 |
| `id` | 图块字符串 id |
| `events` | 默认事件,键为优先级、值为事件 id |
| `type` | 图块逻辑类型(`TileType`) |
| 字段 | 含义 |
| ----------- | -------------------------------------------------------------- |
| `num` | 图块数字 |
| `id` | 图块字符串 id |
| `events` | 默认事件,键为优先级、值为事件 id |
| `type` | 图块逻辑类型(`TileType`) |
| `pass` | 通行性(`ITilePassData`:`onlyEvents` / `outPass` / `inPass`) |
| `eventPass` | 事件可通行性:`false` 撞击触发,`true` 走上去触发 |
| `eventPass` | 事件可通行性:`false` 撞击触发,`true` 走上去触发 |
除了这张表,它内部还维护两组索引:`id → 数字`、`数字 → id`,以及每个图块数字的默认事件。所以你能用任意一种写法查同一条定义。
@ -69,13 +69,13 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
以<strong>楼层 id</strong>(`floorId`)为键,存 `IMapRawData`:
| 字段 | 含义 |
| ------------ | --------------------------------------------------------------------- |
| `floorId` | 楼层 id |
| `width` | 地图宽度 |
| `map` | 地图数组,键为图层纵深、值为扁平存放的图块数字数组 |
| `layerAlias` | 每个图层的字符串别名,键为图层纵深 |
| `events` | 每个图层指定位置的事件:纵深 → 位置索引 → 优先级 → 事件 id |
| 字段 | 含义 |
| ------------ | ---------------------------------------------------------- |
| `floorId` | 楼层 id |
| `width` | 地图宽度 |
| `map` | 地图数组,键为图层纵深、值为扁平存放的图块数字数组 |
| `layerAlias` | 每个图层的字符串别名,键为图层纵深 |
| `events` | 每个图层指定位置的事件:纵深 → 位置索引 → 优先级 → 事件 id |
- `getMap(floorId)`:原始地图数据,查不到返回 `null`;
- `addMap(map)`:登记一份原始数据,同一个 `floorId` 直接覆盖,不告警。
@ -95,14 +95,14 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
这是理解「为什么只读」的关键。每一对都是「定义」与「实例」的关系:
| Store 里的 | 运行时的 | 改 Store 会影响什么 |
| ------------------------------------ | ----------------------------------------------------------------- | -------------------------------------------------------- |
| `tileStore.getData(num)` 的图块定义 | 图层上的实际图块:`mapLayer.getBlock(x, y)`、动态图块的 `raw()` | 已建好的图块持有当时的定义,不会跟着变;之后新建的才会用新定义 |
| `itemStore.getData(num)` 的道具定义 | 背包里的道具:`state.hero.items.getItemState(item)` 的 `raw` 字段 | 已经进背包的道具仍指向旧定义;新拾取的道具才用新定义 |
| `mapStore.getMap(floorId)` 的原始地图 | `state.maps.getMap(floorId)` 的运行时楼层 | 运行时楼层是 `fromRaw` 一次性构造的,改原始数据不会重建它 |
| `eventStore.getEvent(id)` 的事件实例 | 事件层 `eventLayer` 上的 `LayerEventView`,以及事件执行 | 已经建好的视图与已派发的事件不受影响 |
| Store 里的 | 运行时的 | 改 Store 之后 |
| ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------ |
| `tileStore.getData(num)` 的图块定义 | `mapLayer.getTile(x, y)` 的静态图块 `raw()`;动态图块 `raw()` | 静态图块每次现查 Store,会读到新定义;动态图块创建时缓存,不会更新 |
| `itemStore.getData(num)` 的道具定义 | 背包里的道具:`state.hero.items.getItemState(item)` 的 `raw` 字段 | 进背包时已经缓存定义,之后仍指向旧定义 |
| `mapStore.getMap(floorId)` 的原始地图 | `state.maps.getMap(floorId)` 的运行时楼层 | 楼层由 `fromRaw` 一次性构造,改原始数据不会重建它 |
| `eventStore.getEvent(id)` 的事件实例 | 事件层 `eventLayer` 上的 `LayerEventView`,以及事件派发 | 视图只存事件 id,下次派发会按 id 现查,因此会换成新实例 |
一句话概括:<strong>Store 只影响「之后构造出来的东西」</strong>。已经存在对象不会因为你改了定义而更新,它们要么继续用旧定义,要么已经复制了旧数据。
一句话概括:Store 只影响「之后构造」与「每次现查」这两类取用路径。有的运行时对象每次都现查(静态图块的 `raw()`、事件派发),有的在创建时就缓存下来(动态图块、背包道具、运行时楼层)。所以在运行时改定义,会让「谁会看到新值」变得难以预测——这正是要避免它的原因。
## 运行时为什么只读
@ -110,7 +110,7 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
1. <strong>它不进存档</strong>。四个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。所以读档不会恢复你的改动,也不会把它退回原样。
2. <strong>它不进录像</strong>。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致,结果就可能对不上。录像标记的规则见[录像一致性](./replay-consistency.md)。
3. <strong>它不更新已经存在的对象</strong>。上一节的四对关系说明:改定义不会重建图层、背包、楼层与事件视图。
3. <strong>它不保证更新已经存在的对象</strong>。上一节的四对关系说明:有的对象缓存了定义(动态图块、背包道具),有的每次现查(静态图块、事件派发),改完之后谁变了、谁没变取决于取用方式,不可依赖。
4. <strong>它绕过了检查</strong>。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。
所以规则很简单:<strong>登记只在加载期做一次,运行期只读</strong>。这不是风格建议,而是因为它们本来就是「内容」,不是「状态」。
@ -119,12 +119,12 @@ Store 里的东西在<strong>加载 / 初始化阶段</strong>被填一次,之
登记要发生在「游戏开始跑之前」,具体放哪取决于你的数据什么时候可用:
| 位置 | 时机 | 适合登记什么 |
| ----------------------- | -------------------------- | ------------------------------------------------ |
| 插件 `awake(state)` | 主对象构造期,同步 | 定义直接写死在代码里,不依赖加载结果 |
| 插件 `initialize(state)` | 加载开始前,会被等待 | 登记前要先完成异步准备 |
| 插件 `afterLoad(state)` | 加载完毕后,会被等待 | 要读到加载出来的内容才知道该登记什么 |
| 专门的初始化函数 | 由上面任一处调用 | 把同一来源的一批登记集中成一段,避免散落 |
| 位置 | 时机 | 适合登记什么 |
| ------------------------ | -------------------- | ---------------------------------------- |
| 插件 `awake(state)` | 主对象构造期,同步 | 定义直接写死在代码里,不依赖加载结果 |
| 插件 `initialize(state)` | 加载开始前,会被等待 | 登记前要先完成异步准备 |
| 插件 `afterLoad(state)` | 加载完毕后,会被等待 | 要读到加载出来的内容才知道该登记什么 |
| 专门的初始化函数 | 由上面任一处调用 | 把同一来源的一批登记集中成一段,避免散落 |
三个时机的完整含义见[插件系统](./plugin-system.md)。要点是:它们都只在启动流程里各跑一次,不会随玩家操作反复触发。
@ -192,7 +192,7 @@ export class MyContent {
1. <strong>数据来源</strong>:把写死的 `num` / `id` 换成你内容文件里的条目;内容多时用循环读一遍,而不是手写几十条 `addTile`。
2. <strong>键</strong>:`num` 与 `id` 必须唯一。撞了会告警并覆盖,等于悄悄丢掉了另一条定义。
3. <strong>通的掩码</strong>:`outPass` / `inPass` 是方向位掩码(上 1、右 2、下 4、左 8),`0b1111` 表示四向都通。
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 重写一遍。
<strong>注意</strong>:`addEvent` 的签名与另外三个不同,它的第一个参数是事件 id,第二个参数才是事件实例。
@ -258,7 +258,7 @@ console.log(raw, state.tileStore.getType(num));
用同一个 `num` 再调用一次 `addTile` 改掉类型,然后:
1. 看已经存在的对象——图层上的图块 `getBlock` 返回的仍是原来的数字,已进背包的道具 `raw` 仍是旧定义;
1. 看已经存在的对象——静态图块 `mapLayer.getTile(x, y)!.raw()` 会读到新定义,而已进背包的道具 `raw` 仍是旧定义;同一个改动在不同对象上表现并不一致;
2. 存档再读档,看改动是否保留——不会,Store 不在存档里;
3. 录一段包含相关操作的录像并回放,看结果是否与原始游玩一致——定义生效的时点变了,结果就可能对不上。

View File

@ -24,10 +24,10 @@
两个装饰器都写在<strong>类的方法</strong>上方,作用对象是「录像安全检查」,不是录像本身。
| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 |
| ------------------ | ---------------------------------- | -------------------------------------------------------- | -------------- |
| `@shouldReplay()` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 |
| `@ignoreReplay()` | 确实不该计入录像的被动 / 延迟路径 | 把<strong>整段</strong>检查标记为忽略 | 不会 |
| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 |
| ----------------- | --------------------------------- | ---------------------------------------------------- | -------------- |
| `@shouldReplay()` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 |
| `@ignoreReplay()` | 确实不该计入录像的被动 / 延迟路径 | 把<strong>整段</strong>检查标记为忽略 | 不会 |
### @shouldReplay:登记一条路径
@ -63,14 +63,14 @@ replay.array.add(ReplayCode.UseItem, [raw.num]);
内置代码里已经有大量先例,可以照着找感觉:
| 位置 | 方法 |
| ---------- | ----------------------------------------------------------------------------- |
| Flag | `FlagSystem.setField` / `deleteField`、`FlagCommonField.set` / `add` |
| 勇士道具 | `HeroItems.addItem` / `useItem`、`HeroEquipsStore.add` / `delete` |
| 勇士装备 | `HeroEquipment.equip` / `unequip` |
| 勇士属性 | `HeroAttribute` 的 `set` / `add` / `mul` / `div`,以及修饰器的注册与移除 |
| 勇士位置 | `HeroLocation.setFloor` / `setPos` |
| 地图图层 | `MapLayer.setBlock` / `removeBlock` / `openDoor` / 动态图块相关方法 |
| 位置 | 方法 |
| -------- | ------------------------------------------------------------------------ |
| Flag | `FlagSystem.setField` / `deleteField`、`FlagCommonField.set` / `add` |
| 勇士道具 | `HeroItems.addItem` / `useItem`、`HeroEquipsStore.add` / `delete` |
| 勇士装备 | `HeroEquipment.equip` / `unequip` |
| 勇士属性 | `HeroAttribute` 的 `set` / `add` / `mul` / `div`,以及修饰器的注册与移除 |
| 勇士位置 | `HeroLocation.setFloor` / `setPos` |
| 地图图层 | `MapLayer.setBlock` / `removeBlock` / `openDoor` / 动态图块相关方法 |
不要加在这三类地方:
@ -92,16 +92,16 @@ replay.array.add(ReplayCode.UseItem, [raw.num]);
<strong>不能保证的</strong>:
| 它不检查 | 说明 |
| ------------------ | ------------------------------------------------------------------------ |
| 逻辑是否正确 | 方法算错了,只要区间里有新录像步,检查照样通过 |
| 参数是否正确 | 录像步的参数写错了、写少了,检查不看内容 |
| 顺序是否正确 | 它只比较录像长度,不比较先后 |
| 是否一一对应 | 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步 |
| 是否漏了入口 | 没有标记的方法不会被登记,自然也不会出现在告警里 |
| 是否会自动记录 | `@shouldReplay` 只登记路径,记录仍要 `record` 或内置录制入口 |
| 它不检查 | 说明 |
| -------------- | ------------------------------------------------------------ |
| 逻辑是否正确 | 方法算错了,只要区间里有新录像步,检查照样通过 |
| 参数是否正确 | 录像步的参数写错了、写少了,检查不看内容 |
| 顺序是否正确 | 它只比较录像长度,不比较先后 |
| 是否一一对应 | 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步 |
| 是否漏了入口 | 没有标记的方法不会被登记,自然也不会出现在告警里 |
| 是否会自动记录 | `@shouldReplay` 只登记路径,记录仍要 `record` 或内置录制入口 |
最后两条是实践中最容易踩的:<strong>没标记的方法,检查器根本不知道它存在</strong>;而标了记的方法,也不会因此自动写录像。
最后两条是实践中最容易踩的:<strong>没标记的方法,检查器根本不知道它存在</strong>;而标了标记的方法,也不会因此自动写录像。
## 检测机制与边界
@ -188,7 +188,7 @@ export class MySystem {
@shouldReplay('[需改] 这个方法改变了什么状态')
```
这里不含 `import`:`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不要再粘一次。若你换到别的文件后报缺少 `shouldReplay`,再在顶部补这一行。
这里不含 `import`:`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不要再粘一次。若你换到别的文件、编辑器报缺少 `shouldReplay`,再在顶部补这一行。
<strong>改什么</strong>:把消息文本改成这个方法实际改变了什么。内置用的都是英文短语,例如 `Flag field set should be replayed.`;语言本身不影响行为,写清意思即可。