资讯动态

扫雷核心算法拆解:JavaScript状态机与递归展开实战

发布时间:2026/10/18 2:46:53 来源:尧图企业网站定制
做扫雷项目很多人第一次都是跟着某个教程在页面上点来点去最后跑起来就觉得自己会了。可一旦想改棋盘尺寸、换个交互方式或者加一个自定义难度立刻露馅。问题不出在手速而在于整个项目根本没有拆开逻辑和界面糊在一起边界条件靠运气递归展开写得稀里糊涂。这一期我决定把扫雷项目彻底拆开重做。第一期不碰视觉界面只做算法核心棋盘生成、布雷、数字计算、翻开与标记、胜负判断。所有验证都通过命令行打印ASCII棋盘来完成。换句话说这篇博文的价值不是教你画一个网页版扫雷而是先把逻辑做扎实后续接任何界面都不慌。如果你是想练手的小白或者想理解递归展开、状态机设计在实际小游戏里怎么落地的开发者这篇应该能帮到你。我会把每一步为什么这么做、踩坑点在哪里都写出来包括代码层面容易忽略的细节。整个项目用纯JavaScript写跑在Node环境里不依赖第三方库。1. 先把游戏拆成一段可验证的逻辑而不是急着写页面1.1 我从硬编码小棋盘开始而不是一上来就追求完整功能做扫雷最容易犯的错是一开始就把棋盘尺寸写死成9x9、10颗雷然后所有逻辑都围绕这个固定尺寸编码。我第一次做的时候就是这样后面想改成16x16立刻到处都是毛病因为所有循环、边界判断、数组访问都隐含了9和10这两个数字。更好的做法是从一开始把棋盘规模参数化行数、列数、雷数都作为输入。哪怕你调试的时候永远先用3x3、1颗雷的小棋盘也要把函数签名设计成createGame(rows, cols, mines)这种形状。小棋盘能让你肉眼验证每一步大棋盘验证性能和边界两者互不干扰。我这一期所有代码都用纯JavaScript写跑在Node环境里不引用任何第三方库。选择Node而不是直接在浏览器里写原因很简单浏览器里的DOM会干扰你排查逻辑问题。我想先得到一块能打印出来的棋盘能看到每个格子的状态变化之后再考虑怎么把它渲染成像素。这个顺序你一旦试过就会觉得舒服因为绝大部分debug根本不需要浏览器。1.2 扫雷最少需要哪几个状态字段每个格子最少要记住四件事是否是雷、是否已经翻开、是否被插旗、周围一圈有多少颗雷。这个数据结构可以从一开始就定好后面所有函数都围绕它转。我实际用的格子对象长这样function createCell() { return { isMine: false, revealed: false, flagged: false, adjacent: 0 }; }有人会问adjacent能不能不存每次翻开时现算可以但会很啰嗦。因为翻开操作会频繁读取周围格子的信息与其每次循环计算不如在游戏初始化时把所有格子的adjacent预计算好后面就是一次属性读取的事。这个权衡在扫雷这种小规模游戏里不明显但养成不变数据预处理的习惯对后续做其他项目也有帮助。游戏层面还需要记录几个额外状态当前是READY等待第一次点击、PLAYING游戏进行中、WIN或LOSE结束。READY这个状态很容易被忽略但它非常关键直接关系到第一次点下去绝对不能踩雷这个规则能不能干净地实现。如果一上来就布雷等到玩家第一次点击时再去保护处理起来就会很绕更好的做法是第一次点击前不布雷等拿到安全区后再生成雷局。2. 棋盘数据结构和布雷算法那几个容易写错的角落2.1 二维数组是直观但访问时要注意行列顺序棋盘我直接用二维数组存储board[r][c]代表第r行第c列。很多人写着写着会把board[c][r]和board[r][c]混用尤其在遍历的时候外层循环用了c内层用了r结果数组越界或逻辑错乱。一个简单的规避习惯所有函数里统一用rows和cols命名循环顺序永远是先r后c。这个细节看起来不上台面但扫雷项目90%的隐蔽bug都来自行列顺序不一致。另一个细节是如果棋盘比较小可以用row和col分开取名避免缩写歧义。初始化棋盘的代码function createBoard(rows, cols) { const board []; for (let r 0; r rows; r) { board.push([]); for (let c 0; c cols; c) { board[r].push(createCell()); } } return board; }如果是更大的棋盘还可以考虑用一维数组board[index]其中index r * cols c访问稍微绕一点但内存更紧凑。扫雷这个规模用二维数组完全够了不需要为了炫技牺牲可读性。数组的rows和cols拼接成[r, c]传入函数时也一定要保持同样的顺序。2.2 布雷用洗牌取前N个而不是循环碰运气最容易写错的环节是布雷。新手常见写法是while (mines totalMines) { const r randomInt(rows); const c randomInt(cols); if (!board[r][c].isMine) { board[r][c].isMine true; mines; } }这种写法在雷数接近格子总数时会非常慢比如99颗雷放到10x10的格子里后面每放一颗都要反复随机碰撞好多次。虽然扫雷的正常参数不会让性能出严重问题但代码风格不好而且很难做安全区排除。更稳妥的做法是先把所有坐标放进一个数组洗牌然后取前N个作为雷位。洗牌我建议用Fisher-Yates算法它的随机性足够且时间复杂度是O(n)。代码是这样的function shuffle(arr, random Math.random) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; } function placeMines(board, rows, cols, mineCount, safeCells) { const positions []; for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (!safeCells.some(([sr, sc]) sr r sc c)) { positions.push([r, c]); } } } shuffle(positions); for (let i 0; i mineCount; i) { const [r, c] positions[i]; board[r][c].isMine true; } }safeCells是首次点击保护用得上的排除区域后面会细说。注意positions是把所有候选坐标收集齐了再洗牌洗完之后直接拿前mineCount个代码里没有多余的判断。如果你担心safeCells过大导致可用坐标不够需要在调用方做防御性检查mineCount必须小于等于positions.length否则会出现雷数不够的问题。2.3 首击保护先把安全区域排除掉再布雷扫雷的标准体验是第一次点击绝对不能踩雷。实现方式有两种。第一种是先点再布雷鼠标点下去之后才生成雷位并把点击位置排除在外。第二种是预先布雷但布雷时就把点击位置及其周围一圈格子全部留空。我这一期采用的是第二种先根据点击坐标计算出一块安全区布雷时跳过安全区。这样棋盘只要生成一次后续逻辑更简单。安全区的范围决定了首击体验如果只排除点击的那一格虽然首击不炸但可能出现点击后周围紧挨着雷玩家体验不好。经典扫雷通常会保证首击区域附近有一定空白。我实际实现时把点击格周围的3x3区域都作为安全区这样首击后大概率能展开一片而不是只翻开孤零零一个格子。安全区计算function getSafeZone(rows, cols, firstRow, firstCol) { const safe []; for (let dr -1; dr 1; dr) { for (let dc -1; dc 1; dc) { const r firstRow dr; const c firstCol dc; if (r 0 r rows c 0 c cols) { safe.push([r, c]); } } } return safe; }唯一的限制是当棋盘本身特别小或者安全区算出来大于剩余格数时要保证雷数不超过可布雷格数。我在createGame入口会做一个防御性判断避免mineCount rows * cols - safeZone.length导致布雷循环拿不到足够的坐标。你可以选择抛异常也可以降级成只排除点击格看具体需求。2.4 周围雷数预计算偏移表比八个if更省心布雷完成之后需要给每个非雷格子计算adjacent。很多人会写八个if类似if (board[r-1][c-1].isMine) count; if (board[r-1][c].isMine) count;代码极其冗长还容易漏掉某个方向。我更喜欢用一个方向偏移表统一处理const dirs [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; function calcAdjacent(board, rows, cols) { for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (board[r][c].isMine) 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].isMine) { count; } } board[r][c].adjacent count; } } }这个偏移表后面递归展开、实现双击翻开时都能复用所以它应该被定义成模块级别的常量而不是每次函数调用时重新创建。计算完成后整张棋盘的信息就固定了哪些格是雷、每个非雷格周围的雷数是多少剩下的就全是交互逻辑的事。3. 翻开与递归蔓延flood fill 的边界思考3.1 为什么点中空白格要把整片空白全部展开扫雷最核心的交互是翻开格子。当一个格子的adjacent为0时它周围没有雷按照规则应该继续翻开它周围的格子形成一个连锁反应。这个逻辑本质上是洪水填充flood fill只是多了边界判断。我第一次写这个递归时以为只要对四周四个方向递归就够了结果碰到斜对角也有空白格的情况展开范围少了一圈看起来非常奇怪。其实八个方向都要递归因为扫雷的周围指的是九宫格。如果你只上下左右递归点开一个角落空白格时斜向的空白格不会被触达整个空白区域会裂成几块非常影响体验。标准递归版本function revealCell(board, rows, cols, r, c) { if (r 0 || r rows || c 0 || c cols) return; const cell board[r][c]; if (cell.revealed || cell.flagged || cell.isMine) return; cell.revealed true; if (cell.adjacent 0) { for (const [dr, dc] of dirs) { revealCell(board, rows, cols, r dr, c dc); } } }这里有两个容易踩的坑。第一个是忘记检查flagged导致被插旗的格子也被翻开破坏了玩家对雷位的标记预期。第二个是忘记检查isMine如果第一次点击保护没做好递归会一路踩到雷并直接触发游戏结束而在展开逻辑里直接碰到雷又会导致连锁反应把整片雷都翻开。3.2 递归深度真的会爆栈吗我用一个超大棋盘试了试很多教程会警告小心递归爆栈。扫雷标准棋盘最大一般是30x30、99颗雷递归深度最多也就是几百层现代JavaScript引擎默认栈深度通常有几万层远不至于爆栈。但如果你把棋盘调大到100x100甚至200x200空白区域又连成片递归深度就可能达到几千层甚至上万层这时候确实存在栈溢出风险。为了保险我在这一期实现里保留递归版本因为它直观同时在代码注释里写上如果未来要支持超大棋盘可以改成显式栈。显式栈版本的关键是先把待处理坐标放入栈再循环弹出处理不会产生函数调用栈累积function revealCellIterative(board, rows, cols, startR, startC) { const stack [[startR, startC]]; while (stack.length 0) { const [r, c] stack.pop(); if (r 0 || r rows || c 0 || c cols) continue; const cell board[r][c]; if (cell.revealed || cell.flagged || cell.isMine) continue; cell.revealed true; if (cell.adjacent 0) { for (const [dr, dc] of dirs) { stack.push([r dr, c dc]); } } } }两种版本在标准棋盘上行为完全一致。我实际测试时递归版本到200x200的棋盘仍然没问题但显式栈版本心里更踏实。你如果刚开始学先写递归版理解清楚之后再看栈版不要一上来就把两个混在一起。3.3 翻开时的边界条件已翻开、插旗、越界顺序到底怎么排边界条件检查的顺序也有讲究。我习惯把越界检查放在最前面因为它不涉及数据访问直接返回最安全。其次是revealed检查防止重复展开同一个格子。然后是flagged检查最后才是isMine检查。如果把isMine放在flagged之前会导致标记过的雷也被翻开如果把revealed放在最后可能会导致同一个格子在递归中被反复处理虽然结果一样但做了很多无用功。这个顺序问题在写递归展开时格外明显因为一个意外越界或重复处理都可能让整片空白区域的行为变得不可预测。一个容易忽略的点是在实际游戏里玩家点击一个已经翻开的数字格应该触发chord操作如果周围旗子数等于数字则翻开周围未标记的格子而不是简单地再次翻开。这个我在第一期先不做第二期接交互时会补上。但数据结构已经留好了多加一个判断即可。4. 把游戏逻辑做成一个纯函数状态机4.1 为什么要多此一举维护一个状态很多人做扫雷会直接把逻辑散落在各种事件处理函数里点击函数里判断胜负右键函数里改标记然后到处用全局变量记录状态。这样写完确实能跑但你会发现一个问题很难测试。因为全局变量牵一发动全身你可能刚改了布雷逻辑结果点击展开的代码也跟着出问题排查起来非常痛苦。我这一期刻意把逻辑收敛成一个状态机对外只暴露几个动作reveal翻开、flag插旗/取消旗、restart重置。内部自己维护棋盘和游戏状态不依赖任何外部变量。这样我可以把整个游戏逻辑当成一个纯粹的对象来测试。设计上其实很简单function createGame(rows, cols, mineCount, random Math.random) { let board, status READY, firstMove true; function init(safeZone []) { board createBoard(rows, cols); placeMines(board, rows, cols, mineCount, safeZone); calcAdjacent(board, rows, cols); } function reveal(r, c) { if (status WIN || status LOSE) return; const cell board[r][c]; if (cell.revealed || cell.flagged) return; if (firstMove) { const safeZone getSafeZone(rows, cols, r, c); init(safeZone); firstMove false; status PLAYING; } if (cell.isMine) { cell.revealed true; status LOSE; return; } revealCell(board, rows, cols, r, c); if (checkWin()) status WIN; } function flag(r, c) { if (status WIN || status LOSE) return; const cell board[r][c]; if (cell.revealed) return; cell.flagged !cell.flagged; } function checkWin() { let revealedCount 0; const total rows * cols; for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (board[r][c].revealed) revealedCount; } } return revealedCount total - mineCount; } return { reveal, flag, getStatus: () status, getBoard: () board }; }这里有一个设计上的取舍第一次点击之前并没有真正的雷局所以init可以延迟到第一次reveal时执行。这样做的好处是状态机内部永远不需要处理棋盘已经生成但还没处理首击保护的中间状态。第一次reveal时安全区算好然后用新的board替换掉旧的。这其实是一种惰性初始化。如果一开始就布雷后面再想把首击格剔除就得重新生成反而更麻烦。4.2 状态流转READY → PLAYING → WIN/LOSE状态机的流转非常清晰READY等待第一次点击。此时棋盘空间已分配但还没布雷等reveal拿到安全区后再做真正的初始化。PLAYING游戏进行中reveal和flag都可用。WIN翻开格子数等于总格数减去雷数锁定所有操作。LOSE翻到雷锁定所有操作。有人会问既然首次点击前不布雷为什么READY状态下还要有board因为我要在状态机外部提供getBoard()方便调试时打印。打印出来的棋盘可以只是一堆未初始化的格子不影响后续逻辑。真正容易被忽略的是flag操作不应该改变游戏状态但reveal操作需要同时处理首击保护、输赢判断和递归展开。这样一个入口函数里塞了三件事确实有点多但逻辑主线很干净先处理第一击再处理踩雷最后处理展开和胜利。顺序不能乱否则会出现踩雷后还继续展开其他格子的怪现象。4.3 把状态机做成纯函数的好处状态机本身不操作DOM、不读浏览器API、不依赖任何外部环境这就意味着我可以把它直接挪到任何地方复用。第二期做Web界面时只需要在点击事件里调用game.reveal(r, c)然后根据getBoard()的结果重新渲染不需要改任何核心逻辑。这种逻辑与表现分离的思路放到更大的项目里就是领域模型与视图层的边界。扫雷虽小但它是理解这种设计思想成本最低的练手项目之一。另外纯函数状态机天然适合做单元测试。因为每次调用都只依赖传入的参数和内部状态结果可预期、可重复。你不需要模拟鼠标事件不需要渲染DOM直接在测试脚本里调用reveal和flag然后断言状态和棋盘内容几行代码就能把核心规则全部锁死。5. 测试这个玩具项目随机种子、debug输出与自动化断言5.1 把随机种子暴露出来所有bug都可以稳定复现做游戏逻辑最恼火的事情是bug出现一次等你打开调试器准备看的时候它又消失了。原因是Math.random()每次结果都不一样导致雷位每次都不一样问题无法复现。比较实用的做法是自己写一个带种子seed的伪随机数生成器把游戏的随机源从Math.random()替换成这个生成器。这样只要固定种子整盘棋的雷位、洗牌顺序完全可复现。我用的是一段很经典的可种子随机数生成器体积小、无依赖function mulberry32(seed) { let a seed; return function () { a | 0; a (a 0x6D2B79F5) | 0; let t Math.imul(a ^ (a 15), 1 | a); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; }然后把createGame的random参数传进去内部所有随机操作都通过random()来完成。比如shuffle函数里默认用Math.random但可以传入自定义随机函数。这样在测试环境里我可以稳定地复现一个棋盘在生产环境里不传random就还是默认随机。种子除了能复现bug还可以用来做首击保护是否有效的批量验证跑1000个不同种子每次模拟点击断言绝不会踩雷。这个测试虽然简单但比你自己看100遍代码都可靠。5.2 用ASCII棋盘做debug输出20行代码解决可视化问题既然第一期不做UI那如何看到棋盘状态我写了一个很小的ASCII打印函数数字代表周围雷数*代表雷F代表旗子.代表未翻开。这样每次测试后都能直接看结果。function printBoard(board) { for (let r 0; r board.length; r) { let line ; for (let c 0; c board[r].length; c) { const cell board[r][c]; if (cell.flagged) line F; else if (!cell.revealed) line .; else if (cell.isMine) line *; else line String(cell.adjacent); line ; } console.log(line); } }输出效果类似. . . . . . 1 . . . . F . . . . 1 . . .这个打印函数看似不起眼但它帮我在写递归展开时快速定位空白区域是不是完整展开、旗子是否阻挡了展开这类问题。很多bug一眼就能从ASCII棋盘上看出端倪。比如你发现一个数字周围明明没有旗子但递归就是不展开打印出棋盘后立刻就能看到是某个格子被意外标记成了F。5.3 一个40行的测试脚本能覆盖哪些核心场景我写了一个简单的Node测试脚本用assert自带的模块没有引入测试框架。主要覆盖这几个场景首次点击必定不是雷且安全区周围不会再出现雷。翻开一个数字格后只有这个格子被翻开。翻开一个空白格后相邻空白区域被连锁展开。插旗后翻开无效游戏状态不变。踩雷后状态变为LOSE且后续操作全部无效。翻开所有非雷格后状态变为WIN。测试脚本大概长这样const game createGame(9, 9, 10, mulberry32(42)); game.reveal(0, 0); assert.notStrictEqual(game.getStatus(), LOSE); assert.strictEqual(game.getBoard()[0][0].revealed, true);跑完这些断言核心逻辑的基本正确性就有了保障。这里我想特别强调一点测试的价值不在于数量多而在于它把我认为应该这样的规则变成了机器可验证的约束。后续重构时只要测试全绿我就不怕改坏功能。比如我想把递归展开改成显式栈版本只需要跑一遍测试如果全绿就说明行为没有变化这种信心是10个小时的手动点点点都换不来的。我自己在写测试脚本时还加了一个小功能每跑完一个用例就打印一次当前棋盘状态。这样一旦断言失败我能直接看到失败时的棋盘长什么样而不是只看到一个AssertionError。这个习惯让我少走了很多弯路。6. 第一版收尾三个重构教训与第二期路线图6.1 我重写过程中改掉的三个坏味道这个版本我前后重写了三次每次重写都解决了前一版里的一个真实问题。第一次重写是因为我把adjacent计算放在reveal里每次动态算导致展开一大片空白时性能肉眼可见地下降。后来移到初始化阶段预处理点击展开时的循环体只剩简单的属性判断速度快了很多。这个教训让我意识到游戏里不变的量应该尽早算好不要在热路径上重复计算。扫雷的雷位在开局之后就固定了周围雷数也固定了没必要在每次翻开时重新数一遍。第二次重写是因为我把flag和reveal的逻辑写在了两个独立函数里但都直接改全局数组。测试时发现一个边界case当游戏已经LOSE时插旗操作依然有效玩家可以在失败后继续标记格子。后来把状态检查统一放到状态机入口这个问题就消失了。这说明状态机入口统一做战斗检查很有必要把所有可能的状态分支都收口到一处而不是散落在各个函数里。第三次重写是因为我一开始用了一个isFirstClick的布尔标记配合READY状态一起判断结果发现两个标志位可能不一致。比如手动调用init()之后isFirstClick还是true但状态已经变成PLAYING逻辑就会乱。后来干脆用firstMove一个标志位驱动安全区初始化不再维护两个冗余状态。这个教训是当两个状态表示的是同一个含义时删掉一个。6.2 第二期计划渲染层、交互层和chord操作第一期做完逻辑层基本稳定了。第二期我打算接一个真正的界面渲染方式不限定可以是Canvas也可以是DOM网格。需要补的能力至少包括右键插旗/取消旗的交互、长按或双击翻开、数字格周围旗子数匹配时的chord操作、以及一个剩余雷数的指示器。chord操作的核心逻辑其实很薄当玩家点击一个已经翻开的数字格时检查它周围八个格子里的旗子数是否等于该数字如果相等就把周围未标记且未翻开的格子全部递归展开。这个操作在标准扫雷里很重要能大幅提升操作效率。由于核心逻辑已经封装好了到时候第二期更多是渲染和事件绑定的问题而不是把规则重新实现一遍。这也是我坚持第一期不写UI的原因把慢功夫花在最稳定的部分。6.3 给新手的几条实操建议最后从我自己的体验出发给准备做扫雷项目的朋友几条建议第一先把棋盘尺寸、雷数、安全区这些参数全部函数化。不要在任何地方写死数字这样后面改难度时只会改动入口参数。哪怕你现在只做9x9也要养成传参的习惯这是后面扩展的基础。第二尽早写测试和debug打印工具。哪怕只是一个几十行的ASCII棋盘打印函数也能帮你省下大量排查时间。很多人做小项目从来不测试遇到bug就靠print这是最痛苦的方式。你在print的时候其实已经花掉了比写测试多十倍的时间。第三在你开始写界面之前先确认核心逻辑全部通过测试。界面这个东西很容易让人分心你会花大量时间在CSS布局、点击事件、动画效果上如果逻辑本身还有问题你会在两层之间反复调试效率极低。先让逻辑稳如磐石再谈界面表现。第四不要过度设计。第一期的状态机设计已经能覆盖扫雷的所有规则不需要再加排行榜计时器多语言这些功能。先把核心规则跑顺后续每次只加一个需求每加一个需求就补一个测试。这样的节奏虽然慢但每一步都是稳的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