ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用pygame小游戏练Python工程能力:游戏循环、碰撞检测与GitHub发布

用pygame小游戏练Python工程能力:游戏循环、碰撞检测与GitHub发布

很多人学完 Python 的变量、循环、函数之后,会卡在一个很尴尬的位置:语法看得懂,但不知道拿它做什么。教程里推荐的爬虫、数据分析、Web 后端当然有价值,可它们的反馈链条太长——装依赖、配数据库、起服务、调接口,一路走下来,往往还没看到“我做的东西动起来了”,热情就先耗光了。相比之下,写一个能跑起来的小游戏,是投入产出比极高的选择:一个周末,两三百行代码,你就能拥有一个会动、会死、还能重开的完整程序。

标题里我用“劣质”两个字,并不是在自嘲式地降低标准,而是一个明确的工程判断。原型阶段追求“能跑”远比追求“好”重要,因为反馈速度决定了你的迭代次数,而迭代次数决定了你最终能做出什么。一个画面粗糙、音效缺失、玩法单一但完整可运行的小游戏,比一个构思精妙却永远停在设计文档里的“大作”有价值得多。它能让你第一次完整体验“想法 → 代码 → 运行 → 打包 → 上传 → 别人克隆下来能跑”的整条链路。

这篇文章要带你走通的就是这条链路。技术栈选 pygame,因为它足够轻,没有引擎那套场景树、组件系统、编辑器界面的心智负担,你写的每一行代码都能直接对应到屏幕上的一个像素变化。读完之后,你应该能独立完成三件事:一是在本地跑起一个 pygame 项目并理解它的运行机制;二是掌握游戏循环、碰撞检测、帧率控制这几个最容易踩坑的点;三是把代码整理成别人愿意看一眼、并且能一键跑起来的 GitHub 仓库。

更重要的是,我希望你能带走一个判断:小游戏是 Python 初学者最合适的练手项目形态。它同时具备即时反馈、边界清晰、可无限迭代这三个特点,而这些特点恰好是刻意练习所需要的全部条件。你不需要一开始就设计好完整架构,先让它转起来,再一块一块换掉不满意的地方,这个过程中长出来的工程直觉,比看十篇架构文章都实在。

1. 这篇文章真正要解决的问题

先把这个话题里最常见的误区说清楚。很多人觉得“写小游戏”是玩具,跟工程能力没关系,所以要么不屑于做,要么做了也随手丢在某个文件夹里烂掉。但如果你把视角稍微挪一下,会发现小游戏其实是一个被严重低估的训练场:它把软件工程里最难获取的东西——快速、真实、可观测的反馈——压缩到了几分钟的循环里。

想想看,你改一行 Web 接口的代码,要验证它是否正确,可能需要启动服务、构造请求、看日志、查数据库,一轮下来五六分钟。而在游戏里改一个数字,刷新窗口,你立刻就知道手感变没变好。这种“改一下、看一眼”的节奏,是培养调试直觉最有效的方式。你在游戏里学会的定位问题的方法——画面不对看渲染、动作不对看更新、按了没反应看事件——几乎可以原封不动地迁移到任何交互式系统的排查上。

第二个被低估的价值是“边界感”。小游戏的需求边界天然清晰:玩家能动、障碍物会掉、碰到就结束、分数会涨。这种清晰让你可以把注意力从“需求到底是什么”转移到“我该怎么实现它”,而这恰恰是初学者最缺的练习。你在公司里做业务需求,需求本身经常是模糊的、变动的,你很难判断问题出在理解还是实现上;但在小游戏里,问题几乎永远出在实现上,责任明确,进步也就明确。

第三个价值,也是这篇文章后半部分要重点处理的:把它放到 GitHub 上。写代码和发布代码是两件事。很多人写了大量本地脚本,却从来没经历过“让别人能跑起来”这个环节,于是完全意识不到自己在路径、依赖、编码、配置这些地方留下了多少坑。而一旦你把代码推上去,并且要求自己写的 README 能让一个陌生人三分钟内跑起来,你的工程标准会在一个下午之内被强行拉高一个档次。

那么这篇文章适合谁?如果你是刚学完 Python 基础语法、想找点能动手的东西的初学者,这篇文章可以从头照着做;如果你已经会写业务代码,但从没碰过实时循环类的程序,这篇里关于帧率、时间步长、状态机的部分可能会给你一些新角度;如果你是带新人的开发者,这里面的项目结构和排查清单可以直接拿去当练习任务。反过来,如果你想要的是做一个能上架的商业游戏,或者想学 Unity、Godot 这类完整引擎,这篇文章不是你要找的东西。

