在Unity中创建塔防,第1部分

领域


  • 创建一个图块字段。
  • 使用广度优先搜索的搜索路径。
  • 实现对空瓦和端瓦以及墙砖的支持。
  • 在游戏模式下编辑内容。
  • 网格字段和路径的可选显示。

这是有关创建简单塔防游戏的一系列教程的第一部分。 在这一部分中,我们将考虑创建一个运动场,寻找路径并放置最终的瓷砖和墙壁。

该教程是在Unity 2018.3.0f2中创建的。


准备用于塔防类型游戏中的字段。

塔防游戏


塔防是一种游戏类型,玩家的目标是消灭成群的敌人,直到他们到达终点。 玩家通过建造攻击敌人的塔来实现他的目标。 这种体裁有很多变化。 我们将创建一个带有平铺场的游戏。 敌人将越过场地向终点移动,玩家将为他们制造障碍。

我假设您已经研究了一系列有关对象管理的教程。

领域


比赛场地是游戏中最重要的部分,因此我们将首先创建它。 这将是一个具有其自己的组件GameBoard的游戏对象,可以通过在两个维度中设置大小来初始化它,我们可以使用Vector2Int的值。 该字段可以使用任何大小,但是我们将在其他地方选择大小,因此我们将为此创建一个通用的Initialize方法。

此外,我们以一个四边形可视化该场,该四边形表示地球。 我们不会将字段对象本身设为四边形,而是向其添加子四边形对象。 初始化后,我们使地球的XY比例等于场的大小。 也就是说,每个图块的大小将为引擎的一个方形度量单位。

 using UnityEngine; public class GameBoard : MonoBehaviour { [SerializeField] Transform ground = default; Vector2Int size; public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); } } 

为什么要明确将地面设置为默认值?
这个想法是,可以通过序列化的隐藏字段访问通过Unity编辑器可自定义的所有内容。 必须仅在检查器中更改这些字段。 不幸的是,Unity编辑器将不断显示一个编译器警告,该值从未分配。 我们可以通过显式设置字段的默认值来抑制此警告。 您还可以分配null ,但是我这样做是为了明确表明我们只是使用默认值,它不是对地的真实引用,因此我们使用default

在新场景中创建一个野外对象,并添加一个具有类似于地球材质的子四边形。 由于我们正在创建一个简单的原型游戏,因此均匀的绿色材料就足够了。 沿X轴将四边形旋转90°,使其位于XZ平面上。




运动场。

为什么不将游戏放置在XY平面上?
尽管游戏将在2D空间中进行,但我们将使用3D敌人和可以相对于特定点移动的摄像头进行3D渲染。 XZ平面对此更加方便,并且对应于环境照明所使用的标准天窗方向。

游戏


接下来,创建一个负责整个游戏的Game组件。 在此阶段,这意味着它正在初始化该字段。 我们只是通过检查器使尺寸可自定义,并在组件唤醒时强制组件初始化该字段。 让我们使用默认大小11×11。

 using UnityEngine; public class Game : MonoBehaviour { [SerializeField] Vector2Int boardSize = new Vector2Int(11, 11); [SerializeField] GameBoard board = default; void Awake () { board.Initialize(boardSize); } } 

字段大小只能为正,并且使用单个图块创建字段几乎没有意义。 因此,将最小值限制为2×2。 这可以通过添加OnValidate方法来实现,强制限制最小值。

  void OnValidate () { if (boardSize.x < 2) { boardSize.x = 2; } if (boardSize.y < 2) { boardSize.y = 2; } } 

什么时候调用Onvalidate?
如果存在,则Unity编辑器在更改组件后将其调用。 包括将它们添加到游戏对象时,加载场景之后,重新编译之后,在编辑器中更改之后,取消/重试之后以及重置组件之后。

OnValidate是代码中唯一可以为组件配置字段分配值的位置。


游戏对象。

现在,当您启动游戏模式时,我们将收到一个大小正确的字段。 在游戏中,放置摄像机的位置使其可见,然后复制其转换组件,退出播放模式并粘贴该组件的值。 如果原点是11×11视场,则可以从上方(0.10.0)定位摄像头,然后将其沿X轴旋转90°,以便从上方观看,我们可以将摄像头保持在此固定位置,但可以将来进行更改。


相机在领域。

如何复制和粘贴组件值?
通过单击带有齿轮在组件右上角的按钮时出现的下拉菜单。

预制瓦


该字段由正方形瓷砖组成。 敌人将能够在瓷砖之间移动,穿过边缘,但不能沿对角线移动。 移动将始终朝着最近的终点进行。 让我们用箭头用图形表示沿着瓷砖的移动方向。 您可以在此处下载箭头纹理。


在黑色背景上的箭头。

