Практика — 3rd-person платформер с NavMesh-врагом
PhantomCamera FreeLook, CharacterBody3D, double jump, coyote time, преследующий враг, чекпойнты.
Второй практический капстон 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)
В 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
Те же 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
Если ваши платформы разнесены и только игрок может допрыгнуть — враг застрянет внизу.
Решение: либо ставьте врагов в зоне, где они физически могут достичь игрока, либо используйте
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-эквивалент.