Unity中的六边形图:路径查找器,播放器小队,动画

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

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

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

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

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

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

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

第16部分:寻找方法


  • 突出显示单元格
  • 选择搜索目标
  • 找到最短的路径
  • 创建优先级队列

在计算了单元之间的距离之后,我们开始寻找它们之间的路径。

从这一部分开始,将在Unity 5.6.0中创建六角形图教程。 应当指出,在5.6中,存在一个错误,该错误会破坏多个平台的程序集中的纹理数组。 您可以通过在纹理数组检查器中包含“可读”来解决此问题。


计划行程

突出显示的单元格


要搜索两个单元格之间的路径,我们首先需要选择这些单元格。 这不仅仅是选择一个单元并监视地图上的搜索。 例如,我们将首先选择初始单元格,然后选择最后一个单元格。 在这种情况下,突出显示它们将很方便。 因此,让我们添加这样的功能。 在我们创建复杂或有效的突出显示方式之前,我们只是创建一些东西来帮助我们进行开发。

轮廓纹理


选择单元格的一种简单方法是为其添加路径。 最简单的方法是使用包含六边形轮廓的纹理。 在这里您可以下载这样的纹理。 除六角形的白色轮廓外,它是透明的。 将其制成白色后,将来我们将能够根据需要对其进行着色。


在黑色背景上的单元格轮廓

导入纹理并将其纹理类型设置为Sprite 。 她的Sprite Mode将使用默认设置设置为Single 。 由于这是一种非常白色的纹理,因此我们无需转换为sRGB 。 Alpha通道表示透明度,因此启用Alpha为Transparency 。 我还将“ 过滤器模式”纹理设置为Trilinear ,因为否则路径的mip过渡可能会变得太明显。


纹理导入选项

每个单元格一个精灵


最快的方法是在单元格中添加可能的轮廓,并添加每个自己的精灵。 创建一个新的游戏对象,向其添加Image组件( Component / UI / Image ),并为其分配轮廓精灵。 然后将“ 十六进制单元标签”预制实例插入场景,使精灵对象成为其子级,将更改应用于预制,然后摆脱预制。



预制子选择元素

现在,每个单元都有一个精灵,但是它会太大。 为了使轮廓与单元格的中心匹配,请将精灵的变换分量的“ 宽度”和“ 高度 ”更改为17。


选择精灵被浮雕部分隐藏

在一切之上绘图


由于轮廓叠加在像元边缘的区域上,因此它通常出现在凸版的几何形状下方。 因此,部分电路消失了。 可以通过在垂直方向略微提升精灵来避免这种情况,但在中断的情况下则不能避免。 相反,我们可以执行以下操作:始终在其他所有对象上绘制精灵。 为此,请创建自己的精灵着色器。 复制标准的Unity Sprite着色器并对其进行一些更改就足够了。

Shader "Custom/Highlight" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0 [HideInInspector] _RendererColor ("RendererColor", Color) = (1,1,1,1) [HideInInspector] _Flip ("Flip", Vector) = (1,1,1,1) [PerRendererData] _AlphaTex ("External Alpha", 2D) = "white" {} [PerRendererData] _EnableExternalAlpha ("Enable External Alpha", Float) = 0 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off ZWrite Off Blend One OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex SpriteVert #pragma fragment SpriteFrag #pragma target 2.0 #pragma multi_compile_instancing #pragma multi_compile _ PIXELSNAP_ON #pragma multi_compile _ ETC1_EXTERNAL_ALPHA #include "UnitySprites.cginc" ENDCG } } } 

第一个变化是我们忽略了深度缓冲区,从而使Z测试始终成功。

  ZWrite Off ZTest Always 

第二个变化是我们在其余透明几何图形之后进行渲染。 足以将10添加到透明度队列中。

  "Queue"="Transparent+10" 

创建此材质球将使用的新材质。 我们可以忽略其所有属性,并遵循默认值。 然后使精灵预制件使用这种材料。



我们使用自己的精灵材质

现在,选择的轮廓始终可见。 即使该单元格隐藏在较高的浮雕下,其轮廓仍将绘制在其他所有元素之上。 它可能看起来不漂亮,但是所选单元格始终可见,这对我们很有用。


忽略深度缓冲区

选择控制


我们不希望同时突出显示所有单元格。 实际上,一开始它们都应该未被选中。 我们可以通过禁用Highlight预制对象的Image组件来实现。


禁用的图像组件

要启用单元格选择,请将HexCell方法添加到EnableHighlight 。 它应采用uiRect的唯一子项,并包括其Image组件。 我们还将创建DisableHighlight方法。

  public void DisableHighlight () { Image highlight = uiRect.GetChild(0).GetComponent<Image>(); highlight.enabled = false; } public void EnableHighlight () { Image highlight = uiRect.GetChild(0).GetComponent<Image>(); highlight.enabled = true; } 

最后,我们可以指定颜色,以便在打开时为背光赋予色调。

  public void EnableHighlight (Color color) { Image highlight = uiRect.GetChild(0).GetComponent<Image>(); highlight.color = color; highlight.enabled = true; } 

统一包装

找路


现在我们可以选择单元格,我们需要继续并选择两个单元格,然后找到它们之间的路径。 首先,我们需要选择单元格,然后将搜索限制在它们之间的路径上,最后显示此路径。

搜索开始


我们需要选择两个不同的单元格,即搜索的起点和终点。 假设要选择初始搜索单元格,请在单击鼠标的同时按住左Shift键。 在这种情况下,单元格将突出显示为蓝色。 我们需要保存指向该单元格的链接以进行进一步搜索。 此外,在选择新的起始单元格时,必须禁用旧单元格的选择。 因此,我们将HexMapEditor字段添加到searchFromCell

  HexCell previousCell, searchFromCell; 

HandleInput内部HandleInput我们可以使用Input.GetKey(KeyCode.LeftShift)测试按下的Shift键。

  if (editMode) { EditCells(currentCell); } else if (Input.GetKey(KeyCode.LeftShift)) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); } else { hexGrid.FindDistancesTo(currentCell); } 


在哪里看

搜索终点


现在,我们正在寻找两个特定像元之间的路径,而不是寻找到一个像元的所有距离。 因此,将HexGrid.FindDistancesTo重命名为HexGrid.FindPathHexGrid.FindPath提供第二个HexCell参数,并更改Search方法。

  public void FindPath (HexCell fromCell, HexCell toCell) { StopAllCoroutines(); StartCoroutine(Search(fromCell, toCell)); } IEnumerator Search (HexCell fromCell, HexCell toCell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } WaitForSeconds delay = new WaitForSeconds(1 / 60f); List<HexCell> frontier = new List<HexCell>(); fromCell.Distance = 0; frontier.Add(fromCell); … } 

现在, HexMapEditor.HandleInput应该使用searchFromCellcurrentCell作为参数来调用修改后的方法。 另外,我们只有在知道从哪个单元格进行搜索时才可以搜索。 而且,我们不必费心搜索起点和终点是否重合。

  if (editMode) { EditCells(currentCell); } else if (Input.GetKey(KeyCode.LeftShift)) { … } else if (searchFromCell && searchFromCell != currentCell) { hexGrid.FindPath(searchFromCell, currentCell); } 

转向搜索,我们首先需要摆脱所有先前的选择。 因此,使HexGrid.Search在重置距离时关闭选择。 由于这也会关闭初始电池的照明,因此请再次打开它。 在这一阶段,我们还可以突出显示终点。 让她变红。

  IEnumerator Search (HexCell fromCell, HexCell toCell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; cells[i].DisableHighlight(); } fromCell.EnableHighlight(Color.blue); toCell.EnableHighlight(Color.red); … } 


潜在路径的端点

极限搜索


此时,我们的搜索算法仍会计算从起始像元到所有可到达像元的距离。 但是我们不再需要它了。 一旦找到到最终单元的最终距离,我们就可以停下来。 也就是说,当当前单元格有限时,我们可以退出算法循环。

  while (frontier.Count > 0) { yield return delay; HexCell current = frontier[0]; frontier.RemoveAt(0); if (current == toCell) { break; } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } } 


停在终点

如果无法到达端点会怎样?
然后,该算法将继续起作用,直到找到所有可到达的小区为止。 不可能过早退出,它将作为旧的FindDistancesTo方法工作。

路径显示


我们可以找到路径起点和终点之间的距离,但尚不知道真正的路径是什么。 要找到它,您需要跟踪如何到达每个单元格。 但是怎么做呢?

当将一个单元格添加到边界时,我们这样做是因为它是当前单元格的邻居。 一个例外是起始单元格。 通过当前单元格已到达所有其他单元格。 如果我们跟踪每个单元是从哪个单元到达的,那么结果就是一个单元网络。 更准确地说,是一个树状网络,其根源是起点。 到达终点后,我们可以使用它来构建路径。


描述中心路径的树形网络

我们可以通过添加指向HexCell另一个单元格的链接来保存此信息。 我们不需要序列化此数据,因此我们为此使用标准属性。

  public HexCell PathFrom { get; set; } 

HexGrid.Search将其添加到边框时,将其邻居的PathFrom值设置为当前单元格。 此外,当我们找到通往邻居的更短路径时,我们需要更改此链接。

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.PathFrom = current; frontier.Add(neighbor); } else if (distance < neighbor.Distance) { neighbor.Distance = distance; neighbor.PathFrom = current; } 

到达终点后,我们可以通过将这些链接跟随回到起始单元格并选择它们来可视化路径。

  if (current == toCell) { current = current.PathFrom; while (current != fromCell) { current.EnableHighlight(Color.white); current = current.PathFrom; } break; } 


找到路径

值得考虑的是,通常有几种最短的路径。 找到的一个取决于细胞的处理顺序。 有些路径可能看起来不错,而另一些则可能很糟糕,但是从来没有更短的路径。 我们待会儿会再谈这个。

更改搜索开始


选择起点后,更改终点将触发新的搜索。 选择新的起始单元格时,应该发生相同的事情。 为了使之成为可能, HexMapEditor还必须记住端点。

  HexCell previousCell, searchFromCell, searchToCell; 

使用此字段,我们还可以在选择新的开始时启动新的搜索。

  else if (Input.GetKey(KeyCode.LeftShift)) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); if (searchToCell) { hexGrid.FindPath(searchFromCell, searchToCell); } } else if (searchFromCell && searchFromCell != currentCell) { searchToCell = currentCell; hexGrid.FindPath(searchFromCell, searchToCell); } 

另外,我们需要避免起点和终点相等。

  if (editMode) { EditCells(currentCell); } else if ( Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell ) { … } 

统一包装

更智能的搜索


