Найти тему
ElandGames

Делаем свою игру на Unity и изучаем язык программирования C# (Часть 9)

Оглавление

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

Кто такой враг и как его создать

Враги в играх - это наверное одна из древнейших игровых механик и поэтому куда уж без них! Тем более, что созданный нами волк, парень от природы суровый и его "хлебом не корми дай, кого-нибудь покусать". Логично, что он должен нашего главного героя кусать, отнимая его здоровье. Надо только решить каким образом он с нашим главным героем будет пересекаться. Есть несколько способов:

  • Аркадный: волк ходит по своим делам своими маршрутами, но если герой по неосторожности становится у него на пути, то оказывается незамедлительно покусан.
  • Хардкорный: волк всегда преследует главного героя и как только его догоняет, то неспешно пожёвывает
  • Более-менее реалистичный: волк шастает по своим делам, но когда замечает игрока, то начинает его преследовать. Если догоняет, то кусает, если же теряет из виду, то со словами "Не больно то и хотелось" идёт дальше по своим волчьим делам.

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

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

Поэтому будем делать третий вариант - сложный, но реалистичный!

Механика атаки для врага с анимацией

Давайте сделаем для начала анимацию атаки и метод для её включения/выключения. Мы уже работали с анимациями, так что давайте вспоминать как это делается:

1. Выбрать нужный объект (Wolf) в окне иерархии и открыть окно Аниматора.

2. Создать анимационный параметр isAttack типа bool.

3. В окне аниматора правой клавишей мыши на пустом месте вызвать всплывающее меню и выбрать Create State, чтобы создать новый блок анимации.

4. В окне проекта найти папку с моделью волка, найти FBX-файл, который содержит анимацию атаки (Wolf_attack1) и стрелочкой возле иконки файла раскрыть его содержимое.

5. Выбрать новый блок анимации, назвать его Attack и в его поле Motion перетащить файл анимации attack1, который находится внутри FBX-файла Wolf_attack1.

6. С помощью клика правой клавишей мыши на блоке анимации выбрать Make Transition и связать блок Wolf_run и Attack переходами.

В нашем случае анимации находятся внутри FBX-файла и мы можем пользоваться ими прямо оттуда
В нашем случае анимации находятся внутри FBX-файла и мы можем пользоваться ими прямо оттуда

Кликнув на сами стрелочки переходов, нужно добавить условия (Conditions), в которых выбрать наш параметр isAttack, в одном случае в значении true, в другом false.

Чтобы анимация начиналась сразу, галочку Has Exit Time лучше убрать, а чтобы проигрывалась до конца, то в обратном переходе галочку лучше оставить
Чтобы анимация начиналась сразу, галочку Has Exit Time лучше убрать, а чтобы проигрывалась до конца, то в обратном переходе галочку лучше оставить

Теперь немного премудростей программирования. :)

Сделаем в скрипте AnimalMove ссылку на Animator и перетащим его туда в инспекторе.

Главное волку сделать ссылку на аниматор волка, а не аниматор игрока :)
Главное волку сделать ссылку на аниматор волка, а не аниматор игрока :)

Создадим скрипт Enemy, cделаем ему наследование от AnimalMove и создадим в нём метод Attack(), который будет включат/выключать анимацию атаки.

Значение локальной переменной isAttack передаётся в качестве значения для анимационного параметра "isAttack"
Значение локальной переменной isAttack передаётся в качестве значения для анимационного параметра "isAttack"

Как видите мы легко обращаемся к аниматору, который объявлен в скрипте AnimalMove, т.к. скрипт Enemy его наследует, а поле аниматора публичное.

Поскольку скриптов уже много, то давайте создадим для них отдельную папку Scripts и перетащим туда все скрипты. А заодно наведём и порядок в окне иерархии, создав пустой объект Mushrooms и переместив в него все грибы. Кроме того, предлагаю перетащить объекты EventSystem, Main Camera и Directional Light в объект Game, чтоб не мозолили глаза - на сцене итак уже много всего.

Вот так будет аккуратнее! Объекты-папки можно свернуть и навигация в окне Иерархии станет комфортнее
Вот так будет аккуратнее! Объекты-папки можно свернуть и навигация в окне Иерархии станет комфортнее

