C++植物大战僵尸实战:面向对象与碰撞检测全解析

发布时间:2026/10/17 2:56:19
C++植物大战僵尸实战:面向对象与碰撞检测全解析 简介一份基于C实现的植物大战僵尸完整工程面向具备基础语法知识、希望实践游戏开发的C学习者。工程通过塔防玩法复现覆盖面向对象类设计、图形界面交互、主循环逻辑、碰撞检测、资源管理等关键模块适合作为入门游戏开发的练手项目。压缩包共109个文件大小15.82MB包含C源码、Visual Studio解决方案与工程配置、编译生成的exe可执行程序以及金币、阳光、mp3音效等游戏素材还附有pdb、obj、ilk等编译调试中间文件便于深入对照分析。这份资源已吸引5882人学习下载后可直接运行或二次修改结合代码能清晰理解植物与僵尸的种类派生、A*寻路算法、对象池设计等核心实现是自学C游戏开发不错的参考资料也可作为课程设计参考。1. 一个能跑的C版植物大战僵尸项目定位与它能解决什么有同学拿课程设计题目“用C实现植物大战僵尸”来问我说网上能跑的代码要么太简陋玩法残缺要么套了一堆看不懂的引擎框架想改个植物都不知道从哪下手。我翻了几个版本之后最认可的还是一套纯C面向对象写法的源码图形层用轻量级图形库完成不依赖大型引擎工程按模块拆开编译出来就是一个能完整对局的经典游戏。它适合两类人——刚学完语法想做游戏练手的学生以及想快速拿一套干净代码做二次开发的开发者。这篇拆解会把对象模型、碰撞检测、种植逻辑、踩坑点全部过一遍让你拿到的是一份可改的骨架而不是黑匣子。2. 技术栈与工程结构纯C面向对象图形库只做“门面”2.1 图形库选择为什么用 EasyX 而不是大型引擎这个项目的核心玩法逻辑全部由 C 代码自己驱动画面渲染、鼠标输入、计时器这些底层能力由一个轻量级图形库提供。常见做法是选 EasyX它本质是对 Windows 图形接口的一层封装没有引入像 Unity 或 Cocos 那样的运行时环境几个函数就能搞定贴图、画矩形、监听鼠标消息对教学和课程设计来说性价比很高。选型理由其实就三条第一API 足够简单新建一个窗口只需要initgraph(width, height)画一张图片只需要putimage(x, y, img)上手成本几乎为零第二和 Visual Studio 配合好安装后直接在工程里包含头文件就能用不需要配置复杂的链接库路径第三对这个项目来说渲染任务很轻——草地背景、植物贴图、僵尸贴图、阳光图标都是 2D 位图EasyX 完全扛得住。我一般建议用 VS2019 或更新版本跑这个工程创建项目时选“空项目”然后把源码文件全部添加进去。需要注意的是 EasyX 按编译器版本区分安装包装错版本的典型症状是编译报错找不到头文件后面避坑章节会专门讲。2.2 目录结构哪个文件管哪件事源码按模块拆得比较干净每个文件只负责一类对象。我按自己习惯重排过的目录大概是这样的PlantWarZombie/ ├── main.cpp # 入口窗口初始化、消息循环、主循环 ├── game.h / game.cpp # Game类全局状态、对象管理、生成调度 ├── object.h # GameObject基类坐标、矩形、统一接口 ├── plant.h / plant.cpp # 植物基类 向日葵 豌豆射手 ├── zombie.h / zombie.cpp # 僵尸类移动、啃食、前进判定 ├── bullet.h / bullet.cpp # 子弹类飞行、伤害、碰撞检测 ├── sun.h / sun.cpp # 阳光实体下落、收集、加分 ├── map.h / map.cpp # 草地棋盘9x5格子、格子坐标换算 └── resource.h # 常量定义贴图ID、窗口尺寸、单元格大小这个结构的核心思路是“按对象分文件”。面向对象开发游戏时最忌讳把植物、僵尸、子弹的逻辑全部堆在 main.cpp 里改一个功能就要在几千行代码里找半天。拆开之后你要加新植物就只动 plant 相关文件要调僵尸速度就只改 zombie.cpp互不干扰。resource.h里定义的是全局常量比如窗口宽 900、高 600每个格子宽 80、高 100棋盘左上角坐标偏移量等等。把这些数值抽成常量而不是散落在代码里后期调整布局只需要改一处。2.3 一帧数据流从输入到画面整个游戏跑在“更新-渲染”的循环里。大步长是先处理用户的鼠标输入更新游戏状态植物开火、子弹移动、僵尸啃食、阳光下落最后统一绘制到屏幕上。为了减少闪烁绘制前会调用BeginBatchDraw()全部画完再EndBatchDraw()一次性提交到屏幕。一个典型帧的内容用伪代码描述是while (运行游戏) { 处理鼠标消息(); // 种植、收集阳光等输入操作 更新所有植物(); // 冷却计时、生成阳光、发射子弹 更新所有子弹(); // 飞行、碰撞、删除 更新所有僵尸(); // 移动、啃食、判定死亡 更新所有阳光实体(); // 下落、消失 渲染场景(); // 先画棋盘再画对象 }这里的顺序是有讲究的植物先生成子弹子弹再飞行和碰撞僵尸再移动和攻击渲染放在最后。如果先画再改逻辑一帧之内就会出现“明明子弹打中了僵尸画面却还显示僵尸往前走了一步”的延迟感。2.4 资源引用贴图从哪来工程里通常带一个 res 目录存放植物、僵尸、阳光的位图素材代码里通过loadimage(img, _T(res/peashooter.png))载入。注意 EasyX 要求图片路径编码和项目字符集匹配项目属性里字符集没设置对就会在这里翻车这个问题第 5 章会展开。3. 核心模块拆解植物、僵尸、子弹的类模型与战场交互3.1 对象基类坐标、碰撞矩形的统一约定“模型”在这个项目里不是指 3D 建模而是指游戏对象的数据模型——每个对象有哪些属性、对外提供什么接口。所有战场对象都继承自同一个基类它约定好坐标、尺寸和两个纯虚行为接口// object.h class GameObject { public: int x, y; // 对象中心点坐标不是左上角 int width, height; // 绘制贴图的宽高 int hp; // 生命值0或负表示死亡 bool alive; // 是否还存活在场景中 virtual void onUpdate(float dt) 0; // 每帧更新逻辑 virtual void onDraw() 0; // 每帧绘制自身 // 用一个较小的矩形做碰撞体避免贴图透明区域误判 virtual bool isHit(GameObject* other) { int halfSelfW width / 4; int halfSelfH height / 4; int halfOtherW other-width / 4; int halfOtherH other-height / 4; return abs(x - other-x) halfSelfW halfOtherW abs(y - other-y) halfSelfH halfOtherH; } };对象坐标统一用中心点而不是左上角这样碰撞判断可以直接用中心距公式写起来直观。碰撞矩形取了贴图尺寸的一半左右是因为大多数植物和僵尸贴图四周有透明边整张图片直接参与碰撞会让人觉得“明明没碰到也算碰到了”。这里用 1/4 尺寸是一个经验值不同物体可以各自覆写。alive标志很重要对象进入死亡状态时先把它标记为alive false而不是立刻从对象列表里删除。这样遍历列表的时候不会出现“删一个元素导致迭代器失效”的崩溃。3.2 植物基类抽象出“种植成本 行为接口”植物是所有交互的核心它的派生类共享种植参数但行为各不一样。基类Plant只管阳光消耗、冷却时间和种植格坐标派生类覆写onUpdate就能实现不同的战场功能// plant.h —— 植物基类与两个具体植物 class Plant : public GameObject { public: int sunCost; // 种植需要的阳光数 float coolTime; // 种植冷却时间秒 float coolTimer; // 冷却剩余时间 int row, col; // 所在棋盘行列用于判断“这行有没有僵尸” Plant() { alive true; coolTimer 0.0f; } virtual void onUpdate(float dt) 0; virtual void onDraw() 0; }; // 向日葵每隔一段时间在周围生成一个阳光 class Sunflower : public Plant { private: float produceInterval; // 生成阳光的间隔 float produceTimer; public: Sunflower(int row_, int col_) { hp 150; sunCost 50; coolTime 8.0f; row row_; col col_; produceInterval 8.0f; produceTimer produceInterval; } void onUpdate(float dt) override { produceTimer - dt; if (produceTimer 0.0f) { produceTimer produceInterval; Game::instance().createSun(row, col); // 生成阳光实体 } } }; // 豌豆射手只有这一行存在僵尸时才开火 class Peashooter : public Plant { private: float fireInterval; float fireTimer; public: Peashooter(int row_, int col_) { hp 120; sunCost 100; coolTime 6.0f; row row_; col col_; fireInterval 1.4f; fireTimer 0.0f; } void onUpdate(float dt) override { fireTimer - dt; if (fireTimer 0.0f Game::instance().hasZombieInRow(row)) { fireTimer fireInterval; Game::instance().createBullet(row, getX(), getY()); } } };核心逻辑说明豌豆射手开火前会先问Game::instance().hasZombieInRow(row)——这一行有没有僵尸。原版游戏里植物不会盲目开火这个判断就是为还原这个行为。hasZombieInRow在 Game 内遍历僵尸链表只要有任意一个僵尸的row等于目标行且位置在植物左侧就返回 true。createSun和createBullet由 Game 统一管理而不是植物自己new一个新对象扔到某个全局数组里。对象统一由 Game 的addObject方法加入待创建队列主循环里统一 flush 进场景。这么做的原因是为了避免在遍历场景对象时动态插入元素导致容器迭代器失效。3.3 僵尸移动、啃食、进家判定僵尸的行为比植物更简单只有三种状态前进、啃食、死亡。代码里用isEating区分前进和啃食一旦遇到正前方有植物就停下来开咬// zombie.cpp —— 僵尸的移动与攻击判定 void Zombie::onUpdate(float dt) { if (!alive) return; if (isEating) { // 正在啃食植物按攻击间隔造成伤害 atkTimer - dt; if (atkTimer 0.0f) { atkTimer atkInterval; Game::instance().damageFrontPlant(this, atkDamage); } } else { // 没有阻挡则向左移动 x - speed * dt; // 检测前方是否有植物有就停下转入啃食 if (Game::instance().hasPlantInRange(this)) { isEating true; x speed * dt; // 回退半步避免贴图重叠 } } // 走到屏幕最左边游戏失败 if (x leftBoundary) { Game::instance().loseGame(); } } // 僵尸受到子弹伤害时扣除 hphp 归零标记死亡 void Zombie::takeDamage(int dmg) { hp - dmg; if (hp 0) { alive false; } }移动用x - speed * dtspeed 单位是“像素/秒”乘上 dt 才能保证帧率不同时移动速度一致。这里踩过的坑是如果直接写x - speed60 帧和 30 帧下僵尸速度会差一倍。关于固定步长的处理在第 4 章主循环里会再强调。hasPlantInRange的判定条件是“植物中心点在僵尸当前位置前方一小段距离内”判断时用的是GameObject::isHit的扩张版本其实就是在碰撞检测基础上加大了一点儿检测范围让僵尸在接近植物时提前开始啃食而不是贴脸了才动手。3.4 子弹与碰撞矩形检测的实现子弹本身是飞行道具逻辑只有两件事向固定方向飞碰到僵尸就造成伤害并销毁自己。为了减少每帧的碰撞检测次数子弹只检测自己所在行的僵尸列表不会和所有场景对象逐个比对// bullet.cpp —— 子弹飞行与碰撞检测 void Bullet::onUpdate(float dt) { if (!alive) return; x speed * dt; // 豌豆向右飞行 // 查找本行僵尸只比对同一行的对象 std::vectorZombie* zombies Game::instance().getZombiesByRow(row); for (Zombie* z : zombies) { if (!z-isAlive()) continue; if (isHit(z)) { // 使用基类矩形碰撞判断 z-takeDamage(damage); alive false; // 子弹命中后销毁 break; } } // 飞出右边界直接销毁 if (x rightBoundary) alive false; }碰撞判定复用基类的isHit只传一个对象内部计算两个中心点的横向和纵向距离是否同时小于碰撞半径之和。之所以按行过滤是因为植物大战僵尸的战场每行独立子弹只打同一行的敌人不做全场景扫描能把每帧碰撞计算量压下一大截。这段代码里的一个常见性能问题如果每帧都从 vector 头部 erase 子弹或僵尸复杂度会随着场景内对象数量上涨。常见做法是把alive false当作标记主循环每帧末尾统一做一次清理这样批量删除既稳定又高效。4. 游戏主循环一次点击从输入到画面刷新的完整链路4.1 三态状态机菜单、对局、结算对战过程不是一进入程序就直接开打的。项目里用了一个简单的状态机管理三态主菜单、游戏中、失败结算。每种状态下鼠标消息的处理方式完全不同// game.h —— 游戏状态与切换 enum class GameState { MENU, // 主菜单显示“点击开始” PLAYING, // 对局中 GAMEOVER // 结算显示“僵尸吃了你的脑子” }; class Game { private: GameState state; public: void switchState(GameState newState) { state newState; // 进入 PLAYING 时清空上一局残留的对象 if (newState GameState::PLAYING) { clearAllObjects(); resetSunCount(200); startTimer(); } } };状态机的价值在于把“不同状态下同一事件的不同反应”收敛到一处。比如点击鼠标在 MENU 里代表开始游戏在 PLAYING 里代表种植植物在 GAMEOVER 里什么也不做。没有状态机的话你会在消息处理函数里写一大堆if (state ...)判断越堆越乱。resetSunCount(200)是每局初始阳光数放在switchState里做而不是 main 里做能确保“不管从哪个状态进对局初始条件都一致”不会出现上一局残留的阳光数带进新一局的 bug。4.2 主循环骨架固定步长与批量渲染主循环是整场游戏的“心跳”。我一般建议用固定步长驱动逻辑这样不管显示器刷新率是 60Hz 还是 144Hz游戏速度都保持一致// main.cpp —— 窗口初始化与主循环 int main() { initgraph(900, 600); // 创建窗口 Game game Game::instance(); game.switchState(GameState::MENU); GameTimer timer; // 封装的时间源 const float FIXED_STEP 1.0f / 60.0f; float accumulator 0.0f; while (true) { // 1. 一次性取出所有排队中的鼠标消息 ExMessage msg; while (peekmessage(msg, EX_MOUSE | EX_KEY)) { game.handleMessage(msg); // 根据状态分发事件 } // 2. 固定步长更新逻辑 float frameTime timer.deltaTime(); accumulator frameTime; while (accumulator FIXED_STEP) { game.update(FIXED_STEP); // 所有对象的 onUpdate accumulator - FIXED_STEP; } // 3. 批量绘制 BeginBatchDraw(); game.render(); // 所有对象的 onDraw EndBatchDraw(); Sleep(1); // 让出CPU避免空转 } closegraph(); return 0; }accumulator累加器是这个循环的核心把不稳定的帧率换算成固定步长的逻辑更新次数。比如某帧间隔 30ms那就一次性执行两次 16.7ms 的逻辑更新。这样做的好处是无论机器多卡游戏物体的物理行为始终按固定的时间尺度推进不会出现“卡了一下之后植物突然加速长了一堆阳光”的玄学问题。BeginBatchDraw和EndBatchDraw是 EasyX 提供的内存画布机制——先画到后台缓冲再整体刷新到屏幕。没有这个机制逐帧逐对象绘制会产生明显的屏幕闪烁这是最初几版最容易翻车的地方。4.3 种植逻辑格子换算与阳光扣减鼠标点击事件进来之后要先把屏幕坐标换算成棋盘格子的行列再做三重校验阳光够不够、格子是否合法、格子上是否已有植物// game.cpp —— 收到鼠标左键消息后的处理 void Game::handlePlantClick(int mouseX, int mouseY, int plantType) { // 1. 把屏幕坐标换算成棋盘坐标 int gridX mouseX - BOARD_LEFT; int gridY mouseY - BOARD_TOP; // 越界点的位置不在棋盘范围内 if (gridX 0 || gridY 0) return; int col gridX / CELL_W; int row gridY / CELL_H; if (row 0 || row ROWS || col 0 || col COLS) return; // 2. 检查格子上是否已有植物 if (gridOccupied[row][col]) { // 有植物时不做种植可以播放一个提示 return; } // 3. 检查阳光是否充足 Plant* plant createPlantByType(plantType, row, col); if (plant-sunCost sunCount) { delete plant; return; // 阳光不够种植失败 } // 4. 扣阳光、放植物、记录冷却状态 sunCount - plant-sunCost; gridOccupied[row][col] true; addObject(plant); }BOARD_LEFT和BOARD_TOP是棋盘在窗口中的偏移量。很多初次照抄的人会把屏幕坐标直接当棋盘坐标用结果无论怎么点都是种在左上角同一片格子里——因为鼠标坐标是相对整个窗口的而棋盘只是窗口的一部分必须先减掉偏移量。阳光扣减放在所有校验通过之后保证了“种植失败的尝试不会扣你阳光”这是跟原版游戏行为保持一致的重要细节。gridOccupied[row][col]占用表是一个二维数组它比遍历植物列表判断快得多后期做“格子高亮提示”也方便。4.4 对象管理延迟删除与统一清理所有场景对象都挂在 Game 的容器里。新增对象不直接 push 进正在遍历的容器而是先进待创建队列死亡对象只标alive false等每帧更新结束后统一清理void Game::flushPendingObjects() { for (GameObject* obj : pendingAdd) { objects.push_back(obj); } pendingAdd.clear(); auto it objects.begin(); while (it ! objects.end()) { if (!(*it)-isAlive()) { delete *it; // 释放内存 it objects.erase(it); // erase 返回下一个迭代器 } else { it; } } }这段代码处理的正是“一边遍历一边删除”的问题。直接在for (auto obj : objects)里调用erase会让循环的迭代器失效程序在下一次遍历时崩溃。用erase(it)的返回值接住下一个有效迭代器是目前最干净的做法也避免了“用 delete 手动管理内存不够安全”的疑虑。5. 避坑记录5个最容易翻车的细节5.1 现象鼠标点击的位置和画面实际位置完全对不上鼠标点屏幕下方植物却种在棋盘上方或者总觉得点击位置整体向右偏移。初次跑起来时如果 UI 元素和鼠标目标明显错位先怀疑显示器 DPI 缩放。原因Windows 在高分辨率屏幕上默认对程序做缩放鼠标消息里的坐标还是物理像素而绘图坐标系已经被系统拉伸了两套坐标不一致换算出来就偏了。解决在 main 函数最开头调用SetProcessDPIAware()告诉系统这个程序自己感知 DPI不需要缩放处理。这个调用必须在initgraph之前执行。从那以后我每次新建图形项目都会先写上这一行属于标准起手式。5.2 现象编译报错提示“const char * 无法转换为 LPWSTR”代码里写了loadimage(img, res/sunflower.png)编译时却报字符串类型无法转换。这种问题几乎都出在项目字符集配置上。原因VS 项目默认使用 Unicode 字符集字符串常量会被解析成宽字符而普通字符串字面量是窄字符两边类型对不上。解决把项目属性 - 配置属性 - 常规 - 字符集改成“多字节字符集”或者统一改用文本宏_T(res/sunflower.png)。我建议用后者省得以后每写一个字符串都要操心编码问题。5.3 现象EasyX 头文件安装了但 VS 里依然找不到 graphics.h下载安装 EasyX 后新建工程 include 头文件编译却提示“无法打开包含文件 graphics.h”。原因EasyX 安装包按 Visual Studio 版本区分安装时如果选错了版本号头文件被放到了另一个编译器的 include 目录下。解决重新运行安装程序认真核对你的 VS 主版本号或者直接把头文件和库文件手动复制到当前 VS 的 include 和 lib 目录。为了避免再次踩坑安装好后可以新建一个空工程写个initgraph测试用例先编译一遍确认环境没问题再往下开发。5.4 现象子弹明明擦肩而过僵尸却掉血了或者僵尸还没碰到植物植物就开始掉血碰撞检测的结果和小地图目测不一致是游戏开发里特别容易遇到的现象。这个问题的根源是碰撞矩形没有按贴图实际内容收窄。原因贴图本身是一张带透明边距的方图直接用整张图片的宽高做碰撞透明区域也被算作了实体区域导致“看着没碰上实际碰上了”。解决覆写基类的isHit方法给每个对象定义自己的碰撞矩形把四周透明边剔除掉。比如僵尸贴图宽 100碰撞体只取中间 60 像素植物贴图高 120碰撞体只取下半部分 80 像素。这类常量建议放到resource.h统一维护逐个调数值比写死在类里省事得多。5.5 现象游戏运行几秒后随机崩溃崩的位置还各不相同症状非常随机有时候是删子弹时崩有时候是种了新植物之后崩断点定位的代码行每一次都不一样典型的表现就是迭代器失效和内存重复释放。原因在遍历对象列表的循环里直接erase元素或者删了一个对象后后续代码还在使用这个对象的野指针。解决统一走“延迟删除”机制。对象死亡时只把alive设为 false真正删内存的操作放到每一帧末尾集中用一个函数清理。另外每次创建植物、子弹时都用pendingAdd列表暂存等当前遍历结束再追加进场景这样就不存在边遍历边插入元素的问题了。6. 二次开发思路从向日葵到火箭筒的替换6.1 新增植物三步接进现有体系拿到这份源码第一个能练手的改造就是加一个新植物。以“火箭筒射手”为例——攻击力高、攻速慢、阳光消耗大继承Plant基类就行// plant_rocket.h —— 新增火箭筒射手 class RocketShooter : public Plant { private: float fireInterval; float fireTimer; public: RocketShooter(int row_, int col_) { hp 200; sunCost 300; coolTime 15.0f; row row_; col col_; fireInterval 3.0f; fireTimer 0.0f; } void onUpdate(float dt) override { fireTimer - dt; if (fireTimer 0.0f Game::instance().hasZombieInRow(row)) { fireTimer fireInterval; // 复用子弹系统传一个更大的伤害值和爆炸特效标记即可 Game::instance().createBullet(row, getX(), getY(), 80); } } };接入只需要三步在createPlantByType里添加一个类型映射在资源常量表里加上它的贴图 ID 和阳光消耗数字在 UI 选择栏里加一个选项图标。因为所有植物的生命周期、碰撞、掉血、死亡都由基类和 Game 统一管理不用改任何框架代码。6.2 平衡参数调整与验证方法新植物上线后最重要的一步是调参验证。我习惯建一个“单行压力测试”场景固定在第 3 行放一只僵尸在左侧种一个火箭筒射手记录它需要几次攻击才能击杀再放五只僵尸测它能挡住几波。这样一轮一轮调攻速和伤害直到数值手感接近原版游戏。调参只用改fireInterval和伤害参数不需要重启整套程序。这类靠手调参数才能磨出来的细节做游戏逻辑时是最需要耐心的地方——没有捷径靠的是反复跑场景、记录数据、再调。从那以后我不管新增什么植物都会强制自己先跑一遍单行压力测试再放入正式玩法防止一个改坏了整个关卡平衡。希望这份拆解能帮你把这个项目跑起来也让你后面的二次开发少走几步弯路。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询