translatedTitles.Article
DG
Dmitriy Gorin
12/12/25

Godot. Урок 5. Карта, противник и его навигация

С вами

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

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


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



Предисловие

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


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


Прототипирование уровней

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


CSG означает Constructive Solid Geometry (Конструктивная Твердая Геометрия), и является инструментом для объединения, а также вычитания и пересечения примитивов или пользовательских мешей для создания более сложных форм. Вы слышали про “булевы операции” в 3D-моделировании?


Рис. 1. Пример булевых операций в 3D-моделировании.


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


Нет смысла что-то долго рассказывать, тут нужно показывать. Для создания некоторой единицы уровня (будь то платформа, лестница, или целая комната сложной формы), во главе стоит использовать CSGCombiner3D – это просто контейнер для организации CSG объектов.


Начнем с простого: переделаю платформу, на которой находится наш персонаж в тестовой сцене на CSG. Для этого просто добавляем на сцену узел CSGCombiner3D и дочерним к нему (в моем случае) 4 CSGBox3D. У CSGBox3D выставляете размеры, как было до этого у вас, а у CSGCombiner3D не забудьте выставить флажок use_collision и operation оставить Union. Выглядеть будет как на рисунке 3.


Рис. 2. До внесения CSG форм.


Рис. 3. Найдите 10 различий.


Это была самая простая форма. А их:

  • CSGBox3D
  • CSGCylinder3D (также поддерживает конус)
  • CSGSphere3D
  • CSGTorus3D
  • CSGPolygon3D
  • CSGMesh3D


Не будем разбирать каждую из них, оставим это для самостоятельно изучения в документации Godot. Лучше разберем ещё несколько примеров элементов карты, которые вам потребуются: комнаты и лестницы.


Для создания комнаты, мы берем большой параллелепипед из CSGBox3D (рис. 4), внедряем в него ещё один CSGBox3D поменьше, чтобы разница была на толщину стен и выбираем у него operation Subtraction (рис. 5). Фигура от этого должна стать полой внутри. Для создания прохода, на том же уровне добавим ещё CSGBox3D, размером ещё меньше, с проход, и в том же режиме Subtraction (рис. 6).


Рис. 4. Добавление комнаты.


Рис. 5. Выделение полости.


Рис. 6. Создание прохода.


Не забудьте поставить use_collision и по ней можно побегать… если допрыгнете. Нужно бы некоторый проход. Может, лестница?


Для создания лестницы рассмотрим вариант через узел CSGPolygon3D. Добавляем его к новому CSGCombiner3D и изменяя точки в polygon либо напрямую в инспекторе, либо двигая точки во вьюпорте. По умолчанию это 4 точки, описывающие квадрат размером 1 и глубиной выдавливания в 1 (рис. 7).


Рис. 7. Полигон по умолчанию.


Для удобства редактирования во вьюпорте, можно перевести камеру вьюпорта в ортогональный режим (без перспективы) с помощью либо настройки в левой верхней части окна вьюпорта, либо нажав на определенную ось верхней правой части окна вьюпорта (рис. 8). Для моей приподнятой комнаты лестница выглядит как на рисунке 9.


Рис. 8. Ортогональная камера вьюпорта, и где её можно включить.


Рис. 9. Получившаяся лестница.


Таким образом я сделал тестовые комнаты для проверки платформинга и комнату для тестирования будущих противников (рис. 10). Смастерите и себе подобные комнаты.


Рис. 10. Тестовые комнаты.


Теперь, когда есть площадка для тестов противников, можно заняться их разработкой, но сначала…


Доработки класса Character

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


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

Pythonsignal dead()
# Максимальное значение супер-здоровья.
@export var max_over_health: float = 200.0
# Максимальное значение здоровья.
@export var max_health: float = 100.0
# Базовый параметр здоровья.
@export var base_health: float = 100.0 : set = set_base_health, get = get_base_health# Set-метод для установки текущего здоровья.
func set_base_health(value: float) -> void:
 base_health = value
 if value <= 0.0:
  emit_signal("dead")


# Get-метод для получения текущего здоровья.
func get_base_health() -> float:
 return base_health
…
# Изменяет значение текущего здоровья в пределах до максимально допустимого.
func change_health(delta: float, is_over_heal: bool = false) -> void:
 base_health = min(base_health + delta, max_health if not is_over_heal else max_over_health)

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

  • Я разделил метод set для здоровья и метод изменения здоровья, так как это дает больше гибкости в задании поведения.


