小时候电脑课偷偷玩扫雷,大概是最早让很多人意识到“游戏开发挺有意思”的启蒙瞬间。这回我自己动手做了一个《扫雷扩展版》,不是把经典像素级复刻一遍,而是把扫雷的地基拆开,塞进更大的雷区、更灵活的规则,以及原版憋了半天也没敢想的新玩法。如果你也好奇一个看似简单的游戏背后是怎么设计的,或者想用一两个周末搞一个能拿得出手的完整小项目,这篇实操记录应该正合适。我会把从规则拆解、算法实现,到踩坑排查的完整过程都摊开讲,确保你看完能直接动手复现。
1. 扫雷扩展版到底在扩展什么
1.1 原版扫雷让人想扩展的三个痛点
先说项目动机。原版扫雷耐玩,但如果你认真玩过几千局,一定会碰到几个绕不开的坎。
第一,雷区规模被写死了。初级9x9、中级16x16、高级16x30,想手动调一个20x20的棋盘,官方根本不给你入口。第二,地雷密度没有自由度。每个难度对应一个固定雷数,想体验“满地图都是雷”的刺激感,只能靠第三方改版。第三,玩法是死胡同。游戏结束就是结束,没有连击、没有道具、没有变体规则,对今天的玩家来说,内容的厚度确实单薄了一些。
所以我做《扫雷扩展版》时定下的目标特别明确:保留扫雷最核心的“翻开推理”体验,同时把棋盘尺寸、雷区密度、胜负条件、附加玩法全部变成可配置的。我想要的不是一个扫雷模拟器,而是一个“扫雷游乐场”。
1.2 架构拆分:数据、规则、表现三件事不要揉在一起
项目开工前,我先把系统分成了三层,这个习惯帮我后面省了无数麻烦。
数据层负责棋盘本身:哪些格子有雷、哪些格子被翻开、数字是多少,全部用纯数据表示,不掺任何界面逻辑。规则层负责游戏行为:点击格子会触发什么、什么时候胜利、什么时候失败、如何展开空白区域。表现层则处理用户看到的东西:格子怎么画、动画怎么播、音效怎么响。
这样拆分后最大的好处是,换一种渲染方式不需要重写逻辑。我一开始用DOM格子做原型,后来改成Canvas绘制,核心代码几乎没动。后来加“迷雾模式”和“连锁爆炸”两种扩展玩法,也只是在规则层增加状态判断,表现层加了几张贴图的事。
1.3 为什么技术栈选择了Web而不是桌面开发
我也纠结过要不要做成桌面版。毕竟原版扫雷就是桌面程序,感觉上用原生开发更对味。
但实际分析下来,Web方案的性价比高太多了。第一,分发零成本。给别人玩的时候扔一个链接过去就行,对方不用装环境、不用处理依赖。第二,跨平台天然搞定,Windows、macOS、手机浏览器都能跑。第三,开发调试方便,浏览器里开个控制台就能查状态、断点调试,比桌面UI框架的调试体验顺畅不少。
具体技术栈我用的是原生JavaScript + HTML5 Canvas,没有引前端框架。扫雷的逻辑复杂度还没到需要引入框架来管理状态的程度,手写代码反而更清爽。Canvas负责渲染是因为扩展版支持超大型棋盘,100x100的格子如果用DOM节点渲染,光创建节点就够浏览器喝一壶的,Canvas的绘制效率要好得多。
2. 核心算法细节:安全开局与递归展开
2.1 首点安全机制是怎么实现的
扫雷有个不成文的规矩:第一次点击绝对不能踩雷。原版的处理方式比较粗暴,如果首点踩雷,就把这颗雷挪走,但首次点击周围区域是否干净并没有保证。
扩展版我选择做得更彻底:以首点为中心的3x3区域,一颗雷都不会有。实现方式也比我预想的简单,分两步走。
第一步,把首点周围的9个格子(边界处按实际范围收缩)划入“禁区”。第二步,在剩余格子中随机选取雷位。这样既保证首点安全,又不会出现“雷数量不够”的尴尬。
function generateMines(width, height, mineCount, safeX, safeY) { const total = width * height; const forbidden = new Set(); for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = safeX + dx; const ny = safeY + dy; if (nx >= 0 && nx < width && ny >= 0 && ny < height) { forbidden.add(ny * width + nx); } } } const candidates = []; for (let i = 0; i < total; i++) { if (!forbidden.has(i)) { candidates.push(i); } } // Fisher-Yates 洗牌,取前 mineCount 个作为雷位 for (let i = candidates.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; } return new Set(candidates.slice(0, mineCount)); }注意:如果雷密度设置得过高,比如超过50%,候选格子数量不够用,就会导致死循环或者雷数不足。我在配置面板里限制了雷密度上限为45%,并在生成前做了校验,低于这个阈值的配置才允许开始游戏。
2.2 数字计算的边界处理
布完雷之后,就要给每个非雷格子计算“周围8格有多少雷”的数字。这个环节本身不难,但边界判断是新手最容易翻车的地方。
我写了一个统一的边界判断函数,把所有坐标换算都收拢到一处,后面递归展开时也复用它。核心思路是:循环遍历格子周围8个方向时,先检查目标坐标是否落在棋盘范围内,越界直接跳过。
function getAdjacentMineCount(grid, x, y, width, height) { let count = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx; const ny = y + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; if (grid[ny][nx].mine) count++; } } return count; }这个函数我写了不下三遍。第一次用if套if,第二遍改成边界判断统一收口,最后一遍是把坐标偏移量抽成数组。后面每次遇到“周围格子”的遍历场景,直接复用这段逻辑,没有再算错过。
2.3 递归展开如何避免爆栈
扫雷最爽快的一刻,就是点到一块空地,“哗啦”一下翻开一大片。这个效果的学名叫洪泛填充(Flood Fill),从空白格出发,向四周扩散,遇到数字格就停下来。
最常见的实现方式是递归,代码短,读起来也好懂。但扩展版棋盘最大支持到120x80,如果雷密度又低,一次展开可能涉及上千个格子。当时代码在深空区域展开时直接报RangeError栈溢出,我查了下调用栈,递归深度已经好几千层。
解决办法很简单:把递归改成显式栈。用数组模拟一个待处理队列,循环压栈、弹栈,彻底绕开调用栈深度的限制。
function revealFrom(grid, startX, startY, width, height) { const stack = [{ x: startX, y: startY }]; const visited = new Set(); while (stack.length > 0) { const { x, y } = stack.pop(); const key = y * width + x; if (visited.has(key)) continue; visited.add(key); const cell = grid[y][x]; if (cell.revealed || cell.flagged || cell.mine) continue; cell.revealed = true; if (cell.adjacentCount === 0) { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx; const ny = y + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; stack.push({ x: nx, y: ny }); } } } } }注意visited集合不能省。不加visited时,同一格可能被周围四个邻居重复压栈,虽然能跑通,但大棋盘下会多出几万次无效遍历,肉眼可见地卡顿。
2.4 胜利判定不能只数点击数
原版扫雷的胜利条件是“翻开所有非雷格子”。我一开始在每次点击后统计剩余未翻开格子数量,等于雷数就判定胜利。小棋盘没问题,但大棋盘上某次洪泛可能一下翻开几百格,每次都去遍历整个棋盘算一遍剩余数量,性能很浪费。
改进方式是维护一个计数器:游戏开始时,把非雷格子总数记为remaining,每次成功翻开一个新格子就减一。remaining归零时,直接判定胜利。这个方案把胜利判断从O(n)降到了O(1),而且思路更贴近扫雷的规则本质。另外要注意的是,被标记为旗子的格子,在翻开判定时不受影响,只有真正揭开才减少计数。
3. 实操过程:从零搭建一个能跑的扩展版
3.1 棋盘的数据结构
整个项目的数据结构非常朴素,就是一个二维数组,每个元素是一个对象,保存格子的状态。
const CellState = { HIDDEN: 'hidden', REVEALED: 'revealed', FLAGGED: 'flagged' }; function createGrid(width, height) { const grid = []; for (let y = 0; y < height; y++) { const row = []; for (let x = 0; x < width; x++) { row.push({ mine: false, adjacentCount: 0, state: CellState.HIDDEN }); } grid.push(row); } return grid; }这里把“是否有雷”和“是否被翻开”拆成两个维度,比用一个state字段穷举所有组合更清晰。一个格子可以是“有雷+未翻开”,也可以是“无雷+已翻开”,自由组合不会被状态枚举限制住。后期加“迷雾”玩法时,我又给格子对象加了一个fog字段,不用动其他代码。
3.2 配置面板的参数校验
我做了支持自定义参数的配置面板,包括棋盘宽高、雷数量、是否开启迷雾模式、是否开启连锁爆炸四个开关。真正干活时我意识到,参数校验比参数本身更重要。
比如雷数量不能超过总格子数减去9,最低不能低于1。棋盘宽高必须是一个正整数,超过某个上限要提示性能风险。早期版本我图省事没做校验,结果有人输入了一个宽度为0的棋盘,页面直接白屏,排查了快一个小时才找出来。
3.3 交互逻辑:左键翻开、右键标记、双击快速展开
扫雷的交互有三类:左键翻开、右键标记/取消标记、双击(同时按左右键)快速展开周围格子。
左键翻开是主逻辑,处理步骤依次是:判断游戏状态,如果是待开始状态则生成安全雷区并切换到进行中;判断格子是否已翻开或已标记;如果是雷则触发失败流程;否则执行revealFrom展开。
右键标记的逻辑相对独立。已隐藏的格子标记为旗子,已标记的旗子取消标记回到隐藏状态。已翻开的格子不允许标记,这个判断要在事件监听里最先拦截。
双击快速展开是进阶功能。当点击已翻开的数字格时,如果周围旗子数量等于数字本身,就自动翻开周围所有未标记的格子。这个功能原版就有,但它的实现容易出错——直接复用revealFrom会导致误翻旗子格。正确做法是遍历周围格子时,单独过滤掉标记格。
function handleChord(grid, x, y, width, height) { const cell = grid[y][x]; if (cell.state !== CellState.REVEALED || cell.adjacentCount === 0) return; let flagCount = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx; const ny = y + dy; if (nx >= 0 && nx < width && ny >= 0 && ny < height) { if (grid[ny][nx].state === CellState.FLAGGED) flagCount++; } } } if (flagCount !== cell.adjacentCount) return; // 数字周围的旗子数够了,翻开未标记且未翻开的格子 for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const nx = x + dx; const ny = y + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; const neighbor = grid[ny][nx]; if (neighbor.state === CellState.HIDDEN) revealFrom(grid, nx, ny, width, height); } } }双击展开的安全性在于它只对“周围旗子数等于数字”的格子生效。旗子数不等于数字时,什么也不做。这是原版扫雷的规矩,我保留了它,避免玩家因为误点旗子标错而直接输掉。
3.4 游戏状态机:防止逻辑混乱的定海神针
扫雷看起来简单,但状态不少:未开始、进行中、胜利、失败。如果不用状态机,直接用布尔变量互相组合,到后期加功能时很容易出现“明明该赢却显示输”的诡异bug。
我只用了一个state字段搭配一个枚举,所有状态转换都在一个统一入口做:
const GameStatus = { READY: 'ready', PLAYING: 'playing', WIN: 'win', LOSE: 'lose' };状态转换规则固定为:READY下第一次点击左键进入PLAYING;PLAYING下踩雷进入LOSE;PLAYING下remaining归零进入WIN;LOSE和WIN是终态,不再响应任何点击操作。除了这四个流转,不允许其他任何路径。
3.5 渲染层:Canvas绘制格子的性能细节
渲染部分我用Canvas做了两种绘制方案:普通绘制和批量绘制。
普通绘制是逐格调用fillRect和strokeRect,适合中小棋盘,代码直观。批量绘制针对大棋盘,先把所有格子背景画到一个离屏Canvas上,只在棋盘内容变化时重绘这个离屏画布,鼠标悬停效果则单独画一层叠加。这样做的效果非常明显:120x80棋盘下,普通绘制每帧要刷新9600格,批量绘制只有鼠标悬停的那个格子需要重绘。
格子大小我也做了动态计算。根据容器宽度和棋盘列数,算出格子边长,再按比例设置棋盘总高度。早期我固定格子为24像素,结果在不同屏幕下要么太挤要么太散。后来改成自适应宽度,观感统一了不少。
4. 常见问题与排查技巧实录
实际开发过程中,我记录下了一批典型问题。这些问题百分之八十都是细节坑,但每一个都足以让程序表现诡异。整理成速查表,方便对着排查。
| 现象 | 原因 | 处理方案 |
|---|---|---|
| 第一次点击就爆炸 | 布雷前没有预留安全区 | 采用“禁区排除+洗牌”两步生成法 |
| 大棋盘翻开空白区时页面卡死 | 递归深度太高或重复遍历 | 用显式栈替代递归,增加visited集合 |
| 棋盘显示比例不对,格子变扁 | 格子尺寸使用固定像素 | 根据容器宽度动态计算格子边长 |
| 右键标旗后无法再左键翻开 | 事件顺序判断错误 | 先判断state再处理左键,标记格直接拦截 |
| 双击展开会把棋子格翻开 | 展开时没有过滤标记格 | 在遍历周围格子时跳过FLAGGED状态 |
| 胜利判定延迟或不触发 | 每次点击后全量扫描棋盘 | 维护remaining计数器,翻开时自减 |
| 雷密度超过50%时生成失败 | 雷数超过候选区容量 | 配置面板限制雷密度上限并提示 |
| 窗口尺寸改变后棋盘错位 | Canvas没有监听resize事件 | 监听window resize并触发重绘 |
4.1 最隐蔽的bug:双击展开误翻旗子格
上面表格里的第五个问题,是我调试最久的一个。我刚开始实现双击展开时,直接复用了revealFrom函数,以为它会自动跳过标记格。但实际跑起来发现,只要周围旗子数等于数字,右键标过旗子的格子也会被翻开,然后直接触发爆炸。
原因在于revealFrom里面判断的是“是否已翻开”和“是否有雷”,压根没有检查“是否被标记”。这是两个维度的状态,漏掉一个就会出错。修复方案是在展开前遍历周围格子时,对state === FLAGGED的格子直接continue。
这个教训让我给所有格子操作函数定了一条规矩:写上操作前先浏览一遍它依赖哪些状态,目标函数没有覆盖到的状态分支,调用之前自己把关。
4.2 踩雷动画与状态锁的顺序问题
踩雷后的表现顺序也踩过坑。我最初的设计是:点击到雷,立即切换LOSE状态,然后播放爆炸动画。但刷新后发现,胜利或失败后角色仍然能点击棋盘,棋盘还会响应交互,因为点击事件处理器没有直接判断状态。
正确的做法是:在点击事件入口第一行就检查gameState,如果已经是WIN或LOSE,什么都不做直接return。状态切换和事件拦截是两件事,状态切换管的是游戏内部逻辑,事件拦截管的是用户输入入口,两者缺一不可。
4.3 棋盘越大越需要“按需渲染”
很多人写Canvas游戏时习惯每帧全量重绘,小棋盘没问题,但扩展版最大棋盘是120x80,全量重绘的代价是明显的,尤其在低端设备上会感觉鼠标移动都带着延迟。
我改用“脏矩形”思路后流畅了很多:只有发生变化的那一格区域才重绘。具体实现是维护一个dirty集合,每次格子状态变动就把它的坐标加入集合,渲染循环只处理集合中的格子,处理完清空。这个优化让大棋盘下的交互跟手了很多。
5. 扩展版还能怎么玩:规则变体与玩法模块
扫雷扩展版做到这里已经解决了“玩不够”的问题。但项目交付给朋友玩了一圈后,我又加了不少“配件式”玩法。所谓配件式,就是每个玩法独立成模块,打开或关闭不影响核心逻辑。
5.1 迷雾模式:增加信息约束
迷雾模式开启后,未被翻开的格子不显示数字,甚至连格子纹理都隐藏掉,只剩一片漆黑。玩家只能依靠翻开后的反馈和记忆来推理。这个模式对空间记忆力是个不小的考验,也让游戏的策略层次深了一层。
实现起来其实简单:在渲染格子时判断当前是否开启迷雾模式,如果是且格子处于HIDDEN状态,就只画一层深色背景,不画任何标记。规则的改动只发生在表现层,数据层的逻辑完全不变。
5.2 连锁爆炸模式:一次错误全盘皆输
这个模式借鉴了“连锁反应”的玩法。关闭状态是普通扫雷的胜负逻辑,开启后踩中一颗雷,会像多米诺骨牌一样引爆周围格子的雷,然后层层扩散,直至触发一场席卷半个棋盘的大爆炸。
实现时我在规则层的踩雷分支里增加了一个循环:找到被引爆的雷坐标,将其周围8格中的其他雷也标记为引爆,重复直到没有新的雷被引爆。视觉表现就是多帧连环爆炸动画,配合震动效果和炸裂音效,观赏性拉满。这个模式对低雷密度棋盘来说比较友好,高密度下基本一炸毁所有,适合作为自虐挑战模式。
5.3 限时挑战模式:给推理加速度
限时模式是给追求刺激的玩家准备的。开局可选60秒、120秒、180秒三档,倒计时归零仍未清空棋盘则自动判负。这个模式对“双击展开”的使用效率要求很高,高手和新手之间的完成时间差能拉开好几倍。
计时逻辑我用的是requestAnimationFrame驱动的倒计时,每秒检查一次剩余时间。需要注意的坑是浏览器切后台时requestAnimationFrame会暂停,我补充了基于Date.now()的校准,切回来时时间不会偷偷溜走。
6. 项目总结与个人体会
如果让我给这个项目定个性,它不算一个体量很大的作品,但在开发过程中踩过的坑、做的选型、优化的细节,都很有代表性。写代码前先把规则想透,比写代码本身更重要;棋盘的二维数组看起来简单,但它和递归展开、状态判断、渲染策略全都耦合在一起,动一处就要全盘思考的教训,我体会得特别深刻。
有几个经验分享给想动手做同类型项目的人。
第一,先做数据层和规则层,把整个游戏逻辑跑通,再碰渲染和交互。我一开始边写逻辑边写界面,结果逻辑每次改动都牵扯到界面调整,效率低不说,还容易改出隐藏bug。
第二,配置面板和参数校验别偷懒。就算只是给自己玩,一个能自由调节的核心参数加上合理的校验,也能让游戏可玩性提升一个数量级。
第三,所有“感觉不对劲”的现象都值得认真对待。早期版本偶发首点踩雷,我当时觉得概率很低无所谓,直接跳过了,直到有朋友玩到后截图发过来,我才承认那是一个机制漏洞而不是运气问题。后来修复了生成逻辑,再也没有复现过。
最后分享一个小技巧:这个项目的扩展玩法模块我全部做成了配置项驱动,相关的渲染分支、逻辑分支都用统一的配置开关控制。想加新玩法时,只需要新增一个配置字段,然后在小范围内改动规则层,完全不碰核心逻辑。这种做法让项目到现在已经迭代了四个版本,每次加功能都控制在半小时以内。
如果你也想做一个属于自己的经典游戏改版,我建议从扫雷开始练手。它规则清晰、逻辑完备、边界情形丰富,是难得的学习素材。做完了给朋友玩一圈,看他们被连锁爆炸坑得嗷嗷叫的时候,你会觉得两个周末的投入特别值。