Разработчики игр нередко сталкиваются с необходимостью или желанием реализовать систему способностей в своих проектах. Механика эта достаточно популярна, особенно в мидкор+ сегменте. Однако, несмотря на наличие готового фреймворка Gameplay Ability System (GAS) в Unreal Engine, на других движках часто приходится использовать "самодельные" решения.

Я убеждён, что не редки случаи, когда не понятно ни как подступиться в начале, ни как уже потом добавить в игру новую "гениальную" способность, о которой "ранее не договаривались", не сломав уже добавленные. Как минимум, через эти сложности я и сам успел пройти не один раз на сингловых и мультиплеерных проектах.

Дисклеймер:

В этой заметке я делюсь своим опытом и сложившимся на данный момент личным мнением (которое с новым опытом может ещё обновиться).

Я не пытаюсь строить фреймворк, что-то доказать или настоять на истинности и правильности своих суждений.

Код в этом материале только для примера. Его можно считать псевдокодом на C#.

Предыстория заметки:

Недавно на канале Unity Architect была опубликована запись про систему абилок . Я оставил там свои комментарии и ответил на несколько вопросов. Чтобы сохранить возникшие идеи, я решил создать заметку в своём блоге.

Немного увлёкся, и простая заметка, собранная из нескольких коротких комментариев, разрослась в большое рассуждение, выходящее за рамки одного сообщения. Так эта заметка оказалась здесь.

Мои более короткие заметки и другой контент можно отслеживать в VK / Telegram Dtf.


Сложность системы способностей:

Казалось бы, что такого сложного в системе способностей. Но ответ приходит лишь после первого проекта с такой системой, когда отдел разработки перед документацией на следующую версию начинает выглядеть как-то так:

На практике выясняется, что для успешного решения этой задачи требуется ..... опыт! Или же тщательно продуманная и качественно описанная документация по такой массивной механике с указанием всех возможных вариантов применения. Но, чтобы такую документацию написать, геймдизайнеру требуется .... опыт!

Если ни Dev, ни GD в своей карьере ранее таким самостоятельно не занимались, то впереди, вероятно, будет много нового неспрогнозированного контекста, который может очень тяжело вписываться в ранее установленный фундамент.

Система способностей требует максимальной гибкости, потому что имеется большая вариативность:

  • разнообразных способностей;

  • условий использования;

  • целей для применения;

  • правил поведения и жизненного цикла;

  • комбинаций способностей между собой.

Любая гибкость приводит к усложнению системы, а это негативно сказывается на её поддерживаемости. Максимально гибкую систему вводить бессмысленно и дорого. К тому же это требует большого багажа опыта. Поэтому в каждом новом отличном проекте будут свои нюансы и подводные камни, с которыми придётся бороться (или знакомиться). А значит, сделав систему способностей один раз, нет гарантий, что в следующий получится быстрее или легче.

Помимо системы способностей в игре могут быть есть и другие системы: уровни персонажей, бонусы экипировки, специальные события и др. Всё это тоже динамически модифицирует параметры игры, и оно всё тоже должно друг с другом уметь взаимодействовать.

Пример: модификатор скорости перемещения от способности не должен отменять модификаторы скорости от других источников, если это не заложено как требование.

Mortal Kombat X и Blue Protocol
Mortal Kombat X и Blue Protocol

Тяжело изначально предсказать масштаб системы. Некоторые требования появляются только со временем. А что-то для геймдизайнеров "подразумевалось" с самого начала, но становится понятным для исполнителей только через условные полгода.

Пример: есть готовая система способностей, которая работает по принципу баффов, изменяющих параметры персонажей. В какой-то момент появляется необходимость сделать бафф, который позволяет персонажу проходить противников насквозь и получать от этого ускорение. Т.е. бафф, который выбивается из общей системы.

Пример: есть готовая система способностей и способность поджога оппонента. Позже в игре появляются всякие пропсы. Какие-то ломаются и имеют HP. Какие-то - нет. Но способность поджога нужно уметь применять теперь и для пропсов. Для всех. А разработчики изначально такого не закладывали.

Crysis и Victor Vran
Crysis и Victor Vran

Попробую пофантазировать о первом опыте.

Разработчик подумает: "Буду делать каждую способность, как отдельную сущность. Что может быть гибче".

Потом копится большое количество однотипных классов. Разработчик начнёт как-то это систематизировать, заведёт несколько базовых абстракций, вынесет общую логику туда.

