[Первая, вторая, третья и четвёртая части туториала]
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
обязательный параметр health и используем его для задания здоровья вместо того, чтобы определять его по размеру врага. 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
. Так как он довольно сложен, поместим его в отдельный файл. Последовательность должна знать, какую фабрику ей использовать, какой тип врага создавать, их количество и частоту. Чтобы упростить конфигурирование, мы сделаем последний параметр паузой, определяющей, какое время должно пройти до создания следующего врага. Заметьте, что такой подход позволяет использовать в волне несколько фабрик врагов.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
, который начинается с одной стандартной последовательности.using UnityEngine;
[CreateAssetMenu]
public class EnemyWave : ScriptableObject {
[SerializeField]
EnemySpawnSequence[] spawnSequences = {
new EnemySpawnSequence()
};
}
GameScenario
с одним массивом волн, а затем используем его для изготовления сценария.using UnityEngine;
[CreateAssetMenu]
public class GameScenario : ScriptableObject {
[SerializeField]
EnemyWave[] waves = {};
}
State
, сначала для EnemySpawnSequence
. Так как он применяется только к последовательности, сделаем его вложенным. Он действителен только когда имеет ссылку на последовательность, поэтому дадим ему метод-конструктор с параметром-последовательностью.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
задано в той же области видимости. Поэтому вложенные типы знают о приватных членах содержащих их типов.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 () {
}
Game.SpawnEnemy
в EnemySpawnSequence.State.Progress
после увеличения счёта врагов. 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
до обновления состояния остальной части игры. [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 ();
Recycle
класса WarEntity
должен явным образом переопределять его. 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
, опустошающий все тайлы, сбрасывающий все точки создания и обновляющийся контент, а затем задающий стандартные начальную и конечную точки. Затем вместо повторения кода мы можем вызвать Clear
в конце Initialize
. 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]);
}
Game
метод BeginNewGame
, сбрасывающий врагов, другие объекты и поле, а затем начинающий новый сценарий. void BeginNewGame () {
enemies.Clear();
nonEnemies.Clear();
board.Clear();
activeScenario = scenario.Begin();
}
Update
в случае нажатии клавиши B до продвижения по сценарию. void Update () {
…
if (Input.GetKeyDown(KeyCode.B)) {
BeginNewGame();
}
activeScenario.Progress();
…
}
Game
поле конфигурации. Так как мы считаем врагов, то будем использовать integer, а не float. [SerializeField, Range(0, 100)]
int startingPlayerHealth = 10;
int playerHealth;
…
void Awake () {
playerHealth = startingPlayerHealth;
…
}
void BeginNewGame () {
playerHealth = startingPlayerHealth;
…
}
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;
}
К сожалению, не доступен сервер mySQL