diff --git a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md index 9aa50f8..d5f5408 100644 --- a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md +++ b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md @@ -227,4 +227,31 @@ **状态:已处理** +--- + +## 第 6 轮 + +**时间:** 2026-10-20 +**用户意见:** + +- `data-stores.md` 内容太少:要求讲「存的内容 + 接口」,结果每个 Store 只有两段文字。 +- 必须写清 `tileStore` 存的是 `ITileRawData`、常用成员有哪些,并用示例讲。 +- **Store 的示例不要写在一块**:每个 Store 单独写,示例与功能直接对应。 +- 适用于后续所有教程文档(杂类功能示例按功能分开写,D-66)。 + +### 第 6 轮处置 + +**修改方式:** 内联执行(执行器直接编辑),仅修改 `docs/advanced/data-stores.md` 与本职记录。 + +| # | 处置 | +|---|------| +| 存的内容 + 接口太少 | 五个 Store 各扩为独立小节:先写存的数据类型,再列常用成员(字段含义用叙述性列表),然后讲自身查询 / 登记接口。`tileStore` 写明存 `ITileRawData`,逐项说明 `num` / `id` / `events` / `type` / `pass` / `eventPass`;`itemStore` 写明 `IItemRawData` 与 `effect` / `equip`;`mapStore` 写明 `IMapRawData` 的 `map` / `layerAlias` / `events` 三层结构;`enemyStore` 写明 `IEnemyRawData` 的 `attribute` / `special`;`eventStore` 写明存 `IReadonlyGameEvent` 实例及其 `trigger` / `rawEvent` / `compiled` / `execute`。 | +| 每个 Store 单独示例(D-66) | 删除原先「一次读出五个 Store」的合并示例,改为五个独立示例:`inspectTile`(图块换算、定义、类型、默认事件、通行位)、`canUseItem`(道具定义、分类、`canUse`)、`readFloorTile`(地图宽度、行优先下标、图层别名、位置事件)、`previewEnemy`(怪物模板与特殊属性编号)、`runStoredEvent`(事件实例泛型与执行)。每个示例只使用对应 Store,并带解释性注释(D-65)。 | +| 保留项 | 保留「Store 是什么」「为什么运行期只读」「边界与注意」、D-47 的 `高级 II · 加载与插件生命周期` 占位与 ``、D-55 相关指向与小结;未写 `__test__` 内容。 | +| 边界整理(D-61) | `mapStore` 没有列出全部楼层的接口、查询需先判空、`tileStore.getEvent` 返回新 `Map` 等边界仍集中在文末「边界与注意」。 | + +**构建:** `pnpm exec prettier --write docs/advanced/data-stores.md` 后 `--check` 通过;`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 b2041c5..2dd8f8a 100644 --- a/docs/advanced/data-stores.md +++ b/docs/advanced/data-stores.md @@ -1,80 +1,225 @@ # data-common 的 Store -`@user/data-common` 是数据端的 Layer 0 公共层,上面挂着五个内容注册表:`tileStore`、`itemStore`、`mapStore`、`enemyStore`、`eventStore`。它们看起来像「数据表」,实际是内容层原始定义的注册表——图块、道具、地图、怪物、事件的定义登记在这里;游戏运行时从这里查出来,再按定义构造自己的对象。 +`@user/data-common` 是数据端的 Layer 0 公共层,上面挂着五个内容注册表:`tileStore`、`itemStore`、`mapStore`、`enemyStore`、`eventStore`。它们看起来像「数据表」,实际是内容层原始定义的注册表——图块、道具、地图、怪物的定义登记在这里,游戏运行时从这里查出来,再按定义构造自己的对象;事件则直接以实例登记。 -中级教程的[数据端总览](../intermediate/data-state-overview.md)列过这些入口,[图块存储](../intermediate/tile-store.md)讲了 `tileStore` 的日常用法。这一篇讲每个 Store 里存的是什么、它自己能做什么,以及为什么运行期只该读。 +中级教程的[数据端总览](../intermediate/data-state-overview.md)列过这些入口,[图块存储](../intermediate/tile-store.md)讲了 `tileStore` 的日常用法。这一篇逐个讲每个 Store 存的是什么、有哪些常用成员、它自己能做什么,以及为什么运行期只该读。 阅读前需要会基础语法:`class`、`new`、`Map`、`import`。 ## Store 是什么 -Store 里存的是「定义」,游戏里跑的是按定义构造出来的「实例」。图块定义告诉你这个数字代表什么地形、能不能走,真正的图块对象则在楼层加载时按定义创建。 +Store 里存的是「定义」,游戏里跑的是按定义构造出来的「实例」。图块定义告诉你这个数字代表什么地形、能不能走,真正的图块对象则在楼层加载时按定义创建。`eventStore` 稍有不同:它存的就是事件实例本身,不再经过一次构造。 Store 是查表用的注册表,不进存档。勇士、地图、Flag 这些游戏状态才进存档;读档不会把 Store 退回原样,也不会恢复你运行期对它的改动。 运行时读 Store 是正常操作:查表、算数、拼提示文本、给界面提供数据都可以。运行时写 Store 会绕过加载、存档与录像这一整套机制,问题会在不确定的时机表现出来。 -## 读 Store 的示例 +## 怎么读 Store -五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。它们不是需要你实现的模板——接口已经由 `data-common` 提供,你只需要调用。如果你想在代码里读这些定义,可以参考下面的示例: +五个 Store 都是 `state` 上的只读字段,没有各自的管理器,也不需要你 `new` 一个出来。它们不是需要你实现的模板——接口已经由 `data-common` 提供,你只需要调用。 + +查询接口在找不到内容时不会抛错:取定义一般返回 `null`,换算 id 与数字一般返回 `undefined`,取图块类型返回 `TileType.Unknown`,取默认事件返回空 `Map`。所以读之前先判断结果是否存在,再使用。 + +下面每个 Store 各占一节,各配一段只针对它的示例。示例放在能拿到 `state` 的方法里即可,例如事件函数、钩子或插件方法。 + +## tileStore:图块定义 + +每条记录是一个 `ITileRawData`,以图块数字为键。常用成员有: + +- `num`:图块数字,也是这张表的键; +- `id`:图块字符串 id,供数据与代码按名字引用; +- `events`:默认事件表,键是事件优先级,值是事件 id; +- `type`:图块逻辑类型,取值包括 `Unknown`、`None`、`Terrain`、`Animate`、`Item`、`Enemy`、`Npc`、`Autotile`、`Tileset`; +- `pass`:通行性对象,`onlyEvents` 表示是否仅当图块处在事件层时生效,`outPass` 与 `inPass` 是「可以离开」与「可以进入」的方向掩码,方向位由 `PassBit` 的 `Up`、`Right`、`Down`、`Left` 组合而成; +- `eventPass`:触发器的触发方式——`false` 是撞上去触发,`true` 是走上去触发。 + +接口方面,`getData(num)` 按数字取完整定义,`getType(num)` 取逻辑类型,`getEvent(num)` 取默认事件。`num` / `id` / `identity` 在数字与 id 之间换算,`idToNumber` / `numberToId` 则是更直白的单向版本。登记新定义用 `addTile`;数字或 id 与已有定义冲突时会告警(错误码 `133` / `134`)并覆盖。 + +```ts +import { ICoreState } from '@user/data-state'; +import { PassBit, TileType } from '@user/data-common'; + +// 按图块 id 读取定义;id 未知或没有对应定义时直接返回 +export function inspectTile(state: ICoreState, id: string): void { + // num 接受图块数字或 id;id 不存在时返回 undefined + const num = state.tileStore.num(id); + if (num === undefined) return; + + // 完整原始定义;图块数字不存在时返回 null + const data = state.tileStore.getData(num); + if (data === null) return; + + // 逻辑类型:可以据此区分地形、道具、怪物等图块 + const type = state.tileStore.getType(num); + + // 默认事件:键为优先级、值为事件 id;无事件时是空 Map + const events = state.tileStore.getEvent(num); + + // pass 的方向是位掩码,这里判断能否向上离开 + const canLeaveUp = (data.pass.outPass & PassBit.Up) !== 0; + + // id / identity 可以反向换算,identity 返回 [数字, id] + const backId = state.tileStore.id(num); + const [sameNum, sameId] = state.tileStore.identity(num); + + console.log(backId, sameNum, sameId, type === TileType.Terrain, canLeaveUp); + console.log(data.eventPass, events.size); +} +``` + +## itemStore:道具定义 + +每条记录是一个 `IItemRawData`,以道具图块数字为键。常用成员有: + +- `num`:道具在地图上的图块数字,也是这张表的键; +- `category`:道具分类,取值为 `Unknown`、`Constant`(永久)、`Consumable`(一次性)、`Pick`(即捡即用)、`Equipment`(装备); +- `name` 与 `text`:显示名称与描述文本; +- `hideInToolbox`:是否在道具栏中隐藏; +- `effect`:使用或拾取时的效果,包含可选的 `useEvent`(事件 id)、`useEffect(item, state)` 与 `canUse(item, state)`; +- `equip`:装备后的数据,包含可装备的槽位 `slots`、动画 `animate`、数值加成 `value`、百分比加成 `percentage`,以及穿上 / 脱下时的事件 `loadEvent` / `unloadEvent`。 + +`effect` 与 `equip` 都是必填:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。 + +接口方面,`getData(token)` 按数字或 id 取整份定义,`getCategory(num)` 只取分类。登记用 `addItem`;同一个图块数字重复登记会告警(错误码 `181`)并覆盖。 + +```ts +import { ICoreState } from '@user/data-state'; +import { ItemCategory } from '@user/data-common'; + +// 判断某个道具现在能否使用;数据与状态都从 Store 和 state 取 +export function canUseItem(state: ICoreState, token: number | string): boolean { + // token 可以是道具图块数字,也可以是图块 id + const item = state.itemStore.getData(token); + if (item === null) return false; + + // 分类决定使用时机:永久与一次性是「使用」,Pick 是「拾取」 + const category = state.itemStore.getCategory(item.num); + if ( + category !== ItemCategory.Constant && + category !== ItemCategory.Consumable + ) { + return false; + } + + // canUse 可选;没有实现时视为可以使用 + return item.effect.canUse?.(item, state) ?? true; +} +``` + +## mapStore:地图原始数据 + +每条记录是一张地图的 `IMapRawData`,以楼层 id 为键。常用成员有: + +- `floorId`:楼层 id,也是这张表的键; +- `width`:地图宽度,用来把一维下标换算成坐标; +- `map`:按图层纵深组织的图块矩阵,外层键是图层纵深,值是该层按行排列的图块数字数组; +- `layerAlias`:每个图层的字符串别名,键是图层纵深; +- `events`:指定位置上的事件,外层键是图层纵深,中层键是图块位置索引,内层键是事件优先级、值是事件 id。 + +接口只有两个:`getMap(floorId)` 按楼层 id 取整份原始数据,不存在返回 `null`;`addMap(map)` 登记地图,同一个楼层 id 直接覆盖,不告警。Store 没有「列出全部楼层」的接口,查询前需要知道楼层 id。 ```ts import { ICoreState } from '@user/data-state'; -// 一次读出五个 Store 的常见内容;放进能拿到 state 的方法里 -export function readStores(state: ICoreState): void { - // 图块:先按 id 查数字,再取完整定义与类型 - const tileNum = state.tileStore.num('my-tile'); - if (tileNum !== undefined) { - // getData 返回定义,getType 返回逻辑类型 - console.log(state.tileStore.getData(tileNum)); - console.log(state.tileStore.getType(tileNum)); - } +// 读取某层某格的图块与事件;下标按「行优先」排列 +export function readFloorTile( + state: ICoreState, + floorId: string, + x: number, + y: number +): void { + // 整张地图的原始数据;楼层 id 不存在时返回 null + const map = state.mapStore.getMap(floorId); + if (map === null) return; - // 道具与怪物:数字或 id 都接受 - // getData / getEnemy 返回定义本身,不是副本 - const item = state.itemStore.getData('my-item'); - const enemy = state.enemyStore.getEnemy('my-enemy'); + // 一维下标:同一层按行依次排列,所以是 y * width + x + const index = y * map.width + x; - // 地图按楼层 id 查;事件按事件 id 取实例 - const map = state.mapStore.getMap('my-floor'); - const event = state.eventStore.getEvent('my-event'); + // map 的外层键是图层纵深,0 通常是地面层 + const ground = map.map[0]; + if (ground === undefined) return; - console.log(item, map, enemy, event); + // layerAlias 给出该层的可读名字 + const alias = map.layerAlias[0]; + + // events 按「图层 → 位置索引 → 优先级」三层查找 + const eventsAtTile = map.events[0]?.[index] ?? {}; + + console.log(alias, ground[index], eventsAtTile); } ``` -这个示例只做读取:图块先换算数字,再取定义与类型;其余四个 Store 按各自的键直接查询。返回的不是副本,下面逐个讲每个 Store 存什么、能做什么。 - -## tileStore:图块定义 - -每条记录是一个图块的原始定义,以图块数字为键。定义里包含:图块数字本身;字符串 id,供数据里按名字引用;默认事件表,键是事件优先级、值是事件 id;图块逻辑类型,例如地形、动画、道具、怪物、NPC、自动元件;四方向通行性;以及 `eventPass`,它决定触发器的触发方式——`false` 是撞上去触发,`true` 是走上去触发。 - -接口方面,它能按数字取完整定义、默认事件和类型,也能在图块数字与字符串 id 之间换算。登记新定义用 `addTile`;数字或 id 与已有定义冲突时会告警(错误码 `133` / `134`)并覆盖。 - -## itemStore:道具定义 - -每条记录是一个道具的原始定义,以道具图块数字为键。定义里包含:图块数字;道具分类(永久、一次性、即捡即用、装备);显示名称与描述文本;是否在道具栏中隐藏;以及两个效果字段——`effect` 描述使用或拾取时做什么,`equip` 描述装备后的槽位、动画、数值与百分比加成。两个字段都是必填。 - -接口方面,它能按数字或 id 取整份定义,也能只取分类。登记用 `addItem`;同一个图块数字重复登记会告警(错误码 `181`)并覆盖。 - -## mapStore:地图原始数据 - -每条记录是一张地图的原始数据,以楼层 id 为键。记录里包含:楼层 id、地图宽度、每一层的图块矩阵、每个图层的字符串别名,以及每个图层指定位置上的事件。 - -接口方面,它能按楼层 id 取整份原始数据。登记用 `addMap`;同一个楼层 id 直接覆盖,不告警。 - ## enemyStore:怪物定义 -每条记录是一只怪物的原始定义,以怪物图块数字为键。定义里包含:图块数字、怪物属性模板,以及特殊属性的编号与参数值。 +每条记录是一个 `IEnemyRawData`,以怪物图块数字为键。常用成员有: -接口方面,它能按数字或 id 取整份定义。登记用 `addEnemy`;同一个图块数字重复登记会告警(错误码 `181`,与道具共用)并覆盖。 +- `num`:怪物的图块数字,也是这张表的键; +- `attribute`:怪物属性模板,里面是名称、生命、攻击、防御、金币、经验、加点等基础数值; +- `special`:怪物拥有的特殊属性,键是特殊属性编号,值是该项特殊属性的参数。 + +特殊属性编号不在这条记录里定义,而是由 `src/data/enemy/special.ts` 的 `createSpecials` 统一注册;怪物数据只写编号与参数。 + +接口方面,`getEnemy(token)` 按图块数字或 id 取整份定义。登记用 `addEnemy`;同一个图块数字重复登记会告警(错误码 `181`,与道具共用)并覆盖。 + +```ts +import { ICoreState } from '@user/data-state'; + +// 读取怪物模板并预览成长后的基础数值 +export function previewEnemy( + state: ICoreState, + token: number | string, + level: number +): void { + // token 可以是怪物图块数字,也可以是图块 id + const enemy = state.enemyStore.getEnemy(token); + if (enemy === null) return; + + // attribute 是怪物属性模板,不是当前战斗中的怪物实例 + const attr = enemy.attribute; + + // special 的键是特殊属性编号,值是该项的参数 + const specialIds = Object.keys(enemy.special); + + console.log(attr.name, attr.hp * level, attr.atk * level); + console.log(attr.def, attr.money, attr.exp, specialIds); +} +``` ## eventStore:事件实例 -存的是事件实例本身,不是事件定义。每条记录以事件 id 为键,取回的就是当初登记的那个对象。 +存的是事件实例本身,不是事件定义。每条记录是一个 `IReadonlyGameEvent`,以事件 id 为键,取回的就是当初登记的那个对象。常用成员有: -接口方面,它能按 id 取回存储的那个实例,不是副本。登记用 `addEvent(id, event)`,第一个参数是事件 id,第二个才是实例;重复 id 会告警(错误码 `170`)并覆盖。 +- `interpreter`:事件解释器; +- `trigger`:触发器类型,决定事件由什么触发; +- `rawEvent`:原始事件数据; +- `compiled`:已编译的事件数据,未编译时为 `null`; +- `compile()`:编译事件; +- `execute(param, env)`:执行事件,参数与环境由事件自身约定。 + +接口方面,`getEvent(id)` 按 id 取回存储的那个实例,不是副本;可以使用泛型标注参数、环境与返回值类型。登记用 `addEvent(id, event)`,第一个参数是事件 id,第二个才是实例;重复 id 会告警(错误码 `170`)并覆盖。 + +```ts +import { ICoreState } from '@user/data-state'; + +// 取出事件实例并执行;参数与环境按事件自身的约定填写 +export async function runStoredEvent( + state: ICoreState, + id: string +): Promise { + // 泛型依次是参数、环境与返回值;这里假设事件接收一个计数值 + const event = state.eventStore.getEvent< + { count: number }, + Record + >(id); + if (event === null) return; + + // trigger 说明它原本由什么触发;手动执行不受触发器限制 + console.log(event.trigger, event.compiled !== null); + + // execute 返回 Promise,是否等待由调用方决定 + await event.execute({ count: 1 }, {}); +} +``` ## 为什么运行期只读 @@ -88,21 +233,21 @@ export function readStores(state: ICoreState): void { 至于登记这件事本身,绝大多数情况也不用你动手:`add*` 是给数据端自己用的接口——内容加载还没写完,写完后由数据端统一登记,不在你的代码里。 -读 Store 时唯一要注意的是不要就地修改返回的对象:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。 - ## 边界与注意 - 需要自己补充 Store 定义的情况非常少见。`add*` 是数据端自用的登记接口:内容加载还没有写完,写完后由数据端统一登记。绝大多数情况下你只需要读,不需要自己补定义。确实需要补时,属于后续高级教程的内容(`高级 II · 加载与插件生命周期`,待编写),并且要把调用集中放在插件的一个时机方法里(见[插件系统](./plugin-system.md)),只在启动流程里执行一次。 - 道具定义的两个效果字段都是必填。`effect` 与 `equip` 不是二选一:非装备类也要给出一个 `equip`,装备类也要提供 `effect`(可以是空实现)。 -- 返回值不是副本。改 `getData` / `getMap` / `getEvent` 的返回值等于改注册表;需要不同的定义就登记新的,不要改旧的。 +- 返回值不是副本。`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例;需要不同的定义就登记新的,不要改旧的。 +- `tileStore.getEvent` 是例外。它每次新建一个 `Map`,改它不会影响 Store,但也不要在上面做缓存并当成快照。 - 读档不会恢复 Store。改过的定义不会随存档保存,读档也不会把它退回去。 - `eventStore.getEvent` 给的是实例本身,不要在上面做缓存并当成快照。 +- 查询前先判空。定义可能返回 `null`,换算可能返回 `undefined`,类型可能是 `Unknown`,事件表可能是空 `Map`。 ## 小结 -- 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。 +- 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档;`eventStore` 存的是事件实例。 - 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。 - 每个 Store 只负责自己那类内容:读定义、做必要的换算,登记则由数据端在加载期统一完成。 - 读随时可以;返回的通常是注册表里的实例,不要就地修改。