更新日期:2025年6月17日。
项目源码:后续章节发布
索引
扫雷【Minesweeper】
本篇的目标是开发一个扫雷【Minesweeper】
小游戏。
一、游戏最终效果
Unity编辑器小游戏:扫雷
二、玩法简介
扫雷是一款广受欢迎的益智类单人游戏,其玩法简单却富有挑战性。
玩家需要在不触发地雷
的前提下,揭开所有安全的方格,并正确标记出所有地雷的位置。游戏界面由一个方格网格组成,每个方格可能隐藏着地雷或数字。数字表示该方格周围8个相邻方格中地雷的数量。
游戏开始时,所有方格都是隐藏的,玩家通过点击方格来揭示其内容。根据揭示的信息,玩家需要通过逻辑推理逐步排除雷区,找出所有安全区域。
三、正式开始
1.定义游戏窗口类
首先,定义扫雷的游戏窗口类MiniGame_Minesweeper
,其继承至MiniGameWindow【小游戏窗口基类】
:
csharp
/// <summary>
/// 扫雷
/// </summary>
public class MiniGame_Minesweeper : MiniGameWindow
{
}
2.规划游戏窗口、视口区域
通过覆写虚属性
实现规划游戏视口区域大小:
csharp
/// <summary>
/// 游戏名称
/// </summary>
public override string Name => "扫雷 [Minesweeper]";
/// <summary>
/// 游戏窗体大小
/// </summary>
public override Vector2 WindowSize => new Vector2(700, 530);
/// <summary>
/// 游戏视口区域
/// </summary>
public override Rect ViewportRect => new Rect(5, 25, 500, 500);
注意:游戏窗体大小必须 > 游戏视口区域。
然后通过代码打开此游戏窗口:
csharp
[MenuItem("MiniGame/扫雷 [Minesweeper]", priority = 2)]
private static void Open_MiniGame_Minesweeper()
{
MiniGameWindow.OpenWindow<MiniGame_Minesweeper>();
}
便可以看到游戏的窗口、视口区域如下(左侧深色凹陷区域为视口
区域):

3.地图方块阵列
扫雷游戏的背景也是由一系列方块组成的,所以我们先来绘制如下这样的地图方块阵列:

①.定义方块结构体
首先,定义方块结构体Block
,其代表方块阵列中的一个方块:
csharp
/// <summary>
/// 地块
/// </summary>
public struct Block
{
/// <summary>
/// 地块位置
/// </summary>
public Rect Position;
/// <summary>
/// 是否为地雷
/// </summary>
public bool IsMine;
/// <summary>
/// 周围的地雷数量(仅当不为地雷时)
/// </summary>
public string AroundMineCount;
/// <summary>
/// 是否已翻开地块
/// </summary>
public bool IsOpened;
/// <summary>
/// 是否已标记为地雷
/// </summary>
public bool IsSigned;
}
②.生成方块阵列
我们设计如下三种难度等级
的关卡:
名称 | 地图大小 | 地雷数量 |
---|---|---|
初级 | 9*9 | 10 |
中级 | 16*16 | 40 |
高级 | 25*25 | 99 |
csharp
private readonly string[] LEVELS = new string[] { "初级(9*9)", "中级(16*16)", "高级(25*25)" };
所以游戏视口的宽度、高度是根据高级难度(方块尺寸20 * 方块宽高25 = 500)
的大小来设置的:
csharp
private const int BLOCKSIZE = 20;
根据选择的不同难度,来生成对应的地图方块阵列:
csharp
//地图宽度
private static int WIDTH = 9;
//地图高度
private static int HEIGHT = 9;
//地雷总数
private static int MINECOUNT = 10;
//已标记地雷数量
private int _signedCount = 0;
private int _level = 0;
private Block[,] _panel;
/// <summary>
/// 开始游戏
/// </summary>
private void StartGame()
{
if (_level == 0)
{
WIDTH = 9;
HEIGHT = 9;
MINECOUNT = 10;
}
else if (_level == 1)
{
WIDTH = 16;
HEIGHT = 16;
MINECOUNT = 40;
}
else if (_level == 2)
{
WIDTH = 25;
HEIGHT = 25;
MINECOUNT = 99;
}
GenerateMines();
_signedCount = 0;
}
/// <summary>
/// 生成地雷
/// </summary>
private void GenerateMines()
{
//生成地图
_panel = new Block[WIDTH, HEIGHT];
for (int h = 0; h < HEIGHT; h++)
{
for (int w = 0; w < WIDTH; w++)
{
_panel[w, h].Position = new Rect(w * BLOCKSIZE, h * BLOCKSIZE, BLOCKSIZE, BLOCKSIZE);
_panel[w, h].IsMine = false;
_panel[w, h].AroundMineCount = "";
_panel[w, h].IsOpened = false;
_panel[w, h].IsSigned = false;
}
}
}
③.绘制方块阵列
然后在OnGameViewportGUI
方法中绘制方块阵列:
csharp
//已翻开地雷的GUIContent
private GUIContent _mineGC;
//未翻开、已标记方块的GUIContent
private GUIContent _signGC;
//已翻开方块的GUIStyle
private GUIStyle _openedGS;
//未翻开方块的GUIStyle
private GUIStyle _closeGS;
protected override void OnGameViewportGUI()
{
base.OnGameViewportGUI();
DrawPanel();
}
/// <summary>
/// 绘制画布
/// </summary>
private void DrawPanel()
{
for (int h = 0; h < HEIGHT; h++)
{
for (int w = 0; w < WIDTH; w++)
{
DrawBlock(w, h);
}
}
}
/// <summary>
/// 绘制方块
/// </summary>
private void DrawBlock(int x, int y)
{
if (_panel[x, y].IsOpened)
{
if (_panel[x, y].IsMine)
{
//如果已翻开,且为地雷,则直接显示地雷
GUI.color = Color.red;
GUI.Box(_panel[x, y].Position, _mineGC, _openedGS);
GUI.color = Color.white;
}
else
{
//如果已翻开,且不为地雷,则显示其周围的地雷数量
GUI.Box(_panel[x, y].Position, _panel[x, y].AroundMineCount, _openedGS);
}
}
else
{
if (_panel[x, y].IsSigned)
{
//如果未翻开,且已标记,显示标记图标
GUI.Box(_panel[x, y].Position, _signGC, _closeGS);
}
else
{
//如果未翻开,且未标记,显示空白按钮
GUI.Box(_panel[x, y].Position, "", _closeGS);
}
}
}
此时就能绘制出游戏的地图方块阵列了,比如初级(9*9)的:

注意:这里有一个选择关卡难度的过程省略了,该过程很简单便不浪费篇幅赘述了,后续在源码中即可一目了然。
四种状态的方块绘制出来大致是这样的:

4.随机生成地雷
到此时,地图上的所有方块虽然有了,但其中一个地雷也没有,所以我们需要根据当前关卡难度的总地雷数
,来随机生成地雷:
回到GenerateMines
方法中:
csharp
/// <summary>
/// 生成地雷
/// </summary>
private void GenerateMines()
{
//生成地图
//......
//将地图中所有方块的索引存入一个数组
int[] indexs = new int[WIDTH * HEIGHT];
for (int i = 0; i < indexs.Length; i++)
{
indexs[i] = i;
}
//使用洗牌算法打乱所有方块索引数组
Utility.ShuffleAlgorithm(indexs, MINECOUNT);
//从方块索引数组的坐标0开始,直接设置所有地雷
//由于方块索引数组已打乱,所以此时设置的地雷位置是随机的
for (int i = 0; i < MINECOUNT; i++)
{
int index = indexs[i];
int row = index / WIDTH;
int col = index % WIDTH;
//设置为地雷
_panel[row, col].IsMine = true;
}
//刷新地雷周围地块上显示的地雷数量
for (int h = 0; h < HEIGHT; h++)
{
for (int w = 0; w < WIDTH; w++)
{
_panel[w, h].AroundMineCount = CalculateAroundMineCount(w, h);
}
}
}
5.刷新方块周围的地雷数量
这个方法就很简单,发现一个地雷就++
就行:
csharp
/// <summary>
/// 计算一个地块周围的地雷数量(直接返回string类型,用于GUI显示,省去了类型转换过程)
/// </summary>
private string CalculateAroundMineCount(int x, int y)
{
if (_panel[x, y].IsMine)
return "";
int count = 0;
for (int i = -1; i <= 1; i++)
{
for (int j = -1; j <= 1; j++)
{
if (i == 0 && j == 0)
continue;
int newX = x + i;
int newY = y + j;
if (newX >= 0 && newX < WIDTH && newY >= 0 && newY < HEIGHT && _panel[newX, newY].IsMine)
{
count++;
}
}
}
//如果周围一个地雷也没有,就显示空白
return count > 0 ? count.ToString() : "";
}
6.翻开地块、标记地块
接下来是最重要的功能翻开地块、标记地块
,在OnGamePlayingEvent
方法中,分别使用鼠标左键
翻开地块,鼠标右键
标记地块:
csharp
protected override void OnGamePlayingEvent(Event e, Vector2 mousePosition)
{
base.OnGamePlayingEvent(e, mousePosition);
if (e.type == EventType.MouseDown)
{
if (e.button == 0)
{
for (int h = 0; h < HEIGHT; h++)
{
for (int w = 0; w < WIDTH; w++)
{
if (!_panel[w, h].IsOpened)
{
//如果鼠标左键点在了某一方块上
if (_panel[w, h].Position.Contains(mousePosition))
{
//翻开该方块
OpenBlock(w, h);
Repaint();
return;
}
}
}
}
}
else if (e.button == 1)
{
for (int h = 0; h < HEIGHT; h++)
{
for (int w = 0; w < WIDTH; w++)
{
if (!_panel[w, h].IsOpened)
{
//如果鼠标右键点在了某一方块上
if (_panel[w, h].Position.Contains(mousePosition))
{
//标记该方块
SignBlock(w, h);
Repaint();
return;
}
}
}
}
}
}
}
①.连锁翻方块
不过需注意的时,翻开方块时,如果翻到了空白方块(周围地雷数为0)
,则应触发连锁翻方块
功能。
连锁翻方块:从当前空白方块开始,尝试翻开周围的所有邻居方块,遇到不为空白的方块则停止。
csharp
/// <summary>
/// 翻开地块
/// </summary>
private void OpenBlock(int x, int y)
{
if (_panel[x, y].IsOpened)
return;
_panel[x, y].IsOpened = true;
//如果翻开的方块为地雷,则游戏失败
if (_panel[x, y].IsMine)
{
IsGameOvered = true;
}
else
{
//如果翻开的方块为空白方块,则连锁翻方块
if (_panel[x, y].AroundMineCount == "")
{
OpenNeighborBlock(x, y);
}
//每翻开一个方块,检测游戏是否通关
IsGameSuccessed = CheckSuccess();
}
}
/// <summary>
/// 尝试翻开周围的空白地块
/// </summary>
private void OpenNeighborBlock(int x, int y)
{
for (int i = -1; i <= 1; i++)
{
for (int j = -1; j <= 1; j++)
{
if (i == 0 && j == 0)
continue;
int newX = x + i;
int newY = y + j;
if (newX >= 0 && newX < WIDTH && newY >= 0 && newY < HEIGHT && !_panel[newX, newY].IsMine)
{
OpenBlock(newX, newY);
}
}
}
}
注意:
1.直接设置
IsGameOvered = true
则表示游戏失败,将自动弹出Game Over
界面;2.直接设置
IsGameSuccessed = true
则表示游戏通关,将自动弹出Game Success
界面;
②.标记方块
标记方块的逻辑就比翻开方块的逻辑简单得多:
csharp
/// <summary>
/// 标记地块
/// </summary>
private void SignBlock(int x, int y)
{
if (_panel[x, y].IsSigned)
{
//如果已标记,则取消标记
_panel[x, y].IsSigned = false;
_signedCount--;
}
else
{
//已标记的地雷数量未超上限,才允许标记
if (_signedCount < MINECOUNT)
{
//标记地雷
_panel[x, y].IsSigned = true;
_signedCount++;
IsGameSuccessed = CheckSuccess();
}
}
}
7.检测游戏是否通关
检测游戏是否通关的逻辑为:翻开所有非地雷
方块,标记所有地雷
方块:
csharp
/// <summary>
/// 检测游戏是否通关
/// </summary>
private bool CheckSuccess()
{
if (_signedCount != MINECOUNT)
return false;
for (int h = 0; h < HEIGHT; h++)
{
for (int w = 0; w < WIDTH; w++)
{
if (_panel[w, h].IsMine)
{
if (!_panel[w, h].IsSigned) return false;
}
else
{
if (!_panel[w, h].IsOpened) return false;
}
}
}
return true;
}
8.绘制游戏操作说明
最后,操作说明等其他UI统一绘制在OnOtherGUI
方法中:
csharp
protected override void OnOtherGUI()
{
base.OnOtherGUI();
if (_isReady)
return;
//剩余地雷数量
Rect rect = new Rect(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + 5, 50, 20);
GUI.Label(rect, "Mines:");
rect.x += 50;
rect.width = 100;
EditorGUI.IntField(rect, MINECOUNT - _signedCount);
rect.Set(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + ViewportRect.height - 50, 80, 20);
GUI.Button(rect, "Mouse Left");
rect.x += 85;
rect.width = 100;
GUI.Label(rect, "Open block");
rect.Set(ViewportRect.x + ViewportRect.width + 5, ViewportRect.y + ViewportRect.height - 25, 80, 20);
GUI.Button(rect, "Mouse Right");
rect.x += 85;
rect.width = 100;
GUI.Label(rect, "Marked as mines");
}
这里绘制出来的效果如下:

至此,一个简单的扫雷
小游戏就完成了,试玩效果如下:扫雷【Minesweeper】。
9.暂停游戏、退出游戏
同俄罗斯方块。