Практика — контроллер от первого лица
Соберём минимальный 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();
}
}
Настройка в редакторе
- Player GameObject: Capsule, удалите MeshFilter+MeshRenderer (или скройте) — это
“невидимый” контроллер. Добавьте
CharacterController(Center.y = 1, Height = 2, Radius = 0.4). - CameraPivot: пустой child на Y = 1.7 (высота глаз).
- Main Camera: child of CameraPivot, локальная позиция (0, 0, 0).
- GunMuzzle: child of Main Camera, локально (0.3, -0.2, 0.5) — примерно “из правой руки”.
- На Player добавьте
PlayerInput(Input System): создайте Action Asset с Actions: Move (Vector2, WASD composite), Look (Vector2, mouse delta), Jump (button, Space), Fire (button, Mouse left). - В 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.
В следующей главе — оптимизация и сборка проекта.