Загрузка ресурсов и прелоадер

Учимся дожидаться, пока все спрайты цыплёнка и звуки прыжка реально догрузятся, и только потом запускать игру — а пока показываем честную полоску загрузки.
Прелоадер — это экран загрузки, который держит игру на паузе, пока браузер вытягивает все картинки и звуки, и показывает прогресс, чтобы игрок не думал, что всё зависло.

Зачем вообще ждать? Покажу проблему

Представь: ты запускаешь свою игру про цыплёнка, и в первую же секунду на canvas вместо героя — пустота. Цыплёнок появляется только через мгновение, монетки мигают, фон подгружается кусками. Выглядит так, будто игра сломалась. А она не сломалась — просто картинки ещё не успели прилететь с сервера.

Тут важно понять одну вещь про new Image(). Когда ты пишешь img.src = 'chicken.png', картинка не появляется мгновенно. Браузер только отправляет запрос на сервер и идёт дальше выполнять код. Файл прилетит позже — через 50 миллисекунд или через 2 секунды, как повезёт с интернетом. Это как заказать пиццу: ты сделал заказ, но есть её прямо сейчас не получится — надо дождаться курьера.

А наш игровой цикл из прошлых уроков не ждёт никого. Он стартует сразу и на первом же кадре пытается нарисовать цыплёнка, которого ещё нет. Результат — мигающий, дёрганый старт. Ты наверняка видел такое в дешёвых браузерных играх и в кривых рекламных мини-играх: первые полсекунды экран дёргается, текстуры подгружаются на глазах, и сразу хочется закрыть вкладку. У настоящих игр всё иначе — сначала аккуратный экран загрузки, и только потом плавный старт. Эту разницу делает всего одна вещь: ресурсы дождались, прежде чем игра пошла.

Почему так важно именно ждать? Дело в том, что ctx.drawImage с ещё не загруженной картинкой ведёт себя коварно: иногда он молча ничего не рисует, иногда выдаёт в консоль предупреждение, а в некоторых браузерах вообще роняет кадр с ошибкой. То есть один и тот же код у тебя на быстром Wi-Fi будет работать, а у друга на мобильном интернете — мигать и падать. Это классическая «плавающая» ошибка, которую тяжело поймать, потому что она зависит от скорости сети. Прелоадер убирает её раз и навсегда: к моменту старта все картинки гарантированно на месте у любого игрока.

К концу урока у нас будет вот такой сценарий: чёрный экран → аккуратная полоска, которая заполняется по мере загрузки → надпись «Готово!» → и только тогда стартует игра, где цыплёнок уже на месте с первого кадра. Погнали разбираться.

Метафора: курьер и список покупок

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

Прелоадер делает ровно это:

  1. составляет список всех нужных файлов;
  2. начинает грузить их все сразу (курьеры разъехались);
  3. каждый раз, когда один файл «приехал», ставит галочку и обновляет полоску;
  4. когда галочки стоят у всех — открывает дверь, то есть запускает игру.

Главная мысль: игра не должна стартовать, пока загружено === всего. Всё остальное — детали реализации.

Заметь ещё одну тонкость метафоры: курьеры едут одновременно, а не по очереди. Если бы ты ждал, пока приедет один курьер, потом отправлял следующего, поход начался бы только к вечеру. Браузер умеет тянуть несколько файлов параллельно — и мы этим обязательно воспользуемся. А полоска загрузки нужна не для красоты: это честный разговор с игроком. Пустой чёрный экран без полоски выглядит как зависание, и человек закрывает вкладку через пару секунд. Та же полоска, которая медленно, но заметно ползёт вперёд, говорит «всё ок, я работаю, подожди ещё чуть-чуть» — и игрок остаётся. Это маленькая, но важная часть UX в любой игре.

Как браузер сообщает, что картинка готова

У объекта Image есть событие onload — браузер вызывает эту функцию, когда файл полностью прилетел и готов рисоваться. Есть и onerror — на случай, если файл не нашёлся (опечатка в пути, нет интернета). Вот на этих двух событиях и держится весь прелоадер.

const img = new Image();
img.onload = () => console.log('Цыплёнок приехал!');
img.onerror = () => console.log('Ой, файл не нашёлся');
img.src = 'chicken.png'; // запрос уходит ПОСЛЕ назначения onload

Результат: в консоли через долю секунды появляется «Цыплёнок приехал!», если файл существует, или «Ой, файл не нашёлся», если в пути опечатка. Обрати внимание: onload и onerror мы вешаем до присвоения src — иначе быстро прилетевшая из кэша картинка успеет загрузиться раньше, чем мы повесим обработчик.

Пример 1. Грузим один спрайт цыплёнка через Promise

