☰
基于Cocos2dx V3.16的C++植物大战僵尸工程拆解与二次开发指南
2026/10/22 14:38:43 网站建设 项目流程

简介:使用C++语言基于Cocos2d-x V3.16开发的植物大战僵尸游戏,是一份完整的策略塔防游戏源码工程,面向希望深入二维游戏开发全流程的C++学习者和游戏开发入行人员。项目实现了经典植物与僵尸对抗玩法,运用了引擎的精灵、节点、事件、音频和资源加载机制,并用C++编写碰撞检测、路径规划、AI行为、关卡存档等核心模块,有助于理解渲染、物理模拟、用户交互与数据持久的开发链路。资源包共2000个文件,以911个h头文件和469个cpp源文件为主要代码主体,另有388个xml配置、98个java及少量脚本用于辅助构建与扩展,压缩包约162.17MB,目录结构清晰,便于按模块检索。目前已有216人学习下载,是研究Cocos2dx V3.16工程组织或参考完整塔防案例的高性价比资料。

1. 用C++基于Cocos2dx V3.16开发植物大战僵尸:这个项目到底能拆出多少东西

做游戏开发的人应该都动过“复刻一版植物大战僵尸”的念头:玩法成熟、逻辑清晰、素材好找,又能把碰撞检测、AI寻路、状态机、资源管理这些2D游戏的核心模块全过一遍。这份基于Cocos2dx V3.16、用C++写成的植物大战僵尸工程,就是这么一份可以直接拿来拆的源码包。它不是Demo级别的拼凑代码,而是把精灵管理、碰撞判定、僵尸AI、音频混音、横竖屏适配、进度存档都落到具体文件里的完整工程。对想系统学习C++游戏开发、或者打算在Cocos2dx老版本上做塔防类产品的开发者来说,它的价值在于你能从一套能跑通的代码里,反推出当时的设计决策和踩坑路径。本文从工程结构拆到核心玩法逻辑,最后落到我在移植和二次开发时反复撞过的几个坑,尽量让新手能照着重现,让熟手能直接拿去改。

2. 工程结构与战斗循环:先搞清这包代码里每个文件是干什么的

拿到源码包先别急着编译,Cocos2dx 3.16的工程结构不算复杂,但这份包里的文件命名很有讲究,几个关键的.cpp文件对应着不同的系统模块。先花十分钟把文件地图理清楚,后面改代码能省一半时间。

2.1 源码包内的模块地图:从primitives.c到AudioMixer.cpp

打开源码目录,最先看到的一批文件其实已经暗示了工程的技术栈。primitives.c和Base64TranscoderFHS.c是C语言实现的底层工具模块,前者通常用于绘制调试用的几何图元,后者负责Base64编解码——在Cocos2dx里常见于纹理数据的加密传输或存档数据的编码处理。format.c则大概率是资源格式解析的辅助模块,比如自定义的关卡数据格式或者粒子配置文件读取。

再往上是C++层的核心文件。Portrait_png.cpp和Landscape_png.cpp这两个文件名直接暴露了工程的适配策略:同一套UI资源分别做了竖屏和横屏两套打包版本。Cocos2dx 3.16里用CCFileUtils加载纹理时,如果文件名带_png后缀的.cpp,说明资源是通过代码方式直接嵌入的,而不是走传统的Resources目录异步加载。这种做法的好处是资源随包体走、加载速度快,坏处是改一张图就得重新编译整个工程,后面我会专门讲这个坑。

PlayDisable_png.cpp和PlayEnable_png.cpp对应的是按钮的两种状态贴图——Cocos2dx的ui::Button支持setNormalImage和setDisabledImage,这两份文件就是游戏主界面“开始游戏”按钮在可点击和禁用状态下的纹理数据源。jsb_cocos2dx_pluginx_auto.cpp和lua_cocos2dx_pluginx_auto.cpp是脚本绑定层的自动生成文件,说明这个工程原本预留了JavaScript和Lua的脚本扩展接口,意味着核心逻辑虽然用C++写的,但业务层曾经或打算支持热更新脚本。

2.2 从AppDelegate到场景切换:3.16版本的启动流程怎么走

