TwinPath AI
TWINPATH ESCAPE · AI ARCHITECTURE

AI 是怎么
玩这个游戏

一个会打协作平台游戏的 AI,从「看见地图」到「按下按键」要经历什么?这篇文章把它拆开讲透——尤其是 A* 搜索。

3
搜索算法并行运行
C++17
原生搜索核心
0
作弊:只发按键
3
关卡全自动通关
OVERVIEW

先看全景:一条完整的决策流水线

AI 不读代码也不看坐标,它和人类玩家拿到的东西完全一样——只有「按什么键」的权利。

冻结观测
Observation
平台图提取
Graph
选目标
Directive
搜索路径
BFS+DFS+A*
执行动作
Action
01

冻结观测 Observation

把游戏世界在某一帧「拍照」:两个玩家的位置、速度、脚下平台,所有门与踏板的开合状态,移动平台的进度,水与熔岩的范围。观测是不可变的——AI 后续所有推理都基于这张快照,不会边想边改。

02

平台图提取 Graph

从 TMX 地图几何中提取「能站的地方」和「能走的路」,生成一张图:节点是平台段、踏板、门两侧、出口;边是走、跳、下落、爬台阶、乘平台。这一步把「物理世界」翻译成「数学世界」,是搜索的前提。

03

协作任务选择 Directive

这是一个协作游戏,得先决定「我现在该干嘛」:是去踩踏板开门,还是在门边等队友,还是冲向出口。任务选择器读取当前机关状态推断目标——不写死任何关卡流程。

04

搜索 + 执行

拿到起点和终点,让 BFS、DFS、A* 三个算法同时在图上跑,融合出一条最稳的路线;再由执行器把它翻译成一帧一帧的按键序列。跳不过去的沟?还有一个模型预测执行器专门负责「怎么跳」。

唯一的铁律:AI 只能输出 Action(left, right, jump, down) 四个布尔值——和人类键盘完全等价。它不能瞬移、不能改坐标、不能伪造输入。测试套件甚至用 AST 静态检查禁止 AI 代码给玩家的 pos / rect / velocity 赋值。
STEP 1 · WORLD → GRAPH

平台图:把地图变成一张数学的图

搜索算法不懂「平台」「熔岩」,它只懂节点和边。所以第一步是把物理世界翻译过去。

节点:所有「能站的地方」

提取器扫描地图几何,把每一块站得上去的表面切成平台段,再加上语义节点:压力板 plate:0 门的两侧 door:0.left 台阶的每一级 stairs:2 出口 exit:1。每个节点带位置、类型和覆盖范围。

判定「玩家站在哪个节点」用的是脚下真实重叠:以脚底为中心的矩形与平台段求交,而不是拿中心点做几何猜测——中心点探测会在平台边缘产生「假到达」,让路径被错误快进。

边:六种「能走的路」,每条都对应真实物理

两个节点之间有没有边,不是拍脑袋连的,而是用与真实游戏完全一致的物理参数验证出来的:跳跃力 720、重力 1800、单跳最高上升 144px、二段跳约 288px。

边类型含义物理约束举例
walk平地直接走过去两平台间逐像素探测有连续支撑
jump跳上去 / 跳过沟上升量 ≤ 单跳 144px 或二段跳 288px;水下起跳拒绝高弧线
drop踩穿单向板落下去只允许向下,落点必须真的接得住
stairs按住跳爬台阶限制在同列短距攀爬
ride乘移动平台只在正确停靠位置与方向可用,代价随进度实时更新
door穿过已开启的门带条件边:门没开?这条边在图里根本不可通行

条件边是协作玩法的数学基础:「门后的踏板」这个节点,只有当携带条件 open:door:0 被满足时才可达。AI 推理时把「哪些门已开」编码进搜索条件,一张静态地图就表达出了随协作状态变化的动态世界

DEEP DIVE

A* 详解:给盲目搜索装上方向感

这是整个 AI 的大脑。理解了这一节,你就理解了它为什么既快又准。

打分规则:每个候选节点怎么打分

开放列表里的每个候选节点都带一个总分 f = g + h,A* 每次只展开总分最低的那个节点:

# 本项目的节点评分:f(n) = g(n) + h(n) f(n) = g(n) + h(n) # g(n):从起点沿路线走到 n 的真实累计代价(逐边累加的类型代价) # h(n):从 n 到终点的乐观估计 = 直线距离 × 全图最小代价密度

g 来自图的边代价:走过一条边就累加它的类型代价(走 1.2/px、跳跃 +5.0,完整表见下)——同样抵达终点,绕了一堆跳跃边的路线累计 g 自然更高。h 由几何直接算出:到终点的直线距离,乘以图里「每像素至少要花多少代价」的下界,作为剩余代价的乐观估计。两者相加,g 是历史,h 是直觉

优先队列按 f 排序,每次弹出 f 最小的节点展开,弹出的是终点即刻收官;代价打平时按插入先后取(稳定平局序),同一张图、同一组状态,每次搜索都得到完全相同的路径——这是 44 项回归测试能逐字节对齐的前提。

一步步走一遍 A*(用游戏里的场景)

场景:AI 在左边平台,目标是右上方一块踩住开门的压力板,中间隔着沟和一层台阶。

第 1 步 · 起点入队

起点进「开放列表」(待考察的候选)。此时 g=0,h=起点到踏板的直线估计距离。

第 2 步 · 取 f 最小的节点

每次从开放列表里取出 f 值最小的节点展开——这就是「方向感」:明明周围的邻居都能展开,但永远优先试探离目标更近的那个。

第 3 步 · 展开邻居,松弛边

