扫雷游戏是我觉得入门前端最值得练手的小项目之一,原因很简单:它麻雀虽小,五脏俱全。一个完整的扫雷游戏要同时处理棋盘布局、游戏状态管理、鼠标事件监听、连锁翻开、胜负判定这些环节,单独看每一块都不难,但把它们串成一个能稳定运行、体验正常的游戏,中间要考虑的细节非常多。这篇文章会把整个实现过程拆开讲透,包括设计思路、完整代码、逐段讲解,以及我实际开发中踩过的坑。适合刚学完 JavaScript 基础知识想找项目练习的朋友,也适合想快速恢复前端手感的老手。整个过程只用 HTML、CSS、JavaScript 原生的写法,不依赖任何框架,复制粘贴就能跑。
1. 项目设计与整体思路拆解
1.1 为什么用原生三件套,而不是框架
很多人一上来就会想,是不是可以用 Vue 或者 React 来写一个扫雷。我的答案是不建议。扫雷游戏本身的核心价值在算法和事件处理,比如布雷、数字统计、零格扩散、右键标记,这些跟框架没有关系。引入框架反而会增加依赖体积和心智负担,你还要考虑组件生命周期、状态同步这些问题,跑偏了重点。
用原生 JavaScript 写扫雷,最大的收获是能把 click、contextmenu 事件、二维数组操作、递归或队列遍历这些基础功练扎实。等你把这些基础能力吃透了,以后再去用框架,会发现框架只是帮你组织这些逻辑的工具,而不是救世主。我在实际写这个项目的时候,刻意没有使用任何构建工具,一个 index.html、一个 style.css、一个 script.js,双击打开就能玩。
1.2 模块划分与游戏状态机
写这种小游戏之前,我建议先花十分钟把需求写清楚,别急着敲代码。扫雷的核心需求拆出来大概是这样:左键点开格子,右键标记地雷,数字表示周围八格有几个雷,点开空白格会自动扩散翻开,踩到雷就游戏结束,所有安全格全部翻开就算胜利。此外还有计时器、剩余雷数显示、重置功能。
我习惯把这些需求分成三个模块来组织代码:配置模块、逻辑模块、渲染模块。
- 配置模块定义棋盘大小和地雷数量,比如初级是 9x9 棋盘 10 个雷;
- 逻辑模块负责维护棋盘数据、布雷、计算数字、判断胜负;
- 渲染模块负责把数据画到页面上。
三者之间的关系可以理解成:逻辑模块是大脑,渲染模块是手和嘴,大脑决定好状态后,通过渲染模块反映出来。这样写的好处是当你修改难度、增加新功能时,不需要在 DOM 操作里找来找去。
另外,游戏一定要有一个清晰的状态机。我在代码里用 gameOver、firstClick 这两个布尔变量,配合状态变化来控制行为:游戏未开始时不能计时,第一次点击后才能真正布雷,游戏结束后所有点击事件都要被拦截。如果不做状态控制,用户多点了两下就可能出现计时器重复计时、棋盘二次布雷的奇怪问题。
1.3 数据结构选择:二维数组与格子对象
棋盘的存储方式直接决定后面所有逻辑的写法。我见过一些人用一维数组来存储,也就是把棋盘拍扁成一个 board[rows * cols] 的列表,索引换算成 r * cols + c。这种方式能跑,但每次调试都要做一遍数学换算,很容易犯边界错误。我选了更直观的方案:直接用二维数组 board[r][c]。
数组里的每个元素是一个格子对象,保存四个关键属性:
mine:布尔值,表示这个格子是不是雷;revealed:布尔值,表示这个格子是否已经翻开;flagged:布尔值,表示是否被玩家插了旗;count:数字,记录周围八格的地雷数量。
使用一个对象而不是四个平行数组,是为了在判断逻辑时一次取到格子的所有信息。比如左键点击一个格子,你要先看它有没有翻开、有没有被标记、是不是雷、周围几个雷,这些判断都要同时依赖多个属性。把属性打包成对象后,代码读起来非常接近自然语言。
这种设计也符合数据驱动渲染的思路:操作只改数据,渲染统一根据数据生成界面。每次操作结束后调用一次 render(),重新绘制棋盘,而不是在事件处理函数里逐个修改格子 DOM。对于 30x16 这种最高难度棋盘,总共也就 480 个格子,全量渲染的性能损耗完全可以忽略,换来的是逻辑上的绝对清晰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法拆解与关键细节
2.1 布雷算法与首次点击保护
扫雷有一个经典规则:第一次点击绝对不能踩雷。如果一开始就把雷布好,玩家第一次点下去正好点到雷,游戏瞬间结束,体验非常糟糕。
处理这个问题有两种常见方案。
第一种是先布雷,然后在玩家第一次点击后做补救:如果点到雷,就把这颗雷移动到一个安全的空位。这个方案的问题在于“移动”这个操作比较绕,你要不断随机搜索新的位置,而且移动后周围格子的数字都要重新计算。
第二种方案是延迟布雷:第一次点击前不布置任何雷,等玩家点下第一格后,再用排除法生成地雷。我采用了第二种方案,它在代码上就是一行 if (firstClick) placeMines(r, c);,非常直观,逻辑也更加安全。
placeMines 函数的写法是循环随机生成坐标,然后检查两个条件:这个格子是否已经布过雷,以及它是否在玩家第一次点击点的九宫格范围内。两个条件都通过,才把当前格子标记为雷。
需要特别注意的是,排除区域是 3x3 的九宫格,最多占 9 格,所以地雷数量不能超过 rows * cols - 9。如果玩家自定义参数时把雷数调成 100 个而棋盘只有 9x9,随机布雷函数就会陷入死循环。这个校验必须在游戏初始化时做好。
2.2 周边雷数统计的两种实现
棋盘布好雷之后,每个非雷格子都要计算周围八格的地雷个数。这个数字是玩家判断局势的唯一依据,也是游戏核心信息。
实现方式有两种,一种是遍历统计法,一种是布雷增量法。
遍历统计法是在所有雷布完之后,遍历每一个非雷格子,然后检查它周围八个方向,数一数有几个雷。复杂度是 O(rows * cols * 8),也就是每个格子最多做八次判断。对于扫雷这个规模的应用,性能完全没有问题。
布雷增量法的思路正好相反:在布置每一颗雷的时候,把这颗雷周围八个格子的 count 都加 1。这样只需要 O(mines * 8) 的时间,效率更高,但代码稍微绕一点,因为布雷过程中流向和统计是交替进行的。
我个人推荐遍历统计法。逻辑简单、不易出错、调试方便,性能差别在这个场景下根本体现不出来。你要记住一个原则:在写业务代码时,优先选择不容易出错的方案,而不是看着更高明的方案。
2.3 零格扩散:不要递归,用队列
点开扫雷棋盘上数字为 0 的空格子时,游戏会自动把周围相邻的空白格全部翻开,这也就是常说的扩散效果。很多新手在这里喜欢用递归,因为递归写法看起来最接近自然思考:
如果当前格子是 0,就递归翻阅相邻格子。
我一开始也是这么写的,但实际测试中发现递归有一个隐患:浏览器调用栈的深度是有限制的。如果一张高级别棋盘上有一大片连续空白区域,递归扩散的深度可能达到几千层。在某些浏览器上,深度超过一定阈值就会抛出“栈溢出”错误,导致页面崩溃。你可能在小棋盘上测试完全正常,换成 30x16 大面积空域就翻车了。
更稳的做法是用一个队列来做 BFS 遍历。初始把点击位置放入队列,循环从队列头部取出格子,检查它周围八个相邻格子,遇到符合条件的格子就标记翻开,如果是数字为 0 的空格就继续入队。利用队列先进先出的特性,像水波一样一圈一圈往外扩散。
这个方案不依赖调用栈,无论扩散多大区域都不会栈溢出。我最后一次重写代码时直接删掉了递归版本,全部改用循环队列。
2.4 胜负判断与情绪化反馈
胜利条件的判断有一个比较隐蔽的坑:不能通过“翻开所有非雷格”来实时检测状态时只判断数字格子,那样很容易漏算周围扩散的情况。我用一个 revealedCount 计数器,每次成功翻开一个非雷格子就加 1,当计数等于总格子数减去地雷数时,说明所有安全格子都已经被翻开,游戏胜利。
失败的条件就简单了:玩家点击的格子是雷,直接进入失败状态。这个时候要把所有雷的位置都展示出来,让玩家死个明白,同时停止计时器。
我在代码里还有一个很复古的设计:重置按钮的表情会随游戏状态变化。正常状态显示 :),踩雷后变成 :(,胜利后变成 8)。很多网上的实现不会做这个细节,但我觉得这种小小的情绪化反馈能让游戏更有灵魂,也让玩家一眼看出当前状态。
3. 完整代码实现与逐段讲解
3.1 HTML 骨架
扫雷游戏的页面结构非常简单,三个主要区域:顶部信息栏、棋盘容器、底部脚本引入。
顶部信息栏里放了三个东西:剩余雷数计数器、重置按钮、计时器。剩余雷数等于总地雷数减去已插旗数,这是我特意保留的蜗牛级细节,经典扫雷的界面就是左边数字显示雷数,右边数字显示用时,中间是表情按钮。棋盘容器在 HTML 里不需要生成任何格子,全部由 JavaScript 动态创建。
注意脚本标签放在 body 的最后,这样可以确保 DOM 元素已经加载完毕,不需要额外处理加载时机。
code复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>扫雷游戏</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="game-container">
<div class="header">
<div id="mine-counter" class="counter">010</div>
<button id="reset-btn" class="reset-btn">:)</button>
<div id="timer" class="timer">000</div>
</div>
<div id="board" class="board"></div>
</div>
<script src="script.js"></script>
</body>
</html>
3.2 CSS 视觉还原
扫雷的经典像素风格其实用 CSS 很容易还原,核心就是做好立体边框。每个格子默认状态是四个方向不同的边框颜色,左、上边框偏白,右、下边框偏灰,营造出凸起的立体感。格子被翻开后,立体的边框全部取消,改成一条细细的灰线,呈现平面凹陷的效果。
数字的颜色也有讲究。1 到 8 的数字颜色都是不一样的,这是从经典扫雷一直沿用到现在的设定,老实说不太好解释为什么,但它就是有记忆点。实现的时候我直接给每个格子加上 n1 到 n8 的类名,然后通过样式定义各自颜色。
code复制* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #e0e0e0;
font-family: "Courier New", monospace;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
user-select: none;
-webkit-user-select: none;
}
.game-container {
background: #c0c0c0;
border-top: 3px solid #fff;
border-left: 3px solid #fff;
border-right: 3px solid #808080;
border-bottom: 3px solid #808080;
padding: 10px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.counter,
.timer {
background: #000;
color: #f00;
font-size: 24px;
padding: 4px 8px;
font-weight: bold;
}
.reset-btn {
font-size: 20px;
width: 44px;
height: 40px;
border: none;
background: #c0c0c0;
border-top: 3px solid #fff;
border-left: 3px solid #fff;
border-right: 3px solid #808080;
border-bottom: 3px solid #808080;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.reset-btn:active {
border-top: 3px solid #808080;
border-left: 3px solid #808080;
border-right: 3px solid #fff;
border-bottom: 3px solid #fff;
}
.board {
display: grid;
}
.cell {
width: 30px;
height: 30px;
font-size: 16px;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background: #c0c0c0;
border-top: 3px solid #fff;
border-left: 3px solid #fff;
border-right: 3px solid #808080;
border-bottom: 3px solid #808080;
}
.cell.revealed {
border: 1px solid #bbb;
}
.cell.n1 { color: #0000ff; }
.cell.n2 { color: #008000; }
.cell.n3 { color: #ff0000; }
.cell.n4 { color: #000080; }
.cell.n5 { color: #800000; }
.cell.n6 { color: #008080; }
.cell.n7 { color: #000000; }
.cell.n8 { color: #808080; }
3.3 JavaScript 完整代码
这部分是整个项目的核心,我把完整代码直接放出来,去掉所有注释的简洁版本一行不多一行不少,可以直接新建一个 script.js 粘贴进去使用。
code复制(() => {
const CONFIG = {
easy: { rows: 9, cols: 9, mines: 10 },
medium: { rows: 16, cols: 16, mines: 40 },
hard: { rows: 16, cols: 30, mines: 99 }
};
let config = CONFIG.easy;
let board = [];
let rows, cols, mineCount;
let firstClick = true;
let gameOver = false;
let flagCount = 0;
let revealedCount = 0;
let timer = 0;
let timerInterval = null;
const boardEl = document.getElementById('board');
const mineCounterEl = document.getElementById('mine-counter');
const timerEl = document.getElementById('timer');
const resetBtn = document.getElementById('reset-btn');
function initGame(cfg) {
clearInterval(timerInterval);
timerInterval = null;
config = cfg;
rows = cfg.rows;
cols = cfg.cols;
mineCount = cfg.mines;
if (mineCount > rows * cols - 9) {
mineCount = rows * cols - 9;
}
firstClick = true;
gameOver = false;
flagCount = 0;
revealedCount = 0;
timer = 0;
timerEl.textContent = formatNum(0);
buildBoard();
updateMineCounter();
render();
}
function buildBoard() {
board = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => ({
mine: false,
revealed: false,
flagged: false,
count: 0
}))
);
}
function placeMines(excludeRow, excludeCol) {
let placed = 0;
while (placed < mineCount) {
const r = Math.floor(Math.random() * rows);
const c = Math.floor(Math.random() * cols);
if (board[r][c].mine) continue;
if (Math.abs(r - excludeRow) <= 1 && Math.abs(c - excludeCol) <= 1) continue;
board[r][c].mine = true;
placed++;
}
calculateCounts();
}
function calculateCounts() {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (board[r][c].mine) continue;
let count = 0;
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 && board[nr][nc].mine) {
count++;
}
}
}
board[r][c].count = count;
}
}
}
function render() {
boardEl.style.gridTemplateColumns = `repeat(${cols}, 30px)`;
boardEl.innerHTML = '';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = board[r][c];
const div = document.createElement('div');
div.className = 'cell';
div.dataset.r = r;
div.dataset.c = c;
div.addEventListener('click', () => handleClick(r, c));
div.addEventListener('contextmenu', (e) => {
e.preventDefault();
handleRightClick(r, c);
});
if (cell.revealed) {
div.classList.add('revealed');
if (cell.mine) {
div.textContent = '●';
} else if (cell.count > 0) {
div.textContent = cell.count;
div.classList.add('n' + cell.count);
}
} else if (cell.flagged) {
div.textContent = '⚑';
}
boardEl.appendChild(div);
}
}
}
function handleClick(r, c) {
if (gameOver) return;
const cell = board[r][c];
if (cell.revealed || cell.flagged) return;
if (firstClick) {
firstClick = false;
placeMines(r, c);
if (!timerInterval) {
timerInterval = setInterval(() => {
timer++;
timerEl.textContent = formatNum(timer);
}, 1000);
}
}
if (cell.mine) {
cell.revealed = true;
gameOver = true;
clearInterval(timerInterval);
revealAllMines();
resetBtn.textContent = ':(';
render();
return;
}
revealCell(r, c);
if (checkWin()) {
gameOver = true;
clearInterval(timerInterval);
resetBtn.textContent = '8)';
revealAllMines();
render();
}
}
function revealCell(r, c) {
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
const cell = board[r][c];
if (cell.revealed || cell.flagged || cell.mine) return;
cell.revealed = true;
revealedCount++;
if (cell.count === 0) {
const queue = [[r, c]];
while (queue.length) {
const [cr, cc] = queue.shift();
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = cr + dr;
const nc = cc + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
const neighbor = board[nr][nc];
if (!neighbor.revealed && !neighbor.flagged && !neighbor.mine) {
neighbor.revealed = true;
revealedCount++;
if (neighbor.count === 0) {
queue.push([nr, nc]);
}
}
}
}
}
}
render();
}
function handleRightClick(r, c) {
if (gameOver) return;
const cell = board[r][c];
if (cell.revealed) return;
cell.flagged = !cell.flagged;
flagCount += cell.flagged ? 1 : -1;
updateMineCounter();
render();
}
function updateMineCounter() {
const val = mineCount - flagCount;
mineCounterEl.textContent = val < 0 ? '-' + formatNum(Math.abs(val)) : formatNum(val);
}
function formatNum(n) {
return String(Math.max(0, n)).padStart(3, '0');
}
function checkWin() {
return revealedCount === rows * cols - mineCount;
}
function revealAllMines() {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (board[r][c].mine) {
board[r][c].revealed = true;
}
}
}
}
resetBtn.addEventListener('click', () => {
resetBtn.textContent = ':)';
initGame(config);
});
initGame(CONFIG.easy);
})();
这是一段完整的自执行函数,变量都封装在内部,不会污染全局作用域。整体逻辑顺序是:初始化时构建一个没有雷的空棋盘,第一次点击时布雷并启动计时器,之后每次点击要么翻开格子触发扩散,要么踩雷结束游戏。
3.4 难度配置与参数说明
代码开头的 CONFIG 对象定义了三种难度,参数对应经典扫雷的标准规格:
| 难度 | 行数 | 列数 | 地雷数 |
|---|---|---|---|
| easy | 9 | 9 | 10 |
| medium | 16 | 16 | 40 |
| hard | 16 | 30 | 99 |
如果你想自定义,直接把 initGame 里的参数改掉就行,比如创建一个 20x20 棋盘、60 个雷的玩法。但有一个关键约束我上面提过:地雷数不能超过 rows * cols - 9。因为布雷时我们要排除掉玩家第一次点击位置周围 3x3 的范围,这批格子必须是安全的。
另外要注意,initGame 里有一个 clearInterval 的调用,这一步很关键,目的是保证重新开始游戏时旧的计时器不会残留。如果漏掉这个清除逻辑,玩家切难度或点重置按钮后,计时器可能会一分钟跳两秒,非常难排查。
4. 从零跑通项目与实用扩展
4.1 文件组织与运行方式
我强烈建议把三个文件分开存放,分别是 index.html、style.css、script.js,放在同一个文件夹里,然后直接用浏览器打开 index.html,不需要安装任何依赖,也不需要启动服务器。这一点对初学者特别友好,你只要能双击打开网页,就能立刻看到扫雷界面。
HTML 文件里通过 <link> 标签引用了样式文件,通过 <script> 标签引用了脚本文件。注意脚本文件的引入顺序放到了 body 最底部,这样做的好处是执行 JavaScript 之前,页面上的 DOM 元素已经全部加载完毕,不需要额外写 DOMContentLoaded 之类的监听。
如果你之后想把这个项目部署到网上,直接把这三个文件上传到任意静态托管平台就行。只要路径没改错,就能正常跑起来。
4.2 调试技巧
我在写这个项目时总结了一套比较顺手的调试流程。
第一步,给自己留好验证环境的通路。打开代码后先把 CONFIG 的难度改成 easy,因为 9x9 的小棋盘最容易观察行为。
第二步,在 handleClick 里临时加一行打印代码,比如 console.log(r, c, board[r][c]),这样每次点击都能在控制台看到点击的坐标和格子的实际数据。通过对比显示和数据的差别,就能快速定位是逻辑问题还是渲染问题。
第三步,利用浏览器开发者工具的断点能力。在关键路径上打断点,比如 placeMines 函数里,单步执行观察地雷如何放置、排除区域是否生效。这个方法比纯靠肉眼检查代码快十倍。
最后,我还会故意做一个极限测试:把棋盘调成 30x30、地雷数调成 300,然后在空白区域迅速点击一大片,观察页面是否卡顿、扩散是否正常。这种极端条件下最容易暴露出递归爆栈和渲染性能问题。
4.3 扩展方向
基础版本跑通之后,你完全可以在上面做加法,我列几个我做过的方向供参考。
第一,增加难度选择按钮。在页面头部加三个按钮,点击后调用 initGame(CONFIG.xxx) 就行。之前写的 initGame 函数已经保证了完整的状态重置,所以切换难度非常自然。
第二,自定义皮肤。经典扫雷是灰底像素风,你可以用 CSS 变量把所有颜色抽出来,做一套深色主题。格子里的地雷符号可以换成其他字符,旗帜也可以换成图标。CSS 部分的 border-top 和 border-left 改一下就是一种新的立体风格。
第三,加入游戏音效。用原生 Web Audio API 画几个短促的波形,点击翻开时播放一个,踩雷时播放另一个。不需要任何音频文件,代码也就十几行,体验提升很直观。
第四,记录最佳成绩。用 localStorage 存一下胜利的最短时间,刷新页面后还能看到历史最快纪录。这个功能对初学者练习 JSON 序列化和浏览器存储很有帮助。
第五,实现双击数字展开功能。玩家左键双击一个数字格子,如果周围的旗子数量等于数字,就自动翻开其余未标记的格子。这是经典扫雷效率最高的操作,实现灵感来自右键连击的思路,但要用事件区分。
5. 踩坑实录与问题排查
5.1 首次点击踩雷的问题
这是扫雷项目里需求量最高的一个功能,也是很多初版实现最容易忽略的。如果直接先布雷再点击,理论上玩家第一下就有十分之一的概率直接结束游戏,这在体验上是不合理的。
我最终的方案是第一次点击之后才布雷,并且在布雷时把点击位置本身,以及它周围八个格子全部排除在外。这个保证逻辑在 placeMines 里用了一个很通俗的判断:Math.abs(r - excludeRow) <= 1 && Math.abs(c - excludeCol) <= 1。
如果你代码里的布雷是先做好的,也不要慌,可以先记录第一次点击的坐标,如果踩雷就把这颗雷随机移到一个安全位置,同时把当前格子的雷取掉。但要注意,移动后需要重新统计周围数字,否则数字会和实际雷数对不上。
5.2 递归爆栈的坑
这个坑我自己实实在在地踩过。最初实现零格扩散用的是递归,在小棋盘 9x9 上跑得欢快,一天下午把难度切换到高级,点了一大片空白区域,页面直接崩溃,控制台报错信息指向调用栈溢出。
扫雷的零格扩散区域其实是很深的。你可以想象一个极端情况:30x16 的棋盘上有 99 个雷被集中放在一角,其余全部是安全区域,那么玩家点开一格格子后,扩散会连锁触发几千次递归调用。大多数浏览器调用栈上限在万级上下,遇到复杂的棋盘结构很快就会被压垮。
改成队列实现 BFS 后,这个坑就彻底消失了。代码里用一个 while (queue.length) 循环不断往外扩展,完全是迭代逻辑,不消耗调用栈深度。
5.3 右键菜单干扰
在浏览器页面上右键点击任意位置,默认会弹出浏览器的右键菜单。这个菜单对游戏没有任何帮助,还会打断玩家的操作节奏。解决办法是在 contextmenu 事件里调用 event.preventDefault()。
我在代码里用 div.addEventListener('contextmenu', ...) 监听右键,处理标记逻辑的同时把默认行为禁掉。
还有一个容易被忽略的点是移动端的长按屏幕也会触发类似菜单的交互。如果你希望游戏能在手机上正常玩,可以考虑捕获 touchstart 事件并处理长按逻辑。
5.4 计时器竞态
计时器相关的 bug 往往隐藏在重置操作里。比如正在游玩过程中点了重置按钮,老的 setInterval 没有被清除,新的又启动了一个,那么计时器就会以双倍速度跳动,剩余时间彻底失真。
我在 initGame 函数的开头固定执行 clearInterval(timerInterval),这样每次初始化游戏之前都会把上一局的计时器清理干净。另外在胜利和失败时也要把计时器停掉,避免游戏结束后时间还在继续上涨。
我建议你在写任何带计时器的游戏时,都遵循一个原则:凡是创建定时器的地方,必须想好它什么时候被销毁。
5.5 数组越界与边界检查
计算周围八个方向的地雷数量时,最容易出问题的就是边界格子。比如 board[0][0] 这个最左上角的格子,它的上方邻居是 [-1][0],左侧邻居是 [0][-1],这些坐标根本不存在于数组中,直接访问会报错。
解决办法是在访问邻居前先做一轮范围判断:
code复制if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
// 处理邻居逻辑
}
这个判断看起来啰嗦,但它能一次性解决所有处于棋盘边缘的格子访问问题。我的经验是不要想着写什么更优雅的偏移数组来省这几行代码,直接硬性判断反而最不容易出错。
5.6 快速自查清单
如果你写出来的扫雷出现了怪问题,可以按这个清单排查,我平时遇到问题也是对照这几个方向检查的。
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 第一次点击就踩雷 | 没有延迟布雷 | 把布雷逻辑移到首次点击后 |
| 点击空白区域没有扩散 | 递归没用队列或边界判断错误 | 检查扩散函数和边界条件 |
| 右键弹出浏览器菜单 | contextmenu 没有阻止默认行为 | 加上 preventDefault |
| 计时器走得过快 | 多个 setInterval 同时运行 | 初始化时 clearInterval |
| 数字和实际雷数对不上 | 布雷后没重新统计数字 | 调用 calculateCounts |
| 翻开的格子无法继续显示 | 全局渲染没触发 | 在操作后调用 render |
| 重置后棋盘还是旧数据 | 没有重建二维数组 | 检查 buildBoard 是否执行 |
| 大地图卡顿或崩溃 | 渲染方式或扩散算法有问题 | 优化全量渲染或改用队列扩散 |
对照这张表去排查,大部分问题都能在十分钟内找到根源。
我个人在实际操作中的体会是,像扫雷这种项目,看似简单,但真正把它写稳定是需要花点功夫的。从最初毛躁的递归版本,到后来结构清晰的 BFS 版本,我前后重写了三次。每一次重写都能发现上一步设计里的问题。做完扫雷之后,我又用同样的思路去写了贪吃蛇和 2048,上手速度明显快了很多。如果你也在练手,我建议不要只看别人写的代码,一定要自己动手默写一遍完整项目,遇到问题再回来对照这篇文章,收获会大得多。