Событие onload — это хорошо, но callback'и быстро превращаются в кашу, когда файлов десять. Поэтому завернём загрузку одной картинки в Promise — обещание «я отдам тебе готовую картинку, когда она приедет».

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);      // успех — отдаём готовый img
    img.onerror = () => reject(new Error('Не загрузилось: ' + src));
    img.src = src;                          // запускаем загрузку
  });
}

loadImage('chicken.png').then((chickenSprite) => {
  console.log('Размер спрайта:', chickenSprite.width, chickenSprite.height);
});

Результат: функция возвращает Promise, который «исполнится» только когда картинка догрузится. В .then мы получаем уже готовый chickenSprite с известными шириной и высотой — его сразу можно рисовать на canvas. Если файла нет, Promise упадёт в reject и мы об этом узнаем, а не будем гадать.

Разберём по шагам, что тут происходит:

  • new Promise принимает функцию с двумя ручками — resolve (всё ок) и reject (всё плохо).
  • Внутри создаём Image и вешаем обработчики, как в прошлом блоке.
  • Когда onload сработал — зовём resolve(img) и передаём наружу готовую картинку.
  • Когда onerror — зовём reject с понятной ошибкой, чтобы потом было видно, какой именно файл подвёл.

Главный профит: теперь загрузку картинки можно await-ить и складывать в массив, как любой другой Promise. Это открывает дорогу к загрузке всего списка разом.

Если слово «Promise» пока пугает — держи простой образ. Promise это как трек-номер посылки. В момент заказа посылки у тебя ещё нет, но есть бумажка с номером, по которой её рано или поздно привезут. Ты можешь спокойно заниматься делами и сказать «когда привезут — позвоните» (это и есть .then). Наша loadImage возвращает именно такую «бумажку»: картинки ещё нет, но обещание, что она будет, уже на руках. И что особенно удобно — таких обещаний можно набрать целую пачку и потом одной командой дождаться, пока сбудутся все сразу.

Пример 2. Грузим весь список и считаем прогресс

Теперь соберём список всех ресурсов цыплячьей игры и будем грузить их все сразу, обновляя счётчик прогресса. Заведём объект assets, куда сложим готовые картинки по именам — потом достанем как assets.chicken.

// Список того, что нужно игре. Ключ — имя, значение — путь к файлу.
const manifest = {
  chicken: 'sprites/chicken.png',
  coin:    'sprites/coin.png',
  enemy:   'sprites/enemy.png',
  bg:      'sprites/background.png',
};

const assets = {};                 // сюда сложим готовые картинки
let loaded = 0;                    // сколько уже приехало
const total = Object.keys(manifest).length; // сколько всего ждём

function loadAll(onProgress, onDone) {
  for (const name in manifest) {
    loadImage(manifest[name]).then((img) => {
      assets[name] = img;          // запоминаем под именем
      loaded++;                    // ставим галочку
      onProgress(loaded / total);  // сообщаем прогресс: число от 0 до 1
      if (loaded === total) {
        onDone();                  // все на месте — открываем дверь
      }
    });
  }
}

Результат: функция loadAll запускает загрузку всех четырёх файлов одновременно. Каждый раз, когда один приезжает, она вызывает onProgress с дробью вроде 0.25, 0.5, 0.75, 1 — это и есть заполненность полоски. Когда loaded сравняется с total, сработает onDone, и можно стартовать игру.

Ключевые моменты этого кода:

  • manifest — это и есть наш «список покупок». Добавить новый спрайт = дописать одну строку, остальной код не трогаем.
  • loaded / total даёт прогресс от 0 до 1 — удобно умножать на ширину полоски в пикселях.
  • Проверка loaded === total — тот самый «замок на двери»: пока не все, игра не стартует.
  • Картинки грузятся параллельно, а не по очереди — браузер сам тянет их одновременно, так быстрее.

Тот же список через async/await

Если хочется покороче и без вложенных .then, тот же манифест грузится через Promise.all — он ждёт, пока исполнятся все переданные обещания.

async function loadAllAsync(onProgress) {
  const names = Object.keys(manifest);
  let loaded = 0;
  const jobs = names.map(async (name) => {
    assets[name] = await loadImage(manifest[name]);
    loaded++;
    onProgress(loaded / names.length);
  });
  await Promise.all(jobs); // ждём, пока ВСЕ jobs завершатся
}

Результат: то же самое, но без ручной проверки loaded === total — мы просто await-им Promise.all, и строка после него выполнится только когда все картинки готовы. onProgress по-прежнему дёргается на каждой приехавшей картинке, так что полоска заполняется плавно.

Пример 3. Рисуем полоску загрузки на canvas

