80,515 字
403 分钟

MapNavigator:终末地寻路

文章
技术

MaaEnd MapNavigator 寻路导航的状态机、NavMesh 规划、跟随与转向、卡死恢复、语义动作和滑索,以及各参数的来源。

WARNING

MapNavigator 仍处在快速迭代中,各类边界情况的处理还在陆续补齐。本文只能作为大致参考,阈值、时限和个别分支很可能很快过时;主体结构一般不变。文中代码行为以 MaaEnd v2 分支 84de1e1(2026-10-05)为准。

MapNavigator 是 MaaEnd 的寻路模块,按 Pipeline 给出的路线把角色带到目标点,沿途执行跳跃、交互、采集、挖掘、滑索等动作,坐标取自 MapLocator。本文按一次导航的执行顺序描述当前实现:入口与状态机、NavMesh 规划、启动续接、到达判定、走廊跟随与转向、输入后端、卡死恢复与 watchdog、跨区、语义动作、滑索,最后是实测耗时。NavMesh 规划器的内部实现单独成文,见 NavMesh:终末地路径规划。各部分附上对应的 PR,说明参数从哪来。

整体流程

导航主循环每执行一轮叫一刻(tick)。

mermaid
flowchart TD
    IN([MapNavigateAction<br/>一条 path]) --> PREP[重入保护、等首个定位<br/>NAVMESH 展开、Bootstrap]
    PREP --> CAP

    subgraph TICK [Navigate:每刻 = 本刻工作 + 固定休眠 33 ms]
        CAP[取位、滞留、镜头对齐<br/>内联 ZONE、HEADING、FIND] --> TRK[路线跟踪、走廊跟随<br/>软重规划、走廊消费]
        TRK --> ARR{{到达判定}}
        ARR -->|未到| DRV[watchdog、恢复阶梯<br/>转向、前进、冲刺]
        ARR -->|到点| NODE[到点动作<br/>末端纠正]
    end

    DRV -.->|停滞| STALL([恢复阶梯]):::bg
    DRV --> NEXT([下一刻])
    NODE -->|还有路点| NEXT
    NODE -->|终点| OK([返回成功])

    CAP -.->|定位丢失| LOST([盲跳、全局重定位]):::bg
    TRK -.->|偏离走廊| RP([重规划]):::bg
    NODE -.->|TRANSFER、滑索、FIND、TRIGGER| WAIT([等待 phase]):::bg

    classDef bg stroke-dasharray: 4 3

