☰
cocos2d-x三消游戏源码解析:从BFS匹配到状态机完整实现
2026/10/21 5:17:25 网站建设 项目流程

简介:基于cocos2d-x实现的Candy Crush三消游戏完整工程,面向游戏开发初学者及cocos2d-x学习者,用于理解三消玩法核心逻辑与跨平台项目组织方式。资源共43个文件,压缩包仅3.46MB,包含C++源码、头文件、png贴图、plist配置、ttf字体及运行所需的dll,并附有Visual Studio工程文件,解压后目录结构清晰,便于按模块查看。目前已有208人学习浏览。源码基于cocos2d-x 2.2.6,完整涵盖棋盘随机布局、相邻糖果交换判定、三消消除处理、动画特效、计分关卡等关键模块,并补充了迁移到3.0以上版本时API变化与Scene Graph适配思路。读者可对照源码学习游戏循环与事件处理,也可基于现有框架扩展特殊糖果、道具系统、关卡编辑器等玩法,适合作为三消游戏开发的起点参考。

1. 项目定位:cocos2d-x candy crush,一份能直接跑起来的三消游戏骨架

想自己动手做三消,最容易被卡死的地方不是“不知道怎么设计玩法”,而是“匹配算法写完不知道对不对、糖果下落动画怎么编排、触摸交换失败如何回滚”。cocos2d-x candy crush 这个项目正好把这些环节全部串起来:从 8x8 棋盘建模、六色糖果随机生成,到 BFS 三连匹配、消除下落链路和 UI 界面,都有完整代码可以落地。适合两类人:一类是刚学 cocos2d-x 想把 C++ 游戏框架跑通的新手,另一类是想参考三消玩法完整实现、准备做二次开发的熟手。把这份源码拆完你会发现,三消本质上是一个状态机加一整套动作编排。

2. 架构设计:场景节点与三消状态机怎么组织

2.1 技术选型:为什么用 cocos2d-x 的 C++ 分支

这个项目走的是 cocos2d-x 最经典的一条路:C++ 引擎主干,跨平台编译,业务代码统一放在 Classes 目录。相比 Unity 那套组件式开发,cocos2d-x 的思维方式更贴近图形学本身——场景树、节点、精灵、动作系统一条链下来,游戏世界里每个可见元素都是一个 Node,所有变化都由 Action 驱动。

用这套结构做三消,收益非常明显:糖果交换、消除、下落本质上都是位置和透明度的变化,Action 系统自带延时、回调、序列组合,不需要额外引入动画库;Matcher 和 Resolver 是纯算法模块,可以直接脱离引擎做单元测试。入口代码通常是这样的模式:

auto director = Director::getInstance(); auto scene = GameScene::create(); director->runWithScene(scene);

Director 持有场景栈,runWithScene 启动第一个场景。在这个项目里,GameScene 是一切的根节点,后续的棋盘层、UI 层、触摸层都挂在它下面。

2.2 目录结构与模块分层:数据、逻辑、渲染三层拆分

源码的常规组织方式是把业务分成三层,代码放在 Classes 下,资源放在 Resources 下:

Classes/ AppDelegate.cpp # 引擎初始化入口 GameScene/ GameScene.cpp # 场景根节点,协调各层 BoardLayer.cpp # 棋盘渲染层,负责画格子与糖果精灵 HUDLayer.cpp # 分数、步数、游戏结束弹窗 Data/ CandyType.hpp # 糖果颜色枚举与格子结构体 BoardData.cpp # 棋盘二维数组,数据读写与生成 Logic/ Matcher.cpp # BFS 匹配检测,纯算法代码 Resolver.cpp # 消除、下落、补位逻辑编排 UI/ TouchLayer.cpp # 触摸输入层,转换为棋盘坐标 Resources/ candy/ # 糖果纹理与帧动画 ui/ # 按钮、背景、数字艺术字 particles/ # 消除特效粒子

这三层拆分是这个项目最值得抄的地方。BoardData 不持有任何 Sprite,只维护二维数组;Matcher 只读写 BoardData,返回匹配组坐标;BoardLayer 监听数据变化,负责把“数据层的移动”翻译成“渲染层的 Action”。好处是调试匹配算法时不用开模拟器,直接在命令行写个 main 函数就能验证。

2.3 三消主循环:交换到消除的状态机设计

游戏中间的一个回合,流程是固定的:等玩家输入 → 交换相邻两格 → 检测匹配 → 消除 → 下落补位 → 再次检测匹配 → 回到等输入。这个流程用状态机组织,而不是散落的 if 判断:

