Python+Pygame实现的植物大战僵尸完整复刻版,含27种植物、15类僵尸与5大关卡模式
简介:一个开箱即用的植物大战僵尸Python游戏工程,基于Pygame 1.9+和Python 3.7+开发,支持完整游戏流程:从阳光收集、植物拖拽种植、铲除重置,到分波僵尸进攻、多阶段Boss战与末波突袭。内置27种可操作植物(含向日葵、豌豆射手、坚果墙、寒冰射手、樱桃炸弹、双发射手、三线射手、大嘴花、小喷菇、土豆雷、地刺、胆小菇、倭瓜、火爆辣椒、阳光菇、寒冰菇、魅惑菇、火炬树桩、睡莲、杨桃、咖啡豆、海蘑菇、高坚果、缠绕水草、毁灭菇、墓碑吞噬者、大喷菇)和15类僵尸(普通、旗帜、路障、铁桶、读报、橄榄球、鸭子救生圈、铁门、撑杆跳、冰车等),覆盖白昼、夜晚(含墓碑生成与墓碑出怪逻辑)、泳池(支持末波泳池突袭)、传送带、坚果保龄球五大场景模式。关卡由JSON配置驱动,支持F键全屏/U键窗口切换、背景音乐与音效播放、进度条显示、小铲子移除植物、植物卡片拖拽选择等功能。项目结构清晰,包含graphics(图像)、music(背景音乐)、sound(音效)、data(配置文件)、state(状态管理)、component(游戏组件)等模块,并附带README说明文档、15张演示图(webp格式)及启动脚本。
1. 这不是玩具,是能跑通整套游戏逻辑的“植物大战僵尸”工程级复刻
我第一次在同事电脑上看到这个 Pygame 版本的《植物大战僵尸》时,下意识点开的是 main.py,没急着运行,而是先翻了三遍 constants.py 和 data/levels/ 下的 JSON 文件。为什么?因为过去五年里,我见过太多标榜“完整复刻”的 Pygame 小项目——它们往往卡在“能种豌豆射手但打不死僵尸”,或者“有向日葵但阳光数值不涨”,再或者“写了五关但第三关就崩溃”。而这个项目,从目录结构到模块命名,再到 demo 图片里那个正在被橄榄球僵尸撞飞的坚果墙,透着一股“真干过大型游戏逻辑”的沉稳感。
它不是教学 Demo,也不是功能验证原型,而是一个可交付、可调试、可扩展的工程级游戏骨架。27 种植物不是简单贴图+基础攻击,每一种都承载着独立的状态机:胆小菇的“受惊缩回→冷却→重新展开”三态切换;寒冰菇的“范围冻结→持续减速→解冻延迟”时间轴;墓碑吞噬者的“啃咬动画→墓碑消失→短暂眩晕→恢复行动”全流程;甚至毁灭菇这种高风险高回报单位,还实现了“自爆前3秒倒计时UI+半径内植物保护盾”的细节逻辑。15 类僵尸也绝非换皮——撑杆跳僵尸会检测前方是否有障碍物,有则起跳、无则步行;冰车僵尸撞上坚果墙后会碎裂成三块冰晶,每块冰晶落地后生成一只小型滑行僵尸;鸭子救生圈僵尸在泳池模式中会自动浮起,离开水面则立刻脱掉救生圈进入步行状态。这些都不是靠 if-else 堆出来的,而是通过 state 模块下的 ZombieState 抽象基类 + 具体子类(如 PoleVaultingZombieState, IceRinkZombieState)实现的状态驱动设计。
更关键的是它的场景模式解耦能力。白昼/夜晚/泳池/传送带/坚果保龄球这五大模式,并非硬编码进主循环,而是由 state/scene_manager.py 统一调度,每个模式对应一个 Scene 子类(DayScene, NightScene, PoolScene, ConveyorBeltScene, BowlingScene),它们共享同一套 GameWorld 数据模型,但各自重写 update_zombie_spawn_logic(), handle_plant_placement_rules(), render_background_elements() 等钩子方法。比如夜晚模式的墓碑系统,不是在主循环里加个 if is_night:,而是 NightScene 在初始化时主动加载 data/tombstones.json,并在每波开始前调用 spawn_tombstones_for_wave(wave_id),再由 TombstoneManager 负责墓碑的生成、碰撞检测、被吞噬判定与移除回调。这种设计让新增一个“雾天模式”或“屋顶模式”变得极其轻量——你只需要继承 Scene,实现几个抽象方法,再注册进场景管理器即可。
它面向的不是“想学 Pygame 的新手”,而是“需要快速验证游戏机制、做原型迭代、或作为课程设计基线代码的开发者”。如果你正为毕业设计发愁,或是想给团队做一个可演示的塔防框架,又或者只是单纯想搞懂“一个商业级小游戏的底层数据流到底长什么样”,那这个项目就是你该扣住不放的参考系。它把“植物大战僵尸”这个 IP 拆解成了可理解、可调试、可替换的零件:阳光是资源系统,植物是技能组件,僵尸是行为实体,关卡是配置驱动的状态序列。接下来,我们就一层层剥开它的实现肌理。
2. 整体架构设计:为什么选择“状态驱动+配置驱动+模块分层”三位一体
2.1 核心设计哲学:拒绝“上帝类”,拥抱职责分离
很多初学者写 Pygame 游戏,习惯把所有逻辑塞进一个 Game 类里:update() 里处理输入、更新植物、更新僵尸、检测碰撞、播放音效、渲染画面……最后这个类动辄上千行,改一行怕崩一片。而这个项目从 main.py 第一行 from state.game_state import GameState 就定下了基调——一切皆状态,一切皆可切换。
整个游戏生命周期被抽象为 GameState 的继承树:
- MainMenuState:主菜单,处理按键选择、进入关卡
- PlayingState:核心游戏态,但本身不包含具体逻辑,只负责调度
- PausedState:暂停界面,接管输入,冻结下层状态
- GameOverState / VictoryState:结局态,播放动画、统计分数、提供重试入口
而 PlayingState 又进一步委托给 Scene 实例(前面提到的五大模式),Scene 再将具体行为分发给 PlantManager, ZombieManager, SunManager, ProjectileManager 等专用管理器。这种设计带来三个直接好处:
第一,调试成本断崖式下降。你想查“为什么樱桃炸弹没爆炸”?直接断点进 CherryBombPlant.update(),看它的 self.exploding 标志何时置位、self.explosion_radius 是否被正确计算、ProjectileManager 是否收到了爆炸请求。不需要在万行代码里 grep “cherry”。
第二,热替换成为可能。假设你想测试“把豌豆射手射速提高50%”,只需修改 data/plants/peashooter.json 里的 "fire_rate": 0.8(原为1.2),重启游戏即可生效,无需改 Python 代码。同理,调整某关僵尸波次密度,改 data/levels/level_3.json 里 "wave_5": {"zombies": [{"type": "cone", "count": 8}]} 即可。
第三,多人协作边界清晰。美术同学专注 graphics/ 目录下的 PNG 命名规范(如 plant_sunflower_idle_0.png, zombie_bucket_walk_1.png);音频同学按 sound/ 目录结构提交 WAV(sun_gather.wav, pea_shoot.wav, zombie_crunch.wav);策划同学只管 JSON 配置;程序员则聚焦于 component/ 下的 PlantBase, ZombieBase, ProjectileBase 抽象类实现。没有“谁动了谁的代码导致崩溃”的扯皮。
提示:这种架构的代价是初期学习曲线略陡。你需要理解
state/目录下StateManager如何通过push_state()/pop_state()管理栈,以及Scene如何通过set_active_scene()切换上下文。但一旦掌握,后续所有扩展(比如加一个“编辑器模式”用于关卡设计)都水到渠成。
2.2 配置驱动:JSON 不是摆设,是游戏规则的“源代码”
打开 data/plants/ 目录,你会看到 27 个 JSON 文件,以植物英文名为名(sunflower.json, peashooter.json, wallnut.json)。这不是简单的属性列表,而是完整定义了一个植物的“数字生命体征”。以 cherry_bomb.json 为例:
{
"name": "Cherry Bomb",
"cost": 150,
"health": 1,
"cooldown": 60,
"explosion_radius": 1.5,
"explosion_damage": 1800,
"explosion_animation_frames": 12,
"explosion_sound": "cherry_bomb_explode",
"planting_sound": "cherry_bomb_plant",
"description": "Instantly destroys all zombies in a large area.",
"grid_size": [1, 1],
"can_be_planted_on_water": false,
"requires_sunny": true,
"requires_sleepy": false,
"requires_coffee": false,
"requires_lily_pad": false,
"requires_tombstone": false
}
注意 "requires_sunny": true 和 "requires_sleepy": false 这两行。它们直接关联到 tool.py 中的 can_plant_here() 函数逻辑:当玩家拖拽樱桃炸弹到格子上时,系统会检查该格子下方是否有一株处于 SUNNY 状态的向日葵(由 PlantState.SUNNY 标志标识),如果没有,则禁止种植。而 "requires_sleepy": false 则意味着它不能种在胆小菇旁边——因为胆小菇的 SLEEPY 状态会抑制邻近植物激活。这种“需求-状态”映射,让植物间的克制关系不再是硬编码的 if 判断,而是配置文件与状态机的自然耦合。
僵尸配置同样精密。data/zombies/cone.json(路障僵尸)包含:
{
"name": "Cone Zombie",
"health": 1200,
"speed": 0.4,
"armor_health": 600,
"armor_type": "cone",
"walk_animation_speed": 0.15,
"eat_animation_speed": 0.08,
"damage_per_second": 100,
"spawn_chance": 0.15,
"is_flagged": false,
"has_armor": true,
"can_jump": false,
"can_swim": false,
"can_climb": false,
"requires_tombstone": false,
"special_behavior": "breaks_cone_on_low_health"
}
"special_behavior": "breaks_cone_on_low_health" 这个字段,会触发 ZombieBase 子类中的 on_health_drop() 回调,进而调用 self.set_armor_health(0) 并播放头盔碎裂音效。整个过程,策划改配置,程序员写一次回调逻辑,全量僵尸复用。
注意:JSON 配置的威力,在关卡设计中体现得淋漓尽致。
data/levels/level_1.json不是写死的“第1波出5个普通僵尸”,而是:json "waves": [ { "id": 1, "delay": 5.0, "zombies": [ {"type": "normal", "count": 3, "spawn_offset": 0.0}, {"type": "flag", "count": 1, "spawn_offset": 2.5} ] }, { "id": 2, "delay": 15.0, "zombies": [ {"type": "cone", "count": 2, "spawn_offset": 0.0}, {"type": "normal", "count": 4, "spawn_offset": 1.0} ] } ]"spawn_offset"是关键——它表示该批僵尸比波次基准时间晚多少秒出现,制造“错峰进攻”的压迫感。没有这个字段,所有僵尸会堆在同一个时间点,游戏节奏就垮了。
2.3 模块分层:graphics/music/sound/data/state/component,各司其职不越界
项目目录结构本身就是一份最佳实践文档:
graphics/: 纯资源。所有 PNG 按plants/,zombies/,projectiles/,ui/,backgrounds/分类。ui/下甚至细分为cards/(植物卡片)、buttons/(开始/暂停按钮)、bars/(血条/阳光条)。这里不放任何代码,只有图片。music/: 背景音乐 WAV 文件,按场景命名:day_theme.wav,night_theme.wav,pool_theme.wav。music/目录下没有播放逻辑,那是state/audio_manager.py的事。sound/: 音效 WAV,按事件命名:sun_gather.wav,pea_shoot.wav,zombie_crunch.wav,tombstone_crack.wav。音效播放由SoundPlayer类统一管理,支持音量控制、播放队列、重复抑制(避免同一音效连续触发)。data/: 配置中心。plants/,zombies/,levels/,scenes/四个子目录,全部 JSON。scenes/下的day.json,night.json定义了不同模式的背景图路径、初始阳光、特殊规则(如夜晚模式的墓碑生成概率)。state/: 游戏状态中枢。game_state.py,scene_manager.py,audio_manager.py,input_handler.py构成状态流转骨架。state/下不处理具体游戏对象,只做“调度员”。component/: 游戏对象本体。plant.py,zombie.py,projectile.py,sun.py,tombstone.py是真正的“活物”。它们持有自己的health,position,state,animation_frame,并实现update(),render(),collide_with()等接口。component/是唯一允许直接操作pygame.Surface和pygame.Rect的地方。
这种物理隔离,让代码审查变得异常高效。当你收到 PR 说“修复了寒冰射手减速效果”,你只需打开 component/plant.py 查看 IceShroomPlant 类的 on_hit_zombie() 方法,以及 component/zombie.py 中 ZombieBase 的 apply_slow_effect() 实现,无需关心 UI 怎么画、音乐怎么播、JSON 怎么配。
3. 核心机制深度解析:从阳光经济到末波突袭的完整闭环
3.1 阳光经济系统:不只是“+25”,而是一套动态供需模型
阳光(Sun)是这个游戏的“货币”,但它的设计远超简单累加。整个系统由 component/sun.py, state/sun_manager.py, tool.py 中的 generate_sun() 函数共同构成,形成一个生产-采集-消耗-通胀调控的微型经济。
生产端(Supply):
- 向日葵:每 10 秒生成 1 颗阳光(data/plants/sunflower.json 中 "sun_production_interval": 10.0)。但注意,SunflowerPlant.update() 中有一个 self.sun_cooldown 计时器,且生成逻辑是 if self.sun_cooldown <= 0: create_sun_at(self.position)。这意味着如果向日葵被冰冻(apply_slow_effect() 会将其 sun_cooldown 乘以 2),生产速度就腰斩。
- 阳光菇:夜间专属,每 5 秒产 1 颗,但 data/plants/sunshroom.json 中 "requires_sunny": false 且 "requires_sleepy": true,意味着它必须种在胆小菇旁边才能激活——这是典型的“资源协同生产”设计。
- 地面阳光:随机掉落,但 SunManager 会根据当前场上向日葵总数动态调整掉落概率。向日葵越多,随机阳光越少,避免经济爆炸。
采集端(Demand):
- 玩家点击:SunManager.handle_click() 检测鼠标是否在阳光 Rect 内,是则 self.collected += 25 并销毁该 Sun 对象。
- 自动采集:Sun 对象自身有 self.auto_collect_timer,若 5 秒内无人点击,自动飘向屏幕顶部并消失(模拟阳光蒸发),self.auto_collect_timer -= dt。这迫使玩家必须保持操作节奏,不能挂机。
消耗端(Cost):
- 植物种植:PlantManager.try_plant() 会检查 SunManager.collected >= plant_cost,满足才创建植物实例。plant_cost 来自 JSON 配置,如 peashooter.json 的 "cost": 100。
- 铲除回收:PlantManager.remove_plant() 会返还 int(plant_cost * 0.5) 阳光(50% 折旧),鼓励玩家谨慎决策而非随意铲除。
通胀调控(Balance):
最精妙的是 SunManager.adjust_supply_rate()。它监听 WaveManager 的波次事件:每当新波开始,SunManager 会临时提升随机阳光掉落率 30%,持续 15 秒。这是为了应对“新波压力期”的资源焦虑——玩家刚忙完上一波,急需阳光补防,系统就主动“发补贴”。而当玩家连续 3 波未使用阳光(collected > 200 且 last_use_time > 60.0),系统会触发 trigger_sun_rain(),在屏幕随机位置降下 5 颗阳光,防止资源囤积导致策略僵化。
实操心得:我在调试时发现,如果把向日葵的
"sun_production_interval"改成 5.0,游戏前两关会极度轻松,但从第三关开始,僵尸波次密度上升,阳光产出反而跟不上消耗,因为SunManager的通胀调控是基于“波次压力”而非“绝对时间”。这说明经济系统是动态响应的,不是静态公式。要平衡,必须同时调整data/levels/中的波次间隔和data/plants/中的产出参数。
3.2 植物-僵尸交互:碰撞检测、伤害计算与状态传递的三层防御
植物与僵尸的对抗,是塔防游戏的核心。这个项目没有用 Pygame 的 sprite.groupcollide() 简单粗暴处理,而是构建了空间分区 + 精确碰撞 + 状态广播的三层防御体系。
第一层:空间分区(Broad Phase)GameWorld 维护一个 grid 二维数组(5x9),每个格子存一个 list,记录当前位于该格子的所有 Plant 和 Zombie 引用。当僵尸移动时,ZombieBase.update() 会计算其新位置 (x, y),然后 grid_x = int(x // GRID_CELL_WIDTH), grid_y = int(y // GRID_CELL_HEIGHT),仅检查 grid[grid_x][grid_y] 中的植物是否与其发生碰撞。这避免了 O(N²) 的全量检测,将性能瓶颈从 100+ 对象降至 5-10 对。
第二层:精确碰撞(Narrow Phase)PlantBase.collide_with(zombie) 和 ZombieBase.collide_with(plant) 是虚函数。以豌豆射手为例:
def collide_with(self, zombie):
if not self.is_firing: # 只有在射击状态下才触发碰撞
return False
# 计算豌豆子弹与僵尸的矩形交集
pea_rect = pygame.Rect(self.pea_position[0], self.pea_position[1], 8, 8)
zombie_rect = zombie.get_collision_rect()
if pea_rect.colliderect(zombie_rect):
zombie.take_damage(self.damage_per_shot)
self.pea_position = None # 子弹销毁
return True
return False
注意 self.is_firing 的判断——这确保了豌豆不会在装填动画期间“穿模”击中僵尸,保证了视觉与逻辑的一致性。
第三层:状态传递(State Broadcast)
这才是高级之处。当寒冰射手击中僵尸时,IceShroomPlant.on_hit_zombie(zombie) 不仅调用 zombie.take_damage(),还会调用 zombie.apply_slow_effect(0.5, 3.0),后者在 ZombieBase 中设置 self.slow_factor = 0.5 和 self.slow_duration = 3.0。而 ZombieBase.update() 中有:
if self.slow_duration > 0:
self.speed = self.base_speed * self.slow_factor
self.slow_duration -= dt
if self.slow_duration <= 0:
self.speed = self.base_speed
self.slow_factor = 1.0
更进一步,torchwood.json(火炬树桩)的 "ignites_peas": true 配置,会让所有经过其所在格子的豌豆子弹触发 pea.ignite(),将 pea.damage *= 2 并改变颜色。这种“植物影响子弹,子弹影响僵尸,僵尸状态又反馈给植物”的链式反应,构成了丰富的策略深度。
注意:墓碑系统是此机制的巅峰应用。
Tombstone对象不是静态障碍物,而是ZombieBase的子类!它有自己的health,position,state。当墓碑吞噬者靠近时,GraveBusterPlant.update()会检测self.rect.colliderect(tombstone.rect),然后调用tombstone.take_damage(1000)。墓碑被摧毁时,会广播event_manager.post(Event.TOMBSTONE_DESTROYED, tombstone.position),NightScene监听到后,立即在该位置生成一只TombstoneZombie(墓碑僵尸),完成“墓碑出怪”的闭环。
3.3 五大场景模式:从白昼到坚果保龄球的差异化实现逻辑
五大模式不是换张背景图那么简单,它们各自重构了游戏的核心规则:
白昼模式(DayScene):
- 规则:标准开局,阳光随时间自然增长(SunManager.start_day_cycle()),无墓碑,无雾,无水。
- 关键实现:DayScene.spawn_zombie() 直接调用 ZombieManager.spawn(),无额外修饰。
夜晚模式(NightScene):
- 规则:初始阳光低(50),墓碑随机生成,墓碑可被吞噬出怪,胆小菇需咖啡豆激活。
- 关键实现:
- NightScene.__init__() 加载 data/scenes/night.json,启动 TombstoneManager。
- NightScene.update() 中每 30 秒调用 tombstone_manager.spawn_random_tombstone()。
- NightScene.handle_plant_placement() 拦截 CoffeeBeanPlant,检查其放置位置下方是否有 SleepyMushroomPlant,有则调用 sleepy_mushroom.wake_up()。
泳池模式(PoolScene):
- 规则:中间三列为水,植物需睡莲(LilyPad)支撑,僵尸可游泳。
- 关键实现:
- PoolScene.get_grid_cell_type(x, y) 返回 CellType.WATER 或 CellType.LAND。
- PlantManager.can_place_plant() 检查:若目标格为 WATER,则要求植物 "can_be_planted_on_water": true 且下方格子有 LilyPadPlant。
- ZombieBase.update() 中,若 self.position.y > POOL_Y_THRESHOLD,则启用 self.swim_speed 并播放游泳动画。
传送带模式(ConveyorBeltScene):
- 规则:屏幕上方滚动传送带,玩家从中拖拽植物卡片,无阳光收集,植物免费但限时。
- 关键实现:
- ConveyorBeltScene 替换 SunManager 为 ConveyorBeltManager,后者维护一个 deque 存储可用植物类型。
- ConveyorBeltManager.update() 每帧从 deque 左端取一个植物,添加到传送带 Surface 上,并控制其滚动速度。
- PlantManager.try_plant() 不检查阳光,只检查传送带是否有该植物。
坚果保龄球模式(BowlingScene):
- 规则:玩家投掷高坚果(类似保龄球),滚过草坪击倒僵尸,无种植,无阳光。
- 关键实现:
- BowlingScene 注册 BowlingBallManager,管理所有滚动中的高坚果。
- BowlingBallManager.throw_ball(position) 创建 BowlingBall 实例,赋予初速度和旋转。
- BowlingBall.update() 中,self.position.x += self.velocity.x * dt,并检测与僵尸 Rect 的碰撞,碰撞后 zombie.take_damage(500) 并 self.velocity.x *= 0.7(动能衰减)。
实操心得:我曾尝试在
NightScene中加入“雾气”效果,以为只要加个半透明黑色Surface就行。结果发现,NightScene.render()中的雾气图层必须在self.background_surface.blit()之后、self.plant_group.draw()之前绘制,否则植物会被雾遮挡。而self.zombie_group.draw()又必须在雾气之后,才能呈现“僵尸从雾中浮现”的效果。这种渲染顺序的严格依赖,正是场景模式差异化的体现——它不是功能开关,而是整个渲染管线的重排。
3.4 末波突袭:如何让最后一波成为真正的“高潮时刻”
“末波突袭”(Final Wave Assault)是所有关卡的压轴戏,它不是简单地多出几只僵尸,而是一套精心编排的压力测试协议。
以 data/levels/level_5.json 为例,其末波定义为:
"final_wave": {
"zombies": [
{"type": "football", "count": 3, "spawn_offset": 0.0},
{"type": "bucket", "count": 5, "spawn_offset": 1.5},
{"type": "screen_door", "count": 2, "spawn_offset": 3.0}
],
"special_events": [
{"type": "pool_assault", "duration": 10.0, "target_lane": 2},
{"type": "tombstone_rain", "count": 8, "interval": 1.2}
],
"boss": {"type": "gargantuar", "spawn_delay": 8.0}
}
执行流程如下:
1. 常规僵尸投放:按 spawn_offset 错峰投放橄榄球、路障、铁门僵尸,制造第一波冲击。
2. 特殊事件触发:
- "pool_assault":PoolScene 监听到后,立即在 target_lane(第2行)的泳池区域生成 10 只 DuckyRescueZombie,它们从水下突然跃出,打破水面平静。
- "tombstone_rain":NightScene 启动定时器,每 1.2 秒在随机位置生成一座墓碑,8 座墓碑生成完毕后,全部开始“孵化”,3 秒后同时冒出墓碑僵尸。
3. Boss 登场:GargantuarZombie 在第 8 秒出现,但它不是孤身一人——GargantuarZombie.__init__() 会预设 self.minion_spawn_timer = 5.0,即每 5 秒在其当前位置生成一只 ImpZombie(小鬼僵尸),形成“母体+子体”的复合威胁。
整个末波的难点在于资源调度冲突:玩家既要应对水面突袭,又要清理墓碑,还要防备小鬼空降,而 Gargantuar 正在稳步推进。WaveManager 为此专门实现了 is_final_wave_active 标志,并在 PlayingState.update() 中,当此标志为真时,强制降低 SunManager 的阳光生成速率(模拟高压下的效率下降),并将 ZombieManager 的僵尸生成间隔缩短 20%,真正让玩家手忙脚乱。
注意:末波的“突袭感”来自时间压缩与空间压迫的双重叠加。
"pool_assault"的duration: 10.0意味着这 10 秒内,玩家无法在泳池区域种植任何植物(PoolScene会锁定该区域),只能依靠已有的火力网硬抗。这种“剥夺选择权”的设计,比单纯增加僵尸数量更能制造紧张感。
4. 实操部署与开发调试:从零运行到定制关卡的完整路径
4.1 开箱即用:三步启动你的第一个游戏窗口
别被一堆目录吓到,这个项目对新手极其友好。我用一台刚装好 Python 的 Windows 笔记本实测,全程不到 3 分钟:
第一步:环境准备(1分钟)
# 确保 Python 3.7+
python --version # 应输出 Python 3.7.x 或更高
# 安装 Pygame(推荐 2.5.2,兼容性最好)
pip install pygame==2.5.2
# 安装其他依赖(requirements.txt 很精简)
pip install -r requirements.txt
requirements.txt 只有两行:
pygame==2.5.2
numpy==1.24.3
numpy 仅用于 tool.py 中的向量运算优化,非必需,删掉也能跑,只是动画稍卡。
第二步:目录校验(30秒)
确认你的工作目录结构如下(不必完全一致,但关键文件必须存在):
pypvz/
├── main.py # 入口文件
├── constants.py # 全局常量(GRID_SIZE, SCREEN_WIDTH等)
├── tool.py # 工具函数(坐标转换、距离计算)
├── data/ # 配置目录(必须!)
│ ├── plants/
│ ├── zombies/
│ └── levels/
├── graphics/ # 图片目录(必须!)
├── sound/ # 音效目录(必须!)
├── music/ # 音乐目录(必须!)
└── README.md # 详细说明
如果缺少 data/, graphics/, sound/, music/,游戏会启动失败并报错 FileNotFoundError。这是故意设计的——它强迫你下载完整资源包,而不是只拿源码。
第三步:启动游戏(10秒)
cd pypvz
python main.py
你会看到一个 1280x720 的窗口,主菜单出现,背景音乐响起。按 Enter 进入第一关。此时,你可以:
- F 键:全屏/窗口切换
- U 键:强制切回窗口模式
- ESC:返回主菜单
- Mouse Left Click:选择植物卡片,拖拽到草坪种植
- Mouse Right Click:使用小铲子移除植物
- Space:暂停/继续
提示:如果启动黑屏或报错
pygame.error: Couldn't open ...,90% 是graphics/或sound/目录下的某个文件名大小写不匹配(比如sunflower_idle_0.png被写成Sunflower_Idle_0.png)。Windows 不区分大小写,Linux/macOS 区分。用ls graphics/plants/ | head -5检查文件名是否与data/plants/sunflower.json中的"idle_animation": ["sunflower_idle_0.png", ...]完全一致。
4.2 定制你的第一关:修改 JSON 配置的实战指南
想把第一关改成“全是路障僵尸”?或者让向日葵每 5 秒产一次阳光?跟着这个流程走:
案例1:修改向日葵产率
1. 打开 data/plants/sunflower.json
2. 找到 "sun_production_interval": 10.0,
3. 改为 "sun_production_interval": 5.0,
4. 保存文件
5. 重启游戏,进入第一关,观察向日葵头顶的计时器(UI 上有小沙漏图标),它现在应该每 5 秒滴答一次,掉落阳光。
案例2:强化第一关末波
1. 打开 data/levels/level_1.json
2. 找到 "final_wave" 对象(通常在文件末尾)
3. 修改其 zombies 数组:
"zombies": [
{"type": "cone", "count": 10, "spawn_offset": 0.0},
{"type": "bucket", "count": 5, "spawn_offset": 2.0},
{"type": "football", "count": 2, "spawn_offset": 4.0}
]
- 保存,重启游戏,打到第一关结尾,你会看到 17 只高防僵尸同时压境。
案例3:添加新植物(以“樱桃炸弹”为例)
1. 复制 data/plants/cherry_bomb.json 到 data/plants/ 目录(它已存在,此步为演示)
2. 确保 graphics/plants/cherry_bomb/ 下有 idle_0.png, explode_0.png 等动画帧
3. 确保 sound/ 下有 cherry_bomb_plant.wav, cherry_bomb_explode.wav
4. 在 data/levels/level_1.json 的 "available_plants" 数组中,加入 "cherry_bomb"
5. 重启游戏,樱桃炸弹卡片就会出现在主菜单的植物栏中。
注意:JSON 修改后务必用在线 JSON 校验器(如 jsonlint.com)检查语法。一个多余的逗号或引号缺失,会导致
json.decoder.JSONDecodeError,游戏启动失败。我建议用 VS Code 编辑,它自带 JSON 语法高亮和错误提示。
4.3 调试技巧:如何快速定位“植物不攻击”或“僵尸不移动”的问题
游戏运行中遇到逻辑 bug,别急着改代码,先用这套排查法:
问题:“豌豆射手不发射豌豆”
1. 确认状态:在 component/plant.py 的 PeashooterPlant.update() 开头加 print(f"[DEBUG] Peashooter at {self.position}, is_firing={self.is_firing}, fire_timer={self.fire_timer}")
2. 检查触发条件:self.is_firing 为 True 的条件是 self.fire_timer <= 0 且 self.target_zombie is not None。所以,print(f"[DEBUG] Target zombie: {self.target_zombie}") —— 如果是 None,说明 find_target_zombie() 没找到僵尸,检查 ZombieManager 是否真的有僵尸在场上,且其 Rect 是否与豌豆射手的攻击范围相交。
3. 验证攻击范围:PeashooterPlant 的 attack_range 来自 data/plants/peashooter.json 的 "attack_range": 3.0。用 pygame.draw.circle(screen, (255,0,0), self.position, self.attack_range * GRID_CELL_WIDTH, 1) 在 render() 中临时画个红圈,看是否覆盖僵尸。
问题:“路障僵尸卡在草坪上不动”
1. 确认速度:在 component/zombie.py 的 ConeZombie.update() 中 print(f"[DEBUG] ConeZombie speed={self.speed}, position={self.position}")
2. 检查障碍物:self.speed 为 0 的常见原因是 self.is_stunned 或 self.is_frozen 为 True。检查 self.stun_duration 和 self.freeze_duration 是否被意外置位。
3. 验证碰撞:ZombieBase.update() 中,移动后会调用 self.check_collision_with_plants()。如果此处返回 True,说明僵尸撞到了植物(如坚果墙),应进入 self.state = ZombieState.EATING。但如果 self.eat_timer 没有递减,问题就在 ZombieBase.eat() 方法里。
实操心得:我踩过的最大坑是
constants.py中的GRID_CELL_WIDTH = 80和GRID_CELL_HEIGHT = 100。当美术同学把一张plant_sunflower_idle_0.png做成了 120x120 像素,而GRID_CELL_WIDTH还是 80,就会导致向日葵的Rect比实际图像小,阳光掉落点偏移,玩家点不到。解决方法:要么改图片尺寸,要么同步修改constants.py。记住,所有坐标计算都基于GRID_CELL_*,它是整个世界的物理尺度基准。
4.4 扩展开发:如何添加一个新僵尸(以“雪人僵尸”为例)
假设你想添加一个新僵尸 snowman.json,它被击中后会分裂成三只小雪人:
- 创建配置文件
data/zombies/snowman.json:
{
"name": "Snowman Zombie",
"health": 800,
"speed": 0.3,
"walk_animation_speed": 0.1,
"damage_per_second": 50,
"spawn_chance": 0.05,
"is_flagged": false,
"has_armor": false,
"can_jump": false,
"can_swim": true,
"can_climb": false,
"requires_tombstone": false,
"special_behavior": "split_on_death"
}
-
创建图像资源:在
graphics/zombies/snowman/下放walk_0.png,walk_1.png,death_0.png等。 -
编写僵尸类
component/zombie.py:
class SnowmanZombie(ZombieBase):
def __init__(self, position, wave_id):
super().__init__(position, wave_id, "snowman")
self.split_on_death = True # 标记分裂行为
def on_death(self):
super().on_death()
# 分裂成三只小雪人
for i in range(3):
offset_x = random.uniform(-1.5, 1.5)
offset_y = random.uniform(-0.5, 0.5)
new_pos = (self.position[0] + offset_x * GRID_CELL_WIDTH,
self.position[1] + offset_y * GRID_CELL_HEIGHT)
# 创建小雪人僵尸(需先定义 SmallSnowmanZombie 类)
small_zombie = SmallSnowmanZombie(new_pos, self.wave_id)
ZombieManager.add_zombie(small_zombie)
- 注册到僵尸工厂:在
component/zombie.py顶部的ZOMBIE_CLASS_MAP字典中加入:
"snowman": SnowmanZombie,
"small_snowman": SmallSnowmanZombie,
- 在关卡中启用:修改
data/levels/level_3.json的"available_zombies"数组,加入"snowman"。
完成!重启游戏,雪人僵尸就会出现在第三关的波次中。整个过程,你只写了 20 行 Python 代码,其余全部由配置和现有框架支撑。
5. 常见问题与避坑指南:那些文档里不会写的实战经验
5.1 音频播放失真与卡顿:Pygame mixer 的隐藏陷阱
现象:游戏运行一段时间后,音效变慢、变调,甚至完全无声。
原因:Pygame 的 mixer 默认使用 44100 Hz 采样率,但你的 sound/ 目录下 WAV 文件可能是 22050 Hz 或 11025 Hz。mixer 会强行重采样,导致 CPU 占用飙升,最终卡顿。
解决方案:
1. 统一采样率:用 Audacity 打开所有 sound/ 下的 WAV,导出时选择 Project Rate: 44100 Hz。
2. 调整 mixer 初始化参数:在 state/audio_manager.py 的 init_mixer() 中:
pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=512)
关键是 buffer=512(默认是 4096)。小 buffer 减少延迟,但也增加 CPU 负担;大 buffer 更稳但延迟高。512 是平衡点。
3. 音效池管理:不要每次 play() 都新建 Sound 对象。AudioManager 应维护一个 sound_cache = {},get_sound("pea_shoot") 时先查缓存,避免重复加载。
避坑技巧:在
sound/目录下放一个test_tone.wav(纯 440Hz 正弦波,44100Hz),启动游戏后立即播放它。如果音调准确,说明采样率匹配;如果变调,立刻检查文件属性。
5.2 图像闪烁与撕裂:VSync 与帧率控制的终极解法
现象:植物攻击动画、僵尸行走动画出现明显闪烁或跳跃。
原因:Pygame 默认不开启垂直同步(VSync),clock.tick(60) 只是限制最大帧率,但无法保证每一帧都在显示器刷新时绘制,导致画面撕裂。
解决方案:
1. 强制 VSync:在 main.py 的 pygame.init() 后,pygame.display.set_mode() 前,加入:
pygame.display.gl_set_attribute(pygame.GL_SWAP_CONTROL, 1) # 启用 VSync
- 双缓冲渲染:确保
pygame.display.set_mode()使用了pygame.DOUBLEBUF标志:
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.DOUBLEBUF | pygame.HWSURFACE)
- 动画帧率解耦:植物动画不应绑定
clock.tick()。PlantBase应有自己的self.animation_timer,update()中self.animation_timer += dt,当self.animation_timer >= self.animation_speed时才切换帧。这样即使帧率波动,动画速度依然恒定。
注意:
pygame.HWSURFACE(硬件加速)在某些集成显卡上反而更慢。如果启用后卡顿,改为pygame.SWSURFACE(软件渲染)。
5.3 JSON 配置的“幽灵错误”:Unicode 与 BOM 的静默杀手
现象:游戏启动时报 json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes,但你肉眼检查 JSON 完全正确。
原因:文本编辑器(尤其是 Windows 记事本)保存 UTF-8 文件时,会自动添加 BOM(Byte Order Mark)头 EF BB BF。Python 的 json.load() 无法识别它,直接报错。
解决方案:
1. 用 VS Code 或 Notepad++ 编辑:它们默认保存为 UTF-8 without BOM。
2. 命令行批量清理(Linux/macOS):
for file in data/**/*.json; do sed -i '1s/^\xEF\xBB\xBF//' "$file"; done
- Python 脚本自动修复(放入项目根目录,命名为
fix_bom.py):
import os
import json
def remove_bom(file_path):
with open(file_path, 'rb') as f:
raw = f.read()
if raw.startswith(b'\xef\xbb\xbf'):
raw = raw[3:]
with open(file_path, 'wb') as f:
f.write(raw)
print(f"Removed BOM from {file_path}")
for root, dirs, files in os.walk('data'):
for file in files:
if file.endswith('.json'):
remove_bom(os.path.join(root, file))
5.4 跨平台字体渲染:freesansbold.ttf 的替代方案
现象:在 Linux 或 macOS 上,freesansbold.ttf 渲染的 UI 文字模糊、发虚。
原因:freesansbold.ttf 是 Windows 字体,其 hinting(字体微调)针对 Windows 渲染引擎优化,在其他系统上效果差。
解决方案:
1. 使用系统字体:在 tool.py 的 render_text() 函数中:
try:
font = pygame.font.SysFont("DejaVu Sans", size, bold=True) # Linux
except:
try:
font = pygame.font.SysFont("Helvetica", size, bold=True) # macOS
except:
font = pygame.font.Font("freesansbold.ttf", size) # fallback
- 嵌入高质量字体:下载
NotoSansCJK-Regular.ttc(思源黑体),放入fonts/目录,修改tool.py加载路径。
最后提醒:这个项目的真正价值,不在于它“复刻了植物大战僵尸”,而在于它展示了如何用 Python 和 Pygame 构建一个工业级游戏原型。它把“游戏设计”拆解为可配置的规则(JSON)、可插拔的逻辑(State)、可替换的资源(graphics/sound)、可调试的对象(component)。当你下次接到“做个内部培训用的互动游戏”需求时,你脑海里浮现的,不该是“从零开始写 Pygame”,而应该是“打开
data/levels/,复制一份level_template.json,然后开始填空”。这才是工程师该有的复用思维。
简介:一个开箱即用的植物大战僵尸Python游戏工程,基于Pygame 1.9+和Python 3.7+开发,支持完整游戏流程:从阳光收集、植物拖拽种植、铲除重置,到分波僵尸进攻、多阶段Boss战与末波突袭。内置27种可操作植物(含向日葵、豌豆射手、坚果墙、寒冰射手、樱桃炸弹、双发射手、三线射手、大嘴花、小喷菇、土豆雷、地刺、胆小菇、倭瓜、火爆辣椒、阳光菇、寒冰菇、魅惑菇、火炬树桩、睡莲、杨桃、咖啡豆、海蘑菇、高坚果、缠绕水草、毁灭菇、墓碑吞噬者、大喷菇)和15类僵尸(普通、旗帜、路障、铁桶、读报、橄榄球、鸭子救生圈、铁门、撑杆跳、冰车等),覆盖白昼、夜晚(含墓碑生成与墓碑出怪逻辑)、泳池(支持末波泳池突袭)、传送带、坚果保龄球五大场景模式。关卡由JSON配置驱动,支持F键全屏/U键窗口切换、背景音乐与音效播放、进度条显示、小铲子移除植物、植物卡片拖拽选择等功能。项目结构清晰,包含graphics(图像)、music(背景音乐)、sound(音效)、data(配置文件)、state(状态管理)、component(游戏组件)等模块,并附带README说明文档、15张演示图(webp格式)及启动脚本。
更多推荐


所有评论(0)