Этот проект начинался как песочница для социальных экспериментов: персонажи взаимодействуют и принимают решения, играя в дилемму заключённого, имеют память, наследуемые черты характера, ограниченные ресурсы для выживания и прочие проблемы, которые мешают счастливо существовать. Графика должна была успевать за всем этим разнообразием, а художника в проекте нет. Тут я решил поделиться как решал эту проблему: все объекты и анимация сделаны кодом прямо в браузере, на обычном 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 МБ, а кадр при этом не дорожает.
Что получилось
Подход прижился: в игре нет ни одного файла с графикой, а новые позы, предметы и эффекты добавляются как данные. Все хранится как текст в репозитории. Минусы тоже есть. Большие трафареты неудобно править руками, так что пришлось написать для них простой редактор (ну это было не сложно).