Python植物大战僵尸源代码:从零搭建到修改金币
发布时间:2026/9/20 11:09:51 作者:尧图编辑部 阅读量:1,286

简介Python植物大战僵尸源代码是一份面向编程学习者与游戏开发入门者的经典练手项目基于pygame库用面向对象方式复现了植物、僵尸、场地等核心机制适合希望掌握塔防游戏事件循环、碰撞检测与状态管理基础技能的读者。压缩包内共8个文件其中1个game.py主程序负责初始化、主循环和事件处理7张png图片分别提供草地、地图、向日葵、豌豆射手、子弹及僵尸等素材整包仅42KB体积虽小但结构清晰。目前已有12458人学习浏览不少读者借此入门游戏开发。通过研读代码可以掌握pygame窗口创建、精灵管理、图片加载与绘制流程也能理解植物攻击、僵尸移动和路径规划等策略模块如何协作甚至可进一步扩展新植物、新关卡是一份轻量但完整的小型游戏开发教学样本。1. 用一份 Python 植物大战僵尸源代码入门 2D 游戏的最短路径很多人在搜索引擎里输入“植物大战僵尸 源代码”或者“Python植物大战僵尸源代码”下载下来的压缩包要么缺素材要么把几十个.py文件堆在一起没有入口。你真正缺的不是“能跑的代码”而是一套改得动、拆得开、加得了新功能的游戏骨架。如果你已经装好 Python并且知道pip install是什么意思那这篇文章可以直接照着做。我不打算带你逐行抄一份完整项目而是用常见做法把这类源代码里最关键的三块讲清楚主循环怎么搭、植物和僵尸的交互判定怎么写、金币这类数值从哪里改。你不需要先学会 Pygame 的全部 API只需要理解while循环、类的基础写法以及事件队列的消费顺序。等你把一个最小副本跑起来再回头去看网上下载的那些完整源码会发现每个文件的作用都清清楚楚。2. 先搭代码骨架场景、精灵与主循环怎么分离《植物大战僵尸》的玩法层级并不复杂按格种植物植物按冷却时间发射子弹僵尸沿直线前进子弹碰到僵尸就扣血。难点在于一旦你在一整份.py文件里同时处理这些逻辑后续每加一个新植物都要改五六处代码。常见做法是把目录结构分成三层主循环负责调度场景层管理草坪与种植网格精灵层只处理各自的动画、血量和攻击状态。2.1 目录结构把“素材”和“逻辑”分开一份结构合格的 Python 植物大战僵尸源代码通常长得像是这样pvz/ ├── main.py # 程序入口负责主循环 ├── settings.py # 所有可调参数集中在这里 ├── classes/ │ ├── __init__.py │ ├── plant.py │ ├── zombie.py │ └── projectile.py ├── assets/ # 素材目录 │ ├── images/ # 所有 PNG 图片 │ ├── sounds/ │ └── music/ └── saves/ └── save.json # 金币、关卡进度的存档这样拆的好处是植物和僵尸各自的逻辑可以在plant.py与zombie.py里单独测试不需要为了让一个豌豆射手跑起来而加载整个游戏界面。素材目录独立出来以后换图片也不用改代码只要保持文件名不变即可。我在实际阅读各种开源版本时发现很多人把素材也打进代码仓库但图片路径写的是绝对路径换一台电脑就报错。为了避免这种问题你可以在settings.py里用os.path来拼接路径import os BASE_DIR os.path.dirname(__file__) IMAGE_DIR os.path.join(BASE_DIR, assets, images) FONT_DIR os.path.join(BASE_DIR, assets, fonts)用os.path.join而不是字符串拼接是为了自动适应 Windows 和 Linux 不同的分隔符。后续所有加载代码都引用IMAGE_DIR即使你把整个项目文件夹移动到别的地方路径也不会断。2.2 用最小主循环建立帧率基准主循环的作用不是把游戏做完而是先把“窗口、事件、刷新”这三件事跑通。一个干净的最小循环大致如下import pygame from settings import SCREEN_WIDTH, SCREEN_HEIGHT, FPS pygame.init() screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) clock pygame.time.Clock() running True while running: dt clock.tick(FPS) / 1000.0 # 单位是秒1 秒约等于 60 帧 for event in pygame.event.get(): if event.type pygame.QUIT: running False if event.type pygame.MOUSEBUTTONDOWN: # 先保存点击坐标不在事件循环里直接更新游戏数据 print(pygame.mouse.get_pos()) # 更新场景与精灵 scene.update(dt) # 绘制所有画面 scene.draw(screen) pygame.display.flip() pygame.quit()这里重点关注dt变量。clock.tick(FPS)会控制每秒最多刷新多少次除以 1000 以后得到一个浮点数表示上一帧到这一帧经过了多少秒。生物移动速度、攻击冷却时间都应该用dt来计算而不是直接假设每帧固定过 16.67 毫秒否则在低帧率电脑上植物会整体变慢。事件处理也应该避免展开太重逻辑。在pygame.event.get()里做分发的原则是能记录状态就记录状态把实际的对象操作留到scene.update(dt)阶段。这样做的理由很简单事件循环里如果去创建精灵或修改血量就会出现同一帧里多次点击导致重复种植的问题后面会在第五章专门讲这个坑。2.3 资源加载策略图片、声音与配置文件游戏源代码里最常见的报错有三类图片不存在、音量文件格式不认识、按钮坐标对不上。统一做法是加载素材后缓存到一个字典里而不是每次都从磁盘读取import os import pygame from settings import IMAGE_DIR _image_cache {} def load_image(name, alphaTrue): if name not in _image_cache: path os.path.join(IMAGE_DIR, name) image pygame.image.load(path).convert_alpha() _image_cache[name] image return _image_cache[name]素材加载完成后统一用name访问而不是在代码里写assets/images/plant_pea.png这样的完整路径。这个封装的额外好处是游戏结束后你还可以统计哪些图片被加载过排查加载不必要资源的浪费。与此同时像种植冷却时间、子弹速度、僵尸血量这些数值都放进settings.py。下面这张表是我通常会在源码里保留的参数参数名建议初始值作用PLANT_COOLDOWN5.0普通植物两次种植之间的秒数SUN_POINT_INTERVAL10.0天上掉落阳光的间隔PEA_SPEED300豌豆子弹每秒移动像素数PEA_DAMAGE20每颗豌豆造成的伤害ZOMBIE_HP200普通僵尸初始血量ZOMBIE_SPEED20僵尸每秒前进像素数这样设计的原因很直接当你调试时觉得“豌豆太弱”或者“僵尸太快”只需要改settings.py不需要翻找散落在十个文件里的魔法数字。所谓源代码好不好改首先看数值是否集中管理。3. 植物与僵尸的核心交互碰撞、攻击间隔与种植逻辑游戏可玩性主要集中在这一章。植物与僵尸的交互绕不开三件事如何判定“子弹打到了僵尸”、如何控制攻击频率不失控、以及如何让“种植植物”这个动作不产生歧义。我会用pygame.sprite.Sprite子类来说明这也是主流开源版本里最接近“标准答案”的写法。3.1 用精灵组管理实体在一个项目中与其自己实现一个列表来保存所有植物和僵尸更稳妥的做法是使用pygame.sprite.Group。它会自动处理更新和绘制也方便批量遍历。你可以在classes/plant.py里定义一个基础植物import pygame from settings import PEA_DAMAGE, PLANT_COOLDOWN class Plant(pygame.sprite.Sprite): def __init__(self, pos, groups, namepeashooter): super().__init__(groups) self.name name self.image pygame.image.load(fassets/images/{name}.png) self.rect self.image.get_rect(topleftpos) self.hp 100 self.attack_timer 0.0 self.cooldown PLANT_COOLDOWN def update(self, dt): # 攻击计时器每帧减少归零后才能发射下一发 self.attack_timer - dtgroups参数允许同一个精灵同时被添加到多个精灵组例如“所有植物组”和“当前屏幕碰撞检测组”。这样在生成子弹时就不需要遍历场景里的所有对象只拿出固定的一个组来判交叠即可性能表现在地图格子多的时候差别相当明显。3.2 用攻击间隔与伤害参数控制游戏节奏发射豌豆的正确方式不是“按下就发射”而是先检查攻击计时器是否已经归零def try_shoot(self, projectiles_group): if self.attack_timer 0: Projectile(self.rect.center, projectiles_group) self.attack_timer 1.0 # 攻击间隔单位秒注意我没有在try_shoot里直接读取鼠标或键盘状态。原因是植物面向的是“自身所在行”当本行内没有僵尸时它就不需要发射。一个比较好的派生逻辑是把检测僵尸是否在射程内的任务交给assert_zombie_in_line函数def assert_zombie_in_line(self, zombies_group): for zombie in zombies_group: if (self.rect.y zombie.rect.y and zombie.rect.x self.rect.x and zombie.rect.x self.rect.x 500): return True return False这里的判定条件是横向五百像素范围内有僵尸而且两者y坐标相同。严格来说《植物大战僵尸》的“行”判定要看格子的row_index是否一致而不是像素 y 坐标因为不同植物的图片高度并不一致。若使用精灵rect.y直接比较在植物或僵尸图片底部有透明留白时容易漏判。更严谨的方法是给两个类都加一个row_index属性在创建时由场景层根据种植网格赋值。子弹本身的类较短但要妥善处理销毁逻辑class Projectile(pygame.sprite.Sprite): def __init__(self, pos, groups): super().__init__(groups) self.image pygame.Surface((16, 16)) self.rect self.image.get_rect(centerpos) self.speed 300 self.damage PEA_DAMAGE def update(self, dt): self.rect.x int(self.speed * dt) if self.rect.x 1200: self.kill() # 移出屏幕后销毁避免无效对象越积越多self.kill()将子弹从所有已加入的精灵组中移除同时pygame.sprite.Group自身的长度也会自动减少后续任何遍历都不会再把已销毁的子弹算进来。这是一个值得在源码里反复强调的细节因为新手常犯的错误是只把对象从列表中移除却忘了销毁精灵组里的引用导致内存不断增长。3.3 僵尸的血量、移动与受击反馈僵尸类的关键属性主要是hp、speed和row_index。受击逻辑放到take_damage方法里会更清晰避免在碰撞检测处直接改hp属性class Zombie(pygame.sprite.Sprite): def __init__(self, pos, groups): super().__init__(groups) self.hp 200 self.speed 20 self.alive True def take_damage(self, damage): self.hp - damage if self.hp 0: self.alive False self.kill()然后将碰撞检测放在场景更新阶段而不是精灵自身内部def _handle_collisions(self): for bullet in self.bullets: hit_zombies pygame.sprite.spritecollide(bullet, self.zombies, False) for zombie in hit_zombies: zombie.take_damage(bullet.damage) bullet.kill() breakpygame.sprite.spritecollide默认使用每个精灵的rect进行轴对齐矩形碰撞检测。这颗子弹如果同时打中两个重叠的僵尸也只让第一个受击然后销毁子弹。这里用break结束内层循环避免一颗子弹同一帧把一整排僵尸同时打死那会直接毁掉整个游戏的平衡性。还有一个常见误区是直接在Projectile.update中执行碰撞判断。这会造成子弹和僵尸相互引用后续要新增一种带穿透效果的寒冰射手时就必须同时修改子弹类和碰撞场景。把碰撞留在场景层可以让新植物只替换子弹的属性和外观不用管谁在检测碰撞。4. 把“修改金币”做在源代码里从启动常量到存档解析搜索记录里经常出现“植物大战僵尸修改金币”相关词组。如果你拿到的是 Python 源码版本修改金币的方式和改普通游戏存档完全不同——你不需要修改游戏内存只要在源代码里找到金币初始值或者修改存档文件即可。这也正是学习源码的额外好处能看清游戏数值到底存在哪里。4.1 定位初始化金币的入口点在大多数用 Python 写的复刻版里金币并不会直接写在main.py里而是放在存档加载模块或settings.py中。常见的写法有两类# 写法一硬编码在玩家类初始化里 class Player: def __init__(self): self.coins 1000 # 写法二从存档读取 def load_player(): data load_save() return Player(coinsdata.get(coins, 0))你要找的是coins或sun这个变量名首次被赋值的位置。如果项目使用了save.json大概率会在load_save函数附近看到这段代码import json from settings import SAVE_PATH def load_save(): try: with open(SAVE_PATH, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {coins: 100, level: 1, unlocked_plants: [peashooter]}注意json.load(f)读取后返回的是 Python 的字典。若存档文件损坏或者路径不存在代码会返回一个默认字典即新玩家的初始状态。理解这个流程后改动就有了很多入口。4.2 修改 JSON 存档的具体步骤先用文本编辑器打开saves/save.json内容通常是{ coins: 100, level: 1, unlocked_plants: [ peashooter, sunflower ] }把coins字段从100改成99999保存。随后启动游戏前确认load_save用的路径与当前目录一致。常见错误是启动时工作目录不在项目根目录导致saves/save.json找不到于是程序走了FileNotFoundError分支又回到了默认的 100 金币。我用一个简单的工作目录探测法来判断import os current_path os.path.abspath(__file__) project_root os.path.dirname(current_path) SAVE_PATH os.path.join(project_root, saves, save.json)这样无论你在哪个终端路径下执行python main.py存档路径都不会跑偏。对于从网盘下载源码的人来说这条极为实用——下载解压后放在任何文件夹都能直接玩。4.3 调整金币后要同步检查的数值边界仅仅修改金币数字还不够。如果你在源码里把所有“购买价格”判断成if self.coins cost那么修改存档后还需要注意三件事第一不能让金币低于任何植物的售价否则会出现“买不了植物但金币显很多”的错觉。第二不要把存档里的coins改成负数或极大数超过 Python 整数范围会导致 JSON 序列化失败。第三有些版本会在购买植物时扣减金币却在种植失败时把金币退回这就会出现重复扣钱或刷钱漏洞。我在调试一个开源版本时遇到过这样的判定逻辑def buy_plant(self, plant_name): cost self.PLANT_COST[plant_name] if self.coins cost: self.coins - cost return self.plant_grid.add_plant(plant_name) return False这段代码先扣钱再种植物如果add_plant因为格子被占而失败钱已经被扣掉了。更合理的是先验证种植位置是否有空位再执行扣款。这种边界问题在“修改金币”后会被放大因为一旦金币充足你反而难以发现刷钱漏洞。当你通读源码时遇到if条件里同时有“扣钱”和“创建对象”的情况建议把验证逻辑和变更逻辑拆开并按以下顺序执行def buy_plant(self, plant_name): cost self.PLANT_COST[plant_name] if self.coins cost: return False, 金币不足 if not self.plant_grid.has_empty_cell(plant_name): return False, 没有可用格子 self.coins - cost self.plant_grid.add_plant(plant_name) return True, None这里的核心思想是所有会修改数据的操作都应该先经过一次只读校验。这个习惯在改存档、加新植物时比什么都管用。5. 用事件队列缓存、碰撞掩码与渲染剔除验证你的源码副本到了这一步你的 Python 植物大战僵尸源代码已经能跑金币也改完了接下来要解决的是“游戏在后期卡顿”以及“种植植物偶尔失败”这两个最影响体验的问题。这一章的技巧可以直接套用到几乎所有 Pygame 项目里。5.1 用掩码碰撞代替矩形碰撞解决“看着像打中其实没打中”pygame.sprite.spritecollide默认用rect做矩形碰撞但豌豆子弹是圆的僵尸图像也有大片透明区域视觉上子弹已经穿模实际碰撞盒还差几十像素。升级方案是使用pygame.mask.Maskmask pygame.mask.from_surface(self.image)然后在碰撞检测时把spritecollide换成if pygame.sprite.collide_mask(bullet, zombie): zombie.take_damage(bullet.damage) bullet.kill()collide_mask会基于两个精灵图像的非透明像素逐位比较精度更高但代价是每帧多个对象同时触发时 CPU 占用上升。常见做法是先用矩形碰撞粗筛一次只有矩形相交才进入掩码精确检测hit_zombies pygame.sprite.spritecollide(bullet, self.zombies, False) for zombie in hit_zombies: if pygame.sprite.collide_mask(bullet, zombie): zombie.take_damage(bullet.damage) bullet.kill() break当场上同时存在五十只僵尸和二十颗子弹时矩形粗筛能减少约八成不必要的掩码计算。5.2 用帧时间定位卡顿而不是靠感觉游戏卡顿要先量化。在主循环里记录上一帧耗时到列表import time frame_times [] while running: frame_start time.perf_counter() # 原有更新与绘制逻辑... frame_end time.perf_counter() frame_times.append(frame_end - frame_start) if len(frame_times) 120: frame_times.pop(0)当帧时间持续超过 30 毫秒时说明瓶颈在绘制或碰撞。具体可以注释掉screen.blit只保留精灵更新看帧率是否恢复。若恢复说明图片过大或绘制数量过多若没有恢复再去检查update函数里是否有重复遍历所有精灵组。5.3 种植植物不生效先检查事件队列的消费顺序很多“种不上”的问题并非逻辑错误而是事件消费顺序不对。pygame.event.get()每次调用都会清空事件队列如果你在多个地方分别调用这个函数后一次调用拿不到前面的MOUSEBUTTONDOWN事件。常见做法是在主循环只能出现一次事件获取所有 UI 点击和地图种植都共用这一批事件数据events pygame.event.get() for event in events: if event.type pygame.QUIT: running False if event.type pygame.MOUSEBUTTONDOWN and event.button 1: pending_click_pos event.pos # 所有剩余逻辑统一读取 pending_click_pospending_click_pos只在下一帧开始时被新值覆盖这样做的好处是当你同时开启种植预览、阳光收取和暂停菜单时三套逻辑不会互相争抢事件。如果你的源代码里在场景更新时又调用了pygame.event.get()就应该把事件集中到主循环入口处统一处理。本文还有配套的精品资源点击获取