简介:一套基于C++与Cocos2d-X V3.16引擎开发的植物大战僵尸完整游戏源码,面向有一定C++基础、希望深入2D游戏开发流程的程序员。项目实现了经典塔防玩法,涵盖植物种植、僵尸进攻、碰撞检测、路径规划、AI逻辑及音效播放等模块,代码结构清晰,可帮助读者理解精灵与节点管理、事件系统、资源加载、状态序列化等核心机制。Cocos2d-X V3.16的渲染、内存与音频优化在本项目中均有体现,通过C++面向对象设计实现了游戏对象管理、关卡进度保存与碰撞交互逻辑。从资源加载到事件响应,从AI调度到声音控制,覆盖了一款完整2D游戏的常见技术链路。包内共2000个文件,以h头文件、cpp源文件、xml配置为主,另含Java、Objective-C、Python、Shell等脚本及说明文档,压缩包约162.17MB,目录组织完整,便于按功能模块查阅。已有214人学习下载,适合作为游戏编程练习、课程设计或引擎入门参考。
1. 使用C++基于Cocos2dx V3.16开发的植物大战僵尸游戏,难的不是植物而是僵尸
如果一个项目只是把豌豆射手放在草坪上,那它撑死算个 UI Demo。真正让植物大战僵尸成为经典的不是植物阵容,而是那个永远在逼近的僵尸群体——它逼着你把对象管理、碰撞检测、帧循环任务调度全部武装到位。使用C++基于Cocos2dx V3.16开发的植物大战僵尸游戏,本质不是"用引擎摆精灵",而是"在引擎的节点树之上做一套实时战斗系统"。
Cocos2dx V3.16是 3.x 时代一个非常稳定的版本,API 风格统一,事件分发器、调度器、内存自动管理都趋于成熟。它适合这类塔防游戏的核心原因有三个:节点树天然适合表达"草坪-格子-植物-僵尸"的层级关系;内置的 Schedule 和事件监听能支撑高频逻辑;C++ 的直接内存控制让对象池和批渲染不至于被 GC 拖后腿。这篇文章不打算教你怎么把素材拖进编辑器,而是沿着一条可复现的路径,讲清楚对象管理、帧循环、碰撞检测和内存掉坑这四件事。
2. 引擎选型与Cocos2dx V3.16的场景搭建
2.1 为什么是V3.16而不是2.x或4.x
Cocos2dx 3.x 相对 2.x 最大的变化是去掉了 retain/release 手动引用计数,改用Ref配合自动释放池,C++ 侧写起来舒服得多。V3.16 处于 3.x 的中后段,比 3.0 少了大量早期 bug,又比 4.0 少了 Lua 绑定和渲染后端的强制切换,对纯 C++ 开发者是最平滑的版本。4.x 虽然渲染更强,但已经转向 Vulkan/Metal 的跨平台抽象,很多塔防这种 2D 密集型项目用不上。
3.16 的关键组件是Scene、Layer、Sprite、Director、EventDispatcher和Scheduler。我们不引入额外的游戏框架,直接基于引擎原生的这些类做开发,这能保证后续排错时你会的是引擎本身的机制,而不只是某个私人封装库的 API。记住一个原则:引擎不替你做游戏逻辑,只替你管渲染和输入。
2.2 屏幕适配与可视区域计算
植物大战僵尸的棋盘格是固定比例的,适配问题集中在不同分辨率下草坪不能拉伸变形。3.16 里Director::getInstance()->getOpenGLView()->setDesignResolutionSize()是唯一正确的入口。常见做法是选ResolutionPolicy::FIXED_WIDTH或FIXED_HEIGHT,具体看你是横屏还是竖屏。
auto director = Director::getInstance(); auto glview = director->getOpenGLView(); if (!glview) { glview = GLViewImpl::create("PlantsVsZombies"); director->setOpenGLView(glview); } // 设计分辨率 1280x720,横屏固定高度 glview->setDesignResolutionSize(1280, 720, ResolutionPolicy::FIXED_HEIGHT); Size visibleSize = director->getVisibleSize(); Vec2 origin = director->getVisibleOrigin();FIXED_HEIGHT的逻辑是:高度永远按 720 渲染,宽度自适应拉伸裁切。这样草坪的行距在任何屏幕上都是视觉一致的,不会出现手机上两行植物挤成一团的情况。visibleSize和visibleOrigin用来算棋盘起点,不要用getWinSize(),那个拿到的不是逻辑分辨率而是物理像素,在 Retina 屏幕上会算错格子坐标。
2.3 棋盘格的坐标换算
棋盘是 9 列 5 行,每格长宽按visibleSize均分。这里必须处理锚点问题:植物的图片锚点默认在中心,而格子的坐标通常取左上角更符合数格子习惯,所以换算时要偏移半个格子。
Vec2 GameBoard::gridToWorld(int col, int row) { float cellW = visibleSize.width / 9.0f; float cellH = visibleSize.height / 5.0f; float x = origin.x + col * cellW + cellW * 0.5f; float y = origin.y + (4 - row) * cellH + cellH * 0.5f; // row 0 在最下方 return Vec2(x, y); }注意row反转是为了符合玩家习惯——游戏里第 0 行显示在屏幕底部,而数组索引习惯从 0 开始往下加。这也是塔防游戏最经典的坐标 bug 来源:视觉行号和逻辑行号不一致。建议在调试阶段直接把gridToWorld(1, 1)的坐标用一个DrawNode画出来,不要靠肉眼对格子。
3. 面向对象的战场管理:用C++的Map管住每一棵植物和每一只僵尸
3.1 为什么不用Node数组
很多教程会把全部植物塞进一个Vector<Sprite*>,更新时遍历整个数组。这在原型阶段够用,但植物大战僵尸里植物和僵尸的行为频率完全不同:向日葵 10 秒产一次阳光,豌豆射手 1.4 秒射一颗子弹,僵尸每帧都要移动。用一个数组遍历所有对象,意味着每一帧你都浪费了几十次无意义的函数调用,而且删对象时erase会导致迭代器失效。
更关键的是定位问题。当玩家点击铲子删除某格植物时,你需要"根据格子坐标找到对应植物",用数组就得遍历整张地图 O(n)。我用std::unordered_map把"格子坐标"和"对象 ID"做映射,再维护一个std::unordered_map<unsigned int, GameObject*>存对象实体,查询和删除复杂度都是 O(1)。
class GameObject { public: unsigned int id; std::string typeName; int gridCol, gridRow; int hp; virtual void update(float dt) = 0; virtual ~GameObject() = default; }; class Plant : public GameObject { public: float fireCooldown; virtual void update(float dt) override { if (typeName == "Peashooter" && fireCooldown > 0) { fireCooldown -= dt; } } }; class GameBoard { private: std::unordered_map<unsigned int, GameObject*> objectMap; std::unordered_map<int, unsigned int> gridToId; // key = row * 9 + col unsigned int nextId = 1; public: unsigned int addObject(GameObject* obj, int col, int row) { obj->id = nextId++; obj->gridCol = col; obj->gridRow = row; objectMap[obj->id] = obj; gridToId[row * 9 + col] = obj->id; return obj->id; } GameObject* getByGrid(int col, int row) { auto it = gridToId.find(row * 9 + col); if (it != gridToId.end()) { return objectMap[it->second]; } return nullptr; } void removeObject(unsigned int id) { auto obj = objectMap[id]; if (obj) { gridToId.erase(obj->gridRow * 9 + obj->gridCol); objectMap.erase(id); delete obj; // 引擎节点另由 Node 树管理 } } };这段代码的逻辑核心是双索引:gridToId负责"坐标找对象",objectMap负责"id 找对象"。row * 9 + col是把二维坐标压缩成一维 key 的常用做法,省掉std::pair的哈希开销。addObject返回 id,后续子弹追踪目标、僵尸啃植物都直接用 id 操作,避免在回调里意外持有裸指针。
3.2 引擎节点与逻辑对象分离的必要性
这里有一个新手必踩的坑:把Sprite直接当作游戏对象,在 Sprite 上挂自定义属性。确实能跑,但只要界面一刷新,比如切换场景、或者植物被铲除时播放了一个动画,Sprite 被引擎释放,你的逻辑层就拿到野指针了。
我的做法是GameObject是纯逻辑类,不继承Node,但内部持有一个Node* view用来渲染。更新时先更新逻辑,再同步view->setPosition()。
void Plant::syncView() { Vec2 pos = board->gridToWorld(gridCol, gridRow); view->setPosition(pos); }这样做的好处是逻辑层可以脱离渲染层做单元测试。你可以写一个纯 C++ 的测试用例,new一个GameBoard,添加植物,模拟 1000 帧更新,不启动引擎也能验证"豌豆射手第 10 帧是否生成了一颗子弹"。这在后期排查僵尸寻路、子弹命中逻辑时效率极高——因为你不必每次改代码都等引擎起场景。
3.3 对象池:用C++模板模板解决子弹高频创建
豌豆射手每 1.4 秒发射一颗豌豆,满编 9 列射手时每秒创建约 6 颗,每颗存活时间不超过 3 秒。如果每个豌豆都new一个对象再delete,会频繁触发内存分配,在低端安卓上表现为掉帧。标准解法是对象池。
template<typename T> class ObjectPool { private: std::vector<T*> pool; std::vector<bool> inUse; public: explicit ObjectPool(int initialSize = 32) { pool.reserve(initialSize); inUse.reserve(initialSize); for (int i = 0; i < initialSize; i++) { pool.push_back(new T()); inUse.push_back(false); } } T* acquire() { for (size_t i = 0; i < pool.size(); i++) { if (!inUse[i]) { inUse[i] = true; return pool[i]; } } // 池不够用,扩容 pool.push_back(new T()); inUse.push_back(true); return pool.back(); } void release(T* obj) { for (size_t i = 0; i < pool.size(); i++) { if (pool[i] == obj) { inUse[i] = false; break; } } } ~ObjectPool() { for (auto* p : pool) delete p; } };这个池子的缺点是release是 O(n),但对于子弹这种生命周期明确的对象完全够用。注意两个关键点:第一,对象池不自动调用构造函数,T必须有一个reset()方法,acquire后手动调用;第二,对象池只适用于大小稳定、创建销毁频繁的对象。僵尸这种 5 分钟才出 20 只的,直接用new管理就好,别过度设计。
| 容器 | 查询复杂度 | 删除复杂度 | 适合场景 |
|---|---|---|---|
std::vector<GameObject*> | O(n) | O(n) | 子弹遍历、碰撞检测候选集 |
std::unordered_map<id, GameObject*> | O(1) | O(1) | 全局对象管理、按 id 查对象 |
ObjectPool<Bullet> | O(1) 获取 | O(n) 释放 | 高频创建销毁对象 |
std::set<GameObject*> | O(logn) | O(logn) | 按优先级排序的僵尸队列 |
实际项目中这四个容器会同时存在:unordered_map管所有对象,vector存待碰撞检测的子弹,对象池分配子弹实例,set按血量排序僵尸以便快速找最脆的目标。
4. 帧循环与碰撞检测:schedule、update和命中判定
4.1 调度器的三种用法
Cocos2dx 3.16 的Scheduler是驱动整个游戏逻辑的心脏。所有 GameObject 的行为更新都必须挂到一个调度入口上。你有三个选择:Node::scheduleUpdate()每帧调用update(float)、Node::schedule(selector, interval)定时间隔调用、Director::getInstance()->getScheduler()->schedule()全局调度。
我一般不用 Node 自带的scheduleUpdate,因为 C++ 的Ref自动释放机制会导致回调持有Node*时引用计数管理复杂。更可控的做法是在GameBoard里自己维护一个更新队列,由场景的update统一驱动。
void GameScene::update(float dt) { // dt 是上一帧到这一帧的秒数,通常约 1/60 board->updateAll(dt); collisionSystem->checkCollisions(); bulletSystem->updateBullets(dt); } void GameBoard::updateAll(float dt) { for (auto& [id, obj] : objectMap) { obj->update(dt); } }这里的关键是dt不能累计。如果你的游戏运行在 30fps 和 60fps 的设备上,向日葵的产阳光间隔应该是"现实时间 10 秒",而不是"30 帧"或"60 帧"。所有冷却、存活时间的计算统一用dt累加,任何用"帧数计数"的代码在掉帧设备上都会加速或减速。这是塔防游戏最容易出现的行为不平衡点。
4.2 使用C++实现AABB与圆的混合碰撞检测
植物大战僵尸的碰撞有两种形态:豌豆子弹对僵尸是"圆对矩形",僵尸啃植物是"矩形对矩形"。精确做法是用Rect的intersectsRect配合圆心的getDistance做二次判定,避免子弹擦着僵尸边缘飞过去但没命中的视觉错误。
bool CollisionSystem::isHit(Bullet* bullet, Zombie* zombie) { // 第一层:AABB 粗检测,矩形相交才继续 Rect bulletRect(bullet->pos.x - bullet->radius, bullet->pos.y - bullet->radius, bullet->radius * 2, bullet->radius * 2); Rect zombieRect(zombie->pos.x - zombie->width / 2, zombie->pos.y - zombie->height / 2, zombie->width, zombie->height); if (!bulletRect.intersectsRect(zombieRect)) { return false; } // 第二层:圆与矩形的精确碰撞 auto [zLeft, zRight, zTop, zBottom] = getRectEdges(zombieRect); float closestX = std::max(zLeft, std::min(bullet->pos.x, zRight)); float closestY = std::max(zBottom, std::min(bullet->pos.y, zTop)); float distX = bullet->pos.x - closestX; float distY = bullet->pos.y - closestY; return (distX * distX + distY * distY) <= (bullet->radius * bullet->radius); }两层检测的意义在于性能。AABB 相交检测只做 4 次比较,能快速排除 90% 的无关对象;圆-矩形精确检测涉及平方根运算,虽然现代 CPU 很快,但每帧几百颗子弹同时检测时,省一次是一次。std::clamp是 C++17 的特性,如果编译器不支持 C++17,手写max(min())即可。这里没用sqrt求距离,而是比较平方值,是性能优化的习惯做法,因为平方比较省掉一次开方指令。
4.3 使用C++自定义事件解耦植物与僵尸
碰撞命中后,豌豆需要扣僵尸血,僵尸血量为 0 时要播放死亡动画并通知关卡管理器加分。如果直接用函数调用,Bullet类就要 includeZombie类,耦合越来越重。3.16 的EventDispatcher支持自定义事件,用事件总线解耦。
// 子弹命中时,派发一个自定义事件 EventCustom event("ZOMBIE_HIT"); event.setUserData(zombie); _eventDispatcher->dispatchEvent(&event); // 在关卡管理器中监听 _listener = EventListenerCustom::create("ZOMBIE_HIT", [=](EventCustom* e) { auto zombie = static_cast<Zombie*>(e->getUserData()); zombie->hp -= 20; if (zombie->hp <= 0) { this->score += 10; // 从 board 移除,播放死亡动画 } });这个模式的本质是把"发生了什么"和"怎么处理"分离。子弹只负责说"我打中了这个僵尸",至于扣多少血、加分多少、播放什么动画,全由监听方决定。以后你要加个"冰冻豌豆让僵尸减速"的效果,只需要再派发一个ZOMBIE_FROZEN事件,不用动任何子弹代码。
但注意:自定义事件对象EventCustom是栈上构造的,dispatchEvent是同步调用,事件处理完函数就返回了,所以不用担心生命周期。setUserData传的是裸指针,接受方不要delete它,否则会二次释放崩溃。
5. 使用C++内存管理与崩溃排错
5.1 Ref、autorelease 与智能指针的混用陷阱
Cocos2dx 3.16 的Node继承自Ref,引擎用引用计数管理。你new一个Sprite后如果没有addChild,它会进入autorelease池,在当前帧结束自动释放。这个机制是 C++ 侧最容易被坑的地方。
// 错误示范:在 update 里 new 一个没 addChild 的 Sprite auto sp = Sprite::create("pea.png"); sp->setPosition(pos); this->addChild(sp); // OK,父节点持有它 // 正确作法:交给逻辑对象持有并使用智能指针 std::unique_ptr<Plant> plant = std::make_unique<Plant>();我的原则是:渲染节点一律交给节点树管理,逻辑对象一律用智能指针或对象池管理,两者不混用。如果你在GameObject里存了一个Sprite*,且这个 Sprite 被addChild到场景中,那么引擎持有它,你的逻辑类不要delete它,否则引擎释放时会二次释放。
5.2 纹理缓存与图集合并
植物大战僵尸的素材多且碎:每种植物有 2-4 帧动画,加上僵尸的走路/啃食动画,一张张加载小图会拖慢启动速度并占用纹理内存。3.16 提供了SpriteFrameCache和TextureCache。
auto frameCache = SpriteFrameCache::getInstance(); frameCache->addSpriteFramesWithFile("plants.plist"); // 从图集创建精灵 auto peashooter = Sprite::createWithSpriteFrameName("peashooter_1.png");图集合并的工作在导出素材时做:把同类的 100 张 PNG 合成一张大图,配套一个 plist 记录每帧的矩形位置。引擎加载时只需读取一次纹理,渲染时通过 UV 坐标采样大图,渲染性能比 100 次小图加载高一个数量级。这也是 cocos 早就内置的TexturePacker工作流。
5.3 崩溃日志的三种定位法
C++ 崩溃不外乎三种:野指针、数组越界、重复释放。在 Cocos2dx 中最快的定位手段不是断点,而是加日志和做断言。
#ifdef COCOS2D_DEBUG assert(zombie != nullptr); assert(zombie->hp >= 0); CCLOG("Zombie %u hp = %d", zombie->id, zombie->hp); #endifassert只在 DEBUG 包生效,线上版本会被编译器忽略,但 DEBUG 阶段能帮你立刻定位到是哪个逻辑先产生了非法数据。CCLOG会输出到 Android 的 logcat 或 Windows 的控制台,注意在每一帧都输出的日志上打个计数器,避免日志刷屏把性能拖垮。第三种方法是给每个GameObject加一个debugTag,崩溃时打印当前对象的 id 和 typeName,很多时候崩溃原因就是某个野指针的 typeName 变成了乱码,一眼就能看出对象已被提前释放。
6. 进阶技巧:用C++写一个"阳光生产-收集"的最小闭环
前面五部分把基础框架搭完整了,最后一个实战技巧是打通"逻辑-渲染-输入"的数据流。以向日葵产阳光、玩家点击收集为例,串起前面所有机制。
用户在屏幕上点击时,EventDispatcher触发EventListenerTouchOneByOne回调:
auto touchListener = EventListenerTouchOneByOne::create(); touchListener->onTouchBegan = [this](Touch* touch, Event*) { Vec2 touchPos = touch->getLocation(); // 遍历阳光对象,检测点击是否在阳光半径内 for (auto* sun : this->sunPool->getActiveObjects()) { float dist = sun->getPosition().distance(touchPos); if (dist < sun->getCollectRadius()) { this->collectSun(sun->getValue()); sun->setCollected(); return true; } } return false; }; _eventDispatcher->addEventListenerWithSceneGraphPriority(touchListener, this);getLocation()返回的是 UI 坐标,如果你的设计分辨率和物理屏幕不一致,这里拿到的坐标已经经过引擎换算,可以直接和节点坐标比较,不要在回调里手动做比例换算,那是重复劳动且极易出错。
收集阳光后,你要做的是更新金币栏、播放音效、再从对象池里回收这个阳光对象。这里的顺序很重要:先逻辑后渲染,先数据后动画。如果先播了收集动画再回收到池子,动画回调里访问已重置的对象,崩溃概率极高。
一个可运行的完整闭环代码块如下:
void GameScene::collectSun(int value) { sunCount += value; updateSunLabel(); // 刷新 UI // 特殊音效或粒子效果,异步执行不阻塞逻辑 auto particle = ParticleSystemQuad::create("collect_sun.plist"); particle->setAutoRemoveOnFinish(true); particle->setPosition(this->lastTouchPos); this->addChild(particle); }粒子用setAutoRemoveOnFinish(true)让它在播放完自动移除,不需要手动管理生命周期,这是引擎少数"自动驾驶"也是可靠的功能。最后提醒一个实战小技巧:给GameScene::update(float dt)里加一个累计帧耗时统计,用clock()计算每帧逻辑耗时,超过 16 毫秒时用CCLOG打一条警告。塔防游戏后期僵尸数量暴增,性能瓶颈通常出现在碰撞检测的 O(n²) 遍历上,提前加好耗时统计,你才能在出现卡顿的第一时间知道是渲染瓶颈还是逻辑瓶颈。这个性能监控小工具值得写在任何一个项目的最早期,而不是等到优化阶段再补。
本文还有配套的精品资源,点击获取