作为一个写了多年小游戏的开发者,我始终觉得扫雷是检验逻辑思维和前端基本功的绝佳练手项目。别看它界面朴素,真要动手写起来,里面涉及的递归算法、状态机管理、鼠标事件兼容性处理,每一项都值得细细琢磨。这篇文章我打算完整记录从零实现一个扫雷游戏的全过程,包含全部代码和设计思路,如果你正在学前端或者想找个小项目练手,跟着走一遍,收获会不小。
1. 内容整体设计与思路拆解
扫雷这个游戏,规则大家都熟悉:点开一个格子,如果是地雷就游戏结束,如果是数字就显示周围八格地雷的数量,如果是空白就自动向四周扩散展开。看起来简单,但实现起来有几个地方特别考验逻辑,第一个是地雷的随机分布,第二个是点击空白格后的连锁反应,第三个是右键插旗子的状态切换。
1.1 核心技术选型与方案对比
我选择用纯原生 JavaScript 搭配 Canvas 和 DOM 混合实现,而不是直接上 Vue 或 React 框架,原因很简单:扫雷的游戏逻辑本质上是状态驱动,用框架反而要处理响应式更新带来的额外复杂度。原生写法可以让我们把注意力集中在算法本身,游戏状态的变化通过一个简单的 render 函数手动同步到界面上,逻辑更透明,也更容易排查问题。
这里我用一个表格对比一下不同实现方案的优劣:
| 实现方式 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| DOM + CSS Grid | 样式易控制,调试方便 | 大棋盘渲染性能较差 | 新手学习、低难度模式 |
| Canvas 全绘制 | 性能好,适合大棋盘和动画 | 需手动处理点击坐标换算 | 中等以上难度、追求流畅 |
| WebGL | 极致性能 | 学习成本高,杀鸡用牛刀 | 不推荐 |
| DOM + Canvas 混合 | 状态用DOM,雷区边缘效果用Canvas | 实现稍复杂 | 本文采用方案 |
1.2 游戏状态机与数据模型
一个完整的扫雷游戏,状态机其实非常简单清晰,我用一个对象来管理全局状态:
javascript复制const gameState = {
status: 'ready', // ready / playing / win / lose
board: [],
rows: 9,
cols: 9,
mineCount: 10,
flagCount: 0,
revealedCount: 0,
timerInterval: null,
elapsedTime: 0,
difficulty: 'easy'
};
这里最难设计的其实是 board 数组的每一项,它需要同时记录格子的底层数据和当前状态。底层数据包括这个格子是不是地雷、周围地雷数量是多少;状态数据包括是否被翻开、是否被插旗。
我把每个格子设计成一个对象:
javascript复制{
isMine: false, // 是否为地雷
mineAround: 0, // 周围地雷数
isRevealed: false, // 是否已翻开
isFlagged: false // 是否已插旗
}
这样的好处是数据结构一目了然,渲染时直接读取属性判断应该显示什么内容,避免了一堆平行数组带来的索引错乱问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 地雷初始化算法的巧妙处理
地雷初始化有个小坑,如果使用双重循环随机布雷,可能同一个位置被重复选中,导致地雷数量不足。我用的方法是先把所有格子编号放入一个数组,然后用洗牌算法打乱,取前 mineCount 个作为雷区。这样做不仅代码简洁,而且保证了每个格子被选中的概率完全均等。
洗牌算法我选用的是经典的 Fisher-Yates 算法。这个算法从数组末尾开始,每次随机选一个当前位置之前的下标并交换,一趟下来整个数组就完全乱序。
javascript复制function shuffleArray(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 placeMines(row, col, mineCount, totalCells) {
const indices = Array.from({ length: totalCells }, (_, i) => i);
shuffleArray(indices);
const minePositions = new Set(indices.slice(0, mineCount));
minePositions.forEach(pos => {
const r = Math.floor(pos / col);
const c = pos % col;
board[r][c].isMine = true;
});
}
说起来我第一次写扫雷时没注意一个细节:玩家第一次点击就踩雷是非常糟糕的体验。市面上大部分商业扫雷都会保证第一次点击永远不会踩雷,这个实现也不复杂,只需要在布雷之前,先把第一次点击的那个格子暂时标记为“安全格”,布雷时跳过它及其周围一圈,布完雷再把标记清除。
javascript复制function placeMinesSafe(firstRow, firstCol, mineCount) {
const safeZone = new Set();
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const nr = firstRow + dr, nc = firstCol + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
safeZone.add(nr * cols + nc);
}
}
}
// 在非安全区布雷...
}
这一步体验优化值回票价,玩家不会因为手滑第一下就game over。
2.2 数字计算与边界处理
每个格子的“周围地雷数”,我采用方向数组偏移量的方式,循环遍历八个方向,在地图范围内就累加。这里最需要小心的地方是数组越界问题,每次访问前必须判断行和列是否在有效范围内,否则控制台会飘红。
javascript复制const DIRS = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1]
];
function calculateMineNumbers() {
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, nc = c + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && board[nr][nc].isMine) {
count++;
}
}
board[r][c].mineAround = count;
}
}
}
顺便提一个用户体验细节,数字的颜色我用经典的 Windows 扫雷配色:1是蓝色,2是绿色,3是红色,这样老玩家一看就有亲切感。
3. 实操过程与核心环节实现
3.1 核心算法:洪水填充与自动展开
扫雷最核心的机制是当点到空白格时,周围没有雷的格子像一个涟漪一样逐层展开,直到遇到数字边界。我选择用经典的深度优先递归方案来解决,也可以用队列做广度优先,两个都能实现同样的效果。
深度优先写法代码量最少,但有一个隐患:当棋盘特别大,比如30×30的困难模式,大片空白区会触发极深的递归调用,有爆栈风险。经验证明,99格雷的棋盘递归深度也就几百层,远没到浏览器栈上限,所以递归方案在普通难度下足够用。
javascript复制function floodFill(r, c) {
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
const cell = board[r][c];
if (cell.isRevealed || cell.isFlagged || cell.isMine) return;
cell.isRevealed = true;
revealedCount++;
if (cell.mineAround === 0) {
for (const [dr, dc] of DIRS) {
floodFill(r + dr, c + dc);
}
}
}
如果你有强迫症,也可以用显式栈把递归改成迭代,避免潜在的栈溢出:
javascript复制function floodFillIterative(startRow, startCol) {
const stack = [[startRow, startCol]];
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.isRevealed || cell.isFlagged || cell.isMine) continue;
cell.isRevealed = true;
revealedCount++;
if (cell.mineAround === 0) {
for (const [dr, dc] of DIRS) {
stack.push([r + dr, c + dc]);
}
}
}
}
两种方案实际效果差别不大,新手建议先用递归理解逻辑,再尝试改成迭代版本,对算法思维是很好的锻炼。
3.2 胜利条件判断:一个容易忽略的隐藏逻辑
判断胜负是个很容易踩坑的地方。我刚开始以为只要把所有不是雷的格子都翻开就是胜利,这个思路是对的,但实现起来有个更简便的等价条件:当“剩余未翻开的格子数 = 地雷总数”时就说明所有非雷格子已经被翻开。
这个判断方式的好处在于不需要每次翻开格子时都全盘扫描,只需用一个全局计数器 revealedCount,每翻一个格子自增一次。当 revealedCount + mineCount === totalCells 时,说明地雷以外的区域全部翻完,判定胜利,自动把剩余未翻的格子全部补上红旗。
javascript复制function checkWin() {
return revealedCount === rows * cols - mineCount;
}
3.3 界面渲染与交互细节
为了兼顾开发效率和代码可读性,我用 DOM 方式渲染整个棋盘。生成一个二维数组的格子元素,每个格子绑定事件监听器。用 Grid 布局,设置每个格子固定像素,这样整个棋盘大小就是行列数乘以格子边长,不会因浏览器窗口改变而散架。
每个格子我用了两个层:底层显示背景色和数字,顶层是盖住的“未翻开”状态。这样翻开动画只需把顶层隐藏,不需要重绘全部内容,性能开销非常小。
javascript复制function renderBoard() {
const container = document.getElementById('board');
container.innerHTML = '';
container.style.gridTemplateColumns = `repeat(${cols}, 32px)`;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cellDiv = document.createElement('div');
cellDiv.className = 'cell';
cellDiv.dataset.row = r;
cellDiv.dataset.col = c;
cellDiv.addEventListener('click', handleLeftClick);
cellDiv.addEventListener('contextmenu', handleRightClick);
container.appendChild(cellDiv);
}
}
}
这里有个非常重要的细节:扫雷的右键插旗事件。在大部分浏览器上,右键默认会弹出上下文菜单,必须在 contextmenu 事件里调用 event.preventDefault() 禁用默认菜单,否则玩家一按右键就跳出浏览器菜单,体验相当割裂。
3.4 第一次点击保护与计时器实现
刚才提到第一次点击不踩雷的保护机制,它还带来了第二个需求:只有玩家第一次点击后才开始计时。这要求事件处理函数里判断如果状态是 ready,就执行布雷逻辑,然后切换到 playing。
计时器我用了 setInterval,每秒累加一次 elapsedTime,同时更新界面上的时间显示。这里有三个小坑:
- 每次新游戏要
clearInterval旧的计时器,否则多个计时器同时跑,时间飞一样跳跃。 - 页面切到后台时,
setInterval会降低触发频率,导致计时变慢。我这里用Date.now()差值计算时间偏移量来规避这个问题。 - 胜利或失败后必须清掉计时器,否则后台还在跑,下次开局会叠加。
javascript复制function startTimer() {
if (timerInterval) clearInterval(timerInterval);
const startTime = Date.now();
timerInterval = setInterval(() => {
gameState.elapsedTime = Math.floor((Date.now() - startTime) / 1000);
document.getElementById('timer').textContent = gameState.elapsedTime;
}, 100);
}
间隔设成100毫秒而不是1000毫秒,是为了让秒数变化更精确地贴合真实一秒的边界,视觉上不发虚。
3.5 完整代码实现:可直接运行
我将完整代码整理为一个 HTML 文件,方便你直接复制运行。代码结构包含三部分:HTML 结构布局、CSS 样式、JavaScript 游戏逻辑,整体不依赖任何外部库。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>扫雷游戏</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI", Arial, sans-serif;
background: #2c3e50;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.game-wrapper {
background: #bdc3c7;
border-radius: 8px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
user-select: none;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
background: #ecf0f1;
padding: 10px 16px;
border-radius: 4px;
margin-bottom: 12px;
}
.info {
font-size: 18px;
font-weight: bold;
color: #2c3e50;
}
.controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.controls button {
padding: 6px 16px;
border: none;
border-radius: 4px;
background: #3498db;
color: #fff;
font-size: 14px;
cursor: pointer;
}
.controls button:hover {
background: #2980b9;
}
#board {
display: grid;
background: #ecf0f1;
border: 4px solid #95a5a6;
border-radius: 4px;
}
.cell {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
background: #bdc3c7;
border: 1px solid #95a5a6;
cursor: pointer;
transition: background 0.08s;
}
.cell.revealed {
background: #ecf0f1;
cursor: default;
}
.cell.flagged {
background: #f1c40f;
}
.cell.mine-hit {
background: #e74c3c;
}
.color-1 { color: #2980b9; }
.color-2 { color: #27ae60; }
.color-3 { color: #c0392b; }
.color-4 { color: #8e44ad; }
.color-5 { color: #d35400; }
.color-6 { color: #16a085; }
.color-7 { color: #e74c3c; }
.color-8 { color: #7f8c8d; }
</style>
</head>
<body>
<div class="game-wrapper">
<div class="header">
<span class="info">💣 <span id="mineCounter">10</span></span>
<span class="info">⏱ <span id="timer">0</span></span>
</div>
<div class="controls">
<button id="btnEasy" onclick="initGame(9, 9, 10)">简单</button>
<button id="btnMedium" onclick="initGame(16, 16, 40)">中等</button>
<button id="btnHard" onclick="initGame(30, 16, 99)">困难</button>
<button onclick="initGame(state.rows, state.cols, state.mineCount)">重新开始</button>
</div>
<div id="board"></div>
</div>
<script>
const DIRS = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1]
];
const state = {
status: 'ready',
board: [],
rows: 9,
cols: 9,
mineCount: 10,
revealedCount: 0,
flagCount: 0,
timerInterval: null
};
function initGame(rows, cols, mineCount) {
if (state.timerInterval) clearInterval(state.timerInterval);
state.status = 'ready';
state.rows = rows;
state.cols = cols;
state.mineCount = mineCount;
state.revealedCount = 0;
state.flagCount = 0;
state.board = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => ({
isMine: false,
mineAround: 0,
isRevealed: false,
isFlagged: false
}))
);
document.getElementById('mineCounter').textContent = mineCount;
document.getElementById('timer').textContent = '0';
renderBoard();
}
function shuffleArray(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 placeMines(firstRow, firstCol) {
const safeZone = new Set();
if (firstRow >= 0 && firstCol >= 0) {
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const nr = firstRow + dr, nc = firstCol + dc;
if (nr >= 0 && nr < state.rows && nc >= 0 && nc < state.cols) {
safeZone.add(nr * state.cols + nc);
}
}
}
}
const totalCells = state.rows * state.cols;
const indices = Array.from({ length: totalCells }, (_, i) => i);
shuffleArray(indices);
let placed = 0;
for (const idx of indices) {
if (placed >= state.mineCount) break;
if (safeZone.has(idx)) continue;
const r = Math.floor(idx / state.cols);
const c = idx % state.cols;
state.board[r][c].isMine = true;
placed++;
}
for (let r = 0; r < state.rows; r++) {
for (let c = 0; c < state.cols; c++) {
if (state.board[r][c].isMine) continue;
let count = 0;
for (const [dr, dc] of DIRS) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < state.rows && nc >= 0 && nc < state.cols && state.board[nr][nc].isMine) {
count++;
}
}
state.board[r][c].mineAround = count;
}
}
}
function renderBoard() {
const container = document.getElementById('board');
container.innerHTML = '';
container.style.gridTemplateColumns = `repeat(${state.cols}, 32px)`;
for (let r = 0; r < state.rows; r++) {
for (let c = 0; c < state.cols; c++) {
const cellDiv = document.createElement('div');
cellDiv.className = 'cell';
cellDiv.dataset.row = r;
cellDiv.dataset.col = c;
cellDiv.addEventListener('click', onLeftClick);
cellDiv.addEventListener('contextmenu', onRightClick);
container.appendChild(cellDiv);
}
}
syncBoardToDOM();
}
function syncBoardToDOM() {
const cells = document.querySelectorAll('#board .cell');
for (let r = 0; r < state.rows; r++) {
for (let c = 0; c < state.cols; c++) {
const div = cells[r * state.cols + c];
const cell = state.board[r][c];
div.className = 'cell';
div.textContent = '';
if (cell.isFlagged) {
div.classList.add('flagged');
div.textContent = '🚩';
} else if (cell.isRevealed) {
div.classList.add('revealed');
if (cell.isMine) {
div.textContent = '💣';
} else if (cell.mineAround > 0) {
div.textContent = cell.mineAround;
div.classList.add(`color-${cell.mineAround}`);
}
}
}
}
}
function onLeftClick(e) {
if (state.status === 'win' || state.status === 'lose') return;
const row = parseInt(e.target.dataset.row);
const col = parseInt(e.target.dataset.col);
const cell = state.board[row][col];
if (cell.isFlagged || cell.isRevealed) return;
if (state.status === 'ready') {
placeMines(row, col);
state.status = 'playing';
startTimer();
}
if (cell.isMine) {
cell.isRevealed = true;
state.status = 'lose';
e.target.classList.add('mine-hit');
revealAllMines();
if (state.timerInterval) clearInterval(state.timerInterval);
alert('踩到地雷了,游戏结束!');
syncBoardToDOM();
return;
}
floodFill(row, col);
syncBoardToDOM();
if (checkWin()) {
state.status = 'win';
markAllMinesAsFlagged();
if (state.timerInterval) clearInterval(state.timerInterval);
syncBoardToDOM();
alert('恭喜你,扫雷成功!');
}
}
function onRightClick(e) {
e.preventDefault();
if (state.status === 'win' || state.status === 'lose' || state.status === 'ready') {
if (state.status === 'ready') return;
return;
}
const row = parseInt(e.target.dataset.row);
const col = parseInt(e.target.dataset.col);
const cell = state.board[row][col];
if (cell.isRevealed) return;
cell.isFlagged = !cell.isFlagged;
if (cell.isFlagged) {
state.flagCount++;
} else {
state.flagCount--;
}
document.getElementById('mineCounter').textContent = state.mineCount - state.flagCount;
syncBoardToDOM();
}
function floodFill(r, c) {
if (r < 0 || r >= state.rows || c < 0 || c >= state.cols) return;
const cell = state.board[r][c];
if (cell.isRevealed || cell.isFlagged) return;
cell.isRevealed = true;
state.revealedCount++;
if (cell.mineAround === 0 && !cell.isMine) {
for (const [dr, dc] of DIRS) {
floodFill(r + dr, c + dc);
}
}
}
function checkWin() {
return state.revealedCount === state.rows * state.cols - state.mineCount;
}
function revealAllMines() {
for (let r = 0; r < state.rows; r++) {
for (let c = 0; c < state.cols; c++) {
if (state.board[r][c].isMine) {
state.board[r][c].isRevealed = true;
}
}
}
}
function markAllMinesAsFlagged() {
for (let r = 0; r < state.rows; r++) {
for (let c = 0; c < state.cols; c++) {
if (state.board[r][c].isMine) {
state.board[r][c].isFlagged = true;
}
}
}
}
function startTimer() {
if (state.timerInterval) clearInterval(state.timerInterval);
const startTime = Date.now();
state.timerInterval = setInterval(() => {
const seconds = Math.floor((Date.now() - startTime) / 1000);
document.getElementById('timer').textContent = seconds;
}, 100);
}
initGame(9, 9, 10);
</script>
</body>
</html>
整个代码压缩在两百行左右,核心逻辑都在,适合直接拿去研究和二次开发。
4. 常见问题与排查技巧实录
4.1 递归爆栈与性能问题
我调试时发现一个有意思的现象:当地图很稀疏、雷很少的时候,从中心点点击会打开一大片区域,递归层数可能上百层。普通浏览器栈帧虽然够用,如果你增加一个很耗时的DOM操作在递归里,就可能出现卡顿。我的解决方案是把 DOM 读写分离,递归只改数据,等递归完全结束再统一调 syncBoardToDOM() 绘制界面,这样避免了一层层递归里频繁操作DOM带来的性能灾难。
4.2 浏览器对右键的默认行为干扰
在实现右键插旗时发现,点击右键会让浏览器弹出菜单,同时在某些环境下,contextmenu 事件和 click 事件会同时触发。这会导致玩家右键插了个旗子,同时左键的逻辑也跑了一遍,误触翻开格子。用冒泡控制轻易解决了这个问题:在事件处理函数中调用 e.stopPropagation(),并且只处理不包含 button === 2 的 click 事件。
4.3 极简交互带来的体验误区
很多人写扫雷会忽略一个问题:插了旗的格子,如果再点击左键,游戏不应该有任何反应,否则玩家会误触翻开旗子下面的格子。我的代码里明确判断了 if (cell.isFlagged) return,这就是实际体验和逻辑完整性的关键细节。
顺带聊一个我自己的使用习惯:很多人会用扫雷来拼手速、比时间,为了让时间更真实,我特意把 setInterval 的周期设为 100 毫秒,这样第一个 0.5 秒就能看到秒数跳动,不像 1000 毫秒那样有 0.9 秒都是“0”的尴尬等待。
4.4 边界条件的极端情况测试
我给这个游戏做了一些边界测试:
- 1×1 的格子,只有1个雷,点击那唯一的格子必踩雷,这其实符合规则。
- 所有格子都不是雷(地雷数设为0),点击任意格子应该瞬间全盘翻开。
- 连续快速点击同一格,应该不会导致重复翻开或重复计数。我通过递归里的
isRevealed判断规避了这个风险。
这些极端情况往往是比较容易出现逻辑漏洞的地方,测试时务必覆盖到。
4.5 踩坑经验清单:一些可以避开的弯路
我在写这个项目的过程中踩过几个小坑,整理出来供你参考:
- 第一次布雷没有排除安全区,导致开局即雷,玩家体验极差。
- 递归展开时忘记跳过已插旗的格子,结果旗子下面也被翻开了,格式和逻辑全乱。
- 计时器没有在游戏结束时清理,重新开局后两个计时器叠加,秒数疯狂跳动。
- DOM 渲染和逻辑数据不同步,比如数据已经翻开但界面没刷新,排查了半天发现是忘了调用
syncBoardToDOM。 - 数组索引和行列混淆。JS 二维数组是
board[row][col],但渲染时很容易把 x 和 y 弄反,导致点击位置错乱。
5. 游戏扩展与优化方向
5.1 增加难度与自定义模式
上面的完整代码已经内置了三种固定难度。如果你想进一步提升可玩性,可以增加一个自定义模式,让玩家自己输入行数、列数、地雷数,数据范围加以限制就行。扩展方式很简单,在控制区再加一个按钮,弹出表单接收参数后调用 initGame(rows, cols, mines) 即可。
我个人建议地雷数量不超过格子总数的三分之二,否则计算数字时会出现大量密集雷区,玩起来反而没有逻辑推演空间,全是赌运气,违背了扫雷解谜的初衷。
5.2 增加计时排行榜与本地存储
扫雷玩家普遍追求通关时间,你能做一个小功能:把最快通关时间存到 localStorage,每次胜利弹出成绩,超过历史纪录则提示“刷新纪录”。这个功能只需要二三十行代码,但对游戏吸引力提升很明显。
javascript复制function updateBestTime(difficulty, seconds) {
const key = `minesweeper_best_${difficulty}`;
const best = parseInt(localStorage.getItem(key) || '9999');
if (seconds < best) {
localStorage.setItem(key, seconds);
return true;
}
return false;
}
5.3 增加动画与音效
为了让游戏整体质感更好,可以给翻开加一个50毫秒的淡入过渡,给踩雷加一个闪烁动画,配合简单的 Web Audio API 合成音效,效果会非常惊喜。不过要提醒一句:动画持续时间不宜过长,扫雷本身是高密度操作游戏,动画拖得越久,玩家等待放大,反而烦躁。我实测 60~80 毫秒的过渡时间体验最佳。
5.4 加入“双击快速翻开”功能
老版 Windows 扫雷有一个高级功能:如果某个数字周围旗子数已经和数字相等,双击该格子可以快速翻开周围其余未标记的格子。这对减少反复点击非常有用。
实现方式也不复杂:在左键事件里,如果点击的是已翻开且有 mineAround > 0 的格子,遍历周围八个格子,数一下旗子数量,如果旗子数量等于 mineAround,就对其余未翻开的格子执行一次 floodFill 逻辑。
javascript复制function handleChordClick(r, c) {
const cell = state.board[r][c];
if (!cell.isRevealed || cell.mineAround <= 0) return;
let flagCount = 0;
for (const [dr, dc] of DIRS) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < state.rows && nc >= 0 && nc < state.cols) {
if (state.board[nr][nc].isFlagged) flagCount++;
}
}
if (flagCount === cell.mineAround) {
for (const [dr, dc] of DIRS) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < state.rows && nc >= 0 && nc < state.cols) {
const neighbor = state.board[nr][nc];
if (!neighbor.isRevealed && !neighbor.isFlagged && !neighbor.isMine) {
floodFill(nr, nc);
}
}
}
syncBoardToDOM();
checkWin();
}
}
这个功能加上之后,游戏的顺滑程度会上一个档次,老玩家会感到很熟悉。
5.5 移动端适配
如果你想让游戏在手机上也能玩,有几个地方需要调整。一是把格子边长从 32px 增大到 40px 左右,方便手指点击;二是要把右键插旗替换成“长按插旗”手势;三是棋盘宽度较大时,需要在外层容器加 overflow: auto,避免小屏手机上棋盘被压缩变形。
长按插旗用 touchstart 和 touchend 配合一个定时器就能实现:按下保持 300 毫秒以上触发插旗,短于这个时间按左键逻辑处理。注意要在 touchstart 里调用 event.preventDefault(),否则屏幕可能会在长按时触发浏览器默认的手势行为。
写在最后的一些真实体会
扫雷是我这几年带新人练手时特别喜欢推荐的一个题目,它表面简单,但其实能把递归、数组操作、事件处理、状态管理全部串起来,是一个性价比极高的学习项目。写完这个游戏,你对 JavaScript 的异步时序和 DOM 事件流会有一个全新的理解。
如果读这篇文章的你正在学编程,建议不要在拿到代码后直接复制粘贴完事,而是先自己读一遍,把所有函数的作用理清楚,然后故意改掉一些逻辑,比如把递归改成非递归、把安全区去掉,观察游戏行为,最终你会发现对这块代码的理解会深远得多。以后再做类似的小游戏项目,思路会清晰很多。
