Тайлсеты и параллакс
Учимся собирать уровень из одной картинки-тайлсета и делать фон объёмным: дальние горы едут медленнее ближних — это и есть параллакс.
Тайлсет (спрайт-лист) — одна большая картинка, в которой собрано много плиток и кадров; нужный кусочек мы вырезаем по координатам. Параллакс — эффект глубины, когда дальние слои фона двигаются медленнее ближних.
В прошлый раз ты научил мир ездить за цыплёнком — мы сделали камеру в уроке про тайловые уровни платформера, а спрайт-лист цыплёнка резали на кадры в уроке про спрайт-лист и анимацию. Сегодня соединим оба навыка: уровень нарисуем плитками из тайлсета, а позади поставим несколько слоёв фона, которые поедут с разной скоростью. Получится не плоская картинка, а настоящий объёмный мир.
Зачем это нужно
Включи в голове любой красивый платформер из телефона — 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и двух отрисовок встык) за правым краем картинки появится пустота, как только цыплёнок убежит достаточно далеко. Бесконечный фон — это всегда минимум две копии, едущие по кольцу.Путают порядок слоёв. Если нарисовать горы после цыплёнка, они закроют его собой — герой будто провалился за фон. Рисуй строго от дальнего к ближнему: небо, горы, облака, кусты, и только потом уровень с героем. Что нарисовано позже — то сверху.
Мини-проект: собери живой задник сам
База готова. Теперь три апгрейда — делай по шагам и после каждого смотри, что изменилось на экране.
Четвёртый слой — небо. Добавь в начало массива
layersслой неба с множителем0. Он вообще не должен двигаться — небо всегда висит на месте, как луна за окном автобуса. Проверь, что оно рисуется первым (под всеми остальными слоями).Разные плитки в полу. Сейчас весь пол — трава. Сделай так, чтобы верхняя строка была травой (
TILES.grass), а под ней шла земля (TILES.dirt) на пару рядов вниз. Подсказка: добавь второй цикл по строкам и для нижних рядов вырезай плитку земли, меняя толькоcol.Парящая платформа из плиток. Выложи где-нибудь над полом короткую платформу из трёх каменных плиток (
TILES.stone) встык. Убедись, что цыплёнок может на неё запрыгнуть (коллизии у тебя уже есть с уроков платформера), а сама платформа едет вместе с уровнем, а не с фоном.
Если все три пункта заработали — у тебя получился задник уровня настоящей игры: многослойный, глубокий и бесконечный. Покатай цыплёнка туда-сюда и полюбуйся, как горы лениво ползут позади.
Итоги
Сегодня твой мир стал объёмным. Вот что унеси с собой:
Тайлсет — одна картинка на все плитки. Нужный кусочек вырезаем формой
drawImageс девятью аргументами: первая четвёрка — откуда, вторая — куда.Координата плитки = номер колонки × размер плитки. Уровень собираем из массива чисел, рисуя одну и ту же плитку много раз со сдвигом.
Параллакс — это разные множители скорости. Сдвиг слоя =
-cameraX × множитель: дальние слои медленные (0.2), ближние быстрые (0.8).Бесконечный фон — это
% wи две копии встык, едущие по кольцу, чтобы за правым краем не было пустоты.
В следующем уроке мы добавим миру звук: шаги цыплёнка, прыжок, звон собранной монетки и фоновую музыку. Картинка у нас уже живая и глубокая — пора, чтобы она ещё и зазвучала. До встречи, твой цыплёнок уже подбирает плейлист!