Найти в Дзене
ElandGames

Делаем игру на Unity и изучаем C# (Часть 12)

Оглавление

Добавим врагу параметр здоровья и сделаем его реакцию на иссякшее здоровье, а также поговорим о дублировании кода и наследовании. Если интересна тема создания игр, то не забудьте подписаться на канал и поехали!

Параметры игровых персонажей

В нашей игре, волк - это один из игровых персонажей. Мы разбили его логику на отдельные компоненты AnimalMove и Enemy, которые соответственно отвечают за передвижение и агрессивное поведение.

Логично, что такой параметр персонажа, как скорость передвижения speed, находится в компоненте движения AnimalMove, а величина урона damage в компоненте Enemy, но куда добавить здоровье? Оно ни туда, ни сюда не относится, да и вообще является неким общим признаком для всех игровых персонажей.

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

Пока это будут переменные здоровья health и maxHealth, а также метод изменения здоровья ChangeHealth() и максимального здоровья ChangeMaxHealth(). Всё примерно как у игрока и этот код можно даже скопировать из класса Game. Быстро и легко, но это частая ошибка новичков - дублирование кода.

Общая логика для всех игровых персонажей - потеря здоровья и восстановление его до максимального значения.
Общая логика для всех игровых персонажей - потеря здоровья и восстановление его до максимального значения.

Частые проблемы: Дублирование кода и Мега классы

Зачем такие сложности с наследованием? Наследование помогает избежать 2 основные проблемы новичков - дублирование и огромные мега классы.

Мега классы получаются, если в одном скрипте мы начинаем прописывать логику всего, что только можно - и движение, и атака, и обновление интерфейса, и изменение параметров, и всего, что только придёт в голову. Такой класс быстро разрастается в объёме до сотен и даже тысяч строчек кода. В нём трудно что-либо найти и тяжело редактировать.

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

Наследование в C# делается указанием родительского класса через двоеточие. Множественного наследование в C# нет, поэтому родитель может быть только один. Но у него может быть свой родитель и тогда получается последовательная цепочка наследования.
Наследование в C# делается указанием родительского класса через двоеточие. Множественного наследование в C# нет, поэтому родитель может быть только один. Но у него может быть свой родитель и тогда получается последовательная цепочка наследования.

Например, Unit > AnimalMove > Enemy. Причём класс Unit может наследоваться и игроком, а безобидные животные вместо класса Enemy смогут наследовать, например, класс Animal, в котором вместо логики нападения будет логика бегства. А человекоподобные враги смогут иметь свою логику движения прописанную в классе HumanMove.

С дублированием кода мы уже сталкивались. Даже использовали вынесение повторяющегося кода в отдельный метод для решения этой проблемы. Это решение поможет избежать дублирование строчек кода внутри класса. А вот с повторяющимися полями и логикой в разных классах поможет справиться наследование.

Например, метод изменения здоровья ChangeHealth() мы уже создавали для нашего главного героя. Мы можем этот метод просто скопировать в родительский класс всех персонажей Unit и тем самым продублировать код, но можем и представить главного героя не как отдельную сущность, а как игрового персонажа типа Unit, который просто контролируется игроком.

Для этого просто унаследуем класс Player от класса Unit.

Теперь поля health и maxHealth будут доступны в классе Player через наследование
Теперь поля health и maxHealth будут доступны в классе Player через наследование

Изначально здоровье игрока и метод его изменения мы прописали в классе Game, но теперь все это будет в классе Player унаследованном от класса Unit, а значит из Game нужно их удалить. После этого исправить все ссылки на эти поля в нашем коде, которые станут подчеркнуты как ошибки.

Поскольку поле health из этого класса мы удалили, то все ссылки на него выделятся как ошибка
Поскольку поле health из этого класса мы удалили, то все ссылки на него выделятся как ошибка

В классе Game уже есть ссылка на класс Player. Остается только изменить везде обращение к health на обращение player.health.

Читаемость даже улучшилась - теперь мы конкретно указываем чьё здоровье имеется в виду
Читаемость даже улучшилась - теперь мы конкретно указываем чьё здоровье имеется в виду

Аналогично изменим и ссылки из других скриптов. Ссылки на методы ChangeHealth() и ChangeMaxHealth() тоже нужно будет изменить.

Ссылки на методы теперь тоже нужно изменить
Ссылки на методы теперь тоже нужно изменить

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

Меньше ссылок - больше ясности!
Меньше ссылок - больше ясности!

Такие же изменения нужно сделать в скриптах DamageTrigger, Mushroom и Enemy. Запускаем игру и проверяем. Всё работает, но индикатор здоровья не обновляется. Так как метод ChangeHealth() теперь общий для всех существ, то специально для игрока дополним его функционал с помощью переопределения.

В классе Unit пометим метод ChangeHealth() как виртуальный, с помощью идентификатора virtual. А в классе Player пропишем этот метод с идентификатором override, повторив всю его логику с помощью ссылки на базовый (родительский) класс base. Ну и добавим обновление UI.

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

Изменение здоровья волка при ударе

Теперь мы можем унаследовать класс любого игрового персонажа от класса Unit и у него будет здоровье, максимальное здоровье и методы для их изменения. Так и поступим с волком. Унаследуем класс AnimalMove от Unit.

-9

Получилась та самая цепочка наследования Unit > AnimalMove > Enemy. Теперь в методе TakeDamage() класса Enemy легко добавим изменения здоровья волка. Главное не забыть, что меняем здоровье со знаком минус!

Теперь у волка есть переменная health отвечающая за здоровье и её значение будет меняться при ударе игрока.
Теперь у волка есть переменная health отвечающая за здоровье и её значение будет меняться при ударе игрока.

Реакция волка на иссякшее здоровье

Чтобы сделать простейшую механику смерти для волка нам понадобится новый метод Death(), который по идее должен быть свойственен всем игровым персонажам. Поэтому добавим его в класс Unit, но сделаем его виртуальным, чтобы для игрока и волка прописать в нём разную логику. Метод ChangeHealth() будет вызывать метод Death() при здоровье равном или меньше 0.

Виртуальный метод можно оставить пустым, чтобы впоследствии его переопределить.
Виртуальный метод можно оставить пустым, чтобы впоследствии его переопределить.

У нас для игрока уже есть метод GameOver() в классе Game, так что его нужно сделать публичным и вызывать в переопределенном методе Death() в классе Player.

У нас получилось два одноименных метода Death(), но из-за того что у одного из них есть входной параметр VisualStudio их будет воспринимать как разные методы и ошибки не будет.
У нас получилось два одноименных метода Death(), но из-за того что у одного из них есть входной параметр VisualStudio их будет воспринимать как разные методы и ошибки не будет.

В C# методы названные одинаково не смогут работать одновременно. Один фактически перекроет другой. Но если у методов разные входные параметры, то это уже разные методы. Главное самим не запутаться. :) Поэтому предлагаю метод отвечающий за анимацию переименовать в DeathAnimation() и не забыть убрать его из метода GameOver(), чтоб его вызов не дублировался.

Переименовывать, кстати, в VisualStudio лучше по правой кнопке мыши из меню - тогда все ссылки на переименованный метод, класс или поле тоже автоматически переименуются. А иначе все придется исправлять вручную. :)

Чтобы переименовать что-то в проекте не ломая ссылки, делаем это через контекстное меню.
Чтобы переименовать что-то в проекте не ломая ссылки, делаем это через контекстное меню.

Вот теперь никакой двусмысленности.

Для управления анимациями персонажа в будущем лучше будет создать отдельный класс AnimationController
Для управления анимациями персонажа в будущем лучше будет создать отдельный класс AnimationController

Нужно еще только в скрипте Game сделать вызов не метода GameOver(), а метода Death().

-15

Проверяем - всё работает! Наш рефакторинг ничего не поломал! :)

При изменении здоровья игрока в родительском классе Unit был вызван виртуальный метод Death, который вызвал свой переопределенный метод Death в классе Player, а тот уже обратился к классу Game. При этом сам класс Unit обратится бы к классу Game не смог, т.к. ссылка на него есть только у дочернего класса Player.
При изменении здоровья игрока в родительском классе Unit был вызван виртуальный метод Death, который вызвал свой переопределенный метод Death в классе Player, а тот уже обратился к классу Game. При этом сам класс Unit обратится бы к классу Game не смог, т.к. ссылка на него есть только у дочернего класса Player.

Теперь нужно сделать анимацию смерти для волка и вызвать её в переопределенном методе Death() в классе Enemy. Почему в классе Enemy? Потому-что в нём идёт управление триггером урона и может получится так, что волк умрёт с включенным триггером и мы будем получать урон проходя по нему. Поэтому DamageTrigger принудительно отключим.

Если анимация прервётся между анимационными событиями включения и выключения, то триггер урона может остаться включенным.
Если анимация прервётся между анимационными событиями включения и выключения, то триггер урона может остаться включенным.

Что еще нужно для того, чтобы волк перешёл в состояние смерти? Нужно прервать все асинхронные методы движения - Move() и FollowPlayer(). Для этого, как и у игрока, у волка должна быть булевая переменная isAlive. Давайте перенесем её из класса Game в класс Unit.

Параметр isAlive теперь будет у всех игровых персонажей
Параметр isAlive теперь будет у всех игровых персонажей

Исправим ссылки в классах Game и Player. А теперь в классе Enemy добавим изменение этой переменной.

-19

Сделаем выход из цикла асинхронного метода FollowPlayer(), когда isAlive равна false.

-20

Тоже самое сделаем и в методе Move() класса AnimalMove().

-21

Теперь в случае смерти волка циклы движения прервутся, но он все еще может продолжить движение и его скорость лучше обнулить, а еще лучше компонент NavMeshAgent вообще выключить.

Поскольку ссылка на компонент NavMeshAgent является публичной, то она доступна и в дочернем классе
Поскольку ссылка на компонент NavMeshAgent является публичной, то она доступна и в дочернем классе

Создаём блок анимации Death в аниматоре волка, добавляем анимационный параметр isDeath и переходы между блоками. Находим анимацию смерти среди анимаций волка.

Выход из анимации смерти тоже на всякий случай предусмотрим.
Выход из анимации смерти тоже на всякий случай предусмотрим.

В настройках анимации смерти надо не забыть убрать галочку Can Transition To Self, иначе переход будет вызываться постоянно.

-24

Создадим в классе Enemy метод DeathAnimation() такой же как у игрока. Повторение кода, но пока оставим так.

Поскольку анимационные параметры и внутренние переменные именуем одинаково, то все анимационные методы получаются стандартными
Поскольку анимационные параметры и внутренние переменные именуем одинаково, то все анимационные методы получаются стандартными

Остаётся теперь только в методе Death() вызвать анимацию смерти и можно проверять!

Анимация смерти вызывается как и у игрока, но из-за того, что аниматор принадлежит дочернему классу относительно класса Unit, сделать вызов анимации общим мы пока не сможем.
Анимация смерти вызывается как и у игрока, но из-за того, что аниматор принадлежит дочернему классу относительно класса Unit, сделать вызов анимации общим мы пока не сможем.

Проверяем. Ура! Победа! Серый хищник повержен и теперь можно спокойно собирать грибы! :)

Это суровое выживание в дикой природе - или мы, или нас!
Это суровое выживание в дикой природе - или мы, или нас!

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

Скриншоты того, как выглядят ваши проекты тоже с удовольствием бы глянул - наверняка многие уже все переделали по-своему. :)

Серия статей по разработке игр:

Учимся делать игры и программировать на C# | Сергей Эланд | Дзен

А вот тут можно глянуть как я разбавляю суровую жизнь начинающего разработчика игр:

Весна в стиле рок-н-ролл или концерты на выживание
Сергей Эланд20 марта 2024

Кстати, меня просили выкладывать скриншоты финальной версии скриптов - вот скрипт Enemy:

-28
-29
-30