В лабиринте появится кот из отдельного SVG-файла. Если файл потеряется, игра покажет прежний рисунок и объяснит, что случилось. Мы сначала дождёмся изображения через события, затем запишем ту же последовательность через Promise и async/await.

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

Подготовим файл изображения

Нужны лабиринт со стартом и паузой из прошлого урока и локальный HTTP-сервер из урока 19. Папка start содержит те же файлы, что финал урока 28. Скопируй её в свою рабочую папку, проверь запуск, движение, ключ и сброс. Запусти локальный сервер и открой страницу через http://localhost:8000. Изменяй файлы по одному и проверяй результат после каждого шага.

Рядом с game.js создай папку assets. Положи в неё hero.svg из комплекта урока. SVG — текстовое описание рисунка; браузер умеет использовать его как изображение. Наш файл имеет размер 32 × 40 и прозрачный фон. Кот получил розовый нос и сиреневую полоску: так легко отличить загруженный рисунок от запасного.

my-maze/
  index.html
  style.css
  game.js
  assets/
    hero.svg

Рамка столкновений остаётся 32 × 40. Картинка меняет внешний вид, а не ширину проходов. Не увеличивай player.width вслед за случайным размером рисунка: размер, с которым мы рисуем изображение, передадим отдельно.

Сначала дождёмся события загрузки

new Image() создаёт объект изображения. Слово new здесь вызывает встроенный способ создания такого объекта; писать свой класс не требуется. Когда присвоим путь свойству src, браузер начнёт получать файл. JavaScript продолжит выполнять код, не дожидаясь загрузки файла.

У изображения есть обработчики onload и onerror. В них записывают функции: браузер вызовет первую после успешной загрузки, вторую — при ошибке. Назначим обе до src. Имя функции без скобок передаёт её браузеру, а со скобками вызвало бы сразу.

В index.html перед блоком controls добавь сообщение:

<p id="asset-status" role="status">Загружаем изображение… Пока виден нарисованный кот.</p>

В game.js после переменных кнопок добавь состояние загрузки. null означает, что готового изображения ещё нет. Отдельный assetsReady разрешает старт, когда выбран либо файл, либо запасной рисунок:

const assetStatus = document.querySelector("#asset-status");
let heroImage = null;
let assetsReady = false;

function finishLoading(message) {
  assetsReady = true;
  assetStatus.textContent = message;
  updateControls();
  render();
}

В самое начало drawHero, перед вызовом drawTail, добавь проверку:

if (heroImage !== null) {
  ctx.drawImage(heroImage, x, y, player.width, player.height);
  return;
}

Пять аргументов drawImage — изображение, левый верхний угол x/y и размеры рисунка. Если файл ещё не готов, выполнятся прежние drawTail, drawBody и остальные функции. Мы никогда не передаём в drawImage пустое значение.

Чтобы во время ожидания нельзя было начать игру, первой строкой handlePlay добавь if (!assetsReady) return;. В updateControls замени присваивание disabled:

playButton.disabled = !assetsReady || state === "playing" || state === "won";

Теперь в конце game.js, после resetGame(), запиши загрузку:

const image = new Image();
function imageLoaded() {
  heroImage = image;
  finishLoading("Изображение готово. Можно играть.");
}
function imageFailed() {
  finishLoading("Картинка недоступна. Играем с нарисованным котом.");
}
image.onload = imageLoaded;
image.onerror = imageFailed;
image.src = "assets/hero.svg";

Это версия step-01. Обнови страницу: появляется кот с полоской, кнопка старта становится доступна. Затем напиши assets/heroo.svg: в Network появится 404, сообщение изменится, но можно пройти лабиринт с прежним котом. Исправь путь. Наш промежуточный пример уже работает без Promise.

Promise хранит будущий результат

Когда ресурсов станет больше, удобнее получать результат загрузки из функции. Promise — объект, который сначала ожидает, а затем либо получает результат, либо отклоняется с ошибкой. Он завершается один раз. Нам нужны две функции, которые браузер передаёт внутрь его конструктора: resolve завершает ожидание с изображением, reject — с причиной ошибки.

Удали нижний блок от const image = new Image() до присваивания src. Вместо него добавь loadImage. Объявленная внутри функция имеет доступ к image, url и timeoutId того вызова, в котором её создали. Поэтому два вызова loadImage не перепутают свои файлы.

