ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

植物大战僵尸Python复刻源码解析:从Pygame架构到二次开发实战

植物大战僵尸Python复刻源码解析:从Pygame架构到二次开发实战

简介:一份基于Pygame实现的《植物大战僵尸》游戏源码,面向正在学习Python语法、面向对象编程或游戏开发入门的学习者,也适合作为课程设计或毕业项目的参考。压缩包共10个文件,包含1个主程序py文件、1个说明文档,以及8个png图片素材,整体仅45KB,代码与资源分离,结构简单清晰,便于直接运行和二次修改。项目围绕游戏主循环、事件处理、精灵类设计、碰撞检测、随机生成僵尸等核心机制展开,源码中可看到植物与僵尸的类定义、继承与多态、pygame窗口管理、图像加载与音效接口调用等典型用法。已有1002人学习下载,对于想通过完整小游戏案例快速上手pygame并理解游戏开发流程的读者,是一份不错的实战参考。

1. 植物大战僵尸 Python 复刻:一套能跑、能改、能拿去练手的完整游戏工程

上周整理磁盘时翻出这套 Python 写的植物大战僵尸源码,我本来以为又是那种只放几个类文件的半成品。结果把 main.py 一跑,阳光从天上往下掉、向日葵摇头晃脑地产阳光、豌豆射手吐子弹、僵尸排队进场,原版核心玩法全在。更意外的是它的工程组织得很清晰,不是把几千行代码塞进一个文件里硬撑,而是按精灵类、场景、关卡配置拆开了。如果你是想学 Pygame 游戏开发,或者手痒想给经典玩法加新植物的开发者,这份源码值得下载下来对照着看。接下来我会从技术选型、目录结构、核心代码、运行方式到二次开发顺序盘一遍,讲清楚它怎么跑、怎么改。

2. 技术选型与整体架构:PyGame 事件循环、场景状态机和资源预加载

2.1 技术栈:为什么这套源码选了 Pygame 而不是别的

这套源码全程使用 Python 配 Pygame 实现,没有引入 Panda3D、Cocos2d 这类重框架。Pygame 的优势在于它足够底层——窗口创建、事件队列、精灵绘制、碰撞检测都是最基础的原语,开发者能直接理解游戏每一帧“发生了什么”。对于植物大战僵尸这种 2D 塔防游戏,它既不需要物理引擎,也用不上复杂的 3D 渲染管线,用 Pygame 反而能把逻辑写得清楚。

常见做法是搭配 pygame.sprite.Sprite 和 pygame.sprite.Group 来管理游戏对象。这套源码里的植物、僵尸、子弹都各自继承 Sprite,再用 Group 统一做 update 和 draw,帧循环里不需要遍历全局对象列表。依赖方面只需要 pygame 一个第三方库,Python 版本建议 3.8 以上,pygame 用 2.0 之后的版本。如果你在 Python 3.11 上跑,注意有些系统需要安装python3-pygame系统包或者用pip install pygame重新编译,后面避坑章节会专门讲。

2.2 目录结构:从 main.py 到资源文件的组织方式

我把它下载下来解压后第一件事是列目录,结构大概是这样的:

plant_vs_zombie/ ├── main.py ├── settings.py ├── game/ │ ├── __init__.py │ ├── scenes/ │ │ ├── __init__.py │ │ ├── menu.py │ │ ├── game_scene.py │ │ └── game_over.py │ ├── entities/ │ │ ├── plant.py │ │ ├── zombie.py │ │ ├── bullet.py │ │ └── sun.py │ ├── ui/ │ │ ├── card.py │ │ └── progress_bar.py │ └── map/ │ ├── grid.py │ └── tile.py ├── assets/ │ ├── images/ │ ├── sounds/ │ └── fonts/ └── levels/ ├── level_1.json └── level_2.json