尽管我们的算法找到了最短的路径,但它花费大量时间来探索显然不会成为该路径一部分的点。 至少对我们来说很明显。 该算法无法在地图上往下看;它无法看到在某些方向上进行搜索是没有意义的。 尽管道路正朝着与终点相反的方向行驶,但他还是喜欢在道路上行驶。 是否可以使搜索更智能?

目前,在选择下一个要处理的单元格时,我们仅考虑从单元格到起点的距离。 如果我们想做得更聪明,那么我们还必须考虑到终点的距离。 不幸的是,我们还不认识他。 但是我们可以创建剩余距离的估计值。 将此估算值添加到到单元的距离,可以使我们了解通过该单元的路径的总长度。 然后,我们可以使用它来优先进行单元格搜索。

搜索启发式


当我们使用估计或猜测而不是完全已知的数据时,这称为使用搜索启发式。 此启发式表示对剩余距离的最佳猜测。 我们必须为要搜索的每个单元格确定该值,因此我们将HexCell添加一个HexCell整数属性。 我们不需要序列化它,因此另一个标准属性就足够了。

  public int SearchHeuristic { get; set; } 

我们如何对剩余距离做出假设? 在最理想的情况下,我们将有一条直通终点的道路。 如果是这样,则该距离等于此像元与最终像元之间坐标的不变距离。 让我们在启发式方法中加以利用。

由于启发式方法不依赖于先前经过的路径,因此它在搜索过程中是恒定的。 因此,当HexGrid.Search将一个单元格添加到边框时,我们只需要计算一次。

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); frontier.Add(neighbor); } 

搜索优先级


从现在开始,我们将根据与单元格的距离及其启发式方法确定搜索的优先级。 让我们在HexCell为此值添加一个属性。

  public int SearchPriority { get { return distance + SearchHeuristic; } } 

为此,请HexGrid.Search ,以使其使用此属性对边框进行排序。

  frontier.Sort( (x, y) => x.SearchPriority.CompareTo(y.SearchPriority) ); 



无启发式搜索和有启发式搜索

有效启发式


由于有了新的搜索优先级,因此实际上我们将访问较少的单元。 但是,在统一的地图上,该算法仍会处理方向错误的单元。 这是因为,默认情况下,每个移动步骤的成本为5,并且每个步骤的启发式方法仅增加1。也就是说,启发式方法的影响不是很大。

如果移动所有卡的成本相同,那么在确定启发式算法时我们可以使用相同的成本。 在我们的情况下,这将是当前的启发式方法乘以5。这将大大减少已处理单元的数量。


使用启发式×5

但是,如果地图上有道路,那么我们可以高估剩余的距离。 结果,该算法可能会出错,并创建实际上并非最短的路径。



高估和有效的启发式

为了确保找到最短路径,我们需要确保永远不要高估剩余距离。 这种方法称为有效启发式。 由于最小移动成本为1,我们别无选择,只能使用相同的成本来确定启发式方法。

严格来说,使用更低的成本是很正常的,但这只会使启发式方法变弱。 可能的最小启发式为零,这仅是Dijkstra的算法。 对于非零启发式算法,该算法称为A * (发音为“ A star”)。

为什么叫A *?
Niels Nilsson首先提出了在Dijkstra的算法中添加启发式算法的想法。 他将他的版本命名为A1。 Bertram Rafael后来提出了他称为A2的最佳版本。 然后彼得·哈特(Peter Hart)证明,具有良好的启发式A2是最佳的,也就是说,没有更好的版本。 这迫使他称算法A *表示无法改进,即不会出现A3或A4。 是的,A *算法是我们可以获得的最佳算法,但它与启发式算法一样好。

统一包装

优先队列


尽管A *是一种很好的算法,但我们的实现效果并不理想,因为我们使用列表来存储边界,该边界需要在每次迭代时进行排序。 如前一部分所述,我们需要一个优先级队列,但是它的标准实现不存在。 因此,让我们自己创建它。

轮到我们应该支持基于优先级的设置和从队列中排除的操作。 它还应支持更改队列中已有单元的优先级。 理想情况下,我们实现它,以最小化排序和分配内存的搜索。 另外,它应该保持简单。

创建自己的队列


使用所需的通用方法创建一个新的HexCellPriorityQueue类。 我们使用一个简单的列表来跟踪队列的内容。 另外,我们将向其添加Clear方法以清除队列,以便可以重复使用它。

 using System.Collections.Generic; public class HexCellPriorityQueue { List<HexCell> list = new List<HexCell>(); public void Enqueue (HexCell cell) { } public HexCell Dequeue () { return null; } public void Change (HexCell cell) { } public void Clear () { list.Clear(); } } 

我们将单元优先级存储在单元本身中。 也就是说,在将单元添加到队列之前,必须设置其优先级。 但是,如果发生优先级更改,了解旧优先级可能会很有用。 因此,我们将其添加为Change作为参数。

  public void Change (HexCell cell, int oldPriority) { } 

知道队列中有多少个单元格也很有用,因此我们为此添加Count属性。 只需使用我们将执行相应增量和减量的字段即可。

  int count = 0; public int Count { get { return count; } } public void Enqueue (HexCell cell) { count += 1; } public HexCell Dequeue () { count -= 1; return null; } … public void Clear () { list.Clear(); count = 0; } 

添加到队列


将单元格添加到队列后,我们首先将其优先级用作索引,将列表视为简单数组。

  public void Enqueue (HexCell cell) { count += 1; int priority = cell.SearchPriority; list[priority] = cell; } 

但是,这仅在列表足够长的情况下有效,否则我们将超越国界。 您可以通过将空项目添加到列表中直到达到所需的长度来避免这种情况。 这些空元素不引用该单元格,因此您可以通过将null添加到列表中来创建它们。

  int priority = cell.SearchPriority; while (priority >= list.Count) { list.Add(null); } list[priority] = cell; 


带孔清单

但这是我们每个优先级仅存储一个单元格的方式,很可能将有几个。 要跟踪具有相同优先级的所有单元,我们需要使用另一个列表。 尽管我们可以为每个优先级使用真实列表,但是我们也可以向HexCell添加属性以将它们绑定在一起。 这使我们可以创建称为链表的单元链。

  public HexCell NextWithSamePriority { get; set; } 

若要创建链,请让HexCellPriorityQueue.Enqueue强制新添加的单元在删除之前引用具有相同优先级的当前值。

  cell.NextWithSamePriority = list[priority]; list[priority] = cell; 


链表清单

从队列中删除


要从优先级队列中获取单元格,我们需要以最低的非空索引访问链接列表。 因此,我们将循环遍历该列表,直到找到它为止。 如果找不到,则队列为空,并返回null

从找到的链中,我们可以返回任何单元,因为它们都具有相同的优先级。 最简单的方法是从链的开头返回单元格。

  public HexCell Dequeue () { count -= 1; for (int i = 0; i < list.Count; i++) { HexCell cell = list[i]; if (cell != null) { return cell; } } return null; } 

要保持到其余链的链接,请使用与新起点相同优先级的下一个单元。 如果在此优先级上只有一个像元,则该元素将为null ,以后将被跳过。

  if (cell != null) { list[i] = cell.NextWithSamePriority; return cell; } 

最小跟踪


这种方法有效,但是每次接收到一个单元时都会遍历列表。 我们无法避免找到最小的非空索引,但是并不需要每次都从头开始。 相反,我们可以跟踪最低优先级并以此开始搜索。 最初,最小值基本上等于无穷大。

  int minimum = int.MaxValue; … public void Clear () { list.Clear(); count = 0; minimum = int.MaxValue; } 

将单元格添加到队列时,我们会根据需要更改最小值。

  public void Enqueue (HexCell cell) { count += 1; int priority = cell.SearchPriority; if (priority < minimum) { minimum = priority; } … } 

从队列中退出时,我们至少将列表用于迭代,而不是从头开始。

  public HexCell Dequeue () { count -= 1; for (; minimum < list.Count; minimum++) { HexCell cell = list[minimum]; if (cell != null) { list[minimum] = cell.NextWithSamePriority; return cell; } } return null; } 

这大大减少了在优先级列表循环中绕过的时间。

变更优先级


更改单元格的优先级时,必须将其从其所属的链表中删除。 为此,我们需要遵循链条,直到找到它。

让我们首先声明旧优先级列表的开头将是当前单元格,我们还将跟踪下一个单元格。 我们可以立即获取下一个单元格,因为我们知道此索引至少有一个单元格。

  public void Change (HexCell cell, int oldPriority) { HexCell current = list[oldPriority]; HexCell next = current.NextWithSamePriority; } 

如果当前单元格是更改的单元格,则这是头部单元格,我们可以将其切断,就好像我们已将其从队列中拉出一样。

  HexCell current = list[oldPriority]; HexCell next = current.NextWithSamePriority; if (current == cell) { list[oldPriority] = next; } 

如果不是这种情况,那么我们需要遵循链条,直到我们位于更改后的单元格前面的单元格中。 它包含指向已修改单元格的链接。

  if (current == cell) { list[oldPriority] = next; } else { while (next != cell) { current = next; next = current.NextWithSamePriority; } } 

此时,我们可以从链接列表中删除更改的单元格,并跳过它。

  while (next != cell) { current = next; next = current.NextWithSamePriority; } current.NextWithSamePriority = cell.NextWithSamePriority; 

删除单元格后,您需要再次添加它,以使其出现在其新优先级列表中。

  public void Change (HexCell cell, int oldPriority) { … Enqueue(cell); } 

Enqueue方法使计数器增加,但实际上我们没有添加新的单元格。 因此,为了补偿这一点,我们将不得不减少计数器。

  Enqueue(cell); count -= 1; 

队列使用


现在,我们可以利用HexGrid的优先级队列。 可以使用单个实例完成此操作,该实例可用于所有搜索操作。

  HexCellPriorityQueue searchFrontier; … IEnumerator Search (HexCell fromCell, HexCell toCell) { if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } else { searchFrontier.Clear(); } … } 

在开始循环之前,Search必须先将方法添加到队列中fromCell,并且每次迭代都从队列中单元的输出开始。这将替换旧的边界代码。

  WaitForSeconds delay = new WaitForSeconds(1 / 60f); // List<HexCell> frontier = new List<HexCell>(); fromCell.Distance = 0; // frontier.Add(fromCell); searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { yield return delay; HexCell current = searchFrontier.Dequeue(); // frontier.RemoveAt(0); … } 

更改代码,以便添加和更改邻居。更改之前,我们会记住旧的优先级。

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); // frontier.Add(neighbor); searchFrontier.Enqueue(neighbor); } else if (distance < neighbor.Distance) { int oldPriority = neighbor.SearchPriority; neighbor.Distance = distance; neighbor.PathFrom = current; searchFrontier.Change(neighbor, oldPriority); } 