Cocos2dx 3.16的启动入口是AppDelegate类,applicationDidFinishLaunching里做导演、视口和场景的初始化。常见做法是先设置GLViewImpl的分辨率策略——这个工程如果同时存在横竖屏的_png.cpp,那么大概率用的是ResolutionPolicy::NO_BORDER或SHOW_ALL,这两种策略能保证UI在不同比例屏幕上不变形,但代价是会在屏幕两侧出现黑边或者裁切。

场景切换方面,3.16版本推荐用Director::getInstance()->replaceScene而不是pushScene。replaceScene会释放当前场景的内存,pushScene则会把旧场景压栈保留,适合做暂停界面,但频繁使用容易导致内存峰值上涨。这个工程在战斗场景和主菜单之间切换,我猜用的是TransitionFade过渡动画加replaceScene,浇花类游戏的场景层级不多,没必要保留战斗场景在栈底。

启动流程里还有一个容易被忽略的点:AppDelegate里往往注册了cocos2d::FileUtils的搜索路径。如果工程把横竖屏资源放在不同子目录,那FileUtils::getInstance()->addSearchPath("landscape")这行代码就是横竖屏适配的枢纽。改这个路径比改代码逻辑更隐蔽,新手经常遇到“资源加载失败返回nullptr”的报错,原因多半是搜索路径没配对。

2.3 横竖屏资源与UI控件:两份_png.cpp到底怎么回事

Portrait_png.cpp和Landscape_png.cpp的生成方式一般是这样的:美术出图后用TexturePacker之类的工具打成图集,再通过自定义工具把图集数据转成C++数组写入.cpp文件。这样做的历史背景是Cocos2dx 3.x早期在某些平台上直接加载Resources里的png会碰到内存对齐问题,而转成.cpp编译进二进制可以绕开文件系统差异。

使用上有三个关键点。第一,加载这种嵌入式纹理不能走Director::getInstance()->getTextureCache()->addImage("xxx.png"),而要从.cpp里导出的xxx::getInstance()之类的单例拿到Image对象,再手动创建Texture2D。第二,横竖屏切换时不能简单地替换场景,需要在GLView层面重新设置设计分辨率,并且切换后要调用Director::getInstance()->getRunningScene()->updateTransform()。第三,这类.cpp文件编译后体积膨胀明显,一份1MB的png转成.cpp后可能到3MB,如果发现apk或可执行文件异常变大,先查这里。

注意:嵌入式纹理资源虽然加载快,但没法用Cocos2dx的异步加载纹理接口,也就意味着大纹理文件会导致启动白屏时间变长。如果后续要优化启动速度,优先把这些.cpp改回标准Resources目录加载。

3. 节点体系与对象工厂:把植物的种下去和僵尸放出来

Cocos2dx 3.16里所有可见对象都是Node的子类,植物和僵尸的创建本质上是组装一套Sprite加Animation加物理判定体的过程。这个工程的代码风格很典型:先建基类,再派生出具体植物和僵尸类型,用工厂模式统一创建入口,方便战斗逻辑调用。

3.1 用Sprite和Node搭出可复用的对象工厂

游戏里有向日葵、豌豆射手、坚果墙这类功能差异很大的植物,但它们的行为骨架是共通的:种植、产生效果、被僵尸啃咬、死亡。如果每种植物的create函数都写一遍,代码会膨胀到没法维护。常见的做法是定义一个PlantBase类,把通用属性和接口放进去,再写一个PlantFactory统一创建。

// PlantFactory.h 简化版代码 class PlantFactory { public: static PlantBase* createPlant(int typeId, const std::string& texturePath) { PlantBase* plant = nullptr; switch (typeId) { case 1: // 向日葵 plant = Sunflower::create(); break; case 2: // 豌豆射手 plant = Peashooter::create(); break; case 3: // 坚果墙 plant = Wallnut::create(); break; default: plant = PlantBase::create(); break; } if (plant) { plant->setPlantType(typeId); // 这里用Cocos2dx的Sprite::create初始化贴图 auto sprite = Sprite::create(texturePath); plant->addChild(sprite); } return plant; } };

