Бесконечно генерируемые миры в Unity
Хороший дизайн уровней в играх всегда очень высоко ценился игроками и разработчиками и это стало целым отдельным направлением в игростроении. Чаще всего, первую оценку игры мы делаем именно по ее окружению, а в рекламе, к примеру, дизайн может играть фактическки ключевую роль и влиять на то, как игру воспримут.
С тех пор как разработка игр стала легко доступной для инди разработчиков, появилось не мало игр, где дизайну уделялось не такое большое внимание, а все силы уходили именно на геймплей. Позже разработчики пришли к тому, что стали доверять машинам и алгоритмам генерации. Самый яркий пример таких игр с генерируемыми мирами, это конечно же – Minecraft, где весь ландшафт состоит из блоков, хоть это и является иллюзией – ведь хранить даже обозримое кол-во блоков в оперативной памяти невероятно затратно, и все что мы видим в игре на самом деле это один большой “супермеш”.
Также стали появляться всякие раннеры с бесконечно генерируемыми уровнями, и теперь это стало даже неким отдельным жанром, где геймплей неизменчив, а сцены генерируются алгоритмами.
В этой статье мы попробуем разобрать один такой способ генерации сцены прямо в игре, а также как создать цикличность прохождения таких игр.
Геймплей
Сначала разберем то, что будет из себя представлять будущая игра, точнее, нужно придумать примерно абстрактную модель генерируемого мира в игре. Пусть это будет простая 2D игра, где персонажу необходимо попасть из точки А в точку Б в случайно сгенерируемом уровне.
Сцена состоит из 2D блоков, которые будут случайным образом сгенерированы перед прохождением.
Процесс игры разобьем на три этапа:
- Генерация уровня. Сперва необходимо будет сгенерировать уровень, состоящий из блоков.
- Прохождение уровня. Помещаем персонажа на сцену и проходим уровень.
- Завершение игры. Уничтожаем старый уровень и переходим к созданию нового.
Генерация уровня
Уровень будет состоять из блоков, разного типа:
- Стартовый блок. Это именно то место, откуда игрок будет начинать свое путешествие.
- Конечный блок. При попадании игрока на последний блок уровня, игра считается пройденной.
- Промежуточный блок. Из этих блоков будет состоять большая часть уровня.
Именно кол-во промежуточных блоков будет влиять на дизайн и длину уровня.
Программная часть генерации блоков
Создадим скрипт Control, где укажем три переменных спрайтовых (Sprite) переменных для каждого типа блока.
- public class Control : MonoBehaviour {
- public Sprite startBlock;
- public Sprite midBlock;
- public Sprite endBloc;
- }
В эти три переменные занесем каждый спрайт блока по отдельности: стартовый, промежуточный и конечный. Дополним скрипт Control стартовым методом Start.
- public class Control : MonoBehaviour {
- public Sprite startBlock;
- public Sprite midBlock;
- public Sprite endBloc;
- public void Start() {}
- }
В методе Start мы будем запускать генерацию уровня во время старта игры.
- public class Control : MonoBehaviour {
- public Sprite startBlock;
- public Sprite midBlock;
- public Sprite endBloc;
- public void Start() {}
- private IEnumerator OnGeneratingRoutine() {}
- }
В методе OnGeneratingRoutine, будем выполнять сам процесс генерации уровня. Так как уровни у нас могут быть как большими, так и маленькими и генерироваться разное количество времени, процесс генерации мы поместим в корутину, чтобы игра не “зависала” во время работы “генератора”. Далее добавим одну числовую переменную completeLevels, с помощью которой будем указывать количество пройденных уровней.
- public class Control : MonoBehaviour {
- public Sprite startBlock;
- public Sprite midBlock;
- public Sprite endBloc;
- private int completeLevels = 0;
- public void Start() {}
- private IEnumerator OnGeneratingRoutine() {}
- public void CompleteLevel() {
- this.completeLevels += 1;
- }
- }
Добавим метод CompleteLevel, который будет увеличивать переменную completeLevels на одну единицу каждый раз, когда игрок пройдет очередной уровень. Переменная completeLevels в будущем поможет нам генерировать все более сложные и длинные уровни в процессе прохождения игры.
Теперь можно переходить к методу OnGeneratingRoutine, где мы начнем описывать сам алгоритм генерации уровня.
- public class Control : MonoBehaviour {
- public Sprite startBlock;
- public Sprite midBlock;
- public Sprite endBloc;
- private int completeLevels = 0;
- /*…остальной код…*/
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- yield return new WaitForEndOfFrame();
- }
- }
Для начала в методе OnGeneratingRoutine объявим две векторные переменные: size, где укажем размер блоков по длине и высоте и position, где укажем точку, откуда будет начинать строится уровень. Теперь можно построить стартовый блок.
- public class Control : MonoBehaviour {
- public Sprite startBlock;
- public Sprite midBlock;
- public Sprite endBloc;
- private int completeLevels = 0;
- /*…остальной код…*/
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- GameObject newBlock = new GameObject(“Start block”);
- yield return new WaitForEndOfFrame();
- }
- }
Создаем новый GameObject newBlock на сцене.
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- GameObject newBlock = new GameObject(“Start block”);
- newBlock.transform.position = position;
- newBlock.transform.localScale = size;
- SpriteRendere renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.startBlock;
- yield return new WaitForEndOfFrame();
- }
После создания нового блока, устанавливаем ему позицию и размер через его transform, добавляем блоку компонент SpriteRenderer, чтобы отобразить на сцене и указываем, какой именно спрайт ему отобразить, в нашем случае это будет стартовый спрайт первого блока startBlock.
Теперь запустим корутину OnGeneratingRoutine в методе Start и проверим ее выполнение.
- public void Start() {
- StartCoroutine(OnGeneratingRoutine());
- }
Переходим к созданию промежуточных блоков. Для этого в корутине OnGeneratingRoutine добавим еще одну переменную count.
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- GameObject newBlock = new GameObject(“Start block”);
- newBlock.transform.position = position;
- newBlock.transform.localScale = size;
- SpriteRendere renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.startBlock;
- int count = this.completeLevels + 5;
- yield return new WaitForEndOfFrame();
- }
Числовая переменная count будет указывать какое кол-во промежуточных блоков необходимо построить, это число будет зависеть от количества пройденных уровней и, чтобы их изначально не было слишком мало на первых уровнях, еще пяти (5) дополнительных блоков. Строить промежуточные блоки будем через цикл for.
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- GameObject newBlock = new GameObject(“Start block”);
- newBlock.transform.position = position;
- newBlock.transform.localScale = size;
- SpriteRendere renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.startBlock;
- int count = this.completeLevels + 5;
- for(int i = 0; i < count; i++) {
- newBlock = new GameObject(“Middle block”);
- renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.midBlock;
- }
- yield return new WaitForEndOfFrame();
- }
Также как мы строили стартовый блок, также строим и промежуточные: создаем новый GameObject, добавляем ему компонент SpriteRenderer, указываем спрайт для отображения на сцене и задаем размер и позицию.
Так как промежуточные блоки строятся по горизонтали, значит и позицию необходимо с каждым новым блоком сдвигать немного вправо. Для того чтобы узнать на сколько ее необходимо сдвинуть, воспользуемся переменной size, где указаны размеры блоков.
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- /*…остальной код…*/
- int count = this.completeLevels + 5;
- for(int i = 0; i < count; i++) {
- newBlock = new GameObject(“Middle block”);
- renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.midBlock;
- newBlock.transform.localScale = size;
- position.x += size.x;
- newBlock.transform.position = position;
- }
- yield return new WaitForEndOfFrame();
- }
Чтобы сдвинуть позицию блока вверх или вниз, воспользуемся случайной генерацией чисел через Random.
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- /*…остальной код…*/
- int count = this.completeLevels + 5;
- for(int i = 0; i < count; i++) {
- newBlock = new GameObject(“Middle block”);
- renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.midBlock;
- newBlock.transform.localScale = size;
- position.x += size.x;
- position.y += size.y * Random.Range(-1, 2);
- newBlock.transform.position = position;
- }
- yield return new WaitForEndOfFrame();
- }
Высота блока по Y в переменной position также смещается вверх, либо вниз, в зависимости от размера блока, умноженного на случайное число от -1 до 1. Метод Random.Range генерирует ЦЕЛЫЕ числа от минимального до максимально (ИСКЛЮЧИТЕЛЬНО), это значит, что максимальное указанное число никогда достигнуто не будет. Завершаем цикл постройки промежуточных блоков новым WaitForEndOfFrame.
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- /*…остальной код…*/
- int count = this.completeLevels + 5;
- for(int i = 0; i < count; i++) {
- newBlock = new GameObject(“Middle block”);
- renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.midBlock;
- newBlock.transform.localScale = size;
- position.x += size.x;
- position.y += size.y * Random.Range(-1, 2);
- newBlock.transform.position = position;
- yield return new WaitForEndOfFrame();
- }
- yield return new WaitForEndOfFrame();
- }
Можно запустить игру и убедится, что блоки правильно генерируются, после чего переходим к заключительной части генерации блоков – созданию замыкающего, конечного блока. Также как и стартовый блок, он создается отдельно, но также как и промежуточный – с помощью случайной генерации по высоте.
- private IEnumerator OnGeneratingRoutine() {
- Vector2 size = new Vector2(1, 1);
- Vector2 position = new Vector2(0, 0);
- /*…остальной код…*/
- newBlock = new GameObject(“End block”);
- renderer = newBlock.AddComponent<SpriteRenderer>();
- renderer.sprite = this.endBlock;
- position.x += size.x;
- position.y += size.y * Random.Range(-1, 2);
- newBlock.transform.position = position;
- newBlock.transform.localScale = size;
- yield return new WaitForEndOfFrame();
- }
Готово, алгоритм генерации завершен, запускаем игру для последней проверки.
Заключение
Чтобы генерация уровня происходила каждый раз когда игрок завершает игру, в методе CompleteLevel достаточно просто запустить корутину OnGeneratingRoutine заново.
- public class Control : MonoBehaviour {
- public Sprite startBlock;
- public Sprite midBlock;
- public Sprite endBloc;
- private int completeLevels = 0;
- public void Start() {}
- private IEnumerator OnGeneratingRoutine() {}
- public void CompleteLevel() {
- this.completeLevels += 1;
- StartCoroutine(OnGeneratingRoutine());
- }
- }
Сам алгоритм генерации достаточно простой, его можно расширить и дополнить новыми элементами блоков: ловушками, пропастями и тд. Добавить блокам коллайдеры и персонажа, который сможет перемещать по ним.
Здесь все построено на спрайтах, а как их сделать физическими объектами? что бы можно было по ним бегать, как у вас в последнем видео…было бы все на префабах..я бы еще понял…
Вам нужно добавить к спрайтам Collider и Rigidbody.
Просто наложить на объект(спрайт) любой коллайдер(collider2D), им не нужен rigidBody2D, потому что он накладывает физику на объект, он начнёт крутится, на него будет влиять гравитация и он будет бится об другие коллайдеры, даже об игрока, если я опоздал с помощью извини)
Как сделать что бы персонаж мог ломатьь эти блоки.
Задайте Ваш вопрос на почту сайта