Найти в Дзене
Помоги себе сам

Создание игры с самых основ (Unity). Теория

Оглавление

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

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

Движок для энтузиастов (инди-разработчиков) бесплатный, поэтому скачать с официального сайта и установить не представляет труда. После установки запускайте ярлычок Unity Hub и там New project. Потом продолжать работу над проектом надо будет также выбирая его в Unity Hub. Кроме названия и расположения на диске, при создании проекта предлагается выбрать вариант нового проекта из огромного списка шаблонов, но разработчику, который только знакомится с Unity, для первого проекта интересны только 2D и 3D Bult-in Render Pipeline. Отличаются они только настройками камеры, сразу настроенными для двухмерной или трёхмерной игры соответственно. Так что потом можно легко сделать 3D из 2D, если вы осознали что того требует ваш художественный стиль прямо, в процессе работы. Галочки в окошки с Unity Cloud и Version Control ставить не надо, но всегда в дальнейшем их можно будет добавить в проект.

Интерфейс Unity

Расположение окон рабочей области отличается в зависимости от выбранного шаблона, но всё можно без труда настроить под себя. В заметке буду описывать проект по шаблону 3D (Bult-in Render Pipeline).

В свежесозданном проекте уже есть созданная сцена с названием SampleScene. Она расположена в директории проекта -> Assets -> Scenes, что видно во вкладке Project. Вы можете делать отдельные сцены для меню и игры, для разных уровней игры или вообще делать всю игру в одной сцене - важно помнить, что все экземпляры классов должны принадлежать конкретной сцене и если надо их переносить между сценами придётся добавить логику для этого.

Во вкладке Hierarchy видно, что на сцену уже добавлены экземпляры встроенных классов Main Camera и Directional Lihgt. Для чего они нужны понятно по названию, а их параметры отображаются во вкладке Inspector после их выделения. При выделении камеры появляется подсказка с выводом того, что она видит. Если нажать кнопку Play (наверху программы), то автоматически отобразится вкладка Game - показывающая финальный вид - при этом никто не запрещает переключиться обратно во вкладку Scene, чтобы посмотреть не только на попавшее в объектив камеры.

Мне дефолтная компоновка рабочей области удобна: обычно я дополняю центральное окно (где Scene) вкладкой Animator, правое (там где Inspector) - ещё одним Inspector (второй использую в комбинации с замочком) и Lighting, нижнее (где Project) - Animation. При необходимости вкладки добавляются через меню Window, некоторые часто добавляемые вынесены в контекстное меню Add Tab, которое вызывается по клику в правом верхнем углу, а те между которыми приходится часто переключаться, имеют свои горячие кнопки Ctrl+цифра .

Зацикливаться на всех параметрах всего что попадается на экране не буду - это описано в официальной документации. Например, вы хотите сделать источник света на конце посоха волшебника. Первое что надо сделать - почитать официальную документацию. Если у вас проблемы с английским, то выберите более старую версию в левом верхнем углу страницы с документацией, например 2018 - она уже нормально переведена на русский, а изменения между версиями не столь критичны. Там вы узнали, что достаточно расположить Point Light как дочерний объект посоха. Затем вы хотите, чтобы цвет, которым он светит, менялся в зависимости от того насколько близко подкрались враги. Какой параметр отвечает за цвет вы тоже узнали из документации, но вот вопрос: как связать расстояние до врагов созначением этого параметра? Это можно сделать десятками различных способов и приёмов - в этой статье я постараюсь дать информацию достаточную для того, чтобы вы сами находили своё решение.

Префабы и экземпляры

Игра по-сути является процессом взаимодействия с объектами. В Unity есть несколько классов объектов, к которым так или иначе можно свести всё, что только есть в проекте, но для начала познакомимся с фундаментальным классом GameObject.

Представим, что нам нужно создать 10 людей, которые будут ходить случайным образом, а при столкновении начинать драться. Разобьём решение этой задачи на три этапа или подзадачи:

1. Создадим префаб человека. Префаб можно создать нажав правой кнопкой мышки в любой папке проекта (вкладка Project) и там выбрать в контекстном меню Create -> Prefab. Либо можно во вкладке иерархии (Hierarchy) нажать правой кнопкой мышки, в контекстном меню выбрать Create Empty и потом перетащить его в нужную папку проекта. Также можно перетащить любой готовый игровой объект в папку проекта.