此外,我们不再需要对边框进行排序。

 // frontier.Sort( // (x, y) => x.SearchPriority.CompareTo(y.SearchPriority) // ); 


使用优先级队列进行搜索

如前所述,找到的最短路径取决于单元的处理顺序。轮到我们创建与排序列表顺序不同的顺序,因此我们可以采用其他方法。由于我们为每个优先级从链接列表的开头添加和删除,因此它们更像是堆栈而不是队列。最后添加的单元格首先被处理。这种方法的副作用是该算法容易产生锯齿形。因此,之字形路径的可能性也会增加。幸运的是,这样的路径通常看起来更好,所以这种副作用对我们有利。



已排序的列表和队列具有 unitypackage 优先级



第17部分:运动受限


  • 我们找到逐步移动的方法。
  • 立即显示路径。
  • 我们创建一个更有效的搜索。
  • 我们仅可视化路径。

在这一部分中,我们将运动分为多个动作,并尽可能加快搜索速度。


几步走

逐步运动


使用六角网的战略游戏几乎总是回合制的。在地图上移动的单位的速度有限,这限制了一圈的行驶距离。

速度


为了在有限的运动,附加提供支撑HexGrid.FindPathHexGrid.Search整数参数speed它确定一动的运动范围。

  public void FindPath (HexCell fromCell, HexCell toCell, int speed) { StopAllCoroutines(); StartCoroutine(Search(fromCell, toCell, speed)); } IEnumerator Search (HexCell fromCell, HexCell toCell, int speed) { … } 

游戏中不同类型的单位使用不同的速度。骑兵快,步兵慢,等等。我们还没有单位,所以现在我们将使用恒定速度。让我们取值为24。这是一个相当大的值,不能被5整除(默认移动成本)。FindPathHexMapEditor.HandleInput恒定速度添加作为参数

  if (editMode) { EditCells(currentCell); } else if ( Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell ) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); if (searchToCell) { hexGrid.FindPath(searchFromCell, searchToCell, 24); } } else if (searchFromCell && searchFromCell != currentCell) { searchToCell = currentCell; hexGrid.FindPath(searchFromCell, searchToCell, 24); } 

动作


除了跟踪沿路径移动的总成本外,我们现在还需要知道沿路径移动需要多少次移动。但是我们不需要在每个单元格中存储此信息。可以通过将行进距离除以速度来获得。由于这些是整数,因此我们将使用整数除法。即,总距离不超过24对应于路线0。这意味着整个路径可以在当前路线中完成。如果终点距离为30,则必须为转弯1。要到达终点,设备必须在当前转弯以及下一转弯的一部分中花费所有运动。

让我们确定当前单元格及其内部所有邻居的路线HexGrid.Search就在邻居循环之前,当前单元格的路线只能计算一次。我们只要找到与邻居的距离,就可以确定邻居的举动。

  int currentTurn = current.Distance / speed; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int distance = current.Distance; 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; } int turn = distance / speed; … } 

机芯丢失


如果邻居的移动大于当前移动,则我们越过了移动的边界。如果到达邻居的必要运动为1,则一切正常。但是,如果转移到下一个单元格比较昂贵,那么一切都会变得更加复杂。

假设我们沿着同质图移动,也就是说,要进入每个像元,您需要5个移动单位。我们的速度是24,经过4个步骤,我们从机芯中花了20个单位,剩下4个,在下一步中,又需要5个单位,即比可用单位多一个。我们现阶段需要做什么?

有两种方法可以解决这种情况。第一个是即使我们没有足够的移动,也允许部队在当前回合进入第五个格。第二个是在当前移动期间禁止移动,也就是说,剩余的移动点将无法使用,并且将丢失。

选项的选择取决于游戏。通常,第一种方法更适合于单位每转只能移动几步的游戏,例如,《文明》系列的游戏。这确保了单位每转始终可以移动至少一个单元。如果单位每回合可以移动许多格,例如《奇迹时代》或《韦诺之战》,那么第二种选择更好。

由于我们使用速度24,因此我们选择第二种方法。为了使其开始工作,我们需要在将其添加到当前距离之前隔离进入下一个单元的成本。

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

结果,如果我们越过了移动的边界,那么首先我们将使用当前移动的所有移动点。我们可以通过简单地将移动乘以速度来实现。在那之后,我们增加了搬家的成本。

  int distance = current.Distance + moveCost; int turn = distance / speed; if (turn > currentTurn) { distance = turn * speed + moveCost; } 

结果,我们将在第四个单元格中使用4个未使用的移动点来完成第一步移动。这些丢失的点被添加到第五个像元的成本中,因此它的距离变为29,而不是25。因此,该距离比以前更大。例如,第十个单元格的距离为50。但是现在进入该单元格,我们需要越过两个动作的边界,失去8个运动点,也就是说,到它的距离现在变为58。


超出预期的时间

由于未使用的移动点被添加到到像元的距离中,因此在确定最短路径时要考虑它们。最有效的方法是浪费尽可能少的分数。因此,以不同的速度,我们可以获得不同的路径。

显示移动而不是距离


在玩游戏时,我们对用于查找最短路径的距离值并不十分感兴趣。我们对达到终点所需的移动次数感兴趣。因此,让我们显示移动而不是距离。

首先,摆脱UpdateDistanceLabel他的电话HexCell

  public int Distance { get { return distance; } set { distance = value; // UpdateDistanceLabel(); } } … // void UpdateDistanceLabel () { // UnityEngine.UI.Text label = uiRect.GetComponent<Text>(); // label.text = distance == int.MaxValue ? "" : distance.ToString(); // } 

相反,我们将添加到接收任意字符串HexCell常规方法SetLabel中。

  public void SetLabel (string text) { UnityEngine.UI.Text label = uiRect.GetComponent<Text>(); label.text = text; } 

我们使用这种新方法HexGrid.Search清洁细胞。要隐藏单元格,只需为其分配null

  for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; cells[i].SetLabel(null); cells[i].DisableHighlight(); } 

然后,我们为邻居的标记分配其移动的值。之后,我们将能够看到一路走下去还需要多少额外的动作。

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } else if (distance < neighbor.Distance) { int oldPriority = neighbor.SearchPriority; neighbor.Distance = distance; neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; searchFrontier.Change(neighbor, oldPriority); } 


沿 unitypackage 路径移动所需的移动

次数

即时路径


另外,在玩游戏时,我们不在乎路径搜索算法如何找到方式。我们希望立即看到请求的路径。目前,我们可以确定该算法有效,因此让我们摆脱搜索可视化的困扰。

没有corutin


为了让算法缓慢通过,我们使用了Corutin。我们不再需要这样做,因此我们将摆脱通话StartCoroutineStopAllCoroutinesc的困扰HexGrid相反,我们只是将其Search作为常规方法调用

  public void Load (BinaryReader reader, int header) { // StopAllCoroutines(); … } public void FindPath (HexCell fromCell, HexCell toCell, int speed) { // StopAllCoroutines(); // StartCoroutine(Search(fromCell, toCell, speed)); Search(fromCell, toCell, speed); } 

由于我们不再将其Search用作协程,因此不需要产量,因此我们将摆脱此运算符。这意味着我们还将删除声明WaitForSeconds,并将方法的返回类型更改为void

  void Search (HexCell fromCell, HexCell toCell, int speed) { … // WaitForSeconds delay = new WaitForSeconds(1 / 60f); fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { // yield return delay; HexCell current = searchFrontier.Dequeue(); … } } 


即时结果

搜索时间定义


现在我们可以立即获取路径,但是它们的计算速度有多快?短路径几乎立即出现,但是大地图上的长路径似乎有点慢。

让我们测量一下查找和显示路径需要花费多少时间。我们可以使用事件探查器来确定搜索时间,但这有点过多,并且会产生额外的费用。让我们改用Stopwatch命名空间中的System.Diagnostics。由于我们只是暂时使用它,因此我不会将构造添加using到脚本的开头。

在搜索之前,创建一个新的秒表并启动它。搜索完成后,停止秒表并在控制台中显示经过的时间。

  public void FindPath (HexCell fromCell, HexCell toCell, int speed) { System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); sw.Start(); Search(fromCell, toCell, speed); sw.Stop(); Debug.Log(sw.ElapsedMilliseconds); } 

让我们为算法选择最坏的情况-从大地图的左下角到右上角进行搜索。最糟糕的是统一地图,因为该算法将必须处理所有4,800个地图单​​元。


在最坏的情况下进行

搜索搜索所花费的时间可能会有所不同,因为Unity编辑器不是计算机上唯一运行的进程。因此,请对其进行多次测试以了解平均持续时间。以我为例,搜索大约需要45毫秒。这不是很多,相当于每秒22.22条路径。表示为22 pps(每秒路径数)。这意味着在计算此路径时,该帧中游戏的帧速率也将最大降低22 fps。而且这没有考虑其他所有工作,例如渲染框架本身。也就是说,我们得到了相当大的帧率降低,它将降至20 fps。

在执行这种性能测试时,您需要考虑到Unity编辑器的性能将不如最终应用程序的性能高。如果我对装配体执行相同的测试,则平均只需15毫秒。那是66 pps,这要好得多。不过,这仍然是每帧分配的资源的很大一部分,因此帧速率将低于60 fps。

在哪里可以看到程序集的调试日志?
Unity , . . , , Unity Log Files .

仅在必要时搜索。


我们可以进行简单的优化-仅在需要时执行搜索。在我们按住鼠标按钮的每个帧中启动新搜索时。因此,拖放时的帧频将不断被低估。通过HexMapEditor.HandleInput仅在我们真正在处理新端点时才启动新搜索,可以避免这种情况如果不是,则当前可见路径仍然有效。

  if (editMode) { EditCells(currentCell); } else if ( Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell ) { if (searchFromCell != currentCell) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); if (searchToCell) { hexGrid.FindPath(searchFromCell, searchToCell, 24); } } } else if (searchFromCell && searchFromCell != currentCell) { if (searchToCell != currentCell) { searchToCell = currentCell; hexGrid.FindPath(searchFromCell, searchToCell, 24); } } 

仅显示路径标签


显示旅行标记是一项相当昂贵的操作,尤其是因为我们使用了未优化的方法。对所有单元执行此操作肯定会减慢执行速度。因此,让我们跳过中的标签HexGrid.Search

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; // neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } else if (distance < neighbor.Distance) { int oldPriority = neighbor.SearchPriority; neighbor.Distance = distance; // neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; searchFrontier.Change(neighbor, oldPriority); } 

