docs(10-27): U6 修订批次A(范围/图块/装备)

This commit is contained in:
unanmed 2026-10-22 13:58:53 +08:00
parent 011759fdd4
commit 5751dd9d72
7 changed files with 337 additions and 176 deletions

View File

@ -38,4 +38,16 @@
## 迭代记录
(待执行后追加)
### 批次 A(U6-01..U6-09)执行记录(2026-10-22)
- U6-01:`custom-range.md` 的 `new RingRange()` 已提为模块级常量,调用处复用;验证段同步。
- U6-02:新增「estimatePointCount 的取舍」小节,强调只需快速估算、不要为精确牺牲性能;正文与边界条目同步。
- U6-03/D-70:`static-vs-dynamic-tiles.md` 中孤立的 `num()` 方法体已包进完整 `StaticTile` 类上下文;其余代码块检查为完整函数/类。
- U6-04:新增 `point-and-tile-events.md`;把原「事件的两类归属」「事件视图接口」「在代码里操作事件」迁入并扩写(区别、跟随移动、作者可设置、存档恢复);`static-vs-dynamic-tiles.md` 保留简短指引;侧边栏 9→10,`index.md` 同步为十篇。
- U6-05:`markPure` / `ref` 在事件文档与图块文档中均只作「样板内部优化接口,一般不要调用」的简短说明。
- U6-06:`equipment-system.md` 不再讲 `getModifiers` 的懒构建等实现细节,只说明它用于获取装备修饰器;示例与边界条目相应收敛。
- U6-07:改写「槽位写法必须与定义一致/不能混用」;明确索引最精确、名称是粗略描述(同名槽无法指定),并说明 `equip.slots.includes` 的实际判定。
- U6-08/D-69:「修饰器与存档的内部实现」移至文档靠后(装备事件之后、验证之前),开头写明「此章节讲解一下……的内部实现」。
- U6-09:`equipment-system.md` 明确 `loadEvent` / `unloadEvent` 尚未实现,当前只是数据字段,也没有内置穿戴/卸下钩子。
**验证:** `pnpm exec prettier --write`(含新文档)→ `pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 构建通过,无死链告警。

View File

@ -196,6 +196,10 @@ export default defineConfig({
text: '静态图块与动态图块',
link: '/advanced/ii/static-vs-dynamic-tiles'
},
{
text: '点事件与图块事件',
link: '/advanced/ii/point-and-tile-events'
},
{
text: '勇士的装备系统',
link: '/advanced/ii/equipment-system'

View File

@ -57,6 +57,18 @@ export interface IRangeHost {
`BaseRange` 已经实现了 `inRangeIndex`、`iterate`、`scan`、`autoDetect`。继承它时通常只需要实现三个方法:`estimatePointCount`、`iterateLoc`、`inRange`。
### estimatePointCount 的取舍
`autoDetect` 每次调用时都会先执行一遍 `estimatePointCount`,再拿结果和 `list.size` 比较。因此这个估算的耗时会计入每一次范围查询——`scanRange`、光环、地图伤害等所有走 `autoDetect` 的地方都受影响。
它只需要「尽量快地估出一个大致数量」,不需要精确:
- 估算结果只决定 `autoDetect` 走 `scan` 还是 `iterate`,不影响最终命中哪些格;
- 估大或估小最多让选型变差,不会让结果出错;
- 为了准确去遍历格子、读取地图或做复杂计算,反而会让每一次查询都变慢,代价远大于偶尔选错路径。
建议直接用参数算一个近似值,例如 `RectRange` 用 `w * h`、`ManhattanRange` 用与 `radius` 相关的二次式;不要为了追求精确而牺牲速度。内置范围都是常数时间的估算,可以参考它们的写法。
## 内置范围与参数
以下范围都从 `@motajs/common` 导出:
@ -133,7 +145,7 @@ export class RingRange extends BaseRange<IRingRangeParam> {
三个实现各有分工:
- `estimatePointCount` 只服务 `autoDetect`,返回值偏大或偏小都只会让选型变差,不会改变命中结果;
- `estimatePointCount` 只服务 `autoDetect`,返回值偏大或偏小都只会让选型变差,不会改变命中结果;实现时要尽量快,不要为了精确去遍历格子;
- `iterateLoc` 决定「枚举时输出哪些格」,必须和 `inRange` 使用同一套判定;
- `inRange` 决定「单个点算不算命中」,越界必须先由 `inBound` 收口,不能只比较距离。
@ -144,8 +156,12 @@ export class RingRange extends BaseRange<IRingRangeParam> {
系统会自己调用 `bindHost`,你只需要把范围对象与参数传进去。以怪物上下文为例:
```ts
// RingRange 即上一节定义的自定义范围类
// 范围对象提到模块级复用;引擎风格不建议在调用处内联 new
const ringRange = new RingRange();
const loc = state.hero.getLocation();
const hits = state.enemyContext.scanRange(new RingRange(), {
const hits = state.enemyContext.scanRange(ringRange, {
cx: loc.x,
cy: loc.y,
radius: 2
@ -190,11 +206,14 @@ function collectRing(
`scanRange` 只返回范围内的怪物,所以验证要摆好怪物,而不是只看格子。先以勇士为中心,在中心、曼哈顿距离 `1`、距离 `2` 的位置各放一只测试怪,再执行:
```ts
// 同样复用模块级范围实例,不要在调用处内联 new
const ringRange = new RingRange();
state.enemyContext.bindHero(state.hero.getAttribute());
const loc = state.hero.getLocation();
const hits = [
...state.enemyContext.scanRange(new RingRange(), {
...state.enemyContext.scanRange(ringRange, {
cx: loc.x,
cy: loc.y,
radius: 2
@ -235,7 +254,7 @@ console.log('环上格子数', cells.length); // 未被地图边缘裁剪时最
- **越界必须返回 `false`**:`inRange` 先调用 `inBound`,再比较距离。只比较距离会让地图外的坐标也命中。
- **`iterateLoc` 与 `inRange` 必须同源**:两者判定不一致时,`scan` 会按范围枚举出一批格,再用另一套规则判断,结果就会漏格或多格。写完自定义范围后,用同一组参数分别跑 `iterateLoc` 与逐点 `inRange` 对照一次。
- **`estimatePointCount` 只是估算**:它不参与命中判定,也不需要考虑地图裁剪。估得不准只会让 `autoDetect` 选错路径,不会改变结果。
- **`estimatePointCount` 只是估算**:它不参与命中判定,也不需要考虑地图裁剪。估得不准只会让 `autoDetect` 选错路径,不会改变结果;但它每次查询都会执行,必须保持廉价,不要为了准确去遍历格子。
- **负半径按绝对值处理**:这是本篇示例的约定;如果你希望负半径表示「无效」,要在 `inRange` 与 `iterateLoc` 里同时改,不能只改一处。
- **绑定后的范围实例不要跨地图共用**:`bindHost` 是可变状态,同一个实例先后绑定不同尺寸的宿主时,后一次会覆盖前一次。需要并行处理多张地图时,每张地图各用一个实例,或在每次使用前重新绑定。
- **`radius: 0` 是中心单点**:环形范围在半径为 0 时没有「边框」可枚举,按距离恰好为 0 处理,只命中中心。

View File

@ -2,7 +2,7 @@
装备也是道具(`ItemCategory.Equipment`),但它和钥匙、药水不是一套流程。道具表只提供装备的原始定义;玩家实际拥有的每一件装备都是一个独立实例,穿在哪一栏又由另一套接口管理。这套拆分让「同一件装备可以有多份、每份强化不同」成为可能。
本篇讲装备实例仓、实例修饰器、装备槽、存档约定、对比与排序。中级教程的[勇士装备](../../intermediate/hero-equipment.md)已经讲过常用调用,这里补齐实例、修饰器与读档恢复之间的关系。
本篇讲装备实例仓、实例修饰器、装备槽、对比、排序与装备事件,最后说明修饰器与存档的内部约定。中级教程的[勇士装备](../../intermediate/hero-equipment.md)已经讲过常用调用,这里补齐实例、修饰器与读档恢复之间的关系。
## 装备与道具的区别
@ -25,7 +25,9 @@ export interface IItemEquipData<THero> {
}
```
`category` 为 `Equipment` 的道具才会走装备系统。`equip.value` 与 `equip.percentage` 只是新实例的初始值;`loadEvent` 与 `unloadEvent` 目前只是数据字段,引擎不会自动执行,详见下文「装备事件」。`animate` 是渲染端读取的动画 id,动画如何播放属于 `渲染端教程 · 装备动画`。 <!-- TODO(renderer): 装备动画 -->
`category` 为 `Equipment` 的道具才会走装备系统。`equip.value` 与 `equip.percentage` 只是新实例的初始值。
`loadEvent` 与 `unloadEvent` **尚未实现**,详见下文「装备事件」。`animate` 是渲染端读取的动画 id,动画如何播放属于 `渲染端教程 · 装备动画`。 <!-- TODO(renderer): 装备动画 -->
装备需要实例,是因为「定义」与「持有」不是一回事:两把同样的剑可以分别强化到不同数值,卸下再穿上也要记住各自的状态。实例用自增的 `uid` 区分,装备槽只保存 `uid`。
@ -70,7 +72,7 @@ function grantEquipment(state: ICoreState, id: string): number {
- `setValue(name, value)` 与 `getValue(name)`:数值加成,对应 `ValueModifier`;
- `setPercentage(name, value)` 与 `getPercentage(name)`:百分比加成,对应 `PercentageModifier`;
- `getModifiers()`:输出 `[属性名, 修饰器]`;实例尚未构建时会先调用 `buildModifiers()` 兜底。
- `getModifiers()`:用于获取该实例的全部装备修饰器。
把加成设为 `0` 会删除对应条目与修饰器,而不是留下一个值为 0 的修饰器;读取不存在的属性返回 `0`。
@ -88,16 +90,9 @@ function strengthenEquipment(state: ICoreState, uid: number): void {
equip.setValue('atk', 5);
equip.setPercentage('def', 0.1);
// 遍历该实例产生的全部修饰器
// 获取该实例的全部装备修饰器
for (const [name, modifier] of equip.getModifiers()) {
console.log(
'属性',
name,
'修饰器',
modifier.type,
'参数',
modifier.value
);
console.log('属性', name, '修饰器', modifier);
}
}
```
@ -125,7 +120,9 @@ function strengthenEquipment(state: ICoreState, uid: number): void {
| `getEquipped(slot)` | 取指定索引槽上的 `uid` |
| `getEquips()` | 按槽位顺序取所有已装备实例,空槽为 `null` |
`slot` 可以传索引或名称,但写法必须与装备定义里的 `slots` 一致:定义写 `[0]` 就传索引,定义写 `['weapon']` 就传名称。混用会在 `canEquipTo` 的第一道判定就被判成 `CannotEquip`。
`slot` 可以传索引,也可以传名称。`hero.equip.slots` 本身是字符串数组,但 `slot` 参数同时接受 `number | string`:索引是最精确的寻址方式,名称只是粗略描述——多个槽同名时,传名称只能落到第一个可用槽,无法指定具体是哪一个。
装备定义里的 `equip.slots` 决定这件装备能装到哪些槽。`canEquipTo` 的第一道判定是 `equip.slots.includes(slot)`:定义里列了索引就传索引,列了名称就传名称;想让两种写法都可用,就把两种都列进去(如 `[0, 'weapon']`)。
`autoUnload` 只处理「同一件装备已经戴在另一个槽」的情况:为 `true` 时先从原槽卸下再穿到目标槽;为 `false` 时直接放弃这次穿戴。它不会替你处理目标槽上原有的装备——目标槽被占用时,`equip` 总会替换,并把旧装备的 `uid` 返回。
@ -158,24 +155,6 @@ function unequipSlot(state: ICoreState, index: number): number | undefined {
`getEquipped` 与 `unequip` 都只接受索引。按名称操作时,先用 `slots.indexOf(name)` 换成索引;`indexOf` 返回 `-1` 表示该名称不存在。
## 修饰器与存档的关键约定
装备修饰器以 `save = false` 加入勇士属性:
```ts
this.attribute.addModifier(name, modifier, false);
```
第三个参数表示不进属性存档。读档时 `HeroAttribute.loadState` 会清空所有修饰器,只重建存档里保存的那些;装备修饰器不在其中,因此读档后的勇士暂时没有任何装备加成。
恢复发生在 `HeroEquipment.loadState`:它先清空 `equips` 映射,再按存档逐个调用 `equip` 重新穿戴。重新穿戴会把实例的修饰器重新挂到属性上,装备加成就回来了。整个过程在 `replaySystem.disable()` 与 `revert()` 之间进行,不会被记进录像。
`HeroState.loadState` 的顺序保证这条路径可行:属性 → 位置 → 道具(装备实例仓)→ 装备槽。实例仓先按存档重建,装备槽随后才能按 `uid` 找到它们。
由此得到一条实践规则:装备加成只能由 `equip` 与 `unequip` 维护。绕过装备槽,直接把装备修饰器 `addModifier(name, modifier)` 到勇士属性上,读档后这些修饰器会被清掉;如果给它们 `save = true`,读档时属性会先恢复一份,重新穿戴又会再加一份,数值翻倍。需要长期存在的非装备修饰器,应该由拥有它的系统自己负责存档,参见[勇士属性](../../intermediate/hero-attribute.md)。
正确的顺序是:先 `add` 得到实例、改好实例数值,再 `equip` 穿戴。读档时不要手动重挂修饰器,`loadState` 已经处理。
## 装备对比
`compareEquip(equipA, equipB, slot)` 返回「装备 A 时属性减装备 B 时属性」的差值。`equipA` 与 `equipB` 传实例 `uid`,传 `-1` 表示该侧没有装备;`slot` 只接受槽位索引。
@ -252,7 +231,7 @@ function useAtkSort(state: ICoreState): void {
## 装备事件
`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,但它监听的是**修饰器变化**,不是穿戴。`setValue`、`setPercentage`、`buildModifiers` 以及删除条目都会触发它;`equip` 与 `unequip` 本身不会触发。它适合做「装备强化后重算某个系统」这类联动:
`EquipmentState` 提供 `IEquipmentStateHooks.onChangeModifier`,但它监听的是**修饰器变化**,不是穿戴。`setValue`、`setPercentage` 以及删除条目都会触发它;`equip` 与 `unequip` 本身不会触发。它适合做「装备强化后重算某个系统」这类联动:
```ts
import { ICoreState } from '@user/data-state';
@ -264,7 +243,7 @@ function watchEquipment(state: ICoreState, uid: number): void {
const controller = equip.addHook({
onChangeModifier(modifiers) {
// 改变 setValue / setPercentage / buildModifiers 时触发
// 改变 setValue / setPercentage 时触发
// modifiers 是实时迭代器,不要保存到回调之外
for (const [name, modifier] of modifiers) {
console.log('装备属性变化', name, modifier.value);
@ -277,13 +256,7 @@ function watchEquipment(state: ICoreState, uid: number): void {
}
```
`IItemRawData.equip.loadEvent` 与 `unloadEvent` 不是自动钩子。引擎不会在穿戴或卸下时执行它们,目前它们只是数据字段。**引擎也没有内置「装备穿上/卸下」钩子**;`onChangeModifier` 不能替代它:
- `HeroEquipsStore.add` 在创建实例后会立即调用 `buildModifiers()`,`loadState` 重建实例后也会调用。因此正常获得的实例在 `equip` 时已经构建完毕,`equip` 只是把已有修饰器挂到属性上,不会触发 `onChangeModifier`;
- 只有绕过 `add`、直接 `new EquipmentState(...)` 且从未调用过 `getModifiers()` 时,`equip` 才可能顺带触发一次懒构建;那是兜底副作用,不是稳定的穿戴信号;
- 强化或改值同样会触发它,即使没有穿戴。
需要「穿上后触发」的行为,就在调用 `equip` / `unequip` 的统一入口里处理,或者包装一个自己的装备服务:
`IItemRawData.equip.loadEvent` 与 `unloadEvent` **尚未实现**:它们不是自动钩子,引擎不会在穿戴或卸下时执行它们,目前只是数据字段。**引擎也没有内置「装备穿上/卸下」钩子**,`onChangeModifier` 不能替代它。需要「穿上后触发」的行为,就在调用 `equip` / `unequip` 的统一入口里处理,或者包装一个自己的装备服务:
```ts
import { ICoreState } from '@user/data-state';
@ -301,7 +274,19 @@ function equipWithCallback(
}
```
`onChangeModifier` 在 `buildModifiers()` 时也会触发一次。经 `add` / `loadState` 得到的实例在建立时就已构建,所以之后才 `addHook` 的监听者收不到那次初始构建,只会收到后续变化;对这些实例,`equip` 不会触发再次构建。
## 修饰器与存档的内部实现
此章节讲解一下装备修饰器与存档的内部实现。
装备修饰器以 `this.attribute.addModifier(name, modifier, false)` 加入勇士属性,第三个参数表示不进属性存档。读档时 `HeroAttribute.loadState` 会清空所有修饰器,只重建存档里保存的那些;装备修饰器不在其中,因此读档后的勇士暂时没有任何装备加成。
恢复发生在 `HeroEquipment.loadState`:它先清空 `equips` 映射,再按存档逐个调用 `equip` 重新穿戴。重新穿戴会把实例的修饰器重新挂到属性上,装备加成就回来了。整个过程在 `replaySystem.disable()` 与 `revert()` 之间进行,不会被记进录像。
`HeroState.loadState` 的顺序保证这条路径可行:属性 → 位置 → 道具(装备实例仓)→ 装备槽。实例仓先按存档重建,装备槽随后才能按 `uid` 找到它们。
由此得到一条实践规则:装备加成只能由 `equip` 与 `unequip` 维护。绕过装备槽,直接把装备修饰器 `addModifier(name, modifier)` 到勇士属性上,读档后这些修饰器会被清掉;如果给它们 `save = true`,读档时属性会先恢复一份,重新穿戴又会再加一份,数值翻倍。需要长期存在的非装备修饰器,应该由拥有它的系统自己负责存档,参见[勇士属性](../../intermediate/hero-attribute.md)。
正确的顺序是:先 `add` 得到实例、改好实例数值,再 `equip` 穿戴。读档时不要手动重挂修饰器,`loadState` 已经处理。
## 怎么验证
@ -361,7 +346,7 @@ function verifyEquipment(state: ICoreState): void {
## 边界与注意
- **`add` 失败返回 `-1`**:图块 id 不存在,或该图块没有道具定义。`add` 不检查 `category`,传非装备道具可能得到无法使用的实例;调用方应先确认它是装备。
- **槽位写法必须与定义一致**:定义用索引就传索引,定义用名称就传名称。混用会在 `canEquipTo` 里直接返回 `CannotEquip`。
- **索引最精确,名称是粗略描述**:`slot` 可以传索引或名称;同名槽只能靠索引区分。装备定义 `equip.slots` 决定可装备的槽位,`canEquipTo` 会先检查定义里是否包含你传入的写法。
- **`autoUnload` 为 `false` 时不会搬移**:同一件装备已经戴在别的槽时,`equip` 会直接放弃,不做任何修改。
- **`unequip` 只接受索引**:按名称卸下要先 `slots.indexOf(name)`;`-1` 不会卸下任何东西。
- **不要删掉已穿戴的实例**:`delete(uid)` 只移除实例仓里的记录,不会解除装备槽的引用,也不会移除已经挂到属性上的修饰器。先 `unequip` 再 `delete`。
@ -370,7 +355,7 @@ function verifyEquipment(state: ICoreState): void {
- **`compareEquip` 两侧都为空时**返回 `{}` 并告警(code 195),不会抛错;`slot` 只接受索引。
- **`compareEquip` 的基线是当前属性减去目标槽现役装备**:A 或 B 可以就是目标槽上的现役装备,也可以完全未装备;**不要**把它们穿在另一个槽上,否则修饰器会在基线里再算一次。缺失的一侧回退到当前真实最终属性,所以目标槽有装备时,`-1` 得到的是「相对现役装备」而不是「相对空槽」。
- **排序器不影响装备槽**:它只决定 `instances()` 与 `instancesOf()` 的展示顺序;`getEquipped` 与 `getEquips` 永远按槽位顺序。
- **`getModifiers()` 只在未构建时兜底构建**:正常经 `add` / `loadState` 得到的实例已经构建过,调用它不会再次触发 `onChangeModifier`;它返回的是实时迭代器,不要长期保存到回调之外。
- **`getModifiers()` 返回实时迭代器**:不要把它保存到回调之外;需要留存时先转成数组。
编辑器与状态栏的展示不在本篇范围内。 <!-- TODO(editor): 装备栏与状态栏展示 -->

View File

@ -49,25 +49,26 @@
## 本层包含什么
本层一共九篇:这一页导览,加上八篇正文。
本层一共十篇:这一页导览,加上九篇正文。
| 序 | 篇目 | 它能让你做什么 | 前置 |
| --- | ----------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------- |
| 1 | 高级 II 导览 | 看清定位、前置与阅读路径,决定先读哪篇 | 高级 I |
| 2 | [自定义范围对象](./custom-range.md) | 写一个范围对象并绑定宿主,供光环、地图伤害与查询复用 | 高级 I 声明式接口;中级光环、地图伤害 |
| 3 | [静态图块与动态图块](./static-vs-dynamic-tiles.md) | 在两种图块之间转换,并安排点事件与图块事件的归属 | 中级地图结构、地图图块 |
| 4 | [勇士的装备系统](./equipment-system.md) | 管理装备实例、实例修饰器、装备槽、对比与排序 | 中级勇士装备、勇士属性;高级 I 可存档 |
| 5 | [自定义录像指令](./custom-replay-command.md) | 从指令码、指令对象到注册与沙盒回放,新增一条录像指令 | 高级 I 录像一致性;中级录像指令 |
| 6 | [自动寻路](./pathfinding.md) | 构建寻路图、求解路径、交给移动器,并处理瞬移与钩子 | 中级地图结构、图块事件 |
| 7 | [新增勇士属性](./adding-hero-attribute.md) | 从接口、默认值到伤害计算,完整接入一个勇士属性 | 中级勇士属性;高级 I 可存档、录像一致性 |
| 8 | [新增怪物属性](./adding-enemy-attribute.md) | 从接口、数据到伤害、光环、查询与最终效果,接入一个怪物属性 | 中级战斗各篇;本篇的勇士属性对照 |
| 9 | [怪物上下文与战斗深入](./enemy-context-and-combat.md) | 理解 buildup 的装配顺序、刷新时机、伤害缓存与各机制的交互 | 中级光环、查询、最终属性、伤害计算 |
| 3 | [静态图块与动态图块](./static-vs-dynamic-tiles.md) | 在两种图块之间转换与移动,理解 `keepEvent` 的取舍 | 中级地图结构、地图图块 |
| 4 | [点事件与图块事件](./point-and-tile-events.md) | 区分两类事件的归属,安排它们跟随移动与存档恢复 | 静态图块与动态图块 |
| 5 | [勇士的装备系统](./equipment-system.md) | 管理装备实例、实例修饰器、装备槽、对比与排序 | 中级勇士装备、勇士属性;高级 I 可存档 |
| 6 | [自定义录像指令](./custom-replay-command.md) | 从指令码、指令对象到注册与沙盒回放,新增一条录像指令 | 高级 I 录像一致性;中级录像指令 |
| 7 | [自动寻路](./pathfinding.md) | 构建寻路图、求解路径、交给移动器,并处理瞬移与钩子 | 中级地图结构、图块事件 |
| 8 | [新增勇士属性](./adding-hero-attribute.md) | 从接口、默认值到伤害计算,完整接入一个勇士属性 | 中级勇士属性;高级 I 可存档、录像一致性 |
| 9 | [新增怪物属性](./adding-enemy-attribute.md) | 从接口、数据到伤害、光环、查询与最终效果,接入一个怪物属性 | 中级战斗各篇;本篇的勇士属性对照 |
| 10 | [怪物上下文与战斗深入](./enemy-context-and-combat.md) | 理解 buildup 的装配顺序、刷新时机、伤害缓存与各机制的交互 | 中级光环、查询、最终属性、伤害计算 |
## 阅读路径
按目标不同,可以走三条路径:
- **系统定制**:范围对象 → 静态图块与动态图块 → 勇士的装备系统 → 新增勇士属性 → 新增怪物属性 → 怪物上下文与战斗深入。适合改造一整套系统,例如给战斗加一层新的属性与范围规则。
- **系统定制**:范围对象 → 静态图块与动态图块 → 点事件与图块事件 → 勇士的装备系统 → 新增勇士属性 → 新增怪物属性 → 怪物上下文与战斗深入。适合改造一整套系统,例如给战斗加一层新的属性与范围规则。
- **行为扩展**:自定义录像指令 → 自动寻路。适合给游戏加一种新的可回放行为,或替换移动与选路策略。
- **按需查阅**:每篇都单独成立,直接跳到解决当前问题的那一篇即可;篇末的「边界与注意」用于排查改动带来的副作用。
@ -116,6 +117,7 @@
- [自定义范围对象](./custom-range.md)
- [静态图块与动态图块](./static-vs-dynamic-tiles.md)
- [点事件与图块事件](./point-and-tile-events.md)
- [勇士的装备系统](./equipment-system.md)
- [自定义录像指令](./custom-replay-command.md)
- [自动寻路](./pathfinding.md)

View File

@ -0,0 +1,234 @@
# 点事件与图块事件
地图上的事件有两种存放方式:一种挂在**坐标**上,称为点事件(point event);另一种挂在**图块实例**上,称为图块事件(tile event)。平时的读写差别不大,但图块一旦移动或转换,两者就会分道扬镳:点事件留在原坐标,图块事件跟着图块走。
本篇说明两者的区别、各自的读写接口、跟随移动与存档的行为,最后给出可执行的验证方法。图块的创建、转换与移动见[静态图块与动态图块](./static-vs-dynamic-tiles.md)。
## 两种事件的区别
| 项目 | 点事件 | 图块事件 |
| ---------- | --------------------------------------------------------------------- | ------------------------------------ |
| 归属 | 坐标 | 图块实例 |
| 获取方式 | `layer.event(x, y)`、`layer.getPointEvent(x, y)`、`tile.pointEvent()` | `tile.tileEvent()` |
| 换图块后 | 仍留在原坐标 | 由 `set(num)` 恢复为该图块的默认事件 |
| 图块移动后 | 不跟随 | 跟随图块实例 |
| 典型用途 | 「勇士踩进这一格」「进入这一区域」 | 「可推动的石头自带的事件」 |
点事件属于**坐标**。同一坐标换掉图块、清空图块,点事件都还在。`layer.event(x, y)` 按坐标取可写视图;坐标不在地图内时返回 `null`。`layer.getPointEvent(x, y)` 不是视图,它只是 `event(x, y)?.get()` 的简写,返回只读映射,不能用来修改事件。
图块事件属于**图块实例**。静态图块与动态图块各自持有一个事件视图,通过 `tile.tileEvent()` 读写。图块实例被销毁时,它的事件也随之消失;静态图块转动态、动态图块转静态时是否保留,由转换接口的 `keepEvent` 决定。
`tile.pointEvent()` 容易让人误会:它并不拥有事件,只是按图块**当前**的 `locator` 去取那个坐标的点事件视图。动态图块移动后,它读到的是新坐标的点事件,而不是出发地的事件。
## 跟随移动的区别
动态图块移动时,移动器只更新它的 `locator` 与图层索引,不会触碰事件:
- **点事件不跟随**:事件仍按原坐标缓存。图块离开后,原坐标的点事件还在;新坐标的点事件与它无关。`dynamic.pointEvent()` 只是按新坐标重新查一次视图,所以移动后通常读到空事件。
- **图块事件跟随**:事件保存在图块实例自己身上,`locator` 变了,事件视图不变。静态转动态时 `keepEvent` 为 `true`(默认)会把源实例的事件复制过来;动态转静态时同理。
```ts
import { FaceDirection } from '@user/data-common';
import { IMapLayer } from '@user/data-base';
/**
* 给坐标与图块各挂一个事件,再推动图块,观察两者是否跟随。
* 假设 (x, y) 有静态图块,右侧一格为空。
*/
async function moveTileAndObserveEvents(
layer: IMapLayer,
x: number,
y: number
): Promise<void> {
// 点事件挂在坐标上
layer.event(x, y)?.set(1, 'point-event');
const source = layer.getTile(x, y);
if (!source) return;
// 图块事件挂在图块实例上
source.tileEvent().set(10, 'tile-event');
// keepEvent 默认为 true,转换时把图块事件复制到动态实例
const dynamic = source.toDynamic();
const controller = dynamic.step(FaceDirection.Right);
await controller?.onEnd;
// 图块事件跟着实例走到新坐标
console.log('移动后图块事件', dynamic.tileEvent().get(10)); // tile-event
// 点事件留在原坐标
console.log('原坐标点事件', layer.getPointEvent(x, y)?.get(1)); // point-event
// dynamic.pointEvent() 按当前坐标取视图,新坐标没有点事件
console.log('新坐标点事件', dynamic.pointEvent()?.get(1)); // undefined
}
```
注意 `keepEvent` 为 `false` 时,静态图块的事件不会被复制,但源静态图块实例本身也不会被清空。这个不对称行为在[静态图块与动态图块](./static-vs-dynamic-tiles.md)的「双向转换」里有详细说明。
## 事件视图接口
点事件与图块事件共用同一套视图。只读接口是 `IReadonlyEventView`:
| 成员 | 作用 |
| --------- | -------------------------------------------------------------- |
| `get()` | 取当前事件映射,键是优先级,值是 `IGameEventStore` 中的事件 id |
| `ref()` | 取参考基准映射 |
| `dirty()` | 当前事件是否与参考基准不同 |
可写接口 `ILayerEventView` 在此基础上增加:
| 成员 | 作用 |
| ---------------------- | -------------------------------------------------- |
| `set(priority, event)` | 设置指定优先级的事件;同优先级已有事件时覆盖并告警 |
| `delete(priority)` | 删除指定优先级的事件 |
| `reset()` | 把当前事件恢复为参考基准 |
| `clear()` | 清空当前事件 |
| `markPure()` | 把当前事件标记为新的参考基准 |
`set` 的 `event` 参数是**事件 id**,不是事件对象。这个 id 必须已经在 `state.eventStore` 中注册过(`state.eventStore.addEvent(id, event)`);否则执行到这里时找不到对应事件。同一个优先级只能挂一个事件,重复设置会覆盖并告警 code 136。
`ref()` 与 `markPure()` 是样板内部的优化接口:前者暴露参考基准,后者把当前状态设为新的基准,存档压缩与脏标记靠它们工作。一般不要调用,除非你清楚自己在做什么。日常读写只需要 `get`、`set`、`delete`、`clear`。
## 设置与复制事件
给坐标设置点事件:
```ts
import { IMapLayer } from '@user/data-base';
/**
* 给坐标挂点事件;换图块不会影响它。
* eventId 必须已经在 state.eventStore 中注册。
*/
function setPointEvent(
layer: IMapLayer,
x: number,
y: number,
eventId: string
): void {
const view = layer.event(x, y);
if (!view) return;
// 优先级 5;同一优先级已有事件时覆盖并告警
view.set(5, eventId);
// 换掉这一格的图块,点事件仍属于坐标 (x, y)
layer.setBlock(21, x, y);
console.log(layer.getPointEvent(x, y)?.get(5)); // eventId
}
```
给图块设置图块事件:
```ts
import { IMapLayer } from '@user/data-base';
/**
* 给静态图块挂图块事件。
* 图块事件属于实例;转换时是否复制由 keepEvent 决定。
*/
function setTileEvent(
layer: IMapLayer,
x: number,
y: number,
eventId: string
): void {
const tile = layer.getTile(x, y);
if (!tile) return;
// 优先级 10;set(num) 会把它重置为该图块的默认事件
tile.tileEvent().set(10, eventId);
console.log(tile.tileEvent().get(10)); // eventId
}
```
把一个图块的事件整体复制到另一个图块:
```ts
import { IMapLayer } from '@user/data-base';
/**
* 复制图块事件;目标原有的图块事件会被全部清空。
* 这里只复制图块事件,点事件不会跟着复制。
*/
function copyTileEvents(
layer: IMapLayer,
fromX: number,
fromY: number,
toX: number,
toY: number
): void {
const from = layer.getTile(fromX, fromY);
const to = layer.getTile(toX, toY);
if (!from || !to) return;
// 先清空目标,再逐条写入来源的优先级
to.syncTileEvent(from);
}
```
只想追加一条事件时,直接 `tileEvent().set(priority, eventId)`,不要用 `syncTileEvent`,后者会覆盖目标上其它优先级的事件。
## 存档与读档
两类事件都由图层自动保存,作者不需要额外注册存档对象:
- **点事件**:图层只保存 `dirty()` 为 `true` 的坐标。地图数据自带的点事件在加载地图时已经写进参考基准(并调用 `markPure()`),所以存档里只记录运行时新增或修改的差量。读档时先把已有点事件视图 `reset()` 回基准,再按存档 `clear()` 并逐条 `set()`。
- **图块事件**:静态图块只在 `shouldSave()` 为真(即事件偏离默认基准)时写入存档;动态图块总会保存图块数字,事件偏离基准时一并写入。读档时静态/动态图块先 `restoreDefaultEvents()`,再应用存档里的事件。如果存档带自定义事件,读回后 `dirty()` 仍为 `true`,因为应用事件后没有重新 `markPure()`;只有事件与默认基准一致的图块才是干净的。
事件 id 本身不在存档里展开,存档只记录 id 字符串。因此读档时这些 id 必须仍然能在 `state.eventStore` 中找到;注册名称发生变化,旧存档就会指向不存在的事件。
## 怎么验证
下面的函数给同一格设置点事件与图块事件,存档、清空、读档,确认两者都能恢复。它假设 `(x, y)` 有静态图块,且两个事件 id 都已注册。
```ts
import { SaveCompression } from '@user/data-common';
import { IMapLayer } from '@user/data-base';
/**
* 验证点事件与图块事件都能随图层存档恢复。
* 假设 (x, y) 有静态图块,两个事件 id 都已注册。
*/
function verifyEventPersistence(layer: IMapLayer, x: number, y: number): void {
// 点事件挂在坐标上,图块事件挂在实例上
layer.event(x, y)?.set(1, 'point-event');
const tile = layer.getTile(x, y);
if (!tile) return;
tile.tileEvent().set(10, 'tile-event');
const snapshot = layer.saveState(SaveCompression.NoCompression);
// 先清空,确认读档确实把事件写回来
layer.event(x, y)?.clear();
tile.tileEvent().clear();
layer.loadState(snapshot, SaveCompression.NoCompression);
console.log('读档后点事件', layer.getPointEvent(x, y)?.get(1)); // point-event
console.log(
'读档后图块事件',
layer.getTile(x, y)?.tileEvent().get(10) // tile-event
);
}
```
预期现象:
- 读档后原坐标的点事件恢复为 `point-event`;
- 同一格静态图块的图块事件恢复为 `tile-event`;
- 如果只设置图块事件、没有设置点事件,那么读档后 `getPointEvent(x, y)?.get(1)` 应为 `undefined`,这是正常的——两者互不替代。
要观察移动造成的差异,用前面 `moveTileAndObserveEvents` 的例子:图块事件会跟到新坐标,点事件留在原坐标。
## 边界与注意
- **点事件与图块事件不是同一张表**:同一格可以同时有优先级 `5` 的点事件与优先级 `5` 的图块事件,两者互不冲突;冲突只发生在同一个事件视图内部。
- **`getPointEvent` 返回只读映射**:它不能修改事件;要写入请用 `layer.event(x, y)` 或 `tile.pointEvent()`。
- **`set(num)` 会重置图块事件**:静态图块与动态图块的 `set(num)` 都会调用 `restoreDefaultEvents()`。想保留自定义事件,应在 `set` 之后再挂,或用 `syncTileEvent` 从别的实例复制。
- **`layer.setBlock` 不会重置图块事件**:它只改图块数字;同一坐标缓存着的静态图块实例会继续持有旧事件。要重置事件,用 `tile.set(num)` 或 `tile.restoreDefaultEvents()`。
- **`syncTileEvent` 会覆盖目标**:它先清空目标再复制,目标原有的图块事件不会保留。
- **动态图块移动后 `pointEvent()` 读的是新坐标**:想确认出发地的事件,直接用 `layer.getPointEvent(oldX, oldY)`,不要用移动后的图块去反查。
- **事件 id 必须已注册**:`set` 不会替你检查 `eventStore`,缺注册时会在执行阶段找不到事件;读档也依赖同一个 id 仍然存在。
- **`markPure()` 与 `ref()` 是内部接口**:日常读写不要调用;只有实现存档压缩、脏标记一类的底层逻辑时才需要关心它们。
返回[高级 II 导览](./index.md)。

View File

@ -2,15 +2,26 @@
图层上的图块有两种形态。静态图块是地图数组里的一格,位置固定,读写便宜;动态图块是独立实例,有自己的移动器,可以在一格一格之间移动。地图上看到的石头、箱子、可推动的障碍,通常先以静态图块存在,被推动时转成动态图块,停下后再转回静态。
本篇说明两者的分工、创建与获取方式、双向转换、事件归属与操作接口,最后给出可执行的验证方法。静态图块的批量读写见[地图图块](../../intermediate/map-tiles.md),图层与楼层的结构见[地图结构](../../intermediate/map-structure.md)。
本篇说明两者的分工、创建与获取方式、双向转换,以及事件在转换中的归属,最后给出可执行的验证方法。静态图块的批量读写见[地图图块](../../intermediate/map-tiles.md),图层与楼层的结构见[地图结构](../../intermediate/map-structure.md),两类事件的专门说明见[点事件与图块事件](./point-and-tile-events.md)。
## 两种图块的分工
**静态图块**(`IStaticTile`)对应图层数组中的一格。它的位置由坐标决定,`num()` 直接读图层当前格子的图块数字:
```ts
num(): number {
return this.layer.getBlock(this.locator.x, this.locator.y);
import { IMapLayer } from '@user/data-base';
// 这里只保留与本节相关的实现,其余成员从略
class StaticTile {
constructor(
readonly locator: { x: number; y: number },
readonly layer: IMapLayer
) {}
// 静态图块没有独立的位置:位置就是它所在的坐标
num(): number {
return this.layer.getBlock(this.locator.x, this.locator.y);
}
}
```
@ -147,114 +158,17 @@ async function shiftRight(
- 动态图块被转回静态,或被 `delete()` 删除时,会触发图层的 `onDeleteDynamic` 钩子。`delete()` 返回 `Promise<void>`,会等待钩子完成;`toStatic` 只触发钩子,不等待。
- `createDynamic` 与 `transferToDynamic` 都会触发 `onCreateDynamic`;`updateDynamicTile` 会触发 `onUpdateDynamicPosition`,移动器在每步落点更新位置时会调用它。
## 事件的两类归属
## 图块事件与点事件
图块相关的事件分两类,归属完全不同。混淆这两类,是「图块移走了,事件却没跟着走」或「事件跟着图块跑到了不该去的地方」的常见原因。
图块相关的事件有两种归属:点事件属于**坐标**,图块事件属于**图块实例**。图块移动时,图块事件跟着实例走,点事件留在原坐标;静态图块转动态、动态图块转静态时,`keepEvent` 决定图块事件是否复制到新实例。两者都可以由作者设置,也都会随存档保存和恢复。
| 项目 | 点事件(point event) | 图块事件(tile event) |
| ---------- | --------------------------------------------------------------------- | ------------------------------------------ |
| 归属 | 坐标 | 图块实例 |
| 获取方式 | `layer.event(x, y)`、`layer.getPointEvent(x, y)`、`tile.pointEvent()` | `tile.tileEvent()` |
| 换图块后 | 仍留在原坐标 | 随实例转换,是否复制由 `keepEvent` 决定 |
| 图块移动后 | 不跟随 | 跟随图块实例 |
| 典型用途 | 「勇士踩进这一格」「进入这一区域」 | 「这块可推动的石头被推开后,事件仍跟着它」 |
事件视图的 `markPure()` 与 `ref()` 是样板内部的优化接口,用于维护参考基准与脏标记,一般不要调用,除非你清楚自己在做什么。
点事件属于**坐标**。同一坐标换掉图块、清空图块,点事件都还在;动态图块移动到新坐标后,它原来的点事件留在旧坐标。`tile.pointEvent()` 只是按图块**当前**的 `locator` 去取那个坐标的点事件视图,所以动态图块移动后,它读到的是新坐标的点事件,不是出发地的事件。
图块事件属于**图块实例**。静态图块转动态、动态图块转静态时,`keepEvent` 为 `true` 就把源实例的图块事件复制到目标实例;为 `false` 则目标实例按图块定义重建默认事件。复制是「清空目标后逐条写入」,不会与目标原有事件合并。
## 事件视图接口
点事件与图块事件都用同一套事件视图。取可写视图用 `layer.event(x, y)`(返回 `ILayerEventView | null`)或 `tile.tileEvent()`;`layer.getPointEvent(x, y)` 不是视图,它只是 `event(x, y)?.get()` 的简写,返回 `ReadonlyMap<number, string> | null`,不能用来修改点事件。只读接口是 `IReadonlyEventView`:
| 成员 | 作用 |
| --------- | -------------------------------------------------------------- |
| `get()` | 取当前事件映射,键是优先级,值是 `IGameEventStore` 中的事件 id |
| `ref()` | 取参考基准映射 |
| `dirty()` | 当前事件是否与参考基准不同 |
可写接口 `ILayerEventView` 在此基础上增加:
| 成员 | 作用 |
| ---------------------- | -------------------------------------------------- |
| `set(priority, event)` | 设置指定优先级的事件;同优先级已有事件时覆盖并告警 |
| `delete(priority)` | 删除指定优先级的事件 |
| `reset()` | 把当前事件恢复为参考基准 |
| `clear()` | 清空当前事件 |
| `markPure()` | 把当前事件标记为新的参考基准 |
`markPure()` 建立「干净基准」:此后 `dirty()` 只反映相对这一刻的变化。静态图块与动态图块在构造、重置默认事件后都会调用它,所以新建的图块默认是干净的。读档则不一定:`loadState` 先 `restoreDefaultEvents()`,再按存档 `clear()` 并逐条 `set()`,后者不会重新调用 `markPure()`;因此存档里带自定义图块事件时,读回后 `dirty()` 仍为 `true`,只有没有自定义事件的图块才是干净的。存档时只需要保存 `dirty()` 为 `true` 的图块事件;如果事件与基准一致,就没有必要写入存档。
`set` 的 `event` 参数是**事件 id**,不是事件对象。这个 id 必须已经在 `state.eventStore` 中注册过(`state.eventStore.addEvent(id, event)`);否则执行到这里时找不到对应事件。同一个优先级只能挂一个事件,重复设置会覆盖并告警 code 136。
## 在代码里操作事件
### 给静态图块添加并复制图块事件
```ts
import { IMapLayer } from '@user/data-base';
function giveTileEvent(
layer: IMapLayer,
x: number,
y: number,
eventId: string
): void {
const tile = layer.getTile(x, y);
if (!tile) return;
// 优先级 10 上挂一个事件;同一个优先级已有事件时会被覆盖
tile.tileEvent().set(10, eventId);
}
function copyTileEvent(
layer: IMapLayer,
fromX: number,
fromY: number,
toX: number,
toY: number
): void {
const from = layer.getTile(fromX, fromY);
const to = layer.getTile(toX, toY);
if (!from || !to) return;
// syncTileEvent 会先清空目标,再逐条复制来源的全部图块事件
to.syncTileEvent(from);
}
```
`syncTileEvent` 复制的是**图块事件**,不是点事件。它会把目标实例现有的图块事件全部清掉,再按来源的优先级逐条写入;如果目标上原本有来源没有的优先级,那些事件会消失。
### 给坐标设置点事件并验证它不随图块移动
```ts
import { IMapLayer } from '@user/data-base';
function keepPointEvent(
layer: IMapLayer,
x: number,
y: number,
eventId: string
): void {
const view = layer.event(x, y);
if (!view) return;
// 优先级 5 上挂一个点事件
view.set(5, eventId);
// 换掉这一格的图块,点事件仍然属于坐标 (x, y)
layer.setBlock(21, x, y);
// 读到的仍是刚才设置的事件
console.log(layer.getPointEvent(x, y)?.get(5));
}
```
如果要把图块本身移走,用 `transferToDynamic` 加 `mover`;图块事件会跟着动态实例走,点事件则留在原坐标。两类事件可以同时存在于同一格,互不覆盖,因为它们存储在不同的视图里。
两类事件的区别、读写接口与存档行为,见[点事件与图块事件](./point-and-tile-events.md)。
## 怎么验证
下面的函数把「静态 → 动态 → 移动 → 静态」与两类事件放在一次检查里。它假设目标格右侧为空,并且事件 id 已经注册。
下面的函数检查「静态 → 动态 → 移动 → 静态」的完整往返。它假设目标格右侧为空。
```ts
import { FaceDirection } from '@user/data-common';
@ -267,10 +181,8 @@ async function verifyTileConversion(
): Promise<void> {
const source = layer.getTile(x, y);
if (!source) return;
// 先挂自定义图块事件与点事件,便于观察转换后的归属
source.tileEvent().set(10, 'demo-event');
layer.event(x, y)?.set(1, 'demo-point-event');
// 记录转换前的图块数字,最后与落点对比
const num = source.num();
const dynamic = source.toDynamic();
const controller = dynamic.step(FaceDirection.Right);
@ -278,9 +190,7 @@ async function verifyTileConversion(
const restored = dynamic.toStaticIfSafe();
console.log('原位置图块数字', layer.getBlock(x, y)); // 0
console.log('落点图块数字', restored?.num()); // 与 source 相同
console.log('落点图块事件', restored?.tileEvent().get(10)); // demo-event
console.log('原点事件', layer.getPointEvent(x, y)?.get(1)); // demo-point-event
console.log('落点图块数字', restored?.num()); // 与 num 相同
}
```
@ -288,19 +198,14 @@ async function verifyTileConversion(
- 原位置的静态图块数字变成 `0`;
- 落点出现同数字的静态图块;
- 落点图块的图块事件里仍有 `demo-event`,因为 `keepEvent` 默认为 `true`;
- 原坐标的点事件里仍有 `demo-point-event`,点事件没有跟着图块移动。
- 如果落点已有静态图块,`toStaticIfSafe` 会返回 `null`,落点不会变化,动态图块仍停在落点。想看到完整结果,先把落点清空,或换一个空格测试。
如果落点已有静态图块,`toStaticIfSafe` 会返回 `null`,落点不会变化,动态图块仍停在落点。想看到完整结果,先把落点清空,或换一个空格测试。
图块事件在转换中的复制结果由 `keepEvent` 决定,具体验证见[点事件与图块事件](./point-and-tile-events.md)。
## 边界与注意
- **静态图块的 `set()` 会重置默认事件**:`set(num)` 把图块事件恢复为该图块定义的默认事件。想保留自定义事件,应在 `set` 之后再挂,或使用 `syncTileEvent` 从别的实例复制。
- **动态图块不写回图层数组**:图层数组只保存静态图块数字。判断某一格有没有动态图块要用 `getDynamicTilesAt` / `iterateDynamicTiles`,不要只读 `getBlock`。
- **`keepEvent` 为 `false` 时事件不是「丢了」**:目标图块会回到该图块定义的默认事件,这是设计行为,不是转换失败。需要保留自定义事件就显式传 `true`,并在转换前确认源实例的事件已经设置好。
- **`layer.setBlock` 不会重置图块事件**:它只改图块数字;同一坐标缓存着的静态图块实例会继续持有旧事件。要重置事件,用 `tile.set(num)` 或 `tile.restoreDefaultEvents()`,不要以为换了数字事件就自动干净。
- **同一格多个动态图块的顺序不保证**:`getDynamicTilesAt` 返回的是集合语义的迭代器;依赖先后顺序时,请先取出数组并自行排序。
- **`syncTileEvent` 会覆盖目标**:它先清空目标再复制,目标原有的图块事件不会保留。只想追加一条事件时,用 `tileEvent().set(priority, eventId)`,不要用 `syncTileEvent`。
- **点事件与图块事件共用优先级概念但不是同一张表**:同一格可以同时有优先级 `5` 的点事件与优先级 `5` 的图块事件,两者互不冲突;冲突只发生在同一个事件视图内部。
返回[高级 II 导览](./index.md)。