六边形网格地图1 | 创建六边形网格

作者:王质
2017-10-09
23 32 4

前注

本文是 Jasper Flick 的 Unity 教程中的六边形网格地图系列教程的第一篇。
原文地址:http://catlikecoding.com/unity/tutorials/hex-map/part-1/
译者获得作者授权翻译转载于 indienova,后续教程将会陆续翻译。
本人也是初学者,如有错译,望海涵并及时纠正。

前言

将正方形转变为六边形

对六边形网格进行三角剖分

使用立方体坐标系

与单元格进行交互

制作一个游戏内编辑器

这篇教程是一系列关于六边形网格地图教程的第一部分。许多游戏使用六边形网格,尤其是策略类游戏,例如《奇迹时代3(Age of Wonders 3)》、《文明5(Civilization 5)》、《无尽的传说(Endless Legend)》等。我们将从基础开始,逐步地增加特性,直到我们完成一个复杂的基于六边形的地形系统。

我们假定您已经学习过网格基础系列教程,如果没有的话请从 Procedural Grid 开始吧。

本教程基于 Unity3D 5.3.1。

关于六边形 | About Hexagons

为什么使用六边形呢?如果你需要网格系统的话,使用正方形也是可以的。的确,正方形很容易绘制并定位,但是它却有一个缺点。看一看下面网格中心的正方形,并观察一下它的邻居们。

2

Figure 1‑1一个正方形与它的邻居们

一个正方形总共有八个邻居与它相接。其中有四个邻居与正方形的四条边相连,另外四个在它的对角线上。

中心的正方形与其相邻正方形的距离又是多少呢?假设边长是1个单位长度,那么正方形与其边相邻的邻居的距离就是1,而对角线上的邻居的距离却是√2。

两种不同的邻居导致了一些问题。如果想要在格子之间进行移动的话,是否要允许对角线方向上的移动呢?不同的游戏使用了不同的解决方案,各有优劣。其中一种方式就是不再使用正方形网格而是使用六边形网格。

3

Figure 1‑2六边形网格和它的邻居们

与正方形相比,六边形只有六个邻居而不是八个。所有的六个邻居都与其边相交,没有对角线方向上的邻居,所以六边形只有一种邻居,这就简化了许多工作。的确,相比于正方形网格,六边形网格较难创建,但是我们不是不能解决这个问题。

在我们开始之前,我们来设定我们的六边形单元格的大小为10个单位长度。由于六边形是由六个等边三角形构成的环组成,所以由形心到任意顶点的距离都是10。这就是六边形单元格外接圆的半径。

4

Figure 1‑3六边形的外接圆与内切圆

它同样也有一个内切圆,半径是从形心到任意一边的距离。这个数值是很重要的,因为两个相邻的六边形的距离是它的2倍。内切圆半径是外接圆半径的 sqrt(3)/2 倍,对于我们的六边形来说它等于 5 sqrt(3)。我们把这组数据放入静态类中以便于访问。

using UnityEngine;

public static class HexMetrics {
       public  const float outerRadius = 10f;
       public  const float innerRadius = outerRadius * 0.866025404f;
}

如何计算内切圆半径呢?

内切圆半径等于组成六边形的六个三角形之一的高。

所以对于边长是e的六边形来说,它的内切圆半径是 sqrt(e^2 - (e/2)^2) = sqrt(3e^2/4) = e sqrt(3)/2 ~~ 0.886e。

(老外的数学(●’◡’●))

然后我们来定义六边形的六个顶点相对于形心的位置。注点意有两种方式来放置六边形。顶点朝上或边线朝上。这里我们将会使用顶点朝上。首先定义朝上的这一点,然后按顺时针顺序定义剩余的点的位置。将它们置于XZ平面上以便与地面对齐。

5

Figure 1‑4两种放置方向
 public  static Vector3[] corners = {
              new  Vector3(0f, 0f, outerRadius),
              new  Vector3(innerRadius, 0f, 0.5f * outerRadius),
              new  Vector3(innerRadius, 0f, -0.5f * outerRadius),
              new  Vector3(0f, 0f, -outerRadius),
              new  Vector3(-innerRadius, 0f, -0.5f * outerRadius),
              new  Vector3(-innerRadius, 0f, 0.5f * outerRadius)
       };  

构建网格 | Grid Construction

为了创建六边形网格系统,我们需要创建六边形单元格。为此我们创建一个名为 HexCell 的脚本。先空着它,因为我们目前还没有任何单元格的数据。

using UnityEngine;

public class HexCell : MonoBehaviour {
} 

开始非常简单,我们来创建一个 Unity 自带的 Plane 对象,并将 HexCell 脚本添加到上面做成预制体。

