ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

cocos2d-x 三消游戏开发实战:从棋盘数据结构到消除结算的完整实现

cocos2d-x 三消游戏开发实战:从棋盘数据结构到消除结算的完整实现 简介这是一份基于 cocos2d-x 引擎实现 Candy Crush 三消玩法的完整工程源码面向具备 C 基础、希望学习 2D 游戏开发或三消类游戏实现思路的开发者。资源围绕棋盘布局、相邻糖果交换、三连匹配消除、计分与关卡状态管理等核心机制展开并涉及 2.2.6 版本与 3.0 及以上版本在 API、Scene Graph 与性能上的适配差异可作为游戏开发入门与框架迁移的实践参考。压缩包共 43 个文件约 3.46MB包含 7 个 cpp 与 8 个 h 源文件承载游戏逻辑8 个 png 与 4 个 plist 提供糖果贴图及帧动画配置另有 4 个 ttf 字体、5 个 dll 运行库及 sln、vcxproj 等工程文件可直接在 Windows 下编译运行。目前已有 207 人学习适合对照源码理解三消算法、动画特效与关卡组织方式并在此基础上扩展特殊糖果、道具系统与关卡编辑器。1. 用 cocos2d-x 复刻 Candy Crush从三消规则到可玩原型的完整路径很多人第一次想用 cocos2d-x 写 Candy Crush卡住的地方不是引擎 API而是三消规则本身比想象中复杂交换判定、连锁消除、下落填充、死局检测每一步都有边界条件。这个方向适合已经会 C 或 Lua、想找一个完整小项目练手的开发者也适合想理解三消底层数据结构的客户端工程师。我做过一版可玩原型核心逻辑大约 800 行 C跑在 cocos2d-x 3.17 上棋盘 8×8、6 种糖果、支持四连消和五连消特殊块。下面把从棋盘建模到消除结算的完整路径拆开讲参数和坑都标清楚。2. 棋盘数据结构与糖果类型为什么用二维数组而不是节点树2.1 棋盘建模二维数组存逻辑Sprite 只做表现三消的棋盘本质是一个二维网格每个格子存糖果类型 ID。常见做法是用std::vectorstd::vectorint或固定大小数组逻辑层和渲染层分离。逻辑层只关心类型和坐标渲染层用Sprite或SpriteFrame显示。这样做的好处是消除、下落、死局检测都在纯数据上跑不碰节点树性能稳定。// Board.h const int ROWS 8; const int COLS 8; const int CANDY_TYPES 6; // 6 种基础糖果 class Board { public: int grid[ROWS][COLS]; // -1 表示空格 void init(); bool swap(int r1, int c1, int r2, int c2); bool hasMatchAt(int r, int c); void resolveMatches(); void dropAndFill(); bool hasPossibleMove(); };grid用int存类型 ID-1 表示空。swap只交换数据不碰 Sprite。hasMatchAt检查某格是否参与三连。resolveMatches标记并清除。dropAndFill处理下落和补充。hasPossibleMove做死局检测。参数说明ROWS和COLS建议从 8 开始太大在手机上触控精度下降太小连锁空间不足。CANDY_TYPES常用 5 到 6类型太少容易自动三连太多则匹配概率低、节奏慢。我一般用 6配合权重让稀有糖果出现率略低。2.2 糖果类型与特殊块四连消和五连消的生成规则基础糖果之外Candy Crush 的核心爽点是特殊块。四连消生成条纹糖五连消生成彩糖。实现方式是在resolveMatches里记录匹配长度和方向然后决定生成哪种特殊块。enum CandyType { NORMAL 0, STRIPED_H, STRIPED_V, WRAPPED, COLOR_BOMB }; struct MatchInfo { int length; bool horizontal; int r, c; // 生成位置 };匹配长度 4 且水平方向生成STRIPED_H垂直方向生成STRIPED_V。长度 5 生成COLOR_BOMB。L 型或 T 型匹配生成WRAPPED。生成位置一般选匹配的中心格或交换点。注意特殊块的触发逻辑要单独处理。条纹糖消除整行或整列彩糖消除同色所有糖果。触发时可能引发连锁需要递归或队列处理。我一般用队列避免递归深度不可控。2.3 初始化棋盘避免开局就有三连初始化时不能随机填否则开局可能直接有三连玩家还没操作就自动消除。常见做法是逐格填充每次检查左边两个和上边两个是否同类型如果是就重新随机。void Board::init() { for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { int t; do { t rand() % CANDY_TYPES; } while ((c 2 grid[r][c-1] t grid[r][c-2] t) || (r 2 grid[r-1][c] t grid[r-2][c] t)); grid[r][c] t; } } // 确保至少有一个可行移动 while (!hasPossibleMove()) { shuffle(); } }hasPossibleMove遍历所有相邻交换模拟交换后检查是否有三连。如果没有就重新洗牌或重新初始化。这个检查在每次消除后也要跑否则可能死局。参数说明rand()需要先srand(time(nullptr))。洗牌时不要直接打乱整个棋盘容易再次产生三连建议只交换部分格子并重新检查。3. 交换判定与消除结算三消核心逻辑的完整实现3.1 交换判定先模拟再提交玩家拖动糖果时不能直接交换数据要先模拟交换后检查是否有三连。如果有才真正交换并进入消除流程如果没有播放回弹动画。bool Board::swap(int r1, int c1, int r2, int c2) { // 只允许相邻交换 if (abs(r1 - r2) abs(c1 - c2) ! 1) return false; std::swap(grid[r1][c1], grid[r2][c2]); if (hasMatchAt(r1, c1) || hasMatchAt(r2, c2)) { return true; // 有效交换 } std::swap(grid[r1][c1], grid[r2][c2]); // 回滚 return false; }hasMatchAt检查某格是否参与三连。实现时分别向左右和上下扩展统计连续同类型数量。bool Board::hasMatchAt(int r, int c) { int t grid[r][c]; if (t 0) return false; int count 1; for (int i c - 1; i 0 grid[r][i] t; i--) count; for (int i c 1; i COLS grid[r][i] t; i) count; if (count 3) return true; count 1; for (int i r - 1; i 0 grid[i][c] t; i--) count; for (int i r 1; i ROWS grid[i][c] t; i) count; return count 3; }参数说明abs(r1 - r2) abs(c1 - c2) ! 1保证只允许上下左右相邻交换不允许斜角。回滚时直接交换回来不需要额外备份。3.2 消除结算标记、清除、下落、填充消除流程分四步标记所有匹配格、清除标记格、下落、填充新糖果。每一步都要更新逻辑层然后同步到渲染层。void Board::resolveMatches() { bool matched[ROWS][COLS] {false}; bool anyMatch false; // 标记所有匹配 for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (hasMatchAt(r, c)) { matched[r][c] true; anyMatch true; } } } if (!anyMatch) return; // 清除标记格 for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (matched[r][c]) { grid[r][c] -1; } } } dropAndFill(); resolveMatches(); // 递归处理连锁 }dropAndFill从下往上遍历把非空格子下移顶部补新糖果。void Board::dropAndFill() { for (int c 0; c COLS; c) { int writeRow ROWS - 1; for (int r ROWS - 1; r 0; r--) { if (grid[r][c] 0) { grid[writeRow][c] grid[r][c]; writeRow--; } } for (int r writeRow; r 0; r--) { grid[r][c] rand() % CANDY_TYPES; } } }参数说明writeRow从底部开始遇到非空格就写入最后剩余位置填新糖果。递归调用resolveMatches处理连锁但要注意递归深度极端情况可能很深建议改成循环加队列。3.3 特殊块触发条纹糖和彩糖的消除逻辑特殊块触发时消除范围不是简单的三连而是整行、整列或同色所有。实现时在resolveMatches里先检查是否有特殊块被消除然后扩展消除范围。void Board::triggerSpecial(int r, int c) { int type grid[r][c]; if (type STRIPED_H) { for (int i 0; i COLS; i) grid[r][i] -1; } else if (type STRIPED_V) { for (int i 0; i ROWS; i) grid[i][c] -1; } else if (type COLOR_BOMB) { int targetType grid[r][c]; // 实际中需要记录目标类型 for (int i 0; i ROWS; i) for (int j 0; j COLS; j) if (grid[i][j] targetType) grid[i][j] -1; } }注意彩糖的目标类型需要在交换时记录不能直接用当前格类型。常见做法是在交换时把彩糖和目标糖果一起消除并记录目标类型。3.4 死局检测与洗牌保证游戏可继续每次消除和填充后要检查是否还有可行移动。如果没有自动洗牌或提示玩家。bool Board::hasPossibleMove() { for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (c 1 COLS) { std::swap(grid[r][c], grid[r][c1]); bool ok hasMatchAt(r, c) || hasMatchAt(r, c1); std::swap(grid[r][c], grid[r][c1]); if (ok) return true; } if (r 1 ROWS) { std::swap(grid[r][c], grid[r1][c]); bool ok hasMatchAt(r, c) || hasMatchAt(r1, c); std::swap(grid[r][c], grid[r1][c]); if (ok) return true; } } } return false; }洗牌时不要直接随机打乱容易再次死局。常见做法是收集所有糖果类型随机重新分配然后检查是否有三连和可行移动循环直到满足条件。4. 渲染同步与触控交互让逻辑层和表现层对齐4.1 渲染同步逻辑坐标到屏幕坐标的映射逻辑层用行列索引渲染层用像素坐标。需要一个映射函数把(row, col)转成Vec2位置。Vec2 Board::positionAt(int r, int c) { float x origin.x c * tileSize tileSize / 2; float y origin.y (ROWS - 1 - r) * tileSize tileSize / 2; return Vec2(x, y); }origin是棋盘左下角在屏幕上的位置tileSize是每格像素大小。注意 y 轴方向cocos2d-x 的 y 轴向上而行索引通常从上往下所以用ROWS - 1 - r翻转。参数说明tileSize根据屏幕宽度和棋盘列数计算常见做法是visibleSize.width * 0.9 / COLS。origin居中计算。4.2 触控交互拖动和点击两种操作方式三消常见操作是拖动交换也有点击两次交换。拖动实现时记录起点格拖动超过阈值后计算方向触发交换。bool onTouchBegan(Touch* touch, Event* event) { Vec2 pos touch-getLocation(); startRow rowAt(pos); startCol colAt(pos); return true; } void onTouchMoved(Touch* touch, Event* event) { Vec2 pos touch-getLocation(); Vec2 delta pos - startPos; if (delta.length() tileSize * 0.3) { int dir abs(delta.x) abs(delta.y) ? (delta.x 0 ? 1 : -1) : (delta.y 0 ? -1 : 1); // 根据方向计算目标格并尝试交换 } }参数说明拖动阈值tileSize * 0.3是经验值太小容易误触太大反应迟钝。方向判断用abs(delta.x) abs(delta.y)决定水平还是垂直。4.3 动画与回弹交换失败的处理交换失败时要有回弹动画否则玩家不知道操作无效。常见做法是交换两个 Sprite 的位置然后播放回弹动作。auto move1 MoveTo::create(0.1, pos2); auto move2 MoveTo::create(0.1, pos1); auto back1 MoveTo::create(0.1, pos1); auto back2 MoveTo::create(0.1, pos2); sprite1-runAction(Sequence::create(move1, back1, nullptr)); sprite2-runAction(Sequence::create(move2, back2, nullptr));注意动画期间要禁用触控避免连续操作导致状态错乱。可以用一个isAnimating标志位控制。5. 避坑与排查三消原型最常见的 5 个翻车点5.1 消除后没有连锁或者连锁一次就停现象消除一组后下落填充完不再检查新的三连。原因resolveMatches没有递归或循环调用。解决在dropAndFill后再次调用resolveMatches直到没有匹配为止。建议用while循环加标志位避免递归深度过大。5.2 特殊块生成位置不对或者生成了但没触发现象四连消后没有条纹糖或者条纹糖消除时只消了一格。原因匹配长度和方向判断错误或者特殊块触发逻辑没接入消除流程。解决在标记匹配时记录连续长度和方向生成特殊块后要在下一次消除时优先检查特殊块并扩展消除范围。5.3 死局检测漏判玩家卡死现象棋盘上还有可行移动但hasPossibleMove返回 false。原因只检查了相邻交换没有考虑特殊块触发后的消除。解决死局检测要模拟交换后检查是否有三连同时考虑特殊块被交换后是否能触发消除。常见做法是简化检测只检查普通三连特殊块作为额外可行移动。5.4 触控坐标映射错误点哪都不对现象点击位置和实际格子偏差很大或者边缘格子点不到。原因positionAt和rowAt/colAt的坐标转换不一致或者没有考虑锚点和缩放。解决统一用origin col * tileSize计算rowAt用(pos.y - origin.y) / tileSize然后翻转。注意 Sprite 锚点默认是 (0.5, 0.5)位置计算要加tileSize / 2。5.5 动画和逻辑不同步消除后糖果位置错乱现象逻辑层已经消除但渲染层 Sprite 还在旧位置或者下落动画和逻辑下落不一致。原因逻辑层和渲染层更新顺序错误或者动画期间逻辑层继续跑。解决先更新逻辑层再根据逻辑层数据更新 Sprite 位置。动画期间用标志位暂停逻辑更新动画结束后再继续。6. 进阶技巧用权重表和预生成序列控制游戏节奏6.1 权重表让稀有糖果出现率可控纯随机rand() % CANDY_TYPES会导致某些糖果过多或过少。常见做法是给每种糖果配权重用累积概率选择。int weightedRandom() { static int weights[CANDY_TYPES] {30, 25, 20, 15, 7, 3}; int total 0; for (int i 0; i CANDY_TYPES; i) total weights[i]; int r rand() % total; int sum 0; for (int i 0; i CANDY_TYPES; i) { sum weights[i]; if (r sum) return i; } return 0; }权重表可以根据关卡难度调整。前期让基础糖果均匀后期让稀有糖果更少增加难度。6.2 预生成序列避免随机导致的死局和无聊纯随机填充容易产生连续死局或过于简单的局面。进阶做法是预生成一个糖果序列填充时从序列取保证整体分布可控。std::vectorint candyPool; void refillPool() { candyPool.clear(); for (int i 0; i CANDY_TYPES; i) { for (int j 0; j weights[i]; j) { candyPool.push_back(i); } } std::random_shuffle(candyPool.begin(), candyPool.end()); }每次填充从candyPool取取完重新生成。这样能保证每种糖果出现次数符合权重同时避免连续相同。6.3 验证方法用日志和回放检查逻辑正确性三消逻辑容易出边界 bug建议加日志和回放。每次交换、消除、下落都打日志出问题时可以回放。void logBoard() { for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { CCLOG(%d , grid[r][c]); } CCLOG(\n); } }回放可以用固定随机种子记录操作序列复现问题。我一般会在测试时开日志上线关掉。6.4 一个具体技巧用位运算加速匹配检测棋盘 8×8 不大但连锁多时匹配检测频繁。可以用位运算把每行每列的类型编码成整数快速判断三连。不过这个优化在 8×8 上收益有限建议先保证逻辑正确再考虑优化。我自己的习惯是先把逻辑层写成纯 C 可测试的类不依赖 cocos2d-x用单元测试跑通所有边界再接入渲染。这样出问题时能快速定位是逻辑还是表现。希望帮到你。本文还有配套的精品资源点击获取
返回列表