Files
template/docs/advanced/iii/passability-and-pathfinding.md
T
AncTe 93ac3efec8 docs(10-39): 新增高级 III 导览并补齐互链
- 新建 III 导览:读者画像、与 I/II 的区别、前置、八篇地图、阅读路径、边界与自检

- 七篇正文补返回导览,并把已有的兄弟页提及改为真实相对链接(含 event-system 收集章节锚点)
2026-10-27 14:13:10 +08:00

252 lines
16 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.
# 通行性谓词与寻路原理
通行性谓词回答两个问题:这一步能不能走进目标格,以及这次移动会不会撞上目标格。它是数据端唯一的通行语义契约:勇士移动在每一步开始时问它,寻路在构图时也问它。因此在默认实现下,「寻路选出的路」与「勇士实际能走的路」由同一套规则算出。
本篇讲这个契约、默认实现如何判断、它为什么会出现两个实例,以及寻路如何构图、搜索并把步骤交给移动器。损失函数与瞬移策略属于[自动寻路](../ii/pathfinding.md)的用法,本篇不重复。
## 系统模型
通行性判断只有一组输入:当前位置、目标位置、移动方向与地图。勇士移动与寻路构图都把这组输入打包成 `IPassCheckHandler`,再调用同一个 `IPassPredicate`。两者的差别只在「拿到结果后做什么」。
| 对象 | 职责 |
| -------------------------- | ------------------------------------------------------ |
| `IPassCheckHandler` | 一次判断的输入:当前格、目标格、方向与地图 |
| `IPassPredicate` | 通行性契约:`canPass` 与 `shouldHit` |
| `DefaultPassPredicateImpl` | 数据端默认实现:按事件层与图块的通行位判断 |
| `MapGraphBuilder` | 从起点出发构图,只保留可达节点,并标记终端 |
| `PathFinder` | 在图上搜索最小损失路径 |
| `PathfindingSystem` | 绑定移动器、图层、谓词与方向处理器,并把步骤交给移动器 |
两个消费方的调用方式如下。寻路本身不判断通行,它只是反复调用谓词,把「能不能走」翻译成图的边。
```text
勇士移动:一步开始
└─ IPassPredicate.canPass / shouldHit
├─ canPass 为 false → 不能走
├─ canPass 为 true 且 shouldHit → 撞停
└─ 两者都放行 → 正常一步
寻路:构图
└─ 对每个相邻格调用同一组方法
├─ canPass 为 false → 不建边
├─ shouldHit 为 true → 目标格记为终端
└─ 其余 → 建一条可通行边
```
寻路整体是「构图 → 搜索 → 交给移动器」:`MapGraphBuilder` 把当前楼层变成一张有向图,`PathFinder` 在图上找一条最小损失路径,`PathfindingSystem` 再把路径拆成步骤交给绑定的移动器。三者都不自己判断通行,判断统一落在谓词上。
## 通行性谓词契约
`IPassPredicate` 只有两个方法,接收同一个 `IPassCheckHandler`:
- `canPass(handler)`:判断能否从当前格进入目标格。
- `shouldHit(handler)`:判断这次移动是否应该产生撞击。
两者是不同的问题。`shouldHit` 为真并不表示「不能走」,而是「走得到,但会撞」;`canPass` 为假才是真的进不去。勇士移动正是按这个顺序判断的:先看 `canPass`,再在放行时看 `shouldHit`。寻路构图也用同一组结果:`canPass` 决定建不建边,`shouldHit` 决定目标格是不是终端。
`IPassCheckHandler` 带着 `currLoc`、`nextLoc`、`direction` 与 `map`,还带着数据端主对象。谓词不保存「当前是哪个对象在走」的状态,同一个判断函数可以同时被勇士和寻路调用。
## 默认实现怎么判断
`DefaultPassPredicateImpl` 是数据端内置的默认实现。它在 `canPass` 里分层判断:
1. 先从朝向管理器取四方向处理器,把传入方向降级到四向;取不到处理器或降级为 `Unknown` 时直接不通行。
2. 没有地图、地图没有事件层时直接不通行。
3. 在事件层上,当前格静态图块的 `pass.outPass` 必须允许朝移动方向离开;目标格静态图块的 `pass.inPass` 必须允许从反方向进入。事件层的图块无论是否标记「只判事件」都参与判断。
4. 在其它图层上,只检查 `pass.onlyEvents` 为真的图块;没标记的图块不参与通行判断。标记了的图块同样检查离开与进入两个方向。
`shouldHit` 只看事件层的目标格:目标格静态图块存在,并且它的 `eventPass` 为假时,返回真。`eventPass` 是图块的事件通行标记:为真表示玩家会走上去触发事件,为假表示玩家会撞在格子上触发事件。因此默认规则下,带事件但不可走上去的格子会成为寻路的终端,而不是普通中间格。
下面这段函数用默认谓词判断某一步的结果,三种返回值与勇士移动的分支一一对应:
```ts
// src/data/hero/passCheck.ts
import { ITileLocator } from '@motajs/common';
import { FaceDirection } from '@user/data-common';
import { IPassCheckHandler } from '@user/data-base';
import { ICoreState } from '@user/data-state';
import { DefaultPassPredicateImpl } from './predicate';
/** 一步的三种结果:不能走、走得到但会撞、正常进入。 */
export type StepResult = 'cannotMove' | 'hit' | 'step';
/**
* 用默认谓词判断从 curr 朝 direction 走到 next 的结果。
* 默认谓词内部按事件层与 onlyEvents 图块检查通行位,并用 eventPass 判断撞击。
*/
export function judgeStep(
state: ICoreState,
curr: ITileLocator,
next: ITileLocator,
direction: FaceDirection
): StepResult {
// 判断一次也要构造完整输入,地图与状态都从数据端主对象取
const handler: IPassCheckHandler = {
currLoc: curr,
nextLoc: next,
direction,
map: state.hero.location.map,
state
};
// 谓词本身只保存数据端主对象,频繁判断时应复用同一个实例
const predicate = new DefaultPassPredicateImpl(state);
if (!predicate.canPass(handler)) return 'cannotMove';
if (predicate.shouldHit(handler)) return 'hit';
return 'step';
}
```
## 同一契约、两个实例
这是本页最容易讲错的一点:勇士移动与寻路用的谓词来自同一个类,但不是同一个对象。
| 消费方 | 实例来源 | 注入方式 |
| -------- | --------------------------------------------------- | ----------------------------------- |
| 勇士移动 | `DefaultHeroMoveTopImpl` 构造时自己新建一个默认谓词 | 顶层实现的 `predicate()` 返回它 |
| 寻路构图 | 数据端初始化时另建一个默认谓词 | `finder.usePassPredicate(...)` 注入 |
`DefaultHeroMoveTopImpl` 把谓词保存在字段里,`predicate()` 每次返回同一个实例。[数据端初始化时](./core-state.md)又为寻路求解器新建了一个默认谓词,通过 `state.pathfinding.finder.usePassPredicate(...)` 注入。两者行为一致,因为它们由同一个类实现、读同一份地图数据;但它们没有共享状态,也没有自动同步。
因此,改勇士侧的谓词不会改变寻路侧,改寻路侧的谓词也不会改变勇士侧。要让两边保持一致,必须两边都换:要么把同一个谓词实例同时交给顶层实现和求解器,要么在两侧分别构造同一个自定义类。
## 勇士移动的三种结果
勇士移动在每一步开始时调用顶层实现的 `predicate()`,再用返回的谓词判断这一步:
- 不能走:`canPass` 为假。调用顶层实现的 `cannotEnter(handler)`,然后停止本次移动,位置不变。当前默认实现里这个回调是空实现,没有对应的内置触发器。
- 撞停:`canPass` 为真、`shouldHit` 也为真。调用顶层实现的 `hit(handler)`,然后停止本次移动,位置不变。
- 正常一步:两者都放行。本步返回目标格,移动器把坐标写过去;结算时先触发离开当前格,再触发进入目标格。
三种结果与触发器派发的对应关系只有一句:撞停走触碰,正常一步走离开与进入,不能走目前没有内置触发器。触发器本身的收集与执行属于事件系统,本篇不展开。
## 构图:从起点只保留可达节点
`MapGraphBuilder` 负责把图层变成有向图。构图前必须已经注入图层、通行谓词与方向处理器;起点还必须在地图范围内。缺任何一项,`build(start)` 都会告警并返回 `null`。
构图从起点开始做广度扩展:
1. 用方向处理器的 `mapMovement()` 枚举邻域,跳过零偏移的原地条目。
2. 邻格不在地图内就跳过;否则用当前格、邻格、方向与地图构造 `IPassCheckHandler`。
3. 谓词的 `canPass` 为假时不建边,也不继续扩展该邻格。
4. `canPass` 放行但 `shouldHit` 为真时,仍然建边,同时把邻格记为终端。
5. 只有从起点可达的节点才进入图;不可达区域根本不会出现在图里。
每个节点的损失由损失函数计算,未注入时按 1 处理;损失值非法时也回退为 1。损失函数只决定「进入这一格要花多少代价」,不决定能不能进入。
终端是构图阶段留下的约束:终端格可以作为路径的终点,但不能作为路径的中间点。默认实现里,`shouldHit` 为真的格子就是终端,所以「走得到但会撞」的格子只在作为目标时才会出现在路径末尾。
## 搜索:最小损失与终端约束
`PathFinder` 在构好的图上搜索最小损失路径。它不重新判断通行,也不重新计算损失,只读取节点上的 `cost` 与 `terminal`。
搜索的输入输出可以归结为几条约束:
- 起点等于终点时直接返回空路径,表示目标就在脚下。
- 目标不在图里时返回空路径;图只包含从起点可达的节点,不可达自然找不到。
- 终端节点只能作为终点:搜索不会把一个终端节点当作通往其它节点的中间点。
- 找到路径时返回按起点到终点排列的步骤,每一步带方向、出发位置与到达位置。
`find(start, target)` 把构图与搜索串起来,并把结果归入四个状态:
| 状态 | 含义 |
| -------------------------------- | -------------------------------------------------- |
| `PathfindingStatus.Success` | 找到路径,`path` 非空 |
| `PathfindingStatus.InvalidInput` | 构图失败:缺少图层、谓词或方向处理器,或起点越界 |
| `PathfindingStatus.NoPath` | 图上找不到路径,例如目标被终端格或不可通行区域挡住 |
| `PathfindingStatus.TargetUnder` | 起点与终点相同,目标就在脚下 |
`NoPath` 是正常结果,不是错误。它说明按当前谓词与当前图层,从起点走不到目标;调用方不需要为它做特殊恢复。
## 系统层做什么
`PathfindingSystem` 是寻路的对外入口,负责把求解器与移动器接起来:
- 用 `useMover(mover)` 绑定移动器;`getPath(target)` 从移动器的 `tile` 读取起点,没有绑定移动器时直接返回 `InvalidInput`。
- 图层、通行谓词、方向处理器与损失函数都通过 `finder` 注入;换楼层时重新绑定图层。
- `moveTo(target)` 总是逐步移动:取到 `Success` 的路径后,把每一步的方向交给移动器,再启动移动器。
- `teleportTo(target)` 先询问瞬移回退策略;没有策略或策略返回真时退回逐步移动,只有策略放行才把移动器瞬移到最后一个步骤的终点。
- 上一次移动还没有结束时,新的 `moveTo` 或 `teleportTo` 返回 `null`,不会接管当前移动。
- `interrupt()` 会等待底层移动真正停止,再允许开始下一次移动。
系统层不保存地图数据,也不缓存上一次的图;每次寻路都从当前绑定的图层重新构图。完整的绑定流程、损失函数与瞬移策略示例见[自动寻路](../ii/pathfinding.md)。
## 换一套通行语义
换通行语义就是换谓词。实现 `IPassPredicate` 的两个方法,输入都是 `IPassCheckHandler`;需要保留默认规则时,在内部包一个默认实现,再叠加自己的判断。
下面这个类在默认规则之上禁止勇士走进指定图块数字的格子:
```ts
// src/data/hero/noTilePassPredicate.ts
import { IPassCheckHandler, IPassPredicate } from '@user/data-base';
import { IStateSystem } from '@user/data-system';
import { DefaultPassPredicateImpl } from './predicate';
/** 在默认规则之上,禁止进入指定图块数字的格子。 */
export class NoTilePassPredicate implements IPassPredicate {
// 默认谓词负责事件层、onlyEvents 图块与 eventPass 的判断
private readonly base: IPassPredicate;
constructor(
state: IStateSystem,
// 要禁止进入的图块数字,按自己的内容配置传入
private readonly blockedTile: number
) {
this.base = new DefaultPassPredicateImpl(state);
}
canPass(handler: IPassCheckHandler): boolean {
// 默认规则已经拒绝时,不再叠加判断
if (!this.base.canPass(handler)) return false;
const map = handler.map;
const event = map ? map.eventLayer : null;
if (!event) return false;
// 读取目标格在事件层上的静态图块数字
const next = event.getLocationData(
handler.nextLoc.x,
handler.nextLoc.y
);
return next?.static?.raw()?.num !== this.blockedTile;
}
shouldHit(handler: IPassCheckHandler): boolean {
// 撞击语义保持默认:目标格静态图块的 eventPass 为假时撞停
return this.base.shouldHit(handler);
}
}
```
寻路侧只需要把求解器的谓词换掉:
```ts
// src/data/initialize.ts 的 awake 中
const LAVA_TILE = 30;
// 换掉寻路构图使用的谓词;之后构图与终端标记都按新规则计算
state.pathfinding.finder.usePassPredicate(
new NoTilePassPredicate(state, LAVA_TILE)
);
```
勇士侧要换的是顶层实现,而不是直接往移动器里塞谓词。`DefaultHeroMoveTopImpl` 在构造时已经建好自己的默认谓词;保留其它行为时,包一层顶层实现并转发,让 `predicate()` 返回你的谓词。完整写法见[移动 Mover](../i/mover.md#勇士的顶层实现)。如果这套规则就是本作唯一的规则,也可以直接修改默认实现,让勇士与寻路都拿到新语义。
方向处理器决定「哪些方向算相邻」,它的选择会同时影响移动与构图;四方向与八方向的差别见[FaceHandler 系列](../i/face-handler.md#用在哪里)。损失函数与瞬移策略不改变通行性,它们分别决定进入代价与是否逐步移动,见[自定义损失函数](../ii/pathfinding.md#自定义损失函数)与[自定义瞬移策略](../ii/pathfinding.md#自定义瞬移策略)。
## 边界与注意
- 两个实例不共享:勇士侧与寻路侧各有一个谓词对象,改一侧不会自动改另一侧;要一致就两边都换。
- 图层必须属于同一数据端:`useMapLayer` 会校验图层来源,不属于当前状态时告警并忽略这次绑定。
- 未注入方向处理器时构图失败:`build` 返回 `null`,`find` 得到 `InvalidInput`;初始化只注入谓词与移动器,图层与方向处理器要在进入楼层时补上。
- 起点越界会告警并返回失败:`build` 先检查起点是否在图层范围内,越界时不会产生图。
- 终端只能作终点:`shouldHit` 标记的格子不能作为路径中间点,只能作为目标格。
- 无路径是正常结果:目标不可达时返回 `NoPath`,不会移动,也不需要特殊处理。
- 目标在脚下不移动:起点与终点相同时直接返回 `TargetUnder`。
- 换楼层要重新绑定图层:绑定的是具体图层对象,换楼层后要换成新楼层的事件层。
- 瞬移会跳过途经格:瞬移分支只落到终点,途经格的进入、离开与撞击都不会发生;需要补触发时用钩子,或用回退策略退回逐步移动。
返回[高级 III 导览](./index.md)。