ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

cocos2d-x三消游戏开发实战:从数据层到状态机的完整实现

cocos2d-x三消游戏开发实战:从数据层到状态机的完整实现

简介:这份资源是面向 cocos2d-x 初学者与三消游戏开发爱好者的 Candy Crush 实战工程,基于 2.2.6 版本实现基础版糖果消除玩法,并兼顾 3.0 及以上版本的适配思路,适合想通过完整项目理解棋盘布局、交换匹配、消除动画与关卡状态管理的开发者练手。压缩包共 43 个文件,约 3.46MB,以 cpp 与 h 源码为核心,配合 png 贴图、ttf 字体、plist 配置及 dll 运行库,另有 sln、vcxproj 等工程文件,可直接在 Windows 下打开编译运行。已有 210 人学习。工程按 Classes 与 Resources 分目录组织,GameScene、GameBox、Tile 等模块划分清晰,便于对照理解糖果精灵、棋盘网格与消除逻辑的实现方式,也能作为二次开发与特殊糖果、道具系统扩展的起点。

1. 从“能跑就行”到“能交作业”:cocos2d-x 版 candy crush 到底要解决什么

三消游戏看起来是最没门槛的品类:交换两个相邻方块、凑够三个同色就消掉、掉下来补满,循环往复。真动手用 cocos2d-x 写一个 candy crush 出来,第一周就会撞上三堵墙:棋盘数据结构和渲染节点怎么对应、消除后的连锁掉落怎么保证不出现“悬空块”、以及消除动画和逻辑判定怎么解耦才不至于卡成幻灯片。这套方案要解决的就是这三件事,把一款可玩、可演示、可讲清楚设计思路的三消 Demo 从零搭到能验收。它适合正在做课程设计或毕设的学生,也适合想拿一个完整小项目练手 cocos2d-x 场景管理、节点池和状态机的开发者。下面按需求、架构、核心模块、避坑、验收的顺序推下来,每一步都落到能直接改的参数和能直接抄的结构。

2. 需求与选型:为什么是三消,为什么是 cocos2d-x

2.1 先把玩法边界钉死

三消的玩法空间比想象中大,糖果缤纷乐那一类产品背后有特殊糖果、关卡目标、步数限制、障碍物、连锁加成等一大堆系统。课程设计或毕设的周期通常只有几周到两个月,全都要等于全都做不深。我一般会把第一版的范围压到四条:8×8 固定棋盘、相邻交换、三连及以上消除、消除后重力下落加顶部补充。特殊道具(四连生成条纹糖、五连生成彩球)作为第二阶段扩展,关卡目标和步数系统作为第三阶段。这样切的好处是核心循环先闭环,后面加功能都是往已有骨架上挂,不用推倒重来。

范围钉死之后,验收标准也就跟着清楚了:任意一次交换要么产生有效消除并正确结算,要么回弹且不改变棋盘状态;连续消除能正确累计连击数;棋盘任何时刻不存在空位。这三条能过,核心玩法就算立住了。

2.2 引擎选型的真实理由

选 cocos2d-x 而不是纯前端 Canvas 或者别的引擎,理由不是“它最强”,而是它在这个项目场景下的匹配度。三消是典型的 2D 精灵密集场景,cocos2d-x 的 Sprite、Node 树、动作系统(Action)天然适配“方块移动、缩放、淡出”这类表现;场景切换和层管理(Layer)让棋盘、UI、弹窗能干净分层;C++ 侧的性能足够撑住 64 个方块同时做位移动画而不掉帧。另一个现实因素是资料密度:cocos2d-x 的教程和示例在中文社区里存量很大,学生卡在某个 API 上更容易搜到答案。

代价也要说清楚。cocos2d-x 的 C++ 内存管理是手动引用计数,节点创建后要正确 retain/release,否则容易泄漏或者提前析构导致崩溃;跨平台打包的配置链比纯 Web 项目长。这两点在后文的避坑章节会专门展开。

2.3 技术栈与目录约定

项目按 cocos2d-x 的标准工程结构组织,核心代码集中在 Classes 目录,资源放 Resources。下面这张表是模块和职责的对应关系,后面每一章都围绕它展开。

模块主要文件职责
棋盘数据层BoardModel.h/.cpp二维数组存储方块类型,负责交换、匹配检测、下落填充
棋盘表现层BoardLayer.h/.cpp根据数据层状态创建和更新 Sprite 节点
方块节点TileSprite.h/.cpp单个方块的显示、选中态、动画接口
游戏流程控制GameScene.h/.cpp输入分发、状态机、连击结算、胜负判定
资源与配置Resources/贴图、音效、关卡参数 plist

数据层和表现层分离是这个项目最重要的一个决定。很多初学者把方块类型直接存在 Sprite 的 userData 里,逻辑和渲染搅在一起,后面加特殊糖果或者做撤销功能时几乎无法维护。分开之后,数据层可以脱离引擎单独跑单元测试,表现层只负责“把数据画出来”,两边通过一个同步函数对接。

3. 总体架构设计:数据层、表现层与状态机的三层分工

3.1 棋盘数据模型:二维数组加类型枚举

数据层的核心是一个固定尺寸的二维数组,每个格子存一个枚举值表示糖果类型,空位用特殊值标记。下面是最小可用的模型定义。

// BoardModel.h #pragma once #include <vector> #include <cstdlib> enum class CandyType { NONE = -1, // 空位,用于下落过程中的中间态 RED = 0, BLUE, GREEN, YELLOW, PURPLE, ORANGE, TYPE_COUNT // 类型总数,随机生成时用 }; class BoardModel { public: static const int ROWS = 8; static const int COLS = 8; BoardModel(); void reset(); // 重新生成一个无初始三连的棋盘 CandyType get(int row, int col) const; void set(int row, int col, CandyType t); bool swapAndCheck(int r1, int c1, int r2, int c2); // 交换并检测是否有效 std::vector<std::pair<int,int>> findMatches() const; // 返回所有待消除坐标 void removeMatches(const std::vector<std::pair<int,int>>& cells); void applyGravity(); // 下落 + 顶部补充 private: CandyType grid[ROWS][COLS]; bool hasMatchAt(int row, int col) const; CandyType randomType() const; };

CandyType里NONE单独留一个值,是为了让下落过程有明确的“空”状态,表现层看到NONE就知道这个位置暂时不该有精灵。findMatches返回坐标列表而不是直接改数组,是为了让调用方先播消除动画、动画结束再真正清空数据,逻辑和表现的时间点才对得上。

reset()里有一个容易被忽略的细节:初始棋盘不能自带三连,否则玩家一进游戏就看到一堆已经在消的方块。通常的做法是逐格随机生成,每生成一格就检查它左边两个和上边两个是否同色,同色就重摇,最多重摇若干次后换一个类型兜底。

3.2 表现层同步:节点池与坐标映射

表现层维护一个和棋盘同尺寸的TileSprite*指针数组,每个指针对应一个方块节点。数据变化后调用一个同步函数,对比数据层和当前节点状态,决定是移动、创建还是销毁节点。

// BoardLayer.cpp 片段 void BoardLayer::syncWithModel() { for (int r = 0; r < BoardModel::ROWS; ++r) { for (int c = 0; c < BoardModel::COLS; ++c) { CandyType t = _model->get(r, c); TileSprite* tile = _tiles[r][c]; if (t == CandyType::NONE) { if (tile) { tile->runAction(RemoveSelf::create()); _tiles[r][c] = nullptr; } continue; } if (!tile) { // 新方块从棋盘上方掉入 tile = TileSprite::create(t); tile->setPosition(cellToPos(r - BoardModel::ROWS, c)); // 起点在屏幕外 tile->runAction(MoveTo::create(0.2f, cellToPos(r, c))); this->addChild(tile); _tiles[r][c] = tile; } else if (tile->getType() != t) { tile->setType(t); // 类型变化,换贴图 } } } } Vec2 BoardLayer::cellToPos(int row, int col) { // 棋盘左上角原点 + 格子尺寸偏移,格子尺寸由贴图实际大小决定 return Vec2(_origin.x + col * _cellSize.width, _origin.y - row * _cellSize.height); }

