Простой зомби-шутер на Unity. Часть 1

Простой зомби-шутер на Unity. Часть 1

Life-Hack [Жизнь-Взлом]/Хакинг

#Обучение 

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

Дисклеймер №1: данная статья рассчитана на новичков. Если вы собаку съели в Unity, то она может показаться вам скучной.

Дисклеймер №2: для прочтения этой статьи вам потребуется хотя-бы базовое знание программирования. Как минимум, слова «класс» и «метод» не должны вас пугать.

Осторожно, под катом трафик!

Введение в Юнити

Если вы уже знакомы с редактором Unity, можете пропустить введение и перейти сразу к разделу “Создаем игровой мир”.

Основной структурной единицей в Unity является “сцена”. Сцена — это обычно один уровень игры, хотя в некоторых случаях может быть и сразу нескольких уровней в одной сцене или, наоборот, один большой уровень может быть разбит на несколько сцен, подгружаемых динамически. Сцены наполняются игровыми объектами, а они, в свою очередь, наполняются компонентами. Именно компоненты реализуют различные игровые функции: рисование объектов, анимацию, физику и т.п. Такая модель позволяет собирать функциональность из простых блоков, как игрушку из конструктора Лего.

Компоненты можно писать и самому, для этого используется язык программирования C#. Именно таким образом пишется игровая логика. Чуть ниже мы посмотрим как это делается, а пока давайте взглянем на сам движок.

Когда вы запустите движок и создадите новый проект, вы увидите перед собой окно, в котором можно выделить четыре основных элемента:

В верхнем левом углу на скриншоте находится окно Иерархии (“Hierarchy”). Здесь мы можем видеть иерархию игровых объектов в текущей открытой сцене. Unity создал для нас два игровых объекта: камеру (“Main Camera”), через которую игрок будет видеть наш игровой мир и источник света (“Directional Light”), который будет освещать нашу сцену. Без него мы бы видели только черный квадрат.

В центре находится окно редактирования сцены (“Scene”). Здесь мы видим наш уровень и можем его редактировать визуально — двигать и поворачивать объекты мышкой и смотреть, что из этого получается. Рядышком можно увидеть вкладку “Game”, которая сейчас неактивна; если переключиться на нее, то можно будет увидеть, как игра выглядит из камеры. А если запустить игру (кнопкой со значком воспроизведения на панели инструментов), то Unity переключится на эту вкладку, где мы и будем играть в запущенную игру.

В правой верхней части находится окно Инспектора (“Inspector”). В этом окне Unity показывает параметры выбранного объекта и мы можем их редактировать. В частности, мы можем видеть, что у выбранной камеры есть два компонента — “Transform”, который задает положение камеры в игровом мире и, собственно, “Camera”, который и реализует функциональность камеры.

Кстати, компонент Transform есть в том или ином виде у всех игровых объектов в Unity.

Ну и, наконец, в нижней части располагается вкладка “Project”, где мы можем видеть все так называемые ассеты, которые есть в нашем проекте. Ассеты — это файлы с данными, такие как текстуры, спрайты, 3d-модели, анимации, звуки и музыка, конфигурационные файлы. То есть, любые данные, которые мы можем использовать для создания уровней или пользовательского интерфейса. Unity понимает большое количество стандартных форматов (например, png и jpg для картинок, или fbx для 3d-моделей), так что проблем с загрузкой данных в проект не возникнет. А если вы, как и я, не умеете рисовать, то ассеты можно загружать из Unity Asset Store, где собрана огромная коллекция всевозможных ресурсов: как бесплатных, так и продаваемых за деньги.

Справа от вкладки “Project” виднеется неактивная вкладка “Console”. В консоль Unity пишет предупреждения и сообщения об ошибках, так что не забывайте туда периодически поглядывать. Особенно, если что-то не работает — скорее всего, в консоли будет намек на причину проблемы. Также, в консоль можно выводить сообщения и из игрового кода, для отладки.

Создаем игровой мир

