在Unity中创建塔防:敌人

[ 第一部分:平铺并找到路径 ]

  • 放置敌人创造点。
  • 敌人的出现及其在野外的移动。
  • 以恒定的速度产生平稳的运动。
  • 更改敌人的大小,速度和位置。

这是一个简单的塔防游戏教程的第二部分。 它检查了创建敌人的过程及其向最近端点的移动。

本教程是在Unity 2018.3.0f2中制作的。


到达终点的路上的敌人。

敌人创造(产卵)点


在开始制造敌人之前,我们需要确定将它们放置在场地上的位置。 为此,我们将创建派生点。

平铺内容


生成点是切片内容的另一种类型,因此请在GameTileContentTypeGameTileContentType添加一个条目。

 public enum GameTileContentType { Empty, Destination, Wall, SpawnPoint } 

然后创建一个预制件以对其进行可视化。 起点的预制件副本非常适合我们,只需更改其内容类型并为其提供其他材料即可。 我把它弄成橙色。


生成点配置。

向内容工厂添加生成点支持,并为其提供到预制件的链接。

  [SerializeField] GameTileContent spawnPointPrefab = 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); case GameTileContentType.SpawnPoint: return Get(spawnPointPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 


工厂支持生成点。

启用或禁用生成点


与其他切换方法一样,切换生成点状态的方法将添加到GameBoard 。 但是生成点不会影响对路径的搜索,因此更改之后,我们无需寻找新路径。

  public void ToggleSpawnPoint (GameTile tile) { if (tile.Content.Type == GameTileContentType.SpawnPoint) { tile.Content = contentFactory.Get(GameTileContentType.Empty); } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.SpawnPoint); } } 

只有当我们有敌人并且他们需要生成点时,游戏才有意义。 因此,游戏场必须至少包含一个生成点。 以后,当我们添加敌人时,我们还需要访问生成点,因此让我们使用列表来跟踪具有这些点的所有图块。 切换生成点的状态时,我们将更新列表,并防止删除最后一个生成点。

  List<GameTile> spawnPoints = new List<GameTile>(); … public void ToggleSpawnPoint (GameTile tile) { if (tile.Content.Type == GameTileContentType.SpawnPoint) { if (spawnPoints.Count > 1) { spawnPoints.Remove(tile); tile.Content = contentFactory.Get(GameTileContentType.Empty); } } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.SpawnPoint); spawnPoints.Add(tile); } } 

现在, Initialize方法应该设置生成点以创建字段的初始正确状态。 让我们仅包括第一个图块,它位于左下角。

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

现在,我们将进行替代触摸,以切换生成点的状态,但是当您按住左Shift键(通过Input.GetKey方法检查击键)时,端点的状态将切换

  void HandleAlternativeTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { if (Input.GetKey(KeyCode.LeftShift)) { board.ToggleDestination(tile); } else { board.ToggleSpawnPoint(tile); } } } 


带有生成点的字段。

可以访问生成点


战场处理了所有的瓷砖,但敌人不是它的责任。 我们将可以通过带有索引参数的通用GetSpawnPoint方法访问其生成点。

  public GameTile GetSpawnPoint (int index) { return spawnPoints[index]; } 

为了知道哪个索引是正确的,需要有关生成点数的信息,因此我们将使用通用的getter属性使其通用。

  public int SpawnPointCount => spawnPoints.Count; 

敌人产生


生成敌人有点类似于创建图块的内容。 我们通过工厂创建一个预制实例,然后将其放置在现场。

工厂工厂


我们将为敌人建立一个工厂,将工厂创造的一切置于自己的舞台上。 此功能与我们已经拥有的工厂很常见,因此让我们将其代码放入通用基类GameObjectFactory 。 我们将只需要一个带有通用预制参数的CreateGameObjectInstance方法,该方法可以创建并返回一个实例,并且还可以管理整个场景。 我们创建protected方法,也就是说,它仅对类以及从其继承的所有类型可用。 这就是该类所做的一切;它不打算用作功能齐全的工厂。 因此,我们将其标记为abstract ,这将不允许我们创建其对象的实例。

 using UnityEngine; using UnityEngine.SceneManagement; public abstract class GameObjectFactory : ScriptableObject { Scene scene; protected T CreateGameObjectInstance<T> (T prefab) where T : MonoBehaviour { if (!scene.isLoaded) { if (Application.isEditor) { scene = SceneManager.GetSceneByName(name); if (!scene.isLoaded) { scene = SceneManager.CreateScene(name); } } else { scene = SceneManager.CreateScene(name); } } T instance = Instantiate(prefab); SceneManager.MoveGameObjectToScene(instance.gameObject, scene); return instance; } } 

