docs(10-23): 编写高级 II 自动寻路

This commit is contained in:
unanmed 2026-10-21 18:49:56 +08:00
parent 0d3645989f
commit 64fb0494d7

View File

@ -0,0 +1,300 @@
# 自动寻路
自动寻路回答的是「从当前格到目标格走哪条路」。它不负责播放移动动画,也不负责踩踏事件;这两件事分别由移动器和移动钩子完成。寻路系统把「选路」与「执行」拆开:求解器只产出一串方向,真正的位移交给绑定的移动器。
本篇讲这套分工、图的构建入口、两种移动方式与几个可替换点。寻路内部用的是最小损失搜索,本篇只说明它的输入输出,不展开算法实现。
## 参与对象与分工
| 对象 | 获取方式 | 职责 |
| ------------------------ | -------------------------------- | -------------------------------------------- |
| `IPathfindingSystem` | `state.pathfinding` | 对外入口:绑定移动器、取路径、执行移动、中止 |
| `IPathFinder` | `state.pathfinding.finder` | 在图上求最小损失路径 |
| `IMapGraphBuilder` | `IPathFinder` 内部 | 按图层、通行谓词、损失函数与方向组构建有向图 |
| `IPassPredicate` | 通过 `usePassPredicate` 注入 | 判断一步能否通行,并标记只能作为终点的格子 |
| `IObjectMover` | 通过 `useMover` 绑定 | 执行每一步位移,维护位置与朝向 |
| `IPathfindingController` | `moveTo` / `teleportTo` 的返回值 | 把本次路径与底层移动控制器包在一起 |
寻路系统本身不保存地图数据。它每次都从 `IPathFinder` 当前绑定的图层重新构图,因此换楼层、换图层之后必须重新绑定,不能沿用上一次的图。
## 完整流程
一次寻路分为四段:
1. **绑定移动器**:`state.pathfinding.useMover(mover)` 告诉系统「路径最终交给谁走」。勇士场景下传入 `state.hero.location.mover`;动态图块也可以绑定自己的移动器。
2. **构建图**:`finder.useMapLayer(layer)` 选择从哪张图层取格子,通常绑定事件层;`finder.usePassPredicate(predicate)` 判断一步能否通行;`finder.useCostFunction(cost)` 给进入每一格的代价;`finder.useFaceHandler(face)` 指定邻域方向,四方向用 `Dir4FaceHandler`,八方向用 `Dir8FaceHandler`。
3. **求最短路径**:`finder.find(start, target)` 返回 `IPathfindingResult`,里面包含状态与路径步骤。
4. **交给移动器**:`moveTo` 逐步调用 `mover.step(step.dir)` 后启动移动器;`teleportTo` 在策略允许时直接把移动器瞬移到最后一个步骤的终点。
```mermaid
flowchart LR
A[绑定移动器] --> B[构建有向图]
B --> C[求解最小损失路径]
C --> D[交给移动器执行]
```
绑定发生在初始化与换楼层两处。`src/data/initialize.ts` 已经注入通行谓词并把移动器指向勇士,图层先置为 `null`;真正进入楼层后,再把当前楼层的事件层与方向处理器绑上:
```ts
import { Dir4FaceHandler } from '@motajs/common';
import { ICoreState } from '@user/data-state';
/**
* 绑定当前楼层的事件层与四方向移动。
* 每次切换楼层后都要重新调用;不在任何楼层时解除图层绑定。
*/
function bindPathfindingLayer(state: ICoreState): void {
const map = state.hero.location.map;
const layer = map ? map.eventLayer : null;
if (!layer) {
state.pathfinding.finder.useMapLayer(null);
return;
}
// 方向处理器决定邻域:Dir4FaceHandler 只向上下左右扩展
state.pathfinding.finder.useFaceHandler(new Dir4FaceHandler());
// 事件层同时承载图块与事件,寻路通常以它为准
state.pathfinding.finder.useMapLayer(layer);
}
```
初始化里没有注入方向处理器。缺少它时 `build` 返回 `null`,`getPath` 会得到 `InvalidInput`;绑定图层时一并注入即可。
## 结果状态
`IPathfindingResult` 只有两个字段:`status` 与 `path`。`path` 仅在状态为 `Success` 时非空。
| 状态 | 含义 | 处理建议 |
| -------------------------------- | ------------------------------------------------------ | ------------------------------ |
| `PathfindingStatus.Success` | 找到路径 | 读取 `path`,交给移动器 |
| `PathfindingStatus.InvalidInput` | 输入不合法:未绑定移动器、图层或谓词,或起点不在地图内 | 检查绑定与起点坐标 |
| `PathfindingStatus.NoPath` | 从起点出发无法到达目标点 | 不要移动;先检查通行谓词与图层 |
| `PathfindingStatus.TargetUnder` | 目标点就在起点脚下 | 不需要移动 |
每一步是 `IPathfindingStep`,包含 `dir`(移动方向)、`from`(出发位置)与 `to`(到达位置)。`from` 与 `to` 都是只读的坐标对象;路径按起点到终点的顺序排列。
## 两种移动方式
`moveTo` 总是逐步移动。它先取路径,状态不是 `Success` 时直接返回 `null`;否则把每一步的 `dir` 依次交给移动器,再启动移动器。
`teleportTo` 也先取路径,但会额外询问瞬移回退策略:`useFallbackPolicy(policy)` 注入的策略返回 `true` 表示「不允许瞬移」,此时自动退化为逐步移动;返回 `false` 才瞬移。没有设置策略时默认必定逐步移动。
```ts
import { ICoreState } from '@user/data-state';
/** 走到目标点并等待移动结束;返回是否成功启动。 */
async function walkTo(
state: ICoreState,
x: number,
y: number
): Promise<boolean> {
const moving = state.pathfinding.moveTo({ x, y });
if (!moving) return false;
// controller 是底层移动控制器,onEnd 在整段移动结束后兑现
await moving.controller.onEnd;
return true;
}
```
两个方法都返回 `IPathfindingController`:`controller` 是底层移动控制器(可读 `done`、可 `await onEnd`),`path` 是本次使用的寻路结果。需要中止当前移动时调用 `interrupt()`,它会停止底层移动器并等待其结束;新的方向输入或新的寻路调用也可以接管当前移动。
## 初始化在哪里
寻路在 `src/data/initialize.ts` 的 `awake` 中完成三件事:
```ts
// 通行谓词:决定一步能否走、哪一格会撞停
const predicate = new DefaultPassPredicateImpl(state);
state.pathfinding.finder.usePassPredicate(predicate);
// 初始不在任何楼层,图层先置空,切换楼层后再绑定
state.pathfinding.finder.useMapLayer(null);
// 寻路结果交给勇士的移动器执行
state.pathfinding.useMover(state.hero.location.mover);
```
日常需求通常不需要重建这套系统,只需要替换损失函数或瞬移策略。初始化之后替换损失函数就是一次 `useCostFunction` 调用:
```ts
import { PathCostFunction } from '@user/data-system';
import { ICoreState } from '@user/data-state';
/** 在初始化完成之后替换寻路损失函数。 */
function replacePathCost(state: ICoreState, cost: PathCostFunction): void {
state.pathfinding.finder.useCostFunction(cost);
}
```
## 自定义损失函数
`PathCostFunction` 接收一个 `ILayerLocation`,返回进入该格的代价。不注入时每格代价为 `1`。损失函数只读取位置信息,不修改地图:
- `block.tile`:该格静态图块的数字;
- `block.locator`:该格坐标;
- `block.static`:静态图块实例,空格为 `null`;
- `block.dynamics`:该格的动态图块集合。
返回值必须是有限数或 `Infinity`。`Infinity` 是合法的「不可进入」代价;`NaN` 与负数会被视为非法,告警后回退为 `1`。
```ts
import { PathCostFunction } from '@user/data-system';
/** 沼泽图块的数字,按自己的内容配置替换 */
const SWAMP_TILE = 21;
/** 墙图块的数字 */
const WALL_TILE = 1;
/**
* 进入沼泽花费 3,普通地面花费 1,墙不可进入。
* 代价只影响选路,不改变一步能否通行;通行仍由 usePassPredicate 决定。
*/
export const terrainCost: PathCostFunction = block => {
if (block.tile === WALL_TILE) return Infinity;
if (block.tile === SWAMP_TILE) return 3;
return 1;
};
```
损失函数参与的是「进入节点的总代价」,起点自身不计费。目标格的代价也会被计入,所以给目标格设置 `Infinity` 会让它变成不可达。若某种图块在通行谓词里已经禁止进入,损失函数根本不会看到它;两者分工不同,不要只改其中一边就期待通行判定变化。
## 自定义瞬移策略
`PathFallbackPolicy` 接收完整路径,返回 `true` 表示禁止瞬移、退回逐步移动。它只在 `teleportTo` 中调用,`moveTo` 不会询问策略。未设置策略时,`teleportTo` 也总是逐步移动。
策略适合表达「这段路瞬移过去会跳过不该跳过的东西」:路径太长、经过危险图块、或途经需要触发事件的格子。路径只提供方向与坐标,需要图块信息时可以通过闭包读取当前楼层:
```ts
import { PathFallbackPolicy } from '@user/data-system';
import { ICoreState } from '@user/data-state';
/** 岩浆图块的数字 */
const LAVA_TILE = 30;
/**
* 路径超过 20 步,或会经过岩浆时,禁止瞬移。
* 返回 true 表示退回逐步移动。
*/
export function createStepPolicy(state: ICoreState): PathFallbackPolicy {
return path => {
if (path.length > 20) return true;
const map = state.hero.location.map;
const layer = map ? map.eventLayer : null;
if (!layer) return true;
for (const step of path) {
if (layer.getBlock(step.to.x, step.to.y) === LAVA_TILE) {
return true;
}
}
return false;
};
}
```
安装策略同样是 `state.pathfinding.useFallbackPolicy(policy)`。策略只决定「这一次瞬移要不要退化为逐步」,不会修改路径本身;退化为逐步后走的仍是同一条路径。
## 瞬移钩子
`IPathfindingSystemHooks` 提供两个钩子,空实现已经在 `src/data/hook/path.ts` 中留好:
- `onBeforeTeleportTo(path, system)`:只在瞬移分支中触发;
- `onAfterTeleportTo(path, system)`:在移动控制器结束后触发。
两个钩子都挂在寻路系统上,而寻路系统可以绑定任意移动器。因此不只是勇士,动态图块等通过 `useMover` 绑定的对象发生瞬移时也会触发;写回调时不要假设移动的一定是勇士。
```ts
import { ICoreState } from '@user/data-state';
import {
IPathfindingResult,
IPathfindingSystem,
IPathfindingSystemHooks
} from '@user/data-system';
/** 记录自动寻路的瞬移与结束。 */
export class MainPathfindingSystemHooks implements IPathfindingSystemHooks {
constructor(readonly state: ICoreState) {}
onBeforeTeleportTo(
path: IPathfindingResult,
system: IPathfindingSystem
): void {
const mover = system.mover;
const last = path.path.at(-1);
if (!mover || !last) return;
// 瞬移只落在终点,途经格不会触发踩踏事件
console.log(
'从',
mover.tile.x,
mover.tile.y,
'瞬移到',
last.to.x,
last.to.y
);
}
onAfterTeleportTo(path: IPathfindingResult): void {
// 这个钩子挂在所有移动的结束回调上,逐步移动也会走到这里
console.log('寻路移动结束,共', path.path.length, '步');
}
}
```
钩子实例在 `src/data/hook/initialize.ts` 中注册。需要区分「这次是瞬移还是逐步」时,在 `onBeforeTeleportTo` 里记录标记,再在 `onAfterTeleportTo` 里读取并清除;不要只看结束钩子。
## 怎么验证
先准备一张固定地图,把勇士放到起点,再用 `getPath` 观察状态与步数,最后执行 `moveTo` 并等待控制器结束:
```ts
import { ICoreState } from '@user/data-state';
import { PathfindingStatus } from '@user/data-system';
/** 打印寻路结果,并在成功时走到目标点。 */
async function verifyPathfinding(
state: ICoreState,
x: number,
y: number
): Promise<void> {
const path = state.pathfinding.getPath({ x, y });
console.log('状态', path.status, '步数', path.path.length);
if (path.status !== PathfindingStatus.Success) {
console.log('未找到路径,检查移动器、图层、通行谓词与方向处理器');
return;
}
const moving = state.pathfinding.moveTo({ x, y });
if (!moving) {
console.log('已有移动进行中,或本次寻路失败');
return;
}
await moving.controller.onEnd;
const location = state.hero.getLocation();
console.log('到达', location.x, location.y);
}
```
预期英雄沿最小损失路径到达目标点;目标不可达时 `status` 为 `NoPath`,不会发生移动。若状态是 `InvalidInput`,按顺序检查移动器、图层、通行谓词与方向处理器是否都已绑定。
## 边界与注意
- **未绑定移动器时 `getPath` 返回 `InvalidInput`**:寻路需要从移动器读取当前坐标,没有移动器就没有起点。
- **`moveTo` 与 `teleportTo` 只在 `Success` 时启动**:`NoPath`、`TargetUnder`、`InvalidInput` 都会返回 `null`。
- **上一次移动未结束时返回 `null`**:连续调用 `moveTo` 前先等 `controller.onEnd`,或显式 `interrupt()`。
- **目标点在脚下不会移动**:`find` 在起点与终点相同时直接返回 `TargetUnder`。
- **目标格必须可达**:图只包含从起点沿可通行边能到达的节点,不可达区域不会进入图。
- **谓词的 `shouldHit` 会标记终端节点**:终端节点只能作为路径终点,不能作为中间点;目标点不可达时优先检查 `canPass` 与 `shouldHit` 的组合。
- **换楼层要重新绑定**:`useMapLayer` 绑定的是具体图层对象,旧楼层失效后必须换成新楼层的图层。
- **图层必须属于同一个数据端对象**:`useMapLayer` 会校验图层来源,不属于当前状态时告警并忽略这次绑定。
- **损失函数的非法返回值会回退**:`NaN` 与负数告警后按 `1` 处理;`Infinity` 合法,表示不可进入。
- **策略只影响 `teleportTo`**:`moveTo` 永远逐步移动,不会询问回退策略。
- **瞬移会跳过途经格**:途经的踩踏事件不会触发;需要补触发时用 `onBeforeTeleportTo` 或 `onAfterTeleportTo`。
- **`onAfterTeleportTo` 不是瞬移专用**:它挂在移动控制器结束回调上,逐步移动结束时同样会触发。
- **`interrupt()` 会等待底层移动停止**:它返回 `Promise`,需要确保停止后再开始新的移动。
渲染端如何表现寻路移动不在本篇范围内。 <!-- TODO(renderer): 寻路移动动画与瞬移表现 -->