这是用户在 2024-3-18 11:45 为 https://catlikecoding.com/unity/tutorials/prototypes/minecomb/ 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?
Catlike Coding
published 2023-02-28

Minecomb

Sweeping Mines

 扫雷扫雷
  • Create a hexagonal grid.
    创建一个六边形网格。
  • Visualize the grid with small cubes.
    用小立方体来可视化网格。
  • Touch and reveal cells.
    触摸并显示细胞。
  • Make the grid ripple.
    使网格波动。

This is the second tutorial in a series about prototypes. In it we will create a game about sweeping mines.
这是关于原型的系列教程中的第二个教程。在这个教程中,我们将创建一个关于扫雷的游戏。

This tutorial is made with Unity 2021.3.19f1 and upgraded to 2022.3.1f1.
本教程是使用Unity 2021.3.19f1制作的,并升级到2022.3.1f1。

Revealing all cells while trying to avoid mines.
揭示所有的方格,同时避免触雷。

Game Scene 游戏场景

The game that we'll clone in this tutorial is Minesweeper, or any other similar mine-sweeping game. We name it Minecomb. Like we did with Paddle Square, we'll introduce a few twists. In this case we'll use a hexagonal grid layout instead of a square one, which is a common variant. We also once again only use cubes and world-space text.
在本教程中,我们要克隆的游戏是 Minesweeper ,或者其他类似的扫雷游戏。我们将其命名为 Minecomb 。与我们在《挡板方块》中所做的一样,我们会引入一些变化。在这种情况下,我们将使用六边形网格布局,而不是常见的方形布局。我们再次只使用立方体和世界空间文本。

Visuals 视觉效果

We'll use the same graphics settings as Paddle Square, so we can copy that project and remove all scripts, materials, and prefabs from it. Also remove everything except the main light and camera from the game scene.
我们将使用与Paddle Square相同的图形设置,因此我们可以复制该项目并从中删除所有脚本、材质和预设。还要从游戏场景中删除除了主光源和摄像机之外的所有内容。

Let's use a slightly tilted top-down view, changing the camera position to (0, 80, −15) and its rotation to (80, 0, 0). Also, it doesn't have a custom script component anymore. Set the light's rotation to (40, 10, 0) and enable it.
让我们使用一个稍微倾斜的俯视视角,将相机位置改为(0,80,-15),旋转为(80,0,0)。此外,它不再具有自定义脚本组件。将灯光的旋转设置为(40,10,0)并启用它。

We'll use a single TextMeshPro game object to display the amount of unknown mines, with default text 0, position (0, 0, 38), width 20, height 5, rotation (90, 0, 0), font size 64 and centered. It will use the existing glowing yellow material.
我们将使用一个单一的游戏对象来显示未知地雷的数量,使用默认文本 0 ,位置(0, 0, 38),宽度20,高度5,旋转(90, 0, 0),字体大小64,并居中。它将使用现有的发光黄色材质。

Also, because we'll end up rendering many small cubes from a distance let's set the URP asset's Anti Aliasing (MSAA) mode to 4x. This can work in combination with FXAA to deal with a few HDR-range fragments. Let's also disable shadows via the Lighting / Main Light / Casts Shadows toggle.
另外,由于我们将在远处渲染许多小方块,让我们将URP资产的 Anti Aliasing (MSAA) 模式设置为 4x 。这可以与FXAA结合使用,以处理一些HDR范围的片段。让我们还通过 Lighting / Main Light / Casts Shadows 切换禁用阴影。

Game Component 游戏组件

Once again we'll use a single game object with a Game component to control the entire game. At minimum, it needs a reference to the mines text and configuration fields for the amount of rows and columns, set to 8 and 21 by default.
再次,我们将使用一个带有 Game 组件的单个游戏对象来控制整个游戏。至少,它需要一个对地雷文本的引用,以及默认设置为8行和21列的行和列的配置字段。

using TMPro;
using UnityEngine;

public class Game : MonoBehaviour
{
	[SerializeField]
	TextMeshPro minesText;
	
	[SerializeField, Min(1)]
	int rows = 8, columns = 21;
}

Cell State 细胞状态

The goal of the game is to reveal all cells that aren't mines. Revealing a mine means failure. If a non-mine cell is revealed it displays the amount of mines that are adjacent to it. In case of a hexagonal grid there are at most six neighbors, so we need to display digits 0–6. Create a CellState enum type for this, put in its own script asset.
游戏的目标是揭示所有不是地雷的方格。揭示地雷意味着失败。如果揭示的是非地雷方格,它会显示相邻地雷的数量。在六边形网格中,最多有六个邻居,因此我们需要显示数字0-6。为此创建一个 CellState 枚举类型,并将其放在自己的脚本资源中。

public enum CellState
{
	Zero, One, Two, Three, Four, Five, Six
}

We need to keep track of more than the amount of adjacent mines. Cells can themselves contain a mine. We can store this information in the unused bits of CellState. Values up to six require three bits, so we can use the fourth bit as a flag to indicate a mine. To make debugging easier add the System.Flags attribute to the enum as well.
我们需要追踪的不仅仅是相邻地雷的数量。单元格本身也可能包含地雷。我们可以将这些信息存储在 CellState 的未使用位中。值最多为六需要三个位,因此我们可以使用第四个位作为标志来指示地雷。为了更容易进行调试,还可以将 System.Flags 属性添加到枚举中。

[System.Flags]
public enum CellState
{
	Zero, One, Two, Three, Four, Five, Six,
	Mine = 1 << 3
}

There are three more things that we should keep track of. First, a cell can be marked to indicate that it surely contains a mine, to prevent accidentally revealing it. Second, an unsure mark can exist as well. Third, cells can be revealed. Add bit flags for these three options.
我们还需要跟踪三件事情。首先,可以标记一个单元格以表示它肯定包含地雷,以防止意外揭示。其次,也可以存在不确定的标记。第三,可以揭示单元格。为这三个选项添加位标志。

	Mine = 1 << 3,
	MarkedSure = 1 << 4,
	MarkedUnsure = 1 << 5,
	Revealed = 1 << 6

Let's also add some convenient combined masks to make checking the cell states easier. Add one for any kind of mark, one for marked or revealed, and one for a sure mark or a mine.
让我们还添加一些方便的组合掩码,以便更容易地检查单元格状态。添加一个用于任何类型的标记,一个用于标记或揭示,以及一个用于确定标记或地雷。

	Revealed = 1 << 6,
	Marked = MarkedSure | MarkedUnsure,
	MarkedOrRevealed = Marked | Revealed,
	MarkedSureOrMine = MarkedSure | Mine

Checking and setting the non-digit cell states is done via bit mask operations. To abstract this let's add extension methods to check whether a cell is or is not in a state that matches a specific mask. Also add methods to get a cell state with or without a mask applied to it.
通过位掩码操作来检查和设置非数字单元格的状态。为了抽象化这一过程,让我们添加扩展方法来检查单元格是否处于与特定掩码匹配的状态,以及获取应用或不应用掩码的单元格状态的方法。

[System.Flags]
public enum CellState
{
	…
}

public static class CellStateExtensionMethods
{
	public static bool Is (this CellState s, CellState mask) => (s & mask) != 0;

	public static bool IsNot (this CellState s, CellState mask) => (s & mask) == 0;
	
	public static CellState With (this CellState s, CellState mask) => s | mask;

	public static CellState Without (this CellState s, CellState mask) => s & ~mask;
}

The Grid 网格

We'll create separate structs containing native arrays and compute buffers to represent the grid and its visualization, using jobs whenever we need to process multiple cells. The entire grid will be draw with a single procedural draw call.
我们将创建单独的结构体,其中包含原生数组和计算缓冲区,以表示网格及其可视化,每当我们需要处理多个单元时,使用作业。整个网格将使用单个程序性绘制调用进行绘制。

Grid Struct 网格结构

To hide the implementation details of the grid from Game we introduce a Grid struct type. It has public getter properties for its amount of rows and columns as well as the total cell count. Give it an Initialize method that allocates a native array with cell states for a given amount of rows and columns, along with a dispose method.
为了隐藏网格的实现细节,我们引入了一个 Game 结构类型。它具有公共的getter属性,用于获取行数、列数以及总单元格数。给它一个 Initialize 方法,用于为给定的行数和列数分配一个具有单元格状态的本地数组,以及一个dispose方法。

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public struct Grid
{
	public int Rows { get; private set; }

	public int Columns { get; private set; }

	public int CellCount => states.Length;

	NativeArray<CellState> states;

	public void Initialize (int rows, int columns)
	{
		Rows = rows;
		Columns = columns;
		states = new NativeArray<CellState>(Rows * Columns, Allocator.Persistent);
	}

	public void Dispose () => states.Dispose();
}

To access and modify cell states, include a public indexer property that acts as a proxy for the underlying native array. Let's also include a method that converts a row and a column index to a cell index, with a variant to try to get such an index for an unvalidated row and column. And also include a method to convert from cell index back to row and column indices.
为了访问和修改单元格状态,包括一个公共索引器属性,作为底层本地数组的代理。我们还可以包括一个方法,将行和列索引转换为单元格索引,以及一个变体来尝试获取未经验证的行和列的索引。还可以包括一个方法,将单元格索引转换回行和列索引。

	public CellState this[int i]
	{
		get => states[i];
		set => states[i] = value;
	}public int GetCellIndex (int row, int column) => row * Columns + column;

	public bool TryGetCellIndex (int row, int column, out int index)
	{
		bool valid = 0 <= row && row < Rows && 0 <= column && column < Columns;
		index = valid ? GetCellIndex(row, column) : -1;
		return valid;
	}
	
	public void GetRowColumn (int index, out int row, out int column)
	{
		row = index / Columns;
		column = index - row * Columns;
	}

Add a grid field to Game, initializing it in OnEnable and disposing it in OnDisable. Also, add an Update method that resets the grid if the configured rows or columns change, so we can immediately see these changes while in play mode.
Game 中添加一个网格字段,在 OnEnable 中进行初始化,在 OnDisable 中进行处理。此外,添加一个 Update 方法,如果配置的行或列发生变化,则重置网格,以便在播放模式下立即看到这些变化。

	Grid grid;

	void OnEnable ()
	{
		grid.Initialize(rows, columns);
	}

	void OnDisable ()
	{
		grid.Dispose();
	}

	void Update ()
	{
		if (grid.Rows != rows || grid.Columns != columns)
		{
			OnDisable();
			OnEnable();
		}
	}

Visualization Struct 可视化结构

We create a separate GridVisualization struct type to take care of the visualization. It needs to keep track of the grid, a material, and a mesh to do its work. It also contains compute buffers and float3 native arrays for positions and colors set via _Positions and _Colors shader properties. Everything is set up and cleaned up via public Initialize and Dispose methods.
我们创建了一个单独的 GridVisualization 结构类型来处理可视化。它需要跟踪网格、材质和网格来完成工作。它还包含计算缓冲区和通过 _Positions_Colors 着色器属性设置的位置和颜色的本地数组。一切都通过公共的 InitializeDispose 方法进行设置和清理。

Also needed is a public Draw method that invokes Graphics.DrawMeshInstancedProcedural to draw the entire grid. As the grid doesn't move and is always in view we can suffice with a centered unit cube for its bounds.
还需要一个公共的方法来调用 Draw 来绘制整个网格。由于网格不会移动且始终在视图中,我们可以使用一个居中的单位立方体作为其边界。

using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

using static Unity.Mathematics.math;

public struct GridVisualization
{
	static int
		positionsId = Shader.PropertyToID("_Positions"),
		colorsId = Shader.PropertyToID("_Colors");

	ComputeBuffer positionsBuffer, colorsBuffer;

	NativeArray<float3> positions, colors;

	Grid grid;

	Material material;

	Mesh mesh;

	public void Initialize (Grid grid, Material material, Mesh mesh)
	{
		this.grid = grid;
		this.material = material;
		this.mesh = mesh;

		int instanceCount = grid.CellCount;
		positions = new NativeArray<float3>(instanceCount, Allocator.Persistent);
		colors = new NativeArray<float3>(instanceCount, Allocator.Persistent);

		positionsBuffer = new ComputeBuffer(instanceCount, 3 * 4);
		colorsBuffer = new ComputeBuffer(instanceCount, 3 * 4);
		material.SetBuffer(positionsId, positionsBuffer);
		material.SetBuffer(colorsId, colorsBuffer);
	}

	public void Dispose ()
	{
		positions.Dispose();
		colors.Dispose();
		positionsBuffer.Release();
		colorsBuffer.Release();
	}

	public void Draw () => Graphics.DrawMeshInstancedProcedural(
		mesh, 0, material, new Bounds(Vector3.zero, Vector3.one), positionsBuffer.count
	);
}

Add the grid visualization to Game, along with configuration fields for a material and a mesh. The mesh should be set to the default cube. Initialize and dispose the visualization after the grid and draw it at the end of Update.
将网格可视化添加到 Game 中,并添加材质和网格的配置字段。网格应设置为默认的立方体。在 Update 的末尾初始化和释放可视化,并进行绘制。

	[SerializeField]
	Material material;

	[SerializeField]
	Mesh mesh;GridVisualization visualization;

	void OnEnable ()
	{
		grid.Initialize(rows, columns);
		visualization.Initialize(grid, material, mesh);
	}

	void OnDisable ()
	{
		grid.Dispose();
		visualization.Dispose();
	}

	void Update ()
	{
		…

		visualization.Draw();
	}

Visualization Material 可视化材料

To make instancing work in a shader graph we have to use the the approach introduced in the Basics series. Create an HLSL asset for it with the appropriate code. In this case we only need to set the object-to-world matrix position and include a function to get the instance color.
为了使着色器图中的实例化工作,我们必须使用在基础系列中介绍的方法。为此创建一个包含适当代码的HLSL资源。在这种情况下,我们只需要设置物体到世界矩阵的位置,并包含一个获取实例颜色的函数。

#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
	StructuredBuffer<float3> _Positions, _Colors;
#endif

void ConfigureProcedural () {
	#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
		float3 position = _Positions[unity_InstanceID];

		unity_ObjectToWorld = 0.0;
		unity_ObjectToWorld._m03_m13_m23_m33 = float4(position, 1.0);
		unity_ObjectToWorld._m00_m11_m22 = 1.0;
	#endif
}

void ConfigureProcedural_float (float3 In, out float3 Out) {
	Out = In;
}

void GetBlockColor_float (out float3 Color)
{
	#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
		Color = _Colors[unity_InstanceID];
	#else
		Color = 0;
	#endif
}

Create a shader graph that uses the HLSL file to pass through the vertex position and set the fragment color.
创建一个着色器图形,使用HLSL文件传递顶点位置并设置片段颜色。

Visualization shader graph.
可视化着色器图。

The InjectPragmas custom function node contains the following text.
InjectPragmas 自定义函数节点包含以下文本。

#pragma instancing_options assumeuniformscaling procedural:ConfigureProcedural
#pragma editor_sync_compilation

Out = In;

Create a material with it that has GPU instancing enabled and assign it to the game object.
创建一个启用了GPU实例化的材质,并将其分配给游戏对象。

Initializing the Visualization
初始化可视化

To correctly initialize the grid visualization we have to set the positions and colors of all cells. We'll create an InitializeVisualizationJob job that can do this in parallel. It writes to the positions and colors and needs the rows and columns as input. It doesn't need to access the cell states so we won't pass it the entire grid.
为了正确初始化网格可视化,我们必须设置所有单元格的位置和颜色。我们将创建一个可以并行执行此操作的作业。它会写入位置和颜色,并需要行和列作为输入。它不需要访问单元格状态,因此我们不会将整个网格传递给它。

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

using static Unity.Mathematics.math;

[BurstCompile(FloatPrecision.Standard, FloatMode.Fast)]
struct InitializeVisualizationJob : IJobFor
{
	[WriteOnly]
	public NativeArray<float3> positions, colors;

