Скриншот "CREATURES"
Скриншот "CREATURES"

Вступление

Уже какое то время я разрабатываю свой 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 — в канале регулярно выходят обновления, а так же там полно видео с демонстрацией геймплея, на которых вы сможете лучше увидеть, как работает показанная в статье система движений.

Мне будет приятно если вы подпишитесь, чтобы следить за обновлениями по проекту.