
简介这是一份面向 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 pygame2.5.2 # 3. 从项目根目录启动不要 cd 进 scenes/ 再跑 python main.py这里有个新手最容易翻车的点必须在项目根目录执行。源码里的资源路径多半是相对路径assets/images/...你cd到子目录里跑pygame.image.load会直接抛FileNotFoundError而窗口已经建好了于是表现成“黑屏一闪”。如果启动后是黑屏但没退出按CtrlC看终端有没有异常栈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 就只是“顿一下”不会逻辑崩掉。参数说明FPS60是目标帧率不是保证值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, winFalse)主循环拿到非 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_W80、CELL_H96必须和背景图上的草坪格完全对齐差几个像素在放置时看不出来但僵尸的“行对齐”会出现肉眼可见的漂移。校准的办法是临时在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, value25) 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, xNone): _, 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集合里删掉否则那个位置永远种不了新植物——这是血泪经验第一版忘了写玩家反馈“坚果被啃掉之后那块地废了”。参数说明speed22配合CELL_W80一只普通僵尸横穿一格大约 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 章会用到。参数说明speed320比僵尸快一个数量级视觉上才像“射出去的”。子弹在飞出屏幕后必须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_interval8配合向日葵的 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. 二次开发从加一株冰豌豆到自定义关卡配置加植物是这套结构里最舒服的改造前面那套Planttry_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_clsIceBullet)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, encodingutf-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_factor0.5模拟 60 帧、每帧dt1/60断言三秒后它移动的像素数约为正常情况的一半再断言slow_timer归零后slow_factor恢复成 1.0。这种测试不需要开窗口跑起来一秒钟比反复进游戏看“好像慢了一点”靠谱得多。最后一条是我自己踩过的别在level_scene.py里给新植物写if isinstance(plant, SnowPea)这种分支。加第三株、第四株植物的时候这些分支会像滚雪球一样长起来最后这个文件会变成谁都不敢碰的黑匣子。所有差异都应该落在子类的属性和try_act里场景层只认Plant这个基类。从那以后我每接一个 pygame 工程第一件事就是把dt打上日志、把rect画成红框各跑十秒确认时间轴和碰撞盒这两根地基是正的再动手改玩法这个习惯帮我省掉的排查时间比任何调试技巧都多。希望帮到你。本文还有配套的精品资源点击获取