UNITY: Реализация движений для динамичного шутера. Часть 2

С момента прошлой статьи прошло достаточно много времени и я заметил одну интересную вещь: немало людей добавили статью в закладки, несмотря на небольшое число отметок “нравится”. Это убедило меня в полезности материала и потому я решил написать вторую часть

О чём?

В этой статье я продолжу тему мувсета, но дополню предыдущий материал схемой вызова методов, добавлением обработки пользовательского ввода и ивентов, которые позволят удобно отрабатывать нужные операции тогда, когда это будет необходимо: вызов звука при прыжке, покачивание камеры при падении и т.д

В данной статья я буду приводить код из предыдущей, но уже не буду его пояснять. Если вы не читали предыдущую статью – вперёд: UNITY: Реализация движений для динамичного шутера / Хабр

Прежде чем читать статью о реализации передвижения в игре было бы разумно сначала ознакомится с тем, как выглядит конечный результат. Ниже можете рассмотреть систему передвижения, реализуемую в этой статье. Ролик имеет немного более обширную тематику, демонстрируя и другой функционал, но в целом это должно дать понимание того, что вы получите, пройдя по каждому шагу этого туториала

Вызов методов

Методы

Кратко напомню, какие присутствуют методы в нашем коде и для чего они нужны:

CalculateView() – для вращения камеры.

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);
}

CalculateMoveVelocity() – для движения.

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;
        }
    }
}

GetMoveDirrection() – для определения направления движения с учётом пользовательского ввода.

private Vector3 GetMoveDirrection()
{
    Vector3 moveDirection = _cameraHolder.forward * _inputMovement.y + 
                            _cameraHolder.right * _inputMovement.x;
    moveDirection.y = 0;
    moveDirection.Normalize();
    return moveDirection;
}

PerformJump() – для обработки вызова прыжков.

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);
    }
}

CheckWallsAround() – для поиска стен, от которых возможен отскок.

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;
}

Jump() – для прыжка.

private void Jump()
{
    _preJumpVelocity = new Vector3(_rb.linearVelocity.x, 0, _rb.linearVelocity.z);
    _rb.linearVelocity = _preJumpVelocity + Vector3.up * _playerConfigs.jumpForce;
    _lastJumpTime = Time.time;
}

JumpWall() – для прыжка от стены.

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;
}

Dash() – для рывка.

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);
}

Fall() – для резкого падения.

private void Fall()
{
    _dashStartTime = 0;
    _isDashNow = false;

    _rb.linearVelocity = Vector3.zero;
    _rb.AddForce(Physics.gravity * _playerConfigs.fallForce, ForceMode.Impulse);
}

Помимо этих методов, нам так же понадобится реализовать какой то CheckGround() для определения наличия или отсутствия земли под ногами игрока.

private void CheckGround()
{
    bool wasGrounded = _isGrounded;
    _isGrounded = Physics.Raycast(transform.position, Vector3.down, _rayLength, _groundLayer);

    if (_isGrounded)
    {
        _lastGroundedTime = Time.time;
        _wallJumpWithoutGrounded = 0;
    }

    if (wasGrounded != _isGrounded && _isGrounded)
        landingEvent?.Invoke();
}

Описанный выше метод обновляет состояние isGrounded и вызывает соответствующее событие landingEvent в случае, если игрок только что приземлился. Если если wasGrounded и isGrounded одинаковы и игрок находится на земле, значит на момент вызова метода игрок уже находился на земле какое то количество кадров и данный кадр не был первым. Таким образом данный метод будет вызываться только если игрок коснулся земли и только если это первый кадр, в котором игрок касается земли после периода падения.

Обновление статуса рывка также вынесено в отдельный метод и зависит оно от значения продолжительности рывка в конфиге персонажа

private void CalculateDashStatus()
{
    if (_dashStartTime + _playerConfigs.dashTime >= Time.time) 
        _isDashNow = true;
    else
        _isDashNow = false;
}

Для оказания влияния на обычную игровую гравитацию из кода был написан следующий метод, который не является обязательным для функционирования системы, но думаю, его стоит привести в статье:

private void HandleGravity()
{
    if (!_isGrounded && !_isDashNow)
        _rb.AddForce(Physics.gravity * _gravityMultiplier, ForceMode.Acceleration);
}

Если игрок не находится на земле и не находится в рывке, то на него будет оказываться сила при помощи соответствующего метода AddForce.

Вызов

Вызов описанных методов происходит следующим образом:

private void FixedUpdate()
{
    CheckGround();
    HandleGravity();
}

private void Update()
{
    CalculateView();
    CalculateDashStatus();
    CalculateMoveVelocity();
}

В целом, если изменить HandleGravity(), добавив в него учитывание Time.deltaTime, то можно вообще все методы переместить в Update(), но я для себя оставил так.

Данные методы обрабатывают данные, но не отвечают за получение этих данных – они лишь работают с тем, что есть. Получение данных от пользователя мы рассмотрим в следующем разделе.

Ввод пользователя

NewInputSystem

Новая система ввода Unity (Input System) — это современная, гибкая и кроссплатформенная замена старому менеджеру ввода, позволяющая декларативно связывать действия (например, "Прыжок") с различными устройствами (клавиатура, геймпад, сенсор) через Input Actions, что упрощает управление для разных платформ и интегрируется с новыми технологиями вроде UI Toolkit, предлагая генерацию C# скриптов для чистой логики. 

Ключевые особенности:

  • Карты действий (Action Maps): Группы действий, по типу “прыжок”, “атака” и т.д.

  • Действия (Actions): Абстрактные действия, которые мы создаём в карте действий.

  • Привязки (Bindings): Каждое абстрактное действие мы привязываем к конкретным кнопкам.

Почему это полезно? 

  • Абстрагирование от ненужных деталей. Мы создаём события без конкретного указания того, что нажмёт пользователь, как долго он будет удерживать клавишу и т.д. Это позволяет нам привязать действие в коде к событию, не описывая логику обработки клика.

  • Кроссплатформенность. Такой подход позволяет нам не задумываться заранее о конкретной платформе и соответствующих ей способах ввода. Мы можем легко поменять привязки к событиям в настройках NewInputSystem, полностью изменив способ ввода под другую платформу.

После настройки всей схемы ввода нам необходимо будет сгенерировать C# класс при помощи объекта InputActionAsset, который появится с установкой NewInputSystem.

Если в вашем проекте нет новой системы ввода, то обязательно скачайте Input System в Package Manager в Unity.

Настройка новой системы ввода

Для создания схемы ввода нам понадобится одна карта, которая создаётся в настройках NewInputSystem(Edit > Project Settings > InputSystemPackage). Здесь нужно создать карту с помощью знака “+”.

InputSystemPackage
InputSystemPackage

Точно также нам необходимо создать набор действий и привязки к ним. 

Нам понадобится создать действия для движения в двух плоскостях, вращения камеры, прыжка, падения и рывка.

Для прыжка, падения и рывка всё довольно просто – эти действия вызываются в момент нажатия и сразу же выполняются разово. События для таких действий будут иметь тип Button и просто сразу же сигнализировать соответствующим методам без передачи данных.

Движение и поворот сложнее, ведь это действия, выполняемые постоянно, каждый кадр и зависят они от величины ввода. То есть, даже если пользователь не двигается, метод движения всё равно выполняется, но для нулевого ввода. То есть происходит движение с нулевой скоростью, что тоже самое, что и отсутствие движения. В связи с этим логика будет следующая – методы поворота и движения остануться в Update, но данные для них будут получаться в отдельных методах и устанавливаться в переменные, которые используются методами движения и поворота. Данные ввода будут передаваться в момент, когда ввод будет совершаться, а данные будут представлять величину, на которую сменилось значение. Таким образом для передвижения мы установим Controle Type как Vector2, а Action Type как Pass Through. 

Привязки для методов прыжка, рывка и падения могут быть любые, а вот для поворота это должна быть мышь. Для этого нужно добавить привязку как Binding и установить Delta [Mouse]. С движением немного сложнее, так как оно осуществляется несколькими клавишами. Здесь мы используем Composite и устанавливаем нужные нам клавиши.

Character ActionMap
Character ActionMap

Закончив с настройкой нам необходимо открыть InputActionAsset и поставить в нём галочку напротив пункта Generate C# Class, после чего нажать Apply.

