在统一中创建塔防:方案和敌人浪潮

[ 教程的第一第二第三第四部分]

  • 支持小,中,大型敌人。
  • 创建具有多个敌人浪潮的游戏场景。
  • 资产配置和游戏状态的分离。
  • 开始,暂停,获胜,失败并加速游戏。
  • 创建无限重复的场景。

这是有关创建简单塔防游戏的一系列教程的第五部分。 在其中,我们将学习如何创建会产生各种各样敌人的游戏场景。

该教程是在Unity 2018.4.6f1中创建的。


越来越舒服了。

更多敌人


每次创建相同的蓝色立方体并不是很有趣。 支持更有趣的游戏场景的第一步将是支持几种类型的敌人。

敌人配置


有多种方法可以使敌人独特,但我们不会使其复杂化:我们将其分为大,中,小三种。 要标记它们,请创建一个EnemyType枚举。

 public enum EnemyType { Small, Medium, Large } 

更改EnemyFactory ,使其支持所有三种类型的敌人,而不是一种。 对于所有三个敌人,都需要相同的配置字段,因此我们添加了一个包含所有敌人的嵌套EnemyConfig类,然后将三个此类型的配置字段添加到工厂。 由于此类仅用于配置,我们将不会在其他任何地方使用它,因此您可以简单地将其字段公开,以便工厂可以访问它们。 EnemyConfig本身不需要公开。

 public class EnemyFactory : GameObjectFactory { [System.Serializable] class EnemyConfig { public Enemy prefab = default; [FloatRangeSlider(0.5f, 2f)] public FloatRange scale = new FloatRange(1f); [FloatRangeSlider(0.2f, 5f)] public FloatRange speed = new FloatRange(1f); [FloatRangeSlider(-0.4f, 0.4f)] public FloatRange pathOffset = new FloatRange(0f); } [SerializeField] EnemyConfig small = default, medium = default, large = default; … } 

我们还要使每个敌人的健康状况可自定义,因为逻辑上大敌人比小敌人更多。

  [FloatRangeSlider(10f, 1000f)] public FloatRange health = new FloatRange(100f); 

Get添加一个类型参数,以便可以获取特定的敌人类型,默认类型为中。 我们将使用该类型来获取正确的配置(为此使用单独的方法),然后像以前一样仅使用添加的运行状况参数来创建和初始化敌人。

  EnemyConfig GetConfig (EnemyType type) { switch (type) { case EnemyType.Small: return small; case EnemyType.Medium: return medium; case EnemyType.Large: return large; } Debug.Assert(false, "Unsupported enemy type!"); return null; } public Enemy Get (EnemyType type = EnemyType.Medium) { EnemyConfig config = GetConfig(type); Enemy instance = CreateGameObjectInstance(config.prefab); instance.OriginFactory = this; instance.Initialize( config.scale.RandomValueInRange, config.speed.RandomValueInRange, config.pathOffset.RandomValueInRange, config.health.RandomValueInRange ); return instance; } 

Enemy.Initialize添加所需的参数生命值, Enemy.Initialize并使用它来设置生命值,而不是根据敌人的大小来确定它。

  public void Initialize ( float scale, float speed, float pathOffset, float health ) { … Health = health; } 

我们创造不同敌人的设计


您可以选择三个敌人的设计,但是在教程中,我将力求最大程度的简化。 我复制了敌人的原始预制件,并将其用于所有三种尺寸,仅更改了材质:黄色代表小,蓝色代表中,红色代表大。 我没有更改预制立方体的比例,但是使用了工厂比例配置来设置尺寸。 另外,根据大小,我增加了它们的健康程度并降低了速度。


工厂用于三种尺寸的敌人立方体。

最快的方法是通过更改Game.SpawnEnemy ,使这三种类型都出现在游戏中,以便他获得随机的敌人类型,而不是中间的一个类型。

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


不同类型的敌人。

几家工厂


现在,敌人的工厂设置了很多三个敌人。 现有工厂可以创建三种尺寸的立方体,但是没有什么可以阻止我们制造另一家可以创建其他形状的工厂,例如三种尺寸的球体。 我们可以通过在游戏中指定其他工厂来更改创建的敌人,从而切换到其他主题。


球形敌人。

敌人的浪潮


创建游戏场景的第二步将是拒绝以恒定的频率生成敌人。 必须连续不断地创建敌人,直到剧本结束或玩家失败。

创建顺序


