Пиксельное приключение: создаём клон Lemmings в Unity

перевод
PatientZero 23 октября в 12:28 9,9k
Оригинал: Athos Kele

Введение


Думаю, я не единственный в детстве играл на Amiga в Lemmings. Прошли десятилетия, и я стал, среди всего прочего, разработчиком игр, ведущим канал на Youtube с туториалами по Unity.

Одним вечером я наткнулся на эти два видео (часть1 , часть 2) Майка Дейлли о воссоздании Lemmings с помощью Game Maker 2. Во мне разгорелась ностальгия и я решил что-нибудь с ней сделать. Поэтому я начал создавать собственную версию в Unity с использованием собственных ресурсов (по очевидным причинам).

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

Кроме того, здесь можно поиграть в проект на WebGL. Возможны баги.

Сложность проекта заключалась в воссоздании ощущений и механик Lemmings. В том числе обеспечение pixel-perfect-коллизий при перемещении по уровню множества персонажей, которые могут изменяться в зависимости от своих умений.

Создание карты


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

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

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

textureInstance = Instantiate(levelTexture) as Texture2D;

Однако нам нужно не только создать экземпляр текстуры, но и задать узлы на основании цветовой информации, получаемой от текстуры. Поэтому мы создадим небольшой класс Node:

public class Node
{
  public int x;
  public int y;
  public bool isEmpty; 
}

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

//maxX - ширина текстуры, значение можно получить непосредственно из Texture2D
//maxY - высота текстуры

for (int x = 0; x < maxX; x++)
  {
     for (int y = 0; y < maxY; y++)
     {
        //Задаём новый узел
        Node n = new Node();
        n.x = x;
        n.y = y;
        //и затем задаём каждый адрес

        //здесь мы попиксельно считываем текущую текстуру 
        Color c = levelTexture.GetPixel(x, y); 

        //затем задаём цвет экземпляра текстуры
        textureInstance.SetPixel(x, y, c);

        //здесь мы создаём информацию из текстуры; если это полностью прозрачная текстура, цвет пикселя будет иметь 0 в альфа-канале. Таким образом мы можем сказать, что этот узел пустой.
                    
        n.isEmpty = (c.a == 0);

        //Здесь мы делаем то же самое, но на этот раз для миникарты. Все прозрачные пиксели рендерятся как зелёные 
        Color mC = minimapColor;
        if (n.isEmpty)
          mC.a = 0;

        miniMapInstance.SetPixel(x, y, mC);

        //и наконец мы назначаем узел нашей сетке
        grid[x, y] = n;
                }
      }

//После цикла for нам также нужно выполнить для наших текстур .Apply()
textureInstance.Apply();
miniMapInstance.Apply();

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

Примечание: представленный выше цикл for должен выполняться только один раз. Поэтому даже для больших текстур (я использую текстуру размером 1000x200) избыточное потребление ресурсов будет только при загрузке. После этого для внесения изменений в карту мы будем использовать адрес, хранящийся в узле.

Теперь у нас есть текстура и мы должны её отрендерить. Мы добавляем GameObject со SpriteRenderer (в коде ниже хранится как levelRenderer ) и преобразуем нашу Texture2D в спрайт, чтобы назначить её. Это можно сделать с помощью следующих строк:

Rect rect = new Rect(0, 0, maxX, maxY);
levelRenderer.sprite = Sprite.Create(textureInstance, rect, Vector2.zero,100,0, SpriteMeshType.FullRect);

Можно сделать то же самое для миникарты, но вместо Sprite Renderer я использовал компонент Image UI.

Vector2.zero — это осевая точка, позиция 0,0 в нижнем левом углу. Значение 100 рядом с ним — это соотношение пикселя к точке, по умолчанию в Unity 1 точка на 100 пикселей. Также при выполнении любых вычислений с мировыми координатами важно знать, что, например, для нахождения позиции в мире узла node(5,6) мы умножаем x и y на соотношение, т.е. (x *(1 / 100)). Или же можно задать для всех спрайтов в настройках импорта соотношение 1:1/

Наконец, важно, чтобы сетка спрайта (sprite mesh) имела тип FullRect. Иначе Unity будет оптимизировать спрайты, создавая «острова» из непрозрачных пикселей. Это не станет проблемой при удалении пикселей с карты, но нам нужно и добавлять пиксели в пустые области. Задав тип FullRect, мы заставим Unity хранить спрайт как целый прямоугольник размером с исходное изображение.


На рисунке выше показана проблема, возникающая, когда спрайты имеют тип не FullRect.

