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

Палитра из генов: трафарет один, меняется только табличка цветов. Внизу все 36 оттенков.
Палитра из генов: трафарет один, меняется только табличка цветов. Внизу все 36 оттенков.

Анимация — это сдвиги

Поза — это несколько чисел: на сколько строк сдвинуть голову и панцирь в каждом кадре и какие строки взять для ног.

// на сколько строк сдвигается голова в каждом кадре позы
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 МБ, а кадр при этом не дорожает.

Что получилось

Подход прижился: в игре нет ни одного файла с графикой, а новые позы, предметы и эффекты добавляются как данные. Все хранится как текст в репозитории. Минусы тоже есть. Большие трафареты неудобно править руками, так что пришлось написать для них простой редактор (ну это было не сложно).

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