С момента прошлой статьи прошло достаточно много времени и я заметил одну интересную вещь: немало людей добавили статью в закладки, несмотря на небольшое число отметок “нравится”. Это убедило меня в полезности материала и потому я решил написать вторую часть
О чём?
В этой статье я продолжу тему мувсета, но дополню предыдущий материал схемой вызова методов, добавлением обработки пользовательского ввода и ивентов, которые позволят удобно отрабатывать нужные операции тогда, когда это будет необходимо: вызов звука при прыжке, покачивание камеры при падении и т.д
В данной статья я буду приводить код из предыдущей, но уже не буду его пояснять. Если вы не читали предыдущую статью – вперёд: 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
Мне будет приятно если вы подпишитесь, чтобы следить за обновлениями по проекту.
