简介:面向C++与cocos2d-x初学者及想研究三消玩法的移动游戏开发者,这份candy crush工程以cocos2d-x 2.2.6为基础,提供一套可直接参考的糖果消除游戏实现,重点覆盖棋盘布局、相邻糖果交换、同色匹配消除、动画特效、计分与关卡状态管理等核心机制,并可作为向3.0及以上版本迁移适配的对照样本。压缩包共43个文件、约3.46MB,包含7个cpp与8个头文件构成的核心逻辑代码,png、plist、ttf等图像与字体资源配置,以及sln、vcxproj等Windows工程文件和dll、exe运行文件,目录按Classes、Resources、proj.win32等模块组织,便于按功能查阅。已有210人学习下载。读者可从中获得完整可编译的工程结构、三消核心类实现思路和资源组织方式,适合用来拆解基础消除逻辑、理解cocos2d-x场景与层管理,并在此基础上扩展特殊糖果、道具或关卡编辑器。
1. 先谈一个容易被低估的事实
三消的逻辑薄得可怜:一个二维数组、一次连续同色扫描、一次交换回滚判定,核心不到两百行。但把这几百行放进 cocos2d-x 里做成 candy crush 那种手感,工程量会突然翻三倍——落点吸附、连锁下落的时序、动画期间锁输入、死局重排,每一样都能让人在真机上调到后半夜。这份工程照着一套商业三消的最小可玩闭环写:9×9 棋盘、相邻交换、三连以上消除、级联下落补充、步数与目标结算、消除粒子与音效反馈,全部逻辑集中在 Classes 目录,编译出来就能上手点。适合两类人:想搞懂三消判定和动画时序到底怎么排的初学者,以及要拿一套能跑的骨架去换皮做定制的开发者。
2. 项目定位与技术选型:这套骨架到底覆盖到哪一步
先厘清边界。市面上叫「三消 Demo」的东西差别极大,有的只做了消除判定,连下落动画都是瞬移;有的把整套关卡、道具、体力、内购都堆上了。这份工程卡在中间偏实用的位置:玩法闭环完整,运营系统一概没有。判断一个三消骨架值不值得拿来改,就看它有没有把「时序」这件事做对——数据变更和动画播放是不是分离的、动画期间输入有没有被锁住、级联过程能不能被中途打断。这三点做对了,后面加什么功能都是在干净的地基上盖楼。
2.1 已经实现的功能与明确没做的部分
能玩到的内容:主场景加载图集、按关卡配置生成盘面、单指滑动或点击两个格子触发交换、无匹配时自动回滚、匹配成功进入级联消除循环、每步消耗一次步数、目标达成或步数耗尽弹出结算面板、重开本关。没做的部分也很清楚:没有账号系统、没有服务端校验、没有道具栏、没有关卡地图。这反而是好事,一套带运营系统的工程你想改玩法,得先拆掉半层代码;一套纯玩法的骨架,你往上加东西是加法,不是减法。
盘面规格按常见做法定在 9×9、5 种颜色。这个数字不是随便拍的:格子太少连锁不起来,乐趣不足;格子太多单次匹配扫描的成本上来,低端机上消除瞬间会掉帧。5 种颜色是「有难度但不至于卡死」的平衡点,颜色种类每多一种,随机盘面出现可行交换的概率就下降一截,玩家会明显感觉「这局怎么老是没得消」。
| 模块 | 主要文件 | 职责 |
|---|---|---|
| 场景与输入 | GameScene.cpp | 触摸事件、HUD、结算弹窗、重开 |
| 棋盘逻辑 | Board.cpp | 盘面数据、匹配扫描、交换回滚、级联 |
| 格子实体 | Tile.cpp | 精灵、状态标记、移动与消失动画 |
| 关卡数据 | LevelConfig.* | 读取 JSON、生成目标条件 |
| 存档 | GameData.* | 关卡进度、星级、音效开关 |
2.2 目录结构与构建入口
工程是标准的 cocos2d-x 3.x 布局(你手上的project文件和CMakeLists.txt为准,别照抄别人的版本号)。逻辑全在Classes,资源全在Resources,两边靠相对路径和FileUtils::setSearchPaths挂钩,换平台时最容易翻车的就是这条路径。
candy-match/ ├── Classes/ │ ├── AppDelegate.cpp # 启动、分辨率策略、首个场景 │ ├── GameScene.cpp/.h # 主场景:HUD + 触摸 + 结算 │ ├── Board.cpp/.h # 盘面数据与全部消除逻辑 │ ├── Tile.cpp/.h # 单个格子的精灵封装 │ ├── LevelConfig.cpp/.h # 关卡 JSON 解析 │ └── GameData.cpp/.h # UserDefault 存档 ├── Resources/ │ ├── res/ │ │ ├── tiles.plist # 图集描述 │ │ ├── tiles.png # 图集大图(所有糖果切片) │ │ ├── ui.plist / ui.png # HUD 与弹窗切片 │ │ └── particle/ # 消除粒子 plist │ ├── sound/ # swap / match / cascade / fail │ └── level/ │ └── level_001.json # 单关配置,一关一文件 └── proj.*/ # 各平台工程目录一条血泪经验:图集不要一张糖果一个 png。9 种状态(5 色 + 特殊块 + 选中态 + 空)加起来二三十个小图,散图会让 DrawCall 直接飙上去,低端机上消除那一下掉帧特别明显。全部打进一张 1024×1024 的图集,用SpriteFrameCache统一取,DrawCall 能压到个位数。
2.3 棋盘为什么用二维数组,而不是节点堆或 TileMap
新手常见的写法是「一个糖果一个 Sprite,加进 Layer,位置算出来摆上去」,然后靠遍历子节点来找匹配。这条路走到第三步就废了:你得从精灵身上反查它是第几行第几列,getPosition()反算再取整,浮点误差一进来,边界格子就归属错乱;删除节点时子节点数组还在变,遍历直接崩。
所以这份工程把数据层和表现层彻底切开:Board里只有std::vector<std::vector<Tile>>,Tile只存类型和状态,精灵指针也挂在里面但逻辑判断一个都不碰它。任何一次消除,先改数组,再让数组去驱动精灵播动画。这样做的直接好处是:判定逻辑可以脱离引擎单元测试,把Board拎出来喂一个固定盘面,两秒钟就能验证匹配算法对不对,不用等编译跑真机。
// Board.h —— 数据层的全部家当 struct GridPos { int row; int col; }; struct Tile { int type = 0; // 0 表示空;1..5 对应五种糖果 int state = 0; // 0 正常, 1 选中, 2 消除中, 3 下落中 cocos2d::Sprite* sprite = nullptr; // 只是挂载,逻辑层不读它 }; class Board { public: static const int kTypeNone = 0; bool isInside(int r, int c) const { return r >= 0 && r < _rows && c >= 0 && c < _cols; } Tile& at(int r, int c) { return _grid[r][c]; } private: int _rows = 9, _cols = 9; bool _busy = false; // 全局输入锁,动画期间为 true std::vector<std::vector<Tile>> _grid; cocos2d::Node* _boardNode = nullptr; // 所有糖果精灵的父节点 cocos2d::Vec2 _boardOrigin; // 棋盘左下角在 _boardNode 里的坐标 cocos2d::Size _tileSize; // 单格尺寸,由图集切片决定 };参数说明:kTypeNone用 0 而不是 -1,是为了让「空」也能参与==比较而不用额外判空;_busy这把锁是整个手感的地基,缺了它,玩家快速连点两下就能在动画中途发起第二次交换,盘面数据和精灵位置立刻对不上;_boardOrigin存的是棋盘左下角,用左下角而不是中心,是因为节点坐标原点就在左下,后面换算少一次加减。
3. 核心链路拆解:一次交换从手指落下到连击结算
这一章是整套工程里最值得逐行读的部分。三消的难点从来不是「怎么判断三个连一起」,而是「判断完之后那一秒钟内,数据、动画、输入三者怎么保持同步」。下面按一次真实操作的顺序走:触摸落点换算 → 相邻判定与交换 → 无匹配回滚 → 有匹配进入级联 → 动画结束解锁。
3.1 触摸落点换算:屏幕点怎么变成第几行第几列
这一步是所有坐标玄学的源头。注意两个坑:一是convertTouchToNodeSpace和convertToNodeSpace的区别,前者直接吃Touch*且已经把节点的缩放、旋转、父子变换全算进去,后者只做坐标变换;二是 Y 轴方向,节点坐标向上增,而「第几行」的习惯是从上往下数,必须翻一次。
// GameScene.cpp —— 触摸只干一件事:把屏幕点翻译成格子坐标 bool GameScene::onTouchBegan(cocos2d::Touch* touch, cocos2d::Event* /*unused*/) { if (_board->isBusy()) return false; // 动画期间直接吞掉,不进入选中流程 // 用 convertTouchToNodeSpace,缩放/偏移由引擎算好 cocos2d::Vec2 p = _boardNode->convertTouchToNodeSpace(touch); float localX = p.x - _boardOrigin.x; float localY = p.y - _boardOrigin.y; int col = static_cast<int>(localX / _tileSize.width); int row = _rows - 1 - static_cast<int>(localY / _tileSize.height); // Y 轴翻转 if (!_board->isInside(row, col)) return false; // 边界外不接管 _board->select(GridPos{row, col}); return true; }逻辑说明:localX / _tileSize.width取整就是列号,因为格子是等宽排列的;行号必须先减再翻,写成_rows - 1 - floor(...)而不是_rows - floor(...),差一位就会整体错行一格,表现是「点上面一行,下面一行亮」。参数上,_boardOrigin建议在onEnter里按「棋盘总宽高 + 屏幕尺寸」算出来并居中,别在编辑器里手填像素,换分辨率必翻车。
提示:
static_cast<int>对负数会朝零截断,localX为负时-0.5会变成0,看起来像越界却通过了判断。要么先判localX < 0直接返回,要么用std::floor再转。
3.2 交换、匹配判定与回滚:先改数据,再放动画
顺序很重要。正确顺序是「改数组 → 播交换动画 → 扫匹配 → 没匹配就把数组改回来再播回退动画」。反过来先播动画再改数据,你会得到一帧数据和画面不一致的状态,如果这时玩家点了重开,就是一次典型的空指针狂欢。
// Board.cpp —— 一次交换的完整事务 void Board::trySwap(const GridPos& a, const GridPos& b) { if (_busy) return; // 双保险,场景层已经拦过一次 if (!isAdjacent(a, b)) return; // 只允许上下左右,斜角不接 _busy = true; swapData(a, b); // 1. 先改数据 playSwapAnim(a, b, [this, a, b]() { // 2. 动画结束再判 std::vector<GridPos> matches = findMatches(); if (matches.empty()) { swapData(a, b); // 3. 无匹配:数据回滚 playSwapAnim(b, a, [this]() { _busy = false; }); // 动画一起退 } else { resolveCascade(); // 4. 交给级联,它会自己解锁 } }); } bool Board::isAdjacent(const GridPos& a, const GridPos& b) const { return std::abs(a.row - b.row) + std::abs(a.col - b.col) == 1; }参数说明:isAdjacent用曼哈顿距离判 1,比写四个if分支干净,斜着划过去自然被拒。回调里捕获a, b是按值捕获,别用引用捕获局部变量——trySwap早就返回了,引用捕获在那个时刻指向的是已销毁的栈帧,这类崩溃在真机上表现为「偶尔崩在消除后」,极难复现。
匹配扫描本身用「行程长度」写法,比双重循环逐格检查四个方向快得多,也不会重复计数。
// Board.cpp —— 整个算法里最核心的二十行 std::vector<GridPos> Board::findMatches() const { std::set<std::pair<int,int>> hit; // 横向:数连续同色长度,>=3 就整段标记 for (int r = 0; r < _rows; ++r) { int run = 1; for (int c = 1; c <= _cols; ++c) { bool same = (c < _cols) && (_grid[r][c].type == _grid[r][c - 1].type) && (_grid[r][c].type != kTypeNone); if (same) { ++run; continue; } if (run >= 3) { for (int k = c - run; k < c; ++k) hit.insert({r, k}); } run = 1; } } // 纵向同构,把 r/c 互换即可,这里省略 scanVertical(hit); std::vector<GridPos> out; out.reserve(hit.size()); for (auto& p : hit) out.push_back(GridPos{p.first, p.second}); return out; }逻辑说明:循环跑到c == _cols时same恒为 false,强制收尾,这样最后一段连续色也能被统计到,不用额外写一段尾处理。用std::set收集是为了处理 L 形和 T 形交叉——横竖两次扫描会把交叉格算两遍,set天然去重。这里有个小坑:std::pair的默认比较是先行后列,集合顺序和消除动画的播放顺序不一致,如果你想让消除按从左到右的节奏走,最后还得排一次序。
3.3 消除、压缩、补充:级联循环怎么收尾
级联是三消最出效果的地方,也是最容易写出「幽灵格子」的地方。推荐的写法是把一「轮」拆成三段:标记并播放消失动画 → 逐列压缩把空位挤到底部 → 顶部补新块,落完之后递归下一轮,直到findMatches()返回空。
// Board.cpp —— 一轮级联,四个步骤 void Board::resolveCascade() { auto matches = findMatches(); if (matches.empty()) { // 递归出口:没有三连了 _busy = false; // 在这里统一解锁,别在中间层解锁 checkGoal(); return; } for (auto& p : matches) _grid[p.first][p.second].state = kStateClearing; playClearAnim(matches, [this, matches]() { for (auto& p : matches) _grid[p.first][p.second] = Tile{}; // 置空 for (int c = 0; c < _cols; ++c) { int write = 0; // write 指向下一个可落位置 for (int r = 0; r < _rows; ++r) { if (_grid[r][c].type == kTypeNone) continue; if (r != write) moveTile(r, c, write, c); // 数据+动画一起搬 ++write; } for (int r = write; r < _rows; ++r) spawnTile(r, c); // 从上方补新 } resolveCascade(); // 下一轮 }); }逻辑说明:压缩那段是标准的双指针原地压缩,write只在新块落到目标位置时自增,r一直往前走。r != write这个判断既省了一次无意义的移动动画,也避免了「同一个精灵自己移动到自己身上」导致动作被覆盖的怪现象。
参数上,新块的初始位置建议设在棋盘顶部之外一格,再用MoveTo落到目标格,视觉上就是「从上面掉进来」;如果直接setPosition到目标再播放缩放动画,观感会像凭空冒出来。spawnTile里给每个新块的落点时间加一个0.02s * (r - write)的随机抖动,下落节奏就不会像排队军训。
注意:
resolveCascade里matches是按值捕获的,因为它要在 lambda 里用两次(置空和播放动画)。改成引用捕获的话,下一轮递归会把它覆盖掉,表现是「第一次消除动画播完后,格子里还留着上一批的残影」。
3.4 让手感对起来的几个时间参数
功能跑通之后,剩下的全是调参。这套工程的数值参考:交换动画 0.12s,用EaseBackOut起步快收尾带一点回弹;消除是ScaleTo到 0 加FadeOut,0.15s,Spawn并行;下落 0.18s,用EaseBounceOut落地会弹一下;整轮级联之间插 0.03s 的间隔,让玩家看清是「第二轮」而不是一次消完。
// Tile.cpp —— 一个糖果的消失动作 auto vanish = cocos2d::Spawn::create( cocos2d::ScaleTo::create(0.15f, 0.0f), // 缩到没 cocos2d::FadeOut::create(0.15f), // 同时淡出 nullptr); auto done = cocos2d::CallFunc::create(callback); // 动画完才回调改数据 sprite->runAction(cocos2d::Sequence::create(vanish, done, nullptr));这几个数字没有绝对正确的答案,但有个判断标准:单次交换到第一次消除的反馈延迟超过 0.2s,玩家就会觉得「卡」;级联间隔超过 0.1s 又会觉得「慢」。把这两个上限记牢,剩下的按你美术资源的分辨率和节奏感微调就行。
4. 关卡数据与盘面生成:别让玩家开局就没得点
逻辑跑通之后,第二层工作是「这盘棋怎么发」。随机发牌看着简单,实际上两个隐藏条件必须同时满足:开局不能自带三连(否则玩家还没动手就自动消了一波),开局必须存在至少一个可行交换(否则就是死局)。这两条在真实产品里都有对应实现,缺一个都会被玩家吐槽。
4.1 生成一份既无三连又必有解的盘面
做法是先生成再修补:填充每个格子时避开「和左边两个同色」以及「和下面两个同色」这两种情况,这样一次性就能得到无初始三连的盘面;然后跑一次可行性探测,没有可行交换就整盘重来或者局部重排。
// Board.cpp —— 填格子时就把三连掐死在摇篮里 int Board::pickTypeAvoiding(int r, int c) { std::vector<int> banned; if (c >= 2 && _grid[r][c-1].type == _grid[r][c-2].type) banned.push_back(_grid[r][c-1].type); // 左边已经两个同色 if (r >= 2 && _grid[r-1][c].type == _grid[r-2][c].type) banned.push_back(_grid[r-1][c].type); // 下面已经两个同色 std::vector<int> pool; for (int t = 1; t <= kTypeCount; ++t) if (std::find(banned.begin(), banned.end(), t) == banned.end()) pool.push_back(t); return pool[randInt(0, (int)pool.size() - 1)]; // 从合法集合里随机 }逻辑说明:banned最多两个元素,pool最少还剩三种,所以永远不会出现「无颜色可选」的死循环。比起「随机完再全局扫一遍、有三连就重来」的暴力做法,这个写法在 9×9 上一次成型,关卡加载几乎无感。
死局检测则复用匹配逻辑:对每个格子,试着和右邻、上邻交换数据,调一次findMatches()(在盘面副本上),有结果就说明有解。
// Board.cpp —— 可行性探测,注意必须在副本上做,别污染真盘面 bool Board::hasPossibleMove() const { auto copy = _grid; // 值拷贝,安全 for (int r = 0; r < _rows; ++r) { for (int c = 0; c < _cols; ++c) { if (c + 1 < _cols) { std::swap(copy[r][c], copy[r][c+1]); if (hasMatchIn(copy)) return true; std::swap(copy[r][c], copy[r][c+1]); // 换回来 } if (r + 1 < _rows) { std::swap(copy[r][c], copy[r+1][c]); if (hasMatchIn(copy)) return true; std::swap(copy[r][c], copy[r+1][c]); } } } return false; }每次交换后必须原样换回来,忘了这一句就会得到一个「被探测过程打乱」的棋盘——这个 bug 的表现极其迷惑:盘面看着正常,但玩家点下去发现两个格子的糖果颜色和刚才看到的不一样。
4.2 关卡配置:一关一个 JSON,别硬编码在类里
关卡参数(行列数、颜色种类、步数上限、目标类型、目标数量、是否限制时间)全部外置成 JSON,LevelConfig只负责解析成结构体。这样加关卡不用重新编译,改数值不用改代码,是二次开发阶段最省事的一个决定。
{ "id": 12, "rows": 9, "cols": 9, "typeCount": 5, "moves": 25, "goals": [ { "kind": "collect", "type": 1, "count": 40 }, { "kind": "score", "count": 12000 } ], "starScore": [6000, 9000, 12000] }kind目前支持collect(收集指定颜色 N 个)和score(累计分数)两种,结算时按goals数组逐条判定,全部满足才算过关,starScore的三个阈值决定几星。type用整数而不是颜色字符串,是为了和Tile::type直接对齐,省掉一层字符串映射——"red"拼错一个字母在运行时才报错,1写成2至少还能编译通过,出错位置也更好定位。
提示:JSON 解析失败时不要静默返回默认值。加一句
CCLOG("level %d parse failed", id)并回退到第一关,否则改配置时少一个逗号,你会盯着一个空棋盘怀疑人生。
4.3 存档与结算:进度、星级和音效开关
进度存UserDefault就够了,这种纯单机玩法没必要上数据库。键名统一加前缀,避免和引擎或其他模块撞车。
// GameData.cpp —— 三行存取,别过度设计 void GameData::saveLevelResult(int levelId, int stars, int bestScore) { auto* ud = cocos2d::UserDefault::getInstance(); // 只在更高星时覆盖,防止重玩打低分把记录冲掉 int oldStars = ud->getIntegerForKey(StringUtils::format("lv_%d_stars", levelId).c_str(), 0); if (stars > oldStars) { ud->setIntegerForKey(StringUtils::format("lv_%d_stars", levelId).c_str(), stars); } int oldBest = ud->getIntegerForKey(StringUtils::format("lv_%d_best", levelId).c_str(), 0); if (bestScore > oldBest) { ud->setIntegerForKey(StringUtils::format("lv_%d_best", levelId).c_str(), bestScore); } ud->setIntegerForKey("unlocked_level", std::max(levelId + 1, ud->getIntegerForKey("unlocked_level", 1))); ud->flush(); // 关键:不 flush,杀进程时可能丢档 }flush()这一句是必须的,UserDefault内部有写缓存,不加它,玩家在结算弹窗出现的一瞬间强杀应用,进度就没了——这类问题在测试阶段几乎测不出来,上线后差评里全是「打完关卡退出,进度没了」。
音效用AudioEngine::play2d而不是SimpleAudioEngine,前者支持同时播放多个音效实例,连锁消除时match和cascade叠在一起不会互相打断。预加载放在场景onEnter里,别在点击回调里现加载,那一下的 IO 延迟足够让手感变钝。
5. 常见问题排查:五个真实翻过车的地方
这套逻辑看着直白,真跑起来的问题基本集中在「数据和表现不同步」这一件事上。下面五条是复现率最高、且新手最容易卡住的地方,按「现象 → 原因 → 解决」写,遇到对得上的直接抄结论。
5.1 快速连点后,糖果停在半空不动
现象:手指快速划两下,第二次交换的两颗糖果停在半途,之后整个棋盘点不动,只能重开。
原因:_busy只在resolveCascade的递归出口被置回 false,但回滚分支里是靠playSwapAnim的回调解锁的。如果第一次交换正在回滚(正在播第二次交换动画),第二次触摸进来时_busy还是 true,被onTouchBegan拦掉了——这没问题。真正的问题是某些情况下playSwapAnim的回调因为节点被removeFromParent(比如玩家点了重开)而没有执行,锁就永远留着。
解决:把解锁从动画回调里挪出来,改成在update里做超时兜底——记录_busySince,超过 3 秒还是 busy 就强制解锁并CCLOG报警。同时在Board::reset()里显式把_busy置 false,别指望对象析构帮你清状态。
5.2 消除动画播完了,格子还是原来的样子
现象:三连的糖果缩小消失,但两秒后又原地出现同色糖果,或者干脆变成透明块占着位置。
原因:playClearAnim的回调里改的是_grid,但精灵是挂在_boardNode上的子节点,回调只置空了数据没移除精灵;或者反过来,removeFromParent了但数据没清,下一轮压缩时读到的是个type非零、sprite为野指针的Tile。
解决:约定一条铁律——数据先改,精灵跟着数据走。写一个Tile::detach()同时做两件事:sprite->removeFromParent(); sprite = nullptr;。所有清理路径只调这一个方法,不在业务代码里零散地removeFromParent。
5.3 低端机上消除瞬间卡顿,帧率掉到 20
现象:模拟器丝滑,真机一到连锁消除就顿一下,连锁越长越明显。
原因:每个糖果是独立 Sprite 且用了散图,DrawCall 数量随盘面线性增长;同时每轮消除都Sprite::create新建节点,没有复用池。
解决:两件事一起做。资源侧把所有糖果打进一张图集,用SpriteFrameCache::addSpriteFramesWithFile("tiles.plist")预加载,之后一律Sprite::createWithSpriteFrameName;对象侧做一个简单的节点池,消除时setVisible(false)收回池子,补充时从池里取,不要频繁创建销毁。改完 DrawCall 通常能从七八十压到个位数。
5.4 数组越界崩溃,堆栈指向findMatches
现象:随机崩在findMatches的_grid[r][c],或者_grid的operator[],崩溃点每次都不一样。
原因:_rows/_cols和_grid的实际尺寸对不上。常见于「关卡配置读的是 9×9,但_grid是按上一次关卡的 8×8 初始化的」,或者resize只做了一层。
解决:_grid的初始化收敛到一个initGrid(rows, cols),里面_grid.assign(rows, std::vector<Tile>(cols))一步到位,同时更新_rows/_cols。任何需要改尺寸的地方只调这一个函数,其余地方禁止直接碰_grid的尺寸。
5.5 提示误判:明明没得消,界面却说还能动
现象:盘面已经死局,玩家点了半天没有反应,游戏也不自动重排或提示。
原因:hasPossibleMove里交换后忘了换回来,第二次循环时副本已经被污染,返回了错误的 true。
解决:见 4.1 的代码,用「换一次、判一次、再换回来」的三段式,或者干脆每次循环开头重新auto copy = _grid;(9×9 的拷贝成本可以忽略)。另外在checkGoal之后补一句if (!hasPossibleMove()) reshuffle();,重排时加个整体缩放动画,玩家不会觉得是 bug,反而觉得是设计。
6. 二次开发:换皮快、加道具慢,验证清单别省
拿到这套骨架,改起来分两条路。换皮是最快的:把tiles.png/tiles.plist换掉,保证切片名(candy_1~candy_5)和特殊态名称一致,再把particle/里的粒子颜色调一调,LevelConfig里的typeCount跟着改。整个过程不碰一行逻辑代码,半天能出可演示的版本。这里唯一的技巧是:新图集尺寸建议保持 1024×1024 且单格切片尺寸不变,否则_tileSize得跟着重算,_boardOrigin的居中逻辑也要一并改。
加道具就慢得多,因为它要动核心链路。以「炸弹」为例,改造点有三处:Tile加一个bool isBomb,findMatches返回后先判断命中集合里有没有炸弹,有就以命中点为中心扩展一圈行列;resolveCascade里扩展出来的格子必须和普通匹配走同一条清理路径,否则会出现「炸掉了但下落没算」;最后trySwap里要允许「交换涉及炸弹时即使不产生三连也生效」,这意味着回滚条件从「无匹配」变成「无匹配且双方都不是炸弹」。三个点漏一个,炸弹就是半成品。我的建议是先不加道具,把基础玩法的时序跑顺,再动这块。
改完之后,别急着打包,按下面这份清单过一遍。每一条都是别人踩过并付出过代价的。
| 验证项 | 怎么验 | 不合格的表现 |
|---|---|---|
| 边界落点 | 点四角格子和棋盘外一像素 | 角落点不中,或点到外面也选中 |
| 连点压力 | 快速划 20 下不停 | 卡死、糖果悬空、盘面错位 |
| 回滚一致性 | 连续做 10 次无效交换 | 数据与画面颜色不一致 |
| 长时间级联 | 造一个能连 5 轮的盘面 | 中途掉帧、动画错位、锁未释放 |
| 杀进程存档 | 结算弹窗出现时强杀 | 进度回退、星级丢失 |
| 死局恢复 | 手动构造无解盘面 | 无重排、点击无响应 |
我一般把最后一章的验证当成习惯而不是流程:每改完一个和盘面数据相关的函数,先不打包,直接在Board::findMatches后面插一行断言,把当前_grid打印出来和界面对一眼。三消这玩意儿最坑的地方在于它不会立刻崩,而是让数据和画面慢慢错开,等你在真机上发现「这个格子明明有糖果但点不动」的时候,已经不知道是三步前哪次改动埋的雷了。从那以后我每次改完消除逻辑都强制走一遍「打乱 → 交换 → 回滚 → 连锁」这四步,肉眼看一眼日志,比真机点半小时都管用。这套骨架的好处在于是纯逻辑,你可以放心大胆地改,希望帮到你。
本文还有配套的精品资源,点击获取