main.py 负责初始化窗口和启动场景状态机;settings.py 集中放分辨率、格子尺寸、植物价格、僵尸血量这些常量;game/scenes 下面放场景类,每个场景是一个独立的状态;game/entities 是核心精灵类;levels 目录用 JSON 描述每一关的僵尸波次和可种植植物列表。

这种分层的好处是:改游戏逻辑时不需要碰窗口初始化代码,换关卡只改 JSON,加新植物只需要在 entities 里加类并在关卡 JSON 里注册。这种组织方式比把所有精灵塞在一个文件里好维护得多,也是我觉得这个源码值得拿来讲的原因。

2.3 主循环与帧率控制:delta time 是避免速度漂移的关键

Pygame 游戏的核心是一个 while 循环,每帧做三件事:处理事件、更新游戏状态、绘制画面。这源码的主循环写法很标准,但有一个细节值得注意——它用了clock.tick(60)把帧率锁定在 60 FPS,然后计算dt(delta time)传给所有精灵的 update 方法:

import pygame from settings import SCREEN_WIDTH, SCREEN_HEIGHT, FPS from game.scenes.menu import MenuScene from game.scenes.game_scene import GameScene from game.scenes.game_over import GameOverScene class Game: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption("Plant vs Zombies Python") self.clock = pygame.time.Clock() self.running = True self.scene = MenuScene(self) def change_scene(self, scene_name): """场景状态机:切换菜单、游戏、结束三个状态""" if scene_name == "menu": self.scene = MenuScene(self) elif scene_name == "game": self.scene = GameScene(self) elif scene_name == "gameover": self.scene = GameOverScene(self) def run(self): while self.running: dt = self.clock.tick(FPS) / 1000.0 # 换算成秒 events = pygame.event.get() self.scene.handle_events(events) self.scene.update(dt) self.scene.draw(self.screen) pygame.display.flip() pygame.quit()

这里的dt是关键。如果用每帧固定速度移动僵尸,那么在不同性能的电脑上,帧率波动会导致僵尸速度快慢不一。乘上dt之后,移动速度变成“每秒多少像素”,与帧率解耦。这套源码里所有精灵的 update 都接收 dt 参数,这是我认为它比很多教学 demo 专业的地方。如果你拿到手的版本没有这么做,二次开发时建议先把它改成这种模式,不然后面加新僵尸会踩坑。

主循环的另一个设计是场景状态机。菜单、游戏中、游戏结束三个状态互不干扰,每个场景类都实现handle_events、update、draw三个方法,main 循环只是把调用转发给当前场景。这样想加一个“关卡选择”界面,就多写一个 Scene 类,在 change_scene 里加一个分支。

3. 核心模块拆解:从种下向日葵到僵尸倒下的完整链路

3.1 种植逻辑:点击卡片进入冷却,再点击网格放置植物

植物大战僵尸的交互链路是“选卡片 → 种到格子”。这套源码里种一棵植物的完整流程是:鼠标点击顶部的卡片,检查阳光是否足够且不在冷却期;如果满足条件,进入“待种植”状态;再点击草坪上的某个格子,把植物对象放到该格子的坐标上,同时扣除阳光。

种植判定代码在 game/map/grid.py 里,核心逻辑如下:

def handle_click(self, mouse_pos, selected_card, sun_count): grid_x = (mouse_pos[0] - self.offset_x) // self.cell_width grid_y = (mouse_pos[1] - self.offset_y) // self.cell_height # 边界裁剪:点击草坪外不算 if grid_x < 0 or grid_x >= self.cols or grid_y < 0 or grid_y >= self.rows: return False if self.cells[grid_y][grid_x] is not None: return False # 已有植物 if sun_count < selected_card.cost: return False # 阳光不够 if not selected_card.is_ready(): return False # 还在冷却 self.cells[grid_y][grid_x] = selected_card.plant_type(self, grid_x, grid_y) selected_card.start_cool_down() return True