我们只需要为找到的路径查看此信息。因此,到达终点后,我们将计算路线并仅为途中的那些单元格设置标签。

  if (current == toCell) { current = current.PathFrom; while (current != fromCell) { int turn = current.Distance / speed; current.SetLabel(turn.ToString()); current.EnableHighlight(Color.white); current = current.PathFrom; } break; } 


仅显示路径单元的标签

现在,我们仅在开始和结束之间包括单元标签。但是终点是最重要的,我们还必须为其设置标签。您可以通过从目标单元格而不是从其前面的单元格开始路径循环来做到这一点。在这种情况下,端点的照明将从红色变为白色,因此在循环过程中我们将消除其背光。

  fromCell.EnableHighlight(Color.blue); // toCell.EnableHighlight(Color.red); fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); if (current == toCell) { // current = current.PathFrom; while (current != fromCell) { int turn = current.Distance / speed; current.SetLabel(turn.ToString()); current.EnableHighlight(Color.white); current = current.PathFrom; } toCell.EnableHighlight(Color.red); break; } … } 


进度信息对于端点而言最重要,

这些更改之后,最坏情况的时间在编辑器中减少为23毫秒,在完成的装配体中减少为6毫秒。这些分别是43 pps和166 pps-更好。

统一包装

最聪明的搜索


在上一部分中,我们通过实现A *算法使搜索过程更智能但是,实际上,我们仍未以最佳方式执行搜索。在每次迭代中,我们计算从当前像元到其所有邻居的距离。对于尚未或当前属于搜索边界的单元格,这是正确的。但是,已经不再需要从边界中删除的像元了,因为我们已经找到了通往这些像元的最短路径。A *的正确实现会跳过这些单元格,因此我们可以执行相同的操作。

小区搜索阶段


我们如何知道一个牢房是否已经离开边界?虽然我们无法确定这一点。因此,您需要跟踪单元格处于搜索的哪个阶段。她尚未去过边界,或者现在已经在边界,或者在国外。我们可以通过添加一个HexCell简单的integer属性来对此进行跟踪

  public int SearchPhase { get; set; } 

例如,0表示尚未到达单元格,1表示该单元格现在处于边界,2表示已从边界中删除。

击中边界


在其中,HexGrid.Search我们可以将所有单元格重置为0,并始终使用1作为边框。或者,我们可以在每次进行新搜索时增加边界数量。因此,如果每次将边界数量增加两个,我们将不必处理单元格的转储。

  int searchFrontierPhase; … void Search (HexCell fromCell, HexCell toCell, int speed) { searchFrontierPhase += 2; … } 

现在,我们需要在将单元格添加到边界时设置单元格搜索的阶段。该过程从初始单元格开始,然后将其添加到边界。

  fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); 

而且每次我们向边界添加邻居时。

  if (neighbor.Distance == int.MaxValue) { neighbor.SearchPhase = searchFrontierPhase; neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } 

边境检查


到目前为止,为验证尚未将单元格添加到边界,我们使用了等于的距离int.MaxValue现在,我们可以将单元格搜索的阶段与当前边界进行比较。

 // if (neighbor.Distance == int.MaxValue) { if (neighbor.SearchPhase < searchFrontierPhase) { neighbor.SearchPhase = searchFrontierPhase; neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } 

这意味着我们不再需要在搜索之前重置像元距离,也就是说,我们将需要做的工作更少,这很好。

  for (int i = 0; i < cells.Length; i++) { // cells[i].Distance = int.MaxValue; cells[i].SetLabel(null); cells[i].DisableHighlight(); } 

离开边界


当一个单元格从边界中移出时,我们通过增加其搜索阶段来表示这一点。这使她超出了当前边界,处于下一个边界之前。

  while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.SearchPhase += 1; … } 

现在我们可以跳过从边界删除的像元,从而避免了无意义的计算和距离比较。

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase ) { continue; } … } 

在这一点上,我们的算法仍然产生相同的结果,但是效率更高。在我的机器上,最坏情况下的搜索在编辑器中花费20毫秒,而程序集中花费5毫秒。

我们还可以计算该算法处理了单元格的次数,从而在计算到单元格的距离时增加了计数器。以前,在最坏的情况下,我们的算法计算出28,239个距离。在现成的A *算法中,我们计算出它的14120距离。数量减少了50%。这些指标对生产率的影响程度取决于计算搬迁成本时的成本。在我们的例子中,这里的工作量不大,因此装配的改进不是很大,但是在编辑器中却非常明显。

统一包装

扫清道路


启动新搜索时,我们首先需要清除先前路径的可视化。在执行此操作时,请关闭选择并从每个网格单元中删除标签。这是一个非常困难的方法。理想情况下,我们只需要丢弃属于先前路径的那些单元。

仅搜索


让我们首先从中完全删除可视化代码Search他只需要执行路径搜索,而不必知道我们将如何处理此信息。

  void Search (HexCell fromCell, HexCell toCell, int speed) { searchFrontierPhase += 2; if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } else { searchFrontier.Clear(); } // for (int i = 0; i < cells.Length; i++) { // cells[i].SetLabel(null); // cells[i].DisableHighlight(); // } // fromCell.EnableHighlight(Color.blue); fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.SearchPhase += 1; if (current == toCell) { // while (current != fromCell) { // int turn = current.Distance / speed; // current.SetLabel(turn.ToString()); // current.EnableHighlight(Color.white); // current = current.PathFrom; // } // toCell.EnableHighlight(Color.red); // break; } … } } 

为了报告Search我们找到了一种方法,我们将返回布尔值。

  bool Search (HexCell fromCell, HexCell toCell, int speed) { searchFrontierPhase += 2; if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } else { searchFrontier.Clear(); } fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.SearchPhase += 1; if (current == toCell) { return true; } … } return false; } 

记住方式


找到路径后,我们需要记住它。因此,将来我们将能够对其进行清洁。因此,我们将跟踪端点以及端点之间是否存在路径。

  HexCell currentPathFrom, currentPathTo; bool currentPathExists; … public void FindPath (HexCell fromCell, HexCell toCell, int speed) { System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); sw.Start(); currentPathFrom = fromCell; currentPathTo = toCell; currentPathExists = Search(fromCell, toCell, speed); sw.Stop(); Debug.Log(sw.ElapsedMilliseconds); } 

再次显示路径


我们可以使用记录的搜索数据再次可视化路径。让我们为此创建一个新方法ShowPath它将从路径的末端到路径的起​​点进行遍历,突出显示单元格并将笔划值分配给它们的标签。为此,我们需要知道速度,因此将其设置为参数。如果我们没有路径,则该方法只需选择端点即可。

  void ShowPath (int speed) { if (currentPathExists) { HexCell current = currentPathTo; while (current != currentPathFrom) { int turn = current.Distance / speed; current.SetLabel(turn.ToString()); current.EnableHighlight(Color.white); current = current.PathFrom; } } currentPathFrom.EnableHighlight(Color.blue); currentPathTo.EnableHighlight(Color.red); } 

FindPath搜索后调用此方法

  currentPathExists = Search(fromCell, toCell, speed); ShowPath(speed); 

扫一扫


我们再次看到了这条路,但是现在它并没有消失。要清除它,请创建一个method ClearPath实际上,它是一个副本ShowPath,除了它禁用选择和标签,但不包括它们。完成此操作后,他必须清除已不再有效的记录路径数据。

  void ClearPath () { if (currentPathExists) { HexCell current = currentPathTo; while (current != currentPathFrom) { current.SetLabel(null); current.DisableHighlight(); current = current.PathFrom; } current.DisableHighlight(); currentPathExists = false; } currentPathFrom = currentPathTo = null; } 

使用此方法,我们可以通过仅访问必需的单元格来清除旧路径的可视化,地图的大小不再重要。FindPath在开始新搜索之前,我们会先调用它

  sw.Start(); ClearPath(); currentPathFrom = fromCell; currentPathTo = toCell; currentPathExists = Search(fromCell, toCell, speed); if (currentPathExists) { ShowPath(speed); } sw.Stop(); 

此外,我们将在创建新地图时清除路径。

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

并且在加载另一张卡之前。

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

与更改之前一样,再次清除路径可视化。但是现在我们正在使用一种更有效的方法,并且在最坏的搜索情况下,时间已减少到14毫秒。仅由于更智能的清洁,才有足够的改进。组装时间降至3 ms,即333 pps。因此,搜索路径是完全实时的。

现在,我们已经快速搜索了路径,我们可以删除临时调试代码。

  public void FindPath (HexCell fromCell, HexCell toCell, int speed) { // System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); // sw.Start(); ClearPath(); currentPathFrom = fromCell; currentPathTo = toCell; currentPathExists = Search(fromCell, toCell, speed); ShowPath(speed); // sw.Stop(); // Debug.Log(sw.ElapsedMilliseconds); } 

统一包装

第18部分:单元


  • 我们将小队放置在地图上。
  • 保存并加载小队。
  • 我们找到部队的方法。
  • 我们移动单位。

现在,我们已经找到了搜索路径的方法,让我们将小队放置在地图上。


援军到达

建立小队


到目前为止,我们只处理了单元格及其固定对象。单位与它们的区别在于它们是可移动的。小队可以指任何规模的事物,从一个人或一个交通工具到整个军队。在本教程中,我们将自己限制为简单的通用单元类型。之后,我们将继续支持多种类型的单元的组合。

预制小队


要使用小队,请创建一种新型的component HexUnit现在,让我们从一个空的开始MonoBehaviour,然后为它添加功能。

 using UnityEngine; public class HexUnit : MonoBehaviour { } 

使用此组件创建一个空的游戏对象,该对象应该成为预制的。这将是小队的根对象。


预制小队。

将象征该分离的3D模型添加为子对象。我使用了一个简单的缩放立方体,并为其创建了蓝色材质。根对象决定了分离的地面水平,因此,我们相应地移动了子元素。



子多维数据集元素

向小队添加一个对撞机,以便将来更容易选择。标准立方体的对撞机非常适合我们,只需使对撞机适合一个单元即可。

创建小队实例


由于我们还没有游戏性,因此单位的创建在编辑模式下进行。因此,这应该解决HexMapEditor为此,他需要一个预制件,因此添加一个字段HexUnit unitPrefab并连接它。

  public HexUnit unitPrefab; 


连接预制件

创建单元时,我们将其放置在光标下方的单元格上。HandleInput一个用于在编辑地形时查找此单元格的代码。现在,小队也需要它,因此我们将相应的代码移至单独的方法。

  HexCell GetCellUnderCursor () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { return hexGrid.GetCell(hit.point); } return null; } 

