Как я прототип игры писал и учился генерировать лабиринты

image-loader.svg

Я закончил последнюю миссию в Extreme Landings на мобильном телефоне. C чувством глубокого удовлетворения отложил мобилу, выдохнул и закрыл глаза. Адреналин последней успешной посадки с сильным боковым ветром и сломанным рулём направления давал о себе знать.

image-loader.svg

Захотелось чего-то более медитативного. В голове смутно нарисовалась изометрическая проекция реки, по которой плывёт судно. Возможно даже под парусом. Оно пристаёт к берегу, а затем делает красивый разворот в русле реки… Вот прям тут мне вдруг захотелось сделать быстренько и простенько прототип, где можно было бы (вы будете смеяться) красиво разворачиваться по дуге. Ну простая физика, без учёта сопротивления среды, задействованы только скорость, ускорение и разворот.

Игродел из меня никакой, познаний в игровых движках никаких. Опыт в игроделании — есть, но не совсем тот, что нужен. Был опыт работы в уже запущенном проекте, а не написание чего-нибудь с нуля. Значит, париться с движками не будем. Мне вполне подойдёт браузер и JavaScript, а рисовать буду в Canvas — он ведь как раз для этого предназначен.

Чтобы вы понимали уровень притязаний на тот момент — мне хотелось чего-то такого:

image-loader.svg

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

В процессе поиска в интернете наткнулся на сайт с собранными из игр и скомпонованными спрайт-листами. И, о божечки, посмотрите! Спрайты из Eliminator Boat Duel! Те самые лодки, можно вставлять хоть в изометрическую проекцию, хоть в вид сверху. Красота.

На самом деле отсюда мне нужен набор спрайтов для катера. Причём одного.

Например, вот такого.Например, вот такого.

Почему бы не построить прототип, сразу заодно научившись на хорошем спрайт-листе расставлять нужные спрайты?

Теперь нужно внимательно посмотреть на набор спрайтов, прикинуть, сколько их нужно на полный оборот и посчитать углы направлений для каждого из них. На картинке выше видно довольно детальный набор для поворота от 0 до 180 градусов, а вот от 180 до 360 будем отражать зеркально. Дальше грубая сила в виде проставления координат и размеров для каждого спрайта (загоняем их под номерами в конфиг спрайт-листа), немного чистой арифметики для углов поворота и отдельный метод с кучей if-else if, который получает угол текущего направления игрока и берёт соответствующее изображение.

image-loader.svg

Выглядит топорно. Вот эти «волшебные номера» (11.25, 22.5, 33.75, 45…) — это угол поворота, при котором рисуем конкретный спрайт. Например, при направлении 45 ± 5.625 градуса показываем спрайт под номером 4. При смене спрайтов почти наверняка придётся волшебные номера пересчитывать, но сейчас это точно работает.

Хм, точно работает? Проверяю:

image-loader.svg

Да! И-де-аль-но!

А следующим на глаза попался спрайт-лист, где был красивый лабиринт из травы / камышей, с водной поверхностью. Причём обалденно структурированный, с пояснениями прям на картинке. То, что надо.

image-loader.svg

Но для начала я взял просто один кустик травы и один тайл с изображением водной поверхности. За пару-тройку дней интенсивного чтения интернетов и клацанья по кнопкам — смастерил на коленке уровень со слоем рендера и со слоем препятствий.

Банально забил два двумерных массива: один — для расчёта коллизий со значениями 1 и 0, второй — для отрисовки в Сanvas, где каждое число может соответствовать определённому тайлу. То есть я решаю что 0 — это нет коллизии, 1 — это препятствие. А для красивого отображения на препятствии рисую камыши, на проходе — воду или изредка кувшинки. Написал обработчик коллизий, который в некоторых случаях позволял застрять в текстуре. Всё как у настоящих гейм-девелоперов! Даже сделал «камеру», которая отображает только видимый кусок уровня и скроллит отображаемую зону при движении игрока. Это был уже небольшой, простенький игровой движок, с реализацией прототипа игры.

Теперь это выглядело примерно так:

image-loader.svg

Смотрите какая красота: всего 3 тайла для уровня и кораблик, а как сразу выглядит.

С одной стороны — я был готов фигачить уровни. А с другой — представил, что это будет не мой тестовый квадратик в 20 на 20 тайлов, а более-менее серьёзный уровень, скажем, 200×200. Перспектива пугала, примерно так же, как могло бы пугать переписывание всего написанного кода в машинный код ручками. Один уровень — ну ладно. А захочу другой — опять упарываться и расставлять это всё? Эй, в конце концов, я хоть не настоящий разработчик игр, но всё же программист — не хочется делать руками. Значит нужно генерировать!

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

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

