template/docs/dev/store/tile-store.md
2026-05-17 16:31:08 +08:00

16 KiB
Raw Blame History

需求综述

当前关于地图触发器的设计卡在“图块定义本身尚未抽象出来”这一前置问题上。继续讨论 docs/dev/map/tile-info.md 中的地图触发器存储方案,会立刻撞上一个更底层的问题:

  1. 某个图块数字到底对应什么 id
  2. 某个图块默认携带什么触发器类型;
  3. 某个图块属于哪一类图块;
  4. 旧引擎中的 blocksInfo 应该如何转移到新接口。

因此,下一步更合适的顺序不是继续扩展地图侧接口,而是先补齐一个独立的 ITileStore。它属于 Layer 0不参与存档也不承载运行时动态状态只负责提供“图块定义查询”这一底层能力。

这次设计的目标如下:

  1. 将当前挂在全局状态上的 idNumberMapnumberIdMap 收拢到 ITileStore 内;
  2. 为图块定义提供统一查询入口:getDatagetTriggergetType
  3. 提供统一写入口 addTile,用于初始化阶段录入图块定义;
  4. 提供 attachLegacyConverterfromLegacy 两个接口,用于从旧引擎迁移图块定义;
  5. 明确这部分能力不属于存档系统,且应当放在 Layer 0。

接口设计与预期

ITileRawData

ITileRawData 表示单个图块的最小原始定义。按照当前结论,需要包含四个字段:图块数字、图块 id、触发器类型、图块类型。

  • ITileRawData.num:预期频率中频。图块数字是整个图块定义系统的主键,addTilegetData 与旧引擎导入都会依赖它,但日常脚本中通常不会反复直接读写,故为中频。
  • ITileRawData.id:预期频率中频。图块 id 常用于脚本层、兼容层与素材层之间的衔接,出现频率不低,但通常通过 idToNumber 间接使用,故为中频。典型使用场景:旧接口或脚本给出 yellowDoor 这类字符串 id希望先查到对应图块数字。
  • ITileRawData.trigger:预期频率中频。当前触发器设计已经收敛到“数据层只存触发器类型数字”,因此这是图块定义中的核心字段之一,但大多数代码仍会优先通过 getTrigger 访问,故为中频。典型使用场景:地图初始化时根据图块默认定义,给某个格点填入默认触发器类型。
  • ITileRawData.type:预期频率中频。图块类型会被 getType 高效访问但在定义录入阶段仍应直接作为图块原始数据的一部分保存避免再人为拆成第二套并行数据源。典型使用场景地图或逻辑层拿到某个图块定义后希望直接知道其属于地形、怪物、NPC 还是道具。

当前建议接口如下:

export interface ITileRawData {
    readonly num: number;
    readonly id: string;
    readonly trigger: number;
    readonly type: TileType;
}

之所以推荐使用 num 而不是 number,是因为当前仓库内地图与图块相关接口几乎都使用 num 表示图块数字,延续这一命名更自然。

TileType

getType 需要返回一个图块类型枚举。按照当前需求,建议先定义以下八种:

  1. Unknown
  2. None
  3. Terrain
  4. Animate
  5. Item
  6. Enemy
  7. Npc
  8. Tileset

这里的目的不是完整复刻旧引擎的所有 cls,而是先给当前执行层与地图层提供足够稳定、足够粗粒度的类型划分。基于旧引擎的现有分类,当前建议映射关系如下:

  1. 0 号空白图块映射为 None
  2. terrainsautotile 统一映射为 Terrain
  3. animates 映射为 Animate
  4. items 映射为 Item
  5. enemysenemy48 统一映射为 Enemy
  6. npcsnpc48 统一映射为 Npc
  7. tileset 映射为 Tileset
  8. 其他尚未归类或不存在的图块映射为 Unknown

这样处理的原因是:当前数据端真正需要的是“足够稳定的逻辑分类”,而不是把渲染素材维度的细分 cls 原封不动搬进底层接口。

ITileStore

