SplitCircuit:一个带求解器和自动玩家的解谜原型
今年 3 月初,我在 Unity 里做了 SplitCircuit,一个基于种子的小型解谜原型。十次提交、 大约 29 小时,它从一个空仓库变成了:
- 一个可玩的游戏循环,核心是纯数据,每个子系统有自己的种子;
- 一个不需要渲染器就能玩这个游戏的暴力求解器;
- 一个种子扫描器,以及把求解器运行两次并要求答案完全一致的编辑模式测试;
- 一个无限运行器:预先求解随机谜题,再通过真实游戏把它们一局局可视地回放出来,永不停止。
最后这一部分已经作为 WebGL 构建上线。你可以在这里看它自己玩: SplitCircuit 无限自动运行演示。
| 提交 | 落地内容 | 规模 |
|---|---|---|
6bbd3a2 | 概念验证:棋盘、切割、微调、沉积、电路检查 | 154 个文件,+11,051 |
6a8d79e | 求解器、回放、种子扫描器、测试基础设施 | 48 个文件,+2,679 / -51 |
6dba7d7 | 回放改为针对实时棋盘逐回合求解 | 3 个文件,+147 / -110 |
53e7a66 | 无限运行器、四种预设、每个预设一键构建 WebGL | 21 个文件,+1,725 / -539 |
代码由 AI 编程代理根据我的设计和规则文件编写;约束由我设定,每一步由我审查,下一步做什么由 我决定。本文讲的是设计决策,因为正是这些决策让自动玩家的实现成本变得很低。
游戏本身
一个多联骨牌(polyomino)在网格中央生成。你用一条竖直切线和一条水平切线把它切成四块碎片。 碎片向外滑动,各自朝一条边移动,每个 tick 前进一格。在 tick 之间你可以横向微调它们,但 控制是反向耦合的:按“上”会让右侧碎片向上、左侧碎片向下。碎片碰到边缘或之前回合沉积下来的 材料时停止(像俄罗斯方块一样堆叠),并成为棋盘上永久的导电格。
每次沉积之后,游戏从 Start 节点做一次广度优先搜索。如果能到达 End 节点,电路接通,你就赢了。 如果某块碎片连一步都无法前进,或者中央拥挤到无法生成下一个形状,你就输了。
两条让后面一切都变得便宜的规则
项目的规则文件在任何求解器出现之前就定下了两条约束:
- 纯数据核心。
BoardState、CutResult、FragmentState和RouteResult都是普通的 C# 类,没有MonoBehaviour。渲染器和游戏管理器建立在它们之上。 - 确定性种子。 所有生成和模拟都使用带显式种子的
System.Random,绝不使用UnityEngine.Random。形状、颜色、Start/End 位置、中继节点和预置导线各有自己的种子, 所以改动其中一个不会打乱其他的。后续回合的形状由形状种子和回合号推导出来。
正是这两个选择,让求解器成了一天的工作量,而不是一次重写。求解器可以创建棋盘、执行切割、 运行碎片模拟器、检查电路,成千上万次,而完全不碰场景。又因为下一个形状来自带种子的生成器, 求解器能生成真实游戏下一回合会生成的同一个形状。求解器里的注释说得很直接: "Spawn next shape (mirroring GameManager's RNG progression)"。
求解器如何搜索
6a8d79e 里的求解器刻意保持简单:回合内穷举,回合间贪心。
对一个回合,它尝试每一种合法切割。在 16 x 16 的棋盘上,就是 14 个竖直位置乘以 14 个水平位置, 共 196 种切割。对每种切割,它先探测每个微调轴在碎片相撞之前能移动多远,然后在这个范围内尝试 每一种竖直和水平微调偏移,把碎片模拟到静止,再检查电路。
for vCut in 1 .. width-2
for hCut in 1 .. height-2
probe nudge bounds for this cut
for vNudge in minV .. maxV
for hNudge in minH .. maxH
simulate settle, check circuit
如果某个组合能在本回合接通电路,就采用它。如果没有,就用启发式函数给每个组合打分,保留最好的:
- 间隙(主导项)。 分别从 Start 和 End 出发,在导电格中做广度优先搜索。分数是两个可达集合 之间最小的曼哈顿距离,权重为 100,000。缩小这个间隙就是整个游戏的目标。
- 生成区空余(次要项)。 中央生成区域中仍为空的比例,权重为 100。一步棋如果缩小了间隙却 堵死了中央,两回合后就会输。
- 可达规模(决胜项)。 连通的材料越多越好。
然后它执行选中的这一步,用带种子的生成器生成下一个形状,并重复,直到获胜、没有合法走法、中央
拥堵,或达到回合上限。每个结果都带有状态(Solved、Unsolvable、MaxRoundsReached)、
执行的步骤、评估过的组合数和耗时。
它是贪心的,所以并不最优,也可能把一个可解的谜题判为不可解。对一个原型来说这是正确的取舍: 它容易推理,而它回答的问题(“一个合理的玩家能不能完成这个种子?”)正是谜题设计者需要的。
证明它是确定性的
编辑模式测试里有一个测试:用同一个配置运行求解器两次,断言状态、步数以及每一步都相同。 这个测试很小,但正是它让求解器保持诚实:如果生成或模拟中的任何部分开始读取未设种子的随机源或 依赖帧的值,两次运行就会不一致,测试就会失败。
同一个提交还加入了种子扫描器(一个编辑器面板、一个菜单运行器和一个测试工具),它会扫描一段种子 范围并报告求解器能完成哪些;以及一个 PowerShell 批处理模式测试运行器,把 Unity 的 XML 结果 转换成 JSON。
从求解器到自动玩家
第一版回放先在内存中求解整个谜题,只有完整解法成功时才回放。失败时你什么也看不到,也拿不到 任何棋盘图片,而失败恰恰是你最想看清楚的时候。
6dba7d7 改变了结构:回放现在针对真实的 GameManager 棋盘,每次向求解器要一步,然后在屏幕上
驱动真实的切割、微调、tick 和沉积流程。无论结果如何,每个回合都可见、都被记录。同一个提交还
调整了碎片模拟器中的检查顺序:先对每个格检查是否与已沉积材料碰撞,再检查是否到达边缘,这样
碎片就不会和贴在边上的沉积物重叠。规则文件早就描述了这个优先级;现在代码与之一致了。提交历史
没有说明这个不一致是怎么被发现的,所以我不会声称是自动玩家发现了它。
无限运行器
最后一次提交把工作拆成了两个协程:
- 预求解。 维持一个 10 个谜题的队列。根据预设构建一个随机配置,对它运行贪心求解器(最多 50 回合),把结果放入队列。这是纯数据,从不接触场景。
- 回放。 取出下一个谜题,在外部控制下创建一个全新的
GameManager,并通过它回放求解器的 步骤:设置切割、确认、施加微调、推进 tick 直到一切沉积完毕。
我最看重的设计决策是一局游戏如何计分。运行器不信任求解器给出的状态。只有当实时的
GameManager 以 Complete 阶段结束时,它才把这局算作已解。求解器和游戏共用同一套核心类,
但回放路径经过的是游戏自己的状态机、输入入口和生成逻辑。如果两者出现分歧,屏幕上已解和失败的
计数就会显示出来,而且每一局都会把结果写在它的棋盘快照和逐 tick 图片旁边。
一共有四个预设(Checkers、Chess、Go 和 Full Random),它们改变棋盘尺寸、形状生成器、颜色分配 和 Start/End 的放置方式。一个编辑器工具为每个预设生成一个场景,并一步构建成 WebGL。上面链接的 演示是 Full Random 预设。它每局都抽取新的种子,所以每次访问看到的谜题都不同;在一局之内, 一切都由它的种子决定。
生成器护栏
在无限运行器成形的过程中,棋盘生成器多了三条规则,每条都在注释中标为护栏:
- Start 和 End 必须与生成区至少相隔一格,"otherwise fragments can't move and it's an instant loss"(否则碎片无法移动,会直接输掉)。
- Start 和 End 绝不能正交相邻,否则谜题一开始就已经解开了。
- 对一个
n格的形状,每块碎片的最少格数上限为(n - 1) / 4,从而保证总存在合法的四分切割。
提交记录的是这些规则,而不是促成它们的原因,所以我如实呈现它们:让每一个生成出来的棋盘都是 真正谜题的约束。
可以推广的经验
从第一天起就让核心保持纯净、让随机性带上种子。 这两条规则都不是为求解器写的。正是它们让 求解器、确定性测试和自动游玩演示成为一次简短的后续工作,而不是一次重构。
让机器人玩真实的游戏。 一个只检查自己模型的求解器可以永远与自己一致。通过真实的游戏循环 回放它的走法,并以游戏自己的结束状态计分,让两者相互检验。
让失败成为最容易看到的情况。 第一版回放只展示成功。修复方法是针对实时棋盘逐回合求解, 这样一个失败的种子产生的证据最多,而不是最少。