Python+Pygame开发猫咪接椰子小游戏:游戏循环与碰撞检测实战

发布时间:2026/10/8 11:02:24
Python+Pygame开发猫咪接椰子小游戏:游戏循环与碰撞检测实战 有一次朋友让我给孩子做个能玩的小游戏条件是画面不能太复杂、规则必须一眼懂、还得有点搞笑。我第一个想到的就是“猫咪摘椰子”这种蠢萌挑战——猫咪蹲在椰子树下椰子哐哐往下掉玩家要控制猫咪接住它们。题材是当年动画频道里常见的卡通桥段改成游戏却意外地合适。这篇文章要做的就是一个基于 Python 和 Pygame 的迷你游戏Funny Cat Coconut Challenge。猫咪在屏幕底部左右移动接住从树顶随机掉落的椰子接一个加一分漏掉三个则游戏结束达到目标分数则通关。整个过程大约 250 行代码对刚学完 Python 基础语法的人来说是可以完整复现的第一个游戏项目。我的核心判断是这类看上去很“低幼”的小游戏恰好是学习游戏开发最好的最小训练场。游戏循环、键盘事件、随机生成、碰撞检测、对象生命周期、状态管理这些概念在大型游戏里会被各种引擎封装得严严实实但在 Pygame 里全都裸露在代码表面你反而更容易理解它们为什么存在。如果你已经装了 Python那么读完本文可以直接跑起来如果你还没装花 10 分钟装好环境再回来收获会更完整。1. 这篇文章真正要解决的问题先别急着写代码我们想清楚一件事做一个“会动的小游戏”和做一个“跑得顺畅的小游戏”中间隔着什么很多初学者第一次接触游戏开发容易卡在三个地方第一个问题是事件驱动不理解。普通 Python 脚本是从上到下执行一遍就结束游戏却是“一边无限循环一边响应按键和窗口事件”。如果脑子里还是“死循环很危险”的刻板印象会觉得游戏主循环非常怪异。第二个问题是碰撞检测。猫咪和椰子什么时候算“碰到”是图片边缘对齐还是中心距离小于某个值面对两个矩形、两个圆没有清晰的概念就没法写碰撞逻辑。第三个问题是对象生命周期。椰子里有“存活”和“已消失”两种状态。刚刚掉落的椰子要更新坐标、参与碰撞被接住或掉到地上的椰子必须从列表里删掉。新手最容易在这里栽跟头因为只管生成对象不清理对象游戏会越跑越卡。本文所有章节的安排都是围绕这三个问题展开的。读完你会得到一套完整的 Pygame 项目骨架以及一套可以往里面继续加功能的扩展思路。2. 核心概念游戏循环、事件、碰撞与对象管理2.1 游戏循环是什么游戏循环可以理解成一个“不断刷新的世界时钟”while running: 处理事件 更新游戏状态 绘制画面 控制帧率Pygame 每次循环都做四件事看看用户敲了哪个键、把椰子坐标往下移动、把所有物体画到屏幕上、最后让程序休息 1/60 秒。这样画面看起来是连续动画而不是瞬时的图片。这段逻辑非常重要因为在游戏里“时间”是抽象出来的。你通过clock.tick(FPS)控制循环频率而不是依赖time.sleep()。2.2 事件与键盘控制键盘控制有两种写法事件响应只有在按下或松开的那一瞬间触发动作适合菜单选择、跳跃这类离散行为。按键状态使用pygame.key.get_pressed()持续检测按键适合移动角色因为玩家按住左键时玩家应该每帧都向左移动。本文的猫咪移动使用第二种方式。这样按住方向键不松手猫咪就能一直移动游戏体验更顺滑。2.3 碰撞检测的两种基础思路Pygame 提供了最常见的矩形碰撞检测player_rect pygame.Rect(x, y, width, height) coconut_rect pygame.Rect(x - radius, y - radius, radius * 2, radius * 2) if player_rect.colliderect(coconut_rect): # 碰撞发生了这种做法的原理是把两个物体都简化成矩形检查两个矩形是否重叠。优点是简单高效缺点是矩形边缘的空白区域会被误判。如果两个物体都近似圆形也可以用“圆心距离小于半径之和”来判断distance ((cat_x - coconut_x) ** 2 (cat_y - coconut_y) ** 2) ** 0.5 if distance cat_radius coconut_radius: # 碰撞发生了在实际项目中两者可以混用。先用粗略的矩形检测过滤掉大部分无关物体再用精确的圆形检测处理细节。本文为了代码可读性统一使用矩形检测。2.4 对象生命周期与列表清理游戏里的椰子是动态生成的每隔一段时间就往列表里追加一个对象。如果不做清理列表会越来越大每一帧都要遍历越来越多的物体最终卡顿。所以每一帧结束后都要过滤掉不存活的对象coconuts [c for c in coconuts if c.alive]这一行代码决定了你的游戏能跑十分钟还是跑十秒也决定了后面接住椰子时能不能正确加分。3. 开发环境准备与项目结构3.1 安装 Python 和 Pygame建议使用 Python 3.9 以上的版本具体兼容范围以 Pygame 官方发布说明为准。在命令行执行python --version python -m pip install pygame安装完成后可以用一行命令验证 Pygame 是否可用python -m pygame.examples.aliens如果能够打开一个小飞机射击游戏窗口说明 Pygame 安装成功。如果提示模块不存在重新执行安装命令或者试试加镜像源python -m pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple3.2 项目目录结构本文采用单文件实现便于初学者复制运行funny_cat_coconut/ ├── main.py # 游戏主程序 └── README.md # 项目说明可选如果你打算扩展功能可以拆成模块funny_cat_coconut/ ├── settings.py # 常量配置 ├── player.py # 玩家类 ├── coconut.py # 椰子类 ├── background.py # 背景绘制 └── main.py # 主循环优先跑通单文件版本再考虑拆分。不要一开始就过度设计目录结构。4. 场景元素设计4.1 背景如何画不打算使用外部图片素材因为素材版权、下载渠道和格式处理会增加很多噪音。我们用 Pygame 自带的绘图 API 直接画背景天空、草地、树干、树叶。这样做有两个好处第一代码可以零素材运行第二后续想要替换为真实图片时只需要修改draw_*部分不用动核心逻辑。4.2 猫咪角色怎么画猫咪用橙色椭圆当身体两个三角形当耳朵两个黑色圆点当眼睛。嘻嘻这种“几何图形版猫咪”虽然谈不上精美但胜在可爱和零依赖放到少儿编程课堂上也完全没有版权问题。位置由玩家对象管理绘制函数只需要读取player.x和player.y。这符合“数据与表现分离”的基本思想。4.3 椰子怎么画一个深棕色圆加上一个小高光圆看起来就是一个简单椰子。椰子对象自己管x、y和下落速度vy更新方法负责移动绘制方法负责把自己画出来。4.4 UI 怎么显示使用pygame.font.SysFont(simhei, 32)创建字体在屏幕左上角显示分数右上角显示剩余生命。字体名称在 Windows 上是黑体在 macOS 上未必存在所以代码要写好默认回退逻辑这会在常见问题里详细说明。5. 核心代码实现完整示例下面是本文最核心的部分。代码可以直接保存为main.py并运行。 Funny Cat Coconut Challenge 一个简单的 Pygame 小游戏 玩家控制猫咪左右移动接住从椰子树掉落的椰子。 每接住一个分数 1椰子掉到地上生命 -1。 生命归零则游戏失败达到目标分数则通关。 import sys import random import pygame # ---------- 基础配置 ---------- SCREEN_WIDTH 800 SCREEN_HEIGHT 600 FPS 60 MAX_LIVES 3 TARGET_SCORE 20 SPAWN_INTERVAL 45 # 每 45 帧生成一个椰子 # ---------- 颜色 RGB ---------- SKY_BLUE (135, 206, 235) GRASS_GREEN (34, 139, 34) TREE_BROWN (139, 69, 19) LEAF_GREEN (0, 128, 0) CAT_ORANGE (255, 140, 0) COCONUT_BROWN (101, 67, 33) COCONUT_DARK (72, 45, 22) WHITE (255, 255, 255) BLACK (0, 0, 0) class Player: 玩家控制的猫咪 def __init__(self): self.width 90 self.height 60 self.x SCREEN_WIDTH // 2 - self.width // 2 self.y SCREEN_HEIGHT - 80 self.speed 8 self.score 0 self.lives MAX_LIVES def move_left(self): self.x - self.speed if self.x 10: self.x 10 def move_right(self): self.x self.speed if self.x SCREEN_WIDTH - self.width - 10: self.x SCREEN_WIDTH - self.width - 10 property def rect(self): return pygame.Rect(self.x, self.y, self.width, self.height) class Coconut: 从树顶掉落的椰子 def __init__(self): self.x random.randint(150, SCREEN_WIDTH - 150) self.y 130 self.radius 16 self.vy 3.0 self.alive True def update(self): self.y self.vy def draw(self, screen): pygame.draw.circle(screen, COCONUT_BROWN, (self.x, self.y), self.radius) pygame.draw.circle(screen, COCONUT_DARK, (self.x - 2, self.y - 2), self.radius - 5) property def rect(self): return pygame.Rect( self.x - self.radius, self.y - self.radius, self.radius * 2, self.radius * 2, ) def draw_background(screen): 绘制背景天空、草地、椰子树 screen.fill(SKY_BLUE) # 草地 pygame.draw.rect(screen, GRASS_GREEN, (0, SCREEN_HEIGHT - 100, SCREEN_WIDTH, 100)) # 树干 pygame.draw.rect(screen, TREE_BROWN, (SCREEN_WIDTH // 2 - 20, 200, 40, SCREEN_HEIGHT - 200)) # 树叶三层椭圆 leaf_centers [ (SCREEN_WIDTH // 2, 120), (SCREEN_WIDTH // 2 - 70, 180), (SCREEN_WIDTH // 2 70, 180), ] for cx, cy in leaf_centers: pygame.draw.ellipse(screen, LEAF_GREEN, (cx - 80, cy - 40, 160, 80)) # 树顶装饰椰子 pygame.draw.circle(screen, COCONUT_BROWN, (SCREEN_WIDTH // 2 - 35, 125), 15) pygame.draw.circle(screen, COCONUT_BROWN, (SCREEN_WIDTH // 2 35, 115), 15) def draw_player(screen, player): 绘制玩家的猫咪角色几何图形版 pygame.draw.ellipse(screen, CAT_ORANGE, (player.x, player.y, player.width, player.height)) pygame.draw.polygon( screen, CAT_ORANGE, [(player.x 5, player.y 20), (player.x 15, player.y), (player.x 25, player.y 15)] ) pygame.draw.polygon( screen, CAT_ORANGE, [(player.x player.width - 25, player.y 15), (player.x player.width - 15, player.y), (player.x player.width - 5, player.y 20)] ) pygame.draw.circle(screen, BLACK, (player.x player.width // 2 - 12, player.y 20), 4) pygame.draw.circle(screen, BLACK, (player.x player.width // 2 12, player.y 20), 4) def draw_ui(screen, font, player): 绘制分数和生命 score_text font.render(fScore: {player.score}, True, BLACK) lives_text font.render(fLives: {player.lives}, True, BLACK) screen.blit(score_text, (20, 20)) screen.blit(lives_text, (SCREEN_WIDTH - 150, 20)) def show_message(screen, font, message): 游戏结束或胜利时显示提示信息 overlay pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) overlay.set_alpha(180) overlay.fill((0, 0, 0)) screen.blit(overlay, (0, 0)) text font.render(message, True, WHITE) text_rect text.get_rect(center(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) screen.blit(text, text_rect) def main(): pygame.init() screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption(Funny Cat Coconut Challenge) clock pygame.time.Clock() font pygame.font.SysFont(simhei, 32) player Player() coconuts [] spawn_timer 0 running True game_over False win False while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False if event.type pygame.KEYDOWN: if event.key pygame.K_r and (game_over or win): # 重新开始重置所有状态 player Player() coconuts [] spawn_timer 0 game_over False win False if event.key pygame.K_ESCAPE: running False if not game_over and not win: keys pygame.key.get_pressed() if keys[pygame.K_LEFT] or keys[pygame.K_a]: player.move_left() if keys[pygame.K_RIGHT] or keys[pygame.K_d]: player.move_right() # 定时生成椰子 spawn_timer 1 if spawn_timer SPAWN_INTERVAL: coconuts.append(Coconut()) spawn_timer 0 # 更新椰子并检测碰撞 for coco in coconuts: coco.update() if coco.y SCREEN_HEIGHT - 100: # 椰子掉到草地上扣生命 coco.alive False player.lives - 1 elif player.rect.colliderect(coco.rect): # 猫咪接住椰子加分 coco.alive False player.score 1 # 清理不存活的椰子 coconuts [c for c in coconuts if c.alive] if player.lives 0: game_over True if player.score TARGET_SCORE: win True # 绘制所有内容 draw_background(screen) draw_player(screen, player) for coco in coconuts: coco.draw(screen) draw_ui(screen, font, player) if game_over: show_message(screen, font, Game Over! Press R to restart) if win: show_message(screen, font, You Win! Press R to restart) pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit() if __name__ __main__: main()这段代码的逻辑并不复杂但有三个细节值得展开说明第一为什么用列表推导式清理椰子而不是在循环里删除因为遍历列表的同时删除元素会让索引错位容易漏删或者出现意外跳过。更稳妥的做法是先标记alive False循环结束后统一过滤。第二为什么coco.alive False后还要更新所有椰子因为一个帧内可能有多个椰子和猫咪同时碰撞如果直接用break中断循环后面的椰子就不再更新下一帧坐标会不连续碰撞体验很奇怪。标记法保证了所有椰子都能完成一次完整的更新和判断。第三pygame.key.get_pressed()和事件循环的关系。事件循环仍然负责处理窗口关闭、按键重开等离散事件get_pressed()只负责获得当前哪些键被按住。两者不冲突各司其职。6. 运行结果与效果验证6.1 运行命令在项目目录下执行python main.py预期效果屏幕上出现 800x600 的窗口标题为 Funny Cat Coconut Challenge。天空是浅蓝色底部是绿色草地中间是一棵绿色椰子树。一只橙色猫咪位于屏幕底部中间按左右方向键可以移动。每过约 45 帧0.75 秒会有一个椰子从树顶掉落。椰子碰到猫咪左上角 Score 加 1椰子落到草地上右上角 Lives 减 1。Lives 为 0 时黑色半透明遮罩浮现显示 Game Over。Score 达到 20 时显示 You Win。游戏结束或通关后按 R 键重新开始按 ESC 退出程序。6.2 如何判定代码是否写对了有一个很简单的验证方法运行游戏不操作键盘让猫咪站在屏幕中间。如果生命从 3 慢慢变成 0并且游戏结束时弹出了 Game Over 提示说明椰子的生成、下落、掉落判断全部正常。再测试接椰子控制猫咪接住 1 到 2 个椰子分数应该立刻增加。如果分数没有变化优先检查碰撞矩形是否偏离实际绘制位置尤其是椰子rect的x和y用的是圆心坐标而绘制时pygame.draw.circle用的也是圆心坐标两者必须一致。6.3 如果窗口直接闪退怎么办不要慌。在终端里运行程序闪退时终端通常会有 Traceback。最常见的原因有Python/Pygame 版本不匹配。当前环境里存在多个 Python 版本pip install pygame装到了另一个环境。SysFont(simhei, 32)找不到字体并抛出异常部分 Linux 环境会出现。解决办法是先把font pygame.font.SysFont(simhei, 32)改成font pygame.font.Font(None, 36)Font(None, 36)会使用 Pygame 内置默认字体虽然不够漂亮但保证能看到界面。字体美化放到后面再做。7. 常见问题与排查思路问题现象可能原因排查方式解决方案启动报错ModuleNotFoundError: No module named pygame未安装 Pygame 或安装到其他 Python 环境执行python -m pip show pygame并确认命令行用的 Python 与安装时一致重新执行python -m pip install pygame使用python -m pip而不是直接pip窗口出现后一闪而过代码未进入主循环或事件循环写法错误查看终端 Traceback 是否出现在pygame.init()之后检查主循环是否为while running:确保__name__ __main__入口正常键盘方向键没反应焦点不在游戏窗口或get_pressed()未写在主循环内部点击游戏窗口后再按键查看 key code 是否匹配确认按键处理代码位于while running且不在事件循环之外接住椰子不加分碰撞矩形位置和绘制位置不一致打印player.rect和coconut.rect对比坐标统一使用rect属性作为碰撞依据猫咪应使用player.xwidth/height构造碰撞矩形游戏运行越来越卡椰子对象未清理在更新逻辑后检查len(coconuts)是否持续增长将列表过滤代码放在每一帧更新之后确认被删除的椰子alive False中文提示显示乱码系统缺少simhei字体改用pygame.font.Font(None, 36)测试用英文提示或指定实际存在的字体文件路径在 macOS/Linux 上字体名字不同Pygame 的SysFont依赖系统字体库执行fc-list :langzh查看可用中文字体使用pygame.font.Font(/path/to/font.ttf, 32)指定字体文件这里补充一个排查思路遇到问题先看终端输出不要直接猜。Pygame 的报错通常已经精确到了文件和行号。如果终端没有输出可以在代码关键位置临时加print()例如在生成椰子时打印坐标在碰撞时打印分数这样可以快速定位逻辑错误。8. 升级扩展让游戏更像一个完整产品跑通基础版本之后游戏依然很粗糙。如果想让小朋友玩得更开心或者想继续练习工程能力下面几个扩展方向性价比很高。8.1 用精灵图片替代几何图形几何图形版的好处是没有素材也能运行但视觉表现有上限。真正的游戏项目会使用精灵图sprite和瓦片地图tile map。在 Pygame 中加载图片只需要两行代码cat_img pygame.image.load(cat.png).convert_alpha() cat_img pygame.transform.scale(cat_img, (90, 60)) screen.blit(cat_img, (player.x, player.y))需要注意版权不要随便从网上抓一张卡通猫图片就塞进游戏。个人学习无所谓如果要发布或商用一定要选用 CC0 协议或自己绘制的素材。这一点很多人容易忽略真到了上架应用商店时才知道麻烦。8.2 加入音效Pygame 的混音模块支持 WAV 和 OGG 格式。在pygame.init()后加一行pygame.mixer.init() coconut_hit_sound pygame.mixer.Sound(hit.wav) lose_sound pygame.mixer.Sound(lose.wav)然后在加分处播放即可if player.rect.colliderect(coco.rect): coco.alive False player.score 1 coconut_hit_sound.play()音效文件也必须注意版权推荐使用免费音效库或者用工具软件自行生成简单音效。8.3 加入难度递增固定间隔生成椰子游戏玩到后面会变得机械化。更合理的设计是让生成间隔随分数下降spawn_interval max(20, SPAWN_INTERVAL - player.score // 5)这样每得 5 分生成间隔就减小一点椰子越来越密集挑战性逐步上升。分数与难度的比例可以根据目标用户调整幼儿模式间隔大、速度慢成人模式间隔小、速度快。8.4 用 PyInstaller 打包成可执行文件游戏写完后如果只想分享给朋友双击运行可以用 PyInstaller 打包python -m pip install pyinstaller pyinstaller --onefile --windowed main.py生成的dist/main.exe就是独立可执行程序。--windowed参数会隐藏命令行窗口适合给不熟悉命令行的用户使用。需要注意不同操作系统需要分别打包Windows 上打出的包不能直接在 macOS 运行。8.5 加入计分排行榜最简单的方式是保存到文本文件with open(score.txt, a) as f: f.write(f{player.score}\n)读取并显示历史最高分best 0 try: with open(score.txt, r) as f: best max(int(line.strip()) for line in f if line.strip()) except FileNotFoundError: pass这看起来很简单但已经涉及文件读写、异常处理和持久化存储是很好的工程练习。9. 设计背后的工程考量很多人以为“儿童小游戏”不值得讲工程化实际上这种小项目反而最适合迁移到真实项目开发流程。逻辑与渲染分离。本文把背景绘制、角色绘制、UI 绘制分成独立函数。坏处是函数之间通过参数传递数据看起来不如类封装“酷”但好处是每段代码都很好测试和替换。想换一张背景图只需要改draw_background内部实现病毒不影响到碰撞逻辑。常量配置。SCREEN_WIDTH、FPS、SPAWN_INTERVAL都放到了文件顶部。改分辨率、改难度系数、改目标分数只需要改顶部参数。这是从“脚本”过渡到“项目”的重要习惯。对象内聚。Coconut类既管自己的坐标又管自己的绘制。对于这个规模的小游戏来说这是可接受的。一旦项目复杂到上百个对象就要引入组件化或 ECS 架构但那是后话不要现在就去学否则只会得到一堆用不上的理论。不要早优化。游戏只有 800x600 窗口和十几个椰子对象PyGame 一定能跑满 60 帧。你不需要双缓冲优化、不需要对象池、不需要多线程。刻意引入这些技术除了让代码变得难懂没有任何收益。等真实用户反馈“卡顿”时再去性能分析也不迟。这个设计思路实际上回答了开头的疑问为什么一个“幼稚”的游戏值得写因为它的复杂度刚好拦住“什么都不懂的新手”又不能逼出“过度设计的架构师”。在这个微妙区间里你刚好能学会最朴素的工程思维。10. 性能与安全注意事项Pygame 游戏的性能瓶颈一般不在 Python 本身而在渲染和碰撞检测策略。对于本文的小游戏最耗时的操作是每帧重新绘制树和椰子。窗口 800x600只有几个图形完全不用担心。但如果你把背景换成了大尺寸图片并且每次都在主循环里重新加载图片文件那就会明显卡顿。正确的做法是在主循环外加载图片主循环内只blit。# 错误示范每一帧都读磁盘 def draw_background(screen): bg pygame.image.load(bg.png) screen.blit(bg, (0, 0)) # 正确做法启动时加载一次 bg pygame.image.load(bg.png).convert() def draw_background(screen): screen.blit(bg, (0, 0))再补充一个编码层面的安全细节不要用eval()或exec()处理游戏配置。有些教程会用eval(input())来解析玩家输入这在本地单机游戏里可能没明显危害却会养成极差的代码习惯。如果未来项目要接入网络这类代码就是致命漏洞防止越权访问文件和敏感系统资源的最基本方法就是从一开始不接纳这类写法。11. 总结与下一步实践方向现在回头看整篇文章真正讲清楚的其实是六个点游戏循环是理解和掌控游戏时间的基础框架。键盘事件用get_pressed()适合连续移动用事件队列适合离散操作。碰撞检测可以简化为两个矩形的相交关键在于把绘制坐标和碰撞坐标统一。对象生命周期必须写在主循环里生成、更新、标记、清理。分数和生命本质上是状态不要让它们直接散布在全局变量里。一个“幼稚”项目足以支撑起初学者到准工程思维之间的过渡。接下来怎么练我建议不要立刻去学另一个游戏引擎而是先把当前项目改造一遍把生命扣减改成受到伤害后短暂无敌模拟无敌帧效果。增加一种随机掉落物碰到会扣分。让椰子拥有不同大小和速度。加一个暂停功能按 P 键暂停游戏。给猫咪增加一个跳跃动画让视觉效果更生动。如果你把这些都做完了再去接触 Unity 的物理组件、Godot 的场景树或者网页版 Phaser你会恍然大悟原来这些引擎只是把本文的设计封装成了可视化组件而已。最后的提醒写游戏不是为了做出一个大而全的成品而是为了在亲手处理“让物体动起来、让物体交互、让状态变化”的过程中建立起编程的直觉。猫咪摘椰子只是一个起点它甚至可以改成“程序员接需求”“产品经理接反馈”之类的换皮项目玩法逻辑完全不用变分分钟变成另一款有趣的小游戏。把你的主循环跑起来看看那只橙色猫咪第一次稳稳接住掉落的椰子时你会理解为什么游戏开发是最能带来成就感的编程方向之一。