沿所有可通行边走一步:跳上台阶的边代价 5.0,走路代价 1.2。若新路线让某个邻居的 g 变小,就更新它的 g 并记下「我是从哪来的」。

第 4 步 · 弹出终点时收官

终点被从开放列表弹出的那一刻搜索结束,沿着「从哪来」指针回溯就是完整路径。本项目刻意采用弹出时判中(而非生成时),保证最优性成立。

看一眼区别:盲目扩散 vs 带方向感的探索

同一张地图(灰色为墙,蓝点起点,金点终点),左边 BFS 从起点一图一图地漫染,右边 A* 沿启发式的指向穿过缺口。数一数展开格数——这就是启发式换来的效率。

BFS · 广度优先

  已展开 0 格

A* · f = g + h

  已展开 0 格

演示用曼哈顿距离作启发式(网格四向移动下可采纳);游戏本体用的是「欧氏距离 × 最小代价密度」,原理相同。

可采纳启发式:为什么它敢保证「最优」

A* 的魔力全部押在 h 的一个性质上:可采纳性(admissibility)——h 永远不高估真实剩余代价。打个比方:h 是你说「最多还剩这么远」,只要你不吹牛,A* 就绝不会漏掉真正最短的路。

本项目的启发式是直线距离 × 图中最小代价密度

# 欧氏距离 × 全图最小正 cost-per-pixel h(n) = euclidean(n, goal) × min_cost_per_pixel # 直线是两点间最短的可能路径, # 乘以「每像素至少花多少代价」, # 得到「剩余代价的理论下界」——永远不会高估。

它够乐观(绝不高估 → 可采纳 → A* 最优性成立),又够聪明(大方向指得准 → 剪掉绝大部分无用探索)。测试用独立实现的 Dijkstra 做基准,逐条验证生产 A* 的路径代价与之完全一致。

直观感受两种极端:h = 0 时 A* 退化成 Dijkstra(准但慢);h 永远给 0 以上的高估值时它变成贪婪最佳优先(快但可能绕路)。可采纳启发式恰好卡在中间——Dijkstra 的正确性 + 方向感的速度

代价从哪来:不是步数,是「这个动作有多危险」

每条边的代价都编码了设计者的经验判断——移动平台在晃,乘它就该更贵:

边类型基础代价为什么
walk~1.2 / px最安全,代价密度最低
stairs机械可靠,只是慢
door+2.0依赖门保持开启,有协作耦合
ride+3.0平台在动,等待+时机成本
drop+4.0下落不可逆,落点误差要命
jump+5.0最贵:起跳点、弧线、落点都可能翻车

跑动中平台的位置变化还会实时改写 ride 边的可用性与代价——图不是一张死表,而是随游戏状态呼吸的活结构。

失败也是信息:黑名单与重规划

搜索出路径 ≠ 能走通。执行中如果同一条边反复失败(比如起跳点总差半步),A* 把它记进黑名单,下次搜索时那条边带着 blocked:a>b 条件直接不可通行——融合器随之自动换用 BFS / DFS 的备选路线。连续 6 次彻底没路时,AI 会申请一次「回到存档点重来」,而不是在原地无限抽搐。

触发重规划的时机:目标变了、门的开合改变了图的条件、或 1.5 秒没有实质性进展。失败被记成数据,而不是情绪。

STEP 3 · EXECUTION

搜索只是选路,执行才是「会玩」

A* 说「从节点 33 跳到节点 59」,但游戏帧循环里没有「跳」这种东西——只有每一帧的按键组合。

模型预测执行器:先在脑内玩一遍

遇到跳跃边,AI 不会莽。它启动一个无头物理克隆(和真实玩家逐位一致的物理引擎),把候选方案逐个「脑内模拟」一遍:先走 10 帧再跳?先后退助跑再起跳?空中什么时候松方向?二段跳在哪一帧按?

# 每个候选策略在克隆体上完整跑 130 帧, # 模拟里用的是真实碰撞几何:实心墙与单向平台严格分离。 candidates = [ (walk 10 frames, jump), # 走到边缘再跳 (backswing 14 frames, jump), # 先退后助跑 (jump + release @8), # 空中松手漂移 (jump + double @apex), # 顶点二段跳 ] for plan in candidates: outcome = simulate(plan) # 在脑内玩一遍 if outcome.lands_on(target): return commit(plan) # 赢家整段提交,逐帧重放

赢家的完整按键序列被提交并逐帧重放——只回放第一帧的话,下一帧又会重新搜索,永远消费不到那个关键的「延迟跳跃帧」。重放期间还有三重保险:真到达目标立刻中止吸收漂移;脚本耗尽仍未落地就按最后漂移惯性滑行到触地;助跑后退阶段挂起「卡死时钟」(故意背离目标不等于迷路)。

细节控:二段跳的 4 帧窗口

游戏的二段跳只在速度接近抛物线顶点(|vy| ≤ 144)时生效——满速下按了也白按,这个窗口一秒只有约 4 帧。模拟器在正确的帧按下二段跳;实机执行时若物理有几帧漂移,重放器会盯着真实 |vy| 等窗口出现再注入按键,而不是死板按帧号按——这一条直接把宽沟跨越的成功率拉了起来。

另一处细节:驻守压力板时用死区控制——在目标带内干脆松开所有键靠摩擦停车,只有真的滑出去了才单侧修正。早期版本用「速度过快就反向按键」,结果在踏板上左右横跳像在跳舞。

成绩单

三关全通

tutorial、Farlands、Crucible 三关双 AI 真实物理通关,无传送、无脚本按键。

全战役约 11850 帧

从新手谷到双出口 SUCCESS 再回主菜单,全自动。

44 项回归测试

含 4 项全战役通关回归 + 行为质量断言(不抖动、真的会用二段跳)。