将箭头纹理放置在您的项目中,并启用Alpha As Transparency选项。 然后,为箭头创建材质,该材质可以是为其选择了剪切模式的默认材质,然后选择箭头作为主要纹理。


箭头材料。

为什么要使用抠图渲染模式?
它使您可以使用标准Unity渲染管道来遮盖箭头。

为了表示游戏中的每个图块,我们将使用游戏对象。 每个人都有自己的带有箭头材质的四边形,就像该字段具有一个四边形。 我们还将向图块组件添加图块,并带有指向其箭头的链接。

 using UnityEngine; public class GameTile : MonoBehaviour { [SerializeField] Transform arrow = default; } 

创建一个图块对象并将其变成预制件。 瓷砖将与地面齐平,因此将箭头向上一点一点以免渲染时出现深度问题。 同时缩小一点,以使相邻箭头之间的空间很小。 Y偏移为0.001,比例为0.8,所有轴都相同。




预制瓦。

预制砖的层次结构在哪里?
您可以通过双击预制资产或通过选择预制并单击检查器中的“ 打开预制”按钮来打开预制编辑模式。 您可以通过单击其层次结构标题左上角带有箭头的按钮来退出预制编辑模式。

请注意,图块本身不必一定是游戏对象。 仅为了跟踪字段状态才需要它们。 我们可以使用与“ 对象管理”系列教程中的行为相同的方法。 但是在简单游戏或游戏对象原型的早期,我们感到很高兴。 将来可以更改。

我们有瓷砖


要创建图块, GameBoard必须具有指向图块预制件的链接。

  [SerializeField] GameTile tilePrefab = default; 


链接到预制砖。

然后,他可以在两个网格尺寸上使用双循环创建实例。 尽管大小表示为X和Y,但我们将在XZ平面上以及区域本身上排列图块。 由于场相对于原点居中,因此我们需要从图块位置的分量中减去相应的大小减去一除以二。 请注意,这必须是浮点除法,否则即使大小也不能使用。

  public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); Vector2 offset = new Vector2( (size.x - 1) * 0.5f, (size.y - 1) * 0.5f ); for (int y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++) { GameTile tile = Instantiate(tilePrefab); tile.transform.SetParent(transform, false); tile.transform.localPosition = new Vector3( x - offset.x, 0f, y - offset.y ); } } } 


创建图块实例。

稍后,我们将需要访问这些磁贴,因此我们将在阵列中对其进行跟踪。 我们不需要列表,因为初始化后,字段的大小不会改变。

  GameTile[] tiles; public void Initialize (Vector2Int size) { … tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { GameTile tile = tiles[i] = Instantiate(tilePrefab); … } } } 

这项工作如何进行?
这是一个链接的作业。 在这种情况下,这意味着我们正在将指向平铺实例的链接分配给数组元素和局部变量。 这些操作与下面显示的代码相同。

 GameTile t = Instantiate(tilePrefab); tiles[i] = t; GameTile tile = t; 

寻找方法


在此阶段,每个图块都有一个箭头,但它们都指向Z轴的正方向,我们将其解释为北。 下一步是确定图块的正确方向。 我们通过找到敌人必须走到终点的路径来做到这一点。

瓷砖邻居


路径在北,东,南或西在瓷砖之间移动。 为了简化搜索,使GameTile跟踪到其四个邻居的链接。

  GameTile north, east, south, west; 

邻居之间的关系是对称的。 如果该图块是第二个图块的东邻,则第二个图块是第一个图块的西邻。 向GameTile添加常规静态方法以定义两个图块之间的这种关系。

  public static void MakeEastWestNeighbors (GameTile east, GameTile west) { west.east = east; east.west = west; } 

为什么要使用静态方法?
我们可以使它成为具有单个参数的实例方法,在这种情况下,我们将其称为eastTile.MakeEastWestNeighbors(westTile)或类似的名称。 但是在不清楚应调用哪个图块的情况下,最好使用静态方法。 示例是Vector3类的DistanceDot方法。

一旦连接,它就永远不会改变。 如果发生这种情况,则我们在代码中犯了一个错误。 您可以通过在将值null之前比较两个链接来进行验证,如果不正确则显示错误。 您可以Debug.Assert使用Debug.Assert方法。

  public static void MakeEastWestNeighbors (GameTile east, GameTile west) { Debug.Assert( west.east == null && east.west == null, "Redefined neighbors!" ); west.east = east; east.west = west; } 

Debug.Assert有什么作用?
如果第一个参数为false ,则使用第二个参数(如果已指定)显示条件错误。 这样的调用仅包含在测试版本中,而没有包含在发行版本中。 因此,这是在开发过程中添加不会影响最终版本的检查的好方法。

