~2 мин чтения

Практика — контроллер от первого лица

Соберём минимальный FPS-персонаж с движением, прыжком и стрельбой raycast.

Иллюстрация: Практика — контроллер от первого лица

Собираем всё вместе. Цель — играбельный персонаж от первого лица: ходьба, поворот головы, прыжок, стрельба лучом по целям. Используем CharacterController для предсказуемого управления.

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

Player                      ← CharacterController + наш PlayerController.cs
└── CameraPivot             ← пустой Transform, точка вращения камеры
    └── Main Camera         ← Camera + AudioListener
        └── GunMuzzle       ← пустой Transform для луча и эффектов

Почему именно так:

  • Player поворачивается по горизонтали (yaw) — это влияет и на направление движения.
  • CameraPivot поворачивается по вертикали (pitch) — отдельно от тела.
  • Это разделение упрощает физику: коллайдер игрока не наклоняется, когда смотрите в небо.

Player.cs — движение и взгляд

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(CharacterController))]
public class Player : MonoBehaviour
{
    [Header("Movement")]
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float jumpHeight = 1.2f;
    [SerializeField] private float gravity = -20f;

    [Header("Look")]
    [SerializeField] private Transform cameraPivot;
    [SerializeField] private float lookSensitivity = 0.15f;
    [SerializeField] private float maxPitch = 85f;

    [Header("Gun")]
    [SerializeField] private Transform gunMuzzle;
    [SerializeField] private float fireRange = 100f;
    [SerializeField] private LayerMask hitMask = ~0;

    [SerializeField] private GameObject impactPrefab;

    private CharacterController _cc;
    private Camera _camera;
    private Vector3 _velocity;
    private Vector2 _moveInput;
    private Vector2 _lookInput;
    private float _pitch;
    private bool _jumpRequested;
    private bool _fireRequested;

    private void Awake() {
        _cc = GetComponent<CharacterController>();
        _camera = cameraPivot.GetComponentInChildren<Camera>();
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
    }

    // Подключаются через PlayerInput компонент (Behavior: Invoke Unity Events).
    // Send Messages передаёт значение напрямую (например, Vector2) — там сигнатура была бы другой.
    public void OnMove(InputAction.CallbackContext ctx)  => _moveInput = ctx.ReadValue<Vector2>();
    public void OnLook(InputAction.CallbackContext ctx)  => _lookInput = ctx.ReadValue<Vector2>();
    public void OnJump(InputAction.CallbackContext ctx)  { if (ctx.performed) _jumpRequested = true; }
    public void OnFire(InputAction.CallbackContext ctx)  { if (ctx.performed) _fireRequested = true; }

    private void Update() {
        ApplyLook();
        ApplyMove();
        if (_fireRequested) { Fire(); _fireRequested = false; }
    }

    private void ApplyLook() {
        // Yaw — поворот тела
        transform.Rotate(Vector3.up, _lookInput.x * lookSensitivity);

        // Pitch — поворот камеры (отдельно), с ограничением
        _pitch -= _lookInput.y * lookSensitivity;
        _pitch = Mathf.Clamp(_pitch, -maxPitch, maxPitch);
        cameraPivot.localRotation = Quaternion.Euler(_pitch, 0, 0);
    }

    private void ApplyMove() {
        // Локальное направление: вперёд/назад + право/лево относительно игрока
        Vector3 dir = transform.right * _moveInput.x + transform.forward * _moveInput.y;
        Vector3 horizontal = dir * moveSpeed;

        // Гравитация и прыжок
        if (_cc.isGrounded) {
            if (_velocity.y < 0) _velocity.y = -2f; // "прижатие" к земле
            if (_jumpRequested) {
                // Стандартная формула: v = sqrt(-2 * g * h)
                _velocity.y = Mathf.Sqrt(-2f * gravity * jumpHeight);
                _jumpRequested = false;
            }
        } else {
            _jumpRequested = false;
        }
        _velocity.y += gravity * Time.deltaTime;

        Vector3 motion = (horizontal + new Vector3(0, _velocity.y, 0)) * Time.deltaTime;
        _cc.Move(motion);
    }

    private void Fire() {
        // Луч из центра камеры, не из muzzle — так стрельба точнее туда, куда смотрит игрок
        var ray = new Ray(_camera.transform.position, _camera.transform.forward);

        if (Physics.Raycast(ray, out RaycastHit hit, fireRange, hitMask)) {
            Debug.DrawLine(ray.origin, hit.point, Color.red, 1f);

            // Сообщаем урон, если это объект с компонентом Health
            if (hit.collider.TryGetComponent<Health>(out var health)) {
                health.TakeDamage(10);
            }

            // Эффект попадания
            if (impactPrefab != null) {
                Instantiate(impactPrefab, hit.point, Quaternion.LookRotation(hit.normal));
            }
        }
    }
}

Health.cs — приёмник урона

using UnityEngine;
using UnityEngine.Events;

public class Health : MonoBehaviour
{
    [SerializeField] private int max = 100;
    [SerializeField] private UnityEvent onDied;

    private int _current;

    private void Awake() => _current = max;

    public void TakeDamage(int amount) {
        if (_current <= 0) return;
        _current = Mathf.Max(0, _current - amount);
        if (_current == 0) onDied?.Invoke();
    }
}

Настройка в редакторе

  1. Player GameObject: Capsule, удалите MeshFilter+MeshRenderer (или скройте) — это “невидимый” контроллер. Добавьте CharacterController (Center.y = 1, Height = 2, Radius = 0.4).
  2. CameraPivot: пустой child на Y = 1.7 (высота глаз).
  3. Main Camera: child of CameraPivot, локальная позиция (0, 0, 0).
  4. GunMuzzle: child of Main Camera, локально (0.3, -0.2, 0.5) — примерно “из правой руки”.
  5. На Player добавьте PlayerInput (Input System): создайте Action Asset с Actions: Move (Vector2, WASD composite), Look (Vector2, mouse delta), Jump (button, Space), Fire (button, Mouse left).
  6. В PlayerInput → Behavior: Invoke Unity Events. В каждом событии подключите соответствующие методы OnMove, OnLook, OnJump, OnFire.

Тестовая сцена

  • Plane на Y = 0 — пол.
  • Несколько Cube с компонентом Health (как мишени).
  • Directional Light в позиции, обеспечивающей нормальное освещение.

Запускаете Play — должен ходить, прыгать, стрелять. По мишеням раз в ~10 выстрелов одна исчезает (за onDied.Invoke() подключите Destroy через UnityEvent).

Что улучшить дальше

  • Sprint — bool input + множитель скорости.
  • Footstep audio — через Animation Event или таймер по скорости.
  • Coyote time — небольшое окно (0.1 с), в которое можно прыгнуть после схода с края. Заметно улучшает ощущение управления.
  • Jump buffering — запоминать Jump, нажатый чуть раньше приземления, чтобы прыжок не “пропадал”.
  • Camera bobbing — лёгкое покачивание камеры при ходьбе (синусоидальное смещение CameraPivot).
  • Reload, Magazine, Ammo HUD — следующая итерация.
  • Cinemachine FreeLook для третьего лица — заменить ручной MouseLook.
Профайлинг с самого начала

Откройте Window → Analysis → Profiler уже на этом этапе. Посмотрите, где тратится время. CharacterController.Move обычно стоит дёшево, но если вы случайно вызываете GetComponent в Update — увидите. Camera.main с Unity 2020.2 кэшируется внутренне (не сканирует сцену каждый вызов), но overhead на доступ есть — лучше всё равно сохранять ссылку в Awake.

В следующей главе — оптимизация и сборка проекта.