Введение в атомарный подход
Всем привет! Сегодня хотел бы поделиться основами атомарного подхода, с помощью которого можно собирать игровые объекты как конструктор.
Если вкратце, атомарный подход — это способ организации кода игрового объекта, структура которого состоит из атомарных элементов данных и логики, которые обрабатывают эти данные.
Ничего не поняли? Давайте приведу пример персонажа из 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, следовательно, кол-во памяти будет расходоваться меньше, а производительность будет выше, поскольку апдейты будут вызываться не из нативного кода.