更改GameTileContentFactory ,使其GameTileContentFactory此类型的工厂,并在其Get方法中使用CreateGameObjectInstance ,然后从中删除场景控制代码。

 using UnityEngine; [CreateAssetMenu] public class GameTileContentFactory : GameObjectFactory { … //Scene contentScene; … GameTileContent Get (GameTileContent prefab) { GameTileContent instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; //MoveToFactoryScene(instance.gameObject); return instance; } //void MoveToFactoryScene (GameObject o) { // … //} } 

之后,创建一个新的EnemyFactory类型,该类型使用Get方法以及随附的Reclaim方法创建一个Enemy预制实例。

 using UnityEngine; [CreateAssetMenu] public class EnemyFactory : GameObjectFactory { [SerializeField] Enemy prefab = default; public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; } public void Reclaim (Enemy enemy) { Debug.Assert(enemy.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(enemy.gameObject); } } 

新的Enemy类型最初只需要跟踪其原始工厂。

 using UnityEngine; public class Enemy : MonoBehaviour { EnemyFactory originFactory; public EnemyFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } } 

预制件


敌人需要可视化,可视化可以是任何东西-机器人,蜘蛛,鬼魂,更简单的东西(例如,我们使用的立方体)。 但总的来说,敌人拥有复杂的3D模型。 为了确保其方便的支持,我们将对敌人的预制结构使用根对象,仅将Enemy组件附加到该根对象。


预制根

让我们为该对象创建唯一的子元素,它将成为模型的根。 它必须具有转换单位值。


模型的根。

该模型根的任务是相对于敌人坐标的本地原点定位3D模型,以便他将其视为敌人站立或悬挂的参考点。 在我们的例子中,模型将是标准的半角立方体,我将为其赋予深蓝色。 我们将其设为模型根的子级,并将Y位置设置为0.25,使其立足于地面。


立方体模型

因此,敌人的预制件由三个嵌套对象组成:预制件根,模型根和立方体。 对于一个简单的多维数据集来说,它看起来像是半身像,但是这样的系统可以让您移动并为任何敌人设置动画,而无需担心其功能。


敌人的预制等级。

让我们创建一个敌人工厂并为其分配一个预制件。


资产工厂。

将敌人放在野外


要将敌人放到场上, Game必须获得指向敌人工厂的链接。 由于我们需要大量敌人,因此我们将添加一个配置选项来调整生成速度,以每秒的敌人数量表示。 可接受的范围是0.1–10,默认值为1。

  [SerializeField] EnemyFactory enemyFactory = default; [SerializeField, Range(0.1f, 10f)] float spawnSpeed = 1f; 


与敌方工厂进行游戏并产生速度4。

我们将在Update跟踪生成的进度,以速度乘以增量时间来增加它。 如果prggress值超过1,则我们将其递减并使用新的SpawnEnemy方法生成敌人。 如果速度过高且帧时间过长,以至于无法同时创建多个敌人,我们将继续这样做,直到进度超过1。

  float spawnProgress; … void Update () { … spawnProgress += spawnSpeed * Time.deltaTime; while (spawnProgress >= 1f) { spawnProgress -= 1f; SpawnEnemy(); } } 

不需要在FixedUpdate中更新进度吗?
是的,有可能,但是塔防游戏不需要如此精确的时间。 我们将仅在每一帧更新游戏状态,并使其在任何时间增量下都能正常运行。

SpawnEnemy从字段中获得随机的生成点,并在此图块中创建一个敌人。 我们将为Enemy SpawnOn方法以正确SpawnOn自身。

  void SpawnEnemy () { GameTile spawnPoint = board.GetSpawnPoint(Random.Range(0, board.SpawnPointCount)); Enemy enemy = enemyFactory.Get(); enemy.SpawnOn(spawnPoint); } 

目前,所有SpawnOn要做的就是将其自己的位置设置为与图块的中心相等。 由于预制模型的位置正确,所以敌方立方体将位于此图块的顶部。

  public void SpawnOn (GameTile tile) { transform.localPosition = tile.transform.localPosition; } 


敌人出现在产卵点。

移动敌人


敌人出现后,他必须开始沿路径移动到最近的终点。 为此,您需要为敌人设置动画。 我们从一个简单的平滑滑行开始,到另一块瓷砖,然后使它们的移动更加困难。

收集敌人


为了更新敌人的状态,我们将使用与“ 对象管理”系列教程中使用的相同方法。 我们为Enemy添加Enemy通用的GameUpdate方法,该方法返回有关他是否还活着的信息,在此阶段,该信息将始终为真。 现在,只是让他根据时间变化而前进。

  public bool GameUpdate () { transform.localPosition += Vector3.forward * Time.deltaTime; return true; } 

