~2 мин чтения

Практика — 3rd-person платформер с NavMesh-врагом

PhantomCamera FreeLook, CharacterBody3D, double jump, coyote time, преследующий враг, чекпойнты.

Иллюстрация: Практика — 3rd-person платформер с NavMesh-врагом

Второй практический капстон Godot — параллель Unity TPS-главы. Собираем платформер от третьего лица: персонаж бегает, прыгает (с double jump и coyote time), его преследует AI-враг по NavMesh, при смерти игрок респаунится на чекпойнте.

Используем то, что разбирали раньше: CharacterBody3D с move_and_slide, NavigationAgent3D, Phantom Camera плагин для third-person вида, Resource как ScriptableObject-аналог.

Иерархия сцены

World (Node3D)
├── Ground               ← StaticBody3D + CollisionShape3D + MeshInstance3D
├── Platforms            ← набор StaticBody3D на разной высоте
├── Checkpoints
│   ├── Checkpoint_01    ← Area3D + CollisionShape3D + script
│   └── Checkpoint_02
├── Enemies
│   └── Patroller        ← CharacterBody3D + NavigationAgent3D + EnemyAI
├── NavigationRegion3D   ← запекаем NavMesh из Ground + Platforms
└── WorldEnvironment

Player (CharacterBody3D)
├── CollisionShape3D     ← CapsuleShape3D (height=2, radius=0.4)
├── Body (Node3D)         ← визуальная модель + MeshInstance3D
├── GroundCheck (Marker3D)← позиция у ног для is_on_floor
└── CameraTarget (Node3D) ← на уровне головы, цель для PhantomCamera

PhantomCamera3D            ← следящая 3rd-person камера
                              tracking_target = Player/CameraTarget
                              follow_mode = ThirdPerson

PhantomCameraHost          ← дочерний к главной Camera3D
Camera3D                   ← реальная камера сцены

GameState (Node)           ← синглтон через autoload
HUD (CanvasLayer)
CharacterBody3D вместо Rigidbody

В Unity-главе мы взяли Rigidbody для платформера ради инерции. В Godot CharacterBody3D с move_and_slide() достаточно умный — он сам “скользит” по стенам, обрабатывает наклоны и ступеньки. Плюс предсказуем как Unity CharacterController. RigidBody3D имеет смысл только если нужна полноценная физика на игроке (рэгдолл, пинать ящики).

player_motor.gd — контроллер

extends CharacterBody3D
class_name PlayerMotor

@export_group("Movement")
@export var move_speed: float = 6.0
@export var air_control: float = 0.35
@export var turn_speed: float = 12.0           # рад/с

@export_group("Jump")
@export var jump_velocity: float = 5.5
@export var max_jumps: int = 2                 # обычный + double
@export var coyote_time: float = 0.12           # окно прыжка после схода с края
@export var jump_buffer: float = 0.15           # окно "пред-нажатия" перед землёй

@export_group("References")
@export var camera_rig: Node3D                  # обычно — главная Camera3D

@onready var body: Node3D = $Body

var _jumps_left: int = 0
var _last_grounded_time: float = -1.0
var _last_jump_press_time: float = -1.0
var _was_on_floor: bool = false

func _physics_process(delta: float) -> void:
    _update_grounded(delta)
    _update_jump(delta)
    _update_move(delta)
    move_and_slide()

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("jump"):
        _last_jump_press_time = _now()

func _update_grounded(_delta: float) -> void:
    var on_floor := is_on_floor()
    if on_floor:
        _last_grounded_time = _now()
        if not _was_on_floor:
            _jumps_left = max_jumps  # приземлились — восстанавливаем
    _was_on_floor = on_floor

func _update_jump(delta: float) -> void:
    var wants_jump := (_now() - _last_jump_press_time) < jump_buffer
    var in_coyote := (_now() - _last_grounded_time) < coyote_time

    if not wants_jump:
        # Гравитация
        if not is_on_floor():
            velocity += get_gravity() * delta
        return

    var can_first_jump := in_coyote and _jumps_left == max_jumps
    var can_air_jump := not is_on_floor() and _jumps_left > 0 and _jumps_left < max_jumps

    if can_first_jump or can_air_jump:
        velocity.y = jump_velocity     # фиксированная высота прыжка
        _jumps_left -= 1
        _last_jump_press_time = -1.0    # съели буфер
    else:
        if not is_on_floor():
            velocity += get_gravity() * delta