ITileStore 是图块定义的统一查询与写入接口。由于它本身不会进入存档,也不承担运行时状态变化,因此整体频率分布会明显偏向“读取高于写入”。

  • ITileStore.getData(num):预期频率中频。这个接口会返回完整的 ITileRawData适合调试、兼容层、编辑器与初始化阶段使用但在真正的高频逻辑中调用方通常只关心某个单独字段因此为中频。典型使用场景兼容层需要同时读取图块数字、id 与默认触发器。
  • ITileStore.getTrigger(num):预期频率高频。这是 getData(num).trigger 的快捷接口,后续地图初始化、事件绑定与触发器相关逻辑都会优先使用这一接口,故为高频。典型使用场景:根据某个图块数字读取其默认触发器类型,再决定是否写入地图触发器稀疏表。
  • ITileStore.getType(num):预期频率高频。图块类型分类会直接影响地图逻辑、兼容层判断、后续的地图对象设计,因此它和 getTrigger 一样属于高频读取接口。典型使用场景:逻辑层拿到某个图块数字后,需要快速判断它属于地形、道具、怪物还是 NPC。
  • ITileStore.addTile(data):预期频率低频。图块定义在正常运行期不会动态修改,addTile 主要用于初始化与旧数据导入阶段,故为低频。
  • ITileStore.idToNumber(id):预期频率中频。它是 idNumberMap 的方法化替代,兼容层、脚本层和部分初始化逻辑都需要从字符串 id 反查图块数字,故为中频。典型使用场景:旧接口 setBlock('yellowDoor', x, y) 需要先把 id 转成图块数字。
  • ITileStore.numberToId(num):预期频率中频。它是 numberIdMap 的方法化替代,主要用于调试、兼容层与少量需要回推图块 id 的场景,故为中频。典型使用场景:拿到地图上的图块数字后,希望恢复出旧引擎语义下的图块 id。
  • ITileStore.attachLegacyConverter(converter):预期频率低频。仅在初始化或切换兼容转换器时调用,负责把旧引擎图块定义的解释规则注入到 store 中,故为低频。
  • ITileStore.fromLegacy(num, legacy):预期频率低频。用于将单个旧样板图块定义转换并写入 store整体使用方式应与 IEnemyManager.fromLegacyEnemy 类似,由外层自行遍历 legacy store 后逐个调用,故为低频。典型使用场景:初始化时遍历 core.maps.blocksInfo,对每一项执行 tileStore.fromLegacy(num, block)

当前建议接口如下:

export interface ITileStore {
    getData(num: number): ITileRawData | null;
    getTrigger(num: number): number;
    getType(num: number): TileType;
    addTile(data: ITileRawData): void;
    idToNumber(id: string): number | null;
    numberToId(num: number): string | null;
    attachLegacyConverter<TLegacy>(
        converter: ITileLegacyConverter<TLegacy>
    ): void;
    fromLegacy<TLegacy>(num: number, legacy: TLegacy): ITileRawData;
}

返回值语义建议如下:

  1. getData(num):若图块不存在,返回 null
  2. getTrigger(num):若图块不存在或未配置触发器,返回 -1
  3. getType(num):若图块不存在或尚未归类,返回 TileType.Unknown
  4. idToNumber(id) / numberToId(num):不存在时返回 null

之所以让 getTriggergetType 在缺失场景下返回稳定默认值,是因为这两者更偏“高频逻辑查询”,热路径上不适合层层判空。

ITileLegacyConverter / attachLegacyConverter / fromLegacy

ITileStore 本身不应直接理解旧引擎里 blocksInfo 的全部细节,而应通过用户层提供的 legacy converter 完成转换。这样做的原因是:旧引擎中的默认触发器来源并不统一,既有显式 trigger 字段,也有通过 cls 或其他成员隐式决定的情况。

public/project/maps.js 当前样板可以直接看到两类典型情况:

  1. 部分图块显式写了 trigger,例如黄门的 openDoor、箱子的 pushBox、冰面或滑板的特殊触发;
  2. 部分图块没有显式 trigger,但其行为仍然会根据 cls 或其他规则隐式确定,例如怪物图块。

因此更合适的设计是:

export interface ITileLegacyConverter<TLegacy> {
    fromLegacy(num: number, legacy: TLegacy): ITileRawData;
}

其中:

  1. attachLegacyConverter(converter) 负责向 store 注入转换器;
  2. fromLegacy(num, legacy) 负责调用当前转换器完成单个 legacy 图块定义的转换,并将结果写入 store
  3. 导入整个旧引擎 blocksInfo 时,由外层自行遍历并多次调用 fromLegacy

当前更推荐的使用形式是:

tileStore.attachLegacyConverter(converter);

for (const [key, value] of Object.entries(core.maps.blocksInfo)) {
    tileStore.fromLegacy(Number(key), value);
}

其职责包括:

  1. 用户层定义 legacy -> ITileRawData 的转换规则;
  2. 显式处理“trigger 字段优先”与“按 cls 推导”并存的旧设计;
  3. 保证 ITileStore 本体只负责存储与查询,不负责耦合旧样板细节。

实现思路

1. 先建立独立的 store 模块

因为 ITileStore 属于 Layer 0且不依赖地图、怪物、勇士等更高层模块所以更适合作为 @user/data-base/src/store 下的首个 Store 类接口存在。

当前不建议单独再开 tile 文件夹,而是直接放到 packages-user/data-base/src/store 中。这样后续若还有其他内容迁移为新的 Store 类接口,也可以继续并列放在 store 目录下,而不是再拆出多个平行顶层目录。

2. 对外暴露方法,对内仍可继续使用双映射

idNumberMapnumberIdMap 迁移到 ITileStore 后,对外不再暴露原始 Map,而改成方法:

  1. idToNumber(id)
  2. numberToId(num)

但在内部实现上,仍然完全可以保留:

  1. Map<string, number>
  2. Map<number, string>
  3. Map<number, ITileRawData>

也就是说,这次重构的重点是收拢职责和稳定接口,不是刻意放弃现有映射结构。

3. getType 直接从原始数据读取

按照当前结论,ITileRawData 包含:

  1. num
  2. id
  3. trigger
  4. type

