Автор Тема: Seaman - Управление камерой и персонажем. (Цикл уроков по Unity3d)  (Прочитано 23072 раз)

0 Пользователей и 1 Гость просматривают эту тему.

Май 28, 2015, 11:41:15 am
Прочитано 23072 раз

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
(Written by Seaman)

3rd CharController

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

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

 Итак. Что нам нужно.

 Организовать ввод с клавиатуры. Преобразовать введенные данные в удобные для перемещения в трехмерном пространстве.
 Организовать ввод с мышки. На основании этих данных перемещать камеру вокруг персонажа.
 На основе данных пункта 1 произвести собственно перемещение персонажа. На основании данных пункта 2 при необходимости произвести поворот персонажа.

 Соответственно этим трем пунктам у нас будет три класса - CharController, CharCamera и CharMotor. Разберем первый подробнее.

 Что он должен делать? Основные функции:

 Получить данные с клавиатуры.
 Построить из них вектор движения.
 Передать этот вектор классу CharMotor.

 Дополнительные функции:

 Начальный поиск камеры.

 Какие нам нужны данные в этом классе?

 Результат - вектор движения.
 Ссылка на компонент - CharacterController, который должен быть на персонаже, т..к. движение персонажа на этом этапе мы будем осуществлять именно с его помощью.
 Ссылка на себя - на класс CharController. Для чего это нужно? Это поле мы сделаем статическим, т..е. оно будет доступно из любого скрипта в сцене. Тем самым мы просто облегчим себе взаимодействие наших скриптов.

 Какие методы будут у нас?

 Awake() - Этот метод вызывается сразу после того как наш класс появится в сцене. Здесь нужно производить первоначальные установки. У нас здесь мы запомним ссылку на себя, найдем ссылку на CharacterController и попросим у класса камеры найти камеру в сцене.
 Update() - Этот метод вызывается каждый кадр в игре. Здесь в-общем то и производится все действие. Сначала мы тут проверим существование камеры - если она вдруг исчезла прекращаем работу. Затем получаем данные с клавиатуры и вычисляем вектор движения. И напоследок говорим CharMotor что можно двигать персонаж.
 GetInput() - Чтобы не загромождать метод Update (тем более, что в будущем он будет делать гораздо больше) вынесем получение данных с клавиатуры и рассчет вектора движения в отдельный метод.

 Рассмотрим подробнее третий метод - GetInput().

 Как можно получить данные введенные игроком? Можно опрашивать конкретные клавиши методом Input.GetKeyUp. Однако это очень неуниверсальный метод. Есть другой способ - с использованием Input.GetAxis. Он выдает значение положения по виртуальной оси в диапазоне [-1, 1] и не зависит от устройства ввода, т.е. его можно использовать с клавиатурой, джойстиком или даже с мышкой, если понадобится. Однако при этом появляется неприятный эффект дрожания вблизи нулевых значений. Можно это предусмотреть введя некую переменную "мертвую зону" и производя любые действия, если введенные игроком изменения больше ее значения.

 Названия виртуальных осей метода Input.GetAxis - Vertical и Horizontal. Обычно они соответствуют движениям персонажа вперед/назад и влево/вправо соответственно. Поэтому значение по оси Vertical преобразуем в движение по оси Z, а Horizontal - X.

 Итак сначала мы обнуляем вектор движения.
 Затем проверяем вышли ли введенные значения из мертвой зоны, и если вышли прибавляем их к вектору движения.

 Теперь у нас все готово, чтобы написать наш класс CharController:
using UnityEngine;


class CharController : MonoBehaviour
{

   //Ссылка на компонент - CharacterController
   public static CharacterController unityController;

   //Ссылка на себя - на класс CharController
   public static CharController instance;

   //Результат - вектор движения.
   public Vector3 move;

   //Размер мертвой зоны
   private const float _DEAD_ZONE = 0.1f;


   void Awake()
   {

     //Запоминаем ссылку на себя
     instance = this;

     //Находим компонент - CharacterController
     unityController = GetComponent("CharacterController") as CharacterController;

     //Просим у класса камеры найти камеру в сцене
     CharTPSCamera.GetCamera();

   }

 
   void Update()
   {

     //Если камеры нет - ничего не делаем
     if(Camera.mainCamera == null) return;

     //Обрабатываем введенные игроком данные
     GetInput();

     //Говорим CharMotor, что пора двигаться
     CharMotor.instance.UpdateMotor(move);

   }

 

 private void GetInput()
 {

     CharMotor chM = CharMotor.instance;

     //На сколько сместились по "вертикали" (т.е. вперед/назад)
     float vert = Input.GetAxis("Vertical");

     //На сколько сместились по "горизонтали" (т.е. влево/вправо)
     float horiz = Input.GetAxis("Horizontal");

 
    //Обнуляем вектор движения
    move = Vector3.zero;

    //Если смещение по "вертикали" вышло из мертвой зоны
    if (vert > _DEAD_ZONE || vert < -_DEAD_ZONE)

      //Прибавляем к вектору движения это смещение
      move += new Vector3(0, 0, vert);

    //Если смещение по "горизонтали" вышло из мертвой зоны
    if (horiz > _DEAD_ZONE || horiz < -_DEAD_ZONE)

      //Прибавляем к вектору движения это смещение
      move += new Vector3(horiz, 0, 0);

   }
}
« Последнее редактирование: Май 28, 2015, 23:24:53 pm от Mimi Neko »

Май 28, 2015, 11:48:05 am
Ответ #1

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Класс CharMotor


Следующий класс, который мы рассмотрим - CharMotor.

Основные функции класса.
Что нам выдал класс CharController? Вектор движения вперед/назад, налево/направо. Однако наш персонаж может поворачиваться, а этот вектор показывает только смещение относительно самого персонажа. Поэтому первое, что нам нужно сделать, чтобы переместить персонаж - это преобразовать вектор движения в мировые координаты с учетом поворота персонажа.
Длина вектора движения нам не нужна - нам нужно только направление движения. Поэтому мы нормализуем его.
Теперь мы умножим получившийся вектор на скорость персонажа.
Далее. Этот метод мы вызываем из метода Update класса CharController. Метод Update вызывается движком раз в кадр, нам же надо так пересчитать движение, чтобы скорость, на которую мы умножили можно было бы указывать в метрах в секунду. Для этого нужно знать время прошедшее с предыдущего вызова. Это в Юнити обеспечивает Time.deltatime.
Теперь у нас все готово, чтобы сместить персонаж. Для собственно движения применим метод Move из компонента CharacterController.

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

Что нам нужно?

Переменные.
Ссылка на класс CharMotor.
Скорость движения персонажа

Методы.
Awake() - здесь мы просто присваиваем соответствующей переменной ссылку на сам класс CharMotor.
UpdateMotor(Vector3 move) - Это не стандартный метод Юнити Update()! Это иной метод, который мы самостоятельно вызываем из класса CharController. Здесь мы просто вызываем последовательно два метода - _ProcessMotion(Vector3 moveVector) и _RotateChar(Vector3 move).
_ProcessMotion(Vector3 move) - здесь собственно и происходит все действо - все то что описано выше в пунктах 1-5.
_RotateChar() - здесь проверяем движется ли персонаж, и если движется - поворачиваем его по направлению камеры.

using UnityEngine;


public class CharMotor : MonoBehaviour
{

    public static CharMotor instance;
    public float speed = 10.0f;

 
    void Awake()
    {
       instance = this;
    }


    public void UpdateMotor(Vector3 move)
    {
       _RotateChar(move);
       _ProcessMotion(move);
    }


    private void _ProcessMotion(Vector3 moveVector)
    {

       //Преобразуем вектор движения в мировое пространство.
       moveVector = transform.TransformDirection(moveVector);

       //Нормализуем вектор движения
       Vector3.Normalize(moveVector);

       //Применяем скорость персонажа
       moveVector *= speed;

       //Переходим от кадров к секундам
       moveVector *= Time.deltaTime;

       //Двигаем!
      CharController.unityController.Move(moveVector);
    }


    private void _RotateChar(Vector3 move)
    {
       //Проверяем - двигается ли персонаж?
       if(move.x != 0 || move.z != 0)
      {

         //Если двигается - уставливаем его поворот в соответствии с поворотом камеры. Т.к. это нужно сделать только вокруг оси Y,
         //а вокруг X и Z оставить неизменным, то используем метод, который конструирует вращение из трех углов
         transform.rotation = Quaternion.Euler(transform.eulerAngles.x, Camera.mainCamera.transform.eulerAngles.y, transform.eulerAngles.z);
       }
    }
}

 - Вот и все. Далее рассмотрим класс камеры. Он посложнее, возможно уместится в 2 части...
« Последнее редактирование: Май 28, 2015, 12:03:36 pm от Mimi Neko »

Май 28, 2015, 11:56:38 am
Ответ #2

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Класс CharTPSCamera


Ну вот подошли к последнему классу - CharTPSCamera.

TPS здесь означает, что камера от третьего лица, в отличие от FPS - от первого лица.

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

Вторичные функции класса.
Найти камеру в сцене.
Если камеры нет  создаем свою.
Захватываем управление камерой.

Сначала рассмотрим что проще - вторичную функциональность.
В Awake() нам нужно будет сохранить ссылку на себя.
В специальном методе: ищем главную камеру в сцене
Если нашли - используем ее - временная камера это главная камера
Если не нашли - создаем новую камеру
Создаем GameObject "MainCamera" - временная камера
Добавляем ей компонент "Camera"
Устанавливаем тег "MainCamera"
Добавляем этот класс CharTPSCamera как компонент к временной камере
Заменяем ссылку,которую сохранили в Awake на этот добавленный компонент CharTPSCamera
Ищем в сцене объект targetLookAt - это объект, на который все время будет смотреть камера
Если не нашли создаем его в начале координат.
Присваиваем соответствующей переменной компонента CharTPSCamera этот целевой объект.

using UnityEngine;

 
class CharTPSCamera : MonoBehaviour
{

    public static CharTPSCamera instance;
    public Transform target;

 
    void Awake()
    {
        instance = this;
    }

 
    void Start()
    {

    }

 
    static public void GetCamera()
    {

        GameObject tempCamera;
        GameObject targetTemp;
        CharTPSCamera myCamera;

        if (Camera.mainCamera != null) tempCamera = Camera.mainCamera.gameObject;
        else
        {

            tempCamera = new GameObject("Main Camera");
            tempCamera.AddComponent("Camera");
            tempCamera.tag = "Main Camera";

        };

        tempCamera.AddComponent("CharTPSCamera");
        myCamera = tempCamera.GetComponent<CharTPSCamera>();
        targetTemp = GameObject.Find("targetLookAt");

        if(targetTemp == null)
        { 
            targetTemp = new GameObject("targetLookAt");
            targetTemp.transform.position = Vector3.zero;
        }

        myCamera.target = targetTemp.transform;
    }
}