Чтобы перейти в иерархию самого префаба - кликните на нём 2 раза мышкой. Чтобы вернуться на уровень выше (на сцену) нажмите маленькую стрелочку в левом верхнем углу вкладки иерархии.

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

2. Добавим необходимые компоненты. Для описанной задачи минимально достаточно добавить модель, анимации и скрипт. Чтобы добавить компоненты (аниматор и скрипт) надо выделить префаб и во вкладке Inspector добавить их используя кнопку Add Component.

Я уже рассказывал про способ создания человекоподобных персонажей с анимациями, используя бесплатные сервисы: там приводится пример как сгенерировать модель человека и несколько её анимаций (например движение и удар), а также импорт в проект и переключение между ними в Unity из скрипта.

2.1. Добавим в него 3D-модель. Её можно сделать добавив 3D-объекты на сцену (из меню GameObject->3D Object->шар - голова, куб - тело, цилиндры - руки и ноги), нарисовать самому, например в программе Blender, скачать в Asset Store или просто в интернете, а затем импортировать в проект.

2.2. Добавим анимацию движения. Её можно сделать самому используя вкладку Animation или импортировать готовую. В Blender можно сделать персонажа со скелетом, который потом анимировать в Unity, а можно и там же его анимировать перед экспортом.

2.3 Добавим скрипт, останавливающий движение и переключающий анимацию (на анимацию удара) при столкновении. Тут тоже возможны варианты, например можно подсчитывать расстояния до соседей или прибегнуть к рэйкасту в направлении движения. Но для обработки столкновений лучше всего подходит компонент коллайдера. На основе только обработки столкновений уже можно сделать достаточно много вариантов геймплея.

3. Создадим экземпляры людей и поменяем им характеристики. Самым простым вариантом для этого будет создать скрипт (например с названием GameManager) и присоединить его как компонент к основной камере. В этом скрипте в методе Start создать в цикле с помощью метода Instantiate нужное количество экземпляров людей в нужных координатах.

Чтобы создать скрипт можно кликнуть правой кнопкой мышки в любой папке проекта и выбрать Create -> C# Script, либо выбрать в иерархии Main Camera, ей добавить компонент (Add Component) и начать печатать название скрипта - появится предложение создать его.

Двойной клик на скрипте откроет его для редактирования в установленном в настройках редакторе, который можно поменять в меню Edit -> Preferences -> Analysis -> External Tools -> External Script Editor. Для редактирования программного кода я полноценный Visual Studio, но многие больше предпочитают VS Code (оба бесплатные, скачиваются с одного и того же официального сайта). Скорее всего вы уже умеете программировать и у вас уже есть любимая IDE, а если нет -то про установку и основы работы в Visual Studio я уже рассказывал в статье про то с чего начать, если вы хотите научиться программированию - обратите внимание на неё, так как программировать придётся. Скриптинг в Unity реализуется на языке C#, поэтому если ещё не изучили - осваивайте, кроме того потребуются базовые знания принципов ООП.

Сейчас всё чаще говорят про No-Code программирование (оно кстати пошло в массы именно из главного конкурента Unity - игрового движка Unreal Engine) и про генерацию кода нейросетями (уже есть масса плагинов для VS Code, которые используют популярные модели прямо в редакторе). Я считаю, что пока ещё no-code применительно только для протипирования, например чтобы накидать алгоритм взаимодействия, а нейросети - для генерации небольших фрагментов кода, которые можно легко проверить (я рекомендую этот инструмент не игнорировать и взять на вооружение - он уже вполне "созрел").

Игровой цикл Unity

Обычно "игровым циклом" называют другое, так что надеюсь не внесу путаницы.

Работа игрового движка Unity реализует цикл, в котором важен порядок выполнения методов в классе MonoBehaviour. По выделенной жирным шрифтом ссылке приведён полный порядок выполнения, но конечно же не обязательно реализовывать наследование от этого класса или использовать те или иные методы во всех скриптах. Обязательно ознакомьтесь со схемой очерёдности выполнения методов, чтобы потом, знать куда посмотреть когда возникнет вопрос вроде "а клик мышки по кнопке на UI обрабатывается до или после Update?".

