Unity中的六边形图:保存和加载,纹理,距离

第1-3部分:网格,颜色和像元高度

第4-7部分:颠簸,河流和道路

第8-11部分:水,地貌和城墙

第12-15部分:保存和加载,纹理,距离

第16-19部分:找到道路,队员,动画

第20-23部分:战争迷雾,地图研究,程序生成

第24-27部分:水循环,侵蚀,生物群落,圆柱图

第12部分:保存和加载


  • 跟踪地形的类型,而不是颜色。
  • 创建一个文件。
  • 我们将数据写入文件,然后读取它。
  • 我们序列化单元格数据。
  • 减小文件大小。

我们已经知道如何创建非常有趣的地图。 现在,您需要学习如何保存它们。


test.map文件加载。

地形类型


保存地图时,我们不需要存储在应用程序执行过程中跟踪的所有数据。 例如,我们只需要记住像元高度级别。 它的垂直位置本身就是从此数据中获取的,因此您无需存储它。 实际上,最好不要存储这些计算得出的指标。 因此,即使以后我们决定更改高度偏移,地图数据也将保持正确。 数据与其表示是分开的。

同样,我们不需要存储单元格的确切颜色。 您可以写出该单元格为绿色。 但是,确切的绿色阴影会随着视觉样式的改变而改变。 为此,我们可以保存颜色索引,而不是颜色本身。 实际上,对于我们来说,在运行时在单元格中存储此索引而不是真实颜色可能就足够了。 这将允许以后继续进行浮雕的更复杂的可视化。

移动颜色阵列


如果单元格不再具有颜色数据,则应将其存储在其他位置。 将其存储在HexMetrics最方便。 因此,让我们为其添加颜色数组。

  public static Color[] colors; 

像所有其他全局数据(例如噪声)一样,我们可以使用HexGrid初始化这些颜色。

  public Color[] colors; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; … } … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; } } 

并且由于现在我们不直接将颜色分配给单元格,因此我们将摆脱默认颜色。

 // public Color defaultColor = Color.white; … void CreateCell (int x, int z, int i) { … HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab); cell.transform.localPosition = position; cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z); // cell.Color = defaultColor; … } 

设置新颜色以匹配六边形图编辑器的常规数组。


将颜色添加到网格。

单元重构


HexCell删除颜色字段。 相反,我们将存储索引。 代替颜色索引,我们使用更通用的浮雕类型索引。

 // Color color; int terrainTypeIndex; 

color属性只能使用此索引来获取相应的颜色。 现在未直接设置,因此删除此部分。 在这种情况下,我们会收到一个编译错误,我们将尽快修复。

  public Color Color { get { return HexMetrics.colors[terrainTypeIndex]; } // set { // … // } } 

添加新属性以获取并设置新的高程类型索引。

  public int TerrainTypeIndex { get { return terrainTypeIndex; } set { if (terrainTypeIndex != value) { terrainTypeIndex = value; Refresh(); } } } 

编辑器重构


HexMapEditor内部HexMapEditor删除所有与颜色有关的代码。 这将修复编译错误。

 // public Color[] colors; … // Color activeColor; … // bool applyColor; … // public void SelectColor (int index) { // applyColor = index >= 0; // if (applyColor) { // activeColor = colors[index]; // } // } … // void Awake () { // SelectColor(0); // } … void EditCell (HexCell cell) { if (cell) { // if (applyColor) { // cell.Color = activeColor; // } … } } 

现在添加一个字段和方法来控制活动高程类型索引。

  int activeTerrainTypeIndex; … public void SetTerrainTypeIndex (int index) { activeTerrainTypeIndex = index; } 

我们使用此方法代替现在缺少的SelectColor方法。 使用SetTerrainTypeIndex连接UI中的颜色小部件,其他所有内容保持不变。 这意味着负索引仍在使用中,意味着颜色不应更改。

更改EditCell以便将高程类型索引分配给正在编辑的像元。

  void EditCell (HexCell cell) { if (cell) { if (activeTerrainTypeIndex >= 0) { cell.TerrainTypeIndex = activeTerrainTypeIndex; } … } } 

尽管我们从单元格中删除了颜色数据,但该贴图应与以前一样工作。 唯一的区别是默认颜色现在是数组中的第一个。 就我而言,它是黄色的。


黄色是新的默认颜色。

统一包装

将数据保存到文件


为了控制地图的保存和加载,我们使用HexMapEditor 。 我们将创建两个方法来执行此操作,现在将它们留空。

  public void Save () { } public void Load () { } 

将两个按钮添加到UI( GameObject / UI / Button )。 将它们连接到按钮并给出适当的标签。 我将它们放在右侧面板的底部。


保存和加载按钮。

档案位置


要存储卡,您需要将其保存在某处。 与大多数游戏一样,我们会将数据存储在文件中。 但是,将此文件放在文件系统中的何处? 答案取决于游戏运行在哪个操作系统上。 每个操作系统都有自己的标准来存储与应用程序相关的文件。

我们不需要知道这些标准。 Unity知道我们可以使用Application.persistentDataPath获得正确的路径。 您可以在“ Save方法中检查它的状态,在控制台中显示它,然后在“播放”模式下按按钮。

  public void Save () { Debug.Log(Application.persistentDataPath); } 

在台式机系统上,路径将包含公司和产品的名称。 编辑器和程序集均使用此路径。 可以在“ 编辑” /“项目设置” /“播放器”中配置名称。


公司和产品名称。

为什么在Mac上找不到“库”文件夹?
文件夹通常是隐藏的。 显示方式取决于OS X的版本。如果您没有旧版本,请在Finder中选择主文件夹,然后转到显示视图选项 。 有一个用于文件夹的复选框。

WebGL呢?
WebGL游戏无法访问用户的文件系统。 而是将所有文件操作都重定向到内存中的文件系统。 她对我们透明。 但是,要保存数据,您将需要手动订购网页以将数据转储到浏览器存储中。

文件创建


要创建文件,我们需要使用System.IO命名空间中的类。 因此,我们在HexMapEditor类上为其添加了using语句。

 using UnityEngine; using UnityEngine.EventSystems; using System.IO; public class HexMapEditor : MonoBehaviour { … } 

首先,我们需要创建文件的完整路径。 我们使用test.map作为文件 。 必须将其添加到存储数据的路径中。 是否需要插入正斜杠或反斜杠(斜杠或反斜杠)取决于平台。 Path.Combine方法将Path.Combine

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); } 

接下来,我们需要在此位置访问文件。 我们使用File.Open方法执行此操作。 由于我们想将数据写入此文件,因此我们需要使用其创建模式。 在这种情况下,将在指定路径上创建一个新文件,或者替换一个现有文件。

  string path = Path.Combine(Application.persistentDataPath, "test.map"); File.Open(path, FileMode.Create); 

调用此方法的结果将是与此文件关联的打开数据流。 我们可以使用它将数据写入文件。 而且,当我们不再需要它时,我们一定不要忘记关闭它。

  string path = Path.Combine(Application.persistentDataPath, "test.map"); Stream fileStream = File.Open(path, FileMode.Create); fileStream.Close(); 

在此阶段,当您单击“ 保存”按钮时,将在指定为存储数据路径的文件夹中创建test.map文件。 如果您研究此文件,它将是空的,大小为0字节,因为到目前为止我们还没有写任何东西。

写入文件


要将数据写入文件,我们需要一种将数据流式传输到文件的方法。 最简单的方法是使用BinaryWriter 。 这些对象使您可以将原始数据写入任何流。

创建一个新的BinaryWriter对象,我们的文件流将作为其参数。 关闭编写器将关闭其使用的流。 因此,我们不再需要存储到流的直接链接。

  string path = Path.Combine(Application.persistentDataPath, "test.map"); BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Close(); 

要将数据传输到流,可以使用BinaryWriter.Write方法。 所有基本类型(例如整数和浮点型)的Write方法都有一个变体。 它还可以记录行。 让我们尝试写整数123。

  BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Write(123); writer.Close(); 

单击保存按钮, 然后再次检查test.map 。 现在,它的大小为4个字节,因为整数大小为4个字节。

为什么我的文件管理器显示文件占用更多空间?
因为文件系统将空间分成字节块。 它们不跟踪单个字节。 到目前为止,由于test.map仅占用四个字节,因此它需要一块存储空间。

请注意,我们存储二进制数据,而不是人类可读的文本。 因此,如果我们在文本编辑器中打开文件,我们将看到一组模糊的字符。 您可能会看到符号{,后面没有任何空格或一些占位符。

您可以在十六进制编辑器中打开文件。 在这种情况下,我们将看到7b 00 00 00 。 这是整数的四个字节,以十六进制表示法映射。 在普通十进制数中,这是123 0 0 0 。 以二进制形式,第一个字节看起来像01111011

{的ASCII码是123,因此可以在文本编辑器中显示此字符。 ASCII 0是一个空字符,与任何可见字符都不匹配。

剩余的三个字节等于零,因为我们写的数字小于256。如果我们写的是256,则在十六进制编辑器中将看到00 01 00 00

123不应存储为00 00 00 7b吗?
BinaryWriter使用Little-endian格式保存数字。 这意味着最低有效字节将首先被写入。 Microsoft在开发.Net框架时使用了这种格式。 之所以选择它,是因为Intel CPU使用低位字节序格式。

一种替代方法是big-endian,其中最高有效字节先存储在其中。 这对应于数字中通常的数字顺序。 123是123,因为我们指的是大端记录。 如果是小端,则123表示三百二十一。

我们释放资源


关闭作家很重要。 打开文件系统后,文件系统将锁定文件,从而防止其他进程对其进行写入。 如果我们忘记关闭它,我们也会阻止自己。 如果我们两次按保存按钮,则第二次将无法打开流。

无需手动关闭编写器,我们可以为此创建一个using块。 它定义了作者有效的范围。 当可执行代码超出此范围时,将删除编写器并关闭线程。

  using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(123); } // writer.Close(); 

这将起作用,因为writer和文件流类实现了IDisposable接口。 这些对象具有Dispose方法,当它们超出use的范围时会间接调用。

使用的最大好处是,无论程序超出范围如何,它都可以工作。 早期的回报,例外和错误不会打扰他。 另外,他非常简洁。

资料检索


要读取以前写入的数据,我们需要将代码插入Load方法。 与保存时一样,我们需要创建路径并打开文件流。 不同之处在于,现在我们打开文件进行读取,而不是写入。 而不是writer我们需要BinaryReader

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryReader reader = new BinaryReader(File.Open(path, FileMode.Open)) ) { } } 

在这种情况下,我们可以使用File.OpenRead方法打开文件进行读取。

  using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { } 

为什么在编写时不能使用File.OpenWrite?
此方法创建一个流,该流将数据添加到现有文件中,而不是替换它们。

阅读时,我们需要明确指出接收到的数据类型。 要从流中读取整数,我们需要使用BinaryReader.ReadInt32 。 此方法读取一个32位整数,即四个字节。

  using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { Debug.Log(reader.ReadInt32()); } 

应该注意的是,当接收到123时,足以读取一个字节。 但同时,流中将保留属于该整数的三个字节。 此外,这不适用于0-255范围以外的数字。 因此,请不要这样做。

统一包装

写入和读取地图数据


保存数据时,一个重要的问题是是否使用人类可读的格式。 通常,人类可读格式为JSON,XML和具有某种结构的纯ASCII。 可以在文本编辑器中打开,解释和编辑此类文件。 此外,它们简化了不同应用程序之间的数据交换。

但是,此类格式有其自己的要求。 与使用二进制数据相比,文件将占用更多的空间(有时更多)。 从运行时和内存占用两方面来看,它们还可以大大增加编码和解码数据的成本。

