C++ 从零复刻植物大战僵尸:一个周末跑通最小闭环
发布时间:2026/10/1 5:31:42来源:尧图网络
简介这是一份面向C初学者与课程设计需求者的植物大战僵尸控制台游戏源码编号100013171适合用来练习面向对象编程、状态机与STL容器综合应用。项目以状态机实时响应用户输入主循环按绘制界面、获取按键、更新对象状态的节奏推进并借助继承与虚函数实现植物、僵尸的代码复用用STL容器管理地块中的植物、僵尸与子弹便于遍历、增删。压缩包共39个文件约337KB包含9个cpp与9个h源码文件、16个png与2个jpeg图片素材、1个可执行exe、1个README说明及1份license源码按Game、Grid、Plants、Zombies、Bullets、Map、Store、UI等模块拆分结构清晰。目前已有1062人学习下载读者可据此理解多线程并行与游戏主循环设计参考基类派生体系与容器管理思路快速完成同类课设或二次开发。1. 从零用 C 复刻植物大战僵尸一个周末能跑起来的最小闭环很多人第一次搜「C 植物大战僵尸」心里想的其实是两件事一是想找个能直接编译运行的小游戏项目练手二是想知道这玩意儿到底难在哪、值不值得自己动手。我当年也是这么入的坑翻了一堆所谓「C 小游戏代码」结果要么是控制台里打印几个字符的伪游戏要么是依赖一堆看不懂的第三方库、连编译都过不去。真正能跑起来、有画面、有交互、还能自己改的版本其实门槛没有想象中高——一台装了 VS Code 的普通电脑一个下午就能把「阳光掉落、豌豆发射、僵尸走路」这条最小闭环跑通。这篇笔记不讲空泛的「游戏开发概论」而是顺着一个可复现的路径把基于 C 的植物大战僵尸从环境搭建、核心循环、对象建模一路写到碰撞判定和资源加载。适合两类人刚学完 C 基础语法、想找个真实项目练手的入门者以及做过一些小游戏、想看看别人怎么组织代码结构的老手。全程用最常见的工具链不依赖冷门框架代码能抄、参数能调、坑能提前避开。2. 环境与选型为什么用 C 而不是 Scratch 或 Python2.1 工具链怎么选VS Code MinGW 还是 Visual Studio先说结论如果你在 Windows 上想快速跑起来我一般推荐VS Code MinGW-w64g这套组合。原因很直接——轻量、配置透明、跨平台习惯一致以后换到 Linux 或 macOS 也不用重新学一套 IDE。Visual Studio 当然更强调试体验好但安装包动辄几个 G对只想练手的人来说太重了。配置步骤不复杂但每一步都有坑# 1. 下载 MinGW-w64推荐用 MSYS2 安装版本可控 # 安装完成后把 bin 目录加入系统 PATH例如 # C:\msys64\mingw64\bin # 2. 验证编译器是否可用 g --version # 正常应输出类似 g (Rev...) 13.x.x # 3. 验证调试器 gdb --version装好之后VS Code 里需要三个配置文件tasks.json编译任务、launch.json调试配置、c_cpp_properties.json头文件路径。很多人卡在「编译能过但调试断点不生效」八成是launch.json里的miDebuggerPath没指向正确的 gdb.exe。// .vscode/tasks.json 关键片段 { version: 2.0.0, tasks: [ { label: build-pvz, type: shell, command: g, args: [ -g, // 保留调试符号 -stdc17, // 用 C17够用且稳定 -I, include, // 头文件目录 src/*.cpp, // 所有源文件 -o, bin/pvz.exe, -L, lib, // 库目录 -lsfml-graphics, // 图形库 -lsfml-window, -lsfml-system ], group: { kind: build, isDefault: true } } ] }参数说明-g是调试必需别为了体积去掉-stdc17是因为后面要用到std::optional和结构化绑定C11 会写得很别扭-I和-L分别指定头文件和库路径路径写错是最常见的「undefined reference」来源。提示如果你用的是 Visual Studio把g换成cl.exe参数改成/std:c17 /Zi链接库用#pragma comment(lib, sfml-graphics.lib)也行但本文后续命令都以 g 为准。2.2 图形库选型SFML、SDL2 还是 EasyX这是选型里最容易被带偏的一步。网上搜「C 小游戏」很多人会推 EasyX因为它画图确实简单几行代码就能出窗口。但 EasyX 只支持 Windows Visual Studio换编译器就废而且它本质是封装 GDI做动画性能一般。SFML和SDL2才是更通用的选择。我选 SFML理由有三API 是 C 原生面向对象的写起来顺手自带精灵、纹理、声音、字体不用自己造轮子社区示例多遇到问题好搜。SDL2 更底层、更灵活但你要自己封装纹理管理和渲染逻辑对练手项目来说属于过度设计。// 最小验证创建一个 800x600 窗口并保持运行 #include SFML/Graphics.hpp int main() { sf::RenderWindow window(sf::VideoMode(800, 600), PVZ-CPP); window.setFramerateLimit(60); // 限制 60 帧避免 CPU 空转 while (window.isOpen()) { sf::Event event; while (window.pollEvent(event)) { if (event.type sf::Event::Closed) window.close(); } window.clear(sf::Color(30, 30, 30)); // 后续在这里绘制游戏对象 window.display(); } return 0; }这段代码是整个游戏的骨架。setFramerateLimit(60)很关键——不设的话循环会跑满一个核心笔记本风扇直接起飞。pollEvent必须每帧清空事件队列否则窗口拖动、关闭都会卡。编译命令g -stdc17 main.cpp -o pvz.exe -lsfml-graphics -lsfml-window -lsfml-system如果报「找不到 sfml-graphics」说明库没装或路径没配。Windows 下把 SFML 的bin目录里的 dll 拷到 exe 同目录否则运行时会弹「缺少 sfml-graphics-2.dll」。2.3 项目目录结构一开始就分好后面少返工新手最容易犯的错是把所有代码塞进一个main.cpp。写到 500 行以后改一个豌豆速度要在几千行里翻。我一般会这样分pvz/ ├── include/ # 头文件 │ ├── Plant.h │ ├── Zombie.h │ ├── Bullet.h │ └── Game.h ├── src/ # 源文件 │ ├── main.cpp │ ├── Plant.cpp │ ├── Zombie.cpp │ ├── Bullet.cpp │ └── Game.cpp ├── assets/ # 素材 │ ├── images/ │ └── fonts/ └── bin/ # 输出这样分的好处是编译时用src/*.cpp一把梭新增文件不用改配置头文件里只放声明实现放 cpp改实现不会触发全量重编。素材单独放assets加载路径用相对路径换机器也能跑。3. 核心循环与对象建模把植物、僵尸、子弹拆成类3.1 游戏主循环固定时间步长为什么比 deltaTime 更稳游戏循环有两种常见写法一种是每帧算deltaTime用时间差乘速度另一种是固定时间步长逻辑更新频率恒定。做植物大战僵尸这种节奏固定的塔防我强烈建议固定步长。原因很实际僵尸移动、豌豆飞行、阳光掉落如果按 deltaTime 算帧率波动时速度会飘僵尸一会儿快一会儿慢碰撞判定也跟着抖。固定步长把逻辑更新和渲染解耦逻辑每秒固定跑 60 次渲染能跑多快跑多快。// Game.h 核心循环骨架 const float FIXED_DT 1.0f / 60.0f; // 逻辑步长 sf::Clock clock; float accumulator 0.0f; while (window.isOpen()) { float frameTime clock.restart().asSeconds(); if (frameTime 0.25f) frameTime 0.25f; // 防止卡顿后追帧爆炸 accumulator frameTime; while (accumulator FIXED_DT) { update(FIXED_DT); // 逻辑更新参数固定 accumulator - FIXED_DT; } render(); // 渲染不传时间 window.display(); }accumulator是累加器把真实流逝的时间攒起来够一个步长就更新一次逻辑。frameTime上限 0.25 秒是「后悔药」——如果窗口被拖动或系统卡了一下不设上限会导致一次补几百帧游戏直接瞬移。这个模式在《游戏编程模式》里叫「固定时间步长」是塔防、格斗这类确定性要求高的游戏的标准做法。3.2 对象建模用继承还是组合植物、僵尸、子弹、阳光它们有共同点都有位置、都要更新、都要绘制也有差异行为完全不同。新手容易一上来就搞个大基类GameObject然后所有东西都继承它。这没错但继承层次别超过两层否则后面加个「冰冻豌豆」就要改一堆。我的做法是一个轻量基类管位置和生命周期具体行为用组合。// include/Entity.h #pragma once #include SFML/Graphics.hpp class Entity { public: sf::Vector2f position; bool alive true; virtual ~Entity() default; virtual void update(float dt) 0; virtual void draw(sf::RenderWindow window) 0; }; // include/Plant.h class Plant : public Entity { public: int hp 100; float shootCooldown 1.5f; // 射击间隔秒 float cooldownTimer 0.0f; int row 0, col 0; // 所在格子 void update(float dt) override; void draw(sf::RenderWindow window) override; };Plant里row和col是格子坐标不是像素坐标。这一点很关键——植物大战僵尸是网格游戏所有逻辑判定都基于格子渲染时才换算成像素。这样僵尸走到哪一格、豌豆打中哪一格都是整数运算不会出现「差一个像素没打中」的玄学问题。// 格子坐标转像素坐标 const float CELL_W 80.0f; const float CELL_H 100.0f; const float GRID_ORIGIN_X 250.0f; // 草坪起始 X const float GRID_ORIGIN_Y 100.0f; // 草坪起始 Y sf::Vector2f gridToPixel(int row, int col) { return { GRID_ORIGIN_X col * CELL_W, GRID_ORIGIN_Y row * CELL_H }; }参数说明CELL_W和CELL_H要和你的素材尺寸对齐。原版草坪是 9 列 5 行素材单格 80x100 像素比较合适。GRID_ORIGIN是草坪左上角在窗口里的位置调这个值就能整体移动草坪。3.3 僵尸与子弹移动、碰撞、销毁的完整链路僵尸的核心逻辑就三件事沿行向左走、碰到植物就啃、血量为零就死。子弹更简单向右飞、碰到僵尸就消失并扣血。// Zombie::update void Zombie::update(float dt) { if (hp 0) { alive false; return; } // 检查当前格是否有植物 int col pixelToCol(position.x); Plant* target game.getPlantAt(row, col); if (target target-alive) { // 啃食每秒扣 50 血 target-hp - 50 * dt; if (target-hp 0) target-alive false; } else { position.x - speed * dt; // 没植物就继续走 } // 走到最左边算失败 if (position.x GRID_ORIGIN_X - CELL_W) { game.gameOver true; } }pixelToCol是gridToPixel的逆运算把僵尸的像素 X 换算成格子列号。这里有个细节僵尸的「嘴」在身体左侧判定时应该用position.x而不是中心点否则会出现「僵尸还没碰到植物就开始啃」的穿模。我一般会在僵尸类里单独存一个attackPoint偏移量。子弹的碰撞用简单的 AABB轴对齐包围盒就够不需要上物理引擎// Bullet::update void Bullet::update(float dt) { position.x speed * dt; if (position.x 900) { alive false; return; } for (auto z : game.zombies) { if (!z-alive || z-row ! row) continue; sf::FloatRect bulletBox(position.x, position.y, 20, 20); sf::FloatRect zombieBox(z-position.x, z-position.y, 60, 90); if (bulletBox.intersects(zombieBox)) { z-hp - damage; alive false; break; // 一颗子弹只打一个僵尸 } } }row ! row这个判断是性能优化也是逻辑正确性保证——子弹只打同一行的僵尸。break不能省否则一颗子弹会穿透整行僵尸变成「一穿多」的 bug。伤害值damage我一般设 25普通僵尸 100 血正好四发打死和原版手感接近。4. 资源加载与渲染素材、动画、图层顺序4.1 素材从哪来、怎么组织植物大战僵尸的素材网上流传很广但质量参差。常见做法是找现成的素材包按plants/、zombies/、bullets/、ui/分类放。注意版权问题——自己练手没问题真要发布就得换原创或授权素材。加载时别每帧读文件那是性能杀手。正确做法是启动时一次性加载到sf::Texture用std::mapstd::string, sf::Texture缓存// ResourceManager.h class ResourceManager { std::mapstd::string, sf::Texture textures; public: void load(const std::string name, const std::string path) { sf::Texture tex; if (!tex.loadFromFile(path)) { throw std::runtime_error(加载失败: path); } textures[name] std::move(tex); } sf::Texture get(const std::string name) { return textures.at(name); } };loadFromFile返回 bool一定要检查。路径写错时 SFML 不会崩只是画出来一片白新手经常对着白屏找半天。抛异常比静默失败好至少能立刻定位。4.2 动画用 sprite sheet 做帧动画僵尸走路、豌豆射手摇头都是帧动画。做法是把多帧拼成一张图用sf::IntRect切出当前帧// 僵尸走路动画4 帧循环 sf::IntRect frameRect; frameRect.width 60; frameRect.height 90; frameRect.top 0; frameRect.left currentFrame * 60; // 第几帧就偏移几个宽度 sprite.setTextureRect(frameRect);currentFrame每 0.15 秒加一到 4 归零。这个 0.15 秒就是动画速度调小走路快调大走路慢。注意setTextureRect每帧都要调否则动画不动。4.3 图层顺序为什么僵尸要画在植物后面渲染顺序决定遮挡关系。植物大战僵尸的正确顺序是背景草坪 → 植物 → 子弹 → 僵尸 → UI。僵尸画在植物后面是因为僵尸从右边走过来视觉上应该在植物「后面」被挡住一部分这样才有纵深。void Game::render() { window.draw(background); for (auto p : plants) if (p-alive) p-draw(window); for (auto b : bullets) if (b-alive) b-draw(window); for (auto z : zombies) if (z-alive) z-draw(window); drawUI(window); }顺序写反了僵尸就会盖住植物看起来像贴图错误。这个坑我踩过调了半小时才发现是 draw 顺序问题。5. 避坑与排查那些让新手卡一整晚的问题5.1 编译报 undefined reference tosf::...现象代码看着没问题链接阶段一堆undefined reference。 原因链接器没找到 SFML 库或者库的顺序不对。 解决确认-lsfml-graphics -lsfml-window -lsfml-system顺序正确SFML 有依赖关系graphics 依赖 windowwindow 依赖 system顺序反了会失败。另外检查-L路径是否指向lib目录而不是bin。5.2 程序启动就闪退没有任何输出现象双击 exe 窗口一闪而过。 原因多半是缺 dll或者素材路径不对导致抛异常。 解决在命令行里运行 exe能看到具体错误信息。缺 dll 就把 SFML 的bin目录下所有 dll 拷到 exe 旁边。素材路径用相对路径时工作目录是 exe 所在目录不是源码目录这点要分清。5.3 僵尸移动速度忽快忽慢现象帧率一波动僵尸速度就变。 原因用了 deltaTime 乘速度但没做固定步长。 解决改成第 3.1 节的固定时间步长模式逻辑更新和渲染解耦。如果已经用了固定步长还飘检查FIXED_DT是不是被写成了变量。5.4 子弹打不中僵尸或者一穿多现象豌豆从僵尸身上穿过去或者一颗豌豆打死一排。 原因碰撞盒尺寸不对或者循环里漏了break。 解决把碰撞盒画出来调试——用sf::RectangleShape描边肉眼确认位置。break必须加一颗子弹只处理一次碰撞。5.5 内存持续增长玩久了卡顿现象任务管理器里内存一直涨。 原因new出来的对象没delete或者容器只增不减。 解决用std::vectorstd::unique_ptrEntity管理对象死亡对象定期从容器里erase。别用裸指针C17 的智能指针能省掉大部分内存问题。6. 进阶技巧用状态机和数据驱动把项目做大写到能跑之后你会发现加新植物、新僵尸越来越麻烦——每加一种就要改一堆if-else。这时候该上状态机和数据驱动了。僵尸的行为可以拆成几个状态走路、啃食、死亡、冰冻。用枚举加状态转移表比一堆布尔标志清晰得多enum class ZombieState { Walking, Eating, Dying, Frozen }; void Zombie::update(float dt) { switch (state) { case ZombieState::Walking: if (findPlantAhead()) state ZombieState::Eating; else position.x - speed * dt; break; case ZombieState::Eating: if (!findPlantAhead()) state ZombieState::Walking; else eatPlant(dt); break; case ZombieState::Frozen: freezeTimer - dt; if (freezeTimer 0) state ZombieState::Walking; break; case ZombieState::Dying: deathTimer - dt; if (deathTimer 0) alive false; break; } }状态机的好处是每个状态只关心自己的逻辑加「冰冻」只要加一个状态和转移条件不用动走路和啃食的代码。数据驱动更进一步把植物和僵尸的属性抽到配置文件里程序启动时读。这样改数值不用重编译调平衡快得多。// plants.json 示例结构用 nlohmann/json 解析 { peashooter: { hp: 100, cost: 100, cooldown: 7.5, attack: { damage: 25, interval: 1.5 } }, sunflower: { hp: 100, cost: 50, cooldown: 7.5, produce: { sun: 25, interval: 24.0 } } }解析用nlohmann/json单头文件库扔进include就能用。读进来存成std::mapstd::string, PlantData创建植物时按名字查表。这样加一种新植物只要在 json 里加一段代码一行不用改。验证方法很简单改 json 里豌豆伤害从 25 到 50重新运行看僵尸是不是两发就死。如果没变说明配置没加载成功检查文件路径和解析异常。我自己的习惯是每加一个新功能先在纸上画状态转移图再写代码。状态机最怕的就是漏了某个转移条件导致僵尸卡在某个状态不动——这种 bug 调试起来最费时间因为逻辑上「看起来」没问题。另外数据驱动别过度属性超过 20 个字段就该考虑拆表了不然 json 会变成新的意大利面。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网