尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

cocos2d-x实现三消:棋盘建模、消除检测与动画时序全解析

发布时间:2026/9/29 5:00:45

资讯中心
01
ARTICLE

cocos2d-x实现三消:棋盘建模、消除检测与动画时序全解析

cocos2d-x实现三消:棋盘建模、消除检测与动画时序全解析
简介面向 cocos2d-x 游戏开发学习者的一份 Candy Crush 三消游戏工程基于 2.2.6 版本编写展示棋盘布局、糖果交换、消除判定、计分与关卡状态管理等核心功能的实现思路。压缩包内共 43 个文件含游戏贴图、C 源码、配置文件、字体以及 Windows 可执行程序和动态运行库整体仅 3.46MB目录结构紧凑便于按模块拆解学习。工程源码目录按游戏场景、棋盘瓦片、全局数据等模块拆分有助于理解三消游戏的对象设计与匹配逻辑也能参考其工程组织方式为后续迁移到 3.0 或扩展特殊糖果、道具系统打下基础。借助所附的可执行程序可快速体验整体效果并对照源码核对棋盘生成、消除检测、动画反馈与分数更新等关键流程。目前已有 207 人学习适合以实际小游戏入手掌握 cocos2d-x 核心机制并希望在真实工程中观察精灵、动作、触摸事件组合方式的开发者。1. 用 cocos2d-x 重写 candy crush为什么三消的逻辑比美术更先卡住你cocos2d-x 写的 candy crush 这个标题下面往往不是美术问题而是逻辑时序问题。三消的核心玩法是一个状态机棋盘、交换、消除、下落、再消除每一步都要等动画走完才能进下一步。真正难的不是“判断三个一样颜色”而是“回调还没触发、下落就开始了”造成的各种错乱。cocos2d-x 的动作系统 Sequence、Spawn、CallFunc 正好适合串这类异步流程。下面的做法按我自己的踩坑顺序展开适合用 C 版 cocos2d-x 做关卡原型或复刻三消玩法的客户端开发者。2. 棋盘建模与可交换判定先让一次点击变成一次合法操作动手写任何渲染之前先把棋盘的数据结构和手指输入打通。很多半成品卡在第一步是因为点击坐标和数组下标对不上交换完永远检测不到消除或者消除了但画面没反应。先解决逻辑层的数据表达再谈表现。2.1 用二维数组还是瓦片表三消棋盘的两种建模三消棋盘最常见是 8 行 8 列也有 7x7、9x9 的变体。数据结构不需要花哨一个一维数组加行列换算就够。二维数组也能干但在这类项目里一维数组更容易写交换、拷贝和状态重置的逻辑。// CandyType 保留 EMPTY 作为空洞标记0 值天然适合判断 enum CandyType { EMPTY 0, RED 1, GREEN 2, BLUE 3, YELLOW 4, PURPLE 5 }; class Board { public: Board(int rows, int cols) : rows_(rows), cols_(cols), data_(rows * cols, EMPTY) {} // 统一约定第一个参数是行号第二个参数是列号别在调用处反过来 int idx(int row, int col) const { return col * rows_ row; } CandyType get(int row, int col) const { return data_[idx(row, col)]; } void set(int row, int col, CandyType v) { data_[idx(row, col)] v; } int rows_; int cols_; std::vectorCandyType data_; };索引公式col * rows_ row是列优先布局同一列的格子数据在内存里连续后面做下落计算时缓存友好一点。真正选择它的理由不是性能而是所有算法函数只需要传Board不用在回调里传递嵌套vectorvectorCandyType造成拷贝噪音。还有一件事在建模时要定死行方向和屏幕坐标的关系。cocos2d-x 的原点默认在左下角棋盘第 0 行放屏幕底部渲染时y originY row * cellSize很自然。如果心里把第 0 行放屏幕顶部渲染就要写y originY (rows_ - 1 - row) * cellSize。两种都行但只选一种并且写进代码注释不然以后每写一个迭代都要猜一遍。2.2 触控坐标换算成棋盘坐标的公式与边界点击和滑动拿到的是屏幕坐标必须换算到棋盘格子。常见做法是在棋盘的 Node 层里做转换让 BoardLayer 的锚点是 (0,0)并且 ContentSize 恰好等于棋盘像素尺寸。// touch 来自 cocos2d-x 的 EventListenerTouchOneByOne 回调 Vec2 localPos this-convertToNodeSpace(touch-getLocation()); int col (int)floorf(localPos.x / cellSize_); int row (int)floorf(localPos.y / cellSize_); if (row 0 || row rows_ || col 0 || col cols_) { // 触点在棋盘外直接忽略不要做夹逼 return; }这里floorf不能省。强转(int)在正数下等价于 floor但手指滑出棋盘后坐标变负时强转会向零取整可能把 -0.5 变成 0误判成最边上的格子。另一个高频翻车点是ContentSize没设置Layer 大小默认是 0convertToNodeSpace的结果整体偏移一个原点点击判定全乱。设置方法是setContentSize(Size(cols_ * cellSize_, rows_ * cellSize_))。方向判定我一般放在 touchMove 里累计位移而不是在 touchEnd 里比较起点终点。手速快的时候 end 回调已经过了惯性只比较 begin 和 end 容易丢失滑动意图。记录首次激活的格子移动累计超过cellSize_ * 0.3f后锁定一个方向之后不要再改方向避免斜滑时来回抖动。2.3 交换合法性与交换回退先查逻辑再动渲染玩家从 A 格滑向相邻 B 格后程序要做的是先交换棋盘数据跑一遍消除检测再决定播放哪段动画。先交换再检测而不是先模拟后交换是为了复用同一份检测函数避免逻辑和渲染两套状态产生时间差。bool BoardLayer::trySwap(int r1, int c1, int r2, int c2) { std::swap(board_.data_[board_.idx(r1, c1)], board_.data_[board_.idx(r2, c2)]); std::vectorVec2 matched detectMatches(board_); if (matched.empty()) { // 交换不产生消除立刻换回来视觉上补一个回弹动画 std::swap(board_.data_[board_.idx(r1, c1)], board_.data_[board_.idx(r2, c2)]); playBounceBack(r1, c1, r2, c2); return false; } // 交换合法进入消除流程 applyEliminate(matched); return true; }回弹动画用两个 MoveBy 串成一个 Sequence先往目标方向移动 8 像素再退回来时长 0.08 秒左右不要做长距离回弹会显得糖果很滑。关键是先检测再动渲染这样不会出现“逻辑已交换但画面还没跟上”的窗口期也避免玩家快速连点时读到半个棋盘状态。3. BFS 消除检测与死锁重排从颜色连通区到可玩局面交换判定通过后消除检测就上场了。这个函数的正确性决定整个游戏的地基后面下落、连击、特殊糖果全部建立在它的返回值上。检测函数要写得可复用因为开局生成要看一遍每次交换要看一遍下落结束后还要再看一遍。3.1 为什么先找连通区而不是直接查三连最直觉的写法是遍历每个格子向右查两个、向下查两个凑满三个同色就标记消除。这样写十分钟就能跑通但会漏掉 L 型、T 型、横四连配合竖四连交叉的情况。candy crush 里“三个成一线”是基本规则而“同色连通区域的尺寸大于等于 3”是它的等价推广一次 BFS 能同时覆盖所有形状。// 返回所有待消除格子的坐标存成 Vec2x 是列号y 是行号 std::vectorVec2 detectMatches(const Board b) { std::vectorVec2 result; if (b.rows_ 0) return result; std::vectorstd::vectorbool visited(b.rows_, std::vectorbool(b.cols_, false)); int dirs[4][2] {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; for (int r 0; r b.rows_; r) { for (int c 0; c b.cols_; c) { CandyType t b.get(r, c); if (t EMPTY || visited[r][c]) continue; std::vectorVec2 region; std::queuestd::pairint, int q; q.push({r, c}); visited[r][c] true; region.push_back(Vec2(c, r)); while (!q.empty()) { auto [cr, cc] q.front(); q.pop(); for (auto d : dirs) { int nr cr d[0], nc cc d[1]; if (nr 0 || nr b.rows_ || nc 0 || nc b.cols_) continue; if (visited[nr][nc] || b.get(nr, nc) ! t) continue; visited[nr][nc] true; q.push({nr, nc}); region.push_back(Vec2(nc, nr)); } } if (region.size() 3) { result.insert(result.end(), region.begin(), region.end()); } } } return result; }这一段是整套三消逻辑的核心判定标准是region.size() 3不是恰好等于 3。四连、五连会作为同一个区域返回后续做特殊糖果时按区域长度判断就可以拿到“是横四还是竖四”的信息。入队时用std::pairint,int存行和列出队再用结构化绑定拆开代码可读性比手写queue存单一索引好很多。返回值用Vec2(c, r)是故意的配合 cocos2d-x 的Vec2和渲染层无缝对接但语义会牺牲一点后面避坑章节会再强调这个地方的顺序。3.2 死锁检测的预模拟思路与性能边界棋盘上没有可消除的局面时必须重排。手动洗牌无法保证有解最可靠的方式是逐对交换预模拟把每一种可能的相邻交换都临时做一遍跑一次detectMatches只要有一步能产生消除当前局面就是活局。bool isDeadlock(const Board b) { Board tmp b; for (int r 0; r b.rows_; r) { for (int c 0; c b.cols_; c) { // 只检查向右和向下交换避免重复计算 if (c 1 b.cols_) { std::swap(tmp.data_[tmp.idx(r, c)], tmp.data_[tmp.idx(r, c 1)]); if (!detectMatches(tmp).empty()) return false; std::swap(tmp.data_[tmp.idx(r, c)], tmp.data_[tmp.idx(r, c 1)]); } if (r 1 b.rows_) { std::swap(tmp.data_[tmp.idx(r, c)], tmp.data_[tmp.idx(r 1, c)]); if (!detectMatches(tmp).empty()) return false; std::swap(tmp.data_[tmp.idx(r, c)], tmp.data_[tmp.idx(r 1, c)]); } } } return true; }这个函数在 8x8 棋盘上大约要做 112 次交换检测每次检测是遍历 64 个格子真机上整体耗时在毫秒量级只在开局和死锁重排时调用不会进入帧循环。如果棋盘扩到 12x12 或增加传送门、冰块等障碍块计算量会翻几倍剪枝方法是交换两个同色糖果时直接跳过因为同色交换不会改变局面状态。死锁后的重排策略我一般用保留边界的整盘重生成。先把棋盘按静态种子生成一遍跑isDeadlock确认不是死局再丢给玩家。动态重排时不要只 random 一次就结束因为随机结果仍可能是死锁要循环检测最多尝试 32 次超出时把棋盘整体向左平移一行后重新生成至少玩家不会看到糖果原地转圈。3.3 有偏随机与新糖填充生成棋盘时直接 uniform 随机很容易开局就出现横三竖三玩家还没操作就开始自动消体验很怪。常见做法是逐格生成时排除“向左两格同色”和“向下两格同色”这两种情况。CandyType generateCandy(int r, int c, const Board b, int colorCount) { while (true) { CandyType t (CandyType)(rand() % colorCount 1); bool bad false; if (c 2 b.get(r, c - 1) t b.get(r, c - 2) t) bad true; if (r 2 b.get(r - 1, c) t b.get(r - 2, c) t) bad true; if (!bad) return t; } }这个有偏生成只挡掉了最常见水平三连和竖直三连不挡 L 型所以整盘生成完后还是要跑一次detectMatches兜底检测到消除就整盘重来。新糖从顶部掉入空洞时也用同一个函数生成能明显减少“刚掉下来又立刻成三连”的连续消除让连锁反应的节奏更多来自玩家操作而不是随机碰撞。特殊糖果的生成概率要放到配置表里不要写死在代码中。基础糖果均匀分布条形糖、包装糖在颜色权重的基础上叠加 5% 到 10% 的额外概率具体数值靠关卡设计调和算法本身解耦。这样后面调手感时一线策划改表就够了不用翻代码。4. 下落动画与连击序列三消手感的七成来自这里逻辑层在检测完消除后就结束了剩下的都是动画。三消的手感好坏几乎全在下落动画的时序上太快像瞬移太慢像放慢动作回弹角度不对像偷懒。这一章节用最小可跑的动画方案解决“如何让所有格子同时掉到位”这个核心问题。4.1 空洞收集与下落偏移计算消除后棋盘上留下多个空洞每列可能有连续多个空位。计算下落目标位置的标准做法是逐列扫描用一个累计值记录当前列已经遇到多少个空洞把每个糖果向下移动相当于空洞数量的行数。struct FallMove { int fromRow; // 起点行号 int toRow; // 终点行号 int col; }; std::vectorFallMove computeFalls(const Board b, const std::vectorVec2 eliminated) { Board nb b; // 先把消除格子清成空洞否则同一个位置会被重复累计 for (auto p : eliminated) { nb.set((int)p.y, (int)p.x, EMPTY); } std::vectorFallMove moves; for (int c 0; c b.cols_; c) { int drop 0; for (int r 0; r b.rows_; r) { if (nb.get(r, c) EMPTY) { drop; continue; } if (drop 0) { moves.push_back({r, r - drop, c}); nb.set(r - drop, c, nb.get(r, c)); nb.set(r, c, EMPTY); } } } return moves; }这段代码先清空eliminated里的格子再开始数空洞是防止 offset 重复计算的关键。很多人在这里翻车是因为拿原棋盘直接在消除集合上做累加同一个空洞被扫到两次糖果就会多掉一行正好填错位。坐标语义值得再强调一次eliminated里存的 Vec2 是Vec2(col, row)所以第 5 行取(int)p.y作行号、(int)p.x作列号。如果检测函数里存的是Vec2(r,c)这里就会全反。现在这种结构能用但更好的是自己定义一个struct GridPos { int row, col; }彻底消灭 xy 和 rowcol 之间的心算。4.2 Sequence 串联移动动画不要让回调嵌套地狱下落动画最怕的是“A 糖果掉完了再掉 BB 掉完再检测”。如果每个糖果单独 runAction 并各自挂回调连续消除一多就会出现回调套回调、执行顺序随机的问题。正确的做法是一轮里的所有下落动作用 Spawn 并行触发再挂一个统一的完成回调。VectorFiniteTimeAction* allMoves; for (auto f : falls) { Vec2 fromPos gridOrigin_ Vec2(f.col * cellSize_, f.fromRow * cellSize_); Vec2 toPos gridOrigin_ Vec2(f.col * cellSize_, f.toRow * cellSize_); auto move MoveBy::create(dropDuration_, toPos - fromPos); allMoves.pushBack(move); } auto seq Sequence::create( Spawn::create(allMoves), CallFunc::create([this]() { this-afterDropFinished(); }), nullptr); this-runAction(seq);Spawn::create(allMoves)接受VectorFiniteTimeAction*会把所有移动动作打包成一组并行动作。单个 Sequence 负责这组动作结束后触发afterDropFinished后续要做什么都在这个回调里做。不要把afterDropFinished的调用放在某个糖果的移动回调里那等于让最后一个落地的格子决定全局节奏低端机上移动时长大体相同但误差仍在。afterDropFinished里做的事很固定用最新棋盘状态再跑一次detectMatches有匹配就继续播放消除动画和下落没有匹配就解开输入锁。整个流程是串行的状态机而不是递归调用递归在连续消除 10 次时会爆栈风险状态机不会。4.3 需要在意的动画参数下落动画的参数直接影响手感给出一组我调过多个设备后可用的基准值。参数建议值说明单次下落时长0.2 秒距离 2 格以内时好用超过 4 格降到 0.15 秒消除缩放时长0.12 秒小于 0.1 秒会丢帧大于 0.18 秒拖节奏EasingEaseBackOut用于下落超过 2 格距离时有轻微回弹感连锁触发间隔0.35 秒Spawn 结束后紧接下一轮不要在回调中间加延时连击提示延后0.25 秒在下落动画后半程再弹数字视觉上更跟手EaseBackOut 的效果是糖果快到终点时略微越过目标再弹回来模拟真实坠落感。距离只有 1 格时这个效果不明显4 格以上时变化明显所以可以按移动距离动态选择 easing。特效层不要加在糖果节点本身加在糖果层的上一层消除碎片的粒子目标位置取格子的中心点而不是格子的左下角避免视觉偏移。5. 三消开发避坑5 条最常见的翻车现场这个项目看着简单实际踩坑全在细节里。下面这 5 条按我自己的血泪频率排序每条都是真实发生过的现象、原因和最终解法。5.1 数组下标 row/col 习惯性写反现象下落动画看起来整列横移消除检测把斜对角的格子误判成同一连通区。原因get(row, col)定义得清晰但写调用时脑子跟着 cocos 的 x、y 走把get(c, r)传进去。BFS 里读取b.get(nc, nr)的时候如果nc是列、nr是行会让访问变成get(col, row)检测结果完全错位。解决代码评审时先对着idx(int row, int col)看一遍所有调用点。数据结构和算法函数全部限定为get(row, col)、set(row, col)返回值转坐标时写Vec2(col, row)。如果已经有函数传反了直接在定义处改参数名不要在调用处做减号抵消越抵消越乱。5.2 消除 offset 和下落 offset 叠加算错现象一次消除后糖果本该掉到第 3 行却掉到了第 2 行棋盘中多出一个永远填不满的空洞。原因computeFalls里直接遍历原棋盘看到消除格子和空洞都累加 drop 值。同一列有连续两个空洞时第一个空洞被数了一次消除格又被数了一次整个列的偏移量翻倍。解决先在副本棋盘上把eliminated集合里的坐标全部setEmpty再逐列扫描。核心原则是“逻辑清理和下落计算分离”清空洞是一个独立阶段计算下落是下一个阶段两者之间不要省掉那一次明确的setEmpty循环。5.3 动画期间玩家仍能触摸棋盘现象连击还没结束玩家快速放下一个交换两次操作叠加后棋盘变色甚至出现同一格被两个糖果同时占用。原因触摸事件在消除和下落动画期间继续派发trySwap在没有保护的情况下被重复调用。解决在 BoardLayer 维护一个bool acceptingInput_。trySwap判定合法后置 falseafterDropFinished里没有任何新匹配时置回 true。touchBegan 和 touchEnd 开头都检查这个标志触发生效就直接 return。用触摸监听器setEnabled也行但它会丢弃当前正在处理的触点用标志位可以做到“忽略新触摸但不打断正在进行的滑动”。注意输入锁要锁整个操作序列不是只锁结算那几帧。连续消除 6 次时从第一次消到最后一次下落完成锁必须全程保持。5.4 消除特效不加对象池现象连续消除 5 次后帧率掉到 20 FPS内存不断上下跳动低端安卓机上最明显。原因每次消除都new ParticleSystem播完就释放。粒子粒子纹理加载、粒子调度器注册和 TrieNode 销毁都是开销不是只有图片大才卡创建频率高同样卡。解决做一个特效对象池预创建 4 到 6 个粒子节点初始setVisible(false)用到时resetSystem再移动到目标位置播放。更省事的做法是小糖果消除只播一个缩放渐隐的帧动画不启用粒子。粒子是加分项不是必需项先把帧率稳住再看效果。5.5 动画没结束就提前结算逻辑现象视觉上下落动画还剩半路棋盘逻辑已经用新坐标触发了下一轮检测残留的糖果影子一直站在错误的格子上。原因动画开始时就把逻辑棋盘更新成目标位置检测也在动画完成前执行。渲染层和逻辑层各有一段状态互相不同步时整个消除链就乱套。解决用双缓冲思路。computeFalls计算时基于副本棋盘动画执行期间正式棋盘保持旧状态afterDropFinished回调里再提交新的棋盘数据并触发下一轮检测。也就是说移动动画只是视觉预告逻辑提交发生在动画完成后。这样回调层级永远是可控的一段一段而不是互相穿插的状态竞争。6. 连击倍率与验证方法把逻辑做对之后再谈手感逻辑和动画跑通后优先加连击反馈。三消的爽感来自连锁反应连击数字是给玩家的即时奖励不需要复杂系统但要注意同步时机。连击数不要用玩家操作次数来统计而是用引擎在处理连续消除时的轮次。我在afterDropFinished里检测到新匹配就comboCount没有匹配就清 0这样数字跳动的节奏和画面下落完全同步。计分显示用Label加一个简单的值过渡从旧分数平滑到新分数别直接setString硬切硬切会感觉分数是断开的。验证逻辑时不用每次都跑模拟器。把detectMatches、isDeadlock、computeFalls写成不依赖Director和任何渲染节点的纯函数在工程里加一个调试入口手动铺设几张已知棋盘跑出结果断言是否符合预期。void testLShapeEliminate() { Board b(5, 5); b.set(1, 0, RED); b.set(1, 1, RED); b.set(1, 2, RED); b.set(2, 2, RED); // 构成 L 型 4 连通区 auto matched detectMatches(b); assert(matched.size() 4); }这种用例能一次性验证 L 型、四连、五连比开着模拟器手工点击高效得多。我常年保留一组这种断言每次改动棋盘结构或检测规则后跑一遍确保旧逻辑没有被新功能破坏。我现在做这一类项目第一步永远是定get(row, col)的统一约定第二步写输入锁最后才碰特效。这个顺序反了后面几乎一定会回头重构。cocos2d-x 写 candy crush 的核心不是用引擎画糖果而是把“消除、下落、再消除”的时序管住。逻辑层干净美术表现可以慢慢堆逻辑层乱加再多特效也救不回来。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。