简介:Sweet Candy Match 3 甜蜜糖果三消是一款基于 Unity 2020.1.17f1及以上版本开发的完整益智项目源码,面向想要系统学习三消玩法与商业化落地的 Unity 开发者,提供可直接运行的工程与超过百个经过测试的关卡,解决从零搭建同类型游戏的核心难题。资源包共 2001 个文件、约 770MB,涵盖 C# 脚本、Prefab 预制体、Anim 动画、Asset 资源、场景文件与 JSON 配置等,目录结构清晰,便于按模块研读和二次开发。目前已有 434 人学习下载,适合具备基础 C# 能力、想深度实践游戏完整流程的中级开发者。项目内置六种游戏物品及条纹、包装、果酱等特殊特效,支持动态 Reskin 系统换肤扩展,通过重力系统控制棋盘移动方向,并集成 Unity Ads、Admob、激励视频与几十个广告需求来源;同时包含每日奖励、连击反馈、音画表现及奖励旋转小游戏,能帮助读者掌握关卡设计、资源管理、广告变现与迷你玩法整合的完整思路。
1. Sweet Candy Match 3 源码项目到底值不值得入手
很多人第一次看到 Sweet Candy Match 3 甜蜜糖果三消这类 Unity 项目源码,第一反应是换个美术资源就能上线。等你真把它跑起来才发现,三消的核心根本不在糖果好不好看,而在那套网格交换、三连匹配、消除下落、连锁结算的循环逻辑和手感参数。源码项目最值钱的地方,就是把这些底层的 2D 数组操作、匹配检测、重力补齐都写好了,你只需要读懂结构,把界面、关卡、数值换成自己的,就能快速做出一个能玩的三消 Demo。它适合 Unity 新手练手,也适合小团队拿来作休闲产品原型打底。下面我按“怎么读工程、怎么改逻辑、参数怎么调、坑在哪”的顺序,把整个落地路径拆给你。
2. 拆解 Unity 三消源码的骨架:从目录结构到入口场景
三消源码项目拿到手,别急着双击场景点 Play。先看目录,因为你后面八成会遇到“明明逻辑没动,但场景里少了一堆东西”的问题,而这类问题的答案基本都藏在资源组织方式里。常见的三消源码会按功能把脚本拆到 Board、Match、UI、Manager 四个维度的目录里:Board 管棋盘生成,Match 管匹配检测,Manager 管游戏状态和回合流程。这种拆法不是玄学,是为了让你在调一个消除 bug 的时候不用翻遍所有脚本。
2.1 目录里该看什么:场景、脚本、资源的分工
拿到包之后,我一般会按下面这张表先定向扫一遍,而不是点开每个文件夹乱看:
| 文件/目录 | 职责 | 你改它的时候 |
|---|---|---|
| Assets/Scenes/Main.unity | 入口场景 | 换启动流程,比如加个主菜单 |
| Assets/Prefabs/Candy.prefab | 糖果预制体 | 换美术贴图、挂粒子特效 |
| Assets/Scripts/BoardManager.cs | 棋盘初始化、交换入口 | 改棋盘行列数、网格间距 |
| Assets/Scripts/MatchFinder.cs | 匹配检测与消除标记 | 改匹配规则,比如四连、五连 |
| Assets/Scripts/GameManager.cs | 步数、分数、游戏结束 | 改计分方式、步数限制 |
| Assets/Resources/Sprite | 项目素材 | 重命名时小心,别把引用改断 |
这里有个最常见的误区:以为糖果预制体里的 Candy.cs 管一切。实际上,Candy.cs 只负责存自己的行列位置和糖果类型,真正的交换、消除、下落调度都在 BoardManager 和 MatchFinder 里。你要是想改“三消变四消”,只动 Candy 脚本是没用的。
换皮肤相对简单。预制体上挂的 SpriteRenderer 或者 UGUI 的 Image 组件只管显示,双击打开 Candy.prefab 直接替换 Sprite 图,保存后场景会自动引用,不用碰代码。要留意的是 Sprite 的 Pixels Per Unit,常见项目默认是 100,如果你导入的分辨率不一致,棋盘的格子会突然变大变小。
2.2 入口场景与初始化流程:从哪里开始读代码
找入口场景里挂着的 GameRoot 或 GameManager,它的 Awake 和 Start 就定义了整个游戏怎么启动。三消项目的启动顺序通常是固定的:读取当前关卡配置,棋盘按配置创建 grid,生成初始糖果,检查开局有没有现成的消除项,有就清掉重填,最后才允许玩家点击交换。这也是为什么游戏一开始很少出现“全场自动消除”的尴尬场面。
void Start() { LevelConfig cfg = LoadLevel(GameState.CurrentLevel); // 从配置表读本关参数 board.Initialize(cfg.Rows, cfg.Cols); // 按行列数创建棋盘 board.PopulateInitialCandies(); // 开局放满糖果 board.RemoveImmediateMatches(); // 开局不允许出现现成的三消 inputSystem.SetActive(true); // 开局检测通过后才放开交互 }逻辑说明:Initialize 负责网格大小和棋盘位置;PopulateInitialCandies 用随机种子生成糖果,但这里不是纯随机,很多项目会刻意限制生成时就已经三连的情况;RemoveImmediateMatches 调用一次匹配算法,把已有三消除掉并补糖,防止玩家还没操作就先吃一波分数。参数说明:LoadLevel 和 GameState.CurrentLevel 是配置系统的入口,你要做关卡跳转,改的是这里,而不是直接在场景里放十套棋盘。
2.3 全局配置与宏定义:换皮肤和调参不用动逻辑
好的三消源码会把所有可变参数收敛到一个文件里,比如 GameSettings.cs,里面全是序列化字段:行列数、糖果种类数、步数限制、动画时长。这是判断源码质量的一个重要分水岭。我遇到过把参数散落在十几个脚本里的项目,每次调手感都得全局搜索,累死人。
进阶做法是加宏定义做条件编译,比如要做教程关,就加一个 USE_TUTORIAL 的宏,然后在代码里包一段逻辑:
#if USE_TUTORIAL TutorialManager.Instance.ShowStep(3); // 只在宏开启时运行的教程引导 #endif宏定义相关参数说明:Project Settings → Player → Scripting Define Symbols,在这里填 USE_TUTORIAL,代码块才会被编译。好处是你打正式包只要删掉宏,教程逻辑就被编译器忽略,不用删文件,也避免了“教程管理器引用丢失”导致的空指针报错。
3. 匹配与消除逻辑:把核心算法改成自己想要的参数
三消源码里最值得通读的部分,就是匹配检测和消除下落。这套逻辑并不玄学,本质就是一个二维数组的扫描和搬运。很多新手改出 bug,是因为不太理解为什么消除不能直接改数组,为什么交换前要先“仿真”一把。这一章把匹配、下落、交换校验三块拆开讲。
3.1 网格数据结构和匹配检测:水平与垂直扫描怎么共存
糖果棋盘在代码里通常就是一个二维数组,grid[row, col]存的就是 Candy 对象或 null。匹配检测的目标是找出所有“横向或纵向连续同类型超过 3 个”的糖果。常见做法是水平扫一遍、垂直再扫一遍,扫两遍是因为一次扫描没法同时处理两个方向,而且横向扫描结束后,纵向扫描必须从原始网格重新开始,否则会把已经标记消除的糖果误算进新链条。
private List<Candy> FindAllMatches() { List<Candy> targets = new List<Candy>(); // 横向扫描:从每行最右边开始往回数,连续同类就计数 for (int r = 0; r < rows; r++) { int chain = 1; for (int c = 1; c < cols; c++) { if (grid[r, c] != null && grid[r, c - 1] != null && grid[r, c].Type == grid[r, c - 1].Type) { chain++; } else { CollectChain(targets, chain, r, c - 1); chain = 1; // 断链后重新计数 } } CollectChain(targets, chain, r, cols - 1); // 扫到行尾也要收一次 } // 垂直扫描:按列扫,逻辑与横向对称 for (int c = 0; c < cols; c++) { int chain = 1; for (int r = 1; r < rows; r++) { if (grid[r, c] != null && grid[r - 1, c] != null && grid[r, c].Type == grid[r - 1, c].Type) { chain++; } else { CollectChain(targets, chain, r - 1, c); chain = 1; } } CollectChain(targets, chain, rows - 1, c); } return targets; }逻辑说明:grid 的行列索引我按 r 表示行、c 表示列写,避免和 UI 坐标混淆。collect 的调用位置有两个小细节。第一,横向扫到最后一列时,循环结束时那条链还没被记录,所以必须在 for 循环之后再补一次 CollectChain;第二是 CollectChain 内部要判断,如果链长大于等于 3,就把这些糖果标记为待消除,而链长本身可以通过参数传进去。
参数说明:把chain >= 3改成chain >= 4就是四消逻辑;注意这里只能返回“被消除的糖果列表”,不能直接改 grid,因为你怎么改 grid 会影响后续下落算法,理论上讲,消除是“标记”,下落是“搬运”,必须先全部标记完,再统一下落,否则会漏掉同一次操作里的交叉消除。
3.2 消除后的下落与补齐:重力逻辑为什么不直接改数组
标记完消除糖果后,常见做法是先播放消除动画,播放完再执行下落。下落阶段有个很关键的决定:每次消除后,同一列可能留下多个空洞,如果你从头到尾按顺序搬糖果,会造成糖果穿插。三消项目普遍采用“从下往上扫描 + 每列一个写入指针”的写法,保证一列内所有存活糖果按顺序落到最底下。
public void ApplyGravityAndRefill() { for (int c = 0; c < cols; c++) { int writeRow = rows - 1; // 从列底部的下一个空位开始写 for (int r = rows - 1; r >= 0; r--) { if (grid[r, c] != null) { if (writeRow != r) { grid[writeRow, c] = grid[r, c]; grid[r, c] = null; grid[writeRow, c].MoveTo(writeRow, c); // 触发移动动画 } writeRow--; } } // 上面的空位全部补新糖 for (int r = writeRow; r >= 0; r--) { grid[r, c] = SpawnNewCandy(r, c); grid[r, c].MoveTo(r, c); // 新糖从棋盘上方飘落 } } }逻辑说明:这里的核心技巧是 writeRow 从最底下往上走,每次把当前存活糖果写到 writeRow 的位置,写完后 writeRow 往上减一位。这样即便中间有空洞,整个列也能完成“压缩”。新糖的生成顺序是从底部往上补,和很多人想象的不同,如果你从顶部往下生成,生成出来的新糖会被旧糖挡住。
参数说明:MoveTo 并不是立刻移动,而是把目标位置存进 Candy,并启动一个协程做动画。这套代码的关键参数是下落时长和缓动曲线,而不是移动距离本身。很多三消源码的“卡顿感”“生硬感”,就来自这里用 Vector3.Lerp 做线性移动,没有用动画曲线。下落补齐之后,紧接着要再执行一次 FindAllMatches,否则连锁消除就不会出现。
3.3 交换合法性校验:死局判定与预判算法
玩家点击相邻两个糖果交换时,绝对不能先交换再判定,那样会出现“交换过去又弹回来”的闪烁,体验很糟糕。正确做法是先在副本网格上模拟交换,如果模拟结果没有形成新消除,就直接拒绝这次操作,不播放交换动画。
bool CanSwap(int r1, int c1, int r2, int c2) { Candy[,] sim = (Candy[,])grid.Clone(); // 克隆二维数组做仿真 (sim[r1, c1], sim[r2, c2]) = (sim[r2, c2], sim[r1, c1]); // 交换副本 return HasAnyMatch(sim); // 只看仿真棋盘是否有三消 }逻辑说明:这一步的重点是不污染真实棋盘。你会发现现代 C# 里元组交换写法非常简洁,但 Clone 出来的数组是浅复制,Candy 对象本身仍然引用原对象,所以这段代码只适用“仿真只看类型不看位置”的场合,因为 HasAnyMatch 只读 Type 属性,不修改对象,所以没问题。参数说明:这一步还有第二个作用,很多源码项目会顺手在这里判断特殊糖果,比如“彩色糖和任何糖交换都合法”,那只需要在进入仿真前加一个类型判断就行。
死局判定则是另一个话题。常见做法是遍历每个糖果,模拟上下左右交换一次,看是否存在可行解。这个算法是 O(棋盘格子数 × 4),普通 8×8 棋盘没有任何性能压力。如果判定当前棋盘无解,就直接洗牌重排,三消项目里“开局 30 步后突然没有可交换项”并不是 bug,而是随机分布问题,源码里往往会有个重排逻辑,你重新实现时注意别把当前分数步数一起重置就行。
4. 在 Unity 里跑通源码:导入、场景加载与手感参数调整
4.1 用 Unity Hub 导入项目并跑通的最小步骤
三消源码通常是整包丢给你,里面已经有 .unity 场景和 Library 之外的完整工程。你用 Unity Hub 导入时,先确认 Unity 编辑器版本和源码要求一致。源码项目如果是旧版本,直接在最新版 Unity 里打开,往往会出现一堆脚本报错,因为某些 API,比如gameObject.SetActive没变,但Rigidbody的老写法对应的useGravity等字段在不同版本里行为有差异。
最小操作顺序是:先在 Unity Hub 的“安装”页装一个 LTS 版本,再在“项目”页点“添加”,选择源码目录。首次打开会重新导入所有资源,时间长短取决于资源量,这个过程不用慌。导入完成后,先在 Console 面板看有没有红色报错;如果有 Missing Script,十有八九是旧项目里 MonoBehaviour 的脚本被移动或重命名导致引用断了,这个在源码项目里非常常见。
然后打开 Assets/Scenes 下的主场景。很多项目有多个测试场景,不会写名字的话,就选文件名最长、带 Main 或 Game 字样的那个。打开后直接点 Play,如果场景里出现紫红色材质,先别急着卸载,看下一章的避坑说明。跑通之后,建议立刻做一个备份:把整个项目文件夹压缩一份,当你的后悔药。后面改坏逻辑时,你能快速回到一个已知能运行的状态。
4.2 节奏手感:交换时长、下落时长与动画曲线的三个参数
三消游戏的手感,很大程度由两个时长决定:交换时长和下落时长。交换太快显得脆,太慢显得黏;下落太快容易让玩家眼睛跟不上。源码项目里这两个参数通常暴露在 GameSettings 的 Inspector 上,我一般会给三组基准值:新手关交换 0.25 秒、下落 0.5 秒,中段关交换 0.2 秒、下落 0.4 秒,高分关再快一点。真正让手感高级的不是数值,而是动画曲线。
public AnimationCurve fallCurve; // 在 Inspector 里调的曲线 IEnumerator MoveCandy(Candy candy, Vector3 from, Vector3 to) { float t = 0f; while (t < fallDuration) { t += Time.deltaTime / fallDuration; candy.transform.position = Vector3.LerpUnclamped(from, to, fallCurve.Evaluate(t)); yield return null; } }逻辑说明:Vector3.LerpUnclamped 配合 AnimationCurve 的曲率,能让糖果下落时先慢后快再刹住,而不是匀速移动。如果你只调 swapDuration 和 fallDuration 感觉不对味,问题多半出在曲线没有改。参数调法:曲线的起点斜率给到 0 到 1 的平滑过渡,终点斜率趋向平缓,这样的下落就有“重量感”。
另一个容易被忽略的参数是 Time.timeScale。你调试时想慢放看特效用它,玩家出现“网络卡顿式操作”也用它的数值表现来判断。要注意的是,Time.timeScale 设成 0 之后,所有基于 time 的 WaitForSeconds 会停住,但基于 realtime 的 UI 动画还会走,这正好引出后面 UI 那节的问题。
4.3 特效与粒子:粒子特效内存泄露的常见雷区与对象池
三消游戏的消除反馈强烈依赖粒子特效。我见过最快的炫酷方案是用 Particle System 做爆炸,但每次都 Instantiate 一个带 ParticleSystem 的预制体,播放完再 Destroy。这个写法的直接后果是,每消除一组糖果就会创建立即销毁一个 GameObject,GC 压力一大,帧数就瞬间掉 5 到 10 帧。尤其在小屏手机上,很容易形成肉眼可见的顿挫。
常见做法是给特效做一个对象池。核心逻辑很简单:播放前从池子里取,播放完不销毁,而是停掉后塞回池子等下次复用。你只需要确保粒子系统的 Duration 比回收延迟短就行。
public class FxPool : MonoBehaviour { public GameObject fxPrefab; private Queue<ParticleSystem> pool = new Queue<ParticleSystem>(); public ParticleSystem Play(Vector3 position) { ParticleSystem ps = pool.Count > 0 ? pool.Dequeue() : CreateNew(); ps.transform.position = position; ps.gameObject.SetActive(true); ps.Play(); StartCoroutine(Recycle(ps, ps.main.duration)); return ps; } private IEnumerator Recycle(ParticleSystem ps, float delay) { yield return new WaitForSeconds(delay + 0.1f); // 加 0.1 秒缓冲 ps.Stop(); ps.gameObject.SetActive(false); pool.Enqueue(ps); } private ParticleSystem CreateNew() { return Instantiate(fxPrefab, transform).GetComponent<ParticleSystem>(); } }逻辑说明:池子用 Queue,因为先入池的先复用,FIFO 最符合粒子寿命特性,避免一个粒子还没播完就被取走。参数说明:Recycle 的 delay 是 duration + 0.1 秒,确保粒子播放完再停止,否则会出现“特效闪一下被截断”的现象。如果你发现内存占用不降,检查两件事:一是粒子系统的 Stop Action 选没选 Disable,如果选成 Destroy 会把被池化的对象直接销毁,那池子就名存实亡;二是是否在 Recycle 里调用了 Destroy,这是最常见的坑。
5. 三消项目避坑记录:材质、相机、UI 与内存的典型问题
5.1 现象:糖果材质变成紫红色
现象:打开场景后,所有糖果变成紫红色方块,整个棋盘没法看,新手会以为是下载的源码坏了。
原因:Unity 里材质变成紫红色,几乎都是着色器找不到或者不兼容。常见场景有两种:第一种是源码自带的 Shader 在导入时因为路径变化丢失引用,材质球上 Shader 字段为空,引擎只能用默认的错误着色器;第二种是你把项目从内置渲染管线换成了 URP,但材质没有升级,URP 不识别 Built-in 管线的旧 Shader,也会全部变紫。
解决:先选中一个变紫的材质,在 Inspector 里看 Shader 字段。如果显示空,右键重新指定为Sprites/Default(2D 用)或Unlit/Transparent(UI 用)。如果是升级管线导致的批次问题,不要一个个改,直接 Assets 菜单下把整个项目 Reimport,或者把材质全选后右键选Reimport,让 Unity 重新编译着色器。这个操作能解决九成紫红色问题。如果 Reimport 之后还是紫,就要检查你的 shader 文件本身是不是没编过,这样在 Asset 目录里会看到 Shader 旁边有警告图标。
5.2 现象:UGUI 界面不随镜头放大缩小
现象:调整相机后,得分数字和操作按钮尺寸固定不变,明明糖果跟着棋盘变小了,UI 却还是那么大。
原因:这个问题通常出在 Canvas 的 Render Mode。如果 Canvas 是Screen Space - Overlay,它永远贴在屏幕上,跟相机没关系,所以你改相机 FOV,UI 纹丝不动。这在三消项目里不一定是 bug,很多人就是想要独立的 UI 层。但如果你希望 UI 和场景一起缩放,就得把 Canvas 切成Screen Space - Camera,再把 Event Camera 指定成你的主相机。
解决:在 Canvas 上改成 Screen Space - Camera 后,还要看 CanvasScaler 的Scale Factor和Reference Resolution是否匹配你的开发分辨率。经验值是参考分辨率设成 1920×1080,匹配宽或高按 0.5 对半。这里有个附带坑:改成 Camera 模式后,UICanvas 的 Plane Distance 如果设置不当,会导致 UI 在屏幕上太远或太近而看不清,一般给 100 就够了。
5.3 现象:UI 数字滚轮效果失效,分数一直不动
现象:游戏已经在计分,但屏幕上的分数数字从 100 跳到 200 时没有滚动动画,甚至停在 0。有些人会怀疑是缓存问题,实际是逻辑问题。
原因:三消项目常用协程做数字滚动,而协程里如果用WaitForSeconds或WaitForSecondsRealtime,只要你把Time.timeScale设为 0 来暂停游戏,前者会被卡死,后者不受影响。这就导致很多项目在“暂停界面”里把 Time.timeScale 设置成 0 后,分数滚动协程没有报错,但动画永远完不成。另一种原因是 UI 上挂了 CanvasGroup,它的blocksRaycasts开着挡住了射线,按钮没问题,但动画被其他逻辑中断。
解决:首先全局搜WaitForSeconds(,把游戏内 UI 动画的等待全部换成WaitForSecondsRealtime,这样不会因为 timeScale 变化而卡住。其次是检查 UI 动画是不是被人为 SetActive false 了,特别是做“结算界面弹出”时把根节点隐藏,动画自然失效,要改成只把 CanvasGroup 的 alpha 改成 0,而不是隐藏节点。排到这里,基本能覆盖九成“数字不滚”的情况。
5.4 现象:包体变大与启动卡顿
现象:打出的 APK 超过 300MB,首屏加载要等三秒以上,小内存手机上直接闪退。
原因:绝大多数三消源码的美术资源没有压缩优化。常见问题有三类:第一,UI 图集过大,部分 PNG 被设成 2048×2048 以上导入;第二,没有用图集,每颗糖果一张独立纹理,加载时产生大量小纹理上传请求;第三,粒子特效用高分辨率序列帧,几百张图直接拖慢启动加载。
解决:先看 Project Settings 里Sprite Atlas有没有启用。把糖果和 UI 素材放进一个图集是包体优化最直接的动作,至少能省一半内存。然后是纹理导入设置,将图片压缩格式改成 ASTC(安卓)或 ASTC 6×6,分辨率按实际显示大小的一半导入。很多项目犯的错是设计师给了 2K 原图,就直接 2K 导入,其实显示在 200 像素的格子里,压缩到 512 完全够用。最后是启动卡顿,常见做法是场景里堆了太多预制体,把“开局不需要的结算特效、教程引导”全部放进 Resources 或者 AssetBundle 延迟加载。
6. 让源码变成自己的产品:数据驱动关卡与验证技巧
6.1 用配置表驱动关卡,而不是改代码
玩懂一个三消源码之后,你一定会想把它改成多关卡产品。底线建议是别把每关的行列数、步数、目标分数写死在代码里。我常用ScriptableObject存关卡配置:每一种糖果类型、每关棋盘大小、限定步数、三星分数线,都放进一个关卡条目里,再用一个 LevelDatabase 去索引。这样做的好处是,调关卡平衡只用改 Inspector,不用开代码。如果你要做运营和后端,再把这个表转成 JSON 或远程配置,代码结构都不用动。
6.2 慢速调试与批量验证技巧
最后给你一个我踩了好几次坑才养成的习惯:在改动匹配算法后,先不急着上手玩,在 BoardManager 上挂一段编辑模式测试脚本,自动模拟几百次随机交换,检查每次交换后是不是都满足“消除了 N 个、补满了空位”。这个脚本会把隐藏的数组越界问题一次性挖出来,而不是等到测试人员在手机上一关关试出来。调试手感时,用 Time.timeScale 把游戏调成 0.5 倍速,观察交换和掉落的节奏曲线是否丝滑;确认没问题再调回 1。我真吃过“只调了下落时长忘了重测匹配”的亏,结果上线版本出现空棋格,糖果飘在半空。后来我只信自动化校验,不信手感。这个方向只要这样细心走一遍,完全可以成为你手里能反复复用、快速出 Demo 的核心资产,希望帮到你。
本文还有配套的精品资源,点击获取