[
本教程
的第一 ,
第二 ,
第三和
第四部分]
- 支持小,中,大型敌人。
- 创建具有多个敌人浪潮的游戏场景。
- 资产配置和游戏状态的分离。
- 开始,暂停,获胜,失败并加速游戏。
- 创建无限重复的场景。
这是有关创建简单
塔防游戏的一系列教程的第五部分。 在其中,我们将学习如何创建会产生各种各样敌人的游戏场景。
该教程是在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