简介:一份基于C++实现的植物大战僵尸模型与完整工程代码,适合C++初学者和游戏开发爱好者学习。压缩包共109个文件,大小约15.82MB,包含Visual Studio工程文件(sln/vcxproj)、C++源码、编译生成的exe可执行程序,以及调试所需的pdb、obj等中间文件;同时附有游戏运行所需的mp3音频、金币阳光等素材,完整覆盖从源码到可运行程序的链路。目前已有5879人学习下载,热度可见一斑。这份资源能帮助读者直观理解面向对象设计、游戏主循环、碰撞检测、资源管理等核心知识点;通过阅读源码并运行exe对比实际效果,可快速掌握塔防游戏的基础实现思路。对于需要课程设计或入门游戏开发练手的读者,是相当实用的参考工程。
1. 用C++复刻植物大战僵尸:模型和代码的核心边界
我刚开始练C++的时候,也想过做一个植物大战僵尸的完整游戏。结果贴图、音效、动画一上来,代码就乱了,最后连一个豌豆射手都打不死僵尸。后来我才想明白,最值钱的部分不是画面,而是背后的模型:植物什么时候射击,子弹往哪飞,僵尸怎么扣血,冷却怎么计时。这套东西如果立不住,换再好看的素材也是白搭。
这篇笔记不讲怎么用SDL渲染一棵向日葵,而是专注于“模型和代码”本身——用C++把植物大战僵尸的核心战斗逻辑拆开,让你在不依赖图形库的情况下,先把可运行的逻辑骨架跑起来。你会看到一个能直接编译的最小战斗模拟,也会看到每个关键参数该怎么调,以及我踩过的五个坑。适合已经学过C++语法、想做个像样项目的新手,也适合拿来当课程设计的第一版原型。
2. 先搭对象模型:植物、僵尸、子弹的类设计与数据流
2.1 基类与继承:让植物和僵尸共享“战斗单位”的接口
写任何游戏逻辑之前,先把战斗单位抽象出来。植物和僵尸虽然行为完全不一样,但它们都有行、列、生命值、攻击伤害这四样东西。所以我设计了一个Entity基类,再用Peashooter和Zombie去继承它。这样做的好处是,你可以把植物和僵尸放在同一个容器里,统一用基类指针调用update,后面做范围判定、死亡清理都会简单很多。
class Entity { public: Entity(int row, int col, int hp, int damage) : row(row), col(col), hp(hp), maxHp(hp), attackDamage(damage) {} virtual ~Entity() = default; // 每帧更新,具体行为由子类决定 virtual void update(double dt) = 0; bool alive() const { return hp > 0; } int row; int col; int hp; int maxHp; int attackDamage; }; class Peashooter : public Entity { public: Peashooter(int row, int col) : Entity(row, col, 100, 20) {} void update(double dt) override { // 射击计时逻辑写在这里 } }; class Zombie : public Entity { public: Zombie(int row, int col) : Entity(row, col, 100, 10) {} void update(double dt) override { // 向左移动,走到防线就攻击植物 col -= 1; } };这个例子里,坐标先用int是因为网格是离散的。但现实中的僵尸移动需要一个连续位置,不然动画会一顿一顿。所以实际项目里,我会把row保持为整数表示“在第几行”,另外加一个double x表示“当前水平位置”。这两个字段不要合并成一个,否则后面子弹碰撞判定会非常别扭。
为什么用纯虚函数?因为植物和僵尸的update确实不同。你不需要担心虚函数性能,这个规模下开销可以忽略,先把结构做对,再去想优化。这里还有一个容易忽略的点:析构函数要声明成virtual,这样通过基类指针delete派生对象时,才不会只析构一半。
2.2 网格地图的选型:二维数组还是vector?
地图是5行9列,新手第一反应是写一个二维数组。但对于一个会持续增删对象的游戏,二维数组删除元素很麻烦,你只能把某个格子标记成空位。所以我更推荐用vector嵌套,每个格子放一个unique_ptr,空位就是nullptr:
// 5行9列,每个格子放一个unique_ptr,空位是nullptr std::vector<std::vector<std::unique_ptr<Entity>>> grid( 5, std::vector<std::unique_ptr<Entity>>(9));这里有一个很重要的设计决定:僵尸是移动单位,不适合被绑死在网格里。如果你把僵尸也塞进grid,那么每一帧移动后都要重新计算它属于哪个格子,还要担心两个单位挤在同一格,纯属给自己找麻烦。
我一般把场景拆成两层:静态单位(植物)放进grid,动态单位(僵尸)按行管理。僵尸身上保留row字段,表示它在哪条横线上运动,x字段表示它在横线上的具体位置。子弹、僵尸移动、攻击判定都发生在这套数据结构上。
std::vector<std::vector<std::unique_ptr<Entity>>> plantGrid; // 只放植物 std::vector<std::vector<Zombie>> zombiesByRow; // 每行一个僵尸列表这种选型的最大好处在战斗判定:子弹只检查自己所在行的僵尸列表,不用全图遍历。当一张地图上有几十个僵尸时,这个差异非常明显。
2.3 游戏主循环:用固定时间步长把逻辑和渲染分开
新手写游戏循环通常是while (true) { update(); render(); },然后Sleep(16)。这个写法在低帧率电脑上逻辑变慢,在高帧率电脑上逻辑变快,同一个代码在不同机器上难度完全不同。植物大战僵尸这种塔防游戏,玩家对时间流逝极敏感,僵尸什么时候走到防线必须稳定。
所以我在项目里用的是固定时间步长累加器:
#include <chrono> double fixedTimeStep = 1.0 / 60.0; // 每秒60次逻辑更新 double accumulator = 0.0; auto lastTime = std::chrono::steady_clock::now(); while (running) { auto now = std::chrono::steady_clock::now(); accumulator += std::chrono::duration<double>(now - lastTime).count(); lastTime = now; while (accumulator >= fixedTimeStep) { update(fixedTimeStep); accumulator -= fixedTimeStep; } render(); }这里update收到的dt永远是1/60秒,跟屏幕刷新率是60Hz还是144Hz无关。如果某帧卡了一下,accumulator会累积多个fixedTimeStep,下一帧会补上之前欠的逻辑更新。子弹速度、植物冷却、僵尸移动都基于这个dt,所以只要dt固定,逻辑就是一致且可复现的。
如果你直接把真实帧间隔当dt,一旦渲染掉帧,子弹单帧位移变大,就可能从僵尸身上穿过去。第5章会重点讲这个翻车现场,主循环这里就是第一道防线。
3. 关键机制实现:阳光生产、攻击判定与子弹飞行
3.1 阳光的生成与收集:定时器与碰撞盒
阳光是植物大战僵尸的资源循环核心。向日葵每隔一段时间生成一个阳光,阳光掉落一段时间后可以被收集。这个机制本身不复杂,但有两个点容易写乱:生成计时和收集判定。
我习惯把阳光定义成一个独立结构体,因为它既不是植物也不是僵尸,只是一个飘落的“资源物件”:
struct Sun { double x, y; // 当前坐标 double fallSpeed; // 每秒下落多少格 double lifeTime; // 剩余存活时间 bool collected; // 是否已被收集 }; std::vector<Sun> suns;生成逻辑直接写在向日葵的update里,用local timer累加dt。到时间了就push一个Sun,然后重置计时器:
sunTimer += dt; if (sunTimer >= sunInterval) { sunTimer -= sunInterval; suns.push_back({col, row, 1.2, 8.0, false}); }这里的参数含义是:sunInterval是生成间隔,通常8秒一个;1.2是下落速度,代表每秒移动1.2格;8.0是存活时间,超过8秒还没被收集的阳光会消失。这三个值直接影响玩家前期能否顺利种下第一株植物,建议先用保守值测试。
收集判定最简单的做法是:给每个阳光一个圆形碰撞盒,半径0.4格。玩家点击时,计算出点击坐标和当前阳光圆心的距离,小于半径就算收集成功。如果你要做自动收集,可以把碰撞盒从圆形改成矩形,但要注意地图坐标和鼠标屏幕坐标之间的转换,这一步漏掉的话,你会发现点哪里都收集不到阳光。
3.2 攻击判定:用“行”做简化,避免像素级碰撞
豌豆子弹是直线飞行的,它只会命中同一行里的僵尸。所以我做碰撞检测时,不会一上来就计算子弹矩形与僵尸矩形的相交,而是先判断行号是否一致,再判断x坐标是否落在僵尸的横向范围内。
for (auto it = bullets.begin(); it != bullets.end();) { it->x += it->speed * dt; bool bulletHit = false; for (auto& zombie : zombiesByRow[it->row]) { if (it->x >= zombie.x - 0.3 && it->x <= zombie.x + 0.3) { zombie.hp -= it->damage; bulletHit = true; break; } } if (bulletHit || it->x >= 9.0) { it = bullets.erase(it); } else { ++it; } }这里最关键的参数是0.3,这是僵尸的“有效碰撞半宽”。僵尸实际图片宽度可能超过1格,但战斗判定不需要那么精确,给一个范围就够了。注意我用的是区间判断,而不是等号判断。浮点数经过几十帧累加后,坐标会变成0.30000000000000004这种诡异值,用等号判断永远都不会触发。
这套判定方式明显做了简化:它假设子弹是一个点,僵尸是一段宽度。如果你要支持多个僵尸,需要遍历整行;如果每行僵尸数量很多,可以用双端队列或者按x排序来优化。但一个原型阶段,能跑通比跑得快重要。
3.3 植物冷却:用帧计数还是时间戳?
种植冷却这个功能,新手最容易写成int counter,每帧加一,加到100就允许种。这个做法在固定时间步长下没问题,但一旦你改了主循环,或者想实现暂停、倍速,计数器就会出问题。我的做法是直接用游戏时间戳:
double lastPlantTime = 0.0; const double plantCooldown = 5.0; // 在玩家点击格子时: if (gameTime - lastPlantTime >= plantCooldown) { // 允许种植 lastPlantTime = gameTime; } else { // 提示“还在冷却中” }这里的gameTime不是std::chrono::system_clock::now(),而是update里持续累加的全局逻辑时间。为什么不用真实时钟?因为游戏要支持暂停、加速、减速,真实时钟不会听你的。统一用逻辑时间,所有冷却和持续效果都基于它,才能保证可复现。
时间戳方案还有一个额外好处:它天生支持“剩余冷却时间”的UI展示,你只需要算一格gameTime - lastPlantTime就行。计数器方案要再乘dt才能换算成秒,多一道弯。
4. 把模型跑起来:最小可用的C++战斗模拟与控制台运行
4.1 一个能直接编译的最小战斗模拟
有了前面的对象设计,现在写一个极简但完整的战斗模拟:地图只有一行,第0行放一个豌豆射手,一个僵尸从右侧走过来。豌豆射手每隔1.5秒发射一颗子弹,子弹以每秒3格的速度向右飞。僵尸以每秒0.5格的速度向左走。谁先死谁输。
#include <iostream> #include <vector> #include <chrono> #include <thread> struct Bullet { int row; double x; double speed; int damage; }; struct Zombie { int row; double x; double hp; double speed; }; struct Plant { int row; int col; double shootTimer; double shootInterval; int damage; double bulletSpeed; }; int main() { Plant plant{0, 2, 0.0, 1.5, 20, 3.0}; Zombie zombie{0, 8.0, 100.0, 0.5}; std::vector<Bullet> bullets; double dt = 1.0 / 60.0; double totalTime = 0.0; while (totalTime < 30.0) { // 植物射击:累积时间,到间隔就发射 plant.shootTimer += dt; if (plant.shootTimer >= plant.shootInterval) { plant.shootTimer -= plant.shootInterval; bullets.push_back({plant.row, static_cast<double>(plant.col), plant.bulletSpeed, plant.damage}); } // 子弹移动与碰撞 for (auto it = bullets.begin(); it != bullets.end();) { it->x += it->speed * dt; bool hit = false; if (it->row == zombie.row && it->x >= zombie.x - 0.3 && it->x <= zombie.x + 0.3) { zombie.hp -= it->damage; hit = true; } if (hit || it->x >= 9.0) { it = bullets.erase(it); } else { ++it; } } // 僵尸移动 zombie.x -= zombie.speed * dt; // 打印每帧状态 std::cout << "t=" << totalTime << " zombie_x=" << zombie.x << " hp=" << zombie.hp << " bullets=" << bullets.size() << "\n"; if (zombie.hp <= 0) { std::cout << "Win at " << totalTime << "s\n"; break; } if (zombie.x <= 0) { std::cout << "Lose at " << totalTime << "s\n"; break; } totalTime += dt; std::this_thread::sleep_for(std::chrono::milliseconds(16)); } return 0; }这段代码可以直接复制编译运行。你会看到僵尸的血量随着子弹命中而降低,最终输出Win。关键参数说明:
- shootInterval = 1.5秒:本次射击间隔。1.5秒一发,初始节奏偏慢但很稳。
- bulletSpeed = 3.0格/秒:子弹飞行速度。每帧位移 = 3 * 1/60 = 0.05格,碰撞宽度0.3格,所以不会跳过。
- zombie.speed = 0.5格/秒:僵尸移动速度。从8格走到0格需要16秒,足够射手输出。
- zombie.hp = 100,damage = 20:5发子弹打死僵尸。射手7.5秒内打死,僵尸16秒才到,所以必然胜利。
注意代码里用的是erase移动迭代器的方式,这在逻辑上没问题。实际项目里,如果你担心频繁erase造成内存移动,可以换成“标记待删除”再统一清理。但对于模型讲解,这种写法最直观。
4.2 从控制台到图形界面:改哪些地方
这个控制台模拟已经跑通了模型层,接下来要换成SDL或raylib时,你只需要替换main函数里的输出和sleep部分。把std::cout换成SDL_RenderDrawRect,把sleep换成SDL_Delay,update函数和碰撞检测逻辑可以完整保留。
最常见的错误是有人一上来就开SDL窗口,然后把植物和僵尸的位置全部用SDL_Rect表示,导致逻辑层和渲染层完全耦合。正确做法是:逻辑层永远用行列和浮点坐标,渲染时才根据格子宽高计算出屏幕像素坐标。
我一般会把代码拆成model.h存放Entity、Plant、Zombie、Bullet,game.cpp存放update,render.cpp存放图形绘制。后面做图形界面,只需要换render.cpp。如果你一开始就按这个结构写,后面加音效、动画都会轻松很多。
4.3 参数表:先定这几个数值再谈平衡性
在动关卡编辑器之前,先把核心数值集中定义,方便调。这里是我常用的初始值表格:
| 单位 | 属性 | 初始值 | 备注 |
|---|---|---|---|
| 豌豆射手 | 生命值 | 100 | 被普通僵尸啃10口 |
| 豌豆射手 | 射击间隔 | 1.5秒 | 每次攻击生成一枚子弹 |
| 豌豆射手 | 子弹伤害 | 20 | 5发击杀普通僵尸 |
| 子弹 | 飞行速度 | 3格/秒 | 到达地图边界后消失 |
| 普通僵尸 | 生命值 | 100 | 3发豌豆后可被坚果辅助击杀 |
| 普通僵尸 | 移动速度 | 0.5格/秒 | 16秒从右走到左 |
| 向日葵 | 阳光间隔 | 8秒 | 每次生成25阳光 |
| 阳光 | 下落速度 | 1.2格/秒 | 存活8秒后消失 |
这套参数不代表原版数值,只是我比较顺手的起点。调整时遵循一个原则:一次只改一个参数,跑一次自动战斗测试,看胜率变化,再改下一个。一次改三个数字,最后你根本不知道是谁影响了结果。
5. 避坑/常见问题:C++实现植物大战僵尸时最容易翻车的5个点
5.1 内存管理:new出来的植物和僵尸迟迟不释放
现象:游戏运行时间变长,内存占用持续上升,最后卡死。
原因:很多人习惯用new Zombie()创建僵尸,然后放在std::vector<Zombie*>里。当僵尸死亡,只调用了erase,没有delete。更危险的是erase之后,那个指针失去了索引,想delete都找不到。
解决:不要用裸指针。用std::unique_ptr或直接存对象值。第4章的例子就把子弹定义成普通结构体,放进std::vector<Bullet>,析构自动完成。植物网格里用std::unique_ptr<Entity>,即使你忘了delete,对象离开作用域也会被自动清理。血泪教训:用裸指针做游戏原型,最终都会在内存泄漏排查上耗掉大量时间。
5.2 更新与渲染耦合:帧率变高后子弹直接穿透
现象:把笔记本插上电源,帧率从60变成144,子弹反而打不中僵尸,直接穿过去。
原因:主循环用的是真实帧间隔当dt。显示器刷新率越高,单帧时间越短,但逻辑更新次数变多,子弹每次移动距离不变?不对,如果你直接拿真实间隔当dt,当帧率从60变成144,dt从1/60变成了1/144,子弹移动距离变小,按理说更容易命中。那为什么会穿透?问题反了,常见的是low帧率,比如渲染一帧要50ms,dt变成0.05,子弹移动0.15格,如果碰撞宽度0.3还勉强,但如果你把子弹速度调到30格/秒,单帧移动1.5格,子弹就跨过僵尸了。
解决:用固定时间步长累加器,dt恒定为1/60秒。这样子弹单帧位移永远是0.05格,无论屏幕多卡,逻辑都不会跳变。这个坑在第2.3节已经埋了伏笔,但很多人在做完控制台版之后,觉得固定步长啰嗦就删掉了,等到换电脑跑就被打脸。
5.3 硬编码数字:植物属性改起来像玄学
现象:你把豌豆射手伤害从20改成30,结果发现关卡变难了,僵尸走位还更飘了。
原因:代码里到处是魔法数字,比如if (zombie.hp -= 20),你忘了这个20是伤害还是阳光扣费。调参时改了一处,另一处没同步,数值之间互相矛盾。
解决:在程序开头定义常量,或者干脆用JSON配置。比如:
constexpr int PEASHOOTER_DAMAGE = 20; constexpr int PEASHOOTER_INTERVAL = 1.5; constexpr int ZOMBIE_HP = 100;这样每个数字都有名字,调整时只要打开一个头文件或JSON文件。第6章会写怎么用JSON驱动属性,到时候连重新编译都省了。
5.4 浮点数比较:子弹到达检测在关键时刻失灵
现象:子弹明明飞到了僵尸脸上,画面都重叠了,僵尸就是不掉血。
原因:使用了if (bullet.x == zombie.x)。浮点数是二进制近似,0.1 + 0.2算出来不是0.3,而是0.30000000000000004。多个帧累加后,坐标很难刚好相等。
解决:用区间判断,比如fabs(bullet.x - zombie.x) < 0.3,或者用第4章代码里的>= zombie.x - 0.3 && <= zombie.x + 0.3。这个区间同时充当了碰撞宽度,一石二鸟。记住:游戏碰撞检测里永远不要写等号。
5.5 控制台中文乱码:植物名字输出锟斤拷
现象:代码里写了“豌豆射手”,控制台却显示“銆婄帺銆”或“锟斤拷”。
原因:Windows控制台默认代码页是GBK,而源文件用VSCode保存成了UTF-8,两者不一致。这个问题在Linux终端上不明显,在Windows下特别常见。
解决:最简单的是输出英文名,比如Peashooter和Zombie;如果想显示中文,在main开头调用system("chcp 65001"),并把源文件保存为UTF-8 with BOM;或者用宽字符wcout和L"豌豆射手"。我个人的习惯是逻辑层用英文标识,中文留给UI显示,这样最小化编码风险。
6. 进阶:用JSON配置驱动植物属性,并用自动战斗验证平衡性
6.1 用JSON配置文件替代魔法常量
当植物和僵尸种类多起来,代码里的常量会变成一团乱麻。此时把属性挪到外部文件是必然选择。我通常用nlohmann/json这个单头文件库,加载方式非常干净:
#include "json.hpp" #include <fstream> using json = nlohmann::json; json config = json::parse(std::ifstream("plants.json")); struct PlantConfig { int hp; double shootInterval; int damage; }; PlantConfig peashooter { config["peashooter"]["hp"], config["peashooter"]["shoot_interval"], config["peashooter"]["damage"] };对应的JSON文件是这样的:
{ "peashooter": { "hp": 100, "shoot_interval": 1.5, "damage": 20 } }这样调整属性就变成了改文档,不用重写C++逻辑。尤其是做关卡设计时,你可以快速试出“僵尸血量150”到底好不好玩,而不需要反复编译。
6.2 验证平衡性:写一个自动战斗测试
怎么判断一套参数是“数值合理”还是“数值失控”?我通常会让两个单位在无干预下模拟1000局,统计胜率。把第4章的while循环抽成一个函数:
bool runOneBattle(const PlantConfig& p, const ZombieConfig& z) { // 这里是第4章的完整战斗逻辑,返回植物是否胜利 } void validateBalance() { int win = 0; const int rounds = 1000; for (int i = 0; i < rounds; ++i) { if (runOneBattle(peashooter, normalZombie)) ++win; } double winRate = static_cast<double>(win) / rounds; std::cout << "win rate : " << winRate << "\n"; }正常来说,一个豌豆射手对普通僵尸的胜率应该是100%,因为僵尸还没走到就死了。如果你把僵尸血量调到500,胜率会变成0%。这个测试就是你要的“后悔药”:在投入美术资源之前,先用数据决定数值大方向,而不是靠手感拍脑袋。
6.3 我的习惯:先写日志再调参数
最后分享一个血泪经验:调参前一定要打日志。之前我写植物大战僵尸模型时,没有日志,全靠猜,结果把豌豆射手射击间隔误设成0.01秒,每帧发射几十颗子弹,程序卡死还不知道原因。后来养成了习惯:每次update只打印关键事件,比如“bullet hit zombie for 20 damage”,而不是每帧打印坐标。这样跑一轮下来,你能清楚看到伤害发生在哪个时间点,也能判断是子弹太快没检测到,还是坐标错位。
我现在改任何一个属性,都会先跑一遍自动战斗测试,再看日志,最后才动数值。这套流程花不了几分钟,但能让你少熬夜。希望帮到你。
本文还有配套的精品资源,点击获取