	public int rows, columns;

	public void Execute (int i) []
}

We set the colors to uniform 0.5. We initially position the cubes in a rectangular grid centered on the origin, assuming that the size of each cell is one unit.
我们将颜色设置为统一的0.5。我们最初将立方体放置在以原点为中心的矩形网格中,假设每个单元格的大小为一个单位。

	public void Execute (int i)
	{
		positions[i] = GetCellPosition(i);
		colors[i] = 0.5f;
	}

	float3 GetCellPosition (int i)
	{
		int r = i / columns;
		int c = i - r * columns;
		return float3(
			c - (columns - 1) * 0.5f,
			0f,
			r - (rows - 1) * 0.5f
		);
	}

To turn this into a hexagonal grid we shift odd columns up in the Z dimension by 0.25 and shift even columns down by the same amount.
将这个转换为六边形网格,我们将奇数列在Z维度上向上移动0.25,将偶数列向下移动相同的距离。

			r - (rows - 1) * 0.5f - (c & 1) * 0.5f + 0.25f

Schedule this job at the end of GridVisualization.Initialize, then immediately complete it and set the position and color buffers. While we could try to combine scheduling of multiple jobs via a dependency chain, this isn't needed for our simple game.
将此作业安排在 GridVisualization.Initialize 的末尾,然后立即完成并设置位置和颜色缓冲区。虽然我们可以尝试通过依赖链来组合多个作业的调度,但对于我们的简单游戏来说,这并不需要。

	public void Initialize (Grid grid, Material material, Mesh mesh)
	{
		…
		
		new InitializeVisualizationJob
		{
			positions = positions,
			colors = colors,
			rows = grid.Rows,
			columns = grid.Columns
		}.ScheduleParallel(grid.CellCount, grid.Columns, default).Complete();
		positionsBuffer.SetData(positions);
		colorsBuffer.SetData(colors);
	}
Hexagonal 8×21 grid; top scene view zoomed in.
八行二十一列的六边形网格;顶部场景视图放大。

Multiple Blocks per Cell
多个块每个单元

One of the quirks of our game is that well visualize cells and their symbols with multiple small blocks. Specifically, we'll make each cell five blocks wide and seven blocks high, so 35 blocks per cell. Add public constants for these to GridVisualization.
我们游戏的一个特点是使用多个小方块来可视化细胞及其符号。具体来说,我们将每个细胞设为五个方块宽,七个方块高,因此每个细胞有35个方块。在 GridVisualization 中添加公共常量。

	public const int
		blockRowsPerCell = 7,
		blockColumnsPerCell = 5,
		blocksPerCell = blockRowsPerCell * blockColumnsPerCell;

We also include a one-unit gap between cells, so each cell effectively takes up 5×8 units. Scale up the cell positions in the job to match this pattern.
我们还在单元格之间留有一个单位的间隔,因此每个单元格实际上占据了5×8个单位。将作业中的单元格位置按照这个模式进行缩放。

		return
			float3(
				c - (columns - 1) * 0.5f,
				0f,
				r - (rows - 1) * 0.5f - (c & 1) * 0.5f + 0.25f
			) * float3(
				GridVisualization.columnsPerCell + 1,
				0f,
				GridVisualization.rowsPerCell + 1
			);
Grid scaled up. 网格扩大。

To show the entire cells we have to increase the instance count to match in Initialize.
为了显示所有的单元格,我们必须增加实例计数以匹配 Initialize 中的数量。

		int instanceCount = grid.CellCount * blocksPerCell;

Then adjust the job so it sets all blocks per cell, each forming its own rectangular grid.
然后调整工作,使每个单元格设置所有的块,每个块形成自己的矩形网格。

	[WriteOnly, NativeDisableParallelForRestriction]
	public NativeArray<float3> positions, colors;

	public int rows, columns;

	public void Execute (int i)
	{
		float3 cellPosition = GetCellPosition(i);
		int blockOffset = i * GridVisualization.blocksPerCell;

		for (int bi = 0; bi < GridVisualization.blocksPerCell; bi++)
		{
			positions[blockOffset + bi] = cellPosition + GetBlockPosition(bi);
			colors[blockOffset + bi] = 0.5f;
		}
	}

	float3 GetBlockPosition (int i)
	{
		int r = i / GridVisualization.columnsPerCell;
		int c = i - r * GridVisualization.columnsPerCell;
		return float3(c, 0f, r);
	}

To keep the cells centered we have to offset them by half their size.
为了保持细胞居中,我们必须将它们偏移半个尺寸。

		return
			float3(
				c - (columns - 1) * 0.5f,
				0f,
				r - (rows - 1) * 0.5f - (c & 1) * 0.5f + 0.25f
			) * float3(
				GridVisualization.columnsPerCell + 1,
				0f,
				GridVisualization.rowsPerCell + 1
			) - float3(
				GridVisualization.columnsPerCell / 2,
				0f,
				GridVisualization.rowsPerCell / 2
			);
Full grid. 完整的网格。

Updating the Grid 更新网格

Whenever the state of the grid changes we have to update its visualization. We'll always keep the XZ positions the same, but will change the Y positions and colors. Create a new job named UpdateVisualizationJob that updates these per cell in parallel, initially setting both Y and color to 0–1 based on the block index per cell.
每当网格的状态发生变化时,我们必须更新其可视化。我们将始终保持XZ位置不变,但会更改Y位置和颜色。创建一个名为 UpdateVisualizationJob 的新作业,以并行方式更新每个单元格的Y和颜色,最初根据每个单元格的块索引将其设置为0-1。

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

using static Unity.Mathematics.math;

[BurstCompile(FloatPrecision.Standard, FloatMode.Fast)]
struct UpdateVisualizationJob : IJobFor
{
	[NativeDisableParallelForRestriction]
	public NativeArray<float3> positions, colors;

	[ReadOnly]
	public Grid grid;

	public void Execute (int i)
	{
		int blockOffset = i * GridVisualization.blocksPerCell;
		
		for (int bi = 0; bi < GridVisualization.blocksPerCell; bi++)
		{
			float3 position = positions[blockOffset + bi];
			position.y = bi / (float)GridVisualization.blocksPerCell;
			positions[blockOffset + bi] = position;
			colors[blockOffset + bi] = position.y;
		}
	}
}

Schedule and execute this job in a new public GridVisualization.Update method and then update the position and color buffers.
在一个新的公共 GridVisualization.Update 方法中安排并执行此作业,然后更新位置和颜色缓冲区。

	public void Update ()
	{
		new UpdateVisualizationJob
		{
			positions = positions,
			colors = colors,
			grid = grid
		}.ScheduleParallel(grid.CellCount, grid.Columns, default).Complete();
		positionsBuffer.SetData(positions);
		colorsBuffer.SetData(colors);
	}

Invoke it in Game.Update before drawing.
在绘制之前调用它。

	void Update ()
	{
		…

		visualization.Update();
		visualization.Draw();
	}
Updated grid. 更新的网格。

Drawing Symbols 绘图符号

We can make cells display symbols by changing some of their blocks, treating each as a 5×7 bitmap. We'll use two states per block, either the default or an altered state. We can represent these binary bitmaps with ulong values. We can access individual bits of these bitmaps via shifting 1 left by the block index and using that as a mask. For example, the value 0b00000_01110_01000_01110_01000_01110_00000 displays the symbol for 3. Add a static readonly array to the job containing bitmaps for 0–7.
我们可以通过改变一些方块来使细胞显示符号,将每个方块视为一个5×7的位图。我们将使用每个方块两种状态,即默认状态或改变状态。我们可以用 ulong 值表示这些二进制位图。我们可以通过将1左移方块索引并将其用作掩码来访问这些位图的单个位。例如,值 0b00000_01110_01000_01110_01000_01110_00000 显示数字3的符号。在作业中添加一个静态只读数组,其中包含0-7的位图。

	readonly static ulong[] bitmaps =
	{
		0b00000_01110_01010_01010_01010_01110_00000, // 0
		0b00000_00100_00110_00100_00100_01110_00000, // 1
		0b00000_01110_01000_01110_00010_01110_00000, // 2
		0b00000_01110_01000_01110_01000_01110_00000, // 3
		0b00000_01010_01010_01110_01000_01000_00000, // 4
		0b00000_01110_00010_01110_01000_01110_00000, // 5
		0b00000_01110_00010_01110_01010_01110_00000  // 6
	};

