Python Pygame 开发 2D 追逐游戏:从 AI 逻辑到碰撞检测的完整实践
之前在做游戏开发时,经常需要实现一些有趣的、带有“追逐”或“躲避”核心玩法的原型。这类玩法看似简单,但要让角色移动流畅、AI行为自然、碰撞检测精准,背后涉及不少细节。本文将以一个名为「MIKU」Catch_Me_If_You_Can 的趣味小游戏项目为例,完整拆解其开发全流程。我们将使用 Python 的 Pygame 库来实现,涵盖从环境搭建、精灵动画、玩家控制、AI 追逐逻辑、碰撞检测到游戏状态管理的所有环节。无论你是想学习 Pygame 基础,还是希望掌握一个完整小游戏的开发思路,都能从本文获得可直接复用的代码和清晰的实现逻辑。
1. 背景与核心概念
「Catch Me If You Can」直译为“如果你能抓住我”,通常指代一种“追逐-躲避”的游戏模式。在本项目中,我们将其具象化为一个简单的 2D 游戏:玩家控制一个角色(例如,一个可爱的卡通形象「MIKU」),在有限的屏幕空间内移动,目标是躲避一个或多个由简单 AI 控制的“追逐者”。游戏的核心乐趣在于利用灵活的走位与 AI 周旋,尽可能长时间地生存下去。
从技术角度看,这个项目完美涵盖了 2D 游戏开发的基础要素:
- 图形渲染与动画:加载图片、绘制精灵、实现帧动画。
- 用户输入处理:响应键盘或鼠标事件,控制玩家角色移动。
- 游戏物理与逻辑:包括速度、方向、边界限制等。
- 人工智能(AI):为追逐者实现自动寻路或追踪逻辑,这是游戏可玩性的关键。
- 碰撞检测:判断玩家是否被追逐者抓住,触发游戏结束。
- 游戏状态管理:管理游戏开始、进行中、结束等不同状态。
我们将使用Pygame这个轻量级且功能强大的 Python 库来完成这一切。Pygame 提供了对图像、声音、输入设备和基础 2D 图形操作的封装,非常适合快速开发游戏原型和小型项目。
2. 环境准备与版本说明
在开始编写代码之前,我们需要搭建开发环境。请确保你的系统已安装 Python。
2.1 安装 Python 与 Pygame
首先,你需要安装 Python。建议使用 Python 3.7 或更高版本。你可以从 Python 官网 下载并安装。
安装好 Python 后,通过 pip 安装 Pygame。打开你的终端(Windows 上是 CMD 或 PowerShell,macOS/Linux 上是 Terminal)并运行以下命令:
pip install pygame安装完成后,可以通过以下命令验证安装是否成功:
python -c “import pygame; print(pygame.version.ver)”如果输出版本号(如2.5.2),则说明安装成功。
2.2 项目结构与资源准备
我们将创建一个简单的项目文件夹来组织代码和资源。建议的目录结构如下:
catch_me_if_you_can/ ├── main.py # 游戏主程序入口 ├── game.py # 游戏主逻辑类 ├── player.py # 玩家角色类 ├── chaser.py # 追逐者(AI)类 ├── assets/ # 资源文件夹 │ ├── images/ # 图片资源 │ │ ├── miku.png # 玩家角色图片 │ │ └── chaser.png # 追逐者图片 │ └── sounds/ # 音效资源(可选) └── README.md # 项目说明(可选)你需要准备两张 PNG 格式的图片,分别代表玩家角色(如miku.png)和追逐者(如chaser.png)。图片背景最好是透明的(PNG 支持透明通道),尺寸建议在 64x64 像素左右,以适应游戏窗口。你可以使用任何图像编辑软件制作,或在开源素材网站寻找合适的素材。
本文的代码示例将基于此结构。如果你暂时没有图片,也可以使用 Pygame 绘制简单的矩形或圆形来代替,代码中会提供备选方案。
3. 核心原理与 Pygame 基础拆解
在深入项目之前,我们先快速理解 Pygame 程序的基本骨架和几个核心概念。
3.1 Pygame 程序主循环
每个 Pygame 游戏都围绕一个“游戏循环”运行。这个循环每秒运行数十次(由帧率控制),在每次循环中,程序依次完成以下工作:
- 处理事件:检查是否有键盘按下、鼠标点击、退出等事件发生。
- 更新游戏状态:根据输入和游戏逻辑,更新所有游戏对象(如角色位置、AI 决策)的状态。
- 渲染画面:将更新后的游戏状态绘制到屏幕上。
# 这是一个极简的 Pygame 主循环框架 import pygame pygame.init() screen = pygame.display.set_mode((800, 600)) clock = pygame.time.Clock() running = True while running: # 1. 处理事件 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # 2. 更新游戏状态 (这里暂时为空) # ... # 3. 渲染画面 screen.fill((0, 0, 0)) # 用黑色填充屏幕 pygame.display.flip() # 更新整个屏幕显示 # 控制帧率 clock.tick(60) # 每秒60帧 pygame.quit()3.2 精灵(Sprite)与图像处理
在 Pygame 中,Sprite类代表游戏中的一个可视对象。我们通常会创建自己的角色类来继承pygame.sprite.Sprite,并为其添加图像(image)和位置矩形(rect)属性。
加载图片使用pygame.image.load()。为了获得更好的性能和视觉效果(如透明背景),通常会对加载的图片进行转换(convert_alpha())。
class Player(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() # 加载图片并转换 self.image = pygame.image.load(‘assets/images/miku.png’).convert_alpha() # 获取图片的矩形区域,并设置初始位置 self.rect = self.image.get_rect(center=(x, y)) # 自定义属性,如速度 self.speed = 53.3 移动与向量运算
角色的移动本质上是其rect的x和y坐标的连续变化。根据键盘输入(如 WASD 或方向键),我们修改这些坐标。
更优雅的方式是使用向量(pygame.math.Vector2)来表示位置和速度。向量可以方便地进行加减、缩放等运算,特别适合处理涉及角度和速度的移动。
# 使用向量表示位置和速度 self.pos = pygame.math.Vector2(x, y) # 精确位置(浮点数) self.velocity = pygame.math.Vector2(0, 0) # 速度向量 self.rect.center = self.pos # 用精确位置更新显示矩形 # 在更新函数中 self.pos += self.velocity self.rect.center = self.pos3.4 简单的 AI 追逐逻辑
追逐者 AI 的核心是:如何让追逐者朝着玩家当前位置移动。一个简单而有效的方法是计算从追逐者到玩家的方向向量,将其标准化(使其长度为1),然后乘以追逐者的速度,即可得到每帧的速度向量。
# 在 Chaser 类的 update 方法中 def update(self, player_pos): # player_pos 是玩家位置的 Vector2 # 计算方向向量:目标位置 - 自身位置 direction = player_pos - self.pos # 如果距离不为零,则标准化方向向量 if direction.length() > 0: direction = direction.normalize() # 速度 = 方向 * 速度标量 self.velocity = direction * self.speed # 更新位置 self.pos += self.velocity self.rect.center = self.pos3.5 碰撞检测
Pygame 的sprite模块提供了便捷的碰撞检测方法。最常用的是pygame.sprite.spritecollide(),它可以检测一个精灵是否与一个精灵组中的任何精灵发生碰撞。我们用它来判断玩家是否被任何一个追逐者抓住。
# 假设 players 是包含玩家精灵的组,chasers 是包含所有追逐者的组 if pygame.sprite.spritecollide(player, chasers, False): # 发生碰撞,游戏结束 game_over = True4. 完整实战:构建「MIKU」Catch_Me_If_You_Can
现在,我们将上述概念整合起来,构建完整的游戏。我们将按照模块化的思想,分别创建玩家、追逐者和游戏主控类。
4.1 创建玩家角色类 (player.py)
玩家角色需要响应键盘输入,并在屏幕边界内移动。
# file: player.py import pygame class Player(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() # 尝试加载图片,如果失败则绘制一个矩形 try: self.image = pygame.image.load(‘assets/images/miku.png’).convert_alpha() except FileNotFoundError: self.image = pygame.Surface((50, 50), pygame.SRCALPHA) pygame.draw.circle(self.image, (100, 200, 255), (25, 25), 25) # 画一个蓝色圆形代表玩家 self.rect = self.image.get_rect(center=(x, y)) # 使用向量存储精确位置和速度 self.pos = pygame.math.Vector2(self.rect.center) self.velocity = pygame.math.Vector2(0, 0) self.speed = 5 # 屏幕边界(将在游戏主类中设置) self.screen_width = 800 self.screen_height = 600 def set_screen_bounds(self, width, height): """设置屏幕边界,防止角色移出屏幕""" self.screen_width = width self.screen_height = height def handle_input(self): """处理键盘输入,更新速度向量""" keys = pygame.key.get_pressed() self.velocity = pygame.math.Vector2(0, 0) # 使用 WASD 或方向键控制 if keys[pygame.K_w] or keys[pygame.K_UP]: self.velocity.y = -self.speed if keys[pygame.K_s] or keys[pygame.K_DOWN]: self.velocity.y = self.speed if keys[pygame.K_a] or keys[pygame.K_LEFT]: self.velocity.x = -self.speed if keys[pygame.K_d] or keys[pygame.K_RIGHT]: self.velocity.x = self.speed # 如果同时按了斜方向键,速度会叠加,导致斜向移动更快。 # 标准化速度向量,确保斜向移动速度与横向/纵向一致。 if self.velocity.length() > 0: self.velocity = self.velocity.normalize() * self.speed def update(self): """更新角色位置,并限制在屏幕内""" self.handle_input() self.pos += self.velocity # 边界检查 self.pos.x = max(self.rect.width // 2, min(self.screen_width - self.rect.width // 2, self.pos.x)) self.pos.y = max(self.rect.height // 2, min(self.screen_height - self.rect.height // 2, self.pos.y)) # 更新显示矩形的位置 self.rect.center = (int(self.pos.x), int(self.pos.y))4.2 创建追逐者 AI 类 (chaser.py)
追逐者会自动追踪玩家,并拥有自己的速度。
# file: chaser.py import pygame import random class Chaser(pygame.sprite.Sprite): def __init__(self, x, y, speed=3): super().__init__() # 尝试加载图片,如果失败则绘制一个红色矩形 try: self.image = pygame.image.load(‘assets/images/chaser.png’).convert_alpha() except FileNotFoundError: self.image = pygame.Surface((40, 40), pygame.SRCALPHA) pygame.draw.rect(self.image, (255, 50, 50), (0, 0, 40, 40), border_radius=5) # 画一个红色圆角矩形 self.rect = self.image.get_rect(center=(x, y)) self.pos = pygame.math.Vector2(self.rect.center) self.velocity = pygame.math.Vector2(0, 0) self.speed = speed # 可以添加一个“视野”或“激活距离”来增加趣味性 self.activation_distance = 300 # 像素,当玩家进入此距离才开始追逐 def update(self, player_pos): """根据玩家位置更新追逐者状态""" # 计算与玩家的距离 distance_to_player = self.pos.distance_to(player_pos) # 如果玩家在激活距离内,则进行追逐 if distance_to_player < self.activation_distance: direction = player_pos - self.pos if direction.length() > 0: # 标准化方向并乘以速度 direction = direction.normalize() self.velocity = direction * self.speed else: self.velocity = pygame.math.Vector2(0, 0) else: # 玩家在视野外,可以停止移动或随机游走 self.velocity = pygame.math.Vector2(0, 0) # 可选:简单随机游走逻辑 # if random.random() < 0.02: # 2%的概率改变方向 # self.velocity = pygame.math.Vector2(random.uniform(-1, 1), random.uniform(-1, 1)).normalize() * self.speed * 0.5 # 更新位置 self.pos += self.velocity self.rect.center = (int(self.pos.x), int(self.pos.y))4.3 创建游戏主逻辑类 (game.py)
这个类负责初始化 Pygame、管理游戏状态、创建精灵、运行主循环以及处理碰撞。
# file: game.py import pygame import sys from player import Player from chaser import Chaser class CatchMeGame: def __init__(self, width=800, height=600): pygame.init() self.screen = pygame.display.set_mode((width, height)) pygame.display.set_caption(‘「MIKU」Catch Me If You Can’) self.clock = pygame.time.Clock() self.running = True self.game_over = False self.survival_time = 0 # 生存时间(帧或秒) self.width = width self.height = height # 创建精灵组 self.all_sprites = pygame.sprite.Group() self.chasers = pygame.sprite.Group() # 创建玩家 self.player = Player(width // 2, height // 2) self.player.set_screen_bounds(width, height) self.all_sprites.add(self.player) # 创建初始追逐者(例如,4个,放在屏幕四角附近) self.spawn_chasers(4) # 字体,用于显示时间和游戏结束信息 self.font = pygame.font.Font(None, 36) def spawn_chasers(self, count): """在屏幕边缘随机位置生成指定数量的追逐者""" for _ in range(count): # 随机选择在屏幕的哪条边生成 side = random.randint(0, 3) if side == 0: # 上边 x = random.randint(50, self.width - 50) y = -50 elif side == 1: # 右边 x = self.width + 50 y = random.randint(50, self.height - 50) elif side == 2: # 下边 x = random.randint(50, self.width - 50) y = self.height + 50 else: # 左边 x = -50 y = random.randint(50, self.height - 50) # 追逐者速度可以随机,增加难度变化 speed = random.uniform(2.0, 4.0) chaser = Chaser(x, y, speed) self.all_sprites.add(chaser) self.chasers.add(chaser) def handle_events(self): """处理系统事件,如退出""" for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.running = False # 游戏结束后按 R 键重新开始 if self.game_over and event.key == pygame.K_r: self.__init__(self.width, self.height) # 简单重置游戏状态 def update(self): """更新所有游戏对象的状态""" if not self.game_over: self.survival_time += 1 # 更新玩家 self.player.update() # 更新所有追逐者,传入玩家位置 for chaser in self.chasers: chaser.update(self.player.pos) # 碰撞检测:玩家是否被抓住? if pygame.sprite.spritecollide(self.player, self.chasers, False): self.game_over = True # 可选:每隔一段时间生成新的追逐者,增加难度 # if self.survival_time % 600 == 0: # 每10秒(假设60帧)生成一个 # self.spawn_chasers(1) def draw(self): """绘制所有游戏对象和UI信息""" self.screen.fill((30, 30, 40)) # 深蓝色背景 # 绘制所有精灵 self.all_sprites.draw(self.screen) # 绘制UI:生存时间 time_text = self.font.render(f’Time: {self.survival_time // 60}‘, True, (255, 255, 255)) self.screen.blit(time_text, (10, 10)) # 绘制追逐者数量 chaser_count_text = self.font.render(f’Chasers: {len(self.chasers)}‘, True, (255, 255, 255)) self.screen.blit(chaser_count_text, (10, 50)) # 如果游戏结束,显示提示信息 if self.game_over: game_over_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA) game_over_surface.fill((0, 0, 0, 180)) # 半透明黑色遮罩 self.screen.blit(game_over_surface, (0, 0)) text = self.font.render(‘GAME OVER! You were caught.’, True, (255, 100, 100)) text_rect = text.get_rect(center=(self.width // 2, self.height // 2 - 30)) self.screen.blit(text, text_rect) restart_text = self.font.render(‘Press R to Restart, ESC to Quit’, True, (200, 200, 255)) restart_rect = restart_text.get_rect(center=(self.width // 2, self.height // 2 + 30)) self.screen.blit(restart_text, restart_rect) pygame.display.flip() def run(self): """游戏主循环""" while self.running: self.handle_events() self.update() self.draw() self.clock.tick(60) # 60 FPS pygame.quit() sys.exit()4.4 创建程序入口 (main.py)
这是启动游戏的入口文件,非常简单。
# file: main.py from game import CatchMeGame if __name__ == ‘__main__’: game = CatchMeGame() game.run()4.5 运行与验证
- 确保所有文件 (
main.py,game.py,player.py,chaser.py) 和assets/images/文件夹(内含图片)都放在正确的目录结构中。 - 在终端中,导航到项目根目录
catch_me_if_you_can/。 - 运行命令:
python main.py - 游戏窗口应该会弹出。使用WASD或方向键控制蓝色圆形(或你的 MIKU 图片)移动。红色方块(或追逐者图片)会从屏幕外生成并开始追逐你。
- 尝试躲避红色方块。一旦被碰到,游戏会显示“GAME OVER”并暂停。此时按R键可以重新开始,按ESC键退出游戏。
- 观察屏幕左上角显示的生存时间和追逐者数量。
5. 常见问题与排查思路
在开发或运行上述代码时,你可能会遇到一些典型问题。下面是一个快速排查指南。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
运行后立即报错ModuleNotFoundError: No module named ‘pygame’ | Pygame 未安装或未正确安装。 | 在终端运行pip install pygame或pip3 install pygame。确保使用的 Python 环境与运行代码的环境一致。 |
报错FileNotFoundError: No such file or directory: ‘assets/images/miku.png’ | 图片文件路径错误或文件不存在。 | 1. 检查assets/images/目录是否存在,图片文件名是否拼写正确(包括大小写和扩展名)。2. 暂时使用代码中的备选绘图方案(注释掉 try块中的load,确保执行except块)。3. 使用绝对路径或修改 load函数中的相对路径。 |
| 游戏窗口卡顿或移动不流畅 | 帧率不稳定或每帧计算量过大。 | 1. 检查clock.tick(60)是否在主循环中,它限制了最高帧率。2. 如果追逐者数量非常多,AI 计算可能成为瓶颈。考虑优化距离计算(如使用距离平方进行比较)或使用空间划分算法(如四叉树)来减少不必要的计算。 |
| 角色移动速度比预期快(尤其是斜向) | 未对速度向量进行标准化。 | 在Player.handle_input()方法中,当同时按下两个方向键时,速度向量是(speed, speed),其长度是sqrt(2)*speed。务必在计算后调用self.velocity.normalize() * self.speed来确保各方向速度一致。 |
| 追逐者“抖动”或移动不自然 | AI 更新逻辑或位置取整问题。 | 1. 确保Chaser.update()中direction向量在标准化前检查长度是否大于 0,避免除以零错误。2. 位置 ( self.pos) 使用浮点数Vector2,而显示矩形 (self.rect.center) 使用整数。这是正常做法,但可能导致轻微视觉抖动。如果追求平滑,可以考虑使用子像素渲染(更高级的主题)。 |
| 碰撞检测不准确或延迟 | 碰撞矩形 (rect) 与图像大小不匹配,或更新顺序问题。 | 1. 确保self.rect是通过self.image.get_rect()正确获取的。2. 碰撞检测应在所有精灵的 update()方法被调用之后进行,以确保位置是最新的。 |
| 按 R 键无法重新开始游戏 | 游戏结束状态game_over的逻辑或事件处理有误。 | 检查Game.handle_events()中,对KEYDOWN事件和pygame.K_r键值的判断是否在self.game_over为True的条件下执行。 |
6. 最佳实践与工程建议
将一个小游戏跑起来只是第一步。要让代码更健壮、可维护、易扩展,可以参考以下实践:
6.1 资源管理与错误处理
- 集中管理资源路径:不要在多个类中硬编码
‘assets/images/...’。可以创建一个settings.py或config.py文件,定义资源根目录和具体路径常量。 - 健壮的资源加载:像我们代码中那样使用
try-except是一个好习惯。你还可以实现一个资源加载器,在游戏初始化时加载所有资源,如果失败则给出友好提示并退出,而不是在游戏中途崩溃。 - 使用精灵表(Sprite Sheet):如果你的角色有多个动画帧,使用一张包含所有帧的精灵表图片,然后通过
pygame.Rect来裁剪,比加载多个独立图片更高效。
6.2 游戏架构优化
- 状态模式(State Pattern):当前我们用
game_over布尔值管理状态。对于更复杂的游戏(开始菜单、设置、多个关卡),建议使用状态机。每个状态(如MenuState,PlayState,GameOverState)是一个独立的类,负责自己的事件处理、更新和渲染。 - 事件系统:除了 Pygame 的系统事件,可以创建自定义的游戏事件(如
PLAYER_CAUGHT,SPAWN_NEW_CHASER),通过pygame.event.post()发布,在其他模块中监听处理,实现松耦合。 - 配置数据驱动:将追逐者的速度、生成间隔、玩家初始生命值等游戏平衡参数提取到外部配置文件(如 JSON 或 YAML)中。这样调整游戏难度时无需修改代码。
6.3 AI 与游戏性增强
- 更智能的追逐者:
- 路径预测:让追逐者不是直接奔向玩家当前位置,而是预测玩家未来的位置(基于玩家当前速度方向)。
- 行为树或状态机:为追逐者实现“巡逻”、“追逐”、“困惑”、“返回”等不同状态,使行为更丰富。
- 差异化能力:创建不同类型的追逐者,比如有的速度快但视野短,有的速度慢但会“包抄”。
- 玩家能力与道具:
- 添加“冲刺”、“隐身”、“放置障碍”等主动技能。
- 在地图上随机生成临时道具,拾取后获得短暂增益效果。
- 关卡与进度:设计多个关卡,随着关卡提升,追逐者数量、速度或 AI 强度逐渐增加。生存足够时间后进入下一关。
6.4 性能与代码质量
- 脏矩形渲染:如果游戏场景中只有少量区域发生变化,可以使用
pygame.display.update(rect_list)只更新这些“脏”矩形区域,而不是整个屏幕,以提高性能。 - 使用精灵组高效碰撞检测:
pygame.sprite.groupcollide()可以高效地检测两个精灵组之间的所有碰撞。对于大量精灵,考虑使用pygame.sprite.spritecollideany()或空间划分。 - 代码注释与文档:为类和方法添加清晰的文档字符串(docstring),解释其作用、参数和返回值。复杂的逻辑处添加行内注释。
- 版本控制:使用 Git 等工具管理你的代码,定期提交。这不仅是好习惯,也能让你大胆尝试新功能而不怕破坏现有代码。
7. 总结与扩展方向
通过这个「MIKU」Catch_Me_If_You_Can 项目,我们走完了一个完整 2D 小游戏从零到一的开发流程。我们掌握了 Pygame 的核心循环、精灵管理、用户输入、基础 AI 和碰撞检测。更重要的是,我们实践了如何将游戏功能模块化为Player、Chaser和Game等类,这是构建更复杂项目的基础。
你可以以此项目为起点,进行多方面的扩展练习:
- 美术与音效:寻找或绘制更精美的精灵和背景图,为碰撞、游戏结束等事件添加音效。
- 游戏机制:实现分数系统、多种道具、多个关卡、Boss 战等。
- 网络功能:尝试使用 Socket 或更高层的库,实现一个简单的双人联机版,一人扮演 MIKU,另一人控制追逐者。
- 移植与打包:使用
pyinstaller等工具将你的 Python 游戏打包成独立的可执行文件,分享给没有安装 Python 的朋友。
游戏开发是编程技能的综合体现,涉及逻辑、架构、数学甚至美学。希望这个项目能成为你探索游戏开发世界的一块坚实跳板。动手修改代码,加入你自己的创意,是学习的最佳方式。如果在实践中遇到新的问题,不妨回顾文中提到的原理和排查思路,或查阅 Pygame 官方文档,你总能找到解决方案。