В следующей части начнем разбор основной функциональности класса камеры...
« Последнее редактирование: Май 28, 2015, 12:02:32 pm от Mimi Neko »

Май 28, 2015, 12:16:20 pm
Ответ #3

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Класс CharTPSCamera Часть2


Продолжим рассмотрение класса камеры. Переходим к основной функциональности класса.
Сначала нужно решить - где мы будем проводить все перемещения камеры. Можно сделать аналогично CharController в методе Update, который вызывается каждый кадр. Однако лучше это сделать в аналогичном методе LateUpdate. Этот метод тоже вызывается каждый кадр. Отличие в том, что он вызывается после всех методов Update.
Почем лучше сделать в этом методе? Потому что это гарантирует, что в этом кадре персонаж уже переместился в новую позицию, и камера будет смотреть именно на него. Если бы мы сделали в Update, то неизестно что вызвалось бы первым - смещение персонажа или смещение камеры. Тогда камера могла бы смотреть в то место где был персонаж перед этим кадром.
Что мы делаем в этом методе.
Сначала проверяем - есть ли еще target. Поскольку target - публичная переменная, она может стать равной null. В этом случае мы просто ничего не делаем.
Далее последовательно вызываем метод для обработки ввода с мыши - PlayerInput, метод для расчета желаемой позиции камеры по данным PlayerInput и метод для собственно смещения камеры.
Внутренность этих методов разберем позже а пока нужно установить все параметры в значения по умолчанию. Лучше всего это сделать в методе Start.

Какие переменные нам нужно устанавливать в начальное состояние?
Переменная Distance - это расстояние от камеры до персонажа. Она должна быть ограничена снизу и сверху некими значениями, чтобы камера не улетела в бесконечность и не вошла внутрь персонажа. Т.е. нужно ввести постоянные - минимальное значение Distance и максимальное значение Distance. Изначально устанавливаем камеру на расстояние определяемое еще одной постоянной - стартовая дистанция. Т.к. эти константы приватные, то проверок попадает ли это  стартовое значение внутрь диапазона [мин - макс] мы не делаем. Это Вы при задании этой константы должны сделать.
Переменные, определяtмые вводом с мыши по координатам X и Y. Это по сути угла поворота вокруг соответствующих осей. Изначально X равен 0 - т.е. камера прямо за персонажем, а Y равен 10, т.е. камера чуть выше персонажа.
Переменная - желаемое расстояние от камеры до персонажа. Помните, мы говорили, что хотим, чтобы камера перемещалась плавно, а не мгновенно? Вот именно для этого и введена эта переменная. Сначала рассчитывается она в зависимости от управления мышкой, затем плавно камера перемещается в это положение. Начальное ее значение равно Distance, т.к. с мыши мы еще ничего не вводили.
Итак что нам нужно для всего этого.
Переменые:
Distance, _mouseX, _mouseY, _desireDistance.
Константы:
_DISTANCE_MIN, _DISTANCE_MAX, _START_DISTANCE
Методы:
Start, LateUpdate, Reset
Зачем мы вынесли отдельно метод Reset?- чтобы можно было при необходимости обнулить состояние камеры извне.
Все готово для написания кода. Получаем:

using UnityEngine;


class CharTPSCamera : MonoBehaviour
{

    public static CharTPSCamera instance;
    public Transform target;
    public float Distance { get; set; }

 
    //Минимальное расстояние от камеры до персонажа.
    private const float _DISTANCE_MIN = 2f;

    //Начальное расстояние от камеры до персонажа. У меня равно минимальному. Вы можете выбрать любое от минимального до максимального.
    private const float _START_DISTANCE = _DISTANCE_MIN;

    //Максимально расстояние от камеры до персонажа.
    private const float _DISTANCE_MAX = 12f;

    //Значение смещения мыши по оси X.
    private float _mouseX;

    //Значение смещения мыши по оси Y.
    private float _mouseY;

    //Рассчитанное желаемое расстояние от камеры до персонажа.
    private float _desireDistance;

 
    void Awake()
    {
        instance = this;
    }

 
    void Start()
    {
        //Вызываем метод, устанавливающий начальные значения переменных.
        Reset();
    }

 
    void LateUpdate()
    {
        //Проверяем не исчезла лицель камеры, если исчезла ничего не делаем.
        if (target == null) return;

        //Вводим данные с мыши.
        PlayerInput();

        //Рассчитываем желаемую позицию камеры.
        CalcDesirePosition();

        //Смещаем камеру.
        UpatePosition();
    }

 
    private void PlayerInput()
    {

    }

 
    private void CalcDesirePosition()
    {

    }

 
    private Vector3 CalcPosition(float rotx, float roty, float distance)
    {

    }

 
    public void Reset()
    {
        //Обнуляем данные, введенные с мыши.
        _mouseX = 0;
        _mouseY = 10f;

        //Расстояние от камеры до персонажа равно стартовому.
        Distance = _START_DISTANCE;

        //Желаемое расстояние тоже равно стартовому.
        _desireDistance = Distance;
    }

 
    private void UpatePosition()
    {

    }


    static public void GetCamera()
    {
        GameObject tempCamera;
        GameObject targetTemp;
        CharTPSCamera myCamera;

 
        if (Camera.mainCamera != null) tempCamera = Camera.mainCamera.gameObject;
        else
        {
            tempCamera = new GameObject("Main Camera");
            tempCamera.AddComponent("Camera");
            tempCamera.tag = "Main Camera" ;
        }

        tempCamera.AddComponent("CharTPSCamera");
        myCamera = tempCamera.GetComponent<CharTPSCamera>();

        targetTemp = GameObject.Find("targetLookAt");
        if(targetTemp == null)
        {
            targetTemp = new GameObject("targetLookAt");
            targetTemp.transform.position = Vector3.zero;
        }

        myCamera.target = targetTemp.transform;
    }
}

В следующем уроке мы рассмотрим вод данных с мышки в классе камеры...

Май 28, 2015, 12:28:43 pm
Ответ #4

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Класс CharTPSCamera Часть3


Продолжаем рассматривать класс камеры. Следующее что мы обсудим - обработка ввода с мыши. Это "внутренности" метода PlayerInput.
Вращение камеры вокруг персонажа будем производить только если нажата правая кнопка мыши. Т.е. сначала проверяем - нажата ли она. Если нажата получаем данные о смещении мыши и обрабатываем их.
Смещение мыши по горизонтали будет определять поворот камеры вокруг оси Y, а смещение по вертикали - поворот вокруг оси X. Однако эту "смену" осей мы учтем позже, а пока будем рассчитывать смещение мыши по соответствующей оси. Смещение мыши может быть достаточно небольшим, поэтому введем постоянные - коэффициенты чувствительности мыши по осям X и Y и чувствительность вращения колесика мышки. Значения, полученные от мыши просто умножим на эти постоянные.
Вращение камеры по вертикали ограничим. Если этого не сделать, то возможен случай, когда камера повернется по вертикали воруг персонажа и будет смотреть ему в "лицо". При этом она окажется перевернута вверх ногами. Ясно это можно понять из рисунка:


В положении 1 камера сзади и нормально повернута. Если мы ее повернем по вертикали до положения 2 она окажется перед персонажем и будет вверх ногами...
Угол поворота полученный с мышки вообще говоря может быть любым, в том числе и более +-360 градусов. Угол поворота 361 градус эквивалентен углу в 1 градус. Обычный метод ограничения Clamp этого не учитывает. Поэтому нам нужно написать свой метод ограничения угла, который сначала приводит угол в диапазон [-360;360] градусов, а уже затем вызывает Clamp для его ограничения.
Метод делает довольно распространенные вычисления. Такие методы лучше делать не внутренними методами класса, а доступными из любого класса проекта. Это делается вводом нового класса Utils. Класс сделаем статическим. Такой класс существует с самого запуска программы и доступен любому классу проекта. В классе пока один метод - ClampAngle, который циклично проверяет если переданный угол меньше -360, то прибавляем к нему 360, если больше 360, то вычитаем из него 360. Делаем это до тех пор пока угол не будет в диапазоне [-360;360] градусов.
Далее получаем данные с колесика мышки. Ввод с него аналогичен вводу перемещения персонажа. Поэтому мы здесь также используем "мертвую зону" - диапазон изменения ввода с колесика, в котором ничего не делаем. Если же полученное значение вышло за пределы "мертвой зоны", то рассчитываем желаемую дистанцию вычитая полученное с колесика мышки значение из дистанции на текущий момент с учетом чувствительности колесика. И в конце ограничиваем желаемую дистанцию сверху и снизу.
Итак что нам нужно.
Константы: _X_MOUSE_SENSITIVITY, _Y_MOUSE_SENSITIVITY, _MOUSE_WHEEL_SENSITIVITY, _DEAD_ZONE, _Y_MIN_LIMIT, _Y_MAX_LIMIT
Класс: Utils и метод в нем: ClampAngle
Получаем код на данный момент:

using UnityEngine;

 
class CharTPSCamera : MonoBehaviour
{

    public static CharTPSCamera instance;
    public Transform target;

    public float Distance { get; set; }

    private const float _START_DISTANCE = 2f;
    private const float _DISTANCE_MIN = _START_DISTANCE;
    private const float _DISTANCE_MAX = 12f;

    private float _mouseX;
    private float _mouseY;

    private float _desireDistance;

 
    //Константы - чувствительность мыши в горизонтальном, вертикальном направлениях и чувствительность колесика
    private const float _X_MOUSE_SENSITIVITY = 5f;
    private const float _Y_MOUSE_SENSITIVITY = 5f;
    private const float _MOUSE_WHEEL_SENSITIVITY = 5f;

    //"Мертвая зона" внутри которой не реагируем на вращение колесика мышки
    private const float _DEAD_ZONE = 0.01f;

 
    //Ограничения вращения по вертикали - минимальное и максимальное
    private const float _Y_MIN_LIMIT = -40f;
    private const float _Y_MAX_LIMIT = 80f;

 