2. 先搞懂 pygame 的游戏循环,否则后面全是玄学

在动手之前,有一个概念必须先立住,否则后面所有“为什么画面不动”“为什么按键没反应”“为什么 CPU 跑满”的问题,你都只能靠猜。这个概念就是游戏循环。用一句话概括:游戏程序的本质,是一个不停重复执行“处理输入、更新状态、绘制画面”的无限循环,直到用户关闭窗口为止。

这跟我们熟悉的 Web 请求-响应模型有本质区别。Web 后端是“来一个请求,处理一次,返回响应,然后线程去干别的”,它是被动的、事件驱动的、生命周期由外部请求决定的。游戏是主动的,它不管你按不按键,每秒都要把这个世界往前推进 60 次。这意味着即使玩家什么都不做,你的update函数依然会被调用,你的障碍物依然会往下掉,你的分数依然在计算。理解这一点,你就不会写出“以为不按键程序就暂停了”的代码。

在这个循环里,有四件事依次发生:第一是事件处理,从 pygame 的事件队列里把这一帧积累的所有事件取出来,包括窗口关闭、按键按下、鼠标移动、窗口尺寸变化等;第二是状态更新,根据当前输入和上一帧的状态,计算出这一帧的新状态,比如玩家位置、障碍物位置、分数、是否碰撞;第三是渲染,把当前状态画到屏幕缓冲区;第四是帧率控制,通过时钟对象让循环以一个稳定的节奏运行,而不是尽最大努力空转。

2.1 事件处理:为什么按键要用 get_pressed 而不是 KEYDOWN

这里是新手最容易混淆的地方,值得单独说清楚。pygame 提供了两套读取键盘输入的方式,用途完全不同。第一套是事件队列里的KEYDOWN和KEYUP事件,它记录的是“状态发生变化的那一瞬间”。按下 A 键,队列里出现一条KEYDOWN,如果你一直按住不放,操作系统会按自己的重复速率持续发送KEYDOWN,这个速率是系统设置决定的,跟你代码里的帧率毫无关系。

第二套是pygame.key.get_pressed(),它返回的是当前这一帧键盘的实时状态快照:某个键现在是不是按着的,就是是不是按着的。对于“角色持续移动”这种需求,你必须用第二套。如果你用KEYDOWN事件来移动角色,会得到一个非常奇怪的手感:按住方向键,角色一卡一卡地往前挪,松开就停,而且移动速度取决于你的系统键盘重复率设置,换一台电脑就变成另一个手感。

反过来,对于“按一次触发一次”的操作,比如暂停、重开、退出,你必须用KEYDOWN事件。如果你用get_pressed()来判断暂停,按住空格不放,游戏会在一秒钟内疯狂切换暂停/继续几十次,效果就是完全没有暂停。这个区别看起来很小,但它是“操作手感”这类模糊评价的技术根源。

2.2 帧率控制:为什么不能写 while True: pass

另一个必须理解的点是时钟。如果你的主循环里什么都不控制,它会以 CPU 能跑多快就跑多快的速度执行,可能一秒跑几千次。这带来两个问题:一是白白吃满一个 CPU 核心,笔记本风扇立刻起飞;二是游戏速度完全不可控,在性能好的机器上障碍物飞一样往下掉,在性能差的机器上慢得像放幻灯片,同一个游戏在不同电脑上是两种体验。

clock.tick(FPS)的作用就是把循环节奏锁死。它返回一个数值:距离上一次调用过去了多少毫秒。这个返回值非常关键,它是你实现“帧率无关逻辑”的基础。举例来说,如果你写“每帧障碍物下移 4 像素”,那么在 60 帧的机器上它一秒下移 240 像素,在 30 帧的机器上只有 120 像素。而如果你写“每毫秒下移 0.06 像素”,再乘以tick返回的毫秒数,那么在两种机器上的实际速度就是一致的。入门阶段用固定像素是可以接受的,但你要知道这是有代价的简化。

理解了这两点,游戏循环就不再是黑箱。你可以把它想象成一个每秒钟转 60 圈的飞轮,每一圈都做同样四件事,只是每一圈处理的数据不一样。剩下的所有代码,本质上都是在这四个环节里填充具体逻辑。