We'll initially loop through all symbols based on the cell index. If a block is altered set its Y position to 0.5 and its color to 1.
我们将首先根据单元索引循环遍历所有符号。如果一个块被改变,将其Y位置设置为0.5,颜色设置为1。

		int blockOffset = i * GridVisualization.blocksPerCell;
		ulong bitmap = bitmaps[i % bitmaps.Length];

		for (int bi = 0; bi < GridVisualization.blocksPerCell; bi++)
		{
			bool altered = (bitmap & ((ulong)1 << bi)) != 0;

			float3 position = positions[blockOffset + bi];
			position.y = altered ? 0.5f : 0f;
			positions[blockOffset + bi] = position;
			colors[blockOffset + bi] = altered ? 1f : 0.5f;
		}
Digit symbols. 数字符号。

Let's also add symbols for a revealed mine, a mark is that sure and a mistaken variant, a mark that is unsure, and the default hidden state.
让我们还添加一个揭示的地雷的符号,一个确定的标记和一个错误的变体,一个不确定的标记,以及默认的隐藏状态。

	readonly static ulong[] bitmaps =
	{
		…
		0b00000_01110_00010_01110_01010_01110_00000, // 6

		0b00000_10001_01010_00100_01010_10001_00000, // mine
		0b00000_00000_00100_01110_00100_00000_00000, // marked sure
		0b11111_11111_11011_10001_11011_11111_11111, // marked mistaken
		0b00000_01110_01010_01000_00100_00000_00100, // marked unsure
		0b00000_00000_00000_00000_00000_00000_00000  // hidden
	};
All symbols. 所有符号。

Let's also give each symbol its own color. Add another readonly static array for this, using float3 values.
让我们为每个符号分配不同的颜色。为此添加另一个只读静态数组,使用 float3 值。

	static readonly float3[] colorations =
	{
		1.00f * float3(1f, 1f, 1f), // 0
		1.00f * float3(0f, 0f, 1f), // 1
		2.00f * float3(0f, 1f, 1f), // 2
		5.00f * float3(0f, 1f, 0f), // 3
		10.0f * float3(1f, 1f, 0f), // 4
		20.0f * float3(1f, 0f, 0f), // 5
		20.0f * float3(1f, 0f, 1f), // 6

		30.0f * float3(1f, 0f, 1f), // mine
		1.00f * float3(1f, 0f, 0f), // marked sure
		50.0f * float3(1f, 0f, 1f), // marked mistaken
		0.25f * float3(1f, 1f, 1f), // marked unsure
		0.00f * float3(0f, 0f, 0f)  // hidden
	};

The colors are the same for all enable blocks of the cell.
所有单元格的启用块的颜色都是相同的。

		ulong bitmap = bitmaps[i % bitmaps.Length];
		float3 coloration = colorations[i % colorations.Length];

		for (int bi = 0; bi < GridVisualization.blocksPerCell; bi++)
		{
			…
			colors[blockOffset + bi] = altered ? coloration : 0.5f;
		}
Colored symbols. 彩色符号。

Once we are satisfied with the symbols we have to switch to showing the appropriate one for each cell. Introduce a static GetSymbolIndex method for this that returns the correct symbol index for a give cell state.
一旦我们对符号感到满意,我们就需要切换到显示每个单元格的适当符号。为此引入一个静态的 GetSymbolIndex 方法,根据给定的单元格状态返回正确的符号索引。

We first consider all revealed cells. If it is a mine then we show it. If not but it is marked then that is a mistake. Otherwise it is a cell without a mine and we show its digit. If the cell is not revealed then we show either the appropriate mark or that it is hidden.
我们首先考虑所有已揭示的方格。如果是地雷,我们显示它。如果不是地雷但被标记了,那是一个错误。否则,它是一个没有地雷的方格,我们显示它的数字。如果方格没有被揭示,我们显示适当的标记或者显示它是隐藏的。

	enum Symbol { Mine = 7, MarkedSure, MarkedMistaken, MarkedUnsure, Hidden }

	static int GetSymbolIndex (CellState state) =>
		state.Is(CellState.Revealed) ?
			state.Is(CellState.Mine) ? (int)Symbol.Mine :
			state.Is(CellState.MarkedSure) ? (int)Symbol.MarkedMistaken :
			(int)state.Without(CellState.Revealed) :
		state.Is(CellState.MarkedSure) ? (int)Symbol.MarkedSure :
		state.Is(CellState.MarkedUnsure) ? (int)Symbol.MarkedUnsure :
		(int)Symbol.Hidden;

Use this method to select the correct symbol in Execute.
使用此方法在 Execute 中选择正确的符号。

		int symbolIndex = GetSymbolIndex(grid[i]);
		ulong bitmap = bitmaps[symbolIndex];
		float3 coloration = colorations[symbolIndex];

At this point the entire grid will again be hidden, matching the initial state.
此时整个网格将再次隐藏,与初始状态相匹配。

Gameplay 游戏玩法

Now that we have a functioning grid and visualization it is time to implement gameplay.
现在我们有了一个运行良好的网格和可视化,是时候实施游戏玩法了。

Touching Cells 触摸细胞

To play the game the player has to touch cells. As this is done based on the visualization we have to ask GridVisualization whether a cell was hit, and if so what its index is. We do this by giving it a TryGetHitCellIndex method with a Ray parameter.
玩游戏时,玩家必须触摸单元格。由于这是基于可视化进行的,我们必须询问 GridVisualization 是否击中了一个单元格,如果是的话,它的索引是什么。我们通过给它一个带有 TryGetHitCellIndex 参数的 Ray 方法来实现这一点。

The method finds the point where the ray hits the XZ plane and then converts that to a row and column index, performing the inverse calculations of InitializeVisualizationJob.GetCellPosition. Then it tries to get the cell index.
该方法找到光线与XZ平面相交的点,然后将其转换为行和列索引,执行 InitializeVisualizationJob.GetCellPosition 的逆运算。然后尝试获取单元格索引。

	public bool TryGetHitCellIndex (Ray ray, out int cellIndex)
	{
		Vector3 p = ray.origin - ray.direction * (ray.origin.y / ray.direction.y);

		float x = p.x + columnsPerCell / 2;
		x /= columnsPerCell + 1;
		x += (grid.Columns - 1) * 0.5f;
		int c = Mathf.FloorToInt(x);

		float z = p.z + rowsPerCell / 2f;
		z /= rowsPerCell + 1;
		z += (grid.Rows - 1) * 0.5f + (c & 1) * 0.5f - 0.25f;
		int r = Mathf.FloorToInt(z);

		return grid.TryGetCellIndex(r, c, out cellIndex);
	}

To avoid the gaps between cells we add an initial extra offset of 1.5, which makes the row and column indices of a cell start directly at the end of the previous cell's visualization, so including the entire gap. Then also check whether the fractional index coordinates lie beyond that gap.
为了避免单元格之间的间隙,我们添加了一个初始的额外偏移量为1.5,这使得单元格的行和列索引直接从前一个单元格的可视化结束位置开始,包括整个间隙。然后还要检查分数索引坐标是否超出了该间隙。

		float x = p.x + columnsPerCell / 2 + 1.5f;
		…

		float z = p.z + rowsPerCell / 2f + 1.5f;
		…

		return grid.TryGetCellIndex(r, c, out cellIndex) &&
			x - c > 1f / (columnsPerCell + 1) &&
			z - r > 1f / (rowsPerCell + 1);