现在,我们可以使用此方法来HandleInput简化它。

  void HandleInput () { // Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); // RaycastHit hit; // if (Physics.Raycast(inputRay, out hit)) { // HexCell currentCell = hexGrid.GetCell(hit.point); HexCell currentCell = GetCellUnderCursor(); if (currentCell) { … } else { previousCell = null; } } 

接下来,添加一个CreateUnit也使用的新方法GetCellUnderCursor如果有一个小室,我们将创建一个新的小队。

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { Instantiate(unitPrefab); } } 

为了保持层次结构整洁,让我们将网格用作班组中所有游戏对象的父级。

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { HexUnit unit = Instantiate(unitPrefab); unit.transform.SetParent(hexGrid.transform, false); } } 

添加HexMapEditor对创建单元的支持的最简单方法是按一个键。更改方法Update,使其CreateUnit在按U键时调用,与c一样HandleInput,如果光标不在GUI元素上方,则应发生这种情况。首先,我们将检查是否应编辑地图,否则,将检查是否应添加小队。如果是这样,请致电CreateUnit

  void Update () { // if ( // Input.GetMouseButton(0) && // !EventSystem.current.IsPointerOverGameObject() // ) { // HandleInput(); // } // else { // previousCell = null; // } if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButton(0)) { HandleInput(); return; } if (Input.GetKeyDown(KeyCode.U)) { CreateUnit(); return; } } previousCell = null; } 


创建小队的实例

部队安置


现在我们可以创建单位,但它们会出现在地图的原点。我们需要将它们放在正确的位置。为此,有必要让部队知道他们的位置。因此,我们添加了表示它们所占据的单元格HexUnit属性Location设置属性时,我们将更改小队的位置,以使其与单元格的位置匹配。

  public HexCell Location { get { return location; } set { location = value; transform.localPosition = value.Position; } } HexCell location; 

现在,我HexMapEditor.CreateUnit必须指定小队在光标下方的位置。然后单位将在应有的位置。

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { HexUnit unit = Instantiate(unitPrefab); unit.transform.SetParent(hexGrid.transform, false); unit.Location = cell; } } 


地图上的小队

单元方向


到目前为止,所有单元都具有相同的方向,这看起来很不自然。要恢复它们,请添加到HexUnit属性中Orientation这是一个浮点值,指示小队沿着Y轴以度为单位的旋转。设置它时,我们将相应地更改游戏对象本身的旋转。

  public float Orientation { get { return orientation; } set { orientation = value; transform.localRotation = Quaternion.Euler(0f, value, 0f); } } float orientation; 

HexMapEditor.CreateUnit从0到360度分配随机的旋转。

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { HexUnit unit = Instantiate(unitPrefab); unit.transform.SetParent(hexGrid.transform, false); unit.Location = cell; unit.Orientation = Random.Range(0f, 360f); } } 


不同的单位方向

每个小队一小队


如果未在一个单元中创建单位,则它们看起来会很好。在这种情况下,我们得到了一组看起来很奇怪的立方体。


叠加单元

有些游戏允许将多个单元放置在一个位置,而其他游戏则不允许。由于每个单元只需要一个小队,因此我将选择此选项。这意味着仅当当前单元未被占用时,我们才应该创建一个新的小队。这样您可以找出并添加到HexCellstandard属性Unit

  public HexUnit Unit { get; set; } 

我们使用此属性HexUnit.Location来让单元格知道单元是否在其上。

  public HexCell Location { get { return location; } set { location = value; value.Unit = this; transform.localPosition = value.Position; } } 

现在,它HexMapEditor.CreateUnit可以检查当前单元是否空闲。

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell && !cell.Unit) { HexUnit unit = Instantiate(unitPrefab); unit.Location = cell; unit.Orientation = Random.Range(0f, 360f); } } 

编辑繁忙的单元格


最初,单元放置正确,但是如果以后对其单元格进行编辑,一切都会改变。如果单元格的高度发生变化,则占据该单元格的单元将悬挂在其上方或陷入其中。


悬吊和溺水的小队

解决方案是在进行更改后检查小队的位置。为此,将方法添加到HexUnit到目前为止,我们只对小队的位置感兴趣,所以再问一遍。

  public void ValidateLocation () { transform.localPosition = location.Position; } 

更新单元格时,我们必须协调分离的位置,当调用方法RefreshRefreshSelfOnly对象时会发生什么HexCell当然,仅当单元中确实存在分离时才需要这样做。

  void Refresh () { if (chunk) { chunk.Refresh(); … if (Unit) { Unit.ValidateLocation(); } } } void RefreshSelfOnly () { chunk.Refresh(); if (Unit) { Unit.ValidateLocation(); } } 

去除小队


除了创建单位之外,销毁它们也会很有用。因此,添加到HexMapEditor方法DestroyUnit他必须检查光标下方的单元格中是否存在分离,如果存在,则销毁分离的游戏对象。

  void DestroyUnit () { HexCell cell = GetCellUnderCursor(); if (cell && cell.Unit) { Destroy(cell.Unit.gameObject); } } 

请注意,要进入小队,我们要经过牢房。要与小队互动,只需将鼠标移到其单元上方。因此,要使其正常工作,小队不必有对撞机。但是,添加对撞机使它们更易于选择,因为它会阻挡否则会与班组后面的单元碰撞的光线。

让我们Update结合使用Shift + U 销毁小队

  if (Input.GetKeyDown(KeyCode.U)) { if (Input.GetKey(KeyCode.LeftShift)) { DestroyUnit(); } else { CreateUnit(); } return; } 

在创建和销毁多个单元的情况下,请小心并在删除单元时清除属性。也就是说,我们明确清除了到小队的单元链接。添加到用于处理此问题HexUnit方法Die,以及销毁自己的游戏对象。

  public void Die () { location.Unit = null; Destroy(gameObject); } 

我们将在中调用此方法HexMapEditor.DestroyUnit,而不是直接销毁小队。

  void DestroyUnit () { HexCell cell = GetCellUnderCursor(); if (cell && cell.Unit) { // Destroy(cell.Unit.gameObject); cell.Unit.Die(); } } 

统一包装

保存和加载小队


现在我们可以在地图上显示单位了,我们必须将它们包括在保存和加载过程中。我们可以通过两种方式处理此任务。第一种是在记录单元时记录小队数据,以便将单元和小队数据混合。第二种方法是分别保存像元和小队数据。尽管第一种方法似乎更容易实现,但是第二种方法为我们提供了更多的结构化数据。如果我们共享数据,那么将来与他们合作将更加容易。

单位追踪


为了使所有单元保持在一起,我们需要对其进行跟踪。我们将通过添加到HexGrid单位列表来完成此操作此列表应包含地图上的所有单位。

  List<HexUnit> units = new List<HexUnit>(); 

在创建或加载新地图时,我们需要摆脱地图上的所有单位。为了简化此过程,请创建一种方法ClearUnits该方法将杀死列表中的每个人并将其清除。

  void ClearUnits () { for (int i = 0; i < units.Count; i++) { units[i].Die(); } units.Clear(); } 

我们在CreateMap和中调用此方法Load让我们在清理道路后再做。

  public bool CreateMap (int x, int z) { … ClearPath(); ClearUnits(); … } … public void Load (BinaryReader reader, int header) { ClearPath(); ClearUnits(); … } 

将小队添加到网格


现在,在创建新单位时,我们需要将它们添加到列表中。让我们为此设置一个方法,该方法AddUnit还将处理小队的位置及其父对象的参数。

  public void AddUnit (HexUnit unit, HexCell location, float orientation) { units.Add(unit); unit.transform.SetParent(transform, false); unit.Location = location; unit.Orientation = orientation; } 

现在HexMapEditor.CreatUnit,调用AddUnit一个新的分离实例,其位置和随机方向就足够了

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell && !cell.Unit) { // HexUnit unit = Instantiate(unitPrefab); // unit.transform.SetParent(hexGrid.transform, false); // unit.Location = cell; // unit.Orientation = Random.Range(0f, 360f); hexGrid.AddUnit( Instantiate(unitPrefab), cell, Random.Range(0f, 360f) ); } } 

从网格删除小队


添加一种方法来删除小队和c HexGrid只需从列表中删除小队并命令其死亡即可。

  public void RemoveUnit (HexUnit unit) { units.Remove(unit); unit.Die(); } 

我们在中调用此方法HexMapEditor.DestroyUnit,而不是直接销毁小队。

  void DestroyUnit () { HexCell cell = GetCellUnderCursor(); if (cell && cell.Unit) { // cell.Unit.Die(); hexGrid.RemoveUnit(cell.Unit); } } 

保存单位


由于我们将所有单元放在一起,因此我们需要记住它们占据了哪些单元格。最可靠的方法是保存其位置的坐标。为了使之成为可能,我们将字段X和Z 添加到编写它HexCoordinates方法Save中。

 using UnityEngine; using System.IO; [System.Serializable] public struct HexCoordinates { … public void Save (BinaryWriter writer) { writer.Write(x); writer.Write(z); } } 

方法SaveHexUnit现在可以记录单元的坐标和方向。这是我们目前拥有的所有单位数据。

 using UnityEngine; using System.IO; public class HexUnit : MonoBehaviour { … public void Save (BinaryWriter writer) { location.coordinates.Save(writer); writer.Write(orientation); } } 

由于它HexGrid跟踪单位,因此其方法Save将记录单位数据。首先,记下单位总数,然后循环遍历所有单位。

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

我们更改了存储的数据,因此将版本号SaveLoadMenu.Save增加到2。旧的引导代码仍然可以使用,因为它根本不会读取小队数据。但是,您需要增加版本号以指示文件中包含单元信息。

  void Save (string path) { using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(2); hexGrid.Save(writer); } } 

装小队


由于它HexCoordinates是一种结构,因此向其中添加常规方法没有多大意义Load让我们使其成为一个静态方法,该方法读取并返回存储的坐标。

  public static HexCoordinates Load (BinaryReader reader) { HexCoordinates c; cx = reader.ReadInt32(); cz = reader.ReadInt32(); return c; } 

由于单位数量是可变的,因此我们没有可以将数据加载到其中的预先存在的单位。我们可以在加载单元数据之前创建新的单元实例,但这将要求我们HexGrid在启动时创建新单元的实例。所以最好离开它HexUnit我们还使用静态方法HexUnit.Load让我们从简单阅读这些小队开始。为了读取方向浮点的值,我们使用方法BinaryReader.ReadSingle

为什么单身?
float , . , double , . Unity .

  public static void Load (BinaryReader reader) { HexCoordinates coordinates = HexCoordinates.Load(reader); float orientation = reader.ReadSingle(); } 

下一步是创建新小队的实例。但是,为此,我们需要一个链接到设备的预制件。为了不使其复杂化,我们为此添加一个HexUnit静态方法。

  public static HexUnit unitPrefab; 

要设置此链接,请像使用HexGrid噪声纹理一样再次使用它当我们需要支持多种类型的单元时,我们将继续寻求更好的解决方案。

  public HexUnit unitPrefab; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; CreateMap(cellCountX, cellCountZ); } … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; } } 