这段代码的逻辑在于:createPlant是唯一的创建入口,调用方只需要传类型ID和资源路径,不需要关心具体子类的构造细节。参数说明:typeId对应关卡数据里配置的植物编号,texturePath是normal状态下的贴图路径。这里有个设计上的取舍——贴图路径从外部传入而不是在子类内部写死,是为了方便后续做皮肤系统或者屏幕适配时替换资源,代价是调用方需要维护一套路径表。我一般会额外封装一个ResourceManager来统一管理路径到纹理的映射,避免字符串散落在业务代码里。

3.2 碰撞检测与网格化判定:不落地的碰撞怎么处理

植物大战僵尸这类塔防游戏,碰撞检测不需要物理引擎级别的精度。僵尸去啃植物时,只要判断僵尸的位置是否进入了植物的有效判定范围即可。但Cocos2dx 3.16自带的是物理引擎集成,很多人一上来就开PhysicsBody,反而把问题搞复杂了——大量动态刚体在2D网格地图上做碰撞,性能开销大且调试困难。

这个工程更可能采用的是网格化碰撞判定:把草坪划分成9x5的格子,每个格子记录当前被哪个植物占据,僵尸移动时只检查自己所在的格子以及前后一个格子的占用情况。

// GridCollisionSystem.cpp 核心判定逻辑 bool GridCollisionSystem::canZombieEatPlant(const Vec2& zombiePos) { // 将世界坐标转换为网格坐标,每个格子大小假设为80x96 int gridX = static_cast<int>(zombiePos.x / 80.0f); int gridY = static_cast<int>(zombiePos.y / 96.0f); // 边界检查,防止数组越界 if (gridX < 0 || gridX >= 9 || gridY < 0 || gridY >= 5) { return false; } // 查询网格占用表,occupiedGrid是一个二维数组 int plantId = occupiedGrid[gridY][gridX]; if (plantId == 0) { return false; // 空格子,僵尸继续前进 } // 命中植物,通知僵尸切换到攻击状态 ZombieState::Instance()->switchToAttackState(zombieId, gridX, gridY); return true; }

这段逻辑做了三件事:坐标到网格的换算、边界保护、网格占用查询。参数说明:occupiedGrid是一个int类型的二维数组,值为0表示空格,非0表示植物实体ID,更新时机在植物种下和植物被消灭时。这种做法的性能优势是O(1)查询,劣势是碰撞精度只能到格子级,僵尸和植物如果同时出现在两个格子的交界处,可能视觉上还没碰到就触发了攻击。实际游戏里这个问题不明显,因为美术资源的判定框本身就比显示框小,但如果你要做“僵尸被子弹击退”这类位移逻辑,网格碰撞就不够用了,得再加上AABB盒判断。

3.3 事件系统:点击种植与僵尸触发

Cocos2dx 3.16的事件体系是EventListener机制,触摸事件、自定义事件都走Director::getInstance()->getEventDispatcher()。种植植物的交互逻辑通常是:玩家先选卡牌,然后点击草坪格子,游戏根据点击坐标计算出目标网格,扣减阳光值后在目标位置实例化植物。

// GameScene.cpp 中种植植物的触摸处理 void GameScene::onTouchEnded(Touch* touch, Event* event) { Vec2 touchPos = touch->getLocation(); // 将触摸点从屏幕坐标转换到场景世界坐标 Vec2 worldPos = this->convertToNodeSpace(touchPos); // 判断触摸点是否落在草坪有效区域内(y坐标在100到580之间) if (worldPos.y < 100 || worldPos.y > 580) { return; } // 计算目标网格坐标 int gridX = (worldPos.x - gridOriginX) / gridCellWidth; int gridY = (worldPos.y - gridOriginY) / gridCellHeight; // 检查合法性:格子未占用且阳光值足够 if (gridCollisionSystem->isGridEmpty(gridX, gridY) && sunEnergy->getCurrentSun() >= selectedPlantCost) { // 扣减阳光,创建植物,更新网格占用表 sunEnergy->consume(selectedPlantCost); PlantBase* plant = PlantFactory::createPlant(selectedPlantTypeId, plantResourcePath); plant->setPosition(gridOriginX + gridX * gridCellWidth + gridCellWidth/2, gridOriginY + gridY * gridCellHeight + gridCellHeight/2); this->addChild(plant); gridCollisionSystem->occupyGrid(gridX, gridY, plant->getEntityId()); } else { // 提示阳光不足或格子已被占用 showWarningMessage("Cannot plant here!"); } }

