С вами
Дмитрий Горин, ментор по 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()Не без комментариев к коду:
Если делать для каждого оружия возможность узнать дальность его атаки, потребуется прописать это в базовом классе и переопределять. Уверен, что вы уже достаточные умницы, чтобы сделать это самостоятельно.
Далее пойдет…
Для того, чтобы стреляющие противники, как существа более умные, реагировали на изменения вокруг, например обнаружение одним из стреляющих противников игрока, требуется добавить противникам сообщения раздавать и принимать. И небольшой спойлер: новое состояние – 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:
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. Реагирование противников на шум.
Как видите, всё получилось быстро и просто. Теперь переходим к тому, что откладывали на последок…
До этого мы в режиме прототипирования сделали оружие и все его анимации для игрока, и не привязывались к его рукам. Так их их нет и не было. Это стало проблемой, ведь то, как мы видим оружие в камере, отличается от того, как видим его на других существах.
Вид может быть от первого лица и от третьего. В нашем случае камера от первого лица будет доступна только для игрока, но бывает иначе. От третьего лица мы будем видеть оружие у разных противников, и могут быть как заметные различия в анимациях привычных действий, как стрельба, перезарядка и прочее. Требуется сделать железное (хотя, скорее, деревянное) решение гибким и подходящим для множества таких сценариев.
Чтобы такое сделать, план такой:
Перед тем как приступить делать, кратко разъясню про 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 во время выполнения.
Всё работает, и остается всего ничего: распространить эту практику на остальные виды оружия и противников, и установить в нужное положение оружие для вида от первого лица для игрока. Уверен, вы сможете это сделать без моего подробного расписания, у вас точно всё получится!