很多人的编程生涯里,都憋着一个小冲动:做游戏。但真到了动手那一步,要么被游戏引擎密密麻麻的界面劝退,要么在文档海里游到一半就弃了。我个人的建议很简单:想用代码做一个能玩的小游戏,先别碰大引擎,直接用Pygame把每一步走一遍。Pygame是Python生态里最经典的2D游戏开发库,它负责开窗口、画图形、读键盘鼠标、播声音,但游戏规则完全由你自己写。
这篇博文会用它做一个“接水果”小游戏:一个托盘在屏幕底部左右移动,接住从顶上不停掉下来的水果,接一个加一分,漏掉就游戏结束。整个流程下来,你会亲手写完窗口初始化、主循环、键盘输入、移动控制、碰撞检测、计分和重新开始这些最核心的东西。这篇文章适合刚学完Python基础、想用项目练手的人,也适合在大引擎里被复杂流程搞到头大、想回来把基础概念补扎实的人。我会把代码拆开讲,也会把那些不踩一遍根本想不到的坑,一块儿告诉你。
1. 为什么是Pygame:游戏开发的第一个台阶
1.1 Pygame能做什么,不能做什么
Pygame本质上是SDL图形库的Python绑定,你可以把它理解成一个“用Python开2D小窗口的基础工具箱”。它能做的事很明确:创建窗口、在窗口里画矩形圆形和文字、加载图片、读取键盘鼠标输入、播放音效音乐,以及做最简单的矩形碰撞检测。这些刚好覆盖了一款2D小游戏所需的大部分底层能力,而且每一行代码都看得见摸得着,没有魔法。
它不能做什么?不能做3D渲染,不能帮你做物理模拟,没有现成的场景管理、动画状态机、对象组件那一套。很多游戏引擎把“装修”都替你做好了,Pygame则更像毛坯房加工具箱,每一块砖都要自己动手砌。这正是我推荐它作为第一个游戏项目的原因:正因为一切都得自己写,你才能看清一个游戏到底是怎么跑起来的。等把这些基础吃透了,再去用大引擎,你也不会被那些自动化功能搞糊涂。
1.2 第一个项目为什么选“接水果”
入门项目选择很有讲究。贪吃蛇要处理蛇身增长和自碰撞,俄罗斯方块要处理旋转和消行,对刚接触游戏开发的人来说,规则复杂度都偏高。而“接水果”这个项目,规则一句话就能说清:一个会动的物体,被一个受控物体接住,没接住就结束。它的复杂度恰好卡在一个很妙的位置,不会让你陷进规则细节里,又能让你走完游戏开发的完整闭环。
这个项目覆盖了哪些核心点:持续按键控制、物体运动、屏幕边界判断、矩形碰撞检测、计分系统、游戏状态切换、重新开始逻辑。你可能会惊讶,就这么个看起来简单的小游戏,居然能串起这么多知识点。而且它的扩展空间很大,改成接金币、接星星、躲子弹,都只需要替换图形和碰撞逻辑,玩法骨架完全不用动。所以我一直觉得,第一个游戏项目选它,是性价比最高的选择。
2. 动手前必须搞懂的4个核心概念
闭眼写代码之前,我觉得有必要先把Pygame里最关键的4个概念讲透。这几个概念听上去抽象,实际理解了以后再看代码,会顺畅得多。
2.1 Surface:屏幕上的一切都是一张“画布”
Pygame里的Surface是一个非常重要的概念。你可以把整个游戏窗口理解成一张大画布,这张画布本身就是一个Surface对象,也就是代码里的screen。你在窗口里看到的任何东西,不管是背景、托盘、水果还是文字,本质上都是先把内容画到某张Surface上,再用blit这个方法把它“贴”到另一张Surface上。
举个例子,screen.blit(score_surface, (10, 10))的意思就是把已经画好字的score_surface贴到屏幕坐标(10,10)的位置。窗口本身用pygame.display.set_mode创建,每次画完所有内容后,必须调用pygame.display.flip(),把这次画上去的内容真正呈现出来。如果不调flip,画面就一直停留在上一次的内容上,这也是很多人发现“画了但看不到”的原因。
还要提醒一句:Pygame的坐标原点在窗口左上角,x向右增长,y向下增长,跟数学课里的坐标系方向不同。绘制水果圆形的圆心坐标、文字的左上角坐标,全都要按这个坐标系来算。一开始不习惯没关系,画几次图就熟了。
2.2 Rect:位置、大小与碰撞检测
Rect是Pygame里代表“矩形区域”的对象,用x、y、width、height四个值就能定义一个矩形。它最方便的地方在于,提供了大量现成的属性,比如left、right、top、bottom、centerx、centery、midbottom。你不需要自己算托盘最左边在哪,直接读basket.left就行,代码会清晰很多。
碰撞检测是游戏开发的核心环节。Pygame提供的最基础碰撞检测就是rect1.colliderect(rect2),判断两个矩形是否相交。本文里的接水果,就是不断判断托盘矩形和水果矩形有没有重叠。注意,这个方法是基于轴对齐矩形的碰撞检测,也就是AABB思路。以后如果做圆形子弹,想更精确的话,还得用圆心距离来判断,但那是后话。
有一点需要特别留意:Rect的属性都是整数,当你把坐标赋值给它时,浮点数会被自动取整。这意味着如果用浮点数来记录物体位置,不能直接写进Rect,否则每帧都会丢精度。更规范的做法是用单独的变量记录精确位置,每帧算好以后再回填进Rect。小游戏里移动步长是整数,暂时没这个困扰,但你要知道有这回事。
2.3 事件处理:单次按键与持续按键
新手最容易搞混的,就是Pygame里两种不同的输入读取方式。
第一种是事件队列。pygame.event.get()会把这段时间内发生的窗口事件、键盘事件、鼠标事件都取出来,你逐个判断类型来处理。它适合单次触发的情形,比如关窗口、按一下空格键重新开始游戏。第二种是按键状态轮询,pygame.key.get_pressed()返回当前所有按键的按下状态,适合需要持续响应的操作,比如按住左方向键让托盘一直向左移动。
为什么不能用事件来实现持续移动?因为event里那个KEYDOWN事件,只在按键刚按下的那一瞬间触发一次。你按住左键不松手,它不会每秒重复触发60次,所以把移动逻辑写在KEYDOWN里,结果就是按一下移一下,根本没法玩。正确做法是把移动逻辑放进get_pressed的轮询里。这个坑几乎每个新手都会踩,我先帮你排掉了。
另外要记住,事件循环里一定要处理pygame.QUIT事件,否则点了窗口的关闭按钮,程序不会正常退出,表现就是窗口卡死。很多新手就栽在这上面。
2.4 Clock与主循环:游戏的“心脏”
Pygame游戏的核心是一个不断重复的while循环,叫主循环。每一次循环就是一帧,包含四步:读取并处理输入事件、更新游戏里的状态数据、绘制本帧画面、刷新屏幕。只要这个循环还在跑,游戏就活着;循环一旦卡住或者退出条件写错,游戏就假死了。
循环的节奏由Clock控制。pygame.time.Clock().tick(60)的意思是,让这个while循环每秒最多执行60次,也就是60FPS。没有这一行,游戏会以机器的极限速度跑,水果一帧飞出去几百像素,根本没法玩;有了它,游戏速度才和帧率绑定,逻辑才可控。具体来说,如果每帧水果下落4像素,在60FPS下每秒下落240像素,这个数字是稳定的,不管电脑配置如何。
主循环里的顺序也值得注意:先处理输入,再更新位置,最后绘制。如果你把绘制放在更新之前,画面就会慢半拍,看起来总是比操作迟一帧。虽然只是一帧,手感差别却很明显。
3. 从零实现:接水果小游戏完整指南
概念讲完,开始写真正的代码。我按步骤拆开,每一步都给代码和解释,最后再放一个可以直接运行的完整版本。
3.1 初始化窗口与游戏常量
先创建文件和基础框架:
import pygame import random pygame.init() WIDTH, HEIGHT = 480, 640 FPS = 60 SKY_BLUE = (135, 206, 235) BLACK = (0, 0, 0) RED = (220, 80, 60) ORANGE = (255, 165, 0) screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("接水果小游戏") clock = pygame.time.Clock()这里之所以先把宽度高度、颜色、帧率都定义成常量,是为了以后改起来方便。你想把窗口改成手机竖屏尺寸,只改WIDTH和HEIGHT两个值就行。颜色用RGB三元组表示,每个分量范围0到255,(135, 206, 235)是天蓝色,用来做背景比较清爽。
pygame.init()会把Pygame的各个模块都初始化一遍,属于省事写法。set_mode创建窗口后,set_caption设置窗口标题。clock在后面的主循环里用来控制帧率,先创建好等着用。
3.2 玩家托盘:键盘控制与边界限制
在窗口底部放一个橙色的托盘,用左右方向键控制:
basket_w, basket_h = 80, 20 basket_speed = 7 basket = pygame.Rect(WIDTH // 2 - basket_w // 2, HEIGHT - 60, basket_w, basket_h)托盘的中心初始位置放在窗口底部中间偏上一点,用HEIGHT - 60留出安全距离。Rect的四个参数分别是左上角x、左上角y、宽、高。这个Rect既参与绘制,也参与碰撞检测。
主循环里的控制逻辑长这样:
keys = pygame.key.get_pressed() if keys[pygame.K_LEFT] and basket.left > 0: basket.x -= basket_speed if keys[pygame.K_RIGHT] and basket.right < WIDTH: basket.x += basket_speed用get_pressed读取按下状态,按住方向键就会持续移动。basket.left > 0这个判断,是保证托盘不会从窗口左边跑出去;basket.right < WIDTH则是保证右边不越界。这一步非常关键,没有边界限制的话,托盘会跑到屏幕外面去,体验很差。
basket_speed为什么设成7?因为在60FPS下,托盘每秒移动420像素,横跨整个480像素的窗口只需要一秒多,反应时间和手感都比较合适。设成5会稍微肉一点,设成10就有点发飘了。这个参数大家可以根据自己的手感微调。
如果想让游戏支持鼠标控制,也很简单,把键盘控制替换成一行:
basket.centerx = pygame.mouse.get_pos()[0]鼠标在哪,托盘中心就在哪。这个小改动就能让游戏玩法从键控变成鼠标跟手模式,适合不喜欢键盘操作的玩家。
3.3 水果下落:运动、漏接与碰撞判定
接着创建一个水果Rect:
fruit_size = 22 fruit_speed = 4 fruit = pygame.Rect(random.randint(fruit_size, WIDTH - fruit_size), 0, fruit_size, fruit_size)初始x坐标在fruit_size和WIDTH - fruit_size之间随机,是为了保证水果不会从窗口侧边“出生”,至少有一半在屏幕内。y坐标从0开始,也就是从窗口顶部掉下来。
主循环里,水果每帧下落fruit_speed个像素,并且和托盘做碰撞检查:
fruit.y += fruit_speed if basket.colliderect(fruit): score += 1 fruit.y = 0 fruit.x = random.randint(fruit_size, WIDTH - fruit_size) if fruit.y > HEIGHT: game_over = True这里有一个细节:碰撞检测的时机要放在水果位置更新之后,否则这一帧的位置还没跟上,碰撞结果就滞后了。接住水果后,要做的不是销毁它,而是把它重置回顶部,并重新随机一个x坐标。这个操作很典型,所有“接住东西”类型的游戏都是这个思路,理解一次就能用在别处。
漏接判断用fruit.y > HEIGHT,意思是水果完全掉出窗口底部,这时置game_over为True。有人会问为什么不用fruit.top > HEIGHT,其实二者差别不大,因为水果一共才22像素高,用y其实已经有点迟了,用top会让判定稍微早一点,更严格一些。怎么选取决于你想要的手感。
3.4 计分系统与游戏状态
计分和游戏状态,本质上就是两个变量加一段文字渲染:
score = 0 game_over = False分数的显示需要用到字体模块。中文字体是个容易踩坑的地方,不同操作系统可用字体不一样,直接写死某个字体名很可能显示成方框。我建议用一个兼容加载函数:
def load_font(size): for name in ["microsoftyahei", "pingfangsc", "notosanscjksc", "simhei"]: path = pygame.font.match_font(name) if path: return pygame.font.Font(path, size) return pygame.font.Font(None, size) font = load_font(32)pygame.font.match_font会在系统字体库里搜索对应名称。Windows下大概率能找到微软雅黑,macOS下找到苹方,Linux下找到Noto Sans CJK。万一都没找到,就用默认字体兜底。这段代码看起来多,但能一劳永逸地解决中文乱码问题。
绘制文字到屏幕上:
score_surface = font.render(f"得分: {score}", True, BLACK) screen.blit(score_surface, (10, 10))font.render把文字渲染成一个Surface,第二个参数True表示开启抗锯齿,边缘会更平滑。然后blit到屏幕左上角。游戏结束时,画一行提示文字:
tip_surface = font.render("游戏结束,按空格重新开始", True, BLACK) screen.blit(tip_surface, (WIDTH // 2 - tip_surface.get_width() // 2, HEIGHT // 2))居中显示的小技巧用上了get_width。因为文字Surface的宽度是未知数,先用WIDTH除以2减去宽度一半,就能算出左上角坐标,让文字保持在窗口水平居中。
重新开始的逻辑放在事件处理里,因为“按一下空格”是单次触发:
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE and game_over: game_over = False score = 0 fruit.y = 0 fruit.x = random.randint(fruit_size, WIDTH - fruit_size)重置时要把游戏状态、分数、水果位置都恢复初始值,一样都不能漏。漏掉score清零的话,就会玩一次游戏分数越攒越多,完全不对。
到这里,核心代码已经全部给出。把下面这一整段保存为game.py,在安装好Pygame的环境里运行,就能玩到最基础的版本了:
import pygame import random pygame.init() WIDTH, HEIGHT = 480, 640 FPS = 60 SKY_BLUE = (135, 206, 235) BLACK = (0, 0, 0) RED = (220, 80, 60) ORANGE = (255, 165, 0) screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("接水果小游戏") clock = pygame.time.Clock() def load_font(size): for name in ["microsoftyahei", "pingfangsc", "notosanscjksc", "simhei"]: path = pygame.font.match_font(name) if path: return pygame.font.Font(path, size) return pygame.font.Font(None, size) font = load_font(32) basket_w, basket_h = 80, 20 basket_speed = 7 basket = pygame.Rect(WIDTH // 2 - basket_w // 2, HEIGHT - 60, basket_w, basket_h) fruit_size = 22 fruit_speed = 4 fruit = pygame.Rect(random.randint(fruit_size, WIDTH - fruit_size), 0, fruit_size, fruit_size) score = 0 game_over = False running = True while running: clock.tick(FPS) 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: game_over = False score = 0 fruit.y = 0 fruit.x = random.randint(fruit_size, WIDTH - fruit_size) if not game_over: keys = pygame.key.get_pressed() if keys[pygame.K_LEFT] and basket.left > 0: basket.x -= basket_speed if keys[pygame.K_RIGHT] and basket.right < WIDTH: basket.x += basket_speed fruit.y += fruit_speed if basket.colliderect(fruit): score += 1 fruit.y = 0 fruit.x = random.randint(fruit_size, WIDTH - fruit_size) if fruit.y > HEIGHT: game_over = True screen.fill(SKY_BLUE) pygame.draw.rect(screen, ORANGE, basket) pygame.draw.circle(screen, RED, (fruit.x + fruit_size // 2, fruit.y + fruit_size // 2), fruit_size // 2) score_surface = font.render(f"得分: {score}", True, BLACK) screen.blit(score_surface, (10, 10)) if game_over: tip_surface = font.render("游戏结束,按空格重新开始", True, BLACK) screen.blit(tip_surface, (WIDTH // 2 - tip_surface.get_width() // 2, HEIGHT // 2)) pygame.display.flip() pygame.quit()注意水果在视觉上是圆形,但碰撞用的仍然是它外接的Rect。这样碰撞判定会比看到的水果稍大一点,新手玩起来会感觉“容错率”高一点,不会因为差一个像素接不到而烦躁。这其实是个聪明的设计技巧,在游戏里适当把碰撞区域做得比视觉元素宽松,可以显著提升手感。
4. 让游戏更像游戏:体验优化三板斧
基础版能玩了,但说实话它还不太像个“游戏”,顶多算个交互程序。下面三个优化,成本很低,效果却很明显。
4.1 用简单的图形和配色提升观感
游戏的视觉观感,很大程度上取决于配色和几何图形。天蓝色背景配橙色托盘,再配红色圆球水果,对比度足够,一眼就能看清重点。这种“亮背景加纯色元素”的方案,比纯黑背景加白方块好看得多。
绘制圆球用的是pygame.draw.circle,它接受目标Surface、颜色、圆心坐标和半径。这里圆心坐标用fruit.x + fruit_size // 2,是因为Rect记录的是左上角坐标,而圆形需要的是中心点,所以要换算一下。半径取了fruit_size的一半,正好让圆和外接Rect边缘贴合。
如果你的Pygame版本是2.0以上,还可以给托盘画圆角,让画面更柔和:
pygame.draw.rect(screen, ORANGE, basket, border_radius=5)注意,旧版本Pygame不支持border_radius参数,如果你用这个参数报错,先检查一下自己的pygame版本。一个简单的判断方法:如果你成功安装的是pygame 2.x,放心用;如果是pygame 1.9这一类旧版本,就别加了。
4.2 文字渲染:解决中文显示
文字渲染最核心的三个要素:字体、内容、位置。字体要解决“能不能显示中文”的问题,内容要区分“分数”和“结束提示”,位置要做到不挡核心画面。
之前的load_font函数已经解决了字体问题。不过还有个细节值得注意:font.render渲染出来的文字Surface,一旦生成就会常驻内存。如果每帧都重新渲染同一个字符串,会浪费一点性能。这个小游戏规模不大,问题不明显;但养成“只在内容变化时重新渲染文字”的习惯,对后续做大项目有好处。
文字位置除了靠get_width计算水平居中,还有垂直居中同理:用get_height,再配合目标区域的高度去算。如果你以后要做菜单、按钮、多行提示,这个计算方式会反复用到。
4.3 难度递增:让玩法更有层次
游戏好不好玩,很大程度上取决于有没有难度曲线。现在的版本玩久了会觉得平淡,因为掉落速度从头到尾不变。最简单的做法是每得10分,就让下落速度提高一点,同时封顶,避免速度快到无法操作:
# 在初始化时把 fruit_speed 定义为全局变量 fruit_speed = 4 # 在得分后更新速度 if basket.colliderect(fruit): score += 1 fruit_speed = min(4 + score // 10, 12) fruit.y = 0 fruit.x = random.randint(fruit_size, WIDTH - fruit_size)这里min(4 + score // 10, 12)的意思是:速度从4开始,每得10分加1,但最多加到12。用floor division“整数除”来分组,是一个很常见的做法——score // 10取的是十位上的数字,score等于9时结果是0,score等于10时结果是1。
重新开始的时候,别忘了把fruit_speed也重置回4,否则上一次游戏攒下的速度会带到下一局,体验就很奇怪了。这就是所谓的“状态重置完整性”,一个很容易埋bug的地方。
如果你想做更平滑的曲线,还可以把公式改成“速度 = 基础速度 + 得分的平方根×系数”,但那属于后期调优。对小项目来说,整除加分法简单直观,够用。
5. 新手最容易踩的坑,与我的排查思路
写Pygame的过程里,我见过太多人卡在几个莫名其妙的问题上。这些问题排查起来其实不难,但没经验的人很容易绕远路。我把高频问题整理成一套排查思路,照着走基本能解决。
5.1 窗口闪退、卡死与事件处理
症状一:运行后窗口一闪而过,瞬间退出。通常原因是主循环没有正常启动,或者代码里有语法错误被抛出了异常。排查方法是看命令行回显的报错信息,不要只看窗口一闪就完了。语法错误会直接告诉你哪一行有问题,修掉就行。
症状二:窗口能开,但点叉没反应,系统提示“未响应”。十有八九是主循环里漏了pygame.QUIT的事件处理。只要你在事件循环里没有对pygame.QUIT做响应,这个循环就会一直跑下去,窗口自然关不掉。标准做法就是在for event in pygame.event.get()里,判断到QUIT就修改running变量退出循环。
还有一个隐蔽的坑:如果在一个函数里使用pygame.quit(),但主循环没有退出条件,程序还是会卡在while循环里。退出的正确姿势是“事件触发->修改循环条件->退出循环->调用quit”,顺序不能乱。
5.2 物体“瞬移”和碰撞穿透之谜
物体瞬移,最典型的原因是没写clock.tick(FPS)。没有帧率控制时,while循环会尽可能快地跑,每帧移动4像素看上去就已经是飞一般的感觉了。解决方法就是加上clock.tick(FPS),把速度锁住。
还有一种情况是碰撞穿透,明明看起来碰到了,却判定失败。这通常和步长有关。比如水果下落速度改成20,每帧移动20像素,而托盘高度只有20像素,就有可能在两帧之间“跳”过托盘,也就是经典的问题:两个物体在某一帧时还没重叠,下一帧水果已经越过托盘。这种情况叫隧道效应。解决办法是把移动步长控制在物体尺寸的一半以内,或者把碰撞检测做得更细,比如每隔几个像素就检测一次。新手期用前一种方案就够了。
位置更新的顺序也很关键。如果你先画图再更新坐标,画面总比逻辑慢一帧,碰撞感觉也会差一点。把“更新位置”放在“绘制”之前,是标准做法。
5.3 中文乱码、打包分享与稳定性
中文乱码的原因只有一个:用的字体不支持中文字符。不要迷信pygame.font.SysFont("某个已知字体名"),因为不同系统里字体名的写法完全不一样,写错名字时它不会报错,只是静默地使用默认字体,然后中文变成方框。最稳妥的做法就是前面给出的load_font函数,把常用中文字体的各种命名都试一遍,总有一款能命中。
游戏写完想发给朋友玩也很常见。用PyInstaller打包时,记得带上Pygame的全部依赖,否则打包出来的exe可能跑不起来:
pyinstaller --onefile --windowed --collect-all pygame game.py--collect-all pygame的意思是收集pygame模块的所有资源和动态库,这是打包Pygame项目的关键。最后生成的exe会比较大,几十MB很正常,因为里面包含了Python运行时和整个Pygame模块,别嫌它大。
最后一条稳定性建议:退出前调用pygame.quit(),同时把running设为False,让主循环自然结束。如果你在终端里运行游戏,可以再加一个print提示,方便确认程序确实退出到了终端而不是卡在后台。
写到这里,整个接水果小游戏从新建文件到打包分享的完整路径都走了一遍。我在实际带人做这个项目时发现,最快理解游戏逻辑的办法,不是照抄代码,而是故意改坏几个地方:比如把clock.tick去掉看看会发生什么,把get_pressed换成KEYDOWN试试还会不会连续移动,把colliderect改成手工判断坐标区域。每踩一个坑,你对Pygame运行机制的理解就会深一层。
这个项目做完以后,我强烈建议你不要停下来。试着给水果加多种颜色、在战败时播放一段提示音、给玩家加三条命,或者把“接水果”改造成“躲避炸弹”——玩法变了,但窗口、主循环、事件处理、碰撞检测这套骨架完全不用动。你会发现,那些当初看着高深的概念,拆开以后其实也就这些东西。我的经验是:第一个游戏不要求炫,但求完整地走通“输入-逻辑-渲染-反馈”的闭环。走通一次,后面再做任何2D小游戏,你都心里有底了。