Python pygame实战:从零实现9×9扫雷游戏
2026/9/14 3:50:10 网站建设 项目流程

简介:一份基于 Pygame 的经典扫雷游戏完整工程,适合 Python 初学者、Pygame 入门者以及想复刻小游戏做课程设计的开发者。项目将游戏入口与方块逻辑拆分为两个模块,借助 18 张 BMP 图像完成数字、地雷、旗帜、笑脸等界面元素,辅以 TTF 字体与依赖清单;解压后安装依赖并运行入口脚本即可开始游戏,通过 README 可调整窗体大小与雷区数量。压缩包共 24 个文件,整体仅 45KB,体量轻量,且游戏逻辑与界面资源分离,目录结构清晰,便于逐行阅读和二次修改。目前已有 219 人浏览学习;对于想快速理解 Pygame 事件循环、网格坐标映射、扫雷状态切换(翻开、标旗、踩雷、胜利)等要点的读者,这是一份低门槛、结构完整的参考实现,既可直接体验,也可作为课堂作业或课程设计的基础版本继续扩展。

1. 为什么要用 pygame 实现一个 9×9 扫雷

扫雷这个游戏,看着是运气,其实是概率与信息推理。玩家不知道雷在哪,但能从数字推出哪些格子绝对安全,哪些格子必须赌。用 Python 和 pygame 实现它,比做贪吃蛇更能训练工程拆解:雷区生成、数字计算、翻开扩散、画面渲染、鼠标交互,每一项都能独立测试。很多初学者做完这个项目会误以为难点在图形,其实多数 bug 出在洪泛填充和边界处理上。这篇文章从安装 pygame 开始,把 9×9、10 颗雷这样一局完整跑通,最后加上首击安全、左右键快速翻开这类“手感”功能。适合已经会 Python 基础语法、想用 pygame 写第一个像样项目的读者;老手也可以看第三节的栈式扩散和第五节的单元测试部分,避免自己在递归深度和随机种子这类问题上重复踩坑。

2. 先搭环境:pygame 安装与 “failed to build pygame” 排错

2.1 先用python --version确认解释器版本,再决定安装命令

pygame 以 C 扩展形式分发,Windows 和 macOS 上 pip 通常能直接拿到 wheel,Linux 则可能拿到源码包。为了避免装进错误解释器,我习惯用python -m pip而不是裸pip。先检查版本:

python --version python -m pip --version

如果输出中 Python 是 3.8 以上,pygame 2.x 的安装基本没有障碍。注意python -m pip会调用当前 shell 找到的那个 python 对应的 pip,这在同时存在系统 Python、Anaconda、pyenv 的机器上尤其重要。随后执行安装:

python -m pip install pygame

安装完成后不要直接跑长程序,先用一行代码验证是否真的可用:

python -c "import pygame; print(pygame.version.ver)"

能打印出版本号,说明库已经进入当前解释器的搜索路径。如果你用 VS Code,还需要在命令面板里选择这个解释器,否则编辑器右下角的 Python 环境与运行终端里的 python 可能不是同一个。这是“代码里 import pygame 报错,命令行却不报错”的最常见原因,和 pygame 本身无关。

2.2 wheel 构建失败:报错信息的三种读法

常见报错:

error: failed to build 'pygame' when getting requirements to build wheel

这句话只在 pip 找不到预编译 wheel 时出现,说明它准备从源码开始编译。在 Windows 上大概率是网络源没有提供对应平台的 wheel,而不是没有源码;在 Linux 上更常见的是缺 SDL 和编译器。读报错要学会分层:头几行是“哪个包失败”,中间是 gcc 或 clang 的编译日志,最后才给出这个结论。先升级 pip 再重装,能解决一部分缓存问题:

python -m pip install --upgrade pip python -m pip install --force-reinstall pygame

如果仍然失败,就要按系统装 SDL 库。下表是我在不同系统上验证过的做法,注意即使系统已经能 import pygame,也建议看一眼这些依赖是否齐全,否则字体功能会在运行时异常退出。

系统常见原因推荐命令
Windows 10/11官方已提供 wheel,一般不需要额外操作python -m pip install pygame
macOS (Intel/Apple Silicon)Homebrew 环境变量未生效brew install sdl2 sdl2_image sdl2_mixer sdl2_ttf
Debian/Ubuntu缺少编译 SDL 的库和头文件sudo apt install build-essential libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
CentOS/RHEL默认仓库缺少 SDL_image 等优先在虚拟环境装 wheel,不强行编译