相反,二进制数据是紧凑且快速的。 当记录大量数据时,这一点很重要。 例如,在游戏的每个回合中自动保存大地图时。 因此
我们将使用二进制格式。 如果可以处理,则可以使用更详细的格式。

自动序列化呢?
在序列化Unity数据的过程中,我们可以立即将序列化的类直接写入流中。 各个字段的记录详细信息将对我们隐藏。 但是,我们无法直接序列化单元格。 它们是MonoBehaviour类,其中包含我们不需要保存的数据。 因此,我们需要使用一个单独的对象层次结构,这破坏了自动序列化的简单性。 另外,支持将来的代码更改将更加困难。 因此,我们将通过手动序列化来保持完全控制。 此外,这将使我们真正了解正在发生的事情。

要序列化地图,我们需要存储每个单元格的数据。 要保存和加载单个单元格,请将SaveLoad方法添加到HexCell 。 由于它们需要作者或读者才能工作,因此我们将其添加为参数。

 using UnityEngine; using System.IO; public class HexCell : MonoBehaviour { … public void Save (BinaryWriter writer) { } public void Load (BinaryReader reader) { } } 

SaveLoad方法添加到HexGrid 。 这些方法只是通过调用其LoadSave方法来绕过所有单元格。

 using UnityEngine; using UnityEngine.UI; using System.IO; public class HexGrid : MonoBehaviour { … public void Save (BinaryWriter writer) { for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } } public void Load (BinaryReader reader) { for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } } } 

如果我们下载地图,则在更改单元格数据后需要对其进行更新。 为此,只需更新所有片段。

  public void Load (BinaryReader reader) { for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } for (int i = 0; i < chunks.Length; i++) { chunks[i].Refresh(); } } 

最后,我们HexMapEditor的测试代码替换为对网格的SaveLoad方法的调用,并HexMapEditor器或读取器。

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { hexGrid.Save(writer); } } public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { hexGrid.Load(reader); } } 

保存浮雕类型


在当前阶段,重新保存将创建一个空文件,而下载则不执行任何操作。 让我们从仅记录和加载HexCell高程类型索引开始。

将值直接分配给terrainTypeIndex字段。 我们将不使用属性。 由于我们显式更新了所有片段,因此无需调用Refresh属性。 另外,由于我们仅保存正确的地图,因此我们假设所有下载的地图也是正确的。 因此,例如,我们不会检查河流或道路是否允许。

  public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); } 

当保存到该文件时,所有单元的浮雕类型的索引将被依次写入。 由于索引是整数,因此其大小为四个字节。 我的卡包含300个单元,即文件大小为1200个字节。

负载以与写入索引相同的顺序读取索引。 如果在保存后更改了单元格的颜色,则加载贴图会将颜色恢复为保存时的状态。 由于我们不再保存任何内容,因此其余的单元格数据将保持不变。 也就是说,加载将改变地形的类型,但不会改变其高度,水位,地形特征等。

保存所有整数


保存救济类型索引对我们来说还不够。 您需要保存所有其他数据。 让我们从所有整数字段开始。 这是浮雕类型,单元高度,水位,城市水平,农场水平,植被水平和特殊对象的索引。 必须按照记录时的顺序读取它们。

  public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); writer.Write(elevation); writer.Write(waterLevel); writer.Write(urbanLevel); writer.Write(farmLevel); writer.Write(plantLevel); writer.Write(specialIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); waterLevel = reader.ReadInt32(); urbanLevel = reader.ReadInt32(); farmLevel = reader.ReadInt32(); plantLevel = reader.ReadInt32(); specialIndex = reader.ReadInt32(); } 

现在尝试保存和加载地图,在这些操作之间进行更改。 除存储单元的高度外,我们已尽力恢复了存储在存储数据中的所有内容。 发生这种情况的原因是,当您更改高度级别时,需要更新单元格的垂直位置。 可以通过将其分配给属性(而不是字段)(已加载高度的值)来完成。 但是此属性完成了我们不需要的其他工作。 因此,让我们从“ Elevation设置器中提取更新单元格位置的代码,并将其插入到单独的RefreshPosition方法中。 您需要在此处进行的唯一更改是valueelevation字段value引用替换value

  void RefreshPosition () { Vector3 position = transform.localPosition; position.y = elevation * HexMetrics.elevationStep; position.y += (HexMetrics.SampleNoise(position).y * 2f - 1f) * HexMetrics.elevationPerturbStrength; transform.localPosition = position; Vector3 uiPosition = uiRect.localPosition; uiPosition.z = -position.y; uiRect.localPosition = uiPosition; } 

现在,我们可以在设置属性时以及加载高度数据后调用该方法。

  public int Elevation { … set { if (elevation == value) { return; } elevation = value; RefreshPosition(); ValidateRivers(); … } } … public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); RefreshPosition(); … } 

进行此更改后,单元将在加载时正确更改其外观高度。

保存所有数据


单元中是否存在墙和流入/流出的河流存储在布尔字段中。 我们可以简单地将它们写为整数。 此外,道路数据是六个布尔值的数组,我们可以使用循环来编写它们。

  public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); writer.Write(elevation); writer.Write(waterLevel); writer.Write(urbanLevel); writer.Write(farmLevel); writer.Write(plantLevel); writer.Write(specialIndex); writer.Write(walled); writer.Write(hasIncomingRiver); writer.Write(hasOutgoingRiver); for (int i = 0; i < roads.Length; i++) { writer.Write(roads[i]); } } 

入库河和出库河的方向存储在HexDirection字段中。 HexDirection类型是一个枚举,在内部存储为多个整数值。 因此,我们还可以使用显式转换将它们序列化为整数。

  writer.Write(hasIncomingRiver); writer.Write((int)incomingRiver); writer.Write(hasOutgoingRiver); writer.Write((int)outgoingRiver); 

使用BinaryReader.ReadBoolean方法读取布尔值。 河流的方向是整数,我们必须将其转换回HexDirection

  public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); RefreshPosition(); waterLevel = reader.ReadInt32(); urbanLevel = reader.ReadInt32(); farmLevel = reader.ReadInt32(); plantLevel = reader.ReadInt32(); specialIndex = reader.ReadInt32(); walled = reader.ReadBoolean(); hasIncomingRiver = reader.ReadBoolean(); incomingRiver = (HexDirection)reader.ReadInt32(); hasOutgoingRiver = reader.ReadBoolean(); outgoingRiver = (HexDirection)reader.ReadInt32(); for (int i = 0; i < roads.Length; i++) { roads[i] = reader.ReadBoolean(); } } 

现在,我们保存了完整保存和还原地图所需的所有像元数据。每个单元需要9个整数和9个布尔值。每个布尔值占用一个字节,因此每个单元格总共使用45个字节。即,具有300个单元的卡总共需要13,500字节。

统一包装

缩小档案大小


尽管对于300个单元来说,似乎13,500字节不是很多,但是也许我们可以用更少的空间来完成。最后,我们可以完全控制数据的序列化方式。让我们看看是否有一种更紧凑的方式来存储它们。

数值间隔减少


不同的单元格级别和索引存储为整数。但是,它们仅使用较小范围的值。它们中的每个绝对将保持在0-255的范围内。这意味着将仅使用每个整数的第一个字节。其余三个始终为零。存储这些空字节没有任何意义。我们可以通过在写入流之前将整数写入字节来丢弃它们。

  writer.Write((byte)terrainTypeIndex); writer.Write((byte)elevation); writer.Write((byte)waterLevel); writer.Write((byte)urbanLevel); writer.Write((byte)farmLevel); writer.Write((byte)plantLevel); writer.Write((byte)specialIndex); writer.Write(walled); writer.Write(hasIncomingRiver); writer.Write((byte)incomingRiver); writer.Write(hasOutgoingRiver); writer.Write((byte)outgoingRiver); 

现在,要返回这些数字,我们必须使用BinaryReader.ReadByte从字节到整数的转换是隐式完成的,因此我们不需要添加显式转换。

  terrainTypeIndex = reader.ReadByte(); elevation = reader.ReadByte(); RefreshPosition(); waterLevel = reader.ReadByte(); urbanLevel = reader.ReadByte(); farmLevel = reader.ReadByte(); plantLevel = reader.ReadByte(); specialIndex = reader.ReadByte(); walled = reader.ReadBoolean(); hasIncomingRiver = reader.ReadBoolean(); incomingRiver = (HexDirection)reader.ReadByte(); hasOutgoingRiver = reader.ReadBoolean(); outgoingRiver = (HexDirection)reader.ReadByte(); 

因此,我们摆脱了每个整数三个字节的位置,每个单元节省了27个字节。现在,我们每个单元花费18个字节,而每300个单元仅花费5400个字节。

值得注意的是,旧卡数据在此阶段变得毫无意义。加载旧的保存文件时,数据会混合在一起,从而导致单元格混乱。这是因为我们现在正在读取更少的数据。如果读取的数据比以前更多,则尝试读取文件末尾之外的内容时会出现错误。

无法处理旧数据非常适合我们,因为我们正在确定格式。但是,当我们决定保存格式时,我们将需要确保将来的代码始终可以读取它。即使我们更改了格式,理想情况下,我们仍然应该能够读取旧格式。

河字节联盟


在此阶段,我们使用四个字节存储河流数据,每个方向两个。对于每个方向,我们都存储河流的存在及其流动方向,这

显然很明显不需要存储河流的方向。这意味着没有河流的单元需要少两个字节。实际上,无论河流的存在与否,只要向河的方向发送一个字节就足够了。

我们有六个可能的方向,它们以数字形式存储在0-5之间。三位就足够了,因为从0到5的二进制形式的数字看起来像000、001、010、011、100、101和110。也就是说,再有五个未使用的字节。我们可以使用其中之一来指示是否存在河流。例如,您可以使用对应于数字128的第八位,

为此,在将方向转换为字节之前,我们将在其上添加128。即,如果有一条河流流向西北,我们将写入133,其二进制形式为10000101。如果没有河流,那么我们只写一个零字节。

同时,还有四位未使用,但这是正常的。我们可以将河流的两个方向合并为一个字节,但这已经太混乱了。

 // writer.Write(hasIncomingRiver); // writer.Write((byte)incomingRiver); if (hasIncomingRiver) { writer.Write((byte)(incomingRiver + 128)); } else { writer.Write((byte)0); } // writer.Write(hasOutgoingRiver); // writer.Write((byte)outgoingRiver); if (hasOutgoingRiver) { writer.Write((byte)(outgoingRiver + 128)); } else { writer.Write((byte)0); } 

要解码河流数据,我们首先需要读回字节。如果其值不小于128,则意味着有一条河。要获得其方向,请减去128,然后转换为HexDirection

 // hasIncomingRiver = reader.ReadBoolean(); // incomingRiver = (HexDirection)reader.ReadByte(); byte riverData = reader.ReadByte(); if (riverData >= 128) { hasIncomingRiver = true; incomingRiver = (HexDirection)(riverData - 128); } else { hasIncomingRiver = false; } // hasOutgoingRiver = reader.ReadBoolean(); // outgoingRiver = (HexDirection)reader.ReadByte(); riverData = reader.ReadByte(); if (riverData >= 128) { hasOutgoingRiver = true; outgoingRiver = (HexDirection)(riverData - 128); } else { hasOutgoingRiver = false; } 

结果,我们每个单元得到16个字节。改进似乎并不大,但这是用于减少二进制数据大小的技巧之一。

将道路保存在一个字节中


