
聊聊扫雷之前我得先承认一件事我以前一直觉得扫雷就是个用来打发时间的古董小游戏直到自己动手从零实现了一遍才发现这个看起来只有格子、数字和旗子的小东西内里涉及的东西比想象中多得多。棋盘建模、随机布雷、递归展开、边界条件、状态管理甚至还有概率决策和局面评估一个不落。所以这篇内容不打算停留在“怎么玩”的层面而是想站在开发者和深度玩家的角度把扫雷游戏真正拆开来看一遍。今天阅读全文大概需要10分钟读完你能理解扫雷背后的数据结构和算法逻辑如果你打算自己写一个扫雷这篇可以直接当参考方向。这个游戏值得被认真对待尤其是对做过一点开发、写过一点算法的人来说。扫雷是一个典型的“麻雀虽小五脏俱全”项目数据层要管棋盘和地雷逻辑层要管翻开和标记表现层要管刷新和计时稍微做深一点还会牵扯到随机性的质量、用户体验设计和局面难度的定量评估。可以说如果你能把扫雷完整写出来并且手感做得顺滑你就已经顺手练过了很多项目都会用到的核心能力。1. 扫雷的隐藏身份一个练习鼠标的程序为什么值得重新审视1.1 一个“教学工具”的诞生很多人不知道扫雷最初被放进操作系统并不是为了让用户“沉迷游戏”而是带着教学任务来的。1992年Windows 3.1把扫雷作为内置游戏提供给用户当时的开发团队在设计时就考虑到大量从命令行转向图形界面的新用户对鼠标操作并不熟悉。扫雷刚好是一个天然适合训练鼠标操作的场景左键点击翻开格子右键点击标记地雷左右键同时按下还能触发快捷展开。用户玩上几局单击、右键、拖拽这些基本动作就都练熟了比看说明书高效得多。这段历史放到今天看很有意思。一个游戏能被选中当教学工具说明它的操作逻辑足够简单、规则足够清晰但“操作简单”不代表“实现简单”。恰恰相反扫雷的规则虽然一句话就能说完但它的底层实现复杂度在一个小游戏里算是偏上的。所以扫雷在开发者圈子里一直是个被低估的练手项目地位其实不输给贪吃蛇和俄罗斯方块。1.2 为什么扫雷是绕不过去的练手项目拿“写一个扫雷”当练手项目好处太明显了。第一个好处是数据模型清晰。棋盘天然是一个二维矩阵格子状态、数字、地雷都可以直接用数组和枚举表达不涉及复杂的数据结构设计。第二个好处是算法覆盖全面。随机布雷需要随机算法展开空格涉及搜索算法计算周围地雷数量是典型的遍历计数胜负判定则考验状态管理能力。第三个好处是反馈极其直观。一个逻辑错误会直接表现为翻开异常、踩雷错判或边缘格子数字不对你几乎不需要额外调试工具就能定位问题。这也是我推荐有一定编程基础的人用扫雷来练手的原因。你不需要懂图形学不需要懂网络甚至不需要懂复杂的工程架构只要把逻辑拆清楚就能完整跑通一个项目从设计到落地的过程。而且扫雷的优化空间很大写完第一版能玩之后你还可以继续做动画、做音效、做难度曲线甚至做AI辅助每一步都能学到新东西。2. 棋盘建模与布雷算法数据结构决定游戏下限2.1 二维数组与格子状态设计扫雷的核心数据结构我建议直接用二维数组。每个格子需要记录两方面的信息一个是这个格子本身是什么是地雷还是数字还是空白另一个是这个格子当前处于什么状态未翻开、已翻开、被标记为旗帜。这两类信息如果混在一起存后面做翻开逻辑时会非常被动。更清晰的做法是拆成两个数组一个存“答案”一个存“表现”。答案数组里地雷格子记为 -1数字格子记录周围地雷数量 0 到 8表现数组里未翻开记 -2已翻开记 -1标记为旗帜记 -3标记为问号记 -4。当然用语言里的枚举或常量来代替这些魔法数字会更好维护。这种“逻辑数据”和“UI状态”分离的设计是很多游戏项目里通用的套路扫雷虽然小但也值得一开始就按这个思路来。除了必要的设计我体验过一个很常见的反例有人把所有信息塞进一个二维数组用正负号来区分是否翻开结果做右键标记功能时不得不反复改符号最后把状态机搞得乱七八糟。所以说数据结构的取舍直接决定了后面代码能走多远。2.2 首次点击保护与随机布雷的工程细节布雷算法看起来简单无非是在棋盘上随机选若干个格子放雷。但实际写起来有几个细节需要想清楚。最简单的做法是循环生成随机坐标如果该格已经有雷就重新生成。这个方案在棋盘小、雷数少的时候可行但到了高级难度16行30列、480格里放99颗雷随机碰撞会变多虽然也不至于慢但代码不够优雅。我用过更稳妥的方案是Fisher-Yates洗牌算法先生成一个包含所有格子坐标的数组然后从后往前洗牌取前N个元素作为雷区。这样一次遍历就能完成布雷时间复杂度是 O(格子总数)而且能保证每个格子都有均等的概率成为地雷不会有隐藏的取样偏差。比布雷更影响体验的是首次点击保护。如果不做任何保护玩家点下去第一下就踩雷或者翻开一个数字1游戏的“开局感”会非常差。主流扫雷基本上都有这个约定玩家第一次点击的那个格子以及它周围一圈9个格子都不会放雷。实现方式不复杂先记录首点坐标然后在生成雷的时候把首点周边9格从候选坐标集合里剔除再执行洗牌或抽样。我个人的建议是不只是首点本身它的周围一圈也应该保护起来。你想象一下如果只保护首点那么玩家点击后大概率会翻开一个1周围完全没展开体验其实很憋屈而把周围一圈都保护起来首点落下去之后周围至少有几个格子有被展开的可能性开局会顺滑很多。这个细节在标准扫雷里通常也是这么实现的。2.3 数字格的计算与边界处理的两种姿势布雷完成之后下一步是计算每个非地雷格上的数字。说白了就是遍历所有地雷对地雷周围8个格子的数字分别加1。这里最需要注意的就是边界棋盘边缘的格子并没有8个邻居如果不去判断数组下标直接越界程序当场崩掉或者更可怕的是悄悄读到了别的内存区域产生诡异的结果。处理边界问题有两种常见姿势。第一种是暴力判断在遍历邻居之前先检查目标坐标是否在棋盘范围内越界就跳过。第二种是在真实棋盘外面再包一圈把外圈格子统一标记为地雷或者特殊墙这样所有内部格子都能无脑遍历8个邻居不需要任何判断。两种方案都对但从可读性看我更喜欢第一种。const directions [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; function computeNumbers(rows, cols, mineMap) { const numbers Array.from({ length: rows }, () Array(cols).fill(0)); for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (!mineMap[r][c]) continue; for (const [dr, dc] of directions) { const nr r dr; const nc c dc; if (nr 0 nr rows nc 0 nc cols) { numbers[nr][nc]; } } } } return numbers; }这段代码的思路是遍历所有地雷让每个地雷向周围8个邻居“广播”一次邻居的数字加1。复杂度是 O(雷数 × 8)对于任何扫雷棋盘都能忽略不计。需要注意的是数字格的计数是在全部布雷完成之后一次性算好的不要在每次点击时动态计算那样纯属浪费性能。2.4 三种经典难度的参数设计说到难度扫雷最经典的参数配置几乎已经成了事实标准初级 9×9、10颗雷中级 16×16、40颗雷高级 16×30、99颗雷。这里的密度其实很有讲究不是随便拍的。难度棋盘尺寸地雷数雷密度初级9×9 (81格)10约12.3%中级16×16 (256格)40约15.6%高级16×30 (480格)99约20.6%从表格能看出难度越高雷密度其实是越大的。高级棋盘480格里有99颗雷意味着平均每5格就有1颗雷这直接导致了后期经常出现“剩下两格一颗雷”的猜雷局面。很多玩家觉得高级难并不仅仅因为格子多更关键的其实是雷密度上升后无解局面的比例变大了。这个参数设计思路在你自己做难度分级的时候也可以参考想提升难度光扩大棋盘不够雷密度必须跟着上去否则局面会变得“大而空”反而不如小棋盘刺激。所以如果自定义难度我建议按这个思路来算雷数先定棋盘面积再乘以一个目标密度最后取整。比如我做过一个 20×20 的棋盘想要略低于高级的难度密度取 18%雷数就是 400×0.1872 颗实战下来确实比高级稍微温和一点。3. 点击展开的洪泛逻辑递归、栈和边界条件的工程细节3.1 为什么点一个 0 能展开一大片玩扫雷的人都知道点到数字0也就是空白格时游戏会自动把它周围一圈格子全部翻开翻出来的新格子如果还是0就继续向外扩散最终形成一大片被展开的区域。这个过程用算法术语讲叫洪泛填充Flood Fill和画图工具里的油漆桶本质上是一个东西。理解这个机制的关键在于0 的意思是“周围8格没有地雷”所以这8个格子全是安全的可以全部翻开而那些被翻开的格子里如果有一个也是0说明它的周围8格也全部安全于是继续展开直到碰见数字大于0的格子才停下来因为它周围的格子不一定安全需要玩家自己判断。3.2 递归、显式栈与展开顺序的取舍实现洪泛填充最直觉的写法是递归。一个函数先处理当前格子再对周围的0格调用自身几行代码就搞定。扫雷棋盘最大的高级难度 16×30 也才480个格子递归深度理论上不会超过棋盘格子总数对现代运行时来说根本不会爆栈。所以如果你只是写来自己玩递归完全够用。不过我实际写的时候更喜欢用显式栈。原因不是怕爆栈而是显式栈让我能清楚控制格子的处理顺序也方便在后面扩展功能。比如想做“展开动画”用栈就能控制每次弹出一个格子进行处理而递归反而不好插桩。另外显式栈的可调试性也更好随时可以打印栈里的内容看看到底哪些格子还在等着处理。function floodReveal(state, startRow, startCol) { const { rows, cols, numbers, revealed } state; const stack [[startRow, startCol]]; while (stack.length 0) { const [r, c] stack.pop(); if (r 0 || r rows || c 0 || c cols) continue; if (revealed[r][c]) continue; if (numbers[r][c] -1) continue; // 地雷不能通过空白展开 revealed[r][c] true; // 当前格子数字不为0说明周围有雷不继续展开 if (numbers[r][c] ! 0) continue; for (const [dr, dc] of directions) { stack.push([r dr, c dc]); } } }这个写法里的关键判断顺序很有讲究先判断越界再判断是否已经被翻开再判断是否是地雷。顺序反了可能把地雷也翻出来或者数组越界。我调试时踩过这种坑之前把“是否地雷”的判断放到了最前面结果在处理空白区边缘时地雷格子还没被翻开程序就直接崩了。其实是因为栈里压进去邻接地雷的格子时会先检查越界才检查雷但这个顺序容易搞混最好就是按上面代码里的顺序来。3.3 边界条件与交互陷阱被标记的格子到底能不能翻开展开逻辑里还有一个容易忽略的细节被标记为旗帜的格子不应该被翻开。我在做第一版的时候没有在展开逻辑里判断旗子状态结果玩家点开一个空白格周围的旗子也被一起翻开了。后来才意识到旗帜是玩家对“此地有雷”的标注除非玩家自己手动取消或踩雷否则任何自动展开都不应该动它。在判断顺序上我建议把“是否被标记”放在“是否已翻开”之后、其他判断之前。原因很简单一个格子如果是旗帜它通常没有被翻开所以会通过“未翻开”的检查如果这时候不去拦截就会被洪水填充当成可展开格子处理掉。加了标记判断后旗帜格子会被安全跳过玩家的标注不会莫名其妙地消失。另外还要考虑一种特殊情况如果玩家在某个格子点了旗帜后来发现标错了去点开它此时应该怎么做标准扫雷的惯例是旗帜状态下单击左键通常不会直接翻格子而是要么弹出确认要么直接被鼠标事件忽略。我在自己的版本里采用了简单策略旗帜格子上的左键点击被忽略玩家必须先用右键把旗帜去掉才能翻开。这样避免了很多误触连累的局面。4. 手感藏在细节里标记、双击联动、游戏状态与计时器实现4.1 右键三态标记与剩余雷数的计算扫雷的右键标记在早期Windows版本里是“三态循环”的未标记 → 旗帜 → 问号 → 未标记。旗帜表示“我确定这里就有一颗雷”问号则表示“这里有可能是雷但我不确定”。后来的新版扫雷基本把问号去掉了因为如果游戏本身不把问号当成一种有效标记它在算法上没有任何作用反而会让玩家犹豫。但在自己实现的时候保留三态循环其实很好玩也适合教学演示。只要在右键事件里把格子状态依次切换即可。更重要的是剩余雷数的显示值不能单独存一个“剩余雷数”变量去增减因为玩家可能会标错、取消、再标记很容易出现“剩余雷数变成负数”这种尴尬情况。正确的算法是const remainingMines totalMines - flagCount;也就是说剩余雷数永远等于“总雷数”减去“当前旗帜数”。旗帜数每次变化时重新计算或维护一个变量即可。这样即使玩家标了5个旗子而实际只有3颗雷剩余数字也只会变成负数用来提示“你多标了”而不会出现状态不同步的问题。我个人测试下来这种计算方式在各种乱标情况下都不会出错。4.2 双击联动安全展开的正确姿势扫雷的进阶操作是双击展开英文里一般叫 Chord。玩法是当一个数字格的周围已经插上了足够数量的旗帜时比如格子显示3周围也正好插了3个旗帜这时候在数字格上双击或者同时按左右键游戏会一次性翻开这个数字格周围所有未标记且未翻开的格子。这个操作有个前提如果周围的旗帜标错了双击展开会直接触发藏在某个格子里的地雷玩家瞬间就输了。所以它既是高效操作也是“标旗是否正确”的终极检测。实现逻辑也很简单判断之前先数周围旗帜数量和数字相等才执行展开否则就忽略这次双击。很多新手不知道这个功能但高级玩家几乎全程在用因为单靠一个一个左键点永远快不起来。在实现双击的时候还要注意一个细节很多鼠标会同时触发双击和两次单击事件如果不做处理玩家双击一个数字格时先触发了第一次单击翻开格子第二次单击又翻一个结果看到的展开效果会乱掉。我的处理办法是在双击事件里加一个标志位这次点击只执行 Chord 逻辑不再执行单独的翻开逻辑避免“双击变翻两格”的奇怪手感。4.3 游戏状态机、计时器与胜负判定扫雷虽然玩法简单但它的运行状态还是需要一个明确的状态机来管理。我把状态分成四种等待开始、进行中、胜利、失败。等待开始指的是棋盘已经生成但玩家还没有做过任何操作进行中则是第一次点击发生后胜利和失败都是终结态此时棋盘不再响应点击。状态机的好处是能一次性解决很多边界问题。比如计时器扫雷的计时不是从进入游戏就开始的而是从玩家第一次点击棋盘开始。这个设计很合理——玩家可能几十秒都在观察局面思考先点哪里这部分时间不该记入用时时长。实现时只要在第一次左键点击的响应函数里判断当前状态是“等待开始”就把状态切到“进行中”并启动计时器同时把首点坐标传给布雷逻辑执行首次点击保护。胜负判定也有一个容易搞错的地方。很多新手以为“把所有地雷都标上旗帜”就算赢实际上标准扫雷的胜利条件并不是标记完所有雷而是翻开所有非地雷格子。也就是说即使你一个旗子都没标只要把所有安全格全点开了同样算胜利。这个差异在实现时会直接改变判定逻辑每次成功翻开一个非雷格就计数加一当翻开数等于“总格数减去雷数”时判定胜利。用这个逻辑实现比“每插一个旗子就比对是否对应地雷”要稳健得多。5. 进阶玩家的工具思维概率决策、3BV与高效操作5.1 不存在“纯逻辑通关”这回事概率决策怎么下很多科普文章把扫雷描述成“纯逻辑推理游戏”这个说法我只同意一半。扫雷确实有大量要靠逻辑推理的局面比如“一个数字2旁边有两个格子其中必有一雷”这样的约束推理但当你玩到中后期尤其是高级难度一定会遇到没有任何逻辑线索的局面。最典型的就是地图最后剩下两块相邻格子只有一颗雷而这两块格子的周围信息已经完全对称没有任何一条逻辑能告诉你雷到底在左边还是右边。这时候唯一的办法就是猜也就是概率决策。既然猜是不可避免的那怎么猜才能提高胜率我的经验是永远选择“被更多数字约束”的格子。举个例子如果A格子周围关联了三个数字格而B格子只关联了一个数字格那么A格子的雷概率通常会被多个约束加权反而更容易算出哪个更安全。还有一条实战经验在毫无头绪的时候优先点角格或边格因为角落能提供的信息少但它的展开概率在经典规则下反而有微小优势不过这个优势小到可以忽略更多是心理安慰。真正有用的策略是延迟猜雷。当你发现棋盘上存在一个必须猜的局部区域时不要急着去点先把其他所有能靠逻辑确定的安全区域全部处理完把棋盘缩小到最后那一小块猜雷区域再抱着“这局可能直接结束”的心态去点。因为提前猜雷一旦猜错整局直接结束你前面所有安全展开的努力都白费了。5.2 3BV一盘棋到底“值多少“3BV全称 Bechtels Board Benchmark Value是扫雷社区用来评估一盘棋“最少需要多少次左键点击才能通关”的指标。这个指标的厉害之处在于它抛开玩家手速和策略差异只看棋盘本身的结构复杂度。怎么理解3BV它统计的是两样东西加起来的总量一是所有由空白格子连成的区域每个区域算1次点击二是不在任何空白区域里、周围也没有空白格子的孤立数字格每个数字格单独算1次点击。而那些和空白区域相邻的数字格会被空白区域的展开自动翻开不需要额外点击所以不计数。用大白话说3BV就是“如果你每一步都点得完美最少要点多少下”。一盘高级棋局的3BV通常在70到130之间浮动。3BV在70左右属于“福利局”空白区域大连锁展开多很容易刷新个人纪录而3BV超过110以上意味着整盘棋几乎全是孤立的数字格每格都得单独处理即使你逻辑全对也非常耗时。我自己在玩的时候就喜欢先看一眼3BV再决定是否认真打如果数值太高心态反而放松了因为知道这盘不太可能出好成绩。顺便说一个3BV的实际用处它能用来度量你自己的操作效率。把整盘游戏的实际点击次数除以3BV得到的比值越接近1说明你的无效操作越少。超过2是什么意思呢说明你标了很多旗子或者反复右键取消做了大量没有必要的事情。这个比值在扫雷社区叫作 RQP虽然现在计算工具很多但自己写代码计算3BV也是一个很好的算法练习题。5.3 想提速先把右键和双击练成肌肉记忆写完了扫雷我也顺便研究了下怎么才能把它玩快。很多人觉得扫雷快不快取决于反应速度其实不对。在高分段大家拼的更多是操作的“经济性”。第一个原则是减少右键的使用频率。很多中等水平的玩家喜欢先把所有推出来的雷都标上旗帜再逐个点数字格。但这在高手眼里其实是浪费操作旗帜本身只是辅助记忆并不能帮你更快地翻开安全格。相反插旗帜和去掉旗帜都占用时间。高手通常只在两种情况下标旗一种是当前数字格的周围已经没有空格可翻必须靠双击展开这时给足够数量的格子标旗来“激活”双击另一种是局面太复杂需要用旗子帮助大脑记忆和推理。其余时候他们更愿意直接点开安全格。第二个原则是大量使用双击展开。双击的妙处在于它一次操作能翻开很多格子而且不需要精确地逐个点击。每当你确认一个数字格周围的雷都标对了顺手一个双击周围的格子哗啦全开这种操作效率远不是单击能比的。所以节奏应该是先用左键点开安全格推理出雷的位置标旗然后用双击把这块区域扫干净再进入下一片区域。第三个原则也是我个人玩下来最有体会的控制点击节奏。扫雷不是越快越好因为手速快但判断错反而直接输掉。高手的特点是“快而不乱”每次点击之前大脑已经在处理下一步的信息。按我自己的体会连续高速点击超过几十下之后出错率会直线上升所以在关键局面稍微放慢让推理先跟上比盲目追求手速更划算。做完整个扫雷项目之后我自己最大的收获反倒不是“写了一个游戏”而是重新理解了“拆解”这个词。一个看起来只有九宫格和数字的小游戏当你把它拆成数据结构、随机算法、展开逻辑、状态管理、难度评价这五层之后每一步都有各自清晰的边界和优化空间。如果你也想挑战一下我的建议是不要直接抄一份代码先在一个纯命令行环境里把逻辑写通把翻开、标记、胜负判定跑顺了再考虑加界面。等到你需要处理格子动画、鼠标手势、难度评估这些“锦上添花”的功能时你会发现之前每一步结构化的工作都在帮你省时间。