6

Figure 2‑1将 plane 制成 HexCell 预制体

接下来就是网格了。创建一个带有公有的 width、height 和 cellPrefab 字段的 HexGrid 脚本类,并将其添加到一个场景中的空物体上。

using UnityEngine;

public class HexGrid : MonoBehaviour {
       public  int width = 6;
       public  int height = 6;
       public  HexCell cellPrefab;
} 

7

Figure 2‑2HexGrid对象

我们先来创建一个传统的正方形网格系统,这很容易。将单元格存到数组中以便于以后访问。

因为 Plane 的默认大小是 10*10,所以我们用这个数值偏移每一个单元格。

       HexCell[]  cells;

       void  Awake () {
              cells  = new HexCell[height * width];

              for  (int z = 0, i = 0; z < height; z++) {
                     for  (int x = 0; x < width; x++) {
                            CreateCell(x,  z, i++);
                     }
              }
       }

       void  CreateCell (int x, int z, int i) {
              Vector3  position;
              position.x  = x * 10f;
              position.y  = 0f;
              position.z  = z * 10f;

              HexCell  cell = cells[i] = Instantiate(cellPrefab);
              cell.transform.SetParent(transform,  false);
              cell.transform.localPosition  = position;
       }

8

Figure 2‑3正方形网格平面

这样我们便得到了一个无缝的正方形网格。但是哪个单元格是哪个呢?的确,这对我们来说是很容易查看的,但是要是六边形的话就没那么容易了。如果我们能够看到每一个单元格的坐标的话会是非常方便的。

展示坐标 | Showing Coordinates

在场景中添加一个 Canvas 并将其设置为 HexGrid 的子物体。因为它是一个纯粹的信息展示用的 Canvas,所以可以移出它的Raycaster 组件。同理,你也可以删除自动添加到场景中的 EventSystem 对象。

将 RenderMode 设置为 WorldSpace 并沿X轴旋转90°以便它能覆盖在我们的网格上。将它的中心位置和坐标都设置为0,纵坐标做些些许的偏移以便让他的内容能浮现在网格上面。不用管它的高度与宽度,我们会为内容重新定位。你可以将其置零来去掉场景中的巨大矩形框。

最后一步,将 Dynamic Pixels Per Unit 增加到10。这样做会确保 Test 对象能够使用恰当的字体纹理。

9
10

Figure 2‑4六边形网格坐标的Canvas

为了显示坐标,我们先创建一个Test对象然后将它设置为预制体。确保它的锚点的中心坐标设置为原图的中心,大小设置为5*15,文本应该在水平和竖直方向上都居中显示。字体大小设置为。.最后,我们将不会使用默认的字体,我们也不会使用Rich Test。 Raycast Target是否被勾选并不重要,因为我们的Canvas不会使用它。

11
12

Figure 2‑5 Cell label 预制体

现在我们的网格需要得到 Canvas 与预制体的信息。在脚本的顶端添加 using UnityEngine.UI; 语句使我们能够方便地使用UnityEngine.UI.Text 类型。将 Label 预制体设置为公有,而 Canvas 可以使用 GetComponentInChildren函数获取。

public  Text cellLabelPrefab;
    Canvas  gridCanvas;

    void  Awake () {
       gridCanvas  = GetComponentInChildren();
       …
    }

13

Figure 2‑6与 label 预制体链接

连接上 label 预制体之后,我们就可以创建它们的实例来显示单元格坐标了。在X和Z坐标之间加入换行符以便能够分行显示坐标。

              void  CreateCell (int x, int z, int i) {
              …
              Text  label = Instantiate(cellLabelPrefab);
              label.rectTransform.SetParent(gridCanvas.transform,  false);
              label.rectTransform.anchoredPosition  =
                     new  Vector2(position.x, position.z);
              label.text  = x.ToString() + "n" + z.ToString();
       }

14

Figure 2‑7可视化的坐标

六边形的位置 | Hexagon Positions

既然我们现在已经可以通过坐标区分每个单元格了,我们就可以对他们进行调整。六边形单元格间的遂平间距是他们的。让我们来使用这两个数值吧

15

Figure 2‑8相邻六边形的几何分析
              position.x  = x * (HexMetrics.innerRadius * 2f);
              position.y  = 0f;
              position.z  = z * (HexMetrics.outerRadius * 1.5f);

16

Figure 2‑9没有偏移地使用六边形网格间距

当然,行与行之间的相邻六边形是交错开来的。每一行都沿X轴偏移了内切圆半径的长度。我们可以用如下方法实现。

              position.x  = (x + z * 0.5f) * (HexMetrics.innerRadius * 2f);

17

Figure 2‑10完全依照六边形网格坐标而产生的平行四边形网格

这样,我们单元格就被放到了六边形网格应有的位置上,它们将会组成一个平行四边形,而非一个矩形。但是我们想要的是一个矩形的地图,我们还需要将单元格平移回来。

              position.x  = (x + z * 0.5f - z / 2) * (HexMetrics.innerRadius * 2f);

18

Figure 2‑11被放置成矩形的原始六边形网格

渲染六边形 | Rendering Hexagons

在将单元格放置到正确的位置之后,我们就可以着手绘制真实的六边形了。首先,我们要将自己从 plane 中解脱出来,所以我们将HexCell 预制体上的所有组件移除。

19

Figure 3‑1移出了网格的 HexCell 对象

就像是 Mesh Basics 教程中的做法,我们用一个 Mesh 来绘制所有的网格。然而,我们这一次不会预先设置好我们需要的顶点与三角形的数量,而是使用 List 容器。

创建一个新的 HexMesh 组件来处理我们的 Mesh。它需要 Mesh Filter 和 Mesh Renderer 组件,持有一个 Mesh 对象和所需顶点与三角形片面的 List。

using UnityEngine;
using System.Collections.Generic; 

[RequireComponent(typeof(MeshFilter),  typeof(MeshRenderer))]
public class HexMesh : MonoBehaviour {
       Mesh  hexMesh;
       List  vertices;
       List  triangles;

       void  Awake () {
              GetComponent().mesh  = hexMesh = new Mesh();
              hexMesh.name  = "Hex Mesh";
              vertices  = new List();
              triangles  = new List();
       }
}

给 HexGrid 类创建一个新的子物体并添加这个组件,它将自动创建一个 Mesh Renderer 但是并不会给它材质。我们需要在上面添加一个默认材质。

20
21

Figure 3‑2 Hex Mesh 对象

现在 HexGrid 可以获取它的 HexMesh 子物体了,获取方式和 Canvas 相同。

       HexMesh  hexMesh; 

       void  Awake () {
              gridCanvas  = GetComponentInChildren();
              hexMesh  = GetComponentInChildren();           
              …
       }

在 HexGrid 被唤醒后,他将会通知 HexMesh 类去绘制它的单元格。我们必须确保此时 HexMesh 组件已经被唤醒了。我们在 Start 方法中发送这条消息,因为 Start 函数保证在所有 Awake 函数执行之后才执行。

       void  Start () {
              hexMesh.Triangulate(cells);
       }

HexMesh.Triangulate 函数可以在任意时刻被调用,甚至在单元格已经进行过三角剖分之后。所以我们在方法的开始需要清除所有旧的数据。然后遍历所有单元格,独立的绘制它们的三角形片面。这一切结束之后,再将顶点与网格赋值给 Mesh 网格,然后重新计算法线信息。

       public  void Triangulate (HexCell[] cells) {
              hexMesh.Clear();
              vertices.Clear();
              triangles.Clear();
              for  (int i = 0; i < cells.Length; i++) {
                     Triangulate(cells[i]);
              }
              hexMesh.vertices  = vertices.ToArray();
              hexMesh.triangles  = triangles.ToArray();
              hexMesh.RecalculateNormals();
       }       
       void  Triangulate (HexCell cell) {
       }

因为六边形是由三角形组成的所以我们来创建一以三个点为参数个方法来添加三角形。它只是按顺序地添加顶点。三角形的第一个顶点的索引等于在添加它之前顶点List的长度。所以在添加顶点之前请保存这个值。

       void  AddTriangle (Vector3 v1, Vector3 v2, Vector3 v3) {
              int  vertexIndex = vertices.Count;
              vertices.Add(v1);
              vertices.Add(v2);
              vertices.Add(v3);
              triangles.Add(vertexIndex);
              triangles.Add(vertexIndex  + 1);
              triangles.Add(vertexIndex  + 2);
       }

现在我们可以用三角形绘制我们的单元格了。我们先绘制第一个三角形看看效果。第一个顶点是六边形的形心。另外两个顶点是相应六边形的头两个顶点。

       void  Triangulate (HexCell cell) {
              Vector3  center = cell.transform.localPosition;
              AddTriangle(
                     center,
                     center  + HexMetrics.corners[0],
                     center  + HexMetrics.corners[1]
              );
       }

22

Figure 3‑3每个单元格的第一个三角形

显示正常,所以将上端代码放入循环中以画出所有的六个三角形。

              Vector3  center = cell.transform.localPosition;
              for  (int i = 0; i < 6; i++) {
                     AddTriangle(
                            center,
                            center  + HexMetrics.corners[i],
                            center  + HexMetrics.corners[i + 1]
                     );
              }

我们不能共用顶点么?

我们的确可以共用顶点。事实上,我们甚至可以只用4个三角形来渲染一个六边形而不是6个。但是那样做太麻烦。我们保持现在的工作简单易行是因为在之后我们还有很多复杂的处理需要做。现在就优化顶点与三角形的数量只会给我们后面的工作挡道。

不幸的是,现在我们会触发一个 IndexOutOfRangeException 异常。这是因为最后一个三角形试图去获取六边形根本就不存在的第7个顶点。它本应该用第一个顶点给他的顶点数组中的最后一个位置赋值。我们可以在 HexMetrics.corners 数组的最后多创建一个第一个顶点来避免数组越界。

    public  static Vector3[] corners = {
       new  Vector3(0f, 0f, outerRadius),
       new  Vector3(innerRadius, 0f, 0.5f * outerRadius),
       new  Vector3(innerRadius, 0f, -0.5f * outerRadius),
       new  Vector3(0f, 0f, -outerRadius),
       new  Vector3(-innerRadius, 0f, -0.5f * outerRadius),
       new  Vector3(-innerRadius, 0f, 0.5f * outerRadius),
       new  Vector3(0f, 0f, outerRadius)
    };

23

Figure 3‑4完整的六边形网格

六边形坐标系统 | Hexagonal Coordinates

我们来重新关注一下来我们六边形网格中每个单元格的坐标吧。Z坐标很好,但是X坐标却是曲曲折折的。这是我们对每行进行偏移来实现整体矩形效果的副产物。

24

Figure 4‑1被偏移的坐标,高光显示的是零行

这样的坐标系统在使用六边形网格的时候是非常麻烦的。我们来用 HexCoordinate 结构体来将其转换为另一个不同的坐标系。将该类设置为 serializable 以便 Unity 能够储存它,允许他在游戏模式时能够重新编译。我们通过将其设定为公有只读属性来保证坐标不可更改。

using UnityEngine;

[System.Serializable]
public struct HexCoordinates {
       public  int X { get; private set; } 
       public  int Z { get; private set; }
       public  HexCoordinates (int x, int z) {
              X  = x;
              Z  = z;
       }
}

增加一个能用普通的偏移坐标系创建新坐标的静态方法。目前他暂时只是简单的将坐标复制一遍。

       public  static HexCoordinates FromOffsetCoordinates (int x, int z) {
              return  new HexCoordinates(x, z);
       }
}

我们还需要为结构体添加一个方便的字符串转化函数。默认的 ToString 方法返回的只是结构体的名称,没卵用。将其重载为在一行内返回X、Y坐标。然后添加一个能将坐标分行输出的方法,因为我们已经使用这种格式了。

       public  override string ToString () {
              return  "(" + X.ToString() + ", " + Z.ToString() + ")";
       }
       public  string ToStringOnSeparateLines () {
              return  X.ToString() + "n" + Z.ToString();
       }

现在我们要为 HexCell 类添加 HexCoordinates 坐标。

public class HexCell : MonoBehaviour {
       public  HexCoordinates coordinates;
}

调整 HexGrid.CreateCell 函数以便其能适应新的坐标系统。

       HexCell  cell = cells[i] = Instantiate(cellPrefab);
       cell.transform.SetParent(transform,  false);
       cell.transform.localPosition  = position;
       cell.coordinates  = HexCoordinates.FromOffsetCoordinates(x, z);       

       Text  label = Instantiate(cellLabelPrefab);
       label.rectTransform.SetParent(gridCanvas.transform,  false);
       label.rectTransform.anchoredPosition  =
           new  Vector2(position.x, position.z);
       label.text  = cell.coordinates.ToStringOnSeparateLines();

现在我们需要处理一下X坐标,以便相同的坐标能够保持在一条直线上。我们可以通过对坐标进行重新水平偏离来实现。最终实现的这个坐标系统一般被称为轴坐标系 axial coordinates

    public  static HexCoordinates FromOffsetCoordinates (int x, int z) {
       return  new HexCoordinates(x - z / 2, z);
    }

25
26

Figure 4‑2轴坐标系

这个新的二维坐标系统让我们能够直观地描述六边形在4个方向上的移动。但是仍然有两个方向需要特殊处理。这表明了实际上存在第三个维度。确实如此,如果我们将X轴沿水平方向转过一定的角度我们就可以得到消失的Y轴。

27

Figure 4‑3显现的Y轴

因为X、Y轴互相对称,所以如果你将Z值保持不变的话,x+y 永远得一个固定的值。事实上,这三个坐标的和永远为零。如果你将某一维度的坐标增加的话,另外一个就需要减少。这让六个方向上的运动成为了可能。这个坐标系被称为立方体坐标系 cube coordinates,因为它有三个轴并且类似与正方体的解剖结构。

因为一个点的所有坐标值相加为零,所以你可以从任意两个坐标得出第三个。以为我们已经储存了X、Z坐标,所以我们并不需要储存Y坐标了。我们可以使用一个属性来实时计算它的值并且需要在 String 方法里加入Y坐标。

       public  int Y {
              get  {
                     return  -X - Z;
              }
       }

       public  override string ToString () {
              return  "(" +
                     X.ToString()  + ", " + Y.ToString() + ", " + Z.ToString() +  ")";
       } 

       public  string ToStringOnSeparateLines () {
              return  X.ToString() + "n" + Y.ToString() + "n" + Z.ToString();
       }

28

Figure 4‑4立方体坐标系

Inspector界面中的坐标 | Coordinates in the Inspector

在游戏模式时选择一个单元格。但是会发现 Inspector 界面中并不会显示它的坐标。只会显示 HexCell.coordinates。

29

Figure 4‑5 Inspector 界面中不会显示坐标

虽然这不是一个大问题,但是如果能够显示坐标的话将会很方便。Unity3D当前不会显示坐标是因为当前它们并没有被标记为可序列化的字段。为了达到这一目的我们需要在定义X、Z坐标时将其定义为 serializable field。

       [SerializeField]
       private  int x, z;

       public  int X {
              get  {
                     return  x;
              }
       } 

       public  int Z {
              get  {
                     return  z;
              }
       }

       public  HexCoordinates (int x, int z) {
              this.x  = x;
              this.z  = z;
       }

30

Figure 4‑6丑陋的并且可以编辑

现在X、Z坐标被显示出来了,但是可以在 Inspector 界面中对其进行编辑,这并不是我们想要的结果,因为坐标此时应该已经不能变动了。而且这种显示方式也不是很好看,因为坐标被显示在下拉列表里。

我们可以通过为 HexCoordinates 类定义一个 CustomPropertyDrawer 来达到更好的效果。创建一个名为 HexCoordinatesDrawer 的脚本并将其放到 Editor 文件夹里,使其成为一个只能被编辑器使用的脚本。

这个类需要继承自 UnityEditor.PropertyDrawer ,同时还需要一个 UnityEditor.CustomPropertyDrawer 特性来与其所服务的类型进行绑定。

using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(HexCoordinates))]
public class HexCoordinatesDrawer :  PropertyDrawer {
}

Drawers 特性通过OnGUI方法渲染它们的文本。该方法提供了一个屏幕矩形来在里面绘制可序列化的属性和它的标签。

       public override  void OnGUI (
              Rect  position, SerializedProperty property, GUIContent label
       ) {
       }

从属性中提取 x 与 y 坐标并且用他们来创建新的坐标,然后使用我们的 HexCoordinates.ToString 方法来在特定的位置绘制 GUI label。

       public override  void OnGUI (
              Rect  position, SerializedProperty property, GUIContent label
       ) {
              HexCoordinates  coordinates = new HexCoordinates(
                     property.FindPropertyRelative("x").intValue,
                     property.FindPropertyRelative("z").intValue
              );              

              GUI.Label(position,  coordinates.ToString());
       }

31

Figure 4‑7没有前缀标签的坐标展示

这样我们已经正常显示了坐标,但是我们落下了标签名称。这个名称通常使用 EditorGUI.PrefixLabel 方法绘制。它返回了一个能够匹配右方标签大小的绘制空间。

              position  = EditorGUI.PrefixLabel(position, label);
              GUI.Label(position,  coordinates.ToString());

32

Figure 4‑8带有标签的坐标展示

单元格交互 | Touching Cells

如果我们不能与之交互的话就算我们绘制完了六边形网格也没什么卵用。最基本的一个交互方式就是能够选择它,接下来我们将实现这一目标。现在就先把下面这段代码直接放入 HexGrid 类里。等到了一切正常工作我们再把它移到别的地方。

为了能选择一个单元格,我们将从鼠标所在位置向场景中发射一条射线。我们可以使用与我们在 Mesh Deformation 教程中使用的相同的方式。

        void Update () {
              if  (Input.GetMouseButton(0)) {
                     HandleInput();
              }
       }

       void  HandleInput () {
              Ray  inputRay = Camera.main.ScreenPointToRay(Input.mousePosition);
              RaycastHit  hit;
              if  (Physics.Raycast(inputRay, out hit)) {
                     TouchCell(hit.point);
              }
       }      

       void  TouchCell (Vector3 position) {
              position  = transform.InverseTransformPoint(position);
              Debug.Log("touched  at " + position);
       }

这没起到任何效果。我们需要为网格添加一个碰撞器使射线能够撞击到它。

       MeshCollider  meshCollider;

       void  Awake () {
              GetComponent().mesh  = hexMesh = new Mesh();
              meshCollider  = gameObject.AddComponent();
              …
       }

在我们完成网格的制作之后就为其添加碰撞器。

       public  void Triangulate (HexCell[] cells) {
              …
              meshCollider.sharedMesh  = hexMesh;
       }

为什么我们不直接使用 Box Collider呢?

可以,但是它不太适合我们网格的轮廓。在之后的教程中,我们的网格将很快不再保持为一个平面。

我们现在可以选取网格了!但是我们选取的是哪一个格子呢?为了搞清楚这一点,我们需要将射线碰撞的坐标转换为六边形坐标。这是 HexCoordinates 类的工作,我们为其声明一个名为 FromPosition 的静态方法。

       public void TouchCell (Vector3 position)  {
              position = transform.InverseTransformPoint(position);
              HexCoordinates coordinates =  HexCoordinates.FromPosition(position);
              Debug.Log("touched at "  + coordinates.ToString());
       }

这个方法是如何计算出碰撞点在哪一个六边形内的呢?首先,我们可以用六边形的水平宽度除以X坐标。由于Y轴是X轴的镜像,所以-x=y。

       public static HexCoordinates FromPosition  (Vector3 position) {
              float x = position.x /  (HexMetrics.innerRadius * 2f);
              float y = -x;
       }

但是这只有在Z等于零的情况下是正确的。所以我们还要沿着Z轴方向平移。每两行向左平移一个单元格。

              float offset = position.z /  (HexMetrics.outerRadius * 3f);
              x -= offset;
              y -= offset;

我们最终显示在单元格中心的X、Y坐标都是整数,所以我们需要将现在的坐标值转化为整数。同时得出Z的值,得到最终的坐标。

              int iX = Mathf.RoundToInt(x);
              int iY = Mathf.RoundToInt(y);
              int iZ = Mathf.RoundToInt(-x -y);

              return new HexCoordinates(iX, iZ);

我们似乎得到了正确的结果,但是真的是正确的么?如果我们做一些细致的检测的话会发现有一些坐标相加之和不为零。当发生这种错误时我们来输出一条语句,以验证错误是否真的会发生。

              if (iX + iY + iZ != 0) {
                     Debug.LogWarning("rounding  error!");
              }             

              return new HexCoordinates(iX, iZ);

的确,我们收到了警报。如何才能解决这个问题呢?只有在两个相邻六边形边界附近的时候才会出现问题。所以对浮点数的凑整导致了问题的发生。究竟是哪一个方向分量的坐标被错误凑整了呢?因为里单元格中心越远,凑整时会被舍去更多的值,所以有理由相信被舍去最多值的方向分量是错误的。

解决方案就是抛弃类型转换时偏差最大的方向的分量,然后使用另外两个坐标重新计算它。因为我们只需要X和Z坐标,所以我们不用去管Y坐标。

       if (iX + iY + iZ != 0) {
                     float dX = Mathf.Abs(x -  iX);
                     float dY = Mathf.Abs(y -  iY);
                     float dZ = Mathf.Abs(-x -y  - iZ);

                     if (dX > dY &&  dX > dZ) {
                            iX = -iY - iZ;
                     }
                     else if (dZ > dY) {
                            iZ = -iX - iY;
                     }
              }

为六边形着色 | Coloring Hexes

现在我们可以正确的选择单元格了,是时候做一些真正的能够产生显示影响的交互了。我们来改变我们所选择的单元格的颜色吧。给HexGrid 类添加一个可编辑的默认颜色和选中颜色。

       public  Color defaultColor = Color.white;
       public  Color touchedColor = Color.magenta;

33

Figure 5‑1单元格的两种颜色

为 HexCell 类也添加一个公有的 color 字段。

public class HexCell : MonoBehaviour {
       public  HexCoordinates coordinates;
       public  Color color;
}

在 HexGrid.CreateCell 方法中为其设置默认颜色。

       void  CreateCell (int x, int z, int i) {
              …
              cell.coordinates  = HexCoordinates.FromOffsetCoordinates(x, z);
              cell.color  = defaultColor;
              …
       }