添加类似的方法以在北部和南部邻居之间建立关系。

  public static void MakeNorthSouthNeighbors (GameTile north, GameTile south) { Debug.Assert( south.north == null && north.south == null, "Redefined neighbors!" ); south.north = north; north.south = south; } 

我们可以在GameBoard.Initialize创建图块时建立这种关系。 如果X坐标大于零,则可以在当前图块与先前的图块之间创建东西向关系。 如果Y坐标大于零,则可以在当前图块和上一行中的图块之间创建南北关系。

  for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … if (x > 0) { GameTile.MakeEastWestNeighbors(tile, tiles[i - 1]); } if (y > 0) { GameTile.MakeNorthSouthNeighbors(tile, tiles[i - size.x]); } } } 

请注意,字段边缘上的图块没有四个邻居。 一两个邻居引用将保持为null

距离和方向


我们不会强迫所有敌人不断寻找道路。 每个图块只需要执行一次。 然后,敌人将能够从他们所位于的地砖上继续前进。 通过将链接添加到下一个路径图块,我们会将这些信息存储在GameTile 。 此外,我们还将保存到端点的距离,表示为敌人到达端点之前必须访问的图块数。 对于敌人来说,这些信息是无用的,但是我们将使用它来找到最短的路径。

  GameTile north, east, south, west, nextOnPath; int distance; 

每次我们确定需要查找路径时,都需要初始化路径数据。 在找到路径之前,没有下一个图块,并且距离可以认为是无限的。 我们可以将其想象为int.MaxValue的最大可能整数值。 添加通用的ClearPath方法以将GameTile重置为此状态。

  public void ClearPath () { distance = int.MaxValue; nextOnPath = null; } 

只有在有端点的情况下才能搜索路径。 这意味着图块必须成为端点。 这样的图块的距离为零,并且没有最后一个图块,因为路径在其上结束。 添加将瓦片变成端点的通用方法。

  public void BecomeDestination () { distance = 0; nextOnPath = null; } 

最终,所有图块都应变成一条路径,因此它们的距离将不再等于int.MaxValue 。 添加一个方便的getter属性,以检查图块当前是否具有路径。

  public bool HasPath => distance != int.MaxValue; 

此属性如何工作?
这是仅包含一个表达式的getter属性的简化条目。 它与以下代码相同。

  public bool HasPath { get { return distance != int.MaxValue; } } 

箭头运算符=>也可以单独用于属性的获取器和设置器,方法的主体,构造函数以及其他一些地方。

我们成长的方式


如果我们有一个带有路径的图块,那么我们可以让它向其邻居之一生长一条路径。 最初,唯一具有路径的图块是终点,因此我们从零距离开始,然后从零距离开始增加,并朝与敌人移动相反的方向移动。 也就是说,端点的所有直接邻居的距离为1,而这些图块的所有邻居的距离为2,依此类推。

添加一个GameTile隐藏方法,以通过该参数指定其邻居之一的路径。 到邻居的距离比当前图块大一倍,并且邻居的路径表示当前图块。 仅应为已具有路径的图块调用此方法,因此让我们使用assert进行检查。

  void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); neighbor.distance = distance + 1; neighbor.nextOnPath = this; } 

想法是,我们对图块的四个邻居中的每一个调用一次此方法。 由于这些链接中的某些链接将为null ,因此我们将对此进行检查并停止执行。 另外,如果邻居已经有了路径,那么我们不应该做任何事情,也应该停止这样做。

  void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); if (neighbor == null || neighbor.HasPath) { return; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; } 

其余代码未知GameTile跟踪其邻居的方式。 因此, GrowPathTo是隐藏的。 我们将添加间接指示GrowPathTo通用方法,这些方法会告诉切片在特定方向上扩展其路径。 但是,在整个字段中搜索的代码应跟踪访问了哪些图块。 因此,如果执行终止,我们将使其返回邻居或null

  GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor; } 

现在添加在特定方向上增长路径的方法。

  public GameTile GrowPathNorth () => GrowPathTo(north); public GameTile GrowPathEast () => GrowPathTo(east); public GameTile GrowPathSouth () => GrowPathTo(south); public GameTile GrowPathWest () => GrowPathTo(west); 

广泛搜索


GameBoard必须GameBoard所有图块都包含正确的路径数据。 我们通过执行广度优先搜索来做到这一点。 让我们从端点磁贴开始,然后扩展到其邻居的路径,然后到这些磁贴的邻居,依此类推。 每走一步,距离就会增加一倍,并且路径永远不会在已具有路径的图块的方向上增长。 这样可以确保所有图块都将沿着最短路径指向端点。

使用A *查找路径呢?
A *算法是广度优先搜索的进化发展。 当我们寻找唯一的最短路径时,这很有用。 但是我们需要所有最短的路径,因此A *没有任何优势。 有关广度优先搜索和带有动画的六边形网格上的A *的示例,请参阅有关六边形图的系列教程。

要执行搜索,我们需要跟踪添加到路径中但尚未从中生成路径的图块。 这种图块集合通常称为搜索边界。 重要的是,将图块按照添加到边框的顺序进行处理,因此让我们使用Queue 。 稍后我们将不得不执行几次搜索,因此我们将其设置为GameBoard的字段。

 using UnityEngine; using System.Collections.Generic; public class GameBoard : MonoBehaviour { … Queue<GameTile> searchFrontier = new Queue<GameTile>(); … } 

为了使竞争环境的状态始终为真,我们必须在Initialize的末尾找到路径,但是将代码放在单独的FindPaths方法中。 首先,您需要清除所有图块的路径,然后将一个图块作为终点并将其添加到边框。 首先选择第一个图块。 由于tiles是一个数组,因此我们可以使用foreach而不必担心内存污染。 如果以后从数组移动到列表,则还需要用for循环替换foreach循环。

  public void Initialize (Vector2Int size) { … FindPaths(); } void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); } 

接下来,我们需要从边界取一个图块,并为其所有邻居增加一条路径,并将它们全部添加到边界。 首先,我们将向北移动,然后向东,向南移动,最后向西移动。

  public void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } 

当边框中有图块时,我们将重复此阶段。

  while (searchFrontier.Count > 0) { GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } 

不断发展的道路并不总是将我们引向新的领域。 在添加到队列之前,我们需要检查null的值,但是我们可以将检查null推迟到队列的输出之后。

  GameTile tile = searchFrontier.Dequeue(); if (tile != null) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } 

显示路径


现在我们有一个包含正确路径的字段,但是到目前为止我们还没有看到。 您需要配置箭头,以便它们沿着穿过其图块的路径指向。 可以通过转动它们来完成。 由于这些转弯总是相同的,因此我们向GameTile中的每个方向添加一个静态Quaternion段。

  static Quaternion northRotation = Quaternion.Euler(90f, 0f, 0f), eastRotation = Quaternion.Euler(90f, 90f, 0f), southRotation = Quaternion.Euler(90f, 180f, 0f), westRotation = Quaternion.Euler(90f, 270f, 0f); 

还要添加常规的ShowPath方法。 如果距离为零,则图块为终点,没有东西可指向,因此请禁用其箭头。 否则,激活箭头并设置其旋转角度。 可以通过将nextOnPath及其邻居进行比较来确定所需方向。

  public void ShowPath () { if (distance == 0) { arrow.gameObject.SetActive(false); return; } arrow.gameObject.SetActive(true); arrow.localRotation = nextOnPath == north ? northRotation : nextOnPath == east ? eastRotation : nextOnPath == south ? southRotation : westRotation; } 

最后对所有磁贴调用此方法GameBoard.FindPaths

  public void FindPaths () { … foreach (GameTile tile in tiles) { tile.ShowPath(); } } 


找到方法。

我们为什么不将箭头直接变成GrowPathTo?
. . , FindPaths .

更改搜索优先级


事实证明,当终点为西南角时,所有路径都精确地向西移动,直到到达田野边缘,然后才向南移动。这里的一切都是正确的,因为到终点实际上没有更短的路径,因为对角线移动是不可能的。但是,还有许多其他最短的路径可能看起来更漂亮。

为了更好地理解为什么找到这样的路径,请将端点移到地图的中心。字段大小为奇数时,它只是数组中间的一个图块。

  tiles[tiles.Length / 2].BecomeDestination(); searchFrontier.Enqueue(tiles[tiles.Length / 2]); 


终点在中心。

如果您还记得搜索的工作原理,那么结果似乎合乎逻辑。由于我们按照东北-西南-西南的顺序添加邻居,因此北部具有最高优先级。由于我们的搜索顺序相反,因此这意味着我们经过的最后一个方向是南。这就是为什么只有很少的箭头指向南方而许多指向东方的原因。

您可以通过设置路线的优先级来更改结果。让我们交换东方和南方。因此,我们必须获得南北对称和东西向对称性。

  searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest()) 


搜索顺序为北-南-东-西。

它看起来更漂亮,但是最好改变路径的方向,使其接近自然的对角线运动。我们可以通过以棋盘图案反转相邻图块的搜索优先级来实现。

无需弄清楚我们在搜索过程中正在处理哪种类型的图块,而是添加到GameTilegeneral属性,属性指示当前图块是否是替代图块。

  public bool IsAlternative { get; set; } 

我们将在中设置此属性GameBoard.Initialize首先,如果图块的X坐标是偶数,则将它们标记为替代。

  for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.IsAlternative = (x & 1) == 0; } } 

