1. 项目缘起与整体设计思路
1.1 为什么选复古方块赛车作为入门项目
很多刚接触游戏开发的朋友,第一个念头往往是“我要做个开放世界”或者“我要做个联机对战”,结果往往卡在环境配置那一步就放弃了。我带过不少新手,发现最有效的入门路径其实是找一个视觉反馈快、逻辑闭环短、代码量可控的小项目。复古方块赛车恰好满足这三点:画面由简单的矩形色块构成,不需要任何美术素材;核心玩法就是“躲避障碍物”,规则一句话能说清;整个项目从零到能跑,熟练的话一个下午就能搞定。
这个项目的本质是一个纵向卷轴躲避游戏。玩家控制屏幕底部的一个方块左右移动,上方不断有敌方方块向下逼近,碰到就结束。听起来简单,但麻雀虽小五脏俱全——它涵盖了游戏开发最核心的几个模块:游戏循环、事件处理、碰撞检测、对象管理、分数系统、难度递增。你把这几个模块吃透了,后面做任何类型的游戏都只是换皮和加功能的事。
1.2 技术选型:为什么是 pygame
Python 生态里做 2D 游戏的库有好几个,比如 arcade、pyglet,甚至直接用 tkinter 的 Canvas 也能画。但入门阶段我强烈建议用 pygame,原因有三条。
第一,pygame 的抽象层级恰到好处。它不会像游戏引擎那样把底层逻辑全藏起来,你仍然需要自己写游戏循环、自己处理帧率、自己管理精灵组。这种“半手动”的方式对理解游戏运行机制非常有帮助。第二,资料丰富且稳定。pygame 已经维护了二十多年,你遇到的绝大多数问题都能找到现成的答案。第三,依赖极轻。一条pip install pygame就完事,不需要配置复杂的构建工具链,对新手极其友好。
提示:安装时如果遇到编译错误,优先检查 Python 版本是否为 64 位,以及 pip 是否是最新版。绝大多数安装失败都是这两个原因导致的。
1.3 整体架构拆解
在动手写代码之前,我习惯先在纸上把模块关系画清楚。这个项目的架构可以分成四层:
- 入口层:初始化 pygame、创建窗口、启动主循环。
- 状态层:管理游戏状态(进行中 / 已结束)、分数、难度等级。
- 实体层:玩家方块、敌方方块、可能的道具方块,各自有位置、速度、绘制方法。
- 逻辑层:每帧更新位置、检测碰撞、生成新敌人、清理越界对象。
这四层不需要用复杂的类继承体系来实现,用几个简单的类加上主循环里的函数调用就够了。新手最容易犯的错误是一上来就设计庞大的类层次结构,结果写了三百行还没看到任何画面。我的建议是先让画面动起来,再逐步重构。
2. 核心细节解析与实操要点
2.1 游戏循环的正确写法
游戏循环是整个程序的心脏。很多新手写出来的循环是这样的:一个while True里面塞满了所有逻辑,帧率靠time.sleep控制。这种写法在小项目里能跑,但一旦逻辑变复杂就会出现卡顿、输入延迟等问题。
正确的做法是使用 pygame 内置的时钟对象来控制帧率:
import pygame import sys pygame.init() screen = pygame.display.set_mode((480, 640)) pygame.display.set_caption("复古方块赛车") clock = pygame.time.Clock() while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() # 更新逻辑 # 绘制逻辑 pygame.display.flip() clock.tick(60)这里有几个关键点需要展开说。clock.tick(60)的作用是让循环每秒最多执行 60 次,它返回的是上一帧到这一帧经过的毫秒数。这个返回值非常重要,后面做速度计算时会用到。pygame.display.flip()和pygame.display.update()的区别在于前者刷新整个屏幕,后者可以只刷新指定区域。对于这个项目,全屏刷新完全够用,性能开销可以忽略。
注意:千万不要在游戏循环里写
time.sleep(),它会阻塞事件处理,导致窗口无响应。帧率控制交给clock.tick()就好。
2.2 玩家方块的移动手感调优
玩家方块的移动看起来简单,但手感好坏直接影响游戏体验。最粗糙的做法是按下方向键时直接改变方块的 x 坐标,比如每帧加 5 像素。这样做的结果是移动速度完全取决于帧率——帧率越高移动越快,在不同性能的机器上体验不一致。
更好的做法是基于时间的移动。利用clock.tick()返回的dt(delta time),把速度定义成“每秒移动多少像素”,然后每帧移动speed * dt / 1000像素。这样无论帧率如何波动,方块的移动速度都是恒定的。
dt = clock.tick(60) speed = 300 # 每秒移动300像素 keys = pygame.key.get_pressed() if keys[pygame.K_LEFT]: player_x -= speed * dt / 1000 if keys[pygame.K_RIGHT]: player_x += speed * dt / 1000另外,我强烈建议给玩家方块加上边界限制。如果不加限制,方块会直接飞出屏幕外,玩家就再也找不回来了。边界判断的逻辑很简单:移动之后检查 x 坐标是否小于 0 或大于窗口宽度减去方块宽度,如果是就把它钳制在合法范围内。
还有一个手感细节:按键的响应方式。用pygame.key.get_pressed()是持续检测,按住不放会一直移动,适合赛车类游戏。用event.type == pygame.KEYDOWN是单次触发,按一下动一下,适合回合制或菜单操作。这个项目显然应该用前者。
2.3 敌方方块的生成策略
敌方方块的生成看似随意,其实有很多讲究。如果完全随机生成,可能会出现连续几个方块都在同一个位置,玩家根本来不及反应;也可能长时间不生成,游戏变得无聊。我试过几种方案,最后总结出一个比较靠谱的策略。
生成间隔递减。游戏刚开始时,每 1.2 秒生成一个敌人;随着分数增加,间隔逐渐缩短到最低 0.4 秒。这样难度曲线是平滑上升的,玩家有一个适应过程。具体实现可以用一个计时器累加dt,超过阈值就生成新敌人并重置计时器。
横向位置随机但留出安全通道。敌人的 x 坐标在0到窗口宽度 - 敌人宽度之间随机取值。但要注意,如果两个敌人同时出现在相近的位置,中间的空隙可能小于玩家方块的宽度,导致玩家无法通过。解决办法是记录上一次生成的 x 坐标,新的生成位置与它保持一个最小距离。
速度也随难度递增。敌人的下落速度从最初的每秒 150 像素逐步提升到每秒 400 像素。这个递增可以和生成间隔的递减同步进行,用一个统一的“难度系数”来控制。
| 难度等级 | 生成间隔(秒) | 下落速度(像素/秒) | 触发分数 |
|---|---|---|---|
| 1 | 1.2 | 150 | 0 |
| 2 | 1.0 | 200 | 10 |
| 3 | 0.8 | 260 | 25 |
| 4 | 0.6 | 320 | 45 |
| 5 | 0.4 | 400 | 70 |
这张表是我反复试玩后调出来的参数,可以直接抄作业。当然你也可以根据自己的手感微调,但建议每次只改一个参数,改完试玩几局再决定是否继续调整。
2.4 碰撞检测的简化方案
pygame 提供了Rect对象的colliderect方法来做矩形碰撞检测,精度足够这个项目使用。但新手常犯的错误是每帧都对所有敌人做碰撞检测,敌人多了之后性能会下降。虽然这个项目里敌人数量不会太多,但养成好习惯很重要。
我的做法是只检测与玩家在同一水平区域的敌人。具体来说,先判断敌人的 y 坐标是否在玩家方块的上下一定范围内,如果是再做精确的colliderect检测。这样可以过滤掉大部分不可能碰撞的敌人,减少计算量。
player_rect = pygame.Rect(player_x, player_y, player_w, player_h) for enemy in enemies: enemy_rect = pygame.Rect(enemy.x, enemy.y, enemy.w, enemy.h) if abs(enemy.y - player_y) < 100: if player_rect.colliderect(enemy_rect): game_over = True提示:
Rect的构造函数接收的是(left, top, width, height),不是(x, y, w, h)的坐标加尺寸的直觉写法。虽然结果一样,但参数顺序要记清楚,否则会出现碰撞区域偏移的诡异 bug。
3. 完整实操流程与核心环节实现
3.1 环境搭建与项目初始化
第一步永远是环境准备。我建议用虚拟环境来管理依赖,避免不同项目之间的库版本冲突。具体操作如下:
python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install pygame安装完成后,可以用一个简单的脚本验证是否成功:
import pygame print(pygame.version.ver)如果输出了版本号且没有报错,说明环境没问题。接下来创建项目目录结构。我习惯把代码分成几个文件,但对于入门项目,单个文件反而更清晰。建议命名为racing_game.py,所有代码都放在里面,方便随时运行和调试。
窗口尺寸我选择 480x640,这是一个接近手机屏幕比例的竖屏尺寸,很适合纵向卷轴游戏。颜色方案用经典的复古配色:背景深灰(30, 30, 30),玩家方块青色(0, 255, 255),敌人方块红色(255, 60, 60),文字白色(255, 255, 255)。
3.2 玩家方块的完整实现
玩家方块需要处理移动、边界限制和绘制三个功能。我把它们封装成一个简单的类:
class Player: def __init__(self, x, y, w, h, speed): self.x = x self.y = y self.w = w self.h = h self.speed = speed def update(self, dt, keys, screen_w): if keys[pygame.K_LEFT]: self.x -= self.speed * dt / 1000 if keys[pygame.K_RIGHT]: self.x += self.speed * dt / 1000 # 边界限制 if self.x < 0: self.x = 0 if self.x + self.w > screen_w: self.x = screen_w - self.w def draw(self, surface, color): pygame.draw.rect(surface, color, (self.x, self.y, self.w, self.h))这里有个细节值得展开:速度值的选择。我设的是 300 像素每秒,在 480 像素宽的窗口里,从最左边移到最右边大约需要 1.6 秒。这个速度既不会快到难以控制,也不会慢到让人着急。如果你觉得太慢,可以调到 400;觉得太快,调到 250。但不要超过 500,否则方块会像瞬移一样,很难精确操作。
玩家方块的尺寸我设为 40x40。太小了碰撞检测太苛刻,太大了躲避空间不够。40 像素在 480 宽的窗口里,横向可以容纳 12 个方块,留给玩家的操作空间比较充裕。
3.3 敌方方块的管理与更新
敌方方块用列表来管理,每个元素是一个字典或简单对象,包含 x、y、w、h、speed 属性。每帧更新时遍历列表,让 y 坐标增加speed * dt / 1000,然后检查是否超出屏幕底部,超出就移除并加分。
enemies = [] spawn_timer = 0 spawn_interval = 1200 # 毫秒 # 在主循环中 spawn_timer += dt if spawn_timer >= spawn_interval: spawn_timer = 0 x = random.randint(0, screen_w - enemy_w) enemies.append({ 'x': x, 'y': -enemy_h, 'w': enemy_w, 'h': enemy_h, 'speed': current_speed }) for enemy in enemies[:]: enemy['y'] += enemy['speed'] * dt / 1000 if enemy['y'] > screen_h: enemies.remove(enemy) score += 1注意enemies[:]这个写法。在遍历列表的同时删除元素会导致索引错乱,用切片创建一个副本再遍历就安全了。这是 Python 里非常常见的坑,新手一定要记住。
3.4 分数系统与难度递增
分数系统看起来简单,但要做好也需要一些设计。我的方案是:每成功躲避一个敌人加 1 分,分数达到特定阈值时提升难度等级。难度等级影响生成间隔和敌人速度,具体参数参考前面的表格。
score = 0 difficulty = 1 def update_difficulty(score): if score >= 70: return 5 elif score >= 45: return 4 elif score >= 25: return 3 elif score >= 10: return 2 return 1分数显示用 pygame 的字体模块。中文字体需要指定字体文件路径,否则会显示成方块。如果不想处理字体问题,直接用英文和数字最省事。我通常用pygame.font.SysFont(None, 36)来获取默认字体,显示 "Score: 42" 这样的内容完全没问题。
注意:每次调用
font.render()都会创建一个新的 Surface 对象,如果在循环里频繁调用会造成性能浪费。建议把分数文本的渲染放在分数变化时执行,而不是每帧都渲染。
3.5 游戏结束与重新开始
游戏结束界面需要显示最终分数,并提示玩家按某个键重新开始。实现方式是加一个game_over标志位,在主循环里根据这个标志位切换逻辑分支。
if game_over: # 绘制结束界面 text = font.render(f"Game Over! Score: {score}", True, WHITE) screen.blit(text, (screen_w // 2 - text.get_width() // 2, screen_h // 2)) tip = small_font.render("Press R to restart", True, WHITE) screen.blit(tip, (screen_w // 2 - tip.get_width() // 2, screen_h // 2 + 50)) keys = pygame.key.get_pressed() if keys[pygame.K_r]: # 重置所有状态 game_over = False score = 0 enemies.clear() player.x = screen_w // 2 - player.w // 2 spawn_timer = 0 difficulty = 1重新开始的关键是把所有状态都重置干净。我见过不少新手只重置了分数和敌人列表,忘了重置玩家位置或生成计时器,导致重新开始后出现各种奇怪的现象。建议把重置逻辑单独写成一个函数,每次重新开始时调用,避免遗漏。
4. 常见问题与排查技巧实录
4.1 窗口无响应或闪退
这是新手遇到最多的问题。原因通常有三类:一是游戏循环里没有处理pygame.QUIT事件,点击关闭按钮时程序无法正常退出;二是循环里写了阻塞操作,比如time.sleep()或input();三是没有调用pygame.display.flip()或update(),画面一直是黑的,系统认为程序无响应。
排查方法很简单:在循环开头打印一帧的标记,看看是否持续输出。如果只输出一次就停了,说明卡在了某个地方。逐步注释掉可疑代码,定位到具体位置。
4.2 方块移动速度不一致
前面提到过,用固定像素值移动会导致速度随帧率变化。解决办法就是基于dt计算移动距离。但还有一个隐藏问题:如果某一帧的dt特别大(比如窗口被拖动后恢复),方块会瞬移一大段距离,可能直接穿过敌人。解决办法是给dt设一个上限,比如dt = min(dt, 50),这样即使卡顿,单帧移动距离也不会超过 50 毫秒对应的距离。
4.3 碰撞检测不准确
碰撞检测不准通常有两个原因。一是Rect的坐标和绘制坐标不一致。比如你绘制时用的是方块中心点坐标,但Rect用的是左上角坐标,两者差了一半的宽高。二是浮点数精度问题。self.x是浮点数,传给Rect时会被截断成整数,导致碰撞区域和视觉区域有细微偏差。解决办法是在创建Rect时用round()取整,或者干脆把位置变量也存成整数。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 方块穿模 | dt 过大导致单帧位移过大 | 限制 dt 上限 |
| 碰撞区域偏移 | 绘制坐标与 Rect 坐标不一致 | 统一使用左上角坐标 |
| 碰撞时有时无 | 浮点数截断误差 | 用 round() 取整 |
| 敌人重叠生成 | 随机范围没有约束 | 记录上次位置并保持最小间距 |
4.4 游戏越来越卡
如果敌人数量没有上限,玩得越久敌人越多,帧率就会下降。解决办法是给敌人列表设一个最大长度,比如 20 个,超过就不再生成新的。或者更优雅的做法是:敌人超出屏幕底部就立即移除,这样正常情况下屏幕上同时存在的敌人不会超过十几个。
还有一个容易被忽略的性能问题:每帧都重新创建字体对象。pygame.font.SysFont()的调用开销不小,应该只在初始化时创建一次,后面重复使用。
4.5 按键没反应或反应迟钝
按键问题通常和事件处理方式有关。如果你用的是event.get()来检测按键,那么按键事件只在按下的那一瞬间触发一次,按住不放不会持续触发。对于需要持续移动的场景,应该用pygame.key.get_pressed()来获取当前按键状态。
另一个可能的原因是窗口没有获得焦点。在某些系统上,如果窗口不是当前活动窗口,键盘事件不会被捕获。确保游戏窗口处于最前面并且被点击过。
提示:调试按键问题时,可以在循环里打印
pygame.key.get_pressed()中相关键的值,看看按下时是否变成了 True。这是最直接的排查手段。
4.6 分数显示乱码
如果分数文本显示成方块或问号,说明字体不支持你使用的字符。用英文和数字就不会有这个问题。如果确实需要显示中文,需要指定一个支持中文的字体文件路径:
font = pygame.font.Font("path/to/chinese_font.ttf", 36)但字体文件的分发是个麻烦事,入门项目建议直接用英文界面,省去这些折腾。
5. 可扩展方向与个人实操体会
5.1 让游戏更有趣的几个改造思路
基础版本跑通之后,可以尝试加一些新元素来练手。比如道具系统:随机生成绿色的加速道具,吃到后玩家移动速度提升 50%,持续 5 秒。这个改造涉及新的实体类型、状态计时器和效果叠加逻辑,是很好的练习。
再比如多种敌人类型:有的敌人会左右摆动,有的敌人会加速下落,有的敌人需要被撞击两次才消失。每种敌人只需要在更新逻辑里加一个分支,但游戏体验会丰富很多。
还有本地最高分记录:把最高分写入一个文本文件,每次启动时读取。这个功能涉及文件读写和异常处理,是很好的综合练习。
5.2 我在这个项目上踩过的坑
第一个坑是忘记重置生成计时器。游戏结束后重新开始,如果spawn_timer没有归零,可能会立即生成一个敌人,玩家还没反应过来就撞上了。这个 bug 我调试了半个小时才找到,因为逻辑上“看起来没问题”。
第二个坑是敌人速度没有随难度更新。我在生成敌人时把速度写死成了初始值,导致难度提升后生成间隔变短了,但敌人下落速度没变,难度曲线完全不对。后来改成从难度配置表里读取当前速度才解决。
第三个坑是窗口尺寸硬编码。我一开始在多个地方直接写了 480 和 640,后来想改成全屏模式时发现要改十几处。建议把窗口宽高定义成常量,所有地方都引用常量,改起来就方便了。
5.3 给新手的练习建议
如果你跟着这个思路把游戏跑通了,我建议你不要急着加新功能,而是先做三件事:第一,把代码从头到尾读一遍,给每个函数写上注释,确保自己理解每一行的作用;第二,尝试不看教程从零重写一遍,遇到卡住的地方再回去看;第三,故意改坏一些参数,比如把速度调到 5000,看看会发生什么,理解参数之间的制约关系。
这个项目虽然简单,但它是很多复杂游戏的雏形。把这里的每一个细节都吃透,后面学精灵组、学瓦片地图、学状态机,都会顺理成章。我见过太多人急着做“大项目”,结果基础不牢,遇到问题就卡死。慢就是快,把一个小项目做精,比做十个半成品有价值得多。