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

Лабиринт с ключом и дверью

Найди ключ, затем войди в дверь. До ключа дверь розовая и непроходимая; после — зелёная. На второй карте нужен новый ключ. Жёлтые кристаллы дают счёт, но не обязательны для выхода. Управляй котом стрелками клавиатуры.

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

Продолжаем карту из предыдущего урока

Нужен результат карты из клеток. Там уже разобраны строки, столбцы, вложенный цикл и получение стен и предметов из символов. start/ — точная готовая версия этого проекта. Сохрани её копию и открой start/index.html. После проверки прежнего сбора кристаллов переходи к изменениям в game.js.

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

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

Скачать ZIP

Шаг 1. Обозначим ключ и дверь

Символы # . P C остаются прежними. Добавим K для ключа и E для выхода. Слово exit в коде тоже означает выход. Дверь занимает целую клетку 48×48; ключ — отдельную небольшую область 20×20 внутри клетки.

Вместо одной переменной map заведём levels: массив объектов, у каждого есть название и карта. Пока оставь один объект с первой картой. Второй добавим отдельным этапом. В начале game.js нужны данные текущего уровня:

let levelIndex = 0;
let key = null;
let exit = null;
let hasKey = false;

levelIndex — индекс карты, начиная с нуля. key и exit получат прямоугольники при загрузке. null пока означает отсутствие объекта. Флаг hasKey отвечает только на вопрос, найден ли ключ этого уровня.

const levels = [
  {
    name: "Лабиринт 1",
    map: [
      "##########",
      "#P...#..E#",
      "#.#..#.#.#",
      "#.#....#.#",
      "#.####.#.#",
      "#..K.C...#",
      "##########"
    ]
  }
];

Загрузчик теперь принимает индекс. В начале он очищает старые массивы, ключ, дверь, счёт и нажатые клавиши. Внутри используется levels[levelIndex].map. Замени прежнюю loadLevel() целиком: ветки стен, старта и кристаллов знакомы, добавились сброс флагов, ключ и дверь:

function loadLevel(index) {
  levelIndex = index;
  walls = [];
  items = [];
  key = null;
  exit = null;
  hasKey = false;
  score = 0;
  clearKeys();
  const map = levels[levelIndex].map;
  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, isDoor: false });
      }
      if (tile === "P") {
        player.x = x + (tileSize - player.width) / 2;
        player.y = y + (tileSize - player.height) / 2;
      }
      if (tile === "C") {
        items.push({ x: x + 15, y: y + 15, width: 18, height: 18, collected: false });
      }
      if (tile === "K") {
        key = { x: x + 14, y: y + 14, width: 20, height: 20 };
      }
      if (tile === "E") {
        exit = { x: x, y: y, width: tileSize, height: tileSize, isDoor: true };
        walls.push(exit);
      }
    }
  }
  updateHud();
}

Дверь добавляется в walls, потому что сначала блокирует движение. У обычных стен в загрузчике поставь isDoor: false. В moveBy() обе проверки пересечения — для X и Y — теперь используют условие:

if ((!wall.isDoor || !hasKey) && overlaps(player, wall)) {
  // Здесь остаётся прежнее разрешение столкновения по соответствующей оси.
}

У обычной стены !wall.isDoor истинно: она всегда мешает. У двери эта часть ложна, поэтому препятствие остаётся только пока !hasKey. Не меняй сами формулы ограничения X/Y и разбиение движения на маленькие шаги из предыдущего занятия.

Убери из collectItems() прежнее завершение при score === items.length: кристаллы теперь дают только счёт. В конце файла и при перезапуске вызывай loadLevel(0), чтобы явно выбрать первую карту.

Удобно видеть данные рядом с полем. В HTML добавь подписи с id="level" и id="key-state", получи их в переменные levelText и keyText через querySelector. Функция обновления подписей:

function updateHud() {
  scoreText.textContent = score;
  levelText.textContent = (levelIndex + 1) + " из " + levels.length;
  keyText.textContent = hasKey ? "найден" : "не найден";
}

Элементы подписей в index.html и их получение в начале game.js:

<p>Уровень: <strong id="level">1</strong></p>
<p>Ключ: <strong id="key-state">не найден</strong></p>
const levelText = document.querySelector("#level");
const keyText = document.querySelector("#key-state");

Запись hasKey ? "найден" : "не найден" выбирает первую строку, если условие истинно, иначе вторую. Это короткая форма if/else для одного значения. updateHud() только показывает данные; правила не читают текст подписей.

Ключ и дверь рисуются отдельными drawKey() и drawExit() с уже знакомыми линиями, кругом и прямоугольниками. В render() обычные стены рисуем при !wall.isDoor, затем предметы, дверь, несобранный ключ и кота. Полный код этих двух небольших рисунков и обновлённый HTML находятся в step-01/; графика не меняет правил.

Проверь: видны ключ и розовая дверь. Кот упирается в дверь так же, как в стену. Ключ пока не подбирается: этот этап проверяет только карту, рисунок и препятствие. Не пытайся пройти всю партию на step-01/.

