Unity 2D连连看实战:MVC架构与IMGUI开发全解析
1. 项目概述与核心思路拆解
“Unity 2D连连看实战:基于MVC架构与IMGUI的经典游戏重构”这个项目,乍一看是个经典小游戏的重制,但它的核心价值远不止于“用Unity再做一遍连连看”。对于Unity初学者来说,这是一个绝佳的、从零到一构建完整游戏逻辑的练手项目;而对于有一定经验的开发者,它则是一个深入理解MVC(Model-View-Controller)架构在游戏开发中如何落地,以及如何驾驭IMGUI(Immediate 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中的
Selection,View随之取消高亮。
- 如果是第一次点击,记录该坐标到Model的
- 游戏流程控制:处理暂停、继续、重新开始、游戏结束的逻辑。例如,当计时器归零或无可消除对子时,将Model的
GameState设置为GameOver,View检测到状态变化后显示结束界面。
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) { Queue<SearchNode> queue = new Queue<SearchNode>(); 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 调试技巧与常见问题实录
在开发过程中,你肯定会遇到各种问题。以下是我踩过的一些坑和解决方法:
问题1:IMGUI按钮点击无反应或反应异常。
- 排查:首先确认绘制按钮的代码是否在
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)?” 这才是本项目最大的价值所在。
