HexMap学习笔记(一)——创建六边形网格

HexMap学习笔记(一)——创建六边形网格

前言

事情源于前不久偶然翻到了Jasper Flick大佬在catlikecoding上的Unity博客教程。

大致读了一下教程,非常佩服这位作者。这应该是见过最细致的教程,并没有把大段的代码和效果图一股脑丢给你看,而是详细地说明每一个步骤的思路和原理,甚至会在一段代码上反复改动只为读者能完全弄明白。

极少有教程会像这样去一点点的教你作者的思路,内容也是由浅显到深并包含许多综合性的内容,非常具有学习价值。

在学习大有收获之余,我也希望如此精彩的教程能帮助到更多同学。在此选取HexMap这个系列教程整理翻译。

关于选择HexMap系列的原因
这个系列从简单到复杂,有着很明显的难度阶梯曲线,比较适合有一定基础的初学者入手学习。此教程包含的内容从算法、3D数学到Unity的特性、C#语法等,都有涉及,内容相当丰富且综合。并且地图生成本身也是非常有趣和实用的内容。
完全体的漂亮地图

当然,虽然我会尽己所能,但限于水准难免翻译会有疏漏和词不达意的位置。因此还是推荐有能力的同学直接学习英文原版。

另外,因为原版教程中代码的增删改动会以黄色背景色表示,而知乎的代码编辑模块不具备这种功能,为了能最大限度保留作者原意,就直接以截图的方式附上代码。

此系列教程的资源包从Unity5.3.1到Unity2017.3.0p3横跨数个版本,为防止导入出现版本冲突问题,我会统一使用Unity2018.3.0b12实现一遍,并在结尾附上当前教程的工程文件。

原版教程地址:Unity C# and Shader Tutorials

本期原文地址:Hex Map 1



本篇难度:★☆☆☆☆

创建六边形网格

此教程是六边形地图系列的第一部分。许多游戏,尤其是战略游戏,都常使用六边形的网格地图。包括《奇迹时代3》,《文明5》和《无尽传奇》等等。我们将从简单的基础开始逐步添加功能,直到最终得到一个基于六边形网格结构的复杂地图。

此教程的知识基础是假设你已经学完了以Procedural Grid开始的Mesh编程系列教程。工程由Unity5.3.1创建,中间横跨多个Unity版本,最后一部分是用Unity2017.3.0p3完成。

一张基本的六边形地图

1.六边形相关知识

为什么要用六边形?如果需要一个网格地图,使用正方形应该更合理一点。正方形很容易绘制和定位坐标,不过也有一个缺点。

观察一下下图中网格里的正方形和它的相邻正方形:

正方形网格与其相邻单元格

可以看到中间的正方形一共有八个相邻正方形,其中四个在边上相邻,另外四个在角上相邻。

假如正方形边长为1,那么边上相邻的正方形距离中间的正方形长度为1,而角上相邻的正方形距离为√2。

这种距离的差别会在计算移动时导致很多问题。对于这种问题,不同的游戏有不同的解决方式。其中一个就是用六边形网格来代替正方形网格。

六边形网格与其相邻单元格

相比于正方形网格,六边形网格的相邻格由八个变成了六个,并且都是在边上相邻。也就是说每个相邻格对于中间的单元格距离是一致的,这无疑简化了很多事情。不过六边形网格的构造比正方形要复杂一些,而这篇教程就是用来解决这个问题的。

在开始之间,我们先定义六边形的大小。如下图,以边长10作为基准,所以圆心到每个角的距离也是10,因为六边形是由六个等边三角形组成的。我们把圆心到角的距离称作六边形的外径。

六边形的内径和外径

有外径自然就有内径,就是圆心到每条边中心的距离。这个常量很重要,因为相邻六边形之间的距离刚好是这个值的两倍。

内径的长度等于外径的 \frac{\sqrt{3}}{2} 倍,所以在当前边长下内径的值就是 5\sqrt{3} 。把这些常量放在一个静态类中方便取用。