Благодаря всему вышесказанному мы узнали, как воссоздать текстуру как карту.

Юниты и поиск путей




В этой части я пропущу процесс создания анимаций для юнитов внутри Unity, но если вы не знаете, как они создаются, то этот процесс раскрыт в видео.

Так как же нам реализовать pixel-perfect-коллизии?

Мы уже знаем, где на нашей сетке находятся пустые узлы и узлы земли. Давайте определимся с тем, по каким узлам мы можем ходить. «Правило» нашей игры будет следующим: если узел является землёй, а узел над ним — воздухом, то верхний узел является узлом, по которому можно идти. В результате мы сможем ходить из одного пустого узла в другой, но когда мы достигаем узла, под которым нет земли, мы падаем. Таким образом создание поиска путей для каждого юнита будет относительно простым процессом.

Нам просто нужно выполнить следующие действия в нужном порядке:

  1. Проверить, является ли текущий узел null. Если это верно, то мы, вероятно, упали с карты.
  2. Проверить, является ли curNode узлом выхода, и если это так, то юнит покинул уровень (подробнее об этом ниже).
  3. Проверить, является ли нижний узел пустым. Это означает, что мы находимся в воздухе, а значит падаем. Если мы падаем больше четырёх кадров (если последние четыре узла, по которым мы двигались, являлись пустыми), то переключаемся на анимацию падения. Благодаря этому анимация не меняется, когда мы просто спускаемся по склону.
  4. Если под нами земля, то нам нужно смотреть вперёд. Мы смотрим вперёд, если там пустой узел, то движемся туда.
  5. Если узел впереди не является пустым, то мы начинаем смотреть на четыре узла вверх, пока не найдём пустой узел.
  6. Если мы не нашли пустой узел, то просто поворачиваемся и идём в противоположную сторону.

Как вы видите, этот поиск путей очень рудиментарен, но для такой игры, как Lemmings, его более чем достаточно.

Существуют ещё и другие аспекты, которые нужно учитывать при поиске путей, например, способность «Зонтик», которая используется при падении, или способность «Копание», когда мы прокопали очень глубоко, и под нами нет земли, и так далее.

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

Все юниты управляются простым диспетчером, который вручную выполняет их Update(). Поскольку в Lemmings была кнопка «ускорения», для её воссоздания нам нужна имитация времени timeScale (при ускорении она имеет значение 2 или 3), которая передаётся юнитам вместе с отмасштабированной версией дельты времени. Юниты используют отмасштабированное deltaTime (вместо Time.deltaTime) для интерполяции между позициями и используют timeScale для изменения скорости в своём Animator. Таким образом создаётся впечатление, что игра находится в «быстром режиме», хотя масштаб времени Unity сохраняет свою обычную скорость.

Изменение уровня


И здесь начинается самое интересное. У нас есть уровень, но основной частью геймплея Lemmings было динамическое взаимодействие с уровнями.

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

textureInstance.SetPixel(x,y,c);

Где c = color (цвет). Единственная разница между добавлением и удалением пикселя заключается в значении альфа-канала. Не забывайте. что узел считается пустым, если значение альфы равно 0.

Однако, как мы помним из вышесказанного, когда мы используем .SetPixel(), нам также нужно вызвать .Apply() на текстуре, чтобы она действительно обновилась. Нам не нужно делать это каждый раз, когда мы меняем пиксель, потому что мы можем изменять за кадр несколько пикселей. Поэтому мы избегаем использовать .Apply() до конца кадра. Поэтому в конце нашего цикла Update() у нас есть простое булево значение. Когда оно истинно, и для textureInstance, и для миникарты выполняется .Apply():

if(applyTexture)
{
  applyTexture = false;
  textureInstance.Apply();
  miniMapInstance.Apply();
}

Способности