我们可以使用类似的技巧来压缩道路数据。我们有六个布尔值,可以存储在一个字节的前六位中。也就是说,道路的每个方向都由2的幂表示。它们是1、2、4、8、16和32,或二进制格式1、10、100、1000、10000和100000。

要创建完成的字节,我们需要设置与所用道路方向相对应的位。为了获得正确的方向,我们可以使用运算符<<然后使用按位或运算符将它们合并。例如,如果使用第一,第二,第三和第六条道路,那么完成的字节将为100111。

  int roadFlags = 0; for (int i = 0; i < roads.Length; i++) { // writer.Write(roads[i]); if (roads[i]) { roadFlags |= 1 << i; } } writer.Write((byte)roadFlags); 

<<如何工作?
. integer . . integer . , . 1 << n 2 n , .

要获取返回的布尔值,您需要检查该位是否已设置。如果是这样,则使用具有适当数字的按位与运算符屏蔽所有其他位。如果结果不等于零,则该位被设置并且道路存在。

  int roadFlags = reader.ReadByte(); for (int i = 0; i < roads.Length; i++) { roads[i] = (roadFlags & (1 << i)) != 0; } 

将六个字节压缩成一个字节后,我们每个单元收到11个字节。对于300个单元,这仅为3,300字节。也就是说,在处理了一些字节之后,我们将文件大小减少了75%。

为未来做好准备


在宣布我们的保存格式完整之前,我们再添加一个细节。在保存地图数据之前,我们将强制HexMapEditor写入整数零。

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(0); hexGrid.Save(writer); } } 

这会将四个空字节添加到数据的开头。也就是说,在加载卡之前,我们必须读取这四个字节。

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { reader.ReadInt32(); hexGrid.Load(reader); } } 

尽管到目前为止这些字节是无用的,但它们被用作标头,将来会提供向后兼容性。如果我们没有添加这些空字节,那么前几个字节的内容取决于映射的第一个单元格。因此,将来我们很难确定我们要处理的是哪种版本的保存格式。现在我们可以检查前四个字节。如果它们为空,那么我们正在处理格式为0的版本。在将来的版本中,可以在其中添加其他内容。

也就是说,如果标题不为零,则我们正在处理某些未知版本。由于我们无法找到存在的数据,因此我们必须拒绝下载地图。

  using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 0) { hexGrid.Load(reader); } else { Debug.LogWarning("Unknown map format " + header); } } 


统一包装

第13部分:卡管理


  • 我们在“播放”模式下创建新卡。
  • 添加对各种卡大小的支持。
  • 将地图的大小添加到保存的数据。
  • 保存和加载任意地图。
  • 显示卡列表。

在这一部分中,我们将添加对各种大小的卡的支持,以及保存不同的文件。

从这一部分开始,将在Unity 5.5.0中创建教程。


图库的开始

创建新地图


到目前为止,在加载场景时,我们仅创建了一次六边形网格。现在,我们将可以随时启动新地图。新卡将仅替换当前卡。

在Awake中HexGrid,初始化了一些指标,然后确定了单元数,并创建了必要的片段和单元。创建一组新的片段和单元格,然后创建一个新地图。让我们HexGrid.Awake分为两部分-初始化源代码和常规方法CreateMap

  void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(); } public void CreateMap () { cellCountX = chunkCountX * HexMetrics.chunkSizeX; cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); } 

在用户界面中添加按钮以创建新地图。我把它变大了,并放在保存和加载按钮下面。


新地图按钮。

让我们将该按钮On Click事件CreateMapobject 方法联系起来HexGrid也就是说,我们将不通过十六进制地图编辑器,而是直接调用十六进制网格对象方法


通过单击创建地图。

清除旧数据


现在,当您单击New Map按钮时,将创建一组新的片段和单元格。但是,旧的文件不会自动删除。因此,结果,我们得到了几个相互叠加的地图网格。为了避免这种情况,我们首先需要清除旧对象。这可以通过在开始时销毁所有当前片段来完成CreateMap

  public void CreateMap () { if (chunks != null) { for (int i = 0; i < chunks.Length; i++) { Destroy(chunks[i].gameObject); } } … } 

我们可以重用现有对象吗?
, . , . , — , .

是否可以循环破坏像这样的子元素?
当然可以 .

指定单元格中的大小而不是片段


当我们通过字段chunkCountXchunkCountZ对象设置地图的大小时HexGrid但是,在单元格中指示地图的大小将更加方便。同时,我们甚至可以在将来更改片段的大小而无需更改卡的大小。因此,让我们交换单元数和片段数字段的角色。

 // public int chunkCountX = 4, chunkCountZ = 3; public int cellCountX = 20, cellCountZ = 15; … // int cellCountX, cellCountZ; int chunkCountX, chunkCountZ; … public void CreateMap () { … // cellCountX = chunkCountX * HexMetrics.chunkSizeX; // cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; chunkCountX = cellCountX / HexMetrics.chunkSizeX; chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); } 

这将导致编译错误,因为它HexMapCamera使用片段大小来限制其position 进行更改,HexMapCamera.ClampPosition以便他直接使用仍需要的单元数。

  Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * (2f * HexMetrics.innerRadius); 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); return position; } 

一个片段的大小为5 x 5单元,默认情况下,地图的大小为4 x 3片段。因此,要使卡保持相同,我们将必须使用20 x 15单元格的大小。尽管我们已经在代码中分配了默认值,但是网格对象仍然不会自动使用它们,因为这些字段已经存在并且默认为0。


默认情况下,卡的大小为20 x 15。

自定义卡尺寸


下一步将支持创建任意大小的卡片,而不仅仅是默认大小。为此,将HexGrid.CreateMapX和Z 添加到参数中,它们将替换现有的单元格数量。在内部,Awake我们将使用当前单元格的数量来调用它们。

  void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(cellCountX, cellCountZ); } public void CreateMap (int x, int z) { … cellCountX = x; cellCountZ = z; chunkCountX = cellCountX / HexMetrics.chunkSizeX; chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); } 

但是,这仅在单元格数量为片段大小倍数的情况下才能正常工作。否则,整数除法将产生太少的片段。尽管我们可以添加对部分填充有单元格的片段的支持,但我们仅禁止使用与片段不对应的大小。

我们可以使用运算符%来计算将单元数除以片段数的余数。如果不等于零,则存在差异,我们将不会创建新地图。在执行此操作的同时,让我们增加针对零和负大小的保护。

  public void CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return; } … } 

新卡菜单


在当前阶段,“ 新地图”按钮不再起作用,因为该方法HexGrid.CreateMap现在具有两个参数。我们无法将Unity事件直接连接到此类方法。另外,要支持不同大小的卡,我们需要一些按钮。不用将所有这些按钮添加到主UI中,而是创建一个单独的弹出菜单。

在场景中添加一个新的画布(GameObject / UI / Canvas)。我们将使用与现有画布相同的设置,不同之处在于其“ 排序顺序”应等于1。因此,它将位于主编辑器UI的顶部。我将画布和事件系统都设为新UI对象的子级,以使场景层次结构保持整洁。



画布菜单新地图。

面板添加到“ 新地图菜单”以关闭整个屏幕。需要使背景变暗,并且在菜单打开时不允许光标与其他所有东西交互。我给它提供了统一的颜色,清除了它的Source Image,并将(0,0,0,200)设置Color


背景图像设置。

将菜单栏添加到画布的中心,类似于Hex Map Editor面板让我们为她的小,中,大卡创建一个清晰的标签和按钮。如果玩家改变主意,我们还将为她添加一个取消按钮。完成设计的创建后,请停用整个“ 新地图菜单”



新地图菜单。

要管理菜单,请创建一个组件NewMapMenu并将其添加到画布的“ 新地图菜单”对象中要创建新地图,我们需要访问Hex Grid对象因此,我们向其中添加一个公共字段并将其连接。

 using UnityEngine; public class NewMapMenu : MonoBehaviour { public HexGrid hexGrid; } 


新地图菜单的组件。

开闭


我们可以通过简单地激活和禁用画布对象来打开和关闭弹出菜单。让我们添加NewMapMenu两个常用方法来执行此操作。

  public void Open () { gameObject.SetActive(true); } public void Close () { gameObject.SetActive(false); } 

现在,将编辑器的“ 新建地图 UI” 按钮连接Open新建地图菜单”对象中的方法


按下以打开菜单。

同时将“ 取消”按钮连接到方法Close这将使我们能够打开和关闭弹出菜单。

创建新地图


要创建新地图,我们需要在Hex Grid对象中调用方法CreateMap此外,此后,我们需要关闭弹出菜单。NewMapMenu考虑到任意大小,将其添加到将要处理方法中。

  void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); Close(); } 

此方法不应通用,因为我们仍然无法将其直接连接到按钮事件。而是,为每个按钮创建一个将以CreateMap指定大小调用的方法对于小地图,我使用20乘15的大小,对应于地图的默认大小。对于中间卡,我决定将此大小加倍,即40乘30,然后再对大卡再次加倍。用适当的方法连接按钮。

  public void CreateSmallMap () { CreateMap(20, 15); } public void CreateMediumMap () { CreateMap(40, 30); } public void CreateLargeMap () { CreateMap(80, 60); } 

相机锁


现在,我们可以使用弹出菜单创建三种不同尺寸的新地图!一切正常,但我们需要注意一些细节。当“ 新地图菜单”处于活动状态时,我们不能再与编辑器的用户界面和单元格进行交互。但是,我们仍然可以控制摄像机。理想情况下,打开菜单后,相机应锁定。

由于我们只有一台相机,因此一种快速而务实的解决方案是简单地向其中添加静态属性Locked。对于广泛使用,此解决方案不是很合适,但对于我们的简单界面而言,就足够了。这要求我们跟踪其中的静态实例HexMapCamera,该实例是在Awake摄像头时设置的。

  static HexMapCamera instance; … void Awake () { instance = this; swivel = transform.GetChild(0); stick = swivel.GetChild(0); } 

属性Locked可以是仅带有setter的简单静态布尔属性。它所做的只是HexMapCamera在锁定实例关闭实例,而在解锁实例将实例打开。

  public static bool Locked { set { instance.enabled = !value; } } 

现在,它NewMapMenu.Open可以挡住相机了,并且NewMapMenu.Close-将其解锁。

  public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; } 

保持正确的相机位置


相机还有另一个可能的问题。当创建的新地图小于当前地图时,相机可能会出现在地图边界之外。她将一直呆在那里,直到玩家尝试移动相机为止。只有这样,它才会受到新地图限制的限制。

为了解决这个问题,我们可以添加HexMapCamerastatic方法ValidatePosition调用AdjustPosition零偏移量实例方法将迫使相机移动到地图的边界。如果相机已经在新地图的边界内,则它将保持在原位。

  public static void ValidatePosition () { instance.AdjustPosition(0f, 0f); } 

NewMapMenu.CreateMap创建新地图后,在内部调用方法

  void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); HexMapCamera.ValidatePosition(); Close(); } 

统一包装

保存地图大小


尽管我们可以创建不同大小的卡,但是在保存和加载时不会考虑它。这意味着,如果当前地图的大小与所加载地图的大小不匹配,则加载地图将导致错误或错误的地图。

为了解决这个问题,在加载像元数据之前,我们需要创建一个适当大小的新地图。假设我们保存了一张小地图。在这种情况下,如果我们在开始时创建HexGrid.Load20 x 15的地图,一切都会很好

  public void Load (BinaryReader reader) { CreateMap(20, 15); for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } for (int i = 0; i < chunks.Length; i++) { chunks[i].Refresh(); } } 

卡大小存储


当然,我们可以存储任何大小的卡。因此,一种通用的解决方案是将地图的大小保存在这些单元格的前面。

  public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } } 

