恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Unity 2D连连看实战:MVC架构与IMGUI开发全解析
首页
资讯中心
/
Unity 2D连连看实战:MVC架构与IMGUI开发全解析
Unity 2D连连看实战:MVC架构与IMGUI开发全解析
发布时间:2026/8/10 15:41:44
1. 项目概述与核心思路拆解“Unity 2D连连看实战基于MVC架构与IMGUI的经典游戏重构”这个项目乍一看是个经典小游戏的重制但它的核心价值远不止于“用Unity再做一遍连连看”。对于Unity初学者来说这是一个绝佳的、从零到一构建完整游戏逻辑的练手项目而对于有一定经验的开发者它则是一个深入理解MVCModel-View-Controller架构在游戏开发中如何落地以及如何驾驭IMGUIImmediate Mode GUI这套“古老”但高效的UI系统的绝佳案例。我之所以选择这个组合是因为它能让你在实现趣味功能的同时建立起清晰的代码组织思维避免项目后期变成一锅“意大利面代码”。简单来说这个项目要解决的核心问题是如何将一个看似简单、但交互逻辑密集的2D游戏用清晰、可维护的代码结构实现出来。连连看游戏的核心循环是“点击-判断-消除-刷新”玩家每一次点击都涉及数据状态变更、界面反馈和规则校验。如果所有代码都写在一个脚本里很快就会变得难以阅读和调试。MVC架构的价值就在这里体现它将数据管理Model、界面表现View和逻辑控制Controller分离让每一部分职责单一耦合度降低。而IMGUI作为Unity内置的一套基于代码的即时模式GUI系统虽然不像UGUI那样有可视化编辑器但其轻量、灵活、完全由代码驱动的特性非常适合用来构建这种逻辑驱动型强、界面元素相对固定的游戏内界面如游戏主面板、计时器、分数显示能让你更深刻地理解UI与逻辑的交互本质。2. MVC架构在连连看中的具体映射在开始敲代码之前我们必须先把MVC的三个角色在连连看游戏中的职责划分清楚。这一步是项目成败的关键划分得越清晰后续开发就越顺畅。2.1 Model数据模型游戏状态的唯一真相源Model层是游戏的核心大脑它只关心数据不关心这些数据如何被显示。在连连看中Model需要管理以下核心数据地图数据MapData一个二维数组用于存储每个格子的图标类型。例如mapData[x, y] 3表示第x行第y列的格子是第3种图标。这是游戏可玩性的基础。游戏状态GameState包括当前游戏是否进行中、已用时间、剩余时间、当前分数、连击数等。这些是驱动游戏进程的核心变量。玩家操作记录Selection记录玩家当前选中的两个格子的坐标。这是连接玩家输入和游戏逻辑的桥梁。Model层的设计原则是“纯净”。它不应该包含任何GameObject、Transform或者直接调用渲染相关的API。它的方法通常是获取数据、设置数据、以及提供一些纯粹的数据校验方法。例如一个IsEliminable(Vector2Int posA, Vector2Int posB)方法只负责根据地图数据和连连看消除规则直线连接、一个拐角、两个拐角判断两个格子能否消除并返回一个布尔值它自己并不执行消除动作。实操心得在设计Model时我习惯将所有公开的数据都通过属性Property来暴露而不是公共字段。这样可以在数据被修改时加入验证或触发事件为后续的View更新提供钩子。例如当Score属性被设置新值时可以触发一个OnScoreChanged事件View层监听这个事件来更新分数显示。2.2 View视图只负责“看起来怎么样”View层是游戏的皮肤它的唯一职责就是将Model中的数据以可视化的形式呈现出来并捕获玩家的输入事件但不处理任何游戏逻辑。在基于IMGUI的连连看中View层主要包含游戏主界面渲染在OnGUI方法中根据当前的GameState如Playing, Paused, GameOver绘制不同的界面。在Playing状态下通过循环遍历MapData二维数组在对应屏幕位置绘制按钮GUI.Button来表示图标。UI元素绘制绘制计时器、分数板、提示按钮、重新开始按钮等。输入转发当玩家点击一个图标按钮时View层只做一件事将点击的坐标信息如数组索引(x, y)作为一个“事件”或直接调用Controller层提供的方法传递出去。例如Controller.Instance.OnGridClicked(x, y)。IMGUI的OnGUI方法每帧都会调用这使得View层的更新是即时的。我们只需要在OnGUI中根据当前Model的数据状态来绘制即可无需手动管理UI元素的创建、销毁和状态同步这是IMGUI“即时模式”的便利之处但也要求我们的绘制逻辑必须高效。注意事项IMGUI的控件如GUI.Button返回的是当前帧该控件是否被触发的布尔值。因此处理点击事件的代码必须直接放在控件调用的同一帧内。例如if (GUI.Button(rect, iconTexture)) { HandleClick(x, y); }。不要试图把点击事件存储到变量里留到下一帧处理那会失效。2.3 Controller控制器游戏逻辑的指挥家Controller层是Model和View之间的协调者它包含了游戏的核心业务流程。它接收来自View的输入事件根据业务规则操作Model并可能指示View进行更新。在连连看中Controller的职责包括初始化游戏创建Model实例生成随机地图确保所有图标成对且可解重置游戏状态。处理玩家点击接收View传来的格子坐标。逻辑流程如下如果是第一次点击记录该坐标到Model的Selection并让View高亮显示该格子通过改变Model中该格子的“选中状态”View自动响应。如果是第二次点击调用Model的IsEliminable方法判断。如果可以消除则调用Model的EliminateTiles方法清除这两个格子的数据并更新分数、连击数等。然后可能触发一次“地图重整检查”检查是否还有可消除的对子若无则游戏结束。如果不能消除则清空Model中的SelectionView随之取消高亮。游戏流程控制处理暂停、继续、重新开始、游戏结束的逻辑。例如当计时器归零或无可消除对子时将Model的GameState设置为GameOverView检测到状态变化后显示结束界面。Controller是“知道”Model和View存在的它持有它们的引用并驱动它们协同工作。但好的设计是Controller通过接口或抽象类与Model和View交互降低直接依赖。3. 基于IMGUI的游戏界面实现详解很多开发者习惯了UGUI的所见即所得对IMGUI望而却步。实际上对于连连看这类界面元素位置固定、逻辑简单的游戏IMGUI的实现反而更直接、更轻量。3.1 游戏主网格的绘制与布局核心在于计算每个图标按钮在屏幕上的位置。假设我们的网格是8x8图标尺寸是64x64像素网格起始绘制位置为(startX, startY)。void DrawGameGrid() { if (gameModel.MapData null) return; int gridSize 8; int tileSize 64; int startX (Screen.width - gridSize * tileSize) / 2; int startY 100; // 从屏幕上方100像素开始绘制 for (int x 0; x gridSize; x) { for (int y 0; y gridSize; y) { // 计算每个按钮的矩形区域 Rect tileRect new Rect(startX x * tileSize, startY y * tileSize, tileSize, tileSize); // 获取该格子的图标类型和纹理 int tileType gameModel.MapData[x, y]; Texture2D iconTex iconTextures[tileType]; // 根据Model中的数据判断该格子是否被选中以改变显示样式 GUIStyle style (gameModel.IsSelected(x, y)) ? selectedTileStyle : normalTileStyle; // 绘制按钮并处理点击 if (GUI.Button(tileRect, iconTex, style)) { // 点击事件直接调用Controller的方法 gameController.OnTileClicked(x, y); } } } }这里的关键是GUI.Button的第三个参数GUIStyle我们可以预先定义两种样式normalTileStyle和selectedTileStyle通过改变其normal.background的颜色或边框来实现选中高亮效果。3.2 游戏状态UI与交互控制游戏状态如分数、时间的显示相对简单我们使用GUI.Label在固定位置绘制即可。void DrawGameInfo() { // 绘制分数 GUI.Label(new Rect(20, 20, 200, 30), $分数: {gameModel.Score}); // 绘制时间 GUI.Label(new Rect(20, 60, 200, 30), $时间: {Mathf.CeilToInt(gameModel.RemainingTime)}); // 绘制提示按钮 if (GUI.Button(new Rect(Screen.width - 120, 20, 100, 40), 提示)) { gameController.OnHintButtonClicked(); } // 根据游戏状态绘制不同界面 switch (gameModel.CurrentState) { case GameState.GameOver: DrawGameOverWindow(); break; case GameState.Paused: DrawPauseMenu(); break; // ... 其他状态 } } void DrawGameOverWindow() { // 创建一个半透明背景区域 GUI.Box(new Rect(Screen.width*0.2f, Screen.height*0.3f, Screen.width*0.6f, Screen.height*0.4f), ); // 在中心区域绘制结束信息 GUILayout.BeginArea(new Rect(Screen.width*0.3f, Screen.height*0.4f, Screen.width*0.4f, Screen.height*0.2f)); GUILayout.Label(游戏结束, GUILayout.Height(30)); GUILayout.Label($最终分数: {gameModel.Score}, GUILayout.Height(30)); if (GUILayout.Button(再来一局, GUILayout.Height(40))) { gameController.OnRestartClicked(); } GUILayout.EndArea(); }踩坑记录IMGUI的布局系统GUILayout和绝对定位系统GUI可以混用但要注意GUILayout是自动布局它会根据控件顺序和大小自动排列。在弹出窗口这类需要精确定位的场景我更喜欢使用GUI进行绝对定位计算或者使用GUILayout.BeginArea划定一个区域后再在里面使用自动布局这样更容易控制。4. 连连看核心算法连接检测的实现与优化这是项目的算法核心也是面试中常考的点。连连看的消除规则是两个相同的图标可以通过不超过两个拐角即三条直线段的路径连接起来且路径不能被其他图标阻挡。4.1 基础算法分类讨论与广度优先搜索BFS最直观的实现方式是分类讨论三种连接情况直线连接、一个拐角连接、两个拐角连接。直线连接0拐角检查两个点是否在同一行或同一列且中间所有格子均为空在Model中可用-1表示或就是目标点本身。一个拐角连接1拐角想象路径像一个“L”形。假设点A(x1, y1)和点B(x2, y2)。拐点C只有两种可能C1(x1, y2) 和 C2(x2, y1)。只需要检查A到C是否直线可达且C到B是否直线可达并且C点本身为空即可。两个拐角连接2拐角这是最复杂的情况路径像“Z”或“U”形。我们可以将其转化为寻找一个中间点P使得A到P直线可达P到B直线可达且A-P和P-B的路径共享一个转折点实际上更通用的方法是使用广度优先搜索BFS。BFS算法思路将起点A加入队列并记录其来自的方向初始为无方向。从队列中取出一个节点向其上下左右四个方向探索。探索时必须沿着当前方向一直走直到遇到障碍物非空格子或走出地图边界。这个过程中经过的每一个“可达点”都作为潜在的下一个“转折点”加入队列并记录当前探索方向。规则是每次转折改变方向算一个拐角。从起点开始第一次确定方向不算拐角。如果找到终点B且转折次数2则路径存在。如果队列为空仍未找到则不可达。BFS方法代码结构更清晰能覆盖所有情况且易于扩展例如修改最大拐角数。4.2 算法实现示例与优化技巧这里给出一个简化版的BFS思路代码框架public bool IsConnectable(Vector2Int posA, Vector2Int posB, int[,] map) { // 0. 基础检查是否是同一个点图标是否相同是否为空 if (posA posB || map[posA.x, posA.y] ! map[posB.x, posB.y]) return false; // 1. 检查直线连接 if (CheckDirectLine(posA, posB, map)) return true; // 2. 检查一个拐角连接 if (CheckOneCorner(posA, posB, map)) return true; // 3. 使用BFS检查两个拐角连接 return CheckTwoCornersByBFS(posA, posB, map); } // BFS检查两个拐角 private bool CheckTwoCornersByBFS(Vector2Int start, Vector2Int end, int[,] map) { QueueSearchNode queue new QueueSearchNode(); bool[,,] visited new bool[mapWidth, mapHeight, 4]; // 第三维记录以某个方向到达该点 // 初始化将起点四个方向的可达点加入队列第一次移动不算拐角 Vector2Int[] dirs { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right }; for (int i 0; i dirs.Length; i) { Vector2Int nextPos start dirs[i]; while (IsPositionValid(nextPos) IsCellEmptyOrTarget(nextPos, end, map)) { if (!visited[nextPos.x, nextPos.y, i]) { visited[nextPos.x, nextPos.y, i] true; queue.Enqueue(new SearchNode(nextPos, i, 0)); // 当前位置来自方向已转折次数 if (nextPos end) return true; } // 继续沿该方向直线前进 nextPos dirs[i]; } } while (queue.Count 0) { SearchNode current queue.Dequeue(); // 如果已经转折两次只能继续沿原方向走不能再次转折 if (current.cornerCount 2) { // 只尝试原方向 Vector2Int nextPos current.position dirs[current.dir]; while (IsPositionValid(nextPos) IsCellEmptyOrTarget(nextPos, end, map)) { if (nextPos end) return true; nextPos dirs[current.dir]; } continue; } // 可以转折尝试所有其他方向 for (int i 0; i dirs.Length; i) { if (i current.dir) continue; // 同方向不算转折 Vector2Int nextPos current.position dirs[i]; // 转折后必须能至少走一步到达下一个格子 if (IsPositionValid(nextPos) IsCellEmptyOrTarget(nextPos, end, map)) { if (!visited[nextPos.x, nextPos.y, i]) { visited[nextPos.x, nextPos.y, i] true; queue.Enqueue(new SearchNode(nextPos, i, current.cornerCount 1)); if (nextPos end) return true; } } } } return false; }性能优化心得对于8x8这样的小地图上述BFS完全够用。但如果地图更大或者需要实现“提示”功能需要频繁查找可消除对就需要优化。一个常见的优化是“预计算连通性”在每次地图变化消除后后可以计算并缓存所有空格子之间的连通关系在2拐角内这样点击判断或提示时几乎是O(1)的查询。但这会占用更多内存属于空间换时间的策略需要根据实际需求权衡。5. 游戏数据管理与持久化设计一个完整的游戏还需要考虑数据的保存与加载。虽然连连看通常不需要复杂的存档但记录最高分、游戏设置如音效开关等是个好习惯。5.1 使用ScriptableObject管理静态配置对于图标的纹理、每种关卡的网格大小、时间限制等相对静态的配置数据强烈推荐使用Unity的ScriptableObject。你可以创建一个GameConfig的ScriptableObject资产在里面定义公共字段然后在编辑器中配置。[CreateAssetMenu(fileName GameConfig, menuName LinkGame/Config)] public class GameConfig : ScriptableObject { public int gridWidth 8; public int gridHeight 8; public float roundTime 120f; // 每局秒数 public Texture2D[] tileIcons; // 图标纹理数组 public int matchScoreBase 100; // 消除基础分 }这样Controller在初始化时可以直接引用这个配置资产将数据和逻辑分离修改配置无需改动代码。5.2 使用PlayerPrefs或JSON进行轻量级持久化对于最高分、游戏设置等需要持久化的数据简单起见可以用Unity内置的PlayerPrefs。public class PlayerDataManager { private const string KEY_HIGH_SCORE HighScore; private const string KEY_SOUND_ENABLED SoundEnabled; public int HighScore { get PlayerPrefs.GetInt(KEY_HIGH_SCORE, 0); set { if (value HighScore) { PlayerPrefs.SetInt(KEY_HIGH_SCORE, value); PlayerPrefs.Save(); } } } public bool SoundEnabled { get PlayerPrefs.GetInt(KEY_SOUND_ENABLED, 1) 1; set { PlayerPrefs.SetInt(KEY_SOUND_ENABLED, value ? 1 : 0); PlayerPrefs.Save(); } } }如果数据结构更复杂比如需要保存多个关卡进度可以考虑使用JsonUtility将数据类序列化成JSON字符串再存入PlayerPrefs或文件中。注意事项PlayerPrefs在WebGL平台有存储容量限制约1MB且读写是同步的频繁保存可能影响性能。对于更复杂的数据建议使用异步文件读写System.IO或考虑轻量级数据库。6. 项目组织、调试与常见问题排查当所有模块开发完毕后如何将它们优雅地组织起来并高效地调试是项目收尾的关键。6.1 项目目录结构与资源管理一个清晰的项目结构能极大提升协作和维护效率。建议的目录结构如下Assets/ ├── Scripts/ │ ├── Model/ │ │ ├── GameModel.cs │ │ ├── MapData.cs │ │ └── PlayerData.cs │ ├── View/ │ │ ├── GameView.cs (继承自 MonoBehaviour处理IMGUI绘制) │ │ └── UIStyles.cs (存放定义的GUIStyle) │ ├── Controller/ │ │ ├── GameController.cs │ │ └── InputController.cs │ ├── Algorithm/ │ │ └── ConnectionChecker.cs (连接检测算法) │ └── Utilities/ │ ├── GameConfig.asset (ScriptableObject实例) │ └── Extensions.cs ├── Resources/ │ └── Icons/ (存放图标纹理) ├── Scenes/ │ └── Main.unity └── Plugins/ (如有第三方库)将脚本按MVC角色分文件夹存放算法和工具类单独分离。纹理等资源放在Resources文件夹或其子目录下方便使用Resources.Load动态加载虽然IMGUI纹理通常在编辑器中直接拖拽到ScriptableObject或公共字段上更简单。6.2 调试技巧与常见问题实录在开发过程中你肯定会遇到各种问题。以下是我踩过的一些坑和解决方法问题1IMGUI按钮点击无反应或反应异常。排查首先确认绘制按钮的代码是否在OnGUI方法内。其次检查按钮的Rect区域计算是否正确是否被其他控件覆盖。使用GUI.Box在疑似区域画个框可以帮助调试。心得IMGUI的控件ID是基于调用顺序自动生成的。如果在循环或条件分支中绘制控件必须确保每一帧的调用顺序是完全一致的否则会导致控件ID混乱引发不可预知的点击行为。在循环中绘制网格按钮是安全的因为循环顺序固定。问题2游戏逻辑正确但界面刷新不及时或有残留。原因IMGUI的OnGUI可能在一帧内被多次调用。如果View层依赖的Model数据没有在正确的时机更新就可能出现显示不同步。解决确保Model数据的更新发生在OnGUI调用之前。Unity的事件顺序中Update()在OnGUI()之前。因此将Controller的逻辑更新放在Update()中而View的绘制放在OnGUI()中是安全的。对于由协程或异步操作引发的数据变更需要确保在数据变更后触发一个界面重绘的标记虽然IMGUI每帧都绘但你可以通过改变一个bool标志来触发特定的UI逻辑。问题3连接检测算法在边界情况出错比如两个角相邻。排查编写单元测试。为ConnectionChecker创建独立的测试脚本构造各种极端地图案例进行测试如相邻相同图标、地图边缘的图标、被空单元格包围的图标等。在Unity中可以使用Debug.Log输出中间步骤或者使用UnityEditor.EditorApplication.isPaused在关键逻辑处断点。问题4游戏打包后IMGUI的样式字体、颜色与编辑器内看到的不一致。原因在编辑器下IMGUI可能会使用编辑器默认皮肤。如果自定义了GUIStyle但没有指定皮肤或者指定的字体在目标平台不存在就会回退到默认。解决在OnGUI开始时显式地设置GUI.skin为你创建的皮肤资源。并确保该皮肤资源及其使用的字体被包含在构建中。问题5如何为IMGUI添加简单的动画效果如消除时的缩放渐隐挑战IMGUI本身是立即模式的不保留控件状态实现传统补间动画较难。变通方案动画本质是随时间变化的属性。我们可以在Model或一个独立的动画管理器中为需要动画的元素如正在消除的格子记录一个“动画进度”0到1的值。在OnGUI中根据这个进度值实时计算当前的颜色渐隐、缩放比例或位置然后绘制。动画进度在Update中由时间驱动更新。虽然不如UGUI的Animator直观但对于简单效果足够有效。通过这个项目你不仅能收获一个可玩的连连看游戏更重要的是你能掌握如何在Unity中运用MVC架构来组织代码以及如何灵活使用IMGUI来构建游戏界面。这种架构思维和对于底层UI系统的理解在你未来面对更复杂的项目时会是非常宝贵的财富。当你下次再看到游戏需求时你会自然而然地开始思考“这个功能的数据是什么Model怎么表现View流程怎么控制Controller” 这才是本项目最大的价值所在。