简介一份基于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.jsonmain.py 负责初始化窗口和启动场景状态机settings.py 集中放分辨率、格子尺寸、植物价格、僵尸血量这些常量game/scenes 下面放场景类每个场景是一个独立的状态game/entities 是核心精灵类levels 目录用 JSON 描述每一关的僵尸波次和可种植植物列表。这种分层的好处是改游戏逻辑时不需要碰窗口初始化代码换关卡只改 JSON加新植物只需要在 entities 里加类并在关卡 JSON 里注册。这种组织方式比把所有精灵塞在一个文件里好维护得多也是我觉得这个源码值得拿来讲的原因。2.3 主循环与帧率控制delta time 是避免速度漂移的关键Pygame 游戏的核心是一个 while 循环每帧做三件事处理事件、更新游戏状态、绘制画面。这源码的主循环写法很标准但有一个细节值得注意——它用了clock.tick(60)把帧率锁定在 60 FPS然后计算dtdelta 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, health300, cost50) 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, speed20, health200, damage20): 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, speed300, damage40): 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普通僵尸血量 2005 发豌豆才能打死一只这个数值在 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: Couldnt 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.pyfrom 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, health300, cost175) 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, speed280, damage40) 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比如新植物种下去游戏崩溃、子弹伤害没生效之类的问题。希望这套源码和我的经验能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?