有一个参数很容易被忽略:在 macOS 上,如果你通过 Homebrew 安装了 Python,python3pip3可能指向不同架构的二进制,导致编译出来的模块无法加载。这类报错不在 pip 输出里,而是运行时才出现ImportError: dlopen(...) Library not loaded。此时用file $(which python3)检查是 x86_64 还是 arm64,再决定是否需要统一架构重新安装。

2.3 最小可运行窗口:确认 pygame 环境已经就绪

创建一个minimal.py,内容就是打开一个可以关闭的窗口:

import sys import pygame pygame.init() screen = pygame.display.set_mode((320, 240)) pygame.display.set_caption("pygame check") while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() pygame.display.flip()

运行python minimal.py后能看到黑色窗口。这里最需要注意的是事件循环:pygame.event.get()会清空事件队列,所以每个事件只处理一次;QUIT事件由点击窗口关闭按钮触发,不能漏掉。如果窗口一闪而过,多半是循环里没有阻塞点。把这段跑通,后续扫雷的所有绘制和鼠标逻辑都建立在这个骨架上。

3. 先写不依赖 pygame 的扫雷逻辑:布雷、数字与翻开扩散

3.1 用二维数组表示棋盘,雷用 -1,数字用 0 到 8

我把逻辑层单独写成minesweeper.py,不 import pygame。这样核心算法可以被命令行测试,也可以在将来换成 tkinter 或 web 前端。棋盘用两层嵌套的 list,外层是行,内层是列。按board[row][col]读取时,值的含义如下:

含义
-1
0周围 8 格没有雷
1-8周围 8 格有 n 颗雷

初始生成时先全部置 0,再随机把指定数量的位置改成 -1。不要用单个整数矩阵,因为扫雷的“当前状态”还有两个维度:是否已翻开、是否插旗。这三个信息互相独立,混在一个二维数组里会造成状态爆炸。

3.2 用 random.sample 一次性生成雷位,而不是循环里逐个 random

一个常见错误是:

for _ in range(mine_count): r = random.randint(0, height - 1) c = random.randint(0, width - 1) board[r][c] = MINE

这样在雷数逼近格子总数时会出现多次重复命中,需要额外去重。更稳妥的做法是先把所有坐标放进列表,再用random.sample一次抽满。考虑到首击安全,我通常会预留一个排除点:

import random MINE = -1 EMPTY = 0 def neighbors(r, c, height, width): for dr in range(-1, 2): for dc in range(-1, 2): if dr == 0 and dc == 0: continue nr, nc = r + dr, c + dc if 0 <= nr < height and 0 <= nc < width: yield nr, nc def create_board(height, width, mine_count, exclude=None): board = [[EMPTY] * width for _ in range(height)] cells = [(r, c) for r in range(height) for c in range(width) if (r, c) != exclude] for r, c in random.sample(cells, mine_count): board[r][c] = MINE for r in range(height): for c in range(width): if board[r][c] != MINE: board[r][c] = sum(1 for nr, nc in neighbors(r, c, height, width) if board[nr][nc] == MINE) return board

exclude是首击后传入的坐标,确保第一次点击的格子及其位置不布雷。random.sample(cells, mine_count)的第二个参数是抽取数量,它保证不重复,且不会超出列表长度。数字计算用sum统计邻居里的雷数,这里严格判断== MINE,避免以后扩展时把其他负数误判。create_board的签名已经暴露了三个关键参数:heightwidth控制地图尺寸,mine_count控制难度,exclude只影响开局第一下。

3.3 翻开与扩散:用显式栈模拟洪泛填充

当玩家点击空格时,如果该格数字是 0,它会自动扩散到周围所有非雷格,直到碰到有数字的格子。递归实现非常直观,但极限情况下递归深度可能超过 Python 默认的 1000 层限制。9×9 的棋盘一般不会触发,我依然建议用栈:

def reveal(board, revealed, r, c): if revealed[r][c] or board[r][c] == MINE: return False stack = [(r, c)] while stack: cr, cc = stack.pop() if revealed[cr][cc]: continue revealed[cr][cc] = True if board[cr][cc] == 0: for nr, nc in neighbors(cr, cc, len(board), len(board[0])): if not revealed[nr][nc] and board[nr][nc] != MINE: stack.append((nr, nc)) return True

revealed是与board相同形状的布尔二维数组。stack用列表的appendpop实现后进先出,顺序不影响扩散结果,因为我们要把可达空格和数字边界全部翻开。if not revealed[nr][nc] and board[nr][nc] != MINE防止把雷翻开,也防止重复入栈。返回False表示点中雷,让上层状态机处理胜负;返回True表示正常完成一次翻开。逻辑层到此具备三个能力:生成棋盘、读取数字、翻开扩散,完全不依赖 pygame。