一波敌人由一组敌人组成,这些敌人一个接一个地创建,直到波完成。 波浪可以包含不同类型的敌人,并且它们之间的延迟时间可能会有所不同。 为了不使实现复杂化,我们将从一个简单的生成序列开始,该序列以恒定的频率创建相同类型的敌人。 然后,该波将只是这些序列的列表。

要配置每个序列,请创建一个EnemySpawnSequence类。 由于它很复杂,请将其放在单独的文件中。 序列必须知道要使用哪个工厂,要创建哪种类型的敌人,其数量和频率。 为了简化配置,我们将最后一个参数设为暂停,该参数确定在创建下一个敌人之前应该经过多少时间。 请注意,这种方法允许您在wave中使用多个敌方工厂。

 using UnityEngine; [System.Serializable] public class EnemySpawnSequence { [SerializeField] EnemyFactory factory = default; [SerializeField] EnemyType type = EnemyType.Medium; [SerializeField, Range(1, 100)] int amount = 1; [SerializeField, Range(0.1f, 10f)] float cooldown = 1f; } 

海浪


波浪是敌人创造序列的简单阵列。 为它创建一个以一个标准序列开头的EnemyWave EnemyWave类型。

 using UnityEngine; [CreateAssetMenu] public class EnemyWave : ScriptableObject { [SerializeField] EnemySpawnSequence[] spawnSequences = { new EnemySpawnSequence() }; } 

现在,我们可以制造一波又一波的敌人。 例如,我创建了一个波浪,该波浪产生一组立方体敌人,从十个小敌人开始,每秒出现两次。 它们后面是五个平均值,每秒创建一次,最后是一个大敌人,停顿五秒钟。


一波越来越多的立方体。

我可以在序列之间添加延迟吗?
您可以间接实现它。 例如,在小型和中型多维数据集之间插入四秒钟的延迟,将小型多维数据集的数量减少一个,然后插入一个具有四个暂停时间的小型多维数据集的序列。


中小型立方体之间有四秒钟的延迟。

情境


游戏场景是由一系列波浪创建的。 为此,请创建具有单个wave数组的GameScenario资产GameScenario ,然后使用它来创建场景。

 using UnityEngine; [CreateAssetMenu] public class GameScenario : ScriptableObject { [SerializeField] EnemyWave[] waves = {}; } 

例如,我创建了一个场景,其中包含两波中小型敌人(MSC),首先是立方体,然后是球体。


带有两波MSC的场景。

顺序运动


资产类型用于创建脚本,但是由于它们是资产,因此它们必须包含在游戏期间不会更改的数据。 但是,要推进此方案,我们需要以某种方式跟踪它们的状态。 一种方法是复制游戏中使用的资产,以便复制品跟踪其状况。 但是我们不需要复制整个资产,只需说明和指向资产的链接就足够了。 因此,让我们首先为EnemySpawnSequence创建一个单独的State类。 由于它仅适用于序列,因此我们将其嵌套。 它仅在引用序列时才有效,因此我们将为它提供带有序列参数的构造方法。


引用其序列的嵌套状态类型。

 public class EnemySpawnSequence { … public class State { EnemySpawnSequence sequence; public State (EnemySpawnSequence sequence) { this.sequence = sequence; } } } 

当我们要开始按顺序前进时,我们需要一个新的状态实例。 将序列添加到Begin方法中,该方法构造并返回状态。 因此,每个调用Begin都将负责匹配状态,并且序列本身将保持无状态。 甚至有可能以相同的顺序平行前进几次。

 public class EnemySpawnSequence { … public State Begin () => new State(this); public class State { … } } 

为了使状态在热重启后仍然存在,您需要使其可序列化。

  [System.Serializable] public class State { … } 

这种方法的缺点是每次运行序列时,都需要创建一个新的状态对象。 我们可以通过使其成为结构而不是类来避免内存分配。 只要情况仍然很小,这是正常的。 请记住,状态是一种值类型。 传输后将被复制,因此请在一个位置进行跟踪。

  [System.Serializable] public struct State { … } 