(x&1)== 0的作用是什么?
— (AND). . 1, 1. 10101010 00001111 00001010.

. 0 1. 1, 2, 3, 4 1, 10, 11, 100. , .

AND , , . , .

其次,如果它们的Y坐标是偶数,我们将更改结果的符号。因此,我们将创建一个国际象棋图案。

  tile.IsAlternative = (x & 1) == 0; if ((y & 1) == 0) { tile.IsAlternative = !tile.IsAlternative; } 

正如FindPaths我们保持相同的顺序寻找替代瓷砖,而是要使其恢复到所有其他的瓷砖。这将迫使路径进行对角线移动并产生锯齿形。

  if (tile != null) { if (tile.IsAlternative) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest()); } else { searchFrontier.Enqueue(tile.GrowPathWest()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathNorth()); } } 


可变搜索顺序。

换瓷砖


此时,所有图块都为空。一个图块用作端点,但是除了没有可见箭头之外,它看起来与其他所有图块相同。我们将添加通过在其上放置对象来更改图块的功能。

平铺内容


切片对象本身只是跟踪切片信息的一种方法。我们不会直接修改这些对象。而是添加单独的内容并将其放在字段上。现在,我们可以区分空白图块和端点图块。为了说明这些情况,请创建一个枚举GameTileContentType

 public enum GameTileContentType { Empty, Destination } 

接下来,创建一个组件类型GameTileContent该组件类型允许您通过检查器设置其内容的类型,并通过一个通用的getter属性对其进行访问。

 using UnityEngine; public class GameTileContent : MonoBehaviour { [SerializeField] GameTileContentType type = default; public GameTileContentType Type => type; } 

然后,我们将为两种类型的内容创建预制件,每种内容都有一个GameTileContent具有相应指定类型的组件让我们使用一个蓝色的扁平立方体来指定端点图块。由于几乎是平坦的,因此他不需要对撞机。若要预制空内容,请使用空游戏对象。

目的地

空的

端点的预制件和空内容。

我们将内容对象分配给空的图块,因为这样所有图块将始终具有内容,这意味着我们无需检查与内容的链接是否相等null

内容工厂


为了使内容可编辑,我们还将使用与“ 对象管理”教程中相同的方法为此创建一个工厂这意味着您GameTileContent必须跟踪您的原始工厂(该工厂只能设置一次),然后使用方法将自己送回工厂Recycle

  GameTileContentFactory originFactory; … public GameTileContentFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } public void Recycle () { originFactory.Reclaim(this); } 

