1. 为什么拿扫雷当练手项目:一个游戏里藏了三块硬骨头
扫雷这游戏,我前前后后写过三版。第一版是刚学编程不久,想找个“看起来不简单但又不至于做不出来”的项目练手;第二版是给朋友做网页版;第三版是把逻辑重新梳理成干净的命令行版,用来带新人入门。每次写完都有新收获,这也是我为什么一直推荐别人拿扫雷当第一个完整项目——它看起来是个小游戏,但拆开之后,里面有随机生成、网格遍历、递归展开、交互状态管理,这些恰好是以后写任何复杂程序都会反复用到的基础能力。
很多人以为做扫雷就是把雷埋进去、点一下炸不炸,其实真正的难点不在“布雷”,而在“翻开空格之后怎么自动展开一大片”。这个展开逻辑背后是经典的洪水填充算法,也就是深度优先搜索或者广度优先搜索的简化应用。你只要把这一个动作想明白,以后处理迷宫寻路、连连看消除、地图连通域识别,思路就是一路的。
这个项目还特别适合练“怎么把规则翻译成代码”。扫雷规则很简单,但“简单”恰恰意味着你可以把注意力全部集中在如何设计数据结构、如何组织函数、如何判断边界条件上,而不是被复杂的业务逻辑淹没。等你把命令行版本跑通,再想升级图形界面、加计时器、做难度分级,都是在同一个骨架上加肉,扩展感非常明显。
技术选型上,我这个版本用 Python 写命令行交互。理由很实际:Python 语法直观,不需要安装额外依赖,一段脚本就能跑,逻辑完全暴露在眼前,最适合学习和复现。如果你后面想做成网页版,换成 JavaScript 也很快,核心算法几乎可以原样搬过去,改动的只是输入输出那一层。下面所有章节的代码,我都会按“先讲原理,再给代码,再解释为什么这么写”的顺序展开,保证你能直接照着敲出来。
2. 棋盘建模与布雷:数据结构先想清楚,代码才写得顺
2.1 三种状态分离存储,而不是揉在一起
我第一次写扫雷时犯过一个典型错误:用一个二维数组存格子,数字和地雷混在一起,同时还要用字符去表示“没翻开”,结果代码里到处都是判断逻辑,越写越乱。后来我才领悟,游戏里每个格子其实有三类信息:它本身是什么(地雷还是数字)、玩家是否看到了它(翻开与否)、玩家是否给它插了旗。这三类信息必须分开存,否则任何一步操作都得做一堆字符串解析。
这个版本的实现里,我用了三个二维列表,各自只负责一件事:
board:保存格子的真实内容。-1表示地雷,0到8表示周围八格中的地雷数量。visible:布尔值,True 表示该格已经被翻开。flagged:布尔值,True 表示该格被玩家插了旗。
棋盘大小默认是 9 行 9 列、10 颗雷,这也是我说“经典难度”的由来。如果你想让新手玩,可以把雷降到 8 颗;想快点结束,就改成 12 颗。这里建议把行数、列数、雷数全部抽成构造参数,不要写死在代码里,后面做难度分级就不用大改了。
为什么要这样拆分?我打个比方:你玩桌面游戏,每个人头顶上其实挂了三个牌子,分别是“真实身份”“当前显示状态”“玩家打上的标记”,如果你把一个牌子上的内容全部贴在同一个人脸上,别人想改标记时就会连身份一起改坏。程序里同理,职责分离越清楚,逻辑就越不容易互相干扰。后面所有判断都可以直接问“这个格子 visible 了吗?”“是不是 flagged?”而不用去翻字符串,读代码的人也会轻松很多。
2.2 随机布雷去重:用 while + 计数,而不是先造列表再抽
布雷的逻辑非常简单:在给定坐标范围内随机选行、列,如果该位置已经放过雷就重选,直到放满指定数量为止。常见错误是用了两层循环去生成随机数,结果时不时出现雷数不够的情况,或者干脆发生死循环。
我用的是计数循环,代码很朴素:
import random class Game: def __init__(self, rows=9, cols=9, mines=10): self.rows = rows self.cols = cols self.mines = mines self.board = [[0] * cols for _ in range(rows)] self.visible = [[False] * cols for _ in range(rows)] self.flagged = [[False] * cols for _ in range(rows)] self.over = False self.won = False self._place_mines() self._calc_numbers() def _place_mines(self): placed = 0 while placed < self.mines: r = random.randrange(self.rows) c = random.randrange(self.cols) if self.board[r][c] == -1: continue self.board[r][c] = -1 placed += 1注意这里用random.randrange而不是random.randint,因为前者和 Python 的range一样,左闭右开,写起来不容易越界。还有一点很重要:在init里,board初始全部填 0,等_place_mines把地雷位置标记成 -1,之后马上调用_calc_numbers去计算数字。这样布雷和计算数字就是两个独立方法,任何一个出问题都容易定位。
有些新手喜欢先构造一个包含所有坐标的列表,然后用random.sample一次性抽雷。这个做法当然也可以,但当棋盘很大、雷数接近格子总数的时候,构造大列表会浪费内存。while 循环的好处是不论棋盘多大、雷多到离谱,你都只需要在极少数冲突时重抽,代价非常小。实际使用时,9×9 的棋盘抽 10 颗雷,冲突概率不算低,但由于每次冲突都是 O(1) 的判断,整体性能完全没问题。
2.3 数字生成:八方向遍历的“方向数组”技巧
布雷完成后,每个非雷格子里要填一个数字,表示它周围八个格子中有多少颗雷。这个计算没有什么高深技巧,但写起来最容易出现重复代码。这个版本我只写了一个双重循环,配合方向偏移量,一次搞定:
def _calc_numbers(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] == -1: continue cnt = 0 for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue nr, nc = r + dr, c + dc if 0 <= nr < self.rows and 0 <= nc < self.cols and self.board[nr][nc] == -1: cnt += 1 self.board[r][c] = cnt这里的关键是“不要怕遍历八个方向”。很多人会想:能不能只遍历四个方向?答案是不行,因为斜对角的地雷也算相邻。所以八个方向必须全部检查。
再一个容易漏掉的是越界判断。9×9 的棋盘里,边角上的格子根本没有八个邻居,如果不去检查0 <= nr < rows这样的条件,直接访问board[nr][nc]就会抛异常。我把方向偏移和越界判断放在同一个嵌套循环里,代码看起来不长,但已经把边界全部挡掉了。
以后你写任何网格遍历类程序,都可以直接把这个“方向数组 + 越界判断”的模式抄过去。比如贪吃蛇的下一步移动、二维地图的寻路、连连看的连通检测,本质上都是“从一个格子出发,访问邻居格子”。所以这个小小的_calc_numbers,其实是整个扫雷里最值得反复咀嚼的函数之一。
3. 点击翻开与连锁展开:扫雷的“灵魂算法”是洪水填充
3.1 为什么点一个空格会瞬间翻开一大片
玩过扫雷的都体验过:点到一个周围没有地雷的格子,程序会“自动”把它周围一片全部翻开,直到数字边界才停下来。很多人第一次玩会觉得这是个魔法,其实这个魔法就叫洪水填充。
展开逻辑可以这样理解:把每个格子想象成一个房间,数字代表房间里写了“附近有雷”的提示。当你推开一扇门(翻开一个格子),发现房间里什么提示都没有,说明这一片都是安全的,你就应该继续把隔壁的门也推开。一直推,直到走进一个写着数字的房间,这时你就停在这个房间,不再继续往深处推。于是,所有连通的空格子都会被翻开,而数字格子则像围墙一样挡住扩展趋势。
在图的语言里,这就是从起点出发的深度优先搜索。扫雷网格就是一张图,每个格子是一个节点,相邻格子之间有一条边。我选择的实现方式是递归,因为它最贴合人类的直觉:翻开当前格,如果它是空格,就对八个邻居依次执行同样的“翻开”操作。代码看着短,但信息量很足。
3.2 递归实现展开,并用 flag 防止重复访问
下面这段reveal是整个扫雷里最核心的方法。理解它,你就掌握了扫雷算法的一半:
def reveal(self, r, c): if self.over or self.won: return if not (0 <= r < self.rows and 0 <= c < self.cols): return if self.flagged[r][c] or self.visible[r][c]: return self.visible[r][c] = True if self.board[r][c] == -1: self.over = True return if self.board[r][c] == 0: for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue nr, nc = r + dr, c + dc if 0 <= nr < self.rows and 0 <= nc < self.cols: self.reveal(nr, nc)几个细节我要专门拎出来讲。
第一,函数开头绝对要做越界检查。这不是可有可无的防御,而是递归的“逃生门”。没有它,边角格子会在递归时直接崩溃。每次递归进入新格子,都先把坐标范围检查一遍,越界就立刻返回,相当于告诉递归“这里到边界了,别再往下走”。
第二,visible[r][c]的判断是整个算法的核心防重复机制。想想看:一个空格被翻开后,它会同时向八个邻居发起递归,而它的邻居也会向各自的八个邻居发起递归,如果中间不设置“已翻开”标记,A格子会被邻居B翻开,邻居B又会被邻居C翻开,最终每个格子被递归访问很多次,既可能陷入无限递归,也可能让程序做大量无用功。所以翻开一个格子的第一件事,就是立刻把它标记为可见,后续递归看到这个标记就直接返回,不再处理。
第三,flagged[r][c]的判断意味着插了旗子的格子不会被“自动翻开”。这是玩家保护自己标注地雷位置的方式,如果展开时硬要把它翻开,那扫雷的插旗玩法就彻底没意义了。这里我直接在最前面拦住,而不是到数字判断之后才干预。
如果你担心递归在超大地图上会爆栈,比如 100×100 的棋盘全是一片空,那么可以把递归改成显式栈来实现深度优先,或者用队列实现广度优先。原理一样,只是把“函数自己调用自己”改成“把待处理坐标压入栈/队列,循环弹出处理”。我推荐先把递归版本写明白,因为它更容易理解和调试;真遇到超大棋盘,再用栈版本优化不迟。
3.3 踩到地雷时怎么处理
reveal里有一段特殊逻辑:如果翻开的是地雷,self.over = True并立刻返回,不再继续展开。这样玩家第一次踩雷就会立即触发游戏结束,后续点击全部被if self.over or self.won挡住。
这里有个设计取舍:传统扫雷在你踩雷时,会显示所有地雷的位置,然后游戏结束。我们的命令行版本在结束时会打印完整棋盘,把地雷显示成*,效果类似。不过我没有在踩雷后主动遍历显示全部地雷,而是直接把最终棋盘打印出来,这样代码更简洁。
胜利时展开逻辑也要正确处理:如果翻开最后一个安全格子,游戏应该判定胜利而不是继续等待。这个判断我们放在主循环里,用单独的方法去检查,我下一节会详细说。
4. 操作循环与胜负判定:怎么从程序变成能玩的游戏
4.1 主循环就是游戏世界的“心跳”
现在棋盘有了,展开算法也有了,但玩家还没有办法操作。这一步就是把底层逻辑接到交互上,让程序每走一轮都打印棋盘、接收输入、执行操作、判断状态。这个循环我习惯叫作“游戏心跳”,因为整个游戏世界的生命周期就在这里。
主循环的伪代码只有四步:显示棋盘 → 读玩家输入 → 执行操作 → 检查是否输了或赢了。代码写法如下:
def main(): g = Game(9, 9, 10) while not g.over and not g.won: g.display() try: raw = input("\n输入操作(r 行 列:翻开;f 行 列:插旗/拔旗):").strip().split() if len(raw) != 3: print("格式错误:需要三个字段,例如 r 3 4 表示翻开第3行第4列") continue op, r_str, c_str = raw[0], raw[1], raw[2] r, c = int(r_str), int(c_str) if not (0 <= r < g.rows and 0 <= c < g.cols): print("坐标越界,行和列必须在 0 到 8 之间") continue if op == "r": g.reveal(r, c) elif op == "f": if not g.toggle_flag(r, c): print("该格子已翻开,不能插旗") else: print("未知操作,只能输入 r 或 f") g.check_win() except ValueError: print("请输入数字坐标") g.display() if g.won: print("恭喜,所有安全格子都已翻开,你赢了") else: print("踩到地雷,游戏结束")这里用try...except ValueError是个容易被忽略但很必要的细节。玩家是会用手指头敲键盘的,他们大概率会输入r a b、r 3、甚至什么都不输。如果不捕获int()的异常,程序会直接崩溃。很多新手写的游戏“测试时好好的,给别人一玩就崩”,基本都是这个原因。你永远不要假设用户输入是合法的,防御性编程在交互程序里必须成为习惯。
4.2 插旗操作:翻与不翻之间还有第三个状态
扫雷里玩家往往不会马上翻开一个格子,而是先给它插旗,标明“我猜这里是雷”。这个状态必须和“翻开”“未翻开”分开。我在核心逻辑里提供了toggle_flag,它的特点是可以反复切换:第一次插旗,第二次拔旗,方便玩家纠正误判。
def toggle_flag(self, r, c): if self.visible[r][c]: return False self.flagged[r][c] = not self.flagged[r][c] return True这段逻辑很短,但我特意把“已翻开的格子不能插旗”的限制写在了方法里,而不是让主循环去判断。为什么?因为调用方不一定只有一个。以后你扩展图形界面,按钮点击事件可能会直接调用这个方法,如果逻辑只写在主循环里,界面那边就会漏掉限制。把业务规则收进核心类的公开方法,是封装的基本功。
另外提醒一下,插旗的数量并不限制地雷的数量。也就是说玩家可以把整片棋盘都插上旗,程序不会报错。真实的扫雷游戏一般会显示剩余地雷数作为提示,这个版本里可以之后再加一个mines - flagged_count的计算,我放在扩展方向一节再讲。
4.3 胜利判定的写法:别把条件设反了
胜利条件听上去很简单:所有非地雷格子都被翻开就算赢。但很多人的第一版实现会把它写成“翻开格子数等于总数减去雷数”,这在极少数情况下会漏掉一些状态。我这版的做法是直接遍历检查,代码直白,不容易出错:
def check_win(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] != -1 and not self.visible[r][c]: return False self.won = True return True思路是:只要还存在一个不是地雷但没被翻开的格子,就说明还没赢。全部找完都没找到这样的格子,那游戏胜利。这个检查放在每次操作之后,一般不会对性能造成什么问题,因为棋盘撑死也就几十上百个格子。
注意check_win和游戏结束不是同一个状态。over表示踩雷失败,won表示胜利。我把它们分开存,原因是后续你可能想加“失败时显示所有地雷”“胜利时统计用时”这些不同的处理逻辑,两个状态混在一起会让展示层写得很别扭。
4.4 打印棋盘:把二维数组变成人能读懂的界面
命令行的棋盘打印方案是:列号打印在第一行,行号排在每行的最左边;未翻开的格子显示为.,插旗显示为F,地雷显示为*,数字就显示数字本身。这样玩家一眼就能分辨哪些已经翻开、哪些还没处理。
def display(self): print(" " + " ".join(str(i) for i in range(self.cols))) for r in range(self.rows): row = [str(r).rjust(2)] for c in range(self.cols): if self.flagged[r][c]: row.append("F") elif not self.visible[r][c]: row.append(".") elif self.board[r][c] == -1: row.append("*") else: row.append(str(self.board[r][c])) print(" ".join(row))打印的顺序值得注意:flagged的判断必须放在visible之前,否则插了旗的未翻开格子会被打印成.,玩家就看不到自己的标记了。这种“状态优先级”的问题,是交互程序里最容易踩的坑,我在实测部分还会提。
5. 完整代码清单:Python 命令行版扫雷
下面的代码把所有类方法完整组合在一起,并加上可运行的main()入口。你可以直接复制保存成minesweeper.py,然后运行python minesweeper.py。
import random class Game: def __init__(self, rows=9, cols=9, mines=10): self.rows = rows self.cols = cols self.mines = mines self.board = [[0] * cols for _ in range(rows)] self.visible = [[False] * cols for _ in range(rows)] self.flagged = [[False] * cols for _ in range(rows)] self.over = False self.won = False self._place_mines() self._calc_numbers() def _place_mines(self): placed = 0 while placed < self.mines: r = random.randrange(self.rows) c = random.randrange(self.cols) if self.board[r][c] == -1: continue self.board[r][c] = -1 placed += 1 def _calc_numbers(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] == -1: continue cnt = 0 for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue nr, nc = r + dr, c + dc if 0 <= nr < self.rows and 0 <= nc < self.cols and self.board[nr][nc] == -1: cnt += 1 self.board[r][c] = cnt def reveal(self, r, c): if self.over or self.won: return if not (0 <= r < self.rows and 0 <= c < self.cols): return if self.flagged[r][c] or self.visible[r][c]: return self.visible[r][c] = True if self.board[r][c] == -1: self.over = True return if self.board[r][c] == 0: for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue nr, nc = r + dr, c + dc if 0 <= nr < self.rows and 0 <= nc < self.cols: self.reveal(nr, nc) def toggle_flag(self, r, c): if self.visible[r][c]: return False self.flagged[r][c] = not self.flagged[r][c] return True def check_win(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] != -1 and not self.visible[r][c]: return False self.won = True return True def display(self): print(" " + " ".join(str(i) for i in range(self.cols))) for r in range(self.rows): row = [str(r).rjust(2)] for c in range(self.cols): if self.flagged[r][c]: row.append("F") elif not self.visible[r][c]: row.append(".") elif self.board[r][c] == -1: row.append("*") else: row.append(str(self.board[r][c])) print(" ".join(row)) def main(): g = Game(9, 9, 10) while not g.over and not g.won: g.display() try: raw = input("\n输入操作(r 行 列:翻开;f 行 列:插旗/拔旗):").strip().split() if len(raw) != 3: print("格式错误:需要三个字段,例如 r 3 4 表示翻开第3行第4列") continue op, r_str, c_str = raw[0], raw[1], raw[2] r, c = int(r_str), int(c_str) if not (0 <= r < g.rows and 0 <= c < g.cols): print("坐标越界,行和列必须在 0 到 8 之间") continue if op == "r": g.reveal(r, c) elif op == "f": if not g.toggle_flag(r, c): print("该格子已翻开,不能插旗") else: print("未知操作,只能输入 r 或 f") g.check_win() except ValueError: print("请输入数字坐标") g.display() if g.won: print("恭喜,所有安全格子都已翻开,你赢了") else: print("踩到地雷,游戏结束") if __name__ == "__main__": main()运行之后的操作方式很简单:每次程序先打印当前棋盘,然后你输入r 3 4就翻开第 3 行第 4 列,输入f 5 6就给第 5 行第 6 列插旗或拔旗。一遍玩下来,你会非常直观地感受到“空格自动展开”“踩雷立刻结束”“插旗挡住展开”这几个核心体验是怎么由代码控制的。
6. 实测中遇到的坑和想继续做的方向
6.1 坑一:递归展开里忘记查 visible,导致重复访问
我第一次写reveal时,把visible[r][c]的检查放在了翻开之后才做。结果是:一个空格已经被翻开后,它的邻居仍然会再次调用reveal,而每次调用又会对八个邻居发起递归,整个展开过程像是无头苍蝇一样在已经打开的区域里打转。棋盘小的时候还能勉强跑完,棋盘一大就直接递归爆栈了。
后来我养成了一个习惯:凡是涉及递归遍历的代码,先把“当前节点是否已处理”的检查放在函数最前面。这个检查看似只是少了几行代码,实际上是保证算法复杂度从指数级降回 O(格点数) 的关键。你可以自己试一下删掉if self.visible[r][c]: return,看看程序会不会卡死,亲眼见过现象之后印象会特别深。
6.2 坑二:打印棋盘状态顺序不对,玩家看不到旗子
插旗功能做完以后,我发现玩家插了旗从棋盘上看不出来。排查了半天,原因是display里我先把visible判断放在了flagged前面。一个插了旗的格子因为visible还是 False,直接走到了.分支,旗子标记被活活吞掉了。这个例子很好地说明,打印这种看似无聊的代码,其实也需要明确“状态优先级”:先判断插旗,再判断是否翻开,最后才判断格子的真实内容。顺序反了,用户体验就差一个量级。
6.3 坑三:只要顺序不对,雷永远只出现在后面几行
还有个随机性问题也值得说。我最早是用两层循环嵌套random.randint来布雷,由于重置棋盘时没有先清空board,导致程序开始后雷的分布完全偏向后半段棋盘,看起来一点都不随机。任何随机类程序,都要保证“随机性在每轮初始化时重新生效”。正确的做法是在init里把board全部重置为 0,然后再调用布雷函数。现在代码里self.board = [[0] * cols for _ in range(rows)]这一行,就是稳稳的随机前置条件。
6.4 扩展方向:加计时、加难度、加图形界面
跑通命令行版之后,想把它变成真正的成品游戏,有几个很顺手的扩展方向。
加剩余地雷数显示是最简单的:在打印棋盘时,用self.mines - sum(sum(row) for row in self.flagged)统计当前旗子数量,然后打印在棋盘顶部。这行改成计数器维护会更高效,但对小程序来说,直接统计完全够用。
加计时功能也不复杂:在main()开头记录start = time.time(),每次循环打印时计算elapsed = int(time.time() - start),胜利或失败时再把这个数字打出来。如果你想让计时精确到秒,可以跳过开始前的等待时间,不过 9×9 的棋局一般几十秒就结束,误差完全可以接受。
加难度分级更是顺水推舟:把Game(9, 9, 10)改成Game(16, 16, 40)就是高级难度,只要在main()里让用户选一个难度,把参数传给构造方法即可。雷数比例记得调整,16×16 的棋盘放 40 颗雷,密度和 9×9 放 10 颗差不多,玩起来节奏正好。
加图形界面的方案有两个方向:一是 Python 的界面库,把每个按钮绑定一个坐标,点击时调用reveal或toggle_flag;二是迁到网页版,用 HTML 生成格子,JavaScript 实现核心算法。老实说,把命令行版的逻辑扩展到图形界面后,你会体会到“界面归界面、逻辑归逻辑”的真正含义——所有核心方法一个字都不用改,只要把输入输出层换掉就行。这就是一开始把数据结构设计清楚的回报。
做完这个项目后,我最大的体会是:扫雷不是一个“小游戏”,而是一堂浓缩的编程课。随机生成、递归遍历、状态管理、用户输入防御,这些平时分散在各种教科书里的知识点,被一个简单的游戏串成了一条完整的经验链。如果你照着代码敲了一遍,建议再自己从头写一版,不要看代码,写完再对比,收获会大得多。如果在这个基础上还想去挑战,可以试试把洪水填充改成迭代版本,或者给棋盘加一个“首击必安全”的规则,那又是两个很有意思的小问题了。