☰
cocos2d-x三消游戏开发实战:棋盘算法、状态同步与关卡配置
2026/11/6 6:47:47 网站建设 项目流程

简介:这是一份面向 cocos2d-x 初学者与独立开发者的糖果消除游戏实战工程,围绕经典三消玩法提供可运行的 Cocos2d-x 项目参考,适合学习棋盘布局、糖果交换、匹配消除、计分与关卡状态组织等核心模块。压缩包共 43 个文件,约 3.46MB,包含 7 个 cpp 与 8 个头文件构成的 GameScene、GameBox、Tile 等源码模块,8 张 png 图片、4 个 ttf 字体与 4 个 plist 配置资源,并附带 5 个 dll 和 exe,便于在 Windows 环境直接查看运行效果。已有 210 人浏览学习,工程目录涵盖 Classes、Resources 与 proj.win32,适合按场景层、棋盘逻辑和资源管理逐块阅读,也可作为二次开发与版本迁移的对照样例。

1. 从一份三消课设需求说起:为什么选 cocos2d-x 而不是重型引擎

「手机上能玩、有消除动画、有关卡、有分数排行」——这是一份 candy crush 类课设最朴素的需求描述。很多人第一反应是上重型引擎,实际做下来会发现,三消的复杂度根本不在渲染,而在棋盘逻辑、状态同步和动画时序;这恰好是 cocos2d-x 最舒服的区间。Sprite 合批渲染、Action 动画系统、触摸事件分发都是开箱可用,一套 C++ 代码同时出 Android、iOS 和桌面端调试窗口,改一行数值不用等半分钟编译打包。

这套基于 cocos2d-x 的 candy crush 复刻方案,覆盖 8×8 棋盘的数据结构、横纵匹配检测、交换合法性与回弹、消除掉落的时序编排、关卡 JSON 配置、特殊糖果规则、死局检测与洗牌,最后落到一份可照着走的验收清单和十分钟演示脚本。适合要交三消类课程设计、毕业设计的同学,也适合想拿一个能跑完整闭环的小游戏项目改造成自己作品的开发者参考。

2. 棋盘数据结构与匹配算法:8×8 网格上的数据与表现分离

三消项目做到一半崩掉,十有八九是数据层和表现层搅在一起:Sprite 的位置就是游戏状态,删了个节点棋盘就错位。所以这套方案的第一条铁律是——棋盘只认整数数组,Sprite 只是它的投影。

2.1 用定长二维数组存棋盘,把 Sprite 挡在数据层之外

棋盘用 8 列 8 行的定长数组,每个格子存一个整数 typeId,-1 表示空位。不用std::vector<std::vector<int>>,因为棋盘尺寸在关卡配置里就定死了,动态扩容只会带来引用失效的麻烦。坐标约定 row 0 在最下方,和 cocos2d-x 的 y 轴向上一致,能省掉一次翻转,这类看着不起眼的约定,后面每写一处坐标转换都要还债。

// Board.h —— 棋盘只负责数据,不持有任何 Sprite class Board { public: static const int COLS = 8; static const int ROWS = 8; static const int TYPE_NONE = -1; // 空位 static const int TYPE_COUNT = 6; // 六种基础糖果 void reset(unsigned seed); // 铺满,且保证开局没有现成三连 int at(int col, int row) const; // row 0 在最下方 void setAt(int col, int row, int type); bool inRange(int col, int row) const; void swapCells(const cocos2d::Vec2& a, const cocos2d::Vec2& b); private: int _grid[COLS][ROWS]; };

reset里的seed不是摆设:随机铺盘时必须用带种子的伪随机,否则测试时同一个 bug 复现不了,只能靠运气。铺盘还要做一次「去三连」处理——从 (0,0) 开始逐格赋值,赋值时排除掉左边两个和下面两个已经同色的类型,从根源上保证开局稳定。

表现层单独维护一份cocos2d::Sprite* _sprites[COLS][ROWS],通过 typeId 查帧名。数据变了不直接动 Sprite,而是发一个「某格从 A 变成 B」的事件,由表现层决定播不播动画。这条隔离做完,后面写「撤销一步」「重开本关」「录像回放」都只是重放事件流的事。

