template/docs/advanced/ii/pathfinding.md
unanmed 7409a47037 docs(10-27): U7 修订批次E(范围/录像/寻路/编辑器占位)
- U7-02:删除范围对象调用处的模块级复用说明注释
- U7-05:指令码位宽收敛为一句,细节指向高级 III
- U7-06:选择框只作概念示例,代码示例恢复为技能示例
- U7-07:NoPath 改为正常结果,无需处理
- U7-08:移除编辑器过渡说法,jsonc 内容改为「需要编辑器」占位
2026-10-22 16:52:38 +08:00

16 KiB
Raw Blame History

自动寻路

自动寻路回答的是「从当前格到目标格走哪条路」。它不负责播放移动动画,也不负责踩踏事件;这两件事分别由移动器和移动钩子完成。寻路系统把「选路」与「执行」拆开:求解器只产出一串方向,真正的位移交给绑定的移动器。

本篇讲这套分工、图的构建入口、两种移动方式与几个可替换点。寻路内部用的是最小损失搜索,本篇只说明它的输入输出,不展开算法实现。

参与对象与分工

对象 获取方式 职责
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 在策略允许时直接把移动器瞬移到最后一个步骤的终点。
flowchart LR
    A[绑定移动器] --> B[构建有向图]
    B --> C[求解最小损失路径]
    C --> D[交给移动器执行]

绑定发生在初始化与换楼层两处。src/data/initialize.ts 已经注入通行谓词并把移动器指向勇士,图层先置为 null;真正进入楼层后,再把当前楼层的事件层与方向处理器绑上:

// Dir4FaceHandler / Dir8FaceHandler 都在 @user/data-common,不在 @motajs/common
import { Dir4FaceHandler } from '@user/data-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 都是只读的坐标对象;路径按起点到终点的顺序排列。

finder.find(start, target) 和 state.pathfinding.getPath(target) 的区别只在起点:

  • finder.find 要求显式传入起始坐标,不会读取移动器,适合「从任意起点试算」;
  • getPath 是便捷包装,取 state.pathfinding.mover.tile 的当前坐标作为起点,再把结果交给 finder.find。没有绑定移动器时它直接返回 InvalidInput。

两者都依赖同一套图层、通行谓词与方向处理器;换用哪个都不会绕过这些绑定。

两种移动方式

moveTo 总是逐步移动。它先取路径,状态不是 Success 时直接返回 null;否则把每一步的 dir 依次交给移动器,再启动移动器。

teleportTo 也先取路径,但会额外询问瞬移回退策略:useFallbackPolicy(policy) 注入的策略返回 true 表示「不允许瞬移」,此时自动退化为逐步移动;返回 false 才瞬移。没有设置策略时默认必定逐步移动。

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 是本次使用的寻路结果。上一次移动没有结束时,新的 moveTo / teleportTo 会直接返回 null,不会接管当前移动;底层 mover.start() 在移动中同样返回 null。想换目标,必须先等 controller.onEnd,或调用 await state.pathfinding.interrupt() 停止当前移动,再发起新的寻路。

初始化在哪里

寻路在 src/data/initialize.ts 的 awake 中完成三件事:

// 通行谓词:决定一步能否走、哪一格会撞停
const predicate = new DefaultPassPredicateImpl(state);
state.pathfinding.finder.usePassPredicate(predicate);

// 初始不在任何楼层,图层先置空,切换楼层后再绑定
state.pathfinding.finder.useMapLayer(null);

// 寻路结果交给勇士的移动器执行
state.pathfinding.useMover(state.hero.location.mover);

日常需求通常不需要重建这套系统,只需要替换损失函数或瞬移策略。初始化之后替换损失函数就是一次 useCostFunction 调用:

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:该格静态图块的数字,空格为 0;
  • block.locator:该格坐标;
  • block.static:该格静态图块实例,只要坐标在地图内就非 null,空格时实例的 num() 为 0;
  • block.dynamics:该格的动态图块集合。