3. 环境准备与项目结构

动手之前先确认环境。Python 版本以你本机实际安装的为准,本文的代码只用到标准库和 pygame 的常规 API,不依赖特定小版本特性,所以不必纠结于 3.8 还是 3.11。真正需要注意的是解释器隔离:请务必在虚拟环境里安装依赖,不要直接往系统 Python 里装,否则以后不同项目之间的版本冲突会让你非常痛苦。

创建虚拟环境并安装依赖的命令如下,Windows 和 macOS/Linux 略有差异,但venv模块的用法是一致的。

# 文件路径:项目根目录执行 # 1. 进入项目目录 cd dodge-blocks # 2. 创建虚拟环境 python -m venv .venv # 3. 激活虚拟环境 # Windows (PowerShell) .venv\Scripts\Activate.ps1 # Windows (CMD) .venv\Scripts\activate.bat # macOS / Linux source .venv/bin/activate # 4. 安装依赖 pip install pygame # 5. 验证安装是否成功 python -c "import pygame; print(pygame.version.ver)"

最后一行如果能打印出版本号,说明安装是对的。如果这里报ModuleNotFoundError,多半不是安装失败,而是你激活虚拟环境的终端和运行脚本的终端不是同一个,或者 IDE 里配置的解释器还指向系统 Python。这个问题在后面的排查章节还会展开,因为它是最常见的“明明装了却用不了”。

关于依赖声明,我建议养成一个习惯:不要只pip install,同时把依赖写进requirements.txt。这个文件是别人能否复现你环境的关键,它的价值远超你的想象。版本约束建议写成范围而不是死锁单点版本,给兼容性留一点余地。

# 文件路径:requirements.txt pygame>=2.0,<3.0

项目结构方面,入门阶段我强烈建议你先写单文件。很多教程一上来就教你分层的目录结构,entities/、scenes/、systems/一大堆包,结果初学者把时间全花在纠结“这个类该放哪个文件夹”上,游戏本身反而没写完。正确的顺序是先让一个文件跑通全部逻辑,等你确实感受到“这个文件太长了、找东西很痛苦”的时候,再去拆。那种从痛点出发的重构,比照着模板抄目录有意义得多。

等单文件版本跑通了,推荐的拆分方式是这样:把常量配置抽到settings.py,把玩家和障碍物这两个实体类抽到entities.py,把游戏主控类留在main.py。这种拆分依据的是“变化频率”——数值最常改,所以单独放;实体逻辑中等,放一起;主控流程最稳定,留最后。按变化频率分层,比按技术名词分层实用得多。

# 文件路径:game/settings.py # 所有可调参数集中在此,改数值不需要翻代码 SCREEN_WIDTH = 480 SCREEN_HEIGHT = 640 FPS = 60 BG_COLOR = (24, 26, 32) PLAYER_COLOR = (90, 200, 250) BLOCK_COLOR = (240, 96, 96) TEXT_COLOR = (235, 235, 235) PLAYER_WIDTH = 48 PLAYER_HEIGHT = 24 PLAYER_SPEED = 6 BLOCK_SIZE = 36 BLOCK_SPEED = 4 SPAWN_INTERVAL_MS = 700

把颜色、尺寸、速度这些数字抽出来的好处,不只是“好改”。它还有一个隐性收益:当你把魔法数字命名之后,你会被迫想清楚每个数字代表什么,这个过程本身就能帮你发现设计上的模糊之处。比如你会发现“障碍物速度”和“玩家速度”应该是两个独立概念,而不是随手写的 4 和 6。

4. 最小可运行骨架:让窗口先亮起来

在写任何玩法之前,先写一个只有窗口和退出逻辑的最小骨架。这一步看起来毫无产出,实际上是排错效率最高的一步:如果这个骨架都跑不起来,那问题一定在环境而不是在你的游戏逻辑,排查范围立刻缩小十倍。很多人在一个几百行的完整代码里排查“窗口不显示”,那才是真正的灾难。

# 文件路径:game/skeleton.py import sys import pygame SCREEN_WIDTH = 480 SCREEN_HEIGHT = 640 FPS = 60 pygame.init() screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("最小骨架") clock = pygame.time.Clock() running = True while running: # tick 返回距上一帧的毫秒数,后面做帧率无关逻辑会用到 dt = clock.tick(FPS) for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: running = False screen.fill((24, 26, 32)) pygame.display.flip() pygame.quit() sys.exit()