Во-вторых, требуется выделить то, что нужно нашему игроку, и не нужно противнику. Это все обращения к HUD, настройки в _ready() и _process(delta).


В-третьих, всё что связано с установкой первоначального боезапаса, установки узлов head, interact_cast, weapon_node, guns, всё это будет перенесено из переменных в _ready() персонажа.


Вместо дельты проще показать итоговые скрипты. “character.gd”:

Pythonclass_name Character
extends CharacterBody3D
signal dead()
# Максимальное значение супер-здоровья.
@export var max_over_health: float = 200.0
# Максимальное значение здоровья.
@export var max_health: float = 100.0
# Базовая скорость персонажа.
@export var base_speed: float = 2.0
# Базовая высота прыжка.
@export var base_jump: float = 2.0
# Базовый параметр здоровья.
@export var base_health: float = 100.0 : set = set_base_health, get = get_base_health
# Базовая настройка сглаживания движения.
@export var smoothing_movement: float = 0.1
# Направление движение относительно взгляда [var gaze_direction].
var move_direction: Vector2 = Vector2.ZERO : set = move_body, get = get_move_direction
# Направление взгляда.
var gaze_direction: Vector2 = Vector2.ZERO : set = move_gaze, get = get_gaze_direction
# Сила гравитации.
var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
# Состояние запроса на прыжок.
var jump_state: bool = false : set = try_jump, get = is_trying_jump
# Выбранный слот оружия.
var current_gun: int = 0
# Запасы боеприпасов.
var ammo: Dictionary[Ammo.Type, Ammo] = {} : set = set_ammo_pack
# Ссылка на голову персонажа для добавления рук и оружия, и пр.
var head: Node3D
# Raycast для взаимодействия персонажа с окружением.
var interact_cast: RayCast3D
# Конкретное положение для оружия.
var weapon_node: Node3D
# Пушки. Внимание, их расположение по слотам!
var guns: Array[Weapon] = []


# Set-метод для установки текущего здоровья.
func set_base_health(value: float) -> void:
 base_health = value
 if value <= 0.0:
  emit_signal("dead")


# Get-метод для получения текущего здоровья.
func get_base_health() -> float:
 return base_health


# Set-метод для установки боеприпасов.
func set_ammo_pack(ammo_pack: Dictionary[Ammo.Type, Ammo]) -> void:
 ammo = ammo_pack



# Указывает вектор перемещения существа в x/z плоскости. [param vector] должен быть нормализованный!
func move_body(vector: Vector2) -> void:
 move_direction = vector


# Get-метод для получения текущего движения в x/z плоскости.
func get_move_direction() -> Vector2:
 return move_direction


# Указывает вектор перемещения взгляда.
func move_gaze(vector: Vector2) -> void:
 gaze_direction = vector


# Get-метод для получения текущего движения взгляда.
func get_gaze_direction() -> Vector2:
 return gaze_direction


# Заставляет персонажа прыгать.
func try_jump(state: bool = true) -> void:
 jump_state = state


# Get-метод для состояния запроса на прыжок.
func is_trying_jump() -> bool:
 return jump_state
#endregion


# Callback для контроллера игрока по действию "Action".
func action() -> void:
 fire()


# Callback для контроллера игрока по прекращению действия "Action".
func sub_action() -> void:
 stop_fire()


# Обеспечивает стрельбу из оружия.
func fire() -> void:
 guns[current_gun].pull_trigger()


# Прекращает стрельбу из оружия.
func stop_fire() -> void:
 guns[current_gun].release_trigger()


# Описание взаимодействия на каждый из типов встречаемых предметов.
func interact() -> void:
 pass


# Переключает оружие на располагаемое в slot.
func select_gun(slot: int) -> void:
 current_gun = slot
 for gun_slot in guns.size():
  guns[gun_slot].visible = (current_gun == gun_slot)


# Перезаряжает текущее оружие.
func reload_gun() -> void:
 if not guns[current_gun].is_full() and ammo[guns[current_gun].ammo_type].count > 0:
  guns[current_gun].reload()


# Изменяет значение текущего здоровья в пределах до максимально допустимого.
func change_health(delta: float, is_over_heal: bool = false) -> void:
 base_health = min(base_health + delta, max_health if not is_over_heal else max_over_health)


# Callback для заряжаемого оружия.
func _on_weapon_reloaded(type: Ammo.Type, requested_count: int) -> void:
 guns[current_gun].upload_ammo(ammo[type], requested_count)


Новый класс Player в скрипте “player.gd”, который требуется после создания повесить на игрока:

Pythonclass_name Player
extends Character


func _ready() -> void:
 Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
 interact_cast.collide_with_areas = true
 ammo = {
  Ammo.Type.Pistol: PistolAmmo.new(),
  Ammo.Type.Shotgun: ShotgunAmmo.new(),
  Ammo.Type.Rifle: RifleAmmo.new(),
 }
 guns = [
  $Head/WeaponPosition/PistolGun,
  $Head/WeaponPosition/Shotgun,
  $Head/WeaponPosition/AutoRifle,
 ]
 
 head = $Head
 interact_cast = $Head/InteractCast
 weapon_node = $Head/WeaponPosition
 
 select_gun(0)
 for type in ammo:
  hud.set_ammo(ammo[type])


func _process(delta: float) -> void:
 var smooth_delta: float = base_speed * delta / smoothing_movement
 var velocity_dir:= Vector3(velocity.x, 0, velocity.z)
 if not move_direction == Vector2.ZERO:
  var movement_dir: Vector3 = transform.basis * Vector3(move_direction.x, 0, move_direction.y)
  var smooth_dir: Vector3 = velocity_dir.move_toward(movement_dir * base_speed, smooth_delta)
  velocity.x = smooth_dir.x
  velocity.z = smooth_dir.z
  
  guns[current_gun].move()
 else:
  var smooth_dir: Vector3 = velocity_dir.move_toward(Vector3.ZERO, smooth_delta)
  velocity.x = smooth_dir.x
  velocity.z = smooth_dir.z
  
  guns[current_gun].stop_move()
 
 if not gaze_direction == Vector2.ZERO:
  rotate_y(gaze_direction.x)
  head.rotate_x(gaze_direction.y)
  head.rotation_degrees.x = clampf(head.rotation_degrees.x, -90.0, 90.0)
  gaze_direction = Vector2.ZERO
 
 if not is_on_floor():
  velocity.y += -gravity * delta
 elif jump_state:
  velocity.y = base_jump
 
 move_and_slide()


func _physics_process(_delta: float) -> void:
 _interact_hint()


# Set-метод для установки текущего здоровья.
func set_base_health(value: float) -> void:
 super(value)
 hud.set_health(value)


func set_ammo_pack(ammo_pack: Dictionary[Ammo.Type, Ammo]) -> void:
 super(ammo_pack)
 _update_ammo_display()


func interact() -> void:
 var object = interact_cast.get_collider()
 if object is Item:
  if not object.interactable:
   return
  if object is Ammo:
   var delta: int = ammo[object.type].max_count - ammo[object.type].count
   var received: int = object.get_ammo(delta)
   ammo[object.type].count += received
   _update_ammo_display()


func select_gun(slot: int) -> void:
 super(slot)
 hud.select_gun(slot)


func _on_weapon_reloaded(type: Ammo.Type, requested_count: int) -> void:
 super(type, requested_count)
 _update_ammo_display()


# Для обновления информации о рассматриваемом предмете на HUD интерфейсе.
func _interact_hint() -> void:
 var collider = interact_cast.get_collider()
 #if collider is Item:
  #print("Передо мной лежит ", collider)


# Для обновления информации о патронах на HUD интерфейсе.
func _update_ammo_display() -> void:
 for type in ammo:
  hud.set_ammo(ammo[type])

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

  • Здесь можно увидеть новую команду – super(). Это обращение к одноименному методу в базовом классе, в нашем случае Character. Таким образом сначала выполнится этот же метод в базовом классе Character, а потом выполнение вернется в этот метод в классе Player.


Теперь, когда логика класса Character почищена от функций и команд для сцены игрока, время переходить к сборке сцены противника!


Сборка противника

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


При сборке сцены мы создадим корневой узел, базирующийся на классе Character. Структура сцены будет отдаленно напоминать нашего игрока, но для противника добавлены меш лица и рук, чтобы было наглядно видно, что он делает и AnimationPlayer узел для анимаций этого дела (рис. 11).


Рис. 11. Сцена бродящего противника.


Отсутствием оружия придется временно пренебречь для упрощения. К нему вернемся в конце.


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


Рис. 12. Структура контроллера для противника.


Почему так? Поясняю:

  • IdleTimer – это Timer, который будет отсчитывать время между тем, как противник бродит по своей локации;
  • NavigationAgent3D – это одноименный узел, который позволит качественно ориентироваться противнику в пространстве, выбирая маршруты в мире игры;
  • ModeLabel3D – это Label3D, который будет отображать текущее состояние противника над его головой. Чтобы текст был всегда повернут к нам, к камере игрока, необходимо включить режим billboard на Enabled;
  • RayCast3D – потребуется для проверки прямой видимости противником цели для преследования;
  • HearingArea – это Area3D, задача которой не в обнаружении по звуку, как следует из названия, а для того, чтобы противник начал “следить” за игроком, входящим в эту область. Ведь тогда противник может обнаружить нас, если мы будет стоять в направлении его взгляда и между взглядом противника и нами не будет препятствий. Зачем эти проверки, когда игрок слишком далеко?


Отвлекитесь от кода и узлов, и творите анимации (рис. 13). Нам потребуются:

  • Атака;
  • Преследование;
  • Смерть;
  • Ожидание;
  • Движение.


Рис. 13. Анимации для персонажа.


Когда разомнетесь в творческой работе, возвращаемся к программированию. Пока к коду самого управляемого WalkingEnemy. Ему нужно определить нужные узлы в _ready() и движение в _process(delta). Отличаться от игрока Player последний будет отсутствием обращения к оружию, т.к. его нет. Ещё, забегая вперед, потребуется функция для мгновенного поворота в нужную точку противника. Назову её look_to(target). Заниматься плавным поворотом взгляда и всего тела может быть накладно и создавать движения, которые не ожидались.


Нажмите “Расширить скрипт” у WalkingEnemy и добавьте следующее:

Pythonfunc _ready() -> void:
 head = $Body/Head
 interact_cast = $Body/Head/Gaze
 interact_cast.collide_with_areas = true
…
func look_to(target: Vector3) -> void:
 var move_dir: = Vector3(target.x, 0.0, target.z)
 transform.basis = Basis.looking_at(move_dir)

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

  • Напоминаю, что _process() от Player отличается лишь отсутствием команд обращения к оружию;
  • В look_to(target) передаваться будет не конечная цель, а цель – точка на линии пути от противника до цели, и так как для правильного перемещения прямо требуется указывать направление поворота тела по оси Y, то обнуляем эту составляющую приходящего вектора. Если очень нужно, голову можно отдельно покрутить.


А теперь самое вкусненькое – кто этим будет управлять? Конечно же нами написанный контроллер! Но перед тем как начать, стоит кратко затронуть тему…


Конечные автоматы

Конечные автоматы (Finite State Machines, FSM) в играх — это мощный инструмент для моделирования поведения интерфейсов, меню, персонажей (ИИ) и других игровых систем, представляющий собой конечное число состояний и правил переходов между ними. Один из примеров такого графа можно видеть на рисунке 14.


Рис. 14. Пример графа конечного автомата ИИ противника, с условиями переходов.


Звучит круто и абстрактно, и, наверное, сложно, но это чисто ещё один из вариантов оформления кода. Иногда он сделан красивыми графами с переходами (рис. 15), иногда это выглядит как карательный код на куче if/else из Yandere Simulator (рис. 16).


Рис. 15. Конечные автоматы для анимаций в Godot (да, мы их не использовали).


Рис. 16. Пример карательного кода.


… С виду у нас будет что-то посередине.


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


Контроллер бродящего противника

Ранее уже был спойлер (в анимациях) для планируемых состояний у данного типа противника:

  • Ожидает;
  • Бродит;
  • Преследует;
  • Атакует;
  • Мертв.

Чтобы нам ограничить будущую переменную для хранения текущего состояния, опишем в коде перечисление:

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


Неплохо бы определиться с временем ожидания между тем, как противник бродит из точки в точку. Ещё нам потребуется расстояние, которое будет считаться достаточным для атаки. Опишем это парой констант:

Python# Время ожидания для состояния IDLE.
const WAIT_TIME: float = 5.0
# Дистанция до цели, в которой происходит атака.
const ATTACK_DISTANCE: float = 2.0

Ссылка на управляемое существо по аналогии с контроллером игрока и совокупность нужных переменных:

Python# Ссылка на управляемое существо
@export var character: Character
# Состояние конечного автомата поведения.
var state: State = State.IDLE : set = set_state
# Потенциальная цель для атаки.
var target: Character
# Целевая точка для перемещения.
var target_position: Vector3
# Набор возможных точек для перемещения.
var patrol_point_pool: PackedVector3Array
# Флаг на то, что существо прекратило ожидать.
var is_idle_finished: bool
@onready var navigation_agent: NavigationAgent3D = $NavigationAgent3D
@onready var animation_player: AnimationPlayer = $"../AnimationPlayer"
@onready var gaze: RayCast3D = $"../Body/Head/Gaze"
@onready var direct_view_checker: RayCast3D = $RayCast3D
@onready var idle_timer: Timer = $IdleTimer
@onready var mode_label: Label3D = $ModeLabel3D