cellToPos里行号乘的是负的格子高度,因为 cocos2d-x 的坐标系原点在左下角,而棋盘习惯从左上角开始数行。这个符号搞反是新手最常见的翻车点之一,表现就是整个棋盘上下颠倒。syncWithModel里新方块的起始位置故意设在r - ROWS,也就是棋盘上方一屏之外,配合MoveTo就有了“从顶部掉下来”的观感,不需要额外写一套掉落动画逻辑。

3.3 状态机:把输入锁在正确的时机

三消最怕的是玩家在动画播放期间连续点击,导致逻辑在中间态上执行。解决办法是一个简单的状态机,游戏场景只认几个状态:空闲、交换中、消除中、下落中、结算中。只有空闲态接受触摸输入,其余状态一律忽略。

enum class GameState { IDLE, SWAPPING, CLEARING, FALLING, SETTLING }; void GameScene::onTouchEnded(Touch* touch, Event* event) { if (_state != GameState::IDLE) return; // 非空闲态直接丢弃输入 auto cell = posToCell(touch->getLocation()); if (!_boardLayer->isValidCell(cell)) return; if (!_selected.valid) { _selected = cell; _boardLayer->highlight(cell, true); return; } if (isAdjacent(_selected, cell)) { _state = GameState::SWAPPING; _boardLayer->animateSwap(_selected, cell, [this, cell]() { bool ok = _model->swapAndCheck(_selected.row, _selected.col, cell.row, cell.col); if (!ok) { _boardLayer->animateSwap(cell, _selected, [this]() { _state = GameState::IDLE; // 无效交换,回弹后解锁 }); } else { this->resolveBoard(); // 进入消除结算流程 } }); _boardLayer->highlight(_selected, false); _selected = Cell(); } else { // 非相邻,改选 _boardLayer->highlight(_selected, false); _selected = cell; _boardLayer->highlight(cell, true); } }

这段代码的关键在回调里改状态。交换动画播完才去问数据层“这次交换有效吗”,无效就反向再播一次动画,播完把状态改回IDLE。有效则进入resolveBoard,由它负责循环“找匹配、播消除、下落、再找匹配”,直到没有新的匹配为止,最后把状态改回IDLE。整个流程里状态只在动画回调中推进,不会出现逻辑跑在动画前面的情况。

4. 核心模块实现:匹配检测、连锁下落与连击结算

4.1 匹配检测:横竖扫描加去重

匹配检测的朴素做法是对每个格子向左右和上下各延伸,收集连续同色的坐标。要注意两个问题:一是同一批消除里横竖交叉的格子会被重复收集,需要去重;二是四连、五连的识别要基于连续长度,不能只看“有没有三个”。

std::vector<std::pair<int,int>> BoardModel::findMatches() const { std::vector<std::pair<int,int>> result; std::vector<std::vector<bool>> marked(ROWS, std::vector<bool>(COLS, false)); // 横向扫描 for (int r = 0; r < ROWS; ++r) { int runStart = 0; for (int c = 1; c <= COLS; ++c) { bool same = (c < COLS) && grid[r][c] != CandyType::NONE && grid[r][c] == grid[r][runStart]; if (!same) { int len = c - runStart; if (len >= 3) { for (int k = runStart; k < c; ++k) marked[r][k] = true; } runStart = c; } } } // 纵向扫描,逻辑对称 for (int c = 0; c < COLS; ++c) { int runStart = 0; for (int r = 1; r <= ROWS; ++r) { bool same = (r < ROWS) && grid[r][c] != CandyType::NONE && grid[r][c] == grid[runStart][c]; if (!same) { int len = r - runStart; if (len >= 3) { for (int k = runStart; k < r; ++k) marked[k][c] = true; } runStart = r; } } } for (int r = 0; r < ROWS; ++r) for (int c = 0; c < COLS; ++c) if (marked[r][c]) result.emplace_back(r, c); return result; }

用marked布尔矩阵做去重,横竖两轮扫描各自标记,最后统一收集,交叉点只会出现一次。runStart的推进方式保证了连续段被完整识别,长度大于等于 3 就整段标记,四连五连自然被覆盖。如果后面要做特殊糖果,只需要在这里额外记录每段的长度和方向,把信息传给生成逻辑即可,扫描框架不用动。

4.2 连锁下落:逐列压缩加顶部补位

消除之后的下落,本质是对每一列做一次“压缩”:把非空元素按顺序往下堆,空出来的顶部用新随机类型填满。逐列处理比全局处理简单得多,也不容易出错。

void BoardModel::applyGravity() { for (int c = 0; c < COLS; ++c) { int writeRow = ROWS - 1; // 从底部往上写 for (int r = ROWS - 1; r >= 0; --r) { if (grid[r][c] != CandyType::NONE) { if (r != writeRow) { grid[writeRow][c] = grid[r][c]; grid[r][c] = CandyType::NONE; } --writeRow; } } // writeRow 以上全是空位,用新方块填满 for (int r = writeRow; r >= 0; --r) { grid[r][c] = randomType(); } } }

writeRow是“下一个非空方块应该落到的位置”,从底往上遍历,遇到非空就搬到writeRow并下移写指针。遍历结束后writeRow及其上方全是空位,统一补新类型。这个写法的时间复杂度是每列 O(ROWS),64 个格子一次下落不到百次操作,性能上完全不用担心。

表现层配合这个数据变化时,syncWithModel会发现某些格子的类型变了、某些格子从NONE变成了新类型,于是分别触发“换贴图”和“从上方掉入”两种动画。因为数据层已经一次性算好了最终状态,表现层只需要把差异演出来,不需要自己再算一遍下落路径。

4.3 连击结算:循环直到稳定

一次交换可能引发多轮消除:第一轮消掉后下落,落下来的方块又凑成三连,继续消。结算逻辑就是一个循环,每轮找匹配、消除、下落,直到findMatches返回空。

void GameScene::resolveBoard() { _state = GameState::CLEARING; int combo = 0; std::function<void()> step = [this, &combo, step]() { auto matches = _model->findMatches(); if (matches.empty()) { _state = GameState::IDLE; _comboLabel->setString(StringUtils::format("连击 x%d", combo)); return; } ++combo; _score += matches.size() * 10 * combo; // 连击倍率 _model->removeMatches(matches); _boardLayer->playClearEffect(matches, [this, step]() { _model->applyGravity(); _boardLayer->syncWithModel(); _state = GameState::FALLING; this->scheduleOnce([step](float) { step(); }, 0.25f, "resolve_step"); }); }; step(); }

combo在每一轮消除时自增,得分按消除数 × 基础分 × 连击数累计,这是三消里最直观的激励设计。scheduleOnce的 0.25 秒是给下落动画留的缓冲,实际项目里应该和MoveTo的时长对齐,否则会出现动画还没播完就开始下一轮检测的错位。std::function递归捕获自身需要小心,这里用值捕获step在 C++14 之后是可行的,如果工程用的是更老的编译器,改成一个成员函数加定时器重入更稳妥。

5. 避坑与排查:三消项目里最容易翻车的五个点

5.1 棋盘上下颠倒或左右镜像

现象是进游戏后整个棋盘方向不对,或者点击的格子和实际选中的格子错位。原因几乎都是坐标换算时行号或列号的符号搞反,cocos2d-x 原点在左下角,而棋盘逻辑习惯从左上角数行,cellToPos里行方向必须取负。解决办法是把cellToPos和posToCell写成严格互逆的一对函数,并在初始化时用几个固定格子做断言校验,比如断言posToCell(cellToPos(0,0))等于(0,0),跑一次就能定位。

5.2 消除后方块悬空不落

现象是消掉一批方块后,上面的方块停在原地,下面留着空洞。原因通常是数据层清空用了NONE,但表现层同步时只处理了“类型变化”,没处理“从NONE变成新类型”的情况,或者applyGravity的写指针方向写反了。排查方法是先在数据层打印每次下落前后的二维数组,确认压缩逻辑本身正确,再去查syncWithModel里新节点的创建分支有没有被走到。数据层对了,问题一定在表现层。

5.3 动画期间连点导致状态错乱

现象是快速连点后棋盘出现重复消除、分数暴涨或者节点错位。根因是输入没有和动画状态同步,逻辑在中间态上被执行了两次。解决办法就是第 3 章那套状态机,所有触摸入口先判_state == IDLE,状态只在动画完成回调里推进。这里有个血泪经验:不要用isRunning之类的动作状态去反推逻辑状态,动作系统和逻辑状态是两回事,混用迟早出问题。

5.4 节点泄漏与野指针崩溃

现象是玩几分钟后内存持续上涨,或者偶发崩溃在TileSprite的析构附近。cocos2d-x 的节点是引用计数管理,addChild会 retain,removeFromParent会 release,但如果自己用裸指针数组持有节点又没在节点销毁时置空,就会留下野指针。解决办法是_tiles数组在节点被移除时同步置nullptr,并且不要在别处额外 retain 这些节点。用CC_SAFE_RELEASE_NULL之类的宏处理自己持有的引用,养成“谁 retain 谁 release”的习惯。

5.5 初始棋盘自带三连

现象是玩家一进游戏就看到方块在自动消除,或者开局分数不为零。原因是reset()逐格随机时没有做前置检查。解决办法是生成每一格时检查左边两个和上边两个是否同色,同色就重摇,设一个重摇上限(比如 10 次),超过上限就换一个确定不同的类型兜底。这个检查成本很低,但漏掉的话演示第一眼就露馅。

6. 验收与答辩:怎么证明这个三消是“对的”

验收三消项目,我一般不看画面多花哨,先看三条硬指标能不能稳定复现。第一条,随机做 50 次相邻交换,统计有效交换和无效交换的比例,无效交换必须全部正确回弹且棋盘状态不变,有效交换必须全部产生至少一组消除。第二条,构造一个已知会触发连锁的棋盘(比如手动摆一个消除后能落下成三连的布局),验证连击计数和分数倍率是否按预期累加。第三条,连续玩 5 分钟,观察内存曲线是否平稳、有无崩溃,这一条能筛掉大部分节点管理问题。

答辩时最容易被问的是“你的数据和表现是怎么分离的”。这时候不要泛泛而谈分层,直接打开BoardModel和BoardLayer两个文件,指出findMatches返回坐标列表、syncWithModel根据差异驱动动画,说明数据层可以脱离引擎单独测试。第二个高频问题是“连锁消除怎么保证不死循环”,回答要点是每轮消除后必然有格子被清空并补充新类型,匹配集合单调收敛,加上findMatches为空即退出,循环一定终止。如果时间允许,现场改一个参数演示,比如把棋盘从 8×8 改成 6×6,只改ROWS和COLS两个常量,其余代码不动就能跑,这比任何口头解释都有说服力。

下面这张表可以贴在答辩 PPT 的验收页上,逐项打勾。

验收项判定方法通过标准
交换有效性随机 50 次交换无效交换 100% 回弹,状态不变
连锁结算预设连锁棋盘连击数与分数倍率一致
棋盘完整性任意时刻检查无空位、无悬空块
稳定性连续运行 5 分钟内存平稳、无崩溃
可配置性修改棋盘尺寸常量不改其他代码即可运行

从那以后我每次带这类小项目,都会在动手写表现层之前先把数据层的单元测试跑通,棋盘逻辑对了再谈动画,返工次数能少一大半。希望这套结构能帮你把作业或者方案顺利交出去。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进