这段代码里有三个地方值得反复确认。第一,pygame.init()必须在set_mode之前调用,它负责初始化显示、字体、音频等子系统;漏掉它,后面用字体渲染文字时会报“字体模块未初始化”,而错误信息往往指向字体那一行,容易误导你以为是字体的问题。第二,pygame.display.flip()是真正把缓冲区内容推到屏幕上的那一步,如果没有它,你所有的绘制都发生在内存里,屏幕上永远是那块初始的黑色。第三,退出时必须调用pygame.quit(),否则在某些系统上窗口关闭后进程仍会残留。

运行这个骨架,你应该看到一个纯深色背景、标题为“最小骨架”的窗口,按 ESC 或点关闭按钮都能干净退出。如果窗口一闪而过,说明主循环没进去或者立刻退出了;如果窗口出现但标题栏显示“无响应”,说明你没有持续调用事件处理,系统认为这个程序卡死了。把这两种情况记住,它们是后面所有交互问题的判断基准。

5. 核心玩法拆解与完整代码实现

玩法我选了一个极简版本:屏幕底部有一个挡板,玩家用左右方向键控制它移动,上方会不断掉落方块,躲开得分,撞上结束,按空格重开。这个玩法足够简单,总共不到一百五十行,但它完整覆盖了游戏开发里最核心的四个机制:实体、生成、碰撞、状态切换。把这四个机制吃透,换成贪吃蛇、打砖块、飞机大战,思路是完全一样的。

先说实体设计。我把玩家和障碍物各写成一个类,每个类只做三件事:保存自己的矩形区域、更新自己的位置、把自己画出来。这里有个关键选择:用pygame.Rect而不是自己维护 x/y/width/height 四个变量。原因是Rect自带colliderect碰撞检测、自带边界对齐属性,还支持整数化处理,能帮你避开大量浮点数导致的偏移问题。新手常见的“明明没碰到就死了”,八成就是因为碰撞区域和绘制区域不一致,而用Rect至少有唯一的事实来源。

再说生成逻辑。障碍物的生成不能用固定帧数计数,因为帧率会浮动。我用累加毫秒数的方式:每帧把dt加到计时器上,超过阈值就生成一个并清零。这种写法在帧率波动时依然稳定,不会出现“卡一下突然冒出三个方块”的情况。同时我给速度加了一个随分数缓慢增长的公式,让游戏有一点难度曲线,不至于玩十分钟都是同一个节奏。

碰撞检测放在更新阶段的最后。这里要注意顺序:必须先更新所有方块的位置,再统一做碰撞判断,而不是边更新边判断。否则会出现“上一帧还没碰到、这一帧已经穿过去”的漏检,尤其在方块速度快的时候非常明显。这是实时系统里典型的时序问题,理解了它,你以后写任何基于轮询的状态检测都会更小心。

下面是完整的单文件实现,可以直接复制运行。

