С момента прошлой статьи прошло достаточно много времени и я заметил одну интересную вещь: немало людей добавили статью в закладки, несмотря на небольшое число отметок “нравится”. Это убедило меня в полезности материала и потому я решил написать вторую часть
О чём?
В этой статье я продолжу тему мувсета, но дополню предыдущий материал схемой вызова методов, добавлением обработки пользовательского ввода и ивентов, которые позволят удобно отрабатывать нужные операции тогда, когда это будет необходимо: вызов звука при прыжке, покачивание камеры при падении и т.д
В данной статья я буду приводить код из предыдущей, но уже не буду его пояснять. Если вы не читали предыдущую статью – вперёд: 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). Здесь нужно создать карту с помощью знака “+”.
Точно также нам необходимо создать набор действий и привязки к ним.
Нам понадобится создать действия для движения в двух плоскостях, вращения камеры, прыжка, падения и рывка.
Для прыжка, падения и рывка всё довольно просто – эти действия вызываются в момент нажатия и сразу же выполняются разово. События для таких действий будут иметь тип Button и просто сразу же сигнализировать соответствующим методам без передачи данных.
Движение и поворот сложнее, ведь это действия, выполняемые постоянно, каждый кадр и зависят они от величины ввода. То есть, даже если пользователь не двигается, метод движения всё равно выполняется, но для нулевого ввода. То есть происходит движение с нулевой скоростью, что тоже самое, что и отсутствие движения. В связи с этим логика будет следующая – методы поворота и движения остануться в Update, но данные для них будут получаться в отдельных методах и устанавливаться в переменные, которые используются методами движения и поворота. Данные ввода будут передаваться в момент, когда ввод будет совершаться, а данные будут представлять величину, на которую сменилось значение. Таким образом для передвижения мы установим Controle Type как Vector2, а Action Type как Pass Through.
Привязки для методов прыжка, рывка и падения могут быть любые, а вот для поворота это должна быть мышь. Для этого нужно добавить привязку как Binding и установить Delta [Mouse]. С движением немного сложнее, так как оно осуществляется несколькими клавишами. Здесь мы используем Composite и устанавливаем нужные нам клавиши.
Закончив с настройкой нам необходимо открыть InputActionAsset и поставить в нём галочку напротив пункта Generate C# Class, после чего нажать Apply.
После этого в папке 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
Мне будет приятно если вы подпишитесь, чтобы следить за обновлениями по проекту.