    void Awake()
    {
        instance = this;
    }

 
    void Start()
    {
        Distance = Mathf.Clamp(Distance, _DISTANCE_MIN, _DISTANCE_MAX);
        Reset();
    }

 
    void LateUpdate()
    {
        if (target == null) return;
        PlayerInput();
        CalcDesirePosition();
        UpatePosition();
    }

 
    private void PlayerInput()
    {
        //Проеряем - нажата ли правая кнопка мыши
        if(Input.GetMouseButtonDown(1))
        {
            //Нажата - рассчитываем смещения с учетом чувствительности мыши
            _mouseX += Input.GetAxis("Mouse X") * _X_MOUSE_SENSITIVITY;
            _mouseY -= Input.GetAxis("Mouse Y") * _Y_MOUSE_SENSITIVITY;
        }


        //Ограничиваем вращение по вертикали с учетом того, что оно может выходить за пределы диапазона [-360;360]
        _mouseY = Utils.ClampAngle(_mouseY, _Y_MIN_LIMIT, _Y_MAX_LIMIT);

 
        //Данные с колесика мышки
        float scroll = Input.GetAxis("Mouse ScrollWheel");

        //Если вышли за пределы "мертвой зоны"
        if(scroll < -_DEAD_ZONE || scroll > _DEAD_ZONE)
        {
            //Рассчитываем желаемое расстояние от камеры до персонажа
            //Введенное значение умножаем на чувствительность, вычитаем его из текущего расстояния и ограничиваем сверху и снизу
            _desireDistance = Mathf.Clamp(Distance - scroll * _MOUSE_WHEEL_SENSITIVITY, _DISTANCE_MIN, _DISTANCE_MAX);
        }
    }

 
    private void CalcDesirePosition()
    {

    }

 
    private Vector3 CalcPosition(float rotx, float roty, float distance)
    {

    }

 
    public void Reset()
    {
        _mouseX = 0;
        _mouseY = 10f;
        Distance = _START_DISTANCE;
        _desireDistance = Distance;
    }

 
    private void UpatePosition()
    {

    }

 
    static public void GetCamera()
    {
        GameObject tempCamera;
        GameObject targetTemp;
        CharTPSCamera myCamera;


        if (Camera.mainCamera != null) tempCamera = Camera.mainCamera.gameObject;
        else
        {
            tempCamera = new GameObject("Main Camera");
            tempCamera.AddComponent("Camera");
            tempCamera.tag = "Main Camera" ;
        }

        tempCamera.AddComponent("CharTPSCamera");
        myCamera = tempCamera.GetComponent<CharTPSCamera>();
        targetTemp = GameObject.Find("targetLookAt");

        if(targetTemp == null)
        {
            targetTemp = new GameObject("targetLookAt");
            targetTemp.transform.position = Vector3.zero;
        }

        myCamera.target = targetTemp.transform;
    }
}

 

//Новый вспомогательный статический класс
internal static class Utils
{
    //метод для ограничения угла поворота
    public static float ClampAngle(float angle, float min, float max)
    {
        //Делаем
        do
        {
            //Если угол меньше -360 прибавляем к нему 360
            if (angle < -360) angle += 360;

            //Если больше 360 - вычитем
            if (angle > 360) angle -= 360;

        //Пока он не окажется в диапазоне [-360;360]
        } while (angle < -360 || angle > 360);

        //Возвращаем ограничив сверху и снизу
        return Mathf.Clamp(angle, min, max);
    }
}


Май 28, 2015, 12:35:55 pm
Ответ #5

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Класс CharTPSCamera Часть4


Пришло время расчета желаемой позиции камеры.
Что мы имеем на данный момент - желаемое расстояние от камеры до персонажа, и смещения мыши по осям X и Y должным образом обработанные. Исходя из этих трех значений нам и нужно рассчитать желаемую позицию.
Первое, что нужно сделать - сгладить смещение камеры к/от персонажа. Т.е. камера должна плавно набрать скорость, а при приближении к желаемому расстоянию плавно сбросить ее. Этого можно достичь несколькими способами. Можно, например, использовать Time.deltatime как аргумент некоей сглаживающей функции (например косинуса). Мы используем другой способ - включенный в Юнити метод Mathf.SmoothDamp. Ей передается - текущее значение параметра, желаемое значение параметра, текущая скорость изменения параметра, время за которое мы хотим достигнуть желаемого значения. Возвращает она измененное значение параметра и новую скорость изменения параметра. Методы стандартно возвращают одно значение. Чтобы вернуть второе мы передаем один из параметров - скорость изменения - по ссылке (ref).
Далее все три наших значения сглаженное расстояние от камеры до игрока и смещения по осям X и Y мы передаем методу CalcPosition, который собственно рассчитывает желаемое положение камеры. Помните в прошлом уроке мы упоминали, что смещение мышки по оси X вызывает вращение вокруг оси Y и наоборот. Вот при передаче параметров этому методу мы их и меняем местами.
Как можно получить позицию камеры? Разложим ее на две составляющих. Сначала получим точку прямо за персонажем на нужном расстоянии - это вектор (0, 0, -distance). Затем повернем ее вокруг персонажа на нужный угол - этот поворот представляется кватернионом (rotx, roty, 0). Результирующая позиция - сумма позиции персонажа (мы все до сих пор считали относительно него!) и умножения вектор на кватернион.
Итак, что нам нужно:
Константа:
_DISTANCE_SMOOTH - определяет "смягчение" приближения/удаления камеры.
Переменные:
_desirePosition - рассчитанная желаемая позиция камеры - вектор
_velDistance - текущая скорость приближения/удаления камеры.
Методы:
CalcDesirePosition и CalcPosition.

Полный код класса камеры на данный момент:

using UnityEngine;


class CharTPSCamera : MonoBehaviour
{

    public static CharTPSCamera instance;
    public Transform target;

    public float Distance { get; set; }

    private const float _START_DISTANCE = 2f;
    private const float _DISTANCE_MIN = _START_DISTANCE;
    private const float _DISTANCE_MAX = 12f;

    //Параметр смягчения приближения/удаления камеры
    private const float _DISTANCE_SMOOTH = 0.05f;
    private const float _X_SMOOTH = 0.05f;
    private const float _Y_SMOOTH = 0.1f;

 
    //Текущая скорость приближения/удаления камеры
    private float _velDistance;

    private float _mouseX;
    private float _mouseY;

    private float _desireDistance;

    //Полученное желаемое положение камеры
    private Vector3 _desirePosition;

    private const float _X_MOUSE_SENSITIVITY = 5f;
    private const float _Y_MOUSE_SENSITIVITY = 5f;
    private const float _MOUSE_WHEEL_SENSITIVITY = 5f;

    private const float _DEAD_ZONE = 0.01f;

    private const float _Y_MIN_LIMIT = -40f;
    private const float _Y_MAX_LIMIT = 80f;

 

    void Awake()
    {
        instance = this;
    }


    void Start()
    {
        Reset();
    }

 
    void LateUpdate()
    {
        if (target == null) return;
        PlayerInput();
        CalcDesirePosition();
        UpatePosition();
    }

 
    private void PlayerInput()
    {
        if(Input.GetMouseButton(1))
        {
            _mouseX += Input.GetAxis("Mouse X") * _X_MOUSE_SENSITIVITY;
            _mouseY -= Input.GetAxis("Mouse Y") * _Y_MOUSE_SENSITIVITY;
        }

 
        _mouseY = Helper.ClampAngle(_mouseY, _Y_MIN_LIMIT, _Y_MAX_LIMIT);

        float scroll = Input.GetAxis("Mouse ScrollWheel");

        if(scroll < -_DEAD_ZONE || scroll > _DEAD_ZONE)
        {
            _desireDistance = Mathf.Clamp(Distance - scroll * _MOUSE_WHEEL_SENSITIVITY, _DISTANCE_MIN, _DISTANCE_MAX);
        }
    }

 
    //Рассчитываем желаемую позицию камеры
    private void CalcDesirePosition()
    {
        //"Смягчаем" приближение/удаление камеры
        Distance = Mathf.SmoothDamp(Distance, _desireDistance, ref _velDistance, _DISTANCE_SMOOTH);

        //Собственно рассчитываем позицию. Обратите внимание на перекрестную передачу параметров
        //_mouseX и _mouseY
        _desirePosition = CalcPosition(_mouseY, _mouseX, Distance);
    }

 
    private Vector3 CalcPosition(float rotx, float roty, float distance)
    {

        //Точка прямо позади персонажа на расстоянии камеры
        Vector3 direction = new Vector3(0, 0, -distance);

        //Поворот вокруг персонажа на нужный угол
        Quaternion rotation = Quaternion.Euler(rotx, roty, 0);

        //Возвращаем нужную позицию камеры в мировом пространстве
        return target.position + rotation * direction;
    }


    public void Reset()
    {
        _mouseX = 0;
        _mouseY = 10f;

        Distance = _START_DISTANCE;

        _desireDistance = Distance;
    }

 
    private void UpatePosition()
    {

    }

 
    static public void GetCamera()
    {
        GameObject tempCamera;
        GameObject targetTemp;
        CharTPSCamera myCamera;

        if (Camera.mainCamera != null) tempCamera = Camera.mainCamera.gameObject;
        else
        {
            tempCamera = new GameObject("Main Camera");
            tempCamera.AddComponent("Camera");
            tempCamera.tag = "Main Camera" ;
        }

        tempCamera.AddComponent("CharTPSCamera");
        myCamera = tempCamera.GetComponent<CharTPSCamera>();

        targetTemp = GameObject.Find("targetLookAt");

        if(targetTemp == null)
        {
            targetTemp = new GameObject("targetLookAt");
            targetTemp.transform.position = Vector3.zero;
        }

        myCamera.target = targetTemp.transform;
    }
}

 
internal static class Helper
{
    public static float ClampAngle(float angle, float min, float max)
    {
        do
        {
            if (angle < -360) angle += 360;
            if (angle > 360) angle -= 360;
        } while (angle < -360 || angle > 360);

        return Mathf.Clamp(angle, min, max);
    }
}



Май 28, 2015, 12:44:12 pm
Ответ #6

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Класс CharTPSCamera Часть5

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

Итак, что нам нужно.

Константы:
_X_SMOOTH -определяет смягчение движения по осям X и Z
_Y_SMOOTH - определяет смягчение движения по оси Y

Переменные:
_velX, _velY, _velZ - текущие скорости движения по соответствующим осям. Нужны для метода сглаживания.
position - окончательная позиция камеры

Итого полный код класса камеры:

using UnityEngine;


class CharTPSCamera : MonoBehaviour
{

    public static CharTPSCamera instance;
    public Transform target;

    public float Distance { get; set; }

    private const float _START_DISTANCE = 2f;
    private const float _DISTANCE_MIN = _START_DISTANCE;
    private const float _DISTANCE_MAX = 12f;
    private const float _DISTANCE_SMOOTH = 0.05f;

    private const float _X_SMOOTH = 0.05f;
    private const float _Y_SMOOTH = 0.1f;

    private float _velDistance;

    //Скорости движения по соответствующим осям
    private float _velX;
    private float _velY;
    private float _velZ;

    private float _mouseX;
    private float _mouseY;

    private float _desireDistance;
    private Vector3 _desirePosition;

    //Окончательная позиция камеры
    private Vector3 _position;

    private const float _X_MOUSE_SENSITIVITY = 5f;
    private const float _Y_MOUSE_SENSITIVITY = 5f;
    private const float _MOUSE_WHEEL_SENSITIVITY = 5f;
    private const float _DEAD_ZONE = 0.01f;

    private const float _Y_MIN_LIMIT = -40f;
    private const float _Y_MAX_LIMIT = 80f;