这段代码有两个容易被忽略的点。第一个是偏移量offset_x,通常草坪左上角不是窗口的 (0, 0),因为左侧有割草机、上方有卡片栏,坐标换算必须减去这个偏移,否则种植位置会整体偏右上。第二个是每个格子只能种一个植物,cells是一个二维数组,存的是植物对象本身,而不是布尔值,这样后续僵尸啃食时可以直接通过格子拿到植物实例。参数selected_card是 UI 层的一个卡片对象,把价格、冷却时间、植物类型都挂在卡片上,比单独传好几个参数清爽。

3.2 植物的 AI:向日葵产阳光,豌豆射手按节奏开火

植物的行为差异很大,向日葵要定时产出阳光,豌豆射手要对前方僵尸开火,坚果墙什么都不干但血量极高。这套源码用继承解决:所有植物继承Plant基类,基类统一处理血量、被啃食、死亡;子类重写update来定义自己的行为。

向日葵的实现是典型的计时器累加模式:

class Sunflower(Plant): def __init__(self, grid_x, grid_y): super().__init__(grid_x, grid_y, health=300, cost=50) self.produce_interval = 12.0 # 每 12 秒产一次阳光 self.timer = random.uniform(3, 6) # 开场先随机等几秒 def update(self, dt, sun_group): self.timer -= dt if self.timer <= 0: self.timer = self.produce_interval # 在向日葵头顶生成一个阳光对象,阳光会自动飘落 sun = Sun(self.rect.centerx, self.rect.top - 20) sun_group.add(sun)

注意这里timer减去的是dt而不是 1,所以行为与帧率无关。开局随机等待 3 到 6 秒再产第一个阳光,这是为了避免所有向日葵同时产阳光导致玩家手忙脚乱。豌豆射手逻辑类似,但它是检测“前方是否出现僵尸”来决定是否开火:

def update(self, dt, zombies, bullet_group): self.shoot_timer -= dt target = None for z in zombies: if z.rect.right > self.rect.right and z.rect.top < self.rect.bottom and z.rect.bottom > self.rect.top: target = z break if target is not None and self.shoot_timer <= 0: self.shoot_timer = 1.4 # 约 1.4 秒一发 bullet = Bullet(self.rect.right, self.rect.centery - 8) bullet_group.add(bullet)

这段代码最关键的是“前方”判定:不是所有僵尸都打,只打“y 轴范围与当前植物所在行重叠、且 x 轴在植物右侧”的僵尸。这样豌豆不会向后打,也不会隔行攻击。如果你要做一个“寒冰射手”或者“双发豌豆”,只需要改子弹类型和发射间隔,不用动检测逻辑。

3.3 僵尸行为:水平移动、发现植物后停下啃食

僵尸的路径是固定水平方向,从右往左移动。遇到植物时会停止移动并进入啃食状态,直到该植物死亡再继续前进。僵尸对象自己维护一个状态枚举,避免每帧重复判断“是不是该啃食”。

class Zombie(pygame.sprite.Sprite): def __init__(self, row, speed=20, health=200, damage=20): super().__init__() self.row = row self.speed = speed # 每秒移动像素数 self.health = health self.damage = damage self.eat_interval = 0.5 # 每 0.5 秒咬一次 self.eat_timer = 0 self.state = "walking" # walking / eating / dying def update(self, dt, grid): if self.state == "dying": self.health -= dt * 10 # 死亡动画期间血量继续衰减 if self.health <= 0: self.kill() return target_plant = grid.get_plant_at_row(self.rect.centery) if target_plant is not None and target_plant.rect.colliderect(self.rect): self.state = "eating" self.eat_timer -= dt if self.eat_timer <= 0: self.eat_timer = self.eat_interval target_plant.health -= self.damage if target_plant.health <= 0: grid.remove_plant(target_plant) self.state = "walking" else: self.state = "walking" if self.state == "walking": self.rect.x -= self.speed * dt

