Найти в Дзене
Сергей Эланд

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

В этом уроке выдадим нашему главному герою топор и научим отбиваться от волка, а ещё разберём одну важную проблему, которая может привести нашу игру к ошибке. Не забудьте подписаться, если интересна тем создания игр, чтобы изучить предыдущие статьи и не пропустить продолжение. И давайте начнём творить! :) Переполнение стека вызовов Благодаря комментарию подписчика, который обратил внимание на проблему переполнения стека вызовов, я изучил тему (сам был не в курсе) и думаю стоит о ней рассказать простыми словами. И обязательно учесть эту проблему при написании нашего кода. Ошибку StackOverflowException мы можем получить по разным причинам, но одна из них это переполнения стека вызовов. Каждый раз когда мы запускаем какой-либо метод, то наша программа сохраняет его в стек вызовов и держит там пока метод не отработает до конца. Если наш метод вызовет другой метод, то он тоже сохранится в стеке и будут они там храниться целым списком пока последний из них не отработает до конца. Почему так
Оглавление

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

Переполнение стека вызовов

Благодаря комментарию подписчика, который обратил внимание на проблему переполнения стека вызовов, я изучил тему (сам был не в курсе) и думаю стоит о ней рассказать простыми словами. И обязательно учесть эту проблему при написании нашего кода.

Ошибку StackOverflowException мы можем получить по разным причинам, но одна из них это переполнения стека вызовов. Каждый раз когда мы запускаем какой-либо метод, то наша программа сохраняет его в стек вызовов и держит там пока метод не отработает до конца. Если наш метод вызовет другой метод, то он тоже сохранится в стеке и будут они там храниться целым списком пока последний из них не отработает до конца.

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

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

Вот простой эксперимент: в методе Start() вызываем Method(), который будет вызывать сам себя и выводить сообщение в консоль.

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

Запускаем игру и сразу получаем ошибку. За считанные миллисекунды метод забивает собой стек, отработав 13648 раз.

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

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

Поэтому решением будет воспользоваться циклом while, который по сути будет делать тоже самое, но внутри одного запущенного метода. Главное теперь проконтролировать, чтобы метод Move() вызывался только один раз, так как он будет работать бесконечно, а несколько запущенных методов будут работать параллельно и заставят волка метаться из стороны в сторону.

Теперь метод движения Move() запускается только один раз при старте и сам выполняет проверку дистанции, поиск новой точки и в случае обнаружения игрока ничего не делает пока игрок снова не будет потерян.
Теперь метод движения Move() запускается только один раз при старте и сам выполняет проверку дистанции, поиск новой точки и в случае обнаружения игрока ничего не делает пока игрок снова не будет потерян.

Добавляем в игру оружие

В UnityAssetStore найдём подходящую модель оружия - например, я для нашего низкополигонального персонажа нашел вот такой низкополигональный топор.

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

Добавляем в проект, перетаскиваем префаб понравившегося топора на сцену и порой так случается, что префаб совсем не префаб, т.е. он вообще не настроен.

Отличный топор... для белки!
Отличный топор... для белки!

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

Это простые одноцветные материалы без текстур. Просто, но производительно! :)
Это простые одноцветные материалы без текстур. Просто, но производительно! :)

Теперь нам нужно сделать этот топор дочерним объектом к кисти персонажа. В окне иерархии найдем игрока в бесконечновложенных друг в друга объектах костей найдем кисть правой руки. Там даже специальный пустой объект есть под предмет - в него и перетащим топор.

Структура костей даже у человекоподобных моделей может отличаться, но не сильно. Руки обычно являются продолжением позвоночника (Spine).
Структура костей даже у человекоподобных моделей может отличаться, но не сильно. Руки обычно являются продолжением позвоночника (Spine).

Теперь если обнулить координаты топора, то он окажется там где надо.

Оружие в модели персонажа мы располагаем относительно кисти
Оружие в модели персонажа мы располагаем относительно кисти

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

Положение пальцев выглядит не очень естественно, но это мелочи. При желании можно в Блендере немного их загнуть.
Положение пальцев выглядит не очень естественно, но это мелочи. При желании можно в Блендере немного их загнуть.

Механика атаки персонажа

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

  • Анимация удара
  • Триггер урона (DamageTrigger)
  • Анимационное событие включение и выключения триггера
  • Скрипт, в котором будет величина урона и метод для анимационного события

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

Анимация атаки персонажа

Для начала добавим анимацию атаки:

  • Откроем аниматор персонажа
  • Создадим новый блок анимации, назовём его Attack и свяжем переходами с BlendTree
  • Найдём подходящую анимацию в папке персонажа и перетащим её в поле Motion нового блока анимации
  • Добавим новый анимационный параметр isAttack и поставим его в качестве условия перехода к атаке в значении true и в качестве условия выхода из неё в значении false
Для анимации атаки я выбрал двуручную атаку, т.к. она показалась мне поинтереснее.
Для анимации атаки я выбрал двуручную атаку, т.к. она показалась мне поинтереснее.

Вызывать эту анимацию пока временно и для наглядности будем просто из метода Update() в скрипте Player. Когда левая кнопка мыши зажата, то анимация атаки проигрывается по кругу, когда отпускаем она прекращается.

