MapNavigator:终末地寻路
MaaEnd MapNavigator 寻路导航的状态机、NavMesh 规划、跟随与转向、卡死恢复、语义动作和滑索,以及各参数的来源。
WARNINGMapNavigator 仍处在快速迭代中,各类边界情况的处理还在陆续补齐。本文只能作为大致参考,阈值、时限和个别分支很可能很快过时;主体结构一般不变。文中代码行为以 MaaEnd v2 分支 84de1e1(2026-10-05)为准。
MapNavigator 是 MaaEnd 的寻路模块,按 Pipeline 给出的路线把角色带到目标点,沿途执行跳跃、交互、采集、挖掘、滑索等动作,坐标取自 MapLocator。本文按一次导航的执行顺序描述当前实现:入口与状态机、NavMesh 规划、启动续接、到达判定、走廊跟随与转向、输入后端、卡死恢复与 watchdog、跨区、语义动作、滑索,最后是实测耗时。NavMesh 规划器的内部实现单独成文,见 NavMesh:终末地路径规划。各部分附上对应的 PR,说明参数从哪来。
整体流程
导航主循环每执行一轮叫一刻(tick)。
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 | #1999 | ADB、PlayCover 后端 |
| 04-13 | #2162 | 单一 Navigate phase |
| 05-21 | #3084 | NAVMESH(Beta),三角网 A* |
| 06-10 | #3509 | 丢定位超时与盲跳 |
| 06-12 | #3552 | 卡死硬时钟 |
| 06-14 | #3628 | 走廊规划、冲刺限制、转向增强 |
| 06-18 | #3690 | COLLECT 改为异步检测,偏离 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 | #5586 | ActionTraits |
| 09-08 | #5585 | 滑索子状态机 |
| 09-10 | #5618 | 滑索剔除拦路与地形阻挡的索 |
| 09-20 | #5891 | FIND |
| 09-20 | #5899 | 滞留 watchdog |
| 09-29 | #6097 | ADB 转向修正 |
| 09-30 | #6109 | 偏离计时扣除同步规划耗时 |
| 10-01 | #6155 | TRIGGER |
| 10-04 | #6205 | 同步规划时停步,停滞计时扣除规划耗时 |
输入与输出
输入是 Pipeline 自定义动作 MapNavigateAction 的参数,核心是一条路线 path。下面是自动售卖任务走向物资调度终端的一段:
{
"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_timeout | 60000 ms | 无进展超时,设 0 关闭 |
sprint_threshold | 16.0 | 剩余距离超过它才允许冲刺 |
snap_radius | 5.0 | NAVMESH 目标的吸附半径 |
zip | false | 是否允许滑索 |
heading_source | character | 朝向取角色还是镜头,只接受 character、camera |
enable_bootstrap_navmesh | true | 起步时是否用 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 是导航的主体:
- 执行 Bootstrap,选出起点,见启动与续接。它有几级兜底,总能选出一个起点。
- 预先跑一次提示识别。识别第一次运行要做初始化,耗时较长,趁角色还站着先跑掉,免得落在行走中的某一刻上。
- 启动后台扫描线程。
- 按 phase 一刻一刻执行,直到收到停止,或进入 Finished、Failed。
- 检查终点证据。路线尾部有提示待触发时,站定 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_timeout | TRANSFER 一直取不到位,或没有外部位移 | 15000 ms |
heading_turn_failed | HEADING 的转向指令发不出去 | — |
dig_context_missing、dig_dispatch_failed | DIG 缺执行上下文、子任务派发失败 | — |
find_* | FIND 的 7 种失败,见 FIND | 60 s 或 48 步 |
trigger_* | TRIGGER 的 3 种失败,见 TRIGGER | 15000 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,且动作带末端纠正 |
| 连续 RUN | RUN 且不严判 |
| 提示驱动 | 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 路点的展开顺序:
- 规划。成功则跳到第 6 步。
- 起点恢复(#4188「起点位于 off-mesh 时先盲走到最近网格点」):在 32 以内找最近的网格点,有起点声明面时按声明面选层,没有就按起点楼层;距离超过 0.5 就先插一个 RUN 点盲走过去,再从那里重新规划。
- 禁区:端点落在作者禁区里时直接失败,后面的兜底一律跳过。禁区是作者画死的约束,后面每一条兜底都不看可走面,交给它们就相当于让角色从禁区里穿过去。
- 滑索桥接:开启滑索时,步行不通先试滑索,见滑索。连续滑索可能分别接上起终两侧的可走面,步行不连通时这一腿仍可能到达;滑索结果是完整的规划,优先于后面的二维盲走。
- 盲走探针(#3505「NAVMESH 目标不可达时盲走补全最后一段」):起点到目标规划不通时,改为规划到目标附近一个走得到的点,最后一段从那里直线走到目标。这段直线没有规划路线可跟,所以称为盲走。候选点(探针)取在目标到起点的连线上:第一个就是目标本身,之后每个比上一个离目标远 2,最远离目标 42,起点更近时到起点为止。探针只用来挑规划的终点,全部在出发前算完。每个探针先吸附到 12 以内最近的可走格,吸附不上就跳过;吸附上了就从起点规划到吸附点,第一个规划成功的采用。角色先沿这条路线走到吸附点,再按追加的一个 RUN 点直线走到目标。盲走不能超过 30,超过则整个路点失败;吸附最多挪 12,离目标超过 42 的探针剩下的盲走一定超过 30,所以探针最远取到 42。总预算 8000 ms。声明了
target_deck_y时禁用:盲走兜底是二维的,走到目标正上方也算到,用它兜底就把错层吞了回去。 - 开启滑索时,步行结果会与滑索比较。
- 输出路点:终点严判,中间拐点不严判,每点带净空和面高;台沿下落的落点改为严判,
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 要回答两个问题:角色现在属于路线的哪一段,以及从当前位置怎么走过去。
起步流程
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.5)的,取路线顺序里最早的一个。环形路线的首尾常常重合,取最早的才不会把路线头当成路线尾。
- 记离 最近的候选为 , 之前各候选到 的最小距离为 。 之前没有候选,或 ,取 。
- 其余情况从 往前回退。这一步的距离改为前驱到 的距离:前驱要带坐标、区兼容、离 不超过 18,碰到 ZONE、HEADING 这类无坐标标记就停。
左图是投影规则,色块表示角色站在那里时从哪个点接着走,白色区域交给离线规则。右图是离线规则的示意:最近点 w3 离角色 5.7,更早的点里最近的 w0 是 8.5,,于是从 w3 往回退;每个前驱都在 w3 的 18 以内,一直退到 w0。
回退时每个前驱都和最近点比距离,链长因此有界。无坐标的 ZONE、HEADING 标记截断回退;最近点之前只有这类标记时,从路线头开始。
这条规则来自 #5060「bootstrap 续接不再只按最近点判定」。此前角色不在线上时只按最近点判断归属,零点几的距离差就能让一整簇路点被跳过。现在跳过路点需要证据,证据不足则从更早的点接着走。
可达锚点
续接候选给出扫描的起点,之后往后找第一个能规划过去的路点:
- 跳过区不兼容的路点;遇到声明了别的区的 ZONE 就停;遇到没有坐标的必经锚点(比如 HEADING)就停。
- 对候选跑一次规划:终点钉在候选声明的面上;起点楼层取角色正在走的那段规划线的面高,刚从滑索落到架子上时再带上架子高度作为起点声明面。够不到终点那张面的候选不会被选中。第一个规划成功的就是锚点,扫描到此为止:再往后比较代价挑更近的,等于在归属判定之后又做一次就近吞点。
- 必经的语义锚点规划失败时停止扫描。
- 最多规划 8 次。每个不可达的候选都要跑满一次搜索,单次就是秒级,曾有一次连续 34 个候选不可达,额外卡了约 70 s;预算用尽就放弃这轮扫描,交给下一级回退。
- 扫描无果时取续接候选之后的第一个锚点。
动态覆盖
选中锚点后,对它再规划一次,把规划出的点插到锚点之前,这一步叫动态覆盖(overlay):
- 松开前进键,规划耗时从偏离 watchdog 和两个停滞时钟里扣掉(#6109、#6205)。
- 规划时带上当前的运行期禁区,以及与扫描相同的起点楼层和起点声明面。
- 规划点以非严判 RUN 插入,不含终点,锚点本身保留原动作。
- 规划点为空、锚点又在判定圈外时,补一个领头节点,避免路线只剩一个点。
- 续接位置变了再重置硬性进度时钟。
- 跟随状态重置,起步确认清零。规划点为空且角色已在判定圈内时,直接记为已起步。
锚点因此会被规划两次:扫描一次,覆盖一次。#6104 的日志里分别是 118 ms 和 117 ms。
覆盖失败时,续接候选在路线中段就从它接着走;再不行就从路线头开始。
导航中途的重规划走同一个覆盖函数。单次重规划失败不终止导航,退回当前路线继续走,持续没有进展时由卡死恢复的各级超时收口。路线没有被替换,所以只清跟随进度,起步确认保持原状。
镜头对齐
#5917「起步前对齐镜头与角色朝向」:导航的反馈量默认是小地图箭头给出的角色朝向(heading_source 取 character),按前进键时角色却沿镜头方向走,两者不一致时控制环的反馈和实际走向对不上。因此先在起步前把镜头转到导航使用的朝向,按 W 时角色就沿控制环看到的方向走。对齐时只转镜头,前进键保持松开。镜头与导航朝向的差值小于 10° 时跳过,转完等 300 ms 再取一次位置。heading_source 取 camera 时,导航朝向和镜头朝向是同一帧读出的同一个值,差值为 0,对齐每次都跳过。
小地图中心的三角形是角色箭头,白色扇形是镜头的视野,扇形中线就是镜头朝向,角度以正上方为 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 就放回常规值,避免多出一种卡死。
规划生成的点按走廊净空收紧。通道比判定圈还窄时,常规的判定圈会让角色提前弃点、直奔下一点,抄出一条撞墙的弦。
到点处理
进圈之后:
- 还没确认起步时不推进,见下一节。
- 严判点进入原始判定圈(2.25,不受上表的放宽和收紧影响)时,先做末端纠正。滑索和传送门有各自的站位与提交距离,判定圈被放宽或收紧的那几种情况要的正是宽松的判定,这些都不做纠正。
- 严判点仍在移动时刹停,再等 150 ms。PORTAL 也走这一步,之后再按下前进走进传送门。
- 按动作分派:TRANSFER、PORTAL、ZIPLINE、DIG、FIND、INTERACT、SPRINT、JUMP、FIGHT 各有处理函数;RUN、COLLECT 直接推进。HEADING、ZONE 没有坐标,NAVMESH 展开后换成了规划点,三者实际到不了这一步。
起步确认
起步确认最早来自 #2162,对应 startup_motion_confirmed:角色位移达到 0.8 或换区之后才算起步,在此之前三条推进路径都不生效:到点推进、经过即推进、走廊消费(#4164「走廊路点消费对齐起步门控」)。没有这一步,起点附近的路点会在角色动起来之前就被消费掉,转向直接对准更远的点。
跟随状态每次重置,起步确认都会清零:串行续接、每次动态覆盖、作者路线重规划、挖掘之后、异步交互之后,以及滑索动作里的三处。例外有两个:动态覆盖为空且角色已在判定圈内时直接确认;重规划失败退回当前路线时保留原来的确认状态。
经过即推进
角色常常从路点旁边擦过,没有进圈。路线跟踪器在两个连续 RUN 点之间维护一条线段的投影状态:
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 日志 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 起禁冲刺,接近段切走路。走路让滑行距离减半,到点后要走回去的那段也短一半。进圈后的循环:
- 取一帧干净的定位,定位器沿用上一次输出或黑屏的帧跳过,最多等 12 帧 × 120 ms。等不到就刹停,按原判定圈收下。
- 本步走了 0.4 以上才更新朝向和速度:朝向取本步的位移方向,速度取位移除以真实经过的时间。转身的那段时间角色也在走,按睡眠时长算会把速度估高。
- 残差 ≤ 0.5 即验收,日志 “Strict arrival verified.”。
- 满 4 次纠正、连续 2 步走不到 0.4、或用满 6000 ms,按原判定圈收下,日志 “Strict arrival settle gave up, accepting on band.”。
- 转向目标方向,前进键一直按着,步长取 ,按实测速度换算为按住时长,夹在 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 展开出的顶点才有,手写的点没有,拐角用它限制切角。走廊建好后一直沿用,直到下一次作废重建。
黄线是走廊折线:从走廊起点经过当前路点和途中的连续 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 的锚点就是它自己,走廊只到这一个点。下:扫到 HEADING 这类没坐标的必需语义点就停,锚点取它前面最后一个带坐标的点。
严判 RUN 要求精确到点,锚点只能是它自己;连续 RUN 可以越过普通路点一直看到下一个语义锚点,所以它的走廊一直延伸到下一个需要停准的地方,中间的普通 RUN 点只是折线顶点。锚点下标或 zone 变化时,整条走廊作废(invalidate),软重规划次数也一起清零。路点推进、动态覆盖、路线重规划、全局重捕获之后会置 nav_run_dirty,下一刻同样作废重建。
走廊来源
buildPlan 按顺序取:
- 路线自己的点。从当前点连到锚点,带上每个点的走廊半宽(NAVMESH 展开的点有,手写的点没有)。取到 2 个点以上时直接用。中途遇到没坐标的点、或路线先结束了,就返回空,不信一条够不到锚点的线。
- 末端单个裸坐标。当前点就是锚点、折线只有这一个点时,若它是连续 RUN、没有净空、离角色超过 0.25,就从角色直线连过去(#4947)。作者手写的裸坐标按直着走过去处理,交给 A* 会为了贴回网格绕远路;NAVMESH 展开出的点带净空,末点又是严判,不走这一条。
- 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 内不再重试。失败的规划没有留下可作废的走廊,不设冷却的话下一刻马上又会重建;失败的规划又是最慢的一种,会走完整个窗口分档才放弃。每刻重试的开销远高于这段时间退回路点级转向的代价。
投影
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(角色已经过了走廊终点)时,标记为窗口外。
一条折返的走廊,去程 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 刻(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 刻按上面的规则算出的前视距离,与日志里投影点沿走廊到前视点的距离逐刻一致。
前视距离
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。“到了拐点”最早也要越过之后才知道。
三样叠在一起,角色过了拐点还要沿旧方向走两三刻。走廊在拐点 转了 角,角色过了拐点沿旧方向又走了 ,到达 ;从 向出边作垂线,垂足为 。、、 构成直角三角形,直角在 ,斜边 , 处的角是 ,所以角色离出边中心线
转角 ,,每刻位移 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 日志里的一趟(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°,拐点处净空 2.0;每刻位移 1.6,前视距离 11.2,拐角提交距离 。角色每刻朝瞄准点走 1.6。第 1–6 刻投影点离拐点超过 3.5,前视点停在拐点上,灰圈是按前视距离本该落到的位置,走廊航向仍沿当前边;第 7 刻投影点离拐点 2.61,前视点越过拐点,走廊航向开始转。
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 刻的路程;没有速度估计时 。拐点的净空 已知时,再按这个拐角的转角 取
这个上限来自切角的几何。角色离拐点 时开始朝拐角里转,走的是一条弦,从拐点内侧切过去。按对称的情形估算:弦的两端在入边和出边上,各离拐点 。拐点和弦的两端构成等腰三角形,顶角是 ,两个底角各为 。从拐点向弦作垂线,得到一个斜边为 、底角为 的直角三角形,垂线长
就是弦切进弯内侧的深度。净空 是拐点处可通行区域的半宽,弯内侧的墙最近可能就在离拐点 处,所以要求 ,即 。手写路线没有净空,只用按速度算的 。
与上面的动图同一个拐角:,,每刻位移 1.6。按速度算的 ,切进 ,超过净空(灰虚线);按净空算的上限 ,切进的深度恰好是 2.0(红线)。两者取小,。
拐角提交距离也按刻数计,转弯因此总有同样多的刻数去完成;固定的世界距离在慢循环上不到一刻,角色到拐点时还朝着旧方向。4 刻少于前视的 7 刻,拐点落在 4 刻到 7 刻的距离之间时,前视点仍停在拐点上。
短边。短于 0.75 的边只计入前视距离,转角判断跳过它。规划器的顶点按格放置,太短的边方向只是量化噪声,在它上面误判出一个拐角会让前视点停住。
瞄准点
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 左右, 若跟着缩短,定位量化带来的那一点横向偏差就会单独越过转向死区。 也不短于弦长:比前视点更近的瞄准点在循环滞后下会转过头。
示意,角色偏在走廊一侧 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。(a) 基准是投影点所在的当前边。长 0.5 的短边不比较;后面两条边与基准差 30° 和 75°,结果取 75°。8.0 在 75° 那条边的中途用完,这条边整条参与比较,再往后的边不看。(b) 投影点离当前边末端只剩 0.5,这一段不比较,基准落到往北的下一条边上,脚下 90° 的拐角不计入,结果是后一条边的 25°。
软重规划
每刻投影之后检查是否重新生成走廊:
| 原因 | 条件 | 冷却 |
|---|---|---|
| 偏离走廊(3) | 横向偏差 > 2.2 | 1200 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。
- 偏离走廊和进度倒退对停滞时钟的处理不同,见每刻顺序。
示意,相邻两帧相隔 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 按下面的顺序转向和推进:
- 目标航向:有走廊航向用走廊航向,否则用
route_heading。 - 朝向角速度:本刻朝向和参考朝向之差,折算到每 100 ms。
- 结清在途转角账:把上一刻以来朝向的实际变化从待转角里扣掉,过期的作废。
- 航向误差 , 是目标航向、 是当前朝向,交给
SteeringController::Update。 SetForwardState(true),按住前进。ApplySteering:按后端拆批发出,实际发出的量记入在途转角账。- 灵敏度检测记一刻。
- 前进键闭环:按着前进却连续几刻没动就重发。
- 延迟检测记一刻。
- 提示点附近抑制冲刺;判断冲刺条件。
度与单位
控制器以度为单位工作,发给后端前换算成鼠标量或滑动像素。
-
桌面端:记窗口宽 、高 (像素),
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° 出。
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;
}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 日志(Windows 桌面端,单笔上限 30°),每个是一刻:黑箭头是读到的朝向,绿线是目标方向,浅绿扇区是 ±6.6° 死区;从朝向开始先画在途(黄),再接本刻发出的量(蓝,白线分笔),灰色是本刻没补上、留给下一刻的部分。① 误差 −5.3° 落在死区里,不发。② 误差 −45.5°,前几刻已经发出、读数还没体现的 13.5° 先扣掉,只补 32°。③ 和 ④ 的误差都在 139° 左右:前进中发满 90°,原地转身只发 70°。
掉头锁定
误差接近 180° 时两侧一样远,一度的读数抖动就能让指令方向翻转 360°。#4839「近乎掉头的转向锁定方向,不再左右横跳」(cherry-pick 自 #4831)在误差达到 150° 时锁定方向,降到 120° 以下才解锁。锁定之后角色朝同一侧一直转到底;两侧都能转到目标,锁定哪一侧都对。
锁定判断用的是扣除在途之前的误差。锁定只改控制器内部的副本,冲刺条件读到的仍是原始误差。
#6104 日志里的一次掉头,桌面端单笔上限 30°。左图是这段时间的定位点,箭头是七个刻各自读到的朝向:前 6 刻位置挪动不到 1 像素,朝向从 190° 顺时针转到 348°;转完之后的第 7 刻已经往北走了 2 像素。右图每个罗盘是一刻的账:黑箭头是读到的朝向,绿线是目标方向,浅绿扇区是 ±6.6° 死区;从朝向开始顺时针先画在途(黄),再接本刻发出的量(蓝,白线分笔)。黄和蓝加起来最多走到绿线,这就是”在途夹在 0 和误差之间”的几何含义。前三刻误差在 150° 以上,锁定右转;第 4 刻误差降到 106°,解锁。各刻的数据:
| 时间 | 朝向 | 误差 | 在途 | 指令 | 发出 |
|---|---|---|---|---|---|
| 3.267 | −169.4 | 167.4 | 0 | 70(还没按前进) | 30 + 30 + 10 |
| 3.412 | −169.4 | 167.4 | 70 | 90 | 30 + 30 + 30 |
| 3.564 | −157.7 | 155.7 | 148.3 | 7.4 | 7.4 |
| 3.710 | −101.8 | 105.8 | 99.8 | 0(6.0 在死区内) | — |
| 3.846 | −38.5 | 44.5 | 36.5 | 8.0 | 8 |
| 3.977 | −11.5 | 17.5 | 17.5 | 0 | — |
| 4.385 | 10.0 | −2.0 | −2.0 | 0 | — |
误差 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。左边罗盘:黑箭头是本刻读数,灰虚线是参考值,蓝弧是 ,浅灰短箭头是之前各刻的读数。右边是本刻的计算,下面的条是 ,竖线是上限 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 日志 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°: 夹到 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° |
| Linux | 18° | 2.0° | 16 ms | 3 × 18 = 54°,还受 限制 |
| ADB | 90° | 1.5° | 无 | 1 × 90 = 90° |
最后的 5° 落在 6.6° 死区内,通常不会单独发出。上限 3 批让每一刻都还是一个观测点:某一帧朝向读错,最多多转一刻的量,下一次定位就能纠正。
在途转角账
每刻实际发出的转向记一笔在途转角账。账上记着已经发出、朝向读数还没体现的度数,以及起算的参考朝向。一次转向要几刻才完成,控制器却每刻重新决策,没有这笔累计,同一个误差会在落地之前被反复下发。
每刻结算:
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.4 | 0 | −70(停着) | 3 × −20 | 三笔滑动共 449 ms |
| 3 | −102.5 | 0 | −90 | 3 × −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.0 | 0 | — | 落入死区 |
| 6 | +19.8 | 0 | +19.8 | +19.8 | 过冲约 20°,反向纠正 |
上表第 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 保留下来。
左列是 #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-13 | aeceb665「调参」 | Kp 0.3 |
| 04-14 | 6fe6d178「消除抖动」 | 删除 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 | #4682 | Kp 0.6,死区 6.6;前视统一 6.0 |
| 08-07 | #4809 | Kp 0.45;7 刻前视及配套;参考值改为 4 刻内有效;单刻最多 3 批(按 刻间隔 / 100 分配) |
| 08-08 | #4839 | 掉头锁定 150 / 120 |
| 08-11 | #4939 | Kp 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 日志 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 次。
冲刺与走路
冲刺条件
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 重按一次。每次冲刺:
- 处于抑制状态时直接返回。
- 开启 ADB 的 60 ms 静默期,其他后端没有静默期。
- 调后端的冲刺输入:桌面右键按 30 ms,Linux 按 Shift 30 ms,ADB 点按冲刺键 50 ms。
- 游戏会在冲刺时自己退出走路,所以清掉走路状态,日志 “Walk mode cleared by sprint (the game leaves walking on its own).”。
- INFO “Sprint state armed.”。
每刻的判定结果(allow_sprint)打一行 DEBUG,便于事后对照:
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 日志里的一趟,从右侧起点出发,到左下角结束。每一小段轨迹按这一刻被哪条条件拦住上色,几条同时不满足时取图例里靠前的那条。前 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.0 | 4.5 | #5030「增强自动采集,收紧判定圈并接近时切走路」 |
| 滑索 | 5.0 | 7.5 | #5337「提前切换步行接近滑索」 |
| DIG | 5.0 | 7.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 日志里的一次滑索接近,三道圈以滑索站位点为圆心。角色沿走廊一路按冲刺;进 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 后端,其余用桌面后端。
| 项 | 桌面 | Linux | ADB |
|---|---|---|---|
| 最小发送量 | 1.0° | 2.0° | 1.5° |
| 单笔上限 | 30° | 18° | 90° |
| 单刻最多 | 90° | 54° | 90° |
| 最小间隔 | 0 | 16 ms | 0 |
| 静默期 | 0 | 0 | 60 ms |
| 每度单位 | 2.23006 × 宽 / 360 | 同桌面 | 3.0 |
| 转向 | 相对鼠标移动 | 同桌面 | 单指滑动 |
| 冲刺 | 右键按 30 ms | Shift 按 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:游戏逐帧采样触点,与按下或抬起同一帧的位移会整步丢失,两端的停留要盖过负载高时的一帧。
左图是 #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 才报,中间一窗正常就清零重来 |
| z | 2.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 是根号那一项:
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 和观测不相容,并且 。把 代进相容条件再取反:
右边有直观的含义:每刻慢不慢各占一半时, 的均值是 ,标准差是 ,门槛就是均值往上 个标准差。各刻数下的门槛:
| 窗内刻数 | 最少慢刻 | 比例 | |
|---|---|---|---|
| 8 | 7.29 | 8 | 100% |
| 9 | 7.99 | 8 | 89% |
| 10 | 8.68 | 9 | 90% |
| 14 | 11.35 | 12 | 86% |
| 20 | 15.20 | 16 | 80% |
| 45 | 30.30 | 31 | 69% |
假设机器处在临界状态,每刻慢的概率恰好 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 = 本刻计算耗时 − 截图定位 − 转向。判定只看整刻间隔,各阶段耗时只用来说明时间花在哪。
左图黑色阶梯是各刻数下判慢所需的最低慢刻比例,红色区域为慢窗。散点是两份日志按检测器规则切出的窗:#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)补进滞后链。
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%。
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 个。
触发
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;四个条件:
- 处于 Navigate phase。滑索、FIND、TRANSFER、TRIGGER 的等待 phase 都在阶梯之外。
- 普通停滞时钟 ≥ 3500 ms,或前进键重发 2 次仍然原地不动(
forward_hold_futile,见前进键闭环)。 - 有效进展 > 3.0,也就是离锚点还有 3.0 以上;严判点不受这条限制。3.0 以内的停滞交给无进展超时。
- 不在跨层逃逸中。逃逸走的是一条固定走廊,有自己的失败判据。
第 3 条用的距离和停滞时钟是同一个信号。走廊跟随时,这个距离是走廊上剩下的真实距离;串行路点只是沿途的一个记号,可能就在角色身边一个像素外,整段路却还在前头。拿串行路点的距离来判,贴着它卡住的角色会被当成”已经到了”,到达需要的起步动作做不出来,恢复也不会启动。
第 2 条的前进无效计数是停滞时钟正在收集的同一种证据,来得更早也更强:朝向对准了、没在转向、前进键重发两次位置读数都没变。再等停滞时钟走满只是继续往障碍上撞,所以它也能打开阶梯。
触发之后先解析当前锚点(规则见锚点)。恢复回合和升级计数都挂在锚点序号上,锚点变了就重开一轮。换锚点时还会丢掉设备探测器已经存下的结果:那个结果是在对付上一个锚点时看到的,画面早于路线重新编号。第一个锚点不丢,这时还没对付过别的锚点,探测器从停滞开始就在为它预热。
阶梯
满足触发条件、且距上一个恢复刻已过 3500 ms 冷却的刻,才是一个恢复刻;一个回合的第一刻没有冷却。一个恢复刻里按顺序执行:
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- 硬时钟超时。硬时钟停滞超过 30000 ms 时,ZIPLINE 点先丢掉整条滑索链改走路,其余点以
dynamic_recovery_timeout失败。退索会把硬时钟归零,再超时时当前点已经是步行点,那一次才判失败。卡在索边收不拢时先退索走路,给步行一次机会,这个分支也不会反复进入。 - 冷却。距上一个恢复刻不足 3500 ms 时什么都不做。
- 搬走挡路设备。本锚点还没试过时执行,见搬走挡路设备。设备搬完人仍然卡着,同一刻继续往下走。
- 跳。每个恢复刻都跳,见跳。
- 升级。本锚点的跳计数到 2 之后,同一刻继续:
- 以上都没有结论时睡 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「语义动作期间清掉顶墙计数与设备命中」)。
执行:
- 探测没命中,返回”未尝试”,直接去跳。面前没有可交互的东西,跳是更快的办法。
- 重按前进,跑子任务。前进键在子任务期间一直按着:子任务把设备拿起来,角色要在它放下之前走出设备底下,否则设备又落回原处挡路。
- 重新取位。取不到就返回”需要新定位”,这一刻结束。拿旧坐标往下走的话,后面的跳会从旧位置量位移,报出一次没发生过的脱出。
- 用和跳一样的脱出判据判断。没脱出时日志 “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,发出日志 “Dynamic recovery jump pulse issued.”。
- 前跳,睡 500 ms,松前进键。
- 重新取位。取位失败、黑屏或位置无效时,发出日志 WARN “Dynamic recovery waiting for post-jump local tracking fix.”,等下一刻。
- 判断脱出:
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、但确实朝路点前进了的跳。日志样例:
Dynamic recovery jump escaped obstacle. [jump_zone_changed=false] [jump_displacement=2.499300] [jump_moved_forward=true]- 没跳出去时重新解析锚点。跳完人可能落到了另一段路线上:锚点变了,恢复回合在新位置重开,升级计数重置,跳计数记为 1。
虚拟禁区
#5576 之后,绕障这一档的做法是在卡死点前方放一个圆形禁区,再重规划到锚点。
#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 相同。
同样来自 #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,整条路被封死,这时规划失败,进入下面的失败处理。
半径分四步定:
-
生长。从 开始,先算一个试探圆心 。逐个检查已有禁区,第 个的圆心为 、半径为 。同一定位区、没标唯一通路、并且试探圆心落在它外扩一档的范围内:
就把半径提到比它大一档,封顶 6.5:
在已有禁区的边缘又卡死,说明障碍比那个禁区大,新禁区在它的半径上加一档。旧禁区原样保留,两者的并集仍然盖住最初的卡死点。
-
锚点上限。记卡死点到锚点的距离为 。锚点正好在前方时圆心离锚点最近,两者相距 。按这个最坏情形,要求锚点留在圆外、并且离圆边至少 1.0:
锚点在 6.0 处时上限 2.0; 时上限低于下限 1.0,不放禁区。锚点太近时禁区会盖住锚点本身,重规划没有终点。
-
让出当前位置。卡死点和当前位置可能不同(物理脱困之后放的圆,卡死点是脱困前的位置)。记当前位置为 ,当前位置落在圆内时规划起点就在禁区里。半径逐次减半,圆心跟着按新半径重算,直到当前位置落在圆外:
-
下限。 时放弃,日志 “Virtual no-go skipped: no room for a disc between here and the anchor.”。
放下之后日志 “Virtual no-go stamped.”,带是否生长、圆心、半径、卡死朝向和累计个数。
规划。禁区随动态覆盖的规划一起传给规划器。展开层只传同一定位区、没标唯一通路的圆。规划器把格心 满足 (平面距离)的格从可走集合里拿掉。运行期禁区不带高度,窗口里的层在这之前已经按起点层高筛过,按平面距离盖格就够了。和作者在 NavMesh 里画的禁区相比,运行期禁区少一条端点规则:起点或终点落在圆内照样规划。
失败处理:
- 规划失败时半径减半(同样要让出当前位置),圆心按新半径重算,再规划一次,日志 “Virtual no-go shrunk after replan failure.”。规划失败多半是禁区封住了整条窄路;已经是最小半径时不再重试。
- 仍然失败,说明这里是唯一通路,这个圆标为唯一通路(push-through),日志 WARN “Virtual no-go blocks the only passage; marked push-through.”。
- 唯一通路圆之后不参与任何规划。当前位置离它的圆心不超过 (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,每次从不同方向开始试。
-
每个方向从 0.5 起每 0.5 采样一次,采到 6.0。采样点投到底层网格上判断在不在可走面内;投不上的点按可走算。
-
一个方向被采用,要同时满足:
- 最后一个采样点(6.0 处)在可走面上;
- 最长的一段连续不可走 ≤ 2.0。
-
记末段连续可走面的起点(离角色的距离)为 ,步长
把 夹到 里。整条线都可走时 , 取下限 2.5。
| 常量 | 值 | 作用 |
|---|---|---|
kUnstickSampleStepM | 0.5 | 每条射线的采样间隔 |
kUnstickMaxRockCrossingM | 2.0 | 可以跨过的最长不可走段 |
kUnstickMeshMarginM | 1.0 | 越过可走面边缘再走这么远,落脚点在实地上 |
kUnstickMinDistanceM | 2.5 | 最短步长 |
kUnstickMaxDistanceM | 6.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,这一刻结束。
执行:
- 松前进键,等 150 ms。
- 按目标方向和当前朝向的差一次性转过去,至少转 1 个单位。
- 最多 8 次 270 ms 的前进脉冲。每次脉冲后取位,记脱困前的位置为 、这次取到的位置为 , 就停。定位失效或黑屏时立刻停:这多半是掉进了河里,再往前推只会继续往水里走,交给下一刻的丢定位处理。
- 序号加 1,日志 “Physical unstick step executed.”,带计划步长、实际位移、是否挪开。
挪开之后,以脱困前的位置为原点、用原来的卡死朝向放一块圆形禁区再规划,新路线从起点就绕开刚才顶住的东西。放圆或规划失败时退回普通的动态覆盖。原点取脱困前的位置,因为障碍在那个位置的前方,它和挪动后的当前位置是什么关系已经说不准。
序号的生命周期。脱困序号不随恢复回合清零,离开原点 2.0 以上、路点推进或新一趟导航时才清。同一个卡死点上连续几次脱困,扇面的起始方向因此依次轮转,每次换一侧先试。
脱出之后
ResumeAfterEscape 依次:
- 前进无效计数、原地计数清零(#4955「逃逸成功后清空前进无效计数」)。没有这一步,脱出后的第一刻就会因为残留的计数再次打开阶梯。
- 恢复回合、升级计数清零。
- 路线跟踪状态重置。
- 撤销挂起的重规划,走廊标脏,下一刻重建。
- 普通停滞时钟清零。硬时钟保留。
- 切回 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,每刻:
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- 记下丢失的起点,暂停偏离钟。
- 本帧是黑屏就记下黑屏标记,留给落水判断。丢定位期间出现黑屏说明掉进了水里,定位器只把它报成普通的跟踪丢失,所以要在这里记下来,定位找回时才能置位落水自救。
- 松开前进键。
- 丢失满 30000 ms 时以
localization_lost_timeout失败。 - 距上次全局取位 ≥ 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.”;丢失期间见过黑屏就置位落水自救;清丢失状态,重置路线跟踪,走廊标脏,普通时钟清零。
- 全局取位没成功,丢失满 3500 ms、距上次盲跳 ≥ 3500 ms,且路线末尾没有待命中的 TRIGGER 时,前跳 500 ms 再松开,日志 “Localization lost; blind unstick hop issued.”。TRIGGER 待命中时,小地图可能正被要识别的画面盖住,按键会干扰这个画面,所以不跳。
- 其余情况睡 20 ms,下一刻再试。
本地取位自己恢复时,日志 “Localization recovered via normal tracking.”,同样检查黑屏标记,置位落水自救。
重捕获抖动。全局重捕获会清掉丢失计时和普通时钟,掉到错误楼层的角色可以在”找回 → 又丢”之间无限循环,任何超时都不会触发。抖动计数专门处理这种情形:数上次路点推进以来连续发生了几次全局重捕获,到 5 次就失败。正常行进每条腿只有 0 到 1 次短暂丢失,路点没推进却找回又丢了 5 次,只能是卡在了循环里。
抖动计数在三处清零:新一趟导航、路点推进、走廊消费。走廊上的路点被消费也是实打实的前进,一条长腿中途短暂丢几次定位,不清零就会攒到上限误判失败;原地找回又丢的循环消费不到任何路点,照样会被拦下。
跨层逃逸
部分区域分层,分层的定位区带地板高度,底层(…_Base 一类总览区)没有。角色从高处落下或掉进竖井时,定位会切到另一层,路线上却找不到属于这一层的锚点。跨层逃逸从当前所在的分层区规划一条 NavMesh 走廊,回到最近的可达作者路点,按固定走廊走回去。
进入。只在全局重捕获、定位区变了的时候尝试,三个条件:
- 新区是带地板的分层区。新定位要确实落在一层真实的楼层上,底层这类总览区不算。
- 路线在这里没有和当前区兼容的锚点。有的话是正常行进,不需要逃逸。
- 从当前路点往后逐个找作者写的路点(动态生成的点不算),第一个规划得通的作为汇合点,规划时保留走廊的内部拐点。
都满足时日志 “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):
-
整个落水状态先重置,再次落水要重新站定、重新转身。
-
记下岸边位置和此刻的朝向。
-
清掉恢复回合,硬时钟清零。落水之后由落水自救接管:传送之后,落水前的恢复锚点已经过时;恢复回合不清的话,它的脱出检查排在落水自救之前,会抢先把这次传送当成脱出。
-
日志 “River-fall recovery armed (black-screen loss recovered).”。
执行。落水自救排在到达判定之后、偏离 watchdog 之前,每刻:
- 暂停偏离 watchdog 时钟。
- 硬时钟停滞 > 30000 ms 时以
river_fall_recovery_timeout失败。 - 离岸边位置 ≥ 2.0 时结束,日志 “River-fall recovery cleared; resuming navigation.”,进
ResumeAfterEscape。 - 松前进键。
- 第一次进来时站定 2000 ms。上岸那一瞬的箭头读数最不可靠,箭头尖端可能翻转,追踪也还停在保持状态,拿它算转身容易算错。作者记录的三次落水里,有一次读成 −1°,算出的目标正好等于当前朝向,等于没转就往前走。
- 站定之后的第一刻转 180°,只转一次,发完就认,再等 300 ms。早先的做法是每刻拿转到一半的箭头重算误差再补发,转身还没完成就先迈了步,每一刻都朝着没转完的方向往水里走,两次再落水都紧跟在自救的前进脉冲之后。
- 每刻一次 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° |
左图是这一趟在地图上的样子。角色从下方起点往北走,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 声明本身没有坐标。就地消费的语义点每刻按顺序处理:
- 消费已经匹配的 ZONE:声明的区和会话的当前区相同(或声明为空)就推进过去,连续的几个一起消费。消费完路线空了,记路线尾部。
- HEADING。转身完成后再消费一次匹配的 ZONE。
- 没有坐标的 FIND 在这里接手:刹停、切 phase,之后的每一刻交给
TickFindTarget。见 FIND。 - 当前点仍然是没匹配上的 ZONE:按住前进,这一刻到此为止。
未匹配的 ZONE 刻在路线跟踪、阶梯和无进展超时之前返回,这段时间只有滞留 watchdog 和丢定位处理在起作用。TickNavigate 里路线跟踪之前还有一道同样的 ZONE 等待(按住前进、睡 33 ms、返回),作为第二道保险。
PORTAL
到点。PORTAL 的到点距离取判定圈和提交距离 4.0 的较大值。PORTAL 默认严判,到点先刹停,然后:
- 推进路点;PORTAL 是终点时记终点证据。
- 进入传送过渡,按住前进直到取到新定位。
- 重置定位器的跟踪状态。
过渡(每刻):
%%{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(["挂起重规划"])- 过渡超过 12000 ms 时以
portal_transit_timeout失败。 - 落地前:按住前进,做不限定位区的全局取位。
- 取不到,或还在原来的区:等 120 ms。
- 新区在后续路线里找不到对应的 ZONE 声明:停下,等 120 ms。
- 找到了:快进到那个 ZONE 声明(跳过中间的点),更新当前区,普通时钟清零,消费匹配的 ZONE,刹停,重置跟踪,记为已落地。
- 落地后:本地取位的定位区必须和当前区一致,才结束过渡,挂起一次动态重规划,下一刻的重规划检查接手执行。
TRANSFER
TRANSFER 用于机关、跳板一类把角色送走的东西,角色自己不动,等外部位移。
到点:刹停,推进路点,记下当前位置,切到 WaitTransfer phase。
等待(每刻):
- 松前进键。
- 取位失败,且等待超过 15000 ms:以
transfer_wait_timeout失败。 - 有外部位移指换了定位区,或离记下的位置 ≥ 3.0。没有位移时命中计数归零;等待超过 15000 ms 以
transfer_wait_timeout失败。 - 连续 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)里。
每次动手前有两道识别:
- 预筛:后台线程对每帧做模板匹配,只判断值不值得停车。
- 权威识别:停车后跑一次 pipeline 子任务,OCR 认出提示文字才按键。
所有可交互物弹出的都是同一个提示图标,两类共用这道预筛。预筛只决定子任务值不值得跑,子任务动手前还会再识别一次,阈值因此可以放松。
两类的差异
| COLLECT | INTERACT(带文本) | |
|---|---|---|
| 子任务入口 | AutoCollectClickStart | MapNavigatorInteractStart |
| 权威识别 | AutoCollectClick:OCR,ROI 803,350,263,219,95 条名字(简、繁、英、日、韩) | MapNavigatorInteract:OCR,ROI 755,330,297,312,expected 是占位文本,由路线注入 |
| 文字从哪来 | 节点自带的共用名字表 | 路线点上的 interact_text |
| 预筛 | 内置图标 RealTimeTask/AutoPick.png,ROI 取 AutoCollectClick 自己的,阈值 0.75 | MapNavigatorInteractScan 节点(同一张图标,755,330,297,312,0.75),可用 interact_scan 换 |
| 触发距离 | 离最近的同类点 ≤ 3.0,已走过的点也算 | 当前路点是本类点,且距离 ≤ 3.0 |
| 触发后路点 | 不动 | 推进 |
| 路线末尾宽限 | 有,1500 ms | 无 |
| 到点兜底 | 无(经过即推进) | 有 |
文字来源不同,是因为两类的识别对象不同。所有可采集物共用一张名字表,路线上没有可配的东西。交互提示随业务而变,所以路线要同时给出两样:停车后共用节点要认的文字,以及决定何时停车的预筛节点;按下去的始终是交互键。
MapNavigatorInteract 的 expected 是占位文本,运行时由路线点上的 interact_text 替换。没注入时占位文本谁都对不上,不会误按。
三端的按键
两个识别节点在桌面端都是 ClickKey 70(F 键,可被全局设置 KeymapInteract 改绑)。另外两端由资源覆盖:
| 端 | 动作 | ROI 变化 |
|---|---|---|
| Windows、Linux | ClickKey 70 | — |
| macOS | ClickKey 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 子任务。
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.png | AutoCollectClick 的 803,350,263,219 | 0.75 | 1200 ms |
| 交互 | MapNavigatorInteractScan 或 interact_scan 点名的节点 | 755,330,297,312 | 0.75 | 1200 ms |
| 挡路设备 | __MapNavigatorObstacleDevice_InteractPre / ObstacleDevice/InteractButton.png | 760,330,170,310 | 0.65 | 见搬走挡路设备 |
| 上索 | MapNavigatorZiplineMountScan / Zipline/InteractButton.png | 755,330,297,312 | 0.75 | 无 |
采集和交互只在路线里有本类点时启动,没有本类点的路线每刻只多一次布尔判断。启动时从识别节点读 ROI,读不到就不启动。上索扫描器在走到第一个 ZIPLINE 点时才创建,链走完就销毁,不再逐帧匹配。
每类只有一个扫描器:只有正在走向的那个点能触发,一个就够了。路点点名的预筛节点变了就重建,旧的命中标志随旧扫描器一起丢掉,属于上一个点的观测不会带到下一个点。预筛节点名为空时用出厂预筛;节点坏了也照样记下名字,否则每一刻都会重读一遍同一个坏节点。
扫描节点校验
预筛节点只被读参数,从不派发。TryLoadPromptScanProfile 逐项检查,任一条不过,点名它的那些点退化为到点识别:
| 检查 | 原因 |
|---|---|
| 识别类型是 TemplateMatch | 后台只实现了模板匹配 |
| ROI 是绝对坐标,且在 1280×720 内 | 预筛每帧独立跑,相对上一个节点的偏移 ROI 没有参照物 |
| 恰好一张模板、一个阈值 | 作者可以写单值,框架回吐时 template 与 threshold 总是数组;预筛只跑一张图,多给的当写错 |
| 阈值在 (0, 1] | — |
| 模板能在已加载的资源树里找到 | 找不到就没有可匹配的图 |
green_mask 是布尔值 | 纯绿像素排除在匹配之外,与 pipeline 的 TemplateMatch 判据相同;模板全不透明时按没有掩码处理 |
| 模板不大于 ROI | 这是 cv::matchTemplate 的硬前提,这里放过的话后台线程每帧都会踩上去,cpp-algo 里没有 try/catch 兜着 |
模板路径在当前控制器类型挂载的各资源根下查找。
行进中触发
每刻在到达判定之前调一次,要求起步位移已确认,采集先、交互后。函数本体:
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;- 节流:距上一次真正触发不足 1200 ms 直接返回。这是检测触发的唯一节流,时钟只在真正尝试时前进。节流判断在读标志之前,冷却期间的命中留在标志里。
- 触发距离:提示图标是全局的,路过任何可交互物都会弹,所以只在本类语义点附近才动手。两类都是 3.0,判法不同。INTERACT 一命中就把点算走完,所以还要求正在走向的就是这个点,蹭到别处的提示不会悄悄消掉它。COLLECT 不消费路点,按离最近的同类点算,已走过的点也算,因为提示常常在走过头之后才弹出来。3.0 小于滑索的走路进入带 5.0,由
static_assert锁住:滑索提前切走路时,提示触发仍然留在窄带里,否则路线上只要有一个这类点,沿途就可能在陌生的可交互物前停车。 - 读命中标志:带内带外都读,带外的命中当场作废。不作废的话,跨进带内的第一刻就会拿旧观测触发,而那时本点的提示还没弹出来。
- 停车识别:松前进键,等 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:
{
"MapNavigatorInteract": { "recognition": { "param": { "expected": ["物资调度终端", "物資調度終端"] } } },
"MapNavigatorInteractEnd": { "next": [] }
}子任务派发失败(比如正则写坏、框架拒绝 override)导航照常往下走。异步交互不会让导航失败,交互是否真的发生,需要外层 pipeline 自己核对。
预热与兜底
预热
OCR 模型第一次跑有冷启动。Run 在起步前、角色还站着时把每类提示的入口跑一遍,识别节点的动作换成 DoNothing,next 截断。这一趟只为付掉模型的冷启动,付在站着的时候,行进中的刻就不会被它拖慢。issue-6040 两趟的预热分别用了 239 ms 和 195 ms(从开始预热到扫描器启动)。
路线末尾宽限
最后一个 COLLECT 点被推进的那一刻,路线就结束了,扫描器随即销毁,走过头才弹出的提示没有机会触发。
TryRunPromptSubtaskAtRouteTail 在 Run 的循环结束之后补一个窗口:
- 条件:phase 为 Finished;某个共用表类的扫描器在跑;路线最后一个带坐标的点是这一类。
- 停车,等 150 ms。先站定再动手,还在滑行时按下去容易白按一次。
- 循环:读命中标志,命中就跑子任务后返回;没命中就截一帧定位,只为把新画面喂给检测器,等 33 ms。角色站在路线末尾,这个窗口是单独给的,不受 1200 ms 节流。
- 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-18 | COLLECT 从到点停车改为经过即推进;后台扫描器 CollectibleScanner;PositionProvider 加帧观察者;起步前预热;提示点附近压住冲刺 |
| #4975「移植卡死恢复阶梯前置搬走挡路设备」 | 2026-08-12 | collectible_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-23 | interact_rec |
DIG
DIG 是定点动作,按严判到点后停车执行,行进中不做检测。
ArriveDig 的步骤:
- 停车,置位镜头对齐。
- 没有
MaaContext时失败,原因dig_context_missing。 - 同步调起
AutoCollectDigStart,override 为{"AutoCollectDigEnd":{"next":[]}}。派发失败时失败,原因dig_dispatch_failed。 - 子任务返回后等 80 ms。
- 重置路线跟踪状态,以
dig_completed完成该点。
子任务本身很简单:
"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 没有坐标,执行时把镜头转到指定角度,再按前进键继续走。目标有两种写法:
{ "action": "HEADING", "angle": 90 }
{ "action": "HEADING", "target": [688, 350] }target 写法来自 #3040「HEADING target」,取当前位置指向该坐标的方位。角度的别名优先级见解析。
HEADING 在每刻的就地消费段消费(见每刻顺序),用一个 while 循环把连续的 HEADING 点在同一刻里吃完。每个点:
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° | 0 | 6 步 × 28.3°,每步 225 单位(1280 宽下 7.93 单位/度) |
| Linux | 18° | 16 ms | 10 步 × 17° |
| ADB | 90° | 0 | 2 步 × 85° |
三端的步间隔都落在 100 ms。拆步来自 #4091「HEADING 大角度转向按后端线性上限拆步执行」,在那之前大角度一次拖完,超出后端线性区的部分会转少。各后端的单位换算见度与单位。
前进脉冲。转完等 300 ms,按 270 ms 前进,再等 300 ms。只转镜头时角色朝向不变,走一步角色才转到镜头方向;末端纠正里转向之后不松前进键,也是这个原因。差角已在 1° 以内时也照样走这一下脉冲(#4091 加入)。
闭环复核。#3895「HEADING 校验及重试」加入,确认转向确实到位,视角拖动被吞掉时重做一次,容差放得很宽:
- 读稳定朝向:每 120 ms 读一帧,至多 4 帧,相邻两次读数差 ≤ 15° 才算稳定。两次一致的要求来自 #4091。
- 残差 ≤ 40° 接受。
- 否则按残差再走一遍转向、等待、脉冲、等待,最多 3 次;用完仍照样接受。
- 读不到稳定朝向,按已有结果收尾。
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 点名自己的节点。
两种形态
{ "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 每刻
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 --> G6bTickFindTarget 的完整顺序:
- 上下文:没有
MaaContext、会话或当前点时直接失败,原因find_context_missing。统一清理要用到会话,上下文不全时不走FailFind,直接交还失败。 - phase 对不上:当前点已经不是 FIND 时,清理后把 phase 交还给 Navigate,这一刻让给当前点自己的 phase。
- 预算:步数 ≥ 48 或已过 60000 ms,失败
find_budget_exhausted。然后步数加一。 - 判据缺失:
find_stop、find_arrive都没有,失败find_criteria_missing。解析阶段已经拦过,这里是运行期的保险。 - 截图:自己发一次截图并等结果,不读缓存,读缓存会拿到旧帧,FIND 就会照着过期画面走。截图发不出去或没等到结果时停车,空转这一刻,等 120 ms;连续失败由步数预算兜住。
- 停车判据:
find_stop命中就完成,原因find_target_reached。 - 到位坐标:写了
find_arrive才读定位,读数只用来算距离。距离 ≤ 2.0(沿用严判的判定圈)完成,原因find_arrive_reached。定位读不到或黑屏时这一刻不算数,下一步再读。 - 识别目标:
find_target,或注入了find_text的内置节点;两者都没有,失败find_target_missing。调用本身出错(节点不存在、取不到识别详情),失败find_recognition_failed。调用成功和命中分开判:节点写错或框架报错当场失败,免得被当成没看见、一直搜到预算用完。 - 没看到目标:先停车,免得带着上一刻的朝向继续往前冲,连续漏认计数加一。
- 见过目标、漏认不到 3 刻:站住等一刻。遮挡和识别抖动一两帧就把刚对准的镜头甩走,下一刻还得转回来。从没见过目标时不给宽限,直接搜。
- 否则原地转 30°(一圈 12 步)。第一步朝目标最后出现的那一侧转,没见过时向右(
search_sign初值 +1),之后保持同向;每步重新挑方向的话,正后方的目标会让镜头来回摆。转向发不出去,失败find_turn_failed。
- 看到了:漏认计数清零。框宽或框高 ≤ 0 时失败
find_empty_box:DirectHit 这类识别命中了却没有框,宽为零的框中心落在最左边,拿它当方向只会一直往左转。帧尺寸为 0 时停车空转。 - 接近:见下一小节。
- 停车探测:前进中密集探测
find_stop,见下文。 - 等 120 ms。
视觉伺服接近
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;
}
每刻按新框修正一次,对准时移动不停,偏了转视角,走过了退一步。① 把 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 在按着前进时把它盯密:
- 窗口 700 ms,每 120 ms 自己截一帧(
kFindStopProbeWindowMs、kFindStopProbeIntervalMs)。截图失败就收手,下一刻再盯。 - 有模板预筛时,先在这一帧上跑
MatchTemplateOnFrame:裁出 ROI,归一化回作者 ROI 的尺寸,跑后台扫描器那个匹配函数。两边共用一个函数,判据不会漂移。没命中就等 120 ms 继续。 - 预筛命中(或者没有预筛):松前进,等 150 ms,在同一帧上跑
find_stop的权威识别。权威确认也要时间,先站住再确认,免得边走边确认多滑出去一段。 - 确认命中:完成,原因
find_target_reached。 - 没确认,算预筛误报:重新按前进,等 120 ms 继续探测。
没有预筛的停车判据(OCR 等)在第 3 步每次都会先停车 150 ms 再识别,接近段因此走走停停。判据能写成 TemplateMatch 时,大部分探测只付毫秒级的模板匹配。
FIND 退出
完成和失败都经过 LeaveFindPhase:松前进,清掉操舵辅助状态、前进保持计数、定位追踪、路点进度和 FIND 状态。FIND 这一段没读地图,转视角都是开环发的,留下的操舵记账、走廊和速度样本都已过期,下一段会拿旧朝向去操舵。
FIND 结束时角色可能站在路网之外、朝向任意。文档建议在 FIND 后接一个普通移动点,让下一段重新定位;FIND 也可以作为路线终点,完成后路线结束。
FIND 失败
| 原因 | 触发 |
|---|---|
find_context_missing | 没有 MaaContext、会话或当前点 |
find_budget_exhausted | 48 步或 60000 ms 用完 |
find_criteria_missing | find_stop、find_arrive 都没有 |
find_target_missing | find_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_missing | WaitTrigger 里没有 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.h | 1642 |
两套坐标同时出现。滑索记录用森空岛的世界坐标,单位 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 |
|---|---|---|
| Windows | C++,WebView2,经 DevTools 协议监听网络响应并关闭缓存 | — |
| macOS | C++,WKWebView 窗口 | #6088「macOS 用 WKWebView 登录窗口导入滑索」 |
| Linux | Go,临时 profile 启动 Firefox,经本地受限代理抄响应 | #5610「滑索导入支持 linux」 |
Android 上注册了同名动作 ZiplineImport,只提示「Android 端暂不支持导入/更新滑索坐标」并返回失败(#6162「Android 上导入滑索坐标改为提示暂不支持」)。导入要开窗口让用户自己登录,设备上的 agent 是没有界面的子进程,桌面端的两套窗口都用不了;任务入口与桌面 ADB 共用同一个控制器,没法单独对 Android 隐藏,所以用一个同名动作把原因告诉用户。
Linux 的代理只解密提供标记接口的那一个 API 主机。一个本机 PAC 只把这个主机指向代理,登录和其他站点的连接一律直连;根证书一次性生成。登录态留在浏览器里,带签名的 mark/list 请求也由浏览器自己发出,代理只抄下响应体,凭据和签名都不经过导入代码。
三个平台的收工条件相同:
- 所有标定过的地图都抓到后,再静默 1200 ms 收工,给同一批次里最后几条响应留点余量。
- 抓到一部分、凑不齐标定过的地图时,静默 20 s 收工。
- 窗口最多开 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 m | 3×3 |
| 长距滑索架 | 110 m | 3×3 |
| 供电结构 | 判据 | 参数 |
|---|---|---|
| 供电桩、息壤供电桩、供电终端 | 直接连线 | 水平距离 ≤ 30 m |
| 中继器、息壤中继器 | 工厂网格覆盖 | 占地 3×3,覆盖 7×7 |
最后是代价模型的五个参数:
| 字段 | 值 | 含义 |
|---|---|---|
| speed_ratio | 0.35 | 滑索约比跑步快三倍,是个保守估计,待实测收紧 |
| mount_penalty | 25 | 一次上索的开销,含转向对准、交互、起步,同样待实测 |
| transfer_penalty | 10 | 链中换乘的开销。落点就是下一根架子,人不用走过去也不用重新起步,只剩重新对准和交互,所以比上索便宜 |
| min_gain | 25 | 收益门槛。省下的不到一次上索的开销时,这点便宜落在上面几个估计值的误差里,换来的却是多一次交互、多一处可能失败的地方 |
| reach_radius | 6 | 两端架子到可走面的吸附距离上限。链中间的落点不受此限,人从索上落到下一根架子上,脚下有没有可走面都站得住 |
代价全部折成步行像素:比较滑索和步行的耗时,判据两边同乘走路速度,时间就换成了像素,与 NavMesh 的路径长度同单位,因此用不着知道走路的绝对速度。mount_penalty 25 表示上一次索的耗时折合 25 像素步行,与路径长度直接相加。
角格锚点
森空岛给的坐标是架子占地的一个角格,哪个角随朝向变,响应里又没有朝向。实测同一台 3×3 中继器原地旋转后,坐标沿一条轴跳 2 格,正好是占地边长减一(见供电判定的摆放实验)。单个标记还原不出唯一的中心。
所以每根 3×3 架子按 4 个可能的塔心算。记标记的格坐标为 ,占地边长为 ,占地半宽 (3×3 时 ):
加 0.5 是从格的角落移到格的中心,两个 组合出 4 个塔心。
两根架子能不能挂索,先用一个下界筛。记两个标记的坐标差为 ( 是高度),两根架子的占地半宽为 、。双方塔心在每条水平轴上都可能朝对方靠近各自的占地半宽,合计最多靠近 (3×3 对 3×3 时为 2 m);高度不受朝向影响:
超过索长上限的平方,这一对就不可能挂索。比较平方值省掉一次开方。
左上是一根 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 架子时 ,,右边是 。
俯视,一格是一个工厂格。左图是中心可达:覆盖范围的边缘离中继器中心 3 格,架子的边缘离它自己的中心 1 格,两个中心差 4 时,架子还有一列压在覆盖里。右图是同一个摆放,两边的标记都可能落在各自占地的 4 个角格上;中继器的标记偏到左边、架子的标记偏到右边时,标记的轴差就是 。
判据取的是任一可能朝向下能重合就保留。森空岛给出朝向之前,宁可在边界上多留几根其实没通电的架子,也要保证通电的架子进得了规划图。
边界用三条 static_assert 钉住:
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 供电桩 | 同一轴相差 2 | 1 世界单位 = 1 工厂格 |
| 3×3 滑索架与 3×3 中继器紧贴 | 轴差 (5, 2) | 标记点不在统一的中心上 |
| 中继器原地旋转 | X 变 2,轴差变成 (3, 2) | 标记点随朝向在角格间切换 |
| 设备从台面搬到地面 | 相对差仍是 (5, 2) | 偏移与高度无关 |
(a) 到 (c) 是表里前三个实验的俯视图,标记所在的角格按实测轴差画了一种相符的摆法。(b) 和 (c) 是同一个摆放,只把中继器转了个向,标记的 x 轴差就从 5 变成 3。旧判定把标记当中心、要求轴差 ≤ 4,结论跟着翻转。(d) 把两次实测画在标记轴差平面上:新判据的框放宽到 6,两次都保留;(6, 6) 和 (0, 7) 是上面 static_assert 钉住的两个边界点。
PR 正文给出的效果:放宽之后,map01 比旧判定多保留 5 根滑索,map02 多保留 7 根;同时写明自动化检查无法验证这些新增候选是否全部实际通电。一张图上一个供电结构都没记录时,这张图的架子全部算不通电。
规划早退
滑索规划的入口是 PlanZiplineRoute。每条步行腿规划完之后调一次(带步行基线),步行规划失败时也调一次(不带基线,尝试桥接)。下面任何一条成立就返回空,这条腿照常走路:
- 路线没开滑索。这是第一条检查,没开滑索的请求到这里就结束,不读标定和记录,也不多跑规划。
- 账号认不出。
- 没有标定文件,或当前区没标定。
- 通电架子少于 2 根。
- 有步行基线,且步行长度 ≤ 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:
- 两根必须出自同一张图,两边的索长上限都 > 0(没登记物理属性的模板上限为 0,不参与配对)。上限取两者较小的那个。
- 用角格锚点的下界剪枝。各轴取最近那一对塔心都够不着的,逐组量也够不着,这一步省下内层的逐组循环。
- 两端各取 4 个绳端,两两配成最多 16 组,逐组量三维长度。有一组 ≤ 上限,这条边就进
all;在上限以内的那几组连线留给遮挡测试。 - 再问拦路架。被拦的边留在
all,记下垂距;没被拦的进入待测列表,等绳端与遮挡决定能否进certain。
配对不看架子类型和所在区域,这是 #5972「滑索按碰撞遮挡体判断索是否被挡」放开的。在那之前配对要求同类型、同区域,一批实际挂着索的架子因此连不上;这个 PR 一次关掉了 15 个 issue。
拦路架
一根长索的中段如果立着另一根通电架子,人可能滑到中间那根上就停下。#5618「滑索规划剔除拦路与地形阻挡的索」加入了这条规则,#6014「滑索绳端按塔落地高度算,拦路架按三维垂距量」把垂距改成三维。中段立着另一根架子的直索,实际形态可能是经过中间那根的两跳链,所以降到不确定档。拦路架要离两端各超过一个拦截半径,离端点更近的架子就当作端点本身;它与 from 之间也要挂得上索,从 from 够不到的架子接不住滑过来的人。
判定过程,距离单位 m。记索的起端架子(from)为 、末端架子(to)为 ,都是三维标记坐标:
-
索长 (
span)。 的短索不判。 -
索的单位方向 。对同一张图上的其他通电架子 ,算它沿索的位置(
along),要求离两端都超过 15: -
从 里减掉沿索的分量,剩下的就是垂直于索的分量,它的长度是三维垂距(
lateral),要求不超过 15: -
与 之间也要挂得上索(用同一个下界)。
-
第一根满足条件的 让这条边降档。
阈值 15 来自实测。三次滑行终止在中间架子上,拦路架垂距 12.51、10.23、14.99 m;另有一次中段立着垂距 4.55 m 的架子,人却整条滑完了。单靠距离分不开这四例,所以这条规则只降档不删边,阈值取到刚好盖住已知被拦的 14.99。垂距按三维量:平面上离索线 8.78 m、高出 55 m 的那根架子实测拦不住人,只量水平会把这类高出索线几十米的架子也算进来。
左图把判定区画在一条索的侧面:两端各 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 种组合里,判宽了执行侧会删边重规划,判严了能用的索会被压到不确定档。每条边只测一次,两个方向一起定。
俯视示意,一格 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(旧版) | |
|---|---|---|---|
| 不通电 | 59 | 44 | 97 |
| 通电节点 | 242 | 49 | 170 |
| out_of_reach / wrong_floor | 2 / 3 | 0 / 1 | 11 / 3 |
| cut_off | 150 | 25 | 102 |
| 拦路降档 | 19(1.21–13.37 m) | 16(0.46–13.28 m) | 21(0.89–14.74 m) |
| 遮挡降档 | 95 / 381 | 27 / 81 | 地形抬升 14 |
all 边数 | 400 | 97 | 270 |
certain 边数 | 286 | 54 | — |
表里几个数之间的关系:
#6177 是 ,。#5902 的版本还在用地形抬升检查。
剔除失败的索
执行侧每一跳的结局记在逐跳账本里(见逐跳账本)。重规划时,账本里的索失败从两张图里删边。索不分上下行,一根滑不动的索反着大概率也滑不动,所以两个方向一起删。
索失败指 NoLaunch(滑不动)、Lost(落地丢了)、WrongRope(滑错)三种结局。一条边两端分别落在记录的上索点和落点 10 像素以内(两种方向都算),这条边就删掉。回程失败时账本另记一条回程索(见逐跳账本),删边规则对它同样适用。链首那一跳记录的是上索站位,和架子坐标差着供电桩让位的那一点距离,半径太紧会删不掉;太松会顺带罚掉旁边平行的另一根索,代价只是少一条捷径。
NotMounted(按了上索键没站上去)和 Dismounted(主动下索)不删边;Unboardable 只取消那根架子的上索资格。
链代价
从每根能上索的架子出发,在图上跑 Dijkstra[6],求沿索到每根架子最省的滑法,到不了的架子留无穷大。记 speed_ratio 为 ,transfer_penalty 为 ,mount_penalty 为 。一跳两端在像素平面上相距 ,边权里每一跳都含一次换乘开销:
判连通用世界距离,算代价用像素距离,因为代价要和 NavMesh 的路径长度加在一起比较。
一条 跳的链,各跳的像素距离为 ,滑索部分的代价(zip_cost):
链头那一跳是上索,调用方减掉一次 ,换成 。整个方案的代价再加上两段步行,approach 长 ,departure 长 :
采用条件是 ,门槛 见规划早退:
跳数不设上限。每次换乘都收 transfer_penalty,划不来的长链在代价比较里自己就被淘汰了。
候选的生成要用到两组下界。步行再怎么绕也不会比直线短,所以直线距离就是步行长度的下界: 是起点到架子 各上索站点的最短直线距离, 是架子 各落地站点到终点的最短直线距离。
-
的架子整个跳过:就算整段滑行白送,从它起头也达不到收益门槛,从它起头的链都不必算。
-
对每根能上索的 ,分别在
certain和all上跑 Dijkstra。 -
对每根能落地的 ,算这对组合的代价下界(
lower_bound), 的丢掉: -
certain上到得了的记一条候选;all上的链只有严格更便宜时再记一条不确定候选。哪档先用由两趟评估决定:确定边能到达就用确定边,即使它比不确定的直索多几跳、代价更高。不确定的索可能根本不存在,推断错了要整段重新规划,比多出的那几跳贵。
候选按 lower_bound 升序排好,进入评估。
两趟评估
候选的下界只用了欧氏距离,真实的 approach 和 departure 要跑 NavMesh 规划才知道。每条腿一次规划,耗时百毫秒到一秒多,预算是硬约束。候选是成对的,不设上限的话,滑索密集的地图会把规划耗时抬高一个量级;触顶后只拿已经算出来的候选做决策。
kMaxExtraPlans = 12,approach 和 departure 两个缓存共用。缓存按站点下标存:同一个站点会被多个候选对共用,按下标缓存就不会重复规划同一段路。规划失败的结果也存,下次直接跳过。一根架子的几个站点按顺序规划,第一个通的就用。
evaluate(want_certain) 的过程,best 初值为门槛:
- 候选的
best时停止。候选按下界升序排,当前下界都追不上最好成绩,后面的更追不上。 - 规划 approach 腿。预算耗尽记
truncated并停止。 - 用 approach 的真实长度 替换起点下界再剪一次,
best的候选剪掉,省下 departure 的规划。 - 规划 departure 腿。
-
best时更新最优。
先跑 evaluate(true) 只比确定候选,没有解再跑 evaluate(false)。代价比较会让便宜的不确定直索赢过确定链,单靠排序做不到确定优先,所以把两档隔开比。同一趟内候选仍按下界升序。
第一趟耗尽预算后第二趟照跑:缓存里已经规划好的腿可以直接用,碰到第一个没缓存的架子照旧记 truncated 退出,多跑这一趟不额外花预算。没评完又没找到解时返回空,这条腿照常走路。
两段腿的高度都按架子定。approach 的终点钉在架子所在那一层;人站在架子上换路时,approach 的起点也钉在脚下那根架子的高度上,见落地后的起点层。departure 的起点楼层取架子高度:人是从索上落下来的,站在哪一层已知,起点吸附优先挑这一层。departure 的终点面用调用方给的那个,与纯走路方案相同:换的只是走法,目的地不变。
滑索规划结果
选中的候选只存了首尾两根架子。规划器用同一张图(确定或不确定)重跑一次 Dijkstra,还原整条链。候选是成对枚举的,给每个候选存整条链是浪费,所以等选定之后才还原。再为每根架子列出同图里除下一跳之外的邻居,执行侧落地分类时用它们认出滑错到了哪根。
链首要按提示上索,另算一组站位 MountSpots。坐标记的是随朝向变化的角格锚点,设备模型占着锚点四周哪几格未知;交互要人面朝设备且身位与模型有交集,走到锚点上两条都未必成立,所以把各个可能的中心格排成候选逐个试。候选停在中心格这一深度:行进中的提示预筛按到航点的距离开窗,目标越往模型里推,身位被挡住那一刻离航点越远,越容易落在窗外。顺着进场方向的排前面,少一次掉头。
-
4 个塔心各自投影到像素平面,在 2 像素内吸附到同一层的可走面,偏差 ≤ 1 像素才留下。
-
记锚点为 、进场方向为 ,塔心 按 降序排列,越靠进场方向前方的越先试。进场方向取最后一段走路;起点已经站在架子跟前时进场方向为零,排序退化成固定顺序。
-
一个都留不下时退回锚点。
-
5 像素内有供电结构时,追加一个让位站点:记架子坐标为 、供电结构为 ,沿 指向 的方向外移 0.75 像素,同样要吸附得上:
让位站点针对一种具体的失败:游戏的交互面板只给离身位最近的那台设备,供电桩紧贴着架子时,面板给的是供电桩。外移量要同时满足两头:架子得是离身位最近的设备,人也得还够得着架子。让开量加上判定圈要留在够得着的范围里,所以只让开判定圈的零头 0.75 像素;让多了人被推出去,面板一样不出来。
#6104 那次规划里,链首架子 (921.09, 293.91) 旁 4.50 像素有供电结构,让位站点是 (920.51, 293.44),站位共 5 个。
左图中 是锚点, 是最后一段走路的方向。点线垂直于 ,同一条点线上各点的 相等,塔心按所在点线沿 的先后排序。右图是上面那根 #6104 链首架子,像素坐标:供电结构离架子 4.50 像素,在 5 像素圈内,让位站点沿 指向 的方向外移 0.75。
然后 TryAppendZiplineLeg 把结果铺成航点。有步行路线时,滑索只在严格更省时顶替走路;没有步行路线时,滑索尝试桥接不连通的起终两侧。两种情况都不改目的地和到达声明,终点面仍钉在末点上。
- 先铺 approach 的走路点。
- 每跳一个 ZIPLINE 航点,带严判到点,目标层是这一跳起点架子的高度。头一跳的坐标用第一个站位;架子坐标是角格锚点,走到它跟前常常碰不到设备模型。
- 跳与跳之间不插走路点:中途落在下一根架子上时,人就站在下一跳的上索点上。
- 每跳写入规划仰角 , 是高差,按世界坐标算。像素平面的 x/y 按地图比例缩放过,和高度不同尺,混着算出的角没有意义。
- 站位表只挂在头一跳。
chain_continues标记落点之后是否还有下一跳。 - 最后铺 departure;departure 为空时补一个 RUN 到达点,让这一腿仍以目标点收尾。
两个调用点里,桥接模式排在 NavMesh 兜底链的前面。纯步行不连通时,连续滑索仍可能分别接上起终两侧的可走面。桥接出来的是完整规划结果,优先级高于二维盲走和作者提示回退,整条连续链也不会被提示点切碎。
滑索全局规划由 #5197「供能高地路线优化与滑索全局规划」引入:开启滑索后,作者路线里的可选点降为提示,每个分段的起终点之间只规划一次,步行和滑索在同一个比较里竞争。
规划实测
| #6177 | #6104 | #5902(旧版) | |
|---|---|---|---|
| 模式 | 桥接 | 桥接 | 有基线 |
| 步行基线 | — | — | 888.18 |
| 选中代价 | 215.37 | 287.01 | 365.10 |
| 架子数 | 7 | 6 | 11 |
#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:
- 航点没有这一跳的计划时放弃整条链,原因
zipline_hop_missing。这一跳的计划是规划器算好写进航点的,手写路线写不出来;缺了就没有可对准的方向,直接当这根索不存在。 - 人站在另一根架子上时先下来。留在上面瞄,发射的是那根架子上的索,滑到哪都不算数,还会往账本里给本来没问题的索记一笔失败。
- 没在架上时跑子任务
MapNavigatorZiplineMountStart:等提示区域稳定 300 ms,用模板匹配找「登上滑索架」那一行的交互图标(MapNavigator/Zipline/InteractButton.png,阈值 0.9),找到后再匹配一次,按住 Alt 点击图标(AutoAltClickAction)。第一次没找到时等 300 ms 复核一次。这枚图标只挂在「登上滑索架」这一行,各语言客户端里都一样,f494516「上索改为匹配并点击登上滑索架图标」 把这一步从识别文字改成了匹配图标。 - 子任务认不出:这一次是预筛叫停的,说明人还没走到,预筛命中的图标属于别的设备,当没发生,接着走;是到点触发的,换下一个站位。
- 认出并按下后,交给滑索子状态机的
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 为这段延迟预留时间。判定窗口要盖住提示从按键到第一帧读得出的滞后,余量不够的话,已经上架的人会被误判,按下索键弄下架子。
| 常量 | 值 |
|---|---|
| 地面读数不采信的 settle | 600 ms |
| 判定窗口 | 4000 ms |
| 地面态连续帧数 | 2 |
| 重按预算 | 2 |
| 判为在走动的帧间位移 | ≥ 3.0 |
TickMounting 每刻的判定顺序:
- 相邻两帧位移 ≥ 3.0 就当在地面走动,直接判 OnGround,省掉两次识别。
- 读到操作引导:确认上架,进入 OnTower。
- 一直在走动:窗口内继续等,不重按。带着惯性发出的交互正是这一跳落空的原因,这时重按同样不会生效。窗口耗满记 NotMounted,交回导航换站位:一路都在动说明人没被架子锁住,而这个 phase 外头没有 watchdog,只能自己收场。
- settle 之后连续 2 帧地面态:重按。
- 窗口内其余情况继续等。
- 窗口耗满、读不出、也没在动:当作人已经上架、只是提示漏读,记 NotMounted,按下索键回到地面,再请求重规划。
- 窗口耗满、在地面:重按。
第 6 条先下索再重规划,是为了回到一个判得清的地面态,避免在位置没定的状态下继续瞄准和发射。
位移只用来否决:角色被架子锁住时动不了,能动就说明在地面;零位移两种情况都可能,单靠它判不了。
判定没上架之后,第一级处置是重按上索键。重按前有两道防护,避免对已经上架的角色重按:地面态必须由 InWorld 命中给出,而且要再认出一次架子的交互提示才发按键。重按预算用完仍没上架,才交回导航调整站位后重来。
这套判定经过两次按实机日志的调整:#5655「按实机日志核准起滑确认时间与上索确认窗口」,#5665「上索确认放宽到架上任一提示片段读到一帧即认」。
滑索子状态机
一跳的执行交给 ZiplineRideMachine,由 #5585「滑索子状态机与逐跳账本」从原来的一串条件分支改写而来。导航每刻调一次 TickZiplineRide:松前进键,调状态机,没有出口事件就睡 150 ms。实际一刻是 150 ms 加截图定位的耗时,见每刻耗时。
状态机由两样东西驱动:每刻一次的定位,和同步的输入动作。定位时把这一跳可能落到的架子都交给定位器当搜索先验。落地那一帧是冷启动:挂对了落在第一个先验上,挂错了落在其中一个上,空响没滑走就还在上索点,具体落在哪由匹配分决定。输入动作同步发出、发完即回,转没转到位交给后面的观测判断。
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 先对准横扫起点、再转回停点,两段画成了一条边。
| 阶段 | 进入 | 出口 | 时限 |
|---|---|---|---|
| Mounting | Begin 时不在架上;重按 | 见上架判定 | 窗口 4000 ms |
| OnTower | 上架确认;Begin 时已在架上;分类后换档、收偏置、重试或回程 | 俯仰复位,进 Aiming 或 ReturnAiming;复位失败 → 下索、丢链 | — |
| Aiming / ReturnAiming | OnTower | 残差 ≤ 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 → Failed | 2000 ms × 2 / × 4 |
| Handoff / Failed | — | 交出出口事件,清空这一跳,只留账本和人还站在哪根架子上 | — |
几个阶段的含义:Fired 是已经按了左键,等定位断掉(滑出去了)或位置离开起点;Riding 是滑行中小地图整个隐藏、定位不到,只能等;ReturnAiming 是滑错之后从当前架子瞄回上索架。
Begin 区分两种起点:链中续跳和滑回原架时角色已在架上,直接进入瞄准;其余情况是调用方刚按了上索键,要先确认上架。同一跳中途下来、换站位后再回来,算同一跳续写,记录接着写;换了跳就把上一跳按 Dismounted 结账。
瞄准与俯仰
瞄准分偏航和俯仰。偏航有朝向读数,闭环;俯仰读不回来,开环。
偏航
TickAiming 每刻:
-
超过 6000 ms 判失败。
-
上一批转向发出后等 300 ms。
-
连续两帧朝向相差 ≤ 15° 才采信。
-
记当前位置指向目标架子的方位为 ,瞄准偏置为 (回程为 0)。目标朝向
第一种情况往偏置同侧多加 15°,对准的是横扫起点,见下文的瞄准偏置。
-
残差 > 6° 时只发一个后端批次,回到第 2 步,剩下的角度下一刻按新的朝向读数重算。
-
残差 ≤ 6°:这一轮还没调过俯仰时先调俯仰。有横扫时这一刻到此为止,6 s 计时和朝向采信都从头开始,回到第 3 步对准停点,残差 ≤ 6° 后直接左键;没有横扫时调完俯仰就左键。
6° 的容差比走路紧得多。按下左键人就滑走了,半空里没有走廊跟随能把方向修回来,瞄准精度只能在按左键之前保证;走路能容忍 40° 的误差,是因为后面有跟随层纠正。
6 s 超时同时覆盖上架后的稳定等待和全部水平修正;有横扫时,转回停点那一段另起 6 s。每次只发一个批次并等真实反馈,大角度转向因此不会在上索动画还没结束时一次排进多条输入。
俯仰
俯仰没有读数,每次进入 OnTower 先把镜头拉到天空方向的硬限位,从这个已知位置开环往下调:
是目标俯仰(仰为正), 是复位之后还要转的俯仰角。
游戏的俯仰范围是仰 90°、俯 60°,多发的 30° 用来盖住灵敏度取整和游戏内的输入损耗,镜头撞到限位后就不再转。复位后的镜头位置记作最大仰角。桌面端的复位走 pipeline 节点 MapNavigatorZiplinePitchReset(相对鼠标移动,dy 由代码按分辨率覆盖);单独用一个节点发相对鼠标移动,是因为 Swipe 会带一次左键按下和抬起,而在架上左键就是起滑。
触屏后端直接发视角拖动。俯仰每度单位数,桌面是 ( 时 8.92),ADB 见触屏后端,两端都乘灵敏度系数。
目标俯仰按尝试次数取。俯仰读不回来;下降索按规划仰角朝下瞄已经验证能发出去,上升索的正仰角还没核实过。所以第一次按规划仰角瞄,第二次反过来,第三次干脆不动俯仰,三次里总有一次落在对的那一侧:
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° 的容差本身就分得开。没滑出时先收偏置:偏置可能把准星推出了目标索能挂上的范围,收回一半再按,收不动了才去换俯仰档。
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 帧稳定,分类用的是之后停稳的那一帧。
滑索实测
#6104 日志 06:41 那一趟(2026-09-30)。左图是地图:红线是步行,黄线是 5 跳滑索,圆圈里是跳号,与下表对应。右图把每跳按阶段拆开,各跳从自己的开始时刻对齐:
- 灰色是上架和俯仰复位,只有第 1 跳有;后面几跳人已经在架上,沿用记录的俯仰。
- 黄色是瞄准,▼ 是一批偏航。第 1 跳要转 −170°,每批最多 30°,分 6 批;后面几跳转得少,1 到 2 批。
- 红色是最后一批转完到小地图隐藏:确认残差 ≤ 6°,调俯仰,按左键起滑。
- 蓝色是滑行,小地图隐藏,读不出位置。这段大体随索长变长:第 5 跳那根水平约 111 m,滑了 8.9 s。
- 绿色是定位回来到停稳。
| 跳 | 开始(s) | 落地(s) | 用时(s) | 规划仰角 | 落点误差(像素) |
|---|---|---|---|---|---|
| 1 | 50.96 | 64.93 | 14.0(含上架) | 3.61° | 1.0 |
| 2 | 64.93 | 74.03 | 9.1 | −7.01° | 0.4 |
| 3 | 74.03 | 84.75 | 10.7 | −5.90° | 1.7 |
| 4 | 84.75 | 96.19 | 11.4 | 0.83° | 0.1 |
| 5 | 96.19 | 111.15 | 15.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 s | 6 批偏航,残差 160.4° → 131.6 → 103.8 → 71.9 → 41.9 → 12.9,每批最多 30° |
| 起滑判定 | 0.9 s | 从按下左键到确认在滑行 |
| 滑行 | 2.4 s | 从确认在滑行到重新拿到定位,2377 ms |
| 停稳 | 0.8 s | 4 帧 |
整条链 102.6 s,10 跳都一次成功。fired 到 riding 稳定在 868 到 964 ms,即连丢 3 帧定位的时间;滑行 2.4 到 6.5 s。按滑行距离和用时推算,滑速约 13.5 m/s(含识别滞后)。
落地分类
停稳之后,ClassifyLanding 把最后一帧定位归到五类之一:
| 类别 | 条件 | 含义 |
|---|---|---|
| AtTarget | 6 像素内最近的已知节点是这次发射的目标 | 到了。首发和重试的目标是规划落点,回程的目标是上索架 |
| AtOrigin | 最近节点是起点 | 还在起点,没发出去 |
| AtOther | 最近节点是别的已知架子 | 滑错索了 |
| AtStrayTower | 6 像素内没有节点,但滑行发生过 | 停稳在一根没登记的架子上 |
| 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),用完就站在架子上等换路。
滑错索的一次完整过程
#6177 日志,一条链的第 2 跳,一步一帧。左图是地图:A 是上索架,B 是规划落点,C 是实际落到的架子,黄圈是人所在的位置;白色虚线是规划的这一跳,红线是去程,蓝线是回程,绿线是步行。右边是这一步发生了什么,以及重试次数、账本和图里 A–B 这条边的变化。底部时间轴从这一跳开始计时,框出当前这一步:
- 规划从 A (1200.75, 1686.75) 滑到 B (1203.75, 1647.75),规划仰角 −11.91°,在死区之外。
- 去程 1 落到 C (1211.46, 1634.58),离 B 约 15.3 像素,超出落点圈;C 不在已知节点里,分类为 AtStrayTower,记成新架子。C 相对 A 的方位和规划方向差约 7°。
- 回程 1 回到 A,分类 AtTarget(回程的目标是上索架),记下错索方位,重试 1。
- 去程 2:错索与规划差 7° < 12°,瞄准偏置 −3°。仍落到 C,这次 C 已是已知节点,分类 AtOther。滑行中出现一帧假定位 (998.49, 1574.54),被停稳要求吸收。
- 回程 2,重试 2。
- 去程 3 同样偏 −3°,还是落到 C。
- 回程 3 回到 A,重试用完,记 WrongRope,留在架上请求重规划。账本这一跳 6 次发射、3 个错索方位。
- 第 61.9 s 放弃这一跳,人留在架上,从当前位置恢复,丢掉剩余 5 个航点,388 ms 内拿到 3 帧稳定定位。重规划时账本剔除 A→B 这一跳(账本 2 条,删 1 条边),新路线用 5 根架子,首跳从 B 起滑。
- 新路线用不上脚下的 A,人下架走到 B,上索预筛一路开着。
- 第 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 分别处理:
| 事件 | 导航侧处理 |
|---|---|
| HopCompleted | FinishHop:推进航点;下一跳从脚下这根架子出发就直接开跳,中间夹着的走廊点一并跳过;否则下索走路 |
| ChainAbandoned | AbandonZipline:下索,丢链,恢复 |
| ReplanRequested | 丢链,恢复;still_on_tower 为真时留在架上,并记下脚下是哪根架子 |
| NeedsReposition | AdvanceMountSpot:换下一个站位 |
FinishHop 直接记为已起步(见起步确认)。滑行本身就是一次实打实的位移,落点也经过连续几帧稳定定位确认,没有必要再等一次起步确认;链上的下一跳上索点就在脚下,拦住就等于要人先走开再走回来。
所有异常出口都进 DropChainAndRecover。滑索是捷径,走不成就丢掉剩余的链,重新接回后续路线;要是人挂在索上或卡在架子边上就直接结束导航,角色只会原地不动到超时。
- 停车。不留在架上时先下索。
- 跳过剩余的走廊点,再丢掉紧跟其后的所有 ZIPLINE 航点。整条链一起丢:留下任何一跳,重规划都会把人送回索边再试一次,而刚失败的正是这条索。走廊点后面先碰到别的语义点时一个都不丢,最后一跳出事时就是这样,剩下的路本来就是走路。
- 留在架上时用
NoteStandingTower记下脚下这根架子,见落地后的起点层。 - 重置跟踪和进度,开始滑索恢复,并请求动态重规划。
滑索恢复
HandleZiplineRecoveryReplan 在恢复期间每刻运行:
- 停车。超过 6000 ms 以
zipline_recovery_localization_timeout失败导航。 - 第一帧强制全局定位。
- 连续 3 帧定位彼此在 3.0 以内才算稳定,拿不到定位时隔 120 ms 再试。站在架上时脚下是架子,不要求贴回 NavMesh;不在架上时,贴不回可走面的定位也接受。这种定位说明落点不在路面上(落到未登记的架子、崖边没铺面的地方),坐标本身仍然有效,重展开时规划会吸附回最近的可走面。
- 用剩余的作者路线从当前位置重新展开,带上账本。原来的剩余展开是按从链尾落点出发算的,人没到那个点时它和当前位置无关:可接入点可能在另一侧,沿途会撞上原本要用索越过的障碍。从当前位置重新展开,判死的那一跳已记进账本,规划会绕开它另选链路。人还在架上或刚落到架子附近时,起点钉在那根架子的高度上,见落地后的起点层。展开成功且人还在架上时,新路线开头若是几个走向上索站位的走廊点、后面紧跟从脚下这根架子起滑的一跳,就跳过这些走廊点留在架上;否则先下索。重展开从当前位置出发,会铺一段走到上索站位的路,人已经站在这根架子上,照着走就得先下来再上去。
- 人在架上而重展开失败:先下索,恢复从头开始,6 s 计时和稳定计数都清零。下来之后这一轮按不在架上的规则走,重展开再失败还有可达锚点扫描。
- 重展开失败,且人不在架上、位置在可走面上时,退回旧的展开,在里面做可达锚点扫描(见可达锚点),旧展开至少是一条经过规划的路径。扫描的串行 A* 预算是 8 次:每次不可达都要跑满一次秒级的搜索,曾经出现过 34 次连败、额外卡了约 70 s;预算用完就放弃这轮扫描,交给下一级回退。
- 都失败:以
zipline_recovery_route_unavailable失败导航。 - 成功后人还在架上,说明新路线的首跳就从脚下起滑,直接开跳,省掉走过去按上索这一步。
落地后的起点层
滑索架的台面和台下的地面在底图上重叠。人刚落到架子上、或留在架上等换路时,起点吸附只看楼层高度,可能吸到台下那一层,规划出的路线从台下起步。#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-26 | 4 个可能塔心,配对下界 |
| #5337「提前切换步行接近滑索」 | 08-30 | 滑索用更宽的步行带 |
| #5559「滑索落地定位以规划落点为先验参与比分」 | 09-06 | 落地定位以规划落点为先验 |
| #5340「按账号隔离滑索数据」 | 09-08 | 账号标识 |
| #5585「滑索子状态机与逐跳账本」 | 09-08 | 滑索子状态机、逐跳账本 |
| #5610「滑索导入支持 linux」 | 09-09 | Linux 导入 |
| #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-28 | Fired 强制全局搜索 |
| #6084「ADB 后端后端开放滑索」 | 09-29 | 触屏按钮、俯仰换算 |
| #6088「macOS 用 WKWebView 登录窗口导入滑索」 | 09-29 | macOS 导入 |
| #6138「滑索失败后留在架上重规划」 | 10-01 | 回程失败留在架上并记回程索;续跳跳过接近段;架上重展开失败先下来再试 |
| #6146「滑索瞄准去掉单批转向比值,俯仰改乘灵敏度判定倍率」 | 10-01 | 去掉首批实测的转向增益;俯仰乘灵敏度系数 |
| #6162「Android 上导入滑索坐标改为提示暂不支持」 | 10-02 | Android 导入提示 |
| 35d1f74「滑索锁错后侧扫重瞄」 | 10-03 | 按邻居算偏置、逐次加减、外侧横扫 |
| f494516「上索改为匹配并点击登上滑索架图标」 | 10-03 | 上索匹配图标 |
| #6205「滑索落地重规划起点层与同步规划停步计时」 | 10-04 | 落地后重规划钉住架子那层 |
性能
数据有两个来源:本机的 PlayCover 日志和规划日志,以及 #6104 附带的 Windows 日志。两台机器的硬件、输入后端和代码版本各不相同,下面的数字用来看量级和瓶颈落在哪一段。
测量环境
| 项目 | 本机 | #6104 |
|---|---|---|
| 硬件 | Apple M5,10 核(4 性能核 + 6 能效核),24 GB | AMD 核显加 NVIDIA GeForce GTX 1650,日志里没有 CPU 型号 |
| 系统与客户端 | macOS 27.0,PlayCover | Windows 桌面端 |
| 输入后端 | 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.0 | 20° | 125 到 145 ms |
| 09-29 12:38 | 5.0 | 90° | 120.5 ms |
| 09-29 15:09 至 19:23 四趟 | 3.0 或 4.0 | 90° | 143 到 183 ms |
| 09-29 19:32、19:36 两趟 | 3.0 | 90° | 272、290 ms |
最后两趟的发送耗时接近ADB 视角滑动按往返开销推算的新版每笔 290 到 340 ms。
每刻耗时
刻的周期定义和累计分布图见每刻耗时。两份日志按同一口径统计,单位 ms:
| 指标 | 本机中位 | 本机 p90 | 本机 p99 | #6104 中位 | #6104 p90 | #6104 p99 |
|---|---|---|---|---|---|---|
| 刻间隔 | 199.5 | 578 | 1395 | 186 | 278 | 571 |
| 截图 + 定位 | 124.5 | 222 | 475 | 141 | 218 | 272 |
| 其中定位 | 35 | 51 | 137 | 84 | 137 | 227 |
| 单笔转向发送 | 143 | 274 | 421 | 3 | 4 | 6 |
| 刻内耗时 | 200 | 530 | 937 | 144 | 226 | 339 |
样本:本机 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.1 | 154 | 665 | 89.1 | 682 | 1392 |
| 拓扑层 | 19.1 | 386 | 1531 | 18.7 | 1100 | 3943 |
| 几何层 | 10.5 | 93.4 | 763 | 12.3 | 1700 | 14014 |
| 合计 | 101.7 | 1309 | 3998 | 251.4 | 4534 | 19879 |
三段只记定案那一档;合计还含前几档白跑的时间和后处理,所以大于三段之和。这批数据早于 #6219「规划提速并共用旁包与格图段」,之后的耗时没有重测。本机 3567 次的档位分布见 NavMesh 一文的分档实测。
左图每个点是一次成功的规划。窗口大小相同时,#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 s | 5 条退到全图亏欠计价,最长一条 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 ms | 718 ms | 65 ms | 283 ms | 114 ms |
| 四号谷地,沿道路 | 204 ms | 166 ms | 65 ms | 51 ms | 24 ms |
| 武陵,折线桥 | 130 ms | 124 ms | 29 ms | 44 ms | 25 ms |
首次、第二次为接口往返的墙钟时间,分段为规划器自报。长腿是进程里四号谷地的第一次查询,墙钟比第二次多约 820 ms,规划器自报的合计多约 345 ms,多出的部分不在开窗、拓扑、几何三段里。这条腿的拓扑层通道没有接通,退到全图按亏欠计价,拓扑层耗时 283 ms。规划完两张大地图后,查询进程的峰值常驻内存约 2.2 GB。这组数字测于 28de7e4,#6219 之后格图段与旁包在各区间共用、每次规划完归还空闲页,没有重测。
一趟导航
#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 s | Bootstrap:人站在第 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。
参考资料
- ↑ 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
- ↑ R. C. Coulter. Implementation of the Pure Pursuit Path Tracking Algorithm. Technical Report CMU-RI-TR-92-01, Carnegie Mellon University, 1992. PDF
- ↑ 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
- ↑ 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
- ↑ 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
- ↑ 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 一文的参考资料。