然后,我们可以获得真实的尺寸,并使用它来创建具有正确尺寸的地图。

  public void Load (BinaryReader reader) { CreateMap(reader.ReadInt32(), reader.ReadInt32()); … } 

由于现在我们可以加载不同大小的地图,因此我们再次面临摄像机位置的问题。HexMapEditor.Load加载地图后,我们将通过检查其位置来解决它

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 0) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

新档案格式


尽管这种方法适用于我们将来会保留的卡,但不适用于旧卡。反之亦然-本教程上半部分的代码将无法正确加载新的地图文件。为了区分新旧格式,我们将增加标头的整数值。没有地图尺寸的旧保存格式的版本为0。具有地图尺寸的新格式的版本为1。因此,在记录时,它HexMapEditor.Save应该写1而不是0。

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(1); hexGrid.Save(writer); } } 

从现在开始,这些卡将被保存为版本1。如果我们尝试从上一教程的程序集中打开它们,它们将拒绝加载并报告未知的卡格式。实际上,如果我们已经尝试加载这样的卡,就会发生这种情况。您需要更改方法,HexMapEditor.Load以便它接受新版本。

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 1) { hexGrid.Load(reader); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

向后兼容


实际上,如果需要,我们仍然可以下载版本0的地图,假设它们的大小均为20 x15。也就是说,标题不必为1,也可以为零。由于每个版本都需要自己的方法,因此HexMapEditor.Load必须将标头传递给method HexGrid.Load

  if (header <= 1) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } 

HexGrid.Load参数中添加标题并使用它来制定有关进一步操作的决策。如果标题不小于1,则需要读取卡大小数据。否则,我们将使用20 x 15的旧固定卡大小,并跳过读取大小数据的操作。

  public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } CreateMap(x, z); … } 

地图文件版本0

卡大小检查


与创建新图一样,从理论上讲,我们可能必须加载与片段大小不兼容的图。发生这种情况时,我们必须中断卡的下载。HexGrid.CreateMap已经拒绝创建地图并在控制台中显示错误。为了告诉方法的调用者,让我们返回一个布尔值来告诉您是否创建了地图。

  public bool CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return false; } … return true; } 

现在,HexGrid.Load当地图创建失败时它也可以停止执行。

  public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } if (!CreateMap(x, z)) { return; } … } 

由于加载会覆盖现有单元中的所有数据,因此,如果加载了相同大小的地图,则无需创建新的地图。因此,可以跳过此步骤。

  if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z)) { return; } } 

统一包装

档案管理


我们可以保存和加载不同大小的卡,但始终写入和读取test.map现在,我们将添加对不同文件的支持。

与其直接保存或加载地图,不如使用另一个提供高级文件管理的弹出菜单。像在“ 新建地图菜单”中一样创建另一个画布,但是这次我们将其称为“ 保存加载菜单”此菜单将保存和加载地图,具体取决于打开它所按下的按钮。

我们将创建“ 保存加载菜单”设计。就像是保存菜单一样稍后,我们将把它动态地变成启动菜单。像另一个菜单一样,它应该具有背景和菜单栏,菜单标签和取消按钮。然后将滚动视图(GameObject / UI / Scroll View)添加到菜单以显示文件列表。我们在下面插入输入字段(GameObject / UI / Input Field)以指示新卡的名称。我们还需要一个操作按钮来保存地图。最后。添加删除按钮以删除不需要的卡。



设计保存加载菜单。

默认情况下,滚动视图允许水平和垂直滚动,但是我们只需要一个具有垂直滚动的列表。因此,禁用滚动水平并拔出水平滚动条。我们还将“ 运动类型”设置为“ clamped”,并禁用了“ Inertia”以使列表看起来更具限制性。


文件列表选项。

我们将从文件列表对象中删除Scrollbar Horizo​​ntal子级,因为我们不需要它。然后调整垂直滚动条的大小,使其到达列表底部。名称输入对象的占位符文本可以在其子Placeholder中更改我使用了描述性文本,但是您可以将其保留为空白并删除占位符。




更改了菜单设计。

设计已经完成,现在停用菜单,以便默认情况下将其隐藏。

菜单管理


为了使菜单正常工作,我们需要另一个脚本,在这种情况下- SaveLoadMenu就像NewMapMenu,它需要一个到网格的链接,以及方法OpenClose

 using UnityEngine; public class SaveLoadMenu : MonoBehaviour { public HexGrid hexGrid; public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; } } 

将此组件添加到SaveLoadMenu,并为其提供指向网格对象的链接。


组件SaveLoadMenu。

将打开一个菜单来保存或加载。为了简化工作,请在方法中添加Open布尔参数。它确定菜单是否应处于保存模式。我们将在现场跟踪此模式,以了解以后要执行的操作。

  bool saveMode; public void Open (bool saveMode) { this.saveMode = saveMode; gameObject.SetActive(true); HexMapCamera.Locked = true; } 

下面结合按钮保存加载对象六角地图编辑器与方法Open的对象保存加载菜单仅检查“ 保存”按钮的布尔参数


在保存模式下打开菜单。

如果尚未这样做,请将“ 取消”按钮事件连接到方法Close现在保存加载菜单可以打开和关闭。

外观变化


我们将菜单创建为保存菜单,但其模式由按下以打开的按钮确定。我们需要根据模式更改菜单的外观。特别是,我们需要更改菜单标签和操作按钮标签。这意味着我们将需要链接到这些标签。

 using UnityEngine; using UnityEngine.UI; public class SaveLoadMenu : MonoBehaviour { public Text menuLabel, actionButtonLabel; … } 


与标签连接。

在保存模式下打开菜单时,我们将使用现有标签,即菜单的“ 保存地图 ”和操作按钮的“ 保存 ”。否则,我们处于加载模式,即我们使用Load MapLoad

  public void Open (bool saveMode) { this.saveMode = saveMode; if (saveMode) { menuLabel.text = "Save Map"; actionButtonLabel.text = "Save"; } else { menuLabel.text = "Load Map"; actionButtonLabel.text = "Load"; } gameObject.SetActive(true); HexMapCamera.Locked = true; } 

输入卡名


现在让我们离开文件列表。用户可以通过在输入字段中输入卡的名称来指定保存或下载的文件。要获取此数据,我们需要引用Name InputInputField对象的组件

  public InputField nameInput; 


连接到输入字段。

不需要强迫用户输入地图文件的完整路径。仅带扩展名.map的卡名称就足够了让我们添加一个接受用户输入并为其创建正确路径的方法。当输入为空时,这是不可能的,因此在这种情况下,我们将返回null

 using UnityEngine; using UnityEngine.UI; using System.IO; public class SaveLoadMenu : MonoBehaviour { … string GetSelectedPath () { string mapName = nameInput.text; if (mapName.Length == 0) { return null; } return Path.Combine(Application.persistentDataPath, mapName + ".map"); } } 

如果用户输入无效字符怎么办?
, . , , .

Content Type . , - , . , , .

保存和加载


现在它将从事保存和加载SaveLoadMenu因此,我们移动的方法SaveLoad在的HexMapEditorSaveLoadMenu它们不再需要共享,并且将使用path参数而不是固定路径。

  void Save (string path) { // string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(1); hexGrid.Save(writer); } } void Load (string path) { // string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header <= 1) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

由于我们现在正在上传任意文件,因此最好先验证文件是否确实存在,然后再尝试读取它。如果不是,则抛出错误并终止操作。

  void Load (string path) { if (!File.Exists(path)) { Debug.LogError("File does not exist " + path); return; } … } 

现在添加常规方法Action首先要获取用户选择的路径。如果有路径,请保存或加载。然后关闭菜单。

  public void Action () { string path = GetSelectedPath(); if (path == null) { return; } if (saveMode) { Save(path); } else { Load(path); } Close(); } 

通过将Action Button事件附加到此方法,我们可以使用任意映射名称进行保存和加载。由于我们不重置输入字段,因此所选名称将保留到下一次保存或加载。这对于连续多次从一个文件保存或加载非常方便,因此我们不会做任何更改。

地图清单项目


接下来,我们将使用数据存储路径上的所有卡填写文件列表。当您单击列表中的一项时,它将用作“ 名称输入”中的文本SaveLoadMenu为此添加一个通用方法。

  public void SelectItem (string name) { nameInput.text = name; } 

我们需要一些清单项目。通常的按钮会起作用。创建它并将其高度减小到20个单位,以使其在垂直方向上不占用太多空间。它看起来不应该像按钮,因此我们将清除Image组件Source Image链接在这种情况下,它将变成完全白色。另外,我们将确保标签向左对齐,并且文本和按钮的左侧之间应有空格。完成按钮的设计后,我们将其转变为预制件。



按钮是一个列表项。

我们无法将按钮事件直接连接到“ 新地图菜单”,因为它是预制的,并且还不存在于场景中。因此,菜单项需要链接到菜单,以便单击时可以调用方法SelectItem他还需要跟踪他代表的卡的名称,并设置他的文本。让我们为此创建一个小组件SaveLoadItem

 using UnityEngine; using UnityEngine.UI; public class SaveLoadItem : MonoBehaviour { public SaveLoadMenu menu; public string MapName { get { return mapName; } set { mapName = value; transform.GetChild(0).GetComponent<Text>().text = value; } } string mapName; public void Select () { menu.SelectItem(mapName); } } 

将一个组件添加到菜单项,并使按钮调用其方法Select


项目组件。

清单填写


要填充列表,您SaveLoadMenu需要一个指向File List对象视口内的Content的链接他还需要一个指向项目预制件的链接。

  public RectTransform listContent; public SaveLoadItem itemPrefab; 


混合列表和预制的内容。

我们使用一种新方法来填充此列表。第一步是识别现有的地图文件。要获取目录内所有文件路径的数组,可以使用方法Directory.GetFiles此方法有第二个参数,可让您过滤文件。在我们的情况下,仅需要匹配* .map掩码的文件

  void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); } 

不幸的是,不能保证文件顺序。要按字母顺序显示它们,我们需要使用对数组进行排序System.Array.Sort

 using UnityEngine; using UnityEngine.UI; using System; using System.IO; public class SaveLoadMenu : MonoBehaviour { … void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); Array.Sort(paths); } … } 

接下来,我们将为阵列的每个元素创建预制实例。将项目绑定到菜单,设置其地图名称,并使其成为列表内容的子级。

  Array.Sort(paths); for (int i = 0; i < paths.Length; i++) { SaveLoadItem item = Instantiate(itemPrefab); item.menu = this; item.MapName = paths[i]; item.transform.SetParent(listContent, false); } 

由于它Directory.GetFiles返回文件的完整路径,因此我们需要清除它们。幸运的是,这正是使此便捷方法有效的原因Path.GetFileNameWithoutExtension

  item.MapName = Path.GetFileNameWithoutExtension(paths[i]); 

在显示菜单之前,我们需要填写一个列表。由于文件可能会更改,因此每次打开菜单时都需要执行此操作。

  public void Open (bool saveMode) { … FillList(); gameObject.SetActive(true); HexMapCamera.Locked = true; } 

重新填写列表时,我们需要先删除所有旧列表,然后再添加新条目。

  void FillList () { for (int i = 0; i < listContent.childCount; i++) { Destroy(listContent.GetChild(i).gameObject); } … } 


没有安排的物品。

积分的安排


现在,列表将显示项目,但它们将重叠并且处在不利位置。要将它们变成垂直列表,请将“ 垂直布局组”组件(“ 组件/布局/垂直布局组”)添加列表Content对象为了使布置正常工作,请启用子控件大小的宽度和“ 子力展开 ”两者。两个高度选项均应禁用。





