Загрузка ресурсов и прелоадер
Учимся дожидаться, пока все спрайты цыплёнка и звуки прыжка реально догрузятся, и только потом запускать игру — а пока показываем честную полоску загрузки.
Прелоадер — это экран загрузки, который держит игру на паузе, пока браузер вытягивает все картинки и звуки, и показывает прогресс, чтобы игрок не думал, что всё зависло.
Зачем вообще ждать? Покажу проблему
Представь: ты запускаешь свою игру про цыплёнка, и в первую же секунду на canvas вместо героя — пустота. Цыплёнок появляется только через мгновение, монетки мигают, фон подгружается кусками. Выглядит так, будто игра сломалась. А она не сломалась — просто картинки ещё не успели прилететь с сервера.
Тут важно понять одну вещь про new Image(). Когда ты пишешь img.src = 'chicken.png', картинка не появляется мгновенно. Браузер только отправляет запрос на сервер и идёт дальше выполнять код. Файл прилетит позже — через 50 миллисекунд или через 2 секунды, как повезёт с интернетом. Это как заказать пиццу: ты сделал заказ, но есть её прямо сейчас не получится — надо дождаться курьера.
А наш игровой цикл из прошлых уроков не ждёт никого. Он стартует сразу и на первом же кадре пытается нарисовать цыплёнка, которого ещё нет. Результат — мигающий, дёрганый старт. Ты наверняка видел такое в дешёвых браузерных играх и в кривых рекламных мини-играх: первые полсекунды экран дёргается, текстуры подгружаются на глазах, и сразу хочется закрыть вкладку. У настоящих игр всё иначе — сначала аккуратный экран загрузки, и только потом плавный старт. Эту разницу делает всего одна вещь: ресурсы дождались, прежде чем игра пошла.
Почему так важно именно ждать? Дело в том, что ctx.drawImage с ещё не загруженной картинкой ведёт себя коварно: иногда он молча ничего не рисует, иногда выдаёт в консоль предупреждение, а в некоторых браузерах вообще роняет кадр с ошибкой. То есть один и тот же код у тебя на быстром Wi-Fi будет работать, а у друга на мобильном интернете — мигать и падать. Это классическая «плавающая» ошибка, которую тяжело поймать, потому что она зависит от скорости сети. Прелоадер убирает её раз и навсегда: к моменту старта все картинки гарантированно на месте у любого игрока.
К концу урока у нас будет вот такой сценарий: чёрный экран → аккуратная полоска, которая заполняется по мере загрузки → надпись «Готово!» → и только тогда стартует игра, где цыплёнок уже на месте с первого кадра. Погнали разбираться.
Метафора: курьер и список покупок
Загрузка ресурсов — это как собраться в поход. Перед выходом ты пишешь список: спрайт цыплёнка, спрайт монетки, фон, звук прыжка. Пока хоть одной вещи нет в рюкзаке — из дома не выходишь. Иначе окажешься на тропе без воды.
Прелоадер делает ровно это:
- составляет список всех нужных файлов;
- начинает грузить их все сразу (курьеры разъехались);
- каждый раз, когда один файл «приехал», ставит галочку и обновляет полоску;
- когда галочки стоят у всех — открывает дверь, то есть запускает игру.
Главная мысль: игра не должна стартовать, пока загружено === всего. Всё остальное — детали реализации.
Заметь ещё одну тонкость метафоры: курьеры едут одновременно, а не по очереди. Если бы ты ждал, пока приедет один курьер, потом отправлял следующего, поход начался бы только к вечеру. Браузер умеет тянуть несколько файлов параллельно — и мы этим обязательно воспользуемся. А полоска загрузки нужна не для красоты: это честный разговор с игроком. Пустой чёрный экран без полоски выглядит как зависание, и человек закрывает вкладку через пару секунд. Та же полоска, которая медленно, но заметно ползёт вперёд, говорит «всё ок, я работаю, подожди ещё чуть-чуть» — и игрок остаётся. Это маленькая, но важная часть 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 для фонов без неё). Лёгкие ресурсы — короткий прелоадер.
Мини-практика: добавь звук прыжка и кнопку «Играть»
Возьми код прелоадера из примеров и прокачай его сам:
- Добавь звук. Звуки грузятся похоже на картинки, только через
new Audio()и событиеcanplaythroughвместоonload. Напиши функциюloadSound(src)по образцуloadImageи добавь в манифест строкуjump: 'sounds/jump.mp3'. Подсказка: в Promise вешайaudio.oncanplaythrough = () => resolve(audio). - Сделай так, чтобы прогресс считал и картинки, и звуки. Манифест станет смешанным — придётся по расширению файла (
.pngили.mp3) выбирать, какой загрузчик звать. Подумай, как это аккуратно разрулить черезif. - Добавь экран «Нажми, чтобы играть». После загрузки не запускай игру сразу, а нарисуй на canvas надпись и жди клика мышью (
canvas.addEventListener('click', startGame)). Это важно: браузеры не дают играть звуку, пока пользователь не кликнул хотя бы раз по странице — так что этот экран ещё и «разблокирует» звук прыжка.
Если справишься со всеми тремя — у тебя получится настоящий профессиональный старт игры, как в больших проектах.
Итоги
- Картинки и звуки грузятся не мгновенно:
img.src = ...только отправляет запрос, файл приезжает позже. - Запускать игру можно только после загрузки всех ресурсов — иначе будут пустые мигающие кадры.
- Оборачивай загрузку одного файла в Promise — так удобно собирать всё в список и ждать через
Promise.all. - Прогресс — это
загружено / всего, число от 0 до 1; умножай его на ширину полоски, чтобы рисовать заполнение. - Не забывай
onerrorи вешай обработчики до присвоенияsrc.
В прошлом уроке про сущности и компоненты мы научились описывать игровые объекты данными. Теперь мы умеем заранее подгружать всё, что этим объектам нужно для жизни — спрайты и звуки. В следующем уроке соберём это вместе и разберёмся, как переключать игровые состояния: меню, игра, пауза и экран проигрыша — чтобы прелоадер плавно перетекал в главное меню, а оттуда в саму игру про цыплёнка.