Next, add a PerformAction method to Game that performs a mark action if the secondary mouse button is pressed. Revealing a cell is done by invoking TryGetHitCellIndex with Camera.main.ScreenPointToRay(Input.mousePosition) for its ray argument. If that gives us a cell index set its state to marked. Have the method return whether an action was successfully performed. Then invoke it in Update and only update the visualization if needed.
接下来,在 Game 中添加一个 PerformAction 方法,如果按下了鼠标的次要按钮,则执行标记操作。通过使用 Camera.main.ScreenPointToRay(Input.mousePosition) 作为其射线参数来调用 TryGetHitCellIndex 来显示一个单元格。如果这给我们一个单元格索引,则将其状态设置为已标记。让该方法返回是否成功执行了一个操作。然后在 Update 中调用它,只有在需要更新可视化时才进行更新。

	void Update ()
	{
		…

		if (PerformAction())
		{
			visualization.Update();
		}
		visualization.Draw();
	}

	bool PerformAction ()
	{
		bool markAction = Input.GetMouseButtonDown(1);
		if (
			markAction &&
			visualization.TryGetHitCellIndex(
				Camera.main.ScreenPointToRay(Input.mousePosition), out int cellIndex
			)
		)
		{
			grid[cellIndex] = CellState.MarkedSure;
			return true;
		}

		return false;
	}
Touching arbitrary cells.
触摸任意单元格。

Marking Cells 标记细胞

Marking a cell does two things: it set the cell to marked and it also decreases the amount of mines that we assume are still unknown. So we need to know how many mines there are, for which we add a configuration field set to 30 by default. We also need to keep track of how many sure marks there currently are. Initialize the mines text to the full amount in OnEnable and set the mark count to zero. Also make sure that the amount of mines doesn't exceed the cell count.
标记一个单元格有两个作用:将单元格设置为已标记,并减少我们认为仍未知的地雷数量。因此,我们需要知道有多少地雷,为此我们添加一个默认设置为30的配置字段。我们还需要跟踪当前有多少确定的标记。将地雷文本初始化为 OnEnable 中的全部数量,并将标记计数设置为零。还要确保地雷数量不超过单元格数量。

	[SerializeField, Min(1)]
	int rows = 8, columns = 21, mines = 30;
	
	…
			
	int markedSureCount;
	
	void OnEnable ()
	{
		grid.Initialize(rows, columns);
		visualization.Initialize(grid, material, mesh);
		mines = Mathf.Min(mines, grid.CellCount);
		minesText.SetText("{0}", mines);
		markedSureCount = 0;
	}

Then create a DoMarkAction method with a cell index parameter that takes care of the mark action and returns whether it changed something. If the cell is already revealed then marking it makes no sense so we do nothing. Otherwise, if the cell doesn't have any mark we mark it as sure and increase the count. If it already has a sure mark we instead switch to an unsure mark and decrease the count. Otherwise it already has an unsure mark and we clear it. Finally, update the mines text.
然后创建一个带有单元格索引参数的 DoMarkAction 方法,负责标记操作并返回是否有变化。如果单元格已经被揭示,则标记它没有意义,所以我们什么也不做。否则,如果单元格没有任何标记,我们将其标记为确定,并增加计数。如果它已经被确定标记,我们改为标记为不确定,并减少计数。否则,它已经被不确定标记,我们清除它。最后,更新地雷文本。

	bool DoMarkAction (int cellIndex)
	{
		CellState state = grid[cellIndex];
		if (state.Is(CellState.Revealed))
		{
			return false;
		}
		
		if (state.IsNot(CellState.Marked))
		{
			grid[cellIndex] = state.With(CellState.MarkedSure);
			markedSureCount += 1;
		}
		else if (state.Is(CellState.MarkedSure))
		{
			grid[cellIndex] =
				state.Without(CellState.MarkedSure).With(CellState.MarkedUnsure);
			markedSureCount -= 1;
		}
		else
		{
			grid[cellIndex] = state.Without(CellState.MarkedUnsure);
		}

		minesText.SetText("{0}", mines - markedSureCount);
		return true;
	}

Invoke this method in PerformAction so we can cycle through mark types.
PerformAction 中调用此方法,以便我们可以循环遍历标记类型。

			//grid[cellIndex] = CellState.MarkedSure;
			return DoMarkAction(cellIndex);

Revealing Cells 揭示细胞

We also create a DoRevealAction method that works the same way, except that it doesn't do anything if the cell has a mark and otherwise reveals it.
我们还创建了一个相同的方法,只是如果单元格已经标记了,它不会执行任何操作,否则会将其显示出来。

	bool DoRevealAction (int cellIndex)
	{
		CellState state = grid[cellIndex];
		if (state.Is(CellState.MarkedOrRevealed))
		{
			return false;
		}

		grid[cellIndex] = state.With(CellState.Revealed);
		return true;
	}

Have PerformAction invoke DoRevealAction instead of DoMarkAction in case of a reveal action, triggered by the primary mouse button.
在鼠标主按钮触发的揭示动作中,使用 DoRevealAction 而不是 DoMarkAction

		bool revealAction = Input.GetMouseButtonDown(0);
		bool markAction = Input.GetMouseButtonDown(1);
		if (
			(revealAction || markAction) &&
			visualization.TryGetHitCellIndex(
				Camera.main.ScreenPointToRay(Input.mousePosition), out int cellIndex
			)
		)
		{
			return revealAction ? DoRevealAction(cellIndex) : DoMarkAction(cellIndex);
		}
Marking and revealing cells.
标记和显示细胞。

Placing Mines 放置地雷

With player input handled we move on to placing mines. Create a PlaceMinesJob job for this that sets all cells to zero and then randomly changes some of them to mines. It needs the grid, the amount of mines, and a seed value to do this. This isn't a parallel job, so it extends IJob and implements an Execute method without parameters.
处理完玩家输入后,我们开始放置地雷。为此创建一个任务,将所有单元格设置为零,然后随机将其中一些单元格更改为地雷。它需要网格、地雷数量和一个种子值来完成。这不是一个并行任务,所以它扩展了 IJob 并实现了一个没有参数的 Execute 方法。

The random placement is done by creating a Unity.Mathematics.Random struct value with the given seed, then invoking NextInt on it with the cell count to get a cell index.
随机放置是通过使用给定的种子创建一个 Unity.Mathematics.Random 结构值,然后在其上调用 NextInt 以获取单元格索引来完成的。

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile(FloatPrecision.Standard, FloatMode.Fast)]
struct PlaceMinesJob : IJob
{
	public Grid grid;

	public int mines, seed;

	public void Execute ()
	{
		for (int i = 0; i < grid.CellCount; i++)
		{
			grid[i] = CellState.Zero;
		}

		Random random = new Random((uint)seed);
		for (int m = 0; m < mines; m++)
		{
			grid[random.NextInt(grid.CellCount)] = CellState.Mine;
		}
	}
}

Execute this job in a new public Grid.PlaceMines method, with the amount of mines as a parameter. The seed must not be zero, se we'll use Random.Range(1, int.MaxValue) for it.
在一个新的公共 Grid.PlaceMines 方法中执行此作业,将地雷数量作为参数。种子不能为零,所以我们将使用 Random.Range(1, int.MaxValue)

	public void PlaceMines (int mines) => new PlaceMinesJob
	{
		grid = this,
		mines = mines,
		seed = Random.Range(1, int.MaxValue)
	}.Schedule().Complete();

Invoke it at the end of Game.Update and let's also immediately update the visualization for now.
Game.Update 的末尾调用它,并立即更新可视化。

	void OnEnable ()
	{
		…
		grid.PlaceMines(mines);
		visualization.Update();
	}

Let's also forcefully reveal all cells in UpdateVisualizationJob, for debugging.
让我们也强制显示所有的单元格 UpdateVisualizationJob ,用于调试。

		int symbolIndex = GetSymbolIndex(grid[i].With(CellState.Revealed));
Mines placed, but less than 30.
放置了地雷,但不超过30个。

