ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python+pygame复古方块赛车游戏开发教程:从零实现碰撞检测与难度曲线

Python+pygame复古方块赛车游戏开发教程:从零实现碰撞检测与难度曲线

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_SPEED

get_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会自动同步这些属性。你改了left,x也会跟着变,反之亦然。这是 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.02

max(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, bold=True) score_text = font.render(f"Score: {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_RUNNING

reset_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,也把代码复制一份存成不同版本。游戏开发调参很频繁,经常改着改着发现还是上一版好,有备份就能随时回退。这个习惯帮我省了无数次重写的时间。

RELATED READING

延伸阅读

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