简介一个基于cocos2d-x 2.2.6编写的Candy Crush三消游戏完整示例工程适合正在学习跨平台2D游戏开发、或准备用cocos2d-x实现消除类玩法的开发者参考。压缩包共43个文件以cpp/h源码、png图片、plist配置、ttf字体和dll运行库为主另有Visual Studio工程文件与可直接运行的exe程序整体仅3.46MB轻量且结构清晰。目前已有207人学习下载。工程在Classes目录中完整实现了棋盘布局、糖果精灵初始化、相邻交换与碰撞检测、三消匹配算法、消除动画、棋盘状态更新以及计分和关卡状态管理其中匹配判定利用Rect与Point进行坐标计算动画则通过Action类组合实现缩放、淡出等效果并对2.2.6到3.0版本的API变化给出了适配建议。通过阅读源码可以快速理解cocos2d-x的Action动画、Scene/Layer组织、触摸事件和TileMap运用也可在此基础上继续扩展特殊糖果、道具系统乃至网络对战功能。1. cocos2d-x 写的 candy crush三消游戏的技术选型与实现边界想用 cocos2d-x 复刻一版 Candy Crush 的人十有八九是接到休闲游戏外包、老项目翻新或是做技术储备。Cocos2d-x 的 C 版本虽然更新节奏放缓但它对精灵动画、粒子特效和内存管理的掌控度依然扎实做三消这种棋盘密集型玩法比用 Unity 更轻、比用网页技术栈更稳。整条开发路线的核心工作可以拆成三块棋盘数据的匹配判定、动画时序控制、以及触控交互的手感调优。只要把这三块理顺一个能稳定跑起来的三消 Demo 就没有太多玄学了。适合谁一是在 Cocos2d-x 里做过小游戏但没碰过消除类玩法的客户端开发者二是要在短时间内产出可玩原型的独立团队。前提是你接受 C 编译环境的折腾、Android 工程联调的麻烦以及 Cocos2d-x 官方文档相对精简的现实。接下来这套方案我按自己做过的一版 8x8 棋盘三消来展开每个环节都给代码和参数踩过的坑一并列出来。2. 三消核心玩法的架构设计从棋盘数据到匹配判定2.1 棋盘数据结构二维数组之外的工程考量Candy Crush 类游戏的棋盘本质是一张 8x8 或 9x9 的网格每个格子存一种糖果类型。很多新手第一反应是int board[8][8]这没毛病但实际工程里我强烈建议用一维数组加下标换算。原因很朴素三消的下落、填充、匹配全是连续内存访问一维数组对 cache 更友好而且在做序列化存档、断点续玩时可以直接memcpy省去一层循环拷贝。// GameBoard.h class GameBoard { public: static const int ROWS 8; static const int COLS 8; // 0 表示空格1~6 表示六种糖果类型 int cells[ROWS * COLS]; int getAt(int row, int col) const { return cells[row * COLS col]; } void setAt(int row, int col, int type) { cells[row * COLS col] type; } };这里的行优先存储方式决定了后续所有算法里遍历的方向同一行从左到右扫描时下标加 1 就是右邻格列优先的下落模拟则需要对同一列的连续行做操作用一维数组写下来比二维数组更不容易越界因为你在写row * COLS col的时候会下意识检查行和列的范围。初始化棋盘时有一个硬性约束不能预先产生三消。做法是逐格随机生成糖果类型每次生成后跟前两个左邻和上邻比较如果三者同色就重新随机。这个约束必须在初始化阶段做掉否则玩家开场就看到棋盘自己消了一串体验直接崩掉。2.2 消除匹配算法三连通检测与递归边界匹配检测的朴素思路是对每个格子向右和向下做探测看是否存在连续三个同色。写成代码不难但真正的工程难点在于“检测到一次消除后棋盘可能引发连锁消除”的递归过程。如果处理不好递归深度和调用次数会把帧率拖垮。所以要先把单次匹配检测写得足够轻。// MatchFinder.cpp struct Match { int startRow, startCol; // 起始格 int endRow, endCol; // 结束格 int length; // 连续长度 }; bool findMatches(GameBoard board, std::vectorMatch outMatches) { outMatches.clear(); bool found false; // 横向扫描逐行检测连续同色段 for (int r 0; r GameBoard::ROWS; r) { int runStart 0; for (int c 1; c GameBoard::COLS; c) { // 当前格与段首同色则继续延伸 if (c GameBoard::COLS board.getAt(r, c) board.getAt(r, runStart)) { continue; } // 段结束检查长度 int len c - runStart; if (len 3) { outMatches.push_back({r, runStart, r, c - 1, len}); found true; } runStart c; // 新段起点 } } // 纵向扫描逻辑对称按列遍历段首是行号 for (int c 0; c GameBoard::COLS; c) { int runStart 0; for (int r 1; r GameBoard::ROWS; r) { if (r GameBoard::ROWS board.getAt(r, c) board.getAt(runStart, c)) { continue; } int len r - runStart; if (len 3) { outMatches.push_back({runStart, c, r - 1, c, len}); found true; } runStart r; } } return found; }这个实现用的是“连续段扫描”不是对每格做双向探测时间复杂度从 O(n³) 降到 O(n²)在 8x8 棋盘上单次调用看不出差别但配合下落后的连锁判定调用次数可能是十几次甚至几十次累计下来差距明显。匹配结果统一放进Match结构体包含起始和结束坐标后续动画系统只需消费这个列表就知道哪些格子要播放消除动画。有一点你得留意一个格子同时命中横向和纵向两段匹配时它会出现在两个Match里动画系统处理时要按格子去重不能对同一个精灵同时播放两次消除动作否则会闪一下或直接崩。2.3 下落与填充状态机拆解三步走消除动画播完后棋盘上出现空洞上方的糖果需要下落填补然后顶部再生成新糖果。这一步最常见的翻车点是“下落”和“新生成”两个逻辑混在一起数据层和表现层不同步。我一般把下落拆成三个阶段标记空白格、做重力计算、逐帧驱动精灵位移。数据层先行表现层跟随两者用状态机串起来。// FallScheduler.cpp // 每列从下往上扫描将非空格子下移顶部填新糖 void applyGravity(GameBoard board) { for (int c 0; c GameBoard::COLS; c) { int writePos GameBoard::ROWS - 1; // 从底部开始写 for (int r GameBoard::ROWS - 1; r 0; --r) { if (board.getAt(r, c) ! 0) { if (writePos ! r) { // 非空格下移到 writePos board.setAt(writePos, c, board.getAt(r, c)); board.setAt(r, c, 0); } writePos--; } } // writePos 及以上的位置都是空格补新糖 for (int r writePos; r 0; --r) { board.setAt(r, c, randomCandyType()); } } }这段代码的关键是writePos指针它记录当前列从底部往上第一个空位。每处理一个非空格就把数据下移最后从writePos到顶部全部填随机糖。要注意新生成糖果时同样要做“不能直接形成三消”的约束做法可以生成后统一调用一次findMatches做校正也可以在下落结束后再处理。实际开发中我更推荐后者因为生成时逐格校验的代码很啰嗦而统一校验后只把新糖类型替换掉逻辑更干净。填充阶段还要注意如果玩家处于动画播放中数据层不能先进入可操作状态否则玩家再滑一下棋盘数据已经乱掉。3. 用 Cocos2d-x 搭建消除动画从池化精灵到 Action 调度3.1 精灵对象池减少创建销毁开销的实践Cocos2d-x 里创建Sprite本身不慢慢的是纹理加载和节点加入渲染树的时机。三消棋盘上同时存在 64 个格子格若每次消除都重新创建一批精灵再销毁旧的低端安卓机上会出现肉眼可见的掉帧。对象池在这里的收益最大。我一般用一个专门的类管住借出和回收而不是让游戏层直接new Sprite。// CandySpritePool.h class CandySpritePool { public: Sprite* borrow(int candyType) { Sprite* s nullptr; if (_freeList.empty()) { // 池空了才真正创建 s Sprite::createWithSpriteFrameName( StringUtils::format(candy_%d.png, candyType)); } else { s _freeList.back(); _freeList.popBack(); // 复用时要重新设置纹理帧 s-setSpriteFrame( SpriteFrameCache::getInstance()-getSpriteFrameByName( StringUtils::format(candy_%d.png, candyType))); } s-setVisible(true); s-setScale(1.0f); s-setOpacity(255); return s; } void recycle(Sprite* s) { s-stopAllActions(); s-removeFromParent(); s-setVisible(false); _freeList.pushBack(s); } private: VectorSprite* _freeList; // _activeList 可以做泄漏检查略 };注意borrow里若从池中复用精灵必须重置它的scale、opacity、rotation和visible属性否则上一次的消除动画残留会把视觉搞坏。比如上一个糖果以 0.3 的缩放比例被回收再借出时还是 0.3就像原本好好的糖果突然缩小了。对象池的上限设在ROWS * COLS * 2左右就够了下落过程中棋盘最多同时存在接近两倍数量的精灵超过了就说明哪里在泄漏直接打日志检查。3.2 交换与消除动画Action 与 CallFunc 的时序控制三消的动画链是一条流水线交换 → 判定 → 消除 → 下落 → 新糖填充 → 再次判定。这条链必须在逻辑层的每一步之间插入动画回调Cocos2d-x 的CallFunc在这里是主力工具但用不好就出事。先看交换动画的标准写法// 交换两颗糖果的动画 回调 void GameLayer::animateSwap(Sprite* spriteA, Sprite* spriteB, const Vec2 posA, const Vec2 posB) { auto moveA MoveTo::create(0.15f, posB); auto moveB MoveTo::create(0.15f, posA); auto seqA Sequence::create( moveA, CallFunc::create([this]() { this-onSwapFinished(); // 动画结束通知逻辑层判定 }), nullptr); spriteA-runAction(seqA); spriteB-runAction(moveB); // 防连点进入交换状态后屏蔽触摸 _inputLocked true; }这里有个关键点回调只挂在spriteA的Sequence上不要两个精灵都挂否则onSwapFinished会被触发两次。更稳妥的做法是只监听一方的动作完成或者干脆用一个DelayTimeCallFunc组合因为MoveTo在极端卡顿下结束时间会有偏差而延迟回调更容易控制。_inputLocked这个锁非常关键它保证交换动画播放期间玩家不能发起第二次滑动否则会两个操作交叉执行棋盘数据直接乱掉。我在初版就没加这个锁测试时疯狂连点一分钟后棋盘上的糖果全错位了。3.3 消除与下落动画让数据层等待表现层消除动画我惯用ScaleToFadeOut组合配合Spawn并行执行而不是串行这样视觉上更利落。下落动画则麻烦一些每个精灵的位移距离不同持续时间也不同不能用一个统一的缓动参数。// 消除动画缩放 淡出并行 void GameLayer::playEliminateAnim(Sprite* s) { auto scaleDown ScaleTo::create(0.12f, 0.2f); auto fadeOut FadeTo::create(0.12f, 0); auto spawn Spawn::create(scaleDown, fadeOut, nullptr); auto seq Sequence::create( spawn, CallFunc::create([this, s]() { this-onEliminateAnimFinished(s); }), nullptr); s-runAction(seq); }下落动画的持续时间我一般按下落距离计算0.1 0.02 * 下落格数秒这样粒子的视觉速度一致。如果用统一的 0.2 秒从顶部掉到底部的糖果和只掉一格的糖果看起来速度完全不同特别别扭。3.4 音效与粒子特效的触发时机音效最好挂在动画播放的同时而不是之后消除判定一完成就播放音量渐弱即可。粒子的触发放在onEliminateAnimFinished之前也就是动画刚开始时这样粒子散落和糖果消失的视觉更匹配。粒子数量必须控制一个消除点 20~30 个粒子就够多了低端机直接卡。4. 触控交互与手势判定滑动操作的响应与容错4.1 触摸事件监听与坐标转换Cocos2d-x 的触摸事件分发有EventListenerTouchOneByOne和EventListenerTouchAllAtOnce两种三消游戏用单点触摸就够了。监听器挂到棋盘所在的Layer上回调里拿到的坐标是 UI 坐标需要转换成棋盘的行列下标。// GameLayer.cpp void GameLayer::setupTouchListener() { auto listener EventListenerTouchOneByOne::create(); listener-onTouchBegan [this](Touch* t, Event*) { if (_inputLocked) return false; // 动画期间不响应 Vec2 pos t-getLocation(); Vec2 boardPos this-convertToNodeSpace(pos); int row, col; if (convertBoardCoord(boardPos, row, col)) { _touchStartRow row; _touchStartCol col; return true; } return false; }; // onTouchMoved / onTouchEnded 略 Director::getInstance()-getEventDispatcher() -addEventListenerWithSceneGraphPriority(listener, this); }坐标转换的细节比想象中坑多。如果你的棋盘不在原点、做了缩放适配convertToNodeSpace之后还要减去棋盘偏移再除以格子大小最后取整得到下标。这里最容易出问题的点是忽略了锚点。Sprite默认锚点是 (0.5, 0.5)而棋盘格子的坐标计算通常从左上角开始。若不一致点击位置会偏移半个格子测试时尤其明显——你想点第一个格子的糖果实际响应的是它右边的格子。4.2 滑动方向判定阈值与死区设计手势判定是整个手感的核心。Cocos2d-x 的onTouchMoved会高频触发你不能每帧都去判断用户的意图而是要累积位移量超过阈值才触发一次方向判定。这个阈值我调到 12 像素比较合适太小人容易误滑太大反应迟钝。// 在手势结束时判定方向 void GameLayer::onTouchEnded(Touch* t, Event*) { Vec2 delta t-getLocation() - _touchStartPos; // 小于阈值视为点击不做滑动处理 if (delta.length() 12.0f) return; // 比较横向和纵向位移决定滑动方向 int row _touchStartRow; int col _touchStartCol; if (std::abs(delta.x) std::abs(delta.y)) { col (delta.x 0) ? 1 : -1; // 横向 } else { row (delta.y 0) ? -1 : 1; // 纵向y 轴向上为负 } if (row 0 row GameBoard::ROWS col 0 col GameBoard::COLS) { requestSwap(row, col, _touchStartRow, _touchStartCol); } }这里注意 Cocos2d-x 的 y 轴方向是原点在左下角、向上为正棋盘的行号从上往下递增所以纵向滑动时向上滑delta.y 0对应行号减 1。很多从 Unity 转过来的人在这里会搞反搓半天发现糖果朝反方向动。死区设计上不光是滑动距离阈值还要在触摸开始时记录位置结束时的 delta 用结束点减起始点而不是用最后一段Moved的增量。4.3 非法操作回退动画中断与状态锁玩家滑动后如果交换的两个糖果没法形成三消需要把两个糖果滑回原位这是手感好坏的分水岭。我的方案是在请求交换时先做数据层预判能消才播放交换动画不能消则播放“交换 回退”的往返动画。// 请求交换先模拟交换并检测 void GameLayer::requestSwap(int r1, int c1, int r2, int c2) { GameBoard board _gameBoard; // 数据层模拟交换 board.swap(r1, c1, r2, c2); std::vectorMatch matches; if (findMatches(board, matches)) { // 合法交换播放动画后进入消除流程 _inputLocked true; animateSwapAndEliminate(r1, c1, r2, c2, matches); } else { // 非法交换交换后马上回退 board.swap(r1, c1, r2, c2); // 先还原数据 animateSwapBack(r1, c1, r2, c2); } }_inputLocked状态锁是整个交互系统的核心它必须覆盖三种情况交换动画播放中、消除动画播放中、下落动画播放中。每次动画结束的回调里必须显式地解除锁并在解除前检查一次是否又有新匹配。这块逻辑我没少踩坑最初把解锁写在onSwapFinished里结果消除动画还没播完玩家就能操作棋盘上飞着一堆半透明的糖果相当鬼畜。5. Candy Crush 开发避坑5 个真实翻车现场与排查记录5.1 内存暴涨纹理缓存反复加载同一张图现象游戏运行 5 分钟后内存占用从 80MB 涨到 300 多 MB低端机直接闪退。原因用Sprite::create(candy_1.png)反复创建精灵没有用SpriteFrameCache统一管理纹理。每个Sprite创建时若纹理不在缓存中就会重新加载一次旧纹理没有被释放堆积成山。解决在游戏启动时把全部糖果纹理塞进SpriteFrameCache后续创建精灵只用帧名。排查时在AppDelegate里打开内存日志每 30 秒打印一次CCTextureCache::getInstance()-getAllTextures()的大小能直观看到纹理重复加载的次数。5.2 动画时序错乱Action 回调里销毁节点现象消除动画结束的瞬间游戏偶发崩溃崩溃栈指向Node::cleanup。原因在CallFunc回调里直接对Sprite执行了removeFromParent()而该Sprite还在其他 Action 的执行队列里没有完全释放导致 ActionManager 访问了已被清理的节点。解决回调里只回收对象不销毁。把精灵交给对象池的recycle方法由它负责stopAllActions和removeFromParent。对象池回收时先停掉所有动作再移除节点保证 ActionManager 不会再碰这块内存。5.3 连锁消除的递归死循环现象某些局面下消除、下落、再消除、再下落游戏一直不停卡在动画循环里棋盘变成永动机。原因下落填充的新糖果再次触发匹配递归逻辑里缺少终止条件。我的版本里下落结束后无条件再次调用findMatches并继续消除但如果findMatches返回值一直非空循环就停不下来。解决加层层数限制最大连锁 5 层超过就强制停止并给一个合理的得分倍率封顶。同时在每一层连锁开始时判断棋盘的稳定状态如果两次连续消除后棋面完全一样说明进入了死循环直接把新糖类型替换掉打破循环。5.4 不同分辨率下的点击坐标偏移现象在 PC 模拟器上点击正常换成 1920x1080 的安卓手机点击糖果总是错位一格或两格。原因游戏设计分辨率是 960x640用固定宽高比适配后实际渲染区域和 UI 触摸区域的坐标映射没有做对应缩放。convertToNodeSpace在Layer被缩放后返回的坐标逻辑正确但若棋盘节点加了setScale坐标计算没乘回缩放因子就会偏移。解决统一用设计分辨率下的坐标计算行列把convertToNodeSpace的返回结果再除以棋盘节点的缩放比例。判断时不要用屏幕像素坐标直接做除法所有触摸坐标先转成设计分辨率坐标再做棋盘转换。5.5 消除音效爆音延迟播放与内存叠加现象快速连续消除时音效重叠播放导致爆音严重时出现杂音持续 2 秒。原因每次消除都用AudioEngine的play2d创建新音效没有做合并和去重。连锁消除在 0.5 秒内触发 5 次音效5 个重叠的音轨一起播放。解决用AudioEngine::play2d后拿到的 audioId 做一个简短的最近播放列表如果两次播放间隔小于 80 毫秒第二次直接复用同一个音频实例只重新设置播放位置。这种方法能保留跳跃感又不会爆音。6. 把体验做到位连锁消除的连击判定与加分反馈连锁消除是三消游戏最有魔性的地方——玩家滑一次屏幕上噼里啪啦连消五六次心流全靠这个。做连锁反馈时不能简单在每次消除后加固定分数而是要用倍率放大让玩家明确感知到“这一手很赚”。我的做法是用一个连击计数器每触发一次消除就加 1分数按层数 * 层数 * 单格 10 分计算第 5 层时单次消除得分是第 1 层的 25 倍视觉上用屏幕中央一个浮动数字放大显示。验证方法也很重要。跑通玩法后我习惯在findMatches里加一个调试日志每层连锁结束时打印一层简短历史类似chain3, matches4, score240。这个日志对数值调参、查死循环、复现玩家反馈的 Bug 都特别有用线上版本用宏开关关掉即可。还要养成一个习惯每次改完棋盘大小或糖果种类数手动跑一遍“无限随机滑动”的自动化测试让一个脚本在棋盘上随机乱滑 2000 次跑完检查有没有死循环、崩溃和分数溢出。这套压力测试帮我在上线前拦住了两个隐藏很深的边界死循环。希望这篇基于 Cocos2d-x 的三消落地笔记能帮到你棋盘三消这条路不难但把匹配、动画和手感的细节抠到位做出来的成品才留得住玩家。本文还有配套的精品资源点击获取