内径是如何计算的?
取组成六边形的六个等边三角形中的一个,内径就是这个三角形的高,把这个三角形中中间分开成两个直角三角形,即可用勾股定理求得。
对于边长e,内径为: \sqrt{e^{2}-(\frac{e}{2})^{2}}=\sqrt{3\frac{e^2}{4}}=e\frac{\sqrt{3}}{2}\approx0.866e

此外还需要定位中心的六边形单元格。六边形有两种定位方式,要么是尖的朝上,要么是平的朝上。这里我们选前一种方式,并把朝上的第一个角作为起点,然后顺时针添加其余角的顶点,在世界坐标系的XZ平面构建六边形。

可能的朝向

2 网格构建

创建一个六边形的网格就需要定义每一个单元格。为此创建一个HexCell脚本。目前就空着,暂时还不需要任何单元格数据。

一开始很简单,创建一个默认的Plane,把HexCell脚本挂上去,并创建成预制体。

使用一个Plane作为单元格的预制体

下面轮到创建HexGrid脚本,并定义宽度、高度和单元格预制体这几个公共变量。在场景中新建一个空对象挂载这个脚本。

六边形网格对象

先从创建一个标准的正方形网格开始,并把单元格存储在数组里,以便之后访问。

默认的Plane是10乘10的大小,所以以此为基准调整每个单元格的位置。

Plane构成的正方形网格

如此一来就生成了一个严丝合缝的正方形网格,但同时也不好识别每个单元格分别的位置。对于正方形网格也许还比较容易推算出来,但在六边形网格中就不好办了。如果这时能分别看到所有单元格的坐标就很方便了。

2.1 显示单元格坐标

在场景中添加一个canvas组件(GameObject / UI / canvas)并使其成为HexGrid的子物体。因为这是一个纯用来显示信息的canvas,所以可以删除上面的附带的raycaster组件。基于同样的理由,自动添加的EventSystem也可以删了,暂时都用不到。

把canvas的渲染模式设置为World Space,绕X轴旋转90度。将其轴心和坐标全部归零,并给予一个轻微的垂直向上偏移量,这样显示的内容就会出现在网格上面。canvas的宽度和高度都不重要,我们自己会定位显示内容,你可以全部归零来消除场景中canvas的矩形预览框。

最后把Dynamic Pixels Per Unit属性修改为10,确保文本有一个合适的字体纹理分辨率。

六边形网格坐标的Canvas

创建一个Text组件对象(GameObject / UI / text)并设为预制体,用来显示坐标。确保其锚点和轴心居中,大小设置为5x15。文本对齐方式也设置为水平和处置居中,字体大小设置为4。

最后,删除默认显示的文本也不启用Rich Text。是否启用RaycastTarger也不重要,canvas不会调用。

单元格坐标标签的预制体

现在HexGrid脚本中需要引用canvas和text的预制体,添加UnityEngine.UI的头部引用就能访问这些类型。text预制体可以通过公共变量静态引用,canvas就直接调用GetComponentInChildren()找到。

与标签建立连接关系

脚本中获取text预制体的引用后就可以实例化并显示每个单元格的坐标,在X和Z之间添加一个换行符,这样就能显示在不同的行间。

坐标可见

2.2 六边形的位置

现在我们可以直观地识别每个单元格了,开始对单元格的位置进行移动。通过之前的分析我们可以知道在X轴方向上,每个六边形单元格的相对距离是其内径的2倍,同时每行之间的距离应该是其外径的1.5倍。

六边形相邻的几何关系
没有偏移并使用六边形网格每个单元格的距离切分开

当然,连续的六边形单元格位置并不是在彼此的上下方,而应该在X轴上以内径为基准偏移。我们可以把Z轴偏移的一半加到X轴上然后乘以内径的两倍。

与六边形位置相符的菱形网格

现在的网格的形状是是个菱形而不是矩形,由于使用矩形更为方便,我们强制让Z轴上的单元格回到直线上。通过减去部分X轴上的偏移量来实现这个目的,每隔一行所有的X轴上的单元格应该都后退一点,具体的值通过相乘之前减去Z轴偏移的整数倍除以2就行了。

矩形区域内六边形化的间隔

3.渲染六边形

当位置确定后就可以把工作转移到渲染实际的六边形上了。首先移除默认的Plane,HexCell预制体上除了脚本其它组件全部删除。

不再使用Plane

