Паркур и Unity - часть 1

Доброго времени суток. Представлюсь, для тех, кто меня еще не знает. Меня зовут Дима. Я работаю C++ разработчиком уже более 5 лет. На данный момент работаю в крупной Gamedev-студии. Помимо работы увлекаюсь созданием образовательного контента для YouTube и Twitch каналов.

В своём небольшом пет-проекте я вдохновился идеей реализовать свою систему паркура. Что такое паркур? Паркур – скоростное перемещение и преодоление препятствий с использованием прыжковых элементов. Главная проблема, которую надо решить: персонаж должен уметь не только прыгать и бегать, но и взаимодействовать с препятствиями. Лучшим примером паркура в играх от 3-го лица можно назвать серию Assassin's Creed, а в играх от первого лица это бесспорно Mirror’s Edge. Последней я и буду вдохновляться. Разберу решение, к которому я пришёл на данный момент и какие задачи я при этом решил.

Что такое препятствие?

В первую очередь определимся, что мы будем воспринимать как препятствие, с которым может взаимодействовать игрок. После недолгих раздумий я остановился на параллелепипедах, вращения которых вокруг осей OX и OZ равно 0. То есть это параллелепипеды нижняя и верхняя грань которых строго параллельны поверхности "пола". Не стоит их воспринимать как набор абстрактных кубов, по которым будет лазать главный герой. Настраивая их масштабы, из них можно сделать платформу из Mario, плоский рекламный щит, трубы системы вентиляции и так далее.

Какая у вас цель?

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

Разбиение препятствия на составляющие

У каждого препятствия я выделил 5 основных элементов: четыре стены и верхняя грань. Стены – это та часть, по которой мы будем карабкаться, верхняя грань – цель. Для того, чтобы разбить препятствие, мы обратимся к компоненту BoxCollider. Используя его размеры, а также масштаб самого объекта, его вращение и координаты вычислим 8 точек углов параллелепипеда в мировой системе координат. Перейдём к реализации метода.

//Метода получает на выход gameObject, который мы хотим конвертировать //В класс ParkourObstacle, который рассмотри далее private ParkourObstacle GetParkourObstacle(GameObject gameObject) { //Получаем доступ к BoxCollider-у переданного объекта BoxCollider collider = gameObject.GetComponent<BoxCollider>(); //Массив на 8 точек-углов var points = new Vector3[8]; //Прихраним для удобства размеры коллайдера по всем осям координат float xSize = collider.size.x; float ySize = collider.size.y; float zSize = collider.size.z; //Запоминаем позицию центра коллайдера, относительно объекта-препятствия //В большинстве случаев она будет равна Vector3.zero Vector3 center = collider.center; //Далее получаем все углы. Вычисляем локальную координату угла коллайдера //А затем с помощью функции TransformPoint преобразуем её в мировую систему координаты //Дабы не сбиться я расставлял плюсы и минусы ориентируясь на двоичное представление индекса в массиве //Где 0 это плюс, а 1 это минус //Можно было бы выполнить это всё циклом, но не думаю, что такой код был бы сильно короче или более читаемым points[0] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y + ySize / 2, center.z + zSize / 2)); points[1] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y + ySize / 2, center.z + zSize / 2)); points[2] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y - ySize / 2, center.z + zSize / 2)); points[3] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y - ySize / 2, center.z + zSize / 2)); points[4] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y + ySize / 2, center.z - zSize / 2)); points[5] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y + ySize / 2, center.z - zSize / 2)); points[6] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y - ySize / 2, center.z - zSize / 2)); points[7] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y - ySize / 2, center.z - zSize / 2)); //Далее я эмпирическим методом определил, какие точки относятся к каким граням //Wall0 - 0, 1, 2, 3 //Wall1 - 0, 2, 4, 6 //Wall2 - 4, 5, 6, 7 //Wall3 - 1, 3, 5, 7 //Floor - 0, 1, 4, 5 //Создаём объект ParkourObstacle, входными параметрами для конструктора которого, //Будут верхняя грань, массив стен, и коллайдер. return new ParkourObstacle(new ParkourObstacle.Wall(points[0], points[1], points[4], points[5]), new ParkourObstacle.Wall[] { new ParkourObstacle.Wall(points[0], points[1], points[2], points[3]), new ParkourObstacle.Wall(points[0], points[2], points[4], points[5]), new ParkourObstacle.Wall(points[4], points[5], points[6], points[7]), new ParkourObstacle.Wall(points[1], points[3], points[5], points[7]), }, collider); }