Прогресс мы считаем — пора его показать. Нарисуем на canvas рамку и заполняющуюся полоску, опираясь на число от 0 до 1, которое отдаёт onProgress.

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

function drawLoader(progress) {
  // 1. Чистим экран
  ctx.fillStyle = '#1b1b2f';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // 2. Геометрия полоски по центру
  const barW = 300, barH = 28;
  const x = (canvas.width - barW) / 2;
  const y = (canvas.height - barH) / 2;

  // 3. Рамка (пустая часть)
  ctx.strokeStyle = '#ffd166';
  ctx.lineWidth = 3;
  ctx.strokeRect(x, y, barW, barH);

  // 4. Заполнение — ширина зависит от progress (0..1)
  ctx.fillStyle = '#ffd166';
  ctx.fillRect(x, y, barW * progress, barH);

  // 5. Подпись с процентами
  ctx.fillStyle = '#ffffff';
  ctx.font = '16px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText(Math.round(progress * 100) + '%', canvas.width / 2, y - 14);
}

// Связываем загрузку с отрисовкой
loadAll(
  (progress) => drawLoader(progress),   // на каждый шаг — перерисовать полоску
  () => startGame()                      // всё готово — старт игры
);

Результат: на тёмном экране по центру появляется жёлтая рамка. По мере загрузки спрайтов цыплёнка она заполняется слева направо, а сверху бежит цифра процентов: 25%, 50%, 75%, 100%. Как только полоска заполнилась целиком, вызывается startGame() — и игрок видит готовую сцену, где цыплёнок уже стоит на месте, без единого мигания.

Самое важное в строке ctx.fillRect(x, y, barW * progress, barH): ширина заливки — это полная ширина умноженная на прогресс. При progress = 0 ширина нулевая (пусто), при progress = 1 — полная (заполнено). Та же идея, что и с дельта-временем: одно число управляет масштабом.

Разберём drawLoader чуть подробнее, потому что тут спрятано несколько приёмов, которые пригодятся и в самой игре. Сначала мы заливаем весь экран тёмным цветом — без этого старая полоска не стёрлась бы и поверх неё рисовалась бы новая, оставляя грязный след. Это тот же приём «чистим кадр перед отрисовкой», что и в игровом цикле. Дальше мы считаем координаты x и y так, чтобы полоска оказалась ровно по центру: берём центр canvas и отнимаем половину ширины полоски. Такой расчёт работает при любом размере холста — полоска всегда будет посередине, хоть на телефоне, хоть на большом мониторе. Потом рисуем рамку (strokeRect — это контур без заливки) и поверх неё заполнение (fillRect — сплошной прямоугольник). И в конце — текст с процентами; textAlign = 'center' говорит холсту центрировать надпись относительно точки, в которую мы её ставим, иначе цифры уезжали бы вправо.

А где же startGame?

Внутри startGame() живёт наш игровой цикл из прошлых уроков. Раньше мы звали его сразу при загрузке страницы — и в этом была ошибка. Теперь он спрятан за прелоадером:

function startGame() {
  // Здесь спрайты уже точно загружены — можно смело рисовать
  const chicken = { x: 100, y: 100, sprite: assets.chicken };

  function loop() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(chicken.sprite, chicken.x, chicken.y); // никаких пустых кадров!
    requestAnimationFrame(loop);
  }
  loop();
}

Результат: игровой цикл стартует только тогда, когда assets.chicken гарантированно содержит готовую картинку. На самом первом же кадре цыплёнок отрисовывается полностью — мигания при старте больше нет.

Частые ошибки и подводные камни

1. Запускать игру до загрузки

Классика: написал img.src = '...' и тут же ctx.drawImage(img, ...) в следующей строке. Картинка ещё не приехала — рисуется пустота, иногда даже без ошибки в консоли. Лечится ровно тем, чему посвящён урок: рисуем спрайты только из startGame, который зовётся после прелоадера.

2. Вешать onload после src

Если написать сначала img.src = '...', а onload повесить строкой ниже, то картинка из кэша браузера может загрузиться мгновенно — раньше, чем ты повесил обработчик. Тогда onload не сработает никогда, и прелоадер зависнет на 75%. Правило: сначала onload и onerror, потом src.

3. Забыть про onerror

Опечатка в пути (chiken.png вместо chicken.png) — и onload не сработает, loaded не дорастёт до total, полоска замрёт навсегда. Без onerror ты даже не поймёшь, какой файл виноват. Всегда обрабатывай reject и выводи имя проблемного файла.

4. Прогресс прыгает или уходит за 100%

Если случайно увеличить loaded дважды для одной картинки (например, повесить и onload, и onloadend), прогресс перевалит за единицу, и полоска вылезет за рамку. Считай галочку ровно один раз на файл.