This shows us that mines get placed, but we might end up with too few because sometimes the same cell index is chosen more than once. We can fix this by allocating a temporary array for candidate indices in PlaceMinesJob.Execute and selecting random indices from there, then eliminating those indices once chosen.
这表明地雷被放置了,但有时会选择相同的单元索引,导致地雷数量过少。我们可以通过为候选索引分配临时数组,并从中随机选择索引,然后一旦选择了这些索引就将其排除掉来解决这个问题。

		int candidateCount = grid.CellCount;
		var candidates = new NativeArray<int>(
			candidateCount, Allocator.Temp, NativeArrayOptions.UninitializedMemory
		);

		for (int i = 0; i < grid.CellCount; i++)
		{
			grid[i] = CellState.Zero;
			candidates[i] = i;
		}
		
		Random random = new Random((uint)seed);
		for (int m = 0; m < mines; m++)
		{
			int candidateIndex = random.NextInt(candidateCount--);
			grid[candidates[candidateIndex]] = CellState.Mine;
			candidates[candidateIndex] = candidates[candidateCount];
		}

Counting Adjacent Mines 计算相邻的地雷

Besides placing mines we also have to track how many mines are adjacent to each cell. We can do this by incrementing the state of each neighbor of the cell that got a mine. Introduce a SetMine method that does this for a given cell index. Initially it only sets the mine.
除了放置地雷之外,我们还必须追踪每个单元格相邻的地雷数量。我们可以通过增加每个获得地雷的单元格的邻居的状态来实现这一点。引入一个 SetMine 方法,为给定的单元格索引执行此操作。最初,它只设置地雷。

	public void Execute ()
	{
		…
		for (int m = 0; m < mines; m++)
		{
			int candidateIndex = random.NextInt(candidateCount--);
			SetMine(candidates[candidateIndex]);
			candidates[candidateIndex] = candidates[candidateCount];
		}
	}
	
	void SetMine (int mineIndex)
	{
		grid[i] = grid[i].With(CellState.Mine);
	}

Then it gets the cell's row and column index and increments its four direct row and column neighbors, by invoking an Increment method that only does this if the neighbor exists.
然后它获取单元格的行和列索引,并通过调用一个方法来增加其四个直接的行和列邻居,只有在邻居存在时才执行此操作。

	void SetMine (int mineIndex)
	{
		grid[i] = grid[i].With(CellState.Mine);
		grid.GetRowColumn(i, out int r, out int c);
		Increment(r - 1, c);
		Increment(r + 1, c);
		Increment(r, c - 1);
		Increment(r, c + 1);
	}

	void Increment (int r, int c)
	{
		if (grid.TryGetCellIndex(r, c, out int i))
		{
			grid[i] += 1;
		}
	}

The last two column neighbors are found by either incrementing or decrementing the row index, depending on whether the column index is even or odd.
最后两列的相邻列是通过增加或减少行索引来找到的,具体取决于列索引是偶数还是奇数。

		Increment(r, c + 1);

		int rowOffset = (c & 1) == 0 ? 1 : -1;
		Increment(r + rowOffset, c - 1);
		Increment(r + rowOffset, c + 1);
Adjacent mines counted. 相邻的矿井被计算。

Once we've verified that everything works correctly we can remove the forced reveal from UpdateVisualizationJob.
一旦我们确认一切正常运作,我们可以从 UpdateVisualizationJob 中移除强制显示。

		int symbolIndex = GetSymbolIndex(grid[i]);

And also no longer update the visualization in Game.OnEnable.
并且不再更新 Game.OnEnable 中的可视化。

	void OnEnable ()
	{
		…
		//visualization.Update();
	}

Revealing Empty Regions 揭示空白地区

A good feature of a mine-sweeping game is automatically revealing entire empty regions, avoiding the tedious work of touching every cell with zero adjacent mines. This is done by performing a flood fill that reveals all connected zero cells and the nonzero border cells around it.
扫雷游戏的一个好特点是自动显示整个空白区域,避免了触摸每个周围没有地雷的单元格的繁琐工作。这是通过执行泛洪填充来实现的,它会显示所有相连的零单元格以及周围的非零边界单元格。

Create a new non-parallel RevealRegionJob job to reveal a region, using a temporary int2 array as a stack to store the row and column indices of cells whose neighbors need to be checked. We'll need to store at most the entire grid and have to keep track of the current stack size, for which we'll use a field. The job needs the grid along with the start row and column indices to do its work.
创建一个新的非并行的作业,使用一个临时数组作为堆栈来存储需要检查邻居的单元格的行和列索引,以揭示一个区域。我们需要存储最多整个网格,并且需要跟踪当前堆栈的大小,我们将使用一个字段来实现。该作业需要网格以及开始行和列索引来完成其工作。

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

using static Unity.Mathematics.math;

[BurstCompile(FloatPrecision.Standard, FloatMode.Fast)]
struct RevealRegionJob : IJob
{
	public Grid grid;

	public int2 startRowColumn;

	int stackSize;

	public void Execute ()
	{
		var stack = new NativeArray<int2>(grid.CellCount, Allocator.Temp);
		stackSize = 0;
	}
}

The job starts by pushing the start cell indices onto the stack, but should do this only if needed. First, only valid indices should be added. Second, marked or already revealed cells should be skipped. If the cell is accepted then it should be revealed. And finally only if it is a zero cell should its row and column indices be pushed onto the stack. Create a separate method for this.
工作开始时,将起始单元格的索引推入堆栈,但只有在需要时才这样做。首先,只添加有效的索引。其次,跳过已标记或已揭示的单元格。如果接受该单元格,则应将其揭示。最后,只有当它是零单元格时,才应将其行和列索引推入堆栈。为此创建一个单独的方法。

	public void Execute ()
	{
		var stack = new NativeArray<int2>(grid.CellCount, Allocator.Temp);
		stackSize = 0;
		PushIfNeeded(stack, startRowColumn);
	}
	
	void PushIfNeeded (NativeArray<int2> stack, int2 rc)
	{
		if (grid.TryGetCellIndex(rc.x, rc.y, out int i))
		{
			CellState state = grid[i];
			if (state.IsNot(CellState.MarkedOrRevealed))
			{
				if (state == CellState.Zero)
				{
					stack[stackSize++] = rc;
				}
				grid[i] = state.With(CellState.Revealed);
			}
		}
	}

After the first cell is pushed, as long as the stack isn't empty, pop the top index pair from the stack and push all its neighbors if needed.
第一个单元格被推动后,只要堆栈不为空,就从堆栈中弹出顶部索引对,并在需要时推入其所有邻居。

		PushIfNeeded(stack, startRowColumn);
		while (stackSize > 0)
		{
			int2 rc = stack[--stackSize];
			PushIfNeeded(stack, rc - int2(1, 0));
			PushIfNeeded(stack, rc + int2(1, 0));
			PushIfNeeded(stack, rc - int2(0, 1));
			PushIfNeeded(stack, rc + int2(0, 1));
			
			rc.x += (rc.y & 1) == 0 ? 1 : -1;
			PushIfNeeded(stack, rc - int2(0, 1));
			PushIfNeeded(stack, rc + int2(0, 1));
		}

Add a public Reveal method to Grid that executes the job for a given cell index.
Reveal 添加一个公共方法,该方法执行给定单元格索引的作业。

	public void Reveal (int index)
	{
		var job = new RevealRegionJob
		{
			grid = this
		};
		GetRowColumn(index, out job.startRowColumn.x, out job.startRowColumn.y);
		job.Schedule().Complete();
	}

And invoke it at the end of Game.RevealAction instead of directly adjusting the cell state.
而是在 Game.RevealAction 的末尾调用它,而不是直接调整细胞状态。

	bool DoRevealAction (int cellIndex)
	{
		…

		//grid[cellIndex] = state.With(CellState.Revealed);
		grid.Reveal(cellIndex);
		return true;
	}

Now whenever the player touches a hidden zero cell the entire zero region will be revealed at once, including its nonzero border, while being stopped by marks.
现在,每当玩家触摸到一个隐藏的零格子时,整个零区域将立即被揭示出来,包括非零边界,同时被标记所阻止。

Region revealed with a single touch.
区域一触即显。

Failure and Success 失败与成功

