docs(10-31): 修复 Mover/FaceHandler 复审非阻断项

- 补充 predicate()/IPassPredicate 契约与改通行规则示例

- 说明 start() 返回 null 时的队列留存与异常无恢复路径

- 澄清 prepareStep 私有、faceHandler 等只读状态

- face-handler 统一 dirOf 内置特例与分组键说明

- 修正 cannotEnter 签名与 @shouldReplay 放置

- index 主要主题补入 Mover/FaceHandler
This commit is contained in:
unanmed 2026-10-24 19:15:19 +08:00
parent b243c51479
commit 73d1a04fbe
4 changed files with 96 additions and 31 deletions

View File

@ -75,3 +75,23 @@
- `pnpm exec prettier --write docs/advanced/mover.md docs/advanced/face-handler.md docs/advanced/index.md`:通过(mover 有格式变动,其余 unchanged)。
- `pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build`:`build complete in 15.06s`,无 dead link 报错。
## 第 2 轮 · 复审(非阻断项)
本轮仅改 `docs/advanced/mover.md`、`docs/advanced/face-handler.md`、`docs/advanced/index.md` 与本记录;未改生产代码与配置。以下结论均对照 `data-common/src/common/mover.ts`、`data-common/src/common/faceManager.ts`、`data-base/src/hero/mover.ts`、`data-base/src/hero/types.ts`、`data-base/src/map/types.ts`、`data-base/src/map/tile.ts`、`data-base/src/map/mover.ts`、`src/data/hero/moverImpl.ts` 与 `src/data/initialize.ts` 核对。
| # | 项 | 处置 |
|---|----|------|
| 1 | `predicate()` / `IPassPredicate` | `mover.md` 新增「改通行规则:predicate() 与 IPassPredicate」:`canPass` / `shouldHit` 的职责、`IPassCheckHandler` 的 `currLoc` / `nextLoc` / `direction` / `map` / `state` 字段、`CannotMove` / `Hit` / 正常移动的判定顺序、`ignoreTerrain` 会跳过整个谓词、`predicate()` 要返回稳定实例;包装默认实现的示例改为「禁止向左走」,并说明寻路需通过 `usePassPredicate` 单独注入。 |
| 2 | `start()` 返回 null 时的队列 | `mover.md`「执行与等待」与「边界与注意」补充:返回 `null` 时刚排的步骤没有被切片或清空,仍留在移动器里,下一次 `start()` 会执行;不要就用 `clear()` 丢掉。 |
| 3 | 异常后的恢复 | `mover.md`「边界与注意」改写:抛错后 `onEnd` 不再兑现、`moving` 恒真、后续 `start()` 恒 `null`,并会产生未处理的 Promise 拒绝;`moving` 对外只读,没有重置或强制结束入口,运行期没有恢复路径,只能重建数据端;建议用 `try` / `catch` 把预期内的失败转成结果码。 |
| 4 | `prepareStep` 与受保护成员 | `mover.md`「实现自己的移动器」明确:五个 `on*` 方法是 `protected` 抽象方法;`prepareStep` 与 `moveProgress` 是 `private`,不可覆写或调用;`faceHandler`、`moveDirection`、`faceDirection` 在接口上只读。 |
| 5 | `get<FaceDirection>(3)` 的分组键 | `face-handler.md` 说明 `FaceGroup` 为 `const enum`(`Dir4 = 0`、`Dir8 = 1`),内置取用应写枚举成员;`3` 只是当前空闲数字、不是保留区,可能与未来内置分组冲突,自定义分组更推荐字符串 id,数字键取用前先 `get(3)` 判空。 |
| 6 | `dirOf` 的内置特例 | `face-handler.md` 统一口径:内置 `Dir4FaceHandler.dirOf` 遍历八方向表属于实现特例,自定义处理器应只遍历本组表;`dirOf(1, 1) = RightDown` 在当前实现下可复现但不是接口承诺,且不是四方向结果,不能用于四方向判定,必要时用 `degrade` 或 `mapMovement()` 自行收敛。`dirOf` 契约条目与边界条目同步补充。 |
| 7 | `cannotEnter(handler)` 签名 | `mover.md` 包装示例保留接口要求的 `handler` 参数,调用 `DefaultHeroMoveTopImpl.cannotEnter()` 时不转发(默认实现声明为无参空实现),并补一段说明;表格与示例一致。 |
| 8 | `@shouldReplay` 放置 | `mover.md` 模板去掉 `onStepEnd` 上的标记(它只返回落点);最小示例把标记移到实际写回朝向的 `onStepSettled`;「与录像、存档的关系」改为「标在方法体真正写状态的那一个」,并列出 `onStepEnd` / `onStepSettled` / `DynamicTileMover.onStepEnd` 三种情况。 |
| 9 | `index.md` 主要主题 | 「高级 I · 扩展基础」行的主要主题补入「移动 Mover、FaceHandler 系列」,与九篇清单一致。 |
### 本轮构建
- `pnpm exec prettier --write docs/advanced/mover.md docs/advanced/face-handler.md docs/advanced/index.md`:通过(mover、index 有格式变动)。
- `pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build`:`build complete in 19.98s`,无 dead link 报错。

