diff --git a/docs/advanced/data-stores.md b/docs/advanced/data-stores.md index 0633cff..528a8ae 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -10,22 +10,22 @@ 先把三个容易混淆的区分摆清楚: -| 区分 | 一边 | 另一边 | -| -------------- | -------------------------------------- | ------------------------------------ | -| 定义与实例 | Store 里存的是「定义」 | 游戏里跑的是按定义构造出来的「实例」 | -| 注册表与存档 | Store 是查表用的注册表,不进存档 | 勇士、地图、Flag 等游戏状态才进存档 | -| 查表与改表 | 运行时读 Store 是正常操作 | 运行时写 Store 会绕过一整套机制 | +| 区分 | 一边 | 另一边 | +| ------------ | -------------------------------- | ------------------------------------ | +| 定义与实例 | Store 里存的是「定义」 | 游戏里跑的是按定义构造出来的「实例」 | +| 注册表与存档 | Store 是查表用的注册表,不进存档 | 勇士、地图、Flag 等游戏状态才进存档 | +| 查表与改表 | 运行时读 Store 是正常操作 | 运行时写 Store 会绕过一整套机制 | Store 里的东西在加载 / 初始化阶段被填一次,之后整个游戏过程都靠它查表。它不是「玩家做了什么」的记录,而是「世界上有什么」的登记。 ## 四个 Store 一览 -| Store | 类型 | 存什么 | 键 | 常用读取 | 写入 | -| ------------ | --------------------------- | ------------------------ | ---------------- | --------------------------------------------------------------- | ------------------ | -| `tileStore` | `ITileStore` | 图块原始定义 | 图块数字 | `getData` / `getEvent` / `getType` / `num` / `id` / `identity` | `addTile` | -| `itemStore` | `IItemStore` | 道具原始定义 | 道具图块数字 | `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` | 道具原始定义 | 道具图块数字 | `getData` / `getCategory` | `addItem` | +| `mapStore` | `IMapStore` | 地图原始数据 | `floorId` | `getMap` | `addMap` | +| `eventStore` | `IGameEventStore` | 事件实例 | 事件 id | `getEvent` | `addEvent` | 四个 Store 都是 `IDataCommon` 上的只读字段,通过 `state` 直接访问,例如 `state.tileStore`。它们没有各自的「管理器」,也不需要你 `new` 一个出来。 @@ -35,14 +35,14 @@ Store 里的东西在加载 / 初始化阶段被填一次,之 以图块数字为键,存 `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 里的东西在加载 / 初始化阶段被填一次,之 以楼层 id(`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 里的东西在加载 / 初始化阶段被填一次,之 这是理解「为什么只读」的关键。每一对都是「定义」与「实例」的关系: -| 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 现查,因此会换成新实例 | -一句话概括:Store 只影响「之后构造出来的东西」。已经存在对象不会因为你改了定义而更新,它们要么继续用旧定义,要么已经复制了旧数据。 +一句话概括:Store 只影响「之后构造」与「每次现查」这两类取用路径。有的运行时对象每次都现查(静态图块的 `raw()`、事件派发),有的在创建时就缓存下来(动态图块、背包道具、运行时楼层)。所以在运行时改定义,会让「谁会看到新值」变得难以预测——这正是要避免它的原因。 ## 运行时为什么只读 @@ -110,7 +110,7 @@ Store 里的东西在加载 / 初始化阶段被填一次,之 1. 它不进存档。四个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。所以读档不会恢复你的改动,也不会把它退回原样。 2. 它不进录像。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致,结果就可能对不上。录像标记的规则见[录像一致性](./replay-consistency.md)。 -3. 它不更新已经存在的对象。上一节的四对关系说明:改定义不会重建图层、背包、楼层与事件视图。 +3. 它不保证更新已经存在的对象。上一节的四对关系说明:有的对象缓存了定义(动态图块、背包道具),有的每次现查(静态图块、事件派发),改完之后谁变了、谁没变取决于取用方式,不可依赖。 4. 它绕过了检查。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。 所以规则很简单:登记只在加载期做一次,运行期只读。这不是风格建议,而是因为它们本来就是「内容」,不是「状态」。 @@ -119,12 +119,12 @@ Store 里的东西在加载 / 初始化阶段被填一次,之 登记要发生在「游戏开始跑之前」,具体放哪取决于你的数据什么时候可用: -| 位置 | 时机 | 适合登记什么 | -| ----------------------- | -------------------------- | ------------------------------------------------ | -| 插件 `awake(state)` | 主对象构造期,同步 | 定义直接写死在代码里,不依赖加载结果 | -| 插件 `initialize(state)` | 加载开始前,会被等待 | 登记前要先完成异步准备 | -| 插件 `afterLoad(state)` | 加载完毕后,会被等待 | 要读到加载出来的内容才知道该登记什么 | -| 专门的初始化函数 | 由上面任一处调用 | 把同一来源的一批登记集中成一段,避免散落 | +| 位置 | 时机 | 适合登记什么 | +| ------------------------ | -------------------- | ---------------------------------------- | +| 插件 `awake(state)` | 主对象构造期,同步 | 定义直接写死在代码里,不依赖加载结果 | +| 插件 `initialize(state)` | 加载开始前,会被等待 | 登记前要先完成异步准备 | +| 插件 `afterLoad(state)` | 加载完毕后,会被等待 | 要读到加载出来的内容才知道该登记什么 | +| 专门的初始化函数 | 由上面任一处调用 | 把同一来源的一批登记集中成一段,避免散落 | 三个时机的完整含义见[插件系统](./plugin-system.md)。要点是:它们都只在启动流程里各跑一次,不会随玩家操作反复触发。 @@ -192,7 +192,7 @@ export class MyContent { 1. 数据来源:把写死的 `num` / `id` 换成你内容文件里的条目;内容多时用循环读一遍,而不是手写几十条 `addTile`。 2. 键:`num` 与 `id` 必须唯一。撞了会告警并覆盖,等于悄悄丢掉了另一条定义。 -3. 通的掩码:`outPass` / `inPass` 是方向位掩码(上 1、右 2、下 4、左 8),`0b1111` 表示四向都通。 +3. 通行掩码:`outPass` / `inPass` 是方向位掩码(上 1、右 2、下 4、左 8),`0b1111` 表示四向都通。 4. 其它三个 Store:形状相同,把 `addTile` 换成对应调用即可——`addItem(data)` 收 `IItemRawData`,`addMap(map)` 收 `IMapRawData`,`addEvent(id, event)` 收事件实例。所以这套骨架不需要为每个 Store 重写一遍。 注意:`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. 录一段包含相关操作的录像并回放,看结果是否与原始游玩一致——定义生效的时点变了,结果就可能对不上。 diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md index 1f142c6..cebfbf8 100644 --- a/docs/advanced/replay-consistency.md +++ b/docs/advanced/replay-consistency.md @@ -24,10 +24,10 @@ 两个装饰器都写在类的方法上方,作用对象是「录像安全检查」,不是录像本身。 -| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 | -| ------------------ | ---------------------------------- | -------------------------------------------------------- | -------------- | -| `@shouldReplay()` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 | -| `@ignoreReplay()` | 确实不该计入录像的被动 / 延迟路径 | 把整段检查标记为忽略 | 不会 | +| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 | +| ----------------- | --------------------------------- | ---------------------------------------------------- | -------------- | +| `@shouldReplay()` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 | +| `@ignoreReplay()` | 确实不该计入录像的被动 / 延迟路径 | 把整段检查标记为忽略 | 不会 | ### @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]); 不能保证的: -| 它不检查 | 说明 | -| ------------------ | ------------------------------------------------------------------------ | -| 逻辑是否正确 | 方法算错了,只要区间里有新录像步,检查照样通过 | -| 参数是否正确 | 录像步的参数写错了、写少了,检查不看内容 | -| 顺序是否正确 | 它只比较录像长度,不比较先后 | -| 是否一一对应 | 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步 | -| 是否漏了入口 | 没有标记的方法不会被登记,自然也不会出现在告警里 | -| 是否会自动记录 | `@shouldReplay` 只登记路径,记录仍要 `record` 或内置录制入口 | +| 它不检查 | 说明 | +| -------------- | ------------------------------------------------------------ | +| 逻辑是否正确 | 方法算错了,只要区间里有新录像步,检查照样通过 | +| 参数是否正确 | 录像步的参数写错了、写少了,检查不看内容 | +| 顺序是否正确 | 它只比较录像长度,不比较先后 | +| 是否一一对应 | 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步 | +| 是否漏了入口 | 没有标记的方法不会被登记,自然也不会出现在告警里 | +| 是否会自动记录 | `@shouldReplay` 只登记路径,记录仍要 `record` 或内置录制入口 | -最后两条是实践中最容易踩的:没标记的方法,检查器根本不知道它存在;而标了记的方法,也不会因此自动写录像。 +最后两条是实践中最容易踩的:没标记的方法,检查器根本不知道它存在;而标了标记的方法,也不会因此自动写录像。 ## 检测机制与边界 @@ -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`,再在顶部补这一行。 改什么:把消息文本改成这个方法实际改变了什么。内置用的都是英文短语,例如 `Flag field set should be replayed.`;语言本身不影响行为,写清意思即可。