Разберём класс ParkourObstacle. Главная задача данного класса – хранить набор стен и верхнюю грань, а также уметь определять, к какой именно стене относится переданная ему точка. Рассмотрим реализацию, элементы вроде свойств и конструкторов я на данном этапе исключу, дабы не отвлекать от сути. Полный код будет предоставлен в конце статьи.

public class ParkourObstacle { //Верхняя Грань private Wall _floor; //Стены private Wall[] _walls; private BoxCollider _collider; //Пытаемся определить к какой стене относится точка в пространстве //Если таких стен нет //Возвращаем false public bool TryGetWall(Vector3 point, ref Wall wall) { for (int i = 0; i < _walls.Length; i++) { //Составляем плоскость из трёх точек стены //Если ближайшая точка на плоскости совпадает с переданной нами точкой //Мы сюда передаём точки только принадлежащие BoxCollider //Так что такой проверки достаточно Plane wallPlane = new Plane(_walls[i].GetPoint(0), _walls[i].GetPoint(1), _walls[i].GetPoint(2)); if (wallPlane.ClosestPointOnPlane(point) == point) { wall = _walls[i]; return true; } } return false; } public struct Wall { //Не получилось создать массив ибо struct - это тип-значение //А массив - это тип-ссылка private Vector3 _point0; private Vector3 _point1; private Vector3 _point2; private Vector3 _point3; private const int _pointsCount = 4; //Метод для удобного получения точек стены public Vector3 GetPoint(int index) { switch (index) { case 0: return _point0; case 1: return _point1; case 2: return _point2; case 3: return _point3; default: throw new System.Exception("Wall::GetPoint index out of range"); } } } }

Управление

Все предварительные операции сделаны. Теперь можно реализовать поведение персонажа. Сложно назвать это управлением, скорее определение момента, когда нужно запускать паркур. Не буду акцентировать внимание на собственной реализации скрипта управления от первого лица. В нём есть недоработки и не думаю, что очередным FPS-контроллером можно кого-то удивить. Если Вас интересует его реализация, пишите в комментариях, напишу статью. Всю логику паркура я вынес в отдельный скрипт, не скажу, что он легко и просто подойдёт любому скрипту управления от первого лица. Тем не менее, с небольшими изменениями в нём и в своей логике управления, Вы можете брать его на вооружение.

В двух словах условия запуска паркура можно описать так:

  • Есть препятствия доступные для попытки паркура
  • Игрок не касается земли
  • Нажата клавиша перемещения вперёд
  • В данный момент игрок не "паркурит"
  • Нажата клавиша прыжка

Думаю стоит пояснять только первое условие. Препятствия доступные для попытки будут определяться с помощью CapsuleCollider, который будет триггер-коллайдером. То есть будет только определять попадание и выход из него других коллайдеров с помощью функций OnTriggerEnter и OnTriggerExit:

private List<ParkourObstacle> _parkourObstacles = new List<ParkourObstacle>(); private void OnTriggerEnter(Collider other) { if (((1 << other.gameObject.layer) & _obstacleMask) != 0) { //Преобразуем в паркур-препятствие и добавляем в коллекцию доступных _parkourObstacles.Add(GetParkourObstacle(other.gameObject)); } } private void OnTriggerExit(Collider other) { if (((1 << other.gameObject.layer) & _obstacleMask) != 0) { BoxCollider collider = other.GetComponent<BoxCollider>(); //Collider будет свойством для поиска паркур препятствия _parkourObstacles.Remove(_parkourObstacles.Find(obstacle => obstacle.Collider == collider)); } }

Далее в кадре логики, проверив все эти условия и определив препятствия для паркура(Physics.Raycast из позиции персонажа, а не камеры, направленный вперёд), мы убеждаемся, что расстояние до верхней грани препятствия не превышает допустимый лимит (настраиваемый параметр), и после этого запускаем алгоритм залезания:

private void Update() { //Проверка исходных условий bool isForwardPressed = Input.GetAxis("Vertical") > 0f; bool isJumpPressed = Input.GetAxis("Jump") > 0f; if (!_playerController.IsGrounded && isForwardPressed && !_isClimbing && _parkourObstacles.Count > 0 && isJumpPressed) { //Находим коллайдер для попытки паркура RaycastHit hit; if (Physics.Raycast(transform.position, transform.forward, out hit, _parkourCollider.radius, _obstacleMask)) { //Препятствие в коллекции var parkourObstacle = _parkourObstacles.Find(obstacle => obstacle.Collider == hit.collider); ParkourObstacle.Wall wall = default(ParkourObstacle.Wall); //Если нашли препятствие и успешно получили стену из него if (parkourObstacle != null && parkourObstacle.TryGetWall(hit.point, ref wall)) { //Проверяем получаем высшую точку препятствия //Проверяем что разница высот между точкой начала залезания и высшей точкой препятствия допустимая Vector3 highestPoint = wall.GetHighestPoint(); if (highestPoint.y > transform.position.y && highestPoint.y - transform.position.y <= _parkourHeight) { //Запускаем паркур StartClimbing(parkourObstacle, wall, hit.point); } } } } }

Карабканье

Процесс залезание на препятствие я разбил на три этапа:

  • Переход от перемещения к паркуру
  • Подъём вверх
  • Небольшое перемещение вперед и возвращение управления персонажу.

Зачем нужен переход? В большинстве случаев точка начала залезания не будет совпадать с положением персонажа в пространстве, поэтому надо персонажа плавно переместить и повернуть лицом к стене. Скорость перемещения будет настраиваемым параметром. Далее следует подъём вверх и перемещение вперед. Данный код будет выполнятся в рамках одной Корутины.
Логика умещается в три метода:

  • StartClimbing - Вычисление стартовых точек, отключение управления запуск карабканья, сохранение скорости персонажа до начала паркура
  • Climbing - Корутина, выполняющая три этапа перемещения.
  • StopClimbing - Возвращение управления персонажу, придача ему скорости до паркура.

За подробностями прошу в код:

private void StartClimbing(ParkourObstacle obstacle, ParkourObstacle.Wall wall, Vector3 startPoint) { //Взводим флаг _isClimbing = true; //Создаём плоскость стены дабы с помощью нормали её определить точку начала паркура Plane wallPlane = new Plane(wall.GetPoint(0), wall.GetPoint(1), wall.GetPoint(2)); //Вычисляем две точки начала паркура, по обе сторон от стены Vector3 startPosition1 = startPoint + (wallPlane.normal * _playerController.CharacterRadius * -1); Vector3 startPosition2 = startPoint + (wallPlane.normal * _playerController.CharacterRadius); //та точка которая будет ближайшей к игроку, нас и интересует. //Очень важно правильно выбрать точку. Ибо игрок может оказаться внутри препятствия //или на противоположной стороне от него Vector3 climbStartPosition = Vector3.Distance(transform.position, startPosition1) < Vector3.Distance(transform.position, startPosition2) ? startPosition1 : startPosition2; //Вращение "Лицом к стене" Quaternion climbStartRtotation = Quaternion.LookRotation(startPoint - climbStartPosition, Vector3.up); //Запоминаем горизонтальную скорость которая была до начала залезания _velocityBeforeClimb = new Vector2(_rigidbody.velocity.x, _rigidbody.velocity.z).magnitude; //Сбрасываем скорость _rigidbody.velocity = Vector3.zero; //Отключаем управление персонажем _playerController.enabled = false; //Выключаем физику, дабы гравитация нам не мешала _rigidbody.isKinematic = true; //Запускаем корутину _parkourCoroutine = StartCoroutine(Climbing(obstacle, wall, climbStartPosition, climbStartRtotation)); } private IEnumerator Climbing(ParkourObstacle obstacle, ParkourObstacle.Wall wall, Vector3 startPoint, Quaternion startRotation) { //Первый этап из позиции персонажа к позиции начала паркура Vector3 startPlayerPosition = transform.position; Quaternion startPlayerRotation = transform.rotation; float elapsed = 0f; //Вычисляем время на перемещение из скорости перемещения float translationTime = Vector3.Distance(startPlayerPosition, startPoint) / _translationVelocity; while (elapsed < translationTime) { //Плавно перемещаем персонажа transform.position = Vector3.Lerp(startPlayerPosition, startPoint, elapsed / translationTime); transform.rotation = Quaternion.Lerp(startPlayerRotation, startRotation, elapsed / translationTime); elapsed += Time.deltaTime; yield return null; } //Второй этап - подъём вверх //Вычисляем точку назначения из позиции персонажа //Добавив к ней высоту подъёма и половину "роста" персонажа startPlayerPosition = transform.position; Vector3 targetUpPosition = new Vector3(transform.position.x, wall.GetHighestPoint().y + _playerController.CharacterHeight / 2, transform.position.z); elapsed = 0f; float climbTime = Vector3.Distance(startPlayerPosition, targetUpPosition) / _climbVelocity; while (elapsed < climbTime) { transform.position = Vector3.Lerp(startPlayerPosition, targetUpPosition, elapsed / climbTime); elapsed += Time.deltaTime; yield return null; } //Последний этап //Выдвигаем персонажа вперед на два его радиуса //Дабы он комфортно остановился на верхушке препятствия, а не на его кромке Vector3 targetForwardPosition = targetUpPosition + transform.forward * _playerController.CharacterRadius * 2; float forwardTime = Vector3.Distance(startPlayerPosition, targetForwardPosition) / _forwardVelocity; elapsed = 0f; startPlayerPosition = transform.position; while (elapsed < forwardTime) { transform.position = Vector3.Lerp(startPlayerPosition, targetForwardPosition, elapsed / forwardTime); elapsed += Time.deltaTime; yield return null; } //Возвращаем управление персонажем игроку StopClimbing(); } private void StopClimbing() { _isClimbing = false; _playerController.enabled = true; _rigidbody.isKinematic = false; _rigidbody.velocity = transform.forward * _velocityBeforeClimb; }

Заключение

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

Ссылки на нас на других ресурсах:
Youtube
Twitch
Telegram

Код

public class ParkourController : MonoBehaviour { [SerializeField] private LayerMask _obstacleMask; [SerializeField] [Range(0f, 5f)] private float _parkourHeight = 1f; [SerializeField] [Range(0, 60f)] float _translationVelocity = 2f; [SerializeField] [Range(0, 60f)] float _climbVelocity = 2f; [SerializeField] [Range(0, 60f)] float _forwardVelocity = 2f; private List<ParkourObstacle> _parkourObstacles = new List<ParkourObstacle>(); private DoomController _playerController; private CapsuleCollider _parkourCollider; private Rigidbody _rigidbody; private bool _isClimbing = false; private float _velocityBeforeClimb; private void Start() { _playerController = GetComponent<DoomController>(); _rigidbody = GetComponent<Rigidbody>(); foreach (CapsuleCollider collider in GetComponents<CapsuleCollider>()) { if (collider.isTrigger) { _parkourCollider = collider; break; } } } private void Update() { bool isForwardPressed = Input.GetAxis("Vertical") > 0f; bool isJumpPressed = Input.GetAxis("Jump") > 0f; if (isCrouchPressed && _parkourCoroutine != null) { StopCoroutine(_parkourCoroutine); _parkourCoroutine = null; StopClimbing(false); } if (!_playerController.IsGrounded && isForwardPressed && !_isClimbing && _parkourObstacles.Count > 0 && isJumpPressed) { RaycastHit hit; if (Physics.Raycast(transform.position, transform.forward, out hit, _parkourCollider.radius, _obstacleMask)) { var parkourObstacle = _parkourObstacles.Find(obstacle => obstacle.Collider == hit.collider); ParkourObstacle.Wall wall = default(ParkourObstacle.Wall); if (parkourObstacle != null && parkourObstacle.TryGetWall(hit.point, ref wall)) { Vector3 highestPoint = wall.GetHighestPoint(); if (highestPoint.y > transform.position.y && highestPoint.y - transform.position.y <= _parkourHeight) { StartClimbing(parkourObstacle, wall, hit.point); } } } } } private void StartClimbing(ParkourObstacle obstacle, ParkourObstacle.Wall wall, Vector3 startPoint) { _isClimbing = true; Plane wallPlane = new Plane(wall.GetPoint(0), wall.GetPoint(1), wall.GetPoint(2)); Vector3 startPostion1 = startPoint + (wallPlane.normal * _playerController.CharacterRadius * -1); Vector3 startPostion2 = startPoint + (wallPlane.normal * _playerController.CharacterRadius); Vector3 climbStartPosition = Vector3.Distance(transform.position, startPostion1) < Vector3.Distance(transform.position, startPostion2) ? startPostion1 : startPostion2; Quaternion climbStartRtotation = Quaternion.LookRotation(startPoint - climbStartPosition, Vector3.up); _velocityBeforeClimb = new Vector2(_rigidbody.velocity.x, _rigidbody.velocity.z).magnitude; _rigidbody.velocity = Vector3.zero; _playerController.enabled = false; _rigidbody.isKinematic = true; _parkourCoroutine = StartCoroutine(Climbing(obstacle, wall, climbStartPosition, climbStartRtotation)); } private void StopClimbing(bool isComplite = true) { _isClimbing = false; _playerController.enabled = true; _rigidbody.isKinematic = false; LastParkourTime = Time.realtimeSinceStartup; _rigidbody.velocity = transform.forward * _velocityBeforeClimb; } private IEnumerator Climbing(ParkourObstacle obstacle, ParkourObstacle.Wall wall, Vector3 startPoint, Quaternion startRotation) { Vector3 startPlayerPosition = transform.position; Quaternion startPlayerRotation = transform.rotation; float elapsed = 0f; float translationTime = Vector3.Distance(startPlayerPosition, startPoint) / _translationVelocity; while (elapsed < translationTime) { transform.position = Vector3.Lerp(startPlayerPosition, startPoint, elapsed / translationTime); transform.rotation = Quaternion.Lerp(startPlayerRotation, startRotation, elapsed / translationTime); elapsed += Time.deltaTime; yield return null; } transform.rotation = startRotation; startPlayerPosition = transform.position; Vector3 targetUpPosition = new Vector3(transform.position.x, wall.GetHighestPoint().y + _playerController.CharacterHeight / 2, transform.position.z); Vector3 targetForwardPosition = targetUpPosition + transform.forward * _playerController.CharacterRadius * 2; elapsed = 0f; float climbTime = Vector3.Distance(startPlayerPosition, targetUpPosition) / _climbVelocity; while (elapsed < climbTime) { transform.position = Vector3.Lerp(startPlayerPosition, targetUpPosition, elapsed / climbTime); elapsed += Time.deltaTime; yield return null; } float forwardTime = Vector3.Distance(startPlayerPosition, targetForwardPosition) / _forwardVelocity; elapsed = 0f; startPlayerPosition = transform.position; while (elapsed < forwardTime) { transform.position = Vector3.Lerp(startPlayerPosition, targetForwardPosition, elapsed / forwardTime); elapsed += Time.deltaTime; yield return null; } StopClimbing(); } private void OnTriggerEnter(Collider other) { if (((1 << other.gameObject.layer) & _obstacleMask) != 0) { _parkourObstacles.Add(GetParkourObstacle(other.gameObject)); } } private void OnTriggerExit(Collider other) { if (((1 << other.gameObject.layer) & _obstacleMask) != 0) { BoxCollider collider = other.GetComponent<BoxCollider>(); _parkourObstacles.Remove(_parkourObstacles.Find(obstacle => obstacle.Collider == collider)); Debug.Log(_parkourObstacles.Count()); } } private ParkourObstacle GetParkourObstacle(GameObject gameObject) { BoxCollider collider = gameObject.GetComponent<BoxCollider>(); var points = new Vector3[8]; float xSize = collider.size.x; float ySize = collider.size.y; float zSize = collider.size.z; Vector3 center = collider.center; //Wall0 - 0, 1, 2, 3 //Wall1 - 0, 2, 4, 6 //Wall2 - 4, 5, 6, 7 //Wall3 - 1, 3, 5, 7 //Floor - 0, 1, 4, 5 points[0] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y + ySize / 2, center.z + zSize / 2)); points[1] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y + ySize / 2, center.z + zSize / 2)); points[2] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y - ySize / 2, center.z + zSize / 2)); points[3] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y - ySize / 2, center.z + zSize / 2)); points[4] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y + ySize / 2, center.z - zSize / 2)); points[5] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y + ySize / 2, center.z - zSize / 2)); points[6] = gameObject.transform.TransformPoint(new Vector3(center.x + xSize / 2, center.y - ySize / 2, center.z - zSize / 2)); points[7] = gameObject.transform.TransformPoint(new Vector3(center.x - xSize / 2, center.y - ySize / 2, center.z - zSize / 2)); return new ParkourObstacle(new ParkourObstacle.Wall(points[0], points[1], points[4], points[5]), new ParkourObstacle.Wall[] { new ParkourObstacle.Wall(points[0], points[1], points[2], points[3]), new ParkourObstacle.Wall(points[0], points[2], points[4], points[5]), new ParkourObstacle.Wall(points[4], points[5], points[6], points[7]), new ParkourObstacle.Wall(points[1], points[3], points[5], points[7]), }, collider); } } public class ParkourObstacle { private Wall _floor; private Wall[] _walls; private BoxCollider _collider; public BoxCollider Collider { get => _collider; } public ParkourObstacle(Wall floor, Wall[] walls, BoxCollider collider) { _floor = floor; _walls = walls; _collider = collider; } public bool TryGetWall(Vector3 point, ref Wall wall) { for (int i = 0; i < _walls.Length; i++) { Plane wallPlane = new Plane(_walls[i].GetPoint(0), _walls[i].GetPoint(1), _walls[i].GetPoint(2)); if (wallPlane.ClosestPointOnPlane(point) == point) { wall = _walls[i]; return true; } } return false; } public struct Wall { private Vector3 _point0; private Vector3 _point1; private Vector3 _point2; private Vector3 _point3; private const int _pointsCount = 4; public Wall(Vector3 point0, Vector3 point1, Vector3 point2, Vector3 point3) { _point0 = point0; _point1 = point1; _point2 = point2; _point3 = point3; } public Vector3 GetHighestPoint() { Vector3 maxPoint = Vector3.negativeInfinity; for (int i = 0; i < _pointsCount; i++) { var currentPoint = GetPoint(i); if (currentPoint.y > maxPoint.y) { maxPoint = currentPoint; } } return maxPoint; } public Vector3 GetPoint(int index) { switch (index) { case 0: return _point0; case 1: return _point1; case 2: return _point2; case 3: return _point3; default: throw new System.Exception("Wall::GetPoint index out of range"); } } } }
4949
20 комментариев

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

4
Ответить

На счёт подсайта по движкам. Когда я последний раз проверял подсайт UE, там небыли ни единой статьи об UE5, все публиковались в какие-то другие посайты. Возможно, подсайт Unity так же живёт на какой-то своей волне,)

Ответить

Лучшим примером паркура в играх от первого лица это бесспорно Mirror’s Edge.Не соглашусь, Mirror’s Edge скорее был первой годной игрой про паркур, а вот на сегодняшний день первое место - это Dying Light.
Те, кто проходил там испытания ловкости на время не дадут соврать.

2
Ответить

Если рассматривать его для выполнения игровых задач - да.
А по ощущениям все же Mirror’s Edge лучше.

3
Ответить

Сейчас попробую найти подходящий пример использования и добавлю.

1
Ответить