使用垂直布局组。

我们有一个漂亮的物品清单。但是,列表内容的大小无法调整为真实的项目数。因此,滚动条永远不会更改大小。我们可以通过向其添加Content Size Fitter组件Component / Layout / Content Size Fitter)来强制Content自动调整大小其“ 垂直适合”模式应设置为“ 首选大小”



使用内容大小拟合器。

现在只有少量点,滚动条将消失。并且当列表中有太多项目不适合视口时,滚动条就会出现并具有适当的大小。


出现滚动条。

卡删除


现在,我们可以方便地使用许多地图文件。但是,有时有必要去除一些卡。为此,您可以使用Delete按钮让我们为此创建一个方法并使按钮调用它。如果有选定的路径,只需使用删除它File.Delete

  public void Delete () { string path = GetSelectedPath(); if (path == null) { return; } File.Delete(path); } 

在这里,我们还应检查我们是否正在使用真正存在的文件。如果不是这种情况,则我们不应尝试将其删除,但这不会导致错误。

  if (File.Exists(path)) { File.Delete(path); } 

取出卡后,我们不需要关闭菜单。这样可以更轻松地一次删除多个文件。但是,删除后,我们需要清除Name Input,并更新文件列表。

  if (File.Exists(path)) { File.Delete(path); } nameInput.text = ""; FillList(); 

统一包装

第14部分:浮雕纹理


  • 使用顶点颜色创建splat贴图。
  • 创建阵列纹理资产。
  • 将高程索引添加到网格。
  • 浮雕纹理之间的过渡。

在此之前,我们将纯色用于色卡。现在我们将应用纹理。


绘制纹理。

三种类型的混合物


尽管统一的颜色很明显是可以区分的并且可以很好地应付任务,但是它们看起来并不有趣。使用纹理将大大增加地图的吸引力。当然,为此,我们必须混合纹理,而不仅仅是颜色。在“ 渲染3”教程“组合纹理”中,我讨论了如何使用splat贴图混合多个纹理。在我们的六边形图中,您可以使用类似的方法。

在“ 渲染3”教程中仅混合了四个纹理,使用一个splat贴图,我们最多可以支持五个纹理。目前,我们使用五种不同的颜色,因此这非常适合我们。但是,稍后我们可以添加其他类型。因此,需要支持任意数量的浮雕类型。使用显式设置的纹理属性时,这是不可能的,因此必须使用纹理数组。稍后我们将创建它。

使用纹理数组时,我们需要以某种方式告诉着色器要混合哪些纹理。对于角三角形来说,最困难的混合是必需的,角三角形可以在具有其自身类型的地形的三个像元之间。因此,我们需要在每个三角形的三种类型之间混合支持。

使用顶点颜色作为Splat贴图


假设我们可以告诉您要混合的纹理,我们可以使用顶点颜色为每个三角形创建一个splat贴图。由于在每种情况下最多使用三个纹理,因此我们仅需要三个颜色通道。红色代表第一个纹理,绿色代表第二个纹理,蓝色代表第三个纹理。


三角形Splat地图。

三角形图示的总和是否始终等于1?
是的 . . , (1, 0, 0) , (½, ½, 0) (&frac13;, &frac13;, &frac13;) .

如果三角形仅需要一个纹理,则仅使用第一个通道。即,其颜色将完全为红色。在两种不同类型之间混合的情况下,我们使用第一和第二通道。也就是说,三角形的颜色将是红色和绿色的混合物。当找到所有三种类型时,它将是红色,绿色和蓝色的混合物。


三种Splat地图配置。

无论实际混合了哪些纹理,我们都将使用这些splat贴图配置。也就是说,splat映射将始终相同。只有纹理会改变。如何执行此操作,我们将在以后找到。

我们需要进行更改,HexGridChunk以便它创建这些splat贴图,而不是使用单元格颜色。由于我们将经常使用三种颜色,因此我们将为它们创建静态字段。

  static Color color1 = new Color(1f, 0f, 0f); static Color color2 = new Color(0f, 1f, 0f); static Color color3 = new Color(0f, 0f, 1f); 

细胞中心


让我们从默认情况下替换单元格中心的颜色开始。这里没有进行任何混合,因此我们只使用第一种颜色,即红色。

  void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, color1); … } 


细胞红色中心。

单元中心现在变成红色。无论纹理是什么,它们都使用三个纹理中的第一个。它们的splat贴图是相同的,而不管我们为细胞着色的颜色如何。

河邻里


我们仅在单元内部更改了段,没有河流沿它们流动。我们需要对与河流相邻的路段做同样的事情。在我们的案例中,这既是肋条,也是肋的三角形扇形。在这里,对于我们来说,只有红色就足够了。

  void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … } 


与河流相邻的红色部分。

河流


接下来,我们需要注意单元内部河流的几何形状。它们也都应该变成红色。首先,让我们看一下河流的起点和终点。

  void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … } 

然后是构成河岸和河床的几何形状。我对颜色方法调用进行了分组,以使代码更易于阅读。

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); terrain.AddTriangle(centerL, m.v1, m.v2); // terrain.AddTriangleColor(cell.Color); terrain.AddQuad(centerL, center, m.v2, m.v3); // terrain.AddQuadColor(cell.Color); terrain.AddQuad(center, centerR, m.v3, m.v4); // terrain.AddQuadColor(cell.Color); terrain.AddTriangle(centerR, m.v4, m.v5); // terrain.AddTriangleColor(cell.Color); terrain.AddTriangleColor(color1); terrain.AddQuadColor(color1); terrain.AddQuadColor(color1); terrain.AddTriangleColor(color1); … } 


沿细胞的红河。

排骨


所有边缘都是不同的,因为它们位于可以具有不同类型地形的单元之间。我们将第一种颜色用于当前单元格类型,将第二种颜色用于邻居类型。结果,即使两个单元格是同一类型,splat贴图也将变为红绿色渐变。如果两个单元使用相同的纹理,则它只是在两侧变成了相同纹理的混合物。

  void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else { TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad); } … } 


红绿色的肋骨,不包括壁架。

红色和绿色之间的急剧过渡会不会引起问题?
, , . . splat map, . .

, .

带有壁架的边缘要复杂一些,因为它们具有附加的顶点。幸运的是,现有的插值代码可用于splat贴图颜色。仅使用第一和第二种颜色,而不使用开始和结束单元格的颜色。

  void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(color1, color2, 1); TriangulateEdgeStrip(begin, color1, e2, c2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(color1, color2, i); TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad); } TriangulateEdgeStrip(e2, c2, end, color2, hasRoad); } 


红绿色的肋骨壁架。

角度


像元角是最困难的,因为它们必须混合三种不同的纹理。我们将红色用于底峰,将绿色用于左峰,将蓝色用于右峰。让我们从一个三角形的角开始。

  void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); terrain.AddTriangleColor(color1, color2, color3); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); } 


红-绿-蓝色的角,壁架除外。

在这里,我们可以再次将现有的颜色插值代码用于带有壁架的角。仅在三种而不是两种颜色之间进行插值。首先,考虑不在悬崖附近的壁架。

  void TriangulateCornerTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1); Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1); Color c3 = HexMetrics.TerraceLerp(color1, color2, 1); Color c4 = HexMetrics.TerraceLerp(color1, color3, 1); terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleColor(color1, c3, c4); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v3; Vector3 v2 = v4; Color c1 = c3; Color c2 = c4; v3 = HexMetrics.TerraceLerp(begin, left, i); v4 = HexMetrics.TerraceLerp(begin, right, i); c3 = HexMetrics.TerraceLerp(color1, color2, i); c4 = HexMetrics.TerraceLerp(color1, color3, i); terrain.AddQuad(v1, v2, v3, v4); terrain.AddQuadColor(c1, c2, c3, c4); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadColor(c3, c4, color2, color3); } 


红-绿-蓝角corner,悬崖峭壁除外。

当谈到悬崖时,我们需要使用一种方法TriangulateBoundaryTriangle此方法接收开始和左单元格作为参数。但是,现在我们需要适当的splat颜色,具体取决于拓扑。因此,我们将这些参数替换为颜色。

  void TriangulateBoundaryTriangle ( Vector3 begin, Color beginColor, Vector3 left, Color leftColor, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1); terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary); terrain.AddTriangleColor(beginColor, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i); terrain.AddTriangleUnperturbed(v1, v2, boundary); terrain.AddTriangleColor(c1, c2, boundaryColor); } terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary); terrain.AddTriangleColor(c2, leftColor, boundaryColor); } 

对其进行更改,TriangulateCornerTerracesCliff以使其使用正确的颜色。

  void TriangulateCornerTerracesCliff ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … Color boundaryColor = Color.Lerp(color1, color3, b); TriangulateBoundaryTriangle( begin, color1, left, color2, boundary, boundaryColor ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); } } 

并针对进行相同操作TriangulateCornerCliffTerraces

  void TriangulateCornerCliffTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … Color boundaryColor = Color.Lerp(color1, color2, b); TriangulateBoundaryTriangle( right, color3, begin, color1, boundary, boundaryColor ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); } } 


完整的splat救济图。

统一包装

纹理阵列


现在,我们的地形有了一个splat贴图,我们可以将纹理集合传递给着色器。我们不能仅将着色器分配给C#纹理数组,因为该数组必须作为单个实体存在于GPU内存中。Texture2DArray自5.4版以来,我们将必须使用Unity中已支持的特殊对象

所有GPU是否都支持纹理阵列?
GPU , . Unity .
  • Direct3D 11/12 (Windows, Xbox One)
  • OpenGL Core (Mac OS X, Linux)
  • Metal (iOS, Mac OS X)
  • OpenGL ES 3.0 (Android, iOS, WebGL 2.0)
  • PlayStation 4


大师


不幸的是,Unity在5.5版中对纹理数组的支持很小。我们不仅可以创建纹理阵列资源并为其分配纹理。我们必须手动进行。我们可以在“播放”模式下创建纹理数组,也可以在编辑器中创建资产。让我们创建一个资产。

为什么要创建资产?
, Play . , .

, . Unity . , . , .

要创建纹理数组,我们将组装自己的母版。创建一个脚本TextureArrayWizard并将其放置在Editor文件夹中相反,MonoBehaviour它应该ScriptableWizard从namespace 扩展类型UnityEditor

 using UnityEditor; using UnityEngine; public class TextureArrayWizard : ScriptableWizard { } 

我们可以通过通用的静态方法打开向导ScriptableWizard.DisplayWizard它的参数是向导窗口及其创建按钮的名称。我们将以静态方法调用此方法CreateWizard

  static void CreateWizard () { ScriptableWizard.DisplayWizard<TextureArrayWizard>( "Create Texture Array", "Create" ); } 

要通过编辑器访问向导,我们需要将此方法添加到Unity菜单中。这可以通过向该方法添加属性来完成MenuItem让我们将其添加到Assets菜单中,更具体地说,将其添加到Assets / Create / Texture Array中

  [MenuItem("Assets/Create/Texture Array")] static void CreateWizard () { … } 


我们的自定义向导。

使用新的菜单项,您可以打开我们的自定义向导的弹出菜单。它不是很漂亮,但是适合解决问题。但是,它仍然是空的。要创建纹理数组,我们需要一个纹理数组。向其添加一个主字段的通用字段。向导的标准GUI像标准检查器一样显示它。

  public Texture2D[] textures; 


掌握纹理。

让我们创造一些东西


当您单击向导创建按钮时,它消失了。另外,Unity抱怨没有方法OnWizardCreate这是单击“创建”按钮时调用的方法,因此我们需要将其添加到向导中。

  void OnWizardCreate () { } 

