Тайловые уровни
Сегодня ты научишься строить уровни платформера так, как это делают в настоящих играх: рисовать карту цифрами в массиве, превращать эти цифры в твёрдые платформы и показывать только то, что видит камера.
Тайл (плитка) — это квадратный кусочек уровня. Из тайлов, как из кубиков LEGO или клеток в Minecraft, собирается вся карта. А сама карта — это просто двумерный массив чисел, где каждое число говорит «здесь земля» или «здесь пусто».
Зачем вообще нужны тайлы
Представь, что ты делаешь уровень для своего платформера про цыплёнка. На прошлом уроке мы научили камеру следовать за героем по большому миру. Но из чего этот мир сделан? Пока что у нас была пара платформ, прибитых гвоздями прямо в коде: {x: 100, y: 300, w: 200, h: 20}, {x: 400, y: 250, w: 150, h: 20} и так далее. Захотел добавить ещё одну ступеньку — лезь в код, считай пиксели, вписывай новый объект. А теперь представь уровень на пять экранов с сотней платформ. Ты поседеешь раньше, чем дорисуешь.
В настоящих играх так никто не делает. Уровни Super Mario, Celeste, Hollow Knight, Terraria — все они собраны из одинаковых квадратиков. Дизайнер не пишет код на каждый камень, он как будто рисует в тетради в клеточку: вот тут стена, вот тут пол, вот тут пусто. А игра сама читает этот «рисунок» и расставляет картинки.
Вот к чему мы придём за этот урок: уровень, который ты редактируешь, просто меняя цифры в массиве. Поставил 1 — появилась земля. Стёр на 0 — образовалась дыра, в которую цыплёнок может провалиться. Никакого пересчёта координат, никакого дёргания мышкой. Изменил карту — и уровень изменился. Поехали.
Карта — это тетрадь в клеточку
Самая важная метафора урока: уровень — это тетрадный лист в клеточку. Каждая клетка квадратная и одного размера. В каждой клетке либо что-то нарисовано (земля, кирпич, лестница), либо она пустая (там воздух, по которому цыплёнок летит).
Чтобы записать такую тетрадь в код, мы используем двумерный массив — массив, элементы которого сами являются массивами. Внешний массив — это строки (ряды по вертикали), внутренний — клетки в строке (слева направо). Каждое число — это код тайла: что нарисовать в этой клетке.
// 0 — пусто (воздух), 1 — земля
const TILE = 48; // размер одной клетки в пикселях
const level = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
];
Результат: если мысленно посмотреть на этот массив сверху, видно сам уровень: внизу сплошной пол из единиц, чуть выше слева висит платформа из двух клеток, справа — ещё одна. Всё остальное — нули, то есть воздух. По сути ты уже нарисовал уровень, просто цифрами.
Обрати внимание на две вещи. Во-первых, TILE = 48 — это сторона одной клетки в пикселях. Все тайлы квадратные и одинаковые, поэтому достаточно одного числа. Во-вторых, чем больше номер строки в массиве, тем ниже она на экране — ведь на canvas ось Y растёт вниз. Поэтому пол (сплошные единицы) лежит в последней строке массива.
Как клетка превращается в пиксели
Чтобы нарисовать тайл, нам нужно перевести его адрес в сетке (номер столбца col и номер строки row) в координаты на canvas. Формула простая, как таблица умножения:
const x = col * TILE;
const y = row * TILE;
Результат: клетка в столбце 3 и строке 4 при TILE = 48 окажется в точке x = 144, y = 192. Левый верхний угол клетки. Дальше от этой точки рисуем квадрат размером TILE × TILE — и тайл на месте.
Рисуем уровень: проходим по сетке
Теперь главный фокус. Чтобы нарисовать всю карту, мы пробегаем по двумерному массиву двумя вложенными циклами: внешний идёт по строкам, внутренний — по клеткам в строке. Для каждой непустой клетки рисуем тайл в нужном месте.
function drawLevel(ctx) {
for (let row = 0; row < level.length; row++) {
for (let col = 0; col < level[row].length; col++) {
const tile = level[row][col];
if (tile === 0) continue; // пусто — ничего не рисуем
const x = col * TILE;
const y = row * TILE;
ctx.fillStyle = '#6b4f2a'; // земля — коричневая
ctx.fillRect(x, y, TILE, TILE);
ctx.fillStyle = '#7cc36a'; // травка сверху
ctx.fillRect(x, y, TILE, 8);
}
}
}
Результат: на экране появляется уровень, собранный из коричневых квадратов с зелёной полоской травы сверху — ровно по той карте, что мы записали цифрами. Внизу сплошной пол, выше две висячие платформы. Цыплёнку есть на чём стоять.
Разберём по шагам, что здесь происходит:
level.length— это количество строк (рядов). По ним идёт внешний циклrow.level[row].length— количество клеток в конкретной строке. По ним идёт внутренний циклcol.level[row][col]— число в клетке. Сначала строка, потом столбец — порядок важен, перепутаешь и уровень ляжет боком.- Если в клетке
0, делаемcontinue— пропускаем рисование, там воздух. - Иначе считаем
xиyумножением наTILEи рисуем квадрат.
Самое приятное: чтобы добавить новую платформу, тебе больше не нужно трогать drawLevel. Просто поставь 1 в нужной клетке массива level — и она нарисуется сама. Код рисования один раз написан и больше не меняется, меняется только «тетрадь».
Разные тайлы — разные числа
Пока у нас только земля. Но число в клетке может означать что угодно: 1 — земля, 2 — кирпич, 3 — лестница, 4 — шипы. Достаточно добавить ветки в рисование:
function drawTile(ctx, tile, x, y) {
if (tile === 1) { // земля
ctx.fillStyle = '#6b4f2a';
ctx.fillRect(x, y, TILE, TILE);
ctx.fillStyle = '#7cc36a';
ctx.fillRect(x, y, TILE, 8);
} else if (tile === 2) { // кирпич
ctx.fillStyle = '#b5562f';
ctx.fillRect(x, y, TILE, TILE);
} else if (tile === 4) { // шипы
ctx.fillStyle = '#cccccc';
ctx.beginPath();
ctx.moveTo(x, y + TILE);
ctx.lineTo(x + TILE / 2, y);
ctx.lineTo(x + TILE, y + TILE);
ctx.fill();
}
}
Результат: теперь уровень может быть разноцветным: коричневая земля с травой, рыжие кирпичи, серые острые шипы-треугольники. Один и тот же массив, просто разные цифры в клетках дают разные картинки. Позже вместо заливки цветом ты будешь вырезать кусочки из тайлсета — большой картинки со всеми плитками, — но принцип «число решает, что нарисовать» останется тем же.
Превращаем числа в твёрдые платформы
Нарисовать уровень — половина дела. Сейчас цыплёнок честно пролетит сквозь все эти красивые квадраты: для физики они пустое место, ведь физика не знает про fillRect. Нам нужно превратить непустые клетки в твёрдые платформы, на которые можно встать.
Хорошая новость: нам не нужно проверять цыплёнка против каждой клетки уровня — их могут быть тысячи. Мы делаем наоборот: по координатам цыплёнка вычисляем, в какие клетки сетки он сейчас попадает, и проверяем только их. Это тот же фокус, что и с разбиением мира на ячейки, только тут сетка уже готова — это и есть наша карта.
// есть ли твёрдый тайл в клетке (col, row)
function isSolid(col, row) {
if (row < 0 || row >= level.length) return false;
if (col < 0 || col >= level[row].length) return false;
return level[row][col] !== 0; // любое не-ноль = твёрдо
}
Результат: функция отвечает на вопрос «можно ли пройти сквозь эту клетку». Земля, кирпич, любая не-нулевая клетка вернёт true — стоп, твердо. Воздух и любая клетка за пределами карты вернут false. Проверки на выход за границы массива тут не для красоты: без них при попытке заглянуть за край уровня код упадёт с ошибкой.
Теперь привяжем это к нашему цыплёнку. Напомню, что его состояние мы тащим через весь курс одинаковыми именами: chicken.x, chicken.y, скорости chicken.vx и chicken.vy. Возьмём вертикаль — то, как цыплёнок приземляется на тайл после прыжка под действием гравитации:
function landOnTiles(chicken) {
// нижний край цыплёнка после движения
const feetY = chicken.y + chicken.h;
const row = Math.floor(feetY / TILE);
// две клетки под ногами: слева и справа
const colLeft = Math.floor(chicken.x / TILE);
const colRight = Math.floor((chicken.x + chicken.w - 1) / TILE);
if (chicken.vy >= 0 && (isSolid(colLeft, row) || isSolid(colRight, row))) {
chicken.y = row * TILE - chicken.h; // ставим ровно на тайл
chicken.vy = 0; // погасили падение
chicken.onGround = true;
} else {
chicken.onGround = false;
}
}
Результат: цыплёнок падает под действием гравитации, а когда его ноги касаются твёрдого тайла, он мягко встаёт ровно на его поверхность — без проваливания и без дрожания. Если под ногами воздух, он продолжает падать. Флаг onGround мы потом используем, чтобы разрешить прыжок только с земли.
Разберём ключевые места:
Math.floor(feetY / TILE)— деление координаты на размер клетки с округлением вниз даёт номер строки сетки. Это обратная операция кrow * TILE, которой мы рисовали.- Мы проверяем две клетки — под левым и под правым краем цыплёнка. Иначе, стоя на самом краю платформы, он одной половиной висел бы в воздухе и проваливался.
colRightберём отchicken.x + chicken.w - 1: минус один пиксель, чтобы при идеальном выравнивании не зацепить лишнюю соседнюю клетку справа.chicken.y = row * TILE - chicken.h— приклеиваем цыплёнка точно к верху тайла, а не оставляем его утопленным внутри земли.
Рисуем только то, что видит камера
Допустим, твой уровень — это карта на двести клеток в ширину и тридцать в высоту. Это шесть тысяч клеток. Рисовать их все каждый кадр — чистое расточительство: 95% уровня находится далеко за краем экрана, игрок их не видит. А мы честно гоняем по ним цикл шестьдесят раз в секунду. Это лишняя работа, от которой игра начинает тормозить на слабых телефонах.
Решение красивое и простое. У нас есть камера с её смещением camera.x, camera.y и размер экрана. Из них легко посчитать, какие столбцы и строки сейчас в кадре, и пройти циклом только по ним. Это называется culling — отсечение невидимого.
function drawVisibleLevel(ctx, camera, viewW, viewH) {
// первый и последний видимые столбцы
const startCol = Math.floor(camera.x / TILE);
const endCol = Math.floor((camera.x + viewW) / TILE);
// первая и последняя видимые строки
const startRow = Math.floor(camera.y / TILE);
const endRow = Math.floor((camera.y + viewH) / TILE);
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
if (row < 0 || row >= level.length) continue;
if (col < 0 || col >= level[row].length) continue;
const tile = level[row][col];
if (tile === 0) continue;
// вычитаем смещение камеры, чтобы тайл попал на экран
const x = col * TILE - camera.x;
const y = row * TILE - camera.y;
drawTile(ctx, tile, x, y);
}
}
}
Результат: сколько бы ни был огромен уровень, за кадр рисуется лишь горстка тайлов — ровно те, что влезают в экран плюс кромка. Камера едет за цыплёнком, новые тайлы плавно «въезжают» с края, старые исчезают за противоположным. Игра летает с теми же 60 FPS и на уровне в десять экранов, и в сто.
Главная строчка здесь — вычитание - camera.x и - camera.y. Координата клетки в мире одна (col * TILE), но на экране тайл рисуется относительно камеры. Камера уехала вправо на 300 пикселей — значит, всё, что рисуем, сдвигаем влево на 300. Это ровно тот же приём, что мы применяли на уроке про камеру, только теперь он работает не для одного героя, а для всей сетки тайлов.
Частые ошибки и подводные камни
Тайлы — штука простая, но именно на них новички спотыкаются чаще всего. Вот грабли, на которые наступают почти все.
1. Перепутанные строки и столбцы
Самая классическая ошибка: написать level[col][row] вместо level[row][col]. Уровень при этом не падает с ошибкой — он просто рисуется повёрнутым на бок или вообще кашей. Запомни намертво: сначала строка (Y, вертикаль), потом столбец (X, горизонталь). Внешний цикл — строки, внутренний — столбцы.
2. Забыл вычесть камеру
Рисуешь тайлы по col * TILE без - camera.x — и уровень намертво приклеен к экрану, пока цыплёнок уезжает от него вдаль и в итоге улетает за край. Либо наоборот: вычел камеру при рисовании, но забыл учесть её в координатах клика или коллизии. Правило: мировые координаты (для физики) и экранные (для рисования) — это разные системы, и переход между ними всегда через камеру.
3. Выход за границы массива
Цыплёнок добегает до края карты, код пытается прочитать level[row][col] за пределами массива и получает undefined, а то и падает с Cannot read properties of undefined. Всегда проверяй границы: if (row < 0 || row >= level.length) ... — и для строки, и для столбца. Мы делали это и в isSolid, и в drawVisibleLevel не просто так.
4. Разный размер у тайла и у строк карты
Рисуешь тайлы по TILE = 48, а коллизию считаешь, забыв поменять старое 32 где-то в формуле. Картинка и физика «разъезжаются»: цыплёнок встаёт там, где земли визуально нет. Заведи TILE одной константой и используй везде только её — никаких магических чисел в формулах деления и умножения.
5. Кривая карта: строки разной длины
Если в одной строке массива девять чисел, а в соседней — десять, уровень получится рваным, а level[row].length вернёт разные значения. Следи, чтобы все внутренние массивы были одной длины. Удобно держать карту как массив одинаковых строк-строчек и потом превращать их в числа — но об этом в следующих уроках.
Мини-практика: построй свой уровень
Пора закрепить руками. Возьми код этого урока и собери маленький проходимый уровень для цыплёнка.
- Заведи массив
levelразмером хотя бы 8 строк на 16 столбцов. Залей нижнюю строку единицами — это пол. - Добавь две-три висячие платформы на разной высоте, поставив
1в нужных клетках. Между ними оставь зазоры, чтобы между платформами нужно было перепрыгивать. - Сделай в полу яму — пару нулей подряд в нижней строке. Проверь, что цыплёнок в неё проваливается.
- Добавь второй тип тайла —
2(кирпич другого цвета) — и научиdrawTileего рисовать. - Со звёздочкой: поставь несколько шипов (
4) и сделай так, чтобы при касании шипа цыплёнок терял жизнь или возвращался в начало. Подсказка: ту жеisSolid-логику можно расширить доtileAt(col, row), которая возвращает само число тайла, а не просто «твёрдо/нет».
Если хочешь проверить себя: измени всего одну цифру в массиве и убедись, что уровень изменился ровно в одном месте, а код рисования ты при этом не трогал. Вот это и есть та свобода, ради которой мы городили тайлы.
Итоги и что дальше
Сегодня ты перестал прибивать платформы гвоздями в код и начал рисовать уровни цифрами. Коротко, что у тебя теперь в руках:
- Уровень — это двумерный массив, где число в клетке решает, какой тайл там нарисовать. Меняешь цифру — меняется уровень.
- Рисуем карту двумя вложенными циклами по строкам и столбцам, переводя адрес клетки в пиксели через
col * TILEиrow * TILE. - Любая непустая клетка становится твёрдой платформой: по координатам цыплёнка считаем его клетку и проверяем только её через
isSolid. - Рисуем только видимые камерой тайлы, вычисляя диапазон столбцов и строк из
camera.x/camera.yи размера экрана, — и уровень любого размера летает на 60 FPS.
Заметь, как всё сошлось: камера из прошлого урока, гравитация и состояние цыплёнка из ранних — теперь они работают на огромной карте из тайлов. Это уже почти настоящий движок платформера.
Но коричневые квадраты — это всё-таки заглушка. В следующем уроке мы заменим заливку цветом на настоящую графику: возьмём тайлсет — одну большую картинку со всеми плитками — и научимся вырезать из неё нужный кусочек по координатам прямо в цикле рисования. Тот же массив чисел, но уровень заиграет настоящими текстурами. До встречи на следующем кадре!