做扫雷这个练习项目的时候,我正想找一个能串起事件绑定、递归、数组操作的题目。网上扫雷小游戏的实现很多,但大多数是把代码贴出来就不解释了,真正能讲清楚布雷、数字统计、递归展开这些核心点怎么设计的反而少见。这篇就按我实际动手的流程来写,从数据结构选型到成品可玩,把每一个关键决策背后的理由都说明白。无论你是刚学JavaScript不久的前端新手,还是想找个周末项目巩固一下基础的老手,跟着做完,你会得到的不只是一个小游戏,还包括一套“先想清楚再写代码”的工程习惯。
1. 扫雷小游戏项目设计:先想清楚再写代码
很多人拿到项目直接开始写界面,写到一半才发现数据结构和交互逻辑对不上,又回头重构。我个人的习惯是先把关卡定清楚:这个扫雷小游戏到底要做什么、用多大规模、哪些是第一版必须有的,哪些可以后面再加。理清楚之后,代码写起来会顺很多。
1.1 为什么我推荐用原生JavaScript做这个项目
扫雷这个项目特别适合用原生JavaScript来做,原因很简单:它的核心逻辑其实只有三块,数组数据、递归展开、事件处理。这三块正好是前端基础里最常用、也最容易被轻视的部分。如果用框架,很多东西被框架替你处理掉了,反而少了锻炼的机会。
而且原生实现还有一个实际好处:文件结构简单。一个HTML文件就能撑起来,不需要构建工具、不需要npm依赖。做完之后复制到任何地方都能打开,发给别人也能直接看效果。对于练手项目来说,这种“零门槛交付”体验很重要,它让你把注意力全部放在算法和交互上,不会被工程化工具分散。
我当时定下第一版的功能边界非常克制,就做好这几件事:三种经典难度(初级9x9、中级16x16、高级30x16)、左键翻开格子、右键标记旗帜、数字统计、递归展开、计时器、剩余地雷计数、重新开局。什么排行榜、自定义难度、音效,全部留到后面再说。第一版功能越少,核心逻辑越容易调试,这个取舍在项目实战里尤其重要。
1.2 数据结构选型:一维数组的账要怎么算
扫雷棋盘本质上是一个二维网格,所以很多人的第一反应是用二维数组,也就是grid[row][col]。这样写确实直观,grid[2][3]一看就知道是第二行第三列。但我最终选择了一维数组,用一个cells数组存储所有格子,每个格子是一个对象。
const cells = []; for (let i = 0; i < rows * cols; i++) { cells.push({ mine: false, // 是否是地雷 revealed: false, // 是否已翻开 flagged: false, // 是否被标记为旗帜 count: 0 // 周围地雷数 }); }一维数组的好处在于渲染和事件绑定特别方便。生成网格的时候,我只需要按顺序创建对应数量的格子元素,然后用index直接建立DOM元素和数据对象的对应关系。点击事件里通过>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]]; } } function placeMines(excludeIndexes) { const candidates = []; for (let i = 0; i < rows * cols; i++) { if (!excludeIndexes.includes(i)) { candidates.push(i); } } shuffle(candidates); const mineIndexes = candidates.slice(0, mineCount); mineIndexes.forEach(idx => { cells[idx].mine = true; }); }
注意这里的excludeIndexes是首击保护用的,我们马上讲到。这个布雷方式有一个隐含的好处:候选数组天然不会重复选到同一个格子,所以不需要额外的“是否已经埋过雷”判断。
2.2 首击保护:第一次点击绝不踩雷
原版扫雷有个经典设定:玩家第一次点击绝不会踩雷。很多初学者写完布雷之后发现,第一次点击就爆炸,把格子翻开一看雷是固定的,体验非常差。实现首击保护通常有两个思路。
第一个思路是:布雷完成后,如果玩家第一次点击踩到雷,就把这颗雷移到一个安全位置。第二个思路更优雅:第一次点击之前不布雷,等玩家点完,确定好安全区,再布雷。我用的就是第二种。
let firstClick = true; function handleFirstClick(index) { const exclude = getNeighbors(index); exclude.push(index); placeMines(exclude); firstClick = false; }这里的getNeighbors(index)返回目标格子周围的8个格子下标。为什么要把周围一圈也排除掉?因为扫雷有一个很重要的体验设计:点击一个数字为0的空格,会自动展开一大片区域。如果只排除点击位置本身,点击空格的瞬间,递归展开到邻居的时候发现邻居有雷,就会立刻踩雷,体验会非常差。把周围9格都设为安全区,首击展开之后至少能留出一个小范围的缓冲地带。
还有一个细节:如果某个极端自定义模式下,地雷数量接近可用格子数,排除安全区后剩下的格子可能不够布雷。代码里最好做一个校验,如果candidates.length < mineCount,就提示调整参数,避免运行时出错。
2.3 数字统计:两种写法的取舍
布雷完成之后,就要给每个非雷格子计算周围8格的地雷数量。这个逻辑有两种写法。
第一种写法是遍历所有非雷格子,对每个格子再遍历它的8个邻居,数一数邻居中有几颗雷。这种写法的时间复杂度为O(8N),代码直观,适合初学者。
第二种写法反过来:遍历所有地雷,每找到一颗雷,就给它的8个非雷邻居的count加1。
cells.forEach((cell, idx) => { if (cell.mine) { getNeighbors(idx).forEach(n => { cells[n].count++; }); } });地雷的count值不会用到,因为地雷格子永远不会显示出数字,所以不需要跳过地雷邻居。两种写法都能得到正确结果,但我实际项目里更倾向于第二种。原因是扫雷棋盘上地雷数量通常远小于总格子数,第二种遍历次数少很多。更重要的是,这种“由雷向邻居广播”的思路在大规模场景下更好扩展。比如以后要做“无猜模式”,在布雷和数字统计之间可能还需要插入别的逻辑,广播方式更直观。
不过我也要说清楚:如果你是为了读代码方便,选第一种完全没问题。N最大也就480,这个量级性能差异几百微秒都不到。重点是要想清楚自己为什么选,而不是复制一种写法就完事。
2.4 递归展开:零附近自动开花的机制
扫雷最核心的体验,就是点击一个数字为0的格子,周围所有相邻的空区域全部展开,直到碰到有数字的格子边界为止。这个机制实现起来就是递归。
function revealCell(index) { const cell = cells[index]; if (cell.revealed || cell.flagged) return; cell.revealed = true; revealedCount++; if (cell.mine) { handleLose(index); return; } if (cell.count === 0) { getNeighbors(index).forEach(n => revealCell(n)); } }这个函数要特别注意两个地方。
第一,revealed标记必须在递归之前设置。你想想看,A格子是0,它先把自己标记为已翻开,然后依次检查邻居B、C、D。如果邻居B也是0,B再检查它的邻居,其中包括A。此时A已经标记为revealed,就直接返回,不会重复入栈。如果你把标记动作放在递归之后,第一次访问A时A还是未翻开状态,递归到B之后B又递归回到A,A再次进入逻辑,就会无限循环,浏览器直接卡死。这个bug我在调试里反复遇到,细节放到后面说。
第二,检查flagged很重要,旗帜标记的格子不应该被空格展开强制翻开。比如你怀疑某格是雷,手动标了旗,结果旁边点开一个空格把这个格子顺手翻开了,那标记就没有意义了。所以展开时遇到带旗的格子直接跳过。
还有一个性能问题值得说:有些实现喜欢用数组保存一份“已访问”集合,每次访问都从集合里查一遍。这里完全没必要,直接在格子对象上用布尔字段标记就行。扫雷棋盘最大480格,递归深度不会超过480层,栈空间完全够用,不需要别出心裁改成非递归写法。
3. 完整代码实现:搭一个能玩的扫雷小游戏
算法想清楚了,代码写起来就是体力活。这一章给出页面骨架、核心样式和完整JavaScript逻辑。我尽量按实际项目的组织方式展示,方便你直接对照着抄。
3.1 页面骨架与基础样式
HTML结构保持极简,顶部是信息栏,中间是棋盘容器。
<div id="app"> <div class="header"> <select id="difficulty"> <option value="easy">初级 9x9</option> <option value="medium">中级 16x16</option> <option value="hard">高级 30x16</option> </select> <button id="resetBtn">重新开始</button> <div class="info"> <span id="mineCount">10</span> <span id="timer">000</span> </div> </div> <div id="board"></div> </div>棋盘我用CSS Grid布局,列数和格子数量由JavaScript动态生成时设置。CSS里把格子的基础状态做出来,再通过状态类切换显示效果。
#board { display: grid; gap: 0; width: max-content; border-top: 3px solid #999; border-left: 3px solid #999; user-select: none; } .cell { width: 32px; height: 32px; background: #c0c0c0; border: 2px solid; border-color: #fff #999 #999 #fff; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold; cursor: pointer; box-sizing: border-box; } .cell.revealed { border-color: #999; background: #e0e0e0; cursor: default; } .cell.mine-revealed { background: #ffcccc; } .cell.flagged { color: #d00; }格子的立体边框是扫雷的经典视觉语言,没翻开的格子有凸起效果,翻开后变成平面。这套样式值得保留,老玩家一看就有亲切感。
3.2 初始化、布雷与渲染
JavaScript部分,我习惯先定义配置和全局状态,再写初始化函数。
const configs = { easy: { rows: 9, cols: 9, mines: 10 }, medium: { rows: 16, cols: 16, mines: 40 }, hard: { rows: 16, cols: 30, mines: 99 } }; let rows, cols, mineCount; let cells = []; let boardEl; let firstClick = true; let timerId = null; let seconds = 0; let revealedCount = 0; let flagCount = 0; function getNeighbors(index) { const r = Math.floor(index / cols); const c = index % cols; const result = []; for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { if (dr === 0 && dc === 0) continue; const nr = r + dr; const nc = c + dc; if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) { result.push(nr * cols + nc); } } } return result; }初始化时根据当前难度重新生成数据,并渲染棋盘格子。
function initGame() { const cfg = configs[difficultySelect.value]; rows = cfg.rows; cols = cfg.cols; mineCount = cfg.mines; cells = []; for (let i = 0; i < rows * cols; i++) { cells.push({ mine: false, revealed: false, flagged: false, count: 0 }); } firstClick = true; revealedCount = 0; flagCount = 0; seconds = 0; clearInterval(timerId); timerId = null; updateTimerDisplay(); updateMineDisplay(); boardEl.innerHTML = ''; boardEl.style.gridTemplateColumns = `repeat(${cols}, 32px)`; for (let i = 0; i < rows * cols; i++) { const div = document.createElement('div'); div.className = 'cell'; div.dataset.index = i; boardEl.appendChild(div); } }注意initGame里把clearInterval放在生成新棋盘之前。计时器这个坑我在调试部分专门讲。
3.3 点击与旗帜标记
事件绑定我选择挂在棋盘容器上,用事件委托来处理。这样不管格子数量多少,只需要绑定一次,而且重新开局后也不用解绑旧事件。
boardEl.addEventListener('click', (e) => { const index = Number(e.target.dataset.index); if (index === undefined || index === null) return; onCellClick(index); }); boardEl.addEventListener('contextmenu', (e) => { e.preventDefault(); const index = Number(e.target.dataset.index); if (index === undefined || index === null) return; onCellRightClick(index); });左键点击的逻辑分几步:如果是第一次点击,先布雷再翻开;如果当前格子已经翻开或是旗帜,直接忽略;如果游戏已经结束,忽略所有操作。
function onCellClick(index) { if (gameOver) return; const cell = cells[index]; if (cell.revealed || cell.flagged) return; if (firstClick) { const safeZone = getNeighbors(index); safeZone.push(index); placeMines(safeZone); calculateCounts(); firstClick = false; startTimer(); } revealCell(index); checkWin(); }右键标记的逻辑也很直接:已翻开的格子不能标记,已经是旗帜的取消标记,未标记且剩余旗帜数量大于0则标记。
function onCellRightClick(index) { if (gameOver) return; const cell = cells[index]; if (cell.revealed) return; if (cell.flagged) { cell.flagged = false; flagCount--; } else { if (flagCount >= mineCount) return; cell.flagged = true; flagCount++; } const el = boardEl.children[index]; el.classList.toggle('flagged'); el.textContent = cell.flagged ? 'F' : ''; updateMineDisplay(); }旗帜显示我用的字母F。原本想找一个更像旗子的符号,但考虑到不同系统对符号的渲染差异太大,有的会显示成emoji表情,看着很乱,字母F简单、干净、跨平台一致。界面风格完全可以用CSS再做得花哨一些,但核心标识尽量保持稳定。
3.4 胜负判定与重新开局
胜利条件很明确:所有非地雷格子都被翻开。所以每次翻开后统计revealedCount,如果等于rows * cols - mineCount,就赢了。
function checkWin() { const totalSafe = rows * cols - mineCount; if (revealedCount === totalSafe) { gameOver = true; stopTimer(); cells.forEach((cell, idx) => { if (cell.mine) { boardEl.children[idx].classList.add('flagged'); boardEl.children[idx].textContent = 'F'; } }); } }失败处理则是在revealCell里触发的。踩到雷时,把所有雷显示出来,踩中的那颗雷用特殊样式标红。
function handleLose(index) { gameOver = true; stopTimer(); cells.forEach((cell, idx) => { const el = boardEl.children[idx]; if (cell.mine) { el.textContent = '*'; el.classList.add('revealed'); } }); const hitEl = boardEl.children[index]; hitEl.classList.add('mine-revealed'); }重新开局时把难度下拉框的值读出来,再initGame一遍,所有数据和DOM都会重置。
resetBtn.addEventListener('click', () => { gameOver = false; initGame(); });到这里,一个能从头玩到尾、能赢能输的扫雷小游戏就完成了。接下来讲讲我在实际调试过程中踩过的坑,这些才是最费时间的部分。
4. 调试实录:容易翻车的细节全在这里
代码写出来能跑是一回事,把交互体验打磨到“像真正的扫雷”是另一回事。这一章记录我过程中遇到过的几个典型问题,每一个都是实际会出现的场景。
4.1 右键标记与浏览器默认菜单的冲突
第一次做的时候,我以为给棋盘绑定一个mousedown,判断event.button === 2就能处理右键。实际测试发现,在Mac触控板上双指点击时,mousedown的button参数在某些浏览器里并不稳定,而且无论如何鼠标右键松开时,浏览器都会弹出默认菜单。
正确做法是用contextmenu事件。在事件处理器里,先e.preventDefault()阻止默认菜单,再执行标记逻辑。这个事件在Windows和Mac上都能触发,兼容性最稳。
boardEl.addEventListener('contextmenu', (e) => { e.preventDefault(); const index = Number(e.target.dataset.index); if (index === undefined || index === null) return; onCellRightClick(index); });还有一个坑:如果点击的目标是#board容器本身而不是某个格子,e.target.dataset.index会是undefined。我一开始没判断就直接Number(undefined)转成了NaN,然后拿去访问cells[NaN],不会报错,但逻辑会莫名其妙失效。后来加上了空值判断,这类问题就消失了。
4.2 递归死循环:一个隐藏很深的bug
我在2.4节讲过递归展开的标记顺序问题。实际项目里我第一次写的revealCell是这么写的:
function revealCell(index) { const cell = cells[index]; if (cell.revealed || cell.flagged) return; if (cell.count === 0) { getNeighbors(index).forEach(n => revealCell(n)); } cell.revealed = true; revealedCount++; }乍一看逻辑没问题,先递归,再标记。但运行起来一点空白区域就直接卡死,浏览器标签页无响应,只能强制关闭。原因就是我在递归调用之前没有把当前格子标记为已翻开。
具体过程是这样:点击A格子,A的count为0,进入递归分支,把A的邻居B推进递归。B也是0,B又把邻居遍历一遍,其中包含A。此刻A仍然是未翻开状态,于是A再次进入递归,A又去遍历邻居,又访问B,双方互相加入对方递归,永远出不来了。
修复方式特别简单,把cell.revealed = true这行提到递归之前。但顺手就能修好的bug,排查起来却花了我很长时间。这也说明一个问题:递归函数里,状态的更新顺序和递归调用的顺序,不只是风格问题,而是正确性问题。
4.3 计时器叠加:重置游戏的连带问题
计时器我一开始写得很随意。点击棋盘时启动setInterval,每秒更新一次秒数。表面没问题,直到我连续点了两次重新开局,发现新棋盘的计时器每秒增加2秒,再点一次变3秒。
原因挺简单的:我每次开局都没有清理旧计时器。第一次开局创建了一个定时器,重置游戏后又创建了一个新定时器,两个定时器同时运行,秒数自然就叠加了。而且旧定时器里引用的还是同一个秒数变量,所以新局也会被它污染。
修复也很简单,在每个需要重置计时器的地方,先clearInterval(timerId)再置空。
function stopTimer() { clearInterval(timerId); timerId = null; } function startTimer() { if (timerId !== null) return; timerId = setInterval(() => { seconds++; updateTimerDisplay(); }, 1000); }startTimer里判断timerId !== null是为了防止第一次点击后、棋盘还没结束又点了另一个格子,导致创建第二个定时器。这种细节看起来不起眼,但实际体验中非常影响品质。
4.4 常见问题速查表
我把调试过程中遇到的各种现象整理成了表格,排障时直接对照定位就行。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 右键点击弹出浏览器菜单 | 没有阻止contextmenu默认行为 | 在contextmenu处理器中调用e.preventDefault() |
| 第一次点击就爆炸 | 布雷发生在点击前,且没有排除点击位置 | 改为首击后再布雷,排除点击格及周围8格 |
| 点击空白区域页面卡死 | 递归展开时状态标记顺序错误,导致A和B互相递归 | 确保cell.revealed = true在递归调用之前执行 |
| 重新开局后秒数跳动异常 | 旧计时器未清理 | 重置时先clearInterval(timerId)再重新初始化 |
| 旗帜数量显示负数 | 没有限制旗帜数量上限 | 旗帜数>=地雷总数时禁止继续标记 |
| 点击旗帜格子却翻开了 | 左键点击逻辑中没有判断flagged状态 | 在revealCell入口处检查cell.flagged |
| 数字颜色和图标变化看不出来 | 没有设置每次翻开后的样式重置 | 翻开时统一加上revealed类,再根据内容设置额外样式 |
5. 进阶扩展:把扫雷小游戏做成自己的作品
基础版本做完之后,你会发现这个项目就像一块画布,想往哪个方向扩展都行。这一章分享几个我实际试过、觉得收益很高的扩展方向。
5.1 交互增强:移动端长按与自定义难度
原版扫雷是纯粹的鼠标操作游戏,但放到手机上,右键标记变成了一个难题。我试过两种方案:一是双击标记,缺点是容易误触;二是长按标记,这更接近主流交互习惯。
长按实现起来也不难,在触摸事件里加一个定时器,按下500毫秒后触发标记。但要注意,长按之后还要阻止跟手的click事件触发,不然会变成“既标记又翻开”。可以用一个布尔变量记录长按是否触发了,在click事件里判断后直接return。
自定义难度也值得加。很多玩家玩熟了固定三档之后,想试试更大的棋盘。这时候只需要在配置对象里加一项,从界面上让用户填写行数、列数和雷数,再校验一下参数是否合法就行。比如行列乘积要超过地雷数,否则没有安全区;地雷数不能超过总格子数的某个比例,否则游戏体验会崩。
5.2 代码组织:从“能跑”到“好维护”
我第一版把所有逻辑都写在全局作用域里,写起来快,但代码往后扩展会越来越吃力。具体的改进方向是把逻辑拆成几个模块,至少包括配置、棋盘数据、渲染、游戏控制。不一定要用ES Module,同一个文件里用函数按区域分组,加注释分块,读起来就会舒服很多。
如果想把事情做得更彻底,把数据逻辑和DOM操作解耦。也就是说,定义纯函数来处理布雷、展开、胜负判定,这些函数完全不碰DOM;DOM只负责把数据变化同步到界面。这样做的直接好处是,以后想用命令行测试核心逻辑,只需要在Node环境里跑数据函数,不用搭一套浏览器环境。
我见过不少初学者把这两层混在一起,结果就是每次想改样式或者改规则,都得在一堆classList.add和算法逻辑的夹缝里找代码。项目不大时还能忍受,一旦加上自定义难度、存档、排行榜,就会变成噩梦。趁这个项目规模小,动手做一次模块化重构,比做任何高大上的项目都能学得多。
还有一个小而实用的经验:数字的颜色方案可以直接参考大众认知里的扫雷配色,1是蓝色,2是绿色,3是红色,4是深蓝,5是深红,6是青色,7是黑色,8是灰色。这些颜色通过CSS类实现,翻开时根据count值加上对应类。老玩家一眼就能认出数字含义,不需要额外适应。
最后说一点我在实际开发里的体会。扫雷这个项目的难点从来不是“写出来”,而是“写对”。布雷、递归、事件委托这些概念单独拿出来都不难,但把它们组合在一个交互闭环里,很多隐蔽问题就出现了。比如递归死循环,比如计时器叠加,这些坑你踩过一次,对整个前端运行机制的理解会比读十篇教程都深。如果你正在找一个练手项目,我强烈建议你亲自把每一行代码敲一遍,尤其是那个递归展开函数,多折腾几次,收获比想象中大得多。