这意味着 getType(num) 不需要再依赖额外并行映射,而可以直接读取 getData(num)?.type。这样做的好处是:

  1. addTile 的输入结构完整且闭合;
  2. 不会再出现“raw data 一套、type 映射又一套”的双数据源;
  3. legacy converter 转换出的结果可以直接完整写入 store。

4. addTile 只负责录入定义,不负责存档

addTile(data) 的职责应当收敛到“向 store 录入一个图块定义”,而不是承担任何运行时逻辑。

因为这部分数据不会动态变更,也不参与存档,所以其主要使用时机只有:

  1. 初次初始化
  2. 旧引擎数据迁移
  3. 极少量的测试或工具链注入

当前你已经给出了 number 冲突时“警告并覆盖”的语义,这一点应该直接保留。

此外,id 冲突但 num 不冲突时,也应当采用同样的警告并覆盖策略;并且警告内容需要明确指出冲突来源到底是 num 还是 id

5. 全局状态从两张 Map 改为一个 store

当前 packages-user/data-base/src/types.ts 里的 IStateBase 仍直接暴露:

  1. idNumberMap
  2. numberIdMap

如果 ITileStore 建立起来,那么全局状态更合理的暴露方式应当改为:

readonly tileStore: ITileStore;

后续所有调用点统一改成:

  1. state.tileStore.idToNumber(id)
  2. state.tileStore.numberToId(num)
  3. state.tileStore.getTrigger(num)
  4. state.tileStore.getType(num)

这样图块定义相关职责就不会再散落在全局状态根节点上。

6. 旧引擎迁移的职责边界

当前 packages-user/data-state/src/index.ts 在初始化阶段直接遍历 core.maps.blocksInfo,并手动填充 state.idNumberMap / state.numberIdMap

引入 ITileStore 后,这段初始化逻辑更适合拆成两段:

  1. 先在用户层实现并挂载 legacy converter
  2. 再遍历 core.maps.blocksInfo,逐个调用 tileStore.fromLegacy(num, block)
  3. 其他真正依赖图块定义的初始化逻辑,例如朝向绑定,再从 tileStore 继续读取数据。

这样旧引擎兼容逻辑就不会和全局状态初始化逻辑搅在一起。

同时,因为旧样板里的触发器来源并不统一,这种“用户层 converter + store 只负责存储”的边界也更合理:

  1. store 不需要知道 trigger 究竟来自字段、cls,还是更特殊的 legacy 规则;
  2. 用户层可以按当前项目的具体兼容策略自由决定优先级;
  3. 将来若 legacy 来源变化,只需要替换 converter不必改 ITileStore 本体。

涉及文件

需要引用的文件

需要修改的文件

@user/data-base/src/store/types.ts

  • 新增 ITileRawData 接口:定义图块最小原始定义,当前包含 numidtriggertype
  • 新增 TileType 枚举:定义统一的图块逻辑分类
  • 新增 ITileLegacyConverter 接口:定义 legacy 图块定义到 ITileRawData 的转换规则
  • 新增 ITileStore 接口:提供图块定义的统一查询与写入入口
  • ITileStore 新增 attachLegacyConverterfromLegacy

@user/data-base/src/store/tileStore.ts

  • 实现 ITileStore
  • 内部维护 num -> raw dataid -> numnum -> id 的映射
  • 实现 addTile 的警告覆盖逻辑,并区分 num 冲突与 id 冲突
  • 实现 attachLegacyConverterfromLegacy

@user/data-base/src/store/index.ts

  • 导出 tile 模块公共接口与实现

@user/data-base/src/types.ts

  • IStateBase 中移除 idNumberMapnumberIdMap
  • 新增 readonly tileStore: ITileStore

@user/data-base/src/index.ts

  • 补齐 tile 模块的公共导出

@user/data-state/src/core.ts

  • 移除 CoreState 对两张映射的直接持有
  • 改为持有 tileStore

@user/data-state/src/index.ts

  • 将旧引擎 blocksInfo 的初始化逻辑迁移到“挂载 converter 后逐个调用 fromLegacy
  • 后续朝向绑定等逻辑改为通过 tileStore.idToNumber 读取图块数字

docs/dev/map/tile-info.md

  • ITileStore 定稿后,再继续补齐地图触发器设计文档中对图块默认触发器来源的描述

当前结论

  1. TileType 应当直接包含进 ITileRawData,不再单独拆成并行数据源。
  2. legacy 导入不再使用顶层工厂函数,而改为 attachLegacyConverter + fromLegacy 组合;由用户层自行提供 converter再逐个执行转换。
  3. addTilenum 冲突与 id 冲突两种场景下都应警告并覆盖,且警告信息必须明确指出冲突来源。
  4. getTrigger(num) 在图块不存在或无触发器时统一返回 -1
  5. 旧引擎里的默认触发器来源是混合式的:有时来自显式 trigger 字段,有时来自 cls 或其他规则;这一差异应当由用户层 converter 消化,而不是让 ITileStore 本体直接耦合旧样板细节。