我们通过了单位的预制件。

在连接场地之后,我们不再需要直接链接到HexMapEditor相反,他可以使用HexUnit.unitPrefab

 // public HexUnit unitPrefab; … void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell && !cell.Unit) { hexGrid.AddUnit( Instantiate(HexUnit.unitPrefab), cell, Random.Range(0f, 360f) ); } } 

现在,我们可以在中创建新小队的实例HexUnit.Load除了返回它,我们还可以使用加载的坐标和方向将其添加到网格中。为此,请添加一个参数HexGrid

  public static void Load (BinaryReader reader, HexGrid grid) { HexCoordinates coordinates = HexCoordinates.Load(reader); float orientation = reader.ReadSingle(); grid.AddUnit( Instantiate(unitPrefab), grid.GetCell(coordinates), orientation ); } 

最后,HexGrid.Load我们计算单位数量并使用它来加载所有存储的单位,并将自身作为附加参数传递。

  public void Load (BinaryReader reader, int header) { … int unitCount = reader.ReadInt32(); for (int i = 0; i < unitCount; i++) { HexUnit.Load(reader, this); } } 

当然,这仅适用于版本不低于2的保存文件,在较新的版本中,没有要加载的单元。

  if (header >= 2) { int unitCount = reader.ReadInt32(); for (int i = 0; i < unitCount; i++) { HexUnit.Load(reader, this); } } 

现在我们可以正确上传版本2的文件,因此可以SaveLoadMenu.Load将支持的版本数增加到2。

  void Load (string path) { if (!File.Exists(path)) { Debug.LogError("File does not exist " + path); return; } using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header <= 2) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

统一包装

部队运动


小队是可移动的,因此我们必须能够在地图上移动小队。我们已经有一个路径搜索代码,但是到目前为止,我们仅在任意地方对其进行了测试。现在,我们需要删除旧的测试用户界面,并创建新的用户界面以进行小队管理。

地图编辑器清理


沿路径移动单位是游戏的一部分,不适用于地图编辑器。因此,我们将摆脱HexMapEditor与查找路径相关的所有代码。

 // HexCell previousCell, searchFromCell, searchToCell; HexCell previousCell; … void HandleInput () { HexCell currentCell = GetCellUnderCursor(); if (currentCell) { if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } if (editMode) { EditCells(currentCell); } // else if ( // Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell // ) { // if (searchFromCell != currentCell) { // if (searchFromCell) { // searchFromCell.DisableHighlight(); // } // searchFromCell = currentCell; // searchFromCell.EnableHighlight(Color.blue); // if (searchToCell) { // hexGrid.FindPath(searchFromCell, searchToCell, 24); // } // } // } // else if (searchFromCell && searchFromCell != currentCell) { // if (searchToCell != currentCell) { // searchToCell = currentCell; // hexGrid.FindPath(searchFromCell, searchToCell, 24); // } // } previousCell = currentCell; } else { previousCell = null; } } 

删除此代码后,当我们不处于编辑模式时,使编辑器处于活动状态不再有意义。因此,代替模式跟踪字段,我们可以简单地启用或禁用component HexMapEditor此外,编辑器现在不必处理UI标签。

 // bool editMode; … public void SetEditMode (bool toggle) { // editMode = toggle; // hexGrid.ShowUI(!toggle); enabled = toggle; } … void HandleInput () { HexCell currentCell = GetCellUnderCursor(); if (currentCell) { if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } // if (editMode) { EditCells(currentCell); // } previousCell = currentCell; } else { previousCell = null; } } 

由于默认情况下我们不处于地图编辑模式,因此在Awake中我们将禁用编辑器。

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

编辑地图和管理单位时,必须使用raycast搜索光标下方的当前单元格。也许将来对我们来说还有其他用途。让我们将射线投射逻辑从具有波束参数HexGrid的新方法转移到新方法GetCell

  public HexCell GetCell (Ray ray) { RaycastHit hit; if (Physics.Raycast(ray, out hit)) { return GetCell(hit.point); } return null; } 

HexMapEditor.GetCellUniderCursor 可能只是用光标束调用此方法。

  HexCell GetCellUnderCursor () { return hexGrid.GetCell(Camera.main.ScreenPointToRay(Input.mousePosition)); } 

游戏界面


为了控制游戏模式的用户界面,我们将使用一个新组件。而他只会处理单位的选择和移动。为它创建一个新的组件类型HexGameUI要完成他的工作,对他来说,到网格的链接就足够了。

 using UnityEngine; using UnityEngine.EventSystems; public class HexGameUI : MonoBehaviour { public HexGrid grid; } 

将此组件添加到UI层次结构中的新游戏对象。他不必拥有自己的对象,但是对我们来说,很明显,游戏有单独的UI。



游戏UI对象

添加HexGameUI方法SetEditMode,如中所示HexMapEditor当我们不处于编辑模式时,应打开游戏UI。另外,由于游戏UI可以使用路径,因此此处需要包含标签。

  public void SetEditMode (bool toggle) { enabled = !toggle; grid.ShowUI(!toggle); } 

在编辑模式开关的事件列表中添加游戏UI方法。这意味着当播放器更改模式时,将同时调用这两种方法。


几种事件方法。

跟踪当前单元格


根据情况,HexGameUI您需要知道光标当前位于哪个单元格下。因此,我们向其添加一个字段currentCell

  HexCell currentCell; 

创建一个UpdateCurrentCell使用HexGrid.GetCell游标束更新此字段的方法。

  void UpdateCurrentCell () { currentCell = grid.GetCell(Camera.main.ScreenPointToRay(Input.mousePosition)); } 

更新当前单元格时,我们可能需要找出它是否已更改。强制UpdateCurrentCell返回此信息。

  bool UpdateCurrentCell () { HexCell cell = grid.GetCell(Camera.main.ScreenPointToRay(Input.mousePosition)); if (cell != currentCell) { currentCell = cell; return true; } return false; } 

单位选择


在移动小队之前,必须对其进行选择和跟踪。因此,添加一个字段selectedUnit

  HexUnit selectedUnit; 

当我们尝试进行选择时,我们需要先更新当前单元格。如果当前单元是当前单元,则占据该单元的单元将成为所选单元。如果单元格中没有单位,则不会选择任何单位。让我们为此创建一个方法DoSelection

  void DoSelection () { UpdateCurrentCell(); if (currentCell) { selectedUnit = currentCell.Unit; } } 

只需单击鼠标,即可实现单位的​​选择。因此,我们添加了一种Update在激活鼠标按钮时进行选择的方法,当然,仅当光标不在GUI元素上方时才需要执行该选择。

  void Update () { if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButtonDown(0)) { DoSelection(); } } } 

在此阶段,我们学习了如何通过单击鼠标一次选择一个单位。当您单击一个空单元格时,将删除任何单位的选择。但是,尽管我们没有对此进行任何视觉确认。

小队搜寻


选择一个单位后,我们可以将其位置用作寻找路径的起点。要激活它,我们不需要再次单击鼠标按钮。相反,我们将自动查找并显示小队位置与当前像元之间的路径。Update除非做出选择,否则我们将始终在中执行此操作为此,当我们有一个分队时,我们调用method DoPathfinding

  void Update () { if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButtonDown(0)) { DoSelection(); } else if (selectedUnit) { DoPathfinding(); } } } 

DoPathfinding只是更新当前单元格,HexGrid.FindPath如果有端点则调用我们再次使用24的恒定速度。

  void DoPathfinding () { UpdateCurrentCell(); grid.FindPath(selectedUnit.Location, currentCell, 24); } 

请注意,我们不应在每次更新时都找到新路径,而应仅在当前单元格更改时才能找到新路径。

  void DoPathfinding () { if (UpdateCurrentCell()) { grid.FindPath(selectedUnit.Location, currentCell, 24); } } 


查找小队

的路径现在,我们看到选择小队后移动光标时出现的路径。因此,很明显选择了哪个单位。但是,并非总是正确清除路径。首先,如果光标在地图外,让我们清除旧路径。

  void DoPathfinding () { if (UpdateCurrentCell()) { if (currentCell) { grid.FindPath(selectedUnit.Location, currentCell, 24); } else { grid.ClearPath(); } } } 

当然,这要求它HexGrid.ClearPath是通用的,因此我们进行了这样的更改。

  public void ClearPath () { … } 

其次,在选择一个分队时,我们将清除旧的路径。

  void DoSelection () { grid.ClearPath(); UpdateCurrentCell(); if (currentCell) { selectedUnit = currentCell.Unit; } } 

最后,我们将在更改编辑模式时清除路径。

  public void SetEditMode (bool toggle) { enabled = !toggle; grid.ShowUI(!toggle); grid.ClearPath(); } 

仅搜索有效端点


我们不能总是找到方法,因为有时不可能到达最终单元。这很正常。但是有时最终的单元格本身是不能接受的。例如,我们决定路径不能包含水下细胞。但这可能取决于单位。让我们添加一个HexUnit告诉我们单元格是否为有效端点的方法。水下细胞不是。

  public bool IsValidDestination (HexCell cell) { return !cell.IsUnderwater; } 

此外,我们只允许一个单元站立在牢房中。因此,如果最后一个单元繁忙,则它将无效。

  public bool IsValidDestination (HexCell cell) { return !cell.IsUnderwater && !cell.Unit; } 

我们使用此方法HexGameUI.DoPathfinding来忽略无效的端点。

  void DoPathfinding () { if (UpdateCurrentCell()) { if (currentCell && selectedUnit.IsValidDestination(currentCell)) { grid.FindPath(selectedUnit.Location, currentCell, 24); } else { grid.ClearPath(); } } } 

移至终点


如果我们有一个有效的路径,那么我们就可以将小队移动到终点。HexGrid知道什么时候可以做到。我们使它在新的只读属性中传递此信息HasPath

  public bool HasPath { get { return currentPathExists; } } 

要移动小队,请添加到HexGameUI方法中DoMove发出命令并选择单位时将调用此方法。因此,他必须检查是否有办法,如果有,请更改分队的位置。当我们立即将小队传送到终点时。在以下教程之一中,我们将使小队一路走好。

  void DoMove () { if (grid.HasPath) { selectedUnit.Location = currentCell; grid.ClearPath(); } } 

让我们使用鼠标按钮1(右键单击)提交命令。如果选择了一个分队,我们将进行检查。如果未按下按钮,则我们搜索路径。

  void Update () { if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButtonDown(0)) { DoSelection(); } else if (selectedUnit) { if (Input.GetMouseButtonDown(1)) { DoMove(); } else { DoPathfinding(); } } } } 

