ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++实战:从零构建植物大战僵尸游戏

C++实战:从零构建植物大战僵尸游戏 简介这是一份面向C初学者与课程设计需求者的植物大战僵尸控制台游戏源码编号100013171适合用来练习面向对象编程、状态机与STL容器综合应用。项目以状态机实时响应用户输入通过多线程并行避免阻塞其他功能植物与僵尸分别以基类加虚函数重写的方式实现代码复用子弹、植物、僵尸均用STL容器按地块存储便于遍历、增删与移除。主循环采用draw、getkey、update三段式结构逻辑清晰便于二次开发与调试。压缩包共39个文件约337KB包含9个cpp与9个h源文件、16张png与2张jpeg素材图、1个可执行exe、1份README说明及LICENSE源码与资源分离目录结构完整。目前已有1062人学习下载读者可借此掌握继承、多态、容器管理与游戏主循环设计并对照源码理解状态机与多线程在小型项目中的落地方式。1. 用 C 从零复刻植物大战僵尸一个能跑、能改、能交作业的实战方案很多人第一次听到「基于 C 开发的植物大战僵尸」脑子里浮现的是网上那种几百行控制台字符画豌豆射手用*表示僵尸用Z表示跑起来满屏闪烁。这种玩具项目练手可以但拿不出手也学不到真东西。真正值得做的版本是一个带图形窗口、有精灵动画、有碰撞检测、有阳光经济系统、有波次刷怪的完整小游戏。它解决的核心问题是让你把 C 的类与对象、指针与引用、STL 容器、状态机、定时器、资源管理这些零散知识点串成一个能编译、能运行、能演示的闭环。适合已经学完 C 基础语法、写过控制台程序、但没做过完整图形项目的同学也适合想用一个小游戏当课程设计或简历项目的开发者。下面这套方案我按自己实际落地的顺序拆开讲从环境到架构到踩坑尽量让你少走弯路。2. 技术选型为什么用 C 而不是 Python 或 C#2.1 图形库怎么选SFML、SDL2 还是 EasyX做图形小游戏C 这边主流就三条路。EasyX 是国产的只支持 Windows Visual StudioAPI 极简画个圆就一行circle()上手最快但跨平台为零而且封装太厚你学不到窗口消息循环和渲染管线。SDL2 是 C 库跨平台底层什么都要自己写纹理加载、事件分发、音频初始化都得手动学习曲线陡但学完对图形编程理解最深。SFML 是 C 库面向对象封装sf::RenderWindow、sf::Sprite、sf::Texture这些类设计得很干净跨 Windows/Linux/macOS文档齐全社区活跃是这类 2D 小游戏最平衡的选择。我一般推荐 SFML理由是它把「窗口 事件 渲染 音频」四件事用 C 类封装好了你不需要碰 Win32 API 的WndProc也不需要自己写 OpenGL 上下文能把精力放在游戏逻辑上。如果你在 Windows 上用 Visual Studio装 SFML 就是下载对应版本、配好 include 和 lib 路径、把 dll 拷到 exe 旁边十分钟搞定。Linux 下sudo apt install libsfml-dev一行命令。下面给一个最小可运行窗口先确认环境通了再往下做。// main.cpp - SFML 最小窗口确认环境配置正确 #include SFML/Graphics.hpp int main() { // 创建 800x600 窗口标题为 PvZ sf::RenderWindow window(sf::VideoMode(800, 600), PvZ); // 限制帧率 60避免 CPU 空转 window.setFramerateLimit(60); 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; }这段代码的逻辑是RenderWindow负责窗口和渲染目标pollEvent每帧取出所有待处理事件clear清屏display把后台缓冲刷到屏幕。参数上setFramerateLimit(60)是关键不设的话循环会跑满 CPU风扇狂转。sf::Color(30,30,30)是 RGB 值你可以改成草地绿(60,140,60)看看效果。编译命令在 Linux 下是g main.cpp -o pvz -lsfml-graphics -lsfml-window -lsfml-systemWindows 下在 VS 里链接sfml-graphics.lib等即可。窗口能弹出来环境就算通了。2.2 项目目录结构从第一天就分好层新手最容易犯的错是把所有代码塞进一个main.cpp写到两千行以后改一个功能要翻半天。正确做法是第一天就分目录。我一般这样组织PvZ/ ├── src/ │ ├── main.cpp // 入口主循环 │ ├── Game.h/.cpp // 游戏总控状态机 │ ├── Plant.h/.cpp // 植物基类与派生 │ ├── Zombie.h/.cpp // 僵尸基类与派生 │ ├── Bullet.h/.cpp // 子弹 │ ├── Sun.h/.cpp // 阳光 │ └── Grid.h/.cpp // 网格与碰撞 ├── assets/ │ ├── images/ // 精灵图 │ └── fonts/ // 字体 └── CMakeLists.txt分层原则是Game持有所有对象容器负责调度Plant、Zombie各自管自己的状态和动画Grid只负责坐标换算和碰撞查询。这样改僵尸血量只动Zombie.cpp改阳光掉落只动Sun.cpp互不干扰。用 CMake 管理构建跨平台也方便CMakeLists.txt里find_package(SFML 2.5 COMPONENTS graphics window system REQUIRED)然后target_link_libraries即可。2.3 素材从哪来自己画、找开源、还是程序生成植物大战僵尸的素材网上流传很多但直接商用有版权风险自己练手没问题。我一般建议先用纯色矩形和圆形占位把逻辑跑通最后再替换成图片。SFML 里sf::RectangleShape和sf::CircleShape画占位非常快改颜色就能区分豌豆射手和僵尸。等逻辑稳定了再去 OpenGameArt 这类站点找 CC0 授权的 2D 素材或者用 Aseprite 自己画几帧。注意素材尺寸要统一比如每个格子 80x80 像素精灵图按帧切分时sf::IntRect的坐标要算准否则动画会错位。字体用系统自带的或者找开源中文字体否则阳光数字显示不出来。3. 核心系统实现从阳光经济到波次刷怪3.1 网格坐标系统把像素坐标和格子编号互相转换植物大战僵尸的草坪是 5 行 9 列每个格子固定大小。所有对象的位置都基于格子但渲染要用像素。所以第一件事是写一个Grid类提供cellToPixel(row, col)和pixelToCell(x, y)两个函数。假设格子宽 80、高 100草坪左上角在(250, 100)那么// Grid.h - 网格坐标换算 struct Grid { static constexpr int ROWS 5; static constexpr int COLS 9; static constexpr float CELL_W 80.f; static constexpr float CELL_H 100.f; static constexpr float ORIGIN_X 250.f; static constexpr float ORIGIN_Y 100.f; // 格子行列 - 像素中心点 static sf::Vector2f cellToPixel(int row, int col) { return { ORIGIN_X col * CELL_W CELL_W / 2, ORIGIN_Y row * CELL_H CELL_H / 2 }; } // 像素点 - 格子行列越界返回 {-1,-1} static sf::Vector2i pixelToCell(float x, float y) { int col static_castint((x - ORIGIN_X) / CELL_W); int row static_castint((y - ORIGIN_Y) / CELL_H); if (row 0 || row ROWS || col 0 || col COLS) return {-1, -1}; return {row, col}; } };逻辑说明cellToPixel返回格子中心方便精灵居中绘制pixelToCell用于鼠标点击时判断点了哪个格子。参数上ORIGIN_X/Y是草坪偏移你可以根据窗口大小调整。注意static_castint对负数会向零截断所以越界判断必须放在转换之后否则点草坪左边会得到col0而不是-1。这个坑我踩过鼠标点到界面左侧结果种上了植物。3.2 植物基类与多态一个 vector 管所有植物豌豆射手、向日葵、坚果墙行为不同但都有血量、位置、更新、绘制。用基类指针 虚函数是标准做法。std::vectorstd::unique_ptrPlant持有所有植物每帧遍历调用update和draw多态自动分发到具体类型。// Plant.h - 植物基类 class Plant { public: Plant(int row, int col, int hp) : row_(row), col_(col), hp_(hp), pos_(Grid::cellToPixel(row, col)) {} virtual ~Plant() default; virtual void update(float dt) 0; // 纯虚子类必须实现 virtual void draw(sf::RenderWindow w) 0; bool isDead() const { return hp_ 0; } int row() const { return row_; } int col() const { return col_; } sf::Vector2f pos() const { return pos_; } protected: int row_, col_, hp_; sf::Vector2f pos_; float timer_ 0.f; // 通用计时器子类用来控制射击/产阳光间隔 }; // 豌豆射手每 1.5 秒发射一颗子弹 class Peashooter : public Plant { public: Peashooter(int r, int c) : Plant(r, c, 100) {} void update(float dt) override { timer_ dt; if (timer_ 1.5f) { timer_ 0.f; // 通知 Game 生成子弹实际项目用回调或事件队列 shootRequested_ true; } } void draw(sf::RenderWindow w) override { sf::CircleShape shape(30.f); shape.setFillColor(sf::Color::Green); shape.setPosition(pos_); w.draw(shape); } bool consumeShoot() { bool s shootRequested_; shootRequested_ false; return s; } private: bool shootRequested_ false; };逻辑说明基类存row_/col_/hp_和像素位置update接收dt帧间隔秒数子类自己累加计时器。豌豆射手到 1.5 秒就置一个标志位Game每帧检查并生成子弹。参数上dt来自sf::Clockrestart()返回秒数这样动画速度与帧率无关。注意unique_ptr不能拷贝往 vector 里加要用std::move或emplace_back。血量、射速这些数值我一般放在头文件常量里方便调平衡。3.3 僵尸波次与碰撞用时间轴控制难度曲线僵尸不能一窝蜂出来要有波次。简单做法是维护一个spawnTimer每隔几秒从右侧生成一只僵尸随着时间推移间隔缩短。僵尸从右往左移动碰到植物就啃碰到房子就游戏结束。碰撞检测用矩形相交即可SFML 的sf::FloatRect有intersects方法。// Zombie.h - 僵尸移动与啃食 class Zombie { public: Zombie(int row) : row_(row), hp_(200) { pos_ { 900.f, Grid::ORIGIN_Y row * Grid::CELL_H 50.f }; } void update(float dt, std::vectorstd::unique_ptrPlant plants) { // 检查当前格是否有植物 auto cell Grid::pixelToCell(pos_.x, pos_.y); Plant* target nullptr; for (auto p : plants) { if (p-row() cell.y p-col() cell.x) { target p.get(); break; } } if (target) { // 有植物就啃每秒扣 50 血 target-takeDamage(50.f * dt); } else { pos_.x - speed_ * dt; // 没植物就前进 } } bool reachedHouse() const { return pos_.x Grid::ORIGIN_X; } private: int row_, hp_; float speed_ 20.f; // 像素/秒 sf::Vector2f pos_; };逻辑说明每帧先算僵尸在哪个格子遍历植物找同格的找到就啃找不到就左移。speed_ 20意味着穿过一个 80 像素格子要 4 秒节奏合适。参数上僵尸血量 200、啃食速度 50/秒豌豆射手每颗子弹 25 伤害所以打死一只僵尸要 8 颗约 12 秒这期间它可能啃掉一棵植物。这些数值需要反复调建议做成配置文件或常量表。注意pixelToCell返回的cell.y是行cell.x是列别搞反了我见过有人把行列写反导致僵尸啃空气。3.4 阳光经济定时掉落与点击收集阳光是核心资源向日葵产阳光、天上掉阳光、点击收集。用一个Sun类带生命周期掉到地上停留几秒后消失。点击检测用sf::Mouse::getPosition(window)拿到鼠标坐标判断是否在阳光圆形范围内。// Sun.h - 阳光对象 class Sun { public: Sun(sf::Vector2f start, sf::Vector2f target) : pos_(start), target_(target), alive_(true) {} void update(float dt) { // 向目标点移动简单线性插值 sf::Vector2f dir target_ - pos_; float len std::sqrt(dir.x * dir.x dir.y * dir.y); if (len 1.f) { pos_ (dir / len) * 100.f * dt; // 100 像素/秒 } else { life_ - dt; // 到达后开始倒计时 if (life_ 0.f) alive_ false; } } bool contains(sf::Vector2f mouse) const { float dx mouse.x - pos_.x, dy mouse.y - pos_.y; return dx * dx dy * dy 30.f * 30.f; // 半径 30 } bool alive() const { return alive_; } sf::Vector2f pos() const { return pos_; } private: sf::Vector2f pos_, target_; float life_ 8.f; // 停留 8 秒 bool alive_; };逻辑说明阳光从生成点飞向目标点到达后开始 8 秒倒计时超时消失。contains用平方距离避免开方半径 30 像素。参数上飞行速度 100 像素/秒停留 8 秒这些影响手感。收集时遍历阳光容器命中就alive_ false并给玩家加 25 阳光。注意每帧要清理alive_ false的对象用std::remove_if配合erase否则容器越积越大。4. 避坑与排查那些让我重写三遍的坑4.1 现象精灵显示成白色方块图片加载失败原因sf::Texture加载路径错误或者纹理对象在精灵使用前就被析构了。SFML 的Sprite只持有纹理的指针不管理生命周期。如果你在函数里创建Texture然后返回Sprite函数结束纹理就没了精灵就变白块。解决把Texture作为类的成员变量或者放在一个全局资源管理器里确保它活得比Sprite长。加载后用if (!texture.loadFromFile(assets/images/peashooter.png)) { /* 打印错误 */ }检查返回值路径用相对路径时注意工作目录是 exe 所在目录还是项目根目录VS 里默认是项目目录直接运行 exe 是 exe 目录这个差异坑过很多人。4.2 现象游戏跑一会儿就卡顿帧率从 60 掉到 20原因每帧都在创建和销毁对象比如子弹、阳光频繁new/delete导致内存碎片和分配开销。或者碰撞检测用了 O(n²) 的双重循环对象一多就爆。解决对象池。子弹和阳光预先分配一批用的时候取不用的时候标记为不活跃而不是删除。碰撞检测先按行筛选僵尸只和同一行的植物比复杂度从 O(n²) 降到 O(n)。另外sf::Font和sf::Texture绝对不要在循环里加载加载一次缓存起来。4.3 现象点击种植时植物种到了错误的格子原因鼠标坐标是屏幕坐标但草坪有偏移而且窗口可能有缩放。sf::Mouse::getPosition(window)返回的是窗口内坐标但如果你的视图做了缩放或平移还需要用window.mapPixelToCoords转换。解决统一用mapPixelToCoords把鼠标坐标转成世界坐标再传给pixelToCell。另外注意pixelToCell的越界判断点到草坪外要返回{-1,-1}并忽略。我当初没做越界判断点界面左边结果种到了第 0 列看起来像 bug。4.4 现象僵尸重叠在一起像一串糖葫芦原因同一行多个僵尸没有间隔控制生成时都在同一个 x 坐标移动速度又一样就叠在一起了。解决生成时检查该行最右边的僵尸位置如果距离小于一个格子宽度就延后生成或者给每个僵尸一个随机的小速度偏移。更简单的做法是维护每行的生成冷却同一行两次生成至少间隔 2 秒。这样僵尸会自然拉开距离。4.5 现象程序退出时崩溃报访问违规原因容器里存的是裸指针删除时重复释放或者某个对象已经被移除但其他地方还在引用。解决一律用std::unique_ptr或std::shared_ptr不要手动delete。清理容器用erase(std::remove_if(...), end())模式确保只删一次。如果对象之间有引用关系比如子弹持有目标僵尸的指针僵尸死后子弹要能检测到目标失效用weak_ptr或者每帧检查目标是否还在容器里。5. 进阶技巧用状态机和数据驱动把项目做成可扩展的5.1 用有限状态机管理游戏流程游戏有开始菜单、选卡、对战、暂停、胜利、失败几个状态。新手容易用一堆if-else判断当前状态代码很快就乱。正确做法是写一个GameState枚举和状态机每个状态有enter、update、exit三个虚函数Game持有当前状态指针切换时调用exit和enter。// GameState.h - 状态基类 class GameState { public: virtual ~GameState() default; virtual void enter(Game g) 0; virtual void update(Game g, float dt) 0; virtual void exit(Game g) 0; }; class PlayingState : public GameState { public: void enter(Game g) override { // 初始化波次、阳光、清空容器 g.resetLevel(); } void update(Game g, float dt) override { g.updatePlants(dt); g.updateZombies(dt); g.updateSuns(dt); g.checkWinLose(); } void exit(Game g) override { // 保存进度或清理 } };逻辑说明Game里存std::unique_ptrGameState state_切换时state_-exit(*this); state_ std::make_uniqueNewState(); state_-enter(*this);。这样加一个新状态比如「图鉴」只需要写一个新类不用动主循环。参数上dt继续传递保证动画一致。5.2 数据驱动把植物和僵尸数值抽到表格硬编码数值改起来痛苦。我一般用一个简单的 CSV 或 JSON 存植物属性名称、血量、射速、伤害、阳光消耗。启动时读进std::unordered_mapstd::string, PlantData创建时按名字查表。这样调平衡不用重新编译改文件就行。比如名称血量射速(秒)伤害阳光豌豆射手1001.525100向日葵1008.0050坚果墙400--50寒冰射手1001.520175读表用std::ifstream逐行解析注意处理空行和注释。这个表也是你调难度的主要抓手僵尸血量、速度同理。5.3 验证方法怎么确认你的实现是对的写完一个系统别急着往下做先单独验证。阳光系统让向日葵每秒产一次看 10 秒后阳光数是不是 10 的倍数。碰撞把僵尸速度调到 200看它是不是快速穿过植物而不是卡住。波次把生成间隔调到 0.5 秒看会不会崩溃或卡顿。我习惯在update里加临时std::cout打印关键变量确认无误再删掉。另外用sf::Clock打印每帧耗时超过 16 毫秒就说明有性能问题需要优化。最后说个我自己的习惯每完成一个功能就git commit一次写清楚改了什么。有次我调僵尸速度调崩了想回退发现没存版本只能重写。从那以后我养成了小步提交的习惯后悔药随时有。这个项目从环境搭建到能玩我断断续续花了两周踩的坑基本都写在上面的。你如果照着做遇到卡住的地方先检查坐标换算和对象生命周期八成问题出在这两处。希望帮到你。本文还有配套的精品资源点击获取
返回列表