此外,我们需要维护一个活着的敌人列表并更新所有敌人,将它们从死敌列表中删除。 我们可以将所有这些代码放入Game ,但可以将其隔离并创建一个EnemyCollection类型。 这是一个可序列化的类,不继承任何东西。 我们给他一个添加敌人的一般方法,以及另一个更新整个集合的方法。

 using System.Collections.Generic; [System.Serializable] public class EnemyCollection { List<Enemy> enemies = new List<Enemy>(); public void Add (Enemy enemy) { enemies.Add(enemy); } public void GameUpdate () { for (int i = 0; i < enemies.Count; i++) { if (!enemies[i].GameUpdate()) { int lastIndex = enemies.Count - 1; enemies[i] = enemies[lastIndex]; enemies.RemoveAt(lastIndex); i -= 1; } } } } 

现在, Game就足以创建一个这样的集合,在每一帧中对其进行更新并向其中添加创建的敌人。 在可能产生新敌人之后,我们将立即更新敌人,以便立即进行更新。

  EnemyCollection enemies = new EnemyCollection(); … void Update () { … enemies.GameUpdate(); } … void SpawnEnemy () { … enemies.Add(enemy); } 


敌人正在前进。

一路走来


敌人已经在移动,但到目前为止还没有走这条路。 为此,他们需要知道下一步去哪里。 因此,让我们为GameTile通用的getter属性,以获取路径上的下一个图块。

  public GameTile NextTileOnPath => nextOnPath; 

敌人知道要退出的地块以及需要进入的地块后,便可以确定移动一个地块的起点和终点。 敌人可以在这两点之间插入位置,以跟踪其移动。 移动完成后,对下一个图块重复此过程。 但是路径可以随时更改。 无需确定在移动过程中进一步移动的位置,我们只需继续沿着计划的路线移动并检查它,到达下一个图块即可。

Enemy跟踪两个图块,以使其不受路径更改的影响。 他还将跟踪位置,以便我们不必在每个帧中都接收它们,并跟踪移动过程。

  GameTile tileFrom, tileTo; Vector3 positionFrom, positionTo; float progress; 

SpawnOn初始化这些字段。 第一个点是敌人从其移动的图块,终点是路径上的下一个图块。 这假定存在下一个图块,除非在端点处创建了敌人,否则这是不可能的。 然后,我们缓存图块的位置并重置进度。 我们不需要在这里设置敌人的位置,因为他的GameUpdate方法GameUpdate在同一帧中调用的。

  public void SpawnOn (GameTile tile) { //transform.localPosition = tile.transform.localPosition; Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this); tileFrom = tile; tileTo = tile.NextTileOnPath; positionFrom = tileFrom.transform.localPosition; positionTo = tileTo.transform.localPosition; progress = 0f; } 

进度增量将在GameUpdate执行。 让我们添加一个恒定的时间变化量,以便敌人以每秒一格的速度移动。 进度完成后,我们将移动数据,以使To成为值From ,而新的To是路径上的下一个图块。 然后我们递减进度。 当数据变得有意义时,我们在FromTo之间插入敌人的位置。 由于插值器是进步的,其值必须在0到1的范围内,因此我们可以使用s Vector3.LerpUnclamped

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { tileFrom = tileTo; tileTo = tileTo.NextTileOnPath; positionFrom = positionTo; positionTo = tileTo.transform.localPosition; progress -= 1f; } transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); return true; } 

这迫使敌人沿着这条路走,但在到达终点时不会行动。 因此,在更改FromTo的位置之前,需要将路径上的下一个图块与null进行比较。 如果是这样,那么我们已经到达终点,敌人已经完成了行动。 我们为此执行Reclaim并返回false

  while (progress >= 1f) { tileFrom = tileTo; tileTo = tileTo.NextTileOnPath; if (tileTo == null) { OriginFactory.Reclaim(this); return false; } positionFrom = positionTo; positionTo = tileTo.transform.localPosition; progress -= 1f; } 



敌人走的路最短。

敌人现在正在从一个砖块的中心移动到另一个砖块的中心。 值得考虑的是,它们仅在砖块的中心改变其运动状态,因此它们无法立即响应野外变化。 这意味着有时敌人会穿过刚设置的墙壁。 一旦他们开始向牢房移动,没有什么可以阻止他们。 这就是为什么墙也需要真实的道路。


敌人对变化的路径做出反应。

边到边运动


瓷砖中心之间的移动和方向的急剧变化对于抽象游戏来说是正常的,在这种抽象游戏中,敌人正在移动立方体,但通常平滑的运动看起来会更漂亮。 实施的第一步不是沿中心移动,而是沿图块的边缘移动。

相邻瓦片之间的边缘点可以通过平均其位置来找到。 我们仅在更改GameTile.GrowPathTo的路径时才对它进行计算,而不是对每个敌人都一步一步地计算。 使用ExitPoint属性使其可用。

  public Vector3 ExitPoint { get; private set; } … GameTile GrowPathTo (GameTile neighbor) { … neighbor.ExitPoint = (neighbor.transform.localPosition + transform.localPosition) * 0.5f; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; } 

唯一的特殊情况是最终单元格,其出口点将是其中心。

  public void BecomeDestination () { distance = 0; nextOnPath = null; ExitPoint = transform.localPosition; } 

更改Enemy ,使其使用出口点而不是平铺中心。

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { … positionTo = tileFrom.ExitPoint; progress -= 1f; } transform.localPosition = Vector3.Lerp(positionFrom, positionTo, progress); return true; } public void SpawnOn (GameTile tile) { … positionTo = tileFrom.ExitPoint; progress = 0f; } 


敌人在边缘之间移动。

这种变化的副作用是,当敌人由于路径变化而转弯时,它们会保持一秒钟不动。


转身时,敌人会停止。

方向


尽管敌人沿着小路移动,直到他们改变方向。 为了使他们可以看向运动方向,他们需要知道他们所遵循的路径的方向。 我们还将在寻找方法的过程中确定这一点,这样敌人就不必这样做了。

我们有四个方向:北,东,南和西。 让我们列举一下。

 public enum Direction { North, East, South, West } 

然后,我们给GameTile属性存储其路径的方向。

  public Direction PathDirection { get; private set; } 

将方向参数添加到GrowTo ,以设置属性。 由于我们从头到尾都在发展一条道路,因此方向将与我们从那开始的道路相反。

  public GameTile GrowPathNorth () => GrowPathTo(north, Direction.South); public GameTile GrowPathEast () => GrowPathTo(east, Direction.West); public GameTile GrowPathSouth () => GrowPathTo(south, Direction.North); public GameTile GrowPathWest () => GrowPathTo(west, Direction.East); GameTile GrowPathTo (GameTile neighbor, Direction direction) { … neighbor.PathDirection = direction; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; } 

我们需要将方向转换为以四元数表示的转弯。 如果我们可以只调用GetRotation作为方向,那将很方便,因此让我们通过创建扩展方法来实现。 添加一般的静态方法DirectionExtensions ,为其提供一个数组以缓存必要的四元数,并添加GetRotation方法以返回相应的方向值。 在这种情况下,将扩展类与枚举类型放在同一文件中是有意义的。

 using UnityEngine; public enum Direction { North, East, South, West } public static class DirectionExtensions { static Quaternion[] rotations = { Quaternion.identity, Quaternion.Euler(0f, 90f, 0f), Quaternion.Euler(0f, 180f, 0f), Quaternion.Euler(0f, 270f, 0f) }; public static Quaternion GetRotation (this Direction direction) { return rotations[(int)direction]; } } 

什么是扩展方法?
扩展方法是静态类内部的静态方法,其行为类似于某种类型的实例方法。 此类型可以是类,接口,结构,原始值或枚举。 扩展方法的第一个参数必须具有this 。 它定义该方法将使用的类型和实例的值。 这种方法意味着无法扩展属性。

这是否允许您将方法添加到任何内容? 是的,就像您可以编写任何参数为任何类型的静态方法一样。

现在,我们可以在生成时以及每次输入新的图块时旋转Enemy 。 更新数据后,“ From磁贴为我们提供了方向。

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { … transform.localRotation = tileFrom.PathDirection.GetRotation(); progress -= 1f; } transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); return true; } public void SpawnOn (GameTile tile) { … transform.localRotation = tileFrom.PathDirection.GetRotation(); progress = 0f; } 

方向改变


与其立即改变方向,不如在转弯之间插值,就像我们在位置之间插值一样。 要从一个方向转到另一个方向,我们需要知道需要完成的方向更改:无需转弯,右转,左转或后退。 为此,我们添加了一个枚举,该枚举又可以与Direction放置在同一文件中,因为它们很小并且紧密相关。

 public enum Direction { North, East, South, West } public enum DirectionChange { None, TurnRight, TurnLeft, TurnAround } 

添加另一个扩展方法,这一次是GetDirectionChangeTo ,它将返回从当前方向到下一个方向的方向更改。 如果方向一致,则没有移动。 如果下一个大于当前,则向右转。 但是由于重复了这些指示,因此下一个比当前指示少三个时,情况相同。 左转弯将是相同的,只有加法和减法会切换位置。 剩下的唯一情况是转身。

  public static DirectionChange GetDirectionChangeTo ( this Direction current, Direction next ) { if (current == next) { return DirectionChange.None; } else if (current + 1 == next || current - 3 == next) { return DirectionChange.TurnRight; } else if (current - 1 == next || current + 3 == next) { return DirectionChange.TurnLeft; } return DirectionChange.TurnAround; } 

我们仅在一个维度上旋转,因此角度的线性插值对我们而言已足够。添加另一种扩展方法,以度为单位获取方向角。

  public static float GetAngle (this Direction direction) { return (float)direction * 90f; } 

现在,您必须Enemy跟踪需要进行插值的方向,方向变化和角度。

  Direction direction; DirectionChange directionChange; float directionAngleFrom, directionAngleTo; 

SpawnOn变得越来越困难,所以让我们将状态准备代码移到另一种方法。我们会将敌人的初始状态指定为入门状态,因此我们将其称为PrepareIntro在这种状态下,敌人从中心移动到其初始图块的边缘,因此方向不变。角度FromTo相同的。

  public void SpawnOn (GameTile tile) { Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this); tileFrom = tile; tileTo = tile.NextTileOnPath; //positionFrom = tileFrom.transform.localPosition; //positionTo = tileFrom.ExitPoint; //transform.localRotation = tileFrom.PathDirection.GetRotation(); progress = 0f; PrepareIntro(); } void PrepareIntro () { positionFrom = tileFrom.transform.localPosition; positionTo = tileFrom.ExitPoint; direction = tileFrom.PathDirection; directionChange = DirectionChange.None; directionAngleFrom = directionAngleTo = direction.GetAngle(); transform.localRotation = direction.GetRotation(); } 

在这个阶段,我们创建类似小型状态机的东西。为简化起见GameUpdate,请将状态代码移至新方法PrepareNextState我们将仅保留磁贴From的更改To,因为我们在此处使用它们来检查敌人是否完成了路径。

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { … //positionFrom = positionTo; //positionTo = tileFrom.ExitPoint; //transform.localRotation = tileFrom.PathDirection.GetRotation(); progress -= 1f; PrepareNextState(); } … } 

过渡到新状态时,您始终需要更改位置,查找方向更改,更新当前方向并将角度To移至From我们不再转弯。

  void PrepareNextState () { positionFrom = positionTo; positionTo = tileFrom.ExitPoint; directionChange = direction.GetDirectionChangeTo(tileFrom.PathDirection); direction = tileFrom.PathDirection; directionAngleFrom = directionAngleTo; } 

其他动作取决于方向的改变。让我们为每个选项添加一个方法。如果我们向前移动,则角度To与当前单元格路径的方向重合。此外,我们需要设置旋转角度,以使敌人直视前方。

  void PrepareForward () { transform.localRotation = direction.GetRotation(); directionAngleTo = direction.GetAngle(); } 

在转弯的情况下,我们不会立即转弯。我们需要插值一个不同的角度:向右转90度,向左转90度,向后转180°。为了避免由于角度值从359°变为0°而导致转向错误,To应该相对于当前方向指示角度我们无需担心角度会小于0°或大于360°,因为我们Quaternion.Euler可以处理它。

  void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; } void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; } 

最后,PrepareNextState我们可以使用switch更改方向来确定要调用的四种方法中的哪一种。

  void PrepareNextState () { … switch (directionChange) { case DirectionChange.None: PrepareForward(); break; case DirectionChange.TurnRight: PrepareTurnRight(); break; case DirectionChange.TurnLeft: PrepareTurnLeft(); break; default: PrepareTurnAround(); break; } } 

现在最后,GameUpdate我们需要检查方向是否已更改。如果是这样,则在两个角之间进行插值并设置旋转角度。

  public bool GameUpdate () { … transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); if (directionChange != DirectionChange.None) { float angle = Mathf.LerpUnclamped( directionAngleFrom, directionAngleTo, progress ); transform.localRotation = Quaternion.Euler(0f, angle, 0f); } return true; } 


敌人在转。

曲线运动


我们可以通过使敌人在转弯时沿着曲线移动来改善运动。让它们走四分之一圈而不是从砖的边缘到边缘走。这个圈子里的谎言在角落里常见到砖的中心FromTo,在同一边缘上的瓷砖进入了敌人From


四分之一圈旋转以向右转。

我们可以通过使用三角函数沿弧形移动敌人,同时旋转敌人来实现这一点。但这可以通过仅旋转将敌人坐标的本地原点暂时移动到圆心来简化。为此,我们需要更改敌人模型的位置,因此我们将提供Enemy此模型链接,可通过配置字段访问链接。

  [SerializeField] Transform model = default; 


与模型有关的敌人。

为了准备前进或后退,模型应移动到标准位置,即敌人坐标的本地原点。否则,必须将模型偏移测量单位的一半-旋转圆的半径,远离转折点。

  void PrepareForward () { transform.localRotation = direction.GetRotation(); directionAngleTo = direction.GetAngle(); model.localPosition = Vector3.zero; } void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; model.localPosition = new Vector3(-0.5f, 0f); } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; model.localPosition = new Vector3(0.5f, 0f); } void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; model.localPosition = Vector3.zero; } 

现在,敌人本人需要转移到转折点。为此,还必须将其移动半个测量单位,但是确切的偏移量取决于方向。让我们为它添加Direction一个辅助扩展方法GetHalfVector

  static Vector3[] halfVectors = { Vector3.forward * 0.5f, Vector3.right * 0.5f, Vector3.back * 0.5f, Vector3.left * 0.5f }; … public static Vector3 GetHalfVector (this Direction direction) { return halfVectors[(int)direction]; } 

向右或向左旋转时,添加相应的向量。

  void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; model.localPosition = new Vector3(-0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; model.localPosition = new Vector3(0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); } 

当回头时,该位置应为通常的起点。

  void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; model.localPosition = Vector3.zero; transform.localPosition = positionFrom; } 

另外,在计算出口点时,我们可以使用GameTile.GrowPathTo向量的一半,这样就无需访问图块的两个位置。

  neighbor.ExitPoint = neighbor.transform.localPosition + direction.GetHalfVector(); 

现在,在更改方向时,我们不必插值中的位置Enemy.GameUpdate,因为旋转参与了运动。

  public bool GameUpdate () { … if (directionChange == DirectionChange.None) { transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); } //if (directionChange != DirectionChange.None) { else { float angle = Mathf.LerpUnclamped( directionAngleFrom, directionAngleTo, progress ); transform.localRotation = Quaternion.Euler(0f, angle, 0f); } return true; } 


敌人在拐角处平稳弯曲。

恒速


到目前为止,敌人的速度始终等于每秒1个图块,无论它们在图块内部如何移动。但是它们覆盖的距离取决于其状况,因此它们的速度(以每秒单位表示)会有所不同。为了使此速度恒定,我们需要根据状态更改进度速度。因此,添加进度乘数字段并使用它来缩放中的增量GameUpdate

  float progress, progressFactor; … public bool GameUpdate () { progress += Time.deltaTime * progressFactor; … } 

但是,如果进度根据状态而变化,则剩余的进度值不能直接用于下一个状态。因此,在准备新状态之前,我们需要标准化进度并在新状态下应用新的乘数。

  public bool GameUpdate () { progress += Time.deltaTime * progressFactor; while (progress >= 1f) { … //progress -= 1f; progress = (progress - 1f) / progressFactor; PrepareNextState(); progress *= progressFactor; } … } 

向前移动不需要更改,因此它使用的系数为1。向右或向左转时,敌人经过了半径为½的四分之一圆,因此覆盖的距离为¼π。progress等于一除以该值。向后转弯应该不会花费太多时间,因此请将进度加倍,以便半秒钟。最后,介绍性运动仅覆盖了瓷砖的一半,因此,要保持恒定的速度,其进度也需要加倍。

  void PrepareForward () { … progressFactor = 1f; } void PrepareTurnRight () { … progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnLeft () { … progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnAround () { … progressFactor = 2f; } void PrepareIntro () { … progressFactor = 2f; } 

为什么距离等于1/4 * pi?
周长是半径的2π倍。向右或向左旋转仅覆盖此长度的四分之一,半径为½,因此距离为½π×½。

最终状态


由于我们有一个介绍性的状态,让我们添加最后一个状态。目前,敌人到达端点后会立即消失,但让我们将它们的消失推迟到到达末端图块的中心。让我们为此创建一个方法PrepareOutro,设置向前移动,但仅将其移动到图块的中心,进度加倍以保持恒定的速度。

  void PrepareOutro () { positionTo = tileFrom.transform.localPosition; directionChange = DirectionChange.None; directionAngleTo = direction.GetAngle(); model.localPosition = Vector3.zero; transform.localRotation = direction.GetRotation(); progressFactor = 2f; } 

为了GameUpdate不及早消灭敌人,我们将取消其平移。他现在就做PrepareNextState因此,仅在最终状态结束之后检查null返回true

  public bool GameUpdate () { progress += Time.deltaTime * progressFactor; while (progress >= 1f) { //tileFrom = tileTo; //tileTo = tileTo.NextTileOnPath; if (tileTo == null) { OriginFactory.Reclaim(this); return false; } … } … } 

随着PrepareNextState我们开始转向瓷砖。然后,在设置位置之后From,但在设置位置之前,To我们将检查图块是否等于Tonull如果是这样,则准备最终状态并跳过其余方法。

  void PrepareNextState () { tileFrom = tileTo; tileTo = tileTo.NextTileOnPath; positionFrom = positionTo; if (tileTo == null) { PrepareOutro(); return; } positionTo = tileFrom.ExitPoint; … } 


具有恒定速度和最终状态的敌人。

敌人变异


我们有一连串的敌人,它们都是相同的立方体,以相同的速度移动。结果更像是一条长长的蛇,而不是单个敌人。让我们通过随机化它们的大小,位移和速度来使其与众不同。

浮点值范围


我们将更改敌人的参数,从值的范围内随机选择其特征。FloatRange我们在“ 对象管理,配置形状”一文中创建的结构这里很有用,因此让我们对其进行复制。唯一的变化是使用一个参数添加了一个构造函数,并使用readonly-properties打开了对最小值和最大值的访问,因此间隔是不可更改的。

 using UnityEngine; [System.Serializable] public struct FloatRange { [SerializeField] float min, max; public float Min => min; public float Max => max; public float RandomValueInRange { get { return Random.Range(min, max); } } public FloatRange(float value) { min = max = value; } public FloatRange (float min, float max) { this.min = min; this.max = max < min ? min : max; } } 

我们还将属性集复制到该属性集以限制其间隔。

 using UnityEngine; public class FloatRangeSliderAttribute : PropertyAttribute { public float Min { get; private set; } public float Max { get; private set; } public FloatRangeSliderAttribute (float min, float max) { Min = min; Max = max < min ? min : max; } } 

我们仅需要滑块的可视化,因此将其复制FloatRangeSliderDrawerEditor文件夹

 using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(FloatRangeSliderAttribute))] public class FloatRangeSliderDrawer : PropertyDrawer { public override void OnGUI ( Rect position, SerializedProperty property, GUIContent label ) { int originalIndentLevel = EditorGUI.indentLevel; EditorGUI.BeginProperty(position, label, property); position = EditorGUI.PrefixLabel( position, GUIUtility.GetControlID(FocusType.Passive), label ); EditorGUI.indentLevel = 0; SerializedProperty minProperty = property.FindPropertyRelative("min"); SerializedProperty maxProperty = property.FindPropertyRelative("max"); float minValue = minProperty.floatValue; float maxValue = maxProperty.floatValue; float fieldWidth = position.width / 4f - 4f; float sliderWidth = position.width / 2f; position.width = fieldWidth; minValue = EditorGUI.FloatField(position, minValue); position.x += fieldWidth + 4f; position.width = sliderWidth; FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute; EditorGUI.MinMaxSlider( position, ref minValue, ref maxValue, limit.Min, limit.Max ); position.x += sliderWidth + 4f; position.width = fieldWidth; maxValue = EditorGUI.FloatField(position, maxValue); if (minValue < limit.Min) { minValue = limit.Min; } if (maxValue < minValue) { maxValue = minValue; } else if (maxValue > limit.Max) { maxValue = limit.Max; } minProperty.floatValue = minValue; maxProperty.floatValue = maxValue; EditorGUI.EndProperty(); EditorGUI.indentLevel = originalIndentLevel; } } 

模型规模


我们将从改变敌人的规模开始。EnemyFactory将比例设置添加到该选项。标度间隔不应太大,但应足以制造出微型而庞大的敌人。在0.5–2之间且标准值为1的任何值。我们将在此间隔中选择一个随机标度,Get然后通过一种新方法将其传递给敌人Initialize

  [SerializeField, FloatRangeSlider(0.5f, 2f)] FloatRange scale = new FloatRange(1f); public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; instance.Initialize(scale.RandomValueInRange); return instance; } 

该方法Enemy.Initialize只是将其模型的比例尺设置为在所有维度上都相同。

  public void Initialize (float scale) { model.localScale = new Vector3(scale, scale, scale); } 

检查员

场景

刻度范围是0.5到1.5。

路径偏移


为了进一步破坏敌人流动的均匀性,我们可以更改敌人在地砖内的相对位置。它们向前移动,因此沿该方向的移动只会改变其运动的时机,这并不是很明显。因此,我们会将它们移到侧面,远离通过瓷砖中心的理想路径。将一个EnemyFactory路径偏移量添加到该间隔,并将随机偏移量传递给该方法Initialize。偏移量可以是负数,也可以是正数,但不能超过½,因为这会将敌人移动到相邻的图块。此外,我们不希望敌人超出其跟随的范围,因此实际上间隔会更短,例如0.4,但是真正的限制取决于敌人的大小。

  [SerializeField, FloatRangeSlider(-0.4f, 0.4f)] FloatRange pathOffset = new FloatRange(0f); public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; instance.Initialize( scale.RandomValueInRange, pathOffset.RandomValueInRange ); return instance; } 

由于路径的位移会影响行进的路径,Enemy因此有必要对其进行跟踪。

  float pathOffset; … public void Initialize (float scale, float pathOffset) { model.localScale = new Vector3(scale, scale, scale); this.pathOffset = pathOffset; } 

当完全笔直移动时(在介绍性,最终性或法向向前移动期间),我们只需将偏移量直接应用于模型即可。回头时也会发生同样的事情。通过右转或左转,我们已经移动了模型,该模型相对于路径的位移。

  void PrepareForward () { transform.localRotation = direction.GetRotation(); directionAngleTo = direction.GetAngle(); model.localPosition = new Vector3(pathOffset, 0f); progressFactor = 1f; } void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; model.localPosition = new Vector3(pathOffset - 0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; model.localPosition = new Vector3(pathOffset + 0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; model.localPosition = new Vector3(pathOffset, 0f); transform.localPosition = positionFrom; progressFactor = 2f; } void PrepareIntro () { … model.localPosition = new Vector3(pathOffset, 0f); transform.localRotation = direction.GetRotation(); progressFactor = 2f; } void PrepareOutro () { … model.localPosition = new Vector3(pathOffset, 0f); transform.localRotation = direction.GetRotation(); progressFactor = 2f; } 

由于旋转过程中路径的位移会改变半径,因此我们需要更改进度乘数的计算过程。必须从½中减去路径偏移量,以使转弯半径向右,并在向左转弯的情况下增加。

  void PrepareTurnRight () { … progressFactor = 1f / (Mathf.PI * 0.5f * (0.5f - pathOffset)); } void PrepareTurnLeft () { … progressFactor = 1f / (Mathf.PI * 0.5f * (0.5f + pathOffset)); } 

旋转180°时,我们还获得了旋转半径。在这种情况下,我们用半径等于路径偏移的半径覆盖了一半的圆,因此距离是偏移的π倍。但是,当位移为零时,这是行不通的;在小​​位移下,转弯过快。为了避免瞬时转弯,我们可以强制最小半径来计算速度,例如0.2。

  void PrepareTurnAround () { directionAngleTo = directionAngleFrom + (pathOffset < 0f ? 180f : -180f); model.localPosition = new Vector3(pathOffset, 0f); transform.localPosition = positionFrom; progressFactor = 1f / (Mathf.PI * Mathf.Max(Mathf.Abs(pathOffset), 0.2f)); } 

检查员


路径偏移量在-0.25–0.25范围内。

请注意,现在敌人即使转弯也不会改变其相对路径位移。因此,每个敌人的总路径长度各有不同。

为了防止敌人到达附近的砖块,还必须考虑其最大可能的规模。我只是将大小限制为最大值1,因此多维数据集的最大允许偏移为0.25。如果最大尺寸为1.5,则最大位移应减小到0.125。

速度


我们随机化的最后一件事是敌人的速度。我们为其添加一个间隔,EnemyFactory并将价值转移到创建的敌人副本中。让我们将其作为method的第二个参数Initialize敌人不要太慢或太快,以免游戏变得简单或不可能。让我们将间隔限制为0.2-5。速度以每秒单位表示,仅在向前移动时才对应于每秒瓦片。

  [SerializeField, FloatRangeSlider(0.2f, 5f)] FloatRange speed = new FloatRange(1f); [SerializeField, FloatRangeSlider(-0.4f, 0.4f)] FloatRange pathOffset = new FloatRange(0f); public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; instance.Initialize( scale.RandomValueInRange, speed.RandomValueInRange, pathOffset.RandomValueInRange ); return instance; } 

现在,我Enemy必须跟踪和加快速度。

  float speed; … public void Initialize (float scale, float speed, float pathOffset) { model.localScale = new Vector3(scale, scale, scale); this.speed = speed; this.pathOffset = pathOffset; } 

当我们没有明确设置速度时,我们仅使用值1。现在,我们只需要创建进度乘数对速度的依赖关系即可。

  void PrepareForward () { … progressFactor = speed; } void PrepareTurnRight () { … progressFactor = speed / (Mathf.PI * 0.5f * (0.5f - pathOffset)); } void PrepareTurnLeft () { … progressFactor = speed / (Mathf.PI * 0.5f * (0.5f + pathOffset)); } void PrepareTurnAround () { … progressFactor = speed / (Mathf.PI * Mathf.Max(Mathf.Abs(pathOffset), 0.2f)); } void PrepareIntro () { … progressFactor = 2f * speed; } void PrepareOutro () { … progressFactor = 2f * speed; } 



速度范围为0.75-1.25。

因此,我们有许多敌人流向终点。在下一个教程中,我们将学习如何处理它们。是否想知道何时发布?按照我在Patreon上的页面

资料库

PDF文章

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


All Articles