简介:基于C++实现的植物大战僵尸塔防游戏模型,面向对游戏开发感兴趣的C++初学者与进阶者,可系统理解塔防游戏从逻辑设计到代码落地的全过程。资源内含可直接运行的exe程序、完整的C++源码以及VS工程文件,方便边运行边对照代码逐模块推演。包体共109个文件,以cpp源码、exe可执行程序、sln工程配置为主,另有obj、pdb、idb等编译中间文件用于调试分析,压缩包总大小15.82MB,结构紧凑。已有5882人学习下载,适合作为C++游戏编程的入门实践项目。该模型完整展示了面向对象编程(植物与僵尸类的派生关系)、游戏主循环机制、矩形碰撞检测、图形界面库选型等核心内容,同时涉及数据结构、资源管理、调试与优化等实用技巧,能够帮助读者掌握完整的游戏开发流程,为独立制作小游戏打下扎实基础。
1. 一套能跑的 C++ 植物大战僵尸模型:先看结构再谈玩法
拿到一份 C++ 写成的植物大战僵尸模型代码,最怕的不是编译报错,而是编译通过后对着密密麻麻的类文件不知道从哪下手。这份资源包含完整的工程文件、植物与僵尸的动画序列帧、关卡数据配置和可直接运行的 Visual Studio 工程,核心代码覆盖了种植判定、子弹碰撞、波次刷怪和对象池管理,适合已经掌握 C++ 基础语法、想通过一个完整项目串联起类设计、STL 容器和内存管理的开发者。这篇文章按我实际拆解这个工程的经验,把代码结构和运行逻辑逐层拆开讲,新手能照着走通,有经验的也能直接跳到避坑部分看结论。
2. 工程结构与类设计:从文件树到主循环的完整骨架
2.1 模块划分与文件树:拿到底层代码先看这三块
拿到工程后不要急着编译,先看目录结构。一个规范的 C++ 游戏工程,文件布局会直接告诉你设计者的思路。这份资源的文件树大致如下:
PlantVSZombie/ ├─ main.cpp ├─ Game/ │ ├─ GameEngine.h/.cpp │ ├─ GameState.h/.cpp ├─ Objects/ │ ├─ GameObject.h/.cpp │ ├─ Plant.h/.cpp │ ├─ Zombie.h/.cpp │ ├─ Bullet.h/.cpp ├─ Systems/ │ ├─ ObjectPool.h/.cpp │ ├─ CollisionSystem.h/.cpp │ ├─ WaveSystem.h/.cpp ├─ UI/ │ ├─ UIManager.h/.cpp ├─ Resources/ │ ├─ images/ │ ├─ audio/ │ └─ config/ │ ├─ plants.xml │ └─ levels.xml三个目录是核心:Objects放所有游戏实体类,Systems放跨对象的管理逻辑,Game放引擎入口和状态切换。我一般会从main.cpp开始看,再顺藤摸瓜进入GameEngine,最后逐个打开Objects下的类。这样做的好处是能快速建立「主循环驱动系统,系统驱动对象」的调用链印象,避免陷在某个类的细节里出不来。
这段文件树里真正决定代码上限的是Systems/ObjectPool和Systems/WaveSystem这两个文件。前者决定了游戏运行几十分钟后会不会卡顿,后者决定了关卡节奏是否合理。如果拆解时发现游戏卡顿或波次异常,优先排查这两个文件,而不是去改植物属性。
2.2 类继承关系:植物、僵尸、子弹如何共享基类
这个工程在设计上没有走花哨的多重继承,而是用一个GameObject基类统管所有会出现在游戏画面里的实体。看两段核心声明就能明白设计思路:
// GameObject.h class GameObject { public: GameObject(std::string id, int x, int y); virtual ~GameObject() = default; virtual void update(float deltaTime) = 0; virtual void render() = 0; void setPosition(int x, int y) { m_x = x; m_y = y; } int getX() const { return m_x; } int getY() const { return m_y; } std::string& getID() { return m_id; } protected: std::string m_id; int m_x, m_y; float m_health; bool m_active; };基类强制子类实现update和render,这是所有游戏循环的基础约定。update负责逻辑更新(移动、攻击判定、状态变化),render只负责画画面。把两者分开,是为了在帧率波动时做逻辑与渲染解耦。m_active这个布尔值不是花架子——对象池回收对象时会先置m_active = false,系统遍历时跳过非活跃对象,这是避免空指针崩溃的关键。
// Bullet.h class Bullet : public GameObject { public: Bullet(int x, int y, float speed, int damage); void update(float deltaTime) override; void render() override; bool isCollidingWith(Zombie* zombie) const; private: float m_speed; int m_damage; int m_direction; // 1 右,-1 左 };这里有个值得注意的细节:Bullet的isCollidingWith接收的是Zombie*裸指针,而不是智能指针。设计者的意图是碰撞检测属于临时查询行为,不持有对象生命周期,裸指针足够安全——只要保证调用时Zombie对象仍存活。这个约定是性能与安全之间的权衡:如果每个子弹每帧都做shared_ptr的拷贝和引用计数增减,几千发子弹在同一帧跑下来,性能损耗要高出近一个数量级。
再往下看,Plant和Zombie各自继承GameObject,但它们的子类(向日葵、豌豆射手、普通僵尸、路障僵尸)没有单独建类文件,而是用枚举加属性配置区分:
enum class PlantType { SUNFLOWER, PEASHOOTER, WALLNUT, CHERRY_BOMB }; enum class ZombieType { NORMAL, CONEHEAD, BUCKETHEAD };这种设计意味着新增植物不需要新建类,只要在枚举里加值并且往配置表里加一行属性即可。对学习用项目来说,这是一个很实用的折中方案——既控制了类数量,又保留了扩展空间。如果你想加一种「冰冻豌豆」,只改属性和发射逻辑,不需要动继承体系。
2.3 主循环骨架:帧更新与事件处理的顺序
GameEngine.cpp里的主循环是所有机制的调度中心。整个游戏的运转就藏在run方法的循环里:
// GameEngine.cpp 核心循环 void GameEngine::run() { while (m_isRunning) { float deltaTime = getDeltaTime(); // 计算距上帧的秒数 handleEvents(); // 处理鼠标点击、键盘输入 update(deltaTime); // 先更新逻辑:种植、移动、碰撞 render(); // 再渲染画面 capFrameRate(60); // 限制最高帧率 } }这个循环的顺序很有讲究。handleEvents在最前面,保证用户的点击能够第一时间进入本帧的逻辑处理,不会因为掉帧而滞后。update在render之前,因为渲染永远应该展示更新完成之后的状态,否则会出现「一帧显示旧位置,下一帧跳回新位置」的撕裂感。capFrameRate(60)限制帧率有双重作用:一是减少 CPU 占用,二是保证基于时间的移动计算不会因为帧率剧烈波动而让游戏速度失控。
getDeltaTime的实现决定了游戏的物理效果。常见做法是用std::chrono::steady_clock计算两帧之间的真实时间差,单位是秒。比如子弹速度设定为 300 像素/秒,那么每一帧移动距离就是300 * deltaTime。如果去掉capFrameRate并且deltaTime计算错误,游戏运行速度会直接和硬件 FPS 挂钩——高刷屏上的游戏速度是 60Hz 屏幕的将近两倍,这是移植到新电脑时常遇到的第一个问题。这个工程里deltaTime以毫秒为单位存储,所有update函数里的速度参数都基于这个约定,改动单位意味着所有对象的移动速度都要同步调整,工程量很大。
3. 核心机制拆解:种植判定、子弹碰撞与波次刷怪
3.1 种植判定:格子坐标与阳光消耗
植物大战僵尸的种植机制表面上很简单,但代码里有两个容易写崩的点:格子的像素坐标换算,以及种植合法性校验的顺序。这份资源里,种植逻辑写在GameEngine的handleClick分支中:
// GameEngine.cpp 种植处理 void GameEngine::tryPlacePlant(int mouseX, int mouseY) { // 1. 将像素坐标转换为 9x5 格子的逻辑坐标 int gridX = (mouseX - BOARD_LEFT) / CELL_WIDTH; int gridY = (mouseY - BOARD_TOP) / CELL_HEIGHT; // 2. 越界检查:点击位置必须落在草坪内 if (gridX < 0 || gridX >= 9 || gridY < 0 || gridY >= 5) return; // 3. 该格子不能已有植物且必须在草坪上 if (m_gridOccupied[gridY][gridX]) return; // 4. 阳光数量检查 int cost = getPlantCost(m_selectedPlantType); if (m_sunlight < cost) return; // 5. 扣阳光、占格子、创建对象 m_sunlight -= cost; m_gridOccupied[gridY][gridX] = true; float worldX = BOARD_LEFT + gridX * CELL_WIDTH + CELL_WIDTH / 2; float worldY = BOARD_TOP + gridY * CELL_HEIGHT + CELL_HEIGHT / 2; auto plant = ObjectPool::instance().acquirePlant(m_selectedPlantType, worldX, worldY); m_plants.push_back(plant); }注意第一步和第五步的对称关系:点击坐标减去棋盘左上角偏移,除以格子宽高得到格子坐标;种植时反推实际像素坐标时,要加上半格偏移让植物中心恰好落在格子中央。初学者最容易漏的是第三步的m_gridOccupied检测——不检查直接种植会导致一个格子里叠放多棵植物,碰撞逻辑会变得不可预测。
另外这里用的是ObjectPool::acquirePlant而不是裸new,说明种植逻辑从设计之初就绑定了对象池。参数BOARD_LEFT和CELL_WIDTH等常量在GameConfig.h里统一定义,实际运行时点击位置还会经过一个 UI 层的坐标换算(减去窗口标题栏高度),这些细节在调试时经常成为「点击对不上格子」的元凶,详见第 5 章避坑部分。
3.2 子弹与僵尸的碰撞检测:矩形相交与边界修正
碰撞检测是这个工程里最适合拿来练手的部分,因为它的复杂度刚好卡在「能看懂」和「容易写错」的分界线上。这份资源采用的是经典的 AABB(轴对齐包围盒)矩形相交检测,而不是像素级检测。理由是植物大战僵尸的角色碰撞用矩形已经足够精确:豌豆子弹是一个小矩形,僵尸是一个高瘦矩形,两者相交即视为命中。
// CollisionSystem.cpp 矩形相交检测 bool CollisionSystem::checkCollision(const GameObject& a, const GameObject& b) { // 矩形边界,惰性计算:每个对象只算一次包围盒 int aLeft = a.getX() - a.getHalfWidth(); int aRight = a.getX() + a.getHalfWidth(); int aTop = a.getY() - a.getHalfHeight(); int aBottom = a.getY() + a.getHalfHeight(); int bLeft = b.getX() - b.getHalfWidth(); // 同理 int bRight = b.getX() + b.getHalfWidth(); int bTop = b.getY() - b.getHalfHeight(); int bBottom = b.getY() + b.getHalfHeight(); // 分离轴原理:任一轴不相交则整体不相交 if (aRight < bLeft || bRight < aLeft) return false; if (aBottom < bTop || bBottom < aTop) return false; return true; } // Bullet.cpp 子弹命中后的逻辑 void Bullet::update(float deltaTime) { m_x += m_speed * deltaTime * m_direction; // 位置更新 for (auto* zombie : m_allZombies) { if (isCollidingWith(zombie)) { zombie->takeDamage(m_damage); m_active = false; // 标记为待回收 break; // 同一颗子弹只命中一个僵尸 } } }这套检测的问题在于,当子弹速度足够快且帧率足够低时,子弹上一帧在僵尸左边,下一帧已经到了僵尸右边,矩形检测会漏掉这个过程——这就是「子弹穿模」的经典成因之一。绕开这个问题的办法是「扫掠检测」:把子弹本帧的位移当作一条线段,检查这条线段与僵尸的矩形是否有交点,而不仅仅是检查起点和终点。但扫掠检测的代价是计算量翻倍。
我观察这份资源里的做法是妥协方案:子弹速度统一限制在 400 像素/秒以内,同时保证最低帧率不低于 30FPS,那么单帧最大位移约 13 像素,小于僵尸矩形的最小宽度,穿模概率理论上为 0。如果你修改了某个植物,让它的子弹速度超过 600,穿模就会反复出现。这个边界参数值得记下来,第 5 章的调试案例里还会见到它。
3.3 波次系统:僵尸生成的节奏控制
波次系统直接决定游戏的「手感」。僵尸体生成得太密,玩家体验是绝望;太稀疏,又显得没有挑战。这份资源的WaveSystem没有用时间轴脚本,而是用「计时器 + 存活数阈值」双条件来控制刷怪:
// WaveSystem.cpp 波次管理 void WaveSystem::update(float deltaTime, int aliveZombieCount) { m_timer += deltaTime; // 条件1:距离上一波生成已超过间隔 // 条件2:场上存活僵尸低于阈值,防止一次性刷太多 if (m_timer >= m_waveInterval && aliveZombieCount < m_maxAliveCount) { spawnZombieGroup(generateGroupConfig()); m_timer = 0.0f; m_waveIndex++; } } WaveConfig WaveSystem::generateGroupConfig() { // 每波随机生成 2~4 只,种类随波次递进 WaveConfig config; int count = 2 + (rand() % 3); for (int i = 0; i < count; i++) { ZombieType type = (m_waveIndex > 5) ? ZombieType::CONEHEAD : ZombieType::NORMAL; config.zombies.push_back({type, getSpawnLane(), BOARD_WIDTH + 50 + rand() % 100}); } return config; }关键参数就两个:m_waveInterval初始值 10 秒,m_maxAliveCount初始值 8。这两者决定了压力曲线:间隔越短压力越大,存活阈值越高场面越拥挤。调难度时这两个值配合调整比直接改僵尸血量有效的多。瘦弱的脚本写法是在两种方案之间取平衡:只用spawnZombieGroup生成僵尸,却没有限制场上数量,结果就是玩家种植速度永远赶不上刷怪速度——接近 20 波以后游戏就进入不可挽回的失控状态。
另外注意spawnZombieGroup用getSpawnLane区分出怪行,代码里用的是随机行,但实战中同一波最好有至少 2 条路线,这样才能逼玩家分散种植而不是一条路上叠火力,这是关卡设计层面很基础的直觉。
4. 内存与资源管理:C++ 写游戏绕不开的对象生命周期
4.1 对象池:避免每帧 new 和 delete 的卡顿
C++ 写游戏和写业务系统最大的区别在于:游戏里对象的创建和销毁是高频操作,一个「豌豆射手」每 1.4 秒产出一颗子弹,一波僵尸来袭时场上可能有 20 多颗子弹同时存在。子弹的生存周期极短——从发射到命中通常只有 3 到 5 秒。如果这些对象全靠new和delete动态管理,每一帧都在堆上来回分配释放,结果就是内存碎片化越来越严重,游戏运行 20 分钟后开始间歇性卡顿,这就是典型的「堆分配抖动」。
// ObjectPool.h 对象池接口 class ObjectPool { public: static ObjectPool& instance(); // 单例入口 Bullet* acquireBullet(int x, int y); // 从池中取子弹,池空时new一个 void releaseBullet(Bullet* bullet); // 归还子弹,标记为可复用 Zombie* acquireZombie(ZombieType type, int lane); void releaseZombie(Zombie* zombie); private: std::vector<Bullet*> m_bulletPool; std::vector<Zombie*> m_zombiePool; std::vector<Plant*> m_plantPool; }; // ObjectPool.cpp 实现 Bullet* ObjectPool::acquireBullet(int x, int y) { for (auto* b : m_bulletPool) { if (!b->isActive()) { b->setup(x, y); // 复用前重置属性 return b; } } // 没有空闲对象才真正 new Bullet* bullet = new Bullet(x, y, 300.0f, 20); m_bulletPool.push_back(bullet); return bullet; }看acquireBullet的实现逻辑:先从池里找一个m_active == false的旧子弹,调用它的setup方法重置位置、速度、伤害、生命值,然后返回。只有找不到空闲对象时才走new分支。归还时不是delete,而是调releaseBullet把m_active置回false。这套机制保证了同一帧内反复请求对象时,堆分配次数趋近于零。
这里有个隐藏细节:setup方法必须重置所有属性,而不仅仅是位置。很多初学者写对象池时只重置位置,忘记重置生命值或状态标记,结果出现「复活后的子弹伤害异常」这种诡异 bug。我在第 5 章的踩坑记录里会专门讲这个。
4.2 智能指针的选择:shared_ptr 与 unique_ptr 的使用边界
这份资源的对象生命周期管理是「对象池持有裸指针 + 系统层使用智能指针」的混合策略。这意味着设计者避开了全盘shared_ptr的陷阱。Plant和Zombie由对象池统一管理,系统层在遍历时使用的是GameObject*裸指针;而 UI 层的事件回调、关卡数据等临时持有关系用的是std::shared_ptr。
// 典型用法:共享所有权时使用 shared_ptr class LevelConfig { public: std::shared_ptr<WaveSystem> getWaveSystem() const { return m_waveSystem; } private: std::shared_ptr<WaveSystem> m_waveSystem; }; // 典型用法:独占所有权时使用 unique_ptr class GameEngine { private: std::unique_ptr<RenderWindow> m_window; // 渲染窗口独占 ObjectPool m_objectPool; // 对象池不使用智能指针 };这套混合策略的理由很实在:shared_ptr的引用计数是原子操作,多线程环境下性能开销不小。游戏对象在池子里明明由池统一管理,再从池接口传出一份shared_ptr,反而会让所有权变得模糊——到底是池负责释放还是智能指针负责释放?一旦引用计数归零而池里还存着裸指针对它的引用,就是标准的悬垂指针。所以这份资源的约定是:池外使用裸指针,谁借谁还;系统级配置使用shared_ptr,因为配置对象可能被多个系统同时读取,共享所有权才是合理的场景。
给新手一条经验:写 C++ 游戏时不要觉得智能指针越多越好,先想清楚每个对象「谁创建、谁销毁、谁临时用」。有明确归属的用unique_ptr,多系统共享的用shared_ptr,高频短命对象(子弹、粒子)交给对象池,彻底绕开智能指针的引用计数开销——这是三种资源各归其位的分工。
4.3 资源加载与释放:图片和音频的生命周期托管
游戏工程里除了逻辑对象,还有图片、音频、字体等外部资源。这些资源的加载和释放如果写成散落的new Texture和delete Texture,基本上必然泄漏。这份资源在ResourceManager里实现了一套按需加载和统一释放的机制:
// ResourceManager.cpp 纹理加载与缓存 class ResourceManager { public: sf::Texture& getTexture(const std::string& path) { auto it = m_textures.find(path); if (it != m_textures.end()) { return *it->second; } // 缓存未命中,加载新纹理 auto texture = std::make_unique<sf::Texture>(); if (!texture->loadFromFile(path)) { throw std::runtime_error("Failed to load texture: " + path); } auto& tex = *texture; m_textures[path] = std::move(texture); return tex; } private: std::unordered_map<std::string, std::unique_ptr<sf::Texture>> m_textures; };这个实现的妙处在于:所有纹理都缓存在unordered_map中,getTexture返回的是引用,不涉及拷贝;当游戏结束ResourceManager析构时,unique_ptr自动释放所有纹理,不需要手写清理代码。查找操作的时间复杂度是常数,加载过的纹理不会重复载入,这比每次画植物时都从磁盘读一次图片要快好几个数量级。
音频资源的处理方式类似,但有一点差异:音频对象不能直接返回引用,因为每个音效播放时可能需要独立的播放状态。常见的做法是缓存sf::SoundBuffer(音频数据),而sf::Sound(播放器)按需创建。如果把这两层混淆,就会出现「一个僵尸被击中后所有僵尸都播放受击音效」的串音 bug。引用返回 + 缓存持有在这种资源管理方式里是唯一推荐做法,避免资源拷贝导致的性能衰减是全方向入手处理的一步到位的方案。
5. 避坑与排查:C++ 植物大战僵尸移植调试中的 5 个坑
5.1 中文乱码:字符集不一致导致界面显示错乱
- 现象:游戏界面上的植物名称、按钮文字显示为乱码,但英文部分正常。
- 原因:代码文件保存为 UTF-8,而 Windows 下的图形库默认按 GBK 解析字符串。尤其在
main.cpp里直接写"豌豆射手"这种中文字面量时,编译器按源文件编码解析,运行时图形库按系统编码绘制,两套编码不一致必然乱码。 - 解决:所有界面文本统一改用 UTF-8 编码的字符串字面量,并在初始化时调用
setFont加载支持中文的字体文件。更稳妥的做法是把全部可显示文本抽到config/texts.json中,代码里只用getText("plant_name_peashooter")这种键值引用。从那以后我每次给 C++ 工程加中文字符串都要先检查源文件编码是否统一。
5.2 子弹穿模:碰撞检测边界条件没处理
- 现象:豌豆子弹偶尔从僵尸身体中间穿过,没有造成伤害,尤其在游戏加速(帧率波动)时频繁发生。
- 原因:单个矩形检测只检查「当前帧的子弹位置」与「僵尸矩形」是否相交。帧率掉到 30 FPS 以下时,子弹单帧位移超过僵尸矩形宽度,两帧之间没有重叠区域。
- 解决:把子弹的相对位移拆成两步检测——先按子步进逐段检测:
// 分段碰撞检测:将位移拆为 5 段,逐段检查 float steps = 5.0f; for (float s = 0; s < steps; s++) { float testX = prevX + (newX - prevX) * (s / steps); if (isPointInRectangle(testX, testY, zombieRect)) { // 命中处理 break; } }实际调参时我会根据子弹最高速度动态计算步数:ceil(maxSpeed / zombieMinWidth),保证每段位移不超过僵尸矩形的最小宽度。同时把子弹速度上限从 400 提高到 600 时要同步调这个值,否则穿模依旧。
5.3 游戏速度随帧率漂移:高配电脑上游戏像开了倍速
- 现象:同一局游戏,在两个不同配置的电脑上运行速度差异接近两倍——高帧率机器上僵尸跑得飞快,低帧率机器上慢得让人犯困。
- 原因:部分移动逻辑错误地使用了「每帧固定位移量」而不是「位移速度 × 帧间隔」。
- 解决:把所有移动逻辑统一换算为基于时间的公式。模板法是在每个
update入口的第一行调用float dt = deltaTime / 1000.0f;统一转成秒,然后乘以速度常量。检查时重点搜m_x +=和m_y +=结尾,凡是右值没有* dt的都是隐患。
5.4 对象池复用后忘了重置属性:复活子弹伤害异常
- 现象:游戏运行一段时间后,某颗子弹的伤害变成 999,或者子弹碰到僵尸后不会消失,而是穿过去继续飞。
- 原因:对象池复用时只 reset 了位置和速度,没有 reset 伤害、穿透标记、附带效果等相关属性——旧子弹的运行状态残留到了新子弹上。
- 解决:给
Bullet::setup补全所有属性的重置逻辑:
void Bullet::setup(int x, int y) { m_x = x; m_y = y; m_speed = 300.0f; m_damage = 20; m_penetrate = false; // 关闭穿透残留 m_active = true; }从那以后我每次扩展对象类型都会同步更新setup方法,并且把所有可重置字段集中到一个reset()函数里,从源头避免漏项。
5.5 点击错位:窗口缩放后种植点对不上格子
- 现象:拖动窗口大小后,点击种植的位置和实际显示的格子位置偏移,越靠近屏幕右下方偏移越明显。
- 原因:窗口实际渲染尺寸与逻辑尺寸不一致时,鼠标坐标直接映射到逻辑坐标系而没有做缩放换算,点击坐标乘以了一个比例因子。
- 解决:缓存窗口初始大小,每次处理鼠标事件前计算缩放比例:
sf::Vector2f scale( static_cast<float>(window.getSize().x) / LOGIC_WIDTH, static_cast<float>(window.getSize().y) / LOGIC_HEIGHT ); float logicMouseX = mouseEvent.x / scale.x; float logicMouseY = mouseEvent.y / scale.y;固定窗口尺寸也可以绕开这个问题。但如果你要做自适应分辨率,这段换算逻辑是绕不开的,而且必须放在事件处理的最前端,否则后续所有坐标相关逻辑都会跟着偏移。
6. 从能跑到玩得爽:给模型加存档、功能扩展与调参验证
这份资源运行起来之后,只算完成了一半工程——另一半在于持续扩展。我建议从存档系统和纯功能扩展两个方向入手,把资源变成自己的作品。
存档系统是第一个值得加的模块。原工程没有存档功能,每次重开都要重新种一整局,调平衡性时非常低效。常见的做法是序列化存活植物和僵尸的状态:
// SaveSystem.cpp 存档数据结构 void SaveSystem::saveGame(const std::string& filePath) { nlohmann::json data; data["sunlight"] = m_sunlight; data["wave"] = currentWaveIndex; data["plants"] = nlohmann::json::array(); for (auto& plant : m_plants) { data["plants"].push_back({ {"type", (int)plant->getType()}, {"x", plant->getX()}, {"y", plant->getY()}, {"hp", plant->getHealth()} }); } std::ofstream file(filePath); file << data.dump(4); // 缩进 4 空格,方便读 }存档的作用不只是「不从头玩」,更重要的是做平衡性调参。你会发现改一个植物攻击力、改一波僵尸生成速度,都需要反复跑前十几分钟来验证前期节奏——存档正是为了缩短这个「验证曲线上」的重复时间。调m_waveInterval时,我一般把初始间隔和最低间隔差控制在 10 秒左右,只有数值在存档系统里反复验证之后,才知道一个关卡是「能过但勉强」还是「轻松碾压」。
第二个方向是加新植物或新僵尸类型。由于工程用了枚举加配置,扩展一个冰冻豌豆只需要:在PlantType枚举加FROZEN_PEASHOOTER,在plants.xml加入name/cost/cooldown/attackSpeed属性,在Bullet类里加frozen标记。真正要做的是给子弹附加效果时,在update里检查命中后把僵尸的m_speed乘以 0.5 并启动 2 秒倒计时——这段附加逻辑比加植物本身更值得调试。
第三,给游戏加一个调试输出面板。这个不太起眼的扩展,远比加一个新角色实用。把 FPS、当前帧deltaTime、场上对象数量、内存占用集中打在屏幕一角,调试碰撞穿模和性能卡顿时能直接看到数据变化,不用靠猜。
说到调试面板,想起一个具体教训:之前我给一个模拟项目加子弹穿模修复时,只改了子弹速度上限、没有同步调整分段碰撞检测的步数,结果穿模频率不降反升。后来在调试面板上盯住deltaTime和碰撞步数两个数值,才明白问题是位移除数没跟上速度。从那以后,我每次改速度、改碰撞或改帧率相关的参数,都会强制走一遍「三个数值联动检查:速度上限、分段检测步数、帧率下限」,并在调试面板上验证三者匹配,然后再去做功能扩展。希望帮到你。
本文还有配套的精品资源,点击获取