参数说明:gridOriginX和gridOriginY是草坪左下角锚点的世界坐标,gridCellWidth和gridCellHeight是格子尺寸,这两个值必须与碰撞系统里的定义完全一致,否则会出现“植物种下去但碰撞判定看不见它”的灵异事件。触摸事件要记得在onEnter里注册、onExit里注销,3.16版本不再推荐在onTouchBegan里直接返回true来吞掉事件,因为多指触摸和ScrollView会冲突。

4. 核心玩法逻辑:僵尸AI、阳光经济与进度存档

这一层是塔防游戏能不能“好玩”的分水岭。Cocos2dx 3.16提供的是渲染和基础框架,但僵尸的路线规划、植物的攻击时机、阳光的产出节奏,全部要自己在C++层实现。这一章从状态机、数值循环和数据持久化三个角度拆解。

4.1 僵尸的路线与AI状态机:不多走一步,也不原地发呆

僵尸的行为不是靠路径寻路算法——这块草坪基本是直线通道,真正的复杂度在状态切换。一个正常的僵尸至少要有四个状态:走路、攻击、被减速、死亡。如果状态切换写成一坨if-else,加一个新技能(比如橄榄球僵尸冲刺)就得改一片代码,所以工程里大概率维护了一个简单状态机。

// ZombieFSM.cpp 状态机骨架 class ZombieFSM { public: enum class State { WALKING, ATTACKING, SLOWED, DYING }; void update(Zombie* zombie, float dt) { switch (currentState) { case State::WALKING: zombie->moveForward(dt); // 检测是否碰到植物,碰到则切换攻击 if (gridCollisionSystem->canZombieEatPlant(zombie->getPosition())) { transitionTo(State::ATTACKING); } break; case State::ATTACKING: zombie->attackPlant(); // 目标植物被消灭后继续走 if (!gridCollisionSystem->isPlantAlive(targetPlantId)) { transitionTo(State::WALKING); } break; case State::SLOWED: zombie->updateSlowEffect(dt); // 减速状态只是附加状态,持续时间结束回到走动 if (slowTimer <= 0.0f) { transitionTo(State::WALKING); } break; case State::DYING: zombie->playDeathAnimation(); break; } } private: void transitionTo(State newState) { // 退出当前状态时的清理 // 进入新状态的初始化 currentState = newState; } State currentState = State::WALKING; };

代码逻辑是每帧调用update,根据当前状态执行行为并判断是否跳转。参数说明:dt是Cocos2dx传入的帧间隔,在3.16里获取方式是Director::getInstance()->getDeltaTime()。这里有一个常见误区——直接把SLOWED当成独立状态塞进枚举会导致状态爆炸,更好的做法是把它实现为“减速效果”叠加在WALKING上,用一个减速计时器控制移动速度倍率。上面代码为了直观演示才把它列成独立状态,实际工程里我会拆成状态叠加层,比如speedMultiplier这个float字段,被寒冰射手攻击后乘以0.5,大蒜效果可能乘以0.2。

4.2 阳光值、生产节奏与数值配置:平衡不是玄学是配置表

阳光是游戏里的硬通货,向日葵产阳光、僵尸掉阳光、阳光蘑菇在地上结阳光,整个经济系统如果数值拍脑袋写进代码,后期调平衡会改到怀疑人生。这个工程的C++结构里,合理的做法是单独建一个SunConfig.h或data.json配置文件,把产出间隔、阳光值、植物价格都抽出来。

参数名配置值作用域说明
sunflower_produce_interval10.0f全局向日葵每次产出阳光的间隔秒数
sunflower_produce_value50全局向日葵单次产出的阳光量
peashooter_cost100商店豌豆射手的种植消耗
wallnut_cost50商店坚果墙的种植消耗
zombie_speed_base0.8f僵尸AI普通僵尸的基础移动速度
zombie_attack_damage10僵尸AI僵尸每次啃咬对植物造成的伤害

