单HTML文件实现网页版扫雷游戏教程

13 次阅读

扫雷游戏看似规则简单,真正实现时却涉及二维数组、随机布雷、邻居计算、递归展开、状态管理和页面交互等多个知识点。只要利用 HTML、CSS 和 JavaScript,就可以把这些逻辑全部放进一个 HTML 文件中,无需安装框架、配置服务器,也不依赖第三方库,直接用浏览器打开即可运行。

一、单HTML文件实现扫雷需要哪些功能

一个完整的网页版扫雷通常包含以下核心功能:

  • 创建指定大小的棋盘

  • 随机生成地雷

  • 计算每个格子周围的地雷数量

  • 点击格子进行排雷

  • 自动展开连续的空白区域

  • 点击地雷后判定游戏失败

  • 标记疑似地雷

  • 判断玩家是否成功

  • 显示剩余雷数

  • 提供重新开始游戏功能

  • 使用 CSS 完成棋盘和格子的视觉效果

由于整个项目只有一个 HTML 文件,因此可以直接采用内嵌 class="game"> class="game-header"> class="counter"> 雷数: id="mineCount">10

id="restart">重新开始
id="board" class="board">
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();

六、核心代码解析

1. 使用二维数组保存棋盘

createBoard()负责初始化整个游戏。

JavaScript
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);
}

这种方式让逻辑层和页面层保持相对独立。JavaScript只负责维护游戏状态,而 renderBoard()负责将状态转换成 HTML 元素。

2. 左键负责打开格子

点击普通格子后执行:

JavaScript
revealCell(row, col);

如果当前格子是地雷:

JavaScript
if (cell.mine) {
    gameOver = true;
    revealAllMines();
    return;
}

否则继续判断周围是否存在地雷。

3. 右键实现插旗

网页版扫雷通常使用鼠标右键标记地雷,因此需要监听 contextmenu:

JavaScript
cell.addEventListener("contextmenu", event => {
    event.preventDefault();
    toggleFlag(row, col);
});

调用 preventDefault() 可以阻止浏览器默认的右键菜单。

插旗时需要同步维护:

JavaScript
flags++;

取消标记时:

JavaScript
flags--;

这样页面上的剩余雷数就能够实时更新。

七、为什么需要自动展开空白区域

扫雷有一个非常重要的体验设计:如果玩家点击的格子周围没有地雷,那么不应该要求玩家逐个点击相邻的空白格。

例如:

0 0 0 1
0 0 1 2
0 1 2 *
1 2 * *

点击左上角的 0 后,可以自动打开相邻的所有 0,并继续向外扩展,直到遇到有数字的格子。

这也是扫雷实现中比较容易出错的部分。

传统写法可以使用递归:

JavaScript
function 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);
    }
}

但递归层数过深时可能产生调用栈问题,因此示例使用队列实现类似的广度优先搜索。

核心结构是:

JavaScript
const queue = [[row, col]];

while (queue.length > 0) {
    const [currentRow, currentCol] = queue.shift();

    // 查找周围格子
}

这种方式对于普通网页版扫雷来说更加稳妥。

八、扫雷胜负判断

玩家并不一定需要正确插旗才能获胜。

判断胜利的关键是:所有非雷格子都已经被打开。

因此可以计算:

JavaScript
const totalSafeCells = rows * cols - mines;

当:

JavaScript
revealedCount === totalSafeCells

就说明所有安全区域都已经打开,可以宣布胜利。

这种判断方式比统计旗子更加可靠,因为玩家可能不插旗,或者错误插旗,但只要所有安全格子都被正确打开,游戏仍然应该算作完成。

九、如何修改游戏难度

示例使用:

JavaScript
const rows = 9;
const cols = 9;
const mines = 10;

如果希望制作更大的棋盘,可以调整成:

JavaScript
const rows = 16;
const cols = 16;
const mines = 40;

如果想制作困难模式:

JavaScript
const rows = 30;
const cols = 16;
const mines = 99;

同时需要注意页面尺寸。

棋盘越大,固定的 38px 格子越容易导致移动端出现横向滚动。因此实际项目中可以进一步使用 CSS 响应式布局,根据屏幕宽度动态计算格子尺寸。