    void Awake()
    {
        instance = this;
    }

 
    void Start()
    {
        Reset();
    }

 
    void LateUpdate()
    {
        if (target == null) return;
        PlayerInput();
        CalcDesirePosition();
        UpatePosition();
    }

 
    private void PlayerInput()
    {
        if(Input.GetMouseButton(1))
        {
            _mouseX += Input.GetAxis("Mouse X") * _X_MOUSE_SENSITIVITY;
            _mouseY -= Input.GetAxis("Mouse Y") * _Y_MOUSE_SENSITIVITY;
        }

        _mouseY = Helper.ClampAngle(_mouseY, _Y_MIN_LIMIT, _Y_MAX_LIMIT);
        float scroll = Input.GetAxis("Mouse ScrollWheel");

        if(scroll < -_DEAD_ZONE || scroll > _DEAD_ZONE)
        {
            _desireDistance = Mathf.Clamp(Distance - scroll * _MOUSE_WHEEL_SENSITIVITY, _DISTANCE_MIN, _DISTANCE_MAX);
        }
    }

 
    private void CalcDesirePosition()
    {
        Distance = Mathf.SmoothDamp(Distance, _desireDistance, ref _velDistance, _DISTANCE_SMOOTH);
        _desirePosition = CalcPosition(_mouseY, _mouseX, Distance);
    }

 
    private Vector3 CalcPosition(float rotx, float roty, float distance)
    {
        Vector3 direction = new Vector3(0, 0, -distance);
        Quaternion rotation = Quaternion.Euler(rotx, roty, 0);

        return target.position + rotation * direction;
    }

 
    public void Reset()
    {
        _mouseX = 0;
        _mouseY = 10f;

        Distance = _START_DISTANCE;
        _desireDistance = Distance;
    }

 
    private void UpatePosition()
    {

        //Сглаживаем движения по соответствующим осям
        float posX = Mathf.SmoothDamp(_position.x, _desirePosition.x, ref _velX, _X_SMOOTH);
        float posY = Mathf.SmoothDamp(_position.y, _desirePosition.y, ref _velY, _Y_SMOOTH);
        float posZ = Mathf.SmoothDamp(_position.z, _desirePosition.z, ref _velZ, _X_SMOOTH);

 
        //Формируем вектор - окончательное положение камеры
        _position = new Vector3(posX, posY, posZ);

        //Перемещаем камеру в рассчитанное положение
        transform.position = _position;

        //Поворачиваем камеру так, чтобы она глядела на цель
        transform.LookAt(target);
    }

 
    static public void GetCamera()
    {

        GameObject tempCamera;
        GameObject targetTemp;

        CharTPSCamera myCamera;

        if (Camera.mainCamera != null) tempCamera = Camera.mainCamera.gameObject;
        else
        {
            tempCamera = new GameObject("Main Camera");
            tempCamera.AddComponent("Camera");
            tempCamera.tag = "Main Camera" ;
        }

        tempCamera.AddComponent("CharTPSCamera");
        myCamera = tempCamera.GetComponent<CharTPSCamera>();

        targetTemp = GameObject.Find("targetLookAt");

        if(targetTemp == null)
        {
            targetTemp = new GameObject("targetLookAt");
            targetTemp.transform.position = Vector3.zero;
        }

        myCamera.target = targetTemp.transform;
    }
}

 
internal static class Helper
{

    public static float ClampAngle(float angle, float min, float max)
    {
        do
        {
            if (angle < -360) angle += 360;
            if (angle > 360) angle -= 360;
        } while (angle < -360 || angle > 360);

        return Mathf.Clamp(angle, min, max);
    }
}

---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Некоторые замечания под конец.

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

Установка сцены очень проста.
Создаем пустой объект.
Называем его targetLookUp.
Делаем его дочерним к нашему персонажу.
На персонаж навешиваем скрипты CharMotor и CharController.
Не забываем что на персонаже должен быть компонент CharacterController.

Наслаждаемся...


Май 28, 2015, 14:07:36 pm
Ответ #7

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
"Косметический ремонт"


Продолжаем наши изыскания...

Для начала небольшое отступление. Что мы делали не совсем правильно с точки зрения ООП? Мы напрямую вызывали методы другого класса. Вообще это, конечно, не криминал, но мне это не нравится. Поэтому мы несколько переделаем наши классы. Заодно узнаем об одной фишке движка.

В Юнити есть встроенная система сообщений. Сообщения в Юнити бывают трех типов - широковещательные (BroadcastMessag), объекту и его родителям в иерархии (SendMessageUpward), объекту. Мы будем использовать третий тип. Что означает - "послать сообщение"? В Юнити это означает - вызвать метод (даже приватный) и передать ему некий параметр. Чем это отличается от предыдущего? Во первых - метод напрямую не вызывается, он снаружи класса вообще не виден. Не нужны никакие instans-ы. В третьих - Мы можем в будущем навесить на наш объект еще скрипты с методом с таким же именем - они будут вызваны тоже.

Что меняем:
Убираем все переменные instanse...
Убираем их инициализацию в Awake
В CharController в методе Update строчки:
//Говорим CharMotor, что пора двигаться
CharMotor.instance.UpdateMotor(move);

меняем на:
//Говорим CharMotor, что пора двигаться
 SendMessage("_DidMove", move, SendMessageOptions.DontRequireReceiver);

В CharMotor метод UpdateMotor называем _DidMove, и делаем приватным.

Также я добавил возможность отключения управления персонажем (например в диалогах).

Для этого:
в CharController добавляем свойство:
public bool IsControllable
 {
    get { return _isControlable; }
    set
    {
       SendMessage("_SwitchControl", value, SendMessageOptions.DontRequireReceiver);
       _isControlable = value;
    }
 }

Там же в самом начале метода Update строчку: if (!IsControllable) return;
В CharTPSCamera поле: private bool _isControlable = true;
в самом начале метода LateUpdate аналогичную строчку: if (!_isControlable) return;
и в этом же классе камеры метод:
private void _NotControl(bool val)
 {
     _isControlable = val;
 }

В методе GetCamera строчки:
tempCamera.AddComponent("CharTPSCamera");
CharTPSCamera myCamera = tempCamera.GetComponent<CharTPSCamera>();

Заменяем на:
GameObject pl = GameObject.FindGameObjectWithTag("Player");
pl.AddComponent("CharTPSCamera");
CharTPSCamera myCamera = pl.GetComponent<CharTPSCamera>();

Тем самым мы прикрепляем скрипт камеры не к камере, а к игроку и SendMessage будет работать.
Добавляем в CharTPSCamera переменную:
private static Transform _tr;
в конце метода GetCamera добавляем инициализацию этой переменной: _tr = tempCamera.transform;
и в методе _UpatePosition меняем transform на _tr.
Тем самым мы сделали две вещи - кешировали transform, и сохраняем в нем именно трансформ камеры, а не персонажа, к которому прикреплен скрипт камеры.

На этом "косметический ремонт" закончен. Однако урок получился великоват, поэтому я приведу сейчас готовые скрипты и закончу его.

CharController.cs
using UnityEngine;


class CharController : MonoBehaviour
{

    //Ссылка на компонент - CharacterController
    public static CharacterController unityController;
    private bool _isControlable = true;

    public bool IsControllable
    {
       get { return _isControlable; }
       set
       {
          SendMessage("_SwitchControl", value, SendMessageOptions.DontRequireReceiver);
          _isControlable = value;
       }
    }

 
   public Vector3 move;
   private const float _DEAD_ZONE = 0.1f;
   private float _lastJumpTime = -1.0f;

 
   void Awake()
   {
      //Находим компонент - CharacterController
      unityController = GetComponent("CharacterController") as CharacterController;

      //Просим у класса камеры найти камеру в сцене
     CharTPSCamera.GetCamera();
   }

 
   void Update()
   {
      if (Camera.mainCamera == null) return;
      if (!IsControllable) return;

     _GetMoveInput();

     //Говорим CharMotor, что пора двигаться
     SendMessage("_DidMove", move, SendMessageOptions.DontRequireReceiver);
   }

 
   private void _GetMoveInput()
   {
      float vert = Input.GetAxis("Vertical");
      float horiz = Input.GetAxis("Horizontal");

      move = Vector3.zero;

      if (vert > _DEAD_ZONE || vert < -_DEAD_ZONE) move += new Vector3(0, 0, vert);
      if (horiz > _DEAD_ZONE || horiz < -_DEAD_ZONE) move += new Vector3(horiz, 0, 0);
   }
}



CharMotor.cs
using UnityEngine;


public class CharMotor : MonoBehaviour
{

    private Transform _transform;

    //Ссылка на компонент - CharacterController
    public static CharacterController unityController;

    private const float _SPEED = 10.0f;
    private const float _JUMP_SPEED = 8f;
    private const float _MAX_VERT_SPEED = 20f;
    private const float _SLIDE_THRESHHOLD = 0.85f;
    private const float _MAX_CONTROL_SLIDE = 0.4f;
    private CollisionFlags _collisionFlags;

 
    void Awake()
    {
       _transform = transform;

       //Находим компонент - CharacterController
       unityController = GetComponent("CharacterController") as CharacterController;
    }

 
    private void _DidMove(Vector3 move)
    {
        _RotateChar(move);
        _ProcessMotion(move);
    }

 
    private void _ProcessMotion(Vector3 moveVector)
    {
        moveVector = _transform.TransformDirection(moveVector);
        moveVector = Vector3.Normalize(moveVector);
        moveVector *= _SPEED;
        _collisionFlags = unityController.Move(moveVector * Time.deltaTime);
    }

 
    private void _RotateChar(Vector3 move)
    {
        if (move.x != 0 || move.z != 0)
        {
             transform.rotation = Quaternion.Euler(transform.eulerAngles.x, Camera.mainCamera.transform.eulerAngles.y, transform.eulerAngles.z);
        }
    }
}



CharTPSCamera.cs
using UnityEngine;


class CharTPSCamera : MonoBehaviour
{

    private static Transform _tr;
    public Transform target;

    private bool _isControlable = true;

    public float Distance { get; set; }

#region Constant
    private const float _START_DISTANCE = 2f;
    private const float _DISTANCE_MIN = _START_DISTANCE;
    private const float _DISTANCE_MAX = 12f;
    private const float _DISTANCE_SMOOTH = 0.05f;
    private const float _X_SMOOTH = 0.05f;
    private const float _Y_SMOOTH = 0.1f;

    private const float _X_MOUSE_SENSITIVITY = 5f;
    private const float _Y_MOUSE_SENSITIVITY = 5f;
    private const float _MOUSE_WHEEL_SENSITIVITY = 5f;
    private const float _DEAD_ZONE = 0.01f;

    private const float _Y_MIN_LIMIT = -40f;
    private const float _Y_MAX_LIMIT = 80f;

#endregion

 
    private float _velDistance;
    private float _velX;
    private float _velY;
    private float _velZ;

