Как сделать симуляцию жизни в unity

Обновлено: 07.07.2024

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

С тех пор как разработка игр стала легко доступной для инди разработчиков, появилось не мало игр, где дизайну уделялось не такое большое внимание, а все силы уходили именно на геймплей. Позже разработчики пришли к тому, что стали доверять машинам и алгоритмам генерации. Самый яркий пример таких игр с генерируемыми мирами, это конечно же – Minecraft , где весь ландшафт состоит из блоков, хоть это и является иллюзией – ведь хранить даже обозримое кол-во блоков в оперативной памяти невероятно затратно, и все что мы видим в игре на самом деле это один большой “ супермеш ”.

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

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

Геймплей

Сначала разберем то, что будет из себя представлять будущая игра, точнее, нужно придумать примерно абстрактную модель генерируемого мира в игре. Пусть это будет простая 2D игра, где персонажу необходимо попасть из точки А в точку Б в случайно сгенерируемом уровне.

Сцена состоит из 2D блоков, которые будут случайным образом сгенерированы перед прохождением.


Процесс игры разобьем на три этапа:

  • Генерация уровня. Сперва необходимо будет сгенерировать уровень, состоящий из блоков.
  • Прохождение уровня. Помещаем персонажа на сцену и проходим уровень.
  • Завершение игры. Уничтожаем старый уровень и переходим к созданию нового.

Генерация уровня

Уровень будет состоять из блоков, разного типа:

  • Стартовый блок. Это именно то место, откуда игрок будет начинать свое путешествие.
  • Конечный блок. При попадании игрока на последний блок уровня, игра считается пройденной.


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


Именно кол-во промежуточных блоков будет влиять на дизайн и длину уровня.

Программная часть генерации блоков

Создадим скрипт Control , где укажем три переменных спрайтовых ( Sprite ) переменных для каждого типа блока.

  1. publicclass Control : MonoBehaviour
  2. public Sprite startBlock ;
  3. public Sprite midBlock ;
  4. public Sprite endBloc ;
  5. >

В эти три переменные занесем каждый спрайт блока по отдельности: стартовый, промежуточный и конечный. Дополним скрипт Control стартовым методом Start .

  1. publicclass Control : MonoBehaviour
  2. public Sprite startBlock ;
  3. public Sprite midBlock ;
  4. public Sprite endBloc ;
  5. publicvoid Start () <>
  6. >

В методе Start мы будем запускать генерацию уровня во время старта игры.

  1. publicclass Control : MonoBehaviour
  2. public Sprite startBlock ;
  3. public Sprite midBlock ;
  4. public Sprite endBloc ;
  5. publicvoid Start () <>
  6. private IEnumerator OnGeneratingRoutine () <>
  7. >

В методе OnGeneratingRoutine , будем выполнять сам процесс генерации уровня. Так как уровни у нас могут быть как большими, так и маленькими и генерироваться разное количество времени, процесс генерации мы поместим в корутину, чтобы игра не “ зависала ” во время работы “ генератора ”. Далее добавим одну числовую переменную completeLevels, с помощью которой будем указывать количество пройденных уровней.

  1. publicclass Control : MonoBehaviour
  2. public Sprite startBlock ;
  3. public Sprite midBlock ;
  4. public Sprite endBloc ;
  5. privateint completeLevels = 0 ;
  6. publicvoid Start () <>
  7. private IEnumerator OnGeneratingRoutine () <>
  8. publicvoid CompleteLevel ()
  9. this . completeLevels += 1 ;
  10. >
  11. >

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

Теперь можно переходить к методу OnGeneratingRoutine , где мы начнем описывать сам алгоритм генерации уровня.

  1. publicclass Control : MonoBehaviour
  2. public Sprite startBlock ;
  3. public Sprite midBlock ;
  4. public Sprite endBloc ;
  5. privateint completeLevels = 0 ;
  6. /*…остальной код…*/
  7. private IEnumerator OnGeneratingRoutine ()
  8. Vector2 size = new Vector2 ( 1 , 1 );
  9. Vector2 position = new Vector2 ( 0 , 0 );
  10. yieldreturn new WaitForEndOfFrame ();
  11. >
  12. >

Для начала в методе OnGeneratingRoutine объявим две векторные переменные: size , где укажем размер блоков по длине и высоте и position, где укажем точку, откуда будет начинать строится уровень. Теперь можно построить стартовый блок.

  1. publicclass Control : MonoBehaviour
  2. public Sprite startBlock ;
  3. public Sprite midBlock ;
  4. public Sprite endBloc ;
  5. privateint completeLevels = 0 ;
  6. /*…остальной код…*/
  7. private IEnumerator OnGeneratingRoutine ()
  8. Vector2 size = new Vector2 ( 1 , 1 );
  9. Vector2 position = new Vector2 ( 0 , 0 );
  10. GameObject newBlock = new GameObject ( “ Start block” );
  11. yieldreturn new WaitForEndOfFrame ();
  12. >
  13. >