Рассмотрим на примере скрипта, прикреплённого к объекту, порядок выполнения следующих методов цикла MonoBehaviour:

Awake() - выполняется сразу после того как будет загружен скрипт. Обычно в нем загружается всё что может понадобится объекту.

Start() - выполняется в момент появления объекта на сцене. Обычно в нём выполняются однократные действия, например запустить анимацию появления.

Update() - выполняется каждый кадр. В нем реализуется основная игровая логика, требующая оценки того что происходит, например нажал ли игрок кнопку на клавиатуре.

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

Таким образом в каждом кадре игры для всех объектов вызывается Awake(), если он не был вызван ими до этого ни разу. Затем у всех, кто ещё не вызвал Start() - он выполнится. Затем проверится время, прошедшее с предыдущего FixedUpdate(), и если оно прошло - выполнится он. И в конце кадра в любом случае все выполнят свой Update().

Если частота кадров неоправданно низкая (включить отображение статистики можно переключателем Stats в правом верхнем углу вкладки Game), то посмотреть сколько времени выполняется тот или иной этап работы движка можно используя вкладку Profiler. Если выполнения скрипта занимает значительное время (число больше 1мс я считаю уже сигналом к проверке), то надо искать зацикливания или реализовывать асинхронное выполнение. На уровне этой статьи я предложил бы решением использование корутин или подписки на события.

UI и реакция на действие пользователя

Игры с диегетическим интерфейсом - редкость. В подавляющем большинстве проектов так или иначе присутствует классический пользовательский интерфейс (UI).

В простом случае в Unity создаётся объект UI Canvas, на котором размещаются кнопки, картинки, текст и другие элементы интерфейса. Как и любые другие объекты в игре его можно показать или скрыть изменив положение галочки слева от названия или из скрипта используя метод SetActive.

Например мы хотим реализовать следующую логику: при старте игра встаёт на паузу, игроку показывается текст с правилами, а продолжается игра по кнопке "ок". Для этого:

1. Создадим и прикрепим к MainCamera скрипт GameManager. В его методе Start() остановим игру установив Time.timeScale = 0;

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

2. Создадим на сцене UI Canvas, которому укажем Render Mode = Camera. После этого Canvas повиснет в воздухе перед камерой на расстоянии, указанному в параметре Plane Distance (помните, что у камеры есть параметр, отрезающий всё что ближе определённого расстояния, так что сильно близко панели не придвигайте). Разместим на нём TMP (TextMeshPro), в котором поменяем поле Text на правила, с которыми игрок должен ознакомится.

3. Добавим ссылку на UI Canvas в GameManager и покажем его (сразу после паузы):

using Unity.UI;
....
[SreializeField] private Canvas _messageUI;
....
_messageUi.SetActive(true);

Так как область видимости класса GameManager ограничена, поэтому в него надо передать объекты с которыми он будет работать, в частности UI Canvas. Проще всего передать ссылку на него в публичное поле, но это считается плохим стилем программирования. Хорошим стилем считается явно указать, что поле приватное, но добавить перед ним [SreializeField], чтобы можно было передавать в него ссылки на игровые объекты перетаскивая их мышкой со сцены или из проекта прямо в инспектор.

4. Добавим в скрипт GameManager метод, который будет скрывать UI и возобновлять игру.

