16 KiB
需求综述
当前关于地图触发器的设计卡在“图块定义本身尚未抽象出来”这一前置问题上。继续讨论 docs/dev/map/tile-info.md 中的地图触发器存储方案,会立刻撞上一个更底层的问题:
- 某个图块数字到底对应什么 id;
- 某个图块默认携带什么触发器类型;
- 某个图块属于哪一类图块;
- 旧引擎中的
blocksInfo应该如何转移到新接口。
因此,下一步更合适的顺序不是继续扩展地图侧接口,而是先补齐一个独立的 ITileStore。它属于 Layer 0,不参与存档,也不承载运行时动态状态,只负责提供“图块定义查询”这一底层能力。
这次设计的目标如下:
- 将当前挂在全局状态上的
idNumberMap与numberIdMap收拢到ITileStore内; - 为图块定义提供统一查询入口:
getData、getTrigger、getType; - 提供统一写入口
addTile,用于初始化阶段录入图块定义; - 提供
attachLegacyConverter与fromLegacy两个接口,用于从旧引擎迁移图块定义; - 明确这部分能力不属于存档系统,且应当放在 Layer 0。
接口设计与预期
ITileRawData
ITileRawData 表示单个图块的最小原始定义。按照当前结论,需要包含四个字段:图块数字、图块 id、触发器类型、图块类型。
ITileRawData.num:预期频率中频。图块数字是整个图块定义系统的主键,addTile、getData与旧引擎导入都会依赖它,但日常脚本中通常不会反复直接读写,故为中频。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 需要返回一个图块类型枚举。按照当前需求,建议先定义以下八种:
UnknownNoneTerrainAnimateItemEnemyNpcTileset
这里的目的不是完整复刻旧引擎的所有 cls,而是先给当前执行层与地图层提供足够稳定、足够粗粒度的类型划分。基于旧引擎的现有分类,当前建议映射关系如下:
0号空白图块映射为Noneterrains与autotile统一映射为Terrainanimates映射为Animateitems映射为Itemenemys与enemy48统一映射为Enemynpcs与npc48统一映射为Npctileset映射为Tileset- 其他尚未归类或不存在的图块映射为
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;
}
返回值语义建议如下:
getData(num):若图块不存在,返回nullgetTrigger(num):若图块不存在或未配置触发器,返回-1getType(num):若图块不存在或尚未归类,返回TileType.UnknownidToNumber(id)/numberToId(num):不存在时返回null
之所以让 getTrigger 和 getType 在缺失场景下返回稳定默认值,是因为这两者更偏“高频逻辑查询”,热路径上不适合层层判空。
ITileLegacyConverter / attachLegacyConverter / fromLegacy
ITileStore 本身不应直接理解旧引擎里 blocksInfo 的全部细节,而应通过用户层提供的 legacy converter 完成转换。这样做的原因是:旧引擎中的默认触发器来源并不统一,既有显式 trigger 字段,也有通过 cls 或其他成员隐式决定的情况。
从 public/project/maps.js 当前样板可以直接看到两类典型情况:
- 部分图块显式写了
trigger,例如黄门的openDoor、箱子的pushBox、冰面或滑板的特殊触发; - 部分图块没有显式
trigger,但其行为仍然会根据cls或其他规则隐式确定,例如怪物图块。
因此更合适的设计是:
export interface ITileLegacyConverter<TLegacy> {
fromLegacy(num: number, legacy: TLegacy): ITileRawData;
}
其中:
attachLegacyConverter(converter)负责向 store 注入转换器;fromLegacy(num, legacy)负责调用当前转换器完成单个 legacy 图块定义的转换,并将结果写入 store;- 导入整个旧引擎
blocksInfo时,由外层自行遍历并多次调用fromLegacy。
当前更推荐的使用形式是:
tileStore.attachLegacyConverter(converter);
for (const [key, value] of Object.entries(core.maps.blocksInfo)) {
tileStore.fromLegacy(Number(key), value);
}
其职责包括:
- 用户层定义 legacy ->
ITileRawData的转换规则; - 显式处理“trigger 字段优先”与“按 cls 推导”并存的旧设计;
- 保证
ITileStore本体只负责存储与查询,不负责耦合旧样板细节。
实现思路
1. 先建立独立的 store 模块
因为 ITileStore 属于 Layer 0,且不依赖地图、怪物、勇士等更高层模块,所以更适合作为 @user/data-base/src/store 下的首个 Store 类接口存在。
当前不建议单独再开 tile 文件夹,而是直接放到 packages-user/data-base/src/store 中。这样后续若还有其他内容迁移为新的 Store 类接口,也可以继续并列放在 store 目录下,而不是再拆出多个平行顶层目录。
2. 对外暴露方法,对内仍可继续使用双映射
idNumberMap 与 numberIdMap 迁移到 ITileStore 后,对外不再暴露原始 Map,而改成方法:
idToNumber(id)numberToId(num)
但在内部实现上,仍然完全可以保留:
Map<string, number>Map<number, string>Map<number, ITileRawData>
也就是说,这次重构的重点是收拢职责和稳定接口,不是刻意放弃现有映射结构。
3. getType 直接从原始数据读取
按照当前结论,ITileRawData 包含:
numidtriggertype
这意味着 getType(num) 不需要再依赖额外并行映射,而可以直接读取 getData(num)?.type。这样做的好处是:
addTile的输入结构完整且闭合;- 不会再出现“raw data 一套、type 映射又一套”的双数据源;
- legacy converter 转换出的结果可以直接完整写入 store。
4. addTile 只负责录入定义,不负责存档
addTile(data) 的职责应当收敛到“向 store 录入一个图块定义”,而不是承担任何运行时逻辑。
因为这部分数据不会动态变更,也不参与存档,所以其主要使用时机只有:
- 初次初始化
- 旧引擎数据迁移
- 极少量的测试或工具链注入
当前你已经给出了 number 冲突时“警告并覆盖”的语义,这一点应该直接保留。
此外,id 冲突但 num 不冲突时,也应当采用同样的警告并覆盖策略;并且警告内容需要明确指出冲突来源到底是 num 还是 id。
5. 全局状态从两张 Map 改为一个 store
当前 packages-user/data-base/src/types.ts 里的 IStateBase 仍直接暴露:
idNumberMapnumberIdMap
如果 ITileStore 建立起来,那么全局状态更合理的暴露方式应当改为:
readonly tileStore: ITileStore;
后续所有调用点统一改成:
state.tileStore.idToNumber(id)state.tileStore.numberToId(num)state.tileStore.getTrigger(num)state.tileStore.getType(num)
这样图块定义相关职责就不会再散落在全局状态根节点上。
6. 旧引擎迁移的职责边界
当前 packages-user/data-state/src/index.ts 在初始化阶段直接遍历 core.maps.blocksInfo,并手动填充 state.idNumberMap / state.numberIdMap。
引入 ITileStore 后,这段初始化逻辑更适合拆成两段:
- 先在用户层实现并挂载 legacy converter;
- 再遍历
core.maps.blocksInfo,逐个调用tileStore.fromLegacy(num, block); - 其他真正依赖图块定义的初始化逻辑,例如朝向绑定,再从
tileStore继续读取数据。
这样旧引擎兼容逻辑就不会和全局状态初始化逻辑搅在一起。
同时,因为旧样板里的触发器来源并不统一,这种“用户层 converter + store 只负责存储”的边界也更合理:
- store 不需要知道
trigger究竟来自字段、cls,还是更特殊的 legacy 规则; - 用户层可以按当前项目的具体兼容策略自由决定优先级;
- 将来若 legacy 来源变化,只需要替换 converter,不必改
ITileStore本体。
涉及文件
需要引用的文件
- packages-user/data-base/src/types.ts:当前
IStateBase仍直接暴露idNumberMap与numberIdMap - packages-user/data-state/src/index.ts:当前旧引擎图块定义导入逻辑的主要入口
- packages-user/data-state/src/core.ts:当前
CoreState中两张映射的实际持有位置 - packages-user/client-modules/src/fallback/load.ts:当前旧引擎图块
cls分类的实际使用点,可作为TileType映射参考 - docs/dev/map/tile-info.md:后续地图触发器设计将直接依赖
ITileStore
需要修改的文件
@user/data-base/src/store/types.ts
- 新增
ITileRawData接口:定义图块最小原始定义,当前包含num、id、trigger、type - 新增
TileType枚举:定义统一的图块逻辑分类 - 新增
ITileLegacyConverter接口:定义 legacy 图块定义到ITileRawData的转换规则 - 新增
ITileStore接口:提供图块定义的统一查询与写入入口 - 为
ITileStore新增attachLegacyConverter与fromLegacy
@user/data-base/src/store/tileStore.ts
- 实现
ITileStore - 内部维护
num -> raw data、id -> num与num -> id的映射 - 实现
addTile的警告覆盖逻辑,并区分num冲突与id冲突 - 实现
attachLegacyConverter与fromLegacy
@user/data-base/src/store/index.ts
- 导出 tile 模块公共接口与实现
@user/data-base/src/types.ts
- 从
IStateBase中移除idNumberMap与numberIdMap - 新增
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定稿后,再继续补齐地图触发器设计文档中对图块默认触发器来源的描述
当前结论
TileType应当直接包含进ITileRawData,不再单独拆成并行数据源。- legacy 导入不再使用顶层工厂函数,而改为
attachLegacyConverter + fromLegacy组合;由用户层自行提供 converter,再逐个执行转换。 addTile在num冲突与id冲突两种场景下都应警告并覆盖,且警告信息必须明确指出冲突来源。getTrigger(num)在图块不存在或无触发器时统一返回-1。- 旧引擎里的默认触发器来源是混合式的:有时来自显式
trigger字段,有时来自cls或其他规则;这一差异应当由用户层 converter 消化,而不是让ITileStore本体直接耦合旧样板细节。