Добавим врагу параметр здоровья и сделаем его реакцию на иссякшее здоровье, а также поговорим о дублировании кода и наследовании. Если интересна тема создания игр, то не забудьте подписаться на канал и поехали!
Параметры игровых персонажей
В нашей игре, волк - это один из игровых персонажей. Мы разбили его логику на отдельные компоненты AnimalMove и Enemy, которые соответственно отвечают за передвижение и агрессивное поведение.
Логично, что такой параметр персонажа, как скорость передвижения speed, находится в компоненте движения AnimalMove, а величина урона damage в компоненте Enemy, но куда добавить здоровье? Оно ни туда, ни сюда не относится, да и вообще является неким общим признаком для всех игровых персонажей.
Поэтому предлагаю создать новый класс Unit, который станет прародителем для всех классов игровых персонажей и будет ими наследоваться. Он будет содержать общие для всех персонажей параметры и логику.
Пока это будут переменные здоровья health и maxHealth, а также метод изменения здоровья ChangeHealth() и максимального здоровья ChangeMaxHealth(). Всё примерно как у игрока и этот код можно даже скопировать из класса Game. Быстро и легко, но это частая ошибка новичков - дублирование кода.
Частые проблемы: Дублирование кода и Мега классы
Зачем такие сложности с наследованием? Наследование помогает избежать 2 основные проблемы новичков - дублирование и огромные мега классы.
Мега классы получаются, если в одном скрипте мы начинаем прописывать логику всего, что только можно - и движение, и атака, и обновление интерфейса, и изменение параметров, и всего, что только придёт в голову. Такой класс быстро разрастается в объёме до сотен и даже тысяч строчек кода. В нём трудно что-либо найти и тяжело редактировать.
Решением будет разбить логику на отдельные классы с конкретной ответственностью. Move отвечает за движение, а Attack за атаку. Такие классы могут существовать параллельно и быть связаны ссылками. А могут последовательно наследовать логику друг друга.
Например, Unit > AnimalMove > Enemy. Причём класс Unit может наследоваться и игроком, а безобидные животные вместо класса Enemy смогут наследовать, например, класс Animal, в котором вместо логики нападения будет логика бегства. А человекоподобные враги смогут иметь свою логику движения прописанную в классе HumanMove.
С дублированием кода мы уже сталкивались. Даже использовали вынесение повторяющегося кода в отдельный метод для решения этой проблемы. Это решение поможет избежать дублирование строчек кода внутри класса. А вот с повторяющимися полями и логикой в разных классах поможет справиться наследование.
Например, метод изменения здоровья ChangeHealth() мы уже создавали для нашего главного героя. Мы можем этот метод просто скопировать в родительский класс всех персонажей Unit и тем самым продублировать код, но можем и представить главного героя не как отдельную сущность, а как игрового персонажа типа Unit, который просто контролируется игроком.
Для этого просто унаследуем класс Player от класса Unit.
Изначально здоровье игрока и метод его изменения мы прописали в классе Game, но теперь все это будет в классе Player унаследованном от класса Unit, а значит из Game нужно их удалить. После этого исправить все ссылки на эти поля в нашем коде, которые станут подчеркнуты как ошибки.
В классе Game уже есть ссылка на класс Player. Остается только изменить везде обращение к health на обращение player.health.
Аналогично изменим и ссылки из других скриптов. Ссылки на методы ChangeHealth() и ChangeMaxHealth() тоже нужно будет изменить.
Теперь это будет еще и более читаемо, так как мы по поводу здоровья будем обращаться напрямую к игроку.
Такие же изменения нужно сделать в скриптах DamageTrigger, Mushroom и Enemy. Запускаем игру и проверяем. Всё работает, но индикатор здоровья не обновляется. Так как метод ChangeHealth() теперь общий для всех существ, то специально для игрока дополним его функционал с помощью переопределения.
В классе Unit пометим метод ChangeHealth() как виртуальный, с помощью идентификатора virtual. А в классе Player пропишем этот метод с идентификатором override, повторив всю его логику с помощью ссылки на базовый (родительский) класс base. Ну и добавим обновление UI.
Изменение здоровья волка при ударе
Теперь мы можем унаследовать класс любого игрового персонажа от класса Unit и у него будет здоровье, максимальное здоровье и методы для их изменения. Так и поступим с волком. Унаследуем класс AnimalMove от Unit.
Получилась та самая цепочка наследования Unit > AnimalMove > Enemy. Теперь в методе TakeDamage() класса Enemy легко добавим изменения здоровья волка. Главное не забыть, что меняем здоровье со знаком минус!
Реакция волка на иссякшее здоровье
Чтобы сделать простейшую механику смерти для волка нам понадобится новый метод Death(), который по идее должен быть свойственен всем игровым персонажам. Поэтому добавим его в класс Unit, но сделаем его виртуальным, чтобы для игрока и волка прописать в нём разную логику. Метод ChangeHealth() будет вызывать метод Death() при здоровье равном или меньше 0.
У нас для игрока уже есть метод GameOver() в классе Game, так что его нужно сделать публичным и вызывать в переопределенном методе Death() в классе Player.
В C# методы названные одинаково не смогут работать одновременно. Один фактически перекроет другой. Но если у методов разные входные параметры, то это уже разные методы. Главное самим не запутаться. :) Поэтому предлагаю метод отвечающий за анимацию переименовать в DeathAnimation() и не забыть убрать его из метода GameOver(), чтоб его вызов не дублировался.
Переименовывать, кстати, в VisualStudio лучше по правой кнопке мыши из меню - тогда все ссылки на переименованный метод, класс или поле тоже автоматически переименуются. А иначе все придется исправлять вручную. :)
Вот теперь никакой двусмысленности.
Нужно еще только в скрипте Game сделать вызов не метода GameOver(), а метода Death().
Проверяем - всё работает! Наш рефакторинг ничего не поломал! :)
Теперь нужно сделать анимацию смерти для волка и вызвать её в переопределенном методе Death() в классе Enemy. Почему в классе Enemy? Потому-что в нём идёт управление триггером урона и может получится так, что волк умрёт с включенным триггером и мы будем получать урон проходя по нему. Поэтому DamageTrigger принудительно отключим.
Что еще нужно для того, чтобы волк перешёл в состояние смерти? Нужно прервать все асинхронные методы движения - Move() и FollowPlayer(). Для этого, как и у игрока, у волка должна быть булевая переменная isAlive. Давайте перенесем её из класса Game в класс Unit.
Исправим ссылки в классах Game и Player. А теперь в классе Enemy добавим изменение этой переменной.
Сделаем выход из цикла асинхронного метода FollowPlayer(), когда isAlive равна false.
Тоже самое сделаем и в методе Move() класса AnimalMove().
Теперь в случае смерти волка циклы движения прервутся, но он все еще может продолжить движение и его скорость лучше обнулить, а еще лучше компонент NavMeshAgent вообще выключить.
Создаём блок анимации Death в аниматоре волка, добавляем анимационный параметр isDeath и переходы между блоками. Находим анимацию смерти среди анимаций волка.
В настройках анимации смерти надо не забыть убрать галочку Can Transition To Self, иначе переход будет вызываться постоянно.
Создадим в классе Enemy метод DeathAnimation() такой же как у игрока. Повторение кода, но пока оставим так.
Остаётся теперь только в методе Death() вызвать анимацию смерти и можно проверять!
Проверяем. Ура! Победа! Серый хищник повержен и теперь можно спокойно собирать грибы! :)
Надеюсь, что урок был для кого-то полезным и кто-то узнал что-то новое! :) Чтобы не пропустить продолжение не забудьте подписаться на канал, можно порадовать меня лайком или комментарием, чтобы я на радостях поскорее написал продолжение, ну и рассказывайте о ваших успехах - что получается, что нет.
Скриншоты того, как выглядят ваши проекты тоже с удовольствием бы глянул - наверняка многие уже все переделали по-своему. :)
Серия статей по разработке игр:
А вот тут можно глянуть как я разбавляю суровую жизнь начинающего разработчика игр:
Кстати, меня просили выкладывать скриншоты финальной версии скриптов - вот скрипт Enemy: