1. 项目概述:从经典派对游戏到算法实战
最近在重温《马里奥派对》系列时,里面一个叫“选面积大作战”的小游戏让我眼前一亮。它的规则很简单:屏幕上有一张被分割成不同颜色区块的网格地图,玩家需要轮流选择一种颜色,所有与该颜色相连的区块就会归你所有。最终,谁占领的总面积最大,谁就获胜。这个看似简单的游戏,背后却藏着非常巧妙的算法设计——如何快速、准确地计算一片颜色区域的面积?如何判断两个格子是否“相连”?这让我萌生了一个想法:能不能在Unity里,用代码完整地复刻这个玩法,并且把核心的“四邻域连通算法”讲透?
这不只是一个简单的游戏克隆,更是一次绝佳的游戏开发算法实战。对于Unity开发者,尤其是刚入门不久、想深入理解游戏逻辑与算法结合的朋友来说,这个项目涵盖了网格系统处理、用户交互、状态管理和一个经典图论算法的应用。网上虽然有很多算法教程,但往往脱离具体的游戏场景,而这个项目能让你看到算法是如何“驱动”一个完整、可玩的游戏机制的。今天,我就把自己实现这个项目的完整思路、代码细节,以及过程中踩过的坑和优化技巧,毫无保留地分享出来。文末也会提供完整的C#源码,你可以直接拿去研究、修改,甚至扩展成你自己的游戏。
2. 核心玩法与算法设计思路拆解
在动手写代码之前,我们必须把游戏规则和背后的计算逻辑彻底想清楚。这能避免在开发中期陷入逻辑混乱,也是写出优雅代码的前提。
2.1 玩法规则的精确定义
《马里奥派对》里的“选面积大作战”规则可以抽象为以下几点:
- 游戏场地:一个M x N的矩形网格(例如8x8,10x10)。每个网格单元(Cell)被随机分配一种颜色,代表一种“领地类型”。
- 玩家回合:2-4名玩家轮流进行。在当前玩家的回合,他需要从场地中现存的所有颜色类型中,选择一种。
- 占领逻辑:玩家选择颜色后,所有属于该颜色且相互连通的网格单元,以及所有与这些单元相邻(四邻域方向)的其他颜色单元,都会被该玩家占领。注意,这里有一个关键点:占领会引发“连锁反应”。新占领的格子如果旁边有其他颜色的格子,而这些格子颜色相同且连通,那么这些格子也会被一并占领。这个过程会持续到没有新的格子可以被占领为止。
- 胜负判定:当所有网格单元都被玩家占领,或者剩余的未占领颜色已无法被任何玩家有效选择时,游戏结束。统计每位玩家占领的格子总数,多者胜。
这里最核心的难点在于第3点:如何高效、无误地计算一次颜色选择后,最终所有会被占领的格子。这直接引出了我们的核心算法。
2.2 为什么是“四邻域连通算法”?
“连通区域”是计算机视觉和图像处理中的经典问题。在我们这个二维网格世界里,判断两个格子是否属于同一区域,就看它们是否通过相同颜色的格子路径连接起来。连接的方式通常有两种定义:
- 四邻域 (4-neighborhood):只考虑上下左右四个方向的相邻格子。
- 八邻域 (8-neighborhood):考虑上下左右以及对角线方向,共八个相邻格子。
《马里奥派对》原版游戏采用的是四邻域规则。为什么?这主要是出于游戏性和公平性的考虑。八邻域连接性太强,对角线上的格子也算连通,这会导致区域形状更加复杂,连锁反应更容易发生,可能使游戏局势变化过于剧烈和随机,削弱了玩家的策略性思考(比如预判连锁效果)。四邻域规则更清晰、更可控,也更容易被玩家直观理解。
因此,我们的算法核心就是:给定一个起始网格,找出所有通过四邻域路径可达的、颜色相同的网格集合。这本质上是一个无向图的连通分量搜索问题。每个格子是图的一个节点,如果两个格子颜色相同且四邻域相邻,则它们之间有一条边。
2.3 算法选型:DFS vs BFS
搜索连通分量,深度优先搜索(DFS)和广度优先搜索(BFS)都是可行的。在这个特定场景下,我选择了深度优先搜索(DFS)的递归实现。理由如下:
- 代码简洁直观:递归形式的DFS代码非常简短,逻辑清晰,易于理解和调试。对于网格规模不大(比如10x10以内)的游戏来说,完全够用。
- 自然契合“蔓延”感觉:递归的调用栈模拟了占领行为一层层向外扩散的过程,在概念上很贴切。
- 性能考量:对于最大几百个格子的搜索,DFS和BFS的时间复杂度都是O(N),其中N是连通区域的格子数。递归带来的额外函数调用开销在可接受范围内。如果网格非常大(比如100x100),则需要警惕递归深度过大可能导致栈溢出的风险,那时可以考虑显式栈的DFS或BFS。但对我们这个游戏,递归DFS是首选。
当然,BFS也有其优点,例如使用队列可以避免递归深度问题,并且搜索顺序是“一圈圈”扩散,有时更符合需求。在文末的源码中,我也会提供BFS版本的备选实现,供你参考。
3. Unity项目搭建与核心组件解析
理论清晰了,现在开始在Unity中搭建项目。我们的目标是创建一个结构清晰、易于扩展的系统。
3.1 场景与网格生成
首先,我们需要一个可视化的网格。这里不采用Tilemap,而是用更基础的GameObject来动态生成,以便更灵活地控制每个格子的逻辑状态。
1. 创建基础Prefab:创建一个Cube或Quad,命名为GridCell。为其添加一个SpriteRenderer(如果使用2D Sprite)或MeshRenderer(如果使用3D Cube)来显示颜色。再挂载一个我们即将编写的C#脚本,比如CellData.cs。将这个GameObject做成Prefab。
2. 网格生成管理器:创建一个空GameObject,命名为GameBoardManager,并挂载脚本BoardManager.cs。这个脚本负责:
- 定义网格的行数(
rows)和列数(cols)。 - 定义几种用于随机分配的颜色。
- 在
Start()或一个初始化方法中,根据行列数,在指定范围内(如以(0,0)为中心)实例化GridCellPrefab,排列成网格。 - 为每个生成的Cell随机分配一种颜色,并记录其行列索引。
// BoardManager.cs 部分代码示例 public class BoardManager : MonoBehaviour { public GameObject cellPrefab; public int rows = 8; public int cols = 8; public Color[] availableColors; public float cellSpacing = 1.1f; private CellData[,] grid; // 核心数据网格 void Start() { InitializeGrid(); } void InitializeGrid() { grid = new CellData[rows, cols]; Vector2 startPos = new Vector2(-(cols-1)*cellSpacing/2, -(rows-1)*cellSpacing/2); for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { Vector3 spawnPos = new Vector3(startPos.x + c * cellSpacing, startPos.y + r * cellSpacing, 0); GameObject cellObj = Instantiate(cellPrefab, spawnPos, Quaternion.identity, this.transform); CellData cellData = cellObj.GetComponent<CellData>(); // 初始化格子数据 Color randomColor = availableColors[Random.Range(0, availableColors.Length)]; cellData.Initialize(r, c, randomColor); grid[r, c] = cellData; } } } }3. 格子数据脚本:CellData.cs脚本附着在每个格子物体上,是数据模型(Model)层。
// CellData.cs public class CellData : MonoBehaviour { public int rowIndex; public int colIndex; public Color cellColor; public Player owner; // 当前占领的玩家,初始为null private SpriteRenderer spriteRenderer; void Awake() { spriteRenderer = GetComponent<SpriteRenderer>(); } public void Initialize(int row, int col, Color color) { rowIndex = row; colIndex = col; cellColor = color; UpdateVisual(); } public void SetOwner(Player newOwner) { owner = newOwner; UpdateVisual(); // 被占领后,视觉上可能需要变化(如加上玩家标志色边框) } void UpdateVisual() { if (spriteRenderer != null) { spriteRenderer.color = cellColor; // 可以在这里根据owner添加边框效果 } } }注意:这里将
CellData作为组件挂在每个格子上,方便通过GameObject直接获取数据。grid[r, c]数组也持有这些组件的引用,形成了数据访问的双向通道。这是一种在小型项目中常见的简单架构。
3.2 玩家系统与回合管理
我们需要一个GameManager作为游戏总控制器,以及一个Player类来代表玩家。
1. Player类:这是一个纯C#类,不一定是MonoBehaviour,用于存储玩家信息。
// Player.cs [System.Serializable] public class Player { public string playerName; public Color playerColor; // 用于UI显示或格子边框 public int score = 0; // 占领的格子数 public Player(string name, Color color) { playerName = name; playerColor = color; } public void AddScore(int amount) { score += amount; // 可以在这里触发UI更新事件 } }2. GameManager(单例模式):使用单例模式方便全局访问。它负责:
- 管理玩家列表和当前回合玩家索引。
- 持有
BoardManager的引用。 - 处理玩家的颜色选择输入。
- 调用核心算法计算占领区域。
- 更新分数并判断游戏结束。
// GameManager.cs public class GameManager : MonoBehaviour { public static GameManager Instance; public List<Player> players; private int currentPlayerIndex = 0; public BoardManager boardManager; void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); } void Start() { // 初始化玩家,例如:players = new List<Player>{ new Player("P1", Color.red), new Player("P2", Color.blue) }; StartPlayerTurn(players[currentPlayerIndex]); } // 开始一个玩家的回合,例如高亮可选择的颜色 void StartPlayerTurn(Player player) { Debug.Log($"轮到 {player.playerName} 行动"); // 这里可以触发UI,显示当前玩家和可操作提示 } // 这个函数由UI按钮触发,传入玩家选择的颜色 public void OnPlayerSelectColor(Color selectedColor) { Player currentPlayer = players[currentPlayerIndex]; // 调用核心算法,计算并占领区域 List<CellData> cellsToCapture = CalculateAreaToCapture(selectedColor); if (cellsToCapture.Count > 0) { CaptureCells(currentPlayer, cellsToCapture); EndCurrentTurn(); } else { Debug.Log("选择无效!该颜色已无连通区域可占领。"); } } void EndCurrentTurn() { // 检查游戏是否结束... // 切换到下一个玩家 currentPlayerIndex = (currentPlayerIndex + 1) % players.Count; StartPlayerTurn(players[currentPlayerIndex]); } // 核心算法函数,下一节详解 List<CellData> CalculateAreaToCapture(Color selectedColor) { ... } void CaptureCells(Player player, List<CellData> cells) { ... } }4. 四邻域连通算法的C#实现与优化
这是整个项目的灵魂。我们将实现CalculateAreaToCapture函数。
4.1 算法步骤详解
给定一个玩家选择的颜色selectedColor,我们需要找出场上所有未被占领的、颜色为selectedColor的格子,并通过四邻域连通性,找出所有会被连锁占领的格子集合。注意,初始的“种子”格子是所有该颜色的未占领格子,而不仅仅是某一个。
算法步骤如下:
- 初始化一个
HashSet<CellData>用于存储最终要占领的所有格子(cellsToCapture),一个Queue<CellData>或递归栈用于搜索。 - 遍历整个网格,将所有颜色为
selectedColor且owner == null的格子加入一个“待搜索种子”列表。 - 遍历这个种子列表,对每一个种子,如果它还没有被加入到
cellsToCapture中,则以其为起点,进行四邻域连通区域搜索,将这个连通区域内的所有格子加入cellsToCapture。 - 搜索过程中,对于当前格子,检查其上下左右四个邻居(注意边界判断)。如果邻居格子满足:a) 索引有效;b) 未被占领(
owner == null);c) 颜色与selectedColor相同;d) 还未被加入cellsToCapture,则将其作为新的搜索起点。 - 完成所有种子格子的搜索后,
cellsToCapture包含了所有直接连通的颜色区域。但根据规则,占领这些格子后,如果它们旁边有其他颜色的格子,且这些其他颜色的格子因此变得与同色格子连通,也需要被占领。这需要迭代处理。 - 我们需要不断检查
cellsToCapture中所有格子的四邻域。对于每一个邻居格子,如果它未被占领且颜色不同,则找到这个邻居格子所属的整个同色连通区域。如果这个区域的所有格子都至少有一个邻居在cellsToCapture中(即被新占领的格子包围或接触),那么这个区域的格子也要被加入cellsToCapture。 - 重复步骤6,直到在一次完整的检查中,没有新的格子被加入
cellsToCapture为止。此时cellsToCapture就是本次操作最终要占领的所有格子。
4.2 递归DFS核心代码实现
以下是GameManager中核心算法的简化版实现,重点展示递归DFS搜索连通分量:
// GameManager.cs 内部 private List<CellData> CalculateAreaToCapture(Color selectedColor) { HashSet<CellData> totalCaptureSet = new HashSet<CellData>(); CellData[,] grid = boardManager.grid; int rows = boardManager.rows; int cols = boardManager.cols; // 步骤1 & 2: 找到所有该颜色的未占领种子格子 List<CellData> seedCells = new List<CellData>(); for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { CellData cell = grid[r, c]; if (cell.owner == null && ColorsApproximatelyEqual(cell.cellColor, selectedColor)) { seedCells.Add(cell); } } } // 步骤3: 对每个种子进行连通区域搜索 HashSet<CellData> visitedForColor = new HashSet<CellData>(); foreach (CellData seed in seedCells) { if (!visitedForColor.Contains(seed)) { HashSet<CellData> colorRegion = new HashSet<CellData>(); DFSFindConnectedRegion(seed, selectedColor, colorRegion, grid, rows, cols); // 将这个颜色区域加入总集合 foreach (var cell in colorRegion) { totalCaptureSet.Add(cell); visitedForColor.Add(cell); } } } // 如果初始颜色区域都没有,直接返回空 if (totalCaptureSet.Count == 0) return new List<CellData>(totalCaptureSet); // 步骤6 & 7: 处理连锁反应(不同颜色区域) bool changed; do { changed = false; // 收集当前totalCaptureSet所有格子的所有未占领的、颜色不同的邻居 HashSet<CellData> potentialNewColorNeighbors = new HashSet<CellData>(); foreach (CellData capturedCell in totalCaptureSet) { List<CellData> neighbors = GetFourNeighbors(capturedCell, grid, rows, cols); foreach (var neighbor in neighbors) { if (neighbor.owner == null && !ColorsApproximatelyEqual(neighbor.cellColor, selectedColor) && !totalCaptureSet.Contains(neighbor)) { potentialNewColorNeighbors.Add(neighbor); } } } // 检查每个潜在邻居所在的同色区域是否可被占领 HashSet<CellData> visitedOtherColors = new HashSet<CellData>(); foreach (CellData neighborSeed in potentialNewColorNeighbors) { if (visitedOtherColors.Contains(neighborSeed)) continue; HashSet<CellData> otherColorRegion = new HashSet<CellData>(); DFSFindConnectedRegion(neighborSeed, neighborSeed.cellColor, otherColorRegion, grid, rows, cols); // 判断该区域是否所有格子都与totalCaptureSet相邻 bool allCellsAdjacentToCaptured = true; foreach (var cell in otherColorRegion) { if (!IsCellAdjacentToSet(cell, totalCaptureSet, grid, rows, cols)) { allCellsAdjacentToCaptured = false; break; } } // 如果该区域所有格子都与已占领集相邻,则占领整个区域 if (allCellsAdjacentToCaptured) { foreach (var cell in otherColorRegion) { if (totalCaptureSet.Add(cell)) // 如果成功添加(即之前没有) { changed = true; } } } visitedOtherColors.UnionWith(otherColorRegion); } } while (changed); // 只要本轮有新增,就继续循环检查 return new List<CellData>(totalCaptureSet); } // 递归DFS函数 private void DFSFindConnectedRegion(CellData startCell, Color targetColor, HashSet<CellData> resultSet, CellData[,] grid, int rows, int cols) { // 递归终止条件:越界、已访问、已被占领、颜色不匹配 if (startCell == null || resultSet.Contains(startCell) || startCell.owner != null || !ColorsApproximatelyEqual(startCell.cellColor, targetColor)) return; resultSet.Add(startCell); // 四邻域递归搜索 int[][] directions = new int[][] { new int[] { -1, 0 }, new int[] { 1, 0 }, new int[] { 0, -1 }, new int[] { 0, 1 } }; foreach (var dir in directions) { int newR = startCell.rowIndex + dir[0]; int newC = startCell.colIndex + dir[1]; if (newR >= 0 && newR < rows && newC >= 0 && newC < cols) { DFSFindConnectedRegion(grid[newR, newC], targetColor, resultSet, grid, rows, cols); } } } // 辅助函数:获取四邻域格子 private List<CellData> GetFourNeighbors(CellData cell, CellData[,] grid, int rows, int cols) { ... } // 辅助函数:判断格子是否与集合中任一格子相邻 private bool IsCellAdjacentToSet(CellData cell, HashSet<CellData> set, CellData[,] grid, int rows, int cols) { ... } // 辅助函数:比较颜色(Unity Color是浮点数,直接==可能因精度问题出错) private bool ColorsApproximatelyEqual(Color a, Color b, float threshold = 0.01f) { ... }4.3 关键细节与优化点
- 颜色比较:Unity的
Color是结构体,包含r, g, b, a四个float。直接使用==运算符可能会因为浮点数精度问题导致误判。建议自己写一个近似比较函数ColorsApproximatelyEqual,判断每个分量的差值是否小于一个很小的阈值(如0.01f)。 - 使用HashSet:在搜索和去重时,
HashSet<T>的Contains和Add操作平均时间复杂度是O(1),比List快得多。大量使用HashSet来存储已访问节点和结果集合是性能关键。 - 递归深度:对于8x8的网格,最大的连通区域理论上可达64格,递归深度为64。这在Unity中是安全的(默认栈空间足够)。但如果网格扩大到50x50,最大深度可能达到2500,这就有栈溢出风险。这时需要将递归DFS改为显式栈(Stack)的DFS或队列(Queue)的BFS。下面是BFS版本的连通区域搜索示例:
private void BFSFindConnectedRegion(CellData startCell, Color targetColor, HashSet<CellData> resultSet, CellData[,] grid, int rows, int cols) { if (startCell.owner != null || !ColorsApproximatelyEqual(startCell.cellColor, targetColor)) return; Queue<CellData> queue = new Queue<CellData>(); queue.Enqueue(startCell); resultSet.Add(startCell); int[][] directions = { new int[] { -1, 0 }, new int[] { 1, 0 }, new int[] { 0, -1 }, new int[] { 0, 1 } }; while (queue.Count > 0) { CellData current = queue.Dequeue(); foreach (var dir in directions) { int newR = current.rowIndex + dir[0]; int newC = current.colIndex + dir[1]; if (newR >= 0 && newR < rows && newC >= 0 && newC < cols) { CellData neighbor = grid[newR, newC]; if (neighbor.owner == null && ColorsApproximatelyEqual(neighbor.cellColor, targetColor) && resultSet.Add(neighbor)) { queue.Enqueue(neighbor); } } } } } - 连锁反应迭代:算法中最复杂的部分是连锁反应。
do...while循环确保了能处理多层嵌套的连锁。IsCellAdjacentToSet函数需要高效,因为它会被频繁调用。可以预先计算每个格子的邻居列表并缓存,但在这个规模下,实时计算也可以接受。
5. 游戏逻辑完善与用户体验打磨
核心算法跑通后,我们需要完善游戏循环和交互,让它成为一个真正可玩的游戏。
5.1 回合流程与状态管理
在GameManager中,我们需要一个更健壮的回合状态机。例如,定义几个状态:
public enum GameState { Init, PlayerTurn, Processing, GameOver } private GameState currentState;Init: 初始化棋盘和玩家。PlayerTurn: 等待玩家操作(点击UI按钮选择颜色)。此时应高亮当前玩家,并可能在地图上用半透明色显示选择每种颜色后预计能占领的区域(这需要预先计算,是一个高级功能)。Processing: 玩家做出选择后,开始执行占领计算、播放占领动画(格子颜色渐变、粒子效果等)、更新分数。这个状态期间应锁定玩家输入。GameOver: 显示胜利者,提供重新开始按钮。
状态之间的转换要清晰,避免在动画播放中途被输入打断。
5.2 视觉反馈与动画
干巴巴的格子变色很无趣,好的视觉反馈能极大提升游戏体验。
- 选中预览:当鼠标悬停在某种颜色的UI按钮上时,可以在地图上用半透明高亮显示所有该颜色的未占领区域,让玩家提前预判。
- 占领动画:不要瞬间变色。可以为每个
CellData添加一个协程(Coroutine),实现颜色从原始色渐变到玩家色的效果。也可以让被占领的格子依次“弹出”一个缩放动画,形成波浪式的蔓延感。
在// 在CellData.cs中 public IEnumerator CaptureAnimation(Color targetColor, float duration) { float elapsed = 0f; Color startColor = spriteRenderer.color; while (elapsed < duration) { elapsed += Time.deltaTime; float t = elapsed / duration; spriteRenderer.color = Color.Lerp(startColor, targetColor, t); // 可以同时混合一些缩放动画 transform.localScale = Vector3.Lerp(Vector3.one, Vector3.one * 1.2f, Mathf.Sin(t * Mathf.PI)); // 脉冲效果 yield return null; } spriteRenderer.color = targetColor; transform.localScale = Vector3.one; }GameManager的CaptureCells函数中,可以StartCoroutine启动每个格子的动画,并用WaitForSeconds或等待所有协程结束来控制回合节奏。 - 玩家标识:格子被占领后,除了变色,还可以在格子中央显示一个代表玩家的小图标或改变边框颜色。
5.3 胜负判定与策略提示
游戏结束条件需要仔细实现:
- 无地可占:遍历所有未占领格子,检查其颜色是否至少被一位玩家“有效”选择(即选择后能占领至少一个格子)。如果所有未占领颜色的任何选择都无法占领新格子(可能因为该颜色区域被已占领格子完全包围,但包围者不是同一个人?这里规则需要明确),则游戏立即结束。原版规则似乎是直到所有格子被占领为止。
- 提前结束:也可以实现当某玩家面积超过一半时直接获胜,加快游戏节奏。
在PlayerTurn状态,可以添加简单的AI或策略提示。例如,计算如果选择每种颜色能获得多少面积,并在UI上显示这个数字,帮助玩家决策。这只需要调用CalculateAreaToCapture并返回列表的Count即可,但要注意性能,可以在后台异步计算。
6. 性能优化与常见问题排查
当网格变大、颜色变多时,一些性能问题和逻辑Bug就会暴露出来。
6.1 性能瓶颈分析与优化
频繁的网格遍历:
CalculateAreaToCapture中最耗时的部分是遍历整个网格寻找种子格子(O(N)),以及连锁反应中的多次邻居检查。对于10x10的网格,这完全不是问题。但如果做到20x20或更大,就需要优化。- 优化思路1:维护未占领格子列表。用一个
List<CellData>动态记录所有owner == null的格子。当格子被占领后,从列表中移除。这样寻找种子格子时只需遍历这个列表,而非整个网格。 - 优化思路2:缓存连通区域。在游戏开始时,为整个网格计算一次所有颜色的连通区域并缓存起来。当格子被占领后,更新受影响的缓存区域。这比较复杂,但能极大提升后续计算速度,适合追求极致性能或超大地图。
- 优化思路1:维护未占领格子列表。用一个
颜色比较与HashSet操作:
ColorsApproximatelyEqual函数和HashSet的Add/Contains是内层循环高频操作。确保颜色比较函数尽量简单(比如比较Color32而不是Color,或者将颜色映射为整数ID)。HashSet的容量可以预先设定一个合理值以减少扩容开销。动画协程开销:同时为几十上百个格子播放协程动画可能会有开销。可以考虑使用对象池管理动画效果,或者使用更高效的
DOTween插件来处理补间动画。
6.2 常见Bug与调试技巧
连锁反应死循环或漏算:这是最容易出错的地方。务必确保你的“判断区域是否可被占领”的逻辑(
IsCellAdjacentToSet)是正确的。关键规则:一个其他颜色的区域,只有当其每一个格子都与当前totalCaptureSet中的至少一个格子四邻域相邻时,才能被占领。如果这个区域中有一个格子是“孤岛”,不与任何已占领格相邻,那么整个区域都不能被占领。我的算法中通过allCellsAdjacentToCaptured布尔变量来确保这一点。调试时,可以打印出每次迭代中totalCaptureSet的数量和新增的格子,观察其增长是否符合预期。颜色误判:因为浮点数精度,两个视觉上一样的颜色可能被判为不同。除了使用阈值比较,更稳妥的方法是在初始化时,为每种颜色分配一个唯一的整数
ColorId,格子只存储ColorId。比较时直接比较整数,又快又准。边界条件处理:在
GetFourNeighbors函数中,一定要严格检查数组索引r >= 0 && r < rows && c >= 0 && c < cols,否则会引发IndexOutOfRangeException。游戏状态不同步:确保在
Processing状态时,玩家无法再次点击选择颜色。可以通过禁用UI按钮组或检查currentState来实现。内存泄漏:如果频繁创建
List或HashSet,注意在函数内部使用后,如果不再需要,可以将引用置为null(对于局部变量,函数结束会自动回收)。对于需要重用的集合,可以考虑在类级别声明并Clear()后重用,避免频繁的GC(垃圾回收)开销。
7. 功能扩展与创意发散
一个基础版本实现后,你可以尝试很多有趣的扩展,让这个项目成为你作品集里的亮点。
- 多种地图与地形:引入“障碍物”格子(永远不能被占领)、“一次性格子”(占领后消失)、“传送门格子”等,增加策略深度。
- 道具系统:玩家回合除了选颜色,还可以使用道具。例如:“炸弹”移除一个格子及其相邻格子;“颜色转换器”将一小片区域转换成指定颜色;“面积加倍”使本轮占领的格子分数翻倍。
- AI对手:实现不同难度的AI。简单AI随机选择;中等AI选择当前能占领面积最大的颜色;高级AI会模拟未来几步,考虑封锁对手或为自己创造更大的后续机会。
- 网络对战:使用Unity的Netcode或第三方SDK,将游戏改造成在线多人对战模式。这涉及到状态同步、输入命令、随机种子同步等更复杂的知识。
- 数据持久化与回放:记录每一步的操作,实现游戏回放功能,或者将精彩对局保存下来。
这个项目虽然源于一个小游戏,但它串联起了Unity开发中的多个核心技能点:Prefab动态生成、数据模型与视图分离、递归/迭代算法、协程动画、简单状态机、基础AI。无论你是用来巩固算法,还是作为面试作品,都很有价值。
最后,所有的完整C#源码,包括BoardManager,CellData,GameManager,Player以及几个算法辅助类,我已经整理好。你可以直接复制这些代码到你的Unity项目中(记得创建对应的脚本文件并挂载到正确的GameObject上),然后根据文中的步骤设置场景和Prefab,就能运行起来。希望这篇长文和配套源码能帮助你彻底掌握这个有趣的游戏开发案例。在实际动手实现的过程中,你一定会遇到我文中没提到的小问题,这正是学习提升的最好机会。