说实话,很多学Java图形界面的同学一开始接触的就是Swing,教程里的练习项目通常是登录界面、图书管理、学生信息CRUD。这类项目做下来,对JButton、JTextField这些基础组件倒是熟了,但Swing真正的几个关键点——事件驱动模型、组件状态管理、鼠标左右键区分、递归扩散逻辑,反而一点都没碰到。我自己的体会是,想把这些点一次性练到位,扫雷游戏绝对是被低估的练手项目。
扫雷虽然规则简单,但代码实现里涉及的东西一点都不少:一个随时可能被点击的二维格子矩阵、不同状态之间的切换、第一次点击必须避雷、翻开空格时要递归扩展一片区域、右键插旗要能计数,还要配一个计时器控制游戏节奏。这一整套逻辑放到一个用Java Swing写的桌面程序里,刚好能把界面布局、事件监听、算法设计全串联起来。这篇文章就按我实际开发的顺序,把每一步怎么想、怎么写、哪些地方容易踩坑,从头到尾讲清楚。
1. 先想清楚“扫雷规则”在代码里怎么落地
1.1 需求拆解:扫雷游戏的三大核心
在动手敲代码之前,一定要先把规则翻译成程序逻辑。扫雷表面上看是点格子,往后拆解其实只有三件核心事情:第一次点击的安全区、翻开格子时的数字提示、右键插旗子排雷。
从玩家视角看,扫雷玩的是一个“推理”过程:每个格子要么是地雷,要么是普通格,普通格上的数字表示周围八格有多少颗雷。玩家如果判断某个格子是雷,就右键插旗;如果判断没有雷,就左键翻开。如果把雷翻开,游戏直接结束;如果翻开的格子周围没有雷,系统会以这个格子为中心向外扩散,直到遇到有数字的区域才停止。
对应到代码里,这三件事会变成三组模块:一是地雷生成模块,负责在格子矩阵里随机放雷;二是数字计算模块,负责给每个格子算周围雷数;三是交互模块,负责响应鼠标点击、管理格子状态、判断胜负。我建议先把这三块想清楚,再回去写UI,否则布局做完之后回来补逻辑,代码会乱成一团。
1.2 数据模型先行:二维数组与格子状态
Swing界面里的扫雷盘面看上去是一个个按钮,但程序内部不应该直接拿JButton去存储游戏数据。正确的做法是:用二维数组保存格子数据,JButton只负责显示对应内容,这样逻辑和界面之间就能互相独立。
每个格子本身有五个关键信息:是否是地雷、周围雷数、当前显示状态、按钮引用、在网格中的坐标。用一个二维数组存状态,再用另一个二维boolean数组存地雷位置,是最容易理解的结构。实际写下来大概是这样的逻辑:
- mines[][]:boolean数组,true表示这个位置有雷。
- numbers[][]:int数组,记录每个格子周围八格的地雷总数,无雷格可能是0到8。
- states[][]:int数组,记录格子的显示状态,比如未翻开、已翻开、已插旗。
格子状态为什么一定要单独用一层数组?因为界面上的“样子”只是状态的一种映射。插旗和翻开是两个完全不同的动作,如果直接改按钮文字或图标而不记录状态,后面做扩散计算时,程序根本不知道哪些格子已经被翻开过了。
1.3 逻辑状态与UI状态的分离
我见过不少初学者把state数组和按钮文字一一绑定,比如按钮文字是"F"就代表插旗,为空就代表翻开了。这样写平时还能跑,但一旦涉及到“已翻开”和“未翻开”的区分,就会出现问题:一个格子如果周围数字为0,翻开后显示的是空白;有数字的格子显示数字;插旗后显示旗帜。这些显示内容都是由state派生出来的,只有通过state数组才能统一判断。
所以在我的设计里,state数组只有三种取值:COVERED、REVEALED、FLAGGED。执行任何操作之前,第一步永远是判断当前state值是否允许这个操作。比如:
- 左键点开:只允许点击COVERED状态的格子。
- 右键插旗:允许在COVERED和FLAGGED之间来回切换。
- 游戏结束后翻开所有雷:这时COVERED且是雷的格子变成REVEALED,显示雷图标。
界面更新永远由state数组驱动,这个习惯一旦养成,后面写递归展开和胜负判断都会顺很多。
2. 界面骨架先搭起来:JButton、GridLayout与面板层级
2.1 为什么用JButton而不是自绘组件
关于扫雷界面的实现方式,网上方案有两种:一种是用JButton铺满网格,另一种是继承JComponent或JPanel自己绘制每个格子。
如果想做一个像素级还原经典扫雷外观的作品,自绘方案确实更灵活,因为所有雷、数字、旗子都是用Graphics画出来的,窗口缩放时可以随意控制每个格子大小。但自绘图有个明显的门槛:鼠标点击时要自己去算点击位置落在哪个格子,还要处理重绘、双缓冲、刷新机制,代码量会大不少。
用JButton方案则简单得多。JButton本身就是一个个独立的点击区域,天然自带鼠标事件和文字图标显示能力,每个按钮直接对应一个格子,坐标概念几乎不需要额外计算。对于现阶段的目标——把扫雷逻辑完整地做出来——JButton是我推荐的方案。更重要的是,Swing的事件监听模型非常适合这种网格式布局,一个按钮的点击事件,里面只用带上行和列的坐标,就能完成对应的逻辑调用。
2.2 面板层级:JFrame、JPanel、JButton的关系
Swing程序的窗口层级通常是这样的:JFrame是主窗口,它上面放一个顶层容器;顶层容器里再放多个子面板;子面板里再放具体控件。扫雷游戏我建议分三个面板:
- 顶部信息面板:放笑脸重置按钮、剩余雷数标签、计时器标签。
- 雷区面板:用GridLayout管理所有雷格按钮。
- 主面板:用BorderLayout将顶部面板放在NORTH,雷区面板放在CENTER。
这种分层的价值在于,顶部信息和雷区网格是两种不同的布局需求。顶部信息只需要排成一行,雷区却要求行列严格对齐,混在一起用同一个布局管理器会非常别扭。
初始代码大致长这样:
JFrame frame = new JFrame("Java Swing 扫雷"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); JPanel topPanel = new JPanel(new FlowLayout()); mineLabel = new JLabel("剩余雷数: 10"); timeLabel = new JLabel("用时: 0"); resetButton = new JButton("重新开始"); topPanel.add(mineLabel); topPanel.add(resetButton); topPanel.add(timeLabel); gamePanel = new JPanel(new GridLayout(ROWS, COLS, 1, 1)); for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { JButton btn = new JButton(); btn.setMargin(new Insets(0, 0, 0, 0)); btn.setFocusPainted(false); btn.addMouseListener(gridMouseAdapter(r, c)); gamePanel.add(btn); buttons[r][c] = btn; } } frame.add(topPanel, BorderLayout.NORTH); frame.add(gamePanel, BorderLayout.CENTER); frame.pack(); frame.setVisible(true);2.3 布局管理器选择:GridLayout的局限与应对
GridLayout是雷区面板最直接的选择,因为它的格子是等宽等高的,行列数量可以直接用构造函数指定,和二维数组的索引天然对齐。但我实际用下来还是踩了几个坑,必须提前说清楚。
第一个坑是按钮空隙问题。GridLayout的构造函数可以传水平和垂直间隙,但Swing按钮之间默认还有自己的边框和外边距。如果间隙设成0,按钮之间依然有一条细缝,看上去不够紧凑。我习惯把button的border清掉,再把GridLayout的间隙设成1,这样网格线才有经典扫雷那种分割感,又不会像纯自绘那样增加额外工作量。
第二个坑是窗口尺寸。JButton多的时候,如果JFrame设置了固定的首选尺寸,9行9列的雷区和16行16列的雷区高度会差很多。最好在全部按钮添加完毕后调用frame.pack(),让窗口根据面板内容自动计算合适大小。另外,网格跨度大的时候,比如30行30列,单个按钮会被压得很扁,这时可以在按钮上使用setPreferredSize固定每个按钮的最小尺寸,保证点击区域不至于小到难以操作。
第三个坑是JButton默认UI带来的额外边框。Swing按钮在Windows和Mac上的观感不一样,简单处理是统一使用setContentAreaFilled(false)、setOpaque(true)配合自定义背景色,或者直接通过UIManager设置全局按钮样式。这个属于观感层面的优化,逻辑不依赖它,但建议初学者一开始就处理掉,否则后面换主题时界面会非常难看。
3. 布雷与雷数计算:随机性、边界与“首击必安”
3.1 布雷算法:随机数生成地雷位置
布雷的逻辑一句话就能说清:在ROWS行COLS列的格子中,随机抽出指定数量的格子作为地雷。但实际处理时需要注意两个问题:一是地雷位置不能重复,二是第一次点击的格子以及它周围一圈都不能有雷。
最朴素的实现是“洗牌法”:先创建一个数组,按照地雷总数放对应数量的雷标记,其余放空标记,然后用随机数交换数组元素,洗完后按顺序填入矩阵。这种方法能保证雷的总数完全可控,而且每条雷概率均等。
我用这种方法写出来大概是这样:
private void placeMines(int mineCount, int safeRow, int safeCol) { int totalCells = ROWS * COLS; boolean[] tempMines = new boolean[totalCells]; for (int i = 0; i < mineCount; i++) { tempMines[i] = true; } Random random = new Random(); for (int i = totalCells - 1; i > 0; i--) { int j = random.nextInt(i + 1); boolean tmp = tempMines[i]; tempMines[i] = tempMines[j]; tempMines[j] = tmp; } for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (isSafeZone(r, c, safeRow, safeCol)) { tempMines[r * COLS + c] = false; } mines[r][c] = tempMines[r * COLS + c]; } } minesPlaced = 0; for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (mines[r][c]) minesPlaced++; } } if (minesPlaced < mineCount) { placeMines(mineCount, safeRow, safeCol); } }这个方案的缺点在于,当安全区占掉一部分地雷后,实际雷数会少于配置值,所以我加了一个递归调用重新摆放。严格来说更优的做法是:在洗牌完成后,如果发现安全区里有雷,就把这些雷和被标记为空但不在安全区的格子交换,这样就不用递归了。我下面给出的代码是递归版本,优点是简单直观,缺点是多次递归会影响启动速度——不过在9x9或16x16这种规模下完全感知不出来。
3.2 八邻域雷数统计:边界判断
雷摆完了,下一步是给每个非雷格子计算周围8个格子里有多少颗雷。这一步的关键在于处理边界,边角格子没有完整的8个邻居,所以在遍历时要做越界判断。
一个常见的做法是让索引从1到ROWS(或COLS)循环,然后把数组整个扩大一圈,在边缘留一圈“虚拟边界”,这样就不用每次判断坐标是否越界了。我早期练习时用过这种方法,它确实能减少重复条件判断,但在扫雷里我反而更推荐直接判断,因为按钮数组和state数组都是固定大小,多一层虚拟边界会让坐标映射显得混乱。
直接判断的写法也不复杂:
private static int[] DR = {-1, -1, -1, 0, 0, 1, 1, 1}; private static int[] DC = {-1, 0, 1, -1, 1, -1, 0, 1}; private void calculateNumbers() { for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (mines[r][c]) continue; int count = 0; for (int i = 0; i < 8; i++) { int nr = r + DR[i]; int nc = c + DC[i]; if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && mines[nr][nc]) { count++; } } numbers[r][c] = count; } } }把方向增量抽成两个常量数组,好处是后面递归展开时可以直接复用,不需要再写8个if。
3.3 第一次点击不踩雷的处理策略
经典扫雷有一条铁律:玩家第一次点击绝对不能踩雷。实现方式有两种主流思路:第一种是先随机布雷,如果玩家点击的位置正好是雷,就把这颗雷移走后重新补雷;第二种是提前把第一次点击坐标及其周围八格标记为安全区,布雷时完全避开。
第二种方案在游戏体验上更细腻,因为它保证了第一次点击后一定是一个连片的空格区域,而不是一个孤零零的“1”等待玩家去猜。我在代码里就是先把safeRow和safeCol传进布雷方法,然后调用isSafeZone判断周边方格。
需要特别提醒的是,isSafeZone和递归展开里“周围八格”的逻辑不要各写各的。最稳妥的写法是都依赖DR和DC这两个方向数组,否则以后想调整安全区范围时会改漏。
4. 翻开空格时的扩散算法:递归展开是核心
4.1 触发条件:只有未翻开的格子才能响应
鼠标点击一个格子后,首先要判断当前格子的状态。这里有个很容易犯的错误:没判断state就直接调用翻开逻辑。如果玩家连续快速双击,同一个格子可能被触发两次,第二次进去时又要判断一堆状态,逻辑会变得很乱。
正确的流程是:
- 检查游戏是否已结束,如果结束直接返回。
- 根据鼠标按键判断是左键处理还是右键处理。
- 左键处理里,先判断state是不是COVERED,不是就什么都不做。
- 再判断这个格子上有没有旗子,有旗子时默认不翻开。
只有前面这些条件全部满足,才真正进入翻开逻辑。把所有的“能不能操作”的检查都放在最前面,而不是分散到各处,是保证状态机不出混乱的通用做法。
4.2 洪泛扩散的递归实现
翻开一个空格时,如果这个格子周围雷数是0,就要自动往外扩散,把所有相邻的空格一起翻开,直到遇到有数字的格子边界为止。这一步是扫雷的“灵魂”,也是最考察算法基础的地方。
用递归实现非常直接:翻开当前格子后,遍历它的8个邻居,如果邻居未被翻开、不是旗子、不是雷,就递归调用翻开。如果邻居的数字不为0,那就只翻开当前格子,不再继续向外扩散。
private void revealCell(int r, int c) { if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return; if (states[r][c] != STATE_COVERED) return; states[r][c] = STATE_REVEALED; updateButton(r, c); if (numbers[r][c] == 0) { for (int i = 0; i < 8; i++) { revealCell(r + DR[i], c + DC[i]); } } }这里有个细节要注意:递归之前一定要先判断状态是否已经REVEALED。如果不判断,两个方向递归展开时会互相重复进入同一个格子,轻则重复更新引发性能问题,重则造成无限递归。
updateButton方法里根据numbers[r][c]的值设置按钮文字或图标,并设置按钮为不可用。如果数字为0,按钮上显示空白;如果是1到8,分别显示不同颜色,这是扫雷数字颜色的经典设定。用Swing的setEnabled(false)可以让已翻开的按钮不再响应点击,也能防止误触发事件。
4.3 递归深度与性能考量
玩过Java的人都知道递归和栈溢出是一对“好朋友”。在9x9、10x10这种标准配置下,递归深度最多也就几十层,完全不用担心。但在超大自定义面板,比如999x999的格子或者高雷密度的极端情况下,递归展开可能会同时点燃上万次方法调用,即使不触发栈溢出,反复调用方法也会让鼠标点击后的响应出现明显的卡顿。
如果以后想支持大棋盘,可以把递归改成队列加循环的写法:用一个LinkedList保存待处理坐标,不断从队头取出格子展开,新出现的空格子再压入队尾。这种写法在本质上和递归完全一致,但方法调用的开销更小,也不存在爆栈风险。我现在代码里保留的是递归,因为扫雷的常规尺寸用递归实现最直观,注释里也说明了替换成队列的方案。
我实际测试过16x16、40个雷的配置,用递归扩散,整个点击到界面刷新完成基本在几毫秒以内。真正影响手感的地方反而不是算法复杂度,而是按钮更新时对文字、图标、背景色、边距的一系列设置,这些操作组合起来会拖慢渲染,所以我会在updateButton里避免频繁调用Swing的set方法,尽量一次设置完。
5. 右键插旗、计时器与整局状态机:把细节串起来
5.1 鼠标左键、右键与双键操作映射
JButton默认的ActionListener只能感知鼠标左键点击,不能区分左键、右键和中键。要支持右键插旗,就不能只用ActionListener,需要给按钮加上MouseListener,在mousePressed或者mouseClicked里通过鼠标按键类型去判断。
标准做法是加一个公共的MouseAdapter:
gamePanel.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { if (SwingUtilities.isRightMouseButton(e)) { handleRightClick(button); } else if (SwingUtilities.isLeftMouseButton(e)) { handleLeftClick(button); } } });不过,要是给整个gamePanel加鼠标监听,坐标换算代码会稍微复杂一些。更省事的是给每个格子按钮单独加MonMouseAdapter,按钮知道自己的行列坐标,直接在监听器里转调对应逻辑。
这里还有一个常见需求:经典扫雷支持“左右键同时按下”的快捷操作,也就是当已翻开的数字周围旗子数量和数字相等时,同时按下左右键可以一次性翻开周围未插旗的格子。用JButton加MouseListener实现这个会比较绕,要自己维护“左键是否按下”“右键是否按下”的状态,再在第二次按键触发时判断。对入门项目来说,我建议先不做这个功能,等到核心逻辑稳定之后再考虑,避免初期被鼠标状态弄得焦头烂额。
5.2 计时器的启动与停止
扫雷里计时器属于“一次性启动、输赢后停止”的机制。我推荐使用javax.swing.Timer,而不是普通的java.util.Timer,因为Swing的Timer会在Event Dispatch Thread里触发回调,直接更新UI标签不会产生并发修改问题。
gameTimer = new Timer(1000, e -> { usedSeconds++; timeLabel.setText("用时: " + usedSeconds + " 秒"); }); gameTimer.start();这里要注意两个边界:一是开局时机,只有玩家第一次左键点击才启动计时,而不是程序一启动就开始;二是游戏结束后不管输赢都要立刻停止计时,否则玩家已经看到“游戏结束”了,计时器还在后面走秒,体验很差。
在我自己的实现里,定义了一个gameOver标志。所有鼠标点击进来时先判断这个标志,如果为true就直接return,只有重置按钮能把它改回false。
5.3 输赢判定与游戏状态重置
判断输赢有两条路:输的路径好判断,翻开一个雷就触发失败;赢的路径需要统计已翻开的格子数,当“已经翻开的非雷格数量 = 总格子数 - 雷总数”时,就算胜利。
这里有个容易漏掉的情况:玩家不一定要把所有安全格全部翻开才算赢。如果所有安全格都已经被翻开,说明所有雷的位置也必然已经被插旗或者暴露了,按照经典扫雷规则,应该自动将所有雷标记为旗子并判定胜利。实际上判断条件只依赖“安全格是否全部翻开”这个状态即可,没必要要求玩家把旗子插满。
失败时的展示逻辑我也统一处理:遍历所有格子,把所有地雷显示出来,把当前误点的格子用红色背景标出。这一步用for循环遍历mines数组就能实现,不需要复杂的算法。
重置逻辑则要稍微细心一点:
- 停止计时器,清零用时。
- 把所有按钮恢复成初始外观。
- 生成新的雷区,但要保留“首次点击安全”的逻辑。
- 重置游戏结束标志和剩余雷数标签。
重置时如果简单地把所有按钮的文本和图标的icon都清空,但忘了恢复按钮的enabled状态,上一局的按钮还是灰色不可用状态,新游戏就完全没法操作。这个bug很典型,我排查过好几次,最终把“重置按钮外观”和“重新布雷”拆成了两个方法,重置流程的顺序是:先恢复外观,再恢复状态数组,最后重新布雷。
另一个小细节是剩余雷数的计算。经典扫雷里,剩余雷数等于总雷数减去当前插旗数。我维护了一个flagCount变量,右键插旗加1,拔旗减1,剩余雷数标签在每次变化后更新。“剩余雷数可能为负数”这一点也要处理,旗子插多了显示负数是有意为之,但不要因为这个负数去阻止玩家继续插旗——否定玩家行为会让手感和直觉不符。
6. 资源加载和常见坑:让项目从“课堂作业”变“完整作品”
6.1 图片资源的加载方式
很多教程写图片加载会直接写:
new ImageIcon("src/images/mine.png")这种写法在你自己的IDE里能跑,一旦打包成jar分发,路径就全乱了,运行时会报错找不到图片。正确的做法是把图片资源放在项目资源目录下,使用类加载器读取:
InputStream is = this.getClass().getResourceAsStream("/images/mine.png"); BufferedImage image = ImageIO.read(is); ImageIcon icon = new ImageIcon(image);之所以用这种方式,是因为它会从classpath内部查找文件,不管是IDE运行还是打包成jar后运行都能找到资源。我写的扫雷里,数字部分其实没用图片,直接用文字加颜色实现,只有地雷、旗子、笑脸和失败表情使用了ImageIcon。
如果你懒得准备图片资源,也可以完全用文字和颜色代替。比如雷显示"雷"字,旗子显示"旗"字,背景色通过setBackground区分。这种方案在功能上完整,但呈现效果会差一些。如果是做课程设计或者作品展示,我建议还是花点时间准备好图标资源,界面质感从“学生作业”到“作品”往往就差这点细节。
6.2 常见Bug清单与排查思路
我自己开发过程中遇到过不少典型的bug,这里列成清单,给后来的人当排查参考。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 点击按钮没反应 | 状态判断写在前面,但监听起来没绑定 | 先打印按钮行列坐标,确认监听生效 |
| 游戏开始时部分格子显示异常 | 初始state数组没有全部设为COVERED | 在重置逻辑里统一初始化状态数组 |
| 第一次点击后仍然踩雷 | 布雷没有确保安全区;或布雷在safeRow传入前执行 | 把布雷调用放参数正确的位置 |
| 右键插旗后,左键还能翻开 | 没有在左键逻辑里判断FLAGGED状态 | 左键处理时检查state == STATE_COVERED |
| 递归扩散导致StackOverflow | 缺少已翻开判断,导致互相递归不断 | 检查递归函数是否先判断state |
| 计时继续走 | gameOver标志未设置 | 在踩雷和胜利路径里都设置gameOver = true |
| 重置后按钮灰色不能点击 | 旧按钮setEnabled(false)没恢复 | 重置时统一调用btn.setEnabled(true) |
这些坑还有一个共通的排查经验:不要一上来就改代码,先在关键入口点System.out.println打印日志,看看点击事件到底有没有过来、状态值是多少、switch到了哪个分支。打印出来基本就定位了。
6.3 可扩展方向:难度选择、扫雷英雄榜、自定义皮肤
核心扫雷做到能玩、能赢、能重置,其实已经是一个完整项目了。如果还想往“作品”方向走,以下这几个扩展方向都很有性价比。
首先是难度分级。9x9共10个雷是初级,16x16共40个雷是中级,16x30共99个雷是高级。在顶部面板加一个JComboBox或者一排JRadioButton,重置时根据选择重新初始化行、列、雷数即可。这里有一个需要同步修改的地方:GridLayout是在初始化时写的行数和列数,如果难度变了,必须用gamePanel.removeAll()清空所有按钮,再重新生成一遍按钮网格。很多人在扩展难度时遇到“旧按钮残留”的问题,就是没有removeAll。
其次是排行榜。排行榜的核心记录字段是难度、用时、日期。只要在胜利的时候把当前耗时和难度写入一个文件,同时读取历史成绩按时间排序,就是一个可用的英雄榜。注意写入和读取的编码问题,文件组最好统一指定UTF-8。
第三是自定义皮肤。Swing的好处是UI和逻辑分开,只要逻辑返回的数字、状态、图标还在,你可以把按钮背景色、字体、窗口图标全部换成自己整套设计,比如“暗黑风格扫雷”或者“像素字体版扫雷”。我在扩展时把数字颜色换成了Material Design配色,整体看起来完全换了主题,但核心的布雷和扩散一个字节都没动。
说句题外话,经常有人问我Java Swing是不是“过时了”,学了还有没有用。我的观点一直没变过:Swing作为桌面图形界面学习的第一站,它的核心价值在于让人理解事件驱动的图形程序是怎么组织起来的——渲染、监听、状态、线程。这些知识迁移到JavaFX、Android界面开发,甚至前端框架里都非常通用。而且扫雷这个项目代码量不大,足够一个小型毕业设计或课程设计使用,又能把算法、界面、文件操作串在一起,用来作为实训项目再合适不过。
最后分享一个我实际操作里的习惯:每次写扫雷之前,都会先在纸上画出格子的状态转换图,比如“COVERED加左键到REVEALED”“COVERED加右键到FLAGGED”“FLAGGED加右键回到COVERED”。界面和算法再乱,回到这个状态转换图上一对照,基本不会走偏。扫雷看起来是个小游戏,但把它的逻辑捋顺之后,你会发现很多复杂项目里的状态管理,本质上也逃不开这套思路。