Unity: бесконечный процедурно генерируемый город, получаемый при помощи алгоритма WFC (коллапс волновой функции)
Как законодатели мод по теме Unity на российском рынке предлагаем вам почитать интересное исследование о практическом использовании алгоритма WFC (Wave Function Collapse), построенного по образу и подобию известного принципа квантовой механики и очень удобного при процедурной генерации уровней в играх. Ранее на Хабре уже публиковался подробный рассказ об этом алгоритме. Автор сегодняшней статьи Мариан Кляйнеберг рассматривает алгоритм в контексте трехмерной графики и генерации бесконечного города. Приятного чтения!

Мы поговорим об игре, где вы идете по бесконечному городу, который процедурно генерируется по мере вашего движения. Город строится из набора блоков при помощи алгоритма WFC (коллапс волновой функции).
Играбельная сборка доступна для скачивания на сайте itch.io. Также можете взять исходный код на github. Наконец, я предлагаю видео, в котором иду по сгенерированому таким образом городу.
Алгоритм
Я буду называть словом “ячейка” такой элемент 3D-воксельной сетки, который может содержать блок или пустовать. Словом «модуль» я буду называть блок, который может занимать такую ячейку.
Алгоритм решает, какие модули подбирать в каждую ячейку игрового мира. Массив ячеек считается волновой функцией в ненаблюдаемом виде. Таким образом, каждой ячейке соответствует множество модулей, которые могут в ней оказаться. В терминах квантовой механики можно было бы сказать, «ячейка находится в суперпозиции всех модулей». Существование мира начинается в полностью ненаблюдаемом виде, где в каждой ячейке может находиться любой модуль. Далее все ячейки схлопываются, одна за другой. Это означает, что для каждой ячейки случайным образом выбирается по одному модулю из всех возможных.
Далее следует этап распространения ограничений (constraint propagation). Для каждого модуля подбирается такое подмножество модулей, которым разрешено быть смежными с ним. Всякий раз при схлопывании модуля обновляются подмножества других модулей, которые по-прежнему допускаются в качестве смежных ему. Этап распространения ограничений – самая ресурсозатратная часть алгоритма с точки зрения вычислительной мощности.
Важный аспект алгоритма заключается в определении того, какую ячейку схлопнуть. Алгоритм всегда схлопывает ячейку с наименьшей энтропией. Это ячейка, допускающая минимальное количество вариантов выбора (то есть, ячейка с наименьшей хаотичностью). Если у всех модулей вероятность схлопывания одинакова, то наименьшая энтропия будет у той ячейки, которой соответствует минимальное количество возможных модулей. Как правило, вероятности попасть под выбор для разных наличествующих модулей отличаются. Ячейка с двумя возможными модулями, имеющими одинаковую вероятность, предусматривает более широкий выбор (большую энтропию), чем та, в которой два модуля, и для одного из них вероятность попасть под выбор очень велика, а для другого – очень мала.
(Гифка помещена ExUtumno на Github)
Более подробную информацию об алгоритме коллапса волновой функции, а также ряд красивых примеров можно посмотреть здесь. Изначально этот алгоритм был предложен для генерации 2D-текстур на основе единственного образца. В таком случае вероятностные показатели модулей и правила смежности определяются в зависимости от их встречаемости в примере. В данной статье эта информация предоставляется вручную.
Вот видео, демонстрирующее этот алгоритм в действии.
О блоках, прототипах и модулях
Мир генерируется из набора, в котором около 100 блоков. Я создал их при помощи Blender. Сначала блоков у меня было совсем немного, и я понемногу добавлял их, когда считал это нужным.

Алгоритму необходимо знать, какие модули могут располагаться рядом друг с другом. Для каждого модуля существует 6 списков возможных соседей, по одному в каждом из направлений. Однако, я хотел избежать необходимости создавать такой список вручную. Кроме того, я хотел автоматически генерировать повернутые варианты для каждого из моих блоков.
Обе эти задачи решаемы при помощи так называемых прототипов модулей. В сущности, это MonoBehaviour , с которым удобно работать в редакторе Unity. Модули вместе со списками допустимых соседних элементов и повернутыми вариантами автоматически создаются на основе таких прототипов.
Сложная проблема возникла с моделированием информации о смежности, так, чтобы этот автоматический процесс работал. Вот что у меня получилось:

У каждого блока по 6 контактов, по одному на каждую грань. У контакта есть номер. Кроме того, горизонтальные контакты могут быть перевернуты, неперевернуты или симметричны. Вертикальные контакты либо имеют индекс вращения в диапазоне от 0 до 3, либо помечаются как вращательно инвариантные.
Исходя из этого, я могу автоматически проверять, каким модулям разрешено прилегать друг к другу. У смежных модулей должны быть одинаковые номера контактов. Также должна совпадать их симметрия (одинаковый индекс вращения по вертикали, пара из перевернутого и непервернутого контакта по горизонтали), либо модули должны быть симметричны/инвариантны.

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

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

Если на каждом шаге алгоритма генерировать всего один фрагмент, то ограничения не распространяются на смежные с ним фрагменты. В таком случае внутри фрагмента выбирались такие модули, которые оказались бы недопустимы, если принимать во внимание другие фрагменты. В результате, когда алгоритм пытался сгенерировать следующий фрагмент, он мог не найти ни одного решения.
Теперь я уже не использую фрагменты, а храню карту в словаре, отображающем позицию ячейки на ячейку. Ячейка заполняется, только если это необходимо. Некоторые элементы алгоритма следует откорректировать с учетом этого момента. При выборе ячейки, которая должна схлопнуться, невозможно учесть все ячейки, если количество их бесконечно. Вместо этого мы единовременно генерируем лишь небольшой участок карты, как только игрок его достигнет. Вне этой области продолжают распространяться ограничения.
В некоторых случаях данный подход не работает. Рассмотрим набор модулей для прямолинейного участка туннеля с показанного выше рисунка – входа в туннель там нет. Если алгоритм выберет такой туннельный модуль, то туннель по определению получится бесконечным. На этапе распространения ограничений программа попытается выделить бесконечное количество ячеек. Я разработал специальный набор модулей, чтобы обойти эту проблему.
Граничные условия
Здесь существуют два важных граничных условия. Все грани на верхнем уровне карты должны иметь «воздушные» контакты. Все грани на основании карты должны иметь «твердые» контакты. Если эти условия не выполняются, то на карте будут лунки в земле, а некоторые здания окажутся без крыши.
На карте конечного размера эта задача решалась бы легко. Для всех ячеек на самом верхнем и самом нижнем уровне потребовалось бы удалить все модули с неподходящими контактами. Затем запустить распространение ограничений и удалить остальные модули, которые нам больше не подходят.
На карте бесконечного размера подобное не сработает, поскольку как на самом верхнем, так и на самом нижнем уровне у нас бесконечное количество ячеек. Наиболее наивное решение – удалять все неподходящие ячейки сразу по мере их возникновения. Однако, при удалении модуля на верхнем уровне срабатывают ограничения, касающиеся тех ячеек, что к нему прилегали. Возникает лавинообразный эффект, вновь приводящий к бесконечному выделению ячеек.
Я решил эту проблему, создав карту размером 1×n×1, где n — высота. Данная карта использует закольцовывание мира (world wrapping) для распространения ограничений. Механизм работает как в игре Pacman: выходя за правый край карты, персонаж возвращается на нее из-за левого края. Теперь я могу применять на моей карте распространение любых ограничений. Всякий раз при создании новой ячейки на бесконечной карте, эта ячейка инициализируется с набором модулей, соответствующим конкретной позиции на карте.
Состояния ошибок и поиск с возвратом
Иногда алгоритм WFC достигает такого состояния, в котором ячейке не соответствует ни одного возможного модуля. В приложениях, где мы имеем дело с миром конечного размера, можно попросту сбросить результат и начать все сначала. В бесконечном мире это не сработает, так как часть мира уже показана игроку. Сначала я остановился на решении, в котором места возникновения ошибок заполнялись белыми блоками.
В настоящее время я использую поиск с возвратом. Порядок схлопывания ячеек и некоторая информация о распространении ограничений хранится в виде истории. Если алгоритм WFC отказывает, то часть истории отменяется. Как правило, это работает, но иногда ошибки удается распознать слишком поздно, и поиск с возвратом охватывает очень много шагов. В редких случаях та ячейка, в которой находится игрок, регенерируется.
На мой взгляд, из-за такого ограничения применение алгоритма WFC с бесконечными мирами не подходит для коммерческих игр.
Предыстория
Я взялся за проработку этой задачи после того, как посмотрел лекцию Оскара Стельберга, рассказывающего, как он использует алгоритм для генерации уровней в игре Bad North. В общих чертах мой алгоритм был реализован во время недели procjam.
У меня есть некоторые идеи о дальнейшей доработке этого алгоритма, но я не уверен, что когда-нибудь соберусь добавить к нему геймплей. А если и соберусь – наверняка это будет не такая эпичная стратегия, которую вы себе уже вообразили. Однако, если вы хотите проверить, как работает с этим алгоритмом ваша любимая игровая механика – просто попробуйте сами! В конце концов, исходный код выложен в открытом доступе и лицензирован MIT.
- Блог компании Издательский дом «Питер»
- Разработка игр
- Алгоритмы
- C#
- Unity
Бесконечный раннер [платформер]
Сразу сделаем небольшое примечание, что здесь представлена не игра, а способ, как зациклить беговую дорожку, то есть карту. Суть процесса заключается в том, что у нас есть три условных блока, по факту обычные точки, которые расположены на расстоянии, по размерам шаблона. По центру, слева и справа. И именно это точки мы будем сдвигать в одну из сторон. Как только крайняя, допустим левая точка, переходит минимальную допустимую позицию, она перемещается перед двумя другими, затем это же повторит и точка, которая шла впереди левой, тобишь центральная, и так далее. Сама карта, это набор шаблонов, ее частей, всё что нужно, в рандомном порядке добавлять шаблоны в блоке, которые мы сдвигаем. Получается, что камера стоит на месте, движется сама платформа. И вместо того чтобы постоянно использовать функцию создания и уничтожения объектов, в данном случаи части карты, мы создаем всё сразу на старте, а затем активируем/деактивируем нужные блоки, когда это потребуется.
Для начала, нужно определиться, какого размера делать секции для карты. Чтобы решить этот вопрос, добавляем обычный куб на сцену и растягиваем его по оси Х, до тех пор, чтобы его края выходили немного за границы экрана.

