Введение в атомарный подход
Всем привет! Сегодня хотел бы поделиться основами атомарного подхода, с помощью которого можно собирать игровые объекты как конструктор.
Если вкратце, атомарный подход — это способ организации кода игрового объекта, структура которого состоит из атомарных элементов данных и логики, которые обрабатывают эти данные.
Ничего не поняли? Давайте приведу пример персонажа из RPG игры.
С точки зрения кода любой игровой объект состоит из данных и логики. Любой параметр здоровья, уровня или скорости перемещения является элементом данных. А любая механика перемещения или атаки является элементом логики. Таким образом, игровой объект является композицией данных и логики.
Фишка атомарного подхода заключается в том, что за счет разделения данных и логики можно реюзать механики ближнего боя, выстрелов, кулдаунов, взрывов, перемещения, нанесения урона и смерти.
Конкретный пример
Для лучшего понимания я подготовил простой пример. На этом примере попытаюсь показать, как писать на атомарном подходе в Unity и как можно переиспользовать механику перемещения. Но обо всем по порядку...
У персонажа будет здоровье и возможность перемещаться, а у пули — лететь и наносить урон при попадании в персонажа. Давайте запилим на атомарном подходе.
Реализация персонажа
Итак, персонаж имеет параметр здоровья и механику получения урона. На атомарном подходе это будет выглядеть так:
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(); } }
Теперь персонаж будет получать урон, если вызвать у него событие takeDamageEvent:
public sealed class TakeDamageTest : MonoBehaviour { [SerializeField] private Character character; [SerializeField] private int damage; [ContextMenu("TakeDamage")] public void TakeDamage() { this.character.takeDamageEvent.Invoke(this.damage); } }
Теперь нужно заставить персонажа двигаться в различных направлениях, поэтому сделаем для него механику перемещения:
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 AtomicEvent<int> takeDamageEvent; public AtomicVariable<int> hitPoints; public AtomicVariable<float> moveSpeed; //+ public AtomicVariable<Vector3> moveDirection; //+ //Logic: private TakeDamageMechanics takeDamageMechanics; private MovementMechanics movementMechanics; //+ private void Awake() { this.takeDamageMechanics = new TakeDamageMechanics( this.takeDamageEvent, this.hitPoints ); //+ this.movementMechanics = new MovementMechanics( this.moveSpeed, this.moveDirection, this.transform ); } //+ private void Update() { this.movementMechanics.Update(); } private void OnEnable() { this.takeDamageMechanics.OnEnable(); } private void OnDisable() { this.takeDamageMechanics.OnDisable(); } }
Если нужно протестировать перемещение, можно написать простой контроллер:
public sealed class MovementController : 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 Bullet : MonoBehaviour { //Data: 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.moveSpeed, this.moveDirection, this.transform ); } private void Update() { this.movementMechanics.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<float> moveSpeed = new(3); public AtomicVariable<Vector3> moveDirection = new(Vector3.forward); public AtomicVariable<int> damage = new(1); //Логика private MovementMechanics movementMechanics; private BulletCollisionMechanics collisionMechanics; private void Awake() { this.movementMechanics = new MovementMechanics( this.canMove, this.moveSpeed, this.moveDirection, this.transform ); this.collisionMechanics = new BulletCollisionMechanics( this.damage, this.gameObject ); } private void Update() { this.movementMechanics.Update(); this.lifetimeMechanics.Update(); } private void OnTriggerEnter(Collider other) { this.collisionMechanics.OnTriggerEnter(other); } }
Если добавить коллайдеры и Rigidbody на персонажа и пулю, то пуля будет лететь и наносить урон.
Выводы
Подведем преимущества использования атомарного подхода:
- Во-первых, благодаря подходу разделения данных и логики большинство игровых механик будут универсальными, а значит из можно будет реюзать. За счет переиспользования механик, будет меньше дублирования кода, следовательно, код-база будет расти медленнее, и с проектом работать будет проще.
- Во-вторых, сам подход позволяет описать структуру игрового объекта, которую легко читать, поддерживать и расширять.
- В-третьих, подход уменьшает кол-во использования класса MonoBehaviour, следовательно, кол-во памяти будет расходоваться меньше, а производительность будет выше, поскольку апдейты будут вызываться не из нативного кода.