我做了这么多年开发,一直觉得扫雷是被严重低估的教学项目。别看它界面简单,真要较真做起来,里面的算法设计、交互细节和状态管理,足够让一个新手脱一层皮,也能让老手重新审视自己写代码的习惯。这个项目我前前后后做过不下三版,从最初的控制台黑白版,到后来带界面、带动画的完整版,每一次重构都有新收获。这篇就把我最新做的一版完整拆开揉碎讲清楚,从设计思路到每一行关键代码,再到我踩过的坑,一次讲完。
1. 项目整体设计与思路拆解
扫雷的规则人人都懂,但把这个规则翻译成程序逻辑,牵扯到的点比想象中多得多。核心要处理的就三件事:布雷、算数、翻格子。听起来简单,但布雷要考虑随机性和首次点击安全性,算数要处理边界条件,翻格子要处理级联展开和胜负判定。再加上计时、标记、长按数字快翻这些锦上添花的功能,一个完整的扫雷项目其实是很好的工程实践样本。
1.1 核心需求解析
我在动手之前,先把需求列了个清单,按优先级排好:
- 基础棋盘:支持自定义行列数和雷数,预设难度分级(初级9x9/10雷,中级16x16/40雷,高级16x30/99雷)
- 布雷系统:必须保证首次点击永远安全,这是扫雷不成文的规矩
- 雷区计算:每个非雷格显示周围8格雷的数量,边界格不能越界
- 翻格子机制:点到空格时自动展开相连的所有空格区域,这是扫雷最核心的交互体验
- 标记系统:右键插旗和问号标记,要支持状态循环,并且旗子数量要联动雷数显示
- 胜负判定:翻开所有非雷格即胜利,点到雷即失败,失败时要展示所有雷的位置,标错的旗要特殊显示
- 计时与计数:从首次点击开始计时,雷数计数器要实时联动已标旗数量
- 交互增强:双击数字格快翻周围格子(这个机制很多人不知道,是提升效率的关键)
1.2 技术选型与架构思路
这一版我选了前端三件套来做,HTML加CSS加原生JavaScript,不碰任何框架和库。选原生实现是有考量的,扫雷的复杂度用框架反而容易绕弯子,原生DOM操作在这个场景下性能完全够用,而且能让人把精力100%放在逻辑本身,不被框架的API带跑偏。
架构上我把代码分成三个模块:棋盘模型负责纯数据逻辑,包括布雷、计数、状态判断;渲染层负责DOM创建和样式更新;控制层负责事件绑定和游戏流程串联。数据用二维数组存,每个格子是一个对象,包含是否雷、周围雷数、是否翻开、是否标记这几个属性。
数据模型之前我踩过坑,最早用两个平行数组分别存雷和数字,写着写着就在索引对应上出bug了,后来统一成一个对象数组,字段内聚,排查问题轻松太多。这次一上来就定了这个方案,后面写代码确实顺很多。
1.3 这个项目为什么值得认真做
说句实话,扫雷这个项目能锻炼的能力非常全面。首先是基础算法能力,周围雷数计算要写双重循环还不越界,这是很典型的边界处理练习;其次是状态管理,一个格子有隐藏、翻开、插旗、问号几种状态,游戏有进行中、胜利、失败几种状态,理解状态机对以后做复杂前端项目帮助很大;第三是DOM操作的性能意识,小棋盘看不出问题,但如果你把棋盘开到50x50,渲染策略不同,卡顿程度差别就很明显了。
更重要的是,扫雷能让你理解“交互细节决定成败”。同样是翻格子,直接翻开和加一个流畅的展开动画,体验是两个档次。双击快翻这种功能,新手可能根本不知道存在,但老玩家一用就离不开了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 布雷算法的两种姿势与选型
布雷是扫雷逻辑的第一步,最容易犯的错是直接用Math.random()循环布雷,导致同一个位置重复布置,最后雷数不对。避免重复的最简单办法是用“排除法”:先给所有格子生成索引,然后从数组里随机“抽”出雷数个数的索引作为雷的位置,也就是Fisher-Yates洗牌算法的思路。
洗牌方法写起来也很简单,把格子索引放进数组,从末尾开始,每次随机挑一个和当前位置交换,循环雷数次,取前雷数位置布雷就行。这样天然不会有重复,而且洗牌过程完全随机,效率也高。
不过这里有个交互层面的问题,洗牌法虽然随机,但没有考虑首次点击安全。有些实现是点击后如果踩到雷,就把这块区域的雷重新洗掉,但更温和的方案是先把雷布好,等首次点击后检查,如果点到雷,把这个雷移到安全区域外。我采用的是另一种思路:不用洗牌法,而是先随机生成一个唯一的雷集合,首次点击时借用“点击点周围3x3区域不能有雷”的约束,如果点击点在雷集合范围内,重新生成雷集合直到满足条件。实测下来对体验影响不大,因为生成耗时极短。
2.2 数字计算的边界处理和局部变量陷阱
布雷完成后就要给每个非雷格子计算周围雷数。这里有个细节,很多初学者直接上来就对8个方向写八个if判断,容易漏方向和写错索引。我习惯用方向数组,定义const dirs = [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]],然后循环所有方向,把合法方向的雷数累加。这样代码干净,也不容易漏方向。
边界检查是关键。我封装了一个isInside(row, col)函数,判断索引是否在棋盘范围内,这样计算的时候只需要 if (isInside(nr, nc) && grid[nr][nc].isMine) count++,所有边界情况都统一收口处理,不会再出现某个方向越界导致报错的情况。
这里有个我当年实际遇到过的问题,就是方向数组和行列索引的顺序。很多人喜欢存成[x, y]形式,其中x是列索引、y是行索引,但渲染和遍历时用行优先,很容易在某个角落把行列搞反,导致雷数计算全错。解决的土办法是在写计算逻辑之前,先在纸上画一个3x3格子,标注每个位置的行列索引,再对着写方向数组,保证不出错。这算是我个人的土办法,但真的能避免低级错误。
2.3 级联展开的递归与广度优先选择
翻开一个格子时,如果它是空格(周围雷数为0),需要自动把周围相连的所有空格区域全部展开,直到碰到有数字的边界。这个功能我见过有人用深度优先递归写,代码看起来确实简洁:
javascript复制function reveal(row, col) {
const cell = grid[row][col];
if (cell.isRevealed || cell.isFlagged) return;
cell.isRevealed = true;
if (cell.adjacentMines === 0) {
for (const [dr, dc] of dirs) {
const nr = row + dr, nc = col + dc;
if (isInside(nr, nc)) reveal(nr, nc);
}
}
}
但要注意,这个递归在没有保护和棋盘很大的情况下,有可能导致调用栈过深,虽然标准扫雷棋盘不会触发,但如果是教学项目,为了严谨,我建议用广度优先的队列方式实现,用一个数组模拟队列,循环展开:
javascript复制function revealWithQueue(startRow, startCol) {
const queue = [[startRow, startCol]];
while (queue.length) {
const [row, col] = queue.shift();
const cell = grid[row][col];
if (cell.isRevealed || cell.isFlagged) continue;
cell.isRevealed = true;
if (cell.adjacentMines === 0) {
for (const [dr, dc] of dirs) {
const nr = row + dr, nc = col + dc;
if (isInside(nr, nc)) queue.push([nr, nc]);
}
}
}
}
我最终选择了队列版本,原因有两个:第一是避免递归栈溢出的隐患,第二是后续如果想做动画,可以分段处理队列,实现“逐层展开”的视觉效果。
2.4 标记系统与胜负判定的细节
右键标记有三种状态循环:未标记到插旗,插旗到问号,问号再回到未标记。这里有个交互设计的细节,问号标记在标准扫雷里是帮助玩家标记“不确定”的格子,但很多简化版扫雷直接省略了问号。我保留了问号,因为雷数计数器只统计插旗的数量,问号不算。这样玩家可以先用问号标记可疑格子,确认后再改成旗子,体验更合理。
胜负判定可以放在每次翻格子和插旗之后。胜利条件不是“没有插错旗”,而是“所有非雷格子都已翻开”。这个判定逻辑如果写成遍历棋盘检查cell.isRevealed和cell.isMine两个条件,有一个没翻开非雷格就不算胜利。失败判定则简单,翻开格子且cell.isMine为真,直接触发失败流程。
失败后的善后逻辑值得注意:要把所有雷的位置显示出来,尚未标记的雷显示红色图标,标错旗的格子显示叉号且背景变成红色,然后再弹出失败提示。这些细节是扫雷最基本的用户体验,少了这个,整个游戏会显得不够完整。
3. 实操过程与核心环节实现
3.1 静态结构与基础样式搭建
我先把页面的静态骨架写出来。顶部是信息栏,左边显示雷数,中间是笑脸重置按钮,右边显示时间;下面是棋盘的DOM容器。棋盘格子我直接用div网格,在渲染时按行列逐个创建,并通过CSS Grid控制行列布局。每个格子的大小用固定像素,换算起来方便。
样式方面,我参考了经典扫雷的3D凸起效果。未翻开的格子用亮色边框模拟突起,翻开后变为扁平凹陷感,数字用颜色区分。经典配色是这样的:1蓝色、2绿色、3红色、4深蓝色、5褐色、6青色、7黑色、8灰色。颜色这个小细节让老玩家一眼就觉得对味。
CSS用Grid做棋盘布局非常省事,核心就这几行:
css复制.board {
display: grid;
grid-template-columns: repeat(var(--cols), 32px);
grid-template-rows: repeat(var(--rows), 32px);
gap: 0;
border-top: 3px solid #7b7b7b;
border-left: 3px solid #7b7b7b;
}
格子本身用行内样式或类名控制状态,翻开的、插旗的、雷的位置用不同的背景和边框表现。
3.2 初始化流程与参数计算
初始化要接收三个参数:行数、列数、雷数。在构造函数里先做最基本的参数校验,雷数不能大于格子总数,这是起码的防御性编程。
初始化步骤分为四步:创建二维数组并填充空对象;生成雷位置标记雷;计算每个格子的周围雷数;渲染棋盘。这四步的顺序不能乱,尤其是先布雷再算雷数,如果顺序颠倒,会出现全部数字为0的诡异现象。
为了让第一次点击可以雷区转移,我把雷的生成延迟到了首次点击前。这里说一个小技巧,我用一个isFirstClick标志位记录,首次点击时调用placeMines(),该函数会避开点击点周围3x3区域。实现方法是先生成所有合法候选位置(排除点击点3x3范围),再从候选里随机选雷数位置,这样第一次点击永远安全,而且不用重复随机多次,效率高很多。
javascript复制function placeMines(safeRow, safeCol) {
const candidates = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (Math.abs(r - safeRow) <= 1 && Math.abs(c - safeCol) <= 1) continue;
candidates.push([r, c]);
}
}
// 从候选里随机选 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]];
}
for (let i = 0; i < mineCount; i++) {
const [r, c] = candidates[i];
grid[r][c].isMine = true;
}
calculateNumbers();
}
3.3 渲染策略:全量刷新与局部更新
渲染这块有两种做法:每次操作后全量重绘整个棋盘,或者只更新变化了的格子。小棋盘全量重绘性能完全没问题,但大棋盘就会有点闪烁感。我采用的是混合策略:首次初始化时全量创建DOM节点,后续操作只更新对应格子的CSS类名和文本内容,不做销毁重建。
这个策略的好处是显而易见的。每个格子是一个已经存在的DOM节点,翻转操作只需要改className,不会产生节点重建的开销。配合细粒度的类名控制,比如revealed、flagged、mine等,界面切换非常流畅。
javascript复制function updateCell(row, col) {
const cell = grid[row][col];
const el = cellElements[row][col];
if (cell.isRevealed) {
el.classList.add('revealed');
if (cell.isMine) {
el.textContent = '💣';
} else if (cell.adjacentMines > 0) {
el.textContent = cell.adjacentMines;
el.classList.add(`num-${cell.adjacentMines}`);
}
} else if (cell.isFlagged) {
el.textContent = '🚩';
} else {
el.textContent = '';
}
}
3.4 事件绑定与双击快翻的实现
事件处理上,我用了事件委托,在棋盘容器上只绑定一个click和一个contextmenu事件,通过event.target判断点击的是哪个格子。事件委托在这里特别适用,因为格子数量多,给每个格子单独绑两个事件,内存占用和绑定耗时都会增加,委托则不管棋盘多大都只需要两个监听器。
左键点击的逻辑:如果是首次点击,先布雷再继续;如果格子已翻开或插旗,直接忽略;如果是雷,触发失败;否则翻开并级联展开,判胜。
右键点击的逻辑:阻止默认菜单,然后循环切换标记状态,同时更新雷数计数器和格子的样式。
双击快翻这个功能,我是这样实现的:监听dblclick事件,或者通过判断点击间隔来模拟双击。逻辑是:如果一个格子已经翻开,且它的数字大于0,就计算它周围8格中已插旗的数量,如果等于这个数字,就自动翻开周围所有尚未标记和未翻开的格子。这个逻辑如果实现不当,会在插旗错误的情况下踩雷,但这是正常的扫雷规则,踩了才说明旗子插错了,悬念感也在这里。
3.5 计时与重置的细节
计时从首次点击翻开第一个格子开始,到胜利或失败时停止。实现上我用了setInterval每秒加1,显示在右上角。为了避免内存泄漏,每次重置时都要清掉旧的定时器再重新开始。
重置按钮的点击处理有一个容易踩的坑:直接重新初始化棋盘,但样式和事件委托还在,不会出问题。但要注意定时器必须清掉,不然重置后,上一个游戏的秒数还在往上跑,这两个定时器会打架,界面时间乱跳。
为了避免这些问题,我专门做了一个resetGame()函数,里面第一行就是clearInterval(timer),再执行初始化。逻辑顺序调到这个程度,基本不会出现计时异常了。
4. 常见问题与排查技巧实录
4.1 雷数不对或布雷位置重叠
这个问题的原因通常是布雷时用随机数重复设置同一个格子。排查方法是在布雷后写一个计数循环,遍历整个棋盘统计isMine == true的个数,和期望雷数对比,就能快速定位。解决办法就是前面提到的洗牌算法或候选位置数组法,从根源避免重复。
4.2 首次点击踩雷导致体验崩塌
如果没做安全布雷,玩家第一下就点到雷,直接游戏结束,这是扫雷最糟糕的体验。解决方法是延迟布雷,等首次点击后再布,并排除点击点周围3x3区域。我自己的经验是,如果你想做一个高难度模式,可以把这个安全区域从3x3缩减为只排除点击点本身,这样刺激度提高,但普通模式还是保留3x3为好。
4.3 级联展开导致栈溢出或卡死
递归写法在极端情况下确实会栈溢出,虽然标准扫雷棋盘的格子数不多,但如果你把行列数设置得很夸张,比如100x100,并且中间一大片都是空格,递归深度会非常深,浏览器就会报错。改用队列模拟的迭代写法后,这个问题就不存在了。
卡死则要检查级联展开的条件,常见错误是展开了已经插旗的格子,或者在判断adjacentMines === 0时误用了未定义的属性名。建议在关键函数里加临时的调试输出,打印每次翻开的格子坐标和当前队列长度,一眼就能看出是否在死循环。
4.4 右键菜单弹出干扰游戏
没有阻止contextmenu默认行为时,右键点击格子会弹出浏览器菜单,非常打断操作。解决办法是在棋盘容器的contextmenu事件上调用event.preventDefault(),而且事件绑定的对象要选对整个棋盘容器,不是单个格子。这个细节很小,但直接影响游戏手感。
4.5 数字颜色视觉混淆
如果你把数字颜色全都设置成一样的,用起来会非常痛苦。复刻经典配色是必须的,1到8每种数字配一个颜色,既尊重经典,也让玩家在快速扫视时能通过颜色预判危险性。这个细节也是很多山寨扫雷做得不地道的地方。
4.6 计时器重复累加问题
这个问题我在联调时真的遇到过,是重置时没清定时器导致的。在resetGame里清掉旧定时器还不够,要给计时函数增加一个标志位,确保在游戏结束后setInterval确实被清掉了。另外要注意,如果玩家在游戏结束后继续点击棋盘,也不能让计时器再次启动。
5. 体验打磨与扩展方向
基础功能做完之后,这个项目的打磨空间其实还有不少,我用晚些时候的碎片时间给它做了几个增量改进,每一个都让游戏质感提升了一档。
5.1 动画带来反馈感
格子翻开时加一个50到100毫秒的缩放动画,效果立刻不一样。在原本格子从凸起到凹陷的过程中,加一个过渡帧,手感会细腻很多。CSS实现就几行代码,但体验提升非常直观。
5.2 音效与震动反馈的取舍
音效这块我是做了一组模拟信号音,翻开、插旗、胜利、失败各有不同的音色。但考虑到有人会在办公室或图书馆玩,我在设置里加了一个静音开关,默认静音。这个设计很实用,如果做成一个正式产品,不打扰别人是基本礼仪。
5.3 自定义难度与存档
除了预设难度,我加了一个自定义面板,可以自由输入行列数和雷数,并且会提示当前难度对应的安全区域大小,简单直观。存档功能我用了浏览器本地存储,把棋盘数据序列化保存,刷新页面可以继续上次的对局。这样功能虽然小众,但实现起来并不复杂,纯粹是数据的序列化和反序列化。
5.4 剩余雷数的辅助计算
当玩家把所有雷正确标出后,面板上雷数会变成0,此时再翻任何格子若踩雷,就说明标错了。这就是扫雷的核心博弈逻辑。我还在界面左下角做了一个“剩余未标记雷数”的提示,在雷数归零但仍有未翻格子时显示特殊状态,帮助玩家判断是否已经完成扫雷。
5.5 移动端适配与多指手势
手机端的触摸事件和桌面端的鼠标事件行为差异很大,长按标记旗子、双击快翻这些操作需要额外的实现。我的做法是在触摸事件里用计时器模拟长按,延迟300毫秒触发右键标记,同时屏蔽系统手势。这个版本适配后,在地铁上用手机玩自定义大棋盘,体验也过得去。
6. 写在最后的个人心得
扫雷这个项目我做下来最大的体会是,很多人觉得它简单,但真正动手写的时候,边界情况多到让你怀疑人生。光是一个双击快翻功能,就要处理“旗子数量等于数字”“周围有未翻开的空格”“标记的旗恰好在雷上”这些组合情况,任何一个环节没想清楚,都会在运行时露出马脚。
我建议每个想练前端基本功的人,都自己独立写一遍扫雷。不要看现成的源码,就靠规则描述去推,卡住了再回头查。这个过程比刷十道算法题都长记性,因为它逼着你从交互反推数据结构,再从数据结构反推渲染逻辑,是一种系统性的思维训练。
最后分享一个小技巧:扫雷棋盘生成后的第一件事,是在渲染之前把所有格子遍历一遍,把相邻雷数先打印到控制台,这样可以边玩边对照,排查问题省一半时间。如果你在这个项目上卡住了,不妨试试这个方法,把棋盘数据可视化出来,问题往往瞬间就清楚了。