在这里,我们将创建纹理数组。至少如果用户将纹理添加到母版上。如果没有,那么什么也没有创建,需要停止工作。

  void OnWizardCreate () { if (textures.Length == 0) { return; } } 

下一步是请求保存纹理阵列资产的位置。可以使用方法打开文件保存面板EditorUtility.SaveFilePanelInProject其参数定义面板名称,默认文件名,文件扩展名和描述。纹理数组使用常规资产文件扩展名

  if (textures.Length == 0) { return; } EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" ); 

SaveFilePanelInProject返回用户选择的文件路径。如果用户单击此面板上的“取消”,则路径将为空字符串。因此,在这种情况下,我们必须中断工作。

  string path = EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" ); if (path.Length == 0) { return; } 

创建纹理数组


如果我们有正确的道路,那么我们可以继续前进并创建一个新对象Texture2DArray他的构造方法需要指定纹理的宽度和高度,数组的长度,纹理的格式以及对mip纹理的需求。对于阵列中的所有纹理,这些参数应该相同。要配置对象,我们使用第一个纹理。用户必须验证所有纹理具有相同的格式。

  if (path.Length == 0) { return; } Texture2D t = textures[0]; Texture2DArray textureArray = new Texture2DArray( t.width, t.height, textures.Length, t.format, t.mipmapCount > 1 ); 

由于纹理数组是单个GPU资源,因此它对所有纹理使用相同的过滤和折叠模式。在这里,我们再次使用第一个纹理进行设置。

  Texture2DArray textureArray = new Texture2DArray( t.width, t.height, textures.Length, t.format, t.mipmapCount > 1 ); textureArray.anisoLevel = t.anisoLevel; textureArray.filterMode = t.filterMode; textureArray.wrapMode = t.wrapMode; 

现在我们可以使用方法将纹理复制到数组中Graphics.CopyTexture该方法一次复制一个纹理级别的原始纹理数据。因此,我们需要遍历所有纹理及其mip级别。方法参数是两组,分别由纹理资源,索引和Mip级别组成。由于原始纹理不是数组,因此它们的索引始终为零。

  textureArray.wrapMode = t.wrapMode; for (int i = 0; i < textures.Length; i++) { for (int m = 0; m < t.mipmapCount; m++) { Graphics.CopyTexture(textures[i], 0, m, textureArray, i, m); } } 

在此阶段,我们已经在内存中存储了正确的纹理数组,但这还不是一项资产。最后一步将是调用AssetDatabase.CreateAsset数组及其路径。在这种情况下,数据将被写入我们项目中的文件中,并将出现在项目窗口中。

  for (int i = 0; i < textures.Length; i++) { … } AssetDatabase.CreateAsset(textureArray, path); 


贴图


要创建真实的纹理数组,我们需要原始纹理。这是与我们到目前为止使用的颜色相匹配的五种纹理。黄色变成沙子,绿色变成草,蓝色变成泥土,橙色变成石头,白色变成雪。






沙子,草,地球,石头和雪纹理。

请注意,这些纹理不是此浮雕的照片。这些是我使用NumberFlow创建的简单伪随机模式我努力创建与抽象多边形浮雕不冲突的可识别浮雕类型和细节。真实感不适用于此。此外,尽管模式增加了可变性,但其中几乎没有明显的特征会使重复立即引起注意。

将这些纹理添加到主阵列中,确保其顺序与颜色匹配。也就是说,首先是沙子,然后是草,泥土,石头,最后是雪。



创建纹理数组。

创建纹理阵列资产后,选择它并在检查器中对其进行检查。


纹理阵列检查器。

这是最简单的纹理阵列数据显示。请注意,最初已打开一个“可读”开关。由于我们不需要从数组中读取像素数据,因此请将其关闭。我们无法在向导中执行此操作,因为Texture2DArray没有方法或属性可以访问此参数。

(在Unity 5.6中,有一个错误会破坏多个平台上的程序集中的纹理数组。您可以在不禁用Is Readable的情况下解决它。)

还有一点值得注意,它有一个Color Space字段它的值被指定为1。这意味着纹理被假定为在伽马空间中,这是正确的。如果假定它们在线性空间中,则必须将字段设置为0。实际上,设计者Texture2DArray还有一个用于指定颜色空间的附加参数,但是它Texture2D不会显示它是否在线性空间中,因此,无论如何,您都需要设置手动值。

着色器


现在我们有了一系列纹理,我们需要教着色器如何使用它。现在,我们使用VertexColors着色器来渲染terrain 从现在开始,我们将使用纹理而不是颜色,将其重命名为Terrain然后,将其_MainTex参数转换为纹理数组,并为其分配资产。

 Shader "Custom/Terrain" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } … } 


具有一系列纹理的浮雕材料。

要在支持它们的所有平台上启用纹理阵列,您需要将着色器的目标级别从3.0增加到3.5。

  #pragma target 3.5 

由于变量_MainTex现在引用纹理数组,因此我们需要更改其类型。类型取决于目标平台,宏将负责此工作UNITY_DECLARE_TEX2DARRAY

 // sampler2D _MainTex; UNITY_DECLARE_TEX2DARRAY(_MainTex); 

与其他着色器一样,要采样浮雕的纹理,我们需要XZ世界的坐标。因此,我们将在世界上为表面着色器的输入结构添加一个位置。我们还删除了默认的UV坐标,因为我们不需要它们。

  struct Input { // float2 uv_MainTex; float4 color : COLOR; float3 worldPos; }; 

要采样一组纹理,我们需要使用一个macro UNITY_SAMPLE_TEX2DARRAY要采样一个数组,它需要三个坐标。前两个是常规UV坐标。我们将使用缩放到0.02的XZ世界坐标。因此,我们在全放大倍数下可以获得良好的纹理分辨率。纹理大约每四个单元重复一次。

与常规数组一样,第三个坐标用作纹理数组的索引。由于坐标是浮动的,因此在索引GPU数组之前会对它们进行四舍五入。因为直到我们知道需要什么纹理,我们才总是使用第一个。同样,顶点的颜色不会影响最终结果,因为它是一个散点图。

  void surf (Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.worldPos.xz * 0.02; fixed4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, float3(uv, 0)); Albedo = c.rgb * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


一切都变成了沙子。

统一包装

纹理选择


我们需要一个将三个类型混合成一个三角形的浮雕图示。我们有一系列纹理,每种类型的地形都有一个纹理。我们有一个着色器,可以对一系列纹理进行采样。但是到目前为止,我们还没有办法告诉着色器为每个三角形选择哪种纹理。

由于每个三角形最多混合三种类型,因此我们需要将三个索引与每个三角形关联。我们无法存储三角形的信息,因此我们必须存储顶点的索引。三角形的所有三个顶点将仅存储与纯色相同的索引。

网格数据


我们可以使用其中一组UV网格来存储索引。由于三个索引存储在每个顶点上,因此现有的2D UV集将不足。幸运的是,UV集最多可以包含四个坐标。因此,我们将添加到HexMesh第二个列表中Vector3,我们将其称为救济类型。

  public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates; public bool useTerrainTypes; [NonSerialized] List<Vector3> vertices, terrainTypes; 

为“ 十六进制网格块”预制件的“ 地形”子代启用地形类型


我们使用救济类型。

如有必要,我们将Vector3在网格清洁过程中使用另一种浮雕类型列表

  public void Clear () { … if (useTerrainTypes) { terrainTypes = ListPool<Vector3>.Get(); } triangles = ListPool<int>.Get(); } 

在应用网格数据的过程中,我们将浮雕类型保存在第三个UV集中。因此,如果我们决定一起使用它们,它们将不会与其他两个集合冲突。

  public void Apply () { … if (useTerrainTypes) { hexMesh.SetUVs(2, terrainTypes); ListPool<Vector3>.Add(terrainTypes); } hexMesh.SetTriangles(triangles, 0); … } 

要设置三角形的浮雕类型,我们将使用Vector3由于整个三角形的数据相同,因此我们只需将相同的数据添加三遍。

  public void AddTriangleTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); } 

在Quad中混合的工作原理相同。所有四个顶点都属于同一类型。

  public void AddQuadTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); } 

排骨三角形的粉丝


现在,我们需要在中向网格数据添加类型HexGridChunk让我们从开始TriangulateEdgeFan首先,为了提高可读性,我们将对顶点和颜色方法的调用分开。回想一下,每次调用此方法时,我们都会将其传递给他color1,因此我们可以直接使用此颜色,而无需应用参数。

  void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { terrain.AddTriangle(center, edge.v1, edge.v2); // terrain.AddTriangleColor(color); terrain.AddTriangle(center, edge.v2, edge.v3); // terrain.AddTriangleColor(color); terrain.AddTriangle(center, edge.v3, edge.v4); // terrain.AddTriangleColor(color); terrain.AddTriangle(center, edge.v4, edge.v5); // terrain.AddTriangleColor(color); terrain.AddTriangleColor(color1); terrain.AddTriangleColor(color1); terrain.AddTriangleColor(color1); terrain.AddTriangleColor(color1); } 

在颜色之后,我们添加浮雕类型。由于三角形的类型可能不同,因此应使用该参数替换颜色。使用此简单类型创建Vector3只有前四个通道对我们很重要,因为在这种情况下,图示图示始终为红色。由于向量的所有三个组成部分都需要分配,因此让我们为它们分配一种类型。

  void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float type) { … Vector3 types; types.x = types.y = types.z = type; terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); } 

现在,我们需要更改对该方法的所有调用,将color参数替换为单元的地形类型的索引。Vnesom这种变化TriangulateWithoutRiverTriangulateAdjacentToRiverTriangulateWithRiverBeginOrEnd

 // TriangulateEdgeFan(center, e, color1); TriangulateEdgeFan(center, e, cell.TerrainTypeIndex); 

此时,当您启动“播放”模式时,将出现错误,通知您第三组UV网格超出范围。发生这种情况是因为我们尚未将浮雕类型添加到每个三角形和四边形。因此,让我们继续进行更改HexGridChunk

肋条


现在,当创建边缘条时,我们需要知道两侧的地形类型。因此,我们将它们添加为参数,然后创建一个类型向量,其两个通道都被分配了这些类型。第三个渠道并不重要,因此只需将其等同于第一个渠道即可。添加颜色后,将类型添加到四边形。

  void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, float type1, EdgeVertices e2, Color c2, float type2, bool hasRoad = false ) { terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); Vector3 types; types.x = types.z = type1; types.y = type2; terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } } 

现在我们需要改变挑战TriangulateEdgeStrip首先TriangulateAdjacentToRiverTriangulateWithRiverBeginOrEndTriangulateWithRiver您必须使用的细胞类型的翅片的两侧。

 // TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeStrip( m, color1, cell.TerrainTypeIndex, e, color1, cell.TerrainTypeIndex ); 

接下来,最简单的边缘情况TriangulateConnection必须将单元格类型用于最近边缘,将邻居类型用于远端边缘。它们可以相同或不同。

  void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else { // TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad); TriangulateEdgeStrip( e1, color1, cell.TerrainTypeIndex, e2, color2, neighbor.TerrainTypeIndex, hasRoad ); } … } 

这同样适用于TriangulateEdgeTerraces,这是三次TriangulateEdgeStrip壁架的类型相同。

  void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(color1, color2, 1); float t1 = beginCell.TerrainTypeIndex; float t2 = endCell.TerrainTypeIndex; TriangulateEdgeStrip(begin, color1, t1, e2, c2, t2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(color1, color2, i); TriangulateEdgeStrip(e1, c1, t1, e2, c2, t2, hasRoad); } TriangulateEdgeStrip(e2, c2, t1, end, color2, t2, hasRoad); } 

