Одну стену удобно описать прямоугольником. Для лабиринта такой способ быстро становится неудобным: десятки координат трудно проверить и переставить. Запишем уровень картой из символов, а позиции стен, кота и кристаллов получим из этой карты.

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

Лабиринт из клеток 48×48, по которому движется наш кот. Собери все жёлтые кристаллы. Стены не пропускают героя, а кнопка «Сначала» возвращает карту и предметы в исходное состояние. Управление пока клавиатурой.

Открыть учебную игру отдельно

Какие файлы понадобятся

Продолжаем сцену со столкновениями. В ней уже есть кот 32×40, скорость в пикселях в секунду, один игровой цикл, массив предметов и проверка стен по осям. Также понадобятся массивы и цикл for из урока с полем карточек.

Сохрани свою прошлую версию. В комплекте start/ — те же три файла, что в её финале. Открой start/index.html: пока это прежняя сцена с одной стеной. Распаковывай папку целиком, чтобы style.css и game.js оставались рядом с HTML. Здесь нет внешних ресурсов, поэтому подходит и открытие файла напрямую, и знакомый локальный сервер.

Исходники урока

start/ — сцена со столкновениями; step-01/ — рисунок карты; step-02/ — стены и движение; step-03/ и final/ — сбор кристаллов. solutions/ содержит другую проходимую карту. В просмотрщике и ZIP есть инструкция по запуску и ответы на упражнения.

Скачать ZIP

Строка, столбец и клетка

Двумерный массив можно представить как массив рядов, где каждый ряд тоже содержит несколько элементов. Например, маленькая схема из двух строк и двух столбцов:

const cells = [
  ["#", "."],
  ["P", "C"]
];
console.log(cells[1][0]); // P

Первый индекс выбирает строку сверху вниз, второй — столбец слева направо. Индексы начинаются с нуля. Для карты из однобуквенных обозначений удобнее записать каждый ряд обычной строкой. К символу строки тоже можно обратиться по индексу, поэтому выражение map[row][column] сохранится. Менять отдельный символ присваиванием не будем: строки в JavaScript неизменяемые. При редактировании карты заменяй нужную строку в коде целиком.

СимволЧто означаетВо что превратится
#СтенаНеподвижный прямоугольник 48×48
.ПолСвободное место
PСтарт игрокаПоложение кота внутри клетки
CКристаллПредмет 18×18 с флагом collected

В game.js добавь размер клетки и карту. В каждой строке ровно десять символов, строк семь. Одна клетка, или тайл, занимает 48 пикселей нашего внутреннего поля. Получаем ширину 480 и высоту 336.

const tileSize = 48;
const map = [
  "##########",
  "#P...#..C#",
  "#.#..#.#.#",
  "#.#....#.#",
  "#.####.#.#",
  "#..C.C...#",
  "##########"
];

В index.html замени у canvas#game высоту 320 на 336. Ширину 480 и CSS с height: auto оставь. Координаты карты измеряются клетками, координаты кота — пикселями. Преобразование связывает их:

const x = column * tileSize;
const y = row * tileSize;

Например, строка 1, столбец 3 начинаются в (144,48). Не переставляй row и column: столбец отвечает за X, строка — за Y. map.length даёт число строк, а map[row].length — длину выбранной строки. Здесь используются только наши простые латинские символы; правила length у строк с произвольным текстом и эмодзи сложнее.

Шаг 1. Сначала проверим рисунок карты

Сначала сделаем короткую версию без движения, чтобы проверить сами данные. В копии game.js оставь получение canvas и ctx, функции drawScene() и все функции кота от drawBody() до drawHero(). Добавь tileSize и map из блока выше. Старые предметы, стену, обработчики и цикл пока убери из этой копии; исходный start/ остаётся сохранённым.