Шаг 2. Подобрать ключ и открыть выход

Раньше сбор всех кристаллов завершал сцену. Теперь они дают дополнительный счёт, а цель — дверь. На первом этапе мы убрали из collectItems() условие завершения score === items.length; в ней остались перебор и однократная прибавка. Ключу отдельное число не нужно: его состояние хранит hasKey.

Добавь checkObjects() после collectItems(). На этом этапе в levels ещё одна карта, поэтому последняя дверь завершит игру.

function checkObjects() {
  collectItems();
  if (!hasKey && overlaps(player, key)) {
    hasKey = true;
    updateHud();
    statusText.textContent = "Ключ найден. Дверь открыта.";
  }
  if (!hasKey || !overlaps(player, exit)) return false;

  if (levelIndex + 1 < levels.length) {
    loadLevel(levelIndex + 1);
    statusText.textContent = "Новая карта. Для новой двери нужен новый ключ.";
  } else {
    finished = true;
    stopLoop();
    statusText.textContent = "Все уровни пройдены! Нажми «Сначала» для новой игры.";
  }
  return true;
}

Сначала проверяем ключ. При пересечении меняется флаг и подпись; следующий кадр больше не рисует ключ. Условие !hasKey не даёт повторно обрабатывать подбор. Затем ранний return false отклоняет переход, если ключа нет или кот не дошёл до выхода. Это дополнительная проверка правила, кроме физической закрытой двери.

Часть с levelIndex + 1 уже умеет выбрать следующую карту, но пока такой карты нет, поэтому выполняется ветка завершения. Она ставит finished, останавливает единственный цикл и выводит результат.

В конце каждого малого шага внутри moveBy() замени прежние collectItems() и if (finished) break; одной строкой:

if (checkObjects()) return;

Возвращаемое true означает, что выход уже завершил партию или сменил уровень. Остаток старого движения нужно отбросить. Иначе кот, поставленный в новый старт, успел бы получить ещё несколько шагов от прежнего нажатия.

Проверь: сначала дойди до двери без ключа — пройти нельзя. Возьми ключ, убедись, что он исчез, а дверь стала зелёной. Теперь вход в дверь завершает единственную карту. «Сначала» возвращает ключ и закрывает дверь. Это step-02/.

Шаг 3. Вторая карта и полный сброс уровня

В конец массива levels, после первого объекта через запятую, добавь вторую карту:

{
  "name": "Лабиринт 2",
  "map": [
    "##########",
    "#P.#...K.#",
    "#..#.#.#.#",
    "##...#...#",
    "#C####.#.#",
    "#......#E#",
    "##########"
  ]
}

У карт разное расположение стен, старта, ключа и выхода. Переход уже сравнивает индекс с levels.length, поэтому отдельная функция «второй уровень» не нужна. Важно другое: данные первой карты не должны остаться во второй. Полностью проверь начало и конец loadLevel(index):

function loadLevel(index) {
  levelIndex = index;
  walls = [];
  items = [];
  key = null;
  exit = null;
  hasKey = false;
  score = 0;
  clearKeys();
  const map = levels[levelIndex].map;
  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, isDoor: false });
      }
      if (tile === "P") {
        player.x = x + (tileSize - player.width) / 2;
        player.y = y + (tileSize - player.height) / 2;
      }
      if (tile === "C") {
        items.push({ x: x + 15, y: y + 15, width: 18, height: 18, collected: false });
      }
      if (tile === "K") {
        key = { x: x + 14, y: y + 14, width: 20, height: 20 };
      }
      if (tile === "E") {
        exit = { x: x, y: y, width: tileSize, height: tileSize, isDoor: true };
        walls.push(exit);
      }
    }
  }
  updateHud();
}

Карта хранит описание, а новые объекты хранят текущую попытку. hasKey = false заставляет искать второй ключ; новые items возвращают кристаллы; P ставит кота в новый старт. clearKeys() предотвращает неожиданный шаг на новой карте. После перехода отпусти стрелку и нажми направление снова.

Функция загрузки не создаёт обработчики и не запускает requestAnimationFrame. Существующий цикл продолжит новую карту. В handleReset() останови цикл, вызови loadLevel(0), сбрось finished и нарисуй старт. Так кнопка начинает всю игру заново.

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

Самостоятельно: третий лабиринт

Добавь третий объект в levels. У него должна быть своя карта 10×7, ровно один P, один K и один E; можно добавить C. Ключ и выход расположи иначе, чем в примерах. Должен существовать путь от P к K без прохода через закрытую E, а затем путь от K к E.

Меняй только данные, не условие перехода. Пройди первые две карты и убедись, что после второй начинается третья, а победа наступает только после её двери. Нажми «Сначала» уже на третьем уровне: первая карта должна восстановиться полностью.

Подсказка: почему третий уровень не включается

Третий объект должен находиться внутри массива levels, перед закрывающей квадратной скобкой. Проверь запятую между объектами. Число уровней берётся из levels.length.

