ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pygame扫雷项目拆解:状态机与BFS泛洪展开的工程实践

Pygame扫雷项目拆解:状态机与BFS泛洪展开的工程实践 简介这份资源是一套基于Pygame实现的扫雷游戏完整源码主要面向Python初学者、游戏开发入门者以及需要图形界面练手项目的学生。通过阅读和运行代码可以直观理解事件循环、鼠标交互、随机布雷、邻域判定等核心编程思想并掌握pygame的基本用法。压缩包内含24个文件整体仅45KB以18张BMP图片素材为主涵盖数字0到8、地雷、旗帜、问号以及正常、胜利、失败等多张状态图标另有2个Python脚本分别负责游戏主体与方块逻辑1个TTF字体用于界面显示配套的requirements.txt与README说明了依赖安装和参数调整方法用户可自行修改窗口大小和雷区数量。目前已有219人学习下载。整套源码目录清晰、注释简洁将棋盘逻辑与渲染表现适度分离非常适合在现有玩法上继续扩展难度、计时或动画效果也可作为课程设计或社团展示的参考项目。1. 一个 200 行不到的扫雷为什么值得拆开看扫雷这个项目网上源码一抓一大把但绝大多数是单文件把所有逻辑塞进while True循环里渲染、事件、判定、逻辑全部耦合在一起能跑但毫无工程参考价值。这份资源拆成了main.py和mineblock.py两个模块前者管 Pygame 窗口循环和渲染后者把棋盘、布雷、翻开、标记、胜负判定全部封装成独立类而且素材用的是纯 BMP 位图和 TTF 字体文件没有一张依赖素材库生成图这在教学类项目里反而是优点——你能完整看到 Pygame 加载外部资源、处理事件队列、管理多层状态的完整链路。对已经写过几个 Pygame 小游戏的开发者来说真正值得看的是它的状态设计和泛洪展开算法怎么写才不会在 9x9 的小棋盘上翻车对刚接触 Pygame 的人这个项目是少有的「跑起来之后再去读代码能读明白」的入门样例。2. mineblock.py扫雷的数据层不只是二维数组2.1 方块对象的属性设计与为什么要用类而不是字典打开mineblock.py第一反应是这个文件命名很直白它把雷电的格子建模成了MineBlock类。看起来很简单但这个设计决定了后续所有逻辑的复杂度上限。每个格子需要维护的属性包括是否埋雷、周围雷数、当前显示状态未翻开/翻开/插旗/问号以及它在棋盘上的行列坐标。用字典也可以但类的好处是能把「翻开这个格子」「标记为旗子」「计算周围雷数」这些动作直接做成方法而不是在main.py里写一堆散落的函数操作嵌套字典的 key。常见做法是这样定义class MineBlock: def __init__(self, row, col): self.row row self.col col self.is_mine False self.around_mine 0 self.state covered # covered / revealed / flagged / question属性命名的意图很明确state用字符串常量而不是布尔值是为了给后续扩展留空间——很多教材用is_open和is_flag两个布尔组合结果「问号」状态根本表达不出来。这里一个state字段解决所有状态迁移。2.2 布雷算法random.choice的坑与洗牌方案扫雷的布雷逻辑看起来是「随机选 N 个格子放雷」但直接random.choice按格子逐个选会有一个概率层面的小毛病当棋盘接近铺满时重复选到同一格子的概率急剧上升需要反复重试。更干净的做法是把所有格子坐标展开成列表一次random.sample搞定。import random def plant_mines(rows, cols, mine_count, exclude): all_positions [(r, c) for r in range(rows) for c in range(cols)] candidates [pos for pos in all_positions if pos ! exclude] mine_positions random.sample(candidates, mine_count) return set(mine_positions)参数说明exclude是首次点击的格子坐标标准扫雷规则保证第一次点击不会踩雷random.sample保证不重复且时间复杂度是 O(n)比循环choice加去重干净得多。注意mine_count不能超过len(candidates)否则sample会抛ValueError这一步放到参数校验里做。2.3 周围雷数计算边界判断容易写错的三个位置这是整个项目里最容易写出 bug 的部分。计算周围雷数需要对 8 个邻域做遍历核心问题不是算法而是边界。很多人写出来的版本长这样for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr 0 and dc 0: continue nr, nc block.row dr, block.col dc if 0 nr rows and 0 nc cols: if grid[nr][nc].is_mine: block.around_mine 1这段逻辑本身没错但有一个隐藏的性能和可读性问题每次翻开格子时都去重新检查一圈 8 邻域而且对已经翻开的格子重复计算。更合理的做法是在布雷完成后一次性遍历所有非雷格子的邻域做累加把around_mine在初始化阶段算好后续翻开时直接读字段。位置类型容易犯的错正确做法左上角 (0,0)索引 -1 访问到列表末尾先判断边界再取索引底行rows-1nr1越界边界比较用而非单行棋盘邻域遍历越界行列边界分开判断mineblock.py里如果看到初始化的双层循环里套if边界判断逻辑上是 OK 的但真正要检查的是它有没有在布雷完成后的独立循环里做雷数统计而不是把统计动作写到每次点击事件里。3. main.py 的主循环状态机与事件分发怎么组织才不臃肿3.1 游戏三态RUNNING、SUCCESS、FAIL 的分流设计main.py里最重要的不是 Pygame 初始化和窗口创建而是那个控制游戏流转的三态模型。很多初学者把「游戏是否结束」用一个布尔变量控制结果发现要同时处理「踩雷失败」「掀开所有非雷格成功」「插旗数量已满但雷标错」三种情况时布尔值根本不够用。常见设计是把状态集中到一个变量上class GameState: RUNNING 0 SUCCESS 1 FAIL 2 state GameState.RUNNING然后主循环只在state GameState.RUNNING时响应鼠标事件SUCCESS和FAIL仅渲染对应表情图标就是素材里face_success.bmp和face_fail.bmp的用途不再响应棋子翻转。这个小技巧的工程意义在于事件处理逻辑不需要在每次点击时都判断「游戏是不是已经结束了」——把判断提前到事件分发之前循环体内的逻辑直线下降。3.2pygame.event.get()的事件过滤只处理自己关心的Pygame 的事件队列包含鼠标移动、键盘按键、窗口大小变化等大量事件如果直接for event in pygame.event.get()后每类都写分支代码会迅速膨胀。main.py里值得学习的模式是按事件类型先粗筛再对鼠标事件单独细分for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.MOUSEBUTTONDOWN: if state ! GameState.RUNNING: continue x, y event.pos col (x - board_left) // block_size row (y - board_top) // block_size if not (0 row rows and 0 col cols): continue if event.button 1: game_logic.left_click(row, col) elif event.button 3: game_logic.right_click(row, col)注意这里的关键在state ! GameState.RUNNING前置过滤以及在计算row和col后做的边界检查。如果棋盘左上角不是坐标系原点需要先减去board_left和board_top偏移量再做整除否则点击棋盘外缘会得到负数索引或越界索引。3.3 汎洪展开Flood Fill的队列实现避开递归限制点开空白格时要把周围所有「雷数为 0」的区域连锁展开标准算法是 BFS 或 DFS。mineblock.py里用的是 BFS 加显式队列这是对的——Python 默认递归深度 1000 左右虽然 9x9 也不会爆栈但如果之后把棋盘调大到 30x30、雷数 99 的专家模式递归 DFS 在极端空旷区域有 RecursionError 风险。from collections import deque def reveal_blank(start_row, start_col): queue deque([(start_row, start_col)]) visited set() while queue: r, c queue.popleft() if (r, c) in visited: continue visited.add((r, c)) block grid[r][c] block.state revealed if block.around_mine 0: continue for dr in (-1, 0, 1): for dc in (-1, 0, 1): nr, nc r dr, c dc if 0 nr rows and 0 nc cols: if grid[nr][nc].state covered and not grid[nr][nc].is_mine: queue.append((nr, nc))逻辑说明visited集合是必要的否则已入队的相邻格可能被重复入队死循环倒不至于但会让小块区域反复处理在大棋盘上明显卡顿。around_mine 0的格子直接终止分支但本身要标记为revealed这就是扫雷中「数字格不向外扩散」的规则映射。参数上要注意state covered这个入队条件——如果写成! revealed旗子格和问号格也会被展开逻辑就错了。3.4 右键插旗与数字联动标记计数怎么跟胜负判定同步右键点击的语义在扫雷里有三种状态循环无标记 → 旗子 → 问号 → 无标记。这个逻辑放在mineblock.py的toggle_flag方法里更合适因为main.py只负责告诉它「用户右键了这个格子」状态迁移是数据层的职责。def toggle_flag(self): if self.state covered: self.state flagged elif self.state flagged: self.state question elif self.state question: self.state covered插旗数量要同时维护一个全局计数器和remaining_mines做联动标准的胜负判定只看已翻开的非雷格数量是否等于total - mine_count插旗只是辅助玩家记忆的标记不能把「插旗数量等于雷数」当作胜利条件——这是无数人踩过的坑踩错的雷和插错的旗互相抵消时游戏会错误结束。4. 素材加载与参数化配置BMP、TTF 和 README 里的调参入口4.1pygame.image.load与convert_alpha的适用边界这套资源里素材全是.bmp格式对应的是pygame.image.load()直接加载。 BMP 没有 alpha 通道所以不需要convert_alpha()但如果你把素材换成 PNG 想支持透明背景就必须调用convert_alpha()否则透明区域会变成黑色块。常见的一个教学项目卡点就是素材明明是透明的加载进来却是黑底——十有八九是漏了convert_alpha()。import os import pygame def load_image(filename): path os.path.join(resources, filename) image pygame.image.load(path).convert() return image这里.convert()的作用是把图片像素格式转成与当前显示表面一致加速每次blit的像素拷贝。如果素材不带透明通道用convert()就够如果带透明通道必须换convert_alpha()。注意pygame.image.load()无法自动处理中文路径或目录不存在的情况启动前检查os.path.exists(resources)会省很多排查时间。4.2 数字图片与font.render的分工为什么要有 a.TTF素材里同时看到了数字格图片0.bmp到8.bmp和一个a.TTF字体文件。这两者的分工很典型数字格图片是给已翻开格子的数字用的棋盘上雷数 1~8 直接blit对应图片a.TTF则用于渲染状态栏的计时器和剩余雷数因为这两个数字是动态变化的为每个数字准备图片文件太蠢。font_24 pygame.font.Font(os.path.join(resources, a.TTF), 24) def draw_mine_count(surface, count, x, y): text_surface font_24.render(str(count), True, (255, 0, 0)) surface.blit(text_surface, (x, y))如果换成pygame.font.SysFont(None, 24)用系统默认字体在 Linux 服务器或精简版 Windows 上可能拿不到任何中文字体渲染直接报错或显示方块。自带 TTF 的另一个好处是可移植性——换机器运行不会因为系统字体不同而布局错乱。4.3 README 里的参数约定棋盘长宽和雷数该往哪塞摘要里特别提到「扫雷窗口的大小以及扫雷个数参数的设定看 README 文件」——这暗示了main.py顶层应该有可调常量而且极可能没有做成配置文件而是直接写在代码里。常见的形式是这样ROWS 9 COLS 9 MINES 10 BLOCK_SIZE 32 TOOLBAR_HEIGHT 48这里有两个坑。第一MINES必须小于ROWS * COLS - 1否则random.sample抛异常第二窗口宽度应该计算为COLS * BLOCK_SIZE而不是写死一个 320否则调整ROWS或COLS后窗口尺寸跟不上。更稳妥的做法是从mineblock.py的棋盘初始化函数读取这些参数集中在main.py的__main__块中避免两个文件各维护一份数值。4.4 requirements.txt 的锁定策略pygame 版本对导入方式的影响requirements.txt在这个解压包里是很关键的依赖文件。很多环境装不上 Pygame根源是 pip 默认装到了系统 Python 而不是虚拟环境或者是 Python 3.12 没有对应的预编译 wheel需要编译源码。值得注意的安装顺序是python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install -r requirements.txt python main.py参数说明-r requirements.txt会安装文件里的全部依赖如果pip install pygame报failed to build pygame when getting requirements to build wheel多半是 Python 版本太新或缺少编译工具链及时退回 Python 3.9 ~ 3.11 的 64 位版本最省事。mineblock.cpython-39.pyc是 Python 3.9 的缓存文件不影响运行可以直接忽略。5. 从小棋盘到大棋盘测参数边界、看 BFS 极限、随手打包成可执行文件5.1 把 9x9 扩展到 16x16 之前先看三个边界参数把ROWS调到 16、COLS调到 16、MINES调到 40对应的就是 Windows 扫雷的「中级」难度。这个改动表面上只改三个常量但真正的风险点在于棋盘像素尺寸要适配窗口高度。BLOCK_SIZE不变时16 列需要16 * 32 512像素宽配合左右边距窗口宽度至少 560。如果显示分辨率低窗口会超出屏幕边界。import pygame screen_width COLS * BLOCK_SIZE 2 * MARGIN screen_height ROWS * BLOCK_SIZE TOOLBAR_HEIGHT MARGIN screen pygame.display.set_mode((screen_width, screen_height))计算逻辑MARGIN是棋盘左右留白TOOLBAR_HEIGHT是顶部状态栏高度。只要窗口宽度按列数算而不是写死最小分辨率换难度就不需要动窗口代码。5.2 BFS 展开最坏情况的性能观测在 30x30、99 雷的专家级盘面上最坏情况是首击翻开一大片空白BFS 可能要处理 700 个格子。上面的队列实现在这个量级完全没有压力——比标记visited更重要的是deque.popleft()的 O(1) 复杂度如果用列表pop(0)300 个格子的弹出就是 O(n) 的列表搬移肉眼可见地卡顿。5.3 事件循环里的pygame.event.clear()解锁一段掉帧调试技巧如果遇到「翻开空格时连续展开导致界面卡顿」的疑似性能问题优先怀疑的不是算法而是事件队列积压。鼠标拖动、连续点击会在单帧内灌入大量MOUSEBUTTONDOWN和MOUSEMOTION事件Pygame 的事件队列默认深度 128超出后新事件会丢弃。调试时可以在主循环头部清空非关键事件pygame.event.set_blocked(pygame.MOUSEMOTION)作用说明set_blocked让MOUSEMOTION不再进入事件队列大幅减少主循环的事件遍历量对扫雷这种不依赖鼠标坐标反馈的游戏完全无损。若倒计时用的是pygame.time.get_ticks()不存在依赖事件驱动计时的问题。5.4 用 PyInstaller 打成单文件避免对方机器连 Python 都没有最后一招是发布层面。解压包给的是源码但如果要发给自己电脑上没有 Python 环境的同事可以用 PyInstaller 打包成单一可执行文件。重点是加入素材目录默认 PyInstaller 不会把 BMP 和 TTF 收集进去pyinstaller --onefile --add-data resources;resources main.py参数说明Windows 上--add-data的源和目标用分号分隔Linux/macOS 用冒号--onefile会生成单个 exe但首次启动会先自解压到临时目录如果杀毒软件拦截改成--onedir默认模式就正常。main.py里的os.path.join(resources, ...)在这个模式下读取的是解压临时路径需要改用sys._MEIPASS作前缀这是打包后最常见的启动崩溃原因def resource_path(relative_path): if hasattr(sys, _MEIPASS): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.dirname(__file__), relative_path)替换所有resources字符串为resource_path(resources)即可开发环境和打包后都能用同一份代码路径解析逻辑。本文还有配套的精品资源点击获取
返回列表