角度


角的最简单情况是一个简单的三角形。底部的单元格传递第一种类型,左侧的单元格传递第二种,右侧的单元格传递第三种。使用它们,创建类型的向量并将其添加到三角形。

  void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); terrain.AddTriangleColor(color1, color2, color3); Vector3 types; types.x = bottomCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); } 

我们在中使用相同的方法TriangulateCornerTerraces,仅在此处创建一组四边形。

  void TriangulateCornerTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1); Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1); Color c3 = HexMetrics.TerraceLerp(color1, color2, 1); Color c4 = HexMetrics.TerraceLerp(color1, color3, 1); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleColor(color1, c3, c4); terrain.AddTriangleTerrainTypes(types); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v3; Vector3 v2 = v4; Color c1 = c3; Color c2 = c4; v3 = HexMetrics.TerraceLerp(begin, left, i); v4 = HexMetrics.TerraceLerp(begin, right, i); c3 = HexMetrics.TerraceLerp(color1, color2, i); c4 = HexMetrics.TerraceLerp(color1, color3, i); terrain.AddQuad(v1, v2, v3, v4); terrain.AddQuadColor(c1, c2, c3, c4); terrain.AddQuadTerrainTypes(types); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadColor(c3, c4, color2, color3); terrain.AddQuadTerrainTypes(types); } 

在混合壁架和悬崖时,我们需要使用TriangulateBoundaryTriangle只需为其提供类型向量参数并将其添加到其所有三角形即可。

  void TriangulateBoundaryTriangle ( Vector3 begin, Color beginColor, Vector3 left, Color leftColor, Vector3 boundary, Color boundaryColor, Vector3 types ) { Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1); terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary); terrain.AddTriangleColor(beginColor, c2, boundaryColor); terrain.AddTriangleTerrainTypes(types); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i); terrain.AddTriangleUnperturbed(v1, v2, boundary); terrain.AddTriangleColor(c1, c2, boundaryColor); terrain.AddTriangleTerrainTypes(types); } terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary); terrain.AddTriangleColor(c2, leftColor, boundaryColor); terrain.AddTriangleTerrainTypes(types); } 

TriangulateCornerTerracesCliff基于所发送的细胞类型创建矢量。然后将其添加到一个三角形并传递TriangulateBoundaryTriangle

  void TriangulateCornerTerracesCliff ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (rightCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b ); Color boundaryColor = Color.Lerp(color1, color3, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( begin, color1, left, color2, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } } 

同样的道理TriangulateCornerCliffTerraces

  void TriangulateCornerCliffTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (leftCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b ); Color boundaryColor = Color.Lerp(color1, color2, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( right, color3, begin, color1, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } } 

河流


最后一种更改方法是TriangulateWithRiver因为这里我们位于单元格的中心,所以我们只处理当前单元格的类型。因此,为其创建一个向量,并将其添加到三角形和quad-s中。

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … terrain.AddTriangleColor(color1); terrain.AddQuadColor(color1); terrain.AddQuadColor(color1); terrain.AddTriangleColor(color1); Vector3 types; types.x = types.y = types.z = cell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); … } 

类型混合


在这一阶段,网格包含必要的高程指数。对我们而言,剩下的就是强制地形着色器使用它们。为了使索引落入片段着色器中,我们首先需要将它们传递通过顶点着色器。我们可以像在Estuary着色器中那样在自己的顶点函数中执行此操作在这种情况下,我们向输入结构添加一个字段float3 terrain并将其复制到其中v.texcoord2.xyz

  #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 … struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; }; void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); data.terrain = v.texcoord2.xyz; } 

我们需要对每个片段采样3次纹理数组。因此,让我们创建一个方便的函数来创建纹理坐标,对数组进行采样并使用splat贴图为一个索引调制样本。

  float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]); float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw); return c * IN.color[index]; } void surf (Input IN, inout SurfaceOutputStandard o) { … } 

我们可以将向量作为数组使用吗?
是的 - color[0] color.r . color[1] color.g , .

使用此功能,我们可以简单地对纹理数组进行三次采样并组合结果。

  void surf (Input IN, inout SurfaceOutputStandard o) { // float2 uv = IN.worldPos.xz * 0.02; fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); o.Albedo = c.rgb * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


纹理浮雕。

现在我们可以用纹理绘制浮雕。它们像纯色一样混合。由于我们将世界坐标用作UV坐标,因此它们不会随高度变化。结果,沿着陡峭的悬崖,纹理被拉伸了。如果纹理相当中性且变化很大,那么结果将是可以接受的。否则,我们会得到大的丑陋妊娠纹。您可以尝试使用其他几何形状或悬崖纹理将其隐藏,但是在本教程中,我们不会这样做。

扫一扫


现在,当我们使用纹理而不是颜色时,更改编辑器面板将是合乎逻辑的。我们可以创建一个甚至可以显示浮雕纹理的漂亮界面,但是我将重点介绍与现有方案样式相对应的缩写。


救济选项。

此外,HexCell不再需要color属性,因此将其删除。

 // public Color Color { // get { // return HexMetrics.colors[terrainTypeIndex]; // } // } 

HexGrid还可以从中删除颜色数组和相关代码。

 // public Color[] colors; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); // HexMetrics.colors = colors; CreateMap(cellCountX, cellCountZ); } … … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); // HexMetrics.colors = colors; } } 

最后,中也不需要颜色数组HexMetrics

 // public static Color[] colors; 

统一包装

第15部分:距离


  • 显示网格线。
  • 在编辑和导航模式之间切换。
  • 计算单元格之间的距离。
  • 我们找到绕过障碍的方法。
  • 我们考虑了搬家的可变成本。

创建了高质量的地图后,我们将开始导航。


最短的路径并不总是直的。

网格显示


通过在一个单元格之间移动来执行地图上的导航。要到达某个地方,您需要遍历一系列单元。为了更容易估算距离,让我们添加选项以显示地图所基于的六边形网格。

网格纹理


尽管地图网格不规则,但下面的网格还是完美平坦的。我们可以通过将栅格图案投影到地图上来显示这一点。这可以使用重复的网格纹理来实现。


重复网格纹理。

上面显示的纹理包含覆盖2 x 2格的六边形网格的一小部分。此区域是矩形,而不是正方形。由于纹理本身是正方形,因此图案看起来很拉伸。采样时,我们需要对此进行补偿。

网格投影


要投影网格图案,我们需要向Terrain shader添加一个texture属性。

  Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _GridTex ("Grid Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } 


具有网状纹理的浮雕材料。

使用世界的XZ坐标对纹理进行采样,然后将其乘以反照率。由于纹理上的网格线为灰色,因此会将图案交织到浮雕中。

  sampler2D _GridTex; … void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); fixed4 grid = tex2D(_GridTex, IN.worldPos.xz); o.Albedo = c.rgb * grid * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


反照率乘以细网格。

我们需要缩放模式,使其与地图中的单元格匹配。相邻单元格中心之间的距离为15,需要向上翻倍才能向上移动两个单元格。也就是说,我们需要将V网格的坐标除以30。像元的内半径为5√3,并且要将两个像元向右移动,则需要四倍。因此,有必要将U网格的坐标除以20√3。

  float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); fixed4 grid = tex2D(_GridTex, gridUV); 


正确的网格尺寸。

现在,网格线对应于地图的单元格。就像浮雕纹理一样,它们忽略了高度,因此线条将沿着悬崖伸展。


投影到具有高度的单元格上。

网格变形通常不会那么糟,尤其是在远距离观看地图时。


在远处网格。

网格包含


尽管显示网格很方便,但并非总是必需的。例如,在截屏时应将其关闭。此外,并非每个人都喜欢不断看到网格。因此,让我们将其设为可选。我们将把multi_compile指令添加到着色器,以创建带有和不带有网格的选项。为此,我们将使用关键字GRID_ONRendering 5教程“ Multiple Lights”中介绍了条件着色器的编译

  #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 #pragma multi_compile _ GRID_ON 

在声明变量时,我们grid首先为其分配值1。结果,网格将被禁用。然后,我们将仅对具有特定关键字的变体采样网格纹理GRID_ON

  fixed4 grid = 1; #if defined(GRID_ON) float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); grid = tex2D(_GridTex, gridUV); #endif o.Albedo = c.rgb * grid * _Color; 

由于关键字GRID_ON不包括在terrain shader中,因此网格将消失。要再次启用它,我们将向地图编辑器用户界面添加一个开关。为了使之成为可能,我HexMapEditor必须获得指向Terrain材质的链接以及启用或禁用关键字的方法GRID_ON

  public Material terrainMaterial; … public void ShowGrid (bool visible) { if (visible) { terrainMaterial.EnableKeyword("GRID_ON"); } else { terrainMaterial.DisableKeyword("GRID_ON"); } } 


参考材料的编辑器March六边形。

Grid开关添加到UI 并将其连接到方法ShowGrid


电网开关。

保存状态


现在,在“播放”模式下,我们可以切换网格的显示。在第一个测试中,网格最初是关闭的,并且在我们打开开关时变得可见。当您关闭它时,网格将再次消失。但是,如果在显示网格时退出播放模式,则下次关闭播放模式时,即使开关已关闭,它也会再次打开。

这是因为我们正在更改常规Terrain材质的关键字我们正在编辑物料资产,因此更改将保存在Unity编辑器中。它不会保存在装配体中。

为了始终在没有网格的情况下开始游戏,我们将禁用GRID_ONAwake中的关键字HexMapEditor

  void Awake () { terrainMaterial.DisableKeyword("GRID_ON"); } 

统一包装

编辑模式


如果要控制地图上的运动,则需要与其进行交互。至少,我们需要选择单元格作为路径的起点。但是,当您单击一个单元格时,它将被编辑。我们可以手动禁用所有编辑选项,但这很不方便。另外,我们不希望在地图编辑期间执行位移计算。因此,让我们添加一个确定我们是否处于编辑模式的开关。

编辑开关


添加到HexMapEditorBoolean字段editMode以及定义它的方法。然后将另一个开关添加到UI进行控制。让我们从导航模式开始,即默认情况下将禁用编辑模式。

  bool editMode; … public void SetEditMode (bool toggle) { editMode = toggle; } 


编辑模式开关。

要真正禁用编辑,请使调用EditCells取决于editMode

  void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } if (editMode) { EditCells(currentCell); } previousCell = currentCell; } else { previousCell = null; } } 

调试标签


到目前为止,我们还没有单位可以在地图上移动。相反,我们将移动距离可视化。为此,您可以使用现有的单元格标签。因此,当禁用编辑模式时,我们将使它们可见。

  public void SetEditMode (bool toggle) { editMode = toggle; hexGrid.ShowUI(!toggle); } 

由于我们从导航模式开始,因此应启用默认标签。当前HexGridChunk.Awake禁用它们,但他不应再这样做。

  void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ]; // ShowUI(false); } 


坐标标签。

现在,在启动“播放”模式后立即可以看到单元坐标。但是我们不需要坐标,我们使用标签显示距离。由于每个单元格只需要一个数字,因此可以增加字体大小,以便更好地阅读它们。更改十六进制单元标签的预制,使其使用大小为8的粗体。


具有粗体字体的标签8.

现在,在启动“播放”模式后,我们将看到大标签。仅该单元格的第一个坐标可见,其余未放置在标签中。


大标签。