同样地,在 HexMesh 类中添加颜色信息。

       List  colors;

       void  Awake () {
              …
              vertices  = new List();
              colors  = new List();
              …
       }

       public  void Triangulate (HexCell[] cells) {
              hexMesh.Clear();
              vertices.Clear();
              colors.Clear();
              …
              hexMesh.vertices  = vertices.ToArray();
              hexMesh.colors  = colors.ToArray();
              …
       }

现在,在进行三角剖分的时候,我们也需要为每个三角形添加颜色信息。为此我们创建一个方法。

       void  Triangulate (HexCell cell) {
              Vector3  center = cell.transform.localPosition;
              for  (int i = 0; i < 6; i++) {
                     AddTriangle(
                            center,
                            center  + HexMetrics.corners[i],
                            center  + HexMetrics.corners[i + 1]
                     );
                     AddTriangleColor(cell.color);
              }
       }

       void  AddTriangleColor (Color color) {
              colors.Add(color);
              colors.Add(color);
              colors.Add(color);
       }

回到 HexGrid.TouchCell 方法。首先,将单元格坐标转化为对应的数组索引。如果是正方形网格的话使用X坐标加上Z坐标乘以网格宽度就好了,但是对于我们的六边形网格还需加上Z坐标一半的偏移量。然后获取单元格,改变它们的颜色,并且重新绘制单元格。

我们真的需要重新绘制整个网格么?

现在还不是做这些优化的时候。在以后的教程中我们的网格会逐渐演变的复杂得多。现在走任何的捷径将会给我们的未来造成阻碍。而暴力手段解决问题往往总能奏效。
       public void TouchCell (Vector3 position)  {
              position =  transform.InverseTransformPoint(position);
              HexCoordinates coordinates =  HexCoordinates.FromPosition(position);
              int index = coordinates.X +  coordinates.Z * width + coordinates.Z / 2;
              HexCell cell = cells[index];
              cell.color = touchedColor;
              hexMesh.Triangulate(cells);
       }

虽然我们现在已经改变了单元格颜色,但是却依旧未能看到任何颜色变化。只是应为 Unity 的默认着色器程序不使用顶点颜色数据。我们需要编写我们自己的着色器程序。创建一个新的 Default Surface Shader,只需要对其进行两点修改。一,为其输入结构中添加颜色数据。二,对 albedo 乘上这个颜色。我们只关心 RGB 通道,因为我们的材质是不透明的。

Shader  "Custom/VertexColors" {
       Properties {
              _Color ("Color", Color)  = (1,1,1,1)
              _MainTex ("Albedo  (RGB)", 2D) = "white" {}
              _Glossiness  ("Smoothness", Range(0,1)) = 0.5
              _Metallic ("Metallic",  Range(0,1)) = 0.0
       }
       SubShader {
              Tags {  "RenderType"="Opaque" }
              LOD 200

              CGPROGRAM
              #pragma surface surf Standard  fullforwardshadows
              #pragma target 3.0

              sampler2D _MainTex;

              struct Input {
                     float2 uv_MainTex;
                     float4 color : COLOR;
              };

              half _Glossiness;
              half _Metallic;
              fixed4 _Color;

              void surf (Input IN, inout  SurfaceOutputStandard o) {
                     fixed4 c = tex2D(_MainTex,  IN.uv_MainTex) * _Color;
                     o.Albedo = c.rgb *  IN.color;
                     o.Metallic = _Metallic;
                     o.Smoothness = _Glossiness;
                     o.Alpha = c.a;
              }
              ENDCG
       }
       FallBack "Diffuse"
}

用这个 Shader 创建一个新的材质,并确保让 Mesh 网格使用这个材质。这样颜色的变化就会显现出来。

34

Figure 5‑2着色单元格

我得到了一些奇怪的阴影!

在一些版本的 Unity 中,自定义表面着色器会产生一些阴影问题。如果你得到了一些由深度冲突导致的阴影抖动。调整平行光源的阴影偏移值会解决这个问题。

地图编辑器 | Map Editor

现在我们已经能编辑单元格颜色了,我们可以将其升级为一个简单的游戏内编辑器。这项工作超出了 HexGrid 的只能放味,所以将TouchCell 变成一个公有的方法然后为其添加一个颜色参数。同时去除 touchedColor 字段。

       public  void ColorCell (Vector3 position, Color color) {
              position  = transform.InverseTransformPoint(position);
              HexCoordinates  coordinates = HexCoordinates.FromPosition(position);
              int  index = coordinates.X + coordinates.Z * width + coordinates.Z / 2;
              HexCell  cell = cells[index];
              cell.color  = color;
              hexMesh.Triangulate(cells);
       }

创建一个 HexMapEditor 脚本然后将 Update 方法和 HandleInput 方法移到其中。给它一个共有的字段来持有 Hex 网格和颜色数组,一个私有的字段储存被激活的颜色。最后添加一个公有的方法来选择颜色并且确保首先选择最初的颜色。

