Недавно я написал Space Invader Generator в рамках соревнований по кодингу Creative Coding Amsterdam. Разумеется, я сделал это ради развлечения... но и для господства над галактикой тоже! На скриншоте показано, как он выглядит, а в посте я объясню, как он работает.

Вот примеры космических захватчиков, которых он может генерировать:

A pink space invader generated using my tool
A green space invader generated using my tool
A blue space invader generated using my tool

С чего всё началось

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

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

И тогда я подумал об игре Space Invaders. Пришельцы в этой игре маленькие, их легко отрендерить из 3D-блоков и к тому же они давно стали частью истории видеоигр, поэтому их можно узнать с первого взгляда.

Classic space invader rendered using Rayven
Классический космический захватчик, отрендеренный Rayven
The same space invader rendered from a different angle and with different shading
Тот же самый инопланетянин, отрендеренный под другим углом и с другим затенением

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

Соревнования по кодингу

Приятной неожиданностью стало то, что людям понравилась моя идея. Мы набросали правила, после чего начался челлендж Space Invaders!

От каракуль к пикселям

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

Поэтому я запустил Aseprite и приступил к работе:

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

При изучении рисунков начал выявляться паттерн. Это был самый лучший паттерн — основанный на геометрии и векторной графике. И в том, и в другом я очень хорош. Я решил задействовать свои сильные стороны и попробовать сгенерировать векторного захватчика. С радостью могу сказать, что у меня получилось — реализованный мной инструмент способен сгенерировать большинство из нарисованных мной от руки космических пришельцев.

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

И последнее примечание: я часто буду использовать термины «генерация» и «случайный выбор». Обычно это означает, что для вычисления точки в 2D-пространстве я задействую случайность (с некоторыми ограничениями).

Строительство захватчика

Это наша сетка, в процессе мы будем рисовать инопланетянина на ней.

Наверно, стоит начать с тела. Если вы посмотрите на мои наброски, то заметите тот же паттерн, что и я — почти все тела напоминают многоугольник низкого разрешения. План будет заключаться в генерации векторного многоугольника. Низкое разрешение сетки позволит скрыть наши векторные изъяны.

Находим центр

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

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

Определяем верхнюю и нижнюю границы

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

Рисуем левую половину

Случайным образом выберем в левой части от одной до пяти точек.

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

Зеркалим направо

После генерации точек слева мы отзеркаливаем их направо.

Соединяем точки

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

У нашего пришельца появилось тело! Давайте теперь добавим ему конечностей.

Добавляем конечности

В коде генерируемые снизу конечности названы щупальцами, а сверху — рогами. Они генерируются одинаково, только с разными параметрами.

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

Находим основание

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

Рисуем центральную линию

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

Утолщаем линию

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

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

Два примечания об этом алгоритме «толстых линий»:

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

  • В генераторе у ширины линии есть параметр «смягчение» (easing). Может, это уже перебор, но он позволяет удобнее настраивать ширину вдоль щупальца и время от времени заполнять отсутствующий пиксель. Мне понравился этот уровень контроля, поэтому я его оставил.

Наше первое щупальце

Соединив конечные точки средних линий, мы получим первую толстую линию, точнее, щупальце.

Теперь мы готовы аналогичным образом генерировать новые щупальца и рога.

Отращиваем новые щупальца

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

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

Чтобы центральное щупальце было симметричным, мы пойдём простым путём —отрисуем его случайно, а затем отзеркалим.

Добавляем рога

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

Итак, теперь у нас есть очень грубое векторное изображение захватчика. На основе этого грубого наброска нужно создать пикселизированную картинку.

Превращаем векторы в пиксели

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

Пикселизируем тело

Я пошёл по простому пути — алгоритм проверяет, находится ли центр пикселя внутри многоугольника.

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

Пикселизируем конечности

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

Но если точки разнесены слишком далеко, между ними всё равно возникают пробелы. Для решения проблемы я добавил параметр line splitting («разбиение линий»), подразделяющий центральную линию щупальца на большее количество отрезков. Чем больше точек, тем выше вероятность того, что центр пикселя окажется рядом с одной из них. Это необязательно, но позволяет улучшать понравившихся инопланетян.

Картинка уже похожа на захватчика из игры, но он всё ещё слеп. Ему нужны глаза!

Добавляем глаза

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

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

Раскрашиваем захватчика

Наконец, можно добавить цвет, и наш пришелец готов! Давайте немного поговорим о цвете.

Для генерации цветов я использовал цветовое пространство OKLCH. Оно похоже на HSL, но в отличие от него обладает предсказуемой светлостью (lightness). Это значит, что параметр lightness можно оставить неизменным и генерировать два остальных параметра; при этом у сгенерированных цветов светлость будет одинаковой. Это удобно по множеству причин, а в нашем случае позволяет сделать всех захватчиков одинаково яркими.

Код выглядит так:

const l = random(0.55, 0.8, rng, 2).toString();
const c = random(0.2, 0.5, rng, 2).toString();
const h = (random(120, 420, rng, 0) % 360).toString(); // не используем коричневатые тона 60 - 120

document.documentElement.style.setProperty('--theme-l', l);
document.documentElement.style.setProperty('--theme-c', c);
document.documentElement.style.setProperty('--theme-h', h);

Обратите внимание, что для тона (hue) я не использую интервал от 60 до 120 — эти желтовато-коричневые оттенки мне не нравятся.

Модификации цвета при помощи CSS

Здорово то, что мы можем хранить lc и h, как отдельные переменные CSS. Благодаря этому можно легко смешивать их, сопоставлять и изменять при помощи метода CSS calc.

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

.controls {
  --controls-main: oklch(0.6 var(--theme-c) var(--theme-h));
  --controls-main-light: oklch(0.75 var(--theme-c) var(--theme-h));
}

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

.invader--debug .invader-pixel--l {
  fill: oklch(calc(var(--theme-l) * 0.8) calc(var(--theme-c) * 0.6) var(--theme-h));
}

Оживляем инопланетян

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

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

Вот, как это выглядит в движении:

Розовым показаны альтернативные центральные линии щупалец и рогов. В генераторе можно увидеть их в режиме отладки, включив одновременно опции animate и debug.

Размер

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

Но если размер будет слишком большим, то векторная фигура становится очевиднее, и обычно это выглядит некрасиво. Иногда можно получить что-то напоминающее финального босса, но чаще всего получаются ужасные результаты:

A huge pink space invader generated using my tool
Another huge blue space invader generated using my tool

Именно поэтому я ограничился в интерфейсе генератора размером 31x31 пиксель. Но можно ещё немного его увеличить. Если поменять непосредственно URL, удастся дойти до максимального размера 51x51 пиксель. Я оставил эту скрытую фичу, чтобы показать, как увеличение размера разрушает иллюзию.

Заключение

Мы справились!

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

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

И не забудьте сгенерировать свой собственный флот.

О написании поста

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

Анимации в оригинале поста созданы с помощью Anime.js, их код сохранён в репозиторий генератора. TypeScript компилируется и копируется в репозиторий моего блога. Кроме того, есть небольшой скрипт, который координирует анимации и связывает их со скроллингом страницы.

Анимации доступны и в самом генераторе. Если включить параметр step и режим отладки, то вы сможете поэкспериментировать с ними и там.

Комментарии (0)