Проблема метода Input в том, что приходится каждый кадр проверять нажата ли нужная кнопка, что не очень хорошо для производительности.
Проблема метода Input в том, что приходится каждый кадр проверять нажата ли нужная кнопка, что не очень хорошо для производительности.

Чтобы анимация проигрывалась зациклено, нужно в ее настройках поставить галочку возле Loop Time. В списке Clips нужно выбрать анимацию атаки, которую мы настраиваем, т.к. анимаций в одном FBX-файле может быть несколько.

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

При определенной настройке смешивания анимаций наш персонаж начинает вполне неплохо махать топором, плавно переходя к ней даже из анимации бега.

Триггер урона и скрипт оружия

Давайте создадим скрипт Weapon, в котором объявим переменную damage, ссылку на DamageTrigger и метод DamageEnable() с входным параметром типа int. Мы можем воспользоваться имеющимся скриптом DamageTrigger и проверять в нём, кто им пользуется - враг или игрок, но лучше все таки во избежание лишних проверок сделать PlayerDamageTrigger. Создадим его тоже и сделаем ссылку на него в скрипе Weapon.

Публичный метод с входным параметром типа int будет вызываться анимационным событием.
Публичный метод с входным параметром типа int будет вызываться анимационным событием.

Но напомню, что анимационное событие сможет увидеть метод, только в скриптах того же объекта, на котором висит аниматор. Поэтому метод DamageEnable() придётся вызывать не напрямую анимационным событием, а подобным методом в скрипте Player, в котором надо сделать ссылку скрипт Weapon.

Анимационное событие будет вызывать этот метод, а он будет передавать значение в скрипт оружия
Анимационное событие будет вызывать этот метод, а он будет передавать значение в скрипт оружия

Внутри объекта топора создадим пустой объект, добавим на него компонент BoxCollider и повесим скрипт PlayerDamageTrigger. BoxCollider надо настроить по размерам топора.

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

Анимационные события для управления триггером

Добавляем нашей анимации атаки, которую мы выбрали для нашего персонажа, анимационные события. В них вписываем название метода DamageEnable и значения параметра int 1 для включения и 0 для выключения. Напомню, что события создаются правой клавишей на шкале в поле Events и выбором Add Animation Event, а кадры нужные кадры подбираются на шкале предпросмотра. Также выбрав FBX-файл убедитесь, что в инспекторе в списке анимаций выбрана нужная нам.

В данном FBX-файле 2 файла анимации и в инспекторе надо не забыть выбрать нужный нам
В данном FBX-файле 2 файла анимации и в инспекторе надо не забыть выбрать нужный нам

Скрипт Weapon вешаем на объект топора, протаскиваем в нём ссылку на PlayerDamageTrigger. Также в скрипте Player протаскиваем ссылку на объект топора, на котором висит скрипт Weapon. Объект PlayerDamageTrigger выключаем.

Регистрация попадания оружия по врагу

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

Коллайдер в данном случае будет простым ХитБоксом, т.е. областью, попадание в которую будет регистрироваться
Коллайдер в данном случае будет простым ХитБоксом, т.е. областью, попадание в которую будет регистрироваться

В скрипте PlayerDamageTrigger нам понадобится ссылка на Weapon. Ссылку в данном случае можно получить из кода. Поскольку триггер является дочерним объектом для топора, то мы сможем обратится из PlayerDamageTrigger к объекту топора, через ссылку transform.parent.gameObject. И если на этом объекте найдётся скрипт Weapon, то и присвоим ссылку на него нашей ссылке.

Метод TryGetComponent возвращает нам true, если смог найти нужный компонент
Метод TryGetComponent возвращает нам true, если смог найти нужный компонент

Можно запустить и проверить - в режиме Play в поле Weapon появляется ссылка на объект топора.

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

Вот только теперь возникает проблема - волк с коллайдером толкает нашего игрока, что и не особо реалистично и нами не запланировано. Поэтому давайте лучше коллайдер волка сделаем триггером поставив галочку isTrigger, а PlayerDamageTrigger коллайдером галочку убрав. Если топор будет толкаться при ударе, то это вполне реалистично.

В скрипте Enemy пропишем метод OnTriggerEnter() и метод TakeDamage(), который пока будет просто сообщать в консоли, что волк получил урон.

-20

Всё сохраняем, проверяем все ссылки и запускаем игру. Пытаемся бить волка, но ничего не происходит. А всё потому-что на коллайдере должен присутствовать компонент Rigidbody, чтобы он мог регистрироваться триггером. В случае с игроком компонент CharacterController его уже содержит, а вот "триггер" нашего топора нет.

Компонент Rigidbody наделяет объект физическими свойствами твёрдого тела
Компонент Rigidbody наделяет объект физическими свойствами твёрдого тела

Нужно обязательно поставить галочку isKinematic, чтобы наш "триггер" не пытался подчиняться гравитации и реагировать на столкновения. Если isKinematic включен, то этот объект будет воспроизводить физику твердого тела, но двигаться только самостоятельно - в нашем случае с помощью анимации.

Проверяем и видим, что в консоли выводится сообщение при попадании топором по волку со значением урона, который мы получили из скрипта Weapon.

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

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

Подборка статей по разработке игры:

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

А вот тут можно почитать про основы C#: