~2 мин чтения

Навигация — NavigationServer и AI

NavigationRegion3D, NavigationAgent3D — поиск пути и преследующий враг.

Navigation в Godot 4 — это NavigationServer3D (низкоуровневый сервер) и три высокоуровневых узла: NavigationRegion3D, NavigationAgent3D, NavigationLink3D.

Идея

NavigationMesh — упрощённая полигональная “карта проходимости”. Запекается из коллизий сцены. Агенты ищут путь по этой карте (A* под капотом).

World
├── Ground (StaticBody3D + MeshInstance3D)
├── Walls / Platforms
└── NavigationRegion3D
    └── navigation_mesh: NavigationMesh (запекается)

Enemy
├── (модель + коллайдер)
└── NavigationAgent3D (target_position = player_position)

Запекание NavigationMesh

  1. Соберите статичную геометрию.
  2. Добавьте узел NavigationRegion3D на сцену.
  3. В Inspector создайте новый ресурс NavigationMesh на этом узле.
  4. Кнопка Bake NavigationMesh в верхней панели → запекание.

В Geometry → Source Geometry Mode на NavigationMesh выбирается, из чего собирать меш:

  • SOURCE_GEOMETRY_ROOT_NODE_CHILDREN — обрабатывает детей NavigationRegion3D (положите внутрь региона статичную геометрию).
  • SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN — узлы из заданной группы + их дети.
  • SOURCE_GEOMETRY_GROUPS_EXPLICIT — только узлы из группы.

Параметры NavigationMesh:

  • agent_radius — радиус агента (отступ от стен).
  • agent_height — высота агента.
  • agent_max_climb — макс высота ступеньки, которую агент перешагнёт.
  • agent_max_slope — макс угол склона в градусах.
  • cell_size / cell_height — точность мешa.
Регионы соединяются автоматически

Если у вас несколько NavigationRegion3D — они автоматически соединяются на границах. Удобно для модульных уровней: каждая комната — свой регион.

NavigationAgent3D — дочерний узел тела, которое должно ходить по мешу. Не двигает само, рассчитывает следующую точку пути:

extends CharacterBody3D

@export var speed: float = 4.0
@export var target: Node3D

@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D

func _ready() -> void:
    nav_agent.path_desired_distance = 0.5
    nav_agent.target_desired_distance = 1.0

func _physics_process(delta: float) -> void:
    if target == null:
        return

    nav_agent.target_position = target.global_position

    if nav_agent.is_navigation_finished():
        return

    var next_point := nav_agent.get_next_path_position()
    var direction := (next_point - global_position).normalized()

    velocity.x = direction.x * speed
    velocity.z = direction.z * speed
    if not is_on_floor():
        velocity += get_gravity() * delta

    move_and_slide()

Главные методы:

  • get_next_path_position() — куда идти на этом тике.
  • is_navigation_finished() — путь завершён.
  • is_target_reachable() — есть ли путь до цели.
  • distance_to_target() — оставшееся расстояние.

Сигналы:

  • target_reached — достигли цели.
  • waypoint_reached(details) — достигли промежуточной точки.
  • navigation_finished — путь полностью пройден.
  • velocity_computed(safe_velocity) — если включён avoidance.

Avoidance — расступание агентов

Если на сцене много агентов, идущих в одну точку, без расступания они сольются “стопкой”. Включите avoidance_enabled = true на NavigationAgent3D — заработает RVO2 (reciprocal velocity obstacles).

nav_agent.avoidance_enabled = true
nav_agent.radius = 0.6           # радиус расступания
nav_agent.neighbor_distance = 5.0 # видим соседей в этом радиусе
nav_agent.velocity_computed.connect(_on_safe_velocity)

func _on_safe_velocity(safe_vel: Vector3) -> void:
    # Используйте safe_vel вместо raw velocity
    velocity.x = safe_vel.x
    velocity.z = safe_vel.z
    move_and_slide()

func _physics_process(delta: float) -> void:
    # ...
    nav_agent.set_velocity(desired_velocity)  # отдаём желаемую — получаем safe через сигнал

NavigationMesh не поймёт “тут можно прыгнуть через пропасть”. Для этого — NavigationLink3D: прямая связь между двумя точками. Агенты идут по ней как по обычному ребру графа.

В Inspector:

  • start_position + end_position — концы линка.
  • bidirectional — двусторонний или нет.

При прохождении такого ребра агент пройдёт геометрически прямо — это значит, что вам нужно самостоятельно сделать “прыжок” (анимация + физика) в скрипте, отловив проход через линк.

Двери, ящики, поваленные деревья — динамические препятствия. Два режима:

  • Без affect_navigation_mesh — просто отталкивает агентов через avoidance.
  • С affect_navigation_mesh = true — “вырезает” дыру в navmesh (требует rebake региона на лету). Дорого, но точно.

Простой враг с state machine

Полный пример из главы про Unity NavMesh, переписанный на Godot:

extends CharacterBody3D
class_name EnemyChaser

enum State { PATROL, CHASE, ATTACK }

@export var patrol_points: Array[Node3D] = []
@export var player: Node3D
@export var speed: float = 3.0
@export var sight_range: float = 12.0
@export var attack_range: float = 2.0
@export var attack_cooldown: float = 1.2

@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D

var state: State = State.PATROL
var patrol_index: int = 0
var next_attack_time: float = 0.0

func _ready() -> void:
    if patrol_points.size() > 0:
        nav_agent.target_position = patrol_points[0].global_position

func _physics_process(delta: float) -> void:
    var dist_to_player = global_position.distance_to(player.global_position)

    match state:
        State.PATROL:
            if dist_to_player < sight_range and _has_line_of_sight():
                state = State.CHASE
            elif nav_agent.is_navigation_finished():
                patrol_index = (patrol_index + 1) % patrol_points.size()
                nav_agent.target_position = patrol_points[patrol_index].global_position
        State.CHASE:
            nav_agent.target_position = player.global_position
            if dist_to_player < attack_range:
                state = State.ATTACK
            elif dist_to_player > sight_range * 1.5:
                state = State.PATROL
        State.ATTACK:
            nav_agent.target_position = global_position  # стоим
            var now = Time.get_ticks_msec() / 1000.0
            if now >= next_attack_time:
                _attack()
                next_attack_time = now + attack_cooldown
            if dist_to_player > attack_range + 0.5:
                state = State.CHASE

    _move(delta)

func _move(delta: float) -> void:
    if state != State.ATTACK and not nav_agent.is_navigation_finished():
        var next_point = nav_agent.get_next_path_position()
        var dir = (next_point - global_position).normalized()
        velocity.x = dir.x * speed
        velocity.z = dir.z * speed
    else:
        velocity.x = move_toward(velocity.x, 0, speed)
        velocity.z = move_toward(velocity.z, 0, speed)

    if not is_on_floor():
        velocity += get_gravity() * delta
    move_and_slide()

func _has_line_of_sight() -> bool:
    var space = get_world_3d().direct_space_state
    var query = PhysicsRayQueryParameters3D.create(
        global_position + Vector3.UP, player.global_position
    )
    query.exclude = [self]
    var hit = space.intersect_ray(query)
    return hit.is_empty() or hit.collider == player

func _attack() -> void:
    if player.has_method("take_damage"):
        player.take_damage(10)
Гистерезис

Обратите внимание на пороги: вход в CHASE на sight_range, выход — на sight_range * 1.5. Это hysteresis, который не даёт врагу “дёргаться” между состояниями на границе. Тот же приём, что и в Unity-главе.

В следующей главе — частицы.