Точки располагаем следующим образом. Всё зависит от значения которые получилось при растягивании куба, то есть если масштаб по оси Х равен 10, значит позиция левой точки по Х равна -10, для центральной = 0, и соответственно 10 для правой.
Шаблоны секций по размерам создаем, ориентируясь на наш куб.
Теперь, цепляем куда-нибудь скрипт:
using UnityEngine; using System.Collections; using System.Collections.Generic; using UnityEngine.SceneManagement; public class Runner2D : MonoBehaviour < public Transform[] points; public float speed = 5; private string startSectionName, sectionPath; private GameObject[] sectionLink; private Transform[] section; private GameObject sectionStart; private ListsectionDisabled; private float minPosX, addPosX; private int index; void Awake() < switch(SceneManager.GetActiveScene().name) // фильтр по имени сцен, чтобы в каждой из них, использовать свой набор шаблонов < case "Demo": startSectionName = "Start/Level_01_Start"; // стартовый префаб платформы sectionPath = "Level_01"; // папка, где лежат шаблоны для данной сцены break; >> void Start() < minPosX = points[0].position.x; addPosX = Mathf.Abs(minPosX) * 3; StartGame(); >Transform RandomSection() < sectionDisabled = new List(); foreach(Transform tr in section) < if(!tr.gameObject.activeSelf) < sectionDisabled.Add(tr); >> int rnd = Random.Range(0, sectionDisabled.Count); return sectionDisabled[rnd]; > void AddSection() < Transform bock = RandomSection(); if(index == points.Length) index = 0; bock.parent = points[index]; bock.localPosition = Vector3.zero; bock.gameObject.SetActive(true); index++; >void StartGame() < sectionLink = Resources.LoadAll(sectionPath); // все префабы должны находится в папке Resources if(sectionLink.Length < 4) < Debug.Log(this + " Недостаточно объектов для построения уровня. Ошибка запуска игры."); return; >section = new Transform[sectionLink.Length]; for(int i = 0; i < sectionLink.Length; i++) < GameObject clone = Instantiate(sectionLink[i]) as GameObject; clone.SetActive(false); section[i] = clone.transform; >GameObject link = Resources.Load(startSectionName); if(link == null) < Debug.Log(this + " Файл не найден: " + startSectionName + " Ошибка запуска игры."); return; >sectionStart = Instantiate(link) as GameObject; sectionStart.SetActive(true); sectionStart.transform.parent = points[1]; sectionStart.transform.localPosition = Vector3.zero; Transform bock = RandomSection(); bock.parent = points[0]; bock.localPosition = Vector3.zero; bock.gameObject.SetActive(true); bock = RandomSection(); bock.parent = points[2]; bock.localPosition = Vector3.zero; bock.gameObject.SetActive(true); > void Update() < foreach(Transform tr in points) < tr.position -= new Vector3(speed * Time.deltaTime, 0, 0); if(tr.position.x < minPosX) < tr.position += new Vector3(addPosX, 0, 0); tr.GetChild(0).gameObject.SetActive(false); tr.DetachChildren(); AddSection(); >> > >
Точки в массив добавляем так, как показано на скриншоте:
![Бесконечный раннер [платформер]](https://null-code.ru/uploads/posts/2016-04/1461516701_2.png)
С начало лева, потом центральная, затем правая. Это важно для запуска процесса.
Реализация и оптимизация генератора уровней в Unity
В мае этого года мы обсуждали алгоритм, который используем для генерации внешнего мира в игре Fireside. Сегодня мы возвращаемся к этой теме. В прошлый раз нам удалось сгенерировать набор точек на текстуре с помощью фильтрации шума Перлина. Хотя это решение удобно, оно имеет две важные проблемы:
- Оно не особо быстрое
- На самом деле мы не создавали ассетов в Unity
- Создадим в Unity фреймворк, который позволит нам использовать алгоритм генерации текстур
- При помоги сгенерированных текстур создадим ассеты в игровом мире
- Распараллелим генерацию текстур с помощью C# System.Threading.Tasks, чтобы ускорить процесс
Интеграция генерации карт в движок Unity
Мы будем писать Scriptable Objects движка Unity для создания модульного окружения в целях генерации карт. Таким образом, мы дадим гейм-дизайнерам свободу настройки входных данных алгоритма без необходимости работы с кодом. Если вы ещё не слышали о ScriptableObjects, то рекомендую для начала изучить документацию Unity.
Сначала нам потребуется набор различных контейнеров данных. Наш конвейер довольно сложен, и если поместить все необходимые параметры в один объект, он окажется слишком объёмным. Поэтому мы будем использовать по одному пакету данных на каждый уровень алгоритма.

Итак, карта составляется из одного или нескольких сегментов (slice), состоящих из одного или нескольких фрагментов (chunk), созданных из одной или нескольких текстур. Примечание: в большинстве алгоритмов этап сегментов пропускается, но я включил этот этап для дизайна конкретной игры и генерации путей; о причинах я расскажу в этой статье. Можно без проблем игнорировать сегменты и всё равно реализовать описанное здесь решение. При помощи очень удобного ExtendedScriptableObjectDrawer Тома Кэйла мы можем расширить настройки для простоты редактирования.

Здесь вы видите, какой тип данных и на каком уровне мы упаковываем. По сути, каждая генерируемая нами текстура будет распределять один ассет карты. Поэтому чтобы получить разнообразное распределение ассетов, нам нужно наложить друг на друга множество текстур. Разбиение карты на фрагменты и сегменты позволяет нам изменять генерируемые ассеты в соответствии с расстоянием от точки начала координат.
- Конвейер, используемый для генерации текстур
- Масштаб, сопоставляющий пространство текстуры с мировым пространством
- Параметры для дорог
- Seed
- Какой сегмент (slice) будет использоваться на каком расстоянии от точки начала координат
- Какой фрагмент (chunk) используется в зависимости от расстояния до центра сегмента
- Сопоставление между текстурами и ассетом, который должен располагаться на точках, сгенерированных из текстуры
- Текстурные параметры для каждой текстуры
- Текстурные параметры для пути
- Все параметры, описанные в первой части нашего девлога по процедурной генерации карт.
Каждый уровень данных имеет связанный с ним класс C#, использующий паттерн «фабрика», который мы применяем для выполнения логики каждого этапа. Если бы мы хотели только распределять ассеты, то этапы генерации были бы очень простыми. Однако нам также нужно создать пути, по которым будет двигаться игрок. Это немного усложняет архитектуру, потому что после генерации точек нам нужно соединить фрагменты и сегменты.
Если не учитывать пока генерацию путей, то единственная логика, которая нам сейчас нужна — это преобразование сгенерированных на текстуре точек в мировое пространство. Это реализуется благодаря использованию параметра масштабирования из параметров карты, что обеспечивает нам удобный контроль над плотностью размещения ассетов.
internal static Vector3 TexturePointToWorldPoint( Vector2Int texturePoint, float scale, Plane plane, Vector3 origin, ProceduralTextureGenerationSettings settings) < float tx = (float)texturePoint.x / (float)settings.width; float ty = (float)texturePoint.y / (float)settings.height; Vector3 right = GetRight(plane) * scale * tx; Vector3 up = GetUp(plane) * scale * ty; return origin + right + up; >
Поскольку мы сохранили кажду точку в мировом пространстве со связанным с ней префабом, для расположения ассетов достаточно просто вызвать Instantiate для префаба, ссылка на который указана в соответствующем слое параметров фрагмента. Единственное, что нужно учитывать — наш алгоритм не гарантирует, что ассеты не наложатся друг на друга. Пока мы применим такое решение: дадим каждому префабу коллайдер и будем уничтожать все ассеты, с которыми пересекаемся при создании экземпляра префаба. Как сказано в нашем предыдущем девлоге, нужно вызвать Physics2D.SyncTransforms() и yield return new WaitForFixedUpdate(), чтобы проверки коллизий работали правильно.
public IEnumerator PlaceAssets(Chunk chunk) < GameObject chunkObject = new GameObject("Chunk::" + chunk.settings.name); chunkObject.transform.SetParent(worldRoot); ContactFilter2D cf2d = new ContactFilter2D(); foreach (int layerIndex in chunk.generatedLayerAnchors.Keys) < GameObject layerParent = new GameObject(); layerParent.name = chunkObject.name + "::" + "Layer::"+chunk.generatedLayerAnchors[layerIndex].Item1.asset.name; layerParent.transform.SetParent(chunkObject.transform); foreach (Vector3 point in chunk.generatedLayerAnchors[layerIndex].Item2) < PlaceableAsset inst = Instantiate(chunk.generatedLayerAnchors[layerIndex].Item1.asset, layerParent.transform); inst.transform.position = point; Collider2D[] cols = new Collider2D[16]; Physics2D.SyncTransforms(); int numOverlaps = Physics2D.OverlapCollider(inst.mapgenerationCollider, cf2d, cols); for (int i = 0; i < numOverlaps; i++) < if (cols[i].transform.parent != null && cols[i].transform.parent.TryGetComponent(out PlaceableAsset toDestroy)) Destroy(cols[i].transform.parent.gameObject); > > yield return new WaitForFixedUpdate(); > >
Вот и всё! Нам удалось преобразовать наш эксперимент на Processing в работающую систему на движке Unity! Но, увы…
Ускоряем работу
Мы улучшили наш алгоритм, распараллелив его. Так как мы генерируем набор независимых друг от друга текстур (но зависящих от лежащего в их основе шума Перлина), то можно распараллелить генерацию текстур в каждом фрагменте и даже распараллелить генерацию фрагментов.
В официальной документации C# написано, что async / await являются базовой функциональностью C#. Хотя я хорошо знаком с другими возможностями. перечисленными на этом сайте, до начала проекта я не использовал ни async, ни Tasks. Основная причина заключается в том, что в Unity есть похожая функциональность. И это… (барабанная дробь) корутины. На самом деле, в руководствах по программированию на C# в качестве примера используется стандартный способ применения корутин (выполнение запроса к серверу). Это объясняет, почему я (и многие другие Unity-разработчики, которых я знаю) пока не использовал пока асинхронное программирование на C#. Однако это очень полезная возможность и мы используем её, чтобы распараллелить генерацию карт.
//Foo prints the same result as Bar void Start() < Foo(); >async Task Foo() < Debug.Log(“Hello”); await Task.Delay(1000); Debug.Log(“There”); >void Start() < StartCoroutine(Bar()); >IEnumerator Bar()
Вот краткое введение в асинхронное программирование. Как и в случае с корутинами, при реализации асинхронного метода нам нужно возвращать особый тип (Task). Кроме того, нужно пометить метод ключевым словом async. Затем можно использовать ключевое слово await таким же образом, каким бы мы использовали оператор yield в корутине.
Однако существует также очень удобный метод Task.WhenAll, который создаёт Task, блокирующий исполнение, пока не будет завершён набор задач. Это позволяет нам реализовать следующее:
//Generates textures for all layers in parallel. foreach (ChunkLayerSettings setting in settings.chunkLayerSettings) < //generate texture for this chunk textureTasks.Add(textureGenerator.GenerateTextureRoutine( setting.textureSettings, seed, chunkCoords, new TextureGenerationData(seed, chunkCoords, setting.textureSettings))); >result = await Task.WhenAll(textureTasks);
В отличие от корутин, эти задачи выполняются параллельно и не тратят время выполнения в основном потоке. Теперь мы просто можем использовать такой подход при генерации как фрагментов, так и текстур. Это значительно увеличивает производительность: с примерно 10 секунд на сегмент до 3 на сегмент.
При этом мы получаем алгоритм, способный генерировать достаточно сложные и обширные карты примерно за 10 секунд (3 сегмента). Возможны дальнейшие оптимизации, а производительностью можно управлять с помощью размера используемых текстур.
- процедурная генерация
- процедурная генерация карт
- шум перлина
- корутины
- unity3d
Учебное пособие по Endless Runner для Unity
В видеоиграх, каким бы большим ни был мир, у него всегда есть конец. Но некоторые игры пытаются имитировать бесконечный мир, такие игры подпадают под категорию Endless Runner.
Endless Runner — это тип игры, в которой игрок постоянно движется вперед, набирая очки и избегая препятствий. Основная цель — достичь конца уровня, не попадая в препятствия и не сталкиваясь с ними, но часто уровень повторяется бесконечно, постепенно увеличивая сложность, пока игрок не столкнется с препятствием.

Учитывая, что даже современные компьютеры/игровые устройства имеют ограниченную вычислительную мощность, невозможно создать по-настоящему бесконечный мир.
Так как же некоторые игры создают иллюзию бесконечного мира? Ответ заключается в повторном использовании строительных блоков (также известном как объединение объектов), другими словами, как только блок оказывается позади или за пределами поля зрения камеры, он перемещается вперед.
Чтобы создать бесконечный раннер в Unity, нам нужно будет создать платформу с препятствиями и контроллер игрока.
Шаг 1: Создайте платформу
Начнем с создания плиточной платформы, которая позже будет сохранена в Prefab:
- Создайте новый GameObject и назовите его «TilePrefab»
- Создайте новый куб (GameObject -> 3D Object -> Cube)
- Переместите куб внутри объекта «TilePrefab», измените его положение на (0, 0, 0) и масштабируйте до (8, 0,4, 20).

- При желании вы можете добавить рельсы по бокам, создав дополнительные кубы, например:

Что касается препятствий, у меня будет 3 варианта препятствий, но вы можете сделать столько, сколько необходимо:
- Создайте 3 GameObject внутри объекта «TilePrefab» и назовите их «Obstacle1», «Obstacle2» и «Obstacle3»
- Для первого препятствия создайте новый куб и переместите его внутрь объекта «Obstacle1».
- Масштабируйте новый куб примерно до той же ширины, что и платформа, и уменьшите его высоту (игроку придется подпрыгнуть, чтобы избежать этого препятствия).
- Создайте новый Материал, назовите его «RedMaterial» и измените его цвет на Красный, затем назначьте его Кубу (это нужно для того, чтобы препятствие отличалось от основной платформы).


- Для «Obstacle2» создайте пару кубиков и поместите их в треугольную форму, оставив одно свободное пространство внизу (игроку придется присесть, чтобы избежать этого препятствия).

- И, наконец, «Obstacle3» будет дубликатом «Obstacle1» и «Obstacle2», объединенных вместе.


- Теперь выберите все объекты внутри препятствий и измените их тег на «Finish», это понадобится позже для обнаружения столкновения между игроком и препятствием.
Чтобы создать бесконечную платформу, нам понадобится пара скриптов, которые будут обрабатывать пул объектов и активацию препятствий:
- Создайте новый скрипт, назовите его «SC_PlatformTile» и вставьте в него приведенный ниже код:
SC_PlatformTile.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class SC_PlatformTile : MonoBehaviour < public Transform startPoint; public Transform endPoint; public GameObject[] obstacles; //Objects that contains different obstacle types which will be randomly activated public void ActivateRandomObstacle() < DeactivateAllObstacles(); System.Random random = new System.Random(); int randomNumber = random.Next(0, obstacles.Length); obstacles[randomNumber].SetActive(true); >public void DeactivateAllObstacles() < for (int i = 0; i < obstacles.Length; i++) < obstacles[i].SetActive(false); >> >
- Создайте новый скрипт, назовите его «SC_GroundGenerator» и вставьте в него приведенный ниже код:
SC_GroundGenerator.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; public class SC_GroundGenerator : MonoBehaviour < public Camera mainCamera; public Transform startPoint; //Point from where ground tiles will start public SC_PlatformTile tilePrefab; public float movingSpeed = 12; public int tilesToPreSpawn = 15; //How many tiles should be pre-spawned public int tilesWithoutObstacles = 3; //How many tiles at the beginning should not have obstacles, good for warm-up ListspawnedTiles = new List(); int nextTileToActivate = -1; [HideInInspector] public bool gameOver = false; static bool gameStarted = false; float score = 0; public static SC_GroundGenerator instance; // Start is called before the first frame update void Start() < instance = this; Vector3 spawnPosition = startPoint.position; int tilesWithNoObstaclesTmp = tilesWithoutObstacles; for (int i = 0; i < tilesToPreSpawn; i++) < spawnPosition -= tilePrefab.startPoint.localPosition; SC_PlatformTile spawnedTile = Instantiate(tilePrefab, spawnPosition, Quaternion.identity) as SC_PlatformTile; if(tilesWithNoObstaclesTmp >0) < spawnedTile.DeactivateAllObstacles(); tilesWithNoObstaclesTmp--; >else < spawnedTile.ActivateRandomObstacle(); >spawnPosition = spawnedTile.endPoint.position; spawnedTile.transform.SetParent(transform); spawnedTiles.Add(spawnedTile); > > // Update is called once per frame void Update() < // Move the object upward in world space x unit/second. //Increase speed the higher score we get if (!gameOver && gameStarted) < transform.Translate(-spawnedTiles[0].transform.forward * Time.deltaTime * (movingSpeed + (score/500)), Space.World); score += Time.deltaTime * movingSpeed; >if (mainCamera.WorldToViewportPoint(spawnedTiles[0].endPoint.position).z < 0) < //Move the tile to the front if it's behind the Camera SC_PlatformTile tileTmp = spawnedTiles[0]; spawnedTiles.RemoveAt(0); tileTmp.transform.position = spawnedTiles[spawnedTiles.Count - 1].endPoint.position - tileTmp.startPoint.localPosition; tileTmp.ActivateRandomObstacle(); spawnedTiles.Add(tileTmp); >if (gameOver || !gameStarted) < if (Input.GetKeyDown(KeyCode.Space)) < if (gameOver) < //Restart current scene Scene scene = SceneManager.GetActiveScene(); SceneManager.LoadScene(scene.name); >else < //Start the game gameStarted = true; >> > > void OnGUI() < if (gameOver) < GUI.color = Color.red; GUI.Label(new Rect(Screen.width / 2 - 100, Screen.height / 2 - 100, 200, 200), "Game Over\nYour score is: " + ((int)score) + "\nPress 'Space' to restart"); >else < if (!gameStarted) < GUI.color = Color.red; GUI.Label(new Rect(Screen.width / 2 - 100, Screen.height / 2 - 100, 200, 200), "Press 'Space' to start"); >> GUI.color = Color.green; GUI.Label(new Rect(5, 5, 200, 25), "Score: " + ((int)score)); > >
- Прикрепите скрипт SC_PlatformTile к объекту «TilePrefab».
- Назначьте объекты «Obstacle1», «Obstacle2» и «Obstacle3» массиву препятствий.
Для начальной и конечной точек нам нужно создать 2 GameObject, которые должны быть размещены в начале и конце платформы соответственно:

- Назначьте переменные Start Point и End Point в SC_PlatformTile.

- Сохраните объект «TilePrefab» в Prefab и удалите его из сцены.
- Создайте новый GameObject и назовите его «_GroundGenerator»
- Прикрепите скрипт SC_GroundGenerator к объекту «_GroundGenerator».
- Измените положение основной камеры на (10, 1, -9) и измените ее вращение на (0, -55, 0).
- Создайте новый GameObject, назовите его «StartPoint» и измените его позицию на (0, -2, -15).
- Выберите объект «_GroundGenerator» и в SC_GroundGenerator назначьте переменные Main Camera, Start Point и Tile Prefab.
Теперь нажмите Play и наблюдайте, как движется платформа. Как только плитка платформы выходит из поля зрения камеры, она перемещается обратно в конец, при этом активируется случайное препятствие, создавая иллюзию бесконечного уровня (перейдите к 0:11).
Камеру необходимо разместить аналогично видео, чтобы платформы шли к камере и за ней, иначе платформы не будут повторяться.
![]()
Шаг 2: Создайте игрока
Экземпляр игрока будет представлять собой простую сферу с контроллером, способным прыгать и приседать.
- Создайте новую сферу (GameObject -> 3D Object -> Sphere) и удалите ее компонент Sphere Collider.
- Назначьте ему ранее созданный «RedMaterial».
- Создайте новый GameObject и назовите его «Player»
- Переместите сферу внутри объекта «Player» и измените ее положение на (0, 0, 0).
- Создайте новый скрипт, назовите его «SC_IRPlayer» и вставьте в него приведенный ниже код:
SC_IRPlayer.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class SC_IRPlayer : MonoBehaviour < public float gravity = 20.0f; public float jumpHeight = 2.5f; Rigidbody r; bool grounded = false; Vector3 defaultScale; bool crouch = false; // Start is called before the first frame update void Start() < r = GetComponent(); r.constraints = RigidbodyConstraints.FreezePositionX | RigidbodyConstraints.FreezePositionZ; r.freezeRotation = true; r.useGravity = false; defaultScale = transform.localScale; > void Update() < // Jump if (Input.GetKeyDown(KeyCode.W) && grounded) < r.velocity = new Vector3(r.velocity.x, CalculateJumpVerticalSpeed(), r.velocity.z); >//Crouch crouch = Input.GetKey(KeyCode.S); if (crouch) < transform.localScale = Vector3.Lerp(transform.localScale, new Vector3(defaultScale.x, defaultScale.y * 0.4f, defaultScale.z), Time.deltaTime * 7); >else < transform.localScale = Vector3.Lerp(transform.localScale, defaultScale, Time.deltaTime * 7); >> // Update is called once per frame void FixedUpdate() < // We apply gravity manually for more tuning control r.AddForce(new Vector3(0, -gravity * r.mass, 0)); grounded = false; >void OnCollisionStay() < grounded = true; >float CalculateJumpVerticalSpeed() < // From the jump height and gravity we deduce the upwards speed // for the character to reach at the apex. return Mathf.Sqrt(2 * jumpHeight * gravity); >void OnCollisionEnter(Collision collision) < if(collision.gameObject.tag == "Finish") < //print("GameOver!"); SC_GroundGenerator.instance.gameOver = true; >> >
- Прикрепите скрипт SC_IRPlayer к объекту «Player» (вы заметите, что он добавил еще один компонент под названием Rigidbody)
- Добавьте компонент BoxCollider в объект «Player».

- Поместите объект «Player» немного выше объекта «StartPoint», прямо перед камерой.
Нажмите Play и используйте клавишу W, чтобы подпрыгнуть, и клавишу S, чтобы присесть. Цель состоит в том, чтобы избежать красных препятствий: