扫雷游戏看似规则简单,真正实现时却涉及二维数组、随机布雷、邻居计算、递归展开、状态管理和页面交互等多个知识点。只要利用 HTML、CSS 和 JavaScript,就可以把这些逻辑全部放进一个 HTML 文件中,无需安装框架、配置服务器,也不依赖第三方库,直接用浏览器打开即可运行。
一、单HTML文件实现扫雷需要哪些功能
一个完整的网页版扫雷通常包含以下核心功能:
-
创建指定大小的棋盘
-
随机生成地雷
-
计算每个格子周围的地雷数量
-
点击格子进行排雷
-
自动展开连续的空白区域
-
点击地雷后判定游戏失败
-
标记疑似地雷
-
判断玩家是否成功
-
显示剩余雷数
-
提供重新开始游戏功能
-
使用 CSS 完成棋盘和格子的视觉效果
由于整个项目只有一个 HTML 文件,因此可以直接采用内嵌 和 的方式组织代码。
二、扫雷游戏的基本原理
扫雷棋盘本质上是一个二维网格。
例如创建一个 9×9 的棋盘,可以使用二维数组表示:
JavaScriptconst board = [];
每一个位置对应一个对象:
JavaScript{ mine: false, count: 0, revealed: false, flagged: false }
其中:
-
mine表示当前位置是否存在地雷 -
count表示周围八个方向的地雷数量 -
revealed表示当前格子是否已经打开 -
flagged表示当前格子是否被标记
这种数据结构比单纯使用数字数组更加清晰,也方便后续扩展。
三、随机生成地雷
生成棋盘后,需要随机选择若干个位置放置地雷。
可以先创建全部格子,然后随机交换位置。例如:
JavaScriptfunction placeMines() { const positions = []; for (let i = 0; i < rows * cols; i++) { positions.push(i); } for (let i = positions.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [positions[i], positions[j]] = [positions[j], positions[i]]; } for (let i = 0; i < mines; i++) { const index = positions[i]; const row = Math.floor(index / cols); const col = index % cols; board[row][col].mine = true; } }
采用 Fisher-Yates 洗牌算法,可以避免简单随机选取位置时反复生成相同坐标的问题。
四、计算每个格子周围的地雷数量
扫雷最关键的规则之一,就是数字表示当前格子周围八个位置存在多少颗地雷。
八个方向可以通过两个循环计算:
JavaScriptfor (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { if (dr === 0 && dc === 0) continue; const nr = row + dr; const nc = col + dc; if ( nr >= 0 && nr < rows && nc >= 0 && nc < cols && board[nr][nc].mine ) { count++; } } }
需要注意边缘格子。
例如左上角只有三个有效邻居,中间位置则最多有八个邻居。因此必须进行行列范围判断,避免访问不存在的数组元素。
五、实现单HTML文件版扫雷
下面给出一个可以直接运行的完整示例。将代码保存为 minesweeper.html,然后使用浏览器打开即可。
HTMLlang="zh-CN"> charset="UTF-8"> name="viewport" content="width=device-width, initial-scale=1.0">网页版扫雷 * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #f2f4f7; font-family: Arial, "Microsoft YaHei", sans-serif; } .game { padding: 24px; background: #ffffff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); } .game-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 20px; } .counter { min-width: 90px; padding: 8px 12px; background: #222; color: #fff; text-align: center; border-radius: 6px; font-size: 18px; } button { border: none; padding: 9px 16px; border-radius: 6px; background: #1677ff; color: white; cursor: pointer; font-size: 14px; } button:hover { background: #0958d9; } .board { display: grid; gap: 2px; user-select: none; } .cell { width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; background: #bfc5cc; border: 2px outset #e4e7eb; font-weight: bold; font-size: 17px; cursor: pointer; } .cell:hover { background: #d8dce1; } .cell.revealed { background: #e8eaed; border: 1px solid #c8ccd1; cursor: default; } .cell.mine { background: #ff4d4f; } .cell.flagged { background: #ffd666; } .num-1 { color: #1677ff; } .num-2 { color: #389e0d; } .num-3 { color: #cf1322; } .num-4, .num-5, .num-6 { color: #531dab; } @media (max-width: 600px) { .game { padding: 12px; max-width: 100%; overflow: auto; } .cell { width: 30px; height: 30px; font-size: 14px; } } class="game">const rows = 9; const cols = 9; const mines = 10; let board = []; let gameOver = false; let flags = 0; let revealedCount = 0; const boardElement = document.getElementById("board"); const mineCountElement = document.getElementById("mineCount"); const restartButton = document.getElementById("restart"); function createBoard() { board = []; gameOver = false; flags = 0; revealedCount = 0; for (let row = 0; row < rows; row++) { const line = []; for (let col = 0; col < cols; col++) { line.push({ mine: false, count: 0, revealed: false, flagged: false }); } board.push(line); } placeMines(); calculateNumbers(); renderBoard(); updateMineCount(); } function placeMines() { const positions = []; for (let i = 0; i < rows * cols; i++) { positions.push(i); } for (let i = positions.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [positions[i], positions[j]] = [positions[j], positions[i]]; } for (let i = 0; i < mines; i++) { const index = positions[i]; const row = Math.floor(index / cols); const col = index % cols; board[row][col].mine = true; } } function calculateNumbers() { for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { if (board[row][col].mine) { continue; } board[row][col].count = countAdjacentMines(row, col); } } } function countAdjacentMines(row, col) { 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 = row + dr; const nc = col + dc; if ( nr >= 0 && nr < rows && nc >= 0 && nc < cols && board[nr][nc].mine ) { count++; } } } return count; } function renderBoard() { boardElement.innerHTML = ""; boardElement.style.gridTemplateColumns = `repeat(${cols}, 38px)`; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const cell = document.createElement("div"); cell.className = "cell"; cell.dataset.row = row; cell.dataset.col = col; updateCellView(cell, row, col); cell.addEventListener("click", () => { revealCell(row, col); }); cell.addEventListener("contextmenu", event => { event.preventDefault(); toggleFlag(row, col); }); boardElement.appendChild(cell); } } } function updateCellView(element, row, col) { const cell = board[row][col]; element.className = "cell"; element.textContent = ""; if (cell.flagged && !cell.revealed) { element.classList.add("flagged"); element.textContent = ""; return; } if (!cell.revealed) { return; } element.classList.add("revealed"); if (cell.mine) { element.classList.add("mine"); element.textContent = ""; return; } if (cell.count > 0) { element.textContent = cell.count; element.classList.add(`num-${cell.count}`); } } function revealCell(row, col) { if (gameOver) { return; } const cell = board[row][col]; if (cell.revealed || cell.flagged) { return; } cell.revealed = true; revealedCount++; if (cell.mine) { gameOver = true; revealAllMines(); setTimeout(() => { alert("游戏结束,你踩到地雷了!"); }, 50); return; } if (cell.count === 0) { revealEmptyArea(row, col); } renderBoard(); checkWin(); } function revealEmptyArea(row, col) { const queue = [[row, col]]; const visited = new Set(); while (queue.length > 0) { const [currentRow, currentCol] = queue.shift(); const key = `${currentRow},${currentCol}`; if (visited.has(key)) { continue; } visited.add(key); for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { if (dr === 0 && dc === 0) { continue; } const nr = currentRow + dr; const nc = currentCol + dc; if ( nr < 0 || nr >= rows || nc < 0 || nc >= cols ) { continue; } const cell = board[nr][nc]; if (cell.revealed || cell.flagged || cell.mine) { continue; } cell.revealed = true; revealedCount++; if (cell.count === 0) { queue.push([nr, nc]); } } } } } function toggleFlag(row, col) { if (gameOver) { return; } const cell = board[row][col]; if (cell.revealed) { return; } if (!cell.flagged && flags >= mines) { return; } cell.flagged = !cell.flagged; if (cell.flagged) { flags++; } else { flags--; } updateMineCount(); renderBoard(); } function revealAllMines() { for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { if (board[row][col].mine) { board[row][col].revealed = true; } } } renderBoard(); } function checkWin() { const totalSafeCells = rows * cols - mines; if (revealedCount === totalSafeCells) { gameOver = true; setTimeout(() => { alert("恭喜你,扫雷成功!"); }, 50); } } function updateMineCount() { mineCountElement.textContent = mines - flags; } restartButton.addEventListener("click", createBoard); createBoard();class="game-header">class="counter"> 雷数: id="mineCount">10id="board" class="board">
六、核心代码解析
1. 使用二维数组保存棋盘
createBoard()负责初始化整个游戏。
JavaScriptfor (let row = 0; row < rows; row++) { const line = []; for (let col = 0; col < cols; col++) { line.push({ mine: false, count: 0, revealed: false, flagged: false }); } board.push(line); }
这种方式让逻辑层和页面层保持相对独立。JavaScript只负责维护游戏状态,而 renderBoard()负责将状态转换成 HTML 元素。
2. 左键负责打开格子
点击普通格子后执行:
JavaScriptrevealCell(row, col);
如果当前格子是地雷:
JavaScriptif (cell.mine) { gameOver = true; revealAllMines(); return; }
否则继续判断周围是否存在地雷。
3. 右键实现插旗
网页版扫雷通常使用鼠标右键标记地雷,因此需要监听 contextmenu:
JavaScriptcell.addEventListener("contextmenu", event => { event.preventDefault(); toggleFlag(row, col); });
调用 preventDefault() 可以阻止浏览器默认的右键菜单。
插旗时需要同步维护:
JavaScriptflags++;
取消标记时:
JavaScriptflags--;
这样页面上的剩余雷数就能够实时更新。
七、为什么需要自动展开空白区域
扫雷有一个非常重要的体验设计:如果玩家点击的格子周围没有地雷,那么不应该要求玩家逐个点击相邻的空白格。
例如:
0 0 0 1 0 0 1 2 0 1 2 * 1 2 * *
点击左上角的 0 后,可以自动打开相邻的所有 0,并继续向外扩展,直到遇到有数字的格子。
这也是扫雷实现中比较容易出错的部分。
传统写法可以使用递归:
JavaScriptfunction open(row, col) { // 打开当前格子 if (board[row][col].count === 0) { open(row - 1, col); open(row + 1, col); open(row, col - 1); open(row, col + 1); } }
但递归层数过深时可能产生调用栈问题,因此示例使用队列实现类似的广度优先搜索。
核心结构是:
JavaScriptconst queue = [[row, col]]; while (queue.length > 0) { const [currentRow, currentCol] = queue.shift(); // 查找周围格子 }
这种方式对于普通网页版扫雷来说更加稳妥。
八、扫雷胜负判断
玩家并不一定需要正确插旗才能获胜。
判断胜利的关键是:所有非雷格子都已经被打开。
因此可以计算:
JavaScriptconst totalSafeCells = rows * cols - mines;
当:
JavaScriptrevealedCount === totalSafeCells
就说明所有安全区域都已经打开,可以宣布胜利。
这种判断方式比统计旗子更加可靠,因为玩家可能不插旗,或者错误插旗,但只要所有安全格子都被正确打开,游戏仍然应该算作完成。
九、如何修改游戏难度
示例使用:
JavaScriptconst rows = 9; const cols = 9; const mines = 10;
如果希望制作更大的棋盘,可以调整成:
JavaScriptconst rows = 16; const cols = 16; const mines = 40;
如果想制作困难模式:
JavaScriptconst rows = 30; const cols = 16; const mines = 99;
同时需要注意页面尺寸。
棋盘越大,固定的 38px 格子越容易导致移动端出现横向滚动。因此实际项目中可以进一步使用 CSS 响应式布局,根据屏幕宽度动态计算格子尺寸。
十、优化首次点击体验
实际的扫雷游戏通常不会让玩家第一次点击就直接踩中地雷。
当前示例是在初始化时立即布雷,因此存在第一次点击踩雷的可能。
如果希望优化体验,可以把“生成地雷”的时机推迟到第一次点击之后。
基本思路是:
JavaScriptlet firstClick = true; function revealCell(row, col) { if (firstClick) { placeMinesSafely(row, col); calculateNumbers(); firstClick = false; } // 后续正常处理 }
placeMinesSafely()可以在生成地雷时排除第一次点击位置,甚至同时排除它周围的八个格子。
这样玩家第一次点击至少可以获得一个安全区域,游戏体验会明显更好。
十一、可以继续增加哪些功能
单 HTML 扫雷只是一个基础版本,在此基础上可以继续扩展。
增加计时器
可以使用:
JavaScriptlet seconds = 0; setInterval(() => { if (!gameOver) { seconds++; } }, 1000);
再把时间显示到游戏顶部,就可以形成更加完整的游戏界面。
增加难度选择
可以提供简单、中等、困难三个按钮:
JavaScriptconst levels = { easy: { rows: 9, cols: 9, mines: 10 }, medium: { rows: 16, cols: 16, mines: 40 }, hard: { rows: 30, cols: 16, mines: 99 } };
切换难度后重新创建棋盘即可。
增加触摸操作
移动端没有传统意义上的鼠标右键,因此如果希望扫雷游戏兼容手机,可以增加长按事件。
例如长按格子时执行插旗操作,普通点击则负责打开格子。
增加最佳成绩
可以使用浏览器的 localStorage 保存最佳时间:
JavaScriptlocalStorage.setItem("bestTime", seconds);
下次打开页面时读取:
JavaScriptconst bestTime = localStorage.getItem("bestTime");
这样单个 HTML 文件也可以具备简单的数据持久化能力。
十二、单HTML文件开发的优势
将 HTML、CSS 和 JavaScript 全部写入一个文件,对于练习 JavaScript 游戏开发尤其方便。
整个项目不需要:
-
Node.js
-
npm
-
Webpack
-
Vue
-
React
-
后端服务
-
数据库
-
第三方 JavaScript 库
只需要一个 .html 文件即可。
这类实现非常适合学习 DOM 操作、事件监听、二维数组、随机算法以及状态管理。如果后续项目规模扩大,再将样式和 JavaScript 拆分成独立文件,会更加利于维护。
十三、常见问题排查
点击格子没有反应
首先检查是否正确执行:
JavaScriptcell.addEventListener("click", ...)
其次确认 gameOver 是否已经变成 true。
右键出现浏览器菜单
需要阻止默认行为:
JavaScriptevent.preventDefault();
否则无法正常使用右键插旗。
数字显示不正确
重点检查 countAdjacentMines() 中的行列边界判断,以及是否错误地把当前格子本身计算进去。
空白区域无法完整展开
需要确认队列中的新格子是否继续加入:
JavaScriptif (cell.count === 0) { queue.push([nr, nc]); }
同时还要防止已经处理过的格子重复加入,否则可能造成大量重复计算。
胜利条件不触发
不要简单使用“旗子数量等于地雷数量”判断胜利。正确方式应该统计已经打开的安全格子数量:
JavaScriptrevealedCount === rows * cols - mines
这样即使玩家没有插旗,也可以正常完成游戏。
十四、总结
单 HTML 文件实现网页版扫雷,核心并不在于复杂的页面结构,而在于把游戏规则转换成清晰的数据模型和算法。二维数组负责保存棋盘,随机算法负责生成地雷,邻居遍历负责计算数字,队列负责展开空白区域,事件监听负责处理用户操作,最后再通过状态判断决定游戏胜负。
整个实现不依赖任何外部框架,保存成一个 HTML 文件即可运行,非常适合作为 JavaScript DOM 编程和小游戏开发的练习项目。掌握这套实现思路之后,还可以进一步加入首次点击保护、计时器、难度选择、排行榜、音效以及移动端触摸操作,让基础扫雷逐渐发展成一个完整的网页版小游戏。