# 文件路径:game/main.py import random import sys import pygame # ---------------- 基础配置 ---------------- SCREEN_WIDTH = 480 SCREEN_HEIGHT = 640 FPS = 60 BG_COLOR = (24, 26, 32) PLAYER_COLOR = (90, 200, 250) BLOCK_COLOR = (240, 96, 96) TEXT_COLOR = (235, 235, 235) PLAYER_WIDTH = 48 PLAYER_HEIGHT = 24 PLAYER_SPEED = 6 BLOCK_SIZE = 36 BLOCK_SPEED = 4 SPAWN_INTERVAL_MS = 700 class Player: """玩家控制的挡板""" def __init__(self): self.rect = pygame.Rect(0, 0, PLAYER_WIDTH, PLAYER_HEIGHT) self.rect.centerx = SCREEN_WIDTH // 2 self.rect.bottom = SCREEN_HEIGHT - 32 def move(self, dx): self.rect.x += dx # 限制在窗口内,避免跑出边界 if self.rect.left < 0: self.rect.left = 0 if self.rect.right > SCREEN_WIDTH: self.rect.right = SCREEN_WIDTH def draw(self, surface): pygame.draw.rect(surface, PLAYER_COLOR, self.rect, border_radius=6) class Block: """从上方落下的障碍物""" def __init__(self, x, y, speed): self.rect = pygame.Rect(x, y, BLOCK_SIZE, BLOCK_SIZE) self.speed = speed def update(self): self.rect.y += self.speed def draw(self, surface): pygame.draw.rect(surface, BLOCK_COLOR, self.rect, border_radius=4) def is_out_of_screen(self): return self.rect.top > SCREEN_HEIGHT class Game: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Dodge Blocks - 一个劣质小游戏") self.clock = pygame.time.Clock() self.font = pygame.font.SysFont(None, 26) self.big_font = pygame.font.SysFont(None, 44) self.reset() def reset(self): """重置一局游戏的全部状态""" self.player = Player() self.blocks = [] self.score = 0 self.spawn_timer = 0 self.running = True self.game_over = False def handle_events(self): for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.running = False elif event.key == pygame.K_SPACE and self.game_over: self.reset() def spawn_block(self): x = random.randint(0, SCREEN_WIDTH - BLOCK_SIZE) # 分数越高,下落越快,形成难度曲线 speed = BLOCK_SPEED + self.score // 10 self.blocks.append(Block(x, -BLOCK_SIZE, speed)) def update(self, dt_ms): # 1. 处理连续按键输入 keys = pygame.key.get_pressed() if keys[pygame.K_LEFT]: self.player.move(-PLAYER_SPEED) if keys[pygame.K_RIGHT]: self.player.move(PLAYER_SPEED) # 2. 按时间累加生成障碍物,与帧率解耦 self.spawn_timer += dt_ms if self.spawn_timer >= SPAWN_INTERVAL_MS: self.spawn_timer = 0 self.spawn_block() # 3. 统一更新位置 for block in self.blocks: block.update() # 4. 越过底部的方块计分并移除 passed = [b for b in self.blocks if b.is_out_of_screen()] self.score += len(passed) self.blocks = [b for b in self.blocks if not b.is_out_of_screen()] # 5. 所有位置更新完毕后再统一判定碰撞 for block in self.blocks: if self.player.rect.colliderect(block.rect): self.game_over = True break def draw(self): self.screen.fill(BG_COLOR) for block in self.blocks: block.draw(self.screen) self.player.draw(self.screen) score_text = self.font.render(f"Score: {self.score}", True, TEXT_COLOR) self.screen.blit(score_text, (16, 16)) if self.game_over: over_text = self.big_font.render("Game Over", True, TEXT_COLOR) over_rect = over_text.get_rect( center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 - 20) ) self.screen.blit(over_text, over_rect) tip_text = self.font.render( "Press SPACE to restart, ESC to quit", True, TEXT_COLOR ) tip_rect = tip_text.get_rect( center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2 + 32) ) self.screen.blit(tip_text, tip_rect) pygame.display.flip() def run(self): while self.running: dt_ms = self.clock.tick(FPS) self.handle_events() if not self.game_over: self.update(dt_ms) self.draw() pygame.quit() sys.exit() if __name__ == "__main__": Game().run()

代码里有几个设计点需要解释,否则你抄下来也只是“能跑但不知道为什么这么写”。

第一,reset()方法被抽出来单独存在,构造函数里也调用它。这样做的直接好处是重开游戏不需要退出再启动,只需要把状态清空。它不是可有可无的重构,而是状态管理的正确姿势:凡是能被重置的东西,都应该有一个明确的重置入口。以后你要加“最高分”这种需要跨局保留的数据,就会发现把它放在reset()外面天然正确,不会一重开就被清掉。

第二,update里的注释编号对应的是执行顺序,这个顺序不是随手排的。先读输入再更新位置,是保证操作跟手;先生成再统一更新,是保证同一帧内所有实体处于同一个时间点;先计分清理再判碰撞,是避免已经被删掉的对象还参与判定。这类顺序问题在实时系统里极其常见,写的时候顺手标注一下,未来改代码能省很多时间。

第三,game_over状态的处理方式值得注意。我没有在游戏结束时跳出主循环,而是让循环继续跑,只是跳过update。这样做是因为你还需要draw来显示结束画面,还需要handle_events来接收重开指令。如果把循环停了,画面会定格在最后一帧,窗口无响应,玩家只能强杀进程。用状态位控制行为,而不是用循环控制生命周期,这是游戏状态机最朴素的形态。

6. 运行结果与效果验证

代码写完之后,从项目根目录运行:

python game/main.py

正常情况下你应该看到一个 480x640 的窗口,标题是“Dodge Blocks - 一个劣质小游戏”,左上角显示Score: 0。屏幕底部中央有一个蓝色圆角矩形挡板,大概半秒后开始有红色方块从顶部随机位置落下。按住左右方向键,挡板平滑移动,撞到窗口边缘会停住而不是穿出去。成功躲过一个方块,左上角分数加一。被砸中后,屏幕中央出现“Game Over”和重开提示,此时挡板不再响应方向键,按空格可以立刻开始新的一局。

验证是否真的成功,建议按这个顺序检查,每一项都对应一个独立的机制。第一,窗口能否正常关闭:点击标题栏的关闭按钮,程序应该立刻退出,任务管理器里没有残留进程。如果关不掉,说明事件处理有问题。第二,按键是否跟手:连续按住右方向键,挡板应该匀速右移,直到贴住右边就不再动。如果移动一卡一卡,说明你误用了KEYDOWN。第三,分数是否只在方块完全离开屏幕后才增加:观察即将落到底部的方块,分数不应该在它刚接触底部边缘时就跳变。第四,重开是否彻底:撞死之后按空格,分数必须回到 0,屏幕上的方块必须全部清空,不能残留上一局的障碍物。

如果哪一步不符合预期,第一件事不是改代码,而是定位到对应的环节。窗口类问题看display相关调用;按键问题看输入读取方式;位置和计分问题看update的执行顺序;残留状态问题看reset是否漏了字段。这个“按现象定位环节”的思路,比盲目地在代码里加 print 高效得多。

想要更直观地看到帧率和时间步长的关系,可以在主循环里临时加一行日志,观察每帧耗时:

# 文件路径:game/main.py(临时调试用,验证完记得删掉) def run(self): frame_count = 0 while self.running: dt_ms = self.clock.tick(FPS) frame_count += 1 if frame_count % 60 == 0: print(f"第 {frame_count} 帧,本帧耗时 {dt_ms} ms,当前方块数 {len(self.blocks)}") self.handle_events() if not self.game_over: self.update(dt_ms) self.draw()

如果你看到dt_ms稳定在 16 到 17 之间,说明 60 帧跑满了;如果它长期在 30 以上,说明有性能问题或者机器本身吃力。方块数量持续增长而分数不涨,说明清理逻辑没生效,需要检查is_out_of_screen的判断条件。

7. 上传到 GitHub:把“劣质”变成可展示的资产

代码能跑之后,接下来的这一步才是真正拉开差距的地方。同样一个劣质小游戏,丢在本地文件夹里,它是一个练习;放到 GitHub 上并且配好说明,它是一个作品。这两者的差别不在于代码质量,而在于你有没有完成“让别人能跑起来”这个额外的工程动作。

第一步是加.gitignore。这一步千万别跳过,因为虚拟环境目录动辄几十兆、上千个文件,一旦提交进去,你的仓库会变得又大又乱,而且这些文件还会污染后续所有git status的输出,让你看不清真正的改动。

# 文件路径:.gitignore __pycache__/ *.py[cod] .venv/ venv/ env/ build/ dist/ *.spec .idea/ .vscode/ .DS_Store Thumbs.db

第二步是写README.md。判断一个仓库值不值得看,多数人只花十秒钟,而这十秒钟基本都花在 README 上。一个合格的 README 不需要华丽,但必须回答四个问题:这是什么、怎么装、怎么跑、怎么操作。如果你的 README 只写了“我的小游戏”,那它对陌生人来说等于不存在。

# Dodge Blocks 一个用 Python + pygame 写的极简躲避小游戏,用于练习游戏循环、碰撞检测与状态管理。 ## 环境要求 - Python 3.x - pygame 2.x ## 安装与运行 ```bash python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate pip install -r requirements.txt python game/main.py

操作说明

按键功能
← / →左右移动挡板
空格游戏结束后重新开始
ESC退出游戏

玩法

方块从顶部随机位置落下,躲开得分,撞上结束。分数越高,方块下落速度越快。