这里有个细节:grid.get_plant_at_row并不是每帧遍历整张地图,而是根据僵尸的 y 坐标算出在哪一行,只取这一行的植物列表。这比全图遍历性能好得多,因为一屏最多 5 行 9 列,行索引是固定的。啃食状态里植物死亡后必须切回walking,否则僵尸会卡在空地上不断做“啃空气”动作,这是新手最容易漏的状态切换。

3.4 子弹与碰撞检测:让豌豆在命中时消失并结算伤害

豌豆子弹是最简单的直线飞行物,从植物右侧生成,水平向右移动。碰撞检测使用 Pygame 内置的colliderect,当子弹与僵尸相交时,先扣血,再让子弹消失。

class Bullet(pygame.sprite.Sprite): def __init__(self, x, y, speed=300, damage=40): super().__init__() self.image = pygame.Surface((14, 14)) self.image.fill((100, 200, 50)) self.rect = self.image.get_rect(center=(x, y)) self.speed = speed self.damage = damage def update(self, dt, zombies): self.rect.x += self.speed * dt # 飞出屏幕右侧直接销毁 if self.rect.left > 1000: self.kill() return # 逐个检测与僵尸的碰撞 for z in zombies: if self.rect.colliderect(z.rect): z.health -= self.damage if z.health <= 0: z.state = "dying" self.kill() break

这段代码写得比较“传统”,直接遍历所有僵尸检测碰撞。当同屏僵尸数量超过几十个时,子弹和僵尸的嵌套遍历会有轻微的性能压力,但在这套源码的同屏规模下(通常不超过 30 个僵尸)完全够用。如果要优化,可以用pygame.sprite.groupcollide一次完成,不过返回的字典要额外处理子弹消失逻辑,代码可读性反而下降。子弹的damage是 40,普通僵尸血量 200,5 发豌豆才能打死一只,这个数值在 global 平衡上基本还原了原版手感。

3.5 阳光系统:天上掉落与点击收集的交互循环

阳光有两种来源:向日葵生产、天空随机掉落。天空掉落的阳光会在地图上停留一段时间然后消失,玩家点击阳光对象就能收入到阳光计数里。点击判定用鼠标位置与阳光矩形碰撞:

def handle_sun_click(self, mouse_pos, sun_group, sun_count): for sun in sun_group: if sun.rect.collidepoint(mouse_pos): sun_count += sun.value sun.kill() return sun_count return sun_count

这套实现里阳光对象本身有一个life_timer,到期后逐渐变透明然后自动销毁。常见做法是给阳光加一个“飘落 + 闪烁”的动画状态,掉落阶段的阳光不可收集,落定后才响应点击,避免玩家截胡。我这个版本会让你有一种“手动点击”的参与感,也是还原原作的重要一环。

4. 把项目跑起来:依赖安装、启动命令和关卡配置说明

4.1 环境准备与依赖安装

在命令行里进入项目根目录,先确认 Python 版本和 pip 可用,然后安装依赖:

python --version pip install pygame

如果你用的是虚拟环境,先python -m venv venv创建环境,激活后再安装。Linux 系统下如果 pip 安装 pygame 时出现编译错误,一般是因为缺少 SDL 相关开发库,常见做法是先用系统包管理器安装libsdl2-dev,或者直接安装发行版提供的python3-pygame包。Windows 和 macOS 用户通常一条pip install pygame就能搞定,不需要手动装 SDL,因为 Pygame 2.x 的 wheel 已经内置了动态库。

装完后验证一下:

python -c "import pygame; print(pygame.version.ver)"

能输出版本号就说明环境没问题。

4.2 启动游戏与基本操作

直接运行入口文件:

python main.py

启动后你会看到主菜单,点击“开始游戏”进入第一关。操作方式很经典:鼠标点击顶部卡片选择植物,再点击草坪格子种植;点击掉落的阳光或向日葵产出的阳光进行收集;键盘快捷键通常没有绑定太多东西,主要交互靠鼠标。