现在我们可以移动单位了!但是有时他们拒绝找到通往某些牢房的方法。特别地,对于过去曾经脱离的那些细胞。发生这种情况是因为HexUnit在设置新位置时,不会更新旧位置。要解决此问题,我们将清除到该小队旧位置的链接。

  public HexCell Location { get { return location; } set { if (location) { location.Unit = null; } location = value; value.Unit = this; transform.localPosition = value.Position; } } 

避免小队


现在找到正确的方法,单位可以在地图上传送。尽管它们无法移动到已经有小队的牢房,但妨碍前进的支队将被忽略。


在途中忽略部队

一派小队通常可以互相移动,但到目前为止,我们还没有派系。因此,让我们将所有单元视为彼此断开连接并阻塞路径。这可以通过跳过中的繁忙单元来实现HexGrid.Search

  if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase ) { continue; } if (neighbor.IsUnderwater || neighbor.Unit) { continue; } 


避免分队

unitypackage

第19部分:动作动画


  • 我们在单元之间移动单位。
  • 可视化行进的路径。
  • 我们沿着曲线移动部队。
  • 我们强迫部队朝行动方向看。

在这一部分中,我们将迫使单位而不是隐形传送沿着轨道移动。


小队在路上

一路走来


在上一部分中,我们添加了单位以及移动单位的功能。尽管我们使用搜索路径来确定有效端点,但是在发出命令后,部队只是简单地传送到了最后一个牢房。为了使他们实际上遵循找到的路径,我们需要跟踪此路径并创建一个动画过程,以迫使小队在一个单元之间移动。由于查看动画很难注意到小队的运动方式,因此我们还可以在小控件的帮助下可视化行进路径。但是在继续之前,我们需要修复错误。

转弯错误


由于疏忽,我们错误地计算了到达该单元格的路线。现在我们通过将总距离除以小队速度来确定路线t = d / s,并丢弃其余部分。当进入单元格时,您需要精确地花费所有剩余的移动点数,从而发生错误。例如,当每个步骤的成本为1,速度为3时,则每转可以移动三个单元。但是,使用现有的计算,我们第一步只能采取两个步骤,因为对于第三步

= d /小号= 3 / 3 = 1


错误定义的移动速度3进行移动的总成本

为了正确计算移动,我们需要将边界从初始像元移动一级。我们可以通过在计算移动之前将距离减小1来完成此操作,然后第三步的移动将是= 2 / 3 = 0


正确的动作

我们可以通过将计算公式更改为t = d - 1 / s我们将更改为HexGrid.Search

  bool Search (HexCell fromCell, HexCell toCell, int speed) { … while (searchFrontier.Count > 0) { … int currentTurn = (current.Distance - 1) / speed; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int distance = current.Distance + moveCost; int turn = (distance - 1) / speed; if (turn > currentTurn) { distance = turn * speed + moveCost; } … } } return false; } 

我们还更改了动作的标记。

  void ShowPath (int speed) { if (currentPathExists) { HexCell current = currentPathTo; while (current != currentPathFrom) { int turn = (current.Distance - 1) / speed; … } } … } 

注意,使用这种方法,初始单元路径为-1。这是正常现象,因为我们没有显示它,并且搜索算法仍可运行。

出路


沿着小路前进是小队的任务。为了使他做到这一点,他需要知道方法。我们有此信息HexGrid,因此让我们向其添加一个方法以单元格列表的形式获取当前路径。他可以从列表池中获取它,如果确实存在路径,则返回。

  public List<HexCell> GetPath () { if (!currentPathExists) { return null; } List<HexCell> path = ListPool<HexCell>.Get(); return path; } 

通过遵循从最后一个单元格到第一个单元格的链接路径来填充列表,就像在可视化路径时所做的那样。

  List<HexCell> path = ListPool<HexCell>.Get(); for (HexCell c = currentPathTo; c != currentPathFrom; c = c.PathFrom) { path.Add(c); } return path; 

在这种情况下,我们需要整个路径,其中包括初始单元格。

  for (HexCell c = currentPathTo; c != currentPathFrom; c = c.PathFrom) { path.Add(c); } path.Add(currentPathFrom); return path; 

现在我们有了相反的路径。我们可以和他一起工作,但这不是很直观。让我们翻转列表,使其从头到尾。

  path.Add(currentPathFrom); path.Reverse(); return path; 

运动要求


现在,我们可以添加该HexUnit方法,命令他遵循该路径。最初,我们只是让他传送到最后一个牢房。我们不会立即将列表返回到池中,因为它对我们一段时间有用。

 using UnityEngine; using System.Collections.Generic; using System.IO; public class HexUnit : MonoBehaviour { … public void Travel (List<HexCell> path) { Location = path[path.Count - 1]; } … } 

为了请求移动,我们对其进行了更改,HexGameUI.DoMove以便它使用当前路径调用新方法,而不仅仅是设置单元的位置。

  void DoMove () { if (grid.HasPath) { // selectedUnit.Location = currentCell; selectedUnit.Travel(grid.GetPath()); grid.ClearPath(); } } 

路径可视化


在开始对小队进行动画处理之前,让我们检查路径是否正确。我们将通过命令HexUnit记住它必须沿其移动的路径来做到这一点,以便可以使用Gizmos将其可视化。

  List<HexCell> pathToTravel; … public void Travel (List<HexCell> path) { Location = path[path.Count - 1]; pathToTravel = path; } 

添加方法OnDrawGizmos以显示要走的最后一条路径(如果存在)。如果单元尚未移动,则路径应相等null但是由于在Play模式下重新编译后在编辑过程中对Unity进行了序列化,因此它也可能是一个空列表。

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } } 

显示路径的最简单方法是为路径的每个单元格绘制一个Gizmo球。半径为2个单位的球体适合我们。

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } for (int i = 0; i < pathToTravel.Count; i++) { Gizmos.DrawSphere(pathToTravel[i].Position, 2f); } } 

由于我们将显示分离的路径,因此我们将能够同时查看其所有最后路径。


Gizmos将显示最后经过的路径,

为了更好地显示单元格的连接,请在先前单元格和当前单元格之间的直线上画出多个球体。为此,我们需要从第二个单元格开始该过程。可以使用线性插值以0.1个单位的增量来排列球体,这样每段可以得到十个球体。

  for (int i = 1; i < pathToTravel.Count; i++) { Vector3 a = pathToTravel[i - 1].Position; Vector3 b = pathToTravel[i].Position; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Vector3.Lerp(a, b, t), 2f); } } 


更明显的方法

一路下滑


您可以使用相同的方法移动单位。让我们为此创建一个协程。代替绘制小控件,我们将设置小队的位置。而不是增加,我们将使用0.1时间增量,并且将对每次迭代执行yield。在这种情况下,小队将在一秒钟内从一个牢房移动到下一个牢房。

 using UnityEngine; using System.Collections; using System.Collections.Generic; using System.IO; public class HexUnit : MonoBehaviour { … IEnumerator TravelPath () { for (int i = 1; i < pathToTravel.Count; i++) { Vector3 a = pathToTravel[i - 1].Position; Vector3 b = pathToTravel[i].Position; for (float t = 0f; t < 1f; t += Time.deltaTime) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } } … } 

让我们在方法的最后开始协程Travel但是首先,我们将停止所有现有的协程。因此,我们保证两个协程不会同时启动,否则会导致非常奇怪的结果。

  public void Travel (List<HexCell> path) { Location = path[path.Count - 1]; pathToTravel = path; StopAllCoroutines(); StartCoroutine(TravelPath()); } 

每秒移动一个单元格非常慢。游戏中的玩家不想等待那么久。您可以将小队的移动速度作为配置选项,但是现在,让我们使用一个常量。我为她分配了每秒4个单元的值;它的速度非常快,但请注意发生了什么。

  const float travelSpeed = 4f; … IEnumerator TravelPath () { for (int i = 1; i < pathToTravel.Count; i++) { Vector3 a = pathToTravel[i - 1].Position; Vector3 b = pathToTravel[i].Position; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } } 

正如我们可以同时可视化多个路径一样,我们可以使多个单元同时行驶。从游戏状态的角度来看,运动仍然是隐形传送,动画是唯一的视觉效果。单位立即占据了最后一个牢房。您甚至可以找到方法并在它们到达之前开始新的动作。在这种情况下,它们将在视觉上传送到新路径的起点。可以通过在移动单元时阻止单元甚至整个UI来避免这种情况,但是在开发和测试运动时,这种快速反应非常方便。


移动单位。

高度的差异如何?
, . , . , . , . , Endless Legend, , . , .

编译后的位置


Corutin的缺点之一是,在Play模式下重新编译时,它们不会“生存”。尽管游戏状态始终为真,但如果在移动过程中开始重新编译,这可能会导致小队陷入最后路径的某个位置。为了减轻后果,让我们确保在重新编译之后,单元始终处于正确的位置。这可以通过更新其在中的位置来完成OnEnable

  void OnEnable () { if (location) { transform.localPosition = location.Position; } } 

统一包装

运动平稳


从单元的中心到中心的移动看起来过于机械化,并且会导致方向发生急剧变化。对于许多游戏来说,这是正常现象,但是如果您至少需要稍微逼真的动作,这是不可接受的。因此,让我们更改运动以使其看起来更自然。

从肋骨移到肋骨


小队从牢房的中心开始旅程。它传递到单元格边缘的中间,然后进入下一个单元格。他不必直奔中心,而可以直奔他必须越过的下一条边缘。实际上,该单元将在需要改变方向时切断路径。对于路径终点以外的所有像元,这都是可能的。


从边缘移动到边缘的三种方法

让我们适应OnDrawGizmos于显示以这种方式生成的路径。它必须在单元格的边缘之间进行插值,这可以通过平均相邻单元格的位置来找到。对我们来说,每次迭代计算一条边就足够了,重新使用前一次迭代的值。因此,我们可以使该方法适用于初始单元格,但是取而代之的是边缘而不是边缘。

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } Vector3 a, b = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { // Vector3 a = pathToTravel[i - 1].Position; // Vector3 b = pathToTravel[i].Position; a = b; b = (pathToTravel[i - 1].Position + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Vector3.Lerp(a, b, t), 2f); } } } 

要到达最终像元的中心,我们需要使用像元位置作为最后一点,而不是边缘。您可以将这种情况的验证添加到循环中,但是它是如此简单,以至于简单地复制代码并稍作更改将更加明显。

  void OnDrawGizmos () { … for (int i = 1; i < pathToTravel.Count; i++) { … } a = b; b = pathToTravel[pathToTravel.Count - 1].Position; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Vector3.Lerp(a, b, t), 2f); } } 