Затем приходит способность, которая маленько то, а маленько другое. А у разработчика Unity и никакого множественного наследования. Наследование оказалось плохой идеей — надо было использовать композицию. Но уже поздно, ведь дедлайн, как назло, что-то около "вчера".

Далее в игре появляются другие системы со схожей функциональностью, но всё же другие. И это должно уметь сосуществовать с системой способностей.

И так нюанс за нюансом, пока первая реализация системы не превращается во что-то такое:

А в конце на это сверху садится мультиплеер, и всё ломается.


Как подойти к системе:

В оригинальном посте отмечалась важность отделения данных от бизнес-логики. Я это всецело поддерживаю. И не только в контексте системы способностей. В своей статье по сохранению прогресса я писал:

С технической точки зрения игра – это просто данные и операции над ними. Всё, что есть в игре, описывается данными. Всё, что происходит в игре, описывается операциями над данными. Всё, с чем физически взаимодействует игрок, реализуется через разнообразные устройства ввода и вывода.

Для меня, по крайней мере в CoreGame, отделение данных — основная концепция. Особенно когда речь заходит о возможном мультиплеере. Ведь синхронизация в мультиплеере основана на обмене данными между узлами. И делать это наиболее удобно и эффективно, когда данные отделены от всего остального.

Поэтому важно научиться конкретную способность представлять в виде набора исчерпывающих данных. Я пока остановился на декомпозиции способностей на следующие составляющие эффекты:

  • Статусы (агро, невидимость и пр.);

  • Модификаторы (+скорость, -урон и пр.);

  • Действия (нанесение урона целям, призыв новых сущностей, повторное применение и пр.).

В моей практике этих трёх типов эффектов пока оказалось достаточно для того, чтобы реализовать все необходимые способности.

Эти эффекты можно применять и в других системах, что позволяет обрабатывать взаимодействие между системами. Т.е. эффекты можно рассматривать как более общие базовые элементы.

Каждый эффект описывается какой-то своей структурой данных:

public readonly struct AgroStatus
{
  public readonly float Duration;
  public readonly uint TargetId;
}

public readonly struct HpModifier
{
  public readonly float Modifier;
  public readonly ModifierOp Operation; // Set, Add, Mult etc.
}

public readonly struct InstantiateAction
{
  public readonly uint ObjectId;
  public readonly Vector3 Position;
}

Когда персонаж хочет активировать способность, он обращается к контроллеру нужной способности. Например, так:

Character.Abilities[0].Apply();

Внутри контроллера работают несколько модулей, которые решают составные задачи системы способностей:

  • Проверка условий использования: способность может быть заблокирована уровнем, снаряжением, кол-ом маны, кулдауном, игровой зоной и пр.;

  • Определение области действия (если это AoE-способность): точечное использование, глобальное, по площади разных форм и пр.;

  • Поиск целей для применения (в т.ч. внутри AoE): это могут быть другие персонажи, группы персонажей, сам применяющий персонаж, мёртвые или живые, какие-то предметы или даже сам контроллер игрового мира;

  • Вычисление таймингов использования: когда искать цели, когда активировать эффекты способности и пр.;

  • Обработка стоимости использования: снятие маны/золота/здоровья и пр.;

  • Передача данных целям: делает цель носителем эффектов. Т.к. это всего лишь данные, и они обособлены, то обмениваться ими не сложно.

У каждого эффекта или типа эффекта есть некий обработчик, который реагирует на наличие нужных данных на цели, контролирует время существования этих данных и реализует соответствующую для эффекта логику:

  • Статусы: изменяет каким-то образом поведение цели;

  • Модификаторы: для каждой характеристики пробегается по всем наложенным модификаторам и вычисляет новое актуальное значение;

  • Действия: выполняет какое-то предопределённое действие.


Пример способности:

В обсуждениях оригинального поста был получен вопрос:

А если у меня абилка – это Шар Смерти, который падает на область и должен убить всех, кто попадает в радиус?

Могу предложить два решения, которые зависят от более подробного контекста.

Решение 1:

Если Шар Смерти имеет внутреннее состояние и как-то взаимодействует с миром (упругие столкновения, скольжения, кол-во целей в зависимости от кол-ва столкновений), то Шар Смерти выгоднее сделать отдельным игровым объектом, который имеет определённый жизненный цикл и умеет наносить фатальный урон в своём радиусе.

