ARTICLE DETAIL

资讯详情

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

C++植物大战僵尸游戏开发:从架构设计到碰撞检测的完整实战

C++植物大战僵尸游戏开发:从架构设计到碰撞检测的完整实战 简介一份C实现的植物大战僵尸模型代码包面向C初学者与游戏开发爱好者通过可运行的exe和完整工程源码直观展示塔防游戏的核心机制。压缩包共109个文件大小15.82MB包含cpp源码、Visual Studio解决方案sln/vcxproj及编译生成的obj、pdb、exe并附带mp3音频素材既能直接启动体验也能结合工程文件进行断点调试。已有5879人浏览学习适合作为入门实践项目。阅读源码可重点理解面向对象设计Entity基类及Plant、Zombie派生、游戏循环架构、矩形碰撞检测以及资源管理与内存优化等关键知识点同时保留的调试符号和中间文件有助于对照代码观察运行细节对掌握C游戏开发流程很有帮助。1. C 植物大战僵尸模型与代码能跑、能改、能扩展的一套完整玩法如果你想找一份能真正跑起来的 c 植物大战僵尸项目而不是刷一堆只讲语法不落地的小 demo这套模型和源码值得花一个下午拆一遍。它不是用现成引擎套皮而是用纯 C 把阳光产出、植物种植、僵尸波次、子弹碰撞、胜负判定这些核心玩法从零写出来跑在最普通的控制台窗口里。对刚啃完语法、正愁没项目练手的人来说它能让你第一次直观感受 C 怎么组织一个完整程序对写过小工具的人这里也有可借鉴的对象设计与循环架构。整份资源核心就一件事让你看得懂、改得动、跑得起来。2. 拆解架构对象模型、主循环与碰撞检测怎么组织拿到代码第一步不是急着编译而是把入口文件打开读一遍。很多翻车现场其实从这里就埋下了——不看主循环后面调参、加功能全都像在黑匣子里猜。2.1 从 main 入口看整体流程初始化、循环、收尾三段式这套模型的 main 函数结构非常标准就是初始化 → 游戏循环 → 收尾释放三段式int main() { Game game; // 棋盘、阳光、僵尸队列都挂在 Game 对象上 game.init(config.ini); // 读配置初始阳光 50、棋盘 5 行 9 列 while (game.isRunning()) { // 主循环一局游戏的全部内容都在这 game.handleInput(); // 读取键盘处理选卡、移动光标、种植 game.update(); // 更新植物、僵尸、子弹、阳光的状态 game.render(); // 把内存里的状态画到控制台 game.delay(50); // 每帧睡 50 毫秒控制整体节奏 } game.shutdown(); // 释放动态内存输出本局统计 return 0; }这段代码是整个项目的地基。handleInput负责把按键转成具体操作按1选向日葵、按2选豌豆射手方向键移动格子光标空格确认种植update里会遍历所有游戏对象做冷却计时、僵尸位移、子弹碰撞render再把逻辑层的结果映射成控制台字符。我一般会在update里临时加几行日志看看每一帧到底在执行什么。很多人拿到代码直接跑打赢了觉得哦确实厉害打输了又不知道哪一步开始错的——这就是没把循环拆开看。delay(50)是全局节奏开关改小了游戏像加速视频改大了僵尸和植物全部变慢而且它影响所有按帧计时的参数后面调参时会专门说。2.2 Plant 与 Zombie 的类设计一个 Entity 基类吃透多态这套代码最值得抄的是对象设计。它没把每种植物写成互相独立的类而是抽了一个Entity基类把血量、坐标、攻击力这些公共属性放进去再把行为差异甩给子类class Entity { public: int row, col; // 所在格子棋盘固定 5 行 9 列 int hp; // 当前血量 int attack; // 攻击力 int cooldown; // 两次攻击之间的帧间隔 virtual void update(int frame) 0; // 纯虚函数子类必须回答每帧干什么 virtual void onHit(int damage) { // 被攻击时的公共处理 hp - damage; if (hp 0) dead true; } bool isAlive() const { return !dead; } protected: bool dead false; };update(int frame)是纯虚函数等于立了个规矩任何继承它的类都必须实现自己每帧的行为。向日葵的 update 里跑阳光计时器豌豆射手的 update 里检查前方有没有僵尸、决定要不要开火坚果的 update 基本什么都不干。主循环不需要关心当前是哪一种植物统一调plant-update(frame)就行——这就是多态的意义。具体到向日葵它的逻辑是典型的计时器驱动class Sunflower : public Entity { public: Sunflower(int r, int c) : Entity(r, c) { hp 100; sunTimer 300; // 每 300 帧产一次阳光即 15 秒 } void update(int frame) override { if (--sunTimer 0) { game-spawnSun(row, col); // 调 Game 的回调不碰全局变量 sunTimer 300; } } private: int sunTimer; };注意game-spawnSun(row, col)这个设计植物不持有全局状态而是通过一个指针回调到 Game 对象去申请产出阳光。这比直接操作全局变量干净得多以后写测试、做存档都能省很多事。你在改代码时尽量不要破坏这条线——植物只负责到点了通知世界阳光落在哪、值多少点是 Game 的职权范围。僵尸那边同理Zombie继承Entity后重写 update实现左移和啃植物class Zombie : public Entity { public: Zombie(int r, int c) : Entity(r, c) { hp 200; attack 10; } void update(int frame) override { Plant* p game-plantInFront(row, col); if (p) { p-onHit(attack); // 前方有植物就咬咬完停顿 waitFrames 30; } else if (waitFrames 0) { col - 1; // 没有植物就向左挪一格 } else { waitFrames--; } } private: int waitFrames 0; };这套继承体系不超过三层复杂度刚好卡在能体现面向对象又不至于绕晕的位置。如果你正在做 C 课设直接拿这个类结构当参考比从零设计要省一晚上。2.3 格子系统与子弹碰撞为什么不能判坐标相等新手最容易在碰撞检测上翻车因为直觉上两个东西碰上了就是坐标相等。但在这个模型里棋盘是 5 行 9 列的格子子弹每帧在格子里向右飞一段距离僵尸每帧向左挪一步两者大概率永远不会在某帧恰好重叠。所以代码里用的是不等式判定void Bullet::update(int frame) { x 0.2f; // 每帧向右推进 0.2 格 Zombie* z game-firstZombieInRow(row); // 找这一行最左边的僵尸 if (z x z-x) { // 子弹越过僵尸位置就算命中 z-onHit(attack); alive false; // 命中后子弹消失 } }核心思路是只要子弹本帧移动后的位置已经超过了僵尸的位置就视为命中而不是要求那一刻坐标精确相等。这个思路在 2D 小游戏里通用比死抠像素碰撞省事得多也足够稳。格子系统还有一个隐藏优点逻辑和表现彻底分离。update 里只认抽象的row和col渲染时再映射成控制台字符坐标// render 阶段的坐标换算格子坐标 - 控制台字符坐标 int cx col * 8 2; // 每个格子占 8 个字符宽 int cy row * 3 1; // 每个格子占 3 行字符高 gotoxy(cx, cy); std::cout iconChar; // iconChar 是 或 # 之类的占位符以后你要改成图形界面只需要换掉这一小段映射逻辑game 层一行都不用动。这个逻辑层不关心表现层的边界是这套代码架构上最值钱的地方。2.4 源码阅读顺序先看哪几个文件、怎么看才算读懂源码包里的文件不算多但乱翻容易迷失方向。我拆这类项目有个固定习惯先看数据定义再看控制逻辑最后看渲染和配置。对应到这份资源就是下面这个顺序文件作用建议读法entity.hEntity 基类定义多态核心逐行读弄清虚函数和公共属性game.h / game.cpp游戏总控持有对象和主循环先读头文件看成员变量有哪些main.cpp入口与主循环对照 2.1 的小节理解plant.cpp各植物子类与行为对比向日葵和豌豆射手的差异zombie.cpp僵尸生成、移动、攻击重点看波次生成和啃食逻辑bullet.cpp子弹与碰撞判定配合 2.3 的碰撞思路一起看config.ini数值配置最后看改难度全靠它读完怎么做自检我的方法很土但很有效把豌豆射手的攻击间隔从 60 帧改成 30 帧编译运行先预测哪一方会变强、胜负会怎么走再对照实际效果。如果你能准确预测结果说明主循环和类设计的链路你已经打通了。3. 编译与调参Visual Studio / g 双方案与数值改动点这一章解决怎么把这套代码跑起来和跑起来之后想改难度从哪下手。编译阶段的坑不少多数是环境问题跟代码本身关系不大。3.1 Visual Studio 跑通项目字符集、C17 标准与 gotoxy大部分人下载源码后第一件事是双击 .sln 直接编译结果被一堆报错劝退。先说最容易翻的两个点语言标准和字符集。Visual Studio 新建控制台项目后默认语言标准可能停在 C14而源码里如果用了override、结构化绑定这类特性就得切到 C17。操作路径是项目右键 → 属性 → 配置属性 → C/C → 语言 → C 语言标准选ISO C17 标准 (/std:c17)。另外配置属性 → 常规 → 字符集要选使用多字节字符集否则源码里的中文输出和注释在编译期就会闹脾气。配置完直接 CtrlF5 运行能看到一个用字符拼出来的棋盘窗口。如果卡住没有输出先检查gotoxy有没有被正确定义。gotoxy不是标准库函数这套代码里一般会用 Windows API 自己封装void gotoxy(int x, int y) { COORD pos { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); }这是控制台游戏渲染的地基缺了它画面不会刷新只会一行行往下滚。如果你用的不是 Windows 平台这里要换成 ANSI 转义序列或 curses 库后面 g 小节会给替换方案。另外一个跟 VS 强相关的点在你机器上编译好的 exe拷到没装 Visual Studio 的电脑上双击可能报由于找不到 msvcp140.dll 无法继续执行代码。这不是代码坏了是缺少 VC 运行库。两个解法一是让对方装 Microsoft Visual C Redistributable二是在项目属性 → C/C → 代码生成 → 运行库里把多线程 DLL (/MD)改成多线程静态 (/MT)把运行库直接编进 exe。我一般用后者省得给别人装环境。3.2 用 g 在终端编译MinGW 命令与跨平台替换不想开 Visual Studio 的话MinGW 环境一条命令就能编。在装有 MinGW-w64 的机器上进入源码目录执行g -stdc17 -Wall -Wextra -static -o pvz \ main.cpp game.cpp plant.cpp zombie.cpp bullet.cpp \ -Iinclude参数含义拆开说-stdc17指定语言标准漏了会有 C14 不兼容的语法报错-Wall -Wextra打开全部警告老项目编译时会出现不少变量定义了没用有符号无符号比较的提示多数不影响运行但值得逐个看很多隐蔽 bug 就藏在里面-static让 exe 静态链接拷到别的 Windows 机器上不会遇到 msvcp140.dll 缺失的问题。如果你在 Linux 或 macOS 上编译问题集中在SetConsoleCursorPosition上。#ifdef _WIN32之外需要补一个 ANSI 转义版本的光标定位// Linux/macOS 下的简易光标定位替换掉 Windows API 版本 void gotoxy(int x, int y) { printf(\033[%d;%dH, y, x); }这个替换只影响渲染层游戏逻辑一行都不用动又一次体现逻辑与表现分离的好处。习惯用 VSCode 的配好 MinGW 后在.vscode/tasks.json的 args 里填上面这条编译命令即可效果和 VS 没有本质差别。3.3 关键数值参数阳光、冷却、移速分别在哪改这套模型把所有平衡性数值集中在config.ini而不是散落在各个 cpp 里。打开大概长这样[game] rows5 cols9 init_sun50 sun_per_interval25 waves10 [sunflower] sun_cost50 sun_interval300 hp100 [peashooter] sun_cost100 attack20 attack_interval60 hp100 [zombie] hp200 move_speed100 attack10 attack_interval30对应的参数用途如下参数位置含义调大后效果init_sungame开局阳光量前期更好起手难度下降sun_per_intervalgame每波阳光产出的增量数值越大经济越宽裕sun_intervalsunflower向日葵产阳光的帧间隔间隔越短产阳光越快attack_intervalpeashooter豌豆射击的帧间隔间隔越短DPS 越高hpzombie僵尸血量越大越耐打move_speedzombie僵尸每帧移动步长越大走得越快这里有个极易踩的细节配置里存的是整数但僵尸移动逻辑上需要小数步长。常见做法是让配置存千分比读取时再除 1000float speed cfg.getInt(zombie, move_speed) / 1000.0f; zombie-speed speed; // move_speed100 时实际每帧 0.1 格如果你直接看到move_speed100就改成 50僵尸会慢一半符合直觉但如果改成 1移动量变成千分之一格看起来像完全没动你以为是 bug其实是数值范围没搞清。改参数前先确认读取单位这是老手和新手在调难度上最大的差别。3.4 调参后怎么验证效果日志输出与胜负记录改完参数不能只凭感觉变难了要有可对照的数据。我给这套代码加过一段临时日志每 60 帧打一次关键状态if (frame % 60 0) { printf([frame %04d] sun%d zombies%d bullets%d\n, frame, game.getSun(), game.zombieCount(), game.bulletCount()); }这样能清楚看到阳光曲线是涨还是跌、僵尸数量有没有压过火力、子弹数量是否饱和。调参的正确姿势是一次只动一个变量比如只把zombie.hp从 200 提到 300跑三局记录胜负和剩余阳光再决定下一步改什么。一次改五个参数出了问题你根本不知道是谁的锅。4. 避坑记录复现这套代码最容易翻车的五个现场这一章全是复现时的高频翻车点每一条都是现象 → 原因 → 解决的完整链路建议对照你的运行结果一条条排查。4.1 底层崩溃植物被僵尸咬死后程序闪退现象游戏进行到中段植物被啃完紧接着按方向键或空格程序直接崩掉无报错。原因game用std::vectorPlant*保存植物某个函数里边遍历边删除导致的迭代器失效。vector 删除元素后当前位置之后的所有迭代器都作废但那些地址还能访问于是表现为时好时坏、偶发崩溃特别难复现。解决把删除改成延迟标记本帧只记录哪些植物该删遍历结束后统一清理// 遍历阶段只标记不删除 for (auto p : plants) { if (!p-isAlive()) { p-toBeRemoved true; } } // 收集阶段统一删 plants.erase( std::remove_if(plants.begin(), plants.end(), [](const Plant* p) { return p-toBeRemoved; }), plants.end());这个遍历时不删、遍历后再删的模式在任何游戏循环里都通用比在遍历里erase安全一个量级。4.2 游戏速度像玄学Sleep 控帧的精度问题现象同一份代码在不同机器上跑游戏速度明显不一样开了其他程序还会随机卡顿帧率忽快忽慢。原因主循环用Sleep(50)控帧但Sleep的精度本身不可靠它只保证至少等这么久实际睡眠时间受系统调度影响可能差出几十毫秒。更重要的是这套代码的所有逻辑都用帧数计时比如 300 帧产一次阳光帧的实际时长在波动游戏速度自然被系统负载绑架。解决引入真实时间差驱动逻辑。每一帧用std::chrono记录高精度时间计算两帧间的真实毫秒数再把计时器从帧数换算成毫秒数。初期先封装一个FrameTimerclass FrameTimer { std::chrono::steady_clock::time_point last; public: void reset() { last std::chrono::steady_clock::now(); } int elapsedMs() { auto now std::chrono::steady_clock::now(); return (int)std::chrono::duration_caststd::chrono::milliseconds(now - last).count(); } };改到这一步游戏速度从玄学变成可复现的确定值你调参时的数据才可信。4.3 子弹穿模碰撞检测只判坐标相等现象豌豆明明从僵尸身上飞过去却偶尔不造成伤害尤其僵尸成群时漏判严重。原因碰撞判定写成x target-x或x target-x 1。子弹每帧移动 0.2 格、僵尸也在动两者相对位移可能让子弹在某帧跳过僵尸所在位置等号永远碰不上。解决把点判定改成区间判定。记录子弹本帧移动前后的坐标范围只要这个区间与僵尸占位区间有交集就判定命中。这就是 2D 游戏里的扫掠碰撞检测实现量不大float prevX x; x speed; // 本帧移动区间 [prevX, x] 与僵尸位置区间 [z-x, z-x 0.8f] 相交 if (x z-x prevX z-x 0.8f) { z-onHit(attack); alive false; }一个改成区间相交判断能干掉一大半子弹穿怪的问题。4.4 控制台乱码与棋盘错位编码不一致的两种翻车现象Windows 控制台里中文提示变成乱码或者界面里汉字占两格导致棋盘对不齐。原因源码文件是 UTF-8 编码Windows 控制台默认代码页是 GBK两者不一致就会出现乱码。棋盘错位则是因为一个中文字符在 GBK 下占两个显示宽度但渲染代码按一个字符位计算后续字符全挤偏了。解决在程序入口统一切换控制台代码页#ifdef _WIN32 SetConsoleOutputCP(CP_UTF8); SetConsoleCP(CP_UTF8); #endif至于对齐问题渲染时不要用std::cout 植这种裸输出要么封装一个统计显示宽度的函数要么干脆用 ASCII 字符画界面骨架彻底绕开中文字符宽度这个坑。当年我为了这个错位问题排了一晚上最后发现只是编码没统一。4.5 同一格种两棵植物输入处理缺少去抖现象快速连按空格同一个格子能种出两棵植物阳光数量还对不上偶尔出现负数。原因输入轮询在单帧内可能被多次触发种植函数又只判断格子是否为空同一帧内第一次种植后格子已经非空但第二次输入已经进了队列。实现里扣阳光的顺序如果放在种植之后还会出现阳光不足也种成功了的漏判。解决把输入处理改成按键状态机——记录按键是否已按下处理完种植后复位状态没有新的按下事件就不重复触发同时把扣阳光放在种植动作最前面阳光不足直接 returnif (keyPressed !keyHandled) { if (game.getSun() plantCost) { game.spendSun(plantCost); game.plantAt(cursorRow, cursorCol, selectedType); } keyHandled true; // 本帧不再处理该按键 }输入去抖在控制台小游戏里几乎是必踩的坑写的时候意识不到测试时每个玩家都能按出来。5. 扩展改造图形界面、数据驱动关卡与对象池控制台版跑通只是起点。这一章说三个最值得做的扩展方向全部基于原代码的逻辑层做增量改造不需要推倒重来。5.1 用 SFML 替换控制台渲染最小改造路径如果想让游戏拥有真正的画面最平滑的方案是引入 SFML。它是 C 的多媒体库2D 渲染 API 简单学习成本比 Qt 和 Unity 低得多。改造思路是只换表现层因为原来的逻辑层只认row和col现在把渲染映射从字符坐标换成像素坐标#include SFML/Graphics.hpp const float TILE_W 80.0f; // 每个格子宽 80 像素 const float TILE_H 80.0f; // 每个格子高 80 像素 int main() { sf::RenderWindow window(sf::VideoMode(720, 400), PVZ C); while (window.isOpen()) { sf::Event e; while (window.pollEvent(e)) { if (e.type sf::Event::Closed) window.close(); if (e.type sf::Event::KeyPressed) { // 原来的 handleInput 按键逻辑搬到这里 } } game.update(); window.clear(); for (Plant* p : game.plants()) { sf::Sprite spr; spr.setTexture(texMap[p-getType()]); // 按类型取贴图 spr.setPosition(p-col * TILE_W, p-row * TILE_H); window.draw(spr); } window.display(); } return 0; }贴图素材可以先用任意占位图跑通管线网上现成的植物大战僵尸素材很多等逻辑通了再换正式美术资源。这套改造最舒服的地方是碰撞、冷却、波次、胜负判定全部原封不动你只需要完成render 函数内部换成 SFML 绘制和键盘轮询换成事件循环两件事。5.2 关卡参数数据驱动用 JSON 替代硬编码原版的僵尸波次大概率是硬编码在game.cpp里的类似spawnWave(1, 3)一串顺序调用。想新增关卡就得改代码重编译效率太低。正规做法是数据驱动把关卡内容抽到 JSON 里用nlohmann/json这个单头文件库解析#include nlohmann/json.hpp std::ifstream ifs(level2.json); nlohmann::json data nlohmann::json::parse(ifs); game.setInitSun(data[init_sun]); for (auto w : data[waves]) { // 每波第几帧出怪、什么类型、出几只 game.addWave(w[time], w[type], w[count]); }对应的 JSON 文件{ level: 2, init_sun: 75, waves: [ { time: 10, type: normal, count: 3 }, { time: 30, type: cone, count: 2 } ] }改造的价值在于关卡设计从此变成改数据而不是改代码。你可以快速拼出十几关组合去测试植物和僵尸的数值平衡全程不需要重新编译。做游戏最终都会走到这一步硬编码参数只会让你的调试周期越来越长。5.3 存档与对象池为游戏加上持久化控制台版每次启动都从第一关开始很劝退。做存档的本质是把游戏状态序列化。一个最小实现是把关键状态写成文本读档时再反序列化void Game::save(const char* path) { std::ofstream f(path); f sun sun \n; f frame frame \n; for (Plant* p : plants) { f plant p-getType() p-row p-col p-hp \n; } } void Game::load(const char* path) { std::ifstream f(path); std::string tag; while (f tag) { if (tag sun) f sun; else if (tag plant) { int type, r, c, hp; f type r c hp; // 按 type 创建对应植物恢复位置和血量 } } }这个格式很朴素但思路是对的先保存对象类型 关键属性加载时按类型重建。将来换二进制格式或 JSON 只是序列化层的事架构不用动。另一个性能向的扩展是子弹对象池。子弹是高频创建销毁的对象频繁new/delete会产生内存碎片。对象池的思路是预分配一批用完回收复用class BulletPool { std::vectorBullet* pool; public: Bullet* acquire() { if (pool.empty()) return new Bullet(); // 池空才真正创建 Bullet* b pool.back(); pool.pop_back(); b-reset(); return b; } void release(Bullet* b) { pool.push_back(b); } // 不销毁回池 };对象池对控制台版是锦上添花但如果未来做成图形版且单位数量翻几倍这个优化能明显压住 GC 和内存分配开销。6. 验证方法自测入口与从复现到理解的闭环代码跑通、参数调过之后还差最后一步证明你确实理解它了而不只是会运行。我的习惯是给关键逻辑写一个独立的自测入口不依赖游戏主循环直接验证核心行为。比如验证向日葵 300 帧产一次阳光每次 25 点这条规则可写一个轻量测试void testSunflowerLogic() { Game g; g.init(config.ini); Sunflower* s new Sunflower(2, 3); g.board-place(s, 2, 3); int sunBefore g.getSun(); for (int i 0; i 300; i) { s-update(i); // 模拟 300 帧 } assert(g.getSun() sunBefore 25); // 失败会直接中断并报错 std::cout sunflower test passed\n; }这类测试不追求覆盖全部逻辑它的价值在于逼你把这个对象在什么条件下、改变什么状态想清楚。断言失败时你定位问题的过程就是真正读懂代码的过程。写三个这样的自测比读三遍代码都有用。第二个验证动作是极端参数压测。把zombie.hp改成 1、把init_sun改成 999分别跑完整局。前者测试伤害结算的边界后者测试经济系统的上限任何一处越界访问、空指针都会在这种极端值下现出原形。连续玩三局记录阳光曲线、僵尸剩余数、是否偶发崩溃这三局数据过关你才算真正把这个项目验收了。从那以后我每次拿到别人分享的源码都强制自己走一遍跑通 → 改参数 → 写自测 → 加一个小功能的流程过程中踩的坑比看一百篇教程都管用。这套 c 植物大战僵尸模型和代码正是练这套流程的好材料希望帮到你。本文还有配套的精品资源点击获取
返回列表