# Unity 扫雷(Minesweeper)最小可玩项目 下面是一套 **可直接跑** 的 Unity 2D 版本扫雷,实现:左键翻格、右键插旗、空白自动扩散、胜负判断、重新开始。无外部资源依赖,只用内置 `SpriteRenderer + BoxCollider2D + TextMesh`。 > 目标 Unity 版本:2021+(更高版本同理)。平台:Windows。 --- ## 一、场景与预制体 1. 新建 2D 项目,创建空场景 `Main`。 2. 创建空对象 `GameManager`(挂 `GameManager.cs`)。 3. 创建 **Cell 预制体**: - 新建 `Sprite -> Square`,命名 `Cell`;添加组件:`BoxCollider2D`(勾 `Is Trigger` 取消,保持默认)。 - `SpriteRenderer`: - `Color` 设为浅灰(如 `#D6D6D6`)。 - `Draw Mode` = Simple,`Order in Layer` = 0。 - 在 `Cell` 下创建子物体 `Number` -> `3D Object -> Text - TextMesh`: - `TextMesh.characterSize=0.25`,`anchor=MiddleCenter`,`color=black`,`fontSize=60`。 - `transform.localPosition=(0,0, -0.01)`(确保文字在上层显示)。 - 把 `Cell` 拖进 `Assets` 做成 `Prefab`。 > 提示:相机 `Size`/`Position` 可后面由 `Board` 脚本自动调整,无需手调。 --- ## 二、脚本结构 - `GameManager.cs`:全局配置、重新开始、胜负 UI(用 `Debug.Log` 简化,可自行加 Canvas UI)。 - `Board.cs`:生成网格、随机布雷、计算邻雷数、泛洪展开、胜负判断。 - `Cell.cs`:单元格行为(左键翻、右键旗、显示数值)。 将以下脚本分别创建到 `Assets/Scripts/`,文件名需与类名一致。 ### GameManager.cs ```csharp using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager I; // 简单单例 [Header("Board Settings")] public int width = 16; public int height = 16; [Range(0f, 0.9f)] public float mineRatio = 0.15f; // 雷占比 [HideInInspector] public Board board; private bool gameOver; void Awake() { if (I != null && I != this) { Destroy(gameObject); return; } I = this; } void Start() { StartNewGame(); } public void StartNewGame() { gameOver = false; if (board != null) Destroy(board.gameObject); var go = new GameObject("Board"); board = go.AddComponent<Board>(); board.Init(width, height, mineRatio); Debug.Log("New game started."); } public bool IsGameOver() => gameOver; public void OnGameWon() { if (gameOver) return; gameOver = true; Debug.Log("YOU WIN! 所有非雷格都已翻开。"); } public void OnGameLost() { if (gameOver) return; gameOver = true; board.RevealAllMines(); Debug.Log("BOOM! 你踩到雷了。"); } void Update() { if (Input.GetKeyDown(KeyCode.R)) { StartNewGame(); } } } ``` ### Board.cs ```csharp using System.Collections.Generic; using UnityEngine; public class Board : MonoBehaviour { public Cell cellPrefab; // 在 Inspector 里赋值为 Cell 预制体 private int width, height, mineCount, revealedCount; private Cell[,] cells; private bool firstClickDone; public void Init(int w, int h, float mineRatio) { width = Mathf.Max(4, w); height = Mathf.Max(4, h); mineCount = Mathf.Clamp(Mathf.RoundToInt(width * height * mineRatio), 1, width * height - 1); if (cellPrefab == null) { // 尝试自动从 Resources/ 或项目中查找名为 "Cell" 的预制体 var loaded = Resources.Load<Cell>("Cell"); if (loaded != null) cellPrefab = loaded; } GenerateGrid(); PositionCamera(); } void GenerateGrid() { cells = new Cell[width, height]; var parent = this.transform; float spacing = 1.02f; // 适度间距 for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { Cell c = Instantiate(cellPrefab, parent); c.transform.position = new Vector3(x * spacing, y * spacing, 0); c.Init(this, x, y); cells[x, y] = c; } } } void PositionCamera() { var cam = Camera.main; if (!cam) { var camGO = new GameObject("Main Camera"); cam = camGO.AddComponent<Camera>(); cam.orthographic = true; cam.tag = "MainCamera"; } cam.orthographic = true; float spacing = 1.02f; float w = (width - 1) * spacing; float h = (height - 1) * spacing; cam.transform.position = new Vector3(w / 2f, h / 2f, -10f); float sizeByHeight = (h + 2f) / 2f; // 留白 float sizeByWidth = (w + 2f) / (2f * cam.aspect); cam.orthographicSize = Mathf.Max(5f, Mathf.Max(sizeByHeight, sizeByWidth)); } public void OnCellLeftClick(Cell c) { if (GameManager.I.IsGameOver() || c.isFlagged || c.isRevealed) return; if (!firstClickDone) { firstClickDone = true; PlaceMinesAvoiding(c.x, c.y); ComputeNumbers(); } Reveal(c); } public void OnCellRightClick(Cell c) { if (GameManager.I.IsGameOver() || c.isRevealed) return; c.ToggleFlag(); } void PlaceMinesAvoiding(int safeX, int safeY) { int placed = 0; System.Random rng = new System.Random(); HashSet<int> used = new HashSet<int>(); while (placed < mineCount) { int x = rng.Next(width); int y = rng.Next(height); if (Mathf.Abs(x - safeX) <= 1 && Mathf.Abs(y - safeY) <= 1) continue; // 首点和周围 8 格安全 int key = x * 10000 + y; if (used.Contains(key)) continue; if (!cells[x, y].isMine) { cells[x, y].isMine = true; used.Add(key); placed++; } } } void ComputeNumbers() { int[] dx = {-1,0,1,-1,1,-1,0,1}; int[] dy = {-1,-1,-1,0,0,1,1,1}; for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { if (cells[x,y].isMine) { cells[x,y].neighborMines = -1; continue; } int cnt = 0; for (int k=0;k<8;k++) { int nx = x + dx[k]; int ny = y + dy[k]; if (InBounds(nx, ny) && cells[nx,ny].isMine) cnt++; } cells[x,y].neighborMines = cnt; } } } public void Reveal(Cell c) { if (c.isRevealed || c.isFlagged) return; c.Reveal(); revealedCount++; if (c.isMine) { GameManager.I.OnGameLost(); return; } if (c.neighborMines == 0) { // BFS 扩散 Queue<Cell> q = new Queue<Cell>(); q.Enqueue(c); while (q.Count > 0) { var cur = q.Dequeue(); foreach (var nb in Neighbors(cur.x, cur.y)) { if (!nb.isRevealed && !nb.isMine && !nb.isFlagged) { nb.Reveal(); revealedCount++; if (nb.neighborMines == 0) q.Enqueue(nb); } } } } // 胜利:非雷格全部翻开 int totalSafe = width * height - mineCount; if (revealedCount >= totalSafe) { GameManager.I.OnGameWon(); } } public void RevealAllMines() { foreach (var c in cells) { if (c.isMine) c.Reveal(force:true); } } bool InBounds(int x, int y) => x >= 0 && x < width && y >= 0 && y < height; IEnumerable<Cell> Neighbors(int x, int y) { for (int nx = x - 1; nx <= x + 1; nx++) for (int ny = y - 1; ny <= y + 1; ny++) { if (nx == x && ny == y) continue; if (InBounds(nx, ny)) yield return cells[nx, ny]; } } } ``` ### Cell.cs ```csharp using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] [RequireComponent(typeof(BoxCollider2D))] public class Cell : MonoBehaviour { [HideInInspector] public int x, y; [HideInInspector] public bool isMine; [HideInInspector] public bool isRevealed; [HideInInspector] public bool isFlagged; [HideInInspector] public int neighborMines; private SpriteRenderer sr; private TextMesh label; private Board board; Color covered = new Color(0.84f, 0.84f, 0.84f); Color uncovered = Color.white; Color mineColor = new Color(0.9f, 0.3f, 0.3f); Color flagColor = new Color(0.95f, 0.6f, 0.2f); public void Init(Board b, int X, int Y) { board = b; x = X; y = Y; sr = GetComponent<SpriteRenderer>(); sr.color = covered; label = transform.Find("Number")?.GetComponent<TextMesh>(); if (label) label.text = ""; isMine = false; isFlagged = false; isRevealed = false; neighborMines = 0; } public void ToggleFlag() { if (isRevealed) return; isFlagged = !isFlagged; sr.color = isFlagged ? flagColor : covered; if (label) label.text = isFlagged ? "F" : ""; } public void Reveal(bool force=false) { if (isRevealed) return; isRevealed = true; sr.color = uncovered; if (isMine) { sr.color = mineColor; if (label) label.text = "*"; return; } if (neighborMines > 0) { if (label) label.text = neighborMines.ToString(); // 根据数字微调颜色 label.color = NumberColor(neighborMines); } else { if (label) label.text = ""; // 空白 } } Color NumberColor(int n) { switch (n) { case 1: return new Color(0.2f,0.2f,1f); case 2: return new Color(0f,0.5f,0f); case 3: return new Color(1f,0f,0f); case 4: return new Color(0f,0f,0.5f); case 5: return new Color(0.5f,0f,0f); case 6: return new Color(0f,0.5f,0.5f); case 7: return Color.black; case 8: return Color.gray; default: return Color.black; } } void OnMouseOver() { if (GameManager.I == null || board == null) return; if (Input.GetMouseButtonDown(0)) { board.OnCellLeftClick(this); } else if (Input.GetMouseButtonDown(1)) { board.OnCellRightClick(this); } } } ``` --- ## 三、把脚本挂到对象 1. 选中 `GameManager` 对象,挂 `GameManager.cs`。 2. 新建空对象 `Board` 无需手建,`GameManager` 会在运行时创建并挂 `Board.cs`。 3. **重要**:选中 `Assets` 里的 `Cell` 预制体,拖到 `Board` 组件的 `cellPrefab` 槽(运行后会自动赋值,如果你把预制体放进 `Resources/Cell.prefab` 也可自动加载)。 --- ## 四、运行与测试 - 直接按 **Play**: - 左键翻格,右键插旗; - 按 **R** 重新开始; - 控制台会显示胜负信息(可自己加 UI 文本/计时器)。 - 想改难度:修改 `GameManager` 上的 `width/height/mineRatio`。 --- ## 五、可选增强 - 用 `Canvas + Text` 显示剩余雷、计时器、WIN/BOOM 弹窗; - 加入“首次点击永不中雷”的更大安全区(当前是九宫格安全); - 添加“和棋”式数字连翻(已翻开的数字上,若旗数量=数字则自动翻周围未翻格)。 --- ## 六、常见坑 - 如果点击无响应,请确认 `Cell` 预制体上有 **BoxCollider2D**,相机是 **Orthographic** 且能看到网格。 - 文字不显示:确认有 `TextMesh` 子物体且 Z 稍微更小(-0.01)。 - 运行时报 `NullReference`:大多因为 `Board.cellPrefab` 未设置;在 `Board` Inspector 指定预制体或把 `Cell.prefab` 放到 `Assets/Resources/Cell.prefab`。