恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用Python和Pygame从零实现植物大战僵尸:核心架构与避坑指南
首页
资讯中心
/
用Python和Pygame从零实现植物大战僵尸:核心架构与避坑指南
用Python和Pygame从零实现植物大战僵尸:核心架构与避坑指南
发布时间:2026/10/13 23:12:34
简介这是一份植物大战僵尸的Python实现源码包面向想要通过游戏项目学习Python开发与Pygame的初学者适合作为入门练手或课程设计参考。压缩包共10个文件以Python主程序为核心配以8张png格式的游戏素材图与1份说明文档全部内容仅42KB轻量易用下载后即可直接运行体验。目前已有1786人学习下载。源码包含地图、普通僵尸、豌豆射手、向日葵以及豌豆子弹等经典元素地图与素材分层存放方便初学者对照理解精灵绘制、碰撞检测与游戏主循环等核心逻辑。整体代码结构清晰注释说明与素材配套完整稍加修改即可扩展新关卡或新角色是一份实操性较强的Python小游戏学习资料。1. 这个标题到底值得折腾吗Python 重写“植物大战僵尸”的边界与回报先说结论用 Python 写一个“植物大战僵尸”简化版不是实现一个和原版对标的商业游戏而是用最少的代码把塔防、资源管理、碰撞检测、状态机这些游戏开发基本功串起来。很多初学者在 GitHub 上搜过相关源码多半遇到两种情况一是代码能跑但只有一张静态图和几个假僵尸二是结构复杂到读不下去。我自己也踩过这个坑后来花一周时间做了一个 5x9 网格、4 种植物、3 种僵尸的可玩版本才意识到这个项目的真实价值不在“模仿得像”而在“所有逻辑都是你自己能控制的”。如果你正在学 Python想找一个能涵盖类设计、事件循环、碰撞检测、关卡数据结构的练手项目这个方向非常合适如果你期待直接跑一个花哨的完整版那可能要调整预期因为纯 Python 加上 Pygame 的渲染能力撑不住满屏特效但这不影响它成为最好的“游戏逻辑入门”项目之一。这篇文章把我自己做过的方案、参数、以及 5 个最容易翻车的地方全部写出来你可以照着复现也可以只取一部分改造到自己的课程设计或简历项目里。2. 先把架构立住对象模型、状态机与事件循环照着这套写不会乱2.1 游戏对象怎么抽象从植物到僵尸的公共基类写这类游戏第一个容易犯的错是把植物、僵尸、子弹各写一套完全不相关的类结果碰撞检测时到处判断类型。我建议先做一个GameObject基类统一管理位置、生命值、存活状态和更新逻辑。植物和僵尸都有生命值、位置、帧更新方法只是行为不同所以继承后重写update和collide方法就够了。下面是最小基类骨架class GameObject: def __init__(self, x, y, hp, img): self.x x self.y y self.hp hp self.img img self.alive True self.rect img.get_rect(topleft(x, y)) def update(self, game): pass def draw(self, screen): if self.alive: screen.blit(self.img, self.rect) def take_damage(self, amount): self.hp - amount if self.hp 0: self.alive False逻辑说明update接收一个game对象这样能拿到全局事件、精灵组、阳光数量等避免用全局变量。rect是 Pygame 自带的矩形对象碰撞检测直接用它不需要自己写距离公式。take_damage里把alive置为 False外部循环在遍历精灵组时就能把死亡对象移除。参数说明x, y是精灵左上角坐标不是中心点如果用中心点做碰撞需要在初始化时传入rect.center。hp对植物来说可以是 100 或 300对僵尸来说按波次递增后面第 4 章会说具体数值。2.2 事件循环与帧率为什么 Python 版做不了满屏特效很多人第一次写 Pygame 程序会把所有逻辑堆在一个 while 循环里然后发现移动速度忽快忽慢。这是因为没有固定的帧率控制。Pygame 提供Clock.tick(FPS)来限制循环运行速度但这只是“限制最大帧率”真正影响游戏速度的是你每次循环里让对象移动多少像素。from pygame import Clock clock Clock() FPS 60 running True while running: dt clock.tick(FPS) / 1000.0 # 转换成秒 for event in pygame.event.get(): if event.type pygame.QUIT: running False # 更新游戏状态dt 用于每帧位移计算 game.update(dt) game.draw(screen) pygame.display.flip()逻辑说明dt是上一帧到当前帧的时间差单位秒。子弹、僵尸移动都乘以dt这样即使帧率波动单位时间内的位移量也一致。这是游戏循环的标准写法比“每帧移动固定像素”健壮得多。参数说明FPS设成 60 或者 30 都行。我的经验是 Pygame 渲染如果场景里有 50 个精灵60 帧会吃满一个 CPU 核心建议开发期用 30 帧发布演示用 60 帧。注意tick(FPS)的返回值单位是毫秒除以 1000 才是秒。2.3 回合与状态机用枚举管理“待种植-生长-攻击-死亡”游戏里一个植物从种下去到被杀其实是一个状态机刚放下是待种植然后进入生长动画之后循环攻击最后死亡。如果只用 if 判断当前状态状态一多就乱。我用enum.Enum定义状态每个状态对应一套行为逻辑结构清晰还能减少魔法数字。from enum import Enum class PlantState(Enum): PLANTING 0 # 刚点击格子准备放入 GROWING 1 # 生长中不能攻击 READY 2 # 成熟可以攻击 DYING 3 # 被吃或被打毁 class Peashooter: def __init__(self, x, y): self.x x self.y y self.state PlantState.GROWING self.grow_timer 0.0 self.shoot_timer 0.0 def update(self, dt, game): if self.state PlantState.GROWING: self.grow_timer dt if self.grow_timer 1.0: self.state PlantState.READY elif self.state PlantState.READY: self.shoot_timer dt if self.shoot_timer 1.5: self.shoot_timer 0 game.add_bullet(self.x 20, self.y) elif self.state PlantState.DYING: pass逻辑说明GROWING状态让植物种下去后有一秒动画时间避免瞬间攻击READY状态里维护一个独立射击计时器每次到 1.5 秒就发射一颗子弹。DYING状态可以留空等外部把alive置为 False。参数说明生长时间 1.0 秒、射击间隔 1.5 秒是参考原版手感调的。如果你想让游戏更刺激把射击间隔改成 0.8 秒如果想让玩家手忙脚乱就调成 2.0 秒。注意这个射击间隔与后面子弹的飞行速度、僵尸移动速度共同决定拦截效果不是独立参数。3. 用 Pygame 跑通最小可玩版本从空白窗口到僵尸进家门3.1 初始化与地图网格把屏幕切成 5x9 的格子拿到一个空窗口后第一件要做的不是画植物而是把地图网格建出来。原版草坪是 5 行 9 列我直接照用。每个格子宽 80、高 100屏幕就是 9x80720 宽5x100500 高再把顶部留 100 像素放阳光和铲子工具栏。import pygame pygame.init() SCREEN_WIDTH 720 80 # 右侧留 80 像素作为工具栏 SCREEN_HEIGHT 500 100 # 顶部 100 像素 screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) GRID_ROWS 5 GRID_COLS 9 CELL_W 80 CELL_H 100 GRID_X 0 GRID_Y 100 def cell_pos(row, col): return GRID_X col * CELL_W, GRID_Y row * CELL_H逻辑说明cell_pos返回格子的左上角坐标之后种植、僵尸行走、子弹碰撞都依赖这个函数保证所有对象都落在网格上。工具区单独留出来避免和草坪坐标冲突。参数说明CELL_W和CELL_H不是随便取的要匹配植物图片的尺寸。如果你用的是 100x100 的图片格子高度就需要调整网格尺寸不匹配底层贴图是后面碰撞错位的根源之一。3.2 植物种植与阳光生成鼠标点击与定时器种植逻辑最简单的做法监听鼠标点击把坐标换算成网格行列检查该格是否为空、阳光是否足够然后创建植物对象。阳光来源有两种自动生成的阳光每隔 10 秒从天上掉一个以及太阳花主动产出的阳光。这里先写自动生成部分。class Game: def __init__(self): self.sun 100 self.plants [] self.zombies [] self.bullets [] self.grid [[None for _ in range(GRID_COLS)] for _ in range(GRID_ROWS)] self.sun_spawn_timer 0.0 self.sun_item_list [] def handle_click(self, pos, selected_plant): x, y pos if y GRID_Y or x GRID_X GRID_COLS * CELL_W: return # 点击在工具栏或顶部忽略 col (x - GRID_X) // CELL_W row (y - GRID_Y) // CELL_H if self.grid[row][col] is not None: return if self.sun selected_plant.cost: return self.sun - selected_plant.cost plant selected_plant(cell_pos(row, col)[0], cell_pos(row, col)[1]) self.grid[row][col] plant self.plants.append(plant) def update(self, dt): self.sun_spawn_timer dt if self.sun_spawn_timer 10.0: self.sun_spawn_timer 0.0 self.add_sun_item()逻辑说明grid是一个二维列表用来快速判断某个格子是否被占用避免遍历所有植物。点击时用整除得到行列号然后检查占用与阳光。add_sun_item会生成一个飘落的阳光物件玩家点击后sun 50。参数说明初始阳光100自动阳光间隔10秒、每个价值50。这三个数值决定了游戏前期节奏。如果调成初始 200玩家开局就能种两棵植物压力瞬间变小。建议先保持 100 试玩三分钟再根据自己的手感微调。3.3 僵尸生成与移动按波次推进的简单 AI僵尸的 AI 本质上就是“从左往右走遇到植物就啃”。生成方式我采用时间波次游戏开始后第 20 秒生成第一只之后每隔 15 秒生成一只波次强度通过僵尸血量 基础值 波次 * 30来提升。class Zombie: def __init__(self, x, y, hp, speed): self.x x self.y y self.hp hp self.speed speed # 每秒移动像素数 self.alive True self.rect pygame.Rect(x, y, 60, 100) def update(self, dt, game): target_plant self.find_target(game) if target_plant: # 啃植物不移动 target_plant.hp - 25 * dt else: self.x - self.speed * dt self.rect.x self.x def find_target(self, game): # 只找同一行、右侧最近的植物 for plant in game.plants: if (plant.alive and abs(plant.y - self.y) 50 and plant.x self.x): return plant return None逻辑说明find_target通过行匹配y 坐标差值小于 50 像素找植物实际上是在判断在同一网格行。啃食伤害 25 每秒意味着一个 300 血植物能扛 12 秒。僵尸移动只改 x 坐标rect 同步更新保证碰撞检测跟着走。参数说明僵尸的初始速度我设为 15 像素/秒走到最左侧需要 720/1548 秒如果防线被突破玩家有近一分钟时间去补救节奏比较适中。速度低于 10 会让人觉得“太慢没压力”高于 25 则滚雪球一样崩盘。3.4 子弹与碰撞检测用矩形和距离替代物理引擎子弹做成一个直线飞行的对象每次更新向左移动bullet_speed * dt像素。碰撞检测直接用 Pygame 的rect.colliderect不需要计算圆与圆的距离。这一步最关键的坑是子弹生成位置必须从植物右边缘发射而不是植物的左上角否则子弹在植物内部就开始了。class Bullet: def __init__(self, x, y): self.x x self.y y self.speed 200 # 像素/秒 self.damage 20 self.rect pygame.Rect(x, y, 20, 20) self.alive True def update(self, dt, game): self.x self.speed * dt # 向右侧飞错僵尸在左边应该减 # 实际应写 self.x - self.speed * dt self.rect.x self.x for zombie in game.zombies: if zombie.alive and self.rect.colliderect(zombie.rect): zombie.hp - self.damage self.alive False break逻辑说明上面代码里我故意留了一个注释错误提醒你别犯方向错误。原版植物在左边僵尸从右边出现所以子弹应该向左移动即self.x - self.speed * dt。碰撞到僵尸后立即把子弹设为死亡同时把伤害叠加给僵尸。参数说明子弹速度 200 像素/秒从屏幕最右侧飞到左侧需要 3.6 秒这是判断“开枪后多久能击杀目标”的核心。如果你把僵尸速度调到 20子弹速度还是 200那么子弹追不上移动目标需要把子弹速度调到 300 以上。伤害 20、植物射击间隔 1.5 秒对 200 血的僵尸需要 15 发才能杀死也就是 22.5 秒这些数值要联动调整后面第 4 章会讲一套完整参数表。4. 数值设计把源码里的参数调到“能玩但不简单”的手感4.1 冷却时间、阳光花费与生产效率的三角博弈植物大战僵尸的核心博弈是阳光经济。玩家每 10 秒获得 50 阳光而向日葵每 5 秒产出 25 阳光。如果向日葵成本是 50那么它需要 10 秒回本之后开始净产出。这个时间窗就是玩家冒险的代价。我在源码里定义了一个字典集中管理所有植物的数值改参数不用翻代码。PLANT_DATA { sunflower: {cost: 50, hp: 100, produce_interval: 5.0, produce_amount: 25}, peashooter: {cost: 100, hp: 200, shoot_interval: 1.5, bullet_damage: 20}, wallnut: {cost: 150, hp: 800, block_duration: 0.0}, snowpea: {cost: 175, hp: 200, shoot_interval: 1.5, bullet_damage: 15, slow_factor: 0.5}, } PLANT_IMAGES { sunflower: assets/sunflower.png, peashooter: assets/peashooter.png, }逻辑说明所有植物的公共数值都放在这个字典里创建实例时把字典传进去作为初始属性。这样调整难度只需要改数字不需要动类结构。wallnut不攻击只负责挡住僵尸它的收益体现在“拖住僵尸为你争取阳光产出时间”。参数说明cost直接决定玩家前期的选择压力。我调试时把太阳花成本从 50 改成 40发现玩家可以无脑种三朵导致后面阳光大量溢出游戏失去紧张感改成 60 则前期非常憋屈。所以 50 是一个经过验证的平衡点。同理太阳花 5 秒生产 25回本周期 50 / (25/5) 10 秒这个数值正好等于一局游戏前期的节奏窗口。4.2 僵尸血量与速度曲线前 5 波怎么设计僵尸数据我放在另一个字典里并且支持按波次调整。如果你把波次定义为“游戏时间每 20 秒增加一波”那么可以用一个函数根据当前波次返回僵尸的属性和数量。import math ZOMBIE_BASE {hp: 200, speed: 15, damage: 25} def zombie_template(wave): hp ZOMBIE_BASE[hp] wave * 30 speed ZOMBIE_BASE[speed] min(wave * 0.5, 6) damage ZOMBIE_BASE[damage] wave * 2 count 1 wave // 3 # 每三波多一只僵尸 return {hp: hp, speed: speed, damage: damage, count: count}逻辑说明这个函数的返回值是“本波次僵尸的属性”以及需要生成的僵尸数量。count用整除来控制难度阶梯第 1 波 1 只第 3 波 2 只第 6 波 3 只。speed增速缓慢避免后期僵尸快得无法拦截。参数说明基础血量 200对应豌豆射手 20 点伤害需要 10 发子弹配合射击间隔 1.5 秒一只僵尸需要 15 秒击杀。第一波只有一只僵尸从生成到进屋有 48 秒留给玩家充足时间。第 5 波时血量 350速度 17.5数量 2 只压力开始显现。这个曲线我调试过多次前 5 波不应该出现“单种植物被秒杀”的情况否则新手会感到挫败。4.3 子弹伤害与射击间隔两个最容易被改崩的参数很多开发者改平衡性时只调伤害或者只调攻速往往导致游戏要么秒杀一切、要么打不动。这里有一个联动陷阱每秒期望伤害 (子弹伤害 / 射击间隔)。如果你想增强豌豆射手不能只把伤害从 20 改成 40那样僵尸血量也要等比上调否则 1 波僵尸瞬间被秒游戏失去压迫感。建议始终保持这个公式def expected_dps(plant_data): return plant_data[bullet_damage] / plant_data[shoot_interval]逻辑说明这个函数返回每秒伤害期望。调整模组时先确定目标 DPS再反推伤害和间隔。比如想让豌豆射手 DPS 从 13.3 提升到 18可以先设射击间隔 1.5 不变伤害改成 27也可以设伤害 20间隔缩短到 1.1。但间隔受渲染帧率影响小于 0.5 时玩家几乎看不出子弹发射间隙动画观感很差所以优先调伤害。参数说明寒冰射手的减速因子slow_factor0.5是乘法系数它会让僵尸速度减半但伤害只有 15。这意味着寒冰射手的单体 DPS 较低但总输出效率因为减速效果而提升——你的其他子弹有更长时间命中目标。这个参数很容易改错有人直接把伤害改成和豌豆一样 20导致寒冰射手变成豌豆上位替代收费 175 阳光却完全超模。减速效果必须通过 DPS 损失来定价。5. 避坑从源码里最容易翻车的 5 个细节每一个我都踩过5.1 植物种下去瞬间消失现象点击格子后植物图标闪过一下画面里就没有了阳光照常被扣。原因大多数情况是精灵组逻辑混乱。比如你把植物同时加入了plants列表和网格grid但在更新循环里遍历时代码写成for plant in plants: if not plant.alive: plants.remove(plant)而植物刚初始化时alive可能还是 False因为构造函数里没有正确设置。另一个常见原因是update方法里误调用game.grid[row][col] None。解决确保植物初始化时alive True移除逻辑统一放在主循环的“回收阶段”不要在单个对象的update内修改全局列表。我推荐用一个game.cull()方法每次帧末尾统一清理。5.2 子弹穿过僵尸打不中现象子弹明明和僵尸画面上重叠了但碰撞检测不触发。原因这是坐标对齐问题。你的子弹rect是用self.x和self.y创建的但self.x是画图时的左上角坐标而僵尸的rect也可能是贴图外多出的透明边距导致矩形比可视区域大或小。更隐蔽的是植物射击时的子弹生成坐标是(plant.x 20, plant.y 20)但网格坐标没有乘CELL_W导致子弹生成在世界的左上角。解决统一所有实体的rect为实际碰撞区域不要直接用图片尺寸。建议在初始化时明确设置self.rect pygame.Rect(x, y, 40, 60)这种人工指定的碰撞框。调试时在屏幕上画出rect的边框一眼就能看出偏差。5.3 游戏越玩越卡僵尸死后还在内存里现象玩到第 5 波后帧率明显下降CPU 占用飙升。原因僵尸死亡后你只设置了alive False但列表里还保留着这些对象每帧循环还在遍历它们。如果有几百个死亡对象循环耗时成倍增长。解决每帧结束调用game.zombies [z for z in game.zombies if z.alive]过滤掉死亡僵尸。同时注意释放图片引用否则 Pygame 的 Surface 对象不会自动回收。如果嫌每次都创建新列表开销大也可以反向遍历删除但列表推导式在 Python 里更高效。5.4 太阳花产出阳光数量越攒越多一局结束有 999 阳光现象玩家把太阳花种了十几朵很快阳光量溢出到几千游戏失去经济压力。原因定时器或者生成逻辑写错导致同一朵太阳花每帧都触发一次生产。比如produce_timer dt写到update之外或者你用了pygame.time.set_timer又手动累加双重触发。解决把生产逻辑集中在植物自身的update里并且用“是否超过间隔”作为触发条件而不是“等于间隔”。浮点数累加永远可能因为帧率偏差而略超过设定值所以要写成if self.timer self.interval: self.timer - self.interval而不是置为 0。这样即使帧率波动也不会丢失超出的那部分时间避免累计误差导致产阳光频率变快。5.5 退出游戏时报错pygame.error: video system not initialized现象点窗口关闭按钮后控制台刷一堆红色错误程序异常退出。原因主循环结束后你又调用了pygame.quit()但循环内部还有screen.blit或pygame.display.flip()在running为 False 后继续执行或者事件循环里同时处理了QUIT和KEYDOWN时触发了某个对象引用已释放的 Surface。解决在主循环的条件里保证退出后不再执行任何绘制操作标准写法是while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False if not running: break game.update(dt) game.draw(screen) pygame.display.flip() pygame.quit()逻辑说明break强制跳出循环避免执行到game.update时资源已被释放。pygame.quit()只调用一次放在循环外。另一个小坑如果你在game.draw里用了带透明度的图片退出前必须销毁所有Surface否则某些环境下也会报同样的错。6. 进阶让这份源码变成一个真的能扩展的游戏框架6.1 用配置文件替代硬编码数值我见过太多源码把所有数值写成魔法数字散落在各个类里改一处崩三处。建议把游戏参数放到一个 JSON 或 Python 文件里我的做法是建立config.py里面定义植物、僵尸、波次、格子尺寸、帧率、初始阳光等字典。这样调平衡时只需要改一张表不需要读代码逻辑。配置文件名config.py而不是.json的好处是可以直接写表达式比如shoot_interval: 1.5还可以做round(1.5 * 0.8, 2)这种计算避免 JSON 里不能写公式的麻烦。6.2 增加关卡数据结构与出怪逻辑源码里用zombie_template(wave)生成僵尸只是最基础的做法。要支持不同关卡不同出怪模式建议定义关卡数据结构LEVELS { level1: {waves: 3, zombie_types: [basic], spawn_interval: 15}, level2: {waves: 5, zombie_types: [basic, cone], spawn_interval: 12}, level3: {waves: 7, zombie_types: [basic, cone, bucket], spawn_interval: 10}, }逻辑说明每一关可以规定波数、僵尸类型列表和生成间隔。在Game中读取当前关卡配置根据已生成波次决定下一只僵尸的种类用random.choice按概率挑选。如果你想让关卡通完弹回主界面只需在update里判断僵尸列表为空且波次耗尽则触发过关逻辑。6.3 内存优化与对象池僵尸尸体回收的正确姿势第 5 章提到过滤死亡僵尸但频繁创建和销毁 Python 对象本身也有开销。进阶做法是使用对象池预创建一批僵尸实例当需要出怪时从池中取出死亡后重置属性放回池子。这样能减少内存碎片和 GC 压力。一个简单的对象池实现class ZombiePool: def __init__(self, capacity): self.pool [Zombie(0, 0, 0, 0) for _ in range(capacity)] def acquire(self, x, y, hp, speed): z self.pool.pop() z.x, z.y, z.hp, z.speed x, y, hp, speed z.alive True z.rect.x, z.rect.y x, y return z def release(self, zombie): zombie.alive False self.pool.append(zombie)逻辑说明池子里的对象在初始化时并不真正绘制只有acquire后才参与游戏逻辑。注意release时要重置所有属性尤其是rect位置否则下一个僵尸会出现在上一个死亡位置。参数说明池容量可以设为最大波次僵尸数量的 2 倍比如第 10 波最多同时出现 8 只池容量 16。如果池耗尽pop()会报错需要在acquire里判断如果池为空则新建对象。这个兜底逻辑很重要否则越到后期越容易崩。6.4 我的一个习惯先让变量在屏幕上动起来再谈“高级”我做这个项目的时候一开始就想着要搞对象池、设计模式、配置文件结果两天都没跑出第一个子弹。后来我把代码推倒重来先写一个 5 行 9 格的地图放一棵能自动发子弹的豌豆射手再加一个从右向左走的僵尸20 分钟就看到了“射击—扣血—死亡”的最小闭环。从那之后所有扩展都是在这个闭环上叠加的。这个项目的说明书上不需要写“架构优美”而应该写“我能用 Python 把游戏逻辑自洽地实现了”。如果你也想把这份源码改成简历项目我的建议是不要加花哨的粒子效果也不要试图复刻原版所有植物。把注意力放在三个关键点上——网格系统、碰撞检测、数值平衡。这三样做扎实了面试时你能讲清楚每一个参数为什么是这个数以及如果改大会发生什么这才叫真正的收获。我在这个项目里最深的教训是过早优化和过度设计是大多数 Python 小项目翻车的原因先把能玩的版本跑通再回头补充对象池和配置表你会发现那些高级技巧其实只是“让代码更整洁”的工具而不是游戏乐趣的来源。希望这篇笔记能帮你在做同类项目时少踩几个坑剩下的手感就靠你自己一帧一帧调出来了。本文还有配套的精品资源点击获取