Спрайтшит на 0 байт: процедурный пиксель-арт

Этот проект начинался как песочница для социальных экспериментов: персонажи взаимодействуют и принимают решения, играя в дилемму заключённого, имеют память, наследуемые черты характера, ограниченные ресурсы для выживания и прочие проблемы, которые мешают счастливо существовать. Графика должна была успевать за всем этим разнообразием, а художника в проекте нет. Тут я решил поделиться как решал эту проблему: все объекты и анимация сделаны кодом прямо в браузере, на обычном 2D canvas. Заходите под кат - расскажу, как это устроено, и покажу код.

Спрайт — это строки текста
Вот панцирь черепашки, в коде он записан ровно так:
const SHELL = [
'..hhhhh..',
'.SShSSSS.',
'SSSSSSSSS',
'SSSSSSSSS',
'SSSSSSSSS',
'sssssssss',
'.BBBBBBBB',
];Каждый символ — один пиксель. Главное решение здесь в том, что буква обозначает не цвет, а роль: S — панцирь, s — его тёмная кромка, h — блик, B — живот. Цвет каждой роли задаёт отдельная палитра, поэтому один трафарет подходит всем черепашкам сразу. Рисуется это десятью строками:
function drawSprite(ctx, rows, palette, px, x0 = 0, y0 = 0) {
rows.forEach((row, y) => {
[...row].forEach((ch, x) => {
const color = palette[ch];
if (!color) return; // точка и буквы без цвета остаются прозрачными
ctx.fillStyle = color;
ctx.fillRect(x0 + x * px, y0 + y * px, px, px);
});
});
}Черепашка целиком помещается в сетку 12 на 10. Собирается она из трёх деталей: панциря, головы и пары строк ног, каждая кладётся на сетку в своё место. Узор на панцире тоже не рисунок, а правило вида (x + y) % 3 === 0: оно решает, какие пиксели панциря перекрасить.
У текстовых спрайтов есть ещё один плюс: они лежат в git, у них читаемый дифф, и на них можно написать обычные юнит-тесты.

Цвет из генов
У черепашки есть ген hue, число от 0 до 1. Из него получается оттенок, а остальная палитра строится в HSL с постоянными насыщенностью и яркостью:
function palette(hueGene, elder) {
const h = (Math.round(hueGene * 36) % 36) * 10; // 36 оттенков с шагом 10°
const sat = elder ? 28 : 50; // к старости панцирь выцветает
return {
S: `hsl(${h} ${sat}% 46%)`, // панцирь
s: `hsl(${h} ${sat}% 30%)`, // кромка темнее
h: `hsl(${h} ${sat + 5}% 60%)`, // блик светлее
p: `hsl(${(h + 20) % 360} ${sat + 5}% 31%)`, // узор: соседний оттенок
B: 'hsl(72 42% 64%)', // кожа
e: '#1b1b1f', // глаз
};
}Состояние меняет палитру, трафарет остаётся прежним. У больной черепашки зеленеет лицо, у костра всё теплеет, обгоревшая на секунду темнеет и мигает.
Почему оттенков ровно 36? Готовый спрайт рисуется один раз в невидимый холст и кладётся в кэш под ключом из всего, от чего он зависит: оттенок, узор, поза, кадр, направление, состояние, возраст. Если брать произвольное значение гена, то у каждой черепашки был бы свой набор картинок и кэш рос без конца. Шаг в 10° на глаз почти не заметен, а число картинок становится конечным. 36 оттенков хватит всем. Во что всё это обходится, посчитаю в конце.
Дети получают ген так: берётся случайная точка между генами родителей и к ней добавляется немного шума. Поэтому семьи на лугу видны по цвету. Есть одна тонкость. Ген лежит на отрезке, а оттенок — на круге. Гены 0,02 и 0,98 дают двух почти одинаково красных родителей, а точка между ними может оказаться и в 0,5. Так у красной пары рождается бирюзовый ребёнок. Отцовство, если что, подтверждено кодом.

Анимация — это сдвиги
Поза — это несколько чисел: на сколько строк сдвинуть голову и панцирь в каждом кадре и какие строки взять для ног.
// на сколько строк сдвигается голова в каждом кадре позы
const HEAD_DY = { walk: [0, 0, 0, 0], hop: [1, -1], stomp: [-1, 1] };
// ходьба: четыре варианта ног по кругу
const LEGS = [
['.BB....BB...', '.bb....bb...'],
['BB......BB..', 'bb......bb..'],
['..BB..BB....', '..bb..bb....'],
['BB......BB..', '.b.......b..'],
];Черепашка идёт — четыре варианта ног по кругу, голова на месте. Радуется — сначала приседает (панцирь на строку ниже, лапы в стороны), потом подпрыгивает, и голова взлетает вверх. Тушит огонь — задирает переднюю лапу и бьёт ею по земле, а голова качается в такт. Всего поз 17, и все вместе занимают несколько десятков строк данных. Перекрашивать их не нужно: цвет по-прежнему приходит из палитры.

Огонь — формула вместо рисунка
Для пламени трафарета нет вообще: функция строит его строка за строкой, по формуле. Вот что из неё получается при разной силе огня:

Для каждой строки функция считает ширину «капли» и её центр, который покачивается по синусу, причём кончик качается сильнее основания. Чем ближе к центру и к низу, тем светлее: белое ядро, жёлтый, оранжевый, красный край.
function flameRows(h, frame) {
const rows = [];
for (let y = 0; y < h; y++) {
const k = (h - 1 - y) / (h - 1); // 0 у основания, 1 на кончике
const half = 3.7 * (1 - k ** 1.4) + 0.45; // полуширина капли
const center = 4 + Math.sin(k * 3.4 + frame * 2.1) * k * 1.3; // кончик качается
let row = '';
for (let x = 0; x < 8; x++) {
const d = Math.abs(x + 0.5 - center) / half;
const n = Math.sin(x * 12.9898 + y * 78.233 + frame * 37.719) * 43758.5453;
const lick = n - Math.floor(n) < 0.35 ? 0.12 : 0; // язычки по краю
row += d > 1 + lick ? '.' : d < 0.38 && k < 0.55 ? 'w' : d < 0.62 && k < 0.8 ? 'y' : d < 0.88 ? 'o' : 'r';
}
rows.push(row);
}
return rows;
}Строчка с 43758.5453 знакома всем, кто писал шейдеры: это классический хеш из GLSL. Откуда взялась эта константа, помнят немногие, а копируют её все, и я не исключение. Здесь она решает, появится ли в этом кадре пиксель на краю пламени, и из-за неё огонь «дышит». Высота зависит от силы огня, от 3 до 12 строк. Кадров три, по 110 мс, и у каждой горящей клетки своё смещение по времени, чтобы соседние языки не дёргались в такт.
Молния
Туча — маска 40 на 15 пикселей, сложенная из семи кругов-«клубов». Они растут от лёгкой дымки до полной грозовой тучи. Оттенок пикселя зависит от соседей: если сверху пусто, это освещённый край, если снизу — самая тёмная кромка. На земле под тучей сгущается тень и расходится кольцо, которое пульсирует всё быстрее. Потом вспышка на 150 мс, молния на 400 мс и подпалина на шесть секунд:

Сама молния — ломаная от тучи до цели. Вершины смещаются поперёк линии, и у земли смещения затухают, чтобы удар пришёлся точно в клетку:
// hash(a, b, c): любой целочисленный хеш, возвращает число от 0 до 1
function boltPath(seed, x0, y0, x1, y1, segments = 12) {
const len = Math.hypot(x1 - x0, y1 - y0);
const nx = -(y1 - y0) / len, ny = (x1 - x0) / len; // перпендикуляр к линии удара
const amp = Math.max(10, len * 0.07);
const pts = [[x0, y0]];
for (let i = 1; i < segments; i++) {
const t = i / segments;
const off = (hash(seed, i, 31) - 0.5) * 2 * amp * (1 - t * 0.7); // у земли зигзаг тише
pts.push([x0 + (x1 - x0) * t + nx * off, y0 + (y1 - y0) * t + ny * off]);
}
pts.push([x1, y1]);
return pts;
}Из середины выходит вторая, короткая ветка. Обе рисуются тремя проходами: широкий бледно-голубой ореол, линия потоньше и белая сердцевина. Черепашки под тучей видят её заранее: над ними появляется знак тревоги, и осторожные уходят в сторону. Остальные, видимо, верят в теорию вероятностей.
Сколько стоит кадр
Кажется, что рисовать каждый пиксель через fillRect — верный способ посадить батарейку. Чтож, проверим. При 60 fps на кадр есть 16,7 мс. Беру живую сцену из начала статьи в том размере, в каком её видит читатель: 960×352, во всю ширину колонки, 30 на 11 клеток по 32 пикселя. На ней полтора десятка черепашек и три десятка растений. Мерил в браузере без видеокарты, чтобы всю работу честно делал процессор, а потом одним переключателем выключил кэш спрайтов, чтобы посмотреть, что он даёт.
Итог: 1 мс на кадр с кэшем и 6 мс без него.
С кэшем кадр — это полсотни копирований готовых картинок через drawImage, а поведение черепашек считается за сотые доли миллисекунды. Без кэша каждый спрайт в каждом кадре заново раскрашивается квадратик за квадратиком, и кадр дорожает вшестеро: из бюджета в 16,7 мс уходит уже треть, а на слабом устройстве и весь.
Единственное, что я не кэширую, — грозовую тучу. Она рисуется попиксельно в каждом кадре, и в грозу кадр дорожает на треть, а то и на две трети. Так что самое дорогое тут — погода.
Взамен кэш почти ничего не просит: за 10 минут в нём набирается около 1300 картинок, порядка 10 МБ, а кадр при этом не дорожает.
Что получилось
Подход прижился: в игре нет ни одного файла с графикой, а новые позы, предметы и эффекты добавляются как данные. Все хранится как текст в репозитории. Минусы тоже есть. Большие трафареты неудобно править руками, так что пришлось написать для них простой редактор (ну это было не сложно).
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.