ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity消消乐小游戏开发实战:三消算法、对象池与协程实现

Unity消消乐小游戏开发实战:三消算法、对象池与协程实现 简介基于Unity开发的消消乐小游戏完整源代码包面向毕业设计、期末大作业与课程设计场景适合需要可运行、带注释的Unity项目的在校生及初学者。项目由个人手打完成并获98分系统功能完善、界面美观下载后简单部署即可直接运行也可作为二次开发基础。压缩包共321个文件、86.96MB以C#脚本24个cs、Unity资源配置文件20个asset、预制体8个prefab、材质9个mat、美术贴图78个png及3个psd源文件为主另有165个meta元数据用于Unity工程识别整体结构清晰便于阅读与调试。代码注释覆盖核心消除算法与交互逻辑新手也能快速上手。项目内置完整的游戏场景与UI资源可直接作为毕设演示或课程答辩素材已有323人学习下载。1. 基于Unity开发的消消乐小游戏毕业设计选型的性价比之王每年到了毕设季总有人问我“Unity做什么小游戏最好过”。我的答案一直没变消消乐。原因很简单基于Unity开发的消消乐小游戏在功能完整度和工作量之间拿捏得刚好核心玩法是交换相邻方块、凑齐三个相同方块消除逻辑全在C#脚本里不需要美术和策划托底一个人就能啃完。这个资源我把场景、预制体、UI、代码完整拆过一遍确认它适合两类人一类是做毕设或期末大作业的学生需要尽快跑通一个能演示的游戏另一类是刚入行Unity想练手感的新人想通过三消项目搞懂网格数据、对象池、协程和UI交互。接下来我按“工程骨架、消消逻辑、交互反馈、避坑排查、进阶扩展”的顺序拆每一步都能在编辑器里对着看。2. 先看懂工程骨架预制体、场景结构与网格数据模型拿到压缩包解压之后不要急着双击场景先把目录结构和数据模型搞清楚。我见过太多人一上来就拖场景结果运行后方块乱飞最后查了半天是预制体引用断了。Unity项目里预制体引用丢失是黑匣子问题报错不一定直接多数时候只是Console里飘一行Missing。2.1 场景与目录结构哪些文件能改哪些不能动打开项目后先看Assets目录。典型的消消乐工程会按功能分文件夹Scenes放主场景Scripts按Board、Matching、UI、Audio分模块Prefabs放方块和特效预制体Sprites放贴图。先扫一遍脚本命名就能大致说出这个项目的架构。Assets/ ├── Scenes/ │ └── Main.unity ├── Scripts/ │ ├── Board/ │ │ └── Board.cs │ ├── Matching/ │ │ └── MatchDetector.cs │ ├── Tiles/ │ │ └── Tile.cs │ ├── UI/ │ │ └── GameUI.cs │ └── Utils/ │ └── GridUtil.cs ├── Prefabs/ │ ├── TilePrefab.prefab │ └── EffectBurst.prefab ├── Sprites/ │ └── Tiles/ └── Scenes/ └── Main.unity目录结构不是固定的我拆的这个版本大致如此你拿到的包可能有差异但思路一样先找场景再看脚本最后看预制体。毕业设计答辩时老师第一个问题大概率是“架构怎么分的”到时候指着这个目录说清楚“谁负责什么”就够了。2.2 棋盘数据模型二维数组与网格坐标换算消消乐的核心是棋盘数据模型几乎所有的bug都和它有关。逻辑层是个二维数组每个元素代表方块类型0到6表示七种不同颜色视觉层是场景里的方块GameObject。两者通过网格坐标换算关联这是整个项目的地基。public class Board : MonoBehaviour { public int rows 8; public int columns 8; public float tileSize 1f; public GameObject tilePrefab; private int[,] grid; private void Start() { InitGrid(); SpawnTiles(); } private void InitGrid() { grid new int[rows, columns]; for (int r 0; r rows; r) { for (int c 0; c columns; c) { // 随机给类型但要保证不出现开局三连 grid[r, c] Random.Range(0, 7); } } RemoveInitialMatches(); } private void SpawnTiles() { for (int r 0; r rows; r) { for (int c 0; c columns; c) { Vector2 worldPos GridToWorld(r, c); GameObject tile Instantiate(tilePrefab, worldPos, Quaternion.identity, transform); tile.GetComponentTile().Init(r, c, grid[r, c]); } } } public Vector2 GridToWorld(int row, int col) { // 以棋盘左上角为原点行向下、列向右 float x col * tileSize tileSize * 0.5f; float y -row * tileSize - tileSize * 0.5f; return transform.position new Vector2(x, y); } }逻辑说明InitGrid先随机填充数组再通过RemoveInitialMatches打散开局已有的三连避免玩家还没操作就自动消。SpawnTiles把数组映射成场景里的GameObject棋盘挂在一个空物体下所有方块作为子物体统一管理。参数说明rows和columns决定棋盘大小三消游戏用8x8比较标准。tileSize控制格子间距如果后面改了贴图尺寸tileSize要跟着调不然方块之间会出现缝隙或重叠。2.3 方块预制体与七大类型的管理方式预制体通常只做一个TilePrefab用图片或颜色区分七种类型不给每种类型单独做预制体目的是减少资源冗余也让代码用int索引直接对应图集里的Sprite。Tile.cs的Init方法负责初始化public void Init(int row, int col, int type) { this.row row; this.col col; this.type type; // 从图集里取出对应类型的贴图 Image image GetComponentImage(); image.sprite sprites[type]; }这段代码说明Tile持有自己在逻辑网格中的坐标row和col以及类型type。它要读写自己在数组里的位置必须通过Board来操作坐标换算全部走Board的GridToWorld保证逻辑和视觉同步。从这块开始你已经能看懂这个项目的一半了。接下来是重头戏——消除逻辑。3. 消除逻辑到底在做什么匹配检测、下落与连锁判定消消乐的本质是状态机等待玩家输入、交换、判定、消除、下落、填充、再判定。如果项目里没有这个循环只能算一个假消消乐。毕业设计答辩时老师不会关心方块好不好看而是会追问“消除怎么判定”“交换失败怎么回滚”。这一章把这几件事讲透。3.1 交换合法性检测先判断再动刀玩家拖拽或点击交换两个相邻方块代码不能直接换必须先判断“两个位置相邻且是一横一竖”。交换后还要提前做一次匹配预测如果不能形成三连就直接回滚不让玩家看到无意义的交换。public bool TrySwap(int r1, int c1, int r2, int c2) { if (!IsAdjacent(r1, c1, r2, c2)) return false; // 先在数组里交换 SwapGrid(r1, c1, r2, c2); // 预测交换后是否有可消除组合 if (!MatchDetector.HasMatch(grid)) { // 没有可消除组合回滚 SwapGrid(r1, c1, r2, c2); return false; } // 有可消除组合执行动画 AnimateSwap(r1, c1, r2, c2); return true; } private bool IsAdjacent(int r1, int c1, int r2, int c2) { return Mathf.Abs(r1 - r2) Mathf.Abs(c1 - c2) 1; } private void SwapGrid(int r1, int c1, int r2, int c2) { int temp grid[r1, c1]; grid[r1, c1] grid[r2, c2]; grid[r2, c2] temp; }逻辑说明IsAdjacent用曼哈顿距离等于1判断相邻只允许上下左右交换不能斜角换。SwapGrid先改数组再用HasMatch做预测。很多入门教程不做先验预测结果玩家每次换完都看到“白动一下”体验差得离谱。参数说明HasMatch是MatchDetector暴露的静态方法传入grid二维数组返回bool。如果源码里没这个方法建议自己补上它是后面自动求解和死局检测的基础。3.2 匹配检测算法横向与纵向的三连扫描匹配检测的核心是逐行扫横向、逐列扫纵向找连续三个或以上相同类型的位置标记为待消除。注意不要一边扫描一边立刻清除要先收集所有匹配位置再统一消除不然连锁判定会漏掉交叉位置。public static ListVector2Int GetAllMatches(int[,] grid) { ListVector2Int matches new ListVector2Int(); int rows grid.GetLength(0); int cols grid.GetLength(1); // 横向扫描 for (int r 0; r rows; r) { for (int c 0; c cols - 2; c) { if (grid[r, c] ! -1 grid[r, c] grid[r, c 1] grid[r, c] grid[r, c 2]) { matches.Add(new Vector2Int(r, c)); matches.Add(new Vector2Int(r, c 1)); matches.Add(new Vector2Int(r, c 2)); } } } // 纵向扫描 for (int c 0; c cols; c) { for (int r 0; r rows - 2; r) { if (grid[r, c] ! -1 grid[r, c] grid[r 1, c] grid[r, c] grid[r 2, c]) { matches.Add(new Vector2Int(r, c)); matches.Add(new Vector2Int(r 1, c)); matches.Add(new Vector2Int(r 2, c)); } } } return matches; }逻辑说明用List 收集所有匹配格子的行列号扫描完再统一交给Board消除避免在遍历数组时直接修改内容导致索引越界。这是新手最常见的翻车点。参数说明grid里的-1表示空格一般用于下落后的空隙。如果出现四连或五连这段代码会重复添加同一个位置后面消除时要先去重。3.3 下落与填充让棋盘自己恢复正常消除完成后格子变空重力效果让上方方块下落最上层生成新方块填充。简单项目直接把数组重新排列复杂一点的做移动动画。下落的核心是按列处理从下往上找空格把上面第一个非空方块下移private IEnumerator ApplyGravity() { for (int c 0; c columns; c) { for (int r rows - 2; r 0; r--) { if (grid[r, c] -1) continue; int targetRow r; while (targetRow 1 rows grid[targetRow 1, c] -1) { targetRow; } if (targetRow ! r) { grid[targetRow, c] grid[r, c]; grid[r, c] -1; MoveTileVisual(r, c, targetRow, c); } } } yield return new WaitForSeconds(0.2f); SpawnNewTiles(); }逻辑说明按列从下往上处理每个非空格子找到下方最近的空格并下落。MoveTileVisual负责平滑移动GameObject移动完成后再刷新数组。协程的作用是让下落动画结束前不让玩家输入避免数据不一致。参数说明0.2f是下落动画的等待时间如果后面接SpawnNewTiles要控制生成时不产生新的三连否则会出现“刚生成就自动消”的动效混乱。3.4 连锁判定与得分一次操作触发多段消除连锁判定是手感的来源。一次交换消除后棋盘下落填充填充结果又产生新匹配要再次消除直到稳定。代码上用while循环包住“检测-消除-下落-填充”四步private IEnumerator ResolveBoard() { bool anyMatch true; while (anyMatch) { anyMatch false; ListVector2Int matches MatchDetector.GetAllMatches(grid); if (matches.Count 0) { anyMatch true; ScoreManager.AddScore(matches.Count * 10); RemoveTiles(matches); yield return new WaitForSeconds(0.1f); yield return StartCoroutine(ApplyGravity()); } } // 检测死局必要时重新洗牌 if (!HasValidMove(grid)) { ShuffleBoard(); } }逻辑说明GetAllMatches找到所有可消除位置RemoveTiles把格子标为-1并隐藏ApplyGravity让棋盘塌缩填充然后再次进入循环判定。退出后HasValidMove检查棋盘是否还有合法交换没有了就触发洗牌。参数说明ScoreManager.AddScore里的matches.Count * 10是基础分你可以根据四连、五连、T型消除调整系数。我拆的这份源码里得分逻辑是这个思路具体系数以源码为准。4. 交互与UI点击、拖拽、特效与得分反馈消消乐的交互承载在UI层。这部分看起来简单却是答辩演示时最容易卡壳的地方点击没反应、拖拽方向错了、特效全叠在一起。分三块说输入方式、Canvas层级、得分反馈。4.1 选中与交换输入鼠标事件与手势方向过滤桌面端用鼠标移动端用触摸Unity里用IPointerDownHandler接口或在Update里做射线检测都行。关键点是方向过滤判断玩家从起点拖到终点的主要方向是横向还是纵向避免斜角误触。常见做法是在Tile上挂一个拖拽检测组件public class TileInput : MonoBehaviour, IPointerDownHandler, IPointerUpHandler { private Vector2 startPos; private bool isDragging false; public void OnPointerDown(PointerEventData eventData) { startPos Input.mousePosition; isDragging true; } public void OnPointerUp(PointerEventData eventData) { if (!isDragging) return; Vector2 endPos Input.mousePosition; Vector2 delta endPos - startPos; if (delta.magnitude 40f) { if (Mathf.Abs(delta.x) Mathf.Abs(delta.y)) { Board.Instance.TrySwap(tile.row, tile.col, tile.row, tile.col (delta.x 0 ? 1 : -1)); } else { Board.Instance.TrySwap(tile.row, tile.col, tile.row (delta.y 0 ? 1 : -1), tile.col); } } isDragging false; } }逻辑说明OnPointerDown记录按下位置OnPointerUp计算位移向量。delta.magnitude设置阈值避免点击微动触发交换。方向用Abs比较判断玩家意图在水平还是垂直。参数说明40f的阈值在手机端够用PC上如果鼠标移动慢可能误判。也可以改成用EventSystem.current.IsPointerOverGameObject或检测手指滑动的第一象限。4.2 UI Canvas 层级特效、得分浮字与射线穿透消消乐项目一般把方块放在Canvas下用Image组件而不是SpriteRenderer因为Image能被EventSystem直接点中。最容易踩的坑是粒子特效挂在Canvas同一层会挡住方块点击射线。我习惯把Canvas分成GameLayer放方块、UILayer放按钮和得分文字、FXLayer放特效。FXLayer的Raycast Target全部关掉保证点击只落到方块上。动态生成的得分浮字也要处理public GameObject CreateScorePopup(Vector3 worldPos, int score) { GameObject popup Instantiate(scorePrefab, uiLayer.transform); popup.transform.position Camera.main.WorldToScreenPoint(worldPos); // 关闭自身的Raycast Target避免挡点击 popup.GetComponentImage().raycastTarget false; return popup; }逻辑说明新建的浮字默认Image的raycastTarget是true这会让后续点击被它吸收导致方块收不到事件。创建后马上置为false是UI层一个细小但高频的坑。参数说明uiLayer是Canvas下的空物体动态生成的UI都挂到它下面。WorldToScreenPoint把方块的世界坐标转屏幕坐标让浮字显示在消除位置。4.3 得分计算与连击加成得分反馈直接决定正反馈节奏。简单版本每次消除加固定分好版本根据消除数量、连击次数、剩余时间给出加成。如果源码里已有ScoreManager直接在这个类上扩展。可参考的计分公式public void AddScore(int matchedCount) { int combo currentCombo; int baseScore matchedCount * 10; int comboBonus baseScore * Mathf.Max(0, combo - 1) * 2; totalScore baseScore comboBonus; currentCombo; }逻辑说明combo是连续消除次数一次交换后只结算一组消除就停止combo重置为0。baseScore按方块数量计comboBonus给连续消除加成。这个公式简单答辩时容易说清楚。参数说明如果后面接数据统计可以在AddScore里同步更新步数、最高分等变量ScoreManager做成单例全局都能访问。5. 消消乐项目避坑五个常见翻车现场与排查清单这部分是我把这份资源实际跑过、也看学生跑过之后总结的高频问题。每条按“现象→原因→解决”写建议动代码前先读完。5.1 网格坐标偏移方块错位消除判定永远不对现象场景里方块排列看起来整齐但点击交换后实际消除的不是视觉对应的那一组有时甚至把不相邻的方块换了。原因数组下标(row, col)和世界坐标(x, y)映射错了。比如GridToWorld(row, col)传参时写成GridToWorld(col, row)横向纵向差一个交换棋盘旋转90度所有点击都错位。视觉上不明显因为棋盘是矩形看起来一样但逻辑层录入的位置永远差一格匹配检测自然对不上。解决写一个自检脚本编辑器模式下遍历每个Tile打印Debug.Log(${row},{col} - {transform.position})与Board网格对照。发现错位就逐个检查GridToWorld的传参和Tile的Init顺序。我一般把GridToWorld的参数名写成rowBeforeCol强迫自己按顺序写。5.2 下落协程失控Time.timeScale 0 导致动画卡死现象暂停功能加上后下落动画不动了棋盘卡在半空点击也没反馈。退出暂停后方块直接瞬移没有过渡。原因Time.timeScale 0时WaitForSeconds永远不会结束。如果ApplyGravity用的是WaitForSeconds(0.2f)而暂停时正好跑到这段代码协程就挂了。用WaitForSecondsRealtime则会让动画在暂停时继续跑也不对。解决暂停功能别用Time.timeScale实现改用bool isPaused控制只有在非暂停时才启动下落协程。这样既不会卡协程也不会让动画暂停时跑完。如果项目已经用Time.timeScale做慢动作特效就用WaitForSecondsRealtime搭配手动判断。5.3 特效重复创建粒子系统内存泄漏现象每次消除都生成一个粒子特效玩了几分钟后内存逐渐上涨低配手机上卡顿明显。看Profiler发现ParticleSystem的实例数一直没降。原因消除特效生成后没有自动销毁或者销毁延迟设置太长。更隐蔽的是特效预制体的Stop Action没设成Disable粒子播放完仍占着GameObject。解决特效生成统一走对象池或者给出严格生命周期。最简单是协程销毁yield return new WaitForSeconds(1.5f)后调Destroy。好一点的方案是写一个EffectManager传入位置和类型播放完毕后回收。我拆的这份源码特效用得比较基础建议直接升级对象池。5.4 UI点击穿透方块被面板挡住拖拽无响应现象底部UI按钮能正常点击但按钮附近的方块点不动拖拽到屏幕边缘时明显失灵。原因UI面板把Canvas里的方块挡住了。要么是面板Image组件的raycastTarget为true要么是Canvas的sortingOrder设置不对。玩家拖到面板上时EventSystem把鼠标事件交给面板方块收不到PointerUp。解决把所有不需要接收点击的UI元素背景、底板、装饰图的raycastTarget都置为false。有Button组件就让按钮保持独立面板不覆盖游戏区域。动态生成UI后检查raycastTarget这个习惯要养成。5.5 阴影与摄像机设置真机上颜色发灰或方块闪烁现象PC编辑器里正常打包到Android后方块边缘出现细线闪烁或者整体颜色发灰。原因移动端对抗锯齿和纹理压缩更敏感。Sprite图片带透明边界没设Alpha Is Transparency会出现边缘黑边。粒子系统在UI层播放时如果相机有Post Processing后处理也可能造成透明排序问题。解决方块Sprite的Mesh Type设为Full Rect关闭Generate Mip Map确认Texture Type为Sprite(2D and UI)。Post Processing不是必需就关掉消消乐不需要这种效果。阴影问题还在就检查场景里有没有多余平行光或Canvas的Render Mode是否被改成Screen Space - Camera。6. 再进一步对象池、AI自动求解与微信小游戏打包如果想让作品更有分量这三件事值得做把方块从Instantiate/Destroy改成对象池加一个自动求解按钮再考虑多平台打包。6.1 方块对象池消除与生成时的GC压力反复创建和销毁物体会产生大量GC在真机上表现为间歇卡顿。简单做法是维护一个队列消除时回收生成时复用。public class TilePool : MonoBehaviour { private QueueGameObject pool new QueueGameObject(); public GameObject Get(Vector3 position) { GameObject tile pool.Count 0 ? pool.Dequeue() : Instantiate(tilePrefab); tile.transform.position position; tile.SetActive(true); return tile; } public void Return(GameObject tile) { tile.SetActive(false); pool.Enqueue(tile); } }逻辑说明Get优先复用队列中的对象池子空了才Instantiate。Return把方块隐藏回收到队列等待下一次复用。做完后用Profiler对比GC Allocation这条优化能在答辩时讲出具体数字。参数说明池子初始大小按8x8铺满的64个预创建也可以全部懒加载看项目规模。消消乐方块数量固定预创建更稳。6.2 一步可解检测与AI自动求解死局检测在3.4节已经留了口子HasValidMove遍历所有相邻交换每次交换后调用HasMatch全部失败就触发洗牌。洗牌后要重新执行消除检测直到开局可用。自动求解更简单写一个评估函数对每个合法交换计算匹配数取最高的那个执行。BFS搜索在这个场景里没必要贪心就够演示用了。核心逻辑是“把棋盘状态映射成数值挑最大的走”答辩时讲清楚评估函数怎么定义比贴一堆搜索代码更讨喜。6.3 打包与构建Android真机与微信小游戏这版按PC做的改Android很快Player Settings里把包名填好API Level选Android 10以上Scripting Backend选IL2CPP重新构建就行。微信小游戏需要Unity官方转换插件跑一遍转换流程后处理屏幕适配。我的习惯是CanvasScaler统一用Scale With Screen Size参考分辨率1080x1920这样大部分安卓机不会有UI跑偏。写到这里我每次拆完这类小游戏资源都会强制走一遍“数据模型→消除循环→UI→打包”的流程哪怕只演示20分钟也不跳步。上次被Time.timeScale卡住协程后我写WaitForSeconds前一定会问自己“这里会不会被暂停打断”。这份源码适合毕业设计和练手能玩到什么程度取决于你往里填多少细节希望帮到你。本文还有配套的精品资源点击获取
返回列表