    private float _mouseX;
    private float _mouseY;
    private float _desireDistance;
    private Vector3 _desirePosition;
    private Vector3 _position;

 
    void Start()
    {
       Distance = Mathf.Clamp(Distance, _DISTANCE_MIN, _DISTANCE_MAX);
       Reset();
    }

 
    void LateUpdate()
    {
       if (!_isControlable) return;
       if (target == null) return;
       _PlayerInput();
       _CalcDesirePosition();
       _UpatePosition();
    }

 
    private void _PlayerInput()
    {
       if(Input.GetMouseButton(1))
       {
          _mouseX += Input.GetAxis("Mouse X") * _X_MOUSE_SENSITIVITY;
          _mouseY -= Input.GetAxis("Mouse Y") * _Y_MOUSE_SENSITIVITY;
       }

       _mouseY = Helper.ClampAngle(_mouseY, _Y_MIN_LIMIT, _Y_MAX_LIMIT);
       float scroll = Input.GetAxis("Mouse ScrollWheel");
       if(scroll < -_DEAD_ZONE || scroll > _DEAD_ZONE)
      _desireDistance = Mathf.Clamp(Distance - scroll * _MOUSE_WHEEL_SENSITIVITY, _DISTANCE_MIN, _DISTANCE_MAX);
    }

 
    private void _CalcDesirePosition()
    {
       Distance = Mathf.SmoothDamp(Distance, _desireDistance, ref _velDistance, _DISTANCE_SMOOTH);
       _desirePosition = _CalcPosition(_mouseY, _mouseX, Distance);
    }

 
    private Vector3 _CalcPosition(float rotx, float roty, float distance)
    {
       Vector3 direction = new Vector3(0, 0, -distance);
       Quaternion rotation = Quaternion.Euler(rotx, roty, 0);
       return target.position + rotation * direction;
    }

 
    public void Reset()
    {
       _mouseX = 0;
       _mouseY = 10f;
       Distance = _START_DISTANCE;
       _desireDistance = Distance;
    }

 
    private void _UpatePosition()
    {
       float posX = Mathf.SmoothDamp(_position.x, _desirePosition.x, ref _velX, _X_SMOOTH);
       float posY = Mathf.SmoothDamp(_position.y, _desirePosition.y, ref _velY, _Y_SMOOTH);
       float posZ = Mathf.SmoothDamp(_position.z, _desirePosition.z, ref _velZ, _X_SMOOTH);
       _position = new Vector3(posX, posY, posZ);
       _tr.position = _position;
       _tr.LookAt(target);
    }

 
    static public void GetCamera()
    {
       GameObject tempCamera;

       if (Camera.mainCamera != null) tempCamera = Camera.mainCamera.gameObject;
       else
       {
          tempCamera = new GameObject("Main Camera");
          tempCamera.AddComponent("Camera");
          tempCamera.tag = "Main Camera" ;
       }

 
       GameObject pl = GameObject.FindGameObjectWithTag("Player");
       pl.AddComponent("CharTPSCamera");
       CharTPSCamera myCamera = pl.GetComponent<CharTPSCamera>();
       GameObject targetTemp = GameObject.Find("targetLookAt");

       if(targetTemp == null)
       {
           targetTemp = new GameObject("targetLookAt");
           targetTemp.transform.position = Vector3.zero;
       }

       myCamera.target = targetTemp.transform;
       _tr = tempCamera.transform;
    }

 
    private void _NotControl(bool val)
    {
       _isControlable = val;
    }
}

 
internal static class Helper
{
    public static float ClampAngle(float angle, float min, float max)
    {
       do
       {
             if (angle < -360) angle += 360;
             if (angle > 360) angle -= 360;
        } while (angle < -360 || angle > 360);

        return Mathf.Clamp(angle, min, max);
    }
}


Май 28, 2015, 14:15:15 pm
Ответ #8

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Гравитация и прыжки


Итак, что такое "гравитация"? - Это постоянно действующая сила, направленная вниз. Как ее применить ясно - нужно постоянно к вектору перемещения персонажа прибавлять некий вектор, направленный вниз. Удобнее всего это сделать в CharMotor, непосредственно перед сдвигом персонажа. Просто добавляем туда
moveVector = _ApplyGravity(moveVector);
И добавляем соответствующий метод:
private Vector3 _ApplyGravity(Vector3 move)
{
    if(_verticalVelocity > -_MAX_VERT_SPEED) _verticalVelocity -= _GRAVITY * Time.deltaTime;
    if (_collisionFlags == CollisionFlags.CollidedBelow &amp;&amp; _verticalVelocity < -1) _verticalVelocity = -1;

    return new Vector3(move.x, _verticalVelocity, move.z);
}


Тут появилась некая переменная - _verticalVelocity. Пока мы обходились без нее. Однако вспомните, что в CharController, прежде чем учесть ввод пользователя, мы обнуляем вектор движения. При этом вся предыдущая информация, в том числе о вертикальной составляющей теряется. А нам она нужна, чтобы при падении постепенно увеличивать скорость, пока она не достигнет некоей максимально возможной. Поэтому нужно ввести переменную, в которой мы будем между кадрами сохранять вертикальную составляющую скорости персонажа.

Гравитация, как видите очень просто вводится.

Теперь прыжок. Сначала нам нужно получить с ввод клавиатуры когда игрок нажимает клавишу "Прыжок". Делаем это, как обычно в классе CharController. Добавляем в Update перед вводом движения вызов метода _GetHandleInput. и где-то в классе сам метод:
private void _GetHandleInput()
{
    if (!Input.GetButton("Jump")) return;
    if (_lastJumpTime + _JUMP_REPEAT_TIME > Time.time) return;
    if (!unityController.isGrounded) return;
    _lastJumpTime = Time.time;
    SendMessage("_DidJump", SendMessageOptions.DontRequireReceiver);
}


Здесь сначала проверяем клавишу Jump, если не нажата - сразу выходим. Затем проверяем достаточно ли прошло времени с предыдущего прыжка, если нет - тоже выходим. Далее проверяем - на земле ли персонаж, если нет - выход. Если все условия выполнены - запоминаем время прыжка и говорим CharMotor сделать прыжок. (На самом деле - любому скрипту, который лежит на персонаже).

Осталось собственно сделать прыжок. В CharMotor добавляем метод:
private void _DidJump()
{
    _verticalVelocity = _JUMP_SPEED;
}


И необходимые постоянные:
private const float _JUMP_SPEED = 8f;

private const float _GRAVITY = 21f;

private const float _MAX_VERT_SPEED = 20f;


Май 28, 2015, 14:22:09 pm
Ответ #9

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Проскальзывание


Продолжаем. Сегодня мы заставим нашего персонажа скользить на крутых склонах.

Сначала надо определить, что склон достаточно крутой. Как это сделать?

Практически в любом движке, естественно и в Юнити есть специальная функция, позволяющая "пустить" луч из точки до столкновения с поверхностью и определить параметры поверхности в точке столкновения. В частности нормаль к поверхности. Нам это и надо. Вертикальная составляющая нормали и определяет наклон поверхности.

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

Откуда пускаем луч? Просто из transform.position (На самом деле все зависит от Вашего персонажа. Если у него position в самом низу - т.е. если пивот в программе моделирования был в самом низу, то желательно к transform.position прибавить Vector3.up. Это Вам придется самим подбирать. Если персонаж не скользит - первым делом попробуйте сменить точку откуда пускаете луч.).

Как рассчитываем направление скольжения? Это просто та же нормаль к поверхности, только вертикальная составляющая инвертирована.

Итого последовательность действий:
проверяем на земле ли мы, если нет - выход;
обнуляем направление проскальзывания;
пускаем вниз луч, запоминаем нормаль в точке соударения;
если y составляющая нормали меньше порогового значения (чем она меньше, тем более вертикальна поверхность), то рассчитываем направление проскальзывания;
Если вертикальная составляющая нормали к земле больше второго порога - есть возможность некоторого управления персонажем;
если меньше - только скользим без управления персонажем

Как можно добиться частичной потери управляемости? Просто прибавлять к вектору движения, который у нас был до сих пор направление проскальзывания. А при полной потере управления - заменять им...

Также я подумал, что было бы хорошо изменять скорость скольжения в зависимости от крутизны склона более заметно, чем при простом сложении с направлением проскальзывания. Вот такая зависимость - (1/vert - 1)*20 мне показалась самой правильной. Здесь vert - вертикальная составляющая нормали к земле. У горизонтальной поверхности vert = 1 и скорость = 0, у полностью вертикальной vert = 0 и скорость = бесконечности. Я просто перед прибавлением направления проскальзывания к вектору движения умножаю проскальзывание на это значение.

И последняя тонкость - куда вставлять расчет проскальзывания? Тут у меня возникло затруднение. Я хотел в будущем сделать зависимость скорости движения от направления. Т.е. вперед персонаж должен был двигаться быстрее чем в стороны и тем более назад. Однако отдельное рассчитывание вертикальной составляющей вектора движения портило все карты. В общем мне пришлось несколько поменять метод _ProcessMotion.

Вместо одного показателя - скорость персонажа я ввел два вектора для масштабирования вектора движения. Отдельно для движения вперед, отдельно для движения назад. Масштабирование производится методом Vector3.Scale. Он покомпонентно умножает два вектора. Т.е. получается, что смещение персонажа вперед умножается на скорость его движения вперед, смещение вбок - на скорость движения вбок... Это масштабирование я делаю в самом начале метода _ProcessMotion, т.к. после преобразования сектора движения в мировые координаты было бы неправильно. Сразу после этого я восстанавливаю вертикальную составляющую движения. Затем преобразую в мировую систему координат. Применяю гравитацию проскальзывание. Потом сохраняю на будущее вертикальную составляющую вектора движения.

Почему такие сложности с вертикальной составляющей? Вектор движения мы изначально получаем из класса контроллера персонажа, который ничего не знает о вертикальной составляющей. Он вообще не имеет дела с "физикой" - не знает о гравитации, о проскальзывании, наклонах поверхности. Естественно он не может ниоткуда узнать эту вертикальную составляющую. Поэтому ее приходится отдельно рассчитывать и хранить в классе CharMotor.

