refactor: 地图分区与尺寸优化

This commit is contained in:
unanmed 2026-05-06 19:57:11 +08:00
parent a01caba0c8
commit 4c48b63e7e
7 changed files with 441 additions and 85 deletions

View File

@ -0,0 +1,212 @@
# 需求综述
本次改动目标:
1. **自动化分区激活器**:将楼层按游戏进程划分为若干"区域"
到达新区域时自动激活对应楼层、失活旧区域楼层,
从而替代目前繁琐的手动 `setActiveStatus` 调用。
2. **楼层尺寸上移至 `LayerState`**:同一楼层的所有图层尺寸应当
保持一致,因此将尺寸的权威来源从 `MapLayer` 移至 `LayerState`
---
# 实现思路
## 1. 有序地图 id 列表
当前 `IMapStore.maps``ReadonlySet<string>`,无序且随楼层创建
自动填充。区域功能需要以**下标**标识范围,因此需改为有序数组。
修改方案:
- `IMapStore.maps` 类型改为 `ReadonlyArray<string>`
- 新增 `setMapList(maps: string[]): void`,由外部显式指定有序列表
(一般在游戏初始化时调用一次);
- 新增 `useManualOrder(sort: (arr: string[]) => string[]): void`
允许自定义地图列表排序函数。调用时将当前 `maps``slice` 拷贝
传入 `sort`,再对输出做合法性校验:将新旧数组各转为 `Set`
校验 `size` 相等且新集合是旧集合的子集(利用 `Set.prototype.isSubsetOf`
校验通过后用返回值替换内部的 `maps`。这样当地图是动态生成时,
作者依然可以自定义顺序,而不必手动维护全量列表;
- `createLayerState` 不再维护 `maps``maps` 完全由 `setMapList` 管理;
- 若 `createLayerState` 传入的 id 不在 `maps` 中,仍可正常创建,
不影响存档逻辑,但该楼层不参与任何区域判断。
## 2. 区域定义与管理
### 类型定义
```ts
/** 单段闭区间 [start, end]start 和 end 均为 maps 下标 */
export interface IMapAreaInterval {
readonly start: number;
readonly end: number;
}
/** 一个区域由一个或多个独立区间组成 */
export type MapArea = IMapAreaInterval[];
```
### 接口
- `setArea(areas: Set<MapArea>): void`:一次性设置所有区域信息,
覆盖原有区域定义;每个元素代表一个区域,区域可包含多个区间,
使用 `Set<MapArea>` 表示无序区域集合;
- `activeArea(id: string): void`:手动激活指定楼层所在区域的所有楼层。
系统遍历 `areaList`,找到包含该楼层 id 的区域后,对该区域内的所有
楼层调用 `setMapActiveStatus(floor, true)`
- `deactiveArea(id: string): void`:手动取消激活指定楼层所在区域的
所有楼层,逻辑与 `activeArea` 对称;判断时遍历 `areaList`
此操作为低频调用,无需缓存;
## 3. 自动分区激活器
### 接口
- `useAutoActivitor(enable: boolean): void`:是否启用自动激活器。
### 触发接口
需要一个通知接口供玩家相关模块调用:
- `notifyEnterFloor(id: string): void`:玩家进入指定楼层时调用此接口,
通知地图管理器进行自动激活判断。
### 逻辑
`notifyEnterFloor(id)` 的执行流程(每次进入楼层均调用,内部短路):
1. 若自动激活器未启用,直接返回;
2. 若 `isMapActive(id)``true`,直接返回(楼层已激活,无需操作);
3. 遍历 `areaList`,找出包含 `id` 的区域;
4. 若未找到,直接返回(该楼层不在任何区域内);
5. 若 `lastFloorId !== null`,调用 `deactiveArea(lastFloorId)` 失活上一个区域;
6. 调用 `activeArea(id)` 激活新区域,更新 `lastFloorId = id`
### 内部状态
`MapStore` 新增:
- `private areaList: Set<MapArea>`:所有区域定义;
- `private lastFloorId: string | null = null`:上一次触发 `notifyEnterFloor`
的楼层 id用于定位并失活上一个激活区域
- `private autoActivitorEnabled: boolean = false`:自动激活器开关。
## 4. 楼层尺寸上移至 LayerState
### 动机
当前 `MapLayer.width` / `MapLayer.height` 存储在图层中,
但同一楼层的所有图层尺寸必须一致,权威来源应当是 `LayerState`
### 接口变动
**`ILayerState` 新增**
```ts
readonly width: number;
readonly height: number;
```
**`addLayer` 签名调整**
目前 `addLayer(width: number, height: number): IMapLayer`
移除 width/height 参数,改为 `addLayer(): IMapLayer`
使用 `LayerState` 内部存储的尺寸创建图层。
楼层尺寸在 `createLayerState` 创建时指定,`createLayerState` 签名改为:
```ts
createLayerState(id: string, width: number, height: number): ILayerState;
```
运行时仍可通过 `resizeLayer` 修改楼层尺寸,该方法会同步对楼层内所有
图层执行 resize保持尺寸一致。
**`resizeLayer` 签名调整**
当前 `resizeLayer(layer, width, height, keepBlock?)` 只 resize 单个图层,
但既然尺寸是楼层级的,建议改为对该楼层的所有图层同步 resize
```ts
resizeLayer(width: number, height: number, keepBlock?: boolean): void;
```
**`IMapLayer.resize` / `IMapLayer.resize2`**
`IMapLayer` 接口中移除,保留为 `MapLayer` 的内部实现,
仅由 `LayerState.resizeLayer` 调用。
**`IMapLayer.width` / `IMapLayer.height`**
保留在 `IMapLayer` 接口中,供外部通过图层对象直接获取尺寸。
其值始终与所属 `LayerState``width`/`height` 保持一致。
---
# 附加建议结论
1. **`IMapLayer.setMapRef` 可见性**:保留现有设计,偶尔有外部需求。
2. **`active` 状态管理**:不需要单独维护区域激活状态;
`activeArea(id)` / `deactiveArea(id)``setMapActiveStatus`
快捷方式,遍历区域楼层批量调用即可,无需额外的区域状态字段。
3. **`notifyEnterFloor` 返回值**:暂不添加,后续有需求再改进。
---
# 涉及文件
## 需要引用的文件
- `@user/data-base/src/map/types.ts`: 全部现有地图接口
- `@user/data-base/src/map/mapStore.ts`: `MapStore` 实现类
- `@user/data-base/src/map/layerState.ts`: `LayerState` 实现类
- `@user/data-base/src/map/mapLayer.ts`: `MapLayer` 实现类
## 需要修改的文件
### `@user/data-base/src/map/types.ts`
- [x] 新增 `IMapAreaInterval` 接口:区间定义,含 `start`、`end`
- [x] 新增 `MapArea` 类型别名:`IMapAreaInterval[]`,表示一个区域
- [x] 修改 `ILayerState`
- [x] 新增 `readonly width: number``readonly height: number`
- [x] 修改 `addLayer` 签名,移除 `width`/`height` 参数(使用 `LayerState` 自身尺寸)
- [x] 修改 `resizeLayer` 签名:移除 `layer` 参数,改为对整个楼层所有图层同步 resize
- [x] 修改 `IMapLayer`
- [x] 移除 `resize` / `resize2`(改为 `MapLayer` 内部方法)
- [x] 修改 `IMapStore`
- [x] 将 `readonly maps` 类型改为 `ReadonlyArray<string>`
- [x] 修改 `createLayerState` 签名:新增 `width: number`、`height: number` 参数
- [x] 新增 `setMapList(maps: string[]): void`
- [x] 新增 `useManualOrder(sort: (arr: string[]) => string[]): void`
- [x] 新增 `setArea(areas: Set<MapArea>): void`
- [x] 新增 `activeArea(id: string): void`
- [x] 新增 `deactiveArea(id: string): void`
- [x] 新增 `useAutoActivitor(enable: boolean): void`
- [x] 新增 `notifyEnterFloor(id: string): void`
### `@user/data-base/src/map/mapStore.ts`
- [x] 将 `maps: Set<string>` 改为 `maps: string[]`
- [x] 修改 `createLayerState`:添加 `width`/`height` 参数,不再维护 `maps`
- [x] 实现 `setMapList`
- [x] 实现 `useManualOrder`
- [x] 新增 `private areaList: Set<MapArea>`
- [x] 新增 `private lastFloorId: string | null`
- [x] 新增 `private autoActivitorEnabled: boolean`
- [x] 实现 `setArea`、`activeArea`、`deactiveArea`
- [x] 实现 `useAutoActivitor`
- [x] 实现 `notifyEnterFloor`
### `@user/data-base/src/map/layerState.ts`
- [x] 新增 `width: number``height: number` 成员(由构造参数初始化)
- [x] 修改 `addLayer`,移除 `width`/`height` 参数,使用 `this.width`/`this.height`
- [x] 修改 `resizeLayer`,移除 `layer` 参数,改为对所有图层同步 resize
### `@user/data-base/src/map/mapLayer.ts`
- [x] 将 `resize`/`resize2` 改为内部方法(从公共接口移除)
---

View File

@ -242,31 +242,31 @@ setActiveStatus(active: boolean): void;
### `@user/data-base/src/map/types.ts`
- [ ] 新增 `IMapLayerSave` 接口:单个 MapLayer 存档数据格式
- [ ] 新增 `ILayerStateSave` 接口:单个楼层存档数据格式
- [ ] 新增 `IMapStoreSave` 接口MapStore 整体存档数据格式
- [ ] 修改 `ILayerState`:新增 `readonly active: boolean`
- [x] 新增 `IMapLayerSave` 接口:单个 MapLayer 存档数据格式
- [x] 新增 `ILayerStateSave` 接口:单个楼层存档数据格式
- [x] 新增 `IMapStoreSave` 接口MapStore 整体存档数据格式
- [x] 修改 `ILayerState`:新增 `readonly active: boolean`
`setActiveStatus(active: boolean): void`
- [ ] 修改 `IMapLayer`:新增 `setMapRef(array: Uint32Array): void`
- [ ] 新增 `IMapStore` 接口:继承 `ISaveableContent<IMapStoreSave>`
- [x] 修改 `IMapLayer`:新增 `setMapRef(array: Uint32Array): void`
- [x] 新增 `IMapStore` 接口:继承 `ISaveableContent<IMapStoreSave>`
含全部接口(见第 7 节)
### `@user/data-base/src/map/mapLayer.ts`
### `@user/data-base/src/map/layerState.ts`
- [ ] 新增 `active: boolean = false` 成员:楼层激活状态
- [ ] 实现 `setActiveStatus(active: boolean): void`
- [ ] 新增 `private dirty: boolean = false` 成员:楼层级脏标记
- [ ] 修改 `StateMapLayerHook.onUpdateArea`、`onUpdateBlock`、`onResize`
- [x] 新增 `active: boolean = false` 成员:楼层激活状态
- [x] 实现 `setActiveStatus(active: boolean): void`
- [x] 新增 `private dirty: boolean = false` 成员:楼层级脏标记
- [x] 修改 `StateMapLayerHook.onUpdateArea`、`onUpdateBlock`、`onResize`
在转发钩子的同时,将 `state.dirty``true`
- [ ] 新增 `isDirty(): boolean` 方法:返回 `this.dirty`,供 `MapStore` 读取
- [ ] 新增 `setDirty(dirty: boolean): void` 方法:
- [x] 新增 `isDirty(): boolean` 方法:返回 `this.dirty`,供 `MapStore` 读取
- [x] 新增 `setDirty(dirty: boolean): void` 方法:
`MapStore.compareWith` 时根据实际比较结果设置
### `@user/data-base/src/map/mapLayer.ts`
- [ ] 新增 `setMapRef(array: Uint32Array): void` 方法:
- [x] 新增 `setMapRef(array: Uint32Array): void` 方法:
直接替换内部图块数组引用,跳过拷贝,供 `MapStore` 读档时使用。
需确保传入数组长度与 `width × height` 匹配,
并触发必要的钩子通知(不触发 `onResize`,应触发 `onUpdateArea` 通知全区域更新)。
@ -274,21 +274,21 @@ setActiveStatus(active: boolean): void;
### `@user/data-base/src/map/mapStore.ts`(新文件)
- [ ] 实现 `MapStore` 类,实现 `IMapStore`
- [ ] `private mapData: Map<string, LayerState>`:楼层 id 到状态对象的映射
- [ ] `readonly maps: ReadonlySet<string>`:所有楼层 id 的只读集合视图
- [ ] `private refData: Map<string, Map<number, Uint32Array>> | null`:参考基准
- [ ] 实现 `getLayerState`、`getActiveMap`、`createLayerState`
- [ ] 实现 `isMapActive`、`setMapActiveStatus`、`iterateActiveMaps`、`iterateInactiveMaps`、`iterateAllMaps`
- [ ] 实现 `compareWith`
- [ ] 实现 `saveNoCompression`、`saveLowCompression`、`saveHighCompression`
- [ ] 实现 `loadNoCompression`、`loadLowCompression`、`loadHighCompression`
- [ ] 实现 `saveState(compression)``loadState(state, compression)` 分发
- [x] 实现 `MapStore` 类,实现 `IMapStore`
- [x] `private mapData: Map<string, LayerState>`:楼层 id 到状态对象的映射
- [x] `readonly maps: ReadonlySet<string>`:所有楼层 id 的只读集合视图
- [x] `private refData: Map<string, Map<number, Uint32Array>> | null`:参考基准
- [x] 实现 `getLayerState`、`getActiveMap`、`createLayerState`
- [x] 实现 `isMapActive`、`setMapActiveStatus`、`iterateActiveMaps`、`iterateInactiveMaps`、`iterateAllMaps`
- [x] 实现 `compareWith`
- [x] 实现 `saveNoCompression`、`saveLowCompression`、`saveHighCompression`
- [x] 实现 `loadNoCompression`、`loadLowCompression`、`loadHighCompression`
- [x] 实现 `saveState(compression)``loadState(state, compression)` 分发
### `@user/data-base/src/map/index.ts`
- [ ] 补充导出 `mapStore.ts`
- [x] 补充导出 `mapStore.ts`
### `@user/data-base/src/types.ts`
- [ ] 将 `IStateBase.layer` 类型由 `ILayerState` 改为 `IMapStore`
- [x] 将 `IStateBase.layer` 类型由 `ILayerState` 改为 `IMapStore`

View File

@ -18,6 +18,8 @@ export class LayerState
implements ILayerState
{
readonly layerList: Set<IMapLayer> = new Set();
/** 具体 MapLayer 实例列表,供内部 resize 使用 */
private readonly mapLayerList: Set<MapLayer> = new Set();
/** 图层到图层别名映射 */
readonly layerAliasMap: WeakMap<IMapLayer, string> = new WeakMap();
/** 图层别名到图层的映射 */
@ -35,10 +37,18 @@ export class LayerState
/** 楼层级脏标记 */
private dirty: boolean = false;
addLayer(width: number, height: number): IMapLayer {
const array = new Uint32Array(width * height);
const layer = new MapLayer(array, width, height);
constructor(
public width: number,
public height: number
) {
super();
}
addLayer(): IMapLayer {
const array = new Uint32Array(this.width * this.height);
const layer = new MapLayer(array, this.width, this.height);
this.layerList.add(layer);
this.mapLayerList.add(layer);
this.forEachHook(hook => {
hook.onUpdateLayer?.(this.layerList);
});
@ -50,6 +60,7 @@ export class LayerState
removeLayer(layer: IMapLayer): void {
this.layerList.delete(layer);
this.mapLayerList.delete(layer as MapLayer);
const alias = this.layerAliasMap.get(layer);
if (alias) {
const symbol = Symbol.for(alias);
@ -89,15 +100,18 @@ export class LayerState
}
resizeLayer(
layer: IMapLayer,
width: number,
height: number,
keepBlock: boolean = false
): void {
if (keepBlock) {
layer.resize(width, height);
} else {
layer.resize2(width, height);
this.width = width;
this.height = height;
for (const layer of this.mapLayerList) {
if (keepBlock) {
layer.resize(width, height);
} else {
layer.resize2(width, height);
}
}
}

View File

@ -6,21 +6,65 @@ import {
IMapLayer,
IMapLayerSave,
IMapStore,
IMapStoreSave
IMapStoreSave,
MapArea
} from './types';
import { LayerState } from './layerState';
import { uniq } from 'lodash-es';
export class MapStore implements IMapStore {
/** 楼层 id 到状态对象的映射 */
private readonly mapData: Map<string, LayerState> = new Map();
/** 所有楼层 id 的只读集合视图 */
readonly maps: Set<string> = new Set();
/** 所有楼层 id 的有序数组 */
readonly maps: string[] = [];
/** 差分压缩参考基准,首次 compareWith 后设置,之后不再更新 */
private refData: Map<string, Map<number, Uint32Array>> | null = null;
//#region 楼层访问
/** 分区列表 */
private areaList: Set<MapArea> = new Set();
/** 上一次调用 notifyEnterFloor 传入的楼层 id */
private lastFloorId: string | null = null;
/** 自动分区激活器开关 */
private autoActivitorEnabled: boolean = false;
//#region 楼层管理
createLayerState(id: string, width: number, height: number): ILayerState {
if (this.mapData.has(id)) {
logger.warn(121, id);
} else {
this.maps.push(id);
}
const state = new LayerState(width, height);
// 若 refData 已存在,新楼层直接视为全脏
if (this.refData !== null) {
state.setDirty(true);
}
this.mapData.set(id, state);
return state;
}
setMapList(maps: string[]): void {
this.maps.length = 0;
this.maps.push(...uniq(maps));
}
useManualOrder(sort: (arr: string[]) => string[]): void {
const copy = this.maps.slice();
const sorted = sort(copy);
const oldSet = new Set(this.maps);
const newSet = new Set(sorted);
if (oldSet.size !== newSet.size || !newSet.isSubsetOf(oldSet)) {
logger.warn(125);
return;
}
this.maps.length = 0;
this.maps.push(...uniq(sorted));
}
getLayerState(id: string): ILayerState | null {
return this.mapData.get(id) ?? null;
@ -34,20 +78,65 @@ export class MapStore implements IMapStore {
//#endregion
//#region 楼层管理
//#region 分区管理
createLayerState(id: string): ILayerState {
if (this.mapData.has(id)) {
logger.warn(121, id);
setArea(areas: Set<MapArea>): void {
this.areaList = areas;
}
activeArea(id: string): void {
const idx = this.maps.indexOf(id);
if (idx === -1) return;
const area = this.findAreaByIndex(idx);
if (!area) return;
this.setAreaActive(area, true);
}
deactiveArea(id: string): void {
const idx = this.maps.indexOf(id);
if (idx === -1) return;
const area = this.findAreaByIndex(idx);
if (!area) return;
this.setAreaActive(area, false);
}
useAutoActivitor(enable: boolean): void {
this.autoActivitorEnabled = enable;
}
notifyEnterFloor(id: string): void {
if (!this.autoActivitorEnabled) return;
const idx = this.maps.indexOf(id);
if (idx === -1) return;
const area = this.findAreaByIndex(idx);
if (!area) return;
if (this.lastFloorId !== null) {
this.deactiveArea(this.lastFloorId);
}
const state = new LayerState();
// 若 refData 已存在,新楼层直接视为全脏
if (this.refData !== null) {
state.setDirty(true);
this.activeArea(id);
this.lastFloorId = id;
}
private findAreaByIndex(idx: number): MapArea | null {
for (const area of this.areaList) {
for (const interval of area) {
if (idx >= interval.start && idx <= interval.end) {
return area;
}
}
}
return null;
}
private setAreaActive(area: MapArea, active: boolean): void {
for (const interval of area) {
for (let i = interval.start; i <= interval.end; i++) {
const floorId = this.maps[i];
if (floorId !== undefined) {
this.setMapActiveStatus(floorId, active);
}
}
}
this.mapData.set(id, state);
this.maps.add(id);
return state;
}
//#endregion

View File

@ -75,20 +75,6 @@ export interface IMapLayer extends IHookable<
/** 图层纵深 */
readonly zIndex: number;
/**
*
* @param width
* @param height
*/
resize(width: number, height: number): void;
/**
*
* @param width
* @param height
*/
resize2(width: number, height: number): void;
/**
*
* @param block
@ -225,13 +211,15 @@ export interface ILayerState extends IHookable<ILayerStateHooks> {
readonly layerList: Set<IMapLayer>;
/** 此楼层是否处于激活状态 */
readonly active: boolean;
/** 此楼层的地图宽度 */
readonly width: number;
/** 此楼层的地图高度 */
readonly height: number;
/**
*
* @param width
* @param height
* 使
*/
addLayer(width: number, height: number): IMapLayer;
addLayer(): IMapLayer;
/**
*
@ -265,18 +253,12 @@ export interface ILayerState extends IHookable<ILayerStateHooks> {
getLayerAlias(layer: IMapLayer): string | undefined;
/**
*
* @param layer
* @param width
* @param height
*
* @param width
* @param height
* @param keepBlock
*/
resizeLayer(
layer: IMapLayer,
width: number,
height: number,
keepBlock?: boolean
): void;
resizeLayer(width: number, height: number, keepBlock?: boolean): void;
/**
*
@ -336,9 +318,18 @@ export interface IMapStoreSave {
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> {
/** 所有楼层的 id 集合 */
readonly maps: ReadonlySet<string>;
/** 所有楼层的 id 有序数组 */
readonly maps: ReadonlyArray<string>;
/**
* id null
@ -355,8 +346,10 @@ export interface IMapStore extends ISaveableContent<IMapStoreSave> {
/**
* id
* @param id id
* @param width
* @param height
*/
createLayerState(id: string): ILayerState;
createLayerState(id: string, width: number, height: number): ILayerState;
/**
* id id false
@ -391,4 +384,47 @@ export interface IMapStore extends ISaveableContent<IMapStoreSave> {
* @param ref key = id key = zIndexvalue =
*/
compareWith(ref: Map<string, Map<number, Uint32Array>>): void;
/**
*
* @param maps id
*/
setMapList(maps: string[]): void;
/**
* 使 maps
*
* @param sort
*/
useManualOrder(sort: (arr: string[]) => string[]): void;
/**
*
* @param areas
*/
setArea(areas: Set<MapArea>): void;
/**
*
* @param id id
*/
activeArea(id: string): void;
/**
*
* @param id id
*/
deactiveArea(id: string): void;
/**
*
* @param enable
*/
useAutoActivitor(enable: boolean): void;
/**
*
* @param id id
*/
notifyEnterFloor(id: string): void;
}

View File

@ -214,12 +214,16 @@ export class CoreState implements ICoreState {
const reference = new Map<string, Map<number, Uint32Array>>();
for (const id of floors) {
const floor = data[id];
const state = this.maps.createLayerState(id);
const bg = state.addLayer(floor.width, floor.height);
const bg2 = state.addLayer(floor.width, floor.height);
const event = state.addLayer(floor.width, floor.height);
const fg = state.addLayer(floor.width, floor.height);
const fg2 = state.addLayer(floor.width, floor.height);
const state = this.maps.createLayerState(
id,
floor.width,
floor.height
);
const bg = state.addLayer();
const bg2 = state.addLayer();
const event = state.addLayer();
const fg = state.addLayer();
const fg2 = state.addLayer();
bg.setZIndex(BG_ZINDEX);
bg2.setZIndex(BG2_ZINDEX);
event.setZIndex(EVENT_ZINDEX);

View File

@ -182,6 +182,7 @@
"122": "MapStore.loadState: floor '$1' not found in current map data, skipping.",
"123": "MapLayer.setMapRef: array length $1 does not match expected size $2, setMapRef will be ignored.",
"124": "MapStore.loadState: floor '$1' or its layer(s) not found in current reference data, skipping.",
"125": "Expected sorted floor id array has a same floor id set, but an array with a different floor id set is returned.",
"1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency."
}
}