Практика — 3rd-person платформер с NavMesh-врагом
Камера-орбита, физический контроллер, double jump, coyote time, преследующий враг и чекпойнты.
Второй практический капстон — собираем платформер от третьего лица: персонаж бегает, прыгает (с 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-текст
В 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; // съели буфер
}
}
}
Игрок не нажимает прыжок ровно в кадр касания земли — он жмёт чуть раньше или чуть позже. Без этих двух механик треть прыжков “не срабатывают”, и игра ощущается дёргано. С ними — наоборот, управление становится “прощающим” и приятным. Celeste, Hollow Knight, Mario Odyssey — все делают это.
Cinemachine FreeLook — камера
В Cinemachine 3 для орбитальной камеры от 3-го лица:
- Создайте на сцене CinemachineCamera.
- Добавьте на неё компонент CinemachineOrbitalFollow (раньше — отдельный CinemachineFreeLook).
- В Tracking Target укажите Transform на уровне головы игрока (тот самый child
CameraTarget). - Настройте три горизонтальных кольца (top/mid/bottom rig) с радиусами 4, 6, 8 м и высотами 2, 0, -2.
- Активируйте 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 умеет идти по статичной геометрии и через 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();
}
}
GameState.Instance удобен для прототипов, но в больших проектах быстро становится свалкой.
Лучшая альтернатива в Unity — ScriptableObject как event-канал (показано в главе про Prefab и
ScriptableObject). Игрок умер → SO-event “OnDied” → подписаны GameState, HUD, audio manager.
HUD — простейший Health Bar
В сцене:
- Canvas (Screen Space – Overlay).
- HorizontalLayoutGroup с тремя
<Image>— сердечки. - На скрипте
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 Menu —
Time.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-игр.