5. Создадим кнопку (дочернюю для Canvas'a) и зададим ей обработку события OnClick как изображено на скриншоте (втором в галерее ниже). То есть выберем Main Camera, на которой добавлен скрипт GameManager, в котором описан метод обработки. Этот метод выберем справа - таким образом он выполнится при клике на эту кнопку. Как альтернативный вариант можно задать обработчик для кнопки прямо из скрипта ( .onClick.AddListener(() => ... ), но для кнопок с фиксированным поведением лучше задавать через инспектор.

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

Сделаем целью нашей игры не допустить драк среди человечков. Для этого можно кликнуть на любого человечка и вместо него появятся в случайных местах двое новых. В качестве вознаграждения будем начислять игроку баллы по 1 за каждую секунду без драки и по 10 за каждый клик на человечке. Если любая драка продолжится дольше 3 секунд, то игра заканчивается. И вновь прибегнем к методу разбиения задач на подзадачи (рано или поздно проект вырастает, и тогда чтобы не сбиться, удобно пользоваться таск-трекером вместо простого документа, в РФ можно например этим):

1. Добавление очков за время

private void Start() {
PlayerPrefs.SetInt("Score", 0);
StartCoroutine(AddScoreOverTime()); ... }
private IEnumerator AddScoreOverTime() {
while (true) {
PlayerPrefs.SetInt("LevelScore", PlayerPrefs.GetInt("LevelScore")++);
yield return new WaitForSeconds(1f); }

Конечно так оформлять программный код не следует, но в Дзене с этим очень не удобно, поэтому я стараюсь "ужимать" форматирование. В примере я вызываю для решения задачи корутину, что может стать проблемой если будет вызван метод StopAllCorutines, в таком случае более правильным было бы использовать метод InvokeRepeating, что ещё раз показывает, что одну и ту же задачу можно выполнить множеством различных способов.

2. Реакция на клик на человечке. Для этого в скрипте, присоединённом к префабу человечка (в котором человечек двигается и обрабатываются столкновения) добавим метод OnMouseDown.

public void OnMouseDown() {
Instantiate(_human, new Vector3(transform.position.x + Random.Range(-2f, 2f), transform.position.y, transform.position.z + Random.Range(-2f, 2f)), Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
... повторим ещё раз создание нового и начислим очки ...
Destroy(gameObject); }

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

3. Условие поражения. В методе обработки столкновения OnCollisionEnter очевидно будет довольно много всего: запуск анимации драки, поворот чётко на оппонента, остановка метода ходьбы, отключение реакции на последующие столкновения. Сейчас нас интересует запуск отсчёта на поражение и отмена его (реализую это также с помощью корутины):

private IEnumerator coroutine;
...
public IEnumerator LooseTimer() {
yield return new WaitForSeconds(3f);
print("Вы проиграли! Счёт:" + PlayerPrefs.GetInt("LevelScore")); }
...
private void OnCollisionEnter(Collision collision) {
coroutine = LooseTimer();
StartCoroutine(coroutine);
... }
public void OnMouseDown() {
StopCoroutine(coroutine);
... }

Говоря про интерактивность, пример не полноценный без обработки нажатия кнопок клавиатуры. В самом простом варианте подойдёт такой способ: в каждом кадре проверяем нажата ли нужна нам кнопка и реагируем. Для этого в Update() проверяем условие типа if (Input.GetKeyDown(KeyCode.Space)) { ... }

Публикация проекта

Публикация готовой игры по ОС Windows не вызовет проблем. В меню File -> Bild Settings, в первый раз надо настроить в Player Settings иконки, версию, уровни графики итд, а затем нажать кнопку Build (в предыдущем окне) и выбрать паку в которой будет готовая игра.

И ещё пара слов о разбиении задачи на подзадачи (с примерами):

Сейчас вам ничего не мешает используя только информацию из этой статьи установить Unity и сделать проект, на подобии того, что я привёл в конце заметки про импорт в Unity из MagicaVoxel. На пол положить большую плоскость с коллайдером, не являющимся триггером, раскидать на него пару кубов и плоскостей (как трамплины). Как сделать управление персонажем есть на скриншоте к упомянутой статье или просто добавить стандартный компонент CharacterController. Или реализовать какую-то свою идею,
простенькую - учебную, проект мечты будет следующим.

Возвращаясь к примеру из самого начала статьи (менять цвет при приближении врага) я понимаю, что такая простая на первый взгляд задача складывается из нескольких требующих знания инструмента подзадач. Вы уже знаете, что можете прикрепить большой прозрачный сферический коллайдер к объекту. В прикреплённом к нему же скрипте в OnTriggerEnter мы можем добавлять ссылки на врагов и считать расстояние до ближайшего используя координаты transform.position. Цвета, которые должны быть на определённом расстоянии настроить в поле типа Gradient, после чего в Update вызывать Gradient.Evaluate. Читая это у новичка в голове может возникнуть паника, ведь каждая из этих подзадач в свою очередь порождает новые, поэтому я сразу призываю планировать свой проект так, чтобы добавление новых подзадач только добавляло функционал, а не требовало изменения в предыдущих (по крайней мере стараться делать так).

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

Следом за теорией перейду к практике: в течении некоторого времени реализую модель одноклеточного из другой своей статьи и на основе этого проекта тут появится ссылка на продолжение.