Да, тут есть ссылки, которые берутся из родительского существа напрямую. Godot такое допускает, и я в рамках данного урока допущу)


На этом короткая пауза от кода контроллера противника. Обращаю внимание на patrol_point_pool, потому что а как мы в неё будем задавать точки? Так как задавать их кодом, или прописывать каждую точку в некотором файле (а я вам даже не рассказал, как работать с ними работать), будет неудобно, я выбрал следующий вариант: добавлять набор точек на противника, когда тот находится уже в игровом мире. Звучит это просто, и делается тоже просто. Сейчас покажу:

  1. Добавляете противника как есть на сцену в подготовленную для него комнату.
  2. Добавьте противнику дочерний узел Node3D, назовем PatrolPoints.
  3. К этому узлу добавьте пачку узлов класса Marker3D (рис 17).


Рис. 17. Расставленные маркеры для противника.


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


Теперь, мы можем взять положения из этих точек в коде контроллера. Делать это будем как и предварительную настройку контроллера противника – в _ready(). Возвращаемся к коду:

Pythonfunc _ready() -> void:
 navigation_agent.path_desired_distance = 0.5
 navigation_agent.target_desired_distance = 0.5
 
 state = State.IDLE
 idle_timer.start(WAIT_TIME)
 
 if character.has_node("PatrolPoints"):
  var patrol_points: Node3D = character.get_node("PatrolPoints")
  for point in patrol_points.get_children():
   patrol_point_pool.append(point.global_position)

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

  • path_desired_distance и target_desired_distance выставлены на 0.5 по примеру из официальной документации;
  • Так как по умолчанию персонаж просто ждет (я так решил), то прямо тут укажу состояние и запущу ожидание таймером.
  • В конце просто ищем по названию узел с именем “PatrolPoints” и всё что внутри него – всё наше, пихаем в массив точек patrol_point_pool.


Когда мы уже сделали столько, пора определиться, а как будет выглядеть автомат, какие на нем возможны переходы, и какие условия у этих переходов. Для наглядности я это сделал в виде схемы на рисунке 18.


Рис. 18. Конечный автомат для противника.


Если все состояния мы описали, то нужно для переходов ещё методов допилить:

# Set-метод для установки состояния конечного автомата.

Pythonfunc set_state(value: State) -> void:
 state = value
 mode_label.text = State.find_key(value)


# Метод устанавливает целевую точку для перемещения.
func 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 current_agent_position: Vector3 = character.global_position
 var next_path_position: Vector3 = navigation_agent.get_next_path_position()
 
 character.look_to(current_agent_position.direction_to(next_path_position))
 character.move_direction = Vector2(0.0, -1.0)


# Проверяет, что цель в прямой видимости.
func target_is_in_direct_view() -> bool:
 var to_target: Vector3 = gaze.global_position.direction_to(target.global_position)
 # Если цель в зоне видимости существа
 if to_target.dot((gaze.to_global(gaze.target_position) - gaze.global_position).normalized()) > 0.0:
  direct_view_checker.global_transform.basis = Basis.looking_at(to_target)
  direct_view_checker.force_raycast_update()
  # и в прямой видимости:
  return direct_view_checker.get_collider() == target
 return false


# Проверяет, что цель в зоне для атаки.
func target_is_in_fire_distance() -> bool:
 var dist: float = (character.get_global_position() - target.get_global_position()).length()
 return dist < ATTACK_DISTANCE


# Возвращает случайную точку из набора тех, что были загружены в patrol_point_pool.
func get_new_patrol_position() -> Vector3:
 var new_point: Vector3 = patrol_point_pool.get(randi() % (patrol_point_pool.size() - 1))
 while new_point == target_position:
  new_point = patrol_point_pool.get(randi() % (patrol_point_pool.size() - 1))
 return new_point


# Callback-метод для таймера ожидания.
func _on_idle_timer_timeout() -> void:
 is_idle_finished = true


# Callback-метод при входе существа в зону прослушивания.
func _on_hearing_area_3d_body_entered(body: Node3D) -> void:
 if body is Player:
  target = body


# Callback-метод при выходе существа из зоны прослушивания.
func _on_hearing_area_3d_body_exited(body: Node3D) -> void:
 if body is Player:
  target = null
