ARTICLE DETAIL

资讯详情

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

Python pygame俄罗斯方块实训:从环境搭建到核心机制实现

Python pygame俄罗斯方块实训:从环境搭建到核心机制实现 简介这份资源是面向计算机相关专业学生与Python初学者的课程实训项目包以经典俄罗斯方块小游戏为载体帮助读者掌握pygame图形界面开发、游戏循环、碰撞检测与事件响应等核心技能可直接用于课程设计、毕业设计、作业提交或项目立项演示。压缩包共6个文件约6.88MB包含py文件主程序、wav与mp3音效素材、txt分数记录、docx设计报告、md说明文档等覆盖源码、素材与文档全流程。目前已有46人学习下载。项目代码完整且经过测试功能稳定易复现设计报告可供参考学习音效与音乐资源开箱即用读者既能借鉴整体架构与实现思路也能在现有代码基础上修改扩展实现新玩法或功能适合小白进阶与有基础者二次开发。1. 从一份课程实训压缩包说起Python pygame 俄罗斯方块到底能练到什么很多人第一次接触游戏开发都是从一份「课程实训-基于 Python pygame 的经典俄罗斯方块小游戏开发」的压缩包开始的。它看起来只是个交作业用的东西但真正动手跑一遍就会发现这里面藏着游戏循环、事件处理、碰撞检测、矩阵旋转、消行判定这一整套最基础也最核心的机制。你不需要引擎不需要美术资源只要装好 Python 和 pygame就能在一个窗口里把「方块往下掉」这件事拆成几十行可调试的逻辑。它适合两类人一类是刚学完 Python 基础语法、想找个能看见结果的项目练手的新手另一类是想快速验证某个交互逻辑、又不想被 Unity 或 Godot 的工程结构拖住的老手。这一章先把这份实训资料里最值得抠出来的技术点讲清楚后面几章再一步步落到代码和参数上。2. 环境搭不起来后面全是空谈Python 与 pygame 的最小可跑通配置2.1 为什么我坚持用 venv 而不是全局装 pygame课程实训资料里通常会附带一份requirements.txt或者直接写明pip install pygame但如果你直接装在系统 Python 里后面一旦要换版本或者同时跑多个实训项目就会遇到「昨天还能跑今天 import pygame 就报错」的玄学问题。我一般会先建一个独立虚拟环境把 Python 版本和 pygame 版本锁死。Windows 下用python -m venv tetris_envmacOS 或 Linux 下用python3 -m venv tetris_env激活后再装 pygame。这样即使你把整个文件夹拷给同学只要对方也建了同样的环境跑起来的概率会高很多。# 创建虚拟环境目录名随意这里叫 tetris_env python -m venv tetris_env # Windows 激活 tetris_env\Scripts\activate # macOS / Linux 激活 source tetris_env/bin/activate # 安装 pygame建议指定一个稳定版本避免自动升级到不兼容的新版 pip install pygame2.5.2逻辑说明venv会把 Python 解释器和第三方库隔离在项目目录下activate之后所有pip install都只影响这个环境。参数上pygame2.5.2是我在多个实训项目里验证过比较稳的版本如果你用的是 Python 3.12 以上建议不要低于 2.5.0否则可能遇到pygame.display.set_mode返回空窗口的问题。装完之后用python -c import pygame; print(pygame.ver)验证一下能打印出版本号就说明环境通了。2.2 最小游戏窗口五行代码验证 pygame 是否真的在工作很多新手卡在「pygame.init() 之后窗口一闪而过」其实是因为没有写事件循环。下面这段代码是俄罗斯方块项目里最底层的骨架你先把它跑通再往上加方块逻辑。import pygame import sys pygame.init() screen pygame.display.set_mode((300, 600)) # 宽 300高 600对应 10 列 x 20 行的经典比例 pygame.display.set_caption(Tetris - 环境验证) clock pygame.time.Clock() while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() screen.fill((0, 0, 0)) # 黑色背景 pygame.display.flip() clock.tick(60) # 锁定 60 帧避免 CPU 空转逻辑说明set_mode((300, 600))里的宽高不是随便写的经典俄罗斯方块是 10 列 x 20 行每个格子 30 像素所以 300x600 刚好。clock.tick(60)控制主循环频率如果不加循环会跑满 CPU笔记本风扇会狂转。参数上fill((0,0,0))是 RGB 黑色后面画方块时每个格子用pygame.draw.rect填充不同颜色即可。这一步跑通说明你的 pygame 环境和显示驱动都没问题接下来才能进入方块数据结构的设计。3. 方块怎么表示、怎么转俄罗斯方块核心数据结构的选型与实现3.1 用二维列表还是坐标元组我选二维列表的理由俄罗斯方块有 7 种方块I、O、T、S、Z、J、L每种方块在不同旋转状态下形状不同。常见做法有两种一种是用四个坐标点表示另一种是用一个二维列表矩阵表示。课程实训资料里大多用二维列表因为旋转操作可以直接用zip(*matrix)或者手动转置来实现代码更直观。我一般会定义一个SHAPES字典每个键对应一个二维列表列表里 1 表示实心格子0 表示空。SHAPES { I: [[1, 1, 1, 1]], O: [[1, 1], [1, 1]], T: [[0, 1, 0], [1, 1, 1]], S: [[0, 1, 1], [1, 1, 0]], Z: [[1, 1, 0], [0, 1, 1]], J: [[1, 0, 0], [1, 1, 1]], L: [[0, 0, 1], [1, 1, 1]], }逻辑说明每个二维列表的行列数决定了方块占用的网格范围。I是 1x4O是 2x2其他大多是 2x3 或 3x2。参数上你不需要额外记录方块的宽高直接用len(matrix)和len(matrix[0])就能算出来。旋转的时候对矩阵做一次顺时针旋转就是list(zip(*matrix[::-1]))但要注意zip返回的是元组需要转成列表才能继续用。3.2 旋转的边界处理为什么你的方块会穿墙旋转本身不难难的是旋转之后如果超出左右边界或者撞到已有方块怎么办。我见过太多实训代码在这里翻车方块在墙边一旋转就直接嵌进墙里或者和堆叠的方块重叠。常见做法是「尝试旋转如果碰撞就回退」也就是先保存当前形状旋转后检测碰撞如果碰撞就恢复原状。下面是一个可复用的旋转函数和碰撞检测函数。def rotate(matrix): 顺时针旋转 90 度 return [list(row) for row in zip(*matrix[::-1])] def check_collision(board, shape, offset): board: 20x10 的二维列表0 为空非 0 为已固定方块 shape: 当前方块矩阵 offset: (x, y) 当前方块左上角在 board 中的位置 off_x, off_y offset for y, row in enumerate(shape): for x, cell in enumerate(row): if cell: board_x off_x x board_y off_y y if board_x 0 or board_x 10 or board_y 20: return True if board_y 0 and board[board_y][board_x]: return True return False逻辑说明rotate先用matrix[::-1]把行倒序再zip(*...)转置得到顺时针旋转结果。check_collision遍历方块每个实心格子换算成 board 坐标后判断是否越界或与已有方块重叠。参数上offset的y可能为负数表示方块刚生成时有一部分在屏幕上方所以判断board_y 0才检查 board 内容。这个函数在左右移动、下落、旋转时都要调用是整套逻辑里最频繁的守卫。3.3 消行判定与分数计算一行代码写错整局游戏就废了消行是俄罗斯方块里最让人有成就感的部分但也是最容易写出 bug 的地方。常见错误是「消行后没有把上面的行下移」导致中间出现空行。正确做法是从下往上遍历遇到满行就删除并在顶部插入一个空行。def clear_lines(board): 返回消除的行数和新的 board lines_cleared 0 new_board [row[:] for row in board] # 深拷贝避免修改原 board y 19 while y 0: if all(new_board[y]): # 该行全部非 0 del new_board[y] new_board.insert(0, [0] * 10) lines_cleared 1 # 不递减 y因为删除后当前索引已经是新的一行 else: y - 1 return lines_cleared, new_board逻辑说明all(new_board[y])判断一行是否全部为真非 0。删除后insert(0, [0]*10)在顶部补空行保持 board 始终 20 行。参数上lines_cleared可以用来算分常见规则是消 1 行 100 分2 行 300 分3 行 500 分4 行 800 分。注意这里用while而不是for因为删除行之后索引会变化for循环容易漏掉连续满行的情况。4. 主循环与事件驱动把下落、移动、旋转串成一条时间线4.1 下落计时器为什么不能用 sleep很多新手会想用time.sleep(0.5)来控制方块下落速度但这样会阻塞整个游戏循环导致按键没反应、窗口卡死。正确做法是用pygame.time.get_ticks()或者clock.tick()配合一个累加器。我一般会在主循环里维护一个fall_time变量每帧加上clock.tick(60)的返回值超过阈值就下落一格。fall_time 0 fall_speed 500 # 毫秒数值越小下落越快 while True: dt clock.tick(60) # 返回上一帧到现在的毫秒数 fall_time dt if fall_time fall_speed: fall_time 0 # 尝试下落一格 if not check_collision(board, current_shape, (current_x, current_y 1)): current_y 1 else: # 固定方块到 board检查消行生成新方块 lock_piece(board, current_shape, current_x, current_y) lines, board clear_lines(board) score calculate_score(lines) current_shape, current_x, current_y spawn_new_piece()逻辑说明clock.tick(60)既控制帧率又返回时间差fall_time累加达到fall_speed就触发一次下落。参数上fall_speed500是中等难度想加难度可以降到 200 甚至 100。注意lock_piece要把当前方块写入 board然后才能消行和生成新方块顺序不能乱。4.2 键盘事件左右移动、旋转、硬降的键位映射pygame 的键盘事件用pygame.KEYDOWN捕获常见键位是方向键左右移动、上旋转、下加速、空格硬降。我一般会在事件循环里用event.key判断而不是用pygame.key.get_pressed()因为后者会导致按住键时连续触发太快不好控制。for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() if event.type pygame.KEYDOWN: if event.key pygame.K_LEFT: if not check_collision(board, current_shape, (current_x - 1, current_y)): current_x - 1 elif event.key pygame.K_RIGHT: if not check_collision(board, current_shape, (current_x 1, current_y)): current_x 1 elif event.key pygame.K_UP: rotated rotate(current_shape) if not check_collision(board, rotated, (current_x, current_y)): current_shape rotated elif event.key pygame.K_DOWN: if not check_collision(board, current_shape, (current_x, current_y 1)): current_y 1 elif event.key pygame.K_SPACE: while not check_collision(board, current_shape, (current_x, current_y 1)): current_y 1逻辑说明每次移动或旋转前都调用check_collision只有不碰撞才更新坐标或形状。空格硬降用while一直下落到底然后主循环里的下落计时器会触发固定。参数上pygame.K_UP对应上箭头如果你想让 W 键也能旋转加一个or event.key pygame.K_w即可。4.3 渲染顺序先画背景、再画已固定方块、最后画当前方块渲染顺序错了会出现「当前方块被已固定方块盖住」或者「消行后残影」。正确顺序是填充背景色、画网格线可选、画 board 里所有非 0 格子、画当前方块、刷新显示。每个格子用pygame.draw.rect(screen, color, (x*30, y*30, 30, 30))绘制颜色可以根据方块类型从字典里取。COLORS { I: (0, 255, 255), O: (255, 255, 0), T: (128, 0, 128), S: (0, 255, 0), Z: (255, 0, 0), J: (0, 0, 255), L: (255, 165, 0), } def draw_board(screen, board): for y, row in enumerate(board): for x, cell in enumerate(row): if cell: pygame.draw.rect(screen, COLORS.get(cell, (255, 255, 255)), (x*30, y*30, 30, 30)) def draw_piece(screen, shape, offset, color): off_x, off_y offset for y, row in enumerate(shape): for x, cell in enumerate(row): if cell: pygame.draw.rect(screen, color, ((off_x x)*30, (off_y y)*30, 30, 30))逻辑说明draw_board遍历整个 boarddraw_piece只画当前方块。参数上30是格子像素大小和set_mode((300, 600))对应。颜色字典的键要和SHAPES的键一致这样lock_piece时把方块类型写入 board渲染时就能自动取到对应颜色。5. 避坑与排查实训代码里最容易翻车的 5 个地方5.1 现象窗口打开后无响应点关闭按钮没反应原因事件循环里没有处理pygame.QUIT或者pygame.event.get()被其他逻辑阻塞。解决确保主循环每帧都调用pygame.event.get()并且在QUIT事件里调用pygame.quit()和sys.exit()。如果你用了time.sleep立刻删掉换成clock.tick()。5.2 现象方块旋转后位置偏移甚至跑到屏幕外原因旋转函数没有处理矩阵转置后的行列变化或者旋转后没有重新计算 offset。解决旋转后先用check_collision检测如果碰撞就尝试左右微调wall kick微调失败就放弃旋转。常见做法是依次尝试(x, y)、(x-1, y)、(x1, y)、(x-2, y)、(x2, y)。5.3 现象消行后分数加了但方块堆叠错位原因clear_lines里用了浅拷贝删除行时影响了原 board或者insert的位置不对。解决用[row[:] for row in board]做深拷贝删除后始终在索引 0 插入空行。另外注意while循环里删除行后不要递减y否则会跳过下一行。5.4 现象游戏越玩越卡帧率明显下降原因每帧都在创建新的 Surface 或者重复加载图片、字体。解决把字体、颜色、形状矩阵这些不变的对象在循环外初始化循环内只做绘制和事件处理。如果你用了pygame.font.Font不要在循环里反复Font()。5.5 现象打包成 exe 后运行报错提示找不到 pygame原因PyInstaller 没有把 pygame 的资源文件打进去或者虚拟环境里装的 pygame 和打包时用的不是同一个。解决用pyinstaller -F --add-data tetris_env/Lib/site-packages/pygame;pygame main.py显式指定 pygame 目录或者直接用--collect-all pygame。打包前先在干净虚拟环境里跑一遍确认没有缺失依赖。6. 从能跑到好用给俄罗斯方块加一个「下一个方块预览」和难度曲线6.1 下一个方块预览的实现思路预览区通常放在主窗口右侧宽度 120 像素左右。你需要维护一个next_shape变量在当前方块固定后把next_shape赋给current_shape再随机生成一个新的next_shape。渲染时在预览区画一个 4x4 的网格把next_shape居中画进去。注意预览区的坐标原点和主 board 不同需要单独计算偏移。def draw_next_piece(screen, shape, start_x320, start_y50): 在右侧预览区绘制下一个方块 for y, row in enumerate(shape): for x, cell in enumerate(row): if cell: pygame.draw.rect(screen, (255, 255, 255), (start_x x*30, start_y y*30, 30, 30))逻辑说明start_x320是因为主窗口宽 300右侧留 120 像素预览区从 320 开始画比较居中。start_y50是给标题留空间。参数上你可以根据next_shape的实际宽高动态调整start_x让方块在预览区居中而不是固定左上角。6.2 难度曲线每消 10 行提速一次固定下落速度玩久了会无聊加一个简单的难度曲线能明显提升体验。我一般会在消行后判断总消行数每满 10 行就把fall_speed减少 50 毫秒最低不低于 100 毫秒。total_lines 0 fall_speed 500 # 在消行后更新 total_lines lines_cleared if total_lines // 10 (total_lines - lines_cleared) // 10: fall_speed max(100, fall_speed - 50)逻辑说明total_lines // 10计算当前达到的「十行档位」如果这次消行后档位比之前高就提速。max(100, ...)防止速度降到 0 导致方块瞬间落地。参数上50是每次提速的幅度你可以改成 30 或 80根据自己手感调。6.3 一个我踩过的坑预览区方块颜色和主区不一致早期我图省事预览区直接用白色画所有方块结果玩家分不清下一个是 I 还是 O。后来改成从COLORS字典里按方块类型取色但next_shape只存了矩阵没存类型。解决办法是同时维护next_type变量生成新方块时把类型也记下来。这个改动很小但体验提升很明显。# 生成新方块时 next_type random.choice(list(SHAPES.keys())) next_shape SHAPES[next_type] # 预览时 draw_next_piece(screen, next_shape, COLORS[next_type])逻辑说明random.choice(list(SHAPES.keys()))从 7 种方块里等概率选一个。参数上如果你想让某种方块出现概率低一点可以用random.choices配合权重但经典俄罗斯方块是等概率的不建议改。6.4 验证方法用固定随机种子复现同一局调试的时候每次随机方块不一样很难定位问题。我一般会在开头加random.seed(42)这样每次运行生成的方块序列完全一致。等你确认逻辑没问题了再把种子去掉或者改成random.seed()用系统时间。这个技巧在排查「为什么第 37 个方块必现碰撞 bug」时特别有用省得你反复重开游戏碰运气。import random random.seed(42) # 调试时固定序列上线前删掉或改成 random.seed()逻辑说明random.seed(42)让随机数生成器从固定状态开始后续所有random.choice结果可预测。参数上42 只是习惯用法任何整数都行。注意如果你用了random.shuffle或者其他随机函数种子同样会影响它们。这套东西我从第一次照着实训资料跑通到后来自己重写了好几遍最大的体会是俄罗斯方块看起来简单但每一个边界条件都能让你调半天。我现在的习惯是先把碰撞检测和消行逻辑用固定种子跑 100 局确认没有数组越界和死循环再去调手感和界面。希望帮到你。本文还有配套的精品资源点击获取
返回列表