Поскольку я программист и рисую хуже, чем курица лапой, для графики я взял несколько бесплатных ассетов из Unity Asset Store. Ссылки на них вы сможете найти в конце этой статьи.

Из этих ассетов я собрал простой уровень, с которым мы и будем работать:

Никакой магии, я просто перетащил понравившиеся мне объекты из окна Проекта и с помощью мышки расставил их как мне нравится:

Кстати, Unity позволяет в один клик добавлять в сцену стандартные объекты, такие как куб, сфера или плоскость. Для этого достаточно нажать правой кнопкой в окне Иерархии и выбрать, например, 3D Object⇨Plane. Так, асфальт в моем уровне как раз собран из набора таких плоскостей, на которые я “натянул” текстуру из набора ассетов.

N.B. Если вы задаетесь вопросом, почему я использовал множество плоскостей, а не одну с большими значениями scale, то ответ довольно прост: одна плоскость с большим scale будет иметь сильно увеличенную текстуру, что будет смотреться неестественно относительно других объектов в сцене (это можно исправить параметрами материала, но мы ведь пытаемся делать все максимально просто, не так ли?)

Зомби в поисках пути

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

Чтобы это реализовать, мы будем использовать инструмент “навигационная сетка” (Navigation Mesh). На основе данных сцены этот инструмент вычисляет области, где можно перемещаться, и формирует набор данных, по которым во время игры может быть произведен поиск оптимального маршрута из любой точки уровня в любую другую. Эти данные сохраняются в ассет и в дальнейшем не могут быть изменены — этот процесс называется “запеканием” (“baking”). Если вам нужны динамически изменяющиеся препятствия, то можно использовать компонент NavMeshObstacle, но для нашей игры это не нужно.

Важный момент: чтобы Unity знал, какие объекты нужно включить в расчет, необходимо в Инспекторе для каждого объекта (можно в окне Иерархии выделить сразу все) нажать на стрелочку вниз около опции “Static” и отметить пункт “Navigation Static”:

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

Теперь воспользуемся пунктом меню Window⇨AI⇨Navigation и в открывшемся окне выберем вкладку “Bake”. Здесь Unity предложит нам задать такие параметры, как высота и радиус персонажа, максимальный угол наклона земли по которому еще можно ходить, максимальную высоту ступенек и так далее. Мы пока не будем ничего этого менять и просто нажмем кнопку «Bake”.

Unity произведет необходимые расчеты и продемонстрирует нам результат:

Здесь синим отмечена область, где можно ходить. Как видите, Unity оставил небольшой бортик вокруг препятствий — ширина этого бортика как раз зависит от радиуса персонажа. Таким образом, если центр персонажа находится в синей зоне, то он не будет “проваливаться” внутрь препятствий.

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

Давайте создадим игровой объект “Zombie”, добавим в него 3d-модель зомби из ассетов, а также — компонент NavMeshAgent:

Если запустить игру сейчас, то ничего не произойдет. Мы должны сказать компоненту NavMeshAgent, куда двигаться. Для этого мы создадим свой первый компонент на языке C#.

В окне проекта выберите корневую директорию (она называется “Assets”) и в списке файлов нажмите правой кнопкой мыши, чтобы создать директорию “Scripts”. Мы будем хранить все наши скрипты в ней, чтобы в проекте был порядок. Теперь, внутри “Scripts” давайте создадим скрипт “Zombie” и добавим его в игровой объект зомби:

Двойной щелчок на скрипте откроет его в редакторе. Давайте посмотрим, что Unity для нас создал

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Zombie : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

Это стандартная заготовка для компонентов. Как мы видим, Unity подключил нам библиотеки System.Collections и System.Collections.Generic (сейчас они не нужны, но часто бывают нужны в коде игр на Unity, поэтому их включили в стандартный шаблон), а также — библиотеку UnityEngine, где содержится весь основной API движка.

