From ec59a44efc2fdaf84794f7bc9fde95095a0e0aff Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Tue, 21 Apr 2026 21:01:53 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20CoreState=20=E4=B8=80=E9=83=A8?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=9E=8B=E5=A3=B0=E6=98=8E=E6=8C=AA=E5=88=B0?= =?UTF-8?q?=20data-base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/common/face.ts | 0 packages-user/data-base/src/common/index.ts | 1 + packages-user/data-base/src/common/types.ts | 36 ++++++++++++ packages-user/data-base/src/index.ts | 2 + .../src/map/index.ts | 0 .../src/map/layerState.ts | 0 .../src/map/mapLayer.ts | 0 .../src/map/types.ts | 0 packages-user/data-base/src/types.ts | 50 +++++++++++++++++ packages-user/data-state/src/common/index.ts | 3 - packages-user/data-state/src/common/types.ts | 40 -------------- packages-user/data-state/src/common/utils.ts | 6 -- packages-user/data-state/src/core.ts | 9 ++- packages-user/data-state/src/index.ts | 5 +- packages-user/data-state/src/types.ts | 55 +------------------ 15 files changed, 98 insertions(+), 109 deletions(-) rename packages-user/{data-state => data-base}/src/common/face.ts (100%) rename packages-user/{data-state => data-base}/src/map/index.ts (100%) rename packages-user/{data-state => data-base}/src/map/layerState.ts (100%) rename packages-user/{data-state => data-base}/src/map/mapLayer.ts (100%) rename packages-user/{data-state => data-base}/src/map/types.ts (100%) create mode 100644 packages-user/data-base/src/types.ts delete mode 100644 packages-user/data-state/src/common/index.ts delete mode 100644 packages-user/data-state/src/common/types.ts delete mode 100644 packages-user/data-state/src/common/utils.ts diff --git a/packages-user/data-state/src/common/face.ts b/packages-user/data-base/src/common/face.ts similarity index 100% rename from packages-user/data-state/src/common/face.ts rename to packages-user/data-base/src/common/face.ts diff --git a/packages-user/data-base/src/common/index.ts b/packages-user/data-base/src/common/index.ts index 6d5a6ef..5fcbbcb 100644 --- a/packages-user/data-base/src/common/index.ts +++ b/packages-user/data-base/src/common/index.ts @@ -1,2 +1,3 @@ +export * from './face'; export * from './types'; export * from './utils'; diff --git a/packages-user/data-base/src/common/types.ts b/packages-user/data-base/src/common/types.ts index 196a18f..ef726bb 100644 --- a/packages-user/data-base/src/common/types.ts +++ b/packages-user/data-base/src/common/types.ts @@ -16,3 +16,39 @@ export interface IFaceData { /** 图块朝向 */ readonly face: FaceDirection; } + +export interface IRoleFaceBinder { + /** + * 给指定的图块分配朝向绑定 + * @param identifier 图块数字 + * @param main 主图块朝向,一般是朝下 + */ + malloc(identifier: number, main: FaceDirection): void; + + /** + * 将一个图块与另一个图块绑定朝向,需要注意要先调用 {@link malloc} 分配朝向信息 + * @param identifier 当前图块数字 + * @param main 主图块数字,即当前图块与目标图块属于主图块的另一个朝向 + * @param face 当前图块的朝向方向 + */ + bind(identifier: number, main: number, face: FaceDirection): void; + + /** + * 获取一个图块指定朝向的图块数字 + * @param identifier 图块数字,可以是任意朝向的图块数字 + * @param face 要获取的朝向 + */ + getFaceOf(identifier: number, face: FaceDirection): IFaceData | null; + + /** + * 获取指定图块数字是哪个朝向 + * @param identifier 图块数字 + */ + getFaceDirection(identifier: number): FaceDirection | undefined; + + /** + * 获取指定图块数字绑定至的主朝向 + * @param identifier 图块数字,可以是任意朝向的图块数字 + */ + getMainFace(identifier: number): IFaceData | null; +} diff --git a/packages-user/data-base/src/index.ts b/packages-user/data-base/src/index.ts index 09b3249..0be7276 100644 --- a/packages-user/data-base/src/index.ts +++ b/packages-user/data-base/src/index.ts @@ -3,5 +3,7 @@ export * from './enemy'; export * from './flag'; export * from './hero'; export * from './load'; +export * from './map'; export * from './game'; +export * from './types'; diff --git a/packages-user/data-state/src/map/index.ts b/packages-user/data-base/src/map/index.ts similarity index 100% rename from packages-user/data-state/src/map/index.ts rename to packages-user/data-base/src/map/index.ts diff --git a/packages-user/data-state/src/map/layerState.ts b/packages-user/data-base/src/map/layerState.ts similarity index 100% rename from packages-user/data-state/src/map/layerState.ts rename to packages-user/data-base/src/map/layerState.ts diff --git a/packages-user/data-state/src/map/mapLayer.ts b/packages-user/data-base/src/map/mapLayer.ts similarity index 100% rename from packages-user/data-state/src/map/mapLayer.ts rename to packages-user/data-base/src/map/mapLayer.ts diff --git a/packages-user/data-state/src/map/types.ts b/packages-user/data-base/src/map/types.ts similarity index 100% rename from packages-user/data-state/src/map/types.ts rename to packages-user/data-base/src/map/types.ts diff --git a/packages-user/data-base/src/types.ts b/packages-user/data-base/src/types.ts new file mode 100644 index 0000000..c4d18de --- /dev/null +++ b/packages-user/data-base/src/types.ts @@ -0,0 +1,50 @@ +import { IMotaDataLoader } from './load'; +import { ILoadProgressTotal } from '@motajs/loader'; +import { IHeroFollower, IHeroState } from './hero'; +import { IEnemyContext, IEnemyManager } from './enemy'; +import { IFlagSystem } from './flag'; +import { IRoleFaceBinder } from './common'; +import { ILayerState } from './map'; + +export interface IStateSaveData { + /** 跟随者列表 */ + readonly followers: readonly IHeroFollower[]; +} + +export interface IStateBase { + /** 朝向绑定 */ + readonly roleFace: IRoleFaceBinder; + /** id 到图块数字的映射 */ + readonly idNumberMap: Map; + /** 图块数字到 id 的映射 */ + readonly numberIdMap: Map; + + /** 加载进度对象 */ + readonly loadProgress: ILoadProgressTotal; + /** 数据端加载对象 */ + readonly dataLoader: IMotaDataLoader; + + /** 地图状态 */ + readonly layer: ILayerState; + /** 勇士状态 */ + readonly hero: IHeroState; + + /** 怪物管理器 */ + readonly enemyManager: IEnemyManager; + /** 怪物上下文 */ + readonly enemyContext: IEnemyContext; + + /** Flag 系统 */ + readonly flags: IFlagSystem; + + /** + * 保存当前状态 + */ + saveState(): IStateSaveData; + + /** + * 加载状态 + * @param state 状态对象 + */ + loadState(state: IStateSaveData): void; +} diff --git a/packages-user/data-state/src/common/index.ts b/packages-user/data-state/src/common/index.ts deleted file mode 100644 index 5fcbbcb..0000000 --- a/packages-user/data-state/src/common/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './face'; -export * from './types'; -export * from './utils'; diff --git a/packages-user/data-state/src/common/types.ts b/packages-user/data-state/src/common/types.ts deleted file mode 100644 index b256db0..0000000 --- a/packages-user/data-state/src/common/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FaceDirection, type IFaceData } from '@user/data-base'; - -export { FaceDirection }; -export type { IFaceData } from '@user/data-base'; - -export interface IRoleFaceBinder { - /** - * 给指定的图块分配朝向绑定 - * @param identifier 图块数字 - * @param main 主图块朝向,一般是朝下 - */ - malloc(identifier: number, main: FaceDirection): void; - - /** - * 将一个图块与另一个图块绑定朝向,需要注意要先调用 {@link malloc} 分配朝向信息 - * @param identifier 当前图块数字 - * @param main 主图块数字,即当前图块与目标图块属于主图块的另一个朝向 - * @param face 当前图块的朝向方向 - */ - bind(identifier: number, main: number, face: FaceDirection): void; - - /** - * 获取一个图块指定朝向的图块数字 - * @param identifier 图块数字,可以是任意朝向的图块数字 - * @param face 要获取的朝向 - */ - getFaceOf(identifier: number, face: FaceDirection): IFaceData | null; - - /** - * 获取指定图块数字是哪个朝向 - * @param identifier 图块数字 - */ - getFaceDirection(identifier: number): FaceDirection | undefined; - - /** - * 获取指定图块数字绑定至的主朝向 - * @param identifier 图块数字,可以是任意朝向的图块数字 - */ - getMainFace(identifier: number): IFaceData | null; -} diff --git a/packages-user/data-state/src/common/utils.ts b/packages-user/data-state/src/common/utils.ts deleted file mode 100644 index 2e7a185..0000000 --- a/packages-user/data-state/src/common/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - degradeFace, - fromDirectionString, - getFaceMovement, - nextFaceDirection -} from '@user/data-base'; diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index 4f1b491..3466e78 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -1,6 +1,4 @@ import { ICoreState, IStateSaveData } from './types'; -import { ILayerState, LayerState } from './map'; -import { FaceDirection, IRoleFaceBinder, RoleFaceBinder } from './common'; import { DamageSystem, EnemyContext, @@ -16,7 +14,12 @@ import { FlagSystem, IMotaDataLoader, MotaDataLoader, - loading + loading, + IRoleFaceBinder, + ILayerState, + LayerState, + RoleFaceBinder, + FaceDirection } from '@user/data-base'; import { IEnemyAttr } from './enemy/types'; import { diff --git a/packages-user/data-state/src/index.ts b/packages-user/data-state/src/index.ts index aa2296a..bc01750 100644 --- a/packages-user/data-state/src/index.ts +++ b/packages-user/data-state/src/index.ts @@ -1,6 +1,5 @@ -import { loading } from '@user/data-base'; +import { FaceDirection, loading } from '@user/data-base'; import { isNil } from 'lodash-es'; -import { FaceDirection } from './common'; import { ICoreState } from './types'; import { TILE_HEIGHT, TILE_WIDTH } from './shared'; import { state } from './ins'; @@ -62,11 +61,9 @@ export function create() { }); } -export * from './common'; export * from './enemy'; export * from './hero'; export * from './legacy'; -export * from './map'; export * from './core'; export * from './ins'; diff --git a/packages-user/data-state/src/types.ts b/packages-user/data-state/src/types.ts index 754ca9d..41365d7 100644 --- a/packages-user/data-state/src/types.ts +++ b/packages-user/data-state/src/types.ts @@ -1,61 +1,10 @@ -import { ILayerState } from './map'; -import { IRoleFaceBinder } from './common'; -import { - IEnemyContext, - IEnemyManager, - IHeroFollower, - IHeroState, - IMotaDataLoader -} from '@user/data-base'; +import { IHeroFollower, IStateBase } from '@user/data-base'; import { IEnemyAttr } from './enemy/types'; import { IHeroAttr } from './hero'; -import { IFlagSystem } from '../../data-base/src/flag/types'; -import { ILoadProgressTotal } from '@motajs/loader'; - -export interface IGameDataState { - /** 怪物管理器 */ - readonly enemyManager: IEnemyManager; -} export interface IStateSaveData { /** 跟随者列表 */ readonly followers: readonly IHeroFollower[]; } -export interface ICoreState { - /** 朝向绑定 */ - readonly roleFace: IRoleFaceBinder; - /** id 到图块数字的映射 */ - readonly idNumberMap: Map; - /** 图块数字到 id 的映射 */ - readonly numberIdMap: Map; - - /** 加载进度对象 */ - readonly loadProgress: ILoadProgressTotal; - /** 数据端加载对象 */ - readonly dataLoader: IMotaDataLoader; - - /** 地图状态 */ - readonly layer: ILayerState; - /** 勇士状态 */ - readonly hero: IHeroState; - - /** 怪物管理器 */ - readonly enemyManager: IEnemyManager; - /** 怪物上下文 */ - readonly enemyContext: IEnemyContext; - - /** Flag 系统 */ - readonly flags: IFlagSystem; - - /** - * 保存状态 - */ - saveState(): IStateSaveData; - - /** - * 加载状态 - * @param data 状态对象 - */ - loadState(data: IStateSaveData): void; -} +export interface ICoreState extends IStateBase {} From 8c1becc9f12c539e61a11a91f517d4c91be4ab0d Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 24 Apr 2026 17:29:13 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=E5=AD=98=E6=A1=A3=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20&=20=E5=8B=87=E5=A3=AB=E5=B1=9E=E6=80=A7=E5=AD=98?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- dev.md | 22 +- docs/dev/hero-modifier-save.md | 110 +++++++++ docs/dev/save-system.md | 215 +++++++++++++++++ packages-user/data-base/src/common/types.ts | 28 +++ packages-user/data-base/src/flag/system.ts | 2 +- packages-user/data-base/src/flag/types.ts | 15 +- packages-user/data-base/src/hero/attribute.ts | 22 +- packages-user/data-base/src/hero/state.ts | 79 +++++++ packages-user/data-base/src/hero/types.ts | 69 +++++- packages-user/data-base/src/types.ts | 25 +- packages-user/data-state/src/core.ts | 87 ++++++- packages-user/data-state/src/enemy/index.ts | 1 + packages-user/data-state/src/save/index.ts | 1 + packages-user/data-state/src/save/system.ts | 222 ++++++++++++++++++ packages-user/data-state/src/save/types.ts | 142 +++++++++++ packages-user/data-state/src/types.ts | 43 +++- packages/common/src/logger.json | 5 + packages/common/src/utils/types.ts | 7 + prompt.md | 71 +++++- 19 files changed, 1112 insertions(+), 54 deletions(-) create mode 100644 docs/dev/hero-modifier-save.md create mode 100644 docs/dev/save-system.md create mode 100644 packages-user/data-state/src/save/index.ts create mode 100644 packages-user/data-state/src/save/system.ts create mode 100644 packages-user/data-state/src/save/types.ts diff --git a/dev.md b/dev.md index d739fd1..50b1504 100644 --- a/dev.md +++ b/dev.md @@ -28,12 +28,16 @@ - `pnpm build:packages`: 构建所有 `packages` 文件夹下的内容,使用库模式。 - `pnpm build:game`: 构建为可以直接部署的构建包。 - `pnpm build:lib`: 构建所有 `packages` `packages-user` 文件夹下的内容,使用库模式。 +- `pnpm type`: 对仓库执行类型检查 +- `pnpm check:circular`: 对仓库执行循环引用检查 ## 开发原则 -- 模块无副作用原则: - - 所有模块不包含副作用内容,全部由函数、类、常量的声明组成,不出现导出的变量声明、代码执行内容,允许但不建议编写类的静态块。 +- 模块原则: + - 无副作用原则:所有模块不包含副作用内容,全部由函数、类、常量的声明组成,不出现导出的变量声明、代码执行内容,允许但不建议编写类的静态块。 - 如果需要模块初始化,编写一个 `createXxx` 函数,然后在 `index.ts` 中整合,再逐级向上传递,直至遇到包含 `create` 函数的 `index.ts`,所有初始化将会统一在顶层模块中执行。 + - 不允许一个文件导出不属于当前 `monorepo` 或当前文件夹的内容。 + - 不允许出现循环引用,如果不得不进行循环引用,应当首先考虑接口设计是否有问题。 - 命名规则: - 变量、成员、一般常量、方法、函数使用小驼峰。 - 类、接口、类型别名、命名空间、泛型、枚举、组件使用大驼峰。 @@ -47,7 +51,7 @@ - 长文件可使用 `#region` 分段,可以写上 `#endretion` 允许折叠。 - TODO 使用 `// TODO:` 或 `// todo:` 格式。 - 单行注释的双斜杠与注释内容之间添加一个空格,多行注释只允许出现 `jsDoc` 注释,如果需要多行非 `jsDoc` 注释,使用多个单行注释。 - - 注释进行合理换行,考虑到中文字符较宽,建议 40-60 个字符进行换行。不允许在句中换行,必须在标点符号后换行。 + - 注释进行合理换行,考虑到中文字符较宽,建议 40-60 个字符进行换行。不允许在句中换行,必须在标点符号后换行。参数注释换行后保持对齐。 - 单行注释结尾不添加句号,对于多行长注释,可以在结尾添加句号。 - 类型: - 不允许出现非必要的 `any` 类型。 @@ -62,3 +66,15 @@ - 尽量不使用 `?.` 运算符,一般建议仅在副作用函数调用(如 `this.obj?.func()`,`this.obj.func?.()`),或对象 `Required` 化(如 `{ value: obj?.value ?? 0 }`)中使用 `?.` 运算符。 - 只进行必要的非空判断,不必要的非空判断直接使用非空断言 `!` 实现。 - 除非参数要求传入函数等情况,不建议在函数内写任何局部函数。 + - 语句尽量不换行,除非必要,尤其注意三元运算符与 `private readonly` 类成员。 + +## 双端分离 + +样板将渲染端与数据端彻底分离,数据端可以单独在 `node` 环境运行,可以直接用于录像验证。渲染端仅负责向数据端发送消息,不负责任何逻辑运算。 + +- `@user/data-base`: 数据端的系统层,负责核心系统。 +- `@user/data-state`: 数据端的实现层,依靠系统层实现完整的游戏实例。 +- `@user/client-base`: 渲染端的系统层,负责渲染端的核心系统。 +- `@user/client-modules`: 渲染端的实现层,依靠系统层实现客户端的渲染与用户交互。 + +数据端允许运行渲染端代码,但需要使用全局接口 `Mota.r(() => {})` 包裹。除非必要,否则不建议在数据端调用渲染端代码。 diff --git a/docs/dev/hero-modifier-save.md b/docs/dev/hero-modifier-save.md new file mode 100644 index 0000000..c060db7 --- /dev/null +++ b/docs/dev/hero-modifier-save.md @@ -0,0 +1,110 @@ +# 需求综述 + +当前勇士属性修饰器 `IHeroModifier` 是非注册式的,无法直接存档。 +目标是将其改为注册式,每种修饰器在使用前需先在属性对象上注册, +并实现 `ISaveableContent` 接口以支持存档读档。 + +注册接口签名:`registerModifier(identifier: string, cons: () => IHeroModifier): void` + +# 实现思路 + +## 1. 修改 `IHeroModifier` 接口 + +- 将 `identifier` 改为 `type`:一类修饰器可能被添加多次, + `type` 比 `identifier` 更准确地表达"修饰器类型"的含义。 +- 新增泛型参数 `S = unknown` 作为存档类型,让接口继承 `ISaveableContent`: + `IHeroModifier` +- 大多数修饰器的可变状态只有 `value`,因此 `BaseHeroModifier` 将 `S` 默认为 `V`。 + 若修饰器需要特殊存储结构,可以不继承 `BaseHeroModifier`,自行编写实现类。 + +## 2. 新增 `IModifierStateSave` 类型 + +`IModifierStateSave` 记录单条修饰器的存档信息: + +```ts +interface IModifierStateSave { + readonly name: PropertyKey; // 属性名,如 'atk' + readonly type: string; // 修饰器类型(与注册时的 key 对应) + readonly state: unknown; // 修饰器 saveState 结果 +} +``` + +## 3. 修改 `IHeroStateSave`,新增 `modifiers` 字段 + +`attribute` 字段维持原来的 `THero` 只保存基础属性值, +修饰器列表单独作为顶层字段: + +```ts +readonly modifiers: readonly IModifierStateSave[]; +``` + +## 4. 修改 `IHeroAttribute`,新增 `iterateModifiers` 方法 + +`HeroAttribute` 不再继承 `ISaveableContent`,也不负责存读档, +保留现有的 `toStructured(): THero`。 +新增 `iterateModifiers` 方法,供 `HeroState` 在存档时遍历所有已挂载的修饰器: + +```ts +iterateModifiers(): Iterable<[keyof THero, IHeroModifier]>; +``` + +## 5. 修改 `BaseHeroModifier`,新增 `S` 泛型并实现 `ISaveableContent` + +- 改为 `BaseHeroModifier` 隐式以 `S = V` 实现 `ISaveableContent` +- 将 `abstract readonly identifier: string` 改为 `abstract readonly type: string` +- `saveState()` 直接返回 `this.currentValue` +- `loadState(state)` 调用 `this.setValue(state)` 恢复值 + +## 6. 修改 `HeroState` + +修饰器注册表移至 `HeroState`: + +- 新增 `private readonly registry: Map IHeroModifier>` 存储工厂函数 +- 实现 `registerModifier(type, cons)`:向 `registry` 写入工厂, + 并同步注册到接口签名中 +- 实现 `createModifier(type)`:从 `registry` 取出对应工厂, + 调用工厂函数创建并返回修饰器实例,类型为 `IHeroModifier` +- 实现 `createAndInsertModifier(type, name)`: + 调用 `createModifier` 创建实例后,自动调用 `this.attribute.addModifier(name, modifier)` 插入属性对象, + 返回该修饰器实例,类型与 `createModifier` 一致 +- 修改 `saveState()`: + 1. `attribute` 字段调用 `this.attribute.toStructured()` 获取基础属性值(与现在一致) + 2. 遍历 `this.attribute.iterateModifiers()`,对每个修饰器调用 + `modifier.saveState(compression)` 并拼装 `IModifierStateSave[]`, + 写入 `modifiers` 字段 +- 修改 `loadState()`: + 1. 创建新的 `HeroAttribute` 实例(使用 `state.attribute` 还原基础属性值,与现在一致) + 2. 遍历 `state.modifiers`,通过 `registry.get(type)` 创建修饰器实例, + 调用 `modifier.loadState(state)` 恢复值,再 `addModifier(name, modifier)` 挂载 + +# 涉及文件 + +## 需要修改的文件 + +### `@user/data-base/hero/types.ts` + +- [ ] 修改 `IHeroModifier` 接口: + 改为 `IHeroModifier`,`identifier` 改名为 `type`, + 继承 `ISaveableContent` +- [ ] 新增 `IModifierStateSave` 接口:单条修饰器的存档格式 +- [ ] 修改 `IHeroStateSave`:新增 `readonly modifiers: readonly IModifierStateSave[]` 字段 +- [ ] 修改 `IReadonlyHeroAttribute`:新增 `iterateModifiers()` 方法签名 +- [ ] 修改 `IHeroState`:新增以下方法签名 - `registerModifier(type: string, cons: () => IHeroModifier): void` - `createModifier(type: string): IHeroModifier` - `createAndInsertModifier(type: string, name: K): IHeroModifier` + +### `@user/data-base/hero/attribute.ts` + +- [ ] 修改 `BaseHeroModifier`: + 将 `abstract readonly identifier` 改为 `abstract readonly type`; + 实现 `saveState` / `loadState` +- [ ] 修改 `HeroAttribute`:实现 `iterateModifiers()` + +### `@user/data-base/hero/state.ts` + +- [ ] 修改 `HeroState`:新增 `private readonly registry: Map IHeroModifier>` 成员 +- [ ] 实现 `HeroState.registerModifier`:将工厂函数写入 `registry` +- [ ] 实现 `HeroState.createModifier`:从 `registry` 取出工厂并调用,返回新实例; + 若 `type` 未注册则抛出错误 +- [ ] 实现 `HeroState.createAndInsertModifier`:调用 `createModifier` 后, + 再调用 `this.attribute.addModifier(name, modifier)`,返回同一实例 +- [ ] 修改 `HeroState.saveState`:遍历 `iterateModifiers()` 写入 `modifiers` 字段 +- [ ] 修改 `HeroState.loadState`:遍历 `state.modifiers` 重建修饰器并挂载 diff --git a/docs/dev/save-system.md b/docs/dev/save-system.md new file mode 100644 index 0000000..21e9e13 --- /dev/null +++ b/docs/dev/save-system.md @@ -0,0 +1,215 @@ +# 需求综述 + +实现游戏引擎存档系统(`SaveSystem`)及全局事务(`GlobalTransaction`)两个类的完整逻辑。 +存档系统分为两部分: + +- **存档内容**:按 slot id 保存/读取游戏当前状态(`Map>`), + 使用 Dexie 将数据存入 IndexedDB。 +- **全局存储**:跨存档的 key-value 存储,用于存放存档 meta data、全局设置等。 + 支持事务处理,事务中的写入操作在发生错误时全部回滚。 + +此外,系统提供基于内存的自动存档,并支持 undo/redo 操作。 +自动存档不主动写入 IndexedDB,只有显式调用 `saveAutosaveToDB` 时才将 undo +栈顶存档写入数据库。 +存档操作使用 `performance` 接口监控耗时,超过配置阈值时通过 logger 发出警告。 + +--- + +# 实现思路 + +## 1. Dexie 数据库 Schema 设计 + +`SaveSystem` 构造函数接收数据库名称 `name`,在其中创建如下两张表: + +| 表名 | 主键 | 说明 | +| -------- | --------------- | --------------------------------------------------------- | +| `saves` | `id`(number) | 按 slot id 存储存档数据;`id = -1` 固定用于持久化自动存档 | +| `global` | `key`(string) | 全局 key-value 存储 | + +- `saves` 表中每条记录结构为 `{ id: number, compression: SaveCompression, data: Map }`, + 与 `ISaveRead` 对应,直接利用 IndexedDB 的结构化克隆存储,不进行 JSON 序列化。 +- `global` 表中每条记录结构为 `{ key: string, value: unknown }`, + 同样直接存储,不进行 JSON 序列化。 + +## 2. 内部状态 + +`SaveSystem` 需要维护如下私有成员: + +- `private undoStack: ISaveRead[]`:undo 栈,存储 `ISaveRead` 快照 +- `private redoStack: ISaveRead[]`:redo 栈,存储 `ISaveRead` 快照 +- `private stackSize: number`:undo/redo 栈最大容量(默认 `20`) +- `private autosaveLevel: SaveCompression`:默认 `SaveCompression.LowCompression` +- `private commonSaveLevel: SaveCompression`:默认 `SaveCompression.HighCompressoin` +- `private saveTimeTolerance: number`:默认 `100`(ms) +- `private autosaveTimeTolerance: number`:默认 `50`(ms) + +## 3. ISaveRead 数据结构 + +栈与数据库读写均使用新接口 `ISaveRead`: + +```ts +interface ISaveRead { + readonly compression: SaveCompression; + readonly data: Map; +} +``` + +- `compression`:存档时使用的压缩等级,读档时传回给 `loadState`,使接收方能够正确解压。 +- `data`:key 到每个可存档对象序列化数据的 Map,key 与 `ISaveableContent` 注册时的 id 对应。 + +内存栈和数据库均直接存储 `ISaveRead`,不需要引入辅助包装层。 +存档系统本身不负责将数据写回游戏对象,调用方拿到 `ISaveRead` 后自行遍历并调用 +各可存档对象的 `loadState(data, compression)` 完成状态恢复。 + +## 4. 各方法实现 + +### `config(config)` + +将 config 各字段写入对应私有成员,使用传入的值覆盖默认值。 + +### `setAutosaveStackSize(size)` + +将 `stackSize` 更新为 `size`。如果当前 undo 栈超过新的 `size`, +从栈底移除多余条目(保留最新的);redo 栈同理。 + +### `autosave(state)` + +1. 遍历 `state`,对每个 `(key, content)` 调用 + `content.saveState(this.autosaveLevel)` 获取序列化数据, + 汇总为 `Map`,构建 `ISaveRead { compression: autosaveLevel, data }` 并压入 `undoStack`; +2. **清空 `redoStack`**(执行新的自动存档后无法再 redo); +3. 若 `undoStack.length > stackSize`,从栈底(`[0]`)移除多余条目。 + +> IndexedDB 支持结构化克隆,Map、Set、TypedArray 等均可直接存储,无需 JSON 序列化。 + +### `undoAutosave(current)` + +1. 若 `undoStack` 为空,返回 `null`; +2. 将 `current` 序列化为 `ISaveRead { compression: autosaveLevel, data: Map }`, + 压入 `redoStack`;检查 `redoStack.length > stackSize`,超长时从栈底移除多余条目; +3. 弹出 `undoStack` 栈顶(`pop()`),返回弹出的 `ISaveRead`; +4. 调用方拿到返回的 `ISaveRead` 后,自行遍历并对各游戏对象调用 `loadState` 完成恢复。 + +### `redoAutosave(current)` + +与 `undoAutosave` 逻辑对称:将 `current` 序列化压入 `undoStack`, +弹出 `redoStack` 栈顶并返回,调用方自行恢复状态。 + +### `getUndoStack()` / `getRedoStack()` + +使用 `slice()` 返回栈数组的浅拷贝快照,防止外部意外修改栈结构。 + +### `saveAutosaveToDB()` + +1. 若 `undoStack` 为空,直接返回(无需写入); +2. 记录 `t0 = performance.now()`; +3. 取 `undoStack` 栈顶(`ISaveRead`),将其连同 `id = -1` 一起写入 `saves` 表; +4. 记录 `t1 = performance.now()`;若 `t1 - t0 > autosaveTimeTolerance`, + 调用 `logger.warn(115, (t1 - t0).toFixed(0), this.autosaveTimeTolerance.toString())`。 + +### `save(id, state)` + +1. 记录 `t0 = performance.now()`; +2. 遍历 `state`,对每个 `(key, content)` 调用 + `content.saveState(this.commonSaveLevel)` 汇总为 `Map`, + 构建 `{ id, compression: commonSaveLevel, data }` 写入 `saves` 表; +3. 将 `id` 写入全局存储 `'lastSlot'` 键(用于 `getLastSlot()`); +4. 记录 `t1 = performance.now()`;若 `t1 - t0 > saveTimeTolerance`, + 调用 `logger.warn(114, (t1 - t0).toFixed(0), this.saveTimeTolerance.toString())`。 + +### `load(id)` + +1. 从 Dexie `saves` 表查询 `id`; +2. 若不存在返回 `null`; +3. 将读取到的记录中的 `compression` 和 `data` 字段组装成 `ISaveRead` 返回。 + 调用方自行遍历 `data` 并对各游戏对象调用 `loadState` 完成恢复。 + +> `load(-1)` 可用于读取持久化的自动存档。 + +### `deleteSave(id)` + +直接从 Dexie `saves` 表删除对应记录。 + +### `getLastSlot()` + +从全局存储读取 `'lastSlot'` 键对应的值并返回;若不存在则返回 `0`。 + +### `getGlobal(key)` / `setGlobal(key, value)` + +- `getGlobal`:从 Dexie `global` 表读取 `key` 对应的 `value` 字段并返回,类型断言为 `T`; +- `setGlobal`:将 `{ key, value }` 直接写入 Dexie `global` 表,无需 JSON 序列化。 + +### `startGlobalTransaction(handle)` + +使用 `Dexie.transaction('rw', this.db.table('global'), ...)` 包裹 `handle` 调用, +传入 `GlobalTransaction` 实例,出错时自动回滚。 + +### `GlobalTransaction.get(key)` / `GlobalTransaction.set(key, value)` + +在事务上下文中直接读写 `table`(即全局 `global` 表的引用),无需 JSON 序列化。 + +## 5. logger.json 新增 warn 代码 + +当前最大 warn 代码为 `113`,新增如下两条(写入 `packages/common/src/logger.json` +的 `warn` 对象,置于 `113` 之后): + +| 代码 | 消息 | +| ----- | ----------------------------------------------------------------------------------------------------- | +| `114` | `Save operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level.` | +| `115` | `Autosave operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level.` | + +--- + +# 涉及文件 + +## 需要引用的文件 + +- `dexie`:Dexie / Table 类型,用于创建和操作 IndexedDB 数据库 +- `@motajs/common`:`logger`,用于输出存档耗时超限警告 +- `@user/data-base`:`ISaveableContent`、`SaveCompression`,存档接口与压缩枚举 +- `./types`:`ISaveRead`、`IGlobalTrasaction`、`ISaveSystem`、`ISaveSystemConfig` + +## 需要修改的文件 + +### `packages/common/src/logger.json` + +- [x] 在 `warn` 对象中新增代码 `114`:普通存档耗时超限警告 +- [x] 在 `warn` 对象中新增代码 `115`:自动存档耗时超限警告 + +### `packages-user/data-state/src/save/system.ts` + +- [x] 新增 `private undoStack: ISaveRead[]` 成员:存储 undo 历史快照 +- [x] 新增 `private redoStack: ISaveRead[]` 成员:存储 redo 历史快照 +- [x] 新增 `private stackSize: number` 成员:undo/redo 栈容量上限,默认 `20` +- [x] 新增 `private autosaveLevel: SaveCompression` 成员: + 默认 `SaveCompression.LowCompression` +- [x] 新增 `private commonSaveLevel: SaveCompression` 成员: + 默认 `SaveCompression.HighCompressoin` +- [x] 新增 `private saveTimeTolerance: number` 成员:普通存档耗时阈值,默认 `100` +- [x] 新增 `private autosaveTimeTolerance: number` 成员:自动存档耗时阈值,默认 `50` +- [x] 编写构造函数:初始化 Dexie 实例,定义 `saves`(主键 `id`)和 + `global`(主键 `key`)两张表的 schema +- [x] 编写 `config` 方法:将配置项写入私有成员 +- [x] 编写 `setAutosaveStackSize` 方法:更新 stackSize,修剪超长的 undo/redo 栈 +- [x] 编写 `autosave` 方法:遍历 state 序列化为 `ISaveRead` 压入 undoStack, + 清空 redoStack,超长时修剪栈底 +- [x] 编写 `undoAutosave` 方法:将 current 序列化为 `ISaveRead` 压入 redoStack, + 弹出 undoStack 栈顶返回 `ISaveRead`(或 null) +- [x] 编写 `redoAutosave` 方法:与 undoAutosave 对称 +- [x] 编写 `getUndoStack` / `getRedoStack` 方法:使用 `slice()` 返回栈的浅拷贝快照 +- [x] 编写 `saveAutosaveToDB` 方法:取 undoStack 栈顶以 `id = -1` 写入 `saves` 表, + performance 监控,超限时调用 `logger.warn(115, ...)` +- [x] 编写 `save` 方法:遍历 state 序列化为 `ISaveRead` 写入 `saves` 表, + 更新 `lastSlot`,performance 监控,超限时调用 `logger.warn(114, ...)` +- [x] 编写 `load` 方法:从 Dexie `saves` 表读取记录组装为 `ISaveRead` 返回 + (不存在返回 null);`load(-1)` 可读取持久化的自动存档 +- [x] 编写 `deleteSave` 方法:从 Dexie `saves` 表删除指定记录 +- [x] 编写 `getLastSlot` 方法:从全局存储读取 `'lastSlot'`,不存在时返回 `0` +- [x] 编写 `getGlobal` / `setGlobal` 方法:直接读写 Dexie `global` 表,不进行 JSON 序列化 +- [x] 编写 `startGlobalTransaction` 方法: + 使用 Dexie 事务包裹 handle,传入 GlobalTransaction 实例 + +### `packages-user/data-state/src/save/system.ts`(GlobalTransaction 部分) + +- [x] 编写 `GlobalTransaction.get` 方法:在事务上下文中直接读取 table 中 key 对应的 value +- [x] 编写 `GlobalTransaction.set` 方法:在事务上下文中直接写入 key-value,不进行 JSON 序列化 diff --git a/packages-user/data-base/src/common/types.ts b/packages-user/data-base/src/common/types.ts index ef726bb..fbe4822 100644 --- a/packages-user/data-base/src/common/types.ts +++ b/packages-user/data-base/src/common/types.ts @@ -52,3 +52,31 @@ export interface IRoleFaceBinder { */ getMainFace(identifier: number): IFaceData | null; } + +//#region 功能接口 + +export const enum SaveCompression { + /** 不进行压缩,仅提取必要数据 */ + NoCompression, + /** 进行小幅度压缩,以性能为主要考虑目标 */ + LowCompression, + /** 进行大幅度压缩,以体积为主要考虑目标 */ + HighCompression +} + +export interface ISaveableContent { + /** + * 保存对象状态,返回的对象应该经过深拷贝(即 `structuedClone`) + * @param compression 压缩级别 + */ + saveState(compression: SaveCompression): T; + + /** + * 读取对象状态 + * @param state 状态对象 + * @param compression 压缩级别 + */ + loadState(state: T, compression: SaveCompression): void; +} + +//#endregion diff --git a/packages-user/data-base/src/flag/system.ts b/packages-user/data-base/src/flag/system.ts index 7063691..ed98767 100644 --- a/packages-user/data-base/src/flag/system.ts +++ b/packages-user/data-base/src/flag/system.ts @@ -2,7 +2,7 @@ import { IFlagCommonField, IFlagSystem, IFlagSystemSave } from './types'; import { FlagCommonField } from './field'; export class FlagSystem implements IFlagSystem { - private readonly fieldMap: Map> = + private readonly fieldMap: Map> = new Map(); occupied(field: PropertyKey): boolean { diff --git a/packages-user/data-base/src/flag/types.ts b/packages-user/data-base/src/flag/types.ts index 7f77a28..a7cc428 100644 --- a/packages-user/data-base/src/flag/types.ts +++ b/packages-user/data-base/src/flag/types.ts @@ -1,5 +1,7 @@ //#region 字段 +import { ISaveableContent } from '../common'; + export interface IFlagCommonField { /** 此字段所处的 Flag 系统 */ readonly system: IFlagSystem; @@ -42,7 +44,7 @@ export interface IFlagSystemSave { readonly fields: Map; } -export interface IFlagSystem { +export interface IFlagSystem extends ISaveableContent { /** * 判断一个字段是否被占用,类似于旧样板的 `core.hasFlag` * @param field 字段名称 @@ -131,17 +133,6 @@ export interface IFlagSystem { * @param defaultValue 字段默认值 */ getFieldValueDefaults(field: PropertyKey, defaultValue: T): T; - - /** - * 对 Flag 系统进行结构化复制,形成存档对象 - */ - saveState(): IFlagSystemSave; - - /** - * 从指定存档对象读取信息 - * @param state 存档对象 - */ - loadState(state: IFlagSystemSave): void; } //#endregion diff --git a/packages-user/data-base/src/hero/attribute.ts b/packages-user/data-base/src/hero/attribute.ts index e58c2ad..6a1f6f0 100644 --- a/packages-user/data-base/src/hero/attribute.ts +++ b/packages-user/data-base/src/hero/attribute.ts @@ -1,7 +1,9 @@ import { logger } from '@motajs/common'; +import { SaveCompression } from '../common'; import { IHeroAttribute, IHeroModifier } from './types'; -export abstract class BaseHeroModifier implements IHeroModifier { +export abstract class BaseHeroModifier implements IHeroModifier { + abstract readonly type: string; abstract readonly priority: number; owner: IHeroAttribute | null = null; @@ -25,6 +27,14 @@ export abstract class BaseHeroModifier implements IHeroModifier { this.owner = attribute; } + saveState(_compression: SaveCompression): V { + return this.currentValue; + } + + loadState(state: V, _compression: SaveCompression): void { + this.setValue(state); + } + abstract modify(value: T, baseValue: T, name: string): T; abstract clone(): IHeroModifier; @@ -161,4 +171,14 @@ export class HeroAttribute implements IHeroAttribute { getModifiableClone(): IHeroAttribute { return this.clone(); } + + toStructured(): THero { + return structuredClone(this.attribute); + } + + *iterateModifiers(): IterableIterator<[keyof THero, IHeroModifier]> { + for (const [modifier, name] of this.modifierName) { + yield [name, modifier]; + } + } } diff --git a/packages-user/data-base/src/hero/state.ts b/packages-user/data-base/src/hero/state.ts index a202710..37a690d 100644 --- a/packages-user/data-base/src/hero/state.ts +++ b/packages-user/data-base/src/hero/state.ts @@ -1,11 +1,20 @@ +import { HeroAttribute } from './attribute'; import { IHeroAttribute, + IHeroModifier, IHeroMover, IHeroState, + IHeroStateSave, + IModifierStateSave, IReadonlyHeroAttribute } from './types'; +import { SaveCompression } from '../common'; +import { logger } from '@motajs/common'; export class HeroState implements IHeroState { + /** 修饰器工厂函数注册表 */ + private readonly registry: Map IHeroModifier> = new Map(); + constructor( public mover: IHeroMover, public attribute: IHeroAttribute @@ -34,4 +43,74 @@ export class HeroState implements IHeroState { getIsolatedAttribute(): IHeroAttribute { return this.attribute.getModifiableClone(); } + + registerModifier(type: string, cons: () => IHeroModifier): void { + this.registry.set(type, cons); + } + + createModifier(type: string): IHeroModifier | null { + const cons = this.registry.get(type); + if (!cons) { + logger.warn(116, type); + return null; + } else { + return cons() as IHeroModifier; + } + } + + createAndInsertModifier( + type: string, + name: K + ): IHeroModifier | null { + const modifier = this.createModifier(type); + if (!modifier) return null; + this.attribute.addModifier(name, modifier); + return modifier; + } + + saveState(compression: SaveCompression): IHeroStateSave { + const modifiers: IModifierStateSave[] = []; + for (const [name, modifier] of this.attribute.iterateModifiers()) { + modifiers.push({ + name, + type: modifier.type, + state: modifier.saveState(compression) + }); + } + return { + attribute: this.attribute.toStructured(), + locator: { + x: this.mover.x, + y: this.mover.y, + direction: this.mover.direction + }, + followers: structuredClone(this.mover.followers), + modifiers + }; + } + + loadState( + state: IHeroStateSave, + compression: SaveCompression + ): void { + const newAttribute = new HeroAttribute(state.attribute); + for (const save of state.modifiers) { + const cons = this.registry.get(save.type); + if (!cons) continue; + const modifier = cons(); + modifier.loadState(save.state as never, compression); + newAttribute.addModifier( + save.name as keyof THero, + modifier as unknown as IHeroModifier + ); + } + this.attribute = newAttribute; + this.mover.setPosition(state.locator.x, state.locator.y); + this.mover.turn(state.locator.direction); + this.mover.removeAllFollowers(); + state.followers.forEach(follower => { + this.mover.addFollower(follower.num, follower.identifier); + this.mover.setFollowerAlpha(follower.identifier, follower.alpha); + }); + } } diff --git a/packages-user/data-base/src/hero/types.ts b/packages-user/data-base/src/hero/types.ts index c87daaa..a35902b 100644 --- a/packages-user/data-base/src/hero/types.ts +++ b/packages-user/data-base/src/hero/types.ts @@ -1,9 +1,15 @@ -import { IHookBase, IHookable } from '@motajs/common'; -import { FaceDirection } from '@user/data-state'; +import { IFacedTileLocator, IHookBase, IHookable } from '@motajs/common'; +import { FaceDirection, ISaveableContent } from '../common'; //#region 勇士属性 -export interface IHeroModifier { +export interface IHeroModifier< + T = unknown, + V = unknown, + S = unknown +> extends ISaveableContent { + /** 修饰器类型 */ + readonly type: string; /** 修饰器优先级 */ readonly priority: number; /** 修饰器参数值 */ @@ -42,6 +48,15 @@ export interface IHeroModifier { clone(): IHeroModifier; } +export interface IModifierStateSave { + /** 属性名称 */ + readonly name: PropertyKey; + /** 修饰器类型 */ + readonly type: string; + /** 修饰器存档数据 */ + readonly state: unknown; +} + export interface IReadonlyHeroAttribute { /** * 获取勇士的基础属性,即未经过任何 Buff 或装备等加成的属性 @@ -77,6 +92,16 @@ export interface IReadonlyHeroAttribute { * 获取此勇士属性对象的可修改副本 */ getModifiableClone(): IHeroAttribute; + + /** + * 转换为结构化对象 + */ + toStructured(): THero; + + /** + * 遍历所有已挂载的属性修饰器 + */ + iterateModifiers(): Iterable<[PropertyKey, IHeroModifier]>; } export interface IHeroAttribute extends IReadonlyHeroAttribute { @@ -332,7 +357,20 @@ export interface IHeroMover extends IHookable { //#region 勇士状态 -export interface IHeroState { +export interface IHeroStateSave { + /** 勇士属性状态 */ + readonly attribute: THero; + /** 勇士当前位置 */ + readonly locator: IFacedTileLocator; + /** 勇士当前的跟随者 */ + readonly followers: readonly Readonly[]; + /** 勇士属性修饰器状态 */ + readonly modifiers: readonly IModifierStateSave[]; +} + +export interface IHeroState extends ISaveableContent< + IHeroStateSave +> { /** 勇士移动对象 */ readonly mover: IHeroMover; /** 勇士属性对象 */ @@ -369,6 +407,29 @@ export interface IHeroState { * 获取独立勇士属性对象,修改此对象不会影响勇士本身的属性 */ getIsolatedAttribute(): IHeroAttribute; + + /** + * 注册一个修饰器工厂函数 + * @param type 修饰器类型 + * @param cons 工厂函数 + */ + registerModifier(type: string, cons: () => IHeroModifier): void; + + /** + * 创建指定类型的修饰器实例 + * @param type 修饰器类型 + */ + createModifier(type: string): IHeroModifier | null; + + /** + * 创建指定类型的修饰器实例并插入至勇士属性对象 + * @param type 修饰器类型 + * @param name 属性名称 + */ + createAndInsertModifier( + type: string, + name: K + ): IHeroModifier | null; } //#endregion diff --git a/packages-user/data-base/src/types.ts b/packages-user/data-base/src/types.ts index c4d18de..bd52a39 100644 --- a/packages-user/data-base/src/types.ts +++ b/packages-user/data-base/src/types.ts @@ -1,9 +1,7 @@ -import { IMotaDataLoader } from './load'; -import { ILoadProgressTotal } from '@motajs/loader'; import { IHeroFollower, IHeroState } from './hero'; -import { IEnemyContext, IEnemyManager } from './enemy'; +import { IEnemyManager } from './enemy'; import { IFlagSystem } from './flag'; -import { IRoleFaceBinder } from './common'; +import { IRoleFaceBinder, ISaveableContent } from './common'; import { ILayerState } from './map'; export interface IStateSaveData { @@ -19,11 +17,6 @@ export interface IStateBase { /** 图块数字到 id 的映射 */ readonly numberIdMap: Map; - /** 加载进度对象 */ - readonly loadProgress: ILoadProgressTotal; - /** 数据端加载对象 */ - readonly dataLoader: IMotaDataLoader; - /** 地图状态 */ readonly layer: ILayerState; /** 勇士状态 */ @@ -31,20 +24,20 @@ export interface IStateBase { /** 怪物管理器 */ readonly enemyManager: IEnemyManager; - /** 怪物上下文 */ - readonly enemyContext: IEnemyContext; /** Flag 系统 */ readonly flags: IFlagSystem; /** - * 保存当前状态 + * 添加可存档对象,添加后系统将会自动在存档时将对象存储 + * @param id 可存档对象的 id + * @param content 可存档对象 */ - saveState(): IStateSaveData; + addSaveableContent(id: string, content: ISaveableContent): void; /** - * 加载状态 - * @param state 状态对象 + * 根据 id 获取对应的可存档对象 + * @param id 可存档对象的 id */ - loadState(state: IStateSaveData): void; + getSaveableContent(id: string): ISaveableContent | null; } diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index 3466e78..47135f2 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -1,4 +1,4 @@ -import { ICoreState, IStateSaveData } from './types'; +import { ICoreState, ISaveableExecutor } from './types'; import { DamageSystem, EnemyContext, @@ -19,9 +19,12 @@ import { ILayerState, LayerState, RoleFaceBinder, - FaceDirection + FaceDirection, + ISaveableContent, + IStateSaveData, + SaveCompression } from '@user/data-base'; -import { IEnemyAttr } from './enemy/types'; +import { IEnemyAttr } from './enemy'; import { CommonAuraConverter, EnemyLegacyBridge, @@ -36,23 +39,38 @@ import { HERO_DEFAULT_ATTRIBUTE, TILE_HEIGHT, TILE_WIDTH } from './shared'; import { IHeroAttr } from './hero'; import { ILoadProgressTotal, LoadProgressTotal } from '@motajs/loader'; import { isNil } from 'lodash-es'; +import { logger } from '@motajs/common'; +import { ISaveSystem } from './save'; +import { SaveSystem } from './save/system'; export class CoreState implements ICoreState { + // 全局内容 readonly roleFace: IRoleFaceBinder; readonly idNumberMap: Map; readonly numberIdMap: Map; - readonly loadProgress: ILoadProgressTotal; - readonly dataLoader: IMotaDataLoader; - + // 可存档内容 readonly layer: ILayerState; readonly hero: IHeroState; - readonly enemyManager: IEnemyManager; - readonly enemyContext: IEnemyContext; - readonly flags: IFlagSystem; + // 状态内容 + readonly loadProgress: ILoadProgressTotal; + readonly dataLoader: IMotaDataLoader; + readonly enemyContext: IEnemyContext; + readonly saveSystem: ISaveSystem; + + /** 可存档对象映射 */ + private readonly saveables: Map> = new Map(); + /** 所有已添加的可存档对象 */ + private readonly addedSaveables: Set> = new Set(); + /** 已绑定的存档执行器 */ + private readonly executors: Map< + ISaveableContent, + ISaveableExecutor + > = new Map(); + constructor() { this.layer = new LayerState(); this.roleFace = new RoleFaceBinder(); @@ -101,6 +119,25 @@ export class CoreState implements ICoreState { //#endregion + //#region 存档系统 + + this.saveSystem = new SaveSystem(); + // 配置存档系统,一般情况下不建议动,除非你知道你在干什么 + this.saveSystem.config({ + autosaveLevel: SaveCompression.LowCompression, + commonSaveLevel: SaveCompression.HighCompression, + autosaveTimeTolerance: 50, + saveTimeTolerance: 100, + autosaveStackSize: 20 + }); + + // 初始化存档数据库,不要动 + loading.once('coreInit', () => { + this.saveSystem.init(`@game/${core.firstData.name}`); + }); + + //#endregion + //#region 其他初始化 this.flags = new FlagSystem(); @@ -110,6 +147,8 @@ export class CoreState implements ICoreState { this.initEnemyManager(enemys_fcae963b_31c9_42b4_b48c_bb48d09f3f80); }); + this.addSaveableContent('flags', this.flags); + //#endregion } @@ -144,6 +183,36 @@ export class CoreState implements ICoreState { } } + addSaveableContent(id: string, content: ISaveableContent): void { + if (this.saveables.has(id)) { + logger.warn(112, id); + return; + } + this.saveables.set(id, content); + } + + getSaveableContent(id: string): ISaveableContent | null { + const content = this.saveables.get(id); + return (content as ISaveableContent) ?? null; + } + + bindSaveableExecuter( + content: ISaveableContent | string, + executor: ISaveableExecutor + ): void { + if (typeof content === 'string') { + const saveable = this.saveables.get(content); + if (!saveable) return; + this.executors.set(saveable, executor); + } else { + if (!this.addedSaveables.has(content)) { + logger.warn(113); + return; + } + this.executors.set(content, executor); + } + } + saveState(): IStateSaveData { return structuredClone({ followers: this.hero.mover.followers diff --git a/packages-user/data-state/src/enemy/index.ts b/packages-user/data-state/src/enemy/index.ts index c923f4c..5998ef6 100644 --- a/packages-user/data-state/src/enemy/index.ts +++ b/packages-user/data-state/src/enemy/index.ts @@ -5,3 +5,4 @@ export * from './final'; export * from './legacy'; export * from './mapDamage'; export * from './special'; +export * from './types'; diff --git a/packages-user/data-state/src/save/index.ts b/packages-user/data-state/src/save/index.ts new file mode 100644 index 0000000..fcb073f --- /dev/null +++ b/packages-user/data-state/src/save/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/packages-user/data-state/src/save/system.ts b/packages-user/data-state/src/save/system.ts new file mode 100644 index 0000000..8b5fecd --- /dev/null +++ b/packages-user/data-state/src/save/system.ts @@ -0,0 +1,222 @@ +import Dexie, { Table } from 'dexie'; +import { logger } from '@motajs/common'; +import { + IGlobalTrasaction, + ISaveRead, + ISaveSystem, + ISaveSystemConfig +} from './types'; +import { ISaveableContent, SaveCompression } from '@user/data-base'; +import { isNil } from 'lodash-es'; + +interface ISaveRecord { + /** 存档 id */ + readonly id: number; + /** 存档压缩级别 */ + readonly compression: SaveCompression; + /** 存档内容 */ + readonly data: Map; +} + +interface IGlobalRecord { + /** 全局存储的键名 */ + readonly key: string; + /** 全局存储的内容 */ + readonly value: unknown; +} + +export class GlobalTransaction implements IGlobalTrasaction { + constructor(readonly table: Table) {} + + async get(key: string): Promise { + const record = await this.table.get(key); + return record!.value as T; + } + + async set(key: string, value: unknown): Promise { + await this.table.put({ key, value }); + } +} + +export class SaveSystem implements ISaveSystem { + db!: Dexie; + + /** 当前的撤回栈 */ + private readonly undoStack: ISaveRead[] = []; + /** 当前的重做栈 */ + private readonly redoStack: ISaveRead[] = []; + + /** 撤回栈与重做栈的最大长度 */ + private stackSize: number = 20; + /** 自动存档压缩级别 */ + private autosaveLevel: SaveCompression = SaveCompression.LowCompression; + /** 普通存档压缩级别 */ + private commonSaveLevel: SaveCompression = SaveCompression.HighCompression; + /** 普通存档容忍时长 */ + private saveTimeTolerance: number = 100; + /** 自动存档容忍时长 */ + private autosaveTimeTolerance: number = 50; + + init(name: string) { + this.db = new Dexie(name); + this.db.version(1).stores({ + saves: 'id', + global: 'key' + }); + } + + config(config: Readonly>): void { + if (!isNil(config.autosaveLevel)) { + this.autosaveLevel = config.autosaveLevel; + } + if (!isNil(config.commonSaveLevel)) { + this.commonSaveLevel = config.commonSaveLevel; + } + if (!isNil(config.saveTimeTolerance)) { + this.saveTimeTolerance = config.saveTimeTolerance; + } + if (!isNil(config.autosaveTimeTolerance)) { + this.autosaveTimeTolerance = config.autosaveTimeTolerance; + } + if (!isNil(config.autosaveStackSize)) { + const size = config.autosaveStackSize; + this.stackSize = size; + if (this.undoStack.length > size) { + this.undoStack.splice(0, this.undoStack.length - size); + } + if (this.redoStack.length > size) { + this.redoStack.splice(0, this.redoStack.length - size); + } + } + } + + undoAutosave( + current: Map> + ): ISaveRead | null { + if (this.undoStack.length === 0) return null; + const data = new Map(); + for (const [key, content] of current) { + data.set(key, content.saveState(this.autosaveLevel)); + } + this.redoStack.push({ compression: this.autosaveLevel, data }); + if (this.redoStack.length > this.stackSize) { + this.redoStack.splice(0, this.redoStack.length - this.stackSize); + } + return this.undoStack.pop()!; + } + + redoAutosave( + current: Map> + ): ISaveRead | null { + if (this.redoStack.length === 0) return null; + const data = new Map(); + for (const [key, content] of current) { + data.set(key, content.saveState(this.autosaveLevel)); + } + this.undoStack.push({ compression: this.autosaveLevel, data }); + if (this.undoStack.length > this.stackSize) { + this.undoStack.splice(0, this.undoStack.length - this.stackSize); + } + return this.redoStack.pop()!; + } + + getUndoStack(): ISaveRead[] { + return this.undoStack.slice(); + } + + getRedoStack(): ISaveRead[] { + return this.redoStack.slice(); + } + + autosave(state: Map>): void { + const data = new Map(); + for (const [key, content] of state) { + data.set(key, content.saveState(this.autosaveLevel)); + } + this.undoStack.push({ compression: this.autosaveLevel, data }); + this.redoStack.length = 0; + if (this.undoStack.length > this.stackSize) { + this.undoStack.splice(0, this.undoStack.length - this.stackSize); + } + } + + async saveAutosaveToDB(): Promise { + if (this.undoStack.length === 0) return; + const t0 = performance.now(); + const top = this.undoStack[this.undoStack.length - 1]; + const table = this.db.table('saves'); + await table.put({ + id: -1, + compression: top.compression, + data: top.data + }); + const t1 = performance.now(); + if (t1 - t0 > this.autosaveTimeTolerance) { + logger.warn( + 115, + (t1 - t0).toFixed(0), + this.autosaveTimeTolerance.toString() + ); + } + } + + async save( + id: number, + state: Map> + ): Promise { + const t0 = performance.now(); + const data = new Map(); + for (const [key, content] of state) { + data.set(key, content.saveState(this.commonSaveLevel)); + } + const table = this.db.table('saves'); + await table.put({ id, compression: this.commonSaveLevel, data }); + await this.setGlobal('lastSlot', id); + const t1 = performance.now(); + if (t1 - t0 > this.saveTimeTolerance) { + logger.warn( + 114, + (t1 - t0).toFixed(0), + this.saveTimeTolerance.toString() + ); + } + } + + async load(id: number): Promise { + const table = this.db.table('saves'); + const record = await table.get(id); + if (record === undefined) return null; + return { compression: record.compression, data: record.data }; + } + + async deleteSave(id: number): Promise { + const table = this.db.table('saves'); + await table.delete(id); + } + + async getLastSlot(): Promise { + const value = await this.getGlobal('lastSlot'); + return value ?? 0; + } + + async getGlobal(key: string): Promise { + const table = this.db.table('global'); + const record = await table.get(key); + if (!record) return null; + else return record.value as T; + } + + async setGlobal(key: string, value: unknown): Promise { + const table = this.db.table('global'); + await table.put({ key, value }); + } + + async startGlobalTransaction( + handle: (transaction: IGlobalTrasaction) => PromiseLike + ): Promise { + const globalTable = this.db.table('global'); + return this.db.transaction('rw', globalTable, () => { + return handle(new GlobalTransaction(globalTable)); + }); + } +} diff --git a/packages-user/data-state/src/save/types.ts b/packages-user/data-state/src/save/types.ts new file mode 100644 index 0000000..034db4b --- /dev/null +++ b/packages-user/data-state/src/save/types.ts @@ -0,0 +1,142 @@ +import { ISaveableContent, SaveCompression } from '@user/data-base'; +import { Dexie, Table } from 'dexie'; + +export interface IGlobalTrasaction { + /** 全局存储对应的表 */ + readonly table: Table; + + /** + * 获取指定键值对应的数据 + * @param key 全局键值 + */ + get(key: string): Promise; + + /** + * 设置指定键值存储的数据 + * @param key 全局键值 + * @param value 存储数据 + */ + set(key: string, value: unknown): Promise; +} + +export interface ISaveSystemConfig { + /** 自动存档使用的压缩等级 */ + autosaveLevel: SaveCompression; + /** 普通存档使用的压缩等级 */ + commonSaveLevel: SaveCompression; + /** 可容忍的最大存档耗时,超过此值会抛出警告 */ + saveTimeTolerance: number; + /** 可容忍的最大自动存档耗时,超过此值会抛出警告 */ + autosaveTimeTolerance: number; + /** 自动存档栈最大大小 */ + autosaveStackSize: number; +} + +export interface ISaveRead { + /** 该存档的压缩等级 */ + readonly compression: SaveCompression; + /** 该存档的数据 */ + readonly data: Map; +} + +export interface ISaveSystem { + /** Dexie 数据库 */ + readonly db: Dexie; + + /** + * 初始化存档数据库 + * @param name 数据库名称 + */ + init(name: string): void; + + /** + * 配置此存档系统 + * @param config 配置对象 + */ + config(config: Readonly>): void; + + /** + * 从 `undo` 栈读取上一个自动存档,然后将当前状态加入 `redo` 栈 + * @param current 当前游戏状态,需要加入 `redo` 栈 + */ + undoAutosave( + current: Map> + ): ISaveRead | null; + + /** + * 从 `redo` 栈读取自动存档,并将当前状态加入 `undo` 栈 + * @param current 当前游戏状态,需要加入 `undo` 栈 + */ + redoAutosave( + current: Map> + ): ISaveRead | null; + + /** + * 获取当前的撤回栈 + */ + getUndoStack(): ISaveRead[]; + + /** + * 获取当前的重做栈 + */ + getRedoStack(): ISaveRead[]; + + /** + * 进行自动存档,加入撤回栈 + * @param state 状态对象 + */ + autosave(state: Map>): void; + + /** + * 将 `undo` 栈顶的自动存档真正存入 `IndexedDB` + */ + saveAutosaveToDB(): Promise; + + /** + * 将状态对象存入存档 + * @param id 存档 id,用于建立存档索引及查询 + * @param state 状态对象 + */ + save( + id: number, + state: Map> + ): Promise; + + /** + * 根据 id 读取指定存档 + * @param id 存档 id + */ + load(id: number): Promise; + + /** + * 删除指定存档 + * @param id 存档 id + */ + deleteSave(id: number): Promise; + + /** + * 获取最后一次存档的存档栏位 + */ + getLastSlot(): Promise; + + /** + * 获取指定键值对应的全局存储 + * @param key 全局键值 + */ + getGlobal(key: string): Promise; + + /** + * 设置指定键值对应的全局存储 + * @param key 全局键值 + * @param value 存储数据 + */ + setGlobal(key: string, value: unknown): Promise; + + /** + * 进行全局存储的事务处理,适用于多内容查询与设置,当出现错误时其中的任何写入操作都不会真正存储 + * @param handle 事务处理函数 + */ + startGlobalTransaction( + handle: (transaction: IGlobalTrasaction) => PromiseLike + ): Promise; +} diff --git a/packages-user/data-state/src/types.ts b/packages-user/data-state/src/types.ts index 41365d7..318816e 100644 --- a/packages-user/data-state/src/types.ts +++ b/packages-user/data-state/src/types.ts @@ -1,10 +1,41 @@ -import { IHeroFollower, IStateBase } from '@user/data-base'; -import { IEnemyAttr } from './enemy/types'; +import { + IEnemyContext, + IMotaDataLoader, + ISaveableContent, + IStateBase +} from '@user/data-base'; +import { IEnemyAttr } from './enemy'; import { IHeroAttr } from './hero'; +import { ILoadProgressTotal } from '@motajs/loader'; +import { ISaveSystem } from './save'; -export interface IStateSaveData { - /** 跟随者列表 */ - readonly followers: readonly IHeroFollower[]; +export interface ISaveableExecutor { + /** + * 当数据读取后执行的函数,允许对其他存档对象进行读取 + * @param data 对应可存档对象的存档数据 + * @param state 当前的基础状态 + */ + afterLoad(data: T, state: IStateBase): void; } -export interface ICoreState extends IStateBase {} +export interface ICoreState extends IStateBase { + /** 加载进度对象 */ + readonly loadProgress: ILoadProgressTotal; + /** 数据端加载对象 */ + readonly dataLoader: IMotaDataLoader; + /** 怪物上下文 */ + readonly enemyContext: IEnemyContext; + + /** 存档系统 */ + readonly saveSystem: ISaveSystem; + + /** + * 将某个存档执行器绑定至指定的可存档对象,一个可存档对象只能绑定一个执行器,但一个执行器可以绑定多个可存档对象 + * @param content 可存档对象或其注册 id + * @param executor 可存档对象对应的执行器 + */ + bindSaveableExecuter( + content: ISaveableContent | string, + executor: ISaveableExecutor + ): void; +} diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 74e1cc6..3f850b1 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -168,6 +168,11 @@ "109": "Expected a different object reference returned, but got a same reference at modifier '$1' for property '$2'.", "110": "Expected a hero attribute binding before executing any enemy context calculation.", "111": "Cannot add value to flag field '$1', since the current value is not a number.", + "112": "Cannot add saveable content since id '$1' has already been occupied.", + "113": "Cannot bind saveable executor since target saveable content has not been added.", + "114": "Save operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level.", + "115": "Autosave operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level.", + "116": "Cannot construct modifier of type '$1' since no registry for it.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency." } } diff --git a/packages/common/src/utils/types.ts b/packages/common/src/utils/types.ts index 39784d6..8334175 100644 --- a/packages/common/src/utils/types.ts +++ b/packages/common/src/utils/types.ts @@ -1,3 +1,5 @@ +import { FaceDirection } from '@user/data-base'; + export interface ISearchable4Dir { /** 获取上侧元素 */ up(): ISearchable4Dir | null; @@ -142,6 +144,11 @@ export interface ITileLocator { y: number; } +export interface IFacedTileLocator extends ITileLocator { + /** 图块朝向 */ + direction: FaceDirection; +} + export const enum InternalDirectionGroup { /** 上下左右四方向 */ Dir4, diff --git a/prompt.md b/prompt.md index c4da0e7..e683987 100644 --- a/prompt.md +++ b/prompt.md @@ -5,8 +5,8 @@ 1. 将我已经写好的代码视为绝对正确,除非我**明确允许**,否则**不允许任何修改**,哪怕因为接口变化或其他原因导致其中出现类型错误。如果你认为我的代码中存在逻辑错误,应当在对话中提出,而不是直接修改。 2. 我做的任何代码修改都是有原因的,如果我在两次对话期间新增、删除或修改了部分代码,不要将其恢复。 3. 时刻以目的进行驱动,想明白我为什么要这么设计接口,这个接口设计的目的是什么,而不是简单地以实现接口为目标。 -4. 如果思考或实现时有任何问题,应该立刻提问,而不是按照自己的想法去写。 -5. 如果我的目标是重构某个接口,按照我说的方式进行重构,如果是彻底性的重构(接口完全没有重合),则按照正常的方式进行实现,旧代码仅做逻辑与思路上的参考;如果是结构性的重构(接口基本一致,但有一些细节上的差距),则应该将旧代码搬到新的接口上,然后进行一些微调,**不要**擅自新增任何参数、任何新的方法或接口,**不要**仅仅通过新增一个兼容层兼容旧代码来实现重构。 +4. 如果思考或实现时有任何问题,比如我的描述比较模糊,或接口描述比较模糊,或某些地方会产生歧义等等,应该立刻向我提问,而不是按照自己的想法去写。 +5. 如果我的目标是重构某个接口,按照我说的方式进行重构。如果是彻底性的重构(接口完全没有重合),则按照正常的方式进行实现,旧代码仅做逻辑与思路上的参考;如果是结构性的重构(接口基本一致,但有一些细节上的差距),则应该将旧代码搬到新的接口上,然后进行一些微调,**不要**擅自新增任何参数、任何新的方法或接口,**不要**仅仅通过新增一个兼容层兼容旧代码来实现重构。 # 建议规则 @@ -15,3 +15,70 @@ 1. 我有时会在对话中给你提出实现建议,你应该对建议内容进行合理的参考,合理运用建议内容,一定注意不要滥用。 2. 如果实现与类型标注有冲突,应当以类型标注(一般是 `types.ts`)中的内容为参考来源。 3. 如果你认为类型标注中的接口设计有问题,或在实现中发现其缺少某些接口,应该向我提问是否添加,我同意后方可添加。 + +**时刻谨记上述要求,避免一个需求写好几次都写不出来,或写出我不满意的代码而挨骂** + +# 开发流程 + +当我提出需求时,如果没有明确说明直接实现或有其他明确要求,则遵循如下开发流程: + +1. 阅读当前代码,分析需求,将需求整理为一个 markdown 文档,文档中明确标记需求细节,以及代码实现的大体思路。这一阶段中应当考虑全面,遇到任何问题应向我提问并确认。文档可以放在 `docs/dev` 目录下。 +2. 我会对文档进行全面的阅读,确保实现细节与思路没有问题后,允许你开始实现。这一步中我可能会对文档进行细微的调整,确保重新仔细阅读文档。如果实现时遇到了任何问题,应该向我提问,而不是按照自己的想法去写。 + +## 示例文档 + +大致按照下述示例文档的格式编写,如果某些场景需要详细描述某个东西,可以单独开一个标题来写。 + +```md +# 需求综述 + +描述清楚需求的内容与目的。 + +# 实现思路 + +按照下面的格式分条描述实现思路,可以创建为 `todo list`。 + +## 1. 完成 xxx + +... + +## 2. 完成 xxx + +... + +# 涉及文件 + +## 需要引用的文件 + +按照第三方库-其他包-当前包的其他文件的顺序写。 + +- `xxx 库`: 引用第三方库,说明引用目的,以及需要的接口 +- `@user/xxx`: 引用的目的,需要这个文件的哪些接口 +- `xxx.ts`: 引用此文件的目的,需要这个文件的哪些接口 + +## 需要修改的文件 + +### `@user/package/[folder/]file.ts` + +除非必要或明确提出,一般不建议擅自新增公共方法或成员,必要时可以向我提问。 + +- [ ] 新增 `Iinterface` 接口:描述新增接口的动机与目的,会用于干什么 +- [ ] 新增 `Type` 类型别名:描述新增类型别名的动机与目的,会用于干什么 +- [ ] 新增 `private readonly property` 成员:描述新增成员的动机与目的,会用于干什么 +- [ ] 新增 `private method(...)` 方法:描述新增方法的动机与目的,会用于干什么 +- [ ] 编写 `Class.method` 方法:描述实现的大体内容 +- [ ] 修改 `Class.method` 方法中的部分内容:描述修改哪些内容,修改这些内容的目的 +- [ ] 重构文件结构,将 `xxx` 与 `yyy` 修改为 `zzz` 与 `www`... + ... + +### `@motajs/package/[folder/]file.ts` + +... + +# 问题 + +如果我的描述中有歧义或比较模糊,可以在这把问题写出来,或者直接向我提问。 + +1. xxxxxx? +2. xxxxxx? +``` From 2ce50bf23ebdab0d51001211403d76a5e294bc83 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 24 Apr 2026 22:17:01 +0800 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=E6=80=AA=E7=89=A9=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=99=A8=E5=AD=98=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- docs/dev/enemy-manager-save.md | 194 +++++++++++++++++++ docs/dev/hero-modifier-save.md | 26 +-- packages-user/data-base/src/enemy/enemy.ts | 25 +++ packages-user/data-base/src/enemy/manager.ts | 135 ++++++++++++- packages-user/data-base/src/enemy/special.ts | 27 +++ packages-user/data-base/src/enemy/types.ts | 74 ++++++- packages/common/src/logger.json | 4 + 7 files changed, 464 insertions(+), 21 deletions(-) create mode 100644 docs/dev/enemy-manager-save.md diff --git a/docs/dev/enemy-manager-save.md b/docs/dev/enemy-manager-save.md new file mode 100644 index 0000000..c08cf0c --- /dev/null +++ b/docs/dev/enemy-manager-save.md @@ -0,0 +1,194 @@ +# 需求综述 + +对怪物管理器 `IEnemyManager` 进行存档适配,使其能够参与游戏存档系统。 +由于大多数情况下怪物模板不会被修改,不需要全量存储, +只需对比"参考状态"(游戏加载完成时的初始模板),仅保存发生了变化的模板。 + +为此需要: + +- `IEnemy` 和 `ISpecial` 继承 `ISaveableContent` 以支持自身序列化; +- 给 `ISpecial` 添加 `deepEqualsTo` 接口用于特殊属性间的深度比较; +- `IEnemyManager` 继承 `ISaveableContent`,新增 `compareWith`、`modifyPrefabAttribute`、 + `attachEnemyComparer`、`getEnemyComparer` 接口; +- `IEnemyManager` 内部维护 dirty 集合,以首次 `compareWith` 传入的参考为唯一基准; +- `getPrefab` / `getPrefabById` 返回值收窄为 `IReadonlyEnemy`, + 统一由 `modifyPrefabAttribute` 承担模板修改职责。 + +--- + +# 实现思路 + +## 1. 新增存档状态类型 + +在 `types.ts` 中新增如下类型,用于序列化怪物与管理器的状态: + +```ts +/** 单个 IEnemy 的存档状态 */ +interface IEnemySaveState { + readonly attrs: TAttr; + // 特殊属性按 code 映射,值为各 ISpecial.saveState() 的结果 + readonly specials: ReadonlyMap; +} + +/** IEnemyManager 的存档状态,只保存与参考状态不同的模板 */ +interface IEnemyManagerSaveState { + // code -> 变更后的 IEnemySaveState + readonly modified: ReadonlyMap>; +} +``` + +## 2. 新增 `IEnemyComparer` 接口 + +由于管理器外部没有比较怪物属性的需求,将比较逻辑封装为独立的比较器, +附着在 `EnemyManager` 上。比较器接口如下: + +```ts +interface IEnemyComparer { + compare( + enemyA: IReadonlyEnemy, + enemyB: IReadonlyEnemy + ): boolean; +} +``` + +由用户在初始化时通过 `attachEnemyComparer` 提供。若未提供比较器, +在调用 `modifyPrefabAttribute` 或 `changePrefab` 时需发出警告,且视所有怪物均为脏。 + +## 3. `ISpecial` 继承 `ISaveableContent` + +- `saveState` 返回 `structuredClone(this.value)`(即 `getValue()` 的深拷贝); +- `loadState` 调用 `setValue(state)`; +- 新增 `deepEqualsTo(other: ISpecial): boolean`:先对比 `code`, + 再对 `value` 进行深度比较。 + +各内置实现类的比较策略: + +- `NonePropertySpecial`:只需比较 `code`,`value` 为 `void` 无需对比; +- `CommonSerializableSpecial`:`value` 为普通可序列化对象, + 使用 `lodash-es` 的 `isEqual` 进行递归深度比较。 + +## 4. `IEnemy` 继承 `ISaveableContent>` + +- `saveState(compression)`:深拷贝 `attrs`,对每个 special 调用 + `saveState(compression)` 收集到 `specials` Map,返回 `IEnemySaveState`; +- `loadState(state, compression)`:以 `state.attrs` 还原属性, + 然后对已有的每个 special 按 code 查找存档中的对应条目并调用 `loadState`; + 若存档中出现当前怪物未注册的 special code,发出 logger 警告并跳过。 + +## 5. `IEnemyManager` 接口修改 + +### 5a. 继承 `ISaveableContent>` + +- `saveState(compression)`:遍历 dirty 集合,对每个脏模板调用 + `prefab.saveState(compression)`,汇总为 `IEnemyManagerSaveState` 并返回; +- `loadState(state, compression)`:遍历 `state.modified`, + 找到 code 对应的现有模板,调用 `prefab.loadState(enemyState, compression)` 还原; + 若某 code 不在当前 prefab 表中,发出 logger 警告并跳过; + **不清空 dirty 集合**,始终以首次 `compareWith` 提供的参考为唯一基准; + `loadState` 结束后重新用比较器对每个已有脏模板进行比对, + 刷新 dirty 集合(避免加载后实际已恢复初始值的模板仍停留在 dirty 中)。 + +### 5b. 新增 `compareWith` + +```ts +compareWith(reference: ReadonlyMap>): void; +``` + +- 由调用方在游戏初始化完成后提供参考快照,外部传入,管理器保存引用; +- **首次调用**:直接存储参考,清空 dirty 集合; +- **非首次调用**:通过 logger 发出警告,提示此操作风险高, + 请作者确认操作意图,但仍然执行覆盖(直接替换参考,重置 dirty 集合)。 + +### 5c. `getPrefab` / `getPrefabById` 返回值改为 `IReadonlyEnemy` + +原来返回 `IEnemy`,外部可以直接修改模板。 +改为只读引用,外部不能直接修改,必须通过 `modifyPrefabAttribute` 完成。 + +### 5d. 新增 `modifyPrefabAttribute` + +```ts +modifyPrefabAttribute( + code: number | string, + modify: (prefab: IEnemy) => IEnemy +): void; +``` + +执行流程: + +1. 根据 `code`(数字或 id 字符串)找到对应的模板; +2. 将模板以可写引用传入 `modify`,获得修改结果; +3. 若 `modify` 返回的是**新引用**(与传入的不同),则将该新对象替换模板表条目 + (同时更新 `prefabByCode` 与 `prefabById`); +4. 将最终生效的模板与 `compareWith` 中提供的参考模板进行 `IEnemyComparer.compare` 比较: + - 若不相等,则将此 code 加入 dirty 集合; + - 若相等(改回了初始值),则从 dirty 集合中移除; +5. 若未附加比较器,则始终视为脏,并发出 logger 警告。 + +### 5e. 新增 `attachEnemyComparer` / `getEnemyComparer` + +```ts +attachEnemyComparer(comparer: IEnemyComparer): void; +getEnemyComparer(): IEnemyComparer | null; +``` + +- `attachEnemyComparer`:设置当前管理器使用的比较器; +- `getEnemyComparer`:返回当前比较器,如未设置则返回 `null`, + 允许外部在特殊场景下借用比较器。 + +### 5f. `changePrefab` 也参与 dirty 追踪 + +`changePrefab` 直接替换模板表,修改完成后同样与参考模板进行比较, +更新 dirty 集合(逻辑与 `modifyPrefabAttribute` 步骤 4 相同)。 + +`deletePrefab` 不参与 dirty 追踪,存档时直接跳过被删除的模板。 + +--- + +# 涉及文件 + +## 需要引用的文件 + +- `lodash-es`:`CommonSerializableSpecial.deepEqualsTo` 中使用 `isEqual` 进行深度比较 +- `@motajs/common`:引用 `logger` 接口 +- `@user/data-base/common/types.ts`:引用 `ISaveableContent`、`SaveCompression` + +## 需要修改的文件 + +### `packages-user/data-base/src/enemy/types.ts` + +- [ ] 新增 `IEnemySaveState` 类型:单个怪物的存档状态 +- [ ] 新增 `IEnemyManagerSaveState` 类型:管理器的存档状态 +- [ ] 新增 `IEnemyComparer` 接口:包含 `compare` 方法,由用户实现 +- [ ] 修改 `ISpecial`:继承 `ISaveableContent`, + 新增 `deepEqualsTo(other: ISpecial): boolean` +- [ ] 修改 `IEnemy`:继承 `ISaveableContent>` +- [ ] 修改 `IEnemyManager`:继承 `ISaveableContent>`, + 新增 `compareWith`、`modifyPrefabAttribute`、`attachEnemyComparer`、`getEnemyComparer`; + 修改 `getPrefab` 与 `getPrefabById` 返回类型为 `IReadonlyEnemy` + +### `packages-user/data-base/src/enemy/enemy.ts`(`Enemy` 类) + +- [ ] 实现 `saveState(compression): IEnemySaveState` +- [ ] 实现 `loadState(state, compression): void` + +### `packages-user/data-base/src/enemy/manager.ts`(`EnemyManager` 类) + +- [ ] 新增 `private readonly dirtySet: Set` 成员:记录脏模板的 code +- [ ] 新增 `private referenceByCode: Map>` 成员: + 保存参考快照 +- [ ] 新增 `private comparer: IEnemyComparer | null` 成员:比较器 +- [ ] 新增 `private hasReference: boolean` 成员:标记是否已首次调用 `compareWith` +- [ ] 实现 `compareWith`:存储参考快照,非首次调用发出警告,重置 dirty 集合 +- [ ] 实现 `modifyPrefabAttribute`:调用 modify、处理引用变化、比较、更新 dirty 集合 +- [ ] 修改 `changePrefab`:替换模板后同步更新 dirty 集合 +- [ ] 修改 `getPrefab` / `getPrefabById` 返回类型(仅类型,实现无需改动) +- [ ] 实现 `attachEnemyComparer` / `getEnemyComparer` +- [ ] 实现 `saveState`:遍历 dirty 集合,序列化并返回 +- [ ] 实现 `loadState`:根据存档恢复脏模板,恢复后重新刷新 dirty 集合 + +### 引擎内置特殊属性(当前包内) + +- [ ] `NonePropertySpecial`:实现 `saveState`、`loadState`、`deepEqualsTo` + (`value` 为 `void`,`deepEqualsTo` 只比较 `code`) +- [ ] `CommonSerializableSpecial`:实现 `saveState`、`loadState`、`deepEqualsTo` + (`deepEqualsTo` 的 value 比较使用 `lodash-es` 的 `isEqual`) diff --git a/docs/dev/hero-modifier-save.md b/docs/dev/hero-modifier-save.md index c060db7..b5a87e1 100644 --- a/docs/dev/hero-modifier-save.md +++ b/docs/dev/hero-modifier-save.md @@ -83,28 +83,28 @@ iterateModifiers(): Iterable<[keyof THero, IHeroModifier]>; ### `@user/data-base/hero/types.ts` -- [ ] 修改 `IHeroModifier` 接口: +- [x] 修改 `IHeroModifier` 接口: 改为 `IHeroModifier`,`identifier` 改名为 `type`, 继承 `ISaveableContent` -- [ ] 新增 `IModifierStateSave` 接口:单条修饰器的存档格式 -- [ ] 修改 `IHeroStateSave`:新增 `readonly modifiers: readonly IModifierStateSave[]` 字段 -- [ ] 修改 `IReadonlyHeroAttribute`:新增 `iterateModifiers()` 方法签名 -- [ ] 修改 `IHeroState`:新增以下方法签名 - `registerModifier(type: string, cons: () => IHeroModifier): void` - `createModifier(type: string): IHeroModifier` - `createAndInsertModifier(type: string, name: K): IHeroModifier` +- [x] 新增 `IModifierStateSave` 接口:单条修饰器的存档格式 +- [x] 修改 `IHeroStateSave`:新增 `readonly modifiers: readonly IModifierStateSave[]` 字段 +- [x] 修改 `IReadonlyHeroAttribute`:新增 `iterateModifiers()` 方法签名 +- [x] 修改 `IHeroState`:新增以下方法签名 - `registerModifier(type: string, cons: () => IHeroModifier): void` - `createModifier(type: string): IHeroModifier` - `createAndInsertModifier(type: string, name: K): IHeroModifier` ### `@user/data-base/hero/attribute.ts` -- [ ] 修改 `BaseHeroModifier`: +- [x] 修改 `BaseHeroModifier`: 将 `abstract readonly identifier` 改为 `abstract readonly type`; 实现 `saveState` / `loadState` -- [ ] 修改 `HeroAttribute`:实现 `iterateModifiers()` +- [x] 修改 `HeroAttribute`:实现 `iterateModifiers()` ### `@user/data-base/hero/state.ts` -- [ ] 修改 `HeroState`:新增 `private readonly registry: Map IHeroModifier>` 成员 -- [ ] 实现 `HeroState.registerModifier`:将工厂函数写入 `registry` -- [ ] 实现 `HeroState.createModifier`:从 `registry` 取出工厂并调用,返回新实例; +- [x] 修改 `HeroState`:新增 `private readonly registry: Map IHeroModifier>` 成员 +- [x] 实现 `HeroState.registerModifier`:将工厂函数写入 `registry` +- [x] 实现 `HeroState.createModifier`:从 `registry` 取出工厂并调用,返回新实例; 若 `type` 未注册则抛出错误 -- [ ] 实现 `HeroState.createAndInsertModifier`:调用 `createModifier` 后, +- [x] 实现 `HeroState.createAndInsertModifier`:调用 `createModifier` 后, 再调用 `this.attribute.addModifier(name, modifier)`,返回同一实例 -- [ ] 修改 `HeroState.saveState`:遍历 `iterateModifiers()` 写入 `modifiers` 字段 -- [ ] 修改 `HeroState.loadState`:遍历 `state.modifiers` 重建修饰器并挂载 +- [x] 修改 `HeroState.saveState`:遍历 `iterateModifiers()` 写入 `modifiers` 字段 +- [x] 修改 `HeroState.loadState`:遍历 `state.modifiers` 重建修饰器并挂载 diff --git a/packages-user/data-base/src/enemy/enemy.ts b/packages-user/data-base/src/enemy/enemy.ts index 501da36..189e2f1 100644 --- a/packages-user/data-base/src/enemy/enemy.ts +++ b/packages-user/data-base/src/enemy/enemy.ts @@ -2,10 +2,12 @@ import { logger } from '@motajs/common'; import { IEnemy, IEnemyContext, + IEnemySaveState, IReadonlyEnemy, ISpecial, IEnemyView } from './types'; +import { SaveCompression } from '../common/types'; export class Enemy implements IEnemy { /** 怪物身上的特殊属性列表 */ @@ -87,6 +89,29 @@ export class Enemy implements IEnemy { this.addSpecial(special.clone()); } } + + saveState(_compression: SaveCompression): IEnemySaveState { + const specials: Map = new Map(); + for (const special of this.specials) { + specials.set(special.code, special.saveState(_compression)); + } + return { attrs: structuredClone(this.attributes), specials }; + } + + loadState( + state: IEnemySaveState, + compression: SaveCompression + ): void { + this.attributes = structuredClone(state.attrs); + for (const special of this.specials) { + const saved = state.specials.get(special.code); + if (saved === undefined) { + logger.warn(120, special.code.toString(), this.id); + continue; + } + special.loadState(saved, compression); + } + } } export class EnemyView implements IEnemyView { diff --git a/packages-user/data-base/src/enemy/manager.ts b/packages-user/data-base/src/enemy/manager.ts index 2f69c34..210f601 100644 --- a/packages-user/data-base/src/enemy/manager.ts +++ b/packages-user/data-base/src/enemy/manager.ts @@ -2,10 +2,15 @@ import { logger } from '@motajs/common'; import { Enemy as EnemyImpl } from './enemy'; import { IEnemy, + IEnemyComparer, IEnemyManager, + IEnemyManagerSaveState, IEnemyLegacyBridge, - SpecialCreation + IReadonlyEnemy, + SpecialCreation, + IEnemySaveState } from './types'; +import { SaveCompression } from '../common/types'; export class EnemyManager implements IEnemyManager { /** 特殊属性注册表,code -> 创建函数 */ @@ -19,6 +24,14 @@ export class EnemyManager implements IEnemyManager { private readonly prefabById: Map> = new Map(); /** 旧样板怪物 id 到 code 的映射,用于 fromLegacyEnemy 快速查找已有模板 */ private readonly legacyIdToCode: Map = new Map(); + /** 脏模板集合,存储发生了变化的模板 code */ + private readonly dirtySet: Set = new Set(); + /** 参考快照,code -> IReadonlyEnemy,由 compareWith 提供 */ + private referenceByCode: Map> = new Map(); + /** 当前附加的怪物比较器 */ + private comparer: IEnemyComparer | null = null; + /** 是否已首次调用 compareWith */ + private hasReference: boolean = false; constructor(readonly bridge: IEnemyLegacyBridge) {} @@ -127,6 +140,7 @@ export class EnemyManager implements IEnemyManager { const cloned = enemy.clone(); this.prefabByCode.set(enemy.code, cloned); this.prefabById.set(enemy.id, cloned); + this.updateDirty(cloned.code, cloned); } addPrefabFromLegacy(code: number, enemy: Enemy): void { @@ -137,13 +151,14 @@ export class EnemyManager implements IEnemyManager { this.prefabByCode.set(code, prefab); this.prefabById.set(prefab.id, prefab); this.legacyIdToCode.set(enemy.id, code); + this.updateDirty(code, prefab); } - getPrefab(code: number): IEnemy | null { + getPrefab(code: number): IReadonlyEnemy | null { return this.prefabByCode.get(code) ?? null; } - getPrefabById(id: string): IEnemy | null { + getPrefabById(id: string): IReadonlyEnemy | null { return this.prefabById.get(id) ?? null; } @@ -160,6 +175,7 @@ export class EnemyManager implements IEnemyManager { // 再添加新的模板 this.prefabByCode.set(enemy.code, enemy); this.prefabById.set(enemy.id, enemy); + this.updateDirty(enemy.code, enemy); } reusePrefab(source: number | string, code: number, id: string): void { @@ -168,4 +184,117 @@ export class EnemyManager implements IEnemyManager { this.prefabByCode.set(code, prefab); this.prefabById.set(id, prefab); } + + compareWith(reference: ReadonlyMap>): void { + const isSubsequentCall = this.hasReference; + if (isSubsequentCall) { + logger.warn(117); + } + this.referenceByCode = new Map(); + reference.forEach((enemy, key) => { + this.referenceByCode.set(key, enemy.clone()); + }); + this.hasReference = true; + this.dirtySet.clear(); + if (isSubsequentCall) { + this.refreshDirty(reference.keys()); + } + } + + modifyPrefabAttribute( + code: number | string, + modify: (prefab: IEnemy) => IEnemy + ): void { + const prefab = this.internalGetPrefab(code); + if (!prefab) return; + const result = modify(prefab); + const prefabCode = prefab.code; + if (result !== prefab) { + this.prefabByCode.set(result.code, result); + this.prefabById.set(result.id, result); + if (result.code !== prefabCode) { + this.prefabByCode.delete(prefabCode); + } + if (result.id !== prefab.id) { + this.prefabById.delete(prefab.id); + } + } + this.updateDirty(result.code, result); + } + + attachEnemyComparer(comparer: IEnemyComparer): void { + this.comparer = comparer; + } + + getEnemyComparer(): IEnemyComparer | null { + return this.comparer; + } + + saveState(compression: SaveCompression): IEnemyManagerSaveState { + const modified: Map> = new Map(); + for (const code of this.dirtySet) { + const prefab = this.prefabByCode.get(code); + if (!prefab) continue; + modified.set(code, prefab.saveState(compression)); + } + return { modified }; + } + + loadState( + state: IEnemyManagerSaveState, + compression: SaveCompression + ): void { + for (const [code, enemyState] of state.modified) { + const prefab = this.prefabByCode.get(code); + if (!prefab) { + logger.warn(119, code.toString()); + continue; + } + prefab.loadState(enemyState, compression); + } + // loadState 结束后重新刷新 dirty 集合 + this.refreshDirty(state.modified.keys()); + } + + /** + * 根据参考快照更新指定 code 的脏状态 + * @param code 怪物图块数字 + * @param current 当前模板对象 + */ + private updateDirty(code: number, current: IEnemy): void { + if (!this.hasReference) return; + if (!this.comparer) { + logger.warn(118); + this.dirtySet.add(code); + return; + } + const ref = this.referenceByCode.get(code); + if (!ref || !this.comparer.compare(current, ref)) { + this.dirtySet.add(code); + } else { + this.dirtySet.delete(code); + } + } + + /** + * 将所有模板加入脏集合,再与参考比较,去除未变化的模板 + */ + private refreshDirty(dirties: Iterable): void { + if (!this.hasReference) return; + for (const code of dirties) { + this.dirtySet.add(code); + } + if (!this.comparer) return; + for (const code of [...this.dirtySet]) { + const prefab = this.prefabByCode.get(code); + if (!prefab) { + this.dirtySet.delete(code); + continue; + } + const ref = this.referenceByCode.get(code); + if (ref && this.comparer.compare(prefab, ref)) { + this.dirtySet.delete(code); + } + } + } } diff --git a/packages-user/data-base/src/enemy/special.ts b/packages-user/data-base/src/enemy/special.ts index 5072728..59c1083 100644 --- a/packages-user/data-base/src/enemy/special.ts +++ b/packages-user/data-base/src/enemy/special.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash-es'; +import { SaveCompression } from '../common/types'; import { ISpecial, SpecialCreation } from './types'; // TODO: 颜色参数 @@ -45,6 +47,19 @@ export class CommonSerializableSpecial implements ISpecial { this.config ); } + + saveState(_compression: SaveCompression): T { + return structuredClone(this.value); + } + + loadState(state: T, _compression: SaveCompression): void { + this.setValue(state); + } + + deepEqualsTo(other: ISpecial): boolean { + if (this.code !== other.code) return false; + return isEqual(this.value, other.getValue()); + } } export class NonePropertySpecial implements ISpecial { @@ -78,6 +93,18 @@ export class NonePropertySpecial implements ISpecial { clone(): ISpecial { return new NonePropertySpecial(this.code, this.config); } + + saveState(_compression: SaveCompression): void { + return undefined; + } + + loadState(_state: void, _compression: SaveCompression): void { + // 无属性,无需操作 + } + + deepEqualsTo(other: ISpecial): boolean { + return this.code === other.code; + } } export function defineCommonSerializableSpecial( diff --git a/packages-user/data-base/src/enemy/types.ts b/packages-user/data-base/src/enemy/types.ts index 210381a..849a7a2 100644 --- a/packages-user/data-base/src/enemy/types.ts +++ b/packages-user/data-base/src/enemy/types.ts @@ -1,9 +1,36 @@ import { IRange, ITileLocator } from '@motajs/common'; import { IHeroAttribute, IReadonlyHeroAttribute } from '../hero'; +import { ISaveableContent } from '../common/types'; //#region 怪物基础 -export interface ISpecial { +/** 单个 IEnemy 的存档状态 */ +export interface IEnemySaveState { + /** 怪物属性的深拷贝 */ + readonly attrs: TAttr; + /** 特殊属性按 code 映射,值为各 ISpecial.saveState() 的结果 */ + readonly specials: ReadonlyMap; +} + +/** IEnemyManager 的存档状态,只保存与参考状态不同的模板 */ +export interface IEnemyManagerSaveState { + /** code -> 变更后的 IEnemySaveState,仅包含脏模板 */ + readonly modified: ReadonlyMap>; +} + +export interface IEnemyComparer { + /** + * 比较两个怪物是否完全相同 + * @param enemyA 怪物 A + * @param enemyB 怪物 B + */ + compare( + enemyA: IReadonlyEnemy, + enemyB: IReadonlyEnemy + ): boolean; +} + +export interface ISpecial extends ISaveableContent { /** 特殊属性代码 */ readonly code: number; /** 特殊属性需要的数值 */ @@ -40,6 +67,12 @@ export interface ISpecial { * 深拷贝此特殊属性 */ clone(): ISpecial; + + /** + * 深度比较此特殊属性与另一特殊属性是否相同 + * @param other 另一特殊属性 + */ + deepEqualsTo(other: ISpecial): boolean; } export interface IReadonlyEnemy { @@ -82,7 +115,8 @@ export interface IReadonlyEnemy { clone(): IReadonlyEnemy; } -export interface IEnemy extends IReadonlyEnemy { +export interface IEnemy + extends IReadonlyEnemy, ISaveableContent> { /** * 添加特殊属性 * @param special 特殊属性对象 @@ -138,7 +172,9 @@ export interface IEnemyLegacyBridge { fromLegacyEnemy(enemy: Enemy, defaultValue: Partial): TAttr; } -export interface IEnemyManager { +export interface IEnemyManager extends ISaveableContent< + IEnemyManagerSaveState +> { /** * 注册一个特殊属性 * @param code 特殊属性代码 @@ -193,13 +229,13 @@ export interface IEnemyManager { * 获取指定怪物的模板 * @param code 怪物图块数字 */ - getPrefab(code: number): IEnemy | null; + getPrefab(code: number): IReadonlyEnemy | null; /** * 根据怪物的 `id` 获取对应的怪物模板 * @param id 怪物 `id` */ - getPrefabById(id: string): IEnemy | null; + getPrefabById(id: string): IReadonlyEnemy | null; /** * 删除指定的怪物模板 @@ -221,6 +257,34 @@ export interface IEnemyManager { * @param id 复用怪物 id */ reusePrefab(source: number | string, code: number, id: string): void; + + /** + * 设置参考快照,后续对模板的修改将与此比较以确定是否脏。 + * 非首次调用时会发出警告,但仍执行覆盖 + * @param reference code -> 参考怪物的 Map + */ + compareWith(reference: ReadonlyMap>): void; + + /** + * 修改指定怪物模板的属性,修改完成后自动与参考模板比较并更新 dirty 集合 + * @param code 怪物的图块数字或 `id` + * @param modify 修改函数,传入可写怪物对象,返回修改后的对象 + */ + modifyPrefabAttribute( + code: number | string, + modify: (prefab: IEnemy) => IEnemy + ): void; + + /** + * 附加怪物比较器,用于 dirty 集合的判断 + * @param comparer 比较器对象 + */ + attachEnemyComparer(comparer: IEnemyComparer): void; + + /** + * 获取当前附加的怪物比较器,如未设置则返回 `null` + */ + getEnemyComparer(): IEnemyComparer | null; } //#endregion diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 3f850b1..2028acd 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -173,6 +173,10 @@ "114": "Save operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level.", "115": "Autosave operation took $1ms, exceeding the tolerance of $2ms. Consider reducing compression level.", "116": "Cannot construct modifier of type '$1' since no registry for it.", + "117": "EnemyManager.compareWith called more than once. The previous reference will be overridden. Please ensure you intend to do this.", + "118": "No enemy comparer attached to EnemyManager. All enemies will be treated as dirty.", + "119": "Enemy prefab with code $1 not found during loadState, skipping.", + "120": "Special with code $1 not found in enemy '$2' during loadState, skipping.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency." } } From 476f735adc064248aa66ba687145daf1668c6d7a Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 24 Apr 2026 23:12:18 +0800 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20manager=20=E7=9A=84=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E6=A1=88=20&=20fix:=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- .../src/render/elements/props.ts | 2 +- .../client-modules/src/render/map/element.ts | 2 +- .../src/render/map/extension/door.ts | 2 +- .../src/render/map/extension/hero.ts | 3 +- .../src/render/map/extension/manager.ts | 2 +- .../src/render/map/extension/types.ts | 2 +- .../client-modules/src/render/map/moving.ts | 2 +- .../client-modules/src/render/map/renderer.ts | 2 +- .../client-modules/src/render/map/status.ts | 2 +- .../client-modules/src/render/map/types.ts | 2 +- .../client-modules/src/render/map/vertex.ts | 2 +- packages-user/data-base/src/enemy/manager.ts | 20 ++++++++---- packages-user/data-state/src/core.ts | 16 ++++++++-- .../data-state/src/enemy/comparer.ts | 31 +++++++++++++++++++ packages-user/data-state/src/legacy/move.ts | 5 ++- .../legacy-plugin-data/src/fallback.ts | 11 ++----- packages/legacy-ui/src/tools/fixed.ts | 2 ++ 17 files changed, 76 insertions(+), 32 deletions(-) create mode 100644 packages-user/data-state/src/enemy/comparer.ts diff --git a/packages-user/client-modules/src/render/elements/props.ts b/packages-user/client-modules/src/render/elements/props.ts index ce4da18..a3c7a7d 100644 --- a/packages-user/client-modules/src/render/elements/props.ts +++ b/packages-user/client-modules/src/render/elements/props.ts @@ -1,6 +1,6 @@ import { BaseProps, TagDefine } from '@motajs/render-vue'; import { ERenderItemEvent, SizedCanvasImageSource } from '@motajs/render'; -import { ILayerState } from '@user/data-state'; +import { ILayerState } from '@user/data-base'; import { IMapExtensionManager, IMapRenderer } from '../map'; export interface IconProps extends BaseProps { diff --git a/packages-user/client-modules/src/render/map/element.ts b/packages-user/client-modules/src/render/map/element.ts index 11487d8..a2352dd 100644 --- a/packages-user/client-modules/src/render/map/element.ts +++ b/packages-user/client-modules/src/render/map/element.ts @@ -1,5 +1,5 @@ import { MotaOffscreenCanvas2D, RenderItem } from '@motajs/render'; -import { ILayerState } from '@user/data-state'; +import { ILayerState } from '@user/data-base'; import { IMapRenderer } from './types'; import { ElementNamespace, ComponentInternalInstance } from 'vue'; import { CELL_HEIGHT, CELL_WIDTH, MAP_HEIGHT, MAP_WIDTH } from '../../shared'; diff --git a/packages-user/client-modules/src/render/map/extension/door.ts b/packages-user/client-modules/src/render/map/extension/door.ts index ce08fc5..7420ddc 100644 --- a/packages-user/client-modules/src/render/map/extension/door.ts +++ b/packages-user/client-modules/src/render/map/extension/door.ts @@ -2,7 +2,7 @@ import { IMapLayer, IMapLayerHookController, IMapLayerHooks -} from '@user/data-state'; +} from '@user/data-base'; import { IMapDoorRenderer } from './types'; import { IMapRenderer } from '../types'; import { sleep } from 'mutate-animate'; diff --git a/packages-user/client-modules/src/render/map/extension/hero.ts b/packages-user/client-modules/src/render/map/extension/hero.ts index 039ed77..e28d05a 100644 --- a/packages-user/client-modules/src/render/map/extension/hero.ts +++ b/packages-user/client-modules/src/render/map/extension/hero.ts @@ -7,7 +7,7 @@ import { IHeroMovingHooks, nextFaceDirection } from '@user/data-base'; -import { IMapLayer, state } from '@user/data-state'; +import { IMapLayer } from '@user/data-base'; import { IMapRenderer, IMapRendererTicker, IMovingBlock } from '../types'; import { isNil } from 'lodash-es'; import { IHookController, logger } from '@motajs/common'; @@ -15,6 +15,7 @@ import { BlockCls, IMaterialFramedData } from '@user/client-base'; import { ITexture, ITextureSplitter, TextureRowSplitter } from '@motajs/render'; import { IMapHeroRenderer } from './types'; import { TimingFn } from 'mutate-animate'; +import { state } from '@user/data-state'; /** 默认的移动时长 */ const DEFAULT_TIME = 100; diff --git a/packages-user/client-modules/src/render/map/extension/manager.ts b/packages-user/client-modules/src/render/map/extension/manager.ts index 41c6481..4a01b30 100644 --- a/packages-user/client-modules/src/render/map/extension/manager.ts +++ b/packages-user/client-modules/src/render/map/extension/manager.ts @@ -1,5 +1,5 @@ import { IHeroMover } from '@user/data-base'; -import { IMapLayer } from '@user/data-state'; +import { IMapLayer } from '@user/data-base'; import { IMapDoorRenderer, IMapExtensionManager, diff --git a/packages-user/client-modules/src/render/map/extension/types.ts b/packages-user/client-modules/src/render/map/extension/types.ts index 82577d0..bd685cc 100644 --- a/packages-user/client-modules/src/render/map/extension/types.ts +++ b/packages-user/client-modules/src/render/map/extension/types.ts @@ -4,7 +4,7 @@ import { HeroAnimateDirection, IHeroMover } from '@user/data-base'; -import { IMapLayer } from '@user/data-state'; +import { IMapLayer } from '@user/data-base'; import { IMapRenderResult } from '../types'; diff --git a/packages-user/client-modules/src/render/map/moving.ts b/packages-user/client-modules/src/render/map/moving.ts index 8c89b9b..ea464cb 100644 --- a/packages-user/client-modules/src/render/map/moving.ts +++ b/packages-user/client-modules/src/render/map/moving.ts @@ -2,7 +2,7 @@ import { linear, TimingFn } from 'mutate-animate'; import { IMapRenderer, IMapVertexGenerator, IMovingBlock } from './types'; import { IMaterialFramedData, IMaterialManager } from '@user/client-base'; import { logger } from '@motajs/common'; -import { IMapLayer } from '@user/data-state'; +import { IMapLayer } from '@user/data-base'; import { DynamicBlockStatus } from './status'; export interface IMovingRenderer { diff --git a/packages-user/client-modules/src/render/map/renderer.ts b/packages-user/client-modules/src/render/map/renderer.ts index df54f0d..6292c82 100644 --- a/packages-user/client-modules/src/render/map/renderer.ts +++ b/packages-user/client-modules/src/render/map/renderer.ts @@ -30,7 +30,7 @@ import { MapTileBehavior, MapTileSizeTestMode } from './types'; -import { ILayerState, ILayerStateHooks, IMapLayer } from '@user/data-state'; +import { ILayerState, ILayerStateHooks, IMapLayer } from '@user/data-base'; import { IHookController, logger } from '@motajs/common'; import { compileProgramWith } from '@motajs/client-base'; import { isNil, maxBy } from 'lodash-es'; diff --git a/packages-user/client-modules/src/render/map/status.ts b/packages-user/client-modules/src/render/map/status.ts index 922b14b..979abaa 100644 --- a/packages-user/client-modules/src/render/map/status.ts +++ b/packages-user/client-modules/src/render/map/status.ts @@ -1,4 +1,4 @@ -import { IMapLayer } from '@user/data-state'; +import { IMapLayer } from '@user/data-base'; import { IBlockStatus, IMapVertexStatus } from './types'; export class StaticBlockStatus implements IBlockStatus { diff --git a/packages-user/client-modules/src/render/map/types.ts b/packages-user/client-modules/src/render/map/types.ts index 8b93aa0..438586c 100644 --- a/packages-user/client-modules/src/render/map/types.ts +++ b/packages-user/client-modules/src/render/map/types.ts @@ -10,7 +10,7 @@ import { IMaterialManager, ITrackedAssetData } from '@user/client-base'; -import { ILayerState, IMapLayer } from '@user/data-state'; +import { ILayerState, IMapLayer } from '@user/data-base'; import { TimingFn } from 'mutate-animate'; export const enum MapBackgroundRepeat { diff --git a/packages-user/client-modules/src/render/map/vertex.ts b/packages-user/client-modules/src/render/map/vertex.ts index e9bb9a5..a8ca1af 100644 --- a/packages-user/client-modules/src/render/map/vertex.ts +++ b/packages-user/client-modules/src/render/map/vertex.ts @@ -1,4 +1,4 @@ -import { IMapLayer } from '@user/data-state'; +import { IMapLayer } from '@user/data-base'; import { IBlockData, IBlockSplitter, diff --git a/packages-user/data-base/src/enemy/manager.ts b/packages-user/data-base/src/enemy/manager.ts index 210f601..915bd56 100644 --- a/packages-user/data-base/src/enemy/manager.ts +++ b/packages-user/data-base/src/enemy/manager.ts @@ -24,6 +24,10 @@ export class EnemyManager implements IEnemyManager { private readonly prefabById: Map> = new Map(); /** 旧样板怪物 id 到 code 的映射,用于 fromLegacyEnemy 快速查找已有模板 */ private readonly legacyIdToCode: Map = new Map(); + /** 复用映射,reusedCode -> sourceCode */ + private readonly reuseByCode: Map = new Map(); + /** 复用映射,reusedId -> sourceId */ + private readonly reuseById: Map = new Map(); /** 脏模板集合,存储发生了变化的模板 code */ private readonly dirtySet: Set = new Set(); /** 参考快照,code -> IReadonlyEnemy,由 compareWith 提供 */ @@ -124,9 +128,11 @@ export class EnemyManager implements IEnemyManager { private internalGetPrefab(code: number | string) { if (typeof code === 'number') { - return this.prefabByCode.get(code) ?? null; + const sourceCode = this.reuseByCode.get(code) ?? code; + return this.prefabByCode.get(sourceCode) ?? null; } else { - return this.prefabById.get(code) ?? null; + const sourceId = this.reuseById.get(code) ?? code; + return this.prefabById.get(sourceId) ?? null; } } @@ -155,11 +161,13 @@ export class EnemyManager implements IEnemyManager { } getPrefab(code: number): IReadonlyEnemy | null { - return this.prefabByCode.get(code) ?? null; + const sourceCode = this.reuseByCode.get(code) ?? code; + return this.prefabByCode.get(sourceCode) ?? null; } getPrefabById(id: string): IReadonlyEnemy | null { - return this.prefabById.get(id) ?? null; + const sourceId = this.reuseById.get(id) ?? id; + return this.prefabById.get(sourceId) ?? null; } deletePrefab(code: number | string): void { @@ -181,8 +189,8 @@ export class EnemyManager implements IEnemyManager { reusePrefab(source: number | string, code: number, id: string): void { const prefab = this.internalGetPrefab(source); if (!prefab) return; - this.prefabByCode.set(code, prefab); - this.prefabById.set(id, prefab); + this.reuseByCode.set(code, prefab.code); + this.reuseById.set(id, prefab.id); } compareWith(reference: ReadonlyMap>): void { diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index 47135f2..d1eaa10 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -22,7 +22,8 @@ import { FaceDirection, ISaveableContent, IStateSaveData, - SaveCompression + SaveCompression, + IReadonlyEnemy } from '@user/data-base'; import { IEnemyAttr } from './enemy'; import { @@ -42,6 +43,7 @@ import { isNil } from 'lodash-es'; import { logger } from '@motajs/common'; import { ISaveSystem } from './save'; import { SaveSystem } from './save/system'; +import { MainEnemyComparer } from './enemy/comparer'; export class CoreState implements ICoreState { // 全局内容 @@ -92,7 +94,9 @@ export class CoreState implements ICoreState { //#region 怪物初始化 // 怪物管理器初始化 + const comparer = new MainEnemyComparer(); const enemyManager = new EnemyManager(new EnemyLegacyBridge()); + enemyManager.attachEnemyComparer(comparer); enemyManager.setAttributeDefaults('hp', 0); enemyManager.setAttributeDefaults('atk', 0); enemyManager.setAttributeDefaults('def', 0); @@ -159,6 +163,7 @@ export class CoreState implements ICoreState { private initEnemyManager(data: Record) { // TODO: 修改怪物模板并存入存档,即 core.setEnemy const manager = this.enemyManager; + const reference = new Map>(); for (const [id, enemy] of Object.entries(structuredClone(data))) { const num = this.idNumberMap.get(id); if (isNil(num)) continue; @@ -169,7 +174,9 @@ export class CoreState implements ICoreState { const upCode = this.idNumberMap.get(up)!; const rightCode = this.idNumberMap.get(right)!; const downCode = this.idNumberMap.get(down)!; - manager.addPrefabFromLegacy(downCode, enemy); + const prefab = manager.fromLegacyEnemy(downCode, enemy); + reference.set(downCode, prefab); + manager.addPrefab(prefab); this.roleFace.malloc(downCode, FaceDirection.Down); this.roleFace.bind(leftCode, downCode, FaceDirection.Left); this.roleFace.bind(upCode, downCode, FaceDirection.Up); @@ -178,9 +185,12 @@ export class CoreState implements ICoreState { manager.reusePrefab(num, upCode, up); manager.reusePrefab(num, rightCode, right); } else { - manager.addPrefabFromLegacy(num, enemy); + const prefab = manager.fromLegacyEnemy(num, enemy); + reference.set(num, prefab); + manager.addPrefab(prefab); } } + manager.compareWith(reference); } addSaveableContent(id: string, content: ISaveableContent): void { diff --git a/packages-user/data-state/src/enemy/comparer.ts b/packages-user/data-state/src/enemy/comparer.ts new file mode 100644 index 0000000..a04bef7 --- /dev/null +++ b/packages-user/data-state/src/enemy/comparer.ts @@ -0,0 +1,31 @@ +import { IEnemyComparer, IReadonlyEnemy } from '@user/data-base'; +import { IEnemyAttr } from './types'; + +export class MainEnemyComparer implements IEnemyComparer { + compare( + enemyA: IReadonlyEnemy, + enemyB: IReadonlyEnemy + ): boolean { + // 比较基本属性 + if ( + enemyA.getAttribute('hp') !== enemyB.getAttribute('hp') || + enemyA.getAttribute('atk') !== enemyB.getAttribute('atk') || + enemyA.getAttribute('def') !== enemyB.getAttribute('def') || + enemyA.getAttribute('money') !== enemyB.getAttribute('money') || + enemyA.getAttribute('exp') !== enemyB.getAttribute('exp') || + enemyA.getAttribute('point') !== enemyB.getAttribute('point') + ) { + return false; + } + + // 比较特殊属性 + const specialsA = [...enemyA.iterateSpecials()]; + const specialsB = [...enemyB.iterateSpecials()]; + if (specialsA.length !== specialsB.length) return false; + for (const special of specialsA) { + const other = enemyB.getSpecial(special.code); + if (!other || !special.deepEqualsTo(other)) return false; + } + return true; + } +} diff --git a/packages-user/data-state/src/legacy/move.ts b/packages-user/data-state/src/legacy/move.ts index 51e4509..48c3aa3 100644 --- a/packages-user/data-state/src/legacy/move.ts +++ b/packages-user/data-state/src/legacy/move.ts @@ -1,10 +1,9 @@ import EventEmitter from 'eventemitter3'; import { backDir, toDir } from './utils'; -import { loading } from '@user/data-base'; -import type { RenderAdapter } from '@motajs/render'; +import { fromDirectionString, loading } from '@user/data-base'; import type { HeroKeyMover } from '@user/client-modules'; import { sleep } from '@motajs/common'; -import { fromDirectionString, state } from '..'; +import { state } from '..'; // todo: 转身功能 diff --git a/packages-user/legacy-plugin-data/src/fallback.ts b/packages-user/legacy-plugin-data/src/fallback.ts index 8519db7..1271d38 100644 --- a/packages-user/legacy-plugin-data/src/fallback.ts +++ b/packages-user/legacy-plugin-data/src/fallback.ts @@ -1,11 +1,6 @@ import type { TimingFn } from 'mutate-animate'; -import { - fromDirectionString, - heroMoveCollection, - MoveStep, - state -} from '@user/data-state'; -import { hook, loading } from '@user/data-base'; +import { heroMoveCollection, MoveStep, state } from '@user/data-state'; +import { fromDirectionString, hook, loading } from '@user/data-base'; import { Patch, PatchClass } from '@motajs/legacy-common'; import { isNil } from 'lodash-es'; @@ -59,8 +54,6 @@ export function initFallback() { Mota.r(() => { // ----- 引入 - const { mainRenderer } = Mota.require('@user/client-modules'); - const Animation = Mota.require('MutateAnimate'); const patch = new Patch(PatchClass.Control); const patch2 = new Patch(PatchClass.Events); diff --git a/packages/legacy-ui/src/tools/fixed.ts b/packages/legacy-ui/src/tools/fixed.ts index 2383108..ba05235 100644 --- a/packages/legacy-ui/src/tools/fixed.ts +++ b/packages/legacy-ui/src/tools/fixed.ts @@ -29,8 +29,10 @@ export function getDetailedEnemy( return typeof func === 'string' ? func : func(enemy); }; const special: [string, string, string][] = [...enemy.info.special] + // @ts-expect-error 之后修 .filter(v => !enemy.info.specialHalo?.includes(v)) .map(vv => { + // @ts-expect-error 之后修 const s = Mota.require('@user/data-state').specials[vv]; return [ fromFunc(s.name, enemy.info), From 70a58ef4dc755b5fcf745740dbae01ddee80b8a1 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sun, 26 Apr 2026 13:33:49 +0800 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=E5=9C=B0=E5=9B=BE=E5=AD=98?= =?UTF-8?q?=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- docs/dev/map-store-save.md | 294 ++++++++++++++ packages-user/data-base/src/enemy/manager.ts | 2 +- packages-user/data-base/src/map/index.ts | 1 + packages-user/data-base/src/map/layerState.ts | 21 + packages-user/data-base/src/map/mapLayer.ts | 33 +- packages-user/data-base/src/map/mapStore.ts | 383 ++++++++++++++++++ packages-user/data-base/src/map/types.ts | 124 +++++- packages-user/data-base/src/types.ts | 4 +- packages/common/src/logger.json | 5 + 9 files changed, 853 insertions(+), 14 deletions(-) create mode 100644 docs/dev/map-store-save.md create mode 100644 packages-user/data-base/src/map/mapStore.ts diff --git a/docs/dev/map-store-save.md b/docs/dev/map-store-save.md new file mode 100644 index 0000000..fdc8c05 --- /dev/null +++ b/docs/dev/map-store-save.md @@ -0,0 +1,294 @@ +# 需求综述 + +当前 `LayerState` 只存储当前激活地图的数据,切换地图时原地图内容彻底丢失, +无法参与存档系统。为此引入 `IMapStore`,集中管理所有楼层的 `LayerState`, +并实现 `ISaveableContent` 接口以支持存档读档。 + +核心目标: + +- 多楼层数据同时存在于内存中,通过 id 访问; +- 通过 `active` 标记区分"玩家可能到达"与"无需关注"的楼层,节省存档开销; +- 通过 `compareWith` 提供参考基准,配合分级压缩大幅减少存档体积; +- `IStateBase.layer` 类型由 `ILayerState` 改为 `IMapStore`, + 操作楼层必须先通过 `getLayerState(id)` 取得具体楼层。 + +--- + +# 实现思路 + +## 1. 给 LayerState 添加 active 成员 + +`ILayerState` 新增 `readonly active: boolean` 和 `setActiveStatus(active: boolean): void` +两个接口,`LayerState` 实现类中 `active` 默认为 `false`。 + +两者的关系: + +- `ILayerState.setActiveStatus`:直接操作楼层对象; +- `IMapStore.setMapActiveStatus(id, active)`:通过 id 操作, + 内部查找对应楼层后调用其 `setActiveStatus`。 + +## 2. 脏数据追踪(dirty tracking) + +为支持 `LowCompression` 和 `HighCompression` 的差分存档, +需要知道哪些楼层相对于参考基准是否发生了修改。 + +**推荐方案:楼层级简单脏标记 + 存档时实际比较** + +在 `LayerState` 内部维护 `private dirty: boolean = false`: + +- 当楼层内任意 `MapLayer` 触发 `onUpdateBlock`、`onUpdateArea`、`onResize` + 钩子时,将 `dirty` 置为 `true`; +- `dirty` 只在 `compareWith` 首次调用时根据实际数据对比结果初始化, + 初始化后的 gameplay 过程中不再重置(仅置 true)。 + +在 `saveState` 时: + +- 若 `dirty = false`,跳过该楼层(初始化后从未被触碰过); +- 若 `dirty = true`: + - **LowCompression**:与参考基准进行全量比较,若完全一致则跳过 + (消除"改了又改回"场景下的误判),否则存储所有行; + - **HighCompression**:逐行与参考基准比较,只存储不一致的行。 + +在 `loadState` 时:若存档中此楼层没有任何数据(即未出现在 `floors` 中), +读档后将 `dirty` 置为 `false`(视为与参考基准一致)。 + +**不在 `MapLayer` 内维护 `dirtyRows`**,行级比较在 `saveState` 时直接对照参考基准进行。 +这避免了每次 `setBlock`/`putMapData` 都更新行级标记的热路径开销, +且存档时实际比较已能消除误判,无需 `probablyDirty` + `setInterval` 或哈希方案。 + +存档时比较的开销:Uint32Array 内存连续,实际耗时极低,且保存操作本身是低频的, +若将来发现存档耗时问题,可考虑将比较逻辑移至 Web Worker。 + +## 3. compareWith 接口与参数类型 + +```ts +compareWith(ref: Map>): void; +``` + +外层 `Map` 以楼层 id 为键,内层 `Map` 以图层 `zIndex` 为键, +值为对应图层的完整图块数组(Uint32Array,含所有行的扁平数据)。 + +使用此类型而非 `IMapStore` 的理由:接口更轻量,调用方可直接从游戏原始数据构建, +无需额外持有一个完整的 `IMapStore` 实例。 + +关于图层标识符:继续使用 `zIndex`,在单个楼层内 `zIndex` 是语义唯一的, +与已有 `MapLayer.zIndex` 接口保持一致。 + +**`compareWith` 以首次调用为唯一基准**,再次调用不更新参考(以游戏原始数据为基准, +避免存档之间产生依赖关系)。 + +实现步骤: + +1. 若 `refData` 已存在,直接返回; +2. 保存 `ref` 引用到 `private refData`; +3. 遍历当前所有楼层,对每个楼层在 `ref` 中查找对应 id: + - 不存在:`dirty = true`(新楼层,视为全脏); + - 存在:对每个 `MapLayer`(按 `zIndex` 匹配)做全量比较, + 若所有行与参考数据完全一致则 `dirty = false`,否则 `dirty = true`。 + +## 4. 楼层的创建与管理 + +`MapStore` 内部以 `Map` 存储所有楼层。 +`getLayerState(id)` 对不存在的 id 直接返回 `null`,不自动创建。 + +只提供一个创建接口: + +- `createLayerState(id: string): ILayerState`:创建并注册一个空白楼层 + (无任何 `MapLayer`,用户拿到后再调用 `addLayer` 配置图层结构),返回楼层对象。 + +注册时若 id 已存在,发出 logger 警告并覆盖。 + +若 `compareWith` 已调用后再通过上述接口新增楼层,新楼层直接视为全脏(`dirty = true`), +因为 `refData` 中不存在对应数据。 + +## 5. 存档数据格式 + +### 类型定义 + +```ts +/** 单个 MapLayer 的存档数据 */ +interface IMapLayerSave { + readonly width: number; + readonly height: number; + /** + * key = 行索引,value = 该行完整的 Uint32Array 数据; + * NoCompression/LowCompression 时包含所有行(0 到 height - 1); + * HighCompression 时只包含与参考基准不同的行; + * 读档时,不在此 Map 中的行从参考基准还原。 + */ + readonly rows: ReadonlyMap; +} + +/** 单个楼层的存档数据 */ +interface ILayerStateSave { + readonly background: number; + /** + * key = zIndex,value = 对应图层存档数据; + * 使用 Map 格式以支持图层的动态增删。 + */ + readonly layers: ReadonlyMap; +} + +/** 整个 MapStore 的存档数据 */ +interface IMapStoreSave { + /** + * key = 楼层 id,只包含 active 的楼层; + * inactive 的楼层不写入,读档时无需处理。 + */ + readonly floors: ReadonlyMap; +} +``` + +### 各压缩等级存储策略 + +| 压缩级别 | 楼层粒度 | 行粒度 | +| ----------------- | --------------------------------------------------------------- | ------------------------ | +| `NoCompression` | 存储所有 active 楼层 | 存储该楼层所有行 | +| `LowCompression` | 跳过 `dirty = false` 的楼层;dirty 楼层全量比较后仍一致的也跳过 | 存储该楼层所有行 | +| `HighCompression` | 同 LowCompression | 只存储与参考基准不同的行 | + +### 读档策略 + +读档时直接操作数组引用(通过 `setMapRef`),避免逐行拷贝的额外开销: + +1. 若参考基准(`refData`)未设置,抛出 logger 错误,**不进行任何读档操作**; +2. 遍历 `state.floors`,对每个楼层 id: + - 若当前 `MapStore` 中不存在该 id,发出 logger 警告并跳过; + - 对该楼层每个图层,先从参考基准取出对应 `zIndex` 的数组, + 将其深拷贝为新数组作为底层(确保未存档行使用参考基准值); + - 再将 `ILayerStateSave.layers` 中对应图层的 `rows` 数据写入该数组的对应行; + - 调用 `MapLayer.setMapRef(array)` 直接替换内部引用,无需额外拷贝; +3. 对未出现在 `state.floors` 中的 active 楼层, + 从参考基准深拷贝完整数组后调用 `setMapRef` 还原,并将 `dirty` 置为 `false`。 + +## 6. saveState / loadState 实现 + +根据压缩等级分别编写三个存档函数和三个读档函数, +`saveState(compression)` 和 `loadState(state, compression)` 根据 `compression` 分发, +无需在每个楼层的遍历循环内部判断等级: + +- `private saveNoCompression(): IMapStoreSave` +- `private saveLowCompression(): IMapStoreSave` +- `private saveHighCompression(): IMapStoreSave` +- `private loadNoCompression(state: IMapStoreSave): void` +- `private loadLowCompression(state: IMapStoreSave): void` +- `private loadHighCompression(state: IMapStoreSave): void` + +`saveState` 结果需通过 `structuredClone` 深拷贝后返回。 + +## 7. IMapStore 接口设计(新增到 `map/types.ts`) + +```ts +interface IMapStore extends ISaveableContent { + /** 所有楼层的 id 集合 */ + readonly maps: ReadonlySet; + + // --- 楼层访问 --- + /** 获取指定 id 的楼层状态,不存在则返回 null */ + getLayerState(id: string): ILayerState | null; + /** 获取指定 id 的楼层状态,要求楼层必须是 active 的,否则返回 null */ + getActiveMap(id: string): ILayerState | null; + + // --- 楼层管理 --- + /** 创建并注册一个空白楼层,返回楼层状态对象 */ + createLayerState(id: string): ILayerState; + + // --- active 管理 --- + /** 获取指定 id 的楼层是否激活,不存在的 id 返回 false */ + isMapActive(id: string): boolean; + /** 设置指定 id 楼层的激活状态 */ + setMapActiveStatus(id: string, active: boolean): void; + /** 迭代所有 active 的楼层,yield [id, ILayerState] */ + iterateActiveMaps(): Iterable<[string, ILayerState]>; + /** 迭代所有 inactive 的楼层,yield [id, ILayerState] */ + iterateInactiveMaps(): Iterable<[string, ILayerState]>; + /** 迭代所有楼层,yield [id, ILayerState] */ + iterateAllMaps(): Iterable<[string, ILayerState]>; + + // --- 差分压缩基准 --- + /** + * 设置压缩参考基准,以首次调用为唯一基准,再次调用不更新。 + * @param ref 外层 key = 楼层 id,内层 key = zIndex,value = 图层完整图块数据 + */ + compareWith(ref: Map>): void; +} +``` + +## 8. ILayerState 接口修改 + +在现有 `ILayerState` 上新增: + +```ts +/** 此楼层是否处于激活状态 */ +readonly active: boolean; +/** 设置楼层激活状态 */ +setActiveStatus(active: boolean): void; +``` + +## 9. IStateBase 修改 + +将 `IStateBase.layer: ILayerState` 改为 `IStateBase.layer: IMapStore`。 + +--- + +# 涉及文件 + +## 需要引用的文件 + +- `@user/common/types.ts`: `ISaveableContent`, `SaveCompression` +- `@user/data-base/map/types.ts`: 全部现有地图接口(`IMapLayer`, `ILayerState`, 等) + +## 需要修改的文件 + +### `@user/data-base/src/map/types.ts` + +- [ ] 新增 `IMapLayerSave` 接口:单个 MapLayer 存档数据格式 +- [ ] 新增 `ILayerStateSave` 接口:单个楼层存档数据格式 +- [ ] 新增 `IMapStoreSave` 接口:MapStore 整体存档数据格式 +- [ ] 修改 `ILayerState`:新增 `readonly active: boolean` 和 + `setActiveStatus(active: boolean): void` +- [ ] 修改 `IMapLayer`:新增 `setMapRef(array: Uint32Array): void` +- [ ] 新增 `IMapStore` 接口:继承 `ISaveableContent`, + 含全部接口(见第 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`: + 在转发钩子的同时,将 `state.dirty` 置 `true` +- [ ] 新增 `isDirty(): boolean` 方法:返回 `this.dirty`,供 `MapStore` 读取 +- [ ] 新增 `setDirty(dirty: boolean): void` 方法: + 供 `MapStore.compareWith` 时根据实际比较结果设置 + +### `@user/data-base/src/map/mapLayer.ts` + +- [ ] 新增 `setMapRef(array: Uint32Array): void` 方法: + 直接替换内部图块数组引用,跳过拷贝,供 `MapStore` 读档时使用。 + 需确保传入数组长度与 `width × height` 匹配, + 并触发必要的钩子通知(不触发 `onResize`,应触发 `onUpdateArea` 通知全区域更新)。 + 在方法注释中明确标注:调用后不得再持有或修改传入的数组。 + +### `@user/data-base/src/map/mapStore.ts`(新文件) + +- [ ] 实现 `MapStore` 类,实现 `IMapStore` +- [ ] `private mapData: Map`:楼层 id 到状态对象的映射 +- [ ] `readonly maps: ReadonlySet`:所有楼层 id 的只读集合视图 +- [ ] `private refData: Map> | null`:参考基准 +- [ ] 实现 `getLayerState`、`getActiveMap`、`createLayerState` +- [ ] 实现 `isMapActive`、`setMapActiveStatus`、`iterateActiveMaps`、`iterateInactiveMaps`、`iterateAllMaps` +- [ ] 实现 `compareWith` +- [ ] 实现 `saveNoCompression`、`saveLowCompression`、`saveHighCompression` +- [ ] 实现 `loadNoCompression`、`loadLowCompression`、`loadHighCompression` +- [ ] 实现 `saveState(compression)` 和 `loadState(state, compression)` 分发 + +### `@user/data-base/src/map/index.ts` + +- [ ] 补充导出 `mapStore.ts` + +### `@user/data-base/src/types.ts` + +- [ ] 将 `IStateBase.layer` 类型由 `ILayerState` 改为 `IMapStore` diff --git a/packages-user/data-base/src/enemy/manager.ts b/packages-user/data-base/src/enemy/manager.ts index 915bd56..b3ad7c1 100644 --- a/packages-user/data-base/src/enemy/manager.ts +++ b/packages-user/data-base/src/enemy/manager.ts @@ -10,7 +10,7 @@ import { SpecialCreation, IEnemySaveState } from './types'; -import { SaveCompression } from '../common/types'; +import { SaveCompression } from '../common'; export class EnemyManager implements IEnemyManager { /** 特殊属性注册表,code -> 创建函数 */ diff --git a/packages-user/data-base/src/map/index.ts b/packages-user/data-base/src/map/index.ts index 1f9d7d5..8a6081a 100644 --- a/packages-user/data-base/src/map/index.ts +++ b/packages-user/data-base/src/map/index.ts @@ -1,3 +1,4 @@ export * from './layerState'; export * from './mapLayer'; +export * from './mapStore'; export * from './types'; diff --git a/packages-user/data-base/src/map/layerState.ts b/packages-user/data-base/src/map/layerState.ts index d6c8d24..37f4359 100644 --- a/packages-user/data-base/src/map/layerState.ts +++ b/packages-user/data-base/src/map/layerState.ts @@ -29,6 +29,12 @@ export class LayerState /** 图层钩子映射 */ private layerHookMap: Map = new Map(); + /** 楼层是否处于激活状态 */ + active: boolean = false; + + /** 楼层级脏标记 */ + private dirty: boolean = false; + addLayer(width: number, height: number): IMapLayer { const array = new Uint32Array(width * height); const layer = new MapLayer(array, width, height); @@ -106,6 +112,18 @@ export class LayerState return this.backgroundTile; } + setActiveStatus(active: boolean): void { + this.active = active; + } + + isDirty(): boolean { + return this.dirty; + } + + setDirty(dirty: boolean): void { + this.dirty = dirty; + } + protected createController( hook: Partial ): IHookController { @@ -120,18 +138,21 @@ class StateMapLayerHook implements Partial { ) {} onUpdateArea(x: number, y: number, width: number, height: number): void { + this.state.setDirty(true); this.state.forEachHook(hook => { hook.onUpdateLayerArea?.(this.layer, x, y, width, height); }); } onUpdateBlock(block: number, x: number, y: number): void { + this.state.setDirty(true); this.state.forEachHook(hook => { hook.onUpdateLayerBlock?.(this.layer, block, x, y); }); } onResize(width: number, height: number): void { + this.state.setDirty(true); this.state.forEachHook(hook => { hook.onResizeLayer?.(this.layer, width, height); }); diff --git a/packages-user/data-base/src/map/mapLayer.ts b/packages-user/data-base/src/map/mapLayer.ts index a1a7879..326c4cf 100644 --- a/packages-user/data-base/src/map/mapLayer.ts +++ b/packages-user/data-base/src/map/mapLayer.ts @@ -186,14 +186,12 @@ export class MapLayer } const res = new Uint32Array(width * height); const arr = this.mapArray; - const nr = Math.min(r, w); const nb = Math.min(b, h); - for (let nx = x; nx < nr; nx++) { - for (let ny = y; ny < nb; ny++) { - const origin = ny * w + nx; - const target = (ny - y) * width + (nx - x); - res[target] = arr[origin]; - } + for (let ny = y; ny < nb; ny++) { + const lineStart = ny * w + x; + const lineEnd = lineStart + width; + const dy = ny - y; + res.set(arr.subarray(lineStart, lineEnd), dy * width); } return res; } @@ -205,6 +203,27 @@ export class MapLayer return this.mapData; } + setMapRef(array: Uint32Array): void { + if (array.length !== this.width * this.height) { + logger.warn( + 123, + array.length.toString(), + (this.width * this.height).toString() + ); + return; + } + this.mapData.expired = true; + this.mapArray = array; + this.mapData = { + expired: false, + array: this.mapArray + }; + this.empty = !array.some(v => v !== 0); + this.forEachHook(hook => { + hook.onUpdateArea?.(0, 0, this.width, this.height); + }); + } + protected createController( hook: Partial ): IMapLayerHookController { diff --git a/packages-user/data-base/src/map/mapStore.ts b/packages-user/data-base/src/map/mapStore.ts new file mode 100644 index 0000000..19e1fe8 --- /dev/null +++ b/packages-user/data-base/src/map/mapStore.ts @@ -0,0 +1,383 @@ +import { logger } from '@motajs/common'; +import { SaveCompression } from '../common'; +import { + ILayerState, + ILayerStateSave, + IMapLayer, + IMapLayerSave, + IMapStore, + IMapStoreSave +} from './types'; +import { LayerState } from './layerState'; + +export class MapStore implements IMapStore { + /** 楼层 id 到状态对象的映射 */ + private readonly mapData: Map = new Map(); + + /** 所有楼层 id 的只读集合视图 */ + readonly maps: Set = new Set(); + + /** 差分压缩参考基准,首次 compareWith 后设置,之后不再更新 */ + private refData: Map> | null = null; + + //#region 楼层访问 + + getLayerState(id: string): ILayerState | null { + return this.mapData.get(id) ?? null; + } + + getActiveMap(id: string): ILayerState | null { + const state = this.mapData.get(id); + if (!state || !state.active) return null; + return state; + } + + //#endregion + + //#region 楼层管理 + + createLayerState(id: string): ILayerState { + if (this.mapData.has(id)) { + logger.warn(121, id); + } + const state = new LayerState(); + // 若 refData 已存在,新楼层直接视为全脏 + if (this.refData !== null) { + state.setDirty(true); + } + this.mapData.set(id, state); + this.maps.add(id); + return state; + } + + //#endregion + + //#region active 管理 + + isMapActive(id: string): boolean { + return this.mapData.get(id)?.active ?? false; + } + + setMapActiveStatus(id: string, active: boolean): void { + this.mapData.get(id)?.setActiveStatus(active); + } + + *iterateActiveMaps(): Iterable<[string, ILayerState]> { + for (const [id, state] of this.mapData) { + if (state.active) yield [id, state]; + } + } + + *iterateInactiveMaps(): Iterable<[string, ILayerState]> { + for (const [id, state] of this.mapData) { + if (!state.active) yield [id, state]; + } + } + + iterateAllMaps(): Iterable<[string, ILayerState]> { + return this.mapData; + } + + //#endregion + + //#region 存档及压缩 + + compareWith(ref: Map>): void { + if (this.refData !== null) return; + this.refData = ref; + + for (const [id, state] of this.mapData) { + const refFloor = ref.get(id); + if (!refFloor) { + state.setDirty(true); + continue; + } + let dirty = false; + for (const layer of state.layerList) { + const refArray = refFloor.get(layer.zIndex); + if (!refArray) { + dirty = true; + break; + } + const cur = layer.getMapRef().array; + if (cur.length !== refArray.length) { + dirty = true; + break; + } + if (cur.some((v, i) => refArray[i] !== v)) { + dirty = true; + break; + } + } + state.setDirty(dirty); + } + } + + private saveNoCompression(): IMapStoreSave { + const floors = new Map(); + for (const [id, state] of this.mapData) { + if (!state.active) continue; + floors.set(id, this.saveLayerStateFull(state)); + } + return { floors }; + } + + private saveLowCompression(): IMapStoreSave { + const floors = new Map(); + 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)); + } + } + return { floors }; + } + + private saveHighCompression(): IMapStoreSave { + const floors = new Map(); + for (const [id, state] of this.mapData) { + if (!state.active) continue; + if (!state.isDirty()) { + floors.set(id, { + background: state.getBackground(), + layers: new Map() + }); + continue; + } + const refFloor = this.refData?.get(id); + const layersMap = new Map(); + 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 }; + } + + /** + * NoCompression 读档:每个图层均有 fullMap,直接转移所有权,无需参考基准。 + */ + 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) { + const cur = this.mapData.get(id); + if (!cur) { + logger.warn(122, id); + continue; + } + cur.setBackground(layerStateSave.background); + for (const layer of cur.layerList) { + const layerSave = layerStateSave.layers.get(layer.zIndex); + if (!layerSave?.fullMap) continue; + layer.setMapRef(new Uint32Array(layerSave.fullMap)); + } + cur.setDirty(false); + } + } + + /** + * LowCompression 读档: + * - layers 有数据(dirty 楼层)→ fullMap 直接转移所有权 + * - layers 为空(非 dirty 楼层)→ 从参考基准恢复 + */ + private loadLowCompression(state: IMapStoreSave): void { + if (!this.refData) { + logger.error(55); + return; + } + for (const [id, cur] of this.mapData) { + cur.setActiveStatus(state.floors.has(id)); + } + for (const [id, layerStateSave] of state.floors) { + const cur = this.mapData.get(id); + const refFloor = this.refData.get(id); + if (!cur) { + logger.warn(122, id); + continue; + } + if (!refFloor) { + logger.warn(124, id); + continue; + } + cur.setBackground(layerStateSave.background); + for (const layer of cur.layerList) { + const layerSave = layerStateSave.layers.get(layer.zIndex); + if (layerSave?.fullMap) { + layer.setMapRef(layerSave.fullMap); + } else { + const refArray = refFloor?.get(layer.zIndex); + if (!refArray) { + logger.warn(124, id); + return; + } + layer.setMapRef(new Uint32Array(refArray)); + } + } + cur.setDirty(false); + } + } + + /** + * HighCompression 读档: + * - layers 有数据(dirty 楼层)→ 以参考基准为底,叠加差分行 + * - layers 为空(非 dirty 楼层)或图层无变化(rows 缺失)→ 从参考基准恢复 + */ + private loadHighCompression(state: IMapStoreSave): void { + if (!this.refData) { + logger.error(55); + return; + } + for (const [id, cur] of this.mapData) { + cur.setActiveStatus(state.floors.has(id)); + } + for (const [id, layerStateSave] of state.floors) { + const cur = this.mapData.get(id); + const refFloor = this.refData.get(id); + if (!cur) { + logger.warn(122, id); + continue; + } + if (!refFloor) { + logger.warn(124, id); + continue; + } + cur.setBackground(layerStateSave.background); + let isMapDirty = true; + 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); + 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)); + for (const [rowIdx, rowData] of layerSave.rows) { + buf.set( + rowData.subarray(0, layer.width), + rowIdx * layer.width + ); + } + layer.setMapRef(buf); + } + } + cur.setDirty(isMapDirty); + } + } + + saveState(compression: SaveCompression): IMapStoreSave { + if (compression === SaveCompression.HighCompression) { + return this.saveHighCompression(); + } else if (compression === SaveCompression.LowCompression) { + return this.saveLowCompression(); + } else { + return this.saveNoCompression(); + } + } + + loadState(state: IMapStoreSave, compression: SaveCompression): void { + if (compression === SaveCompression.HighCompression) { + this.loadHighCompression(state); + } else if (compression === SaveCompression.LowCompression) { + this.loadLowCompression(state); + } else { + this.loadNoCompression(state); + } + } + + //#region 内部方法 + + /** + * 将楼层所有图层全量序列化(NoCompression / LowCompression 用) + */ + private saveLayerStateFull(state: LayerState): ILayerStateSave { + const layersMap = new Map(); + 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 { + const rows = new Map(); + 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 +} diff --git a/packages-user/data-base/src/map/types.ts b/packages-user/data-base/src/map/types.ts index a702edb..7f959b3 100644 --- a/packages-user/data-base/src/map/types.ts +++ b/packages-user/data-base/src/map/types.ts @@ -1,4 +1,5 @@ import { IHookable, IHookBase, IHookController } from '@motajs/common'; +import { ISaveableContent } from '../common'; export interface IMapLayerData { /** 当前引用是否过期,当地图图层内部的地图数组引用更新时,此项会变为 `true` */ @@ -48,8 +49,7 @@ export interface IMapLayerHooks extends IHookBase { onCloseDoor(num: number, x: number, y: number): Promise; } -export interface IMapLayerHookController - extends IHookController { +export interface IMapLayerHookController extends IHookController { /** 拓展所属的图层对象 */ readonly layer: IMapLayer; @@ -59,8 +59,10 @@ export interface IMapLayerHookController getMapData(): Readonly; } -export interface IMapLayer - extends IHookable { +export interface IMapLayer extends IHookable< + IMapLayerHooks, + IMapLayerHookController +> { /** 地图宽度 */ readonly width: number; /** 地图高度 */ @@ -155,6 +157,15 @@ export interface IMapLayer * @param y 门纵坐标 */ closeDoor(num: number, x: number, y: number): Promise; + + /** + * 直接替换内部图块数组引用,跳过拷贝,高性能但风险较高。 + * 一般仅供 `MapStore` 读档时内部使用,外部正常情况下不应调用。 + * 调用方需确保传入数组的长度与 `width * height` 匹配, + * 且调用后不得再持有或修改传入的数组。 + * @param array 地图数组,会直接替换内部引用 + */ + setMapRef(array: Uint32Array): void; } export interface ILayerStateHooks extends IHookBase { @@ -212,6 +223,8 @@ export interface ILayerStateHooks extends IHookBase { export interface ILayerState extends IHookable { /** 地图列表 */ readonly layerList: Set; + /** 此楼层是否处于激活状态 */ + readonly active: boolean; /** * 添加图层 @@ -275,4 +288,107 @@ export interface ILayerState extends IHookable { * 获取背景图块数字,如果没有设置过,则返回 0 */ getBackground(): number; + + /** + * 设置楼层激活状态 + * @param active 激活状态 + */ + setActiveStatus(active: boolean): void; + + /** + * 楼层是否被修改过(相对于参考基准) + */ + isDirty(): boolean; + + /** + * 设置楼层脏标记 + */ + setDirty(dirty: boolean): void; +} + +/** 单个 MapLayer 的存档数据 */ +export interface IMapLayerSave { + readonly width: number; + readonly height: number; + + /** + * key = 行索引,value = 该行完整的 Uint32Array 数据; + * HighCompression 时使用此接口,仅包含与参考基准不同的行; + * 读档时,不在此 Map 中的行从参考基准还原。 + */ + readonly rows?: ReadonlyMap; + + /** 完整地图,当使用 `NoCompression` 和 `LowCompression` 时使用此接口 */ + readonly fullMap?: Uint32Array; +} + +/** 单个楼层的存档数据 */ +export interface ILayerStateSave { + readonly background: number; + + /** key = zIndex,value = 对应图层存档数据 */ + readonly layers: ReadonlyMap; +} + +/** 整个 MapStore 的存档数据 */ +export interface IMapStoreSave { + /** key = 楼层 id,只包含 active 的楼层,inactive 的楼层不写入,读档时无需处理 */ + readonly floors: ReadonlyMap; +} + +export interface IMapStore extends ISaveableContent { + /** 所有楼层的 id 集合 */ + readonly maps: ReadonlySet; + + /** + * 获取指定 id 的楼层状态,不存在则返回 null + * @param id 楼层 id + */ + getLayerState(id: string): ILayerState | null; + + /** + * 获取指定 id 的楼层状态,要求楼层必须是 active 的,否则返回 null + * @param id 楼层 id + */ + getActiveMap(id: string): ILayerState | null; + + /** + * 创建并注册一个空白楼层,若 id 已存在则警告并覆盖,返回楼层状态对象 + * @param id 楼层 id + */ + createLayerState(id: string): ILayerState; + + /** + * 获取指定 id 的楼层是否激活,不存在的 id 返回 false + * @param id 楼层 id + */ + isMapActive(id: string): boolean; + + /** + * 设置指定 id 楼层的激活状态 + * @param id 楼层 id + * @param active 激活状态 + */ + setMapActiveStatus(id: string, active: boolean): void; + + /** + * 迭代所有 active 的楼层,yield [id, ILayerState] + */ + iterateActiveMaps(): Iterable<[string, ILayerState]>; + + /** + * 迭代所有 inactive 的楼层,yield [id, ILayerState] + */ + iterateInactiveMaps(): Iterable<[string, ILayerState]>; + + /** + * 迭代所有楼层,yield [id, ILayerState] + */ + iterateAllMaps(): Iterable<[string, ILayerState]>; + + /** + * 设置压缩参考基准,以首次调用为唯一基准,再次调用不更新。 + * @param ref 外层 key = 楼层 id,内层 key = zIndex,value = 图层完整图块数据 + */ + compareWith(ref: Map>): void; } diff --git a/packages-user/data-base/src/types.ts b/packages-user/data-base/src/types.ts index bd52a39..9c4b160 100644 --- a/packages-user/data-base/src/types.ts +++ b/packages-user/data-base/src/types.ts @@ -2,7 +2,7 @@ import { IHeroFollower, IHeroState } from './hero'; import { IEnemyManager } from './enemy'; import { IFlagSystem } from './flag'; import { IRoleFaceBinder, ISaveableContent } from './common'; -import { ILayerState } from './map'; +import { IMapStore } from './map'; export interface IStateSaveData { /** 跟随者列表 */ @@ -18,7 +18,7 @@ export interface IStateBase { readonly numberIdMap: Map; /** 地图状态 */ - readonly layer: ILayerState; + readonly layer: IMapStore; /** 勇士状态 */ readonly hero: IHeroState; diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 2028acd..e9b8dcd 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -54,6 +54,7 @@ "52": "To get divider payload, an excitation binding is expected.", "53": "Expected serializable value set as enemy's default attribute.", "54": "Legacy '$1' API has been removed, consider using new APIs: '$2'.", + "55": "Cannot load MapStore state: reference data (compareWith) has not been set.", "1201": "Floor-damage extension needs 'floor-binder' extension as dependency." }, "warn": { @@ -177,6 +178,10 @@ "118": "No enemy comparer attached to EnemyManager. All enemies will be treated as dirty.", "119": "Enemy prefab with code $1 not found during loadState, skipping.", "120": "Special with code $1 not found in enemy '$2' during loadState, skipping.", + "121": "MapStore.createLayerState: floor '$1' already exists, the existing floor will be overwritten.", + "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.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency." } } From c43bfa2ab0e7dcdd22285447609e68e566ebf5f5 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sun, 26 Apr 2026 15:31:27 +0800 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20=E5=9C=B0=E5=9B=BE=E5=AD=98?= =?UTF-8?q?=E6=A1=A3=E7=9A=84=E5=8A=A0=E8=BD=BD=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages-user/client-modules/src/client.ts | 4 +- .../client-modules/src/render/ui/main.tsx | 2 +- packages-user/data-base/src/types.ts | 2 +- packages-user/data-state/src/core.ts | 127 ++++++++++++++---- packages-user/data-state/src/enemy/index.ts | 1 + packages-user/data-state/src/index.ts | 18 --- packages-user/data-state/src/save/index.ts | 1 + packages-user/data-state/src/shared.ts | 17 +++ packages-user/data-state/src/types.ts | 3 +- .../legacy-plugin-data/src/fallback.ts | 8 +- 10 files changed, 130 insertions(+), 53 deletions(-) diff --git a/packages-user/client-modules/src/client.ts b/packages-user/client-modules/src/client.ts index 4a2945e..14b538a 100644 --- a/packages-user/client-modules/src/client.ts +++ b/packages-user/client-modules/src/client.ts @@ -114,7 +114,7 @@ export class ClientCore implements IClientCore { // 使用分频器,用户可以在设置中调整,如果设备性能较差调高分频有助于提高性能表现 excitaion: excitationDivider }); - this.mainMapRenderer = new MapRenderer(this.materials, data.layer); + this.mainMapRenderer = new MapRenderer(this.materials, data.maps); this.mainMapExtension = new MapExtensionManager(this.mainMapRenderer); // 兼容层 @@ -133,7 +133,7 @@ export class ClientCore implements IClientCore { await this.materials.trackedAsset.then(); this.mainMapRenderer.useAsset(this.materials.trackedAsset); - const layer = this.data.layer.getLayerByAlias('event'); + const layer = this.data.maps.getLayerByAlias('event'); if (layer) { this.mainMapExtension.addHero(this.data.hero.mover, layer); this.mainMapExtension.addDoor(layer); diff --git a/packages-user/client-modules/src/render/ui/main.tsx b/packages-user/client-modules/src/render/ui/main.tsx index 40a77a5..f46e26a 100644 --- a/packages-user/client-modules/src/render/ui/main.tsx +++ b/packages-user/client-modules/src/render/ui/main.tsx @@ -243,7 +243,7 @@ const MainScene = defineComponent(() => { > diff --git a/packages-user/data-base/src/types.ts b/packages-user/data-base/src/types.ts index 9c4b160..673f5f4 100644 --- a/packages-user/data-base/src/types.ts +++ b/packages-user/data-base/src/types.ts @@ -18,7 +18,7 @@ export interface IStateBase { readonly numberIdMap: Map; /** 地图状态 */ - readonly layer: IMapStore; + readonly maps: IMapStore; /** 勇士状态 */ readonly hero: IHeroState; diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index d1eaa10..4b3b325 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -16,16 +16,14 @@ import { MotaDataLoader, loading, IRoleFaceBinder, - ILayerState, - LayerState, RoleFaceBinder, FaceDirection, ISaveableContent, - IStateSaveData, SaveCompression, - IReadonlyEnemy + IReadonlyEnemy, + IMapStore, + MapStore } from '@user/data-base'; -import { IEnemyAttr } from './enemy'; import { CommonAuraConverter, EnemyLegacyBridge, @@ -34,16 +32,25 @@ import { MainEnemyFinalEffect, MainMapDamageConverter, MainMapDamageReducer, - registerSpecials + registerSpecials, + MainEnemyComparer, + IEnemyAttr } from './enemy'; -import { HERO_DEFAULT_ATTRIBUTE, TILE_HEIGHT, TILE_WIDTH } from './shared'; +import { + BG2_ZINDEX, + BG_ZINDEX, + EVENT_ZINDEX, + FG2_ZINDEX, + FG_ZINDEX, + HERO_DEFAULT_ATTRIBUTE, + TILE_HEIGHT, + TILE_WIDTH +} from './shared'; import { IHeroAttr } from './hero'; import { ILoadProgressTotal, LoadProgressTotal } from '@motajs/loader'; import { isNil } from 'lodash-es'; import { logger } from '@motajs/common'; -import { ISaveSystem } from './save'; -import { SaveSystem } from './save/system'; -import { MainEnemyComparer } from './enemy/comparer'; +import { ISaveSystem, SaveSystem } from './save'; export class CoreState implements ICoreState { // 全局内容 @@ -52,7 +59,7 @@ export class CoreState implements ICoreState { readonly numberIdMap: Map; // 可存档内容 - readonly layer: ILayerState; + readonly maps: IMapStore; readonly hero: IHeroState; readonly enemyManager: IEnemyManager; readonly flags: IFlagSystem; @@ -74,7 +81,7 @@ export class CoreState implements ICoreState { > = new Map(); constructor() { - this.layer = new LayerState(); + this.maps = new MapStore(); this.roleFace = new RoleFaceBinder(); this.idNumberMap = new Map(); this.numberIdMap = new Map(); @@ -149,9 +156,16 @@ export class CoreState implements ICoreState { // 加载先使用兼容层实现 loading.once('loaded', () => { this.initEnemyManager(enemys_fcae963b_31c9_42b4_b48c_bb48d09f3f80); + this.initMapStore( + core.floorIds, + core.floors as Record + ); }); - this.addSaveableContent('flags', this.flags); + this.addSaveableContent('@system/hero', this.hero); + this.addSaveableContent('@system/flags', this.flags); + this.addSaveableContent('@system/maps', this.maps); + this.addSaveableContent('@system/enemy', this.enemyManager); //#endregion } @@ -193,12 +207,86 @@ export class CoreState implements ICoreState { manager.compareWith(reference); } + private initMapStore( + floors: FloorIds[], + data: Record + ) { + const reference = new Map>(); + 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); + bg.setZIndex(BG_ZINDEX); + bg2.setZIndex(BG2_ZINDEX); + event.setZIndex(EVENT_ZINDEX); + fg.setZIndex(FG_ZINDEX); + fg2.setZIndex(FG2_ZINDEX); + state.setLayerAlias(bg, 'bg'); + state.setLayerAlias(bg2, 'bg2'); + state.setLayerAlias(event, 'event'); + state.setLayerAlias(fg, 'fg'); + state.setLayerAlias(fg2, 'fg2'); + state.setActiveStatus(false); + + const size = floor.width * floor.height; + const ref = new Map(); + + if (floor.bgmap && floor.bgmap.length > 0) { + const arr = new Uint32Array(floor.bgmap.flat()); + bg.setMapRef(arr); + ref.set(BG_ZINDEX, new Uint32Array(arr)); + } else { + ref.set(BG_ZINDEX, new Uint32Array(size)); + } + + if (floor.bg2map && floor.bg2map.length > 0) { + const arr = new Uint32Array(floor.bg2map.flat()); + bg2.setMapRef(arr); + ref.set(BG2_ZINDEX, new Uint32Array(arr)); + } else { + ref.set(BG2_ZINDEX, new Uint32Array(size)); + } + + if (floor.map && floor.map.length > 0) { + const arr = new Uint32Array(floor.map.flat()); + event.setMapRef(arr); + ref.set(EVENT_ZINDEX, new Uint32Array(arr)); + } else { + ref.set(EVENT_ZINDEX, new Uint32Array(size)); + } + + if (floor.fgmap && floor.fgmap.length > 0) { + const arr = new Uint32Array(floor.fgmap.flat()); + fg.setMapRef(arr); + ref.set(FG_ZINDEX, new Uint32Array(arr)); + } else { + ref.set(FG_ZINDEX, new Uint32Array(size)); + } + + if (floor.fg2map && floor.fg2map.length > 0) { + const arr = new Uint32Array(floor.fg2map.flat()); + fg2.setMapRef(arr); + ref.set(FG2_ZINDEX, new Uint32Array(arr)); + } else { + ref.set(FG2_ZINDEX, new Uint32Array(size)); + } + + reference.set(id, ref); + } + this.maps.compareWith(reference); + } + addSaveableContent(id: string, content: ISaveableContent): void { if (this.saveables.has(id)) { logger.warn(112, id); return; } this.saveables.set(id, content); + this.addedSaveables.add(content); } getSaveableContent(id: string): ISaveableContent | null { @@ -222,17 +310,4 @@ export class CoreState implements ICoreState { this.executors.set(content, executor); } } - - saveState(): IStateSaveData { - return structuredClone({ - followers: this.hero.mover.followers - }); - } - - loadState(data: IStateSaveData): void { - this.hero.mover.removeAllFollowers(); - data.followers.forEach(v => { - this.hero.mover.addFollower(v.num, v.identifier); - }); - } } diff --git a/packages-user/data-state/src/enemy/index.ts b/packages-user/data-state/src/enemy/index.ts index 5998ef6..ae9c5d4 100644 --- a/packages-user/data-state/src/enemy/index.ts +++ b/packages-user/data-state/src/enemy/index.ts @@ -1,5 +1,6 @@ export * from './aura'; export * from './calculator'; +export * from './comparer'; export * from './damage'; export * from './final'; export * from './legacy'; diff --git a/packages-user/data-state/src/index.ts b/packages-user/data-state/src/index.ts index bc01750..2cdc90c 100644 --- a/packages-user/data-state/src/index.ts +++ b/packages-user/data-state/src/index.ts @@ -1,27 +1,9 @@ import { FaceDirection, loading } from '@user/data-base'; import { isNil } from 'lodash-es'; import { ICoreState } from './types'; -import { TILE_HEIGHT, TILE_WIDTH } from './shared'; import { state } from './ins'; function createCoreState(state: ICoreState) { - //#region 地图部分 - - const width = TILE_WIDTH; - const height = TILE_HEIGHT; - const bg = state.layer.addLayer(width, height); - const bg2 = state.layer.addLayer(width, height); - const event = state.layer.addLayer(width, height); - const fg = state.layer.addLayer(width, height); - const fg2 = state.layer.addLayer(width, height); - state.layer.setLayerAlias(bg, 'bg'); - state.layer.setLayerAlias(bg2, 'bg2'); - state.layer.setLayerAlias(event, 'event'); - state.layer.setLayerAlias(fg, 'fg'); - state.layer.setLayerAlias(fg2, 'fg2'); - - //#endregion - //#region 图块部分 const data = Object.entries(core.maps.blocksInfo); diff --git a/packages-user/data-state/src/save/index.ts b/packages-user/data-state/src/save/index.ts index fcb073f..213e667 100644 --- a/packages-user/data-state/src/save/index.ts +++ b/packages-user/data-state/src/save/index.ts @@ -1 +1,2 @@ +export * from './system'; export * from './types'; diff --git a/packages-user/data-state/src/shared.ts b/packages-user/data-state/src/shared.ts index 33d5ebb..0f4b609 100644 --- a/packages-user/data-state/src/shared.ts +++ b/packages-user/data-state/src/shared.ts @@ -1,10 +1,27 @@ import { IHeroAttr } from './hero'; +//#region 地图相关 + /** 每个地图的默认宽度 */ export const TILE_WIDTH = 13; /** 每个地图的默认高度 */ export const TILE_HEIGHT = 13; +// 图层纵深,这些纵深与渲染系统的纵深没有关系,仅在地图图层之间生效 + +/** 背景层纵深 */ +export const BG_ZINDEX = 0; +/** 背景层2纵深 */ +export const BG2_ZINDEX = 10; +/** 事件层纵深 */ +export const EVENT_ZINDEX = 20; +/** 前景层纵深 */ +export const FG_ZINDEX = 30; +/** 前景层2纵深 */ +export const FG2_ZINDEX = 40; + +//#endregion + //#region 勇士相关 /** 默认的勇士图片 */ diff --git a/packages-user/data-state/src/types.ts b/packages-user/data-state/src/types.ts index 318816e..5ef2e96 100644 --- a/packages-user/data-state/src/types.ts +++ b/packages-user/data-state/src/types.ts @@ -30,7 +30,8 @@ export interface ICoreState extends IStateBase { readonly saveSystem: ISaveSystem; /** - * 将某个存档执行器绑定至指定的可存档对象,一个可存档对象只能绑定一个执行器,但一个执行器可以绑定多个可存档对象 + * 将某个存档执行器绑定至指定的可存档对象,一个可存档对象只能绑定一个执行器, + * 但一个执行器可以绑定多个可存档对象,主要用来在读档后进行一些全局性的操作 * @param content 可存档对象或其注册 id * @param executor 可存档对象对应的执行器 */ diff --git a/packages-user/legacy-plugin-data/src/fallback.ts b/packages-user/legacy-plugin-data/src/fallback.ts index 1271d38..4cb8edf 100644 --- a/packages-user/legacy-plugin-data/src/fallback.ts +++ b/packages-user/legacy-plugin-data/src/fallback.ts @@ -324,7 +324,7 @@ export function initFallback() { callback?.(); }; - const layer = state.layer.getLayerByAlias('event')!; + const layer = state.maps.getLayerByAlias('event')!; layer.openDoor(x, y).then(cb); const animate = fallbackIds++; @@ -373,7 +373,7 @@ export function initFallback() { cb(); } else { const num = state.idNumberMap.get(id)!; - const layer = state.layer.getLayerByAlias('event')!; + const layer = state.maps.getLayerByAlias('event')!; layer.closeDoor(num, x, y).then(cb); const animate = fallbackIds++; @@ -514,11 +514,11 @@ export function initFallback() { // 先使用 mainMapRenderer 妥协 const { client } = Mota.require('@user/client-modules'); const renderer = client.mainMapRenderer; - if (renderer.layerState !== state.layer) { + if (renderer.layerState !== state.maps) { callback?.(); return; } - const layer = state.layer.getLayerByAlias('event'); + const layer = state.maps.getLayerByAlias('event'); if (!layer) { callback?.(); return; From a01caba0c83070cfc8a556c6ffecc30f386e23ec Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 6 May 2026 19:46:54 +0800 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20=E4=BC=98=E5=8C=96=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- dev.md | 126 ++++++++++++++++++++++++++++++++---------------------- prompt.md | 34 ++++++++------- 2 files changed, 92 insertions(+), 68 deletions(-) diff --git a/dev.md b/dev.md index 50b1504..19d9c08 100644 --- a/dev.md +++ b/dev.md @@ -2,12 +2,12 @@ ## 项目结构 -`public`: mota-js 样板所在目录。 -`packages`: 核心引擎代码 monorepo。 -`packages-user`: 用户代码 monorepo。 -`src`: 游戏入口代码。 +- `public`: mota-js 样板所在目录 +- `packages`: 核心引擎代码 monorepo +- `packages-user`: 用户代码 monorepo +- `src`: 游戏入口代码 -`packages` `packages-user` 可以单独打包为库模式,`src` 单向引用 `packages-user`,`packages-user` 单向引用 `packages`,`src` 为游戏的入口代码。 +依赖关系为单向:`src` → `packages-user` → `packages`。`packages` 与 `packages-user` 均可独立打包为库模式,`src` 为游戏的入口代码。 ## 开发环境 @@ -15,66 +15,88 @@ - `pnpm >= 10.0.0` - 任意支持 `ESNext` 特性的浏览器 -**建议使用 `vscode`,搭配 `prettier` `eslint` 插件** +**建议使用 `vscode`,搭配 `prettier` `eslint` 插件。** ## 开发说明 1. 将项目拉取到本地。 -2. 运行 `pnpm i` 安装所有依赖,如有要求运行 `pnpm approve-builds`,请允许全部。 +2. 运行 `pnpm i` 安装所有依赖(如提示运行 `pnpm approve-builds`,请允许全部)。 3. 运行 `pnpm dev` 进入开发环境。 ## 构建说明 -- `pnpm build:packages`: 构建所有 `packages` 文件夹下的内容,使用库模式。 -- `pnpm build:game`: 构建为可以直接部署的构建包。 -- `pnpm build:lib`: 构建所有 `packages` `packages-user` 文件夹下的内容,使用库模式。 -- `pnpm type`: 对仓库执行类型检查 -- `pnpm check:circular`: 对仓库执行循环引用检查 +| 命令 | 说明 | +| --------------------- | ------------------------------------------------------- | +| `pnpm build:packages` | 以库模式构建 `packages` 下的所有内容 | +| `pnpm build:game` | 构建为可直接部署的游戏包 | +| `pnpm build:lib` | 以库模式构建 `packages` 与 `packages-user` 下的所有内容 | +| `pnpm type` | 对仓库执行类型检查 | +| `pnpm check:circular` | 对仓库执行循环引用检查 | ## 开发原则 -- 模块原则: - - 无副作用原则:所有模块不包含副作用内容,全部由函数、类、常量的声明组成,不出现导出的变量声明、代码执行内容,允许但不建议编写类的静态块。 - - 如果需要模块初始化,编写一个 `createXxx` 函数,然后在 `index.ts` 中整合,再逐级向上传递,直至遇到包含 `create` 函数的 `index.ts`,所有初始化将会统一在顶层模块中执行。 - - 不允许一个文件导出不属于当前 `monorepo` 或当前文件夹的内容。 - - 不允许出现循环引用,如果不得不进行循环引用,应当首先考虑接口设计是否有问题。 -- 命名规则: - - 变量、成员、一般常量、方法、函数使用小驼峰。 - - 类、接口、类型别名、命名空间、泛型、枚举、组件使用大驼峰。 - - 不变常量使用全大写命名法,单词之间使用下划线连接。 - - 专有名词缩写如 `HTTP`, `URI` 全部大写。 - - 会被 `implements` 的接口使用大写 `I` 开头。 - - `id`, `class` 等 `HTML/CSS` 内容使用连字符命名法。 - - 不使用下划线命名法。 -- 注释: - - 常用属性成员、方法、接口、类型必须添加 `jsDoc` 注释。 - - 长文件可使用 `#region` 分段,可以写上 `#endretion` 允许折叠。 - - TODO 使用 `// TODO:` 或 `// todo:` 格式。 - - 单行注释的双斜杠与注释内容之间添加一个空格,多行注释只允许出现 `jsDoc` 注释,如果需要多行非 `jsDoc` 注释,使用多个单行注释。 - - 注释进行合理换行,考虑到中文字符较宽,建议 40-60 个字符进行换行。不允许在句中换行,必须在标点符号后换行。参数注释换行后保持对齐。 - - 单行注释结尾不添加句号,对于多行长注释,可以在结尾添加句号。 -- 类型: - - 不允许出现非必要的 `any` 类型。 - - 所有类的成员必须显式声明类型。 - - 如果有无法避免出现类型错误的地方,使用 `// @ts-expect-error` 标记,并填写原因。 - - 没用到的变量、方法使用下划线开头。 - - 合理运用 `readonly` `protected` `private` 关键字。 - - 函数不建议使用过多可选参数,如果可选参数过多,可以考虑换用对象。 - - 尽量少地使用 `as` 关键字进行类型断言,一般情况下不建议进行任何 `as` 类型断言,除非必要。 -- 其他要求: - - 严格遵循 `eslint` 配置,不允许出现 `eslint` 报错。 - - 尽量不使用 `?.` 运算符,一般建议仅在副作用函数调用(如 `this.obj?.func()`,`this.obj.func?.()`),或对象 `Required` 化(如 `{ value: obj?.value ?? 0 }`)中使用 `?.` 运算符。 - - 只进行必要的非空判断,不必要的非空判断直接使用非空断言 `!` 实现。 - - 除非参数要求传入函数等情况,不建议在函数内写任何局部函数。 - - 语句尽量不换行,除非必要,尤其注意三元运算符与 `private readonly` 类成员。 +### 模块原则 + +- **无副作用**:所有模块只包含函数、类、常量的声明,不允许出现导出的变量声明或顶层代码执行,允许但不建议编写类的静态块。 +- **模块初始化**:如需初始化,编写一个 `createXxx` 函数,在 `index.ts` 中整合后逐级向上传递,直至顶层模块统一执行。 +- **不转发导出**:不允许一个文件导出不属于当前 monorepo 或当前文件夹的内容。 +- **无循环引用**:不允许出现循环引用。若遇到不得不循环引用的情况,应首先反思接口设计是否存在问题。 + +### 命名规则 + +| 命名对象 | 规范 | +| ---------------------------------------------- | ------------------------------------------ | +| 变量、成员、一般常量、方法、函数 | 小驼峰 | +| 类、接口、类型别名、命名空间、泛型、枚举、组件 | 大驼峰 | +| 不变常量 | 全大写,单词间下划线分隔(如 `MAX_COUNT`) | +| 专有名词缩写(如 `HTTP`、`URI`) | 全大写 | +| 需被 `implements` 的接口 | 大写 `I` 开头 | +| HTML/CSS 中的 `id`、`class` 等 | 连字符命名法 | + +不使用下划线命名法。 + +### 注释规范 + +- 常用属性成员、方法、接口、类型必须添加 `jsDoc` 注释。 +- 长文件可使用 `#region` / `#endregion` 分段以支持折叠。 +- TODO 使用 `// TODO:` 或 `// todo:` 格式。 +- 单行注释的 `//` 与注释内容之间留一个空格;不允许出现非 jsDoc 的多行注释,如需多行注释,使用多个单行注释代替。 +- 注释合理换行:考虑中文字符较宽,建议每 40–60 个字符在标点符号后换行,不允许在句中换行;参数注释换行后保持对齐。 +- 单行注释结尾不加句号;较长的多行注释结尾可加句号。 +- 一般不建议给接口、类型别名或类本身写注释(不好看),特殊情况除外。 + +### 类型规范 + +- 不允许出现非必要的 `any` 类型。 +- 所有类的成员必须显式声明类型。 +- 无法避免类型错误时,使用 `// @ts-expect-error` 标记并说明原因。 +- 未使用的变量或方法以下划线开头命名。 +- 合理使用 `readonly`、`protected`、`private` 关键字。 +- 可选参数过多时,考虑改用对象参数。 +- 尽量避免 `as` 类型断言,除非必要。 + +### 其他要求 + +- 严格遵循 `eslint` 配置,不允许出现 eslint 报错。 +- 尽量不使用 `?.` 运算符,仅推荐在以下两种场景中使用: + - 副作用函数调用,如 `this.obj?.func()` 或 `this.obj.func?.()` + - 对象 Required 化,如 `{ value: obj?.value ?? 0 }` +- 只进行必要的非空判断,非必要时直接使用非空断言 `!`。 +- 除非参数要求传入函数等情况,不建议在函数内定义局部函数。 +- 语句尽量不换行,除非必要,尤其注意三元运算符与 `private readonly` 类成员。 ## 双端分离 -样板将渲染端与数据端彻底分离,数据端可以单独在 `node` 环境运行,可以直接用于录像验证。渲染端仅负责向数据端发送消息,不负责任何逻辑运算。 +样板将渲染端与数据端彻底分离: -- `@user/data-base`: 数据端的系统层,负责核心系统。 -- `@user/data-state`: 数据端的实现层,依靠系统层实现完整的游戏实例。 -- `@user/client-base`: 渲染端的系统层,负责渲染端的核心系统。 -- `@user/client-modules`: 渲染端的实现层,依靠系统层实现客户端的渲染与用户交互。 +- **数据端**:可在 `node` 环境中单独运行,可直接用于录像验证,不负责任何渲染逻辑。 +- **渲染端**:仅负责向数据端发送消息,不负责任何逻辑运算。 -数据端允许运行渲染端代码,但需要使用全局接口 `Mota.r(() => {})` 包裹。除非必要,否则不建议在数据端调用渲染端代码。 +| 包 | 层级 | 说明 | +| ---------------------- | ------------ | ------------------------------------ | +| `@user/data-base` | 数据端系统层 | 负责数据端核心系统 | +| `@user/data-state` | 数据端实现层 | 依赖系统层实现完整的游戏实例 | +| `@user/client-base` | 渲染端系统层 | 负责渲染端核心系统 | +| `@user/client-modules` | 渲染端实现层 | 依赖系统层实现客户端的渲染与用户交互 | + +数据端允许调用渲染端代码,但必须使用全局接口 `Mota.r(() => {})` 包裹。除非必要,否则不建议在数据端调用渲染端代码。 diff --git a/prompt.md b/prompt.md index e683987..1d7a51f 100644 --- a/prompt.md +++ b/prompt.md @@ -2,32 +2,34 @@ 以下规则必须时刻遵守,任何情况下都不允许违反。 -1. 将我已经写好的代码视为绝对正确,除非我**明确允许**,否则**不允许任何修改**,哪怕因为接口变化或其他原因导致其中出现类型错误。如果你认为我的代码中存在逻辑错误,应当在对话中提出,而不是直接修改。 -2. 我做的任何代码修改都是有原因的,如果我在两次对话期间新增、删除或修改了部分代码,不要将其恢复。 -3. 时刻以目的进行驱动,想明白我为什么要这么设计接口,这个接口设计的目的是什么,而不是简单地以实现接口为目标。 -4. 如果思考或实现时有任何问题,比如我的描述比较模糊,或接口描述比较模糊,或某些地方会产生歧义等等,应该立刻向我提问,而不是按照自己的想法去写。 -5. 如果我的目标是重构某个接口,按照我说的方式进行重构。如果是彻底性的重构(接口完全没有重合),则按照正常的方式进行实现,旧代码仅做逻辑与思路上的参考;如果是结构性的重构(接口基本一致,但有一些细节上的差距),则应该将旧代码搬到新的接口上,然后进行一些微调,**不要**擅自新增任何参数、任何新的方法或接口,**不要**仅仅通过新增一个兼容层兼容旧代码来实现重构。 +1. **不擅自修改已有代码**:将我已经写好的代码视为绝对正确。除非我**明确允许**,否则**不允许任何修改**,哪怕因为接口变化或其他原因导致其中出现类型错误。若认为我的代码存在逻辑错误,应在对话中提出,而不是直接修改。 +2. **不恢复我的修改**:我做的任何代码修改都是有原因的。若我在两次对话期间新增、删除或修改了部分代码,不要将其恢复。 +3. **以目的驱动,而非以接口驱动**:实现前先想清楚我为什么要这样设计接口、这个接口设计的目的是什么,而不是单纯地以将接口填满为目标。 +4. **遇到歧义立即提问**:若思考或实现时遇到任何问题——例如描述模糊、接口不清晰、某些地方存在歧义等——应立即向我提问,而不是按自己的想法去写。 +5. **按我说的方式重构**:若目标是重构某个接口,按照我指定的方式执行: + - **彻底性重构**(新旧接口完全没有重合):按正常方式全新实现,旧代码仅作逻辑与思路上的参考。 + - **结构性重构**(新旧接口基本一致,细节有差距):将旧代码搬移到新接口上后进行微调。**不要**擅自新增任何参数、方法或接口,**不要**仅通过新增兼容层的方式应对重构。 # 建议规则 -以下规则为建议性,尽量遵守,但是一些特殊情况也可以违反,由你自己把控。 +以下规则为建议性,尽量遵守,特殊情况下可灵活处理。 -1. 我有时会在对话中给你提出实现建议,你应该对建议内容进行合理的参考,合理运用建议内容,一定注意不要滥用。 -2. 如果实现与类型标注有冲突,应当以类型标注(一般是 `types.ts`)中的内容为参考来源。 -3. 如果你认为类型标注中的接口设计有问题,或在实现中发现其缺少某些接口,应该向我提问是否添加,我同意后方可添加。 +1. **合理参考建议**:我有时会在对话中给出实现建议,应合理参考,切忌滥用。 +2. **以类型标注为参考依据**:实现与类型标注有冲突时,以类型标注(一般是 `types.ts`)中的内容为准。 +3. **发现接口问题时提问**:若认为类型标注中的接口设计有问题,或在实现中发现缺少某些接口,应向我提问是否添加,经我同意后方可添加。 -**时刻谨记上述要求,避免一个需求写好几次都写不出来,或写出我不满意的代码而挨骂** +**时刻谨记上述要求,避免一个需求反复修改仍无法满足预期。** # 开发流程 -当我提出需求时,如果没有明确说明直接实现或有其他明确要求,则遵循如下开发流程: +当我提出需求时,若没有明确说明直接实现或有其他明确要求,则遵循如下开发流程: -1. 阅读当前代码,分析需求,将需求整理为一个 markdown 文档,文档中明确标记需求细节,以及代码实现的大体思路。这一阶段中应当考虑全面,遇到任何问题应向我提问并确认。文档可以放在 `docs/dev` 目录下。 -2. 我会对文档进行全面的阅读,确保实现细节与思路没有问题后,允许你开始实现。这一步中我可能会对文档进行细微的调整,确保重新仔细阅读文档。如果实现时遇到了任何问题,应该向我提问,而不是按照自己的想法去写。 +1. 阅读当前代码,分析需求,将需求整理为一个 markdown 文档,放在 `docs/dev` 目录下。文档中需明确标注需求细节,以及代码实现的大体思路。此阶段需考虑全面,遇到任何问题应向我提问并确认,不得自行假设。 +2. 我会对文档进行全面阅读,确认实现细节与思路无误后,方允许开始实现。我可能会对文档进行细微调整,请在实现前重新仔细阅读最终版本。实现过程中如有任何问题,应向我提问,而不是自行决定。 ## 示例文档 -大致按照下述示例文档的格式编写,如果某些场景需要详细描述某个东西,可以单独开一个标题来写。 +大致按照以下格式编写,如某部分需要详细描述,可单独开设标题。我会使用引用块的形式在文档中提出建议或回答。 ```md # 需求综述 @@ -50,7 +52,7 @@ ## 需要引用的文件 -按照第三方库-其他包-当前包的其他文件的顺序写。 +按照第三方库 → 其他包 → 当前包的其他文件的顺序写。 - `xxx 库`: 引用第三方库,说明引用目的,以及需要的接口 - `@user/xxx`: 引用的目的,需要这个文件的哪些接口 @@ -77,7 +79,7 @@ # 问题 -如果我的描述中有歧义或比较模糊,可以在这把问题写出来,或者直接向我提问。 +如果描述中有歧义或比较模糊的地方,可以在此列出,或者直接向我提问。 1. xxxxxx? 2. xxxxxx? From 4c48b63e7e693d6a250f3754c33e474e44b153ab Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 6 May 2026 19:57:11 +0800 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=E5=9C=B0=E5=9B=BE=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E4=B8=8E=E5=B0=BA=E5=AF=B8=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/map-store-improve.md | 212 ++++++++++++++++++ docs/dev/map-store-save.md | 50 ++--- packages-user/data-base/src/map/layerState.ts | 30 ++- packages-user/data-base/src/map/mapStore.ts | 119 ++++++++-- packages-user/data-base/src/map/types.ts | 98 +++++--- packages-user/data-state/src/core.ts | 16 +- packages/common/src/logger.json | 1 + 7 files changed, 441 insertions(+), 85 deletions(-) create mode 100644 docs/dev/map-store-improve.md diff --git a/docs/dev/map-store-improve.md b/docs/dev/map-store-improve.md new file mode 100644 index 0000000..f477b92 --- /dev/null +++ b/docs/dev/map-store-improve.md @@ -0,0 +1,212 @@ +# 需求综述 + +本次改动目标: + +1. **自动化分区激活器**:将楼层按游戏进程划分为若干"区域", + 到达新区域时自动激活对应楼层、失活旧区域楼层, + 从而替代目前繁琐的手动 `setActiveStatus` 调用。 +2. **楼层尺寸上移至 `LayerState`**:同一楼层的所有图层尺寸应当 + 保持一致,因此将尺寸的权威来源从 `MapLayer` 移至 `LayerState`。 + +--- + +# 实现思路 + +## 1. 有序地图 id 列表 + +当前 `IMapStore.maps` 是 `ReadonlySet`,无序且随楼层创建 +自动填充。区域功能需要以**下标**标识范围,因此需改为有序数组。 + +修改方案: + +- `IMapStore.maps` 类型改为 `ReadonlyArray`; +- 新增 `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): void`:一次性设置所有区域信息, + 覆盖原有区域定义;每个元素代表一个区域,区域可包含多个区间, + 使用 `Set` 表示无序区域集合; +- `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`:所有区域定义; +- `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` + - [x] 修改 `createLayerState` 签名:新增 `width: number`、`height: number` 参数 + - [x] 新增 `setMapList(maps: string[]): void` + - [x] 新增 `useManualOrder(sort: (arr: string[]) => string[]): void` + - [x] 新增 `setArea(areas: Set): 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` 改为 `maps: string[]` +- [x] 修改 `createLayerState`:添加 `width`/`height` 参数,不再维护 `maps` +- [x] 实现 `setMapList` +- [x] 实现 `useManualOrder` +- [x] 新增 `private areaList: Set` +- [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` 改为内部方法(从公共接口移除) + +--- diff --git a/docs/dev/map-store-save.md b/docs/dev/map-store-save.md index fdc8c05..1af0da1 100644 --- a/docs/dev/map-store-save.md +++ b/docs/dev/map-store-save.md @@ -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`, +- [x] 修改 `IMapLayer`:新增 `setMapRef(array: Uint32Array): void` +- [x] 新增 `IMapStore` 接口:继承 `ISaveableContent`, 含全部接口(见第 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`:楼层 id 到状态对象的映射 -- [ ] `readonly maps: ReadonlySet`:所有楼层 id 的只读集合视图 -- [ ] `private refData: Map> | 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`:楼层 id 到状态对象的映射 +- [x] `readonly maps: ReadonlySet`:所有楼层 id 的只读集合视图 +- [x] `private refData: Map> | 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` diff --git a/packages-user/data-base/src/map/layerState.ts b/packages-user/data-base/src/map/layerState.ts index 37f4359..9edb9ed 100644 --- a/packages-user/data-base/src/map/layerState.ts +++ b/packages-user/data-base/src/map/layerState.ts @@ -18,6 +18,8 @@ export class LayerState implements ILayerState { readonly layerList: Set = new Set(); + /** 具体 MapLayer 实例列表,供内部 resize 使用 */ + private readonly mapLayerList: Set = new Set(); /** 图层到图层别名映射 */ readonly layerAliasMap: WeakMap = 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); + } } } diff --git a/packages-user/data-base/src/map/mapStore.ts b/packages-user/data-base/src/map/mapStore.ts index 19e1fe8..d72fc72 100644 --- a/packages-user/data-base/src/map/mapStore.ts +++ b/packages-user/data-base/src/map/mapStore.ts @@ -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 = new Map(); - /** 所有楼层 id 的只读集合视图 */ - readonly maps: Set = new Set(); + /** 所有楼层 id 的有序数组 */ + readonly maps: string[] = []; /** 差分压缩参考基准,首次 compareWith 后设置,之后不再更新 */ private refData: Map> | null = null; - //#region 楼层访问 + /** 分区列表 */ + private areaList: Set = 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): 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 diff --git a/packages-user/data-base/src/map/types.ts b/packages-user/data-base/src/map/types.ts index 7f959b3..47f0bfc 100644 --- a/packages-user/data-base/src/map/types.ts +++ b/packages-user/data-base/src/map/types.ts @@ -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 { readonly layerList: Set; /** 此楼层是否处于激活状态 */ 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 { 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; } +/** 单段闭区间 [start, end],start 和 end 均为 maps 下标 */ +export interface IMapAreaInterval { + readonly start: number; + readonly end: number; +} + +/** 一个区域由一个或多个独立区间组成 */ +export type MapArea = IMapAreaInterval[]; + export interface IMapStore extends ISaveableContent { - /** 所有楼层的 id 集合 */ - readonly maps: ReadonlySet; + /** 所有楼层的 id 有序数组 */ + readonly maps: ReadonlyArray; /** * 获取指定 id 的楼层状态,不存在则返回 null @@ -355,8 +346,10 @@ export interface IMapStore extends ISaveableContent { /** * 创建并注册一个空白楼层,若 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 { * @param ref 外层 key = 楼层 id,内层 key = zIndex,value = 图层完整图块数据 */ compareWith(ref: Map>): void; + + /** + * 设定楼层有序列表。设定后有序列表将用于分区索引计算 + * @param maps 楼层 id 数组 + */ + setMapList(maps: string[]): void; + + /** + * 使用自定义排序函数重排 maps。排序函数接收当前列表的副本,返回新顺序。 + * 若返回的数组元素集合与原列表不一致,则警告并放弃本次排序 + * @param sort 排序函数 + */ + useManualOrder(sort: (arr: string[]) => string[]): void; + + /** + * 设定分区列表。每个分区由一个或多个区间组成 + * @param areas 分区集合 + */ + setArea(areas: Set): 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; } diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index 4b3b325..87bb0c5 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -214,12 +214,16 @@ export class CoreState implements ICoreState { const reference = new Map>(); 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); diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index e9b8dcd..20fe5f9 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -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." } } From fbdd609001164783bd373d2d9aa0dc670ab15803 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Mon, 11 May 2026 23:35:46 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E5=8A=A8=E6=80=81=E5=9B=BE?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev.md | 7 +- docs/dev/{ => enemy}/enemy-manager-save.md | 0 docs/dev/{ => hero}/hero-modifier-save.md | 0 docs/dev/map/dynamic-tile-move.md | 595 ++++++++++++++++++ docs/dev/map/dynamic-tile.md | 407 ++++++++++++ docs/dev/{ => map}/map-store-improve.md | 0 docs/dev/{ => map}/map-store-save.md | 0 docs/dev/{ => system}/save-system.md | 0 packages-user/data-base/src/common/index.ts | 1 + packages-user/data-base/src/common/mover.ts | 579 +++++++++++++++++ .../data-base/src/map/dynamicLayer.ts | 142 +++++ .../data-base/src/map/dynamicTile.ts | 72 +++ packages-user/data-base/src/map/index.ts | 1 + packages-user/data-base/src/map/mapLayer.ts | 5 + packages-user/data-base/src/map/mover.ts | 78 +++ packages-user/data-base/src/map/types.ts | 168 ++++- .../data-state/src/enemy/calculator.ts | 9 - packages/common/src/logger.json | 5 + prompt.md | 9 +- 19 files changed, 2063 insertions(+), 15 deletions(-) rename docs/dev/{ => enemy}/enemy-manager-save.md (100%) rename docs/dev/{ => hero}/hero-modifier-save.md (100%) create mode 100644 docs/dev/map/dynamic-tile-move.md create mode 100644 docs/dev/map/dynamic-tile.md rename docs/dev/{ => map}/map-store-improve.md (100%) rename docs/dev/{ => map}/map-store-save.md (100%) rename docs/dev/{ => system}/save-system.md (100%) create mode 100644 packages-user/data-base/src/common/mover.ts create mode 100644 packages-user/data-base/src/map/dynamicLayer.ts create mode 100644 packages-user/data-base/src/map/dynamicTile.ts create mode 100644 packages-user/data-base/src/map/mover.ts diff --git a/dev.md b/dev.md index 19d9c08..de5e670 100644 --- a/dev.md +++ b/dev.md @@ -57,13 +57,15 @@ ### 注释规范 -- 常用属性成员、方法、接口、类型必须添加 `jsDoc` 注释。 +- 公共方法、接口必须在**源头处**(多数情况下为 `interface`)添加 `jsDoc` 注释;其他常用成员、方法、类型也必须添加注释(含义极为明确或极少使用的可例外,但建议全部添加)。 +- 继承或 `implements` 而来的 API(方法、成员等),若注释说明无需变更,则**不应重复添加** `jsDoc` 注释。 +- 不对构造器添加注释。若构造器使用了属性声明语法(`constructor(public prop: T)`)且成员需要说明,可仅对该成员添加参数注释,不写构造器描述。**在这种情况下**建议避免在构造器中使用属性声明语法,将成员单独声明并在构造器中赋值。此条建议**并非**要求不使用构造器的属性声明语法,而是仅在这一情况下不建议使用,常规情况下推荐使用此语法来缩短代码长度并提高可读性。 - 长文件可使用 `#region` / `#endregion` 分段以支持折叠。 - TODO 使用 `// TODO:` 或 `// todo:` 格式。 - 单行注释的 `//` 与注释内容之间留一个空格;不允许出现非 jsDoc 的多行注释,如需多行注释,使用多个单行注释代替。 - 注释合理换行:考虑中文字符较宽,建议每 40–60 个字符在标点符号后换行,不允许在句中换行;参数注释换行后保持对齐。 - 单行注释结尾不加句号;较长的多行注释结尾可加句号。 -- 一般不建议给接口、类型别名或类本身写注释(不好看),特殊情况除外。 +- 一般不建议给接口(`inteface` 本身)、类型别名或类本身写注释(不好看),特殊情况除外。 ### 类型规范 @@ -77,6 +79,7 @@ ### 其他要求 +- 不使用字符串作为键或特殊标识符(如枚举值、事件名、状态名等),应使用枚举代替。仅当明确表示字符串本身(如字符串类型的 id 别名、文件路径等)时方可使用字符串字面量。 - 严格遵循 `eslint` 配置,不允许出现 eslint 报错。 - 尽量不使用 `?.` 运算符,仅推荐在以下两种场景中使用: - 副作用函数调用,如 `this.obj?.func()` 或 `this.obj.func?.()` diff --git a/docs/dev/enemy-manager-save.md b/docs/dev/enemy/enemy-manager-save.md similarity index 100% rename from docs/dev/enemy-manager-save.md rename to docs/dev/enemy/enemy-manager-save.md diff --git a/docs/dev/hero-modifier-save.md b/docs/dev/hero/hero-modifier-save.md similarity index 100% rename from docs/dev/hero-modifier-save.md rename to docs/dev/hero/hero-modifier-save.md diff --git a/docs/dev/map/dynamic-tile-move.md b/docs/dev/map/dynamic-tile-move.md new file mode 100644 index 0000000..ea4c905 --- /dev/null +++ b/docs/dev/map/dynamic-tile-move.md @@ -0,0 +1,595 @@ +# 动态图块移动系统 + +> 本文档为 [动态图块系统](./dynamic-tile.md) 的移动子系统设计文档, +> 重点描述双方向模型、`IDynamicMover` 计划式移动接口及执行流程。 +> 本文档中新增的类型与接口同样需要添加至 `@user/data-base/src/map/types.ts`。 + +--- + +# 背景与动机 + +`IDynamicLayer.moveDynamicStep` / `moveDynamicWith` 是命令式简单接口,适用于 +一次性的简单移动,但对以下场景力不从心: + +- **后退移动**:角色面朝上、向下移动时,朝向不变但位移反向, + 单一 `FaceDirection` 参数无法同时描述朝向和移动方向; +- **连续路径中途动态追加步骤**:命令式接口执行后无法继续追加; +- **移动速度切换**:路径中途需要变速,命令式接口不支持; +- **复杂动画编排**:需要按序执行多段计划时,纯 `Promise` 链表达不够直观。 + +因此引入以图块为粒度绑定的 `IDynamicMover`,采用**先建计划、再执行**的设计理念, +参考 `@motajs/animate/types.ts` 的链式计划构建模式, +以及 `@user/data-state/legacy/move.ts` 中 `ObjectMoverBase` 的双方向与队列设计。 + +--- + +# 实现思路 + +## 1. 双方向模型 + +每个动态图块维护两个方向字段: + +| 字段 | 含义 | 影响 | +| --------------- | ---------------------------- | --------------------------------------- | +| `faceDirection` | **朝向**,视觉上「面朝哪里」 | 通过 `IRoleFaceBinder` 决定渲染图块数字 | +| `moveDirection` | **移动方向**,「向哪边走」 | 结合 `getFaceMovement` 计算位移 | + +两者分离后可以表达: + +- **后退**:`moveDirection = Up`,执行 `backward()` 步, + `moveDirection = Down`,`faceDirection = Down`,位移向下,朝向同步翻转。 +- **横向移动不转身**:使用 `stepFace(move, face)` 单独指定朝向, + `moveDirection` 与 `faceDirection` 独立设置。 + +绝对方向步骤(传入 `FaceDirection`)默认将两者同时更新; +`forward()` 沿当前 `moveDirection` 移动并对齐 `faceDirection`; +`backward()` 将 `moveDirection` 取反并同步 `faceDirection`。 + +## 2. 方向步语义 + +绝对方向步(`IDynamicMoveStepDir` / `IDynamicMoveStepDirFace`)直接传入 `FaceDirection`: + +- `step(dir, count?: number)`:追加 `count`(默认 1)个绝对方向步,`moveDirection` 和 `faceDirection` 均更新为 `dir`; +- `stepFace(move, face, count?: number)`:追加 `count`(默认 1)个绝对方向步,`moveDirection` 更新为 `move`, + `faceDirection` 更新为 `face`——用于移动方向与朝向不一致的场景(如斜切移动保持正向)。 + +相对方向步(`IDynamicMoveStepSpecial`)通过独立方法构建: + +- `forward(count?)`:沿当前 `tile.moveDirection` 移动,`moveDirection` 不变, + `faceDirection` 对齐为 `moveDirection`; +- `backward(count?)`:沿 `opposite(tile.moveDirection)` 移动,`moveDirection` 取反, + `faceDirection` 同步更新为新的 `moveDirection`,自动使用逆向动画。 + +## 3. DynamicMoveStep 类型 + +步骤类型通过 `const enum` 区分,避免字符串比较。 +前进/后退方向用独立的 `DynamicSpecialStep` 枚举表示; +动画播放方向用独立的 `DynamicAnimDirection` 枚举表示,两者互不依赖: + +```ts +const enum DynamicMoveStepType { + /** 绝对方向步,同步更新 moveDirection 和 faceDirection */ + Dir, + /** 绝对方向步,单独指定 faceDirection */ + DirFace, + /** 速度步 */ + Speed, + /** 纯转向步,不产生位移 */ + Face, + /** 特殊步(前进或后退) */ + Special, + /** 动画方向步,不影响 moveDirection/faceDirection */ + AnimDir +} + +/** 特殊步方向:前进或后退 */ +const enum DynamicSpecialStep { + Forward, + Backward +} + +/** 动画播放方向:正向或逆向 */ +const enum DynamicAnimDirection { + Forward, + Backward +} + +/** 绝对方向步:move 方向既是移动方向也是朝向 */ +interface IDynamicMoveStepDir { + type: DynamicMoveStepType.Dir; + move: FaceDirection; +} + +/** 绝对方向步:move 是移动方向,face 是显式指定的朝向 */ +interface IDynamicMoveStepDirFace { + type: DynamicMoveStepType.DirFace; + move: FaceDirection; + face: FaceDirection; +} + +/** 速度步:修改后续移动的每格耗时(单位 ms,越小越快) */ +interface IDynamicMoveStepSpeed { + type: DynamicMoveStepType.Speed; + value: number; +} + +/** 转向步:仅更新 faceDirection,不产生位移,等待渲染动画完成 */ +interface IDynamicMoveStepFace { + type: DynamicMoveStepType.Face; + value: FaceDirection; +} + +/** 特殊步:前进(Forward)或后退(Backward)。`forward(n)`/`backward(n)` 构建时 n>1 会 push n 个相同步骤 */ +interface IDynamicMoveStepSpecial { + type: DynamicMoveStepType.Special; + direction: DynamicSpecialStep; +} + +/** 动画方向步:指定后续步骤动画正向播放(Forward)还是逆向播放(Backward) */ +interface IDynamicMoveAnimDir { + type: DynamicMoveStepType.AnimDir; + dir: DynamicAnimDirection; +} + +type DynamicMoveStep = + | IDynamicMoveStepDir + | IDynamicMoveStepDirFace + | IDynamicMoveStepSpeed + | IDynamicMoveStepFace + | IDynamicMoveStepSpecial + | IDynamicMoveAnimDir; +``` + +## 4. IMoverController 接口 + +`IDynamicMover.start()` 返回 `IMoverController`,用于控制进行中的移动。 + +```ts +interface IMoverController { + /** 本次移动是否已全部完成 */ + readonly done: boolean; + /** 当本次移动全部步骤完成时兑现 */ + readonly onEnd: Promise; + /** + * 向当前队列末尾追加步骤(仅在移动进行中有效,完成后追加无效) + */ + push(...steps: DynamicMoveStep[]): void; + /** + * 停止移动,等待当前步骤完成后停止,返回的 Promise 在停止后兑现 + */ + stop(): Promise; +} +``` + +## 5. IDynamicMover 接口 + +每个 `IDynamicTile` 持有一个**绑定**的 `IDynamicMover` 实例(`tile.mover`), +无需单独创建或管理生命周期。 + +### 5.1 状态读取 + +```ts +// 以下状态属性来自 IObjectMover,IDynamicMover 全部继承 +interface IObjectMover { + /** 是否正在移动 */ + readonly moving: boolean; + /** 当前朝向(与 tile.faceDirection 同步) */ + readonly faceDirection: FaceDirection; + /** 当前移动方向(与 tile.moveDirection 同步) */ + readonly moveDirection: FaceDirection; + /** 所属的可移动对象 */ + readonly tile: T; + // ... +} +``` + +### 5.2 计划构建(链式) + +所有构建方法均返回 `this`,支持链式调用。 +计划在调用 `start()` 前不执行,仅追加到内部队列。 + +```ts +interface IDynamicMover { + // ... + /** 追加 count(默认 1)个绝对方向步,同步更新 moveDirection 和 faceDirection */ + step(dir: FaceDirection, count?: number): this; + /** 追加 count(默认 1)个绝对方向步,单独指定 faceDirection(用于移动方向与朝向不一致的场景) */ + stepFace(move: FaceDirection, face: FaceDirection, count?: number): this; + /** 追加前进步,沿当前 moveDirection 移动,faceDirection 对齐为 moveDirection */ + forward(count?: number): this; + /** 追加后退步,moveDirection 取反,faceDirection 同步更新,自动使用逆向动画 */ + backward(count?: number): this; + /** 追加一个速度步,改变后续移动的每格耗时(ms) */ + speed(value: number): this; + /** 追加一个纯转向步,仅更新 faceDirection,不产生位移 */ + face(dir: FaceDirection): this; + /** 追加一个动画方向步,控制后续步骤的动画播放方向(Forward 正向 / Backward 逆向) */ + animDir(dir: DynamicAnimDirection): this; + /** 清空尚未执行的计划队列 */ + clear(): this; + // ... +} +``` + +**用法示例**: + +```ts +// 先向下走 2 步,切换为半速,再向右走 1 步 +tile.mover + .step(FaceDirection.Down, 2) + .speed(200) + .step(FaceDirection.Right) + .start(); + +// 后退 3 步(moveDirection/faceDirection 翻转,逆向动画) +tile.mover.backward(3).start(); + +// 向右移动但保持朝下(如横向滑步) +tile.mover.stepFace(FaceDirection.Right, FaceDirection.Down, 2).start(); +``` + +### 5.3 执行控制 + +```ts +interface IDynamicMover { + // ... + /** + * 开始执行计划队列,返回控制对象。 + * 若已在移动则返回 null(不中断当前移动)。 + */ + start(): IMoverController | null; +} +``` + +## 6. 移动执行流程 + +每次 `start()` 启动后,执行器按以下总流程处理队列: + +触发 `onMoveStart` → 循环每步:[ +`abstract onStepStart(step, tile, mover)` 执行并返回 `code: number`(子类计算本步场景)→ +触发 `IObjectMoverHooks.onStepStart(code, step, tile, mover)` 钩子(渲染预处理,此时 `tile.x/y` 为**移动前坐标**)→ +等待所有 Promise → +`abstract onStepEnd(code, step, tile, mover)` 执行**逻辑效果**(子类根据 code 决定如何执行)→ +触发 `IObjectMoverHooks.onStepEnd(code, step, tile, mover)` 钩子(此时 `tile.x/y` 为**移动后坐标**) +] → 触发 `onMoveEnd`。 + +### 6.1 绝对方向步(`DynamicMoveStepType.Dir`) + +**onStepStart 阶段**(`tile.x/y` 为原始坐标): + +1. `abstract onStepStart(step, tile, mover)` 执行,子类计算本步 `code`(正常移动、碰墙等)并返回 `Promise`。 +2. 父类拿到 `code`,触发 `IObjectMoverHooks.onStepStart(code, step, tile, mover)` 钩子——渲染端在此阶段播放移动动画, + 可由 `step.move` 与当前 `tile.x/y` 推算目标位置。 +3. `await Promise.all(promises)`——等待全部动画完成。 + +**onStepEnd 阶段**(逻辑效果): + +1. `abstract onStepEnd(code, step, tile, mover)` 执行——子类依据 `code` 决定本步实际效果: + 更新 `tile.moveDirection = step.move`。 +2. 计算 `{dx, dy} = getFaceMovement(step.move)`,**立即**更新 `tile.x += dx`、`tile.y += dy`。 +3. 同步更新 `posMap`。 +4. 执行转向逻辑(见 [dynamic-tile.md §8](./dynamic-tile.md#8-转向逻辑)), + 更新 `tile.faceDirection` 与 `tile.num`。 +5. 子类返回后,父类触发 `IObjectMoverHooks.onStepEnd(code, step, tile, mover)` 钩子,此时 `tile.x/y` 已为新坐标。 + +### 6.1.1 含显式朝向的绝对方向步(`DynamicMoveStepType.DirFace`) + +与 `Dir` 步骤相同,但步骤 5 改为直接将 `tile.faceDirection` 设为 `step.face`, +并通过 `getFaceOf(tile.num, step.face)` 更新 `tile.num`,不经过 `degradeFace` 降级。 + +### 6.2 速度步(`DynamicMoveStepType.Speed`) + +更新内部速度状态,立即生效,无位移,无需等待。 + +### 6.3 纯转向步(`DynamicMoveStepType.Face`) + +经由 `onStepStart` 触发钩子(`tile.x/y` 不变,`step.type === DynamicMoveStepType.Face` 供渲染端区分); +等待渲染端动画完成后,`onStepEnd` 阶段执行转向逻辑,更新 `faceDirection` 与 `tile.num`, +触发 `onStepEnd` 钩子。 + +### 6.4 特殊步(`DynamicMoveStepType.Special`) + +**语义说明**:`forward`/`backward` 以**上一步的移动方向**(即当前 `tile.moveDirection`)为基准; +若无上一步,则回退到当前 `faceDirection`;若图块无朝向绑定,则抛出错误。 + +**Forward**(`DynamicSpecialStep.Forward`): + +1. `actualDir = tile.moveDirection`(不改变 moveDirection); +2. 执行与 `Dir` 步骤相同的位移流程; +3. 步骤 2:跳过(`moveDirection` 保持不变); +4. 步骤 5:执行转向逻辑,以 `actualDir` 更新 `tile.faceDirection` 与 `tile.num`。 + +**Backward**(`DynamicSpecialStep.Backward`): + +1. `actualDir = opposite(tile.moveDirection)`; +2. 在执行前临时将逆向动画标志设为开启(步骤完成后恢复); +3. 执行与 `Dir` 步骤相同的位移流程; +4. 步骤 2:`tile.moveDirection = actualDir`; +5. 步骤 5:执行转向逻辑,以 `actualDir` 更新 `tile.faceDirection` 与 `tile.num`。 + +### 6.5 动画方向步(`DynamicMoveStepType.AnimDir`) + +指定后续步骤动画是**正向播放**(`DynamicAnimDirection.Forward`)还是**逆向播放**(`DynamicAnimDirection.Backward`), +与图块朝向无关(朝向始终由 `faceDirection` 决定)。 +修改 mover 内部的 `currentAnimDir` 状态。 +渲染端在 `onStepStart` 收到 `AnimDir` 步骤时读取并更新本地动画方向状态,后续步骤的 `onStepStart` 触发时依据此状态决定播放方向。 +`onStepEnd` 阶段无逻辑操作,立即完成,不影响坐标与方向。 + +## 7. onStepStart / onStepEnd 钩子返回值 + +`IObjectMoverHooks.onStepStart` 与 `onStepEnd` 均为可选钩子(`?`),返回类型为 `Promise`, +执行器始终通过 `Promise.all` 等待所有订阅方完成后再继续。 + +关键时序:`onStepStart` 所有 Promise 兑现后,才执行逻辑效果(abstract onStepEnd); +`onStepEnd` 全部兑现后,才进入下一步。 +多个订阅方通过 `Promise.all` 并行等待,与 `forEachHook` 返回所有值的设计天然契合—— +`const promises = forEachHook(hook => hook.onStepStart?.(code, step, tile, mover));` +`await Promise.all(promises);` + +## 8. 与 IDynamicLayer / IDynamicTile 接口的关系 + +`IDynamicLayer` 和 `IDynamicTile` **仅保留 `step(dir)` 单步便捷接口** +(等价于 `tile.mover.step(dir).start()`),不再提供 `moveDynamicWith` / `moveWith` 等批量接口。 +复杂移动能力(`forward`/`backward`/`animDir` 等)通过 `tile.mover` 访问。 + +**渲染端订阅移动事件的流程**: + +1. 订阅 `IDynamicLayer.onCreateDynamicTile`; +2. 创建图块时获取 `tile.mover` 引用,直接向其添加钩子; +3. 移动事件从 `tile.mover` 触发,无需经过 `IDynamicLayer` 转发。 + +为此,`IDynamicMover` 扩展为可订阅对象, +实现 `IHookable>`(见接口定义汇总)。 + +--- + +# 涉及文件 + +## 需要修改的文件 + +### `@user/data-base/src/map/types.ts` + +修改现有接口(移动相关新增类型已移至 `move/types.ts`): + +- [ ] `IDynamicTile` 新增 `readonly mover: IDynamicMover` +- [ ] `IDynamicTile` 已有 `readonly faceDirection: FaceDirection` 与 + `readonly moveDirection: FaceDirection`(已在主文档确认) +- [ ] `IObjectMovable` 接口(`x`、`y`、`moveDirection`、`faceDirection` 只读属性 + + `setPos`/`setMoveDirection`/`setFaceDirection`,定义在 `dynamic-tile.md`) +- [ ] `IDynamicLayer` / `IDynamicTile` 仅保留 `step(dir)` 便捷接口, + 移除 `moveDynamicWith` / `moveDynamicStep` / `moveWith` / `moveStep` + (主文档接口汇总需同步更新) + +#### `@user/data-base/src/map/types.ts` + +- [ ] `DynamicMoveStepType` `const enum`: + 含 `Dir`/`DirFace`/`Speed`/`Face`/`Special`/`AnimDir` 六个成员 +- [ ] `DynamicSpecialStep` `const enum`:含 `Forward`/`Backward` 两个成员 +- [ ] `DynamicAnimDirection` `const enum`:含 `Forward`/`Backward` 两个成员 +- [ ] `IDynamicMoveStepDir` 接口(`type: DynamicMoveStepType.Dir`,`move: FaceDirection`) +- [ ] `IDynamicMoveStepDirFace` 接口(`type: DynamicMoveStepType.DirFace`,`move`/`face: FaceDirection`) +- [ ] `IDynamicMoveStepSpeed` 接口(`type: DynamicMoveStepType.Speed`) +- [ ] `IDynamicMoveStepFace` 接口(`type: DynamicMoveStepType.Face`) +- [ ] `IDynamicMoveStepSpecial` 接口(`type: Special`,`direction: DynamicSpecialStep`;无 `count` 字段,`forward(n)`/`backward(n)` 改为 push n 个步骤对象) +- [ ] `IDynamicMoveAnimDir` 接口(`type: DynamicMoveStepType.AnimDir`,`dir: DynamicAnimDirection`) +- [ ] `DynamicMoveStep` 联合类型(含以上六种步骤) +- [ ] `IMoverController` 接口 +- [ ] `IObjectMoverHooks` 接口(含 `onMoveStart`/`onMoveEnd`/`onStepStart`/`onStepEnd`; + `onStepStart`/`onStepEnd` 参数顺序为 `(code, step, tile, mover)`,按使用频率排列) +- [ ] `IObjectMover` 接口(含 `faceDirection`/`moveDirection` 只读属性及全部计划构建方法;`step`/`stepFace` 含 `count?` 参数) +- [ ] `IDynamicMover extends IObjectMover`(无额外成员,继承基类全部能力;`IDynamicMoverHooks` 已移除,直接使用 `IObjectMoverHooks`) + +#### `@user/data-base/src/map/mover.ts` + +- [ ] `abstract class ObjectMover` 抽象基类: + - 实现 `IObjectMover`,在此提供全部**计划构建方法**(`step`/`stepFace`/`forward`/ + `backward`/`speed`/`face`/`animDir`/`clear`/`start`)及队列执行调度逻辑; + - 含四个**抽象方法**,子类必须实现,组成移动核心控制流: + - `abstract onMoveStart(tile, mover): Promise`: 整次移动开始时调用 + - `abstract onMoveEnd(tile, mover): Promise`: 整次移动结束时调用 + - `abstract onStepStart(step, tile, mover): Promise`: 单步逻辑执行前调用, + 子类计算并**返回** `code`;父类将 `code` 传给 `IObjectMoverHooks.onStepStart` 钩子后等待 + - `abstract onStepEnd(code, step, tile, mover): Promise`: 单步钩子等待完成后调用,子类依据 `code` 执行本步实际逻辑效果 +- [ ] `class DynamicMover extends ObjectMover implements IDynamicMover`: + 持有 `tile: IDynamicTile` 引用,维护 `moveQueue`、`moving`、 + `faceDirection`、`moveDirection` 状态;实现四个抽象方法(含 posMap 更新、转向逻辑等) +- [ ] `DynamicTile` 类新增 `readonly mover: DynamicMover`, + 在构造时以 `this` 为参数创建 + +--- + +# 接口定义汇总 + +```ts +const enum DynamicMoveStepType { + Dir, + DirFace, + Speed, + Face, + Special, + AnimDir +} + +const enum DynamicSpecialStep { + Forward, + Backward +} + +const enum DynamicAnimDirection { + Forward, + Backward +} + +interface IDynamicMoveStepDir { + type: DynamicMoveStepType.Dir; + move: FaceDirection; +} + +interface IDynamicMoveStepDirFace { + type: DynamicMoveStepType.DirFace; + move: FaceDirection; + face: FaceDirection; +} + +interface IDynamicMoveStepSpeed { + type: DynamicMoveStepType.Speed; + value: number; +} + +interface IDynamicMoveStepFace { + type: DynamicMoveStepType.Face; + value: FaceDirection; +} + +/** forward(n)/backward(n) 构建时 n>1 会 push n 个相同步骤,步骤对象本身不携带 count */ +interface IDynamicMoveStepSpecial { + type: DynamicMoveStepType.Special; + direction: DynamicSpecialStep; +} + +interface IDynamicMoveAnimDir { + type: DynamicMoveStepType.AnimDir; + dir: DynamicAnimDirection; +} + +type DynamicMoveStep = + | IDynamicMoveStepDir + | IDynamicMoveStepDirFace + | IDynamicMoveStepSpeed + | IDynamicMoveStepFace + | IDynamicMoveStepSpecial + | IDynamicMoveAnimDir; + +interface IMoverController { + readonly done: boolean; + readonly onEnd: Promise; + push(...steps: DynamicMoveStep[]): void; + stop(): Promise; +} + +interface IObjectMoverHooks extends IHookBase { + onMoveStart(mover: IObjectMover, tile: T): Promise; + onMoveEnd(mover: IObjectMover, tile: T): Promise; + /** 触发时 tile.x/y 为移动前坐标,适合渲染端在此播放动画 */ + onStepStart?( + code: number, + step: DynamicMoveStep, + tile: T, + mover: IObjectMover + ): Promise; + /** 触发时 tile.x/y 已更新为移动后坐标 */ + onStepEnd?( + code: number, + step: DynamicMoveStep, + tile: T, + mover: IObjectMover + ): Promise; +} + +interface IObjectMover extends IHookable< + IObjectMoverHooks +> { + readonly moving: boolean; + readonly tile: T; + readonly faceDirection: FaceDirection; + readonly moveDirection: FaceDirection; + + step(dir: FaceDirection, count?: number): this; + stepFace(move: FaceDirection, face: FaceDirection, count?: number): this; + forward(count?: number): this; + backward(count?: number): this; + speed(value: number): this; + face(dir: FaceDirection): this; + animDir(dir: DynamicAnimDirection): this; + clear(): this; + start(): IMoverController | null; +} + +// 这里的 onStepStart 不再接受 code 作为参数,因为这应当是其返回值 `Promise`, +// 具体来说,这四个方法是整个移动的核心方法而非钩子,它应当提供接口让子类实现, +// 子类应当真正执行移动效果,并进行一定的控制。显然子类是没办法知道 onStepStart 中的信息的, +// 所以才提供了一个 code,用于子类向 ObjectMover 提供信息,然后父类再传递给子类, +// 从而子类了解到 onStepStart 中的信息,再进一步决定这一步真正应该如何执行。 +// 而对于钩子,实际上是在子类的 onStepStart 执行完毕后进行的,所以是知道 code 的,所以才是参数。 +// 你应该好好想一想这之间的关系。 +// 以及子类是要求必须实现的,所以不应该会有返回 `void` 的场景,四个方法都应该返回 `Promise`。 +// 总的来说,父类是一个“系统级”的流程控制器,它通过这四个接口来实现真正的移动控制, +// 就像 legacy ObjectMoverBase 中的 startMove 一样。 +abstract class ObjectMover< + T extends IObjectMovable +> implements IObjectMover { + abstract onMoveStart(tile: T, mover: IObjectMover): Promise; + abstract onMoveEnd(tile: T, mover: IObjectMover): Promise; + /** 子类计算并返回本步 code;父类取得 code 后再触发 IObjectMoverHooks.onStepStart 钩子 */ + abstract onStepStart( + step: DynamicMoveStep, + tile: T, + mover: IObjectMover + ): Promise; + /** 子类依据 code 执行本步实际逻辑效果;父类在此之后触发 IObjectMoverHooks.onStepEnd 钩子 */ + abstract onStepEnd( + code: number, + step: DynamicMoveStep, + tile: T, + mover: IObjectMover + ): Promise; + // 计划构建方法(step/stepFace/forward/backward/speed/face/animDir/clear/start)在此实现 +} + +// faceDirection/moveDirection 已提升至 IObjectMover,IDynamicMover 无需额外声明 +// IDynamicMoverHooks 已移除,直接使用 IObjectMoverHooks +interface IDynamicMover extends IObjectMover { + // 继承自 IObjectMover:moving, faceDirection, moveDirection, tile, 全部计划构建方法及钩子 +} +``` + +--- + +# 问题 + +1. **`backward()` 的移动方向基准** ✅ 已确定 + + `backward()` 以 `opposite(tile.moveDirection)` 作为移动方向(基于 `moveDirection` 取反,非 `faceDirection`)。 + `moveDirection` 更新为反向,`faceDirection` 通过转向逻辑跟随同步更新。 + +2. **`forward()` 是否更新 faceDirection** ✅ 已确定 + + `forward()` 沿当前 `moveDirection` 移动,`moveDirection` 不变, + `faceDirection` 通过转向逻辑对齐为 `moveDirection`。 + +3. **转向步触发钩子方式** ✅ 已确定 + + 统一经由 `onStepStart(code, step, tile, mover)` 钩子处理,在步骤逻辑执行前触发。 + 渲染端通过 `step.type === DynamicMoveStepType.Face` 区分纯转向步与位移步; + `onStepStart` 触发时 `tile.x/y` 为原始坐标,渲染端可直接读取,无需反推。 + 转向逻辑(`faceDirection`/`num` 更新)在 `onStepEnd` 阶段执行。 + +4. **`animDir` 与渲染钩子集成** ✅ 已确定 + + `onStepStart` 统一接收所有步骤,`IDynamicMoveAnimDir` 步骤本身即为信息载体。 + 渲染端在 `onStepStart` 收到 `AnimDir` 步骤时更新本地动画方向状态; + 后续方向步的 `onStepStart` 触发时,渲染端依据本地状态决定动画播放方向。 + +5. **玩家移动与图块移动的复用** ✅ 已确定 + + 玩家(勇士)移动逻辑与 `IDynamicMover` 高度一致,共同抽象为 + `IObjectMover`(参考 legacy `ObjectMoverBase`,适配新 API); + 核心抽象类为 `abstract class ObjectMover`,含四个抽象方法组成控制流: + - **计划构建方法**(`step`/`stepFace`/`forward`/`backward`/ + `speed`/`face`/`animDir`/`clear`/`start`)全部在 `ObjectMover` 基类实现,子类无需重复声明。 + - **移动流程钩子**(`IObjectMoverHooks`,实现 `IHookable>`): + - `onMoveStart(mover, tile)`:整次移动队列开始时触发; + - `onMoveEnd(mover, tile)`:整次移动队列结束时触发; + - `onStepStart(code, step, tile, mover)`:单步开始前触发(`tile.x/y` 为移动前坐标); + - `onStepEnd(code, step, tile, mover)`:单步完成后触发(`tile.x/y` 已更新); + 其中 `code: number` 标识移动场景(如碰墙、遇敌、循环地图等),由具体实现层设置。 + - **四个抽象方法**(`onMoveStart`/`onMoveEnd`/`onStepStart`/`onStepEnd`):子类(`DynamicMover`/`HeroMover`) + 通过实现这四个方法完全控制移动行为,执行流程由 `ObjectMover` 基类统一调度。 + 关键:`onStepStart` 返回 `Promise`(code),父类将此 code 传给钩子后再调用 `onStepEnd(code, ...)`, + 子类在 `onStepEnd` 中依据 code 执行实际状态变更。四个方法均返回 `Promise`,无 `void` 选项。 + - **泛型参数** `T extends IObjectMovable`:`IObjectMovable` 是可移动对象的最小接口 + (`x`、`y`、`moveDirection`、`faceDirection` 只读属性 + + `setPos`/`setMoveDirection`/`setFaceDirection` 方法),定义在 `dynamic-tile.md`; + `IDynamicTile extends IObjectMovable`,玩家侧使用 `IHeroMovable extends IObjectMovable`。 + - **`IDynamicMover extends IObjectMover`**: + 无额外成员,直接继承基类全部能力(含 `faceDirection`/`moveDirection` 及 `IObjectMoverHooks`)。 diff --git a/docs/dev/map/dynamic-tile.md b/docs/dev/map/dynamic-tile.md new file mode 100644 index 0000000..3d59a4e --- /dev/null +++ b/docs/dev/map/dynamic-tile.md @@ -0,0 +1,407 @@ +# 需求综述 + +当前地图系统仅支持静态图块(通过 `IMapLayer` / `Uint32Array` 存储), +无法支持可移动的动态图块(例如 NPC、推箱子、可交互物件等)。 +动态图块的核心特点: + +1. **允许重叠**——同一格点可同时存在多个动态图块; +2. **整数坐标**——始终处于格点整数坐标,不出现小数,便于渲染优化与交互; +3. **异步移动**——移动接口返回 `Promise`,配合渲染动画; +4. **朝向转向**——部分图块移动时需同步更新朝向(如四方向 NPC 行走图)。 + +--- + +# 实现思路 + +## 1. 动态图块标识方案 + +**采用引用标识**:`createDynamicTile` 与 `transferToDynamic` 均返回 +`IDynamicTile` 对象引用,调用方持有该引用作为后续操作(移动、删除、 +设置朝向等)的唯一凭证。无需维护任何 ID,无唯一性管理负担。 + +调用方可将返回的 `IDynamicTile` 引用存储在自己的数据结构中, +`tile.x`、`tile.y`、`tile.num`、`tile.direction` 等字段始终反映图块的 +最新状态——内部实现类持有可变字段,接口以 `readonly` 暴露给外部, +保证外部不能直接修改,内部可通过类实例更新。 + +## 2. 坐标表示 + +使用独立的 `x: number, y: number` 参数,与现有 `IMapLayer` 接口风格 +(如 `setBlock(block, x, y)`、`getBlock(x, y)`)保持一致,不引入 `ITileLocator`。 + +## 3. 方向表示 + +移动方向与转向方向统一使用 `FaceDirection` 枚举: + +- 语义明确,与已有 `IRoleFaceBinder`(朝向绑定)直接对接; +- 已有工具函数 `getFaceMovement(dir)` 可将其转换为坐标偏移,无需重复实现; +- 多步路径(`moveDynamicWith` 的 `steps` 参数)使用 `FaceDirection[]`。 + +`IDirectionDescriptor` 更偏向纯数学计算(用于范围迭代等), +语义上不适合表达「图块朝向某方向移动」的含义。 + +**已确定**:移动方向(`moveDirection`)与朝向(`faceDirection`)分离为两个独立字段。 +后退等相对移动必须依赖两者分离才能正确表达(例如面朝上、向下后退时,`faceDirection` +保持 `Up`,`moveDirection` 更新为 `Down`)。 + +移动系统另立文档独立设计,包含双方向模型、`IDynamicMover` 抽象、 +计划式移动接口及执行流程等完整方案,见 +[动态图块移动系统](./dynamic-tile-move.md)。 + +`moveDynamicStep` / `moveDynamicWith` 保留为命令式便捷接口,适用于单次简单移动; +复杂路径和计划式移动统一使用 `IDynamicMover`。 + +## 4. 动态图块数据模型与图层归属 + +动态图块按**所属静态图层**组织层级关系:每个 `IMapLayer` 持有一个 +`IDynamicLayer`,动态图块的渲染深度即为其所属 `IMapLayer` 的 `zIndex`, +与静态图块一致。因此 `IDynamicLayer` 接口挂载在 `IMapLayer` 上, +而不是 `ILayerState` 上(见第 5 节)。 + +每个动态图块存储以下信息: + +```ts +/** 可移动对象的最小公共接口,供 `IObjectMover` 泛型约束使用 */ +interface IObjectMovable { + readonly x: number; + readonly y: number; + readonly moveDirection: FaceDirection; + readonly faceDirection: FaceDirection; + setPos(x: number, y: number): void; + setMoveDirection(dir: FaceDirection): void; + setFaceDirection(dir: FaceDirection): void; +} + +interface IDynamicTile extends IObjectMovable { + readonly num: number; // 图块数字(决定渲染图像) + readonly layer: IDynamicLayer; // 所属动态图层 + readonly mover: IDynamicMover; // 绑定的移动器(计划式移动) + /** 删除此图块,转发至 layer.deleteDynamic */ + delete(): void; + /** 还原为静态图块,转发至 layer.transferToStatic */ + toStatic(): void; + /** 还原为静态图块,如果当前位置有东西则不转换,转发只 layer.transferToStaticIfSafe */ + toStaticIfSafe(): boolean; + /** + * 单步便捷移动接口,等价于 `mover.step(dir, count); return mover.start();`。 + * 适用于简单移动场景,复杂路径通过 `tile.mover` 访问。 + */ + step(dir: FaceDirection, count?: number): IMoverController | null; +} +``` + +`x`、`y`、`moveDirection`、`faceDirection` 及 `setPos`/`setMoveDirection`/`setFaceDirection` +均由 `IObjectMovable` 提供,`IDynamicTile` 不再重复声明。 +`IObjectMover` 以此接口为泛型约束, +使图块和玩家 mover 共享核心执行逻辑;具体渲染效果通过 `IObjectMoverHooks` 的 `onStepStart`/`onStepEnd` 钩子实现。 + +`DynamicLayer` 内部维护两个结构: + +- `tileSet: Set` — 所有图块的集合,用于迭代与归属判断; +- `posMap: Map>>` — 按坐标索引 + (外层 key = y,内层 key = x,value = 该格点所有图块对象集合), + 图块移动时同步更新,支持越界坐标(见第 4.1 节)。 + +调用方持有 `IDynamicTile` 对象引用即可完成所有操作, +`posMap` 仅供按坐标查询(`getDynamicTilesAt`)使用, +因使用频率低,嵌套 `Map` 的开销完全可以接受。 + +`IDynamicTile` 上的 `moveStep`/`moveWith`/`delete`/`setDirection`/`setPos`/`toStatic` +均为便捷转发方法,内部直接调用 `tile.layer` 对应接口,不修改任何内部状态。 +调用方可直接通过 `tile.xxx()` 操作,无需额外持有 `IDynamicLayer` 引用。 + +### 4.1 越界移动 + +动态图块的坐标不受楼层 `width`/`height` 约束,允许出现负值或超出地图范围的坐标。 +这满足了如「图块飞出屏幕」「过场动画」等临时越界需求。 +`DynamicLayer` 无需感知楼层尺寸,`resizeLayer` 也无需通知 `DynamicLayer`。 + +## 5. IDynamicLayer 挂载在 IMapLayer 上 + +动态图块的存储结构与 `IMapLayer`(Uint32Array)本质不同,独立封装为 `IDynamicLayer`。 +`IMapLayer` 新增 `readonly dynamicLayer: IDynamicLayer` 属性, +调用方通过具体的图层对象操作该层的动态图块,z 层级天然与静态图块一致。 + +`IDynamicLayer` 实现 `IHookable` 以支持渲染端订阅变更事件。 +`IRoleFaceBinder` 通过 `setFaceBinder(binder)` 方法注入(见第 8 节), +不在构造时传入,使多楼层共用同一个 `binder` 实例更灵活。 + +`transferToDynamic(x, y)` 不需要 `layer` 参数, +因为它隶属于某个具体的 `IDynamicLayer`,直接从同级 `IMapLayer` 读写图块即可, +并返回创建的 `IDynamicTile` 引用。 + +## 6. 移动函数的坐标来源 + +`moveDynamicStep(direction, tile)` 与 `moveDynamicWith(steps, tile)` 均不接受 +`ox, oy` 参数,改为直接接受 `IDynamicTile` 引用。当前坐标直接读取 `tile.x`、 +`tile.y`,无需调用方额外传入。 + +内部行为: + +- 直接读取 `tile.x`、`tile.y` 作为出发点; +- **立即**将图块逻辑坐标更新为目标位置(单步 `(tile.x+dx, tile.y+dy)`); +- 同步更新 `posMap`; +- 触发 `onMoveTile` 钩子,收集所有钩子返回的 `Promise | void`; +- `await Promise.all(promises)`—— + 等待所有渲染动画完成后,本步才算兑现,再进入下一步。 + +详细多步路径与计划式移动的执行流程见 +[动态图块移动系统](./dynamic-tile-move.md)。 + +## 7. transferToDynamic 与 transferToStatic + +`transferToDynamic(x, y)` 不需要额外的 `layer` 参数。 +`DynamicLayer` 在构造时持有对所属 `MapLayer` 的引用, +直接调用 `mapLayer.getBlock(x, y)` 读取图块数字,再调用 `mapLayer.setBlock(0, x, y)` 清除, +最后创建对应的动态图块并返回其引用。 +若该位置图块为 0(空白),则发出 logger 警告并仍然创建 `num = 0` 的动态图块。 + +新增 `transferToStaticIfSafe(tile)` 作为安全版本:仅当目标位置静态图块为 0(空白)时才执行还原, +否则不转换并返回 `false`;转换成功返回 `true`。适用于不确定目标格是否已有图块的场景。 + +新增 `transferToStatic(tile)` 作为逆操作:将动态图块还原为静态图块。 +对于「只移动一次就固定」的图块(如推箱子放到指定位置后不再移动), +及时转回静态存储可以降低存档体积、简化渲染订阅。执行流程: + +1. 读取 `tile.x`、`tile.y`、`tile.num`; +2. 若坐标不在所属 `MapLayer` 的合法范围内(即 `x < 0`、`y < 0`、 + `x >= width` 或 `y >= height`),发出 logger 警告并放弃操作; +3. 若 `mapLayer.getBlock(tile.x, tile.y) !== 0`,在开发环境下发出 logger 警告 + (目标格点已有静态图块内容,将被覆盖); + 调用 `mapLayer.setBlock(tile.num, tile.x, tile.y)` 写回静态图块; +4. 从 `tileSet` 和 `posMap` 中移除该图块,触发 `onDeleteTile` 钩子。 + +## 8. 转向逻辑 + +`moveDynamicStep` / `moveDynamicWith` 在每一步移动时自动更新朝向(使用双方向模型, +详见 [动态图块移动系统](./dynamic-tile-move.md)): + +1. 更新 `tile.moveDirection` 为本步实际移动方向; +2. 通过外部注入的 `IRoleFaceBinder` 调用 `getFaceOf(tile.num, direction)` 查询 + 该方向对应的图块数字; +3. 若图块无朝向绑定(返回 `null`),则不修改 `num`,仅更新 `faceDirection`; +4. 若有绑定,将 `tile.num` 与 `tile.faceDirection` 更新为查询结果。 + +`IRoleFaceBinder` 通过 `setFaceBinder(binder: IRoleFaceBinder): void` 方法注入, +不在构造时传入;初始状态视为无朝向绑定(`getFaceOf` 始终返回 `null`)。 +若多个楼层共用同一个 `RoleFaceBinder` 实例,直接对各楼层各图层分别调用 `setFaceBinder` 即可。 + +对于八方向移动,转向查询逻辑扩展如下: + +1. 更新 `tile.moveDirection` 为本步实际移动方向; +2. 调用 `getFaceOf(tile.num, direction)` 查询; +3. 若返回 `null` 且当前方向为斜向(八方向之一), + 调用 `degradeFace(direction)` 降级为四方向后再查询一次; +4. 若仍返回 `null`,不修改 `num`,仅更新 `faceDirection`; +5. 若查询到结果,将 `tile.num` 更新为结果图块数字,并更新 `faceDirection`。 + +手动设置朝向通过独立接口 `setDynamicDirection(tile, direction)` 完成, +逻辑与上述步骤 1–5 相同,但不触发移动。 + +## 9. IDynamicLayerHooks 与 ILayerStateHooks + +`IDynamicLayerHooks` 定义三个钩子: + +- `onCreateTile(tile: IDynamicTile)`:图块被创建(含转换)时触发; +- `onDeleteTile(tile: IDynamicTile)`:图块被删除时触发(传入删除前的快照); +- `onMoveTile(tile: IDynamicTile, fromX: number, fromY: number): Promise | void`: + 图块移动一步时触发,`tile` 为更新后的状态,`fromX`/`fromY` 为移动前坐标。 + 返回 `Promise` 时,移动器将等待其兑现后再进行下一步(配合 `Promise.all` 并行等待所有订阅方)。 + +`ILayerStateHooks` 新增对应的三个转发钩子,额外携带 `layer: IMapLayer` 参数, +与现有的 `onUpdateLayerArea`、`onResizeLayer` 等钩子风格一致: + +- `onCreateDynamicTile(layer: IMapLayer, dynamicLayer: IDynamicLayer, tile: IDynamicTile)` +- `onDeleteDynamicTile(layer: IMapLayer, dynamicLayer: IDynamicLayer, tile: IDynamicTile)` +- `onMoveDynamicTile(layer: IMapLayer, dynamicLayer: IDynamicLayer, tile: IDynamicTile, fromX: number, fromY: number)` + +`LayerState` 在为每个 `MapLayer` 注册 `StateMapLayerHook` 时, +同步订阅该层 `dynamicLayer` 的三个钩子,将事件加上 `layer`、`dynamicLayer` 参数后转发至楼层钩子。 + +--- + +# 涉及文件 + +> 移动相关的接口(`DynamicMoveDir`、`IDynamicMoveStep`、`IMoverController`、`IDynamicMover`) +> 设计详情见 [动态图块移动系统](./dynamic-tile-move.md), +> 同样需要添加至 `types.ts`。 + +## 需要引用的文件 + +- `@motajs/common`:`IHookable`, `IHookBase`, `Hookable`, `logger` +- `@user/data-base/src/common`:`FaceDirection`, `IRoleFaceBinder`, + `getFaceMovement`, `degradeFace` +- `@user/data-base/src/map/types.ts`:`IMapLayer`, `IMapLayerHooks`, + `ILayerState`, `ILayerStateHooks` + +## 需要修改的文件 + +### `@user/data-base/src/map/types.ts` + +- [ ] 新增 `IObjectMovable` 接口: + 含 `x`、`y`、`moveDirection`、`faceDirection` 四个只读属性及 + `setPos`/`setMoveDirection`/`setFaceDirection` 三个方法; + 供 `IObjectMover` 泛型约束使用 +- [ ] 新增 `IDynamicTile extends IObjectMovable` 接口: + 含 `num`、`layer`、`mover` 三个只读属性及 `delete`/`toStatic` 两个便捷方法; + 无 `id` 字段,坐标/方向成员由 `IObjectMovable` 提供 +- [ ] 新增移动相关类型(详见移动文档接口定义汇总,已移至 `move/types.ts`): + `DynamicMoveStepType`、`DynamicMoveStep`、`IMoverController`、 + `IObjectMoverHooks`、`IObjectMover`、`IDynamicMover` +- [ ] 新增 `IDynamicLayerHooks extends IHookBase` 接口: + 含 `onCreateTile`、`onDeleteTile`、`onMoveTile` 三个钩子方法 +- [ ] 新增 `IDynamicLayer extends IHookable` 接口: + - [ ] `createDynamicTile(num, x, y): IDynamicTile`: 在指定位置创建动态图块, + 返回图块引用 + - [ ] `transferToDynamic(x, y): IDynamicTile`: 从所属静态图层读取并清除 + 指定位置图块,创建对应动态图块并返回引用 + - [ ] `transferToStatic(tile: IDynamicTile)`: 将动态图块还原为静态图块; + 坐标越界则警告并放弃,否则写回静态图层并触发 `onDeleteTile` + - [ ] `transferToStaticIfSafe(tile: IDynamicTile): boolean`: 仅当目标位置静态图块为 0 + 时才还原,否则不转换;返回是否转换成功 + - [ ] `deleteDynamicTile(tile: IDynamicTile)`: 删除指定图块,不在此层则警告 + - [ ] `getDynamicTilesAt(x, y)`: 获取指定格点所有动态图块的可迭代对象 + - [ ] `moveDynamicWith` / `moveDynamicStep` 已由 `IDynamicMover` 权其责。 + `IDynamicLayer` 不再提供此两个方法,删除对应条目。 + - [ ] `setDynamicDirection(tile: IDynamicTile, direction)`: 手动设置朝向, + 同步更新 `direction` 与 `num`(若有朝向绑定) + - [ ] `setDynamicPos(tile: IDynamicTile, x: number, y: number)`: 直接设置图块位置, + 同步更新 `posMap`,触发 `onMoveTile` 钩子,不更新朝向 + - [ ] `setFaceBinder(binder: IRoleFaceBinder)`: 注入朝向绑定器 +- [ ] 修改 `IMapLayer`:新增 `readonly dynamicLayer: IDynamicLayer` +- [ ] 修改 `ILayerStateHooks`:新增 `onCreateDynamicTile`、`onDeleteDynamicTile`、 + `onMoveDynamicTile` 三个转发钩子,额外携带 `layer: IMapLayer` 与 + `dynamicLayer: IDynamicLayer` 两个参数 + +### `@user/data-base/src/map/dynamicLayer.ts`(新文件) + +- [ ] 实现 `DynamicLayer extends Hookable` 类, + 实现 `IDynamicLayer` +- [ ] 实现内部类 `DynamicTile implements IDynamicTile`: + 持有 `layer: IDynamicLayer` 引用,`delete`/`toStatic` 转发至 `layer`; + `step(dir, count?)` 封装为便捷方法 +- [ ] `DynamicTile` 构造时同步创建 `readonly mover: DynamicMover`, + 移动调度由 `move/dynamicMover.ts` 中的 `DynamicMover` 类实现 +- [ ] `private mapLayer: IMapLayer`:构造参数,所属静态图层引用, + 供 `transferToDynamic` / `transferToStatic` 读写使用 +- [ ] `private faceBinder: IRoleFaceBinder | null = null`:朝向绑定器, + 通过 `setFaceBinder` 注入 +- [ ] `private tileSet: Set`:所有图块的集合,用于迭代与归属判断 +- [ ] `private posMap: Map>>`: + 按坐标索引(外层 key = y,内层 key = x),支持越界坐标 +- [ ] 实现全部接口方法,移动时同步更新 `tileSet` 与 `posMap` + +### `@user/data-base/src/map/mapLayer.ts` + +- [ ] 新增 `readonly dynamicLayer: DynamicLayer`:构造时以 `this` 为参数创建 + +### `@user/data-base/src/map/layerState.ts` + +- [ ] 修改 `StateMapLayerHook`(或 `addLayer` 中的订阅逻辑): + 在为每个 `MapLayer` 注册钩子时,同时订阅其 `dynamicLayer` 的三个钩子, + 将事件加上 `layer`、`dynamicLayer` 参数后转发至楼层的 `ILayerStateHooks` + +--- + +# 接口定义汇总 + +以下为本次新增与修改的完整接口签名,供实现时参考。 + +移动相关接口(`IObjectMoverHooks`、`IObjectMover`、`IDynamicMover`、`IMoverController`、`DynamicMoveStep` 等) +详见 [dynamic-tile-move.md](./dynamic-tile-move.md) 接口定义汇总,此处不再重复。 + +## 新增接口 + +### IObjectMovable + +```ts +interface IObjectMovable { + readonly x: number; + readonly y: number; + readonly moveDirection: FaceDirection; + readonly faceDirection: FaceDirection; + setPos(x: number, y: number): void; + setMoveDirection(dir: FaceDirection): void; + setFaceDirection(dir: FaceDirection): void; +} +``` + +### IDynamicTile + +```ts +interface IDynamicTile extends IObjectMovable { + readonly num: number; + readonly layer: IDynamicLayer; + readonly mover: IDynamicMover; + delete(): void; + toStatic(): void; + toStaticIfSafe(): boolean; + /** 等价于 mover.step(dir, count); return mover.start(); */ + step(dir: FaceDirection, count?: number): IMoverController | null; +} +``` + +### IDynamicLayerHooks + +```ts +interface IDynamicLayerHooks extends IHookBase { + onCreateTile(tile: IDynamicTile, layer: IDynamicLayer): void; + onDeleteTile(tile: IDynamicTile, layer: IDynamicLayer): void; +} +``` + +### IDynamicLayer + +```ts +interface IDynamicLayer extends IHookable { + createDynamic(num: number, x: number, y: number): IDynamicTile; + transferToDynamic(x: number, y: number): IDynamicTile; + transferToStatic(tile: IDynamicTile): void; + transferToStaticIfSafe(tile: IDynamicTile): boolean; + deleteDynamic(tile: IDynamicTile): void; + getDynamicTilesAt(x: number, y: number): Iterable; + setDynamicDirection(tile: IDynamicTile, direction: FaceDirection): void; + setDynamicPos(tile: IDynamicTile, x: number, y: number): void; + setFaceBinder(binder: IRoleFaceBinder): void; +} +``` + +## 修改接口 + +### IMapLayer(新增属性) + +```ts +interface IMapLayer { + // ...现有成员... + readonly dynamicLayer: IDynamicLayer; +} +``` + +--- + +# 问题 + +1. **`ox, oy` 是否为必要参数?** ✅ 已确定 + + 已确定移除。调用方持有 `IDynamicTile` 引用,坐标直接从 `tile.x`、`tile.y` 读取, + `ox, oy` 为冗余参数,接口简化为 `moveDynamicStep(direction, tile)` 和 + `moveDynamicWith(steps, tile)`。 + +2. **`IRoleFaceBinder` 的注入方式** ✅ 已确定 + + 已确定使用 `setFaceBinder(binder)` 方法注入,不在构造时传入。 + 实际修改 `faceBinder` 的场景极少,接口保持简单。 + +3. **动态图块的存档支持** ⏸ 暂缓 + + 动态图块状态(NPC 当前位置、推箱子位置等)属于游戏核心存档内容, + 长期来看必须纳入存档体系。但当前设计与旧引擎完全不同, + 存档格式需从头设计,建议在动态图块功能稳定后单独立项设计存档方案。 + **本次实现不涉及存档,`IDynamicLayer` 暂不实现 `ISaveableContent`。** + +4. **`IDynamicLayer` 是否需要感知楼层尺寸** ✅ 已确定 + + 已确定不感知。标识方案改为引用,`posMap` 同步改为 + `Map>>` 嵌套结构(外层 key = y,内层 key = x), + 彻底去掉字符串键,天然支持越界坐标,`resizeLayer` 无需通知 `DynamicLayer`。 + `getDynamicTilesAt` 接口保留,使用频率低,嵌套 Map 开销完全可接受。 diff --git a/docs/dev/map-store-improve.md b/docs/dev/map/map-store-improve.md similarity index 100% rename from docs/dev/map-store-improve.md rename to docs/dev/map/map-store-improve.md diff --git a/docs/dev/map-store-save.md b/docs/dev/map/map-store-save.md similarity index 100% rename from docs/dev/map-store-save.md rename to docs/dev/map/map-store-save.md diff --git a/docs/dev/save-system.md b/docs/dev/system/save-system.md similarity index 100% rename from docs/dev/save-system.md rename to docs/dev/system/save-system.md diff --git a/packages-user/data-base/src/common/index.ts b/packages-user/data-base/src/common/index.ts index 5fcbbcb..64a2b6f 100644 --- a/packages-user/data-base/src/common/index.ts +++ b/packages-user/data-base/src/common/index.ts @@ -1,3 +1,4 @@ export * from './face'; +export * from './mover'; export * from './types'; export * from './utils'; diff --git a/packages-user/data-base/src/common/mover.ts b/packages-user/data-base/src/common/mover.ts new file mode 100644 index 0000000..f6eb757 --- /dev/null +++ b/packages-user/data-base/src/common/mover.ts @@ -0,0 +1,579 @@ +import { + Hookable, + HookController, + IHookable, + IHookBase, + IHookController, + ITileLocator +} from '@motajs/common'; +import { FaceDirection } from './types'; + +//#region 对象移动 + +export const enum ObjectMoveStepType { + /** 绝对方向步,同步更新移动方向与朝向 */ + Dir, + /** 绝对方向步,显式指定朝向 */ + DirFace, + /** 速度步 */ + Speed, + /** 纯转向步 */ + Face, + /** 特殊步,如前进或后退 */ + Special, + /** 动画方向步 */ + AnimDir +} + +export const enum ObjectSpecialStep { + /** 前进 */ + Forward, + /** 后退 */ + Backward +} + +export const enum ObjectAnimDirection { + /** 正向播放动画 */ + Forward, + /** 反向播放动画 */ + Backward +} + +export interface IObjectMovable { + /** 当前横坐标 */ + readonly x: number; + /** 当前纵坐标 */ + readonly y: number; + + /** + * 设置对象位置 + * @param x 横坐标 + * @param y 纵坐标 + */ + setPos(x: number, y: number): void; + + /** + * 获取当前朝向 + */ + getCurrentFaceDirection(): FaceDirection; +} + +export interface IObjectMoveStepDir { + /** 步骤类型 */ + type: ObjectMoveStepType.Dir; + /** 本步移动方向 */ + move: FaceDirection; +} + +export interface IObjectMoveStepDirFace { + /** 步骤类型 */ + type: ObjectMoveStepType.DirFace; + /** 本步移动方向 */ + move: FaceDirection; + /** 本步显式朝向 */ + face: FaceDirection; +} + +export interface IObjectMoveStepSpeed { + /** 步骤类型 */ + type: ObjectMoveStepType.Speed; + /** 后续移动的每格耗时,单位为 ms */ + value: number; +} + +export interface IObjectMoveStepFace { + /** 步骤类型 */ + type: ObjectMoveStepType.Face; + /** 要设置的朝向 */ + value: FaceDirection; +} + +export interface IObjectMoveStepSpecial { + /** 步骤类型 */ + type: ObjectMoveStepType.Special; + /** 特殊步方向 */ + direction: ObjectSpecialStep; +} + +export interface IObjectMoveAnimDir { + /** 步骤类型 */ + type: ObjectMoveStepType.AnimDir; + /** 动画播放方向 */ + dir: ObjectAnimDirection; +} + +export type ObjectMoveStep = + | IObjectMoveStepDir + | IObjectMoveStepDirFace + | IObjectMoveStepSpeed + | IObjectMoveStepFace + | IObjectMoveStepSpecial + | IObjectMoveAnimDir; + +export interface IMoverController { + /** 本次移动是否已经全部完成 */ + done: boolean; + /** 当本次移动结束时兑现 */ + onEnd: Promise; + + /** + * 向当前移动队列末尾追加步骤 + * @param steps 要追加的步骤列表 + */ + push(...steps: ObjectMoveStep[]): void; + + /** + * 在当前步移动之后立刻插入指定步,顺序为参数传入的顺序 + * @param steps 要插入的步骤列表 + */ + insert(...steps: ObjectMoveStep[]): void; + + /** + * 停止当前移动,在当前步骤完成后兑现 + */ + stop(): Promise; +} + +export interface IObjectMoverHooks extends IHookBase { + /** + * 当移动开始时触发 + * @param tile 移动器绑定的图块 + * @param mover 当前移动器 + */ + onMoveStart?(tile: T, mover: IObjectMover): Promise; + + /** + * 当移动结束时触发 + * @param tile 移动器绑定的图块 + * @param mover 当前移动器 + */ + onMoveEnd?(tile: T, mover: IObjectMover): Promise; + + /** + * 当单步移动开始前触发,此时对象坐标仍为移动前坐标 + * @param code 当前步移动代码 + * @param step 当前步骤 + * @param tile 移动器绑定的图块 + * @param mover 当前移动器 + */ + onStepStart?( + code: number, + step: ObjectMoveStep, + tile: T, + mover: IObjectMover + ): Promise; + + /** + * 当单步移动结束后触发,此时对象坐标已更新为移动后坐标 + * @param code 当前步移动代码 + * @param step 当前步骤 + * @param tile 移动器绑定的图块 + * @param mover 当前移动器 + */ + onStepEnd?( + code: number, + step: ObjectMoveStep, + tile: T, + mover: IObjectMover + ): Promise; +} + +export interface IObjectMover extends IHookable< + IObjectMoverHooks +> { + /** 当前是否正在移动 */ + readonly moving: boolean; + /** 当前绑定的对象 */ + readonly tile: T; + /** 当前朝向 */ + readonly faceDirection: FaceDirection; + /** 当前移动方向 */ + readonly moveDirection: FaceDirection; + /** 当前动画播放方向 */ + readonly currAnimDir: ObjectAnimDirection; + /** 当前移动速度,单位毫秒 */ + readonly currentSpeed: number; + + /** + * 追加若干个绝对方向步,并同步更新移动方向与朝向 + * @param dir 移动方向 + * @param count 追加次数,默认 1 + */ + step(dir: FaceDirection, count?: number): this; + + /** + * 追加若干个绝对方向步,移动方向与朝向分别指定 + * @param move 移动方向 + * @param face 朝向方向 + * @param count 追加次数,默认 1 + */ + stepFace(move: FaceDirection, face: FaceDirection, count?: number): this; + + /** + * 追加若干个前进步,沿当前移动方向移动 + * @param count 追加次数,默认 1 + */ + forward(count?: number): this; + + /** + * 追加若干个后退步,沿当前移动方向的反方向移动 + * @param count 追加次数,默认 1 + */ + backward(count?: number): this; + + /** + * 设置后续移动速度 + * @param value 每格耗时,单位为 ms + */ + speed(value: number): this; + + /** + * 追加一个纯转向步骤 + * @param dir 目标朝向 + */ + face(dir: FaceDirection): this; + + /** + * 设置后续步骤的动画播放方向 + * @param dir 动画播放方向 + */ + animDir(dir: ObjectAnimDirection): this; + + /** + * 清空尚未执行的步骤队列 + */ + clear(): this; + + /** + * 开始执行当前计划队列 + * @returns 若当前已在移动,则返回 `null` + */ + start(): Readonly | null; +} + +//#endregion + +//#region 移动基类 + +export abstract class ObjectMover + extends Hookable> + implements IObjectMover +{ + abstract readonly tile: T; + + /** 尚未开始执行的移动步骤队列 */ + protected readonly moveQueue: Readonly[] = []; + + /** 当前是否正在移动 */ + moving: boolean = false; + /** 当前朝向 */ + faceDirection: FaceDirection = FaceDirection.Unknown; + /** 当前移动方向 */ + moveDirection: FaceDirection = FaceDirection.Unknown; + /** 当前动画播放方向 */ + currAnimDir: ObjectAnimDirection = ObjectAnimDirection.Forward; + /** 当前移动速度,单位毫秒 */ + currentSpeed: number = 100; + + /** 是否调用了 `IMoverController.stop` 接口 */ + private shouldStop: boolean = false; + + protected createController( + hook: Partial> + ): IHookController> { + return new HookController(this, hook); + } + + /** + * 当移动开始时执行 + * @param tile 移动图块 + * @param controller 移动控制器 + */ + protected abstract onMoveStart( + tile: T, + controller: Readonly + ): Promise; + + /** + * 当移动结束时触发 + * @param tile 移动图块 + * @param controller 移动控制器 + */ + protected abstract onMoveEnd( + tile: T, + controller: Readonly + ): Promise; + + /** + * 当单步移动开始时触发,返回移动代码,此移动代码将会传递至 {@link onStepEnd} + * @param step 当前移动步对象 + * @param tile 移动图块 + * @param controller 移动控制器 + */ + protected abstract onStepStart( + step: ObjectMoveStep, + tile: T, + controller: Readonly + ): Promise; + + /** + * 当单步移动结束时触发,返回坐标对象代表这一步移动结果 + * @param code 移动代码,由 {@link onStepStart} 返回值传递而来 + * @param step 当前移动步对象 + * @param tile 移动图块 + * @param controller 移动控制器 + */ + protected abstract onStepEnd( + code: number, + step: ObjectMoveStep, + tile: T, + controller: Readonly + ): Promise; + + /** + * 向计划队列末尾追加一个步骤 + * @param step 要追加的步骤 + */ + protected pushStep(step: Readonly): void { + this.moveQueue.push(step); + } + + /** + * 获取当前应当作为相对移动基准的方向 + */ + private getCurrentDirection(): FaceDirection { + if (this.moveDirection !== FaceDirection.Unknown) { + return this.moveDirection; + } else { + return this.faceDirection; + } + } + + // TODO: 需要做一个朝向系统以解决朝向难以处理的问题 + /** + * 获取指定方向的反方向 + * @param dir 当前方向 + */ + private getOppositeDirection(dir: FaceDirection): FaceDirection { + switch (dir) { + case FaceDirection.Left: + return FaceDirection.Right; + case FaceDirection.Up: + return FaceDirection.Down; + case FaceDirection.Right: + return FaceDirection.Left; + case FaceDirection.Down: + return FaceDirection.Up; + case FaceDirection.LeftUp: + return FaceDirection.RightDown; + case FaceDirection.RightUp: + return FaceDirection.LeftDown; + case FaceDirection.LeftDown: + return FaceDirection.RightUp; + case FaceDirection.RightDown: + return FaceDirection.LeftUp; + case FaceDirection.Unknown: + return FaceDirection.Unknown; + } + } + + /** + * 根据步骤内容预先同步移动器内部状态 + * @param step 当前步骤 + */ + private prepareStep(step: ObjectMoveStep): void { + switch (step.type) { + case ObjectMoveStepType.Dir: + this.moveDirection = step.move; + this.faceDirection = step.move; + break; + case ObjectMoveStepType.DirFace: + this.moveDirection = step.move; + this.faceDirection = step.face; + break; + case ObjectMoveStepType.Face: + this.faceDirection = step.value; + break; + case ObjectMoveStepType.Special: { + const dir = this.getCurrentDirection(); + if (step.direction === ObjectSpecialStep.Backward) { + const opposite = this.getOppositeDirection(dir); + this.moveDirection = opposite; + this.faceDirection = opposite; + } else { + this.moveDirection = dir; + this.faceDirection = dir; + } + break; + } + case ObjectMoveStepType.AnimDir: + this.currAnimDir = step.dir; + break; + case ObjectMoveStepType.Speed: + this.currentSpeed = step.value; + break; + } + } + + step(dir: FaceDirection, count: number = 1): this { + for (let i = 0; i < count; i++) { + this.pushStep({ + type: ObjectMoveStepType.Dir, + move: dir + }); + } + return this; + } + + stepFace( + move: FaceDirection, + face: FaceDirection, + count: number = 1 + ): this { + for (let i = 0; i < count; i++) { + this.pushStep({ + type: ObjectMoveStepType.DirFace, + move, + face + }); + } + return this; + } + + forward(count: number = 1): this { + for (let i = 0; i < count; i++) { + this.pushStep({ + type: ObjectMoveStepType.Special, + direction: ObjectSpecialStep.Forward + }); + } + return this; + } + + backward(count: number = 1): this { + for (let i = 0; i < count; i++) { + this.pushStep({ + type: ObjectMoveStepType.Special, + direction: ObjectSpecialStep.Backward + }); + } + return this; + } + + speed(value: number): this { + this.pushStep({ + type: ObjectMoveStepType.Speed, + value + }); + return this; + } + + face(dir: FaceDirection): this { + this.pushStep({ + type: ObjectMoveStepType.Face, + value: dir + }); + return this; + } + + animDir(dir: ObjectAnimDirection): this { + this.pushStep({ + type: ObjectMoveStepType.AnimDir, + dir + }); + return this; + } + + clear(): this { + this.moveQueue.length = 0; + return this; + } + + /** + * 开始移动流程 + * @param queue 移动队列 + */ + private async moveProgress( + queue: ObjectMoveStep[], + controller: Readonly + ) { + // 移动开始 + await this.onMoveStart(this.tile, controller); + await Promise.all( + this.forEachHook(hook => { + return hook.onMoveStart?.(this.tile, this); + }) + ); + + // 移动流程 + while (queue.length > 0) { + const step = queue.shift(); + if (!step || !this.moving || this.shouldStop) break; + this.prepareStep(step); + const code = await this.onStepStart(step, this.tile, controller); + const stepStartHooks = this.forEachHook(hook => + hook.onStepStart?.(code, step, this.tile, this) + ); + await Promise.all(stepStartHooks); + const loc = await this.onStepEnd(code, step, this.tile, controller); + this.tile.setPos(loc.x, loc.y); + const stepEndHooks = this.forEachHook(hook => + hook.onStepEnd?.(code, step, this.tile, this) + ); + await Promise.all(stepEndHooks); + } + + // 移动结束 + await this.onMoveEnd(this.tile, controller); + const moveEndHooks = this.forEachHook(hook => + hook.onMoveEnd?.(this.tile, this) + ); + await Promise.all(moveEndHooks); + } + + start(): IMoverController | null { + if (this.moving) return null; + + const queue = this.moveQueue.slice(); + this.clear(); + this.shouldStop = false; + + this.faceDirection = this.tile.getCurrentFaceDirection(); + this.moveDirection = FaceDirection.Unknown; + + const { promise, resolve } = Promise.withResolvers(); + + const controller: IMoverController = { + done: false, + onEnd: promise, + push: (...steps: ObjectMoveStep[]) => { + if (!this.moving || this.shouldStop || controller.done) { + return; + } + queue.push(...steps); + }, + insert: (...steps: ObjectMoveStep[]) => { + if (!this.moving || this.shouldStop || controller.done) { + return; + } + queue.unshift(...steps); + }, + stop: () => { + this.shouldStop = true; + return controller.onEnd; + } + }; + + this.moving = true; + const moving = this.moveProgress(queue, controller); + moving.then(() => { + this.moving = false; + controller.done = true; + resolve(); + }); + + return controller; + } +} + +//#endregion diff --git a/packages-user/data-base/src/map/dynamicLayer.ts b/packages-user/data-base/src/map/dynamicLayer.ts new file mode 100644 index 0000000..31a4026 --- /dev/null +++ b/packages-user/data-base/src/map/dynamicLayer.ts @@ -0,0 +1,142 @@ +import { + HookController, + Hookable, + IHookController, + ITileLocator, + logger +} from '@motajs/common'; +import { + IDynamicLayer, + IDynamicLayerHooks, + IDynamicTile, + IMapLayer +} from './types'; +import { FaceDirection, degradeFace } from '../common'; +import { DynamicTile } from './dynamicTile'; + +export class DynamicLayer + extends Hookable + implements IDynamicLayer +{ + /** 坐标到动态图块集合的映射,外层 key = y,内层 key = x,不使用 index 是为了支持地图外图块 */ + private readonly tilePosMap: Map>> = + new Map(); + /** 动态图块到其当前坐标的映射 */ + private readonly posTileMap: Map = new Map(); + + constructor(public readonly layer: IMapLayer) { + super(); + } + + protected createController( + hook: Partial + ): IHookController { + return new HookController(this, hook); + } + + createDynamic(num: number, x: number, y: number): IDynamicTile { + const tile = new DynamicTile(num, x, y, this); + 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 { + const num = this.layer.getBlock(x, y); + if (num === 0) { + logger.warn(127, x.toString(), y.toString()); + } + this.layer.setBlock(0, x, y); + return this.createDynamic(num, x, y); + } + + transferToStatic(tile: IDynamicTile): void { + 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; + } + if (this.layer.getBlock(x, y) !== 0) { + logger.warn(129, x.toString(), y.toString()); + } + this.layer.setBlock(tile.num, x, y); + this.removeTile(tile); + this.forEachHook(hook => { + void hook.onDeleteTile?.(tile, this); + }); + } + + transferToStaticIfSafe(tile: IDynamicTile): boolean { + if (this.layer.getBlock(tile.x, tile.y) !== 0) return false; + this.layer.setBlock(tile.num, tile.x, tile.y); + this.deleteDynamic(tile); + return true; + } + + async deleteDynamic(tile: IDynamicTile): Promise { + if (!this.posTileMap.has(tile)) { + logger.warn(130); + return; + } + this.removeTile(tile); + const hooks = this.forEachHook(hook => hook.onDeleteTile?.(tile, this)); + await Promise.all(hooks); + } + + getDynamicTilesAt(x: number, y: number): Iterable { + return this.tilePosMap.get(y)?.get(x) ?? new Set(); + } + + iterateDynamicTiles(): Iterable { + return this.posTileMap.keys(); + } + + setDynamicDirection(tile: IDynamicTile, direction: FaceDirection): void { + const numBefore = tile.num; + tile.setFaceDirection(direction); + if (tile.num !== numBefore) return; + const degraded = degradeFace(direction); + if (degraded !== direction) { + tile.setFaceDirection(degraded); + } + } + + updateDynamicTile(tile: IDynamicTile): void { + const oldPos = this.posTileMap.get(tile); + if (oldPos) { + this.removeTileFromPosMap(tile, oldPos.x, oldPos.y); + oldPos.x = tile.x; + oldPos.y = tile.y; + this.addTileToPosMap(tile, tile.x, tile.y); + } else { + this.addTileToPosMap(tile, tile.x, tile.y); + this.posTileMap.set(tile, { x: tile.x, y: tile.y }); + } + 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, + y: number + ): void { + this.tilePosMap.get(y)?.get(x)?.delete(tile); + } + + /** 从两个内部映射中移除图块记录 */ + private removeTile(tile: IDynamicTile): void { + const pos = this.posTileMap.get(tile); + if (pos) { + this.removeTileFromPosMap(tile, pos.x, pos.y); + } + this.posTileMap.delete(tile); + } +} diff --git a/packages-user/data-base/src/map/dynamicTile.ts b/packages-user/data-base/src/map/dynamicTile.ts new file mode 100644 index 0000000..f4702ed --- /dev/null +++ b/packages-user/data-base/src/map/dynamicTile.ts @@ -0,0 +1,72 @@ +import { isNil } from 'lodash-es'; +import { + FaceDirection, + IMoverController, + IObjectMover, + IRoleFaceBinder +} from '../common'; +import { IDynamicLayer, IDynamicTile } from './types'; +import { DynamicTileMover } from './mover'; + +export class DynamicTile implements IDynamicTile { + readonly mover: IObjectMover; + + /** 当前的朝向绑定对象 */ + private face: IRoleFaceBinder | null = null; + + constructor( + public num: number, + public x: number, + public y: number, + public readonly layer: IDynamicLayer + ) { + this.mover = new DynamicTileMover(this); + } + + setFaceBinder(binder: IRoleFaceBinder | null): void { + this.face = binder; + } + + setFaceDirection(direction: FaceDirection): number { + if (!this.face) return this.num; + const next = this.face.getFaceOf(this.num, direction); + if (next) { + this.num = next.identifier; + } + return this.num; + } + + delete(): Promise { + return this.layer.deleteDynamic(this); + } + + toStatic(): void { + this.layer.transferToStatic(this); + } + + toStaticIfSafe(): boolean { + return this.layer.transferToStaticIfSafe(this); + } + + step(dir: FaceDirection, count?: number): IMoverController | null { + if (this.mover.moving) return null; + this.mover.step(dir, count); + return this.mover.start(); + } + + setPos(x: number, y: number): void { + this.x = x; + this.y = y; + this.layer.updateDynamicTile(this); + } + + getCurrentFaceDirection(): FaceDirection { + if (this.face) { + const face = this.face.getFaceDirection(this.num); + if (isNil(face)) return FaceDirection.Unknown; + else return face; + } else { + return FaceDirection.Down; + } + } +} diff --git a/packages-user/data-base/src/map/index.ts b/packages-user/data-base/src/map/index.ts index 8a6081a..55e9fdb 100644 --- a/packages-user/data-base/src/map/index.ts +++ b/packages-user/data-base/src/map/index.ts @@ -1,4 +1,5 @@ export * from './layerState'; export * from './mapLayer'; export * from './mapStore'; +export * from './mover'; export * from './types'; diff --git a/packages-user/data-base/src/map/mapLayer.ts b/packages-user/data-base/src/map/mapLayer.ts index 326c4cf..372d468 100644 --- a/packages-user/data-base/src/map/mapLayer.ts +++ b/packages-user/data-base/src/map/mapLayer.ts @@ -1,11 +1,13 @@ import { isNil } from 'lodash-es'; import { + IDynamicLayer, IMapLayer, IMapLayerData, IMapLayerHookController, IMapLayerHooks } from './types'; import { Hookable, HookController, logger } from '@motajs/common'; +import { DynamicLayer } from './dynamicLayer'; // todo: 提供 core.setBlock 等方法的替代方法,同时添加 setBlockList,以及前景背景的接口 @@ -23,6 +25,8 @@ export class MapLayer /** 地图数据引用 */ private mapData: IMapLayerData; + readonly dynamicLayer: IDynamicLayer; + constructor(array: Uint32Array, width: number, height: number) { super(); this.width = width; @@ -35,6 +39,7 @@ export class MapLayer expired: false, array: this.mapArray }; + this.dynamicLayer = new DynamicLayer(this); } resize(width: number, height: number): void { diff --git a/packages-user/data-base/src/map/mover.ts b/packages-user/data-base/src/map/mover.ts new file mode 100644 index 0000000..c6cd03a --- /dev/null +++ b/packages-user/data-base/src/map/mover.ts @@ -0,0 +1,78 @@ +import { ITileLocator, logger } from '@motajs/common'; +import { + getFaceMovement, + ObjectMover, + ObjectMoveStep, + ObjectMoveStepType +} from '../common'; +import { IDynamicTile } from './types'; + +//#region 动态图块 + +const enum DynamicMoveCode { + /** 正常执行 */ + Success +} + +export class DynamicTileMover extends ObjectMover { + constructor(public readonly tile: IDynamicTile) { + super(); + } + + protected onMoveStart(): Promise { + return Promise.resolve(); + } + + protected onMoveEnd(): Promise { + return Promise.resolve(); + } + + protected onStepStart(): Promise { + return Promise.resolve(DynamicMoveCode.Success); + } + + protected onStepEnd( + code: number, + step: ObjectMoveStep, + tile: IDynamicTile + ): Promise { + if (code !== DynamicMoveCode.Success) { + logger.warn(126, 'DynamicMoveCode.Success (0)', code.toString()); + return Promise.resolve({ x: tile.x, y: tile.y }); + } + const locator: ITileLocator = { + x: tile.x, + y: tile.y + }; + switch (step.type) { + case ObjectMoveStepType.Dir: { + const { x, y } = getFaceMovement(step.move); + tile.setFaceDirection(step.move); + locator.x += x; + locator.y += y; + break; + } + case ObjectMoveStepType.DirFace: { + const { x, y } = getFaceMovement(step.move); + tile.setFaceDirection(step.face); + locator.x += x; + locator.y += y; + break; + } + case ObjectMoveStepType.Face: { + tile.setFaceDirection(step.value); + break; + } + case ObjectMoveStepType.Special: { + const { x, y } = getFaceMovement(this.moveDirection); + tile.setFaceDirection(this.faceDirection); + locator.x += x; + locator.y += y; + break; + } + } + return Promise.resolve(locator); + } +} + +//#endregion diff --git a/packages-user/data-base/src/map/types.ts b/packages-user/data-base/src/map/types.ts index 47f0bfc..49e6da5 100644 --- a/packages-user/data-base/src/map/types.ts +++ b/packages-user/data-base/src/map/types.ts @@ -1,5 +1,14 @@ import { IHookable, IHookBase, IHookController } from '@motajs/common'; -import { ISaveableContent } from '../common'; +import { + FaceDirection, + IMoverController, + IObjectMovable, + IObjectMover, + IRoleFaceBinder, + ISaveableContent +} from '../common'; + +//#region 静态图层 export interface IMapLayerData { /** 当前引用是否过期,当地图图层内部的地图数组引用更新时,此项会变为 `true` */ @@ -75,6 +84,9 @@ export interface IMapLayer extends IHookable< /** 图层纵深 */ readonly zIndex: number; + /** 此图层对应的动态图块图层,z 层级与静态图块一致 */ + readonly dynamicLayer: IDynamicLayer; + /** * 设置某一点的图块 * @param block 图块数字 @@ -154,6 +166,10 @@ export interface IMapLayer extends IHookable< setMapRef(array: Uint32Array): void; } +//#endregion + +//#region 图层管理 + export interface ILayerStateHooks extends IHookBase { /** * 当设置背景图块时执行,如果设置的背景图块与原先一样,则不会执行 @@ -288,6 +304,10 @@ export interface ILayerState extends IHookable { setDirty(dirty: boolean): void; } +//#endregion + +//#region 楼层管理 + /** 单个 MapLayer 的存档数据 */ export interface IMapLayerSave { readonly width: number; @@ -428,3 +448,149 @@ export interface IMapStore extends ISaveableContent { */ notifyEnterFloor(id: string): void; } + +//#endregion + +//#region 动态图块 + +export interface IDynamicLayerHooks extends IHookBase { + /** + * 当图块被创建(含从静态图块转换)时触发 + * @param tile 被创建的动态图块 + * @param layer 所属的动态图层 + */ + onCreateTile(tile: IDynamicTile, layer: IDynamicLayer): void; + + /** + * 当图块被删除时触发 + * @param tile 被删除的动态图块 + * @param layer 所属的动态图层 + */ + onDeleteTile(tile: IDynamicTile, layer: IDynamicLayer): Promise; + + /** + * 当更新动态图块的位置时触发(包括使用 `mover` 触发的移动) + * @param tile 更新位置的图块 + * @param layer 所属的动态图层 + */ + onUpdateTilePosition(tile: IDynamicTile, layer: IDynamicLayer): void; +} + +export interface IDynamicLayer extends IHookable { + /** 当前动态图层所属的静态图层 */ + readonly layer: IMapLayer; + + /** + * 在指定位置创建一个动态图块 + * @param num 图块数字 + * @param x 横坐标 + * @param y 纵坐标 + * @returns 创建的动态图块引用 + */ + createDynamic(num: number, x: number, y: number): IDynamicTile; + + /** + * 从所属静态图层读取并清除指定位置的图块,创建对应动态图块并返回引用。 + * 若该位置图块为 0,则发出警告并仍然创建 `num = 0` 的动态图块 + * @param x 横坐标 + * @param y 纵坐标 + * @returns 创建的动态图块引用 + */ + transferToDynamic(x: number, y: number): IDynamicTile; + + /** + * 将动态图块还原为静态图块。坐标越界则警告并放弃, + * 否则写回静态图层并触发 {@link IDynamicLayerHooks.onDeleteTile} + * @param tile 要还原的动态图块 + */ + transferToStatic(tile: IDynamicTile): void; + + /** + * 仅当目标位置静态图块为 0(空白)时才还原为静态图块,否则不转换 + * @param tile 要还原的动态图块 + * @returns 是否转换成功 + */ + transferToStaticIfSafe(tile: IDynamicTile): boolean; + + /** + * 删除指定动态图块,触发 {@link IDynamicLayerHooks.onDeleteTile} 钩子。 + * 若图块不属于此层则发出警告 + * @param tile 要删除的动态图块 + */ + deleteDynamic(tile: IDynamicTile): Promise; + + /** + * 获取指定格点上所有动态图块的可迭代对象 + * @param x 横坐标 + * @param y 纵坐标 + */ + getDynamicTilesAt(x: number, y: number): Iterable; + + /** + * 迭代所有的动态图块 + */ + iterateDynamicTiles(): Iterable; + + /** + * 手动设置动态图块的朝向,更新 `tile.num`(若有朝向绑定)。 + * 转向逻辑与移动时的转向逻辑相同,但不触发移动 + * @param tile 要设置朝向的动态图块 + * @param direction 目标朝向 + */ + setDynamicDirection(tile: IDynamicTile, direction: FaceDirection): void; + + /** + * 更新图块内部存储位置 + * @param tile 动态图块 + */ + updateDynamicTile(tile: IDynamicTile): void; +} + +export interface IDynamicTile extends IObjectMovable { + /** 当前图块数字 */ + readonly num: number; + /** 当前图块所属的动态图层 */ + readonly layer: IDynamicLayer; + /** 当前动态图块的移动器 */ + readonly mover: IObjectMover; + + /** + * 设置图块朝向,会一并修改 {@link num},返回设置后的当前图块数字 + * @param direction 图块朝向 + */ + setFaceDirection(direction: FaceDirection): number; + + /** + * 直接删除此图块 + */ + delete(): Promise; + + /** + * 将当前图块还原为静态图块 + */ + toStatic(): void; + + /** + * 还原为静态图块,如果当前位置有东西则不转换 + */ + toStaticIfSafe(): boolean; + + /** + * 单步便捷移动接口,适用于简单移动场景,复杂路径通过 `tile.mover` 访问。 + * 等价于: + * + * ```ts + * mover.step(dir, count); + * return mover.start(); + * ``` + */ + step(dir: FaceDirection, count?: number): IMoverController | null; + + /** + * 注入朝向绑定器,初始状态视为无朝向绑定 + * @param binder 朝向绑定器 + */ + setFaceBinder(binder: IRoleFaceBinder | null): void; +} + +//#endregion diff --git a/packages-user/data-state/src/enemy/calculator.ts b/packages-user/data-state/src/enemy/calculator.ts index 364ba32..4831ba6 100644 --- a/packages-user/data-state/src/enemy/calculator.ts +++ b/packages-user/data-state/src/enemy/calculator.ts @@ -16,10 +16,6 @@ export class MainDamageCalculator implements IDamageCalculator< /** 当前是否正在计算支援怪的伤害 */ private inGuard: boolean = false; - /** - * 计算战斗伤害信息 - * @param handler 信息对象 - */ calculate( handler: IReadonlyEnemyHandler ): IEnemyDamageInfo { @@ -153,11 +149,6 @@ export class MainDamageCalculator implements IDamageCalculator< }; } - /** - * 获取临界计算的上界 - * @param handler 信息对象 - * @param attribute 目标属性名 - */ getCriticalLimit( handler: IReadonlyEnemyHandler, attribute: CriticalableHeroStatus diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 20fe5f9..571c3b9 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -183,6 +183,11 @@ "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.", + "126": "Only move code '$1' is expected, but got '$2'.", + "127": "Block at $1,$2 is 0, the dynamic tile will have num 0.", + "128": "Position $1,$2 is out of map bounds, operation cancelled.", + "129": "Position $1,$2 is not empty, existing block will be overwritten.", + "130": "The given tile is not managed by this dynamic layer.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency." } } diff --git a/prompt.md b/prompt.md index 1d7a51f..f0bd64b 100644 --- a/prompt.md +++ b/prompt.md @@ -2,11 +2,12 @@ 以下规则必须时刻遵守,任何情况下都不允许违反。 -1. **不擅自修改已有代码**:将我已经写好的代码视为绝对正确。除非我**明确允许**,否则**不允许任何修改**,哪怕因为接口变化或其他原因导致其中出现类型错误。若认为我的代码存在逻辑错误,应在对话中提出,而不是直接修改。 +1. **不擅自修改已有代码**:将我已经写好的代码视为绝对正确。除非我**明确允许**,否则**不允许任何修改**,哪怕因为接口变化或其他原因导致其中出现类型错误。若认为我的代码存在逻辑错误,应在对话中提出,而不是直接修改,不要做任何“顺手”的事。 2. **不恢复我的修改**:我做的任何代码修改都是有原因的。若我在两次对话期间新增、删除或修改了部分代码,不要将其恢复。 3. **以目的驱动,而非以接口驱动**:实现前先想清楚我为什么要这样设计接口、这个接口设计的目的是什么,而不是单纯地以将接口填满为目标。 4. **遇到歧义立即提问**:若思考或实现时遇到任何问题——例如描述模糊、接口不清晰、某些地方存在歧义等——应立即向我提问,而不是按自己的想法去写。 -5. **按我说的方式重构**:若目标是重构某个接口,按照我指定的方式执行: +5. **接口缺失时停止并提问**:若完成需求所需的接口尚不存在,应立即停止实现,向我提出疑问,而不是擅自新增接口来推进。 +6. **按我说的方式重构**:若目标是重构某个接口,按照我指定的方式执行: - **彻底性重构**(新旧接口完全没有重合):按正常方式全新实现,旧代码仅作逻辑与思路上的参考。 - **结构性重构**(新旧接口基本一致,细节有差距):将旧代码搬移到新接口上后进行微调。**不要**擅自新增任何参数、方法或接口,**不要**仅通过新增兼容层的方式应对重构。 @@ -17,6 +18,8 @@ 1. **合理参考建议**:我有时会在对话中给出实现建议,应合理参考,切忌滥用。 2. **以类型标注为参考依据**:实现与类型标注有冲突时,以类型标注(一般是 `types.ts`)中的内容为准。 3. **发现接口问题时提问**:若认为类型标注中的接口设计有问题,或在实现中发现缺少某些接口,应向我提问是否添加,经我同意后方可添加。 +4. **接口设计兼顾合理性与便捷性**:设计接口时不仅要考虑合理性,还要考虑使用便捷性。罕见场景应当被支持,但不应与常见场景共用同一接口——这只会增加常见场景的使用难度。 +5. **避免多余的非空判断与类型守卫**:若某个类型已满足目标的类型约束,不应再对其添加任何判断或过滤操作。典型例子:`Promise.all` 对数组元素类型没有任何限制,传入 `(Promise | void)[]` 完全合法,无需多此一举地写成 `Promise.all(arr.filter(v => !!v))`。 **时刻谨记上述要求,避免一个需求反复修改仍无法满足预期。** @@ -29,7 +32,7 @@ ## 示例文档 -大致按照以下格式编写,如某部分需要详细描述,可单独开设标题。我会使用引用块的形式在文档中提出建议或回答。 +大致按照以下格式编写,如某部分需要详细描述,可单独开设标题;若某个接口内容较多,也可以在文档中为其单独开一个章节进行讲述。我会使用引用块的形式在文档中提出建议或回答。Markdown 文档不需要刻意换行,我的编辑器有自动换行功能,正常写没有问题。 ```md # 需求综述