Тайлсеты и параллакс

Учимся собирать уровень из одной картинки-тайлсета и делать фон объёмным: дальние горы едут медленнее ближних — это и есть параллакс.

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

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

Зачем это нужно

Включи в голове любой красивый платформер из телефона — Geometry Dash, Alto's Odyssey, Ori. Замечал, что когда герой бежит, далёкие горы будто почти не двигаются, облака ползут чуть быстрее, а трава и кусты под ногами проносятся мимо со свистом? Вот это ощущение «я реально куда-то еду, и мир глубокий» — не магия художника, а простой трюк под названием параллакс. Один множитель скорости на каждый слой — и плоский задник превращается в объём.

А ещё есть скучная, но важная проблема: уровень нельзя рисовать одной гигантской картинкой. Если бы каждый уровень был отдельным PNG на 2400 пикселей, игра весила бы как фотоальбом и грузилась полминуты. Поэтому уровни собирают из плиток-кубиков: одна маленькая картинка-тайлсет, где нарисованы трава, земля, камень, и из этих кусочков, как из лего, выкладывают карту любой длины. Тысяча плиток земли — это одна и та же картинка, нарисованная тысячу раз.

К концу урока у тебя будет вот что: цыплёнок бежит по уровню, выложенному плитками из одного тайлсета; за ним в три слоя стоит фон — далёкие горы, облака поближе и кусты у самой земли; и когда цыплёнок движется, эти слои едут с разной скоростью, создавая глубину. А ещё фон будет бесконечным: сколько бы цыплёнок ни бежал, горы никогда не кончатся. Погнали разбираться.

И ещё один момент, ради которого всё это затевается. Тайлсет — это не просто экономия места. Когда уровень собран из плиток, ты можешь редактировать его как текст: поменял число в массиве — и в полу появилась дырка или выросла стена. Художник нарисовал один маленький лист плиток, а ты из него выкладываешь хоть десять уровней, хоть сто, не трогая ни одной картинки. Именно так устроены все редакторы уровней, от Mario Maker до твоего будущего собственного. Так что сегодняшний урок — это фундамент, на котором потом вырастет вся карта твоей аркады про цыплёнка.

Главная идея: одна картинка — много кусочков

Сначала про тайлсет, потому что без него уровень не построить. Представь альбом с наклейками: один большой лист, на котором ровными рядами напечатаны разные наклейки. Ты не покупаешь каждую наклейку отдельно — ты берёшь лист и отрезаешь нужную. Тайлсет — это ровно такой лист. Одна картинка, разбитая невидимой сеткой на квадратики одинакового размера (например, 32 на 32 пикселя), и в каждой клетке нарисован свой кусочек: трава, земля, камень, цветок.

Чтобы вырезать нужную плитку, у drawImage есть длинная форма — с девятью аргументами. Это та же команда, которой ты резал кадры цыплёнка из спрайт-листа, так что встреча не первая:

context.drawImage(
  tileset,        // откуда берём — наша картинка-лист
  sx, sy, sw, sh, // ИЗ картинки: левый верх кусочка и его размер
  dx, dy, dw, dh  // КУДА на холст: левый верх и размер при отрисовке
);

Результат: сам по себе этот вызов ничего не показывает — это шаблон. Но идея простая: первая четвёрка чисел (sx, sy, sw, sh) — это рамка-ножницы, которой мы вырезаем кусочек из тайлсета, а вторая четвёрка (dx, dy, dw, dh) — куда на холст этот кусочек приклеить. Меняешь первую четвёрку — вырезаешь другую плитку из того же листа.

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

Заметь, что вся скорость слоёв привязана к одной-единственной переменной — cameraX, той самой, что мы завели в уроке про камеру. Мы не двигаем слои сами по себе, как отдельные объекты со своими координатами. Мы берём положение камеры и каждому слою говорим: «едь на свою долю от этого сдвига». Дальний слой берёт 20% движения камеры, ближний — 80%. Поэтому параллакс почти ничего не стоит по коду: камера уже есть, остаётся домножить.

Запомни главную формулу параллакса: сдвиг слоя = −cameraX × множитель. У далёкого слоя множитель маленький (0.2 — еле ползёт), у ближнего большой (0.8 или 1 — несётся почти как сам уровень). Минус — потому что фон, как и весь мир, едет навстречу герою.

Пример 1. Вырезаем плитки и собираем пол уровня