# Callback-метод для сигнала о смерти управляемым существом.
func _on_character_dead() -> void:
 state = State.DEAD

Пояснения к коду:

  • Не забудьте присоединить сигнал timeout от таймера на _on_idle_timer_timeout() и сигналы body_entered и body_exited от области HearingArea;
  • В set_movement_target устанавливается целевая точка в навигационного агента (если она реально новая), и от него же берется точка пути до этой цели. Управление персонажем при этом работает через взгляд, ранее описанную look_to(target);
  • Для проверки прямой видимости используется множество преобразований и некая dot(). Если кратко, то это скалярное произведение векторов, часто используемое для расчета видимости. Если же более полно, то [ДАННЫЕ УДАЛЕНЫ];
  • Вы можете для патрулирования брать не случайные точки, а ходить к ним по очереди. Тогда не забудьте добавить счетчик, на которой из точек было окончено патрулирование противником.


Ну и переходим к самому соку: конечному автомату. Поведение в нем описанное будет обрабатываться каждый физический кадр, чтобы лишний раз не грузить систему. Выглядеть оно будет почти через if else, только через match case:

Pythonfunc _physics_process(delta: float) -> void:
 match state:
  State.IDLE:
   if is_instance_valid(target):
    if target_is_in_direct_view():
     state = State.CHASE
     return
   if is_idle_finished:
    pass
    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
     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.CHASE:
   if not is_instance_valid(target):
    character.move_direction = Vector2.ZERO
    state = State.IDLE
    idle_timer.start()
    is_idle_finished = false
    return
   else:
    set_movement_target(target.global_position)
   if target_is_in_fire_distance():
    state = State.ATTACK
   
   animation_player.play("chase")
  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():
    state = State.ATTACK
   else:
    state = State.CHASE

   animation_player.play("attack")
  State.DEAD:
   character.move_direction = Vector2.ZERO
   # Отключаю работу ИИ.
	set_physics_process(false)

   animation_player.play("death_1")

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


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


Навигационная карта

Я для примера сделал навигацию противника только в рамках своей комнаты. Этот вариант делается очень быстро и просто. Чисто сделайте 3 простых шага:

  1. Добавьте узел NavigationRegion3D, создайте в инспекторе ему навигационный меш;
  2. Перенесите комнату для противника дочерним для этого узла NavigationRegion3D.
  3. Выделите узел NavigationRegion3D и нажмите кнопку “Запечь сетку навигации” (рис. 19). Готово! Этот узел хранит сетку, по которой ваш противник сможет перемещаться. И не обязательно, чтобы NavigationRegion3D был родительским для комнаты противника.


Рис. 19. Запекание навигационной карты.


Пора запустить и проверить, работает ли оно? Настраиваем здоровье, скорость противника, и наблюдаем.


Аним. 1. Оно бродит.


Аним. 2. Оно дерется.


У меня работает. Надеюсь, у вас тоже. Но погодите-ка, а как он дерется?


Вооружаем противника

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


Кулак у нас точно не огнестрельное оружие. Пора организовать класс для оружия ближнего боя – MeleeWeapon:

Pythonclass_name MeleeWeapon
extends Weapon
# Время между выстрелами.
var fire_delta: float
# Урон оружия.
var damage: float


func attack() -> void:
 pass


func pull_trigger() -> void:
 attack()


func release_trigger() -> void:
 pass

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

  • Многого от такого оружия сейчас не потребуется, лишь урон и… пусть ещё время между ударами;
  • pull_trigger() и release_trigger() нужны для совместимости с вызовом уже существующих методов у персонажа.

А там и сценку для кулака как орудие нанесения урона (рис. 20, 21).


Рис. 20. Иерархия для новых файлов.


Рис. 21. Сцена кулака.


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


Добавим это “оружие” противнику на сцену, зададим в анимации атаки у персонажа вызов метода “атаковать” (рис. 22)...


Рис. 23. Добавленный кулак на сцену противника и атака им в анимации.


В коде, чтобы противник имел доступ к оружию, надо это оружие как-то зафиксировать. Пока сделаем это ручками (в “walking_enemy.gd”!):

Pythonfunc _ready() -> void:
 guns = [
  $Body/Head/WeaponPosition/Fist,
 ]
 
 select_gun(0)
 …

Теперь, когда противник держит свой кулак выбранным, он будет готов его применить. Что, по факту, он и делал на анимации 2!

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

hub.comments