JavaScript扫雷游戏开发:数据结构、泛洪算法与事件驱动实战

发布时间:2026/9/16 6:31:50
JavaScript扫雷游戏开发:数据结构、泛洪算法与事件驱动实战 简介一款面向计算机、电子信息及数学等专业学生课程设计、期末大作业或毕业设计的JavaScript扫雷游戏完整源码包。项目基于原生JavaScript实现不依赖第三方库采用模块化设计拆分配置、逻辑与界面完整覆盖随机布雷、点击翻开、右键标记、胜负判断、计时与重置等流程可帮助学生理解事件驱动编程、DOM操作以及基础算法和数据结构覆盖棋盘绘制、地雷计数等常见细节。压缩包共9个文件含3个JS脚本、1个HTML页面、1个CSS样式表、2张图片素材和1份说明文档整体仅34KB解压即可在浏览器运行无需额外安装依赖。源码结构清晰各模块职责明确既可直接体验也可作为课程设计或毕设模板从理论学习过渡到实践开发目前已有55人学习。对于希望快速上手JavaScript游戏开发的同学是一份轻量而完整的参考资源。1. 扫雷课设的正确打开方式把「规则」翻译成「数据」扫雷大概是课设里正反馈来得最快的一类题目需求不用跟产品经理确认9x9、16x16、16x30 三档难度全世界通用渲染只需要一张由方格组成的 HTML 表格最难的技术点是数字 0 格子引发的连锁揭示。这个难点恰好是「树的遍历 事件驱动」两个课程考核点的结合所以老师评分点大多落在这里而不是界面上。真正被低估的还有标题末尾的.zip课设交付物是一个解压后双击就能跑的压缩包里面放 index.html、CSS、JS 三件套没有本地服务器也没有构建步骤。本地 file:// 协议里那一点限制反而比游戏逻辑更容易让初学者翻车。这篇文章就按交付标准来组织棋盘数据、交互与泛洪、计时与判定、zip 打包适合一周内要拿出一个能演示、能讲清楚、有算法含量项目的人。2. 棋盘建模与布雷用 JavaScript 对象数组管理格子状态2.1 数据结构选型为什么用对象而不是数字矩阵很多初学者先写let board[9][9]用 0 表示空格、-1 表示雷、1-8 表示数字。这个方案在运算时很简洁但游戏做到一半要增加新状态就麻烦了——一个格子需要同时表达「是否翻开」「是否被插旗」「是否是雷」「周围有几颗雷」四件事用数字矩阵就得叠加revealed[][]、flagged[][]两张平行表代码里到处是坐标的重复判断调试起来全是数字在脑子里转。我更推荐用对象数组每个格子是一个对象function createBoard(rows, cols) { const board []; for (let r 0; r rows; r) { board.push([]); for (let c 0; c cols; c) { board[r].push({ isMine: false, isRevealed: false, isFlagged: false, isQuestioned: false, adjacentMines: 0 }); } } return board; }四个布尔字段对应「是不是雷」「有没有翻开」「有没有被右键插旗」「是不是问号态」adjacentMines存周边八格的雷数。把所有状态放进同一个对象里后续写expose()、toggleFlag()时只需要操作board[r][c]不用在三个平行数组间搬运。这也是 JavaScript 对象在数据建模上的常规用法——用字段组合表达状态而不是用魔法数字表达状态。字段命名一致后渲染函数里判断格子画面也只需读这一个对象。棋盘规模用参数而不是写死成 9x9。扫雷的默认参数表是约定俗成的建议直接按这个难度表封装成配置对象老玩家一看就认得难度行数列数雷数雷的覆盖率初级9910约 12%中级161640约 16%高级163099约 21%这三个数字是扫雷的标准配置也是答辩时老师第一个会问「为什么是 10 颗雷」的答案。2.2 布雷用洗牌法而不是循环随机数布雷的第一版常见写法是「循环雷的总数每次生成随机行列如果该格已经有雷就跳过继续」。这样写的问题在于Math.random()本身均匀但「跳过已有雷」会让后生成的雷在剩余空间里被塞得更密集更麻烦的是当棋盘接近满雷时循环可能要随机很多次才能找到一个空位。虽然 9x9 棋盘上不会出性能问题但这套逻辑在讲原理时容易被追问边界条件。常见做法是先把所有格子拍平成一个数组用 Fisher-Yates 洗牌打乱然后取前mineTotal个索引布雷function placeMines(board, rows, cols, mineTotal, safeIndex) { const total rows * cols; const indices Array.from({ length: total }, (_, i) i); // Fisher-Yates 洗牌从后往前每次把当前元素与随机位置的元素交换 for (let i total - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [indices[i], indices[j]] [indices[j], indices[i]]; } let placed 0; for (let i 0; i total placed mineTotal; i) { const idx indices[i]; if (idx safeIndex) continue; // 首点保护跳过玩家第一次点的位置 const r Math.floor(idx / cols); const c idx % cols; if (!board[r][c].isMine) { board[r][c].isMine true; placed; } } return board; }参数safeIndex是第一次点击的线性索引。扫雷有一条不成文的规则第一次点击永远不能踩雷。实现上不需要做「踩雷后把雷挪走」这类事后补救直接在布雷时把首点索引从候选区排除即可。代价是如果雷数接近总格数比如 30x30 布 500 雷空位不够时循环最后几步可能找不到足够安全格课设三档难度不会触发这个边界。随机数生成器没有做种子化因为扫雷不需要「复现同一局」的功能。严格说Math.random()的随机性对游戏已经足够不需要引入crypto.getRandomValues()后者的强随机数在这里没有收益还拖慢初始化。2.3 雷数统计与首点安全的常见误区布雷完成后要算每个格子周围的雷数。两种写法一种是逐格扫描周围 8 格数到几就填几另一种是布雷时每放一颗雷就把周围 8 格的adjacentMines加 1。后者效率更优但依赖「每颗雷都能找到周围 8 格」的边界判断容易写乱。逐格扫描的写法更直白function computeAdjacentMines(board, rows, cols) { const dirs [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; 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].adjacentMines count; } } }dirs数组把 8 个方向的偏移量集中放在一处比 8 个内联if清晰也方便以后扩展成只算上下左右的变体。逐格扫描的时间复杂度是 O(rows * cols * 8)对课设数据量完全可以接受如果以后做 50x50 大棋盘再改成布雷时累加也不迟。另一个容易写错的点是首点安全范围。很多标准扫雷不仅保证「第一次点击的格子不是雷」还保证「周围 8 格也没有雷」否则玩家点开一看是个 3、4体验上很像开局踩雷。加这个规则只需把safeIndex所在格子以及周围 8 格的索引都放进一个跳过集合在placeMines里把idx safeIndex的判断改成 Set 的has即可。这个细节在答辩时值得主动讲属于「程序对规则理解的完整度」。提示数据结构和算法是这道题的核心考点第一版代码写完棋盘初始化先渲染一版纯色格子确认没有雷数和数字错误再做交互。3. 事件绑定与 Flood Fill 泛洪把点击变成状态转换3.1 左键揭示的守卫式写法界面层用二维 DOM 网格渲染棋盘每个格子绑定两个事件左键单击揭示右键单击插旗。最常见的错误是「输了之后还能继续点」以及「已经翻开的格子可以重复翻开」。按守卫顺序写function expose(row, col) { const cell board[row][col]; if (game.locked || cell.isRevealed || cell.isFlagged || cell.isQuestioned) return; if (cell.isMine) { triggerGameOver(row, col); return; } cell.isRevealed true; game.openedCount; if (cell.adjacentMines 0) { floodReveal(row, col); // 数字为0时触发泛洪 } if (game.openedCount totalSafeCells) { triggerWin(); return; } renderCell(row, col); }守卫顺序不能乱game.locked在最前表示「游戏已经结束谁点击都不响应」其次isRevealed防止重复翻开最后才是旗和问号态。如果先判断旗态游戏结束后「展示所有雷」的渲染逻辑可能被拦截。renderCell只负责一个格子的视图更新从expose()里拆出来避免每次点击都把整张棋盘重绘一遍。渲染层用button还是div rolegridcell各有取舍button 天然支持 Tab 键和回车但默认内边距会让雷形图标贴不完整CSS 里要把 padding 重置为 0用 div 则要自己补键盘事件和tabindex。课设阶段用 button 更省事事件绑定和格子状态之间的对应关系如下事件触发前提结果左键单击格子未翻开、未插旗、未问号、游戏未锁定揭示该格数字为 0 时触发泛洪右键单击格子未翻开、游戏未锁定无 → 旗 → 问号 → 无 三态切换左键双击数字格已翻开、周围旗数等于该数字一次性揭开周围未插旗格子第三行的双击是扫雷里的 chord 操作课设非必需但做上去以后等于给老师多了一个可问的交互点。3.2 Flood Fill 泛洪用显式队列替代递归扫雷最核心的算法是「点开数字 0 的格子时把相邻的同为 0 的格子连同它们外围的数字格子全部揭开」这就是泛洪填充Flood Fill。常见实现是递归但递归会隐式使用调用栈9x9 初级盘深度不会超过 81中级、高级以及自定义 40x30 大棋盘时一次长链的递归深度加上浏览器本身的 JS 执行栈偶尔会触发 RangeError。答辩现场如果被问到「递归深度超过限制怎么办」会很难收场所以这里给出用显式队列的 BFS 版本function floodReveal(startRow, startCol) { const queue [[startRow, startCol]]; while (queue.length 0) { const [r, c] queue.shift(); // 遍历以 (r, c) 为中心的九宫格跳过自身 for (let dr -1; dr 1; dr) { for (let dc -1; dc 1; dc) { if (dr 0 dc 0) continue; const nr r dr, nc c dc; if (nr 0 || nr rows || nc 0 || nc cols) continue; const cell board[nr][nc]; if (cell.isRevealed || cell.isMine || cell.isFlagged || cell.isQuestioned) continue; cell.isRevealed true; game.openedCount; if (cell.adjacentMines 0) { queue.push([nr, nc]); // 只有0号格才继续向外扩散 } } } } }注意queue.shift()在数组头部出队是 O(n) 操作对扫雷的格子数量级几百个以内无伤大雅。队列里入队的不是所有邻居而是「邻接雷数为 0 且尚未揭开」的格子因为数字格揭开后不需要再向外发散——这是泛洪区别于「把所有相邻格子都入队」的关键剪枝。这里用了 ES6 的解构语法const [r, c] queue.shift()和const [dr, dc] of dirs标准扫雷的遍历逻辑用这两句表达最直观。3.3 右键标记三态与 contextmenu 细节右键在扫雷里承担插旗功能但在网页里右键默认弹出浏览器菜单。监听方式是为每个格子绑定contextmenu事件并通过event.preventDefault()阻止菜单出现。扫雷的旗子状态是「无标记 → 旗 → 问号 → 无标记」三态循环而不是二选一因为问号态是给「不确定」保留的位置cellEl.addEventListener(contextmenu, (e) { e.preventDefault(); const cell board[row][col]; if (game.locked || cell.isRevealed) return; if (!cell.isFlagged !cell.isQuestioned) { cell.isFlagged true; // 无标记 - 旗 } else if (cell.isFlagged) { cell.isFlagged false; cell.isQuestioned true; // 旗 - 问号 } else { cell.isQuestioned false; // 问号 - 无标记 } renderCell(row, col); updateMineCounter(); // 左上角剩余雷数只随旗子数变化 });这里用isFlagged isQuestioned双布尔表达三态也可以用单一状态枚举 0/1/2渲染时 switch 即可。注意计数逻辑左上角的剩余雷数 总雷数 - 旗子数问号不计入。但这个计数器是纯参考它不影响胜负判定——真正决定胜利的条件是所有非雷格全部被翻开而不是旗子插得跟雷数一样多。老师问「怎么算赢」时如果答「插满旗就赢」实际上是一个常见的伪判定。事件绑定的实现方式有两种给每个格子直接传参或者用事件委托。前者在格子数量几百个时闭包变量占用不大可以接受如果做成 50x50 大棋盘建议改成在棋盘容器上监听 click/contextmenu再用closest(button)定位。事件委托的额外收益是将来做 chord 功能时只需多监听一个dblclick不需要重新遍历绑定几百个监听器。3.4 状态机字段锁定与解锁贯穿所有事件把game.locked、game.status、game.openedCount集中放进一个game对象而不是散落成多个全局变量。散落写法的典型问题是赢、输、重置三条路径各有独特的状态变更写漏一个就会出现「赢了但计时还在走」「重置后棋盘还有旧雷」的灵异现象。聚合之后所有事件函数入口先读game.status状态流转只发生在triggerWin()、triggerGameOver()、resetBoard()三个函数里。const game { status: idle, // idle | playing | won | lost locked: false, openedCount: 0, timerId: null, seconds: 0, flagCount: 0 };status与locked不是冗余status playing可以用来切换界面表情图标locked才是供事件函数快速拒绝点击的开关。重置时必须把两者都归位否则会出现「点击无响应但状态栏显示还在游戏里」的错位现象。这一层做好后面加暂停、加难度切换、加排行榜都是在状态机上加分支而不是改事件函数。到这里交互逻辑已经能完整跑起来左键揭示、右键插旗、0 号格自动泛洪、输赢有状态。下一章把剩下两块最容易被扣分的部分——计时与判定——按课设验收要求补上。4. 计时与胜负判定把「过得去」变成「问不倒」4.1 秒表首次点击启动用基准时间校正漂移课设最常见的秒表写法是setInterval(() seconds, 1000)。这个写法在浏览器标签页切到后台时会被节能策略降频回到前台会发现秒表比真实时间慢好几秒而且计时从页面加载就开始跑玩家盯着棋盘想半天也算进了用时。正确做法是记录一个基准时间戳显示时用Date.now()与基准的差值计算function startTimer() { const start Date.now(); game.timerId setInterval(() { game.seconds Math.floor((Date.now() - start) / 1000); timerEl.textContent game.seconds; }, 250); }间隔设 250ms 而不是 1000ms是为了让「切后台回来以后秒数更新」的延迟不超过 250ms。真实时间用Date.now()差值计算setInterval只负责周期性刷新界面这样浏览器降频也不怕下次触发时会直接按基准时间补齐。startTimer要放在第一次点击的expose()里并只在game.status idle时调用一次否则重复点击会开出多个 interval。game.timerId在重置和输赢判定时都要clearInterval。4.2 胜负判定胜利条件是「翻完」而不是「插完旗」再强调一次胜利的唯一条件是openedCount 总格数 - 雷数。失败条件则简单踩到雷。两个判定分别写在两个触发函数里比统一写一个checkEndCondition()更直观function triggerWin() { game.status won; game.locked true; clearInterval(game.timerId); flagAllMinesForStyle(); showModal(你赢了); } function triggerGameOver(hitRow, hitCol) { game.status lost; game.locked true; clearInterval(game.timerId); revealAllMines(hitRow, hitCol); showModal(踩雷了); }revealAllMines里的hitRow, hitCol用于把踩中的那颗雷标成红色底这是 UI 必做项否则玩家根本不知道刚才点的是哪颗。胜利后是否自动把所有雷标成旗子各家扫雷行为不一我习惯是标——让玩家一眼确认自己是靠排雷赢的而不是蒙的。建议把game.status的状态流转画成一张简表写进课设报告里状态含义进入方式idle等待第一次点击resetBoardplaying游戏中第一次 exposewon胜利triggerWinlost失败triggerGameOver状态只在三个函数里发生迁移事件监听函数不再直接改game.status。这样确保「棋盘锁定的瞬间所有事件一起失效」而不是靠每个按钮各自判断。4.3 界面同步剩余雷数与数字渲染的单向数据流界面上一共有三个「状态源」棋盘数据、计时器读数、剩余雷数。保持「先改数据、再调 render」的单向顺序不要在事件回调里直接写样式否则重置时容易出现「数据清空了但旗子图标还挂在格子上的」的残留问题。updateMineCounter的公式是mineTotal - game.flagCounty 值只随旗子数变化。有一个容易漏的界面 bug取消插旗后计数器数字回来了但格子样式没有从旗恢复成未翻开状态通常是renderCell忘了处理isFlagged和isQuestioned分支。渲染函数内部建议按优先级写死顺序已翻开数字或雷优先渲染其次旗再次问号最后未翻开底色。按照这个顺序检查每个格子的 DOM class渲染逻辑分支少且不容易互相覆盖。4.4 重置逻辑把所有状态一次归零reset 是最后写也最容易写坏的函数。清单里需要清棋盘数据、openedCount、seconds、timerId、每个格子的 DOM class、计时器文本、雷数文本、flagCount以及最容易漏的game.locked false——上一局如果输了triggerGameOver会把棋盘锁死重置按钮如果不解锁整个游戏就永远点不动了。function resetBoard() { clearInterval(game.timerId); board createBoard(rows, cols); game.status idle; game.locked false; game.openedCount 0; game.seconds 0; game.flagCount 0; placeMines(board, rows, cols, mineTotal, -1); // 首点保护等第一次点击时再定 computeAdjacentMines(board, rows, cols); renderBoard(board); }placeMines的safeIndex传 -1表示重置阶段不排除任何格子等玩家第一次点击时expose()里再根据实际点击位置决定要不要重排。另一种方案是「点开才布雷」reset 后先不布雷第一次点击时再调用placeMines并把首点及其周围 8 格放进跳过集合。这个方案代码分支多一点但能保证首圈绝对安全答辩时主动讲「为什么要延迟布雷」属于很加分的防御性设计。5. 从本地调试到 zip 交付双击 index.html 就能玩5.1 file:// 协议下别用 ES Modulezip 解压后最常见的打开方式是老师双击 index.html。如果 JS 用了script typemodule浏览器在本地直接打开时会因为跨域策略拒绝加载模块脚本控制台报错类似 Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type本质不是代码错误而是加载方式限制。需要交付双击版本时用普通script srcjs/mine.js按依赖顺序书写。开发阶段想用 ES Modules 组织代码可以用python -m http.server或 HBuilderX 的内置运行环境起本地服务交付前再切回普通脚本或者提前确认验收方式。5.2 zip 里放什么、不放什么内部目录保持简洁index.html、css/、js/、assets/、README.md、一份答辩演示截图目录。不要把node_modules、.git、.idea以及编辑器配置目录打进去解压后只会让老师觉得混乱。压缩打包使用标准的.zip格式游戏本体不需要设置密码解压即玩是交付的默认要求。README 写一屏以内包含运行方式、三档难度参数、操作方式和已实现的扩展功能。5.3 验收前自测清单交之前按这份清单逐条点一遍比临时加特效更能保住分检查项预期结果双击 index.html直接进入游戏控制台无报错三档难度切换棋盘与雷数刷新计时归零首次点击点到的格子和周围一圈都没有雷右键三态无 → 旗 → 问号 → 无剩余雷数只随旗变失败场景所有雷亮出踩中的雷红底棋盘锁死胜利场景翻开最后一块安全格的瞬间触发胜利后台切回计时器读数按真实时间补齐重置按钮旗、雷、计时器、锁定状态全部清空每过一条打一个勾。全部通过之后再在 Chrome 和 Edge 各跑一遍确认没有javascript运行时报错右键压缩成 zip 提交。本文还有配套的精品资源点击获取