template/docs/advanced/face-handler.md
unanmed c0b3e58f17 docs(10-32): 重构 Mover/FaceHandler 为用法优先(D-77)
- mover.md:改为用法优先,拿移动器/排计划/开始等待/执行中控制前置;删除内部执行流程整节,自定义压缩为末尾两节
- face-handler.md:改为用法优先,取处理器/常用方法/使用场景前置;自定义处理器压缩为末尾一节
- 10-32-USER-REVIEW.md:记录第 1 轮执行结果与自评
2026-10-24 19:39:35 +08:00

351 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# FaceHandler 系列
方向在数据端有两个落点。一个是逻辑朝向 `FaceDirection`:勇士朝哪、动态图块这一步朝哪,都用它表示。另一个是图块数字:同一个角色可能有多张不同朝向的图,换方向时要换成对应的数字。
两个落点之间夹着一套换算。`IFaceHandler` 把任意朝向变成本组支持的方向、位移、反方向与旋转;`IRoleFaceBinder` 把朝向映射成图块数字。
本篇只讲数据端。这些值最后怎么画、动画怎么播,由渲染端读取后完成。
<!-- TODO(renderer): 朝向图块绘制 -->
## 三个概念
| 概念 | 是什么 | 用在哪 |
| --------------- | -------------------------------------------------------------- | ----------------------------------------- |
| `FaceDirection` | 方向值,`const enum`,含 `Unknown` 在内共九个成员 | 移动方向、朝向、寻路步骤里的 `dir` |
| `FaceGroup` | 方向组的编号,内置 `Dir4` 与 `Dir8`,表示「这一组认哪些方向」 | 从 `state.faceManager` 取处理器时作为 key |
| `IFaceHandler` | 朝向处理器,把任意方向换算成本组支持的方向、偏移、反方向与旋转 | 移动器构造、寻路邻域、图块转向 |
`FaceDirection` 的成员依次是 `Unknown`、`Left`、`Up`、`Right`、`Down`,再加上四个斜向 `LeftUp`、`RightUp`、`LeftDown`、`RightDown`。它是 `const enum`,数值由声明顺序决定;不要按数值硬编码,一律写成员名。`Unknown` 表示没有方向,它也会出现在处理器的输入与输出里。
`FaceGroup` 只有 `Dir4` 与 `Dir8` 两个成员,分别表示四方向(上下左右)与八方向(上下左右加斜向)。它本身不携带行为,只是一个编号;真正的换算逻辑在对应的 `IFaceHandler` 上。取处理器时写 `FaceGroup.Dir4` / `FaceGroup.Dir8`,不要自己写 `0`、`1`。
还有一个配套类型 `IFaceDescriptor`,它是只读的 `{ x, y }` 增量,处理器用它表示「朝这个方向走一步,坐标加多少」。它不是坐标,而是位移量。
## 拿到朝向处理器
`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<FaceDirection>(FaceGroup.Dir8);
if (!dir8) {
// 启动期没有注册时才会走到这里
console.log('八方向处理器未注册');
return;
}
// 按字符串 id 取到的是同一个实例
const byId = state.faceManager.getById('dir8');
console.log(dir8 === byId); // true
const offset = dir8.movement(FaceDirection.Up);
console.log(offset); // { x: 0, y: -1 }
}
```
`get` 的泛型决定返回的 `IFaceHandler<T>` 里 `T` 是什么:不写泛型时 `T` 退化为 `number`,`degrade`、`opposite`、`next` 的静态类型都会是 `number`;需要 `FaceDirection` 的强类型时写 `get<FaceDirection>(...)`。
内置处理器在启动期注册(`src/data/initialize.ts` 的 `awake` 中),四方向与八方向各注册了数字 key 与字符串 id。启动之后按约定只读使用:运行期再注册只会影响之后取用它的地方,不会替换已经构造好的对象里保存的处理器。
同一个角色的图块数字映射由 `state.roleFace` 负责,见下文「朝向图块映射」。
## 常用方法
八个方法可以按「输入方向、输出什么」分成三组:降级与换算、遍历、由偏移反推。`degrade` 是核心,因为调用方可以传进任意方向值,包括本组不认的方向。
| 方法 | 作用 | 输入先 `degrade` | `Unknown` 的结果 |
| --------------------------- | -------------------------------------- | ------------------ | --------------------------- |
| `degrade(dir)` | 把任意方向降级为本组支持的方向 | 否,它本身就是降级 | `Unknown` |
| `movement(dir)` | 单步坐标偏移 | 是 | `{ x: 0, y: 0 }` |
| `move(dir, count)` | 走 `count` 步的偏移,`count` 可为负 | 是 | `{ x: 0, y: 0 }` |
| `opposite(dir)` | 本组内的反方向 | 是 | `Unknown` |
| `next(dir, anticlockwise?)` | 顺时针(默认)或逆时针旋转一步 | 是 | `Unknown` |
| `dirOf(dx, dy)` | 由偏移反推方向,多个符合时返回其中一个 | 不接收方向 | 匹配不到时 `Unknown` |
| `mapDirection()` | 迭代本组支持的所有方向 | 不接收方向 | 包含 `Unknown` |
| `mapMovement()` | 迭代本组所有方向及其偏移 | 不接收方向 | 包含 `Unknown` 对应的零偏移 |
最常见的是「把方向换成坐标」:
```ts
import { FaceDirection, FaceGroup } from '@user/data-common';
import { ICoreState } from '@user/data-state';
/** 从勇士当前位置朝 direction 走一步后的坐标。 */
function targetOf(
state: ICoreState,
direction: FaceDirection
): { x: number; y: number } | null {
const handler = state.faceManager.get<FaceDirection>(FaceGroup.Dir8);
if (!handler) return null;
// [需改] 把 direction 换成你要算的方向
const { x, y } = handler.movement(direction);
const loc = state.hero.getLocation();
return { x: loc.x + x, y: loc.y + y };
}
```
`move` 适合一次算多格,例如 `handler.move(FaceDirection.Left, 3)` 得到 `{ x: -3, y: 0 }`;传负数就是反方向。`opposite` 与 `next` 都在本组内求解:先 `degrade`,再查本组的映射表,所以传入本组不认的方向时,结果取决于 `degrade` 的归并。`next` 的第二个参数是 `anticlockwise`,省略时为 `false`,即顺时针。
`dirOf` 走的是相反方向:给你一个偏移量,问它是哪个方向。它不接收方向,也不会先 `degrade`;偏移量与表中某一项完全相等时才命中,多个方向共用同一偏移时只返回其中一个,命中顺序就是表的条目顺序。内置 `Dir4FaceHandler.dirOf` 实际遍历的是八方向偏移表,所以 `dir4.dirOf(1, 1)` 会返回 `RightDown`,而不是 `Unknown`。这是内置实现的特例,不要照抄到自定义处理器里,也不要拿它做四方向判定;需要严格四方向结果时,把返回值再用 `degrade` 折一次。
`mapDirection` 与 `mapMovement` 用于遍历,两者的结果都包含 `Unknown`,`mapMovement` 里 `Unknown` 对应的偏移是 `{ x: 0, y: 0 }`。自己按 `mapMovement` 扩展邻域时,要跳过零偏移条目,否则对象会原地走一步;寻路的构图循环就是这样处理的。
## 用在哪里
### 移动器与寻路
移动器构造时保存一个处理器,每一步用它把 `moveDirection` 换成落点偏移。内置的 `DynamicTile` 构造自己的 `DynamicTileMover` 时,从 `state.faceManager` 按 `DYNAMIC_MOVER_FACE`(即 `Dir8`)取处理器;自己写移动器时,处理器要作为构造参数传进去(见[移动 Mover](./mover.md#实现自己的移动器))。处理器必须与对象的方向体系一致:四方向对象配八方向处理器,或反过来,偏移与朝向都会对不上。
寻路复用同一套方向规则。构建有向图时,它按 `mapMovement()` 的条目向相邻格扩展,所以处理器直接决定「能往哪几个方向走」:四方向处理器只扩展上下左右,八方向处理器会扩展到斜向。注入方式是 `finder.useFaceHandler(handler)`;没有注入时构图会失败(找不到图层、谓词或处理器都会失败),详见[自动寻路](./ii/pathfinding.md)。
因为「方向即偏移」被多处共用,改处理器的行为会同时影响移动与寻路:把四方向处理器的斜向归并改成折到上下,移动器的斜向输入和寻路的斜向分支会一起改变。只希望其中一处变化时,不要直接换掉共享的处理器,而是给那处单独注入一个处理器实例。
### 图块朝向
地图图层默认使用 `state.roleFace` 作为自己的朝向绑定器,图块转向时查的就是这份映射。`MapTileBase.setFaceDirection(direction)` 的处理顺序是:
1. 用当前图块数字与目标方向查 `getFaceOf`,查到就换图并返回新数字;
2. 查不到时用 `Dir4` 处理器的 `degrade` 折一次,再查一次;
3. 仍查不到则保持原图块数字。
第 2 步在数据端只返回折后查到的图块数字,没有把图块本身换掉。按方法注释的语义,降级路径用于「斜向折成四方向再取图」,但当前实现不会写回图块,所以使用降级路径时不要假定图块已经换掉。
### 朝向图块映射
同一个角色可能有多张图,分别对应不同朝向。真正把「朝右」换成「朝右那张图的图块数字」的是 `IRoleFaceBinder`,地图图层通过 `state.roleFace` 使用它。
| 方法 | 作用 | 返回 |
| ------------------------------ | ------------------------------------ | ------------------- |
| `malloc(identifier, main)` | 给一个图块分配主朝向,登记为一个新组 | `void` |
| `bind(identifier, main, face)` | 把一个图块绑定到某个主图块的某个朝向 | `void` |
| `getFaceOf(identifier, face)` | 取该组在指定朝向下使用的图块数字 | `IFaceData \| null` |
| `getFaceDirection(identifier)` | 取该图块数字自身的朝向 | `FaceDirection` |
| `getMainFace(identifier)` | 取该图块数字所属组的主朝向 | `IFaceData \| null` |
`malloc` 是建组:`identifier` 作为主图块,`main` 是它的朝向(主朝向一般是朝下)。`bind` 把同组的另一张图块挂进来,`main` 必须是已经 `malloc` 过的图块数字。两者都只写映射,不发事件:
```ts
import { FaceDirection } from '@user/data-common';
import { ICoreState } from '@user/data-state';
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 }
// 2 这张图自带的朝向
console.log(binder.getFaceDirection(2)); // Right
// 2 所属组的主朝向;返回的 identifier 仍是传入的图块数字
console.log(binder.getMainFace(2)); // { identifier: 2, face: Down }
}
```
`bind` 有两个前置条件,任一不满足都会记一条错误日志并放弃绑定:`main` 没有 `malloc` 过,或绑定的朝向与主朝向相同。因此建组的顺序必须是先 `malloc` 主图块,再 `bind` 其余朝向。`getFaceOf` 与 `getMainFace` 找不到时返回 `null`;`getFaceDirection` 找不到时返回 `Unknown`,而不是 `null`。
示例里的 `1` 与 `2` 只是占位,换图前先确认这两个图块数字在当前地图里没有被别的朝向组占用。`malloc` 会覆盖同一个图块数字原有的组,`bind` 会覆盖该组同一朝向的旧映射,所以要从地图的图块数据里取真实数字,不要照抄示例。
## 两个内置处理器
内置实现都在 `@user/data-common` 的 `faceManager.ts` 里。它们都实现 `IFaceHandler<FaceDirection>`,区别只在认哪些方向、以及斜向怎么归并:
| 处理器 | 支持方向 | `degrade` 行为 | 典型用途 |
| ----------------- | ---------------- | -------------------------------------------------- | -------------------------------- |
| `Dir8FaceHandler` | 八个方向 | 原样返回,不做归并 | 八方向移动、勇士与动态图块的默认 |
| `Dir4FaceHandler` | 上下左右四个方向 | 四个正方向原样;四个斜向折到左右;其余为 `Unknown` | 只走四方向的地图与寻路 |
`Dir4FaceHandler` 的归并需要记住一条:斜向折到左右,不是折到上下。
| 输入 | `degrade` 结果 |
| ------------ | -------------- |
| `LeftUp` | `Left` |
| `LeftDown` | `Left` |
| `RightUp` | `Right` |
| `RightDown` | `Right` |
| 其余非正方向 | `Unknown` |
这一点决定了四方向体系里的实际手感:斜向输入不会让对象斜着走,也不会变成上下位移,而是变成横向一步。四方向地图上如果希望斜向折到上下,就需要自己写一个处理器(见下一节)。
两个处理器在 `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`)。需要四方向时,改的是这些分组来源,而不是处理器本身的实现。
## 实现自己的朝向处理器
这一节的使用概率很低。需要自定义的场合有两类:仍然走四方向,但希望斜向换一种归并;或者方向数不是 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<number, FaceDirection> = 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<FaceDirection, IFaceDescriptor> = 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<FaceDirection> {
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<FaceDirection> {
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<FaceDirection>('vertical-dir4');
if (!handler) return;
// 斜向现在折到上下:LeftUp 的降级结果是 Up
console.log(handler.movement(FaceDirection.LeftUp)); // { x: 0, y: -1 }
}
```
注册只往表里放处理器,不会替任何已有对象换掉手上的那一个。真正生效的地方是显式取用它的代码:构造移动器时把处理器传进去,寻路时调用 `finder.useFaceHandler(handler)`。这些地方之后按新策略计算,已经构造好的移动器仍持有构造时的处理器。
## 边界与注意
- <strong>四方向的斜向折左右</strong>:`Dir4FaceHandler.degrade` 把 `LeftUp` 与 `LeftDown` 折到 `Left`,`RightUp` 与 `RightDown` 折到 `Right`,不是折到上下。需要折上下时自建处理器。
- <strong>get 与 getById 可能返回 null</strong>:自建分组、或在启动期之外取用处理器时,先判空再调用;直接对 `null` 调方法会在运行期报错。
- <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` 所有子图块。
- <strong>处理器是共享对象</strong>:不要在实例上保存「当前处理的是哪个对象」之类的状态;多个对象共用同一个处理器时,状态会互相覆盖。注册一次即可,也没有卸载流程;换处理器要改使用方的引用,而不是重新 `register`。
- <strong>不同分组互不影响</strong>:`Dir4`、`Dir8` 与自定义分组各有一份处理器,换掉其中一个不会改动其余分组;注册也不替换已经构造好的对象手里的实例。
- <strong>getMainFace 返回的是朝向</strong>:返回的 `IFaceData` 里,`identifier` 仍是你传入的图块数字,`face` 是它所属组的主朝向,不是主图块的数字。
## 怎么验证
先确认内置处理器取得到,再逐项打印关键换算结果:
```ts
import { FaceDirection, FaceGroup } from '@user/data-common';
import { ICoreState } from '@user/data-state';
/** 打印内置处理器的关键结果,确认方向换算符合预期。 */
function verifyFaceHandler(state: ICoreState): void {
const dir4 = state.faceManager.get<FaceDirection>(FaceGroup.Dir4);
const dir8 = state.faceManager.get<FaceDirection>(FaceGroup.Dir8);
if (!dir4 || !dir8) {
console.log('处理器未注册,检查 initialize.ts 的注册');
return;
}
// 四方向:斜向应折到左右
console.log('左上降级', dir4.degrade(FaceDirection.LeftUp)); // Left
// 八方向:向上走一步是纵坐标 -1
console.log('向上单步', dir8.movement(FaceDirection.Up)); // { x: 0, y: -1 }
// 反方向与旋转;旋转方向以处理器认的方向集合为准
console.log('向右的反方向', dir8.opposite(FaceDirection.Right)); // Left
console.log('向上的顺时针', dir8.next(FaceDirection.Up));
console.log('向上的逆时针', dir8.next(FaceDirection.Up, true));
}
```
再验证朝向图块的登记与读取:先 `malloc` 主图块,再 `bind` 另一朝向,然后核对 `getFaceOf`、`getFaceDirection` 与 `getMainFace` 的返回值;把 `bind` 挪到 `malloc` 之前,应当看到错误日志且映射没有建立。
最后验证自定义处理器:注册后取回,确认 `degrade(LeftUp)` 变成 `Up`、`movement(LeftUp)` 变成 `{ x: 0, y: -1 }`;再把它交给一个新建的移动器或 `finder.useFaceHandler`,重算一次目标格与一条路径,确认斜向输入按新策略归并。已构造好的移动器不会发生变化,若结果没变,先检查是否仍在用旧的处理器。
返回[高级 I 导览](./index.md)。