第三步是初始化仓库并推送。这里有几个细节值得注意:分支名建议直接用 `main` 而不是历史遗留的 `master`;首次提交的信息要写清楚做了什么,而不是 `update` 或 `first commit` 这种等于没写的内容;如果远程仓库创建时勾选了自动生成 README,你的首次推送会因为历史不一致被拒绝,那就需要先合并。 ```bash # 文件路径:项目根目录执行 git init git add . git commit -m "feat: 初始化躲避方块小游戏,包含核心玩法与说明文档" git branch -M main git remote add origin https://github.com/<your-name>/dodge-blocks.git git push -u origin main

如果推送时提示rejected,说明远程已有提交,先执行git pull --rebase origin main把远程内容合过来,再推一次。如果提示认证失败,说明你的凭据已失效,需要重新配置访问方式,不要反复重试同一个命令。

最后一步,也是最能体现专业度的一步:验证“陌生人能否跑起来”。最可靠的做法是把仓库重新克隆到一个全新目录,用全新的虚拟环境走一遍 README 里的命令。这个动作会立刻暴露你所有想当然的地方,比如你本地有个未提交的配置文件、你依赖了某个全局安装的包、你的路径写法只在当前操作系统下成立。我建议你把这一步当成发布前的固定流程,它比任何代码审查都更能提升仓库的可用性。

8. 常见问题与排查思路

下面这些问题是这类项目里出现频率最高的,我把现象、原因和解决方式整理成表,方便你按图索骥。但更重要的是理解排查的思维方式:先确定问题出在哪个环节,再在那个环节里找具体原因,而不是凭感觉改代码。

问题现象可能原因排查方式解决方案
报ModuleNotFoundError: No module named 'pygame'依赖未安装,或装到了另一个解释器执行python -c "import sys; print(sys.executable)"看解释器路径用同一个解释器执行pip install pygame,并在 IDE 里把解释器指向虚拟环境
窗口一闪而过主循环条件一开始就是 False,或没进入循环在循环入口加一条 print检查running初始值,确认事件处理不会立刻置 False
窗口出现但显示“无响应”主循环里没有调用pygame.event.get()查看循环体内是否漏了事件处理每帧必须清空事件队列,否则系统判定程序卡死
画面全黑,什么都看不到漏了pygame.display.flip(),或绘制顺序错误在flip前打印一次调用补上刷新调用,确保绘制发生在flip之前
按住方向键角色一卡一卡用了KEYDOWN事件来做持续移动检查输入读取代码改为pygame.key.get_pressed()按帧读取
暂停功能疯狂切换用了get_pressed()来处理一次性操作检查暂停逻辑改为监听KEYDOWN事件
障碍物下落速度在不同机器上不一致移动量按“每帧固定像素”写,且未锁帧率打印clock.tick返回值用dt乘以速度系数,或确保tick(FPS)被调用
明明没碰到就判定死亡碰撞矩形与实际绘制区域不一致临时把rect用描边方式画出来统一用Rect作为唯一事实来源,避免手算坐标
方块穿过挡板没有被判定先判碰撞后更新位置,或速度过大导致跨帧跳过打印每帧位置统一更新完再判定;必要时对高速物体做分段检测
分数在方块未离开屏幕时就增加is_out_of_screen判断条件用了>=或判断了bottom观察边界值用rect.top > SCREEN_HEIGHT判断完全离开
重开后残留上一局方块reset()漏了某个字段逐项对照__init__里的属性把需要重置的字段集中到reset()中管理
中文文字显示为方块默认字体不含中文字形换成系统中文字体测试用pygame.font.Font("字体文件路径", 字号)指定字体
打包后双击闪退缺少资源文件,或相对路径在打包后失效用带控制台的打包方式运行看报错用pyinstaller -F -c调试,确认资源路径处理正确
git push被拒绝远程仓库已有提交,历史不一致git log --oneline查看本地与远程差异先git pull --rebase origin main再推送
仓库体积异常大虚拟环境或__pycache__被提交git count-objects -vH补充.gitignore,必要时用git rm -r --cached取消跟踪

表格里有两行值得再多说两句。关于“中文字体显示为方块”,这是新手最容易卡住的问题之一,因为代码没错、逻辑没错,但屏幕上就是一排方块,很容易怀疑是自己写错了渲染逻辑。根本原因是pygame.font.SysFont(None, size)使用的是系统默认字体,而这个默认字体在某些平台上并不包含中文字形。稳妥的做法是显式指定一个确定存在的字体文件,或者干脆在 UI 上先用英文,等核心玩法稳定了再处理本地化。这不是妥协,而是把问题按优先级排序。

关于“打包后闪退”,如果你想把游戏发给不装 Python 的朋友,可以用 PyInstaller 打包成可执行文件。打包时建议先用带控制台的模式跑通,确认没问题再换成无控制台模式,否则一旦出错你连报错信息都看不到。

# 文件路径:项目根目录执行 pip install pyinstaller # 第一步:带控制台打包,方便看报错 pyinstaller -F -c -n dodge-blocks game/main.py # 第二步:确认无误后,改成无控制台打包 pyinstaller -F -w -n dodge-blocks game/main.py

打包产物在dist/目录下。注意这个目录已经在.gitignore里了,不要把二进制文件提交到仓库,那会让仓库迅速膨胀。正确做法是在 GitHub 的 Releases 页面上传打包好的文件,让仓库本身保持干净。

9. 最佳实践与后续迭代方向

代码能跑、能推、别人能克隆,这个循环走完一遍之后,接下来往哪个方向走,决定了你是停留在“做过一个小游戏”,还是真的从中长出了工程能力。我按投入产出比从高到低给几条建议。

第一,把常量彻底集中管理。你现在可能还有几个数字散落在代码里,比如挡板距底部的距离、初始生成延迟。把它们全部挪到settings.py,并且给每个常量加一行注释说明它影响什么。这个动作很小,但它建立的习惯很重要:当你以后接手一个几千行的项目,你会感谢当初那个把所有魔法数字都命名的自己。

第二,引入一个显式的状态机。现在的代码用game_over一个布尔量控制流程,短期够用,但你只要加一个“开始菜单”或者“暂停界面”,一个布尔量就撑不住了。更通用的写法是定义一个状态枚举,比如MENU、PLAYING、PAUSED、GAME_OVER,然后让update和draw根据当前状态分发到不同分支。这个改动的价值在于,它把“程序现在处于什么阶段”这件事从散落的布尔变量变成了一个唯一、可枚举、可断言的事实。

第三,把逻辑和渲染分离。目前update里没有任何绘制,draw里没有任何状态修改,这个边界保持得很好,请务必守住。一旦你在draw里顺手改了个变量,或者为了省事在update里直接画东西,测试就没法做了,因为逻辑和副作用搅在一起。守住这条线,你才有可能给计分规则、碰撞判定写单元测试——这些纯函数不需要窗口就能验证,是这类项目里最适合补测试的部分。

第四,加日志而不是加 print。调试游戏时你会反复想知道“这一帧发生了什么”,用print会把终端刷爆。改用标准库的logging,设置一个可控的级别,只在需要时打开调试输出。这个习惯从练习项目就开始培养,收益会一直延续到生产环境。

# 文件路径:game/main.py 顶部(示例片段) import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) logger = logging.getLogger(__name__) # 在碰撞发生时记录,比 print 更可控 # logger.info("游戏结束,最终得分 %s", self.score)

第五,注意版本管理的粒度。不要攒一天再提交一次巨大的 commit。每完成一个可验证的小功能就提交一次,信息写清楚做了什么、为什么这么做。比如“feat: 增加难度曲线,分数每 10 分提升下落速度”就比“改了点东西”有用一百倍。当你想回退到“加难度之前”的版本时,你会立刻体会到这个习惯的价值。

第六,警惕一个常见的性能误区:不要在游戏循环里做阻塞操作。读文件、发网络请求、time.sleep、大量字符串拼接日志,这些东西一旦放进update或draw,都会直接拖垮帧率。正确做法是把它们挪到循环外面,或者在后台线程里完成,循环里只读取结果。这个原则在以后写任何实时系统时都适用。

至于功能上的迭代方向,我建议按这个顺序推进:先加音效,这是提升“完成度体感”最划算的一步,几行代码就能让游戏从“作业”变成“作品”;再加本地最高分存档,用json模块读写一个小文件即可,顺便练习文件 IO 和异常处理;然后尝试把单文件拆成模块,这一步一定要在你真的觉得“文件太长”之后再动手,否则容易为了架构而架构;最后可以考虑加一个难度选择菜单,这会逼着你把状态机真正做扎实。

我最后想说的其实不是技术。这个游戏的代码水平确实不高,画面也确实不好看,但它的价值从来不在于质量,而在于它是完整的。一个人从零写下一个能跑、能推、别人能拉下来玩的东西,和一个人看了十篇教程却什么也没发布,两者之间的差距会随着时间不断放大。前者每完成一次都会积累一点“我能做出东西”的确信,后者则在不断消耗它。所以如果你手上正好有一个想法,别等它变好,先让它跑起来,把它推到 GitHub 上,然后在一个个 commit 里把它改好——这个顺序,比反过来容易实现得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进