func _update_move(delta: float) -> void:
    var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")

    if input_dir == Vector2.ZERO:
        # Затухание горизонтальной скорости на земле
        var damp: float = 0.85 if is_on_floor() else 0.99
        velocity.x *= damp
        velocity.z *= damp
        return

    # Направление относительно камеры (проекция на горизонтальную плоскость)
    var cam_basis := camera_rig.global_transform.basis
    var cam_fwd := -cam_basis.z
    cam_fwd.y = 0
    cam_fwd = cam_fwd.normalized()
    var cam_right := cam_basis.x
    cam_right.y = 0
    cam_right = cam_right.normalized()

    var wish_dir := (cam_right * input_dir.x + cam_fwd * -input_dir.y).normalized()

    # Поворот тела персонажа в направлении движения
    var target_yaw := atan2(wish_dir.x, wish_dir.z)
    body.rotation.y = lerp_angle(body.rotation.y, target_yaw, turn_speed * delta)

    # Применение скорости: на земле — мгновенно (lerp с t=1.0 = snap к target),
    # в воздухе — частично (t=air_control ≈ 0.35). Это даёт классический FPS-feel.
    var t := 1.0 if is_on_floor() else air_control
    var target_horizontal := wish_dir * move_speed
    velocity.x = lerp(velocity.x, target_horizontal.x, t)
    velocity.z = lerp(velocity.z, target_horizontal.z, t)

func _now() -> float:
    return Time.get_ticks_msec() / 1000.0
Coyote time + jump buffering

Те же 0.12 / 0.15 секунды, что в Unity-главе. Игрок не нажимает прыжок точно в кадр касания земли — он жмёт чуть раньше или чуть позже. Без этих окон до трети прыжков “не срабатывают”. Celeste, Hollow Knight, Mario Odyssey все делают это.

Phantom Camera для 3rd-person

Установите плагин Phantom Camera (AssetLib → “Phantom Camera” → Install → активировать в Project Settings → Plugins).

Иерархия камеры

Player (CharacterBody3D)
└── CameraTarget (Node3D, y=1.6)    ← цель слежения

PhantomCamera3D                       ← виртуальная камера
   tracking_target = "../Player/CameraTarget"
   follow_mode = THIRD_PERSON
   third_person_settings:
     follow_distance: 4.5
     pitch_min: -45
     pitch_max: 70
     enable_collision: true           ← не пробивать стены

PhantomCameraHost (на главной Camera3D)
Camera3D (current = true)

Параметры:

  • follow_distance — расстояние от target.
  • pitch_min / pitch_max — диапазон вертикального угла.
  • enable_collision — камера не пробивает стены (SpringArm3D-стиль).
  • damping — плавность догоняния.

Управление мышью

PhantomCamera 3D в third-person режиме автоматически обрабатывает мышь, если включён mouse_look_enabled. Sensitivity настраивается в Inspector.

Если хотите вручную:

# В скрипте на PhantomCamera3D — или используйте input_axis_x/y bindings плагина
func _unhandled_input(event: InputEvent) -> void:
    if event is InputEventMouseMotion:
        var orbit := global_rotation
        orbit.y -= event.relative.x * 0.003
        orbit.x = clamp(orbit.x - event.relative.y * 0.003, deg_to_rad(-45), deg_to_rad(70))
        global_rotation = orbit

EnemyChaser — преследование по NavMesh

Из главы про Navigation, но упрощённо для платформера (без атаки в ближнем бою — просто контактный урон):

extends CharacterBody3D
class_name ChaserEnemy

@export var target: Node3D
@export var speed: float = 3.5
@export var contact_damage: int = 1
@export var hit_cooldown: float = 0.8
@export var catch_distance: float = 1.2

@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D

var _next_hit_time: float = 0.0

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

    nav_agent.target_position = target.global_position

    if 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()

    # Контактный урон
    var dist := global_position.distance_to(target.global_position)
    var now := Time.get_ticks_msec() / 1000.0
    if dist <= catch_distance and now >= _next_hit_time:
        if target.has_method("take_damage"):
            target.take_damage(contact_damage)
            _next_hit_time = now + hit_cooldown