判断空格要用 block.tile === 0,不要用 block.static === null:图层按坐标缓存静态图块实例,空地上 layer.getTile() 同样返回实例。

返回值应是非负的有限数。NaN 与负数会被视为非法,告警后回退为 1。不可进入的点由通行谓词控制,不要用 Infinity 去标记不可达——那不属于损失函数的职责,也不是标准用法。

import { PathCostFunction } from '@user/data-system';

/** 沼泽图块的数字,按自己的内容配置替换 */
const SWAMP_TILE = 21;

/**
 * 进入沼泽花费 3,普通地面花费 1。
 * 墙等不可进入的格子交给通行谓词排除,损失函数不会看到它们。
 */
export const terrainCost: PathCostFunction = block => {
    if (block.tile === SWAMP_TILE) return 3;
    return 1;
};

损失函数参与的是「进入节点的总代价」,起点自身不计费,目标格的代价同样计入。若某种图块在通行谓词里已经禁止进入,损失函数根本不会看到它;两者分工不同,不要只改其中一边就期待通行判定变化。

自定义瞬移策略

PathFallbackPolicy 接收完整路径,返回 true 表示禁止瞬移、退回逐步移动。它只在 teleportTo 中调用,moveTo 不会询问策略。未设置策略时,teleportTo 也总是逐步移动。

策略适合表达「这段路瞬移过去会跳过不该跳过的东西」:路径太长、经过危险图块、或途经需要触发事件的格子。路径只提供方向与坐标,需要图块信息时可以通过闭包读取当前楼层:

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 绑定的对象发生瞬移时也会触发;写回调时不要假设移动的一定是勇士。

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,
        system: IPathfindingSystem
    ): void {
        // 第二个参数同样是寻路系统,可用来判断这次移动绑定的移动器
        void system;
        // 这个钩子挂在所有移动的结束回调上,逐步移动也会走到这里
        console.log('寻路移动结束,共', path.path.length, '步');
    }
}

这段代码直接补进 src/data/hook/path.ts 的 MainPathfindingSystemHooks 即可(类已存在,方法体是空的)。注册发生在 src/data/hook/initialize.ts 的 initializeHooks:

// initializeHooks 内部
state.pathfinding.addHook(new MainPathfindingSystemHooks(state));

需要区分「这次是瞬移还是逐步」时,在 onBeforeTeleportTo 里记录标记,再在 onAfterTeleportTo 里读取并清除;不要只看结束钩子。

怎么验证

先准备一张固定地图,把勇士放到起点,再用 getPath 观察状态与步数,最后执行 moveTo 并等待控制器结束:

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。
  • NoPath 是正常结果:图只包含从起点沿可通行边能到达的节点;目标被怪物挡住或不可达时返回 NoPath,不会移动,也不需要特殊处理。
  • 谓词的 shouldHit 会标记终端节点:终端节点只能作为路径终点,不能作为中间点;目标点不可达时优先检查 canPass 与 shouldHit 的组合。
  • 换楼层要重新绑定:useMapLayer 绑定的是具体图层对象,旧楼层失效后必须换成新楼层的图层。
  • 图层必须属于同一个数据端对象:useMapLayer 会校验图层来源,不属于当前状态时告警并忽略这次绑定。
  • 损失函数的非法返回值会回退:NaN 与负数告警后按 1 处理;不可达由通行谓词控制,不要用 Infinity 标记。
  • 策略只影响 teleportTo:moveTo 永远逐步移动,不会询问回退策略。
  • 瞬移会跳过途经格:途经的踩踏事件不会触发;需要补触发时用 onBeforeTeleportTo 或 onAfterTeleportTo。
  • onAfterTeleportTo 不是瞬移专用:它挂在移动控制器结束回调上,逐步移动结束时同样会触发。
  • interrupt() 会等待底层移动停止:它返回 Promise,需要确保停止后再开始新的移动。

渲染端如何表现寻路移动不在本篇范围内。

返回高级 II 导览。