进入时先过重入保护:同一个 MaaTasker 已在导航则拒绝。随后最多等 100 次 × 100 ms 取得首个定位,把 NAVMESH 段规划成 RUN 路点;规划失败时切掉已走过的前缀再展开一次(#5247「展开失败时跳过已走过的前缀再重试」)。Bootstrap 只执行一次,找到角色当前所在的路段并接上起步规划,之后进入 Navigate 循环。

虚线为旁路。旁路恢复后回到下一刻;超过各自的时限则返回失败。四个等待 phase WaitTransfer、WaitZipline、WaitFind、WaitTrigger 有自己的循环,卡死检测、绕障、重规划和提示扫描只在 Navigate phase 生效。

每刻在工作做完之后固定休眠 33 ms,实际周期是本刻工作耗时加 33 ms。延迟检测按健康时每刻约 110 ms 设计,超过 280 ms 的刻算作慢刻;本文测量的 PlayCover 上中位约 200 ms,见性能。

步骤章节
入口、phase、每刻顺序、失败原因状态机
解析、动作特性路线与动作
NAVMESH 规划路径搜索,内部实现见 NavMesh 一文
Bootstrap 续接启动与续接
到达判定、走廊消费、末端纠正到达判定
走廊投影、前视点、软重规划走廊跟随
转向、冲刺、走路转向、冲刺与走路、输入后端
每刻耗时与灵敏度延迟与灵敏度
恢复阶梯、虚拟禁区、物理脱困卡死与恢复
丢定位、落水、偏离、滞留watchdog
ZONE、PORTAL、TRANSFER跨区
采集、交互、DIG、HEADING、FIND语义动作
滑索滑索
各步耗时性能

难点

  • 只有小地图:移动时能用的信息只有两样,一是 MapLocator 从小地图解出的坐标和朝向,二是随包预烘的静态地形(见 NavMesh),相当于蒙着眼走路,只知道自己在哪、面朝哪。画面另外只在三处用到:交互提示的识别、挡路设备的模板匹配、用黑屏判断落水。撞墙、卡住、跌下台子、走进水里,都只能事后从坐标序列的变化或定位丢失里推断出来。下面几条都受这一点限制。
  • 控制回路慢且有滞后:每刻要截图、定位、发转向,PlayCover 上一刻的中位耗时约 200 ms。一次转向要过几刻才在朝向读数上体现,按原始误差每刻下发,同一个转角会被发好几遍,过冲后再往回找,表现为左右摆(#4939「扣除已发出未兑现的转向,删除冗余的阻尼项」)。见转向。
  • 地形:窄桥、台阶、落差、深水都在路上,规划要在窄缝和绕远的宽路之间取舍。见路径搜索。
  • 卡死:#3526「[自动采集]寻路卡死但未触发急停」中角色一直往前跳水,发现时已经跳了两个小时;定位丢失时前进键一直按着顶墙,约 13 分钟后才被外层超时(#3509)。见卡死与恢复、watchdog。
  • 地图上没有的障碍:玩家自建的设备、网格没记录的石头会挡在路上(#4975、#5576)。见卡死与恢复。
  • 到点精度:采集点间距只有 2.1 到 3.7,常规判定圈 3.25 一次会吞掉一串(#5030)。见到达判定。
  • 跨区与传送:区域切换、室内分层、传送、机关送人时,坐标会跳变或暂时丢失。见跨区。
  • 滑索:索上看不到小地图,落点只能在落地后再分类;拦路的塔会把人截停在半路。见滑索。
  • 平台差异:桌面用鼠标,ADB 和 PlayCover 用触屏滑动,单笔转向上限、最小发送间隔、触点采样各不相同。见输入后端。
  • 机器差异:健康时每刻约 110 ms,到 350 ms 左右就走不动了。见延迟检测。
  • 灵敏度不是默认值:转向量按默认视角灵敏度换算,玩家改过灵敏度时,同一条指令会转过头或转不到位。#5583 起导航在行走中自动检测,判决成立后按检测出的倍率缩放转向量。见灵敏度检测。

演进

#1170「feat: cppMapNavigator」(2026-03-10,+7806/−43)是首版,状态机有 9 个 phase:Bootstrap、AlignHeading、AdvanceOnRoute、WaitZoneTransition、WaitRelocation、RecoverRejoin、ExactTargetRefine、Finished、Failed。转向侧维护一个虚拟偏航角 virtual_yaw,并在线学习每度对应的鼠标量(EMA,α 0.382,范围 1.0 到 4.0)。#1179 当天删掉了 ExactTargetRefine。

#1999「adb/playcover 支持」加入 ADB 与 Win32 后端,删掉 WaitRelocation 和 RecoverRejoin,加入 WaitTransfer,同时引入 Runners/ 下的五个 runner 和 979 行的 turn_execution_engine。

#2162「大道至简 至于大道在哪你别管」(+2134/−4226,60 个文件)把 AlignHeading、AdvanceOnRoute、WaitZoneTransition 合并为单一的 Navigate phase,删掉整个 Runners/、turn_execution_engine、virtual_yaw 和转向比例估计。PR 描述把结构收敛为「路线跟踪 + 统一转向 + 语义流转」,平台差异沉到输入层。#2290 再删掉 PoseEstimator,转向只看航向误差。此后 phase 只增加了等待类:#5079 的 WaitZipline、#5891 的 WaitFind 和 #6155 的 WaitTrigger。

Phase 结构稳定之后,大部分改动落在两处:规划器一路从三角网 A*⁠[1]换到体素格上的两段搜索;Navigate phase 里不断加入兜底,每一道都对应一类实际卡死或失败的 issue。

日期PR变化
03-10#1170首版,9 个 phase
04-07#1999ADB、PlayCover 后端
04-13#2162单一 Navigate phase
05-21#3084NAVMESH(Beta),三角网 A*
06-10#3509丢定位超时与盲跳
06-12#3552卡死硬时钟
06-14#3628走廊规划、冲刺限制、转向增强
06-18#3690COLLECT 改为异步检测,偏离 watchdog
06-20#3754落水自救
07-04#3995基于 NavMesh 的自动绕障、跨层逃逸
07-24#4460体素栅格
08-07#4809前视距离随速度变化,前进保持闭环
08-11#4939扣除在途转向
08-12#4975卡死时搬走挡路设备
08-16#5026格图随包预烘焙
08-20#5079滑索
08-25#5198末端纠正
09-03#5405拓扑与几何两段搜索
09-07#5576运行期虚拟禁区绕障
09-08#5586ActionTraits
09-08#5585滑索子状态机
09-10#5618滑索剔除拦路与地形阻挡的索
09-20#5891FIND
09-20#5899滞留 watchdog
09-29#6097ADB 转向修正
09-30#6109偏离计时扣除同步规划耗时
10-01#6155TRIGGER
10-04#6205同步规划时停步,停滞计时扣除规划耗时

输入与输出

输入是 Pipeline 自定义动作 MapNavigateAction 的参数,核心是一条路线 path。下面是自动售卖任务走向物资调度终端的一段:

json
{
  "path": [
    { "action": "ZONE", "zone_id": "OMVBase01" },
    { "action": "NAVMESH", "target": [185.9, 141.39] },
    {
      "action": "INTERACT",
      "target": [185.9, 130],
      "interact_text": ["物资调度终端", "物資調度終端"],
      "interact_rec": true
    }
  ]
}

路线由路点组成,每个路点是坐标加动作。坐标与 MapLocator 相同,为底图像素,下文的距离单位除特别注明外都是底图像素。路点有两种写法:数组 [x, y, 动作…, strict, zone],或带 action 字段的对象。

动作共 15 种:

动作含义
RUN普通路点,经过即推进
SPRINT到点冲刺一次
JUMP到点跳跃
FIGHT到点停下,攻击一次
INTERACT到点停下交互;给了 interact_text 时改为行进中检测提示
TRANSFER到点停住,等机关、跳板把角色送到下一段
PORTAL跨区过渡,到点后盲走等待区域切换
HEADING无坐标,把镜头转到指定角度
NAVMESH从当前位置自动规划到 target,展开为一串 RUN
ZONE无坐标,声明后续路点所在的地图
COLLECT经过即推进,同时开启采集检测
DIG到点停下,执行挖掘子任务
FIND按识别框寻找并接近一个没有坐标的目标
ZIPLINE滑索上索点,只由规划生成
TRIGGER路线终点,行进中识别指定画面,命中即导航成功

路线级参数及默认值:

参数默认值作用
arrival_timeout60000 ms无进展超时,设 0 关闭
sprint_threshold16.0剩余距离超过它才允许冲刺
snap_radius5.0NAVMESH 目标的吸附半径
zipfalse是否允许滑索
heading_sourcecharacter朝向取角色还是镜头,只接受 character、camera
enable_bootstrap_navmeshtrue起步时是否用 NavMesh 规划接入路线

路点上自带的值优先。

输出是成功或失败,重试交给 Pipeline 的 on_error。返回值的几种来源:

  • 参数为空、path 为空、或 NAVMESH 展开后为空:直接返回成功。
  • 参数解析失败、控制器后端不支持、同一个 tasker 已在导航:返回失败。
  • 导航跑完:到达终点且记过终点证据才算成功;失败时日志写明原因,共 20 多种,见失败原因。

状态机

入口

MapNavigateAction 解析参数,解析 interact_text 指向的 OCR 节点和滑索数据,然后交给 NaviController::Navigate。入口还打开 normalize_position_via_navmesh:规划在 NavMesh 底图上进行,实时定位要先用 NavMesh 数据包自带的分层仿射换到 NavMesh 底图的像素系,角色位置和规划结果才在同一个坐标系里。

Navigate 的第一件事是防止导航套导航(#5067「INTERACT 升级为异步交互,提示触发统一使用距离闸」)。进程里有一张带锁的登记表,记着哪些 tasker 正在导航。进入 Navigate 时先把当前 tasker 登记上去;表里已经有它,说明这个 tasker 正在导航,这次调用直接返回失败。导航结束时,不论成败,登记都会撤掉。

防的是这种情况:导航途中调起的交互子任务是一段 Pipeline,它的 next 可能又接回导航动作;子任务是同步执行的,于是同一条导航线程上会套出第二层导航,两层抢同一个控制器和定位器,而且可以一直套下去。按 tasker 登记以后,一个 tasker 同一时间只有一层导航,同一进程里多台设备各自导航,互不影响。

登记是 Navigate 的第一步,前面什么都不做,所以被拒的调用几乎没有开销:作者的 Pipeline 即使无限重试这个节点,每次也只是查一下表,不会去加载数据包或等定位。

之后依次:预加载 NavMesh 数据包,等首个定位(100 次 × 100 ms),展开 NAVMESH 段,构造会话,进入 Run。Run 是导航的主体:

  1. 执行 Bootstrap,选出起点,见启动与续接。它有几级兜底,总能选出一个起点。
  2. 预先跑一次提示识别。识别第一次运行要做初始化,耗时较长,趁角色还站着先跑掉,免得落在行走中的某一刻上。
  3. 启动后台扫描线程。
  4. 按 phase 一刻一刻执行,直到收到停止,或进入 Finished、Failed。
  5. 检查终点证据。路线尾部有提示待触发时,站定 150 ms 再执行,最多宽限 1500 ms,见采集与交互。

Phase

导航任何时候都只处于一个 phase(NaviPhase),主循环每一刻只执行当前 phase 的那一份工作。大部分时间处于 Navigate,沿路线走;轮到传送、滑索、FIND、TRIGGER 这类要等另一件事完成的点时,切到对应的等待 phase,事情办完再切回 Navigate。

Phase在做什么进入离开
Bootstrap判断角色在路线哪一段,接上起步规划导航开始选出起点后转 Navigate,整次导航只有这一次
Navigate沿路线走:跟踪路线、到点、watchdog、卡死恢复起步完成,或某个等待 phase 结束轮到要等待的点;路线走完;失败
WaitTransfer站着等机关、跳板把角色送走到达 TRANSFER 点定位稳定落到别处后回 Navigate;15 s 没动则失败
WaitZipline挂在滑索上按下上索落地后回 Navigate;没挂上也回 Navigate 换站位
WaitFind跟着画面里的目标框逼近目标轮到 FIND 点到达目标后回 Navigate;超过 60 s 或 48 步则失败
WaitTrigger站在 TRIGGER 点上等识别命中走到 TRIGGER 点时识别还没命中命中即成功;15 s 未命中则失败
Finished成功结束路线走完且有终点证据—
Failed失败结束任一失败原因成立—

回 Navigate 之前都会先看路线还有没有剩下的点;没有了就直接结束,有终点证据进 Finished,没有进 Failed。TRIGGER 只能做路线的最后一个点,带一个识别节点:行进途中每 300 ms 拿定位刚截的画面识别一次,命中就算导航成功;走到点上还没命中,才切到 WaitTrigger 原地等,见 TRIGGER。

等待 phase 单独分出来,是为了在等待期间关掉 Navigate 里的卡死检测、绕障、重规划和对交互提示的响应。这些判断的依据都一样:角色正按指令朝目标走,坐标却迟迟没有往目标推进,说明卡住或走偏了。等待期间角色并没有在按指令走路,坐标动或不动都说明不了这一点,照常判断只会误报,因此设计:

  • WaitTransfer、WaitZipline:角色被机关或滑索带着移动,坐标的变化来自机关和滑索。
  • WaitTrigger:角色站在点上等识别,坐标长时间不变是正常的,卡死检测却会判成卡死,接着去绕障。
  • WaitFind:角色跟着画面里的目标框移动,小地图只在写了 find_arrive 时用来量离终点的距离,这些判断拿不到连续的坐标。退出时进度、恢复状态和定位跟踪都从头开始。

等待 phase 的每一刻还会清掉顶墙计数和挡路设备的观测:等待结束时角色可能已经换了位置,旧记录不再适用。

每刻顺序

Navigate phase 每一刻(Tick)执行 TickNavigate,步骤按功能归成下面几段。顺序本身就是设计的一部分:先确认坐标可信,再决定往哪走,最后才判断是否卡住。

段步骤
准备记刻间隔;路线已空时检查终点证据;推进 PORTAL 过渡
重规划检查 1滑索恢复,或执行上一刻挂起的动态重规划;正在原地等待时直接返回
取位截图定位,顺带喂滞留 watchdog;路线末尾有 TRIGGER 时识别一次;定位失败进丢定位处理
定位恢复普通追踪恢复时,若丢定位期间见过黑屏就置位落水自救并返回
滞留判决原地超过 120 s 失败,见滞留
镜头对齐上一次站定动作之后的第一刻,把镜头转到角色朝向
跨层逃逸到点已回到不分层的底层、离逃逸目标 3.0 以内时退出
就地消费消费 ZONE、HEADING、FIND;未匹配的 ZONE 在这里按住前进并返回
重规划检查 2就地消费可能挂起重规划,这里再检查一次
路线跟踪RouteTracker::Update:判定圈、起步确认、经过即推进
走廊跟随NavRun 生成走廊和瞄准点,软重规划在这一步发生
走廊消费角色沿走廊已经越过的 RUN 点一并推进
停滞时钟更新普通时钟和硬时钟;跨层逃逸停滞 12 s 失败
脱出检查恢复中离卡死点 2.0 以上或换区即视为脱出
提示行进中的提示子任务、上索提示、上索站位推进
到达判定进圈则执行到点动作
Watchdog落水自救、偏离 watchdog
恢复阶梯设备、跳、虚拟禁区、物理脱困
驱动转向;前进保持闭环;提示点附近降速、冲刺条件
收尾无进展超时;休眠 33 ms

几处顺序的原因:

  • 软重规划在走廊消费和停滞时钟之前。偏离走廊触发的重规划生成的是一条新的、通常更长的走廊,停滞时钟随之清零;进度倒退触发的重规划不清零:它触发的原因正是角色沿走廊没有进展,重新生成的还是同一条走廊,挡路的是网格看不到的动态障碍;每次都清零的话,唯一能绕开障碍的卡死恢复会被一直推迟。
  • 设备探测从停滞第一刻就开始喂。等恢复阶梯跑到设备那一级时,探测结果已经存好,读取不再花时间。
  • 镜头对齐排在就地消费之前,紧跟着的 HEADING 转身从对齐后的镜头起算。
  • TRIGGER 识别排在丢定位处理之前。要识别的画面(剧情、对话框、界面)常常盖住小地图,出现的那一刻定位正好失败;先识别,命中就直接结束导航。

失败原因

所有失败走同一个出口:停下所有移动,清掉路线跟踪和恢复状态,切到 Failed,再打一条错误日志,带上原因、当前距离、航向误差和停滞时长。

原因触发时限
localization_lost_timeout连续丢定位30000 ms
localization_thrash全局重捕获连续成功 5 次—
crosstier_escape_stalled跨层逃逸中硬时钟停滞12000 ms
dwell_watchdog原地滞留,ZIPLINE 点改为退索120000 ms
river_fall_recovery_timeout落水自救中硬时钟停滞30000 ms
offroute_wedge_timeout连续 RUN 段偏离走廊12000 ms
dynamic_recovery_timeout恢复阶梯中硬时钟停滞,ZIPLINE 点改为退索30000 ms
no_progress_timeout普通时钟停滞arrival_timeout,默认 60000 ms
zipline_recovery_localization_timeout滑索恢复期间等不到稳定定位6000 ms
zipline_recovery_route_unavailable滑索恢复后剩余路线展开失败—
route_tail_without_final_success路线尾部被消费,却没有终点证据—
portal_transit_timeout传送门过渡未完成12000 ms
transfer_wait_timeoutTRANSFER 一直取不到位,或没有外部位移15000 ms
heading_turn_failedHEADING 的转向指令发不出去—
dig_context_missing、dig_dispatch_failedDIG 缺执行上下文、子任务派发失败—
find_*FIND 的 7 种失败,见 FIND60 s 或 48 步
trigger_*TRIGGER 的 3 种失败,见 TRIGGER15000 ms

每个时限背后都是一类曾经无限卡住的情形,具体来源在对应章节里。

路线与动作

解析

数组写法按元素类型识别:开头两个数是坐标,布尔值是 strict,数组是动作列表,字符串是区域名。字符串长得像动作名或关键字时拒绝,避免把拼错的动作当成区域。

对象写法的键有别名,按优先级取第一个出现的:

字段别名优先级
区域zone_id > zoneId > zone > map_name > mapName
严判strict > strict_arrival > strictArrival
角度angle > heading > yaw

其他规则:

  • 同一坐标写多个动作时展开为多个路点;其中有非 RUN 动作时丢弃 RUN。动作列表为空时补 RUN。
  • NAVMESH 强制严判。规划出来的这一腿按严判的判定圈和前视收尾,与作者有没有写 strict_arrival 无关。
  • interact_text 这类文本列表遇到空值直接报错。空文本在识别侧能匹配任何提示,正好是这些字段要避免的见提示就按。
  • 路线级的交互、FIND 默认值在路点解析完之后下发,路点自带的值优先。FIND 参数冲突只在真正喂到某个 FIND 点上时才报错。
  • route_required(#5197)把路线切成分段,滑索规划按分段进行。

enable_bootstrap_navmesh 是给叠层地区准备的开关:楼层高度(tier floor)落在空中时,起步规划会绕出几百格;关掉它,Bootstrap 落到现成的两档兜底(串行续接、直接朝路线头走),照录制点走。

动作特性(#5586)

每种动作的静态策略写在 ActionTraits 表里,一种动作一行:

动作严判末端纠正接近时走路起步即走路纠正前走路分段边界判定圈下限
RUN✓✓
SPRINT✓✓
JUMP✓✓
FIGHT✓✓
INTERACT✓✓
TRANSFER✓✓
PORTAL✓4.0
HEADING✓
NAVMESH✓✓✓
ZONE
COLLECT✓✓
DIG✓✓✓✓✓✓
FIND✓✓
ZIPLINE✓✓
TRIGGER✓✓✓
  • 严判:到点必须停准。路点上的 strict 与这一列取或,路线只能把非严判的动作改严。
  • 末端纠正:进圈后再纠正到位才验收,只对路线上明写了 strict 的点生效,见末端纠正。
  • 分段边界:开启滑索时,全局规划在这些点处分段,见滑索。
  • FIND 不设严判。FIND 的锚点只是开始找的位置,收尾由视觉伺服完成。
  • TRIGGER 设成分段边界,开启滑索时这个点原样保留;不在边界上的点可能被滑索规划换成普通规划点,trigger_node 也就丢了。

此处由表派生出几个谓词,后文将会反复用到:

谓词定义
要求严判带坐标,且路点写了 strict 或动作本身严判
到点纠正带坐标,作者明写 strict,且动作带末端纠正
连续 RUNRUN 且不严判
提示驱动COLLECT,或带 interact_text 的 INTERACT

此前这些判定散落在 RequiresStrictArrival、SettlesAtArrival、IsIntrinsicRouteBoundary 等函数里。改为表之后,表以外含 ActionType:: 的判定从 33 行减到 15 行。

路径搜索

NAVMESH 路点在导航开始前规划,结果展开为一串带净空信息的 RUN 点。导航中的起步接入、动态重规划、卡死绕障、跨层逃逸、滑索的步行腿也调用同一个规划器。

规划器的实现原理十分复杂,因此单独成文,详见 NavMesh:终末地路径规划,按执行顺序讲数据包、预烘场、垂直可达判据、窗口分档、两段搜索、视线判据、后处理和诊断。本节只留导航侧要用到的部分:规划的大致流程、交回导航的结果,以及展开器自己的兜底。

规划概览

步做法详见
数据主包(三角网、离网连接、面属性、预烘体素格图)98 MB,旁包 23 MB,OccluderPack 20 MB;旁包与主包必须配对数据包
吸附起终点吸到最近的可走三角,半径 8,吸不上放到 16吸附
前置检查与选类端点不能落在作者禁区里,离核心格不超过 8;再选起点所在的类,窗内搜索只在这个类里进行;终点在台沿下面的另一类时,可以从台沿跳下去换类续规划前置检查与选类
开窗以起终点为焦点开椭圆小窗,能证明结果与整类窗口逐位相同才采信,否则把允许的代价翻倍再开窗口分档
基线原始可走格上的纯长度最短路,判定可达基线
拓扑层净空掩膜上的逐格 A*,越窄单价越高,定走哪条通道拓扑层
几何层拓扑层选中的走廊里跑 Lazy Theta*,出紧绷的折线几何层
后处理取直、端点拼接、拐角抬升,再取每点的净空和高度,把台沿下落的落点挪离台沿,挑出航点后处理

水平方向的单位是底图像素,竖直方向是米。按滑索数据里的仿射推算,武陵 1 像素约 1.33 m,四号谷地约 1.42 m;体素边长 0.25 像素,约 0.33 到 0.36 m。垂直可达判据里可直接迈上的台阶高 0.5,跨窄凸起或浅坑允许抬升 1.25,角色没有摔落伤害,向下落差不设上限;台沿下落另查碰撞体,见垂直可达判据。

拓扑层路径与最终路点

黄色粗线为拓扑层的逐格路径,红色为最终路点。

  • 左图在四号谷地,拓扑层沿道路中轴走,几何层在路面内取直,502 个拓扑格最后剩 21 个路点。规划共 161 ms:开窗 65 ms,拓扑层 51 ms,几何层 24 ms。
  • 右图在武陵,路线经过水面上的一段折线桥。桥面窄,几何层贴着拓扑层走,51 个路点,共 116 ms。

白色虚线是起终点连线,右图的连线横穿水面,路线经折线桥绕过去。

规划器的几次大改:#3084 是三角网 A*;#4460 改为每次规划在窗口内现场体素化;#5026 把格图挪到随包预烘;#5405 改为拓扑与几何两段搜索;#5450 加入旁包和窗口分档。见 NavMesh 一文的演进。

规划结果

规划器交回一条折线,每点带所在格的净空和所在面的高度,另附一组航点下标和台沿下落的落点,展开器照抄。面高写进路点,运行中重规划时,起点楼层取角色正在走的那段规划线上的面高(两端路点之间按投影插值)。落点改成严判点,带上下层面高和起跳点;角色沿下落方向越过它才算到,越过之前起点楼层仍按台上算。净空随路点交给跟随层,用于收紧判定圈和限制切弯,见到达判定、走廊跟随。

NAVMESH 路点可以用 target_deck_y 声明终点落在哪一层重叠面上(#4836「NAVMESH 航点支持声明所在重叠面」),高度匹配容差 2。它决定终点停在哪个 span,也让起点先只吸附种子格里有终点那一类面的三角;终点的三角吸附仍用楼层高度。刚从滑索落到架子上时,导航另传架子高度作为起点声明面,起点按它吸附(#6205「滑索落地重规划起点层与同步规划停步计时」),见 NavMesh 一文的起点所在的面。

导航时规划器只交回路点或错误文本,日志里留下窗口行或失败行;窗口、分段耗时和各阶段点列只有 WebUI 的路线预览会收集,见诊断。

兜底

一个 NAVMESH 路点的展开顺序:

  1. 规划。成功则跳到第 6 步。
  2. 起点恢复(#4188「起点位于 off-mesh 时先盲走到最近网格点」):在 32 以内找最近的网格点,有起点声明面时按声明面选层,没有就按起点楼层;距离超过 0.5 就先插一个 RUN 点盲走过去,再从那里重新规划。
  3. 禁区:端点落在作者禁区里时直接失败,后面的兜底一律跳过。禁区是作者画死的约束,后面每一条兜底都不看可走面,交给它们就相当于让角色从禁区里穿过去。
  4. 滑索桥接:开启滑索时,步行不通先试滑索,见滑索。连续滑索可能分别接上起终两侧的可走面,步行不连通时这一腿仍可能到达;滑索结果是完整的规划,优先于后面的二维盲走。
  5. 盲走探针(#3505「NAVMESH 目标不可达时盲走补全最后一段」):起点到目标规划不通时,改为规划到目标附近一个走得到的点,最后一段从那里直线走到目标。这段直线没有规划路线可跟,所以称为盲走。候选点(探针)取在目标到起点的连线上:第一个就是目标本身,之后每个比上一个离目标远 2,最远离目标 42,起点更近时到起点为止。探针只用来挑规划的终点,全部在出发前算完。每个探针先吸附到 12 以内最近的可走格,吸附不上就跳过;吸附上了就从起点规划到吸附点,第一个规划成功的采用。角色先沿这条路线走到吸附点,再按追加的一个 RUN 点直线走到目标。盲走不能超过 30,超过则整个路点失败;吸附最多挪 12,离目标超过 42 的探针剩下的盲走一定超过 30,所以探针最远取到 42。总预算 8000 ms。声明了 target_deck_y 时禁用:盲走兜底是二维的,走到目标正上方也算到,用它兜底就把错层吞了回去。
  6. 开启滑索时,步行结果会与滑索比较。
  7. 输出路点:终点严判,中间拐点不严判,每点带净空和面高;台沿下落的落点改为严判,target_deck_y 取下层面高,另记起跳点。作者声明的 target_deck_y 写在最后一个点上。重规划的锚点就是这里生成的点,声明落在点上才能在重规划之后保留下来。

保留作者路线(#5205「避免盲走兜底跳过作者路线」):开启滑索时,作者写的移动点变成可选提示,每个分段先从起点到终点整段规划一次,让步行和滑索在同一个比较里竞争。这次整段规划不允许盲走:全局规划只有完整抵达终点才能跳过作者提示,盲走只是逐点执行时的局部兜底,走到可走面边缘再直冲终点算不上完整路线。失败就逐点按原动作重放作者提示。此前一次全局规划接受了盲走兜底,先走到离终点约 27.64 的可走面边缘再盲走过去,4 个作者提示点全被跳过,角色走进了水里。

手写 RUN 段:作者手写的 RUN 折线按原样直走(#3929「移除对预制菜的神秘判定」)。只剩一个手写的尾点时也直着走过去(#4947「尾部裸坐标点不再交给 A* 贴回网格」),交给 A* 会为了贴回网格绕远路,无视作者的预期路线。这两条在运行期的走廊生成里判断。

展开失败:整条路线展开失败时,按角色位置切掉已走过的前缀再展开一次(#5247),续接点的判法见启动与续接。

缓存与并发

  • 数据包加载放进单线程任务队列,按「路径#区」缓存在进程级的表里,不淘汰。导航在等首个定位之前就把加载排进队列。各区的条目共用同一份格图段字节和同一份旁包(#6219「规划提速并共用旁包与格图段」)。
  • 引擎内按区缓存清洗后的数据,只留最近两个,通常是一层底层加一层分层。
  • 同一引擎上的规划持锁串行。
  • 展开阶段的规划可以取消。运行期的重规划不能取消,在当前这一刻里同步执行,人停在原地等它算完:规划前先松开前进键,规划耗时从两个停滞时钟和偏离 watchdog 的计时里扣掉(#6205「滑索落地重规划起点层与同步规划停步计时」)。更早的 #6109 先给动态覆盖的规划加上了偏离 watchdog 的扣除,起因是 #6104 的失败:两次规划共 12.6 s 全算成了偏离时间,见偏离。

启动与续接(Bootstrap)

角色开始导航时可能已经站在路线中段,比如 Pipeline 重试,或上一个节点已经走了一部分。Bootstrap 要回答两个问题:角色现在属于路线的哪一段,以及从当前位置怎么走过去。

起步流程

mermaid
flowchart LR
    subgraph G1a [起步 A*]
        direction TB
        B["BeginNavigation<br/>清空状态与禁区,置位镜头对齐"] --> E{"线路开启<br/>起步 A*?"}
        E -->|是| C["续接候选<br/>定扫描起点"]
        C --> S["可达锚点扫描<br/>第一个规划得通的点"]
        S --> O["动态覆盖<br/>对锚点再规划一次"]
        O -->|成功| N1(["Navigate"])
        O -->|失败| X1(["转串行续接"])
        E -->|否| X1
    end
    subgraph G1b [串行续接]
        direction TB
        R{"续接候选在<br/>路线中段?"} -->|是| Q["从候选点接着走"] --> N2(["Navigate"])
        R -->|否| H["从路线头开始"] --> N2
    end
    G1a --> G1b

起步 A* 由线路的 enable_bootstrap_navmesh 控制。两条取锚点的路子最后都要调 PlanNavmeshRoute,关掉开关就等于起步不做规划,直接落到串行续接的两档兜底。开关只看线路声明,代码不做自动判断:自动判断”网格不对劲”容易误伤正常的路线。

续接候选

续接候选决定角色属于哪一段。先在路线的线段上找投影。设一条线段从路点 (段首)连到下一个路点 (段尾),角色位置为 ,从 向直线 作垂线,垂足记作 :

  • 投影参数 : 在 上走过的比例。点积 , 是两个向量的夹角。除以一次 得到 ,就是 离 的距离( 在 之前时为负);再除以一次 ,换算成占段长的比例。 时 就是 , 时是 , 是中点; 时垂足落在 之前, 时落在 之后。
  • 离线距离:, 到这条线段的垂直距离。
  • 离段首、离段尾: 和 , 到两个端点的距离。

这些距离都是底图平面上两点间的直线距离。

判定规则:

  • 每对相邻、同区的路点构成一条线段,不限动作类型;两点重合的线段跳过。(垂足落在两端点之间)且离线距离 的线段为候选。
  • 认段首是从 接着走,人要先回到 ;认段尾是从 接着走, 算已经走过。 认段首; 时, 才认段首;其余认段尾。
  • 中间那档在 正好落在线段上时可以换算成 的分界。记段长 ,这时 ,,代入 得 。段长 10 时分界在 0.475;段长短于 1.67 时 ,这一档用不上,分界就是 0.35。 离线越远, 和 的差越小,分界还会再往段首移。
  • 多段同时成为候选时取离线距离最小的一段,相等时归前面的段。

没有线段成为候选时(离所有线段都超过 3.0,或垂足都落在端点外),改看路点本身。只看区兼容、离 不超过 18 的路点,下面「候选」指这些点,距离指 到路点的距离:

  1. 有站在点上(距离 ≤ 1.5)的,取路线顺序里最早的一个。环形路线的首尾常常重合,取最早的才不会把路线头当成路线尾。
  2. 记离 最近的候选为 , 之前各候选到 的最小距离为 。 之前没有候选,或 ,取 。
  3. 其余情况从 往前回退。这一步的距离改为前驱到 的距离:前驱要带坐标、区兼容、离 不超过 18,碰到 ZONE、HEADING 这类无坐标标记就停。
续接候选的两条规则

左图是投影规则,色块表示角色站在那里时从哪个点接着走,白色区域交给离线规则。右图是离线规则的示意:最近点 w3 离角色 5.7,更早的点里最近的 w0 是 8.5,,于是从 w3 往回退;每个前驱都在 w3 的 18 以内,一直退到 w0。

回退时每个前驱都和最近点比距离,链长因此有界。无坐标的 ZONE、HEADING 标记截断回退;最近点之前只有这类标记时,从路线头开始。

这条规则来自 #5060「bootstrap 续接不再只按最近点判定」。此前角色不在线上时只按最近点判断归属,零点几的距离差就能让一整簇路点被跳过。现在跳过路点需要证据,证据不足则从更早的点接着走。

可达锚点

续接候选给出扫描的起点,之后往后找第一个能规划过去的路点:

  • 跳过区不兼容的路点;遇到声明了别的区的 ZONE 就停;遇到没有坐标的必经锚点(比如 HEADING)就停。
  • 对候选跑一次规划:终点钉在候选声明的面上;起点楼层取角色正在走的那段规划线的面高,刚从滑索落到架子上时再带上架子高度作为起点声明面。够不到终点那张面的候选不会被选中。第一个规划成功的就是锚点,扫描到此为止:再往后比较代价挑更近的,等于在归属判定之后又做一次就近吞点。
  • 必经的语义锚点规划失败时停止扫描。
  • 最多规划 8 次。每个不可达的候选都要跑满一次搜索,单次就是秒级,曾有一次连续 34 个候选不可达,额外卡了约 70 s;预算用尽就放弃这轮扫描,交给下一级回退。
  • 扫描无果时取续接候选之后的第一个锚点。

动态覆盖

选中锚点后,对它再规划一次,把规划出的点插到锚点之前,这一步叫动态覆盖(overlay):

  1. 松开前进键,规划耗时从偏离 watchdog 和两个停滞时钟里扣掉(#6109、#6205)。
  2. 规划时带上当前的运行期禁区,以及与扫描相同的起点楼层和起点声明面。
  3. 规划点以非严判 RUN 插入,不含终点,锚点本身保留原动作。
  4. 规划点为空、锚点又在判定圈外时,补一个领头节点,避免路线只剩一个点。
  5. 续接位置变了再重置硬性进度时钟。
  6. 跟随状态重置,起步确认清零。规划点为空且角色已在判定圈内时,直接记为已起步。

锚点因此会被规划两次:扫描一次,覆盖一次。#6104 的日志里分别是 118 ms 和 117 ms。

覆盖失败时,续接候选在路线中段就从它接着走;再不行就从路线头开始。

导航中途的重规划走同一个覆盖函数。单次重规划失败不终止导航,退回当前路线继续走,持续没有进展时由卡死恢复的各级超时收口。路线没有被替换,所以只清跟随进度,起步确认保持原状。

镜头对齐

#5917「起步前对齐镜头与角色朝向」:导航的反馈量默认是小地图箭头给出的角色朝向(heading_source 取 character),按前进键时角色却沿镜头方向走,两者不一致时控制环的反馈和实际走向对不上。因此先在起步前把镜头转到导航使用的朝向,按 W 时角色就沿控制环看到的方向走。对齐时只转镜头,前进键保持松开。镜头与导航朝向的差值小于 10° 时跳过,转完等 300 ms 再取一次位置。heading_source 取 camera 时,导航朝向和镜头朝向是同一帧读出的同一个值,差值为 0,对齐每次都跳过。

小地图上的角色箭头与视野扇形:对齐前箭头 40°、镜头 110°,相差 70°;对齐后镜头转到 40°;取 camera 时导航朝向就是镜头朝向

小地图中心的三角形是角色箭头,白色扇形是镜头的视野,扇形中线就是镜头朝向,角度以正上方为 0°、顺时针增大。左图取 character,控制环读的是箭头 40°,按 W 时角色沿镜头 110° 走,两者差 70°;扇形中线落在绿色虚线的 ±10° 以内时跳过对齐。中图把镜头转 −70°,扇形中线与箭头重合,角色原地站着。右图取 camera,导航朝向直接取扇形中线,差值恒为 0。

导航开始时置位一次(ArmCameraAlign),之后每次站定去做别的事(传送、过图、挖掘、交互、FIND、滑索、严判到点、JUMP、FIGHT)都重新置位,在下一个 Navigate 刻消费:做这些事的过程中镜头可能被挪走。

刹车、卡住重发和脱困不置位。对齐排在就地消费的语义动作之前,紧随其后的 HEADING 转身也从对齐后的镜头起算。

到达判定

判定圈

情形半径条件
常规3.25 = 2.5 + 0.5 + 0.25前视半径 + 余量 + 定位量化
严判2.25 = 2.0 + 0.25路点写了 strict
采集、交互提示点min(圈, 1.5)硬性无进展超过 6000 ms 后放回常规值
走廊净空收紧min(圈, max(净空, 1.0))非严判、且路点带净空(规划生成的点)
PORTAL至少 4.0提交距离
滑索上索点1.5,按键落空后 1.0未上索、硬性无进展不超过 6000 ms
自动采集路线上的判定圈

左图是自动采集路线 AutoCollectRoute10Goto3 的 8 个采集点,相邻间距 2.5 到 8.2。红色虚线是常规的 3.25 圈,一个圈能盖住好几个相邻点;绿色是收紧后的 1.5 圈,点与点基本分开。右图是一个采集点周围的几道带:判定圈之外,还有接近时切走路(3.0 进入、4.5 退出)和禁冲刺(8.0)两道,见冲刺与走路。

提示点收紧到 1.5 来自 #5030「增强自动采集,收紧判定圈并接近时切走路」。收紧之后真站不上去时,硬性无进展超过 6000 ms 就放回常规值,避免多出一种卡死。

规划生成的点按走廊净空收紧。通道比判定圈还窄时,常规的判定圈会让角色提前弃点、直奔下一点,抄出一条撞墙的弦。

到点处理

进圈之后:

  1. 还没确认起步时不推进,见下一节。
  2. 严判点进入原始判定圈(2.25,不受上表的放宽和收紧影响)时,先做末端纠正。滑索和传送门有各自的站位与提交距离,判定圈被放宽或收紧的那几种情况要的正是宽松的判定,这些都不做纠正。
  3. 严判点仍在移动时刹停,再等 150 ms。PORTAL 也走这一步,之后再按下前进走进传送门。
  4. 按动作分派:TRANSFER、PORTAL、ZIPLINE、DIG、FIND、INTERACT、SPRINT、JUMP、FIGHT 各有处理函数;RUN、COLLECT 直接推进。HEADING、ZONE 没有坐标,NAVMESH 展开后换成了规划点,三者实际到不了这一步。

起步确认

起步确认最早来自 #2162,对应 startup_motion_confirmed:角色位移达到 0.8 或换区之后才算起步,在此之前三条推进路径都不生效:到点推进、经过即推进、走廊消费(#4164「走廊路点消费对齐起步门控」)。没有这一步,起点附近的路点会在角色动起来之前就被消费掉,转向直接对准更远的点。

跟随状态每次重置,起步确认都会清零:串行续接、每次动态覆盖、作者路线重规划、挖掘之后、异步交互之后,以及滑索动作里的三处。例外有两个:动态覆盖为空且角色已在判定圈内时直接确认;重规划失败退回当前路线时保留原来的确认状态。

经过即推进

角色常常从路点旁边擦过,没有进圈。路线跟踪器在两个连续 RUN 点之间维护一条线段的投影状态:

cpp
const bool projection_growing =
    state->best_projection_on_segment >= 0.55 && segment->clamped_projection + 0.05 >= state->best_projection_on_segment;
const bool route_fact_passed =
    segment->next_distance + position_quantum < segment->current_distance || segment->turn_back_yaw >= 110.0;
const bool hard_pass_evidence = route_fact_passed || segment->raw_projection >= 1.05 || near_next_waypoint;
const bool should_latch = !state->passed_waypoint_latched && segment->raw_projection >= 0.40
    && (((cross <= 1.5) && hard_pass_evidence) || ((cross <= 3.0) && projection_growing && hard_pass_evidence));
  • 前提:当前点和下一点都是非严判 RUN,且同区。
  • 投影:原始投影参数 ≥ 0.40。
  • 横向偏差:≤ 1.5;或 ≤ 3.0 且投影还在增长(历史最好值 ≥ 0.55,当前值不比它少 0.05 以上)。
  • 硬证据,任一条:离下一点比离当前点近 0.25 以上(一个定位量化步);回头看当前点的角度 ≥ 110°;原始投影 ≥ 1.05;已进入下一点的判定圈。

满足以上条件先标记为已经过,标记之后还要夹紧投影 ≥ 0.90、原始投影 ≥ 1.05、或进入下一点的判定圈,三者之一才真正推进。一刻可以连推好几个点。历史最好投影只在横向偏差 ≤ 3.0 时更新;还没标记且横向偏差超过 3.0 时,这条线段的状态清零。

走廊消费

规划生成的点可能很密,转角处又常被抄近路。走廊跟随层沿走廊算出角色的弧长,路点的走廊弧长不超过角色弧长 + 0.25 的,一并视为经过。扫描到锚点或第一个非连续 RUN 点为止。这一机制来自 #3458「navmesh dynamic detour recovery overlay」。

#6104 日志 18.11 s 到 19.58 s 的逐刻回放,w24 在 18.95 s 由走廊消费推进

动图逐刻回放 #6104 日志 06:15 那一趟的 18.11 s 到 19.58 s,相邻路点相距 0.8 到 3 像素。右侧三行是当前路点的三项推进判定:到点比的是离路点的距离和判定圈;经过即推进要求角色在当前路点到下一点这条边上的投影参数 t 至少 0.40;走廊消费比的是路点和垂足 F 从走廊起点 A 量起的弧长。哪一项推进了路点,哪一行就高亮。到点推进的那几刻,推进完这一刻的处理就结束了,日志里没有记下走廊投影,第三行留空。

18.30 s 推进到 w24 后走廊重建,角色还在 w24 之前,于是把当时的位置 A 插在折线开头。之后角色从 w24 内侧擦过,一直在判定圈 1.75 之外。到 18.95 s,离 w24 2.09,t 是 0.375;从 A 沿走廊量弧长,w24 是 2.87,在 s(F) + 0.25 = 4.18 以内,w24 被消费;w25 是 5.69,扫描停在 w25。

角色的投影被夹在走廊窗口之外时要小心:投影在窗口之前,一律不消费;投影在窗口之后、横向偏差超过 2.2,也不消费。#4630「走廊消费在投影夹到末点时不再整段吞点」修复的正是投影夹到末点时一次吞掉整段路点的问题,对应 #4610「环境监测旧庭树失败」和 #4625「环境监测任务有两个失败了」。

投影夹在走廊窗口之前和窗口之后两种情况

上图是投影夹在窗口外的两种情况,窗口指从游标所在的边到末边的这一段。角色在窗口之前时,垂足夹到游标边的起点,那里的路点按弧长会被算作经过,这一刻一律跳过消费。角色在窗口末端之外时,垂足夹到锚点,锚点之前的路点会一次全部算作经过;横偏在 2.2 以内时角色就在锚点附近,照常消费,超过 2.2 时跳过。

走廊消费成功后,恢复阶梯和全局重捕获计数清零,再跑一次路线跟踪。

末端纠正

#5198「严判点到达后减速并纠正过冲」作用于路线上明写了 strict、且动作带末端纠正特性的点:RUN、SPRINT、JUMP、FIGHT、TRANSFER、NAVMESH、DIG。对这些点,判定圈是纠正的触发条件:进圈只说明剩下的残差值得走回去,纠正到位才验收。纠正时前进键一直按着,松开前进键再拖视角只会转镜头,角色仍朝原来的方向。

接近时先减速:严判点在判定圈外 6.0 起禁冲刺,接近段切走路。走路让滑行距离减半,到点后要走回去的那段也短一半。进圈后的循环:

  1. 取一帧干净的定位,定位器沿用上一次输出或黑屏的帧跳过,最多等 12 帧 × 120 ms。等不到就刹停,按原判定圈收下。
  2. 本步走了 0.4 以上才更新朝向和速度:朝向取本步的位移方向,速度取位移除以真实经过的时间。转身的那段时间角色也在走,按睡眠时长算会把速度估高。
  3. 残差 ≤ 0.5 即验收,日志 “Strict arrival verified.”。
  4. 满 4 次纠正、连续 2 步走不到 0.4、或用满 6000 ms,按原判定圈收下,日志 “Strict arrival settle gave up, accepting on band.”。
  5. 转向目标方向,前进键一直按着,步长取 ,按实测速度换算为按住时长,夹在 120 到 450 ms。第一步还不知道速度,固定 200 ms。

步长下限 0.6 来自小地图定位的静止处理:两次输出相距不到 0.40 时,定位器沿用上一次的坐标(见 MapLocator),比这更短的一步分不清是走了还是没走,所以步长不低于它。连续两步位移都不到 0.4,说明按键根本没让角色挪动,再纠正也只是原地重复。

纠正本身从不判失败,最坏情况和加纠正之前一样按判定圈收下。

一次末端纠正

左图取自 #6104 日志里的一次严判到点。灰线是走廊跟随,9.48 s 切走路;进圈后第 1 步残差 1.26,开环按 200 ms,实际走向和目标方向差了 24°;第 2 步残差 0.64,按第 1 步实测的速度算出 170 ms;之后残差 0.37,验收。右图是按住时长与残差的关系,实测走路速度约 3.7 像素/s。

终点

路线尾部被消费时,还要确认角色真的到了终点,任一条成立即可:

  • 终点在到达时记过账,日志 “Final arrival evidence recorded.”。
  • 角色当前与终点同区,且在终点验收带以内:严判 2.0,否则 2.5。

都不成立时以 route_tail_without_final_success 失败,日志 “Route tail consumed without final success evidence.”。经过即推进和走廊消费都可能把终点前的点吞掉,这条检查防止路线在没到终点时报成功。

走廊跟随

走廊是角色从当前位置走到锚点(下一个需要停准的点)所沿的一条路。它的中心线是一条折线,途中的路点是折线的顶点;NAVMESH 规划出的路线还给每个顶点记了宽度,即该处可通行区域的半宽,称为净空,下图折线两侧的灰色范围就是它。路点本身是一串离散的点,按路点跟随时,角色到了一个点才转向下一个,每次换点航向都会跳变。连成走廊之后路是连续的,角色每一刻都能在上面量出三样东西:自己对应路上的哪个位置(投影点),离中心线多远(横向偏差),沿路往前一段朝哪个方向(前视点)。转向追的是这个方向,横向偏差太大时重新生成走廊。

当前路点是带坐标的 RUN 时,由 NavRunController 跟随。它在当前点和锚点之间建一条折线作为走廊,每刻把角色投影上去,沿折线往前量出前视点,算出这一刻要追的航向。这与路径跟踪里的 pure pursuit⁠[2]同一思路:每刻追路径前方一段距离处的点。路点级的 RouteTracker 同时在跑,它给出的 route_heading 指向当前路点本身,走廊建不起来的刻退回用它。

走廊的起点在建走廊时定下:路线自己的点从当前路点开始,再按角色位置裁剪或在开头补上角色位置;A* 走廊直接从角色位置出发,规则见走廊来源。净空只有 NAVMESH 展开出的顶点才有,手写的点没有,拐角用它限制切角。走廊建好后一直沿用,直到下一次作废重建。

走廊折线、净空范围,以及投影点、横向偏差、前视点、corridor_heading、route_heading、straight_to_anchor 的位置关系

黄线是走廊折线:从走廊起点经过当前路点和途中的连续 RUN 点,一直到锚点;灰色是各顶点净空连成的范围。这条走廊是在上一个路点推进时建的,起点就是当时角色所在的位置,之后角色偏到了折线一侧。角色向折线做垂线,垂足是投影点,垂线长是横向偏差;从投影点沿折线量出前视距离,得到前视点,corridor_heading 从角色指向它(精确的取法见瞄准点)。route_heading 指向当前路点,straight_to_anchor 是角色到锚点的直线距离。

走廊每刻的输出被几处读取:

输出读取方
corridor_heading转向:有走廊航向就追它
upcoming_turn_deg冲刺条件:前方 8.0 内的最大转角
remaining_to_anchor冲刺条件:沿走廊到锚点的剩余距离
straight_to_anchor无进展 watchdog:到锚点的直线距离
cross_track、投影弧长软重规划;走廊消费

锚点

严判 RUN 以自身为锚点。连续 RUN 从当前点往后找,第一个带坐标、且不是连续 RUN 的点就是锚点,即下一个语义动作点或严判点;途中遇到别区的 ZONE 声明、或 HEADING 这类没坐标的必需语义点就停,取已经扫过的最后一个带坐标的点。

连续 RUN、严判 RUN、途中遇到没坐标的必需语义点三种情况下锚点的位置

每行是路线上的一段路点,黄色底是走廊覆盖的路点。上:当前点是连续 RUN,往后扫到第一个语义动作点(这里是采集点)为锚点。中:严判 RUN 的锚点就是它自己,走廊只到这一个点。下:扫到 HEADING 这类没坐标的必需语义点就停,锚点取它前面最后一个带坐标的点。

严判 RUN 要求精确到点,锚点只能是它自己;连续 RUN 可以越过普通路点一直看到下一个语义锚点,所以它的走廊一直延伸到下一个需要停准的地方,中间的普通 RUN 点只是折线顶点。锚点下标或 zone 变化时,整条走廊作废(invalidate),软重规划次数也一起清零。路点推进、动态覆盖、路线重规划、全局重捕获之后会置 nav_run_dirty,下一刻同样作废重建。

走廊来源

buildPlan 按顺序取:

  1. 路线自己的点。从当前点连到锚点,带上每个点的走廊半宽(NAVMESH 展开的点有,手写的点没有)。取到 2 个点以上时直接用。中途遇到没坐标的点、或路线先结束了,就返回空,不信一条够不到锚点的线。
  2. 末端单个裸坐标。当前点就是锚点、折线只有这一个点时,若它是连续 RUN、没有净空、离角色超过 0.25,就从角色直线连过去(#4947)。作者手写的裸坐标按直着走过去处理,交给 A* 会为了贴回网格绕远路;NAVMESH 展开出的点带净空,末点又是严判,不走这一条。
  3. A*。以上都不成立时从角色位置规划到锚点,带上目标层高和当前的虚拟禁区。严判 RUN 以自身为锚点,折线只有一个点,所以它的走廊总是这条 A*。

前两种以 literal = true 提交,第三种为 false。

用路线自己的点之前,要先裁到角色所在的位置。折线总是从会话的当前路点开始,规划生成的点判定圈按净空收紧,常常只有一个像素左右;角色从路点旁一个像素处擦过时,折线起点就落在角色身后,这时再把角色位置接到折线开头,走廊会让角色掉头去走已经走过的地面。裁剪的规则:

  • 逐条边往前丢:角色在这条边上的投影参数 ( 的定义见续接候选, 表示垂足已越过边的终点),且离边的终点不超过 4.0,才丢掉这条边的起点。
  • 遇到转角 ≥ 90° 的顶点就停。过了直角,出边会把角色往来时的方向带,角色还没绕过拐角,在入边上的垂足就已经跑出了入边末端;在这里裁掉,跟随层拿到的是一条直接横穿拐角的线。
  • 4.0 的上限防定位漂移。定位偶尔会在几秒内报出几百像素外的位置,不设上限的话,这种位置会被当成已经越过全部顶点,剩下的折线被整段删掉。
  • 最后角色离当前边的垂足在 2.5 以内、且垂足落在边的内部,才把起点替换成垂足。

裁不上(角色还在折线起点之前,或离得远)时,若角色离起点超过 0.25,就把角色当前位置插到折线开头,净空复制首项。

路线自己的点裁到角色位置的三种情况:从当前路点旁擦过、已越过几个顶点、角色还在折线起点之前

w0 是会话的当前路点。(a) 角色已经从 w0 旁擦过,垂足落在 w0→w1 内部,起点换成垂足;红虚线是不裁、直接把角色接到 w0 的走廊,开头一段要掉头。(b) 角色在 w0→w1、w1→w2 上的投影都越过了边的终点,到 w1、w2 的距离 3.4、1.7 都不超过 4.0,两条边的起点依次丢掉;在 w2→w3 上垂足落在边内、离角色不到 2.5,起点换成垂足。(c) 角色在首边上的投影参数 ,一个顶点都不裁,角色位置插到开头。

走廊建失败后,同一个锚点 3000 ms 内不再重试。失败的规划没有留下可作废的走廊,不设冷却的话下一刻马上又会重建;失败的规划又是最慢的一种,会走完整个窗口分档才放弃。每刻重试的开销远高于这段时间退回路点级转向的代价。

投影

cpp
const double raw_t = ((position.x - a.x) * dx + (position.y - a.y) * dy) / len_sq;
const double t = std::clamp(raw_t, 0.0, 1.0);
const double px = a.x + t * dx;
const double py = a.y + t * dy;
const double cross = std::hypot(position.x - px, position.y - py);
if (!best || cross < best->cross_track) {

投影找的是角色在走廊上对应的位置。每条边的算法和续接候选里的投影参数相同:边从 到 ,角色在 ,

把 限制在 里:小于 取 ,大于 取 。垂足落在边外时 被夹到 0 或 1, 取较近的端点,所以 总是这条边上离 最近的点, 是 到这条边的距离。代码里的 raw_t、t、(px, py)、cross 依次是夹之前的 、夹之后的 、、。

每条边各算出一组 和 ,取 最小的边:它的 是投影点, 是横向偏差。扫描从游标所在的边开始,到最后一条边为止; 相等时取先出现的边。每刻结束把游标设为投影边,下一刻从这里扫起,所以投影只会往前走,折返的走廊上投影留在后经过的那一段。扫描的第一条边上 raw_t < 0(角色还没到这条边的起点),或最后一条边上 raw_t > 1(角色已经过了走廊终点)时,标记为窗口外。

折返走廊上逐刻的投影:游标只往前移,第 11 刻全局最近的边在去程上,从游标扫起投影留在回程

一条折返的走廊,去程 e0、e1 和回程 e4、e5 相距约 2.6。黄线是游标及之后、这一刻参与扫描的边,灰虚线是游标之前、不再扫描的边;空心圆是投影点。游标每刻末移到投影边:第 4 刻投影到 e1,从第 5 刻起从 e1 扫起;第 8 刻投影到 e4。第 11 刻角色斜着切向去程一侧,离 e0 的距离 1.25 比离 e5 的 1.52 还近,从头扫会投影回去程(红虚线);从游标 e4 扫起,投影留在回程的 e5 上。

每刻位移

前视距离按”每刻走多远”来定,所以先要估计每刻位移。

  • 控制器自己数刻,没动的刻也计数。
  • 与上一个样本相距不超过 0.25(一个定位量化步)时不记样本。
  • 速度超过 40 像素/秒,或刻序号间隔超过 8 刻,清空窗口重来。前者快到不可能是行走,多半是重新捕获后落到了别处;后者跨过了一段停滞,旧样本对这段时间一无所知。两种情况下样本对都不再代表一刻的行进。
  • 最多保留 8 个样本、2000 ms。
  • 估计值 按下式从窗口里的样本算出。

是参与估计的样本位置,按时间排列。 是最新样本; 是起点样本,取离最新样本至少 700 ms 的样本里最晚的一个,没有这样的样本时取窗口里最早的一个。、 是 、 记录时的刻序号。分子是样本连成的折线长度,分母是两端相隔的刻数,没记样本的刻也算在分母里,所以 是这段时间里平均每刻走的距离。

速度采样排在所有提前返回之前。走廊建不起来的刻也照样采样,走廊恢复后的第一刻就有可用的速度估计。

#6104 日志一趟起步后前 18 刻的速度采样窗口、起点样本选取、每刻位移估计和由此得到的前视距离

取自 #6104 日志里一趟起步后的前 18 刻(5.02–7.18 s)。左上是窗口里样本的位置,粗线是参与估计的那段折线,黑圈是起点样本;下方时间轴上 ● 是样本,× 是离上一个样本不超过 0.25、没记样本的刻,○ 是被挤出窗口的样本。第 1–4 刻只有一个样本,没有估计,前视距离取 2.5。第 2–4 刻没记样本,刻序号照样增加:第 5 刻的估计是第 1、5 刻两个样本间的弧长除以 4 刻,得 0.23,7 倍是 1.6,夹到下限 2.0。第 12 刻起,往回 700 ms 以外有了样本,起点换成最后一个这样的样本(第 6 刻)。第 15 刻起窗口满 8 个,每记一个新样本就挤出最老的一个。这 18 刻按上面的规则算出的前视距离,与日志里投影点沿走廊到前视点的距离逐刻一致。

前视距离

cpp
if (!route.startup_motion_confirmed) {
    return kNavRunLookaheadLowSpeedM;          // 2.5
}
const std::optional<double> step = estimateStepPerTick();
if (!step) {
    return kNavRunLookaheadLowSpeedM;          // 2.5
}
return std::clamp(kNavRunLookaheadPreviewTicks * *step, kNavRunLookaheadMinM, kNavRunLookaheadMaxM); // 7, 2, 14

是前视距离, 是上一节的每刻位移, 就是角色 7 刻走的路程,结果夹在 2 到 14 之间。起步尚未确认、或还没有速度估计时 。

**为什么要设计一个前视距离?**走到拐点再发转向,等角色真正转过来,它已经在拐点之后好一段了。原因都在控制回路里:

  • 指令生效有延迟。一刻要截图、定位、算航向、发转向,健康时约 110 ms,PlayCover 上中位约 200 ms(见难点)。发出的转向要过约 2 刻才开始体现:掉头锁定那张表里,3.267 发出 70°,3.412 的读数没动,3.564 才动了 11.7°。
  • 转身时角色照常前进。转向期间前进键一直按着,指令生效之前角色沿旧方向走;生效之后每刻也只转 60° 左右(同一张表里相邻两刻转了 56°、63°),70° 的弯还要再花一刻多。
  • 位置一刻一采样。每刻位移 1.6 时相邻两次定位相隔 1.6,定位很少恰好落在拐点上:这一刻还差 0.9,下一刻已经越过 0.7。“到了拐点”最早也要越过之后才知道。

三样叠在一起,角色过了拐点还要沿旧方向走两三刻。走廊在拐点 转了 角,角色过了拐点沿旧方向又走了 ,到达 ;从 向出边作垂线,垂足为 。、、 构成直角三角形,直角在 ,斜边 , 处的角是 ,所以角色离出边中心线

过了 70° 拐点沿旧方向又走 x,离出边中心线 x sin θ;走 2 刻后超出净空

转角 ,,每刻位移 1.6。多走 1 刻,,离中心线 1.5,还在净空 2.0 以内;多走 2 刻,,离中心线 3.0,已经出了走廊。

前视把发转向的时机提前。转角不超过 50° 的弯,拐角一进入前视范围,走廊航向就开始转;超过 50° 的弯,前视点先停在拐点上,离拐点不到拐角提交距离时才越过拐点、开始转。延迟耗掉的那段路因此大半落在拐点之前。

同样的指令延迟下,到拐点才转的角色冲出走廊净空,按前视提前转的角色留在净空以内

示意,与拐角的动图同一个拐角:转 70°,净空 2.0,每刻位移 1.6。延迟按掉头那次的实测取整:第 k 刻发出的转向从第 k + 2 刻起才开始转,之后每刻最多转 60°。左边到了拐点(离拐点不到 0.8 或已越过)才转:第 7 刻定位离拐点还差 0.9,第 8 刻已越过 0.7,这一刻才发出转向;生效前又沿原方向走了 2 刻,第 9–14 刻都在净空之外,最远离中心线 3.9。右边按前视走:第 6 刻离拐点 2.5,进入拐角提交距离,提前发出转向;同样等 2 刻,延迟耗掉的 3.2 里有 2.5 花在拐点之前,转弯从拐点后 0.7 处开始,最远离中心线 1.2,一直在净空以内。

直线上同样需要前视。角色偏在走廊一侧时,如果追脚下的投影点,航向垂直扎向中心线,加上延迟,过了线才收住,再垂直扎回来,左右来回摆。追前方距离 处的点,回线的角度是 , 是横向偏差。偏差越小角度越小,角色斜着并回中心线,推导见瞄准点。

前视点沿走廊往前量,给出角色要追的方向,所以前视距离就是全部的转弯预判:拐角进入前视范围之后,转向目标才开始转。前视距离按刻数计,同时跟着速度和循环周期变。固定距离在角色减速时预判过多;链路慢到每刻走得更远时,又留不出反应的余量。按刻数计,同一套参数在快机和慢机上给出同样多的反应刻数。7 刻来自实机盲测:两种呈现顺序下 7 都胜过 5,也胜过 9;11 明显有害,会切角、游荡,耗时翻倍。

前视距离的历史:

时间变化
#4682 之前三档固定值:走 4.0、冲刺 5.5、前方急弯(> 55°)2.5
#4604(07-30)「前视点按贴线预算夹在走廊拐角前」在三档之上加弦长余量 0.5 夹紧,最小 2.0
#4682(08-02)「移除不知道谁丢的二向箔」删掉三档和夹紧,统一 6.0,起步前 2.5
#4809(08-07)「修复跟随层提前转向、循线抖动、过角余量不足、自救链路失效并增强延迟抗性」改为 7 刻、转角预算、拐角提交距离、短边、瞄准距离、速度采样

#4604 的夹紧让前视弦离走廊折线不超过 0.5,实测的跟踪误差约为这个值的 1.7 倍,多出来的部分是跟随滞后。#4682 删掉夹紧,前视统一 6.0,按观测到的 5–8 px/s 行进速度留够转完一个弯的时间;这个距离随速度定死,#4809 改为从每刻实测位移推算。

#6104 日志一段连续折弯里逐刻的走廊、投影点、前视点和走廊航向

取自 #6104 日志里的一趟(29.9–33.8 s),角色从左下走到 w20,在 w20 折返向左,再进入 w22–w25 的 U 形弯。每帧是一刻:黄线是这一刻的走廊折线,蓝色一段是投影点沿走廊量到前视点的距离,红箭头是走廊航向。这一段每刻位移 0.76–1.26,前视距离 5.30–8.83。30.28 s 起前视点停在拐点 w20 上,量到的距离从 6.04 缩到 2.91;30.98 s 进入拐角提交距离,前视点越过 w20,停在 w21 上,规则见下一节。到点的刻没有走廊输出,下一刻从角色位置重建走廊。角色在 w20 的折返处冲出了折线,31.29 s 重建后横向偏差逐刻升到 1.40。32.44–33.27 s 前视点先后停在 w22、w23 上。整份日志 2610 个走廊跟随刻的横向偏差中位 0.26,P90 0.95,P99 1.80,最大 2.58。

拐角

前视点沿折线往前推,遇到拐角时有两条规则决定它是停在拐点上还是越过去。

角色逐刻接近 70° 拐角时,前视点先停在拐点上,进入拐角提交距离后越过拐点

示意。走廊在拐点转 70°,拐点处净空 2.0;每刻位移 1.6,前视距离 11.2,拐角提交距离 。角色每刻朝瞄准点走 1.6。第 1–6 刻投影点离拐点超过 3.5,前视点停在拐点上,灰圈是按前视距离本该落到的位置,走廊航向仍沿当前边;第 7 刻投影点离拐点 2.61,前视点越过拐点,走廊航向开始转。

cpp
if (len >= kNavRunCorridorEdgeMinM) {
    const double heading = NaviMath::CalcTargetRotation(a.x, a.y, b.x, b.y);
    const double turn = std::abs(NaviMath::NormalizeAngle(heading - base_heading));
    if (turn > kNavRunLookaheadTurnBudgetDeg && travelled > CornerCommitDistance(path, edge, turn, commit_distance)) {
        return a;
    }
}

转角预算 50°。前视点从投影点往前推,每经过一个拐点,看拐点后那条边相对当前边转了多少:

是角色当前所在边(投影边)的航向, 是后面第 条边的航向,航向的定义见瞄准点末尾。 把角度差换算到 :两条边航向 350° 和 10°,直接相减是 340°,wrap 之后是 −20°,实际只差 20°。 时,前视点停在这条边的起点,也就是这个拐点上。预算看的是相对当前边累积的转角:四个 30° 的台阶和一个急弯一样会让角色偏出走廊。

是相对 的净航向差。同向的台阶会累加,四个 30° 的台阶在第二级就是 60°,超出预算;左右交替的折线(+30、−30、+30)净差始终不超过 30°,不会停。

前视点停下,是为了不越过角色还没开始转的弯。前视距离按刻数计,慢循环上它会变长,前视点已经绕过拐角,拐角本身却还在很远的地方;角色朝前视点直线开过去,切的是弯的内侧,那里就是墙。

拐角提交距离(CornerCommitDistance),记作 。投影点沿走廊到拐点的距离不超过 时,前视点才越过拐点。

是每刻位移, 是前视距离, 是 4 刻的路程;没有速度估计时 。拐点的净空 已知时,再按这个拐角的转角 取

这个上限来自切角的几何。角色离拐点 时开始朝拐角里转,走的是一条弦,从拐点内侧切过去。按对称的情形估算:弦的两端在入边和出边上,各离拐点 。拐点和弦的两端构成等腰三角形,顶角是 ,两个底角各为 。从拐点向弦作垂线,得到一个斜边为 、底角为 的直角三角形,垂线长

就是弦切进弯内侧的深度。净空 是拐点处可通行区域的半宽,弯内侧的墙最近可能就在离拐点 处,所以要求 ,即 。手写路线没有净空,只用按速度算的 。

离拐点 d 处开始转,弦切进弯内侧 d sin(θ/2);d = 6.4 时撞墙,d = 3.49 时恰好切进净空 2.0

与上面的动图同一个拐角:,,每刻位移 1.6。按速度算的 ,切进 ,超过净空(灰虚线);按净空算的上限 ,切进的深度恰好是 2.0(红线)。两者取小,。

拐角提交距离也按刻数计,转弯因此总有同样多的刻数去完成;固定的世界距离在慢循环上不到一刻,角色到拐点时还朝着旧方向。4 刻少于前视的 7 刻,拐点落在 4 刻到 7 刻的距离之间时,前视点仍停在拐点上。

短边。短于 0.75 的边只计入前视距离,转角判断跳过它。规划器的顶点按格放置,太短的边方向只是量化噪声,在它上面误判出一个拐角会让前视点停住。

瞄准点

cpp
const double dx = lookahead.x - anchor.x;          // anchor 是投影点
const double dy = lookahead.y - anchor.y;
constexpr double kChordFloorM = 1e-6;
const double chord = std::hypot(dx, dy);
if (chord <= kChordFloorM) {
    return NaviMath::CalcTargetRotation(position.x, position.y, lookahead.x, lookahead.y);
}
const double reach = std::max(kNavRunAimReachMinM, chord);   // 6.0
return NaviMath::CalcTargetRotation(position.x, position.y, anchor.x + dx / chord * reach, anchor.y + dy / chord * reach);

记投影点为 (代码里的 anchor)、前视点为 ,两点间的直线距离 称为弦长。瞄准点 从 沿 的方向推出 :

是 方向上长度为 1 的向量,乘以 就是沿这个方向走 。弦长不短于 6 时 , 就是 ;弦长不足 6 时 落在 再往前的位置。走廊航向从角色位置 指向 。

角色偏在走廊一侧时,这个航向自带回线的分量。设 在角色所在的同一条直边上, 离边的距离是横向偏差 , 在边上 前方 处。、、 构成直角三角形,直角在 ,两条直角边是 和 ,走廊航向与边的方向差

越大,同样的 造成的 越小。 的下限 6 用来压住定位噪声:前视点停在近处的拐点上时,弦长可能只有 2 左右, 若跟着缩短,定位量化带来的那一点横向偏差就会单独越过转向死区。 也不短于弦长:比前视点更近的瞄准点在循环滞后下会转过头。

弦长不短于 6.0 时瞄准点就是前视点;弦长不足 6.0 时瞄准点沿弦的方向推到 6.0

示意,角色偏在走廊一侧 0.8 处。(a) 前视点在当前边上 10.0 外,弦长不短于 6.0,瞄准点就是前视点;走廊航向与边的方向差 ,朝折线一侧偏,这就是回线的分量。(b) 前视点停在 2.5 外的拐点上,瞄准点沿弦的方向推到 6.0。直接追前视点(灰虚线)与边的方向差 ,追瞄准点(红线)为 。

定位坐标按 0.25 量化,横向偏差因此可能凭空多出 0.25。 时它造成 的航向误差,低于 6.6° 的转向死区; 时是 ,已经超过死区。

航向由 CalcTargetRotation 算出。从 指向 ,记 ,:

是点 相对横轴正向的角度,范围 ,乘 换成度。底图坐标的 轴朝下,往北是 方向,所以这里把往北的分量 放在 的位置,往东的分量 放在 的位置,算出的是从正北顺时针量到目标方向的角度:正北 0°,正东 90°,正南 180°,正西 −90°,对 360 取模后为 270°。结果取整,所以日志里的走廊航向和目标航向都是整数。

前方转角

前方转角统计投影点之后 8.0 路程内的边。这 8.0 沿折线量:从投影点出发逐条往后数边,每数一条就从 8.0 里扣掉它的长度(当前边只扣投影点到末端那一段),扣完为止;8.0 在某条边中途用完时,这条边整条算进范围。范围内长度至少 0.75 的边参与比较。记其中第一条的航向为 ,结果是

取遍范围内各条长边的航向, 与拐角里相同。结果交给冲刺条件。短于 0.75 的边只扣长度:规划器的顶点按格放置,这么短的边方向只是量化噪声。

当前边从投影点到末端剩下不足 0.75 时,基准会落到下一条边上,脚下这个拐角就不计入。冲刺条件同时要求航向误差小于 25°,这种情况通常也不满足。

前方 8.0 内最大转角的取法:短边不比较,基准取第一条长边;当前边只剩不足 0.75 时基准落到下一条边

示意,蓝色是从投影点沿折线量出的 8.0。(a) 基准是投影点所在的当前边。长 0.5 的短边不比较;后面两条边与基准差 30° 和 75°,结果取 75°。8.0 在 75° 那条边的中途用完,这条边整条参与比较,再往后的边不看。(b) 投影点离当前边末端只剩 0.5,这一段不比较,基准落到往北的下一条边上,脚下 90° 的拐角不计入,结果是后一条边的 25°。

软重规划

每刻投影之后检查是否重新生成走廊:

原因条件冷却
偏离走廊(3)横向偏差 > 2.21200 ms
远离走廊(3)横向偏差 > 4.0跳过冷却
进度倒退(4)起步已确认,且 800 ms 内剩余距离没有减少 0.5 以上1200 ms
  • 横向偏差小于 1.25 时忽略进度倒退,同时把进度时钟刷成当前时间。
  • 每个锚点最多 3 次,次数挂在走廊上,锚点或 zone 变化时清零。远离走廊可以跳过冷却,同样占用这 3 次;用完之后交给外层 3.5 s 起跳的卡死与恢复。
  • 重规划后重新投影,打 INFO “NavRunController soft replan.”,字段为原因、本锚点第几次、重规划后的横向偏差、锚点下标。路线自己的点重建时按走廊来源的规则裁剪:裁不上就把角色插到折线开头,横向偏差为 0;起点换成垂足时,横向偏差等于角色到垂足的距离,不超过 2.5。A* 走廊从角色位置出发,横向偏差为 0。#6104 日志里软重规划共 24 次,23 次重规划后的横向偏差是 0;另一次起点裁到了垂足,为 2.34。
  • 偏离走廊和进度倒退对停滞时钟的处理不同,见每刻顺序。
软重规划逐刻示意:偏离走廊、冷却中不重规划、远离走廊跳过冷却、横向偏差小时忽略进度倒退、进度倒退、3 次用完

示意,相邻两帧相隔 0.2 s。走廊通向右边的锚点,三圈线是离当前走廊 1.25、2.2、4.0 的横向偏差,灰虚线是被换掉的旧走廊;下方是横向偏差随时间的变化,灰底是每次重规划后的 1.2 s 冷却。0.6 s 横向偏差 2.3,偏离走廊,第 1 次重规划,新走廊从角色位置出发,横向偏差回到 0。1.0 s 又到 2.28,还在冷却中,不重规划。1.2 s 超过 4.0,远离走廊,跳过冷却做第 2 次。1.4–2.0 s 角色停在原地,2.0 s 时已 0.8 s 没有进展,但横向偏差只有 0.10,不算进度倒退,进度时钟刷新。2.2 s 起角色横移到偏差 1.5 左右,2.8 s 又满 0.8 s 没有进展,这次横向偏差不小于 1.25,进度倒退,第 3 次。3.0 s、3.2 s 又先后偏离、远离走廊,3 次已用完,交给外层的卡死与恢复。

原因编号还有 AnchorChanged(1)和 ZoneChanged(2),它们走 invalidate 后重建,不算软重规划,日志里的 nav_run_replan_reason 只会出现 0、3、4。

有几种情况这一刻拿不到走廊航向:走廊建失败(处于 3000 ms 冷却)、投影丢失、投影落在窗口外且横向偏差超过 4.0。这些刻退回路点级的 route_heading。straight_to_anchor 在这些提前返回之前就已写出:路点级的退路看的是几个像素外的下一个路点,锚点却还在几十像素外,没有这个值,走廊断掉的这段时间里无进展 watchdog 会一直把角色读成已经到达。

转向

转向与推进顺序

走廊给出目标航向之后,TickNavigate 按下面的顺序转向和推进:

  1. 目标航向:有走廊航向用走廊航向,否则用 route_heading。
  2. 朝向角速度:本刻朝向和参考朝向之差,折算到每 100 ms。
  3. 结清在途转角账:把上一刻以来朝向的实际变化从待转角里扣掉,过期的作废。
  4. 航向误差 , 是目标航向、 是当前朝向,交给 SteeringController::Update。
  5. SetForwardState(true),按住前进。
  6. ApplySteering:按后端拆批发出,实际发出的量记入在途转角账。
  7. 灵敏度检测记一刻。
  8. 前进键闭环:按着前进却连续几刻没动就重发。
  9. 延迟检测记一刻。
  10. 提示点附近抑制冲刺;判断冲刺条件。

度与单位

控制器以度为单位工作,发给后端前换算成鼠标量或滑动像素。

  • 桌面端:记窗口宽 、高 (像素),

    1280 宽时一圈 2854,每度约 7.93。俯仰的可动行程是 180°,所以

    取不到分辨率时退回工作分辨率的宽度。

  • 2.23006 在 aa813aaf(03-10)中替换了原来固定的 5690/360,使转向量随分辨率变化。

  • ADB:每度 3.0 像素。

  • 灵敏度系数:乘在偏航和俯仰上。偏航从度到单位只有这一处换算,校正系数也只乘在这里和俯仰上。见灵敏度检测。

  • 换算:转 度发出的单位数

    是灵敏度系数, 为 0 时补成 ±1。

#6104 日志里 30° 对应 297 单位,7.4° 对应 73,和 1600 宽窗口的每度 9.91 吻合。

控制律

常量:死区 6.6°,前进中单刻上限 90°,原地上限 70°,Kp 1.0,可信角速度上限每 100 ms 60°,掉头锁定 150° 进、120° 出。

cpp
if (rate_magnitude > kHeadingRatePlausibleMaxDeg) {          // 60 / 100 ms
    LogDebug << "SteeringController glitch-suppressed." ...;
    return command;                                         // 本刻不转
}
if (std::abs(heading_error) < kTurnLatchExitDeg) {          // 120
    turn_latch_sign = 0;
}
else if (turn_latch_sign == 0 && std::abs(heading_error) >= kTurnLatchEnterDeg) {   // 150
    turn_latch_sign = heading_error >= 0.0 ? 1 : -1;
}
if (turn_latch_sign != 0) {
    heading_error = std::abs(heading_error) * turn_latch_sign;
}
cpp
const double pending = std::clamp(pending_turn_deg, std::min(0.0, heading_error), std::max(0.0, heading_error));
const double effective_error = heading_error - pending;
const double p_term = std::abs(effective_error) < kHeadingDeadband ? 0.0 : effective_error * kKp;
const double max_cmd = moving_forward ? kMovingMaxCmd : kTurningMaxCmd;
const double cmd = std::clamp(p_term, -max_cmd, max_cmd);
command.yaw_delta_deg = cmd;
command.issued = std::abs(cmd) >= 2.0;

执行顺序:毛刺抑制 → 掉头锁定 → 扣除在途 → 死区 → 比例 → 限幅。

扣除在途。已经发出、朝向读数还没体现的转角先从误差里减掉。游戏转完一条封顶的指令要好几刻,控制器却每刻都在算,每刻按原始误差下发,同一个转角会被发好几遍,过冲后再往回找,就是左右摆。扣除量夹在 0 和误差之间,剩下的部分和原误差同号,扣除只会让指令变小,指令方向保持不变。

死区只作用于比例项,用来压住直线上低于噪声的航向误差。真正的拐角误差很大,大角度转向不受死区影响。

上限。moving_forward 在 SetForwardState(true) 之前读取,所以刚停下之后的第一刻也按原地上限 70° 算。一份 #6097 之前的 PlayCover 日志里,起步第一刻就是 moving_forward=false、cmd=-70。

issued 要求 ≥ 2.0,这条从不起作用:Kp 为 1、死区 6.6,非零的指令绝对值都不小于 6.6。

#6104 日志里四个代表性的转向刻画成罗盘:死区内不发、扣掉在途只补差额、前进中封顶 90°、原地转身封顶 70°

四个罗盘取自 #6104 日志(Windows 桌面端,单笔上限 30°),每个是一刻:黑箭头是读到的朝向,绿线是目标方向,浅绿扇区是 ±6.6° 死区;从朝向开始先画在途(黄),再接本刻发出的量(蓝,白线分笔),灰色是本刻没补上、留给下一刻的部分。① 误差 −5.3° 落在死区里,不发。② 误差 −45.5°,前几刻已经发出、读数还没体现的 13.5° 先扣掉,只补 32°。③ 和 ④ 的误差都在 139° 左右:前进中发满 90°,原地转身只发 70°。

掉头锁定

误差接近 180° 时两侧一样远,一度的读数抖动就能让指令方向翻转 360°。#4839「近乎掉头的转向锁定方向,不再左右横跳」(cherry-pick 自 #4831)在误差达到 150° 时锁定方向,降到 120° 以下才解锁。锁定之后角色朝同一侧一直转到底;两侧都能转到目标,锁定哪一侧都对。

锁定判断用的是扣除在途之前的误差。锁定只改控制器内部的副本,冲刺条件读到的仍是原始误差。

#6104 日志里一次掉头:地图上的位置与朝向,以及七个刻各自的转向账

#6104 日志里的一次掉头,桌面端单笔上限 30°。左图是这段时间的定位点,箭头是七个刻各自读到的朝向:前 6 刻位置挪动不到 1 像素,朝向从 190° 顺时针转到 348°;转完之后的第 7 刻已经往北走了 2 像素。右图每个罗盘是一刻的账:黑箭头是读到的朝向,绿线是目标方向,浅绿扇区是 ±6.6° 死区;从朝向开始顺时针先画在途(黄),再接本刻发出的量(蓝,白线分笔)。黄和蓝加起来最多走到绿线,这就是”在途夹在 0 和误差之间”的几何含义。前三刻误差在 150° 以上,锁定右转;第 4 刻误差降到 106°,解锁。各刻的数据:

时间朝向误差在途指令发出
3.267−169.4167.4070(还没按前进)30 + 30 + 10
3.412−169.4167.4709030 + 30 + 30
3.564−157.7155.7148.37.47.4
3.710−101.8105.899.80(6.0 在死区内)—
3.846−38.544.536.58.08
3.977−11.517.517.50—
4.38510.0−2.0−2.00—

误差 167° 进入锁定,3.710 那刻降到 106° 解锁。前两刻发出 160°,第二刻时朝向读数还一点没动,在途扣掉了 70°,指令只按剩下的部分算;之后读数追上来,在途逐刻消掉,只补了 7.4° 和 8° 两个零头。整个掉头约 1.1 s,最后误差 2°,没有过冲。

朝向角速度与毛刺抑制

朝向读数来自小地图箭头的识别,每刻一个。毛刺是这个读数序列里孤立的一帧错值:前后几刻都正常,中间一刻突然跳到很远的角度,下一刻又跳回来,画成随时间变化的曲线,像曲线上扎出来的一根刺。角色在两刻之间转不了那么多,这一帧反映的是识别读错,角色的实际朝向没变。拿它算误差,控制器会发出一条大转向,把本来对准的角色转歪,下一刻读数恢复后再转回来。判断依据是角速度:两次读数之间算出的转动快到角色做不到,就把这一刻当作毛刺,不按它转向,这就是毛刺抑制。

角速度 的单位是每 100 ms 的度数(#3744「朝向角速度时间归一化」):

是本刻读到的朝向, 是本刻的时刻(ms); 是参考朝向, 是记下它的时刻; 与拐角里相同,把差换算到 。分母用实际经过的毫秒数,两刻隔 300 ms 时转了 30°,算出来是每 100 ms 10°,和每刻隔 100 ms、每刻转 10° 的结果一样。

参考值按下面的规则更新:

  • :算作没转,,参考值保留。静止时读数的微小抖动不算转动。
  • 超过 0.05°:按上式算 ,再把参考值换成本刻的 和 。参考值保留了几刻,这几刻的转动就一起除以这几刻的总时长。
  • 参考值记下之后已经过了 4 刻以上: 记 0,参考值照样换成本刻读数。时效按刻数计(#4809 起,替换了 #3744 的 400 ms)。参考值过期的含义是错过了几次观测机会,截图慢只会把这些机会拉长,次数不变;按墙钟时间设上限,恰好在最需要角速度的慢循环上把它丢掉。
示意序列:每刻 100 ms,参考值的保留与更新、角速度的计算,以及一帧读错引起两刻毛刺抑制

示意序列,每刻 100 ms。左边罗盘:黑箭头是本刻读数,灰虚线是参考值,蓝弧是 ,浅灰短箭头是之前各刻的读数。右边是本刻的计算,下面的条是 ,竖线是上限 60。第 1、2 刻读数只抖了 0.03°、0.04°,参考值留在第 0 刻。第 3 刻转到 40°, 相对第 0 刻算,是 30°,间隔 3 刻 = 300 ms,。第 4 刻转了 30°,。第 5 刻是一帧毛刺,读数错成 235°,, 超过 60,这一刻不发转向;参考值照规则换成了这个错的读数,第 6 刻读数回到 95° 时,相对 235° 又算出 ,再抑制一刻。第 7 刻恢复正常。一帧读错会让转向停两刻。

#4939 删掉阻尼项之后,角速度只用于毛刺抑制:(每 100 ms 60°,即 600°/s)视为读数毛刺,SteeringController::Update 一进来就返回空指令,这一刻不发转向,掉头锁定也不更新,只打 “SteeringController glitch-suppressed.”。在途转角账在调用控制器之前结算,照常进行。

#6104 日志里 ω 不为 0 的各刻的 |ω| 分布,对数纵轴,最大 55.2,上限 60

#6104 日志 2621 个转向刻里 的 1232 刻,纵轴取对数。中位数 2.7,99% 在 36.2 以下。最大的 55.2 出现在一次起步掉头:前两刻共发出 160°(70 + 90),第 4 到第 5 刻之间读数在 140 ms 里转了 77.3°,折合 552°/s。整份日志没有一刻 超过 60,毛刺抑制一次也没触发;实测最快的转动离上限还差 4.8。

末端纠正收尾之后清零角速度和在途转角账。收尾里的转镜头绕开了转向控制器,在途转角账认不出这些转动,清掉重新起算。

批次

ApplySteering 先做几项检查:角度为 0、处于静默期、绝对值小于后端的最小发送量、未到最小间隔,任一成立就不发。随后按单笔上限 拆批。控制器给的本刻指令记作 ,批次数:

是向上取整, 刚好是 的整数倍时不多出一批。有最小发送间隔的后端(Linux,16 ms)再取 , 是这一刻距上一刻的毫秒数;一刻 150 ms 时这一项是 9,3 批的上限先起作用。

本刻总量夹到 ,然后逐批发出:每批发 ,剩余小于后端的最小发送量、或某批被拒时停止。各批的发送耗时求和,记为延迟检测的 Steer 阶段,被拒的那批也算在内。漏算的部分会落到 Other 阶段头上。

四个罗盘:桌面 90° 拆成三批 30°,桌面 40° 拆成 30 + 10,Linux 90° 只发三批 18° 剩 36°,ADB 90° 一批发完

四个罗盘各是一刻的拆批:黑箭头是当前朝向,绿线是指令要转到的方向,蓝弧是本刻发出的量(白线分批),浅灰弧是本刻发不完、留给下一刻的部分。① 桌面 90°:,三批各 30°。② 桌面 40°:,第一批 30°,第二批发剩下的 10°。③ Linux 90°: 夹到 3,本刻最多 ,剩 36°;这 36° 不记任何账,下一刻控制器按新的误差重新算。④ ADB 90°:单笔上限就是 90°,一批发完。

#5285「转向批次按请求角度分配以提高灵活性」之前,批次数按本刻时长分配(当时是 刻间隔 / 100)。一次大角度掉头一刻只发一批,要拖约 1.7 s,角色一直在走,转身扫出一段 3.5 m 的弧,撞上旁边的东西。现在批次按指令要求的角度分配,单刻上限仍在,每一刻都还是一个观测点:185° 的转向分三刻发成 90 + 90 + 5。

90 + 90 + 5 是跨三刻的序列。控制器单刻指令本身不超过 90°,各后端单刻实际能发的量:

后端单笔上限 最小发送量最小发送间隔单刻最多
桌面30°1.0°无3 × 30 = 90°
Linux18°2.0°16 ms3 × 18 = 54°,还受 限制
ADB90°1.5°无1 × 90 = 90°

最后的 5° 落在 6.6° 死区内,通常不会单独发出。上限 3 批让每一刻都还是一个观测点:某一帧朝向读错,最多多转一刻的量,下一次定位就能纠正。

在途转角账

每刻实际发出的转向记一笔在途转角账。账上记着已经发出、朝向读数还没体现的度数,以及起算的参考朝向。一次转向要几刻才完成,控制器却每刻重新决策,没有这笔累计,同一个误差会在落地之前被反复下发。

每刻结算:

cpp
const bool drops_turn_sends = motion_controller_->SteeringDropsTurnSends();
if (drops_turn_sends) {
    std::erase_if(steering_rate.in_flight, [&](const SteeringRateState::InFlightTurn& turn) {
        return std::chrono::duration_cast<std::chrono::milliseconds>(now - turn.sent_at).count() >= pending_lifetime_ms(turn.delta_deg);
    });
    if (steering_rate.pending_turn_deg != 0.0) {
        if (steering_rate.in_flight.empty()) {
            steering_rate.pending_turn_deg = 0.0;
        }
        else {
            const double landed = NaviMath::NormalizeAngle(current_heading - steering_rate.pending_ref_heading_deg);
            const double owed = std::abs(steering_rate.pending_turn_deg);
            double negative_sent_deg = 0.0;
            double positive_sent_deg = 0.0;
            for (const SteeringRateState::InFlightTurn& turn : steering_rate.in_flight) {
                (turn.delta_deg < 0.0 ? negative_sent_deg : positive_sent_deg) += turn.delta_deg;
            }
            steering_rate.pending_turn_deg =
                std::clamp(std::clamp(steering_rate.pending_turn_deg - landed, -owed, owed), negative_sent_deg, positive_sent_deg);
        }
    }
}
else if (steering_rate.pending_turn_deg != 0.0) {
    const int64_t pending_age_ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - steering_rate.cmd_at).count();
    if (pending_age_ms >= pending_lifetime_ms(steering_rate.cmd_delta_deg)) {
        steering_rate.pending_turn_deg = 0.0;
    }
    else {
        const double landed = NaviMath::NormalizeAngle(current_heading - steering_rate.pending_ref_heading_deg);
        const double owed = std::abs(steering_rate.pending_turn_deg);
        steering_rate.pending_turn_deg = std::clamp(steering_rate.pending_turn_deg - landed, -owed, owed);
    }
}

记账上的待转角为 ,上一刻读到的朝向为 。两条分支都先算两刻之间朝向实际转过的量,再从账上扣掉:

结算完参考朝向换成本刻读数,所以 总是相邻两刻之间的变化。夹到 让 只减不增:朝待转方向转了多少就扣多少;往反方向转时 的绝对值变大,被夹回 ,账不变;转过头时 变号,最多到 。

控制器从误差 里扣掉 再算指令(见控制律)。本刻实际发出 之后记账:

两条分支的区别在作废规则:

  • drops_turn_sends 只有 ADB 后端为真,ADB 的一次滑动可能被游戏整笔吞掉。ADB 每刻发出的量单独排一笔 , 是这笔的发送时刻;每笔按自己的寿命判断, 就移出队列,队列空了 直接清零。被吞掉的一笔到了自己的寿命就过期,后面再往哪边发什么都不影响它。扣完 之后再按方向夹一次:

    是队列里反向各笔之和, 是正向各笔之和, 不会超出队列里还没过期的各笔能凑出的范围。

  • ADB 的发送时刻在调用 ApplySteering 之前才取,寿命从真正发送的时刻算起。同一刻里前面可能做过重规划,从这一刻的开头算的话,这段耗时就可能超过一整个寿命。

  • 桌面和 Linux 只有一个时钟,每次发送都把它重置为这一刻的开头,寿命按最近一次发出的 算;到期时整笔 一起作废。

一笔的寿命:

是后端的单笔上限。600 ms 盖住最坏的截图到定位滞后加一笔的偏航时间,320°/s 是实机测得的单条指令偏航速度。一刻发了几批时转得更远,超出第一笔的部分按这个速度加时间。过了寿命还没兑现,就当这次拖动被游戏吞掉,作废待转角;继续挂着会一直压住转向,等一个不会来的转动。

桌面端一刻转 90° 时寿命 787.5 ms,Linux 一刻最多 54° 时 712.5 ms,ADB 一刻最多 90° 等于单笔上限,恒为 600 ms,各后端的曲线见输入后端的右图。走路时转速约减半,寿命加倍;按慢跑算的寿命会在转身中途到期,控制环又把这个转角发一遍。

演变:#4939(08-11)固定 600 ms → #5027(08-16)走路加倍 → #5285(08-27)按 320°/s 加长 → #6097(09-29)每笔单独排队、从发送时刻起算 → #6142(10-01)逐笔排队只留给 ADB,正反两向分别夹;桌面和 Linux 回到单一时钟。#6097 之前只有一个时钟,到期时整笔待转角一起作废,每次新发送还会刷新这个时钟。#4939 引入时按实机测得的约 100°/s 偏航速度估算,一笔封顶指令约 300 ms 落地,寿命取它的两倍。

旧版单一时钟的问题在一份 #6097 之前的 PlayCover 日志里能看到(ADB 后端,单笔 20°,每度 5.0):

刻误差在途指令发出说明
2−105.40−70(停着)3 × −20三笔滑动共 449 ms
3−102.50−903 × −20间隔 1278 ms,朝向只转了 −12.9,待转角已整笔作废
4−57.7−22.2−35.5−20、−15.5朝向转了 −37.8,−60 + 37.8 = −22.2
5−4.0−3.00—落入死区
6+19.80+19.8+19.8过冲约 20°,反向纠正
#6097 之前的 PlayCover 日志第 2 到 7 刻:每刻的结算、控制和记账,以及距最近一次发送的时间与寿命

上表第 2 到 7 刻逐刻回放,按当时的规则(单笔 20°,单一时钟)重算。左边罗盘:黑箭头是本刻朝向,灰箭头是上一刻朝向,紫弧是两刻之间实际转过的 ,绿线和浅绿扇区是目标方向 ± 死区,黄弧是账上的 ,蓝弧是本刻发出的量(白线分笔)。右边按一刻里的顺序列出 ① 结算、② 控制、③ 发出与记账;底部的条是结算时距最近一次发送的时间,竖线是寿命 。

  • 第 2 刻:账上为空。误差 −105.4°,还没按前进,原地上限 70°,发 3 × −20。,寿命 ms。
  • 第 3 刻:距上次发送 1278 ms,超过 725 ms, 整笔作废。控制器看到的误差还是 −102.5°,又发 −60。
  • 第 4 刻:距上次发送 697 ms,没过期。朝向转了 ,。误差 −57.7° 扣掉 −22.2 剩 −35.5,发 −20、−15.5,,时钟重置,寿命 648.6 ms。
  • 第 5 刻:又转了 −54.7°, 剩 −3.0。误差 −4.0 扣掉 −3.0 只剩 −1.0,在死区里,不发。
  • 第 6 刻:距第 4 刻 949 ms,超过 648.6 ms,剩下的 −3.0 作废。朝向已经转过头,误差 +19.8°,反向发 +19.8。
  • 第 7 刻:朝向往反方向动了 −1.5°, 被夹回 ,账不增加。误差 17.3° 小于 ,扣除量夹到 17.3,剩 0,不发。

第 3 刻的待转角作废是按日志数值反推的:间隔 1278 ms 超过当时的寿命(600 + 40 / 320 × 1000 = 725 ms),−60 的待转角整笔清零,于是同一个转角又发了一遍,两刻后过冲 20°。这里寿命按设计起了作用:第 2 刻三笔共 −60,1278 ms 里朝向只动了 −12.9,当作被吞掉处理是对的。少兑现的那部分和 #6097 标题里的”增益、丢步”对得上,对应的改动是单笔 20° → 90°、每度 5.0 → 3.0、两端停留 8 / 30 → 100 / 100 ms。

#6097 的在途记账改的是另一种情形。旧版只有一个时钟,每次新发送都会刷新它,被吞掉的一笔就跟着后面的发送一直挂在账上,压住之后的转向。改成每笔单独排队之后,被吞掉的那笔到了自己的寿命就过期,后面再发什么都影响不到它。按每笔各自的时钟算,上表第 3 刻的结果一样:第 2 刻那三笔各自也已经超过 725 ms。

#6142「在途转向逐笔寿命只用于 ADB」做了两处收窄。一是逐笔排队只留给会吞掉滑动的 ADB,桌面和 Linux 的转向指令不会丢,回到每次发送都刷新的单一时钟。二是 ADB 的上下界按方向分开算。#6097 的版本把队列里各笔相加,再夹到 0 和这个和之间。队列里有一笔 +40 还没落地、又发出一笔 −30 时,和是 +10,范围缩成 [0, +10];等 +40 落地,待转角算出来是 −10,被夹成 0,刚发出的反向转动从待转角里消失了,控制环会把这个反向转角再发一遍。按方向分开时范围是 [−30, +40],−10 保留下来。

队列里 +40 未兑现又发出 −30:#6097 把 p 夹到 [0, +10],+40 兑现后 −30 那笔从账上消失;#6142 夹到 [−30, +40],保留 −10

左列是 #6097 的夹法,右列是 #6142。黄弧是队列里的两笔,−30 接在 +40 后面画;黑弧是账上的 ;浅蓝扇区是 允许的范围;紫弧是 +40 兑现的 。上排是发出 −30 的那一刻,两版的 都是 ,#6097 的范围是 ,#6142 是 。下排是 +40 兑现之后:两版都先算 ,实际还没执行的是那笔 −30,按 夹完账上留下 −10。#6097 再夹到 得 0,−30 从账上消失,控制器会把这段反向转角再发一次;#6142 的范围包含 −10,保留下来。+40 那笔虽然已经兑现,还没到寿命,仍在队列里,所以下排的上界还是 +40。

参数演变

控制参数逐提交核对的变化:

日期提交变化
04-13#2162「大道至简 至于大道在哪你别管」Kp 0.68,死区 2.6,前进上限 28,原地上限 70,误差 EMA α 0.30,横向偏差偏置增益 3.2(偏置上限 28°)
04-13aeceb665「调参」Kp 0.3
04-146fe6d178「消除抖动」删除 EMA
04-18#2290「重构 MapNavigator 无状态航向追踪并修复 MapLocator 冷启动定位漂移」删除横向偏差偏置
06-14#3628「更好的线路规划 冲刺限制 转向控制增强」前进上限 90;加阻尼(增益 0.5,角速度死区 3.0,可信上限 60,阻尼上限 14);冲刺条件 25° / 40°
06-20#3744「朝向角速度时间归一化」角速度按 100 ms 归一;变化阈值 0.05;参考值 400 ms 内有效
07-30#4604前视弦长余量 0.5
08-02#4682Kp 0.6,死区 6.6;前视统一 6.0
08-07#4809Kp 0.45;7 刻前视及配套;参考值改为 4 刻内有效;单刻最多 3 批(按 刻间隔 / 100 分配)
08-08#4839掉头锁定 150 / 120
08-11#4939Kp 1.0,删除阻尼,加在途扣除,寿命 600 ms
08-16#5027「新增游戏走路模式切换」走路时寿命、前进重发窗口加倍
08-27#5285批次按角度分配;寿命按 320°/s 加长
09-29#6097「ADB 转向放宽单笔上限并修正增益、丢步与在途记账」ADB 单笔 20 → 90,每度 5.0 → 3.0,两端停留 8 / 30 → 100 / 100;在途转角账每笔排队、从发送时刻起算
10-01#6142「在途转向逐笔寿命只用于 ADB」ADB 每次滑动横向最多 270 像素,超出拆成多次;逐笔排队只用于 ADB,按方向分别夹

Kp 的轨迹是 0.68 → 0.3 → 0.6 → 0.45 → 1.0。#4939 把已发出未兑现的转角直接从误差里扣掉,标题称阻尼项冗余并删除,Kp 同时升到 1.0。

前进键闭环

前进键只在按下的那一刻发一次按下事件,这一下被吞掉的话,角色朝向正确却站着不动,要等几秒后停滞时钟到点、恢复阶梯接手才会发现。前进键闭环每刻检查一次:

  • 本刻有没有进展: 是本刻位置到上一刻位置的距离(地图像素), 算动了。动了、本刻发出了转向、或者本来就没按前进,三者有一个成立就算有进展。原地转身的刻在发转向,刹车到点时没按前进,都算有进展,不会触发重发。转向只看本刻发出的量,前几刻发出、本刻才兑现的转动不算进展。
  • 原地计数 :有进展时清零,没进展加一。恢复阶梯在跑时也清零,那时移动键归恢复管。
  • 时重发前进键,WARN “Forward hold produced no motion; re-sending it.”,补发次数 加一, 清零重新数。
  • 只在有进展时清零。

按刻数计,截图慢时等待不会被拉长;3 刻已经超过单次丢帧或刹车稳定所需的时间。走路时窗口放宽到 6 刻。走路时一刻的位移低于定位量化步 0.25,直线行走也会读成没动;阈值负责过滤量化噪声,所以放宽窗口,阈值保持不变。

时提前放恢复阶梯进来,不必等停滞时钟到 3500 ms。第一次重发对应按键被吞的情况,值得再试;第二次之后角色仍然纹丝不动,说明按键已经送达、角色被挡住了,再等停滞时钟只会让角色多顶几秒墙。恢复接手后 保留:清掉的话,下一刻恢复条件就不成立了,恢复阶梯第一步还没走完就会被关掉。

#6104 日志一段起步的第 3 到 14 刻:位置、朝向、原地计数、补发次数与停滞时钟,两次重发前进键后恢复阶梯提前接手

#6104 日志 06:41 那一趟起步的第 3 到 14 刻(桌面端),之后卡在 (912.12, 263.02) 的就是这一段,见卡死与恢复。左边是地图俯视,单位是地图像素:空心圈是上一刻的位置,虚线圆是它周围半径 0.25 的范围,实心点是本刻位置(动了画绿色,没动画红色),黑箭头是朝向,绿线是目标方向。右边是本刻的判定和两个计数,底部的条是停滞时钟。

  • 第 3 刻发了 13.9° 转向,算有进展。第 4、5 刻朝向还在转,那是前几刻发出的转向在兑现;本刻没发、位置没动,原地计数数到 2。
  • 第 6 刻挪了 0.83 像素,又发了 −13.3°,两个计数清零;第 7 刻发了 −8.4°,仍然清零。
  • 第 8 到 10 刻没动也没发,第 10 刻计数到 3,重发前进键,补发次数 1。
  • 第 11 到 13 刻还是没动,第 13 刻第二次重发,补发次数 2。
  • 第 14 刻开头检查恢复条件,补发次数已到 2,恢复阶梯接手,先起跳一次。这时停滞时钟才 1.2 s 左右,按 3500 ms 的门槛还要再等约 2.3 s。

#6104 日志里前进键重发共 27 次。

冲刺与走路

冲刺条件

cpp
const bool heading_aligned_for_sprint = std::abs(heading_error) < kAutoSprintMaxHeadingErrorDeg;          // 25
const bool corridor_turn_calm =
    !nav_run_result.has_corridor_heading || nav_run_result.upcoming_turn_deg < kAutoSprintMaxUpcomingTurnDeg; // 40
const double sprint_remaining = nav_run_result.has_corridor_heading && std::isfinite(nav_run_result.remaining_to_anchor)
                                    ? nav_run_result.remaining_to_anchor : route.along_track_remaining;
const bool has_strict_arrival_braking_room =
    !target_requires_strict_arrival || route.waypoint_distance > arrival_distance + kStrictArrivalSprintBrakeDistance; // 6.0

冲刺需要同时满足:

条件来源
航向误差 < 25°(扣除在途和掉头锁定之前的原始误差)#3628
有走廊时,前方 8.0 内最大转角 < 40°#3628
后端支持冲刺—
起步已过 3000 ms(字面量,不在配置里)—
sprint_threshold > 0,且剩余距离 > 阈值(默认 16.0);剩余距离优先取走廊的 remaining_to_anchor—
前方是严判点时,到路点的直线距离 > 判定圈 + 6.0#3507「恢复长距离自动冲刺」
距上次冲刺 ≥ 1500 ms#1170「cppMapNavigator」

这组条件只在角色已经对准走廊、扫描范围内没有急弯时允许冲刺。条件里没有净空项:桥上的净空接近 0,带净空项会把冲刺完全锁死;朝向对齐和前方转角两条已经能防止冲进拐角。

前方是严判点时,只要求留出制动余量:在判定圈 + 6.0 之外照常冲刺,长直线跑向严判点时仍能冲刺,到余量处再刹住。

条件一直满足时每 1.5 s 重按一次。每次冲刺:

  1. 处于抑制状态时直接返回。
  2. 开启 ADB 的 60 ms 静默期,其他后端没有静默期。
  3. 调后端的冲刺输入:桌面右键按 30 ms,Linux 按 Shift 30 ms,ADB 点按冲刺键 50 ms。
  4. 游戏会在冲刺时自己退出走路,所以清掉走路状态,日志 “Walk mode cleared by sprint (the game leaves walking on its own).”。
  5. INFO “Sprint state armed.”。

每刻的判定结果(allow_sprint)打一行 DEBUG,便于事后对照:

plaintext
TickNavigate sprint gate. [allow_sprint=false] [heading_aligned_for_sprint=false] [corridor_turn_calm=true] [startup_grace_elapsed=false] [has_strict_arrival_braking_room=true] [sprint_remaining=79.008362] [param_.sprint_threshold=16.000000]

这一行来自起步第一刻:误差 105°,起步未满 3 s,两条都没过。

#6104 日志一趟的轨迹按冲刺条件的拦截原因上色,以及单刻判定的几何示意

左图取自 #6104 日志里的一趟,从右侧起点出发,到左下角结束。每一小段轨迹按这一刻被哪条条件拦住上色,几条同时不满足时取图例里靠前的那条。前 3 s 是起步宽限;每个拐角前约 8.0 处扫描段碰到拐角,条件不再满足,过了拐角才恢复;最后 16.0 剩余距离不够,不再冲刺。直线段上大多是浅蓝:几何条件都满足,在等 1.5 s 冷却。冷却在路点推进时清零,14 次冲刺里有 9 次发在路点推进的那一刻。“冷却”一项由允许冲刺的时刻和路点推进时刻反推,日志里没有单独输出。右图是单刻判定的几何:朝向在 ±25° 扇区内,沿走廊往前 8.0 的扫描段没有 ≥ 40° 的拐角,离锚点的走廊距离大于 16.0,锚点是严判点时还要在判定圈 + 6.0 之外。

提示点附近

离最近的采集、交互点,或前方还没经过的 DIG、滑索点不超过 8.0 时进入冲刺抑制;已经站上滑索架时不再算上索点。进入时若正在冲刺且按着前进,松开前进键 60 ms 再按下,把冲刺打断,INFO “Sprint cancelled near collect point (dropped to walking speed).”。这一机制来自 #3690「异步检测 COLLECT」。路线上没有需要减速接近的点时,这段逻辑完全不碰冲刺。

走路

#5027「新增游戏走路模式切换」加入走路。走路时速度和转速都减半,用于接近需要停准的点。

触发条件,任一即可:

  • WaitFind phase 一律走路。FIND 的接近段移动连续,修正只跟着每刻的识别框,跑起来容易冲过头。
  • 当前点有 walk_approach 特性,或是要做末端纠正的点,走路接近让滑行距离和到点后走回去的距离都减半。

走路开关在每刻最开头、按 phase 分派之前更新。任何 phase、任何提前返回的分支都先经过这一步,条件不再成立时,下一刻就会关掉走路。

步行带的进带与退带:

点进入退出来源
采集、交互等提示点3.04.5#5030「增强自动采集,收紧判定圈并接近时切走路」
滑索5.07.5#5337「提前切换步行接近滑索」
DIG5.07.5#5546「挖掘节点全部使用精确进近」

在这些点附近走路,交互提示能停留足够长的时间供识别和按键。进出分开做迟滞,因为每次按键都会翻转游戏里的走路状态。滑索的步行带比普通提示点大:步行带太小时,刚切换走路的下一帧就可能出现上索提示,角色立刻停下。DIG 在 2.25 处验收,普通 3.0 的步行带只留 0.75 用来减速,所以 DIG 提前到 5.0 开始走路,挖掘的验收位置保持 2.25。

DIG 单独按到 DIG 点的距离判断:更近的采集点可能还在自己的步行带外,稍远、但已经进入更大步行带的挖掘点照样要走路。跨过进带或退带时打 INFO “Walk mode boundary crossed.”。

阻断条件,任一成立就切回慢跑:

  • 不在 Navigate phase;
  • 定位无效;
  • 附近没有目标点;
  • 正在恢复(恢复阶梯或跨层逃逸);
  • 起步位移未确认,且该点没有 walk_at_startup 特性。连续挖掘会重置起步确认,DIG 带这个特性,短腿从起步就走路;等确认位移再切换时,人可能已经进了到达圈。

末端纠正带 settle_walking 特性的点进圈后也请求走路,收尾后切回慢跑。走路换来的是接近段和收尾的精度,到点就还回去,跳跃、冲刺这些动作照旧在慢跑态下执行。

几个带之间的关系由 static_assert 锁住:各类进带小于退带;步行带小于冲刺抑制带 8.0;提示触发带 3.0 小于滑索进带 5.0;DIG 进带大于严判前视 2.0 + 0.25。

#6104 日志里一次滑索接近的移动状态,以及步行带进带与退带的迟滞示意

左图是 #6104 日志里的一次滑索接近,三道圈以滑索站位点为圆心。角色沿走廊一路按冲刺;进 8.0 的那一刻(离站位点 7.62)松前进键打断冲刺;进 5.0 的那一刻(4.19)按走路键;走到 2.08 起跳上索,进入滑索流程时走路状态一并释放。圆点都画在触发那一刻所用的定位上。右图是迟滞:进带在内圈,退带在外圈,人在两圈之间的环里来回晃时保持上一刻的状态,不按键。DIG 的两道圈和滑索相同;采集、交互点的圈是 3.0 / 4.5,画在判定圈的图里。

走路状态是进程级的全局变量。上一轮遗留的走路状态会在下一轮开始时恢复成慢跑,WARN “Walk mode belief survived the previous run; restoring jogging before navigating.”。走路时速度和转速都减半,在途转角账寿命和前进键重发窗口都加倍。

桌面端按 Ctrl 切换;ADB 由 #6068「ADB 摇杆轻推走路模式」实现,摇杆只推到半径的 0.25。

输入后端

选择

控制器类型为 adb、playcover、native_android 时用 ADB 后端,linux 用 Linux 后端,其余用桌面后端。

项桌面LinuxADB
最小发送量1.0°2.0°1.5°
单笔上限30°18°90°
单刻最多90°54°90°
最小间隔016 ms0
静默期0060 ms
每度单位2.23006 × 宽 / 360同桌面3.0
转向相对鼠标移动同桌面单指滑动
冲刺右键按 30 msShift 按 30 ms点 (1166, 620),按住 50 ms
走路Ctrl 按 30 ms同桌面摇杆只推 18 像素
前进复位抬 W → 延时 → 按 W同桌面摇杆抬起 max(延时, 32) ms 后重新拖

三个后端都支持边走边转。冲刺会让游戏退出走路,ADB 冲刺之后把摇杆推回满行程。ADB 的其他按钮:跳 (1166, 475),攻击 (1030, 551),交互 (1080, 390),滑索上 (1099, 526),滑索下 (1166, 411),各用独立的触点编号。

平台支持的来源:#1999(ADB、PlayCover)、#2367「支持后台寻路」、#5114(Linux 控制器)、#5451「适配 PlayCover 多指触控」。

ADB 视角滑动

ADB 的一次转向是一次单指滑动,以 1280 × 720 为坐标系:

  • 起点中心 (640, 264),即横向居中、纵向比屏幕中心高 96。每次滑动横向最多 270 像素,纵向按边距 32 限在上 232、下 423 以内;超出的部分拆成多次滑动依次发出,任一次失败,这次转向返回失败(#6142)。
  • 以中心横向对称起落:起点 = 中心 − 位移 / 2,终点 = 起点 + 位移。这样滑动万一被当成点击,落点也离屏幕中心最近,碰不到两侧的任务追踪和按钮。
  • 横向 270 的上限也来自对称起落:位移越长,按下点越靠左,过长时按下点会落进左侧的摇杆区域,那一次拖动推动的是摇杆,镜头保持不动。270 = 90° × 3.0,正好是灵敏度系数为 1 时一刻的最大转角,按下点最左在 x = 505。
  • 每次滑动的时序:按下,停 100 ms;分 6 步移动,步间停 10 ms,最后一步之后不停;停 100 ms;抬起。睡眠合计 250 ms,另有 8 次控制器往返(按下、6 次移动、抬起)。

两端各停 100 ms:游戏逐帧采样触点,与按下或抬起同一帧的位移会整步丢失,两端的停留要盖过负载高时的一帧。

ADB 滑动时序与在途寿命

左图是 #6097 前后一次滑动的时序。旧版两端停 8 ms 和 30 ms,睡眠合计 88 ms,那份 PlayCover 日志里实测每笔 124 到 176 ms,往返开销约 36 到 88 ms。按同样的开销推算,新版每笔约 290 到 340 ms,这个数没有实测。右图是一刻发出的总转角对应的在途寿命,实线为慢跑,虚线为走路。

#6097 把单笔上限从 20° 放到 90°,每度从 5.0 改到 3.0。旧版一刻最多 3 × 20° = 60°,新版一刻就能发 90°,灵敏度系数不超过 1 时只要一次滑动;两端停留加长到 100 ms,对应标题里的”丢步”。

两个边界情况:

  • 配置里的 swipe_duration_ms = 70 只在构造时赋值、出现在初始化日志里,滑动逻辑不读它。
  • 90° × 3.0 = 270 像素,灵敏度系数大于 1 时,一刻的 90° 要拆成 2 到 3 次滑动。系数取上限 2.5 时是 675 像素,拆成 270 + 270 + 135,光睡眠就有 750 ms。#6142 之前横向不拆,系数超过 607 / 270 ≈ 2.25 时一次滑动会被屏幕边界截断,上报给灵敏度估计的却是截断前的量;现在整量发出,上报的与实发的一致。

ADB 摇杆

虚拟摇杆原点 (198, 552),半径 72。按下时在原点按下,停 16 ms,再分 4 步每步 16 ms 拖到目标点,共约 80 ms。走路时只推半径的 0.25,即 18 像素,斜向再除以 √2;切换走路时若触点按着,立即重新拖到新半径。释放后停 max(延时, 32) ms。

静默期

ADB 在移动指令之后 60 ms 内不发转向:摇杆状态刚变时,游戏还没受理视角拖动,这段时间里的转向会被吞掉。动作真正改变(或强制设置)时开启,冲刺时也开启,原地停住时清除。静默期里 ApplySteering 返回未发出,所以不记在途转角账。FIND 的步进间隔必须长于静默期,由 static_assert 锁住。

延迟与灵敏度

每刻耗时

每刻在工作做完之后固定休眠 33 ms,周期是本刻工作耗时加 33 ms。刻间隔按”本刻开始到上刻开始”计。

两份日志的每刻耗时分布

两份日志的累计分布。左为 macOS 上的 PlayCover,1132 刻:定位本身中位 35 ms,截图加定位 124 ms,刻间隔 200 ms。右为 #6104 日志(Windows),2621 刻:定位 84 ms,截图加定位 141 ms,刻间隔 186 ms。两边的刻间隔都远长于 33 ms 的休眠,大头在截图和定位上。

延迟检测

#4958(08-12)「新增寻路延迟异常提示」在机器过慢时提示用户。参数:

参数值说明
慢刻阈值280 ms健康时每刻约 110 ms,到 350 ms 就走不动了。判据要求过半的刻超线,实际触发点比 280 ms 高约 100 ms
预热、窗口2000 ms、5000 ms按真实时间切。慢机每秒攒不到几刻,按刻数切的话,机器越慢越难判出来
每窗最少刻数8满 5 s 但不够 8 刻时接着攒,稀疏的窗不会被几刻带偏
连续慢窗3要连续慢满 15 s 才报,中间一窗正常就清零重来
z2.326单侧 99% 置信。下界过半才判慢,等于有把握认定过半的刻都超线

慢刻比例。刻间隔超过 280 ms 的刻叫慢刻。一个窗里有 刻,其中 刻是慢刻,样本比例是

直接拿 判慢不可靠,刻数少时运气成分很大:假设这台机器真实的慢刻比例只有 40%,8 刻里碰巧出现 5 刻以上慢刻的概率仍有 17%,这时 。检测器要回答的是:根据这 刻,真实比例 最低可能是多少。连这个最低值都过半,才算有把握说过半的刻都超线。

Wilson 下界。真实比例为 时,每刻是不是慢刻相当于抛一枚正面概率为 的硬币, 服从二项分布。刻数不太少时, 近似服从正态分布,均值是 ,标准差是 。一个候选的 如果离观测到的 不超过 个标准差,就认为它和观测相容:

两边平方,整理成关于 的二次不等式:

满足它的 落在两个根之间,这一段就是 Wilson 得分区间⁠[3]。较小的根是下界:

是标准正态分布单侧 1% 的分位点,真实比例低于 的可能性约为 1%。 的窗记为慢窗。

教科书上更常见的 Wald 区间 把标准差里的 直接换成 。8 刻全慢时 ,Wald 区间宽度为 0,等于 8 刻就断定比例恰好是 100%。Wilson 区间的标准差按候选的 计算,同样 8 刻全慢,下界是 0.60,刻数少时区间自然变宽。

代码就是上面 的公式,centre 是分子的前两项,margin 是根号那一项:

cpp
double WilsonLowerBound(int64_t hits, int64_t total, double z)
{
    if (total <= 0) {
        return 0.0;
    }
    const double n = static_cast<double>(total);
    const double p = static_cast<double>(hits) / n;
    const double z2 = z * z;
    const double centre = p + z2 / (2.0 * n);
    const double margin = z * std::sqrt(p * (1.0 - p) / n + z2 / (4.0 * n * n));
    return std::max(0.0, (centre - margin) / (1.0 + z2 / n));
}

换算成慢刻数。判据 可以化成只含 的不等式。 说明 0.5 落在区间下方:0.5 和观测不相容,并且 。把 代进相容条件再取反:

右边有直观的含义:每刻慢不慢各占一半时, 的均值是 ,标准差是 ,门槛就是均值往上 个标准差。各刻数下的门槛:

窗内刻数 最少慢刻比例
87.298100%
97.99889%
108.68990%
1411.351286%
2015.201680%
4530.303169%
慢刻比例恰好一半时一窗里慢刻数的分布

假设机器处在临界状态,每刻慢的概率恰好 50%,一窗里的慢刻数服从上图的二项分布。虚线是上表的门槛,红色柱是会被判成慢窗的情形,14 刻的窗合计 0.65%,45 刻的窗合计 0.80%,都在 1% 以内。门槛超出一半的部分 比 增长得慢,所以刻数越多,所需比例越接近一半。

健康机器 110 ms 一刻,5 s 约 45 刻,要 31 刻超线;慢到 350 ms 一刻时约 14 刻,要 12 刻超线。实际触发点因此比 280 ms 高出约 100 ms。

窗口与计数:

  • 只收刻序号与上一刻连续的样本。刻序号断开说明中间有刻走了跳跃、攀爬、换层、丢定位这些分支,间隔里混着它们的耗时。只有走完整个常规导航刻的才计入,语义节点、恢复、丢定位的刻在前面就返回了。
  • 窗口从第一个计入的样本开始计时,满 5 s 且至少 8 刻才结算。
  • 出现正常窗时计数清零,历史一起清空,提示里报的中位数因此正好来自判慢所依据的那几窗。
  • 连续 3 个慢窗后报一次,之后不再判。日志 “Navigation tick latency looks abnormal.”,带刻间隔中位数、各阶段中位数、慢刻比例和样本数;同时发通知 navigation.latency_slow。

连续慢窗数 每结算一窗更新一次,到 3 时报告:

各窗的刻互不重叠,把各窗看作相互独立来估算,临界状态的机器连续 3 窗都被判慢的概率不超过 。

各阶段:Screencap、ImagePrep、Locate 来自定位,Steer 是转向各批发送耗时之和,Other = 本刻计算耗时 − 截图定位 − 转向。判定只看整刻间隔,各阶段耗时只用来说明时间花在哪。

延迟检测的 Wilson 判据

左图黑色阶梯是各刻数下判慢所需的最低慢刻比例,红色区域为慢窗。散点是两份日志按检测器规则切出的窗:#6104 日志 106 窗,一个慢窗都没有;PlayCover 日志 56 窗,11 个判慢,都在左上角刻数很少的窗里,最多连着 2 个,所以两份日志都没有报。右图是真实超线比例固定时,Wilson 下界随刻数的变化:比例 75% 时要 20 刻左右才过线,60% 时 60 刻也过不了。

#6097 之后 ADB 一次转向滑动约 0.3 s,发了转向的刻单靠 Steer 阶段就接近 280 ms。一段路大部分刻都在转向时,ADB 上可能误报。检测器写于 #6097 之前,当时一次滑动约 0.15 s。这一条是推断,没有日志验证。

灵敏度检测

游戏的视角灵敏度是玩家设置,同一条指令在不同玩家那里转出不同的角度。检测器比对发出的转向和实测的朝向变化,估计实际转到了指令的多少倍。

两版模型:

  • #4969(08-12)「新增转向灵敏度异常提示」:6 刻滞后,按 12 s 真实时间切窗、每窗累计指令至少 150°,过半窗口倍率超过 1.2 时报中位数。这一版只报转过头:转不到位有太多正常原因,指令被吞、贴墙转不动、掉帧都会让倍率偏低,从倍率上分不出是哪种。
  • #5583(09-08)「灵敏度判决落地后自动缩放转向量」:整体重写为 12 刻分布滞后的岭回归⁠[4],加入偏慢判定,判决成立后自动缩放转向量。下面描述的是这一版。

模型。记号:第 刻发出的转向指令为 (度),第 刻读到的朝向为 ,两刻之间的朝向变化为 。

一条指令不会在下一刻就转完:游戏要过几帧才把视角转过去,截图本身也有延迟,转量摊在后面几刻里。设指令发出后的第 刻兑现它的 倍,本刻看到的朝向变化就是之前各条指令在这一刻兑现的份额之和:

是识别误差之类的噪声, 是第 阶滞后的增益,这种模型叫分布滞后模型。一条指令 前后总共兑现 ,所以倍率是

灵敏度正常时 。逐刻拿 除以上一刻的指令,量到的只是 ,#6104 日志里 只有 0.03;12 阶一起拟合,才能把摊开的转量加回来。

每记一行,就是记下 和 。 行叠起来写成矩阵:

有 行 12 列, 是要求的未知数。

记行规则:

  • 刻序号连续、滞后链满 12 刻时记一行。攒不满说明账刚断过,这刻不进方程。

  • 跳过一刻、那刻没发转向、两刻相隔不超过 600 ms 时,两刻并成一行。跳过的第 刻没有单独记行,它的方程照样成立,而且 。两刻的方程相加:

    未知数还是同一组 ,第 列换成两刻各自对齐后的指令之和。代码里 row[i] 是 的系数;跳过那刻的 在这一行记完之后由 PushLag(0.0) 补进滞后链。

cpp
else if (linked && gap_ticks == 1 && gap_ms <= config_.bridge_max_gap_ms) {
    if (chain_len_ >= kLagCount) {
        LagVector row = cmd_lags_;
        for (int i = 1; i < kLagCount; ++i) {
            row[i] += cmd_lags_[i - 1];
        }
        const double heading_delta = NormalizeDeg(heading - prev_heading_deg_);
        total_.Add(row, heading_delta, issued_delta_deg);
        run_.Add(row, heading_delta, issued_delta_deg);
    }
    PushLag(0.0);
}
  • 其他情况滞后链清零;定位降级时也清零。
  • 所有偏航输出都从同一个出口走,出口按当下的系数折回度数累加。出口的累计和本刻上报的指令差 0.5° 以上,说明别的路径发过转向,之前的滞后链对不上号,作废。
灵敏度检测逐刻记行

#6104 日志 06:46 那一趟的第 20 到 29 刻,逐刻往 里记行。每行 12 格是这一刻之前 12 刻发出的指令(度), 是上一刻,最右一列是本行的 。每过一刻整排右移一格,同一条指令在矩阵里沿斜线往下走,12 刻后从右边出去:第 22 刻发出的约 20° 在第 23 刻落在 ,第 24 刻落在 ,这一刻朝向转了 17°。带 * 的第 22、27 刻是两刻并成的一行,第 21、26 刻没有单独记行,格子里的 a+b 是两刻各自对齐后的指令。左侧罗盘对比实测的 和用最下一行 算出的预测 :单行的预测和实测能差出 10°,回归要的是全部 1470 行的残差平方和最小,单行有偏差很正常。

求解。最小二乘取让所有行残差平方和最小的 :

对每个 求偏导并令其为 0,得到正规方程 。岭回归在左边加一项 :

这等价于在残差平方和上再加罚项 。寻路时指令常常连着几刻同向、大小相近,相邻两列几乎一样, 和 之间怎么分配都拟合得差不多, 接近奇异,单个 会解得大正大负。罚项在这些分法里挑系数平方和最小的那个;倍率只看 ,分法变化对它影响很小。

按矩阵自身的尺度取:

是对角线的平均值,即每列指令平方和的平均, 取它的千分之一。样本数翻倍、或者指令幅度翻倍时,、 和 按同一个倍数放大, 不变,正则化的相对强度始终一样。

累加量。检测器不存样本,只存三个累加量,每记一行加一次:

合计 144 + 12 + 1 个数,占用的内存和跑了多少刻无关。全程和当前线路各有一份。求解时用 Gauss-Jordan 消元同时解两组方程,系数矩阵都是 :

第一组给出增益,倍率估计 ;第二组用在下面的标准误差上。

残差与标准误差。残差平方和展开后只用到三个累加量,不用回放样本:

噪声方差的估计:

分母减 12,是因为拟合 12 个系数用掉了 12 个自由度:只有 12 行时总能找到一组 把每行都拟合得严丝合缝,RSS 为 0,噪声却依然存在。

是 12 个 之和。噪声让每个 都有波动,而且它们相互关联。普通最小二乘里 的协方差矩阵是 ,求和的方差等于这个矩阵所有元素之和,即 。检测器用 代替 :

正好是第二组方程的解 各分量之和,所以和 一起解。岭回归估计的精确协方差是 ,比 略小; 只有对角线均值的千分之一,两者几乎相等,检测器的写法让 SE 略偏大,判决略偏保守。

SE 随样本数大约按 缩小:#6104 日志里 从 500 样本时的 0.82 降到 1534 样本时的 0.73。

判决。偏快的条件:

偏慢的条件:

此外还要没有任何线路单独读出正常值,见下文。 是倍率的置信区间,整个区间都落在判决线外才判。真实倍率恰好在判决线上时,估计值靠噪声越过 3 倍标准误差的概率约 0.13%。

cpp
const double margin = config_.sigma_margin * estimate->se;
const bool too_fast = estimate->ratio - margin > config_.overshoot_ratio && estimate->ratio <= config_.max_ratio;
const bool too_slow = estimate->ratio + margin < config_.undershoot_ratio && estimate->ratio >= config_.min_ratio && !normal_run_seen_
                      && !RunReadsNormal();
参数值说明
判决线1.20、1 / 1.20正常机的估计值落在 1.00 ± 0.05 以内
σ 余量(sigma_margin)3 倍标准误差估计值朝 1.0 退 3 倍标准误差还过线才判,置信区间整个落在判决线外才算证据
有效范围[0.4, 2.5]估计值超出这段就当观测本身出了问题,不改
起判样本500攒够 500 刻才开始判,之后样本每翻一倍再判一次,线路结束也判一次
单线路否决50 样本单条线路攒到 50 个样本,它自己的估计值才能否决偏慢的判决
并行间隔600 ms刻序号跳一格且那刻没发转向,两刻相隔在 600 ms 内才并成一行

偏慢的判决要更小心。卡墙时指令很大、朝向不动,一段卡墙就能把全程估计压到一半,所以判偏慢还要求没有任何一条线路单独读出正常值。累加量有全程和当前线路两份;任何一条线路单独攒到 50 个样本且倍率高于 0.833,就否决偏慢。

判决在累计样本数到 (500、1000、2000……)时各做一次,每条线路结束时样本够 500 也做一次。要多少样本才判得出来:把 代进偏快条件,

按 #6104 日志的 估算,并假设噪声水平和这份日志相当:倍率 1.5 时要 53 个样本,1.35 时要 212 个,都在第一次判决(500 样本)时成立;1.25 时要 1903 个,到 2000 样本那次才成立。

灵敏度估计的置信区间与判决线

蓝点是 #6104 日志里检测器记下的 15 次估计:500、1000 两次是定时判决,其余 13 次在线路结束时。竖线是 ±3 SE,全部落在两条虚线判决线之间,所以没有判。红、橙两条实线是把 代进判决条件得到的界,估计值高过红线才判偏快,低过橙线才判偏慢:500 样本时要高过约 1.30,2000 样本时要高过约 1.25。样本越多,界越贴近判决线。

落地。判决只触发一次,乘在偏航和俯仰上的系数取倍率的倒数,一直保持到进程退出:

校正后发 度,实际转过 度。转过头时 ,缩小转向量;转不到位时 ,放大转向量。日志 “Turn sensitivity corrected.”,通知 navigation.sensitivity_corrected。之后累加量清零从头再估,日志里能看出校正后的倍率是否回到 1。

灵敏度估计的滞后核

用 #6104 日志的 22 趟、1470 刻样本,按同样的岭回归离线重估 12 阶增益。转量主要落在第 2、3 刻(按中位刻间隔 186 ms 折算约 0.4 到 0.6 s),第 4 刻之后基本归零。累计倍率 0.968 ± 0.019,日志里检测器同期的估计是 0.978 ± 0.019,都在正常带 [0.83, 1.20] 内。第 1 阶增益只有 0.03:指令发出后的下一刻,朝向几乎还没动。

卡死与恢复

NavMesh 只记录静态地形。路上的箱子、设备、矮墙边缘、模型碰撞比网格宽出来的那一截,未纳入白名单的实体规划层都看不见:路线是通的,角色却顶在原地。跟随层只会沿走廊一直推,把这种情况交给恢复阶梯处理。

阶梯有四档,按锚点计次,整体受 30 s 硬时钟约束:

档动作开放条件次数上限出处
搬走挡路设备跑 MapNavigatorObstacleDevice 子任务,把挡路的设备搬走探测到交互按钮每锚点 1 次#4975「移植卡死恢复阶梯前置搬走挡路设备」
跳前跳,等 500 ms,重新取位每个恢复刻不限—
虚拟禁区绕障在卡死点前方放一块圆形禁区,重规划到锚点本锚点已跳满 2 次每锚点 3 次#5576「卡死恢复的 detour 一档改用运行期虚拟禁区重规划绕障」
物理脱困扫描可走面选方向,转身走一步本锚点已跳满 2 次,绕障失败、用完或被跳过不限#3995「基于 NavMesh 的自动绕障」

跳是首选的反应:矮的障碍跳过去就能沿原来的精确路线接着走。同一锚点跳满 2 次(kRecoveryJumpAttemptsBeforeDetour)还脱不了身,才退到 NavMesh 绕障。

#6104 日志的 22 趟里,恢复跳共 26 次,虚拟禁区 9 个。

触发

cpp
const bool should_try_recovery =
    session_->phase() == NaviPhase::Navigate && (stalled_ms >= kObstacleRecoveryMinTriggerMs || forward_hold_futile)
    && (effective_progress > kNoProgressMinDistance || waypoint.RequiresStrictArrival()) && !runtime_state_.cross_tier_escape.active;

四个条件:

  1. 处于 Navigate phase。滑索、FIND、TRANSFER、TRIGGER 的等待 phase 都在阶梯之外。
  2. 普通停滞时钟 ≥ 3500 ms,或前进键重发 2 次仍然原地不动(forward_hold_futile,见前进键闭环)。
  3. 有效进展 > 3.0,也就是离锚点还有 3.0 以上;严判点不受这条限制。3.0 以内的停滞交给无进展超时。
  4. 不在跨层逃逸中。逃逸走的是一条固定走廊,有自己的失败判据。

第 3 条用的距离和停滞时钟是同一个信号。走廊跟随时,这个距离是走廊上剩下的真实距离;串行路点只是沿途的一个记号,可能就在角色身边一个像素外,整段路却还在前头。拿串行路点的距离来判,贴着它卡住的角色会被当成”已经到了”,到达需要的起步动作做不出来,恢复也不会启动。

第 2 条的前进无效计数是停滞时钟正在收集的同一种证据,来得更早也更强:朝向对准了、没在转向、前进键重发两次位置读数都没变。再等停滞时钟走满只是继续往障碍上撞,所以它也能打开阶梯。

触发之后先解析当前锚点(规则见锚点)。恢复回合和升级计数都挂在锚点序号上,锚点变了就重开一轮。换锚点时还会丢掉设备探测器已经存下的结果:那个结果是在对付上一个锚点时看到的,画面早于路线重新编号。第一个锚点不丢,这时还没对付过别的锚点,探测器从停滞开始就在为它预热。

阶梯

满足触发条件、且距上一个恢复刻已过 3500 ms 冷却的刻,才是一个恢复刻;一个回合的第一刻没有冷却。一个恢复刻里按顺序执行:

mermaid
flowchart LR
    subgraph G2a [恢复刻]
        direction TB
        T{"硬时钟<br/>> 30 s?"} -->|ZIPLINE 点| Z(["退索走路"])
        T -->|其他点| F(["dynamic_recovery_timeout"])
        T -->|否| D["搬走挡路设备<br/>每锚点 1 次"]
        D -->|脱出| R(["ResumeAfterEscape"])
        D --> J["前跳 500 ms<br/>重新取位"]
        J -->|脱出| R
        J --> E{"本锚点<br/>已跳 ≥ 2 次?"}
        E -->|否| W(["等下一个恢复刻"])
        E -->|是| UP(["转升级"])
    end
    subgraph G2b [升级]
        direction TB
        G{"绕障 < 3 次<br/>且不在唯一通路旁?"} -->|是| V["虚拟禁区<br/>重规划"]
        V -->|成功| N(["沿新路线继续"])
        V -->|失败| U["物理脱困"]
        G -->|否| U
        U -->|找到方向| N
        U -->|找不到| W2(["等下一个恢复刻"])
    end
    G2a --> G2b
  1. 硬时钟超时。硬时钟停滞超过 30000 ms 时,ZIPLINE 点先丢掉整条滑索链改走路,其余点以 dynamic_recovery_timeout 失败。退索会把硬时钟归零,再超时时当前点已经是步行点,那一次才判失败。卡在索边收不拢时先退索走路,给步行一次机会,这个分支也不会反复进入。
  2. 冷却。距上一个恢复刻不足 3500 ms 时什么都不做。
  3. 搬走挡路设备。本锚点还没试过时执行,见搬走挡路设备。设备搬完人仍然卡着,同一刻继续往下走。
  4. 跳。每个恢复刻都跳,见跳。
  5. 升级。本锚点的跳计数到 2 之后,同一刻继续:
    • 卡死朝向取走廊航向,没有走廊时取路线航向。
    • 附近有标为唯一通路的圆形禁区(距离 ≤ 圆半径 + 1.0 + 1.5)时跳过绕障。
    • 绕障次数 < 3 时做虚拟禁区重规划。成功就切回 Navigate 继续走;失败打 WARN “Dynamic recovery detour attempt failed; switching to physical unstick.”。
    • 绕障失败、次数用完或被跳过时,同一刻做物理脱困。
  6. 以上都没有结论时睡 33 ms,等下一刻。

跳在每个恢复刻都执行,新的一次停滞总是先试着跳出去;同一锚点跳失败 2 次后,后面的档位才打开。绕障在前方放一块圆形虚拟禁区再重规划,这个圆在整趟导航里一直把路线挡在这块地方之外。已经证明会封住唯一通路的地方跳过绕障,直接物理脱困。

阶梯的档位按恢复刻叠加,每一刻都从设备和跳开始。卡在同一个锚点上、每档都没脱出时的节奏:

恢复刻动作
1搬走挡路设备(探测到设备时)+ 跳 #1
2跳 #2 → 绕障 #1,失败则同刻物理脱困
3跳 #3 → 绕障 #2
4跳 #4 → 绕障 #3
5 起跳 → 物理脱困

恢复刻之间至少隔 3500 ms,实际间隔取决于触发条件何时再次成立。绕障成功会换上新路线,普通停滞时钟随之清零;前进无效计数在换路线时保留,角色转向或挪动才清,处在等待 phase 时每刻都清。#6104 日志里的两次脱困正好是这两种情形:06:31 那次,跳 #3 在新路线换上 0.24 s 后就发出,此时停滞时钟刚清零,打开阶梯的只能是前进无效计数;06:41 那次,新路线换上后停滞时钟从 0 重新攒到 3.5 s,才有跳 #3。

脱出检查。恢复回合开始时记下卡死点位置。之后的每一刻,在阶梯之前先检查:离卡死点 ≥ 2.0,或定位区变了,就判定脱出,日志 “Dynamic recovery escaped obstacle.”,进 ResumeAfterEscape。跳和搬走挡路设备的脱出判据在各自动作完成后当场再查一遍。

一次完整的卡死恢复:左为地图上的卡死点、两块圆形禁区和物理脱困方向,右上为各档事件时间线,右下为停滞时钟

#6104 日志 06:41 那一趟,起步 2 s 后卡在 (912.12, 263.02),绕障的目标是前方的滑索上架点。右下的停滞时钟取自每刻的走廊日志;恢复刻不经过那条日志,图上表现为平台。事件顺序:

时刻(s)事件
9.2、9.8前进键重发两次,位置不变
10.1停滞时钟才到 1.2 s,前进无效计数先打开阶梯:跳 #1
13.7冷却 3.5 s 后跳 #2,计数到 2,开始升级
14.4圆形禁区 ① ,朝向 124°
14.8新路线换上,生成 10 个点;停滞时钟清零
14.9–18.5人没动,一直没有确认起步,停滞时钟重新攒到 3.5 s
18.5跳 #3
19.1圆形禁区 ② 朝向 81°:试探圆心落在 ① 外扩 1.5 的范围内,半径长到 3.5
19.6规划失败(目标面不可达),② 半径减半到 1.75 再规划
19.9仍然失败,② 标为唯一通路;同一刻转物理脱困
20.9物理脱困朝 306° 走 2.5,实际位移 1.58(,算挪开了);以脱困前的位置为原点再放一块圆形禁区,又长到 3.5,位置和 ② 收缩前相同
21.5从新位置出发规划成功,生成 29 个点
21.6离卡死点 2.19,脱出

第三个圆和收缩前的 ② 大小、位置都一样,两次规划的差别只在起点:卡死点出发时目标面不可达,挪开 1.58 之后就规划通了。

搬走挡路设备

路上放置的设备挡路时,可以交互把它拿起来挪开。这一档共 11 张模板,后台扫描器由采集点扫描器泛化而来。

探测。后台扫描交互按钮所在的区域,ROI 直接读 Pipeline 里交互按钮检查用的那一个,配置只有 JSON 一处。阈值 0.65,低于 Pipeline 默认的 0.7:探测只决定子任务值不值得跑,子任务动手之前还会按自己的阈值再查一遍。

探测从停滞的第一刻就开始喂,到第一个恢复刻时结果已经存好,读取不花时间。停滞时钟重新起跑的边沿会丢掉旧结果,等待 phase 的每一刻也会丢掉(#6228「语义动作期间清掉顶墙计数与设备命中」)。

执行:

  1. 探测没命中,返回”未尝试”,直接去跳。面前没有可交互的东西,跳是更快的办法。
  2. 重按前进,跑子任务。前进键在子任务期间一直按着:子任务把设备拿起来,角色要在它放下之前走出设备底下,否则设备又落回原处挡路。
  3. 重新取位。取不到就返回”需要新定位”,这一刻结束。拿旧坐标往下走的话,后面的跳会从旧位置量位移,报出一次没发生过的脱出。
  4. 用和跳一样的脱出判据判断。没脱出时日志 “Device handled but the agent is still pinned; continuing to the jump.”,同一刻接着跳。

除”未尝试”之外,其余结果都跑过子任务,都消耗次数。每个锚点只有 1 次:子任务自己的超时就可能花掉 30 s 硬时钟里的约 15 s,剩下的时间还要留给跳、绕障和物理脱困。

子任务里交互后的等待取 1000 ms。PR 里的实测:交互后 0.79 s 时按钮的识别置信度只有 0.53 到 0.65,到 1.28 s 才升到 0.97。

跳

  1. 跳计数加 1,发出日志 “Dynamic recovery jump pulse issued.”。
  2. 前跳,睡 500 ms,松前进键。
  3. 重新取位。取位失败、黑屏或位置无效时,发出日志 WARN “Dynamic recovery waiting for post-jump local tracking fix.”,等下一刻。
  4. 判断脱出:
cpp
const bool jump_made_progress = jump_waypoint_distance + kNoProgressDistanceEpsilon < route.waypoint_distance;
const bool jump_moved_forward = jump_displacement >= kDynamicRecoveryResetDistance * 0.5 && jump_made_progress;
if (jump_zone_changed || jump_displacement >= kDynamicRecoveryResetDistance || jump_moved_forward) {
    LogInfo << "Dynamic recovery jump escaped obstacle." << VAR(jump_zone_changed) << VAR(jump_displacement)
            << VAR(jump_moved_forward);
    return ResumeAfterEscape("recovery_jump_escape");
}

三种情况算跳出去了:换了定位区;位移 ≥ 2.0;位移 ≥ 1.0 且离当前路点近了 0.5 以上。第三条接受位移不到 2.0、但确实朝路点前进了的跳。日志样例:

plaintext
Dynamic recovery jump escaped obstacle. [jump_zone_changed=false] [jump_displacement=2.499300] [jump_moved_forward=true]
  1. 没跳出去时重新解析锚点。跳完人可能落到了另一段路线上:锚点变了,恢复回合在新位置重开,升级计数重置,跳计数记为 1。

虚拟禁区

#5576 之后,绕障这一档的做法是在卡死点前方放一个圆形禁区,再重规划到锚点。

起点到终点的直线穿过一处网格里没有的障碍,角色在叉号处卡死,卡死点前方放一个半径 2.0 的圆形禁区,重规划的红色路线贴着圆边绕到终点

#5576 里的原理图,俯视,纵坐标向下增大。起点到终点的直线(虚线)穿过一处障碍,NavMesh 里没有这块障碍,规划器照直线出路线,角色朝上走到叉号处卡死(y 1847.0)。恢复阶梯沿卡死朝向在前方 3.0 处放一个 的圆形禁区(圆心 y 1844.0),圆内的格从可走集合里拿掉,再规划一次,新路线(红线)贴着圆边绕到终点。图里的终点对应恢复流程里的锚点。障碍有一部分在圆外,圆是对障碍大小的估计。

原理是把卡死当成一次障碍观测。NavMesh 上没有的障碍,规划器看不见,每次重规划都给出同一条穿过它的路线;角色卡住的位置和朝向说明障碍大致在卡死点正前方。在那里盖一个圆,规划器把圆内的格当成不可走,新路线就绕开这块区域。圆的大小靠估计:盖小了,角色会在圆边上再卡一次,这次卡死说明障碍比圆大,新圆长一档,旧圆保留;盖大了,可能把整条窄路封死,规划失败时半径减半再试,仍失败就把这处记为唯一通路,交给物理脱困。standoff 距离把角色所在的格留在圆外,重规划才有起点;锚点净空把锚点留在圆外,重规划才有终点。

参数值
初始半径2.0
生长步长1.5
半径上限6.5
半径下限1.0
standoff 距离(卡死点到圆边)1.0
锚点净空1.0

圆心。卡死朝向 按罗盘角计(0 朝上、顺时针),地图纵坐标向下增大,方向向量是 。记卡死点为 ,半径为 的圆,圆心取在

圆的近边离卡死点正好 1.0,卡死点本身不在禁区里。朝向的口径与物理脱困的 PlanUnstickTarget 相同。

左图:路宽 12 的道路上朝 0° 卡死,半径 2.0 的圆心前移 3.0,近边离卡死点 1.0;右图:半径 2.0、3.5、5.0 与上限 6.5 的圆,近边都在卡死点前 1.0 处

同样来自 #5576,俯视。左:路宽 12 的道路上,角色朝 0°(向上,)卡死,,圆心前移 3.0。圆的近边离卡死点 1.0,角色所在的格留在圆外,重规划有起点。圆两侧到路边各剩 3.33 和 4.67。右:把各档半径画在同一个卡死点上。半径按 2.0、3.5、5.0 生长,圆心跟着前移,近边始终在卡死点前 1.0 处。 时左侧只剩 0.33;上限 6.5 的直径 13 超过路宽 12,整条路被封死,这时规划失败,进入下面的失败处理。

半径分四步定:

  1. 生长。从 开始,先算一个试探圆心 。逐个检查已有禁区,第 个的圆心为 、半径为 。同一定位区、没标唯一通路、并且试探圆心落在它外扩一档的范围内:

    就把半径提到比它大一档,封顶 6.5:

    在已有禁区的边缘又卡死,说明障碍比那个禁区大,新禁区在它的半径上加一档。旧禁区原样保留,两者的并集仍然盖住最初的卡死点。

  2. 锚点上限。记卡死点到锚点的距离为 。锚点正好在前方时圆心离锚点最近,两者相距 。按这个最坏情形,要求锚点留在圆外、并且离圆边至少 1.0:

    锚点在 6.0 处时上限 2.0; 时上限低于下限 1.0,不放禁区。锚点太近时禁区会盖住锚点本身,重规划没有终点。

  3. 让出当前位置。卡死点和当前位置可能不同(物理脱困之后放的圆,卡死点是脱困前的位置)。记当前位置为 ,当前位置落在圆内时规划起点就在禁区里。半径逐次减半,圆心跟着按新半径重算,直到当前位置落在圆外:

  4. 下限。 时放弃,日志 “Virtual no-go skipped: no room for a disc between here and the anchor.”。

放下之后日志 “Virtual no-go stamped.”,带是否生长、圆心、半径、卡死朝向和累计个数。

规划。禁区随动态覆盖的规划一起传给规划器。展开层只传同一定位区、没标唯一通路的圆。规划器把格心 满足 (平面距离)的格从可走集合里拿掉。运行期禁区不带高度,窗口里的层在这之前已经按起点层高筛过,按平面距离盖格就够了。和作者在 NavMesh 里画的禁区相比,运行期禁区少一条端点规则:起点或终点落在圆内照样规划。

失败处理:

  1. 规划失败时半径减半(同样要让出当前位置),圆心按新半径重算,再规划一次,日志 “Virtual no-go shrunk after replan failure.”。规划失败多半是禁区封住了整条窄路;已经是最小半径时不再重试。
  2. 仍然失败,说明这里是唯一通路,这个圆标为唯一通路(push-through),日志 WARN “Virtual no-go blocks the only passage; marked push-through.”。
  3. 唯一通路圆之后不参与任何规划。当前位置离它的圆心不超过 (standoff 1.0 加一档 1.5)时,阶梯直接跳过绕障,改走物理脱困。

生命周期。禁区放在运行期状态的顶层,整趟导航内一直保留,只在下一次 BeginNavigation 时清空。禁区记录的是障碍的位置,随重规划一起清掉的话,下一次规划又会穿过刚判定有障碍的地方。禁区按定位区坐标记录,只对同区规划生效。

三块圆形虚拟禁区的位置、朝向和生长关系,以及每次重规划的路线代价和规划耗时

#6104 日志 06:31 的一次脱困,三块圆形禁区的卡死朝向依次是 57°、345°、121°。第二、三个圆的试探圆心(十字)都落在第一个圆外扩 1.5 的虚线圈内,半径长到 3.5;它们离第二个圆都不够近,没有再长到 5.0。圆点是由圆心和朝向反推的卡死点。右图是每次重规划的结果:禁区越多,绕到同一锚点的路线越长,代价从 348 涨到 419。三次规划耗时 3.4 到 4.7 s,运行期重规划同步执行,这段时间整个导航都停在这一刻里。事件顺序(相对第一次前进键重发):

时刻(s)事件
0、0.8前进键重发两次,位置 (714.84, 1423.41)
1.1跳 #1
4.7跳 #2
5.5禁区 1:,朝向 57°
8.9新路线换上,生成 67 个点
9.1跳 #3
9.9禁区 2:生长到 ,朝向 345°
14.6新路线换上
14.8跳 #4
15.5禁区 3:,朝向 121°
19.8新路线换上
20.1离卡死点 4.0,脱出

物理脱困

物理脱困来自 #3995。绕障需要规划得通,唯一通路上放了圆形禁区就规划不出来,这一档直接让角色朝一个有可走面的方向走一步。

选方向(PlanUnstickTarget):

  1. 方向从相对卡死朝向的五个偏角里取。记卡死朝向为 、本次脱困的序号为 ,第 个试的方向()是

    同一个卡死点上连续脱困, 逐次加 1,每次从不同方向开始试。

  2. 每个方向从 0.5 起每 0.5 采样一次,采到 6.0。采样点投到底层网格上判断在不在可走面内;投不上的点按可走算。

  3. 一个方向被采用,要同时满足:

    • 最后一个采样点(6.0 处)在可走面上;
    • 最长的一段连续不可走 ≤ 2.0。
  4. 记末段连续可走面的起点(离角色的距离)为 ,步长

    把 夹到 里。整条线都可走时 , 取下限 2.5。

常量值作用
kUnstickSampleStepM0.5每条射线的采样间隔
kUnstickMaxRockCrossingM2.0可以跨过的最长不可走段
kUnstickMeshMarginM1.0越过可走面边缘再走这么远,落脚点在实地上
kUnstickMinDistanceM2.5最短步长
kUnstickMaxDistanceM6.0射线长度,也是最长步长

2.0 以内的不可走段当作可以跨过去的石头,更长的当作水面,这个方向不要。

物理脱困的方向扇面和单条射线的采样判据

左图是 06:41 那次的物理脱困。卡死朝向 81°,这是这个卡死点上的第一次脱困,扇面从 +180° 开始:261° 被拒,第二条 −135° 即 306° 采用,整条线都可走,步长取下限 2.5。右图是单条射线的三种情形:中间隔 1.5 的石头、末段回到可走面,采用,;隔 3.0 的水面,超过 2.0,拒绝;一路不可走,拒绝。

五个方向都不合格时,日志 WARN “Physical unstick: no on-mesh escape bearing found.”,序号加 1,这一刻结束。

执行:

  1. 松前进键,等 150 ms。
  2. 按目标方向和当前朝向的差一次性转过去,至少转 1 个单位。
  3. 最多 8 次 270 ms 的前进脉冲。每次脉冲后取位,记脱困前的位置为 、这次取到的位置为 , 就停。定位失效或黑屏时立刻停:这多半是掉进了河里,再往前推只会继续往水里走,交给下一刻的丢定位处理。
  4. 序号加 1,日志 “Physical unstick step executed.”,带计划步长、实际位移、是否挪开。

挪开之后,以脱困前的位置为原点、用原来的卡死朝向放一块圆形禁区再规划,新路线从起点就绕开刚才顶住的东西。放圆或规划失败时退回普通的动态覆盖。原点取脱困前的位置,因为障碍在那个位置的前方,它和挪动后的当前位置是什么关系已经说不准。

序号的生命周期。脱困序号不随恢复回合清零,离开原点 2.0 以上、路点推进或新一趟导航时才清。同一个卡死点上连续几次脱困,扇面的起始方向因此依次轮转,每次换一侧先试。

脱出之后

ResumeAfterEscape 依次:

  1. 前进无效计数、原地计数清零(#4955「逃逸成功后清空前进无效计数」)。没有这一步,脱出后的第一刻就会因为残留的计数再次打开阶梯。
  2. 恢复回合、升级计数清零。
  3. 路线跟踪状态重置。
  4. 撤销挂起的重规划,走廊标脏,下一刻重建。
  5. 普通停滞时钟清零。硬时钟保留。
  6. 切回 Navigate。

硬时钟保留是关键。每次小跳都算一次”脱出”,普通时钟一直被清零;只看普通时钟的话,原地反复跳永远不会超时。见两个停滞时钟。

状态生命周期

恢复相关的状态各有各的清除点:

状态路点推进新一趟导航其他清除点
恢复回合清清换节点、走廊消费、换锚点、落水自救置位、脱出
升级计数清清换锚点、脱出
物理脱困序号清清离原点 > 2.0
落水、偏离、滑索恢复、FIND清清各自的逻辑
全局重捕获抖动清清走廊消费
滞留 watchdog保留清离开半径 20、换区、ZIPLINE 退索
虚拟禁区保留清无
镜头对齐保留置位消费时清

Watchdog

恢复阶梯处理的是”人在原地、路线是通的”。其余几类卡住的情形各有一个 watchdog:

Watchdog盯的是时限结局出处
丢定位连续取不到可用定位30 s盲跳,超时失败#3509
重捕获抖动反复丢失又找回,路点不推进5 次失败#3995
跨层逃逸掉到错误的楼层硬时钟 12 s走逃逸走廊,停滞失败#3995
落水丢定位期间见过黑屏硬时钟 30 s转身上岸,超时失败#3754、#4956
偏离离开走廊且离锚点没变近6 s / 12 s重规划,超时失败#3690、#6028、#6109、#6205
滞留一直没离开半径 20 的圆120 s失败,ZIPLINE 点先退索#5899
无进展普通停滞时钟默认 60 s失败—

两个停滞时钟

所有进展判据都建立在两个时钟上。它们吃同一个信号:

  • 当前点是 RUN、且走廊给出了到锚点的直线距离时,用这个直线距离;
  • 否则用路线跟踪算出的到当前路点的剩余距离。

RUN 锚点上,到锚点剩下的距离才是真正的进度。追着路线跟踪的剩余距离走,角色正常绕开障碍时会被误判成停滞;脚下恰好有一个沿途路点时,又会在锚点还很远的时候被当成到了。

两个时钟都记录”到目前为止的最好距离”,新距离比最好距离小 0.5 以上才算进展,重新计时。0.5 取的是进展阈值和定位量化(0.25)里较大的一个。

两个时钟的区别在于谁能把它清零:

事件普通时钟硬时钟
路点推进、快进(SkipPast)清清
路点改落脚点(Retarget)清—
动态覆盖清接续点变化时清
整条路线替换清清
全局重捕获、物理脱困、偏离走廊的软重规划、脱出清—
传送门落地、TRANSFER 完成、HEADING清—
落水自救置位、进入跨层逃逸、换上索站位—清
进展信号的来源切换清清
净进展 > 0.5重新计时重新计时

Retarget 清普通时钟,是因为到点的距离改按新落脚点重算,旧点上攒下的最好距离会让无进展判据以为人在倒退。

运行期的同步规划期间人停着等,规划结束后两个时钟的计时起点都往后挪一段规划耗时(#6205)。慢的机器上一次规划就要几秒(#6104 那台最长 8.6 s,见规划耗时),超过恢复阶梯的 3500 ms,不扣的话规划一结束就会触发恢复。

普通时钟按当前路点序号计,硬时钟按锚点序号计(没有走廊时按路点序号),所以绕障换路线、走廊上的路点被一个个消费掉时,硬时钟照样往上走。硬时钟来自 #3552「A*过于保守 卡死判定失效」,修的是 #3526:角色一直往前跳水,跳了两个小时没有触发急停。硬时钟和普通时钟吃同一个信号,恢复阶梯的脱出只清普通时钟,清不掉硬时钟,恢复超时这才真的能触发。

恢复超时按硬时钟量。每次小跳”脱出”都经过 ResumeAfterEscape,恢复回合的开始时刻和普通时钟都跟着重置;按回合开始时刻计时的话,经过的时间永远涨不上去,角色可以原地一直跳下去,作者记录过一次原地跳了约 6 分钟、1094 次、一次超时都没有。硬时钟只在朝路点有净进展时才重新计时,这种原地循环在 30 s 没有真实进展之后就会急停。

读时钟的地方:

读取者时钟阈值
恢复阶梯触发普通3500 ms
无进展超时普通arrival_timeout,默认 60000 ms
恢复超时硬30000 ms
落水自救超时硬30000 ms
跨层逃逸失败硬12000 ms
上索换站位硬2000 ms
采集点判定圈放回常规值硬6000 ms

硬时钟没有单独的日志,只能从普通时钟、清零事件和动态覆盖的接续点反推。

丢定位

#3509「定位丢失时增加超时与脱困,避免无限卡死」之前,定位一旦过不了定位区校验,每刻都提前返回,前进键一直按着顶墙;PR 正文记录的那次约 13 分钟后才被外层超时。典型的情形是角色被推过定位区边界,进了路线没有规划过的子区,每个定位结果都过不了定位区校验。现在长时间拿不到可用定位时,松开前进键,定期跳一下试着挣脱,丢失超过时限就失败,让 Pipeline 重试。

本地取位失败时进 HandleLocalizationLoss,每刻:

mermaid
flowchart LR
    subgraph G3a [丢定位的每一刻]
        direction TB
        A["记丢失起点、暂停偏离 watchdog 时钟<br/>黑屏则记下标记、松前进键"] --> T{"丢失<br/>≥ 30 s?"}
        T -->|是| F(["localization_lost_timeout"])
        T -->|否| G{"距上次全局<br/>≥ 120 ms?"}
        G -->|是| H["全局取位"]
        H -->|成功| X(["跨层判断、抖动计数<br/>恢复导航"])
        H -->|失败| J1(["转盲跳判断"])
        G -->|否| J1
    end
    subgraph G3b [盲跳判断]
        direction TB
        J{"丢失 ≥ 3.5 s、距上次盲跳 ≥ 3.5 s<br/>且没有 TRIGGER 待命中?"}
        J -->|是| K(["盲跳 500 ms"])
        J -->|否| S(["睡 20 ms"])
    end
    G3a --> G3b
  1. 记下丢失的起点,暂停偏离钟。
  2. 本帧是黑屏就记下黑屏标记,留给落水判断。丢定位期间出现黑屏说明掉进了水里,定位器只把它报成普通的跟踪丢失,所以要在这里记下来,定位找回时才能置位落水自救。
  3. 松开前进键。
  4. 丢失满 30000 ms 时以 localization_lost_timeout 失败。
  5. 距上次全局取位 ≥ 120 ms 时,做一次不限定位区的全局取位。成功时:
    • 正在跨层逃逸中:新区和逃逸区共享几何时保留走廊,日志 “Cross-tier escape rode a zone flip; preserving the corridor.”;否则日志 “Cross-tier escape: re-acquired zone left the pit span; reverting to loss handling.”,退出逃逸,按普通丢失处理。
    • 不在逃逸中、定位区变了:先尝试进入跨层逃逸。
    • 定位区变了,就把会话的当前区改成新区,否则下一刻的本地取位还会按旧区校验,再次失败。
    • 重捕获抖动加 1,满 5 次以 localization_thrash 失败。
    • 日志 “Localization recovered via global re-acquire; resuming navigation.”;丢失期间见过黑屏就置位落水自救;清丢失状态,重置路线跟踪,走廊标脏,普通时钟清零。
  6. 全局取位没成功,丢失满 3500 ms、距上次盲跳 ≥ 3500 ms,且路线末尾没有待命中的 TRIGGER 时,前跳 500 ms 再松开,日志 “Localization lost; blind unstick hop issued.”。TRIGGER 待命中时,小地图可能正被要识别的画面盖住,按键会干扰这个画面,所以不跳。
  7. 其余情况睡 20 ms,下一刻再试。

本地取位自己恢复时,日志 “Localization recovered via normal tracking.”,同样检查黑屏标记,置位落水自救。

重捕获抖动。全局重捕获会清掉丢失计时和普通时钟,掉到错误楼层的角色可以在”找回 → 又丢”之间无限循环,任何超时都不会触发。抖动计数专门处理这种情形:数上次路点推进以来连续发生了几次全局重捕获,到 5 次就失败。正常行进每条腿只有 0 到 1 次短暂丢失,路点没推进却找回又丢了 5 次,只能是卡在了循环里。

抖动计数在三处清零:新一趟导航、路点推进、走廊消费。走廊上的路点被消费也是实打实的前进,一条长腿中途短暂丢几次定位,不清零就会攒到上限误判失败;原地找回又丢的循环消费不到任何路点,照样会被拦下。

跨层逃逸

部分区域分层,分层的定位区带地板高度,底层(…_Base 一类总览区)没有。角色从高处落下或掉进竖井时,定位会切到另一层,路线上却找不到属于这一层的锚点。跨层逃逸从当前所在的分层区规划一条 NavMesh 走廊,回到最近的可达作者路点,按固定走廊走回去。

进入。只在全局重捕获、定位区变了的时候尝试,三个条件:

  1. 新区是带地板的分层区。新定位要确实落在一层真实的楼层上,底层这类总览区不算。
  2. 路线在这里没有和当前区兼容的锚点。有的话是正常行进,不需要逃逸。
  3. 从当前路点往后逐个找作者写的路点(动态生成的点不算),第一个规划得通的作为汇合点,规划时保留走廊的内部拐点。

都满足时日志 “Cross-tier escape engaged: routing out of a wrong tier via navmesh.”,硬时钟清零。

逃逸中:

  • 恢复阶梯和偏离 watchdog 都关闭。
  • 定位在本层和底层之间来回切是正常的,共享几何的区之间切换不打断走廊。
  • 离汇合点 ≤ 3.0、且当前区是没有地板的底层时退出,日志 “Cross-tier escape reached the rejoin point on the base floor; resuming authored route.”。两个条件缺一不可:竖井下层绕的圈在平面上会从井口正下方经过,只看距离会在下层提前退出。
  • 硬时钟停滞 ≥ 12000 ms 时以 crosstier_escape_stalled 失败,日志 “Cross-tier escape made no corridor progress (walled or unfollowable); terminating so the pipeline can retry.”。逃逸中重新应用的动态覆盖清不掉硬时钟,它量的是走廊上真实没有进展的时间;逃逸路线再长,只要一直在前进就不会被提前掐断。

两种失败模式分开处理:A 是找回又丢的循环,靠重捕获抖动;B 是走廊走不通,靠硬时钟。

#3630「修复切换 tier 时不进行重定位」只加了丢定位期间的全局重捕获,跨层逃逸和抖动计数都在 #3995 里。

落水

角色掉进深水时游戏会黑屏,把人传送回岸边,面朝水。定位器把黑屏归为普通的跟踪丢失,落水自救靠的是丢失期间记下的黑屏标记。#3754「增加意外掉水的脱困恢复」加入这一段,#4956「落水自救先站定再整段回头」改成现在的做法:先站定等箭头读数稳下来,转 180° 一次,再一下一下往岸上走,直到离开岸边;岸很窄、反复落水的情形由硬时钟兜住。

置位。定位恢复(本地或全局)时,丢失期间见过黑屏就置位落水自救(arm):

  1. 整个落水状态先重置,再次落水要重新站定、重新转身。

  2. 记下岸边位置和此刻的朝向。

  3. 清掉恢复回合,硬时钟清零。落水之后由落水自救接管:传送之后,落水前的恢复锚点已经过时;恢复回合不清的话,它的脱出检查排在落水自救之前,会抢先把这次传送当成脱出。

  4. 日志 “River-fall recovery armed (black-screen loss recovered).”。

执行。落水自救排在到达判定之后、偏离 watchdog 之前,每刻:

  1. 暂停偏离 watchdog 时钟。
  2. 硬时钟停滞 > 30000 ms 时以 river_fall_recovery_timeout 失败。
  3. 离岸边位置 ≥ 2.0 时结束,日志 “River-fall recovery cleared; resuming navigation.”,进 ResumeAfterEscape。
  4. 松前进键。
  5. 第一次进来时站定 2000 ms。上岸那一瞬的箭头读数最不可靠,箭头尖端可能翻转,追踪也还停在保持状态,拿它算转身容易算错。作者记录的三次落水里,有一次读成 −1°,算出的目标正好等于当前朝向,等于没转就往前走。
  6. 站定之后的第一刻转 180°,只转一次,发完就认,再等 300 ms。早先的做法是每刻拿转到一半的箭头重算误差再补发,转身还没完成就先迈了步,每一刻都朝着没转完的方向往水里走,两次再落水都紧跟在自救的前进脉冲之后。
  7. 每刻一次 270 ms 前进脉冲,日志 “River-fall recovery inland pulse.”。视角转完以后,角色要迈一步朝向才会跟上,这一步按镜头方向走,正好离水而去。

物理脱困的脉冲在定位失效时立刻停下,也是为了落水:后续的丢定位处理会记下黑屏,定位恢复后由落水自救接手。

偏离

偏离 watchdog 盯的是”人离开了走廊,离锚点也没有变近”。停滞时钟吃的是走廊进度:串行路点的游标卡在远处而走廊进度照样往前走时,停滞时钟看不出问题,角色会一直游荡到动作整体超时。偏离 watchdog 只在离开走廊、离锚点又没变近时计时,先重规划,再不行就尽早失败,让 Pipeline 重试。

生效条件,全部满足时计时,任一不满足时整个状态清零:

  • Navigate phase;
  • 当前点是连续 RUN(非严判的 RUN),只有它的”在不在路线上”有意义;
  • 到路线跟踪器当前路段的横向偏差 > 3.0。投影夹在路段两端之间,角色落在路段起点之后时,这个值就是到起点的直线距离;
  • 横向偏差是有限值。投影算不出来时偏差是无穷大,代表的是”没法判断”,交给停滞时钟;
  • 不在跨层逃逸中。

计时用的是到当前路点的直线距离,只有真的在路线外、又没有往回靠的时候才会一直涨。

计时。和停滞时钟一样记最好距离,距离比最好值小 0.5 以上就重新计时:

  • 计满 6000 ms、且距上次偏离重规划 ≥ 4000 ms 时,从当前位置重规划,日志 WARN “Off-route wedge detected; replanning from current position.”。
  • 计满 12000 ms 时以 offroute_wedge_timeout 失败。

盲期扣除。有几段时间里 watchdog 的那段代码根本跑不到,路线进度也不可能变化。这几段由 PauseAt 记下起点,下一次 watchdog 运行时由 ResumeAt 把整段从计时里扣掉,日志 “Off-route wedge clock resumed after a blind stretch.”:

调用点出处
丢定位处理#6028「偏离看门狗计时扣除丢定位与落水自救的盲期」,close #5446
落水自救同上
动态覆盖的同步规划#6109「偏离看门狗计时扣除同步重规划耗时」,close #6104
其余运行期同步规划#6205「滑索落地重规划起点层与同步规划停步计时」:走廊规划(换锚点、软重规划)、滑索失败后的重规划

丢定位和落水自救的刻都在 watchdog 之前返回,路线进度不会变,下一次 watchdog 运行时把计时起点整段后移。上次重规划的时刻也一起后移,冷却不受盲期影响。

#6104 的失败。#6104「送货失败」日志的最后一趟,07:02:18 开始,25.5 s 后以 offroute_wedge_timeout 失败:

时刻(s)事件
0开始
2.3路线展开完成,耗时 1881 ms,76 个路点
2.5起步锚点规划 118 ms,动态覆盖再规划 117 ms,进 Navigate
3.8软重规划(进度倒退)
7.0卡在 (1250.39, 1735.3),离当前路段 4.95;偏离 watchdog 时钟约在此时起跑(由 wedge_ms 反推)
7.4、8.0前进键重发两次
8.3、11.9跳 #1、#2
12.6圆形禁区 ,朝向 354°,开始绕障规划
18.8规划耗时 6222 ms,代价 404;新路线生成 67 个点
19.0偏离 watchdog 时钟读数 11960 ms,触发偏离重规划
25.3第二次规划结束,耗时 6390 ms
25.5偏离 watchdog 时钟超过 12000 ms,失败,航向误差 177.33°
#6104 失败那一趟:地图上卡住的位置、当前路段、圆形禁区和绕障路线的第一个路点,以及偏离 watchdog 时钟的读数

左图是这一趟在地图上的样子。角色从下方起点往北走,7.0 s 停在红叉处,之后前进键重发两次、跳两次,位置都没动。这时路线跟踪器的游标已经推进到路点 4,当前路段是 4→5;角色在这段起点的后面,投影夹到起点,横向偏差按到路点 4 的直线距离算成 4.95,超过 3.0,判为离开路线,偏离 watchdog 时钟起跑。角色到原路线折线本身只有 0.77。12.6 s 绕障规划在卡住处前方盖了 的圆形禁区;18.8 s 规划出的绕障路线,第一个路点在角色身后 7.0,仍在 3.0 之外,角色朝北,要转 177° 才能往回走。

右图是偏离 watchdog 时钟,红线由日志里的 wedge_ms 反推,从 7.0 s 起跑。两次同步规划(6.2 s + 6.4 s = 12.6 s)里 watchdog 跑不到,计时照样往上涨:绕障规划结束时已经 11.96 s,紧接着触发的偏离重规划又花了 6.4 s,结束的下一刻就超过 12 s 判失败。绿色虚线是 #6109 之后的推算:两段规划都从计时里扣掉,偏离 watchdog 时钟到 19.2 s 左右才碰到 6 s 线,离失败线还远。推算曲线只扣时间,没有模拟扣除之后的行为变化。

#6109 修的是 watchdog 的计时。绕障规划本身在这台 Windows 机器上花了 6 s 以上,规划耗时的分布见性能。

滞留

滞留 watchdog 是最后一道兜底(#5899「加滞留看门狗兜底导航原地卡死」):角色在一个半径 20 的圆里待满 120 s 就判失败。判定只用位置:圆心记在世界像素坐标上,路点序号、锚点、重规划都清不掉它,唯一的办法是真的走出去。

时限按作者统计的导航日志定:健康导航里最长的一次滞留是 23.5 s,而且都在阶梯第一次尝试时就脱出;真卡死的会超过 160 s,阶梯已经试过几十次。120 s 是阶梯时限 30 s 的 4 倍。

计时挂在取位上。每次取位都更新它,包括物理脱困脉冲、跳之后的那些取位。卡死时有一半的时间花在单个刻里面,脱困脉冲、重规划这些都走不到 TickNavigate 的末尾,取位却一直在做,这些定位结果恰好就是角色没动的证据。

只计行走。取位失败、黑屏、位置无效、或不在 Navigate phase 时,这一帧不计时,圆心和已经攒下的时间保留。传送、滑索、脚本交互本来就要站着不动,看不见的帧也说明不了人动没动。语义节点占用的刻同样不计。暂停只保留圆心和时间、不往上加,所以暂停只会推迟判决。

圆心。第一次可用定位记为圆心;离开圆心 20 以上或换了定位区时重新记圆心,计时归零。路点推进不清滞留状态。

判决。判决排在定位恢复之后、其余逻辑之前,取到位置就判,后面的任何分支都抢不到它前面:

  • 当前点是 ZIPLINE:丢掉滑索链改走路,日志 WARN “Dwell watchdog tripped at a zipline tower; dropping the chain and walking.”,滞留状态重置。处置和恢复超时一样,先退索走路;退链后重新计时,再攒满 120 s 时手上已经是步行点,那一次才判失败。
  • 其余点:日志 ERROR “Dwell watchdog tripped; the agent never left its dwell radius.”,以 dwell_watchdog 失败。

无进展超时

每刻末尾,普通停滞时钟超过 arrival_timeout(默认 60000 ms,设 0 关闭)时以 no_progress_timeout 失败。普通时钟会被每次脱出清零,所以离锚点 3.0 以上、阶梯能触发的地方,这条超时基本不会走到;它实际兜的是离锚点 3.0 以内、阶梯不管的停滞。

跨区

路线用 ZONE 声明定位区的切换,PORTAL 和 TRANSFER 是两种会把角色送到别处的动作。

ZONE

ZONE 声明本身没有坐标。就地消费的语义点每刻按顺序处理:

  1. 消费已经匹配的 ZONE:声明的区和会话的当前区相同(或声明为空)就推进过去,连续的几个一起消费。消费完路线空了,记路线尾部。
  2. HEADING。转身完成后再消费一次匹配的 ZONE。
  3. 没有坐标的 FIND 在这里接手:刹停、切 phase,之后的每一刻交给 TickFindTarget。见 FIND。
  4. 当前点仍然是没匹配上的 ZONE:按住前进,这一刻到此为止。

未匹配的 ZONE 刻在路线跟踪、阶梯和无进展超时之前返回,这段时间只有滞留 watchdog 和丢定位处理在起作用。TickNavigate 里路线跟踪之前还有一道同样的 ZONE 等待(按住前进、睡 33 ms、返回),作为第二道保险。

PORTAL

到点。PORTAL 的到点距离取判定圈和提交距离 4.0 的较大值。PORTAL 默认严判,到点先刹停,然后:

  1. 推进路点;PORTAL 是终点时记终点证据。
  2. 进入传送过渡,按住前进直到取到新定位。
  3. 重置定位器的跟踪状态。

过渡(每刻):

mermaid
%%{init: {"flowchart": {"rankSpacing": 30}}}%%
flowchart TD
    T{"过渡<br/>> 12 s?"} -->|是| F(["portal_transit_timeout"])
    T -->|否| L{"已落地?"}
    L -->|否| G["按住前进<br/>全局取位"]
    G -->|"取不到或<br/>还是原区"| W(["等 120 ms"])
    G -->|"新区在后续<br/>没有 ZONE 声明"| S(["停下等 120 ms"])
    G -->|新区匹配| A["快进到该 ZONE<br/>记为已落地"]
    A --> W
    L -->|是| C{"本地取位<br/>区一致?"}
    C -->|否| W
    C -->|是| R(["挂起重规划"])
  1. 过渡超过 12000 ms 时以 portal_transit_timeout 失败。
  2. 落地前:按住前进,做不限定位区的全局取位。
    • 取不到,或还在原来的区:等 120 ms。
    • 新区在后续路线里找不到对应的 ZONE 声明:停下,等 120 ms。
    • 找到了:快进到那个 ZONE 声明(跳过中间的点),更新当前区,普通时钟清零,消费匹配的 ZONE,刹停,重置跟踪,记为已落地。
  3. 落地后:本地取位的定位区必须和当前区一致,才结束过渡,挂起一次动态重规划,下一刻的重规划检查接手执行。

TRANSFER

TRANSFER 用于机关、跳板一类把角色送走的东西,角色自己不动,等外部位移。

到点:刹停,推进路点,记下当前位置,切到 WaitTransfer phase。

等待(每刻):

  1. 松前进键。
  2. 取位失败,且等待超过 15000 ms:以 transfer_wait_timeout 失败。
  3. 有外部位移指换了定位区,或离记下的位置 ≥ 3.0。没有位移时命中计数归零;等待超过 15000 ms 以 transfer_wait_timeout 失败。
  4. 连续 2 帧都观察到外部位移,算送达:更新当前区,普通时钟清零,重置导航辅助状态,切回 Navigate。

取位重试间隔 120 ms。WaitTransfer 是独立 phase,恢复阶梯、偏离、滞留在这期间都不计。

语义动作

路线上的动作分两类。SPRINT、JUMP、FIGHT 和不带文本的 INTERACT 到点执行一次固定输入,由 ActionExecutor 处理。COLLECT、带文本的 INTERACT、DIG、HEADING、FIND、TRIGGER 要看画面才能决定何时动手、动手之后算不算完成,这一节逐个讲。滑索同属后一类,篇幅大,单列为滑索。

动作触发执行完成失败原因
COLLECT行进中后台预筛命中,且离最近的同类点 ≤ 3.0子任务 AutoCollectClickStart:OCR 名字表,命中按交互键路点经过即推进,与采没采到无关无
INTERACT(带文本)行进中预筛命中,且正走向的就是该点、距离 ≤ 3.0;途中没触发则到点子任务 MapNavigatorInteractStart:OCR 路线给的文本,命中按交互键触发即推进;到点兜底跑完也推进无
INTERACT(无文本)到点交互键按 5 次,每次 100 ms到点推进无
DIG严判到点(2.25)子任务 AutoCollectDigStart:画面中心点两下子任务返回dig_context_missing、dig_dispatch_failed
HEADING成为当前点(无坐标)分步转视角、前进脉冲、闭环复核复核通过或重试用尽heading_turn_failed
FIND走到锚点,或作为控制节点成为当前点每刻识别目标框,转向、前进或后退find_stop 命中,或进入 find_arrive 的 2.0 圈7 种 find_*
TRIGGER出发起每 300 ms 识别一次;途中没命中则到点原地等只跑 trigger_node 的识别,节点动作跳过命中即导航成功3 种 trigger_*

采集与交互

COLLECT 和带文本的 INTERACT 共用一套实现 AsyncPromptAction。依据是游戏自己的交互提示:角色够得着某个可交互物时,画面右侧会弹出一行提示(图标加文字)。导航用这个提示判断”够近了”,坐标只用来划定在哪一段路上留意它。两类动作的流程完全相同:走路时后台预筛盯着画面上的提示,停车后由 pipeline 节点做权威识别;差别只在一份配置(spec)里。

每次动手前有两道识别:

  1. 预筛:后台线程对每帧做模板匹配,只判断值不值得停车。
  2. 权威识别:停车后跑一次 pipeline 子任务,OCR 认出提示文字才按键。

所有可交互物弹出的都是同一个提示图标,两类共用这道预筛。预筛只决定子任务值不值得跑,子任务动手前还会再识别一次,阈值因此可以放松。

两类的差异

COLLECTINTERACT(带文本)
子任务入口AutoCollectClickStartMapNavigatorInteractStart
权威识别AutoCollectClick:OCR,ROI 803,350,263,219,95 条名字(简、繁、英、日、韩)MapNavigatorInteract:OCR,ROI 755,330,297,312,expected 是占位文本,由路线注入
文字从哪来节点自带的共用名字表路线点上的 interact_text
预筛内置图标 RealTimeTask/AutoPick.png,ROI 取 AutoCollectClick 自己的,阈值 0.75MapNavigatorInteractScan 节点(同一张图标,755,330,297,312,0.75),可用 interact_scan 换
触发距离离最近的同类点 ≤ 3.0,已走过的点也算当前路点是本类点,且距离 ≤ 3.0
触发后路点不动推进
路线末尾宽限有,1500 ms无
到点兜底无(经过即推进)有

文字来源不同,是因为两类的识别对象不同。所有可采集物共用一张名字表,路线上没有可配的东西。交互提示随业务而变,所以路线要同时给出两样:停车后共用节点要认的文字,以及决定何时停车的预筛节点;按下去的始终是交互键。

MapNavigatorInteract 的 expected 是占位文本,运行时由路线点上的 interact_text 替换。没注入时占位文本谁都对不上,不会误按。

三端的按键

两个识别节点在桌面端都是 ClickKey 70(F 键,可被全局设置 KeymapInteract 改绑)。另外两端由资源覆盖:

端动作ROI 变化
Windows、LinuxClickKey 70—
macOSClickKey 3(resource_macos 的 MacOSKeyMap.json)—
ADB、云端 ADB、PlayCover(都挂 resource_adb)AutoAltClickAction,点识别到的提示框AutoCollectClick 720,350,309,219;MapNavigatorInteractScan 720,330,332,312

触屏没有 F 键,ADB 资源改成点识别到的提示本身,expected 沿用主资源。预筛读节点时取的是叠加覆盖之后的数据,所以 ADB 上采集预筛的 ROI 跟着变成 720,350,309,219。

INTERACT 的几种写法

写法行为来源
不写 interact_text到点停车,交互键按 5 次原语义
interact_text 写字符串或数组异步交互,数组里任一条正则命中即按#5067「INTERACT 升级为异步交互,提示触发统一使用距离闸」
interact_text: { "node": "..." }开跑前从那个 OCR 节点的 expected 读出文字表#5078「interact_text 支持指向 OCR 节点取文字表」
加 interact_rec: true前面全照旧,识别节点的按键换成 DoNothing#5179「INTERACT 新增 rec 模式」
加 interact_scan换掉预筛节点#5067

给了 interact_text 的 INTERACT 行进中检测到提示就停车跑一次子任务,到点时再兜底一次;没给文本就保持原语义,到点连按交互键。文字写成 { "node": ... } 时,多条路线可以共用同一张表。interact_scan 只换预筛看什么,必须和 interact_text 一起用。按键恒为交互键,不可换;interact_rec 只认提示、不按键。

几条退化规则:

  • 节点读不出文字表:被点名的节点必须是 OCR,expected 必须非空且不含空串,否则该点退回原语义,整条路线照跑。
  • 只写 interact_scan 没写文本:该点按普通 INTERACT 走。路线说明了停下后认什么,才走异步交互。只换预筛不给文本时,共用识别节点里的占位文本没被替换,停下来也认不出东西,所以这种点退回原语义,免得白停一次。
  • rec 模式但文本没解析出来:该点走原语义的到点分支,那里也认 interact_rec,跳过按键。到点连按交互键正是 rec 模式要避开的,所以这个判断不看有没有文本。
  • interact_text 写空:空串、空数组、{ "node": "" } 在解析阶段直接拒绝。空文本在识别端匹配任何东西,会变成见到提示就按,这几个字段存在的目的就是避免这种行为。

后台预筛

#3690「异步检测 COLLECT」把识别搬到后台线程。导航只有一个线程,在一刻的中途嵌套调用框架识别会卡死下一次控制器 IPC;所以检测放在只碰 OpenCV 的工作线程里,导航线程只看标志,权威识别留给随后跑的 pipeline 子任务。

mermaid
flowchart LR
    subgraph G4a [送帧与匹配]
        direction TB
        A["截图定位时<br/>帧观察者交出整帧"] --> C["导航线程:缩放 ROI<br/>拷进单槽,新帧覆盖旧帧"]
        C --> E["后台线程:缩回 ROI 尺寸<br/>灰度,matchTemplate"]
        E --> F{"max_val ≥ 阈值?"}
        F -->|否| E2(["丢弃,等下一帧"])
        F -->|是| G(["置命中标志"])
    end
    subgraph G4b [导航线程每刻]
        direction TB
        T{"距上次触发<br/>满 1200 ms?"} -->|否| T2(["标志留着,下一刻再看"])
        T -->|是| H{"读命中标志并清零<br/>命中且在触发距离内?"}
        H -->|是| J(["停车,跑 pipeline 子任务"])
        H -->|否| K(["观测作废"])
    end
    G4a --> G4b

送帧。扫描器的帧来自定位截图 PositionProvider::Capture:截完、判过黑屏、提取小地图之前调一次帧观察者,所以扫描帧率等于定位帧率(见每刻耗时)。FIND 自己发的截图不经过这里。观察者只绑一次,按顺序喂给采集、交互、挡路设备、上索四个扫描器。每个扫描器各绑一次的话,先停下的那个会悄悄切断其他扫描器的帧来源。

缩放与拷贝。pipeline 里的 ROI 都按 1280×720 写,SubmitFrame 按实际帧尺寸缩放后裁到帧内。记作者写的 ROI 为 ,实际帧宽高为 、:

裁出的小图深拷贝进唯一的待处理槽,因为函数返回后控制器会复用整帧的缓冲区。交出小图不阻塞;后台线程还没处理完上一张时,新图直接覆盖,只留最新的一张。导航线程在这里只付一次小图拷贝的代价。

匹配。后台线程把小图用 INTER_AREA 缩回作者写的 ROI 尺寸,再转灰度,模板因此始终在 1280×720 的像素尺度上匹配。匹配用 TM_CCOEFF_NORMED,带掩码时先筛掉非有限值:带掩码的归一化相关没有除零保护,纯色窗口会算出 inf 或 nan,inf 会排到最高分。全部筛掉时 minMaxLoc 返回 0,低于任何阈值。

匹配分数 ≥ 0.5 的读数会记下来,用来校准阈值。下文的实测曲线就是这些读数。

命中标志。命中时置一个原子布尔,导航线程用 exchange(false) 读,读一次就清零,一次命中对应一次尝试。调用方在冷却期间不读,标志一直留着,命中不会丢。

四个消费者

消费者节点 / 模板ROI(桌面)阈值节流
采集内置 RealTimeTask/AutoPick.pngAutoCollectClick 的 803,350,263,2190.751200 ms
交互MapNavigatorInteractScan 或 interact_scan 点名的节点755,330,297,3120.751200 ms
挡路设备__MapNavigatorObstacleDevice_InteractPre / ObstacleDevice/InteractButton.png760,330,170,3100.65见搬走挡路设备
上索MapNavigatorZiplineMountScan / Zipline/InteractButton.png755,330,297,3120.75无

采集和交互只在路线里有本类点时启动,没有本类点的路线每刻只多一次布尔判断。启动时从识别节点读 ROI,读不到就不启动。上索扫描器在走到第一个 ZIPLINE 点时才创建,链走完就销毁,不再逐帧匹配。

每类只有一个扫描器:只有正在走向的那个点能触发,一个就够了。路点点名的预筛节点变了就重建,旧的命中标志随旧扫描器一起丢掉,属于上一个点的观测不会带到下一个点。预筛节点名为空时用出厂预筛;节点坏了也照样记下名字,否则每一刻都会重读一遍同一个坏节点。

扫描节点校验

预筛节点只被读参数,从不派发。TryLoadPromptScanProfile 逐项检查,任一条不过,点名它的那些点退化为到点识别:

检查原因
识别类型是 TemplateMatch后台只实现了模板匹配
ROI 是绝对坐标,且在 1280×720 内预筛每帧独立跑,相对上一个节点的偏移 ROI 没有参照物
恰好一张模板、一个阈值作者可以写单值,框架回吐时 template 与 threshold 总是数组;预筛只跑一张图,多给的当写错
阈值在 (0, 1]—
模板能在已加载的资源树里找到找不到就没有可匹配的图
green_mask 是布尔值纯绿像素排除在匹配之外,与 pipeline 的 TemplateMatch 判据相同;模板全不透明时按没有掩码处理
模板不大于 ROI这是 cv::matchTemplate 的硬前提,这里放过的话后台线程每帧都会踩上去,cpp-algo 里没有 try/catch 兜着

模板路径在当前控制器类型挂载的各资源根下查找。

行进中触发

每刻在到达判定之前调一次,要求起步位移已确认,采集先、交互后。函数本体:

cpp
    const auto now = std::chrono::steady_clock::now();
    if (last_trigger_at_.time_since_epoch().count() != 0 && now - last_trigger_at_ < std::chrono::milliseconds(kPromptScanIntervalMs)) {
        return false;
    }

    const std::vector<std::string>* expected = nullptr;
    bool rec = false;
    bool in_band = false;
    if (spec_.text_from_route) {
        // INTERACT:正走向的就是本点
        const Waypoint* waypoint = CurrentWaypointOfThisKind();
        EnsureScannerFor(waypoint);
        in_band = waypoint != nullptr && waypoint_distance <= kPromptTriggerBandWu;
        if (in_band) {
            expected = &waypoint->interact_text;
            rec = waypoint->interact_rec;
        }
    }
    else {
        // COLLECT:离最近的同类点,已走过的也算
        const double nearest_sq = NearestDistanceSq();
        in_band = nearest_sq >= 0.0 && nearest_sq <= kPromptTriggerBandWu * kPromptTriggerBandWu;
    }

    if (!ConsumeDetection() || !in_band) { // 带外的命中当场作废
        return false;
    }

    last_trigger_at_ = now;
    motion_controller->SetForwardState(false);
    utils::SleepFor(kStopWaitMs);
    RunPromptSubtask(context_, spec_, expected, rec);
    return true;
  1. 节流:距上一次真正触发不足 1200 ms 直接返回。这是检测触发的唯一节流,时钟只在真正尝试时前进。节流判断在读标志之前,冷却期间的命中留在标志里。
  2. 触发距离:提示图标是全局的,路过任何可交互物都会弹,所以只在本类语义点附近才动手。两类都是 3.0,判法不同。INTERACT 一命中就把点算走完,所以还要求正在走向的就是这个点,蹭到别处的提示不会悄悄消掉它。COLLECT 不消费路点,按离最近的同类点算,已走过的点也算,因为提示常常在走过头之后才弹出来。3.0 小于滑索的走路进入带 5.0,由 static_assert 锁住:滑索提前切走路时,提示触发仍然留在窄带里,否则路线上只要有一个这类点,沿途就可能在陌生的可交互物前停车。
  3. 读命中标志:带内带外都读,带外的命中当场作废。不作废的话,跨进带内的第一刻就会拿旧观测触发,而那时本点的提示还没弹出来。
  4. 停车识别:松前进键,等 150 ms,同步跑子任务,子任务返回后再等 80 ms。

COLLECT 的触发距离把已走过的点算进来,因为 COLLECT 点经过即推进,提示往往在点被推进之后才弹出。采集点本身不停车,一处点位有几件东西时,每 1200 ms 可以再触发一次。#5030 删掉了原先的重试距离要求(两次触发之间角色必须挪开一段距离),同一坐标上的多件物品因此都能采完。

触发之后

调用方 TryRunPromptSubtaskWhileWalking 再做两件事:

  • 清掉另一类的命中标志。屏幕上一次只弹一个提示,一次观测只值一次停车,不清的话同一个提示会被停两次。
  • INTERACT 当场推进路点。提示弹出就说明够近了,不必再往前走剩下那几个单位:交互一打开界面,角色就不动了,小地图也没了,剩下那段永远走不完,一次成功的交互会被拖成到达超时。

推进不看子任务的结果。OCR 没认出路线给的文本时什么都不按,路点照样算走完。松预筛的代价由此封顶:对 INTERACT 每次误停花掉自己一个点,对 COLLECT 只是多停一下。

子任务覆盖

子任务用 MaaContextRunTask 同步调起,带一份 pipeline override。override 会合并进节点,所以只写调用方自己掌管的三件事:截断共用出口的 next,让子任务到此返回;路线给的文本;rec 模式下把按键换成 DoNothing。ROI 和按键一律留在 pipeline 里。

issue-6040 里一次到点兜底实际发出的 override:

json
{
    "MapNavigatorInteract": { "recognition": { "param": { "expected": ["物资调度终端", "物資調度終端"] } } },
    "MapNavigatorInteractEnd": { "next": [] }
}

子任务派发失败(比如正则写坏、框架拒绝 override)导航照常往下走。异步交互不会让导航失败,交互是否真的发生,需要外层 pipeline 自己核对。

预热与兜底

预热

OCR 模型第一次跑有冷启动。Run 在起步前、角色还站着时把每类提示的入口跑一遍,识别节点的动作换成 DoNothing,next 截断。这一趟只为付掉模型的冷启动,付在站着的时候,行进中的刻就不会被它拖慢。issue-6040 两趟的预热分别用了 239 ms 和 195 ms(从开始预热到扫描器启动)。

路线末尾宽限

最后一个 COLLECT 点被推进的那一刻,路线就结束了,扫描器随即销毁,走过头才弹出的提示没有机会触发。

TryRunPromptSubtaskAtRouteTail 在 Run 的循环结束之后补一个窗口:

  1. 条件:phase 为 Finished;某个共用表类的扫描器在跑;路线最后一个带坐标的点是这一类。
  2. 停车,等 150 ms。先站定再动手,还在滑行时按下去容易白按一次。
  3. 循环:读命中标志,命中就跑子任务后返回;没命中就截一帧定位,只为把新画面喂给检测器,等 33 ms。角色站在路线末尾,这个窗口是单独给的,不受 1200 ms 节流。
  4. 1500 ms 内没有命中就结束。

宽限放在成功判定之后,这里没采到也不会把已经跑成功的路线翻成失败。它只服务共用表那一类:带文本的 INTERACT 每个点都恰好跑一次权威识别,用不着它。

到点兜底

INTERACT 在途中没有触发、走进判定圈时,ArriveInteract 停车(并置位镜头对齐),跑一次同样的子任务,然后推进,完成原因 async_interact_completed。没认出提示就当这里没东西可交互,照样推进。每个带文本的 INTERACT 点因此恰好跑一次权威识别:途中触发了就推进,没触发就到点补跑。

不带文本的 INTERACT 走原语义:交互键按 5 次,每次 100 ms。这个分支也置位镜头对齐:它和 JUMP、FIGHT 一样自己停车,绕开了主入口的置位,而弹出的交互面板正是镜头会被挪走的地方。

判定圈、走路与冲刺

提示驱动的点判定圈收紧到 1.5,硬性无进展 6000 ms 后放回常规值,见判定圈。采集点之间只隔 2.1 到 3.7,比常规的 3.25 判定圈还近,不收紧的话一个圈会把一串点一起吞掉。接近时 3.0 切走路、4.5 切回,8.0 以内压住冲刺,见冲刺与走路。

提示实测

issue-6040 的路线终点是一个带文本的 INTERACT 点,interact_text 两条(简、繁),用的是出厂预筛。同一个点走了两趟。

两趟接近交互点的轨迹,每个预筛读数按分数上色,叠加判定圈、走路圈和冲刺压制圈;右侧是设备图标的读数

左边两张是两趟接近交互点的俯视图。每个圆点是一帧预筛读数,放在读数时刻的定位上,颜色是匹配分数,色条顶端就是阈值 0.75;只有 ≥ 0.5 的读数有记录,所以只画得出这些。同一处有多个读数时叠成一个点,标 ×n,颜色取其中最高的。四个同心圆从外到内是 8.0 压住冲刺、4.5 切回跑步、3.0 切走路、1.5 判定圈。两趟都是越近越亮,到 2 像素附近停在 0.61 上下,没有一个点到色条顶端。第 2 趟在离点 2.1 处同一个定位上读了 13 次。

  • 扫描器参数:ROI 755,330,297,312,模板宽 24,阈值 0.75。
  • 第 1 趟有 26 个 ≥ 0.5 的读数,第 2 趟 42 个。远处在 0.50 到 0.56 之间,走到离点 2 左右升到 0.61 上下,两趟都没有一个读数到 0.75。
  • 预筛一次也没触发,两趟都是走进 1.5 判定圈后由到点兜底完成,进圈时的距离是 1.356503 和 1.346291。
  • 第 1 趟兜底的权威识别:08:29:06.808 开始 OCR,07.036 命中「物资调度终端」,07.038 按下 F,07.100 动作完成。从进圈到路线收尾一共 573 ms。

右图是同一份日志里另一趟的挡路设备扫描器,模板换成设备专用的交互按钮:命中时 0.987 到 0.998,没有设备时 0.51 到 0.53,两者相差约 0.45。左图的提示图标从远到近只抬高了 0.1。这条路线上的交互全靠到点兜底完成:进圈时离点 1.36,权威识别一次就认出了提示文字。

提示驱动的演进

PR日期变化
#3690「异步检测 COLLECT」2026-06-18COLLECT 从到点停车改为经过即推进;后台扫描器 CollectibleScanner;PositionProvider 加帧观察者;起步前预热;提示点附近压住冲刺
#4975「移植卡死恢复阶梯前置搬走挡路设备」2026-08-12collectible_scanner 泛化为 roi_template_scanner,挡路设备探测复用它
#5030「增强自动采集,收紧判定圈并接近时切走路」2026-08-16判定圈 1.5,卡住 6 s 退让;冷却 1500 → 1200 ms;删掉重试距离要求;路线末尾宽限;接近时切走路
#5067「INTERACT 升级为异步交互,提示触发统一使用距离闸」2026-08-18抽出 AsyncPromptAction,COLLECT 与 INTERACT 共用;interact_text、interact_scan;两类统一用 3.0 触发距离;触发命中即推进路点
#5078「interact_text 支持指向 OCR 节点取文字表」2026-08-18{ "node": ... } 写法
#5179「INTERACT 新增 rec 模式」2026-08-23interact_rec

DIG

DIG 是定点动作,按严判到点后停车执行,行进中不做检测。

ArriveDig 的步骤:

  1. 停车,置位镜头对齐。
  2. 没有 MaaContext 时失败,原因 dig_context_missing。
  3. 同步调起 AutoCollectDigStart,override 为 {"AutoCollectDigEnd":{"next":[]}}。派发失败时失败,原因 dig_dispatch_failed。
  4. 子任务返回后等 80 ms。
  5. 重置路线跟踪状态,以 dig_completed 完成该点。

子任务本身很简单:

json
"AutoCollectDig": {
    "desc": "无条件点击两次后结束",
    "recognition": "DirectHit",
    "action": { "type": "Click", "param": { "target": [640, 360, 1, 1] } },
    "repeat": 2,
    "repeat_delay": 500,
    "next": ["AutoCollectDigEnd"]
}

画面中心 (640, 360) 点两下,间隔 500 ms。出口节点 AutoCollectDigEnd 在当前 pipeline 里本来就没有 next,导航仍然显式截断它,和采集、交互两类子任务的写法一致。

DIG 的两种失败都让导航判败,提示类子任务派发失败不影响导航。

#5546「挖掘节点全部使用精确进近」(2026-09-06)给 DIG 加上了末端纠正、起步即走路、纠正前走路三个特性,同时把 AutoCollectRoute 5、7、9、11、13 的挖掘点改写成 [x, y, "DIG", true]。末端纠正只对作者明写 strict 的点生效,所以这些点要带上那个 true。接近时 5.0 切走路、7.5 切回,理由见冲刺与走路;进圈后的纠正见末端纠正。

HEADING

HEADING 没有坐标,执行时把镜头转到指定角度,再按前进键继续走。目标有两种写法:

json
{ "action": "HEADING", "angle": 90 }
{ "action": "HEADING", "target": [688, 350] }

target 写法来自 #3040「HEADING target」,取当前位置指向该坐标的方位。角度的别名优先级见解析。

HEADING 在每刻的就地消费段消费(见每刻顺序),用一个 while 循环把连续的 HEADING 点在同一刻里吃完。每个点:

mermaid
flowchart LR
    subgraph G5a [转向]
        direction TB
        A["算目标朝向<br/>angle 取模,或指向 target"] --> B["停车,等 150 ms"]
        B --> C{"差角 ≤ 1°?"}
        C -->|是| D["前进脉冲 270 ms"]
        C -->|否| E["分步转视角<br/>等 300、脉冲 270、等 300"]
        E -->|发不出去| X(["heading_turn_failed"])
        D --> V1(["转校验"])
        E --> V1
    end
    subgraph G5b [校验]
        direction TB
        V["稳定读朝向,至多 4 帧<br/>相邻两读差 ≤ 15°"]
        V -->|读不到| Z(["推进到下一个点"])
        V --> R{"残差 ≤ 40°?"}
        R -->|是| Z
        R -->|否,已重试 3 次| Z
        R -->|否,未满 3 次| E2["按残差再转一次"]
        E2 --> V
    end
    G5a --> G5b

分步转向。TurnToHeadingOnce 把差角 按后端的单笔上限 拆成 等份:

每步单位数算出 0 时补成 ±1。步间隔取 ms, 是后端的最小发送间隔。

后端单笔上限最小发送间隔转 170°
桌面30°06 步 × 28.3°,每步 225 单位(1280 宽下 7.93 单位/度)
Linux18°16 ms10 步 × 17°
ADB90°02 步 × 85°

三端的步间隔都落在 100 ms。拆步来自 #4091「HEADING 大角度转向按后端线性上限拆步执行」,在那之前大角度一次拖完,超出后端线性区的部分会转少。各后端的单位换算见度与单位。

前进脉冲。转完等 300 ms,按 270 ms 前进,再等 300 ms。只转镜头时角色朝向不变,走一步角色才转到镜头方向;末端纠正里转向之后不松前进键,也是这个原因。差角已在 1° 以内时也照样走这一下脉冲(#4091 加入)。

闭环复核。#3895「HEADING 校验及重试」加入,确认转向确实到位,视角拖动被吞掉时重做一次,容差放得很宽:

  1. 读稳定朝向:每 120 ms 读一帧,至多 4 帧,相邻两次读数差 ≤ 15° 才算稳定。两次一致的要求来自 #4091。
  2. 残差 ≤ 40° 接受。
  3. 否则按残差再走一遍转向、等待、脉冲、等待,最多 3 次;用完仍照样接受。
  4. 读不到稳定朝向,按已有结果收尾。

40° 的容差很宽。复核要抓的是整笔视角拖动被吞掉的情况,那时残差接近整个差角;几度的误差交给后面的走廊跟随去修。

推进之后,如果定位已经无效就跳出循环,后面的 HEADING 等下一刻重新取位,免得拿缺失的朝向去算。复核失败时当前点仍按原语义完成。只有第一笔转向发不出去才会失败(heading_turn_failed);复核阶段补转失败,直接以当前读数收尾。

TurnToHeadingOnce 还被三处复用:起步和站定动作之后的镜头对齐、FIND 的搜索与修正、滑索的瞄准。

FIND

#5891「新增 FIND 语义,按识别框寻找并接近目标」(2026-09-20)处理只有识别框、没有坐标的目标,比如 NPC 头顶的名字、场景物件的标签。导航拿识别框直接对着世界走:转视角搜索,对准了前进,走过头退一步,全程不读小地图,这一段是视觉伺服(visual servoing)⁠[5]。命中 find_stop 算到位,走到 find_arrive 附近也算到位,两个都给时先到先算。带 target 的先走到锚点再找,不带的是就地开找的控制节点。

字段

字段含义约束
find_target目标识别节点,任何能给出识别框的节点(OCR、NeuralNetworkDetect、TemplateMatch、And 组合等)与 find_text 二选一
find_text内联 OCR 文本表;写成 { "node": ... } 时与 find_target 同义与 find_target 二选一
find_stop完成判据节点,命中即完成与 find_arrive 至少一个
find_arrive完成坐标 [x, y],进入 2.0 圈即完成与 find_stop 至少一个
  • 四个字段都有驼峰别名。
  • 路线级默认值按组下发:目标来源(find_target / find_text)整组下发,点上写了其中任一个,路线级的两个都不生效;完成条件逐字段补齐。
  • 二选一冲突、两项都缺、find_arrive 不是恰好两个数,都在解析阶段失败:没有到达判据,就无从判断找没找到。
  • find_stop 要能区分目标与背景:FIND 只看判据命中与否,不核对框指向的是谁,判据写成任何角色身上都会出现的交互提示,路过别的可交互物时就会提前完成。

内联文本用内置节点 MapNavigatorFind:OCR,ROI 100,80,1080,520,expected 是占位文本。路线写 find_text 时,导航把 expected 换成路线给的文字表,按帧调用这个节点,节点本身从不派发;ROI 和阈值留在 pipeline 里。ROI 只覆盖世界画面的中上部,需要收紧时照抄一份节点,用 find_target 点名自己的节点。

两种形态

json
{ "action": "FIND", "target": [182.52, 173.4], "find_target": "GiftOperatorName", "find_stop": "GiftOperatorApproachStop" }
{ "action": "FIND", "find_target": "GiftOperatorName", "find_stop": "GiftOperatorApproachStop" }
  • 带 target:先按普通点走到锚点(接近时走路,不设严判),进圈后开始找。
  • 不带 target:控制节点,在就地消费段被消费,当场开始找。就地消费段只接控制节点,带坐标的 FIND 要等走到锚点由 ArriveFind 接手,否则会被提前消费掉。

FIND 是分段边界,开启滑索时不会被跳过。

接手

BeginFind 接手,预算从这一刻开始算:停车并置位镜头对齐,等 150 ms,清空 FIND 状态,记下开始时间,phase 切到 WaitFind。WaitFind 一律走路,见走路;卡死检测、绕障、重规划、预筛在这个 phase 都不执行,见 phase。

同时备好停车判据的预筛。走路时用模板预筛密集盯着提示,命中才跑权威识别;停车判据读不成模板(OCR 等)时,退回每次探测都跑权威识别,代价是每次探测更贵。

find_stop 是 TemplateMatch 时,用和扫描节点校验同一套 TryLoadPromptScanProfile 读出 ROI、模板、阈值;其他类型没有预筛,探测时直接跑识别。

FIND 每刻

mermaid
flowchart LR
    subgraph G6a [停止条件]
        direction TB
        A{"预算用尽?<br/>48 步或 60 s"} -->|是| F1(["find_budget_exhausted"])
        A -->|否| B["截图"]
        B --> C{"find_stop 命中?"}
        C -->|是| OK(["完成"])
        C -->|否| D{"写了 find_arrive<br/>且进入 2.0 圈?"}
        D -->|是| OK
        D -->|否| X(["转识别目标"])
    end
    subgraph G6b [识别与接近]
        direction TB
        E{"识别目标"}
        E -->|未命中| M{"见过目标<br/>且连续漏认 < 3?"}
        M -->|是| W(["站住等一刻"])
        M -->|否| S(["原地转 30° 搜索"])
        E -->|命中| AP["按框转向、前进或后退"]
        AP --> P["前进中密集探测 find_stop<br/>700 ms 窗口"]
        P -->|确认命中| OK2(["完成"])
        P -->|没有| N(["等 120 ms,下一刻"])
    end
    G6a --> G6b

TickFindTarget 的完整顺序:

  1. 上下文:没有 MaaContext、会话或当前点时直接失败,原因 find_context_missing。统一清理要用到会话,上下文不全时不走 FailFind,直接交还失败。
  2. phase 对不上:当前点已经不是 FIND 时,清理后把 phase 交还给 Navigate,这一刻让给当前点自己的 phase。
  3. 预算:步数 ≥ 48 或已过 60000 ms,失败 find_budget_exhausted。然后步数加一。
  4. 判据缺失:find_stop、find_arrive 都没有,失败 find_criteria_missing。解析阶段已经拦过,这里是运行期的保险。
  5. 截图:自己发一次截图并等结果,不读缓存,读缓存会拿到旧帧,FIND 就会照着过期画面走。截图发不出去或没等到结果时停车,空转这一刻,等 120 ms;连续失败由步数预算兜住。
  6. 停车判据:find_stop 命中就完成,原因 find_target_reached。
  7. 到位坐标:写了 find_arrive 才读定位,读数只用来算距离。距离 ≤ 2.0(沿用严判的判定圈)完成,原因 find_arrive_reached。定位读不到或黑屏时这一刻不算数,下一步再读。
  8. 识别目标:find_target,或注入了 find_text 的内置节点;两者都没有,失败 find_target_missing。调用本身出错(节点不存在、取不到识别详情),失败 find_recognition_failed。调用成功和命中分开判:节点写错或框架报错当场失败,免得被当成没看见、一直搜到预算用完。
  9. 没看到目标:先停车,免得带着上一刻的朝向继续往前冲,连续漏认计数加一。
    • 见过目标、漏认不到 3 刻:站住等一刻。遮挡和识别抖动一两帧就把刚对准的镜头甩走,下一刻还得转回来。从没见过目标时不给宽限,直接搜。
    • 否则原地转 30°(一圈 12 步)。第一步朝目标最后出现的那一侧转,没见过时向右(search_sign 初值 +1),之后保持同向;每步重新挑方向的话,正后方的目标会让镜头来回摆。转向发不出去,失败 find_turn_failed。
  10. 看到了:漏认计数清零。框宽或框高 ≤ 0 时失败 find_empty_box:DirectHit 这类识别命中了却没有框,宽为零的框中心落在最左边,拿它当方向只会一直往左转。帧尺寸为 0 时停车空转。
  11. 接近:见下一小节。
  12. 停车探测:前进中密集探测 find_stop,见下文。
  13. 等 120 ms。

视觉伺服接近

cpp
bool ApproachSighting(const Context& ctx, FindState& find, const MaaRect& box, int32_t frame_width, int32_t frame_height)
{
    const int32_t center_x = box.x + box.width / 2;
    const int32_t center_y = box.y + box.height / 2;
    // 目标消失后,搜索先朝这一侧转
    find.last_seen_side = center_x >= frame_width / 2 ? 1 : -1;
    const int32_t offset_px = center_x - frame_width / 2;
    // 判定用 1280 基准帧的像素
    const int32_t offset_base =
        static_cast<int32_t>(std::lround(static_cast<double>(offset_px) * kPipelineRoiBaseWidth / static_cast<double>(frame_width)));

    if (std::abs(offset_base) > kFindAlignTolerancePx) {
        // 小偏角边走边转,偏太大先站定
        const bool walk_through = std::abs(offset_base) <= kFindWalkWhileTurningPx;
        if (!walk_through) {
            ctx.motion_controller->SetForwardState(false);
            // 等过静默期再转
            utils::SleepFor(ctx.action_wrapper->SteeringProfile().action_quiet_period_ms);
        }
        const double degrees_per_px = kTurnDegreesPerCircle / static_cast<double>(ComputeTurn360Units(frame_width));
        const double yaw_deg = static_cast<double>(offset_px) * degrees_per_px * kFindSteerGain;
        const bool turned = TurnToHeadingOnce(ctx, yaw_deg);
        if (walk_through) {
            ctx.motion_controller->SetForwardState(true);
        }
        return turned;
    }

    if (static_cast<double>(center_y) > static_cast<double>(frame_height) * kFindPassedCenterYRatio) {
        ctx.motion_controller->SetForwardState(false);
        ctx.action_wrapper->SetMovementStateSync(false, false, true, false, kFindBackwardPulseMs);
        ctx.action_wrapper->SetMovementStateSync(false, false, false, false, 0);
        return true;
    }

    // 对准了保持前进
    ctx.motion_controller->SetForwardState(true);
    return true;
}
FIND 按框中心位置分区、俯视下偏移换算出的转角、同一个框连续四刻在画面上的位置

每刻按新框修正一次,对准时移动不停,偏了转视角,走过了退一步。① 把 1280×720 的画面按框中心的位置分区:

框中心动作说明
离中线 ≤ 80保持前进kFindAlignTolerancePx = 80,1280 基准帧像素
80 到 160边走边转kFindWalkWhileTurningPx = 160
超过 160停车,等后端静默期,再转偏得太多时边走边转会带着旧方向越走越偏,先站定转正。停车本身也是一条移动指令,紧跟着的转向会被静默期吞掉。ADB 60 ms,其他后端 0
≤ 80 且低于 (720 高下为 480)后退 200 ms框中心掉进画面下三分之一算走过了,退一小步

② 和 ③ 是偏移到转角的换算。记框中心离中线的像素数为 (即 offset_px),帧宽为 。区间判定用折算到 1280 宽的像素 ,角度换算用真实帧宽:

区间判定折算到基准帧,同一个容差在任何分辨率下占画面的比例相同;角度换算用真实帧宽,是因为转一圈的单位数按帧宽定义。1280 宽时 每像素。展开后 和 只以比值出现:

与分辨率无关。框在画面边缘时 ,对应 ,乘增益后 26.6°。② 是俯视,角色朝上,每条线是一种偏移这一刻要转到的方向:偏 80 转 3.3°,偏 160 转 6.7°,偏 255 转 10.6°。偏移到角度的换算是线性化的,增益打满会转过头,所以只取 0.33。

③ 按线性化的换算推演同一个框:每刻修掉 33% 的偏差,余下 67%,第 刻的偏移 满足

偏 255 的框依次变成 171、114、77;前两刻在 160 以外,先停车再转,第 3 刻边走边转,第 4 刻进了 80 的容差,保持前进。实际收敛还受走动和识别抖动影响。

转角都很小,任何后端的单笔上限都装得下,TurnToHeadingOnce 一步发完。每步末尾等 120 ms,这段节流同时充当下一步转向的静默期,比后端的静默期短的话,下一步的转向会被上一条移动指令吞掉。这个关系用一条 static_assert 在编译期锁住:kFindStepSleepMs 必须大于 ADB 的静默期。

停车探测

每刻只查一次 find_stop 的话,提示窗口很窄,可能就夹在两刻之间,弹出来又被走过去。ProbeStopWhileWalking 在按着前进时把它盯密:

  1. 窗口 700 ms,每 120 ms 自己截一帧(kFindStopProbeWindowMs、kFindStopProbeIntervalMs)。截图失败就收手,下一刻再盯。
  2. 有模板预筛时,先在这一帧上跑 MatchTemplateOnFrame:裁出 ROI,归一化回作者 ROI 的尺寸,跑后台扫描器那个匹配函数。两边共用一个函数,判据不会漂移。没命中就等 120 ms 继续。
  3. 预筛命中(或者没有预筛):松前进,等 150 ms,在同一帧上跑 find_stop 的权威识别。权威确认也要时间,先站住再确认,免得边走边确认多滑出去一段。
  4. 确认命中:完成,原因 find_target_reached。
  5. 没确认,算预筛误报:重新按前进,等 120 ms 继续探测。

没有预筛的停车判据(OCR 等)在第 3 步每次都会先停车 150 ms 再识别,接近段因此走走停停。判据能写成 TemplateMatch 时,大部分探测只付毫秒级的模板匹配。

FIND 退出

完成和失败都经过 LeaveFindPhase:松前进,清掉操舵辅助状态、前进保持计数、定位追踪、路点进度和 FIND 状态。FIND 这一段没读地图,转视角都是开环发的,留下的操舵记账、走廊和速度样本都已过期,下一段会拿旧朝向去操舵。

FIND 结束时角色可能站在路网之外、朝向任意。文档建议在 FIND 后接一个普通移动点,让下一段重新定位;FIND 也可以作为路线终点,完成后路线结束。

FIND 失败

原因触发
find_context_missing没有 MaaContext、会话或当前点
find_budget_exhausted48 步或 60000 ms 用完
find_criteria_missingfind_stop、find_arrive 都没有
find_target_missingfind_target、find_text 都没有
find_recognition_failed目标或停车判据的识别调用出错
find_empty_box目标命中但框宽或框高为 0
find_turn_failed搜索或修正的转向发不出去

步数和时长任一用尽,这个点就判失败。48 步够原地转 4 圈。FIND 失败时 MapNavigateAction 返回 false,交给外层的 on_error 或重试。

TRIGGER

TRIGGER(#6155「新增 TRIGGER 语义」)放在路线末尾,表示看到某个画面就算到了,比如进入剧情、弹出对话框或界面。路点带一个识别节点 trigger_node(也可写 triggerNode),导航只跑它的识别,节点自己的动作和 next 都跳过;导航成功返回后,由外层 Pipeline 接着走。

解析时的约束:

  • 只能用对象写法。数组写法里的字符串是区域名,写不进节点名。
  • 必须带 trigger_node,且必须是路线的最后一个点,否则参数解析报错。命中就结束导航,TRIGGER 后面的点永远轮不到。
  • 别的动作带了 trigger_node 时,字段被忽略。

途中识别。从出发起,只要路线的最后一个点是还没命中的 TRIGGER,Navigate 每一刻在定位之后检查一次:距上次识别满 300 ms,就拿定位刚截的那一帧跑识别,省掉一次截图。命中时松开前进键,路线直接跳到 TRIGGER 之后,同时记下终点证据;下一刻路线已空,进 Finished。命中与角色走到哪无关,走到半路命中也算成功。

到点等待。走到 TRIGGER 点时还没命中,停下所有移动,切到 WaitTrigger。WaitTrigger 每刻重新截一帧识别,没命中睡 300 ms 再来;截图失败同样睡 300 ms 重试;命中即完成到点。等满 15000 ms 失败。

接近。走廊跟随只接管 RUN 点,TRIGGER 点本身直线走向坐标。需要寻路时,在 TRIGGER 前面放一个同坐标的 NAVMESH 点,展开出的 RUN 点由走廊跟随,途中识别照常进行。

选画面。识别从出发就开始跑,路上别处也会出现的画面会让导航提前结束,所以要选只在目的地出现的画面。识别每 300 ms 一次,一闪而过的画面可能漏掉。坐标要放在画面一定会出现的地方,否则只能等满 15 s 失败。

丢定位。命中的画面经常盖住小地图,这一刻定位失败。途中识别排在丢定位处理之前,定位失败的刻照样识别;TRIGGER 待命中期间丢定位也不盲跳,免得按键干扰刚出现的画面,见丢定位。

原因触发
trigger_wait_timeout到点后等满 15000 ms 仍未命中
trigger_recognition_failed途中或到点等待时,识别调用出错
trigger_context_missingWaitTrigger 里没有 MaaContext,或当前点不是 TRIGGER

滑索

滑索架是玩家在大地图上自建的交通设施。两根通电的架子之间按距离挂索;人站上架子,对准一根索按左键,就滑到另一头的架子上,落地后仍站在架上,可以接着瞄下一根,右键下架。路线参数打开滑索后,MapNavigator 把滑索当作一种可选的走法,与步行放在同一个代价比较里;纯步行走不通时,一串滑索还能把起终两侧的可走面接起来。

整套逻辑的 C++ 部分约 4800 行,另有 Go 代码约 1200 行,用于 Linux 导入和 Android 上的提示:

部分文件行数
记录、账号、标定、Windows 与 macOS 导入Zipline/ 下 8 个文件1636
Linux 导入、Android 提示go-service/ziplineimport/ 下 11 个文件1191
偏好与结论提示zipline_preference.*219
规划zipline_leg_planner.*1281
执行zipline_ride_machine.*、zipline_action.*、zipline_types.h1642

两套坐标同时出现。滑索记录用森空岛的世界坐标,单位 m;规划和执行用底图像素。两张有滑索的大地图,map02 每米 0.75 像素,map01 每米 0.703125 像素。下文的索长、跨度、拦路架垂距按 m 计,落点圈、删边半径、步行带、判定圈按像素计。

记录与账号

每张森空岛地图存一条记录,带账号标识、地图编号、抓取时间和全部标记,落在进程启动时工作目录下的 debug/record/Ziplines.json(#6145「统一持久化输出路径基准为启动 CWD」)。标记的 x/z 是水平面,y 是高度,另有模板编号和区域编号。同一张图的各区共用一套世界坐标,所以配对时不看区域编号。

写入时按(账号,地图)把整张记录替换掉,玩家拆掉的滑索随之从记录里消失。逐条合并会把拆掉的滑索留下来,成为图里一条走不通的边,规划会反复选中它再失败,比缺这条滑索更糟。

账号隔离由 #5340「按账号隔离滑索数据」加入。记录里的账号字段是一个伪匿名标识,盐和算法与 CaptureUid 相同:角色编号(8 到 12 位数字)拼上本机随机生成、存在 debug/record/random_salt.txt 的盐,取 SHA-256 的前 16 位十六进制。运行时只认 go-service 发布在节点 CurrentAccountIdentity 里的当前账号,格式不是 16 位十六进制就当认不出;认不出时整趟走路。

旧版本落盘的记录没有账号字段。#5649「滑索寻路沿用旧版本无账号的记录」加了认领:当前账号名下一条记录都没有时,把无账号的旧记录全部归到当前账号名下并落盘。这些坐标升级前一直在用,不认领的话,用户什么都没动,滑索却在升级后静默失效,只能重新导入一遍。名下已经有账号级数据时一条都不认领:旧记录说不清属于哪个账号,掺进来的话,换号后会静默用错坐标。

用不用滑索由节点 MapNavigatorZiplinePreference 的 zipline 字段定,取 always、never、auto 三值。auto 跟随任务自己的设置。认不出的值也按 auto 处理,一个读不懂的字符串因此改变不了寻路结果。

一趟寻路结束后,用户会看到一句结论。规划器按腿记四本账:用上了、账号认不出、没有数据、有滑索但没选中。这里没有滑索可用、有滑索但没选中这两种情况,用户该做的事不同,混成一句会把人带偏,所以分开记。

四本账用 thread_local 变量逐腿累加:只留最后一条腿的结论的话,前面用上滑索的那些腿就被说没了;thread_local 让并发的寻路请求各记各的账。提示按账号认不出、没有数据、没选中的顺序只说一条,没数据排在没选中前面,因为只有它需要用户动手。同一句话一次运行只说一遍,有一趟用上了滑索就清零重来。

导入

坐标来自森空岛网页地图。导入动作按检测到的区服打开国服或国际服的地图页,让用户在窗口里登录,页面自己去拉各张图的标记列表,导入动作只把路径含 /map/mark/list 的响应抄下来。三个桌面平台的窗口不同:

平台实现PR
WindowsC++,WebView2,经 DevTools 协议监听网络响应并关闭缓存—
macOSC++,WKWebView 窗口#6088「macOS 用 WKWebView 登录窗口导入滑索」
LinuxGo,临时 profile 启动 Firefox,经本地受限代理抄响应#5610「滑索导入支持 linux」

Android 上注册了同名动作 ZiplineImport,只提示「Android 端暂不支持导入/更新滑索坐标」并返回失败(#6162「Android 上导入滑索坐标改为提示暂不支持」)。导入要开窗口让用户自己登录,设备上的 agent 是没有界面的子进程,桌面端的两套窗口都用不了;任务入口与桌面 ADB 共用同一个控制器,没法单独对 Android 隐藏,所以用一个同名动作把原因告诉用户。

Linux 的代理只解密提供标记接口的那一个 API 主机。一个本机 PAC 只把这个主机指向代理,登录和其他站点的连接一律直连;根证书一次性生成。登录态留在浏览器里,带签名的 mark/list 请求也由浏览器自己发出,代理只抄下响应体,凭据和签名都不经过导入代码。

三个平台的收工条件相同:

  1. 所有标定过的地图都抓到后,再静默 1200 ms 收工,给同一批次里最后几条响应留点余量。
  2. 抓到一部分、凑不齐标定过的地图时,静默 20 s 收工。
  3. 窗口最多开 180 s。这段时间是留给用户登录的,登录之后抓齐只要几秒。

只有带合法角色编号、确实含标记的响应才推进收工判据。没登录时页面也会返回空列表,拿收到响应当作进展的话,会在用户还在登录时把窗口关掉。主地图列表先空后非空,说明用户刚完成登录。

落盘前先把所有响应并到一起。同一张图可能由不止一条响应带回来,而落盘是整张替换,逐条落盘的话后一条会把前一条整个抹掉。账号判定只看实际带回标记的响应,登录前那批角色编号为空的公开空列表不参与。

一次导入里出现的角色编号必须恰好一个,否则拒绝落盘。角色编号只在内存里参与匹配,落盘的只有哈希后的账号标识。合并后的标记按内容去重,顺带把落盘顺序定死。

标定与架子

世界坐标到底图像素靠每张图一组标定,配置在 zipline_frames.json。两套坐标彼此独立,标定过才能互换;没标定的地图一律不参与滑索规划,照着错的坐标只会规划出一条走不通的假路。

平面部分是完整仿射,高度部分是一条直线。记世界坐标为 ( 是高度),标定的六个数 plane 为 ,height_scale 为 ,height_offset 为 :

是底图像素坐标。矩阵负责缩放、旋转和翻转,后面的向量负责平移。

地图plane高度
map01base[0.703125, 0, 720, 0, −0.703125, 630]原样
map02base[0.75, 0, 1536, 0, −0.75, 1344]原样

两张图都是纯缩放加平移,y 轴翻转。没标定的区当作本来就没有滑索,规划直接返回空,不计入任何一本账,这种情况用户导入坐标也解决不了。导入也以标定表为准判断抓齐了没有。

每张图的标定可以带一份模板白名单,只有名单里的标记成为节点;名单留空时,该地图下的标记全都算数。配置还登记了架子和供电结构的物理属性,没登记索长的模板不参与配对。

架子索长上限占地
滑索架80 m3×3
长距滑索架110 m3×3
供电结构判据参数
供电桩、息壤供电桩、供电终端直接连线水平距离 ≤ 30 m
中继器、息壤中继器工厂网格覆盖占地 3×3,覆盖 7×7

最后是代价模型的五个参数:

字段值含义
speed_ratio0.35滑索约比跑步快三倍,是个保守估计,待实测收紧
mount_penalty25一次上索的开销,含转向对准、交互、起步,同样待实测
transfer_penalty10链中换乘的开销。落点就是下一根架子,人不用走过去也不用重新起步,只剩重新对准和交互,所以比上索便宜
min_gain25收益门槛。省下的不到一次上索的开销时,这点便宜落在上面几个估计值的误差里,换来的却是多一次交互、多一处可能失败的地方
reach_radius6两端架子到可走面的吸附距离上限。链中间的落点不受此限,人从索上落到下一根架子上,脚下有没有可走面都站得住

代价全部折成步行像素:比较滑索和步行的耗时,判据两边同乘走路速度,时间就换成了像素,与 NavMesh 的路径长度同单位,因此用不着知道走路的绝对速度。mount_penalty 25 表示上一次索的耗时折合 25 像素步行,与路径长度直接相加。

角格锚点

森空岛给的坐标是架子占地的一个角格,哪个角随朝向变,响应里又没有朝向。实测同一台 3×3 中继器原地旋转后,坐标沿一条轴跳 2 格,正好是占地边长减一(见供电判定的摆放实验)。单个标记还原不出唯一的中心。

所以每根 3×3 架子按 4 个可能的塔心算。记标记的格坐标为 ,占地边长为 ,占地半宽 (3×3 时 ):

加 0.5 是从格的角落移到格的中心,两个 组合出 4 个塔心。

两根架子能不能挂索,先用一个下界筛。记两个标记的坐标差为 ( 是高度),两根架子的占地半宽为 、。双方塔心在每条水平轴上都可能朝对方靠近各自的占地半宽,合计最多靠近 (3×3 对 3×3 时为 2 m);高度不受朝向影响:

超过索长上限的平方,这一对就不可能挂索。比较平方值省掉一次开方。

一个标记对应的 4 个可能塔心,两根架子按最近一对塔心取距离下界,两对架子按锚点和按塔心量出的距离

左上是一根 3×3 架子的标记和 4 个可能的塔心,四种颜色的虚线框是四种朝向下的占地。右上是下界的取法:A、B 两个标记 x 轴差 6、z 轴差 2,两边的塔心在每条轴上都能朝对方靠近 1,x 轴最近差 ,z 轴能贴齐,最近的一对塔心水平只差 4。下图取自 #5252「兼容滑索架角格锚点距离」正文:两对实际挂着索的长距滑索架,按锚点量是 111.526 m 和 110.204 m,都超过 110 m 上限;按塔心逐组量,最小的一组是 108.931 m 和 107.986 m。

#6104 那条链(见滑索实测)的最后一跳也是这种情况:两根架子按锚点量水平距离约 111.0 m,靠塔心容差才进了图。

供电判定

自己建的架子不通电就滑不了,规划前先按供电范围把它们挡掉。供电结构分两类,判据不同:

  • 供电桩、息壤供电桩、供电终端:和架子直接连线,标记之间的水平距离不超过 30 m 就算通电,。只量原始世界坐标里的 、,架子与供电桩的高低差不进判据。
  • 中继器、息壤中继器:按工厂网格覆盖判断,覆盖范围 7×7 格,见下文。

这一步也有意挡掉共享滑索。共享滑索不用供电就能滑,导入标记时会和自己建的架子一起带进来;但它随时可能消失,规划进链的话,人走到跟前架子可能已经没了。通电判定把它们一并剔除,规划只用自己电网里的架子。

中继器的覆盖范围以它的中心格为中心。中继器和架子的标记都是角格锚点,判据把两边的不确定量都算进去。记中继器覆盖范围的半宽为 ,中继器和架子的占地半宽为 、,两个标记的轴差为 、,两条轴都满足

才算可能重合。中心可达是两个真实中心之间允许的轴差:差在覆盖半宽加架子半宽以内,架子占地就和覆盖范围有交集。锚点不确定是两个标记各自离真实中心最多差出的格数。7×7 覆盖、3×3 中继器、3×3 架子时 ,,右边是 。

中继器 7×7 覆盖与 3×3 架子:真实中心差 4 时仍重合一列,两边标记各偏 1 格后轴差最多 6

俯视,一格是一个工厂格。左图是中心可达:覆盖范围的边缘离中继器中心 3 格,架子的边缘离它自己的中心 1 格,两个中心差 4 时,架子还有一列压在覆盖里。右图是同一个摆放,两边的标记都可能落在各自占地的 4 个角格上;中继器的标记偏到左边、架子的标记偏到右边时,标记的轴差就是 。

判据取的是任一可能朝向下能重合就保留。森空岛给出朝向之前,宁可在边界上多留几根其实没通电的架子,也要保证通电的架子进得了规划图。

边界用三条 static_assert 钉住:

cpp
static_assert(grid_areas_may_overlap(5.0, 2.0, { 7, 7 }, { 3, 3 }, { 3, 3 }));
static_assert(grid_areas_may_overlap(6.0, 6.0, { 7, 7 }, { 3, 3 }, { 3, 3 }));
static_assert(!grid_areas_may_overlap(0.0, 7.0, { 7, 7 }, { 3, 3 }, { 3, 3 }));

(5, 2) 是下表摆放实验里紧贴着的一对,旧判定把它挡掉了,必须判可能重合;任一轴到 7 才能确定不重合。

这些几何关系来自 #5204「修复滑索镜头、定位与供电锚点判定」正文的摆放实验:

实验标记坐标结论
两个相邻的 2×2 供电桩同一轴相差 21 世界单位 = 1 工厂格
3×3 滑索架与 3×3 中继器紧贴轴差 (5, 2)标记点不在统一的中心上
中继器原地旋转X 变 2,轴差变成 (3, 2)标记点随朝向在角格间切换
设备从台面搬到地面相对差仍是 (5, 2)偏移与高度无关
两个相邻 2×2 供电桩的标记差、中继器与架子紧贴时的标记轴差 (5,2)、中继器旋转后变成 (3,2),以及新旧判据在标记轴差平面上的保留区域

(a) 到 (c) 是表里前三个实验的俯视图,标记所在的角格按实测轴差画了一种相符的摆法。(b) 和 (c) 是同一个摆放,只把中继器转了个向,标记的 x 轴差就从 5 变成 3。旧判定把标记当中心、要求轴差 ≤ 4,结论跟着翻转。(d) 把两次实测画在标记轴差平面上:新判据的框放宽到 6,两次都保留;(6, 6) 和 (0, 7) 是上面 static_assert 钉住的两个边界点。

PR 正文给出的效果:放宽之后,map01 比旧判定多保留 5 根滑索,map02 多保留 7 根;同时写明自动化检查无法验证这些新增候选是否全部实际通电。一张图上一个供电结构都没记录时,这张图的架子全部算不通电。

规划早退

滑索规划的入口是 PlanZiplineRoute。每条步行腿规划完之后调一次(带步行基线),步行规划失败时也调一次(不带基线,尝试桥接)。下面任何一条成立就返回空,这条腿照常走路:

  1. 路线没开滑索。这是第一条检查,没开滑索的请求到这里就结束,不读标定和记录,也不多跑规划。
  2. 账号认不出。
  3. 没有标定文件,或当前区没标定。
  4. 通电架子少于 2 根。
  5. 有步行基线,且步行长度 ≤ 50 像素。

第 5 条来自收益门槛。记步行基线的长度为 ,min_gain 为 ,有基线时门槛(gain_threshold)

滑索方案的总代价必须低于 ;而任何滑索方案至少付一次 mount_penalty(25), 即 时谁都赢不了。省下的不到 min_gain 时,这点便宜落在代价模型自身的误差里,换来的却是实打实的多一次交互,所以要省下 min_gain 以上才算有收益。

没有基线时门槛是无穷大,任何接得上的链都收:纯步行不连通,没有可比较的基线,能把起终两侧可走面接起来的链都优先于盲走和作者路线回退。

节点按记录收集:只收账号和地图都匹配的记录,每条记录先收齐供电结构,再逐根判通电,不通电的只计数。每根架子记下出自哪条记录:一条记录就是一张图,不同图的架子之间挂不上索。供电结构另外投影一份到像素平面,上索让位时要用(见滑索规划结果)。

两端可达

一条链只有头尾两根架子需要人走到跟前;链中间的架子是人从索上落上去的,脚下有没有可走面都不影响,所以只筛两端。筛的时候平面距离和高度一起判:可走面在同一片平面坐标上能摞好几层,只比平面距离的话,架在楼顶而脚下那层在楼底也算够得着,人走过去才发现头顶上什么都没有。

每根架子把锚点和 4 个塔心都当作站点,逐个吸附到 NavMesh:

  • 吸附距离 ≤ reach_radius 6,且吸附面高度与架子高度之差在同层容差内,这个站点算数。
  • 吸附得到、高度对不上,计 wrong_floor;一个都吸附不到,计 out_of_reach。这些架子上不了索,仍可作链中间的节点。
  • 全部架子都上不去时提前返回。这个原因和不划算分开报,上不去多半是标定或高度对不上,需要另查。

吸附得上还要连得通。NavMesh 的每块可走面有连通类号,窗内搜索只在一个类里走,所以上索点要跟角色同类,下索点要跟终点同类。不同类的架子照样按直线距离排在前头,规划一条条失败,会把规划预算耗尽。这一步只排除已知走不通的组合,类号问不出来时按可能连通处理:多跑一条规划的代价,比误删一根能用的架子小。

#6227「同类走不通时经台沿跳下换类续规划」 让规划器在终点没有声明面时,可以从台沿跳下换到另一类(见 NavMesh 一文的台沿下落换类)。approach 的终点总钉在架子那一层,用不上这条路;departure 的下索点仍只留与终点同类的。

站点与起点同类,这根架子可以上索;与终点同类,可以落地。两头都不同类计 cut_off。

账本里记为 Unboardable 的架子(每个站位都走到过、一次上索提示都没出来)这一趟取消上索资格,仍可当落点:人是从索上落到架子上的,上不去和够不着是两回事。

每根架子到起点、到终点各取一个欧氏距离下界,取所有站点里最近的那个,规划最后落在哪个站点,真实路程都不会比它短。

配对

记录里没有哪两根架子之间挂着索的信息,规划器按几何推断:同一张图上两根架子,任一对可能塔心的三维距离不超过两者索长上限里较小的那个,就当它们之间有一条候选索。索不分上下行,两个方向都算。位置含糊时优先保留候选:推错了,执行侧会删掉失败的边再重新规划;推漏了,经过这根索的整条链都发现不了。

BuildLinks 对每一对 i < j:

  1. 两根必须出自同一张图,两边的索长上限都 > 0(没登记物理属性的模板上限为 0,不参与配对)。上限取两者较小的那个。
  2. 用角格锚点的下界剪枝。各轴取最近那一对塔心都够不着的,逐组量也够不着,这一步省下内层的逐组循环。
  3. 两端各取 4 个绳端,两两配成最多 16 组,逐组量三维长度。有一组 ≤ 上限,这条边就进 all;在上限以内的那几组连线留给遮挡测试。
  4. 再问拦路架。被拦的边留在 all,记下垂距;没被拦的进入待测列表,等绳端与遮挡决定能否进 certain。

配对不看架子类型和所在区域,这是 #5972「滑索按碰撞遮挡体判断索是否被挡」放开的。在那之前配对要求同类型、同区域,一批实际挂着索的架子因此连不上;这个 PR 一次关掉了 15 个 issue。

拦路架

一根长索的中段如果立着另一根通电架子,人可能滑到中间那根上就停下。#5618「滑索规划剔除拦路与地形阻挡的索」加入了这条规则,#6014「滑索绳端按塔落地高度算,拦路架按三维垂距量」把垂距改成三维。中段立着另一根架子的直索,实际形态可能是经过中间那根的两跳链,所以降到不确定档。拦路架要离两端各超过一个拦截半径,离端点更近的架子就当作端点本身;它与 from 之间也要挂得上索,从 from 够不到的架子接不住滑过来的人。

判定过程,距离单位 m。记索的起端架子(from)为 、末端架子(to)为 ,都是三维标记坐标:

  1. 索长 (span)。 的短索不判。

  2. 索的单位方向 。对同一张图上的其他通电架子 ,算它沿索的位置(along),要求离两端都超过 15:

  3. 从 里减掉沿索的分量,剩下的就是垂直于索的分量,它的长度是三维垂距(lateral),要求不超过 15:

  4. 与 之间也要挂得上索(用同一个下界)。

  5. 第一根满足条件的 让这条边降档。

阈值 15 来自实测。三次滑行终止在中间架子上,拦路架垂距 12.51、10.23、14.99 m;另有一次中段立着垂距 4.55 m 的架子,人却整条滑完了。单靠距离分不开这四例,所以这条规则只降档不删边,阈值取到刚好盖住已知被拦的 14.99。垂距按三维量:平面上离索线 8.78 m、高出 55 m 的那根架子实测拦不住人,只量水平会把这类高出索线几十米的架子也算进来。

拦路架的判定区域与四条实测索,以及 issue-6177 日志里拦路降档和遮挡降档的分布

左图把判定区画在一条索的侧面:两端各 15 m 不判,中间垂距 15 m 以内降档。三个红叉是实测被拦停的三根,绿点是滑完的那根;空心绿点是平面距离 8.78 m 的那根,它高出索线 55 m,三维垂距约 55.7 m,在图外,按三维量不再降档。四例里被拦和滑完的垂距交错,15 只是盖住已知被拦例子的取值,所以规则只降档。

右上是 #6177 日志里 19 条拦路降档的垂距,1.21 到 13.37 m。规划器逐条记下拦路架的垂距,就是为了攒出这样的分布:阈值 15 的依据只有四条实测索,后续调它要靠实机日志。右下是下一节的遮挡降档。

绳端与遮挡

索挂在架子顶上,判断中途撞不撞东西,要用绳端的高度。架子把人从脚下地面抬到绳端高度,滑到那头也保持这个高度,所以索挂不挂得住要按抬升后的两端连线量;按塔底量的是贴地那条线,几乎每根索都会被判成撞地。#6014 定下的绳端高度是塔底上方 5.72 m(kRopeEndHeightWu),参与规划的滑索架和长距滑索架都一样;有的索从梁底下几厘米处钻过去,要按这个高度才判得通。

塔底高度也不直接用记录值。记录里的塔底是建造格的整数高度,建成的塔会贴到脚下的实际地面上,两者差出几厘米,贴着梁底过的绳就会判错。所以每个可能的塔心先到遮挡场景里取实际地面高度,再加 5.72 m。

侧视示意:按塔底连线穿进地面,按绳端连线越过地面;梁底附近放大后,整数高度算出的绳撞梁,实际地面算出的绳从梁底下过

侧视示意,地形和梁是画出来的,5.72 m 是实际取值。按塔底连线贴着地面走,碰到一点起伏就判成撞地;把两端各抬高 5.72 m,才是人实际滑过的那条线。右图把梁底附近纵向放大:塔底取建造格的整数高度时,整条绳跟着抬高几厘米,贴着梁底过的绳就被判成撞梁。

遮挡测试判断两根架子之间有没有隔着实体,隔着就挂不住索。难处是两端的绳端位置都定不下来:架子会旋转,记录的坐标只是 3×3 占地里的一个角格,响应里又没有朝向,单凭坐标说不出塔心在哪(见角格锚点)。所以每根架子在 4 个可能的塔心上方各放一个绳端,一对架子两两配成最多 4 × 4 = 16 条绳端连线,超过索长上限的那几条在配对时已经去掉。

遮挡测试 NavmeshLineGroupBlocks 对剩下的连线逐条做射线求交,看它碰不碰得到碰撞体。只要有一条不撞,就说明至少有一种朝向组合下索挂得住,这条边进 certain,测到这一条就停;全部撞上才留在不确定档。取宽松的一侧,和配对时优先保留候选是同一个道理:真实的那对塔心就在这 16 种组合里,判宽了执行侧会删边重规划,判严了能用的索会被压到不确定档。每条边只测一次,两个方向一起定。

俯视示意:两根架子各 4 个可能的塔心,两两配成 16 条绳端连线;实体只挡住一部分连线时边进 certain,挡住全部 16 条时边留在不确定档

俯视示意,一格 1 m,两根架子的间距缩短了。实心方格是标记,空心圆是各自 4 个可能的塔心,连线画的是塔心上方两个绳端之间的那一段;绿线不撞,红色虚线撞上,× 是首个碰撞点。① 实体只挡住下侧,A、B 的塔心都在上侧时有 4 条连线绕得过去,这条边进 certain。② 实体加长后 16 条全部撞上,这条边留在不确定档。图里画出了全部 16 条的结果;实际测试按顺序逐条测,遇到第一条不撞的就停。

射线只测两个绳端之间那一段;架子本身不在遮挡数据里,挡不着自己的绳。全部撞上的边记下每条连线首个碰撞点到起端的距离,取其中最远的那个。没有遮挡场景时全部放行。

遮挡体之前,#5618 用的是地形抬升检查:沿塔心连线采样 NavMesh 高度,地面高出连线就降档。#5972 换成碰撞遮挡体,#6014 再把起点从塔底改成落地后的绳端。

上节右下图是 #6177 的 95 条遮挡降档,占待测的 381 条边的四分之一;首个碰撞点离起端 2.99 到 96.56 m,中位约 26.7 m。

两档候选图

拦路和遮挡两条规则都只降档。all 收下两端可能的塔心之间有一组连线在索长以内的边;certain 只收两件事同时成立的边:中段没有拦路架,索长以内的那几组连线里有一组中途不撞实体。任一条不成立只降一档,边仍留在 all 里。没有哪条规则能把一根索从图里抹掉,阈值取错只改变偏好顺序。

三份日志的建图数字:

#6177#6104#5902(旧版)
不通电594497
通电节点24249170
out_of_reach / wrong_floor2 / 30 / 111 / 3
cut_off15025102
拦路降档19(1.21–13.37 m)16(0.46–13.28 m)21(0.89–14.74 m)
遮挡降档95 / 38127 / 81地形抬升 14
all 边数40097270
certain 边数28654—

表里几个数之间的关系:

#6177 是 ,。#5902 的版本还在用地形抬升检查。

剔除失败的索

执行侧每一跳的结局记在逐跳账本里(见逐跳账本)。重规划时,账本里的索失败从两张图里删边。索不分上下行,一根滑不动的索反着大概率也滑不动,所以两个方向一起删。

索失败指 NoLaunch(滑不动)、Lost(落地丢了)、WrongRope(滑错)三种结局。一条边两端分别落在记录的上索点和落点 10 像素以内(两种方向都算),这条边就删掉。回程失败时账本另记一条回程索(见逐跳账本),删边规则对它同样适用。链首那一跳记录的是上索站位,和架子坐标差着供电桩让位的那一点距离,半径太紧会删不掉;太松会顺带罚掉旁边平行的另一根索,代价只是少一条捷径。

NotMounted(按了上索键没站上去)和 Dismounted(主动下索)不删边;Unboardable 只取消那根架子的上索资格。

链代价

从每根能上索的架子出发,在图上跑 Dijkstra⁠[6],求沿索到每根架子最省的滑法,到不了的架子留无穷大。记 speed_ratio 为 ,transfer_penalty 为 ,mount_penalty 为 。一跳两端在像素平面上相距 ,边权里每一跳都含一次换乘开销:

判连通用世界距离,算代价用像素距离,因为代价要和 NavMesh 的路径长度加在一起比较。

一条 跳的链,各跳的像素距离为 ,滑索部分的代价(zip_cost):

链头那一跳是上索,调用方减掉一次 ,换成 。整个方案的代价再加上两段步行,approach 长 ,departure 长 :

采用条件是 ,门槛 见规划早退:

跳数不设上限。每次换乘都收 transfer_penalty,划不来的长链在代价比较里自己就被淘汰了。

候选的生成要用到两组下界。步行再怎么绕也不会比直线短,所以直线距离就是步行长度的下界: 是起点到架子 各上索站点的最短直线距离, 是架子 各落地站点到终点的最短直线距离。

  1. 的架子整个跳过:就算整段滑行白送,从它起头也达不到收益门槛,从它起头的链都不必算。

  2. 对每根能上索的 ,分别在 certain 和 all 上跑 Dijkstra。

  3. 对每根能落地的 ,算这对组合的代价下界(lower_bound), 的丢掉:

  4. certain 上到得了的记一条候选;all 上的链只有严格更便宜时再记一条不确定候选。哪档先用由两趟评估决定:确定边能到达就用确定边,即使它比不确定的直索多几跳、代价更高。不确定的索可能根本不存在,推断错了要整段重新规划,比多出的那几跳贵。

候选按 lower_bound 升序排好,进入评估。

两趟评估

候选的下界只用了欧氏距离,真实的 approach 和 departure 要跑 NavMesh 规划才知道。每条腿一次规划,耗时百毫秒到一秒多,预算是硬约束。候选是成对的,不设上限的话,滑索密集的地图会把规划耗时抬高一个量级;触顶后只拿已经算出来的候选做决策。

kMaxExtraPlans = 12,approach 和 departure 两个缓存共用。缓存按站点下标存:同一个站点会被多个候选对共用,按下标缓存就不会重复规划同一段路。规划失败的结果也存,下次直接跳过。一根架子的几个站点按顺序规划,第一个通的就用。

evaluate(want_certain) 的过程,best 初值为门槛:

  1. 候选的 best 时停止。候选按下界升序排,当前下界都追不上最好成绩,后面的更追不上。
  2. 规划 approach 腿。预算耗尽记 truncated 并停止。
  3. 用 approach 的真实长度 替换起点下界再剪一次, best 的候选剪掉,省下 departure 的规划。
  4. 规划 departure 腿。
  5. best 时更新最优。

先跑 evaluate(true) 只比确定候选,没有解再跑 evaluate(false)。代价比较会让便宜的不确定直索赢过确定链,单靠排序做不到确定优先,所以把两档隔开比。同一趟内候选仍按下界升序。

第一趟耗尽预算后第二趟照跑:缓存里已经规划好的腿可以直接用,碰到第一个没缓存的架子照旧记 truncated 退出,多跑这一趟不额外花预算。没评完又没找到解时返回空,这条腿照常走路。

两段腿的高度都按架子定。approach 的终点钉在架子所在那一层;人站在架子上换路时,approach 的起点也钉在脚下那根架子的高度上,见落地后的起点层。departure 的起点楼层取架子高度:人是从索上落下来的,站在哪一层已知,起点吸附优先挑这一层。departure 的终点面用调用方给的那个,与纯走路方案相同:换的只是走法,目的地不变。

滑索规划结果

选中的候选只存了首尾两根架子。规划器用同一张图(确定或不确定)重跑一次 Dijkstra,还原整条链。候选是成对枚举的,给每个候选存整条链是浪费,所以等选定之后才还原。再为每根架子列出同图里除下一跳之外的邻居,执行侧落地分类时用它们认出滑错到了哪根。

链首要按提示上索,另算一组站位 MountSpots。坐标记的是随朝向变化的角格锚点,设备模型占着锚点四周哪几格未知;交互要人面朝设备且身位与模型有交集,走到锚点上两条都未必成立,所以把各个可能的中心格排成候选逐个试。候选停在中心格这一深度:行进中的提示预筛按到航点的距离开窗,目标越往模型里推,身位被挡住那一刻离航点越远,越容易落在窗外。顺着进场方向的排前面,少一次掉头。

  1. 4 个塔心各自投影到像素平面,在 2 像素内吸附到同一层的可走面,偏差 ≤ 1 像素才留下。

  2. 记锚点为 、进场方向为 ,塔心 按 降序排列,越靠进场方向前方的越先试。进场方向取最后一段走路;起点已经站在架子跟前时进场方向为零,排序退化成固定顺序。

  3. 一个都留不下时退回锚点。

  4. 5 像素内有供电结构时,追加一个让位站点:记架子坐标为 、供电结构为 ,沿 指向 的方向外移 0.75 像素,同样要吸附得上:

让位站点针对一种具体的失败:游戏的交互面板只给离身位最近的那台设备,供电桩紧贴着架子时,面板给的是供电桩。外移量要同时满足两头:架子得是离身位最近的设备,人也得还够得着架子。让开量加上判定圈要留在够得着的范围里,所以只让开判定圈的零头 0.75 像素;让多了人被推出去,面板一样不出来。

#6104 那次规划里,链首架子 (921.09, 293.91) 旁 4.50 像素有供电结构,让位站点是 (920.51, 293.44),站位共 5 个。

上索站位按进场方向排序,以及供电结构贴着架子时沿供电结构指向架子的方向外移 0.75 像素的让位站点

左图中 是锚点, 是最后一段走路的方向。点线垂直于 ,同一条点线上各点的 相等,塔心按所在点线沿 的先后排序。右图是上面那根 #6104 链首架子,像素坐标:供电结构离架子 4.50 像素,在 5 像素圈内,让位站点沿 指向 的方向外移 0.75。

然后 TryAppendZiplineLeg 把结果铺成航点。有步行路线时,滑索只在严格更省时顶替走路;没有步行路线时,滑索尝试桥接不连通的起终两侧。两种情况都不改目的地和到达声明,终点面仍钉在末点上。

  1. 先铺 approach 的走路点。
  2. 每跳一个 ZIPLINE 航点,带严判到点,目标层是这一跳起点架子的高度。头一跳的坐标用第一个站位;架子坐标是角格锚点,走到它跟前常常碰不到设备模型。
  3. 跳与跳之间不插走路点:中途落在下一根架子上时,人就站在下一跳的上索点上。
  4. 每跳写入规划仰角 , 是高差,按世界坐标算。像素平面的 x/y 按地图比例缩放过,和高度不同尺,混着算出的角没有意义。
  5. 站位表只挂在头一跳。chain_continues 标记落点之后是否还有下一跳。
  6. 最后铺 departure;departure 为空时补一个 RUN 到达点,让这一腿仍以目标点收尾。

两个调用点里,桥接模式排在 NavMesh 兜底链的前面。纯步行不连通时,连续滑索仍可能分别接上起终两侧的可走面。桥接出来的是完整规划结果,优先级高于二维盲走和作者提示回退,整条连续链也不会被提示点切碎。

滑索全局规划由 #5197「供能高地路线优化与滑索全局规划」引入:开启滑索后,作者路线里的可选点降为提示,每个分段的起终点之间只规划一次,步行和滑索在同一个比较里竞争。

规划实测

#6177#6104#5902(旧版)
模式桥接桥接有基线
步行基线——888.18
选中代价215.37287.01365.10
架子数7611

#5902 的滑索方案代价约为步行基线的 41%。#6104 那次的展开总耗时 7329 ms,其中滑索候选额外跑了 7 条 NavMesh 规划,合计 4780 ms(单条 349 到 1150 ms),没有触到 12 条的预算;同一趟的下一段只有 5.8 像素,在规划早退的收益门槛处直接返回。

接近上索点

规划结果交给执行侧后,链首 ZIPLINE 点前面是一段普通走廊。离上索点越近,导航的行为越接近采集与交互:

距离行为来源
≤ 5.0(7.5 退出)切走路#5337「提前切换步行接近滑索」
≤ 3.0上索预筛开始放行后台模板匹配 MapNavigatorZiplineMountScan,阈值 0.75
≤ 1.5到点只对还没上架的 ZIPLINE 点,硬性无进展 ≤ 6000 ms 时
≤ 1.0按空过一次之后的到点kZiplineRestandBandWu

步行带比普通提示点宽:带子太窄的话,切到走路后的第一帧就可能冒出上索提示,人立刻停下。判定圈按采集那一套收紧到 1.5:上索认的是跟采集一样的交互提示,面板给的是离身位最近的那台设备,按常规判定圈停下时人还够不着架子。收紧只对链首那一次:链中间的点是人从索上落到台面上的,用不着再认提示,台面上又迈不动步,收紧了就是站在原地等超时。硬性无进展超过 6000 ms 时放回常规值,避免多出一种卡死。

按空过一次之后判定圈再收到 1.0:按空一次说明人站得还不够近,收紧之后接着往前走(有备用站位的已经改瞄备用站位),原地重按只会得到同一个结果。实在收不拢,由上索点自己的重规划预算收场,用完就弃索走路。

到点或预筛命中后进入 StartZiplineHop:

  1. 航点没有这一跳的计划时放弃整条链,原因 zipline_hop_missing。这一跳的计划是规划器算好写进航点的,手写路线写不出来;缺了就没有可对准的方向,直接当这根索不存在。
  2. 人站在另一根架子上时先下来。留在上面瞄,发射的是那根架子上的索,滑到哪都不算数,还会往账本里给本来没问题的索记一笔失败。
  3. 没在架上时跑子任务 MapNavigatorZiplineMountStart:等提示区域稳定 300 ms,用模板匹配找「登上滑索架」那一行的交互图标(MapNavigator/Zipline/InteractButton.png,阈值 0.9),找到后再匹配一次,按住 Alt 点击图标(AutoAltClickAction)。第一次没找到时等 300 ms 复核一次。这枚图标只挂在「登上滑索架」这一行,各语言客户端里都一样,f494516「上索改为匹配并点击登上滑索架图标」 把这一步从识别文字改成了匹配图标。
  4. 子任务认不出:这一次是预筛叫停的,说明人还没走到,预筛命中的图标属于别的设备,当没发生,接着走;是到点触发的,换下一个站位。
  5. 认出并按下后,交给滑索子状态机的 Begin,phase 切到 WaitZipline(见 phase)。航点要等落地再推进:起滑那一刻人还在上索点,就算这条链已经是路线的尾巴,也不能在这里收工。

换站位

站位有两种用完的方式。第一种是到点后认不出提示。这时可能是人差一点没走到跟前,可能是站位猜的方向上没有设备模型,也可能是架子边上的供电桩占着面板,都得靠挪身位解决:交回导航换下一个站位,预筛一路开着,提示先冒出来就先按。

第二种是顶在设备上走不动。还有下一个站位、离当前站位 ≤ 5.0、硬停滞 ≥ 2000 ms 时换站位,抢在恢复阶梯(3500 ms 起,见阶梯)之前,static_assert(kZiplineMountSpotStallMs < kObstacleRecoveryMinTriggerMs) 锁住先后。这时到点判定过不去、提示也没出来,等下去先来的是恢复阶梯,它跳一下、挪一下设备,就把这一轮的站位拖走了。提示在时先按提示(第一种),所以这条排在它后面。距离限在站位跟前 5.0 以内:进场路上被地形卡住时,换站位解决不了问题,还会把硬时钟一直按回零,让恢复阶梯永远轮不到。

AdvanceMountSpot 把当前 ZIPLINE 点改瞄下一个站位,置按空过标记,并把硬进度时钟归零;时钟不归零的话,下一刻又满足换站位条件,剩下的站位会被一口气烧光。站位全部用完时在账本里记一笔 Unboardable,再放弃这条链,原因 zipline_prompt_missing。

上索点够不着

接近途中触发动态重规划时,同一个上索点只允许重规划 1 次(kZiplineApproachReplanBudget),第 2 次请求就放弃这条链改走路。预算要小到能赶在 watchdog 之前让出路来:偏离 watchdog 6 s 重规划一次、12 s 掐掉整趟导航。滑索省下的那点路抵不上一次导航失败,判错了也只损失一段捷径。

Watchdog 见 watchdog。另外,ZIPLINE 点的硬时钟超过 30 s 时,恢复阶梯第一步就是丢链改走路,见阶梯。

上架判定

上索键按下去之后,人有没有站上架子,没有直接的读数。判定读两个画面信号:

信号节点含义
右上角大世界按钮MapNavigatorZiplineOnGround,引用公共节点 InWorld认得出 = 在地面
底部操作引导MapNavigatorZiplineOnTowerHint,OCR,ROI 200,668,900,44读到底部操作引导的任一片段 = 在架上

两个都没命中就是 Unclear。右上角那两个大世界按钮在架上会收起,所以认得出按钮就说明人还在地面。架上的操作引导有三条:「向目标移动」「R指定连续移动连接点」「离开滑索架」,三条一起出现,站在地上时这条带子没有字,所以读到任一片段就认在架上。OCR 会把相邻提示连成一串,也会掉字,所以 expected 按片段写(移动、连接点、离开、滑索、索架、滑架、开滑)。白字压在亮地面上时,检测器常常不给这组字出框,所以并联了一条跳过检测、整框直接识别的读法。

ADB 的操作引导文字不同(「左右滑动屏幕选择前进目标」「向目标移动」),由 resource_adb 覆盖成另一组片段和 ROI。

两个信号的处理不对称。底部提示的逐帧可读性随机位起落,所以架上一侧读到一帧即认;地面一侧要连续 2 帧,避免在上架过程中重按。从按键到按钮收起之间,地面读数不采信,settle 为这段延迟预留时间。判定窗口要盖住提示从按键到第一帧读得出的滞后,余量不够的话,已经上架的人会被误判,按下索键弄下架子。

常量值
地面读数不采信的 settle600 ms
判定窗口4000 ms
地面态连续帧数2
重按预算2
判为在走动的帧间位移≥ 3.0

TickMounting 每刻的判定顺序:

  1. 相邻两帧位移 ≥ 3.0 就当在地面走动,直接判 OnGround,省掉两次识别。
  2. 读到操作引导:确认上架,进入 OnTower。
  3. 一直在走动:窗口内继续等,不重按。带着惯性发出的交互正是这一跳落空的原因,这时重按同样不会生效。窗口耗满记 NotMounted,交回导航换站位:一路都在动说明人没被架子锁住,而这个 phase 外头没有 watchdog,只能自己收场。
  4. settle 之后连续 2 帧地面态:重按。
  5. 窗口内其余情况继续等。
  6. 窗口耗满、读不出、也没在动:当作人已经上架、只是提示漏读,记 NotMounted,按下索键回到地面,再请求重规划。
  7. 窗口耗满、在地面:重按。

第 6 条先下索再重规划,是为了回到一个判得清的地面态,避免在位置没定的状态下继续瞄准和发射。

位移只用来否决:角色被架子锁住时动不了,能动就说明在地面;零位移两种情况都可能,单靠它判不了。

判定没上架之后,第一级处置是重按上索键。重按前有两道防护,避免对已经上架的角色重按:地面态必须由 InWorld 命中给出,而且要再认出一次架子的交互提示才发按键。重按预算用完仍没上架,才交回导航调整站位后重来。

这套判定经过两次按实机日志的调整:#5655「按实机日志核准起滑确认时间与上索确认窗口」,#5665「上索确认放宽到架上任一提示片段读到一帧即认」。

滑索子状态机

一跳的执行交给 ZiplineRideMachine,由 #5585「滑索子状态机与逐跳账本」从原来的一串条件分支改写而来。导航每刻调一次 TickZiplineRide:松前进键,调状态机,没有出口事件就睡 150 ms。实际一刻是 150 ms 加截图定位的耗时,见每刻耗时。

状态机由两样东西驱动:每刻一次的定位,和同步的输入动作。定位时把这一跳可能落到的架子都交给定位器当搜索先验。落地那一帧是冷启动:挂对了落在第一个先验上,挂错了落在其中一个上,空响没滑走就还在上索点,具体落在哪由匹配分决定。输入动作同步发出、发完即回,转没转到位交给后面的观测判断。

mermaid
flowchart LR
    subgraph G7a [上架与瞄准]
        direction TB
        B(["Begin"]) -->|"刚按上索键"| M["Mounting"]
        B -->|"已在架上"| T["OnTower<br/>俯仰复位"]
        M -->|"操作引导 1 帧"| T
        M -->|"一直在动 / 重按用完"| NR(["NeedsReposition"])
        M -->|"窗口满且读不出"| D1(["转 Dismounting"])
        T --> A["Aiming / ReturnAiming"]
        A -->|"残差 ≤ 6°<br/>调俯仰、左键<br/>有偏置先外侧横扫"| F1(["转 Fired"])
        A -->|"超时 6 s"| D1
    end
    subgraph G7b [出发、落地与收尾]
        direction TB
        F["Fired"] -->|"连丢 3 帧"| R["Riding"]
        F -->|"位移 ≥ 3"| L["Landed"]
        R -->|"定位恢复"| L
        F -->|"1.5 s 没动"| C
        L -->|"4 帧停稳<br/>或 8 s"| C{"Classified"}
        C -->|"换档 / 收偏置<br/>重试 / 回程"| T2(["回 OnTower"])
        C -->|"Unknown 冷启动"| L
        C -->|"链未完到达<br/>没滑出、重试用完<br/>回程失败"| H(["Handoff"])
        C -->|"链尾到达、Lost"| D["Dismounting"]
        D -->|"3 帧稳定<br/>且无操作引导"| H
        D -->|"8 s 下不来"| X(["Failed"])
    end
    G7a --> G7b

跨栏的转移画成「转 Dismounting」「转 Fired」「回 OnTower」这类终点节点。图里省掉了 Mounting 的重按自环,以及 Fired、Riding 各自 30 s 的超时(都直接进 Classified);有偏置时 Aiming 先对准横扫起点、再转回停点,两段画成了一条边。

阶段进入出口时限
MountingBegin 时不在架上;重按见上架判定窗口 4000 ms
OnTower上架确认;Begin 时已在架上;分类后换档、收偏置、重试或回程俯仰复位,进 Aiming 或 ReturnAiming;复位失败 → 下索、丢链—
Aiming / ReturnAimingOnTower残差 ≤ 6° → 调俯仰、左键 → Fired;有偏置时先在停点外侧 15° 处调俯仰,再转回停点左键;超时 → 下索、丢链6000 ms,横扫后重新计时
Fired左键定位离起滑点 ≥ 3 → Landed;连丢 3 帧 → Riding;有定位 1500 ms 没动 → 分类;一直丢、超时 → 分类前 400 ms 不判;30 s
Riding连丢定位定位回来 → Landed;超时 → 分类30 s
Landed定位回来;Unknown 回环连续 4 帧位移 < 1.5 → 分类;超时 → 分类8000 ms
Classified分类见落地分类—
Dismounting按下索键连续 3 帧在 3.0 以内且读不到操作引导 → Handoff;4 s 再按一次;8 s → Failed2000 ms × 2 / × 4
Handoff / Failed—交出出口事件,清空这一跳,只留账本和人还站在哪根架子上—

几个阶段的含义:Fired 是已经按了左键,等定位断掉(滑出去了)或位置离开起点;Riding 是滑行中小地图整个隐藏、定位不到,只能等;ReturnAiming 是滑错之后从当前架子瞄回上索架。

Begin 区分两种起点:链中续跳和滑回原架时角色已在架上,直接进入瞄准;其余情况是调用方刚按了上索键,要先确认上架。同一跳中途下来、换站位后再回来,算同一跳续写,记录接着写;换了跳就把上一跳按 Dismounted 结账。

瞄准与俯仰

瞄准分偏航和俯仰。偏航有朝向读数,闭环;俯仰读不回来,开环。

偏航

TickAiming 每刻:

  1. 超过 6000 ms 判失败。

  2. 上一批转向发出后等 300 ms。

  3. 连续两帧朝向相差 ≤ 15° 才采信。

  4. 记当前位置指向目标架子的方位为 ,瞄准偏置为 (回程为 0)。目标朝向

    第一种情况往偏置同侧多加 15°,对准的是横扫起点,见下文的瞄准偏置。

  5. 残差 > 6° 时只发一个后端批次,回到第 2 步,剩下的角度下一刻按新的朝向读数重算。

  6. 残差 ≤ 6°:这一轮还没调过俯仰时先调俯仰。有横扫时这一刻到此为止,6 s 计时和朝向采信都从头开始,回到第 3 步对准停点,残差 ≤ 6° 后直接左键;没有横扫时调完俯仰就左键。

6° 的容差比走路紧得多。按下左键人就滑走了,半空里没有走廊跟随能把方向修回来,瞄准精度只能在按左键之前保证;走路能容忍 40° 的误差,是因为后面有跟随层纠正。

6 s 超时同时覆盖上架后的稳定等待和全部水平修正;有横扫时,转回停点那一段另起 6 s。每次只发一个批次并等真实反馈,大角度转向因此不会在上索动画还没结束时一次排进多条输入。

俯仰

俯仰没有读数,每次进入 OnTower 先把镜头拉到天空方向的硬限位,从这个已知位置开环往下调:

是目标俯仰(仰为正), 是复位之后还要转的俯仰角。

游戏的俯仰范围是仰 90°、俯 60°,多发的 30° 用来盖住灵敏度取整和游戏内的输入损耗,镜头撞到限位后就不再转。复位后的镜头位置记作最大仰角。桌面端的复位走 pipeline 节点 MapNavigatorZiplinePitchReset(相对鼠标移动,dy 由代码按分辨率覆盖);单独用一个节点发相对鼠标移动,是因为 Swipe 会带一次左键按下和抬起,而在架上左键就是起滑。

触屏后端直接发视角拖动。俯仰每度单位数,桌面是 ( 时 8.92),ADB 见触屏后端,两端都乘灵敏度系数。

目标俯仰按尝试次数取。俯仰读不回来;下降索按规划仰角朝下瞄已经验证能发出去,上升索的正仰角还没核实过。所以第一次按规划仰角瞄,第二次反过来,第三次干脆不动俯仰,三次里总有一次落在对的那一侧:

cpp
double PitchTargetForAttempt(double elevation_deg, int attempt)
{
    if (std::abs(elevation_deg) < kZiplinePitchDeadbandDeg) {
        return 0.0;
    }
    const double aim = std::clamp(elevation_deg, -kZiplinePitchMaximumDepressionDeg, kZiplinePitchMaximumElevationDeg);
    if (attempt == 0) {
        return aim;
    }
    if (attempt == 1) {
        return -aim;
    }
    return 0.0;
}

死区 8°,俯角上限 60°,仰角上限 90°。规划仰角在 ±8° 以内时三档都是水平。实测的链多数落在这个死区里:#6104 那 5 跳的规划仰角是 3.61°、−7.01°、−5.90°、0.83°、−4.63°。#6177 那一跳是 −11.91°,第一档的目标俯仰就是 −11.91°。 时先发俯仰,再等 300 ms;没有横扫时接着左键,有横扫时先转回停点再左键。

回程瞄准的俯仰种子用两端世界坐标现算;途中发现的架子没有世界坐标,算不出仰角,就平着按。

瞄准偏置

同一根架子上常挂着几根方向相近的索。准星停在规划方向上时,起滑可能挂到旁边那根,人就滑到了别的架子。瞄准偏置让停点往远离邻居的一侧偏一点。35d1f74「滑索锁错后侧扫重瞄」 把它从滑错之后固定的 ±3° 改成按邻居计算、逐次调整。

邻居有三种来源:同架其他索的落点、这一跳途中发现的架子、这一跳每次发射实际落到的架子;和这一跳两端是同一根架子的不算。每个邻居换算成它相对规划方向的偏角。

时机偏置
Begin 新的一跳取偏角绝对值最小的邻居,它在 12° 以内时往反方向偏 3°;没有邻居、偏角正好为 0 或 ≥ 12° 时为 0
滑错后滑回上索架,还有重试错索和当前偏置在同一侧,说明偏过了头:方向不变,幅度减半。错索在另一侧或原来没偏:往错索的反方向,幅度加 3°
去程没滑出(AtOrigin)偏置减半后仍 ≥ 1.5° 时减半重瞄,这一次不换俯仰档;减半后不到 1.5° 才换档
回程0

往哪一侧偏,幅度都不超过那一侧最近邻居偏角的一半,停点不会越过两根索之间的中线。12° 的来由和以前一样:两根索方向差 12° 以上时,6° 的容差本身就分得开。没滑出时先收偏置:偏置可能把准星推出了目标索能挂上的范围,收回一半再按,收不动了才去换俯仰档。

cpp
double ZiplineRideMachine::EscalatedAimBiasDeg(double wrong_bearing_deg) const
{
    const double offset = NaviMath::NormalizeAngle(wrong_bearing_deg - BearingDeg(plan_.mount, plan_.landing));
    if (offset == 0.0) {
        return aim_bias_deg_;
    }
    const bool overshot = offset * aim_bias_deg_ > 0.0;
    const double away = overshot ? std::copysign(1.0, aim_bias_deg_) : std::copysign(1.0, -offset);
    const double magnitude = overshot ? std::abs(aim_bias_deg_) / 2.0 : std::abs(aim_bias_deg_) + kZiplineAimBiasStepDeg;
    return away * std::min(magnitude, AwaySideCapDeg(NeighborOffsetsDeg(), away));
}

横扫。有偏置时,第一段转向的目标是停点再往外 15° 的地方。对准那里之后调俯仰,重新计 6 s,再转回停点按左键。转回时准星从远离邻居的那一侧向目标索靠拢,先扫到的是目标索,邻居还在更远的另一侧。第一段转向停在离横扫起点 6° 以内,转回时残差至少 ,超过 6° 的容差,所以转回这一步一定会发出,扫的方向也一定是从外侧往回。代码用 static_assert(kZiplineAimSweepLeadDeg > 2.0 * kZiplineAimToleranceDeg) 固定这个关系。

起滑与滑行

左键之后进入 Fired。起滑有两种结局:索没通电或两端根本没挂索时,起滑是空响,人还站在架子上,坐标一直有效、位移为零;真滑起来,小地图随即读不回坐标。两种结局分得开,确认时间只要盖住从丢定位到判出滑行的那几刻。

TickFired 前 400 ms 不判,免得把起步前的几帧当成没滑起来。之后:

  • 有定位且离起滑点 ≥ 3:已经滑出去又落地(短索),进 Landed。
  • 有定位、没动,超过 1500 ms:空响,直接分类。
  • 连续 3 帧没定位:判为滑行中。滑行中小地图整个隐藏,站在架子上没滑走时跟踪不会断。进入 Riding 前清掉跟踪状态:落地帧离起点一整跨,起点的旧位置留在跟踪器里,会把真落点当成远跳拒掉,清掉之后落地走冷启动。

Fired 期间强制全局搜索

#6023「滑索移动途中错误定位」记录了一种误判:起滑后小地图隐藏,定位器的跟踪仍停在上索点附近,隐藏帧被跟出一个贴着上索点的假位置(分数 0.4297,位移 21.57),状态机据此判成落到了别的架子上。#6051「滑索上拒绝使用运动连续性仲裁得出的定位」之后,Fired 阶段每帧都强制全局搜索,避开跟踪器在隐藏帧上跟出的假位置。

落地先验

落地那一帧是冷启动的全局搜索。#5559「滑索落地定位以规划落点为先验参与比分」把这一跳所有已知节点交给定位器当先验,每个节点开半径 24 像素的小窗,与 YOLO 粗搜的窗口比分。半径要装得下落点的散布,又要小到错误的先验只能撞出弱峰;模板的半宽由定位器自己补边,不算在这 24 像素里。

已知节点包括这一跳两端、同架其他索的落点、途中发现的架子、账本里所有跳的两端,起点另外按起滑时的实际定位放一份:架子台面比一个节点像素大,站偏一点也得认出是原地。

停稳

定位回来后进 Landed,连续 4 帧相邻位移 < 1.5 才算停稳:滑行中位置每刻都在变,连着 4 刻几乎不动,说明这趟已经结束。8 s 内稳不下来也进分类,按定位对不上处理。停稳的要求同时过滤掉零星的假定位:#6177 里有一次滑行 748 ms 后报出一帧 (998.49, 1574.54),离真实落点两百多像素,没能连成 4 帧稳定,分类用的是之后停稳的那一帧。

滑索实测

issue-6104 一条 5 跳滑索链:地图上的路线和跳号,以及每跳按上架、瞄准、起滑、滑行、停稳拆开的用时

#6104 日志 06:41 那一趟(2026-09-30)。左图是地图:红线是步行,黄线是 5 跳滑索,圆圈里是跳号,与下表对应。右图把每跳按阶段拆开,各跳从自己的开始时刻对齐:

  • 灰色是上架和俯仰复位,只有第 1 跳有;后面几跳人已经在架上,沿用记录的俯仰。
  • 黄色是瞄准,▼ 是一批偏航。第 1 跳要转 −170°,每批最多 30°,分 6 批;后面几跳转得少,1 到 2 批。
  • 红色是最后一批转完到小地图隐藏:确认残差 ≤ 6°,调俯仰,按左键起滑。
  • 蓝色是滑行,小地图隐藏,读不出位置。这段大体随索长变长:第 5 跳那根水平约 111 m,滑了 8.9 s。
  • 绿色是定位回来到停稳。
跳开始(s)落地(s)用时(s)规划仰角落点误差(像素)
150.9664.9314.0(含上架)3.61°1.0
264.9374.039.1−7.01°0.4
374.0384.7510.7−5.90°1.7
484.7596.1911.40.83°0.1
596.19111.1515.0(含下索)−4.63°1.8

时间相对这一趟导航开始。5 跳共 60.2 s,每跳只发射一次,落点误差都在 6 像素的落点圈以内。2 到 4 跳都落在下一根架子上,人还在架上,直接接着瞄下一跳;第 5 跳是链尾,落地后下索。

旧版本的 #5902 有一条 10 跳的链,记录更细,第 1 跳的分段:

段用时说明
找站位约 31 s到点认不出提示,在几个站位之间换
上架2.3 s上架确认用了 2094 ms
俯仰复位0.4 s含 pipeline 节点的 post_delay 200 ms
瞄准4.4 s6 批偏航,残差 160.4° → 131.6 → 103.8 → 71.9 → 41.9 → 12.9,每批最多 30°
起滑判定0.9 s从按下左键到确认在滑行
滑行2.4 s从确认在滑行到重新拿到定位,2377 ms
停稳0.8 s4 帧

整条链 102.6 s,10 跳都一次成功。fired 到 riding 稳定在 868 到 964 ms,即连丢 3 帧定位的时间;滑行 2.4 到 6.5 s。按滑行距离和用时推算,滑速约 13.5 m/s(含识别滞后)。

落地分类

停稳之后,ClassifyLanding 把最后一帧定位归到五类之一:

类别条件含义
AtTarget6 像素内最近的已知节点是这次发射的目标到了。首发和重试的目标是规划落点,回程的目标是上索架
AtOrigin最近节点是起点还在起点,没发出去
AtOther最近节点是别的已知架子滑错索了
AtStrayTower6 像素内没有节点,但滑行发生过停稳在一根没登记的架子上
Unknown没有定位,或滑行没发生过又不在起点定位还没对上

落点圈 6 像素(kZiplineLandingBandWu)。判断两个节点是不是同一根架子时,两端都有世界坐标就要求同区域、三维距离 < 2 m,同一区域里两根架子挨不到这么近;否则按像素距离 < 6。AtStrayTower 时用当前定位造一个新节点,记进途中发现的架子。

分类结果查决策表。到了就交回;滑错了先滑回来,调整偏置后按预算重试;没发出去先收偏置,再换一档俯仰,都用完记滑不动;回程也失败时另记回程那根索。这几种失败都让人留在当时站着的架子上等重规划。定位对不上给一次冷启动的机会,超时就丢。

分类去程回程
AtTarget记 Completed。链未完:留在架上交回导航;链尾:下索后交回滑回了上索架:记下错索方位。重试 < 2 次时俯仰档归零、按错索方位调整偏置,回 OnTower 重瞄;用完记 WrongRope,留在架上请求重规划
AtOther / AtStrayTower以落到的架子为起点进入回程,回 OnTower 瞄回上索架回程又落到别处:记 WrongRope,另记一条回程索失败,留在新落到的架子上请求重规划
AtOrigin偏置减半后仍 ≥ 1.5° 时减半重瞄;否则俯仰档 < 3 时换下一档;三档用完记 NoLaunch,留在架上请求重规划俯仰档 < 3 时换下一档;三档用完:记 WrongRope,另记一条回程索失败(NoLaunch),留在回程起点那根架子上请求重规划
Unknown第一次进入时清跟踪、设 8 s 截止,回 Landed 重等停稳;过了截止记 Lost,下索,丢链同左

分类后的失败都让人留在当时站着的架子上请求重规划:去程滑不动或重试用完时是上索架,回程又落到别处时是新落到的那根,回程三档都没滑出时是回程起点那根。先下来再重规划要白付一次上索,重规划本身会看新路线用不用得上脚下这根架子,用不上时才下来。#6138「滑索失败后留在架上重规划」 把回程失败也归到这一类,此前回程失败要先下索、丢链。

滑错索又滑回来之后,同一跳最多再试 2 次(kZiplineHopRetryBudget),用完就站在架子上等换路。

滑错索的一次完整过程

issue-6177 一次滑错索的逐步回放:三次去程都落到没登记的架子 C,三次回程回到 A,重试用完后删掉 A–B 这条边重规划,人走到 B 再上索

#6177 日志,一条链的第 2 跳,一步一帧。左图是地图:A 是上索架,B 是规划落点,C 是实际落到的架子,黄圈是人所在的位置;白色虚线是规划的这一跳,红线是去程,蓝线是回程,绿线是步行。右边是这一步发生了什么,以及重试次数、账本和图里 A–B 这条边的变化。底部时间轴从这一跳开始计时,框出当前这一步:

  1. 规划从 A (1200.75, 1686.75) 滑到 B (1203.75, 1647.75),规划仰角 −11.91°,在死区之外。
  2. 去程 1 落到 C (1211.46, 1634.58),离 B 约 15.3 像素,超出落点圈;C 不在已知节点里,分类为 AtStrayTower,记成新架子。C 相对 A 的方位和规划方向差约 7°。
  3. 回程 1 回到 A,分类 AtTarget(回程的目标是上索架),记下错索方位,重试 1。
  4. 去程 2:错索与规划差 7° < 12°,瞄准偏置 −3°。仍落到 C,这次 C 已是已知节点,分类 AtOther。滑行中出现一帧假定位 (998.49, 1574.54),被停稳要求吸收。
  5. 回程 2,重试 2。
  6. 去程 3 同样偏 −3°,还是落到 C。
  7. 回程 3 回到 A,重试用完,记 WrongRope,留在架上请求重规划。账本这一跳 6 次发射、3 个错索方位。
  8. 第 61.9 s 放弃这一跳,人留在架上,从当前位置恢复,丢掉剩余 5 个航点,388 ms 内拿到 3 帧稳定定位。重规划时账本剔除 A→B 这一跳(账本 2 条,删 1 条边),新路线用 5 根架子,首跳从 B 起滑。
  9. 新路线用不上脚下的 A,人下架走到 B,上索预筛一路开着。
  10. 第 77.8 s 到达 B,上索确认用了 1905 ms,从 B 滑向下一根。

这次运行早于 35d1f74「滑索锁错后侧扫重瞄」,偏置按旧规则:滑错后往错索反方向偏 3°,每次重试都一样,也没有横扫。

六趟滑行连瞄准平均约 10.3 s,加上恢复,这一跳在 A 上耗了约一分钟。C 第一次落地时不在已知节点里,可能的原因有几种:这根架子没有记录、被供电判定挡掉,或者 A→C 只在 all 图里,而这次选的是 certain 链,同架邻居按所选那张图列出。

下索

链尾到达、瞄准失败、Lost 都要先下索再交回导航。下索键在桌面是右键按住 80 ms。站在架子上时,移动指令会被架子的选点状态吃掉,所以走路之前必须先下来。

TickDismounting 的判据由 #6039「下索后读到架上操作引导就不交回导航」收紧:定位稳定之外,还要读不到架上的操作引导才算下来了。下索键偶尔不生效,人留在架上时定位同样稳定,只看定位分不出来。

  • 连续 3 帧定位在 3.0 以内,且读不到操作引导:交回导航。
  • 4 s 还没下来且只按过一次:再按一次。
  • 8 s 还没下来:Failed,丢链,原因 zipline/dismount/stuck。

导航侧主动叫下索(换跳、恢复时)走 LeaveZiplineTower,按完再等 400 ms,刚下来的那几百毫秒里移动指令还会被架子吃掉。

逐跳账本

每一跳结束时写一条记录,含计划、每次发射(时刻、朝向、俯仰档、偏置、分类、落点)、错索方位和结局。结局 7 种:

结局含义删边
Completed——
WrongRope滑错过,滑回后重试仍没到;回程失败时也记这一条是
NoLaunch在架上逐档俯仰都发射过,索没起滑:索被挡或没通电是
NotMounted按了上索键没上架:这根索还没试过,架子本身也不判死否
Unboardable每个站位都走到过,一次提示都没出来:这根架子上不去,这一趟不再拿它当上索点取消上索资格
Lost落地定位一直对不上是
Dismounted主动下索,交给恢复否

记账的几个时机:

  • 分类、瞄准失败、上架失败时当场记。
  • Begin 一个新跳时,上一跳还开着就按 Dismounted 结账。
  • 回程失败时(回程又落到别处,或回程三档都没滑出)另记一条回程索:上索点是回程起点那根架子,落点是原上索架,结局按失败方式记 WrongRope 或 NoLaunch。规划器只按每条记录的两端删边,回程那根索不单独记的话,重规划会把人再派上去。
  • 导航侧叫下索时,发射过的记 NoLaunch,没发射过的记 Dismounted。这一跳发射过却被导航侧叫下来,说明发过但没滑成,按滑不动记,重规划不再选它。

账本的寿命是一趟导航,ResetNavigation 时清空。它有两个读者:规划器据此排掉滑不动和滑错的索,主状态机据此决定要不要整段退回走路。

一趟里索失败累计 ≥ 3 次(kZiplineAbandonWalkFallbackCount,按记录条数算,回程失败一次计 2),重展开时整段关掉滑索。没到这个数时,重展开照常让滑索参与,只把执行侧判死过的跳从候选里拿掉,一根索滑不动不该连累整段路的所有捷径;失败太多说明这一带的标定或定位整体不可靠,才整段退回纯走路。

出口事件

状态机交回导航的出口事件有 4 种,TickZiplineRide 分别处理:

事件导航侧处理
HopCompletedFinishHop:推进航点;下一跳从脚下这根架子出发就直接开跳,中间夹着的走廊点一并跳过;否则下索走路
ChainAbandonedAbandonZipline:下索,丢链,恢复
ReplanRequested丢链,恢复;still_on_tower 为真时留在架上,并记下脚下是哪根架子
NeedsRepositionAdvanceMountSpot:换下一个站位

FinishHop 直接记为已起步(见起步确认)。滑行本身就是一次实打实的位移,落点也经过连续几帧稳定定位确认,没有必要再等一次起步确认;链上的下一跳上索点就在脚下,拦住就等于要人先走开再走回来。

所有异常出口都进 DropChainAndRecover。滑索是捷径,走不成就丢掉剩余的链,重新接回后续路线;要是人挂在索上或卡在架子边上就直接结束导航,角色只会原地不动到超时。

  1. 停车。不留在架上时先下索。
  2. 跳过剩余的走廊点,再丢掉紧跟其后的所有 ZIPLINE 航点。整条链一起丢:留下任何一跳,重规划都会把人送回索边再试一次,而刚失败的正是这条索。走廊点后面先碰到别的语义点时一个都不丢,最后一跳出事时就是这样,剩下的路本来就是走路。
  3. 留在架上时用 NoteStandingTower 记下脚下这根架子,见落地后的起点层。
  4. 重置跟踪和进度,开始滑索恢复,并请求动态重规划。

滑索恢复

HandleZiplineRecoveryReplan 在恢复期间每刻运行:

  1. 停车。超过 6000 ms 以 zipline_recovery_localization_timeout 失败导航。
  2. 第一帧强制全局定位。
  3. 连续 3 帧定位彼此在 3.0 以内才算稳定,拿不到定位时隔 120 ms 再试。站在架上时脚下是架子,不要求贴回 NavMesh;不在架上时,贴不回可走面的定位也接受。这种定位说明落点不在路面上(落到未登记的架子、崖边没铺面的地方),坐标本身仍然有效,重展开时规划会吸附回最近的可走面。
  4. 用剩余的作者路线从当前位置重新展开,带上账本。原来的剩余展开是按从链尾落点出发算的,人没到那个点时它和当前位置无关:可接入点可能在另一侧,沿途会撞上原本要用索越过的障碍。从当前位置重新展开,判死的那一跳已记进账本,规划会绕开它另选链路。人还在架上或刚落到架子附近时,起点钉在那根架子的高度上,见落地后的起点层。展开成功且人还在架上时,新路线开头若是几个走向上索站位的走廊点、后面紧跟从脚下这根架子起滑的一跳,就跳过这些走廊点留在架上;否则先下索。重展开从当前位置出发,会铺一段走到上索站位的路,人已经站在这根架子上,照着走就得先下来再上去。
  5. 人在架上而重展开失败:先下索,恢复从头开始,6 s 计时和稳定计数都清零。下来之后这一轮按不在架上的规则走,重展开再失败还有可达锚点扫描。
  6. 重展开失败,且人不在架上、位置在可走面上时,退回旧的展开,在里面做可达锚点扫描(见可达锚点),旧展开至少是一条经过规划的路径。扫描的串行 A* 预算是 8 次:每次不可达都要跑满一次秒级的搜索,曾经出现过 34 次连败、额外卡了约 70 s;预算用完就放弃这轮扫描,交给下一级回退。
  7. 都失败:以 zipline_recovery_route_unavailable 失败导航。
  8. 成功后人还在架上,说明新路线的首跳就从脚下起滑,直接开跳,省掉走过去按上索这一步。

落地后的起点层

滑索架的台面和台下的地面在底图上重叠。人刚落到架子上、或留在架上等换路时,起点吸附只看楼层高度,可能吸到台下那一层,规划出的路线从台下起步。#6205「滑索落地重规划起点层与同步规划停步计时」 之后,运行期规划在这种情况下把架子高度作为起点声明面交给规划器,起点按这一层吸附。高度由 NavigationSession::LandedTowerDeckY 给出:

  • 架子有两个来源。留在架上等重规划时,DropChainAndRecover 用 NoteStandingTower 记下脚下这根:丢链后上一个航点是链里最后一跳,它的落点是链尾那根架子,人可能停在链上任何一根架子上。没有记录时,上一个航点是 ZIPLINE 跳就取它的落点架子。
  • 人离这根架子 6 像素及以上(落点圈外)、架子没有世界坐标,或者已经走到台沿下落那一段时,不给高度,起点照常吸附。
  • 用到它的规划有四处:NavRun 的走廊规划、可达锚点扫描、动态覆盖、滑索恢复的重展开。重展开时只有第一条腿带上它;第一条腿是滑索链时,交给上索前那段步行(approach)。
  • NavRun 每刻把位置投影到走廊上,投影所在的边到了台沿下落那一段,就标记为已离开台面;航点推进或跳过时,记下的架子和这个标记一起清掉。

规划器里起点声明面怎么吸附、怎么选 span,见 NavMesh 一文的起点所在的面。

触屏后端

桌面端先支持滑索,起滑是左键,下索是右键。ADB 后端把左键点击映射成攻击按钮、右键映射成冲刺按钮,沿用默认实现的话,起滑会打出一刀,下索会变成冲刺。#6084「ADB 后端后端开放滑索」给 ADB 单独覆盖了这两个动作,点屏幕上的滑索按钮:

按钮坐标(1280×720)触点编号
冲刺(1166, 620)2
跳(1166, 475)3
攻击(1030, 551)4
交互(1080, 390)5
起滑(1099, 526)6
下索(1166, 411)7

每个按钮一个固定触点编号;视角拖动用 1 号,虚拟摇杆用 8 号,几路输入可以同时按住。

ADB 的俯仰每度单位数:

公式来自 #6084,偏航每度 3.0 见ADB 视角滑动。ADB 的操作引导片段、上索预筛和上索识别的 ROI 也由 resource_adb 覆盖(两者都是 700,330,352,312)。

滑索演进

PR日期内容
#5079「支持三维可达判定的滑索寻路与使用」08-20带三维可达判定的滑索寻路与执行
#5197「供能高地路线优化与滑索全局规划」08-24步行与滑索在同一个比较里竞争
#5204「修复滑索镜头、定位与供电锚点判定」08-25俯仰复位与范围;中继器角格判定
#5252「兼容滑索架角格锚点距离」08-264 个可能塔心,配对下界
#5337「提前切换步行接近滑索」08-30滑索用更宽的步行带
#5559「滑索落地定位以规划落点为先验参与比分」09-06落地定位以规划落点为先验
#5340「按账号隔离滑索数据」09-08账号标识
#5585「滑索子状态机与逐跳账本」09-08滑索子状态机、逐跳账本
#5610「滑索导入支持 linux」09-09Linux 导入
#5618「滑索规划剔除拦路与地形阻挡的索」09-09拦路架、两档候选图、两趟评估
#5649「滑索寻路沿用旧版本无账号的记录」09-11认领旧记录
#5655「按实机日志核准起滑确认时间与上索确认窗口」09-11起滑确认、上架窗口
#5665「上索确认放宽到架上任一提示片段读到一帧即认」09-11操作引导片段一帧即认
#5972「滑索按碰撞遮挡体判断索是否被挡」09-24遮挡体;跨类型、跨区配对
#6014「滑索绳端按塔落地高度算,拦路架按三维垂距量」09-26绳端 5.72 m;三维垂距
#6039「下索后读到架上操作引导就不交回导航」09-27下索判据
#6051「滑索上拒绝使用运动连续性仲裁得出的定位」09-28Fired 强制全局搜索
#6084「ADB 后端后端开放滑索」09-29触屏按钮、俯仰换算
#6088「macOS 用 WKWebView 登录窗口导入滑索」09-29macOS 导入
#6138「滑索失败后留在架上重规划」10-01回程失败留在架上并记回程索;续跳跳过接近段;架上重展开失败先下来再试
#6146「滑索瞄准去掉单批转向比值,俯仰改乘灵敏度判定倍率」10-01去掉首批实测的转向增益;俯仰乘灵敏度系数
#6162「Android 上导入滑索坐标改为提示暂不支持」10-02Android 导入提示
35d1f74「滑索锁错后侧扫重瞄」10-03按邻居算偏置、逐次加减、外侧横扫
f494516「上索改为匹配并点击登上滑索架图标」10-03上索匹配图标
#6205「滑索落地重规划起点层与同步规划停步计时」10-04落地后重规划钉住架子那层

性能

数据有两个来源:本机的 PlayCover 日志和规划日志,以及 #6104 附带的 Windows 日志。两台机器的硬件、输入后端和代码版本各不相同,下面的数字用来看量级和瓶颈落在哪一段。

测量环境

项目本机#6104
硬件Apple M5,10 核(4 性能核 + 6 能效核),24 GBAMD 核显加 NVIDIA GeForce GTX 1650,日志里没有 CPU 型号
系统与客户端macOS 27.0,PlayCoverWindows 桌面端
输入后端ADB 后端(控制器类型 playcover)桌面后端(控制器类型 win32)
每刻数据2026-09-28 至 09-29 的两份 debug 日志,11 趟,均在武陵2026-09-30 06:11 至 07:02,22 趟,武陵、四号谷地和 OMVBase01
规划数据2026-09-28 至 10-03 的 16 份本地日志,3567 次成功规划同一份日志,125 次规划
代码版本本地开发构建,随日期变化v2.30.1

本机这批每刻日志录于 #6097 调参期间,ADB 后端的转向参数在两天里改过几次:

时段每度单位单笔上限每笔发送耗时中位
09-28 四趟5.020°125 到 145 ms
09-29 12:385.090°120.5 ms
09-29 15:09 至 19:23 四趟3.0 或 4.090°143 到 183 ms
09-29 19:32、19:36 两趟3.090°272、290 ms

最后两趟的发送耗时接近ADB 视角滑动按往返开销推算的新版每笔 290 到 340 ms。

每刻耗时

刻的周期定义和累计分布图见每刻耗时。两份日志按同一口径统计,单位 ms:

指标本机中位本机 p90本机 p99#6104 中位#6104 p90#6104 p99
刻间隔199.55781395186278571
截图 + 定位124.5222475141218272
其中定位355113784137227
单笔转向发送143274421346
刻内耗时200530937144226339

样本:本机 1132 刻(刻间隔 1126 个)、1404 次定位、715 笔转向;#6104 2621 刻(刻间隔 2610 个)、3517 次定位、976 笔转向。一刻内的耗时从这一刻开头量到做出转向决策,含截图、定位、走廊计算和转向发送,只统计常规导航刻:语义节点、恢复、丢定位这几种刻在做出转向决策之前就提前返回了。

  • 截图与定位。两边截图加定位的中位接近,构成相反。按中位相减,本机截图约 90 ms、定位 35 ms;#6104 截图约 57 ms、定位 84 ms。
  • 转向发送。差了两个数量级。ADB 后端的一笔转向是一次单指滑动,按下、移动、两端停留都在这一刻里同步完成;桌面后端发一次相对鼠标移动,3 ms。本机一刻内的耗时的中位比截图加定位多出约 75 ms,多出的部分主要就是这一项。
  • 长尾。本机刻间隔的 p90 是 578 ms,超过延迟检测的 280 ms 慢刻阈值;#6104 的 p90 是 278 ms,贴着这条线。
  • 转向与横向偏差。本机 1132 刻里 510 刻下了转向,单刻转角中位 21.8°;#6104 2621 刻里 755 刻,中位 11.5°。走廊横向偏差本机 686 刻中位 0.36、p90 1.44、p99 2.84;#6104 2610 刻中位 0.26、p90 0.95、p99 1.80。刻间隔更长的本机一侧,单刻转角和横向偏差都更大。

规划耗时

单次规划的分段耗时,单位 ms:

阶段本机中位本机 p90本机 p99#6104 中位#6104 p90#6104 p99
开窗29.115466589.16821392
拓扑层19.1386153118.711003943
几何层10.593.476312.3170014014
合计101.713093998251.4453419879

三段只记定案那一档;合计还含前几档白跑的时间和后处理,所以大于三段之和。这批数据早于 #6219「规划提速并共用旁包与格图段」,之后的耗时没有重测。本机 3567 次的档位分布见 NavMesh 一文的分档实测。

两台机器单次规划的窗口大小与耗时,以及 #6104 日志 17 次路线展开的耗时构成

左图每个点是一次成功的规划。窗口大小相同时,#6104 那台机器慢 3 到 7 倍:10 万到 20 万格,本机中位 46 ms(1020 次),#6104 中位 158 ms(67 次);100 万到 350 万格,本机 443 ms(1377 次),#6104 3184 ms(32 次)。两边代码版本不同,倍数只作参考。红色菱形是拓扑层通道没接通、退到全图按亏欠计价的规划(失败处理见 NavMesh 一文的拓扑层),#6104 里有 18 次,中位 4.3 s,p90 16.9 s;其余 107 次中位 206 ms。右图见下一节。

路线展开耗时

导航开始时整条作者路线先展开一次:每条腿跑步行规划;路线开了滑索时,再加上滑索筛选和候选腿规划(见两趟评估)。上图右侧是 #6104 日志的 17 次展开,按开始时刻排列。

  • 只走路。06:11 到 06:23 和 06:57、07:02 这几次只有步行腿,0.1 到 2.7 s。
  • 筛选。灰色部分主要是滑索筛选,每次 0.6 到 4.4 s。武陵比四号谷地重:武陵 198 根架子、271 条候选索,四号谷地 49 根、81 条。拆开看,连通类判定 0.4 到 2.0 s;遮挡判定在同一张图上第一次最慢,武陵 2.8 s、四号谷地 1.4 s,之后 0.07 到 0.5 s。06:25、06:36、06:38、06:40 四次筛选完找不到一对两端都够得着的架子,没进入候选腿规划。
  • 候选腿。kMaxExtraPlans = 12 限制的是条数,单条耗时取决于窗口大小和是否退全图计价。06:41 那次规划了 7 条,合计 4.8 s(见规划实测);06:46 那次只规划了 2 条,其中下索腿退到全图亏欠计价,一条就 5.7 s(见下文一趟导航);06:26 那一趟两次展开都用满了 12 条。

06:28 的 97.9 s。06:26 那一趟第一次展开 28.5 s:步行腿 11.1 s(窗口 1799 × 2473 格),筛选 2.0 s,12 条候选腿 15.4 s,其中 6 条失败,5 条报「起点种子格落在窗外」。选中的链有 7 根架子。前 3 跳正常,第 4 跳失败,人留在第 4 根架子上等新路线。06:28:09 起按滑索恢复从架上重新展开剩余路线:

步骤耗时说明
步行腿15.0 s退到全图亏欠计价
筛选3.1 s账本剔除 1 根索,两个方向一起删
候选腿,成功 7 条78.3 s5 条退到全图亏欠计价,最长一条 34.4 s
候选腿,失败 5 条1.4 s都报「终点附近没有声明的面 (deck=323.000000)」

恢复处理每刻运行,这次展开在一刻里同步完成,97.9 s 里没有一帧定位,人站在架上没动。最后选中一条 5 根架子的链,起点就是脚下这根,直接开跳。

运行期规划。展开之外,#6104 日志里还有 74 次规划,中位 172 ms,p90 1032 ms,最长 8.6 s。运行期的规划同样同步执行、计入这一刻(见缓存与并发)。

单腿复测

本地用 tools/MapNavigator 的规划预览接口(MaaEnd v2 28de7e4)测了 3 条腿,每条连续规划两次,分段耗时取第二次:

腿首次第二次开窗拓扑层几何层
四号谷地,长腿1542 ms718 ms65 ms283 ms114 ms
四号谷地,沿道路204 ms166 ms65 ms51 ms24 ms
武陵,折线桥130 ms124 ms29 ms44 ms25 ms

首次、第二次为接口往返的墙钟时间,分段为规划器自报。长腿是进程里四号谷地的第一次查询,墙钟比第二次多约 820 ms,规划器自报的合计多约 345 ms,多出的部分不在开窗、拓扑、几何三段里。这条腿的拓扑层通道没有接通,退到全图按亏欠计价,拓扑层耗时 283 ms。规划完两张大地图后,查询进程的峰值常驻内存约 2.2 GB。这组数字测于 28de7e4,#6219 之后格图段与旁包在各区间共用、每次规划完归还空闲页,没有重测。

一趟导航

#6104 日志 06:46 那一趟:地图上的规划路线、按速度上色的实际轨迹与冲刺位置,65 s 拐角处软重规划的放大,各阶段时间线

#6104 日志 06:46:59 那一趟,四号谷地,作者路线 3 个点。左图虚线是展开后的规划路线(58 点),彩色线是实际轨迹,颜色是相邻定位算出的速度(5 帧平均),绿三角是按下冲刺键的位置,黑点每 10 s 一个。右上放大 65 s 那个拐角:① 路线在路点 41 转向东,这一刻发了 −46°;朝向读数约 0.5 s 后才跟上,这段时间人继续往东南偏南走。② 走廊横向偏差超过 2.2,触发软重规划,新走廊从人的位置接回还没碰到的路点 42,前视点停在拐点 43,人折向东北。③ 碰到路点 44,下一个路点 45 在东南,误差 +115°,按 90° 上限发出。④ 0.9 s 后朝向转到 170°,回到路线上。左图蓝框里的小钩就是这段折返。右下是阶段时间线。

时间事件
0.17 s拿到首个定位 (636.69, 232.0)
0.18 到 3.0 s第一条腿的步行规划,第 1 档定案,窗口 1661 × 1806 格,58 点,代价 349.09,2.8 s
3.0 到 3.6 s滑索筛选,49 根架子里 44 根没通电
3.6 到 4.0 s上索腿规划,0.37 s
4.0 到 9.7 s下索腿规划,通道未接通,退到全图亏欠计价,代价 350.06,5.7 s;结果是滑索方案都不如走路
9.7 到 9.9 s第二条腿只有 5.6 像素,收益门槛处直接返回
9.9 到 10.27 sBootstrap:人站在第 1 个航点上续接,锚点规划两次(188、183 ms),见起步流程
10.4 s镜头已对齐,差 −1.5°
28.3 s卡住,跳一次脱困,位移 4.29(见阶梯)
65.5 s软重规划,原因 3(偏离走廊);之后航向误差先到 −77°、再到 120°,大角度按 30° 一笔拆开连发
74.4、75.8 s两次运行期规划,369、352 ms
77.4 s路线尾部消费完,离终点 2.06 像素,结束

导航阶段 67.1 s、262 刻,79 刻下了转向,共发出 94 笔(桌面端单笔上限 30°,大角度拆成几笔);冲刺键按了 38 次。实际走了 354.9 像素,规划路线长 349.1,平均 5.3 像素/s。结束时灵敏度检测给出 0.969 ± 0.021(1254 个样本)。这一趟总共 77.4 s,开头的 9.9 s 花在路线展开上,其中 6.7 s 是最后没被采用的滑索评估。

代码位于 MaaEnd 仓库 agent/cpp-algo/source/MapNavigator。

参考资料

  1. ↑ P. E. Hart, N. J. Nilsson, B. Raphael. A Formal Basis for the Heuristic Determination of Minimum Cost Paths. IEEE Transactions on Systems Science and Cybernetics, 4(2): 100–107, 1968. doi:10.1109/TSSC.1968.300136
  2. ↑ R. C. Coulter. Implementation of the Pure Pursuit Path Tracking Algorithm. Technical Report CMU-RI-TR-92-01, Carnegie Mellon University, 1992. PDF
  3. ↑ E. B. Wilson. Probable Inference, the Law of Succession, and Statistical Inference. Journal of the American Statistical Association, 22(158): 209–212, 1927. doi:10.1080/01621459.1927.10502953
  4. ↑ A. E. Hoerl, R. W. Kennard. Ridge Regression: Biased Estimation for Nonorthogonal Problems. Technometrics, 12(1): 55–67, 1970. doi:10.1080/00401706.1970.10488634
  5. ↑ S. Hutchinson, G. D. Hager, P. I. Corke. A Tutorial on Visual Servo Control. IEEE Transactions on Robotics and Automation, 12(5): 651–670, 1996. doi:10.1109/70.538972
  6. ↑ E. W. Dijkstra. A Note on Two Problems in Connexion with Graphs. Numerische Mathematik, 1: 269–271, 1959. doi:10.1007/BF01386390

规划器内部用到的算法(Lazy Theta*、距离变换、λ 中轴等)的文献见 NavMesh 一文的参考资料。

MapNavigator:终末地寻路

https://tail.lemonmiaow.xyz/posts/mapnavigator-design/
作者 Lemon_miaow
发布于 2026-10-03
许可协议 CC BY-NC-ND 4.0