View File

@ -83,7 +83,9 @@ state.faceManager.registerById('dir8', dir8);
`opposite` 与 `next` 都在本组内求解:先 `degrade`,再查本组的映射表,因此传入一个本组不认的方向时,结果取决于 `degrade` 的归并。`next` 的第二个参数是 `anticlockwise`,省略时为 `false`,即顺时针。
`dirOf` 走的是相反方向:给你一个偏移量,问它是哪个方向。它不接收方向,也不会先 `degrade`;偏移量与表中某一项完全相等时才命中,多个方向共用同一偏移时只返回其中一个。它反查的是处理器内部的偏移表,命中顺序就是表的条目顺序。内置 `Dir4FaceHandler.dirOf` 实际遍历的是八方向偏移表,所以 `dir4.dirOf(1, 1)` 会返回 `RightDown`,而不是 `Unknown`;这与「四方向只认上下左右」的直觉不一致,属于实现与文档描述不一致的地方(按接口注释,`dirOf` 只承诺「多个符合时返回其中一个」,并不承诺只返回本组方向)。需要严格四方向结果时,用 `mapDirection()` 的成员集合过滤返回值。
`dirOf` 走的是相反方向:给你一个偏移量,问它是哪个方向。它不接收方向,也不会先 `degrade`;偏移量与表中某一项完全相等时才命中,多个方向共用同一偏移时只返回其中一个。它反查的是处理器内部的偏移表,命中顺序就是表的条目顺序。内置 `Dir4FaceHandler.dirOf` 实际遍历的是八方向偏移表,所以 `dir4.dirOf(1, 1)` 会返回 `RightDown`,而不是 `Unknown`。这是内置实现的特例,不要照抄到自定义处理器里:自定义处理器的 `dirOf` 应该只遍历本组自己的表(后文模板就是这样),否则会返回本组不支持的方向。
至于 `dirOf(1, 1) = RightDown` 能不能用:它在当前实现下可复现(表是模块级常量、遍历顺序固定),但不是接口承诺。按接口注释,`dirOf` 只承诺「多个符合时返回其中一个」,并不承诺只返回本组方向;它给出的又是四方向体系不支持的方向。所以不要拿它做四方向判定,需要严格四方向结果时,把返回值再用 `degrade` 折一次,或自己遍历 `mapMovement()` 里本组的条目。
`mapDirection` 与 `mapMovement` 用于遍历。两者的结果都包含 `Unknown`,`mapMovement` 里 `Unknown` 对应的偏移是 `{ x: 0, y: 0 }`。寻路就是按 `mapMovement` 的条目扩展邻域的,所以处理器认哪些方向,直接决定图的分支数。自己按 `mapMovement` 扩展邻域时,要跳过零偏移条目,否则对象会原地走一步;寻路的构图循环就是这样处理的。
@ -293,7 +295,7 @@ export class VerticalDir4FaceHandler implements IFaceHandler<FaceDirection> {
- `Unknown` 不能被当成普通方向参与运算:`movement` 与 `move` 返回零偏移,`opposite` 与 `next` 返回 `Unknown`。
- `mapMovement` 必须包含 `Unknown` 及其零偏移。处理器的方法可能返回 `Unknown`,调用方靠这张表把每个方向都映射到偏移;缺了它,`Unknown` 只能走各方法自己的兜底分支,行为就不再由表统一决定。
- `next` 的第二个参数省略时表示顺时针;逆时针表要能与顺时针表首尾相接,转满一圈回到起点。
- `dirOf` 匹配不到时要返回 `Unknown`,不要抛错;多个方向共用同一偏移时返回其中一个即可。
- `dirOf` 匹配不到时要返回 `Unknown`,不要抛错;多个方向共用同一偏移时返回其中一个即可。只遍历本组自己的偏移表——内置 `Dir4FaceHandler` 遍历八方向表是特例(见前文),照抄会让你返回本组不支持的方向。
- 表用 `ReadonlyMap` 这类共享常量即可,不要在处理器实例上记录「当前处理的是哪个对象」,处理器是共用的。
注册时选一个未被内置占用的分组键,数字与字符串 id 都可以:
@ -305,7 +307,7 @@ import { VerticalDir4FaceHandler } from './vertical-face-handler';
/** 注册自定义朝向组,并取回验证一次。 */
function registerVerticalFace(state: ICoreState): void {
// 3 是自定义分组键,内置只占用了 0(Dir4)与 1(Dir8)
// 3 是当前空闲的数字键,不是保留区;自定义分组更推荐字符串 id
state.faceManager.register(3, new VerticalDir4FaceHandler());
// 也可以再按字符串 id 注册,便于配置里写 id
state.faceManager.registerById(
@ -322,6 +324,8 @@ function registerVerticalFace(state: ICoreState): void {
}
```
分组键就是 `register` / `get` 的数字参数。内置的 `FaceGroup` 是 `const enum`:`Dir4` 为 `0`、`Dir8` 为 `1`,所以取内置处理器时写 `FaceGroup.Dir4` / `FaceGroup.Dir8`,不要自己写 `0`、`1`——字面量不会跟着枚举声明顺序变化。示例里的 `3` 只是当前没有被内置占用的一个数字,**不是保留给自定义分组的区间**:分组键由注册时传入,没有命名空间保护,将来内置新增分组时可能和 `3` 撞车,后注册的会覆盖先注册的。自定义分组更稳妥的做法是用 `registerById` 的字符串 id 并用 `getById` 取回;如果坚持用数字键,取用前先 `get(3)` 确认返回 `null`,升级后也复查一次。
注册只往表里放处理器,不会替任何已有对象换掉手上的那一个。真正生效的地方是显式取用它的代码:构造移动器时把处理器传进去(见[移动 Mover](./mover.md#实现自己的移动器)),寻路时调用 `finder.useFaceHandler(handler)`。这些地方之后按新策略计算,已经构造好的移动器仍持有构造时的处理器。
## 朝向图块:IRoleFaceBinder
@ -396,7 +400,7 @@ function bindFacingTiles(binder: IRoleFaceBinder): void {
- <strong>四方向的斜向折左右</strong>:`Dir4FaceHandler.degrade` 把 `LeftUp` 与 `LeftDown` 折到 `Left`,`RightUp` 与 `RightDown` 折到 `Right`,不是折到上下。需要折上下时自建处理器。
- <strong>get 与 getById 可能返回 null</strong>:自建分组、或在启动期之外取用处理器时,先判空再调用;直接对 `null` 调方法会在运行期报错。
- <strong>dirOf 可能有多解</strong>:偏移量相同、方向不同的情况只返回其中一个,不要用它反推「唯一方向」,也不要用它校验位移是否合法。
- <strong>dirOf 可能有多解</strong>:偏移量相同、方向不同的情况只返回其中一个,不要用它反推「唯一方向」,也不要用它校验位移是否合法。内置 `Dir4FaceHandler` 还会返回八方向结果(`dirOf(1, 1)` 为 `RightDown`),这是实现特例,不要当作四方向结果使用。
- <strong>Unknown 的偏移是零</strong>:`movement` 与 `move` 对 `Unknown` 返回 `{ x: 0, y: 0 }`,`opposite` 与 `next` 返回 `Unknown`;把它当普通方向做位置运算,对象会原地不动。
- <strong>bind 前必须 malloc</strong>:主图块没有分配朝向时,`bind` 会记错误并放弃;绑定的朝向与主朝向相同也会被拒绝。
- <strong>重复 malloc 会覆盖</strong>:同一个图块数字再次 `malloc` 会重建该组的映射,之前 `bind` 进去的其它朝向不会被保留;已经 `bind` 过的子图块仍持有旧映射表,`getFaceOf` 可能查到旧结果。重建整组后要重新 `bind` 所有子图块。

View File

@ -18,11 +18,11 @@
高级教程按难度与主题分成三层。层与顶层的中级教程不是同一套划分:中级讲「怎么用」,高级讲「怎么扩展」。
| 层级 | 定位 | 主要主题 |
| --------------------------- | -------------------------------------- | ---------------------------------------------------------------------------- |
| 高级 I · 扩展基础 | 以插件系统为核心,后续内容都是它的拓展 | 声明式接口、插件系统、可存档内容、录像一致性、data-common 的 Store、钩子深入 |
| [高级 II · 深入定制](./ii/) | 深入定制 | 范围、图块、装备、录像指令、寻路、勇士与怪物属性、战斗上下文 |
| 高级 III · 原理与架构 | 原理与架构 | 双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试 |
| 层级 | 定位 | 主要主题 |
| --------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 高级 I · 扩展基础 | 以插件系统为核心,后续内容都是它的拓展 | 声明式接口、插件系统、可存档内容、录像一致性、data-common 的 Store、钩子深入、移动 Mover、FaceHandler 系列 |
| [高级 II · 深入定制](./ii/) | 深入定制 | 范围、图块、装备、录像指令、寻路、勇士与怪物属性、战斗上下文 |
| 高级 III · 原理与架构 | 原理与架构 | 双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试 |
高级 II 已经可阅读,入口见[高级 II 导览](./ii/);本层收录范围、图块、装备、录像指令、寻路、属性与战斗上下文。高级 III 仍用 `高级 III · <专题>` 这样的占位标记指出归属,暂不提供页面入口。

View File

@ -47,14 +47,14 @@
`HeroMover` 在每一步把信息打包成 `IHeroMoveTopHandler`,交给 `IHeroMoveTopImpl` 判断:
| 方法 | 返回 | 作用 |
| ------------------------ | ---------------- | ------------------------------------------------------ |
| `predicate()` | `IPassPredicate` | 返回通行性谓词,`HeroMover` 用它判断能否进入、是否撞击 |
| `inBound(x, y, floorId)` | `boolean` | 目标格是否在地图范围内 |
| `enter(handler)` | `Promise<void>` | 正常进入目标格后触发 |
| `leave(handler)` | `Promise<void>` | 离开当前格时触发 |
| `hit(handler)` | `Promise<void>` | 撞到不可进入的目标格时触发 |
| `cannotEnter(handler)` | `Promise<void>` | 被判定为无法进入时触发 |
| 方法 | 返回 | 作用 |
| ------------------------ | ---------------- | ------------------------------------------------------------------------------ |
| `predicate()` | `IPassPredicate` | 返回通行性谓词,`HeroMover` 用它判断能否进入、是否撞击(见下文「改通行规则」) |
| `inBound(x, y, floorId)` | `boolean` | 目标格是否在地图范围内 |
| `enter(handler)` | `Promise<void>` | 正常进入目标格后触发 |
| `leave(handler)` | `Promise<void>` | 离开当前格时触发 |
| `hit(handler)` | `Promise<void>` | 撞到不可进入的目标格时触发 |
| `cannotEnter(handler)` | `Promise<void>` | 被判定为无法进入时触发 |
`handler` 里带着 `currLoc`、`nextLoc`、`direction`、`floorId`、`face` 与 `state`,够你在判定里读位置、朝向与状态。引擎内置的 `DefaultHeroMoveTopImpl`(`src/data/hero/moverImpl.ts`)就是默认实现:它用通行谓词与事件系统完成边界判断、进出与撞击触发器。`MainDataPlugin` 在 `awake` 里把它装上:
@ -64,10 +64,30 @@ const heroMoveTopImpl = new DefaultHeroMoveTopImpl(state);
state.hero.location.mover.useTopImplementation(heroMoveTopImpl);
```
### 改通行规则:predicate() 与 IPassPredicate
`predicate()` 返回通行性谓词 `IPassPredicate`,真正「能不能走」的判定在它里面。`HeroMover` 先看边界;对方向步(`Dir`、`DirFace` 与 `Special`)再取一次谓词,用它决定本步的结果:
| 方法 | 作用 |
| -------------------- | --------------------------------------------------------------------- |
| `canPass(handler)` | 能否从当前格进入目标格;返回 `false` 时本步不移动,走「无法进入」分支 |
| `shouldHit(handler)` | 能进入但会产生撞击时返回 `true`;本步不移动,走「撞击」分支 |
两个方法的参数都是 `IPassCheckHandler`,字段包括 `currLoc`、`nextLoc`、`direction`(本步移动朝向)、`map`(勇士当前地图,可能为 `null`)与 `state`。判定顺序固定:
- `canPass` 为假 → `CannotMove`,不触发 `OnTouch` 撞击事件;
- `canPass` 为真且 `shouldHit` 为真 → `Hit`,触发 `OnTouch`;
- `canPass` 为真且 `shouldHit` 为假 → 正常移动。
两个容易踩空的地方:`config({ ignoreTerrain: true })` 会让 `HeroMover` 跳过整个谓词判断、只剩边界检查,此时改谓词看不到效果;`predicate()` 在每个方向步都会被调用,要返回构造时建好的稳定实例,不要在方法里新建对象(内置实现就是把 `DefaultPassPredicateImpl` 存在字段里)。
寻路用的是同一形状的谓词,但由 `state.pathfinding.finder.usePassPredicate(...)` 独立注入,不会自动跟随勇士的顶层实现。改了勇士的通行规则、又希望寻路一致时,要把同一个谓词再注入给 `finder`,详见[自动寻路](./ii/pathfinding.md)。
想保留默认行为、只改其中一环时,包一层并转发给默认实现,比从头写省事:
```ts
// src/data/hero/moverTop.ts
import { FaceDirection } from '@user/data-common';
import {
IHeroMoveTopHandler,
IHeroMoveTopImpl,
@ -76,17 +96,27 @@ import {
import { IStateSystem } from '@user/data-system';
import { DefaultHeroMoveTopImpl } from './moverImpl';
/** 保留默认的边界、通行与事件,只给撞击加一条日志。 */
/** 保留默认的边界、通行与事件,只禁止向左走,并给撞击加一条日志。 */
export class MyHeroMoveTopImpl implements IHeroMoveTopImpl {
// 默认实现负责边界、通行谓词与四类事件派发
private readonly base: DefaultHeroMoveTopImpl;
// 每步都会调用 predicate(),构造时建好一次,避免每步新建
private readonly passPredicate: IPassPredicate;
constructor(state: IStateSystem) {
this.base = new DefaultHeroMoveTopImpl(state);
const inner = this.base.predicate();
this.passPredicate = {
// [需改] 把这里换成你要拦的条件
canPass: handler =>
handler.direction !== FaceDirection.Left &&
inner.canPass(handler),
shouldHit: handler => inner.shouldHit(handler)
};
}
predicate(): IPassPredicate {
return this.base.predicate();
return this.passPredicate;
}
inBound(x: number, y: number, floorId: string | undefined): boolean {
@ -108,12 +138,14 @@ export class MyHeroMoveTopImpl implements IHeroMoveTopImpl {
}
cannotEnter(handler: IHeroMoveTopHandler): Promise<void> {
// 默认实现的 cannotEnter 是空实现,没有参数
// 接口要求带 handler;默认实现是空实现、用不到它,所以这里不转发
return this.base.cannotEnter();
}
}
```
`cannotEnter` 在接口上要收 `IHeroMoveTopHandler`,而 `DefaultHeroMoveTopImpl` 的实现声明为无参空实现(它用不到 handler),所以包装类保留参数、调用基类时不传。要让「无法进入」也触发点什么,就在这个方法里读 `handler` 自己实现,不要指望默认实现。
装配时把它换进勇士移动器:
```ts
@ -157,6 +189,8 @@ state.hero.location.mover.useTopImplementation(new MyHeroMoveTopImpl(state));
`start()` 把当前待执行队列取出来执行,并返回 `IMoverController`;如果移动器已经在移动,返回 `null`,不会接管当前移动。
返回 `null` 时它连队列都没有动过:既不切片也不清空,你刚排的步骤原样留在移动器里。等当前这次结束后再调用 `start()`,它们会照常执行;不想执行就用 `clear()` 丢掉。不要以为 `start()` 失败会把刚排的步骤一起清掉。
```ts
import { FaceDirection } from '@user/data-common';
import { ICoreState } from '@user/data-state';
@ -323,8 +357,7 @@ import {
IMoverController,
IObjectMovable,
ObjectMover,
ObjectMoveStep,
shouldReplay
ObjectMoveStep
} from '@user/data-common';
import { IFaceHandler, ITileLocator } from '@motajs/common';
@ -368,7 +401,6 @@ export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
}
// [需改] 单步结束:返回这一步的落点;坐标由基类写入
@shouldReplay('Simple mover step should be replayed.')
protected async onStepEnd(
code: number,
step: Readonly<ObjectMoveStep>,
@ -383,7 +415,7 @@ export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
return { x: tile.x, y: tile.y };
}
// [需改] 单步已结束、坐标已写入之后执行
// [需改] 单步已结束、坐标已写入之后执行;要在这里写状态就补 @shouldReplay(见示例)
protected async onStepSettled(
before: ITileLocator,
curr: ITileLocator,
@ -400,9 +432,11 @@ export class SimpleMover<T extends IObjectMovable> extends ObjectMover<T> {
- `onStepStart` 在每步开始前运行。此时基类已经更新过移动朝向、面朝朝向、速度与动画方向,但坐标还没变。它返回本步的结果码,供后面的逻辑与钩子使用。
- `onStepEnd` 计算这一步的落点,返回一个 `ITileLocator`。基类拿它和当前坐标比较,不同才调用 `tile.setPos`。不要在这里自己写坐标。
- `onStepSettled` 在坐标写入之后运行,参数里带着移动前坐标 `before` 与落点 `curr`。这里适合触发进入 / 离开 / 踩踏一类的事件,或者把朝向同步回对象。
- 五个方法都是 `protected`,外部代码不能直接调用。对外是链式方法、`start()`、三个 setter 与几个只读状态。
- 五个方法都是 `protected` 抽象方法,外部代码不能直接调用,子类必须实现。对外是链式方法、`start()`、三个 setter 与几个只读状态。
- 基类里的 `prepareStep` 与 `moveProgress` 是 `private`,子类既不能覆写也不能调用。`prepareStep` 每步会把 `moveDirection`、`faceDirection`、`currAnimDir`、`currentSpeed` 按步骤更新好,子类读这些状态即可。
- `faceHandler`、`moveDirection`、`faceDirection` 在 `IObjectMover` 接口上都是 `readonly`,按只读使用。手动改它们不会改变流程,还会被下一步的 `prepareStep` 覆盖。
模板里的 `@shouldReplay` 标在会改状态的步进方法上,用来让这一步进入录像检查;如果你的移动不需要进录像,可以去掉,原因见下文「与录像、存档的关系」。
模板本身不写状态,所以没有加 `@shouldReplay`。你的实现一旦在某个方法里改状态(写朝向、写对象、派发事件),就按示例把标记加在那个方法上;原因与放置规则见下文「与录像、存档的关系」。
## 示例:一个最小可移动对象
@ -487,7 +521,6 @@ export class SimpleMover extends ObjectMover<SimpleTile> {
return SimpleMoveCode.Success;
}
@shouldReplay('Simple tile moving step should be replayed.')
protected async onStepEnd(
code: number,
step: Readonly<ObjectMoveStep>,
@ -518,6 +551,8 @@ export class SimpleMover extends ObjectMover<SimpleTile> {
}
}
// 这里写回朝向,属于状态变更,所以要标 @shouldReplay
@shouldReplay('Simple tile facing should be replayed.')
protected async onStepSettled(
before: ITileLocator,
curr: ITileLocator,
@ -575,9 +610,15 @@ state.hero.location.mover.useTopImplementation(new MyHeroMoveTopImpl(state));
## 与录像、存档的关系
`HeroMover` 在 `onStepStart`、`onStepEnd`、`onStepSettled` 上标了 `@shouldReplay`;`DynamicTileMover` 标在 `onStepEnd`。这些方法会改变状态,所以移动会进入录像检查。自定义移动器同理:把 `@shouldReplay` 标在真正改状态的步进方法上,通常是 `onStepEnd` 或 `onStepSettled`,不要标在只读查询上。
`HeroMover` 在 `onStepStart`、`onStepEnd`、`onStepSettled` 上都标了 `@shouldReplay`,因为这三个方法体里都有写入或副作用:`onStepStart` 登记录像码,`onStepEnd` 与 `onStepSettled` 会调用顶层实现触发事件。`DynamicTileMover` 只标 `onStepEnd`,因为它在那里面写回朝向并算出落点。
标记的具体含义、检测能保证什么与不能保证什么,见[录像一致性](./replay-consistency.md)。
自定义移动器同理:标在方法体里真正写状态的那一个,而不是「看起来最关键」的那一个。判断标准很直接:这个方法体里有没有写操作。
- `onStepEnd` 只返回落点,坐标由基类在它返回后写入;方法体里没有写入时,标它没有意义。
- 在 `onStepSettled` 里调用 `tile.setFaceDirection(...)` 把朝向写回对象,写入就在这里,要标 `onStepSettled`。
- 方法体里既有落点计算又有写入(内置 `DynamicTileMover.onStepEnd` 就是这种),标 `onStepEnd`。
不要标在只读查询上。标记的具体含义、检测能保证什么与不能保证什么,见[录像一致性](./replay-consistency.md)。
位置本身是存档内容。`IHeroLocationSave` 保存 `x`、`y`、`direction`、`floorId`;移动过程不会为每一步写存档,读档后恢复的是位置结果,不是「正在走第几步」。因此不要在移动器里另存一份位置状态,否则读档后容易与对象本身不一致。
@ -614,12 +655,12 @@ state.hero.location.mover.useTopImplementation(new MyHeroMoveTopImpl(state));
## 边界与注意
- <strong>start 返回 null</strong>:移动器已经在移动时不会接管,此时也没有新的控制器可操作。要等或要停,只能用上一次 `start()` 保存的 `controller`:`await controller.onEnd`,或调用 `controller.stop()`。`IHeroMover` 没有 `stop()`,不要在移动器上找。
- <strong>start 返回 null</strong>:移动器已经在移动时不会接管,此时也没有新的控制器可操作。要等或要停,只能用上一次 `start()` 保存的 `controller`:`await controller.onEnd`,或调用 `controller.stop()`。`IHeroMover` 没有 `stop()`,不要在移动器上找。返回 `null` 时刚排的步骤没有被消费,仍留在移动器里,下一次 `start()` 会执行;不要就用 `clear()`。
- <strong>moveDirection 被重置</strong>:`start()` 把移动朝向(`moveDirection`)设为 `Unknown`,面朝朝向(`faceDirection`)不受影响;`forward()` 用的是面朝朝向,不依赖这次重置。
- <strong>执行期间链式方法无效</strong>:`mover.step`、`mover.face`、`mover.speed` 等写的是下一次的队列;执行中追加只能用 `controller.push` 或 `controller.insert`。
- <strong>stop 不立刻中断</strong>:当前步会走完。第一步还没开始时调用,可能一步都不走。
- <strong>不要在步进方法里 await stop</strong>:`stop()` 返回的 Promise 要等当前步结束才兑现,在步进方法或步进钩子里 `await` 会卡死整次移动。
- <strong>步进方法或钩子抛错会卡住移动器</strong>:`ObjectMover` 没有为执行 Promise 兜底;任何一步或任一钩子抛错,`onEnd` 都不再兑现、`done` 保持假、`moving` 保持真,之后的 `start()` 会一直返回 `null`。自定义步进方法要把预期内的失败转成结果码,而不是抛错。
- <strong>步进方法或钩子抛错会卡住移动器,而且没有恢复入口</strong>:`ObjectMover` 没有为执行 Promise 兜底;任何一步或任一钩子抛错,`onEnd` 都不再兑现、`done` 保持假、`moving` 保持真,之后的 `start()` 会一直返回 `null`,这次拒绝也没有被捕获,会以未处理的 Promise 拒绝暴露出来。`moving` 对外是只读状态,`IHeroMover` / `IObjectMover` 没有重置或强制结束的方法;`controller.stop()` 只能置停止标记,无法兑现那个已经被拒绝的 Promise。结论是运行期没有恢复路径,只能重建数据端(重新创建对象与移动器)。要避免它:自定义步进方法与钩子把预期内的失败转成结果码或内部标记,用 `try` / `catch` 包住可能抛错的外部调用,不要让它冒泡。
- <strong>setPos 不触发步进钩子</strong>:移动器的 `setPos` 只触发 `onSetPos`,不触发 `onStepStart` / `onStepEnd`;移动过程中的落点写入也不会触发 `onSetPos`。
- <strong>speed 与 animDir 只是状态</strong>:它们不产生位移。没有消费方读取时,设置速度与动画方向不会产生可见效果。
- <strong>faceHandler 必须与对象方向体系一致</strong>:四方向对象配八方向处理器,或反过来,都会让偏移与朝向对不上。