简介:这是一份面向C++初学者与课程设计需求者的控制台版植物大战僵尸完整项目源码,采用状态机实时响应用户输入,并通过多线程并行避免阻塞其他功能执行。代码以继承实现复用,所有植物公用一个基类,僵尸以普通僵尸为基类,借助虚函数重写各自特殊行为;同时利用STL容器管理地块中的植物、僵尸与子弹,便于遍历、移除和添加。主循环遵循绘制界面、获取输入、更新状态、执行逻辑的经典结构,适合用来理解游戏框架与面向对象设计。资源包共39个文件,包含9个cpp与9个h源码、16张png与2张jpeg素材、1个可执行exe、1份README说明及license,压缩后约337KB,结构清晰、便于直接编译运行与二次开发。目前已有1062人学习下载,可作为课程设计参考、C++练手项目或状态机与多线程入门案例,帮助读者快速掌握游戏主循环、类继承体系与容器管理的落地写法。
1. 从零手搓植物大战僵尸:C++ 小游戏到底能练到什么
很多人第一次想用 C++ 写点看得见的东西,脑子里蹦出来的就是植物大战僵尸。它规则直观、画面简单、逻辑闭环,比控制台里打印九九乘法表有意思得多。但真动手才发现,这游戏远不止“种植物、打僵尸”六个字:阳光经济、格子坐标、碰撞判定、波次生成、状态机切换,每一块都能把人卡住。我见过太多人卡在“僵尸走到植物面前不攻击”或者“豌豆打出去穿模”这种细节上,最后不了了之。
这篇笔记面向两类人:一是刚学完 C++ 基础语法、想找个完整项目练手的;二是写过小游戏但总在架构上翻车的。我会把基于 C++ 开发植物大战僵尸的完整路径拆开——从环境配置、核心数据结构、渲染循环,到碰撞检测、波次调度和性能调优。不依赖任何游戏引擎,纯 C++ 加一个轻量图形库就能跑起来。读完你至少能拿到一个可编译、可扩展的骨架,而不是一堆散落的代码片段。
2. 环境与骨架:用 C++ 把游戏循环跑起来
2.1 为什么选 SFML 而不是直接上引擎
用 C++ 写植物大战僵尸,第一个决策就是图形库。常见选项有 SFML、SDL2、EasyX、Qt。EasyX 只支持 Windows 且偏教学,Qt 太重,SDL2 偏底层要自己封装纹理和文字。SFML 的定位刚好:面向对象接口、跨平台、文档清晰,适合中小型 2D 游戏。它把窗口、纹理、精灵、文字、音频都封装好了,你不需要碰 OpenGL 就能把画面跑起来。
安装方式按平台分:Windows 下推荐用 vcpkg 或直接下载预编译包,把 include 和 lib 目录配到 IDE 里;Linux 下sudo apt install libsfml-dev一行搞定;macOS 用 Homebrew。VS Code 用户注意,c_cpp_properties.json 里的 includePath 要加上 SFML 的 include 目录,tasks.json 的链接参数要带-lsfml-graphics -lsfml-window -lsfml-system,否则会出现“函数变量无法跳转”或链接报错。
提示:如果你用的是 Visual Studio,记得把 SFML 的 bin 目录加到系统 PATH,否则运行时会弹“找不到 sfml-graphics-2.dll”。这个坑每年都有人踩。
2.2 最小可运行的游戏循环
游戏循环是所有实时程序的骨架。它的核心就三件事:处理输入、更新状态、渲染画面。下面是一个最小可运行版本,窗口 1440×810,对应常见的 16:9 比例。
#include <SFML/Graphics.hpp> #include <vector> int main() { // 创建窗口,尺寸 1440x810,标题为游戏名 sf::RenderWindow window(sf::VideoMode(1440, 810), "Plants vs Zombies C++"); window.setFramerateLimit(60); // 锁定 60 帧,避免 CPU 空转 sf::Clock clock; // 用于计算每帧耗时 while (window.isOpen()) { sf::Event event; while (window.pollEvent(event)) { if (event.type == sf::Event::Closed) window.close(); } float dt = clock.restart().asSeconds(); // 本帧时间增量,单位秒 // update(dt); // 后续在这里更新阳光、僵尸、子弹位置 // render(window); // 后续在这里绘制所有精灵 window.clear(sf::Color(30, 120, 30)); // 深绿色背景,模拟草坪 window.display(); } return 0; }这段代码的逻辑说明:pollEvent负责把操作系统的事件队列取空,clock.restart()返回上一帧到现在的秒数,这个dt是后面所有运动计算的基础。参数方面,setFramerateLimit(60)把帧率锁在 60,好处是运动速度稳定、CPU 占用低;如果你要做高刷新率适配,可以去掉它改用垂直同步。window.clear()的颜色值可以换成草坪贴图,但初期用纯色能更快定位坐标问题。
编译命令(Linux/macOS):
g++ main.cpp -o pvz -lsfml-graphics -lsfml-window -lsfml-system ./pvzWindows 下如果用 MinGW,命令类似,只是库路径要用-L指定。跑通这一步,你就有了一个能开窗口、能响应关闭事件的骨架,接下来所有功能都往这个循环里塞。
2.3 目录结构与资源组织
别把所有代码写在一个 main.cpp 里。我一般会这样分:
pvz/ ├── src/ │ ├── main.cpp │ ├── Game.h / Game.cpp // 游戏主类,持有窗口和场景 │ ├── Plant.h / Plant.cpp // 植物基类与派生 │ ├── Zombie.h / Zombie.cpp // 僵尸基类与派生 │ ├── Bullet.h / Bullet.cpp // 子弹 │ └── Grid.h / Grid.cpp // 格子坐标转换 ├── assets/ │ ├── images/ │ └── fonts/ └── CMakeLists.txt用 CMake 管理构建,比手写 g++ 命令可维护得多。CMakeLists.txt 里用find_package(SFML 2.5 COMPONENTS graphics window system REQUIRED)找到库,再target_link_libraries链接。这样换平台时只需要改 CMake 配置,不用重写编译命令。资源路径建议用相对路径加一个getResourcePath()函数统一处理,避免发布时路径写死导致图片加载失败。
3. 核心玩法拆解:阳光、植物、僵尸的数据结构
3.1 阳光经济与定时器
阳光是这游戏的资源系统。自然阳光每隔约 10 秒掉落一个,向日葵每 24 秒产出一个。实现上不要用sleep,而是在 update 里累加dt,超过阈值就触发。
class SunManager { public: float naturalTimer = 0.f; const float naturalInterval = 10.f; // 自然阳光间隔,秒 int sunCount = 50; // 初始阳光 void update(float dt) { naturalTimer += dt; if (naturalTimer >= naturalInterval) { naturalTimer -= naturalInterval; // 减去而不是清零,避免帧率波动导致漂移 spawnSunAtRandom(); } } void spawnSunAtRandom() { // 在屏幕上方随机 x 位置生成一个阳光精灵 float x = static_cast<float>(rand() % 1300 + 70); // 后续把阳光加入待收集列表 } };关键点是naturalTimer -= naturalInterval而不是直接归零。如果某帧卡顿导致 dt 偏大,减去间隔能保留多余时间,阳光节奏不会越跑越慢。参数上,naturalInterval可以按难度调整,sunCount初始值 50 是原版设定,改大了游戏会变简单。阳光收集判定用鼠标点击坐标和阳光精灵的包围盒做相交测试,这个后面碰撞章节会细说。
3.2 植物与僵尸的类设计
植物和僵尸都有共同属性:位置、血量、精灵、状态。用继承还是组合?我倾向基类加虚函数。基类Entity放位置、血量、update和draw虚函数,植物和僵尸各自派生。
class Entity { public: sf::Sprite sprite; sf::Vector2f position; int hp = 100; bool alive = true; virtual void update(float dt) = 0; virtual ~Entity() = default; }; class Plant : public Entity { public: float attackCooldown = 0.f; float attackInterval = 1.4f; // 豌豆射手射击间隔 int damage = 20; void update(float dt) override { attackCooldown += dt; if (attackCooldown >= attackInterval) { attackCooldown -= attackInterval; fireBullet(); } } void fireBullet() { /* 生成子弹并加入子弹列表 */ } }; class Zombie : public Entity { public: float speed = 20.f; // 像素每秒 void update(float dt) override { position.x -= speed * dt; // 僵尸向左移动 sprite.setPosition(position); } };这里attackInterval和speed是最常调的两个参数。豌豆射手原版约 1.4 秒一发,僵尸移动速度换算成像素大约每秒 20 到 30。调参时建议把这些数值集中放到一个 config 头文件里,改起来不用翻遍源码。虚函数带来一点运行时开销,但实体数量在几百级别完全无感,不必过早优化。
3.3 格子坐标与放置逻辑
植物大战僵尸的草坪是 5 行 9 列。鼠标点击屏幕坐标要转换成格子坐标,才能判断这个位置能不能种。
struct Grid { static constexpr int ROWS = 5; static constexpr int COLS = 9; static constexpr float CELL_W = 140.f; static constexpr float CELL_H = 140.f; static constexpr float ORIGIN_X = 100.f; static constexpr float ORIGIN_Y = 100.f; // 屏幕坐标转格子坐标,返回 {-1,-1} 表示不在格子内 static sf::Vector2i screenToGrid(float x, float y) { int col = static_cast<int>((x - ORIGIN_X) / CELL_W); int row = static_cast<int>((y - ORIGIN_Y) / CELL_H); if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return {-1, -1}; return {col, row}; } };ORIGIN_X和ORIGIN_Y是草坪左上角在窗口里的像素位置,CELL_W和CELL_H是每格宽高。这三个值必须和你的背景图对齐,否则会出现“明明点中了格子却种不下去”的玄学问题。建议先用调试模式把格子线画出来,确认对齐后再关掉。放置时还要检查该格子是否已有植物,用一个bool occupied[ROWS][COLS]数组记录即可。
4. 碰撞、波次与状态机:让游戏真正跑起来
4.1 碰撞检测的三种场景
这游戏里的碰撞分三类:子弹打僵尸、僵尸啃植物、僵尸到达最左侧。前两种用 AABB(轴对齐包围盒)就够,不需要复杂的物理引擎。
bool intersects(const sf::FloatRect& a, const sf::FloatRect& b) { return a.left < b.left + b.width && a.left + a.width > b.left && a.top < b.top + b.height && a.top + a.height > b.top; } // 子弹与僵尸的碰撞 for (auto& bullet : bullets) { for (auto& zombie : zombies) { if (intersects(bullet.getBounds(), zombie.getBounds())) { zombie.hp -= bullet.damage; bullet.alive = false; if (zombie.hp <= 0) zombie.alive = true ? false : false; // 标记死亡 break; // 一颗子弹只打一个僵尸 } } }getBounds()返回精灵的全局包围盒,SFML 的getGlobalBounds()直接可用。注意子弹命中后要break,否则一颗子弹会穿透打中同一帧里的多个僵尸。僵尸啃植物则是判断僵尸的包围盒和植物格子是否重叠,重叠时僵尸停止移动并开始扣植物血量。僵尸到达最左侧(x 小于某个阈值)就触发游戏失败。
注意:包围盒比精灵视觉范围略大是正常的,但如果你发现子弹“擦边”就命中,检查一下精灵的 origin 设置。origin 默认在左上角,旋转或缩放后会偏移。
4.2 波次生成与难度曲线
僵尸不能一窝蜂全出来,要有波次。常见做法是用一个计时器加一个波次表。
struct Wave { float startTime; // 该波开始时间,秒 int zombieCount; // 僵尸数量 float spawnGap; // 每只僵尸生成间隔 }; std::vector<Wave> waves = { {5.f, 3, 2.0f}, {25.f, 6, 1.5f}, {50.f, 10, 1.0f}, {80.f, 15, 0.8f}, }; float gameTime = 0.f; void updateWaves(float dt) { gameTime += dt; for (auto& w : waves) { if (gameTime >= w.startTime && !w.spawned) { // 启动该波的生成逻辑,按 spawnGap 逐个生成 w.spawned = true; } } }startTime控制节奏,zombieCount和spawnGap控制压力。难度曲线要平滑,别第二波就放 20 只。我一般让每波僵尸数量递增约 1.5 倍,间隔递减 0.2 秒。如果要做无尽模式,就用公式动态生成波次,而不是写死表。生成位置在屏幕右侧外,x 设为窗口宽度加一点偏移,让僵尸“走进来”而不是凭空出现。
4.3 游戏状态机与暂停
游戏有多个状态:主菜单、游戏中、暂停、胜利、失败。用枚举加 switch 管理,比一堆 bool 标志清晰。
enum class GameState { Menu, Playing, Paused, Win, Lose }; GameState state = GameState::Menu; void update(float dt) { switch (state) { case GameState::Playing: updateSun(dt); updatePlants(dt); updateZombies(dt); updateBullets(dt); checkWinLose(); break; case GameState::Paused: // 不更新逻辑,只渲染暂停界面 break; default: break; } }暂停时只跳过 update,渲染照常,这样暂停界面能盖在游戏画面上。状态切换用键盘事件触发,比如按 P 在 Playing 和 Paused 之间切换。胜利条件是所有波次结束且场上无僵尸,失败条件是僵尸到达最左侧。这两个判定放在checkWinLose()里,每帧检查一次,开销可忽略。
5. 避坑与排查:那些让我重写三遍的细节
5.1 精灵坐标与鼠标坐标不一致
现象:点击格子种植物,明明看着点中了,却提示“不能种在这里”。原因通常是窗口做了缩放,或者精灵用了setScale,导致屏幕坐标和世界坐标不统一。解决:用window.mapPixelToCoords(sf::Mouse::getPosition(window))把像素坐标转成世界坐标,再做格子转换。如果窗口固定不缩放,直接传像素坐标也行,但一旦加了全屏或缩放就会翻车。
5.2 子弹穿模与帧率相关
现象:低帧率时子弹直接穿过僵尸不触发碰撞。原因是子弹速度太快,一帧移动距离超过僵尸宽度,AABB 检测在离散帧之间漏掉了。解决:要么限制子弹速度,要么做连续碰撞检测——在子弹上一帧位置和当前位置之间做线段与包围盒的相交测试。简单做法是把子弹速度控制在每帧不超过僵尸宽度的一半,60 帧下每秒 600 像素以内基本安全。
5.3 资源加载失败导致白屏
现象:程序能跑,但所有精灵都是白色方块。原因几乎都是纹理加载路径错误,sf::Texture::loadFromFile返回 false 但没检查。解决:每次加载后判断返回值,失败时打印路径到控制台。路径用相对于可执行文件的写法,或者用std::filesystem::current_path()拼绝对路径。发布时把 assets 目录和 exe 放同一级。
5.4 实体删除时的迭代器失效
现象:僵尸死亡后从 vector 里删除,程序随机崩溃。原因是在遍历 vector 的同时 erase,迭代器失效。解决:用“标记删除”模式——先遍历把alive = false的实体标记,遍历结束后用std::remove_if统一删除。或者用索引倒序遍历删除。这个坑在子弹、僵尸、阳光三个列表里都会遇到,统一处理最省心。
5.5 内存泄漏与智能指针
现象:长时间运行后内存持续上涨。原因是用new创建实体后忘记delete。解决:实体用std::vector<std::unique_ptr<Entity>>管理,或者直接存值。SFML 的 Sprite 和 Texture 要注意生命周期——Texture 必须比 Sprite 活得久,否则 Sprite 会变成白块。把 Texture 集中放在一个资源管理器里,用std::map<std::string, sf::Texture>持有,Sprite 只存引用或指针。
6. 进阶技巧:用数据驱动和调试工具提升开发效率
写到能玩之后,真正拉开差距的是可维护性。我踩过最大的坑是把所有数值硬编码在逻辑里,改一个豌豆伤害要翻五个文件。后来改成数据驱动:植物和僵尸的属性全部从一张表里读。
struct PlantData { std::string name; int cost; int hp; float attackInterval; int damage; }; std::map<std::string, PlantData> plantTable = { {"Peashooter", {"Peashooter", 100, 300, 1.4f, 20}}, {"Sunflower", {"Sunflower", 50, 300, 24.0f, 0}}, {"Wallnut", {"Wallnut", 50, 4000, 0.f, 0}}, };这样加新植物只需要加一行数据,逻辑代码不用动。数值平衡时改表就行,不用重新编译逻辑。更进一步可以把表放到 JSON 或 CSV 里,运行时加载,策划改数值不用碰代码。
调试工具方面,我习惯加一个调试模式,按 F1 切换。开启后画出所有包围盒、格子线、实体 ID 和当前状态。碰撞问题一眼就能看出来是哪个盒子偏了。再加一个帧率显示和实体计数,性能问题也能快速定位。SFML 的sf::Text画这些信息很方便,字体加载一次复用即可。
最后一个习惯:每加一个新功能,先写一个最小可复现的测试场景。比如做波次系统时,先手动放三只僵尸验证移动和碰撞,确认没问题再接波次表。这样出问题时排查范围小,不会在一堆交互里迷失。希望帮到你。
本文还有配套的精品资源,点击获取