2.2 横纵扫描找三连:一次遍历拿到所有匹配组

匹配检测的输入是棋盘,输出是一组「匹配组」,每组包含若干格子和一个形状标记。形状标记决定了后面生成什么特殊糖果:三连是普通消除,四连出条纹,五连出彩色炸弹,L 形和 T 形出包装糖。

struct MatchGroup { std::vector<cocos2d::Vec2> cells; // 本次消除涉及的格子 int shape; // 3/4/5=直线长度, 6=L 或 T 形 }; // 扫描横向与纵向连续同色段,长度 >= 3 记为一次匹配 std::vector<MatchGroup> Board::findMatches() const { std::vector<MatchGroup> result; for (int r = 0; r < ROWS; ++r) { // 横向扫描 int c = 0; while (c < COLS) { int t = at(c, r); int run = 1; while (c + run < COLS && t != TYPE_NONE && at(c + run, r) == t) ++run; if (t != TYPE_NONE && run >= 3) { MatchGroup g; g.shape = run; for (int k = 0; k < run; ++k) g.cells.push_back(Vec2(c + k, r)); result.push_back(g); } c += run; // 关键:跳过整段,避免重复计入 } } // 纵向扫描结构完全对称,此处略 mergeIntersections(result); // 共享格子的横纵组并成 L/T 形 return result; }

这里最容易写错的是c += run。如果写成++c,一段五连会被算成三个三连,分数直接翻三倍,而且特殊糖果会生成一堆——这种 bug 在自测时往往看不出来,因为画面表现是对的,只有分数对不上。

mergeIntersections做的事很朴素:两组如果共享至少一个格子,就合并成一组,形状标为 L/T。合并顺序会影响特殊糖果的生成位置,通常约定以交换发生的那一格作为生成点,没有交换信息时取两组的交叉格。这个约定要提前定死,否则同一局面在不同机器上跑出不同结果,联调时会怀疑人生。

复杂度是 O(行 × 列),8×8 下等于 64 次比较,随便扫。不要为了炫技去上并查集或者位运算压缩,那是给 64×64 棋盘准备的。

2.3 消除、下落、补充的时序:一次结算要跑完整条链

一次玩家交换引发的结算不是「消一次就完事」,而是「消除 → 下落 → 顶部补充 → 再检测」的循环,直到棋盘上再也找不到三连为止,这叫级联(cascade)。级联层数直接影响连击倍率,也是三消爽感的核心来源。

// 一次完整结算:消除 -> 下落 -> 补充 -> 再检测 void GameScene::resolveBoard() { int cascade = 0; while (true) { auto groups = _board.findMatches(); if (groups.empty()) break; cascade++; // 1) 数据层一次性算完:清空格、记录每颗糖果的落差 auto drops = _board.clearAndCollapse(groups); // 返回 {col, fromRow, toRow} _score += scoreOf(groups, cascade); // 倍率 1, 1.5, 2, 2.5 ... // 2) 表现层按落差播放,行数越多延迟越长 playRemoveAnim(groups); playDropAnim(drops, 0.08f); // 每行错峰 0.08 秒 playRefillAnim(_board.refillTop()); waitForAnimations(0.28f); // 与最长那条动画对齐 } checkDeadLock(); // 结算完立刻问一句:还有解吗 }

clearAndCollapse是纯数据操作:把匹配格置 -1,然后每一列从下往上做一次「压实」,同时把每颗糖果的 fromRow 和 toRow 记进 drops 返回。这样表现层拿到的就是一份完整的位移清单,播放时只负责按清单跑 Action,完全不需要回头查棋盘——数据层和表现层的边界就是靠这个返回值划清楚的。

上面写的是同步形式,落到 cocos2d-x 里不能真的阻塞主线程。实际有两种写法:用scheduleOnce把每一阶段串成回调链,或者用一个小型协程把waitForAnimations挂起。两种都行,重点是保证「同一时刻只有一个结算流程在跑」,这一条比用哪种写法重要得多。

提示:0.08f这个错峰间隔是调整出来的,不是拍脑袋。间隔太小视觉上像瞬移,太大整局节奏拖沓,实测在 60fps 下 0.06 到 0.1 之间手感最好,低端机可以把间隔乘 1.5 保证动画不丢帧。

3. 交换交互与动画编排:把触摸、动作和状态锁串成一条线

玩家在三消里只有两个动作:选中和交换。但这两个动作引发的连锁反应是所有 bug 的来源——手指还在滑,上一轮的级联还没播完,一个手快的连点就能把棋盘搞成一团乱麻。

3.1 从触摸点到格子索引:一次坐标转换定生死

触摸事件拿到的是屏幕坐标,要先转到棋盘节点的局部坐标,再除以单格尺寸取整。转换用的节点必须是棋盘容器节点本身,不是场景根节点,也不是某个 Sprite——用错节点,在多分辨率适配下会出现「点得到但选不中」的偏移,而且是那种小屏正常大屏偏一半的偏移。

bool GameScene::onTouchBegan(Touch* t, Event* e) { if (_state != GameState::Idle) return false; // 结算期间直接吞掉输入 Vec2 p = _boardNode->convertToNodeSpace(t->getLocation()); int col = static_cast<int>(p.x / TILE_SIZE); int row = static_cast<int>(p.y / TILE_SIZE); if (!_board.inRange(col, row)) return false; // 点在棋盘外,交给别的层处理 _pickedCell = Vec2(col, row); _touchStartPos = t->getLocation(); return true; }

TILE_SIZE是设计分辨率下的单格边长,比如 8 格排满 640 宽度就是 80。棋盘节点本身会随屏幕做缩放,convertToNodeSpace已经把缩放反算掉了,所以这里除的永远是设计值,不要再去乘什么屏幕缩放系数——重复缩放是坐标偏移的第一大来源。

onTouchBegan返回 false 表示这次触摸不归我管,事件会继续往下传。棋盘外区域留给 UI 按钮,别在这里直接 return true 把整个屏幕吃掉。

3.2 交换合法性判定与非法回弹

三消的交换有两层合法性:位置合法(必须是四邻,斜着不算)和结果合法(交换后必须能形成三连,否则要弹回去)。第二层判定要做到「先改数据、跑一次匹配、没结果就还原」,全程不碰 Sprite。

void GameScene::trySwap(const Vec2& a, const Vec2& b) { // 只允许上下左右相邻,曼哈顿距离为 1 if (std::abs(a.x - b.x) + std::abs(a.y - b.y) != 1) return; _state = GameState::Swapping; _board.swapCells(a, b); auto groups = _board.findMatches(); if (groups.empty()) { _board.swapCells(a, b); // 先还原数据 playSwapBackAnim(a, b); // 再播回弹动画 _state = GameState::Idle; return; } playSwapAnim(a, b, [this]() { // 动画播完才进入结算 _state = GameState::Resolving; resolveBoard(); }); }

注意回弹分支里数据先还原、动画后播。如果反过来,动画还在半路上数据已经换位,玩家在动画期间再点一次就会基于错误状态判断。顺序是「数据即时生效,表现延迟跟随」,所有交互逻辑都该守这条。

playSwapAnim用MoveTo加EaseBackOut就行,两个 Sprite 同时跑,回调里接结算。别给它加Sequence套DelayTime,延迟应该由状态机管,不该藏在动画里。

3.3 用状态机锁住输入:结算期间只认一个「忙」字

玩家手速永远超过动画时长。级联三层的时候,一次结算可能持续一秒多,这一秒里所有触摸都应该被吞掉。最省事也最可靠的办法是状态机加输入门禁。

enum class GameState { Idle, // 等待玩家操作 Swapping, // 交换动画中 Resolving, // 消除/下落/补充结算中 Shuffling, // 死局洗牌中 GameOver // 步数耗尽或目标达成 }; // 在 update 里兜底,防止回调链断裂导致状态卡死 void GameScene::update(float dt) { if (_state != GameState::Idle && _stateTimer > MAX_STATE_DURATION) { CCLOG("state %d timeout, force to Idle", (int)_state); forceSettleToIdle(); // 强行结算到稳定态,宁可丢一次动画也不锁死 } }

MAX_STATE_DURATION通常设成 6 到 8 秒。它是黑匣子式的保护措施:回调链理论上不会断,但一旦断了(比如某个 Sprite 被提前 remove 掉,Action 回调不触发),没有这层兜底玩家就彻底卡死,只能重启游戏。

状态切换建议集中在一两个函数里做,别散落在各处_state = ...。散着写的代码在加了「暂停」「复活」「关卡切换」之后会变成灾难,谁都说不清某个时刻到底处于什么状态。

4. 关卡配置与特殊糖果:把数值搬出代码,把死局挡住

做到第十关你会发现,步数、目标分数、障碍血量、糖果种类数全在代码里硬编码,每改一次数值就要重新编译一次。这一章解决两件事:数值外置和死局兜底。

4.1 关卡配置表设计:JSON 驱动步数、目标与障碍

关卡配置用 JSON,一个文件一关,或者一个文件里放一个数组。cocos2d-x 自带 rapidjson,解析成本很低,关键在于字段设计要能覆盖后面加需求:目标类型、障碍类型、星级评分线,这三样一定会加。

{ "level": 7, "moves": 25, "colors": 6, "targets": [ { "type": "score", "value": 12000 }, { "type": "collect", "color": 2, "count": 30 } ], "obstacles": [ { "kind": "ice", "cells": [[3,3],[3,4],[4,3],[4,4]], "hp": 2 } ], "starScores": [8000, 12000, 16000] }

moves是步数上限,也是失败条件;colors控制本关参与随机的基础糖果种类数,值是 5 的时候难度明显高于 6,这是调难度最省事的旋钮,比调目标分数有效;targets做成数组是留扩展位,一关可以同时要求分数和收集指定颜色;obstacles的 cells 用 [col, row] 二元组,和棋盘坐标约定保持一致,hp 表示需要相邻消除几次才能破。

解析代码只做一件事——把 JSON 翻译成一个LevelConfig结构体,不做任何规则判断:

bool LevelConfig::loadFromFile(const std::string& path) { auto content = FileUtils::getInstance()->getStringFromFile(path); rapidjson::Document doc; if (doc.Parse(content.c_str()).HasParseError()) return false; // 解析失败要能报出关卡号 _moves = doc["moves"].GetInt(); _colors = doc["colors"].GetInt(); for (auto& t : doc["targets"].GetArray()) { Target tg; tg.type = t["type"].GetString(); tg.value = t.HasMember("value") ? t["value"].GetInt() : 0; _targets.push_back(tg); } return true; }

colors要做一次下限保护,小于 4 的时候随机铺盘极其容易开局就死,玩家会以为是 bug。加一条if (_colors < 4) _colors = 4;的重试逻辑,比在配置文件里手写注释可靠。

4.2 特殊糖果的生成与组合规则

特殊糖果是三消的乐趣来源,但规则一旦不明确,实现就会乱成一锅粥。方案里把规则收敛成一张表,代码只查表不发挥。

匹配形状生成物触发效果生成位置
三连直线无直接消除—
四连直线条纹糖消整行或整列四连中间两格之一
五连直线彩色炸弹消掉全场某一种颜色五连中点
L 形 / T 形包装糖3×3 范围爆破交叉格
条纹 + 条纹十字条纹同时消一行一列被交换那格
彩色炸弹 + 任意全场同色消除该色全部被交换那格

表里的「被交换那格」很关键:组合消除时生成位置决定了新生成的特殊糖果落在哪,如果取错格子,玩家会觉得自己的操作没被识别。

生成逻辑放在结算流程的开头,findMatches之后、clearAndCollapse之前:

// 按形状给匹配组挂上特殊糖果,返回需要在结算后落位的格子 std::vector<SpecialSpawn> Board::planSpecials( const std::vector<MatchGroup>& groups, const Vec2& swapCell) { std::vector<SpecialSpawn> spawns; for (const auto& g : groups) { if (g.shape == 4) spawns.push_back({ pickMid(g.cells), Special::STRIPE }); else if (g.shape == 5) spawns.push_back({ g.cells[2], Special::BOMB }); else if (g.shape == 6) spawns.push_back({ crossCell(g), Special::WRAP }); } if (spawns.size() >= 2) spawns = mergeCombo(spawns, swapCell); // 双特殊合并 return spawns; }

mergeCombo处理两个特殊糖果同时生成的情况,把它们合并成更强的组合效果。这一段逻辑最容易写漏——漏了不会崩,只是玩家凑出两个四连却只得到一个条纹糖,体验上像是「游戏吞了我的操作」,而且没人会写 bug 单,只会说手感不好。

4.3 死局检测与洗牌:每次结算后都要问一句「还有解吗」

三消最容易翻车的地方是死局:棋盘上没有任何一步交换能形成三连,玩家点哪儿都没反应。测试同学不一定能撞上,但关卡数值调紧之后出现的概率会显著上升。

检测方法是遍历所有相邻对,试换一次跑匹配,再换回来——全程在拷贝上做,不污染真实棋盘:

bool Board::hasValidMove() const { for (int c = 0; c < COLS; ++c) { for (int r = 0; r < ROWS; ++r) { if (c + 1 < COLS && testSwapOnCopy(c, r, c + 1, r)) return true; if (r + 1 < ROWS && testSwapOnCopy(c, r, c, r + 1)) return true; } } return false; } // 在局部拷贝上交换并检测,原件不动 bool Board::testSwapOnCopy(int c1, int r1, int c2, int r2) const { Board copy = *this; copy.swapCells(Vec2(c1, r1), Vec2(c2, r2)); return !copy.findMatches().empty(); }

调用时机只有一处:resolveBoard的 while 循环退出之后。不要在每次消除后都调用,那是 64 次拷贝乘以级联层数,纯浪费。检测到无解就洗牌——把所有非空格的 typeId 收集起来打乱重铺,然后重新检测一遍有没有意外形成三连,有的话再洗一次,循环上限设 20 次,超了就直接重建棋盘。

洗牌不要「就地随机交换几对」,那样可能洗出新的死局。整套重铺虽然动静大,但正确性容易保证,8×8 重铺一次的开销也就是几十微秒。

注意:洗牌之后要重置玩家可能存在的选中状态,否则玩家洗牌前选中的格子会指向一颗完全不同的糖果,下一次交换判定会莫名其妙地失败。

5. 避坑与排查:五类最容易翻车的问题

以下五条都是实际会遇到、而且从画面上看不出病因的问题。每条按「现象 → 原因 → 解决」写,遇到时可以直接对照。

5.1 逻辑与表现不同步导致的三类现象

第一类:消除之后棋子位置和数组对不上。现象是有些格子看起来是红糖果,点下去消掉的却是隔壁那个,二次交换后整盘错位。原因通常是「数据先改一格、动画回调里再改一格」,两边节奏不一致,中途被别的流程打断就永久错位。解决思路是把结算拆成严格的两段:数据层一次算完,产出完整的位移清单;表现层只消费清单,不回头查棋盘,也不在动画回调里修改数组。

第二类:快速连点导致重复消除、分数翻倍。现象是同一处被消两次,分数涨得离谱,偶尔还伴随数组越界崩溃。原因是onTouchBegan没有状态门禁,玩家在一个结算周期里点下了两三次,两次resolveBoard交错执行。解决就是第 3.3 节的状态机,加上update里的超时兜底,双重保证。

第三类:非法交换后棋盘多出一对同色糖果。现象是回弹之后棋盘上莫名其妙出现三连,但玩家没操作。原因是回弹分支里数据还原和动画播放顺序反了,或者swapCells被调用了两次——第一次在trySwap开头,第二次在回弹,成了「换过去又换回来再换一次」,净效果等于没换,但中途检测逻辑读到的是脏状态。解决是让swapCells只有一个入口,且在回弹分支里严格只调一次。

第四类:特殊糖果的组合效果没触发。现象是玩家凑出两个条纹糖交换,只炸了一条线。原因就是 4.2 节说的mergeCombo写漏,或者判定顺序把组合情况当成了普通情况先处理掉了。解决是把组合判定提到形状判定之前,先看这次交换的两个格子是不是都带特殊标记。

5.2 性能与适配:卡顿、偏移和内存

卡顿。现象是中低端机上五连级的级联播起来掉帧,触摸响应变得粘手。原因是同时存在的 Action 和粒子特效太多,每个消除都单独创建一套粒子节点,一波级联下来几百个节点在跑。解决办法是对象池加限流:粒子节点一次性预创建 30 个,用完归还;同屏同时播放的消除动画上限设成 40 个,超出部分直接跳过动画只更新数据——玩家感知不到少了几个特效,但帧率能稳住。

// 简易对象池:预创建、借用、归还,避免频繁 new/delete class ParticlePool { public: void prewarm(int n) { // 场景加载时一次性创建 for (int i = 0; i < n; ++i) _idle.push_back(createNode()); } Node* acquire() { if (_idle.empty()) return nullptr; // 池空直接放弃本特效,不扩容 Node* n = _idle.back(); _idle.pop_back(); return n; } void release(Node* n) { n->removeAllChildren(); _idle.push_back(n); } private: std::vector<Node*> _idle; };

acquire在池空时返回 nullptr 而不是扩容,这是故意的:特效只是锦上添花,宁可少播一个也不能在战斗中分配内存。

触摸偏移。现象是高分屏上点击位置和选中格子差半格,模拟器正常真机不正常。原因是用了场景根节点做convertToNodeSpace,而棋盘容器做了缩放或偏移。解决是统一用棋盘容器节点转换,并且把TILE_SIZE固定成设计分辨率下的值。

内存。现象是切关卡几次之后内存持续上涨。原因是关卡切换时只removeAllChildren,没有释放纹理和配置对象。解决是关卡资源按「引用计数 + 显式释放」管理,切关时打印一次当前纹理缓存数量,涨了就是漏了。

6. 验收与答辩:一份能跑通的检查清单和十分钟演示脚本

自己觉得做完了和能过验收,中间隔着一段距离。我一般会按下面这张表跑一遍,全过再谈优化。

验收项判定标准常见不通过原因
开局稳定连续重开 30 次,无开局三连、无死局铺盘没做去三连
交换判定斜向、隔格、棋盘外点击均无响应曼哈顿距离判断缺失
非法回弹20 次无效交换,棋盘状态零变化数据还原顺序错误
级联正确人为构造五连级联,分数倍率与预期一致扫描步进写成 ++c
特殊糖果四种形状各触发一次,效果符合规则表组合合并逻辑写漏
死局兜底构造无解盘面,1 秒内自动洗牌只在初始化检测
输入门禁结算期间狂点 10 次,无异常无状态锁
关卡配置改 JSON 数值不重新编译即生效数值硬编码在代码里
跨端运行桌面窗口与真机各跑三关坐标转换节点用错

答辩演示最忌讳的是「边玩边讲」,讲到一半自己卡在死局上,场面很难收。十分钟的节奏我一般这么排:

前两分钟讲需求与选型,一句话说清为什么用 cocos2d-x——2D 批渲染和 Action 系统够用,双端一套代码。中间四分钟做三件事:开一局正常玩到级联,让评委看到连击倍率;故意做一次非法交换,展示回弹;打开关卡 JSON 改一个数字,重启后数值立刻变化,这一下最能证明「配置驱动」。最后四分钟留一局构造好的残局,盘面接近死局,当场演示自动洗牌,然后展示验收清单表格。

被问到「最难的地方在哪」时,别答算法,答状态同步——数据层和表现层怎么划边界,这是这个项目里唯一有工程含量的话题,讲清楚了你和「照着教程抄了个三消」的人就区分开了。

我吃过一次亏:演示前五分钟临时改了个粒子参数,重新打包,结果真机上纹理没加载出来,一片白块。从那以后我每次演示前都强制走一遍完整清单,从冷启动开始,用干净安装包,跑满三关再上台。希望这套结构和清单能帮到你。

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

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

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

立即咨询