function loadImage(url) {
  return new Promise(function (resolve, reject) {
    const image = new Image();
    function loaded() {
      clearTimeout(timeoutId);
      resolve(image);
    }
    function failed() {
      clearTimeout(timeoutId);
      reject(new Error("Не удалось загрузить " + url));
    }
    const timeoutId = setTimeout(failed, 8000);
    image.onload = loaded;
    image.onerror = failed;
    image.src = url;
  });
}

new Error(...) создаёт объект с текстом причины. Это ещё не показ сообщения пользователю: его выберет вызывающий код. Таймер из урока 15 ограничивает ожидание восемью секундами. При load/error мы отменяем таймер; если соединение зависло, он отклонит Promise. Этот предел — наше решение для маленьких локальных файлов, а не свойство формата SVG. Более поздний load не изменит уже завершённый Promise. Загрузка в браузере при этом не обязательно отменяется.

После loadImage добавь:

function imageLoaded(image) {
  heroImage = image;
  finishLoading("Изображение готово. Можно играть.");
}
function imageFailed(error) {
  finishLoading("Картинка недоступна. Играем с нарисованным котом.");
}
loadImage("assets/hero.svg").then(imageLoaded).catch(imageFailed);

then передаёт готовое изображение в imageLoaded. catch передаёт ошибку в imageFailed. В параметре error доступен объект причины, хотя для игрока мы пока выбираем одно понятное сообщение. Это step-02: результат снаружи тот же, но загрузка теперь возвращает значение через Promise.

Запишем ожидание через async и await

async перед function позволяет использовать внутри await. Await приостанавливает именно эту функцию до результата Promise; страница и браузер продолжают работать. Сама async-функция тоже возвращает Promise. Если ожидание завершается ошибкой, выполнение переходит в ближайший catch.

Оставь loadImage. Замени функции imageLoaded/imageFailed и цепочку then/catch следующим кодом:

async function prepareImages() {
  try {
    heroImage = await loadImage("assets/hero.svg");
    finishLoading("Изображение готово. Можно играть.");
  } catch (error) {
    finishLoading("Картинка недоступна. Играем с нарисованным котом.");
  }
}
prepareImages();

Это final. try содержит попытку загрузки, catch — решение при ошибке. В обоих случаях finishLoading разрешает старт и обновляет картинку. Загрузка запускается один раз при открытии страницы. resetGame сбрасывает партию, но не создаёт новый Image, новый цикл или новые обработчики.

Открой готовую broken-path: там намеренно ошибочное имя файла. Это образец проверки отказа, а не файл для продолжения трека. Вернись к final и проверь обновление страницы, старт и пять сбросов. В DevTools можно включить медленную сеть и выключить кеш: пока ресурс ожидается, сообщение видно, поле не пустое, старт недоступен.

Самостоятельно: картинка для кристалла

Подключи второй ресурс assets/gem.svg: скопируй только рисунок из solutions/second-image/assets/gem.svg, пока не открывая game.js решения. Можно использовать и свой небольшой рисунок. Кристалл должен остаться предметом с рамкой 18 × 18 и прежним правилом подбора. Пока его рисунок не готов или недоступен, показывай жёлтый квадрат. Этот ресурс необязателен для старта: герой уже готов, а у предмета есть запасной вид.

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

Подсказка 1: одно правило, два изображения

Не используй heroImage для кристалла. Нужна отдельная переменная с начальным null. Переиспользуй loadImage; второй загрузчик с теми же событиями писать не нужно.

Подсказка 2: место рисования

В render найди ветку !item.collected. В ней выбирай drawImage или прежний fillRect. После окончания второй загрузки вызови render, даже если игра ещё не запущена.

Разбор после попытки

В solutions/second-image у кристалла своя переменная gemImage и своя обработка ошибки. prepareAll сначала ждёт prepareImages, затем prepareGem. Если файл не загрузится, вместо кристалла останется квадрат. Намеренно неверный путь проверь самостоятельно: в решении он правильный.

Что должно получиться

ПроверкаРезультат
Правильный путьРисунок появился до начала игры
404 или ожидание дольше восьми секундПонятное сообщение и запасной кот; игра доступна
Сброс во время загрузкиПартия остаётся в ready, появляется одна готовая картинка
Пять новых попытокНет повторных обработчиков и второго RAF
Второй ресурс сломанПервый работает, предмет подбирается в запасном виде

Теперь ты можешь объяснить, почему строка после src не гарантирует готовность файла, чем отличаются load/error и resolve/reject и почему catch содержит игровое решение, а не просто скрывает ошибку.

Справочно: изображения, Promise и async-функции на MDN.