Подсказка: ключ за закрытой дверью

При проверке первого пути считай E стеной. Только после достижения K дверь можно считать свободной. Наличие всех трёх букв ещё не доказывает, что карту можно пройти.

Пример после своей попытки

В solutions/ добавлена третья карта с ключом в нижней левой части и выходом справа. Логика загрузки и движения осталась прежней. Сравни данные и пройди оба маршрута: до ключа и после.

Если переход работает неправильно

Что произошлоЧто проверить
Ключ открывает дверь следующего уровня заранееhasKey сбрасывается внутри loadLevel при каждом вызове, а не только при первом запуске.
Ключ исчез, но дверь не пропускаетВ обоих циклах стен проверяется isDoor/hasKey; обычные стены имеют isDoor:false.
После первой двери кот дёрнулсяcheckObjects возвращает true после перехода, moveBy немедленно заканчивается.
Вторая карта выглядит как перваяПроверь levelIndex и выбор levels[levelIndex].map, а не постоянный levels[0].map.
После сбора кристаллов игра замерлаУбери старое завершение по items.length из collectItems: теперь цель — выход.
На третью карту не попастьПосмотри длину levels, единственные P/K/E, одинаковую длину строк и путь к ключу при закрытой двери.

Проверка готовности

Дверь проверена и до ключа, и после. Вторая карта действительно другая, с новым ключом и новым счётом. Повторный запуск возвращает первую карту без удвоения скорости или предметов. Ты можешь без образца объяснить, почему loadLevel() очищает массивы и клавиши, но не запускает цикл. Собственный третий уровень проходит без новых функций движения.

Лабиринт с управлением на телефоне

В этом примере работают стрелки, WASD и экранные кнопки. Разберём, как связать управление с движением, настроить чёткую картинку на экранах разной плотности и переключать уровни.

Игра состоит из двух лабиринтов 25×15. Размер клетки — 30×30, игрока — 16×16. Символ D обозначает выход. Чтобы перейти на следующий уровень, нужно найти ключ и добраться до двери.

Играть в «Побег из лабиринта»

В просмотрщике доступны HTML, CSS и JavaScript игры. HTML показан как index.html; на сайте он хранится как source.html для встраивания. Для локальной копии сохрани его под именем index.html, рядом положи style.css и game.js.

Можно ли создавать игры на JavaScript

Да, и браузер для этого подходит лучше, чем кажется. JavaScript получает ввод с клавиатуры, мыши, сенсорного экрана и геймпада, меняет состояние игры и рисует следующий кадр через Canvas, SVG, DOM или WebGL. Игроку не нужно устанавливать клиент: достаточно открыть ссылку. Такой формат удобен для головоломок, карточных игр, платформеров, небольших стратегий, образовательных проектов и игровых рекламных механик. Ограничение не в самом языке, а в размере проекта, требованиях к графике и количестве систем, которые команда готова поддерживать.

Первую 2D-игру полезно написать без движка. Так видно, откуда берутся координаты, почему объект движется с разной скоростью на разных экранах и как проверяется стена. После этого проще понять, какие повторяющиеся задачи Phaser берёт на себя. Для крупного 3D-проекта, сложной физики или редактора уровней чистый Canvas быстро станет тесным. Но для лабиринта он даёт ровно тот уровень контроля, который нужен для обучения.

Что выбрать: DOM, Canvas или игровой движок

У JavaScript нет одного «правильного» способа рисовать игру. Выбор зависит от того, сколько объектов меняется на экране и какие инструменты понадобятся через неделю, а не только в первом прототипе. Крестики-нолики удобно собрать из HTML-кнопок, потому что браузер уже умеет работать с фокусом и текстом. Аркаду с постоянно движущимися объектами проще рисовать на Canvas. Если нужны сцены, загрузчик ресурсов, камера, анимации и физика, выгоднее начать с движка.

Подход Когда подходит Что придётся делать самому
DOM + CSS Карточные, пошаговые и интерфейсные игры Игровые правила и состояние; доступность и раскладку браузер уже частично решает
Canvas 2D Лабиринты, аркады, платформеры и частицы Отрисовку, коллизии, камеру, сцены и работу с ресурсами
PixiJS Визуально насыщенный 2D и быстрый рендеринг Игровую архитектуру, физику и смену состояний: PixiJS прежде всего рендерер
Phaser Полноценные 2D-игры, где нужны сцены, физика, камера и ассеты Правила своей игры; базовая инфраструктура уже есть
Three.js / Babylon.js 3D-сцены и браузерные 3D-игры В Three.js больше игровых систем собирается вручную; Babylon.js даёт более цельный набор

Наш лабиринт будет на Canvas 2D без зависимостей. Это не попытка доказать, что движки не нужны. Наоборот, мы сначала разберём задачи, которые движок позже автоматизирует: цикл, ввод, карту, столкновения и переход между уровнями. Код останется достаточно коротким, чтобы его можно было прочитать целиком. При этом это будет настоящая игра, а не квадрат, который бесконечно летит вправо.

Что нужно знать до старта

Достаточно понимать переменные, функции, массивы, объекты и условные операторы. Классы не обязательны: в учебной игре простые объекты показывают состояние яснее и не прячут логику за большим количеством файлов. Из инструментов нужен современный браузер и любой редактор текста — Cursor, VS Code, Sublime Text или другой привычный вариант. Node.js для самой игры не требуется, потому что JavaScript выполняется в браузере. Локальный сервер пригодится только для удобного запуска и будущей загрузки изображений или модулей.

Не ждите, пока выучите весь язык

Если вы умеете прочитать объект и написать условие, начинайте. Незнакомые методы проще разобрать в месте, где от них зависит видимый результат.

Как устроен проект «Побег из лабиринта»

Для готовой версии хватит трёх файлов. HTML создаёт оболочку, панель статуса, Canvas и кнопки управления. CSS отвечает за размер игрового поля и мобильную раскладку. В game.js находятся карты, состояние, ввод, обновление и отрисовка. Такое разделение не перегружает первый проект, но уже не позволяет стилям и игровой логике смешаться в одном длинном документе.

maze-game/
├── game.html
├── style.css
└── game.js

Исходные файлы полной игры доступны в просмотрщике выше: их можно открыть, скопировать и скачать. Чтобы повторить проект локально, создайте папку, положите в неё три файла и запустите простой HTTP-сервер. В редакторах обычно есть расширение Live Server. Подойдёт и команда python3 -m http.server 8000, если Python уже установлен. После этого игра откроется по адресу http://localhost:8000.

Минимальная HTML-разметка

У Canvas есть два размера: внутреннее разрешение растрового буфера и видимый размер в CSS. Если менять только CSS, браузер растянет старую картинку, и линии станут размытыми. Поэтому внутренний размер мы настроим из JavaScript с учётом плотности экрана. В разметке оставим сам холст, статус и доступную текстовую подсказку. Кнопки мобильного управления будут обычными HTML-элементами, а не нарисованными зонами внутри Canvas.

<main class="maze" data-maze-game>
  <header class="maze__header">
    <button type="button" data-restart-level>Начать уровень заново</button>
  </header>
  <section class="maze__panel" aria-label="Состояние игры">
    <strong data-level>Уровень 1 из 2</strong>
    <span data-message aria-live="polite">Найдите ключ</span>
  </section>

  <div class="maze__stage">
    <canvas data-canvas tabindex="0" aria-label="Игровое поле лабиринта"></canvas>
  </div>

  <div class="maze__controls" aria-label="Экранное управление">
    <button type="button" data-direction="up" aria-label="Двигаться вверх">↑</button>
    <button type="button" data-direction="left" aria-label="Двигаться влево">←</button>
    <button type="button" data-direction="down" aria-label="Двигаться вниз">↓</button>
    <button type="button" data-direction="right" aria-label="Двигаться вправо">→</button>
  </div>
</main>
<script src="game.js" defer></script>

Координаты Canvas и плотность экрана

Точка 0, 0 находится в левом верхнем углу Canvas. Координата x растёт вправо, а y — вниз. Прямоугольник игрока описывается четырьмя числами: позицией x, y, шириной и высотой. Метод fillRect(x, y, width, height) рисует его за один вызов. Когда координаты меняются и кадр рисуется заново, глаз воспринимает последовательность картинок как движение.

Система координат Canvas и тайловая карта лабиринта
Карта хранится в клетках, а Canvas рисует их в пикселях. Размер клетки связывает эти две системы координат.

На обычном экране один CSS-пиксель часто совпадает с одним пикселем буфера. На HiDPI-экране физическая плотность выше, поэтому такой Canvas выглядит мягким. Значение window.devicePixelRatio показывает, во сколько раз стоит увеличить буфер. После увеличения мы масштабируем контекст и продолжаем рисовать в удобных логических координатах. Ограничение коэффициента до двух уменьшает размер буфера. О связи плотности экрана и CSS-пикселей смотри справочник devicePixelRatio.

function resizeCanvas() {
  if (!game.map) return;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);

  canvas.width = Math.round(game.map.pixelWidth * dpr);
  canvas.height = Math.round(game.map.pixelHeight * dpr);
  canvas.style.aspectRatio = `${game.map.pixelWidth} / ${game.map.pixelHeight}`;

  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

Интерактивный пример: координаты и управление

В этом поле ещё нет стен и цели. Оно изолирует одну задачу: получить ввод, изменить координаты и нарисовать квадрат на новом месте. Кликните по демо и нажмите стрелку или WASD. Счётчик покажет текущие x и y, поэтому связь между клавишей и числами видна сразу. Если удерживать две клавиши, квадрат пойдёт по диагонали с той же общей скоростью, а не ускорится в полтора раза.