Вставь такую render() и один вызов в конце файла. Полная сборка этого короткого опыта есть в step-01/; его кнопки отключены атрибутом disabled, потому что движения ещё нет.

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawScene();
  for (let row = 0; row < map.length; row = row + 1) {
    for (let column = 0; column < map[row].length; column = column + 1) {
      const tile = map[row][column];
      const x = column * tileSize;
      const y = row * tileSize;
      if (tile === "#") {
        ctx.fillStyle = "#a4b9d9";
        ctx.fillRect(x, y, tileSize, tileSize);
      }
      if (tile === "C") {
        ctx.fillStyle = "#ffcb66";
        ctx.fillRect(x + 15, y + 15, 18, 18);
      }
      if (tile === "P") {
        drawHero(x + 8, y + 4);
      }
    }
  }
}

render();

Это вложенный цикл: внешний выбирает строку, внутренний проходит все её столбцы. После последнего столбца внутренний цикл заканчивается; внешний переходит к следующей строке, а столбец снова начинается с нуля. Для этой карты получится 7×10, то есть 70 посещений клеток.

Мы не рисуем точку как знак на экране: свободная клетка уже имеет цвет фона. У кристалла остаются отступы 15 с каждой стороны: 48−18=30. Кот не квадратный, поэтому его отступы разные: (48−32)/2=8 по X и (48−40)/2=4 по Y. Его размер не меняется.

Проверь: в step-01 видны внешняя рамка стен, внутренние проходы, кот слева сверху и три кристалла. Поменяй одну точку на решётку в любой свободной клетке и обнови страницу: должна появиться одна стена. Верни проход. Если карта повёрнута, перепутаны индексы; если сдвигается весь ряд, проверь его длину.

Шаг 2. Превратим карту в стены для движения

Рисунок стены сам по себе не мешает коту пройти. Проверка столкновений из прошлого урока читает массив walls, поэтому теперь получим этот массив из карты. Верни рабочую сцену из start/: её функции ввода, update, moveBy, overlaps, цикл и функции кота остаются прежними. Добавь карту и tileSize, поменяй размер Canvas на 480×336.

Убери прежнюю функцию createItems() и вручную заданный массив walls. Вместо них перед функциями запиши let walls = []; и let items = [];. Временная collectItems() пока пуста — её вернём на следующем этапе. Вставь загрузку карты:

function loadLevel() {
  walls = [];
  items = [];
  score = 0;
  scoreText.textContent = score;
  for (let row = 0; row < map.length; row = row + 1) {
    for (let column = 0; column < map[row].length; column = column + 1) {
      const tile = map[row][column];
      const x = column * tileSize;
      const y = row * tileSize;
      if (tile === "#") {
        walls.push({ x: x, y: y, width: tileSize, height: tileSize });
      }
      if (tile === "P") {
        player.x = x + (tileSize - player.width) / 2;
        player.y = y + (tileSize - player.height) / 2;
      }
    }
  }
}

push() добавляет объект в массив. Теперь любое количество решёток даст столько же стен. Клетка P устанавливает координаты кота по уже разобранным отступам; сама буква не остаётся игровым объектом. Старые массивы в начале заменяем новыми, иначе каждый перезапуск дописывал бы ещё один набор стен.

В конце game.js, после однократного подключения обработчиков, добавь loadLevel(); перед render();. В handleReset() вместо старых фиксированных координат и createItems() вызывай загрузку карты:

function handleReset() {
  stopLoop();
  loadLevel();
  finished = false;
  render();
  statusText.textContent = "Карта восстановлена. Нажми «Продолжить».";
}

moveBy() и рисование стен уже перебирают walls, поэтому им не важно, написаны прямоугольники вручную или получены из данных. Остальные части сцены не меняются. Полный результат соединения старого движения и новой карты лежит в step-02/.

Проверь: кнопка «Продолжить» даёт полю фокус. Кот проходит по коридорам шириной 48, но упирается во внешние и внутренние стены. Попробуй двигаться по диагонали у угла. Кристаллов в этой версии пока нет; пауза и «Сначала» работают как раньше.