Итого. Изменения в классе CharMotor:

     //Скорость персонажа
    private const float _FORWARD_SPEED = 10.0f;
    private const float _BACKWARD_SPEED = 3.0f;
    private const float _STRAFF_SPEED = 4.0f;

    //Вектора для масштабирования вектора движения в соответствии со скоростью в данном направлении
    private static readonly Vector3 _fSpeed = new Vector3(_STRAFF_SPEED, 1, _FORWARD_SPEED);
    private static readonly Vector3 _bSpeed = new Vector3(_STRAFF_SPEED, 1, _BACKWARD_SPEED);

 
    //Вертикальная составляющая нормали к поверхности, при которой начниается проскальзывание
    private const float _SLIDE_THRESHHOLD = 0.95f;

    //Вертикальная составляющая нормали к поверхности, до которой сохраняется управляемость персонажа
    private const float _MAX_CONTROL_SLIDE = 0.85f;

 
    private void _ProcessMotion(Vector3 moveVector)
    {
        //Масштабируем вектор движения в соответствии со скоростью в данном направлении
        //Если двигаемся вперед используем для масштабирования _fSpeed, назад - _bSpeed
        moveVector = Vector3.Scale(moveVector, moveVector.z > 0 ? _fSpeed : _bSpeed);

        //Добавляем вертикальную составляющую
        moveVector = new Vector3(moveVector.x, _verticalVelocity, moveVector.z);

        //Преобразуем вектор движения в мировое пространство.
        moveVector = _transform.TransformDirection(moveVector);

        //Применяем гравитацию
        moveVector = _ApplyGravity(moveVector);

        //применяем проскальзывание
        moveVector = _ApplySlide(moveVector);

        //Сохраняем вертикальную составляющую на будущее
        _verticalVelocity = moveVector.y;

        //Двигаем!
        _collisionFlags = unityController.Move(moveVector * Time.deltaTime);
    }

 
    private Vector3 _ApplySlide(Vector3 move)
    {
        //Если мы не на земле -возвращаем немодифицированное движение);
        if (_collisionFlags != CollisionFlags.CollidedBelow) return move;

        //Направление проскальзывания
        Vector3 slideD = Vector3.zero;

        //Информация о точке где луч пересекает землю будет сохраняться тут
        RaycastHit hit;

        //Если луч, пущенный из точки чуть выше позиции персонажа вниз с чем то ударился);
        if (Physics.Raycast(_transform.position, Vector3.down, out hit))
        {
            float vert = hit.normal.y;

            //Если вертикальная составляющая нормали к земле меньше порога - т.е. земля достаточно вертикальна);
            if(vert< _SLIDE_THRESHHOLD)

                //формируем направление проскальзывания
                slideD = new Vector3(hit.normal.x, -vert, hit.normal.z);

            //Временное значение для того, чтобы по более крутому склону быстрее скользил.
            float temp = (1/vert - 1)*20;

            //Если вертикальная составляющая нормали к земле больше второго порога
            //Т.е. угол склона в пределах [_MAX_CONTROL_SLIDE - _SLIDE_THRESHHOLD]
            if (vert > _MAX_CONTROL_SLIDE)

                //оставляем возможность управления - прибавляем вектор проскальзывания к вектору движения
                move += slideD * temp;

            else

                //Иначе - никакого управления - заменяем вектор движения на проскальзывание
                move = slideD * temp;
        }

 
        //Возвращаем модифицирванный вектор движения
        return move;
    }



Май 28, 2015, 14:32:31 pm
Ответ #10

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Окклюжн камеры. Часть 1


Следующее что мы рассмотрим - Окклюжн камеры.

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

Как можно сделать это? Нужно пустить луч от камеры к персонажу (или наоборот) и сдвигать камеру ближе к персонажу, пока между ними не будет иных объектов. Более точно можно определить пустив не один луч, а несколько - по краям отображаемой камерой области и в центре ее. Осталось определить точки в пространстве, которые определяют отображаемую камерой область. Причем выбирать их надо как можно ближе к камере (это если луч пускаем от персонажа - что оказывается удобнее). Наиболее близко к камере отображаемая область - это "nearClipPlane" - ближняя плоскость отсечки. Итак вывод - нам нужно получить четыре угла nearClipPlane.

Рассмотрим рисунок:


В классе камеры есть параметры ближней плоскости отсечения:
nearClipPlane - расстояние от камеры до плоскости
fieldOfView - угол поля зрения.

Нам нужно получить height - половину высоты плоскости. Из тригонометрии:

height = nearClipPlane * tan(fieldOfView/2 * Deg2Rad)

Зная высоту легко получить ширину плоскости - просто умножив на aspect, который тоже есть в камере.

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

Чтобы с этими данными было проще работать объединим их в одну структуру.
public struct ClipPlane
{
    //Точки, определяющие положение ближней плоскостиотсечения камеры
    public Vector3 upperLeft;
    public Vector3 upperRight;
    public Vector3 lowerLeft;
    public Vector3 lowerRight;

 
    //Конструктор. На входе - позиция камеры
    public ClipPlane(Vector3 pos)
    {
        //Если главной камеры нет
        if(Camera.mainCamera == null)
        {
            //Все точки просто устанавливаем равными входному значению
            upperRight = upperLeft = lowerRight = lowerLeft = pos;
            return;
        }

 
        Transform tr = Camera.mainCamera.transform;
        float distance = Camera.mainCamera.nearClipPlane;

        //Высота ближней плоскости отсечения
        float height = distance * Mathf.Tan((Camera.mainCamera.fieldOfView / 2) * Mathf.Deg2Rad);

        //ее ширина
        float width = height * Camera.mainCamera.aspect;

 
        //рассчитываем положение точек
        lowerRight = pos + tr.right * width - tr.up * height + tr.forward * distance;
        lowerLeft = pos - tr.right * width - tr.up * height + tr.forward * distance;
        upperRight = pos + tr.right * width + tr.up * height + tr.forward * distance;
        upperLeft = pos - tr.right * width + tr.up * height + tr.forward * distance;
    }
}

Поместим эту структуру вместе с вспомогательным классом Helper из предыдущих уроков в отдельный файл - CharMotorHelper.cs

На самом деле нам вовсе не нужны пять булевых значений - есть ли что-то между персонажем и этими пятью точками. Нам нужно найти наименьшее расстояние от персонажа до точек в которых лучи пущенные от персонажа ударяют в загораживающие объекты. Т.е. мы последовательно пускаем луч от персонажа в эти пять точек плоскости отсечения и определяем ближе ли точка соударения луча к персонажу, чем предыдущая. Возвращаем наименьшее.
 
   private float _CheckClip(Vector3 fromp, Vector3 to)
    {
        //Изначално устанавливаем такое значение, которое в результате рассчетов получиться не может
        float nearestDistance = -1f;

        RaycastHit hit;

        //Создаем структуру - ближнюю плоскость отсечения
        ClipPlane cp = new ClipPlane(to);

        //Рисуем для отладки конус от персонажа к плоскости отсечения
        DrawFrustum(fromp, to);

        //Пускаем луч к углам плоскости. Если он с чем-то столкнулся - меняем рассчитанное расстояние
        if (Physics.Linecast(fromp, cp.upperLeft, out hit) &amp;&amp; hit.collider.tag != "Player" )  nearestDistance = hit.distance;

        if (Physics.Linecast(fromp, cp.lowerLeft, out hit) &amp;&amp; hit.collider.tag != "Player" )

            if(hit.distance < nearestDistance || nearestDistance == -1) nearestDistance = hit.distance;

        if (Physics.Linecast(fromp, cp.lowerRight, out hit) &amp;&amp; hit.collider.tag != "Player" )

            if (hit.distance < nearestDistance || nearestDistance == -1) nearestDistance = hit.distance;

        if (Physics.Linecast(fromp, cp.upperRight, out hit) &amp;&amp; hit.collider.tag != "Player" )

            if (hit.distance < nearestDistance || nearestDistance == -1) nearestDistance = hit.distance;

        //В конце проверяем центр плоскости
        if (Physics.Linecast(fromp, to + _tr.forward * -camera.nearClipPlane, out hit) &amp;&amp; hit.collider.tag != "Player" )

            if (hit.distance < nearestDistance || nearestDistance == -1) nearestDistance = hit.distance;

 
        //Возвращаем рассчитанное расстояние
        return nearestDistance;
    }

 
    public void DrawFrustum(Vector3 fromp, Vector3 to)
    {
        ClipPlane cp = new ClipPlane(to);
        Debug.DrawLine(fromp, to + _tr.forward * -camera.nearClipPlane, Color.red);
        Debug.DrawLine(fromp, cp.upperRight);
        Debug.DrawLine(fromp, cp.upperLeft);
        Debug.DrawLine(fromp, cp.lowerRight);
        Debug.DrawLine(fromp, cp.lowerLeft);

        Debug.DrawLine(cp.upperRight, cp.upperLeft);
        Debug.DrawLine(cp.upperRight, cp.lowerRight);
        Debug.DrawLine(cp.lowerLeft, cp.lowerRight);
        Debug.DrawLine(cp.lowerLeft, cp.upperLeft);
    }

Второй метод чисто отладочный - он рисуем конус от персонажа во все пять точек плоскости отсечения.
Думаю. На сегодня хватит. Продолжение следует...


Май 28, 2015, 14:37:22 pm
Ответ #11

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Окклюжн камеры. Часть 2


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

Какова последовательность действий? Рассчитываем как обычно желаемую позицию камеры. Проверяем есть ли перекрываемые объекты. Начальная позиция для проверки - позиция цели камеры (target), конечная - только что рассчитанная желаемая позиция.

Если перекрывающий объект есть, уменьшаем желаемое расстояние от камеры до персонажа на некоторое значение. Пересчитываем желаемую позицию камеры по новому расстоянию.

Повторяем все пока между целью камеры и камерой есть препятствие.

Желательно еще ограничить число шагов. Если число попыток приблизить камеру больше этого значения - просто рывком перемещаем камеру прямо на то расстояние, на котором обнаружили препятствие.

Итак нам нужно.
_MAX_OCCLUSION_CHECK - максимальное количество шагов
_OCCLUSION_STEP - шаг приближения камеры
nearclip - расстояние от камеры до ближней плоскости отсечения. Можно, в принципе везде использовать Camera.mainCamera.nearClipPlane, но я предпочел один раз запомнить это значение.

Добавляем эти переменые. В методе GetCamera инициализируем nearcli. В LateUpdate вместо _CalcDesirePosition() вызываем новый метод:

    private void _CalcDesirePositionClip()
    {
        bool isOccluded;

        //Счетчик шагов рассчета оклюжн
        var count = 0;

        //Первоначальный рассчет желаемой позиции камеры
        _CalcDesirePosition();

        do
        {
            //Нет препятствия
            isOccluded = false;

            //Рассчитываем ближайшую точку препятствия по ближней плоскости отсечения
            //Начало - в позиции цели камеры, конец - в только что рассчитанной желаемой позиции
            var nearesDist = _CheckClip(target.position, _desirePosition);

            //Если есть препятствие
            if (nearesDist != -1)
            {
                //и счетчик шагов не исчерпан
                if (count < _MAX_OCCLUSION_CHECK)
                {
                    //флаг - есть препятствие
                    isOccluded = true;

                    //сдвигаем камеру на шаг
                    Distance -= _OCCLUSION_STEP;

                    //Если слишком близко - ограничиваем. Тут не учитывается _DISTANCE_MIN! Камера может зайти внутрь персонажа!
                    //NOTE Чтобы избежать этого можно здесь использовать Bounding персонажа - подумать как...
                    if (Distance < 0.25f) Distance = 0.25f;
                }
                else
                    //Если счетчик шагов исчерпан просто рывком перемещаем камеру на расстояние чуть меньшее чем препятствие
                    Distance = nearesDist - nearclip;

                //Устанавливаем желаемое расстояние
                _desireDistance = Distance;

                //Пересчитываем позицию камеры по измененному желаемому расстоянию
                _CalcDesirePosition();
            }


            //Прибавляем шаг
            count++;

        //Повторяем пока есть препятствие
        } while (isOccluded);
    }

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


Май 28, 2015, 15:08:48 pm
Ответ #12

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Возврат после окклюжн


Осталось совсем немного и вторая часть цикла будет завершена.

Что не доделано?

Хотелось бы, чтобы после того, как персонаж перестал загораживать какой-то объект - камера возвращалась на прежнее расстояние.

Что для этого нужно?
_preOccludedDistance - здесь мы будем сохранять расстояние от камеры до персонажа перед тем ка зафиксирован окклюжн;
_DISTANCE_RESUME_SMOOTH - это сглаживание восстановления расстояния от камеры, заменяет временно _DISTANCE_SMOOTH;
_distanceSmooth - переменная - вводится для того чтобы можно было оперативно менять сглаживание изменения расстояния. сюда записываем или _DISTANCE_RESUME_SMOOTH или _DISTANCE_SMOOTH.

В нужных местах заменяем _distanceSmooth, и устанавливаем _preOccludedDistance.
В конце _PlayerInput:
_preOccludedDistance = _desireDistance;
 _distanceSmooth = _DISTANCE_SMOOTH;


В конце Reset:
_desireDistance = Distance;

В начале _CalcDesirePosition:
if (_desireDistance < _preOccludedDistance &amp;&amp; (Time.time - _lastTime) > 0.2f)
 {
      _ResetDesireDistance();
      _lastTime = Time.time;
 }


Здесь мы проверяем не каждый кадр, а через некое время, т.к. иначе эта проверка занимает неоправданно много времени. Естественно вводим переменную _lastTime.
Добавляем метод:
private void _ResetDesireDistance()
 {
      Vector3 pos = _CalcPosition(_mouseY, _mouseX, _preOccludedDistance);
      float nearestDistance = _CheckClip(_target.position, pos);
      if (nearestDistance == -1 || nearestDistance > _preOccludedDistance)
      _desireDistance = _preOccludedDistance;
 }


Далее. Всплыла одна недоработка. Если персонаж состоит из нескольких частей то на каждой должен быть тег Player - от этого никуда не деться. Однако даже несмотря на это если камер смотрит на какую-то часть, которая подальше от центра персонажа, то оклюжн не работает. Почему? Потому что столкновения с объектами с тегом Player не учитываются, но дальше же мы луч не пускаем. И, следовательно объект, действительно перекрывающий камеру не обнаруживается.

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

В результате пришлось вынести проверку в отдельный метод:
private static bool _CheckClipI(Vector3 fr, Vector3 to, out float distance)
{
//Флаг - былоли столкновение
bool flag;

//точка начала луча - сначала начальная переданная точка
Vector3 start = fr;

//Информация о месте удара
RaycastHit hit;

//Делаем
do
{
//Ударились ли во что-то?
flag = Physics.Linecast(start, to, out hit);

//рассчитываем новую точку старта луча
start = hit.point + (to - start) * 0.01f;

//Делаем до тех пор пока ударяемся во что-то и это что-то не плейер и расстояние до конечной точки луча достаточно большое
} while (flag &amp;&amp; hit.collider.tag == "Player" &amp;&amp; (to - start).magnitude > 0.01f);

//Окончательное расстояние до соударения.
//Это мы возвращаем в out параметре
distance = hit.distance;

//Флаг - ударились ли вообще
return flag;
}


и переписать метод _CheckClip:
private float _CheckClip(Vector3 fromp, Vector3 to)
{
//Изначально устанавливаем такое значение, которое в результате рассчетов получиться не может
float nearestDistance = -1f;

//Создаем структуру - ближнюю плоскость отсечения
ClipPlane cp = new ClipPlane(to);

//Рисуем для отладки конус от персонажа к плоскости отсечения
DrawFrustum(fromp, to);

float dist;

if (_CheckClipI(fromp, cp.upperLeft, out dist)) nearestDistance = dist;
if (_CheckClipI(fromp, cp.lowerLeft, out dist))
if (dist < nearestDistance || nearestDistance == -1) nearestDistance = dist;

if (_CheckClipI(fromp, cp.lowerRight, out dist))

if (dist < nearestDistance || nearestDistance == -1) nearestDistance = dist;

if (_CheckClipI(fromp, cp.upperRight, out dist))

if (dist < nearestDistance || nearestDistance == -1) nearestDistance = dist;

if (_CheckClipI(fromp, to + _tr.forward * -_nearclip, out dist))

if (dist < nearestDistance || nearestDistance == -1) nearestDistance = dist;

//Возвращаем рассчитанное расстояние
return nearestDistance;
}


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

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

Ну а пока можете взять готовые скрипты ниже в архиве.

До встреч...


Май 28, 2015, 15:31:30 pm
Ответ #13

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Третья часть цикла - Состояния
(примечание: с появлением меканим, нижеописаное может быть реализовано с помощью его машины состояний)


Привет!

После некоторого перерыва продолжаю уроки. Задержка вызвана тем, что у меня были глюки с переносом анимаций в Юнити. Я для примера использую контент из Обливиона. Почему то при экспорте из 3dsmax постоянно идут глюки. Обойти я их смог, а вот выяснить отчего они - нет. Обходится перезагрузкой макса после каждого экспорта. Похоже это проблема всего контента из Облививна. Точно то же самое пришлось делать при импорте nif файлов в Макс. Но, в общем - все хорошо, что хорошо заканчивается!

Сразу предупрежу - этот материал сложнее, чем предыдущий. Но это и хорошо - идем от простого к сложному. Если что-то непонятно - прошу на здешний форум с вопросами. Можно их задавать и на других форумах - у Огасода и на русском портале по Юнити. Я там бываю постоянно.

Как импортировать, как создавать префабы  я тут описывать не стану. Все же уроки по программированию. Если будет время впоследствии сделаю отдельный урок по этой теме. Будем считать, что у Вас есть префаб персонажа, на котором уже висят наши скрипты из предыдущих уроков, у которого есть дочерний компонент - targetLookAt. На всем есть тег - Player.

Какие анимации нам нужны?
Ходьба вперед, назад и в стороны;
Бег вперед, назад и в стороны;
Подкрадывание вперед, назад и в стороны;
Подкрадывание "бегом" вперед, назад и в стороны;
Начало прыжка, падение и приземление;
Анимация стояния на месте (idle).

Итого пока 20 анимаций. При желании количество анимаций можно сократить. Но я буду описывать по максимуму.

Теперь нужно рассмотреть такое понятие как "состояние" персонажа. Можно на каждую анимацию ввести отдельное состояние. Однако это мне показалось неправильным. Чем собственно отличается ходьба от бега? Только скоростью перемещения и проигрываемой анимацией. Аналогично перемещение в стороны и назад. Все это одно состояние - перемещение - "Move". Подкрадывание - "Sneak" отличается от него значительно сильнее. В этом состоянии изменяются характеристики персонажа - его заметность для НПС, возможность сделать некоторые действия (например прыжок из подкрадывания сделать нельзя). Следующее, естественно вводимое состояние, - "Stay" - персонаж стоит без дела. Вот прыжок приходится разбивать на три состояния - начало прыжка - "Jump", падение - "Fall" и приземление - "Land". Во-первых в состояние падения можно попасть напрямую из состояния движения и подкрадывания просто упав со скалы. Во-вторых в состояние приземления можно вообще никогда не попасть, если скала слишком высокая - попадешь прямиком в состояние "умер". И в-третьих такая разбивка просто облегчает программирование. В дальнейшем мы рассмотрим еще три состояния - карабканья "Climb", использования (действия) "Use" и смерти "Death". Но пока нам хватит шести.

Есть несколько способов управления состояниями. 3dbuzz предлагает просто внутри наших классов реализовать методы по одному на каждое состояние и общий метод переключения состояний. Однако это сильно не универсальное решение. К тому же выясняется, что для многих состояний приходится вводить дополнительные методы для "общения" классов между собой. Все это приводит к запутанному коду. Я опишу свое решение, правда оно, наверное, сложнее для понимания (во всяком случае для объяснения точно сложнее), но зато универсальное. Оно позволит использовать один общий класс для управления состояниями не только персонажей, но любых других объектов.

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

Первое - состояния мы будем привязывать к классу. Т.е. наш класс CharController будет иметь несколько состояний (см. выше какие). Другой класс, например - AIController, будет иметь другие состояния. Естественно включить в описание состояния тип класса, к которому это состояние принадлежит.

Класс CharController (Для примера и чтобы отличить класс, который имеет состояния от нашего класса State, я буду использовать именно его. Хотя все верно для любого класса, который может иметь состояния) может быть только в каком-то одном состоянии. Следующий параметр состояния - имя состояния. Оно однозначно отличает одно состояние класса от другого.

Чтобы меньше заботится о сохранении текущего состояния вводим еще один параметр - флаг находится ли класс CharController в данное время в этом состоянии или нет.

Следующие четыре параметра потребуют дополнительных объяснений. Это четыре метода, которые автоматом будут вызываться когда класс CharController входит в данное состояние, выходит из него, когда состояние заносится в стек состояний, и когда класс переходит в состояние вынимаемое из стека состояний. В принципе из предыдущего предложения уже многое понятно. Когда класс CharController  входит в какое-то состояние наш класс State автоматом вызовет некий метод Begin из класса CharController. Для каждого состояния можно определить свой такой метод, а можно использовать один для всех состояний. Аналогично когда CharController выходит из какого-то состояния вызывается метод End. Естественно, имя метода Вы выбираете сами! Это названия параметров состояния, а не методов класса CharController.

О стеке состояний. Часто необходимо переходить не любое возможное состояние, а в то, которое было до перехода в текущее. Пример - прыжок. После приземления было бы здорово не переходить в состояние Stay, а возвращаться в то, которое было до прыжка. Бежали до прыжка - после приземления продолжаем бежать. Для этого нужно где-то сохранять это состояние. Обычно вводят специальную переменную для этого. Мы же просто сохраним состояние в стеке, который организован в нашем классе State, а затем когда надо снимем его оттуда. Уровень вложения стека может быть любым. Соответственно этим двум действиям вводим два параметра - метод, вызываемый когда состояние кладется на стек и метод, который вызывается, когда состояние снимается со стека.

Подытожим кодом:

StateDesc.cs
public class StateDesc
{

    //Тип класса, к которому относится это состояние
    public Type ClassState { get; private set; }

    //Имя состояния
    public string Name { get; private set; }

    //Флаг - находится ли класс в этом состоянии или нет
    public bool InState { get; set; }

