Модульные механики на Unity
Привет, Хабр! Меня зовут Игорь, и я Unity Developer. Сегодня я покажу, как можно реализовывать модульные механики для игровых объектов, разделяя данные и логику.
Если вы не читали мою статью про атомарно-ориентированный дизайн, то рекомендую ознакомиться с ней. Но это опционально.
Цель статьи — наглядно показать, как можно гибко описывать и переиспользовать поведение игровых объектов в Unity, поэтому присаживайтесь к огоньку.
Для лучшего понимания я подготовил простой пример на кубах — это персонаж и пуля.
У персонажа будет здоровье и возможность перемещаться, а у пули — лететь и наносить урон при попадании в персонажа. На этом примере попытаюсь раскрыть прелесть разделения данных и логики. Ну шо, поехали :)
Реализация персонажа
В атомарном подходе любой игровой объект состоит из данных и логики. Под данными я подразумеваю любую информацию, которую хранит объект (поля / свойства / события), под логикой — алгоритмы (методы, функции / процедуры), которые обрабатывают эту информацию и изменяют состояние персонажа. Да, это ECS, только объектно-ориентированный :)
Итак, персонаж имеет параметр здоровья и механику получения урона, опишем это:
public sealed class Character : MonoBehaviour
{
//Данные:
public AtomicEvent<int> takeDamageEvent;
public AtomicVariable<int> hitPoints;
//Логика:
private TakeDamageMechanics takeDamageMechanics;
}
Поле takeDamageEvent типа AtomicEvent<T> является событием получения урона
Поле hitPoints типа AtomicVariable<T> содержит текущее кол-во здоровья и оповещает о его изменении.
Поле takeDamageMechanics обрабатывает событие получения урона и уменьшает кол-во здоровья персонажа.
Классы AtomicEvent & AtomicVariable являются универсальными классами, которые можно использовать для описания свойств любого игрового объекта:
[Serializable]
public sealed class AtomicEvent<T>
{
private event Action<T> onEvent;
public void Invoke(T args)
{
this.onEvent?.Invoke(args);
}
public void Subscribe(Action<T> action)
{
this.onEvent += action;
}
public void Unsubscribe(Action<T> action)
{
this.onEvent -= action;
}
}
[Serializable]
public sealed class AtomicVariable<T>
{
private event Action<T> onValueChanged;
public T Value
{
get { return this.value; }
set
{
this.value = value;
this.onValueChanged?.Invoke(value);
}
}
[SerializeField]
private T value;
public AtomicVariable()
{
}
public AtomicVariable(T value)
{
this.value = value;
}
public void Subscribe(Action<T> action)
{
this.onValueChanged += action;
}
public void Unsubscribe(Action<T> action)
{
this.onValueChanged -= action;
}
}
Класс TakeDamageMechanics является элементом логики, который подписывается на событие takeDamageEvent. Обработка события происходит в методе OnTakeDamage(int), в котором происходит уменьшение значения здоровья hitPoints:
public sealed class TakeDamageMechanics
{
//Зависимости на данные:
private readonly AtomicEvent<int> takeDamageEvent;
private readonly AtomicVariable<int> hitPoints;
public TakeDamageMechanics(
AtomicEvent<int> takeDamageEvent,
AtomicVariable<int> hitPoints
)
{
this.takeDamageEvent = takeDamageEvent;
this.hitPoints = hitPoints;
}
//Аналогичен MonoBehaviour.OnEnable
public void OnEnable()
{
this.takeDamageEvent.Subscribe(this.OnTakeDamage);
}
//Аналогичен MonoBehaviour.OnDisable
public void OnDisable()
{
this.takeDamageEvent.Unsubscribe(this.OnTakeDamage);
}
//Логика:
private void OnTakeDamage(int damage)
{
this.hitPoints.Value -= damage;
}
}
Теперь подключим TakeDamageMechanics к классу персонажа:
public sealed class Character : MonoBehaviour
{
//Data:
public AtomicEvent<int> takeDamageEvent;
public AtomicVariable<int> hitPoints;
//Logic:
private TakeDamageMechanics takeDamageMechanics;
private void Awake()
{
this.takeDamageMechanics = new TakeDamageMechanics(
this.takeDamageEvent, this.hitPoints
);
}
private void OnEnable()
{
this.takeDamageMechanics.OnEnable();
}
private void OnDisable()
{
this.takeDamageMechanics.OnDisable();
}
}
Подключим скрипт Character к GameObject'у на сцене
Теперь можно протестировать получение урона:
public sealed class TakeDamageTest : MonoBehaviour
{
[SerializeField]
private Character character;
[SerializeField]
private int damage;
[ContextMenu("TakeDamage")]
public void TakeDamage()
{
this.character.takeDamageEvent.Invoke(this.damage);
}
}
Теперь нужно сделать механику смерти персонажа, когда его здоровье равно нулю. Добавим данные и логику в класс Character:
public sealed class Character : MonoBehaviour
{
//Data:
public AtomicEvent<int> takeDamageEvent;
public AtomicVariable<int> hitPoints;
public AtomicEvent deathEvent; //+
public AtomicVariable<bool> isDead; //+
//Logic:
private TakeDamageMechanics takeDamageMechanics;
private DeathMechanics deathMechanics; //+
private void Awake()
{
this.takeDamageMechanics = new TakeDamageMechanics(
this.takeDamageEvent, this.hitPoints
);
this.deathMechanics = new DeathMechanics( //+
this.hitPoints, this.isDead, this.deathEvent //+
); //+
}
private void OnEnable()
{
this.takeDamageMechanics.OnEnable();
this.deathMechanics.OnEnable(); //+
}
private void OnDisable()
{
this.takeDamageMechanics.OnDisable();
this.deathMechanics.OnDisable(); //+
}
}
Поле deathEvent — событие смерти
Поле isDead хранит состояние: мертв персонаж или нет
Поле deathMechanics обрабатывает событие смерти персонажа и ставит флажок isDead в true
Вот как выглядит механика смерти:
public sealed class DeathMechanics
{
private readonly AtomicVariable<int> hitPoints;
private readonly AtomicVariable<bool> isDead;
private readonly AtomicEvent deathEvent;
public DeathMechanics(
AtomicVariable<int> hitPoints,
AtomicVariable<bool> isDead,
AtomicEvent deathEvent
)
{
this.hitPoints = hitPoints;
this.isDead = isDead;
this.deathEvent = deathEvent;
}
public void OnEnable()
{
this.hitPoints.Subscribe(this.OnHitPointsChanged);
}
public void OnDisable()
{
this.hitPoints.Unsubscribe(this.OnHitPointsChanged);
}
private void OnHitPointsChanged(int hitPoints)
{
if (hitPoints <= 0)
{
this.deathEvent.Invoke();
this.isDead.Value = true;
}
}
}
Важно отметить, что когда описываем классы логики, то в этих классах не должно быть состояния! Эти классы только получают внешние зависимости на данные и работают с ними.
Механика перемещения
Теперь нужно заставить персонажа двигаться в различных направлениях, поэтому сделаем для него механику перемещения:
public sealed class MovementMechanics
{
private readonly AtomicVariable<float> moveSpeed;
private readonly AtomicVariable<Vector3> moveDirection;
private readonly Transform transform;
public MovementMechanics(
AtomicVariable<float> moveSpeed,
AtomicVariable<Vector3> moveDirection,
Transform transform
)
{
this.moveSpeed = moveSpeed;
this.moveDirection = moveDirection;
this.transform = transform;
}
//Вызывается каждый кадр
public void Update()
{
this.transform.position += this.moveDirection.Value * (this.moveSpeed.Value * Time.deltaTime);
}
}
Класс MoveMechanics принимает параметры скорости и направления перемещения и меняет позицию игрового объекта через Transform каждый кадр. Добавим механику в код персонажа:
public sealed class Character : MonoBehaviour
{
//Data :
public AtomicVariable<float> moveSpeed; //+
public AtomicVariable<Vector3> moveDirection; //+
//Logic:
private MovementMechanics movementMechanics; //+
private void Awake()
{
//+
this.movementMechanics = new MovementMechanics(
this.moveSpeed, this.moveDirection, this.transform
);
}
//+
private void Update()
{
this.movementMechanics.Update();
}
}
Компилируем код и видим новые параметры для перемещения:
Если нужно протестировать перемещение, можно написать тестовый скрипт:
public sealed class MovementTest : MonoBehaviour
{
[SerializeField]
private Character character;
private void Update()
{
if (Input.GetKey(KeyCode.LeftArrow))
{
this.character.moveDirection.Value = Vector3.left;
}
else if (Input.GetKey(KeyCode.RightArrow))
{
this.character.moveDirection.Value = Vector3.right;
}
else if (Input.GetKey(KeyCode.UpArrow))
{
this.character.moveDirection.Value = Vector3.forward;
}
else if (Input.GetKey(KeyCode.DownArrow))
{
this.character.moveDirection.Value = Vector3.back;
}
else
{
this.character.moveDirection.Value = Vector3.zero;
}
}
}
Теперь нужно сделать так, чтобы персонаж не смог двигаться, если он мертв. Для этого напишем еще одну механику, которая будет разрешать / запрещать перемещение, исходя из состояния персонажа:
public sealed class CanMoveMechanics
{
private readonly AtomicVariable<bool> canMove;
private readonly AtomicVariable<bool> isDead;
public CanMoveMechanics(
AtomicVariable<bool> canMove,
AtomicVariable<bool> isDead
)
{
this.canMove = canMove;
this.isDead = isDead;
}
//Вызывается каждый кадр
public void Update()
{
this.canMove.Value = this.CanMove();
}
private bool CanMove()
{
if (this.isDead.Value)
{
return false;
}
//В будущем можно добавить еще условия для перемещения
return true;
}
}
Переменная canMove содержит информацию о том, может ли объект перемещаться или нет. Сделаем так, чтобы перемещение работало, если canMove находится в true:
//Доработаем механику перемещения:
public sealed class MovementMechanics
{
private readonly AtomicVariable<bool> canMove; //+
private readonly AtomicVariable<float> moveSpeed;
private readonly AtomicVariable<Vector3> moveDirection;
private readonly Transform transform;
public MovementMechanics(
AtomicVariable<bool> canMove, //+
AtomicVariable<float> moveSpeed,
AtomicVariable<Vector3> moveDirection,
Transform transform
)
{
this.canMove = canMove; //+
this.moveSpeed = moveSpeed;
this.moveDirection = moveDirection;
this.transform = transform;
}
public void Update()
{
if (this.canMove.Value) //+
{
this.transform.position += this.moveDirection.Value * (this.moveSpeed.Value * Time.deltaTime);
}
}
}
Обновим скрипт Character.cs:
public sealed class Character : MonoBehaviour
{
//Data :
public AtomicVariable<bool> canMove; //+
public AtomicVariable<float> moveSpeed;
public AtomicVariable<Vector3> moveDirection;
//Logic:
private MovementMechanics movementMechanics;
private CanMoveMechanics canMoveMechanics; //+
private void Awake()
{
this.canMoveMechanics = new CanMoveMechanics( //+
this.canMove, this.isDead
);
this.movementMechanics = new MovementMechanics(
this.canMove, this.moveSpeed, this.moveDirection, this.transform //+
);
}
//+
private void Update()
{
this.movementMechanics.Update();
this.canMoveMechanics.Update(); //+
}
}
Таким образом, мы получили персонажа, который умеет перемещаться, получать урон и умирать.
Реализация пули
Пуля должна иметь следующие механики: перемещаться в пространстве, наносить урон персонажу при попадании, и если пуля летит долго, то она исчезает со сцены.
Поскольку механику перемещения мы уже написали, то мы ее можем переиспользовать для пули:
public sealed class Bullet : MonoBehaviour
{
//Data:
public AtomicVariable<bool> canMove = new(true);
public AtomicVariable<float> moveSpeed = new(3);
public AtomicVariable<Vector3> moveDirection = new(Vector3.forward);
//Logic:
private MovementMechanics movementMechanics;
private void Awake()
{
this.movementMechanics = new MovementMechanics(
this.canMove,
this.moveSpeed,
this.moveDirection,
this.transform
);
}
private void Update()
{
this.movementMechanics.Update();
}
}
Механика времени жизни для пули будет выглядеть так:
public sealed class BulletLifetimeMechanics
{
private readonly AtomicVariable<float> lifetime;
private readonly GameObject bullet;
public BulletLifetimeMechanics(
AtomicVariable<float> lifetime,
GameObject bullet
)
{
this.lifetime = lifetime;
this.bullet = bullet;
}
public void Update()
{
this.lifetime.Value -= Time.deltaTime;
if (this.lifetime.Value <= 0)
{
GameObject.Destroy(this.bullet);
}
}
}
Переменная lifetime будет хранить оставшееся время до уничтожения пули. Сама логика уменьшения времени будет обрабатываться в методе Update() через Time.deltaTime. В классах я стараюсь избегать корутин. Подключаем поведение к пуле:
public sealed class Bullet : MonoBehaviour
{
//Data:
public AtomicVariable<float> lifetime = new(3); //+
//Logic:
private BulletLifetimeMechanics lifetimeMechanics; //+
private void Awake()
{
//+
this.lifetimeMechanics = new BulletLifetimeMechanics(
this.lifetime, this.gameObject
);
}
private void Update()
{
this.lifetimeMechanics.Update(); //+
}
}
Теперь механика нанесения урона персонажу при столкновении с ним:
public sealed class BulletCollisionMechanics
{
private readonly AtomicVariable<int> damage;
private readonly GameObject bullet;
public BulletCollisionMechanics(
AtomicVariable<int> damage,
GameObject bullet
)
{
this.damage = damage;
this.bullet = bullet;
}
public void OnTriggerEnter(Collider collider)
{
if (collider.TryGetComponent(out Character character))
{
character.takeDamageEvent.Invoke(this.damage.Value);
GameObject.Destroy(this.bullet);
}
}
}
Метод OnTriggerEnter делегирует вызов из монобеха и сам обрабатывает логику столкновения.
В результате класс Bullet будет выглядеть следующим образом:
public sealed class Bullet : MonoBehaviour
{
public AtomicVariable<bool> canMove = new(true);
public AtomicVariable<float> moveSpeed = new(3);
public AtomicVariable<Vector3> moveDirection = new(Vector3.forward);
[Space]
public AtomicVariable<int> damage = new(1);
public AtomicVariable<float> lifetime = new(3);
private MovementMechanics movementMechanics;
private BulletCollisionMechanics collisionMechanics;
private BulletLifetimeMechanics lifetimeMechanics;
private void Awake()
{
this.movementMechanics = new MovementMechanics(
this.canMove, this.moveSpeed, this.moveDirection, this.transform
);
this.collisionMechanics = new BulletCollisionMechanics(
this.damage, this.gameObject
);
this.lifetimeMechanics = new BulletLifetimeMechanics(
this.lifetime, this.gameObject
);
}
private void Update()
{
this.movementMechanics.Update();
this.lifetimeMechanics.Update();
}
private void OnTriggerEnter(Collider other)
{
this.collisionMechanics.OnTriggerEnter(other);
}
}
Если добавить коллайдеры на персонажа и пулю, то пуля будет лететь и наносить урон
Выводы
Итак, что нам дает подход разделения данных и логики:
Каждая механика становится универсальной, ее можно переиспользовать
Легко расширять и изменять структуру игровых объектов
Легко модифицировать механики, поскольку любая механика может принимать любой набор данных
Какие рекомендации, я хотел бы дать:
Стараться делать так, чтобы класс логики не имел состояния
Стараться делать классы логики по принципу SRP
Стараться уходить от корутин в целях производительности
В завершении скажу, что это был один из способов атомарного подхода реализации игровых объектов, а подробнее про атомарные механики на Unity, я расскажу на бесплатном вебинаре уже 5 октября. Благодарю за внимание и жду всех на вебинаре :)
Ссылка на демо-проект в Google Drive