简介:面向Python初学者的pygame趣味小游戏完整源码包,以中秋兔子接月饼为主题,演示如何用pygame搭建一个完整且可交互的游戏循环。包内不仅包含游戏主体逻辑和精灵类定义,还提供了配套图片、背景音乐等全部素材,共计16个文件,其中10张图片用于角色和界面展示,3张图片作为场景背景,1个音频文件负责背景配乐,另有2个Python脚本;整个压缩包只有3.12MB,下载和使用都很轻量。项目共实现了月饼、兔子、背景、得分、倒计时、菜单、鼠标七个精灵,所有精灵都继承自pygame的Sprite基类,这样设计的好处是能够清晰体会面向对象编程与pygame精灵组机制的结合。通过阅读代码,可以学习到窗口初始化、游戏事件循环、碰撞检测、文字渲染、倒计时控制以及音频播放等多个核心模块的写法。目前已有168人学习下载,代码结构清晰、注释到位,既能够直接运行体验游戏效果,也适合作为Python课程设计或pygame入门练手的参考实例。
1. 兔子接月饼,Pygame 实战里最值得抄的第一份完整代码
有一类小游戏的源码在技术社区里被反复翻出来看:规则一眼就懂,但里面塞满了游戏开发的常用套路。兔子接月饼就是这样——玩家控制一只兔子左右移动,去接住从天上不断掉落的月饼,接得越多分越高,漏接三次游戏结束。很多第一次完整跑通 Pygame 的人,都死磕过这份代码的某个细节:为什么兔子用的是rect.x而不是坐标赋值、为什么月饼掉落的y值要不停加上speed、为什么碰撞检测放在event循环外面。
Pygame 写小游戏,本质就是在做三件事:初始化窗口和时钟、循环里处理用户输入、更新物体的坐标和碰撞状态。这份兔子接月饼源码把这三件事压缩在一个主循环里,肉眼能跟完,调试起来也不绕。对新手来说,它是从「照着官方文档画一个红色矩形」到「能拿得出手、能给别人玩」之间最近的一步;对有几年经验的工程师来说,它的价值在另一面:看别人如何把一个游戏的逻辑状态用最直接的方式表达出来,回头重构起来心里会特别有数。
2. Pygame 环境跑通与游戏主循环的最小骨架,先让窗口和时钟转起来
2.1 安装 Pygame 前先确认 Python 版本,macOS 和 Windows 的做法不一样
兔子接月饼源码依赖 Pygame,而 Pygame 的安装坑大半出在 Python 版本和虚拟环境上。现在多数发行版 Pygame 已经跟得上最新的 Python 3.12、3.13,但仍然建议项目单独建虚拟环境,不要直接装到系统环境里——后期你要 pip freeze 导出依赖,或者换机器跑源码时,虚拟环境能少一半麻烦。
python3 -m venv mooncake_env source mooncake_env/bin/activate # Windows 下是 mooncake_env\Scripts\activate pip install --upgrade pip pip install pygame提示:如果
pip install pygame报error: failed to build 'pygame' when getting requirements to build wheel,多数不是 Pygame 本身的问题,而是 pip 版本过旧,或系统 Python 缺少头文件。先pip install --upgrade pip,再装,绝大多数情况直接解决。
验证能不能用,一条命令进 Python 交互式环境:
import pygame pygame.init() print(pygame.version.ver)能打出版本号说明环境通了。切记这一步不要省——很多源码跑不起来的第一个原因,不是代码写错,是 Pygame 的某些依赖在系统里没装完全。
2.2 主循环的四段式结构,pygame 游戏跑起来全靠这一套
Python 的 Pygame 小游戏,不论兔子接月饼还是俄罗斯方块,主循环长相都差不多:初始化、事件处理、更新状态、绘制画面。这四段式是 Pygame 的固定套路,也是这份源码最容易让人看得一头雾水的地方。
import pygame def main(): pygame.init() screen = pygame.display.set_mode((800, 600)) pygame.display.set_caption("Mooncake Catcher") clock = pygame.time.Clock() running = True while running: # 1. 事件处理 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: running = False # 2. 更新游戏逻辑(兔子移动、月饼下落) # 3. 绘制背景和物体 screen.fill((30, 30, 60)) pygame.draw.rect(screen, (255, 240, 200), (400, 500, 80, 50)) # 4. 刷新画面 pygame.display.flip() clock.tick(60) pygame.quit() if __name__ == "__main__": main()代码里第 1 段pygame.event.get()是事件队列,鼠标、键盘的输入全在这里,必须每帧取一次,不取就堆积,窗口也会卡成未响应。第 2 段和第 3 段的顺序是一开始最容易搞反的点:先更新坐标再绘制,画面才是连续的;反过来就会看到物体拖着残影或者慢半拍。第 4 段clock.tick(60)是核心中的核心,它把循环锁在每秒 60 帧,保证不同电脑上游戏速度一致。
这段骨架能跑通,兔子接月饼的框架就算立住了。后面的兔子贴图、月饼下落、碰撞检测,全都是在这个循环里加代码块而已。
3. 兔子对象与月饼对象的建模思路,pygame 里的 Rect 和 Surface 是关键
3.1 Surface 代表可见物体,Rect 决定位置,兔子接月饼里每个角色都是这两个的组合
Pygame 里没有「角色」这个概念,所有看得见摸得着的东西都是Surface——就是一块矩形内存区域,用来装像素。Rect则是这块区域的几何信息,或者理解成一个不透明的碰撞盒子。兔子是一张图片加载成的 Surface,月饼也是一张图片加载成的 Surface,区别只在位置坐标和尺寸。
rabbit = pygame.image.load("rabbit.png").convert_alpha() rabbit_rect = rabbit.get_rect() rabbit_rect.x = 350 rabbit_rect.y = 500 mooncake = pygame.image.load("mooncake.png").convert_alpha() mooncake_rect = mooncake.get_rect() mooncake_rect.x = 200 mooncake_rect.y = 0convert_alpha()常见做法是加载 PNG 之后立刻调用,作用是把图片像素格式转换成 Pygame 内部最擅长绘制的格式,同时保留透明通道。如果你在源码里见过有人不写这一句,画面上往往会有黑色方块背景——那就是 alpha 通道没处理好。
Rect的几个常用属性在兔子接月饼里全都能用上:x、y是左上角坐标,centerx、centery是中心点,left、right、top、bottom是四边。碰撞检测时用两个rect.colliderect()直接判断,比手动比较 x、y 值快得多也准得多。
提示:鼠标控制兔子时不要直接
rabbit_rect.x = mouse_x,否则鼠标跳到屏幕另一端时兔子也瞬移过去,体验极差。常见做法是取鼠标 x 坐标后,让rabbit_rect.centerx跟随,并限制在窗口宽度范围内。
3.2 月饼下落的速度和加速度参数,抄源码时最需要改的就是这三个值
月饼掉落的实现逻辑极其朴实:每帧让mooncake_rect.y加一个固定值。至于加多少,直接决定游戏难度。看别人写的兔子接月饼源码,最常改的就是这三行:
mooncake_speed = 3 # 初始下落速度,单位是像素/帧 mooncake_accel = 1 # 每接住一个月饼后速度增量 mooncake_max_speed = 12 # 速度上限,防止游戏后期快得完全没法玩这三个参数放在while循环外面初始化,每接住一个就加一次速。为什么这样设计?因为如果从第一帧到最后都用一个速度,游戏曲线太平,玩家三分钟就腻了;如果无限加速,后期谁都防不住。减速带和上限是让游戏「越玩越快,但永远差一点」的关键。
在更新月的块下落逻辑的代码里,实际长这样:
if mooncake_rect.y < 600: mooncake_rect.y += mooncake_speed else: # 月饼掉出屏幕底部,视为漏接 lives -= 1 mooncake_rect.y = -50 mooncake_rect.x = random.randint(0, 700)月饼从y = -50开始往下掉,超出窗口底部就重置位置,重新从顶上丢下来。这里的随机 x 用了random.randint,所以每次掉落的位置不可预测——这是小游戏保证「每局都不一样」的最小手段。源码里一般不会做月饼的花样轨迹,抛物线、左右飘动都是进阶玩家的二次开发了。
3.3 碰撞检测用 colliderect 还是 mask,pickle 一个细节:图片透明区域的碰撞假阳性
兔子接月饼的碰撞逻辑,大多数源码直接用colliderect,代码三行搞定:
if rabbit_rect.colliderect(mooncake_rect): score += 10 mooncake_rect.y = -50 mooncake_rect.x = random.randint(0, 700)这行代码配合图片后,有一个隐蔽的问题:如果月亮糕图片是圆形的,但它的 Rect 是正方形,兔子碰到月饼图片的四角位置时,肉眼上还没接触,分数却已经加了。透明 PNG 图片越大,这四个角的误差越明显。
解决办法是靠pygame.mask做像素级碰撞。把兔子和月饼各做一份 Mask,再调用pygame.sprite.collide_mask:
from pygame.sprite import collide_mask rabbit_mask = pygame.mask.from_surface(rabbit) mooncake_mask = pygame.mask.from_surface(mooncake) if rabbit_mask.overlap(mooncake_mask, (mooncake_rect.x - rabbit_rect.x, mooncake_rect.y - rabbit_rect.y)): score += 10但mask碰撞需要额外分配内存和计算点阵,对兔子接月饼这种物体数量只有两个的小游戏来说完全无所谓;如果一份源码里同时有几十个月饼在飞,mask 碰撞的成本就要掂量下了。新手改源码时建议先一致用colliderect,跑顺了再升级成 mask,两者并存时反而容易出 bug。
4. pygame 的渲染循环与分数/生命值 HUD,把兔子接月饼从「能动」做到「能玩」
4.1 每帧填充背景、绘制图片与文字的固定顺序,pygame 的渲染顺序就是遮挡顺序
Pygame 没有 z-index 概念,后画的东西永远盖在先画的东西上面。兔子接月饼的渲染顺序通常是:背景色、月饼、兔子、HUD 文字、生命值。这个顺序看着随机,实际上决定了游戏的视觉正确性。
screen.fill((20, 20, 50)) # 1. 背景,清空上一帧残留 screen.blit(mooncake, mooncake_rect) # 2. 月饼,最早渲染 screen.blit(rabbit, rabbit_rect) # 3. 兔子,压在月饼上面 score_surface = font.render(f"Score: {score}", True, (255, 255, 255)) screen.blit(score_surface, (10, 10)) # 4. HUD 最上层 pygame.display.flip()screen.fill不写的话,上一帧的画不会消失,而是会全体残留在屏幕上,形成拖影。flip()把整块后台缓冲推到前台显示器上——这是双缓冲机制,每帧先在后台画完再一次展示,避免闪烁和撕裂感。
文字显示有两个坑是新手最容易踩的。第一个是字体,要用pygame.font.SysFont("simhei", 28)这种中文字体名称;用默认字体画中文,出来的是一堆方框。第二个是字体尺寸固定的情况下,把分数数字画上去后,如果数字位数变了,需要用render返回的surface.get_width()计算宽度,才不会覆盖到别的东西。
4.2 用精灵组 Sprite 统一管理多个月饼,pygame 的 group 机制比裸 Rect 列表抗造
基础版本里一个月饼接完重置,位置自己管,代码还好写。但到了有二三十个月饼满天飞的地步,手写列表管理坐标和状态会让代码变成一堆for和if。Pygame 的精灵组机制就是为这个场景准备的。
import pygame import random class Mooncake(pygame.sprite.Sprite): def __init__(self): super().__init__() self.image = pygame.image.load("mooncake.png").convert_alpha() self.rect = self.image.get_rect() self.rect.x = random.randint(0, 700) self.rect.y = -30 self.speed = random.randint(2, 6) def update(self): self.rect.y += self.speed class Rabbit(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image = pygame.image.load("rabbit.png").convert_alpha() self.rect = self.image.get_rect(topleft=(x, y))Sprite子类里,image和rect两个名字是约定俗成,pygame.sprite.Group的绘制和碰撞方法都认这两个属性。主循环里的调用就变得异常简洁:
all_sprites = pygame.sprite.Group() mooncakes = pygame.sprite.Group() rabbit = Rabbit(350, 500) all_sprites.add(rabbit) for _ in range(10): m = Mooncake() all_sprites.add(m) mooncakes.add(m) while running: all_sprites.update() hit_list = pygame.sprite.spritecollide(rabbit, mooncakes, False) for m in hit_list: score += 10 m.rect.y = -30 m.rect.x = random.randint(0, 700) all_sprites.draw(screen) pygame.display.flip()spritecollide第三个参数传True的话,被碰到的月饼会被从 group 里移除并消失;传False则保留。兔子接月饼一般传False,因为月饼不应该被接一次就永久消失,而是回顶部重新掉。这里要注意,如果你的某份源码里就是传True,它的设计意图可能是「接住即吃掉、月饼数量有限」,两种都是合理的玩法设计,看游戏想要什么效果。
4.3 生命值和游戏结束状态的过渡逻辑,避免一张 Game Over 画面卡死循环
兔子接月饼的第三个核心循环状态是游戏的结束。漏接三个月饼,或者按源码里设定的生命值归零,游戏要停止接住逻辑,展示结算画面,并且等玩家按键重开。常见做法是加一个if game_over分支:
lives = 3 score = 0 game_over = False while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if game_over and event.type == pygame.KEYDOWN and event.key == pygame.K_r: # 重启所有变量 lives = 3 score = 0 game_over = False if not game_over: # 原有的移动检测、碰撞检测 # 当 lives <= 0 时 game_over = True else: screen.blit(game_over_bg, (0, 0)) restart_hint = font.render("Press R to restart", True, (255, 255, 255)) screen.blit(restart_hint, (250, 400)) pygame.display.flip()这套结构最忌讳的是把 game over 检测直接放在pygame.QUIT分支里,然后pygame.quit()退出,那玩家想重玩就得重新执行整个脚本。重开变量时记得全部重置——包括分数、生命值、月饼的 y 坐标,缺一个就等于开挂或卡死。有些源码直接把main()函数又递归调用一次,也能跑,但 python 的递归深度有限,玩个几十局就有崩的风险,不建议在游戏主循环里递归。
5. Pygame 源码常见报错与三个必调的参数,把复用性做对才敢拿去改
5.1 Image not found 与 font 报错的排查方向,别急着改代码逻辑
跑兔子接月饼源码时,九成报错集中在资源加载上。pygame.error: Couldn't open rabbit.png这类问题,不是图片不存在,就是路径不对。源码里的"rabbit.png"是相对路径,意味着工作目录必须和图片文件在同一层级。
排查命令要会用:
ls -la # 确认当前目录文件 pwd # 看当前工作目录如果你用 vs code 打开整个文件夹再运行,当前目录通常会变成文件所在目录;如果你只在 vs code 里打开了一个main.py再运行,当前目录可能是项目根目录,图片相对路径照样失效。最稳妥的是用绝对路径,但要考虑换机器后路径变化——折中方案是用os.path.join(os.path.dirname(__file__), "rabbit.png")构造路径,这会永远以代码文件所在目录为基准。
字体报错最常见的是pygame.error: no system font,不让指定"simhei";这时换成pygame.font.get_default_font()看能不能出字,或者用pygame.font.match_font找一个系统中文字体。如果要在没有中文字体的服务器上跑,干脆所有显示文本都用英文,省时省力。
5.2 分数计算与速度递增的平衡:中央的tick(60)与 delta time 真的该用了
主流源码里clock.tick(60)是硬编码帧率,月饼下落速度也是按「像素/帧」写的。因此在高刷新率显示器(120Hz、144Hz)上,如果代码写成tick(120)或者忘写tick,月饼会以两倍速度落下,游戏难度直接翻倍。差分时间(delta time)就是把速度从「像素/帧」改为「像素/秒」的标准做法。
dt = clock.tick(60) / 1000.0 # 转成秒,dt 表示上一帧耗时 mooncake_rect.y += mooncake_speed * dt * 60这里dt是上一帧到当前帧的秒数,乘回 60 是保持原来参数数值不变。如果不想让成品移植到不同刷新率的机器上难度突变,这个改动比加任何游戏功能都值得。没引入 delta time 的源码,在笔记本和台式机上玩的难度会有肉眼可见的差别。
6. 打包成独立可执行文件并保留游戏节奏的验证方法,最后收官再说三个实用技巧
6.1 用 PyInstaller 打成 exe,依赖内部资源路径的小技巧
源码最终要发给别人玩,不能要求对方装 Python 和 Pygame。打包工具常见用的是 PyInstaller,命令简单但有两个细节:
pip install pyinstaller pyinstaller -F -w mooncake.py --add-data "rabbit.png:." --add-data "mooncake.png:."-F打成单文件,-w去掉命令行黑窗(Windows 下是--noconsole),--add-data把图片资源打进包内。但单文件模式的运行时会把资源解压到临时目录,os.path.dirname(__file__)这时拿到的是解压路径,反而不对。标准做法是运行时判断是否被打包:
import sys, os if getattr(sys, 'frozen', False): base_path = sys._MEIPASS else: base_path = os.path.dirname(__file__) rabbit_img_path = os.path.join(base_path, 'rabbit.png')sys._MEIPASS是 PyInstaller 单文件模式的专属属性——已打包时base_path用它,开发时用脚本目录。这样同一个代码既能本地python mooncake.py跑,也能打包后正常运行。
6.2 测试游戏难度是否合理的两个指标,帧率稳定性比分数数字更能说明问题
游戏写完不是能跑就完事,还要验证难度曲线。接住率就是实际接到的月饼数量除以月饼总数,这个指标在玩两局之后看最直观——第一局能接到 60% 以上,说明前期太简单;到第五分钟还能维持在 40% 以上,说明速度递增太慢。调试速度参数时,先调mooncake_speed初始值 1 像素/帧、微调mooncake_accel增量,不要同时动三个参数,否则一次改完根本说不清是哪个变化影响了手感。
6.3 隐藏资源的两个字:pygame 的 event 循环里加上暂停功能
很多成品源码没有暂停功能,被打断时只能眼睁睁看兔子漏接。加暂停很简单,在事件循环里拦截一个p键,进入一个内层等待循环:
paused = False while running: for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key == pygame.K_p: paused = not paused if paused: pause_text = font.render("Paused", True, (255, 255, 255)) screen.blit(pause_text, (350, 280)) pygame.display.flip() continue # 原有的更新和绘制逻辑continue的效果是跳过当帧的移动计算和碰撞检测,只保留事件处理和画面冻结。这样做的代价很小,但绝大多数网上流传的兔子接月饼源码都缺这一步,补上以后可玩性会明显提升。真正高手拿到别人的 Pygame 作品,第一件事也不是看代码逻辑,而是先把这类手感细节捋一遍。
本文还有配套的精品资源,点击获取