简介:这是一份面向 Python 初学者与编程兴趣者的植物大战僵尸复刻项目源码资源,适合用来练习 pygame 游戏开发、事件循环与精灵动画等基础技能,也可作为课程设计或课余练手的小型实战项目。压缩包共 10 个文件,以 png 图片素材和一个 py 主程序为主,另附一份 md 说明文档,整体约 42KB,体积轻量、结构简单。素材涵盖草地背景、僵尸、豌豆射手、向日葵、豌豆子弹等多张地图与角色贴图,主程序则把这些图片资源组织成可运行的塔防玩法,下载后无需额外找图即可直接运行与二次修改。已有 1791 人学习下载,说明其作为入门级游戏案例具有一定参考价值。读者可以从中了解游戏主循环、角色与子弹的坐标更新、碰撞判定以及图片资源的加载方式,并参照说明文档快速上手,在现成框架上替换素材、调整数值或扩展新植物与新僵尸,体验从读懂代码到动手改造的完整过程。
1. 从双击就闪退说起:这套植物大战僵尸 Python 源码到底能跑出什么
上周 A 同学把一份植物大战僵尸风格的 Python 源码发过来,说双击main.py,窗口一闪就没了,连报错都没看清。这类项目很典型:它不是pip install完就能玩的库,而是一份完整的 pygame 手写游戏工程,图片、音效、场景、实体、关卡配置全在本地目录里,跑不起来十有八九是资源路径写死或者没装 pygame。这套源码实现的是经典塔防循环:九列五行草坪、阳光经济、植物卡牌与冷却、僵尸按波次从右侧推进、豌豆射手自动索敌开火、坚果墙挡路、樱桃炸弹清场。它适合两类人——想拿一个能真正玩起来的 2D 工程练 Python 面向对象、场景状态管理和帧同步的,以及想在自己项目里抄一套精灵动画、碰撞检测、关卡配置系统的。下面按“能干什么、怎么跑、怎么改”的顺序,从目录一路拆到可以动手加一株新植物。
2. 技术选型与目录结构:pygame 撑起一个塔防要准备哪些东西
2.1 为什么是 pygame 而不是别的 2D 框架
塔防这类游戏对引擎的要求其实很“偏科”:实体数量中等(一屏几十个),逻辑复杂度低,但对帧同步、鼠标精确点击、精灵帧动画的要求高。pygame 在这三点上刚好够用,而且没有场景树、没有内置物理、没有资源管线,所有东西都得自己搭——对想练架构的人来说这反而是优点,因为你能亲手写出一套Scene、Entity、ResourceManager。换成带编辑器的引擎,很多结构就被引擎代劳了,读源码学不到东西。
代价也很明确:pygame 只封装了 SDL2 的绘图、事件、音频,碰撞只有Rect.colliderect这一层,动画要自己按帧切图,性能上也别指望几万个精灵。所以这套源码里凡是涉及“谁更新、谁绘制、谁被销毁”的问题,全部由业务代码管,这正是后面几章要拆的重点。
2.2 目录分层:assets / core / entities / scenes / levels 各管什么
拿到源码先别急着读main.py,先tree看一眼结构,心里有个分层图,后面找代码会快很多。这类工程常见的组织方式是按“资源—核心—实体—场景—关卡”五层切开:
pvz_python/ ├── main.py # 唯一入口,建窗口、跑主循环 ├── settings.py # 全局常量:分辨率、帧率、格子尺寸、路径 ├── requirements.txt ├── assets/ │ ├── images/ # plants/ zombies/ cards/ ui/ 四类贴图 │ ├── sounds/ # 射击、啃食、种植、背景乐 │ └── fonts/ # 中文字体 ttf,别指望系统字体 ├── core/ │ ├── resource.py # 贴图/音效缓存,避免每帧 load │ └── grid.py # 格子坐标与像素坐标互转 ├── entities/ │ ├── entity.py # Entity 基类 │ ├── plant.py # Plant 基类 + 各植物子类 │ ├── zombie.py # Zombie 基类 + 各僵尸子类 │ ├── bullet.py # 子弹 │ └── sun.py # 阳光掉落物 ├── scenes/ │ ├── menu_scene.py # 主菜单 │ ├── level_scene.py # 战斗主场景,最厚的一个文件 │ └── result_scene.py # 胜负结算 └── levels/ └── level_1.py # 波次表、初始阳光、卡牌列表| 目录 | 职责 | 改动影响面 |
|---|---|---|
settings.py | 分辨率、FPS、CELL_W/CELL_H、资源根路径 | 改分辨率必须同步改背景图和坐标原点 |
core/ | 缓存与坐标换算,无游戏逻辑 | 几乎所有实体都依赖,改错直接全屏错位 |
entities/ | 单个对象的行为与状态 | 加植物、加僵尸都在这层,最安全 |
scenes/ | 场景切换与一局游戏的编排 | 改玩法节奏、加关卡选择在这层 |
levels/ | 纯数据:第几秒刷什么僵尸 | 改难度不用碰逻辑代码 |
这种分法的好处是“加内容不加逻辑”:新增一个植物基本只动entities/plant.py和卡牌配置,level_scene.py里的鼠标放置流程一行都不用改。反过来说,如果你发现自己在level_scene.py里写了一大堆if plant_type == "peashooter",那就是分层漏了。
2.3 依赖、入口和第一次运行的检查顺序
这套工程的依赖非常干净,一个 pygame 就够了,Python 建议 3.9 以上,因为代码里用到了dict合并和类型注解。安装和启动就三步:
# 1. 建虚拟环境,避免和系统里的老 pygame 打架 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 2. 装依赖 pip install pygame==2.5.2 # 3. 从项目根目录启动,不要 cd 进 scenes/ 再跑 python main.py这里有个新手最容易翻车的点:必须在项目根目录执行。源码里的资源路径多半是相对路径(assets/images/...),你cd到子目录里跑,pygame.image.load会直接抛FileNotFoundError,而窗口已经建好了,于是表现成“黑屏一闪”。如果启动后是黑屏但没退出,按Ctrl+C看终端有没有异常栈,pygame 的错误基本都打在 stdout 里,不会弹窗。
settings.py里通常会集中放这几类常量,第一次跑之前扫一眼,心里有数:
# settings.py import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) ASSETS = os.path.join(BASE_DIR, "assets") # 用绝对路径兜底相对路径问题 SCREEN_W, SCREEN_H = 1280, 720 FPS = 60 TITLE = "Tower Defense Demo" GRID_COLS, GRID_ROWS = 9, 5 CELL_W, CELL_H = 80, 96 GRID_X, GRID_Y = 256, 96 # 草坪左上角在屏幕上的位置BASE_DIR这一行值得单独说:很多下载来的源码直接写"./assets/...",换个工作目录就炸,改成基于__file__拼绝对路径,能省掉后面一大堆“为什么我这跑不起来”的排查。同理,GRID_X/GRID_Y是草坪贴图在背景上的起始像素,和SCREEN_W/SCREEN_H是一套的,改分辨率必须一起改,否则鼠标点下去种到隔壁格。
3. 主循环与网格坐标:一帧画面是怎么被算出来的
3.1 主循环与 dt:让僵尸在不同刷新率下走一样快
整个游戏的心脏就在main.py那二十来行里。它做四件事:算时间步、收事件、更新当前场景、刷屏。看着简单,但dt怎么算决定了僵尸走得稳不稳。
# main.py import pygame from settings import SCREEN_W, SCREEN_H, FPS, TITLE from scenes.menu_scene import MenuScene def main(): pygame.init() pygame.mixer.init() # 音频子系统单独初始化 screen = pygame.display.set_mode((SCREEN_W, SCREEN_H)) pygame.display.set_caption(TITLE) clock = pygame.time.Clock() scene = MenuScene(screen) running = True while running: dt = clock.tick(FPS) / 1000.0 # 毫秒转秒 dt = min(dt, 0.05) # 卡顿时钳制,防止一帧穿模 events = pygame.event.get() for event in events: if event.type == pygame.QUIT: running = False nxt = scene.handle_events(events) # 返回非 None 表示切场景 if nxt is not None: scene = nxt continue scene.update(dt) scene.draw(screen) pygame.display.flip() pygame.quit() if __name__ == "__main__": main()逻辑说明:clock.tick(FPS)不只是限帧,它返回的是“距上一次调用过去了多少毫秒”,把它除以 1000 就得到这一帧的秒数,所有移动、冷却、计时全部乘这个dt,游戏速度才和刷新率解耦。min(dt, 0.05)是保险丝——窗口被拖动、系统卡了一下,dt可能飙到 0.5 秒,僵尸一帧就横穿整行,钳到 50ms 就只是“顿一下”,不会逻辑崩掉。
参数说明:FPS=60是目标帧率,不是保证值;dt的物理单位是秒,所以speed = 22.0表示每秒 22 像素。如果你后面看到某个类里写着self.x += self.speed而没乘dt,那基本就是遗留 bug,在 144Hz 屏上它会跑得飞快。
3.2 场景基类与切换:菜单、选卡、战斗、结算怎么串
场景用的是一个很轻的“返回下一个场景”协议,而不是维护一个场景栈。对塔防这种线性流程(菜单 → 战斗 → 结算 → 菜单)来说,栈是多余的。
# scenes/base_scene.py import pygame class Scene: def __init__(self, screen): self.screen = screen def handle_events(self, events): """返回新场景实例表示切换;返回 None 表示留在当前场景。""" return None def update(self, dt): pass def draw(self, surface): pass逻辑说明:handle_events的返回值就是切换信号。战斗场景在检测到“僵尸走到最左侧”时return ResultScene(self.screen, win=False),主循环拿到非 None 就直接替换掉scene,旧的场景对象失去引用后被 GC 回收,资源由ResourceManager的缓存统一持有,所以不需要手动释放贴图。
参数说明:events是这一帧的pygame.event.get()结果,一次性传给所有场景,别在场景内部再调一次pygame.event.get(),否则事件会被“吃掉”,表现为按钮点一次没反应、点两次触发两次。
3.3 网格映射:鼠标点下去到底落在第几行第几列
塔防最核心的坐标换算就两个函数,一个把格子转成像素中心点,一个把鼠标像素转成格子索引。所有“种植物”和“僵尸在哪一行”的判断都建立在它们之上。
# core/grid.py from settings import GRID_COLS, GRID_ROWS, CELL_W, CELL_H, GRID_X, GRID_Y def cell_to_center(col, row): """格子索引 -> 该格中心的像素坐标,用于摆放植物。""" x = GRID_X + col * CELL_W + CELL_W // 2 y = GRID_Y + row * CELL_H + CELL_H // 2 return x, y def pixel_to_cell(pos): """鼠标像素 -> 格子索引;点在草坪外返回 None。""" x, y = pos if not (GRID_X <= x < GRID_X + GRID_COLS * CELL_W): return None if not (GRID_Y <= y < GRID_Y + GRID_ROWS * CELL_H): return None col = (x - GRID_X) // CELL_W row = (y - GRID_Y) // CELL_H return col, row逻辑说明:先做边界判断再整除,顺序不能反。漏掉边界判断的话,鼠标在屏幕左上角点一下会得到负数索引,-1 // 80在 Python 里是-1,于是植物被种到列表的最后一个格子上,或者直接抛IndexError,这是我在类似工程里见过最多的玄学 bug 之一。
参数说明:CELL_W=80、CELL_H=96必须和背景图上的草坪格完全对齐,差几个像素在放置时看不出来,但僵尸的“行对齐”会出现肉眼可见的漂移。校准的办法是临时在draw里把每个格子中心画个红点,跑一次目测一遍,比盲猜省事。
放置流程本身很短,但耦合了阳光、冷却、占位三件事:
def try_plant(self, pos, card): cell = pixel_to_cell(pos) if cell is None: return False col, row = cell if (col, row) in self.occupied: # 一格只能种一株 return False if self.sun < card.cost: # 阳光不够 return False if card.cd_timer > 0: # 卡牌还在冷却 return False plant = card.factory(col, row) # 用工厂函数创建对应植物 self.plants.append(plant) self.occupied.add((col, row)) self.sun -= card.cost card.cd_timer = card.cooldown return True这段是level_scene.py里放置逻辑的骨架。card.factory存的是类本身(比如Peashooter),用工厂而不是if/elif串类型判断,加新植物时只需要在关卡配置里多写一行卡牌定义,放置代码不用动。occupied用集合存格子索引,比遍历plants列表查(p.col, p.row)快,也没那么容易被忘记清理。
4. 实体系统拆解:植物、僵尸、子弹三套类怎么串起来
4.1 Entity 基类与 update/draw 契约
三类实体共享的东西其实只有四样:位置、血量、存活标志、一个矩形。把这四样抽成基类,主场景就能用一个for e in entities: e.update(dt, world)统一驱动。
# entities/entity.py import pygame class Entity: def __init__(self, x, y, hp): self.x = float(x) self.y = float(y) self.hp = hp self.max_hp = hp self.alive = True self.rect = pygame.Rect(0, 0, 0, 0) # 由子类同步 def sync_rect(self, w, h): """以 (x, y) 为中心同步碰撞矩形,移动后必须调用。""" self.rect.width = w self.rect.height = h self.rect.center = (int(self.x), int(self.y)) def update(self, dt, world): raise NotImplementedError def draw(self, surface): raise NotImplementedError def kill(self): self.alive = False逻辑说明:x/y存浮点,rect存整数,是因为 pygame 的Rect只吃整数,而移动必须用浮点累加,否则低速移动会被取整吃成 0,僵尸看起来完全不动。每次update里改完x/y就调一次sync_rect,把误差收在绘制和碰撞这一层。
参数说明:world是战斗场景本身,实体通过它访问“这一行有没有僵尸”“这株植物前面有没有敌人”这类全局查询,而不是让实体互相持有引用。这个方向很关键——如果植物持有僵尸列表,僵尸又持有植物列表,删除时就会出现循环引用和野指针式的访问,最后表现成“明明打死了还在啃”。
4.2 植物继承树:豌豆射手、向日葵、坚果墙的差异点
植物这一层用的是“基类管计时和占位,子类只改数值和行为”的写法,是最适合拿来练手继承结构的地方。
# entities/plant.py import pygame from entities.entity import Entity from core.grid import cell_to_center class Plant(Entity): cost = 0 cooldown = 7.5 # 卡牌冷却,秒 max_hp = 300 damage = 0 def __init__(self, col, row): cx, cy = cell_to_center(col, row) super().__init__(cx, cy, self.max_hp) self.col, self.row = col, row self.fire_timer = 0.0 self.sync_rect(70, 80) # 略小于格子,避免相邻误判 def update(self, dt, world): self.fire_timer += dt self.try_act(world) def try_act(self, world): """默认什么都不做,由子类覆盖。""" pass class Peashooter(Plant): cost = 100 cooldown = 1.4 damage = 20 max_hp = 300 def try_act(self, world): if self.fire_timer < self.cooldown: return if not world.has_zombie_ahead(self.row, self.x): return # 前面没敌人就不空放,省得满屏子弹 self.fire_timer = 0.0 world.spawn_bullet(self.row, self.x + 24, self.y - 18, self.damage) class Sunflower(Plant): cost = 50 cooldown = 7.5 max_hp = 300 def try_act(self, world): if self.fire_timer >= self.cooldown: self.fire_timer = 0.0 world.spawn_sun(self.x, self.y, value=25) class WallNut(Plant): cost = 50 cooldown = 20.0 max_hp = 1200 # 血厚,不产出也不攻击 def try_act(self, world): pass逻辑说明:Plant.update只做一件事——累加计时器然后调try_act。射击、产阳光、什么都不做,全部由子类覆盖try_act实现。这样加一株新植物时,继承Plant、写cost/max_hp/cooldown、覆盖try_act三步就够了,level_scene完全不知道新植物的存在。
参数说明:fire_timer是累加器而不是倒计时,好处是不会出现负数、也不需要在每次开火后重置成cooldown而丢失余量。sync_rect(70, 80)比格子小一圈,是因为僵尸的碰撞矩形贴着植物走,两个等宽矩形会让僵尸在格子边界反复切换“走/啃”状态,表现出来就是原地抽搐。
4.3 僵尸状态机与啃食逻辑
僵尸只有两个状态:walk和eat,但切换条件写错就会出现“隔空啃草”或者“穿过坚果”。核心判断是“脚前方一小段距离内有没有植物”。
# entities/zombie.py from entities.entity import Entity from core.grid import cell_to_center SCREEN_W = 1280 class Zombie(Entity): max_hp = 200 speed = 22.0 # 像素/秒 eat_dps = 80.0 # 每秒啃掉的血量 def __init__(self, row, x=None): _, cy = cell_to_center(0, row) super().__init__(x if x is not None else SCREEN_W + 40, cy, self.max_hp) self.row = row self.state = "walk" self.target = None self.slow_timer = 0.0 self.slow_factor = 1.0 self.sync_rect(56, 88) def update(self, dt, world): if self.slow_timer > 0: self.slow_timer -= dt if self.slow_timer <= 0: self.slow_factor = 1.0 target = world.plant_in_front(self.row, self.rect.left) if target is not None: self.state = "eat" self.target = target target.hp -= self.eat_dps * dt if target.hp <= 0: target.kill() world.remove_occupancy(target.col, target.row) self.state = "walk" self.target = None else: self.state = "walk" self.x -= self.speed * self.slow_factor * dt self.sync_rect(56, 88) if self.x < 200: # 走到最左侧判负 world.lose()逻辑说明:plant_in_front由world实现,扫描同一行里rect.right落在僵尸rect.left附近(比如 ±20 像素)的植物。用“前方一小段”而不是“严格重叠”,是为了防止浮点误差导致僵尸刚好卡在两格之间来回横跳。啃死植物后要显式调用remove_occupancy,把格子从occupied集合里删掉,否则那个位置永远种不了新植物——这是血泪经验,第一版忘了写,玩家反馈“坚果被啃掉之后那块地废了”。
参数说明:speed=22配合CELL_W=80,一只普通僵尸横穿一格大约 3.6 秒,这个数值直接决定难度手感,想调难度先调它和eat_dps,别急着改波次表。slow_factor是给减速效果预留的,冰系子弹把slow_factor设成 0.5、slow_timer设成 3 秒,3 秒后自动恢复,不需要额外的状态机。
4.4 子弹与碰撞:为什么不用 Rect 而是行内比对
标准做法是用Rect.colliderect做碰撞,但塔防里的子弹是水平直线飞行、目标只在同一行,用行内坐标比对更简单也更快。
# entities/bullet.py from entities.entity import Entity SCREEN_W = 1280 class Bullet(Entity): speed = 320.0 def __init__(self, row, x, y, damage): super().__init__(x, y, 1) self.row = row self.damage = damage self.sync_rect(20, 20) def update(self, dt, world): self.x += self.speed * dt self.sync_rect(20, 20) if self.x > SCREEN_W: self.kill() return hit = world.first_zombie_hit(self.row, self.x) if hit is not None: self.on_hit(hit) self.kill() def on_hit(self, zombie): """命中后的伤害结算,子类可覆盖做元素效果。""" zombie.hp -= self.damage if zombie.hp <= 0: zombie.kill()逻辑说明:first_zombie_hit(row, x)在同一行里找“左边界 ≤ x ≤ 右边界”的第一个僵尸。之所以取第一个而不是全部,是因为僵尸会被击退或堆叠,一发子弹打穿一排会让数值严重失衡。on_hit单独抽出来就是给元素效果留的口子,普通豌豆什么都不用写,冰豌豆覆盖它加个减速即可,后面第 6 章会用到。
参数说明:speed=320比僵尸快一个数量级,视觉上才像“射出去的”。子弹在飞出屏幕后必须kill(),否则列表只增不减,跑十分钟后帧率会明显下滑——这不是理论风险,是实测过的。
4.5 阳光经济与波次生成:两张表决定一局的节奏
一局的节奏其实由两个数据源控制:阳光的产出频率,和波次表。把它们做成纯数据放在levels/level_1.py,逻辑代码只负责读表。
# levels/level_1.py LEVEL_1 = { "start_sun": 50, "sun_drop_interval": 8.0, # 天上掉阳光的间隔,秒 "cards": ["Sunflower", "Peashooter", "WallNut", "CherryBomb"], "waves": [ {"at": 5.0, "spawn": [("normal", 0), ("normal", 2)]}, {"at": 25.0, "spawn": [("cone", 1), ("normal", 3), ("normal", 4)]}, {"at": 50.0, "spawn": [("bucket", 2), ("cone", 0), ("cone", 4)]}, ], }对应的驱动器就是一个“已过时间”累加器:
# scenes/level_scene.py(节选) def update_waves(self, dt): self.elapsed += dt while self.wave_idx < len(self.cfg["waves"]): wave = self.cfg["waves"][self.wave_idx] if self.elapsed < wave["at"]: break for kind, row in wave["spawn"]: self.zombies.append(ZOMBIE_FACTORY[kind](row)) self.wave_idx += 1逻辑说明:while而不是if,是为了在卡顿跳帧时把该刷的波次一次补齐,避免漏刷。ZOMBIE_FACTORY是个{"normal": NormalZombie, "cone": ConeZombie, ...}的字典,和植物的工厂思路一致,加新僵尸只改配置和这个字典。
参数说明:at是“本局开始后的第几秒”而不是“上一波之后几秒”,这样调波次时改一个数字就行,不用重算。sun_drop_interval=8配合向日葵的 7.5 秒产出,前期大约每 8 秒有 25+ 点阳光进账,第一株豌豆(100)大约在 12 秒左右能种下,这个节奏是塔防手感的关键,快一点就无脑,慢一点就绝望。
5. 避坑与排查:贴图黑块、坐标错位、僵尸瞬移的现场记录
5.1 五条踩坑记录:现象、原因、解决
第一条:图片加载出来是纯黑方块,或者直接报cannot convert without video mode。现象是植物卡片能画出来但一片黑。原因是pygame.image.load之后立刻调了convert_alpha(),而那时display.set_mode还没执行,没有视频模式就没法转换。解决办法是把所有convert调用推迟到窗口建好之后,或者干脆在ResourceManager初始化时统一加载——只要保证ResourceManager是在set_mode之后才被实例化的就行。
第二条:不同电脑上僵尸速度不一样,高刷屏上快得离谱。现象是同一个关卡,笔记本上正常,台式机上僵尸像冲刺。原因就是前面说的:某个update里写了self.x -= self.speed没乘dt。解决办法是全工程搜+= self.speed和-= self.speed,逐个确认后面跟着* dt;同时在main.py里把dt打到日志里跑十秒,正常应该在 0.016 上下浮动。
第三条:中文全是方块。现象是波次提示、胜负文字显示成豆腐块。原因是pygame.font.SysFont("simhei", 24)依赖系统字体,换台机器就找不到。解决办法是把 ttf 文件放进assets/fonts/,用pygame.font.Font(os.path.join(ASSETS, "fonts", "xxx.ttf"), 24)显式指定路径,字体文件跟着项目走。
第四条:植物种下去之后,鼠标已经松开了还在“粘”着一株。现象是拖拽种植物,放下去后再点别的格子又冒出一株。原因是放置逻辑绑在了MOUSEMOTION而不是MOUSEBUTTONUP,或者放完之后没有清空self.holding状态。解决办法是统一种植入口,只在MOUSEBUTTONUP且button == 1时走try_plant,成功失败都清空持有状态。
第五条:玩几分钟之后帧率越来越低,声音也开始卡。现象是前两分钟流畅,后面明显掉帧。原因通常是两处:子弹飞出屏幕没kill(),以及每帧都在pygame.image.load同一个贴图。解法是实体列表每帧过滤一次[e for e in entities if e.alive],贴图统一走ResourceManager.get_image(path)缓存,命中缓存直接返回。
5.2 一套通用的排查顺序
遇到“画面不对”类问题,别急着读逻辑代码,按这个顺序走一遍基本能定位:先看终端有没有异常栈(pygame 不弹窗,全靠 stdout);再临时在draw里把rect画成红框,确认碰撞盒和贴图是否对得上;然后把dt、实体数量、当前场景名打一行日志,跑五秒看数值是否合理;最后才是怀疑逻辑。顺序反了的话,很容易在正确的代码里找半天“为什么不对”,这属于典型的翻车现场。
6. 二次开发:从加一株冰豌豆到自定义关卡配置
加植物是这套结构里最舒服的改造,前面那套Plant+try_act+ 工厂的设计就是为了这个。我做冰豌豆的时候只动了两处:一个Bullet子类,一个Peashooter子类。
# entities/bullet.py 追加 class IceBullet(Bullet): """命中后附加减速,不改伤害。""" def on_hit(self, zombie): zombie.hp -= self.damage zombie.slow_timer = 3.0 zombie.slow_factor = 0.5 # 三秒内半速 if zombie.hp <= 0: zombie.kill() # entities/plant.py 追加 class SnowPea(Peashooter): cost = 175 cooldown = 1.6 damage = 20 def try_act(self, world): if self.fire_timer < self.cooldown: return if not world.has_zombie_ahead(self.row, self.x): return self.fire_timer = 0.0 world.spawn_bullet(self.row, self.x + 24, self.y - 18, self.damage, bullet_cls=IceBullet)spawn_bullet加一个bullet_cls参数、默认Bullet,就完成了从“普通豌豆”到“冰豌豆”的扩展,僵尸那边的减速逻辑已经在 4.3 节写好了,一行都不用动。然后在关卡配置的cards里加上"SnowPea",卡牌工厂字典里补一条映射,游戏里就能选到它了。
关卡改成外部配置也是同一个思路,把level_1.py的字典换成 JSON,读进来就行:
# scenes/level_scene.py 追加 import json import os from settings import BASE_DIR def load_level(name): path = os.path.join(BASE_DIR, "levels", f"{name}.json") with open(path, "r", encoding="utf-8") as f: cfg = json.load(f) # 做一次校验,防止手写配置把行号写飞 for wave in cfg["waves"]: for kind, row in wave["spawn"]: assert 0 <= row < 5, f"行号越界: {row}" return cfg验证改造有没有生效,别靠肉眼盯着屏幕猜。我一般写个十几行的脚本直接跑逻辑层:造一个Zombie、给它slow_factor=0.5,模拟 60 帧、每帧dt=1/60,断言三秒后它移动的像素数约为正常情况的一半;再断言slow_timer归零后slow_factor恢复成 1.0。这种测试不需要开窗口,跑起来一秒钟,比反复进游戏看“好像慢了一点”靠谱得多。
最后一条是我自己踩过的:别在level_scene.py里给新植物写if isinstance(plant, SnowPea)这种分支。加第三株、第四株植物的时候,这些分支会像滚雪球一样长起来,最后这个文件会变成谁都不敢碰的黑匣子。所有差异都应该落在子类的属性和try_act里,场景层只认Plant这个基类。从那以后我每接一个 pygame 工程,第一件事就是把dt打上日志、把rect画成红框各跑十秒,确认时间轴和碰撞盒这两根地基是正的,再动手改玩法;这个习惯帮我省掉的排查时间,比任何调试技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取