~3 мин чтения

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

Камера-орбита, физический контроллер, double jump, coyote time, преследующий враг и чекпойнты.

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

Второй практический капстон — собираем платформер от третьего лица: персонаж бегает, прыгает (с double jump и coyote time), его преследует AI-враг, при смерти игрок респаунится на чекпойнте. Используем то, что разбирали в предыдущих главах: Cinemachine, NavMesh, физический Rigidbody, ScriptableObject для конфига.

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

World
├── Ground             ← Plane или Mesh с MeshCollider
├── Platforms          ← набор кубов на разной высоте
├── Checkpoints
│   ├── Checkpoint_01  ← пустой объект с триггер-коллайдером
│   └── Checkpoint_02
├── Enemies
│   └── Patroller      ← NavMeshAgent + EnemyAI (из главы про NavMesh)
└── NavMeshSurface     ← запекаем NavMesh на Ground + Platforms

Player                 ← Rigidbody + CapsuleCollider + PlayerMotor.cs
├── Body               ← визуальная модель (mesh)
└── (CameraTarget)     ← child Transform на уровне головы

CM FreeLook            ← Cinemachine 3rd-person camera, Tracking Target = Player/CameraTarget
Main Camera            ← обычная Camera + CinemachineBrain + AudioListener

GameState              ← менеджер: текущий чекпойнт, респаун, HUD-ссылки
HUD (Canvas)           ← Health Bar, debug-текст
Почему Rigidbody, а не CharacterController

В FPS мы выбрали CharacterController — он предсказуем, не “соскальзывает”. В платформере хочется ощущения веса и инерции: Rigidbody даёт это бесплатно через physical materials, damping и столкновения с движущимися платформами. CharacterController всё ещё можно использовать, но Rigidbody обычно выигрывает в платформерах.

PlayerMotor.cs — физический контроллер

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody), typeof(CapsuleCollider))]
public class PlayerMotor : MonoBehaviour
{
    [Header("Movement")]
    [SerializeField] private float moveSpeed = 6f;
    [SerializeField] private float airControl = 0.35f;     // 0..1, насколько в воздухе слушается ввода
    [SerializeField] private float turnSpeed = 720f;       // град/с
    [SerializeField] private float dampingGrounded = 8f;   // быстрая остановка на земле
    [SerializeField] private float dampingAir = 0.2f;

    [Header("Jump")]
    [SerializeField] private float jumpHeight = 1.6f;
    [SerializeField] private int maxJumps = 2;             // обычный + double
    [SerializeField] private float coyoteTime = 0.12f;     // окно прыжка после схода с края
    [SerializeField] private float jumpBuffer = 0.15f;     // окно запоминания нажатия перед землёй

    [Header("Ground Check")]
    [SerializeField] private Transform groundCheck;        // пустой объект у ног
    [SerializeField] private float groundCheckRadius = 0.25f;
    [SerializeField] private LayerMask groundMask = ~0;

    [Header("Camera Reference")]
    [SerializeField] private Transform cameraRig;          // обычно Camera.main.transform

    private Rigidbody _rb;
    private CapsuleCollider _col;

    private Vector2 _moveInput;
    private bool _jumpPressedThisFrame;
    private float _lastGroundedTime;
    private float _lastJumpPressTime = float.NegativeInfinity;
    private int _jumpsLeft;
    private bool _isGrounded;

    private void Awake() {
        _rb = GetComponent<Rigidbody>();
        _col = GetComponent<CapsuleCollider>();
        _rb.freezeRotation = true; // мы поворачиваем сами
    }

    // Подключаются через PlayerInput (Behavior: Invoke Unity Events)
    public void OnMove(InputAction.CallbackContext ctx) => _moveInput = ctx.ReadValue<Vector2>();
    public void OnJump(InputAction.CallbackContext ctx) {
        if (ctx.performed) {
            _jumpPressedThisFrame = true;
            _lastJumpPressTime = Time.time;
        }
    }

    private void FixedUpdate() {
        UpdateGrounded();
        UpdateMove();
        TryJump();
        _jumpPressedThisFrame = false;
    }

    private void UpdateGrounded() {
        Vector3 origin = groundCheck != null ? groundCheck.position : transform.position;
        bool wasGrounded = _isGrounded;
        _isGrounded = Physics.CheckSphere(origin, groundCheckRadius, groundMask, QueryTriggerInteraction.Ignore);

        if (_isGrounded) {
            _lastGroundedTime = Time.time;
            if (!wasGrounded) _jumpsLeft = maxJumps; // приземлились — восстанавливаем прыжки
        }
        _rb.linearDamping = _isGrounded ? dampingGrounded : dampingAir;
    }