    //Метод, вызываемый при входе в это состояние
    public Action<string> Enter { get; private set; }

    //Метод, вызываемый при выходе из жтого состояния
    public Action<string> Exit { get; private set; }

    //Метод, вызываемый, когда это состояние кладется на стек
    public Action<string> Push { get; private set; }

    //Метод, вызываемый, когда это состояние снимается со стека
    public Action<string> Pop { get; private set; }

 

    //Конструктор состояния
    public StateDesc(Type t, string n, Action<string> en, Action<string> ex, Action<string> pu, Action<string> po)
    {
        ClassState = t;
        Name = n;
        Enter = en;
        Exit = ex;
        Push = pu;
        Pop = po;
        InState = false;
    }
}


Это класс, описывающий одно состояние. В нем мы видим те параметры, которые обсуждали выше и конструктор, который создает новое состояние.

Теперь переходим к самому менеджеру состояний.

В нем в первую очередь нужно как-то сохранять состояния. Для каждого класса, которое может иметь состояния нужно иметь свое хранилище. Все эти хранилища объединяем в словарь Dictionary. Ключ в словаре - тип класса. Сам тип (Type) не очень удобно использовать в качестве ключа. Будем использовать GUID, который есть в Type. Все состояния одного класса объединяем в список (List).

Аналогично. создаем хранилище для стека состояний - словарь в котором ключи - GUID классов, а значения - стеки состояний (Stack):
private static readonly Dictionary<Guid, List<StateDesc>> _states = new Dictionary<Guid, List<StateDesc>>();
private static readonly Dictionary<Guid, Stack<StateDesc>> _stacks = new Dictionary<Guid, Stack<StateDesc>>();


Далее - для работы с состояниями нам нужны методы:
Добавить состояние - AddState;
Удалить состояние - RemoveState;
Проверить есть ли такое состояние - HasState;
Найти состояние по имени - FindState;
Найти состояние, в котором сейчас находится класс - FindCurrent;
Изменить состояние - EditState;
Переключить флаг текущего состояния - ToggleInState;
Перейти в состояние - GoToState;
Положить состояние на стек - PushState;
Снять состояние со стека - PopState.

Все методы я тут подробно описывать не буду. Приведу для примера пару. Остальные смотрите в коде комментарии.
     static public void AddState(StateDesc sd)
    {
        //Получаем GUID класса у которого это состояние
        var g = sd.ClassState.GUID;

        //Если этот класс еще не регистрировал свои состояния
        if (!_states.ContainsKey(g))
        {
            //Добавляем новый список состояний этого класса в общий словарь состояний.
            //По умолчанию 16 состояний. Если будет больше - список автоматически растет
            _states.Add(g, new List<StateDesc>(16));

            //Добавляем в новый список это состояние
            _states[g].Add(sd);
        }

        //Иначе - если такого состояния еще нет в списке состояний данного класса
        else if (!_states[g].Contains(sd))

            //Добавляем это состояние в список состояний класса
            _states[g].Add(sd);
    }


Как видите он подробно задокументирован. Что-то еще по нему писать, по моему не нужно.
     ///
    /// Переходим в состояние n класса с типом t
    ///
    /// t -Тип класса, состояние которого меняем
    /// n - Имя состояния в которое переходим

    static public void GoToState(Type t, string n)
    {

        //Debug.Log("IN_STATE-" + n);

        //Получаем GUID класса
        var g = t.GUID;

        //Если для данного класса не зарегистрированы состояния
        if (!_states.ContainsKey(g))

            //Бросаем исключение. Тут можно просто Debug.LogError()
            throw new Exception("Попытка перейти в состояние перед добавлением состояний для класса");

        //Пытаемя найти состояние с таким именем
        var sdNew = FindState(new StateDesc(t, n, null, null, null, null));

        //Если нет такого состояния
        if (sdNew == null)

            //Бросаем исключение
            throw new Exception("Попытка перейти в несуществующее состояние класса");

        //Ищем текущее состояние класса
        var sdOld = FindCurrent(t);

        //Если состояние на самом деле не меняется - просто выходим
        if(sdOld == sdNew) return;

        //Если мы были до того в каком-то состоянии
        if (sdOld != null)
        {
            //Если определен метод, который нужно вызвать при выходе из состояния
            if (sdOld.Exit != null)

                //Вызываем его
                sdOld.Exit(sdOld.Name); //Выход из старого состояния

            //Изменяем флаг - теперь это состояние не текущее для класса
            ToggleInState(sdOld);
        }

        //Если определен метод, который нужно вызвать при входе в состояние
        if (sdNew.Enter != null)

            //Вызываем его
            sdNew.Enter(n); //Вход в новое

        //Изменяем флаг - теперь это состояние текущее у класса
        ToggleInState(sdNew);
    }

Тоже, в принципе метод достаточно задокументирован. В любом случае - Вы можете задать мне вопрос на любом форуме.
Ну вот пока все. (Уфф! Сколко 'букофф'!!!)

Класс целиком можно взять ниже в архиве.

« Последнее редактирование: Май 29, 2015, 22:59:17 pm от Mimi Neko »

Май 29, 2015, 13:01:11 pm
Ответ #14

Mimi Neko

  • Администратор
  • Старожил форума

  • Оффлайн
  • *****

  • 2456
  • Репутация:
    153
    • Просмотр профиля
Класс CharAnimator

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

Кто-то спросит - зачем тут хранить анимации, если уже есть хранение их в любом анимированном объекте - компонент Animation? Все очень просто. У меня персонаж состоит из нескольких частей. Отдельно руки, ноги, ступни, кисти, туловище, таз и голова. Сделано это для того, чтобы проще было "одевать" персонаж в разную амуницию. Т.е. Вы можете купить кирасу, одеть ее на туловище, но не иметь сапог вообще. Получается в инспекторе нужно перетаскивать анимации на очень много объектов. Мне это делать лень. Поэтому я создал пустой объект, перетащил на него анимации, навесил на него все скрипты персонажа, а один из них - новый класс CharAnimator все эти анимации при запуске перемещает на все части тела персонажа.

Что для этого нужно:
AnimationClip[] - массив всех анимаций персонажа;
Animation[] - массив всех компонентов Animation всех частей тела персонажа, которые сейчас есть в сцене.

Ну и, естественно, метод, который все это обрабатывает.
    public AnimationClip[] anim;

    public Animation[] allAnim;

 
    void Awake()
    {
        //Ищем все компоненты анимации у всех детей (части тела - дети этого объекта)
        allAnim = gameObject.GetComponentsInChildren<Animation>();

        //Для каждого компонента
        foreach (Animation an in allAnim)
        {
            //Для каждой анимации, которые есть у персонажа
            foreach (AnimationClip ac in anim)
            {
                //если такая анимация уже есть на части тела (если Вы ее вручную наложили в инспекторе) - пропускаем
                if (an[ac.name] != null) continue;

                //иначе добавлем в компонент Animation части тела эту анимацию
                an.AddClip(ac, ac.name);
            }             
        }
    }

В инспекторе переносим в массив anim все нужные анимации, а этот скрипт уже сам находит всех детей - части тела и назначает им эти анимации.

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

Для того чтобы наш класс анимаций мог выбрать какую анимацию проигрывать ему нужно передавать некие данные. Что ему нужно:
state - имя состояния в которое переходим;
move - вектор движения - анимация выбирается в зависимости от направления движения вперед/назад;
speed - флаг, показывающий бежит персонаж или идет.

Я эти параметры объединил в одну структуру, которую определил в классе CharController:
     public struct SwAnim
    {
        //Имя состояния
        public string state;

        //вектор движения
        public Vector3 move;

        //параметр, показывающий бежит персонаж, или идет
        public int speed;


        //Конструктор структуры
        public SwAnim(string s, Vector3 m, int sp)
        {
            state = s;
            move = m;
            speed = sp;
        }
    }

Ну и сам метод, который вызывается при смене состояния.
    private void _SwitchState(CharController.SwAnim sw)
    {
        //Для всех частей тела
        foreach (Animation part in allAnim)
        {
            //В зависимости от состояния, в которое переходим
            switch (sw.state)
            {
                //Если стоим
                case "Stay":

                    //просто проигрываем анимацию
                    part.CrossFade("idle");
                    break;

                //Если двигаемся
                case "Move":

                    //Анимация движения в зависимости от направления и скорости);
                    switch (sw.speed)
                    {
                        //Идем
                        case 0:

                            //проверяем направление вперед/назад
                            part.CrossFade(sw.move.z > 0 ? "walkforward" : "walkbackward");

                            //Если при этом еще есть движение в стороны
                            if (sw.move.x < 0)

                                //подмешиваем анимацию движения влево
                                part.Blend("walkleft", -sw.move.x / 2);

                            else

                                // и вправо
                                part.Blend("walkright", sw.move.x / 2);
                            break;

                        //бежим - все аналогично, только другие анимации
                        case 1:

                            part.CrossFade(sw.move.z > 0 ? "walkfastforward" : "walkfastbackward");

                            if (sw.move.x < 0) //налево
                                part.Blend("walkfastleft", -sw.move.x / 2);

                            else               //направо
                                part.Blend("walkfastright", sw.move.x / 2);
                            break;

                    }
                    break;

                //Если подкрадываемся - не отличается от движения, только меняется анимация...
                case "Sneak":

                    //Анимация движения в зависимости от направления и скорости);
                    switch (sw.speed)
                    {
                        case 0:

                            part.CrossFade(sw.move.z > 0 ? "sneakforward" : "sneakbackward");

                            if (sw.move.x < 0) //налево
                                part.Blend("sneakleft", -sw.move.x / 2);

                            else               //направо
                                part.Blend("sneakright", sw.move.x / 2);
                            break;

                        case 1:

                            part.CrossFade(sw.move.z > 0 ? "sneakfastforward" : "sneakfastbackward");

                            if (sw.move.x < 0) //налево
                                part.Blend("sneakfastleft", -sw.move.x / 2);

                            else               //направо
                                part.Blend("sneakfastright", sw.move.x / 2);
                            break;

                    } break;

                //Начало прыжка
                case "Jump":

                    //Прогигрываем анимацию прыжка. Окончание определяется контроллером
                    part.CrossFade("jumpstart");
                    break;

                //Свободное падение
                case "Fall":

                    //Прогигрываем анимацию падения. Окончание определяется контроллером
                    part.CrossFade("jumploop");
                    break;

                //Приземление
                case "Land":

                    //Прогигрываем анимацию приземления. Окончание определяется контроллером
                    part.CrossFade("jumpland");
                    break;

                //Действие - пока не реализовано
                case "Use":
                    break;

                //Карабканье - пока не реализовано
                case "Climb":
                    break;
            }           
        }
    }

Метод простой - для всех частей тела запускаем нужную анимацию в зависимости от входных параметров. Если персонаж двигается по диагонали - подмешиваем к анимации вперед/назад еще и анимацию налево/направо.