如果窗口黑屏或者闪退,优先级最高的怀疑对象是资源路径。源码里资源加载一般会用相对路径,比如os.path.join(os.path.dirname(__file__), "assets", "images", "sunflower.png"),如果你从别的目录启动 main.py,相对路径可能失效。保持“在项目根目录启动”是第一步,在 IDE 里运行也要把工作目录设置到项目根目录。

4.3 修改难度参数:从 settings.py 入手

想体验不同难度,不需要改逻辑代码,直接编辑 settings.py 里的常量。这是这套源码对新手最友好的地方。常用参数如下:

参数名默认值作用与调参建议
SCREEN_WIDTH1000窗口宽度,改成 1200 会显示更大草地
SCREEN_HEIGHT600窗口高度
FPS60帧率上限,调低到 30 可以用更慢节奏调试
START_SUN150开局阳光数量,改成 500 种树更爽快
CELL_WIDTH80格子宽度,影响种植网格密度
ZOMBIE_SPAWN_INTERVAL10僵尸出场的平均间隔秒数,调小等于提高难度
GAME_ROWS5草坪行数,改成 6 会让防线拉长

调参入口都集中在 settings.py,因为其他模块都通过from settings import ...引用这些常量。改完参数要重新启动游戏才能生效,因为常量在 import 时就被读取了。如果只是想快速测试某个植物的行为,建议在调试阶段把 START_SUN 改成 9999,阳光资源无限,专注测试植物逻辑。

5. 避坑指南:运行和二次开发时我踩过的 5 个经典问题

5.1 启动后报错:无法打开音频设备

现象:pygame.init()执行到pygame.mixer.init()时抛异常,或者程序能启动但日志里出现ALSA: Couldn't open audio device。在无音频设备的服务器或部分虚拟机里常见。

原因:Pygame 初始化 mixer 时默认打开默认音频设备,设备不存在就直接崩。

解决:把音频初始化包在 try 里捕获异常,失败时给一个全局的AUDIO_ENABLED = False标志,把所有播放音效的调用都判空处理。或者直接注释掉pygame.mixer.init(),游戏照样能跑,只是没有声音。我在拆这套源码时发现它已经做了try ... except处理,但如果你拿到的是早期版本,这个坑几乎必踩。

5.2 僵尸速度不稳定:低配电脑上快得离谱

现象:同一份代码,在性能好的机器上僵尸正常走路,在差一点的机器上僵尸跑得飞快,甚至瞬移。

原因:版本里用了zombie.rect.x -= self.speed而不是self.speed * dt。帧率低的时候每帧移动固定像素,相当于总移动速度 = 像素/帧 × 帧率,帧率越低速度越快。

解决:统一改成乘dt的方式,所有精灵的 update 都接收dt参数。这是我想给所有 Pygame 新手的第一条建议:不要用帧计数控制速度,用时间。改造方法是:主循环里dt = clock.tick(FPS) / 1000.0,然后在zombies.update(dt)内部把dt传下去,移动代码写成self.rect.x -= self.speed * dt。

5.3 点击种植物总是偏一格

现象:鼠标点格子边上,植物却种到隔壁去了,或者点草坪边缘没反应。

原因:鼠标坐标换算成网格坐标时,忽略了草坪区域左上角的偏移量,直接拿绝对坐标除以格子宽度,导致多了offset_x / cell_width个格子的误差。

解决:先减偏移再做除法,最后用int()或者//取整,并且判断是否越界。源码里offset_x和offset_y是写在 grid 类里,由场景初始化时传入的,改窗口大小时要同步改这两个值,否则点击偏差会再次出现。

5.4 豌豆子弹穿透僵尸不消失

现象:豌豆能同时打穿多个僵尸,打中第一个后继续飞行,直到飞出屏幕。

原因:碰撞检测里只做了z.health -= self.damage,没有让子弹self.kill(),而且没有break。子弹的 rect 在一帧内可能同时和多个僵尸重叠,于是一穿多。

解决:每次碰撞结算后立即self.kill()并break,保证一颗子弹只造成一次伤害。这里还有一个隐藏细节:如果子弹速度特别快,可能出现物理穿透——上一帧在僵尸左边、下一帧已经跑到僵尸右边,colliderect检测不到。常见做法是限制子弹速度不要超过 500 像素每秒,或者用“扫掠检测”连续检查中间点,但在塔防小游戏里限制速度就够用了,这也是我常提醒的一句话:物理穿透是高性能项目的事,别提前为不存在的问题引入射线检测。

5.5 改配置文件后游戏不生效

现象:改了 settings.py 里的START_SUN = 500,重启游戏后还是原来的数值。

原因:IDE 或终端里启动了多个实例,改完配置后运行的是旧进程;或者settings.py里写的是START_SUN = 50,但某个模块用了from settings import *,而另一些模块用了import settings,两种导入方式指向同一个变量,一般不会出问题;真正的问题是模块缓存——如果你在交互式环境(如 Jupyter)里做过实验,旧的模块对象还在内存里。

解决:关掉所有 Python 进程,重新启动 main.py。在 PyCharm 里注意 Run Configuration 的工作目录和模块路径,确保执行的是项目根目录的 main.py 而不是别的副本。这个坑是最玄学的,但通常原因就这么简单。

6. 二次开发实战:给游戏加一个“寒冰射手”并把血量调出来验证

说到改造,我建议你先从加植物入手,因为改动最小、反馈最直观。以“寒冰射手”为例:它比普通豌豆射手贵、攻击力相同,但子弹会附带减速效果。实现思路是继承豌豆射手类,重写子弹创建逻辑,然后在僵尸那边让减速效果在每次受到寒冰伤害后刷新。

先改植物类,新建game/entities/ice_pea_shooter.py:

from game.entities.plant import Plant from game.entities.ice_bullet import IceBullet class IcePeaShooter(Plant): def __init__(self, grid_x, grid_y): super().__init__(grid_x, grid_y, health=300, cost=175) self.shoot_timer = 0 def update(self, dt, zombies, bullet_group): self.shoot_timer -= dt target = None for z in zombies: if z.rect.right > self.rect.right and z.rect.top < self.rect.bottom and z.rect.bottom > self.rect.top: target = z break if target is not None and self.shoot_timer <= 0: self.shoot_timer = 1.4 bullet = IceBullet(self.rect.right, self.rect.centery - 8) bullet_group.add(bullet)

再自定义子弹类,让它在攻击时给僵尸附加减速标记:

class IceBullet(Bullet): def __init__(self, x, y): super().__init__(x, y, speed=280, damage=40) def update(self, dt, zombies): self.rect.x += self.speed * dt for z in zombies: if self.rect.colliderect(z.rect): z.speed = max(z.speed * 0.5, 8) # 减速到一半,最低不低于 8 z.health -= self.damage self.kill() break

改完代码后,还要做三件事:把这个类导入到卡片注册表、加一张卡片图片、在关卡 JSON 的available_plants列表里加上ice_pea_shooter。否则游戏里根本选不出来。验证方法很简单,把START_SUN改成 9999,开局直接种两排寒冰射手,看僵尸过不过得来。

我从这套源码上得到的一个习惯是:每次改完新内容,不急着进完整关卡,先在调试模式下跑三个检查——第一是右键点击目标植物能正常种植且阳光扣除正确;第二是僵尸被新子弹击中后速度确实变慢;第三是暂停一分钟后内存占用没有明显上涨。三关都过了才进正式关卡测试。这个习惯帮我避掉了不知道多少静默 bug,比如新植物种下去游戏崩溃、子弹伤害没生效之类的问题。希望这套源码和我的经验能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进