diff --git a/.planning/phases/10-docs-restructure/10-32-USER-REVIEW.md b/.planning/phases/10-docs-restructure/10-32-USER-REVIEW.md new file mode 100644 index 0000000..0f237e4 --- /dev/null +++ b/.planning/phases/10-docs-restructure/10-32-USER-REVIEW.md @@ -0,0 +1,44 @@ +# 高级 I 补写(Mover / FaceHandler)用户修订记录 + +## 第 1 轮(2026-10-24,待执行) + +**用户意见(核心):** 两篇文档重心不对。重心应是**怎么使用**,不是怎么自定义。游戏需要自定义移动器 / 自定义朝向组的概率几乎为 0,不要大篇幅写自定义;应重点讲:怎么用、不同可移动对象怎么获取移动器(动态图块、勇士等)。自定义部分最多一两节简单带过。 + +**长期规范:** + +- **D-77**:文档重心是「怎么用」;自定义内容最多一两节;写作后自问每个章节的使用概率,通篇低概率说明文档不好。 +- **D-78**:独立读者审阅必须为每个章节给出「被使用的概率」(高/中/低 + 理由)。 + +**重构要求:** + +- `mover.md`:以用法为主——先讲移动器能做什么;**如何获取移动器**(勇士 `state.hero.location.mover`、动态图块 `tile.mover`、如何得到动态图块等);如何规划(`step`/`stepFace`/`forward`/`backward`/`tp`/`face`/`jump`/`speed`/`animDir`/`push`/`clear`);如何开始与等待(`start` → `IMoverController` → `onEnd`);执行中控制(`controller.push`/`insert`/`stop`);配置与钩子的用法;自定义移动器 / 勇士 `useTopImplementation` 压缩到末尾 1–2 节。 +- `face-handler.md`:以用法为主——如何获取处理器(`faceManager.get` / `getById`)、常用方法(`move`/`movement`/`opposite`/`next`/`degrade`/`dirOf`/`mapMovement`)、用在哪里(移动器、图块朝向、`roleFace` 绑定)、内置 Dir4/Dir8 差异;自定义处理器压缩到末尾 1 节。 +- 低概率内容(内部执行流程、深度实现差异等)后置或大幅删减。 +- 保持 D-72(注释优先)与源码准确性。 + +**状态:已执行(2026-10-24)** + +### 执行记录 + +**改动范围:** 仅 `docs/advanced/mover.md`、`docs/advanced/face-handler.md`;未改代码、配置与状态文件。 + +**`mover.md`(775 → 525 行):** + +- 新顺序:能做什么 → 拿到移动器(勇士 / 动态图块 / 其他对象)→ 排计划 → 开始与等待 → 执行中控制 → 勇士配置 → 钩子 → 读取与设置状态 → 自定义(2 节)→ 边界 → 验证。 +- 「拿到移动器」提前到第 2 节,给出 `state.hero.location.mover`、`tile.mover` 与取得动态图块的方式(`transferToDynamic` / `createDynamic` / `getDynamicTilesAt` / `iterateDynamicTiles`),并补 `tile.step()` 便捷入口。 +- 步骤类型表并入「排计划」(`controller.push` 需要);配置表前移到用法区。 +- 删除独立的「内部执行流程」整节;删掉 `SimpleTile` + 装配 + 插件装配三段示例,自定义移动器只留一条 5 方法说明与一个最小模板。 +- 自定义压缩为末尾两节:「实现自己的移动器」「勇士的顶层实现」;保留 D-76 面朝 / 移动朝向说明。 +- 新增勇士「排好计划也不一定真走」的用法提示(通行失败时 `HeroMover` 调 `controller.stop()`)。 + +**`face-handler.md`(447 → 350 行):** + +- 新顺序:三个概念 → 拿到处理器 → 常用方法 → 用在哪里(移动器 / 寻路、图块朝向、`roleFace` 映射)→ 内置 Dir4/Dir8 → 自定义(1 节)→ 边界 → 验证。 +- 「拿到处理器」提前,明确 `get` 泛型、`getById` 与 `null` 判空;`state.roleFace` 在「用在哪里」中作为映射入口讲。 +- 常用方法表保留 8 个方法,补一段 `movement` 组合用法;`dirOf` 特例按 D-72 收敛为「实现特例,不要当四方向结果」。 +- 自定义处理器压缩为 1 节:契约 4 条 + 一个完整模板(用 `CW` 数组替代原有的两张旋转表)+ 注册示例;删除大段表格解释。 +- `setFaceDirection` 降级分支与 `Dir8.degrade` 的实现差异各保留一句,放在使用说明处而非独立小节。 + +**验证:** `prettier --write` 两篇通过;`cmd /c "pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build"` 构建通过,无死链;CRLF 保持。 + +**自评(按 D-77 / D-78):** 高概率章节(拿移动器、排计划、开始与等待、执行中控制、常用方法、用在哪里)占正文约一半且全部前置;自定义仅末尾 2 节 / 1 节。仍属低概率但保留的内容:`mover.md` 的自定义模板与勇士顶层实现示例(读者确需扩展时才有用),`face-handler.md` 的 `IRoleFaceBinder` 细节(图块换向时才会用到)。\*\* diff --git a/docs/advanced/face-handler.md b/docs/advanced/face-handler.md index a28cc18..38f9ca5 100644 --- a/docs/advanced/face-handler.md +++ b/docs/advanced/face-handler.md @@ -1,10 +1,10 @@ # FaceHandler 系列 -方向在数据端有两个落点。一个是逻辑朝向 `FaceDirection`:勇士朝哪、动态图块这一步朝哪,都用它表示。另一个是图块数字:同一个角色可能有多张不同朝向的图,移动时需要按当前朝向换成对应的数字。 +方向在数据端有两个落点。一个是逻辑朝向 `FaceDirection`:勇士朝哪、动态图块这一步朝哪,都用它表示。另一个是图块数字:同一个角色可能有多张不同朝向的图,换方向时要换成对应的数字。 -两个落点之间夹着一套换算。四方向与八方向认的方向集合不同,斜向输入在四方向体系里必须先归并成某个方向,才有偏移和转向可言。`IFaceHandler` 负责这套换算,把任意朝向变成本组支持的方向、位移、反方向与旋转;`IRoleFaceBinder` 负责把朝向映射成图块数字。 +两个落点之间夹着一套换算。`IFaceHandler` 把任意朝向变成本组支持的方向、位移、反方向与旋转;`IRoleFaceBinder` 把朝向映射成图块数字。 -本篇只讲数据端。这些值最后怎么画、动画怎么播,由渲染端读取后完成(`渲染端 · 朝向图块绘制`)。 +本篇只讲数据端。这些值最后怎么画、动画怎么播,由渲染端读取后完成。 @@ -16,21 +16,20 @@ | `FaceGroup` | 方向组的编号,内置 `Dir4` 与 `Dir8`,表示「这一组认哪些方向」 | 从 `state.faceManager` 取处理器时作为 key | | `IFaceHandler` | 朝向处理器,把任意方向换算成本组支持的方向、偏移、反方向与旋转 | 移动器构造、寻路邻域、图块转向 | -`FaceDirection` 的成员依次是 `Unknown`、`Left`、`Up`、`Right`、`Down`,再加上四个斜向 `LeftUp`、`RightUp`、`LeftDown`、`RightDown`。它是 `const enum`,数值由声明顺序决定;不要按数值硬编码,一律写成员名。方向值为 `Unknown` 时表示没有方向,它也会出现在处理器的输入与输出里。 +`FaceDirection` 的成员依次是 `Unknown`、`Left`、`Up`、`Right`、`Down`,再加上四个斜向 `LeftUp`、`RightUp`、`LeftDown`、`RightDown`。它是 `const enum`,数值由声明顺序决定;不要按数值硬编码,一律写成员名。`Unknown` 表示没有方向,它也会出现在处理器的输入与输出里。 -`FaceGroup` 只有 `Dir4` 与 `Dir8` 两个成员,分别表示四方向(上下左右)与八方向(上下左右加斜向)。它本身不携带行为,只是一个编号;真正的换算逻辑在对应的 `IFaceHandler` 上。 +`FaceGroup` 只有 `Dir4` 与 `Dir8` 两个成员,分别表示四方向(上下左右)与八方向(上下左右加斜向)。它本身不携带行为,只是一个编号;真正的换算逻辑在对应的 `IFaceHandler` 上。取处理器时写 `FaceGroup.Dir4` / `FaceGroup.Dir8`,不要自己写 `0`、`1`。 还有一个配套类型 `IFaceDescriptor`,它是只读的 `{ x, y }` 增量,处理器用它表示「朝这个方向走一步,坐标加多少」。它不是坐标,而是位移量。 ## 拿到朝向处理器 -`state.faceManager` 是朝向处理器的注册表,有两种取法:按数字分组取,或按字符串 id 取。两者找不到时都返回 `null`。`get` 的泛型决定返回的 `IFaceHandler` 里 `T` 是什么:不写泛型时 `T` 退化为 `number`,`degrade`、`opposite`、`next` 的静态类型都会是 `number`;需要 `FaceDirection` 的强类型时写 `get(...)`。 +`state.faceManager` 是朝向处理器的注册表,有两种取法:按数字分组取,或按字符串 id 取。两者找不到时都返回 `null`。 ```ts import { FaceDirection, FaceGroup } from '@user/data-common'; import { ICoreState } from '@user/data-state'; -/** 取内置的八方向处理器,并算一步向上的偏移。 */ function stepUp(state: ICoreState): void { const dir8 = state.faceManager.get(FaceGroup.Dir8); if (!dir8) { @@ -48,23 +47,15 @@ function stepUp(state: ICoreState): void { } ``` -注册发生在启动期,位于 `src/data/initialize.ts` 的 `awake` 中,四个内置注册分别是 `Dir4` + `'dir4'` 与 `Dir8` + `'dir8'`: +`get` 的泛型决定返回的 `IFaceHandler` 里 `T` 是什么:不写泛型时 `T` 退化为 `number`,`degrade`、`opposite`、`next` 的静态类型都会是 `number`;需要 `FaceDirection` 的强类型时写 `get(...)`。 -```ts -// src/data/initialize.ts 的 awake 中,朝向组在启动期一次性注册 -const dir4 = new Dir4FaceHandler(); -const dir8 = new Dir8FaceHandler(); -state.faceManager.register(FaceGroup.Dir4, dir4); -state.faceManager.registerById('dir4', dir4); -state.faceManager.register(FaceGroup.Dir8, dir8); -state.faceManager.registerById('dir8', dir8); -``` +内置处理器在启动期注册(`src/data/initialize.ts` 的 `awake` 中),四方向与八方向各注册了数字 key 与字符串 id。启动之后按约定只读使用:运行期再注册只会影响之后取用它的地方,不会替换已经构造好的对象里保存的处理器。 -启动之后按约定只读使用:运行期再注册分组只会影响之后取用它的地方,不会替换已经构造好的对象里保存的处理器。自定义分组用未被占用的数字或字符串 id 都可以,见后文「实现自己的朝向处理器」。 +同一个角色的图块数字映射由 `state.roleFace` 负责,见下文「朝向图块映射」。 -## IFaceHandler 能做什么 +## 常用方法 -八个方法可以按「输入方向、输出什么」分成三组:降级与换算、遍历、由偏移反推。`degrade` 是一整套的核心,因为调用方可以传进任意方向值,包括本组不认的方向。 +八个方法可以按「输入方向、输出什么」分成三组:降级与换算、遍历、由偏移反推。`degrade` 是核心,因为调用方可以传进任意方向值,包括本组不认的方向。 | 方法 | 作用 | 输入先 `degrade` | `Unknown` 的结果 | | --------------------------- | -------------------------------------- | ------------------ | --------------------------- | @@ -77,48 +68,7 @@ state.faceManager.registerById('dir8', dir8); | `mapDirection()` | 迭代本组支持的所有方向 | 不接收方向 | 包含 `Unknown` | | `mapMovement()` | 迭代本组所有方向及其偏移 | 不接收方向 | 包含 `Unknown` 对应的零偏移 | -`degrade` 决定这一组的归并策略:本组不认的方向该折成哪一个。后文会看到,内置的 `Dir4FaceHandler` 把斜向折到左右,而自定义处理器可以改成折到上下。 - -`movement` 与 `move` 的区别只在步数。`move(dir, -2)` 等价于把 `movement(dir)` 的横纵分量都乘 `-2`,也就是朝反方向走两步,但它返回的仍是 `dir` 的位移乘以负数,不会先取 `opposite`。 - -`opposite` 与 `next` 都在本组内求解:先 `degrade`,再查本组的映射表,因此传入一个本组不认的方向时,结果取决于 `degrade` 的归并。`next` 的第二个参数是 `anticlockwise`,省略时为 `false`,即顺时针。 - -`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` 扩展邻域时,要跳过零偏移条目,否则对象会原地走一步;寻路的构图循环就是这样处理的。 - -## 两个内置处理器 - -内置实现都在 `@user/data-common` 的 `faceManager.ts` 里。它们都实现 `IFaceHandler`,区别只在认哪些方向、以及斜向怎么归并: - -| 处理器 | 支持方向 | `degrade` 行为 | 典型用途 | -| ----------------- | ---------------- | -------------------------------------------------- | -------------------------------- | -| `Dir8FaceHandler` | 八个方向 | 原样返回,不做归并 | 八方向移动、勇士与动态图块的默认 | -| `Dir4FaceHandler` | 上下左右四个方向 | 四个正方向原样;四个斜向折到左右;其余为 `Unknown` | 只走四方向的地图与寻路 | - -`Dir8FaceHandler` 的 `degrade` 把八个方向原样保留;按接口注释的约定,无法合理降级的方向(包括 `Unknown`)应返回 `Unknown`。当前实现是把入参原样返回,所以 `degrade(999)` 得到 `999`——这是实现与注释不一致的地方,不要把这种返回值当作合法方向使用。查表失败时 `movement` 退回零偏移,`opposite` 与 `next` 退回 `Unknown`。 - -`Dir4FaceHandler` 的归并需要记住一条:斜向折到左右,不是折到上下。上表说的是 `degrade` 与位移的方向集合;不要据此认为 `dirOf` 也只返回四个方向,原因见前文。 - -| 输入 | `degrade` 结果 | -| ------------ | -------------- | -| `LeftUp` | `Left` | -| `LeftDown` | `Left` | -| `RightUp` | `Right` | -| `RightDown` | `Right` | -| 其余非正方向 | `Unknown` | - -这一点决定了四方向体系里的实际手感:斜向输入不会让对象斜着走,也不会变成上下位移,而是变成横向一步。四方向地图上如果希望斜向折到上下,就需要自己写一个处理器(见下一节)。 - -两个处理器在 `Unknown` 上的行为一致:`movement` 与 `move` 返回零偏移,`opposite` 与 `next` 返回 `Unknown`,`mapDirection` 与 `mapMovement` 会把它列出来。 - -勇士与动态图块默认都走 `Dir8`:勇士的分组来自 `src/data/constant.ts` 的 `HERO_FACE`,由 `MainDataPlugin` 构造时传入;动态图块的分组来自 `data-base/src/shared.ts` 的 `DYNAMIC_MOVER_FACE`(同文件的 `HERO_MOVER_FACE` 也定义为 `Dir8`)。需要四方向时,改的是这些分组来源,而不是处理器本身的实现。 - -## 用处理器算位置与方向 - -处理器最常见的用法是「把方向换成坐标」。下面三段各自独立,分别算目标格、算反方向、遍历四方向。 +最常见的是「把方向换成坐标」: ```ts import { FaceDirection, FaceGroup } from '@user/data-common'; @@ -132,205 +82,42 @@ function targetOf( const handler = state.faceManager.get(FaceGroup.Dir8); if (!handler) return null; - const loc = state.hero.getLocation(); // [需改] 把 direction 换成你要算的方向 const { x, y } = handler.movement(direction); + const loc = state.hero.getLocation(); return { x: loc.x + x, y: loc.y + y }; } ``` -```ts -import { FaceDirection, FaceGroup } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; +`move` 适合一次算多格,例如 `handler.move(FaceDirection.Left, 3)` 得到 `{ x: -3, y: 0 }`;传负数就是反方向。`opposite` 与 `next` 都在本组内求解:先 `degrade`,再查本组的映射表,所以传入本组不认的方向时,结果取决于 `degrade` 的归并。`next` 的第二个参数是 `anticlockwise`,省略时为 `false`,即顺时针。 -/** 取某个方向在本组内的反方向。 */ -function oppositeOf( - state: ICoreState, - direction: FaceDirection -): FaceDirection | null { - const handler = state.faceManager.get(FaceGroup.Dir8); - if (!handler) return null; +`dirOf` 走的是相反方向:给你一个偏移量,问它是哪个方向。它不接收方向,也不会先 `degrade`;偏移量与表中某一项完全相等时才命中,多个方向共用同一偏移时只返回其中一个,命中顺序就是表的条目顺序。内置 `Dir4FaceHandler.dirOf` 实际遍历的是八方向偏移表,所以 `dir4.dirOf(1, 1)` 会返回 `RightDown`,而不是 `Unknown`。这是内置实现的特例,不要照抄到自定义处理器里,也不要拿它做四方向判定;需要严格四方向结果时,把返回值再用 `degrade` 折一次。 - // [需改] 把 direction 换成你要取反的方向 - return handler.opposite(direction); -} -``` +`mapDirection` 与 `mapMovement` 用于遍历,两者的结果都包含 `Unknown`,`mapMovement` 里 `Unknown` 对应的偏移是 `{ x: 0, y: 0 }`。自己按 `mapMovement` 扩展邻域时,要跳过零偏移条目,否则对象会原地走一步;寻路的构图循环就是这样处理的。 -```ts -import { FaceDirection, FaceGroup } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; +## 用在哪里 -/** 列出四方向处理器认的所有方向与偏移,包含 Unknown。 */ -function listDir4Steps(state: ICoreState): void { - const handler = state.faceManager.get(FaceGroup.Dir4); - if (!handler) return; +### 移动器与寻路 - for (const [dir, offset] of handler.mapMovement()) { - // dir 为 Unknown 时 offset 是 { x: 0, y: 0 } - console.log(dir, offset.x, offset.y); - } -} -``` +移动器构造时保存一个处理器,每一步用它把 `moveDirection` 换成落点偏移。内置的 `DynamicTile` 构造自己的 `DynamicTileMover` 时,从 `state.faceManager` 按 `DYNAMIC_MOVER_FACE`(即 `Dir8`)取处理器;自己写移动器时,处理器要作为构造参数传进去(见[移动 Mover](./mover.md#实现自己的移动器))。处理器必须与对象的方向体系一致:四方向对象配八方向处理器,或反过来,偏移与朝向都会对不上。 -`move` 适合一次算多格,例如 `handler.move(FaceDirection.Left, 3)` 得到 `{ x: -3, y: 0 }`;传负数就是反方向。需要逐个方向处理时用 `mapMovement`,不要自己拼一份方向数组——自定义处理器换掉方向集合后,遍历写法仍然成立。 +寻路复用同一套方向规则。构建有向图时,它按 `mapMovement()` 的条目向相邻格扩展,所以处理器直接决定「能往哪几个方向走」:四方向处理器只扩展上下左右,八方向处理器会扩展到斜向。注入方式是 `finder.useFaceHandler(handler)`;没有注入时构图会失败(找不到图层、谓词或处理器都会失败),详见[自动寻路](./ii/pathfinding.md)。 -## 实现自己的朝向处理器 +因为「方向即偏移」被多处共用,改处理器的行为会同时影响移动与寻路:把四方向处理器的斜向归并改成折到上下,移动器的斜向输入和寻路的斜向分支会一起改变。只希望其中一处变化时,不要直接换掉共享的处理器,而是给那处单独注入一个处理器实例。 -需要自定义的场合有两类:仍然走四方向,但希望斜向换一种归并;或者方向数不是 4 与 8,需要一套自己的方向规则。两者都通过实现 `IFaceHandler` 完成。 +### 图块朝向 -实现时可以把「降级表」「运动表」「旋转表」放在模块顶层做共享常量,类只负责按契约查表。这样同一个处理器可以被多个对象共用,实例上不保存任何「当前对象」的状态。需要自己写一个时,可参考下述模板。 +地图图层默认使用 `state.roleFace` 作为自己的朝向绑定器,图块转向时查的就是这份映射。`MapTileBase.setFaceDirection(direction)` 的处理顺序是: -### 模板:一个自定义朝向处理器 +1. 用当前图块数字与目标方向查 `getFaceOf`,查到就换图并返回新数字; +2. 查不到时用 `Dir4` 处理器的 `degrade` 折一次,再查一次; +3. 仍查不到则保持原图块数字。 -这个模板仍是四方向,但把斜向折到上下,用来演示 `degrade` 的策略作用。`[需改]` 标出需要替换的部分。 +第 2 步在数据端只返回折后查到的图块数字,没有把图块本身换掉。按方法注释的语义,降级路径用于「斜向折成四方向再取图」,但当前实现不会写回图块,所以使用降级路径时不要假定图块已经换掉。 -```ts -import { IFaceDescriptor, IFaceHandler } from '@motajs/common'; -import { FaceDirection } from '@user/data-common'; +### 朝向图块映射 -// [需改] 降级表:决定本组认哪些方向、其余方向怎么归并 -const VERTICAL_DEGRADE: ReadonlyMap = new Map([ - [FaceDirection.Left, FaceDirection.Left], - [FaceDirection.Up, FaceDirection.Up], - [FaceDirection.Right, FaceDirection.Right], - [FaceDirection.Down, FaceDirection.Down], - // 斜向折到上下,这是与内置 Dir4FaceHandler 的唯一区别 - [FaceDirection.LeftUp, FaceDirection.Up], - [FaceDirection.RightUp, FaceDirection.Up], - [FaceDirection.LeftDown, FaceDirection.Down], - [FaceDirection.RightDown, FaceDirection.Down] -]); - -// [需改] 运动表:本组每个方向的单步偏移,必须包含 Unknown 的零偏移 -const VERTICAL_MOVEMENTS: ReadonlyMap = new Map( - [ - [FaceDirection.Unknown, { x: 0, y: 0 }], - [FaceDirection.Left, { x: -1, y: 0 }], - [FaceDirection.Up, { x: 0, y: -1 }], - [FaceDirection.Right, { x: 1, y: 0 }], - [FaceDirection.Down, { x: 0, y: 1 }] - ] -); - -// 顺时针旋转顺序,不含 Unknown -const VERTICAL_CW: ReadonlyMap = new Map([ - [FaceDirection.Up, FaceDirection.Right], - [FaceDirection.Right, FaceDirection.Down], - [FaceDirection.Down, FaceDirection.Left], - [FaceDirection.Left, FaceDirection.Up] -]); - -// 逆时针旋转顺序,不含 Unknown -const VERTICAL_CCW: ReadonlyMap = new Map([ - [FaceDirection.Up, FaceDirection.Left], - [FaceDirection.Left, FaceDirection.Down], - [FaceDirection.Down, FaceDirection.Right], - [FaceDirection.Right, FaceDirection.Up] -]); - -// 反方向表,只覆盖本组方向 -const VERTICAL_OPPOSITE: ReadonlyMap = new Map([ - [FaceDirection.Up, FaceDirection.Down], - [FaceDirection.Down, FaceDirection.Up], - [FaceDirection.Left, FaceDirection.Right], - [FaceDirection.Right, FaceDirection.Left] -]); - -/** 四方向处理器:斜向一律折到上下。 */ -export class VerticalDir4FaceHandler implements IFaceHandler { - degrade(dir: number): FaceDirection { - // 表中没有的方向(含 Unknown 与任意非法值)一律视为无法降级 - return ( - VERTICAL_DEGRADE.get(dir as FaceDirection) ?? FaceDirection.Unknown - ); - } - - movement(dir: number): IFaceDescriptor { - // 先降级,再查表;查不到按零偏移处理 - return VERTICAL_MOVEMENTS.get(this.degrade(dir)) ?? { x: 0, y: 0 }; - } - - move(dir: number, count: number): IFaceDescriptor { - // 等价于 movement 乘以 count;count 为负表示反向位移 - const { x, y } = this.movement(dir); - return { x: x * count, y: y * count }; - } - - opposite(dir: number): FaceDirection { - const degraded = this.degrade(dir); - // 降级后仍查不到时返回 Unknown - return VERTICAL_OPPOSITE.get(degraded) ?? FaceDirection.Unknown; - } - - next(dir: number, anticlockwise: boolean = false): FaceDirection { - const degraded = this.degrade(dir); - // Unknown 不参与旋转,原样返回 - if (degraded === FaceDirection.Unknown) return FaceDirection.Unknown; - - const map = anticlockwise ? VERTICAL_CCW : VERTICAL_CW; - return map.get(degraded) ?? FaceDirection.Unknown; - } - - dirOf(dx: number, dy: number): FaceDirection { - // 只认本组的单步偏移;斜向偏移没有对应方向,返回 Unknown - for (const [dir, { x, y }] of VERTICAL_MOVEMENTS) { - if (x === dx && y === dy) return dir; - } - return FaceDirection.Unknown; - } - - mapDirection(): Iterable { - return VERTICAL_MOVEMENTS.keys(); - } - - mapMovement(): Iterable<[FaceDirection, IFaceDescriptor]> { - return VERTICAL_MOVEMENTS.entries(); - } -} -``` - -模板的每个方法都对应一条契约,实现时要逐条满足: - -- 所有接收方向的入参,都要在方法内部先经过 `degrade`。`movement`、`move`、`opposite`、`next` 都是如此;`degrade` 本身就是降级,`dirOf`、`mapDirection`、`mapMovement` 不接收方向。 -- `Unknown` 不能被当成普通方向参与运算:`movement` 与 `move` 返回零偏移,`opposite` 与 `next` 返回 `Unknown`。 -- `mapMovement` 必须包含 `Unknown` 及其零偏移。处理器的方法可能返回 `Unknown`,调用方靠这张表把每个方向都映射到偏移;缺了它,`Unknown` 只能走各方法自己的兜底分支,行为就不再由表统一决定。 -- `next` 的第二个参数省略时表示顺时针;逆时针表要能与顺时针表首尾相接,转满一圈回到起点。 -- `dirOf` 匹配不到时要返回 `Unknown`,不要抛错;多个方向共用同一偏移时返回其中一个即可。只遍历本组自己的偏移表——内置 `Dir4FaceHandler` 遍历八方向表是特例(见前文),照抄会让你返回本组不支持的方向。 -- 表用 `ReadonlyMap` 这类共享常量即可,不要在处理器实例上记录「当前处理的是哪个对象」,处理器是共用的。 - -注册时选一个未被内置占用的分组键,数字与字符串 id 都可以: - -```ts -import { FaceDirection } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; -import { VerticalDir4FaceHandler } from './vertical-face-handler'; - -/** 注册自定义朝向组,并取回验证一次。 */ -function registerVerticalFace(state: ICoreState): void { - // 3 是当前空闲的数字键,不是保留区;自定义分组更推荐字符串 id - state.faceManager.register(3, new VerticalDir4FaceHandler()); - // 也可以再按字符串 id 注册,便于配置里写 id - state.faceManager.registerById( - 'vertical-dir4', - new VerticalDir4FaceHandler() - ); - - const handler = state.faceManager.get(3); - if (!handler) return; - - // 斜向现在折到上下:LeftUp 的降级结果是 Up - console.log(handler.degrade(FaceDirection.LeftUp)); - console.log(handler.movement(FaceDirection.LeftUp)); // { x: 0, y: -1 } -} -``` - -分组键就是 `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 - -同一个角色可能有多张图,分别对应不同朝向。逻辑上的「朝右」需要换成「朝右那张图的图块数字」,这件事由 `state.roleFace` 完成。`IRoleFaceBinder` 只维护图块数字之间的对应关系,不关心对象在哪、移动器是什么。 +同一个角色可能有多张图,分别对应不同朝向。真正把「朝右」换成「朝右那张图的图块数字」的是 `IRoleFaceBinder`,地图图层通过 `state.roleFace` 使用它。 | 方法 | 作用 | 返回 | | ------------------------------ | ------------------------------------ | ------------------- | @@ -343,26 +130,23 @@ function registerVerticalFace(state: ICoreState): void { `malloc` 是建组:`identifier` 作为主图块,`main` 是它的朝向(主朝向一般是朝下)。`bind` 把同组的另一张图块挂进来,`main` 必须是已经 `malloc` 过的图块数字。两者都只写映射,不发事件: ```ts -import { FaceDirection, IRoleFaceBinder } from '@user/data-common'; +import { FaceDirection } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; -/** 登记一组朝向图块,并读回映射结果。 */ -function bindFacingTiles(binder: IRoleFaceBinder): void { +function bindFacingTiles(state: ICoreState): void { + const binder = state.roleFace; // [需改] 1 是主图块数字(朝下),2 是同一角色的另一张朝向图块 binder.malloc(1, FaceDirection.Down); binder.bind(2, 1, FaceDirection.Right); // 传入组内任意一个图块数字,都能查到该朝向使用的图块 console.log(binder.getFaceOf(1, FaceDirection.Right)); // { identifier: 2, face: Right } - console.log(binder.getFaceOf(2, FaceDirection.Right)); // { identifier: 2, face: Right } // 2 这张图自带的朝向 console.log(binder.getFaceDirection(2)); // Right // 2 所属组的主朝向;返回的 identifier 仍是传入的图块数字 console.log(binder.getMainFace(2)); // { identifier: 2, face: Down } - - // 没有登记过的图块数字 - console.log(binder.getFaceOf(999, FaceDirection.Right)); // null } ``` @@ -370,31 +154,152 @@ function bindFacingTiles(binder: IRoleFaceBinder): void { 示例里的 `1` 与 `2` 只是占位,换图前先确认这两个图块数字在当前地图里没有被别的朝向组占用。`malloc` 会覆盖同一个图块数字原有的组,`bind` 会覆盖该组同一朝向的旧映射,所以要从地图的图块数据里取真实数字,不要照抄示例。 -地图图层默认使用 `state.roleFace` 作为自己的朝向绑定器,所以图块转向时查的就是这份映射。`MapTileBase.setFaceDirection` 先按原方向查 `getFaceOf`,查到就换图并返回新数字;查不到时,按方法注释的约定,再用 `Dir4` 处理器的 `degrade` 折一次、重新查一次,查到就换成折后的图块并返回,仍查不到则保持原图块数字。这就是两层结构在实现里的交汇点:朝向映射决定换哪张图,处理器决定斜向该往哪折。 +## 两个内置处理器 -实现差异:当前 `setFaceDirection` 的降级分支只 `return next.identifier`,没有调用 `this.set(...)` 写回图块,所以折一次查到映射时,返回值变了但图块本身没换。本文按方法注释描述语义;这个不一致尚未修正,使用降级路径时不要假定图块已经换掉。 +内置实现都在 `@user/data-common` 的 `faceManager.ts` 里。它们都实现 `IFaceHandler`,区别只在认哪些方向、以及斜向怎么归并: -## 它们怎么串起来 +| 处理器 | 支持方向 | `degrade` 行为 | 典型用途 | +| ----------------- | ---------------- | -------------------------------------------------- | -------------------------------- | +| `Dir8FaceHandler` | 八个方向 | 原样返回,不做归并 | 八方向移动、勇士与动态图块的默认 | +| `Dir4FaceHandler` | 上下左右四个方向 | 四个正方向原样;四个斜向折到左右;其余为 `Unknown` | 只走四方向的地图与寻路 | -一个方向值从产生到落地,会依次经过三层: +`Dir4FaceHandler` 的归并需要记住一条:斜向折到左右,不是折到上下。 -- `FaceDirection` 是逻辑朝向。移动器、寻路步骤、图块对象都用它表示「朝哪」。 -- `IFaceHandler` 把逻辑朝向变成位移与转向。移动器构造时保存一个处理器,每一步用它把 `moveDirection` 换成落点偏移;寻路用它决定邻域分支。 -- `IRoleFaceBinder` 把逻辑朝向变成图块数字。动态图块取当前朝向时,走的是 `layer.faceBinder.getFaceDirection(tileNum)`;图块转向时反过来用朝向查数字。 +| 输入 | `degrade` 结果 | +| ------------ | -------------- | +| `LeftUp` | `Left` | +| `LeftDown` | `Left` | +| `RightUp` | `Right` | +| `RightDown` | `Right` | +| 其余非正方向 | `Unknown` | -具体到动态图块:`DynamicTile` 构造自己的 `DynamicTileMover` 时,从 `state.faceManager` 按 `DYNAMIC_MOVER_FACE`(即 `Dir8`)取处理器,并把图块当前朝向一起交给基类;单步结束时再用处理器把 `moveDirection` 换成坐标增量。处理器与初始朝向此后都保存在移动器实例上,换分组不会影响已经建好的实例。 +这一点决定了四方向体系里的实际手感:斜向输入不会让对象斜着走,也不会变成上下位移,而是变成横向一步。四方向地图上如果希望斜向折到上下,就需要自己写一个处理器(见下一节)。 -再往上就是渲染端:图块数字与坐标交给渲染端,由它决定画哪张图、播哪个朝向的动画(`渲染端 · 朝向图块绘制`)。数据端只保证「数字与朝向对得上」,不决定画面。 +两个处理器在 `Unknown` 上的行为一致:`movement` 与 `move` 返回零偏移,`opposite` 与 `next` 返回 `Unknown`,`mapDirection` 与 `mapMovement` 会把它列出来。查表失败时 `movement` 同样退回零偏移。 - +**实现差异**:按接口注释的约定,无法合理降级的方向(包括 `Unknown`)应返回 `Unknown`;但当前 `Dir8FaceHandler.degrade` 把入参原样返回,所以传入非法值会得到一个非法方向。以注释为准,不要把这种返回值当作合法方向使用。 -## 与寻路、范围对象的关系 +勇士与动态图块默认都走 `Dir8`:勇士的分组来自 `src/data/constant.ts` 的 `HERO_FACE`,由 `MainDataPlugin` 构造时传入;动态图块的分组来自 `data-base/src/shared.ts` 的 `DYNAMIC_MOVER_FACE`(同文件的 `HERO_MOVER_FACE` 也定义为 `Dir8`)。需要四方向时,改的是这些分组来源,而不是处理器本身的实现。 -寻路复用同一套方向规则。构建有向图时,它按 `mapMovement()` 的条目向相邻格扩展,所以处理器直接决定「能往哪几个方向走」:四方向处理器只扩展上下左右,八方向处理器会扩展到斜向。注入方式是 `finder.useFaceHandler(handler)`,需要在绑定图层时一并设置;不注入时构图会失败,状态为 `InvalidInput`。详见[自动寻路](./ii/pathfinding.md)。 +## 实现自己的朝向处理器 -范围对象也复用方向这一层。射线范围 `RayRange` 的参数里,`dir` 是 `IFaceDescriptor[]`,每一项形如 `{ x: 0, y: -1 }`;它要的是偏移量,不是 `FaceDirection`。需要从方向得到偏移时,用处理器的 `movement` 转换即可。详见[自定义范围对象](./ii/custom-range.md)。 +这一节的使用概率很低。需要自定义的场合有两类:仍然走四方向,但希望斜向换一种归并;或者方向数不是 4 与 8,需要一套自己的方向规则。两者都通过实现 `IFaceHandler` 完成。 -因为「方向即偏移」被多处共用,改处理器的行为会同时影响移动与寻路:把四方向处理器的斜向归并改成折到上下,移动器的斜向输入和寻路的斜向分支会一起改变。只希望其中一处变化时,不要直接换掉共享的处理器,而是给那处单独注入一个处理器实例。 +实现时把「降级表」「运动表」「旋转顺序」放在模块顶层做共享常量,类只负责按契约查表。处理器是共享对象,实例上不保存任何「当前对象」的状态。几条契约要逐条满足: + +- 所有接收方向的入参,都要在方法内部先经过 `degrade`;`dirOf`、`mapDirection`、`mapMovement` 不接收方向。 +- `Unknown` 不能被当成普通方向参与运算:`movement` 与 `move` 返回零偏移,`opposite` 与 `next` 返回 `Unknown`。 +- `mapMovement` 必须包含 `Unknown` 及其零偏移,调用方靠这张表把每个方向都映射到偏移。 +- `dirOf` 匹配不到时返回 `Unknown`,不要抛错;只遍历本组自己的偏移表。 + +下面这个模板仍是四方向,但把斜向折到上下,用来演示 `degrade` 的策略作用。`[需改]` 标出需要替换的部分: + +```ts +import { IFaceDescriptor, IFaceHandler } from '@motajs/common'; +import { FaceDirection } from '@user/data-common'; + +// [需改] 降级表:本组认哪些方向、其余方向怎么归并 +const DEGRADE: ReadonlyMap = new Map([ + [FaceDirection.Left, FaceDirection.Left], + [FaceDirection.Up, FaceDirection.Up], + [FaceDirection.Right, FaceDirection.Right], + [FaceDirection.Down, FaceDirection.Down], + // 斜向折到上下,这是与内置 Dir4FaceHandler 的唯一区别 + [FaceDirection.LeftUp, FaceDirection.Up], + [FaceDirection.RightUp, FaceDirection.Up], + [FaceDirection.LeftDown, FaceDirection.Down], + [FaceDirection.RightDown, FaceDirection.Down] +]); + +// [需改] 运动表:本组每个方向的单步偏移,必须包含 Unknown 的零偏移 +const MOVEMENTS: ReadonlyMap = new Map([ + [FaceDirection.Unknown, { x: 0, y: 0 }], + [FaceDirection.Left, { x: -1, y: 0 }], + [FaceDirection.Up, { x: 0, y: -1 }], + [FaceDirection.Right, { x: 1, y: 0 }], + [FaceDirection.Down, { x: 0, y: 1 }] +]); + +// 顺时针顺序,不含 Unknown;逆时针与反方向都在这张表上取 +const CW: readonly FaceDirection[] = [ + FaceDirection.Up, + FaceDirection.Right, + FaceDirection.Down, + FaceDirection.Left +]; + +/** 四方向处理器:斜向一律折到上下。 */ +export class VerticalDir4FaceHandler implements IFaceHandler { + degrade(dir: number): FaceDirection { + // 表中没有的方向(含 Unknown 与任意非法值)一律视为无法降级 + return DEGRADE.get(dir) ?? FaceDirection.Unknown; + } + + movement(dir: number): IFaceDescriptor { + // 先降级,再查表 + return MOVEMENTS.get(this.degrade(dir)) ?? { x: 0, y: 0 }; + } + + move(dir: number, count: number): IFaceDescriptor { + // 等价于 movement 乘以 count;count 为负表示反向位移 + const { x, y } = this.movement(dir); + return { x: x * count, y: y * count }; + } + + opposite(dir: number): FaceDirection { + const i = CW.indexOf(this.degrade(dir)); + return i < 0 ? FaceDirection.Unknown : CW[(i + 2) % CW.length]; + } + + next(dir: number, anticlockwise: boolean = false): FaceDirection { + const i = CW.indexOf(this.degrade(dir)); + if (i < 0) return FaceDirection.Unknown; + + // 顺时针 +1,逆时针 -1,转满一圈回到起点 + const step = anticlockwise ? -1 : 1; + return CW[(i + step + CW.length) % CW.length]; + } + + dirOf(dx: number, dy: number): FaceDirection { + // 只认本组的单步偏移;斜向偏移没有对应方向 + for (const [dir, { x, y }] of MOVEMENTS) { + if (x === dx && y === dy) return dir; + } + return FaceDirection.Unknown; + } + + mapDirection(): Iterable { + return MOVEMENTS.keys(); + } + + mapMovement(): Iterable<[FaceDirection, IFaceDescriptor]> { + return MOVEMENTS.entries(); + } +} +``` + +注册时选一个未被内置占用的分组键,数字与字符串 id 都可以。数字键没有命名空间保护,将来内置新增分组时可能撞车,后注册的会覆盖先注册的;自定义分组更推荐字符串 id: + +```ts +import { FaceDirection } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; +import { VerticalDir4FaceHandler } from './vertical-face-handler'; + +function registerVerticalFace(state: ICoreState): void { + state.faceManager.registerById( + 'vertical-dir4', + new VerticalDir4FaceHandler() + ); + + const handler = state.faceManager.getById('vertical-dir4'); + if (!handler) return; + + // 斜向现在折到上下:LeftUp 的降级结果是 Up + console.log(handler.movement(FaceDirection.LeftUp)); // { x: 0, y: -1 } +} +``` + +注册只往表里放处理器,不会替任何已有对象换掉手上的那一个。真正生效的地方是显式取用它的代码:构造移动器时把处理器传进去,寻路时调用 `finder.useFaceHandler(handler)`。这些地方之后按新策略计算,已经构造好的移动器仍持有构造时的处理器。 ## 边界与注意 @@ -404,10 +309,8 @@ function bindFacingTiles(binder: IRoleFaceBinder): void { - Unknown 的偏移是零:`movement` 与 `move` 对 `Unknown` 返回 `{ x: 0, y: 0 }`,`opposite` 与 `next` 返回 `Unknown`;把它当普通方向做位置运算,对象会原地不动。 - bind 前必须 malloc:主图块没有分配朝向时,`bind` 会记错误并放弃;绑定的朝向与主朝向相同也会被拒绝。 - 重复 malloc 会覆盖:同一个图块数字再次 `malloc` 会重建该组的映射,之前 `bind` 进去的其它朝向不会被保留;已经 `bind` 过的子图块仍持有旧映射表,`getFaceOf` 可能查到旧结果。重建整组后要重新 `bind` 所有子图块。 -- 处理器是共享对象:不要在实例上保存「当前处理的是哪个对象」之类的状态;多个对象共用同一个处理器时,状态会互相覆盖。 -- 处理器不随对象销毁:处理器是无状态的共享对象,注册一次即可,也没有卸载流程;真正持有它的是移动器、寻路 `finder` 与图块层,换处理器要改使用方的引用,而不是重新 `register`。 -- 不同分组互不影响:`Dir4`、`Dir8` 与自定义分组各有一份处理器,换掉其中一个不会改动其余分组。 -- 注册不替换已有实例:注册只影响之后取用它的代码;已经构造好的移动器仍持有构造时传入的处理器。 +- 处理器是共享对象:不要在实例上保存「当前处理的是哪个对象」之类的状态;多个对象共用同一个处理器时,状态会互相覆盖。注册一次即可,也没有卸载流程;换处理器要改使用方的引用,而不是重新 `register`。 +- 不同分组互不影响:`Dir4`、`Dir8` 与自定义分组各有一份处理器,换掉其中一个不会改动其余分组;注册也不替换已经构造好的对象手里的实例。 - getMainFace 返回的是朝向:返回的 `IFaceData` 里,`identifier` 仍是你传入的图块数字,`face` 是它所属组的主朝向,不是主图块的数字。 ## 怎么验证 diff --git a/docs/advanced/mover.md b/docs/advanced/mover.md index a45c029..ef4ffb6 100644 --- a/docs/advanced/mover.md +++ b/docs/advanced/mover.md @@ -1,16 +1,16 @@ # 移动 Mover -对象移动是数据端最常见的动作之一:勇士走一步、动态图块被推走、NPC 沿路径巡逻。移动器把「想怎么走」和「实际怎么走」拆开:先用链式调用排出一串移动步骤,再调用 `start()` 按顺序执行;每一步落在哪一格,由移动器的子类决定。 +对象移动是数据端最常见的动作之一:勇士走一步、动态图块被推走、NPC 沿路径巡逻。移动器把「想怎么走」和「实际怎么走」拆开:先用链式调用排出一串移动步骤,再调用 `start()` 按顺序执行。 -这样拆的好处是同一套执行流程可以服务不同对象。勇士的 `HeroMover` 自己不判定能不能走,它把边界、通行、踩踏与撞击转发给顶层实现对象 `IHeroMoveTopImpl`;动态图块的 `DynamicTileMover` 只把方向换成落点。读这篇不需要先读它们的源码:先会用现成移动器,再看怎么扩展。 - -数据端只维护坐标与状态,画面上的位移、动画与速度表现由渲染端读取这些状态后完成。渲染端内容用 `渲染端 · 移动表现` 占位。 +数据端只维护坐标与状态,画面上的位移、动画与速度表现由渲染端读取这些状态后完成。 -## 三个角色 +## 移动器能做什么 -移动相关的接口都在 `@user/data-common` 的 `mover.ts` 里。三个角色分工明确: +一次移动分两步:**排队**与**执行**。`step`、`face`、`tp` 这些方法只往队列里放步骤,不会立刻移动对象;`start()` 才真正开始执行,并返回本次执行的句柄。用句柄的 `onEnd` 等这一整次移动结束。 + +移动相关的接口都在 `@user/data-common` 的 `mover.ts` 里,三个角色分工明确: | 接口 | 角色 | 关键成员 | | ------------------ | -------------- | --------------------------------------------- | @@ -18,20 +18,175 @@ | `IObjectMover` | 移动器 | 链式规划、`start()`、状态、钩子 | | `IMoverController` | 一次执行的句柄 | `done`、`onEnd`、`push`、`insert`、`stop` | -关系是组合:对象实现 `IObjectMovable`,移动器持有对象(`tile`),并在每一步把新坐标写回对象。`IObjectMover` 本身是一个可挂钩子的对象,`IObjectMoverHooks` 是它对应的钩子族。 - -`IMoverController` 不是移动器本身,而是 `start()` 返回的一张「这次执行」的凭证。移动结束后仍然可以读它的 `done`,但不能再追加步骤。 +多数场景只需要碰后两者:拿现成的移动器排队、执行、等待。移动器只负责「这一步落到哪」,能不能走、撞到什么由调用方的规则决定;勇士的规则见后文「勇士的顶层实现」。 ## 拿到现成的移动器 -多数场景不需要自己 `new` 移动器。引擎已经为两类对象建好了实例: +不要自己 `new` 勇士或动态图块的移动器:它们由引擎随对象创建,与对象同生命周期。重复 `new` 会得到第二个互不相干的移动器,位置与朝向都会对不上。 -- `state.hero.location.mover`:勇士位置对象的移动器,类型是 `IHeroMover`。除了通用接口,它还提供 `config`、`getConfig`、`useTopImplementation`。 -- 动态图块的 `mover` 字段:类型是 `IObjectMover`,创建动态图块时一并创建;`dynamic.step(dir)` 是它的单步便捷入口。 +### 勇士 -这两个实例与对象同生命周期。重复 `new` 会得到第二个互不相干的移动器,位置与朝向都会对不上。 +`state.hero.location.mover` 是勇士位置对象的移动器,类型是 `IHeroMover`。除了通用的链式方法与控制器,它还提供 `config`、`getConfig` 与 `useTopImplementation`(分别见后文「勇士的移动配置」与「勇士的顶层实现」)。 -`IHeroMover` 的配置有三个字段: +```ts +import { FaceDirection } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; + +async function heroStepRight(state: ICoreState): Promise { + // [需改] 换成你要的方向与步数 + const controller = state.hero.location.mover + .step(FaceDirection.Right) + .start(); + await controller?.onEnd; +} +``` + +勇士位置对象本身的接口(坐标、楼层、朝向、存档)见[勇士位置](../intermediate/hero-location.md)。 + +### 动态图块 + +动态图块(`IDynamicTile`)自带 `mover`,类型是 `IObjectMover`,在创建动态图块时一并创建。拿到动态图块通常从图层对象入手: + +```ts +import { FaceDirection } from '@user/data-common'; +import { IMapLayer } from '@user/data-base'; + +async function pushTileRight(layer: IMapLayer): Promise { + // 把 (5, 5) 的静态图块转成动态图块;这一步会清掉原静态图块 + const tile = layer.transferToDynamic(5, 5); + if (!tile) return; + + // [需改] 换成你要的方向与步数 + const controller = tile.mover.step(FaceDirection.Right).start(); + await controller?.onEnd; +} +``` + +`layer.createDynamic(num, x, y)` 直接新建动态图块,`layer.getDynamicTilesAt(x, y)` 与 `layer.iterateDynamicTiles()` 用来找已存在的动态图块。只是推一格这类简单场景,用 `tile.step(dir, count)` 更省事,它内部就是 `mover.step(...)` 加 `mover.start()`。创建、转换与移动的完整说明见[静态图块与动态图块](./ii/static-vs-dynamic-tiles.md)。 + +### 其他可移动对象 + +只有自定义对象才需要自己建移动器:对象实现 `IObjectMovable`(`x`、`y`、`setPos`、`getCurrentFaceDirection`),再为它构造一个移动器实例,写法见后文「实现自己的移动器」。引擎内置对象没有「换一个移动器」的公开入口:勇士只能用 `useTopImplementation` 替换规则层,动态图块的 `mover` 字段是只读的。 + +## 排一个移动计划 + +规划方法是链式的:每次调用追加一个或几个步骤,返回移动器本身,所以可以连着写。规划阶段不会移动对象,对象要等 `start()` 才动。 + +| 方法 | 追加的步骤 | 作用 | +| ------------------------------ | ------------------- | -------------------------------------------------- | +| `step(dir, count?)` | `Dir` | 朝 `dir` 走 `count` 格,同时更新移动朝向与面朝朝向 | +| `stepFace(move, face, count?)` | `DirFace` | 朝 `move` 走,面朝朝向显式设为 `face` | +| `forward(count?)` | `Special(Forward)` | 沿当前面朝朝向前进 | +| `backward(count?)` | `Special(Backward)` | 沿当前面朝朝向的反方向后退,面朝朝向不变 | +| `face(dir)` | `Face` | 只转向,不移动 | +| `tp(x, y, rel?)` | `Teleport` | 瞬移到目标格 | +| `jump(x, y, rel?)` | `Jump` | 跳跃到目标格 | +| `speed(value)` | `Speed` | 设置后续移动的每格耗时,单位 ms | +| `animDir(dir)` | `AnimDir` | 设置后续步骤的动画播放方向 | +| `push(steps)` | 一组步骤 | 把预先拼好的 `ObjectMoveStep` 数组原样追加 | +| `clear()` | 无 | 清空尚未执行的队列 | + +`step` 的 `count` 默认是 1。传 2 会追加两个 `Dir` 步骤,逐步执行,不是把一步拆成两格。`stepFace` 把移动朝向与面朝朝向分开,适合「侧身走」这类表现。 + +`forward` 与 `backward` 是相对步,都以当前的面朝朝向(`faceDirection`)为基准:`forward` 直接朝面朝朝向走,`backward` 朝面朝朝向的反方向走并保持朝向不变,所以连续后退会一直沿同一条轴线,不会因为上一步写入的反方向而左右摆动。 + +`tp` 与 `jump` 的 `rel` 省略时为 `false`,`x`、`y` 是目标格坐标;`rel` 为 `true` 时,`x`、`y` 是相对当前位置的偏移。数据端上两者都是直接落到目标格,区别只在步骤类型,渲染端可以据此播放不同的表现。 + +`speed` 与 `animDir` 只是写移动器状态,本身不产生位移。`speed` 的单位是毫秒每格,初始 `currentSpeed` 为 `100`;`animDir` 设置 `ObjectAnimDirection.Forward` 或 `Backward`。 + +`mover.push(steps)` 接收步骤对象数组,适合把循环生成或从别处收集来的步骤一次加入;`controller.push(...steps)` 是可变参数,直接把步骤对象逐个传入。两者名字相同、形状不同,别把数组塞给控制器。`clear()` 只清空尚未执行的队列,不影响正在执行的那一次。 + +规划方法只是往队列里放对象,移动器真正面对的是 `ObjectMoveStep` 联合类型。执行中用 `controller.push` 追加时,也要按这个形状传: + +| `type` | 字段 | 效果 | +| ------------------------- | ----------------------------------------------------- | ------------------------------------- | +| `ObjectMoveType.Dir` | `move` | 走向 `move`,移动朝向与面朝朝向都更新 | +| `ObjectMoveType.DirFace` | `move`、`face` | 走向 `move`,面朝朝向设为 `face` | +| `ObjectMoveType.Speed` | `value` | 设置后续每格耗时 | +| `ObjectMoveType.Face` | `value` | 只改面朝朝向 | +| `ObjectMoveType.Special` | `direction`:`ObjectSpecialStep.Forward` / `Backward` | 前进或后退 | +| `ObjectMoveType.AnimDir` | `dir`:`ObjectAnimDirection.Forward` / `Backward` | 设置动画播放方向 | +| `ObjectMoveType.Teleport` | `x`、`y`、`rel` | 瞬移 | +| `ObjectMoveType.Jump` | `x`、`y`、`rel` | 跳跃 | + +`Speed` 与 `AnimDir` 不产生位移,`Face` 只改面朝朝向。`ObjectSpecialStep` 与 `ObjectAnimDirection` 都是 `const enum`,分别表示前进 / 后退与正向 / 反向播放。 + +## 开始与等待 + +`start()` 把当前待执行队列取出来执行,并返回 `IMoverController`;如果移动器已经在移动,返回 `null`,不会接管当前移动。 + +返回 `null` 时它连队列都没有动过:既不切片也不清空,刚排的步骤原样留在移动器里。等当前这次结束后再调用 `start()`,它们会照常执行;不想执行就用 `clear()` 丢掉。不要以为 `start()` 失败会把刚排的步骤一起清掉。 + +```ts +import { FaceDirection } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; + +async function walkRightThenDown(state: ICoreState): Promise { + const mover = state.hero.location.mover; + const before = state.hero.getLocation(); + + // [需改] 按需求换成你要的步数与方向 + mover + .step(FaceDirection.Right, 2) // 向右两格 + .face(FaceDirection.Down) // 只转向,不移动 + .step(FaceDirection.Down); // 向下一格 + + const controller = mover.start(); + if (!controller) { + // 上一次移动还没结束;这里直接放弃,避免和它抢位置 + return; + } + + // done 在整次执行结束时才变 true + console.log('开始', controller.done); // false + await controller.onEnd; + console.log('结束', controller.done); // true + + const after = state.hero.getLocation(); + console.log('前', before.x, before.y, '后', after.x, after.y); +} +``` + +`onEnd` 在整次执行结束时兑现,`done` 是同一时点的布尔查询。等待优先用 `await controller.onEnd`;`done` 适合在同步代码里快速判断。`onEnd` 只在整次执行正常跑完时才兑现,任何一步或任一钩子抛错都会让它永远悬着,见后文「边界与注意」。 + +`start()` 还会把移动朝向(`moveDirection`)重置为 `Unknown`,面朝朝向(`faceDirection`)不受影响。因此执行期间再用移动器规划,写的是「下一次」的队列,不是本次。 + +勇士还有一层额外的可能:计划排好了也不一定真走。边界、通行与撞击由顶层实现判定,走不了时 `HeroMover` 会调用 `controller.stop()` 结束本次,位置不变(见后文「勇士的顶层实现」)。判断「走没走成」要看坐标,不能只看 `onEnd` 是否兑现。 + +## 执行中追加、插入与停止 + +`controller` 才是当前这次执行的入口。`push` 与 `insert` 只在「仍在移动、没有调用过 `stop`、尚未 `done`」时生效,其余情况静默忽略;`stop()` 总是把停止标记置真并返回本次的 `onEnd`: + +- `push(...steps)`:追加到本次队列末尾。 +- `insert(...steps)`:插到当前步之后。当前步已经从队列取出,所以实际是插到剩余队列的最前面;多个步骤按参数顺序排列。 +- `stop()`:把内部停止标记置真,返回本次的 `onEnd`;当前步会走完,之后不再开始新步骤。 + +```ts +import { FaceDirection, ObjectMoveType } from '@user/data-common'; +import { ICoreState } from '@user/data-state'; + +async function extendWhileMoving(state: ICoreState): Promise { + const controller = state.hero.location.mover + .step(FaceDirection.Right, 2) + .start(); + if (!controller) return; + + // 执行已经开始,但还没结束;追加的步骤会进入本次队列 + controller.push({ type: ObjectMoveType.Dir, move: FaceDirection.Down }); + + await controller.onEnd; +} +``` + +执行期间在移动器上调用 `step`、`face`、`speed` 等方法,只会写回移动器内部的待执行队列,本次执行不会消费它们。执行中要追加,只能用 `controller.push` 或 `controller.insert`。 + +`stop()` 的停止检查发生在每轮循环开头,所以不会打断当前步:第一步还没开始就调用,通常一步都不走;在某一步执行中调用,当前步会走完再停。在步进方法或步进钩子里调用 `stop()` 时不要 `await` 它返回的 Promise——它要等当前步结束才兑现,`await` 会把整次移动卡死。内置的 `HeroMover` 在撞击、无法进入等分支里就是直接调用、不等待。 + +停止后 `done` 会变真,本次控制器不能再追加。想继续走,要重新排计划并再次 `start()`。 + +## 勇士的移动配置 + +`IHeroMover.config` 只对勇士有效,三个字段都是开关: | 字段 | 含义 | | --------------- | -------------------------- | @@ -41,49 +196,186 @@ `config` 只覆盖传入的字段,没传的保持原值;`getConfig` 返回当前配置的只读快照。 -### 勇士的顶层实现 - -`useTopImplementation` 替换的是勇士这一层的行为,不是移动器本身。`IHeroMover` 没有 `stop()`,停止只发生在 `start()` 返回的控制器上。 - -`HeroMover` 在每一步把信息打包成 `IHeroMoveTopHandler`,交给 `IHeroMoveTopImpl` 判断: - -| 方法 | 返回 | 作用 | -| ------------------------ | ---------------- | ------------------------------------------------------------------------------ | -| `predicate()` | `IPassPredicate` | 返回通行性谓词,`HeroMover` 用它判断能否进入、是否撞击(见下文「改通行规则」) | -| `inBound(x, y, floorId)` | `boolean` | 目标格是否在地图范围内 | -| `enter(handler)` | `Promise` | 正常进入目标格后触发 | -| `leave(handler)` | `Promise` | 离开当前格时触发 | -| `hit(handler)` | `Promise` | 撞到不可进入的目标格时触发 | -| `cannotEnter(handler)` | `Promise` | 被判定为无法进入时触发 | - -`handler` 里带着 `currLoc`、`nextLoc`、`direction`、`floorId`、`face` 与 `state`,够你在判定里读位置、朝向与状态。引擎内置的 `DefaultHeroMoveTopImpl`(`src/data/hero/moverImpl.ts`)就是默认实现:它用通行谓词与事件系统完成边界判断、进出与撞击触发器。`MainDataPlugin` 在 `awake` 里把它装上: - ```ts -// src/data/initialize.ts 的 awake 中 -const heroMoveTopImpl = new DefaultHeroMoveTopImpl(state); -state.hero.location.mover.useTopImplementation(heroMoveTopImpl); +// 忽略地形碰撞,并允许走出地图 +state.hero.location.mover.config({ ignoreTerrain: true, allowOutBound: true }); + +// 读回当前配置 +const { ignoreTerrain, autoSave } = state.hero.location.mover.getConfig(); ``` -### 改通行规则:predicate() 与 IPassPredicate +`ignoreTerrain` 会让 `HeroMover` 跳过通行谓词与进出 / 撞击事件,只剩边界检查;`allowOutBound` 为真时连边界也跳过。因此打开 `ignoreTerrain` 后改谓词看不到效果。 -`predicate()` 返回通行性谓词 `IPassPredicate`,真正「能不能走」的判定在它里面。`HeroMover` 先看边界;对方向步(`Dir`、`DirFace` 与 `Special`)再取一次谓词,用它决定本步的结果: +`autoSave` 只是保存在移动器上的开关,数据端移动时不会读取它;需要它的系统通过 `getConfig()` 读取后自行处理。 -| 方法 | 作用 | -| -------------------- | --------------------------------------------------------------------- | -| `canPass(handler)` | 能否从当前格进入目标格;返回 `false` 时本步不移动,走「无法进入」分支 | -| `shouldHit(handler)` | 能进入但会产生撞击时返回 `true`;本步不移动,走「撞击」分支 | +配置是立即写入的,`start()` 不会把它恢复成默认值,也不会写进存档:读档只恢复位置与朝向(`x`、`y`、`direction`、`floorId`),移动器上的配置与其它状态都不在存档里,重建数据端后回到默认值。想每次移动都从默认值开始,就在排计划前显式再 `config` 一次。 -两个方法的参数都是 `IPassCheckHandler`,字段包括 `currLoc`、`nextLoc`、`direction`(本步移动朝向)、`map`(勇士当前地图,可能为 `null`)与 `state`。判定顺序固定: +## 钩子 -- `canPass` 为假 → `CannotMove`,不触发 `OnTouch` 撞击事件; -- `canPass` 为真且 `shouldHit` 为真 → `Hit`,触发 `OnTouch`; -- `canPass` 为真且 `shouldHit` 为假 → 正常移动。 +`IObjectMoverHooks` 是移动器上的钩子族,泛型参数就是被移动的对象类型。用 `mover.addHook(...)` 注册,用返回的控制器 `unload()` 或 `removeHook(...)` 移除,规则与其它钩子一致,见[钩子深入](./hooks-in-depth.md)。 -两个容易踩空的地方:`config({ ignoreTerrain: true })` 会让 `HeroMover` 跳过整个谓词判断、只剩边界检查,此时改谓词看不到效果;`predicate()` 在每个方向步都会被调用,要返回构造时建好的稳定实例,不要在方法里新建对象(内置实现就是把 `DefaultPassPredicateImpl` 存在字段里)。 +| 钩子 | 触发时机 | 参数 | 异步 | +| -------------- | ------------------------------------- | ------------------------------- | ---- | +| `onMoveStart` | 整次移动开始,子类 `onMoveStart` 之后 | `tile`、`mover` | 是 | +| `onMoveEnd` | 整次移动结束,子类 `onMoveEnd` 之后 | `tile`、`mover` | 是 | +| `onStepStart` | 单步开始前,坐标仍是移动前 | `code`、`step`、`tile`、`mover` | 是 | +| `onStepEnd` | 单步结束,坐标已更新 | `code`、`step`、`tile`、`mover` | 是 | +| `onSetPos` | 调用移动器 `setPos` 时 | `x`、`y`、`tile`、`mover` | 否 | +| `onSetFaceDir` | 调用移动器 `setFaceDir` 时 | `dir`、`tile`、`mover` | 否 | +| `onSetMoveDir` | 调用移动器 `setMoveDir` 时 | `dir`、`tile`、`mover` | 否 | -寻路用的是同一形状的谓词,但由 `state.pathfinding.finder.usePassPredicate(...)` 独立注入,不会自动跟随勇士的顶层实现。改了勇士的通行规则、又希望寻路一致时,要把同一个谓词再注入给 `finder`,详见[自动寻路](./ii/pathfinding.md)。 +`onSetFaceDir` 的 `dir` 是面朝朝向(`faceDirection`),`onSetMoveDir` 的 `dir` 是移动朝向(`moveDirection`)。 -想保留默认行为、只改其中一环时,包一层并转发给默认实现,比从头写省事: +几条与使用直接相关的时序: + +- `onStepStart` 触发时坐标还是移动前,`onStepEnd` 触发时坐标已经写入对象。两者之间是子类 `onStepEnd` 计算落点、基类写坐标、`onStepSettled` 运行。 +- `code` 是子类 `onStepStart` 返回的数字,会被传给 `onStepStart` 钩子、子类 `onStepEnd` 与 `onStepEnd` 钩子;基类不解释它的含义,由子类自己约定。 +- 前四个钩子返回 `Promise`,引擎用 `Promise.all` 并发等待。完成顺序不等于注册顺序;需要顺序时,把有依赖的步骤写进同一个钩子按 `await` 串行。细节见[钩子深入](./hooks-in-depth.md#异步钩子)。 +- `onSetPos`、`onSetFaceDir`、`onSetMoveDir` 是同步的,返回 `void`,并且只由对应的 setter 触发。 + +## 读取与设置状态 + +移动器把一次执行需要的状态都放在自己身上: + +| 成员 | 含义 | 说明 | +| --------------- | ------------ | ------------------------------------------------------ | +| `moving` | 是否正在移动 | `start()` 后为真,整次结束才回到假 | +| `tile` | 绑定的对象 | 构造时传入 | +| `faceDirection` | 面朝朝向 | 由步骤或 `setFaceDir` 更新 | +| `moveDirection` | 移动朝向 | 由步骤或 `setMoveDir` 更新,`start()` 重置为 `Unknown` | +| `currAnimDir` | 当前动画方向 | 初始为 `ObjectAnimDirection.Forward` | +| `currentSpeed` | 当前每格耗时 | 单位毫秒,初始为 `100` | +| `faceHandler` | 朝向处理器 | 构造时传入,负责方向与偏移的换算 | + +面朝朝向(`faceDirection`)指对象「脸朝哪」,移动朝向(`moveDirection`)指「这一步往哪走」,两者是不同的概念,不要混用。`step` 会让两者一起更新;`stepFace` 与 `backward` 会让两者不同,例如向右走但脸朝上,或者朝下后退时移动朝向是上。`forward()` 沿的是面朝朝向,而不是移动朝向。 + +`IObjectMover.forward` 的源码注释写的是「沿当前移动方向」,这里指的是**面朝朝向**:`forward` 读的是 `faceDirection`,不是 `moveDirection`。同理,`onSetMoveDir` 注释里的「设置朝向」指设置**移动朝向**。源码实现与这个理解一致,不要把它当成 bug 去改。 + +`speed` 与 `animDir` 往队列里放的是步骤,真正写入 `currentSpeed`、`currAnimDir` 是在这一步执行时;执行后状态留在移动器上,`start()` 不会恢复默认值,所以会带到下一次移动。 + +`setPos`、`setFaceDir`、`setMoveDir` 与链式规划不同:它们立即生效、不排队。`setPos` 直接写对象坐标,`setFaceDir` 直接改面朝朝向,`setMoveDir` 直接改移动朝向。三者分别触发 `onSetPos`、`onSetFaceDir`、`onSetMoveDir` 钩子。 + +移动过程中的落点写入由基类直接调用 `tile.setPos`,不经过移动器的 `setPos`,所以不会触发 `onSetPos`。`onSetPos` 只对应你显式调用移动器 `setPos` 的那一次。 + +## 实现自己的移动器 + +这一节的使用概率很低:内置的 `HeroMover` 与 `DynamicTileMover` 已经覆盖勇士与动态图块。只有当你有一个新的可移动对象、并且它的落点规则与这两者都不同时,才需要自己写一个。 + +`ObjectMover` 是移动器基类,负责队列、控制器、状态更新、钩子分发与坐标回写。子类只需要实现五个 `protected` 方法: + +- `onMoveStart` 与 `onMoveEnd` 包住整次执行,分别在第一步之前与最后一步之后运行,返回的 Promise 会被等待。 +- `onStepStart` 在每步开始前运行。此时基类已经更新过移动朝向、面朝朝向、速度与动画方向,但坐标还没变。它返回本步的结果码,供后面的逻辑与钩子使用。 +- `onStepEnd` 计算这一步的落点,返回一个 `ITileLocator`。基类拿它和当前坐标比较,不同才调用 `tile.setPos`。不要在这里自己写坐标。 +- `onStepSettled` 在坐标写入之后运行,参数里带着移动前坐标 `before` 与落点 `curr`。这里适合触发进入 / 离开 / 踩踏一类的事件,或者把朝向同步回对象。 + +最小实现如下,`[需改]` 标出需要按需求替换的部分: + +```ts +import { + FaceDirection, + IObjectMovable, + ObjectMover, + ObjectMoveStep, + ObjectMoveType, + shouldReplay +} from '@user/data-common'; +import { IFaceHandler, ITileLocator } from '@motajs/common'; + +// 自定义结果码:onStepStart 返回,onStepEnd 与钩子都会收到 +const enum SimpleMoveCode { + /** 这一步可以执行 */ + Success, + /** 这一步不执行,保持原地 */ + Blocked +} + +// 只处理四方向与瞬移的最小移动器;T 换成你自己的可移动对象 +export class SimpleMover extends ObjectMover { + constructor( + readonly tile: T, + faceHandler: IFaceHandler + ) { + // 初始朝向取对象当前朝向;之后由每一步的 prepareStep 维护 + super(faceHandler, tile.getCurrentFaceDirection()); + } + + protected async onMoveStart(): Promise {} + + protected async onMoveEnd(): Promise {} + + protected async onStepStart( + step: Readonly + ): Promise { + // [需改] 在这里决定这一步能不能走 + return step.type === ObjectMoveType.Jump + ? SimpleMoveCode.Blocked + : SimpleMoveCode.Success; + } + + protected async onStepEnd( + code: number, + step: Readonly + ): Promise { + if (code !== SimpleMoveCode.Success) { + return { x: this.tile.x, y: this.tile.y }; + } + + switch (step.type) { + case ObjectMoveType.Dir: + case ObjectMoveType.DirFace: + case ObjectMoveType.Special: { + // 方向 → 偏移:moveDirection 已由基类按步骤内容算好 + const { x, y } = this.faceHandler.movement(this.moveDirection); + return { x: this.tile.x + x, y: this.tile.y + y }; + } + case ObjectMoveType.Teleport: { + // rel 为 true 时,x / y 表示相对当前位置的偏移 + return { + x: step.rel ? this.tile.x + step.x : step.x, + y: step.rel ? this.tile.y + step.y : step.y + }; + } + default: + // Face / Speed / AnimDir 不产生位移 + return { x: this.tile.x, y: this.tile.y }; + } + } + + // [需改] 把朝向同步给对象;方法体里有写操作,所以要标 @shouldReplay + @shouldReplay('Simple tile facing should be replayed.') + protected async onStepSettled( + before: ITileLocator, + curr: ITileLocator, + step: Readonly + ): Promise { + // 例如:this.tile.setFaceDirection(this.faceDirection); + } +} +``` + +两条容易忘的约定: + +- 通行判断不在移动器基类里。`SimpleMover` 只回答「这一步落到哪」,不检查目标格能不能进、有没有墙,也不触发踩踏事件;需要这些行为就照 `HeroMover` 的做法,把判定转发给一层独立的规则对象。 +- 方法体里只要有写操作(写朝向、写对象、派发事件),就给它标 `@shouldReplay`,标在真正写状态的那个方法上,不要标在只读查询上。原因与放置规则见[录像一致性](./replay-consistency.md)。 + +`faceHandler` 决定方向体系:四方向对象配 `Dir4FaceHandler`,八方向对象配 `Dir8FaceHandler`,混用会让偏移与朝向对不上。处理器本身的接口与自定义方式见[FaceHandler 系列](./face-handler.md#实现自己的朝向处理器)。 + +## 勇士的顶层实现 + +勇士的移动器自己不判定能不能走。`HeroMover` 在每一步把信息打包成 `IHeroMoveTopHandler`,交给 `IHeroMoveTopImpl` 判断。这是勇士唯一的公开扩展点:`state.hero.location.mover.useTopImplementation(impl)` 装上,传 `null` 会卸掉实现,之后每一步都会直接停下并告警。 + +| 方法 | 返回 | 作用 | +| ------------------------ | ---------------- | ------------------------------------------------------ | +| `predicate()` | `IPassPredicate` | 返回通行性谓词,`HeroMover` 用它判断能否进入、是否撞击 | +| `inBound(x, y, floorId)` | `boolean` | 目标格是否在地图范围内 | +| `enter(handler)` | `Promise` | 正常进入目标格后触发 | +| `leave(handler)` | `Promise` | 离开当前格时触发 | +| `hit(handler)` | `Promise` | 撞到不可进入的目标格时触发 | +| `cannotEnter(handler)` | `Promise` | 被判定为无法进入时触发 | + +`handler` 里带着 `currLoc`、`nextLoc`、`direction`、`floorId`、`face` 与 `state`,够你在判定里读位置、朝向与状态。`predicate()` 返回的谓词有两个方法:`canPass(handler)` 为假时本步不移动,走「无法进入」分支;`canPass` 为真且 `shouldHit(handler)` 为真时本步不移动,走「撞击」分支;两者都为真才正常移动。 + +内置的 `DefaultHeroMoveTopImpl`(`src/data/hero/moverImpl.ts`)是默认实现,它用通行谓词与事件系统完成边界判断、进出与撞击触发器。想保留默认行为、只改其中一环时,包一层并转发给默认实现最省事: ```ts // src/data/hero/moverTop.ts @@ -96,8 +388,8 @@ import { import { IStateSystem } from '@user/data-system'; import { DefaultHeroMoveTopImpl } from './moverImpl'; -/** 保留默认的边界、通行与事件,只禁止向左走,并给撞击加一条日志。 */ -export class MyHeroMoveTopImpl implements IHeroMoveTopImpl { +/** 保留默认的边界、通行与事件,只禁止向左走。 */ +export class NoLeftMoveTopImpl implements IHeroMoveTopImpl { // 默认实现负责边界、通行谓词与四类事件派发 private readonly base: DefaultHeroMoveTopImpl; // 每步都会调用 predicate(),构造时建好一次,避免每步新建 @@ -132,535 +424,37 @@ export class MyHeroMoveTopImpl implements IHeroMoveTopImpl { } hit(handler: IHeroMoveTopHandler): Promise { - // [需改] 撞击时先做自己的事,再交给默认实现触发原有触发器 - console.log('撞到', handler.nextLoc); return this.base.hit(handler); } cannotEnter(handler: IHeroMoveTopHandler): Promise { - // 接口要求带 handler;默认实现是空实现、用不到它,所以这里不转发 + // 接口要求带 handler;默认实现用不到它,所以这里不转发 return this.base.cannotEnter(); } } ``` -`cannotEnter` 在接口上要收 `IHeroMoveTopHandler`,而 `DefaultHeroMoveTopImpl` 的实现声明为无参空实现(它用不到 handler),所以包装类保留参数、调用基类时不传。要让「无法进入」也触发点什么,就在这个方法里读 `handler` 自己实现,不要指望默认实现。 - -装配时把它换进勇士移动器: +装配时把它换进勇士移动器(`src/data/initialize.ts` 的 `awake` 中,默认实现就是这么装的): ```ts -// src/data/initialize.ts 的 awake 中,替换掉默认实现 -state.hero.location.mover.useTopImplementation(new MyHeroMoveTopImpl(state)); +// 依赖加载后才知道配置时,把这段放进 afterLoad +state.hero.location.mover.useTopImplementation(new NoLeftMoveTopImpl(state)); ``` -这就是勇士的扩展点:`HeroMover` 负责录像登记、坐标写入与步进流程,`IHeroMoveTopImpl` 负责「这一步能不能走、撞到什么、进出触发什么」。给勇士扩展时,优先实现 `IHeroMoveTopImpl` 并通过 `useTopImplementation` 装上;不要为了改通行规则去重写整个移动器,更不要从 `ObjectMover` 起手——`ObjectMover` 只算落点,边界、通行、踩踏、撞击和 `HeroMover` 的配置(含 `autoSave` 开关)都不在里面,勇士会失去这些行为。 +几点注意: -勇士位置对象的接口见[勇士位置](../intermediate/hero-location.md);动态图块的创建、转换与移动见[静态图块与动态图块](./ii/static-vs-dynamic-tiles.md)。 - -## 排一个移动计划 - -规划方法是链式的:每次调用追加一个或几个步骤,返回移动器本身,所以可以连着写。规划阶段不会移动对象,对象要等 `start()` 才动。 - -| 方法 | 追加的步骤 | 作用 | -| ------------------------------ | ------------------- | -------------------------------------------------- | -| `step(dir, count?)` | `Dir` | 朝 `dir` 走 `count` 格,同时更新移动朝向与面朝朝向 | -| `stepFace(move, face, count?)` | `DirFace` | 朝 `move` 走,面朝朝向显式设为 `face` | -| `forward(count?)` | `Special(Forward)` | 沿当前面朝朝向前进 | -| `backward(count?)` | `Special(Backward)` | 沿当前面朝朝向的反方向后退,面朝朝向不变 | -| `face(dir)` | `Face` | 只转向,不移动 | -| `tp(x, y, rel?)` | `Teleport` | 瞬移到目标格 | -| `jump(x, y, rel?)` | `Jump` | 跳跃到目标格 | -| `speed(value)` | `Speed` | 设置后续移动的每格耗时,单位 ms | -| `animDir(dir)` | `AnimDir` | 设置后续步骤的动画播放方向 | -| `push(steps)` | 一组步骤 | 把预先拼好的 `ObjectMoveStep` 数组原样追加 | -| `clear()` | 无 | 清空尚未执行的队列 | - -`step` 的 `count` 默认是 1。传 2 会追加两个 `Dir` 步骤,逐步执行,不是把一步拆成两格。`stepFace` 把移动朝向与面朝朝向分开,适合「侧身走」这类表现。 - -`forward` 与 `backward` 是相对步,都以当前的面朝朝向(`faceDirection`)为基准:`forward` 直接朝面朝朝向走,`backward` 朝面朝朝向的反方向走并保持朝向不变,所以连续后退会一直沿同一条轴线,不会因为上一步写入的反方向而左右摆动。 - -`tp` 与 `jump` 的 `rel` 省略时为 `false`,`x`、`y` 是目标格坐标;`rel` 为 `true` 时,`x`、`y` 是相对当前位置的偏移。数据端上两者都是直接落到目标格,区别只在步骤类型,渲染端可以据此播放不同的表现。 - -`speed` 与 `animDir` 只是写移动器状态,本身不产生位移。`speed` 的单位是毫秒每格,初始 `currentSpeed` 为 `100`;`animDir` 设置 `ObjectAnimDirection.Forward` 或 `Backward`。真正让速度与动画方向「看得见」的是渲染端。 - -`mover.push(steps)` 接收的是步骤对象数组,适合把循环生成或从别处收集来的步骤一次加入;`controller.push(...steps)` 是可变参数,直接把步骤对象逐个传入。两者名字相同、形状不同,别把数组塞给控制器。`clear` 只清空尚未执行的队列,不影响正在执行的那一次。 - -## 执行与等待 - -`start()` 把当前待执行队列取出来执行,并返回 `IMoverController`;如果移动器已经在移动,返回 `null`,不会接管当前移动。 - -返回 `null` 时它连队列都没有动过:既不切片也不清空,你刚排的步骤原样留在移动器里。等当前这次结束后再调用 `start()`,它们会照常执行;不想执行就用 `clear()` 丢掉。不要以为 `start()` 失败会把刚排的步骤一起清掉。 - -```ts -import { FaceDirection } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; - -async function walkRightThenDown(state: ICoreState): Promise { - const mover = state.hero.location.mover; - const before = state.hero.getLocation(); - - // [需改] 按需求换成你要的步数与方向 - mover - .step(FaceDirection.Right, 2) // 向右两格 - .face(FaceDirection.Down) // 只转向,不移动 - .step(FaceDirection.Down); // 向下一格 - - const controller = mover.start(); - if (!controller) { - // 上一次移动还没结束;这里直接放弃,避免和它抢位置 - return; - } - - // done 在整次执行结束时才变 true - console.log('开始', controller.done); // false - await controller.onEnd; - console.log('结束', controller.done); // true - - const after = state.hero.getLocation(); - console.log('前', before.x, before.y, '后', after.x, after.y); -} -``` - -`onEnd` 是整次执行结束时兑现的 Promise,`done` 是同一时点的布尔查询。等待优先用 `await controller.onEnd`;`done` 适合在同步代码里快速判断。`onEnd` 只在整次执行正常跑完时才兑现,任何一步或任一钩子抛错都会让它永远悬着,见「边界与注意」。 - -`start()` 会做三件事:把当前队列切片成本次执行的局部队列、清空移动器内部的待执行队列、把 `moveDirection` 重置为 `Unknown`。因此执行期间再用移动器规划,写的是「下一次」的队列,不是本次。 - -`moveDirection` 被重置这一点与 `forward()` 无关:`forward()` 沿的是面朝朝向(`faceDirection`),而面朝朝向不随 `start()` 清空。被清空的只有移动朝向(`moveDirection`),所以只有需要读 `moveDirection` 的自定义逻辑,才要先在本计划里用 `step` 或 `stepFace` 把它确定下来。 - -## 执行中追加、插入与停止 - -`controller` 才是当前这次执行的入口。`push` 与 `insert` 只在「仍在移动、没有调用过 `stop`、尚未 `done`」时生效,其余情况静默忽略;`stop()` 则总是把停止标记置真并返回本次的 `onEnd`: - -- `push(...steps)`:追加到本次队列末尾。 -- `insert(...steps)`:插到当前步之后。当前步已经从队列取出,所以实际是插到剩余队列的最前面;多个步骤按参数顺序排列。 -- `stop()`:把内部停止标记置真,返回本次的 `onEnd`;当前步会走完,之后不再开始新步骤。 - -```ts -import { FaceDirection, ObjectMoveType } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; - -async function extendWhileMoving(state: ICoreState): Promise { - const controller = state.hero.location.mover - .step(FaceDirection.Right, 2) - .start(); - if (!controller) return; - - // 执行已经开始,但还没结束;追加的步骤会进入本次队列 - controller.push({ type: ObjectMoveType.Dir, move: FaceDirection.Down }); - - await controller.onEnd; -} -``` - -这里有一条容易踩的边界:执行期间在移动器上调用 `step`、`face`、`speed` 等方法,只会写回移动器内部的待执行队列,本次执行不会消费它们。执行中要追加,只能用 `controller.push` 或 `controller.insert`。 - -`stop()` 的停止检查发生在每轮循环开头,所以不会打断正在执行的这一步。如果第一步还没开始就调用 `stop()`,循环会在开始第一步之前退出,结果是一步都不走;如果在某一步执行中调用,当前步会走完再停。 - -```ts -import { FaceDirection } from '@user/data-common'; -import { ICoreState } from '@user/data-state'; - -async function stopBeforeFirstStep(state: ICoreState): Promise { - const controller = state.hero.location.mover - .step(FaceDirection.Right, 3) - .start(); - if (!controller) return; - - // 立即停止:当前还没有步骤真正开始,通常一步都不走 - await controller.stop(); -} -``` - -在步进方法或步进钩子里调用 `stop()` 时不要 `await` 它返回的 Promise。它要等当前步结束才兑现,`await` 会把整次移动卡死。内置的 `HeroMover` 在撞击、无法进入等分支里就是直接调用、不等待。 - -停止后 `done` 会变真,本次控制器不能再追加。想继续走,要重新排计划并再次 `start()`。 - -## 步骤类型 - -规划方法只是往队列里放对象;移动器真正面对的是 `ObjectMoveStep` 联合类型。八种步骤对应 `ObjectMoveType` 的八个成员: - -| `type` | 字段 | 效果 | -| ------------------------- | ----------------------------------------------------- | ------------------------------------- | -| `ObjectMoveType.Dir` | `move` | 走向 `move`,移动朝向与面朝朝向都更新 | -| `ObjectMoveType.DirFace` | `move`、`face` | 走向 `move`,面朝朝向设为 `face` | -| `ObjectMoveType.Speed` | `value` | 设置后续每格耗时 | -| `ObjectMoveType.Face` | `value` | 只改面朝朝向 | -| `ObjectMoveType.Special` | `direction`:`ObjectSpecialStep.Forward` / `Backward` | 前进或后退 | -| `ObjectMoveType.AnimDir` | `dir`:`ObjectAnimDirection.Forward` / `Backward` | 设置动画播放方向 | -| `ObjectMoveType.Teleport` | `x`、`y`、`rel` | 瞬移 | -| `ObjectMoveType.Jump` | `x`、`y`、`rel` | 跳跃 | - -`Speed` 与 `AnimDir` 不产生位移,`Face` 只改面朝朝向。它们的状态更新由基类在每步开始前完成,自定义移动器只需要在计算落点时知道「这几类不移动」。 - -自定义移动器必须自己处理这些 `type`:至少要知道每个类型该返回什么落点。没有识别的类型不要抛错,原样返回当前坐标即可,这样未知步骤表现为原地不动。 - -`ObjectSpecialStep` 与 `ObjectAnimDirection` 都是 `const enum`,分别表示前进 / 后退与正向 / 反向播放。 - -## 移动器状态 - -移动器把一次执行需要的状态都放在自己身上: - -| 成员 | 含义 | 说明 | -| --------------- | ------------ | ------------------------------------------------------ | -| `moving` | 是否正在移动 | `start()` 后为真,整次结束才回到假 | -| `tile` | 绑定的对象 | 构造时传入 | -| `faceDirection` | 面朝朝向 | 由步骤或 `setFaceDir` 更新 | -| `moveDirection` | 移动朝向 | 由步骤或 `setMoveDir` 更新,`start()` 重置为 `Unknown` | -| `currAnimDir` | 当前动画方向 | 初始为 `ObjectAnimDirection.Forward` | -| `currentSpeed` | 当前每格耗时 | 单位毫秒,初始为 `100` | -| `faceHandler` | 朝向处理器 | 构造时传入,负责方向与偏移的换算 | - -面朝朝向(`faceDirection`)指对象「脸朝哪」,移动朝向(`moveDirection`)指「这一步往哪走」,两者是不同的概念,不要混用。`step` 会让两者一起更新;`stepFace` 与 `backward` 会让两者不同,例如向右走但脸朝上,或者朝下后退时移动朝向是上。`forward()` 沿的是面朝朝向,而不是移动朝向。 - -`IObjectMover.forward` 的源码注释写的是「沿当前移动方向」,这里指的是**面朝朝向**:`forward` 读的是 `faceDirection`,不是 `moveDirection`。同理,`onSetMoveDir` 注释里的「设置朝向」指设置**移动朝向**。源码实现与这个理解一致,不要把它当成 bug 去改。 - -`speed` 与 `animDir` 往队列里放的是步骤,真正写入 `currentSpeed`、`currAnimDir` 是在这一步执行时;执行后状态留在移动器上,`start()` 不会恢复默认值,所以会带到下一次移动。勇士的 `config` 也是立即写入且不重置。想每次都从默认值开始,就在排计划时显式写 `speed(100)`、`animDir(ObjectAnimDirection.Forward)` 与对应的 `config`。 - -`setPos`、`setFaceDir`、`setMoveDir` 与链式规划不同:它们立即生效、不排队。`setPos` 直接写对象坐标,`setFaceDir` 直接改面朝朝向,`setMoveDir` 直接改移动朝向。三者分别触发 `onSetPos`、`onSetFaceDir`、`onSetMoveDir` 钩子。 - -移动过程中的落点写入由基类直接调用 `tile.setPos`,不经过移动器的 `setPos`,所以不会触发 `onSetPos`。`onSetPos` 只对应你显式调用移动器 `setPos` 的那一次。 - -## 钩子 - -`IObjectMoverHooks` 是移动器上的钩子族,泛型参数就是被移动的对象类型。用 `mover.addHook(...)` 注册,用返回的控制器 `unload()` 或 `removeHook(...)` 移除,规则与其它钩子一致,见[钩子深入](./hooks-in-depth.md)。 - -| 钩子 | 触发时机 | 参数 | 异步 | -| -------------- | ------------------------------------- | ------------------------------- | ---- | -| `onMoveStart` | 整次移动开始,子类 `onMoveStart` 之后 | `tile`、`mover` | 是 | -| `onMoveEnd` | 整次移动结束,子类 `onMoveEnd` 之后 | `tile`、`mover` | 是 | -| `onStepStart` | 单步开始前,坐标仍是移动前 | `code`、`step`、`tile`、`mover` | 是 | -| `onStepEnd` | 单步结束,坐标已更新 | `code`、`step`、`tile`、`mover` | 是 | -| `onSetPos` | 调用移动器 `setPos` 时 | `x`、`y`、`tile`、`mover` | 否 | -| `onSetFaceDir` | 调用移动器 `setFaceDir` 时 | `dir`、`tile`、`mover` | 否 | -| `onSetMoveDir` | 调用移动器 `setMoveDir` 时 | `dir`、`tile`、`mover` | 否 | - -`onSetFaceDir` 的 `dir` 是面朝朝向(`faceDirection`),`onSetMoveDir` 的 `dir` 是移动朝向(`moveDirection`)。 - -几条与使用直接相关的时序: - -- `onStepStart` 触发时坐标还是移动前,`onStepEnd` 触发时坐标已经写入对象。两者之间是子类 `onStepEnd` 计算落点、基类写坐标、`onStepSettled` 运行。 -- `code` 是子类 `onStepStart` 返回的数字。它会被传给 `onStepStart` 钩子、子类 `onStepEnd` 与 `onStepEnd` 钩子;基类不解释它的含义,由子类自己约定。 -- 前四个钩子返回 `Promise`,引擎用 `Promise.all` 并发等待。完成顺序不等于注册顺序;需要顺序时,把有依赖的步骤写进同一个钩子按 `await` 串行。细节见[钩子深入](./hooks-in-depth.md#异步钩子)。 -- `onSetPos`、`onSetFaceDir`、`onSetMoveDir` 是同步的,返回 `void`,并且只由对应的 setter 触发。 - -## 实现自己的移动器 - -只有现成的 `HeroMover` 与 `DynamicTileMover` 不满足需求时,才需要自己写。例如新对象有自己的落点规则、需要在每一步前后做额外处理,或者要换一套结果码。 - -`ObjectMover` 是移动器基类,负责队列、控制器、状态更新、钩子分发与坐标回写。子类只需要实现五个 `protected` 方法,决定每一步「能不能走、落到哪、落完之后做什么」。 - -如果想给自己的对象接上移动器,可以参考下面的模板。模板用 `SimpleMover` 作为类名,用 `SimpleMoveCode` 作为结果码;`[需改]` 标出需要按需求替换的部分。 - -```ts -import { - FaceDirection, - IMoverController, - IObjectMovable, - ObjectMover, - ObjectMoveStep -} from '@user/data-common'; -import { IFaceHandler, ITileLocator } from '@motajs/common'; - -// 自定义结果码:onStepStart 返回,onStepEnd 与钩子都会收到 -const enum SimpleMoveCode { - /** 这一步可以执行 */ - Success, - /** 这一步不执行,保持原地 */ - Blocked -} - -// 模板:自定义移动器。T 换成你自己的可移动对象类型 -export class SimpleMover extends ObjectMover { - constructor( - readonly tile: T, - faceHandler: IFaceHandler - ) { - // 初始朝向取对象当前朝向;之后由每一步的 prepareStep 维护 - super(faceHandler, tile.getCurrentFaceDirection()); - } - - // [需改] 整次移动开始时执行;没有额外逻辑可以留空 - protected async onMoveStart( - tile: T, - controller: Readonly - ): Promise {} - - // [需改] 整次移动结束时执行 - protected async onMoveEnd( - tile: T, - controller: Readonly - ): Promise {} - - // [需改] 单步开始:检查这一步能否执行,返回结果码 - protected async onStepStart( - step: Readonly, - tile: T, - controller: Readonly - ): Promise { - return SimpleMoveCode.Success; - } - - // [需改] 单步结束:返回这一步的落点;坐标由基类写入 - protected async onStepEnd( - code: number, - step: Readonly, - tile: T, - controller: Readonly - ): Promise { - if (code !== SimpleMoveCode.Success) { - return { x: tile.x, y: tile.y }; - } - - // 模板先原地不动;按 step.type 换成你的落点规则 - return { x: tile.x, y: tile.y }; - } - - // [需改] 单步已结束、坐标已写入之后执行;要在这里写状态就补 @shouldReplay(见示例) - protected async onStepSettled( - before: ITileLocator, - curr: ITileLocator, - tile: T, - step: Readonly, - controller: Readonly - ): Promise {} -} -``` - -五个方法的职责: - -- `onMoveStart` 与 `onMoveEnd` 包住整次执行,分别在第一步之前与最后一步之后运行,返回的 Promise 会被等待。 -- `onStepStart` 在每步开始前运行。此时基类已经更新过移动朝向、面朝朝向、速度与动画方向,但坐标还没变。它返回本步的结果码,供后面的逻辑与钩子使用。 -- `onStepEnd` 计算这一步的落点,返回一个 `ITileLocator`。基类拿它和当前坐标比较,不同才调用 `tile.setPos`。不要在这里自己写坐标。 -- `onStepSettled` 在坐标写入之后运行,参数里带着移动前坐标 `before` 与落点 `curr`。这里适合触发进入 / 离开 / 踩踏一类的事件,或者把朝向同步回对象。 -- 五个方法都是 `protected` 抽象方法,外部代码不能直接调用,子类必须实现。对外是链式方法、`start()`、三个 setter 与几个只读状态。 -- 基类里的 `prepareStep` 与 `moveProgress` 是 `private`,子类既不能覆写也不能调用。`prepareStep` 每步会把 `moveDirection`、`faceDirection`、`currAnimDir`、`currentSpeed` 按步骤更新好,子类读这些状态即可。 -- `faceHandler`、`moveDirection`、`faceDirection` 在 `IObjectMover` 接口上都是 `readonly`,按只读使用。手动改它们不会改变流程,还会被下一步的 `prepareStep` 覆盖。 - -模板本身不写状态,所以没有加 `@shouldReplay`。你的实现一旦在某个方法里改状态(写朝向、写对象、派发事件),就按示例把标记加在那个方法上;原因与放置规则见下文「与录像、存档的关系」。 - -## 示例:一个最小可移动对象 - -把模板的类型参数固定成 `SimpleTile`,只让四方向与瞬移产生位移,就得到一个能跑的最小移动器。下面两段可以放在同一个文件,也可以拆成两个文件;拆开时按第二段的 `import` 引入。 - -```ts -import { FaceDirection, IObjectMovable } from '@user/data-common'; - -// 最小可移动对象:只保存坐标与朝向 -export class SimpleTile implements IObjectMovable { - constructor( - public x: number, - public y: number, - private direction: FaceDirection = FaceDirection.Down - ) {} - - // IObjectMovable:移动器在落点确定后通过它写回坐标 - setPos(x: number, y: number): void { - this.x = x; - this.y = y; - } - - // IObjectMovable:移动器构造时读取初始朝向 - getCurrentFaceDirection(): FaceDirection { - return this.direction; - } - - // 不在 IObjectMovable 里:移动器在 onStepSettled 把朝向写回对象 - setFaceDirection(dir: FaceDirection): void { - this.direction = dir; - } -} -``` - -```ts -import { - FaceDirection, - IMoverController, - ObjectMover, - ObjectMoveStep, - ObjectMoveType, - shouldReplay -} from '@user/data-common'; -import { IFaceHandler, ITileLocator } from '@motajs/common'; -import { SimpleTile } from './simple-tile'; - -const enum SimpleMoveCode { - /** 这一步可以执行 */ - Success, - /** 这一步不执行,保持原地 */ - Blocked -} - -// 只处理四方向与瞬移的最小移动器 -export class SimpleMover extends ObjectMover { - constructor( - readonly tile: SimpleTile, - faceHandler: IFaceHandler - ) { - super(faceHandler, tile.getCurrentFaceDirection()); - } - - protected async onMoveStart( - tile: SimpleTile, - controller: Readonly - ): Promise {} - - protected async onMoveEnd( - tile: SimpleTile, - controller: Readonly - ): Promise {} - - protected async onStepStart( - step: Readonly, - tile: SimpleTile, - controller: Readonly - ): Promise { - // 跳跃不在最小实现里;其余步骤都放行 - if (step.type === ObjectMoveType.Jump) { - return SimpleMoveCode.Blocked; - } - return SimpleMoveCode.Success; - } - - protected async onStepEnd( - code: number, - step: Readonly, - tile: SimpleTile, - controller: Readonly - ): Promise { - if (code !== SimpleMoveCode.Success) { - return { x: tile.x, y: tile.y }; - } - - switch (step.type) { - case ObjectMoveType.Dir: - case ObjectMoveType.DirFace: - case ObjectMoveType.Special: { - // 方向 → 偏移:moveDirection 已由基类按步骤内容算好 - const { x, y } = this.faceHandler.movement(this.moveDirection); - return { x: tile.x + x, y: tile.y + y }; - } - case ObjectMoveType.Teleport: { - // rel 为 true 时,x / y 表示相对当前位置的偏移 - const nx = step.rel ? tile.x + step.x : step.x; - const ny = step.rel ? tile.y + step.y : step.y; - return { x: nx, y: ny }; - } - default: - // Face / Speed / AnimDir 不产生位移 - return { x: tile.x, y: tile.y }; - } - } - - // 这里写回朝向,属于状态变更,所以要标 @shouldReplay - @shouldReplay('Simple tile facing should be replayed.') - protected async onStepSettled( - before: ITileLocator, - curr: ITileLocator, - tile: SimpleTile, - step: Readonly, - controller: Readonly - ): Promise { - // 坐标已经写入 tile,这里把朝向同步给对象 - tile.setFaceDirection(this.faceDirection); - } -} -``` - -这段示例把「方向 → 偏移 → 落点」串了起来:`faceHandler.movement(this.moveDirection)` 给出单格的坐标偏移,加到 `tile.x`、`tile.y` 上就是落点;基类拿到落点后负责写入坐标。 - -通行判断不在移动器基类里。`SimpleMover` 不检查目标格能不能进、有没有墙,也不触发踩踏事件,它只回答「这一步落到哪」。`HeroMover` 也一样:它登记录像、写坐标、跑流程,但自己不判定通行,而是把判定与进出/撞击事件转发给顶层实现对象(见前文「勇士的顶层实现」)。 - -把对象与移动器接起来: - -```ts -import { Dir4FaceHandler, FaceDirection } from '@user/data-common'; -import { SimpleMover } from './simple-mover'; -import { SimpleTile } from './simple-tile'; - -async function runSimpleMover(): Promise { - const tile = new SimpleTile(3, 4, FaceDirection.Down); - // 四方向对象配四方向处理器;八方向换成 Dir8FaceHandler - const mover = new SimpleMover(tile, new Dir4FaceHandler()); - - const controller = mover.step(FaceDirection.Right, 2).start(); - if (!controller) return; - - await controller.onEnd; - console.log(tile.x, tile.y); // 5, 4 -} -``` - -`faceHandler` 决定方向体系。四方向对象要配四方向处理器,八方向对象配八方向处理器;混用会让偏移与朝向对不上。朝向处理器本身的接口与自定义方式见[FaceHandler 系列](./face-handler.md)。 - -## 接进插件与真实对象 - -自定义移动器与朝向处理器都要在数据端插件的 `awake` 里装配,依赖加载后配置或已创建对象的放到 `afterLoad`。 - -- 处理器:`state.faceManager.register(...)` 放进 `awake`,见[FaceHandler 系列](./face-handler.md#实现自己的朝向处理器)。 -- 勇士的规则:`state.hero.location.mover.useTopImplementation(...)` 是公开的替换点;移动器实例本身由 `createLocation` 创建,没有公开 setter。 -- 动态图块:`mover` 在 `DynamicTile` 构造时随对象创建,只读;需要整套换掉时,在自己的对象创建流程里构造替代实例,而不是事后改字段。 -- 生命周期:移动器与对象同生命周期,不需要卸载;钩子用 `unload()` 移除。移动器上的状态不进入存档,位置由 `IHeroLocationSave` 保存;自定义的额外状态要做成可存档内容,见[可存档内容](./saveable-content.md)。 - -```ts -// src/data/plugin/my-plugin/plugin.ts 的 awake 中 -state.hero.location.mover.useTopImplementation(new MyHeroMoveTopImpl(state)); -``` - -这段放 `awake` 还是 `afterLoad` 取决于依赖:`DefaultHeroMoveTopImpl` 只用到 `state`,`awake` 就够;如果装配需要加载后的配置或已创建的对象,放到 `afterLoad`。 - -## 与录像、存档的关系 - -`HeroMover` 在 `onStepStart`、`onStepEnd`、`onStepSettled` 上都标了 `@shouldReplay`,因为这三个方法体里都有写入或副作用:`onStepStart` 登记录像码,`onStepEnd` 与 `onStepSettled` 会调用顶层实现触发事件。`DynamicTileMover` 只标 `onStepEnd`,因为它在那里面写回朝向并算出落点。 - -自定义移动器同理:标在方法体里真正写状态的那一个,而不是「看起来最关键」的那一个。判断标准很直接:这个方法体里有没有写操作。 - -- `onStepEnd` 只返回落点,坐标由基类在它返回后写入;方法体里没有写入时,标它没有意义。 -- 在 `onStepSettled` 里调用 `tile.setFaceDirection(...)` 把朝向写回对象,写入就在这里,要标 `onStepSettled`。 -- 方法体里既有落点计算又有写入(内置 `DynamicTileMover.onStepEnd` 就是这种),标 `onStepEnd`。 - -不要标在只读查询上。标记的具体含义、检测能保证什么与不能保证什么,见[录像一致性](./replay-consistency.md)。 - -位置本身是存档内容。`IHeroLocationSave` 保存 `x`、`y`、`direction`、`floorId`;移动过程不会为每一步写存档,读档后恢复的是位置结果,不是「正在走第几步」。因此不要在移动器里另存一份位置状态,否则读档后容易与对象本身不一致。 - -## 内部执行流程 - -此章节讲解一下移动器的内部执行流程。下面按 `ObjectMover` 的源码顺序说明,只讲与使用相关的时序,不逐行翻译实现。 - -`start()` 的步骤: - -1. 如果 `moving` 为真,直接返回 `null`。 -2. 把 `moveQueue` 切片成本次的局部队列 `queue`,再清空内部队列。 -3. 把 `shouldStop` 置假,把 `moveDirection` 重置为 `Unknown`。 -4. 创建控制器:`done` 初始为假,`onEnd` 是待兑现的 Promise;`push` 与 `insert` 操作的是局部队列 `queue`,`stop` 把 `shouldStop` 置真并返回 `onEnd`。 -5. 把 `moving` 置真,启动 `moveProgress(queue, controller)`。 -6. `moveProgress` 结束时,把 `moving` 置假、`done` 置真,并兑现 `onEnd`。 - -`moveProgress` 的顺序: - -1. 子类 `onMoveStart`,然后钩子 `onMoveStart`,用 `Promise.all` 等待。 -2. 循环取步:从 `queue` 头部取一步;如果取不到、`moving` 已为假或 `shouldStop` 为真,退出循环。 -3. 调用 `prepareStep`,同步更新 `moveDirection`、`faceDirection`、`currAnimDir`、`currentSpeed`。 -4. 子类 `onStepStart` 返回结果码 `code`。 -5. 钩子 `onStepStart`,收到 `code`,用 `Promise.all` 等待。 -6. 子类 `onStepEnd` 返回落点 `ITileLocator`。 -7. 记录移动前坐标 `before` 与落点 `curr`;坐标不同时才调用 `tile.setPos`。这里调用的是对象的方法,不经过移动器的 `setPos`。 -8. 子类 `onStepSettled(before, curr, ...)`。 -9. 钩子 `onStepEnd`,收到 `code`,用 `Promise.all` 等待。 -10. 回到循环开头,直到队列空或被停止。 -11. 子类 `onMoveEnd`,然后钩子 `onMoveEnd`,用 `Promise.all` 等待。 - -因为 `controller.push` 与 `controller.insert` 改的就是局部队列 `queue`,它们能影响本次执行;而 `mover.step`、`mover.face` 等方法写的是内部待执行队列,局部队列不会读它,所以执行期间无效。 - -`shouldStop` 的检查点在每轮循环开头。当前步的 `onStepStart`、`onStepEnd`、`onStepSettled` 与两组钩子都会走完,才在下一轮退出。这就是 `stop()` 不打断当前步的原因。 +- `predicate()` 在每个方向步都会被调用,要返回构造时建好的稳定实例,不要在方法里新建对象(内置实现就是把 `DefaultPassPredicateImpl` 存在字段里)。 +- `config({ ignoreTerrain: true })` 会让 `HeroMover` 跳过整个谓词判断、只剩边界检查,此时改谓词看不到效果。 +- 寻路用的是同一形状的谓词,但由 `state.pathfinding.finder.usePassPredicate(...)` 独立注入,不会自动跟随勇士的顶层实现。改了勇士的通行规则、又希望寻路一致时,要把同一个谓词再注入给 `finder`,详见[自动寻路](./ii/pathfinding.md)。 ## 边界与注意 -- start 返回 null:移动器已经在移动时不会接管,此时也没有新的控制器可操作。要等或要停,只能用上一次 `start()` 保存的 `controller`:`await controller.onEnd`,或调用 `controller.stop()`。`IHeroMover` 没有 `stop()`,不要在移动器上找。返回 `null` 时刚排的步骤没有被消费,仍留在移动器里,下一次 `start()` 会执行;不要就用 `clear()`。 +- start 返回 null:移动器已经在移动时不会接管,此时也没有新的控制器可操作。要等或要停,只能用上一次 `start()` 保存的 `controller`:`await controller.onEnd`,或调用 `controller.stop()`。`IHeroMover` 没有 `stop()`,不要在移动器上找。返回 `null` 时刚排的步骤没有被消费,仍留在移动器里,下一次 `start()` 会执行;不想要就用 `clear()`。 - moveDirection 被重置:`start()` 把移动朝向(`moveDirection`)设为 `Unknown`,面朝朝向(`faceDirection`)不受影响;`forward()` 用的是面朝朝向,不依赖这次重置。 - 执行期间链式方法无效:`mover.step`、`mover.face`、`mover.speed` 等写的是下一次的队列;执行中追加只能用 `controller.push` 或 `controller.insert`。 - stop 不立刻中断:当前步会走完。第一步还没开始时调用,可能一步都不走。 - 不要在步进方法里 await stop:`stop()` 返回的 Promise 要等当前步结束才兑现,在步进方法或步进钩子里 `await` 会卡死整次移动。 -- 步进方法或钩子抛错会卡住移动器,而且没有恢复入口:`ObjectMover` 没有为执行 Promise 兜底;任何一步或任一钩子抛错,`onEnd` 都不再兑现、`done` 保持假、`moving` 保持真,之后的 `start()` 会一直返回 `null`,这次拒绝也没有被捕获,会以未处理的 Promise 拒绝暴露出来。`moving` 对外是只读状态,`IHeroMover` / `IObjectMover` 没有重置或强制结束的方法;`controller.stop()` 只能置停止标记,无法兑现那个已经被拒绝的 Promise。结论是运行期没有恢复路径,只能重建数据端(重新创建对象与移动器)。要避免它:自定义步进方法与钩子把预期内的失败转成结果码或内部标记,用 `try` / `catch` 包住可能抛错的外部调用,不要让它冒泡。 +- 步进方法或钩子抛错会卡住移动器,而且没有恢复入口:`ObjectMover` 没有为执行 Promise 兜底;任何一步或任一钩子抛错,`onEnd` 都不再兑现、`done` 保持假、`moving` 保持真,之后的 `start()` 会一直返回 `null`,这次拒绝也没有被捕获,会以未处理的 Promise 拒绝暴露出来。`moving` 对外是只读状态,`IHeroMover` / `IObjectMover` 没有重置或强制结束的方法;`controller.stop()` 只能置停止标记,无法兑现那个已经被拒绝的 Promise。结论是运行期没有恢复路径,只能重建数据端。要避免它:自定义步进方法与钩子把预期内的失败转成结果码或内部标记,用 `try` / `catch` 包住可能抛错的外部调用,不要让它冒泡。 - setPos 不触发步进钩子:移动器的 `setPos` 只触发 `onSetPos`,不触发 `onStepStart` / `onStepEnd`;移动过程中的落点写入也不会触发 `onSetPos`。 - speed 与 animDir 只是状态:它们不产生位移。没有消费方读取时,设置速度与动画方向不会产生可见效果。 - faceHandler 必须与对象方向体系一致:四方向对象配八方向处理器,或反过来,都会让偏移与朝向对不上。 @@ -726,50 +520,6 @@ async function verifyPlan(state: ICoreState): Promise { 预期:`done` 从假变真;整次共四个步骤(`Dir`、`Dir`、`Face`、`Dir`),坐标先水平移动两格,再向下移动一格;钩子顺序与表中一致,且每一步的 `onStepStart` 都在对应的 `onStepEnd` 之前。 -再验证执行中追加:调用 `start()` 后立刻 `controller.push` 一个 `Dir` 步骤,观察最终坐标是否多走了这一步。如果改用 `mover.step` 追加,最终坐标不会包含它,因为那是下一次执行的队列。 - -最后验证停止:在 `onStepEnd` 钩子里调用 `controller.stop()`,观察移动是否在当前步结束时停下。不要把 `stop()` 的 Promise 在钩子里 `await`,否则会卡住。 - -```ts -import { - FaceDirection, - IMoverController, - IObjectMoverHooks -} from '@user/data-common'; -import { IHeroLocation } from '@user/data-base'; -import { ICoreState } from '@user/data-state'; - -// 在第一步结束时叫停;不要 await stop() 返回的 Promise -class StopAfterOneStep implements IObjectMoverHooks { - controller: Readonly | null = null; - - async onStepEnd(): Promise { - this.controller?.stop(); - } -} - -async function verifyStop(state: ICoreState): Promise { - const mover = state.hero.location.mover; - const hook = new StopAfterOneStep(); - const hookController = mover.addHook(hook); - - const before = state.hero.getLocation(); - const controller = mover.step(FaceDirection.Right, 3).start(); - if (!controller) { - hookController.unload(); - return; - } - - // start() 返回时第一步还没结束,这里补上控制器引用 - hook.controller = controller; - await controller.onEnd; - - const after = state.hero.getLocation(); - console.log('只应移动一格', after.x - before.x); - hookController.unload(); -} -``` - -预期:坐标只变化一格,`done` 为真,控制器上的后续追加不再生效。 +再验证执行中追加:调用 `start()` 后立刻 `controller.push` 一个 `Dir` 步骤,最终坐标会多走这一步;改用 `mover.step` 追加则不会,因为那是下一次执行的队列。最后验证停止:在 `onStepEnd` 钩子里调用 `controller.stop()`(不要 `await`),移动应在当前步结束时停下,坐标只变化一格。 返回[高级 I 导览](./index.md)。