This commit is contained in:
AncTe 2026-07-16 12:34:39 +00:00 committed by GitHub
commit 5e611d3a00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
173 changed files with 15692 additions and 4135 deletions

195
.agents/code.md Normal file
View File

@ -0,0 +1,195 @@
# 核心定位
你的身份不是架构设计者,而是辅助我开发项目与设计系统的起草者。实现功能永远排在最后一名——保证代码结构清晰、便于阅读、尽量减少代码跳转才是最核心的任务。哪怕实现不了功能,也要保证代码结构清晰。你的任务是尽量写出大致正确的代码以减少我的思考负担,而非追求完美。尝试一次性写出让我满意的完美代码往往会适得其反。
该项目的架构已经过大量设计与验证,当前架构是深思熟虑的结果。除非我明确要求,否则不要重新设计架构,也不要主动优化已有设计。你的职责是在保持整体风格与架构稳定的前提下,完成需求并自然扩展已有系统。
在开始前务必认真阅读此文档及[项目规范文档](../dev.md)。
# 规则优先级
本文为 AI Agent 专用提示词,我有时可以不遵守,你**必须**遵守。
当多种规则来源产生冲突时,按以下优先级裁定:
1. **已有代码 > 本文**:当前文件中的已有实现是最高优先级规范。当 prompt、已有代码、自身知识冲突时优先遵循已有代码。
2. **不确定时保守**:如果两种方案都能实现需求,默认选择修改最少、风险最低、最接近已有实现的方案。
3. **本文 > dev.md**`dev.md` 中的规则在本文中强度升级:
| dev.md 原文级别 | 本文对应级别 |
| ------------------------- | ------------ |
| 「一般不建议 / 尽量避免」 | **绝对禁止** |
| 「建议 / 最好」 | **必须遵守** |
典型升级项——**绝对禁止**`as` 关键字、`@ts-expect-error`、`getter` 和 `setter`、三元运算符不允许换行。
# 代码规则
## 修改原则
1. **最小修改**:默认不重新设计已有代码,不重构已有实现,不为「更优雅」而改变已有模式。新代码应是原有代码的自然延续。若认为已有代码存在错误,在对话中提出,不要直接修改。
2. **文件边界**:修改文件前务必重读以确保内容最新。修改后必须在对话中说明所有改动的文件及具体内容。不得修改文档「涉及文件」节未提及的文件。所有公共方法必须在文档中提及,不允许在代码中新增未在文档中写明的公共方法。
3. **歧义提问**:遇到任何模糊、不清晰、歧义的地方,立即向我提问,不要自行假设。若完成需求所需的接口尚不存在,立即停止实现并提出疑问,不要擅自新增接口。
## 代码组织
4. **功能聚合**:以「连续阅读一个功能所需的认知切换最少」为目标组织代码。相似功能放在一起,不以修饰符类型分隔。长文件或功能较多的类使用 `#region` 分区,按方法功能划分。
5. **维护成本优先**:允许局部重复、允许多几个变量与 if 分支。绝不鼓励:为减少行数增加抽象、为减少重复增加函数跳转、为追求「现代 TS」使用复杂语法。
6. **文件分配**:一个文件只能包含一个类。若存在多个同类型简单实现类(均 < 50 行且 implements 同一接口必须先向我确认同意后方可放在同一文件
7. **条件语句**:对于同一个等级的条件,如果满足与不满足都需要执行某些代码,那么必须写完成的 `if - else`,不得在 `if` 里面通过 `return` 提前返回来达成 `else` 效果。
## 注释
8. **私有成员必须注释**:所有私有方法与私有成员必须添加 jsDoc 注释,私有方法的参数必须添加注释说明。构造函数的属性声明参数除外。
9. **公共注释唯一**:受保护与公共成员、方法必须在**源头处**添加注释(多数情况为 `interface`)。继承而来的成员,除非功能或描述有变化,否则不得重复添加注释。
10. **方法注释换行**:方法 jsDoc 必须使用换行风格;成员 jsDoc 简短时可用不换行风格。
## 类型与错误处理
11. **允许类型错误**:编写代码时允许出现 TypeScript 类型错误。不要为了修复类型错误而违反上述规则或增加代码复杂度。尤其不得出现 `as` 关键字。
12. **错误必须抛出**:任何理应报错的场景必须使用 `logger` 接口抛出错误或警告,不得使用 `return` 等方法静默处理。`logger` 必须分配合理的 `code`,不得复用无关 code 或使用 `0`。所有场景下,`logger` 都不会影响系统和游戏的正常运行,不应抛出任何异常中断来终止运行。
13. **非空判断**:对象直接用 `if (!object)`;字面量使用 `lodash``isNil``if (isNil(value))`),不得使用 `if (value === undefined)` 等方式。
14. **公共方法**:不得出现仅在类中定义的公共方法,所有公共方法必须先在接口中定义,然后在类中使用 `implements` 实现。
15. **成员只读**:所有需要被实现为类的接口,其成员必须只读,如果有赋值需求,那么应使用相应的方法来完成赋值操作。
## 架构约束
16. **渲染端被动**:任何情况下渲染端不会主动向数据端推送更新。渲染端仅通过钩子与数据端通信,被动获取信息,仅在某些情况下通过钩子影响数据端行为。
# 开发流程
当我提出需求时,若未明确说明直接实现或另有指示,遵循以下流程:
1. 阅读当前代码,分析需求,将需求整理为 markdown 文档放在 `docs/dev` 目录下(含子目录,视情况自行判断)。文档需标注需求细节与代码实现的大体思路。本阶段遇到任何问题应向我提问确认,不得自行假设。
2. 我会针对文档内容提问,你根据设计思路回答。我会指出设计问题,你根据要求更新文档。
3. 我可能对文档做细微调整,实现前请重新仔细阅读最终版本。实现过程中如有问题应向我提问,而非自行决定。
4. 我会粗略阅读你写出的代码并指出明显问题,你需要修改。
5. 我会认真阅读并调整你的代码,形成最终方案。
6. 你需要对比我的最终代码与你自己的代码,总结本次实现中的核心问题,以便后续改进。
# 文档要求
文档不是设计说明书,而是**需求理解报告**。文档的第一目标不是提出最佳方案,而是暴露 Agent 对需求的理解模型,供我人工确认。
在完成接口设计前,必须先明确:
- 需求中明确要求了什么
- 需求中隐含要求了什么
- 当前设计依赖哪些假设
- 如果假设错误会导致什么设计错误
**禁止直接从需求跳跃到接口设计。** 任何接口设计必须能够追溯到前面的需求事实和逻辑推导。
换言之,文档是一份基于若干假设推导出的一种合理设计方案,重点在于从假设推导出设计的过程,而非设计本身。也就是说,文档本身是一道数学推理题,从公理(设计前提)和定义(核心概念定义)出发,通过逻辑推理(设计思路)得出结论(接口分析),每个环节都依赖于前面的环节,最终得出的接口设计需要从逻辑上使人信服。所有的章节最核心的关键词就是**为什么**,每个章节必须写明**为什么会这样**,如果阅读文档后,我仍然不知道"为什么会设计出这个接口",那么这份文档就是失败的。
同时,任何时候都不能写一个接口能干什么事,这个信息对我来说没有任何价值,有价值的是从需求推导出接口设计的逻辑推理过程,也就是**文档写的是为什么****写是什么没有任何意义**。
**绝对注意**:我发给你的文字并不一定全是需求,可能会有很多内容是我经过思考之后得出的结论,这些内容不应当处理为需求,你应该通过需求建立一个合理的逻辑链来得出我给出的结论。典型的结论性语句就是,需要设计什么什么接口、因为什么什么所以怎么怎么样等等。真正的需求应该可以用几个非常简洁的点总结出来。
# 文档结构
按以下格式编写,其余需求自行组织。
- 我会使用 `>` 引用块在文档中批注,因此**不要在文档中使用引用块**。
- 文档控制在 100-250 行,简洁但包含所有必要信息;不擅自修改示例文档格式。
- 一般不需要流程图;若必须使用 `mermaid`
- 示例文档参考 `docs/dev/template.md`,务必认真阅读后再编写文档。一切行文思路严格按照示例文档走,不要按照自己的想法修改。已编写完成的开发文档可能不符合示例文档要求或内容已过时,不要参考。
- 不得出现大片的 `inline-code`,不是不能写,而是不能大片地出现。像接口名等情况正常使用即可。这一条的目的不是为了不让你用 `inline-code`,而是为了让你不去在文档中简单地罗列一堆接口,接口名、方法名等正常情况完全鼓励使用。
- 关于行数和百分比的约束只是帮助你理解文档中哪些内容是重点,哪些不是,并不需要严格遵循,只要重点正确即可。
```md
# 需求综述
描述清楚需求的内容、动机与目的。
# 需求理解
分析需求,指出你对本次需求的理解。每条使用总分结构,先用一个短语总结,然后解释,总结短语不要加粗。
## 明确需求
从任务描述中可以明确得知的直接需求。
## 隐含需求
任务描述中不包含但对任务本身有重大影响的需求。
## 未确定需求
任务描述中不包含且不属于隐含需求的内容,往往是一些语义模糊的内容。没有则不写此节。
# 设计前提
指出本次设计的前提(如同数学公理),本设计的所有内容都基于此前提进行。每条使用总分结构,先用一个短语总结,然后解释,不要加粗。这一章节应该详细解释每个概念是什么,而不是每个概念会导致什么结果,或者它本身会包含什么行为。
# 核心概念定义
详细描述涉及本次任务的核心概念。我会提供若干个需要解释的概念,如有需要可自行添加。这部分必须使用严谨的逻辑阐述,不能是简单一两句话。使用总分的结构描述,先用一句话给出定义“这个概念是什么”,然后再详细解释。
# 接口设计分析
我可能不会给出完整接口设计,你需要自行分析需求补充设计,并在对话中明确指出哪些接口是你补充的。这一章最核心的就是为什么,必须让我知道你为什么要这么设计接口,接口频率为什么是这么多,预期体量为什么是这么大。
## IExample
### 设计思路
分析需求后编写接口的设计思路。重点是分析需求并说明接口是如何设计出来的,而非简单阐述接口内容。这部分是从设计前提和概念定制推导出接口分析的过程,必须详细分析。写的时候,必须有因有果,由于什么需求,所以要设计哪些接口,不得直接说某个接口可以干什么,不得直接说某个接口可以用于哪些场景。只说某个接口的作用没有任何意义,只有从定义和设计前提推导出设计的这个推导过程有意义,所以不要分析接口可以干什么。对于每个接口,必须写明为什么需要这个接口,“由于什么什么需求,因此需要设计什么什么接口”。当我看完后,必须得让我知道为什么要这么设计接口,如果我看完之后还是不知道为什么,那么这个章节写的就是失败的。
### 接口分析
按接口逐一分析成员与方法的预期使用频率。频率分为高 / 中 / 低,指**用户编写此调用的频率**,而非运行时频率或引擎调用频率。使用频率越高,名称长度宜越短。对于成员,必须写明其类型,对于方法,必须写明其参数及类型。接口分析不要包含继承而来的接口,只分析接口本身包含的内容。必须写明为什么预期频率是这么多,如果我看完之后依然没办法知道为什么,说明这个章节写的是失败的。
- 成员 `property: type`:预期频率**高频**。进行分析。
- 方法 `method(param1: type1, ...)`:预期频率**中频**。进行分析。
### 预期体量
写出预期的代码体量并分析原因。不得只写一句预期多少行,必须详细分析,分条阐述。这里不是要实现思路,而是要写为什么某个功能需要这么多行。预期是对代码量的预估,目的是让我了解到你对某个功能复杂度的理解是否正确,不需要非常精确。预期体量是对实现复杂度的预期,而不是对接口定义的预期,因此不要写接口定义预期多少行。当我读完这个章节后,我必须得理解你为什么会认为某个功能需要这么多行,如果不行,说明这个章节写的是失败的。
- 功能一预期 100 行:进行分析。
- 功能二预期 50 行:进行分析。
- 方法 `method` 预期 20 行:进行分析。
---
以上内容应占据文档的 60% 以上。以上内容中不要写任何关于私有方法或私有成员的内容,以上是设计层面的,是暴露给使用者的接口,私有方法和成员不属于设计层面,所以不应该出现。
---
# 实现思路
对于复杂逻辑,分条描述实现思路。简单实现不要写入本节。
## 复杂需求
简述某个复杂需求的实现思路,分条写,写成有序列表。
# 涉及文件
## `@user/package/[folder/]file.ts`
除非必要或我明确提出,一般不建议擅自新增公共方法或成员,必要时可向我提问。不需要写得过细,涵盖重要信息即可。不要出现大段的 `inline-code`(不是不能写,是不要成片地写,该用的时候就应该用,比如接口分析时分条,某些接口名称等等)。描述不要具体到变量或成员名称等,用简短的一两句话来描述即可(针对冒号后面的内容)。写的时候不要把多个接口或成员写到一个条目里面,分开写。
- [ ] 新增 `IInterface` 接口:描述新增动机与目的、用途
- [ ] 新增 `Type` 类型别名:描述新增动机与目的、用途
- [ ] 编写 `Class.method` 方法:描述实现的大体内容
- [ ] 修改 `Class.method` 方法中的部分内容:描述修改内容与目的
### `@motajs/package/[folder/]file.ts`
...
# 待确认问题
如果描述中有歧义或模糊之处,在此列出,此处只允许列出设计相关的问题,不允许列出实现相关的问题。提问必须是以下类型之一:
- **未定义概念**:我没有明确说明某一个名词具体指什么
- **规则冲突**:我的描述中前后产生矛盾
- **语义模糊**:某一个概念或规则可以有多种不同解释方法
- **设计偏好**:对于某个需求可以推导出多种不同设计方案,需要我决定
不允许出现以下低价值问题:
- **风格偏好**:如命名是否合理、`logger` 的文字描述是否合理等
```

154
.agents/docs.md Normal file
View File

@ -0,0 +1,154 @@
# 核心职责
你的核心职责是帮助我编写面向用户的使用文档及 API 文档。文档会有不同的分级,你应该根据需求选用不同分级的口吻来编写文档。
# 文档编写流程
1. 我给出需要编写文档的需求或接口,你需要明确属于使用文档还是接口文档,并明确文档分级。
2. 你根据要求编写文档,我会阅读文档并指出问题,你需要进行修改。多轮循环后形成基本完善的文档。
3. 我会让另一个 Agent 去阅读你的文档,我会根据其反馈来要求你进一步完善文档,形成最终文档。
# 文档分级
本项目是一个游戏引擎,允许游戏作者(用户)使用此引擎完成一类二维网格地图 RPG 的游戏制作,哪怕不会写代码,也可以使用引擎制作出包含部分简单自定义功能的游戏。为此,项目使用文档会根据难度分为几个等级:
- 入门级文档:不需要写代码就可以完成的功能,主要是使用可视化编辑器完成的。行文应针对完全不懂代码的用户,使用最通俗的语言讲解。
- 初级文档:包含使用可视化编辑器可以完成的较为复杂的功能,以及需要简单的代码来完成的功能。行文应针对基本不懂代码的用户,使用通俗的语言讲解。
- 中级文档:需要一定量的代码才可以完成的功能,一般是深度自定义或是复杂逻辑。行文应针对基本懂代码的用户,可以使用一些专业用语,但不应写出理解难度过大的内容。
- 高级文档:需要对引擎有一定的理解才可以完成的功能,一般是针对引擎底层架构相关的功能。行文应针对精通代码的用户,使用专业口吻行文。
# 项目使用文档
项目使用文档的目的是教会游戏作者使用引擎的某种功能来实现自己的需求,这些文档需要以一个实例来进行编写(我一般会给出实例)。文档中需要先提出功能的实现思路,然后给出引擎提供的接口,并对其进行讲解,最后给出代码实例,并讲解为什么要如此写代码,以及为什么这么写代码可以实现想要的功能。同时,还需要提供实例的部分常见变体,比如对于增加玩家攻击的需求,还需要同时给出增加防御、增加血量等需求的实例,以及增加不同值的实例,以保证用户可以理解代码的功能。
目前的引擎针对大多数场景需求都有优化,这些需求都可以总结成一个固定的模板,务必在文档的中部或结尾给出模板,同时也要在这一等级文档的 `templates.md` 中添加此模板,并链接到对应的文档。
文档应讲明白这个需求为什么可以这么实现,但不应涉及为什么引擎如此设计接口,关于接口设计的内容属于引擎开发的问题,不应暴露给用户。也不要讲解接口的具体实现逻辑,只需要讲解接口的功能。
此类文档的结构相对自由,但大致应遵循如下结构:
```md
# 文档标题,一般是对需求的描述
此处讲解需求本身,并明确给出本文会讲解到的内容。
# 接口讲解
此处讲解完成需求所使用到的接口,先列出,再讲解。具体章节分配由你决定。
# 功能模板(可选)
此处给出完成功能所使用的模板代码,如果无法总结成模板就不要写此章节。
# 功能实现
此处先给出代码,然后讲解为什么要这么写,对于不同等级的文档,使用不同的口吻进行讲解,确保对应水平的用户可以理解代码内容。
如果是使用可视化编辑器完成的功能,只需要一步步讲解操作即可,不需要先给出操作再解释为什么。
# 拓展
此处给出一些相关的文档,方便用户快速查询相似需求。
```
# 接口文档
接口文档不再进行分级,其主要内容是讲解引擎接口的作用,对于简单的接口应使用更通俗的口吻,对于复杂的接口应使用更专业的口吻。
接口文档的目的是讲明白一个接口可以干什么,其参数是什么、返回值是什么、可能产生哪些异常,并让用户了解这个接口该怎么用。
接口文档有明确的格式要求,务必遵守。下面是文档结构要求,你可以阅读附近的接口文档来了解更详细的行文思路。
## 类接口文档
````md
# 类 Xxx
在这里概述此类。务必写明此类属于渲染端还是数据端。
这里还需写明明确的继承关系,使用 mermaid 图描述:
**类继承关系**
```mermaid
graph LR
Class -> BaseClass1 -> BaseClass2
click BaseClass1 "rel path"
click BaseClass2 "rel path"
```
# 成员
列出表格讲述类的成员,表格第一列为成员名,第二列为类型,第三列为描述。注意,讲解时以类所实现的接口为准,类中包含但接口中没有的不讲解,修饰符也以接口为准。
# 方法
## `方法名()`
对于构造器,使用 `constructor()` 作为标题,对于方法,使用 `方法名()` 作为标题。方法与成员一样,也以接口为准,不以类本身为准。
```ts
function method(...params): ReturnType;
```
先给出方法的类型定义(如上),然后描述这个方法的功能,然后在这里讲解参数,使用无序列表的方式,不要使用表格。然后讲解返回值与异常:
**方法参数**
- `param1`: 参数描述
- `param2`: 参数描述
**方法返回值(可选)**
描述方法返回值
**方法异常(可选)**
- `警告 Code`: 描述原因及可能的解决方案
- `警告 Code`: 描述原因及可能的解决方案
# 应用实例
## 实例 1
使用一个短语作为章节名称。这里给出代码,然后讲解代码作用,代码中应给出合理的注释。不需要讲解地很细,但是必须得让人能理解。不宜给出过多实例,最多给出三个,对于不常见的类可以不给出实例。
````
## 函数接口文档
目前的引擎设计中直接暴露的函数较少,因此将它们都放到一个文档里面。
````md
# 函数
简单讲述本文中涉及的函数及其大致功能。
# 函数列表
## `函数名()`
```ts
function method(...params): ReturnType;
```
先给出函数的类型定义(如上),然后描述这个函数的功能,然后在这里讲解参数,使用无序列表的方式,不要使用表格。然后讲解返回值与异常:
**函数参数**
- `param1`: 参数描述
- `param2`: 参数描述
**函数返回值(可选)**
描述函数返回值。
**函数异常(可选)**
- `警告 Code`: 描述原因及可能的解决方案
- `警告 Code`: 描述原因及可能的解决方案
**使用示例(可选)**
给出使用示例代码,并描述。不建议过长,但应该讲清楚。
````

46
.agents/review.md Normal file
View File

@ -0,0 +1,46 @@
# 核心职责
你的职责不是辅助我进行架构设计,也不是进行系统代码编写,而是对我写出的代码进行 review以及测试用例的编写工作。
# Code Review
在 code review 期间,你需要阅读我写出的代码,以及我给出的需求说明,提出我的代码中可能不合理的部分,我会针对这些问题给出回答,或修改相应的代码,执行几轮循环。期间不得修改我写出的代码。
在 review 期间,务必留意 @dev.md 中的代码规范要求,如果出现了某些不合规范的地方,需要在对话中提出,尤其注意 `as` 关键字、方法排序等要求,重点关注写有“不建议”、“建议”、“最好”、“尽量”等字样的规则。
# 测试用例
在我明确要求你进行测试用例的编写前,不得擅自推进到测试用例的编写阶段。
## 测试编写流程
1. 我提出需要编写测试的功能。
2. 你需要分析功能,并自行提出合理的测试用例,形成文档,文档要求见后文。
3. 我会根据文档评判你的测试用例是否合理,并提出需要改进的地方,经过几轮循环后形成最终方案。
4. 根据文档内容编写测试用例,此时**不得**擅自运行测试指令,由我自己运行。
5. 我会根据测试结果来判断系统可能存在的问题,对于显而易见的问题我会自行解决,对于复杂的问题我可能会交给你寻找原因。
## 文档要求
文档最核心的目的是让我知道你为什么会提出这一测试用例。文档放在 `docs/test/` 目录下,有时会放到其子文件夹下。示例文档参考 `docs/test/template.md`
注意,测试用例不应仅仅包含合理输入的结果,还应考虑非法输入或是可能产生异常的结果,这时应该主要测试系统是否会正确抛出异常,或是给出合理的 `logger` 输出。
文档结构要求:
```md
# 测试目的
测试 XXX 系统的基本功能及异常处理。
# 测试用例
## 测试用例 1
- 设计目的:这里描述设计这一测试用例的目的,不得描述这个测试用例是干什么的,必须描述这个测试用例是怎么来的,为什么需要这一测试用例。
- 针对接口:列举这一用例主要针对的接口,不要把所有的接口都写上去,只写最重要的若干个接口,最好在五个以内。
### 测试内容
描述这一测试用例的内容,并写出本测试用例的预期结果。
```

View File

@ -9,5 +9,5 @@
"vueIndentScriptAndStyle": false,
"arrowParens": "avoid",
"trailingComma": "none",
"endOfLine": "auto"
}
"endOfLine": "crlf"
}

15
AGENTS.md Normal file
View File

@ -0,0 +1,15 @@
# AI Agent 指南
身为 AI Agent你可能会被分配不同的任务不同的任务请参阅不同的文件来查看具体要求。注意不要查看本次任务之外的提示词文件以避免上下文与注意力污染。
# 编写代码或设计系统
如果要求你进行代码编写,以及系统设计相关的行为,参阅 [code.md](./.agents/code.md)。
# 代码评审及测试用例编写
如果要求你对我写好的代码进行评审,或针对某个功能编写测试用例,参阅 [review.md](./.agents/review.md)。
# 使用文档编写
如果要求你针对某个接口编写 API 文档,或要求你针对某个功能编写指南文档,参阅 [docs.md](./.agents/docs.md)。注意本节针对的是面向项目使用者的文档,而不是面向项目开发者的文档。

163
dev.md
View File

@ -2,12 +2,12 @@
## 项目结构
`public`: mota-js 样板所在目录
`packages`: 核心引擎代码 monorepo。
`packages-user`: 用户代码 monorepo。
`src`: 游戏入口代码
- `public`: mota-js 样板所在目录
- `packages`: 核心引擎代码 monorepo`@motajs/`
- `packages-user`: 用户代码 monorepo`@user/`
- `src`: 游戏入口代码
`packages` `packages-user` 可以单独打包为库模式,`src` 单向引用 `packages-user``packages-user` 单向引用 `packages``src` 为游戏的入口代码。
依赖关系为单向:`src` → `packages-user``packages`。`packages` 与 `packages-user` 均可独立打包为库模式`src` 为游戏的入口代码。
## 开发环境
@ -15,50 +15,131 @@
- `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 build:packages` | 以库模式构建 `packages` 下的所有内容 |
| `pnpm build:game` | 构建为可直接部署的游戏包 |
| `pnpm build:lib` | 以库模式构建 `packages``packages-user` 下的所有内容 |
| `pnpm lint:packages` | 运行 `packages` 文件夹下的 `eslint` 格式检查 |
| `pnpm lint:user` | 运行 `packages-user` 文件夹下的 `eslint` 格式检查 |
| `pnpm lint:custom` | 运行自定义文件的 `eslint` 格式检查,相当于 `pnpm eslint ...` |
| `pnpm check:type` | 对仓库执行类型检查 |
| `pnpm check:circular` | 对仓库执行循环引用检查 |
## 术语统一
- 方法:一般指挂载到接口 `interface` 或类 `class` 上的函数。
- 函数:一般指在文件顶层定义的函数,有时也会指方法,需要根据语境判断。
- 接口:有时指 `interface`,有时也会指方法、成员等内容,需要根据语境判断。
- 成员/属性:一般指接口 `interface`、类 `class`、对象 `object` 上的字段。
## 开发原则
- 模块无副作用原则:
- 所有模块不包含副作用内容,全部由函数、类、常量的声明组成,不出现导出的变量声明、代码执行内容,允许但不建议编写类的静态块。
- 如果需要模块初始化,编写一个 `createXxx` 函数,然后在 `index.ts` 中整合,再逐级向上传递,直至遇到包含 `create` 函数的 `index.ts`,所有初始化将会统一在顶层模块中执行。
- 命名规则:
- 变量、成员、一般常量、方法、函数使用小驼峰。
- 类、接口、类型别名、命名空间、泛型、枚举、组件使用大驼峰。
- 不变常量使用全大写命名法,单词之间使用下划线连接。
- 专有名词缩写如 `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 }`)中使用 `?.` 运算符。
- 只进行必要的非空判断,不必要的非空判断直接使用非空断言 `!` 实现。
- 除非参数要求传入函数等情况,不建议在函数内写任何局部函数。
### 模块原则
- **无副作用**:所有模块只包含函数、类、常量的声明,不允许出现导出的变量声明或顶层代码执行,允许但不建议编写类的静态块。
- **模块初始化**:如需初始化,编写一个 `createXxx` 函数,在 `index.ts` 中整合后逐级向上传递,直至顶层模块统一执行。注意,当前设计理念下,**不应该**有场景会需要这种函数。
- **不转发导出**:不允许一个文件导出不属于当前 monorepo 或当前文件夹的内容。
- **无循环引用**:不允许出现循环引用。若遇到不得不循环引用的情况,应首先反思接口设计是否存在问题。
- **无类型导入**:除非某些极其特殊的场景,否则不得出现 `import type` 引入,全部使用普通引入。
### 命名规则
| 命名对象 | 规范 |
| ---------------------------------------------- | ------------------------------------------ |
| 变量、成员、一般常量、方法、函数 | 小驼峰 |
| 类、接口、类型别名、命名空间、泛型、枚举、组件 | 大驼峰 |
| 不变常量 | 全大写,单词间下划线分隔(如 `MAX_COUNT` |
| 专有名词缩写(如 `HTTP`、`URI` | 全大写 |
| 需被 `implements` 的接口 | 大写 `I` 开头 |
| HTML/CSS 中的 `id`、`class` 等 | 连字符命名法 |
| 代码文件名 | 小驼峰 |
| Markdown 文档文件名 | 连字符命名法 |
不使用下划线命名法,私有属性或方法不要以下划线开头。
### 注释规范
- 公共方法、接口必须在**源头处**(多数情况下为 `interface`)添加 `jsDoc` 注释;其他常用成员、方法、类型也必须添加注释(含义极为明确或极少使用的可例外,但建议全部添加)。接口中每个方法之间必须添加空行,成员之间应根据成员功能添加换行。
- 继承或 `implements` 而来的 API方法、成员等若注释说明无需变更则**不应重复添加** `jsDoc` 注释。
- 不对构造器添加注释。若构造器使用了属性声明语法(`constructor(public prop: T)`)且成员需要说明,可仅对该成员添加参数注释,不写构造器描述。**在这种情况下**建议避免在构造器中使用属性声明语法,将成员单独声明并在构造器中赋值。此条建议**并非**要求不使用构造器的属性声明语法,而是仅在这一情况下不建议使用,常规情况下推荐使用此语法来缩短代码长度并提高可读性。
- 长文件可使用 `#region` / `#endregion` 分段以支持折叠。
- TODO 使用 `// TODO:``// todo:` 格式。
- 单行注释的 `//` 与注释内容之间留一个空格;不允许出现非 jsDoc 的多行注释,如需多行注释,使用多个单行注释代替。
- 注释合理换行:考虑中文字符较宽,建议每 4060 个字符在标点符号后换行,不要频繁换行,每行长度应差不多,不允许在句中换行;参数注释换行后保持对齐。
- 单行注释结尾不加句号;较长的多行注释结尾可加句号。
- 一般不建议给接口(`inteface` 本身)、类型别名、枚举或类本身写注释(即需要紧接着一长段缩进的时候,这时候写注释不好看),特殊情况除外。
- 对于 jsDoc 注释,方法注释必须使用换行风格;对于成员 jsDoc 注释,除非注释较长需要换行,否则使用不换行的风格。
### 类型规范
- 不允许出现非必要的 `any` 类型。
- 所有类的成员必须显式声明类型。
- 无法避免类型错误时,使用 `// @ts-expect-error` 标记并说明原因。
- 未使用的变量或方法以下划线开头命名。
- 合理使用 `readonly`、`protected`、`private` 关键字。
- 可选参数过多时(大于两个),考虑改用对象参数。
- 尽量避免 `as` 类型断言,除非必要。绝对不允许出现连续 `as`,例如 `as unknown as xxx`
- 函数类型单独开一个 `type` 类型别名,除非函数类型本身较短(小于 20 字符,且不会因为此函数类型导致换行)。
- 当任何时候必须出现对象类型的时候,单独声明一个 `interface`,不得出现直接的对象类型。
- 对于某个类中的对象成员,必须使用接口作为类型,不得使用类作为类型,除非某些极特殊情况。
### 其他要求
- 不使用字符串作为键或特殊标识符(如枚举值、事件名、状态名等),应使用枚举代替。仅当明确表示字符串本身(如字符串类型的 id 别名、文件路径等)时方可使用字符串字面量。
- 严格遵循 `eslint` 配置,不允许出现 eslint 报错。
- 尽量不使用 `?.` 运算符,仅推荐在以下两种场景中使用:
- 副作用函数调用,如 `this.obj?.func()``this.obj.func?.()`
- 对象 Required 化,如 `{ value: obj?.value ?? 0 }`
- 只进行必要的非空判断,非必要时直接使用非空断言 `!`
- 除非参数要求传入函数等情况,不建议在函数内定义局部函数。
- 语句尽量不换行,除非必要,尤其注意三元运算符与 `private readonly` 类成员。
- 极度不建议写 `getter``setter`,但在极少数场景下还是可以使用的,一般是必须要提供运算符操作方法且需要监听 `get``set` 时才允许使用。
- 方法实现中,未使用的后置参数直接不填,不得写以下划线开头的参数名。
- 使用对象解构语法时,除非明确后续可能新增其他变量,否则不允许出现单个的对象解构,如 `const { value } = obj`,必须写成 `const value = obj.value`
- 换行使用 `CRLF` 格式。
## 双端分离
样板将渲染端与数据端彻底分离:
- **数据端**:可在 `node` 环境中单独运行,可直接用于录像验证,不负责任何渲染逻辑。
- **渲染端**:仅负责向数据端发送消息,不负责任何逻辑运算。
数据端允许调用渲染端代码,但必须使用全局接口 `Mota.r(() => {})` 包裹。除非必要,否则不建议在数据端调用渲染端代码。
### 渲染端
渲染端目前已基本制作完成,但底层架构仍需重构,目前分为两层:
| 包 | 层级 | 说明 |
| ---------------------- | ------ | ------------------------------------ |
| `@user/client-base` | 系统层 | 负责渲染端核心系统 |
| `@user/client-modules` | 实现层 | 依赖系统层实现客户端的渲染与用户交互 |
### 数据端
数据端目前正在从旧引擎进行彻底性重构,分为三层:
**Layer 0 — 公共层**:包含公共接口、工具函数等内容,不依赖任何外部游戏逻辑,可被任意高层直接引用。包含统一接口 `IDataCommon`
**Layer 1 — 数据层**:包含所有会影响游戏存档与流程的数据内容,如地图、怪物、玩家属性等。本层通过统一接口 `IStateBase` 对外暴露数据访问能力,各类数据模块均以此接口为核心组织。
**Layer 2 — 系统层**:直接引用 Layer 1负责产生影响游戏进程的动作如玩家控制、战斗计算等。本层内容不会进入存档仅通过修改 Layer 1 的数据来影响游戏状态。
| 包 | 层级 | 说明 |
| ------------------- | ------- | -------------------------------------------------------------------------------- |
| `@user/data-common` | Layer 0 | 公共层,定义 `IDataCommon` 及公共无依赖接口 |
| `@user/data-base` | Layer 1 | 数据层,定义 `IDataBase` 及可存档游戏数据(地图、怪物、玩家属性等) |
| `@user/data-system` | Layer 2 | 系统层,定义 `IDataSystem`,依赖数据层实现玩家控制、战斗计算等影响游戏进程的动作 |
| `@user/data-state` | Layer 3 | 数据端的顶层模块,一般仅用于初始化以及仅供渲染端调用的顶层模块 |

View File

@ -0,0 +1,85 @@
# 需求综述
动态图块移动功能完成后,发现 `FaceDirection` 朝向相关操作(位移、反方向、旋转方向、降级)分散在独立工具函数中(`getFaceMovement`、`nextFaceDirection`、`degradeFace` 等),缺乏统一抽象,导致调用点分散、扩展性不足(例如:四方向与八方向切换时需要手动处理,不同移动场景难以共享同一套逻辑)。
目标是设计一套统一的朝向管理接口:`IFaceHandler<T>` 代表一组朝向并提供相应操作,`IFaceManager` 作为注册中心管理多个 handler同时内置两个 handler 分别对应四方向和八方向。新接口位于 `@user/data-base/src/common/`,与 `FaceDirection` 同包,同时取代现有的 `DirectionMapper`
# 实现思路
## 1. 设计 IFaceHandler 接口
`IFaceHandler<T extends number>` 代表**一组朝向**,泛型 `T` 表示本组的朝向类型(通常为 `FaceDirection`,也可以是自定义枚举以支持拓展)。内部隐含一个该组支持的方向集合,通过 `directions` 成员暴露。对于不在集合内的输入方向,通过 `degrade` 方法将其映射到集合内最合适的方向,其余操作方法在调用时**均先执行 degrade**。
`mapDirection` 同时取代 `DirectionMapper.map()` 的功能,不再需要单独维护 `DirectionMapper`
主要成员与方法:
- `degrade(dir: number): T`:将任意朝向值(含其他枚举类型或 `FaceDirection`)降级为本组支持的方向。对于无法合理降级的方向(包括 `Unknown`),返回 `FaceDirection.Unknown`(数值 `0`,兼容所有 `T`)。
- `movement(dir: number): IFaceDescriptor`:获取指定方向的坐标偏移量,输入先经过 `degrade``Unknown` 返回 `{ x: 0, y: 0 }`
- `move(dir: number, count: number): IFaceDescriptor`:获取指定方向走 `count` 步的坐标偏移量,等价于 `movement * count`。`count` 允许为负数,表示反向位移。输入先经过 `degrade`
- `opposite(dir: number): T`:获取本组内的反方向,输入先经过 `degrade``Unknown` 返回 `Unknown`
- `next(dir: number, anticlockwise?: boolean): T`:在本组方向集合内,顺时针(默认)或逆时针旋转一步,输入先经过 `degrade``Unknown` 返回 `Unknown`
- `mapDirection(): Iterable<T>`:迭代本组支持的所有朝向,包含 `Unknown`(其与其他方向一视同仁,不作特例处理)。
- `mapMovement(): Iterable<[T, IFaceDescriptor]>`:迭代本组所有朝向及其对应的坐标描述器,包含 `Unknown`(对应 `{ x: 0, y: 0 }`)。
## 2. 设计 IFaceManager 接口
`IFaceManager` 是 handler 的注册中心,同时支持数字 key 与字符串 id 两种注册与查找方式。数字 key 适合内置组的高频调用,字符串 id 适合使用频率较低的自定义场景:
- `register(group: number, handler: IFaceHandler<number>): void`:以数字 key 注册一个 handler。
- `registerById(id: string, handler: IFaceHandler<number>): void`:以字符串 id 注册一个 handler。
- `get<T extends number>(group: number): IFaceHandler<T> | null`:按数字 key 查找 handler未找到返回 `null`
- `getById<T extends number>(id: string): IFaceHandler<T> | null`:按字符串 id 查找 handler未找到返回 `null`
内置的数字 key 组用新增的 `InternalFaceGroup` 枚举标识(与现有的 `InternalDirectionGroup` 风格一致)。
## 3. 内置 Handler 实现
### Dir8FaceHandler八方向
- `directions`:包含全部八个有效方向与 `Unknown`,共九个成员。
- `degrade`:直接返回输入(转为 `T`),无需降级。
- `next``Unknown` 返回 `Unknown`;按 45° 步进顺时针顺序Up → RightUp → Right → RightDown → Down → LeftDown → Left → LeftUp → Up。
- `opposite``Unknown` 返回 `Unknown`Up↔DownLeft↔RightLeftUp↔RightDownRightUp↔LeftDown。
- `movement`:与现有 `getFaceMovement` 一致。
### Dir4FaceHandler四方向
- `directions`:包含 Up、Down、Left、Right 四个方向与 `Unknown`,共五个成员。
- `degrade`四方向不变斜向降级为水平分量LeftUp/LeftDown → LeftRightUp/RightDown → Right`Unknown` → `Unknown`。与现有 `degradeFace` 行为一致。
- `next`:先 degrade`Unknown` 返回 `Unknown`;再按 90° 步进顺时针Up → Right → Down → Left → Up。
- `opposite`:先 degrade`Unknown` 返回 `Unknown`Up↔DownLeft↔Right。
- `movement`:先 degrade再返回偏移量。
## 4. 实现 FaceManager 类
实现 `IFaceManager`,不导出全局单例,应将实例挂载到游戏实例下。两个内置 handler`Dir8FaceHandler` 与 `Dir4FaceHandler`不在构造时注册而在游戏实例初始化阶段注册key 分别为 `InternalFaceGroup.Dir8``InternalFaceGroup.Dir4`
## 5. 现有代码处理
- 现有 `getFaceMovement`、`nextFaceDirection`、`degradeFace`、`fromDirectionString` 等工具函数**暂时保留**,不做删改;新代码直接使用新接口,旧代码的迁移视后续情况另行处理。
- `@motajs/common` 中的 `DirectionMapper``IDirectionMapper` 接口将被废弃,其调用方(如 `range.ts`)的迁移视后续情况另行处理。
# 涉及文件
## 需要修改的文件
### `@user/data-base/src/common/faceManager.ts`(新增文件)
- [ ] 新增 `InternalFaceGroup` 枚举:包含 `Dir4``Dir8` 两个成员,作为 `IFaceManager` 的内置数字 key
- [ ] 新增 `IFaceDescriptor` 接口:描述一个方向的坐标增量,包含 `x``y` 两个只读成员
- [ ] 新增 `IFaceHandler<T extends number>` 接口:包含 `degrade`、`movement`、`move`、`opposite`、`next`、`mapDirection`、`mapMovement` 七个成员
- [ ] 新增 `IFaceManager` 接口:包含 `register`、`registerById`、`get`、`getById` 四个方法
- [ ] 实现 `Dir8FaceHandler` 类(`implements IFaceHandler<FaceDirection>`
- [ ] 实现 `Dir4FaceHandler` 类(`implements IFaceHandler<FaceDirection>`
- [ ] 实现 `FaceManager` 类(`implements IFaceManager`
### `@user/data-base/src/common/index.ts`
- [ ] 导出新增的枚举、接口与类

View File

@ -0,0 +1,209 @@
# 需求综述
`packages-user/data-system/src/combat/types.ts` 中的战斗流程接口已经是本次实现的权威设计,文档只负责解释这套接口的目的、运行语义与落地方式,不再额外改造公共接口。
本次需要补齐的是 `packages-user/data-system/src/combat/combat.ts` 中真正的战斗流程实现,使 `ICombatFlow<TEnemy, THero>` 可以驱动一次完整的实际战斗,而不是只停留在伤害预估层。
按照当前 `types.ts` 的设计,战斗流程分为三个阶段:
1. 战前阶段:按优先级执行 `before` 脚本。
2. 战斗阶段:取得本次战斗的 `IEnemyDamageInfo<TEnemy, THero>`,并执行战前 hook。
3. 战后阶段:执行战后脚本,再执行战后 hook。
当前设计已经明确区分了两种入口:
1. `battle(enemy: IEnemyView<TEnemy>)`:面向怪物视图对象。
2. `battleComputed(enemy: IReadonlyEnemy<TEnemy>)`:面向计算后怪物对象。
是否“在地图上”不能仅由参数类型决定,而应以当前绑定上下文能否反查出有效坐标为准。对于不在地图上的怪物,当前设计已经明确:
1. 它会被视为“战斗时隶属于当前绑定的 `IEnemyContext<TEnemy, THero>`”。
2. 它不会被真正放入上下文。
3. `handler.context` 使用当前绑定的上下文。
4. `handler.locator` 使用原点 `(0, 0)`
5. `handler.onMap``false`
另外,当前流程对象的失败语义也已经确定:`hero`、`context`、`damage` 任意一个未绑定,则本次战斗直接返回 `null`
# 接口设计与预期
## IEnemyDamageInfo
`IEnemyDamageInfo<TEnemy, THero>` 直接作为战斗结果对象使用,不新增额外结果结构。
- `IEnemyDamageInfo.damage`:预期频率**高频**。大多数消费战斗结果的代码首先关心伤害值。典型使用场景:战后脚本根据伤害值扣除勇士生命或触发保命逻辑。
- `IEnemyDamageInfo.turn`:预期频率**低频**。回合数重要,但直接消费它的逻辑明显少于伤害值。
- `IEnemyDamageInfo.handler`:预期频率**中频**。脚本或日志逻辑经常会顺手读取本次战斗关联的怪物、勇士与定位信息。典型使用场景:战后脚本通过 `handler.enemy` 读取掉落,或通过 `handler.locator` 定位当前怪物。
## ICombatFlowHandler
`ICombatFlowHandler<TEnemy, THero>` 是脚本阶段使用的可写信息对象,职责是把战斗执行阶段真正需要改动的数据集中到一个 handler 中。
- `ICombatFlowHandler.onMap`:预期频率**低频**。它只在脚本需要区分“地图内战斗”和“离图战斗”时才会被使用。
- `ICombatFlowHandler.hero`:预期频率**高频**。几乎所有真正产生副作用的战斗脚本都会碰到勇士对象。典型使用场景:战后脚本扣除生命、增加金币或写入状态。
- `ICombatFlowHandler.enemy`:预期频率**中频**。不少脚本会读取或修改怪物对象,但频率仍低于直接操作勇士对象。典型使用场景:战后脚本读取怪物奖励,或修改怪物的战后状态。
- `ICombatFlowHandler.context`:预期频率**低频**。只有需要反查地图对象或联动上下文的脚本才会使用它。
- `ICombatFlowHandler.locator`:预期频率**低频**。只有与地图位置强相关的逻辑才会读取坐标。
- `ICombatFlowHandler.state`:预期频率**低频**。它主要服务于跨系统联动,不是常规战斗脚本的主输入。
`handler` 的组装语义需要分成两条路径:
1. `battle(enemyView)`
- 先尝试从绑定上下文反查该视图的真实坐标
- 若能反查到坐标,则 `onMap = true`
- 若不能反查到坐标,则按离图战斗处理,`onMap = false`
- `enemy` 使用该视图对应的可写怪物对象
- `context`、`state` 使用绑定上下文中的真实运行信息
2. `battleComputed(enemy)`
- 若能从绑定上下文反查到视图,则按在图战斗处理,`onMap = true`
- 若无法反查到视图,则按离图战斗处理,`onMap = false`
- 离图时 `context` 仍使用绑定上下文,`locator` 固定使用原点 `(0, 0)`
- 离图时怪物不真正加入上下文,但本次战斗仍要提供一个可写的怪物对象作为 `handler.enemy`
## ICombatFlowHook
`ICombatFlowHook<TEnemy, THero>` 是实例级观察型扩展点,它只接收 `IEnemyDamageInfo<TEnemy, THero>`,不直接接触可写 handler。
- `ICombatFlowHook.onBeforeCombat()`:预期频率**低频**。更适合动画、日志、录像等系统层观察逻辑。典型使用场景:战前记录一次战斗开始事件。
- `ICombatFlowHook.onAfterCombat()`:预期频率**低频**。同样偏观察与收尾,不负责流程控制。典型使用场景:战后统一记录本次战斗结果。
当前设计下hook 的定位很明确:
1. 它们只拿到 `info`
2. 它们不直接承担状态修改职责。
3. 它们位于脚本阶段之后,只负责观察已经成立的流程结果。
## ICombatScript
`ICombatScript<TEnemy, THero>` 是战斗流程对象上的注册式扩展点,用于承载真正参与流程的逻辑。
- `ICombatScript.priority`:预期频率**中频**。所有脚本都要声明优先级,但它主要出现在定义脚本和排查顺序问题时。典型使用场景:让扣血脚本先于奖励脚本,或让删怪脚本压到最后执行。
- `ICombatScript.before()`:预期频率**中频**。只有需要拦截战斗或做战前准备的脚本才会实现它。典型使用场景:根据 `info.damage` 判断本次战斗是否允许继续进行。
- `ICombatScript.after()`:预期频率**中频**。绝大多数默认行为都更适合放在战后脚本里。典型使用场景:扣血、删怪、发奖励。
脚本层的规则已经确定:
1. `before()` 可以返回 `false` 来取消本次战斗。
2. `after()` 只负责战后逻辑,不再影响战斗是否成立。
3. 同一 `priority` 的脚本不允许并存;注册时需要警告并拒绝新增。
4. 脚本按 `priority` 从高到低执行。
## ICombatFlow
`ICombatFlow<TEnemy, THero>` 是对外暴露的流程对象,本体负责绑定依赖、组织阶段顺序与执行脚本。
- `ICombatFlow.hero`:预期频率**低频**。它主要用于只读检查或调试。
- `ICombatFlow.context`:预期频率**低频**。通常只在调试或初始化检查中使用。
- `ICombatFlow.damage`:预期频率**低频**。它更偏运行时绑定状态的公开暴露,而不是常规调用点。
- `ICombatFlow.bindHero()`:预期频率**中频**。初始化流程对象时必须绑定勇士。典型使用场景:状态系统创建完勇士属性对象后注入给战斗流程。
- `ICombatFlow.bindContext()`:预期频率**中频**。初始化或切换上下文时需要重绑战斗目标来源。典型使用场景:进入新楼层后,将新的怪物上下文绑定给战斗流程。
- `ICombatFlow.bindDamage()`:预期频率**中频**。战斗流程本身不做伤害计算,必须依赖外部提供的伤害上下文。典型使用场景:伤害系统初始化完成后绑定给战斗流程。
- `ICombatFlow.battle()`:预期频率**低频**。它面向怪物视图对象,适合最常规的战斗入口。典型使用场景:交互逻辑在玩家尝试攻击某个怪物视图时直接传入 `IEnemyView<TEnemy>`
- `ICombatFlow.battleComputed()`:预期频率**低频**。它主要服务于“只有计算后怪物对象,没有视图对象”的调用点。典型使用场景:脚本逻辑对一个临时怪物对象发起离图战斗。
- `ICombatFlow.addCombatScript()`:预期频率**中频**。所有默认行为和扩展模块都要通过它接入流程。典型使用场景:初始化阶段注册扣血、删怪、奖励等脚本。
结合这些成员可以看出,`ICombatFlow` 的设计目的不是把所有逻辑都塞进一个类里,而是提供一个可被上层系统组装的公共流程壳。
## 流程顺序
当前流程顺序已经确定为:
1. 先计算本次战斗的 `IEnemyDamageInfo<TEnemy, THero>`
2. 按优先级执行全部 `before()` 脚本。
3. 若战前脚本未取消战斗,则执行 `onBeforeCombat(info)` hook。
4. 执行全部 `after()` 脚本。
5. 执行 `onAfterCombat(info)` hook。
6. 返回本次战斗信息。
也就是说hook 并不是包裹整段脚本流程的最外层,而是位于脚本之后的观察阶段:`before scripts -> before hook -> after scripts -> after hook`。
# 预期体量
预期代码体量为 140-220 行。分析如下:
1. `combat/combat.ts` 需要新建 `CombatFlow<TEnemy, THero>`,实现 hook 能力、绑定逻辑、脚本存储、重复优先级拒绝以及 `battle(...)` / `battleComputed(...)` 两条入口,这部分是主要体量来源。
2. `combat/combat.ts` 还需要补齐离图 `battleComputed(...)` 的虚拟上下文语义,包括 `onMap = false`、原点定位与不真正加入上下文的处理。
3. `combat/index.ts` 只需要增加 `export * from './combat'`,改动很小。
4. `combat/types.ts` 作为已确认的设计源头,本次文档不计划修改其公共接口。
# 可能风险
本次文档将 `combat/types.ts` 视为已确认设计,不计划改动其中的公共接口,因此这里不额外展开实现风险。
# 实现思路
## 1. 新增 `combat/combat.ts`
新增 `CombatFlow<TEnemy, THero>` 类,使用 `@motajs/common``Hookable` / `HookController` 体系实现 `ICombatFlow<TEnemy, THero>`
## 2. 完成绑定与脚本存储
类内部保存三个绑定对象与脚本列表:
1. `hero`:保存可写勇士对象,同时对外按接口暴露只读视图。
2. `context`:保存当前战斗所属的怪物上下文。
3. `damage`:保存当前使用的伤害上下文。
4. `scriptList`:保存已注册脚本,并在注册时检查重复优先级。
## 3. 完成两条战斗入口的数据组装
1. `battle(enemyView)`
- 检查绑定是否完整,不完整直接返回 `null`
- 通过 `damage.getDamageInfo(enemyView)` 获取伤害信息
- 通过 `context.getEnemyLocatorByView(enemyView)` 判断当前是否在图
- 若能拿到坐标,则组装 `onMap = true``ICombatFlowHandler<TEnemy, THero>`
- 若拿不到坐标,则按离图语义组装 `onMap = false`、`locator = (0, 0)` 的 `ICombatFlowHandler<TEnemy, THero>`
2. `battleComputed(enemy)`
- 检查绑定是否完整,不完整直接返回 `null`
- 先尝试从 `context.getViewByComputed(enemy)` 反查视图
- 若反查成功,则复用在图路径处理
- 若反查失败,则按离图语义处理:`onMap = false`、`locator = (0, 0)`、怪物不加入上下文
- 离图时的伤害计算直接使用绑定 `context` 上当前 `IDamageSystem<TEnemy, THero>` 的 calculator基于绑定上下文、原点坐标与目标怪物组装只读 handler 进行纯计算
## 4. 完成 `battle(...)` 主流程
主流程按固定顺序执行:
1. 先取得 `IEnemyDamageInfo<TEnemy, THero>``ICombatFlowHandler<TEnemy, THero>`
2. 按优先级执行全部 `before()` 脚本,若任一脚本返回 `false`,则立刻结束并返回 `null`
3. 执行 `onBeforeCombat(info)` hook。
4. 执行全部 `after()` 脚本,处理真正的副作用。
5. 执行 `onAfterCombat(info)` hook。
6. 返回本次战斗信息。
## 5. 更新 `combat/index.ts`
在当前导出列表基础上补上 `export * from './combat'`,让上层可以直接拿到流程实现。
# 涉及文件
## 需要引用的文件
1. `@motajs/common`:用于 `IHookBase`、`IHookable`、`IHookController`、`Hookable`、`HookController`、`logger`。
2. `@user/data-base`:用于 `IEnemy`、`IReadonlyEnemy`、`IHeroAttribute`、`IReadonlyHeroAttribute`、`IStateBase`。
3. `combat/types.ts`:本次实现的权威接口来源,重点使用 `ICombatFlow`、`ICombatFlowHandler`、`ICombatFlowHook`、`ICombatScript`、`IEnemyDamageInfo`、`IEnemyView`。
4. `combat/context.ts`:需要使用 `getViewByComputed(...)`、`getEnemyLocatorByView(...)`、`getDamageSystem()` 等能力。
5. `combat/damage.ts`:需要复用 `IDamageContext.getDamageInfo(...)`、`IDamageContext.getDamageInfoByComputed(...)` 与 `IDamageSystem.getCalculator()` 的现有语义。
6. `combat/enemy.ts`:需要依赖怪物视图提供的可写怪物入口。
## 需要修改的文件
### `packages-user/data-system/src/combat/combat.ts`
- [ ] 新增 `CombatFlow<TEnemy, THero>` 类:实现 `ICombatFlow<TEnemy, THero>` 的完整流程壳。
- [ ] 新增绑定成员:保存 `hero`、`context`、`damage` 三个外部注入对象。
- [ ] 新增脚本存储成员:保存已注册的战斗脚本,并在注册时拒绝重复优先级。
- [ ] 新增私有组装方法:分别处理 `battle(IEnemyView<TEnemy>)``battleComputed(IReadonlyEnemy<TEnemy>)` 的 handler 组装。
- [ ] 编写 `CombatFlow.battle(...)``CombatFlow.battleComputed(...)`:按接口设计执行战前、战斗、战后三阶段。
### `packages-user/data-system/src/combat/index.ts`
- [ ] 新增 `export * from './combat'`:导出战斗流程实现。
# 问题
1. `battle(enemy: IEnemyView<TEnemy>)` 传入怪物视图时,是否应当与 `battleComputed(...)` 保持完全一致的离图判定,即只要 `context.getEnemyLocatorByView(enemy)` 返回空,就按 `onMap = false`、原点坐标处理?
> 使用所有的可能查询方式,包括 `getEnemyLocator` `getEnemyLocatorByView` `getViewByComputed` 等都查一遍,如果还是没有才按不在地图上处理。还有为啥要写成离图在图?看着不别扭吗,直接写成在地图上和不在地图上多清晰。
2. 离图战斗时提供给 `handler.enemy` 的可写怪物对象,是否直接使用传入视图上的可写对象 / 传入 computed 怪物的克隆对象即可,还是还有额外约束?

View File

@ -0,0 +1,194 @@
# 需求综述
对怪物管理器 `IEnemyManager` 进行存档适配,使其能够参与游戏存档系统。
由于大多数情况下怪物模板不会被修改,不需要全量存储,
只需对比"参考状态"(游戏加载完成时的初始模板),仅保存发生了变化的模板。
为此需要:
- `IEnemy``ISpecial` 继承 `ISaveableContent` 以支持自身序列化;
- 给 `ISpecial` 添加 `deepEqualsTo` 接口用于特殊属性间的深度比较;
- `IEnemyManager` 继承 `ISaveableContent`,新增 `compareWith`、`modifyPrefabAttribute`、
`attachEnemyComparer`、`getEnemyComparer` 接口;
- `IEnemyManager` 内部维护 dirty 集合,以首次 `compareWith` 传入的参考为唯一基准;
- `getPrefab` / `getPrefabById` 返回值收窄为 `IReadonlyEnemy<TAttr>`
统一由 `modifyPrefabAttribute` 承担模板修改职责。
---
# 实现思路
## 1. 新增存档状态类型
`types.ts` 中新增如下类型,用于序列化怪物与管理器的状态:
```ts
/** 单个 IEnemy 的存档状态 */
interface IEnemySaveState<TAttr> {
readonly attrs: TAttr;
// 特殊属性按 code 映射,值为各 ISpecial.saveState() 的结果
readonly specials: ReadonlyMap<number, unknown>;
}
/** IEnemyManager 的存档状态,只保存与参考状态不同的模板 */
interface IEnemyManagerSaveState<TAttr> {
// code -> 变更后的 IEnemySaveState
readonly modified: ReadonlyMap<number, IEnemySaveState<TAttr>>;
}
```
## 2. 新增 `IEnemyComparer<TAttr>` 接口
由于管理器外部没有比较怪物属性的需求,将比较逻辑封装为独立的比较器,
附着在 `EnemyManager` 上。比较器接口如下:
```ts
interface IEnemyComparer<TAttr> {
compare(
enemyA: IReadonlyEnemy<TAttr>,
enemyB: IReadonlyEnemy<TAttr>
): boolean;
}
```
由用户在初始化时通过 `attachEnemyComparer` 提供。若未提供比较器,
在调用 `modifyPrefabAttribute``changePrefab` 时需发出警告,且视所有怪物均为脏。
## 3. `ISpecial<T>` 继承 `ISaveableContent<T>`
- `saveState` 返回 `structuredClone(this.value)`(即 `getValue()` 的深拷贝);
- `loadState` 调用 `setValue(state)`
- 新增 `deepEqualsTo(other: ISpecial<T>): boolean`:先对比 `code`
再对 `value` 进行深度比较。
各内置实现类的比较策略:
- `NonePropertySpecial`:只需比较 `code``value` 为 `void` 无需对比;
- `CommonSerializableSpecial``value` 为普通可序列化对象,
使用 `lodash-es``isEqual` 进行递归深度比较。
## 4. `IEnemy<TAttr>` 继承 `ISaveableContent<IEnemySaveState<TAttr>>`
- `saveState(compression)`:深拷贝 `attrs`,对每个 special 调用
`saveState(compression)` 收集到 `specials` Map返回 `IEnemySaveState<TAttr>`
- `loadState(state, compression)`:以 `state.attrs` 还原属性,
然后对已有的每个 special 按 code 查找存档中的对应条目并调用 `loadState`
若存档中出现当前怪物未注册的 special code发出 logger 警告并跳过。
## 5. `IEnemyManager<TAttr>` 接口修改
### 5a. 继承 `ISaveableContent<IEnemyManagerSaveState<TAttr>>`
- `saveState(compression)`:遍历 dirty 集合,对每个脏模板调用
`prefab.saveState(compression)`,汇总为 `IEnemyManagerSaveState<TAttr>` 并返回;
- `loadState(state, compression)`:遍历 `state.modified`
找到 code 对应的现有模板,调用 `prefab.loadState(enemyState, compression)` 还原;
若某 code 不在当前 prefab 表中,发出 logger 警告并跳过;
**不清空 dirty 集合**,始终以首次 `compareWith` 提供的参考为唯一基准;
`loadState` 结束后重新用比较器对每个已有脏模板进行比对,
刷新 dirty 集合(避免加载后实际已恢复初始值的模板仍停留在 dirty 中)。
### 5b. 新增 `compareWith`
```ts
compareWith(reference: ReadonlyMap<number, IReadonlyEnemy<TAttr>>): void;
```
- 由调用方在游戏初始化完成后提供参考快照,外部传入,管理器保存引用;
- **首次调用**:直接存储参考,清空 dirty 集合;
- **非首次调用**:通过 logger 发出警告,提示此操作风险高,
请作者确认操作意图,但仍然执行覆盖(直接替换参考,重置 dirty 集合)。
### 5c. `getPrefab` / `getPrefabById` 返回值改为 `IReadonlyEnemy<TAttr>`
原来返回 `IEnemy<TAttr>`,外部可以直接修改模板。
改为只读引用,外部不能直接修改,必须通过 `modifyPrefabAttribute` 完成。
### 5d. 新增 `modifyPrefabAttribute`
```ts
modifyPrefabAttribute(
code: number | string,
modify: (prefab: IEnemy<TAttr>) => IEnemy<TAttr>
): 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<TAttr>): void;
getEnemyComparer(): IEnemyComparer<TAttr> | 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<TAttr>` 类型:单个怪物的存档状态
- [ ] 新增 `IEnemyManagerSaveState<TAttr>` 类型:管理器的存档状态
- [ ] 新增 `IEnemyComparer<TAttr>` 接口:包含 `compare` 方法,由用户实现
- [ ] 修改 `ISpecial<T>`:继承 `ISaveableContent<T>`
新增 `deepEqualsTo(other: ISpecial<T>): boolean`
- [ ] 修改 `IEnemy<TAttr>`:继承 `ISaveableContent<IEnemySaveState<TAttr>>`
- [ ] 修改 `IEnemyManager<TAttr>`:继承 `ISaveableContent<IEnemyManagerSaveState<TAttr>>`
新增 `compareWith`、`modifyPrefabAttribute`、`attachEnemyComparer`、`getEnemyComparer`
修改 `getPrefab``getPrefabById` 返回类型为 `IReadonlyEnemy<TAttr>`
### `packages-user/data-base/src/enemy/enemy.ts``Enemy` 类)
- [ ] 实现 `saveState(compression): IEnemySaveState<TAttr>`
- [ ] 实现 `loadState(state, compression): void`
### `packages-user/data-base/src/enemy/manager.ts``EnemyManager` 类)
- [ ] 新增 `private readonly dirtySet: Set<number>` 成员:记录脏模板的 code
- [ ] 新增 `private referenceByCode: Map<number, IReadonlyEnemy<TAttr>>` 成员:
保存参考快照
- [ ] 新增 `private comparer: IEnemyComparer<TAttr> | 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`

View File

@ -0,0 +1,54 @@
# 怪物系统文件结构重构
**目的:** 使 `IEnemyHandler` 能持有 `IStateBase` 引用而不产生循环引用。
**循环根源:** `enemy/types.ts` 混写了两层接口,`data-base/src/types.ts` 经由 `./enemy` index 引入 `IEnemyManager`,导致无法反向引用。
## 接口归属
**`enemy/types.ts`Layer 1 — 存档/管理层,保留)**
只保留以下接口,其余全部迁出:
- `IEnemySaveState`、`IEnemyManagerSaveState`、`IEnemyComparer`
- `ISpecial`、`IReadonlyEnemy`、`IEnemy`
- `SpecialCreation`、`IEnemyLegacyBridge`、`IEnemyManager`
只允许引用 `@motajs/common`、`../common`,不得引用 `../types`
**`enemy/utils.ts`Layer 0 — 工具层,与实现类并列)**
`enemy/types.ts` 迁出以下接口(实现类 `MapLocIndexer` 已在此文件):
- `IMapLocHelper`、`IMapLocIndexer`
**`combat/types.ts`Layer 2 — 战斗/上下文层,现为空文件)**
`enemy/types.ts` 迁入以下接口:
- `IEnemyHandler`、`IReadonlyEnemyHandler`、`IEnemyView`
- `IEnemySpecialModifier`、`IAuraView`、`IEnemyAuraView`、`IAuraConverter`
- `IEnemySpecialQueryModifier`、`IEnemySpecialQueryEffect`、`IEnemyCommonQueryEffect`、`IEnemyFinalEffect`
- `IMapDamageInfoExtra`、`IMapDamageInfo`、`IMapDamageView`、`IMapDamageConverter`、`IMapDamageReducer`、`IMapDamage`
- `IEnemyDamageInfo`、`IEnemyCritical`、`CriticalableHeroStatus`、`IDamageCalculator`、`IDamageContext`、`IDamageSystem`
- `IEnemyContext`
需引用 `../enemy/types`Layer 1、`../enemy/utils`Layer 0、`../types``IStateBase`,现在无循环)。在 `IEnemyHandler``IReadonlyEnemyHandler` 中新增 `readonly state: IStateBase<TAttr, THero>`
## 文件修改清单
| 文件 | 操作 |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `combat/types.ts` | 迁入所有 Layer 2 接口;新增三条 import`../enemy/types`、`../enemy/utils`、`../types``IEnemyHandler` 系列加 `state` 字段 |
| `combat/context.ts` | 从 `enemy/context.ts` **移动**import 路径改为从 `./types`、`../enemy/types`、`../enemy/utils` 引入 |
| `combat/damage.ts` | 从 `enemy/damage.ts` **移动**import 路径同上 |
| `combat/mapDamage.ts` | 从 `enemy/mapDamage.ts` **移动**import 路径同上 |
| `combat/index.ts` | 新建,`export * from './types/context/damage/mapDamage'` |
| `enemy/types.ts` | 删除所有 Layer 2 接口(迁出后仅剩 Layer 1 内容) |
| `enemy/utils.ts` | 新增 `IMapLocHelper`、`IMapLocIndexer` 接口定义 |
| `enemy/index.ts` | 删除对 `context`、`damage`、`mapDamage` 的导出 |
| `data-base/src/index.ts` | 新增 `export * from './combat'` |
| `data-base/src/types.ts` | `import { IEnemyManager }` 改从 `./enemy/types` 直接引入(`enemy/types.ts` 不再引用 `../types`,无循环) |
| `enemy/enemy.ts` | import 路径无需改动(`IEnemy` 等仍在 `./types` |
| `enemy/manager.ts` | import 路径无需改动(全部 Layer 1 接口仍在 `./types` |
| `enemy/special.ts` | import 路径无需改动 |

View File

@ -0,0 +1,204 @@
# 需求综述
为勇士添加装备系统,实现装备的装上、卸下及属性加成。核心工作分为四层:实例存储层(`IHeroEquipsStore`,不可堆叠,以唯一 `uid` 管理每件装备)、装备状态层(`IEquipmentState`,纯数据,持有修饰器实例并负责存档读档)、属性加成层(通用 `ValueModifier``PercentageModifier`)、装备系统层(`IHeroEquipment`,操作装上/卸下,管理槽位)。
# 需求理解
## 明确需求
1. 独立 uid 管理。装备道具不沿用 `count` 合并计数,每件实例以自增 `uid` 唯一标识
2. 通用修饰器加成。属性加成通过 `ValueModifier``PercentageModifier` 实现,百分比 `priority` 高于数值
3. 修饰器存档隔离。装备修饰器不入勇士状态存档——需在 `addModifier` 中新增 `nosave` 参数
4. 排序与对比。提供 `IEquipmentSorter` 自定义排序和 `compareEquip` 属性对比
5. 槽位系统。槽位名称通过 `setSlots` 动态设置,装备声明可用槽位
## 隐含需求
1. 实例与定义分离。修饰器的值可独立于定义数据变化(强化通过 `setValue`),但修饰器的存在(有哪些属性)始终由定义决定
2. 装上/卸下必须可逆。卸下后勇士属性恢复到装上前的状态,修饰器的添加和移除必须对称
3. 重复装上处理策略。同一装备不能重复装上同一个槽,但已在其他槽的装备需通过 `autoUnload` 或直接拒绝来处理
4. 排序影响 UI 展示。装备实例的排序结果直接影响背包和装备栏的展示顺序,排序标准由游戏作者定义
# 设计前提
1. 装备不可堆叠。装备可能拥有不同的强化状态,同名装备的属性值可以不同,因此无法像消耗品一样合并计数——每件装备都是独立个体
2. 固定槽位序列。槽位是勇士身上若干个固定位置按顺序排列而成,每件装备只能安装到其声明兼容的槽位中
3. 修饰器是唯一加成载体。装备对勇士属性的影响完全通过修饰器实现,不存在修饰器之外的加成方式
# 核心概念定义
## 装备
装备是 `ItemCategory.Equipment` 类别的道具。与永久道具和消耗道具不同,装备不可堆叠——同名但强化状态不同的两件装备拥有不同的运行时数据,必须分立管理。每件装备的定义数据中包含可安装的槽位信息和属性加成定义(数值加成与百分比加成),实际属性影响由从定义派生的修饰器产生。
## 装备槽
装备槽是勇士身上若干固定位置的序列。每个槽位由一个字符串标识其名称(如"武器"、"头盔"),由游戏作者定义。每件装备在其定义中声明兼容的槽位列表,只有目标槽位名称在声明中时方可装上。槽位索引与槽位名称是两个维度:传入槽名时可能在多个同名槽中自动选择空槽;传入索引时精确定位。同一槽位同时最多容纳一件装备;若目标槽位已被占用,装上时自动卸载原装备。
## 装备装上行为
装备装上行为是指一件装备实例与勇士身上某个槽位之间建立的绑定关系。此关系使得装备的修饰器在该期间对勇士属性生效。此关系是临时的、可逆的——绑定可随时解除。
## 装备卸下行为
装备卸下行为是指解除装备实例与槽位之间绑定关系的操作。解除后装备实例本身不会消失,仅槽位恢复为空,装备的修饰器不再影响勇士属性。
## 装备修饰器
装备修饰器是装备对勇士属性产生影响的抽象单元。每件装备从定义数据派生出若干修饰器,每个修饰器持有一个值(加成的量)并拥有一种计算规则。修饰器分为两类:固定数值加成——直接在属性值上叠加;百分比加成——基于原始基础属性按比例计算。修饰器之间具有先后关系:百分比修饰器先于数值修饰器计算。修饰器的值与其所属装备实例绑定,可随强化等操作独立变化。
# 接口设计分析
## `IEquipmentState`
### 设计思路
由于装备修饰器的值会因强化而变化,独立于装备的定义数据,因此需要一个与装备实例一一对应的运行时状态对象,用于持有全部修饰器并在构造时从定义数据派生创建。修饰器本身不应直接序列化,而是保存其上游数据(即当前的 `value``percentage` 映射),读档时通过 `setValue` 恢复。这样设计也使得 `SaveCompression` 的差异化存储成为可能:`NoCompression` 完整存储,其他级别仅存与定义值不同的条目。
### 接口分析
- 成员 `uid: number`:预期频率**中频**。实例在存储层和装备系统中的唯一标识
- 成员 `item: IItemRawData<THero>`:预期频率**中频**。装备定义数据引用,获取名称、槽位等信息
- 成员 `modifiers: [SelectKey<THero, number>, IHeroModifier<number>][]`:预期频率**中频**。该装备的全部修饰器,装上/卸下时遍历
### 预期体量
合计约 80 行。
- 构造器预期 15 行:两组循环分别从 `IItemEquipData.value``IItemEquipData.percentage` 创建 `ValueModifier``PercentageModifier`,修饰器数量通常为个位数
- 存档逻辑预期 30 行:两个压缩分支,每个分支内遍历两种修饰器、比较值、写入 `Map`
- 读档逻辑预期 25 行:与存档对称,差异模式下还需处理键缺失时回退到定义数据的逻辑
## `IHeroEquipsStore`
### 设计思路
由于装备不可堆叠(设计前提 1需要以 `uid` 为键的独立存储模型,因此设计 `IHeroEquipsStore``Map` 管理所有实例。作为 `IHeroItems.equipment` 成员暴露,替代原有基于 `count` 的装备分表。
由于游戏作者可能需要自定义装备在背包中的排序,因此提供 `useSorter` 注入 `IEquipmentSorter` 回调,由 `instances``instancesOf` 调用。
由于装备栏 UI 需要展示装备切换前后的属性变化,因此提供 `compareEquip`,它先卸载目标槽位当前装备的修饰器,再分别模拟装上两件候选装备,通过 `getFinalAttribute` 对比差值。
### 接口分析
- 方法 `add(item: number | string): number`:预期频率**高频**。创建装备实例分配 `uid`,返回新 `uid`
- 方法 `delete(uid: number): void`:预期频率**中频**。按 `uid` 移除实例
- 方法 `get(uid: number): IEquipmentState<THero> | null`:预期频率**高频**。获取实例状态,与是否当前装备无关
- 方法 `count(item: number | string): number`:预期频率**中频**。按装备类型统计实例数量
- 方法 `compareEquip(equipA: number, equipB: number, slot: number): Readonly<Partial<THero>>`:预期频率**中频**。模拟装上两件装备后对比属性差
- 方法 `useSorter(comparer: IEquipmentSorter<THero> | null): void`:预期频率**低频**。注入自定义排序器
- 方法 `instancesOf(equip: number | string): IEquipmentState<THero>[]`:预期频率**中频**。按类型过滤并排序
- 方法 `instances(): IEquipmentState<THero>[]`:预期频率**中频**。排序返回全部实例
### 预期体量
合计约 120 行。
- 增删查预期 20 行:`add` 中包含从类型标识解析图块数字、从物品存储获取定义数据、创建 `EquipmentState`,其余为纯 `Map` 操作
- 计数预期 8 行:遍历所有实例,按 `item.num` 比较并计数
- 排序预期 15 行:根据是否注册了排序器分两分支,有排序器时需构造 `IEquipmentSortHandler` 并调用回调
- 实例列表获取预期 20 行:`instances` 收集全部实例并排序,`instancesOf` 多一层类型过滤
- 属性对比预期 35 行:两次 `clone` 属性对象,先卸载目标槽位已有装备的修饰器,再分别遍历候选装备的 `modifiers` 调用 `addModifier`,收集受影响的键并通过 `getFinalAttribute` 比对差值
- 存档读档预期 20 行:遍历所有实例调用各自存档读档方法
## `IHeroEquipment`
### 设计思路
由于装上/卸下操作需要同时访问装备实例存储(获取 `IEquipmentState` 及修饰器)和勇士属性对象(执行修饰器的添加与移除),且槽位占用关系需要独立维护(`Map<槽索引, uid>`),因此需要一个持有两者引用的系统层组件。
由于 UI 需要在装上之前判断兼容性且不能产生副作用,因此提供 `canEquipTo` 做只读检查。装上过程涉及多层判断(槽位兼容、已装检查、替换处理),因此 `equip` 返回被替换的 `uid` 供调用方跟进。卸下时按槽索引精确定位并清理,返回卸下的 `uid`
由于装备栏 UI 需要按槽位顺序渲染,因此 `getEquips``slots` 顺序输出实例数组,空槽填 `null`,通过数组下标直接对应槽位。
### 接口分析
- 成员 `slots: readonly string[]`:预期频率**低频**。当前槽位名称序列
- 方法 `setSlots(slots: string[]): void`:预期频率**低频**。运行时设置槽位名称
- 方法 `canEquipTo(uid: number, slot: number | string): EquipStatus`:预期频率**高频**。无副作用的兼容性检查
- 方法 `equip(uid: number, slot: number | string, autoUnload?: boolean): number | undefined`:预期频率**高频**。装上并自动处理冲突,返回被替换 `uid`
- 方法 `unequip(slot: number): number | undefined`:预期频率**中频**。按槽卸下,返回被卸下 `uid`
- 方法 `equipped(uid: number): boolean`:预期频率**高频**。按 `uid` 查询是否已在某槽
- 方法 `getEquips(): (IEquipmentState<THero> | null)[]`:预期频率**中频**。按槽位顺序输出,空槽为 `null`
### 预期体量
合计约 140 行。
- 兼容性检查预期 30 行:分 `typeof slot === 'number'` 和字符串两分支,数字分支直接查 `Map`,字符串分支需遍历 `slots` 查找匹配槽名并判断空槽
- 装上逻辑预期 50 行:同 `uid` 跨槽检查与 `autoUnload` 策略、数字/字符串两分支下的槽位查找与替换、卸载旧装备后再装新装备
- 卸下逻辑预期 15 行:按槽索引取值、遍历修饰器移除、清空槽位
- 装备列表获取预期 15 行:按 `slots` 数组长度遍历,对每个下标查 `Map` 获取 `uid` 再查存储获取实例
- 查询与存档读档预期 20 行:`equipped` 遍历 `Map.values()` 比对;存档写 `Map``slots`,读档清空后按存档顺序逐个 `equip`
## 通用修饰器
### 设计思路
由于装备的属性加成只有固定数值和基于基础属性的百分比两种形式,因此提供 `ValueModifier``PercentageModifier` 两个通用修饰器即可覆盖全部需求。二者均继承 `BaseHeroModifier<number, number>``type` 硬编码以便读档时通过工厂重建,`priority` 由构造器参数传入——装备创建时分别传 0 和 10 以确保百分比先于数值计算。
### 预期体量
合计约 40 行。
- 构造器与 `type` 存储预期 15 行:接收 `priority``value`,存储 `type` 为硬编码字符串
- `modify` 方法预期 6 行:分别实现加法(`value + this.value`)和百分比计算(`value + baseValue * this.value`
- 无独立存档逻辑:修饰器值的变化由 `EquipmentState``value`/`percentage` 映射管理
---
# 实现思路
## 存档差异化存储
1. `saveState` 时判断 `SaveCompression``NoCompression` 直接存入完整的 `value``percentage` 映射
2. 其他级别将每个修饰器的当前值与 `this.item.equip` 中的原始定义值逐一比对,仅当不同时写入映射
3. `loadState` 反向处理:`NoCompression` 对所有修饰器 `setValue`;其他级别查询存档中的值,键缺失时回退到原始定义值
## `compareEquip` 属性模拟
1. 克隆当前属性,若目标槽位已有装备,先移除其修饰器
2. 将装备 A 的修饰器加到副本上,通过 `getFinalAttribute` 获取各属性值
3. 对另一副本重复步骤 1 和 2换为装备 B
4. 收集两件装备 `modifiers` 覆盖到的所有属性键,计算差值
# 涉及文件
## `@user/data-base/hero/types.ts`
- [ ] 新增 `IEquipmentStateSave`:装备状态存档接口,按照本文描述完成设计
- [ ] 新增 `IHeroEquipsStoreSave`:实例仓库存档接口,按照本文描述完成设计
- [ ] 新增 `IHeroEquipmentSave`:装备系统存档接口,按照本文描述完成设计
- [ ] 新增 `IEquipmentState`:装备状态纯数据层接口,按照本文描述完成设计
- [ ] 新增 `IHeroEquipsStore`:装备实例存储接口,按照本文描述完成设计
- [ ] 新增 `IEquipmentSortHandler`:排序上下文接口,按照本文描述完成设计
- [ ] 新增 `IEquipmentSorter`:排序回调接口,按照本文描述完成设计
- [ ] 新增 `EquipStatus`:装备兼容判断结果枚举,按照本文描述完成设计
- [ ] 新增 `IHeroEquipment`:装备系统层接口,按照本文描述完成设计
- [ ] 修改 `IHeroItems`:新增装备存储成员以接入装备系统
- [ ] 修改 `IHeroAttribute.addModifier`:新增存档隔离参数
- [ ] 修改 `IHeroStateSave`:新增装备系统成员以接入存档流程
- [ ] 修改 `IHeroState`:新增装备系统成员以接入读档流程
## `@user/data-base/hero/equipment.ts`
- [ ] 编写 `EquipmentState` 类:按照本文描述实现装备状态层
- [ ] 编写 `HeroEquipsStore` 类:按照本文描述实现实例存储层
- [ ] 编写 `HeroEquipment` 类:按照本文描述实现装备系统层
## `@user/data-base/hero/modifier.ts`
- [ ] 编写 `ValueModifier` 类:按照本文描述实现数值修饰器
- [ ] 编写 `PercentageModifier` 类:按照本文描述实现百分比修饰器
## `@user/data-base/hero/attribute.ts`
- [ ] 修改 `HeroAttribute` 类:按照本文描述支持修饰器存档隔离
## `@user/data-base/hero/items.ts`
- [ ] 修改 `HeroItems` 类:按照本文描述传递勇士属性引用并接入装备存储
## `@user/data-base/hero/state.ts`
- [ ] 修改 `HeroState` 类:按照本文描述初始化装备系统并改写存档读档流程

View File

@ -0,0 +1,140 @@
# 需求综述
对勇士跟随者相关接口进行结构性重构。核心动机是让跟随者复用勇士已有的位置与渲染接口(`IHeroLocation`、`IHeroRendering`),而非为跟随者定义独立的位置/渲染类型。这样跟随者与勇士共享同一套移动与渲染模型,简化接口层级,避免重复定义。
重构四个接口:`IHeroLocation`、`IHeroRendering`、`IHeroFollower`、`IHeroFollowersController`。前两者是勇士与跟随者共用的基础接口,后两者专属于跟随者管理。同时需要将旧的 `HeroMoveController` 类拆分为 `HeroLocation`、`HeroRendering`、`HeroFollowersController` 三个独立类,`HeroFollower` 为新实现类,并修改 `HeroState` 适配新接口。
# 接口设计分析
## IHeroLocation
### 接口综述
勇士位置接口,代表一个可移动对象在游戏世界中的坐标与朝向,并挂载移动器负责移动控制。该接口继承 `ISaveableContent<IHeroLocationSave>` 实现存档读档,继承 `IObjectMovable` 获得基础位置读写能力。存档格式为 `IFacedTileLocator`(包含 `x`、`y`、`direction`)。移动能力由 `IObjectMover<this>` 提供,支持行走、跳跃、瞬移、朝向控制等全部需求。
### 接口分析
- `IHeroLocation.mover`:预期频率**高频**。几乎所有移动操作都经由 `mover` 进行,在剧情演出与逻辑中频繁出现。典型使用场景:演出中让角色沿指定路线移动。
## IHeroRendering
### 接口综述
勇士渲染信息接口,存在于数据端,存放无需进入渲染端即可管理的渲染相关数据。当前仅包含透明度(`alpha`),后续可能扩展。继承 `ISaveableContent<IHeroRenderingSave>` 实现存档读档,继承 `IHookable<IHeroRenderingHooks>` 支持透明度变化时触发回调。旧实现中的 `image` 作为纯渲染端概念被移除。
### 接口分析
- `IHeroRendering.alpha`:预期频率**低频**。直接读取透明度值的需求极少。
- `IHeroRendering.setAlpha`:预期频率**低频**。设置透明度仅在特殊效果中出现,一座塔中调用次数有限。
## IHeroFollower
### 接口综述
跟随者接口,包含跟随者的渲染信息与位置信息。直接复用 `IHeroRendering``IHeroLocation`,使跟随者拥有与勇士完全一致的移动与渲染能力。存档格式 `IHeroFollowerSave` 嵌套包含 `IHeroRenderingSave``IHeroLocationSave`。对比旧 `IHeroFollower = { num, identifier, alpha }` 的纯数据结构,新接口让跟随者从静态数据升级为拥有完整移动能力的独立对象。
### 接口分析
- `IHeroFollower.rendering`:预期频率**低频**。
- `IHeroFollower.location`:预期频率**低频**。
> 跟随者本身就是一个使用场景极低的需求,大多数游戏中都不会使用到一两次,所以几乎所有跟随者相关接口都应该是低频。
## IHeroFollowersController
### 接口综述
跟随者控制器接口,负责跟随者的增删查改与聚集操作。继承 `IHookable<IHeroFollowersControllerHooks>`,提供添加、移除、聚集三个事件的钩子支持。对比旧实现:索引取代 `identifier` 作为查找方式,`setFollowerAlpha` 移除(改为通过 `follower.rendering.setAlpha` 操作),`addFollower` 的 `num` 参数合并了旧的图块数字与标识符。
### 接口分析
- `IHeroFollowersController.addFollower`:预期频率**低频**。
- `IHeroFollowersController.getFollower`:预期频率**低频**。
- `IHeroFollowersController.getFollowersById`:预期频率**低频**。
- `IHeroFollowersController.getAllFollowers`:预期频率**低频**。
- `IHeroFollowersController.removeFollower`:预期频率**低频**。
- `IHeroFollowersController.removeAllFollowers`:预期频率**低频**。
- `IHeroFollowersController.gatherFollowers`:预期频率**低频**。
- `IHeroFollowersController.gatherFollowersSync`:预期频率**低频**。
### 预期体量
预期代码体量合计约 250300 行。分析如下:
- `HeroLocation`:需实现 `IObjectMovable``ISaveableContent`,并创建内部 `ObjectMover` 子类,预计 7090 行。
- `HeroRendering`:仅管理 `alpha` 的存取和钩子触发,预计 2535 行。
- `HeroFollower`:组合 `HeroRendering``HeroLocation` 并实现存档读档,预计 3545 行。
- `HeroFollowersController`:维护跟随者列表和增删查改聚集操作,预计 120150 行。
# 涉及文件
## 需要修改的文件
### `@user/data-base/hero/types.ts`
- [x] 重构 `IHeroLocation` 接口:改为继承 `ISaveableContent<IHeroLocationSave>``IObjectMovable`,仅保留 `mover` 成员。
- [x] 新增 `IHeroLocationSave` 类型别名。
- [x] 重构 `IHeroRendering` 接口:新增继承 `ISaveableContent<IHeroRenderingSave>``IHookable<IHeroRenderingHooks>`
- [x] 新增 `IHeroRenderingSave` 接口。
- [x] 新增 `IHeroRenderingHooks` 接口。
- [x] 新增 `IHeroFollowerSave` 接口。
- [x] 重构 `IHeroFollower` 接口:改为组合 `rendering``location`,继承 `ISaveableContent<IHeroFollowerSave>`
- [x] 重构 `IHeroFollowersController` 接口:修改为上文中的新设计。
- [x] 新增 `IHeroFollowersControllerHooks` 接口。
- [x] 修改 `IHeroState` 接口:新增 `location`、`rendering`、`followers` 成员,移除旧的 `mover`、`attachMover`、`getHeroMover`。
- [x] 修改 `IHeroStateSave``locator` 类型改为 `IHeroLocationSave``followers` 类型改为 `readonly IHeroFollowerSave[]`
- [ ] 删除旧的 `IHeroMoveController`、`IHeroMoveControllerHooks` 接口及旧 `IHeroFollower`、`HeroAnimateDirection` 枚举。
### `@user/data-base/hero/mover.ts`
删除此文件,内容拆分至 `location.ts`、`rendering.ts`、`followersController.ts`。
### `@user/data-base/hero/location.ts`
新建文件。
- [ ] 实现 `HeroLocation` 类:基于新 `IHeroLocation` 接口。
- [ ] 内部创建 `ObjectMover<this>` 子类实例,挂载为 `mover`
### `@user/data-base/hero/rendering.ts`
新建文件。
- [ ] 实现 `HeroRendering` 类:基于新 `IHeroRendering` 接口。
- [ ] 实现 `setAlpha`:更新值并触发 `onSetAlpha` 钩子。
### `@user/data-base/hero/follower.ts`
新建文件。
- [ ] 实现 `HeroFollower` 类:组合 `HeroRendering``HeroLocation` 实例,实现新 `IHeroFollower` 接口。
- [ ] 内部保存 `num`,供 `getFollowersById` 查询匹配。
### `@user/data-base/hero/followersController.ts`
新建文件。
- [ ] 实现 `HeroFollowersController` 类:基于新 `IHeroFollowersController` 接口。
- [ ] 内部持有勇士 `IHeroLocation` 引用,供聚集操作使用。
- [ ] 实现 `addFollower(num)`:创建 `HeroFollower`,加入列表,触发 `onAddFollower` 钩子。
- [ ] 实现 `getFollower(index)`:按索引返回跟随者,越界返回 `null`
- [ ] 实现 `getFollowersById(num)`:返回匹配 `num``[index, follower]` 迭代器。
- [ ] 实现 `getAllFollowers()`:返回跟随者列表。
- [ ] 实现 `removeFollower(index)`:按索引移除跟随者,触发 `onRemoveFollower` 钩子,动画结束后兑现。
- [ ] 实现 `removeAllFollowers()`:清空跟随者列表,触发钩子,动画结束后兑现。
- [ ] 实现 `gatherFollowers()`:将所有跟随者移动至勇士位置并同步朝向,动画结束后兑现。
- [ ] 实现 `gatherFollowersSync()`:立即将所有跟随者瞬移至勇士位置并同步朝向。
### `@user/data-base/hero/state.ts`
修改 `HeroState` 类。
- [ ] 修改构造函数参数并新增 `location`、`rendering`、`followers` 成员,替代旧的 `mover`
- [ ] 删除 `attachMover`、`getHeroMover` 方法。
- [ ] 修改 `saveState`:通过 `location.saveState`、`rendering.saveState` 及各跟随者的 `saveState` 获取存档数据。
- [ ] 修改 `loadState`:通过 `location.loadState`、`rendering.loadState` 恢复位置与渲染,重建跟随者列表。
### `@user/data-base/hero/index.ts`
- [ ] 新增导出 `location.ts`、`rendering.ts`、`follower.ts`、`followersController.ts`。
- [ ] 移除 `mover.ts` 的导出。

131
docs/dev/hero/hero-items.md Normal file
View File

@ -0,0 +1,131 @@
# 需求综述
为勇士添加背包系统用于存储永久道具Constant、消耗道具Consumable和装备道具Equipment。即捡即用道具Pick在 addItem/getItem 时直接执行效果,不进入背包。
背包系统由 `IHeroItems` 接口定义,作为 `IHeroState` 的新成员挂载。三种类型的道具分别存储在三张 `Map<number, IHeroItemState>` 中,键为道具图块数字 `num`。背包需要支持存档读档,继承 `ISaveableContent<IHeroItemsSave>`
# 接口设计分析
## IHeroItemState
### 设计意图
单个道具在背包中的运行时状态,存储道具的基础标识信息与当前数量。`raw` 为对 `IItemRawData` 的引用,提供 `category`、`name` 等定义数据的便捷访问,读档时通过 `state.itemStore.getData(num)` 恢复。
### 接口分析
- `IHeroItemState.id`:预期频率**中频**。道具的字符串标识符,用于通过 id 寻址时做匹配。典型使用场景:通过 id 判断是否持有某特定道具。
- `IHeroItemState.num`:预期频率**高频**。道具的图块数字,是 Map 键及与地图关联的核心标识。典型使用场景:地图事件中检查玩家是否拥有 `num` 对应的钥匙。
- `IHeroItemState.raw`:预期频率**高频**。道具原始定义数据引用,提供 category、name、effect 等属性。典型使用场景:显示道具名称、判断道具类型、执行道具效果。
- `IHeroItemState.count`:预期频率**中频**。道具当前持有数量。三种可存储类型的道具均正常存储数量,不存在时视为无此道具。
### 预期体量
约 12-16 行。
## IHeroItemSave
### 设计意图
单个道具的存档格式,仅需存储可序列化的字段(`num`、`count``id` 和 `raw` 在读档时通过 `itemStore``tileStore` 恢复。
### 预期体量
约 5-8 行。
## IHeroItemsSave
### 设计意图
整个背包的存档格式,包含三种类型道具的存档数组。由于三种类型的道具分表存储,存档也对应分表。
### 预期体量
约 5-8 行。
## IHeroItems
### 设计意图
勇士背包管理接口,作为 `IHeroState` 的新成员。提供道具的增减查用等基础操作,所有操作均支持通过 `num`(数字)或 `id`(字符串)寻址。
`addItem` 是核心接口,负责向勇士给予道具(`count` 可为负数以实现扣除)。`getItem` 是 `addItem(item, 1)` 的便捷写法,语义为勇士在游戏中"拾取"一个道具。`getItemState` 是查询接口,获取道具的只读状态。
对于 Pick 类型道具,`addItem` / `getItem` 不将其存入背包,而是直接执行 `raw.effect.pickEffect`
### 接口分析
- `IHeroItems.addItem(item, count?)`:预期频率**中频**。向勇士添加或减少道具。若道具为 Pick 类型则立刻执行其效果;否则根据 `category` 选择对应分表,增加或减少 `count`,数量归零或负数时自动删除条目。典型使用场景:勇士在地图上走到道具格后拾取道具;事件脚本中给予或扣除玩家道具。
- `IHeroItems.getItem(item)`:预期频率**高频**。使勇士获取一个道具,即 `addItem(item, 1)` 的另一种写法。Pick 类型立即执行效果,其他类型背包数量加一。典型使用场景:勇士走到地图道具格上触发拾取。
- `IHeroItems.getItemState(item)`:预期频率**高频**。获取指定道具的只读状态,是所有查询操作的入口。返回 `null` 表示未持有。典型使用场景NPC 检查玩家是否持有某任务道具UI 显示道具详情。
- `IHeroItems.useItem(item)`:预期频率**中频**。使用道具,仅对 Constant 和 Consumable 类型生效。调用 `raw.effect.useEffect`Consumable 类型使用后数量减一。返回 `boolean` 表示是否使用成功。
- `IHeroItems.itemCount(item)`:预期频率**高频**。获取道具当前持有数量,不存在时返回 0。典型使用场景UI 中显示道具剩余数量。
### 预期体量
接口约 20-25 行。
实现类 `HeroItems` 约 80-100 行。内部维护三张 `Map<number, IHeroItemState>`,提供增删查用及存档读档逻辑。
# 实现思路
## id 与 num 的统一寻址
所有接口参数均接受 `number | string` 类型的 `item`。当传入为数字时,直接用作 Map 键;当传入为字符串时,需要先通过 `state.tileStore.idToNumber(item)` 转换为 `num`,再用于查找。若转换结果为 `null` 则视为不存在。
注意:道具 `id``num` 的转换依赖 `ITileStore.idToNumber`,而非 `IItemStore`(道具本身是 tile 的一类具体属性,通过 tile 即可定位,`IItemStore` 只负责道具定义存储,不需要 id↔num 双向索引)。
## 道具存取流程
1. `addItem(item, count)`:将 `item` 解析为 `num`,调用 `state.itemStore.getData(num)` 获取 `raw`。若 `raw``null` 则忽略。若 `raw.category === ItemCategory.Pick`,执行 `raw.effect.pickEffect(raw)` 后直接返回。否则,根据 `raw.category` 选择对应分表 `Map<number, IHeroItemState>`,若已存在条目则加大 `count`,结果 ≤ 0 则删除条目,否则创建新条目插入。
2. `getItem(item)`:调用 `addItem(item, 1)`
3. `getItemState(item)`:将 `item` 解析为 `num`,通过 `state.itemStore.getCategory(num)` 获取道具类型并定位对应分表,从分表中获取条目并返回只读快照,不存在返回 `null`
4. `useItem(item)`:通过 `getItemState` 获取条目,若不存在或非 Constant/Consumable 类型则返回 `false`;若 `raw.effect.canUse``false` 则返回 `false`。调用 `raw.effect.useEffect`。若为 Consumable 类型,`count--`,归零时删除条目。返回 `true`
5. `itemCount(item)`:通过 `getItemState` 获取条目,存在则返回 `count`,不存在返回 `undefined`
## 存档读档
- `saveState`:遍历三张分表,将每个条目提取 `{ num, count }` 形成 `IHeroItemSave`,构造 `IHeroItemsSave` 并返回。
- `loadState`:遍历 `IHeroItemsSave` 中三种类型数组,对每条 `IHeroItemSave` 调用 `state.itemStore.getData(num)` 恢复 `raw`,重建分表。
# 涉及文件
## 需要引用的文件
- `@user/data-common`:引用 `ISaveableContent`、`IDataCommonExtended`、`SaveCompression`、`IItemRawData`、`ItemCategory`、`IDataCommon`。
- `@user/data-base/hero/types.ts`:引用 `IHeroState`,向其新增 `items` 成员。
## 需要修改的文件
### `@user/data-base/hero/types.ts`
- [x] 新增 `IHeroItemSave` 接口:包含 `readonly num: number`、`readonly count: number`。
- [x] 新增 `IHeroItemsSave` 接口:包含 `readonly constants: readonly IHeroItemSave[]`、`readonly consumables: readonly IHeroItemSave[]`、`readonly equipments: readonly IHeroItemSave[]`。
- [x] 新增 `IHeroItemState` 接口:包含 `readonly id: string`、`readonly num: number`、`readonly raw: IItemRawData<THero>`、`count: number`。
- [x] 新增 `IHeroItems<THero>` 接口:继承 `ISaveableContent<IHeroItemsSave>`、`IDataCommonExtended`,提供 `addItem`、`getItem`、`getItemState`、`useItem`、`itemCount` 方法。
- [x] 修改 `IHeroStateSave<THero>`:新增 `readonly items: IHeroItemsSave` 成员。
- [x] 修改 `IHeroState<THero>`:新增 `readonly items: IHeroItems<THero>` 成员。
### `@user/data-base/hero/items.ts`(新建)
- [ ] 实现 `HeroItemState` 类:实现 `IHeroItemState` 接口,存储 `id`、`num`、`raw`、`count`。
- [ ] 实现 `HeroItems` 类:实现 `IHeroItems<THero>` 接口,按功能分区(查询 / 增删 / 使用 / 存档 / 私有辅助)。
- [ ] `HeroItems` 内部维护 `constants`、`consumables`、`equipments` 三张 `Map<number, HeroItemState>`
- [ ] 实现 `internalGetItemState` 私有方法:通过 `itemStore.getCategory` 定位分表后从中获取,返回可修改引用。
- [ ] 实现 `getItemState`:包装 `internalGetItemState`,返回 `Readonly` 快照。
- [ ] 实现 `addItem`:通过 `state.itemStore.getData` 获取 `raw`Pick 类型执行效果后直接返回;其他类型按 `category` 插入对应分表,支持负数删除。
- [ ] 实现 `getItem`:转发至 `addItem(item, 1)`
- [ ] 实现 `useItem`:仅对 Constant/Consumable 调用 `raw.effect.useEffect`Consumable 减量,返回成功与否。
- [ ] 实现 `itemCount`:通过 `getItemState` 返回 count 或 0。
- [ ] 实现 `mapToSave` / `loadMap`:存档仅存 `{ num, count }`,读档通过 `itemStore.getData` 恢复 `raw`
- [ ] 实现 `saveState` / `loadState`:序列化与反序列化三张分表。
### `@user/data-base/hero/state.ts`
- [ ] `HeroState` 构造函数中新增 `this.items = new HeroItems(state)`
- [ ] `saveState` 中新增 `items: this.items.saveState(compression)`
- [ ] `loadState` 中新增 `this.items.loadState(state.items, compression)`
### `@user/data-base/hero/index.ts`
- [ ] 新增 `export * from './items'` 导出。

View File

@ -0,0 +1,110 @@
# 需求综述
当前勇士属性修饰器 `IHeroModifier` 是非注册式的,无法直接存档。
目标是将其改为注册式,每种修饰器在使用前需先在属性对象上注册,
并实现 `ISaveableContent` 接口以支持存档读档。
注册接口签名:`registerModifier(identifier: string, cons: () => IHeroModifier): void`
# 实现思路
## 1. 修改 `IHeroModifier` 接口
- 将 `identifier` 改为 `type`:一类修饰器可能被添加多次,
`type``identifier` 更准确地表达"修饰器类型"的含义。
- 新增泛型参数 `S = unknown` 作为存档类型,让接口继承 `ISaveableContent<S>`
`IHeroModifier<T, V, S = unknown>`
- 大多数修饰器的可变状态只有 `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<V>`
- 改为 `BaseHeroModifier<T, V>` 隐式以 `S = V` 实现 `ISaveableContent<V>`
- 将 `abstract readonly identifier: string` 改为 `abstract readonly type: string`
- `saveState()` 直接返回 `this.currentValue`
- `loadState(state)` 调用 `this.setValue(state)` 恢复值
## 6. 修改 `HeroState`
修饰器注册表移至 `HeroState`
- 新增 `private readonly registry: Map<string, () => IHeroModifier>` 存储工厂函数
- 实现 `registerModifier(type, cons)`:向 `registry` 写入工厂,
并同步注册到接口签名中
- 实现 `createModifier<V>(type)`:从 `registry` 取出对应工厂,
调用工厂函数创建并返回修饰器实例,类型为 `IHeroModifier<unknown, V>`
- 实现 `createAndInsertModifier<K, V>(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`
- [x] 修改 `IHeroModifier<T, V>` 接口:
改为 `IHeroModifier<T, V, S = unknown>``identifier` 改名为 `type`
继承 `ISaveableContent<S>`
- [x] 新增 `IModifierStateSave` 接口:单条修饰器的存档格式
- [x] 修改 `IHeroStateSave<THero>`:新增 `readonly modifiers: readonly IModifierStateSave[]` 字段
- [x] 修改 `IReadonlyHeroAttribute<THero>`:新增 `iterateModifiers()` 方法签名
- [x] 修改 `IHeroState<THero>`:新增以下方法签名 - `registerModifier(type: string, cons: () => IHeroModifier): void` - `createModifier<V>(type: string): IHeroModifier<unknown, V>` - `createAndInsertModifier<K extends keyof THero, V>(type: string, name: K): IHeroModifier<unknown, V>`
### `@user/data-base/hero/attribute.ts`
- [x] 修改 `BaseHeroModifier<T, V>`
`abstract readonly identifier` 改为 `abstract readonly type`
实现 `saveState` / `loadState`
- [x] 修改 `HeroAttribute<THero>`:实现 `iterateModifiers()`
### `@user/data-base/hero/state.ts`
- [x] 修改 `HeroState<THero>`:新增 `private readonly registry: Map<string, () => IHeroModifier>` 成员
- [x] 实现 `HeroState.registerModifier`:将工厂函数写入 `registry`
- [x] 实现 `HeroState.createModifier`:从 `registry` 取出工厂并调用,返回新实例;
`type` 未注册则抛出错误
- [x] 实现 `HeroState.createAndInsertModifier`:调用 `createModifier` 后,
再调用 `this.attribute.addModifier(name, modifier)`,返回同一实例
- [x] 修改 `HeroState.saveState`:遍历 `iterateModifiers()` 写入 `modifiers` 字段
- [x] 修改 `HeroState.loadState`:遍历 `state.modifiers` 重建修饰器并挂载

121
docs/dev/hero/hero-mover.md Normal file
View File

@ -0,0 +1,121 @@
# 需求综述
将旧样板中的 `HeroMover``packages-user/data-state/src/legacy/move.ts`)基于新的 `IObjectMover` / `ObjectMover` 基类进行彻底性重构,编写新的 `IHeroMover` 接口和 `HeroMover` 实现类。
新实现的核心变化:
- 基类从旧 `ObjectMoverBase`EventEmitter 模式)迁移至 `ObjectMover<IHeroLocation>`Hook 模式)。
- `inLockControl` 删除,重构后不再需要。
- 移动速度不再由 `HeroMover` 存储,速度变更通过 `ObjectMover.speed()` 方法体现,实际动画时长在 Hook 中自行处理。
- `noRoute`、`ignoreTerrain`、`autoSave` 不再作为公开属性暴露,改为通过 `config()` / `getConfig()` 方法管理,配置对象为 `IHeroMoverConfig`
- 摒弃所有 `core.xxx` 调用,这些功能将在后续通过新接口逐步补充。
- `HeroMover` 的实例化由 `IHeroLocation` 的实现类内部完成,构造时传入 `this` 作为 `tile`,解决循环依赖。
# 接口设计分析
`IHeroMover` 直接扩展 `IObjectMover<IHeroLocation>`,以下仅分析新增内容。
## IHeroMover
### 接口综述
`IHeroMover` 在通用对象移动器基础上,通过 `config()` 方法接收一个 `IHeroMoverConfig` 配置对象来控制本次移动的行为模式(是否忽略地形、是否记录路径、是否触发自动存档)。这种方法将三个相关标志聚合到统一的配置接口中,调用方便且后续扩展新配置项时无需新增独立属性。`getConfig()` 返回当前生效的只读配置,供内部逻辑和 Hook 查询移动上下文。
### 接口分析
- `IHeroMover.config(config)`:预期频率**中频**。在移动开始前统一配置本次移动的行为标志,一次性设置多个相关选项,比分别设置三个独立属性更加便利。典型使用场景:自动寻路系统在每次移动前调用 `mover.config({ noRoute: true }).forward(3).start()`,通过链式调用一气呵成。
- `IHeroMover.getConfig()`:预期频率**低频**。查询当前移动器的配置快照,主要用于 Hook 内部判断移动上下文或调试时检查配置状态。典型使用场景Hook 中的 `onStepStart` 获取 `mover.getConfig()` 以判断当前步是否需要路线记录。
### IHeroMoverConfig 成员分析
- `noRoute`:预期频率**中频**。控制本次移动是否记录进路线系统。路线回放、自动寻路等系统均需跳过路线记录,涉及面较广,故为中频。
- `ignoreTerrain`:预期频率**低频**。控制本次移动是否跳过地形碰撞检测,仅用于瞬移、强制移动等特殊场景。
- `autoSave`:预期频率**低频**。控制本次移动是否在特定时机触发自动存档,仅在地图伤害相关全局系统内部设置。
> 三个配置项在 `config()` 参数中均为可选,未显式设置的项保持当前值不变,`getConfig()` 返回的是已补全默认值的完整配置只读快照。
### 预期体量
接口定义部分(`IHeroMover`、`IHeroMoverConfig`)约 15-20 行。
`HeroMover` 类的预期代码体量为 200-250 行。分析如下:
- 类定义与私有属性(`noRoute`、`ignoreTerrain`、`autoSave` 各自独立声明),以及 `config` / `getConfig` 实现,约 30 行。
- `onMoveStart` 实现(同步渲染端状态、预检查首步地形等),约 30 行。
- `onMoveEnd` 实现(恢复状态、通知渲染端结束、清除自动寻路状态),约 20 行。
- `onStepStart` 实现(地形检测、自动存档判断、渲染端动画调用等),约 55 行。
- `onStepEnd` 实现(根据移动代码分发处理、返回新坐标、路线记录、触发器等),约 65 行。
- 辅助方法(坐标计算、地形检测等),约 30 行。
- `HeroMoveCode` 枚举定义,约 10 行。
### 可能风险
- `IHeroLocation` 接口已定义 `readonly mover: IObjectMover<this>`。由于 `IHeroMover extends IObjectMover<IHeroLocation>`,若 `HeroMover` 的构造在 `IHeroLocation` 内部完成(传入 `this`),类型可自然满足且无循环依赖问题。但需确保 `IHeroLocation` 的实现类确实在其构造过程中创建并持有 `HeroMover` 实例。
# 实现思路
## 1. 在 `types.ts` 中新增相关接口与枚举
新增 `IHeroMoverConfig` 接口,包含 `noRoute`、`ignoreTerrain`、`autoSave` 三个可选布尔成员。
新增 `IHeroMover` 接口,继承 `IObjectMover<IHeroLocation>`,新增 `config(config)``getConfig()` 两个方法。
新增 `HeroMoveCode` 枚举,公开导出,包含正常移动、停止、撞击、不可移动等代码,供 Hook 用户判断每步移动结果。
## 2. 完成 `HeroMover`
`packages-user/data-state/src/hero/mover.ts` 中实现 `HeroMover extends ObjectMover<IHeroLocation>`
构造函数接收两个参数:
- `tile: IHeroLocation` — 勇士位置对象,同时满足 `IObjectMovable`,作为基类移动操作的绑定目标。
- `faceHandler: IFaceHandler<FaceDirection>` — 朝向处理器,由 `IHeroLocation` 实现类在创建 `HeroMover` 时传入(本样式中为 `Dir4FaceHandler`)。
`config(config)`:将传入配置中已显式提供的字段分别赋值到对应的私有属性 `noRoute`、`ignoreTerrain`、`autoSave`,未传入的字段保持原值,返回 `this` 以支持链式调用。
`getConfig()`:从三个私有属性分别读取当前值,组装为 `Readonly<IHeroMoverConfig>` 返回,默认值均为 `false`
`onMoveStart`:移动开始时读取当前配置,通过 `IHeroLocation.mover`(渲染端 `HeroMoveController` 实例)启动渲染端移动动画。若 `ignoreTerrain``false`,预检查第一步是否能通过。
`onMoveEnd`:移动结束时通知渲染端移动结束,清除自动寻路相关状态。
`onStepStart`:根据 `ignoreTerrain` 判断是否进行地形检测,若不可通过则返回对应的 `HeroMoveCode`。处理 `autoSave` 的存档判断逻辑。启动渲染端单步移动动画。根据 `noRoute` 判断是否记录路线。
> **注意**:地形检测、路线记录、触发器、中毒伤害等具体逻辑依赖的新接口尚不存在,`core.xxx` 调用在此重构中摒弃。当前在这些位置以 `// TODO:` 标记占位,等待后续接口补充。
`onStepEnd`:根据 `onStepStart` 返回的 `HeroMoveCode` 执行对应逻辑:
- `Step`:返回目标坐标 `ITileLocator` 让基类更新位置,执行路线记录与后置逻辑。
- `Hit`:停止移动,触发目标格触发器。
- `CannotMove`:停止移动,不触发触发器。
- `Stop`:停止移动,清除自动寻路。
## 3. 在 `hero/index.ts` 中导出
`packages-user/data-state/src/hero/index.ts` 中新增 `export * from './mover'`
# 涉及文件
## 需要引用的文件
- `@motajs/common`:引用 `ITileLocator` 等基础接口。
- `@user/data-common`:引用 `IObjectMover`、`IObjectMovable`、`ObjectMover`、`ObjectMoveStep`、`ObjectMoveStepType`、`FaceDirection`、`IFaceHandler`、`IFaceDescriptor` 等类型。
- `@user/data-base`:引用 `IHeroLocation`、`IHeroMoveController` 等勇士相关接口。
- `types.ts`(同包顶层):引用 `IHeroMover`、`IHeroMoverConfig`、`HeroMoveCode`(类实现接口时自引用)。
## 需要修改的文件
### `packages-user/data-state/src/types.ts`
- [ ] 新增 `IHeroMoverConfig` 接口:定义移动配置对象的三个可选布尔成员 `noRoute`、`ignoreTerrain`、`autoSave`。
- [ ] 新增 `IHeroMover` 接口:继承 `IObjectMover<IHeroLocation>`,新增 `config()``getConfig()` 方法。
- [ ] 新增 `HeroMoveCode` 枚举:定义勇士移动步骤返回码,公开导出供 Hook 使用。
### `packages-user/data-state/src/hero/mover.ts`(新建)
- [ ] 实现 `HeroMover` 类:继承 `ObjectMover<IHeroLocation>`,实现 `config`、`getConfig` 及基类四个抽象方法。
### `packages-user/data-state/src/hero/index.ts`
- [ ] 新增 `export * from './mover'`:导出新模块。

View File

@ -0,0 +1,120 @@
# 需求综述
`HeroLocation` 当前仅有坐标信息(`x`/`y`),无法确定勇士当前所处的楼层,导致 `HeroMover.checkTerrain` 无法定位到正确的楼层进行地形判定。同时 `checkTerrain` 仍为 TODO 存根,直接返回 `HeroMoveCode.Step`,无法完成通行判定。
本次需要完成以下内容:
1. 为 `HeroLocation` 新增楼层标识信息,保存楼层 id`string | undefined`)而非 `ILayerState` 对象。勇士允许不处在任何楼层,此时 `floorId``undefined`
2. 完成 `HeroMover` 的地形通行判定逻辑,并引入可插拔的 `ITerrainPassChecker` 接口,允许用户注入自定义判定器。当未注入判定器时,地形判定直接返回不可通行。
# 接口设计分析
## ITerrainPassChecker
### 接口综述
`ITerrainPassChecker` 是地形通行判定的可插拔抽象仅含一个判定方法。HeroMover 每步移动前调用它来判定前方一格是否可通行(高频操作)。用户通过在 `HeroMover` 上调用 `useTerrainChecker` 注入判定器。若未注入(`null`),则 `checkTerrain` 直接返回 `HeroMoveCode.CannotMove`,即所有地形均不可通行。
该接口定义在 Layer 1`@user/data-base`)。由于 `IHeroLocation` / `IHeroMover` 同为 Layer 1 对象,无法持有 `IStateBase` 引用(`IStateBase` 是 Layer 1 的顶层接口,其中 `maps` 需要 `IStateBase` 才可访问),因此 `canPass` 仅接收 `floorId` 作为楼层标识,具体如何获取 `ILayerState` 由判定器实现方自行处理(判定器实现一般在 Layer 2可持有 `IStateBase` 引用)。
参考 `IDamageCalculator` 的设计模式:职责单一、实例注入、无外部依赖。
### 接口分析
- `ITerrainPassChecker.canPass(locator, direction, floorId)`:预期频率**高频**。每步移动都需要判定前方地形是否可通行,故为单单词命名。典型使用场景:`HeroMover.onStepStart` 中拿到当前步的 `moveDirection` 后调用 `canPass` 进行判定。`floorId` 可能为 `undefined`(勇士不处于任何楼层时),判定器需自行处理。
### 预期体量
接口定义约 6 行。HeroMover 中 `checkTerrain` 修改约 10 行,`useTerrainChecker` 约 5 行。
## IHeroLocation
### 接口变化
`IHeroLocation` 新增 `readonly floorId: string | undefined` 只读成员。这是楼层标识符,与 `IMapStore.getLayerState(id)``id` 参数一致。`undefined` 表示勇士尚未处于任何楼层。
## IHeroMover
### 接口变化
`IHeroMover` 新增 `useTerrainChecker(checker)` 方法,传入 `null` 移除判定器(默认状态为 `null`)。
预期频率:**低频**。一般仅在初始化时调用一次以注入自定义判定逻辑。
## 默认判定器
默认判定器(基于图块 `inPass`/`outPass` 的标准通行判定)作为 `ITerrainPassChecker` 的独立实现,放在 `@user/data-state/src/hero/`Layer 2。该实现持有 `IStateBase` 引用,可在 `canPass` 中通过 `floorId` 查找 `ILayerState`,进而获取事件层图块数据完成通行判定。
由于默认判定器不在本次需求的核心范围内,仅在设计层面预留位置,后续单独实现。
# 实现思路
## 1. 在 `types.ts` 中新增接口与类型
新增 `ITerrainPassChecker` 接口:
```ts
export interface ITerrainPassChecker {
canPass(
locator: ITileLocator,
direction: FaceDirection,
floorId: string | undefined
): boolean;
}
```
修改 `IHeroLocation`:新增 `readonly floorId: string | undefined`
修改 `IHeroLocationSave`:新增 `readonly floorId: string | undefined`。由于原定义为 `extends Readonly<IFacedTileLocator>`,新增 `floorId` 后需展开为显式成员声明,避免将 `floorId` 类型污染到 `IFacedTileLocator`
修改 `IHeroMover`:新增 `useTerrainChecker(checker: ITerrainPassChecker | null): void`
## 2. 修改 `HeroLocation`
- 新增 `floorId: string | undefined` 成员,初始值 `undefined`
- `saveState` 中将 `floorId` 写入 `IHeroLocationSave`
- `loadState` 中从 `IHeroLocationSave` 恢复 `floorId`
由于暂不提供 `setFloorId``floorId` 的赋值留待后续处理。
## 3. 修改 `HeroMover`
- 新增 `private terrainChecker: ITerrainPassChecker | null = null`
- 实现 `useTerrainChecker(checker)``this.terrainChecker = checker ?? null`。
- 修改 `checkTerrain` 方法:
1. 若 `terrainChecker``null`,直接返回 `HeroMoveCode.CannotMove`
2. 否则以当前坐标(`{ x: tile.x, y: tile.y }`)、`this.moveDirection`、`tile.floorId` 为参数调用 `terrainChecker.canPass`
3. 若 `canPass` 返回 `false`,返回 `HeroMoveCode.CannotMove`;返回 `true` 则返回 `HeroMoveCode.Step`
## 4. 导出
`ITerrainPassChecker` 已定义在 `hero/types.ts`,通过 `hero/index.ts` 导出,无需额外操作。
# 涉及文件
## 需要引用的文件
- `@motajs/common`:引用 `ITileLocator`
- `@user/data-common`:引用 `FaceDirection`、`IObjectMover`、`IObjectMovable`、`IFaceHandler`、`IMoverController`、`ObjectMoveStep`、`ObjectMoveStepType`、`getFaceMovement`。
- `@user/data-base`:引用 `IHeroLocation`、`IHeroMover`、`IHeroMoverConfig`、`HeroMoveCode`。
## 需要修改的文件
### `packages-user/data-base/src/hero/types.ts`
- [ ] 新增 `ITerrainPassChecker` 接口:提供可插拔的地形通行判定能力。
- [ ] 修改 `IHeroLocation`:新增 `readonly floorId: string | undefined`
- [ ] 修改 `IHeroLocationSave`:新增 `readonly floorId: string | undefined`。将 `extends Readonly<IFacedTileLocator>` 展开为显式成员声明 `readonly x: number`、`readonly y: number`、`readonly direction: FaceDirection`、`readonly floorId: string | undefined`。
- [ ] 修改 `IHeroMover`:新增 `useTerrainChecker(checker: ITerrainPassChecker | null): void`
### `packages-user/data-base/src/hero/location.ts`
- [ ] 新增 `floorId: string | undefined` 成员,初始 `undefined`
- [ ] 修改 `saveState`,新增 `floorId` 字段。
- [ ] 修改 `loadState`,恢复 `floorId` 字段。
### `packages-user/data-base/src/hero/mover.ts`
- [ ] 新增 `private terrainChecker: ITerrainPassChecker | null = null`
- [ ] 实现 `useTerrainChecker` 方法。
- [ ] 修改 `checkTerrain` 方法:无判定器时返回 `CannotMove`,有判定器时委托调用 `canPass`

121
docs/dev/item/item-store.md Normal file
View File

@ -0,0 +1,121 @@
# 需求综述
参考 `ITileStore` 的设计,为道具系统定义 Layer 0 的原始数据存储层,包含 `IItemRawData`、`IItemLegacyConverter`、`IItemStore` 接口及 `ItemStore` 实现类,并挂载至 `IDataCommon`。道具本身是一类 tile 的具体属性,通过 tile 即可定位,不需要独立的 id↔num 双向索引。
# 接口设计分析
## ItemCategory 枚举
`store/types.ts` 中与 `TileType` 并列定义,用于标识道具分类:
- `Unknown`:未知或尚未归类的道具。
- `Constant`:永久道具,获得后常驻生效。
- `Consumable`:一次性道具,使用后消耗。
- `Pick`:即捡即用道具,捡到立刻生效。
- `Equipment`:装备类道具,可装备 / 卸下。
### 设计意图
放在 `store/types.ts` 而非 `data-common/src/types.ts`,以避免 `types.ts``store/types.ts` 之间的循环引用。`types.ts` 通过 `./store` 导入 `IItemStore` 挂载到 `IDataCommon`;若将 `ItemCategory` 放在 `types.ts``store/types.ts` 引用它时会产生循环。
## IItemRawData
### 设计意图
参考 `ITileRawData` 的设计,`IItemRawData` 是道具的原始数据定义,存储于 `IItemStore` 中。除基础数据字段外,通过 `readonly effect: IItemEffect` 成员承载道具效果。旧样板字符串效果由 `IItemLegacyConverter` 在转换时通过 `new Function` 编译为 `IItemEffect` 实例。
`IItemStore` 作为 Layer 0 的基础设施被 `IDataCommon` 持有,与 `ITileStore` 平级。后续 `IItemManager`Layer 1通过 `IDataCommonExtended.state.itemStore` 访问原始数据,据此创建 `IItem<TAttr>` 运行时实例。
### 接口分析
- `IItemRawData.num`:预期频率**高频**。道具在地图上的图块数字,是地图→道具的核心关联键。典型使用场景:事件触发器读取地图上某图块对应的道具信息。
- `IItemRawData.id`:预期频率**高频**。道具的字符串唯一标识符。典型使用场景:通过 id 跨地图引用道具。
- `IItemRawData.category`:预期频率**中频**。道具分类,决定使用时的基础行为分支。典型使用场景:拾取道具时判断是否为 `Pick` 类以立即触发效果。
- `IItemRawData.name`:预期频率**高频**。道具显示名称UI 展示的核心字段。
- `IItemRawData.text`:预期频率**低频**。道具描述文本,仅详情展示时出现。
- `IItemRawData.effect`:预期频率**中频**。道具效果对象,包含 `pickEffect`、`useEffect`、`canUse` 三个方法。典型使用场景:拾取道具时调用 `effect.pickEffect(state)` 触发即捡即用效果。
### 预期体量
`IItemRawData` 约 1520 行(含 `effect` 成员)。
## IItemEffect
### 设计意图
道具效果接口,与 `IItemRawData` 并列定义在 `store/types.ts` 中。包含 `pickEffect`、`useEffect`、`canUse` 三个方法,参数为当前道具的 `IItemRawData`。由 `IItemLegacyConverter` 在转换时通过 `new Function` 编译实例化,编译函数内部自行持有 `IStateSystem` 引用(由实现类构造函数传入),方法内据此调用编译函数。
### 接口分析
- `IItemEffect.pickEffect`:预期频率**低频**。拾取道具时调用(仅 `Pick` 类型),参数为当前道具数据。
- `IItemEffect.useEffect`:预期频率**中频**。使用道具时调用(`Constant` 和 `Consumable` 类型),参数为当前道具数据。典型使用场景:使用消耗道具触发属性变化。
- `IItemEffect.canUse`:预期频率**中频**。判定道具是否可用(`Constant` 和 `Consumable` 类型),参数为当前道具数据。典型使用场景:开门钥匙判断是否满足使用条件。
### 预期体量
`IItemEffect` 约 1215 行。
## IItemLegacyConverter
### 设计意图
参考 `ITileLegacyConverter`,定义从旧样板道具对象到 `IItemRawData` 的转换器接口。转换时需将旧样板中的字符串效果(`itemEffect`、`useItemEffect`、`canUseItemEffect`)通过 `new Function` 编译为 `IItemEffect` 实例,赋给 `IItemRawData.effect`。编编译函数签名为 `(state: IStateSystem) => void``canUse` 返回 `boolean`),实现类内部自行持有 `IStateSystem`,接口方法仅传递 `IItemRawData`
与后续 `IItemLegacyBridge` 不同——`IItemLegacyConverter` 位于 store 层负责原始数据的导入(含效果编译),`IItemLegacyBridge`Layer 1负责运行时属性的转换。二者分层独立分别处理静态数据与运行时对象。
### 接口分析
- `IItemLegacyConverter.fromLegacy`:预期频率**低频**。仅在样板加载阶段调用,执行旧格式→新格式的批量转换,含字符串效果的编译。
### 预期体量
一个方法,约 810 行。
## IItemStore
### 设计意图
参考 `ITileStore` 的接口模式,作为道具原始数据的集中存储。道具本身是一类 tile 的具体属性,通过 tile 即可查找到对应的道具信息,因此不需要 `ITileStore` 那样的双向 id↔num 索引。但数据内仍存储 `id``num`,方便在需要时进行查询。
### 接口分析
- `IItemStore.getData`:预期频率**高频**。按图块数字获取完整原始数据,是查询道具定义的最常用入口。典型使用场景:地图图层解析到某图块数字后读取道具定义。
- `IItemStore.getCategory`:预期频率**中频**。快速判断道具分类,避免完整获取数据。典型使用场景:拾取判定时只关心是否为 `Pick` 类型。
- `IItemStore.addItem`:预期频率**低频**。添加一个道具原始定义,仅在样板加载时调用。
- `IItemStore.attachLegacyConverter`:预期频率**低频**。挂载旧样板转换器,初始化阶段调用。
- `IItemStore.fromLegacy`:预期频率**低频**。使用当前转换器转换并写入一个旧样板道具定义。
### 预期体量
接口约 2025 行,实现类 `ItemStore` 约 5070 行(内部仅维护 `dataMap: Map<number, IItemRawData>` 一张表,与 `TileStore` 相比少了双向索引表)。
# 涉及文件
## 需要引用的文件
- `@user/data-common/src/store/types.ts`:参考 `ITileRawData`、`ITileLegacyConverter`、`ITileStore` 的接口模式,用于设计 `IItemStore`
- `@user/data-common/src/store/tileStore.ts`:参考 `TileStore` 的实现模式,用于实现 `ItemStore`
## 需要修改的文件
### `@user/data-common/src/store/types.ts`
- [x] 新增 `ItemCategory` 枚举:包含 `Unknown`、`Constant`、`Consumable`、`Pick`、`Equipment` 五个成员。放在 `store/types.ts` 以避免与 `types.ts` 的循环引用。
- [x] 补齐 `IItemRawData` 接口:新增 `readonly effect: IItemEffect` 成员。
- [x] 新增 `IItemEffect` 接口:包含 `pickEffect(item: IItemRawData): void`、`useEffect(item: IItemRawData): void`、`canUse(item: IItemRawData): boolean` 三个方法。
- [x] 新增 `IItemLegacyConverter<TLegacy>` 接口:定义旧样板道具→`IItemRawData` 的转换方法,负责将旧样板字符串效果编译为 `IItemEffect` 实例。
- [x] 新增 `IItemStore<TLegacy>` 接口:提供 `getData`、`getCategory`、`addItem`、`attachLegacyConverter`、`fromLegacy` 方法,不需要 id↔num 双向索引。
### `@user/data-common/src/store/itemStore.ts`(新建)
- [x] 新增 `ItemStore<TLegacy>` 类:实现 `IItemStore<TLegacy>` 接口,参考 `TileStore` 的实现模式。
- [x] 内部维护 `dataMap: Map<number, IItemRawData>` 一张表,无需 id num 双向映射。
- [x] 实现 `fromLegacy`:无 converter 时抛出错误。
### `@user/data-common/src/store/index.ts`
- [x] 新增 `export * from './itemStore'` 导出。
### `@user/data-common/src/types.ts`
- [x] 修改 `IDataCommon` 接口:新增 `readonly itemStore: IItemStore` 成员。

View File

@ -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<void>;
/**
* 向当前队列末尾追加步骤(仅在移动进行中有效,完成后追加无效)
*/
push(...steps: DynamicMoveStep[]): void;
/**
* 停止移动,等待当前步骤完成后停止,返回的 Promise 在停止后兑现
*/
stop(): Promise<void>;
}
```
## 5. IDynamicMover 接口
每个 `IDynamicTile` 持有一个**绑定**的 `IDynamicMover` 实例(`tile.mover`
无需单独创建或管理生命周期。
### 5.1 状态读取
```ts
// 以下状态属性来自 IObjectMover<IDynamicTile>IDynamicMover 全部继承
interface IObjectMover<T extends IObjectMovable> {
/** 是否正在移动 */
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<number>`
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<void>`
执行器始终通过 `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<IObjectMoverHooks<IDynamicTile>>`(见接口定义汇总)。
---
# 涉及文件
## 需要修改的文件
### `@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<T extends IObjectMovable>` 接口(含 `onMoveStart`/`onMoveEnd`/`onStepStart`/`onStepEnd`
`onStepStart`/`onStepEnd` 参数顺序为 `(code, step, tile, mover)`,按使用频率排列)
- [ ] `IObjectMover<T extends IObjectMovable>` 接口(含 `faceDirection`/`moveDirection` 只读属性及全部计划构建方法;`step`/`stepFace` 含 `count?` 参数)
- [ ] `IDynamicMover extends IObjectMover<IDynamicTile>`(无额外成员,继承基类全部能力;`IDynamicMoverHooks` 已移除,直接使用 `IObjectMoverHooks<IDynamicTile>`
#### `@user/data-base/src/map/mover.ts`
- [ ] `abstract class ObjectMover<T extends IObjectMovable>` 抽象基类:
- 实现 `IObjectMover<T>`,在此提供全部**计划构建方法**`step`/`stepFace`/`forward`/
`backward`/`speed`/`face`/`animDir`/`clear`/`start`)及队列执行调度逻辑;
- 含四个**抽象方法**,子类必须实现,组成移动核心控制流:
- `abstract onMoveStart(tile, mover): Promise<void>`: 整次移动开始时调用
- `abstract onMoveEnd(tile, mover): Promise<void>`: 整次移动结束时调用
- `abstract onStepStart(step, tile, mover): Promise<number>`: 单步逻辑执行前调用,
子类计算并**返回** `code`;父类将 `code` 传给 `IObjectMoverHooks.onStepStart` 钩子后等待
- `abstract onStepEnd(code, step, tile, mover): Promise<void>`: 单步钩子等待完成后调用,子类依据 `code` 执行本步实际逻辑效果
- [ ] `class DynamicMover extends ObjectMover<IDynamicTile> 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<void>;
push(...steps: DynamicMoveStep[]): void;
stop(): Promise<void>;
}
interface IObjectMoverHooks<T extends IObjectMovable> extends IHookBase {
onMoveStart(mover: IObjectMover<T>, tile: T): Promise<void>;
onMoveEnd(mover: IObjectMover<T>, tile: T): Promise<void>;
/** 触发时 tile.x/y 为移动前坐标,适合渲染端在此播放动画 */
onStepStart?(
code: number,
step: DynamicMoveStep,
tile: T,
mover: IObjectMover<T>
): Promise<void>;
/** 触发时 tile.x/y 已更新为移动后坐标 */
onStepEnd?(
code: number,
step: DynamicMoveStep,
tile: T,
mover: IObjectMover<T>
): Promise<void>;
}
interface IObjectMover<T extends IObjectMovable> extends IHookable<
IObjectMoverHooks<T>
> {
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<number>`
// 具体来说,这四个方法是整个移动的核心方法而非钩子,它应当提供接口让子类实现,
// 子类应当真正执行移动效果,并进行一定的控制。显然子类是没办法知道 onStepStart 中的信息的,
// 所以才提供了一个 code用于子类向 ObjectMover 提供信息,然后父类再传递给子类,
// 从而子类了解到 onStepStart 中的信息,再进一步决定这一步真正应该如何执行。
// 而对于钩子,实际上是在子类的 onStepStart 执行完毕后进行的,所以是知道 code 的,所以才是参数。
// 你应该好好想一想这之间的关系。
// 以及子类是要求必须实现的,所以不应该会有返回 `void` 的场景,四个方法都应该返回 `Promise<void>`
// 总的来说,父类是一个“系统级”的流程控制器,它通过这四个接口来实现真正的移动控制,
// 就像 legacy ObjectMoverBase 中的 startMove 一样。
abstract class ObjectMover<
T extends IObjectMovable
> implements IObjectMover<T> {
abstract onMoveStart(tile: T, mover: IObjectMover<T>): Promise<void>;
abstract onMoveEnd(tile: T, mover: IObjectMover<T>): Promise<void>;
/** 子类计算并返回本步 code父类取得 code 后再触发 IObjectMoverHooks.onStepStart 钩子 */
abstract onStepStart(
step: DynamicMoveStep,
tile: T,
mover: IObjectMover<T>
): Promise<number>;
/** 子类依据 code 执行本步实际逻辑效果;父类在此之后触发 IObjectMoverHooks.onStepEnd 钩子 */
abstract onStepEnd(
code: number,
step: DynamicMoveStep,
tile: T,
mover: IObjectMover<T>
): Promise<void>;
// 计划构建方法step/stepFace/forward/backward/speed/face/animDir/clear/start在此实现
}
// faceDirection/moveDirection 已提升至 IObjectMoverIDynamicMover 无需额外声明
// IDynamicMoverHooks 已移除,直接使用 IObjectMoverHooks<IDynamicTile>
interface IDynamicMover extends IObjectMover<IDynamicTile> {
// 继承自 IObjectMovermoving, 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<T extends IObjectMovable>`(参考 legacy `ObjectMoverBase`,适配新 API
核心抽象类为 `abstract class ObjectMover<T>`,含四个抽象方法组成控制流:
- **计划构建方法**`step`/`stepFace`/`forward`/`backward`/
`speed`/`face`/`animDir`/`clear`/`start`)全部在 `ObjectMover` 基类实现,子类无需重复声明。
- **移动流程钩子**`IObjectMoverHooks<T>`,实现 `IHookable<IObjectMoverHooks<T>>`
- `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<number>`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<IDynamicTile>`**
无额外成员,直接继承基类全部能力(含 `faceDirection`/`moveDirection` 及 `IObjectMoverHooks`)。

View File

@ -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<T extends IObjectMovable>` 以此接口为泛型约束,
使图块和玩家 mover 共享核心执行逻辑;具体渲染效果通过 `IObjectMoverHooks``onStepStart`/`onStepEnd` 钩子实现。
`DynamicLayer` 内部维护两个结构:
- `tileSet: Set<IDynamicTile>` — 所有图块的集合,用于迭代与归属判断;
- `posMap: Map<number, Map<number, Set<IDynamicTile>>>` — 按坐标索引
(外层 key = y内层 key = xvalue = 该格点所有图块对象集合),
图块移动时同步更新,支持越界坐标(见第 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<IDynamicLayerHooks>` 以支持渲染端订阅变更事件。
`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> | 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)` 完成,
逻辑与上述步骤 15 相同,但不触发移动。
## 9. IDynamicLayerHooks 与 ILayerStateHooks
`IDynamicLayerHooks` 定义三个钩子:
- `onCreateTile(tile: IDynamicTile)`:图块被创建(含转换)时触发;
- `onDeleteTile(tile: IDynamicTile)`:图块被删除时触发(传入删除前的快照);
- `onMoveTile(tile: IDynamicTile, fromX: number, fromY: number): Promise<void> | void`
图块移动一步时触发,`tile` 为更新后的状态,`fromX`/`fromY` 为移动前坐标。
返回 `Promise<void>` 时,移动器将等待其兑现后再进行下一步(配合 `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<T extends IObjectMovable>` 泛型约束使用
- [ ] 新增 `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<IDynamicLayerHooks>` 接口:
- [ ] `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<IDynamicLayerHooks>` 类,
实现 `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<IDynamicTile>`:所有图块的集合,用于迭代与归属判断
- [ ] `private posMap: Map<number, Map<number, Set<IDynamicTile>>>`
按坐标索引(外层 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<IDynamicLayerHooks> {
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<IDynamicTile>;
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<number, Map<number, Set<IDynamicTile>>>` 嵌套结构(外层 key = y内层 key = x
彻底去掉字符串键,天然支持越界坐标,`resizeLayer` 无需通知 `DynamicLayer`
`getDynamicTilesAt` 接口保留,使用频率低,嵌套 Map 开销完全可接受。

View File

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

View File

@ -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<string, Map<number, Uint32Array>>): 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<string, LayerState>` 存储所有楼层。
`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<number, Uint32Array>;
}
/** 单个楼层的存档数据 */
interface ILayerStateSave {
readonly background: number;
/**
* key = zIndexvalue = 对应图层存档数据;
* 使用 Map 格式以支持图层的动态增删。
*/
readonly layers: ReadonlyMap<number, IMapLayerSave>;
}
/** 整个 MapStore 的存档数据 */
interface IMapStoreSave {
/**
* key = 楼层 id只包含 active 的楼层;
* inactive 的楼层不写入,读档时无需处理。
*/
readonly floors: ReadonlyMap<string, ILayerStateSave>;
}
```
### 各压缩等级存储策略
| 压缩级别 | 楼层粒度 | 行粒度 |
| ----------------- | --------------------------------------------------------------- | ------------------------ |
| `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<IMapStoreSave> {
/** 所有楼层的 id 集合 */
readonly maps: ReadonlySet<string>;
// --- 楼层访问 ---
/** 获取指定 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 = zIndexvalue = 图层完整图块数据
*/
compareWith(ref: Map<string, Map<number, Uint32Array>>): 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`
- [x] 新增 `IMapLayerSave` 接口:单个 MapLayer 存档数据格式
- [x] 新增 `ILayerStateSave` 接口:单个楼层存档数据格式
- [x] 新增 `IMapStoreSave` 接口MapStore 整体存档数据格式
- [x] 修改 `ILayerState`:新增 `readonly active: boolean`
`setActiveStatus(active: boolean): void`
- [x] 修改 `IMapLayer`:新增 `setMapRef(array: Uint32Array): void`
- [x] 新增 `IMapStore` 接口:继承 `ISaveableContent<IMapStoreSave>`
含全部接口(见第 7 节)
### `@user/data-base/src/map/mapLayer.ts`
### `@user/data-base/src/map/layerState.ts`
- [x] 新增 `active: boolean = false` 成员:楼层激活状态
- [x] 实现 `setActiveStatus(active: boolean): void`
- [x] 新增 `private dirty: boolean = false` 成员:楼层级脏标记
- [x] 修改 `StateMapLayerHook.onUpdateArea`、`onUpdateBlock`、`onResize`
在转发钩子的同时,将 `state.dirty``true`
- [x] 新增 `isDirty(): boolean` 方法:返回 `this.dirty`,供 `MapStore` 读取
- [x] 新增 `setDirty(dirty: boolean): void` 方法:
`MapStore.compareWith` 时根据实际比较结果设置
### `@user/data-base/src/map/mapLayer.ts`
- [x] 新增 `setMapRef(array: Uint32Array): void` 方法:
直接替换内部图块数组引用,跳过拷贝,供 `MapStore` 读档时使用。
需确保传入数组长度与 `width × height` 匹配,
并触发必要的钩子通知(不触发 `onResize`,应触发 `onUpdateArea` 通知全区域更新)。
在方法注释中明确标注:调用后不得再持有或修改传入的数组。
### `@user/data-base/src/map/mapStore.ts`(新文件)
- [x] 实现 `MapStore` 类,实现 `IMapStore`
- [x] `private mapData: Map<string, LayerState>`:楼层 id 到状态对象的映射
- [x] `readonly maps: ReadonlySet<string>`:所有楼层 id 的只读集合视图
- [x] `private refData: Map<string, Map<number, Uint32Array>> | null`:参考基准
- [x] 实现 `getLayerState`、`getActiveMap`、`createLayerState`
- [x] 实现 `isMapActive`、`setMapActiveStatus`、`iterateActiveMaps`、`iterateInactiveMaps`、`iterateAllMaps`
- [x] 实现 `compareWith`
- [x] 实现 `saveNoCompression`、`saveLowCompression`、`saveHighCompression`
- [x] 实现 `loadNoCompression`、`loadLowCompression`、`loadHighCompression`
- [x] 实现 `saveState(compression)``loadState(state, compression)` 分发
### `@user/data-base/src/map/index.ts`
- [x] 补充导出 `mapStore.ts`
### `@user/data-base/src/types.ts`
- [x] 将 `IStateBase.layer` 类型由 `ILayerState` 改为 `IMapStore`

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

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

View File

@ -0,0 +1,44 @@
# 需求综述
触发器系统主体已经完成,下一步开始逐步补充内置触发器类型。当前第一批只先设计两个最基础的内置触发器:战斗触发器与开门触发器。
本文只确认这两个类型的职责、使用频率预期与当前待补充的设计边界,不展开实现思路,也不列涉及文件。后续如果继续新增对话、商店、自定义事件等内置触发器,再在此文档上继续补充即可。
---
# 共通约束
这两个内置触发器都只是 `ITrigger` 的内置实现。当前阶段不建议先给它们增加额外公共配置成员,怪物信息、门信息原则上应跟随当前触发位置上的图块或运行时载体,而不是在触发器实例里重复保存一份。
- `type`:预期频率**低频**。由注册表和类型定义决定,通常只在注册、调试或排查问题时关注。
- `priority`:预期频率**低频**。大多数情况下使用固定默认值即可,只有极少数“同格混合触发”场景才需要显式调整。
- `trigger(handler)`:预期频率**中频**。平时更多通过 `ITriggerCollection.trigger(...)` 间接触发,直接持有某个内置触发器并手动调用的场景相对有限。
- `collection()`:预期频率**中频**。继承 `ITrigger` 的统一包装能力,使用方式与其他触发器一致,这里不做额外语义扩展。
---
# 类型设计与预期
## 战斗触发器(暂定 `IBattleTrigger`
- 核心职责:在当前触发位置执行一次与怪物的战斗。
- `IBattleTrigger.trigger(handler)`:预期频率**中频**。典型使用场景:玩家移动到怪物格后,移动系统收集到该触发器并交给集合统一触发;或脚本手动触发某个战斗事件。
- 校验要求:触发前必须确认当前指定图块或对应的运行时载体确实是怪物。若不是怪物,发出警告并终止本次触发,不进入战斗流程。
- 数据要求:需要能从当前触发位置解析出“这是怪物”这一事实,以及后续进入战斗流程所需的怪物信息。
- 职责边界:该触发器当前只先负责“确认目标是怪物并进入战斗分支”。战斗后怪物移除、地图更新、事件派发等后续职责,先不在本文中拍死。
## 开门触发器(暂定 `IOpenDoorTrigger`
- 核心职责:在当前触发位置执行一次开门行为。
- `IOpenDoorTrigger.trigger(handler)`:预期频率**中频**。典型使用场景:玩家撞到门格或主动交互门格后,系统收集到该触发器并尝试开门;或脚本在演出中手动触发某扇门的开启。
- 校验要求:触发前必须确认当前指定图块或对应的运行时载体包含门相关信息。若不包含,发出警告并终止本次触发,不执行开门行为。
- 数据要求:需要能从当前触发位置读取门相关信息,并定位到既有的 `openDoor` 流程。
- 职责边界:该触发器当前只先负责“确认目标是门并进入开门分支”。钥匙校验、动画等待、图块替换、开门后事件等具体流程,先依附既有开门系统,不在本文中提前拆开。
---
# 当前待补充设计
1. 运行时需要一条统一路径,让触发器能从 `handler` 提供的上下文中读取当前位置对应的怪物信息或门信息;这一层目前还没有最终定稿。
2. 这两个内置触发器原则上都不应在实例上重复保存业务对象;更合理的方向是由当前位置的图块或运行时载体提供数据。若后续发现这条路线不够用,再单独讨论是否为内置触发器补配置成员。
3. 这两个触发器当前都只先定义“校验 + 进入对应系统”的第一层职责。后续如果需要细分成更多内置类型,例如强制战斗、免钥匙开门、条件开门等,再在这个基础上继续扩展。

103
docs/dev/map/trigger.md Normal file
View File

@ -0,0 +1,103 @@
# 需求综述
当前地图系统已完成图层管理与动态图块的构建,但地图上每个格点可能存在的**事件逻辑**(如战斗、对话、商店等)尚无统一抽象。本需求旨在建立一套触发器系统,将地图事件的注册、收集与触发解耦为三个独立接口层,最终可通过统一的方式触发地图上某一点所有匹配的事件。
触发器本身不与地图绑定,可独立于地图存在。由于触发的执行依赖 Layer 2 的上下文(`IStateBase` 等),整个触发器系统均归属 Layer 2`@user/data-system`),地图在 Layer 1 中只存储可序列化的图块数据,不直接持有 `ITrigger`
---
# 接口设计与预期
## ITrigger
- `ITrigger.type`:预期频率**低频**,触发器的类型标识符,以数字表示,用于区分不同种类的触发器(如战斗、对话等);一般仅在实例化时通过构造参数传入一次,运行期基本不读写,故为低频。
- `ITrigger.priority`:预期频率**低频**,仅在创建触发器时设置一次,用于控制同格点多触发器的执行顺序;绝大多数情况下一个格点只会有一个触发器,多触发器仅为少量拓展场景预留,在 `collect` 场景下,同一次收集中的 `priority` 重复会被视为配置冲突,故为低频。
- `ITrigger.trigger(handler)`:预期频率**中频**,用户在脚本中有时会直接持有某个触发器引用并手动触发,但大多数事件触发都通过 `ITriggerCollection.trigger` 完成,直接调用此方法的场合相对有限,故为中频。接受 `ITriggerHandler` 上下文对象(必选 `state: IStateBase`,可选 `layer?: ILayerState`、`mapLayer?: IMapLayer`、`locator?: ITileLocator`),返回 `Promise<void>`。典型使用场景:脚本中已持有某个战斗触发器引用,希望直接触发而不经过格点收集流程。
- `ITrigger.collection()`:预期频率**中频**,当用户希望将单个触发器包装为集合并走统一触发流程时使用,场景较为固定但有一定出现概率,故为中频。典型使用场景:脚本中手动构造只含单个触发器的集合,再调用 `collection.trigger(...)` 统一触发。
## ITriggerRegistry
以触发器**类型**`type: number`)为核心提供注册与查询;`registerString` / `getString` 是触发器自身 id 的字符串别名,与地图图块绑定无关,不参与 `collect` 的自动收集流程。共四个方法,不拆分为子接口。
- `ITriggerRegistry.register(type, factory)`:预期频率**低频**,按触发器类型注册触发器工厂函数(`(type: number) => ITrigger`),由注册表在实例化时将当前 `type` 透传给工厂,使 `ITrigger.type` 跟随注册表,`collect` 时每次调用工厂创建独立实例,一种触发器类型只注册一次,故为低频。
- `ITriggerRegistry.get(type)`:预期频率**低频**,主要供 `ITriggerCollector` 内部调用,用户代码几乎不会直接使用,故为低频。
- `ITriggerRegistry.registerString(id, factory)`:预期频率**低频**,为触发器注册字符串别名(`() => ITrigger`),仅用于手动按字符串 id 查询,与地图图块收集无关,故为低频。
- `ITriggerRegistry.getString(id)`:预期频率**低频**,按字符串 id 查询触发器工厂,调用方手动获取后实例化,主要供内部调用,故为低频。
## ITriggerCollection
- `ITriggerCollection.count`:预期频率**低频**,在"当格点无任何触发器时跳过处理"的场景下可快速判断,避免额外迭代,故为低频。
- `ITriggerCollection.trigger(handler)`:预期频率**高频**,这是触发器系统对用户暴露的最主要入口,凡是需要触发某格事件的地方都会出现此调用,故为高频。接受 `ITriggerHandler` 上下文对象,顺序异步执行所有触发器,返回 `Promise<void>`。典型使用场景:玩家移动到某格后,移动系统调用 `collection.trigger(handler)` 依次执行该格所有已排序的触发器。
- `ITriggerCollection.triggerIter(handler)`:预期频率**低频**,返回 `AsyncGenerator<ITrigger, void, ITriggerHandler | null>`,允许调用方逐个手动推进触发器执行,每次 `next(handler)` 可传入新的上下文(传 `null` 则沿用初始 `handler`),故为低频。典型使用场景:需要在两个触发器之间插入额外效果(如战斗结束后立刻播放特效)的进阶场景。
- `ITriggerCollection.iterate()`:预期频率**低频**,仅在需要检查当前集合包含哪些触发器时使用(如 UI 显示交互提示),多数情况下直接触发而无需遍历,故为低频。
- `ITriggerCollection.push(trigger)`:预期频率**低频**,在脚本中偶尔需要向已有集合末尾追加一个触发器(如特殊演出追加额外效果),直接插入末尾不重新排序,故为低频。
- `ITriggerCollection.unshift(trigger)`:预期频率**低频**,与 `push` 对称,向集合头部强制插入触发器,直接插入头部不重新排序,场景更为罕见,故为低频。
- `ITriggerCollection.concat(...others)`:预期频率**低频**,将当前集合与一个或多个其他集合按自身在前、传入参数依序在后的顺序合并为新集合,不重新排序。此接口需求存疑,先行提供,故为低频。
## ITriggerCollector
`ITriggerCollector` 独立存在于 Layer 2`@user/data-system`),不附属于 `ILayerState`。调用方持有 collector 引用,调用 `collect(x, y, layer)` 时显式传入目标图层(`IMapLayer`)。收集时会依赖后续由 `IMapLayer.getTileInfo` 暴露的图块信息,其中包含触发器类型。引擎层会利用 `ILayerState.eventLayer` 自动调用 `collect` 实现默认收集行为,该行为不在本接口设计范围内;若需对多个图层收集,调用方自行多次调用并合并结果,合并顺序、跨层排序结果与跨层冲突处理均由调用方自行决定。
- `ITriggerCollector.collect(x, y, layer)`:预期频率**中频**,在移动系统或交互系统中需要确认某格事件时调用,使用场景固定且有一定频率,故为中频。`layer` 为必选,调用方显式指定要收集的目标图层(`IMapLayer`)。典型使用场景:玩家向某格移动时,移动系统持有 collector 引用并调用 `collector.collect(x, y, layer)` 获取目标格的 `ITriggerCollection`,再决定是否阻断移动或直接触发。
- `ITriggerCollector.attachRegistry(registry)`:预期频率**低频**,仅在初始化或切换注册表时调用,故为低频。
---
# 实现思路
## 1. 触发器对象 ITrigger
`ITrigger` 代表一类事件逻辑(如"战斗触发器"、"对话触发器"),是整个系统的原子单元。`type` 成员以数字标识触发器种类,在常规注册流程下由注册表在实例化时通过构造参数注入;`priority` 控制同格点的执行顺序,并在 `collect` 场景下充当唯一执行位。触发器本身是极其宽泛的东西,一般只有几种类型,比如战斗、触发系统事件、触发自定义事件等,不会每种战斗和每种对话都开一种触发器。触发时接受外部传入的 `ITriggerHandler` 上下文参数,执行对应逻辑。
`ITrigger` 提供 `collection()` 方法,将单个触发器包装为 `ITriggerCollection`,供不需要收集步骤的场景直接使用。
## 2. 触发器注册接口 ITriggerRegistry
触发器以类型为单位注册。为保证每个收集场景下的 `ITrigger` 实例相互独立(触发器可在内部保存状态而不互相影响),注册时传入工厂函数 `(type: number) => ITrigger`,由注册表在创建实例时将当前类型透传给工厂,使实例上的 `ITrigger.type` 跟随注册表;`collect` 时按需调用工厂创建新实例。字符串别名注册仍用于手动查询场景,不参与 `collect` 自动收集。`ITriggerRegistry` 直接包含以下四个方法:
- `register(type: number, factory: (type: number) => ITrigger)`:按触发器类型注册触发器工厂
- `get(type: number)`:按触发器类型查询触发器工厂
- `registerString(id: string, factory: () => ITrigger)`:为触发器注册字符串别名(仅用于手动查询,不参与 `collect` 自动收集)
- `getString(id: string)`:按字符串 id 查询触发器工厂
每个 key 只对应一个工厂。重复注册同一 key 时,发出警告并以新工厂覆盖旧的。
## 3. 触发收集 ITriggerCollector
`ITriggerCollector` 属于 Layer 2`@user/data-system`),独立于 `ILayerState` 存在,不挂载到任何 Layer 1 接口上。地图在 Layer 1 中只存储可序列化的图块数据(如图块编号),不持有 `ITrigger` 实例。收集时,调用方显式传入目标图层 `layer``IMapLayer``ITriggerCollector` 读取该图层中该格点的图块数据——包括静态图块(通过 `IMapLayer.getBlock`)以及动态图块(通过 `IMapLayer.dynamicLayer`)——并结合后续由 `IMapLayer.getTileInfo` 暴露的图块信息读取其中记录的触发器类型,再通过 `ITriggerRegistry` 获取工厂并构造对应的 `ITrigger`,收集后排序,返回 `ITriggerCollection`。注册表通过 `attachRegistry(registry)` 设置。
排序规则:按触发器自身 `priority` 降序排列。这里的 `priority` 不仅表示执行顺序,也表示单次收集内的唯一执行位;正常情况下同一坐标只会有一个触发器,多触发器支持仅用于极少数拓展场景。收集阶段若发现两个或以上触发器的 `priority` 相同,则视为配置冲突,发出警告,并将本次结果中所有该优先级的触发器全部剔除,不进入 `ITriggerCollection`
## 4. 触发器集合 ITriggerCollection
`ITriggerCollection` 是一组已排序的 `ITrigger` 的载体,提供统一的触发入口 `trigger(...)` 与迭代方法 `iterate()`、数量属性 `count`。`push` / `unshift` / `concat` 均不对集合重新排序,语义为"强制指定执行位置",与 `priority` 排序体系完全分离(`ITriggerCollection` 本身与地图无关,不应自行排序)。可由:
- `ITriggerCollector.collect` 返回;
- `ITrigger.collection()` 方法直接构造(单触发器集合)。
触发执行策略:`trigger(handler)` 顺序异步执行(返回 `Promise<void>`),触发器之间串行等待;`triggerIter(handler)` 返回异步生成器,调用方可逐个推进执行并为每一步单独传入上下文,适用于需要在触发器间插入额外逻辑的进阶场景。
---
# 涉及文件
## 需要引用的文件
- `@user/data-base`(跨包引用):`@user/data-system/trigger/types.ts` 中的 `ITriggerHandler` 需要引用 `IStateBase`(全局状态)、`ILayerState`(楼层状态)、`IMapLayer`(图层)与 `ITileLocator``ITriggerCollector.collect` 同样需要引用 `IMapLayer`,并依赖其后续提供的 `getTileInfo` 读取图块信息中的触发器类型
## 需要修改的文件
### `@user/data-base` 中的 `ILayerState`
- [x] 新增 `readonly eventLayer: IMapLayer | null` 属性:表示该楼层的默认事件图层,供引擎默认收集行为使用(引擎默认收集逻辑不在本设计范围内)
- [x] 新增 `setEventLayer(layer: IMapLayer | null): void` 方法:设置默认事件图层
## 需要新建的文件
### `@user/data-system/trigger/types.ts`
- [ ] 新增 `ITriggerHandler` 接口:触发时传入的上下文对象,包含必选的 `state: IStateBase` 以及可选的 `layer?: ILayerState`、`mapLayer?: IMapLayer`、`locator?: ITileLocator`
- [ ] 新增 `ITrigger` 接口:触发器原子对象,代表一类事件逻辑;包含 `type: number` 类型标识、`priority` 优先级成员、`trigger(handler: ITriggerHandler): Promise<void>` 触发方法与 `collection()` 方法以便包装为集合
- [ ] 新增 `ITriggerRegistry` 接口:包含 `register(type, factory: (type: number) => ITrigger)` `get(type)` `registerString(id, factory: () => ITrigger)` `getString(id)` 四个方法;以触发器类型 `type` 为核心管理注册与查询,字符串 id 为触发器自身别名,不参与 `collect` 自动收集;数字注册路径下由注册表透传 `type` 给工厂,保证实例的 `ITrigger.type` 与注册项一致
- [ ] 新增 `ITriggerCollection` 接口:包含已排序的触发器集合,提供 `trigger(handler)` 顺序异步触发入口(`Promise<void>`)与 `triggerIter(handler)` 异步迭代触发入口(`AsyncGenerator<ITrigger, void, ITriggerHandler | null>`)、`iterate()` 迭代方法、`count` 数量属性、`push` / `unshift` 向集合末尾或头部插入(不重排序)的方法,以及 `concat(...others)` 按自身在前的顺序合并多个集合(不重排序)的方法
- [ ] 新增 `ITriggerCollector` 接口:包含 `collect(x: number, y: number, layer: IMapLayer)` 收集方法与 `attachRegistry(registry: ITriggerRegistry | null)` 注册表设置方法

View File

@ -0,0 +1,151 @@
# 需求综述
录像由数万条录像步组成,每条录像步包含可变数量的参数,参数类型涵盖数字、字符串、布尔值和 bigint。直接操作 `ArrayBuffer` 的字节布局会导致系统耦合存储格式细节、修改录像困难。需要一个独立工具封装底层存储的读写,并提供插入、删除、修改等编辑能力。
# 需求理解
## 明确需求
1. 变长指令编码。指令码使用 `uint8``uint16`,宽度取决于已注册命令的最大 ID。
2. 自描述参数格式。参数使用类型标识 + 变长内容的编码,每条录像步的参数数量可变。类型标识同时承担短字符串的长度信息,节省额外字节。
3. 录像可编辑。支持在任意有效位置插入、删除、修改录像步——不是只追加的日志。
4. 顺序流式读取。播放是按顺序逐帧进行的,需要流式读取器避免每次随机访问的 O(n) 偏移扫描。
## 隐含需求
1. 容量管理。`ArrayBuffer` 不可动态扩容,写入前需检查容量,不足时分配新缓冲并复制。
2. 偏移累积。指令数组中的每条录像步长度不固定,随机访问某一条时需从头扫描累积偏移。流式读取器可以利用顺序访问特性只扫描一次。
# 设计前提
1. 双缓冲存储。指令数组和参数数组各自使用独立的 `ArrayBuffer`,通过 `DataView` 读写。
2. 不可变元数据。`IReplayMetadata` 中的格式参数在初始化时确定,读取时不修改。
3. 边解码边播放。不一次性解码全部数据——沙箱通过流式读取器按需解码。
# 核心概念定义
## 指令数组
录像中所有指令码与参数数量的序列化存储。每条录像步在指令数组中占用 1+W 字节W 为指令码宽度):第一个 `uint8` 为参数数量,后续 `uint8``uint16` 为指令码。
## 参数数组
录像中所有参数内容的序列化存储。每个参数由一个 `uint8` 类型标识开头后接对应类型的二进制内容。类型标识同时编码短字符串的长度8-256 → 长度 n-7
## 流式读取器
持有一个内部偏移指针,每次调用 `read` 解码并返回当前指针指向的录像步,然后推进指针。由于指令数组中每条录像步的总字节数不固定,指针以步索引而非字节偏移为单位——读取器内部维护已扫描过的累积字节偏移,每次 `read` 仅解码一步。
## 指令宽度
指令码的存储字节数。初始为 `uint8`1 字节)。命令 ID 上限在 `registerCommand` 注册时即可获知——若注册了 > 255 的 ID直接初始化为 `uint16`,不涉及运行时重编码。若需要变更已存在录像的指令宽度(例如切换为更大的指令码空间以兼容新增命令,或压缩为更小的空间以节省体积),则通过 `setCommandWidth` 方法将全部指令条目按新宽度重编码。
# 接口设计分析
## `IReplayArray`
### 设计思路
由于录像需要支持任意有效位置的插入和删除——比如玩家在已录制的操作序列中间补充了额外操作,或者修复了某段错误的录像——只提供追加写入是不够的。因此需要 `insert``delete` 方法,以及在原位置修改的 `set` 方法。追加仍然是最频繁的操作——因此需要 `add` 方法。
由于沙箱播放是顺序推进的,每次调用 `get(index)` 都需要从头扫描积累偏移——因此设计 `createReadStream` 创建流式读取器。流式读取器内部维护步索引指针和已累积字节偏移,每次 `read` 仅解码一步,将偏移计算从 O(n²) 降为 O(n)。同时保留 `get` 用于跳转播放等非顺序场景。
由于命令 ID 上限在注册阶段已知,指令宽度在 `IReplayArray` 构造时即确定——若最大 ID > 255 则初始化为 `uint16`。但已存在的录像可能需要变更宽度——例如加载旧 `uint8` 录像后注册了 > 255 的新命令需要升级,或者想将 `uint16` 录像压缩回 `uint8` 以节省存储。因此需要 `setCommandWidth` 方法将全部指令条目按新宽度重编码。
### 接口分析
- 成员 `length: number`:预期频率**中频**。安全系统检测步数变化、沙箱判断播放是否结束。
- 成员 `metadata: IReplayMetadata`:预期频率**低频**。存档时读取以持久化格式参数。
- 方法 `add(id: number, params: ReplayParamValue[])`:预期频率**高频**。每次 `record` 调用一次。
- 方法 `insert(index: number, id: number, params: ReplayParamValue[])`:预期频率**低频**。`index` 等于 `length` 时等同于 `add``index` 超出 `length` 时触发警告并忽略。
- 方法 `delete(index: number)`:预期频率**低频**。
- 方法 `set(index: number, id: number, params: ReplayParamValue[])`:预期频率**低频**。
- 方法 `get(index: number): IReplayStepHandler`:预期频率**低频**。跳转播放时使用。
- 方法 `createReadStream(startIndex?: number): IReplayReadStream`:预期频率**中频**。沙箱播放时调用。
- 方法 `setCommandWidth(width: ReplayCommandWidth)`:预期频率**低频**。变更已存在录像的指令码宽度,将全部指令重编码。
### 预期体量
预期代码体量 300-360 行。
- 参数编码预期 100 行。七种参数类型各自需要独立的写入逻辑:布尔值为类型 0 接 1 字节int8/int16/int32/int64/float64 各为一个单字节类型标识1-5接对应宽度的定长写入bigint 需类型 6 接 `uint16` 长度再接 n 个 `int32`短字符串将长度编码进类型字节8-256长度 = n-7`TextEncoder` 编码后直接写入内容;长字符串用类型 7 接 `uint16` 长度再写内容。每种类型还需容量检查与扩容,七种类型的分支代码量相当可观。
- 参数解码预期 80 行。与编码对称的七种读取分支根据类型字节分发。数值类型按宽度读bigint 读长度后拼接 int32短字符串从类型字节反推长度后 `TextDecoder` 解码,长字符串先读长度。每条分支均含边界检查。
- 指令读写预期 60 行。写入时根据当前 `commandWidth` 决定写入 1 还是 2 字节,读取时同理。每条录像步的指令部分 = `uint8` 参数数量 + W 字节指令码。`get` 和 `createReadStream` 均需维护字节偏移——每次读取或跳过一条录像步时,根据上一条的参数数量和参数总字节长度计算下一条的起始偏移。
- 插入与删除预期 60 行。`insert` 需将指定位置及之后的所有指令和参数数据向后移动以腾出空间,再在空位写入新步。`delete` 则反向——将之后的数据向前移动覆盖被删除步。参数数组的移动量取决于被操作步的参数总字节数,需先解码获取。`set` 本质是先 `delete``insert`
- 容量管理与复制预期 30 行。两个 `ArrayBuffer` 各自独立管理容量,写入前检查,不足时分配更大新缓冲(如 2 倍增长),逐字节复制旧数据后再继续写入。
- 流式读取器预期 25 行。内部维护步索引、指令数组字节偏移、参数数组字节偏移。`read` 解码当前步并推进三个变量,返回 `IReplayStepHandler`,超出 `length` 时返回 null。构造时可选 `startIndex`,需扫描至指定位置。
- 指令宽度变更预期 25 行。`setCommandWidth` 遍历全部已写指令条目——解析每条录像步的参数数量和原宽度的指令码——按新宽度重新写入指令数组。`commandWidth` 可能变大也可能变小,编码前需根据新宽度判断每条指令码是否在范围内(例如 `uint8` 模式下指令 ID > 255 应报错)。
## `IReplayReadStream`
### 设计思路
由于沙箱播放是顺序推进的,流式读取器持有当前步索引指针及已累积的字节偏移,每次调用 `read` 仅解码一步且指针自动推进——避免了 `get(n)` 每次 O(n) 扫描的累积开销。读取完毕后返回 null调用方可判断播放结束。
### 接口分析
- 成员 `index: number`:预期频率**低频**。显示当前播放进度。
- 成员 `length: number`:预期频率**低频**。用于判断是否已读取完毕。
- 方法 `read(): IReplayStepHandler | null`:预期频率**高频**。沙箱每步调用一次。
### 预期体量
预期代码体量 25 行。
## `IReplayMetadata`
### 设计思路
指令码宽度在读取时必须知晓才能正确解析每条录像步。`uint8` 和 `uint16` 是不同的编码模式,不是一个可变参数——因此用枚举 `ReplayCommandWidth` 标记,存档时持久化此枚举值。
### 接口分析
- 成员 `commandWidth: ReplayCommandWidth`:预期频率**低频**。初始化时根据最大命令 ID 设定,指令读写时使用。
### 预期体量
预期代码体量 5 行(含枚举定义)。
---
# 实现思路
## 参数编码格式
| 类型标识 | 参数类型 | 内容编码 |
| -------- | --------------------- | ------------------------------------------------- |
| 0 | 布尔值 | 1 字节0 或 1 |
| 1 | int8 | 1 字节 |
| 2 | int16 | 2 字节 |
| 3 | int32 | 4 字节 |
| 4 | int64 | 8 字节 |
| 5 | float64 | 8 字节 |
| 6 | bigint | `uint16` 长度 N后接 N 个 `int32` |
| 7 | 长字符串≥250 字节) | `uint16` 长度 N后接 `TextEncoder` 编码的 N 字节 |
| 8-256 | 短字符串n-7 字节) | `TextEncoder` 编码的 n-7 字节,无额外长度字段 |
短字符串方案类型标识本身编码了长度8 表示 1 字节256 表示 249 字节),`TextEncoder` 编码后直接写内容。大多数录像参数(道具 ID 名、操作描述等)落在此范围内,基本无需额外长度字段。
## 指令宽度确定
1. `IReplayArray` 构造时传入最大命令 ID`IReplaySystem.registerCommand` 的累积结果提供)。若 maxId > 255`commandWidth` 初始化为 `Uint16`;否则为 `Uint8`
2. 注册阶段即确定宽度,后续 `add`/`insert` 不涉及宽度切换。
3. `setCommandWidth` 用于变更已存在录像的指令宽度:遍历全部指令条目,将原宽度的指令码按新宽度重写,更新 `metadata`。若缩小宽度时存在超过新宽度表示范围的指令 ID应报错。
## 流式读取器内部状态
`read()` 每步仅解码当前指针指向的录像步:
1. 从指令数组当前字节偏移读 `uint8` 参数数量,再按 `commandWidth` 读指令码。
2. 根据参数数量,从参数数组当前字节偏移逐参数解码。
3. 将指令数组字节偏移增加 `1 + commandWidth`,参数数组字节偏移增加已解码参数的总字节数。
4. 返回 `IReplayStepHandler``index` 为当前步索引。指针推进到下一步。
# 涉及文件
## `@user/data-common/src/replay/array.ts`
- [ ] 新增 `ReplayCommandWidth` 枚举
- [ ] 编写 `IReplayArray`、`IReplayReadStream`、`IReplayMetadata` 接口
- [ ] 编写 `ReplayArray` 类:实现 `IReplayArray`,封装双 `ArrayBuffer` 操作、指令宽度管理与变更、CRUD 编辑、流式读取
- [ ] 编写 `ReplayReadStream` 类:实现 `IReplayReadStream`

View File

@ -0,0 +1,80 @@
# 需求综述
录像系统依赖作者在各逻辑系统中手动调用 `record`,这一依赖关系使得录像极易因疏忽而失效。需要一种机制来检测这类失效。
# 需求理解
## 明确需求
1. 录像存在失效风险。录像能否正确还原游戏流程完全取决于作者是否在每个状态修改处都调用了 `record`,遗漏是不可避免的风险。
## 隐含需求
1. 检测不能误报。系统内部的状态修改(如战斗结算中的属性计算)不应被检测为遗漏——只有从玩家操作入口派生的修改才需要检测。
2. 播放时同样需检测。沙箱播放期间原始录像系统重新录制用于交叉验证——如果播放逻辑存在遗漏,同样应被检测到。
# 设计前提
1. 已知方案。`@shouldReplay` 标记影响状态的方法,`@ignoreReplay` 标记特定的延迟或被动代码路径,`begin`/`end` 界定检测区间。以下设计基于此方案推导。
# 核心概念定义
## 检测区间
由一对 `begin`/`end` 调用界定的代码执行范围。区间内收集标记,结束时比较区间前后的录像步数变化,结合标记信息决定是否告警。
## 状态影响标记
数据端方法上的 `@shouldReplay` 修饰器。表示此方法的调用应伴随录像步的写入——它是系统判断"此处有状态修改"的唯一依据。
## 检测抑制标记
特定方法上的 `@ignoreReplay` 修饰器。某些场景中,`@shouldReplay` 方法确实被调用,但录像步的写入发生在此区间之外(如异步回调中),不加抑制会误报。它不应用于所有渲染端交互函数——那样系统永不会告警。
# 接口设计分析
## 检测体系
### 设计思路
由于录像失效来源于作者忘记 `record`,而系统无法自动区分"状态修改来自玩家操作"还是"系统内部结算"——计算机不可能判断一次属性修改是玩家打怪触发的还是战斗计算过程——因此必须由作者显式标注哪些方法会影响状态。由此设计 `@shouldReplay` 修饰器。
标注了哪些方法会影响状态之后,还需要界定"在什么范围内检测"。一次玩家操作可能触发若干 `@shouldReplay` 方法——它们应当在同一区间内被收容,区间结束时统一评判。因此需要 `beginReplaySafetyCollection``endReplaySafetyCollection`。区间需要知道录像是否增长——`begin` 接收 `IReplaySystem` 参数以记录初始步数。
仅有标注和区间还不够——有些代码路径中 `@shouldReplay` 方法确实会被调用,但录像步的写入并不在当前区间内完成。例如玩家点击购买道具,购买逻辑触发属性修改(`@shouldReplay`),但对应的 `record` 在异步事件处理中延迟执行。若不加抑制,这些场景都会误报。因此需要 `@ignoreReplay` 修饰器,标记此类延迟或被动触发路径。
### 接口分析
- 修饰器 `@shouldReplay(description: string)`:预期频率**中频**。每个影响状态的方法标注一次。
- 修饰器 `@ignoreReplay(description: string)`:预期频率**中频**。每个需抑制误报的方法标注一次。
- 函数 `beginReplaySafetyCollection(replay: IReplaySystem): void`:预期频率**低频**。仅出现在渲染端交互入口和沙箱播放循环两处。
- 函数 `endReplaySafetyCollection(): void`:预期频率**低频**。与 `begin` 配对。
### 预期体量
预期代码体量 50 行。
- 修饰器工厂各 10 行。
- `begin`/`end` 及区间管理预期 30 行。
---
# 实现思路
## 检测流程
1. 渲染端交互入口调用 `beginReplaySafetyCollection(replay)`
2. 代码执行——`@shouldReplay` 和 `@ignoreReplay` 修饰的方法被调用时向区间注册标记。
3. 调用 `endReplaySafetyCollection()`,检查:有 `@shouldReplay` 标记、无 `@ignoreReplay` 标记、步数未增 → 警告。
4. 沙箱每步包裹 `begin`/`end`,用于交叉验证。
# 涉及文件
## `@user/data-common/src/replay/safety.ts`
- [ ] 编写 `shouldReplay``ignoreReplay` 修饰器工厂
- [ ] 编写 `beginReplaySafetyCollection``endReplaySafetyCollection` 函数
# 待确认问题
无。

View File

@ -0,0 +1,210 @@
# 需求综述
需要一个能录制并回放游戏过程的系统,通过录像唯一还原一段游戏流程。
# 需求理解
## 明确需求
1. 游戏过程可回放。能通过录像唯一地还原出游戏从起点到终点的一段完整流程。
2. 录像步可自定义。录像步的类型不限于系统内置——移动、使用道具等——也允许作者自定义。
## 隐含需求
1. 异步播放。由于播放每个录像步都伴随渲染动画,动画未结束前不能执行下一步——播放必须是异步的。
2. 渲染端被动通知。架构约束下渲染端不能主动拉取数据——需要钩子将每步信息推送给渲染端。
3. 录像需跨会话持久化。录像可能录制于这次游戏,在下次游戏中被播放——需要存档。
## 未确定需求
无。
# 设计前提
1. 确定性执行。计入录像的操作在相同输入下产生相同输出。
2. 单向依赖。录像系统位于 `@user/data-common`,不依赖 Layer 1/2。渲染端被动接收信息。
3. 外部主动录制。系统不自动录制——作者在逻辑系统中手动调用录像步写入接口。
4. 底层存储独立。录像的二进制存储由 `IReplayArray` 统一管理(详见 `replay-array.md`),本系统不直接操作 `ArrayBuffer`
# 核心概念定义
## 录像步
一段游戏流程中不可再分的最小操作单元。由一个指令标识和一组数量可变的参数构成:指令标识区分操作类型,参数描述操作的细节。
## 录像
一组按时间顺序排列的录像步的序列。完整记录从某初始状态到某最终状态的全部操作,是还原一段游戏流程的充要条件。
## 录像录制
将玩家操作转化为录像步并通过 `IReplayArray.add` 追加到末尾的过程。由作者在各逻辑系统(商店、移动等)中主动调用写入接口完成。
## 录像播放
`IReplayArray` 中通过流式读取器依次取出录像步并驱动数据端状态变化的过程。由于播放时需要从初始状态逐步重建到目标状态——这是整个系统中唯一需要批量修改全局状态的场景——使用沙箱隔离播放过程。
# 接口设计分析
## `IReplaySystem`
### 设计思路
由于要还原游戏流程,需要记录玩家操作。不同操作对应不同录像步类型——因此需要 `registerCommand` 注册命令,使每种命令 ID 映射到对应的执行对象。
注册了命令类型后,运行时需要向 `IReplayArray` 中追加新步——因此需要 `record` 方法,接收命令 ID 和可变长度的参数,内部委托给 `IReplayArray.add`
由于需要回放录像,且回放时原始录像系统仍需录制新步(做交叉验证),播放需要有独立的执行环境。此外播放前需要重置全局状态,但重置逻辑属于数据端不应被 Layer 0 直接依赖——因此需要 `createReplaySandbox` 创建沙箱,通过 `IStateReseter` 接口间接访问重置能力。播放结束需要销毁沙箱——因此需要 `releaseSandbox`
由于播放期间渲染端和数据端需要切换行为,需要一个成员标记当前是否有活跃沙箱——因此需要 `replaying`。外部可能需要获取当前沙箱实例——因此需要 `sandbox` 成员。
### 接口分析
- 成员 `replaying: boolean`:预期频率**高频**。
- 成员 `sandbox: IReplaySandbox | null`:预期频率**中频**。
- 方法 `registerCommand(id: number, command: IReplayCommand)`:预期频率**低频**。
- 方法 `record(id: number, ...params: ReplayParamValue[])`:预期频率**高频**。
- 方法 `createReplaySandbox(reseter: IStateReseter, save?: Map<string, unknown>): IReplaySandbox`:预期频率**低频**。
- 方法 `releaseSandbox()`:预期频率**低频**。
### 预期体量
预期代码体量 60-80 行。
- 命令注册与管理预期 15 行:`Map<number, IReplayCommand>` 的增删查。
- 沙箱创建与销毁预期 20 行:深拷贝 `IReplayArray`、注入命令注册表和 `IStateReseter`、创建 `ReplaySandbox` 实例。
- 存档实现预期 25 行:`saveState` 从 `IReplayArray` 导出 `ArrayBuffer``IReplayMetadata``loadState` 从存档数据重建 `IReplayArray` 并恢复标记。
## `IReplayCommand`
### 设计思路
由于作者可以自定义录像步类型,播放不同步类型时需要执行不同的操作逻辑。录像系统本身无法知晓这些逻辑,需要作者提供。这些逻辑往往需要持有数据端引用,纯函数回调无法满足——因此需要接口对象 `IReplayCommand`,作者编写实现类通过构造器持有所需引用。沙箱执行时传递当前步信息——因此 `execute` 直接接收 `IReplayStepHandler`
### 接口分析
- 方法 `execute(step: IReplayStepHandler)`:预期频率**低频**。每个命令编写一个实现类。
### 预期体量
预期代码体量 5 行。
## `IReplaySandbox`
### 设计思路
由于需要回放录像,播放过程中需要流程控制——连续推进、暂停、单步前进、终止。因此需要 `play`、`pause`、`resume`、`step`、`stop` 五个方法。
由于需求隐含要求异步播放和渲染端被动通知——项目已有 `IHookable` 通用钩子系统,沙箱扩展 `IHookable<IReplaySandboxHook>`,每步触发钩子并 `await` 其完成。
由于播放期间原始录像系统仍在录制,沙箱不能引用正在增长的 `IReplayArray`——创建时深拷贝,播放时通过 `IReplayReadStream` 顺序读取每一步,避免每次随机访问的 O(n) 偏移扫描。播放速度需要调节,渲染端据此调整动画时长——因此需要 `speed` 只读成员和 `setSpeed` 方法。
### 接口分析
- 成员 `replaying: boolean`:预期频率**高频**。
- 成员 `pausing: boolean`:预期频率**中频**。
- 成员 `speed: number`:预期频率**中频**。播放倍率,只读。
- 方法 `setSpeed(speed: number)`:预期频率**低频**。
- 方法 `play()`:预期频率**低频**。
- 方法 `pause()`:预期频率**低频**。
- 方法 `resume()`:预期频率**低频**。
- 方法 `stop()`:预期频率**低频**。
- 方法 `step()`:预期频率**中频**。返回 `Promise<void>`
### 预期体量
预期代码体量 120-160 行。
- 播放循环预期 80 行。在循环中通过 `IReplayReadStream.read()` 逐帧解码,传给 `IReplayCommand.execute`,再触发 `onStep` 钩子并 `await`。连续模式下循环自动推进,单步模式执行一步后挂起等待下个 `step` 调用,暂停冻结循环但不丢失索引,终止跳出循环并清空流式读取器。四种模式的状态切换组合逻辑占主要行数。
- 状态管理预期 30 行。`play`/`pause`/`resume`/`stop`/`step` 之间的互斥与合法性检查——未播放时 `pause` 无效果,已暂停时 `resume` 才恢复,`stop` 后一切操作无效。
- 钩子集成预期 20 行。扩展 `Hookable` 后在循环中调用 `forEachHook`,收集 `onStep` 的 Promise 并全部 `await`
## `IStateReseter`
### 设计思路
由于沙箱播放前需将全局状态恢复到基准状态——从头播放恢复为初始状态,从存档继续播放恢复为存档状态。重置能力属于数据端,不应被 `@user/data-common` 直接依赖——因此设计 `IStateReseter` 接口隔离此依赖,`reset` 接收可选存档数据覆盖两种场景。
### 接口分析
- 方法 `reset(save?: Map<string, unknown>)`:预期频率**低频**。
### 预期体量
预期代码体量 3 行。
## `IReplaySandboxHook`
### 设计思路
由于架构约束下渲染端被动接收信息,沙箱需要钩子将每步信息推送给渲染端。项目已有 `IHookable`/`IHookBase` 体系——因此沙箱钩子扩展 `IHookBase`,提供 `onStep` 方法接收 `IReplayStepHandler`。沙箱 `await` 其完成后再推进下一步。
### 接口分析
- 方法 `onStep?(step: IReplayStepHandler): Promise<void>`:预期频率**低频**。渲染端注册时定义一次。
### 预期体量
预期代码体量 4 行。
## `IReplayStepHandler`
### 设计思路
由于钩子和命令执行器都需要当前步的完整信息(操作类型、参数、索引),因此设计统一的 handler 风格数据接口 `IReplayStepHandler`,包含 `id`、`params`、`index`。
### 接口分析
- 成员 `id: number`:预期频率**高频**。
- 成员 `params: readonly ReplayParamValue[]`:预期频率**高频**。
- 成员 `index: number`:预期频率**低频**。
### 预期体量
预期代码体量 5 行。
## 类型别名
`ReplayParamValue = number | string | boolean | bigint`
---
# 实现思路
## 沙箱播放与交叉验证
1. 创建沙箱时深拷贝当前 `IReplayArray`
2. 调用 `reseter.reset(save)` 重置全局状态。
3. 创建 `IReplayReadStream`,循环:`read()` 解码一步 → `IReplayCommand.execute` → 触发 `onStep``await`
4. 原始录像系统清空并重新录制——播放中沙箱执行的命令触发 `record`,若遗漏则安全系统警告。
# 涉及文件
## `@user/data-common/src/replay/types.ts`
- [ ] 新增 `IReplaySystem`、`IReplaySandbox`、`IReplayCommand`、`IStateReseter`、`IReplaySandboxHook`、`IReplayStepHandler` 接口与 `ReplayParamValue` 类型别名
## `@user/data-common/src/replay/replay.ts`
- [ ] 编写 `ReplaySystem` 类:命令注册、沙箱创建、存档实现
## `@user/data-common/src/replay/sandbox.ts`
- [ ] 编写 `ReplaySandbox` 类:扩展 `Hookable` 实现 `IReplaySandbox`
## `@user/data-common/src/replay/array.ts`
- [ ] 详见 `replay-array.md`
## `@user/data-common/src/types.ts`
- [ ] 新增 `IDataCommon` 成员 `replay`
## `@user/data-common/src/index.ts`
- [ ] 新增 `export * from './replay'`
## `@user/data-state/src/core.ts`
- [ ] 新增 `ReplaySystem` 实例化与 `IStateReseter` 实现
- [ ] 新增存档注册

View File

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

View File

@ -0,0 +1,215 @@
# 需求综述
实现游戏引擎存档系统(`SaveSystem`)及全局事务(`GlobalTransaction`)两个类的完整逻辑。
存档系统分为两部分:
- **存档内容**:按 slot id 保存/读取游戏当前状态(`Map<string, ISaveableContent<unknown>>`
使用 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<string, unknown> }`
`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<string, unknown>;
}
```
- `compression`:存档时使用的压缩等级,读档时传回给 `loadState`,使接收方能够正确解压。
- `data`key 到每个可存档对象序列化数据的 Mapkey 与 `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<string, unknown>`,构建 `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<string, unknown>`
构建 `{ 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<T>(key)` / `setGlobal(key, value)`
- `getGlobal`:从 Dexie `global` 表读取 `key` 对应的 `value` 字段并返回,类型断言为 `T`
- `setGlobal`:将 `{ key, value }` 直接写入 Dexie `global` 表,无需 JSON 序列化。
### `startGlobalTransaction<R>(handle)`
使用 `Dexie.transaction('rw', this.db.table('global'), ...)` 包裹 `handle` 调用,
传入 `GlobalTransaction` 实例,出错时自动回滚。
### `GlobalTransaction.get<T>(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 序列化

106
docs/dev/template.md Normal file
View File

@ -0,0 +1,106 @@
> 本示例文档以物体移动为例,代码参考 `packages-user/data-common/src/common/mover.ts`。文档较为简略,实际编写的时候可以更丰富一些。
# 需求综述
为提供更便捷的物体移动接口,故进行本设计。本设计提供统一的移动接口 `IObjectMover`,并完成其抽象实现 `abstract class ObjectMover`,以方便具体拓展。
# 需求理解
## 明确需求
1. 移动与物体分离。移动与物体是独立的两个系统,二者之间不应存在任何耦合,移动单方面调用物体。
2. 移动计划。移动器需要包含移动计划及其执行两个阶段,先形成计划,再进行执行。
3. 移动流程控制。移动时需要提供相应的接口干预移动本身。
## 隐含需求
1. 异步执行。移动自然需要动画,因此移动本身必然是异步的。
2. 移动物体。不是所有的物体都能够移动,只有某些可以,这些应该被称为可移动物体。
## 未确定需求
1. 一个物体能不能绑定两个移动器?
# 设计前提
1. 网格移动。所有移动均基于离散网格,不支持连续空间移动。
2. 允许自定义拓展。允许自定义移动策略,不是所有的移动都由某一个类统一实现。
# 核心概念定义
> 这里是一个示例,实际情况中可以更丰富一些。
## 移动
一个物体的坐标从一点改变至另一点的行为称为移动。
## 可移动物体
所有可以被移动器移动的物体都可以被称为可移动物体,它们的核心特征是包含坐标信息,并拥有设置其坐标的接口。最常见的可移动物体是地图上的动态图块以及玩家操控的勇士。
## 移动器
一种对移动的抽象接口,可以通过调用可移动物体的坐标接口来操控可移动物体,但其提供的接口往往更加丰富,且拥有计划机制,允许进行连续移动。
# 接口设计分析
默认场景下,移动主要发生在两种物体上:地图动态图块及玩家操控的勇士。二者的主要差别在于后者由玩家操控,需要进行移动判定,例如需要进行通行性判定,事件触发判定等,而前者通常由系统代码控制,且常用于演出,并不需要进行判定。
## IObjectMover
### 设计思路
> 这部分应当分析需求,并写明接口是如何设计出来的,而不是简单地阐述接口内容。第一句必须写为什么需要设计这个接口本身,这里是 `IObjectMover` 接口,后面才能写接口的具体内容。
为了实现通用的物体移动功能,因此设计 `IObjectMover` 接口,包含所有物体移动最基本的接口支持。
对于玩家操控,判定往往发生在移动前,因此为统一接口,设计出 `onStepStart``onStepEnd` 两个接口,前者进行判定,后者根据判定结果进行相应的操作。为了让后者知道判定结果,将 `onStepStart` 的返回值设计为 `Promise<number>``onStepEnd` 接收之并执行操作。
在实际情况中,常见的使用场景是先规划好线路,然后让物体移动,因此接口需要设计成计划式,计划完成后通过 `start` 接口开始移动,故需要两个接口 `onMoveStart``onMoveEnd` 分别在移动开始前和结束后执行某些操作。
为了能够在开始移动后对移动进行干预及监听,`start` 应返回 `IMoveController` 作为返回值,其中应包含临时在当前步后插入一步、在当前移动队列后插入一步、下一步结束后立刻停止移动、当移动结束后兑现的 `Promise` 等内容。
接下来考虑移动计划,最通用的移动方法是指定移动方向与物体的面朝朝向,然后物体面朝指定朝向,向移动方向移动一格,故需要 `stepFace` 接口。在绝大多数情况下,移动方向与朝向是一致的,故提供 `step` 接口。除了这种情况,还会包含前进一格的需求,故提供 `forward` 接口;与之对应,还提供 `backward` 后退接口,而且在多数情况下,后退往往代表面朝前向后走,这里也使用这一语义。在后退时,移动动画的播放进度应该相反,故提供 `animDir` 接口。在移动时,移动速度是一个重要参数,故提供 `speed` 接口。在某些情况下,物体可能会需要瞬移,故提供 `tp` 接口。除了这些,还需要提供转向接口 `face`,调整面朝朝向;以及一个相对比较常见的 `jump` 接口。
对于移动对象,仅需要 `x` `y` `setPos` `getCurrentFaceDirection` 接口即可完成所有上述行为,故将这几个接口包装为 `IObjectMovable`,同时 `interface IObjectMover<T extends IObjectMovable>` 允许自定义拓展。
### 接口分析
> 此处仅列出部分。
- `IObjectMover.forward(count?: number)`:预期频率**高频**。向前移动一格是地图行走、动画演出等场景的核心需求,在逻辑与演出中都会频繁出现,故为高频。
- `IObjectMover.speed(speed: number)`:预期频率**中频**。移动中修改移速有一定使用场景,但远不及 `forward`、`step` 等移动接口的频率,通常只在特殊演出或逻辑中出现,故为中频。
- `IObjectMover.stepFace(move: FaceDirection, face: FaceDirection, count?: number)`:预期频率**低频**。移动方向与朝向不同的常见场景(后退)已由 `backward` 覆盖,只有极特殊情况才需要此接口(如角色朝向固定但沿垂直方向平移),相当罕见,故为低频。
### 预期体量
预期代码体量为 200-300 行。分析如下:
- 计划功能预期 100 行:`IObjectMover` 首先需要完成计划存储与计划的定义,这些接口基本大致就是向数组中添加元素,每个方法内容都不多。
- 移动流程预期 200 行:`IObjectMover` 还需要完成移动流程的编写工作,需要根据每个移动步按照流程执行不同的行为,这一过程较为复杂。
## IMoveController
...
# 实现思路
> 仅列出实现思路较为复杂的内容,例如像计划存储等属于非常直观简单的内容,不需要列出实现思路。
## 移动流程控制
核心难点在于如何进行移动控制,以下为核心思路。
1. 为实现移动流程,首先需要写一个大循环来处理每一个移动步。
2. 对于每一个移动步,首先调用 `onStepStart` 接口,获取移动 `code`,然后触发钩子,然后进行 `onStepEnd` 的执行,其返回值作为移动后的目标位置,最后触发钩子。
# 涉及文件
## `@user/data-common/src/common/mover.ts`
- [ ] 编写 `IObjectMover` 接口:按照本文描述完成设计
- [ ] 编写 `IObjectMovable` 接口:按照本文描述完成设计
- [ ] 编写 `IMoveController` 接口:按照本文描述完成设计
- [ ] 完成每个移动步的接口设计:每个移动步都需要一个接口对象,本文未明确提及,需包含每种移动计划
- [ ] 完成移动枚举的编写:每种移动计划都需要一个枚举类型来标注
- [ ] 编写 `ObjectMover` 抽象类:实现 `IObjectMover` 接口,作为移动基类

View File

@ -8,8 +8,10 @@
"test": "vitest",
"preview": "vite preview",
"declare": "tsx script/declare.ts",
"type": "vue-tsc --noEmit",
"lines": "tsx script/lines.ts packages packages-user",
"lint:packages": "eslint packages/",
"lint:user": "eslint packages-user/",
"lint:custom": "eslint",
"build:packages": "vue-tsc --noEmit && tsx script/build-packages.ts",
"build:game": "tsx script/declare.ts && vue-tsc --noEmit && tsx script/build-game.ts",
"build:lib": "vue-tsc --noEmit && tsx script/build-lib.ts",
@ -17,6 +19,7 @@
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"pack:template": "tsx script/pack-template.ts",
"check:type": "vue-tsc --noEmit",
"check:circular": "madge --circular src/main.ts"
},
"dependencies": {

View File

@ -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);

View File

@ -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 {

View File

@ -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';

View File

@ -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';

View File

@ -3,11 +3,11 @@ import {
FaceDirection,
getFaceMovement,
HeroAnimateDirection,
IHeroMover,
IHeroMovingHooks,
IHeroMoveController,
IHeroMoveControllerHooks,
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;
@ -54,7 +55,7 @@ export class MapHeroRenderer implements IMapHeroRenderer {
new TextureRowSplitter();
/** 勇士钩子 */
readonly controller: IHookController<IHeroMovingHooks>;
readonly controller: IHookController<IHeroMoveControllerHooks>;
/** 勇士每个朝向的贴图对象 */
readonly textureMap: Map<FaceDirection, IMaterialFramedData> = new Map();
/** 勇士渲染实体,与 `entities[0]` 同引用 */
@ -72,7 +73,7 @@ export class MapHeroRenderer implements IMapHeroRenderer {
constructor(
readonly renderer: IMapRenderer,
readonly layer: IMapLayer,
readonly hero: IHeroMover
readonly hero: IHeroMoveController
) {
this.controller = hero.addHook(new MapHeroHook(this));
this.controller.load();
@ -106,7 +107,7 @@ export class MapHeroRenderer implements IMapHeroRenderer {
private addHeroMoving(
renderer: IMapRenderer,
layer: IMapLayer,
hero: IHeroMover
hero: IHeroMoveController
) {
if (isNil(hero.image)) {
logger.warn(88);
@ -450,7 +451,7 @@ export class MapHeroRenderer implements IMapHeroRenderer {
}
}
class MapHeroHook implements Partial<IHeroMovingHooks> {
class MapHeroHook implements Partial<IHeroMoveControllerHooks> {
constructor(readonly hero: MapHeroRenderer) {}
onSetImage(image: ImageIds): void {

View File

@ -1,5 +1,5 @@
import { IHeroMover } from '@user/data-base';
import { IMapLayer } from '@user/data-state';
import { IHeroMoveController } from '@user/data-base';
import { IMapLayer } from '@user/data-base';
import {
IMapDoorRenderer,
IMapExtensionManager,
@ -14,7 +14,7 @@ import { IOnMapTextRenderer } from './types';
export class MapExtensionManager implements IMapExtensionManager {
/** 勇士状态至勇士渲染器的映射 */
readonly heroMap: Map<IHeroMover, IMapHeroRenderer> = new Map();
readonly heroMap: Map<IHeroMoveController, IMapHeroRenderer> = new Map();
/** 地图图层到门渲染器的映射 */
readonly doorMap: Map<IMapLayer, IMapDoorRenderer> = new Map();
/** 单例的文字渲染拓展(独立图层) */
@ -22,7 +22,10 @@ export class MapExtensionManager implements IMapExtensionManager {
constructor(readonly renderer: IMapRenderer) {}
addHero(state: IHeroMover, layer: IMapLayer): IMapHeroRenderer | null {
addHero(
state: IHeroMoveController,
layer: IMapLayer
): IMapHeroRenderer | null {
if (this.heroMap.has(state)) {
logger.error(45, 'hero renderer');
return null;
@ -32,7 +35,7 @@ export class MapExtensionManager implements IMapExtensionManager {
return heroRenderer;
}
removeHero(state: IHeroMover): void {
removeHero(state: IHeroMoveController): void {
const renderer = this.heroMap.get(state);
if (!renderer) return;
renderer.destroy();

View File

@ -2,15 +2,15 @@ import { ITexture, Font } from '@motajs/render';
import {
FaceDirection,
HeroAnimateDirection,
IHeroMover
IHeroMoveController
} from '@user/data-base';
import { IMapLayer } from '@user/data-state';
import { IMapLayer } from '@user/data-base';
import { IMapRenderResult } from '../types';
export interface IMapExtensionManager {
/** 勇士状态至勇士渲染器的映射 */
readonly heroMap: Map<IHeroMover, IMapHeroRenderer>;
readonly heroMap: Map<IHeroMoveController, IMapHeroRenderer>;
/** 地图图层到门渲染器的映射 */
readonly doorMap: Map<IMapLayer, IMapDoorRenderer>;
/** 单例的文字渲染拓展(独立图层) */
@ -21,13 +21,16 @@ export interface IMapExtensionManager {
* @param state
* @param layer
*/
addHero(state: IHeroMover, layer: IMapLayer): IMapHeroRenderer | null;
addHero(
state: IHeroMoveController,
layer: IMapLayer
): IMapHeroRenderer | null;
/**
*
* @param state
*/
removeHero(state: IHeroMover): void;
removeHero(state: IHeroMoveController): void;
/**
*

View File

@ -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 {

View File

@ -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';

View File

@ -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 {

View File

@ -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 {

View File

@ -1,4 +1,4 @@
import { IMapLayer } from '@user/data-state';
import { IMapLayer } from '@user/data-base';
import {
IBlockData,
IBlockSplitter,

View File

@ -243,7 +243,7 @@ const MainScene = defineComponent(() => {
>
<map-render
renderer={mainMapRenderer}
layerState={state.layer}
layerState={state.maps}
extension={mainMapExtension}
loc={[0, 0, MAP_WIDTH, MAP_HEIGHT]}
/>

View File

@ -1,6 +1,7 @@
{
"name": "@user/data-base",
"dependencies": {
"@user/data-common": "workspace:*",
"@motajs/common": "workspace:*",
"@motajs/types": "workspace:*",
"@motajs/loader": "workspace:*"

View File

@ -1,2 +0,0 @@
export * from './types';
export * from './utils';

View File

@ -1,18 +0,0 @@
export const enum FaceDirection {
Unknown,
Left,
Up,
Right,
Down,
LeftUp,
RightUp,
LeftDown,
RightDown
}
export interface IFaceData {
/** 图块数字 */
readonly identifier: number;
/** 图块朝向 */
readonly face: FaceDirection;
}

View File

@ -1,11 +1,6 @@
import { logger } from '@motajs/common';
import {
IEnemy,
IEnemyContext,
IReadonlyEnemy,
ISpecial,
IEnemyView
} from './types';
import { SaveCompression } from '@user/data-common';
import { IEnemy, IEnemySaveState, IReadonlyEnemy, ISpecial } from './types';
export class Enemy<TAttr> implements IEnemy<TAttr> {
/** 怪物身上的特殊属性列表 */
@ -87,44 +82,27 @@ export class Enemy<TAttr> implements IEnemy<TAttr> {
this.addSpecial(special.clone());
}
}
}
export class EnemyView<TAttr> implements IEnemyView<TAttr> {
/** 计算后怪物 */
private readonly computedEnemy: IEnemy<TAttr>;
constructor(
readonly baseEnemy: IEnemy<TAttr>,
readonly context: IEnemyContext<TAttr, unknown>
) {
this.computedEnemy = baseEnemy.clone();
saveState(_compression: SaveCompression): IEnemySaveState<TAttr> {
const specials: Map<number, unknown> = new Map();
for (const special of this.specials) {
specials.set(special.code, special.saveState(_compression));
}
return { attrs: structuredClone(this.attributes), specials };
}
reset(): void {
this.computedEnemy.copyFrom(this.baseEnemy);
}
getBaseEnemy(): IReadonlyEnemy<TAttr> {
return this.baseEnemy;
}
getComputedEnemy(): IReadonlyEnemy<TAttr> {
this.context.requestRefresh(this);
return this.computedEnemy;
}
/**
* EnemyContext 使
*/
getComputingEnemy(): IEnemy<TAttr> {
return this.computedEnemy;
}
getModifiableEnemy(): IEnemy<TAttr> {
return this.baseEnemy;
}
markDirty(): void {
this.context.markDirty(this);
loadState(
state: IEnemySaveState<TAttr>,
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);
}
}
}

View File

@ -1,8 +1,4 @@
export * from './enemy';
export * from './context';
export * from './damage';
export * from './mapDamage';
export * from './manager';
export * from './special';
export * from './types';
export * from './utils';

View File

@ -2,33 +2,52 @@ 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 '@user/data-common';
export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
export class EnemyManager<TEnemy> implements IEnemyManager<TEnemy> {
/** 特殊属性注册表code -> 创建函数 */
private readonly specialRegistry: Map<number, SpecialCreation<any, TAttr>> =
new Map();
private readonly specialRegistry: Map<
number,
SpecialCreation<any, TEnemy>
> = new Map();
/** 自定义怪物属性注册表name -> 默认值 */
private readonly attributeRegistry: Map<keyof TAttr, any> = new Map();
private readonly attributeRegistry: Map<keyof TEnemy, any> = new Map();
/** 怪物模板表code -> IEnemy */
private readonly prefabByCode: Map<number, IEnemy<TAttr>> = new Map();
private readonly prefabByCode: Map<number, IEnemy<TEnemy>> = new Map();
/** 怪物模板表id -> IEnemy */
private readonly prefabById: Map<string, IEnemy<TAttr>> = new Map();
private readonly prefabById: Map<string, IEnemy<TEnemy>> = new Map();
/** 旧样板怪物 id 到 code 的映射,用于 fromLegacyEnemy 快速查找已有模板 */
private readonly legacyIdToCode: Map<string, number> = new Map();
/** 复用映射reusedCode -> sourceCode */
private readonly reuseByCode: Map<number, number> = new Map();
/** 复用映射reusedId -> sourceId */
private readonly reuseById: Map<string, string> = new Map();
/** 脏模板集合,存储发生了变化的模板 code */
private readonly dirtySet: Set<number> = new Set();
/** 参考快照code -> IReadonlyEnemy由 compareWith 提供 */
private referenceByCode: Map<number, IReadonlyEnemy<TEnemy>> = new Map();
/** 当前附加的怪物比较器 */
private comparer: IEnemyComparer<TEnemy> | null = null;
/** 是否已首次调用 compareWith */
private hasReference: boolean = false;
constructor(readonly bridge: IEnemyLegacyBridge<TAttr>) {}
constructor(readonly bridge: IEnemyLegacyBridge<TEnemy>) {}
registerSpecial(code: number, cons: SpecialCreation<any, TAttr>): void {
registerSpecial(code: number, cons: SpecialCreation<any, TEnemy>): void {
this.specialRegistry.set(code, cons);
}
setAttributeDefaults<K extends keyof TAttr>(
setAttributeDefaults<K extends keyof TEnemy>(
name: K,
defaultValue: TAttr[K]
defaultValue: TEnemy[K]
): void {
if (
typeof defaultValue === 'function' ||
@ -42,7 +61,7 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
this.attributeRegistry.set(name, defaultValue);
}
fromLegacyEnemy(code: number, enemy: Enemy): IEnemy<TAttr> {
fromLegacyEnemy(code: number, enemy: Enemy): IEnemy<TEnemy> {
// 如果该旧样板怪物已经通过 addPrefabFromLegacy 注册为模板,直接克隆模板
const existingCode = this.legacyIdToCode.get(enemy.id);
if (existingCode) {
@ -59,15 +78,15 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
*
* @param enemy
*/
private createAttributes(enemy: Enemy): TAttr {
const attrs: Partial<TAttr> = {};
private createAttributes(enemy: Enemy): TEnemy {
const attrs: Partial<TEnemy> = {};
for (const [name, defaultValue] of this.attributeRegistry) {
attrs[name] = structuredClone(defaultValue);
}
Object.assign(attrs, this.bridge.fromLegacyEnemy(enemy, attrs));
return attrs as TAttr;
return attrs as TEnemy;
}
/**
@ -75,9 +94,9 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
* @param code
* @param enemy
*/
private convertLegacyEnemy(code: number, enemy: Enemy): IEnemy<TAttr> {
private convertLegacyEnemy(code: number, enemy: Enemy): IEnemy<TEnemy> {
const attrs = this.createAttributes(enemy);
const result = new EnemyImpl<TAttr>(
const result = new EnemyImpl<TEnemy>(
enemy.id,
code,
structuredClone(attrs)
@ -97,13 +116,13 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
return result;
}
createEnemy(code: number): IEnemy<TAttr> | null {
createEnemy(code: number): IEnemy<TEnemy> | null {
const prefab = this.prefabByCode.get(code);
if (!prefab) return null;
return prefab.clone();
}
createEnemyById(id: string): IEnemy<TAttr> | null {
createEnemyById(id: string): IEnemy<TEnemy> | null {
const prefab = this.prefabById.get(id);
if (!prefab) return null;
return prefab.clone();
@ -111,13 +130,15 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
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;
}
}
addPrefab(enemy: IEnemy<TAttr>): void {
addPrefab(enemy: IEnemy<TEnemy>): void {
if (
this.prefabByCode.has(enemy.code) ||
this.prefabById.has(enemy.id)
@ -127,6 +148,7 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
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,14 +159,17 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
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<TAttr> | null {
return this.prefabByCode.get(code) ?? null;
getPrefab(code: number): IReadonlyEnemy<TEnemy> | null {
const sourceCode = this.reuseByCode.get(code) ?? code;
return this.prefabByCode.get(sourceCode) ?? null;
}
getPrefabById(id: string): IEnemy<TAttr> | null {
return this.prefabById.get(id) ?? null;
getPrefabById(id: string): IReadonlyEnemy<TEnemy> | null {
const sourceId = this.reuseById.get(id) ?? id;
return this.prefabById.get(sourceId) ?? null;
}
deletePrefab(code: number | string): void {
@ -154,18 +179,132 @@ export class EnemyManager<TAttr> implements IEnemyManager<TAttr> {
this.prefabById.delete(prefab.id);
}
changePrefab(code: number | string, enemy: IEnemy<TAttr>): void {
changePrefab(code: number | string, enemy: IEnemy<TEnemy>): void {
// 先删除旧的模板(如果存在)
this.deletePrefab(code);
// 再添加新的模板
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 {
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<number, IReadonlyEnemy<TEnemy>>): 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<TEnemy>) => IEnemy<TEnemy>
): 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<TEnemy>): void {
this.comparer = comparer;
}
getEnemyComparer(): IEnemyComparer<TEnemy> | null {
return this.comparer;
}
saveState(compression: SaveCompression): IEnemyManagerSaveState<TEnemy> {
const modified: Map<number, IEnemySaveState<TEnemy>> = 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<TEnemy>,
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<TEnemy>): 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<number>): 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);
}
}
}
}

View File

@ -1,3 +1,5 @@
import { isEqual } from 'lodash-es';
import { SaveCompression } from '@user/data-common';
import { ISpecial, SpecialCreation } from './types';
// TODO: 颜色参数
@ -45,6 +47,19 @@ export class CommonSerializableSpecial<T> implements ISpecial<T> {
this.config
);
}
saveState(_compression: SaveCompression): T {
return structuredClone(this.value);
}
loadState(state: T, _compression: SaveCompression): void {
this.setValue(state);
}
deepEqualsTo(other: ISpecial<T>): boolean {
if (this.code !== other.code) return false;
return isEqual(this.value, other.getValue());
}
}
export class NonePropertySpecial implements ISpecial<void> {
@ -78,6 +93,18 @@ export class NonePropertySpecial implements ISpecial<void> {
clone(): ISpecial<void> {
return new NonePropertySpecial(this.code, this.config);
}
saveState(_compression: SaveCompression): void {
return undefined;
}
loadState(_state: void, _compression: SaveCompression): void {
// 无属性,无需操作
}
deepEqualsTo(other: ISpecial<void>): boolean {
return this.code === other.code;
}
}
export function defineCommonSerializableSpecial<T, TAttr = any>(

View File

@ -1,9 +1,34 @@
import { IRange, ITileLocator } from '@motajs/common';
import { IHeroAttribute, IReadonlyHeroAttribute } from '../hero';
import { ISaveableContent } from '@user/data-common';
//#region 怪物基础
export interface ISpecial<T = void> {
/** 单个 IEnemy 的存档状态 */
export interface IEnemySaveState<TAttr> {
/** 怪物属性的深拷贝 */
readonly attrs: TAttr;
/** 特殊属性按 code 映射,值为各 ISpecial.saveState() 的结果 */
readonly specials: ReadonlyMap<number, unknown>;
}
/** IEnemyManager 的存档状态,只保存与参考状态不同的模板 */
export interface IEnemyManagerSaveState<TAttr> {
/** code -> 变更后的 IEnemySaveState仅包含脏模板 */
readonly modified: ReadonlyMap<number, IEnemySaveState<TAttr>>;
}
export interface IEnemyComparer<TAttr> {
/**
*
* @param enemyA A
* @param enemyB B
*/
compare(
enemyA: IReadonlyEnemy<TAttr>,
enemyB: IReadonlyEnemy<TAttr>
): boolean;
}
export interface ISpecial<T = void> extends ISaveableContent<T> {
/** 特殊属性代码 */
readonly code: number;
/** 特殊属性需要的数值 */
@ -40,6 +65,12 @@ export interface ISpecial<T = void> {
*
*/
clone(): ISpecial<T>;
/**
*
* @param other
*/
deepEqualsTo(other: ISpecial<T>): boolean;
}
export interface IReadonlyEnemy<TAttr> {
@ -82,7 +113,8 @@ export interface IReadonlyEnemy<TAttr> {
clone(): IReadonlyEnemy<TAttr>;
}
export interface IEnemy<TAttr> extends IReadonlyEnemy<TAttr> {
export interface IEnemy<TAttr>
extends IReadonlyEnemy<TAttr>, ISaveableContent<IEnemySaveState<TAttr>> {
/**
*
* @param special
@ -138,7 +170,9 @@ export interface IEnemyLegacyBridge<TAttr> {
fromLegacyEnemy(enemy: Enemy, defaultValue: Partial<TAttr>): TAttr;
}
export interface IEnemyManager<TAttr> {
export interface IEnemyManager<TAttr> extends ISaveableContent<
IEnemyManagerSaveState<TAttr>
> {
/**
*
* @param code
@ -193,13 +227,13 @@ export interface IEnemyManager<TAttr> {
*
* @param code
*/
getPrefab(code: number): IEnemy<TAttr> | null;
getPrefab(code: number): IReadonlyEnemy<TAttr> | null;
/**
* `id`
* @param id `id`
*/
getPrefabById(id: string): IEnemy<TAttr> | null;
getPrefabById(id: string): IReadonlyEnemy<TAttr> | null;
/**
*
@ -221,719 +255,34 @@ export interface IEnemyManager<TAttr> {
* @param id id
*/
reusePrefab(source: number | string, code: number, id: string): void;
}
//#endregion
//#region 辅助接口
export interface IMapLocHelper {
/**
* ->
* @param x
* @param y
*/
locToIndex(x: number, y: number): number;
/**
* ->
* @param locator
*
*
* @param reference code -> Map
*/
locaterToIndex(locator: ITileLocator): number;
compareWith(reference: ReadonlyMap<number, IReadonlyEnemy<TAttr>>): void;
/**
* ->
* @param index
* dirty
* @param code `id`
* @param modify
*/
indexToLocator(index: number): ITileLocator;
}
export interface IMapLocIndexer extends IMapLocHelper {
/**
*
* @param width
*/
setWidth(width: number): void;
}
export interface IEnemyHandler<TAttr, THero> {
/** 怪物属性信息 */
readonly enemy: IEnemy<TAttr>;
/** 怪物定位符 */
readonly locator: ITileLocator;
/** 勇士属性信息 */
readonly hero: IReadonlyHeroAttribute<THero>;
}
export interface IReadonlyEnemyHandler<TAttr, THero> {
/** 怪物属性信息 */
readonly enemy: IReadonlyEnemy<TAttr>;
/** 怪物定位符 */
readonly locator: ITileLocator;
/** 勇士属性信息 */
readonly hero: IReadonlyHeroAttribute<THero>;
}
//#endregion
//#region 怪物对象
export interface IEnemyView<TAttr> {
/** 怪物视图所属的上下文 */
readonly context: IEnemyContext<TAttr, unknown>;
/**
*
*/
reset(): void;
/**
*
*/
getBaseEnemy(): IReadonlyEnemy<TAttr>;
/**
*
*/
getComputedEnemy(): IReadonlyEnemy<TAttr>;
/**
*
* markDirty
*/
getModifiableEnemy(): IEnemy<TAttr>;
/**
*
*/
markDirty(): void;
}
//#endregion
//#region 光环与查询
export interface IEnemySpecialModifier<TAttr> {
/**
*
* @param handler
*/
add(handler: IReadonlyEnemyHandler<TAttr, unknown>): ISpecial<any>[];
/**
*
* @param handler
*/
delete(handler: IReadonlyEnemyHandler<TAttr, unknown>): ISpecial<any>[];
/**
* true false
* @param handler
* @param special
*/
modify(
handler: IEnemyHandler<TAttr, unknown>,
special: ISpecial<any>
): boolean;
}
export interface IAuraView<TAttr, T = any> {
/** 此光环视图的优先级 */
readonly priority: number;
/** 此光环视图的影响范围 */
readonly range: IRange<T>;
/** 这个光环视图是否有可能修改怪物的基本属性 */
readonly couldApplyBase: boolean;
/** 这个光环视图是否有可能修改怪物的特殊属性 */
readonly couldApplySpecial: boolean;
/**
*
*/
getRangeParam(): T;
/**
*
* @param handler
* @param baseEnemy
*/
apply(
handler: IEnemyHandler<TAttr, unknown>,
baseEnemy: IReadonlyEnemy<TAttr>
modifyPrefabAttribute(
code: number | string,
modify: (prefab: IEnemy<TAttr>) => IEnemy<TAttr>
): void;
/**
*
* @param handler
* @param baseEnemy
* dirty
* @param comparer
*/
applySpecial(
handler: IEnemyHandler<TAttr, unknown>,
baseEnemy: IReadonlyEnemy<TAttr>
): IEnemySpecialModifier<TAttr> | null;
}
export interface IEnemyAuraView<TAttr, R, S> extends IAuraView<TAttr, R> {
/** 此光环视图所属的怪物 */
readonly enemy: IReadonlyEnemy<TAttr>;
/** 此光环视图所属的特殊属性 */
readonly special: ISpecial<S>;
/** 此光环视图所属怪物的定位符 */
readonly locator: ITileLocator;
}
export interface IAuraConverter<TAttr, THero> {
/**
*
* @param special
* @param handler
*/
shouldConvert(
special: ISpecial<any>,
handler: IReadonlyEnemyHandler<TAttr, THero>
): boolean;
attachEnemyComparer(comparer: IEnemyComparer<TAttr>): void;
/**
*
* `null`
*/
convert(
special: ISpecial<any>,
handler: IReadonlyEnemyHandler<TAttr, THero>,
context: IEnemyContext<TAttr, THero>
): IEnemyAuraView<TAttr, any, any>;
}
export interface IEnemySpecialQueryModifier<
TAttr,
THero
> extends IEnemySpecialModifier<TAttr> {
/**
*
*/
shouldQuery(handler: IReadonlyEnemyHandler<TAttr, THero>): boolean;
}
export interface IEnemySpecialQueryEffect<TAttr, THero> {
/** 效果优先级,与光环属性共用 */
readonly priority: number;
/**
*
*/
for(
ctx: IEnemyContext<TAttr, THero>
): IEnemySpecialQueryModifier<TAttr, THero>;
}
export interface IEnemyCommonQueryEffect<TAttr, THero> {
/** 优先级,越高的越先执行 */
readonly priority: number;
/**
*
*/
apply(
handler: IEnemyHandler<TAttr, THero>,
special: ISpecial<any>,
query: () => IEnemyContext<TAttr, THero>
): void;
}
export interface IEnemyFinalEffect<TAttr, THero> {
/** 效果优先级,越高会越先被执行 */
readonly priority: number;
/**
*
*/
apply(handler: IEnemyHandler<TAttr, THero>): void;
}
//#endregion
//#region 地图伤害
export interface IMapDamageInfoExtra {
/** 捕捉怪物信息 */
catch: Set<ITileLocator>;
/** 阻击怪物信息 */
repulse: Set<ITileLocator>;
}
export interface IMapDamageInfo {
/** 伤害值 */
damage: number;
/** 伤害类型 */
type: number;
/** 地图伤害额外信息 */
extra: IMapDamageInfoExtra;
}
export interface IMapDamageView<T = any> {
/**
*
*/
getRange(): IRange<T>;
/**
*
*/
getRangeParam(): T;
/**
*
* @param locator
*/
getDamageAt(locator: ITileLocator): Readonly<IMapDamageInfo> | null;
/**
*
* @param locator
*/
getDamageWithoutCheck(
locator: ITileLocator
): Readonly<IMapDamageInfo> | null;
}
export interface IMapDamageConverter<TAttr, THero> {
/**
*
*/
convert(
handler: IReadonlyEnemyHandler<TAttr, THero>,
context: IEnemyContext<TAttr, THero>
): IMapDamageView<any>[];
}
export interface IMapDamageReducer {
/**
*
*/
reduce(
info: Iterable<Readonly<IMapDamageInfo>>,
locator: ITileLocator
): Readonly<IMapDamageInfo>;
}
export interface IMapDamage<TAttr, THero> {
/** 当前绑定的怪物上下文 */
readonly context: IEnemyContext<TAttr, THero>;
/**
*
* @param converter
*/
useConverter(converter: IMapDamageConverter<TAttr, THero>): void;
/**
*
* @param reducer
*/
useReducer(reducer: IMapDamageReducer): void;
/**
*
* @param locator
* @param info
*/
addMapDamage(locator: ITileLocator, info: IMapDamageInfo): void;
/**
*
* @param locator
* @param info
*/
deleteMapDamage(locator: ITileLocator, info: IMapDamageInfo): void;
/**
* 访
* @param locator
*/
markDirty(locator: ITileLocator): void;
/**
*
* @param view
*/
markEnemyDirty(view: IEnemyView<TAttr>): void;
/**
*
*/
refreshAll(): void;
/**
*
* @param view
*/
deleteEnemy(view: IEnemyView<TAttr>): void;
/**
*
* @param locator
*/
getReducedDamage(locator: ITileLocator): Readonly<IMapDamageInfo> | null;
/**
*
* @param locator
*/
getSeparatedDamage(
locator: ITileLocator
): Iterable<Readonly<IMapDamageInfo>>;
}
//#endregion
//#region 伤害系统
export interface IEnemyDamageInfo {
/** 战斗伤害值 */
readonly damage: number;
/** 战斗回合数 */
readonly turn: number;
}
export interface IEnemyCritical {
/** 此临界点中指定勇士属性的值 */
readonly nextValue: number;
/** 当前勇士指定属性的值 */
readonly baseValue: number;
/** 此临界点中指定勇士数值的值与当前值的差,即 `nextValue - baseValue` */
readonly nextDiff: number;
/** 当前状态下怪物的伤害信息 */
readonly baseInfo: IEnemyDamageInfo;
/** 此临界点下怪物的伤害信息 */
readonly info: IEnemyDamageInfo;
/** 此临界点的伤害值与当前伤害值的差 */
readonly damageDiff: number;
}
export type CriticalableHeroStatus<THero> = keyof {
[P in keyof THero as THero[P] extends number ? P : never]: number;
};
export interface IDamageCalculator<TAttr, THero> {
/**
*
* @param handler
*/
calculate(handler: IReadonlyEnemyHandler<TAttr, THero>): IEnemyDamageInfo;
/**
*
* @param handler
* @param attribute
*/
getCriticalLimit(
handler: IReadonlyEnemyHandler<TAttr, THero>,
attribute: CriticalableHeroStatus<THero>
): number;
}
export interface IDamageContext<TAttr, THero> {
/**
*
* @param enemy
*/
getDamageInfo(enemy: IEnemyView<TAttr>): IEnemyDamageInfo | null;
/**
*
* @param enemy
*/
getDamageInfoByComputed(
enemy: IReadonlyEnemy<TAttr>
): IEnemyDamageInfo | null;
/**
*
* @param enemy
* @param attribute
* @param precision `12-16` 12
*/
calculateCritical(
enemy: IEnemyView<TAttr>,
attribute: CriticalableHeroStatus<THero>,
precision?: number
): Generator<IEnemyCritical, void, void>;
}
export interface IDamageSystem<TAttr, THero> extends IDamageContext<
TAttr,
THero
> {
/** 伤害系统所属的上下文 */
readonly context: IEnemyContext<TAttr, THero>;
/**
* 使
* @param calculator
*/
useCalculator(calculator: IDamageCalculator<TAttr, THero>): void;
/**
* 使
*/
getCalculator(): IDamageCalculator<TAttr, THero> | null;
/**
*
* @param hero
*/
bindHeroStatus(hero: IReadonlyHeroAttribute<THero> | null): void;
/**
*
* @param enemy
*/
markDirty(enemy: IEnemyView<TAttr>): void;
/**
*
* @param enemy
*/
deleteEnemy(enemy: IEnemyView<TAttr>): void;
/**
*
*/
markAllDirty(): void;
/**
*
* @param modify
*/
with(hero: IHeroAttribute<THero>): IDamageContext<TAttr, THero>;
}
//#endregion
//#region 上下文
export interface IEnemyContext<TAttr, THero> {
/** 怪物上下文宽度 */
readonly width: number;
/** 怪物上下文高度 */
readonly height: number;
/** 此上下文使用的索引对象 */
readonly indexer: IMapLocIndexer;
/**
*
* @param width
* @param height
*/
resize(width: number, height: number): void;
/**
*
* @param converter
*/
registerAuraConverter(converter: IAuraConverter<TAttr, THero>): void;
/**
*
* @param converter
*/
unregisterAuraConverter(converter: IAuraConverter<TAttr, THero>): void;
/**
*
* @param converter
* @param enabled
*/
setAuraConverterEnabled(
converter: IAuraConverter<TAttr, THero>,
enabled: boolean
): void;
/**
*
* @param effect
*/
registerSpecialQueryEffect(
effect: IEnemySpecialQueryEffect<TAttr, THero>
): void;
/**
*
* @param effect
*/
unregisterSpecialQueryEffect(
effect: IEnemySpecialQueryEffect<TAttr, THero>
): void;
/**
*
* @param code
* @param effect
*/
registerCommonQueryEffect(
code: number,
effect: IEnemyCommonQueryEffect<TAttr, THero>
): void;
/**
*
* @param code
* @param effect
*/
unregisterCommonQueryEffect(
code: number,
effect: IEnemyCommonQueryEffect<TAttr, THero>
): void;
/**
*
* @param effect
*/
registerFinalEffect(effect: IEnemyFinalEffect<TAttr, THero>): void;
/**
*
* @param effect
*/
unregisterFinalEffect(effect: IEnemyFinalEffect<TAttr, THero>): void;
/**
*
* @param hero
*/
bindHero(hero: IReadonlyHeroAttribute<THero> | null): void;
/**
*
*/
getBindedHero(): IReadonlyHeroAttribute<THero> | null;
/**
*
* @param enemy
*/
getEnemyLocator(enemy: IEnemy<TAttr>): Readonly<ITileLocator> | null;
/**
*
* @param view
*/
getEnemyLocatorByView(
view: IEnemyView<TAttr>
): Readonly<ITileLocator> | null;
/**
*
* @param locator
*/
getEnemyByLocator(locator: ITileLocator): IEnemyView<TAttr> | null;
/**
*
* @param x
* @param y
*/
getEnemyByLoc(x: number, y: number): IEnemyView<TAttr> | null;
/**
*
* @param enemy
*/
getViewByComputed(enemy: IReadonlyEnemy<TAttr>): IEnemyView<TAttr> | null;
/**
*
* @param locator
* @param enemy
*/
setEnemyAt(locator: ITileLocator, enemy: IEnemy<TAttr>): void;
/**
*
* @param locator
*/
deleteEnemy(locator: ITileLocator): void;
/**
*
* @param range
* @param param
*/
scanRange<T>(
range: IRange<T>,
param: T
): Iterable<[ITileLocator, IEnemyView<TAttr>]>;
/**
*
*/
iterateEnemy(): Iterable<[ITileLocator, IEnemyView<TAttr>]>;
/**
*
* @param aura
*/
addAura(aura: IAuraView<TAttr>): void;
/**
*
* @param aura
*/
deleteAura(aura: IAuraView<TAttr>): void;
/**
*
* @param damage
*/
attachMapDamage(damage: IMapDamage<TAttr, THero> | null): void;
/**
*
*/
getMapDamage(): IMapDamage<TAttr, THero> | null;
/**
*
* @param system
*/
attachDamageSystem(system: IDamageSystem<TAttr, unknown> | null): void;
/**
*
*/
getDamageSystem(): IDamageSystem<TAttr, THero> | null;
/**
*
*
* 1.
* 2.
* 3.
* 4.
*/
buildup(): void;
/**
*
* @param view
*/
markDirty(view: IEnemyView<TAttr>): void;
/**
*
* @param view
*/
requestRefresh(view: IEnemyView<TAttr>): void;
/**
*
*/
clear(): void;
/**
*
*/
destroy(): void;
getEnemyComparer(): IEnemyComparer<TAttr> | null;
}
//#endregion

View File

@ -1,25 +0,0 @@
import { ITileLocator } from '@motajs/common';
import { IMapLocIndexer } from './types';
export class MapLocIndexer implements IMapLocIndexer {
private width: number = 0;
setWidth(width: number): void {
this.width = width;
}
locToIndex(x: number, y: number): number {
return y * this.width + x;
}
locaterToIndex(locator: ITileLocator): number {
return locator.y * this.width + locator.x;
}
indexToLocator(index: number): ITileLocator {
return {
x: index % this.width,
y: Math.floor(index / this.width)
};
}
}

View File

@ -2,7 +2,7 @@ import { IFlagCommonField, IFlagSystem, IFlagSystemSave } from './types';
import { FlagCommonField } from './field';
export class FlagSystem implements IFlagSystem {
private readonly fieldMap: Map<PropertyKey, FlagCommonField<any>> =
private readonly fieldMap: Map<PropertyKey, IFlagCommonField<any>> =
new Map();
occupied(field: PropertyKey): boolean {

View File

@ -1,5 +1,7 @@
//#region 字段
import { ISaveableContent } from '@user/data-common';
export interface IFlagCommonField<T> {
/** 此字段所处的 Flag 系统 */
readonly system: IFlagSystem;
@ -42,7 +44,7 @@ export interface IFlagSystemSave {
readonly fields: Map<PropertyKey, any>;
}
export interface IFlagSystem {
export interface IFlagSystem extends ISaveableContent<IFlagSystemSave> {
/**
* `core.hasFlag`
* @param field
@ -131,17 +133,6 @@ export interface IFlagSystem {
* @param defaultValue
*/
getFieldValueDefaults<T>(field: PropertyKey, defaultValue: T): T;
/**
* Flag
*/
saveState(): IFlagSystemSave;
/**
*
* @param state
*/
loadState(state: IFlagSystemSave): void;
}
//#endregion

View File

@ -1,7 +1,14 @@
import { logger } from '@motajs/common';
import { IHeroAttribute, IHeroModifier } from './types';
import { SaveCompression } from '@user/data-common';
import {
IHeroAttribute,
IHeroAttributeCloneOption,
IHeroModifier
} from './types';
import { isNil } from 'lodash-es';
export abstract class BaseHeroModifier<T, V> implements IHeroModifier<T, V> {
export abstract class BaseHeroModifier<T, V> implements IHeroModifier<T, V, V> {
abstract readonly type: string;
abstract readonly priority: number;
owner: IHeroAttribute<unknown> | null = null;
@ -25,6 +32,14 @@ export abstract class BaseHeroModifier<T, V> implements IHeroModifier<T, V> {
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<T, V>;
@ -33,8 +48,10 @@ export abstract class BaseHeroModifier<T, V> implements IHeroModifier<T, V> {
export class HeroAttribute<THero> implements IHeroAttribute<THero> {
/** 当前勇士属性修饰器 */
private readonly modifier: Map<keyof THero, IHeroModifier[]> = new Map();
/** 当前每个修饰器对应的属性 */
/** 当前每个修饰器对应的属性名称 */
private readonly modifierName: Map<IHeroModifier, keyof THero> = new Map();
/** 当前标记为不存档的修饰器集合 */
private readonly modifierNosave: Set<IHeroModifier> = new Set();
/** 当前勇士最终属性 */
private readonly finalAttribute: THero;
@ -45,6 +62,8 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
this.finalAttribute = structuredClone(attribute);
}
//#region 属性计算
/**
*
* @param curr
@ -68,6 +87,7 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
const nextValue = modifier.modify(value, baseValue, name);
// 部署之后就没必要弹这个警告了,额外判断反而可能会有一定的性能损失,直接 tree-shaking 优化掉
if (import.meta.env.DEV && this.isSameReference(value, nextValue)) {
// 对于对象属性,如果返回值和原始值的引用相同,那么应该抛出警告
const modiferName = modifier.constructor.name;
logger.warn(109, modiferName, String(name));
}
@ -77,6 +97,25 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
this.finalAttribute[name] = value;
}
*catchCalculateProgress<K extends keyof THero>(name: K) {
const modifierList = this.modifier.get(name);
if (!modifierList) return;
const baseValue = this.attribute[name];
let value = baseValue;
for (const modifier of modifierList as IHeroModifier<THero[K]>[]) {
const nextValue = modifier.modify(value, baseValue, name);
// 部署之后就没必要弹这个警告了,额外判断反而可能会有一定的性能损失,直接 tree-shaking 优化掉
if (import.meta.env.DEV && this.isSameReference(value, nextValue)) {
// 对于对象属性,如果返回值和原始值的引用相同,那么应该抛出警告
const modiferName = modifier.constructor.name;
logger.warn(109, modiferName, String(name));
}
value = nextValue;
yield [modifier, value] as [IHeroModifier<THero[K]>, THero[K]];
}
}
getBaseAttribute<K extends keyof THero>(name: K): THero[K] {
return this.attribute[name];
}
@ -85,22 +124,59 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
return this.finalAttribute[name];
}
setBaseAttribute<K extends keyof THero>(name: K, value: THero[K]): void {
//#endregion
//#region 属性操作
set<K extends keyof THero>(name: K, value: THero[K]): void {
this.attribute[name] = value;
this.markDirty(name);
}
addBaseAttribute<K extends keyof SelectType<THero, number>>(
name: K,
value: number
): void {
add(name: SelectKey<THero, number>, value: number): void {
(this.attribute[name] as number) += value;
this.markDirty(name);
}
mul(name: SelectKey<THero, number>, value: number): void {
(this.attribute[name] as number) *= value;
this.markDirty(name);
}
div(name: SelectKey<THero, number>, value: number): void {
(this.attribute[name] as number) /= value;
this.markDirty(name);
}
//#endregion
//#region 修饰器处理
*iterateModifiers(): IterableIterator<[PropertyKey, IHeroModifier]> {
for (const [modifier, name] of this.modifierName) {
yield [name, modifier];
}
}
getModifiers<K extends keyof THero>(
name: K
): Iterable<IHeroModifier<THero[K]>> {
const arr = this.modifier.get(name) as IHeroModifier<THero[K]>[];
return arr ?? [];
}
getModifierIndex(modifier: IHeroModifier): number {
const name = this.modifierName.get(modifier);
if (isNil(name)) return -1;
const arr = this.modifier.get(name);
if (!arr) return -1;
return arr.indexOf(modifier);
}
addModifier<K extends keyof THero>(
name: K,
modifier: IHeroModifier<THero[K], unknown>
modifier: IHeroModifier<THero[K]>,
save: boolean = true
): void {
if (modifier.owner) {
const modiferName = modifier.constructor.name;
@ -113,6 +189,9 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
modifierList.sort((left, right) => right.priority - left.priority);
this.modifierName.set(modifier, name);
if (!save) {
this.modifierNosave.add(modifier);
}
modifier.bindAttribute(this);
this.markDirty(name);
}
@ -129,31 +208,60 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
modifier.bindAttribute(null);
modifierList.splice(index, 1);
this.modifierName.delete(modifier);
this.modifierNosave.delete(modifier);
this.markDirty(name);
}
deleteModifierByIndex<K extends keyof THero>(
name: K,
index: number
): IHeroModifier<THero[K]> | null {
const arr = this.modifier.get(name);
if (!arr) return null;
const modifier = arr.splice(index, 1);
if (modifier.length === 0) return null;
else return modifier[0] as IHeroModifier<THero[K]>;
}
markDirty(name: keyof THero): void {
this.recalculateAttribute(name);
}
markModifierDirty(modifier: IHeroModifier): void {
markModifierDirty(modifier: IHeroModifier<THero[keyof THero]>): void {
const name = this.modifierName.get(modifier);
if (name === undefined) return;
this.markDirty(name);
}
clone(cloneModifier: boolean = true): IHeroAttribute<THero> {
setModifierSaveEnabled(modifier: IHeroModifier, save: boolean): void {
if (save) {
this.modifierNosave.delete(modifier);
} else {
this.modifierNosave.add(modifier);
}
}
getModifierSaveEnabled(modifier: IHeroModifier): boolean {
return !this.modifierNosave.has(modifier);
}
//#endregion
//#region 属性克隆
clone(
option: Readonly<Partial<IHeroAttributeCloneOption>> = {}
): IHeroAttribute<THero> {
const { cloneModifier = true } = option;
const cloned = new HeroAttribute<THero>(
structuredClone(this.attribute)
);
if (!cloneModifier) return cloned;
// 拷贝修饰器
for (const [modifier, name] of this.modifierName) {
cloned.addModifier(
name,
modifier.clone() as IHeroModifier<THero[keyof THero]>
);
for (const [name, modifiers] of this.modifier) {
const arr: IHeroModifier[] = modifiers.map(v => v.clone());
cloned.modifier.set(name, arr);
cloned.recalculateAttribute(name);
}
return cloned;
}
@ -161,4 +269,10 @@ export class HeroAttribute<THero> implements IHeroAttribute<THero> {
getModifiableClone(): IHeroAttribute<THero> {
return this.clone();
}
toStructured(): THero {
return structuredClone(this.attribute);
}
//#endregion
}

View File

@ -0,0 +1,287 @@
import { IItemRawData, SaveCompression, IDataCommon } from '@user/data-common';
import { isNil, maxBy } from 'lodash-es';
import { ValueModifier, PercentageModifier } from './modifier';
import {
IEquipmentState,
IHeroModifier,
IEquipmentStateSave,
IHeroEquipsStore,
IEquipmentSorter,
IEquipmentSortHandler,
IHeroEquipsStoreSave
} from './types';
import { logger } from '@motajs/common';
export class EquipmentState<THero> implements IEquipmentState<THero> {
readonly item: IItemRawData<THero>;
/** 装备产生的所有修饰器,每个元素为 [属性名, 修饰器] */
readonly modifiers: [SelectKey<THero, number>, IHeroModifier<number>][];
/** 装备的数值属性 */
private readonly value: Map<SelectKey<THero, number>, number>;
/** 装备的百分比属性 */
private readonly percentage: Map<SelectKey<THero, number>, number>;
constructor(
readonly uid: number,
item: IItemRawData<THero>,
buildModifier: boolean = true
) {
this.item = item;
this.modifiers = [];
const equip = item.equip;
this.value = new Map(equip.value);
this.percentage = new Map(equip.percentage);
if (buildModifier) {
this.rebuildModifiers();
}
}
/**
*
*/
private rebuildModifiers() {
this.modifiers.length = 0;
for (const [name, baseVal] of this.value) {
this.modifiers.push([name, new ValueModifier(baseVal)]);
}
for (const [name, basePct] of this.percentage) {
this.modifiers.push([name, new PercentageModifier(basePct)]);
}
}
getModifiers(): Iterable<
[keyof SelectType<THero, number>, IHeroModifier<number>]
> {
return this.modifiers;
}
/**
* NoCompression
* @returns
*/
private saveNoCompression(): IEquipmentStateSave<THero> {
return {
uid: this.uid,
num: this.item.num,
value: new Map(this.value),
percentage: new Map(this.percentage)
};
}
/**
* Low / High
* @returns
*/
private saveDiff(): IEquipmentStateSave<THero> {
const { value, percentage } = this.item.equip;
const valueDiff = new Map<SelectKey<THero, number>, number>();
for (const [name, equipValue] of this.value) {
const base = value.get(name);
if (base !== equipValue) {
valueDiff.set(name, equipValue);
}
}
const perDiff = new Map<SelectKey<THero, number>, number>();
for (const [name, equipPer] of this.percentage) {
const base = percentage.get(name);
if (base !== equipPer) {
perDiff.set(name, equipPer);
}
}
return {
uid: this.uid,
num: this.item.num,
value: valueDiff,
percentage: perDiff
};
}
saveState(compression: SaveCompression): IEquipmentStateSave<THero> {
if (compression === SaveCompression.NoCompression) {
return this.saveNoCompression();
} else {
return this.saveDiff();
}
}
/**
* NoCompression 使
* @param state
*/
private loadNoCompression(state: IEquipmentStateSave<THero>): void {
this.value.clear();
this.percentage.clear();
for (const [name, value] of state.percentage) {
this.value.set(name, value);
}
for (const [name, value] of state.percentage) {
this.percentage.set(name, value);
}
this.rebuildModifiers();
}
/**
* Low / High 退
* @param state
*/
private loadDiff(state: IEquipmentStateSave<THero>): void {
this.value.clear();
this.percentage.clear();
for (const [name, value] of state.percentage) {
this.value.set(name, value);
}
for (const [name, value] of state.percentage) {
this.percentage.set(name, value);
}
// 差异内容
for (const [name, value] of state.value) {
this.value.set(name, value);
}
for (const [name, value] of state.percentage) {
this.percentage.set(name, value);
}
this.rebuildModifiers();
}
loadState(
state: IEquipmentStateSave<THero>,
compression: SaveCompression
): void {
if (compression === SaveCompression.NoCompression) {
this.loadNoCompression(state);
} else {
this.loadDiff(state);
}
}
}
export class HeroEquipsStore<THero> implements IHeroEquipsStore<THero> {
/** 装备实例存储表 */
private readonly instanceMap: Map<number, EquipmentState<THero>> =
new Map();
/** 自增 uid 计数器 */
private nextUid: number = 0;
/** 排序器 */
private sorter: IEquipmentSorter<THero> | null = null;
readonly state: IDataCommon;
constructor(state: IDataCommon) {
this.state = state;
}
add(item: number | string): number {
const num = this.state.tileStore.num(item);
if (isNil(num)) return -1;
const raw = this.state.itemStore.getData(num!);
if (!raw) return -1;
const uid = this.nextUid++;
const state = new EquipmentState<THero>(uid, raw);
this.instanceMap.set(uid, state);
return uid;
}
delete(uid: number): void {
this.instanceMap.delete(uid);
}
get(uid: number): IEquipmentState<THero> | null {
return this.instanceMap.get(uid) ?? null;
}
count(item: number | string): number {
const num = this.state.tileStore.num(item);
if (isNil(num)) return 0;
let count = 0;
for (const state of this.instanceMap.values()) {
if (state.item.num === num) count++;
}
return count;
}
useSorter(comparer: IEquipmentSorter<THero> | null): void {
this.sorter = comparer;
}
/**
* uid
* @param list
* @returns
*/
private sortList(list: EquipmentState<THero>[]): EquipmentState<THero>[] {
const sorter = this.sorter;
if (sorter) {
return list.sort((a, b) => {
const handler: IEquipmentSortHandler<THero> = {
equipA: a,
equipB: b,
store: this,
state: this.state
};
const cmp = sorter.compare(handler);
if (cmp === 0) {
return a.uid - b.uid;
} else {
return cmp;
}
});
} else {
return list.sort((a, b) => a.uid - b.uid);
}
}
instancesOf(equip: number | string): IEquipmentState<THero>[] {
const num = this.state.tileStore.num(equip);
if (isNil(num)) return [];
return this.sortList(
[...this.instanceMap.values()].filter(v => v.item.num === num)
);
}
instances(): IEquipmentState<THero>[] {
return this.sortList([...this.instanceMap.values()]);
}
saveState(compression: SaveCompression): IHeroEquipsStoreSave<THero> {
const equipments: IEquipmentStateSave<THero>[] = [];
for (const state of this.instanceMap.values()) {
equipments.push(state.saveState(compression));
}
return { equipments };
}
loadState(
state: IHeroEquipsStoreSave<THero>,
compression: SaveCompression
): void {
this.instanceMap.clear();
for (const save of state.equipments) {
const raw = this.state.itemStore.getData(save.num);
if (!raw) {
logger.error(59, save.num.toString());
continue;
}
const instance = new EquipmentState<THero>(save.uid, raw, false);
instance.loadState(save, compression);
this.instanceMap.set(save.uid, instance);
}
const maxUid = maxBy(state.equipments, 'uid');
if (!maxUid) {
logger.error(58);
return;
}
this.nextUid = maxUid.uid + 1;
}
}

View File

@ -0,0 +1,313 @@
import { isNil } from 'lodash-es';
import { IDataCommon } from '@user/data-common';
import {
EquipStatus,
IEquipmentState,
IHeroAttribute,
IHeroEquipment,
IHeroEquipmentSave,
IHeroEquipsStore
} from './types';
import { logger } from '@motajs/common';
export class HeroEquipment<THero> implements IHeroEquipment<THero> {
/** 当前已装备的装备,键表示装备槽索引,值表示装备至这个装备槽的装备 uid */
private readonly equips: Map<number, number> = new Map();
slots: string[] = [];
readonly state: IDataCommon;
constructor(
private readonly store: IHeroEquipsStore<THero>,
private readonly attribute: IHeroAttribute<THero>
) {
this.state = this.store.state;
}
setSlots(slots: string[]): void {
this.slots = slots;
}
canEquipTo(uid: number, slot: number | string): EquipStatus {
const state = this.store.get(uid);
if (!state) return EquipStatus.CannotEquip;
const raw = state.item;
const slots = raw.equip.slots;
// 如果装备压根不支持这个槽位
if (!slots.includes(slot)) {
return EquipStatus.CannotEquip;
}
if (typeof slot === 'number') {
// 如果传入的 slot 是指定索引
if (!this.equips.has(slot)) {
return EquipStatus.CanEquip;
} else {
return EquipStatus.NeedReplace;
}
} else {
// 如果传入的 slot 是槽位名称,那么要对每个槽位都判断
let hasEmpty = false;
let hasSlot = false;
this.slots.forEach((name, index) => {
if (slots.includes(name) && !this.equips.has(index)) {
hasEmpty = true;
}
if (name === slot) {
hasSlot = true;
}
});
if (hasSlot) {
if (hasEmpty) {
return EquipStatus.CanEquip;
} else {
return EquipStatus.NeedReplace;
}
} else {
return EquipStatus.CannotEquip;
}
}
}
//#region 装备行为
/**
*
* @param state
*/
private loadEquipEffect(state: IEquipmentState<THero>) {
for (const [name, modifier] of state.getModifiers()) {
// @ts-expect-error 泛型无法推导
this.attribute.addModifier(name, modifier, true);
}
}
/**
*
* @param state
*/
private unloadEquipEffect(state: IEquipmentState<THero>) {
for (const [name, modifier] of state.getModifiers()) {
// @ts-expect-error 泛型无法推导
this.attribute.deleteModifier(name, modifier);
}
}
equip(
uid: number,
slot: number | string,
autoUnload: boolean = true
): number | undefined {
if (this.canEquipTo(uid, slot) === EquipStatus.CannotEquip) {
return void 0;
}
// 检查有没有同 uid 装备
for (const [index, curr] of this.equips) {
if (curr !== uid) continue;
if (index === slot || this.slots[index] === slot) {
// 指定装备已经装备至了指定装备槽,直接忽略
return void 0;
} else {
// 否则看 autoUnload
if (autoUnload) {
this.unequip(index);
break;
} else {
return void 0;
}
}
}
const state = this.store.get(uid);
if (!state) {
logger.warn(146, uid.toString());
return void 0;
}
if (typeof slot === 'number') {
// 数字槽位,直接进行指定替换
const curr = this.equips.get(slot);
this.unequip(slot);
this.equips.set(slot, uid);
this.loadEquipEffect(state);
return curr;
} else {
// 字符串槽位,需要判断是否包含空槽
let first = -1;
let empty = -1;
this.slots.forEach((name, index) => {
if (name !== slot) return;
if (first === -1) first = index;
if (empty !== -1 && !this.equips.has(index)) {
empty = index;
}
});
if (empty === -1) {
// 无空槽,替换第一个匹配的槽位
if (first === -1) {
logger.warn(147, uid.toString());
return void 0;
} else {
const curr = this.equips.get(first);
this.unequip(first);
this.equips.set(first, uid);
this.loadEquipEffect(state);
return curr;
}
} else {
// 此时有空槽,直接装备上就行
this.equips.set(empty, uid);
this.loadEquipEffect(state);
return void 0;
}
}
}
unequip(slot: number): number | undefined {
const uid = this.equips.get(slot);
if (isNil(uid)) return void 0;
const state = this.store.get(uid);
if (!state) {
logger.warn(146, uid.toString());
return void 0;
}
this.unloadEquipEffect(state);
this.equips.delete(slot);
return uid;
}
//#endregion
//#region 装备获取
equipped(uid: number): boolean {
return [...this.equips.values()].includes(uid);
}
getEquipped(slot: number): number | undefined {
return this.equips.get(slot);
}
getEquips(): (IEquipmentState<THero> | null)[] {
const result: (IEquipmentState<THero> | null)[] = [];
for (let i = 0; i < this.slots.length; i++) {
const uid = this.equips.get(i);
if (isNil(uid)) {
result.push(null);
} else {
const state = this.store.get(uid);
if (!state) {
logger.warn(146, uid.toString());
result.push(null);
} else {
result.push(state);
}
}
}
return result;
}
//#endregion
//#region 装备对比
compareEquip(
equipA: number,
equipB: number,
slot: number
): Readonly<Partial<THero>> {
const stateA = this.store.get(equipA);
const stateB = this.store.get(equipB);
if (!stateA) {
logger.warn(146, equipA.toString());
return {} as Partial<THero>;
}
if (!stateB) {
logger.warn(146, equipB.toString());
return {} as Partial<THero>;
}
const clone = this.attribute.clone();
// 获取比对槽位的装备对应的所有修饰器,这些修饰器需要在克隆对象中删除
const equipped = this.getEquipped(slot);
if (!isNil(equipped)) {
const state = this.store.get(equipped);
if (!state) {
logger.warn(146, equipped.toString());
return {} as Partial<THero>;
}
for (const [name, modifier] of state.getModifiers()) {
const index = this.attribute.getModifierIndex(modifier);
clone.deleteModifierByIndex(name, index);
}
}
// 分别将两个装备的修饰器克隆并加入到克隆属性上对比
const attrA: Partial<THero> = {};
const attrB: Partial<THero> = {};
const keys = new Set<SelectKey<THero, number>>();
for (const [name, modifier] of stateA.getModifiers()) {
// @ts-expect-error 泛型无法推导
clone.addModifier(name, modifier);
}
for (const [name] of stateA.getModifiers()) {
attrA[name] = clone.getFinalAttribute(name);
keys.add(name);
}
for (const [name, modifier] of stateA.getModifiers()) {
// @ts-expect-error 泛型无法推导
clone.deleteModifier(name, modifier);
}
for (const [name, modifier] of stateB.getModifiers()) {
// @ts-expect-error 泛型无法推导
clone.addModifier(name, modifier);
}
for (const [name] of stateB.getModifiers()) {
attrB[name] = clone.getFinalAttribute(name);
keys.add(name);
}
// 第二次没必要再删除了,因为这个 clone 对象不会再被使用到
// 然后收集键名并对比
const diff: Partial<THero> = {};
for (const key of keys) {
const final = this.attribute.getFinalAttribute(key);
const valueA = (attrA[key] as number) ?? final;
const valueB = (attrB[key] as number) ?? final;
diff[key] = (valueA - valueB) as THero[SelectKey<THero, number>];
}
return diff;
}
//#endregion
saveState(): IHeroEquipmentSave {
return {
equipped: this.equips,
slots: this.slots
};
}
loadState(state: IHeroEquipmentSave): void {
this.slots.length = 0;
state.slots.forEach(v => {
this.slots.push(v);
});
// 由于装备修饰器不进存档,所以此时的勇士处于没有任何装备修饰器的状态,故可以安全清除
this.equips.clear();
for (const [index, uid] of state.equipped) {
this.equip(uid, index);
}
}
}

View File

@ -0,0 +1,217 @@
import {
Hookable,
HookController,
IFacedTileLocator,
IHookController,
logger
} from '@motajs/common';
import {
FaceDirection,
IDataCommon,
IFaceHandler,
SaveCompression
} from '@user/data-common';
import { HeroLocation } from './location';
import { HeroRendering } from './rendering';
import {
IHeroFollower,
IHeroFollowerSave,
IHeroFollowersController,
IHeroFollowersControllerHooks,
IHeroLocation,
IHeroRendering
} from './types';
import { isNil } from 'lodash-es';
export class HeroFollower implements IHeroFollower {
readonly num: number;
readonly state: IDataCommon;
readonly rendering: IHeroRendering;
readonly location: IHeroLocation;
/** 所属的跟随者控制器,用于获取相邻跟随者 */
private readonly controller: IHeroFollowersController;
constructor(
num: number | string,
loc: IFacedTileLocator,
faceHandler: IFaceHandler<FaceDirection>,
controller: IHeroFollowersController
) {
const state = controller.state;
if (typeof num === 'string') {
const n = state.tileStore.idToNumber(num);
if (isNil(n)) {
logger.warn(142);
this.num = 0;
} else {
this.num = n;
}
} else {
this.num = num;
}
this.state = state;
this.rendering = new HeroRendering(state);
this.location = new HeroLocation(state, loc, faceHandler);
this.controller = controller;
}
next(): IHeroFollower | null {
const all = this.controller.getAllFollowers();
const myIndex = all.indexOf(this);
if (myIndex === -1) return null;
return this.controller.getFollower(myIndex + 1);
}
last(): IHeroFollower | null {
const all = this.controller.getAllFollowers();
const myIndex = all.indexOf(this);
if (myIndex === -1) return null;
return this.controller.getFollower(myIndex - 1);
}
saveState(compression: SaveCompression): IHeroFollowerSave {
return {
num: this.num,
rendering: this.rendering.saveState(compression),
location: this.location.saveState(compression)
};
}
loadState(state: IHeroFollowerSave, compression: SaveCompression): void {
this.rendering.loadState(state.rendering, compression);
this.location.loadState(state.location, compression);
}
}
export class HeroFollowersController
extends Hookable<IHeroFollowersControllerHooks>
implements IHeroFollowersController
{
readonly state: IDataCommon;
/** 跟随者列表 */
private readonly followers: HeroFollower[] = [];
/** 勇士位置对象,用于获取聚集目标位置 */
private readonly heroLocation: IHeroLocation;
/** 朝向处理器,传递给新创建的跟随者 */
private readonly faceHandler: IFaceHandler<FaceDirection>;
constructor(
state: IDataCommon,
heroLocation: IHeroLocation,
faceHandler: IFaceHandler<FaceDirection>
) {
super();
this.state = state;
this.heroLocation = heroLocation;
this.faceHandler = faceHandler;
}
protected createController(
hook: Partial<IHeroFollowersControllerHooks>
): IHookController<IHeroFollowersControllerHooks> {
return new HookController(this, hook);
}
addFollower(num: number | string): IHeroFollower {
const loc: IFacedTileLocator = {
x: this.heroLocation.x,
y: this.heroLocation.y,
direction: this.heroLocation.mover.faceDirection
};
const follower = new HeroFollower(num, loc, this.faceHandler, this);
this.followers.push(follower);
const index = this.followers.length - 1;
this.forEachHook(hook => {
hook.onAddFollower?.(follower, index);
});
return follower;
}
getFollower(index: number): IHeroFollower | null {
return this.followers[index] ?? null;
}
*getFollowersById(
num: number | string
): IterableIterator<[number, IHeroFollower]> {
const store = this.state.tileStore;
const target = typeof num === 'string' ? store.idToNumber(num)! : num;
for (let i = 0; i < this.followers.length; i++) {
if (this.followers[i].num === target) {
yield [i, this.followers[i]];
}
}
}
getAllFollowers(): IHeroFollower[] {
return this.followers.slice();
}
async removeFollower(index: number): Promise<void> {
const removed = this.followers.splice(index, 1);
if (removed.length > 0) {
this.forEachHook(hook => {
hook.onRemoveFollower?.(removed[0], index);
});
}
}
async removeAllFollowers(): Promise<void> {
const snapshot = this.followers.slice();
this.followers.length = 0;
this.forEachHook(hook => {
snapshot.forEach((v, i) => {
hook.onRemoveFollower?.(v, i);
});
});
}
async gatherFollowers(): Promise<void> {
const { x, y, mover } = this.heroLocation;
for (let i = 0; i < this.followers.length; i++) {
const follower = this.followers[i];
follower.location.mover.clear();
let skip = false;
for (let j = i - 1; j >= 0; j--) {
const dir = this.followers[j].location.mover.moveDirection;
if (dir === FaceDirection.Unknown) {
follower.location.mover.clear();
follower.location.mover.tp(x, y);
follower.location.mover.face(mover.faceDirection);
skip = true;
break;
} else {
follower.location.mover.step(dir);
}
}
if (!skip) {
follower.location.mover.step(mover.moveDirection);
}
}
const controllers = this.followers.map(
v => v.location.mover.start()?.onEnd
);
await Promise.all(controllers);
this.forEachHook(hook => {
hook.onGatherFollowers?.(false);
});
}
gatherFollowersSync(): void {
const { x, y } = this.heroLocation;
const dir = this.heroLocation.mover.faceDirection;
for (const follower of this.followers) {
const mover = follower.location.mover;
mover.setPos(x, y);
mover.setFaceDir(dir);
}
this.forEachHook(hook => {
hook.onGatherFollowers?.(true);
});
}
}

View File

@ -1,4 +1,10 @@
export * from './attribute';
export * from './equipment';
export * from './follower';
export * from './items';
export * from './location';
export * from './modifier';
export * from './rendering';
export * from './mover';
export * from './state';
export * from './types';

View File

@ -0,0 +1,189 @@
import { isNil } from 'lodash-es';
import { IDataCommon, ItemCategory, SaveCompression } from '@user/data-common';
import { HeroEquipsStore } from './equipStore';
import {
IHeroItems,
IHeroItemSave,
IHeroItemsSave,
IHeroItemState
} from './types';
export class HeroItems<THero> implements IHeroItems<THero> {
/** 永久道具 */
private readonly constants: Map<number, IHeroItemState<THero>> = new Map();
/** 消耗道具 */
private readonly consumables: Map<number, IHeroItemState<THero>> =
new Map();
readonly equipment: HeroEquipsStore<THero>;
constructor(readonly state: IDataCommon) {
this.equipment = new HeroEquipsStore<THero>(state);
}
/**
* item num id tileStore num
* @param item id
*/
private resolveNum(item: number | string): number | undefined {
if (typeof item === 'number') return item;
return this.state.tileStore.idToNumber(item);
}
/**
*
* @param category
*/
private getMap(category: ItemCategory): Map<number, IHeroItemState<THero>> {
switch (category) {
case ItemCategory.Constant:
return this.constants;
case ItemCategory.Consumable:
return this.consumables;
default:
return this.constants;
}
}
/**
* 使
* @param item id
*/
private internalGetItemState(
item: number | string
): IHeroItemState<THero> | null {
const num = this.resolveNum(item);
if (isNil(num)) return null;
const category = this.state.itemStore.getCategory(num);
const map = this.getMap(category);
return map.get(num) ?? null;
}
getItemState(
item: number | string
): Readonly<IHeroItemState<THero>> | null {
return this.internalGetItemState(item);
}
itemCount(item: number | string): number {
return this.getItemState(item)?.count ?? 0;
}
addItem(item: number | string, count: number = 1): void {
const num = this.resolveNum(item);
if (isNil(num)) return;
const raw = this.state.itemStore.getData(num);
if (!raw) return;
if (raw.category === ItemCategory.Pick) {
for (let i = 0; i < count; i++) {
raw.effect.useEffect(raw);
}
return;
}
if (raw.category === ItemCategory.Equipment) {
for (let i = 0; i < count; i++) {
this.equipment.add(item);
}
return;
}
const map = this.getMap(raw.category);
const existing = map.get(num);
if (existing) {
existing.count += count;
if (existing.count <= 0) {
map.delete(num);
}
} else if (count > 0) {
map.set(num, { id: raw.id, num: raw.num, raw, count });
}
}
getItem(item: number | string): void {
this.addItem(item, 1);
}
useItem(item: number | string): boolean {
const state = this.internalGetItemState(item);
if (!state) return false;
const { raw } = state;
if (
raw.category !== ItemCategory.Constant &&
raw.category !== ItemCategory.Consumable
) {
return false;
}
if (!raw.effect.canUse(raw)) return false;
raw.effect.useEffect(raw);
if (raw.category === ItemCategory.Consumable) {
state.count--;
if (state.count <= 0) {
this.consumables.delete(raw.num);
}
}
return true;
}
/**
*
* @param map
*/
private mapToSave(
map: Map<number, IHeroItemState<THero>>
): readonly IHeroItemSave[] {
const result: IHeroItemSave[] = [];
for (const state of map.values()) {
result.push({ num: state.num, count: state.count });
}
return result;
}
/**
*
* @param map
* @param saves
*/
private loadMap(
map: Map<number, IHeroItemState<THero>>,
saves: readonly IHeroItemSave[]
): void {
for (const save of saves) {
const raw = this.state.itemStore.getData(save.num);
if (!raw) continue;
map.set(save.num, {
id: raw.id,
num: raw.num,
raw,
count: save.count
});
}
}
saveState(compression: SaveCompression): IHeroItemsSave<THero> {
return {
constants: this.mapToSave(this.constants),
consumables: this.mapToSave(this.consumables),
equipStore: this.equipment.saveState(compression)
};
}
loadState(
state: IHeroItemsSave<THero>,
compression: SaveCompression
): void {
this.constants.clear();
this.consumables.clear();
this.loadMap(this.constants, state.constants);
this.loadMap(this.consumables, state.consumables);
this.equipment.loadState(state.equipStore, compression);
}
}

View File

@ -0,0 +1,51 @@
import { IFacedTileLocator } from '@motajs/common';
import { FaceDirection, IDataCommon, IFaceHandler } from '@user/data-common';
import { HeroMover } from './mover';
import { IHeroLocation, IHeroLocationSave, IHeroMover } from './types';
export class HeroLocation implements IHeroLocation {
x: number;
y: number;
floorId: string | undefined = undefined;
readonly state: IDataCommon;
readonly mover: IHeroMover<this>;
constructor(
state: IDataCommon,
loc: IFacedTileLocator,
faceHandler: IFaceHandler<FaceDirection>
) {
this.state = state;
this.x = loc.x;
this.y = loc.y;
const mover = new HeroMover(this, faceHandler);
mover.faceDirection = loc.direction;
this.mover = mover;
}
setPos(x: number, y: number): void {
this.x = x;
this.y = y;
}
getCurrentFaceDirection(): FaceDirection {
return this.mover.faceDirection;
}
saveState(): IHeroLocationSave {
return {
x: this.x,
y: this.y,
direction: this.mover.faceDirection,
floorId: this.floorId
};
}
loadState(state: IHeroLocationSave): void {
this.x = state.x;
this.y = state.y;
this.floorId = state.floorId;
this.mover.setFaceDir(state.direction);
}
}

View File

@ -0,0 +1,40 @@
import { BaseHeroModifier } from './attribute';
import { IHeroModifier } from './types';
export class ValueModifier extends BaseHeroModifier<number, number> {
readonly type = '@system/value';
constructor(
value: number,
readonly priority: number = 0
) {
super(value);
}
modify(value: number): number {
return value + this.value;
}
clone(): IHeroModifier<number, number> {
return new ValueModifier(this.value, this.priority);
}
}
export class PercentageModifier extends BaseHeroModifier<number, number> {
readonly type = '@system/percentage';
constructor(
value: number,
readonly priority: number = 10
) {
super(value);
}
modify(value: number, baseValue: number): number {
return value + baseValue * this.value;
}
clone(): IHeroModifier<number, number> {
return new PercentageModifier(this.value, this.priority);
}
}

View File

@ -1,138 +1,222 @@
import { Hookable, HookController, IHookController } from '@motajs/common';
import { ITileLocator, logger } from '@motajs/common';
import {
FaceDirection,
IFaceHandler,
IMoverController,
ObjectMoveStep,
ObjectMover,
ObjectMoveType,
IDataCommon
} from '@user/data-common';
import {
HeroMoveCode,
IHeroLocation,
IHeroMover,
IHeroMoverConfig,
IHeroMoveTopHandler,
IHeroMoveTopImpl
} from './types';
import { isNil } from 'lodash-es';
import { getFaceMovement, nextFaceDirection } from '../common/utils';
import { IHeroFollower, IHeroMover, IHeroMovingHooks } from './types';
import { FaceDirection } from '../common';
const DEFAULT_HERO_IMAGE: ImageIds = 'hero.png';
export class HeroMover
extends Hookable<IHeroMovingHooks>
implements IHeroMover
export class HeroMover<T extends IHeroLocation>
extends ObjectMover<T>
implements IHeroMover<T>
{
x: number = 0;
y: number = 0;
direction: FaceDirection = FaceDirection.Down;
image: ImageIds = DEFAULT_HERO_IMAGE;
readonly state: IDataCommon;
/** 当前勇士是否正在移动 */
moving: boolean = false;
alpha: number = 1;
/** 是否不记录进路线系统 */
private noRoute: boolean = false;
/** 是否忽略地形碰撞检测 */
private ignoreTerrain: boolean = false;
/** 是否在特定时机触发自动存档 */
private autoSave: boolean = false;
/** 是否允许到达地图外 */
private allowOutBound: boolean = false;
readonly followers: IHeroFollower[] = [];
/** 勇士移动顶层实现对象 */
private topImpl: IHeroMoveTopImpl | null = null;
protected createController(
hook: Partial<IHeroMovingHooks>
): IHookController<IHeroMovingHooks> {
return new HookController(this, hook);
constructor(
readonly tile: T,
faceHandler: IFaceHandler<FaceDirection>
) {
super(faceHandler);
this.state = tile.state;
}
setPosition(x: number, y: number): void {
this.x = x;
this.y = y;
this.forEachHook(hook => {
hook.onSetPosition?.(x, y);
});
config(config: Partial<IHeroMoverConfig>): this {
if (!isNil(config.noRoute)) {
this.noRoute = config.noRoute;
}
if (!isNil(config.ignoreTerrain)) {
this.ignoreTerrain = config.ignoreTerrain;
}
if (!isNil(config.autoSave)) {
this.autoSave = config.autoSave;
}
if (!isNil(config.allowOutBound)) {
this.allowOutBound = config.allowOutBound;
}
return this;
}
turn(direction?: FaceDirection): void {
const next = isNil(direction)
? nextFaceDirection(this.direction)
: direction;
this.direction = next;
this.forEachHook(hook => {
hook.onTurnHero?.(next);
});
getConfig(): Readonly<IHeroMoverConfig> {
return {
noRoute: this.noRoute,
ignoreTerrain: this.ignoreTerrain,
autoSave: this.autoSave,
allowOutBound: this.allowOutBound
};
}
startMove(): void {
this.moving = true;
this.forEachHook(hook => {
hook.onStartMove?.();
});
useTopImplementation(impl: IHeroMoveTopImpl | null): void {
this.topImpl = impl;
}
async move(dir: FaceDirection, time: number = 100): Promise<void> {
await Promise.all(
this.forEachHook(hook => {
return hook.onMoveHero?.(dir, time);
})
);
const { x, y } = getFaceMovement(dir);
this.x += x;
this.y += y;
/**
*
* @param curr
* @param dir
* @param floorId id
*/
private createHandler(
curr: ITileLocator,
step: Readonly<ObjectMoveStep>,
floorId: string | undefined
): IHeroMoveTopHandler {
switch (step.type) {
case ObjectMoveType.Dir:
case ObjectMoveType.DirFace:
case ObjectMoveType.Special: {
const { x, y } = this.faceHandler.movement(this.moveDirection);
const nx = curr.x + x;
const ny = curr.y + y;
return {
currLoc: { x: curr.x, y: curr.y },
nextLoc: { x: nx, y: ny },
direction: this.moveDirection,
floorId,
face: this.faceHandler,
state: this.state
};
}
case ObjectMoveType.Jump:
case ObjectMoveType.Teleport: {
const nx = step.rel ? curr.x + step.x : step.x;
const ny = step.rel ? curr.y + step.y : step.y;
return {
currLoc: { x: curr.x, y: curr.y },
nextLoc: { x: nx, y: ny },
direction: this.moveDirection,
floorId,
face: this.faceHandler,
state: this.state
};
}
case ObjectMoveType.AnimDir:
case ObjectMoveType.Face:
case ObjectMoveType.Speed: {
return {
currLoc: { x: curr.x, y: curr.y },
nextLoc: { x: curr.x, y: curr.y },
direction: this.moveDirection,
floorId,
face: this.faceHandler,
state: this.state
};
}
}
}
async endMove(): Promise<void> {
if (!this.moving) return;
await Promise.all(
this.forEachHook(hook => {
return hook.onEndMove?.();
})
);
this.moving = false;
protected async onMoveStart(): Promise<void> {
if (!this.topImpl) {
logger.warn(144);
}
}
async jumpHero(
x: number,
y: number,
time: number = 500,
waitFollower: boolean = false
): Promise<void> {
await Promise.all(
this.forEachHook(hook => {
return hook.onJumpHero?.(x, y, time, waitFollower);
})
);
this.x = x;
this.y = y;
protected async onMoveEnd(): Promise<void> {}
protected async onStepStart(
step: Readonly<ObjectMoveStep>,
tile: IHeroLocation
): Promise<number> {
if (!this.topImpl) return HeroMoveCode.Stop;
const type = step.type;
const handler = this.createHandler(tile, step, tile.floorId);
// 边界判断
const { x: nx, y: ny } = handler.nextLoc;
const inBound = this.topImpl.inBound(nx, ny, handler.floorId);
if (!this.allowOutBound && !inBound) return HeroMoveCode.CannotMove;
// 移动步判断
if (
type === ObjectMoveType.Dir ||
type === ObjectMoveType.DirFace ||
type === ObjectMoveType.Special
) {
if (this.ignoreTerrain) {
return HeroMoveCode.Step;
} else {
const canPass = this.topImpl.canPass(handler);
if (canPass) {
const hit = this.topImpl.shouldHit(handler);
if (hit) return HeroMoveCode.Hit;
else return HeroMoveCode.Step;
} else {
return HeroMoveCode.CannotMove;
}
}
}
// 跳跃和瞬移
if (type === ObjectMoveType.Jump || type === ObjectMoveType.Teleport) {
if (this.ignoreTerrain) {
return HeroMoveCode.Step;
} else {
if (inBound) {
return HeroMoveCode.Step;
} else {
return HeroMoveCode.Stop;
}
}
}
// 其他的一律可以直接执行
return HeroMoveCode.Step;
}
setImage(image: ImageIds): void {
this.image = image;
this.forEachHook(hook => {
hook.onSetImage?.(image);
});
}
protected async onStepEnd(
code: number,
step: Readonly<ObjectMoveStep>,
tile: IHeroLocation,
controller: Readonly<IMoverController>
): Promise<ITileLocator> {
if (!this.topImpl) return { x: tile.x, y: tile.y };
setAlpha(alpha: number): void {
this.alpha = alpha;
this.forEachHook(hook => {
hook.onSetAlpha?.(alpha);
});
}
const handler = this.createHandler(tile, step, tile.floorId);
setFollowerAlpha(identifier: string, alpha: number): void {
const follower = this.followers.find(v => v.identifier === identifier);
if (!follower) return;
follower.alpha = alpha;
this.forEachHook(hook => {
hook.onSetFollowerAlpha?.(identifier, alpha);
});
}
// 不能移动或停止
if (code === HeroMoveCode.CannotMove || code === HeroMoveCode.Stop) {
// 这里不能 await因为其 Promise 会在当前步结束后兑现,如果 await 就会卡住
controller.stop();
return { x: tile.x, y: tile.y };
}
addFollower(follower: number, identifier: string): void {
this.followers.push({ num: follower, identifier, alpha: 1 });
this.forEachHook(hook => {
hook.onAddFollower?.(follower, identifier);
});
}
// 撞击时也使用当前位置,同时处理撞击行为
if (code === HeroMoveCode.Hit) {
await this.topImpl.hit(handler);
// 这里同样不能 await
controller.stop();
return { x: tile.x, y: tile.y };
}
removeFollower(identifier: string, animate: boolean = false): void {
const index = this.followers.findIndex(
v => v.identifier === identifier
);
if (index === -1) return;
this.followers.splice(index, 1);
this.forEachHook(hook => {
hook.onRemoveFollower?.(identifier, animate);
});
}
// 正常移动
if (code === HeroMoveCode.Step) {
return handler.nextLoc;
}
removeAllFollowers(): void {
this.followers.length = 0;
this.forEachHook(hook => {
hook.onRemoveAllFollowers?.();
});
return { x: tile.x, y: tile.y };
}
}

View File

@ -0,0 +1,41 @@
import { Hookable, HookController, IHookController } from '@motajs/common';
import { IDataCommon } from '@user/data-common';
import {
IHeroRendering,
IHeroRenderingHooks,
IHeroRenderingSave
} from './types';
export class HeroRendering
extends Hookable<IHeroRenderingHooks>
implements IHeroRendering
{
alpha: number = 1;
readonly state: IDataCommon;
constructor(state: IDataCommon) {
super();
this.state = state;
}
protected createController(
hook: Partial<IHeroRenderingHooks>
): IHookController<IHeroRenderingHooks> {
return new HookController(this, hook);
}
setAlpha(alpha: number): void {
this.alpha = alpha;
this.forEachHook(hook => {
hook.onSetAlpha?.(alpha);
});
}
saveState(): IHeroRenderingSave {
return { alpha: this.alpha };
}
loadState(state: IHeroRenderingSave): void {
this.setAlpha(state.alpha);
}
}

View File

@ -1,26 +1,74 @@
import { HeroAttribute } from './attribute';
import { HeroEquipment } from './equipment';
import { HeroFollowersController } from './follower';
import { HeroItems } from './items';
import { HeroLocation } from './location';
import { HeroRendering } from './rendering';
import {
IHeroAttribute,
IHeroMover,
IHeroFollowersController,
IHeroEquipment,
IHeroItems,
IHeroLocation,
IHeroModifier,
IHeroRendering,
IHeroState,
IHeroStateSave,
IModifierStateSave,
IReadonlyHeroAttribute
} from './types';
import {
FaceDirection,
IDataCommon,
IFaceHandler,
SaveCompression
} from '@user/data-common';
import { IFacedTileLocator, logger } from '@motajs/common';
export class HeroState<THero> implements IHeroState<THero> {
constructor(
public mover: IHeroMover,
public attribute: IHeroAttribute<THero>
) {}
/** 修饰器工厂函数注册表 */
private readonly registry: Map<
string,
<K extends keyof THero>() => IHeroModifier<THero[K]>
> = new Map();
attachMover(mover: IHeroMover): void {
this.mover = mover;
readonly location: IHeroLocation;
readonly rendering: IHeroRendering;
readonly followers: IHeroFollowersController;
readonly items: IHeroItems<THero>;
readonly equip: IHeroEquipment<THero>;
constructor(
state: IDataCommon,
faceHandler: IFaceHandler<FaceDirection>,
public attribute: IHeroAttribute<THero>
) {
this.rendering = new HeroRendering(state);
const defaultLoc: IFacedTileLocator = {
x: 0,
y: 0,
direction: FaceDirection.Down
};
this.location = new HeroLocation(state, defaultLoc, faceHandler);
this.followers = new HeroFollowersController(
state,
this.location,
faceHandler
);
this.items = new HeroItems(state);
this.equip = new HeroEquipment(this.items.equipment, this.attribute);
}
attachAttribute(attribute: IHeroAttribute<THero>): void {
this.attribute = attribute;
}
getHeroMover(): IHeroMover {
return this.mover;
getLocation(): IFacedTileLocator {
return {
x: this.location.x,
y: this.location.y,
direction: this.location.mover.faceDirection
};
}
getModifiableAttribute(): IHeroAttribute<THero> {
@ -34,4 +82,79 @@ export class HeroState<THero> implements IHeroState<THero> {
getIsolatedAttribute(): IHeroAttribute<THero> {
return this.attribute.getModifiableClone();
}
registerModifier(
type: string,
cons: <K extends keyof THero>() => IHeroModifier<THero[K]>
): void {
this.registry.set(type, cons);
}
createModifier<T, V>(type: string): IHeroModifier<T, V> | null {
const cons = this.registry.get(type);
if (!cons) {
logger.warn(116, type);
return null;
}
return cons() as IHeroModifier<T, V>;
}
createAndInsertModifier<K extends keyof THero, V>(
type: string,
name: K
): IHeroModifier<THero[K], V> | null {
const modifier = this.createModifier<THero[K], V>(type);
if (!modifier) return null;
this.attribute.addModifier(name, modifier);
return modifier;
}
saveState(compression: SaveCompression): IHeroStateSave<THero> {
const modifiers: IModifierStateSave<THero>[] = [];
for (const [name, modifier] of this.attribute.iterateModifiers()) {
if (!this.attribute.getModifierSaveEnabled(modifier)) continue;
modifiers.push({
name: name as keyof THero,
type: modifier.type,
state: modifier.saveState(compression)
});
}
const followerSaves = this.followers
.getAllFollowers()
.map(v => v.saveState(compression));
return {
attribute: this.attribute.toStructured(),
location: this.location.saveState(compression),
rendering: this.rendering.saveState(compression),
followers: followerSaves,
modifiers,
items: this.items.saveState(compression),
equip: this.equip.saveState(compression)
};
}
loadState(
state: IHeroStateSave<THero>,
compression: SaveCompression
): void {
const newAttribute = new HeroAttribute<THero>(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, compression);
newAttribute.addModifier(save.name, modifier);
}
this.attribute = newAttribute;
this.location.loadState(state.location, compression);
this.rendering.loadState(state.rendering, compression);
this.items.loadState(state.items, compression);
this.equip.loadState(state.equip, compression);
void this.followers.removeAllFollowers();
for (const save of state.followers) {
const follower = this.followers.addFollower(save.num);
follower.loadState(save, compression);
}
}
}

View File

@ -1,9 +1,28 @@
import { IHookBase, IHookable } from '@motajs/common';
import { FaceDirection } from '@user/data-state';
import {
IFacedTileLocator,
IHookBase,
IHookable,
ITileLocator
} from '@motajs/common';
import {
FaceDirection,
IDataCommonExtended,
IFaceHandler,
IItemRawData,
IObjectMovable,
IObjectMover,
ISaveableContent
} from '@user/data-common';
//#region 勇士属性
export interface IHeroModifier<T = unknown, V = unknown> {
export interface IHeroModifier<
H = unknown, // 属性值类型
V = unknown, // 修饰器参数类型
Save = unknown // 存档类型
> extends ISaveableContent<Save> {
/** 修饰器类型 */
readonly type: string;
/** 修饰器优先级 */
readonly priority: number;
/** 修饰器参数值 */
@ -34,12 +53,26 @@ export interface IHeroModifier<T = unknown, V = unknown> {
* @param baseValue
* @param name
*/
modify(value: T, baseValue: T, name: PropertyKey): T;
modify(value: H, baseValue: H, name: PropertyKey): H;
/**
*
*/
clone(): IHeroModifier<T, V>;
clone(): IHeroModifier<H, V>;
}
export interface IModifierStateSave<THero> {
/** 属性名称 */
readonly name: keyof THero;
/** 修饰器类型 */
readonly type: string;
/** 修饰器存档数据 */
readonly state: unknown;
}
export interface IHeroAttributeCloneOption {
/** 是否克隆属性修饰器,默认为 `true` */
cloneModifier: boolean;
}
export interface IReadonlyHeroAttribute<THero> {
@ -69,14 +102,53 @@ export interface IReadonlyHeroAttribute<THero> {
/**
*
* @param cloneModifier
* @param option
*/
clone(cloneModifier?: boolean): IReadonlyHeroAttribute<THero>;
clone(
option?: Readonly<Partial<IHeroAttributeCloneOption>>
): IHeroAttribute<THero>;
/**
*
* `clone()`
*/
getModifiableClone(): IHeroAttribute<THero>;
/**
*
*/
toStructured(): THero;
/**
*
*/
iterateModifiers(): Iterable<[PropertyKey, IHeroModifier]>;
/**
*
* @param name
*/
getModifiers<K extends keyof THero>(
name: K
): Iterable<IHeroModifier<THero[K]>>;
/**
*
* @param modifier
*/
getModifierIndex(modifier: IHeroModifier): number;
/**
*
* @param modifier
* @param save
*/
setModifierSaveEnabled(modifier: IHeroModifier, save: boolean): void;
/**
*
* @param modifier
*/
getModifierSaveEnabled(modifier: IHeroModifier): boolean;
}
export interface IHeroAttribute<THero> extends IReadonlyHeroAttribute<THero> {
@ -85,26 +157,39 @@ export interface IHeroAttribute<THero> extends IReadonlyHeroAttribute<THero> {
* @param name
* @param value
*/
setBaseAttribute<K extends keyof THero>(name: K, value: THero[K]): void;
set<K extends keyof THero>(name: K, value: THero[K]): void;
/**
*
* @param name
* @param value
*/
addBaseAttribute<K extends SelectKey<THero, number>>(
name: K,
value: number
): void;
add(name: SelectKey<THero, number>, value: number): void;
/**
*
* @param name
* @param value
*/
mul(name: SelectKey<THero, number>, value: number): void;
/**
*
* @param name
* @param value
*/
div(name: SelectKey<THero, number>, value: number): void;
/**
*
* @param name
* @param modifier
* @param save true
*/
addModifier<K extends keyof THero>(
name: K,
modifier: IHeroModifier<THero[K], unknown>
modifier: IHeroModifier<THero[K]>,
save?: boolean
): void;
/**
@ -114,240 +199,572 @@ export interface IHeroAttribute<THero> extends IReadonlyHeroAttribute<THero> {
*/
deleteModifier<K extends keyof THero>(
name: K,
modifier: IHeroModifier<THero[K], unknown>
modifier: IHeroModifier<THero[K]>
): void;
/**
*
* @param cloneModifier
*
* @param name
* @param index
* @returns
*/
clone(cloneModifier?: boolean): IHeroAttribute<THero>;
deleteModifierByIndex<K extends keyof THero>(
name: K,
index: number
): IHeroModifier<THero[K]> | null;
/**
*
*
* @param name
*/
catchCalculateProgress<K extends keyof THero>(
name: K
): Iterable<[IHeroModifier<THero[K]>, THero[K]]>;
}
//#endregion
//#region 勇士位置
export interface IHeroLocationSave {
/** 当前横坐标 */
readonly x: number;
/** 当前纵坐标 */
readonly y: number;
/** 当前朝向 */
readonly direction: FaceDirection;
/** 当前所在楼层 idundefined 表示尚不处于任何楼层 */
readonly floorId: string | undefined;
}
export interface IHeroLocation
extends
ISaveableContent<IHeroLocationSave>,
IObjectMovable,
IDataCommonExtended {
/** 当前所在楼层 idundefined 表示尚不处于任何楼层 */
readonly floorId: string | undefined;
/** 勇士的移动对象 */
readonly mover: IHeroMover<this>;
}
//#endregion
//#region 勇士移动
export const enum HeroAnimateDirection {
/** 正向播放动画 */
Forward,
/** 反向播放动画 */
Backward
export const enum HeroMoveCode {
/** 正常移动 */
Step,
/** 移动被停止 */
Stop,
/** 不能移动,并撞击目标格触发器 */
Hit,
/** 不能移动,不触发触发器 */
CannotMove
}
export interface IHeroFollower {
/** 跟随者的图块数字 */
readonly num: number;
/** 跟随者的标识符 */
readonly identifier: string;
/** 跟随者的不透明度 */
alpha: number;
}
export interface IHeroMovingHooks extends IHookBase {
/**
*
* @param x
* @param y
*/
onSetPosition(x: number, y: number): void;
/**
*
* @param direction
*/
onTurnHero(direction: FaceDirection): void;
/**
*
*/
onStartMove(): void;
/**
*
* @param direction
* @param time
*/
onMoveHero(direction: FaceDirection, time: number): Promise<void>;
/**
*
*/
onEndMove(): Promise<void>;
/**
*
* @param x
* @param y
* @param time
* @param waitFollower
*/
onJumpHero(
x: number,
y: number,
time: number,
waitFollower: boolean
): Promise<void>;
/**
*
* @param image id
*/
onSetImage(image: ImageIds): void;
/**
*
* @param alpha
*/
onSetAlpha(alpha: number): void;
/**
*
* @param follower
* @param identifier
*/
onAddFollower(follower: number, identifier: string): void;
/**
*
* @param identifier
* @param animate `true` 使
*/
onRemoveFollower(identifier: string, animate: boolean): void;
/**
*
*/
onRemoveAllFollowers(): void;
/**
*
* @param identifier
* @param alpha
*/
onSetFollowerAlpha(identifier: string, alpha: number): void;
}
export interface IHeroMover extends IHookable<IHeroMovingHooks> {
/** 勇士横坐标 */
readonly x: number;
/** 勇士纵坐标 */
readonly y: number;
/** 勇士朝向 */
export interface IHeroMoveTopHandler extends IDataCommonExtended {
/** 当前位置 */
readonly currLoc: ITileLocator;
/** 要移动至的位置 */
readonly nextLoc: ITileLocator;
/** 移动方向 */
readonly direction: FaceDirection;
/** 勇士图片 */
readonly image?: ImageIds;
/** 跟随者列表 */
readonly followers: readonly IHeroFollower[];
/** 勇士当前的不透明度 */
readonly alpha: number;
/** 当前楼层 id */
readonly floorId: string | undefined;
/** 朝向管理对象 */
readonly face: IFaceHandler<FaceDirection>;
}
export interface IHeroMoveTopImpl {
/**
*
*
* @param x
* @param y
* @param floorId id
*/
setPosition(x: number, y: number): void;
inBound(x: number, y: number, floorId: string | undefined): boolean;
/**
*
* @param direction
*
* @param handler
*/
turn(direction?: FaceDirection): void;
canPass(handler: IHeroMoveTopHandler): boolean;
/**
*
*
* @param handler
*/
startMove(): void;
shouldHit(handler: IHeroMoveTopHandler): boolean;
/**
*
* @param dir
* @param time 100ms
* @returns `Promise`
*
* @param handler
*/
move(dir: FaceDirection, time?: number): Promise<void>;
hit(handler: IHeroMoveTopHandler): Promise<void>;
}
export interface IHeroMoverConfig {
/** 是否不记录进路线系统 */
noRoute: boolean;
/** 是否忽略地形碰撞检测 */
ignoreTerrain: boolean;
/** 是否在特定时机触发自动存档 */
autoSave: boolean;
/** 是否允许到达地图外 */
allowOutBound: boolean;
}
export interface IHeroMover<T extends IHeroLocation>
extends IObjectMover<T>, IDataCommonExtended {
/**
*
* @param config
*/
config(config: Partial<IHeroMoverConfig>): this;
/**
*
* @returns `Promise`
*
*/
endMove(): Promise<void>;
getConfig(): Readonly<IHeroMoverConfig>;
/**
*
* @param x
* @param y
* @param time 500ms
* @param waitFollower
* @returns `Promise`
*
* @param impl
*/
jumpHero(
x: number,
y: number,
time?: number,
waitFollower?: boolean
): Promise<void>;
useTopImplementation(impl: IHeroMoveTopImpl | null): void;
}
//#endregion
//#region 勇士渲染
export interface IHeroRenderingSave {
/** 勇士的不透明度 */
readonly alpha: number;
}
export interface IHeroRenderingHooks extends IHookBase {
/**
*
* @param image id
*
* @param alpha
*/
setImage(image: ImageIds): void;
onSetAlpha?(alpha: number): void;
}
export interface IHeroRendering
extends
ISaveableContent<IHeroRenderingSave>,
IHookable<IHeroRenderingHooks>,
IDataCommonExtended {
/** 勇士的当前不透明度 */
readonly alpha: number;
/**
*
* @param alpha
*/
setAlpha(alpha: number): void;
}
//#endregion
//#region 勇士跟随者
export interface IHeroFollowerSave {
/** 跟随者图块数字 */
readonly num: number;
/** 跟随者渲染对象保存 */
readonly rendering: IHeroRenderingSave;
/** 跟随者位置保存 */
readonly location: IHeroLocationSave;
}
export interface IHeroFollower
extends ISaveableContent<IHeroFollowerSave>, IDataCommonExtended {
/** 跟随者的图块数字 */
readonly num: number;
/** 跟随者的渲染信息对象 */
readonly rendering: IHeroRendering;
/** 跟随者的位置对象 */
readonly location: IHeroLocation;
/**
*
* @param follower
* @param identifier
*
* @returns null
*/
addFollower(follower: number, identifier: string): void;
next(): IHeroFollower | null;
/**
*
* @param identifier
* @param animate `true` 使
*
* @returns null
*/
removeFollower(identifier: string, animate?: boolean): void;
last(): IHeroFollower | null;
}
export interface IHeroFollowersControllerHooks extends IHookBase {
/**
*
* @param follower
* @param index
*/
onAddFollower?(follower: IHeroFollower, index: number): void;
/**
*
*
* @param follower
* @param index
*/
removeAllFollowers(): void;
onRemoveFollower?(follower: IHeroFollower, index: number): void;
/**
*
* @param identifier
* @param alpha
*
* @param sync `gatherFollowersSync`
*/
setFollowerAlpha(identifier: string, alpha: number): void;
onGatherFollowers?(sync: boolean): void;
}
export interface IHeroFollowersController
extends IHookable<IHeroFollowersControllerHooks>, IDataCommonExtended {
/**
*
* @param num id
*/
addFollower(num: number | string): IHeroFollower;
/**
*
* @param index
*/
getFollower(index: number): IHeroFollower | null;
/**
* id
* @param num id
* @returns [, ]
*/
getFollowersById(num: number | string): Iterable<[number, IHeroFollower]>;
/**
*
*/
getAllFollowers(): IHeroFollower[];
/**
*
* @param index
*/
removeFollower(index: number): Promise<void>;
/**
*
*/
removeAllFollowers(): Promise<void>;
/**
*
*/
gatherFollowers(): Promise<void>;
/**
*
*/
gatherFollowersSync(): void;
}
//#endregion
//#region 勇士道具
export interface IHeroItemSave {
/** 道具图块数字 */
readonly num: number;
/** 道具持有数量 */
readonly count: number;
}
export interface IHeroItemsSave<THero> {
/** 永久道具存档 */
readonly constants: readonly IHeroItemSave[];
/** 消耗道具存档 */
readonly consumables: readonly IHeroItemSave[];
/** 装备实例仓库存档 */
readonly equipStore: IHeroEquipsStoreSave<THero>;
}
export interface IHeroItemState<THero> {
/** 道具字符串 id */
readonly id: string;
/** 道具图块数字 */
readonly num: number;
/** 道具原始定义数据引用 */
readonly raw: IItemRawData<THero>;
/** 道具持有数量 */
count: number;
}
export interface IHeroItems<THero>
extends ISaveableContent<IHeroItemsSave<THero>>, IDataCommonExtended {
/** 勇士装备实例存储 */
readonly equipment: IHeroEquipsStore<THero>;
/**
* count `Pick`
* @param item id
* @param count 1
*/
addItem(item: number | string, count?: number): void;
/**
* `Pick` 使
* `addItem(item, 1)`
* @param item id
*/
getItem(item: number | string): void;
/**
*
* @param item id
*/
getItemState(item: number | string): Readonly<IHeroItemState<THero>> | null;
/**
* 使 Constant Consumable Consumable 使
* @param item id
* @returns 使
*/
useItem(item: number | string): boolean;
/**
*
* @param item id
*/
itemCount(item: number | string): number;
}
//#endregion
//#region 勇士装备
export interface IEquipmentStateSave<THero> {
/** 装备实例 uid */
readonly uid: number;
/** 装备道具图块数字 */
readonly num: number;
/** 当前数值加成 */
readonly value: Map<SelectKey<THero, number>, number>;
/** 当前百分比加成 */
readonly percentage: Map<SelectKey<THero, number>, number>;
}
export interface IHeroEquipsStoreSave<THero> {
/** 所有装备实例的存档 */
readonly equipments: readonly IEquipmentStateSave<THero>[];
}
export interface IEquipmentState<THero> extends ISaveableContent<
IEquipmentStateSave<THero>
> {
/** 装备实例 uid */
readonly uid: number;
/** 装备的定义数据引用 */
readonly item: IItemRawData<THero>;
/**
* [, ]
*/
getModifiers(): Iterable<[SelectKey<THero, number>, IHeroModifier<number>]>;
}
export interface IEquipmentSortHandler<THero> extends IDataCommonExtended {
/** 要排序的装备 A */
readonly equipA: IEquipmentState<THero>;
/** 要排序的装备 B */
readonly equipB: IEquipmentState<THero>;
/** 装备存储实例 */
readonly store: IHeroEquipsStore<THero>;
}
export interface IEquipmentSorter<THero> {
/**
*
* `equipA` `equipB` 0 `uid`
* @param handler
*/
compare(handler: IEquipmentSortHandler<THero>): number;
}
export interface IHeroEquipsStore<THero>
extends ISaveableContent<IHeroEquipsStoreSave<THero>>, IDataCommonExtended {
/**
*
* @param item id
* @returns uid
*/
add(item: number | string): number;
/**
*
* @param uid uid
*/
delete(uid: number): void;
/**
*
* @param uid uid
*/
get(uid: number): IEquipmentState<THero> | null;
/**
*
* @param comparer
*/
useSorter(comparer: IEquipmentSorter<THero> | null): void;
/**
* `uid`
* @param equip id
*/
instancesOf(equip: number | string): IEquipmentState<THero>[];
/**
* `uid`
*/
instances(): IEquipmentState<THero>[];
/**
*
* @param item id
*/
count(item: number | string): number;
}
export interface IHeroEquipmentSave {
/** 当前已装备的 uid 映射 */
readonly equipped: ReadonlyMap<number, number>;
/** 当前装备槽数组 */
readonly slots: readonly string[];
}
export const enum EquipStatus {
/** 可以直接装备到目标装备栏 */
CanEquip,
/** 不能装备到目标装备栏 */
CannotEquip,
/** 需要将目标装备栏上的装备进行替换 */
NeedReplace
}
export interface IHeroEquipment<THero>
extends ISaveableContent<IHeroEquipmentSave>, IDataCommonExtended {
/** 装备槽位数组,每一项字符串表示这一栏装备的名称,用于显示及装备槽匹配 */
readonly slots: readonly string[];
/**
*
* @param slots
*/
setSlots(slots: string[]): void;
/**
*
* @param uid uid
* @param slot
*/
canEquipTo(uid: number, slot: number | string): EquipStatus;
/**
* 穿
* @param uid uid
* @param slot
* @param autoUnload `true`
* @returns uid `undefined`
*/
equip(
uid: number,
slot: number | string,
autoUnload?: boolean
): number | undefined;
/**
*
* @param slot
* @returns uid `undefined`
*/
unequip(slot: number): number | undefined;
/**
*
* @param uid uid
*/
equipped(uid: number): boolean;
/**
* uid
* @param slot
*/
getEquipped(slot: number): number | undefined;
/**
*
*/
getEquips(): (IEquipmentState<THero> | null)[];
/**
* A B
* @param equipA A uid
* @param equipB B uid
* @param slot
*/
compareEquip(
equipA: number,
equipB: number,
slot: number
): Readonly<Partial<THero>>;
}
//#endregion
//#region 勇士状态
export interface IHeroState<THero> {
export interface IHeroStateSave<THero> {
/** 勇士属性状态 */
readonly attribute: THero;
/** 勇士当前位置 */
readonly location: IHeroLocationSave;
/** 勇士渲染状态 */
readonly rendering: IHeroRenderingSave;
/** 勇士当前的跟随者 */
readonly followers: readonly IHeroFollowerSave[];
/** 勇士属性修饰器状态 */
readonly modifiers: readonly IModifierStateSave<THero>[];
/** 勇士道具背包状态 */
readonly items: IHeroItemsSave<THero>;
/** 勇士装备状态 */
readonly equip: IHeroEquipmentSave;
}
export interface IHeroState<THero> extends ISaveableContent<
IHeroStateSave<THero>
> {
/** 勇士移动对象 */
readonly mover: IHeroMover;
readonly location: IHeroLocation;
/** 勇士属性对象 */
readonly attribute: IReadonlyHeroAttribute<THero>;
/** 勇士跟随者对象 */
readonly followers: IHeroFollowersController;
/** 勇士的渲染对象,包含一些必要渲染信息,存在于数据端,并非渲染端 */
readonly rendering: IHeroRendering;
/** 勇士道具背包 */
readonly items: IHeroItems<THero>;
/** 勇士装备系统 */
readonly equip: IHeroEquipment<THero>;
/**
*
* @param mover
*
*/
attachMover(mover: IHeroMover): void;
/**
*
*/
getHeroMover(): IHeroMover;
getLocation(): IFacedTileLocator;
/**
*
@ -369,6 +786,29 @@ export interface IHeroState<THero> {
*
*/
getIsolatedAttribute(): IHeroAttribute<THero>;
/**
*
* @param type
* @param cons
*/
registerModifier(type: string, cons: () => IHeroModifier): void;
/**
*
* @param type
*/
createModifier<T, V>(type: string): IHeroModifier<T, V> | null;
/**
*
* @param type
* @param name
*/
createAndInsertModifier<K extends keyof THero, V>(
type: string,
name: K
): IHeroModifier<THero[K], V> | null;
}
//#endregion

View File

@ -1,7 +1,8 @@
export * from './common';
export * from './enemy';
export * from './flag';
export * from './hero';
export * from './load';
export * from './map';
export * from './game';
export * from './types';

View File

@ -0,0 +1,183 @@
import {
HookController,
Hookable,
IHookController,
ITileLocator,
logger
} from '@motajs/common';
import {
IDynamicLayer,
IDynamicLayerHooks,
IDynamicTile,
IMapLayer
} from './types';
import { FaceDirection, IDataCommon, degradeFace } from '@user/data-common';
import { DynamicTile } from './dynamicTile';
export class DynamicLayer
extends Hookable<IDynamicLayerHooks>
implements IDynamicLayer
{
readonly state: IDataCommon;
/** 坐标到动态图块集合的映射,外层 key = y内层 key = x不使用 index 是为了支持地图外图块 */
private readonly tilePosMap: Map<number, Map<number, Set<IDynamicTile>>> =
new Map();
/** 动态图块到其当前坐标的映射 */
private readonly posTileMap: Map<IDynamicTile, ITileLocator> = new Map();
constructor(public readonly layer: IMapLayer) {
super();
this.state = layer.state;
}
protected createController(
hook: Partial<IDynamicLayerHooks>
): IHookController<IDynamicLayerHooks> {
return new HookController(this, hook);
}
createDynamic(num: number, x: number, y: number): IDynamicTile {
const tile = new DynamicTile(num, x, y, this);
tile.setTriggerType(this.layer.getTriggerType(x, y));
this.addTileToPosMap(tile, x, y);
this.posTileMap.set(tile, { x, y });
this.forEachHook(hook => hook.onCreateTile?.(tile, this));
return tile;
}
transferToDynamic(
x: number,
y: number,
keepTrigger: boolean = true
): IDynamicTile {
const num = this.layer.getBlock(x, y);
const triggerType = keepTrigger ? this.layer.getTriggerType(x, y) : -1;
if (num === 0) {
logger.warn(127, x.toString(), y.toString());
}
this.layer.setBlock(0, x, y);
this.layer.revertTrigger(x, y);
const tile = this.createDynamic(num, x, y);
tile.setTriggerType(triggerType);
return tile;
}
/**
*
*/
private syncStaticTrigger(tile: IDynamicTile, keepTrigger: boolean): void {
if (keepTrigger) {
this.layer.setTriggerType(tile.triggerType, tile.x, tile.y);
} else {
this.layer.revertTrigger(tile.x, tile.y);
}
}
transferToStatic(tile: IDynamicTile, keepTrigger: boolean = true): void {
const { x, y } = tile;
const { width, height } = this.layer;
if (x < 0 || y < 0 || x >= width || y >= height) {
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.syncStaticTrigger(tile, keepTrigger);
this.removeTile(tile);
this.forEachHook(hook => hook.onDeleteTile?.(tile, this));
}
transferToStaticIfSafe(
tile: IDynamicTile,
keepTrigger: boolean = true
): boolean {
const { x, y } = tile;
const { width, height } = this.layer;
if (x < 0 || y < 0 || x >= width || y >= height) {
logger.warn(128, x.toString(), y.toString());
return false;
}
if (this.layer.getBlock(tile.x, tile.y) !== 0) return false;
this.layer.setBlock(tile.num, x, y);
this.syncStaticTrigger(tile, keepTrigger);
this.removeTile(tile);
this.forEachHook(hook => hook.onDeleteTile?.(tile, this));
return true;
}
async deleteDynamic(tile: IDynamicTile): Promise<void> {
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<IDynamicTile> {
return this.tilePosMap.get(y)?.get(x) ?? new Set();
}
iterateDynamicTiles(): Iterable<IDynamicTile> {
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);
}
}

View File

@ -0,0 +1,92 @@
import { isNil } from 'lodash-es';
import {
FaceDirection,
IDataCommon,
IMoverController,
IObjectMover,
IRoleFaceBinder,
ITileRawData
} from '@user/data-common';
import { IDynamicLayer, IDynamicTile } from './types';
import { DynamicTileMover } from './mover';
import { logger } from '@motajs/common';
export class DynamicTile implements IDynamicTile {
readonly state: IDataCommon;
readonly mover: IObjectMover<IDynamicTile>;
raw: ITileRawData | null;
triggerType: number;
/** 当前的朝向绑定对象 */
private face: IRoleFaceBinder | null = null;
constructor(
public num: number,
public x: number,
public y: number,
public readonly layer: IDynamicLayer
) {
this.state = layer.state;
this.mover = new DynamicTileMover(this);
this.triggerType = -1;
const data = this.state.tileStore.getData(num);
if (!data) {
logger.warn(143, num.toString());
this.raw = null;
} else {
this.raw = data;
}
}
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;
}
setTriggerType(type: number): void {
this.triggerType = type;
}
delete(): Promise<void> {
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;
}
}
}

View File

@ -1,3 +1,5 @@
export * from './layerState';
export * from './mapLayer';
export * from './mapStore';
export * from './mover';
export * from './types';

View File

@ -11,6 +11,7 @@ import {
IMapLayerHookController,
IMapLayerHooks
} from './types';
import { IDataCommon, ITileStore } from '@user/data-common';
import { MapLayer } from './mapLayer';
export class LayerState
@ -18,6 +19,8 @@ export class LayerState
implements ILayerState
{
readonly layerList: Set<IMapLayer> = new Set();
/** 具体 MapLayer 实例列表,供内部 resize 使用 */
private readonly mapLayerList: Set<MapLayer> = new Set();
/** 图层到图层别名映射 */
readonly layerAliasMap: WeakMap<IMapLayer, string> = new WeakMap();
/** 图层别名到图层的映射 */
@ -29,10 +32,32 @@ export class LayerState
/** 图层钩子映射 */
private layerHookMap: Map<IMapLayer, IMapLayerHookController> = new Map();
addLayer(width: number, height: number): IMapLayer {
const array = new Uint32Array(width * height);
const layer = new MapLayer(array, width, height);
active: boolean = false;
eventLayer: IMapLayer | null = null;
/** 楼层级脏标记 */
private dirty: boolean = false;
constructor(
public readonly state: IDataCommon,
public readonly tileStore: ITileStore,
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,
this.tileStore
);
this.layerList.add(layer);
this.mapLayerList.add(layer);
this.forEachHook(hook => {
hook.onUpdateLayer?.(this.layerList);
});
@ -44,6 +69,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);
@ -83,15 +109,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);
}
}
}
@ -106,6 +135,29 @@ export class LayerState
return this.backgroundTile;
}
setActiveStatus(active: boolean): void {
this.active = active;
}
setEventLayer(layer: IMapLayer | null): void {
if (!layer) {
this.eventLayer = null;
} else {
if (!this.layerList.has(layer)) {
return;
}
this.eventLayer = layer;
}
}
isDirty(): boolean {
return this.dirty;
}
setDirty(dirty: boolean): void {
this.dirty = dirty;
}
protected createController(
hook: Partial<ILayerStateHooks>
): IHookController<ILayerStateHooks> {
@ -120,18 +172,21 @@ class StateMapLayerHook implements Partial<IMapLayerHooks> {
) {}
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);
});

View File

@ -1,11 +1,16 @@
import { isNil } from 'lodash-es';
import {
IDynamicLayer,
ILayerLocation,
ILayerState,
IMapLayer,
IMapLayerData,
IMapLayerHookController,
IMapLayerHooks
} from './types';
import { Hookable, HookController, logger } from '@motajs/common';
import { DynamicLayer } from './dynamicLayer';
import { IDataCommon, ITileStore } from '@user/data-common';
// todo: 提供 core.setBlock 等方法的替代方法,同时添加 setBlockList以及前景背景的接口
@ -13,6 +18,8 @@ export class MapLayer
extends Hookable<IMapLayerHooks, IMapLayerHookController>
implements IMapLayer
{
readonly state: IDataCommon;
width: number;
height: number;
empty: boolean = true;
@ -22,9 +29,20 @@ export class MapLayer
private mapArray: Uint32Array;
/** 地图数据引用 */
private mapData: IMapLayerData;
/** 手动触发器覆盖映射key = y * width + x */
private triggerMap: Map<number, number> = new Map();
constructor(array: Uint32Array, width: number, height: number) {
readonly dynamicLayer: IDynamicLayer;
constructor(
array: Uint32Array,
width: number,
height: number,
public readonly layerState: ILayerState,
private readonly tileStore: ITileStore
) {
super();
this.state = layerState.state;
this.width = width;
this.height = height;
const area = width * height;
@ -35,6 +53,30 @@ export class MapLayer
expired: false,
array: this.mapArray
};
this.dynamicLayer = new DynamicLayer(this);
}
inMap(x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < this.width && y < this.height;
}
/**
*
*/
private remapTriggerMap(
beforeWidth: number,
width: number,
height: number
): Map<number, number> {
const next = new Map<number, number>();
for (const [index, type] of this.triggerMap) {
const x = index % beforeWidth;
const y = Math.floor(index / beforeWidth);
if (x < width && y < height) {
next.set(y * width + x, type);
}
}
return next;
}
resize(width: number, height: number): void {
@ -50,6 +92,7 @@ export class MapLayer
this.height = height;
const area = width * height;
const newArray = new Uint32Array(area);
this.triggerMap = this.remapTriggerMap(beforeWidth, width, height);
this.mapArray = newArray;
// 将原来的地图数组赋值给现在的
if (beforeArea > area) {
@ -79,13 +122,16 @@ export class MapLayer
resize2(width: number, height: number): void {
if (this.width === width && this.height === height) {
this.empty = true;
this.mapArray.fill(0);
this.triggerMap.clear();
return;
}
this.mapData.expired = true;
this.width = width;
this.height = height;
this.mapArray = new Uint32Array(width * height);
this.triggerMap.clear();
this.mapData = {
expired: false,
array: this.mapArray
@ -109,13 +155,63 @@ export class MapLayer
}
getBlock(x: number, y: number): number {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
// 不在地图内,返回 -1
if (!this.inMap(x, y)) {
return -1;
}
return this.mapArray[y * this.width + x];
}
getLocationData(x: number, y: number): ILayerLocation | null {
if (!this.inMap(x, y)) return null;
const index = y * this.width + x;
const num = this.mapArray[index];
const raw = this.state.tileStore.getData(num);
const dynamics = this.dynamicLayer.getDynamicTilesAt(x, y);
const trigger = this.triggerMap.get(index) ?? -1;
const data: ILayerLocation = {
tile: num,
raw,
trigger,
dynamics
};
return data;
}
getTriggerType(x: number, y: number): number {
if (!this.inMap(x, y)) {
return -1;
}
const index = y * this.width + x;
if (this.triggerMap.has(index)) {
return this.triggerMap.get(index)!;
}
return this.tileStore.getTrigger(this.mapArray[index]);
}
setTriggerType(type: number, x: number, y: number): void {
if (!this.inMap(x, y)) {
return;
}
const index = y * this.width + x;
if (this.tileStore.getTrigger(this.mapArray[index]) === type) {
this.triggerMap.delete(index);
} else {
this.triggerMap.set(index, type);
}
}
revertTrigger(x: number, y: number): void {
if (this.inMap(x, y)) {
this.triggerMap.delete(y * this.width + x);
}
}
clearTrigger(): void {
this.triggerMap.clear();
}
putMapData(array: Uint32Array, x: number, y: number, width: number): void {
if (array.length % width !== 0) {
logger.warn(8);
@ -186,25 +282,49 @@ 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;
}
/**
*
*/
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);
});
}
getMapRef(): IMapLayerData {
return this.mapData;
}
setTriggerRef(triggers: Map<number, number>): void {
this.triggerMap = triggers;
}
getTriggerRef(): ReadonlyMap<number, number> {
return this.triggerMap;
}
protected createController(
hook: Partial<IMapLayerHooks>
): IMapLayerHookController {

View File

@ -0,0 +1,578 @@
import { logger } from '@motajs/common';
import { IDataCommon, SaveCompression } from '@user/data-common';
import { ITileStore } from '@user/data-common';
import {
ILayerState,
ILayerStateSave,
IMapLayer,
IMapLayerSave,
IMapStore,
IMapStoreSave,
MapArea
} from './types';
import { LayerState } from './layerState';
import { uniq } from 'lodash-es';
export class MapStore implements IMapStore {
/** 楼层 id 到状态对象的映射 */
private readonly mapData: Map<string, LayerState> = new Map();
/** 所有楼层 id 的有序数组 */
readonly maps: string[] = [];
/** 差分压缩参考基准,首次 compareWith 后设置,之后不再更新 */
private refData: Map<string, Map<number, Uint32Array>> | null = null;
/** 分区列表 */
private areaList: Set<MapArea> = new Set();
/** 上一次调用 notifyEnterFloor 传入的楼层 id */
private lastFloorId: string | null = null;
/** 自动分区激活器开关 */
private autoActivitorEnabled: boolean = false;
constructor(
private readonly tileStore: ITileStore,
public readonly state: IDataCommon
) {}
//#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(this.state, this.tileStore, 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;
}
getActiveMap(id: string): ILayerState | null {
const state = this.mapData.get(id);
if (!state || !state.active) return null;
return state;
}
//#endregion
//#region 分区管理
setArea(areas: Set<MapArea>): void {
this.areaList = areas;
}
activeArea(id: string): void {
const idx = this.maps.indexOf(id);
if (idx === -1) return;
const area = this.findAreaByIndex(idx);
if (!area) return;
this.setAreaActive(area, true);
}
deactiveArea(id: string): void {
const idx = this.maps.indexOf(id);
if (idx === -1) return;
const area = this.findAreaByIndex(idx);
if (!area) return;
this.setAreaActive(area, false);
}
useAutoActivitor(enable: boolean): void {
this.autoActivitorEnabled = enable;
}
notifyEnterFloor(id: string): void {
if (!this.autoActivitorEnabled) return;
const idx = this.maps.indexOf(id);
if (idx === -1) return;
const area = this.findAreaByIndex(idx);
if (!area) return;
if (this.lastFloorId !== null) {
this.deactiveArea(this.lastFloorId);
}
this.activeArea(id);
this.lastFloorId = id;
}
/**
* maps
* @param idx maps
*/
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;
}
/**
*
* @param area
* @param active
*/
private setAreaActive(area: MapArea, active: boolean): void {
for (const interval of area) {
for (let i = interval.start; i <= interval.end; i++) {
const floorId = this.maps[i];
if (floorId !== undefined) {
this.setMapActiveStatus(floorId, active);
}
}
}
}
//#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<string, Map<number, Uint32Array>>): 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);
}
}
/**
*
* @param state
*/
private getTriggerMap(state: ILayerState) {
const triggers = new Map<number, Map<number, number>>();
for (const layer of state.layerList) {
const map = layer.getTriggerRef();
if (map.size > 0) {
triggers.set(layer.zIndex, new Map(map));
}
}
return triggers;
}
/**
*
* @param state
*/
private emptySave(state: ILayerState): ILayerStateSave {
return {
background: state.getBackground(),
layers: new Map(),
triggers: this.getTriggerMap(state)
};
}
/**
*
* @param layer
*/
private fullLayer(layer: IMapLayer): IMapLayerSave {
return {
width: layer.width,
height: layer.height,
fullMap: new Uint32Array(layer.getMapRef().array)
};
}
/**
* NoCompression / LowCompression
* @param state
*/
private saveLayerStateFull(state: LayerState): ILayerStateSave {
const background = state.getBackground();
const layers = new Map<number, IMapLayerSave>();
const triggers = new Map<number, Map<number, number>>();
for (const layer of state.layerList) {
const arr = layer.getMapRef().array;
layers.set(layer.zIndex, {
width: layer.width,
height: layer.height,
fullMap: new Uint32Array(arr)
});
triggers.set(layer.zIndex, new Map(layer.getTriggerRef()));
}
return { background, layers, triggers };
}
/**
* HighCompression
* @param layer
* @param refArray
*/
private diffRows(
layer: IMapLayer,
refArray: Uint32Array
): Map<number, Uint32Array> {
const rows = new Map<number, Uint32Array>();
const arr = layer.getMapRef().array;
for (let row = 0; row < layer.height; row++) {
const start = row * layer.width;
const end = start + layer.width;
const slice = arr.subarray(start, end);
const refSlice = refArray.subarray(start, end);
const same = refSlice.every((v, i) => slice[i] === v);
if (!same) {
rows.set(row, new Uint32Array(slice));
}
}
return rows;
}
/**
* LowCompression
* @param id id
* @param state
*/
private isStateEqualToRef(id: string, state: LayerState): boolean {
const refFloor = this.refData?.get(id);
if (!refFloor) return false;
for (const layer of state.layerList) {
const refArray = refFloor.get(layer.zIndex);
if (!refArray) return false;
const cur = layer.getMapRef().array;
if (cur.length !== refArray.length) return false;
if (cur.some((v, i) => v !== refArray[i])) return false;
}
return true;
}
//#endregion
//#region 存档
/**
*
*/
private saveNoCompression(): IMapStoreSave {
const floors = new Map<string, ILayerStateSave>();
for (const [id, state] of this.mapData) {
if (!state.active) continue;
floors.set(id, this.saveLayerStateFull(state));
}
return { floors };
}
/**
*
*/
private saveLowCompression(): IMapStoreSave {
const floors = new Map<string, ILayerStateSave>();
if (this.refData) {
// 包含参考标准时需要对比
for (const [id, state] of this.mapData) {
if (!state.active) continue;
if (state.isDirty() && this.isStateEqualToRef(id, state)) {
floors.set(id, this.saveLayerStateFull(state));
} else {
floors.set(id, this.emptySave(state));
}
}
} else {
// 不包含参考标准时仅看 dirty 标记
for (const [id, state] of this.mapData) {
if (!state.active) continue;
if (state.isDirty()) {
floors.set(id, this.saveLayerStateFull(state));
} else {
floors.set(id, this.emptySave(state));
}
}
}
return { floors };
}
/**
*
*/
private saveHighCompression(): IMapStoreSave {
const floors = new Map<string, ILayerStateSave>();
// 没有参考标准,直接退回低压缩级别
if (!this.refData) return this.saveLowCompression();
for (const [id, state] of this.mapData) {
if (!state.active) continue;
if (state.isDirty()) {
const refFloor = this.refData.get(id);
const layersMap = new Map<number, IMapLayerSave>();
// 对每一个地图的每一行进行遍历,然后仅存储有差别的行
for (const layer of state.layerList) {
const refArray = refFloor?.get(layer.zIndex);
if (refArray) {
const rows = this.diffRows(layer, refArray);
if (rows.size === 0) continue;
layersMap.set(layer.zIndex, {
width: layer.width,
height: layer.height,
rows
});
} else {
layersMap.set(layer.zIndex, this.fullLayer(layer));
}
}
floors.set(id, {
background: state.getBackground(),
layers: layersMap,
triggers: this.getTriggerMap(state)
});
} else {
floors.set(id, this.emptySave(state));
}
}
return { floors };
}
/**
*
* @param save
* @param layer
*/
private loadTriggers(save: ILayerStateSave, layer: IMapLayer) {
const triggers = save.triggers.get(layer.zIndex);
layer.clearTrigger();
if (triggers) {
layer.setTriggerRef(new Map(triggers));
}
}
/**
* NoCompression fullMap
* @param state
*/
private loadNoCompression(state: IMapStoreSave): void {
for (const [id, cur] of this.mapData) {
cur.setActiveStatus(state.floors.has(id));
}
for (const [id, save] of state.floors) {
const cur = this.mapData.get(id);
if (!cur) {
logger.warn(122, id);
continue;
}
cur.setBackground(save.background);
for (const layer of cur.layerList) {
// 地图
const layerSave = save.layers.get(layer.zIndex);
if (layerSave?.fullMap) {
layer.setMapRef(new Uint32Array(layerSave.fullMap));
}
// 触发器
this.loadTriggers(save, layer);
}
// 需要额外进行判断是否与参考地图相同
if (this.isStateEqualToRef(id, cur)) {
cur.setDirty(false);
} else {
cur.setDirty(true);
}
}
}
/**
* LowCompression
* - layers dirty fullMap
* - layers dirty
* @param state
*/
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, save] 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(save.background);
let shouldDirty = false;
for (const layer of cur.layerList) {
// 地图
const layerSave = save.layers.get(layer.zIndex);
if (layerSave?.fullMap) {
layer.setMapRef(new Uint32Array(layerSave.fullMap));
shouldDirty = true;
} else {
const refArray = refFloor.get(layer.zIndex);
if (!refArray) {
logger.warn(124, id);
return;
}
layer.setMapRef(new Uint32Array(refArray));
}
// 触发器
this.loadTriggers(save, layer);
}
cur.setDirty(shouldDirty);
}
}
/**
* HighCompression
* - layers dirty
* - layers dirty rows
* @param state
*/
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, save] 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(save.background);
let shouldDirty = false;
for (const layer of cur.layerList) {
const refArray = refFloor.get(layer.zIndex);
if (!refArray) {
logger.warn(124, id);
continue;
}
// 地图
const layerSave = save.layers.get(layer.zIndex);
if (!layerSave?.rows || layerSave.rows.size === 0) {
// 图层无变化或非 dirty 楼层,从参考基准恢复
layer.setMapRef(new Uint32Array(refArray));
} else {
// 以参考基准为底,叠加差分行
shouldDirty = true;
const buf = new Uint32Array(refArray);
for (const [rowIdx, rowData] of layerSave.rows) {
buf.set(rowData, rowIdx * layer.width);
}
layer.setMapRef(buf);
}
// 触发器
this.loadTriggers(save, layer);
}
cur.setDirty(shouldDirty);
}
}
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);
}
}
//#endregion
}

View File

@ -0,0 +1,93 @@
import { ITileLocator, logger } from '@motajs/common';
import {
getFaceMovement,
ObjectMover,
ObjectMoveStep,
ObjectMoveType
} from '@user/data-common';
import { IDynamicTile } from './types';
import { DYNAMIC_MOVER_FACE } from '../shared';
//#region 动态图块
const enum DynamicMoveCode {
/** 正常执行 */
Success
}
export class DynamicTileMover extends ObjectMover<IDynamicTile> {
constructor(public readonly tile: IDynamicTile) {
const face = tile.state.faceManager;
super(face.get(DYNAMIC_MOVER_FACE)!);
}
protected onMoveStart(): Promise<void> {
return Promise.resolve();
}
protected onMoveEnd(): Promise<void> {
return Promise.resolve();
}
protected onStepStart(): Promise<number> {
return Promise.resolve(DynamicMoveCode.Success);
}
protected onStepEnd(
code: number,
step: ObjectMoveStep,
tile: IDynamicTile
): Promise<ITileLocator> {
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 ObjectMoveType.Dir: {
const { x, y } = getFaceMovement(step.move);
tile.setFaceDirection(step.move);
locator.x += x;
locator.y += y;
break;
}
case ObjectMoveType.DirFace: {
const { x, y } = getFaceMovement(step.move);
tile.setFaceDirection(step.face);
locator.x += x;
locator.y += y;
break;
}
case ObjectMoveType.Face: {
tile.setFaceDirection(step.value);
break;
}
case ObjectMoveType.Special: {
const { x, y } = getFaceMovement(this.moveDirection);
tile.setFaceDirection(this.faceDirection);
locator.x += x;
locator.y += y;
break;
}
case ObjectMoveType.Teleport:
case ObjectMoveType.Jump: {
const { x, y, rel } = step;
tile.setFaceDirection(this.faceDirection);
if (rel) {
locator.x += x;
locator.y += y;
} else {
locator.x = x;
locator.y = y;
}
break;
}
}
return Promise.resolve(locator);
}
}
//#endregion

View File

@ -0,0 +1,690 @@
import { IHookable, IHookBase, IHookController } from '@motajs/common';
import {
FaceDirection,
IDataCommonExtended,
IMoverController,
IObjectMovable,
IObjectMover,
IRoleFaceBinder,
ISaveableContent,
ITileRawData
} from '@user/data-common';
import { ITileStore } from '@user/data-common';
//#region 静态图层
export interface IMapLayerData {
/** 当前引用是否过期,当地图图层内部的地图数组引用更新时,此项会变为 `true` */
expired: boolean;
/** 地图图块数组,是对内部存储的直接引用,仅建议读取,不建议修改 */
array: Uint32Array;
}
export interface ILayerLocation {
/** 该点的静态图块数字 */
readonly tile: number;
/** 该点的静态图块信息 */
readonly raw: ITileRawData | null;
/** 该点的静态触发器,-1 表示未设置(即使用图块本身的触发器),否则表示该点的静态触发器,覆盖图块本身的触发器 */
readonly trigger: number;
/** 该点包含的所有动态图块 */
readonly dynamics: Iterable<IDynamicTile>;
}
export interface IMapLayerHooks extends IHookBase {
/**
* `resize`
* @param width
* @param height
*/
onResize(width: number, height: number): void;
/**
*
* @param x
* @param y
* @param width
* @param height
*/
onUpdateArea(x: number, y: number, width: number, height: number): void;
/**
*
* @param block
* @param x
* @param y
*/
onUpdateBlock(block: number, x: number, y: number): void;
/**
* `Promise`
* @param x
* @param y
*/
onOpenDoor(x: number, y: number): Promise<void>;
/**
* `Promise`
* @param num
* @param x
* @param y
*/
onCloseDoor(num: number, x: number, y: number): Promise<void>;
}
export interface IMapLayerHookController extends IHookController<IMapLayerHooks> {
/** 拓展所属的图层对象 */
readonly layer: IMapLayer;
/**
*
*/
getMapData(): Readonly<IMapLayerData>;
}
export interface IMapLayer
extends
IHookable<IMapLayerHooks, IMapLayerHookController>,
IDataCommonExtended {
/** 地图宽度 */
readonly width: number;
/** 地图高度 */
readonly height: number;
/**
*
* `true` `false`
*/
readonly empty: boolean;
/** 图层纵深 */
readonly zIndex: number;
/** 当前图层所属的地图状态对象 */
readonly layerState: ILayerState;
/** 此图层对应的动态图块图层z 层级与静态图块一致 */
readonly dynamicLayer: IDynamicLayer;
/**
*
* @param x
* @param y
*/
inMap(x: number, y: number): boolean;
/**
*
* @param block
* @param x
* @param y
*/
setBlock(block: number, x: number, y: number): void;
/**
*
* @param x
* @param y
* @returns 0 -1
*/
getBlock(x: number, y: number): number;
/**
*
* @param x
* @param y
*/
getLocationData(x: number, y: number): ILayerLocation | null;
/**
* 退
* @param x
* @param y
*/
getTriggerType(x: number, y: number): number;
/**
*
* @param type
* @param x
* @param y
*/
setTriggerType(type: number, x: number, y: number): void;
/**
* 退
* @param x
* @param y
*/
revertTrigger(x: number, y: number): void;
/**
*
*/
clearTrigger(): void;
/**
*
* @param array
* @param x
* @param y
* @param width
*/
putMapData(array: Uint32Array, x: number, y: number, width: number): void;
/**
*
*/
getMapData(): Uint32Array;
/**
*
* @param x
* @param y
* @param width
* @param height
*/
getMapData(
x: number,
y: number,
width: number,
height: number
): Uint32Array;
/**
*
* `MapStore` 使
* `width * height`
*
* @param array
*/
setMapRef(array: Uint32Array): void;
/**
*
*/
getMapRef(): IMapLayerData;
/**
* 使
* @param triggers
*/
setTriggerRef(triggers: Map<number, number>): void;
/**
* 使
*/
getTriggerRef(): ReadonlyMap<number, number>;
/**
*
* @param zIndex
*/
setZIndex(zIndex: number): void;
/**
*
* @param x
* @param y
*/
openDoor(x: number, y: number): Promise<void>;
/**
*
* @param num
* @param x
* @param y
*/
closeDoor(num: number, x: number, y: number): Promise<void>;
}
//#endregion
//#region 图层管理
export interface ILayerStateHooks extends IHookBase {
/**
*
* @param tile
*/
onChangeBackground(tile: number): void;
/**
*
* @param layerList
*/
onUpdateLayer(layerList: Set<IMapLayer>): void;
/**
*
* @param layer
* @param x
* @param y
* @param width
* @param height
*/
onUpdateLayerArea(
layer: IMapLayer,
x: number,
y: number,
width: number,
height: number
): void;
/**
*
* @param layer
* @param block
* @param x
* @param y
*/
onUpdateLayerBlock(
layer: IMapLayer,
block: number,
x: number,
y: number
): void;
/**
*
* @param layer
* @param width
* @param height
*/
onResizeLayer(layer: IMapLayer, width: number, height: number): void;
}
export interface ILayerState
extends IHookable<ILayerStateHooks>, IDataCommonExtended {
/** 地图列表 */
readonly layerList: Set<IMapLayer>;
/** 当前楼层共享的图块定义 store */
readonly tileStore: ITileStore;
/** 此楼层是否处于激活状态 */
readonly active: boolean;
/** 此楼层的地图宽度 */
readonly width: number;
/** 此楼层的地图高度 */
readonly height: number;
/** 当前楼层的默认事件层 */
readonly eventLayer: IMapLayer | null;
/**
* 使
*/
addLayer(): IMapLayer;
/**
*
* @param layer
*/
removeLayer(layer: IMapLayer): void;
/**
*
* @param layer
*/
hasLayer(layer: IMapLayer): boolean;
/**
*
* @param layer
* @param alias
*/
setLayerAlias(layer: IMapLayer, alias: string): void;
/**
*
* @param alias
*/
getLayerByAlias(alias: string): IMapLayer | null;
/**
*
* @param layer
*/
getLayerAlias(layer: IMapLayer): string | undefined;
/**
*
* @param width
* @param height
* @param keepBlock
*/
resizeLayer(width: number, height: number, keepBlock?: boolean): void;
/**
*
* @param tile
*/
setBackground(tile: number): void;
/**
* 0
*/
getBackground(): number;
/**
*
* @param active
*/
setActiveStatus(active: boolean): void;
/**
*
* @param layer
*/
setEventLayer(layer: IMapLayer | null): void;
/**
*
*/
isDirty(): boolean;
/**
*
*/
setDirty(dirty: boolean): void;
}
//#endregion
//#region 楼层管理
export interface IMapLayerSave {
readonly width: number;
readonly height: number;
/**
* key = value = Uint32Array
* HighCompression 使
* Map
*/
readonly rows?: ReadonlyMap<number, Uint32Array>;
/** 完整地图,当使用 `NoCompression` 和 `LowCompression` 时使用此接口 */
readonly fullMap?: Uint32Array;
}
export interface ILayerStateSave {
/** 楼层背景 */
readonly background: number;
/** key = zIndexvalue = 对应图层存档数据 */
readonly layers: ReadonlyMap<number, IMapLayerSave>;
/** 静态触发器覆盖映射,仅在存在覆盖时写入 */
readonly triggers: ReadonlyMap<number, ReadonlyMap<number, number>>;
}
export interface IMapStoreSave {
/** key = 楼层 id只包含 active 的楼层inactive 的楼层不写入,读档时无需处理 */
readonly floors: ReadonlyMap<string, ILayerStateSave>;
}
export interface IMapAreaInterval {
/** 区域起始索引,包含 */
readonly start: number;
/** 区域结束索引,包含 */
readonly end: number;
}
export type MapArea = IMapAreaInterval[];
export interface IMapStore
extends ISaveableContent<IMapStoreSave>, IDataCommonExtended {
/** 所有楼层的 id 有序数组 */
readonly maps: ReadonlyArray<string>;
/**
* 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
* @param width
* @param height
*/
createLayerState(id: string, width: number, height: number): 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 = zIndexvalue =
*/
compareWith(ref: Map<string, Map<number, Uint32Array>>): void;
/**
*
* @param maps id
*/
setMapList(maps: string[]): void;
/**
* 使 maps
*
* @param sort
*/
useManualOrder(sort: (arr: string[]) => string[]): void;
/**
*
* @param areas
*/
setArea(areas: Set<MapArea>): void;
/**
*
* @param id id
*/
activeArea(id: string): void;
/**
*
* @param id id
*/
deactiveArea(id: string): void;
/**
*
* @param enable
*/
useAutoActivitor(enable: boolean): void;
/**
*
* @param id id
*/
notifyEnterFloor(id: string): void;
}
//#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<void>;
/**
* 使 `mover`
* @param tile
* @param layer
*/
onUpdateTilePosition?(tile: IDynamicTile, layer: IDynamicLayer): void;
}
export interface IDynamicLayer
extends IHookable<IDynamicLayerHooks>, IDataCommonExtended {
/** 当前动态图层所属的静态图层 */
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,
keepTrigger?: boolean
): IDynamicTile;
/**
*
* {@link IDynamicLayerHooks.onDeleteTile}
* @param tile
*/
transferToStatic(tile: IDynamicTile, keepTrigger?: boolean): void;
/**
* 0
* @param tile
* @returns
*/
transferToStaticIfSafe(tile: IDynamicTile, keepTrigger?: boolean): boolean;
/**
* {@link IDynamicLayerHooks.onDeleteTile}
*
* @param tile
*/
deleteDynamic(tile: IDynamicTile): Promise<void>;
/**
*
* @param x
* @param y
*/
getDynamicTilesAt(x: number, y: number): Iterable<IDynamicTile>;
/**
*
*/
iterateDynamicTiles(): Iterable<IDynamicTile>;
/**
* `tile.num`
*
* @param tile
* @param direction
*/
setDynamicDirection(tile: IDynamicTile, direction: FaceDirection): void;
/**
*
* @param tile
*/
updateDynamicTile(tile: IDynamicTile): void;
}
export interface IDynamicTile extends IObjectMovable, IDataCommonExtended {
/** 当前图块数字 */
readonly num: number;
/** 当前动态图块携带的触发器类型,-1 表示无触发器 */
readonly triggerType: number;
/** 当前图块所属的动态图层 */
readonly layer: IDynamicLayer;
/** 当前动态图块的移动器 */
readonly mover: IObjectMover<IDynamicTile>;
/** 当前动态图块的图块数据 */
readonly raw: ITileRawData | null;
/**
* {@link num}
* @param direction
*/
setFaceDirection(direction: FaceDirection): number;
/**
*
* @param type
*/
setTriggerType(type: number): void;
/**
*
*/
delete(): Promise<void>;
/**
*
*/
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

View File

@ -0,0 +1,6 @@
import { FaceGroup } from '@user/data-common';
/** 动态图块所使用的默认移动组,不知道干什么的就别动 */
export const DYNAMIC_MOVER_FACE = FaceGroup.Dir8;
/** 勇士移动所使用的默认移动组,不知道干什么的就别动 */
export const HERO_MOVER_FACE = FaceGroup.Dir8;

View File

@ -0,0 +1,46 @@
import { IHeroFollower, IHeroState } from './hero';
import { IEnemyManager } from './enemy';
import { IFlagSystem } from './flag';
import { IMapStore } from './map';
import {
IDataCommon,
IEnemyAttr,
IHeroAttr,
ISaveableContent
} from '@user/data-common';
export interface IStateSaveData {
/** 跟随者列表 */
readonly followers: readonly IHeroFollower[];
}
export interface IStateBase extends IDataCommon {
/** 地图状态 */
readonly maps: IMapStore;
/** 勇士状态 */
readonly hero: IHeroState<IHeroAttr>;
/** 怪物管理器 */
readonly enemyManager: IEnemyManager<IEnemyAttr>;
/** Flag 系统 */
readonly flags: IFlagSystem;
/**
*
* @param id id
* @param content
*/
addSaveableContent(id: string, content: ISaveableContent<unknown>): void;
/**
* id
* @param id id
*/
getSaveableContent<T>(id: string): ISaveableContent<T> | null;
}
export interface IDataBaseExtended {
/** 当前对象对应的数据层对象Layer 1 对象) */
readonly state: IStateBase;
}

View File

@ -0,0 +1,6 @@
{
"name": "@user/data-common",
"dependencies": {
"@motajs/common": "workspace:*"
}
}

View File

@ -1,5 +1,5 @@
import { logger } from '@motajs/common';
import { IFaceData, IRoleFaceBinder } from './types';
import { IFaceData, IRoleFaceBinder } from '../common';
import { isNil } from 'lodash-es';
import { FaceDirection } from '.';

View File

@ -0,0 +1,282 @@
import { FaceDirection } from './types';
//#region 接口与枚举
export const enum FaceGroup {
/** 四方向(上下左右) */
Dir4,
/** 八方向(上下左右+斜向) */
Dir8
}
export interface IFaceDescriptor {
/** 横坐标增量 */
readonly x: number;
/** 纵坐标增量 */
readonly y: number;
}
export interface IFaceHandler<T extends number> {
/**
*
* `Unknown` `FaceDirection.Unknown`
* @param dir
*/
degrade(dir: number): T;
/**
* `degrade`
* @param dir
*/
movement(dir: number): IFaceDescriptor;
/**
* `count` `movement * count`
* `count` `degrade`
* @param dir
* @param count
*/
move(dir: number, count: number): IFaceDescriptor;
/**
* `degrade``Unknown` `Unknown`
* @param dir
*/
opposite(dir: number): T;
/**
* `degrade`
* `Unknown` `Unknown`
* @param dir
* @param anticlockwise
*/
next(dir: number, anticlockwise?: boolean): T;
/**
* `Unknown`
*/
mapDirection(): Iterable<T>;
/**
* `Unknown` `{ x: 0, y: 0 }`
*/
mapMovement(): Iterable<[T, IFaceDescriptor]>;
}
export interface IFaceManager {
/**
* key handler
* @param group key
* @param handler
*/
register(group: number, handler: IFaceHandler<number>): void;
/**
* id handler
* @param id id
* @param handler
*/
registerById(id: string, handler: IFaceHandler<number>): void;
/**
* key handler `null`
* @param group key
*/
get<T extends number>(group: number): IFaceHandler<T> | null;
/**
* id handler `null`
* @param id id
*/
getById<T extends number>(id: string): IFaceHandler<T> | null;
}
//#endregion
//#region 内置 Handler
const ZERO_DESCRIPTOR: IFaceDescriptor = { x: 0, y: 0 };
const DIR8_MOVEMENTS: ReadonlyMap<FaceDirection, IFaceDescriptor> = new Map([
[FaceDirection.Unknown, ZERO_DESCRIPTOR],
[FaceDirection.Left, { x: -1, y: 0 }],
[FaceDirection.Up, { x: 0, y: -1 }],
[FaceDirection.Right, { x: 1, y: 0 }],
[FaceDirection.Down, { x: 0, y: 1 }],
[FaceDirection.LeftUp, { x: -1, y: -1 }],
[FaceDirection.RightUp, { x: 1, y: -1 }],
[FaceDirection.LeftDown, { x: -1, y: 1 }],
[FaceDirection.RightDown, { x: 1, y: 1 }]
]);
/** 顺时针旋转顺序(不含 Unknown */
const DIR8_CW: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Up, FaceDirection.RightUp],
[FaceDirection.RightUp, FaceDirection.Right],
[FaceDirection.Right, FaceDirection.RightDown],
[FaceDirection.RightDown, FaceDirection.Down],
[FaceDirection.Down, FaceDirection.LeftDown],
[FaceDirection.LeftDown, FaceDirection.Left],
[FaceDirection.Left, FaceDirection.LeftUp],
[FaceDirection.LeftUp, FaceDirection.Up]
]);
/** 逆时针旋转顺序(不含 Unknown */
const DIR8_CCW: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Up, FaceDirection.LeftUp],
[FaceDirection.LeftUp, FaceDirection.Left],
[FaceDirection.Left, FaceDirection.LeftDown],
[FaceDirection.LeftDown, FaceDirection.Down],
[FaceDirection.Down, FaceDirection.RightDown],
[FaceDirection.RightDown, FaceDirection.Right],
[FaceDirection.Right, FaceDirection.RightUp],
[FaceDirection.RightUp, FaceDirection.Up]
]);
const DIR8_OPPOSITE: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Up, FaceDirection.Down],
[FaceDirection.Down, FaceDirection.Up],
[FaceDirection.Left, FaceDirection.Right],
[FaceDirection.Right, FaceDirection.Left],
[FaceDirection.LeftUp, FaceDirection.RightDown],
[FaceDirection.RightDown, FaceDirection.LeftUp],
[FaceDirection.RightUp, FaceDirection.LeftDown],
[FaceDirection.LeftDown, FaceDirection.RightUp]
]);
export class Dir8FaceHandler implements IFaceHandler<FaceDirection> {
degrade(dir: number): FaceDirection {
return dir as FaceDirection;
}
movement(dir: number): IFaceDescriptor {
return DIR8_MOVEMENTS.get(this.degrade(dir)) ?? ZERO_DESCRIPTOR;
}
move(dir: number, count: number): IFaceDescriptor {
const { x, y } = this.movement(dir);
return { x: x * count, y: y * count };
}
opposite(dir: number): FaceDirection {
const degraded = this.degrade(dir);
return DIR8_OPPOSITE.get(degraded) ?? FaceDirection.Unknown;
}
next(dir: number, anticlockwise: boolean = false): FaceDirection {
const degraded = this.degrade(dir);
if (degraded === FaceDirection.Unknown) return FaceDirection.Unknown;
const map = anticlockwise ? DIR8_CCW : DIR8_CW;
return map.get(degraded) ?? FaceDirection.Unknown;
}
mapDirection(): Iterable<FaceDirection> {
return DIR8_MOVEMENTS.keys();
}
mapMovement(): Iterable<[FaceDirection, IFaceDescriptor]> {
return DIR8_MOVEMENTS.entries();
}
}
const DIR4_DEGRADE: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Left, FaceDirection.Left],
[FaceDirection.Up, FaceDirection.Up],
[FaceDirection.Right, FaceDirection.Right],
[FaceDirection.Down, FaceDirection.Down],
[FaceDirection.LeftUp, FaceDirection.Left],
[FaceDirection.LeftDown, FaceDirection.Left],
[FaceDirection.RightUp, FaceDirection.Right],
[FaceDirection.RightDown, FaceDirection.Right]
]);
const DIR4_MOVEMENTS: ReadonlyMap<FaceDirection, IFaceDescriptor> = new Map([
[FaceDirection.Unknown, ZERO_DESCRIPTOR],
[FaceDirection.Left, { x: -1, y: 0 }],
[FaceDirection.Up, { x: 0, y: -1 }],
[FaceDirection.Right, { x: 1, y: 0 }],
[FaceDirection.Down, { x: 0, y: 1 }]
]);
const DIR4_CW: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Up, FaceDirection.Right],
[FaceDirection.Right, FaceDirection.Down],
[FaceDirection.Down, FaceDirection.Left],
[FaceDirection.Left, FaceDirection.Up]
]);
const DIR4_CCW: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Up, FaceDirection.Left],
[FaceDirection.Left, FaceDirection.Down],
[FaceDirection.Down, FaceDirection.Right],
[FaceDirection.Right, FaceDirection.Up]
]);
const DIR4_OPPOSITE: ReadonlyMap<FaceDirection, FaceDirection> = new Map([
[FaceDirection.Up, FaceDirection.Down],
[FaceDirection.Down, FaceDirection.Up],
[FaceDirection.Left, FaceDirection.Right],
[FaceDirection.Right, FaceDirection.Left]
]);
export class Dir4FaceHandler implements IFaceHandler<FaceDirection> {
degrade(dir: number): FaceDirection {
return DIR4_DEGRADE.get(dir as FaceDirection) ?? FaceDirection.Unknown;
}
movement(dir: number): IFaceDescriptor {
return DIR4_MOVEMENTS.get(this.degrade(dir)) ?? ZERO_DESCRIPTOR;
}
move(dir: number, count: number): IFaceDescriptor {
const { x, y } = this.movement(dir);
return { x: x * count, y: y * count };
}
opposite(dir: number): FaceDirection {
const degraded = this.degrade(dir);
return DIR4_OPPOSITE.get(degraded) ?? FaceDirection.Unknown;
}
next(dir: number, anticlockwise: boolean = false): FaceDirection {
const degraded = this.degrade(dir);
if (degraded === FaceDirection.Unknown) return FaceDirection.Unknown;
const map = anticlockwise ? DIR4_CCW : DIR4_CW;
return map.get(degraded) ?? FaceDirection.Unknown;
}
mapDirection(): Iterable<FaceDirection> {
return DIR4_MOVEMENTS.keys();
}
mapMovement(): Iterable<[FaceDirection, IFaceDescriptor]> {
return DIR4_MOVEMENTS.entries();
}
}
//#endregion
//#region FaceManager
export class FaceManager implements IFaceManager {
private readonly byGroup: Map<number, IFaceHandler<number>> = new Map();
private readonly byId: Map<string, IFaceHandler<number>> = new Map();
register(group: number, handler: IFaceHandler<number>): void {
this.byGroup.set(group, handler);
}
registerById(id: string, handler: IFaceHandler<number>): void {
this.byId.set(id, handler);
}
get<T extends number>(group: number): IFaceHandler<T> | null {
return (this.byGroup.get(group) as IFaceHandler<T>) ?? null;
}
getById<T extends number>(id: string): IFaceHandler<T> | null {
return (this.byId.get(id) as IFaceHandler<T>) ?? null;
}
}
//#endregion

View File

@ -0,0 +1,6 @@
export * from './face';
export * from './faceManager';
export * from './indexer';
export * from './mover';
export * from './types';
export * from './utils';

View File

@ -0,0 +1,61 @@
import { ITileLocator } from '@motajs/common';
//#region 接口定义
export interface ILocationHelper {
/**
* ->
* @param x
* @param y
*/
locToIndex(x: number, y: number): number;
/**
* ->
* @param locator
*/
locaterToIndex(locator: ITileLocator): number;
/**
* ->
* @param index
*/
indexToLocator(index: number): ITileLocator;
}
export interface ILocationIndexer extends ILocationHelper {
/**
*
* @param width
*/
setWidth(width: number): void;
}
//#endregion
//#region 默认实现
export class MapLocIndexer implements ILocationIndexer {
private width: number = 0;
setWidth(width: number): void {
this.width = width;
}
locToIndex(x: number, y: number): number {
return y * this.width + x;
}
locaterToIndex(locator: ITileLocator): number {
return locator.y * this.width + locator.x;
}
indexToLocator(index: number): ITileLocator {
return {
x: index % this.width,
y: Math.floor(index / this.width)
};
}
}
//#endregion

View File

@ -0,0 +1,692 @@
import {
Hookable,
HookController,
IHookable,
IHookBase,
IHookController,
ITileLocator
} from '@motajs/common';
import { FaceDirection } from './types';
import { IFaceHandler } from './faceManager';
//#region 对象移动
export const enum ObjectMoveType {
/** 绝对方向步,同步更新移动方向与朝向 */
Dir,
/** 绝对方向步,显式指定朝向 */
DirFace,
/** 速度步 */
Speed,
/** 纯转向步 */
Face,
/** 特殊步,如前进或后退 */
Special,
/** 动画方向步 */
AnimDir,
/** 传送步 */
Teleport,
/** 跳跃步 */
Jump
}
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: ObjectMoveType.Dir;
/** 本步移动方向 */
move: FaceDirection;
}
export interface IObjectMoveStepDirFace {
/** 步骤类型 */
type: ObjectMoveType.DirFace;
/** 本步移动方向 */
move: FaceDirection;
/** 本步显式朝向 */
face: FaceDirection;
}
export interface IObjectMoveStepSpeed {
/** 步骤类型 */
type: ObjectMoveType.Speed;
/** 后续移动的每格耗时,单位为 ms */
value: number;
}
export interface IObjectMoveStepFace {
/** 步骤类型 */
type: ObjectMoveType.Face;
/** 要设置的朝向 */
value: FaceDirection;
}
export interface IObjectMoveStepSpecial {
/** 步骤类型 */
type: ObjectMoveType.Special;
/** 特殊步方向 */
direction: ObjectSpecialStep;
}
export interface IObjectMoveAnimDir {
/** 步骤类型 */
type: ObjectMoveType.AnimDir;
/** 动画播放方向 */
dir: ObjectAnimDirection;
}
export interface IObjectMoveTP {
/** 步骤类型 */
type: ObjectMoveType.Teleport;
/** 目标横坐标 */
x: number;
/** 目标纵坐标 */
y: number;
/** 是否相对模式 */
rel: boolean;
}
export interface IObjectMoveJump {
/** 步骤类型 */
type: ObjectMoveType.Jump;
/** 目标横坐标 */
x: number;
/** 目标纵坐标 */
y: number;
/** 是否相对模式 */
rel: boolean;
}
export type ObjectMoveStep =
| IObjectMoveStepDir
| IObjectMoveStepDirFace
| IObjectMoveStepSpeed
| IObjectMoveStepFace
| IObjectMoveStepSpecial
| IObjectMoveAnimDir
| IObjectMoveTP
| IObjectMoveJump;
export interface IMoverController {
/** 本次移动是否已经全部完成 */
done: boolean;
/** 当本次移动结束时兑现 */
onEnd: Promise<void>;
/**
*
* @param steps
*/
push(...steps: Readonly<ObjectMoveStep>[]): void;
/**
*
* @param steps
*/
insert(...steps: Readonly<ObjectMoveStep>[]): void;
/**
*
*/
stop(): Promise<void>;
}
export interface IObjectMoverHooks<T extends IObjectMovable> extends IHookBase {
/**
*
* @param tile
* @param mover
*/
onMoveStart?(tile: T, mover: IObjectMover<T>): Promise<void>;
/**
*
* @param tile
* @param mover
*/
onMoveEnd?(tile: T, mover: IObjectMover<T>): Promise<void>;
/**
*
* @param code
* @param step
* @param tile
* @param mover
*/
onStepStart?(
code: number,
step: Readonly<ObjectMoveStep>,
tile: T,
mover: IObjectMover<T>
): Promise<void>;
/**
*
* @param code
* @param step
* @param tile
* @param mover
*/
onStepEnd?(
code: number,
step: Readonly<ObjectMoveStep>,
tile: T,
mover: IObjectMover<T>
): Promise<void>;
/**
* `setPos`
* @param x
* @param y
* @param tile
* @param mover
*/
onSetPos?(x: number, y: number, tile: T, mover: IObjectMover<T>): void;
/**
* `setFaceDir`
* @param dir
* @param tile
* @param mover
*/
onSetFaceDir?(dir: FaceDirection, tile: T, mover: IObjectMover<T>): void;
/**
* `setMoveDir`
* @param dir
* @param tile
* @param mover
*/
onSetMoveDir?(dir: FaceDirection, tile: T, mover: IObjectMover<T>): void;
}
export interface IObjectMover<T extends IObjectMovable> extends IHookable<
IObjectMoverHooks<T>
> {
/** 当前是否正在移动 */
readonly moving: boolean;
/** 当前绑定的对象 */
readonly tile: T;
/** 当前朝向 */
readonly faceDirection: FaceDirection;
/** 当前移动方向 */
readonly moveDirection: FaceDirection;
/** 当前动画播放方向 */
readonly currAnimDir: ObjectAnimDirection;
/** 当前移动速度,单位毫秒 */
readonly currentSpeed: number;
/** 朝向信息对象 */
readonly faceHandler: IFaceHandler<FaceDirection>;
/**
*
* @param x
* @param y
*/
setPos(x: number, y: number): void;
/**
*
* @param dir
*/
setFaceDir(dir: FaceDirection): void;
/**
*
* @param dir
*/
setMoveDir(dir: FaceDirection): void;
/**
*
* @param x
* @param y
* @param rel 使
*/
tp(x: number, y: number, rel?: boolean): this;
/**
*
* @param x
* @param y
* @param rel 使
*/
jump(x: number, y: number, rel?: boolean): this;
/**
*
* @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<IMoverController> | null;
}
//#endregion
//#region 移动基类
export abstract class ObjectMover<T extends IObjectMovable>
extends Hookable<IObjectMoverHooks<T>>
implements IObjectMover<T>
{
abstract readonly tile: T;
/** 尚未开始执行的移动步骤队列 */
protected readonly moveQueue: Readonly<ObjectMoveStep>[] = [];
/** 当前是否正在移动 */
moving: boolean = false;
/** 当前朝向 */
faceDirection: FaceDirection = FaceDirection.Unknown;
/** 当前移动方向 */
moveDirection: FaceDirection = FaceDirection.Unknown;
/** 当前动画播放方向 */
currAnimDir: ObjectAnimDirection = ObjectAnimDirection.Forward;
/** 当前移动速度,单位毫秒 */
currentSpeed: number = 100;
/** 是否调用了 `IMoverController.stop` 接口 */
private shouldStop: boolean = false;
readonly faceHandler: IFaceHandler<FaceDirection>;
constructor(faceHandler: IFaceHandler<FaceDirection>) {
super();
this.faceHandler = faceHandler;
}
protected createController(
hook: Partial<IObjectMoverHooks<T>>
): IHookController<IObjectMoverHooks<T>> {
return new HookController(this, hook);
}
/**
*
* @param tile
* @param controller
*/
protected abstract onMoveStart(
tile: T,
controller: Readonly<IMoverController>
): Promise<void>;
/**
*
* @param tile
* @param controller
*/
protected abstract onMoveEnd(
tile: T,
controller: Readonly<IMoverController>
): Promise<void>;
/**
* {@link onStepEnd}
* @param step
* @param tile
* @param controller
*/
protected abstract onStepStart(
step: Readonly<ObjectMoveStep>,
tile: T,
controller: Readonly<IMoverController>
): Promise<number>;
/**
*
* @param code {@link onStepStart}
* @param step
* @param tile
* @param controller
*/
protected abstract onStepEnd(
code: number,
step: Readonly<ObjectMoveStep>,
tile: T,
controller: Readonly<IMoverController>
): Promise<ITileLocator>;
/**
*
* @param step
*/
protected pushStep(step: Readonly<ObjectMoveStep>): void {
this.moveQueue.push(step);
}
/**
*
*/
private getCurrentDirection(): FaceDirection {
if (this.moveDirection !== FaceDirection.Unknown) {
return this.moveDirection;
} else {
return this.faceDirection;
}
}
/**
*
* @param step
*/
private prepareStep(step: Readonly<ObjectMoveStep>): void {
switch (step.type) {
case ObjectMoveType.Dir:
this.moveDirection = step.move;
this.faceDirection = step.move;
break;
case ObjectMoveType.DirFace:
this.moveDirection = step.move;
this.faceDirection = step.face;
break;
case ObjectMoveType.Face:
this.faceDirection = step.value;
break;
case ObjectMoveType.Special: {
const dir = this.getCurrentDirection();
if (step.direction === ObjectSpecialStep.Backward) {
const opposite = this.faceHandler.opposite(dir);
this.moveDirection = opposite;
this.faceDirection = dir;
} else {
this.moveDirection = dir;
this.faceDirection = dir;
}
break;
}
case ObjectMoveType.AnimDir:
this.currAnimDir = step.dir;
break;
case ObjectMoveType.Speed:
this.currentSpeed = step.value;
break;
}
}
setPos(x: number, y: number): void {
this.tile.setPos(x, y);
this.forEachHook(hook => {
hook.onSetPos?.(x, y, this.tile, this);
});
}
setFaceDir(dir: FaceDirection): void {
this.faceDirection = dir;
this.forEachHook(hook => {
hook.onSetFaceDir?.(dir, this.tile, this);
});
}
setMoveDir(dir: FaceDirection): void {
this.moveDirection = dir;
this.forEachHook(hook => {
hook.onSetMoveDir?.(dir, this.tile, this);
});
}
tp(x: number, y: number, rel: boolean = false): this {
this.pushStep({
type: ObjectMoveType.Teleport,
x,
y,
rel
});
return this;
}
jump(x: number, y: number, rel: boolean = false): this {
this.pushStep({
type: ObjectMoveType.Jump,
x,
y,
rel
});
return this;
}
step(dir: FaceDirection, count: number = 1): this {
for (let i = 0; i < count; i++) {
this.pushStep({
type: ObjectMoveType.Dir,
move: dir
});
}
return this;
}
stepFace(
move: FaceDirection,
face: FaceDirection,
count: number = 1
): this {
for (let i = 0; i < count; i++) {
this.pushStep({
type: ObjectMoveType.DirFace,
move,
face
});
}
return this;
}
forward(count: number = 1): this {
for (let i = 0; i < count; i++) {
this.pushStep({
type: ObjectMoveType.Special,
direction: ObjectSpecialStep.Forward
});
}
return this;
}
backward(count: number = 1): this {
for (let i = 0; i < count; i++) {
this.pushStep({
type: ObjectMoveType.Special,
direction: ObjectSpecialStep.Backward
});
}
return this;
}
speed(value: number): this {
this.pushStep({
type: ObjectMoveType.Speed,
value
});
return this;
}
face(dir: FaceDirection): this {
this.pushStep({
type: ObjectMoveType.Face,
value: dir
});
return this;
}
animDir(dir: ObjectAnimDirection): this {
this.pushStep({
type: ObjectMoveType.AnimDir,
dir
});
return this;
}
clear(): this {
this.moveQueue.length = 0;
return this;
}
/**
*
* @param queue
*/
private async moveProgress(
queue: Readonly<ObjectMoveStep>[],
controller: Readonly<IMoverController>
) {
// 移动开始
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);
if (this.tile.x !== loc.x && this.tile.y !== loc.y) {
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<void>();
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

View File

@ -1,7 +1,21 @@
import { FaceDirection, type IFaceData } from '@user/data-base';
export const enum FaceDirection {
Unknown,
Left,
Up,
Right,
Down,
LeftUp,
RightUp,
LeftDown,
RightDown
}
export { FaceDirection };
export type { IFaceData } from '@user/data-base';
export interface IFaceData {
/** 图块数字 */
readonly identifier: number;
/** 图块朝向 */
readonly face: FaceDirection;
}
export interface IRoleFaceBinder {
/**

View File

@ -1,6 +1,7 @@
import { FaceDirection } from './types';
/**
* @deprecated `IFaceHandler`
*
* @param dir
*/
@ -28,6 +29,7 @@ export function getFaceMovement(dir: FaceDirection): Loc {
}
/**
* @deprecated `IFaceHandler`
*
* @param dir
* @param unknown `FaceDirection.Unknown`
@ -52,6 +54,7 @@ export function degradeFace(
}
/**
* @deprecated `IFaceHandler`
*
* @param dir
* @param anticlockwise
@ -155,6 +158,7 @@ export function nextFaceDirection(
}
/**
* @deprecated `IFaceHandler`
*
* @param dir
*/

View File

@ -0,0 +1,6 @@
export * from './common';
export * from './replay';
export * from './save';
export * from './store';
export * from './types';

View File

@ -0,0 +1,823 @@
import { logger } from '@motajs/common';
import {
IReplayArray,
IReplayArrayConfig,
IReplayArraySave,
IReplayReadStream,
IReplayStepHandler,
ReplayCommandWidth,
ReplayParamValue
} from './types';
import { ISaveableContent } from '../save';
interface INormalizedParam {
/**
*
*
* - 0: boolean
* - 1: int8
* - 2: int16
* - 3: int32
* - 4: int64
* - 5: float
* - 6: bigint
* - 7: string
* - 8 ~ 255: n - 7
*/
readonly paramType: number;
/** 参数值 */
readonly paramValue: number | Uint8Array;
/** 此参数占据的字节长度,包含类型 token */
readonly byteLength: number;
}
interface IDecodedParam {
/** 参数值 */
readonly paramValue: ReplayParamValue;
/** 参数占据的字节数,包含类型 token */
readonly byteLength: number;
}
interface IDecodedCommand {
/** 指令标识 */
readonly command: number;
/** 指令的参数数量 */
readonly paramCount: number;
}
export class ReplayArray
implements IReplayArray, ISaveableContent<IReplayArraySave>
{
length: number = 0;
commandWidth: ReplayCommandWidth = ReplayCommandWidth.Uint8;
/** 文本编码器 */
private readonly textEncoder: TextEncoder;
/** 文本解码器 */
private readonly textDecoder: TextDecoder;
/** 指令数组缓冲区 */
private commandBuffer: ArrayBuffer;
/** 参数数组缓冲区 */
private paramBuffer: ArrayBuffer;
/** 参数起始索引缓冲区 */
private indexBuffer: ArrayBuffer;
/** 当前参数缓冲区已经使用了多少字节 */
private paramUsed: number;
/** 指令数组的 DataView */
private commandView: DataView;
/** 参数数组的 DataView */
private paramView: DataView;
/** 指令数组的 TypedArray */
private commandArray: Uint8Array;
/** 参数数组的 TypedArray */
private paramArray: Uint8Array;
/** 参数起始索引 TypedArray */
private indexArray: Uint32Array;
/** 指令数组扩容乘数 */
private readonly commandExpand: number;
/** 参数数组扩容乘数 */
private readonly paramExpand: number;
/** 指令数组最大长度 */
private readonly commandMax: number;
/** 参数数组最大长度 */
private readonly paramMax: number;
/** 所有可用的读取流 */
private readonly readStreams: Set<IReplayReadStream>;
constructor(config: Readonly<IReplayArrayConfig>) {
this.textEncoder = new TextEncoder();
this.textDecoder = new TextDecoder();
this.readStreams = new Set();
this.paramUsed = 0;
this.commandWidth = config.commandWidth;
this.commandMax = config.commandMaxLength;
this.paramMax = config.paramMaxLength;
if (config.commandExpandMultiplier < 1) {
const str = config.commandExpandMultiplier.toString();
logger.warn(149, 'command', str);
this.commandExpand = 2;
} else {
this.commandExpand = config.commandExpandMultiplier;
}
if (config.paramExpandMultiplier < 1) {
const str = config.paramExpandMultiplier.toString();
logger.warn(149, 'param', str);
this.paramExpand = 2;
} else {
this.paramExpand = config.paramExpandMultiplier;
}
const commandSize = this.getCommandSize();
this.commandBuffer = new ArrayBuffer(
config.initCommandLength * commandSize
);
this.paramBuffer = new ArrayBuffer(config.initParamLength);
this.indexBuffer = new ArrayBuffer(config.initCommandLength * 4);
this.commandView = new DataView(this.commandBuffer);
this.paramView = new DataView(this.paramBuffer);
this.commandArray = new Uint8Array(this.commandBuffer);
this.paramArray = new Uint8Array(this.paramBuffer);
this.indexArray = new Uint32Array(this.indexBuffer);
}
/**
*
*/
private getCommandSize() {
if (this.commandWidth === ReplayCommandWidth.Uint8) {
return 2;
} else {
return 3;
}
}
/**
*
* @param paramLength
*/
private checkBufferExpand(paramLength: number) {
const commandSize = this.getCommandSize();
const commandLength = Math.ceil(
this.commandBuffer.byteLength / commandSize
);
let expanded = false;
if (commandLength - this.length < 10) {
if (commandLength >= this.commandMax) {
logger.warn(150, 'command', this.commandMax.toString());
} else {
const nextSize = Math.min(
Math.ceil(commandLength * this.commandExpand),
this.commandMax
);
const origin = this.commandArray;
this.commandBuffer = new ArrayBuffer(nextSize * commandSize);
this.commandView = new DataView(this.commandBuffer);
this.commandArray = new Uint8Array(this.commandBuffer);
this.commandArray.set(origin);
const originIndex = this.indexArray;
this.indexBuffer = new ArrayBuffer(nextSize * 4);
this.indexArray = new Uint32Array(this.indexBuffer);
this.indexArray.set(originIndex);
expanded = true;
}
}
if (this.paramBuffer.byteLength - this.paramUsed < paramLength + 10) {
if (this.paramBuffer.byteLength >= this.paramMax) {
logger.warn(150, 'param', this.paramMax.toString());
} else {
const nextSize = Math.min(
Math.ceil(this.paramBuffer.byteLength * this.paramExpand),
this.paramMax
);
const origin = this.paramArray;
this.paramBuffer = new ArrayBuffer(nextSize);
this.paramView = new DataView(this.paramBuffer);
this.paramArray = new Uint8Array(this.paramBuffer);
this.paramArray.set(origin);
expanded = true;
}
}
if (expanded) {
// 重新检查一遍,防止拓展之后还是不够用
this.checkBufferExpand(paramLength);
}
}
//#region 写入与编码
/**
*
*/
private expireStreams() {
this.readStreams.forEach(v => {
v.expired = true;
});
this.readStreams.clear();
}
/**
* Uint8Array
* @param param
*/
private normalizeParam(param: ReplayParamValue): INormalizedParam {
if (typeof param === 'boolean') {
// 0 - boolean
return {
paramType: 0,
paramValue: param ? 1 : 0,
byteLength: 2
};
} else if (typeof param === 'number') {
let type = 1;
let byte = 2;
if (Number.isInteger(param)) {
if (param < 128 && param >= -128) {
// 1 - int8
type = 1;
byte = 2;
} else if (param < 32768 && param >= -32768) {
// 2 - int16
type = 2;
byte = 3;
} else if (param < 2147483648 && param >= -2147483648) {
// 3 - int32
type = 3;
byte = 5;
} else {
// 4 - int64
type = 4;
byte = 9;
}
} else {
// 5 - float
type = 5;
byte = 9;
}
return {
paramType: type,
paramValue: param,
byteLength: byte
};
} else if (typeof param === 'bigint') {
// 6 - bigint
const wall = 2n ** 2047n;
if (param > wall - 1n || param < -wall) {
logger.warn(152);
}
const bit = param.toString(2);
const length = Math.ceil(bit.length / 8);
const arr = new Uint8Array(length);
let total = 0n;
for (let i = 0; i < length; i++) {
const base = param - total;
const remain = base % 256n;
total += remain << (BigInt(i) * 8n);
arr[i] = Number(remain);
}
return {
paramType: 6,
paramValue: arr,
byteLength: arr.length
};
} else if (typeof param === 'string') {
const arr = this.textEncoder.encode(param);
if (arr.length < 248) {
// 8 ~ 255 - string
return {
paramType: arr.length + 8,
paramValue: arr,
byteLength: arr.length
};
} else {
// 7 - string
return {
paramType: 7,
paramValue: arr,
byteLength: arr.length
};
}
}
logger.warn(148, typeof param, String(param));
return {
paramType: 0,
paramValue: 0,
byteLength: 0
};
}
/**
* Uint8Array
* @param params
*/
private normalizeParamList(params: ReplayParamValue[]): INormalizedParam[] {
let arr = params;
if (params.length > 255) {
logger.warn(153, params.length.toString());
arr = params.slice(0, 255);
}
return arr.map(v => this.normalizeParam(v));
}
/**
*
* @param params
*/
private calculateParamsLength(params: INormalizedParam[]): number {
return params.reduce((prev, curr) => prev + curr.byteLength, 0);
}
/**
*
* @param startIndex
* @param paramCount
* @param command
*/
private setCommandArray(
startIndex: number,
paramCount: number,
command: number
) {
this.commandView.setUint8(startIndex, paramCount);
if (this.commandWidth === ReplayCommandWidth.Uint8) {
this.commandView.setUint8(startIndex + 1, command);
} else {
this.commandView.setUint16(startIndex + 1, command);
}
}
/**
*
* @param startIndex
* @param params
*/
private setParamArray(startIndex: number, params: INormalizedParam[]) {
params.forEach(param => {
this.paramView.setInt8(startIndex, param.paramType);
const num = param.paramValue as number;
const arr = param.paramValue as Uint8Array;
if (param.paramType === 0) {
// 0 - boolean
this.paramView.setInt8(startIndex + 1, num);
} else if (param.paramType === 1) {
// 1 - int8
this.paramView.setInt8(startIndex + 1, num);
} else if (param.paramType === 2) {
// 2 - int16
this.paramView.setInt16(startIndex + 1, num);
} else if (param.paramType === 3) {
// 3 - int32
this.paramView.setInt32(startIndex + 1, num);
} else if (param.paramType === 4) {
// 4 - int64
const high = Math.floor(num / 2147483648);
const low = num % 2147483648;
this.paramView.setInt32(startIndex + 1, low);
this.paramView.setInt32(startIndex + 5, high);
} else if (param.paramType === 5) {
// 5 - float
this.paramView.setFloat64(startIndex + 1, num);
} else if (param.paramType === 6) {
// 6 - bigint
this.paramArray[startIndex + 1] = arr.length;
this.paramArray.set(arr, startIndex + 2);
} else if (param.paramType === 7) {
// 7 - string
this.paramView.setInt32(startIndex + 1, arr.length);
this.paramArray.set(arr, startIndex + 5);
} else {
// 8 ~ 256 - string
this.paramArray.set(arr, startIndex + 1);
}
});
}
add(command: number, params: ReplayParamValue[]): void {
const normalized = this.normalizeParamList(params);
const length = this.calculateParamsLength(normalized);
this.checkBufferExpand(length);
// 追加指令
const commandSize = this.getCommandSize();
const commandStart = commandSize * this.length;
this.setCommandArray(commandStart, params.length, command);
// 追加参数
this.setParamArray(this.paramUsed, normalized);
this.indexArray[this.length] = this.paramUsed;
this.paramUsed += length;
this.length++;
this.expireStreams();
}
insert(index: number, command: number, params: ReplayParamValue[]): void {
const normalized = this.normalizeParamList(params);
const length = this.calculateParamsLength(normalized);
this.checkBufferExpand(length);
const commandSize = this.getCommandSize();
// 先进行位移
const commandStart = index * commandSize;
const paramStart = this.indexArray[index];
this.commandArray.copyWithin(commandStart + commandSize, commandStart);
this.paramArray.copyWithin(paramStart, paramStart + length);
this.indexArray.copyWithin(index, index + 1);
// 然后进行赋值操作,索引数组因为这一个指令的起始索引其实没变,所以不需要赋值
this.setCommandArray(commandStart, params.length, command);
this.setParamArray(paramStart, normalized);
this.length++;
this.paramUsed += length;
// 最后把后面的内容的索引数组增加 length 即可
for (let i = index + 1; i < this.length; i++) {
this.indexArray[i] += length;
}
this.expireStreams();
}
delete(index: number): void {
const commandSize = this.getCommandSize();
const commandStart = index * commandSize;
const paramStart = this.indexArray[index];
const nextParam = this.indexArray[index + 1];
const paramLength = nextParam - paramStart;
// 直接进行位移
this.commandArray.copyWithin(commandStart, commandStart + commandSize);
this.paramArray.copyWithin(paramStart, nextParam);
this.indexArray.copyWithin(index, index + 1);
this.length--;
this.paramUsed -= paramLength;
// 然后把后续值赋零
const commandLast = this.length * commandSize;
const paramLast = this.paramUsed;
this.commandArray[commandLast] = 0;
this.commandArray[commandLast + 1] = 0;
this.commandArray[commandLast + 2] = 0;
for (let i = 0; i < paramLength; i++) {
this.paramArray[paramLast + i] = 0;
}
// 最后把后面的索引减少 paramLength
for (let i = paramStart; i < this.length; i++) {
this.indexArray[i] -= paramLength;
}
this.expireStreams();
}
set(index: number, command: number, params: ReplayParamValue[]): void {
const normalized = this.normalizeParamList(params);
const length = this.calculateParamsLength(normalized);
const paramStart = this.indexArray[index];
const nextParam = this.indexArray[index + 1];
const paramLength = nextParam - paramStart;
const deltaLength = length - paramLength;
if (deltaLength > 0) {
this.checkBufferExpand(deltaLength);
}
// 先写入指令
const commandSize = this.getCommandSize();
const commandStart = index * commandSize;
this.setCommandArray(commandStart, params.length, command);
// 然后根据差值位移参数数组,如果参数长度减少还需要将最后几项置零
this.paramArray.copyWithin(nextParam + deltaLength, nextParam);
this.paramUsed += deltaLength;
if (deltaLength < 0) {
const zeros = -deltaLength;
const paramLast = this.paramUsed;
for (let i = 0; i < zeros; i++) {
this.paramArray[paramLast + i] = 0;
}
}
// 然后设置参数数组
this.setParamArray(paramStart, normalized);
// 最后调整索引数组
for (let i = paramStart + 1; i < this.length; i++) {
this.indexArray[i] += deltaLength;
}
this.expireStreams();
}
setCommandWidth(width: ReplayCommandWidth): void {
const oldWidth = this.commandWidth;
const oldSize = this.getCommandSize();
this.commandWidth = width;
const newSize = this.getCommandSize();
const commandLength = this.commandBuffer.byteLength / oldSize;
const length = Math.floor(commandLength * newSize);
const newBuffer = new ArrayBuffer(length);
const newView = new DataView(newBuffer);
for (let i = 0; i < this.length; i++) {
const oldStart = i * oldSize;
const newStart = i * newSize;
// 先读取旧指令
const count = this.commandView.getUint8(oldStart);
let oldCommand = 0;
if (oldWidth === ReplayCommandWidth.Uint8) {
oldCommand = this.commandView.getUint8(oldStart + 1);
} else {
oldCommand = this.commandView.getUint16(oldStart + 1);
}
// 再写入新的 DataView
newView.setUint8(newStart, count);
if (width === ReplayCommandWidth.Uint8) {
if (oldCommand > 255) {
logger.warn(154, oldCommand.toString());
}
newView.setUint8(newStart + 1, oldCommand);
} else {
newView.setUint16(newStart + 1, oldCommand);
}
}
this.commandBuffer = newBuffer;
this.commandArray = new Uint8Array(newBuffer);
this.commandView = newView;
this.expireStreams();
}
//#endregion
//#region 读取与解码
/**
*
* @param startIndex
*/
private decodeCommand(startIndex: number): IDecodedCommand {
const count = this.commandView.getUint8(startIndex);
let command = 0;
if (this.commandWidth === ReplayCommandWidth.Uint8) {
command = this.commandView.getUint8(startIndex + 1);
} else {
command = this.commandView.getUint16(startIndex + 1);
}
return {
command,
paramCount: count
};
}
/**
*
* @param startIndex
*/
private decodeParam(startIndex: number): IDecodedParam {
const type = this.paramArray[startIndex];
let value: ReplayParamValue = 0;
let byte = 0;
if (type === 0) {
// 0 - boolean
const next = this.paramArray[startIndex + 1];
byte = 2;
if (next === 1) {
value = true;
} else if (next === 0) {
value = false;
} else {
logger.warn(151, next.toString());
value = false;
}
} else if (type === 1) {
// 1 - int8
byte = 2;
value = this.paramView.getInt8(startIndex + 1);
} else if (type === 2) {
// 2 - int16
byte = 3;
value = this.paramView.getInt16(startIndex + 1);
} else if (type === 3) {
// 3 - int32
byte = 5;
value = this.paramView.getInt32(startIndex + 1);
} else if (type === 4) {
// 4 - int64
const low = this.paramView.getInt32(startIndex + 1);
const high = this.paramView.getInt32(startIndex + 5);
byte = 9;
value = low + high * 2147483647;
} else if (type === 5) {
// 5 - float
byte = 9;
value = this.paramView.getFloat64(startIndex + 1);
} else if (type === 6) {
// 6 - bigint
const length = this.paramView.getInt8(startIndex + 1);
let base = 0n;
for (let i = 0; i < length; i++) {
const num = this.paramView.getInt8(startIndex + 2 + i);
base += BigInt(num) << (8n * BigInt(i));
}
byte = length + 2;
value = base;
} else if (type === 7) {
// 7 - string
const length = this.paramView.getInt32(startIndex + 1);
const endIndex = startIndex + 5 + length;
const arr = this.paramArray.slice(startIndex + 5, endIndex);
byte = length + 2;
value = this.textDecoder.decode(arr);
} else {
// 8 ~ 255 - string
const length = type - 7;
const endIndex = startIndex + 1 + length;
const arr = this.paramArray.slice(startIndex + 5, endIndex);
byte = length + 1;
value = this.textDecoder.decode(arr);
}
return {
paramValue: value,
byteLength: byte
};
}
/**
*
* @param startIndex
* @param count
*/
private decodeParamList(
startIndex: number,
count: number
): IDecodedParam[] {
const arr: IDecodedParam[] = [];
let index = startIndex;
for (let i = 0; i < count; i++) {
const decoded = this.decodeParam(index);
index += decoded.byteLength;
arr.push(decoded);
}
return arr;
}
/**
*
* @param command
* @param params
* @param index
*/
private createReplayStepHandler(
command: number,
params: IDecodedParam[],
index: number
): IReplayStepHandler {
return {
command,
params: params.map(v => v.paramValue),
index
};
}
get(index: number): IReplayStepHandler {
const commandSize = this.getCommandSize();
const commandStart = index * commandSize;
const paramStart = this.indexArray[index];
const command = this.decodeCommand(commandStart);
const params = this.decodeParamList(paramStart, command.paramCount);
return this.createReplayStepHandler(command.command, params, index);
}
createReadStream(startIndex: number = 0): Readonly<IReplayReadStream> {
const commandSize = this.getCommandSize();
let currCommand = startIndex * commandSize;
let currParam = this.indexArray[startIndex];
const stream: IReplayReadStream = {
index: startIndex,
length: this.length,
expired: false,
read: () => {
if (stream.expired) {
logger.warn(155);
}
if (stream.index >= this.length) return null;
const { command, paramCount } = this.decodeCommand(currCommand);
const params = this.decodeParamList(currParam, paramCount);
stream.index++;
currCommand += commandSize;
currParam += params.reduce(
(prev, curr) => prev + curr.byteLength,
0
);
const index = stream.index;
return this.createReplayStepHandler(command, params, index);
},
destroy: () => {
this.readStreams.delete(stream);
}
};
this.readStreams.add(stream);
return stream;
}
//#endregion
//#region 重建方法
rebuildIndexArray(): void {
const commandSize = this.getCommandSize();
let currCommand = 0;
let currParam = 0;
// 需要对每个参数进行解码,然后 cumsum
for (let i = 0; i < this.length; i++) {
const { paramCount } = this.decodeCommand(currCommand);
const params = this.decodeParamList(currParam, paramCount);
this.indexArray[i] = currParam;
currCommand += commandSize;
currParam += params.reduce(
(prev, curr) => prev + curr.byteLength,
0
);
}
this.paramUsed = currParam;
}
setReplayArray(
commandWidth: ReplayCommandWidth,
commandBuffer: ArrayBuffer,
paramBuffer: ArrayBuffer,
length: number
): void {
this.commandWidth = commandWidth;
this.commandBuffer = commandBuffer;
this.commandArray = new Uint8Array(commandBuffer);
this.commandView = new DataView(commandBuffer);
this.paramBuffer = paramBuffer;
this.paramArray = new Uint8Array(paramBuffer);
this.paramView = new DataView(paramBuffer);
const commandSize = this.getCommandSize();
const commandBufferLength = Math.ceil(
commandBuffer.byteLength / commandSize
);
this.indexBuffer = new ArrayBuffer(commandBufferLength * 4);
this.indexArray = new Uint32Array(this.indexBuffer);
this.length = length;
this.rebuildIndexArray();
}
//#endregion
getCommandArray(): ArrayBuffer {
return this.commandBuffer;
}
getParamArray(): ArrayBuffer {
return this.paramBuffer;
}
//#region 存读档
saveState(): IReplayArraySave {
return {
commands: this.commandBuffer,
params: this.paramBuffer,
metadata: {
commandWidth: this.commandWidth
}
};
}
loadState(state: IReplayArraySave): void {
this.commandBuffer = state.commands;
this.paramBuffer = state.params;
this.commandWidth = state.metadata.commandWidth;
this.commandView = new DataView(this.commandBuffer);
this.paramView = new DataView(this.paramBuffer);
this.commandArray = new Uint8Array(this.commandBuffer);
this.paramArray = new Uint8Array(this.paramBuffer);
this.indexArray = new Uint32Array(this.indexBuffer);
this.expireStreams();
}
}

View File

@ -0,0 +1,183 @@
import { logger } from '@motajs/common';
import { IReplaySystem } from './types';
interface IReplaySafetyCollection {
/** 这个安全对象(由 `@shouldReplay()` 触发)的名称,即传入给 `@shouldReplay` 的参数 */
readonly name: string;
/** 这个安全对象所包含的所有字对象 */
readonly messages: IReplaySafetyCollection[];
}
interface IReplaySafetyDetailQueue {
/** 详细信息代码 */
readonly code: number;
/** 此详细信息对应的安全对象 */
readonly collection: IReplaySafetyCollection;
}
type ReplayDecorator = <
Return,
This,
Func extends (this: This, ...args: any[]) => Return
>(
method: Func,
context: ClassMethodDecoratorContext
) => (this: This, ...args: any[]) => Return;
/** 本次收集使用的录像系统 */
let replaySystem: IReplaySystem | null = null;
/** 当前是否正在进行录像收集 */
let collecting: boolean = false;
/** 收集之前录像系统的录像长度 */
let beforeLength: number = 0;
/** 收集时是否有 `@ignoreReplay` 调用 */
let shouldIgnore: boolean = false;
/** 录像安全收集根对象 */
const collection: IReplaySafetyCollection = {
name: 'root',
messages: []
};
/** 当前录像安全收集对象 */
let currentCollection: IReplaySafetyCollection | null = null;
/** 详细信息 code */
let detailCode = 0;
/** 录像收集详细信息队列,保留 50 个以确保可以在控制台重复输出 */
const detailQueue: IReplaySafetyDetailQueue[] = [];
/**
*
* @param system
*/
export function beginReplaySafetyCollection(system: IReplaySystem): void {
if (collecting || replaySystem) {
logger.warn(159);
return;
}
replaySystem = system;
collecting = true;
beforeLength = system.route.length;
collection.messages.length = 0;
currentCollection = collection;
}
/**
*
*/
export function endReplaySafetyCollection(): void {
if (!collecting || !replaySystem) {
logger.warn(160);
return;
}
if (shouldIgnore) return;
if (replaySystem.route.length > beforeLength) return;
if (collection.messages.length === 0) return;
// 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用
const code = detailCode++;
const col: IReplaySafetyCollection = {
name: `detail#${code}`,
messages: collection.messages.slice()
};
const command = `Mota.require('@user/data-common').logReplaySafetyDetail(${code});`;
const simplified = [...collection.messages]
.sort((a, b) => b.messages.length - a.messages.length)
.map(v => v.name)
.join('\n');
logger.warn(161, command, simplified);
while (detailQueue.length > 50) {
detailQueue.shift();
}
detailQueue.push({
code,
collection: col
});
}
/**
*
* @param collection
*/
function logCollection(collection: IReplaySafetyCollection): void {
console.group(collection.name);
const sorted = collection.messages.toSorted(
(a, b) => b.messages.length - a.messages.length
);
for (const msg of sorted) {
if (msg.messages.length === 0) {
console.log(msg.name);
} else {
logCollection(msg);
}
}
console.groupEnd();
}
/**
*
* @param code
*/
export function logReplaySafetyDetail(code: number): void {
const root = detailQueue.find(v => v.code === code);
if (!root) {
logger.warn(162, code.toString());
return;
}
logCollection(root.collection);
}
/**
*
* @param message 使
*/
export function shouldReplay(message: string): ReplayDecorator {
return function <
Return,
This,
Args extends any[],
Func extends (this: This, ...args: Args) => Return
>(
this: This,
method: Func,
context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return {
return function (this: This, ...args: Args): Return {
const before = currentCollection;
if (!before) return method.apply(this, args);
const exist = before.messages.find(v => v.name === message);
const newCollection: IReplaySafetyCollection = exist ?? {
name: `${String(context.name)}: ${message}`,
messages: []
};
if (!exist) {
before.messages.push(newCollection);
}
currentCollection = newCollection;
const result = method.apply(this, args);
currentCollection = before;
return result;
};
};
}
/**
*
* 使
*/
export function ignoreReplay(): ReplayDecorator {
return function <
Return,
This,
Args extends any[],
Func extends (this: This, ...args: Args) => Return
>(this: This, method: Func): (this: This, ...args: Args) => Return {
return function (this: This, ...args: Args): Return {
shouldIgnore = true;
return method.apply(this, args);
};
};
}

View File

@ -0,0 +1,3 @@
export * from './array';
export * from './sandbox';
export * from './types';

View File

@ -0,0 +1,146 @@
import {
Hookable,
HookController,
IHookController,
logger
} from '@motajs/common';
import {
IReplayArray,
IReplayReadStream,
IReplaySandbox,
IReplaySandboxHook,
IReplaySystem
} from './types';
export class ReplaySandbox
extends Hookable<IReplaySandboxHook>
implements IReplaySandbox
{
pausing: boolean = true;
speed: number = 1;
ended: boolean = false;
playing: boolean = false;
/** 下一步是否需要暂停播放 */
private needPause: boolean = false;
/** 当前录像是否播放完毕 */
private ending: boolean = false;
/** 暂停 `Promise` 的 `resolve` 函数 */
private pauseResolve: () => void = () => {};
/** 录像的流式读取器 */
private reader: Readonly<IReplayReadStream>;
constructor(
readonly route: IReplayArray,
readonly system: IReplaySystem,
startIndex: number
) {
super();
this.reader = route.createReadStream(startIndex);
}
protected createController(
hook: Partial<IReplaySandboxHook>
): IHookController<IReplaySandboxHook> {
return new HookController(this, hook);
}
setSpeed(speed: number): void {
this.speed = speed;
this.forEachHook(hook => hook.onSpeedSet?.(speed));
}
getReplayed(): number {
return this.reader.index;
}
/**
*
*/
private async startReplayLoop() {
if (this.ended) return;
if (this.reader.expired) {
logger.warn(156);
return;
}
while (true) {
if (this.needPause && !this.pausing) {
this.pausing = true;
this.needPause = false;
this.pauseResolve();
this.forEachHook(hook => hook.onPauseReplay?.());
break;
}
if (this.reader.expired) {
logger.warn(156);
break;
}
const success = await this.step();
if (!success) break;
}
if (this.ending) {
this.ended = true;
}
}
play(): void {
if (this.playing || this.ended) return;
this.pausing = false;
this.playing = true;
this.forEachHook(hook => hook.onStartReplay?.());
this.startReplayLoop();
}
pause(): Promise<void> {
if (this.pausing || this.ended || !this.playing) {
return Promise.resolve();
}
const { promise, resolve } = Promise.withResolvers<void>();
this.pauseResolve = resolve;
this.needPause = true;
return promise;
}
resume(): void {
if (!this.pausing || !this.playing || this.ended) return;
this.pausing = false;
this.forEachHook(hook => hook.onResumeReplay?.());
this.startReplayLoop();
}
async stop(): Promise<void> {
if (this.pausing || this.ended || !this.playing) return;
await this.pause();
this.forEachHook(hook => hook.onStopReplay?.());
}
async step(): Promise<boolean> {
if (!this.pausing || !this.playing || this.ended) return false;
if (this.reader.expired) {
logger.warn(156);
return false;
}
const next = this.reader.read();
if (!next) {
this.ending = true;
return false;
}
const command = this.system.getCommand(next.command);
if (!command) {
logger.warn(157, next.command.toString());
return false;
}
const success = await command.execute(next);
if (!success) {
logger.warn(
158,
next.command.toString(),
JSON.stringify(next.params)
);
return false;
}
await Promise.all(this.forEachHook(hook => hook.onStep?.(next)));
return true;
}
}

View File

@ -0,0 +1,87 @@
import {
Hookable,
HookController,
IHookController,
logger
} from '@motajs/common';
import {
IReplayArray,
IReplayCommand,
IReplaySandbox,
IReplaySandboxConfig,
IReplaySystem,
IReplaySystemHook,
ReplayCommandWidth,
ReplayParamValue
} from './types';
import { ReplayArray } from './array';
import { ReplaySandbox } from './sandbox';
export class ReplaySystem
extends Hookable<IReplaySystemHook>
implements IReplaySystem
{
replaying: boolean = false;
sandbox: IReplaySandbox | null = null;
route: IReplayArray;
/** 所有注册的指令 */
private readonly commands: Map<number, IReplayCommand> = new Map();
constructor() {
super();
this.route = new ReplayArray({
initCommandLength: 1000,
initParamLength: 10000,
commandExpandMultiplier: 1.2,
paramExpandMultiplier: 1.2,
commandWidth: ReplayCommandWidth.Uint8,
commandMaxLength: 1_000_000,
paramMaxLength: 10_000_000
});
}
protected createController(
hook: Partial<IReplaySystemHook>
): IHookController<IReplaySystemHook> {
return new HookController(this, hook);
}
registerCommand(code: number, command: IReplayCommand): void {
if (this.commands.has(code)) {
logger.warn(163);
return;
}
this.commands.set(code, command);
}
getCommand(code: number): IReplayCommand | null {
return this.commands.get(code) ?? null;
}
record(code: number, ...params: ReplayParamValue[]): void {
this.route.add(code, params);
this.forEachHook(hook =>
hook.onRecordCommand?.(code, this.route.length, params)
);
}
createReplaySandbox(
config: Readonly<IReplaySandboxConfig>
): IReplaySandbox {
config.reseter.reset(config.save);
const sandbox = new ReplaySandbox(
config.route,
this,
config.startIndex ?? 0
);
this.sandbox = sandbox;
this.forEachHook(hook => hook.onCreateSandbox?.(sandbox));
return sandbox;
}
releaseSandbox(): void {
this.sandbox?.stop();
this.sandbox = null;
}
}

View File

@ -0,0 +1,354 @@
import { IHookable, IHookBase } from '@motajs/common';
export type ReplayParamValue = number | string | boolean | bigint;
export interface IReplayStepHandler {
/** 当前步的指令标识 */
readonly command: number;
/** 当前步的参数列表 */
readonly params: readonly ReplayParamValue[];
/** 当前步在录像中的索引 */
readonly index: number;
}
export interface IReplayCommand {
/**
*
* @param step
* @returns `Promise`
*/
execute(step: IReplayStepHandler): Promise<boolean>;
}
export interface IReplaySandboxHook extends IHookBase {
/**
*
* @param step
*/
onStep?(step: IReplayStepHandler): Promise<void>;
/**
*
* @param speed
*/
onSpeedSet?(speed: number): void;
/**
*
*/
onStartReplay?(): void;
/**
*
*/
onStopReplay?(): void;
/**
*
*/
onPauseReplay?(): void;
/**
*
*/
onResumeReplay?(): void;
}
export interface IReplaySandbox extends IHookable<IReplaySandboxHook> {
/** 是否处于暂停状态 */
readonly pausing: boolean;
/** 当前录像是否已经播放完毕 */
readonly ended: boolean;
/** 播放倍率1 为正常速度 */
readonly speed: number;
/** 当前是否正在播放,当调用 `play` 后,调用 `stop` 前,此值会是 `true` */
readonly playing: boolean;
/**
*
* @param speed
*/
setSpeed(speed: number): void;
/**
*
*/
getReplayed(): number;
/**
*
*/
play(): void;
/**
*
*/
pause(): Promise<void>;
/**
*
*/
resume(): void;
/**
*
*/
stop(): Promise<void>;
/**
* Promise
* @returns
*/
step(): Promise<boolean>;
}
export interface IStateReseter {
/**
*
* @param save
*/
reset(save?: Map<string, unknown>): void;
}
export const enum ReplayCommandWidth {
/** 1 字节宽度,支持 0-255 的指令 ID */
Uint8 = 1,
/** 2 字节宽度,支持 0-65535 的指令 ID */
Uint16 = 2
}
export interface IReplayReadStream {
/** 已经读取过的录像步数量,即上一次调用 `read` 时读取的录像步索引 */
index: number;
/** 录像总步数 */
length: number;
/** 当前录像读取流是否已经过期,当有录像被修改时此值会变为 true */
expired: boolean;
/**
*
* null
*/
read(): IReplayStepHandler | null;
/**
* 使
*/
destroy(): void;
}
export interface IReplayArrayConfig {
/** 初始指令数组长度,以指令数量为单位 */
initCommandLength: number;
/** 初始参数数组长度,以字节为单位 */
initParamLength: number;
/** 指令数组扩容乘数 */
commandExpandMultiplier: number;
/** 参数数组扩容乘数 */
paramExpandMultiplier: number;
/** 录像指令码位宽 */
commandWidth: ReplayCommandWidth;
/** 指令最大长度,即最多有多少个录像步 */
commandMaxLength: number;
/** 参数数组最大长度 */
paramMaxLength: number;
}
export interface IReplayMetadataSave {
/** 当前录像使用的指令码宽度 */
readonly commandWidth: ReplayCommandWidth;
}
export interface IReplayArraySave {
/**
* Uint8
* Uint16 uint16
*/
readonly commands: ArrayBuffer;
/**
*
*
*
* - 0: boolean --- 2 Byte
* - 1: int8 --- 2 Byte
* - 2: int16 --- 3 Byte
* - 3: int32 --- 5 Byte
* - 4: int64 --- 9 Byte
* - 5: float --- 9 Byte
* - 6: bigint --- n + 1 Byte, n bigint
* - 7: string --- n + 1 Byte, n
* - 8 ~ 255: n - 7 --- n + 1 Byte, n
*/
readonly params: ArrayBuffer;
/** 录像元数据 */
readonly metadata: IReplayMetadataSave;
}
export interface IReplayArray {
/** 录像中的总步数 */
readonly length: number;
/** 录像的指令码宽度 */
readonly commandWidth: ReplayCommandWidth;
/**
*
* @param command
* @param params
*/
add(command: number, params: ReplayParamValue[]): void;
/**
*
*
* @param index
* @param command
* @param params
*/
insert(index: number, command: number, params: ReplayParamValue[]): void;
/**
*
*
* @param index
*/
delete(index: number): void;
/**
*
*
* @param index
* @param command
* @param params
*/
set(index: number, command: number, params: ReplayParamValue[]): void;
/**
*
* @param index
*/
get(index: number): IReplayStepHandler;
/**
*
* @param startIndex 0
*/
createReadStream(startIndex?: number): Readonly<IReplayReadStream>;
/**
*
* @param width
*/
setCommandWidth(width: ReplayCommandWidth): void;
/**
*
*/
getCommandArray(): ArrayBuffer;
/**
*
*
* - 0: boolean
* - 1: int8
* - 2: int16
* - 3: int32
* - 4: int64
* - 5: float
* - 6: bigint
* - 7: string
* - 8 ~ 255: n - 7
*/
getParamArray(): ArrayBuffer;
/**
*
*/
rebuildIndexArray(): void;
/**
*
* @param commandWidth
* @param commandBuffer
* @param paramBuffer
* @param length
*/
setReplayArray(
commandWidth: ReplayCommandWidth,
commandBuffer: ArrayBuffer,
paramBuffer: ArrayBuffer,
length: number
): void;
}
export interface IReplaySystemHook extends IHookBase {
/**
*
* @param sandbox
*/
onCreateSandbox?(sandbox: IReplaySandbox): void;
/**
*
* @param command
* @param index
* @param params
*/
onRecordCommand?(
command: number,
index: number,
params: ReplayParamValue[]
): void;
}
export interface IReplaySandboxConfig {
/** 录像播放沙箱使用的录像数组 */
route: IReplayArray;
/** 录像播放前进行的状态重置使用的状态重置对象 */
reseter: IStateReseter;
/** 录像播放的起始索引,默认为 0 */
startIndex?: number;
/** 初始化使用的存档对象 */
save?: Map<string, unknown>;
}
export interface IReplaySystem extends IHookable<IReplaySystemHook> {
/** 当前是否处在录像播放状态 */
readonly replaying: boolean;
/** 当前正在播放的录像沙箱实例 */
readonly sandbox: IReplaySandbox | null;
/** 当前的录像操作器,用于直接操作或读取录像数据 */
readonly route: IReplayArray;
/**
*
* @param code
* @param command
*/
registerCommand(code: number, command: IReplayCommand): void;
/**
*
* @param code
*/
getCommand(code: number): IReplayCommand | null;
/**
*
* @param code
* @param params
*/
record(code: number, ...params: ReplayParamValue[]): void;
/**
*
* @param config
*/
createReplaySandbox(config: Readonly<IReplaySandboxConfig>): IReplaySandbox;
/**
*
*/
releaseSandbox(): void;
}

View File

@ -0,0 +1,2 @@
export * from './system';
export * from './types';

View File

@ -0,0 +1,223 @@
import Dexie, { Table } from 'dexie';
import { logger } from '@motajs/common';
import {
IGlobalTrasaction,
ISaveRead,
ISaveSystem,
ISaveSystemConfig,
ISaveableContent,
SaveCompression
} from './types';
import { isNil } from 'lodash-es';
interface ISaveRecord {
/** 存档 id */
readonly id: number;
/** 存档压缩级别 */
readonly compression: SaveCompression;
/** 存档内容 */
readonly data: Map<string, unknown>;
}
interface IGlobalRecord {
/** 全局存储的键名 */
readonly key: string;
/** 全局存储的内容 */
readonly value: unknown;
}
export class GlobalTransaction implements IGlobalTrasaction {
constructor(readonly table: Table<IGlobalRecord, string>) {}
async get<T>(key: string): Promise<T> {
const record = await this.table.get(key);
return record!.value as T;
}
async set(key: string, value: unknown): Promise<void> {
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<Partial<ISaveSystemConfig>>): 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<string, ISaveableContent<unknown>>
): ISaveRead | null {
if (this.undoStack.length === 0) return null;
const data = new Map<string, unknown>();
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<string, ISaveableContent<unknown>>
): ISaveRead | null {
if (this.redoStack.length === 0) return null;
const data = new Map<string, unknown>();
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<string, ISaveableContent<unknown>>): void {
const data = new Map<string, unknown>();
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<void> {
if (this.undoStack.length === 0) return;
const t0 = performance.now();
const top = this.undoStack[this.undoStack.length - 1];
const table = this.db.table<ISaveRecord, number>('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<string, ISaveableContent<unknown>>
): Promise<void> {
const t0 = performance.now();
const data = new Map<string, unknown>();
for (const [key, content] of state) {
data.set(key, content.saveState(this.commonSaveLevel));
}
const table = this.db.table<ISaveRecord, number>('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<ISaveRead | null> {
const table = this.db.table<ISaveRecord, number>('saves');
const record = await table.get(id);
if (record === undefined) return null;
return { compression: record.compression, data: record.data };
}
async deleteSave(id: number): Promise<void> {
const table = this.db.table<ISaveRecord, number>('saves');
await table.delete(id);
}
async getLastSlot(): Promise<number> {
const value = await this.getGlobal<number | undefined>('lastSlot');
return value ?? 0;
}
async getGlobal<T>(key: string): Promise<T | null> {
const table = this.db.table<IGlobalRecord, string>('global');
const record = await table.get(key);
if (!record) return null;
else return record.value as T;
}
async setGlobal(key: string, value: unknown): Promise<void> {
const table = this.db.table<IGlobalRecord, string>('global');
await table.put({ key, value });
}
async startGlobalTransaction<R>(
handle: (transaction: IGlobalTrasaction) => PromiseLike<R>
): Promise<R> {
const globalTable = this.db.table<IGlobalRecord, string>('global');
return this.db.transaction('rw', globalTable, () => {
return handle(new GlobalTransaction(globalTable));
});
}
}

View File

@ -0,0 +1,165 @@
import { Dexie, Table } from 'dexie';
export const enum SaveCompression {
/** 不进行压缩,仅提取必要数据 */
NoCompression,
/** 进行小幅度压缩,以性能为主要考虑目标 */
LowCompression,
/** 进行大幅度压缩,以体积为主要考虑目标 */
HighCompression
}
export interface ISaveableContent<T> {
/**
* `structuedClone`
* @param compression
*/
saveState(compression: SaveCompression): T;
/**
*
* @param state
* @param compression
*/
loadState(state: T, compression: SaveCompression): void;
}
export interface IGlobalTrasaction {
/** 全局存储对应的表 */
readonly table: Table<unknown, string>;
/**
*
* @param key
*/
get<T>(key: string): Promise<T>;
/**
*
* @param key
* @param value
*/
set(key: string, value: unknown): Promise<void>;
}
export interface ISaveSystemConfig {
/** 自动存档使用的压缩等级 */
autosaveLevel: SaveCompression;
/** 普通存档使用的压缩等级 */
commonSaveLevel: SaveCompression;
/** 可容忍的最大存档耗时,超过此值会抛出警告 */
saveTimeTolerance: number;
/** 可容忍的最大自动存档耗时,超过此值会抛出警告 */
autosaveTimeTolerance: number;
/** 自动存档栈最大大小 */
autosaveStackSize: number;
}
export interface ISaveRead {
/** 该存档的压缩等级 */
readonly compression: SaveCompression;
/** 该存档的数据 */
readonly data: Map<string, unknown>;
}
export interface ISaveSystem {
/** Dexie 数据库 */
readonly db: Dexie;
/**
*
* @param name
*/
init(name: string): void;
/**
*
* @param config
*/
config(config: Readonly<Partial<ISaveSystemConfig>>): void;
/**
* `undo` `redo`
* @param current `redo`
*/
undoAutosave(
current: Map<string, ISaveableContent<unknown>>
): ISaveRead | null;
/**
* `redo` `undo`
* @param current `undo`
*/
redoAutosave(
current: Map<string, ISaveableContent<unknown>>
): ISaveRead | null;
/**
*
*/
getUndoStack(): ISaveRead[];
/**
*
*/
getRedoStack(): ISaveRead[];
/**
*
* @param state
*/
autosave(state: Map<string, ISaveableContent<unknown>>): void;
/**
* `undo` `IndexedDB`
*/
saveAutosaveToDB(): Promise<void>;
/**
*
* @param id id
* @param state
*/
save(
id: number,
state: Map<string, ISaveableContent<unknown>>
): Promise<void>;
/**
* id
* @param id id
*/
load(id: number): Promise<ISaveRead | null>;
/**
*
* @param id id
*/
deleteSave(id: number): Promise<void>;
/**
*
*/
getLastSlot(): Promise<number>;
/**
*
* @param key
*/
getGlobal<T>(key: string): Promise<T | null>;
/**
*
* @param key
* @param value
*/
setGlobal(key: string, value: unknown): Promise<void>;
/**
*
* @param handle
*/
startGlobalTransaction<R>(
handle: (transaction: IGlobalTrasaction) => PromiseLike<R>
): Promise<R>;
}

View File

@ -0,0 +1,3 @@
export * from './itemStore';
export * from './tileStore';
export * from './types';

View File

@ -0,0 +1,44 @@
import { logger } from '@motajs/common';
import {
IItemLegacyConverter,
IItemRawData,
IItemStore,
ItemCategory
} from './types';
export class ItemStore<THero, TLegacy> implements IItemStore<THero, TLegacy> {
/** 以道具图块数字为键的原始道具定义表 */
private readonly dataMap: Map<number, IItemRawData<THero>> = new Map();
/** 当前挂载的旧样板道具转换器 */
private converter: IItemLegacyConverter<THero, TLegacy> | null = null;
getData(num: number): IItemRawData<THero> | null {
return this.dataMap.get(num) ?? null;
}
getCategory(num: number): ItemCategory {
return this.dataMap.get(num)?.category ?? ItemCategory.Unknown;
}
addItem(data: IItemRawData<THero>): void {
this.dataMap.set(data.num, data);
}
attachLegacyConverter(
converter: IItemLegacyConverter<THero, TLegacy>
): void {
this.converter = converter;
}
fromLegacy(num: number, legacy: TLegacy): IItemRawData<THero> {
const converter = this.converter;
if (!converter) {
logger.error(57);
throw new Error('Expected a item legacy converter.');
}
const data = converter.fromLegacy(num, legacy);
this.addItem(data);
return data;
}
}

View File

@ -0,0 +1,102 @@
import { isNil } from 'lodash-es';
import { logger } from '@motajs/common';
import {
ITileLegacyConverter,
ITileRawData,
ITileStore,
TileType
} from './types';
export class TileStore<TLegacy = unknown> implements ITileStore<TLegacy> {
/** 以图块数字为键的原始图块定义表 */
private readonly dataMap: Map<number, ITileRawData> = new Map();
/** 由图块 id 反查图块数字的映射表 */
private readonly idMap: Map<string, number> = new Map();
/** 由图块数字反查图块 id 的映射表 */
private readonly numMap: Map<number, string> = new Map();
/** 当前挂载的旧样板图块转换器 */
private legacyConverter: ITileLegacyConverter<TLegacy> | null = null;
getData(num: number): ITileRawData | null {
return this.dataMap.get(num) ?? null;
}
getTrigger(num: number): number {
return this.dataMap.get(num)?.trigger ?? -1;
}
getType(num: number): TileType {
return this.dataMap.get(num)?.type ?? TileType.Unknown;
}
addTile(data: ITileRawData): void {
const oldData = this.dataMap.get(data.num);
const oldNum = this.idMap.get(data.id);
if (oldData) {
logger.warn(133, data.num.toString(), oldData.id);
this.deleteBy(oldData.num, oldData.id);
}
if (!isNil(oldNum) && oldNum !== data.num) {
logger.warn(134, data.id, oldNum.toString());
const oldIdData = this.dataMap.get(oldNum);
if (oldIdData) {
this.deleteBy(oldIdData.num, oldIdData.id);
} else {
this.idMap.delete(data.id);
this.numMap.delete(oldNum);
}
}
this.dataMap.set(data.num, data);
this.idMap.set(data.id, data.num);
this.numMap.set(data.num, data.id);
}
idToNumber(id: string): number | undefined {
return this.idMap.get(id);
}
numberToId(num: number): string | undefined {
return this.numMap.get(num);
}
num(token: number | string): number | undefined {
if (typeof token === 'number') {
return token;
} else {
return this.idMap.get(token);
}
}
id(token: number | string): string | undefined {
if (typeof token === 'number') {
return this.numMap.get(token);
} else {
return token;
}
}
attachLegacyConverter(converter: ITileLegacyConverter<TLegacy>): void {
this.legacyConverter = converter;
}
fromLegacy(num: number, legacy: TLegacy): ITileRawData {
const converter = this.legacyConverter;
if (!converter) {
logger.error(56);
throw new Error('Expected a tile legacy converter.');
}
const data = converter.fromLegacy(num, legacy);
this.addTile(data);
return data;
}
/** 删除一组旧的图块定义及其双向索引 */
private deleteBy(num: number, id: string): void {
this.dataMap.delete(num);
this.idMap.delete(id);
this.numMap.delete(num);
}
}

View File

@ -0,0 +1,252 @@
//#region tile
export const enum TileType {
/** 未知或尚未归类的图块 */
Unknown,
/** 空白图块 */
None,
/** 地形类图块 */
Terrain,
/** 动画类图块 */
Animate,
/** 道具类图块 */
Item,
/** 怪物类图块 */
Enemy,
/** NPC 类图块 */
Npc,
/** 自动元件 */
Autotile,
/** Tileset 切片图块 */
Tileset
}
export const enum PassBit {
/** 上方向掩码 */
Up = 0b0001,
/** 右方向掩码 */
Right = 0b0010,
/** 下方向掩码 */
Down = 0b0100,
/** 左方向掩码 */
Left = 0b1000
}
export interface ITilePassData {
/** 是否仅当图块处在事件层时生效 */
readonly onlyEvents: boolean;
/** 可以离开的方向 */
readonly outPass: number;
/** 可以进入的方向 */
readonly inPass: number;
}
export interface ITileRawData {
/** 图块数字 */
readonly num: number;
/** 图块字符串 id */
readonly id: string;
/** 默认触发器类型 */
readonly trigger: number;
/** 图块逻辑类型 */
readonly type: TileType;
/** 图块的通行性对象 */
readonly pass: ITilePassData;
/**
* `false`
* `true` `noPass`
*/
readonly eventPass: boolean;
}
export interface ITileLegacyConverter<TLegacy> {
/**
*
* @param num
* @param legacy
*/
fromLegacy(num: number, legacy: TLegacy): ITileRawData;
}
export interface ITileStore<TLegacy> {
/**
*
* @param num
*/
getData(num: number): ITileRawData | null;
/**
*
* @param num
*/
getTrigger(num: number): number;
/**
*
* @param num
*/
getType(num: number): TileType;
/**
* `num` `id`
* @param data
*/
addTile(data: ITileRawData): void;
/**
* id
* @param id id
*/
idToNumber(id: string): number | undefined;
/**
* id
* @param num
*/
numberToId(num: number): string | undefined;
/**
* id
* @param token id
*/
num(token: number | string): number | undefined;
/**
* id id
* @param token id
*/
id(token: number | string): string | undefined;
/**
*
* @param converter
*/
attachLegacyConverter(converter: ITileLegacyConverter<TLegacy>): void;
/**
* 使
* @param num
* @param legacy
*/
fromLegacy(num: number, legacy: TLegacy): ITileRawData;
}
//#endregion
//#region item
export const enum ItemCategory {
/** 未知或尚未归类的道具 */
Unknown,
/** 永久道具,使用后不会消耗 */
Constant,
/** 一次性道具,使用后消耗 */
Consumable,
/** 即捡即用道具,捡到立刻生效 */
Pick,
/** 装备类道具,可装备 / 卸下 */
Equipment
}
export interface IItemEffect<THero> {
/**
* 使 `Pick`
* `Constant` `Consumable` 使
*/
readonly useEvent: unknown;
/**
* 使使 `Pick`
* `Constant` `Consumable` 使
* @param item
*/
useEffect(item: IItemRawData<THero>): void;
/**
* 使 `Constant` `Consumable`
* @param item
*/
canUse(item: IItemRawData<THero>): boolean;
}
export interface IItemEquipData<THero> {
/** 可以装备至的装备槽,`number` 表示指定索引装备槽,`string` 表示指定装备槽名称,每个装备槽之间为或的关系 */
readonly slots: (number | string)[];
/** 动画 id */
readonly animate: AnimationIds;
/** 数值加成 */
readonly value: Map<SelectKey<THero, number>, number>;
/** 百分比加成 */
readonly percentage: Map<SelectKey<THero, number>, number>;
/** 穿上装备事件 */
readonly loadEvent: unknown;
/** 脱下装备事件 */
readonly unloadEvent: unknown;
}
export interface IItemRawData<THero> {
/** 道具在地图上的图块数字 */
readonly num: number;
/** 道具的字符串标识符 */
readonly id: string;
/** 道具分类 */
readonly category: ItemCategory;
/** 道具显示名称 */
readonly name: string;
/** 道具描述文本 */
readonly text: string;
/** 是否在道具栏中隐藏 */
readonly hideInToolbox: boolean;
/** 道具效果对象(非 Equipment 类型) */
readonly effect: IItemEffect<THero>;
/** 装备道具属性Equipment 类型) */
readonly equip: IItemEquipData<THero>;
}
export interface IItemLegacyConverter<THero, TLegacy> {
/**
*
* @param num
* @param legacy
*/
fromLegacy(num: number, legacy: TLegacy): IItemRawData<THero>;
}
export interface IItemStore<THero, TLegacy> {
/**
*
* @param num
*/
getData(num: number): IItemRawData<THero> | null;
/**
*
* @param num
*/
getCategory(num: number): ItemCategory;
/**
*
* @param data
*/
addItem(data: IItemRawData<THero>): void;
/**
*
* @param converter
*/
attachLegacyConverter(
converter: IItemLegacyConverter<THero, TLegacy>
): void;
/**
* 使
* @param num
* @param legacy
*/
fromLegacy(num: number, legacy: TLegacy): IItemRawData<THero>;
}
//#endregion

Some files were not shown because too many files have changed in this diff Show More