十、优化首次点击体验

实际的扫雷游戏通常不会让玩家第一次点击就直接踩中地雷。

当前示例是在初始化时立即布雷,因此存在第一次点击踩雷的可能。

如果希望优化体验,可以把“生成地雷”的时机推迟到第一次点击之后。

基本思路是:

JavaScript
let firstClick = true;

function revealCell(row, col) {
    if (firstClick) {
        placeMinesSafely(row, col);
        calculateNumbers();
        firstClick = false;
    }

    // 后续正常处理
}

placeMinesSafely()可以在生成地雷时排除第一次点击位置,甚至同时排除它周围的八个格子。

这样玩家第一次点击至少可以获得一个安全区域,游戏体验会明显更好。

十一、可以继续增加哪些功能

单 HTML 扫雷只是一个基础版本,在此基础上可以继续扩展。

增加计时器

可以使用:

JavaScript
let seconds = 0;

setInterval(() => {
    if (!gameOver) {
        seconds++;
    }
}, 1000);

再把时间显示到游戏顶部,就可以形成更加完整的游戏界面。

增加难度选择

可以提供简单、中等、困难三个按钮:

JavaScript
const levels = {
    easy: {
        rows: 9,
        cols: 9,
        mines: 10
    },
    medium: {
        rows: 16,
        cols: 16,
        mines: 40
    },
    hard: {
        rows: 30,
        cols: 16,
        mines: 99
    }
};

切换难度后重新创建棋盘即可。

增加触摸操作

移动端没有传统意义上的鼠标右键,因此如果希望扫雷游戏兼容手机,可以增加长按事件。

例如长按格子时执行插旗操作,普通点击则负责打开格子。

增加最佳成绩

可以使用浏览器的 localStorage 保存最佳时间:

JavaScript
localStorage.setItem("bestTime", seconds);

下次打开页面时读取:

JavaScript
const bestTime = localStorage.getItem("bestTime");

这样单个 HTML 文件也可以具备简单的数据持久化能力。

十二、单HTML文件开发的优势

将 HTML、CSS 和 JavaScript 全部写入一个文件,对于练习 JavaScript 游戏开发尤其方便。

整个项目不需要:

只需要一个 .html 文件即可。

这类实现非常适合学习 DOM 操作、事件监听、二维数组、随机算法以及状态管理。如果后续项目规模扩大,再将样式和 JavaScript 拆分成独立文件,会更加利于维护。

十三、常见问题排查

点击格子没有反应

首先检查是否正确执行:

JavaScript
cell.addEventListener("click", ...)

其次确认 gameOver 是否已经变成 true。

右键出现浏览器菜单

需要阻止默认行为:

JavaScript
event.preventDefault();

否则无法正常使用右键插旗。

数字显示不正确

重点检查 countAdjacentMines() 中的行列边界判断,以及是否错误地把当前格子本身计算进去。

空白区域无法完整展开

需要确认队列中的新格子是否继续加入:

JavaScript
if (cell.count === 0) {
    queue.push([nr, nc]);
}

同时还要防止已经处理过的格子重复加入,否则可能造成大量重复计算。

胜利条件不触发

不要简单使用“旗子数量等于地雷数量”判断胜利。正确方式应该统计已经打开的安全格子数量:

JavaScript
revealedCount === rows * cols - mines

这样即使玩家没有插旗,也可以正常完成游戏。

十四、总结

单 HTML 文件实现网页版扫雷,核心并不在于复杂的页面结构,而在于把游戏规则转换成清晰的数据模型和算法。二维数组负责保存棋盘,随机算法负责生成地雷,邻居遍历负责计算数字,队列负责展开空白区域,事件监听负责处理用户操作,最后再通过状态判断决定游戏胜负。

整个实现不依赖任何外部框架,保存成一个 HTML 文件即可运行,非常适合作为 JavaScript DOM 编程和小游戏开发的练习项目。掌握这套实现思路之后,还可以进一步加入首次点击保护、计时器、难度选择、排行榜、音效以及移动端触摸操作,让基础扫雷逐渐发展成一个完整的网页版小游戏。