    private void UpdateMove() {
        if (_moveInput.sqrMagnitude < 0.01f) return;

        // Направление ввода относительно камеры (проекция на горизонтальную плоскость)
        Vector3 camFwd = cameraRig.forward; camFwd.y = 0; camFwd.Normalize();
        Vector3 camRight = cameraRig.right; camRight.y = 0; camRight.Normalize();
        Vector3 wishDir = (camRight * _moveInput.x + camFwd * _moveInput.y).normalized;

        // Поворот тела в направлении движения
        Quaternion targetRot = Quaternion.LookRotation(wishDir, Vector3.up);
        transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRot, turnSpeed * Time.fixedDeltaTime);

        // Применение скорости — на земле полное, в воздухе ослабленное
        float control = _isGrounded ? 1f : airControl;
        Vector3 desiredVel = wishDir * moveSpeed * control;

        // Сохраняем вертикальную составляющую от гравитации/прыжка
        Vector3 vel = _rb.linearVelocity;
        Vector3 horizontalVel = new Vector3(vel.x, 0, vel.z);
        Vector3 delta = desiredVel - horizontalVel;
        // Ограничим, чтобы не получить нереалистично большую силу
        _rb.AddForce(delta, ForceMode.VelocityChange);
    }

    private void TryJump() {
        bool wantsJump = Time.time - _lastJumpPressTime < jumpBuffer;
        bool inCoyoteWindow = Time.time - _lastGroundedTime < coyoteTime;

        if (!wantsJump) return;

        bool canFirstJump = inCoyoteWindow && _jumpsLeft == maxJumps;
        bool canAirJump = !_isGrounded && _jumpsLeft > 0 && _jumpsLeft < maxJumps;

        if (canFirstJump || canAirJump) {
            // Сбрасываем вертикальную скорость, чтобы прыжок был стабильной высоты
            Vector3 v = _rb.linearVelocity;
            v.y = Mathf.Sqrt(-2f * Physics.gravity.y * jumpHeight);
            _rb.linearVelocity = v;
            _jumpsLeft--;
            _lastJumpPressTime = float.NegativeInfinity; // съели буфер
        }
    }
}
Coyote time + jump buffering — секрет хорошего платформера

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

Cinemachine FreeLook — камера

В Cinemachine 3 для орбитальной камеры от 3-го лица:

  1. Создайте на сцене CinemachineCamera.
  2. Добавьте на неё компонент CinemachineOrbitalFollow (раньше — отдельный CinemachineFreeLook).
  3. В Tracking Target укажите Transform на уровне головы игрока (тот самый child CameraTarget).
  4. Настройте три горизонтальных кольца (top/mid/bottom rig) с радиусами 4, 6, 8 м и высотами 2, 0, -2.
  5. Активируйте CinemachineInputAxisController на той же камере — он привяжет правый стик/мышь к управлению камерой.

Параметры по вкусу:

  • Horizontal Axis Speed — скорость орбиты.
  • Recentering — автоматический возврат за спину персонажа после паузы во вводе.
  • Damping — насколько плавно камера догоняет цель.

В PlayerMotor поле cameraRig указывает на главную Camera (с CinemachineBrain). Это даёт поворот игрока в направлении движения относительно того, куда смотрит камера — стандартный feel 3-го лица.

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

Уже разобрали в главе про NavMesh. Для платформера достаточно варианта без атаки в ближнем бою — просто “догнал → нанёс урон → задержка → опять”:

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class ChaserEnemy : MonoBehaviour
{
    [SerializeField] private Transform target;
    [SerializeField] private int contactDamage = 1;
    [SerializeField] private float hitCooldown = 0.8f;
    [SerializeField] private float catchDistance = 1.2f;

    private NavMeshAgent _agent;
    private float _nextHitTime;

    private void Awake() => _agent = GetComponent<NavMeshAgent>();

    private void Update() {
        if (target == null) return;
        _agent.SetDestination(target.position);

        float dist = Vector3.Distance(transform.position, target.position);
        if (dist <= catchDistance && Time.time >= _nextHitTime) {
            if (target.TryGetComponent<PlayerHealth>(out var hp)) {
                hp.TakeDamage(contactDamage);
                _nextHitTime = Time.time + hitCooldown;
            }
        }
    }
}
NavMesh ≠ платформер

NavMesh умеет идти по статичной геометрии и через NavMeshLink — прыжки между фиксированными точками. Но он не умеет “по-настоящему” прыгать как игрок. Если ваши платформы разнесены и только игрок может допрыгнуть — враг застрянет внизу. Это часть дизайна: или ставьте врагов только на одной плоскости, или используйте NavMeshLink для предзаданных скачков, или пишите собственный AI без NavMesh.

Health, чекпойнты и респаун

PlayerHealth.cs со встроенной защитой от двойного срабатывания и i-frames (краткая неуязвимость после получения урона):

using UnityEngine;
using UnityEngine.Events;

public class PlayerHealth : MonoBehaviour
{
    [SerializeField] private int maxHearts = 3;
    [SerializeField] private float invincibilitySeconds = 0.7f;
    [SerializeField] private UnityEvent<int> onHealthChanged; // для HUD
    [SerializeField] private UnityEvent onDied;

    private int _hearts;
    private float _invincibleUntil;

    private void Awake() { _hearts = maxHearts; onHealthChanged?.Invoke(_hearts); }

    public void TakeDamage(int amount) {
        if (Time.time < _invincibleUntil || _hearts <= 0) return;

        _hearts = Mathf.Max(0, _hearts - amount);
        _invincibleUntil = Time.time + invincibilitySeconds;
        onHealthChanged?.Invoke(_hearts);

        if (_hearts == 0) onDied?.Invoke();
    }

    public void Heal(int amount) {
        _hearts = Mathf.Min(maxHearts, _hearts + amount);
        onHealthChanged?.Invoke(_hearts);
    }

    public void ResetHealth() { _hearts = maxHearts; onHealthChanged?.Invoke(_hearts); }
}

Checkpoint.cs — пустой GameObject с триггер-коллайдером:

public class Checkpoint : MonoBehaviour
{
    private void OnTriggerEnter(Collider other) {
        if (other.CompareTag("Player")) {
            GameState.Instance.SetActiveCheckpoint(transform);
        }
    }
}

GameState.cs — простой singleton-менеджер с возрождением:

using UnityEngine;

public class GameState : MonoBehaviour
{
    public static GameState Instance { get; private set; }

    [SerializeField] private Transform player;
    [SerializeField] private PlayerHealth playerHealth;
    [SerializeField] private Transform initialSpawn;

    private Transform _activeCheckpoint;

    private void Awake() {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
    }

    private void Start() {
        _activeCheckpoint = initialSpawn;
        playerHealth.onDied.AddListener(Respawn);
    }

    public void SetActiveCheckpoint(Transform t) => _activeCheckpoint = t;

    public void Respawn() {
        // На случай Rigidbody-игрока обнуляем инерцию
        if (player.TryGetComponent<Rigidbody>(out var rb)) {
            rb.linearVelocity = Vector3.zero;
            rb.angularVelocity = Vector3.zero;
        }
        player.position = _activeCheckpoint.position;
        player.rotation = _activeCheckpoint.rotation;
        playerHealth.ResetHealth();
    }
}
Singleton — компромисс, не паттерн

GameState.Instance удобен для прототипов, но в больших проектах быстро становится свалкой. Лучшая альтернатива в Unity — ScriptableObject как event-канал (показано в главе про Prefab и ScriptableObject). Игрок умер → SO-event “OnDied” → подписаны GameState, HUD, audio manager.

HUD — простейший Health Bar

В сцене:

  1. Canvas (Screen Space – Overlay).
  2. HorizontalLayoutGroup с тремя <Image> — сердечки.
  3. На скрипте HeartsHud.cs:
using UnityEngine;
using UnityEngine.UI;

public class HeartsHud : MonoBehaviour
{
    [SerializeField] private Image[] hearts;
    [SerializeField] private Sprite fullSprite;
    [SerializeField] private Sprite emptySprite;

    public void OnHealthChanged(int currentHearts) {
        for (int i = 0; i < hearts.Length; i++) {
            hearts[i].sprite = i < currentHearts ? fullSprite : emptySprite;
        }
    }
}

В PlayerHealth подключите onHealthChanged через Inspector → HeartsHud.OnHealthChanged.

Чего тут НЕ хватает

Это минимальное играбельное ядро. Из расширений просятся:

  • Animator на игроке: idle / run / jump / fall / hit (Blend Tree по скорости).
  • Footstep audio через Animation Event.
  • Двигающиеся платформы — для них Rigidbody-игрок лучше, чем CharacterController, но потребуется ловить факт “стою на платформе” и плюсовать её скорость.
  • Wall slide / wall jump — отдельная state-машина внутри PlayerMotor.
  • Pause MenuTime.timeScale = 0 + отдельный Canvas.
  • Save System — сериализация в PlayerPrefs или JSON, после прохождения checkpoint’а.
  • VFX от приземления — Particle System на земле в момент wasGrounded == false && _isGrounded.

Итого

Имея 100–150 строк кода и 4–5 ScriptableObject/prefab’ов, получаем играбельный 3D-платформер с вменяемым управлением и AI. Это в разы быстрее, чем собирать тот же набор с нуля в Three.js или Babylon — и наглядно показывает, где Unity действительно даёт преимущество как платформа для 3D-игр.