假定存在GameTileContentFactory,因此,我们将使用所需的方法为此创建可编写脚本的对象类型Recycle在此阶段,我们不会为创建一个利用内容的功能齐全的工厂而烦恼,因此我们将使其简单地破坏内容。之后,可以在不更改其余代码的情况下将对象的重用添加到工厂。

 using UnityEngine; using UnityEngine.SceneManagement; [CreateAssetMenu] public class GameTileContentFactory : ScriptableObject { public void Reclaim (GameTileContent content) { Debug.Assert(content.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(content.gameObject); } } 

Get工厂添加隐藏的方法,并以prefab作为参数。在这里,我们再次跳过对象的重用。他创建对象的实例,设置其原始工厂,将其移至工厂场景并返回。

  GameTileContent Get (GameTileContent prefab) { GameTileContent instance = Instantiate(prefab); instance.OriginFactory = this; MoveToFactoryScene(instance.gameObject); return instance; } 

实例已移至工厂内容场景,可以根据需要创建该场景。如果我们在编辑器中,那么在创建场景之前,我们需要检查它是否存在,以防在热重启期间看不到它。

  Scene contentScene; … void MoveToFactoryScene (GameObject o) { if (!contentScene.isLoaded) { if (Application.isEditor) { contentScene = SceneManager.GetSceneByName(name); if (!contentScene.isLoaded) { contentScene = SceneManager.CreateScene(name); } } else { contentScene = SceneManager.CreateScene(name); } } SceneManager.MoveGameObjectToScene(o, contentScene); } 

我们只有两种类型的内容,因此只需为其添加两个预制配置字段。

  [SerializeField] GameTileContent destinationPrefab = default; [SerializeField] GameTileContent emptyPrefab = default; 

工厂要完成的最后一件工作是创建一个Get带有参数的通用方法,该参数GameTileContentType接收相应预制件的实例。

  public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 

是否必须向每个图块添加空内容的单独实例?
, . . , - , , , , . , . , , .

让我们创建一个工厂资产并配置其到预制件的链接。


内容工厂

然后将Game链接传递到工厂。

  [SerializeField] GameTileContentFactory tileContentFactory = default; 


与工厂比赛。

攻砖瓦


要更改字段,我们需要能够选择一个图块。我们将使其在游戏模式下成为可能。我们将向玩家在游戏窗口上单击的位置的场景发出光束。如果光束与瓷砖相交,则玩家触摸了它,即必须对其进行更改。Game将处理玩家的输入,但将负责确定玩家触摸了哪个图块GameBoard

并非所有光线都与图块相交,因此有时我们什么也收不到。因此,我们将GameBoard方法添加到,该方法GetTile始终始终最初返回null(这意味着未找到图块)。

  public GameTile GetTile (Ray ray) { return null; } 

为了确定射线是否已经穿过瓷砖,我们需要Physics.Raycast通过将射线指定为参数来调用它返回有关是否有交叉路口的信息。如果是这样,尽管我们还不知道哪一块,但我们可以退还该瓷砖,因此现在我们将其退还null

  public GameTile TryGetTile (Ray ray) { if (Physics.Raycast(ray) { return null; } return null; } 

要了解是否有瓷砖相交,我们需要有关该相交的更多信息。Physics.Raycast可以使用第二个参数提供此信息RaycastHit这是输出参数,由其out前面的单词指示这意味着方法调用可以为我们传递给它的变量分配一个值。

  RaycastHit hit; if (Physics.Raycast(ray, out hit) { return null; } 

我们可以嵌入用于输出参数的变量的声明,让我们开始吧。

  if (Physics.Raycast(ray, out RaycastHit hit) { return null; } 

我们不在乎发生哪个对撞机相交,我们只是使用XZ相交位置来确定图块。我们通过将场的一半大小添加到相交点的坐标中,然后将结果转换为整数值来获得图块的坐标。结果,最终的图块索引将为其X坐标加上Y坐标乘以字段宽度。

  if (Physics.Raycast(ray, out RaycastHit hit)) { int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); return tiles[x + y * size.x]; } 

但这仅在图块的坐标在字段内时才可行,因此我们将对其进行检查。如果不是这种情况,则不会返回该图块。

  int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); if (x >= 0 && x < size.x && y >= 0 && y < size.y) { return tiles[x + y * size.x]; } 

内容变更


这样就可以更改磁贴的内容,将其添加到GameTilegeneral属性Content它的getter简单地返回内容,而setter丢弃先前的内容(如果有),然后放置新的内容。

  GameTileContent content; public GameTileContent Content { get => content; set { if (content != null) { content.Recycle(); } content = value; content.transform.localPosition = transform.localPosition; } } 

这是您唯一需要检查内容的地方null,因为最初我们没有内容。为了保证,我们执行assert,以便不使用调用设置器null

  set { Debug.Assert(value != null, "Null assigned to content!"); … } 

最后,我们需要玩家输入。在束转换点击鼠标可以通过调用进行ScreenPointToRayInput.mousePosition参数。必须拨打主摄像机的电话,可以通过进行访问Camera.main为此添加属性c Game

  Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition); 

然后,我们添加了一种Update检查升级过程中是否按下了主鼠标按钮的方法为此,请Input.GetMouseButtonDown以零作为参数进行调用。如果按下了该键,我们将处理玩家的触摸,即,我们从田地中取出砖块,并将端点作为其内容,并从工厂取出。

  void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } } void HandleTouch () { GameTile tile = GetTile(TouchRay); if (tile != null) { tile.Content = tileContentFactory.Get(GameTileContentType.Destination); } } 

现在,我们可以通过按光标将任何图块变成端点。


几个端点。

正确的领域


尽管我们可以将图块转换为端点,但这并不影响到目前为止的路径。此外,我们尚未为图块设置空内容。维护字段的正确性和完整性是一项任务GameBoard,因此让我们让他负责设置图块的内容。为了实现这一点,我们将通过其方法为它提供到内容工厂的链接Intialize,并使用它为所有图块提供空内容的实例。

  GameTileContentFactory contentFactory; public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { this.size = size; this.contentFactory = contentFactory; ground.localScale = new Vector3(size.x, size.y, 1f); tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.Content = contentFactory.Get(GameTileContentType.Empty); } } FindPaths(); } 

现在,我Game必须将工厂转移到现场。

  void Awake () { board.Initialize(boardSize, tileContentFactory); } 

为什么不将出厂配置字段添加到GameBoard?
, , . , .