using UnityEngine;

public class HexMapEditor : MonoBehaviour { 
       public Color[] colors;
       public HexGrid hexGrid; 

       private Color  activeColor;

       void Awake () {
              SelectColor(0);
       }

       void Update () {
              if  (Input.GetMouseButton(0)) {
                     HandleInput();
              }
       }

       void HandleInput () {
              Ray inputRay =  Camera.main.ScreenPointToRay(Input.mousePosition);
              RaycastHit hit;
              if  (Physics.Raycast(inputRay, out hit)) {
                     hexGrid.ColorCell(hit.point, activeColor);
              }
       }

       public void SelectColor  (int index) {
              activeColor =  colors[index];
       }
}

添加另外一个 Canvas,这回使用它的默认设置。为它添加 HexMapEditor 脚本,并为其设置一些颜色,再赋给它 HexGrid 的引用。这回我们需要事件系统了。

35

Figure 6‑1具有四种颜色的六边形网格地图编辑器

为 Canvas 添加一个 Panel 来放置颜色选择器,并为这个 Panel 添加一个 Toggle Group 组件。将 Pabel 设置为适当的大小并将其拖到屏幕的左上角。

36

Figure 6‑2带有Toggle Group的Panel

现在用每个颜色的 Toggle 来填充整个 Panel。我们现在不关心界面是否美观,手动设置它就可以了。

37
38

Figure 6‑3每个颜色一个Toggle

确保只有第一个 Toggle 被选中了。同时确保他们都在一个 Toggle Group 中,只有这样才能保证同时只有一个 Toggle 被选中。最后将其与 SelectColor 方法绑定。你可以通过 On Value Changed 事件UI界面下方的加号按钮来注册方法。选择 HexMaoEditor 对象,并且在下拉列表中选择正确的方法。

39

Figure 6‑4第一个 Toggle

事件系统提供了一个布尔参数来表示没打那个选择变化时每一个 Toggle 的开关状态。但是我们并不关心这个。我们需要手动添加一个整型参数来表示我们所选择的颜色的索引。第一个 Toggle 是0,其次是1、2、3等等。

什么时候toggle事件方法会被调用?

每当 Toggle 状态改变的时候就会调用事件方法。如果这个方法只是用一个布尔参数的话,它就表示这个 Toggle 的开关状态。

由于我们的 Toggle 都在一个组中,选择组内另外一个 Toggle 会导致正在被激活的 Toggle 被注销。这就意味着 SelecColor 方法两次。这没有任何问题,因为第二次被调用的那次才是真正起作用的。

40

Figure 6‑5使用多种颜色着色

虽然UI功能好使了,但是还有一个恼人的细节需要处理。为了能看到这个问题,我们移动一下 Panel,好让它能覆盖在六边形网格上方。当选择一个新的颜色的时候,你也为UI空间下方的单元格绘制了颜色。这是因为我们在同时与 UI 系统和六边形网格进行交互。这件事是不能被接受的。

可以通过向事件系统询问我们的光标是否在一些对象上方来解决这个问题。由于事件系统只知道 UI 对象,这就意味着我们此时正在与UI 系统进行交互。我们只有在不是这种情况的时候才能处理颜色输入。

using UnityEngine;
using UnityEngine.EventSystems;

       …     

       void  Update () {
              if  (
                     Input.GetMouseButton(0)  &&
                     !EventSystem.current.IsPointerOverGameObject()
              )  {
                     HandleInput();
              }
       }

下一篇教程是混合单元格颜色

近期点赞的会员

 分享这篇文章

王质 

Game as a Vocation. 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. Simidal 2017-10-09

    六边形的位置 那一段 少翻译了一段话。-。应该是横向相邻的两个六边形中心点相距六边形内圈半径的2倍,以及纵向相邻的两行六边形 相距 六边形外圈半径的1.5倍。
    渲染六边形的代码也有所缺少= =、、、、

    最近由 Simidal 修改于:2017-10-09 16:23:16
    • arthliant 2017-10-14

      @Simidal:额,检查了好几遍竟然还有这种错误。。。十分糟心
      另外,渲染部分应该不缺代码吧
      文章里修改不太方便,我先在我日志里改了~

  2. Chliders 2019-03-11

    代码少复制了一些,所以会出现错误。看原文就好

  3. 小乐 2021-12-16

    亲,代码里面所有的 GetComponentInChildren 的方法里面没有带类型哦,原文都是有的,比如: gridCanvas = GetComponentInChildren();

您需要登录或者注册后才能发表评论

登录/注册