简介:基于C++实现的植物大战僵尸模型及完整工程代码,面向C++初学者、游戏开发爱好者与经典塔防玩法研究者,帮助理解游戏对象抽象、交互逻辑与代码组织方式。资源包为7z压缩格式,约15.82MB,共109个文件,涵盖.cpp源文件、可直接运行的.exe程序、.vcxproj工程配置、.pdb调试符号、.obj中间文件以及MP3音频和植物/僵尸/阳光等素材文件,方便对照代码与运行效果进行学习。已有5879人学习下载,反映出该案例在讲解类设计、游戏循环、碰撞检测等知识点上的实用价值。代码从基础Entity类派生出Plant、Zombie等子类,并围绕SDL/SFML图形界面、矩形碰撞检测、资源管理、多线程渲染等C++关键技术展开实现。读者可先用exe体验游戏,再逐段阅读源码追踪植物与僵尸的交互流程,也可借助调试工程修改参数,观察不同逻辑对关卡的影响,从而系统掌握C++游戏的开发与调试思路。
1. 用 C++ 复刻植物大战僵尸:模型先立住,代码才写得顺
看到“c++的植物大战僵尸模型以及代码”这个标题,别先想成几千行的图形大作。我把这类 C++ 小游戏完整写过一遍后,结论很直接:一套能玩的 PVZ 战斗模型,核心逻辑三百行上下就能跑起来,难点不在某个炫技算法,而在于植物、僵尸、子弹、阳光这四个对象怎么分工,网格坐标和像素坐标怎么互不干扰。当成 C++ 入门后的第一个完整项目,比刷语法题更能把类、容器、游戏循环和随机数串成体系。接下来按模型、代码、编译、排错的顺序展开。
2. 战斗模型怎么立:网格、对象分工与固定时间步
模型不是一张 UML 图,而是代码里真实存在的数据结构、调用约定和数值参数。在做任何功能之前,先把“游戏世界有哪些东西、它们之间怎么交互”定义清楚,后面每写一个类都是往这套骨架上填肉。
2.1 四个核心对象的分工:植物、僵尸、子弹、阳光
经典 PVZ 战斗循环可以压缩成四句话:植物消耗阳光被种进格子;向日葵周期性产阳光,豌豆射手周期性发射子弹;僵尸从右侧出现并向左移动,遇到植物开始啃;子弹命中僵尸扣血,血量归零后僵尸死亡,僵尸走到最左侧则游戏失败。
围绕这几句话,对象划分如下:
| 对象 | 核心职责 | 关键字段 | 生命周期 |
|---|---|---|---|
| Plant | 占用格子、产阳光或射击 | hp、timer、type | 被僵尸啃死或被铲掉 |
| Zombie | 向左移动、啃植物 | hp、speed、state | 被子弹打死或走到终点 |
| Bullet | 沿行方向飞行、命中扣血 | x、speed、damage | 命中后消失或飞出屏幕 |
| Sun | 下落、等待收集 | x、y、timer | 被收集或超时消失 |
这四个对象有一件事完全一致:每一帧都需要被更新。所以常见做法是抽一个 GameObject 基类,把“逐帧更新”统一成虚函数,派生类各自维护内部状态。游戏主循环只维护一个对象容器,每帧遍历调用 update,而不是在外部堆一堆 if 分支去判断对象类型。
我一般会在这一步顺手把对象的“死亡”也定义清楚。GameObject 里留一个 isDead() 虚函数,容器遍历到死亡对象后延迟到帧末统一清理。这样遍历过程中不会出现“删除元素导致迭代器失效”这类让新手头疼的崩溃,也为后面做对象池留了接口。
2.2 网格坐标与像素坐标:二维数组管格子,vector 管实体
PVZ 的战斗区本质上是一张网格,经典布局是 5 行 9 列。种植物时只需要关心行列下标,判断“这一格能不能种”用二维数组最直观;而僵尸、子弹、阳光是在网格之间连续移动的,必须用像素坐标。
网格下标和像素坐标的换算是整个项目里最容易被写乱的地方。我的约定是:格子物理尺寸 100×80 像素,战斗区左上角留出 80 像素放顶部 UI 和阳光栏,所以第 (row, col) 格的左上角像素坐标是 (80 + col * 100, 120 + row * 80)。换算函数单独放一个头文件,避免每个类各写一套魔法数字。
struct GridPos { int row; int col; }; // 战场格子:存指针,nullptr 表示空格 std::vector<std::vector<Plant*>> grid(5, std::vector<Plant*>(9, nullptr)); // 格子坐标 -> 像素坐标,返回格子左上角 float gridToPixelX(int col) { return 80.f + col * 100.f; } float gridToPixelY(int row) { return 120.f + row * 80.f; }给植物设置显示位置时,用左上角坐标加上格子宽高的一半,就能让植物中心落在格子中心。后面子弹的出生点、阳光的落点都通过这套函数换算,不再手写坐标。几个常量的含义要写清楚:80 和 120 是界面偏移,100 和 80 是格子宽高。如果以后换美术素材,只需要改这四个值,战斗逻辑一行都不用动。
2.3 固定时间步游戏循环:别让高刷显示器改变游戏难度
游戏循环是 C++ 小游戏绕不开的骨架。如果直接按“每渲染一帧就更新一次逻辑”,在 60Hz 和 144Hz 显示器上,僵尸移动速度会差 2.4 倍,游戏难度被硬件改变,这是设计事故。
我一般用固定时间步:逻辑按固定时长推进,渲染尽量跑满刷新率。这样无论显示器是多少刷新率,游戏里的一秒永远是逻辑意义上的一秒。
const float STEP_MS = 16.0f; // 逻辑步长约 60 FPS 节奏 float accumulator = 0.f; auto last = std::chrono::steady_clock::now(); while (running) { auto now = std::chrono::steady_clock::now(); accumulator += std::chrono::duration<float, std::milli>(now - last).count(); last = now; if (accumulator > 250.f) accumulator = 250.f; // 防止死亡螺旋 while (accumulator >= STEP_MS) { update(STEP_MS / 1000.f); // 逻辑步进,传入秒 accumulator -= STEP_MS; } render(); }这里有个容易被忽略的参数:250 毫秒的钳制。当电脑卡顿超过一定时间再恢复时,如果不钳制,积压的步进会让逻辑瞬间追很多帧,游戏像开了加速一样跳动。render() 不推进任何逻辑,只负责绘制当前状态。这样调试时单独把 update 跑一遍就能复现战斗结果,不依赖画面表现。
提示:固定步长下的 update(dt) 是所有计时的唯一来源,渲染、输入、暂停都不允许直接推进游戏时间。
3. 核心机制落地:继承、碰撞检测与随机数
模型定好后,这一章把核心机制写成可以直接编译的 C++ 代码。代码结构按类划分,每个类只负责一件事,这是整个方案里性价比最高的设计决策。
3.1 对象模型:基类只管生命周期,派生类管具体行为
GameObject 基类只做三件事:提供虚函数 update、提供虚函数 isDead、维护死亡标记。具体血量、速度、行为全放到派生类里。这样主循环面对的是统一的对象接口,以后新增植物类型不需要改动循环逻辑。
class GameObject { public: virtual ~GameObject() = default; virtual void update(float dt) = 0; // 每帧逻辑 virtual bool isDead() const { return dead; } protected: bool dead = false; }; class Plant : public GameObject { public: Plant(float x, float y, PlantType type) : posX(x), posY(y), type(type) {} void update(float dt) override; private: float posX, posY; float timer = 0.f; float hp = 300.f; // 普通植物血量 PlantType type; // 向日葵 / 豌豆射手 / 坚果墙 }; class Zombie : public GameObject { public: explicit Zombie(float spawnY) : posY(spawnY) {} void update(float dt) override; private: float posX = 900.f; // 右侧出生 float posY; float hp = 200.f; // 普通僵尸血量 float speed = 12.f; // 每秒移动像素数 bool attacking = false; };这段代码里的参数都有讲究:hp 用浮点,方便后面做持续伤害类型;speed 单位是“像素/秒”而不是“像素/帧”,这样 update 的 dt 才有意义;posX 初始值 900 对应出屏外,实际值应该和窗口宽度保持一致,别在代码里出现第二份。
Plant::update 里用一个 timer 做周期性动作,这是最简单的节奏控制方式。向日葵 7 秒产一个阳光、豌豆射手 1.4 秒打一发的配比接近原版手感,直接抄这个数值就能有接近原版的资源节奏。注意 timer 累加的是 dt,不是帧数,否则暂停和低帧率下节奏全乱。
void Plant::update(float dt) { timer += dt; if (type == SUNFLOWER && timer >= 7.0f) { spawnSun(posX, posY - 30.f); timer = 0.f; } else if (type == PEASHOOTER && timer >= 1.4f) { spawnBullet(posX + 50.f, posY - 20.f); timer = 0.f; } } void Zombie::update(float dt) { if (attacking) return; // 啃植物时原地站立 posX -= speed * dt; if (posX <= 0.f) { gameOver(); // 走到最左侧,游戏失败 } }僵尸啃植物的攻击不在这两个 update 里写死,而是放到碰撞检测部分统一处理,避免僵尸既要检测植物、又要检测子弹,逻辑交叉成网状。游戏主循环把各对象 update 串起来:
void Game::update(float dt) { for (auto& p : plants) p->update(dt); for (auto& z : zombies) z->update(dt); for (auto& b : bullets) b->update(dt); collisions(); // 统一处理子弹命中、僵尸啃植物 cleanDeadObjects(); // 延迟清理死亡对象 checkWave(); // 检查是否进入下一波 }3.2 AABB 碰撞检测:子弹为什么不会漏判
子弹命中僵尸的判断不需要做像素级检测。所有对象用矩形包围盒描述,两个矩形重叠就算命中,这就是 AABB 碰撞。实现只有一行逻辑,但选型理由要说清楚:PVZ 的豌豆和僵尸都是近似直立矩形,AABB 的误差完全可以接受;换成圆形包围盒反而要在行列方向上多绕一圈。
struct AABB { float x, y, w, h; }; bool hit(const AABB& a, const AABB& b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; }子弹的包围盒取 14×14,僵尸的取 60×90,都以对象中心为基准计算矩形角点。豌豆每帧移动约 5 像素,远小于子弹和僵尸的宽度,所以每帧检测一次不会穿透。如果以后加一种高速弹(比如一帧位移超过 74 像素),就必须做两次检测之间的扫掠,否则会跨帧穿越。
碰撞命中后的结算要防一个经典坑:一颗子弹只应该打中一个僵尸。我在 Bullet 里加了一个 hitOnce 标记,命中后置位,后续遍历直接跳过。
for (auto& b : bullets) { if (b->hitOnce) continue; for (auto& z : zombies) { AABB bulletBox{b->x, b->y, 14.f, 14.f}; AABB zombieBox{z->x, z->y, 60.f, 90.f}; if (hit(bulletBox, zombieBox)) { z->hp -= 25.f; // 每发豌豆伤害 b->hitOnce = true; break; // 本帧不再检测其他僵尸 } } }伤害数值 25 是调过的结果。如果按 20 算,僵尸 200 血要挨 10 发,配 1.4 秒射击间隔等于 14 秒才打死一个,太拖。调整到 25 后是 8 发、约 11 秒,体感接近原版。数值不是抄完就完,要拿秒表过一整局来微调。
3.3 随机数:C++ 里别再写裸 rand()
向日葵产阳光、僵尸生成间隔都需要随机数。很多 C++ 教程还在教 rand() 配合 srand(time(0)),但游戏里 rand() 的缺陷很明显:全局状态容易被污染,不同平台分布质量不稳定。C++11 之后标准库提供了 ,这才是适合做游戏随机数的标准工具。
#include <random> static std::mt19937 rng{ static_cast<unsigned>( std::chrono::steady_clock::now().time_since_epoch().count() ) }; // 每隔 0.5 秒判定一次阳光掉落,10% 概率掉一个 std::bernoulli_distribution sunDrop(0.1f); static float sunTimer = 0.f; sunTimer += dt; if (sunTimer >= 0.5f) { if (sunDrop(rng)) { spawnSun(randomX, groundY); } sunTimer = 0.f; }分布对象要定义在函数外复用,不要每次调用都新建,否则影响分布均匀性。种子用 steady_clock 时间戳,不用 random_device,因为部分环境下 random_device 是伪随机,会退化成固定序列。换成时间戳之后,每次启动刷怪位置都不一样了,这是做游戏随机数最稳的起手式。僵尸生成的间隔则用 uniform_int_distribution,比如在 30 到 60 秒之间随机决定下一波间隔,这类离散随机用整数分布更直观。
4. 构建与运行:VSCode 配置 C/C++ 环境和 CMake 构建
代码写完要能跑起来才算落地。这里讲两条路径:轻量直接的方式是用 VSCode 配置 C/C++ 环境做单机编译;正规一点的方式是用 CMake 管理多文件工程,方便以后加素材、加关卡。
4.1 VSCode 里配置 C/C++ 环境,一键编译整个项目
在 VSCode 中打开项目后,按 Ctrl+Shift+B 会读取 .vscode/tasks.json。这里配置编译任务,推荐直接调用 g++ 编译整个项目。前提是 MinGW 的 bin 目录已经加入 PATH,或者在 command 里写完整路径。
{ "tasks": [ { "label": "build-pvz", "type": "process", "command": "g++", "args": [ "-std=c++17", "-Iinclude", "src/main.cpp", "src/Game.cpp", "src/Plant.cpp", "src/Zombie.cpp", "src/Bullet.cpp", "-o", "pvz.exe" ], "group": "build" } ] }参数说明:-std=c++17 启用 C++17 标准;-Iinclude 指定头文件目录;-o 指定输出文件名。如果代码里用了 Windows 多媒体定时器才需要加 -lwinmm,用 std::chrono 做计时的项目不用加。这样配置后每次改完代码按一个快捷键就能编译,不用切回命令行敲长命令。
调试配置 launch.json 是另一件事,核心要点只有三个:program 字段指向 pvz.exe 的完整路径;miDebuggerPath 指向 gdb 的完整路径;cwd 设为工程根目录,否则相对路径读资源文件会失败。
4.2 用 CMake 组织多文件工程,告别越写越长的编译命令
当项目从单文件长到十几个源文件时,tasks.json 里的命令会变得又长又难维护。常见做法是切到 CMake。VSCode 装 CMake Tools 插件后,会自动读取 CMakeLists.txt 完成配置和构建。
cmake_minimum_required(VERSION 3.16) project(PvZClone) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(pvz src/main.cpp src/Game.cpp src/Plant.cpp src/Zombie.cpp src/Bullet.cpp )这个文件足够跑通全部核心代码。后续每新增一个源文件,在 add_executable 列表里加一行即可。命令行环境下也不依赖 IDE,直接执行 cmake -S . -B build 再 cmake --build build 就能完成编译,这条命令适合写进 CI 或者给别人复现时使用。
链接图形库时,在 add_executable 之后加 target_link_libraries(pvz PRIVATE ...)。如果用的 SDL2,还需要 include_directories 指向 SDL2 头文件目录。这些属于环境相关配置,不同系统的库路径不一样,不能照抄,要按实际安装位置改。
4.3 msvcp140.dll 找不到:运行库、依赖检查和两种编译路线
把编译好的 exe 发给别人,对方双击报“由于找不到 msvcp140.dll,无法继续执行代码”,这种情况出现频率极高。msvcp140.dll 是 Microsoft Visual C++ 运行库的一部分,使用 MSVC 编译器编译的程序,目标机器上必须有对应版本的 Visual C++ Redistributable。解决方式就是让对方安装 microsoft visual c++ redistributable,x64 和 x86 都装上最稳。
如果是自己排查,先确认程序到底依赖哪些 dll。MinGW 环境下可以用 objdump 查看:
objdump -p pvz.exe | grep "DLL Name"输出里会列出所有依赖的动态库。如果看到 msvcp140.dll,说明走的 MSVC 路线;如果看到 libstdc++-6.dll 和 libgcc_s_seh-1.dll,说明走的 MinGW 路线。两种路线的应对方式不同:MSVC 装对应运行库,MinGW 则要把这些 dll 一起拷贝到 exe 同目录。
如果想让发布包做到“拷过去就能跑”,最省心的方案是静态链接。开发期用动态链接方便调试,发布期切静态链接,exe 体积会变大,但目标机器不用装任何运行库,这个取舍对小型游戏项目非常划算。
5. C++ 复刻 PVZ 常见问题排查:五个翻车点一次说清
下面五条都来自实际做 C++ 小游戏时踩过的坑,按“现象、原因、解决”展开。前四条是逻辑层面的问题,第五条是分发层面的问题,按顺序排查能覆盖绝大多数运行异常。有些问题单看现象像玄学,定位到根因之后其实都是模型没拆干净。
5.1 僵尸一多帧率骤降,操作像慢动作
现象:开场几波很流畅,第三波后僵尸超过 20 个,画面开始明显卡顿。 原因:每帧都在堆上 new 僵尸、delete 子弹,内存分配成为瓶颈;或者 update 里做了字符串拼接、日志输出这类高开销操作。 解决:子弹和阳光用对象池预分配,死亡对象标记为可复用,而不是每帧 new/delete。字符串和日志只在状态切换时拼一次,严禁写进每帧循环。验证方法是在帧循环里统计每帧分配次数,把次数压到接近零。
// 错误示范:每帧都在堆上分配 auto* z = new Zombie(y); // 僵尸多了之后 new 本身成为瓶颈 zombies.push_back(z); // 正确姿势:对象池里取一个,死亡后标记复用 Zombie* z = pool.acquire(); z->reset(y);前几帧可能看不出差别,僵尸到 30 个以上时,对象池方案能稳定省下几毫秒的帧耗时,体感差异非常明显。
5.2 植物种下去显示位置不对,偏了半个格子
现象:种在第 3 行第 5 格,画面里却跑到格子右上角,或者整体偏移半个身位。 原因:坐标换算的基准不统一。部分代码按格子左上角计算,另一部分按中心点计算,两种基准混用导致位置差半个格。 解决:统一约定:格子坐标换算统一用左上角,显示位置统一用中心点。所有对象只能调用同一套换算函数,不允许在类内部自己实现另一份。
// 统一用中心点做显示坐标 float centerX = gridToPixelX(col) + 50.f; // 格子宽 100 float centerY = gridToPixelY(row) + 40.f; // 格子高 80排查时先打印一行 grid 坐标和像素坐标,确认基准一致再往下追。这种问题越早统一约定,后面加新单位时越省事。
5.3 子弹偶尔穿过僵尸,明明有碰撞却打不到
现象:子弹从豌豆位置直线飞过去,偶尔直接穿过僵尸身体,碰撞判定像失效了。 原因:可变时间步导致某帧跨度大,子弹一帧位移超过“僵尸宽度 + 子弹宽度”。以子弹 14 宽、僵尸 60 宽算,只要一帧位移超过 74 像素,就会看到上一帧在左侧、下一帧在右侧,包围盒始终不相交。 解决:改回固定时间步,逻辑步长保持 16 毫秒,每帧位移约 8 像素,远小于 74 像素,永远不会穿透。如果坚持做可变步长,子弹运动必须改成扫掠体检测,复杂度高一个量级,不建议新手碰。
5.4 向日葵产阳光节奏漂移,暂停后对不上
现象:向日葵标称每 7 秒产一个阳光,实际操作几分钟后节奏对不上,暂停恢复后尤其明显。 原因:把计时写在渲染帧里,或者用帧计数代替时间累计。帧计数在 60Hz 和 144Hz 下对应的实际时间完全不同,暂停机制又会打断帧序。 解决:计时只依赖 update(dt) 里累加的 dt,暂停时不调用 update,恢复后从暂停点继续,不补帧。
// 错误示范:帧计数不等于时间 if (frameCount % 420 == 0) spawnSun(); // 60Hz 下是 7 秒,144Hz 下变成 2.9 秒 // 正确姿势:累加真实时长 timer += dt; if (timer >= 7.0f) { spawnSun(); timer = 0.f; }这个问题排查起来最费时间,因为单局短时间里看不出异常,玩久了才暴露。建议从一开始就只用 dt 累加,不要碰帧计数。
5.5 exe 拷到别的电脑就崩溃,提示缺运行库
现象:开发机一切正常,打包发给同事后双击没反应,或者提示找不到某个 dll。 原因:开发机装了完整运行库,目标机没有。用 MSVC 编译依赖 msvcp140.dll,用 MinGW 编译则缺少对应的 libstdc++-6.dll,本质都是动态链接带来的部署问题。 解决:发布前先 objdump 查看依赖列表,把依赖的 dll 拷到 exe 同目录一起打包;或者直接静态链接。我现在的习惯是开发期动态链接,发布时临时切一次静态链接,打包体积变大但省掉一堆运行时问题。这条路走通后,分发成本和反馈成本都明显下降。
6. 再进一步:状态机、动画帧与存档
核心战斗跑通后,往“像样”走有三个方向:僵尸行为状态机、动画帧控制、存档恢复。这三个方向按顺序做,每个都能让项目上一个台阶,而且都是独立可验证的小模块。
僵尸行为建议尽早从“直线移动”改成显式状态机。枚举三个状态:WALK 时向左移动并检测面前是否有植物;ATTACK 时原地播放啃咬;DYING 时播放死亡动画并在结束后移除。用 switch 分发,不要用布尔标记互相组合,否则以后加减速、冰冻、被击退时状态会彻底失控。
enum class ZombieState { WALK, ATTACK, DYING }; void Zombie::update(float dt) { switch (state) { case ZombieState::WALK: posX -= speed * dt; if (plantInFront()) state = ZombieState::ATTACK; break; case ZombieState::ATTACK: attackCooldown -= dt; if (attackCooldown <= 0.f) { damagePlantInFront(10.f); attackCooldown = 1.f; } break; case ZombieState::DYING: break; } }动画帧用帧索引配合帧计时器即可,和产阳光的 timer 是同一套思路:每帧累加 dt,超过阈值就切到下一帧,播完最后一帧就结束。做的时候把帧数据和对象状态解耦,不要在动画回调里直接改游戏逻辑。
存档只需要序列化四个数值:阳光总数、当前波次、每格植物类型和血量、每个僵尸的位置和血量。格式用自定义文本或二进制都可以,建议先存成每行一个字段的文本,出错时用文本编辑器就能肉眼排查。我第一次写存档时贪快直接用二进制,结果版本迭代后字段对不上,黑匣子完全打不开,后来换成文本格式,调试省了太多时间。
做这类 C++ 游戏项目,我个人的体会是:先抄数值、后调数值、最后敢改数值。第一次按 7 秒阳光、1.4 秒射击、25 伤害做,跑通后按自己的手感调整。前期所有坑都踩过一遍后,后续做 SDL2、SFML 甚至 Qt 游戏,这套模型设计和排错思路都是通用的。希望这篇能帮你少走几圈弯路。
本文还有配套的精品资源,点击获取