Шаг 3. Предметы тоже получаем из данных

Внутри внутреннего цикла loadLevel(), после проверки P, добавь проверку C:

if (tile === "C") {
  items.push({ x: x + 15, y: y + 15, width: 18, height: 18, collected: false });
}

Теперь верни collectItems() из предыдущего проекта. Один кристалл увеличивает счёт один раз; игра заканчивается, когда счёт равен числу предметов. Здесь это выглядит так:

function collectItems() {
  for (const item of items) {
    if (!item.collected && overlaps(player, item)) {
      item.collected = true;
      score = score + 1;
      scoreText.textContent = score;
    }
  }
  if (score === items.length) {
    finished = true;
    stopLoop();
    statusText.textContent = "Все кристаллы собраны! Нажми «Сначала» для новой попытки.";
  }
}

Строки карты остаются исходным описанием. При подборе меняется объект из items, а не символ C в карте. Поэтому loadLevel() создаёт новый предмет с collected: false, и при перезапуске он снова появляется на карте. Координаты не нужно копировать в отдельный список.

Проверь: собери все три кристалла. Каждый исчезает, счёт увеличивается только один раз. После последнего движение останавливается. Нажми «Сначала»: вернулись старт P, три кристалла и нулевой счёт. Затем запусти снова. step-03/ и final/ совпадают.

Самостоятельно: другая карта, те же правила

Нарисуй свою карту 10×7. Используй те же четыре символа, один P и не меньше трёх C. Внешний край оставь стенами. Сделай хотя бы один поворот и ответвление; от старта должен существовать путь к каждому кристаллу. Менять render(), moveBy() или координаты в загрузчике нельзя: меняются только строки карты.

До запуска проверь одинаковую длину строк. После запуска пройди карту полностью, затем перезапусти и собери предметы в другом порядке. Если один кристалл окружён стенами, это ошибка данных уровня, а не повод менять столкновения.

Подсказка: начни с пустой рамки

Сначала сделай внешние стены и открытое внутреннее поле, поставь P и C. Добавляй внутренние стены по одной и проверяй, остаётся ли проход. Не начинай с плотной сетки, где трудно понять, какие проходы соединены.

Подсказка: как проверить путь

На бумаге или прямо в редакторе пройди от P по соседним свободным клеткам вверх, вниз, влево и вправо. Все C должны попасть в достижимую область. Диагональное соприкосновение углами не считается проходом.

Один вариант после своей попытки

В solutions/ изменён только массив карты. Там четыре кристалла и другой путь. Счётчик сам использует новое items.length, поэтому условие завершения не пришлось переписывать.

Что проверить при ошибке

СимптомПроверка
Карта сдвинута или обрезанаВ каждой строке 10 символов, строк 7; Canvas 480×336. Учитывай решётки по краям.
Кот стоит внутри стеныДолжен быть ровно один P; отступы вычисляются отдельно по ширине и высоте кота.
Видимая стена пропускаетРисование и движение должны использовать одинаковый walls из loadLevel, а не два разных списка.
После рестарта предметов стало большеВ loadLevel сначала новые пустые walls/items; обработчики и RAF внутри этой функции не создаются.
Кристалл нельзя собратьПроверь путь по соседним клеткам и достаточную ширину коридора.
Игра заканчивается сразуВ карте должен быть хотя бы один C; на этапе только со стенами collectItems ещё не завершает игру.

Когда уровень готов

Ты можешь назвать клетку и вычислить координаты её левого верхнего угла в пикселях, объяснить работу двух циклов и путь данных от символа # к прямоугольнику в walls. Свою карту можно пройти до конца без изменения функций движения. После рестарта предметы восстанавливаются, а стены и обработчики не дублируются. Попробуй без образца написать две формулы перевода строки и столбца в X/Y — именно они связывают карту и рисунок.