Как стать автором
Обновить

Задачка «Вооружимся архитектурой»

Время на прочтение5 мин
Количество просмотров7.4K

Всем привет! Меня зовут Дядиченко Григорий, и я технический продюсер. В своём блоге в телеграм я периодически публикую задачки по Unity. Решение одной задачки получается слишком длинным, чтобы писать про него в блог. Поэтому я решил это оформить в статью. Задачка звучит так.

Вы решили сделать с друзьями свой скайрим. У вас пока в игре есть два вида оружия: молот и меч. Они отличаются уроном, прочностью и скоростью атаки. Плюс логикой атаки. Атака молота аое в круговой зоне. Атака меча бьёт до 3 ближайших противников. К вам пришёл катжит и за лунный сахар предложил купить у него схему архитектуры на картинке выше. Предложите решение лучше. В чём проблема данной схемы?

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

Определение сущности объекта через наследование

Есть старая задачка по C++ про зоопарк и тигров. И она неплохо объясняет концепцию наследования, но в ней та же проблема. Если доводить до абсурда, то в мире насчитали более 1.6 миллиона видов животных. И если у нас зоопарк со всеми видами животных, то вы с ума сойдёте разбираться в группировках или в отдельных видах.

Определять объект через наследование таким образом просто плохо. Видов банально средневекового оружия (включая Японское) больше 100 штук. И это неизбежно приведёт к забавным костылям. Что то, что называется моргенштерном по типу является "молотом". Как скажем в World Of Warcraft почти все нейтральные юниты являлись паладинами. Белка паладин конечно я уверен, что существует:

Но это не лучшее решение на уровне проектировки. А тут специально введено ограничение, что это RPG. То есть там много оружия.

Помимо этого в RPG у нас урон наносит не только оружие. Оружие наносит не только физический урон, но и магический, или урон огнём. А ещё оружие может иметь разное поведение атаки, а может и одинаковое. АОЕ урон может стать бафом с доп. уроном. Есть резисты, бафы и т.п. Давайте же спроектируем что-то здравое.

Сначала был урон

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

Атака — это поведение

В чём разница между интерфейсом и абстрактным классом и почему не стоит вводить абстрактный класс Weapon?

Есть старый как мир пример с поведением открывать. Открывать двери может лом, ключ карта или же металлический ключ. Но они совершенно разные по природе. У них нет некоего объекта обобщения. Их обобщает единое поведение в контексте системы.

С оружием есть обобщающий объект — оружие. Но в рпг помимо этого урон могут наносить ловушки, заклинания и много чего ещё. Поэтому обобщающим с точки зрения атаки должен быть именно интерфейс. Это в будущем будет очень удобно.

Тоже самое с получением урона. Является ли разрушаемый стол — Unit, а белка — "паладином"? Нет. Белка и стол просто "могут получать урон", так же как игрок, противники и всё что вы захотите ещё. И это единственное, что им надо знать в контексте урона. Поэтому заведём второй интерфейс.

Мы научились наносить урон и получать урон. Что дальше?

Оружие атакует?

Вот мы завели IAttack, теперь наследуем от него кучу конкретных классов оружий и всё классно. Не совсем.

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

public struct AOEAttack : IAttack{
    private Vector3 _position;
    private AOEAttackData _attackData;
  
    public AOEAttack(Vector3 position, AOEAttackData data)
    {
        _position = position;
        _attackData = data;
    }
  
    public void Attack(){
        IEnumerator<IDamagable> damagables = WorldManager.GetDamagable(position, _attackData.Area);
        foreach(var damagable in damagables){
            damagable.TakeDamage(_attackData.Damage);
        }
    }
}

Что такое AOEAttackData? Для атаки нам нужно хранить как-то параметры этой атаки.

public class AttackData{
  public DamageData Damage;
  public AttackType AttackType;
}
public class AOEAttackData : AttackData{
  public float Area;
}
public enum AttackType{
  AOE, //Hammer Damage
  Splash //Sword Damage
}

Enum для определение типов атаки удобен для сериализации, и у нас получается достаточно стройная модель данных. Но у нас всё ещё нет оружия.

public class Weapon{
  public AttackData AttackData;
  public int Sustain;
}

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

 Weapon hammer = new Weapon()
{
    AttackData = new AOEAttackData()
    {
        AttackType = AttackType.AOE,
        Damage = new DamageData()
        {
            Damage = 100,
            Type = DamageType.Physical
        },
        Area = 100
    }
};

Данные для атаки молота готовы. Допустим пока у нас атакует только пользователь. Во-первых, нам нужно создать атаку.

public static class AttackManager
{
    public static IAttack GetAttack(Vector3 position, AttackData data)
    {
        switch (data.AttackType)
        {
            case AttackType.AOE:
                return data is AOEAttackData aoeData ? new AOEAttack(position, aoeData) : null;
            case AttackType.Splash:
                return data is SplashAttackData splashData ? new SplashAttack(position, splashData) : null;
        }
        return null;
    }
}

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

И дальше наконец-таки атакуем. Дадим пользователю в руки молот и нанесём урон куда-то на клавишу "А".

public class Player : MonoBehaviour
{
    private Weapon _weapon;
    private void Start()
    {
        _weapon = new Weapon()
        {
            AttackData = new AOEAttackData()
            {
                AttackType = AttackType.AOE,
                Damage = new DamageData()
                {
                    Damage = 100,
                    Type = DamageType.Physical
                },
                Area = 100
            }
        };
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            var attack = AttackManager.GetAttack(transform.position, _weapon.AttackData);
            attack.Attack();
        }
    }
}

И в целом задача решена. Полную схему я приведу в конце. Теперь по одному нюансу моего решения.

Почему атаки — это команды?

Очень часто важен порядок атак. Чтобы скажем понимать юнит жив или мёртв, нужно ли наносить ему урон, запускать какую-то анимацию и т.п. И в реальной боевой архитектуре атака не наносилась бы пользователем, а шла бы скажем в AttackManager и в очередь атак и потом резолвилась бы эта очередь. Так получается чуть больше явного контроля + удобства для отмены той же атаки если всё произошло в один такт и один кадр.

В заключении

В реальной игре ещё были бы всякие бафы, глобал эффекты и т.п. и много чего ещё. Это чисто небольшой архитектурный срез одной подсистемы, которую в дальнейшем удобно расширять. На самом деле команды атаки и данные можно через рефлексию подтягивать в систему через то, что вы создаёте нужный вам тип атаки, и он дальше уже интегрирован во всю систему. Без добавления доп. строчки с as в AttackManager. И это может кто-то написать самостоятельно. Такая доп. задачка к задаче.

Финальная архитектура, которую у каджита уже можно было бы купить выглядит как-то так.

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

Теги:
Хабы:
Всего голосов 6: ↑5 и ↓1+4
Комментарии32

Публикации

Истории

Работа

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
14 сентября
Конференция Practical ML Conf
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн