原文地址:https://catlikecoding.com/unity/tutorials/hex-map/part-27/
机翻+个人润色
这是六边形地图系列教程的第27章。上一个章节是我们完成了地图生成器。在最后一部分中,我们添加了对无缝地图的支持,方法是连接东西边缘。
这篇教程是基于Unity2017.3.0p3制作
循环使世界转动
我们的地图可以用来表示不同大小的区域,但它们总是被限 制为矩形。我们可以为单个岛屿或整个大陆绘制地图,但不能绘制整个星球。行星是球形的,没有坚硬的边界来阻止其表面的运动。继续朝一个方向走,在某个点你会回到你开始的地方。
我们不能用六边形网格包住一个球体,这样的平铺是不可能的。最好的近似方法是使用二十面体拓扑结构,它要求12个单元为五边形。然而,将网格绕在圆柱体上是可能的,不会发生扭曲或异常。这仅仅是一个连接地图东西两侧的问题。除了循环逻辑之外,其他一切都可以保持不变。
圆柱与球体的近似度很低,因为它 不能表示极点。但这并没有阻止许多游戏使用东西方向的循环来呈现行星地图。极地地区根本就不是游戏区域的一部分。
把南北也循环起来怎么样?
如果你把东西南北都循环,就得到了环面的拓扑结构。所以这不是球形物体的有效表示,尽管有些游戏使用了这种循环方法。本教程只介绍东西循环,但是您也可以使用相同的方法添加南北循环。它只需要更多的工作和其他指标。
有两种方法来处理圆柱循环。第一种方法是把地图做成圆柱形,弯曲它的表面和上面的所有东西,这样东西两个面就能互相接触。你不再是在一个平面上玩,而是在一个真实的圆柱体上。第二种方法是坚持使用平面地图,并使用搬运或复制地图单元来进行循环工作。大多数游戏使用第二种方法,我们也将如此。
你是否想要一个包裹地图取决于你是想要一个局部的还是行星的比例。我们可以通过使循环可选来支持这两种方法。添加一个新的单选框到Create new Map菜单中,默认选择循环。
新的地图菜单与循环选项
在NewMapMenu中添加一个字段来跟踪这个选项,并添加一个方法来更改它。让新的单选框在该方法的状态发生更改时调用该方法。
bool wrapping = true;
…
public void ToggleWrapping (bool toggle) {
wrapping = toggle;
}
当创建一个新的地图时,传递是否需要包裹地图的信息。
void CreateMap (int x, int z) {
if (generateMaps) {
mapGenerator.GenerateMap(x, z, wrapping);
}
else {
hexGrid.CreateMap(x, z, wrapping);
}
HexMapCamera.ValidatePosition();
Close();
}
调整HexMapGenerator.GenerateMap让它接受这个新参数,然后将其传递给HexGrid.CreateMap。
public void GenerateMap (int x, int z, bool wrapping) {
…
grid.CreateMap(x, z, wrapping);
…
}
HexGrid应该知道它当前是否正在循环,因此为它指定一个字段,并让CreateMap设置它。其他类将需要根据网格是否循环更改它们的逻辑,因此将字段公开。这也使得可以通过检查器设置默认值。
public int cellCountX = 20, cellCountZ = 15;
public bool wrapping;
…
public bool CreateMap (int x, int z, bool wrapping) {
…
cellCountX = x;
cellCountZ = z;
this.wrapping = wrapping;
…
}
HexGrid在两个地方调用了CreateMap。我们可以使用它自己的字段来给参数赋值。
void Awake () {
…
CreateMap(cellCountX, cellCountZ, wrapping);
}
…
public void Load (BinaryReader reader, int header) {
…
if (x != cellCountX || z != cellCountZ) {
if (!CreateMap(x, z, wrapping)) {
return;
}
}
…
}
网格循环切换,默认启用。
因为循环是根据每个地图定义的,所以也应该保存和加载它。这意味着我们必须调整保存文件的格式,因此在SaveLoadMenu中增加版本常量。
const int mapFileVersion = 5;
保存时,让HexGrid简单地在维度维度之后编写循环的布尔值。
public void Save (BinaryWriter writer) {
writer.Write(cellCountX);
writer.Write(cellCountZ);
writer.Write(wrapping);
…
}
当加载时,只在需要修改文件版本时读取。如果没有,我们有旧的地图,所以它不会自动换行。将此信息存储在本地变量中,并将其与正确的循环状态进行比较。如果不同,我们就不能重用现有的映射拓扑,就像加载不同的维度一样。
public void Load (BinaryReader reader, int header) {
ClearPath();
ClearUnits();
int x = 20, z = 15;
if (header >= 1) {
x = reader.ReadInt32();
z = reader.ReadInt32();
}
bool wrapping = header >= 5 ? reader.ReadBoolean() : false;
if (x != cellCountX || z != cellCountZ || this.wrapping != wrapping) {
if (!CreateMap(x, z, wrapping)) {
return;
}
}
…
}
循环地图需要对逻辑进行相当多的更改,例如在计算距离时。这可能会影响到没有直接引用网格的代码。不要总是将此信息作为参数传递,让我们将其添加到HexMetrics中。引入一个静态整数,该整数包含与地图宽度匹配的循环大小。如果它大于0,那么我们有一个循环地图。添加一个方便的属性来检查这一点。
public static int wrapSize;
public static bool Wrapping {
get {
return wrapSize > 0;
}
}
每次调用HexGrid.CreateMap时,我们都要设置wrap大小。
public bool CreateMap (int x, int z, bool wrapping) {
…
this.wrapping = wrapping;
HexMetrics.wrapSize = wrapping ? cellCountX : 0;
…
}
由于此数据在播放模式下无法重新编译,因此也将其设置为OnEnable。
void OnEnable () {
if (!HexMetrics.noiseSource) {
HexMetrics.noiseSource = noiseSource;
HexMetrics.InitializeHashGrid(seed);
HexUnit.unitPrefab = unitPrefab;
HexMetrics.wrapSize = wrapping ? cellCountX : 0;
ResetVisibility();
}
}
当处理循环地图时,我们会处理很多关于X维上的位置,以单元格宽度度量。我们可以使用HexMetrics.innerRadius * 2f,这很方便,如果我们不需要一直做乘法。让我们添加一个额外的不变常量HexMetrics.innerDiameter。
public const float innerRadius = outerRadius * outerToInner;
public const float innerDiameter = innerRadius * 2f;
我们已经可以在三个地方使用直径。首先,在HexGrid.CreateCell中定位新单元格时。
void CreateCell (int x, int z, int i) {
Vector3 position;
position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter;
…
}
其次,在HexMapCamera中,当移动相机的位置时。
Vector3 ClampPosition (Vector3 position) {
float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter;
position.x = Mathf.Clamp(position.x, 0f, xMax);
…
}
在地图坐标中,从一个世界坐标转换到地图坐标。
public static HexCoordinates FromPosition (Vector3 position) {
float x = position.x / HexMetrics.innerDiameter;
…
}
unitypackage
当地图不循环时,它有一个定义良好的东西边缘,因此也有一个定义良好的水平中心。这和循环地图的情况不一样。它没有东西边缘,所以也没有中心。或者,我们可以说,中心是摄像机所在的地方。这很有用,因为我们希望地图总是以我们的视角为中心。那么,无论我们在哪里,我们永远看不到地图的东或西边缘。
为了使地图可视化集中在摄像机上,我们必须根据摄像机的移动改变物体的位置。如果它向西移动,我们就必须把现在东边最远的东西搬到最西边。相反的方向也是一样的。
理想情况下,当摄像机移动到相邻的单元的列时,我们立即将最远的单元列移植到另一侧。然而,我们不需要如此精确。相反,我们可以移植整个地图块。这允许我们移动地图的部分,而不需要改变任何网格。
由于我们将同时移动整个块的列,让我们通过为每个组创建列父对象来对它们进行分组。将这些对象的数组添加到 HexGrid并在CreateChunks中初始化它。我们只将它们用作容器,因此只需要跟踪它们的 Transform
组件的引用。就像块一样,它们的初始位置都在网格的局部原点。
Transform[] columns;
…
void CreateChunks () {
columns = new Transform[chunkCountX];
for (int x = 0; x < chunkCountX; x++) {
columns[x] = new GameObject("Column").transform;
columns[x].SetParent(transform, false);
}
…
}
块现在应该成为适当的列的子列,而不是网格的子对象。
void CreateChunks () {
…
chunks = new HexGridChunk[chunkCountX * chunkCountZ];
for (int z = 0, i = 0; z < chunkCountZ; z++) {
for (int x = 0; x < chunkCountX; x++) {
HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab);
chunk.transform.SetParent(columns[x], false);
}
}
}
分组成列的块
因为所有的块现在都是列的子元素,我们可以直接销毁所有的列,而不是CreateMap中的块。这也将消除作为子节点的块。
public bool CreateMap (int x, int z, bool wrapping) {
…
if (columns != null) {
for (int i = 0; i < columns.Length; i++) {
Destroy(columns[i].gameObject);
}
}
…
}
向HexGrid添加一个新的CenterMap
方法,参数为X坐标。将位置转换为列索引,方法是将其除以以单元为单位的块宽度。这是摄像机当前所在列的索引,这意味着它应该是地图的中心列。
public void CenterMap (float xPosition) {
int centerColumnIndex = (int)
(xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX));
}
我们只需要在中心列索引更改时调整地图结构。让我们在用一个字段来追踪它。当创建一个地图时使用−1作为一个默认值,,所以新地图将总是为中心的。
int currentCenterColumnIndex = -1;
…
public bool CreateMap (int x, int z, bool wrapping) {
…
this.wrapping = wrapping;
currentCenterColumnIndex = -1;
…
}
…
public void CenterMap (float xPosition) {
int centerColumnIndex = (int)
(xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX));
if (centerColumnIndex == currentCenterColumnIndex) {
return;
}
currentCenterColumnIndex = centerColumnIndex;
}
现在我们知道了中心列的索引,我们也可以通过简单地减去和添加一半的列来确定最小和最大索引。当我们使用整数时,这在列数为奇数的情况下非常有效。在偶数的情况下,不可能有一个完全居中的列,因此其中一个指标的距离就会太远。这会导致指向最远地图边缘方向的单列偏差,但这不是问题。
currentCenterColumnIndex = centerColumnIndex;
int minColumnIndex = centerColumnIndex - chunkCountX / 2;
int maxColumnIndex = centerColumnIndex + chunkCountX / 2;
注意,这些索引可以是负的,也可以大于自然最大列索引。当相机靠近地图的自然中心时,最小值为零。我们的工作是移动列,使它们与这些相对指标对齐。我们通过调整循环中每一列的局部X坐标来实现这一点。
int minColumnIndex = centerColumnIndex - chunkCountX / 2;
int maxColumnIndex = centerColumnIndex + chunkCountX / 2;
Vector3 position;
position.y = position.z = 0f;
for (int i = 0; i < columns.Length; i++) {
position.x = 0f;
columns[i].localPosition = position;
}
对于每一列,检查其索引是否小于最小索引。如果是的话,它离中心的左边太远了。它必须传送到地图的另一边。这是通过使它的X坐标等于地图宽度来实现的。同样地,如果列的索引大于最大索引,那么它就离中心的右边太远了,必须被传送到另一个方向。
for (int i = 0; i < columns.Length; i++) {
if (i < minColumnIndex) {
position.x = chunkCountX *
(HexMetrics.innerDiameter * HexMetrics.chunkSizeX);
}
else if (i > maxColumnIndex) {
position.x = chunkCountX *
-(HexMetrics.innerDiameter * HexMetrics.chunkSizeX);
}
else {
position.x = 0f;
}
columns[i].localPosition = position;
}
改变HexMapCamera.AdjustPosition中在处理地图循环时,它调用WrapPosition替换调用ClampPosition。最初,只需使新的WrapPosition方法复制ClampPosition,惟一的区别是它在最后调用CenterMap。
void AdjustPosition (float xDelta, float zDelta) {
…
transform.localPosition =
grid.wrapping ? WrapPosition(position) : ClampPosition(position);
}
…
Vector3 WrapPosition (Vector3 position) {
float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter;
position.x = Mathf.Clamp(position.x, 0f, xMax);
float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius);
position.z = Mathf.Clamp(position.z, 0f, zMax);
grid.CenterMap(position.x);
return position;
}
要确保地图立即开始居中,请在OnEnable中调用ValidatePosition。
void OnEnable () {
instance = this;
ValidatePosition();
}
https://giant.gfycat.com/GlumSomberConey.webm
以地图为中心左右移动
当我们还在夹持相机的移动时,地图现在试图保持在相机的中心,根据需要传送数据块列。这在使用小地图和放大视图时是很明显的,但是在大地图上传送块是在摄像机的视野之外的。地图最初的东西边缘之所以明显,只是因为它们之间还没有三角连接。
若要将相机跨行移动,请移除WrapPosition中对相机X坐标的校正。相反,当X小于0时,继续加上地图宽度,当X大于地图宽度时,继续减少地图宽度。
Vector3 WrapPosition (Vector3 position) {
// float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter;
// position.x = Mathf.Clamp(position.x, 0f, xMax);
float width = grid.cellCountX * HexMetrics.innerDiameter;
while (position.x < 0f) {
position.x += width;
}
while (position.x > width) {
position.x -= width;
}
float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius);
position.z = Mathf.Clamp(position.z, 0f, zMax);
grid.CenterMap(position.x);
return position;
}
https://gfycat.com/ifr/LinearMistyFlea
摄像机跨列在地图上移动
除了三角间隙外,摄像机的传送在游戏视图中应该是不可见的。然而,当这种情况发生时,一半的地形和水域会发生视觉上的变化。这是因为我们使用世界位置来采样这些纹理。突然间传送一个块会改变纹理对齐。
我们可以通过确保纹理以块大小的倍数平铺来解决这个问题。块大小来自HexMetrics中的常量,因此让我们创建一个HexMetrics.cginc的shader包含文件,并把相关的定义放在那里。基础比例尺由块大小和外单元半径确定。如果您使用了不同的度量标准,您还必须调整这个文件。
#define OUTER_TO_INNER 0.866025404
#define OUTER_RADIUS 10
#define CHUNK_SIZE_X 5
#define TILING_SCALE (1 / (CHUNK_SIZE_X * 2 * OUTER_RADIUS / OUTER_TO_INNER))
这导致平铺比例为0.00866025404。如果我们使用它的整数倍,纹理不会受到块传送的影响。而且,一旦我们正确地三角化它们的连接,东西边缘贴图边缘的纹理将无缝对齐。
我们在Terrain 着色器中使用了0.02的UV标尺。我们可以用两倍的平铺比例尺,也就是0.01732050808。它比以前小了一点,把纹理放大了一点,但在视觉上没有明显的变化。
#include "../HexMetrics.cginc"
#include "../HexCellData.cginc"
…
float4 GetTerrainColor (Input IN, int index) {
float3 uvw = float3(
IN.worldPos.xz * (2 * TILING_SCALE),
IN.terrain[index]
);
…
}
我们在Roads的 Shader中使用0.025的噪音uv。我们可以用三倍的平铺比例尺来代替,0.02598076212是一个比较接近的比例。
#include "HexMetrics.cginc"
#include "HexCellData.cginc"
…
void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
float4 noise =
tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE));
…
}
最后,在Water.cginc中,我们用了0。015的泡沫和0。025的波。同样,我们可以用两倍和三倍的平铺比例尺来代替。
#include "HexMetrics.cginc"
float Foam (float shore, float2 worldXZ, sampler2D noiseTex) {
shore = sqrt(shore) * 0.9;
float2 noiseUV = worldXZ + _Time.y * 0.25;
float4 noise = tex2D(noiseTex, noiseUV * (2 * TILING_SCALE));
…
}
…
float Waves (float2 worldXZ, sampler2D noiseTex) {
float2 uv1 = worldXZ;
uv1.y += _Time.y;
float4 noise1 = tex2D(noiseTex, uv1 * (3 * TILING_SCALE));
float2 uv2 = worldXZ;
uv2.x += _Time.y;
float4 noise2 = tex2D(noiseTex, uv2 * (3 * TILING_SCALE));
…
}
unitypackage
在这一点上,我们循环地图的唯一视觉线索是最东端和最西端之间的小差距。这个缺口的存在是因为我们目前还没有对非循环地图的相对边角之间的连接进行三角化。
边缘的间隙
为了三角化东西列之间的连接,我们必须使地图两边的细胞彼此相邻。我们现在不这么做,因为在HexGrid.CreateCell中我们只在前一个单元格的X指数大于0时才与它建立E-W关系。要跨越此关系,还必须在启用循环时将行的最后一个单元格与同一行的第一个单元格连接起来。
void CreateCell (int x, int z, int i) {
…
if (x > 0) {
cell.SetNeighbor(HexDirection.W, cells[i - 1]);
if (wrapping && x == cellCountX - 1) {
cell.SetNeighbor(HexDirection.E, cells[i - x]);
}
}
…
}
随着E-W邻居关系的建立,我们现在得到了部分三角剖分。边缘连接并不完美,因为微扰不能正确地平铺。我们待会再处理。
E-W的连接
我们还必须连接NE-SW关系。我们可以将每一行的第一个单元格与前一行的最后一个单元格连接起来。这只是前一个单元格。
if (z > 0) {
if ((z & 1) == 0) {
cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX]);
if (x > 0) {
cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX - 1]);
}
else if (wrapping) {
cell.SetNeighbor(HexDirection.SW, cells[i - 1]);
}
}
else {
…
}
}
NE-NW的连接
最后,在第一行之后的每一行的末尾建立封装的SE-NW连接。这些单元格将连接到前一行的第一个单元格。
if (z > 0) {
if ((z & 1) == 0) {
…
}
else {
cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX]);
if (x < cellCountX - 1) {
cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX + 1]);
}
else if (wrapping) {
cell.SetNeighbor(
HexDirection.SE, cells[i - cellCountX * 2 + 1]
);
}
}
}
SE-NW的连接
为了使间隙完美,我们必须确保用于干扰顶点位置的噪声与地图的东西边缘匹配。我们可以使用与着色器相同的技巧,但是我们用于微扰的噪声刻度是0。003。我们必须把它放大,使它平铺,这会使微扰更加不稳定。
另一种方法不是平铺噪声,而是交叉淡化地图边缘的噪声。如果我们在单个单元格的宽度上交叉渐变,那么微扰将平滑地过渡,没有间断。这个区域的噪声会稍微平滑一些,从远处看变化会很突然,但是对于一个小的顶点扰动来说,变化就不明显了。
温度抖动呢?
在生成地图时,我们还使用了相同的噪声抖动来修改温度。这种突然的交叉褪色在这里会更加明显,但只有在使用强烈抖动时才会如此。由于抖动只是为了增加一些细微的变化,所以这个限制是可以接受的。如果你想要强烈的抖动,你必须在更大的距离内交叉渐变。
如果我们不循环地图,我们可以用 HexMetrics.SampleNoise
取一个样本。但是在东西交界处,我们必须添加交叉淡出。因此,在返回样本之前,将它存储在一个变量中。
public static Vector4 SampleNoise (Vector3 position) {
Vector4 sample = noiseSource.GetPixelBilinear(
position.x * noiseScale,
position.z * noiseScale
);
return sample;
}
循环时,我们需要第二个样品来混合。我们将在地图的东侧进行转换,因此第二个样本必须在西侧进行。
Vector4 sample = noiseSource.GetPixelBilinear(
position.x * noiseScale,
position.z * noiseScale
);
if (Wrapping && position.x < innerDiameter) {
Vector4 sample2 = noiseSource.GetPixelBilinear(
(position.x + wrapSize * innerDiameter) * noiseScale,
position.z * noiseScale
);
}
交叉渐变是通过一个简单的线性插值完成的,从西到东,跨越一个单元格的宽度。
if (Wrapping && position.x < innerDiameter) {
Vector4 sample2 = noiseSource.GetPixelBilinear(
(position.x + wrapSize * innerDiameter) * noiseScale,
position.z * noiseScale
);
sample = Vector4.Lerp(
sample2, sample, position.x * (1f / innerDiameter)
);
}
混合噪声微扰,不完美
结果并不完全相符。这是因为东边的部分单元格有负的X坐标。为了远离这个区域,让我们将过渡区域向西移动半个单元格的宽度。
if (Wrapping && position.x < innerDiameter * 1.5f) {
Vector4 sample2 = noiseSource.GetPixelBilinear(
(position.x + wrapSize * innerDiameter) * noiseScale,
position.z * noiseScale
);
sample = Vector4.Lerp(
sample2, sample, position.x * (1f / innerDiameter) - 0.5f
);
}
正确的交叉淡出
现在看起来我们已经有了正确的三角剖分,让我们确保我们可以编辑地图上的任何地方和整个环绕缝。事实证明,在传送块上的坐标是错误的,较大的笔刷会被接缝切断。
刷子被切断了
为了解决这个问题,我们必须让HexCoordinates知道循环地图。我们可以通过在构造函数方法中验证X坐标来实现这一点。我们知道轴向X坐标是由X坐标减去Z坐标的一半得到的。我们可以用这个知识来转换回来,并检查偏移坐标是否小于零。如果是这样,我们有一个坐标超出了未循环的地图的东侧。当我们在每个方向上传送最多一半的贴图时,我们只需向X添加一次循环的宽度就足够了。当偏移坐标大于循环的宽度时,我们需要做减法。
public HexCoordinates (int x, int z) {
if (HexMetrics.Wrapping) {
int oX = x + z / 2;
if (oX < 0) {
x += HexMetrics.wrapSize;
}
else if (oX >= HexMetrics.wrapSize) {
x -= HexMetrics.wrapSize;
}
}
this.x = x;
this.z = z;
}
有时我在编辑地图的底部或顶部时会出错?
由于受到干扰,当游标位于地图之外的单元格行中时,就会发生这种情况。这是错误,因为我们没有验证HexGrid.GetCellwith中一个向量参数的坐标。修复方法依赖于以坐标为参数的GetCell方法,该方法执行所需的检查。
public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position); // int index = // coordinates.X + coordinates.Z * cellCountX + coordinates.Z / 2; // return cells[index]; return GetCell(coordinates); }
三角剖分在地形上效果很好,但东西方向的水岸边缘似乎缺失了。它们实际上并没有丢失,但也没有循环。它们翻转并延伸到了地图的另一边。
丢失的海岸线
这是因为我们在三角化岸边的水时使用了邻居的位置。为了解决这个问题,我们必须检测到我们正在处理的邻居在地图的另一边。为了简单起见,我们将为单元格添加一个列的索引属性到HexCell。
public int ColumnIndex { get; set; }
在HexGrid.CreateCell中分配这个索引。它等于X偏移坐标除以块大小。
void CreateCell (int x, int z, int i) {
…
cell.Index = i;
cell.ColumnIndex = x / HexMetrics.chunkSizeX;
…
}
现在我们可以通过比较当前单元格与其相邻单元格的列索引,检测到我们在HexGridChunk.TriangulateWaterShore中的坐标循环。如果邻居的列指数比它小了不止一步,那么我们在西边而邻居在东边。所以我们必须把邻居循环到西边来。反之亦然。
Vector3 center2 = neighbor.Position;
if (neighbor.ColumnIndex < cell.ColumnIndex - 1) {
center2.x += HexMetrics.wrapSize * HexMetrics.innerDiameter;
}
else if (neighbor.ColumnIndex > cell.ColumnIndex + 1) {
center2.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter;
}
海岸的边缘,但是角落还没有处理
这样可以处理海岸的边缘,但还不能处理角落。我们也必须对下一个邻居做同样的事情。
if (nextNeighbor != null) {
Vector3 center3 = nextNeighbor.Position;
if (nextNeighbor.ColumnIndex < cell.ColumnIndex - 1) {
center3.x += HexMetrics.wrapSize * HexMetrics.innerDiameter;
}
else if (nextNeighbor.ColumnIndex > cell.ColumnIndex + 1) {
center3.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter;
}
Vector3 v3 = center3 + (nextNeighbor.IsUnderwater ?
HexMetrics.GetFirstWaterCorner(direction.Previous()) :
HexMetrics.GetFirstSolidCorner(direction.Previous()));
…
}
正确的跨列的海岸线
地图东侧和西侧是否连通也影响地图生成。当地图进行循环时,生成算法也进行循环。这将产生不同的地图,但是在地图X轴边界非零时,循环并不明显。
默认地图种子1208905299,有和没有循环的地图
当循环时,使用Map Border x是没有意义的,但是我们不能直接去掉它,因为这会合并区域。在循环时,我们可以只使用区域边界。
通过在所有情况下用borderX替换mapBorderX来调整HexMapGenerator.CreateRegions。这个新变量将等于regionBorder或mapBorderX,这取决于地图是否被循环。我只展示了对下面第一种情况的更改。
int borderX = grid.wrapping ? regionBorder : mapBorderX;
MapRegion region;
switch (regionCount) {
default:
region.xMin = borderX;
region.xMax = grid.cellCountX - borderX;
region.zMin = mapBorderZ;
region.zMax = grid.cellCountZ - mapBorderZ;
regions.Add(region);
break;
…
}
这保持了区域分离,但这只在地图东西两侧有不同区域时才有必要。有两种情况不是这样。首先,当只有一个区域时。第二,当有两个区域水平分割地图时。在这种情况下,我们可以将borderX设置为零,允许陆块穿过东西接缝。
switch (regionCount) {
default:
if (grid.wrapping) {
borderX = 0;
}
region.xMin = borderX;
region.xMax = grid.cellCountX - borderX;
region.zMin = mapBorderZ;
region.zMax = grid.cellCountZ - mapBorderZ;
regions.Add(region);
break;
case 2:
if (Random.value < 0.5f) {
…
}
else {
if (grid.wrapping) {
borderX = 0;
}
region.xMin = borderX;
region.xMax = grid.cellCountX - borderX;
region.zMin = mapBorderZ;
region.zMax = grid.cellCountZ / 2 - regionBorder;
regions.Add(region);
region.zMin = grid.cellCountZ / 2 + regionBorder;
region.zMax = grid.cellCountZ - mapBorderZ;
regions.Add(region);
}
break;
…
}
单独的区域循环的地图
乍一看,这似乎很好,但实际上有一个不连续沿缝。当设置侵蚀百分比为零时,这一点变得更加明显。
使用无侵蚀的地形的接缝
由于裂缝阻碍了地形块的生长,导致了断层的出现。单元格到块中心的距离用于确定先添加哪些单元格,而地图另一侧的单元格则非常远,因此它们几乎永远不会被包括在内。这当然是不正确的。我们要让HexCoordinates.DistanceTo感知到循环地图。
我们通过将三个轴上的绝对距离相加并将结果减半来计算六坐标之间的距离。Z距离总是正确的,但是X和Y距离会受到循环的影响。我们先单独计算X+Y。
public int DistanceTo (HexCoordinates other) {
// return
// ((x < other.x ? other.x - x : x - other.x) +
// (Y < other.Y ? other.Y - Y : Y - other.Y) +
// (z < other.z ? other.z - z : z - other.z)) / 2;
int xy =
(x < other.x ? other.x - x : x - other.x) +
(Y < other.Y ? other.Y - Y : Y - other.Y);
return (xy + (z < other.z ? other.z - z : z - other.z)) / 2;
}
确定对任意单元格进行循环是否会产生更小的距离并不简单,因此,让我们简单地计算将另一个坐标循环到西侧时的X+Y。如果它小于原来的X+Y,就用它。
int xy =
(x < other.x ? other.x - x : x - other.x) +
(Y < other.Y ? other.Y - Y : Y - other.Y);
if (HexMetrics.Wrapping) {
other.x += HexMetrics.wrapSize;
int xyWrapped =
(x < other.x ? other.x - x : x - other.x) +
(Y < other.Y ? other.Y - Y : Y - other.Y);
if (xyWrapped < xy) {
xy = xyWrapped;
}
}
如果这没有获得更短的距离,那么可能在另一个方向的循环更短,所以检查一下。
if (HexMetrics.Wrapping) {
other.x += HexMetrics.wrapSize;
int xyWrapped =
(x < other.x ? other.x - x : x - other.x) +
(Y < other.Y ? other.Y - Y : Y - other.Y);
if (xyWrapped < xy) {
xy = xyWrapped;
}
else {
other.x -= 2 * HexMetrics.wrapSize;
xyWrapped =
(x < other.x ? other.x - x : x - other.x) +
(Y < other.Y ? other.Y - Y : Y - other.Y);
if (xyWrapped < xy) {
xy = xyWrapped;
}
}
}
现在我们总是以循环地图上获得最短的距离。地形块不再被缝隙阻挡,使得陆地块可以循环。
正确的循环地图,没有和有侵蚀的地图
unitypackage
现在已经介绍了地图生成和三角化剖分,剩下的就是检查单元、探索和可见性。
当我们试图移动一个单位到世界各地时遇到的第一个障碍是地图的边缘无法探测。
地图缝隙无法被探索
沿着地图边缘的单元格是不可探测的,被突然隐藏了的地图的边缘。但是当地图循环时,只需要标记南北单元格,而不需要标记东单元格和西单元格。调整HexGrid.CreateCell让它考虑到这一点。
if (wrapping) {
cell.Explorable = z > 0 && z < cellCountZ - 1;
}
else {
cell.Explorable =
x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1;
}
接下来,让我们检查可见性是否在缝隙中正确工作。它只针对地形,不针对地形特征。看起来循环可见性和未循环的最后一个单元的可见性一样。
不正确的可见性功能
这是因为HexCellShaderData使用的纹理的循环模式被设置为拉伸模式(clamp)。解决方案就是简单地将其拉伸模式(clamp)设置为重复模式(repeat)。但我们只需要对U坐标这样做,所以在Initialize中分别设置wrapModeU和wrapModeV。
public void Initialize (int x, int z) {
if (cellTexture) {
cellTexture.Resize(x, z);
}
else {
cellTexture = new Texture2D(
x, z, TextureFormat.RGBA32, false, true
);
cellTexture.filterMode = FilterMode.Point;
// cellTexture.wrapMode = TextureWrapMode.Clamp;
cellTexture.wrapModeU = TextureWrapMode.Repeat;
cellTexture.wrapModeV = TextureWrapMode.Clamp;
Shader.SetGlobalTexture("_HexCellData", cellTexture);
}
…
}
另一个问题是,单位目前没有循环。当它们所在的列被重新定位时,它们会保持原来的位置。
单位没有循环,是在错误的一边
这可以通过使列的单位子列来解决,就像块一样。首先,不再使它们在HexGrid.AddUnit中成为网格的一个子元素。
public void AddUnit (HexUnit unit, HexCell location, float orientation) {
units.Add(unit);
unit.Grid = this;
// unit.transform.SetParent(transform, false);
unit.Location = location;
unit.Orientation = orientation;
}
因为单元移动,它们可能会在不同的列中结束,这意味着我们必须改变它们的父元素。为了实现这一点,可以向HexGrid添加一个公共MakeChildOfColumn方法,将子的Transform组件和列索引作为参数。
public void MakeChildOfColumn (Transform child, int columnIndex) {
child.SetParent(columns[columnIndex], false);
}
在HexUnit中调用此方法。设置Location属性。
public HexCell Location {
…
set {
…
Grid.MakeChildOfColumn(transform, value.ColumnIndex);
}
}
它负责单位的创建。我们还必须确保它们在移动时移动到正确的列。这要求我们跟踪HexUnit.TravelPath中的当前列索引。在这个方法的开始,它是路径开始处单元格的列索引,或者是当前单元格的列索引(如果运行被重新编译中断)。
IEnumerator TravelPath () {
Vector3 a, b, c = pathToTravel[0].Position;
yield return LookAt(pathToTravel[1].Position);
// Grid.DecreaseVisibility(
// currentTravelLocation ? currentTravelLocation : pathToTravel[0],
// VisionRange
// );
if (!currentTravelLocation) {
currentTravelLocation = pathToTravel[0];
}
Grid.DecreaseVisibility(currentTravelLocation, VisionRange);
int currentColumn = currentTravelLocation.ColumnIndex;
…
}
在每次迭代过程中,检查下一个列索引是否不同,如果不同,则调整单元的父索引。
int currentColumn = currentTravelLocation.ColumnIndex;
float t = Time.deltaTime * travelSpeed;
for (int i = 1; i < pathToTravel.Count; i++) {
…
Grid.IncreaseVisibility(pathToTravel[i], VisionRange);
int nextColumn = currentTravelLocation.ColumnIndex;
if (currentColumn != nextColumn) {
Grid.MakeChildOfColumn(transform, nextColumn);
currentColumn = nextColumn;
}
…
}
这使得单位循环就像块一样。然而,当移动到地图接缝时,单位还没有循环。相反,它们突然朝错误的方向移动。无论接缝位于何处,这种情况都会发生,当它们在整个地图上快速移动时,这种情况最为显著。
https://thumbs.gfycat.com/DimBreakableIchthyostega-mobile.mp4
在地图上跳跃
在这里我们可以用和在海岸上一样的方法,只是这次我们把单位沿着的路线循环起来。当下一列循环到东方时,我们也将路线传送到东方,同样的,对于其他方向也是如此。我们需要调整关系到c控制点的路线上的a和b控制点。
for (int i = 1; i < pathToTravel.Count; i++) {
currentTravelLocation = pathToTravel[i];
a = c;
b = pathToTravel[i - 1].Position;
// c = (b + currentTravelLocation.Position) * 0.5f;
// Grid.IncreaseVisibility(pathToTravel[i], VisionRange);
int nextColumn = currentTravelLocation.ColumnIndex;
if (currentColumn != nextColumn) {
if (nextColumn < currentColumn - 1) {
a.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize;
b.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize;
}
else if (nextColumn > currentColumn + 1) {
a.x += HexMetrics.innerDiameter * HexMetrics.wrapSize;
b.x += HexMetrics.innerDiameter * HexMetrics.wrapSize;
}
Grid.MakeChildOfColumn(transform, nextColumn);
currentColumn = nextColumn;
}
c = (b + currentTravelLocation.Position) * 0.5f;
Grid.IncreaseVisibility(pathToTravel[i], VisionRange);
…
}
https://thumbs.gfycat.com/FlatBonyIchthyosaurs-mobile.mp4
循环的运动
最后我们要调整的是单位面对它要到达的第一个单元格时的初始旋转角度。如果这个单元格恰好位于东西缝的另一边,那么这个单元格的方向就错了。
当地图循环时,有两种方法可以查看不直接朝北或朝南的点。你可以向东看,也可以向西看。看与点最近距离相匹配的方向是有意义的,因为这也是运动方向,所以我们看一下。
循环时,检查X轴上的相对距离。如果它小于地图宽度的负一半,那么我们应该向西看,这是通过将该点向西循环来完成的。否则,如果距离大于地图宽度的一半,那么我们应该向东绕。
IEnumerator LookAt (Vector3 point) {
if (HexMetrics.Wrapping) {
float xDistance = point.x - transform.localPosition.x;
if (xDistance < -HexMetrics.innerRadius * HexMetrics.wrapSize) {
point.x += HexMetrics.innerDiameter * HexMetrics.wrapSize;
}
else if (xDistance > HexMetrics.innerRadius * HexMetrics.wrapSize) {
point.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize;
}
}
…
}
现在我们有了一个功能齐全的循环地图。这也结束了六边形地图系列。前面的一些教程中,涵盖了更多的主题,但是它们不是特定于针对六边形地图的。我可能会在以后的系列文章中介绍它们。享受你的地图!
我下载了最后一个包,在播放模式下得到旋转错误?
这是因为相机使用自定义旋转轴。你需要加上这个轴。有关详细信息,请参见第5部分,大图。
我下载了最后一个包,得到的图形比截图还难看?
我将项目设置为使用线性颜色空间。Gamme space让它更亮。
我下载了最后一个包,总是生成相同的地图?
生成器被设置为始终使用相同的固定种子1208905299,这是用于大多数屏幕截图的种子。禁用使用固定种子使其随机。
项目工程文件下载地址:unitypackage
项目文档下载地址:PDF