В таком случае, способность будет представлять собой лишь действие "инстанциировать Шар Смерти". Её цель — игровой мир, который контролирует жизненные циклы объектов внутри мира. Мир инстанциирует Шар, и дальше уже дело за Шаром. При необходимости обработчик способности может как-то на Шар подписаться и трекать события с него — это уже детали.

Решение 2:

Если Шар Смерти не взаимодействует с миром и не имеет внутреннего состояния, то получается, что суть этой способности — через N сек нанести фатальный урон всем целям в заданной области.

Таким образом, способность сводится к действию "нанести фатальный урон". Цель — персонажи в области. Контроллер их находит и раздаёт DamageAction'ы, по которым обработчик наносит фатальный урон.

Шар Смерти — это лишь визуальное воплощение, которое реализуется вне логики. Его можно заменить на языки пламени, исходящие из земли, облако яда или что-то другое, что способно наносить урон по области.

Дополнение про инстанциирование объектов:

Через инстанциирование новых объектов можно рекурсивно делать очень сложные способности. Ведь у этих новых объектов могут быть свои способности, которые они могут активировать по собственным определённым правилам.

Тот же Шар Смерти после применения может раскидать в округе осколки, которые наносят не фатальный урон по чуть большему радиусу. Если осколки будут отдельными объектами, то цепочку вызова способностей можно продолжить дальше.


Известный лайфхак:

Из-за своей гибкости (и монструозности) система способностей порой напрашивается на использование её вне контекста способностей в обычном их понимании. В играх в качестве способностей можно рассматривать множество обычных действий: перезарядка, выстрел, прыжок, кувырок и пр. Эти действия тоже имеют стоимость применения, ограничения использования и другие параметры. Только в качестве цели выбирается сам игрок.

Deadshot Brigade
Deadshot Brigade

Если это первый опыт разработки системы способностей, то эту задачу обычно оставляют на потом, когда уже будут готовы базовые элементы управления. Но после реализации системы способностей будет очень хотеться воткнуть её вместо сделанных иначе прыжков и других действий. Потому что это всё сильно упростит. Может быть не всегда — здесь не уверен. Тем не менее такие переработки могут дорого обойтись.

Не призываю ставить задачу по системе способностей раньше остальных — это вызовет свои неудобства. Но если заранее известно, что в игре появится система способностей, то стоит вести разработку с прицелом на появление такой системы и возможности подцепиться к ней в определённых местах. К сожалению, как именно, подскажет только личный опыт. Все системы разные – ко всем нужны разные подходы.


Данные во главе угла:

При проектировании способности важно определить, что она из себя представляет на уровне данных, и как она меняет другие игровые данные. Визуальное представление — вторично и вмешиваться в работу бизнес-логики (и разработчика) не должно.

Такое частое обращение внимания на данные как будто бы само собой двигает повествование в сторону Data Oriented Design. А в этом поле уже существует архитектурный паттерн, который позволяет сконцентрироваться на проектировании игры через данные (вдохнули) — это Entity-Component-System, он же ECS.

Задача системы способностей – одна из тех задач (да, не из "всех"), которая хорошо решается в ECS. Способностей и их эффектов много, комбинаций эффектов ещё больше, цели для эффектов разнообразные. Здесь всё масштабно, очень динамично и комбинаторно сложно.

В такой парадигме Статусы, Модификаторы и Действия – это компоненты:

public struct CurseStatusComponent : IComponent
{
  public readonly float DamagePerSecond;
  public readonly long StartTimestamp;
  public readonly TimeSpan Duration;
}

Компоненты добавляются к целевым Entity:

CurseStatusComponent status =
  new(damagePerSecond, DateTime.Now.Ticks, duration);
character.AddComponent(status);

Обработчики этих компонентов – это системы:

public sealed class CurseStatusSystem : UpdateSystem
{
  private Filter _filter;

  public override void OnAwake() =>
    _filter = World.Filter
      .With<CurseStatusComponent>()
      .Build();

  public override void OnUpdate(float deltaTime)
  {
    foreach (Entity entity in _filter)
      ...
  }
}

Жизнь без ECS:

Если игра уже построена не на ECS, то ради одних способностей тащить это туда – нецелесообразно. Это сильно усложнит общую архитектуру проекта. Тем не менее, для способностей можно построить что-то похожее в рамках "классического" подхода.

Для всех носителей эффектов потребуется сделать какой-то общий контракт со списками, куда можно будет помещать наложенные эффекты. При этом для Статусов, Модификаторов и Действий тоже потребуются базовые абстрактные классы, чтобы их можно было хранить в общих коллекциях.

