
1. 为什么选 pygame 做第一个游戏项目很多人学 Python 学到一定程度都会卡在同一个地方语法都会了函数也能写但就是不知道拿它做什么。看教程能看懂关掉教程脑子一片空白。这个阶段最缺的不是知识而是一个能跑起来、能看到画面、能操作的完整小项目。pygame 就是为这个场景准备的。pygame 是一套基于 SDL 的 Python 游戏开发库它的定位非常明确不追求 3A 级渲染效果而是让你用几十行代码就能把一个窗口 一堆方块 键盘控制跑起来。对于刚接触图形编程的人来说这种即时反馈极其重要。你敲完代码按下运行屏幕上立刻出现一个能动的方块这种成就感是刷一百道算法题都给不了的。这次要做的复古方块赛车游戏核心玩法很简单玩家控制底部的一个方块左右移动躲避从上方不断落下的敌方方块撞到就结束。听起来像躲避球或者简化版的赛车游戏但麻雀虽小五脏俱全——它包含了游戏开发的所有基础要素游戏循环、事件处理、碰撞检测、帧率控制、状态管理、分数系统。把这几个东西吃透后面做贪吃蛇、打砖块、飞机大战都是同一套骨架换个皮。这篇文章适合谁看如果你满足下面任意一条那这篇内容就是写给你的刚学完 Python 基础语法想找个能落地的练手项目之前跟着教程写过 pygame但只会照抄不理解每行代码在干嘛想搞明白游戏循环、碰撞检测这些概念到底怎么落地打算做自己的第一个小游戏需要一个能直接抄的完整模板我会把整个项目从零拆到能跑每个参数为什么这么设、每个函数为什么这么写都会讲清楚。代码可以直接复制运行但更重要的是理解背后的逻辑这样你改起来才不会抓瞎。2. 项目整体设计与核心思路拆解2.1 游戏的核心机制设计先想清楚这个游戏到底怎么玩。玩家在屏幕底部敌方方块从顶部往下掉玩家要左右移动躲开。撞到就 Game Over躲过去就加分。这个机制看起来简单但设计上有几个关键决策点需要提前定下来。第一玩家是赛车还是方块标题里叫赛车游戏但实际实现上我们用方块代替。原因很实际画一辆车需要图片素材而方块用pygame.draw.rect一行代码就能画出来。对于入门项目先跑通逻辑再考虑美化是正确顺序。等你把逻辑吃透了换成图片也就是把draw.rect换成blit的事。第二敌方方块是单个还是多个单个太简单玩家站着不动都能躲。多个才有挑战性但多个就涉及到一个核心问题怎么管理这些不断生成、移动、消失的方块答案是用列表管理。每个敌方方块是一个字典或对象存它的 x、y、速度所有方块放在一个列表里每帧遍历更新位置出屏幕的就从列表里删掉。这是游戏开发里最经典的对象池思想的简化版。第三难度怎么递增如果敌方方块永远一个速度玩久了会腻。所以需要随时间提升难度。最简单的做法是让方块下落速度随分数增加而加快或者让生成间隔越来越短。这里我们选前者因为改速度只需要动一个变量逻辑最简单。2.2 技术选型为什么是 pygame 而不是别的有人会问做游戏为什么不用 Unity、Godot 这些专业引擎答案很简单学习成本和项目规模要匹配。Unity 用 C#你得先学 C# 再学 Unity 的组件系统、场景管理、物理引擎光是入门就得几周。而 pygame 你只要会 Python 就能上手一个下午就能出成果。还有人问能不能用 tkinter 的 Canvas 做技术上可以但 tkinter 是为 GUI 设计的不是为游戏设计的。它的刷新机制、事件处理、帧率控制都不如 pygame 顺手。pygame 专门为游戏循环做了优化pygame.time.Clock().tick(60)一行就能锁定 60 帧tkinter 要实现同样效果得自己算时间差。所以选型逻辑很清晰项目小、要快速出成果、学习者 Python 基础扎实但图形编程零基础pygame 是最优解。等这个项目做完了你对游戏循环有了肌肉记忆再去学 Unity 会顺畅很多。2.3 游戏循环整个项目的心脏游戏和普通程序最大的区别在于游戏是活着的。普通程序执行完就退出游戏要一直运行直到玩家主动关闭。这个一直运行的机制就是游戏循环。游戏循环的本质是一个while True死循环每一轮循环做三件事处理事件键盘按了什么、鼠标点了哪里、窗口是不是被关了更新状态玩家位置变了没、敌方方块移动了没、有没有碰撞渲染画面把当前所有东西画到屏幕上这三步每秒重复 60 次人眼看起来就是连续动画。这里有个关键点顺序不能乱。必须先处理输入再更新状态否则玩家的操作会延迟一帧必须先更新状态再渲染否则画出来的是上一帧的旧数据。while running: clock.tick(60) # 锁定 60 帧 for event in pygame.event.get(): # 1. 处理事件 ... update_game_state() # 2. 更新状态 draw_everything() # 3. 渲染画面 pygame.display.flip() # 把画好的内容显示出来pygame.display.flip()这行特别容易被新手忽略。pygame 的渲染是双缓冲的你所有的绘制操作其实画在一块隐藏的画布上flip()才是把这块画布一次性显示到屏幕上。如果不调用它你看到的永远是黑屏。这个坑我当年踩过盯着黑窗口找了半小时才发现少了一行。3. 核心细节解析与实操要点3.1 窗口初始化和基础配置先把骨架搭起来。窗口大小选 480x640这是个竖屏比例适合从上往下掉的玩法。为什么不用 800x600 的横屏因为赛车游戏天然是纵向的竖屏能让下落距离更长玩家有更多反应时间。import pygame import random import sys pygame.init() WIDTH, HEIGHT 480, 640 screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(复古方块赛车) clock pygame.time.Clock()pygame.init()这行必须放在最前面它负责初始化 pygame 的所有子系统显示、声音、字体等。漏了它后面调用任何 pygame 功能都会报错。颜色定义建议用常量方便统一修改BLACK (0, 0, 0) WHITE (255, 255, 255) RED (220, 60, 60) BLUE (60, 120, 220) GRAY (40, 40, 40)这里有个细节为什么红色用 (220, 60, 60) 而不是 (255, 0, 0)纯红色在屏幕上看起来太刺眼稍微降低一点饱和度和亮度视觉上更舒服。这是做 UI 的经验纯色往往不如稍微脏一点的颜色耐看。3.2 玩家对象的封装玩家用字典存还是用类存新手建议先用字典因为直观但项目稍微大一点就该用类。这里我用类因为后面扩展方便而且能帮你建立面向对象的思维。class Player: def __init__(self): self.width 50 self.height 50 self.x WIDTH // 2 - self.width // 2 self.y HEIGHT - 80 self.speed 6 def move(self, keys): if keys[pygame.K_LEFT] and self.x 0: self.x - self.speed if keys[pygame.K_RIGHT] and self.x WIDTH - self.width: self.x self.speed def draw(self, surface): pygame.draw.rect(surface, BLUE, (self.x, self.y, self.width, self.height))几个关键点解释一下速度为什么是 6这是试出来的。速度 3 太慢玩家躲不及速度 10 太快一按就撞墙。6 在 60 帧下每秒移动 360 像素屏幕宽 480横穿一次大约 1.3 秒手感刚好。这个值你可以自己调找到最舒服的。边界判断为什么是self.x 0而不是 0其实都行差一个像素看不出来。但用更符合直觉x 是左上角坐标x0 时方块左边缘贴屏幕左边再往左就出界了。keys[pygame.K_LEFT]这种写法为什么能持续移动因为pygame.key.get_pressed()返回的是当前所有按键的状态只要键按着对应位置就是 True。这跟事件处理里的KEYDOWN不一样——KEYDOWN只在按下的那一瞬间触发一次适合做按一下跳一下的操作而get_pressed()适合做按住持续移动。3.3 敌方方块的生成与管理敌方方块是游戏的核心难点。每个方块要有位置、大小、速度还要能移动、能判断出界、能画出来。用列表管理所有方块class Enemy: def __init__(self, speed): self.width random.randint(40, 70) self.height 40 self.x random.randint(0, WIDTH - self.width) self.y -self.height self.speed speed def update(self): self.y self.speed def off_screen(self): return self.y HEIGHT def draw(self, surface): pygame.draw.rect(surface, RED, (self.x, self.y, self.width, self.height))宽度为什么随机固定宽度太单调随机宽度让游戏有变化玩家需要判断不同宽度的方块怎么躲。范围 40 到 70 是试出来的太窄容易漏过太宽躲不开。初始 y 为什么是-self.height让方块从屏幕上方外面开始这样它进入屏幕时是平滑的不会突然出现。如果 y 从 0 开始方块会凭空冒出来视觉上很突兀。速度参数为什么从外面传进来因为速度要随难度变化不能写死在类里。生成方块的时候根据当前分数算速度传进去。生成逻辑用计时器控制不能每帧都生成否则屏幕瞬间被塞满spawn_timer 0 spawn_interval 45 # 帧数 # 在游戏循环里 spawn_timer 1 if spawn_timer spawn_interval: spawn_timer 0 enemies.append(Enemy(current_speed))spawn_interval 45是什么概念60 帧下 45 帧等于 0.75 秒生成一个。这个频率下屏幕上的方块数量适中既有压力又不至于无解。如果设成 20一秒三个新手根本反应不过来。3.4 碰撞检测的实现碰撞检测是游戏里最容易写错的地方。新手常犯的错误是只判断中心点或者用圆形距离公式。对于矩形正确做法是AABB 检测轴对齐包围盒def check_collision(player, enemy): return (player.x enemy.x enemy.width and player.x player.width enemy.x and player.y enemy.y enemy.height and player.y player.height enemy.y)这四个条件必须同时成立才算碰撞。逐个解释player.x enemy.x enemy.width玩家左边在敌方右边界的左侧player.x player.width enemy.x玩家右边在敌方左边界的右侧player.y enemy.y enemy.height玩家上边在敌方下边界的上方player.y player.height enemy.y玩家下边在敌方上边界的下方四个条件同时满足说明两个矩形在 x 轴和 y 轴上都有重叠也就是真的碰上了。这个逻辑看起来绕但画个图就明白了两个矩形不碰撞的情况只有四种——一个在另一个的左边、右边、上边、下边。把这四种排除掉剩下的就是碰撞。注意碰撞检测一定要用player.x player.width这种右边界的写法不能只比较player.x和enemy.x。只比较左上角坐标的话两个方块擦边而过也会判定碰撞手感会很差。4. 完整实操流程与核心环节实现4.1 搭建游戏主循环骨架把前面所有零件组装起来。先写主循环的框架把状态变量都定义好def main(): player Player() enemies [] score 0 base_speed 3 spawn_timer 0 spawn_interval 45 running True game_over False while running: clock.tick(60) for event in pygame.event.get(): if event.type pygame.QUIT: running False if event.type pygame.KEYDOWN: if event.key pygame.K_SPACE and game_over: # 重开游戏 return main() if not game_over: keys pygame.key.get_pressed() player.move(keys) # 难度递增 current_speed base_speed score // 10 # 生成敌方方块 spawn_timer 1 if spawn_timer spawn_interval: spawn_timer 0 enemies.append(Enemy(current_speed)) # 更新敌方方块 for enemy in enemies[:]: enemy.update() if enemy.off_screen(): enemies.remove(enemy) score 1 elif check_collision(player, enemy): game_over True # 渲染 screen.fill(BLACK) player.draw(screen) for enemy in enemies: enemy.draw(screen) draw_score(screen, score) if game_over: draw_game_over(screen) pygame.display.flip() pygame.quit() sys.exit()这段代码里有几个值得说的细节enemies[:]是什么写法这是列表的浅拷贝。为什么遍历的时候要用拷贝因为遍历过程中如果直接enemies.remove(enemy)会改变列表长度导致遍历出错跳过元素或者报错。用[:]创建一个副本遍历副本、修改原列表就安全了。这个坑非常隐蔽新手经常遇到明明该删的没删掉或者删了之后后面的元素被跳过根源就在这里。score // 10是什么意思每躲过 10 个方块速度加 1。这样难度是阶梯式上升的不会突然变难。如果你想让难度上升更快改成score // 5想更慢改成score // 20。return main()重开游戏是什么逻辑这是最简单的重开方式——直接递归调用自己。虽然严格来说递归会累积调用栈但对于这种小游戏玩家重开几十次也不会出问题。如果你追求严谨可以用一个外层循环包起来但没必要。4.2 分数显示和游戏结束画面分数显示需要用到字体。pygame 自带默认字体但中文支持不好所以分数用英文或者数字def draw_score(surface, score): font pygame.font.SysFont(arial, 28) text font.render(fScore: {score}, True, WHITE) surface.blit(text, (10, 10))font.render的三个参数分别是文本内容、是否抗锯齿、颜色。抗锯齿设为 True 文字边缘更平滑性能开销可以忽略。游戏结束画面def draw_game_over(surface): font_big pygame.font.SysFont(arial, 48) font_small pygame.font.SysFont(arial, 24) text1 font_big.render(GAME OVER, True, RED) text2 font_small.render(Press SPACE to restart, True, WHITE) rect1 text1.get_rect(center(WIDTH // 2, HEIGHT // 2 - 30)) rect2 text2.get_rect(center(WIDTH // 2, HEIGHT // 2 30)) surface.blit(text1, rect1) surface.blit(text2, rect2)get_rect(center...)这个用法很实用。它自动帮你算好文字居中的位置不用手动算宽度。center参数指定中心点坐标pygame 会自动调整左上角位置让文字居中。这个技巧在做 UI 的时候能省很多事。4.3 参数调优的实操记录代码能跑之后接下来就是调手感。我实际调的时候记录了几个关键参数的变化参数初始值调整后原因玩家速度464 太慢躲不及快速下落的方块生成间隔60 帧45 帧60 帧太稀疏游戏没压力基础速度232 太慢方块下落像蜗牛难度系数score//5score//10//5 难度上升太快20 分就玩不下去方块宽度固定 5040-70 随机固定宽度太单调调参这件事没有标准答案核心原则是让玩家在 30 秒到 2 分钟之间死一次。死太快挫败感强死太慢无聊。你可以自己玩几把感受一下节奏然后微调。提示调参的时候建议把spawn_interval和base_speed做成变量放在文件顶部改起来方便。不要散落在代码各处否则改一个参数要找半天。4.4 让画面更有复古感标题里有复古两个字光用纯色方块有点单调。加几个小细节就能提升质感第一加网格背景。每隔 40 像素画一条暗灰色的线模拟老式游戏机的像素网格def draw_grid(surface): for x in range(0, WIDTH, 40): pygame.draw.line(surface, GRAY, (x, 0), (x, HEIGHT)) for y in range(0, HEIGHT, 40): pygame.draw.line(surface, GRAY, (0, y), (WIDTH, y))第二给方块加边框。纯色方块看起来太平加一圈亮色边框立刻有立体感pygame.draw.rect(surface, BLUE, (self.x, self.y, self.width, self.height)) pygame.draw.rect(surface, WHITE, (self.x, self.y, self.width, self.height), 2)最后那个2是边框宽度不填就是实心填充。第三加个简单的拖尾效果。玩家移动时留下淡淡的残影这个用透明度实现trail_surface pygame.Surface((self.width, self.height)) trail_surface.set_alpha(80) trail_surface.fill(BLUE)set_alpha(80)设置透明度0 全透明255 不透明。80 大约是 30% 不透明度残影效果刚好。5. 常见问题与排查技巧实录5.1 新手最容易踩的五个坑坑一黑屏什么都不显示。九成是忘了pygame.display.flip()。所有的绘制操作都是在内存里的画布上进行的不调用 flip 就不会显示到屏幕上。另一个可能是screen.fill(BLACK)放在了绘制之后把画好的东西又盖住了。记住顺序先 fill 清屏再画东西最后 flip。坑二窗口卡死无响应。通常是忘了处理pygame.event.get()。操作系统会往事件队列里塞事件比如关闭窗口如果你不取出来处理队列满了系统就认为程序卡死了。所以每帧必须调用pygame.event.get()把事件取干净。坑三方块移动速度跟帧率绑定。如果你用self.x 1这种写法在 60 帧的机器上每秒移动 60 像素在 30 帧的机器上每秒只移动 30 像素。解决办法是用clock.tick(60)锁定帧率或者用delta_time计算时间差。入门项目用前者就够了。坑四碰撞检测判定太灵敏。如果两个方块只是擦边就判定碰撞玩家会觉得明明没碰到怎么就死了。检查你的碰撞检测是不是只比较了左上角坐标。正确的 AABB 检测要比较四条边。坑五列表遍历时删除元素出错。前面说过的enemies[:]问题。记住一个原则遍历列表时如果要修改列表先拷贝一份再遍历。5.2 问题速查表现象可能原因解决方法黑屏忘了 flip / fill 顺序错检查渲染顺序窗口无响应没处理事件队列每帧调用 event.get()方块速度不一致帧率未锁定用 clock.tick(60)碰撞判定异常只比较了左上角用完整 AABB 检测删除元素报错遍历时修改列表用 [:] 拷贝后遍历文字显示方块字体不支持中文用英文或加载中文字体游戏越来越卡对象没清理出屏幕的方块及时 remove5.3 性能优化的几个实用技巧这个游戏规模小一般不会卡。但如果你把生成频率调得很高屏幕上几百个方块就会开始掉帧。几个优化点第一及时清理出屏幕的对象。这是最重要的。出屏幕的方块如果不删列表会越来越长每帧遍历的开销越来越大。用enemies.remove(enemy)及时清理。第二避免在循环里重复创建对象。比如字体对象pygame.font.SysFont不要每帧都创建在循环外面创建一次循环里复用。创建字体是个相对耗时的操作每帧创建会拖慢速度。第三用convert()优化 Surface。如果你加载了图片调用image.convert()能大幅提升 blit 速度。这个项目用 draw.rect 画方块不涉及图片但以后做复杂项目会用到。第四减少不必要的重绘。背景网格如果每帧都重画其实有点浪费。可以先把网格画到一个 Surface 上每帧直接 blit 这个 Surface比重新画线快。# 优化前每帧画线 for x in range(0, WIDTH, 40): pygame.draw.line(...) # 优化后预渲染 grid_surface pygame.Surface((WIDTH, HEIGHT)) # 在 grid_surface 上画一次网格 # 循环里直接 screen.blit(grid_surface, (0, 0))5.4 我踩过的几个真实坑说几个文档里不会写、只有实际做才会遇到的问题。第一个坑pygame.quit()和sys.exit()的顺序。我一开始只写了pygame.quit()结果窗口关了但程序没退出终端还卡着。后来加上sys.exit()才正常。原因是pygame.quit()只是释放 pygame 的资源不会终止 Python 进程。两个都要写。第二个坑按键持续移动的延迟感。一开始我用KEYDOWN事件做移动结果按一下只移动一格要一直狂按才能移动。后来改用pygame.key.get_pressed()才实现按住持续移动。这个区别新手很容易搞混事件适合一次性动作跳跃、射击按键状态适合持续动作移动、加速。第三个坑随机宽度导致方块重叠。如果两个方块生成在同一位置会叠在一起看起来像一个。解决办法是记录上一个方块的 x 坐标新的生成时如果太近就重新随机。不过对于入门项目重叠也不影响玩法可以暂时不管。第四个坑难度递增太快。我一开始用score // 5结果玩到 20 分速度就翻倍了根本反应不过来。改成score // 10之后节奏舒服多了。难度曲线要平缓让玩家有我还能再撑一会儿的感觉而不是怎么突然这么难。6. 项目扩展方向与进阶思路6.1 从方块到真赛车的升级路径现在玩家和敌人都是方块想升级成真正的赛车需要做几件事第一步准备图片素材。找两张俯视角的赛车图片一张玩家车、一张敌方车。图片格式建议 PNG支持透明背景。尺寸控制在 50x80 左右太大影响性能。第二步替换绘制逻辑。把pygame.draw.rect换成screen.blitplayer_img pygame.image.load(player_car.png).convert_alpha() def draw(self, surface): surface.blit(player_img, (self.x, self.y))convert_alpha()比convert()多保留透明度通道PNG 图片必须用它否则透明部分会变成黑色。第三步调整碰撞检测。图片的碰撞盒通常比图片本身小一圈因为车头车尾有空白。可以手动缩小碰撞盒def get_hitbox(self): return (self.x 5, self.y 5, self.width - 10, self.height - 10)6.2 增加游戏性的几个方向方向一加道具系统。随机掉落加速道具、护盾道具、减速道具。玩家吃到护盾可以撞一次不死吃到减速让所有敌方方块慢下来。这个用列表管理道具逻辑和敌方方块类似。方向二加多车道。把屏幕分成 3 到 4 条车道敌方方块只在车道上生成玩家也只能在车道间切换。这样游戏更有赛车的感觉操作也从连续移动变成离散切换。方向三加音效。pygame 的pygame.mixer可以播放音效。移动、碰撞、得分分别配不同的音效游戏体验立刻提升一个档次。注意音效文件要短太长会延迟。方向四加最高分记录。把最高分存到本地文件每次启动读取游戏结束时比较更新。用 Python 的json或简单的文本文件都行。import json def load_high_score(): try: with open(highscore.json, r) as f: return json.load(f)[high] except FileNotFoundError: return 0 def save_high_score(score): with open(highscore.json, w) as f: json.dump({high: score}, f)6.3 代码结构的重构建议现在所有代码都在一个文件里一百多行还能接受。但如果继续加功能很快就会变成几百行改起来痛苦。建议按职责拆分player.py玩家类enemy.py敌人类settings.py所有常量配置main.py主循环和游戏逻辑拆分的核心原则是**改配置不用翻逻辑代码改逻辑不用翻配置**。所有可调参数速度、间隔、颜色、尺寸集中在 settings.py调参的时候只改一个文件。另外游戏状态建议用枚举管理而不是一堆布尔变量from enum import Enum class GameState(Enum): MENU 1 PLAYING 2 GAME_OVER 3这样加个开始菜单、暂停功能都很方便不用维护一堆is_playing、is_paused、is_game_over的布尔值。6.4 发布和分享的注意事项做完了想分享给别人玩有几个点要注意第一打包成 exe。用pyinstaller可以把 Python 脚本打包成 Windows 可执行文件别人不用装 Python 就能玩pyinstaller --onefile --windowed main.py--onefile打包成单个文件--windowed不显示命令行窗口。打包后的文件在dist目录里。第二资源文件路径问题。打包后图片、音效的路径会变需要用sys._MEIPASS处理。这是个经典坑打包后运行报找不到文件基本都是这个原因。第三控制文件大小。图片和音效不要太大否则打包出来的 exe 几十兆分享不方便。图片压缩一下音效用低比特率。我个人在实际操作中的体会是入门项目最大的价值不在于做出来的东西多完美而在于把从零到能跑这条路完整走一遍。你走完这一遍下次做新项目就知道该从哪下手、哪里会踩坑、怎么调试。这个手感是看多少教程都换不来的。所以别纠结画面丑、玩法简单先让它跑起来跑起来之后再慢慢改。改的过程本身就是最好的学习。