
简介一份基于C与Cocos2d-x V3.16开发的策略塔防游戏完整工程复刻经典植物大战僵尸玩法面向希望系统学习Cocos2d-x框架和C游戏开发的初中级程序员。工程完整包含精灵动画、碰撞检测、路径规划、AI逻辑、交互事件、音频管理及游戏存档等核心模块可帮助读者从零理解二维游戏整体架构与实现思路。资源共两千个文件压缩包约一百六十二兆字节以C头文件与源文件为主辅以配置文件、跨平台适配文件以及构建脚本覆盖安卓与苹果移动平台适配。目前已有二百一十六人学习下载适合课程设计、毕业设计或个人练手。压缩包内含可直接运行的完整项目通过研读代码可掌握节点体系、内存管理、资源加载与状态持久化等实用技巧并为后续功能扩展和多平台移植提供基础。1. 用 C 在 Cocos2d-x V3.16 上复刻植物大战僵尸能跑通核心玩法就是一次合格的引擎实战Cocos2d-x V3.16 搭配 C 复刻一版植物大战僵尸是我给想从语法过渡到项目的同学最常开的“作业”。它不是一份流水账教程而是一条完整的工程链路场景怎么搭、实体怎么建模、调度器怎么驱动玩法、碰撞怎么做裁剪。做完这版你对 C 的面向对象和引擎的“渲染、调度、事件”会有整体手感。这个标题适合三类人学过 C 基础但没写过完整游戏的人想用 C 小游戏练手又不满足控制台输出的人想从 Cocos2d-x 源码层理解帧循环的客户端新人。下面按我做这类项目的路径来拆。2. 从 AppDelegate 到主场景先把 Cocos2d-x V3.16 的项目骨架搭对很多初学者拿到的 Cocos2d-x 3.16 模板跑起来后只在 HelloWorld 里改来改去最后发现游戏逻辑和引擎的启动流程纠缠在一起一动就出问题。其实只要把入口和 Scene 的创建关系理顺后面加玩法就是往里填东西的事。2.1 启动链与三个被忽略的初始化点Cocos2d-x V3.16 的项目入口是各平台工程里的 main 函数最终都会调到AppDelegate::applicationDidFinishLaunching()。这个函数里做的四件事顺序是固定的创建 OpenGL View、设置设计分辨率、设定帧间隔、创建并运行第一个 Scene。我在 VSCode 里配好 C/C 环境后第一次看这段代码最容易漏掉的是资源目录设置。bool AppDelegate::applicationDidFinishLaunching() { auto director Director::getInstance(); auto glview director-getOpenGLView(); if (!glview) { glview GLViewImpl::create(PlantsVsZombies); director-setOpenGLView(glview); } glview-setDesignResolutionSize(1280.0f, 720.0f, ResolutionPolicy::FIXED_HEIGHT); director-setDisplayStats(false); director-setAnimationInterval(1.0f / 60.0f); auto scene GameScene::createScene(); director-runWithScene(scene); return true; }这里三个点容易被忽略。第一setDesignResolutionSize的选择直接影响触摸坐标换算FIXED_HEIGHT会让固定高度为 720宽度随屏幕比例伸缩适合横屏塔防。第二setAnimationInterval决定主循环 tick 频率60 FPS 对应1/60秒不要写成整数 60。第三runWithScene只能调用一次后续切场景应该用Director::getInstance()-replaceScene(...)。2.2 用 GameScene 承载玩法Layer 分层与坐标约定植物大战僵尸的棋盘是 5 行 9 列。我的习惯是把棋盘本体当作“世界坐标锚点”所有玩法对象都以行列换算成像素坐标而不是把每个对象绝对摆在屏幕上。这样适配不同分辨率时只动棋盘原点就行。场景层结构我一般这样拆。bool GameScene::init() { if (!Layer::init()) { return false; } auto visible Director::getInstance()-getVisibleSize(); auto bg Sprite::create(images/bg.jpg); bg-setPosition(Vec2(visible.width / 2, visible.height / 2)); this-addChild(bg, -10); _gridLayer GridLayer::create(); this-addChild(_gridLayer, 0); _battleField BattleField::create(); // 内部再分植物层、僵尸层、子弹层 this-addChild(_battleField, 1); _uiLayer UILayer::create(); this-addChild(_uiLayer, 10); this-scheduleUpdate(); return true; }z-order 从 -10 到 10把背景、棋盘、玩法实体、UI 分开。这里有个经常翻车的点把 UI 层建得比玩法层低阳光计数就被植物遮住。我建议把它写成队内注释背景层 0玩法层 0~5UI 层 10。设计分辨率策略的选择也值得先定下来我按实际需求整理了一个小对照。策略适配方式适合场景FIXED_HEIGHT高度固定宽度按屏幕比例伸缩横屏塔防、两端留黑边FIXED_WIDTH宽度固定高度按屏幕比例伸缩竖屏关卡游戏EXACT_FIT宽高独立拉伸可能变形只用于 PC 窗口调试2.3 替换默认模板的最小改动清单把模板换成自己的玩法改动集中在三个文件AppDelegate.cpp窗口名与分辨率、GameScene.cpp层结构、还有一个存放棋盘原点和格子尺寸的常量头文件。// Constants.h #pragma once constexpr int kBoardRows 5; constexpr int kBoardCols 9; constexpr float kCellW 100.0f; constexpr float kCellH 100.0f; constexpr float kBoardX 240.0f; // 棋盘左下角 x constexpr float kBoardY 120.0f; // 棋盘左下角 y棋盘原点的选取我是按背景图里的草地位置来的。不同美术资源草地位置不一样所以把kBoardX / kBoardY单独抽出来调图时只改这里。kCellW / kCellH和设计分辨率 1280x720 是对应的5 行 9 列正好填满 900x500 的棋盘区域。跑通这一步后模板的 HelloWorld 场景已经可以完全丢掉剩下要做的是把“棋盘第 row 行 col 列的像素坐标”算出来。这个换算后续所有种植、碰撞都会用到最好写成公共函数。Vec2 gridToWorld(int row, int col) { return Vec2(kBoardX col * kCellW kCellW / 2, kBoardY row * kCellH kCellH / 2); }这个函数的参数说明值得记一句row从下往上数对应 Cocos 左下角为原点的坐标习惯col从左往右数。如果以后想从左上角开始数就改成kBoardY (kBoardRows - 1 - row) * kCellH。两种约定没有哪个更高级但整个项目必须只用一种否则后排植物的坐标会乱成一团。3. 植物和僵尸的实体设计类层次、动画帧与调度器实体设计是这个项目里最能体现 C 功力的部分。设计得好的话加一个新植物可能只需要改数据和一张表设计得差的话每加一个植物就要复制粘贴一坨逻辑。3.1 为什么不能直接继承 SpriteEntity 基类怎么拆新手最容易做的事是class Plant : public Sprite因为这样可以直接 addChild、直接设位置。但这样做没几天就卡住了植物有“啃咬中”“攻击中”“未成熟”等状态Sprite 对象表现得像状态机时你会把渲染、属性、行为全塞进一个类里。常见的可靠做法是先用一个不依赖渲染的实体基类再让节点持有显示对象。enum class Team { Plants, Zombies }; enum class EntityState { Idle, Walking, Eating, Attack, Dead }; class Entity : public Node { public: virtual bool init() override { if (!Node::init()) return false; _hp 100.0f; _team Team::Plants; _state EntityState::Idle; _row 0; _col 0; return true; } void setTeam(Team t) { _team t; } void setGrid(int row, int col) { _row row; _col col; } virtual void takeDamage(float dmg) { _hp - dmg; if (_hp 0.0f) { _state EntityState::Dead; onDead(); } } virtual void onDead() {} protected: float _hp; Team _team; EntityState _state; int _row; int _col; };这里我特意让Entity继承Node而不是Sprite。原因有两个Cocos2d-x 的 Node 本身就带位置、层级、调度能力这正好是实体需要的而 Sprite 是渲染节点应该作为_view被持有。takeDamage做成虚函数植物和僵尸各自的掉血表现僵尸缺胳膊、植物变枯萎由子类重写onDead去处理这是 C 面向对象里最值得练的一笔。3.2 植物 Plant种植、冷却、攻击节奏Plant 的设计可以做得非常简单攻击行为用调度器驱动。以普通豌豆射手为例。class Peashooter : public Entity { public: static Peashooter* create() { auto p new Peashooter(); if (p p-init()) { p-autorelease(); return p; } delete p; return nullptr; } bool init() override { if (!Entity::init()) return false; _hp 100.0f; _attackInterval 1.4f; _bulletSpeed 600.0f; _view Sprite::create(plants/peashooter.png); this-addChild(_view); schedule([this](float dt) { this-onUpdate(dt); }, 0.1f, plant_tick); return true; } void onUpdate(float dt) { _fireCooldown - dt; if (_fireCooldown 0.0f) { fire(); _fireCooldown _attackInterval; } } void fire() { if (_onFireCallback) { _onFireCallback(_row, getPosition()); } } std::functionvoid(int, Vec2) _onFireCallback; private: float _attackInterval; float _fireCooldown; float _bulletSpeed; Sprite* _view; };这里的调度器我用了带 key 的schedule重载可以在unschedule(plant_tick)时精确停掉某一个回调不会误伤其它定时任务。_onFireCallback是一个std::function这是 C 回调函数最常见的落地形式把射击事件抛给战场处理植物自己不需要知道子弹怎么生成。对植物来说0.1 秒 tick 一次比每帧都跑更省。实际项目中植物数量多起来后每个植物一个独立调度是不小的开销这个权衡到第 6 章讲对象池时再展开。3.3 僵尸 Zombie行走向量与状态切换僵尸的行为是一条直线从左往右走遇到植物后停下来啃啃死了继续走。这个逻辑对应的状态切换是Walking - Eating - Walking写起来特别容易把状态判断漏在动画回调里。class Zombie : public Entity { public: bool init() override { if (!Entity::init()) return false; setTeam(Team::Zombies); _hp 200.0f; _speed 20.0f; _state EntityState::Walking; _attack 30.0f; _attackInterval 0.8f; _view Sprite::create(zombies/zombie_walk_1.png); this-addChild(_view); playWalkAnimation(); scheduleUpdate(); return true; } void update(float dt) override { if (_state EntityState::Dead) return; bool hasPlant _battlefield-hasPlantAt(_row, getPositionX() 10.0f); if (hasPlant) { if (_state ! EntityState::Eating) { _state EntityState::Eating; playEatAnimation(); } _attackTimer - dt; if (_attackTimer 0.0f) { _attackTimer _attackInterval; _battlefield-damagePlantAt(_row, getPositionX(), _attack); } } else { if (_state ! EntityState::Walking) { _state EntityState::Walking; playWalkAnimation(); } setPositionX(getPositionX() - _speed * dt); } } void takeDamage(float dmg) override { Entity::takeDamage(dmg); if (_state ! EntityState::Dead) { playHitFlash(); } } };这段代码里的_battlefield是关键依赖。不要让僵尸自己遍历整个场景找植物而是由战场对象提供一个“这一行、这一段 x 范围内有没有植物”的查询。这样碰撞检测被限制在单行内这一行上植物数量少遍历代价几乎为零。这是做实时策略游戏时非常重要的边界控制思路比全局碰撞扫描可靠得多。3.4 动画帧管理AnimationCache 的用法与内存边界Cocos2d-x 3.16 里播放动画的常见做法是把帧SpriteFrame组装成Animation再包一层Animate动作去执行。如果每次创建实体都重新加载动画内存和加载时间都扛不住。所以动画要放进AnimationCache统一管理。// 在 GameScene 初始化时预加载 auto cache AnimationCache::getInstance(); auto walkAnim Animation::create(); walkAnim-setDelayPerUnit(0.12f); walkAnim-addSpriteFrame(SpriteFrame::create(zombies/walk_1.png, Rect::ZERO)); walkAnim-addSpriteFrame(SpriteFrame::create(zombies/walk_2.png, Rect::ZERO)); walkAnim-addSpriteFrame(SpriteFrame::create(zombies/walk_3.png, Rect::ZERO)); cache-addAnimation(walkAnim, zombie_walk); // 使用时 auto animate Animate::create(AnimationCache::getInstance()-getAnimation(zombie_walk)); zombie-getView()-runAction(RepeatForever::create(animate));需要留意的是帧之间要平滑setDelayPerUnit(0.12f)表示每帧停留 0.12 秒三帧循环一次大约 0.36 秒数值再大走路会像幻灯片。另外SpriteFrame::create传入的 Rect 是纹理里的裁剪范围如果资源是整图我这里用Rect::ZERO表示取整张图。实际项目里建议用SpriteFrameCache配合 plist 管理每一个命名的 frame这样改动作时只动 plist不碰 C 代码。4. 核心玩法循环阳光、种植、碰撞与波次刷怪骨架和实体类都齐了接下来是让游戏转起来的四个核心循环。这四个模块之间是有顺序的建议按“阳光 - 种植 - 碰撞 - 波次”推进每一个都是下一步的前置条件。4.1 阳光的生成、拾取与计数阳光是这个游戏的硬通货。常见做法是让阳光在场上随机时间、随机位置掉落同时向日葵每隔一段时间从自身位置生产阳光。我用一个SunManager统一管理而不是让每个产出方自己生成 Node。class SunManager : public Node { public: bool init() override { if (!Node::init()) return false; _sunCount 150; _dropTimer 0.0f; scheduleUpdate(); return true; } void update(float dt) override { _dropTimer - dt; if (_dropTimer 0.0f) { _dropTimer 7.0f random(-2.0f, 3.0f); spawnFallingSun(); } } void spawnFallingSun() { auto sun Sun::create(); float x random(kBoardX, kBoardX kBoardCols * kCellW); float y Director::getInstance()-getVisibleSize().height * random(0.8f, 1.1f); sun-setPosition(Vec2(x, y)); this-addChild(sun); auto fall MoveTo::create(2.0f, Vec2(x, kBoardY kCellH * 0.5f)); auto delay DelayTime::create(3.0f); auto fade FadeOut::create(1.0f); auto seq Sequence::create(fall, delay, fade, RemoveSelf::create(), nullptr); sun-runAction(seq); } void collectSun(int amount) { _sunCount amount; if (_onSunCountChanged) { _onSunCountChanged(_sunCount); } } };random函数是引擎自带的左闭右开注意别把范围写反。这里阳光下落路径用Sequence串起来整个生命周期是“落下 - 停留 - 淡出 - 移除”比用状态机手动 update 简洁。参数上2 秒落地、3 秒停留、1 秒淡出接近原版手感可以根据屏幕尺寸微调。4.2 格子种植触摸坐标到棋盘坐标的换算种植的第一步是解决“点到屏幕哪个格子”。这里最容易翻车的是直接拿触摸坐标除以格子尺寸而忽略了棋盘原点偏移和设计分辨率的缩放。Cocos 的setDesignResolutionSize会让实际视口和逻辑尺寸之间有放缩系数触摸回调里拿到的坐标已经是逻辑坐标所以不要在回调里再手动乘比例。auto listener EventListenerTouchOneByOne::create(); listener-setSwallowTouches(true); listener-onTouchBegan [this](Touch* touch, Event*) - bool { Vec2 pos touch-getLocation(); if (_uiLayer-hitTestPlantCard(pos)) { _selectedCard _uiLayer-getSelectedCardType(); return true; } if (_selectedCard ! PlantType::None) { int col (int)((pos.x - kBoardX) / kCellW); int row (int)((pos.y - kBoardY) / kCellH); if (row 0 row kBoardRows col 0 col kBoardCols) { tryPlant(row, col, _selectedCard); _selectedCard PlantType::None; } } return true; }; this-getEventDispatcher()-addEventListenerWithSceneGraphPriority(listener, this);临界点判断值得单独说如果触摸正好落在第 0 列左边的非棋盘区域(pos.x - kBoardX) / kCellW会得到负数向下取整后如果不去判断范围数组就越界了。我在这里先判断了row / col的合法区间再做种植这是用血的教训换来的习惯。setSwallowTouches(true)保证点中卡槽后不会继续穿透到棋盘否则会出现“选了一个向日葵又立刻种下去的误操作”。4.3 子弹碰撞与伤害结算豌豆子弹的移动是简单的直线运动难的是命中判定写在哪个环节。新手常把碰撞逻辑写成每颗子弹每帧遍历全部僵尸8 颗子弹乘 10 个僵尸就是 80 次矩形相交一旦加入群体伤害帧时间就起飞。我一般把战场按行拆开让子弹只查询自己所在行的僵尸列表。void BattleScene::onBulletHitCheck(float dt) { for (int row 0; row kBoardRows; row) { auto zombies _zombies[row]; for (auto bullet : _bullets[row]) { Rect bulletRect bullet-getBoundingBox(); for (auto zombie : zombies) { if (zombie-getState() EntityState::Dead) continue; Rect zombieRect zombie-getView()-getBoundingBox(); if (bulletRect.intersectsRect(zombieRect)) { zombie-takeDamage(bullet-getDamage()); bullet-setDead(true); break; } } } } }getBoundingBox()返回的是节点在世界坐标下的包围盒触摸回调返回的坐标和节点坐标在同一坐标系里所以可以直接相交。这里我做了个小优化每颗子弹命中一个僵尸后break避免一颗子弹穿透多个目标这符合原版豌豆的判定逻辑。后续如果要加穿透效果的寒冰射手或西瓜这一行的判断改成不 break 就行。4.4 关卡波次Scheduler 控制刷怪节奏波次刷怪最朴素的做法是每秒检查“当前时间到了第几波”但这会让逻辑散落在 update 里。我用的是调度器里的scheduleOnce加回调链。void WaveManager::startWavePlan() { scheduleOnce([this](float dt) { spawnZombies(1, 3); scheduleOnce([this](float dt) { spawnZombies(1, 5); scheduleOnce([this](float dt) { spawnZombies(2, 8); }, 15.0f, wave_3); }, 10.0f, wave_2); }, 5.0f, wave_1); }这个链式写法比维护一个时间表更易读每个scheduleOnce的第三个参数是 key方便在场景退出时统一unschedule(wave_1)。需要注意嵌套 lambda 的捕获列表[this]捕获的 this 在对象销毁后继续回调会造成悬挂所以WaveManager的生命周期必须跟随场景场景销毁前一定要调用unscheduleAllCallbacks()。刷怪数量spawnZombies(1, 3)的意思是第 1 行刷 3 只第二关我会把波次之间的间隔从 10 秒缩到 7 秒来增加压力这个数值建议做成配置而不是硬编码。5. Cocos2d-x V3.16 避坑五个绕不开的经典问题这个版本跑起来不算新但坑都是磨出来的。我把这些年反复踩到的、适合所有 Cocos2d-x 3.x 项目的五个问题写在这里每条按现象、原因、解决来记。5.1 Retina 屏触摸坐标偏移现象在 PC 上正常在 Android 高分辨率设备上点击位置总是比实际植物偏下或偏左几十像素。原因setDesignResolutionSize之后触摸坐标受到视口缩放和屏幕安全区的影响。部分设备返回的getLocation()用的是 GL 坐标而节点的坐标在缩放后的逻辑坐标系里原本一致的两个坐标系被 Retina 屏的分辨率给撬开了。解决触摸回调里统一用touch-getLocation()不要用getLocationInView()后者拿到的可能是像素坐标。然后在 AppDelegate 里确认设计分辨率模式我固定用FIXED_HEIGHT这样横向留白由引擎自己处理逻辑坐标和触摸坐标始终在同一套数值里。改完后写一个“点击棋盘边缘看坐标对应”的调试函数把位置打印出来对照棋盘范围比肉眼对位置靠谱。5.2 动画重复播放的内存上涨现象僵尸走两步之后内存涨了十几兆帧率掉到 30 以下。原因很多人在实体创建时直接向AnimationCache里 add 一个新的 Animation用完了不 remove缓存越积越多。另一个来源是RepeatForever的 action 没有在实体销毁时stopAllActions动画帧回调里的引用计数释放不掉。解决动画统一在场景初始化时注册实体只通过 key 取用。销毁实体时在onDead()里主动_view-stopAllActions()再removeFromParentAndCleanup(true)。我习惯在场景退出时打印一次AnimationCache::getInstance()-getAnimationCount()一旦异常就回去查谁在动态 add。5.3 调度器停止时机导致的回调崩溃现象退出战斗场景的瞬间控制台报错 “scheduler ... update schedule for registered function”。原因实体把scheduleUpdate挂在自己身上场景removeFromParent时实体也被清理但清理顺序不保证。如果实体的 update 回调里还引用了_battlefield而战场已经先一步被释放回调就崩溃了。解决在场景的onExit()里先停掉全局调度器再移除子节点。具体代码是this-unscheduleAllCallbacks()然后按依赖倒序 removeChild。更保险的做法是所有实体回调里先判断_battlefield ! nullptr这种防御性判断在底层引擎代码崩溃时能救你一次。5.4 AudioEngine 播放与场景切换的资源释放现象从战斗场景回到主菜单再进入战斗背景音乐重叠或直接没有声音。原因Cocos2d-x 3.16 的AudioEngine是独立模块不会随场景自动停止。战斗场景销毁时背景音乐还在原通道继续播新场景又play2d一次就会出现两个音频实例。解决在战斗场景onExit里调用AudioEngine::stopAll()并在进入场景时用AudioEngine::play2d(audio/bgm.mp3, true, volume)。我还会在场景之间用Director::getInstance()-getRunningScene()做判断避免在场景切换的同一帧里去启动音频导致线程安全警告。5.5 Android 返回键与场景切换的冲突现象在 Android 上按返回键游戏直接黑屏退出日志里是正在执行的场景切换动作被打断。原因返回键默认会结束 Activity而 Cocos 的 Android 壳在onKeyDown里把返回键分发给EventKeyboard如果主菜单场景没有监听最后会走到默认的退出逻辑。解决在主菜单和战斗场景都注册键盘监听。auto listener EventListenerKeyboard::create(); listener-onKeyReleased [this](EventKeyboard::KeyCode code, Event*) { if (code EventKeyboard::KeyCode::KEY_BACK) { Director::getInstance()-popScene(); } }; this-getEventDispatcher()-addEventListenerWithSceneGraphPriority(listener, this);这里我吃的亏是把KEY_BACK写成了KEY_ESCAPEPC 上没问题Android 上一按就没了。两个键在 3.16 里是不同枚举值要分开处理。6. 一个值得长期留住的技巧把对象池用在子弹和僵尸刷新上实战中最容易卡帧的不是场景切换而是同一帧里大量创建、销毁节点。豌豆射手五连发加两路僵尸每秒钟要创建 10 个左右的子弹节点销毁时又要清理 action 和渲染数据。我第一次做这个项目时用的是原生 new/delete 的直觉结果一上真机就翻车。后面用了对象池帧时间从 8ms 降到 3ms 左右。对象池的思路很简单创建过的节点不销毁而是隐藏起来放回池子下次需要时直接拿一个复用它。class BulletPool : public Node { public: static BulletPool* create(int prewarmSize 20) { auto pool new BulletPool(); if (pool pool-init(prewarmSize)) { pool-autorelease(); return pool; } delete pool; return nullptr; } bool init(int prewarmSize) { if (!Node::init()) return false; for (int i 0; i prewarmSize; i) { auto bullet Bullet::create(); bullet-setVisible(false); bullet-pause(); _idleList.push_back(bullet); this-addChild(bullet); } return true; } Bullet* get() { if (_idleList.empty()) { auto bullet Bullet::create(); bullet-setVisible(false); bullet-pause(); _idleList.push_back(bullet); this-addChild(bullet); } auto bullet _idleList.back(); _idleList.pop_back(); bullet-setVisible(true); bullet-resume(); return bullet; } void recycle(Bullet* bullet) { bullet-stopAllActions(); bullet-setVisible(false); bullet-pause(); _idleList.push_back(bullet); } private: std::vectorBullet* _idleList; };池化容器我用的是 C STL 里的std::vector避免频繁 new/delete。pause()和resume()是 Cocos 节点自带的暂停调度能力比手动标记“不可用”要可靠得多。子弹飞出屏幕或命中后不再removeFromParentAndCleanup而是调recycle回到池子。僵尸死亡同理只是预热的数量要大一些。用对象池的验证方式很简单开着Director::getInstance()-setDisplayStats(true)观察战斗激烈时的绘制调用次数和 FPS。如果绘制次数曲线不再随着僵尸数量猛增说明池化到位了。我早期做这个项目时舍不得用池子总觉得节点多几帧无所谓直到在低端 Android 机上被内存分配卡到 1 秒一帧才老老实实重建了这套机制。这个教训让我形成一个习惯凡是会高频创建和销毁的对象先预估峰值数量预热一半再池化。如果你的峰值是 30 个子弹那就预热 20 个剩下 10 个作为缓冲。这个技巧不只在 Cocos 里有效在 C 游戏服务端的对象复用里也是一样的道理。希望帮到你。本文还有配套的精品资源点击获取