4. 用 pygame 把逻辑层变成可以玩的界面:事件、绘制与状态机

4.1 从逻辑坐标到像素坐标:cell_size 换算与绘制网格

pygame 的坐标系以左上角为原点,x 向右,y 向下。扫雷里最常见的换算就是把鼠标像素位置除以格子边长得到行列:

cell_size = min(WINDOW_W // cols, WINDOW_H // rows) board_px_w = cols * cell_size board_px_h = rows * cell_size

绘制时先用深灰色背景铺满窗口,再对每个revealed格子画方块。核心绘制循环如下:

COLOR_BG = (189, 189, 189) COLOR_REVEALED = (255, 255, 255) COLOR_HIDDEN = (160, 160, 160) COLOR_BORDER = (128, 128, 128) COLOR_NUM = {1: (0, 0, 255), 2: (0, 128, 0), 3: (255, 0, 0)} def draw_board(screen, board, revealed, flags, cell_size): rows = len(board) cols = len(board[0]) for r in range(rows): for c in range(cols): x, y = c * cell_size, r * cell_size rect = pygame.Rect(x, y, cell_size, cell_size) if revealed[r][c]: screen.fill(COLOR_REVEALED, rect) value = board[r][c] if value > 0: text = FONT.render(str(value), True, COLOR_NUM.get(value, (0, 0, 0))) screen.blit(text, text.get_rect(center=rect.center)) elif flags[r][c]: screen.fill(COLOR_HIDDEN, rect) pygame.draw.polygon(screen, (255, 0, 0), [(x + 6, y + 6), (x + 22, y + 14), (x + 6, y + 22)]) else: screen.fill(COLOR_HIDDEN, rect) pygame.draw.rect(screen, COLOR_BORDER, rect, 1)

pygame.Rect可以直接传给screen.fill,只填充指定矩形区域。数字字体用全局FONT = pygame.font.Font(None, cell_size * 3 // 4)None表示使用默认字体,不需要额外字体文件。旗帜部分用pygame.draw.polygon画三角形,坐标是相对 cell_size 写死的,如果格子大小变了,这段要改成按比例计算。另一个常见坑:每个格子都画 1px 边框会让相邻格子的边框线重叠,但视觉上只表现为网格线稍粗,统一画在矩形上即可接受。

4.2 鼠标事件处理:左右键分别对待,事件坐标换算成行列

在 pygame 中,MOUSEBUTTONDOWN事件的event.pos是窗口像素坐标。我先把逻辑层封装成一个Game类,它只持有数据和操作,不负责绘制:

class Game: def __init__(self, rows, cols, mine_count): self.rows, self.cols, self.mine_count = rows, cols, mine_count self.board = None self.revealed = None self.flags = None self.state = "READY" def start(self, r, c): self.board = create_board(self.rows, self.cols, self.mine_count, exclude=(r, c)) self.revealed = [[False] * self.cols for _ in range(self.rows)] self.flags = [[False] * self.cols for _ in range(self.rows)] self.state = "PLAYING" self.reveal(r, c) def reveal(self, r, c): if self.revealed[r][c] or self.flags[r][c]: return if self.board[r][c] == MINE: self.revealed[r][c] = True self.state = "LOSE" return reveal(self.board, self.revealed, r, c) if self.check_win(): self.state = "WIN" def check_win(self): return sum(sum(row) for row in self.revealed) == self.rows * self.cols - self.mine_count def toggle_flag(self, r, c): if self.state != "PLAYING": return self.flags[r][c] = not self.flags[r][c]

事件处理时,信息栏如果占用顶部 40px,鼠标 y 坐标要先减去这个偏移量:

info_height = 40 def handle_click(event, game): x, y = event.pos y -= info_height if y < 0 or x >= board_px_w or y >= board_px_h: return row, col = y // cell_size, x // cell_size if event.button == 1 and not game.flags[row][col]: game.reveal(row, col) elif event.button == 3: game.toggle_flag(row, col)

event.button中 1 是左键,3 是右键。左键点击前检查not game.flags[row][col]:插旗的格子不能被翻开,这是扫雷的基本规则。如果点击已翻开数字格并需要支持 chord,则单独处理,见第五节。row, col = y // cell_size, x // cell_size完成从像素到逻辑坐标的整数除法,自动舍弃小数部分。

4.3 游戏状态机:READY / PLAYING / WIN / LOSE,只有四种跳转

扫雷的交互状态可以定义为常量,状态跳转规则如下:

状态进入条件可接受操作
READY程序启动或新局开始左键点击同时布雷并翻开
PLAYINGREADY 后第一次点击左键翻开、右键插旗
WIN已翻开方格数等于安全格总数显示胜利,禁止操作
LOSE左键点中雷显示雷位置,禁止操作

在 READY 状态下,第一次左键点击调用game.start(row, col)start内部先生成棋盘,再调用reveal,最后检查胜利条件。把状态判断放在逻辑层比较简单:

if game.state == "READY" and event.button == 1: game.start(row, col) elif game.state == "PLAYING": handle_click(event, game)

这里handle_click内部需要先判断game.state,所以直接复用即可。注意game.start里首击安全只排除了点击格本身,没有把周围 8 格全部设为安全;经典扫雷也是保证第一击不在雷上,周围是否安全由随机决定。

4.4 用字体对象绘制数字和剩余雷数信息栏

窗口顶部预留 40px 高度显示剩余雷数和耗时。剩余雷数 =mine_count - sum(sum(row) for row in flags),耗时用pygame.time.get_ticks(),它返回从pygame.init()开始的毫秒数:

mine_left = game.mine_count - sum(sum(row) for row in game.flags) time_ms = pygame.time.get_ticks() - start_ticks info_text = f"Mines: {mine_left} Time: {time_ms // 1000}s"

sum(sum(row) for row in flags)把二维布尔矩阵简化成旗子总数。布尔值参与算术时会被转换为 0/1,这也是 Python 类型转换的常见场景。用FONT.render(info_text, True, (0, 0, 0))生成文字,再blit到坐标(10, 10)。信息栏区域在绘制棋盘之前先用灰色矩形覆盖,避免上一帧的文字残影留在窗口上。

5. 让扫雷更好玩:首击安全、快速翻开与逻辑单元测试

5.1 首击安全的最小改动:扩展 create_board 的 exclude

逻辑层已经留了exclude参数,只需在 READY 点击时把第一次坐标传进去。如果希望“首击后以点击格为中心 3×3 都安全”,可以把exclude换成集合,在生成cells时用集合差过滤。我的做法是只保证点击格不是雷,不做多余保护,因为雷数少时排除周围 8 格会让雷区分布明显偏离。

5.2 左右键同时按下的 chord 操作

chord 的规则:对一个已翻开的数字格,周围旗子数等于该数字时,点它会翻开周围所有未翻开的非旗格。pygame 可以通过event.button == 2(中键)实现,也可以在右键按住时检测左键事件:

def chord(game, r, c): if game.board[r][c] <= 0: return around = list(neighbors(r, c, len(game.board), len(game.board[0]))) flag_count = sum(1 for nr, nc in around if game.flags[nr][nc]) if flag_count == game.board[r][c]: for nr, nc in around: if not game.flags[nr][nc]: game.reveal(nr, nc)

注意必须先判断当前格已翻开且数字大于 0。flag_count和数字相等时,执行翻开可能再次触发级联扩散,逻辑层的reveal已经处理了重复入栈,这里可以放心调用。

5.3 用 pytest 给扫雷逻辑加一道保险

把逻辑层独立出来的好处是可以不启动窗口就测试。我通常写三个测试:雷数等于 mine_count、数字与邻居雷数一致、点击空格后扩散到边界。

def test_mine_count(): random.seed(0) board = create_board(9, 9, 10) assert sum(row.count(-1) for row in board) == 10 def test_numbers(): random.seed(0) board = create_board(4, 4, 3) assert board[0][0] == sum(1 for nr, nc in neighbors(0, 0, 4, 4) if board[nr][nc] == -1)

random.seed(0)固定随机序列,让测试结果可复现。这个技巧对任何用到随机的游戏都有效,不只是扫雷。4×4 放 3 颗雷既避免全雷极端,又能覆盖相邻数字边界。当界面出现“只点一下全部翻开”的意外时,优先在测试里复现,不要先去调鼠标坐标换算。

5.4 压测棋盘尺寸的边界参数

有些 Linux 系统上 pygame 在窗口尺寸超过屏幕时会创建失败。把WINDOW_WWINDOW_H留成变量,启动时用pygame.display.Info()获取当前屏幕大小,再取 min 保证窗口不超边界。棋盘尺寸和难度用参数而不是写死,方便后续做成选关界面。经典难度参数可以直接预设为 9×9/10 雷、16×16/40 雷、30×16/99 雷三档,这套数值在多数扫雷变体中都能保持合理的胜率和开局节奏,是一线实现里验证过的默认配置。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询