ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++从零手搓植物大战僵尸:游戏循环与碰撞检测实战

C++从零手搓植物大战僵尸:游戏循环与碰撞检测实战 简介这是一份面向C初学者与课程设计需求者的控制台版植物大战僵尸完整项目源码编号100013171适合用来练习面向对象设计、状态机与STL容器综合应用。项目以状态机实时响应用户输入主循环按绘制界面、获取按键、更新对象状态的节奏推进并通过继承与虚函数实现植物、僵尸的代码复用用STL容器管理地块中的植物、僵尸与子弹便于遍历、增删。压缩包共39个文件约337KB包含9个cpp与9个h源码文件、16张png与2张jpeg图片素材、1个可执行exe、1份README说明及LICENSE源码按Game、Grid、Plants、Zombies、Bullets、Map、Store、UI等模块拆分结构清晰。目前已有1062人学习下载。读者可从中获得一套可直接运行参考的课设方案理解多线程并行与状态机结合的实现思路并借鉴模块划分与容器管理方式用于二次开发或课程答辩准备。1. 从零手搓植物大战僵尸C 小游戏到底能练到什么很多人第一次动念写游戏都是从「植物大战僵尸」开始的。它规则直观、素材好找、玩法闭环短用 C 写一个能跑起来的简化版比啃完一本语法书更能让人摸到编程的门道。这个标题指向的不是某个成品而是一条用 C 从零搭建塔防小游戏的路径窗口怎么开、僵尸怎么走、豌豆怎么飞、阳光怎么攒、碰撞怎么判。它适合刚学完 C 基础、想找个项目把指针、数组、类、循环真正用起来的人也适合想给孩子做个能玩的小 demo 的家长。核心难点不在语法而在把「游戏循环」这件事想清楚——所有对象每帧更新一次状态再统一绘制这个模型立住了后面加什么植物、什么僵尸都是往里填内容。下面按我实际写过的顺序把这条路拆开讲。2. 先定骨架游戏循环、坐标系统和对象管理2.1 为什么不用现成引擎而是自己写循环用 C 写小游戏第一反应往往是「要不要上引擎」。我的建议是这个体量别上。植物大战僵尸的核心逻辑简单到可以用一个主循环加几个数组撑起来上引擎反而要花大量时间学 API、配环境最后游戏没写几行配置文档看了一堆。自己写循环的好处是每一帧发生了什么你全知道出问题能定位到具体哪一行这对练手来说比「跑起来就行」重要得多。游戏循环的本质是一个while循环每轮做三件事处理输入、更新状态、绘制画面。听起来简单但新手最容易翻车的地方是「更新」和「绘制」混在一起写导致逻辑和显示耦合改一个数值要动好几处。正确做法是让每个对象自己管自己的状态主循环只负责按顺序调用它们的update()和draw()。// 游戏主循环骨架固定时间步长避免不同机器速度不一致 #include chrono #include thread const double FRAME_TIME 1.0 / 60.0; // 目标 60 帧 while (running) { auto frameStart std::chrono::steady_clock::now(); handleInput(); // 1. 读键盘鼠标 updateWorld(FRAME_TIME); // 2. 所有对象按 dt 推进 renderWorld(); // 3. 统一绘制 auto frameEnd std::chrono::steady_clock::now(); double elapsed std::chrono::durationdouble(frameEnd - frameStart).count(); if (elapsed FRAME_TIME) { std::this_thread::sleep_for( std::chrono::durationdouble(FRAME_TIME - elapsed)); } }这段代码的关键在FRAME_TIME和dt。如果直接让对象每帧移动固定像素机器快就动得快、机器慢就动得慢游戏体验完全不可控。把每帧经过的时间dt传给update()对象用速度 * dt计算位移速度单位就是「像素每秒」跟帧率解耦。sleep_for那几行是给循环限速防止 CPU 空转跑满一个核。参数上60 帧对这类游戏足够再高肉眼也看不出差别反而增加无谓计算。2.2 用二维数组还是对象列表来管格子植物大战僵尸的场地是标准网格5 行 9 列。管理这些格子有两种常见做法一是开一个Plant* grid[5][9]的二维指针数组二是维护一个std::vectorPlant*列表。我一般用前者管「谁占了这个格子」用后者管「这一帧要更新谁」两者配合。二维数组的好处是判断「这个格子有没有植物」是 O(1)种植物、铲植物直接按下标操作不用遍历。坏处是僵尸、子弹这些不在格子里的东西塞不进去。所以僵尸和子弹单独用std::vector存每帧遍历更新飞出屏幕或血量为零就标记删除。const int ROWS 5; const int COLS 9; Plant* grid[ROWS][COLS] { nullptr }; // 格子占用表 std::vectorZombie* zombies; // 僵尸列表 std::vectorBullet* bullets; // 子弹列表 // 种植物先查格子是否为空再放 bool plantAt(int row, int col, PlantType type) { if (row 0 || row ROWS || col 0 || col COLS) return false; if (grid[row][col] ! nullptr) return false; // 已被占用 grid[row][col] createPlant(type, row, col); return true; }这里有个血泪经验grid初始化一定要写 { nullptr }否则里面是野指针第一次判断grid[row][col] ! nullptr就可能随机为真植物种不下去还找不到原因。删除对象时也要记得把grid对应位置置回nullptr不然格子永远被「幽灵植物」占着。参数上行列下标从 0 开始越界检查不能省玩家点击位置换算成格子时尤其容易算出负数或超界值。2.3 坐标换算屏幕像素和格子下标怎么互转玩家点的是屏幕坐标游戏逻辑用的是格子下标中间必须有一层换算。假设场地左上角在屏幕的(originX, originY)每个格子宽CELL_W、高CELL_H那么// 屏幕坐标 - 格子下标 int col (mouseX - originX) / CELL_W; int row (mouseY - originY) / CELL_H; // 格子下标 - 屏幕坐标格子左上角 int px originX col * CELL_W; int py originY row * CELL_H;整数除法在这里是故意的它天然完成「向下取整」正好对应格子归属。但要注意鼠标点在场地左侧或上方时mouseX - originX是负数整数除法会向零取整而不是向下取整算出的col可能是 0 而不是 -1导致误判。稳妥做法是先判断鼠标是否落在场地矩形内再换算。这个坑我在第一次写点击种植时踩过点场地外面居然也能种上排查半天才发现是负数除法的问题。3. 让僵尸动起来移动、碰撞与波次生成3.1 僵尸沿直线推进的最小实现僵尸的行为比植物简单从右侧屏幕外生成沿所在行向左匀速移动碰到植物就停下啃啃完继续走走到最左侧判负。核心就一个位置更新加一个碰撞检测。struct Zombie { double x, y; // 像素坐标 int row; // 所在行 double speed; // 像素/秒向左为负 int hp; bool eating; // 是否正在啃植物 void update(double dt) { if (eating) return; // 啃的时候不移动 x speed * dt; // speed 为负值 } };speed用负值表示向左这样x speed * dt一行搞定方向不用额外判断。eating标志位控制「啃植物时停下」这个状态由碰撞检测来设置和清除。参数上普通僵尸速度我一般设 20 到 30 像素每秒太快玩家来不及反应太慢没有压迫感。血量按豌豆伤害来配一颗豌豆 20 点伤害普通僵尸 100 血就是五发。3.2 碰撞检测僵尸和植物、子弹和僵尸碰撞检测用轴对齐矩形AABB就够不需要精确到像素。每个对象维护一个包围盒两个盒子在 x 和 y 方向都有重叠就算碰撞。struct Rect { double x, y, w, h; }; bool overlap(const Rect a, const Rect b) { return a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y; } // 僵尸找它所在行、最靠右的植物 void checkZombieEat(Zombie* z) { int col (int)((z-x - originX) / CELL_W); if (col 0 || col COLS) { z-eating false; return; } Plant* p grid[z-row][col]; if (p overlap(z-getRect(), p-getRect())) { z-eating true; p-hp - z-damage * dt; // 持续啃咬按时间扣血 } else { z-eating false; } }这里有个容易忽略的点僵尸的x是它的中心还是左上角必须和getRect()里的换算保持一致否则碰撞框会整体偏移出现「明明碰到了却不啃」的玄学现象。我习惯统一用左上角存坐标包围盒直接就是{x, y, w, h}省得来回换算。子弹和僵尸的碰撞同理子弹每帧移动后检查是否和某个僵尸重叠重叠就扣血并销毁子弹。3.3 波次生成用随机数控制节奏僵尸不能一窝蜂全出来要有波次。常见做法是维护一个计时器每隔一段时间生成一批波次越往后数量越多、间隔越短。C 里用random比rand()好分布均匀且可控。#include random std::mt19937 gen(std::random_device{}()); std::uniform_int_distributionint rowDist(0, ROWS - 1); std::uniform_real_distributiondouble gapDist(3.0, 6.0); double spawnTimer 0.0; double nextGap gapDist(gen); void updateSpawner(double dt, int wave) { spawnTimer dt; if (spawnTimer nextGap) { spawnTimer 0.0; int row rowDist(gen); zombies.push_back(createZombie(row)); // 波次越高间隔越短 nextGap gapDist(gen) / (1.0 wave * 0.1); } }std::mt19937是梅森旋转算法比rand()的随机质量高得多uniform_int_distribution保证每个行号被选中的概率相等。参数上初始间隔 3 到 6 秒比较合适波次系数 0.1 让难度平滑上升。注意gen只初始化一次别放进循环里反复构造那样每次种子都变随机数反而不随机了——这也是个经典翻车点。4. 阳光、豌豆和状态机把玩法闭环补全4.1 阳光经济定时掉落和点击收集阳光是游戏的资源系统决定玩家能种多少植物。它有两个来源天上定时掉落向日葵定期产出。掉落物本质是一个带生命周期的对象落地后停留一段时间被点击就加资源超时消失。struct Sun { double x, y; double targetY; // 落到这个高度停住 double life; // 剩余存活时间 bool collected; void update(double dt) { if (y targetY) y 60 * dt; // 下落 else life - dt; // 落地后倒计时 if (life 0) collected true; } };点击收集时遍历所有阳光判断鼠标点是否落在它的圆形范围内。这里用圆形判定比矩形更贴合视觉因为阳光画出来是圆的。参数上下落速度 60 像素每秒、落地停留 8 秒是我试过比较舒服的值。停留太短玩家来不及点太长屏幕上堆满阳光影响视线。4.2 豌豆射手的攻击节奏豌豆射手每隔固定时间发射一颗子弹前提是它所在行有僵尸。这个「有僵尸才打」的判断能省不少无谓的子弹也让行为更合理。struct Peashooter { double cooldown; // 剩余冷却 int row, col; void update(double dt, const std::vectorZombie* zombies) { cooldown - dt; if (cooldown 0) return; if (!hasZombieInRow(row, zombies)) return; bullets.push_back(createBullet(row, col)); cooldown 1.4; // 攻击间隔秒 } };cooldown用倒计时而不是正计时判断条件更直观。攻击间隔 1.4 秒是原版豌豆射手的节奏配合 20 点伤害一只普通僵尸要挨五发大约 7 秒给玩家留出反应时间。hasZombieInRow只检查同一行、且在射手右侧的僵尸避免朝身后开火。4.3 用状态机管僵尸的「走」和「啃」僵尸只有两个状态走路和啃咬。用布尔标志位能凑合但状态一多就乱。更清晰的做法是枚举加状态机每个状态有自己的进入、更新、退出逻辑。enum class ZombieState { Walking, Eating, Dying }; void updateZombie(Zombie* z, double dt) { switch (z-state) { case ZombieState::Walking: z-x z-speed * dt; if (findPlantAhead(z)) z-state ZombieState::Eating; break; case ZombieState::Eating: if (!findPlantAhead(z)) { z-state ZombieState::Walking; break; } z-targetPlant-hp - z-damage * dt; if (z-targetPlant-hp 0) { removePlant(z-targetPlant); z-state ZombieState::Walking; } break; case ZombieState::Dying: z-deathTimer - dt; if (z-deathTimer 0) z-alive false; break; } }状态机的价值在于把「什么时候切换」集中到一处而不是散落在各个 if 里。加新状态比如冰冻、眩晕时只改这个 switch不用满代码找判断。Dying状态是为了播放死亡动画留的缓冲没有动画也可以直接标记删除。5. 避坑与排查那些让我重写三遍的细节5.1 现象植物种下去了但僵尸直接穿过去原因碰撞检测用的是僵尸中心点所在格子而僵尸的包围盒已经跨到了相邻格子导致它「看到」的植物和实际碰到的不是同一个。解决碰撞检测不要只查一个格子查僵尸包围盒覆盖的所有列取最靠右的那个植物作为啃咬目标。5.2 现象子弹飞过僵尸却不造成伤害原因子弹速度太快一帧移动的像素超过了僵尸的宽度出现「隧穿」——这一帧在僵尸左边下一帧已经在右边中间没有检测到重叠。解决子弹移动后不要只检测终点位置而是检测从旧位置到新位置这条线段覆盖的矩形范围或者把子弹速度降下来保证单帧位移小于僵尸宽度。5.3 现象游戏跑一会儿越来越卡原因删除对象时只标记了alive false但没有真正从std::vector里移除列表越积越长每帧遍历的开销越来越大。解决每帧更新后统一做一次清理用「移除-擦除」惯用法把死亡对象真正删掉。zombies.erase( std::remove_if(zombies.begin(), zombies.end(), [](Zombie* z) { return !z-alive; }), zombies.end());注意remove_if只是把要删的元素挪到末尾并返回新末尾真正的删除靠erase两个要配套用只写一个等于没删。5.4 现象窗口一拖动或缩放画面就错位原因绘制坐标写死成了固定像素没有跟随窗口尺寸变化。解决把场地原点、格子尺寸都做成相对窗口宽高的比例值窗口大小改变时重新计算而不是用常量。这个坑在需要适配不同分辨率时特别明显。5.5 现象中文路径下素材加载失败原因很多图像库对非 ASCII 路径支持不好fopen这类 C 函数在 Windows 上默认用本地编码中文路径直接打不开。解决素材路径尽量用英文或者用宽字符版本的接口读取。这个不是逻辑 bug但排查起来很费时间因为报错信息往往只说「文件未找到」。6. 进阶把代码拆成可扩展的模块顺手验证正确性写到能玩之后下一步是让它好维护。我踩过的最大教训是所有逻辑塞在一个main.cpp里加到第三种植物时已经改不动了。后来我把代码拆成几个模块Game管主循环和状态Plant、Zombie、Bullet、Sun各自一个类ResourceManager管素材加载Config管所有可调参数。拆完之后加新植物只需要继承Plant基类、实现update()和draw()再在工厂函数里注册一下不用动主循环。验证正确性有个笨但有效的办法给关键逻辑写单元测试。比如碰撞检测函数喂几组已知的矩形断言输出符合预期阳光经济模拟点击若干次断言资源数正确。游戏逻辑不像后端接口那么好测但纯计算的部分完全可以测。// 用断言快速验证碰撞逻辑不需要引入测试框架 void testOverlap() { Rect a{0, 0, 10, 10}; Rect b{5, 5, 10, 10}; Rect c{20, 20, 5, 5}; assert(overlap(a, b) true); // 相交 assert(overlap(a, c) false); // 分离 assert(overlap(a, a) true); // 自身 }assert在 Debug 下生效、Release 下被裁掉适合做开发期的快速校验。参数上没什么可调的重点是覆盖边界情况完全重合、边贴边、一个包含另一个这几种都测过碰撞逻辑基本就稳了。还有一个我后来才养成的习惯把所有可调数值——僵尸速度、豌豆伤害、阳光产量、波次间隔——集中到一个头文件里用constexpr定义。调平衡的时候只改这一个文件不用满项目搜魔法数字。这个习惯让我在后期调难度时省了大量时间也避免了「改了这处忘了那处」导致数值对不上。写这个项目最大的收获不是学会了某个 API而是理解了「游戏就是一个不断循环的状态机」这件事。想清楚每个对象每帧该做什么剩下的都是体力活。如果你也在写别一上来就追求还原原版先把「僵尸能走过来、豌豆能打死它」这条最小闭环跑通再一点点加内容。希望帮到你。本文还有配套的精品资源点击获取
返回列表