
简介这是一份基于C与Cocos2d-x V3.16实现的《植物大战僵尸》策略塔防游戏完整工程源码适合希望系统学习Cocos2d-x框架、练习C游戏编程或独立完成中等规模2D项目的开发者作为参考。压缩包约162MB共2000个文件其中911个h头文件、469个cpp源文件和388个xml配置构成核心另有java、m、py、js等辅助源码与脚本覆盖多平台构建、资源定义和格式转换等工具链。目前已有214人学习下载具备一定的参考热度。工程完整实现了植物种植、僵尸进攻、碰撞检测、路径规划与AI攻击逻辑并运用精灵节点、事件分发、音频引擎、资源加载等Cocos2d-x机制同时通过C数据结构与文件I/O完成关卡进度序列化存档。从场景搭建到游戏循环从渲染流程到持久化存储这份工程直观展示了塔防类游戏从零到一的完整技术链路适合课程设计、毕业设计或求职作品的技术蓝本也可帮助读者排查移植中的典型问题。1. 用 C 在 Cocos2dx V3.16 上复刻植物大战僵尸到底在验证什么Cocos2dx V3.16 是 2016 年的引擎在 Cocos Creator 全面铺开之后它依然被大量中小团队用来快速验证 2D 塔防原型原因是 C API 足够薄节点树、渲染、触摸和动作系统都能在源码里看到调试没有黑盒。用这个组合做植物大战僵尸要解决的恰好是塔防最典型的三件事植物与僵尸的类继承怎么设计网格种植坐标怎么换算高频子弹和阳光对象的生命周期怎么管。这套思路对 C 基本功的锻炼比写纯控制台小游戏扎实得多也对应面试里常被问到的多态、虚函数与内存管理。后面给出的代码都基于 V3.16 工程结构直接创建 GameLayer 替换原有 HelloWorld 场景即可跑通。2. 节点树与 C 类体系V3.16 对局场景怎么搭2.1 分出植物层、僵尸层、子弹层渲染顺序才不会乱V3.16 的界面本质是一棵节点树Scene 是根Layer 挂在 Scene 下Sprite、Label、Node 挂在 Layer 下面节点之间可以有任意父子关系。复刻植物大战僵尸时不要把所有元素都堆到一个层里。我一般会拆四个层底图网格层、植物层、僵尸层、子弹层。拆层的第一个原因是渲染顺序。大量 Sprite 的绘制顺序由节点树顺序和 localZOrder 共同决定分四个 Node 容器可以把植物固定放在僵尸层之下子弹层放在最上面。第二个原因是触摸事件分发顺序同样依赖节点顺序。如果所有对象挤在同一层每新增一个对象都要手动调整 z-order后期出现绘制穿插时很难查。一个精简的 GameLayer 头文件长这样#pragma once #include cocos2d.h #include CPlant.h #include CZombie.h class GameLayer : public cocos2d::Layer { public: static cocos2d::Scene* createScene(); virtual bool init() override; CREATE_FUNC(GameLayer); void setupGrid(); void spawnZombie(float dt); void updateGame(float dt); private: cocos2d::Node* m_plantLayer; cocos2d::Node* m_zombieLayer; cocos2d::Node* m_bulletLayer; cocos2d::VectorCPlant* m_plants; cocos2d::VectorCZombie* m_zombies; int m_sun; };CREATE_FUNC是 V3.16 大量使用的宏展开后生成一个static GameLayer* create()内部执行new GameLayer()、调用init()然后交给autorelease()。调用方拿到的是一个自动释放对象不需要关心何时 delete。VectorCPlant*是引擎对标准容器的封装元素必须是Ref子类析构时会自动处理每个元素的引用计数适合存放挂进节点树的对象。初始化时创建三个子层它们本身不参与逻辑只承担容器职责。这样后面做碰撞检测时可以先用层做粗过滤再对层内对象做精确判断避免每次遍历全场景节点。2.2 抽象基类 CPlant 与 CZombie覆盖与隐藏要分清植物和僵尸有大量相似行为都有血量、都在对应层里行动、都有死亡表现。这种继承关系正是 C 面向对象发挥价值的地方。先设计一个 CPlant 抽象基类把公共属性和流程写在基类里// CPlant.h #pragma once #include cocos2d.h class CPlant : public cocos2d::Node { protected: int m_hp; int m_sunCost; float m_attackInterval; float m_timer; public: CREATE_FUNC(CPlant); virtual bool init() override { if (!Node::init()) return false; m_hp 100; m_sunCost 50; m_attackInterval 1.0f; m_timer 0.0f; return true; } virtual void onUpdate(float dt) { m_timer dt; if (m_timer m_attackInterval) { m_timer 0.0f; onAction(); } } virtual void onAction() {} virtual void onZombieHit(CZombie* zombie) {} void reduceHp(int damage) { m_hp - damage; if (m_hp 0) { onDie(); } } virtual void onDie() { this-removeFromParent(); } CC_SYNTHESIZE(int, m_row, Row); CC_SYNTHESIZE(int, m_col, Col); };这里要特别注意 C 的叫法子类实现基类的虚函数叫覆盖override不是重载overload。onAction()在基类是空实现豌豆射手覆盖它来发射子弹寒冰射手覆盖它发射寒冰弹坚果墙覆盖它处理受击。如果子类写了一个参数不同的onAction(int damage)那它只是隐藏了基类版本调度逻辑永远走不到子类实现。这个坑在 C 类体系里几乎必然会踩。僵尸的基类思路一致一个CZombie继承 Node包含walking和eating两个状态update里根据状态切换位移或攻击动画。不同僵尸只覆盖移动速度和血量数值特殊僵尸再追加技能虚函数。新增一个植物只需要新建类改几个参数局部性很强。为了看清职责划分我习惯把派生关系列成一张表既方便评审也方便排错基类派生类覆盖的虚函数新增成员CPlantCSunFloweronAction 里生成阳光无CPlantCShooteronAction 里生成子弹子弹类型、射程CPlantCWallNutonZombieHit 播放受击防御状态CZombieCNormalZombie不覆盖用默认速度无CZombieCTankZombie覆盖 reduceHp 附加判定护甲血量这张表说明了一个事实技能的差异用虚函数表达数值差异用成员变量表达不会出现一张巨大的 switch-case。后续增加第五种植物就多一个类文件不动 GameLayer 里的主逻辑。2.3 卡槽选植物用独立类别把按钮状态散落在 GameLayerPVZ 底部有一条卡槽玩家要先选中植物才能点击格子种植。不要在 GameLayer 里用数组加一堆 if 判断更好的做法是把卡槽做成一个小类内部管理自己的冷却状态和亮起状态class CPlantCard : public cocos2d::Node { public: CREATE_FUNC(CPlantCard); void initCard(int plantType) { m_plantType plantType; m_coolDown 0.0f; } void update(float dt) override { if (m_coolDown 0.0f) { m_coolDown - dt; } } bool isReady() const { return m_coolDown 0.0f; } void startCoolDown(float seconds) { m_coolDown seconds; } private: int m_plantType; float m_coolDown; };卡槽冷却由自身维护GameLayer 只需要在种植成功后调用startCoolDown(7.0f)不用在每帧 update 里遍历大量冷却判断。这种把游戏实体内部状态封装进实体自己的做法比纯粹在 GameLayer 里堆逻辑更接近商业项目的组织方式也是 C 类设计里很容易被忽略的一点。3. 网格坐标与矩形碰撞PVZ 种植判定和攻击判定的实现3.1 1280×720 下的 5 行 9 列网格换算PVZ 的种植区域是 5 行 9 列这个布局从第一代开始就没变过。在 V3.16 里我把设计分辨率固定为 1280×720种植区不铺满全屏左右留出状态栏宽度。简单做法是让种植区左上角对齐屏幕左上角每个格子宽度用可见宽度除以列数void GameLayer::setupGrid() { Size visible Director::getInstance()-getVisibleSize(); // 设计分辨率 1280 x 7209 列 5 行 m_gridWidth visible.width / 9.0f; m_gridHeight visible.height / 5.0f; m_gridOrigin Vec2(0, 0); }当玩家点击屏幕时触摸点从屏幕坐标换算成格子行列bool GameLayer::onTouchBegan(cocos2d::Touch* touch, cocos2d::Event* event) { Vec2 pos touch-getLocation(); int col static_castint((pos.x - m_gridOrigin.x) / m_gridWidth); int row static_castint((pos.y - m_gridOrigin.y) / m_gridHeight); if (col 0 || col 9 || row 0 || row 5) { return false; } // m_blocks 是二维数组空位置返回 nullptr if (m_blocks[row][col] ! nullptr) { return false; } return handlePlantAt(row, col); }取整时机是这段代码的关键先用减法去掉种植区左上角的偏移再除以格宽。如果直接除状态栏区域的点击会负数归零被当成第 0 列。行列锁进有效范围后还需要在handlePlantAt里做阳光数量判断和植物冷却判断。网格坐标在后面的碰撞检测里会反复使用拿到 row 就能快速知道对象属于哪一行把行号作为攻击判定的第一道闸门而不是每帧拿所有对象做全屏矩形相交。3.2 不用物理引擎用 Rect 相交做植物攻击与僵尸啃食判定Cocos2dx V3.16 内置了 Box2D 和 Chipmunk但植物大战僵尸完全用不上重力、刚体和复杂材质。物理引擎带来的调试成本和计算开销都不划算。常见做法是自己维护碰撞判定所有可交互对象都有包围盒用Rect::intersectsRect判断是否相交。豌豆子弹与僵尸的判定void GameLayer::updateBullets(float dt) { std::vectorCBullet* toRecycle; for (auto bullet : m_bullets) { bullet-setPositionX(bullet-getPositionX() - kBullerSpeed * dt); for (auto zombie : m_zombies) { if (zombie-getRow() ! bullet-getRow()) { continue; // 不同行的僵尸永远不会被打中 } Rect bulletBox bullet-getBoundingBox(); Rect zombieBox zombie-getBoundingBox(); if (bulletBox.intersectsRect(zombieBox)) { zombie-reduceHp(bullet-getDamage()); toRecycle.push_back(bullet); break; } } } for (auto bullet : toRecycle) { recycleBullet(bullet); } }这段代码每帧执行一次先按子弹速度向前移动再对同一行僵尸逐个做矩形相交。循环里不能直接回收子弹否则m_bullets在迭代过程中被修改迭代器就失效。先把命中子弹收集到toRecycle遍历结束后统一回收这个延迟处理模式在对象池里非常常用。僵尸吃植物是另一个方向的判定bool GameLayer::isZombieEating(CZombie* zombie, CPlant* plant) { if (zombie-getRow() ! plant-getRow()) { return false; } // 僵尸从右往左走只有它的矩形左边界碰到植物右边界时才进入啃食态 Rect zr zombie-getBoundingBox(); Rect pr plant-getBoundingBox(); bool hitHorizontal zr.getMinX() pr.getMaxX() zr.getMaxX() pr.getMinX(); bool hitVertical zr.getMinY() pr.getMaxY() zr.getMaxY() pr.getMinY(); return hitHorizontal hitVertical; }为什么用僵尸左边界而不是整个矩形相交因为 PVZ 的僵尸遇到植物后会停住进入啃食动画如果用整个矩形相交脚部模型会提前接触植物视觉上还没啃到就开始掉血。用getMinX()配合行过滤能把攻击点精确控制在僵尸手部附近。3.3 子弹效果做成状态枚举命中后由僵尸自己处理子弹不只有伤害寒冰射手还会让僵尸减速。不要在子弹命中逻辑里写一堆 if 判断僵尸类型正确的做法是子弹携带效果枚举命中后把效果参数传给僵尸由僵尸内部决定行为enum class EBulletEffect { Normal, Frozen }; class CBullet : public cocos2d::Node { public: int getDamage() const { return m_damage; } EBulletEffect getEffect() const { return m_effect; } private: int m_damage; EBulletEffect m_effect; };命中处理简化成一行调用if (bulletBox.intersectsRect(zombieBox)) { zombie-applyHit(bullet-getDamage(), bullet-getEffect()); toRecycle.push_back(bullet); break; }applyHit内部根据 EBulletEffect 的值决定是否启动减速计时器。后续加入火球、篮球都只改枚举和数值不需要改动子弹碰撞逻辑。顺便一提碰撞对象多起来之后可以把 5 行僵尸分别放进 5 个VectorCZombie*子弹和植物做判定时只遍历对应行高峰期能把每帧碰撞计算量大幅降下来。4. 子弹、阳光与调度器C 对象池和 V3.16 事件分发4.1 豌豆高频生成时不频繁 new/delete交给对象池一局打到后期场上同时存在的豌豆和寒冰弹很容易超过几十个加上爆炸特效和飘动的阳光每帧都有大量对象创建和销毁。C 里频繁new和delete会产生内存碎片还会因为cocos2d::Ref的引用计数管理不当带来野指针。对象池是这类高频场景的标准解法。我用cocos2d::VectorCBullet*实现一个简单池class BulletPool { public: BulletPool() default; ~BulletPool() { m_idle.clear(); // Vector clear 会逐个 release确保不泄漏 } CBullet* obtain() { CBullet* bullet nullptr; if (!m_idle.empty()) { bullet m_idle.back(); m_idle.popBack(); } else { bullet CBullet::create(); bullet-retain(); // 池子持有一份引用 } bullet-reset(); bullet-setVisible(true); return bullet; } void recycle(CBullet* bullet) { bullet-removeFromParent(); // 从显示树移除 bullet-retain(); // 补回一份引用保证池内对象不析构 bullet-setVisible(false); m_idle.pushBack(bullet); } private: cocos2d::VectorCBullet* m_idle; };CBullet::create()返回的是 autorelease 对象引用计数由Ref管理。创建后立刻retain()让对象在自动释放池之外仍然存活。recycle里removeFromParent让父节点释放一次引用紧接着再retain两个动作配合池子始终持有一份稳定引用。子弹再次发射时调用obtain()池里有空闲对象就直接复用没有才创建高频分配被降成低频分配。用cocos2d::Vector而不是std::vector是因为Vector::clear和popBack都会正确处理元素引用计数。确认对象真正退役后m_idle.clear()一次收尾即可不需要手写释放循环。4.2 Scheduler 与 TouchListener波次生成和阳光收集V3.16 的 Scheduler 是替代手写while(true)循环的官方调度机制把帧循环、延迟调用、周期调用统一起来。最常用的三个调度 API 是方法触发时机典型场景scheduleUpdate()每帧回调update(float dt)移动所有僵尸schedule(callback, interval)每隔 interval 秒回调一次天空随机掉落阳光scheduleOnce(callback, delay)延迟 delay 秒后只回调一次播放完死亡动画后移除对象生成阳光和僵尸波次用周期调度this-schedule([this](float dt) { // 每 1.2 秒从屏幕顶部生成一个阳光 this-spawnSunFromSky(); }, 1.2f, sky_sun); this-schedule([this](float dt) { this-trySpawnZombieWave(); }, 3.0f, zombie_wave);注意第二个schedule重载带一个字符串 key想停止时调用unschedule(sky_sun)即可。V3.16 支持字符串 key 和 target 两种调度绑定方式字符串版本在逻辑上更直观不用去记一个局部变量的生命周期。阳光收集用触摸事件实现。V3.16 事件分发走EventListenerTouchOneByOneauto touchListener EventListenerTouchOneByOne::create(); touchListener-onTouchBegan [this](Touch* touch, Event* event) { Vec2 pos touch-getLocation(); for (auto sun : m_suns) { Rect box sun-getBoundingBox(); if (box.containsPoint(pos)) { sun-collect(); m_sun 25; return true; } } return false; }; _eventDispatcher-addEventListenerWithSceneGraphPriority(touchListener, this);addEventListenerWithSceneGraphPriority会让监听器跟随当前节点从场景中自动移除场景切换时不用手动注销。但用addEventListenerWithFixedPriority注册的全局监听器必须在onExit里手动removeEventListener否则场景切换后回调仍可能触发访问已经释放的 this 指针。这是 V3.16 里崩溃率最高的一个点。阳光收集放在onTouchBegan而不是onTouchMoved是为了贴合原版手感点到即收不要求滑动经过。sun-collect()内部放一个 Action播放飞向阳光计数器的动画回调里再把阳光对象回收到池中。4.3 别在 update 里做字符串拼接和文件操作V3.16 的 update 走 60 FPS 主循环任何同步文件读写在低端 Android 机上都会造成肉眼可见的卡顿。比如播放音效时不要在 update 里反复调用未预加载的AudioEngine::play2d建议在进入场景前先完成预加载。字符串拼接也要避免引擎的log函数在 Release 包里仍然会执行格式化正确做法是用调试宏包起来#if COCOS2D_DEBUG log(sun count: %d, m_sun); #endifCOCOS2D_DEBUG在 debug 构建下为 1Release 下为 0这段日志代码会被预处理器直接裁掉不会进入发布包。这个习惯对 C 项目尤其重要因为标准库的std::to_string和字符串拼接都会触发堆分配放在 60 帧循环里会拖慢整个调度链。5. 发布前的优化与 Cocos2dx V3.16 的几个具体坑5.1 合图、预加载和对象池把 draw call 压下来V3.16 用 OpenGL 渲染性能瓶颈通常在 draw call 数量而不是 CPU 逻辑。植物、僵尸、子弹如果各自引用独立 PNG一场战斗同时显示的纹理可能超过二三十张。常见做法是把所有植物帧和僵尸帧打成一个.plist大图再用SpriteFrameCache加载。加载放在闪屏或 Loading 场景里不要在 update 中临时取帧auto cache SpriteFrameCache::getInstance(); cache-addSpriteFramesWithFile(plants_anim.plist, plants_anim.png); cache-addSpriteFramesWithFile(zombies_anim.plist, zombies_anim.png);运行时用Sprite::createWithSpriteFrameName(peashooter_01.png)创建精灵帧切换不会产生新的纹理绑定。性能检查优先看 draw call方向上比纠结单个函数的几十条指令更有价值。5.2 引用计数、脱管节点与调度的生命周期Ref引用计数是最容易写出诡异问题的地方。常见错误是调用node-release()后指针没有置空之后又在别处removeFromParent()触发二次释放。我一般在代码里定两条规则用create()创建的对象不手动管理交给 autorelease 和父节点需要长生命周期时用retain()并在对象回收入口统一release()不要分散在多个回调里。调度生命周期也如此。植物被僵尸吃光后它的 update 调度不会自动停必须在onDie()里显式调unscheduleUpdate()。更不能在removeFromParent()之后继续setPosition这同样是 V3.16 下崩溃的热点。5.3 发布时别忽略 VC 运行库与 NDK 版本用 Visual Studio 编译 Windows 版本发布到别的机器时需要带上 Microsoft Visual C Redistributable缺少的表现是双击 exe 后提示缺失 VCRUNTIME140.dll。在项目属性页把运行库改为多线程静态链接/MT可以避免这条依赖但 exe 体积会变大如果保持动态链接/MD就在安装包里捆绑对应版本的 Redistributable 安装器。Android 发布时注意 V3.16 的 NDK 编译工具链兼容性。不要直接使用过新的 NDK否则链接阶段会出现大量 undefined reference优先参考工程自带的 android 工程配置里指向的 NDK 版本区间。发布后重点检查两点安装包是否打进冗余 ABI以及真机入场贴图是否迟缓。如果缓慢问题几乎都出在 SpriteFrameCache 预加载时机晚于场景切换。本文还有配套的精品资源点击获取