资讯动态

Cocos2d-x 3.16下用C++复刻植物大战僵尸:架构与避坑指南

发布时间:2026/10/22 4:08:08 来源:尧图企业网站定制
简介一份使用C基于Cocos2dx V3.16引擎开发的植物大战僵尸游戏完整工程面向希望系统学习Cocos2d-x游戏开发、策略塔防玩法实现的中高级程序员。项目覆盖精灵与节点管理、碰撞检测、路径规划、AI行为、事件响应及音频控制等核心模块同时展示通过C数据结构和文件I/O实现关卡进度持久化的方案适合作为独立练手或课设参考。压缩包共2000个文件以911个h头文件、469个cpp源码文件为主辅以xml场景配置、java与m桥接文件、sh构建脚本等整体约162.17MB目录结构完整便于按功能检索。当前已有216人学习下载对于想深入理解Cocos2dx渲染流程和C游戏架构的开发者这份资源提供了可编译运行的实战范例可帮助快速搭建工程、阅读关键算法并在此基础上扩展关卡与玩法。1. 复刻植物大战僵尸为什么我劝你先别急着写植物一条很个人、但和标题直接相关的开场我评审过某开发者用 Cocos2dx V3.16 交上来的一个模拟项目X核心战斗已经能跑僵尸会走植物会开枪但一进第二关就崩。调了半天问题不在子弹不在僵尸 AI而在最不起眼的触摸回调里释放节点的顺序上。这个标题看上去像老掉牙的练手项目但真想用 C 在 Cocos2dx V3.16 上把它做稳定你需要处理的是网格坐标换算、引用计数、对象回收、动作调度这一整套问题。它适合想快速上手 C 游戏开发、又不想拿空场景和满天方块当练习对象的人也适合手里攥着老资源、想在 3.x 版本上落地一个完整游戏的人。下面我讲的是一条能真正走通的路线。2. 先拆玩法再写代码Cocos2d-x 3.16 下植物大战僵尸的架构选择很多初学者拿到这个需求后的第一反应是打开编辑器创建 GameScene再往上面拖精灵。这种做法在二十个类以后会彻底失控。植物大战僵尸的复杂度不在单帧渲染而在多个系统同时运作时互不干扰。所以在写任何代码之前先把架构定下来。2.1 为什么是 V3.16 而不是 4.x稳定、资料集中、C 手感一致Cocos2d-x 4.x 把渲染器整个换成了 OpenGL ES 3.0 为主的后端很多 3.x 时代的 API 被标废弃C 示例的存量也远不如 3.x 丰富。V3.16 是 3.x 产品线里一个非常安定的版本网上能找到的完整游戏例程、开源复刻项目、甚至当年流行的资源格式绝大多数都是围绕 3.x 写的。你拿到一套 plist 序列帧资源放到 3.16 里基本不用改放到 4.x 上反而要处理资源导入和 API 迁移的额外成本。从语言角度讲3.16 的 C 接口比较原始——大量使用 Ref 引用计数、create 方法返回 autorelease 对象、节点树直接管理生命周期。这和现代 C 的风格不同但正因为原始你能看到游戏引擎最核心的内存管理逻辑。对想搞懂游戏引擎原理的人来说3.16 的教学价值比封装完善的 4.x 更高。还有一个实际考量3.16 在低端 Android 设备上的兼容性表现稳定。用合适的旧版 NDK 打包不需要处理新版 NDK 强制要求 libc 等带来的兼容问题。如果你是在维护老项目V3.16 几乎是唯一不需要大改的选择。2.2 游戏里真正要开发的六个子系统复刻植物大战僵尸我的拆分方式是六个子系统每个对应一个或一组类子系统核心类职责资源加载TextureCache / SpriteFrameCache管理纹理图集、序列帧、音频预加载UI 层HudLayer顶部阳光计数、卡片栏、冷却显示网格系统GameGrid像素坐标与 9x5 格坐标互转实体系统PlantBase / ZombieBase植物和僵尸共有的属性、状态、动作战斗系统BattleLayer / Bullet子弹生成、碰撞检测、伤害结算关卡系统LevelConfig僵尸波次表、植物解锁表、关卡数据这个划分不是拍脑袋想出来的而是源自一个常见翻车教训把所有节点直接挂在 GameScene 下update 里同时遍历植物、僵尸、子弹代码写到后面自己都看不懂。把每个子系统钉死到具体类上后面做调试、加功能、查内存泄漏都有明确的搜索范围。2.3 工程骨架与文件划分我建工程时一般会把 Classes 目录按子系统拆开方便后期维护。一个典型的目录结构如下Classes/ AppDelegate.cpp GameScene.cpp / .h ui/HudLayer.cpp grid/GameGrid.cpp plant/PlantBase.cpp plant/Peashooter.cpp plant/Sunflower.cpp zombie/ZombieBase.cpp battle/BattleLayer.cpp battle/Bullet.cpp data/LevelConfig.cpp data/PlantConfig.cpp Resources/ textures/... audio/...这样做的好处是每个文件只干一件事依赖方向从 GameScene 向下指向各子系统GameScene 是组装者而不是实现者。另一个设计原则是数据与逻辑分离。每种植物的阳光消耗、冷却时间、攻击力、攻击间隔以及每波僵尸的种类和数量都放进 data 目录下的配置类或常量表不要让魔法数字散落在 update 函数里。之后调平衡性时只需要改一个地方。3. 从最小工程到草坪网格用 C 把游戏场景立起来架构定好之后第一步不是写植物而是跑通一个最小工程。理解 AppDelegate 到 GameScene 的初始化链路是后面所有操作的基础。3.1 最小可跑工程AppDelegate 与 GameScene 的初始化Cocos2d-x 3.16 的入口由 AppDelegate 控制。applicationDidFinishLaunching 是最先执行的地方常见做法是bool AppDelegate::applicationDidFinishLaunching() { auto director Director::getInstance(); auto glview director-getOpenGLView(); if (!glview) { glview GLViewImpl::createWithRect(PVZClone, Rect(0, 0, 960, 640)); director-setOpenGLView(glview); } glview-setDesignResolutionSize(960, 640, ResolutionPolicy::SHOW_ALL); auto scene GameScene::createScene(); director-runWithScene(scene); return true; }这段代码有三个细节容易被忽略。第一个是 createWithRect 的 Rect 参数它只决定桌面模拟窗口的初始尺寸真正的渲染范围由下一行的 setDesignResolutionSize 决定。第二个是 ResolutionPolicy 的选择植物大战僵尸这类固定棋盘游戏用 SHOW_ALL 最安全它会完整保留整个草坪不裁切代价是宽屏设备两侧会出现黑边但游戏逻辑不会错位这对开发期来说比 EXACT_FIT 重要。第三个是 scene 创建后不需要手动 retainrunWithScene 会自动持有它。GameScene 本身只需要做组装工作。常见写法是Scene* GameScene::createScene() { auto scene Scene::create(); auto layer GameScene::create(); scene-addChild(layer); return scene; } bool GameScene::init() { if (!Layer::init()) return false; auto visible Director::getInstance()-getVisibleSize(); auto bg Sprite::create(textures/lawn.png); bg-setPosition(visible.width / 2, visible.height / 2); addChild(bg, -1); grid_ GameGrid::createGrid(9, 5, Vec2(30, 95), 100, 90); addChild(grid_); auto hud HudLayer::create(); addChild(hud, 10); return true; }注意这里把草坪背景作为一张整图铺在底层网格系统是一个不渲染的节点只负责坐标计算。HudLayer 渲染在 z 轴 10 的位置保证卡片栏永远在植物和僵尸之上。如果这一步做完桌面上能看到一个草坪窗口而不是黑屏说明场景链路已经通了。3.2 网格坐标与像素坐标互转种植和碰撞都靠它植物大战僵尸的所有玩法都发生在 9 列 x 5 行的草坪网格上。种植时要把手指点击的像素坐标换算成格子坐标子弹碰撞、僵尸停脚又要把格子坐标换算回像素坐标。这个互转函数写对了后面能省一半的调试时间。Vec2 GameGrid::cellToPixel(int col, int row) { float x originX_ col * cellWidth_ cellWidth_ * 0.5f; float y originY_ row * cellHeight_ cellHeight_ * 0.5f; return Vec2(x, y); } bool GameGrid::pixelToCell(const Vec2 pos, int* col, int* row) const { int c (int)((pos.x - originX_) / cellWidth_); int r (int)((pos.y - originY_) / cellHeight_); if (c 0 || c cols_ || r 0 || r rows_) return false; *col c; *row r; return true; }originX_ 和 originY_ 是草坪左下角对应的像素坐标cellWidth_ 和 cellHeight_ 是格子的像素尺寸。我用 9 列、5 行、每格 100 x 90 来算草坪区域在 960x640 的设计分辨率下大约从 x30 开始y 从 95 开始。两个注意点cellToPixel 返回的是格子中心坐标因为 Sprite 的 setPosition 默认以中心点为锚pixelToCell 用的是整数截断所以手指点击格子左边和点击格子右边会稳定落在同一格内不会出现点击边缘导致前后两帧判定不一致的问题。3.3 植物基类与僵尸基类继承还是组合很多教程倾向把每个植物写成一个独立类比如 Peashooter 继承 PlantBase。这样做在植物种类少的时候很直观但到后期向日葵要产阳光、豌豆射手要开火、土豆地雷要启动倒计时行为差异会大到基类塞满条件判断。我的折中方案是基类只放所有植物共有的数值字段和生命周期行为通过枚举和代码分支区分。class PlantBase : public Node { public: enum class State { Idle, Attack, Dying }; virtual bool init(int plantId); virtual void update(float dt) override; void takeDamage(float dmg); bool isDead() const { return hp_ 0.0f; } CC_SYNTHESIZE(int, plantId_, PlantId); CC_SYNTHESIZE(int, sunCost_, SunCost); CC_SYNTHESIZE(float, maxHp_, MaxHp); CC_SYNTHESIZE(float, attackInterval_, AttackInterval); CC_SYNTHESIZE(State, state_, State); protected: float hp_; float attackTimer_; };你可以看到PlantBase 里没有任何具体的发射子弹或产出阳光逻辑这些交给派生类覆盖 update 或在状态分支里处理。为什么用 Node 而不是 Sprite 作为基类因为植物除了静态外观还有动画、动作、隐藏特效持有 Sprite 子节点比直接继承 Sprite 更灵活。至于引用计数Cocos 的 create 方法返回 autorelease 对象加入节点树后由节点持有你不需要手动 delete否则会破坏引用计数平衡。这条规则在 C 开发中贯穿始终。4. 种植、开火与啃食让核心玩法跑起来的三个关键代码段架构和场景就绪后进入玩法实现。这一章的三个代码段分别对应玩家操作、战斗表现、敌人行为。它们共同依赖第 3 章的网格坐标换算。4.1 种植逻辑从触点到落子的完整状态流种植操作看起来简单实际要处理三层状态当前是否选中了卡片、手指是否有效落在草坪内、阳光是否足够。很多翻车现场是因为顺序反了——先扣阳光再检查格子结果点空也扣钱。bool GameScene::onTouchBegan(Touch* touch, Event*) { auto pos touch-getLocation(); if (hud_-hitCard(pos, currentSeedId_)) { selecting_ true; return true; } if (selecting_) { int col -1, row -1; if (grid_-pixelToCell(pos, col, row) !grid_-hasPlant(col, row)) { auto plant PlantBase::createWithId(currentSeedId_, col, row); if (plant hud_-consumeSun(plant-getSunCost())) { plant-setPosition(grid_-cellToPixel(col, row)); battleLayer_-addPlant(plant, col, row); } else { // 阳光不够或卡片冷却中给个提示动画 } } selecting_ false; } return false; }逻辑顺序是先点击卡片区域进入种植待选状态再点草坪格此时才检查该格是否空着、阳光是否足够。createWithId 内部会去 PlantConfig 查表拿到阳光消耗、血量、攻击间隔等数值并把 state_ 初始化为 Idle。触摸回调的返回值也很关键onTouchBegan 返回 true 表示本层消费掉这个触摸事件否则事件会继续往下传到背景层。回到桌面开发时这个 bool 经常被忽略导致点击卡片后背景层也响应了触摸。4.2 子弹系统与对象池避免每帧频繁创建节点豌豆射手开火、子弹水平飞行、命中僵尸结算伤害。最朴素的做法是每发子弹新建一个 Sprite飞行结束 removeFromParent。但这样做的代价是频繁的创建和销毁节点尤其在波次密集时会产生明显的 GC 压力和节点树抖动。我一般会配合一个简单的对象池。void BattleLayer::update(float dt) { for (auto bullet : bullets_) { if (!bullet-isActive()) continue; bullet-setPositionX(bullet-getPositionX() bullet-getSpeed() * dt); for (auto zombie : zombiesInRow(bullet-getRow())) { if (zombie-isDying()) continue; if (zombie-getWorldRect().intersectsRect(bullet-getBoundingBox())) { zombie-takeDamage(bullet-getDamage()); bullet-deactivate(); break; } } } }这段代码里有两个关键优化。第一个是按行检测子弹只和同一行的僵尸比较碰撞9 列 x 5 行的网格把碰撞检测的复杂度从 n^2 降到了近线性。第二个是 deactivate 而不是 removeFromParent子弹只是隐藏位置并标记为非活跃下次需要发射时重新激活并设置到射手前方即可。对象池的容量可以根据屏幕内最多同时存在的子弹数来定对植物大战僵尸这个体量预创建 30 到 40 发完全是够用的。碰撞检测本身用的 intersectRect 是矩形相交。子弹的 hitbox 窄长僵尸的 hitbox 我习惯取 contentSize 的 75% 宽度这样玩家会感觉命中判定更友好不会出现明明飞到了僵尸脸上却不掉血的情况。4.3 僵尸 AI一条路上的三种状态僵尸的行为比植物简单因为它只有一条水平线可走。但简单不代表没有状态机。一个完整的僵尸至少要处理 Walk、Eat、Dying 三种状态否则会出现边走路边啃植物的诡异表现。void ZombieBase::update(float dt) { switch (state_) { case State::Walk: setPositionX(getPositionX() - moveSpeed_ * dt); if (auto plant battleLayer_-plantInFrontOf(this)) { state_ State::Eat; targetPlant_ plant; playEatAnimation(); } break; case State::Eat: if (targetPlant_ !targetPlant_-isDead()) { attackTimer_ dt; if (attackTimer_ attackInterval_) { attackTimer_ 0.0f; targetPlant_-takeDamage(attackDamage_); } } else { state_ State::Walk; playWalkAnimation(); } break; case State::Dying: // 播放死亡动画动画结束后由 BattleLayer 统一回收 break; } }moveSpeed 是僵尸每秒向前移动的像素数我一般设定在 20 到 25 像素每秒换算过来大约 0.23 格每秒给玩家留出足够的反应时间。attackInterval 设为 0.5 秒attackDamage 为 20。plantInFrontOf 在 BattleLayer 中实现它检查僵尸面前一个身位内是否存在存活植物只检查本行植物列表。容易踩坑的点Dying 状态不要立刻 removeFromParent因为死亡动画可能还有 1 到 2 秒的播放时间而且如果动画期间节点被移出节点树动作回调会触发空指针访问。统一由 BattleLayer 在 update 末尾做回收最安全。5. 避坑手册Cocos2d-x 3.16 里植物大战僵尸最容易翻车的五个地方这一章写给那些把代码跑起来、正要往项目里加玩法的人。每一条都是真实调试中遇到过的现象按现象、原因、解决的顺序整理。5.1 植物种下去瞬间纹理变黑块现象点击卡片后植物创建成功但精灵渲染成黑色色块过几秒又自动恢复。原因纹理图集还没被 SpriteFrameCache 完全加载到 GPU就有人通过 createWithSpriteFrameName 创建了精灵。在异步加载场景下框架返回的是纹理占位对象渲染出来就是黑的。V3.16 在低配模拟器上尤其容易复现。解决在进入 GameScene 前把植物和僵尸用到的 plist 全部预加载加载完成后再切换场景。auto cache SpriteFrameCache::getInstance(); cache-addSpriteFramesWithFile(textures/plants.plist, textures/plants.png); cache-addSpriteFramesWithFile(textures/zombies.plist, textures/zombies.png);把这段放到 AppDelegate 里 runWithScene 之前或者在启动场景中做一个资源加载进度条。副作用是启动时间变长但对战斗过程中的稳定性是值得的。5.2 触摸回调里 removeFromParentAndCleanup 导致崩溃现象僵尸死亡动画播完在动画回调里调用 removeFromParentAndCleanup(true)程序崩溃在事件派发相关调用栈里。原因触摸事件和动画回调都在 Director 的主循环中被派发。回调执行时节点树可能正在遍历立刻 cleanup 会释放节点持有的监听器、引用计数归零导致后续遍历访问到悬空指针。这在 C 版本中比在 Lua/JS 版本中更容易触发因为没有脚本层的保护。解决不在回调里直接清理。把待删除节点扔到一个待回收队列里由 BattleLayer 在每帧 update 结束后统一处理。void BattleLayer::flushDeadNodes() { for (auto node : pendingRemove_) { node-removeFromParent(); } pendingRemove_.clear(); }注意这里用 removeFromParent 而不是 removeFromParentAndCleanup。后者会把纹理缓存引用也一并释放如果还有别的节点引用同一份纹理会出现借尸还魂的渲染错误前者只把节点移出节点树引用计数自然降为 0 后再释放内存安全得多。5.3 碰撞检测失灵坐标空间没统一现象子弹明明穿过僵尸的身体intersectsRect 就是不返回 true。原因每个节点 boundingBox 返回的是本地坐标系矩形。子弹挂在 battleLayer 下僵尸挂在 zombieLayer 下两者本地坐标的参考原点是不同的直接比较等于拿着两份不同坐标系里的坐标硬比。解决比较之前统一到世界坐标。我一般在 ZombieBase 里实现一个 getWorldRectRect ZombieBase::getWorldRect() const { Vec2 origin convertToWorldSpace(Vec2::ZERO); float w getContentSize().width * getScaleX(); float h getContentSize().height * getScaleY(); return Rect(origin.x, origin.y, w, h); }同样碰撞比较的对象也要两侧都换算比如子弹的 boundingBox 换成 convertToWorldSpace 后的矩形。统一后如果还失灵打印矩形坐标看看能定位到是哪一层的位置偏移出了问题。5.4 波次生成瞬间卡顿同一帧涌现出大量僵尸现象进入第二波画面突然掉帧然后 8 个僵尸同时出现在战场上有的位置重叠在一起偶发闪退。原因在波次回调里循环创建僵尸每个僵尸的纹理加载、动画创建都在这一帧完成。手机上同时加载多份纹理和构造多个节点对象会因为 GPU 上传和堆分配造成明显卡顿。解决把波次表做成数据驱动僵尸按时间逐个出现而不是同帧出。LevelConfig 里记录每只僵尸的出现延迟BattleLayer 用 update 里的计时器逐个触发生成。struct WaveEntry { int zombieType; float delayAfterPrev; };资源方面坚持预加载动画对象在 ZombieBase 初始化时一次性创建好后续只是 setVisible(true) 并重置位置。这比每次 create 新的 Animate 动作省得多。5.5 从后台切回来僵尸瞬移了一大截现象游戏切到后台过几十秒再回来僵尸已经跑到了植物脸上甚至直接越过了几列。原因Director 暂停时累计的不是帧数而是时间。切回后台再激活时Director 计算出一个很大的 deltaTime僵尸按速度为 25 像素/秒乘以这个 deltaTime一次走了几秒钟的路程。碰撞检测在这期间没机会触发视觉上就是瞬移。解决给 update 里的 dt 设置上限。float dt Director::getInstance()-getDeltaTime(); dt std::min(dt, 0.05f);所有战斗系统的 update 统一用这个 clamp 后的 dt 做位移和计时。这样后台切回来最多跳一小步不会产生穿透。另一种做法是在切后台的监听里暂停实体移动的 update恢复后再启动但这种方案要处理暂停事件监听多个层叠加时容易漏掉某个子系统。clamp dt 是最简单可靠的兜底。6. 进阶打磨从能玩到值得复盘的几个习惯游戏逻辑全部跑通之后剩下的工作就是打磨和验证。我会先做三件事第一把性能验证放到真机而不是桌面端模拟器。V3.16 桌面的 GPU 和手机上完全是两个世界碰撞检测的数量、图集大小、音频加载速度只有在真机上才能看出真实水平。第二用 Release 模式跑一遍所有关卡观察僵尸密集型波次有没有掉帧。第三加一个简单的开关系统方便随时开启调试日志输出网格坐标、当前节点数和每帧碰撞次数。存档系统是我第二个必做的模块。Cocos2d-x 自带 UserDefaults对这类关卡进度和解锁植物数量级的存档够用。存档不要只存当前章节要存植物解锁列表、阳光总数、关卡星级这样一个 JSON 结构的字典可以扩展到任意多的存档字段。有一个习惯我一直保持每次新增一个字段时先思考旧存档没有这个字段时会不会崩溃。解决方法是读取时做默认值兜底。对象池的扩大应用也值得做。除了子弹僵尸死亡后的特效粒子、向日葵产出的阳光值漂浮文字都可以复用。原则是一个节点如果会在一次战斗中被创建超过 10 次就值得进对象池。阳光文字这种 UI 控件频繁 create Label 还会引入中文字体纹理重复上传的问题复用能明显改善帧率波动。最后一个建议是关于调整平衡性的。植物大战僵尸这类游戏的核心体验是数值节奏阳光消耗、植物冷却、僵尸血量、攻击频率这些参数我全部收拢到配置表里不藏在代码深处。改平衡时只动数值配置不碰逻辑代码这样测试一个数值方案的成本只需要重新编译一次而不是在一堆 update 函数里搜索魔法数字。我自己的习惯是每加一个玩法功能先在真机上完整跑一遍对应关卡再回桌面调试。这样既积累了真实设备的数据又能快速迭代代码。希望这篇文章能帮你在 C 与 Cocos2dx V3.16 的这个组合上少走几步弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