В лабиринте появится кот из отдельного 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.