Также, Unity создал для нас класс Zombie (название совпадает с именем файла; это важно: если они не будут совпадать, Unity не сможет сопоставить скрипт с компонентом в сцене). Класс унаследован от MonoBehaviour — это базовый класс для компонентов, создаваемых пользователем.

Внутри класса Unity создал для нас два метода: Start и Update. Эти методы движок будет вызывать сам: Start — сразу после того, как сцена была загружена, а Update — каждый кадр. На самом деле, таких вызываемых движком функций очень много, но большинство из них сегодня нам не понадобятся. Полный список, а также последовательность их вызова всегда можно подсмотреть в документации: https://docs.unity3d.com/Manual/ExecutionOrder.html

Давайте заставим зомби двигаться по карте!

Для начала, нам нужно подключить библиотеку UnityEngine.AI. Именно в ней содержится класс NavMeshAgent и другие классы, связанные с навигационной сеткой. Для этого добавим в начало файла директиву using UnityEngine.AI.

Затем, нам нужно получить доступ к компоненту NavMeshAgent. Для этого мы можем использовать стандартный метод GetComponent. Он позволяет получить ссылку на любой компонент в том же игровом объекте, в котором находится компонент из которого мы вызываем этот метод (в нашем случае — это игровой объект “Zombie”). Заведем в классе поле NavMeshAgent navMeshAgent, в методе Start получим ссылку на NavMeshAgent и попросим его двигаться в точку (0, 0, 0). У нас должен получиться вот такой скрипт:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Zombie : MonoBehaviour
{
    NavMeshAgent navMeshAgent;

    // Start is called before the first frame update
    void Start()
    {
        navMeshAgent = GetComponent<NavMeshAgent>();
        navMeshAgent.SetDestination(Vector3.zero);
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

Запустив игру, мы увидим, как зомби двигается к центру карты:

Зомби преследует жертву

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

По аналогии с зомби, создадим игровой объект “Player” (на этот раз выберем 3d-модель полицейского), также добавим в него компонент NavMeshAgent и свежесозданный скрипт Player. Содержимое скрипта Player пока трогать не будем, а вот в скрипт Zombie потребуется внести правки. Также, рекомендую поставить у игрока в компоненте NavMeshAgent значение свойства Priority в 10 (или любое другое значение меньше стандартных 50, то есть задать игроку более высокий приоритет). В таком случае, если игрок и зомби встретятся на карте, зомби не сможет сдвинуть игрока, тогда как игрок сможет отпихнуть зомби.

Чтобы преследовать игрока, зомби нужно знать его положение. А для этого нам нужно получить ссылку на него в нашем классе Zombie с помощью стандартного метода FindObjectOfType. Запомнив ссылку, мы сможем обратиться к компоненту transform игрока и попросить у него значение position. А чтобы зомби преследовал игрока всегда, а не только в начале игры, мы будем задавать цель для NavMeshAgent в методе Update. Получится вот такой скрипт:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Zombie : MonoBehaviour
{
    NavMeshAgent navMeshAgent;
    Player player;

    // Start is called before the first frame update
    void Start()
    {
        navMeshAgent = GetComponent<NavMeshAgent>();
        player = FindObjectOfType<Player>();
    }

    // Update is called once per frame
    void Update()
    {
        navMeshAgent.SetDestination(player.transform.position);
    }
}

Запустим игру и убедимся, что зомби нашел свою жертву:

Спасение бегством

Наш игрок пока что стоит, как истукан. Это явно не поможет ему выжить в таком агрессивном мире, поэтому нужно научить его перемещаться по карте.

Для этого нам потребуется получать от Unity информацию о нажатых клавишах. Метод GetKey стандартного класса Input как раз предоставляет такую информацию!

N.B. Вообще, такой способ получения ввода не совсем каноничен. Лучше использовать Input.GetAxis и биндинг через Project Settings⇨Input Manager. А еще лучше — New Input System. Но эта статья и так получилась чересчур длинная, так что, сделаем как попроще.

Откроем скрипт Player и изменим его следующим образом:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class Player : MonoBehaviour
{
    NavMeshAgent navMeshAgent;
    public float moveSpeed;

    // Start is called before the first frame update
    void Start()
    {
        navMeshAgent = GetComponent<NavMeshAgent>();
    }

    // Update is called once per frame
    void Update()
    {
        Vector3 dir = Vector3.zero;
        if (Input.GetKey(KeyCode.LeftArrow))
            dir.z = -1.0f;
        if (Input.GetKey(KeyCode.RightArrow))
            dir.z = 1.0f;
        if (Input.GetKey(KeyCode.UpArrow))
            dir.x = -1.0f;
        if (Input.GetKey(KeyCode.DownArrow))
            dir.x = 1.0f;
        navMeshAgent.velocity = dir.normalized * moveSpeed;
    }
}

Как и в случае с зомби, в методе Start мы получаем ссылку на компонент NavMeshAgent игрока и запоминаем ее в поле класса. Но теперь мы еще и добавили поле moveSpeed.

Благодаря тому, что это поле публичное, его значение можно редактировать прямо в Инспекторе в Unity! Если у вас в команде есть гейм-дизайнер, он будет очень рад, что ему не нужно лезть в код, чтобы подредактировать параметры игрока.

Поставим 10 в качестве скорости:

В методе Update будем используем Input.GetKey, чтобы проверить нажата ли какая-то из стрелок на клавиатуре и сформировать вектор направления движения для игрока. Обратите внимание, что мы используем координаты X и Z. Это связано с тем, что в Unity ось Y смотрит вверх, в небо, а земля расположена в плоскости XZ.

После того, как мы сформировали вектор направления движения dir, мы его нормализуем (в противном случае, если игрок захочет двигаться по диагонали, вектор будет чуть длиннее единичного и такое движение будет быстрее, чем движение прямо) и умножаем на заданную скорость движения. Результат передаем в navMeshAgent.velocity и агент сделает всю остальную работу.

Запустив игру, мы сможем наконец-то попытаться убежать от зомби в безопасное место:

Чтобы камера двигалась вместе с игроком, давайте напишем еще один простой скрипт. Назовем его “PlayerCamera”:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerCamera : MonoBehaviour
{
    Player player;
    Vector3 offset;

    // Start is called before the first frame update
    void Start()
    {
        player = FindObjectOfType<Player>();
        offset = transform.position - player.transform.position;
    }

    // Update is called once per frame
    void LateUpdate()
    {
        transform.position = player.transform.position + offset;
    }
}

Смысл этого скрипта должен быть по большей части понятен. Из особенностей — здесь вместо Update мы используем LateUpdate. Этот метод аналогичен Update, но вызывается всегда строго после того, как отработают Update у всех скриптов в сцене. В данном случае мы используем LateUpdate, потому что нам важно, чтобы NavMeshAgent рассчитал новое положение игрока до того, как мы переместим камеру. Иначе может возникать неприятный эффект “подергивания”.

Если теперь прикрепить этот компонент к игровому объекту “Main Camera” и запустить игру, персонаж игрока будет всегда в центре внимания!

Минутка анимации

Отвлечемся на минутку от проблем выживания в условиях зомби-апокалипсиса и задумаемся о вечном — об искусстве. Наши персонажи сейчас похожи на ожившие статуи, приводимые в движение неведомой силой (возможно, магнитами под асфальтом). А хотелось бы, чтобы они выглядели как настоящие, живые (и уже не очень) люди — шевелили руками и ногами. В этом нам поможет компонент Animator и инструмент под названием Animator Controller.

Animator Controller — это конечный автомат (стейт-машина), где мы задаем определенные состояния (персонаж стоит, персонаж идет, персонаж умирает и т.п.), привязываем к ним анимации и задаем правила перехода из одного состояния в другое. Unity будет автоматически переключаться от одной анимации к другой, как только сработает соответствующее правило.

Давайте создадим Animator Controller для зомби. Для этого создадим в проекте директорию Animations (помните про порядок в проекте), а в ней — с помощью правой кнопки — Animator Controller. И назовем его “Zombie”. Двойной щелчок — и перед нами предстанет редактор:

Пока что здесь нет никаких состояний, но есть две точки входа (“Entry” и “Any State”) и одна точка выхода (“Exit”). Перетащим пару анимаций из ассетов:

Как видите, как только мы перетащили первую анимацию, Unity автоматически привязал ее к точке входа Entry. Это так называемая анимация по-умолчанию. Она будет проигрываться сразу после старта уровня.

Чтобы перейти в другое состояние (и воспроизвести другую анимацию), нам нужно создать правила перехода. А для этого нам, прежде всего, потребуется добавить параметр, который мы будем задавать из кода для управления анимациями.

В верхнем левом углу окна редактора есть две кнопки: “Layers” и “Parameters”. По умолчанию выбрана вкладка “Layers”, нам же нужно переключиться на “Parameters”. Теперь мы можем добавить новый параметр типа float, воспользовавшись кнопкой “+”. Назовем его “speed”:

Теперь надо сказать Unity, что должна воспроизводиться анимация “Z_run”, когда speed больше 0 и “Z_idle_A”, когда speed равен нулю. Для этого мы должны создать два перехода: один из “Z_idle_A” в “Z_run”, а другой — в обратную сторону.

Начнем с перехода из idle в run. Щелкаем правой кнопкой по прямоугольнику “Z_idle_A” и выбираем “Make Transition”. Появится стрелочка, щелкнув по которой можно настроить ее параметры. Во-первых, необходимо убрать галочку “Has Exit Time”. Если этого не сделать, анимация будет переключаться не по нашему условию, а когда закончит воспроизводиться предыдущая. Нам это совершенно не нужно, поэтому галочку снимаем. Во-вторых, внизу, в списке условий (“Conditions”) нужно нажать на “+” и Unity добавит нам условие. Значения по умолчанию в данном случае — как раз те, что нам нужны: параметр “speed” должен быть больше нуля для перехода из idle в run.

По аналогии создаем переход в обратную сторону, но в качестве условия теперь указываем “speed” меньше, чем 0.0001. Проверки на равенство для параметров типа float нет, их можно сравнивать только на больше/меньше:

Теперь нужно привязать контроллер к игровому объекту. Выберем 3d-модель зомби в сцене (это дочерний объект у объекта “Zombie”) и перетащим контроллер мышкой в соответствующее поле в компоненте Animator:

Осталось только написать скрипт, который будет управлять параметром speed!

Создадим скрипт MovementAnimator следующего содержания:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class MovementAnimator : MonoBehaviour
{
    NavMeshAgent navMeshAgent;
    Animator animator;

    // Start is called before the first frame update
    void Start()
    {
        navMeshAgent = GetComponent<NavMeshAgent>();
        animator = GetComponentInChildren<Animator>();
    }

    // Update is called once per frame
    void Update()
    {
        animator.SetFloat("speed", navMeshAgent.velocity.magnitude);
    }
}

Здесь мы, как и в других скриптах, в методе Start получаем доступ к NavMeshAgent. Также мы получаем доступ к компоненту Animator, но, так как компонент MovementAnimator мы будем крепить к игровому объекту “Zombie”, а Animator находится в дочернем объекте, вместо GetComponent нужно использовать стандартный метод GetComponentInChildren.

В методе Update мы запрашиваем у NavMeshAgent его вектор скорости, рассчитываем его длину и передаем ее аниматору в качестве параметра speed. Никакой магии, все по науке!

Теперь добавим компонент MovementAnimator в игровой объект Zombie и, если запустить игру, мы увидим, что зомби теперь анимирован:

Заметьте, что, поскольку мы поместили код управления аниматором в отдельный компонент MovementAnimation, его можно легко добавить и для игрока. Нам даже не потребуется создавать контроллер с нуля — можно скопировать контроллер зомби (это можно сделать, выбрав файл “Zombie” и нажав Ctrl+D) и заменить анимации в прямоугольниках-состояниях на “m_idle_А” и “m_run”. Все остальное — аналогично зомби. Я оставлю это вам в качестве упражнения (ну или скачайте код в конце статьи).

Одно маленькое дополнение, которое полезно сделать — добавить следующие строчки в класс Zombie:

В метод Start:

navMeshAgent.updateRotation = false;

В метод Update:

transform.rotation = Quaternion.LookRotation(navMeshAgent.velocity.normalized);

Первая строчка говорит NavMeshAgent’у, что он не должен управлять поворотом персонажа, мы будем делать это сами. Вторая строчка задает поворот персонажа в ту же сторону, куда направлено его движение. NavMeshAgent по умолчанию интерполирует угол поворота персонажа и это выглядит не очень красиво (зомби поворачивается медленнее, чем меняет направление движения). Добавление этих строчек убирает этот эффект.

N.B. Для задания поворота мы используем кватернион. В трехмерной графике основными способами задания поворота объекта являются углы Эйлера, матрицы поворота и кватернионы. Первые два не всегда удобны в работе, а также подвержены такому неприятному эффекту, как “Gimbal Lock”. Кватернионы лишены этого недостатка и сейчас используются практически повсеместно. Unity предоставляет удобный инструментарий для работы с кватернионами (как, впрочем и с матрицами, и с углами Эйлера), позволяющий не вдаваться в подробности устройства этого математического аппарата.

Я вижу цель

Замечательно, теперь мы можем убежать от зомби. Но этого недостаточно, рано или поздно появится второй зомби, потом третий, пятый, десятый… а от толпы уже так просто не убежишь. Чтобы выжить, придется убивать. Тем более, что пистолет у игрока в руке уже есть.

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

На экране курсор мыши передвигается по двумерному пространству — поверхности монитора. При этом, наша игровая сцена — трехмерная. Наблюдатель видит сцену через свой глаз, где все лучи света сходятся в одной точке. Объединив все эти лучи, мы получим пирамиду видимости:

Глаз наблюдателя видит только то, что попадает в эту пирамиду. Причем, движок специально усекает эту пирамиду с двух сторон: во-первых, со стороны наблюдателя располагается экран монитора, так называемая “ближняя плоскость” (на рисунке она окрашена в желтый цвет). Монитор не может физически отобразить объекты ближе, чем экран, поэтому движок их отсекает. Во-вторых, поскольку компьютер обладает конечным объемом ресурсов, движок не может продлить лучи в бесконечность (например, для буфера глубины должен быть задан некоторый диапазон возможных значений; причем, чем он шире, тем ниже будет точность), поэтому пирамида отсекается сзади так называемой “дальней плоскостью”.

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

Построить такой луч и найти его пересечение с объектами в сцене можно с помощью стандартного метода Raycast из класса Physics. Но если мы используем этот метод, он будет находить пересечение со всеми объектами в сцене — землей, стенами, зомби… Мы же хотим, чтобы курсор перемещался только по земле, поэтому нам нужно каким-то образом объяснить Unity, что поиск пересечения должен ограничиваться только заданным набором объектов (в нашем случае — только плоскостями земли).

Если выделить любой игровой объект в сцене, то в верхней части инспектора можно увидеть выпадающий список “Layer”. По умолчанию там будет значение “Default”. Открыв выпадающий список, в нем можно найти пункт “Add layer…”, который откроет окно редактора слоев. В редакторе нужно добавить новый слой (назовем его “Ground”):

Теперь можно выбрать все плоскости земли в сцене и с помощью этого выпадающего списка назначить им слой Ground. Это позволит далее в скрипте указать методу Physics.Raycast, что нужно проверять пересечения луча только с этими объектами.

Теперь давайте перетащим спрайт курсора из ассетов в сцену (я использую Spags Assets⇨Textures⇨Demo⇨white_hip⇨white_hip_14):

Продолжение следует...

Источник


Report Page