Правильная реализация движения персонажа

Перед прочтением важно знать

  1. Хоть я здесь и разбираю в т.ч. нефизическое движение, я настоятельно рекомендую его НЕ использовать. И наоборот, я настоятельно рекомендую использовать ФИЗИЧЕСКОЕ движение.

  2. В коде не должно быть прямой привязки к кнопкам. Должна быть привязка к параметрам Input Manager . Которые вы с легкостью можете найти в:
Edit -> Project Settings -> Input
Это хороший тон так делать. Удобнее менять все настройки клавиш из одного внешнего места, а не из многих документов с кодом.
  1. Я буду использовать здесь 2 термина: "телепортация" и "плавное движение". В моем понимании:

    • Плавное движение - перерасчет позиции обьекта в рамках физики или паралельно физике на вызове FixedUpdate().

    • Телепортация - перерасчет позиции обьекта на промежутке времени большем чем fixedDeltaTime.

Есть люди у которых мнение отличается.

  • Плавное движение - исключительно физическое движение
  • Телепортация - изменение позиции вручную или использование .Translate() метода.

Учтите, что все что написано ниже упирается в верхние значения терминов, а не эти.


Двигать обьекты в игровых движках можно следующими способами:

На практике метод передвижения подбирается под конкретного персонажа[персонажа - не буквально. Это может быть и автомобиль]. В одном случае лучше будет физическое перемещение. В другом - нефизическое. В третьем случае будет лучше всего CharacterController. Понимание что лучше в каком случае прийдет с практикой.

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

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

Нужно запомнить всего одно правило: Двигать/поворачивать через присвоение transform.position/transform.rotation нельзя. Это порождает проблемы. Прям в любом случае это вам вылезет боком.

Все для чего нужно это - телепортация в другое место обьекта, но никак не его движение.

Пример правильной реализации движения:

( на примере обьекта-шара )

using UnityEngine;

//эта строчка гарантирует что наш скрипт не завалится 
//ести на плеере будет отсутствовать компонент Rigidbody
[RequireComponent(typeof(Rigidbody))]
public class Movement : MonoBehaviour
{
    public float Speed = 10f;
    public float JumpForce = 300f;

    //что бы эта переменная работала добавьте тэг "Ground" на вашу поверхность земли
    private bool _isGrounded;
    private Rigidbody _rb;

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

    // обратите внимание что все действия с физикой 
    // необходимо обрабатывать в FixedUpdate, а не в Update
    void FixedUpdate()
    {
        MovementLogic();
        JumpLogic();
    }

    private void MovementLogic()
    {
        float moveHorizontal = Input.GetAxis("Horizontal");

        float moveVertical = Input.GetAxis("Vertical");

        Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical);

        _rb.AddForce(movement * Speed);
    }

    private void JumpLogic()
    {
        if (Input.GetAxis("Jump") > 0)
        {
            if (_isGrounded)
            {
                _rb.AddForce(Vector3.up * JumpForce);

                // Обратите внимание что я делаю на основе Vector3.up 
                // а не на основе transform.up. Если персонаж упал или 
                // если персонаж -- шар, то его личный "верх" может 
                // любое направление. Влево, вправо, вниз...
                // Но нам нужен скачек только в абсолютный вверх, 
                // потому и Vector3.up
            }
        }
    }

    void OnCollisionEnter(Collision collision)
    {
        IsGroundedUpate(collision, true);
    }

    void OnCollisionExit(Collision collision)
    {
        IsGroundedUpate(collision, false);
    }

    private void IsGroundedUpate(Collision collision, bool value)
    {
        if (collision.gameObject.tag == ("Ground"))
        {
            _isGrounded = value;
        }
    }
}

Основой для этого скрипта послужил код взятый из: https://unity3d.com/learn/tutorials/projects/roll-ball-tutorial/moving-player

