cocos2d-x三消游戏源码解析:从工程结构到核心循环 简介这份资源是面向 cocos2d-x 初学者与三消游戏开发爱好者的实战项目包基于 cocos2d-x 2.2.6 实现了一个基础版 Candy Crush并针对 3.0 及以上版本的 API 变化给出适配思路。内容涵盖棋盘布局、相邻糖果交换、三连匹配消除、计分与关卡状态管理等核心机制同时涉及特殊糖果、道具系统、网络同步与关卡编辑器等扩展方向适合想通过完整项目理解引擎用法的开发者。压缩包共 43 个文件约 3.46MB包含 7 个 cpp 与 8 个 h 源码文件、8 张 png 图集资源、4 个 ttf 字体、4 个 plist 配置以及 dll、sln、vcxproj 等工程与运行库文件可直接在 Windows 下编译运行。目前已有 207 人学习能帮助读者快速掌握消除逻辑与动画特效的实现方式。1. 从一份 cocos2d-x 源码看三消游戏的最小闭环很多人第一次想动手做三消卡住的地方不是算法而是不知道一个能跑起来的工程到底该有哪些文件。这份用 cocos2d-x 写的 candy crush 源码包恰好把这个问题回答清楚了它不是一个只贴了几段核心逻辑的片段而是一个能编译、能点、能消、能补位的完整小工程。你拿到手之后最直接的用法是把它当成三消玩法的“参照系”——棋盘怎么建、元素怎么落、匹配怎么判、消除后怎么补、连锁怎么算分这几件事在源码里都有对应的类和函数而不是散落在博客里的伪代码。它适合两类人一类是刚学完 cocos2d-x 基础、想找一个规模可控的完整项目练手的开发者另一类是做休闲游戏、需要快速验证三消核心手感的技术负责人。源码用的是 C 配合 cocos2d-x 的经典节点体系没有引入额外的重型框架所以阅读门槛主要集中在对 Sprite、Layer、Schedule 这些基础概念的理解上而不是被一堆第三方库绕晕。接下来我会按“工程怎么读、棋盘怎么建、匹配怎么判、坑在哪”的顺序把这份源码拆到你能自己复现的程度。2. 工程结构与棋盘初始化先搞清楚谁管数据、谁管显示2.1 三消工程里最常见的分层方式打开这份 cocos2d-x candy crush 源码你会看到它基本遵循了“场景层 棋盘逻辑层 元素显示层”的三段式结构。场景层负责挂载棋盘、接收触摸、驱动更新棋盘逻辑层用一个二维数组保存每个格子的元素类型和状态元素显示层则是每个糖果对应的 Sprite 节点。这种分法不是这份源码独有的而是三消项目里最不容易翻车的一种组织方式原因是它把“数据判定”和“画面表现”隔开了——匹配算法只跟二维数组打交道消除动画只跟 Sprite 打交道两边通过一个映射关系同步。常见做法是定义一个GameBoard类持有int board[ROW][COL]再定义一个Tile或Candy类继承自Sprite每个 Tile 记录自己的行列坐标。源码里也是这个思路只不过命名上更贴近 candy crush 的语境。你读的时候先找到那个二维数组再找到数组更新后触发刷新显示的函数整个数据流就通了。2.2 棋盘初始化的代码骨架初始化阶段最容易出问题的是“生成时就已经有三连”的情况。合格的实现会在填充棋盘时做一次预检测避免开局自动消除。下面这段骨架是我从源码里提炼出来的典型写法你可以直接对照自己的工程改// GameBoard.h static const int ROW 8; static const int COL 8; int m_board[ROW][COL]; // 只存类型 id不存节点 cocos2d::Layer* m_tileLayer; // 所有糖果 Sprite 的父节点 // GameBoard.cpp void GameBoard::initBoard() { for (int r 0; r ROW; r) { for (int c 0; c COL; c) { int type; do { type randomType(); // 随机 0~N-1 } while (wouldMatchAt(r, c, type)); // 预判避免开局三连 m_board[r][c] type; createTileSprite(r, c, type); } } } bool GameBoard::wouldMatchAt(int r, int c, int type) { // 检查左边两个和上边两个是否同类型 if (c 2 m_board[r][c-1] type m_board[r][c-2] type) return true; if (r 2 m_board[r-1][c] type m_board[r-2][c] type) return true; return false; }逻辑上initBoard先填数据再建 Sprite保证数组和画面一一对应wouldMatchAt只往左和往上检查因为填充是从左上到右下进行的右边和下面的格子还没赋值检查了也没意义。参数方面ROW和COL决定棋盘规模candy crush 原版是 8x8 或 9x9这份源码用的是 8x8你改成 9x9 时记得同步调整 Sprite 的间距和触摸坐标换算否则点击位置会整体偏移。提示初始化时如果只做随机填充而不做预判开局就可能出现三连玩家还没操作分数就动了这种问题在真机上很容易被当成 bug 反馈。2.3 坐标换算与触摸响应棋盘逻辑用的是行列下标触摸事件拿到的是屏幕坐标中间必须有一次换算。源码里的做法是先算出棋盘左上角的原点再用(touchPos - origin) / tileSize得到行列。这里有个细节cocos2d-x 的坐标系原点在左下角而棋盘通常按从上到下渲染所以行号需要翻转一次。我见过不少人在这一步翻车表现是点击上半部分消的是下半部分的糖果排查半天以为是匹配算法错了其实是坐标没翻。换算函数建议单独抽出来不要散落在触摸回调里bool GameBoard::convertToRowCol(const cocos2d::Vec2 touchPos, int outRow, int outCol) { Vec2 origin m_tileLayer-getPosition(); // 棋盘左下角 float tileSize m_tileLayer-getContentSize().width / COL; int col static_castint((touchPos.x - origin.x) / tileSize); int row ROW - 1 - static_castint((touchPos.y - origin.y) / tileSize); if (row 0 || row ROW || col 0 || col COL) return false; outRow row; outCol col; return true; }tileSize用层宽度除以列数得到比硬编码一个像素值更稳换分辨率时不用改。row的翻转是ROW - 1 -那一步漏掉它就会出现上下颠倒。返回false表示点到了棋盘外面调用方直接忽略这次触摸即可。3. 匹配判定与消除补位三消的核心循环怎么写才不卡3.1 横向纵向扫描的判定逻辑匹配判定是三消的心脏。这份源码用的是最经典的“扫描连续相同”方案对每一行从左到右扫对每一列从上到下扫遇到连续三个及以上同类型就标记为待消除。它没有用更复杂的图搜索因为三消的规则就是横竖直线扫描足够且更好调试。判定函数通常返回一个待消除坐标的集合而不是直接改数组这样消除动画和连锁计算可以分开处理。std::vectorstd::pairint,int GameBoard::findMatches() { std::vectorstd::pairint,int matched; // 横向扫描 for (int r 0; r ROW; r) { int runStart 0; for (int c 1; c COL; c) { if (c COL m_board[r][c] m_board[r][runStart]) continue; if (c - runStart 3) { for (int k runStart; k c; k) matched.push_back({r, k}); } runStart c; } } // 纵向扫描同理交换 r 和 c 的角色 // ... 去重后返回 return matched; }runStart记录当前连续段的起点当类型变化或到达行尾时判断长度是否达到 3。注意c COL这个边界它让循环多走一次专门用来收尾最后一段连续。返回的集合里可能有重复坐标横竖交叉点消除前要去重否则同一个糖果会被消两次分数和动画都会出问题。3.2 消除、下落与补充的时序消除之后要下落下落之后要补充新糖果补充之后可能又形成新的匹配这就是连锁。源码里把这一整套做成了一个循环找匹配 → 有就消除 → 下落 → 补充 → 再找匹配直到没有匹配为止。这个循环必须能终止否则遇到判定 bug 会死循环卡死界面。void GameBoard::resolveBoard() { int chain 0; while (true) { auto matched findMatches(); if (matched.empty()) break; chain; removeTiles(matched); // 从数组和显示层移除 applyGravity(); // 上方糖果下落 refillEmptySlots(); // 顶部补新糖果 addScore(matched.size(), chain); // 连锁越高分越高 } } void GameBoard::applyGravity() { for (int c 0; c COL; c) { int writeRow ROW - 1; for (int r ROW - 1; r 0; --r) { if (m_board[r][c] ! EMPTY) { m_board[writeRow][c] m_board[r][c]; if (writeRow ! r) m_board[r][c] EMPTY; --writeRow; } } } }applyGravity用的是“写指针”技巧从底部往上找非空元素依次放到writeRow位置writeRow只在放入后上移。这样一趟就能完成下落不需要反复交换。chain用来做连锁加成第一次消除倍率 1第二次 2以此类推这是 candy crush 类游戏得分手感的关键参数你可以按项目需要调整曲线。注意下落和补充如果直接改数组而不更新 Sprite 位置画面和数据就会脱节表现为“糖果还在原地但已经消了”。每次数组变动后都要同步刷新对应格子的显示节点。3.3 交换合法性判断玩家交换两个相邻糖果之前要先判断这次交换能不能形成匹配。常见做法是先在数据层模拟交换跑一次findMatches有结果才真正执行并播放动画没有就换回去。这份源码也是这个流程好处是逻辑统一不会出现“动画播了但没消”的尴尬。bool GameBoard::trySwap(int r1, int c1, int r2, int c2) { if (abs(r1 - r2) abs(c1 - c2) ! 1) return false; // 只允许相邻 std::swap(m_board[r1][c1], m_board[r2][c2]); if (findMatches().empty()) { std::swap(m_board[r1][c1], m_board[r2][c2]); // 换回 return false; } return true; }abs(r1-r2) abs(c1-c2) ! 1是曼哈顿距离判断保证只处理上下左右相邻斜对角不算。模拟交换后如果没匹配就立刻换回玩家看到的是糖果弹回原位。这个函数返回true之后调用方再去跑resolveBoard职责清晰。4. 避坑与排查这份源码跑起来最容易翻车的几个点4.1 编译报错找不到 cocos2d.h现象是打开工程直接编译提示cocos2d.h找不到。原因通常是源码包里的工程文件用的是相对路径引用 cocos2d-x 引擎目录而你本地的引擎版本或路径跟作者不一致。解决办法是先确认你本地装的是 cocos2d-x 3.x 还是 4.x然后在工程属性或 CMakeLists 里把引擎头文件路径改成你自己的实际路径。如果版本跨越大部分 API 名称有变化优先看报错行涉及的类逐个替换成当前版本的写法。4.2 棋盘显示错位或点击偏移现象是糖果画在格子外面或者点击位置和消除位置对不上。原因一般是tileSize用了硬编码值而实际分辨率或设计分辨率变了。解决方式是统一用m_tileLayer-getContentSize().width / COL动态计算并检查棋盘层的锚点和位置设置。如果只是上下颠倒回到 2.3 节确认行号有没有做ROW - 1 -翻转。4.3 消除后出现空白格不补充现象是消掉一批糖果后上面的掉下来了但顶部空着没有新糖果。原因是refillEmptySlots只处理了部分列或者补充时没有同步创建 Sprite。排查时先打印每列的空格数量确认补充循环覆盖了所有EMPTY位置再检查新建 Sprite 有没有加到m_tileLayer上。补充和下落是两个独立步骤漏掉任何一个都会留下空洞。4.4 连锁停不下来或分数暴涨现象是消除一次之后连锁无限进行分数异常高。原因是findMatches返回了重复坐标却没有去重导致同一批糖果被反复判定为可消除。解决办法是在findMatches返回前用集合去重或者在removeTiles里把已消除位置置为EMPTY后再进入下一轮。另外确认resolveBoard的循环条件是基于最新数组状态而不是缓存的旧结果。4.5 触摸响应迟钝或连点失效现象是快速点击多个糖果时只有第一次生效。原因是触摸事件没有正确设置吞没或多点触控或者交换动画期间没有锁住输入。常见做法是在trySwap执行期间设一个m_isBusy标志动画结束后再放开避免玩家在结算过程中插入新的交换导致状态错乱。5. 进阶技巧把这份源码改成你自己的三消玩法5.1 特殊糖果的生成规则candy crush 里四连生成条纹糖、五连生成彩球这份源码的基础版本没有做特殊糖果但它的匹配结果里已经包含了连续段的长度信息你可以在findMatches里额外返回每段的长度和方向然后在消除时判断长度 4 就在交换位置生成一个横向或纵向消除的特殊糖果长度 5 就生成一个万能糖果。关键是把“生成特殊糖果”放在removeTiles之后、applyGravity之前这样新糖果会落在正确的位置上。5.2 用配置表驱动关卡参数基础源码里的行列数、糖果类型数、目标分数都是写死的。实际项目里我一般会抽一个 JSON 或 plist 配置把rows、cols、typeCount、moveLimit、targetScore放进去GameBoard初始化时读取。这样加关卡不用改代码也方便做难度曲线。下面是一个最小配置示例{ level: 1, rows: 8, cols: 8, typeCount: 5, moveLimit: 20, targetScore: 1500 }typeCount控制糖果种类种类越少越容易形成匹配难度越低moveLimit是步数限制配合targetScore决定关卡节奏。读取时注意做默认值兜底配置缺字段时不至于崩溃。5.3 验证改动是否正确的自检清单改完代码后我习惯按这几步走一遍先看开局有没有自动三连再看一次交换能不能正确消除然后看消除后下落和补充是否连续最后看连锁分数是不是按倍率递增。这四步能覆盖大部分逻辑回归。如果时间允许再手动构造一个四连和一个五连确认特殊糖果生成位置正确。5.4 一个我踩过的坑最早我改这份源码时为了省事直接在findMatches里把匹配到的糖果从数组删掉结果下落逻辑拿到的数组已经缺了元素索引全乱画面直接花掉。从那以后我每次改消除相关代码都强制走一遍“先标记、再统一移除、再下落、再补充”的流程不在判定阶段动数组。这个习惯帮我省了很多排查时间。希望这份拆解能帮你把 cocos2d-x 三消的核心闭环跑通少走一点弯路。本文还有配套的精品资源点击获取