enum class GameState { WaitingInput, // 等待玩家触摸 Swapping, // 正在播放交换动画 Matching, // 匹配检测中 Resolving, // 消除与下落 GameOver };

每个状态都有唯一入口和出口。比如只有 WaitingInput 状态下触摸才会被响应,Swapping 状态下任何触摸都被忽略,这样避免了玩家疯狂点击导致的数组错乱。状态切换集中在 GameScene 的一个函数里,每次切换都打一行日志,排查问题时会很有用。

3. 核心模块拆解:匹配检测与下落填充的完整链路

3.1 棋盘数据模型:用二维数组管理格子状态

棋盘是固定 8 行 8 列,每种糖果用一个枚举表示。为了让渲染层同步走,格子结构里保存了类型和一个“是否为空”的标记:

enum class CandyType { Red, Green, Blue, Yellow, Purple, Orange }; struct Cell { CandyType type = CandyType::Red; bool empty = false; // true 表示该格已被消除,等待下落填充 }; class BoardData { public: static const int ROWS = 8; static const int COLS = 8; Cell grid[ROWS][COLS]; void initRandom(); // 随机填充,避开开局三连 bool swapCells(int r1, int c1, int r2, int c2); Cell& cellAt(int r, int c) { return grid[r][c]; } };

随机填充有个关键细节:不能开局就自带三连。常见做法是逐格生成候选类型,剔除“左边连续两个同色”和“上方连续两个同色”的情况:

CandyType randomTypeAvoidingMatch(int r, int c) { std::vector<CandyType> candidates = { CandyType::Red, CandyType::Green, CandyType::Blue, CandyType::Yellow, CandyType::Purple, CandyType::Orange }; if (c >= 2 && grid[r][c-1].type == grid[r][c-2].type) { auto t = grid[r][c-1].type; candidates.erase(std::remove(candidates.begin(), candidates.end(), t), candidates.end()); } if (r >= 2 && grid[r-1][c].type == grid[r-2][c].type) { auto t = grid[r-1][c].type; candidates.erase(std::remove(candidates.begin(), candidates.end(), t), candidates.end()); } return candidates[rand() % candidates.size()]; }

函数末尾的 rand 是从候选集里随机挑一个。如果左右已经有连续两个同色,就把这个颜色从候选集移除,上下同理。这样生成的棋盘天然不会出现开局三连,省掉后续“开局死局”的兜底逻辑。

3.2 BFS 匹配检测:连通块搜索与三连判定

匹配检测的常见实现是 BFS 连通域。从每个格子出发,向上下左右四个方向扩散,把同色格子聚成一组,丢了组长度小于 3 的组即可:

bool Matcher::hasMatch(const BoardData& board) { bool visited[8][8] = {false}; for (int r = 0; r < 8; ++r) { for (int c = 0; c < 8; ++c) { if (visited[r][c] || board.cellAt(r, c).empty) continue; std::queue<std::pair<int,int>> q; std::vector<std::pair<int,int>> group; q.push({r, c}); visited[r][c] = true; int dr[4] = {-1, 1, 0, 0}; int dc[4] = {0, 0, -1, 1}; while (!q.empty()) { auto cur = q.front(); q.pop(); group.push_back(cur); for (int i = 0; i < 4; ++i) { int nr = cur.first + dr[i]; int nc = cur.second + dc[i]; if (nr < 0 || nr >= 8 || nc < 0 || nc >= 8) continue; if (visited[nr][nc]) continue; if (board.cellAt(nr, nc).type != board.cellAt(cur.first, cur.second).type) continue; visited[nr][nc] = true; q.push({nr, nc}); } } if (group.size() >= 3) return true; } } return false; }

BFS 的内部循环里,visited 数组保证每个格子只访问一次,dr/dc 四个方向覆盖上下左右。匹配判定只看 group.size(),三连就触发消除,四连五连自动支持。换入新糖果后调用这个函数,返回 true 就继续 resolve,返回 false 就回滚交换。

3.3 消除、下落与补位动画:动作驱动的全流程

Resolver 是整个项目里最需要耐心调的部分,它的职责是:遍历棋盘,找出所有大小大于等于 3 的连通组,把对应格子标记为空,并触发精灵的播放特效;然后按列进行下落,把空位上方的糖果整体下移,最后在顶部生成新糖果。核心逻辑是下落压缩:

void Resolver::dropCells(BoardData& board, BoardLayer* layer) { for (int c = 0; c < 8; ++c) { int writeRow = 7; for (int r = 7; r >= 0; --r) { if (!board.cellAt(r, c).empty) { if (writeRow != r) { board.cellAt(writeRow, c) = board.cellAt(r, c); board.cellAt(r, c).empty = true; layer->animateMove(r, c, writeRow, c); } --writeRow; } } for (int r = writeRow; r >= 0; --r) { CandyType type = BoardData::randomTypeAvoidingMatch(r, c); board.cellAt(r, c).type = type; board.cellAt(r, c).empty = false; layer->spawnCandy(r, c); } } }

writeRow 指针从每列底部往上扫描,非空格搬到当前位置,空格留在原地。顶部 writeRow 以下的格子全部填新糖果。动画层 animateMove 内部就是一个 MoveTo 动作加延时回调:

void BoardLayer::animateMove(int fromR, int fromC, int toR, int toC) { auto sprite = candySprites[fromR][fromC]; candySprites[toR][toC] = sprite; candySprites[fromR][fromC] = nullptr; Vec2 targetPos = gridPosition(toR, toC); sprite->runAction(Sequence::create( MoveTo::create(0.15f, targetPos), CallFunc::create([sprite]{ sprite->setLocalZOrder(1); }), nullptr )); }

candySprites 是二维指针数组,存每个格子对应的精灵。MoveTo 的 0.15 秒是关键体验参数,太快像瞬移,太慢让连续消除的节奏拖沓;换糖果时可以调。

3.4 触摸交换与回滚:交互层的约束逻辑

触摸层拿到屏幕坐标后,先转换到棋盘坐标,再判断相邻性、执行交换、检测匹配。该项目常见做法是先把逻辑层做初步判断,再让渲染层跟着走:

void GameScene::trySwap(int r1, int c1, int r2, int c2) { if (state != GameState::WaitingInput) return; int dr = abs(r1 - r2), dc = abs(c1 - c2); if (dr + dc != 1) return; // 只允许上下左右相邻 board.swapCells(r1, c1, r2, c2); boardLayer->animateSwap(r1, c1, r2, c2); if (Matcher::hasMatch(board)) { state = GameState::Resolving; resolver.resolve(board, boardLayer); } else { board.swapCells(r1, c1, r2, c2); boardLayer->animateSwapBack(r1, c1, r2, c2); } }

dr + dc != 1 这个判断是常有疏漏的地方,它把对角线和非相邻的非法操作挡在外面。交换失败时执行第二次 swapCells 用于回滚数据,同时渲染层用动画把糖果滑回原位,玩家看起来只是轻轻抖了一下,不会直接闪回。

4. 编译运行指南:从源码到可玩游戏的步骤

4.1 环境准备:依赖清单与目录约定

以最常见的主机平台 Windows 为例,需要准备:Visual Studio 2019 或更新版本、CMake 3.15 以上、cocos2d-x 3.x C++ 分支引擎源码。资源目录里需要准备 8 张糖果贴图和一套按钮图,放在 Resources/candy 和 Resources/ui 下,代码运行时会按固定格式加载。

环境配好之后,第一件事推荐先编译引擎自带的 cpp-tests 工程,确认渲染环境是通的。这一步很能筛掉“引擎库路径没配置好”这类翻车情况,否则后面工程跑起来黑屏,很难分清是代码问题还是环境问题。

4.2 编译流程:CMake 生成工程与实际参数

cocos2d-x 3.x 的 C++ 工程用 CMake 组织,目录下通常已有 CMakeLists.txt,只需在命令行手动生成工程文件:

mkdir build cd build cmake .. -G "Visual Studio 16 2019" -A x64 -DCMAKE_BUILD_TYPE=Debug cmake --build . --target CandyCrush --config Debug

-G 指定 VS 版本,-A 指定目标位数,不指定时 CMake 可能默认生成 Win32 工程,导致真机调试时纹理内存打满。Debug 选项保留调试信息,方便断点查看匹配结果。编译完成后在 build 目录找到 exe,把 Resources 文件夹复制到可执行文件同目录下启动。

Android 上思路类似,用 cocos 命令行工具或直接导入原生工程后由 cmake 脚本指向 NDK 工具链;要点是保证 NDK 与引擎头文件版本一致,否则一堆 C++ 标准库的宏冲突会让误报刷屏。

4.3 运行验证:怎么判断项目已经是“活的”

编译通过不等于能玩,运行时需要逐项验证。最直接的方式是在 Matcher 里临时加打印,每次匹配后日志输出消除组坐标,观察坐标与画面是否同步:

验证点预期表现观察方式
开局棋盘8x8 糖果无三连截图并观察是否有初始三连
交换合法只允许相邻交换后触发匹配连续随机点击观察控制台日志
快速连接0.5 秒内两次消除应串行快速点击后等待动画结束再操作
内存占用连续 50 次消除后不持续增长任务管理器观察 exe 内存曲线

遇到快速点击导致界面错乱,优先怀疑触摸层没做 GameState 锁。这个项目的代码在状态机保护下基本不会有这个问题,但自己改造时删掉状态检查是常见误操作。

5. 避坑手册:坐标错位与引用计数泄漏的排查记录

5.1 触摸坐标错位:点在左边,右边响应

现象:手指点击棋盘左侧,右侧一排糖果跟着动,整体偏移量在 Retina 设备上尤其明显。原因:触摸坐标是屏幕坐标系,直接拿去和 BoardLayer 子节点做坐标比较,没有转换到节点坐标系。解决:在收到触摸事件时先做一次 convertToNodeSpace:

Vec2 localPos = boardLayer->convertToNodeSpace(touchPos); int col = (int)(localPos.x / CELL_WIDTH); int row = (int)(localPos.y / CELL_HEIGHT);

Convert 的语义是把屏幕坐标换算成以节点锚点为原点的局部坐标。设计时 BoardLayer 的原点与棋盘左上角对齐,就省去偏移常量带来的手工计算。

5.2 resolve 死循环:消除完又消除,停不下来

现象:某一步之后控制台日志疯狂打印匹配,界面一直在闪,像得了癫痫。原因:下落填充之后新生成的糖果再次形成三连,逻辑设计上需要“循环判断直到无匹配”,但实现时用了递归调用 resolve,递归过深栈溢出,或者循环里没有更新 visited 状态,老匹配组反复被检测。解决:把递归改成循环,每次下落完成后重新执行 hasMatch,有匹配就继续 resolve,直到没有为止。

do { resolver.resolve(board, boardLayer); } while (Matcher::hasMatch(board) && state != GameState::GameOver);

加上 do-while 之后,连锁消除是自动完成的,但注意加一个最大循环次数兜底,比如 10 次,防止算法 bug 时界面进入无限动画。

5.3 纹理错乱:糖果显示成白块或紫块

现象:运行后所有糖果显示为白色方块,控制台报 “Texture not found” 之类的日志。原因:Resources 路径写错,最常见的翻车点是用相对路径但把资源放在了错误位置。解决:统一用 FileUtils 的 getInstance()->addSearchPath 把资源目录注入搜索路径,同时确认资源名称大小写与代码一致:

FileUtils::getInstance()->addSearchPath("Resources"); FileUtils::getInstance()->addSearchPath("Resources/candy");

Windows 上大小写不敏感容易让人忽略,但到 Linux 服务器或 Android 上就立刻崩。养成习惯,资源名统一小写加下划线。

5.4 引用计数泄漏:连续开关关卡内存持续涨

现象:反复 restart 游戏 20 次,内存从 80MB 涨到 300MB。原因:BoardLayer 重刷时直接 removeFromParentAndCleanup 了旧精灵,但只要还有某个 CallFunc 闭包持有 sprite 的智能指针,对象就不会释放。解决:所有进入 Action 的捕获列表统一用 raw 指针,不持有 Sprite 的强引用;场景切换统一走 Director::replaceScene,让整棵节点树自动释放。

5.5 交换后数组和画面脱节

现象:偶尔出现“两个糖果换过去了,但没有匹配消除,回滚后画面与数据不一致”的情况。原因:触摸回调里直接修改了 BoardData,跳过了 GameScene 的状态机,后续的下落逻辑按旧状态处理。解决:所有对 BoardData 的写操作统一收口到 GameScene::trySwap —— 触摸层只上报请求,不落地修改。

6. 二次开发:加入道具系统与关卡目标的改造路径

如果想把这个三消项目改成可运营的游戏,最落地的改造是加一个道具系统。方案在数据层加一个 SpecialType 字段,匹配组达到 4 个以上时生成特殊糖果而不是普通糖果:

struct SpecialInfo { enum class Kind { None, HorizontalStriped, VerticalStriped, Bomb }; Kind kind = Kind::None; };

匹配检测返回的 group 超过 4 时,把中心位置换成特殊糖果,替换动作复用 Resolver 的动画流程,只不过 spawnCandy 换成 spawnSpecialCandy。Bomb 触发时做一次周围 3x3 范围的额外消除,这个逻辑写在 Resolver 的最前面,先处理特殊糖果,再走普通消除流程。

另一个高性价比改造是加严格的目标条件:HUD 里显示剩余步数,每次有匹配生效时步数减一,步数清零但没有达成目标分数就弹 GameOver。这个改动只需要在 GameScene 增加一个 stepCount 整型,trySwap 成功分支里自减并刷新 HUD。自己加道具的时候,最大的坑是道具触发时机与状态机冲突。我改造过一次,把 Bomb 触发逻辑放在了 Swapping 状态里,结果玩家还没看到交换动画,炸弹就把周围糖果消了,画面直接瞬跳。从那以后我每次改这个项目,无论加什么玩法,都强制走一遍检查:先确认新的玩法逻辑挂在哪个 GameState 下、会不会打断正在播的动画、失败回滚是否也走 trySwap。走完这三步再上真机,基本不会出结构性翻车。希望帮到你。

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

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

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

立即咨询