
Привет, Хабр! Меня зовут Игорь, и я 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
ссылка на оригинал статьи https://habr.com/ru/articles/764658/
Добавить комментарий