就像在Mesh Basics系列教程中所做的一样,我们用单个Mesh来呈现整个网格。但这一次不打算预先确定需要多少个顶点和三角形了,将使用列表来代替数组作为存储容器。

创建一个新的脚本HexMesh来管理Mesh,它需要MeshFilter和MeshRenderer组件,一个Mesh对象与保存其顶点和三角形的列表。

在HexGrid上创建一个空的子对象并挂载此脚本,会自动添加MeshFilter和MeshRenderer组件。然后再添加一个默认的材质球。

六边形网格对象

现在HexGrid脚本可以像获取canvas组件一样获取HexMesh。

当HexGrid调用Awake()获取HexMesh后,HexMesh才能三角化单元格,所以这里要确保调用顺序在Awake()之后。在MonoBehaviour的生命周期里Start()是在Awake()之后的,所以在这里执行。

HexMesh.Triangulate ()可能在任何时候调用,即使在之前已经对单元格三角化的情况下也是如此。所以首先从清理旧数据开始,然后循环遍历所有单元格分别对它们进行三角剖分。这一步进行完之后将生成的顶点和三角形数据赋值给Mesh,最后重新计算Mesh的法线。

因为六边形是三角形组成的,比较直观的方式是通过给定的三个顶点创建添加三角形的方法,按顺序往列表里添加顶点并把这些顶点的下标相连形成三角形。第一个顶点的下标等于添加新顶点之前顶点列表的长度,所以在添加顶点之前先保存它。

现在可以从第一个三角形开始试试效果,它的第一个顶点应该是六边形的中心点,另外两个顶点是相对于中心的第一个和第二个角的顶点。

每个单元格的第一个三角形

可以看到没问题,接下来就循环六次渲染剩下的的三角形。

我们不能共用顶点么?
当然可以,甚至可以做的更好,只使用四个,而不是六个三角形来组成一个六边形。但不这么做会让接下来的工作更简单,也许现在优化顶点数是个不错的点子,但随着教程的进展事情反而会变得更复杂。现在优化顶点和三角形只会碍事。

不过这样写会产生一个数组越界的异常,这是因为最后一个三角形会试图获取不存在的第七个角。这时候应该绕回到第一个角作为它的最终顶点。但在不改变代码逻辑的情况下也可以用点小技巧,在HexMetrics.corners中复制第一个角作为第七个角,这样就不用担心数组越界问题了。

完成六边形

4.六边形的坐标

现在来观察一下六边形网格里每一个单元格的坐标,Z轴方向的坐标看起来没问题,但是X轴方向的坐标看起来是呈锯齿状的。这是我们为了让网格整体呈现一个矩形而强行偏移每一行坐标产生的副作用。

坐标偏移,高亮显示零坐标轴线

直接处理六边形网格的坐标偏移不太方便,让我们添加一个HexCoordinates结构体用来在不同的坐标系中转换。将其序列化以便Unity在运行模式也能识别它,并且在属性中只公开get确保坐标不可改动。

提前先创建一个转换成常规偏移坐标的静态转换方法,具体的等会写,现在先直接返回参数的值。

再添加一个字符串转换方法。默认的ToString()方法返回的是结构体的类型名,这显然没法用。所以重载这个方法让其返回当前结构的坐标值。同样的再添加一个分成两行显示坐标的方法,因为现在的显示用的就是这样的格式。

现在可以给HexCell声明一个坐标了。

这时候就可以修改HexGrid.CreateCell,用新的坐标获取方法。

现在我们修改X轴上的坐标,令其在一条直线上对齐,通过去掉水平偏移实现。

轴向坐标

目前这个二维坐标系可以让我们在四个方向上描述移动和偏移量,但是在一个单元格中可是有六个相邻单元格,剩下的两个方向去哪了?这表明了其实还存在第三个维度,事实上水平翻转一下X轴就可以得到那条缺失的Y轴。

Y轴出现

当X轴和Y轴互为镜像的情况下,如果Z轴不变,那么三个坐标相加应该总会得到一样的结果。事实上当你这么做时会发现这个结果总是0。如果你在一个轴向上增加坐标,另一个轴上就会减少,这就产生了六个可能的移动方向。这些坐标通常称为立方体坐标,因为它是三维的,其拓扑结构类似于立方体。

