1. 为什么用 pygame 做一款复古方块赛车游戏很多人第一次接触游戏开发都是从我想做个能玩的东西开始的。但真打开教程一看要么是贪吃蛇、打砖块这种老掉牙的案例要么直接上 Unity、Godot 这种重型引擎光环境配置就劝退一半人。我当年也是这么过来的后来发现一个特别适合新手的组合Python pygame 复古方块风格。这套组合的好处是你不需要任何美术资源用纯色矩形就能拼出一个完整的游戏而且 pygame 的 API 足够简单几十行代码就能让画面动起来。这篇内容要聊的就是怎么从零做一个复古方块赛车游戏。所谓复古方块就是所有视觉元素都用矩形色块表示——你的车是一个方块对面的车是方块马路是灰色背景加白色虚线。听起来简陋但玩起来意外地上头而且这种风格对新手极其友好不用找素材、不用学建模、不用处理图片透明通道所有精力都能放在游戏逻辑本身。这个项目适合谁如果你已经会 Python 基础语法变量、循环、函数、类的基本概念但没写过游戏那这篇就是为你准备的。如果你完全没碰过 Python建议先花两天把基础过一遍再回来不然类class和坐标系那部分会有点吃力。整个项目做完大概 200 到 300 行代码分模块拆解后每部分都不复杂。我先把最终效果说清楚让你有个预期屏幕上方会有一条马路你的车在底部可以用左右方向键控制横向移动上方会不断有敌方方块车往下掉速度随分数增加而加快撞车就游戏结束显示得分按 R 键重开。就这么简单但麻雀虽小五脏俱全游戏循环、事件处理、碰撞检测、状态管理、难度曲线这些核心概念全都能练到。提示pygame 的坐标系和数学课上的不一样原点 (0, 0) 在屏幕左上角x 向右增大y 向下增大。这个一定要先记住后面所有位置计算都基于这个规则搞反了车就会往反方向跑。2. 环境搭建与 pygame 最小可运行骨架2.1 安装 pygame 时最容易踩的版本坑安装本身一条命令的事pip install pygame但这里有个坑我踩过不止一次。如果你系统里同时装了 Python 2 和 Python 3或者用了 conda 环境pip install可能装到了另一个解释器下面结果运行时报ModuleNotFoundError: No module named pygame。我的习惯是永远用python -m pip install pygame这种写法明确指定用当前python对应的 pip避免装错地方。另一个常见问题是版本兼容。pygame 2.x 和 1.x 在部分 API 上有差异比如pygame.display.set_mode的某些参数行为。新手直接用最新版就行目前 2.x 系列已经很稳定。装完可以用下面这段代码验证import pygame print(pygame.version.ver)能打印出版本号就说明装好了。如果报错先确认你的 Python 版本是不是 3.7 以上太老的版本 pygame 2.x 不支持。2.2 一个游戏最少需要哪几行代码在写赛车游戏之前先理解 pygame 程序的最小骨架。任何 pygame 游戏都逃不出这四步初始化、创建窗口、主循环、退出清理。import pygame import sys pygame.init() screen pygame.display.set_mode((480, 640)) pygame.display.set_caption(复古方块赛车) clock pygame.time.Clock() running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False screen.fill((30, 30, 30)) pygame.display.flip() clock.tick(60) pygame.quit() sys.exit()这段代码跑起来是一个深灰色的空窗口关掉就退出。别看它简单里面每一行都有讲究。pygame.init()负责初始化所有子模块包括显示、字体、声音等。有人会偷懒不写结果用到字体或声音时报错所以养成习惯一开始就写上。set_mode((480, 640))里的元组是窗口宽高单位是像素。我选 480x640 是因为它是竖屏比例适合赛车这种纵向卷轴的游戏。你也可以用 400x600差别不大。clock.tick(60)是控制帧率的关键。它让主循环每秒最多执行 60 次也就是 60 FPS。如果不加这句循环会以 CPU 能跑的最快速度疯狂执行风扇狂转不说游戏速度还会因为电脑性能不同而差异巨大。加了它游戏在快慢机器上表现一致。注意pygame.display.flip()和screen.fill()的顺序不能反。正确顺序是先用fill把上一帧擦掉再画新内容最后flip把缓冲区显示出来。如果顺序搞错你会看到画面闪烁或者拖影。2.3 主循环里到底该放什么新手最容易犯的错是把所有逻辑都堆在while里几百行挤在一起改一处崩三处。我的建议是从一开始就按职责拆分事件处理、状态更新、画面绘制这三块分开写。事件处理负责响应键盘和窗口操作状态更新负责计算每个物体这一帧移动到哪里画面绘制负责把当前状态画到屏幕上。这个输入-更新-渲染的模式是所有游戏引擎的通用架构现在养成习惯以后学任何引擎都轻松。在赛车游戏里事件处理主要管左右方向键和退出状态更新管玩家车位置、敌方车位置、分数、碰撞判断渲染管画马路、画车、画分数。后面每一节我都会围绕这三块展开。3. 用矩形搭建游戏世界马路、玩家车与敌方车3.1 马路和车道线的画法复古方块风格的核心就是pygame.draw.rect这个函数。它的签名是rect(surface, color, rect)其中rect可以是(x, y, width, height)四元组。马路我打算画成居中的一条带子两侧留出深色背景当作路肩。假设窗口宽 480马路宽 300那马路左边距就是(480 - 300) / 2 90。ROAD_LEFT 90 ROAD_WIDTH 300 ROAD_COLOR (60, 60, 60) LINE_COLOR (220, 220, 220) # 画马路 pygame.draw.rect(screen, ROAD_COLOR, (ROAD_LEFT, 0, ROAD_WIDTH, 640))车道虚线怎么画思路是画一串白色小矩形从上往下排列然后让它们整体向下移动移出屏幕后回到顶部形成路在后退的错觉。这就是卷轴滚动的基本原理。line_y 0 while line_y 640: pygame.draw.rect(screen, LINE_COLOR, (ROAD_LEFT ROAD_WIDTH // 2 - 3, line_y, 6, 40)) line_y 80这里ROAD_WIDTH // 2 - 3是让虚线居中减 3 是因为线宽 6要往左偏半个宽度。虚线长 40间隔 80看起来比较舒服。实际做的时候我会把这些数字定义成常量方便调整。3.2 玩家车的表示与移动手感调优玩家车就是一个矩形我用pygame.Rect对象来管理因为它自带x、y、left、right等属性碰撞检测也方便。player pygame.Rect(0, 0, 40, 60) player.centerx 480 // 2 player.bottom 640 - 20车宽 40 高 60底部留 20 像素边距。用centerx和bottom定位比直接算x、y更直观。移动手感是新手最容易忽略的地方。很多人直接写player.x 5按一下动一下松手就停感觉很生硬。我的做法是每帧检测按键状态而不是只在按键事件里移动keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: player.x - PLAYER_SPEED if keys[pygame.K_RIGHT]: player.x PLAYER_SPEEDget_pressed()返回当前所有按键的状态按住就持续为真这样移动才顺滑。PLAYER_SPEED我一般设 5 到 7太快容易撞车太慢躲不开。还要加边界限制不然车会开出马路if player.left ROAD_LEFT: player.left ROAD_LEFT if player.right ROAD_LEFT ROAD_WIDTH: player.right ROAD_LEFT ROAD_WIDTH提示这里用left和right而不是x和x width是因为pygame.Rect会自动同步这些属性。你改了leftx也会跟着变反之亦然。这是 pygame 很贴心的设计但新手常常不知道白白多写很多计算。3.3 敌方车的生成策略与难度曲线敌方车如果随机生成很容易出现三辆车堵死一条路的情况玩家根本没法躲。我的策略是把马路分成三条车道每次随机选一条车道生成并且保证同一时间不会有两辆车在同一车道太近的位置。先算车道中心LANE_COUNT 3 LANE_WIDTH ROAD_WIDTH // LANE_COUNT lane_centers [ROAD_LEFT LANE_WIDTH * i LANE_WIDTH // 2 for i in range(LANE_COUNT)]生成敌方车时随机选一个车道中心车宽 40 居中放置enemy pygame.Rect(0, 0, 40, 60) enemy.centerx random.choice(lane_centers) enemy.y -60 # 从屏幕上方外面进来难度曲线靠两个参数控制生成间隔和下落速度。分数越高间隔越短、速度越快。spawn_interval max(0.4, 1.2 - score * 0.01) enemy_speed 4 score * 0.02max(0.4, ...)是设下限不然分数高了之后间隔趋近于零满屏都是车没法玩。速度同理虽然没写下限但实际测试下来分数到 50 左右就已经很刺激了。生成时机用计时器控制不要每帧都生成spawn_timer clock.get_time() / 1000.0 if spawn_timer spawn_interval: spawn_timer 0 spawn_enemy()clock.get_time()返回上一帧到这一帧的毫秒数除以 1000 转成秒。用时间而不是帧数来控制生成游戏在不同性能的机器上节奏才一致。4. 碰撞检测、计分与游戏状态管理4.1 矩形碰撞检测为什么够用pygame 的Rect自带colliderect方法两个矩形是否相交一行搞定if player.colliderect(enemy): game_over True有人会担心矩形碰撞不够精确毕竟车看起来是方的实际应该是圆的或者有细节。但在复古方块风格里车本来就是矩形矩形碰撞就是最精确的。而且游戏手感上稍微宽松一点的碰撞判定反而更友好玩家不会因为差一两个像素就莫名其妙撞上。如果你想让判定更宽容一点可以把玩家的碰撞矩形缩小几像素hitbox player.inflate(-8, -8) if hitbox.colliderect(enemy): ...inflate(-8, -8)表示宽高各缩小 8 像素这样视觉上车还没碰到判定就已经避开了玩起来更舒服。这个技巧在动作游戏里很常用叫宽容判定。4.2 分数系统与速度递增的实现分数我打算按存活时间来算每过一秒加一分。这样比躲过一辆车加一分更平滑难度曲线也更好控制。score_timer clock.get_time() / 1000.0 if score_timer 1.0: score_timer - 1.0 score 1注意这里是- 1.0而不是 0这样即使某一帧卡顿导致时间超过 1 秒也不会丢失分数。这是个细节但体现了对时间累积的正确处理。分数要显示在屏幕上需要初始化字体font pygame.font.SysFont(arial, 28, boldTrue) score_text font.render(fScore: {score}, True, (255, 255, 255)) screen.blit(score_text, (10, 10))SysFont用系统字体不用额外加载字体文件适合新手。render的第二个参数True表示开启抗锯齿文字边缘更平滑。blit就是把渲染好的文字贴到屏幕上。注意字体渲染每帧都做是有性能开销的。分数变化不频繁的话可以只在分数改变时重新渲染并缓存结果。不过对于这种小游戏每帧渲染完全没问题不用过早优化。4.3 游戏状态机运行、结束、重开游戏至少有三个状态运行中、已结束、等待重开。新手常犯的错是用一堆布尔变量is_running、is_game_over、is_paused互相组合逻辑很快就乱了。正确做法是用一个状态变量STATE_RUNNING running STATE_GAMEOVER gameover state STATE_RUNNING主循环里根据状态决定更新什么、画什么if state STATE_RUNNING: update_game() draw_game() elif state STATE_GAMEOVER: draw_gameover()游戏结束时记录最终分数按 R 键重开时重置所有状态if state STATE_GAMEOVER: if keys[pygame.K_r]: reset_game() state STATE_RUNNINGreset_game()里要把玩家位置、敌方车列表、分数、计时器全部重置。这里有个坑如果你用列表存敌方车重置时一定要enemies.clear()或者重新赋值enemies []不然上一局的车会残留到新一局开局就撞。5. 让方块动起来滚动背景与帧率控制5.1 马路虚线的无缝滚动前面画虚线是静态的现在让它动起来。核心思路是维护一个偏移量每帧增加画线时加上这个偏移并对虚线间隔取模实现无缝循环。line_offset (line_offset scroll_speed) % 80 line_y -80 line_offset while line_y 640: pygame.draw.rect(screen, LINE_COLOR, (road_center_x - 3, line_y, 6, 40)) line_y 80% 80是关键它保证偏移量始终在 0 到 79 之间循环。起始line_y设为-80 line_offset这样虚线从屏幕上方外面进入移出底部后自然衔接。scroll_speed可以和敌方车速度挂钩让玩家感觉路在加速后退。这个技巧不只用于马路任何需要无限滚动的背景星空、云层、地板纹理都是同样的原理。理解了取模循环你就掌握了卷轴游戏的精髓。5.2 帧率、速度与游戏手感的关系clock.tick(60)锁 60 帧但游戏里所有速度参数都是每帧移动多少像素。这意味着如果某台机器掉帧到 30游戏速度会直接减半。对于单机小游戏这通常可以接受但更专业的做法是用delta time帧间隔时间来驱动移动dt clock.tick(60) / 1000.0 player.x PLAYER_SPEED * dt * 60这样无论帧率多少每秒移动的距离都一致。不过对新手来说直接用固定帧率加固定速度更简单直观等熟练了再引入 dt 也不迟。我建议这个项目先用固定速度把游戏跑通理解每个环节再考虑优化。手感调优上有几个参数值得反复试玩家速度、敌方车速度、生成间隔、虚线滚动速度。我的经验是玩家速度要比敌方车速度略快这样才有我能躲开的掌控感虚线滚动速度可以比敌方车速度稍慢营造纵深感。5.3 用列表管理多个敌方车敌方车会同时存在多个必须用列表管理enemies [] def spawn_enemy(): enemy pygame.Rect(0, 0, 40, 60) enemy.centerx random.choice(lane_centers) enemy.y -60 enemies.append(enemy) def update_enemies(): for enemy in enemies: enemy.y enemy_speed # 移除移出屏幕的车 enemies[:] [e for e in enemies if e.y 640]enemies[:] [...]这种写法是原地修改列表而不是创建新列表再赋值。区别在于如果有其他地方引用了这个列表原地修改能保证引用同步。虽然这个小项目里用enemies [...]也没问题但养成原地修改的习惯能避免很多隐蔽的 bug。遍历时删除元素是另一个经典坑。如果你写for e in enemies: if ...: enemies.remove(e)会跳过元素甚至报错。正确做法是用列表推导式重建或者倒序遍历。我上面用的列表推导式最清晰。6. 实测中那些教程不会告诉你的坑6.1 事件队列不处理会假死pygame 的事件队列必须每帧清空否则系统会认为程序无响应。这就是为什么主循环里必须有for event in pygame.event.get()。哪怕你暂时不需要处理任何事件也要把这个循环写上至少处理QUIT。我见过有人为了优化把事件处理去掉结果窗口标题栏显示无响应点关闭也关不掉。这不是 pygame 的 bug是操作系统的机制程序不响应事件消息系统就判定它卡死了。6.2 中文显示乱码的解决用SysFont渲染中文时如果字体不支持中文会显示成一堆方块。解决办法是找一个支持中文的系统字体font pygame.font.SysFont(simhei, 28)simhei是黑体Windows 上一般都有。但跨平台就不保险了更稳妥的做法是打包一个字体文件用pygame.font.Font(font.ttf, 28)加载。对于这个项目我建议分数和提示都用英文省去字体麻烦等以后做正式项目再处理中文。6.3 游戏结束画面闪烁的原因如果你在游戏结束状态还继续更新敌方车位置同时又不重置画面会看到车在结束画面上继续移动很出戏。正确做法是状态切换到GAMEOVER后update_game()不再被调用画面定格在最后一帧然后叠加半透明遮罩和文字。半透明遮罩用Surface加 alpha 实现overlay pygame.Surface((480, 640)) overlay.set_alpha(150) overlay.fill((0, 0, 0)) screen.blit(overlay, (0, 0))set_alpha(150)让黑色遮罩半透明底下的游戏画面还能隐约看到视觉上更有层次。6.4 参数调优的实测记录我把调参过程中的几组数据整理成表供你参考。这些是我反复试出来的直接抄能省不少时间。参数推荐值太小的问题太大的问题玩家速度6躲不开车太容易没挑战敌方车初始速度4节奏太慢开局就难初始生成间隔1.2 秒车太密太稀疏无聊最小生成间隔0.4 秒满屏车后期不够刺激虚线滚动速度5没有速度感视觉太晕窗口尺寸480x640车太小车太大不好躲这些值不是绝对的你可以根据自己的手感微调。关键是理解每个参数影响什么调的时候有方向。7. 从这个小项目还能往哪扩展代码跑通之后这个游戏其实还有很多可以玩的花样。我列几个难度递增的方向你可以挑感兴趣的继续做。最简单的扩展是加音效。pygame 的mixer模块可以播放音效撞车时来一声、得分时来一声游戏体验立刻提升一个档次。音效文件网上有很多免费资源加载用pygame.mixer.Sound(crash.wav)播放用.play()。再进一步是加道具系统。比如路上随机出现加速道具、护盾道具玩家吃到后有短暂的无敌或加速效果。这需要引入道具列表和状态计时器逻辑和敌方车类似但增加了状态管理的内容。还可以做多车道动态变化比如马路会左右弯曲或者车道数量随分数变化。这需要把马路从固定矩形改成根据曲线计算位置难度上一个台阶但视觉效果会好很多。如果你想练面向对象可以把玩家车、敌方车、道具都抽象成类每个类有自己的update和draw方法。这样主循环会变得非常干净也更容易扩展新物体。我建议先用函数式写法把游戏跑通理解每个环节再重构成面向对象这样你能真切体会到两种写法的差异。最后分享一个我自己的习惯每做完一个功能就 commit 一次哪怕没有 git也把代码复制一份存成不同版本。游戏开发调参很频繁经常改着改着发现还是上一版好有备份就能随时回退。这个习惯帮我省了无数次重写的时间。