# 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`。