translatedTitles.Article
DG
Dmitriy Gorin
1 day ago

Godot. Урок 6. Новый противник и особенности с ним связанные

С вами

Дмитрий Горин, ментор по Godot

  • Разработчик графических подсистем в АТОМ и инди-разработчик игр
  • Участвую экспертом в мероприятиях и хакатонах, посвященных разработке игр
  • Веду образовательную деятельность при университете РГРТУ им. В.Ф. Уткина (группа ВК)


Вопросы по движку и программе можно задавать в соответствующем топике Godot чата Практик•ON



Предисловие

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


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


Новая комната

Сразу же создадим пространство для тестирования нового типа противников. Я сделал новую комнату, идущую сразу же за предыдущей, и выделил в ней проход в комнату сбоку (рис. 1).


Рис. 1. Новая созданная комната.


Это сделано для удобства новых возможностей навигации и коммуникации для нового типа противников. Комментарии по созданию такой комнаты излишни, поэтому как создадите площадку, переходим непосредственно к …


Новый противник

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

  • Стреляющим;
  • Общающимся с соратниками;
  • Не сталкивающимся с другими такими же;
  • Реагирующим на звуки.

Сначала построим базу для нового противника, и она будет состоять из предыдущего типа противника: бродящего. Просто дублируем всю папку и переименовываем файлы (рис. 2) и корневой узел в самой сцене (рис. 3) для нового типа противника: стреляющего. И даем ему пистолет. Визуально рекомендую сменить цвет меша этого противника, чтобы его было легко отличить от бродящего.


Рис. 2. Продублированные сцена и файлы.


Рис. 3. Сцена стреляющего противника.


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

Рис. 4. Пример игнорирования проблемы.


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

А теперь давайте постепенно пройдемся по списку выше и сделаем новые возможности для противника!


Стрельба

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

Pythonfunc _ready() -> void:
 guns = [
  $Body/Head/WeaponPosition/PistolGun,
 ]
 ammo = {
  Ammo.Type.Pistol: PistolAmmo.new(50, 50),
 }
 …


func fire() -> void:
 if guns[current_gun].is_empty():
  reload_gun()
 else:
  super()

Комментарии к коду:

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


В контроллере для этого типа противников я внес следующие изменения:

Pythonfunc _physics_process(_delta: float) -> void:
 match state:
  …
  State.ATTACK:
   # Если цель пропала из вида:
   if not is_instance_valid(target):
    character.move_direction = Vector2.ZERO
    state = State.IDLE
    idle_timer.start()
    is_idle_finished = false
    return
   if target_is_in_fire_distance() and target_is_in_direct_view():
    character.look_to(character.global_position.direction_to(target.global_position))
    character.move_direction = Vector2(0.0, 0.0)
   else:
    state = State.CHASE
   animation_player.play("attack")
…


func target_is_in_fire_distance() -> bool:
 var dist: float = (character.get_global_position() - target.get_global_position()).length()
 return dist < character.guns[character.current_gun].get_fire_range()

Не без комментариев к коду:

  • Для того, чтобы противник вел взгляд во время атаки, я в соответствующем состоянии обновляю взгляд противника на цель;
  • Так как у разного оружия разная дальность атаки, можно вместо используемого ранее “ATTACK_DISTANCE” использовать значение дальности атаки самого оружия. Если что, в ручном задании дистанции для атаки нет ничего плохого.

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

Далее пойдет…


(Со)общение

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


Новое состояние позволит обрабатывать сценарий, когда противник не видит игрока, но знает, что “там” что-то есть. “Там”, это позиция на карте, куда будет стремиться противник, получивший сообщение о чем-то для него интересным.


Для посылания сигнала о том, что что-то интересное происходит, нужна новая область. Создадим её (рис. 5) и будем сообщать всем в её зоне, что игрок обнаружен. Обратите внимание, сигнала никакого я не присоединял.


Рис. 5. Область оповещения противников.


Внесем новое состояние SEARCH и опишем его поведение:

Pythonenum State {
 IDLE,
 PATROL,
 SEARCH,
 CHASE,
 ATTACK,
 DEAD
}
…