Создаем новый GameObject newBlock на сцене.

  1. private IEnumerator OnGeneratingRoutine ()
  2. Vector2 size = new Vector2 ( 1 , 1 );
  3. Vector2 position = new Vector2 ( 0 , 0 );
  4. GameObject newBlock = new GameObject ( “ Start block” );
  5. newBlock . transform . position = position ;
  6. newBlock . transform . localScale = size ;
  7. SpriteRendere renderer = newBlock . AddComponent SpriteRenderer >();
  8. renderer . sprite = this . startBlock ;
  9. yieldreturn new WaitForEndOfFrame ();
  10. >

После создания нового блока, устанавливаем ему позицию и размер через его transform , добавляем блоку компонент SpriteRenderer, чтобы отобразить на сцене и указываем, какой именно спрайт ему отобразить, в нашем случае это будет стартовый спрайт первого блока startBlock .

Теперь запустим корутину OnGeneratingRoutine в методе Start и проверим ее выполнение.

Переходим к созданию промежуточных блоков. Для этого в корутине OnGeneratingRoutine добавим еще одну переменную count .

  1. private IEnumerator OnGeneratingRoutine ()
  2. Vector2 size = new Vector2 ( 1 , 1 );
  3. Vector2 position = new Vector2 ( 0 , 0 );
  4. GameObject newBlock = new GameObject ( “ Start block” );
  5. newBlock . transform . position = position ;
  6. newBlock . transform . localScale = size ;
  7. SpriteRendere renderer = newBlock . AddComponent SpriteRenderer >();
  8. renderer . sprite = this . startBlock ;
  9. int count = this . completeLevels + 5 ;
  10. yieldreturn new WaitForEndOfFrame ();
  11. >

Числовая переменная count будет указывать какое кол-во промежуточных блоков необходимо построить, это число будет зависеть от количества пройденных уровней и, чтобы их изначально не было слишком мало на первых уровнях, еще пяти (5) дополнительных блоков. Строить промежуточные блоки будем через цикл for .

  1. private IEnumerator OnGeneratingRoutine ()
  2. Vector2 size = new Vector2 ( 1 , 1 );
  3. Vector2 position = new Vector2 ( 0 , 0 );
  4. GameObject newBlock = new GameObject ( “ Start block” );
  5. newBlock . transform . position = position ;
  6. newBlock . transform . localScale = size ;
  7. SpriteRendere renderer = newBlock . AddComponent SpriteRenderer >();
  8. renderer . sprite = this . startBlock ;
  9. int count = this . completeLevels + 5 ;
  10. for ( int i = 0 ; i count ; i ++)
  11. newBlock = new GameObject ( “ Middle block” );
  12. renderer = newBlock . AddComponent SpriteRenderer >();
  13. renderer . sprite = this . midBlock ;
  14. >
  15. yieldreturn new WaitForEndOfFrame ();
  16. >

Также как мы строили стартовый блок, также строим и промежуточные: создаем новый GameObject , добавляем ему компонент SpriteRenderer , указываем спрайт для отображения на сцене и задаем размер и позицию.

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

  1. private IEnumerator OnGeneratingRoutine ()
  2. Vector2 size = new Vector2 ( 1 , 1 );
  3. Vector2 position = new Vector2 ( 0 , 0 );
  4. /*…остальной код…*/
  5. int count = this . completeLevels + 5 ;
  6. for ( int i = 0 ; i count ; i ++)
  7. newBlock = new GameObject ( “ Middle block” );
  8. renderer = newBlock . AddComponent SpriteRenderer >();
  9. renderer . sprite = this . midBlock ;
  10. newBlock . transform . localScale = size ;
  11. position . x += size . x ;
  12. newBlock . transform . position = position ;
  13. >
  14. yieldreturn new WaitForEndOfFrame ();
  15. >

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

  1. private IEnumerator OnGeneratingRoutine ()
  2. Vector2 size = new Vector2 ( 1 , 1 );
  3. Vector2 position = new Vector2 ( 0 , 0 );
  4. /*…остальной код…*/
  5. int count = this . completeLevels + 5 ;
  6. for ( int i = 0 ; i count ; i ++)
  7. newBlock = new GameObject ( “ Middle block” );
  8. renderer = newBlock . AddComponent SpriteRenderer >();
  9. renderer . sprite = this . midBlock ;
  10. newBlock . transform . localScale = size ;
  11. position . x += size . x ;
  12. position . y += size . y * Random . Range (- 1 , 2 );
  13. newBlock . transform . position = position ;
  14. >
  15. yieldreturn new WaitForEndOfFrame ();
  16. >

