Как я прототип игры писал и учился генерировать лабиринты
Я закончил последнюю миссию в Extreme Landings на мобильном телефоне. C чувством глубокого удовлетворения отложил мобилу, выдохнул и закрыл глаза. Адреналин последней успешной посадки с сильным боковым ветром и сломанным рулём направления давал о себе знать.
Захотелось чего-то более медитативного. В голове смутно нарисовалась изометрическая проекция реки, по которой плывёт судно. Возможно даже под парусом. Оно пристаёт к берегу, а затем делает красивый разворот в русле реки… Вот прям тут мне вдруг захотелось сделать быстренько и простенько прототип, где можно было бы (вы будете смеяться) красиво разворачиваться по дуге. Ну простая физика, без учёта сопротивления среды, задействованы только скорость, ускорение и разворот.
Игродел из меня никакой, познаний в игровых движках никаких. Опыт в игроделании — есть, но не совсем тот, что нужен. Был опыт работы в уже запущенном проекте, а не написание чего-нибудь с нуля. Значит, париться с движками не будем. Мне вполне подойдёт браузер и JavaScript, а рисовать буду в Canvas — он ведь как раз для этого предназначен.
Чтобы вы понимали уровень притязаний на тот момент — мне хотелось чего-то такого:
Тут я понял, что корабль из квадрата так себе: не видно направления движения. Надо рисовать примитивный для пользователя какой-то интерфейс, указывающий направление, а ещё лучше — добавить спрайты. Ведь раньше нормальные люди делали всё на спрайтах, даже в DOOM II были спрайты и игралось просто огонь.
В процессе поиска в интернете наткнулся на сайт с собранными из игр и скомпонованными спрайт-листами. И, о божечки, посмотрите! Спрайты из Eliminator Boat Duel! Те самые лодки, можно вставлять хоть в изометрическую проекцию, хоть в вид сверху. Красота.
На самом деле отсюда мне нужен набор спрайтов для катера. Причём одного.
Например, вот такого.
Почему бы не построить прототип, сразу заодно научившись на хорошем спрайт-листе расставлять нужные спрайты?
Теперь нужно внимательно посмотреть на набор спрайтов, прикинуть, сколько их нужно на полный оборот и посчитать углы направлений для каждого из них. На картинке выше видно довольно детальный набор для поворота от 0 до 180 градусов, а вот от 180 до 360 будем отражать зеркально. Дальше грубая сила в виде проставления координат и размеров для каждого спрайта (загоняем их под номерами в конфиг спрайт-листа), немного чистой арифметики для углов поворота и отдельный метод с кучей if-else if, который получает угол текущего направления игрока и берёт соответствующее изображение.
Выглядит топорно. Вот эти «волшебные номера» (11.25, 22.5, 33.75, 45…) — это угол поворота, при котором рисуем конкретный спрайт. Например, при направлении 45 ± 5.625 градуса показываем спрайт под номером 4. При смене спрайтов почти наверняка придётся волшебные номера пересчитывать, но сейчас это точно работает.
Хм, точно работает? Проверяю:
Да! И-де-аль-но!
А следующим на глаза попался спрайт-лист, где был красивый лабиринт из травы / камышей, с водной поверхностью. Причём обалденно структурированный, с пояснениями прям на картинке. То, что надо.
Но для начала я взял просто один кустик травы и один тайл с изображением водной поверхности. За пару-тройку дней интенсивного чтения интернетов и клацанья по кнопкам — смастерил на коленке уровень со слоем рендера и со слоем препятствий.
Банально забил два двумерных массива: один — для расчёта коллизий со значениями 1 и 0, второй — для отрисовки в Сanvas, где каждое число может соответствовать определённому тайлу. То есть я решаю что 0 — это нет коллизии, 1 — это препятствие. А для красивого отображения на препятствии рисую камыши, на проходе — воду или изредка кувшинки. Написал обработчик коллизий, который в некоторых случаях позволял застрять в текстуре. Всё как у настоящих гейм-девелоперов! Даже сделал «камеру», которая отображает только видимый кусок уровня и скроллит отображаемую зону при движении игрока. Это был уже небольшой, простенький игровой движок, с реализацией прототипа игры.
Теперь это выглядело примерно так:
Смотрите какая красота: всего 3 тайла для уровня и кораблик, а как сразу выглядит.
С одной стороны — я был готов фигачить уровни. А с другой — представил, что это будет не мой тестовый квадратик в 20 на 20 тайлов, а более-менее серьёзный уровень, скажем, 200×200. Перспектива пугала, примерно так же, как могло бы пугать переписывание всего написанного кода в машинный код ручками. Один уровень — ну ладно. А захочу другой — опять упарываться и расставлять это всё? Эй, в конце концов, я хоть не настоящий разработчик игр, но всё же программист — не хочется делать руками. Значит нужно генерировать!
В этих размышлениях я плавал туда-сюда по карте и начал замечать, что вот тут прорисовка не такая плавная, как хотелось бы. А вот здесь неудачно перерисовываются стыкующиеся тайлы при скролле — становится видно полоску с белым фоном, которая сразу же скрывается тайлами. В общем — я похоже собирал те грабли, которые уже были собраны ранее всеми, кто писал игры и игровые движки.
Раз уж собрался что-то делать с генерацией уровня, то надо сделать что-то ещё и с отрисовкой. А ещё кода в игре стало заметно больше. Теперь меня уже начало раздражать, что я не всегда помню, какие свойства есть у объекта, прилетающего в метод, который правлю. IDE старалась, помогала изо всех сил, но не всегда это у неё получалось. Нужна типизация — и логичным образом на ум пришёл TypeScript, в котором можно всё красиво уложить в классы, типизировать свойства объектов и всё, что попадётся под руку.
Дальше последовала неделя, в течении которой я скачал игровой движок Phaser 3. Разбирался в устройстве, в терминологии, читал документацию, разбирал код примеров, думал, как прикрутить TypeScript вместо JavaScript, как это всё завести, как переписать уже работающий прототип на работающий прототип в Phaser… Сугубо рутинная работа.
А вот потом пришло время писать генератор лабиринтов!
Основные рассматриваемые механики движения были две: движение карты навстречу игроку — сверху вниз по экрану, примерно как в игре SpyHunter.
Второй вариант — генерировать отдельный уровень целиком и ставить игрока на него. Остановился на втором варианте, посчитав, что это больше похоже на то, что хочу видеть: движение в произвольном направлении, исследование карты.
Значит будем гонять на катере по лабиринту и собирать звёзды. Вообще это должны были быть спасательные круги, но подходящих картинок не нашлось, поэтому — звёздочки из ассетов с уроками движка.
На всю следующую неделю моим любимым чтивом стали: Википедия, redblobgames.com и переводы на Хабре от @PatientZero про всякие игроделательные штуки. Я читал, думал, надо оно мне или нет, рисовал что-то на бумажке, перечитывал, рисовал, считал и так много раз. В конце концов для реализации остановился на методе Basic BSP Dungeon generation. В принципе, несмотря на то, что это метод уже весьма старый, его хватает, чтобы собрать работающий генератор лабиринтов.
Идея проста: есть исходное пустое пространство карты. Делим его на две равные или примерно равные половины. Потом каждую половинку делим ещё раз и повторяем до тех пор, пока полученные зоны не станут слишком маленькими для дальнейшего деления. Например, у нас есть карта 100×100, делить будем в пределах от 40% до 60% размера и по очереди — сначала по вертикали, потом по горизонтали. Считаем, что зоны, где оба размера меньше 20, нам не интересны. В процессе сохраняем результат деления в дерево — это нам понадобится, когда начнём соединять комнаты коридорами.
После того как всё разделено, генерируем в последних зонах комнаты. Поднимаемся по сохранённому дереву выше от листьев к корню, генерируем комнаты, соединяем коридором с ранее сгенерированными комнатами. Повторяем, пока не дойдём до корня. На выходе получаем готовый лабиринт, в котором все комнаты наверняка соединены. Метод требует поиграться параметрами в процессе генерации, но результат хороший. Для Roguelike игр — то, что нужно.
Выглядит это так:
Поскольку прямоугольные заводи — дело не самое частое в дикой природе, начал думать, как можно генерировать не просто квадратные комнаты, а что-то более интересное.
Изучая материалы по генерации уровней и лабиринтов, наткнулся на Хабре на статью «Генерация подземелий и пещер для моей игры». Понял, что теперь нужно использовать клеточный автомат, который из нагенерированных прямоугольных комнат и коридоров сделает формы, напоминающее отдельные заводи в лабиринте из камышей (если это каменные стены — то будет выглядеть как пещеры). Как он это делает? Весьма интересно:
Принимаем, что у нас каждая клетка может находиться в одном из четырёх состояний: мёртвая (М), живая (Ж), условно мёртвая (МУ) и условно живая (ЖУ). Мёртвая — прохода нет, и клетка не изменит своё состояние на следующих итерациях. Живая — это проход, которые не изменяет состояние на последующих итерациях. Условные клетки — это клетки в суперпозиции и в каждой итерации они для расчёта имеют состояние живая / мёртвая, но на последующей итерации могут изменить состояние по определённым условиям.
Сразу помечаем весь периметр уровня клетками М, а также границы между поделёнными зонами. Так в процессе работы клеточного автомата эти комнаты не сольются и между ними всегда будет граница. Одного ряда хватит.
Сгенерированные комнаты и коридоры заливаем клетками Ж. Они ни не должны меняться.
Клетка МУ на каждой итерации проверяет соседей: если имеет 4 и более соседа в состояниях М и МУ — на следующей итерации будет в состоянии МУ. Клетка ЖУ — превратится в МУ, только если количество соседей М и МУ будет равно 5 и больше.
Вроде это все необходимые ограничения, на которых можно строить клеточный автомат.
Один из промежуточных результатов генератора для экспериментов выглядел так.
Здесь показана проверка, как генерируются разные варианты лабиринта, а потом несколько итераций клеточного автомата поверх.
На сам клеточный автомат я потратил несколько часов. Потом ещё полчаса на то, чтобы он принимал на вход размеры лабиринта, а на выходе отдавал сгенерированный лабиринт в нужном виде для игры. Проверил рендер, корректность обработки коллизий.
Ну и пару-тройку часов ушло на расстановку полноценного набора спрайтов окружения: нужно учитывать, что генератор создаёт по факту карту коллизий, из которой вторым слоем (он рендерится в спрайты) нужно сгенерировать карту тайлов. Например камыши — это стенка, но есть отдельные тайлы для камышей у воды, камышей в центре стены, камышей на повороте.
А по водной глади для интереса нужно раскидать кувшинки, которые не преграждают путь, но смотрятся красиво. Положение кувшинок также генерируется случайно. Просто при расстановке тайлов типа «проход» с каким-то шансом генерим не воду, а кувшинку на воде. Чтобы хорошо смотрелось — пришлось подбирать коэффициенты.
Всё.
Уровень сгенерирован и готов!
Следующая мысль спустя какое-то время плавания: «Скучно плывём». Что можно добавить для скорости? NITRO — ЗАКИСЬ АЗОТА! Здесь всё довольно просто: с одной стороны это должно быть явное ускорение, но с другой стороны — после окончания действия обычная скорость не должна вызывать ощущение черепашьего шага. Количество NITRO и время действия должно быть ограничено, а для интереса — их на карте должно быть несколько штук, чтобы игрок мог собрать.
Ну вот сейчас точно всё. Прототип готов.
При старте уровня под капотом запускается генерация лабиринта, в полученный лабиринт расставляем игрока, звёзды, NITRO. Всё это отрисовываем и можно плыть!
Исходный код тут
Поиграться в результат можно здесь