因为所有的坐标加起来一定等于0,所以你总是可以从其他两个坐标中得到另一个坐标。我们目前已经存储了X和Z的坐标,就不需要存储Y的坐标了。可以创建一个属性包含计算它的方法,并在ToString()方法中使用。

立体坐标


4.1 在inspector上显示坐标。

运行模式中选中网格中的一个单元格,只有HexCell.coordinates的前缀名而并没有显示具体坐标值。

Inspector上没有显示坐标

虽然这不是什么大问题,但如果能显示坐标值会显得整洁美观。现在没有显示是因为坐标值没有标记成序列化字段,为此需要显示定义一下。

难看并且可编辑

现在显示倒是显示出来了,但是是可编辑状态的。我们并不想这样,因为按道理来说坐标是固定不变的,而且显示在下面也不好看。

我们可以通过为HexCoordinates制定一个自定义的特性来让它做得更好。创建一个HexCoordinatesDrawer脚本放在Editor文件夹下,这是一个只用于编辑器的脚本。这个类应该扩展自UnityEditor.PropertyDrawer,并需要UnityEditor.CustomPropertyDrawer特性来让其正确关联起来。

PropertyDrawers通过OnGUI()方法显示其内容,该方法提供了要在其中绘制的屏幕矩形、序列化的属性数据以及它所属的字段的标签。

从属性中提取X和Z的值并用它们新建一个坐标,然后用重载的ToString()方法在指定位置绘制GUI标签。

没有标签名的坐标

这样坐标就显示出来了,但是类型名丢失了。名字之类的通常使用EditorGUI.PrefoxLabel()方法绘制。有个额外的好处是它返回一个经过调整的矩形,与该标签名右侧的空间刚好匹配。

有标签名的坐标

5.点击单元格

如果不能交互那这个六边形的网格也没什么意思。最基本的交互方式就是点击定位单元格,接下来我们就试着去实现它。现在先把这部分代码写在HexGrid里,一旦一切正常,就把这段代码搬到别的位置去。

实现这个功能可以通过鼠标向场景打射线的方式,和Mesh Deformation 教程中的使用方式一样。

这些代码现在什么都干不了,我们需要现在网格上添加碰撞盒好让射线能检测到。

并在三角化之后给Collider的Mesh赋值。

不能就用BoxCollider么?
可以,但是没法精确吻合Mesh的轮廓。而且我们的Mesh不会一直都是平的,不过那是之后教程的部分了。

现在能点击网格了,但是不知道具体点击的是哪一个。为了弄清楚这个,需要从鼠标点击的位置转换成六边形的坐标。这是HexCoordinates的工作,所以在其中声明一个静态方法FromPosition()。

这个方法该怎么计算选中的是哪个六边形?可以同过X值除以六边形的水平宽度,然后因为Y是X的镜像,通过负的X得到Y。

不过这只在Z为零的时候才是对的,Z的坐标递增时需要向左移动单元格。

现在X和Y计算完成,因为需要的是每个单元格中心的整数坐标,所以四舍五入一下,Z的值也能推导出来,然后构筑最后的输出坐标。

看起来似乎没问题,但这些坐标都正确么?仔细研究一下你会发现,有可能所有坐标加起来不等于0。在这种情况下报一个警告确保真的发生了。

事实上得也到警告信息了,似乎只发生在鼠标点击的位置接近六边形的边界的时候。所以是四舍五入那出了问题,因为离单元格的中心越远,四舍五入舍去的值就越多,所以我们做一个合理的假设:舍去值更大的坐标是错误的。

最后解决方法就变成了废弃具有最大舍去增量的坐标值,然后用其它的两个坐标去重新构建它。这里我们只需要去重建X和Z,不用为Y费神,因为Y本来就是由X和Z求得的。

5.1 为单元格着色

显现点击单元格的坐标是正确的,在此基础上做一些真正的交互:改变点击单元格的颜色。在HexGrid声明一个默认的颜色和点击变化的颜色。

单元格颜色选择

在HexCell中添加一个公共的颜色字段:

在HexGrid.CreateCell()中赋值默认的颜色:

