本系列:把这两年用 Python 写的十几个小项目挨个开源、挨个说清楚。不讲”我做了什么”,讲”做的过程中被什么打脸、最后怎么想明白的”。 第 1 篇 · 离阵 Arrow Exodus 源码:github.com/ghostgorge/ArrowExodusV2(v3 / v4,当前版本) 初版存档:github.com/ghostgorge/ArrowExodus(v1,含八种机关的完整实现) 技术栈:Python + pygame,PyInstaller 打成单文件 exe,双击即玩,不装 Python
一、起因:App Store 上那一整排箭头游戏
Arrow Away: Tap Maze、Arrow Puzzle、Arrow Maze、Arrow Out……规则简单到一句话说完:
点一支箭,它沿自身方向飞出棋盘;只有整条出路是空的才飞得出去;点到被挡住的箭,扣一条命。
宣传语高度一致,都在强调”一步走错就锁死全盘”。我想照着做一个,顺手写了个求解器来验收关卡——然后求解器告诉了我一件官方绝口不提的事。
二、第一个发现:这类游戏根本不可能”走死”
拆开看,箭头谜题的数学内核是给”谁挡谁”的有向图找一个拓扑序。而移除操作有一个致命性质:
纯移除玩法是”汇合的”(confluent)。 移走一支箭,只会让别人的出路更通畅,绝不会更差。 所以任何一步合法的走法都是安全的——你根本不可能把自己走死。
求解器实测:纯箭头关卡的陷阱率恒为 0。”一步走错锁死全盘”在数学上不成立。它的难点只在”看出哪几支是自由的”——那是眼力题,不是规划题。
这个结论直接决定了整个项目的设计方向:要做出真正的极难,必须引入能制造不可逆错误的机关,而不是单纯把棋盘做大。
三、v1:八种自制机关,和”构造出来”的可解性
于是有了八种机关,重点标出哪些真正破坏了汇合性:
| 机关 | 规则 | 破坏汇合性 |
|---|---|---|
| 石块 | 永久障碍 | 否(纯眼力) |
| 裂墙 | 挡路,但可拿一支箭去撞碎,箭与墙同归于尽 | 是(资源分配) |
镜面 / \ | 把经过的箭折 90°,出路不再是直线 | 否,但眼力难度陡增 |
| 传送门 | 成对,一进一出、方向不变 | 否 |
| 步数箭 | 只走指定格数就停,跑到别处继续挡路 | 是 |
| 旋转箭 | 每有一步成功,它就顺时针转 90° | 是(时机) |
| 双生箭 | 锁链相连同时发射,两条出路都要通,而且互相挡路 | 否,但约束极强 |
| 序锁箭 | 必须先清掉 N 支箭才点得动 | 否(强制排序) |
| 陷坑 | 箭掉进去也算清除,等于棋盘中央的出口 | 否 |
关卡不是”随机生成 + 碰运气求解”,而是逆序构造:
从空棋盘开始一支支往里放箭,只有当这支箭在”已放好的箭”面前出路通畅才允许落子。 把放置顺序倒过来就是一条合法解——最后放的最先走(它当时路是空的),倒数第二支等它走了路也空……
裂墙的”先砸墙、再过人”依赖关系也能纳进这套构造:追踪时把还没指定砸墙者的裂墙当作已经碎掉处理,因为先放的箭在游戏里最后走,那时墙确实碎了;一旦某支箭被指定为砸墙者,这面墙才对后续放置变成实心——后续放的箭在游戏里更早走,那时墙还立着。顺序自然对上。
v1 交付了 10 章 80 关,全部程序生成 + 求解器验收。然后我自己通关了,感觉:太简单。
四、v2 的自我打脸:三个傻子机器人打穿了 61/80 关
v2 试图用”死亡率”证明自己难:随手乱点最终走死的概率顶到 0.99。但那是 25 步里每步 89% 安全累乘出来的(0.89²⁵ ≈ 0.05)——没有任何一步是难的,只是走得久了总会打滑,何况还有撤销键,惩罚约等于零。
我写了三个不含任何判断的机器人扔进 v2 的 80 关:
| 打法 | 规则 | 一次通关 |
|---|---|---|
| 剥洋葱 | 永远点最靠近棋盘边缘的自由箭 | 31 / 80 |
| 最短出路 | 永远点出路最短的那支 | 27 / 80 |
| 扫描顺序 | 从左上到右下,遇到能点的就点 | 41 / 80 |
| 三者并集 | 61 / 80 |
八十关里六十一关能被一条固定规则一次通关。玩家反馈的”从边缘找方向对的往里渗透就行”不是错觉,那就是 v2 的最优解。
难度指标被关卡长度刷掉了。这是整个项目最值钱的一课:任何”靠拉长流程累乘出来”的难度都是假难度。
五、v3:把难度做在两根轴上
重做时换了坐标系:
| 轴 | 含义 |
|---|---|
| W 决策面宽度 | 每步有几个合法走法 |
| S/W 安全率 | 其中几个不会走死 |
安全率
高
窄×安全 │ 宽×安全
「照着念答案」 │ 「剥洋葱」← v2 在这
│
W 窄 ───────────────┼─────────────── W 宽
│
窄×危险 │ 宽×危险
★真正的谜题★ │ 「算不动,只能试」
三条路,一条活的 │
低
主指标换成紧步占比(安全率 ≤ 0.5 的步在全程的占比);无脑打法通关数是一票否决项,第 7 章起必须为 0。
具体三把刀:
- 封边——外圈铺石块,出口从
4(k−1)个压到 1–3 个(配陷坑做内部出口)。镜面从装饰升级成必需的路网。实测 W 从 5.8 掉到 2.8。 - 减箭——11~16 支,不是 25~31 支。箭多只会稀释陷阱密度、增加扫描量。
- 把不可逆性放到窄面上——同一个陷阱,W=8 时安全率 0.88,W=2 时 0.50,杀伤力翻三倍。封边不是为了自己变难,是给陷阱当放大器。
实测教训:只封边不难。 早期样本 W 压到 2.3 但安全率 1.00——那是”窄×安全”,玩家从扫描变成照着念答案,一样不动脑。两刀必须一起上。
技术上有两个关键点。决策面闸门:裸倒推产生的是随机拓扑序,天然又宽又浅,它只问”这支箭射线通不通”,从不问”这样摆会让正着玩时多出几个自由箭”。所以倒推每摆一支就回头数一遍自由箭,超过 cap 就撤销重摇——这一行判断就是难度总开关。旋转箭的时钟反推:d0 = (想要的发射方向 − 步号) mod 4,于是”让某支箭只能在第 7 步点”这种编排可以精确做到。
还有一个反直觉的结论:九个机关里只有两台是真正的陷阱引擎。
- 裂墙:一次性资源。撞碎它的箭被消耗,墙也没了,后来者穿过豁口继续飞,可能撞死在石块上。在封边盘上,走廊里的裂墙就是一扇要付一支箭才能开的门。
- 旋转箭:方向 =
(初始方向 + 已成功步数) mod 4。困在只留一个缺口的石块口袋里时,只有m ≡ c (mod 4)那几拍点得动,必须消耗别的箭”垫步”对拍子。
石块 / 镜面 / 传送门 / 陷坑 / 双生 / 序锁全部是单调的,造不出死局,它们只负责路径形状和阅读负担。数据也印证了主次:只上裂墙的章节紧步占比只能做到 0.03,旋转箭一进来就跳到 0.17–0.48。所以 v3 把旋转箭提前到第 3 章。
六、实测曲线(74 关全量精确求解,非抽样)
| 章 | 名称 | 紧步占比 | 安全率 | W | 无脑通关 | 箭数 |
|---|---|---|---|---|---|---|
| 1 | 初识箭阵 | 0.00 | 1.00 | 2.5 | 3/3(就该无脑) | 8 |
| 2 | 一次性的门 | 0.03 | 0.88 | 4.5 | 1.2/3 | 12 |
| 3 | 周而复始 | 0.17 | 0.83 | 3.7 | 1.1/3 | 12 |
| 4 | 折出去 | 0.22 | 0.81 | 3.4 | 0.5/3 | 12 |
| 5 | 咽喉 | 0.22 | 0.81 | 3.3 | 0.4/3 | 12 |
| 6 | 钥匙链 | 0.28 | 0.77 | 3.2 | 0.4/3 | 13 |
| 7 | 饿着的拍子 | 0.37 | 0.74 | 2.7 | 0/3 | 13 |
| 8 | 同频 | 0.37 | 0.73 | 3.0 | 0/3 | 13 |
| 9 | 全是咽喉 | 0.41 | 0.70 | 2.8 | 0/3 | 14 |
| 10 | 万象归墟 | 0.48 | 0.67 | 2.8 | 0/3 | 12 |
对比 v2:末章紧步占比 0.09 → 0.48,安全率 0.89 → 0.67,决策面 5.6 → 2.8,箭数 25 → 12,被无脑打法通关 61/80 → 26/74。
终关:9×9、只有 12 支箭、紧步占比 0.61、安全率 0.58——六成的步里,看得见的路有一半以上是死的。
顺带删掉了”扫描”道具(花 3 星尘把所有自由箭高亮,等于官方发的剥洋葱外挂),第 3 章起关闭出路预览。提示保留——那是帮你想,不是替你看。
七、没有做、也不打算做的
| 别做 | 为什么 |
|---|---|
| 加战争迷雾 / 藏箭头方向 | 完全信息 + 困难搜索 = 谜题;不完全信息 + 简单搜索 = 猜谜 |
| 加箭数、加盘面 | 稀释陷阱密度,增加扫描量,不增加思考量 |
| 靠累计死亡率刷难度 | 可以靠拉长关卡刷分,且有撤销键时惩罚趋近于零 |
把 cap 压到 2 以下 | 退化成唯一解,玩家在照着念答案 |
八、v4:把生成器交到玩家手里
既然关卡的难度可以被精确度量,那就干脆做成工具。v4 加了关卡设计器:左键画、右键擦、F5 一键验证难度(直接调求解器出报告)、F6 试玩、Ctrl+S 保存,还能生成 AEX1: 分享码发给别人。自制关卡不进星星经济,只记最好成绩。
九、工程与一个关键的性能发现
core/board.py 规则内核(Level 静态 / State 不可变 / Game 可撤销)
core/solver.py 求解器 + 三个无脑机器人 + 精确难度评估
core/levels.py 烘焙好的 74 关(自动生成,勿手改)
core/editor.py v4 关卡设计器(草稿/体检/倒序生成/分享码)
tools/gen_levels.py 关卡烘焙器(封边 + 母题预留 + 决策面闸门)
tools/verify.py 16 项自动验收
规则内核刻意把静态与动态拆开:Level 是棋盘和地形,全程不变;State = (箭头元组, 未碎裂墙集合, 已清除数) 是不可变的。这一个决定同时带来三件好事——撤销就是弹栈、求解器可以直接对状态做记忆化、关卡校验能无副作用地重放。
统计安全率要对沿途每个分支各问一次”走完这步还有救吗”,代价极高。两处优化把单关分析从几十秒压到毫秒级:
- 共享记忆表:整场分析只遍历一次可达状态空间;
- 死局判据剪枝:箭永不移动,所以它最好的处境就是别人全走光;连那时都飞不出去就直接判死——只会漏判,不会误杀。
当前状态 selftest: OK(74/74 可通关)、verify.py: ALL PASS,其中六项是 v3 新加的红线:终章箭数与决策面上限、第 7 章起无脑打法全灭、全局无脑通关率 < 40%、紧步占比逐章不降、安全率逐章不升、最大箭数 ≤ 18。
十、已知取舍
- 前两章紧步占比只有 0.03。只用裂墙时,一面墙在十几支箭的局里只贡献一个抉择点,会被平均稀释。这两章定位就是”看路”,难度从第 3 章起飞。
- 第 9 章没有传送门和序锁——加上之后摇不进难度带,保留了不带它们的版本。
- 求解器对 9×9 / 16 箭以上开始吃力,终章几关的分析节点数接近预算上限。
尾巴
这个项目真正的产出不是 74 关,是一句结论:
难度不是”会不会输”,是”每一步要不要想”。 前者可以靠拉长流程刷出来,后者只能靠压窄决策面、再往窄面上放不可逆。
顺便,它还教会我一件事——先写求解器,再写游戏。没有那个求解器,我到今天还以为原作宣传的”走错锁死”是真的。
📦 源码:https://github.com/ghostgorge/ArrowExodusV2 🕹 Windows 双击 run.bat 直接跑,build.bat 出单文件 exe;游戏内连按两下 Shift 有作弊台。
下一篇:DrCapsule——Dr. Mario 式药丸消除。回复「继续」。
