ARTICLE DETAIL

资讯详情

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

C++从零手搓植物大战僵尸:核心玩法与架构实战

C++从零手搓植物大战僵尸:核心玩法与架构实战 简介这是一份面向C初学者与课程设计需求者的植物大战僵尸控制台游戏源码采用状态机实时响应用户输入并通过多线程并行避免阻塞其他功能执行。代码以继承实现复用所有植物公用一个基类僵尸以普通僵尸为基类借助虚函数重写各自特殊行为同时利用STL容器管理地块中的植物、僵尸与子弹便于遍历、移除和添加。主循环遵循绘制界面、获取输入、更新状态、执行逻辑的经典结构适合用来理解游戏框架与面向对象设计。资源包共39个文件包含9个cpp与9个h源码文件、16张png与2张jpeg图片素材、1个可执行程序、1份README说明及license压缩后约337KB结构清晰、便于直接编译运行与二次开发。目前已有1062人学习下载读者可从中获得完整课设方案、状态机与多线程实践思路、继承与虚函数应用范例以及基于STL容器的对象管理方法对提升C工程能力有实际参考价值。1. 从零手搓植物大战僵尸C 小游戏到底能练到什么很多人第一次想用 C 写游戏脑子里冒出来的就是植物大战僵尸。原因很直接玩法规则清晰、格子布局天然适合面向对象、素材表现力强而且网上能搜到大量“植物大战僵尸 html 完整代码”和“植物大战僵尸 scratch 编程”的版本唯独 C 的完整实现少得可怜。这个标题指向的就是用 C 从零搭一个可运行的植物大战僵尸原型不是复刻商业版而是把“阳光收集、植物放置、僵尸寻路、子弹碰撞、波次推进”这条主循环跑通。它适合已经学过 C 基础语法、能写类但没做过完整项目的人也适合想用一个小游戏把 STL、智能指针、状态机和帧循环串起来的开发者。你不需要引擎不需要美术资源一张网格加几个矩形就能开始。2. 先定架构再写代码C 小游戏的三层拆分2.1 为什么不用引擎而是裸写帧循环用 C 做植物大战僵尸第一个岔路口是选引擎还是裸写。常见做法是裸写原因有三个。第一这个游戏的渲染需求极低就是二维网格加精灵图引入引擎反而增加构建复杂度。第二裸写能逼你把游戏循环、时间步长、输入轮询这些底层机制搞清楚这些知识换到任何引擎里都通用。第三C 小游戏的核心训练目标是内存管理和对象生命周期引擎会把这些细节藏起来。裸写帧循环的最小结构是这样// main.cpp #include chrono #include thread int main() { const double FPS 60.0; const double frameTime 1.0 / FPS; // 每帧固定时间步长 bool running true; while (running) { auto start std::chrono::high_resolution_clock::now(); // 1. 处理输入 // 2. 更新逻辑阳光增长、僵尸移动、碰撞检测 // 3. 渲染画面 auto end std::chrono::high_resolution_clock::now(); double elapsed std::chrono::durationdouble(end - start).count(); if (elapsed frameTime) { std::this_thread::sleep_for( std::chrono::durationdouble(frameTime - elapsed)); } } return 0; }这段代码的关键在frameTime和sleep_for的配合。固定时间步长意味着逻辑更新不依赖实际帧率僵尸移动速度不会因为机器快慢而变。elapsed是这一帧实际消耗的时间如果小于 16.6 毫秒就补睡防止 CPU 空转。参数上FPS 设 60 是通用选择设 30 也能跑但动画会顿设 120 对这个小游戏没有收益。2.2 用类图把植物、僵尸、子弹的关系理清裸写不等于乱写。植物大战僵尸的对象关系天然适合继承加组合。我一般会先画一张极简类图再动手类名核心职责关键成员GameObject所有实体的基类x, y, hp, alivePlant植物行为cost, cooldown, attack()Sunflower产阳光produceSun()Peashooter发射子弹shoot()Zombie僵尸行为speed, move(), eat()Bullet子弹飞行damage, move()Grid网格管理cellToPixel(), isOccupied()GameObject提供虚函数update()和render()子类各自实现。这里有个血泪经验不要把碰撞检测写进每个子类的update()里而是统一放在一个CollisionManager里遍历所有活跃对象。否则僵尸吃植物、子弹打僵尸、僵尸走到边缘这三套逻辑会散落在不同文件改一个速度参数要翻五个地方。网格坐标转换是另一个必须提前定好的事。植物大战僵尸是 5 行 9 列每个格子假设 80x100 像素那么格子(row, col)转屏幕坐标就是// grid.h struct Grid { static constexpr int ROWS 5; static constexpr int COLS 9; static constexpr int CELL_W 80; static constexpr int CELL_H 100; static constexpr int OFFSET_X 250; // 左侧留给房子和阳光栏 static constexpr int OFFSET_Y 100; static std::pairint,int cellToPixel(int row, int col) { return { OFFSET_X col * CELL_W, OFFSET_Y row * CELL_H }; } };OFFSET_X和OFFSET_Y是布局参数改这两个值就能整体平移战场。cellToPixel返回的是格子左上角像素坐标放置植物时还要加上半个格子的偏移让精灵居中。这个函数被放置逻辑、僵尸寻路、子弹生成三处调用所以必须是static且无状态。2.3 阳光系统一个被低估的状态机阳光是植物大战僵尸的经济系统也是最容易写乱的部分。新手常犯的错是让向日葵直接修改全局阳光变量结果多个向日葵同时产阳光时数值对不上。正确做法是阳光作为一个独立资源由SunManager统一管理// sun_manager.h class SunManager { public: void addSun(int amount) { sun_ amount; } bool spendSun(int amount) { if (sun_ amount) return false; sun_ - amount; return true; } int getSun() const { return sun_; } void update(double dt) { // 自然阳光每 10 秒掉落一次 naturalTimer_ dt; if (naturalTimer_ 10.0) { naturalTimer_ 0.0; pendingDrops_.push_back(randDropPosition()); } } private: int sun_ 50; // 开局给 50 double naturalTimer_ 0.0; std::vectorPosition pendingDrops_; };spendSun返回bool而不是直接扣是为了让调用方知道购买是否成功。如果返回 false放置逻辑就不应该创建植物对象。naturalTimer_用double累加而不是整数帧计数这样即使帧率波动10 秒就是 10 秒。开局 50 阳光是经典设定够种一个向日葵这个数值直接决定前期节奏改成 100 会让游戏太简单。3. 核心玩法落地放置、射击、寻路逐个跑通3.1 鼠标点击到植物放置的完整链路放置植物看起来简单实际要处理四件事点击位置是否在网格内、该格子是否已被占用、阳光是否足够、冷却是否结束。这四步任何一步失败都要给玩家反馈否则玩家会以为游戏卡了。// placement.cpp bool tryPlacePlant(int mouseX, int mouseY, PlantType type, SunManager sun) { // 1. 反算格子坐标 int col (mouseX - Grid::OFFSET_X) / Grid::CELL_W; int row (mouseY - Grid::OFFSET_Y) / Grid::CELL_H; // 2. 边界检查 if (row 0 || row Grid::ROWS || col 0 || col Grid::COLS) { return false; } // 3. 占用检查 if (grid.isOccupied(row, col)) { return false; } // 4. 资源检查 int cost getPlantCost(type); if (!sun.spendSun(cost)) { return false; } // 5. 创建植物并标记格子 auto plant createPlant(type, row, col); grid.occupy(row, col, plant); plants.push_back(std::move(plant)); return true; }反算格子坐标用的是整数除法mouseX - OFFSET_X如果为负除法结果向零取整会得到 0 而不是负数所以边界检查必须放在除法之后、且要显式判断row 0。这是 C 整数除法的一个坑Python 的//是向下取整行为不同。std::move把植物所有权转进plants容器grid里只存裸指针或weak_ptr用于查询避免双重释放。3.2 豌豆射手的子弹生成与碰撞检测豌豆射手每隔固定时间生成一颗子弹子弹沿直线向右飞碰到僵尸就扣血并消失。这里的关键是子弹的生成频率和飞行速度要匹配否则会出现子弹穿过僵尸的“隧穿”现象。// peashooter.cpp void Peashooter::update(double dt, std::vectorBullet bullets) { cooldown_ - dt; if (cooldown_ 0.0 hasZombieInRow(row_)) { cooldown_ 1.4; // 攻击间隔 1.4 秒 auto [px, py] Grid::cellToPixel(row_, col_); bullets.emplace_back(px 60, py 30, row_, 20); // 伤害 20 } } // bullet.cpp void Bullet::update(double dt) { x_ speed_ * dt; // speed_ 单位是像素/秒 if (x_ SCREEN_WIDTH) alive_ false; }cooldown_用秒而不是帧配合dt累减。hasZombieInRow是优化同一行没有僵尸就不生成子弹省去无意义的碰撞检测。子弹速度我一般设 300 像素/秒僵尸移动速度设 20 像素/秒这样一颗子弹从最左飞到最右大约 2.4 秒期间僵尸只走 48 像素不会出现子弹追不上僵尸的尴尬。如果子弹速度设太低比如 100僵尸走一格的时间子弹才飞半格视觉上就像子弹在飘。碰撞检测用简单的 AABB 就够bool checkCollision(const Bullet b, const Zombie z) { return b.x_ z.x_ ZOMBIE_W b.x_ BULLET_W z.x_ b.row_ z.row_; }因为子弹和僵尸都在同一行所以只需要判断 x 轴重叠和行号相等。ZOMBIE_W和BULLET_W是精灵宽度设成实际贴图宽度即可。这里不需要 y 轴判断省一次比较。3.3 僵尸寻路的简化实现与波次推进植物大战僵尸的僵尸寻路其实不需要 A*因为路径就是一条直线。僵尸从右侧出现沿所在行向左走遇到植物就停下来啃。真正需要设计的是波次系统。// wave_manager.cpp class WaveManager { public: void update(double dt, std::vectorZombie zombies) { timer_ dt; if (timer_ nextWaveTime_) { spawnWave(zombies); waveIndex_; timer_ 0.0; nextWaveTime_ 20.0 waveIndex_ * 5.0; // 每波间隔递增 } } private: void spawnWave(std::vectorZombie zombies) { int count 2 waveIndex_; // 每波僵尸数递增 for (int i 0; i count; i) { int row rand() % Grid::ROWS; zombies.emplace_back(SCREEN_WIDTH, row); } } int waveIndex_ 0; double timer_ 0.0; double nextWaveTime_ 20.0; };nextWaveTime_随波次递增给玩家喘息时间。spawnWave里僵尸行号随机但实际游戏中应该避免同一波全挤在一行可以加一个简单的去重如果连续三个僵尸同一行就重新随机。僵尸数量2 waveIndex_是线性增长到第 10 波就是 12 个配合植物火力需要玩家合理布局。如果想让难度曲线更陡可以改成2 waveIndex_ * 1.5。4. 避坑与排查C 小游戏最容易翻车的五个地方4.1 现象程序跑几分钟后越来越卡内存持续上涨原因子弹和僵尸对象被标记为alive_ false后没有从容器中移除std::vector只增不减。每帧还在遍历这些死对象做碰撞检测。解决每帧更新结束后做一次清理用 erase-remove 惯用法bullets.erase( std::remove_if(bullets.begin(), bullets.end(), [](const Bullet b) { return !b.alive_; }), bullets.end());注意不能在遍历bullets的过程中直接 erase迭代器会失效。清理要放在所有更新逻辑之后、渲染之前。4.2 现象植物种下去后僵尸还是从它身上走过去原因僵尸的碰撞检测只判断了子弹没有判断植物。僵尸的update里缺少“前方是否有植物”的检查。解决在僵尸移动前先查询当前格子是否被占用void Zombie::update(double dt, Grid grid) { int col (x_ - Grid::OFFSET_X) / Grid::CELL_W; if (grid.isOccupied(row_, col)) { // 有植物进入啃食状态 state_ ZombieState::Eating; grid.damagePlant(row_, col, eatDamage_ * dt); return; } x_ - speed_ * dt; }eatDamage_ * dt让啃食伤害按时间累积而不是每帧固定扣血这样帧率变化不影响平衡。4.3 现象点击植物卡片后阳光扣了但植物没种上原因spendSun在创建植物之前调用如果后续创建失败比如格子被占用阳光已经扣了但没退还。解决把资源检查放在最前面确认所有条件都满足后再扣阳光。或者用“预扣加回滚”模式先扣创建失败时addSun(cost)退回去。我一般用前者逻辑更清晰。4.4 现象窗口关闭后进程还在后台任务管理器里能看到原因帧循环的running标志没有被窗口关闭事件正确设置或者sleep_for阻塞了事件处理。解决把事件轮询放在帧循环最开头且不要用阻塞式输入。如果用 SDL 或 SFMLPollEvent必须每帧调用。裸写 Win32 的话PeekMessage而不是GetMessage。另外running要用std::atomicbool如果事件回调和主循环在不同线程。4.5 现象僵尸走到最左边后不消失继续往左走导致坐标溢出原因缺少左边界销毁逻辑或者销毁条件写成了x_ 0但僵尸的x_是精灵中心而不是左上角。解决统一用精灵左上角作为坐标原点销毁条件设为x_ ZOMBIE_W 0。同时加一个兜底如果x_小于某个极小值比如 -1000强制销毁防止浮点误差导致对象永远不满足条件。5. 进阶技巧用数据驱动让关卡配置不再硬编码写到后面你会发现每加一种植物或僵尸就要改一堆if-else这就是硬编码的代价。我后来改成用一张配置表驱动植物和僵尸的属性全部从外部读取代码里只保留行为逻辑。// plant_config.h struct PlantConfig { std::string name; int cost; double cooldown; int hp; int damage; double attackInterval; }; std::unordered_mapPlantType, PlantConfig loadConfigs() { return { { PlantType::Sunflower, { Sunflower, 50, 7.5, 300, 0, 0.0 } }, { PlantType::Peashooter, { Peashooter, 100, 7.5, 300, 20, 1.4 } }, { PlantType::Wallnut, { Wallnut, 50, 30.0, 4000, 0, 0.0 } }, }; }这张表把费用、冷却、血量、伤害、攻击间隔全部集中管理。改数值不用重新编译逻辑代码调平衡时只动这一个文件。cooldown是种植冷却attackInterval是攻击间隔向日葵的attackInterval为 0 表示不攻击。Wallnut血量 4000 是原版设定僵尸啃食伤害按每秒 100 算能扛 40 秒。验证配置是否生效我一般会写一个简单的自检函数void validateConfigs(const std::unordered_mapPlantType, PlantConfig configs) { for (const auto [type, cfg] : configs) { assert(cfg.cost 0 cost must be non-negative); assert(cfg.hp 0 hp must be positive); assert(cfg.cooldown 0.0 cooldown must be non-negative); } }assert在 Debug 下生效Release 下被优化掉不影响性能。这个自检在加载配置后立即调用能在开发阶段就发现数值填错的问题而不是等到运行时出现负费用或者零血量。还有一个实用技巧是给游戏加一个“时间缩放”调试开关。按住某个键时dt乘以 0.25游戏变慢动作方便观察碰撞和寻路细节。这个功能在调僵尸速度和子弹速度匹配时特别有用比反复改代码重新编译快得多。实现上就是在帧循环里判断按键状态然后调整传给update的dt值渲染逻辑不受影响。我自己写这个项目最大的教训是一开始总想先把画面做好看结果渲染代码写了一堆核心逻辑还是空的。后来改成先用矩形色块代替精灵把放置、射击、啃食、波次全部跑通再替换贴图效率高了一倍不止。如果你也打算动手建议第一版连图片都不要加载纯色块加控制台输出就能验证 90% 的逻辑。希望帮到你。本文还有配套的精品资源点击获取
返回列表