5. Грузить ресурсы по очереди вместо параллели

Соблазн: await loadImage(a); await loadImage(b); — выглядит аккуратно, но файлы грузятся друг за другом, и загрузка тянется в разы дольше. Представь, что у тебя 30 спрайтов по 100 мс каждый: последовательно это 3 секунды ожидания, а параллельно — почти те же 100 мс, потому что браузер тянет их одновременно. Правильно — запустить все загрузки сразу и дождаться их через Promise.all, как в примере 2.

6. Огромные несжатые картинки

Технически это не баг кода, но именно из-за этого прелоадер у новичков «висит» долго. Спрайт цыплёнка размером 4000×4000 пикселей весит мегабайты и грузится вечность, хотя на экране он размером с ноготь. Перед тем как класть картинку в игру, ужми её до реального размера и сохрани в подходящем формате (PNG для спрайтов с прозрачностью, JPG для фонов без неё). Лёгкие ресурсы — короткий прелоадер.

Мини-практика: добавь звук прыжка и кнопку «Играть»

Возьми код прелоадера из примеров и прокачай его сам:

  1. Добавь звук. Звуки грузятся похоже на картинки, только через new Audio() и событие canplaythrough вместо onload. Напиши функцию loadSound(src) по образцу loadImage и добавь в манифест строку jump: 'sounds/jump.mp3'. Подсказка: в Promise вешай audio.oncanplaythrough = () => resolve(audio).
  2. Сделай так, чтобы прогресс считал и картинки, и звуки. Манифест станет смешанным — придётся по расширению файла (.png или .mp3) выбирать, какой загрузчик звать. Подумай, как это аккуратно разрулить через if.
  3. Добавь экран «Нажми, чтобы играть». После загрузки не запускай игру сразу, а нарисуй на canvas надпись и жди клика мышью (canvas.addEventListener('click', startGame)). Это важно: браузеры не дают играть звуку, пока пользователь не кликнул хотя бы раз по странице — так что этот экран ещё и «разблокирует» звук прыжка.

Если справишься со всеми тремя — у тебя получится настоящий профессиональный старт игры, как в больших проектах.

Итоги

  • Картинки и звуки грузятся не мгновенно: img.src = ... только отправляет запрос, файл приезжает позже.
  • Запускать игру можно только после загрузки всех ресурсов — иначе будут пустые мигающие кадры.
  • Оборачивай загрузку одного файла в Promise — так удобно собирать всё в список и ждать через Promise.all.
  • Прогресс — это загружено / всего, число от 0 до 1; умножай его на ширину полоски, чтобы рисовать заполнение.
  • Не забывай onerror и вешай обработчики до присвоения src.

В прошлом уроке про сущности и компоненты мы научились описывать игровые объекты данными. Теперь мы умеем заранее подгружать всё, что этим объектам нужно для жизни — спрайты и звуки. В следующем уроке соберём это вместе и разберёмся, как переключать игровые состояния: меню, игра, пауза и экран проигрыша — чтобы прелоадер плавно перетекал в главное меню, а оттуда в саму игру про цыплёнка.

Проверьте себя
1. Почему нельзя сразу рисовать спрайт после строки img.src = 'chicken.png'?
AПотому что присвоение src только отправляет запрос — картинка прилетит позже, а пока её ещё нет
BПотому что canvas не успевает очиститься
CПотому что drawImage работает только внутри requestAnimationFrame
DПотому что src нужно присваивать дважды
2. Что покажет прогресс loaded / total, когда из 4 файлов загрузились 3?
A0.75
B3
C1.33
D0.25
3. Почему обработчик onload нужно вешать ДО присвоения img.src?
AКартинка из кэша может загрузиться мгновенно, и onload, повешенный позже, уже не сработает
BИначе src не сохранится в объекте
CПотому что onload работает только с локальными файлами
DЧтобы браузер не показал ошибку 404
4. Зачем в loadImage нужен onerror и reject?
AЧтобы при опечатке в пути узнать, какой файл не загрузился, а не ждать вечно
BЧтобы ускорить загрузку картинок
CЧтобы картинка перерисовывалась каждый кадр
DЧтобы прогресс считался дважды
5. Чем грозит загрузка ресурсов по очереди (await a; await b; await c) вместо параллельной?
AФайлы грузятся друг за другом, и общая загрузка тянется в разы дольше
BЧасть файлов вообще не загрузится
CПрогресс уйдёт за 100%
DИгра запустится до загрузки
6. Когда правильно вызывать startGame() с игровым циклом?
AТолько после того, как loaded стало равно total — то есть все ресурсы загружены
BСразу при загрузке страницы, чтобы не терять время
CНа каждый вызов onProgress
DВнутри onerror