配置一般在GameManager::loadConfig()里用FileUtils::getInstance()->getDataFromFile读取,转成ValueMap后分发到各个系统。要注意的是Cocos2dx 3.16的ValueMap对浮点数的处理有坑——JSON里写0.8读出来可能是Value::Type::DOUBLE,直接asFloat()在某些平台会截断精度。我一般会统一用asDouble()转成double再强转float,或者干脆把配置值都定义成整数毫秒秒数,规避浮点误差。

做数值调整时的技巧是:先改配置,再重启游戏验证,不要写“热更新数值”功能,那是给自己挖坑。3.16版本如果想在运行时改配置,得自己实现监听回调,一旦改错没生效,排查难度远大于重启一次。

4.3 进度存档:用文件I/O把游戏状态序列化下来

存档是单机游戏的“后悔药”。植物大战僵尸的存档内容包括:当前关卡进度、已解锁的植物列表、阳光值、当前局内的所有植物和僵尸状态。C++下的序列化方案很成熟,常见做法是定义一个SaveGameData结构体,重载<<和>>操作符,直接写入FileUtils::getInstance()->getWritablePath()指向的目录。

// SaveManager.cpp 存档与读档核心 bool SaveManager::saveGame(const GameState& state) { std::string savePath = FileUtils::getInstance()->getWritablePath() + "savegame.dat"; std::ofstream outFile(savePath, std::ios::binary); if (!outFile.is_open()) { log("Cannot open save file for writing!"); return false; } // 写入版本号,方便后续升级迁移 int version = 1; outFile.write(reinterpret_cast<char*>(&version), sizeof(version)); // 写入关卡ID和阳光值 outFile.write(reinterpret_cast<char*>(&state.levelId), sizeof(state.levelId)); outFile.write(reinterpret_cast<char*>(&state.sunEnergy), sizeof(state.sunEnergy)); // 写入已种植植物的数量 int plantCount = state.plantList.size(); outFile.write(reinterpret_cast<char*>(&plantCount), sizeof(plantCount)); // 逐个写入植物坐标和类型 for (const auto& plant : state.plantList) { outFile.write(reinterpret_cast<char*>(&plant.typeId), sizeof(plant.typeId)); outFile.write(reinterpret_cast<char*>(&plant.gridX), sizeof(plant.gridX)); outFile.write(reinterpret_cast<char*>(&plant.gridY), sizeof(plant.gridY)); } outFile.close(); return true; }

这里reinterpret_cast<char*>是二进制序列化的标准姿势,注意不要直接对std::string成员做reinterpret_cast,因为string内部是堆指针,存进文件的是指针地址而不是字符串内容,一重启进程就悬垂。正确做法是像代码里一样,先写大小,再逐个写字符。加载存档时按相反顺序读取,并且要做版本兼容——如果版本号大于当前程序支持的最大版本,直接返回false并提示玩家存档不兼容。另外Cocos2dx的getWritablePath()在Android上指向应用私有目录,在iOS上指向Documents,不需要自己加子目录,但要注意及时flush文件流,否则程序被系统杀掉时可能丢失存档数据。

5. 避坑手册:Cocos2dx 3.16下的资源、音频与内存雷区

Cocos2dx 3.16是个老版本,但正因为老,网上能查到的解决方案大多是针对3.0~3.10的,直接套用经常翻车。这一章把我实际踩过的四个坑写实,每条都是“现象 → 原因 → 解决”的格式,照着排查能省不少时间。

5.1 现象:AudioMixer.cpp混音崩溃,声音一多就闪退

有一次我把豌豆射手的射击音效频率调快了一倍,结果每次密集攻击时游戏直接闪退,崩溃栈指向AudioMixer.cpp的某个混音函数。查了半天发现是短音效同时播放实例数过多,Cocos2dx 3.16默认的音频引擎是SimpleAudioEngine,它对同一时间播放的音效数量有限制,超过上限会触发内部资源分配异常。解决方法是改用experimental::AudioEngine,并给每个音效ID设置最大播放实例,或者做音效通道池,把同类型射击音效复用一个播放ID,后到的覆盖先到的。

5.2 现象:横屏包在竖屏设备上变形,UI全部被拉伸

打包成横屏后,在平板和手机上UI位置完全不对,按钮被拉成椭圆。原因是AppDelegate里设置的设计分辨率只考虑了宽度,没考虑ResolutionPolicy。3.16版本如果设置setDesignResolutionSize(1136, 640, ResolutionPolicy::SHOW_ALL),横屏在16:9的设备上正常,但在4:3设备上会出现上下黑边,而如果用FIXED_WIDTH,高度不够的地方会直接裁切。解决方法是运行时按实际屏幕宽高比动态选择策略,1024x768的iPad用SHOW_ALL,1920x1080的手机用FIXED_WIDTH。这个判断逻辑放在applicationDidFinishLaunching里,别放在某个Scene里。

5.3 现象:场景切换后纹理残留,内存占用居高不下

战斗结束后返回主菜单,内存只降了一点点,再打一局直接卡死。排查发现是用SpriteFrameCache缓存了植物大战僵尸的所有图集,但设计上这些图集在主菜单场景并不需要。Cocos2dx 3.16里SpriteFrameCache是全局单例,只往里面塞不清理,内存自然只增不减。解决方法是进入主菜单前遍历所有不需要的纹理,调用Director::getInstance()->getTextureCache()->removeTexture(texture),同时用SpriteFrameCache::getInstance()->removeSpriteFramesFromFile(plistName)移除对应的plist映射。别图省事直接removeAll,有些公共UI资源是全局复用的,全清空会导致下个场景闪一下黑块。

5.4 现象:jsb_cocos2dx_pluginx_auto.cpp修改后编译没反应

因为我需要给Lua脚本暴露一个自定义C++函数,按网上教程改了这个自动生成的绑定文件,重新编译后运行发现函数还是找不到。原因就出在“自动生成”四个字上——这个文件是工具生成的,你改了也会在下次执行绑定生成脚本时被覆盖。正确做法是不要动_auto.cpp文件,而是在manual目录新建一个手写绑定文件,注册到jsb_register宏里。这个问题排查了我一整天,最后才发现改错了文件。从那以后我每次看源码包里带“auto”字样的文件,都先去根目录查有没有对应的生成脚本,避免白改。

6. 二次开发:把这套PVZ框架改造成通用塔防模板

这个工程最值得动手做的不是原样跑起来,而是把它抽成一套可以复用的塔防玩法模板。最后一章讲讲我改框架时会动的几个地方和验证手段。

6.1 用调试宏快速定位是逻辑还是渲染的问题

Cocos2dx 3.16的log()输出在Android上用logcat查看很方便,但Release包默认不打印。我会在工程里统一启用一个DEBUG_GAMEPLAY宏,把场景切换、植物创建、僵尸状态跳转这些关键路径全部打点:

#ifdef DEBUG_GAMEPLAY log("Zombie %d transition to %d", zombieId, (int)currentState); #endif

这样排查问题是先看日志时序对不对,而不是一头扎进渲染层。日志级别要控制住,别每帧刷屏,否则性能会被拖垮。

6.2 把植物大战僵尸改成通用塔防框架的三步重构

第一步:把PlantBase改名为DefenderBase,去掉向日葵的阳光产品逻辑,改成纯战斗单位。第二步:把GridCollisionSystem的占用数组变成可动态调整尺寸,支持蛇形路径而不是单纯直线。第三步:把存档模块的GameState结构体里的植物列表换成游戏通用实体列表,这样怪物产出、资源掉落都可以统一写入存档。

做完这三步后,验证方式是新建一个“机枪塔打野猪”的Demo场景,沿用原来的碰撞和AI系统,半天时间跑通,就说明这套框架抽取成功了。改造最大的收获不是代码复用了多少,而是搞清楚了哪些模块是Cocos2dx帮你扛的,哪些是必须自己写的——3.16的Action系统能做位移和动画,但真正的游戏规则从来都要自己写。希望这份拆解能帮到你,至少在编译通过后少走几步回头路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询