Обратите внимание что за основу взят код из официальной документации/туториалов по юнити. Если есть несколько источников информации по какому-то мелкому но часто задаваемому вопросу (например движение персонажа ) - выбирайте официальную документацию! Там точно фигни не посоветуют, в отличии от пестрящих дичью форумов, в т.ч. сервисе вопросов/ответов от юнити. Там в таких темах слишком часто пишут ответы те люди, которые понятия не имеют о правильном подходе.

Пример результата:


Связанные с темой понятия:

Есть Update() - этот метод вызывается на каждой прорисовке кадра. Time.DeltaTime- это расчетное время между прорисовкой двух кадров. Если FPS проседает на компьютере, то этот параметр возрастает пропорционально проседанию.

Есть FixedUpdate() - это метод который вызывается при перепросчете физики. Time.FixedDeltaTime, как вы уже догадались, это время между вызовами FixedUpdate(). Оно может изменятся вручную через настройки, но упирается в физические возможности машины на которой игра будет запущена.

Если обьект не обладает физическими свойствами (не имеет RigitBody) эти параметры и методы можно использовать для НЕфизического передвижения.

Например поворот камеры.

Или крутящийся куб на небосводе.

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

но, даже, в этом случае предпочтительно использовать Transform.Translate , но про это позже

Мы не получим дергающуюся картинку при проседании кадров если сделаем НЕФИЗИЧЕСКОЕ движение правильно:

ВАЖНО: !!!ПРИМЕР ВРЕДНОГО КОДА!!!! Не делайте так!

transform.position += transform.forward * speed * Time.deltaTime;

мы присваиваем в новую позицию:

  1. старую позицию
  2. направление движения
  3. скорость передвижения умноженную на Time.deltaTime.

Поэтому, даже, если, у нас было 60 кадров и случилось проседание до 10 кадров -- скорость вращения/движения обьекта не будет изменятся. Ведь, мы ее учитываем вместе с проседанием кадров.


Про физические свойства движения.

Допустим мы двигаем обьект через rb.Velocity или через AddForce(), то это физическое движение обьекта. То есть она может изменятся во времени сама под действием неких физических законов. Например, мы задумали сделать прыжок персонажа:

if (IsGrounded && Input.GetButtonDown("Jump"))
{
    rb.velocity = new Vector3(0, 100, 0);
}

Мы разово задаем вектор скачка. Только 1 долю секунды. Но он будет изменятся во времени автоматически равномерно уменьшаясь под силой тяжения. Пока не станет нулевым (верхняя точка прыжка), а потом не пойдет в минус по Y (падение), а потом не упадет на землю и не отскочит от нее (снова плюс по Y) и так до полной остановки физической скорости обьекта на земле.

Допустим, мы двигаем изменением transform.Positon нашего плеера вперед по нажатию клавиши "пробел". В какой-то момент мы перестаем нажимать кнопку -- движение резко остановится и замрет. Это потому, что наше движение НЕ является физическим. Допустим мы подойдем к стенке и попробуем пройти на нее. Т.к. мы занимаемся телепортацией обьекта, то наш персонаж сначала дойдет до стенки, а потом телепортируется ВНУТРЬ нее, после чего Collider ее вытолкнет из себя. Как глубоко телепортируется внутрь зависит лишь от того, на какое расстояние мы телепортируем нашего персонажа за кадр. То есть это "Bad Practice" так реализовать перемещение персонажа.

Но в то же время есть и допустимое не-физическое перемещение. Это использование метода Transform.Translate(). Это уже НЕ телепортация обьекта. Это - плавное перемещение обьекта (просто без учета физики). Но это не освобождает нас от использования deltaTime/fixedDeltaTime, как в примере оф.документации.

Если девайс с игрой сильно загружен, вызов методов Update()/FixedUpdate() тоже может просесть в скорости. И если в физике это учтено и без нас, то сейчас мы делаем НЕ физическое движение и именно по-этому это нужно учитывать добавлением даного множителя.

Но и без использования даного множителя у нас не появится проблем с провалами сквозь стены. Это просто фикс скорости.


Пример простой но хорошей НЕФИЗИЧЕСКОЙ реализации кода движения на примере персонажа.

Если в прошлом примере мы двигали шар, то было допустимо его толкать используя физ.модель. То есть мы использовали AddForce() для этих целей.

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

То есть когда мы пытаемся подвигать, наш персонаж падает (мы ж его толкаем, логично!). Когда он упал - он не может двигатся из-за силы трения. Зато мы можем двигать его в прыжке. :)

Давайте актуализируем этот код под даного персонажа. Мы заменим физический толчек обьекта на не-физическое, но ПЛАВНОЕ перемещение обьекта в пространстве:

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class Movement : MonoBehaviour
{
    // т.к. логика движения изменилась мы выставили меньшее и более стандартное значение
    public float Speed = 5f;

    public float JumpForce = 300f;

    //что бы эта переменная работала добавьте тег "Ground" на вашу поверхность земли
    private bool _isGrounded;
    private Rigidbody _rb;

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

    void FixedUpdate()
    {
        //обратите внимание что все действия с физикой 
        //желательно делать в FixedUpdate, а не в Update
        JumpLogic();

        // в даном случае допустимо использовать это здесь, но можно и в Update.
        // но раз уж вызываем здесь, то 
        // двигать будем используя множитель fixedDeltaTimе 
        MovementLogic();
    }

    private void MovementLogic()
    {
        float moveHorizontal = Input.GetAxis("Horizontal");

        float moveVertical = Input.GetAxis("Vertical");

        Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical);

        // что бы скорость была стабильной в любом случае
        // и учитывая что мы вызываем из FixedUpdate мы умножаем на fixedDeltaTimе
        transform.Translate(movement * Speed * Time.fixedDeltaTime);
    }

    private void JumpLogic()
    {
        if (Input.GetAxis("Jump") > 0)
        {
            if (_isGrounded)
            {
                // Обратите внимание что я делаю на основе Vector3.up а не на основе transform.up
                // если наш персонаж это шар - его up может быть в том числе и вниз и влево и вправо. 
                // Но нам нужен скачек только вверх! Потому и Vector3.up
                _rb.AddForce(Vector3.up * JumpForce);
            }
        }
    }

    void OnCollisionEnter(Collision collision)
    {
        IsGroundedUpate(collision, true);
    }

    void OnCollisionExit(Collision collision)
    {
        IsGroundedUpate(collision, false);
    }

    private void IsGroundedUpate(Collision collision, bool value)
    {
        if (collision.gameObject.tag == ("Ground"))
        {
            _isGrounded = value;
        }
    }
}

С этим кодом мы получим такой результат:

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


Но как же реализация на физике?

Да, можно подобное реализовать и на физике.

Наша прошлая версия скрипта имела несколько недостатков. А именно:

Вспомните уроки физкультуры, когда нужно было пробежать 30 метров вперед, взять палочку, пробежать 30 метров назад, положить палочку и еще раз 30 метров в другую сторону... Что случалось с бегуном в этот момент если посмотреть сбоку? Сначала скорость растет, потом достигает пика, а потом торможение, взятие палочки, бег в другую сторону -- снова возрастание скорости. Никаких резких скачков. Этого можно добится именно передвижением при помощи физики.

Давайте поместим на наш куб CapsuleCollider (минимальное торможение из-за силы трения) и заблочим в rigitBody rotateX и rotateZ(что б наш персонаж не падал на бок).