Дальше последовала неделя, в течении которой я скачал игровой движок Phaser 3. Разбирался в устройстве, в терминологии, читал документацию, разбирал код примеров, думал, как прикрутить TypeScript вместо JavaScript, как это всё завести, как переписать уже работающий прототип на работающий прототип в Phaser… Сугубо рутинная работа.

А вот потом пришло время писать генератор лабиринтов!

Основные рассматриваемые механики движения были две: движение карты навстречу игроку — сверху вниз по экрану, примерно как в игре SpyHunter.

image-loader.svg

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

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

На всю следующую неделю моим любимым чтивом стали: Википедия, redblobgames.com и переводы на Хабре от @PatientZero про всякие игроделательные штуки. Я читал, думал, надо оно мне или нет, рисовал что-то на бумажке, перечитывал, рисовал, считал и так много раз. В конце концов для реализации остановился на методе Basic BSP Dungeon generation. В принципе, несмотря на то, что это метод уже весьма старый, его хватает, чтобы собрать работающий генератор лабиринтов.

Идея проста: есть исходное пустое пространство карты. Делим его на две равные или примерно равные половины. Потом каждую половинку делим ещё раз и повторяем до тех пор, пока полученные зоны не станут слишком маленькими для дальнейшего деления. Например, у нас есть карта 100×100, делить будем в пределах от 40% до 60% размера и по очереди — сначала по вертикали, потом по горизонтали. Считаем, что зоны, где оба размера меньше 20, нам не интересны. В процессе сохраняем результат деления в дерево — это нам понадобится, когда начнём соединять комнаты коридорами.

image-loader.svg

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

Выглядит это так:

image-loader.svg

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

Изучая материалы по генерации уровней и лабиринтов, наткнулся на Хабре на статью «Генерация подземелий и пещер для моей игры». Понял, что теперь нужно использовать клеточный автомат, который из нагенерированных прямоугольных комнат и коридоров сделает формы, напоминающее отдельные заводи в лабиринте из камышей (если это каменные стены — то будет выглядеть как пещеры). Как он это делает? Весьма интересно:

  1. Принимаем, что у нас каждая клетка может находиться в одном из четырёх состояний: мёртвая (М), живая (Ж), условно мёртвая (МУ) и условно живая (ЖУ). Мёртвая — прохода нет, и клетка не изменит своё состояние на следующих итерациях. Живая — это проход, которые не изменяет состояние на последующих итерациях. Условные клетки — это клетки в суперпозиции и в каждой итерации они для расчёта имеют состояние живая / мёртвая, но на последующей итерации могут изменить состояние по определённым условиям.

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

  3. Сгенерированные комнаты и коридоры заливаем клетками Ж. Они ни не должны меняться.

  4. Клетка МУ на каждой итерации проверяет соседей: если имеет 4 и более соседа в состояниях М и МУ — на следующей итерации будет в состоянии МУ. Клетка ЖУ — превратится в МУ, только если количество соседей М и МУ будет равно 5 и больше.

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

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

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

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

Ну и пару-тройку часов ушло на расстановку полноценного набора спрайтов окружения: нужно учитывать, что генератор создаёт по факту карту коллизий, из которой вторым слоем (он рендерится в спрайты) нужно сгенерировать карту тайлов. Например камыши — это стенка, но есть отдельные тайлы для камышей у воды, камышей в центре стены, камышей на повороте.

А по водной глади для интереса нужно раскидать кувшинки, которые не преграждают путь, но смотрятся красиво. Положение кувшинок также генерируется случайно. Просто при расстановке тайлов типа «проход» с каким-то шансом генерим не воду, а кувшинку на воде. Чтобы хорошо смотрелось — пришлось подбирать коэффициенты.

Всё.

Уровень сгенерирован и готов!

Следующая мысль спустя какое-то время плавания: «Скучно плывём». Что можно добавить для скорости? NITRO — ЗАКИСЬ АЗОТА! Здесь всё довольно просто: с одной стороны это должно быть явное ускорение, но с другой стороны — после окончания действия обычная скорость не должна вызывать ощущение черепашьего шага. Количество NITRO и время действия должно быть ограничено, а для интереса — их на карте должно быть несколько штук, чтобы игрок мог собрать.

Ну вот сейчас точно всё. Прототип готов.

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

image-loader.svg

Исходный код тут
Поиграться в результат можно здесь

© Habrahabr.ru