Обработчик клавиатуры не должен сам двигать игрока. Он только запоминает, какие кнопки сейчас нажаты. Функция обновления читает это состояние на каждом кадре и вычисляет новое положение. Благодаря этому две кнопки работают одновременно, а удержание не зависит от частоты системного автоповтора клавиатуры. При потере фокуса набор очищается, иначе персонаж мог бы продолжить движение после переключения вкладки.

Уровень как данные, а не сотня вызовов рисования

Лабиринт удобно хранить массивом строк. Каждый символ описывает одну клетку: решётка означает стену, точка — пол, P — старт игрока, K — ключ, D — дверь. Так карту можно читать прямо в коде и менять без редактора уровней. Вторая карта имеет тот же формат, поэтому движок не знает, какой уровень сейчас рисует. Он получает данные и выполняет одинаковые правила.

Символ Объект Поведение
#СтенаНе пропускает игрока
.ПолСвободная клетка
PСтартНачальная позиция игрока
KКлючМеняет hasKey на true
DДверьПропускает только после ключа
const LEVELS = [
  [
    "#########################",
    "#P....#.............#...#",
    "#####.#.###########.#.#.#",
    "#.....#.....#.......#.#.#",
    "#.#########.#.#######.#.#",
    "#.........#.#.........#.#",
    "#.#######.#.###########.#",
    "#.#.....#.#.........#...#",
    "#.#.###.#.#########.#.###",
    "#...#...#.....#.....#...#",
    "###.#.#######.#.#######.#",
    "#...#.......#.#.......#K#",
    "#.#########.#.#######.#.#",
    "#...........#.........#D#",
    "#########################"
  ],
  // Вторая карта использует те же символы.
];

Карты должны быть прямоугольными: каждая строка содержит одинаковое число символов. В каждой карте нужен один старт, один ключ и одна дверь. Проверка этих условий при загрузке экономит время, потому что опечатка превращается в понятную ошибку, а не в невидимую стену. Парсер проходит по строкам, находит специальные клетки и сохраняет их координаты. После этого символы P, K и D можно считать полом, а объекты рисовать отдельными функциями.

function parseLevel(rows) {
  const width = rows[0].length;
  const cells = rows.map((row) => row.split(""));
  const points = {};

  rows.forEach((row, y) => {
    if (row.length !== width) throw new Error("Карта должна быть прямоугольной");

    [...row].forEach((cell, x) => {
      if (cell === "P") points.player = { x, y };
      if (cell === "K") points.key = { x, y };
      if (cell === "D") points.door = { x, y };
      if (cell === "P" || cell === "K" || cell === "D") cells[y][x] = ".";
    });
  });

  if (!points.player || !points.key || !points.door) {
    throw new Error("На карте нужны P, K и D");
  }

  return { cells, width, height: rows.length, ...points };
}
Схема состояний лабиринта: поиск ключа, открытие двери и смена уровня
Уровень меняется не по таймеру, а после последовательности проверяемых игровых событий.

Состояние игры: что нужно помнить между кадрами

Canvas хранит рисунок, но не правила игры. После очистки из картинки нельзя восстановить положение квадрата или состояние двери. Эти сведения живут в обычном объекте JavaScript, который называют состоянием игры. Отрисовка только читает его и показывает текущий результат. Такой порядок позволяет отдельно проверять правила и отдельно менять внешний вид.

const game = {
  levelIndex: 0,
  map: null,
  player: {
    x: 0,
    y: 0,
    size: 16,
    speed: 148
  },
  hasKey: false,
  mode: "playing",
  message: "Найдите ключ"
};

Поле levelIndex выбирает карту из массива LEVELS. В map лежит результат парсинга текущего уровня. Координаты игрока измеряются в пикселях, потому что движение плавное, а точки ключа и двери остаются привязанными к клеткам. Флаг hasKey отвечает на один вопрос и не пытается хранить весь инвентарь. Режим mode позволяет остановить правила после победы, не останавливая отрисовку интерфейса.

Управление стрелками и WASD

Для управления используем event.code, а не устаревший keyCode. Значение code описывает физическую кнопку, поэтому KeyW продолжает работать при русской раскладке. Стрелки и WASD сводятся к четырём внутренним направлениям. Событие keydown добавляет направление в набор, а keyup удаляет. Прокрутку страницы блокируем только для управляющих клавиш и только внутри сфокусированной игры.

const pressed = new Set();
const codeToDirection = {
  ArrowUp: "up", KeyW: "up",
  ArrowDown: "down", KeyS: "down",
  ArrowLeft: "left", KeyA: "left",
  ArrowRight: "right", KeyD: "right"
};

window.addEventListener("keydown", (event) => {
  const direction = codeToDirection[event.code];
  if (!direction || !game.hasFocus || game.mode !== "playing") return;
  event.preventDefault();
  pressed.add(direction);
});

window.addEventListener("keyup", (event) => {
  const direction = codeToDirection[event.code];
  if (direction) pressed.delete(direction);
});

window.addEventListener("blur", () => setGameFocus(false));