Поскольку часть логики поведения для нашего волка теперь будет еще и в скрипте Enemy, то с объекта волка скрипт AnimalMove нужно убрать заменив его на Enemy. Все унаследованные поля также отобразятся в инспекторе. Только ссылки на компоненты Animator и NavMeshAgent придётся протащить заново.

Скрипт Enemy отображает в инспекторе поля унаследованные от AnimalMove
Скрипт Enemy отображает в инспекторе поля унаследованные от AnimalMove

Точки маршрута пока перетаскивать не будем - пусть волк стоит на месте. Так будет проще его тестировать. :)

Теперь каким-то образом мы должны вызывать метод Attack(), когда игрок находится достаточно близко к волку. Мы могли бы вычислять расстояние с помощью Vector3.Distance(), но эта функция довольно ресурсоёмкая и если волк каждый кадр будет считать расстояние до игрока, то ничего криминального конечно не произойдёт, но в последствии, при большом количестве врагов, это ощутимо скажется на производительности.

Гораздо эффективнее будет создать триггер, который, при попадании игрока в него, даст волку понять, что пора кусать. Создадим внутри объекта волка пустой объект AttackTrigger и добавим ему компонент SphereCollider. Увеличим радиус сферы до 1 и расположим её в районе морды-лица хищника. Ну и конечно не забудем сделать этот коллайдер триггером поставив галочку isTrigger. Поскольку триггер является дочерним объектом для волка, то он будет перемещаться вместе с ним.

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

Создадим специально обученный скрипт AttackTrigger и повесим его на одноимённый объект. В скрипте сделаем ссылку на Enemy и пропишем реакцию на попадание игрока в триггер с помощью метода OnTriggerEnter(), а также метод выхода из триггера OnTriggerExit(), чтобы дать волку понять, что кусать уже некого.

С помощью проверки TryGetComponent(), мы делаем так, чтобы волк атаковал только игрока
С помощью проверки TryGetComponent(), мы делаем так, чтобы волк атаковал только игрока

Не забыв проставить в инспекторе в скрипте AttackTrigger ссылку на волка, запускаем игру и если нигде ничего не забыли, то при подходе к волку он начинает неистово кусать персонажа за ляжку, т.к. тот нагло вторгся в его триггер!

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

Теперь надо сделать так, чтобы в момент анимации укуса у главного героя каким-то образом отнималось здоровье. Можно сделать по тому же принципу. Создадим абсолютно такую же конструкцию и назовём DamageTrigger. На нем будет скрипт DamageTrigger и SphereCollider радиусом поменьше в районе пасти. При пересечении игрока с этим триггером, у игрока будет вычитаться здоровье равное величине урона, наносимое врагом. Для урона сделаем переменную damage в скрипте Enemy.

Зададим величину урона, наносимого врагом, в скрипте Enemy
Зададим величину урона, наносимого врагом, в скрипте Enemy
Воспользуемся методом изменения здоровья игрока
Воспользуемся методом изменения здоровья игрока

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

Анимационные события

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

Создадим в скрипте Enemy ссылку на DamageTrigger и метод DamageEnable(), который будет получать числовое значение типа int, потому как анимационные события почему-то не в курсе про тип bool. :) Метод будет включать и выключать объект DamageTrigger в нужный момент в зависимости от анимации.

Чтобы анимационное событие увидело метод, он должен быть публичным
Чтобы анимационное событие увидело метод, он должен быть публичным

Вот сейчас будет сложновато для понимания. :)

Находим FBX-файл анимации атаки, из которого мы её брали. В его настройках в инспекторе во вкладке Animation находим поле Events и разворачиваем. Снизу в окне предпросмотра анимации есть белая полоска и её мы можем перетаскивать, перебирая кадры и находя нужный. Находим кадр визуального начала укуса. На временной шкале поля Events это место тоже отобразится и правой клавишей мышки тыкнув туда, выберем Add Animation Event, чтобы его создать.

  • В его поле Function вписываем название нашего метода DamageEnable.
  • В поле int вписываем 1.
Если анимация будет не внутри файла FBX, то настройка анимационных событий будет происходить в окне Animation
Если анимация будет не внутри файла FBX, то настройка анимационных событий будет происходить в окне Animation

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

Как это всё работает? Во время проигрывания анимации, на кадрах, где мы разместили события, оно будет срабатывать и каким-то неведомым мне образом, находить на объекте, на котором расположен именно этот аниматор, внутри скриптов, которые висят на этом же объекте метод под названием DamageEnable() и, в зависимости от выбранного типа входного значения метода, передаст туда значение одного из своих полей - в нашем случае поля int. Больше вопросов, чем ответов, но оно работает. :)

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

Объект DamageTrigger теперь включается только в момент укуса во избежание ложных срабатываний
Объект DamageTrigger теперь включается только в момент укуса во избежание ложных срабатываний

Всё! Теперь наша серая собака опасна и свирепа - лучше к ней не подходить и на пути не вставать! Но вот научить её саму к нам подходить и осознанно начинать преследовать было бы неплохо.

Обнаружение игрока и преследование

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

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

Создадим внутри волка пустой объект DetectTrigger, повесим на него SphereCollider с радиусом например 7 и повесим новый скрипт DetectTrigger.

Очертания триггера явно показывают зону видимости волка
Очертания триггера явно показывают зону видимости волка

В скрипте DetectTrigger нам нужно получить ссылку на компонент движения AnimalMove, так как механика обнаружения игрока может быть свойственна и врагам, и безобидным существам. А в нём добавим булевую переменную isPlayerDetected, которая будет своеобразным переключателем между режимами блуждания и преследования.

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

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

Скрипт DetectTrigger будет просто регистрировать момент входа и момент выхода игрока из триггера и запускать одну из веток этого метода.

В качестве аргумента передаём либо ссылку на игрока, либо "нулевое" значение null.
В качестве аргумента передаём либо ссылку на игрока, либо "нулевое" значение null.

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

На всякий случай сделаем проверку и перед запуском следующего цикла движения.
На всякий случай сделаем проверку и перед запуском следующего цикла движения.

Следование за целью сделаем в виде отдельного метода в скрипте Enemy, а в скрипте AnimalMove сделаем виртуальный метод PlayerDetectReaction(), который в зависимости от типа существа, сможем переопределять при наследовании (например, волки бегут за нами, а зайцы от нас).

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

Добавляем вызов этого метода в PlayerDetect(), передавая в него ссылку на игрока, а также прописываем продолжение свободного движения в случае если игрок потерян.

Проверка на количество вейпоинтов уже встречается в 2 местах и можно её перенести внутрь самого метода Move(), чтобы не дублировать код
Проверка на количество вейпоинтов уже встречается в 2 местах и можно её перенести внутрь самого метода Move(), чтобы не дублировать код

В скрипте Enemy переопределим метод PlayerDetectReaction(), не забыв для этого дописать override, чтобы он вызывал новый асинхронный метод FollowPlayer(), который пропишем ниже.

Этот метод будет направлять волка к игроку, до тех пор пока волк его не потеряет. А еще он будет уменьшать скорость движения волка в компоненте NavMeshAgent до нуля при достижении подходящей дистанции для атаки. Её подбираем опытным путём, так чтобы волк не забегал внутрь игрока и при этом останавливался достаточно близко, чтобы наносить урон.

Во избежание ошибок проверка на существование самого скрипта в асинхронных методах не помешает - this != null
Во избежание ошибок проверка на существование самого скрипта в асинхронных методах не помешает - this != null

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

В целом, дело сделано - волк бегает за персонажем, кусает его, а если теряет, то идёт гулять дальше. Вот только единственный способ от него убежать это зайти ему за спину, т.к. скорость игрока меньше скорости волка. Нужно добавить игроку бег. Кроме того, поведение волка и его параметры можно настроить получше. Но эта статья и без того сложная и объёмная, так что не будем её перегружать. К тому же, писать статьи более 4 часов уже морально сложновато. :)

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

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

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

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

А вот мой дипломный проект на котором я сам учился:

А вот тут я со своей группой играю весёлую музыку, как уж не поделиться :)