做课设的时候很多同学学完了 Python 的for、class、def甚至把《Python编程从入门到实践》翻了三遍但一打开 PyCharm 还是不知道从哪写起。语法会项目不会这是大一学生最典型的状态。问题不在语法本身而在于缺少一个能把零散语法点串成完整系统的练手载体。植物大战僵尸恰好是这样一个载体。它不复杂但有完整的状态流转阳光经济、种植冷却、子弹碰撞、僵尸寻路、胜负判定一个都不少。写完这一个项目你对面向对象、事件循环、帧更新、坐标换算的理解会明显上一台阶比刷十道算法题更接近真实开发。这篇文章不打算只丢一段代码让你复制运行。我会把整个游戏拆成“配置层、实体层、循环层”三块先讲清楚每一块为什么这么拆再给出可以直接运行的完整源码。全程不依赖任何图片素材纯pygame几何绘制避免新手最常踩的那个坑——代码抄下来了素材没下载运行直接报FileNotFoundError。读完之后你应该能做到三件事在自己的电脑上把游戏跑起来看懂每一行代码在游戏循环里的位置以及知道接下来该改哪几个参数、加哪几个类把它变成你自己课设里能讲出东西的项目。1. 先说结论这个项目值得练但别直接抄网上搜“Python 植物大战僵尸”能翻出大量标着“完整源码”的帖子。问题在于其中相当一部分是十年前的老代码用的是 Python 2 语法print后面不带括号还有一些依赖pygame早期版本的 API在新版本上直接报错。更麻烦的是这些代码往往只有一个几百行的main.py所有逻辑揉在一起变量名是a、b、img1、img2你就算跑起来了也学不到任何可迁移的东西。所以这个项目真正的价值不在于“做出一个游戏”而在于它逼着你做三个工程决策数据放哪儿、逻辑谁来管、界面怎么画。这三个决策在任何语言、任何框架里都成立只是游戏这个场景把它们压缩得很小、很直观。我倾向于把植物大战僵尸定位成“第一个有架构意识的练手项目”。它小到一两天能写完又大到足以让你体会“如果配置散落在代码各处改一个数值要翻遍整个文件”的痛苦。你在settings.py里集中管理参数的那一下就是一种最朴素的配置中心思想。需要提前说清楚适用边界。这套代码做的是一个可玩的最小完整版本五种行列网格、三种植物、两种僵尸、阳光经济、击杀计数和胜负判定都有。但它没有动画帧、没有音效、没有多波次关卡也不追求原版美术还原。如果你的课设要求是“必须还原原版所有关卡”那这套代码只是一个起点你得自己在它上面继续扩展。如果你要的是“能演示、能讲清原理、能当场跑起来”那它够用。2. 游戏到底是怎么“跑”起来的核心概念拆解在写任何一行代码之前得先建立游戏程序的运行模型。很多新手卡住不是因为不会写类而是脑子里没有“一帧一帧刷”的概念总想找一个“开始游戏之后就一直运行”的函数。理解下面这四个概念后面的代码你会看得顺很多。2.1 游戏循环每一帧都在做什么普通脚本是“从上往下执行完就结束”游戏程序不是。它是这样一个死循环先把画面清空再根据当前数据重新画一遍然后处理玩家这一帧的输入最后更新所有实体的位置和状态接着进入下一轮。这个循环每秒跑 60 次你就看到了“动画”。这就是为什么游戏里的所有对象都要有一个update()方法。植物、僵尸、子弹、阳光它们不需要知道别人在干什么只需要在被调用时回答一个问题这一帧我应该发生什么变化。主循环负责统一调度实体只负责自身状态流转这就是最基础的职责分离。这里真正容易踩坑的地方是时间单位。如果你写成self.x - 1那么游戏的运行速度就绑死在帧率上了。电脑快的时候一秒跑 120 帧僵尸走得飞快电脑卡的时候一秒 30 帧僵尸像在散步。正确做法是把每一帧经过的毫秒数dt传进update()速度按“像素/帧”定义时乘以dt / 16换算这样无论帧率怎么波动游戏速度都稳定。2.2 实体与状态面向对象在这里怎么用新手写游戏最容易写成“一堆全局变量 一堆 if 判断”。比如用plants []存字典每次判断都写if plant[type] sunflower。这样写前 50 行还行写到 300 行你自己都记不清哪个字典有哪些键。更稳的做法是把每一类游戏对象抽象成一个类。Plant类有三个关键属性位置行、列、像素坐标、生命值、计时器。Zombie类有位置、生命值、速度、攻击力。Pea类有位置、伤害、速度。它们都实现update(dt, game)和alive标记。alive这个标记是整套设计的关键。当植物被打死、僵尸被射死、子弹飞出屏幕我们不立刻从列表里删掉它而是把alive置为False在每一帧更新结束后统一过滤一遍列表。为什么因为在遍历列表的过程中修改列表长度是 Python 里非常经典的坑会导致跳过元素甚至索引越界。先标记、后清理是游戏开发和很多后端任务队列里通用的做法。2.3 网格坐标与像素坐标新手最容易绕晕的地方塔防游戏有两套坐标系必须分清楚。一套是逻辑坐标——第几行第几列这是你用来判断“这一格有没有植物”的。另一套是像素坐标——屏幕上具体哪个点这是pygame画图用的。比如玩家点击屏幕(400, 300)你要先算出这是第几行第几列才能判断能不能种。反过来僵尸走到某个位置要判断有没有撞上植物用的是像素矩形的碰撞检测。两套坐标之间必须有一个稳定的换算函数否则各种错位问题会把你逼疯。我的做法是提供一个cell_center(row, col)函数把逻辑坐标转成像素坐标。所有实体的像素位置只在初始化时算一次之后移动就基于像素坐标增量不再回头看网格。这样职责很清晰网格只回答“哪一格”像素只回答“画在哪”。2.4 碰撞检测的最小可用方案真正做游戏会用空间分割、四叉树之类的东西优化碰撞检测。但这个项目里僵尸最多十几只、子弹最多二三十颗两两比较完全够用性能根本不是瓶颈。pygame.Rect提供了colliderect()方法判断两个矩形是否相交一行代码搞定。需要注意的是子弹只和同一行的僵尸碰撞。虽然矩形本身已经包含了 Y 轴信息理论上不同行的矩形不会相交但显式加一层row判断能让逻辑更清楚也避免了因为绘制尺寸偏差导致的“隔行打中”这种诡异 bug。3. 环境准备与依赖安装这个项目对环境的依赖很低一个 Python 加一个pygame就够。具体 Python 版本请以你本机已有版本为准本文不绑定特定小版本号只要是近几年的 3.x 版本都能跑。IDE 用 PyCharm、VS Code 或者直接命令行都行代码本身不依赖任何插件。唯一需要安装的第三方库是pygame。建议使用 2.x 版本因为代码里用到了border_radius这个圆角矩形参数它是 2.0 之后才有的。# 安装依赖Windows / macOS / Linux 通用 python -m pip install pygame # 如果系统里同时装了 Python 2 和 3用这个更保险 python3 -m pip install pygame # 验证是否安装成功能打印出版本号就说明 OK python -c import pygame; print(pygame.version.ver)如果你在国内网络环境下安装较慢可以临时指定镜像源这条命令同样适用于requirements.txt安装python -m pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple第三方库只装这一个就够了不需要numpy、不需要PIL代码里所有图形都是pygame.draw实时绘制的圆形和矩形。这也是我特意选择纯几何绘制的原因——你发给同学、交到老师电脑上都不存在素材路径不对的问题双击就能跑。4. 项目骨架与全局配置4.1 目录结构项目一共四个文件虽然不多但结构必须一开始就分清楚。如果你把所有代码塞进一个main.py等它长到 400 行你想改个僵尸血量都得用全局搜索。分文件不是为了显得专业是为了让你改参数时不用理解逻辑。pvz_demo/ ├── main.py # 程序入口、主循环、绘制、事件处理 ├── entities.py # 植物 / 僵尸 / 子弹 / 阳光 四个实体类 ├── settings.py # 所有可调参数、颜色、配置字典 └── requirements.txt # 依赖清单requirements.txt内容很简单写上依赖名即可具体版本让 pip 自己解析避免把版本号写死之后在别人电脑上装不上pygame2.04.2 settings.py把所有可调参数集中管理这是整个项目里我最想强调的一个文件。新手常见做法是“植物血量 100”直接写在Plant.__init__里等你玩了几局觉得太简单想把僵尸攻击力调高就得去entities.py里翻。而如果你有二十个植物这个工作量就爆炸了。把所有数值抽到settings.py好处是调整游戏平衡变成了一个纯配置工作。下面这份配置里的数值你可以随便改改完直接运行不需要动任何逻辑代码。# 文件路径settings.py # 所有可调参数集中在这里调平衡时只动这个文件 # ---------- 网格与窗口 ---------- GRID_ROWS 5 GRID_COLS 9 CELL_W 90 CELL_H 100 GRID_LEFT 130 # 左侧留白作为房子区域 GRID_TOP 90 # 顶部留白放状态栏 SCREEN_W GRID_LEFT GRID_COLS * CELL_W 20 SCREEN_H GRID_TOP GRID_ROWS * CELL_H 30 FPS 60 # ---------- 阳光经济 ---------- SUN_START 150 SUN_VALUE 25 SUN_INTERVAL_MS 8000 # 天空自然掉落阳光的间隔 SUN_LIFETIME_MS 9000 # 单个阳光在场上的存在时间 # ---------- 植物属性 ---------- PLANT_CONFIG { sunflower: {cost: 50, cooldown: 5000, hp: 60}, peashooter: {cost: 100, cooldown: 5000, hp: 100}, wallnut: {cost: 50, cooldown: 15000, hp: 400}, } SUNFLOWER_INTERVAL_MS 7000 # 向日葵产阳光间隔 PEASHOOTER_INTERVAL_MS 1400 # 豌豆射手开火间隔 # ---------- 僵尸属性 ---------- ZOMBIE_CONFIG { normal: {hp: 200, speed: 0.25, damage: 20}, cone: {hp: 400, speed: 0.22, damage: 20}, } SPAWN_INTERVAL_MS 6000 # 初始出怪间隔 SPAWN_ACCEL_STEP_MS 20000 # 每过这么久出怪加快一档 SPAWN_ACCEL_VALUE_MS 500 # 每档缩短的毫秒数 SPAWN_MIN_MS 2500 # 出怪间隔下限 # ---------- 子弹 ---------- PEA_DAMAGE 20 PEA_SPEED 6 # 像素/帧以 60FPS 为基准 # ---------- 颜色 ---------- COLOR_BG (38, 46, 38) COLOR_GRID (56, 70, 52) COLOR_GRID_LINE (78, 96, 72) COLOR_TEXT (235, 240, 230) COLOR_SUN (247, 205, 56) COLOR_SUNFLOWER (250, 205, 60) COLOR_PEA (120, 220, 120) COLOR_ZOMBIE (150, 170, 140) COLOR_ZOMBIE_CONE (200, 150, 90) COLOR_WALLNUT (170, 120, 70) COLOR_HP_BACK (90, 40, 40) COLOR_HP_FRONT (220, 70, 70)这里有个设计细节值得说一下ZOMBIE_CONFIG里speed的单位是“像素/帧”而不是“像素/秒”。为什么因为它更贴近人的直觉——“一帧移动 0.25 个像素”60 帧就是一秒 15 像素跨过一个格子大概需要 6 秒。这种单位选择在游戏开发里很常见代价是必须用dt做换算这个换算在实体类里会看到。5. 核心模块实现实体层5.1 坐标换算与四个实体类打开entities.py第一部分先写坐标换算工具。这个函数只做一件事给定行列返回该格子中心的像素坐标。注意这里用的是//整除因为第 0 列的中心是GRID_LEFT 45而不是GRID_LEFT 0。植物类的核心在于update()里的分支逻辑。向日葵每 7 秒产一个阳光豌豆射手只有在本行右侧存在僵尸时才开火这一点很重要否则会出现“植物对着空气无限射击”的滑稽画面。僵尸类则要判断前方有没有植物有就停下啃没有就继续往左走。# 文件路径entities.py import pygame import settings as S def cell_center(row, col): 把逻辑坐标行、列转换成格子中心的像素坐标。 x S.GRID_LEFT col * S.CELL_W S.CELL_W // 2 y S.GRID_TOP row * S.CELL_H S.CELL_H // 2 return x, y class Plant: 植物基类通过 kind 区分具体类型避免写三个重复的类。 def __init__(self, kind, row, col): self.kind kind self.row row self.col col cfg S.PLANT_CONFIG[kind] self.hp cfg[hp] self.max_hp cfg[hp] self.cost cfg[cost] self.cooldown cfg[cooldown] self.x, self.y cell_center(row, col) self.timer 0 self.alive True property def rect(self): return pygame.Rect(self.x - 30, self.y - 30, 60, 60) def update(self, dt, game): if not self.alive: return self.timer dt if self.kind sunflower: if self.timer S.SUNFLOWER_INTERVAL_MS: self.timer 0 game.spawn_sun(self.x, self.y - 10, from_skyFalse) elif self.kind peashooter: if game.row_has_zombie(self.row, self.x): if self.timer S.PEASHOOTER_INTERVAL_MS: self.timer 0 game.peas.append(Pea(self.row, self.x 25, self.y - 12)) else: # 没有目标时清空计时避免蓄力导致一有僵尸就瞬间连发 self.timer 0 def take_damage(self, dmg): self.hp - dmg if self.hp 0: self.alive False class Zombie: 僵尸有植物挡路就啃没植物就继续前进。 def __init__(self, kind, row, x): cfg S.ZOMBIE_CONFIG[kind] self.kind kind self.row row self.x float(x) self.y cell_center(row, 0)[1] self.hp cfg[hp] self.max_hp cfg[hp] self.speed cfg[speed] self.damage cfg[damage] self.eating None self.alive True property def rect(self): return pygame.Rect(int(self.x) - 25, self.y - 35, 50, 70) def update(self, dt, game): if not self.alive: return target game.plant_at_row_front(self.row, self.rect) if target is not None: # 啃食时停止移动按每秒伤害结算 self.eating target target.take_damage(self.damage * dt / 1000.0) if not target.alive: game.remove_plant(target) else: self.eating None self.x - self.speed * dt / 16.0 class Pea: 豌豆子弹向右飞行命中同行的第一只僵尸后消失。 def __init__(self, row, x, y): self.row row self.x float(x) self.y y self.damage S.PEA_DAMAGE self.speed S.PEA_SPEED self.alive True property def rect(self): return pygame.Rect(int(self.x) - 8, self.y - 8, 16, 16) def update(self, dt, game): self.x self.speed * dt / 16.0 if self.x S.SCREEN_W: self.alive False return for z in game.zombies: if self.row z.row and z.alive and self.rect.colliderect(z.rect): z.hp - self.damage if z.hp 0: z.alive False self.alive False return class Sun: 阳光可以从天而降也可以由向日葵原地产出点击后计入余额。 def __init__(self, x, y, fall_toNone, valueS.SUN_VALUE): self.x float(x) self.y float(y) self.fall_to fall_to self.value value self.life S.SUN_LIFETIME_MS self.alive True property def rect(self): return pygame.Rect(int(self.x) - 18, int(self.y) - 18, 36, 36) def update(self, dt): if self.fall_to is not None and self.y self.fall_to: self.y min(self.fall_to, self.y 1.2 * dt / 16.0) self.life - dt if self.life 0: self.alive False这段代码里有三个点容易被忽略但都是真实项目里会咬人的地方。第一个是dt / 16.0这个换算系数。16 毫秒大致对应 60 FPS 的每帧时长所以用speed * dt / 16.0就相当于“以 60 帧为基准的每秒实际位移”。如果你直接写self.x - self.speed那么游戏在高刷新率显示器上会明显变快。第二个是豌豆射手的计时器清零逻辑。如果不写else: self.timer 0植物会一直累加计时等到有僵尸出现的那一帧timer早就超过阈值了于是立刻发射看起来像“瞬发挂”。这类时序 bug 在游戏里非常常见光看代码很难发现必须跑起来感受。第三个是僵尸的伤害结算用了dt / 1000.0。因为damage定义的是每秒伤害而dt是毫秒所以必须先除以 1000 再乘。如果这里写成target.take_damage(self.damage * dt)僵尸一帧就能把坚果墙啃穿你会以为是血量配错了实际上是单位算错了。5.2 主循环层Game 类怎么组织main.py里的Game类承担四件事管理所有实体列表、处理输入、按dt更新、把当前状态画到屏幕上。这四个职责其实还可以再拆但对于一个练手项目来说放在一个类里更利于你对照着读。先看初始化和重置部分。reset()单独抽出来是为了让玩家按 R 键可以重开一局而不需要退出程序再启动。这一点在演示和答辩时特别好用——出了状况按一下 R比手忙脚乱关窗口体面得多。# 文件路径main.py import math import random import sys import pygame import settings as S from entities import Plant, Zombie, Pea, Sun def load_font(size): 按优先级找一个支持中文的系统字体找不到就退回默认字体。 candidates [ microsoftyahei, simhei, msyh, pingfangsc, notosanscjksc, wenquanyimicrohei, ] for name in candidates: path pygame.font.match_font(name) if path: return pygame.font.Font(path, size) return pygame.font.Font(None, size) class Game: def __init__(self): pygame.init() self.screen pygame.display.set_mode((S.SCREEN_W, S.SCREEN_H)) pygame.display.set_caption(Plants vs Zombies - Python Demo) self.clock pygame.time.Clock() self.font load_font(26) self.small_font load_font(15) self.reset() def reset(self): self.plants [] self.zombies [] self.peas [] self.suns [] self.sun_count S.SUN_START self.selected sunflower self.cooldown_left {k: 0 for k in S.PLANT_CONFIG} self.sky_sun_timer 0 self.spawn_timer 0 self.elapsed 0 self.kill_count 0 self.target_kill 10 self.game_over False self.win False事件处理部分要注意鼠标坐标的边界判断。玩家可能点在顶栏、点在左边的房子区域、点出屏幕外这些都不是合法的种植位置。pos_to_cell()返回(None, None)表示无效调用方必须处理这两种情况否则会出现负数行列导致的越界。def handle_events(self): for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() elif event.type pygame.KEYDOWN: if event.key pygame.K_ESCAPE: pygame.quit() sys.exit() elif event.key pygame.K_r: self.reset() elif event.key pygame.K_1: self.selected sunflower elif event.key pygame.K_2: self.selected peashooter elif event.key pygame.K_3: self.selected wallnut elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: self.on_click(event.pos) def pos_to_cell(self, pos): x, y pos if x S.GRID_LEFT or y S.GRID_TOP: return None, None col (x - S.GRID_LEFT) // S.CELL_W row (y - S.GRID_TOP) // S.CELL_H if row S.GRID_ROWS or col S.GRID_COLS: return None, None return int(row), int(col) def on_click(self, pos): if self.game_over or self.win: return # 优先级一先判断有没有点到阳光 for s in reversed(self.suns): if s.alive and s.rect.collidepoint(pos): s.alive False self.sun_count s.value return # 优先级二再判断是不是要种植物 row, col self.pos_to_cell(pos) if row is None: return if any(p.row row and p.col col for p in self.plants): return cfg S.PLANT_CONFIG[self.selected] if self.cooldown_left[self.selected] 0: return if self.sun_count cfg[cost]: return self.sun_count - cfg[cost] self.cooldown_left[self.selected] cfg[cooldown] self.plants.append(Plant(self.selected, row, col))注意on_click里两个return的顺序。阳光必须优先于种植否则当阳光恰好落在格子上时玩家怎么点都会种植物而收不到阳光。这是一个体验细节但它体现的是“输入优先级”这个概念——同一份输入可能命中多个对象你必须显式定义谁先响应。更新阶段是整个游戏的心脏。所有实体的update()按顺序调用然后统一过滤掉死亡对象最后做胜负判定。def update(self, dt): if self.game_over or self.win: return self.elapsed dt for k in self.cooldown_left: if self.cooldown_left[k] 0: self.cooldown_left[k] max(0, self.cooldown_left[k] - dt) # 天空自然掉落阳光 self.sky_sun_timer dt if self.sky_sun_timer S.SUN_INTERVAL_MS: self.sky_sun_timer 0 x random.randint( S.GRID_LEFT 40, S.GRID_LEFT S.GRID_COLS * S.CELL_W - 40, ) self.spawn_sun(x, S.GRID_TOP - 20, from_skyTrue) # 出怪随着时间推移逐渐加快 self.spawn_timer dt interval max( S.SPAWN_MIN_MS, S.SPAWN_INTERVAL_MS - (self.elapsed // S.SPAWN_ACCEL_STEP_MS) * S.SPAWN_ACCEL_VALUE_MS, ) if self.spawn_timer interval: self.spawn_timer 0 self.spawn_zombie() for p in self.plants: p.update(dt, self) for z in self.zombies: z.update(dt, self) for pea in self.peas: pea.update(dt, self) for s in self.suns: s.update(dt) # 先统计死亡再统一清理避免遍历中修改列表 dead_zombies [z for z in self.zombies if not z.alive] self.kill_count len(dead_zombies) self.plants [p for p in self.plants if p.alive] self.zombies [z for z in self.zombies if z.alive] self.peas [p for p in self.peas if p.alive] self.suns [s for s in self.suns if s.alive] for z in self.zombies: if z.x S.GRID_LEFT - 30: self.game_over True break if self.kill_count self.target_kill and not self.zombies: self.win True配套的辅助方法不复杂但每一个都有明确的职责写在Game里比散落在各处要好。def spawn_zombie(self): row random.randint(0, S.GRID_ROWS - 1) kind cone if random.random() 0.3 else normal self.zombies.append(Zombie(kind, row, S.SCREEN_W - 20)) def spawn_sun(self, x, y, from_skyTrue): if from_sky: target random.randint( S.GRID_TOP 60, S.GRID_TOP S.GRID_ROWS * S.CELL_H - 40, ) self.suns.append(Sun(x, y, fall_totarget)) else: self.suns.append(Sun(x, y, fall_toNone)) def row_has_zombie(self, row, x): return any(z.row row and z.alive and z.x x for z in self.zombies) def plant_at_row_front(self, row, rect): 返回僵尸正前方、与之发生矩形碰撞的植物多个时取最靠右的。 best None for p in self.plants: if p.row ! row or not p.alive: continue if rect.colliderect(p.rect): if best is None or p.x best.x: best p return best def remove_plant(self, plant): plant.alive Falseplant_at_row_front里“取最靠右的那个”这个细节很关键。理论上同一格只能种一棵植物不会出现重叠。但如果你以后扩展成“一格可种多棵”或者加了子弹弹道偏移就可能出现多个候选此时选最右侧的那个才符合“僵尸先啃最靠近它的植物”的直觉。6. 完整代码与运行验证6.1 绘制部分用几何图形代替素材绘制代码看着长但套路固定先铺背景再画网格再画实体的图形最后画状态栏。这里用不同的几何图形区分植物类型——向日葵是花瓣加圆盘豌豆射手是圆形加炮管坚果墙是椭圆。僵尸是矩形身体加一个圆形头铁桶僵尸头顶再加个梯形。def draw(self): self.screen.fill(S.COLOR_BG) self.draw_grid() self.draw_topbar() for s in self.suns: self.draw_sun(s) for p in self.plants: self.draw_plant(p) for z in self.zombies: self.draw_zombie(z) for pea in self.peas: pygame.draw.circle(self.screen, S.COLOR_PEA, (int(pea.x), pea.y), 8) if self.game_over: self.draw_banner(GAME OVER press R to restart, (225, 80, 80)) elif self.win: self.draw_banner(YOU WIN! press R to restart, (110, 220, 130)) pygame.display.flip() def draw_grid(self): for r in range(S.GRID_ROWS): for c in range(S.GRID_COLS): rect pygame.Rect( S.GRID_LEFT c * S.CELL_W, S.GRID_TOP r * S.CELL_H, S.CELL_W, S.CELL_H, ) pygame.draw.rect(self.screen, S.COLOR_GR
阅读完成 · 觉得有帮助?