feat: 静态触发器存储

This commit is contained in:
unanmed 2026-05-17 20:49:08 +08:00
parent 13fc4e1b7c
commit bf781c5ee8
9 changed files with 741 additions and 174 deletions

283
docs/dev/map/tile-info.md Normal file
View File

@ -0,0 +1,283 @@
# 需求综述
重新考虑之后,这一层需求更适合收敛成“图块数据”和“触发器数据”两套独立结构,而不是先抽象一层通用 `ITileInfo` 对象。
原因如下:
1. `IMapLayer` 内部的 `Uint32Array` 主要服务于高频查询与渲染,本身就应该尽量纯粹;
2. 大多数格点只是墙壁、地板、空气,不含任何触发器,没有必要给每个格点额外挂一个信息对象;
3. 当前触发器系统在数据层真正需要的并不是 `ITrigger` 对象,而只是“该载体对应哪一种触发器类型”;
4. 动态图块是实例对象,天然适合作为触发器的运行时载体,触发器跟随动态图块移动也更自然;
5. 存档是否需要记录触发器,取决于“触发器是跟随地图状态定义,还是跟随图块定义”,这应当单独讨论,而不是先把对象模型写死。
因此,本文不再优先设计 `IWrappedTileInfo`、`ITileInfo`、`createTileInfo`、`bindTileInfo` 这一套通用信息接口,而改为先构建一套**以 `ITileStore` 默认值为主、地图稀疏覆盖为辅的触发器方案**
1. `ILayerState` 持有 `ITileStore` 引用,静态格点未主动设置时使用图块默认触发器类型;
2. `IMapLayer` 仅按坐标稀疏存储“手动覆盖值”,并提供 `revertTrigger` 恢复默认触发器;
3. 动态图块实例直接持有自己的触发器类型数字;
4. `ITrigger` 对象仍只存在于 Layer 2在收集阶段按需实例化
5. `setBlock` 与触发器解绑,不再通过 `keepInfo` 一类参数耦合两者。
---
# 接口设计与预期
## ILayerState.tileStore
- `ILayerState.tileStore`:当前图层状态持有的图块定义 store 引用。
- 预期频率:**低频到中频**。外部直接访问它的频率不会太高,但 `IMapLayer.getTriggerType` 等底层逻辑会稳定依赖它作为默认触发器来源。
- 典型使用场景:静态格点没有手动设置触发器时,`IMapLayer` 先读取当前图块数字,再通过 `layerState.tileStore.getTrigger(num)` 获取默认触发器类型。
## IMapLayer.getTriggerType
- `IMapLayer.getTriggerType(x, y)`:按坐标读取当前静态格点的“有效触发器类型”。若该点存在手动覆盖则返回覆盖值,否则回退到当前图块在 `ITileStore` 中的默认触发器类型。
- 预期频率:**高频**。触发器收集时每次都需要先确认目标静态格点最终会暴露出哪一种触发器。
- 典型使用场景:`ITriggerCollector.collect(x, y, layer)` 读取 `layer``(x, y)` 上的静态触发器类型。
- 返回值建议:与 `ITileStore` 保持一致,越界或不存在触发器时统一返回 `-1`
## IMapLayer.setTriggerType
- `IMapLayer.setTriggerType(type, x, y)`:为指定静态格点写入手动覆盖的触发器类型数字。
- 预期频率:**中频**。主要出现在地图初始化、编辑器写回、运行时脚本修改事件配置等场景。
- 典型使用场景:给某一格配置战斗触发器;或显式将某个原本有默认触发器的图块覆盖为“无触发器”。
- 语义建议:`setTriggerType` 只负责写覆盖值,不负责恢复默认值。若 `type = -1`,表示显式覆盖为“无触发器”;若要恢复为图块默认触发器,应调用 `revertTrigger`
## IMapLayer.revertTrigger
- `IMapLayer.revertTrigger(x, y)`:删除指定静态格点的手动触发器覆盖,使其重新回退到 `ITileStore` 的默认触发器类型。
- 预期频率:**低频到中频**。主要出现在编辑器撤销覆盖、脚本恢复默认配置、动静态图块转换回写时。
- 典型使用场景:某格点曾被脚本临时设置为其他触发器,演出结束后恢复为该图块原本的默认触发器。
## IDynamicTile.triggerType
- `IDynamicTile.triggerType`:当前动态图块携带的触发器类型数字,`-1` 表示无触发器。
- 预期频率:**中频**。收集器在读取某格所有动态图块时需要访问;运行时若有脚本操作某个动态图块事件,也可能直接读写。
- 典型使用场景:某个 NPC 作为动态图块移动时,其战斗或对话触发器跟随该实例一起移动。
## IDynamicTile.setTriggerType
- `IDynamicTile.setTriggerType(type)`:修改当前动态图块绑定的触发器类型。
- 预期频率:**低频到中频**。通常只在创建动态图块、转换动静态图块、特殊脚本重配置时使用。
- 典型使用场景:演出过程中把一个原本只是装饰的动态图块改成可交互单位。
## IDynamicLayer.transferToDynamic
- `IDynamicLayer.transferToDynamic(x, y, keepTrigger?)`:把静态图块转换为动态图块,并按参数决定是否保留原先的触发器。
- 预期频率:**中频**。每次把地图中的静态物件实例化为可移动对象时都会用到。
- 典型使用场景:把地图上的 NPC 图块转成 `IDynamicTile`,并让对话或战斗触发器跟着它一起移动。
- 语义建议:`keepTrigger` 默认值先定为 `true`。当 `keepTrigger = true` 时,将静态格点当前的有效触发器类型迁移到新建的动态图块;当 `keepTrigger = false` 时,新动态图块不保留原触发器,并且静态格点上的手动覆盖应一并清除。
## IDynamicLayer.transferToStatic
- `IDynamicLayer.transferToStatic(tile, keepTrigger?)`:把动态图块还原为静态图块,并按参数决定是否把动态图块携带的触发器写回静态格点。
- 预期频率:**中频**。会移动的图块在结束移动后重新落回地图时使用。
- 典型使用场景:可推动箱子停止后重新固化为静态图块,同时决定是否保留它携带的触发器。
- 语义建议:`keepTrigger` 默认值先定为 `true`。当 `keepTrigger = true` 时,将 `tile.triggerType` 写回目标静态格点;若写回值与该图块默认触发器一致,则应直接 `revertTrigger`,只在不一致时存入覆盖值。当 `keepTrigger = false` 时,不写回动态图块携带的触发器,静态格点自然回退到图块默认触发器。
## IDynamicLayer.transferToStaticIfSafe
- `IDynamicLayer.transferToStaticIfSafe(tile, keepTrigger?)`:在满足安全回写条件时将动态图块还原为静态图块,并保持与 `transferToStatic` 一致的触发器迁移语义。
- 预期频率:**中频**。用于需要先判断目标格点是否允许回写的动静态转换场景。
- 典型使用场景:某个可移动物体尝试停回地图时,若目标格点安全则回写图块和触发器,否则保留动态状态。
- 语义建议:为保持三组转换接口的对称性,`keepTrigger` 也应加入该接口,且默认值同样先定为 `true`
## ITriggerCollector.collect
- `ITriggerCollector.collect(x, y, layer)`:当前不再从 `getTileInfo` 中读取对象信息,而是分别读取静态格点与动态图块上的触发器类型数字,再按需实例化 `ITrigger`
- 预期频率:**中频**。玩家移动、交互判定、脚本触发等场景都会使用。
- 典型使用场景:
1. 读取 `layer.getTriggerType(x, y)`
2. 迭代 `layer.dynamicLayer.getDynamicTilesAt(x, y)`,读取每个 `tile.triggerType`
3. 将所有非 `-1` 的触发器类型交给 `ITriggerRegistry` 创建运行时 `ITrigger` 对象;
4. 排序后组成 `ITriggerCollection`
这里需要注意:**数据层不再存 `ITrigger` 对象,只存触发器类型数字;对象实例化延后到收集阶段完成。** 这样既保留了 Layer 2 的运行时灵活性,又降低了 Layer 1 的存储复杂度。
## 当前不再优先设计的接口
以下内容在这轮设计中不再作为主方案:
1. `ITileInfo` / `IWrappedTileInfo` 一类通用格点信息对象;
2. `createTileInfo()` / `bindTileInfo()` 一类对象工厂接口;
3. `setBlock(block, x, y, keepInfo?)` 这类把图块写入与触发器保留策略绑在一起的接口。
这并不表示将来永远不会有通用格点附加信息,只是当前真实需求已经收敛到“触发器类型数字”,继续上抽象只会把接口设计得更重。
---
# 实现思路
## 1. 图块数组与触发器数据彻底分离
`IMapLayer` 内部继续保留 `Uint32Array` 作为图块数字的权威存储,仅服务于:
1. 高频 `getBlock` 查询;
2. 区域拷贝与渲染;
3. 与现有 `openDoor`、`closeDoor`、`putMapData`、`setMapRef` 等图块相关操作配合。
触发器数据则不再混入图块数组逻辑中,而是作为独立结构维护。
## 2. 静态图层使用“默认值 + 稀疏覆盖”
静态图层不应自行持有整份默认触发器表,而应通过 `ILayerState.tileStore` 获取默认值,只在地图侧额外存储“手动覆盖值”。
当前建议结构如下:
```ts
Map<number, number>;
```
- key = `y * width + x` 形式的格点下标;
- value = 手动覆盖的触发器类型数字。
`IMapLayer.getTriggerType(x, y)` 的读取顺序建议为:
1. 若坐标越界,返回 `-1`
2. 若该点存在手动覆盖,直接返回覆盖值;
3. 否则读取当前图块数字,并通过 `tileStore.getTrigger(num)` 返回默认触发器类型。
这样可以自然满足“仅存必要”的目标:
1. 没有手动修改过触发器的格点不占额外存储;
2. 大多数格点直接复用 `ITileStore` 中的默认定义,不需要在地图层重复抄一份;
3. 只有真正偏离默认值的点,才进入地图侧稀疏映射;
4. `revertTrigger` 只需删除覆盖记录即可恢复默认。
## 3. 动态图块直接携带触发器类型
动态图块已经是实例对象,因此不需要再为它额外建一层稀疏映射。更自然的方案是:
1. `DynamicTile` 内部直接保存 `triggerType: number`
2. 图块移动时,触发器天然跟随这个实例走;
3. 收集器按坐标枚举动态图块后,直接读取其 `triggerType`
这样“触发器跟着动态图块走”就不再需要额外维护坐标索引迁移,只要图块对象本身移动即可。
## 4. 动静态转换只负责迁移或丢弃触发器
因为静态格点与动态图块现在是两个不同的触发器载体,所以 `transferToDynamic` / `transferToStatic` / `transferToStaticIfSafe` 的核心职责也更清晰了:
1. `transferToDynamic(..., keepTrigger = true)`:把静态格点上的触发器迁到新动态图块;
2. `transferToDynamic(..., keepTrigger = false)`:只转换图块,不保留原触发器,并清理静态格点上的手动覆盖;
3. `transferToStatic(..., keepTrigger = true)`:把动态图块携带的触发器写回静态格点;若与默认值一致则回退默认,否则记为手动覆盖;
4. `transferToStatic(..., keepTrigger = false)`:只还原图块,不回写动态图块触发器,让静态格点自然回退到图块默认触发器;
5. `transferToStaticIfSafe(..., keepTrigger = true)`:在安全回写条件满足时,保持与 `transferToStatic` 一致的迁移语义。
这比把“是否保留触发器”揉进 `setBlock` 更符合职责边界,因为真正发生触发器载体切换的地方只有这些转换接口。
## 5. 图块写接口不再隐式影响触发器
既然图块数组与触发器数据已经拆开,则以下接口默认不应直接修改触发器:
1. `setBlock`
2. `putMapData`
3. `setMapRef`
4. `openDoor`
5. `closeDoor`
这样做的好处是:
1. 图块外观变化与逻辑触发变化彻底解耦;
2. 不再需要 `keepInfo` / `keepTrigger` 之类参数污染高频图块写路径;
3. 调用方若确实希望同步改动触发器,应显式调用 `setTriggerType` 或走动静态转换接口。
这里唯一需要特殊处理的是 `resize` / `resize2`:当地图缩小时,越界格点对应的稀疏触发器记录必须一起裁剪。
## 6. 触发器对象延后到 Layer 2 实例化
当前触发器接口仍然可以保留 `ITrigger` 对象模型,但这个对象不应该提前存进地图数据层。
更合理的职责划分是:
1. Layer 1 只存 `triggerType: number`
2. `ITriggerCollector` 在收集时根据 `triggerType``ITriggerRegistry` 要工厂;
3. 再由工厂创建运行时 `ITrigger` 对象并执行后续排序、触发流程。
这样既满足“地图上只存一个触发器类型数字即可”的诉求,也不需要推翻当前 Layer 2 的触发器执行模型。
## 7. 当前存档边界
静态格点的触发器来源当前已经明确为“图块默认值 + 地图手动覆盖值”两层结构,因此存档边界也可以先做阶段性收敛:
1. 图块默认触发器由 `ITileStore` 提供,不需要在地图层重复存储;
2. 若静态格点的手动覆盖值需要进入存档,那么只需要保存地图侧的稀疏覆盖映射,而不需要保存整份默认触发器表;
3. 动态图块的存储方案目前尚未设计完成,因此其 `triggerType` 是否进入存档、读档后如何恢复,暂不在本轮接口设计中拍死。
因此,这一节当前只确认静态覆盖的存储边界;动态图块相关存档语义后续再单独设计。
## 8. 单个载体当前只存一个触发器类型
本轮设计中,每个静态格点与每个动态图块都只保存一个 `triggerType: number`
这样做的原因是:
1. 当前触发器本身不接受用户自定义参数,地图侧没有必要提前背负对象列表;
2. 对绝大多数格点而言,一个触发器类型已经足够;
3. 同一点多触发器仍可通过“静态格点 1 个 + 多个动态图块各 1 个”的方式聚合。
当前这条限制已经足够。以现有触发器定位来看,一个触发器类型只是告诉系统“这次行为该走哪种处理分支”;更复杂的行为应当放到自定义事件等更高自由度的描述层中,而不是在单个图块上堆叠多个触发器类型。
---
# 涉及文件
## 需要引用的文件
- `@user/data-base/src/map/types.ts`:当前 `ILayerState`、`IMapLayer`、`IDynamicLayer`、`IDynamicTile` 的权威接口定义
- `@user/data-base/src/store/types.ts``ITileStore` 的权威接口定义
- `@user/data-base/src/map/mapLayer.ts`:静态图层当前的图块数组实现
- `@user/data-base/src/map/dynamicLayer.ts`:动静态图块转换逻辑的当前实现
- `@user/data-base/src/map/dynamicTile.ts`:动态图块实例对象,适合新增 `triggerType`
- `docs/dev/map/trigger.md`:当前触发器文档仍假定从 `getTileInfo` 读取信息,后续需要同步调整
## 需要修改的文件
### `@user/data-base/src/map/types.ts`
- [ ] 不再新增 `ITileInfo` / `IWrappedTileInfo` 作为本轮主接口
- [ ] 为 `ILayerState` 新增 `readonly tileStore: ITileStore`
- [ ] 为 `IMapLayer` 新增 `getTriggerType(x, y): number`
- [ ] 为 `IMapLayer` 新增 `setTriggerType(type: number, x: number, y: number): void`
- [ ] 为 `IMapLayer` 新增 `revertTrigger(x: number, y: number): void`
- [ ] 为 `IDynamicTile` 新增 `readonly triggerType: number`
- [ ] 为 `IDynamicTile` 新增 `setTriggerType(type: number): void`
- [ ] 修改 `IDynamicLayer.transferToDynamic`:新增 `keepTrigger?: boolean`
- [ ] 修改 `IDynamicLayer.transferToStatic`:新增 `keepTrigger?: boolean`
- [ ] 修改 `IDynamicLayer.transferToStaticIfSafe`:新增 `keepTrigger?: boolean`
### `@user/data-base/src/map/mapLayer.ts`
- [ ] 新增静态手动覆盖触发器类型的稀疏存储结构
- [ ] 实现 `getTriggerType` / `setTriggerType` / `revertTrigger`
- [ ] 保持 `setBlock`、`putMapData`、`setMapRef`、`openDoor`、`closeDoor` 与触发器逻辑解耦
- [ ] 在 `resize` / `resize2` 中裁剪越界触发器记录
### `@user/data-base/src/map/dynamicTile.ts`
- [ ] 新增 `triggerType` 成员与对应写接口
### `@user/data-base/src/map/dynamicLayer.ts`
- [ ] 在 `transferToDynamic` 中实现静态格点与动态图块之间的触发器迁移或丢弃逻辑
- [ ] 在 `transferToStatic` 中实现动态图块与静态格点之间的触发器迁移或丢弃逻辑
- [ ] 若保留 `transferToStaticIfSafe` 的对称性,同步补齐其触发器迁移逻辑
### `@user/data-base/src/map/mapStore.ts`
- [ ] 若静态手动覆盖需要进入存档,仅保存地图侧稀疏覆盖映射
- [ ] 动态图块触发器字段的存档逻辑待动态图块存储方案定稿后再补齐
### `docs/dev/map/trigger.md`
- [ ] 将 `collect` 过程从“读取 `getTileInfo` 中的对象信息”改为“读取静态与动态载体上的触发器类型数字,再按需实例化 `ITrigger`
---
# 当前结论
1. 静态格点触发器来源采用“`ITileStore` 默认值 + 地图手动覆盖值”两层结构,`revertTrigger` 用于恢复默认值。
2. `getTriggerType(x, y)``ITileStore.getTrigger(num)` 对齐,越界或不存在触发器时统一返回 `-1`
3. `transferToDynamic`、`transferToStatic`、`transferToStaticIfSafe` 的 `keepTrigger` 默认值暂定为 `true`
4. 静态图层只稀疏存储手动覆盖值,不重复保存整份默认触发器表。
5. 动态图块触发器的存档语义暂不拍死,待动态图块存储方案单独定稿后再处理。
6. 单个载体只保存一个 `triggerType` 当前已经足够,复杂行为应交由自定义事件等更高层描述。