All that it left at this point is to detect failure and success and start new games. Begin by moving the code that sets the mines from OnEnable to a new StartNewGame method, which OnEnable invokes. Also introduce a field that indicates the game over state, which is disabled when a new game is started.
此时所剩的就是检测失败和成功,并开始新的游戏。首先将设置地雷的代码从 OnEnable 移动到一个新的 StartNewGame 方法中,并调用 OnEnable 。同时引入一个字段来表示游戏结束的状态,在开始新游戏时将其禁用。

	bool isGameOver;

	void OnEnable ()
	{
		grid.Initialize(rows, columns);
		visualization.Initialize(grid, material, mesh);
		StartNewGame();
	}

	void StartNewGame ()
	{
		isGameOver = false;
		mines = Mathf.Min(mines, grid.CellCount);
		minesText.SetText("{0}", mines);
		markedSureCount = 0;
		grid.PlaceMines(mines);
	}

Let's first consider failure. It happens when a mine is revealed. Check this at the end of DoRevealAction and if so enable the game over state and set the mines text to FAILURE.
让我们首先考虑失败。当地雷被揭示时,失败就会发生。在 DoRevealAction 的末尾检查这一点,如果是这样的话,就启用游戏结束状态,并将地雷文本设置为 FAILURE

	bool DoRevealAction (int cellIndex)
	{
		…

		grid.Reveal(cellIndex);

		if (state.Is(CellState.Mine))
		{
			isGameOver = true;
			minesText.SetText("FAILURE");
		}
		return true;
	}

To make it possible for the player to evaluate their mistake let's also reveal all mines and incorrect marks. We can do that with a simple parallel job that reveals all cells that are either marked for sure or a mine.
为了让玩家能够评估他们的错误,让我们也揭示所有的地雷和错误标记。我们可以通过一个简单的并行任务来实现,揭示所有被确定标记或是地雷的方格。

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;

[BurstCompile(FloatPrecision.Standard, FloatMode.Fast)]
struct RevealMinesAndMistakesJob : IJobFor
{
	public Grid grid;

	public void Execute (int i) => grid[i] = grid[i].With(
		grid[i].Is(CellState.MarkedSureOrMine) ? CellState.Revealed : CellState.Zero
	);
}

Add a public method to run it to Grid.
添加一个公共方法来运行它到 Grid

	public void RevealMinesAndMistakes () => new RevealMinesAndMistakesJob
	{
		grid = this
	}.ScheduleParallel(CellCount, Columns, default).Complete();

Then invoke it in Game.DoRevealAction on failure.
在失败时调用它。

		if (state.Is(CellState.Mine))
		{
			isGameOver = true;
			minesText.SetText("FAILURE");
			grid.RevealMinesAndMistakes();
		}
Failure showing mines and mistaken marks.
失败显示矿井和错误标记。

We start a new game in PerformAction if we're in the game over state and the player performs any action, immediately applying that action to the new game.
如果我们处于游戏结束状态并且玩家执行任何操作,我们会在 PerformAction 中开始一场新游戏,立即将该操作应用于新游戏。

		if (
			(revealAction || markAction) &&
			visualization.TryGetHitCellIndex(
				Camera.main.ScreenPointToRay(Input.mousePosition), out int cellIndex
			)
		)
		{
			if (isGameOver)
			{
				StartNewGame();
			}
			return revealAction ? DoRevealAction(cellIndex) : DoMarkAction(cellIndex);
		}

To detect success we have to know how many cells are revealed and how many are still hidden. Add public properties for these values to Grid. The hidden cell count is equal to the total cell count minus the revealed cell count. The revealed cell count is something we have to track, which must be done in RevealRegionJob. To make that possible we have to use a native array with a single value to store the revealed cell count. The revealed cell count property is then a getter and setter that forwards to the single element of this native array.
要检测成功,我们必须知道有多少个单元格被揭示,有多少个仍然隐藏。为这些值添加公共属性。隐藏单元格计数等于总单元格计数减去揭示单元格计数。揭示单元格计数是我们必须跟踪的内容,这必须在 RevealRegionJob 中完成。为了实现这一点,我们必须使用一个具有单个值的本地数组来存储揭示单元格计数。揭示单元格计数属性然后是一个getter和setter,将其转发到这个本地数组的单个元素。

	public int HiddenCellCount => CellCount - RevealedCellCount;
	
	public int RevealedCellCount
	{
		get => revealedCellCount[0];
		set => revealedCellCount[0] = value;
	}

	NativeArray<int> revealedCellCount;

	NativeArray<CellState> states;

	public void Initialize (int rows, int columns)
	{
		Rows = rows;
		Columns = columns;
		revealedCellCount = new NativeArray<int>(1, Allocator.Persistent);
		states = new NativeArray<CellState>(Rows * Columns, Allocator.Persistent);
	}

	public void Dispose ()
	{
		revealedCellCount.Dispose();
		states.Dispose();
	}

In RevealRegionJob we have to increment the revealed cell count whenever a cell is revealed.
RevealRegionJob 中,每当揭示一个单元格时,我们必须增加揭示的单元格计数。

	void PushIfNeeded (NativeArray<int2> stack, int2 rc)
	{
		if (grid.TryGetCellIndex(rc.x, rc.y, out int i))
		{
			CellState state = grid[i];
			if (state.IsNot(CellState.MarkedOrRevealed))
			{
				if (state == CellState.Zero)
				{
					stack[stackSize++] = rc;
				}
				grid.RevealedCellCount += 1;
				grid[i] = state.With(CellState.Revealed);
			}
		}
	}

And in PlaceMinesJob we have to set it to zero.
而在 PlaceMinesJob 中,我们必须将其设置为零。

	public void Execute ()
	{
		grid.RevealedCellCount = 0;
		…
	}

Now Grid.DoRevealAction can check whether the game is a success. This is the case if it isn't a failure and the hidden cell count is equal to the amount of mines.
现在可以检查游戏是否成功。如果游戏不是失败,并且隐藏的方块数等于地雷数量,则游戏成功。

		if (state.Is(CellState.Mine))
		{
			grid.RevealMinesAndMistakes();
			markedSureCount = gameOverState;
			minesText.SetText("FAILURE");
		}
		else if (grid.HiddenCellCount == mines)
		{
			isGameOver = true;
			minesText.SetText("SUCCESS");
		}
Successfully revealed all non-mine cells.
成功揭示了所有非地雷的方块。

Ripples 涟漪

At this point our game is fully functional, but let's add an extra visual effect to it: each time the player touches a cell it causes the grid to ripple a little.
此时,我们的游戏已经完全可用,但让我们为其添加一个额外的视觉效果:每当玩家触摸一个单元格时,它会使网格产生轻微的波纹效果。

Ripple Data 涟漪数据

As the player could rapidly touch the grid we should support multiple ripples at the same time, let's say up to ten. Add a ripple count and a float3 native array to GridVisualization to keep track of these ripples.
由于玩家可以快速触摸网格,我们应该同时支持多个涟漪,比如说最多十个。在 GridVisualization 中添加一个涟漪计数和一个本地数组来跟踪这些涟漪。

	NativeArray<float3> positions, colors, ripples;
	
	int rippleCount;public void Initialize (Grid grid, Material material, Mesh mesh)
	{
		…
		colors = new NativeArray<float3>(instanceCount, Allocator.Persistent);
		ripples = new NativeArray<float3>(10, Allocator.Persistent);
		rippleCount = 0;

		…
	}

	public void Dispose ()
	{
		positions.Dispose();
		colors.Dispose();
		ripples.Dispose();
		…
	}

Whenever a cell hit is confirmed in TryGetHitCellIndex add a ripple if we aren't at the maximum yet. We use the XZ coordinates of the ray projected onto the XZ plane as the ripple origin, storing them in the first two components of the ripple data. The third component represent the age of the ripple, which starts at zero.
每当在 TryGetHitCellIndex 中确认了一个单元格的命中,如果我们还没有达到最大值,就添加一个涟漪。我们使用射线在XZ平面上投影的XZ坐标作为涟漪的起点,并将它们存储在涟漪数据的前两个分量中。第三个分量表示涟漪的年龄,从零开始。

	public bool TryGetHitCellIndex (Ray ray, out int cellIndex)
	{
		…

		bool valid = grid.TryGetCellIndex(r, c, out cellIndex) &&
			x - c > 1f / (columnsPerCell + 1) &&
			z - r > 1f / (rowsPerCell + 1);

		if (valid && rippleCount < ripples.Length)
		{
			ripples[rippleCount++] = float3(p.x, p.z, 0f);
		}
		return valid;
	}

Showing the Ripples 展示涟漪

Add public fields for the ripples and their count to UpdateVisualizationJob.
UpdateVisualizationJob 添加公共字段以存储涟漪及其数量。

	[NativeDisableParallelForRestriction]
	public NativeArray<float3> positions, colors, ripples;

	public int rippleCount;

We update the ripples themselves in GridVisualization.Update directly before running the job. We won't do this inside a job because most of the time there will be either zero or a single ripple to update. Loop through the ripples, increasing their age to a maximum of one second if it's less than that. Otherwise remove the ripple. Then pass them and their count to the job.
我们在运行作业之前直接更新涟漪本身。我们不会在作业内部进行此操作,因为大多数情况下,要么没有涟漪要更新,要么只有一个涟漪要更新。循环遍历涟漪,如果其年龄小于一秒,则将其增加到最大值。否则,移除涟漪。然后将它们及其数量传递给作业。

	public void Update ()
	{
		float dt = Time.deltaTime;
		for (int i = 0; i < rippleCount; i++)
		{
			float3 ripple = ripples[i];
			if (ripple.z < 1f)
			{
				ripple.z = Mathf.Min(ripple.z + dt, 1f);
				ripples[i] = ripple;
			}
			else
			{
				ripples[i] = ripples[--rippleCount];
				i -= 1;
			}
		}

		new UpdateVisualizationJob
		{
			positions = positions,
			colors = colors,
			ripples = ripples,
			rippleCount = rippleCount,
			grid = grid
		}.ScheduleParallel(grid.CellCount, grid.Columns, default).Complete();
		positionsBuffer.SetData(positions);
		colorsBuffer.SetData(colors);
	}

Next, add a method to accumulate the ripples for a given position to UpdateVisualizationJob. We'll create ripples that move outward at fairly high speed and fade out after a second. We'll use the function (1-cos(d2π10))(1-t2) where d=50t-||p-r||, p is the block XZ position, r is the ripple XZ origin, and t is the ripple age. To show a single period per ripple only include it if d lies in the 0–10 exclusive range.
接下来,添加一个方法来累积给定位置的涟漪到 UpdateVisualizationJob 。我们将创建以相当高的速度向外扩散并在一秒后消失的涟漪。我们将使用函数 (1-cos(d2π10))(1-t2) ,其中 d=50t-||p-r||p 是方块的XZ位置, r 是涟漪的XZ起点, t 是涟漪的年龄。如果 d 位于0-10的排他范围内,只显示一个周期的涟漪。

	float AccumulateRipples (float3 position)
	{
		float sum = 0f;
		for (int r = 0; r < rippleCount; r++)
		{
			float3 ripple = ripples[r];
			float d = 50f * ripple.z - distance(position.xz, ripple.xy);
			if (0 < d && d < 10f)
			{
				sum += (1f - cos(d * 2f * PI / 10f)) * (1f - ripple.z * ripple.z);
			}
		}
		return sum;
	}

Use the accumulated ripples to adjust the block Y position and color in Execute. Let's subtract half the ripple value to the Y position, pushing the blocks downward, and darken the color by up to 5%.
使用累积的波纹来调整 Execute 中的方块Y位置和颜色。让我们将波纹值的一半从Y位置中减去,将方块向下推,并将颜色变暗最多5%。

			float3 position = positions[blockOffset + bi];
			float ripples = AccumulateRipples(position);
			position.y = (altered ? 0.5f : 0f) - 0.5f * ripples;
			positions[blockOffset + bi] = position;
			colors[blockOffset + bi] =
				(altered ? coloration : 0.5f) * (1f - 0.05f * ripples);

To make sure that the ripples keep moving always update the visualization in Game.Update.
为了确保涟漪不断移动,请始终在 Game.Update 中更新可视化。

	void Update ()
	{
		…

		//if (PerformAction())
		//{
		//	visualization.Update();
		//}
		PerformAction();
		visualization.Update();
		visualization.Draw();
	}
Touching cells produces ripples.
触摸细胞会产生涟漪。

The ripple effect is fairly strong to make it obvious, but you might want to tone it down so it is less distracting.
涟漪效应相当强烈,以至于很明显,但你可能希望减弱它,使其不那么分散注意力。

Updating when Required 根据需要进行更新

We only need to update the visualization when there are active ripples or when the player touched a cell. But touching a cell causes a ripple, so we can suffice by checking for active ripples. This means that we can make GridVisualization.Update private and invoke it in Draw when needed.
只有在有活动的涟漪或玩家触摸单元格时,我们才需要更新可视化。但是触摸单元格会引起涟漪,所以我们可以通过检查活动的涟漪来满足需求。这意味着我们可以将 GridVisualization.Update 设为私有,并在需要时在 Draw 中调用它。

	public void Draw ()
	{
		if (rippleCount > 0)
		{
			Update();
		}
		Graphics.DrawMeshInstancedProcedural(
			mesh, 0, material, new Bounds(Vector3.zero, Vector3.one), positionsBuffer.count
		);
	}

	//public void Update ()
	void Update ()
	{
		…
	}

Now we no longer need to explicitly update the grid in Game.Update.
现在我们不再需要显式地更新 Game.Update 中的网格。

		PerformAction();
		//visualization.Update();
		visualization.Draw();

We can also eliminate the boolean return type from the action methods.
我们还可以从动作方法中消除布尔返回类型。

	void PerformAction ()
	{
		bool revealAction = Input.GetMouseButtonDown(0);
		bool markAction = Input.GetMouseButtonDown(1);
		if (
			…
		)
		{
			…
			//return revealAction ? DoRevealAction(cellIndex) : DoMarkAction(cellIndex);
			if (revealAction)
			{
				DoRevealAction(cellIndex);
			}
			else
			{
				DoMarkAction(cellIndex);
			}
		}

		//return false;
	}

	void DoMarkAction (int cellIndex)
	{
		CellState state = grid[cellIndex];
		if (state.Is(CellState.Revealed))
		{
			return; // false;
		}

		…
		//return true;
	}

	void DoRevealAction (int cellIndex)
	{
		…
		if (state.Is(CellState.MarkedOrRevealed))
		{
			return; // false;
		}

		…
		//return true;
	}

We've reached the end of this prototype tutorial. You should now know how to make a mine-sweeping game with a nonstandard visualization. From here you could entirely change its visualization, switch to a traditional square grid, make the grid wrap around, improve the mine-placement algorithm, or go in a different direction.
我们已经到达了这个原型教程的结尾。你现在应该知道如何制作一个具有非标准可视化的扫雷游戏。从这里,你可以完全改变它的可视化,切换到传统的方形网格,使网格环绕,改进地雷放置算法,或者朝着不同的方向发展。

The next tutorial is Runner 2.
下一个教程是Runner 2。

license 许可证 repository 仓库 PDF
switch theme 切换主题
contents 内容
  1. Game Scene
    1. Visuals
    2. Game Component
    3. Cell State
  2. The Grid
    1. Grid Struct
    2. Visualization Struct
    3. Visualization Material
    4. Initializing the Visualization
    5. Multiple Blocks per Cell
    6. Updating the Grid
    7. Drawing Symbols
  3. Gameplay
    1. Touching Cells
    2. Marking Cells
    3. Revealing Cells
    4. Placing Mines
    5. Counting Adjacent Mines
    6. Revealing Empty Regions
    7. Failure and Success
  4. Ripples
    1. Ripple Data
    2. Showing the Ripples
    3. Updating when Required