func _physics_process(_delta: float) -> void:
 match state:
	State.SEARCH:
   if is_instance_valid(target):
    if target_is_in_direct_view():
     if target_is_in_fire_distance():
      state = State.ATTACK
     else:
      state = State.CHASE
     # Оповещение других, что цель цель найдена.
     emit_attention()
     return
   
   if navigation_agent.is_navigation_finished():
    character.move_direction = Vector2.ZERO
    state = State.IDLE
    idle_timer.start()
    is_idle_finished = false
   else:
    # Просто двигать.
    set_movement_target()
    animation_player.play("move")


Область есть, состояние поиска есть – теперь воспользуемся ею. Напишем 2 метода – один оповещение, другой – с реакцией:

Pythonfunc emit_attention() -> void:
 for listener in emit_area.get_overlapping_bodies():
  # Себя не оповещаем.
  if listener == character:
   continue
  if listener.has_method("_on_alert_received"):
   print(listener, " hear it!")
   listener._on_alert_received(target.global_position if is_instance_valid(target) else character.global_position)
…


func _on_alert_received(pos: Vector3) -> void:
 if state > State.SEARCH:
  return
 
 set_movement_target(pos)
 state = State.SEARCH


Один противник, обнаруживший игрока, сообщит всем ближайшим в моменте, что есть цель, и все должны пойти её тоже искать, даже если её не видят. Я использую вызов этого оповещения в моменте перехода из IDLE, PATROL, SEARCH в CHASE, ATTACK:

Pythonfunc _physics_process(_delta: float) -> void:
	match state:
  State.IDLE:
   if is_instance_valid(target):
    if target_is_in_direct_view():
     state = State.CHASE
     emit_attention()
     return
   if is_idle_finished:
    state = State.PATROL
    # Двигать в новую точку.
    set_movement_target(get_new_patrol_position())
   
   animation_player.play("idle")
  
  State.PATROL:
   if is_instance_valid(target):
    if target_is_in_direct_view():
     state = State.CHASE
     emit_attention()
     return
   if navigation_agent.is_navigation_finished():
    character.move_direction = Vector2.ZERO
    state = State.IDLE
    idle_timer.start()
    is_idle_finished = false
   else:
    # Просто двигать.
    set_movement_target()
   
   animation_player.play("move")
  
  State.SEARCH:
   if is_instance_valid(target):
    if target_is_in_direct_view():
     if target_is_in_fire_distance():
      state = State.ATTACK
     else:
      state = State.CHASE
     # Оповещение других, что цель цель найдена.
     emit_attention()
     return
   
   if navigation_agent.is_navigation_finished():
    character.move_direction = Vector2.ZERO
    state = State.IDLE
    idle_timer.start()
    is_idle_finished = false
   else:
    # Просто двигать.
    set_movement_target()
    animation_player.play("move")
  ...


Вызовы выделил. И хотелось бы сказать, что на этом всё, но не всё…


Так как область работает с телами (тот же Character), метод в контроллере не будет обнаружен. Придется сделать небольшую прокладку в коде “shooting_enemy.gd”:

Python@onready var controller: Node = $EnemyController
…


func _on_alert_received(pos: Vector3) -> void:
 controller._on_alert_received(pos)

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


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


Аним. 1. Конфликт в конечной точке.


Обхождение других противников

Тут не придется много добавлять, это возможности используемого NavigationAgent3D:

  1. Включите у агента свойство avoidance_enabled.
  2. Присоедините к контроллеру стреляющего противника сигнал velocity_computed().
  3. В обработчике для сигнала выше напишите следующее:
Pythonfunc _on_velocity_computed(safe_velocity: Vector3) -> void:
 if state == State.IDLE or state == State.ATTACK:
  return
 character.look_to(safe_velocity)
 character.move_direction = Vector2(0.0, -1.0)

4. Перепишите формирование пути следующим образом:

Pythonfunc set_movement_target(movement_target: Vector3 = target_position) -> void:
 if not movement_target == target_position:
  navigation_agent.set_target_position(movement_target)
  target_position = movement_target
 
 var velocity: Vector3 = character.global_position.direction_to(navigation_agent.get_next_path_position())
 
 if navigation_agent.avoidance_enabled:
  navigation_agent.set_velocity(velocity)
 else:
  _on_velocity_computed(velocity)


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


Аним. 2. Обхождение противников другими.

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


Что было сделано с нашей стороны? Когда включается функция обхождения, по сигналу velocity_computed() можно получить “безопасный” путь, с учетом не учтенных в навигационном меше, препятствий. Используем этот безопасный путь.


