
Вступление
Уже какое то время я разрабатываю свой first-person shooter на Unity, чья система передвижения сильно вдохновлена ULTRAKILL’ом. Изначально я начал этот проект в качестве практики и вызова самому себе, так как шутеры – это жанр максимально далёкий от моих предпочтений. В связи с этим мне пришлось изучить много нового и я хочу поделиться этими знаниями.
В ходе разработки мне пришлось решить интересные задачи: процедурные анимации смены оружия, Raycast и Projectile выстрелы, создание Muzzle эффектов, Hit эффектов и т.д., но в настоящей статье я расскажу о том как я разработал систему движения для своей игры и, надеюсь, вам понравится моё решение и вы сможете использовать его в своих проектах.
Референсы
В связи с тем, что я скорее разработчик, чем геймдизайнер, я намеренно брал конкретные примеры при разработке, чтобы не морочить голову попытками придумать что-то эдакое. В качестве референса я просто взял движения из ULTRAKILL и убрал скольжение. Всё остальное оставил: быстрое движение без бега, высокий и плавный прыжок, отскоки от стен, резкое падение вниз и рывок.
Приятным сигналом было получить негативные комментарии людей, которым не нравится ULTRAKILL в стиле “мувсет ультраговнища” – это значит что мне удалось попасть в ощущения оригинала.
Соответственно, для моего проекта было необходимо реализовать следующее:
Стандартное движение на WASD – высокая скорость без возможности перехода на бег. В воздухе движение пользователя не должно полностью выключаться – вместо этого нужно позволить пользователю сохранить контроль, но уменьшить влияние ввода.
Прыжки – обычный прыжок с сохранением инерции движения в момент прыжка.
Отскоки от стен – тоже самое, что и обычный прыжок, но отскоки от стен не бесконечные(иначе можно было бы взбираться по стенам на любую высоту). В качестве ограничения нужно задать количество отскоков до приземления. После приземления счётчик обнуляется и вновь можно прыгать по стенам. Также важно, чтобы отскок был направлен вверх и в противоположную от стены сторону, а не просто вверх.
Резкое падение – при нахождении в воздухе пользователь может вызвать резкое падение и приземлиться в нужный ему момент.
Рывок – резкое ускорение в направлении движения пользователя.
Существующие подходы
1. Character Controller
“The Unity Character Controller is a component designed for handling player movement and collision detection without directly relying on the physics engine's Rigidbody. It is primarily used for first-person or third-person character control in games where precise, non-physics-driven movement is desired.” – Официальная документация по Unity Character Controller.
Как следует из указанного, Character Controller является компонентом, который мы можем добавить на игровой объект. Управление осуществляется из кода методами Move и SimpleMove. Обработка столкновений происходит автоматически, но такой объект лишён физики – нам придётся самим писать логику для гравитации и столкновения с объектами Rigidbody.
Если не хотите заморачиваться с реализацией физики – не стоит выбирать этот вариант.
2. Изменение Transform.position
Наверное, самый плохой вариант из всех. Изменение позиции объекта буквально телепортирует его в нужную точку. При покадровой телепортации это выглядит как плавное движение, но стоит помнить, что это всё ещё телепортация. Это значит, что если движение будет слишком быстрым, то в какой то момент объект может оказаться в другом объекте. То есть, в момент очередной телепортации точка для телепортации может оказаться внутри коллайдера другого объекта. Это приведёт к выталкиванию объекта наружу. Иногда точка телепортации вообще может оказаться по другую сторону от преграды и объект не заметит её.
3. AddForce
Движение персонажа через добавление силы(AddForce) это, по сути, толкание объекта в нужном вам направлении с нужной вам силой. Минус очевиден из описания – меньший контроль. Такой тип движения – это применение определённой силы в определённом направлении к объекту с определённой массой. Это как если бы вы двигали машинку не схватив её рукой, а легонько толкая. Для хорошего контроля нужно понимание физики: различных ForceMode’ов и т.д. К тому же может потребоваться настройка физики в проекте для повышения точности симуляции. Я не стал выбирать этот вариант.
4. Изменение Rigidbody.linearVelocity
Rigidbody.linearVelocity – это вектор, представляющий скорость изменения позиции объекта (его скорость в пространстве). Он определяет, насколько быстро и в каком направлении движется объект в мировых координатах (единицы в секунду). linearVelocity используется для управления движением физических объектов с помощью компонента Rigidbody. Для своего проекта я выбрал именно этот вариант.
Реализация
Итак, определившись со способом передвижения мы можем приступить к реализации.
Для передвижения при помощи Rigidbody.linearVelocity понадобится создать игровой объект с компонентами Collider и Rigidbody. В качестве управляющего компонента создадим MonoBehaviour скрипт под названием PlayerMovement. Внутри игрового объекта создадим пустышку под названием CameraHolder и поместим внутрь камеру. При реализации поворота “головой” мы будем по отдельности вращать камеру и всего игрока. Тело будет вращаться вокруг оси Y, а камера вокруг оси X, так как телом мы будем вращать влево и вправо, а головой вверх и вниз.
Код
При описании кода я буду приводить только отдельные методы для передвижения, а в конце в общих чертах расскажу где можно вызывать эти методы и как можно получать ввод пользователя.
Повороты
Для вращения камеры используется следующий метод
private void CalculateView()
{
_newCameraRotation.x += _inputView.y * _playerSettings.verticalSensetivity *
Time.deltaTime * (_playerSettings.verticalInverted == true ? 1f : -1f);
_newCameraRotation.x = Mathf.Clamp(_newCameraRotation.x,
_playerConfigs.cameraVerticalRotateMin,
_playerConfigs.cameraVerticalRotateMax);
_newCaracterRotation.y += _inputView.x * _playerSettings.horisontalSensetivity *
Time.deltaTime * (_playerSettings.horisontalInverted == true ? -1f : 1f);
_cameraHolder.localRotation = Quaternion.Euler(_newCameraRotation);
transform.localRotation = Quaternion.Euler(_newCaracterRotation);
}
_newCameraRotation – это поле, в котором хранится текущий ввод пользователя с мышки в виде Vector2 для двух осей соответственно. Они отображают, насколько пользователь сдвинул мышь по высоте и ширине экрана.
verticalSensetivity и horisontalSensetivity – это два модификатора для увеличения ввода пользователя или, проще говоря, чувствительности мыши. Они хранятся в объекте ScriptableObject под названием playerSettings. Вместо ScriptableObject можно вынести все значения в переменные и менять их из инспектора, но я предпочитаю использовать SO для возможности создавать несколько вариантов настроек.
cameraVerticalRotateMin и cameraVerticalRotateMax – это ограничения для подъёма и опускания камеры. Хранятся в ScriptableObject под названием playerConfigs.
В конце методы мы по отдельности присваиваем повороты камере и телу персонажа.
Движения
private void CalculateMoveVelocity()
{
if (!_isDashNow)
{
Vector3 moveDirection = GetMoveDirrection();
float currentAcceleration = _isGrounded ? _playerConfigs.acceleration : _playerConfigs.airAcceleration;
if (!_isGrounded)
{
Vector3 currentVelocity = new Vector3(_rb.linearVelocity.x, 0, _rb.linearVelocity.z);
float maxAirSpeed = _playerConfigs.walkSpeed;
if (currentVelocity.magnitude > maxAirSpeed)
currentVelocity = currentVelocity.normalized * maxAirSpeed;
Vector3 airControl = moveDirection * (currentAcceleration * _playerConfigs.walkSpeedAirModif * Time.fixedDeltaTime);
Vector3 newVelocity = currentVelocity + airControl;
if (newVelocity.magnitude > maxAirSpeed)
newVelocity = newVelocity.normalized * maxAirSpeed;
newVelocity.y = _rb.linearVelocity.y;
_rb.linearVelocity = newVelocity;
}
else
{
Vector3 targetVelocity = moveDirection * _playerConfigs.walkSpeed;
Vector3 newVelocity = Vector3.MoveTowards(
new Vector3(_rb.linearVelocity.x, 0, _rb.linearVelocity.z),
targetVelocity,
currentAcceleration * Time.fixedDeltaTime
);
newVelocity.y = _rb.linearVelocity.y;
_rb.linearVelocity = newVelocity;
}
}
}
В данном методе мы ограничиваем расчёт движения при выполнении рывка, а внутри разделяем выполнение для случаев, когда мы на земле и когда мы в воздухе. Это самый большой метод из всех и его, наверное, стоило бы раздробить на два или даже три. Например, стоит вынести логику проверки условий и разбить расчёт движения на методы расчёта в воздухе и на земле. Так как движение происходит постоянно, данный метод будет вызываться в Update, как и метод CalculateView.
private Vector3 GetMoveDirrection()
{
Vector3 moveDirection = _cameraHolder.forward * _inputMovement.y + _cameraHolder.right * _inputMovement.x;
moveDirection.y = 0;
moveDirection.Normalize();
return moveDirection;
}
Этот метод используется для определения направления движения с учётом пользовательского ввода. Рассчитывается ввод по двум осям, обнуляется ось Y, а затем всё нормализуется для возвращения значения.
Прыжки
private void PerformJump()
{
bool canJump = (Time.time - _lastGroundedTime <= _coyoteTime) && (Time.time - _lastJumpTime >= _jumpCoyoteTime);
if (canJump)
Jump();
else
{
Vector3 _wallJumpVector;
if (CheckWallsAround(out _wallJumpVector) && _wallJumpWithoutGrounded < _playerConfigs.maxWallJumpsWithoutGrounded)
JumpWall(_wallJumpVector);
}
}
Метод выше, отвечающий за вызов прыжка, проводит все необходимые проверки, а затем вызывает нужный в данный момент метод. Для вызова прыжка от стены необходимо найти стену для отскока – этим занимается метод ниже:
private bool CheckWallsAround(out Vector3 wallNormal)
{
Collider[] hits = Physics.OverlapSphere(transform.position, 0.6f, _wallLayer);
foreach (var hit in hits)
{
if (Mathf.Abs(hit.transform.position.y - transform.position.y) < 0.5f)
continue;
wallNormal = (transform.position - hit.ClosestPoint(transform.position)).normalized;
return true;
}
wallNormal = Vector3.zero;
return false;
}
Два метода ниже отвечают соответственно за прыжок и прыжок от стены:
private void Jump()
{
_preJumpVelocity = new Vector3(_rb.linearVelocity.x, 0, _rb.linearVelocity.z);
_rb.linearVelocity = _preJumpVelocity + Vector3.up * _playerConfigs.jumpForce;
_lastJumpTime = Time.time;
}
private void JumpWall(Vector3 jumpVector)
{
jumpVector.y = _playerConfigs.wallJumpY;
_preJumpVelocity = new Vector3(_rb.linearVelocity.x, 0, _rb.linearVelocity.z);
_rb.linearVelocity = _preJumpVelocity + jumpVector * _playerConfigs.wallJumpForce;
_wallJumpWithoutGrounded += 1;
}
Рывок
private void Dash()
{
Vector3 dashVector = GetMoveDirrection();
if (dashVector.magnitude < 0.1f)
{
dashVector = _cameraHolder.transform.forward;
dashVector.y = 0;
}
_dashStartTime = Time.time;
_rb.linearVelocity = Vector3.zero;
_rb.AddForce(dashVector * _playerConfigs.dashForce, ForceMode.Impulse);
}
Рывок для мгновенного ускорения использует ForceMode.Impulse
ForceMode.Impulse — это режим применения силы в Unity, который мгновенно передает импульс объекту, что эквивалентно кратковременному удару. Этот режим учитывает массу объекта, поэтому для воздействия на более тяжелые объекты требуется приложить большую силу.
Падение
private void Fall()
{
_dashStartTime = 0;
_isDashNow = false;
_rb.linearVelocity = Vector3.zero;
_rb.AddForce(Physics.gravity * _playerConfigs.fallForce, ForceMode.Impulse);
}
Падение обнуляет таймер рывка и по сути выключает его. Соответствующий флажок помечается как false, а linearVelocity обнуляется, после чего персонаж резко отправляется вниз при помощи всё того же AddForce с ForceMode.Impulse и направлением равным направлению гравитации умноженном на силу падения.
Итог
В коде выше я намеренно не привожу части с вызовом указанных методов, так как это отдельная логика, которая может быть устроена по вашему усмотрению. Из кода так же были убраны ивенты и прочие вещи, которые не принимают участия в логике движения. Вы можете добавить дополнительные методы для проверки условий перед вызовом, а затем использовать New Input System от Unity для получения пользовательского ввода и его обработки. В статье также не показан метод CheckGround по всё тем же причинам: он не имеет прямой связи с методами движения и вы можете отслеживать состояние игрока по своему.
В общем, предложенные здесь методы для передвижения могут быть добавлены в игру и применяться в зависимости от ваших потребностей: используйте NewInputSystem или проверяйте ввод пользователя по старому, добавьте методы для валидации если это нужно. Можете написать собственный класс для работы с вводом и подписать перечисленные методы к соответствующим событиям – используйте эти решения для своих проектов.
Если вас заинтересовала статья или же вы просто хотите увидеть полный код моего контроллера персонажа – напишите в комментариях и я постараюсь выпустить вторую статью по теме с полным обзором и описанием подключения новой системы инпута для получения ввода.
Больше о моём проекте вы можете увидеть здесь:
https://t.me/UnityGameLab — в канале регулярно выходят обновления, а так же там полно видео с демонстрацией геймплея, на которых вы сможете лучше увидеть, как работает показанная в статье система движений.
Мне будет приятно если вы подпишитесь, чтобы следить за обновлениями по проекту.