前两天有个朋友问我:“我想写个小游戏,但一看Unity就头大,有没有更轻的入口?”我直接告诉他:去试试Pygame。不是因为它能做出多惊艳的作品,而是因为它足够简单,简单到你能在几个小时内跑通一个完整的小游戏,并且真正理解游戏运行的本质。我最早接触Pygame时,花了不到一个周末就写出了第一个贪吃蛇。那段时间踩过的坑、悟出来的道理,到现在做其他图形项目还在用。所以这篇文章,我想用一套“先拆思路、再写代码、最后修坑”的方式,带你用Pygame开发你的第一个小游戏。
这篇文章不是让你照抄一段代码就完事,而是把背后的设计逻辑、坐标换算、事件循环这些关键点掰开讲清楚。适合刚学Python、想接触游戏开发的初学者,也适合想给学生带兴趣班的老师做参考。整个过程中,你不需要掌握高深的算法,只需要基本的Python语法和一点点耐心。
1. 为什么要用Pygame做第一个小游戏
1.1 Pygame到底适合谁
很多初学者一上来就想做“3D大作”,结果被引擎的编辑器、资源管线和各种组件劝退。Pygame不是引擎,它更像是一盒积木:窗口、图片、矩形、声音、事件,所有东西都是Python对象,你可以直接控制每一个细节。这种“底层但可控”的感觉,恰好是建立游戏思维最好的方式。
Pygame适合三类人:
- 刚学会Python基础语法的人:你可以不用理解复杂的类继承,先用函数写一个游戏,再慢慢重构。
- 想理解游戏循环和事件驱动的人:用Pygame你能清晰看到“每一帧做了什么”,而不是被引擎封装得严严实实。
- 需要快速出原型的人:比如某个创意关卡想验证手感,用Pygame比开一个重量级引擎快得多。
但要注意,Pygame不适合做重度商业游戏,它的性能上限摆在那里。我做第一个小游戏时压根不考虑这些,因为目标不是做产品,而是验证“我能独立完成一个游戏”。
1.2 和网页游戏、Unity相比,Pygame赢在哪里
拿当时我同样试过的几个方案来看,Pygame的优势很突出:
| 对比项 | Pygame | 网页版 Canvas / Phaser | Unity / Godot |
|---|---|---|---|
| 环境复杂度 | 只要Python和pip | 需要浏览器调试,还要选框架 | 几个GB的编辑器,学习曲线陡峭 |
| 代码可见度 | 所有逻辑都在py文件里 | 逻辑被框架和DOM事件打散 | 脚本和编辑器拖拽混合 |
| 适合入门 | 非常适合 | 中等 | 偏重 |
| 做第一个游戏的时间 | 半天到两天 | 两天到一周 | 一周以上 |
我自己用Unity做过一个2D demo,光是场景管理和碰撞体层级就折腾了很久。而用Pygame时,一个窗口、一个while循环就撑起了全部游戏。Pygame不是万能药,但作为“第一个小游戏”的起点,它几乎是最优解。
2. 开发前的准备:环境、目录和游戏循环
2.1 安装Pygame的版本选择
安装很简单,但版本问题值得说一句。我用的是Python 3.10,当时直接执行了:
pip install pygame如果你在Python 3.12或更高版本上遇到兼容问题,建议用官方预编译的wheel包。Windows用户注意,Pygame有两个分支:一个是社区维护版pygame,另一个是旧版pygame-ce。普通学习直接装pygame就行,性能差别在这个规模下感知不强。
安装完成后,验证一下:
import pygame print(pygame.version.ver)能打印出版本号,环境就准备好了。很多人卡在“安装了却无法导入”,多半是pip装到了另一个Python环境,比如同时装了Anaconda和系统Python。这时候在终端里输入where python或which python,再用对应的pip重新安装。
2.2 项目目录和最小骨架
做第一个小游戏,我建议用一个干净的目录结构,而不是把所有代码塞进一个文件。推荐这样:
snake_game/ ├── main.py ├── settings.py ├── game.py ├── player.py ├── food.py └── assets/一开始可能觉得拆这么细很麻烦,但当你改到第20版就会发现,每个模块只做一件事,排查问题容易得多。当然,如果只是练手,先写一个main.py也可以,我后来是重构时才拆开的。
最小骨架就是打开一个窗口,并让它不断刷新而不卡死:
import pygame pygame.init() screen = pygame.display.set_mode((640, 480)) pygame.display.set_caption("我的第一个小游戏") running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False pygame.display.flip() pygame.quit()这段代码虽然什么都不做,但它是所有Pygame游戏的地基。pygame.init()负责初始化所有模块,set_mode创建窗口,while循环是游戏的“生命线”。没有这个循环,窗口会一闪而过。
2.3 pygame.display.flip和update到底有什么区别
这里有个细节坑了我第一次。pygame.display.flip()是整窗口刷新,pygame.display.update()可以只刷新指定区域。新手阶段统一用flip()没有毛病,因为画面简单。但如果你后面做了大面积动态区域,用update局部刷新能明显省性能。
用个比喻:flip是把整张画布重新贴到屏幕上,update则是只贴你改过的补丁。小游戏里性能瓶颈不在显示,所以放心用flip。
还有帧率控制。经典写法是:
clock = pygame.time.Clock() while running: clock.tick(60) ...clock.tick(60)的意思是限制每秒最多跑60帧,避免CPU空转。我见过有人不写这行,结果游戏画面加速到每秒几千帧,蛇条一秒钟穿过整个屏幕。帧率控制不只是优化,更是游戏逻辑稳定运行的前提。
3. 核心思路拆解:从零设计一个小游戏
3.1 为什么选贪吃蛇作为第一个项目
我当时选贪吃蛇不是随机的。贪吃蛇涵盖了几乎所有2D游戏的基础元素:
- 玩家输入(方向键控制)
- 对象移动(蛇头每帧按方向移动)
- 碰撞检测(撞墙、撞自己、吃到食物)
- 随机生成(食物)
- 状态管理(运行中、游戏结束)
- 分数系统(吃一个加一分)
这些元素同样适用于俄罗斯方块、飞机躲避、打砖块。可以说,你只要吃透贪吃蛇,再去做其他小游戏,就是在同一套框架里换皮、加规则。我在教一个朋友入门时也让他先做贪吃蛇,他两天后就能自己改出“吃一个食物蛇变短”的变种,这就是框架理解到位的表现。
3.2 先设计数据模型,再写界面
很多人写游戏上来就画窗口,然后走一步看一步。我吃过这个亏,结果代码写得一团乱。正确的做法是先想清楚游戏里有哪些“数据”。
贪吃蛇的数据模型在我看来只有三块:
- 蛇:一个由多个坐标点组成的列表,
snake = [(200, 200), (180, 200), (160, 200)] - 方向:当前移动方向,用向量表示,
(20, 0)表示向右 - 食物:一个坐标点,
food = (400, 300)
这里的关键是:蛇的身体不需要单独画图形,只需要保存格子坐标。每一帧把列表里的所有坐标画成小方块,看起来就是一条蛇。我见过有新手把每一节身体都做成一个对象,然后管理对象生命周期,反而把自己绕晕了。
为什么用列表顺序很重要?因为蛇移动的本质是“头部伸长、尾部缩短”。如果吃到了食物,尾部就不缩短,整体长度加一。所以对我来说,列表就是蛇的全部。
3.3 像素坐标和网格坐标,别搞混
贪吃蛇最简单的实现是把整个游戏区域按20像素一格分成网格。此时会遇到两个坐标系:
- 网格坐标:比如
(5, 3)表示第5列第3行 - 像素坐标:比如
(100, 60)表示在窗口中的具体位置
转换公式特别简单:
pixel_x = grid_x * grid_size pixel_y = grid_y * grid_size我当时犯过的错误是直接用像素坐标去移动,每次加20,看起来也能跑,但碰撞检测和食物对齐变得很麻烦。后来全部改成网格坐标,只在绘制时转成像素坐标,代码瞬间清爽了。
这个思路也适用于其他格子类游戏。先让逻辑运行在抽象坐标系里,最后才考虑“画出来”。这样游戏逻辑和界面显示解耦,想换皮肤或者改分辨率都容易。
4. 实操:一步步写一个能玩的贪吃蛇
4.1 初始化窗口和游戏常量
我建议把常量都放在开头,方便后期调整。下面是初始化部分:
import pygame import random pygame.init() GRID_SIZE = 20 CELL_SIZE = 20 WIDTH = 30 HEIGHT = 20 screen = pygame.display.set_mode((WIDTH * CELL_SIZE, HEIGHT * CELL_SIZE)) pygame.display.set_caption("贪吃蛇") clock = pygame.time.Clock() BLACK = (0, 0, 0) GREEN = (0, 200, 0) RED = (200, 0, 0) WHITE = (255, 255, 255)这里WIDTH = 30表示横向有30格,HEIGHT = 20表示纵向有20格,窗口实际像素是600×400。把格子数量和格子大小分开定义,好处是你想改难度时,不需要改所有坐标。
初始化后还要定义蛇、方向和初始分数。我习惯用一个reset_game()函数来重置所有状态:
def reset_game(): global snake, direction, next_direction, food, score snake = [(WIDTH // 2, HEIGHT // 2)] direction = (1, 0) next_direction = direction food = spawn_food() score = 0next_direction这个变量的存在是为了防止同一帧内多次按键导致方向冲突,后面会详细说。
4.2 控制蛇的移动与转向
蛇应该每隔一段时间动一次,而不是每帧都动。因为一帧的速度太快了。我设置一个计数器,每走5帧才让蛇移动一格,这样在60帧下有每秒12次移动,既流畅又不会太快。
MOVE_INTERVAL = 5 frame_count = 0 while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_UP and direction != (0, 1): next_direction = (0, -1) elif event.key == pygame.K_DOWN and direction != (0, -1): next_direction = (0, 1) elif event.key == pygame.K_LEFT and direction != (1, 0): next_direction = (-1, 0) elif event.key == pygame.K_RIGHT and direction != (-1, 0): next_direction = (1, 0) frame_count += 1 if frame_count >= MOVE_INTERVAL: direction = next_direction head = snake[0] new_head = (head[0] + direction[0], head[1] + direction[1]) snake.insert(0, new_head) snake.pop() frame_count = 0注意方向防反逻辑:当蛇正在向右走时,不能直接按左键掉头,否则蛇头会立刻穿过身体。我这里用direction != (1, 0)来判断“不是向左走”,按左时next_direction = (-1, 0),方向反了就不更新。
还有一个细节:direction = next_direction必须在移动时执行,而不是在按键时执行。因为同一帧里你可能快速按了两个键,如果立即改变方向,第二次按键会基于第一次按键后的方向判断,逻辑就乱套了。我把它称为“延迟应用方向”,这是新手最容易忽略的点。
4.3 食物生成与吃食物
食物不能在蛇身上,也不能在墙外。我写了一个简单函数:
def spawn_food(): while True: pos = (random.randint(0, WIDTH - 1), random.randint(0, HEIGHT - 1)) if pos not in snake: return pos这个写法看起来很笨,但胜在可靠。如果地图非常大,可以换成在空余格子列表中随机选一个,但早期游戏场景不过600格,while循环几乎不会超过几次。
吃食物的判定发生在蛇移动之后。我移动时先insert新头,再判断是否吃到食物。如果吃到了,就不执行pop(),这样蛇就多长了一节;同时分数加一,再生成新食物:
if new_head == food: score += 1 food = spawn_food() else: snake.pop()这里有个关键顺序:insert是先把新头加进列表,如果食物正好在旧头移动到的新位置,说明蛇吃到了。很多人会把pop()放在insert之前,导致蛇永远不会增长。我当初调试了半天才发现,增长的本质就是“不收缩尾端”,所以必须把pop()放在吃到食物的分支里。
4.4 绘制画面和游戏结束判定
绘制部分需要把网格坐标转换为像素坐标,然后画方块。我用pygame.Rect来管理位置和大小:
def draw(): screen.fill(BLACK) for segment in snake: rect = pygame.Rect(segment[0] * CELL_SIZE, segment[1] * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, GREEN, rect) food_rect = pygame.Rect(food[0] * CELL_SIZE, food[1] * CELL_SIZE, CELL_SIZE, CELL_SIZE) pygame.draw.rect(screen, RED, food_rect) pygame.display.flip()结束判定我放在移动逻辑之后:
head = snake[0] if head[0] < 0 or head[0] >= WIDTH or head[1] < 0 or head[1] >= HEIGHT: running = False if head in snake[1:]: running = False撞墙和撞到自己都算结束。这里判断head in snake[1:]会随着蛇变长越来越慢,但对我们的小地图完全够用。如果要优化,可以用一个set记录身体位置,时刻检查,不过那是进阶话题。
结束之后,我通常会显示一个简单的结束画面,而不是直接黑屏退出。早期版本我直接running = False,结果玩家体验很差:没有任何提示就消失。后来我改成在结束画面里展示得分,再按任意键重新开始。这一步看起来很小,却让游戏真正“完整”了。
5. 常见问题与排查技巧实录
5.1 我踩过的5个坑
坑一:按键方向导致蛇反向自杀。我第一次写时没有判断“当前方向”,按了上和左,蛇直接掉头穿过身体。后来加了个next_direction延迟应用才解决。
坑二:食物生成在蛇身上。如果只用random.randint不管蛇的位置,食物可能出现蛇身上,玩家永远吃不到。一定用pos not in snake判断。
坑三:画面闪烁。出现闪烁通常是因为没有先填充背景,或者没有用flip()刷新。解决方法是先screen.fill(BLACK),再绘制所有对象,最后flip()。
坑四:游戏速度不稳定。没有clock.tick(60)时,游戏在性能好的电脑上快得离谱。加上帧率限制后,不管什么机器,手感一致。
坑五:退出时程序卡死。常见原因是没有pygame.quit(),或者销毁了窗口还继续绘制。在循环退出后加一个pygame.quit(),并确保running = False后不再执行逻辑。
5.2 问题排查速查表
| 现象 | 可能原因 | 检查方法 |
|---|---|---|
| 窗口一闪而过 | 缺少主循环 | 确认有while running |
| 无法导入pygame | 装了多个Python | 用pip list检查当前环境 |
| 按键没反应 | 事件处理未写在循环里 | 确认pygame.event.get()在循环内 |
| 蛇不增长 | pop()写在分支外 | 确认只有没吃到食物时才pop() |
| 食物频繁刷在蛇身上 | 缺少位置排除 | 检查spawn_food()的判断 |
| 画面很卡 | 每帧全图刷新 | 考虑限制帧率或局部刷新 |
| 标题栏没有图标 | 没设置帧率 | 这不是问题,先忽略 |
排查的时候我建议多用临时输出。比如在移动后打印一下head坐标,看看是坐标计算错了还是绘制错了。打印可以快速缩小范围,比对着代码发呆高效十倍。
6. 让游戏更像“作品”:优化、打包与扩展
6.1 性能优化:从能用到够用再到好用
贪吃蛇这种规模,性能压力不大。但有几个习惯值得早点养成:
- 能用
pygame.Rect就少用pygame.draw.circle,矩形绘制速度更快。 - 不要频繁创建Surface,尽量在初始化时建好,然后
blit。 - 食物和蛇的绘制区域如果很小,可以只刷新这些区域,但这点优化在你做复杂界面之前可以忽略。
我曾经试着在游戏中加入了粒子效果,每个粒子都是一个Surface,结果帧率掉到20。后来发现问题是每帧都重新Surface,改成初始化时批量创建才恢复60。对于第一个小游戏,能跑满60帧就足够,不要过早优化。
6.2 用PyInstaller打包成独立程序
做完游戏,想让朋友直接运行,不能让他装Python。我用PyInstaller打包了很多次,踩过最典型的坑是资源路径问题。
简单打包命令:
pip install pyinstaller pyinstaller -F -w main.py-F是打包成单文件,-w是运行时不要终端窗口。如果游戏里引用了外部图片或音频,千万不能用相对路径直接写assets/xxx.png,因为PyInstaller会把程序解压到临时目录。我当时的解决方案是写一个函数,动态获取资源路径:
import sys import os def resource_path(relative_path): base_path = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base_path, relative_path)把这个函数用在加载图片和音频的地方,打包后就能正确找到资源。我一开始没这么做,发给朋友测试时背景图加载失败,黑屏了半天,后来查了官方文档才明白。
6.3 后续可以扩展的方向
游戏既然跑起来了,就别急着收工。我给了自己三个扩展方向,难度递增:
- 加障碍物:在棋盘上随机生成固定障碍,碰撞后结束,这是最简单的玩法扩展。只需要维护一个障碍物列表,碰撞判定和食物生成时排除这些坐标即可。
- 加速机制:每吃5个食物,移动间隔减少1,游戏越来越快。把
MOVE_INTERVAL改成变量,分数变化时更新即可。 - 菜单和音效:加入开始界面和背景音效,让游戏有完整的“包装”。这一步会接触更多Pygame的模块,比如
pygame.mixer和状态切换。
如果还想更深入,可以尝试做一个“多关卡壁障”。也就是每关结束后,重新生成一个更大的地图,同时保留之前的速度。这些扩展都会让你对状态机有更深的理解——游戏本质上是一堆状态的转移。
我个人的体会是,Pygame教我的不是某个库的API,而是“把抽象的逻辑变成可见画面”的能力。每次做完一个小游戏,我都会把逻辑模块拆出来复用,后来做第二个小游戏时,直接套用了这个贪吃蛇的代码骨架,只花了一个晚上就完成了新玩法。如果你也刚起步,建议别贪多,先把一个最简单的游戏做到能玩、能打包、能分享,你就会发现游戏开发并没有想象中那么难。