Высота блока по Y в переменной position также смещается вверх, либо вниз, в зависимости от размера блока, умноженного на случайное число от -1 до 1. Метод Random.Range генерирует ЦЕЛЫЕ числа от минимального до максимально (ИСКЛЮЧИТЕЛЬНО), это значит, что максимальное указанное число никогда достигнуто не будет. Завершаем цикл постройки промежуточных блоков новым WaitForEndOfFrame .

  1. private IEnumerator OnGeneratingRoutine ()
  2. Vector2 size = new Vector2 ( 1 , 1 );
  3. Vector2 position = new Vector2 ( 0 , 0 );
  4. /*…остальной код…*/
  5. int count = this . completeLevels + 5 ;
  6. for ( int i = 0 ; i count ; i ++)
  7. newBlock = new GameObject ( “ Middle block” );
  8. renderer = newBlock . AddComponent SpriteRenderer >();
  9. renderer . sprite = this . midBlock ;
  10. newBlock . transform . localScale = size ;
  11. position . x += size . x ;
  12. position . y += size . y * Random . Range (- 1 , 2 );
  13. newBlock . transform . position = position ;
  14. yieldreturn new WaitForEndOfFrame ();
  15. >
  16. yieldreturn new WaitForEndOfFrame ();
  17. >

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

  1. private IEnumerator OnGeneratingRoutine ()
  2. Vector2 size = new Vector2 ( 1 , 1 );
  3. Vector2 position = new Vector2 ( 0 , 0 );
  4. /*…остальной код…*/
  5. newBlock = new GameObject ( “ End block” );
  6. renderer = newBlock . AddComponent SpriteRenderer >();
  7. renderer . sprite = this . endBlock ;
  8. position . x += size . x ;
  9. position . y += size . y * Random . Range (- 1 , 2 );
  10. newBlock . transform . position = position ;
  11. newBlock . transform . localScale = size ;
  12. yieldreturn new WaitForEndOfFrame ();
  13. >

Готово, алгоритм генерации завершен, запускаем игру для последней проверки.

Заключение

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

  1. publicclass Control : MonoBehaviour
  2. public Sprite startBlock ;
  3. public Sprite midBlock ;
  4. public Sprite endBloc ;
  5. privateint completeLevels = 0 ;
  6. publicvoid Start () <>
  7. private IEnumerator OnGeneratingRoutine () <>
  8. publicvoid CompleteLevel ()
  9. this . completeLevels += 1 ;
  10. StartCoroutine ( OnGeneratingRoutine ());
  11. >
  12. >

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

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

Для этого фестиваля я неспешно делаю в одиночку 2D-платформер по одной из своих завалявшихся идей. И да, здесь опять анимация, но на этот раз не трехмерная, а спрайт-шитовая. Как оказалось, хоть статья была написана давненько, а проблемы все еще актуальны. И теперь я могу дополнить ее еще и пунктом для спрайтовой анимации.

Эта заметка будет:

- полезна тем, кто только начинает работать с анимацией в Unity;

- довольно бесполезна для опытных разработчиков, хотя мне было бы приятно получить от них фидбэк;

- совершенно не нужна тем, кто не имеет ничего общего с Unity и Mecanim. Разве что они хотят почитать про Mixamo.

Если кто не в курсе, Mixamo — это облачная служба автоматического риггинга и банк персонажей и анимаций, а Fuse — это приложение для создания гуманоидных моделей (редактор типа как в Sims), которые потом можно анимировать через Mixamo. К сожалению, в 2015 году все это купила Adobe, которая полностью забила на дальнейшее развитие этих продуктов и прикрутила Fuse к своему Creative Cloud. Что примечательно, все это сейчас совершенно бесплатно — бери не хочу, только зарегайся в Adobe и поставь себе кучу их ненужных сервисов.

Основные затраты по времени у нас вызвал тот факт, что для разных двуногих моделей, на вид одинаковых и с одинаковым скелетом, нужны разные анимации. В частности, если вы натаскаете разных человечков с Mixamo и скачаете для одного из них весь набор нужных движений, эти анимации к другим человечкам подходить не будут. С другой стороны, если вы сделаете персонажей в Adobe Fuse (потратив несколько часов на его установку и полчаса на запуск), то скачанные с ними Mixamo-анимации будут взаимозаменяемы. Если бы кто-то сказал нам об этом в самом начале, это бы сэкономило уйму времени, которое мы в итоге потратили на закачку тонны анимаций, их смену и (неочевидный момент) вставку оружия в руки персонажей, что нам пришлось делать дважды (а это ужасно муторный процесс).

