2026/10/29 11:24:18

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-x2.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::vectorstd::pairint,int findMatches() const; // 返回所有待消除坐标 void removeMatches(const std::vectorstd::pairint,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::vectorstd::pairint,int BoardModel::findMatches() const { std::vectorstd::pairint,int result; std::vectorstd::vectorbool marked(ROWS, std::vectorbool(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::functionvoid() 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在 C14 之后是可行的如果工程用的是更老的编译器改成一个成员函数加定时器重入更稳妥。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会 retainremoveFromParent会 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 分钟内存平稳、无崩溃可配置性修改棋盘尺寸常量不改其他代码即可运行从那以后我每次带这类小项目都会在动手写表现层之前先把数据层的单元测试跑通棋盘逻辑对了再谈动画返工次数能少一大半。希望这套结构能帮你把作业或者方案顺利交出去。本文还有配套的精品资源点击获取