Начнём с тайлсета. Заведём картинку-лист и опишем, какой плиткой выкладываем землю. Цыплёнка и камеру берём готовыми с прошлых уроков — ничего не переписываем.

const canvas = document.getElementById('game');
const context = canvas.getContext('2d');
const VIEW_W = 800;
const VIEW_H = 450;

const TILE = 32; // размер одной плитки в пикселях

// картинка-лист: в ней ряд плиток по 32px
const tileset = new Image();
tileset.src = '/sprites/tileset.png';

// какие кусочки где лежат в тайлсете (по номеру колонки)
const TILES = {
  grass: { col: 0 }, // трава  — первая клетка листа
  dirt:  { col: 1 }, // земля  — вторая клетка
  stone: { col: 2 }, // камень — третья клетка
};

// тот самый цыплёнок и камера из прошлых уроков
const chicken = { x: 100, y: 300, vx: 0, vy: 0, w: 48, h: 48 };
let cameraX = 0;

Результат: на экране пока пусто — мы только описали данные. Главное здесь: TILE = 32 — это сторона одной клетки, а в объекте TILES мы записали, в какой колонке тайлсета лежит каждая плитка. Чтобы вырезать траву (колонка 0), её sx будет 0 * 32 = 0; для земли (колонка 1) — 1 * 32 = 32. Номер колонки умножаем на размер плитки — получаем координату в картинке.

Теперь выложим пол. Уровень — это просто массив чисел: 0 — пусто, 1 — трава. Пробежимся по нему и для каждой единички вырежем из тайлсета плитку травы и приклеим на холст.

// карта одной строки пола: 0 — воздух, 1 — трава
const groundRow = [1,1,1,1,1,0,0,1,1,1,1,1,1,0,0,1,1,1,1,1];
const GROUND_Y = 380; // на какой высоте лежит пол

function drawGround() {
  for (let i = 0; i < groundRow.length; i++) {
    if (groundRow[i] === 0) continue; // дырка в полу — пропускаем
    const tile = TILES.grass;
    context.drawImage(
      tileset,
      tile.col * TILE, 0, TILE, TILE, // вырезаем плитку травы из листа
      i * TILE, GROUND_Y, TILE, TILE  // ставим её i-й по счёту вдоль пола
    );
  }
}

Результат: вдоль низа холста выкладывается дорожка из травяных плиток — 20 квадратиков подряд, но с двумя провалами там, где в массиве стоят нули. Все плитки — это одна и та же картинка из тайлсета, нарисованная много раз со сдвигом i * TILE по горизонтали. Поменяешь единичку на 0 — в полу появится дырка; поставишь TILES.stone вместо TILES.grass — дорожка станет каменной.

Останови взгляд на этом моменте, потому что это и есть главный фокус тайлов. Мы загрузили один файл tileset.png, а нарисовали из него восемнадцать плиток пола. Если бы уровень был на двести плиток, мы бы всё равно держали в памяти одну картинку — менялись бы только числа i в цикле. Браузеру не нужно грузить двести файлов: он один раз положил тайлсет в память и оттуда быстро копирует кусочки. Вот почему игры с огромными уровнями весят так мало.

И обрати внимание: цыплёнок и платформы у нас рисуются внутри сдвига камеры (через translate из прошлого урока), значит и пол из плиток мы рисуем там же — в уровневых координатах. Тогда плитки поедут вместе с цыплёнком как единый мир. Фон же мы будем рисовать до этого сдвига, своими руками двигая каждый слой на его долю. Разделение простое: уровень и герой — через камеру, фон — через параллакс-множитель.

Пример 2. Три слоя фона и параллакс

Теперь самое вкусное — глубина. Загрузим три картинки-фона: далёкие горы, облака и ближние кусты. Каждому слою дадим свой множитель скорости. Чем дальше слой — тем меньше множитель, тем медленнее он едет.

// три слоя фона: дальше -> меньше множитель -> медленнее едет
const layers = [
  { img: new Image(), speed: 0.2 }, // горы вдалеке — почти стоят
  { img: new Image(), speed: 0.5 }, // облака — едут средне
  { img: new Image(), speed: 0.8 }, // кусты у земли — почти как уровень
];
layers[0].img.src = '/sprites/bg-mountains.png';
layers[1].img.src = '/sprites/bg-clouds.png';
layers[2].img.src = '/sprites/bg-bushes.png';