Главное — не делать абстракцию через интерфейс, а реализации — через структуры. Тогда при хранении в коллекции по интерфейсу будет происходить постоянный boxing/unboxing.

Т.е. получится что-то такое:

public interface IEffectTarget
{
  ICollection<ActionEffect> Actions { get; }
  ICollection<StatusEffect> Statuses { get; }
  ICollection<ModifierEffect> Modifiers { get; }
}

Или даже такое:

public interface IEffectTarget // или сразу IEntity
{
  ICollection<Effect> Effects { get; }
}

С обработчиками сложнее – тут значительно больше разных вариантов, особенно если уходить в конкретику.

Если у обработчиков есть какое-то внутреннее состояние, то можно обработчики сделать индивидуальными для каждого носителя. Или даже для каждого наложенного эффекта.

Лучше, когда обработчики stateless. В этом случае их можно сделать общими для всех носителей и крутить логику централизованно. Т.е. некое подобие систем из ECS. Из своего опыта, это оказалось удобнее в поддержке, отладке, доработке и предоставляет простор для параллельной обработки.

Коллекции эффектов можно заменить на реактивные. Тогда обработчикам будет удобно подписываться на изменения и следить за наличием нужных эффектов.


В мультиплеере:

Т.к. вся работа системы способностей упирается в оперирование данными, то идейно это достаточно несложно встраивается в мультиплеер – достаточно обычную коллекцию заменить на синхронизируемую:

public sealed class PlayerObject : IEffectTarget
{
  public PlayerState State { get; }
  ICollection<Effect> IEffectTarget.Effects => State.AppliedEffects;
}

public sealed class PlayerState : NetworkBehaviour
{
  public SyncList<Effect> AppliedEffects = new();
}

Пример искусственный и упрощённый. В реальности всё не так "просто": есть свои нюансы от фреймворка к фреймворку. Но суть примерно такая: раз данные лежат в коллекции, а логика строится от данных в этой коллекции, то достаточно синхронизировать эту коллекцию.

Решение вопросов, связанных с оптимизацией реплицируемых данных, компенсацией задержек и пр. – это предметы для разговора другого порядка.


Визуал:

Если Данные, Логика и Представления друг от друга отделены, то задача подключения визуала не должна вызывать больших трудностей. Есть данные, есть их изменения – следим за этим и рендерим картинку, которая соответствует данным.

Слои проекта
Слои проекта

Однако способностей может быть много, для каждой нужна своя реакция, а какие-то реакции даже повторяются у разных способностей и т.д. Можно попробовать визуальные реакции декомпозировать на простые переиспользуемые команды (включить VFX, потрясти камеру, обновить полоску здоровья и пр.). В т.ч. переиспользуемые между разными системами.

Команды отправлять из Логики в шину команд, откуда их будет забирать для обработки слой Представления (при этом прямой связи между Логикой и Представлением не возникает). Или ввести посредника, который будет общие события Логики переводить в команды для Представления. Это также позволит централизованно решать всякие коллизии, когда разные системы, например, пытаются одновременно потрясти камеру с разной силой.

Всё снова сводится к данным и управлению потоками этих данных, только в визуальной части.

The Iron Oath
The Iron Oath

Другое дело, когда Представление как-то переплетается с Логикой. Здесь может возникнуть сильно больше занятных сложностей. Как мне кажется, лучший способ их решить – не доводить до них. Логике и Данным лучше не знать о существовании Представления. А задачи по типу "применять способность, когда пройдёт анимация каста" решать через настройку таймингов в Логике и переставлять акценты на "воспроизводить анимацию каста до непосредственного применения способности".

Также независимость от Представления в мультиплеере с клиент-серверной архитектурой позволит серверу свободно использовать данные способностей для просчёта логики и изменения игровых данных без лишних визуальных зависимостей, а клиенту — заниматься без лишнего контекста отрисовкой VFX'ов, HUD'ов, анимаций и пр.


Дополнительный контент:

  • Дизайним абилки как в X-COM: Habr

  • Как устроены абилки в War Robots: Habr

  • Гибкая система способностей персонажей в играх: Habr

  • Gameplay Ability System в UE (в т.ч. мультиплеере): YouTube: Unreal Meetup

Комментарии (6)


  1. F4_345gg
    07.10.2024 01:34
    +1

    спасибо было познавательно )