Набор Set удобен тем, что одна клавиша не может появиться в нём дважды. В функции обновления проверяем обе клавиши каждого направления. Если игрок одновременно держит вверх и вправо, получаем диагональный вектор. Перед умножением на скорость его нужно нормализовать, иначе диагональ окажется быстрее прямого движения. Это мелкая деталь, но она сразу отделяет управляемое движение от случайно работающего примера.

Игровой цикл: input, update, render

Игровой цикл повторяет три действия. Сначала код читает ввод, затем меняет состояние и после этого рисует новый кадр. Браузер вызывает функцию, переданную в requestAnimationFrame, перед очередной перерисовкой страницы. Частота не обязана быть равна 60: экран может работать на 90, 120 или 144 Гц, а вкладка может временно остановиться. Поэтому скорость нельзя задавать фразой «прибавь три пикселя за кадр».

Игровой цикл JavaScript: ввод, обновление, столкновения, состояние и отрисовка
Canvas отвечает за последний шаг. Правила игры остаются в состоянии и функции обновления.

Аргумент timestamp показывает время текущего кадра. Разность с предыдущим кадром даёт deltaTime, то есть длительность шага. Мы переводим миллисекунды в секунды и ограничиваем значение сверху. Ограничение нужно после возвращения во вкладку: персонаж не должен телепортироваться через стену из-за одного огромного кадра. Скорость в таком коде читается как «150 пикселей в секунду».

let previousTime = performance.now();