由于我们现在有多个端点,GameBoard.FindPaths因此我们对其进行了更改,以使其要求BecomeDestination每个端点并将其全部添加到边界。这就是支持多个端点所需的全部。照常清除所有其他磁贴。然后,我们删除中心的固定端点。

  void FindPaths () { foreach (GameTile tile in tiles) { if (tile.Content.Type == GameTileContentType.Destination) { tile.BecomeDestination(); searchFrontier.Enqueue(tile); } else { tile.ClearPath(); } } //tiles[tiles.Length / 2].BecomeDestination(); //searchFrontier.Enqueue(tiles[tiles.Length / 2]); … } 

但是,如果我们可以将图块变成端点,那么我们应该能够执行相反的操作,将端点变成空图块。但是,我们可以得到一个完全没有终点的领域。在这种情况下,FindPaths将无法执行其任务。当所有单元格的路径初始化后边框为空时,会发生这种情况。我们将其表示为字段的无效状态,返回false并完成执行;否则最后返回true

  bool FindPaths () { foreach (GameTile tile in tiles) { … } if (searchFrontier.Count == 0) { return false; } … return true; } 

实现对删除端点的支持的最简单方法,使其成为切换操作。通过单击空白图块,我们将它们转换为端点,然后单击端点,将其删除。但是现在它正在更改内容GameBoard,因此我们将为它提供一个通用方法ToggleDestination,其参数是tile。如果图块是端点,则使其为空并调用FindPaths。否则,我们将其作为终点,也将其称为FindPaths

  public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } 

添加端点永远不会创建无效的字段状态,而删除端点则可以。因此,在将FindPaths图块清空后,我们将检查它是否成功执行如果不是,则取消更改,将图块转回到端点,然后再次调用FindPaths以返回到先前的正确状态。

  if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } 

验证可以提高效率吗?
, . , . , . FindPaths , .

现在最后,Initialize我们可以ToggleDestination使用中心磁贴作为参数来调用,而不是显式调用FindPaths这是我们唯一以无效字段状态开始的时间,但是可以保证我们以正确的状态结束。

  public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { … //FindPaths(); ToggleDestination(tiles[tiles.Length / 2]); } 

最后,我们强制Game调用ToggleDestination而不是设置磁贴本身的内容。

  void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { //tile.Content = //tileContentFactory.Get(GameTileContentType.Destination); board.ToggleDestination(tile); } } 


具有正确路径的多个端点。

我们不应该禁止Game直接设置图块的内容吗?
. . , Game . , .

墙壁


塔防的目的是防止敌人到达终点。该目标有两种实现方式。首先,我们杀死它们,其次,我们放慢它们的速度,以便有更多时间杀死它们。在方块场上,时间可以延长,增加了敌人需要走的距离。这可以通过在野外放置障碍物来实现。通常,这些都是也会杀死敌人的塔楼,但是在本教程中,我们将仅局限于墙壁。

内容内容


墙是另一种内容,因此让我们GameTileContentType向其中添加元素。

 public enum GameTileContentType { Empty, Destination, Wall } 

然后创建墙预制件。这次,我们将创建一个包含图块内容的游戏对象,并向其添加一个子多维数据集,该子多维数据集将位于该字段的顶部并填充整个图块。将它放高半个单位并保存对撞机,因为墙壁可以在视觉上与它后面的部分瓷砖重叠。因此,当玩家触摸墙壁时,他会影响相应的图块。

根

立方

预制件

预制墙。

在代码和检查器中将墙预制件添加到工厂。

  [SerializeField] GameTileContent wallPrefab = default; … public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); case GameTileContentType.Wall: return Get(wallPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 


有预制墙的工厂。

打开和关闭墙壁


GameBoard就像我们对终点所做的那样,添加到墙壁的开/关方法。最初,我们不会检查字段的错误状态。

  public void ToggleWall (GameTile tile) { if (tile.Content.Type == GameTileContentType.Wall) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Wall); FindPaths(); } } 

我们将只支持在空砖和墙砖之间切换,而不允许墙直接替换端点。因此,我们只会在瓷砖为空时创建墙。此外,墙壁应阻止对路径的搜索。但是每个瓦片都必须有一条通往终点的路径,否则敌人会被卡住。为此,我们再次需要使用validate FindPaths,如果更改创建了错误的字段状态,则将其丢弃。

  else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Wall); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } } 

与打开和关闭端点相比,打开和关闭墙壁的使用要频繁得多,因此我们将主要使用切换墙壁Game可以通过额外的触摸(通常是鼠标右键)来切换端点,这可以通过传递Input.GetMouseButtonDown值1 来识别

  void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } else if (Input.GetMouseButtonDown(1)) { HandleAlternativeTouch(); } } void HandleAlternativeTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleDestination(tile); } } void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleWall(tile); } } 


现在我们有了墙。

为什么在对角相邻的墙的阴影之间会出现较大的间隙?
, , , . , , far clipping plane . , far plane 20 . , MSAA, .

我们还要确保端点不能直接替换墙。

  public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { … } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } 

路径搜索锁


为了让墙壁阻止对路径的搜索,对于我们而言,不要在搜索边界添加带有墙壁的图块就足够了。这可以通过强制GameTile.GrowPathTo不返回带有墙的瓷砖来完成但是路径仍应沿墙的方向增长,以使场地上的所有瓷砖都具有路径。这是必要的,因为有敌人的瓷砖可能会突然变成墙壁。

  GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; } 

为确保所有图块都有路径,他们GameBoard.FindPaths必须在搜索完成后检查此路径如果不是这种情况,则该字段的状态无效,需要返回false不必为无效状态更新路径可视化,因为该字段将返回到先前的状态。

  bool FindPaths () { … foreach (GameTile tile in tiles) { if (!tile.HasPath) { return false; } } foreach (GameTile tile in tiles) { tile.ShowPath(); } return true; } 


墙壁影响道路。

为了确保墙壁实际上具有正确的路径,您需要使立方体半透明。


透明的墙壁。

注意,所有路径的正确性要求不允许墙壁围住没有终点的区域的一部分。我们可以分割地图,但前提是每一部分至少有一个端点。此外,每面墙都必须与空的瓷砖或端点相邻,否则它将无法形成路径。例如,不可能制作出3×3的实心砖块。

隐藏路


路径的可视化使我们能够查看路径搜索的工作原理,并确保它确实正确。但这并不一定要显示给玩家,或者至少不一定要显示给玩家。因此,让我们提供关闭箭头的功能。这可以通过添加GameTile常规方法来完成,该方法HidePath只需禁用其箭头即可。

  public void HidePath () { arrow.gameObject.SetActive(false); } 

路径映射状态是字段状态的一部分。GameBoard向默认值添加一个布尔值字段以false跟踪其状态,并将一个通用属性添加为getter和setter。设置器必须显示或隐藏所有图块上的路径。

  bool showPaths; public bool ShowPaths { get => showPaths; set { showPaths = value; if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } else { foreach (GameTile tile in tiles) { tile.HidePath(); } } } } 

现在,FindPaths仅当启用渲染时,该方法才应显示更新的路径。

  bool FindPaths () { … if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } return true; } 

默认情况下,禁用路径可视化。关闭瓷砖预制件中的箭头。


默认情况下,预制箭头处于非活动状态。

我们这样做是为了Game在按下某个键时切换可视化状态。使用P键是合乎逻辑的,但是在Unity编辑器中启用/禁用游戏模式也是一个热键。结果,当使用热键退出游戏模式时,可视化将切换,这看起来不太好。因此,我们使用V键(可视化的缩写)。


没有箭头。

网格显示


当箭头被隐藏时,很难分辨每个图块的位置。让我们添加网格线。从此处下载方形边框网格纹理该纹理可用作单独的图块轮廓。


网格纹理。

我们不会将此纹理单独添加到每个图块,而是将其应用于地面。但是,我们将使此网格成为可选的,以及使路径可视化。因此,我们将添加到GameBoard配置字段Texture2D并为其选择网格纹理。

  [SerializeField] Texture2D gridTexture = default; 


与网格纹理的字段。

添加另一个布尔字段和一个属性,以控制网格可视化的状态。在这种情况下,设置者必须更改地球的材质,可以通过调用GetComponent<MeshRenderer>地球并获得material结果的属性来实现如果需要显示网格,则将mainTexture网格纹理分配给material 属性否则,将其分配给他null请注意,当您更改材质的纹理时,将创建材质实例的副本,因此它独立于材质资产。

  bool showGrid, showPaths; public bool ShowGrid { get => showGrid; set { showGrid = value; Material m = ground.GetComponent<MeshRenderer>().material; if (showGrid) { m.mainTexture = gridTexture; } else { m.mainTexture = null; } } } 

让我们Game用G键切换网格的可视化。

  void Update () { … if (Input.GetKeyDown(KeyCode.G)) { board.ShowGrid = !board.ShowGrid; } } 

另外,将默认的网格可视化添加到中Awake

  void Awake () { board.Initialize(boardSize, tileContentFactory); board.ShowGrid = true; } 


未缩放的网格。

到目前为止,我们在整个领域都有边界。它与纹理匹配,但这不是我们所需要的。我们需要缩放材料的主要纹理,使其与网格的大小匹配。您可以通过调用SetTextureScale具有纹理属性名称(_MainTex)和二维尺寸material 方法来实现我们可以直接使用字段的大小,该字段的大小间接转换为value Vector2

  if (showGrid) { m.mainTexture = gridTexture; m.SetTextureScale("_MainTex", size); } 

没有

与

具有路径可视化功能的缩放网格已打开和关闭。

因此,在这个阶段,我们为塔防游戏的平铺游戏找到了一个可行的领域。在下一个教程中,我们将添加敌人。PDF

资料库

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


All Articles