InputActionAsset
InputActionAsset

После этого в папке Assets появится новый скрипт. Его не нужно размещать на сцене или что-то ещё с ним делать. Теперь мы можем создавать в коде его экземпляры и пользоваться его событиями для привязки методов к ним.

Единая точка ввода

Создавать экземпляр данного класса везде, где нам нужно получение ввода не очень правильно и удобно. Наиболее верным решением будет создание отдельного класса, который можно разместить на сцене и в котором будет создаваться соответствующий экземпляр. Именно этот класс будет единой точкой, из которой все остальные будут получать ссылку на один единственный экземпляр.

Моё решение выглядит следующим образом:

using UnityEngine;

public class PlayerInputSystem : MonoBehaviour
{
    private InputSystem_Actions _actions;
    public InputSystem_Actions Actions {  get { return _actions; } }


    private void Awake()
    {
        _actions = new InputSystem_Actions();
        _actions.Enable();
    }
}

Можно сделать данный класс синглтоном или использовать Zenject для инъекции зависимостей везде, где понадобится данный класс – это решать вам. В данной статье я стараюсь исходить из того, что читатель не знает ни о паттернах, ни о Zenject.

Подписка на события

Новая система ввода предоставляет возможность подписываться на созданные в InputSystemPackage действия. Данные будут передаваться при помощи InputAction.CallbackContext, из которого мы сможем вытащить всё что нужно соответствующими методами, например, ReadValue<Vector2>().

Для получения сигнала о событиях и для обработки данных были созданы дополнительные методы:

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 void PerformDash()
{
    if (_playerDashEnergy.Dash())
        Dash();
}

private void PerformSlideOrFall()
{
    if (!_isGrounded)
        Fall();
}

Данные методы не принимают никаких данных, поэтому для их привязки пришлось использовать анонимные методы. Можно использовать перегрузку методов и создать одноименные с новой сигнатурой для обработки информации, но так как данных не передается, я оставил код в таком виде.

В моём случае привязка происходит в методе Start():

private void Start()
{
    _rb = GetComponent<Rigidbody>();

    fallEvent.AddListener(() => { _isFallNow = true; });
    landingEvent.AddListener(() => {
        if (_isFallNow)
            landingAfterFallEvent?.Invoke();
        _isFallNow = false; });

    _playerInputSystem.Actions.Character.Movement.performed += (e) =>
    {
        _inputMovement = e.ReadValue<Vector2>();
    };
    _playerInputSystem.Actions.Character.View.performed += (e) => _inputView = e.ReadValue<Vector2>();
    _playerInputSystem.Actions.Character.Jump.performed += (e) => PerformJump();
    _playerInputSystem.Actions.Character.Dash.performed += (e) => PerformDash();
    _playerInputSystem.Actions.Character.SlideOrFall.performed += (e) => PerformSlideOrFall();
}

События

Для удобной привязки методов к тем или иным событиям были использованы следующие UnityEvent’ы:

public UnityEvent jumpEvent = new UnityEvent();
public UnityEvent wallJumpEvent = new UnityEvent();
public UnityEvent dashEvent = new UnityEvent();
public UnityEvent fallEvent = new UnityEvent();
public UnityEvent landingEvent = new UnityEvent();
public UnityEvent landingAfterFallEvent = new UnityEvent();
public UnityEvent<Vector2> inputMovementUpdateEvent = new UnityEvent<Vector2>();

Каждое событие вызывается в соответствующем методе, например, dashEvent в методы рывка, fallEvent в методе падения, а приземление в CheckGround(), с предварительной проверкой того, что прикосновение к земле было первым после периода падения. 

Полный код занимает 265 строк без попыток сэкономить за счет читаемости. 

Итог

В коде выше я привёл весь набор необходимых скриптов и описал логику их работы. Описанной выше системы достаточно для того чтобы воспроизвести продемонстрированную ранее систему движения.

Если вас заинтересовала статья или же вы просто хотите увидеть больше контента по проекту – напишите в комментариях и я постараюсь выпустить новую статью по интересной вам теме.

Больше о моём проекте вы можете увидеть здесь: https://t.me/UnityGameLab

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

Информация на этой странице взята из источника: https://habr.com/ru/articles/983244/