视觉解析到 AI 求解俄罗斯方块(纯前端)

-
2026-04-06

链接

Github Repo:https://github.com/chinalichen/tetris-master

Demo:https://fun.gowilli.xyz/tetris

背景

最近在带宝宝玩一些手指交互类的游戏,主要为了锻炼手指灵活性、识别颜色,同时培养一点初级的逻辑判断能力。在此期间,我偶然发现了一款“变种俄罗斯方块”游戏。

本来是陪宝宝玩,结果我反而上瘾了。这种游戏的好处是可以随时停止,非常适合碎片时间。但随着分数逐渐变高,盘面变得支离破碎,只靠人眼已经很难在脑海中瞬间推演 3 个形状的先后放置顺序及消除连锁反应。

理论上系统给出的题目都是有解的,虽然人眼算不过来,但用代码做深度优先搜索(DFS)枚举就很容易了。于是开发了 Github-teris-master  这款纯前端 AI 求解器,代码纯由 AI 生成。可以在  Tetris Master Playground 中试用,复制下方截图在页面中粘贴即可计算出摆放方式。

它的核心机制与传统下落式俄罗斯方块不同:

  1. 静态消除:有一个 \(8 \times 8\) 的固定棋盘。
  2. 三块齐发:每一回合,系统会在底部随机给出 3 个形状各异的方块(如 \(1\times3\) 的长条、\(2\times2\) 的正方形、\(3\times3\) 的大 L 形等)。
  3. 自由放置:玩家需要将这 3 个形状一一拖入棋盘的空白处,摆放顺序不限。当摆满一整行或一整列时,该行/列就会消除并得分。只有 3 个形状全放完,才会刷新下一轮。一旦某个形状无法放入,游戏结束。

本质

我们把这个问题剥离到本质,它其实是一个典型的 CV(计算机视觉) + 启发式搜索 问题,整个链路完全可以在前端浏览器内闭环:

  1. 输入提取(CV):用户直接粘贴手机截图(包含不同主题皮肤、甚至底部有广告)。我们需要在前端通过 <canvas>ImageData 像素级扫描,精准提取出 \(8 \times 8\) 的棋盘布尔矩阵,以及底部 3 个形状的二维布尔数组。
  2. 逻辑引擎与求解(AI):实现一套包含“碰撞检测”和“行列消除”的规则引擎,并对 3 个物体的放置顺序进行全排列(\(3! = 6\) 种),在棋盘的所有合法坐标上进行 DFS 搜索。

在这其中,算法的穷举是简单的,但从复杂且多变的前端截图中精准提取特征,才是真正的工程难点。

如何解决?

最开始的设想很美好:棋盘的空白格子应该是黑灰色的,方块是彩色的,我们只要设置一个固定的 RGB 亮度阈值,就能轻松把棋盘抠出来。

但在引入了不同的测试用例(粉色皮肤、护眼绿皮肤、木头纹理皮肤)后,这个“硬编码”的假设被彻底击碎了:

  • 在粉色皮肤中,背景空白格是一种略暗的脏粉色,而某些实体方块是极度暗的深紫色。背景居然比实体方块还要亮!
  • 截图的上下方经常带有渐变色,或者底部挂着花花绿绿的横幅广告。

我们需要一种不依赖绝对颜色、能自适应任何皮肤主题的全量扫描聚类算法 (Global Sampling Clustering)

动态颜色基准与多重网格采样

既然不能写死颜色,我们就去寻找图像规律。无论在什么主题下,“背景网格”永远是视觉上最“平淡”(饱和度最低、三原色差值最小)且相对偏暗的颜色。

我们可以扫描棋盘的 64 个网格中心,给每个像素打一个“物理分数”,找出得分最低的那种颜色,将其动态标定为这局游戏的“空白底色(emptyCellColor)”。此后,只要格子颜色与这个底色的欧几里得距离大于某个阈值,我们就认为它是实体方块。

// Before: 脆弱的绝对阈值判断
const isFilled = Math.max(r, g, b) > 85 || Math.max(r, g, b) - Math.min(r, g, b) > 15;

// After: 基于动态底色的色差判断
const variance = Math.max(r, g, b) - Math.min(r, g, b);
const brightness = r + g + b;
const score = variance + brightness * 0.05; // 饱和度为主,亮度为辅

// 选出 minScore 对应的 emptyCellColor 后...
const isFilled = colorDist(currentColor, emptyCellColor) > 20;

数学抽象

在解决了视觉解析后,我们来看 AI 求解的核心:启发式评估函数 (Heuristic Evaluation Function)

由于我们的终极目标是“活下去”(生存最大化),单纯追求一次性消除的得分是短视的。我们需要在 DFS 搜索到达叶子节点(即 3 个方块全部放置完毕)时,对当前盘面状态 \(S\) 进行打分。

我们可以将其抽象为以下评估公式:

\[E(S) = \alpha \cdot \text{Count}_{empty} - \beta \cdot \text{Count}_{holes} + \gamma \cdot \text{LinesCleared}\]

死洞 (Holes):指那些被四周方块或墙壁完全死死包围的空白格子。这是生存类游戏中最致命的毒药。

指标权重系数业务意义
\(\text{Count}_{empty}\)\(\alpha = 10\)奖励盘面剩余的空白格子总数
\(\text{Count}_{holes}\)\(\beta = 50\)极大力度惩罚封闭的死洞
\(\text{LinesCleared}\)\(\gamma = 100\)鼓励能在本回合引发连消的操作

通过这种评估函数,AI 往往能找出一些人类直觉上觉得“填不进去”,但通过巧妙利用“前一步放置引发消除,从而为后一步腾出空间”的连消绝杀。

剩余的0.5个很好的实践

在最终的工程落地中,我还加入了两个非常关键的实践,这也是填平“玩具脚本”与“可用工具”之间鸿沟的最后 0.5 步:

  1. 防穿透采样 (Multi-point Sampling) 有些极简皮肤的方块内部是纯色且非常扁平的,如果刚好与背景色差异不大,单点采样(只测格子正中心 1 个像素)可能会将其误判为空白。 真实的解法是:在一个格子的物理区域内,进行 \(3 \times 3\) 矩阵的 9 点采样。 只要边缘或阴影有任何一个像素与背景有色差,该格子就被判定为实体。
  2. 基于最大公约数 (GCD) 的网格自适应 底部的 3 个待选方块大小是不固定的。我们不能用写死的比例去盲猜它是 \(2\times3\) 还是 \(1\times4\)。我们可以拿到方块的绝对像素包围盒 \((W, H)\),然后在合理的网格大小区间内,寻找一个最佳的 blockSize,使得 \(\text{error} = |W - \text{cols} \times \text{blockSize}| + |H - \text{rows} \times \text{blockSize}|\) 最小,从而精准反推出这块物体的真实行列数。

总结

这虽然只是一个为了解决“玩游戏卡关”而诞生的周末项目,但它展示了前端 <canvas> 在纯客户端图像处理上的潜力,以及 TDD(测试驱动开发)在对抗视觉解析 Edge Cases 时带来的安全感。

工程的迷人之处往往就在这里:理论上的 DFS 枚举只需要 50 行代码,而为了让这 50 行代码能在一个不受控的真实物理环境(带有渐变、不同皮肤、充满广告的截图)中稳定运行,我们需要投入十倍的精力去构建鲁棒的特征提取体系。这与我们在企业级架构中解决不确定性问题的思路,是完全一致的。

 


目录