NavMesh не прыгает

Если ваши платформы разнесены и только игрок может допрыгнуть — враг застрянет внизу. Решение: либо ставьте врагов в зоне, где они физически могут достичь игрока, либо используйте NavigationLink3D для предзаданных скачков, либо пишите AI без NavMesh.

PlayerHealth — с i-frames

extends Node
class_name PlayerHealth

signal damaged(hp_left: int)
signal died

@export var max_hearts: int = 3
@export var invincibility_seconds: float = 0.7

var _hp: int
var _invincible_until: float = 0.0

func _ready() -> void:
    _hp = max_hearts

func take_damage(amount: int) -> void:
    var now := Time.get_ticks_msec() / 1000.0
    if now < _invincible_until or _hp <= 0:
        return

    _hp = max(0, _hp - amount)
    _invincible_until = now + invincibility_seconds
    damaged.emit(_hp)

    if _hp == 0:
        died.emit()

func reset() -> void:
    _hp = max_hearts
    damaged.emit(_hp)

Чекпойнты и респаун через autoload

В Godot синглтоны делаются через Autoload (Project → Project Settings → Autoload). Это удобнее, чем Instance.set/get шаблон.

# game_state.gd
extends Node

var active_checkpoint: Transform3D = Transform3D.IDENTITY

func set_active_checkpoint(t: Transform3D) -> void:
    active_checkpoint = t

func respawn(player: Node3D, health: PlayerHealth) -> void:
    if player is CharacterBody3D:
        (player as CharacterBody3D).velocity = Vector3.ZERO
    player.global_transform = active_checkpoint
    health.reset()

Регистрация: Project Settings → Autoload → Path = res://scripts/game_state.gd, Name = GameState. После этого GameState доступен из любого скрипта как глобал.

checkpoint.gd — Area3D-триггер на чекпойнте:

extends Area3D
class_name Checkpoint

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node3D) -> void:
    if body.is_in_group("player"):
        GameState.set_active_checkpoint(global_transform)

Главное: пометьте игрока в группе player (Inspector → Node → Groups → add “player”).

Связать смерть с респауном:

# Скрипт игрока, в _ready
$Health.died.connect(_on_died)

func _on_died() -> void:
    GameState.respawn(self, $Health)

HUD сердечек

hud.tscn:

CanvasLayer
└── MarginContainer
    └── HBoxContainer
        ├── TextureRect (heart_full.png)
        ├── TextureRect (heart_full.png)
        └── TextureRect (heart_full.png)

hearts_hud.gd:

extends HBoxContainer

@export var heart_full: Texture2D
@export var heart_empty: Texture2D

@onready var heart_nodes: Array[TextureRect] = []

func _ready() -> void:
    for child in get_children():
        if child is TextureRect:
            heart_nodes.append(child)

func on_health_changed(current: int) -> void:
    for i in heart_nodes.size():
        heart_nodes[i].texture = heart_full if i < current else heart_empty

Подключите сигнал PlayerHealth.damaged к этому методу через Inspector → Node → Signals или из кода.

Что добавить дальше

  • Animator через AnimationTree: BlendSpace2D для locomotion (forward/strafe), one-shot для прыжка.
  • Footstep audio через Method Track в AnimationPlayer.
  • Movable платформы — AnimatableBody3D с velocity свойством (применяется к стоящим на ней).
  • Wall slide / wall jump — отдельный state-машина с проверкой is_on_wall_only().
  • Pause Menu — отдельная сцена с get_tree().paused = true.
  • Save System — через FileAccess или ConfigFile в user://saves.cfg.

Сравнение со сложностью Unity TPS

Эквивалентный Unity-проект занимает примерно столько же кода. Главные отличия:

  • move_and_slide() делает больше за нас, чем CharacterController.Move() — slope handling и slide-along-wall встроены.
  • Phantom Camera даёт Cinemachine-стиль 3rd-person из одного плагина.
  • Autoload проще, чем Unity Singleton-паттерн.
  • Time.get_ticks_msec() / 1000.0 вместо Time.time — немного многословнее.
  • Resource + class_name дают такой же ScriptableObject-стиль конфига.

В целом — порядка 150–200 строк кода для играбельного TPS. Те же 1.5–2 часа на сборку, что и Unity-эквивалент.