基于肋的

路径生成的路径不太像之字形,并且最大转弯角度从120°减小到90°。可以认为这是一种改进,因此我们在协程中应用了相同的更改,TravelPath以查看其在动画中的外观。

  IEnumerator TravelPath () { Vector3 a, b = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { // Vector3 a = pathToTravel[i - 1].Position; // Vector3 b = pathToTravel[i].Position; a = b; b = (pathToTravel[i - 1].Position + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } a = b; b = pathToTravel[pathToTravel.Count - 1].Position; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } 


以变​​化的速度运动

切割角度后,路径段的长度取决于方向的变化。但是我们将速度设置为每秒细胞数。结果,分离速度随机变化。

跟随曲线


跨越细胞边界时,方向和速度的瞬时变化看起来很难看。最好使用逐渐变化的方向。我们可以通过迫使部队遵循曲线而不是直线来增加对此的支持。您可以为此使用贝塞尔曲线。特别是,我们可以采用二次Bezier曲线,在该曲线处,像元的中心将成为中间控制点。在这种情况下,相邻曲线的切线将成为彼此的镜像,即整个路径将变为连续的平滑曲线。


从一边到

Bezier一边的曲线使用一种用于获取二次Bezier曲线上的点的方法来创建辅助类如“ 曲线和样条线”教程中所述,公式用于1 - t 2 A + 2 1 - t t B + t 2 C 在哪里 C是控制点,t是插值器。

 using UnityEngine; public static class Bezier { public static Vector3 GetPoint (Vector3 a, Vector3 b, Vector3 c, float t) { float r = 1f - t; return r * r * a + 2f * r * t * b + t * t * c; } } 

GetPoint不应该限制为0-1吗?
0-1, . . , GetPointClamped , t . , GetPointUnclamped .

要显示中的曲线路径OnDrawGizmos,我们需要跟踪的不是两个点,而是三个点。另外一个点是我们在当前迭代中使用的单元格的中心,该中心具有索引i - 1,因为周期从1开始。收到所有点后,可以将其替换Vector3.LerpBezier.GetPoint

在开始和结束单元格中,而不是终点和中点,我们可以简单地使用单元格的中心。

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } Vector3 a, b, c = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { a = c; b = pathToTravel[i - 1].Position; c = (b + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { Gizmos.DrawSphere(Bezier.GetPoint(a, b, c, t), 2f); } } a = c; b = pathToTravel[pathToTravel.Count - 1].Position; c = b; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Bezier.GetPoint(a, b, c, t), 2f); } } 


使用贝塞尔曲线创建的路径弯曲

路径看起来要好得多。我们将相同的更改应用于,TravelPath并查看如何使用这种方法对单元进行动画处理。

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { a = c; b = pathToTravel[i - 1].Position; c = (b + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } } a = c; b = pathToTravel[pathToTravel.Count - 1].Position; c = b; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } } 


我们沿着曲线移动

,即使分离速度不稳定,动画也变得流畅。由于相邻线段的曲线的切线重合,因此速度是连续的。速度的变化是逐渐发生的,并在一个分离物穿过细胞时发生,而在改变方向时则变慢。如果他直行,则速度保持恒定。此外,班组以零速开始和结束旅程。这模仿了自然运动,所以就这样吧。

时间追踪


到目前为止,我们从0开始对每个段进行迭代,一直持续到达到1。在以恒定值递增时效果很好,但是迭代取决于增量时间。当对一个段的迭代完成时,我们可能会超出1个数量,具体取决于时间增量。在高帧频下这是不可见的,但是在低帧频下会导致抖动。

为了避免浪费时间,我们需要将剩余时间从一个片段转移到另一个片段。这可以通过t沿整个路径而不是仅在每个段中进行跟踪来完成。然后,在每个段的末尾,我们将从中减去1。

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; float t = 0f; for (int i = 1; i < pathToTravel.Count; i++) { a = c; b = pathToTravel[i - 1].Position; c = (b + pathToTravel[i].Position) * 0.5f; for (; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } t -= 1f; } a = c; b = pathToTravel[pathToTravel.Count - 1].Position; c = b; for (; t < 1f; t += Time.deltaTime * traveSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } } 

如果已经在执行此操作,请确保在路径的开头考虑时间增量。这意味着我们将立即开始移动,并且不会闲置一帧。

  float t = Time.deltaTime * travelSpeed; 

另外,我们并没有在路径应该结束的时间点准确地完成,而是在那一刻之前。在此,差异还可以取决于帧速率。因此,让我们让小队在终点处完全完成路径。

  IEnumerator TravelPath () { … transform.localPosition = location.Position; } 

统一包装

方向动画


单位开始沿平滑曲线移动,但它们并未根据移动方向改变方向。结果,它们似乎在滑行。为了使该运动看起来像真实的运动,我们需要旋转它们。

展望未来


就像在“ 曲线和样条线”教程中一样,我们可以使用曲线的导数来确定单位的方向。二次贝塞尔曲线的导数公式:2 1 - t B - A + t C - B 添加到Bezier方法来计算它。

  public static Vector3 GetDerivative ( Vector3 a, Vector3 b, Vector3 c, float t ) { return 2f * ((1f - t) * (b - a) + t * (c - b)); } 

导数向量位于运动方向的一条直线上。我们可以使用该方法Quaternion.LookRotation将其转换为小队转弯。我们将在每一步中执行它HexUnit.TravelPath

  transform.localPosition = Bezier.GetPoint(a, b, c, t); Vector3 d = Bezier.GetDerivative(a, b, c, t); transform.localRotation = Quaternion.LookRotation(d); yield return null; … transform.localPosition = Bezier.GetPoint(a, b, c, t); Vector3 d = Bezier.GetDerivative(a, b, c, t); transform.localRotation = Quaternion.LookRotation(d); yield return null; 

路径的开头没有错误吗?
, . B , . , t=0 , , Quaternion.LookRotation . , , t=0 . . , t>0 .
, t<1

与分离的位置相反,在路径末端的非理想定向并不重要。但是,我们需要确保其方向与最终旋转相对应。为此,完成后,我们将其方向等同于其在Y中的旋转。

  transform.localPosition = location.Position; orientation = transform.localRotation.eulerAngles.y; 

现在,这些单元正好在水平和垂直移动方向上看。这意味着它们会向前和向后倾斜,从斜坡上下降并爬上它们。为了确保它们始终笔直,我们在使用方向矢量的分量Y来确定其旋转单位之前将其强制为零。

  Vector3 d = Bezier.GetDerivative(a, b, c, t); dy = 0f; transform.localRotation = Quaternion.LookRotation(d); … Vector3 d = Bezier.GetDerivative(a, b, c, t); dy = 0f; transform.localRotation = Quaternion.LookRotation(d); 


展望未来

我们来看重点


在整个路径中,单位都在向前看,但是在开始移动之前,它们可以朝另一个方向看。在这种情况下,他们会立即改变方向。如果它们在运动开始之前朝着路径的方向转动会更好。

在其他情况下,朝正确的方向看可能会很有用,因此让我们创建一种方法LookAt来强制小队改变方向以查看特定点。可以使用方法设置所需的旋转Transform.LookAt,首先将点设置为与分离相同的垂直位置。之后,我们可以检索小队的方向。

  void LookAt (Vector3 point) { point.y = transform.localPosition.y; transform.LookAt(point); orientation = transform.localRotation.eulerAngles.y; } 

为了使分离实际发生,我们将把该方法转换为另一个corutin,以恒定的速度旋转它。旋转速度也可以调整,但是我们将再次使用该常数。旋转应该很快,大约每秒180°。

  const float rotationSpeed = 180f; … IEnumerator LookAt (Vector3 point) { … } 

没有必要修补转弯的加速,因为它是不可见的。只要在两个方向之间进行插值就足够了。不幸的是,这不像两个数时那样简单,因为角度是圆形的。例如,从350°到10°的过渡应导致顺时针旋转20°,但是简单的插补将迫使逆时针方向旋转340°。

创建正确旋转的最简单方法是使用球面插值在两个四元数之间进行插值。这将导致最短的转弯。为此,我们获取开始和结束的四元数,然后使用进行过渡Quaternion.Slerp

  IEnumerator LookAt (Vector3 point) { point.y = transform.localPosition.y; Quaternion fromRotation = transform.localRotation; Quaternion toRotation = Quaternion.LookRotation(point - transform.localPosition); for (float t = Time.deltaTime; t < 1f; t += Time.deltaTime) { transform.localRotation = Quaternion.Slerp(fromRotation, toRotation, t); yield return null; } transform.LookAt(point); orientation = transform.localRotation.eulerAngles.y; } 

这将起作用,但是插值总是从0到1,无论旋转角度如何。为了确保均匀的角速度,我们需要在旋转角度增加时减慢插值速度。

  Quaternion fromRotation = transform.localRotation; Quaternion toRotation = Quaternion.LookRotation(point - transform.localPosition); float angle = Quaternion.Angle(fromRotation, toRotation); float speed = rotationSpeed / angle; for ( float t = Time.deltaTime * speed; t < 1f; t += Time.deltaTime * speed ) { transform.localRotation = Quaternion.Slerp(fromRotation, toRotation, t); yield return null; } 

知道角度后,如果转弯为零,我们可以完全跳过转弯。

  float angle = Quaternion.Angle(fromRotation, toRotation); if (angle > 0f) { float speed = rotationSpeed / angle; for ( … ) { … } } 

现在,我们可以TravelPath在移动LookAt第二个像元位置之前简单地执行yield 来增加单位的旋转Unity将自动启动协程LookAt,并TravelPath等待其完成。

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position); float t = Time.deltaTime * travelSpeed; … } 

如果您检查代码,则小队将传送到最后一个单元,然后转到那里,然后再传送回到路径的起​​点并开始从那里开始移动。发生这种情况是因为我们Location在协程开始之前为属性分配了一个值TravelPath为了摆脱隐形传送,我们可以在开始TravelPath时将分离的位置返回到初始单元格。

  Vector3 a, b, c = pathToTravel[0].Position; transform.localPosition = c; yield return LookAt(pathToTravel[1].Position); 


移动前先转弯

扫一扫


收到了我们需要的动作之后,我们就可以摆脱这种方法了OnDrawGizmos删除它或注释掉,以备将来需要查看路径时使用。

 // void OnDrawGizmos () { // … // } 

由于我们不再需要记住移动的方向,因此最终TravelPath您可以释放单元格列表。

  IEnumerator TravelPath () { … ListPool<HexCell>.Add(pathToTravel); pathToTravel = null; } 

真正的小队动画呢?
, . 3D- . . , . Mecanim, TravelPath .

统一包装

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


All Articles