После создания этой системы нам просто достаточно определять, на какие пиксели мы воздействуем и как именно. В статье я рассмотрю только высокоуровневую логику событий, но вы можете посмотреть код в видео. Здесь и далее, когда я говорю «есть ли пиксель или нет», то имеется в виду пустота узла, ведь если вы не упали с карты, то в любом её месте всегда будет пиксель.

  • Хождение
    • Это базовая способность. Двигаемся дальше, здесь нет ничего интересного.
  • Остановка
    • Стоп! Перенаправляем юнитов в другую сторону.
  • Зонтик
    • Когда юнит падает с большой высоты, он умирает, но с этой способностью он плавно опускается вниз, как Мэри Поппинс. Эта способность работает очень просто, она всего лишь проверяет свою активность. Кроме того, она меняет скорость падения для интерполяции между пикселями, чтобы создать эффект расслабленного падения.
  • Копание вперёд
    • В оригинальной игре она называлась «Basher» — юнит просто копал тоннель вперёд. С точки зрения логики она переопределяет поиск пути: если впереди есть хотя бы один пиксель, то юнит продолжает копать на определённое количество пикселей, затем для каждого пикселя вперёд он берёт 8 пикселей над ним и отправляет их, чтобы они были удалены. Размер в восемь пикселей выбран из-за роста наших персонажей.
  • Копание вниз
    • Похоже на копание вперёд,. Вместо того, чтобы брать пиксели спереди и сверху, способность берёт 2-3 пикселя под юнитом, 2-3 вперёд, и, разумеется, из одного ряда ниже.
  • Взрывание
    • Это простой юнит-самоубийца, он просто взрывается, оставляя вместо себя дыру. Удаляемые пиксели находятся на заданном радиусе вокруг его позиции.
  • Строительство
    • Ещё одна классическая способность — юнит строит ряд пикселей диагонально вверх с точки, в которую он смотрит. Это достигается получением 4-5 пикселей впереди и вверху с дальнейшей передачей их для «добавления». Также это влияет на свойства узлов (превращает узел из пустого в заполненный). После этого способность интерполирует до следующей диагональной позиции и повторяет операции пока, не столкнётся со стеной или пока не закончится количество пикселей, выделенных на постройку.
  • Заполнение
    • Ещё одна забавная способность, которая плавно подводит нас к следующему разделу. Она впервые появилась в Lemmings 2: это юнит, бросающий пиксели «песка», которые имеют свойства «жидкости» — всегда спускаются вниз, пока не достигнут точки покоя. Подробнее мы рассмотрим их позже.

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


Спускаемся плавно, как Мэри Поппинс


Примеры использования некоторых способностей

«Жидкости»


Или как я называю их, узлы заполнения. Это ещё одно забавное дополнение. Высокоуровневая логика их такова: это динамические узлы, имеющие собственный «поиск путей». Сложите их все вместе и получите эффект жидкости. Как показано на видео, можно также создать эффект падающего снега.

Вот как это работает — у нас есть следующей класс:

public class FillNode
{
  public int x;
  public int y;
  public int t;
}

x и y — это, как можно догадаться, адрес узла. t здесь — это количество раз, когда узел заполнения находился в «позиции тупика». Можно поэкспериментировать с числами, но для себя я выбрал, что если он находился в ней 15 раз, то узел считается остановившимся, таким образом превращаясь из узла заполнения в обычный узел.

Узлы заполнения не обновляются каждый кадр, потому что тогда им пришлось бы менять позицию 60 раз в секунду, из-за чего они могли бы двигаться слишком быстро. Мы используем действующий только на них универсальный таймер. Лично я использовал обновление раз в 0,05 секунды, но вы можете поэкспериментировать с ним, чтобы получить различные эффекты жидкости.

Итак, когда они обновляются, то их «поиск путей» выглядит следующим образом:

  1. Проверяем, существует ли узел под нашим узлом, если нет, то этот узел заполнения упал за пределы карты.
  2. Если он существует, то проверяем, пуст ли он. Если он пуст, то очищаем пиксель, в котором находимся и добавляем к пикселю (узлу) ниже. Таким образом мы продолжаем двигаться вниз.
  3. Однако если он не пуст, проверяем пиксель вперёд-вниз. Если он пуст, перемещаемся туда.
  4. Если вперёд-вниз заполнен, то перемещаемся назад-вниз.
  5. Если мы находимся в позиции, из которой некуда двигаться, то прибавляем к t узла заполнения.
  6. Если t больше заданного нами значения, то удаляем узел заполнения и оставляем пиксель полным.

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

Учитывая всё вышесказанное, можно подумать, что эффект «жидкости» сильно влияет на скорость, но в нашем случае это не так. Как показано в видео, я тестировал его с большими числами (больше 10 000 узлов заполнения). Хотя не было шансов, что все 10 000 будут «живыми» одновременно, это создало очень красивый эффект, показанный ниже.


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

Редактор уровней


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

Но я хочу рассказать подробнее о сериализации, поэтому…

Сериализация


Как я показывал в видео, есть несколько способов её реализации. Мне даже необязательно использовать для создания уровней редактор. Мы можем просто загрузить с диска изображение в .png и использовать его в качестве уровня. Уже созданная нами система открывает широкие возможности. Однако нам по-прежнему необходимо создавать игру из текстуры, так что нам требуется сохранять позицию спауна и позицию выхода (давайте называть их двумя событиями). В сиквелах оригинальных Lemmings было несколько таких событий, но давайте сосредоточимся на каждом из них, основа логики будет одинаковой.

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

В результате я решил сериализировать всё необходимео на уровне в один файл. Единственное, что стоит здесь упомянуть — разумеется, невозможно сериализировать непосредственно Texture2D, но вы можете преобразовать её в массив байтов, закодировав текстуру. Unity упростила нам жизнь, потому что мы просто сделать следующее:

byte[] levelTexture = textureInstance.EncodeToPng();

Расширенный редактор уровней


Хоть редактор уровней — хорошая особенность игры, скорее всего, игрокам будет скучно начинать каждый раз с нуля. Как сказано выше, в видео я уже демонстрировал способ загрузки .png из файла, но мне хотелось, чтобы игра работала на WebGL без решения сложных проблем.

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

Как же это сделать? В Unity есть класс WWW, поэтому мы создадим корутину для загрузки текстуры:

IEnumerator LoadTextureFromWWW(string url)
{
  WWW www = new WWW(url);
  yield return www;

  //при загрузке www проверять, есть ли текстура
  if(<a class="url" href="http://www.texture" target="_blank">www.texture</a> == null)
  {
    //ссылка недействительна
  }
  else
  {
    //у нас есть текстура, тогда
    textureInstance = <a class="url" href="http://www.texture" target="_blank">www.texture</a>;
  }
}

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

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

Изменения для WebGL-сборки


… в WebGL это не разрешено. Обойти эту проблему можно, вставив в файл index WebGL-сборки небольшой фрагмент javascript. Что-нибудь подобное:

//прямо под этой строкой (она уже присутствует в WebGL-сборке)
 var gameInstance = UnityLoader.instantiate("gameContainer", "Build/lennys.json");
//добавляем
 function GetUserInput(){
  var inp = prompt("link");
  gameInstance.SendMessage("GameManager","ReceiveLink",inp);
  }

Благодаря этому в браузере появится всплывающее окно с текстовым полем, куда можно вставить ссылку. После нажатия на OK скрипт передаст сообщение, найдёт игровой объект «GameManager» и функцию «ReceiveLink», в подписи которой есть строка inp. Сама функция выглядит вот так:

public void ReceiveLink(string url)
  {
      linkField.text = url;
  }

Здесь linkField — это элемент UI InputField, ничего особенного.

Стоит заметить следующее:

  • Даже несмотря на то, что у нас есть скрипт под названием GameManager, JavaScript ищет игровой объект (а не класс!) с таким именем. Вызываемая нами функция расположена в классе UIManager (который находится в том же gameobject).
  • Текстура не загружается, пока игрок не нажимает на кнопку загрузки текстуры в игровом UI

Для выполнения функции JavaScript необходимо добавить следующие строки при нажатии кнопки загрузки url:

Application.ExternalEval("GetUserInput()");

Кроме того, в WebGL есть ещё одно ограничение. Да, вы уже догадались — мы не можем сохранить уровень пользователя, не обеспечив себе головную боль. Как же решить эту проблему? Очень просто — зададим MVP для нашего проекта.

Однако нам по-прежнему нужно дать игрокам поиграть в созданные ими загруженные как изображения или нарисованные от руки уровни. Поэтому мы будем сохранять их «на лету». Это значит, что все созданные уровни будут в следующей сессии утеряны. Это не особо серьёзная проблема, ведь можно загружать изображения онлайн. Сначала мы определяем, находимся ли на платформе WebGL, это делается так:

if (Application.platform == RuntimePlatform.WebGLPlayer)
  isWebGl = true;

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

Заключение




Это был очень интересный проект. Я работал над ним в свободное время и он занял 1-2 недели, но непосредственная работа заняла примерно столько же времени, сколько в видео, если не считать время на исследования и создание «графики» и «анимации» юнитов. И разумеется, в игре есть баги.

Также стоит заметить, что кроме нескольких вызовов API код в основном очень прост.

Продемонстрированный здесь pixel-perfect-поход стал также отправной точкой для создания нескольких других игр той эры. Не буду здесь раскрывать секреты, но скоро поработаю и над другими проектами, следите за моим каналом и поддержите его, если он вам понравился.
Проголосовать:
+33
Сохранить: