扫雷游戏,我估计是不少程序员接触过的第一个“看起来简单、写起来全是坑”的练手项目。它不像俄罗斯方块那样需要复杂的碰撞和旋转逻辑,也不像贪吃蛇那样要处理连续运动的坐标变换,但它在规则上的克制恰恰是最大的考验:一个十几行就能说清楚的游戏规则,落地成代码时却涉及数据建模、递归展开、边界处理、交互状态机、自动化解题算法等一整套东西。这篇文章我就以扫雷游戏作为核心,从玩法规则的数据建模开始,把棋盘生成、布雷算法、数字计算、翻开逻辑、右键插旗、双击展开、首点保护、胜负判定这些模块逐一拆开,给出可直接复现的 JavaScript 实现方案,最后再聊聊用自动扫雷器做算法验证的思路。想拿它练手、准备面试项目、或者单纯想搞懂扫雷背后计算原理的朋友,这篇应该能让你少走不少弯路。
1. 先把规则翻译成数据:扫雷的核心建模思路
1.1 格子状态与棋盘数据结构的设计
扫雷最根本的模型就是一个二维网格。初级难度是 9×9 网格、埋 10 颗雷,中级是 16×16、40 颗雷,高级是 16×30、99 颗雷。这个参数表本身就是一套不错的设计参考,你完全可以自定义更多难度,比如 20×20、130 雷之类的,只要保证雷数小于格子总数即可。
每个格子至少需要记录三类信息:是否埋雷、是否已经翻开、是否被玩家插了旗子。我见过很多新手用一个数组存“是否雷”,再用另一个数组存“是否翻开”,这样逻辑上也能跑通,但是函数签名会变得很难看——你每操作一个格子都要同时维护两个数组的索引,很容易漏掉同步更新。
根据我自己的经验,用一个对象数组或者二维对象数组更省心:
javascript复制function createCell() {
return {
mined: false, // 是否是雷
revealed: false, // 是否已翻开
flagged: false, // 是否被插旗
adjacentMines: 0 // 周围8格雷的数量
};
}
const rows = 9;
const cols = 9;
const board = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => createCell())
);
这样每个格子自带完整的状态,后续逻辑读起来非常直观,比如判断“这个格子能不能左键点击”,只需要写 !cell.revealed && !cell.flagged 就够了,不用在两个数组之间来回协调。
你可能会问,adjacentMines 这个字段为什么一开始就要存在?因为扫雷所有智能判断都依赖“数字”。每次翻开一个格子,玩家看到的数字其实就是这个字段的值,而它需要在布雷之后统一计算一次,游戏过程中不会再变化。把这个字段作为格子状态的组成部分,会让渲染层拿数据时非常省事——直接把格子的 display() 方法映射到界面上就行。
1.2 布雷到底该用什么算法
布雷看起来最简单:随机挑 M 个格子放雷就行。但这里面有个细节坑——你要保证同一颗雷不会被重复放置。
我见过不少直接写 random 然后循环选格子、碰到已布雷的格子就重试的算法。这个做法在小棋盘、低雷密度时问题不大,但当棋盘大、雷密度接近 25% 时,试错的期望次数会显著上升,最坏情况甚至可能造成明显的卡顿。比如 99 颗雷铺在 480 个格子里,越到后面抽到“空白格子”的概率越低,随机重试的循环次数会让人肉眼看出来。
所以正确姿势是洗牌算法。把棋盘上所有格子的坐标展开成一个一维数组,然后用 Fisher-Yates 洗牌,取前 M 个元素作为雷区:
javascript复制function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function plantMines(board, mineCount) {
const rows = board.length;
const cols = board[0].length;
const cells = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
cells.push([r, c]);
}
}
shuffle(cells);
for (let i = 0; i < mineCount; i++) {
const [r, c] = cells[i];
board[r][c].mined = true;
}
}
用洗牌算法之后,每次布雷的时间复杂度是稳定的 O(rows×cols),不会出现“运气差导致循环很久”的情况。而且这个一维索引序列还有一个额外好处:它天然就是一组随机排列,之后实现“首点保护”时可以直接拿这个序列做交换,很灵活。
1.3 数字计算与边界处理
布雷完成后,紧接着要计算每个非雷格子周围的雷数。很多新手在这里第一次栽跟头——边界格子只有 3 或 5 个邻居,不像中间格子有 8 个邻居,处理不好就会数组越界。
我之前写过一段很啰嗦的代码,用八个 if 分别判断上、下、左、右、左上……后来发现效率低且容易漏。更好的做法是定义一组“方向偏移量”,然后循环判断每个方向是否合法:
javascript复制const DIRS = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1]
];
function calcNumbers(board) {
const rows = board.length;
const cols = board[0].length;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (board[r][c].mined) continue;
let count = 0;
for (const [dr, dc] of DIRS) {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && board[nr][nc].mined) {
count++;
}
}
board[r][c].adjacentMines = count;
}
}
}
这组偏移量的顺序有没有讲究?没有,但建议固定写成顺时针或逆时针,方便后面调试时逐个核对。我自己习惯从左上开始按顺时针排,这样万一某个格子的数字不对,我能一目了然定位到是哪个方向漏算了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 翻开逻辑:从递归展开到迭代写法
2.1 数字 0 的连锁反应是怎么触发的
扫雷翻开的是“空白区域”时,会像水波一样往四周扩散,把整片连在一起的空白格全部翻开,同时把这些空白格边缘的“数字格”也翻开。这个扩散行为不涉及随机判断,纯粹是沿着邻接关系走,所以自然让人想到递归。
递归写法非常简洁:
javascript复制function revealCell(row, col) {
const cell = board[row][col];
if (cell.revealed || cell.flagged || cell.mined) return;
cell.revealed = true;
if (cell.adjacentMines === 0) {
for (const [dr, dc] of DIRS) {
const nr = row + dr;
const nc = col + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
revealCell(nr, nc);
}
}
}
}
这段代码的逻辑核心是:先把自己翻开,如果自己是 0,就把 8 个邻居都递归打开;那些邻居如果也是 0,就继续往外扩。数字格(非 0)在递归里充当了“边界”,因为它的 adjacentMines 不为 0,所以不会继续扩散。
2.2 递归写法的一个隐患:爆栈
递归写法虽然简单,但在极端棋盘上有一个潜在问题——如果棋盘非常大,比如 100×100 全部无雷区域,一次性展开的格子数量会非常大,递归深度可能会超过调用栈上限,在浏览器环境里直接抛出 Maximum call stack size exceeded。
这里顺便说清楚“深度”为什么可能很大。递归的核心不是总调用次数,而是嵌套深度。如果空白区域形状比较狭长,比如一条蛇形的 0 连通区域,那么递归深度可能接近这条路径的长度。在超大棋盘上,这种路径完全可能上百层甚至上千层,而浏览器的默认调用栈深度通常在万级左右,看起来没那么容易爆,但一旦玩家棋盘达到 200×200 以上,这个问题就是真实存在的。
更稳妥的做法是用显式栈实现迭代展开,把“待展开的格子”放进一个数组,循环处理:
javascript复制function revealCell(row, col) {
const stack = [[row, col]];
while (stack.length > 0) {
const [r, c] = stack.pop();
const cell = board[r][c];
if (!cell || cell.revealed || cell.flagged || cell.mined) continue;
cell.revealed = true;
if (cell.adjacentMines === 0) {
for (const [dr, dc] of DIRS) {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
stack.push([nr, nc]);
}
}
}
}
}
如果你是用 while 循环配合 push/pop,注意顺序无所谓,因为是全量展开,不要求像广度优先那样分层。这个版本不存在递归深度的限制,而且我发现它在实际渲染时还有个优势:每弹出一个格子就标记 revealed,配合 requestAnimationFrame 可以做“逐步展开”的动画效果,观感比瞬间全展开好很多。
2.3 点击逻辑里必须做的三道防线
处理点击时,不能只写一个 revealCell 就完事了。玩家可能会点已翻开的格子、点插了旗的格子,这些操作都要被拦截掉。建议把外层点击处理和内层翻开逻辑分开:
javascript复制function handleLeftClick(row, col) {
const cell = board[row][col];
if (cell.revealed) return;
if (cell.flagged) return;
if (cell.mined) {
gameOver("lose");
return;
}
revealCell(row, col);
checkWin();
}
翻开的格子再点击就忽略,这是基本规则。插旗的格子点左键也忽略,这个防呆设计很关键,不然玩家可能不小心把旗子下的雷翻开。游戏状态(进行中、胜利、失败)也需要在最外层做统一判断,避免死了还能继续点格子。
3. 交互细节:右键、双击与首点保护
3.1 右键状态的经典循环:无标记 → 旗子 → 问号
老版 Windows 扫雷的右键是“空 → 旗子 → 问号 → 空”的三态循环。现在的 Windows 扫雷虽然移除了问号,但问号这个状态用于“暂时不确定”的标记其实很实用,尤其在后来的自动扫雷器算法里,它代表“可能是雷但还没确认”的立场。
实现三态循环用一个 nextFlagState 函数即可:
javascript复制function cycleFlagState(cell) {
if (cell.revealed) return;
if (!cell.flagged && !cell.questioned) {
cell.flagged = true;
} else if (cell.flagged && !cell.questioned) {
cell.flagged = false;
cell.questioned = true;
} else if (!cell.flagged && cell.questioned) {
cell.questioned = false;
}
}
注意插旗与问号是互斥的,所以需要在变更时同时维护两个布尔值。有些实现会把 flagged 直接设计成枚举类型 'none' | 'flag' | 'question',我在做第二版时改成了这种枚举,代码反而更清晰。如果你的项目用 TypeScript,建议优先用联合类型。
插旗的核心规则是:插旗后格子绝对不能被左键翻开。这个互斥判断必须放在所有点击处理的最前面,很多人写到最后发现“旗子还能被翻开”,多半是忘了在 handleLeftClick 里加 if (cell.flagged) return。
3.2 剩余雷数显示与错误旗子
界面上的“剩余雷数”不是实时剩余未发现的雷数,而是“总雷数 - 旗子数”。这个设计从老扫雷一直延续到现在,它更像一个“计数器”,暗示玩家你还有多少雷没标记。代码实现上,只需要每次右键状态变化后刷新一次数字:
javascript复制const minesLeft = totalMines - countFlagsOnBoard(board);
但这里有一个常见误区:如果玩家把旗子插在没雷的格子上,剩余雷数会显示减少,但实际没雷的格子被标记了,这会让数字看起来比实际未发现雷数少。所以严格来说,这个数字是“操作计数器”,不是“剩余雷数”,理解这一点对后续做自动扫雷器很重要——自动扫雷器不能拿这个数字去反推未标记雷的数量,必须自己维护一套判断逻辑。
3.3 首点保护:如何保证第一次点击永远不是雷
扫雷早期版本没有首点保护,玩家第一次点击踩中雷会被直接炸死,体验很差。后来版本加入的保护机制是:玩家第一次点击的格子及其周围一定没有雷。
实现思路有两种:
第一种:先随机布雷,玩家点击后如果踩到雷,就把这颗雷“转移”到别处。这种方案需要处理很多边界情况,比如周围全是雷的话转移失败。我认为不推荐。
第二种更优雅:生成雷区时预留安全区。先随机选一个格子作为安全中心,然后布雷时排除这个格子及其周围一圈,再把洗牌后的格子序列中不属于安全区的格子设为雷:
javascript复制function plantMinesWithSafeZone(board, mineCount, safeRow, safeCol) {
const rows = board.length;
const cols = board[0].length;
const cells = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const isSafe = Math.abs(r - safeRow) <= 1 && Math.abs(c - safeCol) <= 1;
if (!isSafe) cells.push([r, c]);
}
}
shuffle(cells);
for (let i = 0; i < Math.min(mineCount, cells.length); i++) {
const [r, c] = cells[i];
board[r][c].mined = true;
}
}
这个方案要求 mineCount <= rows*cols - 9,否则没有足够空间放雷。这种情况在常规难度下不会出现,但是自定义极限模式(比如 3×3 棋盘埋 8 颗雷)就会触发,所以程序设计时必须做合法性校验。
这个“先选安全区再布雷”的流程能保证首点永远安全,不用临时换雷,逻辑上非常干净。我第一次实现首点保护时用的是“先布雷后换雷”的方案,结果在某次自动扫雷算法测试时发现一个极难复现的 bug——某个情况下首点周围被雷包围,换雷操作把数字计算搞乱了。后来改成安全区方案,这个坑就从根上消失了。
3.4 双击自动展开:老手都在用的高效操作
扫雷的“双击”操作(把光标放在一个已翻开的数字格上,同时按左右键)是效率神器。它只在一个条件下生效:该数字周围已经插旗的格子数量等于这个数字本身。如果满足条件,就自动翻开周围所有未标记的格子。
这个操作的逻辑意义在于:玩家用旗子把数字周围的雷“锁定”了,剩下没插旗的格子可以放心翻开。实现时要注意一点——这个双击操作本身有危险性,如果玩家插旗插错了,双击就会把没雷的格子判断成安全格子翻开,然后炸死。所以从设计角度讲,它更像是一个“信任玩家标记正确”的操作。
一个安全版的实现可以跳过验证,直接展开未标记格:
javascript复制function handleChordClick(row, col) {
const cell = board[row][col];
if (!cell.revealed || cell.adjacentMines === 0) return;
const neighbors = getNeighbors(row, col);
const flagsAround = neighbors.filter(([r, c]) => board[r][c].flagged).length;
if (flagsAround !== cell.adjacentMines) return;
for (const [r, c] of neighbors) {
if (!board[r][c].revealed && !board[r][c].flagged) {
handleLeftClick(r, c);
}
}
}
为什么这里要用 flagsAround === cell.adjacentMines 而不是 flagsAround >= cell.adjacentMines?因为等于才是恰好对应。如果旗子比数字多,说明玩家插错了,反而不能展开。这个判断是扫雷双击功能的灵魂,很多 AI 算法里的“确定性推进”也建立在同样的逻辑上。
4. 胜负判定与渲染细节
4.1 什么时候算赢,什么时候算输
失败判定发生在点击雷格的那一刻,这个很简单。但是胜利判定容易写反——很多新人会写成“所有雷都被插上旗子”,这其实是错的。因为玩家可以插错旗子,这样永远无法通过正确的插旗来获胜。
官方扫雷的胜利条件是:所有非雷格子全部被翻开。不管你有没有插旗、插了几面旗,只要把 0-8 的格子全部翻开,就赢了。所以胜利判定应该是:
javascript复制function checkWin() {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (!board[r][c].mined && !board[r][c].revealed) {
return false;
}
}
}
return true;
}
这里一个可优化的点是维护一个 revealedCount 计数,翻开一个格子就加一,胜利时判断 revealedCount === totalCells - mineCount 即可,不用每次都遍历整个棋盘。棋盘小的时候无所谓,但如果你跟我一样拿它跑自动扫雷模拟器,一秒要跑几千局,这个遍历的开销就值得省掉了。
4.2 翻车后的雷区展示规则
游戏失败时,除了玩家踩中的那颗雷,是否要把所有雷都显示出来?我见过两种设计:一种只展示踩中的雷,其余保持隐藏;一种全部显示。大多数经典扫雷选择后者,而且还有一个细节:把玩家插错旗的格子(没雷却插了旗)单独标记为“错旗”样式,通常是个叉号。
这个设计的价值其实是教育性的——让玩家知道哪里猜错了。在自动扫雷器里,这类反馈会被用来修正算法策略。如果是纯练手项目,展示所有雷也不会带来逻辑问题,反而调试起来更容易。
4.3 渲染方案:DOM 还是 Canvas
扫雷棋盘的渲染方案选择与棋盘规模有很大关系。初级 9×9 用 DOM 生成 81 个格子完全没问题,代码简单、事件绑定直观、调试方便。但如果你要做 50×50 的大棋盘,DOM 节点数量上升到 2500 个,每次翻牌都操作大量 DOM 会让性能变得很吃力。
我的建议是:如果是做项目练手,第一版用 DOM 实现就好,明确逻辑后再考虑 Canvas 优化。如果是做自动扫雷算法测试平台,Canvas 是必然选择,因为你要在短时间内高频刷新所有格子的状态,DOM 的布局计算会成为瓶颈。
用 Canvas 绘制格子并不复杂,关键是把格子的四种视觉状态映射清楚:
- 未翻开:灰色凸起,无文字
- 已翻开数字:深色底,根据数字显示不同颜色(1 蓝色、2 绿色、3 红色……)
- 旗子:红色小旗图标
- 问号:蓝色问号文字
这些视觉规则的背后是扫雷的 UX 设计逻辑,通过颜色和凸起凹陷的视觉语言,让玩家一眼就能区分“可点击区域”和“已揭示区域”。
5. 常见问题与排查心得
5.1 数据正确性验证:先写测试再写 UI
扫雷这类游戏,边界条件太多,手动点格子很难穷举所有情况。我强烈建议先把核心逻辑抽成纯函数,然后写自动化测试验证几类关键场景:
javascript复制// 测试1:3x3棋盘,中心布雷,周围格子数字应全部为1
// 测试2:边界格子布雷,相邻数字计算不越界
// 测试3:翻开的0区域应扩散到整片连通空白区
// 测试4:插旗的格子不能被翻开
// 测试5:非雷格全部翻开后,游戏判定胜利
我用 Node 的 test 模块跑这些测试,每改一次逻辑就跑一遍,能避免大量手工点击的重复劳动。尤其是当你后续要加“自动扫雷器”时,如果没有测试保护,算法逻辑一改,游戏核心规则可能就悄悄坏了,你还不一定能及时发现。
5.2 隐藏很深的状态不同步问题
一个我踩过比较深的坑是:格子状态变了,但 UI 没有同步刷新。比如玩家插旗后,flagged 已经变成 true,但界面上格子看起来还是普通未翻开状态。这类问题 90% 是因为渲染逻辑依赖了一个被缓存的副本,或者事件绑定里读取了旧的 board 引用。
解决方案是每次状态变更后统一调用一次 renderBoard(),不要想着“只更新被点击的那一个格子”。在 DOM 方案下全量重绘棋盘只有 81-480 个节点,性能消耗完全可以接受;在 Canvas 方案下,全量绘制也只是一次 fillRect 循环。全量重绘能显著降低状态同步 bug 的发生概率。
5.3 事件委托与动态生成格子的兼容
如果你用 DOM 的方式动态生成格子,每个格子都单独绑定 addEventListener,事件监听器数量会达到几百个。虽然这在实际运行中通常不会出问题,但更优雅的做法是用事件委托——在棋盘容器上监听 click 和 contextmenu,通过 event.target 拿到格子坐标:
javascript复制boardElement.addEventListener('click', (e) => {
const cellElement = e.target.closest('.cell');
if (!cellElement) return;
const row = parseInt(cellElement.dataset.row);
const col = parseInt(cellElement.dataset.col);
handleLeftClick(row, col);
});
boardElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
const cellElement = e.target.closest('.cell');
if (!cellElement) return;
const row = parseInt(cellElement.dataset.row);
const col = parseInt(cellElement.dataset.col);
handleRightClick(row, col);
});
这种做法的好处是:棋盘重建时无需清理旧监听器,只需要重新生成 DOM 节点即可。另外一定记得 preventDefault 阻止浏览器默认的右键菜单,否则右键状态循环永远点不出来。
5.4 浏览器栈溢出之外的实际报错
递归爆栈并不只在超大棋盘出现。如果你在使用我上面给的递归 revealCell 版本,并且游戏棋盘是一个“回”字形空白区域,某些点的递归深度可能达到棋盘边长级别。初级棋盘 9 深、高级棋盘 16 深,看起来很安全,但一旦你写自定义模式把棋盘调成 100×100,一次全开就可能触发栈溢出。所以我后来干脆所有日志都统一走迭代栈版本,既避免了爆栈,又方便后续加动画。
6. 进阶玩法:自动扫雷器的算法思路
写完了完整可玩的扫雷,接下来值得做的事就是写一个自动扫雷器来验证你的核心逻辑可靠性。我认识的很多人都是从这个阶段开始对扫雷游戏进入更深层认知的。
6.1 确定性推进:什么时候可以无脑下判断
自动扫雷器的第一层逻辑是“确定性推进”。它基于两条简单规则:
- 如果某个已翻开的数字格,周围未插旗的未知格数量等于该数字,那么这些未知格全部是雷,可以插旗。
- 如果某个已翻开的数字格,周围已经插旗的数量等于该数字,那么其余未知格全部安全,可以翻开。
这两条规则实质上就是“双击展开”的自动化版本。在扫雷中,凡是能用这两条规则处理的情况都不需要猜测,属于必然正确的操作。
更严谨地实现的话,这一步要把棋盘上的格子状态抽象成“已翻开数字”、“已插旗”、“未翻开未知”三类,然后遍历所有已翻开数字格做约束判断。
6.2 约束传播与猜雷策略
确定性推进只能解决一部分局面,走到后面总会碰到“必须猜”的情况。这时候可以用更高级的多格约束分析,比如“一个数字与多个未知格构成的约束系统”,通过组合优化判断哪些格子必然安全。
高阶思路的核心是把扫雷转化成“数独式”的约束满足问题。比如某个 1 的周围有 A、B 两个未知格,另一个 2 的周围有 B、C 两个未知格,综合分析可以推出 A 是雷、C 不是雷。这种推理虽然没有确定性规则那么直接,但用回溯搜索完全可以在毫秒级内完成。
如果组合搜索也解不出来,就启动概率估算:对每个未知格,用约束条件估算它是雷的概率,选择概率最低的格子尝试翻开。这一层逻辑的工程实现很有趣,而且能显著拉高扫雷器的胜率。
6.3 为什么自动扫雷器能反过来验证游戏逻辑
自动扫雷器本质上是一个暴力使用者,它会在极短时间内执行成百上千次翻开和插旗操作。如果游戏核心逻辑有边界 bug,比如某个特殊情况下数组越界没被拦截,自动扫雷器大概率能触发到。我会在自动扫雷器运行后统计“不该爆却爆了”的失败局数,一旦出现,就说明游戏核心逻辑有漏洞。用这种思路做回归测试,比单纯手动点几百盘覆盖率高得多。
7. 后续扩展方向
扫雷做完基础版后,可以往这些方向继续加内容:增加“解除首点保护”的真·专家模式、加入撤销与回放功能、做排行榜和步数统计、把棋盘渲染升级成 Canvas 动画版本、加入移动端手势支持。如果你对算法感兴趣,还可以深入做扫雷求解器,写一篇“扫雷概率推断算法”的进阶拆解。
我在实际开发中比较大的体会是:扫雷最大的价值从来不是它本身有多难,而是它能强迫你把规则里的每一个隐藏假设都变成深思熟虑后的代码逻辑。你在实现“首点保护”时,如果没意识到安全区上空余格子不够放雷怎么办;在实现“双击展开”时,如果没意识到旗子插错了会让玩家死于过度信任。这些“如果”正是写项目最有营养的部分。把这个项目从规则到 UI 到算法完整走一遍之后,你会发现自己对状态管理和边界条件的敏感度会有一个明显的提升。