Я покажу две кнопочки на Mixamo, которые нам помогли ускорить закачку всего этого добра. Первая из них — в форме черепушки — снимает скин с предпросмотра, позволяя значительно повысить производительность сервиса и предотвратить его падения.

Вторая полезная фишка — в окошечке загрузки, это возможность не загружать скин (модельку), а скачать только анимацию, уменьшив вес загрузки раз в 10. Таким образом, вам нужно только одну анимацию скачать с моделью (with skin, не забудьте пометить как-нибудь этот fbx, чтобы в юнити проще было его найти), а все остальные можно оставить без нее.

А на скриншоте ниже показаны две волшебные кнопки, которые и достают текстуры и материалы из моделек (здесь и далее версия Unity 2017.4.0f1).

И не удивляйтесь, если у вас получаются вот такие ресницы:

А дальше мы переходим к самому интересному…

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

Проблема 1: как зациклить анимацию (например, ходьба или idle). Или же наоборот, не зацикливать. Мы помнили, что где-то этот флажок был, но проискали его целый вечер. Вот он:

  • Повесить коллайдер на оружие и проводить проверку попадания по физике. Честно. Зато дополнительный коллайдер жрет мощность, хотя мог бы этого не делать. Даже если его включать только на время атаки. В нашей игре мы сделали вначале коллайдеры. И они работали… до какого-то момента, когда они работать стали нестабильно. Я почти целый день пыталась выяснить, в чем же, собственно, причина, и не нашла ее. Зато официальный туториал Unity меня убедил, что триггер-коллайдеры должны быть статичными. Пришлось уйти от коллайдеров. (Апдейт: триггер-коллайдеры могут прекрасно работать с кинематическими rigidbody)
  • Анимационные события. Мощно. Удобно. Если бы не тот факт, что анимации, идущие вместе с моделями, закрыты для редактирования. Для того чтобы повесить на них ивенты, надо создать новую анимацию в Юнити и скопировать туда все точки нужной анимации вручную. Это не сильно долго, но довольно муторно, если у вас этих анимаций хотя бы десяток. И хорошо бы это пришлось делать один раз — но ведь анимации подлежат постоянным изменениям, и тогда придется все опять переделывать. Как бы то ни было, этот способ лучше всего подойдет в том случае, если у вас на одну атаку несколько разных анимаций (для разнообразия) или больше одного момента атаки в анимации.
  • Параметр задержки, настраиваемый в редакторе. Лучше всего, если он будет в долях от длительности анимации атаки (во многих случаях подойдет 0.5) — тогда будет проще менять анимации при необходимости. Мне показался этот вариант самым простым и удобным.

Проблема 4: состояние смерти. Trigger vs bool. Триггер, если кто опять не в курсе, это такой bool-параметр анимации, который сам выключается где-то там после запуска этой анимации. Очень удобно использовать для запуска тех же анимаций атаки. Когда именно он выключается и что там потом происходит — этого никто не знает. И в этом кроется огромная проблема. У нас персонажи после проигрывания анимации смерти по триггеру снова возвращались в idle-состояние. И происходило это даже тогда, когда уже было полностью исключено как в коде, так и в аниматоре (см. скриншот). Так и не выяснив причину, мы решили поменять тип параметра на bool. И столкнулись с еще одной неприятной проблемой: персонажи зависали в начале анимации смерти и дальше не продвигались. Происходило это от того, что анимация смерти все время переходила сама в себя. Как оказалось, это лечится простым флажком:

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

Проблема 5 (для классических спрайтовых анимаций):

И вот в очередной раз принимаемся за анимацию главного персонажа, вроде бы все на месте, но откуда-то возникают задержки (заметите на гифке?)

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

// Use this for initialization
void Start () <
healthBarLength = Screen.width /4;
if(maxHealth maxHealth) _curHealth = maxHealth;
healthBarLength = (Screen.width / 4) * (_curHealth / (float)maxHealth);
>
>

//Выводит бар, показывающий состояние здоровья игрока

using UnityEngine;
using System.Collections;

public class PlayerHealth : MonoBehaviour //публичные переменные для настроек
private int maxHealth = 100;

//блок переменных локального пользования
private int _curHealth = 100;

Можно ли написать игру без ифов и условий в коде? Да! И я это докажу. Обучение с нуля с гарантией трудоустройства .

🎛 Игра жизнь и другие вычисления на видеокарте в Unity 3D [часть 1/2]

Первая часть видео про создание различных симуляций с помощью шейдеров в Unity 3D, которые исполняются на .

Жизнь из кода. Создание симуляции жизни клеток [Unity]

Видео повествует о создании симуляции жизни клеток, проверки поведения и развития в разных условиях. Присутствует .

Читайте также: