简介:基于cocos2d-x引擎制作的糖果消除游戏完整工程,面向希望通过实际项目学习跨平台二维游戏开发的初学者和中级开发者。项目中已附带可在视窗系统下直接运行的执行程序以及对应的全套源代码,核心逻辑覆盖了随机糖果棋盘的生成、相邻糖果交换操作、三消或更多同色连通的判定、消除时动画特效的播放、剩余步数或分数统计以及关卡切换等常见玩法模块。压缩包一共包含四十三个文件,整体体积约三点四六兆字节,除了主要的工程文件和源码头文件之外,还收集了游戏所用到的界面贴图、属性列表配置、字体文件以及程序运行所需要的动态链接库,解压后即可在开发环境中打开并对照源码进行学习。目前这套资源已有两百零八人浏览学习,比较适合刚接触休闲游戏开发,又想弄清三消游戏背后实现逻辑的读者,既可以从头搭建相似玩法,也能在此基础上继续扩展特殊糖果、道具系统甚至联机对战等内容。
1. 一个用 cocos2d-x 写的 candy crush:把三消源码拆到能直接改玩法再上线的程度
用 cocos2d-x 写的 candy crush 三消玩法,核心亮点是整套可复现的“交换-匹配-消除-掉落”状态机。适合两类人:想把三消逻辑移植到自研引擎的开发者,和想在自己项目里加消除玩法但不想从零写算法的同事。它把糖果网格抽象成二维数据数组,用回溯式的匹配检测,不用看几百个文件才能跑起来,把核心的“棋盘”类提出来就能用。我拿到这套资源后,习惯是先跑效果,再一行行拆匹配与连锁逻辑,比对着文档啃效率高很多。三消玩法的爽感来自连锁,而这套代码的连锁判定写在数据层,调试时可以直接打印棋盘状态,非常直观。
2. 三消核心算法:从棋盘建模到匹配、消除、掉落的状态机闭环
2.1 棋盘建模:二维数组与精灵节点的对应关系
糖果消消乐的核心不是渲染,是棋盘模型。常见做法是把棋盘定义为一个 M×N 的二维数组,每个格子存一个糖果类型。经典布局是 8 行 8 列,初始用例 5 或 6 种类型,按算法随机填入,但必须保证初始局面没有现成的三连——否则玩家还没动手就开始连锁消除,关卡节奏直接失控。
这套源码在生成棋盘时用了洗牌式填充:每填一个格子都回看左侧两格和上方两格,发现三连就换一个类型再填。这样做的好处是生成结果可控,不会出现大面积的预置匹配。代价是生成效率略低,但 8×8 的棋盘规模下完全无感。
// BoardData.h enum CandyType { CandyNone = -1, CandyRed = 0, CandyBlue, CandyGreen, CandyYellow, CandyPurple, CandyOrange }; struct CandyData { CandyType type; int row; int col; bool markedForClear; CandyData() : type(CandyRed), row(0), col(0), markedForClear(false) {} }; class BoardData { public: BoardData(int rows, int cols); void generate(); void swap(int r1, int c1, int r2, int c2); std::vector<std::pair<int,int>> findMatches(); void clearMarked(); void applyGravity(); void refill(); private: bool hasMatchAt(int r, int c); int rows; int cols; std::vector<std::vector<CandyData>> grid; };逻辑说明:grid是棋盘的全部家底,swap只交换两个格子的 type,不直接动精灵节点。真正的视觉交换由上层控制层完成,棋盘层只负责数据一致。findMatches返回所有需要消除的格子坐标集合,applyGravity负责让糖果下落补齐空格,refill负责在顶部生成新糖果。数据层独立于引擎还有一个隐性好处:你能不依赖任何渲染代码给棋盘写单元测试,后面加障碍物、改关卡配置时,回归测试的成本会低很多。
参数说明:rows 和 cols 决定棋盘尺寸,难度高时可以调到 9×9 甚至 10×10,但要注意屏幕适配。糖果类型数量建议 5~7 种:类型太少容易频繁出现三消,玩家会觉得局面失控;类型太多则匹配难度偏高,消除节奏拖沓。这个枚举里用的是 6 种类型加一个空类型,实际调整枚举数量就能控制初始局面的匹配密度。
2.2 匹配检测:回溯式扫描与标记-消除解耦
三消检测的思路直接但容易写错:对每个格子,向右连续扫描同类型糖果,再向下连续扫描,统计连续数量。数量等于或多于 3 就标记。边界是最容易翻车的地方——扫描到最右侧或最下方时,连续段刚好结束在边界上,如果不做一次收尾判断,会漏掉一整行或一整列。
std::vector<std::pair<int,int>> BoardData::findMatches() { std::vector<std::pair<int,int>> matches; bool matched[8][8] = {false}; // 横向扫描,统计连续同色段 for (int r = 0; r < rows; r++) { int streak = 1; for (int c = 1; c < cols; c++) { if (grid[r][c].type == grid[r][c-1].type) { streak++; } else { if (streak >= 3) { for (int k = c - streak; k < c; k++) { matched[r][k] = true; } } streak = 1; } } // 处理行尾的连续段 if (streak >= 3) { for (int k = cols - streak; k < cols; k++) { matched[r][k] = true; } } } // 纵向扫描,统计连续同色段 for (int c = 0; c < cols; c++) { int streak = 1; for (int r = 1; r < rows; r++) { if (grid[r][c].type == grid[r-1][c].type) { streak++; } else { if (streak >= 3) { for (int k = r - streak; k < r; k++) { matched[k][c] = true; } } streak = 1; } } if (streak >= 3) { for (int k = rows - streak; k < rows; k++) { matched[k][c] = true; } } } for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (matched[r][c]) { matches.push_back({r, c}); } } } return matches; }逻辑说明:这里用了一个临时的matched二维布尔数组,先标定需要消除的格子,再统一转成坐标列表,而不是边扫边消除。边扫边消除会出大问题——某一行消除后格子空了,下一行扫描同一列时类型对不上,连锁判定直接乱掉。标记和消除分开,是这套源码里最值得抄的设计。注意,上面代码的bool matched[8][8]是默认尺寸写法,如果要通用化,应当改成vector<vector<bool>> matched(rows, vector<bool>(cols, false)),否则换棋盘尺寸时就崩了。
参数说明:streak是连续计数,大于等于 3 就触发标记。如果想做“只消除恰好 3 个”的变体玩法,把判断改成streak == 3即可;想支持 4 连、5 连触发特殊糖果,保留>=不变。主线玩法里>=是标准配置,因为 4 连和 5 连会生成特殊糖果,这一章后面会展开。
2.3 消除、下落与连锁:用循环把状态跑完整
消除和下落是紧密相连的两步。消除把格子清空,下落把上空格的糖果整体往下移,顶部生成新糖果。做完后必须重扫一次棋盘,看新补入的糖果是否形成新的三连——这就是连锁(连击)。连锁是得分弹跳的主要来源,也是新手容易写崩的地方。
void BoardData::clearMarked() { for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (grid[r][c].markedForClear) { grid[r][c].type = CandyNone; // 空类型,等待下落补位 grid[r][c].markedForClear = false; } } } } void BoardData::applyGravity() { for (int c = 0; c < cols; c++) { int writeRow = rows - 1; // 从底部向上扫描,把非空糖果搬到底部空位 for (int r = rows - 1; r >= 0; r--) { if (grid[r][c].type != CandyNone) { grid[writeRow][c].type = grid[r][c].type; if (writeRow != r) { grid[r][c].type = CandyNone; } writeRow--; } } } } void BoardData::refill() { for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (grid[r][c].type == CandyNone) { // 随机生成一种糖果类型 grid[r][c].type = (CandyType)(rand() % (CandyOrange + 1)); } } } }逻辑说明:applyGravity的下落通过“从底部向上扫描、把非空格子往底部空位搬”的方式实现,没有复杂的下落速度模拟,数据层瞬间完成。refill只在所有下落结束时调用,生成随机类型。为了防止新生成糖果立刻形成三连,有些实现会在 refill 后做一次 findMatches 检查,发现匹配就重生成该格,直到没有匹配为止。这套源码里没做这层强校验,改为在 generate 阶段兜底。
参数说明:CandyNone是特殊空类型,不能是玩家可见的糖果。不要用CandyRed充当“空”,否则下落逻辑会把所有红色糖果当成空处理。随机范围CandyOrange + 1是类型数量,后续增加新糖果类型时,这里要跟着加,最好直接用kCandyTypeCount这类常量化写法,免得枚举改了这里忘了改。
连锁判定的入口通常长这样:交换两个糖果 → 检测 → 有匹配就消除 → 下落 → 补位 → 再检测,直到某一次检测找不到匹配,局面才稳定。新手容易把这里的循环写成“先消除再检测”,漏掉“消除后糖果还没下落就再次检测”的时序,导致连锁判定永远少算一轮。
bool BoardLayer::executeResolveChain() { bool anyMatch = true; int comboCount = 0; while (anyMatch) { auto matches = board->findMatches(); if (matches.empty()) { anyMatch = false; } else { comboCount++; board->markMatches(matches); board->clearMarked(); board->applyGravity(); board->refill(); // 动画与数据分开,先同步数据,再刷新精灵位置 refreshCandySprites(); // 连锁分数:第一次 +50,第二次 +100,第三次 +200 scoreSystem->addComboScore(comboCount, matches.size()); } } return comboCount > 0; }逻辑说明:executeResolveChain是整套消除流程的主入口。每轮先找匹配,没有匹配就退出;有匹配就计数、标记、消除、下落、补位、刷新精灵,然后进入下一轮。comboCount 每轮加 1,分数结算可以按“第一次消除 50 分、第二次 100 分、第三次 200 分”这种递进规则来做,不需要额外的状态机。要注意,刷新精灵位置必须等数据层全部完成后再做,否则玩家会看到糖果下落到一半、新糖果已经出现的割裂画面。
参数说明:连锁分数的递增倍率是控制数值爽感的关键。我一般用底数 50、倍率 2 的指数递增,最多触发 5 次连锁,否则分数膨胀太快;也可以用线性递增,适合更休闲的玩法。如果棋盘上加了障碍物,这里的 refill 要改成带权重生成的版本,先预置障碍物坐标和解锁条件,再决定新糖果能否落在障碍物上。
3. 把触控和动画做对:三消手感从数据到画面的最后一公里
3.1 触摸事件的坐标换算:从屏幕点到棋盘格的映射
数据层写得再好,触摸换算不对,玩家照样觉得“手感差”。三消的操作手感,很大程度在这个换算公式上:触摸点是屏幕坐标,棋盘数据是行列号。核心公式是row = (touchY - originY) / cellSize、col = (touchX - originX) / cellSize,但 origin 的取值才是翻车高发区——它到底是棋盘左下角还是中心,取决于节点锚点,没有统一约定前不要写换算函数。
bool GameScene::onTouchBegan(Touch* touch, Event* event) { auto pos = touch->getLocation(); auto boardOrigin = boardSprite->getPosition(); Size cellSize = boardSprite->getContentSize() / GRID_COUNT; // 锚点居中时,origin 是左下角坐标,所以要减去一半棋盘尺寸 int col = (pos.x - boardOrigin.x + cellSize.width / 2) / cellSize.width; int row = (pos.y - boardOrigin.y + cellSize.height / 2) / cellSize.height; if (row < 0 || row >= GRID_COUNT || col < 0 || col >= GRID_COUNT) { return false; } selectedStart = {row, col}; return true; }逻辑说明:换算的关键是锚点。boardSprite 是整个棋盘的容器节点,位置是它的锚点所在处。如果锚点设为(0.5, 0.5),origin 底部值要用“中心坐标减半棋盘尺寸”得出;如果锚点是(0, 0),直接用 getPosition 当 origin 就行。这套源码里用的是锚点居中,所以公式里加了半格偏移,把触摸点归到最近的格子中心。
参数说明:cellSize 是格子尺寸,用棋盘精灵的 contentSize 除以 GRID_COUNT 得到。触摸点到格子中心的偏差超过半格时会被归到相邻格子,这个“判定阈值”直接决定手感。玩家反映“点了没反应”时,优先检查返回 false 的范围判断;反映“总是点偏一格”时,优先检查半格偏移的正负号。
3.2 滑动方向判定与交换动画时序:分清楚意图和抖动
cocos2d-x 触摸系统里,玩家按住、滑动、松手,通常用 onTouchMoved 持续收到 move 事件。这里不能每个事件都触发交换,否则手稍微一抖就会反复交换同一对糖果。标准做法是:记录触摸开始点,位移超过一个阈值后才认为是一次滑动意图,再执行交换。
void GameScene::onTouchMoved(Touch* touch, Event* event) { auto delta = touch->getDelta(); accumulatedDistance += sqrt(delta.x * delta.x + delta.y * delta.y); // 位移不足一格宽度的三分之一,忽略这次移动 if (accumulatedDistance < SWIPE_THRESHOLD) { return; } accumulatedDistance = 0.0f; auto startPos = convertToBoardCoord(touchStartPos); auto curPos = convertToBoardCoord(touch->getLocation()); int dr = curPos.row - startPos.row; int dc = curPos.col - startPos.col; // 只处理位移最大的方向,防止斜滑同时触发两个方向 if (abs(dr) > abs(dc)) { performSwap(startPos.row, startPos.col, startPos.row + (dr > 0 ? 1 : -1), startPos.col); } else if (abs(dc) > 0) { performSwap(startPos.row, startPos.col, startPos.row, startPos.col + (dc > 0 ? 1 : -1)); } }逻辑说明:accumulatedDistance累计的是每次 move 事件的位移量,超过阈值才响应,把“滑动意图”和“触摸抖动”区分开。方向判定里,abs(dr) 与 abs(dc) 谁大就往哪个方向交换,避免斜向滑动同时触发两个方向的歧义。convertToBoardCoord内部就是 3.1 里的换算公式,触摸结束点不一定回到开始点的格子,所以这里用当前坐标重新计算一次行列。
参数说明:SWIPE_THRESHOLD 一般设为一格宽的三分之一到二分之一。格子 80 像素时,阈值 30 像素左右比较合适;偏小会频繁误触发,偏大则玩家划完整格都没反应。若反馈“划得很用力但就是不换”,优先调小阈值;若“不想换却换了”,优先调大阈值。
3.3 交换、消除、下落动画的时序编排:先给反馈再算结果
数据层交换之后,视觉层要做三个动画:失败的交换要换回去,成功的消除要给一个渐隐缩小的反馈,落下的糖果要移动到新位置。cocos2d-x 里处理这种序列动画的惯用方式是Sequence + MoveTo + CallFunc,回调嵌在动画结束点上,保证下一步逻辑启动时画面已经就位。
void GameScene::performSwap(int r1, int c1, int r2, int c2) { board->swap(r1, c1, r2, c2); auto sprite1 = candySprites[r1][c1]; auto sprite2 = candySprites[r2][c2]; auto target1 = sprite2->getPosition(); auto target2 = sprite1->getPosition(); auto seq1 = Sequence::create( MoveTo::create(0.15f, target1), CallFunc::create([this, r1, c1, r2, c2]() { onSwapAnimationDone(r1, c1, r2, c2); }), nullptr ); sprite1->runAction(seq1); sprite2->runAction(MoveTo::create(0.15f, target2)); }逻辑说明:两个精灵同时执行 MoveTo,动画时长必须一致,一只先到、另一只后到会产生明显的卡顿感。回调只绑在 sprite1 上,保证数据层下一轮检测启动前,两个精灵都已经到位。cocos2d-x 里没有“等待两段动画都结束”的原生 API,常见做法是用 DelayTime 兜底,但更稳妥的还是把回调绑在时长相同的那一个上,用一套时长值控制两端。
参数说明:0.15f 是交换动画时长,单位秒。追求“爽快”手感可以用 0.1;想突出“糖果软弹”质感,可以在 MoveTo 之外再加一个 ScaleTo 动画,先放大到 1.05 再缩回 1.0。这套源码做了取舍,没有加弹性缩放,保持动画逻辑轻量,消除和下落两个阶段用不同时长来制造节奏差。
消除动画和下落动画的编排遵循同样的模式:消除阶段对标记过的糖果精灵做 FadeOut 加 ScaleTo,动画结束后立刻把对应位置的引用置空;下落阶段对每个有位移的精灵做 MoveTo,时长按位移距离等比计算,保证下落速度恒定,而不是简单用固定时间。
void GameScene::playClearAnimation(std::vector<std::pair<int,int>>& cleared) { for (auto& pos : cleared) { auto sprite = candySprites[pos.first][pos.second]; auto seq = Sequence::create( Spawn::create(ScaleTo::create(0.2f, 0.1f), FadeOut::create(0.2f), nullptr), CallFunc::create([this, pos]() { candySprites[pos.first][pos.second] = nullptr; }), nullptr ); sprite->runAction(seq); } }逻辑说明:Spawn 让 ScaleTo 和 FadeOut 同时开始同时结束,消除质感是“糖果一边缩小一边变透明”,比先后执行更干脆。这里最容易被忽略的是动画结束后的引用置空。如果不置空,后续刷新棋盘时会继续操作已失效的节点,轻则报空指针,重则在低端机上直接崩。这个习惯应该在写第一版时就养好,不要等出问题了再补。
参数说明:FadeOut 时长如果长于 ScaleTo,糖果会先缩到很小、再渐渐透明,观感像“化掉”;反过来则像“闪没”。0.2f 是两个时长相同的平衡点。平板这类大屏设备上,建议按格子实际尺寸等比拉长到 0.25~0.3f,否则大格子上动画会显得太快。
3.4 特殊糖果生成与作用范围:四连、五连和 L/T 形消除
四连、五连在匹配检测阶段就能识别。四连生成直线消除糖果(消除整行或整列),五连生成彩色糖果(可与任意颜色配对触发全局消除),L 形、T 形匹配生成炸弹型糖果。识别方式很直接:在 findMatches 结果里统计每一行每一列的连续长度,等于 4 生成直线,等于 5 生成彩色,等于 3 的 L 形或 T 形组合生成十字炸弹。
void GameScene::spawnSpecialCandy(int r, int c, SpecialType type) { auto sprite = Sprite::create("special_candy.png"); sprite->setTag(type); sprite->setPosition(getPositionForCell(r, c)); candySprites[r][c] = sprite; addChild(sprite); // 用脉冲动画提示玩家这个糖果不普通 auto pulse = RepeatForever::create(Sequence::create( ScaleTo::create(0.4f, 1.15f), ScaleTo::create(0.4f, 0.95f), nullptr )); sprite->runAction(pulse); }逻辑说明:特殊糖果生成后在视觉上必须和普通糖果有明显区别。常见做法是换一套贴图资源,再叠加一个脉冲动画。这块代码里 SpecialType 通过 Tag 存在精灵节点上,后续作用范围判定直接读 Tag。Tag 是整型,为避免与引擎内部节点 Tag 冲突,建议从 1000 开始分配,别用 1、2、3 这种容易撞的值。
作用范围的实现可以统一用“范围计算器”来做:直线糖果消除整行或整列,十字炸弹消除触发点为中心的区域,彩色糖果消除当前棋盘上所有同色糖果。范围计算和消除动作分开,方便复用和测试。
std::vector<std::pair<int,int>> GameScene::getSpecialEffectRange(SpecialType type, int r, int c) { std::vector<std::pair<int,int>> range; switch (type) { case SpecialRowClear: // 整行消除 for (int cc = 0; cc < GRID_COUNT; cc++) range.push_back({r, cc}); break; case SpecialColClear: // 整列消除 for (int rr = 0; rr < GRID_COUNT; rr++) range.push_back({rr, c}); break; case SpecialBomb5x5: // 以触点为中心的 5×5 范围 for (int dr = -2; dr <= 2; dr++) { for (int dc = -2; dc <= 2; dc++) { int nr = r + dr; int nc = c + dc; if (nr >= 0 && nr < GRID_COUNT && nc >= 0 && nc < GRID_COUNT) { range.push_back({nr, nc}); } } } break; default: break; } return range; }逻辑说明:这个函数只做范围计算,不做实际消除,职责分离让它可以独立复用。消除动作统一走 clearMarked 那条链,取值超界时直接过滤掉,避免数组越界。直线消除整行会让该行所有格子瞬间清空,下落补位后视觉上会出现“横穿一排空洞”的观感,某些变体玩法因此只消除该行连续段所在的整行,而不是跨列整行,具体取舍看节奏设计。
参数说明:5×5 炸弹范围是常见设定,半径 2 格就是 5×5,半径 3 格就是 7×7。范围越大,连锁概率越高,分数也越高。8×8 棋盘上 5×5 已经很大,再往上很容易直接把整局打破,新关卡设计时尽量从 3×3 起步测试。
4. 避坑排查:从坐标翻车到连锁崩溃,六条血泪经验
这一章专门记录我在拆这套源码和复刻三消玩法时踩过的坑。这些坑可以归成数据层、节点层、适配层三类:数据层的坑表现为逻辑错乱,靠打印日志最直接;节点层的坑表现为渲染异常或内存异常,靠看节点树最直接;适配层的坑最隐蔽,通常只在真机上暴露。下面六条都是我回滚过、重写过、在测试机上复现过的真实问题。
4.1 现象:点击糖果没反应,但触摸回调明明触发了
排查时监听 onTouchBegan,发现 return true 了,但点击位置总是偏一格。用日志输出转化后的行列号,对比实际点击的糖果位置,发现行列数总差一行或一列。原因出在锚点:棋盘精灵锚点是 (0,0) 时可以直接用 getPosition 当 origin,但代码里把精灵设成了锚点 (0.5,0.5),origin 用的却是“锚点 (0,0) 时才有意义”的值。坐标系一偏,所有行列换算全盘偏移。
解决:把精灵锚点与坐标换算逻辑对齐。要么统一锚点 (0,0),用 getPosition 直接做 origin;要么锚点居中,用getPosition() - boardSize / 2换算。从那以后我创建棋盘节点时都会写注释注明锚点约定,换算函数入口加断言origin == getPosition() - boardSize / 2,一旦有人改了锚点,调试期立刻暴露。
4.2 现象:消除后棋盘上残留半透明糖果残影
视觉上消除动画执行了,但糖果精灵没有真正销毁,或者残影在新棋盘上还能看到。原因有两个:一个是回调里只把动画跑完,没把精灵从父节点移除,节点还挂在场景树里;另一个是精灵引用没置空,后续刷新棋盘时旧引用被新精灵覆盖,旧节点没人管就成了残留。
解决:在消除动画的 CallFunc 回调里,节点执行removeFromParent()并把 candySprites 对应位置置空。这一步不能省,残留节点不单是视觉问题,还会参与触摸检测——点击到已消除的位置会触发意外的交换。排查时可以用引擎的节点树调试功能,把所有还挂着糖果纹理的节点列出来,看父节点和时间戳。
4.3 现象:棋盘数据被交换操作搞乱,出现“凭空多出的糖果”
数据层 swap 之后,如果玩家操作的是不相邻的格子(手势划歪了),数据层没做相邻性校验就会强行交换两块不相邻的糖果,画面出现错乱。排查后发现 swap 调用前确实没有检查abs(r1-r2) + abs(c1-c2) == 1。
解决:在 performSwap 入口加上相邻性判断,不相邻直接 return false,不触发动画和数据变更。另外,滑动结束时触摸点可能落在棋盘外,也要在 touchEnd 时做范围过滤。这个校验看似多余,实则是防手滑误触的最后一道闸。
4.4 现象:下落补位后棋盘出现大小不一的空洞,或顶部糖果堆叠到棋盘外
下落的实现没有处理列内连续空格的移动,只是简单把非空糖果往下搬,结果一列里存在两个连续空格时,第二个空格没有被后续糖果补上。本质是 applyGravity 遍历顺序写反了——从上往下搬会把上方的空格“搬下去”,正确方向是自底向上扫描。
解决:严格按 2.3 里的 applyGravity 写法,底部向上扫描。这个方向的本质区别在于,从底部找第一个可写入的空位作为落点,而不是从顶部找空格去填。后来我把这个函数写成独立单测,输入一个中间有空洞的列,断言落点顺序,每次改动前先跑一遍,基本没有再犯过。
void testGravity() { // 构造一列:[空, 红, 空, 蓝] // 期望下落结果:[空, 空, 红, 蓝] BoardData board(1, 4); board.setCell(0, 0, CandyNone); board.setCell(0, 1, CandyRed); board.setCell(0, 2, CandyNone); board.setCell(0, 3, CandyBlue); board.applyGravity(); assert(board.getCell(0, 0) == CandyNone); assert(board.getCell(0, 1) == CandyNone); assert(board.getCell(0, 2) == CandyRed); assert(board.getCell(0, 3) == CandyBlue); }逻辑说明:这个测试覆盖了单列双空洞的场景,是最容易出问题的输入。一个空格的列大概率不会写错,连续两个空格才是分水岭。自底向上实现通过 writeRow 指针保证每个非空糖果都能落到该列最下面的空位。
4.5 现象:连锁消除执行到一半,程序直接崩溃
连锁过程里,第一次消除触发下落,下落后的新布局又产生三连,于是递归或循环调用。如果实现用的是递归函数而不是循环,连锁 5 次以上时调用栈会爆掉。崩溃日志里能看到 resolveChain 反复压栈直到栈溢出,有的版本还会表现为连锁次数突然卡住不动,那是死循环而非爆栈。
解决:把递归改为 while 循环,或用队列保存待处理的消除集合,每轮循环处理一个集合。这套源码里用的是 while 循环方案,链越深越安全。同时要检查循环退出条件是否包含“本次无匹配”判断——如果糖果类型种类数设置太少,棋盘上始终存在三连,循环永远不会退出,那就是死循环。
4.6 现象:不同分辨率下列表和棋盘对齐错位,按钮文字显示不全
cocos2d-x 老项目的适配方案五花八门:固定分辨率加 letterbox 的、按比例直接缩放的都有。按固定分辨率设计的源码,在宽屏机型上棋盘会被裁掉一部分或整体偏到边上。排查时先看 DesignResolutionSize 设了什么值,再看 Resize 策略是 FixedHeight 还是 FixedWidth。真机上比模拟器更明显,模拟器宽高比固定,真机刘海屏、挖孔屏差异很大。
解决:统一用 FixedHeight 加合理宽边距的方案。棋盘居中放,四周留出固定间隙,小屏上间隙会被压缩但棋盘不裁切。如果项目还依赖旧版的自适应缩放接口,迁移到 3.x 后要重新写适配层,不要直接沿用老代码。另有一个容易忽略的点:按钮和分数标签的定位要用相对百分比或相对棋盘位置,不要写死绝对坐标,否则换一台设备按钮就可能跑到屏外。
5. 进阶:从硬编码棋盘到关卡数据驱动
5.1 用 JSON 描述一关的目标和步数
玩法逻辑跑通后,值得做的是把每关的目标步数、目标分数、目标消除量、棋盘尺寸和特殊障碍从代码里抽出来。常见做法是用 JSON 描述关卡,cocos2d-x 项目里 JSON 解析库多且稳定。一个典型的关卡配置长这样:
{ "levelId": 1, "boardRows": 8, "boardCols": 8, "targetScore": 8000, "moveLimit": 15, "candyTypes": 6, "startingPattern": "normal", "obstacles": [ { "type": "jelly", "count": 6, "dropWeight": 0.1 }, { "type": "ice", "count": 4, "dropWeight": 0.05 } ], "comboScoreBase": 50, "comboMultiplier": 2 }逻辑说明:这套资源原本把棋盘尺寸和糖果类型数量写死在代码里,我后来把全部字段抽到 JSON,每关加一个配置文件,不动编译逻辑。目标分数、移动步数和障碍物配置是最常用的三个字段,前两个控制难度,第三个开始引入“消除受限格子”的玩法层次。dropWeight 是生成时投放障碍物的权重,0.1 表示大约每 10 个新糖果会产出 1 个被障碍覆盖的格子。
参数说明:moveLimit 在 15~25 之间是休闲关卡区间,偏低会让玩家频繁失败,偏高则失去挑战性。candyTypes 影响匹配概率,5 和 6 是常见值。targetScore 要和 comboScoreBase、comboMultiplier 联调,先跑一局脚本看实际得分的分布,再定目标值,不要拍脑袋。
5.2 验证一关能不能通:调试开关做成菜单项
数据驱动让关卡数量快速扩充,同时带来新风险:怎样验证一个关卡是可解的?完全依赖人工试玩费时费力,至少可以做两个自动化检查:第一步,用无界步数自动按规则打出一个最高分,确认分数能超过 targetScore;第二步,限制步数回放同一策略,看是否仍有解。后者是搜索算法的活,前者已经能筛掉大部分设计过难的关卡。
我一般会在 Debug 构建里加一个 Automation 模式,用脚本控制 BoardLayer 的 executeResolveChain 无限次调用,同时打印最高分和剩余步数。这一步不做进日常发布包,只在关卡测试时打开。配置文件的合法性检查也在加载阶段做掉——字段缺失、步数为负数、障碍物总数超过棋盘格数,都直接报错,不让错误藏到运行时。
一个值得养成的习惯是:每次增加新关卡字段,先写一份最小可用的 JSON,跑一局看数据层行为,再逐步加入障碍物等复杂字段。数据驱动的玩法,最容易翻车的不是算法本身,而是配置字段的疏漏——漏一个字段,运行时表现出的问题往往又怪又隐蔽,单靠试玩很难定位到是配置的问题。
话说回来,我从做这套三消源码的复刻以后,每写一次消除逻辑都会强制走一遍“数据层单元测试、动画回调置空、坐标锚点约定”这三件事。这三件事分别对应找匹配、清节点、定坐标,是三消玩法最容易翻车的三个位置。希望这篇拆解能帮你在拿到源码后少走几步弯路。
本文还有配套的精品资源,点击获取