function loop(time) {
  const deltaTime = Math.min((time - previousTime) / 1000, 0.05);
  previousTime = time;

  if (game.mode === "playing") {
    update(deltaTime);
  }

  render();
  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

В клеточном лабиринте можно было двигаться ровно на одну клетку по нажатию. Мы выбрали плавное движение, чтобы на одном примере показать deltaTime и настоящую проверку стен. Карта при этом всё равно остаётся тайловой: стены легко редактировать строками. Сочетание непрерывных координат игрока и дискретной карты встречается во многих 2D-играх. Следующая задача — связать эти две системы без перебора каждой стены на каждом кадре.

Столкновения со стенами

В этой версии проверяется только ближайшее окружение игрока. По границам прямоугольника игрока можно вычислить несколько клеток, которых он касается сейчас. Если хотя бы одна из них содержит #, новое положение запрещено. Движение по X и Y проверяем отдельно: сначала пробуем горизонтальный шаг, затем вертикальный. Благодаря этому квадрат скользит вдоль стены, а не замирает при касании угла.

function obstacleAt(x, y) {
  const inset = 2;
  const left = Math.floor((x + inset) / CELL);
  const right = Math.floor((x + game.player.size - inset) / CELL);
  const top = Math.floor((y + inset) / CELL);
  const bottom = Math.floor((y + game.player.size - inset) / CELL);
  const corners = [
    [left, top],
    [right, top],
    [left, bottom],
    [right, bottom]
  ];

  for (const [tileX, tileY] of corners) {
    if (isWall(tileX, tileY)) return "wall";
    if (!game.hasKey && isDoor(tileX, tileY)) return "door";
  }

  return "";
}

function movePlayer(dx, dy) {
  const nextX = game.player.x + dx;
  if (!obstacleAt(nextX, game.player.y)) game.player.x = nextX;

  const nextY = game.player.y + dy;
  if (!obstacleAt(game.player.x, nextY)) game.player.y = nextY;
}

Функция isWall должна считать стеной и выход за границу карты. Тогда отдельные проверки краёв Canvas не нужны: внешний мир просто состоит из непроходимых клеток. Небольшой inset уменьшает прямоугольник коллизии на пару пикселей и не даёт игроку цепляться за углы из-за округления. Для скоростного платформера такой проверки может быть мало, потому что объект способен перескочить тонкую стену за один шаг. В нашем лабиринте скорость ограничена, а deltaTime зажат, поэтому дискретная проверка работает предсказуемо.

Ключ, дверь и переход между уровнями

После движения смотрим, с какой специальной клеткой пересекается игрок. Контакт с ключом устанавливает hasKey = true и убирает предмет с карты. Дверь без ключа остаётся стеной и выводит короткую подсказку. Дверь с ключом вызывает загрузку следующего уровня. Если текущая карта последняя, режим меняется на won, а поверх поля появляется финальный экран.

function handleObjects() {
  if (!game.hasKey && overlapsTile(game.map.key, 3)) {
    game.hasKey = true;
    setMessage("Ключ найден. Теперь ищите дверь");
    updateStatus();
  }

  if (!overlapsTile(game.map.door, 3) || !game.hasKey) return;

  if (game.levelIndex < LEVELS.length - 1) {
    loadLevel(game.levelIndex + 1);
  } else {
    game.mode = "won";
    setMessage("Лабиринт пройден");
    overlay.hidden = false;
    updateStatus();
  }
}

Функция loadLevel должна полностью сбрасывать данные, относящиеся к карте. Она парсит новый массив строк, ставит игрока в стартовую клетку, возвращает hasKey в false и обновляет подпись уровня. Если забыть хотя бы один флаг, ключ с первой карты случайно откроет вторую дверь. Это типичная причина странных багов при смене сцен. Чёткая точка инициализации делает переход уровней обычной операцией, а не цепочкой исключений.

Состояния вместо случайных флагов

В маленькой игре легко добавить пять логических переменных: isStarted, isPaused, isWon, isLoading и isRestarting. Через несколько шагов некоторые из них начнут противоречить друг другу. Одно строковое поле mode не позволяет одновременно быть в режимах playing и won. Для большого проекта состояния обычно оформляют сценами или конечным автоматом. В лабиринте достаточно значений playing и won, но принцип уже виден.

Отрисовка карты и объектов

Функция render ничего не решает за игрока. Она очищает Canvas, рисует фон, стены, дверь, ключ, персонажа и интерфейс победы в фиксированном порядке. Если ключ уже собран, его просто не рисуют. Цвет двери зависит от hasKey, поэтому изменение состояния сразу видно без отдельной анимационной системы. Вся графика состоит из примитивов Canvas, и внешние изображения загружать не требуется.

function render() {
  if (!game.map) return;
  drawFloor();
  drawWalls();
  drawDoor();
  if (!game.hasKey) drawKey();
  drawPlayer();
}

Порядок важен: объект, нарисованный последним, окажется сверху. Для сложной сцены добавляют слои — фон, мир, эффекты и интерфейс. Здесь отдельные функции уже выполняют роль маленьких слоёв. Метод save() перед временной настройкой тени или прозрачности и restore() после неё не дают стилям одного объекта испортить следующий. Это особенно заметно у свечения ключа и затемнения финального экрана.

Управление на телефоне и доступность

У телефона нет WASD, поэтому под Canvas находятся четыре кнопки. События pointerdown и pointerup добавляют и удаляют те же внутренние направления, что клавиатура. Игровая логика не знает, откуда пришла команда. Свойство touch-action: none применяется только к кнопкам, а не ко всей странице, поэтому вертикальная прокрутка статьи остаётся нормальной. Отмена указателя и потеря фокуса тоже очищают направление, чтобы квадрат не ехал сам.

Canvas остаётся растровой картинкой и плохо передаёт смысл скринридеру. Поэтому номер уровня и сообщения о ключе или двери выводятся обычным HTML рядом с полем. Область сообщений имеет aria-live="polite", а кнопки получают понятные подписи. Клавиатурный фокус выделяется рамкой. Эти детали не превращают графическую игру в полностью доступную, но не оставляют управление и результат невидимыми за пределами картинки.

Как проверить игру, а не только посмотреть на неё

Рабочий первый запуск ещё не доказывает, что правила собраны правильно. Попробуйте пройти к двери без ключа и убедитесь, что она не пропускает. Затем возьмите ключ, вернитесь тем же путём и перейдите на второй уровень. Проверьте удержание двух клавиш, потерю фокуса вкладки и рестарт после победы. На телефоне нажмите направление, уведите палец за пределы кнопки, отпустите его и убедитесь, что движение прекращается.

Симптом Частая причина Что проверить
Canvas пустой Скрипт запущен до появления разметки или не найден контекст defer, селектор Canvas и ошибки в консоли
Игра быстрее на другом мониторе Скорость задана в пикселях за кадр Умножение скорости на deltaTime
Игрок проходит сквозь стену Слишком большой шаг или проверка только центра Ограничение deltaTime и четыре угла коллизии
После смены уровня дверь уже открыта Старое состояние не сброшено hasKey = false внутри loadLevel
Стрелки прокручивают страницу Игра не получила фокус или нет точечного preventDefault Клик по полю и обработку только управляющих клавиш
Квадрат продолжает ехать Не очищен набор клавиш Обработчики blur, pointercancel и visibilitychange

Почему код разделён на update и render

Соблазнительно двигать игрока прямо в drawPlayer. Тогда одна функция сразу даёт видимый результат, но состояние начинает зависеть от способа отображения. Снимок для мини-карты или дополнительный рендер внезапно сдвинет персонажа второй раз. В чистой схеме update меняет числа, а render только читает их. Поэтому графику можно заменить, не переписывая правила ключа и двери.

Такое разделение упрощает отладку. Если игрок прошёл стену, проблему ищут в обновлении и коллизиях, а не среди градиентов. Если дверь стала неправильного цвета, состояние можно вывести в консоль и проверить отдельно от рисования. Этот принцип сохраняется и в движках: названия методов отличаются, но сцена всё равно обновляет мир и показывает его. Подробнее устройство цикла разобрано в материале «Игровой цикл на JavaScript».

Когда переходить с Canvas на Phaser

Чистый Canvas удобен, пока список собственных систем остаётся коротким. Когда появляются десятки спрайтов, несколько сцен, камера, физика, звук, загрузочный экран и редактор тайлов, вы начинаете писать маленький движок. В этот момент Phaser экономит время: он уже решает загрузку ресурсов, жизненный цикл сцен, ввод, камеры, анимации и типовые варианты физики. Знание текущего проекта не пропадёт, потому что карта, состояние, коллизии и разделение update/render останутся знакомыми. Поменяется уровень готовых инструментов.

Переходить стоит не после первой работающей функции, а после законченного маленького проекта. Иначе трудно понять, какую проблему решает новый метод движка. Лабиринт можно перенести в Phaser по частям: карту отдать tilemap-системе, игрока сделать игровым объектом, а уровни — отдельными сценами или данными сцены. Начальный маршрут есть в статье «Phaser JS для новичков». Движение в маленькой Canvas-сцене отдельно разобрано в статье о движении персонажа и границ экрана.

Как опубликовать браузерную игру

Готовая версия состоит из статических файлов, поэтому ей не нужен отдельный серверный язык. Папку можно разместить на GitHub Pages, Cloudflare Pages, Netlify, обычном хостинге или внутри существующего сайта. Важно сохранить относительные пути между HTML, CSS, JavaScript и будущими изображениями. После загрузки откройте игру в приватном окне и проверьте консоль: локальный браузерный кэш иногда скрывает пропавшие файлы. Для игровых платформ позже понадобятся их SDK, правила загрузки и обработка рекламы, но ядро лабиринта останется тем же.

Куда развивать лабиринт дальше

Сначала добавьте одну механику, которая использует уже существующее состояние. Например, счётчик шагов потребует увеличивать число при движении и рисовать его в HTML-панели. Затем попробуйте несколько ключей разных цветов и двери, которые проверяют конкретный предмет. После этого можно ввести движущегося врага, но не начинать с поиска пути: пусть он ходит по заданному маршруту. Каждый такой шаг усложняет одно место и оставляет остальную архитектуру понятной.

Следующий полезный этап — вынести карты в отдельный JSON-файл и добавить проверку их корректности. Потом можно загрузить спрайты вместо геометрических фигур, подключить звук после первого пользовательского действия и сохранить пройденный уровень в localStorage. Если количество объектов вырастет, измерьте производительность до оптимизации. Пулы объектов, Web Workers и сложная физика не нужны лабиринту только потому, что встречаются в статьях о больших играх. Хорошая следующая версия решает заметную игроку задачу, а не демонстрирует ещё один термин.

Частые вопросы

Можно ли сделать игру на JavaScript без движка?

Да. Для небольшой 2D-игры достаточно Canvas и браузерных событий. Придётся самостоятельно написать цикл, ввод, отрисовку, столкновения и переходы состояний, зато каждая часть будет видна. Для обучения это преимущество. Для крупного проекта те же задачи выгоднее отдать Phaser или другому движку.

Нужно ли сначала полностью выучить JavaScript?

Нет. Нужна база: переменные, функции, условия, массивы и объекты. Игра связывает их в одну работающую систему и показывает, какие пробелы мешают прямо сейчас. Незнакомый API Canvas можно изучать по мере появления задачи. Полное знание языка не наступает до практики.

Почему используется Canvas, а не HTML-элементы?

Лабиринт постоянно перерисовывает карту и движущийся объект, поэтому Canvas здесь естественнее. Для шахмат, викторины или карточной игры DOM мог бы быть удобнее из-за готовых кнопок, текста и фокуса. Производительность не стоит сравнивать без конкретной сцены и измерений. Выбор определяется структурой игры, а не правилом «Canvas всегда быстрее».

Зачем нужен requestAnimationFrame?

Он просит браузер вызвать функцию перед следующим визуальным обновлением. Это связывает отрисовку с жизненным циклом страницы и обычно экономит ресурсы в неактивной вкладке. Метод не обещает ровно 60 кадров в секунду, поэтому движение считается через время между кадрами. Для пошаговой игры цикл может быть проще, но для плавного движения он подходит хорошо.

Можно ли добавить третий уровень?

Да. Добавьте ещё один массив строк в LEVELS с теми же символами и одинаковой длиной строк внутри карты. Код перехода уже сравнивает текущий индекс с длиной массива. Убедитесь, что у новой карты есть один старт, ключ и дверь. После этого движок загрузит её без отдельной функции.

Подойдёт ли этот код для мобильного браузера?

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

Как связаны части игры

Создание игр на JavaScript начинается не с выбора самого мощного движка, а с понятной связи между вводом, состоянием и кадром. В нашем лабиринте карта хранится как данные, игрок движется независимо от частоты экрана, стены проверяются по тайлам, а ключ и дверь меняют ход игры через явные условия. Второй уровень загружается тем же кодом, поэтому проект уже можно расширять, а не переписывать. Сохраните материалы, измените одну карту и добавьте собственную механику. Именно в этот момент пример перестаёт быть чужим листингом и становится вашей игрой.

Книга по JavaScript для начинающих

JavaScript с нуля: учимся программировать, создавая игры

Пошаговый формат с маленькими практическими задачами: от базового синтаксиса до первых игровых механик без перегруза теорией.

Подробнее о книге