由于我们不再需要坐标,因此我们将删除HexGrid.CreateCell分配中label.text

  void CreateCell (int x, int z, int i) { … Text label = Instantiate<Text>(cellLabelPrefab); label.rectTransform.anchoredPosition = new Vector2(position.x, position.z); // label.text = cell.coordinates.ToStringOnSeparateLines(); cell.uiRect = label.rectTransform; … } 

您也可以从UI中删除“ 标签”开关及其关联的方法HexMapEditor.ShowUI

 // public void ShowUI (bool visible) { // hexGrid.ShowUI(visible); // } 


方法切换不再。

统一包装

寻找距离


现在我们有了标记的导航模式,我们可以开始显示距离了。我们将选择一个单元格,然后显示从该单元格到地图上所有单元格的距离。

距离显示


要跟踪到单元格的距离,请添加到HexCellinteger字段distance它将指示此单元格与所选单元格之间的距离。因此,对于选定的单元格本身,它将为零,对于紧邻的邻居为1,依此类推。

  int distance; 

设置距离后,我们必须更新单元格标签以显示其值。HexCell有对RectTransformUI对象的引用我们将需要打电话给他GetComponent<Text>进入牢房。考虑Text名称空间中的内容UnityEngine.UI,因此请在脚本开头使用它。

  void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance.ToString(); } 

我们不应该直接链接到Text组件吗?
, . , , , . , .

让我们将常规属性设置为接收并设置到单元的距离,并更新其标签。

  public int Distance { get { return distance; } set { distance = value; UpdateDistanceLabel(); } } 

使用cell参数将其添加到HexGrid常规方法中FindDistancesTo现在,我们将简单地设置每个单元的零距离。

  public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = 0; } } 

如果未启用编辑模式,则HexMapEditor.HandleInput使用当前单元格调用新方法。

  if (editMode) { EditCells(currentCell); } else { hexGrid.FindDistancesTo(currentCell); } 

坐标之间的距离


现在处于导航模式,触摸其中之一后,所有单元格均显示为零。但是,当然,它们应该显示到单元格的真实距离。要计算到它们的距离,我们可以使用像元的坐标。因此,假设它HexCoordinates具有方法DistanceTo,并在中使用它HexGrid.FindDistancesTo

  public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } } 

现在添加到HexCoordinates方法中DistanceTo他必须将自己的坐标与另一组坐标进行比较。让我们仅从测量X开始,我们将相互减去X坐标。

  public int DistanceTo (HexCoordinates other) { return x - other.x; } 

结果,我们获得了相对于所选单元格沿X的偏移。但是距离不能为负,因此您需要返回坐标差X模。

  return x < other.x ? other.x - x : x - other.x; 


沿X的距离。

因此,仅在仅考虑一个维度的情况下,我们才能获得正确的距离。但是六边形网格中有三个尺寸。因此,让我们将所有三个维度的距离加起来,看看它能给我们带来什么。

  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); 


XYZ距离的总和。

事实证明,我们得到的距离是原来的两倍。即,为了获得正确的距离,必须将该数量分成两半。

  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; 


真实距离。

为什么总和等于距离的两倍?
, . , (1, −3, 2). . , . . , . .


.

统一包装

克服障碍


我们计算出的距离对应于从选定单元格到其他单元格的最短路径。我们找不到更短的方法。但是,如果路由不阻塞任何内容,则可以保证这些路径是正确的。悬崖,水和其他障碍物会使我们四处走走。也许根本无法到达某些细胞。

为了找到绕过障碍物的方法,我们需要使用其他方法,而不是简单地计算坐标之间的距离。我们不再能够单独检查每个单元格。我们将不得不搜索地图,直到找到可以到达的每个单元。

搜索可视化


地图搜索是一个反复的过程。要了解我们在做什么,查看搜索的每个阶段会有所帮助。我们可以通过将搜索算法转换为协程来实现,为此我们需要一个搜索空间System.Collections每秒60次迭代的刷新率很小,足以让我们看到正在发生的事情,并且在小地图上进行搜索并不会花费太多时间。

  public void FindDistancesTo (HexCell cell) { StartCoroutine(Search(cell)); } IEnumerator Search (HexCell cell) { WaitForSeconds delay = new WaitForSeconds(1 / 60f); for (int i = 0; i < cells.Length; i++) { yield return delay; cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } } 

我们需要确保在任何给定时间只有一个搜索处于活动状态。因此,在开始新搜索之前,我们将停止所有协程。

  public void FindDistancesTo (HexCell cell) { StopAllCoroutines(); StartCoroutine(Search(cell)); } 

此外,加载新地图时,我们需要完成搜索。

  public void Load (BinaryReader reader, int header) { StopAllCoroutines(); … } 

广度优先搜索


甚至在开始搜索之前,我们就知道到选定单元格的距离为零。而且,如果可以达到的话,到所有邻居的距离当然是1。然后,我们可以看看这些邻居之一。此单元格很可能具有其自己的邻居,并且尚未计算出其距离。如果是这样,那么到这些邻居的距离应该是2。我们可以对距离为1的所有邻居重复此过程。之后,我们对距离为2的所有邻居重复此过程。依此类推,直到到达所有像元。

也就是说,首先找到距离为1的所有像元,然后找到距离为2的所有像元,然后距离为3的依此类推,依此类推,直到完成。这样可以确保我们找到与每个可达单元的最小距离。该算法称为广度优先搜索。

为了使其正常工作,我们需要知道是否已经确定到单元的距离。为此,通常将单元格放置在称为现成或封闭集合的集合中。但是我们可以设置到该单元格的距离以int.MaxValue指示我们尚未访问它。我们需要在执行搜索之前对所有单元格执行此操作。

  IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } … } 

您也可以使用更改更改隐藏所有未访问的单元格HexCell.UpdateDistanceLabel之后,我们将在空白地图上开始每次搜索。

  void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance == int.MaxValue ? "" : distance.ToString(); } 

接下来,我们需要跟踪需要访问的单元以及它们的访问顺序。这样的集合通常称为边界或开放集。我们只需要按照遇到它们的相同顺序处理单元。为此,您可以使用队列Queue,它是命名空间的一部分System.Collections.Generic所选单元格将是第一个放置在此队列中的单元,并且距离为0。

  IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } WaitForSeconds delay = new WaitForSeconds(1 / 60f); Queue<HexCell> frontier = new Queue<HexCell>(); cell.Distance = 0; frontier.Enqueue(cell); // for (int i = 0; i < cells.Length; i++) { // yield return delay; // cells[i].Distance = // cell.coordinates.DistanceTo(cells[i].coordinates); // } } 

从这一刻起,当队列中有内容时,算法将执行循环。在每次迭代时,都会从队列中检索最前面的单元格。

  frontier.Enqueue(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); } 

现在我们有了当前单元格,该单元格可以任意距离。接下来,我们需要将其所有邻居添加到队列中,离所选单元格更远一步。

  while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor != null) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } } } 

但是,我们应该仅添加尚未指定距离的那些像元。

  if (neighbor != null && neighbor.Distance == int.MaxValue) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } 


广泛的搜索。

避免饮水


确保广度优先搜索在单调地图上找到正确的距离后,我们可以开始添加障碍。如果满足某些条件,可以通过拒绝将单元格添加到队列中来完成。

实际上,我们已经跳过了一些单元格:那些不存在的单元格,以及我们已经指出的距离。让我们重写代码,以便在这种情况下我们显式跳过邻居。

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } 

我们还跳过所有在水下的细胞。这意味着在搜索最短距离时,我们仅考虑地面运动。

  if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } if (neighbor.IsUnderwater) { continue; } 


无水移动距离。

该算法仍然可以找到最短的距离,但是现在可以避免所有积水。因此,水下细胞永远不会像隔离的土地那样增加距离。如果选择了水下单元,则仅接收距离。

避免悬崖


同样,为了确定访问邻居的可能性,我们可以使用肋骨的类型。例如,您可以让悬崖遮挡整个路径。如果您允许在斜坡上移动,那么仅在其他路​​径上仍可以到达悬崖另一侧的单元。因此,它们之间的距离可能非常不同。

  if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; } 


没有越过悬崖的距离。

统一包装

差旅费


我们可以避免像元和边,但是这些选项是二进制的。可以想象,在某些方向上导航比在其他方向上导航更容易。在这种情况下,距离是用人工或时间来衡量的。

快速道路


顺理成章的是,在道路上行驶更容易,更快捷,因此,让我们让边缘与道路的交点变得更便宜。由于我们使用整数值来设置距离,因此沿道路移动的成本将等于1,而与其他边缘交叉的成本将增加至10。这是一个很大的差异,可让我们立即查看是否获得正确的结果。

  int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } neighbor.Distance = distance; 


距离错误的道路。

边框排序


不幸的是,事实证明,广度优先搜索无法在变动的移动成本下工作。他假设将单元按距离增加的顺序添加到边界,对我们而言,这不再相关。我们需要一个优先级队列,即一个对自身进行排序的队列。没有标准优先级队列,因为您不能以适合所有情况的方式对其进行编程。

我们可以创建自己的优先级队列,但是让我们在以后的教程中对其进行优化。现在,我们仅将队列替换为具有method的列表Sort

  List<HexCell> frontier = new List<HexCell>(); cell.Distance = 0; frontier.Add(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier[0]; frontier.RemoveAt(0); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … neighbor.Distance = distance; frontier.Add(neighbor); } } 

我不能使用ListPool <HexCell>吗?
, , . , , .

为了使边框正确,我们需要在向其添加单元格后对其进行排序。实际上,我们可以推迟排序,直到添加了单元的所有邻居为止,但是,我重复一遍,直到优化对我们不感兴趣为止。

我们要按距离对单元格进行排序。为此,我们需要调用列表排序方法,并带有执行该比较方法的链接。

  frontier.Add(neighbor); frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance)); 

这种排序方法如何工作?
. , . .

  frontier.Sort(CompareDistances); … static int CompareDistances (HexCell x, HexCell y) { return x.Distance.CompareTo(y.Distance); } 


排序的边框仍然不正确。

边框更新


在开始对边界进行分类之后,我们开始获得更好的结果,但是仍然存在错误。这是因为将单元格添加到边界时,我们不一定会找到到该单元格的最短距离。这意味着现在我们不能再跳过已经分配了距离的邻居。相反,我们需要检查是否找到了更短的路径。如果是这样,那么我们需要更改到邻居的距离,而不是将其添加到边界。

  HexCell neighbor = current.GetNeighbor(d); if (neighbor == null) { continue; } if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; frontier.Add(neighbor); } else if (distance < neighbor.Distance) { neighbor.Distance = distance; } frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance)); 


正确的距离。

现在我们有了正确的距离,我们将开始考虑移动的成本。您可能会注意到,到某些像元的距离最初太大,但是当它们从边界移开时会被校正。这种方法称为Dijkstra算法,以Edsger Dijkstra的第一个发明命名。

斜坡


我们不希望仅限于公路成本。例如,您可以将不带道路的平面边缘相交的成本降低到5,而不带道路的坡度的值为10。

  HexEdgeType edgeType = current.GetEdgeType(neighbor); if (edgeType == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; } 


要克服斜坡,您需要做更多的工作,而且道路总是快的。

救济对象


我们可以在存在救济物的情况下增加成本。例如,在许多游戏中,导航森林更加困难。在这种情况下,我们只需将对象的所有级别添加到距离中即可。这条路再次加速了一切。

  if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; } 


如果没有路,物体会减速。

墙壁


最后,让我们考虑一下墙壁。如果道路不通过,则墙壁应阻止移动。

  if (current.HasRoadThroughEdge(d)) { distance += 1; } else if (current.Walled != neighbor.Walled) { continue; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; } 


墙壁不允许我们通过,您需要寻找大门。

统一包装

Source: https://habr.com/ru/post/zh-CN425919/


All Articles