资讯详情

用C++复刻植物大战僵尸:核心战斗模型与代码实现详解

📅 2026/9/29 11:06:11 | 华诺云谱 👁 阅读
用C++复刻植物大战僵尸:核心战斗模型与代码实现详解
简介一份基于C实现的植物大战僵尸模型与完整工程代码适合C初学者和游戏开发爱好者学习。压缩包共109个文件大小约15.82MB包含Visual Studio工程文件sln/vcxproj、C源码、编译生成的exe可执行程序以及调试所需的pdb、obj等中间文件同时附有游戏运行所需的mp3音频、金币阳光等素材完整覆盖从源码到可运行程序的链路。目前已有5879人学习下载热度可见一斑。这份资源能帮助读者直观理解面向对象设计、游戏主循环、碰撞检测、资源管理等核心知识点通过阅读源码并运行exe对比实际效果可快速掌握塔防游戏的基础实现思路。对于需要课程设计或入门游戏开发练手的读者是相当实用的参考工程。1. 用C复刻植物大战僵尸模型和代码的核心边界我刚开始练C的时候也想过做一个植物大战僵尸的完整游戏。结果贴图、音效、动画一上来代码就乱了最后连一个豌豆射手都打不死僵尸。后来我才想明白最值钱的部分不是画面而是背后的模型植物什么时候射击子弹往哪飞僵尸怎么扣血冷却怎么计时。这套东西如果立不住换再好看的素材也是白搭。这篇笔记不讲怎么用SDL渲染一棵向日葵而是专注于“模型和代码”本身——用C把植物大战僵尸的核心战斗逻辑拆开让你在不依赖图形库的情况下先把可运行的逻辑骨架跑起来。你会看到一个能直接编译的最小战斗模拟也会看到每个关键参数该怎么调以及我踩过的五个坑。适合已经学过C语法、想做个像样项目的新手也适合拿来当课程设计的第一版原型。2. 先搭对象模型植物、僵尸、子弹的类设计与数据流2.1 基类与继承让植物和僵尸共享“战斗单位”的接口写任何游戏逻辑之前先把战斗单位抽象出来。植物和僵尸虽然行为完全不一样但它们都有行、列、生命值、攻击伤害这四样东西。所以我设计了一个Entity基类再用Peashooter和Zombie去继承它。这样做的好处是你可以把植物和僵尸放在同一个容器里统一用基类指针调用update后面做范围判定、死亡清理都会简单很多。class Entity { public: Entity(int row, int col, int hp, int damage) : row(row), col(col), hp(hp), maxHp(hp), attackDamage(damage) {} virtual ~Entity() default; // 每帧更新具体行为由子类决定 virtual void update(double dt) 0; bool alive() const { return hp 0; } int row; int col; int hp; int maxHp; int attackDamage; }; class Peashooter : public Entity { public: Peashooter(int row, int col) : Entity(row, col, 100, 20) {} void update(double dt) override { // 射击计时逻辑写在这里 } }; class Zombie : public Entity { public: Zombie(int row, int col) : Entity(row, col, 100, 10) {} void update(double dt) override { // 向左移动走到防线就攻击植物 col - 1; } };这个例子里坐标先用int是因为网格是离散的。但现实中的僵尸移动需要一个连续位置不然动画会一顿一顿。所以实际项目里我会把row保持为整数表示“在第几行”另外加一个double x表示“当前水平位置”。这两个字段不要合并成一个否则后面子弹碰撞判定会非常别扭。为什么用纯虚函数因为植物和僵尸的update确实不同。你不需要担心虚函数性能这个规模下开销可以忽略先把结构做对再去想优化。这里还有一个容易忽略的点析构函数要声明成virtual这样通过基类指针delete派生对象时才不会只析构一半。2.2 网格地图的选型二维数组还是vector地图是5行9列新手第一反应是写一个二维数组。但对于一个会持续增删对象的游戏二维数组删除元素很麻烦你只能把某个格子标记成空位。所以我更推荐用vector嵌套每个格子放一个unique_ptr空位就是nullptr// 5行9列每个格子放一个unique_ptr空位是nullptr std::vectorstd::vectorstd::unique_ptrEntity grid( 5, std::vectorstd::unique_ptrEntity(9));这里有一个很重要的设计决定僵尸是移动单位不适合被绑死在网格里。如果你把僵尸也塞进grid那么每一帧移动后都要重新计算它属于哪个格子还要担心两个单位挤在同一格纯属给自己找麻烦。我一般把场景拆成两层静态单位植物放进grid动态单位僵尸按行管理。僵尸身上保留row字段表示它在哪条横线上运动x字段表示它在横线上的具体位置。子弹、僵尸移动、攻击判定都发生在这套数据结构上。std::vectorstd::vectorstd::unique_ptrEntity plantGrid; // 只放植物 std::vectorstd::vectorZombie zombiesByRow; // 每行一个僵尸列表这种选型的最大好处在战斗判定子弹只检查自己所在行的僵尸列表不用全图遍历。当一张地图上有几十个僵尸时这个差异非常明显。2.3 游戏主循环用固定时间步长把逻辑和渲染分开新手写游戏循环通常是while (true) { update(); render(); }然后Sleep(16)。这个写法在低帧率电脑上逻辑变慢在高帧率电脑上逻辑变快同一个代码在不同机器上难度完全不同。植物大战僵尸这种塔防游戏玩家对时间流逝极敏感僵尸什么时候走到防线必须稳定。所以我在项目里用的是固定时间步长累加器#include chrono double fixedTimeStep 1.0 / 60.0; // 每秒60次逻辑更新 double accumulator 0.0; auto lastTime std::chrono::steady_clock::now(); while (running) { auto now std::chrono::steady_clock::now(); accumulator std::chrono::durationdouble(now - lastTime).count(); lastTime now; while (accumulator fixedTimeStep) { update(fixedTimeStep); accumulator - fixedTimeStep; } render(); }这里update收到的dt永远是1/60秒跟屏幕刷新率是60Hz还是144Hz无关。如果某帧卡了一下accumulator会累积多个fixedTimeStep下一帧会补上之前欠的逻辑更新。子弹速度、植物冷却、僵尸移动都基于这个dt所以只要dt固定逻辑就是一致且可复现的。如果你直接把真实帧间隔当dt一旦渲染掉帧子弹单帧位移变大就可能从僵尸身上穿过去。第5章会重点讲这个翻车现场主循环这里就是第一道防线。3. 关键机制实现阳光生产、攻击判定与子弹飞行3.1 阳光的生成与收集定时器与碰撞盒阳光是植物大战僵尸的资源循环核心。向日葵每隔一段时间生成一个阳光阳光掉落一段时间后可以被收集。这个机制本身不复杂但有两个点容易写乱生成计时和收集判定。我习惯把阳光定义成一个独立结构体因为它既不是植物也不是僵尸只是一个飘落的“资源物件”struct Sun { double x, y; // 当前坐标 double fallSpeed; // 每秒下落多少格 double lifeTime; // 剩余存活时间 bool collected; // 是否已被收集 }; std::vectorSun suns;生成逻辑直接写在向日葵的update里用local timer累加dt。到时间了就push一个Sun然后重置计时器sunTimer dt; if (sunTimer sunInterval) { sunTimer - sunInterval; suns.push_back({col, row, 1.2, 8.0, false}); }这里的参数含义是sunInterval是生成间隔通常8秒一个1.2是下落速度代表每秒移动1.2格8.0是存活时间超过8秒还没被收集的阳光会消失。这三个值直接影响玩家前期能否顺利种下第一株植物建议先用保守值测试。收集判定最简单的做法是给每个阳光一个圆形碰撞盒半径0.4格。玩家点击时计算出点击坐标和当前阳光圆心的距离小于半径就算收集成功。如果你要做自动收集可以把碰撞盒从圆形改成矩形但要注意地图坐标和鼠标屏幕坐标之间的转换这一步漏掉的话你会发现点哪里都收集不到阳光。3.2 攻击判定用“行”做简化避免像素级碰撞豌豆子弹是直线飞行的它只会命中同一行里的僵尸。所以我做碰撞检测时不会一上来就计算子弹矩形与僵尸矩形的相交而是先判断行号是否一致再判断x坐标是否落在僵尸的横向范围内。for (auto it bullets.begin(); it ! bullets.end();) { it-x it-speed * dt; bool bulletHit false; for (auto zombie : zombiesByRow[it-row]) { if (it-x zombie.x - 0.3 it-x zombie.x 0.3) { zombie.hp - it-damage; bulletHit true; break; } } if (bulletHit || it-x 9.0) { it bullets.erase(it); } else { it; } }这里最关键的参数是0.3这是僵尸的“有效碰撞半宽”。僵尸实际图片宽度可能超过1格但战斗判定不需要那么精确给一个范围就够了。注意我用的是区间判断而不是等号判断。浮点数经过几十帧累加后坐标会变成0.30000000000000004这种诡异值用等号判断永远都不会触发。这套判定方式明显做了简化它假设子弹是一个点僵尸是一段宽度。如果你要支持多个僵尸需要遍历整行如果每行僵尸数量很多可以用双端队列或者按x排序来优化。但一个原型阶段能跑通比跑得快重要。3.3 植物冷却用帧计数还是时间戳种植冷却这个功能新手最容易写成int counter每帧加一加到100就允许种。这个做法在固定时间步长下没问题但一旦你改了主循环或者想实现暂停、倍速计数器就会出问题。我的做法是直接用游戏时间戳double lastPlantTime 0.0; const double plantCooldown 5.0; // 在玩家点击格子时 if (gameTime - lastPlantTime plantCooldown) { // 允许种植 lastPlantTime gameTime; } else { // 提示“还在冷却中” }这里的gameTime不是std::chrono::system_clock::now()而是update里持续累加的全局逻辑时间。为什么不用真实时钟因为游戏要支持暂停、加速、减速真实时钟不会听你的。统一用逻辑时间所有冷却和持续效果都基于它才能保证可复现。时间戳方案还有一个额外好处它天生支持“剩余冷却时间”的UI展示你只需要算一格gameTime - lastPlantTime就行。计数器方案要再乘dt才能换算成秒多一道弯。4. 把模型跑起来最小可用的C战斗模拟与控制台运行4.1 一个能直接编译的最小战斗模拟有了前面的对象设计现在写一个极简但完整的战斗模拟地图只有一行第0行放一个豌豆射手一个僵尸从右侧走过来。豌豆射手每隔1.5秒发射一颗子弹子弹以每秒3格的速度向右飞。僵尸以每秒0.5格的速度向左走。谁先死谁输。#include iostream #include vector #include chrono #include thread struct Bullet { int row; double x; double speed; int damage; }; struct Zombie { int row; double x; double hp; double speed; }; struct Plant { int row; int col; double shootTimer; double shootInterval; int damage; double bulletSpeed; }; int main() { Plant plant{0, 2, 0.0, 1.5, 20, 3.0}; Zombie zombie{0, 8.0, 100.0, 0.5}; std::vectorBullet bullets; double dt 1.0 / 60.0; double totalTime 0.0; while (totalTime 30.0) { // 植物射击累积时间到间隔就发射 plant.shootTimer dt; if (plant.shootTimer plant.shootInterval) { plant.shootTimer - plant.shootInterval; bullets.push_back({plant.row, static_castdouble(plant.col), plant.bulletSpeed, plant.damage}); } // 子弹移动与碰撞 for (auto it bullets.begin(); it ! bullets.end();) { it-x it-speed * dt; bool hit false; if (it-row zombie.row it-x zombie.x - 0.3 it-x zombie.x 0.3) { zombie.hp - it-damage; hit true; } if (hit || it-x 9.0) { it bullets.erase(it); } else { it; } } // 僵尸移动 zombie.x - zombie.speed * dt; // 打印每帧状态 std::cout t totalTime zombie_x zombie.x hp zombie.hp bullets bullets.size() \n; if (zombie.hp 0) { std::cout Win at totalTime s\n; break; } if (zombie.x 0) { std::cout Lose at totalTime s\n; break; } totalTime dt; std::this_thread::sleep_for(std::chrono::milliseconds(16)); } return 0; }这段代码可以直接复制编译运行。你会看到僵尸的血量随着子弹命中而降低最终输出Win。关键参数说明shootInterval 1.5秒本次射击间隔。1.5秒一发初始节奏偏慢但很稳。bulletSpeed 3.0格/秒子弹飞行速度。每帧位移 3 * 1/60 0.05格碰撞宽度0.3格所以不会跳过。zombie.speed 0.5格/秒僵尸移动速度。从8格走到0格需要16秒足够射手输出。zombie.hp 100damage 205发子弹打死僵尸。射手7.5秒内打死僵尸16秒才到所以必然胜利。注意代码里用的是erase移动迭代器的方式这在逻辑上没问题。实际项目里如果你担心频繁erase造成内存移动可以换成“标记待删除”再统一清理。但对于模型讲解这种写法最直观。4.2 从控制台到图形界面改哪些地方这个控制台模拟已经跑通了模型层接下来要换成SDL或raylib时你只需要替换main函数里的输出和sleep部分。把std::cout换成SDL_RenderDrawRect把sleep换成SDL_Delayupdate函数和碰撞检测逻辑可以完整保留。最常见的错误是有人一上来就开SDL窗口然后把植物和僵尸的位置全部用SDL_Rect表示导致逻辑层和渲染层完全耦合。正确做法是逻辑层永远用行列和浮点坐标渲染时才根据格子宽高计算出屏幕像素坐标。我一般会把代码拆成model.h存放Entity、Plant、Zombie、Bulletgame.cpp存放updaterender.cpp存放图形绘制。后面做图形界面只需要换render.cpp。如果你一开始就按这个结构写后面加音效、动画都会轻松很多。4.3 参数表先定这几个数值再谈平衡性在动关卡编辑器之前先把核心数值集中定义方便调。这里是我常用的初始值表格单位属性初始值备注豌豆射手生命值100被普通僵尸啃10口豌豆射手射击间隔1.5秒每次攻击生成一枚子弹豌豆射手子弹伤害205发击杀普通僵尸子弹飞行速度3格/秒到达地图边界后消失普通僵尸生命值1003发豌豆后可被坚果辅助击杀普通僵尸移动速度0.5格/秒16秒从右走到左向日葵阳光间隔8秒每次生成25阳光阳光下落速度1.2格/秒存活8秒后消失这套参数不代表原版数值只是我比较顺手的起点。调整时遵循一个原则一次只改一个参数跑一次自动战斗测试看胜率变化再改下一个。一次改三个数字最后你根本不知道是谁影响了结果。5. 避坑/常见问题C实现植物大战僵尸时最容易翻车的5个点5.1 内存管理new出来的植物和僵尸迟迟不释放现象游戏运行时间变长内存占用持续上升最后卡死。原因很多人习惯用new Zombie()创建僵尸然后放在std::vectorZombie*里。当僵尸死亡只调用了erase没有delete。更危险的是erase之后那个指针失去了索引想delete都找不到。解决不要用裸指针。用std::unique_ptr或直接存对象值。第4章的例子就把子弹定义成普通结构体放进std::vectorBullet析构自动完成。植物网格里用std::unique_ptrEntity即使你忘了delete对象离开作用域也会被自动清理。血泪教训用裸指针做游戏原型最终都会在内存泄漏排查上耗掉大量时间。5.2 更新与渲染耦合帧率变高后子弹直接穿透现象把笔记本插上电源帧率从60变成144子弹反而打不中僵尸直接穿过去。原因主循环用的是真实帧间隔当dt。显示器刷新率越高单帧时间越短但逻辑更新次数变多子弹每次移动距离不变不对如果你直接拿真实间隔当dt当帧率从60变成144dt从1/60变成了1/144子弹移动距离变小按理说更容易命中。那为什么会穿透问题反了常见的是low帧率比如渲染一帧要50msdt变成0.05子弹移动0.15格如果碰撞宽度0.3还勉强但如果你把子弹速度调到30格/秒单帧移动1.5格子弹就跨过僵尸了。解决用固定时间步长累加器dt恒定为1/60秒。这样子弹单帧位移永远是0.05格无论屏幕多卡逻辑都不会跳变。这个坑在第2.3节已经埋了伏笔但很多人在做完控制台版之后觉得固定步长啰嗦就删掉了等到换电脑跑就被打脸。5.3 硬编码数字植物属性改起来像玄学现象你把豌豆射手伤害从20改成30结果发现关卡变难了僵尸走位还更飘了。原因代码里到处是魔法数字比如if (zombie.hp - 20)你忘了这个20是伤害还是阳光扣费。调参时改了一处另一处没同步数值之间互相矛盾。解决在程序开头定义常量或者干脆用JSON配置。比如constexpr int PEASHOOTER_DAMAGE 20; constexpr int PEASHOOTER_INTERVAL 1.5; constexpr int ZOMBIE_HP 100;这样每个数字都有名字调整时只要打开一个头文件或JSON文件。第6章会写怎么用JSON驱动属性到时候连重新编译都省了。5.4 浮点数比较子弹到达检测在关键时刻失灵现象子弹明明飞到了僵尸脸上画面都重叠了僵尸就是不掉血。原因使用了if (bullet.x zombie.x)。浮点数是二进制近似0.1 0.2算出来不是0.3而是0.30000000000000004。多个帧累加后坐标很难刚好相等。解决用区间判断比如fabs(bullet.x - zombie.x) 0.3或者用第4章代码里的 zombie.x - 0.3 zombie.x 0.3。这个区间同时充当了碰撞宽度一石二鸟。记住游戏碰撞检测里永远不要写等号。5.5 控制台中文乱码植物名字输出锟斤拷现象代码里写了“豌豆射手”控制台却显示“銆婄帺銆”或“锟斤拷”。原因Windows控制台默认代码页是GBK而源文件用VSCode保存成了UTF-8两者不一致。这个问题在Linux终端上不明显在Windows下特别常见。解决最简单的是输出英文名比如Peashooter和Zombie如果想显示中文在main开头调用system(chcp 65001)并把源文件保存为UTF-8 with BOM或者用宽字符wcout和L豌豆射手。我个人的习惯是逻辑层用英文标识中文留给UI显示这样最小化编码风险。6. 进阶用JSON配置驱动植物属性并用自动战斗验证平衡性6.1 用JSON配置文件替代魔法常量当植物和僵尸种类多起来代码里的常量会变成一团乱麻。此时把属性挪到外部文件是必然选择。我通常用nlohmann/json这个单头文件库加载方式非常干净#include json.hpp #include fstream using json nlohmann::json; json config json::parse(std::ifstream(plants.json)); struct PlantConfig { int hp; double shootInterval; int damage; }; PlantConfig peashooter { config[peashooter][hp], config[peashooter][shoot_interval], config[peashooter][damage] };对应的JSON文件是这样的{ peashooter: { hp: 100, shoot_interval: 1.5, damage: 20 } }这样调整属性就变成了改文档不用重写C逻辑。尤其是做关卡设计时你可以快速试出“僵尸血量150”到底好不好玩而不需要反复编译。6.2 验证平衡性写一个自动战斗测试怎么判断一套参数是“数值合理”还是“数值失控”我通常会让两个单位在无干预下模拟1000局统计胜率。把第4章的while循环抽成一个函数bool runOneBattle(const PlantConfig p, const ZombieConfig z) { // 这里是第4章的完整战斗逻辑返回植物是否胜利 } void validateBalance() { int win 0; const int rounds 1000; for (int i 0; i rounds; i) { if (runOneBattle(peashooter, normalZombie)) win; } double winRate static_castdouble(win) / rounds; std::cout win rate : winRate \n; }正常来说一个豌豆射手对普通僵尸的胜率应该是100%因为僵尸还没走到就死了。如果你把僵尸血量调到500胜率会变成0%。这个测试就是你要的“后悔药”在投入美术资源之前先用数据决定数值大方向而不是靠手感拍脑袋。6.3 我的习惯先写日志再调参数最后分享一个血泪经验调参前一定要打日志。之前我写植物大战僵尸模型时没有日志全靠猜结果把豌豆射手射击间隔误设成0.01秒每帧发射几十颗子弹程序卡死还不知道原因。后来养成了习惯每次update只打印关键事件比如“bullet hit zombie for 20 damage”而不是每帧打印坐标。这样跑一轮下来你能清楚看到伤害发生在哪个时间点也能判断是子弹太快没检测到还是坐标错位。我现在改任何一个属性都会先跑一遍自动战斗测试再看日志最后才动数值。这套流程花不了几分钟但能让你少熬夜。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