Слух и шум

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


Добавим на сцену пистолета область Area3D, которая будет шумом от оружия (рис. 6).


Рис. 7. Область шума.


Теперь просто пишем в коде пистолета:

Pythonfunc shoot() -> void:
 …
 emit_sound()
…


func emit_sound() -> void:
 for listener in sound_area.get_overlapping_bodies():
  if listener.has_method("_on_alert_received"):
   print(listener, " hear it!")
   listener._on_alert_received(sound_area.global_position)

Пистолет при выстреле оповестит всех, кто будет в области этого шума, и передаст своё положение для исследования этого места. Так как класс оружия базируется на Node, а не Node3D, положение было взято из sound_area.


Аним. 3. Реагирование противников на шум.


Как видите, всё получилось быстро и просто. Теперь переходим к тому, что откладывали на последок…


Исправление положения и анимаций оружия

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


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


Чтобы такое сделать, план такой:

  1. Сделать разделение на вид от первого и третьего лица;
  2. Зафиксировать оружие на руке;
  3. Сбросить положения и сделать анимации оружия чисто относительно самого себя;
  4. Развязать визуальное представление меша и его Raycast, участвующий в стрельбе.


Перед тем как приступить делать, кратко разъясню про 4-й пункт: у нас Raycast и так отвязан от меша. Пистолет и так не может быть направлен куда угодно, но стрельба регистрируется по Raycast, который всегда смотрит прямо. Проблема возникает на моменте, что мы сбрасываем положение меша в ноль, и в руке пистолет может вращаться всей сценой. Это неминуемо приведет к выстрелу не туда, куда смотрит игрок или противник.


Во-первых…

Чтобы разделить набор анимаций для вида от 1-го и 3-го лица, можно было бы сделать библиотеки анимаций, но мною было решено упростить дело следующий образом: сделать отдельный AnimationPlayer (рис. 8).


Рис. 8. Измененная структура сцены.


Был добавлен не только новый AnimationPlayer, но и AudioStreamPlayer3D. Он будет использоваться в анимациях, где задействуется звук от третьего лица для работы пространственного звука.


В коде оружия тоже будут изменения, если вы вдруг не заметили в инспекторе (“weapon.gd”):

Pythonenum DisplayMode {
 FIRST_PERSON,
 THIRD_PERSON
}
# Состояние отображения и анимаций оружия.
@export var display_mode: DisplayMode = DisplayMode.THIRD_PERSON
…


func set_display_mode(mode: DisplayMode) -> void:
 return


Для указания направления взгляда, чтобы выстрел был точно в цель, независимо от положения оружия, потребуется поправить “range_weapon.gd”:

Python@onready var animation_player: AnimationPlayer
@onready var shoot_cast: RayCast3D


func _process(delta: float) -> void:
 update_aim()
 …
…


func set_aim_source(aim_node: Node3D) -> void:
 aim_source = aim_node


func update_aim() -> void:
 if is_instance_valid(aim_source):
  shoot_cast.global_transform = aim_source.global_transform


Эти флаги и методы будут использоваться в оружии (“pistol_gun.gd”):

Pythonfunc _ready() -> void:
 set_display_mode(display_mode)
 shoot_cast = $RayCast3D


func set_display_mode(mode: DisplayMode) -> void:
 if mode == DisplayMode.FIRST_PERSON:
  animation_player = $AnimationPlayerFP
 elif mode == DisplayMode.THIRD_PERSON:
  animation_player = $AnimationPlayerTP
 
 display_mode = mode


Обращу внимание, что animation_player и shoot_cast были из “pistol_gun.gd” перемещены в родительский класс.


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


Во вторых…

Следует переместить оружие на руку на уровне сцены узлов (рис. 9).


Рис. 9. Перемещение точки держания оружия.


В-третьих…

Сбрасываем положение оружия и переделываем все анимации. Да, это мучительно долго.


Рис. 10. Обнуление положений и создание анимации вокруг этого положения.


В-четвертых…

...уже сделано в первом пункте! Благодаря shoot_cast.global_transform = aim_source.global_transform, пистолет может быть направлен в любую сторону, а направление выстрела будет сонаправлено взгляду (рис. 11, 12).

Рис. 11. Позиционирование оружия и его Raycast.


Рис. 12. Положение оружия и Raycast во время выполнения.


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

hub.comments