HTML5游戏开发实战:用现代前端技术重构经典扫雷
1. 为什么我们要用现代前端技术重构扫雷相信很多朋友和我一样对扫雷这款游戏有着特殊的感情。它不仅是Windows系统里一个简单的娱乐程序更是很多人编程启蒙的“第一课”。我还记得自己刚开始学前端的时候第一个想动手实现的就是它。但那时候写出来的代码现在回头看简直是一团乱麻——所有的逻辑都塞在一个文件里全局变量满天飞想加个新功能都无从下手。所以今天我想和大家一起用现代前端技术栈重新“锻造”一次这个经典游戏。这不仅仅是为了怀旧更是一次绝佳的工程化实战演练。我们会把那些老旧的、面条式的代码彻底重构为模块清晰、易于维护、并且具备良好扩展性的现代Web应用。在这个过程中你会亲身体会到ES6的模块化、CSS Grid布局的威力以及如何用状态管理的思想来驾驭复杂的游戏逻辑。最终产出的将不再是一个简单的教学Demo而是一个代码结构清晰、可读性强、甚至能轻松移植到不同框架比如Vue或React中的“工业级”小项目。对于已经掌握了HTML、CSS、JavaScript基础但苦于不知道如何组织更复杂代码的开发者来说这次重构之旅会是一次质的飞跃。你会发现原来那些看似高深的“设计模式”和“最佳实践”在这样一个具体的、有明确目标的项目中会变得如此自然和必要。好了话不多说我们直接进入正题看看如何从零开始搭建一个现代化的扫雷游戏。2. 项目架构与模块化设计告别“一锅炖”在原始的实现里所有的代码——从生成棋盘到处理点击事件——都堆在一个巨大的script标签里。这种写法对于学习基本语法没问题但一旦项目规模稍微扩大或者你需要和别人协作维护起来就是一场噩梦。我们重构的第一步就是进行彻底的模块化拆分。2.1 使用ES6模块组织代码现代前端开发的核心思想是“分而治之”。我们将游戏的不同职责划分到独立的模块中。我会创建一个src目录里面大概是这样组织的src/ ├── game/ │ ├── Board.js # 棋盘数据与逻辑 │ ├── Game.js # 游戏核心状态与流程控制 │ └── Cell.js # 单个格子的模型 ├── ui/ │ ├── Renderer.js # 负责将游戏状态渲染到DOM │ └── Controls.js # 处理用户交互点击、右键 ├── utils/ │ └── helpers.js # 工具函数如随机数生成 └── main.js # 应用入口初始化并串联所有模块这样做的好处显而易见。比如Board.js只关心棋盘数据的生成和计算布雷、算数字它完全不知道DOM长什么样。Renderer.js则只负责根据Board提供的数据用CSS Grid画出界面。如果哪天我想把渲染引擎从DOM换成Canvas我只需要重写Renderer.js其他模块几乎不用动。这种关注点分离是构建可维护应用的基础。2.2 用Class重构游戏核心模型原始代码用普通的对象和数组来存储游戏状态这在简单场景下够用但缺乏封装性。我们使用ES6的class来为游戏的核心概念建立模型让数据和行为内聚在一起。让我们先看看Cell.js它代表棋盘上的一个格子// src/game/Cell.js export class Cell { constructor(row, col) { this.row row; this.col col; this.isMine false; this.isRevealed false; this.isFlagged false; this.adjacentMines 0; } // 标记或取消标记 toggleFlag() { if (!this.isRevealed) { this.isFlagged !this.isFlagged; } return this.isFlagged; // 返回当前标记状态便于UI更新 } // 揭开格子 reveal() { if (this.isRevealed || this.isFlagged) return false; this.isRevealed true; return true; } // 判断当前格子是否可安全点击非地雷且未被揭开 get isSafe() { return !this.isMine; } // 判断是否显示数字是安全格且周围有雷 get shouldShowNumber() { return this.isRevealed this.isSafe this.adjacentMines 0; } // 判断是否是空白格是安全格且周围无雷 get isEmpty() { return this.isRevealed this.isSafe this.adjacentMines 0; } }你看一个格子该有的属性和行为都被封装在这个类里了。toggleFlag、reveal这些方法定义了格子能做什么而isSafe、shouldShowNumber这些getter则提供了便捷的状态查询避免了在业务逻辑里写一堆if (!cell.isMine cell.isRevealed ...)这样冗长的判断。接下来Board类将管理这些Cell的集合。3. 核心游戏逻辑的现代化实现有了清晰的数据模型实现游戏的核心逻辑就变得顺理成章了。我们将把生成棋盘、放置地雷、计算数字、处理点击展开这些经典算法用更现代、更清晰的方式重写一遍。3.1 使用Board类管理棋盘状态Board类是游戏的大脑它持有所有格子的二维数组并负责最关键的算法。// src/game/Board.js import { Cell } from ./Cell.js; export class Board { constructor(rows 10, cols 10, mineCount 15) { this.rows rows; this.cols cols; this.mineCount mineCount; this.cells []; this._initializeBoard(); this._placeMines(); this._calculateAdjacentMines(); } // 初始化一个空的格子矩阵 _initializeBoard() { for (let r 0; r this.rows; r) { this.cells[r] []; for (let c 0; c this.cols; c) { this.cells[r][c] new Cell(r, c); } } } // 随机放置地雷 - 更优雅的实现 _placeMines() { let minesToPlace this.mineCount; const totalCells this.rows * this.cols; // 创建一个包含所有格子索引的数组 const allIndices Array.from({ length: totalCells }, (_, i) i); // 使用Fisher-Yates洗牌算法打乱顺序 for (let i allIndices.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [allIndices[i], allIndices[j]] [allIndices[j], allIndices[i]]; } // 取前mineCount个作为地雷位置 for (let i 0; i minesToPlace; i) { const idx allIndices[i]; const row Math.floor(idx / this.cols); const col idx % this.cols; this.cells[row][col].isMine true; } } }这里我改进了布雷算法。原始版本用while循环随机选位置可能会重复选中已有地雷的格子效率在极端情况下比如在99%都是雷的棋盘上可能不高。新方法先创建一个所有格子的索引数组然后用经典的Fisher-Yates算法进行洗牌再顺序取前N个作为雷位。这种方法保证了绝对均匀的随机分布且效率是稳定的O(n)。3.2 计算相邻地雷数与递归展开计算每个格子周围的地雷数以及点击空白格时的递归展开是扫雷算法的精髓。// 继续在 Board.js 中添加方法 _calculateAdjacentMines() { // 定义八个方向的偏移量 const directions [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; for (let r 0; r this.rows; r) { for (let c 0; c this.cols; c) { const cell this.cells[r][c]; if (cell.isMine) continue; let count 0; for (const [dr, dc] of directions) { const nr r dr; const nc c dc; if (this._isValidCoordinate(nr, nc) this.cells[nr][nc].isMine) { count; } } cell.adjacentMines count; } } } // 一个实用的边界检查辅助方法 _isValidCoordinate(row, col) { return row 0 row this.rows col 0 col this.cols; } // 核心递归展开空白区域 revealCell(row, col) { const cell this.getCell(row, col); if (!cell || cell.isRevealed || cell.isFlagged) { return []; // 返回一个受影响的格子数组用于UI更新 } const revealedCells []; const stack [[row, col]]; // 使用栈代替递归避免调用栈溢出 while (stack.length 0) { const [r, c] stack.pop(); const currentCell this.getCell(r, c); // 再次检查因为同一个格子可能被多次加入栈从不同路径 if (!currentCell || currentCell.isRevealed || currentCell.isFlagged) continue; currentCell.reveal(); revealedCells.push(currentCell); // 如果当前格子是空白格周围无雷才展开其邻居 if (currentCell.adjacentMines 0) { for (const [dr, dc] of this.directions) { const nr r dr; const nc c dc; if (this._isValidCoordinate(nr, nc)) { const neighbor this.getCell(nr, nc); // 只将未揭开且未标记的安全邻居加入栈 if (neighbor !neighbor.isRevealed !neighbor.isFlagged neighbor.isSafe) { stack.push([nr, nc]); } } } } } return revealedCells; // 返回所有被揭开的格子 } getCell(row, col) { if (this._isValidCoordinate(row, col)) { return this.cells[row][col]; } return null; }这里我做了两个重要的优化。第一将八个方向的偏移量定义为一个常量数组这样代码更清晰也便于复用。第二在revealCell方法中我使用了显式的栈Stack来代替函数递归。虽然递归写法更简洁但在极端大的棋盘上递归深度可能超过JavaScript引擎的限制导致堆栈溢出。用循环和栈来模拟递归过程是更稳健的做法。这个方法最后返回一个被揭开的格子数组这样UI层可以批量更新而不是每揭开一个格子就操作一次DOM性能更好。4. 状态管理与游戏流程控制在原始代码中游戏状态是否结束、是否胜利是通过一个全局的gameOver变量来管理的这在小项目中没问题但随着功能增加比如计时器、难度选择、撤销操作状态会变得难以同步。我们需要一个更集中的状态管理机制。4.1 创建Game类作为总指挥Game类将扮演“导演”的角色它持有Board实例并协调游戏规则、状态和用户交互。// src/game/Game.js import { Board } from ./Board.js; export class Game { constructor(rows, cols, mineCount) { this.board new Board(rows, cols, mineCount); this.state { status: READY, // READY, PLAYING, WIN, LOSE startTime: null, endTime: null, flagsUsed: 0 }; this.subscribers []; // 用于实现简单的观察者模式 } // 开始游戏第一次点击时 start(firstClickRow, firstClickCol) { // 一个重要的优化确保第一次点击不会是雷 // 如果第一次点击的位置是雷我们需要重新布雷 if (this.board.getCell(firstClickRow, firstClickCol).isMine) { this._relocateMine(firstClickRow, firstClickCol); } this.state.status PLAYING; this.state.startTime Date.now(); this._notifySubscribers(); // 通知所有订阅者状态已更新 } // 处理左键点击 handleReveal(row, col) { if (this.state.status ! PLAYING) return; const cell this.board.getCell(row, col); if (!cell || cell.isRevealed || cell.isFlagged) return; // 如果是游戏中的第一次点击触发游戏开始 if (this.state.status READY || (this.state.status PLAYING !this.state.startTime)) { this.start(row, col); } const revealedCells this.board.revealCell(row, col); // 检查是否踩雷 if (cell.isMine) { this.state.status LOSE; this.state.endTime Date.now(); this._notifySubscribers(); return { revealedCells, gameOver: true, won: false }; } // 检查是否胜利 if (this._checkWin()) { this.state.status WIN; this.state.endTime Date.now(); this._notifySubscribers(); return { revealedCells, gameOver: true, won: true }; } this._notifySubscribers(); return { revealedCells, gameOver: false }; } // 处理右键标记 handleFlag(row, col) { if (this.state.status ! PLAYING this.state.status ! READY) return; const cell this.board.getCell(row, col); if (!cell || cell.isRevealed) return; const newFlagState cell.toggleFlag(); // 更新已使用的旗帜计数 this.state.flagsUsed newFlagState ? 1 : -1; // 标记后也检查是否胜利所有雷都被正确标记 if (this._checkWin()) { this.state.status WIN; this.state.endTime Date.now(); } this._notifySubscribers(); return { cell, flagged: newFlagState }; } // 胜利条件所有安全格被揭开或所有地雷被正确标记 _checkWin() { let allSafeRevealed true; let allMinesFlagged true; for (let r 0; r this.board.rows; r) { for (let c 0; c this.board.cols; c) { const cell this.board.getCell(r, c); if (cell.isMine) { if (!cell.isFlagged) allMinesFlagged false; } else { if (!cell.isRevealed) allSafeRevealed false; } } } return allSafeRevealed || allMinesFlagged; } // 简单的观察者模式允许UI订阅游戏状态变化 subscribe(callback) { this.subscribers.push(callback); } _notifySubscribers() { this.subscribers.forEach(cb cb(this.state, this.board)); } // 重新开始游戏 restart(rows, cols, mineCount) { this.board new Board(rows, cols, mineCount); this.state { status: READY, startTime: null, endTime: null, flagsUsed: 0 }; this._notifySubscribers(); } }这个Game类有几个关键设计。第一它管理了一个明确的state对象包含了游戏状态、时间、旗帜数等所有状态变更都通过这个对象清晰可控。第二它实现了第一次点击保护通过_relocateMine方法这里未完全实现思路是将第一次点击位置的雷移到其他空白位置确保玩家不会一上来就“暴毙”这是很多现代扫雷游戏的标配提升了用户体验。第三它引入了一个简单的观察者模式Pub/Sub。UI组件如Renderer可以订阅游戏状态的变化当Game内部状态改变时会自动通知所有订阅者从而实现数据和视图的解耦。UI不再需要主动轮询或直接修改游戏状态只需要响应通知并更新视图即可。5. 现代化UICSS Grid与响应式设计游戏逻辑是筋骨UI则是皮肉。我们将彻底抛弃传统的基于float或inline-block的布局拥抱CSS Grid Layout并加入一些现代化的交互细节。5.1 使用CSS Grid构建灵活棋盘CSS Grid让我们能像搭积木一样轻松定义行和列非常适合棋盘类布局。/* styles.css */ .game-container { display: flex; flex-direction: column; align-items: center; font-family: Segoe UI, system-ui, sans-serif; min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 2rem; } .game-header { margin-bottom: 2rem; color: white; text-align: center; } .game-stats { display: flex; justify-content: space-around; width: 100%; max-width: 500px; background: rgba(255, 255, 255, 0.1); padding: 1rem; border-radius: 10px; margin-bottom: 1.5rem; backdrop-filter: blur(10px); } .stat { color: white; font-size: 1.2rem; } /* 核心棋盘Grid布局 */ .game-board { display: grid; /* 使用repeat和minmax实现响应式格子大小 */ grid-template-columns: repeat(var(--cols), minmax(30px, 1fr)); grid-template-rows: repeat(var(--rows), minmax(30px, 1fr)); gap: 2px; /* 格子间隙 */ background-color: #4a5568; /* 间隙的颜色 */ padding: 4px; border-radius: 8px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); margin-bottom: 2rem; } /* 棋盘格子样式 */ .cell { aspect-ratio: 1 / 1; /* 确保格子是正方形 */ background-color: #a0aec0; /* 未揭开时的颜色 */ border-radius: 4px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.1rem; cursor: pointer; user-select: none; transition: all 0.15s ease; /* 一个精致的内部阴影模拟凹陷效果 */ box-shadow: inset 2px 2px 4px rgba(0, 0, 0, 0.2), inset -2px -2px 4px rgba(255, 255, 255, 0.1); } .cell:hover:not(.revealed) { background-color: #90a0b8; transform: scale(1.05); } /* 已揭开的格子 */ .cell.revealed { background-color: #edf2f7; cursor: default; box-shadow: none; /* 去掉凹陷效果 */ } /* 地雷格子 */ .cell.mine { background-color: #fc8181; background-image: url(data:image/svgxml;utf8,svg xmlnshttp://www.w3.org/2000/svg viewBox0 0 24 24 fill%23000circle cx12 cy12 r8//svg); background-repeat: no-repeat; background-position: center; background-size: 70%; } /* 旗帜标记 */ .cell.flagged { background-color: #f6e05e; background-image: url(data:image/svgxml;utf8,svg xmlnshttp://www.w3.org/2000/svg viewBox0 0 24 24 fill%23c53030path dM3 3h2v18H3zm16 0l-6 6 6 6V3z//svg); background-repeat: no-repeat; background-position: center; background-size: 70%; } /* 数字颜色 */ .cell.number-1 { color: #2b6cb0; } .cell.number-2 { color: #276749; } .cell.number-3 { color: #c53030; } .cell.number-4 { color: #6b46c1; } .cell.number-5 { color: #b83280; } .cell.number-6 { color: #d69e2e; } .cell.number-7 { color: #000000; } .cell.number-8 { color: #4a5568; }这里我大量使用了现代CSS特性。grid-template-columns: repeat(var(--cols), minmax(30px, 1fr));这行是关键它通过CSS自定义属性--cols动态设置列数并且用minmax确保了格子既有最小尺寸又能随着容器放大1fr。aspect-ratio: 1 / 1是另一个神器它能轻松保证格子是完美的正方形无需再用JavaScript计算高度。我还为不同数字定义了不同的颜色并使用了内阴影inset box-shadow来营造格子的立体感这些细节能显著提升游戏的视觉质感。5.2 实现响应式与交互反馈一个好的UI不仅要好看还要好用。我们需要让游戏在不同设备上都能良好显示并且给用户清晰的操作反馈。// src/ui/Renderer.js export class Renderer { constructor(game, containerId game-container) { this.game game; this.container document.getElementById(containerId); if (!this.container) { console.error(Container with id ${containerId} not found.); return; } this.cellElements []; // 缓存DOM元素避免频繁查询 this._initUI(); this._renderBoard(); // 订阅游戏状态变化 this.game.subscribe((state, board) this._onGameStateChange(state, board)); } _initUI() { // 清空容器并构建基础UI结构 this.container.innerHTML ; const header document.createElement(header); header.className game-header; header.innerHTML h1 现代扫雷/h1; const stats document.createElement(div); stats.className game-stats; stats.innerHTML div classstat idmine-count雷数: ${this.game.board.mineCount}/div div classstat idflag-count: span0/span / ${this.game.board.mineCount}/div div classstat idtimer⏱️: span0/spans/div ; const boardEl document.createElement(div); boardEl.className game-board; // 通过CSS变量动态设置Grid行列 boardEl.style.setProperty(--rows, this.game.board.rows); boardEl.style.setProperty(--cols, this.game.board.cols); boardEl.id board; const controls document.createElement(div); controls.className game-controls; controls.innerHTML button idbtn-restart classbtn 新游戏/button select iddifficulty classdropdown option value10,10,15初级 (10x10, 15雷)/option option value16,16,40中级 (16x16, 40雷)/option option value16,30,99高级 (16x30, 99雷)/option /select ; this.container.appendChild(header); this.container.appendChild(stats); this.container.appendChild(boardEl); this.container.appendChild(controls); // 绑定控制按钮事件 document.getElementById(btn-restart).addEventListener(click, () this._handleRestart()); document.getElementById(difficulty).addEventListener(change, (e) this._handleDifficultyChange(e)); // 初始化计时器 this.timerInterval null; this.startTime null; } _renderBoard() { const boardEl document.getElementById(board); boardEl.innerHTML ; // 清空旧棋盘 this.cellElements []; // 清空缓存 const { rows, cols, cells } this.game.board; for (let r 0; r rows; r) { this.cellElements[r] []; for (let c 0; c cols; c) { const cell cells[r][c]; const cellEl document.createElement(div); cellEl.className cell; cellEl.dataset.row r; cellEl.dataset.col c; cellEl.setAttribute(aria-label, 格子 ${r}, ${c}); // 无障碍支持 // 绑定事件监听器注意使用箭头函数或bind来保持this上下文 cellEl.addEventListener(click, (e) this._handleCellClick(e, r, c)); cellEl.addEventListener(contextmenu, (e) this._handleCellRightClick(e, r, c)); boardEl.appendChild(cellEl); this.cellElements[r][c] cellEl; } } this._updateBoardView(); // 初始渲染 } _updateBoardView() { const { cells } this.game.board; for (let r 0; r cells.length; r) { for (let c 0; c cells[r].length; c) { const cell cells[r][c]; const cellEl this.cellElements[r][c]; // 重置类名 cellEl.className cell; cellEl.textContent ; if (cell.isRevealed) { cellEl.classList.add(revealed); if (cell.isMine) { cellEl.classList.add(mine); } else if (cell.adjacentMines 0) { cellEl.textContent cell.adjacentMines; cellEl.classList.add(number-${cell.adjacentMines}); } } else if (cell.isFlagged) { cellEl.classList.add(flagged); } } } } _onGameStateChange(state, board) { // 更新统计信息 document.getElementById(flag-count).querySelector(span).textContent state.flagsUsed; this._updateTimer(state); // 更新棋盘视图 this._updateBoardView(); // 处理游戏结束状态 if (state.status WIN || state.status LOSE) { this._stopTimer(); // 可以在这里显示更精美的结束弹窗而不是用alert setTimeout(() { const message state.status WIN ? 恭喜通关用时 ${Math.floor((state.endTime - state.startTime) / 1000)} 秒。 : 很遗憾踩到地雷了; // 使用自定义弹窗或更新UI区域显示信息 this._showGameOverMessage(message); }, 100); // 稍作延迟让最后一步的UI更新完成 } else if (state.status PLAYING state.startTime !this.timerInterval) { this._startTimer(); } } _startTimer() { this.startTime Date.now(); this.timerInterval setInterval(() { const elapsed Math.floor((Date.now() - this.startTime) / 1000); document.getElementById(timer).querySelector(span).textContent elapsed; }, 1000); } _stopTimer() { if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval null; } } _updateTimer(state) { const timerSpan document.getElementById(timer).querySelector(span); if (state.status PLAYING state.startTime) { const elapsed Math.floor((Date.now() - state.startTime) / 1000); timerSpan.textContent elapsed; } else if (state.status READY) { timerSpan.textContent 0; } } _showGameOverMessage(msg) { // 可以替换为更友好的UI通知这里简单用alert演示 alert(msg); } _handleCellClick(event, row, col) { this.game.handleReveal(row, col); } _handleCellRightClick(event, row, col) { event.preventDefault(); // 阻止浏览器默认右键菜单 this.game.handleFlag(row, col); } _handleRestart() { const difficultySelect document.getElementById(difficulty); const [rows, cols, mines] difficultySelect.value.split(,).map(Number); this.game.restart(rows, cols, mines); this._renderBoard(); // 重新渲染棋盘 } _handleDifficultyChange(event) { this._handleRestart(); // 切换难度即重新开始 } }这个Renderer类负责所有与DOM相关的操作。它有几个亮点第一它订阅了Game的状态变化this.game.subscribe实现了数据驱动视图。游戏状态一变UI自动更新我们不需要手动去调用各种更新函数。第二它缓存了所有格子元素的引用this.cellElements在更新视图时直接操作这个缓存数组比每次都使用document.querySelector性能要好得多。第三它实现了完整的游戏控制界面包括计时器、难度选择和重新开始按钮让游戏体验更完整。计时器的实现也考虑了性能只在游戏进行时启动定时器游戏结束时清除。6. 性能优化与高级特性探索一个基础版本的游戏已经完成了。但对于追求极致的开发者来说我们还可以走得更远探索一些高级特性和性能优化点让这个项目从“不错”变得“出色”。6.1 使用Canvas或WebGL进行渲染对于非常大的棋盘比如100x100动态创建和管理上千个DOM元素可能会带来性能压力。这时我们可以考虑使用canvas或WebGL进行渲染。CanvasRenderer可以作为一个可插拔的替代方案与现有的Game逻辑完全兼容。// src/ui/CanvasRenderer.js export class CanvasRenderer { constructor(game, canvasId game-canvas) { this.game game; this.canvas document.getElementById(canvasId); this.ctx this.canvas.getContext(2d); this.cellSize 30; this.colors { /* 定义颜色方案 */ }; this._resizeCanvas(); this._drawBoard(); this.game.subscribe((state, board) this._onGameStateChange(state, board)); // 处理Canvas上的点击事件需要将像素坐标转换为棋盘坐标 this.canvas.addEventListener(click, (e) this._handleCanvasClick(e)); this.canvas.addEventListener(contextmenu, (e) { e.preventDefault(); this._handleCanvasRightClick(e); }); } _resizeCanvas() { const { rows, cols } this.game.board; this.canvas.width cols * this.cellSize; this.canvas.height rows * this.cellSize; this.canvas.style.width ${this.canvas.width}px; this.canvas.style.height ${this.canvas.height}px; } _drawBoard() { const { cells } this.game.board; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); for (let r 0; r cells.length; r) { for (let c 0; c cells[r].length; c) { this._drawCell(r, c, cells[r][c]); } } } _drawCell(row, col, cell) { const x col * this.cellSize; const y row * this.cellSize; // 绘制格子背景 this.ctx.fillStyle cell.isRevealed ? this.colors.revealed : this.colors.hidden; this.ctx.fillRect(x, y, this.cellSize, this.cellSize); // 绘制边框 this.ctx.strokeStyle this.colors.border; this.ctx.strokeRect(x, y, this.cellSize, this.cellSize); if (cell.isRevealed) { if (cell.isMine) { // 绘制地雷 this._drawMine(x, y); } else if (cell.adjacentMines 0) { // 绘制数字 this._drawNumber(x, y, cell.adjacentMines); } } else if (cell.isFlagged) { // 绘制旗帜 this._drawFlag(x, y); } } _handleCanvasClick(event) { const rect this.canvas.getBoundingClientRect(); const x event.clientX - rect.left; const y event.clientY - rect.top; const col Math.floor(x / this.cellSize); const row Math.floor(y / this.cellSize); this.game.handleReveal(row, col); } _onGameStateChange(state, board) { this._drawBoard(); // 状态变化时重绘整个Canvas // ... 同样可以更新计时器、计数器等UI } }Canvas渲染的优势在于无论棋盘多大它都只有一个DOM元素canvas重绘性能极高。缺点是实现交互如点击检测和动态效果如动画需要更多代码。我们可以根据项目需求灵活选择DOM或Canvas方案甚至设计一个渲染器抽象层让两者可以热切换。6.2 实现游戏状态持久化与回放这是一个更有挑战性但也更有趣的特性。我们可以利用现代浏览器提供的localStorage或IndexedDB来保存游戏状态甚至记录每一步操作实现“存档/读档”和“游戏回放”功能。// src/utils/GameRecorder.js export class GameRecorder { constructor(game) { this.game game; this.moves []; // 记录每一步操作{type: reveal|flag, row, col, timestamp} this.startTime null; } recordMove(type, row, col) { this.moves.push({ type, row, col, timestamp: Date.now() - this.startTime }); } startRecording() { this.moves []; this.startTime Date.now(); // 可以监听Game的事件或直接由Game调用recordMove } saveToLocalStorage(slot autosave) { const gameData { boardConfig: { rows: this.game.board.rows, cols: this.game.board.cols, mineCount: this.game.board.mineCount }, gameState: this.game.state, // 注意不能直接保存Cell对象需要序列化关键数据 boardData: this._serializeBoard(this.game.board), moves: this.moves, saveTime: new Date().toISOString() }; try { localStorage.setItem(minesweeper_save_${slot}, JSON.stringify(gameData)); return true; } catch (e) { console.error(保存游戏失败:, e); return false; } } loadFromLocalStorage(slot autosave) { const dataStr localStorage.getItem(minesweeper_save_${slot}); if (!dataStr) return null; try { return JSON.parse(dataStr); } catch (e) { console.error(读取存档失败:, e); return null; } } _serializeBoard(board) { // 将棋盘数据转换为可序列化的格式 return board.cells.map(row row.map(cell ({ isMine: cell.isMine, isRevealed: cell.isRevealed, isFlagged: cell.isFlagged, adjacentMines: cell.adjacentMines }))); } // 回放功能 replay(moves, speed 1.0) { // 重置游戏到初始状态 this.game.restart(/* ... */); let index 0; const playNextMove () { if (index moves.length) return; const move moves[index]; setTimeout(() { if (move.type reveal) { this.game.handleReveal(move.row, move.col); } else { this.game.handleFlag(move.row, move.col); } index; playNextMove(); }, move.timestamp / speed); }; playNextMove(); } }这个GameRecorder类展示了如何为游戏增加高级功能。状态持久化可以让玩家随时中断和继续游戏而回放功能则非常适合做教学演示或bug复现。在实际项目中你还需要考虑数据结构的版本管理、向后兼容性等问题。7. 工程化与构建部署最后我们不能只停留在开发环境。为了让项目更专业我们需要考虑代码的构建、优化和部署。这将涉及到现代前端工具链比如使用npm管理依赖用Webpack或Vite打包以及代码风格检查和格式化。7.1 使用npm与ES6模块首先我们初始化一个npm项目并设置package.json中的type为module以便使用ES6模块语法。// package.json { name: modern-minesweeper, version: 1.0.0, type: module, scripts: { dev: vite, build: vite build, preview: vite preview, lint: eslint src/, format: prettier --write src/ }, devDependencies: { vite: ^4.0.0, eslint: ^8.0.0, prettier: ^3.0.0 } }7.2 使用Vite进行快速开发与构建Vite是一个现代化的前端构建工具启动速度极快非常适合开发。我们在项目根目录创建vite.config.js进行简单配置。// vite.config.js import { defineConfig } from vite; export default defineConfig({ base: ./, // 使用相对路径便于静态部署 build: { outDir: dist, assetsDir: assets, rollupOptions: { input: { main: ./index.html } } }, server: { open: true // 启动后自动打开浏览器 } });7.3 组织入口文件与模块导入我们的入口main.js文件现在变得非常简洁只需要初始化各个模块并将它们连接起来。// src/main.js import { Game } from ./game/Game.js; import { Renderer } from ./ui/Renderer.js; // 或者 import { CanvasRenderer } from ./ui/CanvasRenderer.js; // 初始化游戏实例中级难度 const game new Game(16, 16, 40); // 初始化渲染器DOM版本 const renderer new Renderer(game, app); // 或者使用Canvas渲染器 // const canvasRenderer new CanvasRenderer(game, game-canvas); // 为了方便调试可以将game实例挂载到window仅开发环境 if (import.meta.env.DEV) { window.game game; } console.log( 现代扫雷游戏已启动);对应的index.html文件也保持简洁主要作为应用的容器。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title现代扫雷 - HTML5游戏开发实战/title link relstylesheet href./src/styles.css /head body div idapp classgame-container !-- Renderer.js会动态填充这里 -- /div !-- 如果使用Canvas版本可以这样 -- !-- canvas idgame-canvas/canvas -- script typemodule src./src/main.js/script /body /html现在在终端运行npm run devVite会启动一个开发服务器并自动打开浏览器。你修改代码后热更新HMR会立即生效开发体验非常流畅。运行npm run build则会打包优化你的代码生成一个dist文件夹里面的文件可以直接部署到任何静态网站托管服务上比如GitHub Pages、Vercel或Netlify。走到这一步我们已经完成了一个从“玩具代码”到“可工程化项目”的完整重构。这个扫雷游戏不仅功能完整而且代码结构清晰、易于扩展和维护。你可以基于这个框架轻松地添加更多功能比如音效、主题切换、成就系统、在线排行榜等等。希望这次实战能让你感受到运用现代前端开发理念即使是经典的小游戏也能焕发出全新的生命力。