View File

@ -36,22 +36,42 @@ export class DynamicLayer
createDynamic(num: number, x: number, y: number): IDynamicTile {
const tile = new DynamicTile(num, x, y, this);
tile.setTriggerType(this.layer.getTriggerType(x, y));
this.addTileToPosMap(tile, x, y);
this.posTileMap.set(tile, { x, y });
this.forEachHook(hook => hook.onCreateTile?.(tile, this));
return tile;
}
transferToDynamic(x: number, y: number): IDynamicTile {
transferToDynamic(
x: number,
y: number,
keepTrigger: boolean = true
): IDynamicTile {
const num = this.layer.getBlock(x, y);
const triggerType = keepTrigger ? this.layer.getTriggerType(x, y) : -1;
if (num === 0) {
logger.warn(127, x.toString(), y.toString());
}
this.layer.setBlock(0, x, y);
return this.createDynamic(num, x, y);
this.layer.revertTrigger(x, y);
const tile = this.createDynamic(num, x, y);
tile.setTriggerType(triggerType);
return tile;
}
transferToStatic(tile: IDynamicTile): void {
/**
*
*/
private syncStaticTrigger(tile: IDynamicTile, keepTrigger: boolean): void {
if (keepTrigger) {
this.layer.setTriggerType(tile.triggerType, tile.x, tile.y);
} else {
this.layer.revertTrigger(tile.x, tile.y);
}
}
transferToStatic(tile: IDynamicTile, keepTrigger: boolean = true): void {
const { x, y } = tile;
const { width, height } = this.layer;
if (x < 0 || y < 0 || x >= width || y >= height) {
@ -62,16 +82,26 @@ export class DynamicLayer
logger.warn(129, x.toString(), y.toString());
}
this.layer.setBlock(tile.num, x, y);
this.syncStaticTrigger(tile, keepTrigger);
this.removeTile(tile);
this.forEachHook(hook => {
void hook.onDeleteTile?.(tile, this);
});
this.forEachHook(hook => hook.onDeleteTile?.(tile, this));
}
transferToStaticIfSafe(tile: IDynamicTile): boolean {
transferToStaticIfSafe(
tile: IDynamicTile,
keepTrigger: boolean = true
): boolean {
const { x, y } = tile;
const { width, height } = this.layer;
if (x < 0 || y < 0 || x >= width || y >= height) {
logger.warn(128, x.toString(), y.toString());
return false;
}
if (this.layer.getBlock(tile.x, tile.y) !== 0) return false;
this.layer.setBlock(tile.num, tile.x, tile.y);
this.deleteDynamic(tile);
this.layer.setBlock(tile.num, x, y);
this.syncStaticTrigger(tile, keepTrigger);
this.removeTile(tile);
this.forEachHook(hook => hook.onDeleteTile?.(tile, this));
return true;
}
@ -117,12 +147,18 @@ export class DynamicLayer
this.forEachHook(hook => hook.onUpdateTilePosition?.(tile, this));
}
/**
*
*/
private addTileToPosMap(tile: IDynamicTile, x: number, y: number): void {
const xMap = this.tilePosMap.getOrInsertComputed(y, () => new Map());
const set = xMap.getOrInsertComputed(x, () => new Set());
set.add(tile);
}
/**
*
*/
private removeTileFromPosMap(
tile: IDynamicTile,
x: number,
@ -131,7 +167,9 @@ export class DynamicLayer
this.tilePosMap.get(y)?.get(x)?.delete(tile);
}
/** 从两个内部映射中移除图块记录 */
/**
*
*/
private removeTile(tile: IDynamicTile): void {
const pos = this.posTileMap.get(tile);
if (pos) {

View File

@ -10,6 +10,7 @@ import { DynamicTileMover } from './mover';
export class DynamicTile implements IDynamicTile {
readonly mover: IObjectMover<IDynamicTile>;
triggerType: number;
/** 当前的朝向绑定对象 */
private face: IRoleFaceBinder | null = null;
@ -21,6 +22,7 @@ export class DynamicTile implements IDynamicTile {
public readonly layer: IDynamicLayer
) {
this.mover = new DynamicTileMover(this);
this.triggerType = -1;
}
setFaceBinder(binder: IRoleFaceBinder | null): void {
@ -36,6 +38,10 @@ export class DynamicTile implements IDynamicTile {
return this.num;
}
setTriggerType(type: number): void {
this.triggerType = type;
}
delete(): Promise<void> {
return this.layer.deleteDynamic(this);
}

View File

@ -11,6 +11,7 @@ import {
IMapLayerHookController,
IMapLayerHooks
} from './types';
import { ITileStore } from '../store';
import { MapLayer } from './mapLayer';
export class LayerState
@ -38,6 +39,7 @@ export class LayerState
private dirty: boolean = false;
constructor(
public readonly tileStore: ITileStore,
public width: number,
public height: number
) {
@ -46,7 +48,12 @@ export class LayerState
addLayer(): IMapLayer {
const array = new Uint32Array(this.width * this.height);
const layer = new MapLayer(array, this.width, this.height);
const layer = new MapLayer(
array,
this.width,
this.height,
this.tileStore
);
this.layerList.add(layer);
this.mapLayerList.add(layer);
this.forEachHook(hook => {

View File

@ -8,6 +8,7 @@ import {
} from './types';
import { Hookable, HookController, logger } from '@motajs/common';
import { DynamicLayer } from './dynamicLayer';
import { ITileStore } from '../store';
// todo: 提供 core.setBlock 等方法的替代方法,同时添加 setBlockList以及前景背景的接口
@ -24,10 +25,17 @@ export class MapLayer
private mapArray: Uint32Array;
/** 地图数据引用 */
private mapData: IMapLayerData;
/** 手动触发器覆盖映射key = y * width + x */
private triggerMap: Map<number, number> = new Map();
readonly dynamicLayer: IDynamicLayer;
constructor(array: Uint32Array, width: number, height: number) {
constructor(
array: Uint32Array,
width: number,
height: number,
private readonly tileStore: ITileStore
) {
super();
this.width = width;
this.height = height;
@ -42,6 +50,25 @@ export class MapLayer
this.dynamicLayer = new DynamicLayer(this);
}
/**
*
*/
private remapTriggerMap(
beforeWidth: number,
width: number,
height: number
): Map<number, number> {
const next = new Map<number, number>();
for (const [index, type] of this.triggerMap) {
const x = index % beforeWidth;
const y = Math.floor(index / beforeWidth);
if (x < width && y < height) {
next.set(y * width + x, type);
}
}
return next;
}
resize(width: number, height: number): void {
if (this.width === width && this.height === height) {
return;
@ -55,6 +82,7 @@ export class MapLayer
this.height = height;
const area = width * height;
const newArray = new Uint32Array(area);
this.triggerMap = this.remapTriggerMap(beforeWidth, width, height);
this.mapArray = newArray;
// 将原来的地图数组赋值给现在的
if (beforeArea > area) {
@ -84,13 +112,16 @@ export class MapLayer
resize2(width: number, height: number): void {
if (this.width === width && this.height === height) {
this.empty = true;
this.mapArray.fill(0);
this.triggerMap.clear();
return;
}
this.mapData.expired = true;
this.width = width;
this.height = height;
this.mapArray = new Uint32Array(width * height);
this.triggerMap.clear();
this.mapData = {
expired: false,
array: this.mapArray
@ -121,6 +152,39 @@ export class MapLayer
return this.mapArray[y * this.width + x];
}
getTriggerType(x: number, y: number): number {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
return -1;
}
const index = y * this.width + x;
if (this.triggerMap.has(index)) {
return this.triggerMap.get(index)!;
}
return this.tileStore.getTrigger(this.mapArray[index]);
}
setTriggerType(type: number, x: number, y: number): void {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
return;
}
const index = y * this.width + x;
if (this.tileStore.getTrigger(this.mapArray[index]) === type) {
this.triggerMap.delete(index);
} else {
this.triggerMap.set(index, type);
}
}
revertTrigger(x: number, y: number): void {
if (x >= 0 && y >= 0 && x < this.width && y < this.height) {
this.triggerMap.delete(y * this.width + x);
}
}
clearTrigger(): void {
this.triggerMap.clear();
}
putMapData(array: Uint32Array, x: number, y: number, width: number): void {
if (array.length % width !== 0) {
logger.warn(8);
@ -201,13 +265,6 @@ export class MapLayer
return res;
}
/**
*
*/
getMapRef(): IMapLayerData {
return this.mapData;
}
setMapRef(array: Uint32Array): void {
if (array.length !== this.width * this.height) {
logger.warn(
@ -229,6 +286,18 @@ export class MapLayer
});
}
getMapRef(): IMapLayerData {
return this.mapData;
}
setTriggerRef(triggers: Map<number, number>): void {
this.triggerMap = triggers;
}
getTriggerRef(): ReadonlyMap<number, number> {
return this.triggerMap;
}
protected createController(
hook: Partial<IMapLayerHooks>
): IMapLayerHookController {

View File

@ -1,5 +1,6 @@
import { logger } from '@motajs/common';
import { SaveCompression } from '../common';
import { ITileStore } from '../store';
import {
ILayerState,
ILayerStateSave,
@ -31,6 +32,8 @@ export class MapStore implements IMapStore {
/** 自动分区激活器开关 */
private autoActivitorEnabled: boolean = false;
constructor(private readonly tileStore: ITileStore) {}
//#region 楼层管理
createLayerState(id: string, width: number, height: number): ILayerState {
@ -39,7 +42,7 @@ export class MapStore implements IMapStore {
} else {
this.maps.push(id);
}
const state = new LayerState(width, height);
const state = new LayerState(this.tileStore, width, height);
// 若 refData 已存在,新楼层直接视为全脏
if (this.refData !== null) {
state.setDirty(true);
@ -117,6 +120,10 @@ export class MapStore implements IMapStore {
this.lastFloorId = id;
}
/**
* maps
* @param idx maps
*/
private findAreaByIndex(idx: number): MapArea | null {
for (const area of this.areaList) {
for (const interval of area) {
@ -128,6 +135,11 @@ export class MapStore implements IMapStore {
return null;
}
/**
*
* @param area
* @param active
*/
private setAreaActive(area: MapArea, active: boolean): void {
for (const interval of area) {
for (let i = interval.start; i <= interval.end; i++) {
@ -169,7 +181,7 @@ export class MapStore implements IMapStore {
//#endregion
//#region 存档及压缩
//#region 存档工具
compareWith(ref: Map<string, Map<number, Uint32Array>>): void {
if (this.refData !== null) return;
@ -202,6 +214,116 @@ export class MapStore implements IMapStore {
}
}
/**
*
* @param state
*/
private getTriggerMap(state: ILayerState) {
const triggers = new Map<number, Map<number, number>>();
for (const layer of state.layerList) {
const map = layer.getTriggerRef();
if (map.size > 0) {
triggers.set(layer.zIndex, new Map(map));
}
}
return triggers;
}
/**
*
* @param state
*/
private emptySave(state: ILayerState): ILayerStateSave {
return {
background: state.getBackground(),
layers: new Map(),
triggers: this.getTriggerMap(state)
};
}
/**
*
* @param layer
*/
private fullLayer(layer: IMapLayer): IMapLayerSave {
return {
width: layer.width,
height: layer.height,
fullMap: new Uint32Array(layer.getMapRef().array)
};
}
/**
* NoCompression / LowCompression
* @param state
*/
private saveLayerStateFull(state: LayerState): ILayerStateSave {
const background = state.getBackground();
const layers = new Map<number, IMapLayerSave>();
const triggers = new Map<number, Map<number, number>>();
for (const layer of state.layerList) {
const arr = layer.getMapRef().array;
layers.set(layer.zIndex, {
width: layer.width,
height: layer.height,
fullMap: new Uint32Array(arr)
});
triggers.set(layer.zIndex, new Map(layer.getTriggerRef()));
}
return { background, layers, triggers };
}
/**
* HighCompression
* @param layer
* @param refArray
*/
private diffRows(
layer: IMapLayer,
refArray: Uint32Array
): Map<number, Uint32Array> {
const rows = new Map<number, Uint32Array>();
const arr = layer.getMapRef().array;
for (let row = 0; row < layer.height; row++) {
const start = row * layer.width;
const end = start + layer.width;
const slice = arr.subarray(start, end);
const refSlice = refArray.subarray(start, end);
const same = refSlice.every((v, i) => slice[i] === v);
if (!same) {
rows.set(row, new Uint32Array(slice));
}
}
return rows;
}
/**
* LowCompression
* @param id id
* @param state
*/
private isStateEqualToRef(id: string, state: LayerState): boolean {
const refFloor = this.refData?.get(id);
if (!refFloor) return false;
for (const layer of state.layerList) {
const refArray = refFloor.get(layer.zIndex);
if (!refArray) return false;
const cur = layer.getMapRef().array;
if (cur.length !== refArray.length) return false;
if (cur.some((v, i) => v !== refArray[i])) return false;
}
return true;
}
//#endregion
//#region 存档
/**
*
*/
private saveNoCompression(): IMapStoreSave {
const floors = new Map<string, ILayerStateSave>();
for (const [id, state] of this.mapData) {
@ -211,77 +333,119 @@ export class MapStore implements IMapStore {
return { floors };
}
/**
*
*/
private saveLowCompression(): IMapStoreSave {
const floors = new Map<string, ILayerStateSave>();
for (const [id, state] of this.mapData) {
if (!state.active) continue;
// 非 dirty 或 dirty 但与参考基准完全一致 → 空 layers读档时从参考基准恢复
if (
!state.isDirty() ||
(this.refData && this.isStateEqualToRef(id, state))
) {
floors.set(id, {
background: state.getBackground(),
layers: new Map()
});
} else {
floors.set(id, this.saveLayerStateFull(state));
if (this.refData) {
// 包含参考标准时需要对比
for (const [id, state] of this.mapData) {
if (!state.active) continue;
if (state.isDirty() && this.isStateEqualToRef(id, state)) {
floors.set(id, this.saveLayerStateFull(state));
} else {
floors.set(id, this.emptySave(state));
}
}
}
return { floors };
}
private saveHighCompression(): IMapStoreSave {
const floors = new Map<string, ILayerStateSave>();
for (const [id, state] of this.mapData) {
if (!state.active) continue;
if (!state.isDirty()) {
floors.set(id, {
background: state.getBackground(),
layers: new Map()
});
continue;
} else {
// 不包含参考标准时仅看 dirty 标记
for (const [id, state] of this.mapData) {
if (!state.active) continue;
if (state.isDirty()) {
floors.set(id, this.saveLayerStateFull(state));
} else {
floors.set(id, this.emptySave(state));
}
}
const refFloor = this.refData?.get(id);
const layersMap = new Map<number, IMapLayerSave>();
for (const layer of state.layerList) {
const refArray = refFloor?.get(layer.zIndex);
const rows = this.diffRows(layer, refArray);
if (rows.size === 0 && refArray) continue; // 与参考完全一致
layersMap.set(layer.zIndex, {
width: layer.width,
height: layer.height,
rows
});
}
floors.set(id, {
background: state.getBackground(),
layers: layersMap
});
}
return { floors };
}
/**
*
*/
private saveHighCompression(): IMapStoreSave {
const floors = new Map<string, ILayerStateSave>();
// 没有参考标准,直接退回低压缩级别
if (!this.refData) return this.saveLowCompression();
for (const [id, state] of this.mapData) {
if (!state.active) continue;
if (state.isDirty()) {
const refFloor = this.refData.get(id);
const layersMap = new Map<number, IMapLayerSave>();
// 对每一个地图的每一行进行遍历,然后仅存储有差别的行
for (const layer of state.layerList) {
const refArray = refFloor?.get(layer.zIndex);
if (refArray) {
const rows = this.diffRows(layer, refArray);
if (rows.size === 0) continue;
layersMap.set(layer.zIndex, {
width: layer.width,
height: layer.height,
rows
});
} else {
layersMap.set(layer.zIndex, this.fullLayer(layer));
}
}
floors.set(id, {
background: state.getBackground(),
layers: layersMap,
triggers: this.getTriggerMap(state)
});
} else {
floors.set(id, this.emptySave(state));
}
}
return { floors };
}
/**
*
* @param save
* @param layer
*/
private loadTriggers(save: ILayerStateSave, layer: IMapLayer) {
const triggers = save.triggers.get(layer.zIndex);
layer.clearTrigger();
if (triggers) {
layer.setTriggerRef(new Map(triggers));
}
}
/**
* NoCompression fullMap
* @param state
*/
private loadNoCompression(state: IMapStoreSave): void {
for (const [id, cur] of this.mapData) {
cur.setActiveStatus(state.floors.has(id));
}
for (const [id, layerStateSave] of state.floors) {
for (const [id, save] of state.floors) {
const cur = this.mapData.get(id);
if (!cur) {
logger.warn(122, id);
continue;
}
cur.setBackground(layerStateSave.background);
cur.setBackground(save.background);
for (const layer of cur.layerList) {
const layerSave = layerStateSave.layers.get(layer.zIndex);
if (!layerSave?.fullMap) continue;
layer.setMapRef(new Uint32Array(layerSave.fullMap));
// 地图
const layerSave = save.layers.get(layer.zIndex);
if (layerSave?.fullMap) {
layer.setMapRef(new Uint32Array(layerSave.fullMap));
}
// 触发器
this.loadTriggers(save, layer);
}
// 需要额外进行判断是否与参考地图相同
if (this.isStateEqualToRef(id, cur)) {
cur.setDirty(false);
} else {
cur.setDirty(true);
}
cur.setDirty(false);
}
}
@ -289,6 +453,7 @@ export class MapStore implements IMapStore {
* LowCompression
* - layers dirty fullMap
* - layers dirty
* @param state
*/
private loadLowCompression(state: IMapStoreSave): void {
if (!this.refData) {
@ -298,7 +463,7 @@ export class MapStore implements IMapStore {
for (const [id, cur] of this.mapData) {
cur.setActiveStatus(state.floors.has(id));
}
for (const [id, layerStateSave] of state.floors) {
for (const [id, save] of state.floors) {
const cur = this.mapData.get(id);
const refFloor = this.refData.get(id);
if (!cur) {
@ -309,21 +474,26 @@ export class MapStore implements IMapStore {
logger.warn(124, id);
continue;
}
cur.setBackground(layerStateSave.background);
cur.setBackground(save.background);
let shouldDirty = false;
for (const layer of cur.layerList) {
const layerSave = layerStateSave.layers.get(layer.zIndex);
// 地图
const layerSave = save.layers.get(layer.zIndex);
if (layerSave?.fullMap) {
layer.setMapRef(layerSave.fullMap);
layer.setMapRef(new Uint32Array(layerSave.fullMap));
shouldDirty = true;
} else {
const refArray = refFloor?.get(layer.zIndex);
const refArray = refFloor.get(layer.zIndex);
if (!refArray) {
logger.warn(124, id);
return;
}
layer.setMapRef(new Uint32Array(refArray));
}
// 触发器
this.loadTriggers(save, layer);
}
cur.setDirty(false);
cur.setDirty(shouldDirty);
}
}
@ -331,6 +501,7 @@ export class MapStore implements IMapStore {
* HighCompression
* - layers dirty
* - layers dirty rows
* @param state
*/
private loadHighCompression(state: IMapStoreSave): void {
if (!this.refData) {
@ -340,7 +511,7 @@ export class MapStore implements IMapStore {
for (const [id, cur] of this.mapData) {
cur.setActiveStatus(state.floors.has(id));
}
for (const [id, layerStateSave] of state.floors) {
for (const [id, save] of state.floors) {
const cur = this.mapData.get(id);
const refFloor = this.refData.get(id);
if (!cur) {
@ -351,34 +522,32 @@ export class MapStore implements IMapStore {
logger.warn(124, id);
continue;
}
cur.setBackground(layerStateSave.background);
let isMapDirty = true;
cur.setBackground(save.background);
let shouldDirty = false;
for (const layer of cur.layerList) {
const refArray = refFloor.get(layer.zIndex);
if (!refArray) {
logger.warn(124, id);
continue;
}
const layerSave = layerStateSave.layers.get(layer.zIndex);
// 地图
const layerSave = save.layers.get(layer.zIndex);
if (!layerSave?.rows || layerSave.rows.size === 0) {
// 图层无变化或非 dirty 楼层,从参考基准恢复
layer.setMapRef(new Uint32Array(refArray));
} else {
// 以参考基准为底,叠加差分行
isMapDirty = false;
const size = layer.width * layer.height;
const buf = new Uint32Array(size);
if (refArray) buf.set(refArray.subarray(0, size));
shouldDirty = true;
const buf = new Uint32Array(refArray);
for (const [rowIdx, rowData] of layerSave.rows) {
buf.set(
rowData.subarray(0, layer.width),
rowIdx * layer.width
);
buf.set(rowData, rowIdx * layer.width);
}
layer.setMapRef(buf);
}
// 触发器
this.loadTriggers(save, layer);
}
cur.setDirty(isMapDirty);
cur.setDirty(shouldDirty);
}
}
@ -402,71 +571,5 @@ export class MapStore implements IMapStore {
}
}
//#region 内部方法
/**
* NoCompression / LowCompression
*/
private saveLayerStateFull(state: LayerState): ILayerStateSave {
const layersMap = new Map<number, IMapLayerSave>();
for (const layer of state.layerList) {
const arr = layer.getMapRef().array;
layersMap.set(layer.zIndex, {
width: layer.width,
height: layer.height,
fullMap: new Uint32Array(arr)
});
}
return { background: state.getBackground(), layers: layersMap };
}
/**
* HighCompression
*/
private diffRows(
layer: IMapLayer,
refArray?: Uint32Array
): Map<number, Uint32Array> {
const rows = new Map<number, Uint32Array>();
const arr = layer.getMapRef().array;
if (refArray) {
for (let row = 0; row < layer.height; row++) {
const start = row * layer.width;
const end = start + layer.width;
const slice = arr.subarray(start, end);
const refSlice = refArray.subarray(start, end);
const same = refSlice.every((v, i) => slice[i] === v);
if (!same) {
rows.set(row, new Uint32Array(slice));
}
}
} else {
for (let row = 0; row < layer.height; row++) {
const start = row * layer.width;
const end = start + layer.width;
rows.set(row, new Uint32Array(arr.subarray(start, end)));
}
}
return rows;
}
/**
* LowCompression
*/
private isStateEqualToRef(id: string, state: LayerState): boolean {
const refFloor = this.refData?.get(id);
if (!refFloor) return false;
for (const layer of state.layerList) {
const refArray = refFloor.get(layer.zIndex);
if (!refArray) return false;
const cur = layer.getMapRef().array;
if (cur.length !== refArray.length) return false;
for (let i = 0; i < cur.length; i++) {
if (cur[i] !== refArray[i]) return false;
}
}
return true;
}
//#endregion
}

View File

@ -7,6 +7,7 @@ import {
IRoleFaceBinder,
ISaveableContent
} from '../common';
import { ITileStore } from '../store';
//#region 静态图层
@ -103,6 +104,33 @@ export interface IMapLayer extends IHookable<
*/
getBlock(x: number, y: number): number;
/**
* 退
* @param x
* @param y
*/
getTriggerType(x: number, y: number): number;
/**
*
* @param type
* @param x
* @param y
*/
setTriggerType(type: number, x: number, y: number): void;
/**
* 退
* @param x
* @param y
*/
revertTrigger(x: number, y: number): void;
/**
*
*/
clearTrigger(): void;
/**
*
* @param array
@ -130,11 +158,31 @@ export interface IMapLayer extends IHookable<
height: number
): Uint32Array;
/**
*
* `MapStore` 使
* `width * height`
*
* @param array
*/
setMapRef(array: Uint32Array): void;
/**
*
*/
getMapRef(): IMapLayerData;
/**
* 使
* @param triggers
*/
setTriggerRef(triggers: Map<number, number>): void;
/**
* 使
*/
getTriggerRef(): ReadonlyMap<number, number>;
/**
*
* @param zIndex
@ -155,15 +203,6 @@ export interface IMapLayer extends IHookable<
* @param y
*/
closeDoor(num: number, x: number, y: number): Promise<void>;
/**
*
* `MapStore` 使
* `width * height`
*
* @param array
*/
setMapRef(array: Uint32Array): void;
}
//#endregion
@ -225,6 +264,8 @@ export interface ILayerStateHooks extends IHookBase {
export interface ILayerState extends IHookable<ILayerStateHooks> {
/** 地图列表 */
readonly layerList: Set<IMapLayer>;
/** 当前楼层共享的图块定义 store */
readonly tileStore: ITileStore;
/** 此楼层是否处于激活状态 */
readonly active: boolean;
/** 此楼层的地图宽度 */
@ -316,7 +357,6 @@ export interface ILayerState extends IHookable<ILayerStateHooks> {
//#region 楼层管理
/** 单个 MapLayer 的存档数据 */
export interface IMapLayerSave {
readonly width: number;
readonly height: number;
@ -332,27 +372,27 @@ export interface IMapLayerSave {
readonly fullMap?: Uint32Array;
}
/** 单个楼层的存档数据 */
export interface ILayerStateSave {
/** 楼层背景 */
readonly background: number;
/** key = zIndexvalue = 对应图层存档数据 */
readonly layers: ReadonlyMap<number, IMapLayerSave>;
/** 静态触发器覆盖映射,仅在存在覆盖时写入 */
readonly triggers: ReadonlyMap<number, ReadonlyMap<number, number>>;
}
/** 整个 MapStore 的存档数据 */
export interface IMapStoreSave {
/** key = 楼层 id只包含 active 的楼层inactive 的楼层不写入,读档时无需处理 */
readonly floors: ReadonlyMap<string, ILayerStateSave>;
}
/** 单段闭区间 [start, end]start 和 end 均为 maps 下标 */
export interface IMapAreaInterval {
/** 区域起始索引,包含 */
readonly start: number;
/** 区域结束索引,包含 */
readonly end: number;
}
/** 一个区域由一个或多个独立区间组成 */
export type MapArea = IMapAreaInterval[];
export interface IMapStore extends ISaveableContent<IMapStoreSave> {
@ -504,21 +544,25 @@ export interface IDynamicLayer extends IHookable<IDynamicLayerHooks> {
* @param y
* @returns
*/
transferToDynamic(x: number, y: number): IDynamicTile;
transferToDynamic(
x: number,
y: number,
keepTrigger?: boolean
): IDynamicTile;
/**
*
* {@link IDynamicLayerHooks.onDeleteTile}
* @param tile
*/
transferToStatic(tile: IDynamicTile): void;
transferToStatic(tile: IDynamicTile, keepTrigger?: boolean): void;
/**
* 0
* @param tile
* @returns
*/
transferToStaticIfSafe(tile: IDynamicTile): boolean;
transferToStaticIfSafe(tile: IDynamicTile, keepTrigger?: boolean): boolean;
/**
* {@link IDynamicLayerHooks.onDeleteTile}
@ -557,6 +601,8 @@ export interface IDynamicLayer extends IHookable<IDynamicLayerHooks> {
export interface IDynamicTile extends IObjectMovable {
/** 当前图块数字 */
readonly num: number;
/** 当前动态图块携带的触发器类型,-1 表示无触发器 */
readonly triggerType: number;
/** 当前图块所属的动态图层 */
readonly layer: IDynamicLayer;
/** 当前动态图块的移动器 */
@ -568,6 +614,12 @@ export interface IDynamicTile extends IObjectMovable {
*/
setFaceDirection(direction: FaceDirection): number;
/**
*
* @param type
*/
setTriggerType(type: number): void;
/**
*
*/

View File

@ -91,10 +91,10 @@ export class CoreState implements ICoreState {
> = new Map();
constructor() {
this.maps = new MapStore();
const tileStore = new TileStore<LegacyTileData>();
tileStore.attachLegacyConverter(new TileLegacyBridge());
this.tileStore = tileStore;
this.maps = new MapStore(tileStore);
this.loadProgress = new LoadProgressTotal();
this.dataLoader = new MotaDataLoader(this.loadProgress);

View File

@ -57,6 +57,15 @@
- `IObjectMover.speed()`:预期频率**中频**。移动中修改移速有一定使用场景,但远不及 `forward`、`step` 等移动接口的频率通常只在特殊演出或逻辑中出现故为中频。典型使用场景NPC 逃离怪物时先定在原地,随后逐渐加速逃跑。
- `IObjectMover.stepFace()`:预期频率**低频**。移动方向与朝向不同的常见场景(后退)已由 `backward` 覆盖,只有极特殊情况才需要此接口(如角色朝向固定但沿垂直方向平移),相当罕见,故为低频。
## 预期体量
本节应当写出预期的代码体量,并分析原因。示例如下:
预期代码体量为 200-300 行。分析如下:
- `IObjectMover` 首先需要完成计划存储与计划的定义,这些接口基本大致就是向数组中添加元素,每个方法内容都不多,整体预计在 100 行左右。
- `IObjectMover` 还需要完成移动流程的编写工作,需要根据每个移动步按照流程执行不同的行为,这一过程较为复杂,预计需要 100-200 行。
# 实现思路
按照下面的格式分条描述实现思路。