同样的还要在HexMesh中添加颜色信息:

当三角化时,我们也需要给每个三角形添加颜色。另外分出一个方法去实现这个功能。

回到HexGrid.TouchCell里。整个流程就是先转换单元格坐标为保存单元格的数组下标。在四边形网格中就是X+Z乘以宽度,但在这里还需要加上一半的Z轴偏移。获取到点击的单元格后改变其颜色,然后再次三角化整个Mesh。

我们真的需要重新三角化整个Mesh么?
这里可以玩点花样,但现在还不是进行此类优化的时候。在后面的教程中Mesh会变得越来越复杂,现在做的任何假设和玩的花样过些时候都会无效。但是重新三角化整个Mesh这种野蛮的方式无论在什时候都是有效的。

尽管现在颜色已经改变了,但我们什么都没看到。这是因为默认的着色器没有应用顶点颜色。我们现在需要创建一个自定义的着色器(Assets / Create / Shader / Default Surface Shader),它只需要做两处改动:

1.在输入结构中添加颜色信息

2.输出时让反射率与颜色相乘。

当材质不透明时只用关心RGB通道。

新建一个材质球应用这个自定义的着色器,然后替换原来的材质球,单元格的颜色就能显示出来。

单元格着色效果
我得到了一些奇怪的阴影效果!
在某些Unity版本中,自定义表面着色器会遇到阴影问题。如果你遇到了阴影抖动或带状阴影问题,说明Z轴发生了冲突。校准方向光的阴影偏斜应该能解决这个问题。

6.地图编辑

现在我们把颜色编辑功能做成一个专门的编辑器,这就超出了HexGrid的功能范围了。所以把TouchCell()方法设为公共并添加额外的参数。同样要删除touchedColor字段。

创建一个HexMapEditor脚本,然后把Update和HandleInput方法移动到这里,添加一个公共字段引用HexGrid,一个存储颜色的数组,一个私有字段保记录当前颜色。最后添加一个公共方法选择颜色,并确保当前选择的颜色是数组中的第一个。

另外新建一个canvas,这一次保持默认的设置,把HexMapEditor脚本添加到这里,添加若干颜色并把HexGrid拖入字段中。这一次需要EventSystem组件了,并且它也会随着新建canvas再次自动创建。

拥有4种颜色的地图编辑器

在canvas上添加一个用来选择颜色的面板,根据颜色数量添加若干toggle组件(Components/ UI/toggle),放在屏幕的一角。

用Toggle Group实现调色板

为每个toggle指定颜色,我们现在不需要花哨的UI,只要它足够清楚易用就行。

一个Toggle一个颜色

确保只有第一个toggle被选中,并确保都在一个toggle group中,这样一次只能有一个toggle被选中。最后在编辑器中把他们与SelectColor()方法关联起来。

第一个Toggle

这个事件在每次选择变化时提供一个布尔参数表明这个toggle是否被选中。但是我们不需要关心这个。相反我们需要手动提供一个符合颜色数组下标的我们希望选中的整数型的参数。所以设置第一个为0,第二个为1,以此类推。

toggle的事件方法是什么时候调用的?
当每次选中的toggle变化时,就会调用这个方法。如果这个方法有一个布尔类型的参数,它会告诉我们这个toggle是否被选中。
当我们的toggle在一个组中,选中一个不同的toggle首先会取消选中当前被选中的toggle,然后选中现在选中的。这意味着SelectColor()方法会调用两次。这是对的,因为第二次的调用才是我们需要关心的。
多种颜色着色

虽然UI只是一个功能性的组件,但是会有一个烦人的细节。移动UI面板使其覆盖到六边形网格上。在选择颜色时同时也会改变UI下的单元格的颜色。同时进行UI和场景中的交互是冲突的。

可以通过询问事件系统是否检测到鼠标位于某个对象之上来解决这个问题。因为它只能检测UI对象,这表明我们在与UI交互。所以我们应该只在这种情况下自己处理输入。

本期工程文件:tank1018702/Hex-Map-Learning


有想系统学习游戏开发的童鞋,欢迎访问levelpp.com/

编辑于 2019-05-29

文章被以下专栏收录