function drawBackground() {
  for (const layer of layers) {
    // слой едет в ту же сторону, что и камера, но медленнее
    const offset = -cameraX * layer.speed;
    context.drawImage(layer.img, offset, 0);
  }
}

Результат: за цыплёнком появляется объёмный задник в три слоя. Когда цыплёнок бежит вправо и cameraX растёт, горы (множитель 0.2) уползают влево еле-еле, облака (0.5) — заметнее, а кусты (0.8) проносятся почти так же быстро, как сам уровень. Глаз сразу читает, что горы далеко, а кусты близко: появилась глубина, хотя все картинки плоские.

Почему именно минус и множитель

Минус в -cameraX — тот же, что и в камере: герой бежит вправо, значит мир (и фон вместе с ним) должен ехать влево, ему навстречу. А множитель layer.speed делает магию глубины. Если бы у всех слоёв был множитель 1, они ехали бы синхронно с уровнем и слиплись бы в одну плоскую картинку — никакой глубины. А если поставить 0, слой вообще не двигался бы (так делают для неба или статичного градиента). Разница в множителях и рождает иллюзию расстояния.

Порядок слоёв решает всё

Тут есть ловушка, в которую новички падают постоянно. Холст рисует как маляр красит стену: что нанёс позже — то поверх. Значит, рисовать слои надо от дальнего к ближнему. Сначала горы (они на самом дне), потом облака поверх гор, потом кусты поверх облаков, и только в самом конце — уровень и цыплёнка поверх всего. В нашем массиве layers элементы как раз идут в правильном порядке: горы первые, кусты последние, и цикл for...of рисует их по очереди. Перепутаешь порядок — и горы окажутся перед цыплёнком, закрыв его собой. Если вдруг герой «провалился» за фон — почти всегда виноват порядок отрисовки.

Пример 3. Бесконечный фон, который не кончается

Запусти Пример 2 и пробеги цыплёнком подольше вправо. Заметишь беду: картинка фона конечная, и за её правым краем начинается пустота — голый холст. Горы просто кончились. Чинится это зацикливанием: мы рисуем фон не один раз, а столько, сколько нужно, чтобы он плиткой замостил весь экран, и используем остаток от деления, чтобы сдвиг всё время оставался в пределах одной ширины картинки.

function drawBackgroundLooped() {
  for (const layer of layers) {
    const w = layer.img.width; // ширина одной картинки слоя
    // сдвиг, прокрученный в кольцо: всегда от -w до 0
    let offset = (-cameraX * layer.speed) % w;
    if (offset > 0) offset -= w; // подстраховка для левого края

    // рисуем картинку дважды встык, чтобы не было дырки справа
    context.drawImage(layer.img, offset, 0);
    context.drawImage(layer.img, offset + w, 0);
  }
}

Результат: теперь фон бесконечный — сколько бы цыплёнок ни бежал, горы и облака не кончаются никогда. Секрет в % w (остаток от деления на ширину картинки): он держит сдвиг в кольце от -w до 0, поэтому картинка как будто гоняется по кругу. А две отрисовки встык (offset и offset + w) гарантируют, что пока одна копия уезжает за левый край, вторая уже въезжает справа — стыка не видно.

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

Частые ошибки и подводные камни

  • Путают исходный и целевой размер в drawImage. В форме с девятью аргументами легко перепутать: первая четвёрка (sx, sy, sw, sh) — откуда вырезаем из тайлсета, вторая (dx, dy, dw, dh) — куда рисуем на холст. Перепутаешь местами — вырежешь не ту плитку или растянешь её в кашу. Запомни порядок: сначала «из картинки», потом «на холст».

  • Считают координату плитки в пикселях, а не в клетках. Если в тайлсете плитки по 32px, то третья плитка лежит на sx = 2 * 32 = 64, а не на sx = 2 или sx = 3. Номер колонки всегда умножай на TILE. Забудешь умножить — вырежешь тоненькую полоску на стыке двух плиток.

  • Дают всем слоям множитель 1. Тогда фон едет ровно как уровень, все слои слипаются, и параллакса нет — картинка плоская. Глубина рождается именно из разных множителей: дальний 0.2, ближний 0.8.

  • Забывают про минус в сдвиге фона. Если написать cameraX * layer.speed без минуса, фон поедет в ту же сторону, что и герой, — мир будто разваливается. Герой вправо — фон влево, как и сам уровень: сдвиг отрицательный.

  • Рисуют бесконечный фон одной картинкой. Без зацикливания (% w и двух отрисовок встык) за правым краем картинки появится пустота, как только цыплёнок убежит достаточно далеко. Бесконечный фон — это всегда минимум две копии, едущие по кольцу.

  • Путают порядок слоёв. Если нарисовать горы после цыплёнка, они закроют его собой — герой будто провалился за фон. Рисуй строго от дальнего к ближнему: небо, горы, облака, кусты, и только потом уровень с героем. Что нарисовано позже — то сверху.

Мини-проект: собери живой задник сам

База готова. Теперь три апгрейда — делай по шагам и после каждого смотри, что изменилось на экране.

  1. Четвёртый слой — небо. Добавь в начало массива layers слой неба с множителем 0. Он вообще не должен двигаться — небо всегда висит на месте, как луна за окном автобуса. Проверь, что оно рисуется первым (под всеми остальными слоями).

  2. Разные плитки в полу. Сейчас весь пол — трава. Сделай так, чтобы верхняя строка была травой (TILES.grass), а под ней шла земля (TILES.dirt) на пару рядов вниз. Подсказка: добавь второй цикл по строкам и для нижних рядов вырезай плитку земли, меняя только col.

  3. Парящая платформа из плиток. Выложи где-нибудь над полом короткую платформу из трёх каменных плиток (TILES.stone) встык. Убедись, что цыплёнок может на неё запрыгнуть (коллизии у тебя уже есть с уроков платформера), а сама платформа едет вместе с уровнем, а не с фоном.

Если все три пункта заработали — у тебя получился задник уровня настоящей игры: многослойный, глубокий и бесконечный. Покатай цыплёнка туда-сюда и полюбуйся, как горы лениво ползут позади.

Итоги

Сегодня твой мир стал объёмным. Вот что унеси с собой:

  • Тайлсет — одна картинка на все плитки. Нужный кусочек вырезаем формой drawImage с девятью аргументами: первая четвёрка — откуда, вторая — куда.

  • Координата плитки = номер колонки × размер плитки. Уровень собираем из массива чисел, рисуя одну и ту же плитку много раз со сдвигом.

  • Параллакс — это разные множители скорости. Сдвиг слоя = -cameraX × множитель: дальние слои медленные (0.2), ближние быстрые (0.8).

  • Бесконечный фон — это % w и две копии встык, едущие по кольцу, чтобы за правым краем не было пустоты.

В следующем уроке мы добавим миру звук: шаги цыплёнка, прыжок, звон собранной монетки и фоновую музыку. Картинка у нас уже живая и глубокая — пора, чтобы она ещё и зазвучала. До встречи, твой цыплёнок уже подбирает плейлист!

Проверьте себя
1. Что такое тайлсет?
AОтдельный файл-картинка под каждую плитку уровня
BОдна большая картинка, в которой собрано много плиток, вырезаемых по координатам
CСписок координат платформ в виде массива
DСпециальный HTML-элемент для рисования сетки
2. Если в тайлсете плитки по 32px, какой sx у плитки в третьей колонке (col = 2)?
Asx = 2
Bsx = 3
Csx = 64
Dsx = 96
3. По какому принципу слои фона создают ощущение глубины (параллакс)?
AДальние слои едут с меньшим множителем скорости, ближние — с большим
BВсе слои едут с одинаковой скоростью, но разного цвета
CДальние слои рисуются крупнее ближних
DСлои меняются местами каждый кадр
4. Почему сдвиг слоя фона считают как -cameraX × множитель, а не cameraX × множитель?
AМинус ускоряет отрисовку фона
BГерой бежит вправо, значит фон должен ехать влево ему навстречу
CБез минуса картинка не загрузится
DЗнак не важен, это привычка
5. Как сделать фон бесконечным, чтобы за правым краем картинки не было пустоты?
AРастянуть одну картинку на всю длину уровня
BИспользовать остаток от деления (% w) на ширину картинки и рисовать две копии встык
CПерезагружать картинку каждый кадр
DПоставить множитель скорости равным нулю
6. В форме drawImage с девятью аргументами за что отвечает первая четвёрка чисел (sx, sy, sw, sh)?
AКуда на холст приклеить кусочек
BОткуда из тайлсета вырезать кусочек: его левый верх и размер
CПрозрачность и поворот плитки
DМножитель скорости слоя