序列的状态仅包括两个方面:生成的敌人数量和暂停时间的进度。 我们添加了Progress方法,该方法将暂停的值增加时间增量,然后在达到配置的值时将其重置,类似于Game.Update的生成时间。 每当这种情况发生时,我们都会增加敌人的数量。 此外,暂停值必须以最大值开头,这样序列可以在开始时没有暂停的情况下创建敌人。

  int count; float cooldown; public State (EnemySpawnSequence sequence) { this.sequence = sequence; count = 0; cooldown = sequence.cooldown; } public void Progress () { cooldown += Time.deltaTime; while (cooldown >= sequence.cooldown) { cooldown -= sequence.cooldown; count += 1; } } 


状态仅包含必要的数据。

我可以从State访问EnemySpawnSequence.cooldown吗?
是的,因为State设置在相同的范围内。 因此,嵌套类型知道包含它们的类型的私有成员。

进展必须持续到创建所需数量的敌人并结束暂停为止。 在这一点上,“ Progress应该报告完成,但是最有可能我们将跳过该值。 因此,这时我们必须返回额外的时间,以便按以下顺序提前使用它。 为此,您需要将时间增量转换为参数。 我们还需要指出我们尚未完成,这可以通过返回负值来实现。

  public float Progress (float deltaTime) { cooldown += deltaTime; while (cooldown >= sequence.cooldown) { cooldown -= sequence.cooldown; if (count >= sequence.amount) { return cooldown; } count += 1; } return -1f; } 

在任何地方制造敌人


为了使序列产生敌人,我们需要将Game.SpawnEnemy转换为另一个公共静态方法。

  public static void SpawnEnemy (EnemyFactory factory, EnemyType type) { GameTile spawnPoint = instance.board.GetSpawnPoint( Random.Range(0, instance.board.SpawnPointCount) ); Enemy enemy = factory.Get(type); enemy.SpawnOn(spawnPoint); instance.enemies.Add(enemy); } 

由于Game本身不再会产生敌人,因此我们可以从Update删除敌人的工厂,创建速度,创建促进过程和敌人创建代码。

  void Update () { } 

在增加敌人数量之后,我们将在EnemySpawnSequence.State.Progress调用Game.SpawnEnemy

  public float Progress (float deltaTime) { cooldown += deltaTime; while (cooldown >= sequence.cooldown) { … count += 1; Game.SpawnEnemy(sequence.factory, sequence.type); } return -1f; } 

波浪前进


让我们采用与沿整个波移动相同的方法来沿序列移动。 让我们给EnemyWave自己的Begin方法,该方法返回嵌套的State结构的新实例。 在这种情况下,状态包含波索引和活动序列的状态,我们从第一个序列的开头进行初始化。


包含序列状态的波动状态。

 public class EnemyWave : ScriptableObject { [SerializeField] EnemySpawnSequence[] spawnSequences = { new EnemySpawnSequence() }; public State Begin() => new State(this); [System.Serializable] public struct State { EnemyWave wave; int index; EnemySpawnSequence.State sequence; public State (EnemyWave wave) { this.wave = wave; index = 0; Debug.Assert(wave.spawnSequences.Length > 0, "Empty wave!"); sequence = wave.spawnSequences[0].Begin(); } } } 

我们还添加了EnemyWave.State方法Progress ,该方法使用与以前相同的方法,但有少量更改。 我们从沿活动序列开始,然后用此调用的结果替换时间增量。 剩下的时间,我们进入下一个序列(如果可以访问),并对其进行进度。 如果没有剩余序列,那么我们返回剩余时间; 否则返回负值。

  public float Progress (float deltaTime) { deltaTime = sequence.Progress(deltaTime); while (deltaTime >= 0f) { if (++index >= wave.spawnSequences.Length) { return deltaTime; } sequence = wave.spawnSequences[index].Begin(); deltaTime = sequence.Progress(deltaTime); } return -1f; } 

脚本推广


添加GameScenario的处理相同。 在这种情况下,状态包含波索引和活动波的状态。

 public class GameScenario : ScriptableObject { [SerializeField] EnemyWave[] waves = {}; public State Begin () => new State(this); [System.Serializable] public struct State { GameScenario scenario; int index; EnemyWave.State wave; public State (GameScenario scenario) { this.scenario = scenario; index = 0; Debug.Assert(scenario.waves.Length > 0, "Empty scenario!"); wave = scenario.waves[0].Begin(); } } } 

由于我们处于顶层,所以Progress方法不需要参数,您可以直接使用Time.deltaTime 。 我们不需要返回剩余时间,但是我们需要显示脚本是否已完成。 在最后一波结束后,我们将返回false ,并返回true以表明脚本仍处于活动状态。

  public bool Progress () { float deltaTime = wave.Progress(Time.deltaTime); while (deltaTime >= 0f) { if (++index >= scenario.waves.Length) { return false; } wave = scenario.waves[index].Begin(); deltaTime = wave.Progress(deltaTime); } return true; } 

脚本运行


要播放Game脚本,您需要一个脚本配置字段并跟踪其状态。 我们将只在Awake中运行脚本并对其运行Update ,直到游戏其余部分的状态Update为止。

  [SerializeField] GameScenario scenario = default; GameScenario.State activeScenario; … void Awake () { board.Initialize(boardSize, tileContentFactory); board.ShowGrid = true; activeScenario = scenario.Begin(); } … void Update () { … activeScenario.Progress(); enemies.GameUpdate(); Physics.SyncTransforms(); board.GameUpdate(); nonEnemies.GameUpdate(); } 

现在,已配置的脚本将在游戏开始时启动。 升级将一直进行到完成为止,此后什么也不会发生。


两波加速了十倍。

开始和结束游戏


我们可以重现一种情况,但完成后将不会出现新的敌人。 为了使游戏继续进行,我们需要使手动或由于玩家输/赢的新场景成为可能。 您也可以选择几种方案,但是在本教程中我们将不考虑它。

新游戏的开始


理想情况下,我们需要有机会在任何给定时间启动新游戏。 为此,您需要重置整个游戏的当前状态,也就是说,我们将不得不重置许多对象。 首先,向GameBehaviorCollection添加一个Clear方法,该方法利用其所有行为。

  public void Clear () { for (int i = 0; i < behaviors.Count; i++) { behaviors[i].Recycle(); } behaviors.Clear(); } 

这表明可以处理所有行为,但是到目前为止,情况并非如此。 为此,请向GameBehavior添加抽象的Recycle方法。

  public abstract void Recycle (); 

WarEntity类的Recycle方法必须显式覆盖它。

  public override void Recycle () { originFactory.Reclaim(this); } 

Enemy还没有Recycle方法,因此添加它。 他要做的就是迫使工厂退还它。 然后,我们在直接访问工厂的任何地方都称为Recycle

  public override bool GameUpdate () { if (Health <= 0f) { Recycle(); return false; } progress += Time.deltaTime * progressFactor; while (progress >= 1f) { if (tileTo == null) { Recycle(); return false; } … } … } public override void Recycle () { OriginFactory.Reclaim(this); } 

GameBoard还需要重置,因此让我们给它提供Clear方法,该方法清空所有图块,重置所有创建点并更新内容,然后设置标准起点和终点。 然后,无需重复代码,我们可以在Initialize的末尾调用Clear

  public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { … for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … } } Clear(); } public void Clear () { foreach (GameTile tile in tiles) { tile.Content = contentFactory.Get(GameTileContentType.Empty); } spawnPoints.Clear(); updatingContent.Clear(); ToggleDestination(tiles[tiles.Length / 2]); ToggleSpawnPoint(tiles[0]); } 

现在,我们可以将BeginNewGame方法添加到Game ,转储敌人,其他对象和字段,然后启动一个新脚本。

  void BeginNewGame () { enemies.Clear(); nonEnemies.Clear(); board.Clear(); activeScenario = scenario.Begin(); } 

如果您在继续执行脚本之前按B,我们将在Update调用此方法。

  void Update () { … if (Input.GetKeyDown(KeyCode.B)) { BeginNewGame(); } activeScenario.Progress(); … } 

输了


游戏的目标是在一定数量的敌人到达终点之前击败所有敌人。 触发失败条件所需的敌人数量取决于玩家的初始健康状况,为此我们将在Game添加一个配置字段。 由于我们计算敌人,我们将使用整数,而不是浮点数。

  [SerializeField, Range(0, 100)] int startingPlayerHealth = 10; 



最初,玩家有10点生命。

在“唤醒”或新游戏开始的情况下,我们将初始值分配给玩家的当前健康状况。

  int playerHealth; … void Awake () { playerHealth = startingPlayerHealth; … } void BeginNewGame () { playerHealth = startingPlayerHealth; … } 

添加一个公共的静态EnemyReachedDestination方法, EnemyReachedDestination敌人可以告诉Game他们已经到达端点。 发生这种情况时,请减少玩家的健康。

  public static void EnemyReachedDestination () { instance.playerHealth -= 1; } 

在适当的时间在Enemy.GameUpdate中调用此方法。

  if (tileTo == null) { Game.EnemyReachedDestination(); Recycle(); return false; } 

现在我们可以在Game.Update检查失败的条件。 如果玩家的生命值等于或小于零,则触发失败条件。 我们只需记录此信息,并在继续前进之前立即开始一个新游戏。 但是,只有在初期健康的前提下,我们才能做到这一点。 这使我们可以将0用作初始健康状况,使其不可能丢失。 因此,对我们来说测试脚本会很方便。

  if (playerHealth <= 0 && startingPlayerHealth > 0) { Debug.Log("Defeat!"); BeginNewGame(); } activeScenario.Progress(); 

胜利的


失败的另一种选择是胜利,如果玩家还活着的话,那就是在场景结束时获得胜利。 也就是说,当GameScenario.Progess的结果为false ,我们在日志中显示一个胜利消息,开始一个新游戏,并立即继续前进。

  if (playerHealth <= 0) { Debug.Log("Defeat!"); BeginNewGame(); } if (!activeScenario.Progress()) { Debug.Log("Victory!"); BeginNewGame(); activeScenario.Progress(); } 

但是,即使在场上仍然有敌人,胜利也会在最后一次暂停结束后才能出现。 我们需要将胜利推迟到所有敌人消失之前,这可以通过检查敌人的收藏是否为空来实现。 我们假定它具有IsEmpty属性。

  if (!activeScenario.Progress() && enemies.IsEmpty) { Debug.Log("Victory!"); BeginNewGame(); activeScenario.Progress(); } 

将所需的属性添加到GameBehaviorCollection

  public bool IsEmpty => behaviors.Count == 0; 

时间控制


我们还实现时间管理功能,这将有助于测试,通常是一种游戏功能。 首先,让Game.Update检查空格键,并使用此事件启用/禁用游戏中的暂停。 这可以通过在零和一之间切换Time.timeScale值来完成。 这不会改变游戏逻辑,但是会冻结所有对象。 或者,您可以使用非常小的值而不是0(例如0.01)来创建极慢的运动。

  const float pausedTimeScale = 0f; … void Update () { … if (Input.GetKeyDown(KeyCode.Space)) { Time.timeScale = Time.timeScale > pausedTimeScale ? pausedTimeScale : 1f; } if (Input.GetKeyDown(KeyCode.B)) { BeginNewGame(); } … } 

-, Game , .

  [SerializeField, Range(1f, 10f)] float playSpeed = 1f; 


.

, . .

  if (Input.GetKeyDown(KeyCode.Space)) { Time.timeScale = Time.timeScale > pausedTimeScale ? pausedTimeScale : playSpeed; } else if (Time.timeScale > pausedTimeScale) { Time.timeScale = playSpeed; } 


. , . , , , .


GameScenario , 1. , . , , , , .

  [SerializeField, Range(0, 10)] int cycles = 1; 


.

GameScenario.State .

  int cycle, index; EnemyWave.State wave; public State (GameScenario scenario) { this.scenario = scenario; cycle = 0; index = 0; wave = scenario.waves[0].Begin(); } 

Progress , false , . .

  public bool Progress () { float deltaTime = wave.Progress(Time.deltaTime); while (deltaTime >= 0f) { if (++index >= scenario.waves.Length) { if (++cycle >= scenario.cycles && scenario.cycles > 0) { return false; } index = 0; } wave = scenario.waves[index].Begin(); deltaTime = wave.Progress(deltaTime); } return true; } 


, . , . , . .

GameScenario . . , 0.5 ×1, ×1.5, ×2, ×2.5, .

  [SerializeField, Range(0f, 1f)] float cycleSpeedUp = 0.5f; 

GameScenario.State . 1 . Time.deltaTime .

  float timeScale; EnemyWave.State wave; public State (GameScenario scenario) { this.scenario = scenario; cycle = 0; index = 0; timeScale = 1f; wave = scenario.waves[0].Begin(); } public bool Progress () { float deltaTime = wave.Progress(timeScale * Time.deltaTime); while (deltaTime >= 0f) { if (++index >= scenario.waves.Length) { if (++cycle >= scenario.cycles && scenario.cycles > 0) { return false; } index = 0; timeScale += scenario.cycleSpeedUp; } wave = scenario.waves[index].Begin(); deltaTime = wave.Progress(deltaTime); } return true; } 


; .

? Patreon !



PDF

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


All Articles