А потом нацепим на него вот этот скрипт:

    using UnityEngine;

    [RequireComponent(typeof(Rigidbody))]
    [RequireComponent(typeof(CapsuleCollider))]
    public class Movement : MonoBehaviour
    {
        public float Speed = 0.3f;
        public float JumpForce = 1f;

        //даем возможность выбрать тэг пола.
        //так же убедитесь что ваш Player сам не относится к даному слою. 

        //!!!!Нацепите на него нестандартный Layer, например Player!!!!
        public LayerMask GroundLayer = 1; // 1 == "Default"

        private Rigidbody _rb;
        private CapsuleCollider _collider; // теперь прийдется использовать CapsuleCollider
        //и удалите бокс коллайдер если он есть

        private bool _isGrounded
        {
            get {
                var bottomCenterPoint = new Vector3(_collider.bounds.center.x, _collider.bounds.min.y, _collider.bounds.center.z);

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

                //_collider.bounds.size.x / 2 * 0.9f -- эта странная конструкция берет радиус обьекта.
                // был бы обязательно сферой -- брался бы радиус напрямую, а так пишем по-универсальнее

                return Physics.CheckCapsule(_collider.bounds.center, bottomCenterPoint, _collider.bounds.size.x / 2 * 0.9f, GroundLayer);
                // если можно будет прыгать в воздухе, то нужно будет изменить коэфициент 0.9 на меньший.
            }
        }

        private Vector3 _movementVector
        {
            get
            {
                var horizontal = Input.GetAxis("Horizontal");
                var vertical = Input.GetAxis("Vertical");

                return new Vector3(horizontal, 0.0f, vertical);
            }
        }

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

            //т.к. нам не нужно что бы персонаж мог падать сам по-себе без нашего на то указания.
            //то нужно заблочить поворот по осях X и Z
            _rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ;

            //  Защита от дурака
            if (GroundLayer == gameObject.layer)
                Debug.LogError("Player SortingLayer must be different from Ground SourtingLayer!");
        }

        void FixedUpdate()
        {
            JumpLogic();
            MoveLogic();
        }

        private void MoveLogic()
        {
            // т.к. мы сейчас решили использовать физическое движение снова,
            // мы убрали и множитель Time.fixedDeltaTime
            _rb.AddForce(_movementVector * Speed, ForceMode.Impulse);
        }

        private void JumpLogic()
        {
            if (_isGrounded && (Input.GetAxis("Jump") > 0))
            {
                _rb.AddForce(Vector3.up * JumpForce, ForceMode.Impulse);
            }
        }
    }

Вы видите эту плавность, как будто человек бежит, останавливается, бежит в другую сторону? Красота!

А теперь вернитесь к прошлой гифке и присмотритесь... Движение совсем не такое :) Там как буд-то рукой двигают шахматную фигуру по доске.

Ну и описанные выше баги поведения были пофикшены с такой реализацией.

Можно добавить еще физический материал нашему персонажу и откоректировать его поведение.

Вообще улучшать реализацию можно до бесконечности. Но, думаю, основные проблемы СПОСОБОВ ПЕРЕДВИЖЕНИЯ с которыми вы столкнетесь, я затронул :)

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


Реализация нестандартной физики движений.

Одним из моих любимейших примеров нестандартной физики движения является игра Ori and the Blind Forest

https://www.youtube.com/watch?v=aKLxJTvaVy0

Такое перемещение/такие прыжки невозможно сделать на основе физики. Это делалось через нефизическое перемещение + костыли для получения нужных эфектов.

Сначала разрабатываются концепты движения. Они делаются в любом видеоредакторе с примитивными фигурами. Вот пример (если станет недоступным искать можно по Ori and the blind forest Enemy Concepts ) :

https://www.youtube.com/watch?v=A8cV-oJfsjk

Обратите внимание на то, то здесь прорисовано не только перемещение обьекта, но и его вытягивания/сжатия. Изменения формы во время любого взаимодействия с внешним миром. В т.ч. выстрелы так же влияют на форму. А так же что указываются радиусы опознавания главного героя каждым отдельным врагом.

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

Костыли для каждого персонажа/врага свои собственные. Это делается что бы каждый из них обладал своей уникальной физикой. Сделать это на общей физике навряд ли возможно.


Движение реализовано "правильно" но предмет все равно пролетает сквозь стену

ДАЖЕ если вы реализовали физическое передвижение вашего персонажа, все равно может случится такое, что просчет CollisionDetect может проходить с ошибками. Это редкость, но такое бывает иногда.

Для таких случаев есть настройки отвечающие за обработку CollisionDetect в настройках самого RigitBody.

Делать такое нежелательно т.к. это негативно сказывается на производительности. Так что считайте это, скорее, спасательным кругом, чем панацеей.