简介:这是一份基于Python实现的《植物大战僵尸》游戏源码资源,面向Python初学者与小游戏开发爱好者,帮助学习者通过可运行的完整项目掌握Pygame框架基础、游戏循环逻辑、图像资源加载与事件响应机制。资源包共10个文件,含8个PNG格式游戏素材(如僵尸、豌豆射手、向日葵、草坪及地图等)、1个核心Python主程序(game.py)实现游戏主体逻辑,以及1份README.md说明文档,整体仅42KB,轻量易解压,开箱即用。目前已有1791人学习下载,适合课设实践、兴趣项目复现或Pygame入门教学参考。读者可直接运行game.py体验完整游戏流程,深入理解资源目录组织方式(素材集中存放、代码与文档分离)、精灵类设计思路及简单碰撞检测实现,是兼具完整性、可读性与教学价值的小型游戏开发范例。
1. 这不是玩具代码:一个能真跑起来、可调试、带完整游戏逻辑的 PyGame 植物大战僵尸复刻项目
上周帮某高校实验室带毕设的学生调一个“植物大战僵尸 Python 版”,结果翻出这个源码包——它没用任何 Web 框架,不依赖云服务,纯本地 PyGame 实现;但你能种向日葵攒阳光、拖拽豌豆射手到草坪格子、僵尸会按波次刷新、被击中后有血条和倒地动画、甚至阳光随时间衰减、植物冷却时间精确到帧。这不是教学 Demo,是实打实按游戏设计文档拆解出来的可运行工程:主循环帧率锁 60,碰撞检测用 AABB+半径双判据,状态机管理植物/僵尸生命周期,资源加载走 lazy-init 避免启动卡顿。适合想从零理解“游戏循环怎么写”“状态怎么流转”“资源怎么组织”的 Python 中级开发者,也适合需要快速验证游戏机制原型的策划同学。如果你之前下载过十几个“puzzle.py”“game_v1.py”却始终卡在“窗口一闪而过”或“僵尸不走”,那这次你拿对了东西。
2. 环境准备与一键运行:从 pip install 到首局通关的完整链路
2.1 依赖安装:只装 PyGame,不碰虚拟环境玄学
这个项目对环境极其克制:仅依赖 PyGame 2.5.2+(低版本可能缺pygame.transform.smoothscale导致缩放模糊),其他全是标准库。我试过 Python 3.8~3.12 全部通过,但强烈建议用 3.9 或 3.10——PyGame 在这两个版本上编译最稳,尤其 Windows 下避免SDL2.dll加载失败。
pip install pygame==2.5.2提示:不要加
-U强制升级全局 PyGame,容易和你其他项目冲突。如果已装高版本,先pip uninstall pygame再装指定版。Mac 用户若报pygame.error: No available video device,请确认已安装 XQuartz 并重启终端。
2.2 项目结构速览:5 个核心目录讲清数据流向
解压后你会看到清晰的分层结构(共 14 个文件,不含.git):
plants_vs_zombies/ ├── main.py # 入口:初始化窗口、主循环、事件分发 ├── config/ # 全局配置:常量、路径、音效开关 │ ├── __init__.py │ ├── constants.py # 所有数值:阳光值、冷却时间、僵尸血量、格子坐标映射表 │ └── paths.py # 图片/音效相对路径,支持跨平台斜杠处理 ├── assets/ # 资源:png 图片 + wav 音效(无 mp3,规避解码依赖) │ ├── images/ │ │ ├── plants/ # 植物图集:peashooter.png, sunflower.png... │ │ ├── zombies/ # 僵尸图集:zombie_walk1.png, zombie_eat.png... │ │ └── ui/ # UI 元素:sun_icon.png, shovel.png, toolbar_bg.png │ └── sounds/ ├── game/ # 核心逻辑:类定义与交互规则 │ ├── __init__.py │ ├── plant.py # Plant 基类 + 各植物子类(含 update() 行为树) │ ├── zombie.py # Zombie 基类 + 不同类型(普通/铁桶/撑杆)状态机 │ ├── sun.py # 阳光生成、拾取、衰减逻辑(带计时器) │ ├── grid.py # 9x5 草坪网格管理:坐标转换、格子状态(空/有植物/被啃) │ └── collision.py # 碰撞检测模块:plant-zombie / zombie-lawn 边界 └── utils/ # 工具函数:图片缓存、帧动画播放器、日志记录 ├── __init__.py ├── animation.py # SpriteSheet 动画控制器(支持帧率自适应) └── cache.py # LRU 缓存图片对象,避免重复 load 影响帧率关键点:所有资源路径由config.paths统一管理,修改图片位置只需改paths.py一行;game/下每个模块职责单一,比如zombie.py不管渲染,只管“该往哪走、血剩多少、是否死亡”。
2.3 首次运行:三步确认你的环境完全就绪
进入项目根目录,执行:
python main.py成功标志(按顺序出现):
- 黑窗口弹出 → 立即显示标题栏 “Plants vs Zombies”(说明 PyGame 初始化成功)
- 3 秒后主界面加载:顶部阳光计数器、底部植物工具栏、9x5 草坪网格(说明
assets/路径正确,图片加载无异常) - 点击向日葵图标 → 鼠标变成向日葵光标 → 点击草坪第一行中间格子 → 向日葵种下并开始产阳光(说明事件监听、植物放置、状态更新全链路通畅)
如果卡在第 1 步:检查 PyGame 是否安装成功(python -c "import pygame; print(pygame.version.ver)")
如果卡在第 2 步:用python -c "from config.paths import IMAGE_PATH; print(IMAGE_PATH)"确认路径拼接无误,再手动打开assets/images/ui/toolbar_bg.png看文件是否存在
如果卡在第 3 步:打开main.py,找到handle_mouse_click()函数,在if self.selected_plant:分支下加一行print(f"Selected: {self.selected_plant}"),确认点击事件被捕获
2.4 主循环精讲:60 FPS 下每一帧在做什么
main.py的Game.run()是心脏,其核心循环如下(已简化注释):
def run(self): clock = pygame.time.Clock() while self.running: # 1. 处理输入事件(鼠标/键盘)→ 更新 selected_plant / shovel_mode for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False elif event.type == pygame.MOUSEBUTTONDOWN: self.handle_mouse_click(event.pos) # 2. 更新游戏世界状态(核心!) self.sun_manager.update() # 阳光生成/衰减/拾取 self.zombie_manager.update() # 僵尸移动/攻击/死亡 self.plant_manager.update() # 植物攻击/冷却/生长 self.grid.update() # 检查格子是否被啃食 # 3. 渲染:严格按层级顺序(背景→草坪→植物→僵尸→UI) self.screen.blit(self.background, (0, 0)) self.grid.draw(self.screen) self.plant_manager.draw(self.screen) self.zombie_manager.draw(self.screen) self.ui_manager.draw(self.screen) # 4. 锁帧:确保稳定 60 FPS,避免 CPU 空转 clock.tick(60)重点:update()和draw()严格分离——所有计算逻辑在 update 阶段完成,draw 阶段只做 blit(位块传输)。这是游戏开发铁律:避免在渲染时做耗时计算(如碰撞检测)。你会发现collision.py的check_collision()只返回布尔值,不触发任何状态变更,状态变更全在zombie.update()里根据返回值决定。
3. 核心模块拆解:植物、僵尸、阳光三大系统的状态机实现
3.1 植物系统:从静态图片到有行为的生命体
game/plant.py定义了Plant基类,所有植物继承它。关键不在“画什么”,而在“怎么动”。以豌豆射手为例:
class PeaShooter(Plant): def __init__(self, x, y): super().__init__(x, y, cost=100, cooldown=1.4) # 成本100阳光,冷却1.4秒 self.peas = [] # 存储当前发射的豌豆实例(避免每帧 new 对象) self.last_shot_time = 0 def update(self, current_time): # 1. 检查冷却:用浮点时间戳比帧计数更精准 if current_time - self.last_shot_time > self.cooldown: # 2. 发射豌豆:创建新 pea,加入 manager 统一管理 pea = Pea(self.rect.right, self.rect.centery) self.peas.append(pea) self.last_shot_time = current_time # 3. 更新所有豌豆位置(物理模拟:匀速直线运动) for pea in self.peas[:]: # 遍历副本,避免删除时迭代错误 pea.update() if pea.rect.x > SCREEN_WIDTH: # 飞出屏幕则回收 self.peas.remove(pea) def draw(self, screen): super().draw(screen) # 先画植物本体 for pea in self.peas: # 再画所有豌豆 pea.draw(screen)逻辑说明:
cooldown是浮点秒数,current_time来自pygame.time.get_ticks()/1000,避免帧率波动导致攻击频率失真self.peas是对象池雏形:不频繁 new/delete,而是复用列表,提升 GC 效率Pea类本身极轻量,只有rect和update(),无图像加载逻辑(图片由utils.cache预加载)
对比向日葵(SunFlower):
class SunFlower(Plant): def __init__(self, x, y): super().__init__(x, y, cost=50, cooldown=10.0) # 产阳光慢,但成本低 self.sun_generation_timer = 0 def update(self, current_time): self.sun_generation_timer += (current_time - self.last_update_time) self.last_update_time = current_time if self.sun_generation_timer >= 10.0: # 每10秒产1个阳光 sun = Sun(self.rect.centerx, self.rect.centery - 20) self.sun_manager.add_sun(sun) # 通知阳光管理器 self.sun_generation_timer = 0参数说明:
last_update_time记录上一帧时间,用于计算真实流逝时间(delta_time)sun_generation_timer是累加器,不依赖帧数,确保 10 秒就是 10 秒,哪怕帧率掉到 30add_sun()是解耦设计:植物不关心阳光怎么显示,只通知 manager
3.2 僵尸系统:多状态机驱动的 AI 行为
game/zombie.py用状态机(State Pattern)管理僵尸生命周期。普通僵尸Zombie类结构如下:
class Zombie: def __init__(self, x, y): self.x = x self.y = y self.speed = 0.5 # 像素/帧(注意:非像素/秒!因帧率锁定,等效于0.5*60=30px/s) self.health = 10 self.state = 'walking' # ['walking', 'eating', 'dying'] self.eating_target = None # 指向被啃的植物 self.animation = Animation('zombie_walk', frame_rate=8) # 每8帧切一张图 def update(self, grid): if self.state == 'walking': self.x -= self.speed # 向左走(玩家在右,僵尸从右来) # 检查是否到达草坪最左列(x < 50) if self.x < 50: self.state = 'eating' self.eating_target = grid.get_plant_at(0, int(self.y//100)) # 粗略映射行号 elif self.state == 'eating': if self.eating_target and self.eating_target.health > 0: self.eating_target.health -= 0.1 * (1/60) # 每秒扣6点血(60帧下每帧扣0.1) else: self.state = 'walking' # 目标消失,继续走 elif self.state == 'dying': self.animation.play('zombie_die') # 播放死亡动画 if self.animation.is_finished(): return True # 告诉 manager 可以移除 self.animation.update() # 无论什么状态,都要更新动画 return False # 默认不销毁关键设计:
- 状态切换由自身逻辑触发,不依赖外部指令(如
zombie.set_state('eating')) eating_target是弱引用:僵尸只存植物在网格中的坐标索引,避免循环引用导致 GC 失败speed单位是“像素/帧”而非“像素/秒”,因为主循环已锁帧,省去时间缩放计算,更稳定
撑杆僵尸(PoleVaultingZombie)扩展了此逻辑:
class PoleVaultingZombie(Zombie): def __init__(self, x, y): super().__init__(x, y) self.speed = 1.2 # 更快 self.vaulting = False self.vault_start_x = 0 def update(self, grid): if self.vaulting: # 撑杆跳动画:先上抛再下落,用二次函数模拟 t = (pygame.time.get_ticks() - self.vault_start_time) / 1000 self.y = self.vault_start_y - 100 * t + 200 * t**2 if self.y > self.vault_start_y + 50: # 落地 self.vaulting = False self.state = 'walking' else: super().update(grid) # 检测前方是否有植物,触发跳跃 front_plant = grid.get_plant_at(int((self.x-50)//100), int(self.y//100)) if front_plant and not self.vaulting: self.vaulting = True self.vault_start_time = pygame.time.get_ticks() self.vault_start_y = self.y这里用t²模拟抛物线,不引入物理引擎,用数学公式达成视觉效果,是小型游戏常见技巧。
3.3 阳光系统:经济模型与粒子效果的结合
game/sun.py不只是飘动的圆圈,它实现了完整的经济闭环:
class Sun: def __init__(self, x, y): self.x = x self.y = y self.value = 25 # 阳光价值 self.fall_speed = 0.8 self.rotation = 0 self.rotation_speed = 5 self.lifetime = 10.0 # 10秒后自动消失 self.age = 0.0 self.collected = False def update(self, delta_time): self.y += self.fall_speed # 下落 self.rotation += self.rotation_speed self.age += delta_time if self.age > self.lifetime: return True # 超时,销毁 def is_hovered(self, mouse_pos): # 圆形碰撞检测:距离平方 < 半径平方(避免开方) dx = mouse_pos[0] - self.x dy = mouse_pos[1] - self.y return (dx*dx + dy*dy) < 400 # 半径20像素 def collect(self): self.collected = True return self.valuesun_manager.py控制生成策略:
class SunManager: def __init__(self): self.suns = [] self.spawn_timer = 0 self.spawn_interval = 5.0 # 初始5秒产1个 def update(self, current_time): # 1. 更新所有阳光 for sun in self.suns[:]: if sun.update(current_time - self.last_update_time): self.suns.remove(sun) # 2. 定时生成:间隔随游戏时间缩短(增加难度) self.spawn_timer += (current_time - self.last_update_time) if self.spawn_timer >= self.spawn_interval: # 随机位置:在屏幕顶部 200px 内生成 x = random.randint(100, SCREEN_WIDTH - 100) y = random.randint(-50, -20) self.suns.append(Sun(x, y)) self.spawn_timer = 0 # 难度递增:每过30秒,间隔减少0.2秒(最低2秒) if current_time > 30: self.spawn_interval = max(2.0, 5.0 - (current_time // 30) * 0.2)参数说明:
spawn_interval动态调整,让游戏后期阳光雨更密集,逼玩家更快决策is_hovered()用距离平方比较,比math.sqrt()快 3 倍以上,高频调用必须优化collect()返回value,供 UI 更新计数器,数据流单向:阳光 → manager → UI
4. 避坑指南:新手必踩的 4 个血泪现场与修复方案
4.1 现象:窗口弹出后立即崩溃,报错pygame.error: video system not initialized
原因:PyGame 模块未正确初始化,常见于pygame.init()被放在main.py的类方法里,而非模块顶层。或者pygame.display.set_mode()调用前有其他 PyGame 函数(如pygame.image.load)被提前执行。
解决:打开main.py,确认第 10 行左右有pygame.init(),且它在if __name__ == "__main__":之后、任何pygame.*调用之前。检查config/paths.py里是否有pygame.image.load调用——所有资源加载必须在pygame.init()之后、set_mode()之后进行。
4.2 现象:僵尸走到草坪最左边就卡住不动,不啃植物
原因:grid.py中get_plant_at(col, row)方法返回None,但zombie.update()里没有判空,直接访问self.eating_target.health导致AttributeError。
解决:在zombie.py的eating状态分支中,添加空值检查:
elif self.state == 'eating': if self.eating_target and hasattr(self.eating_target, 'health'): # 双重保险 self.eating_target.health -= 0.1 * (1/60) else: self.state = 'walking' self.eating_target = None # 清空目标4.3 现象:向日葵产的阳光飘到一半就消失,数量对不上
原因:Sun类的lifetime是 10.0 秒,但update()中self.age += delta_time的delta_time计算错误。原代码用pygame.time.get_ticks()差值,但未除以 1000 转为秒,导致age以毫秒增长,10 秒实际是 10000 毫秒,age却在 10 秒内涨到 60000(60帧×1000),远超lifetime。
解决:统一用秒为单位。在Sun.update()中:
def update(self, delta_time): # delta_time 已是秒(来自 clock.tick(60) 的返回值) self.y += self.fall_speed self.rotation += self.rotation_speed self.age += delta_time # 正确!delta_time 就是秒 if self.age > self.lifetime: return True并在sun_manager.update()调用处传入delta_time(clock.tick(60)返回毫秒,需/1000)。
4.4 现象:点击植物图标后,鼠标光标不变化,无法种植
原因:main.py中handle_mouse_click()逻辑有缺陷:当点击 UI 区域(如工具栏)时,未正确设置self.selected_plant,且未阻止事件向下传递到草坪。
解决:重构点击处理逻辑,增加区域判断:
def handle_mouse_click(self, pos): # 1. 先检查是否点击工具栏 if self.ui_manager.toolbar_rect.collidepoint(pos): plant_type = self.ui_manager.get_clicked_plant(pos) if plant_type: self.selected_plant = plant_type self.shovel_mode = False return # 截断,不往下执行 # 2. 再检查是否点击铲子 if self.ui_manager.shovel_rect.collidepoint(pos): self.shovel_mode = True self.selected_plant = None return # 3. 最后才处理草坪点击 if self.grid.rect.collidepoint(pos): col, row = self.grid.get_grid_position(pos) if self.shovel_mode: self.grid.remove_plant(col, row) elif self.selected_plant: self.plant_manager.try_place(self.selected_plant, col, row)注意:
collidepoint()比坐标范围判断更鲁棒,适配旋转/缩放后的矩形。
5. 二次开发实战:3 个可立即落地的改造方案与验证技巧
5.1 方案一:给豌豆射手加“溅射伤害”,3 行代码搞定范围攻击
需求:让豌豆击中僵尸时,对周围 100 像素内所有僵尸造成 30% 伤害。不改核心循环,只增强Pea类。
步骤:
- 打开
game/plant.py,找到Pea类(通常在PeaShooter下方) - 修改
update()方法,在豌豆击中僵尸后添加范围检测:
class Pea: def update(self): self.rect.x += self.speed # ... 原有碰撞检测代码 ... if hit_zombie: # 原有单体伤害 zombie.health -= self.damage # 新增:溅射伤害(3 行核心) for other_zombie in self.zombie_manager.zombies: if other_zombie != zombie: # 排除自己 dist = ((other_zombie.x - zombie.x)**2 + (other_zombie.y - zombie.y)**2)**0.5 if dist < 100: other_zombie.health -= self.damage * 0.3验证技巧:
- 启动游戏,种一排豌豆射手,引一群僵尸聚堆
- 观察:当第一个僵尸被击中倒地时,周围僵尸血条是否同步下降
- 进阶验证:在
zombie.update()开头加print(f"Zombie {id(self)} health: {self.health}"),看控制台输出是否有多条日志同时变化
避坑提醒:self.zombie_manager需要从PeaShooter传入(原代码未传递)。修改Pea.__init__(),增加zombie_manager参数,并在PeaShooter.update()创建Pea时传入self.zombie_manager。这是典型的“依赖注入”实践——不硬编码全局变量,提高可测试性。
5.2 方案二:实现“植物升级系统”,用配置驱动而非硬编码
需求:让向日葵升到 2 级后,产阳光间隔从 10 秒缩短到 7 秒。不改SunFlower类,用配置表驱动。
步骤:
- 在
config/constants.py底部添加升级配置:
PLANT_UPGRADES = { 'sunflower': [ {'level': 1, 'sun_interval': 10.0, 'cost': 50}, {'level': 2, 'sun_interval': 7.0, 'cost': 200}, {'level': 3, 'sun_interval': 5.0, 'cost': 500}, ], 'peashooter': [ {'level': 1, 'damage': 20, 'cooldown': 1.4, 'cost': 100}, {'level': 2, 'damage': 30, 'cooldown': 1.2, 'cost': 250}, ] }- 修改
game/plant.py的SunFlower.__init__(),读取配置:
def __init__(self, x, y, level=1): upgrade = next(u for u in PLANT_UPGRADES['sunflower'] if u['level'] == level) super().__init__(x, y, cost=upgrade['cost'], cooldown=0) # cooldown 无意义,用 sun_interval self.sun_interval = upgrade['sun_interval'] self.level = level- 在
SunFlower.update()中,用self.sun_interval替代硬编码的10.0。
验证技巧:
- 在
main.py中临时添加升级按钮(如按U键):
elif event.type == pygame.KEYDOWN: if event.key == pygame.K_u and self.selected_plant == 'sunflower': # 查找当前等级,升到下一级 current_level = self.plant_manager.get_level('sunflower') next_level = min(current_level + 1, 3) self.plant_manager.upgrade_plant('sunflower', next_level)- 启动后种向日葵,按
U,观察产阳光频率是否变快(用手机秒表测两次间隔)
设计哲学:把数值逻辑从代码中剥离到配置,是大型项目可维护性的基石。下次调平衡,策划同学改constants.py就行,不用动 Python 代码。
5.3 方案三:接入实时性能监控,一眼看出帧率瓶颈
需求:想知道update()阶段哪个模块最耗时,避免盲目优化。
步骤:
- 在
main.py顶部加性能计时器:
import time # ... 其他 import ... class Game: def __init__(self): # ... 原有代码 ... self.timers = { 'sun': 0, 'zombie': 0, 'plant': 0, 'grid': 0, 'total_update': 0, 'total_draw': 0 }- 在
run()循环中插入计时:
def run(self): clock = pygame.time.Clock() while self.running: # ... 事件处理 ... # 计时 update 阶段 start = time.perf_counter() self.sun_manager.update() self.timers['sun'] = time.perf_counter() - start start = time.perf_counter() self.zombie_manager.update() self.timers['zombie'] = time.perf_counter() - start # ... 其他 update ... self.timers['total_update'] = time.perf_counter() - start_all_update # 计时 draw 阶段(同理) start = time.perf_counter() self.screen.blit(...) self.timers['total_draw'] = time.perf_counter() - start- 在
UIManager.draw()中显示帧率和耗时:
def draw_fps_and_timers(self, screen, timers): font = pygame.font.SysFont(None, 24) texts = [ f"FPS: {int(1000/clock.get_time()) if clock.get_time()>0 else 0}", f"Update: {timers['total_update']*1000:.1f}ms", f"Sun: {timers['sun']*1000:.1f}ms", f"Zombie: {timers['zombie']*1000:.1f}ms", ] for i, text in enumerate(texts): rendered = font.render(text, True, (255,255,255)) screen.blit(rendered, (10, 10 + i*25))验证技巧:
- 启动游戏,打开任务管理器,观察 Python 进程 CPU 占用是否低于 15%(健康值)
- 故意在
zombie.update()中加time.sleep(0.001)模拟卡顿,看监控面板是否显示Zombie耗时飙升 - 当
total_update> 16ms(60FPS 的帧预算),说明有模块超时,优先优化它
从那以后我每次接手新游戏项目,第一件事就是加这套计时器——它像汽车仪表盘,不告诉你怎么修发动机,但清楚显示哪个转速表爆红。没有监控的优化,都是玄学。希望帮到你。
本文还有配套的精品资源,点击获取