Коротко
Ресурсы браузерной игры загружают по манифесту, где у каждого файла записан размер, и считают прогресс по байтам: в нашем примере три картинки — это 43% файлов, но только 26% байтов уровня. Запросы идут не больше 4 одновременно, полоса прогресса плавно догоняет реальное значение и никогда не откатывается, а экран загрузки появляется, только если ожидание дольше 300 мс. Следующий уровень подгружают заранее через общий кэш обещаний, поэтому один файл не скачивается дважды.
Содержание
- Манифест: что и сколько весит
- Почему прогресс считают по байтам
- Сколько запросов отправлять одновременно
- Картинки, звук и данные: как дождаться готовности
- Полоса прогресса без рывков и откатов
- Когда показывать экран загрузки
- Подгрузка следующего уровня по требованию
- Код на JavaScript
- Практический сценарий: игра из трёх уровней
- Когда вам это не подойдёт
Манифест: что и сколько весит
Манифест — это список файлов уровня с типом и размером в байтах, который собирается вместе с игрой. Размер в манифесте нужен, потому что сервер не обязан сообщать его заранее: по MDN, в HTTP/1.1 вместо заголовка Content-Length может прийти Transfer-Encoding: chunked, а в HTTP/2 этот заголовок необязателен.
Пример манифеста первого уровня (размеры условные):
| Файл | Тип | Размер |
|---|---|---|
hero.png | картинка | 180 КБ |
tiles.png | картинка | 420 КБ |
enemies.png | картинка | 260 КБ |
music-1.ogg | звук | 2 400 КБ |
jump.ogg | звук | 18 КБ |
coin.ogg | звук | 12 КБ |
level-1.json | данные | 40 КБ |
| Итого | 3 330 КБ |
Почему прогресс считают по байтам
Прогресс по байтам показывает долю реально скачанного, а прогресс по числу файлов — нет. Когда загружены три картинки из семи файлов, полоса «по файлам» стоит на 43%, хотя скачано 860 из 3 330 КБ, то есть 26%. Дальше полоса быстро проскочит два маленьких звука и надолго застынет на музыке — игрок решит, что игра зависла.
прогресс = Σ min(скачано_i, размер_i) ÷ Σ размер_i
min защищает от случая, когда файл на сервере оказался больше, чем записано в манифесте: полоса не уйдёт за 100%.
Сколько запросов отправлять одновременно
Ограничьте загрузку 4 одновременными запросами — это наш стартовый выбор, а не стандарт браузера. Если отправить сразу 200 запросов для мелких спрайтов, первыми придут случайные файлы, а нужные для первого экрана будут ждать в общей очереди. Очередь с ограничением сохраняет порядок манифеста: сначала то, без чего нельзя начать, потом остальное.
Ставьте в начало манифеста файлы первого экрана, а музыку — в конец: игру можно запускать, пока музыка ещё догружается.
Картинки, звук и данные: как дождаться готовности
Каждый тип файла считается готовым в свой момент:
- Картинка готова, когда выполнилось обещание
img.decode(). По MDN, оно выполняется, когда изображение загружено и раскодировано, и бросаетEncodingError, если запрос не удался или данные повреждены. Метод доступен во всех основных браузерах с января 2020 года. - Звук для Web Audio загружают как байты через
fetch, а затем раскодируют черезAudioContext.decodeAudioData(подробнее о звуке — в статье по ссылке в конце). - Данные уровня готовы после
response.json().
Для прогресса по байтам тело ответа читают по частям: по MDN, response.body — это поток ReadableStream, его можно перебрать через for await...of и после каждой части прибавлять её длину.
Полоса прогресса без рывков и откатов
Полоса прогресса должна плавно догонять реальное значение и никогда не уменьшаться. Мы сглаживаем её экспоненциальной формулой, которая не зависит от частоты кадров:
показано += (max(показано, цель) − показано) × (1 − e^(−10·dt))
При 60 кадрах в секунду полоса проходит 90% разрыва до цели за 14 кадров, примерно 0,23 с. max не даёт полосе ехать назад, если при повторной попытке загрузки цель вдруг уменьшилась.
Когда показывать экран загрузки
Экран загрузки показывают, только если загрузка дольше 300 мс, и держат его не меньше 500 мс. Оба порога — наш выбор: короткая загрузка без экрана выглядит как мгновенный переход, а экран, мелькнувший на 80 мс, выглядит как сбой.
| Загрузка длится | Экран | Игрок ждёт всего |
|---|---|---|
| 120 мс | не показываем | 120 мс |
| 450 мс | показываем с 300 мс | 800 мс |
| 2 400 мс | показываем с 300 мс | 2 400 мс |
Наш уровень на 3 330 КБ без учёта задержек запросов скачивается за 2,08 с при 1,6 МБ/с, за 0,67 с при 5 МБ/с и за 0,17 с при 20 МБ/с, — то есть у одних игроков экран будет, у других нет, и обе ветки нужно проверить.
Подгрузка следующего уровня по требованию
Следующий уровень начинают загружать заранее, пока игрок проходит текущий, а при входе в него ждут только то, что не успело скачаться. Чтобы файл, общий для двух уровней, не запросился дважды, кэш хранит не готовые ресурсы, а обещания: второй вызов получает то же обещание, что и первый.
Удобный момент для подгрузки — когда игрок прошёл половину уровня или открыл дверь к выходу. Не начинайте её во время боя с боссом: сеть и раскодирование картинок конкурируют с игрой за время кадра.
Код на JavaScript
Код содержит прогресс по байтам, загрузку с ограничением запросов, плавную полосу, правило экрана загрузки и кэш обещаний. Сеть в проверке заменена имитацией, поэтому код запускается в Node.js 24, вывод совпадает с комментариями.
const LEVEL_1 = [
{ url: 'hero.png', size: 180_000 },
{ url: 'tiles.png', size: 420_000 },
{ url: 'enemies.png', size: 260_000 },
{ url: 'music-1.ogg', size: 2_400_000 },
{ url: 'jump.ogg', size: 18_000 },
{ url: 'coin.ogg', size: 12_000 },
{ url: 'level-1.json', size: 40_000 },
];
// Прогресс по байтам из манифеста
function progress(manifest, loaded) {
const total = manifest.reduce((s, a) => s + a.size, 0);
const done = manifest.reduce((s, a) => s + Math.min(loaded[a.url] ?? 0, a.size), 0);
return done / total;
}
// Общий кэш обещаний: один файл — один запрос, даже если его просят два уровня
const cache = new Map();
function getAsset(asset, loadOne, onBytes) {
if (!cache.has(asset.url)) cache.set(asset.url, loadOne(asset, onBytes));
return cache.get(asset.url);
}
// Загрузка в порядке манифеста, не больше parallel запросов одновременно
async function loadAll(manifest, loadOne, parallel = 4, onProgress) {
const loaded = {};
const queue = [...manifest];
const result = {};
async function worker() {
while (queue.length) {
const asset = queue.shift();
result[asset.url] = await getAsset(asset, loadOne, (bytes) => {
loaded[asset.url] = bytes;
onProgress?.(progress(manifest, loaded));
});
loaded[asset.url] = asset.size; // файл из кэша тоже считается скачанным
onProgress?.(progress(manifest, loaded));
}
}
await Promise.all(Array.from({ length: parallel }, worker));
return result;
}
// Полоса догоняет цель плавно и не едет назад
function updateBar(bar, dt) {
const k = 1 - Math.exp(-10 * dt);
bar.shown += (Math.max(bar.shown, bar.target) - bar.shown) * k;
return bar.shown;
}
// Экран загрузки: после 300 мс ожидания и минимум на 500 мс
function loadingScreenPlan(loadMs, showAfter = 300, minShown = 500) {
if (loadMs <= showAfter) return { show: false, totalMs: loadMs };
return { show: true, totalMs: Math.max(loadMs, showAfter + minShown) };
}
// ---- Проверка: имитация сети вместо fetch ----
let requests = 0;
function fakeLoad(asset, onBytes) {
requests++;
return new Promise((resolve) => {
let got = 0;
const step = () => {
got = Math.min(asset.size, got + 4000);
onBytes(got);
if (got >= asset.size) resolve(asset.url);
else setImmediate(step);
};
step();
});
}
console.log(progress(LEVEL_1, { 'hero.png': 180000, 'tiles.png': 420000, 'enemies.png': 260000 }).toFixed(2)); // 0.26
let last = 0, wentBack = false;
await loadAll(LEVEL_1, fakeLoad, 4, (p) => { if (p < last) wentBack = true; last = p; });
console.log(last.toFixed(2), wentBack, requests); // 1.00 false 7
// Второй уровень делит с первым tiles.png и jump.ogg — повторных запросов нет
const LEVEL_2 = [LEVEL_1[1], LEVEL_1[4], { url: 'level-2.json', size: 52_000 }, { url: 'boss.png', size: 310_000 }];
await loadAll(LEVEL_2, fakeLoad);
console.log(requests); // 9
const bar = { shown: 0, target: 0.5 };
let frames = 0;
while (updateBar(bar, 1 / 60) < 0.45) frames++;
console.log(frames + 1); // 14
bar.target = 0.2;
console.log(updateBar(bar, 1 / 60) >= 0.45); // true
for (const ms of [120, 450, 2400]) console.log(ms, JSON.stringify(loadingScreenPlan(ms)));
// 120 {"show":false,"totalMs":120}
// 450 {"show":true,"totalMs":800}
// 2400 {"show":true,"totalMs":2400}
В браузере fakeLoad заменяет настоящий загрузчик. Для картинки он выглядит так:
// Только для браузера: картинка с прогрессом по байтам и раскодированием
async function loadImage(asset, onBytes) {
const response = await fetch(asset.url);
if (!response.ok) throw new Error(asset.url + ': ' + response.status);
const parts = [];
let got = 0;
for await (const part of response.body) {
parts.push(part);
got += part.length;
onBytes(got);
}
const img = new Image();
img.src = URL.createObjectURL(new Blob(parts));
await img.decode(); // готова к отрисовке без задержки в первом кадре
return img;
}
Практический сценарий: игра из трёх уровней
Возьмём платформер из трёх уровней, у каждого свой манифест, а tiles.png и звуки общие. Входные условия: первый экран должен появиться быстро, переход между уровнями — без долгого ожидания. Порядок работы:
- Сборка. Скрипт сборки проходит по папке ресурсов и пишет манифест каждого уровня с размерами файлов.
- Старт. Меню грузит только свои картинки; экран загрузки не нужен, если они укладываются в 300 мс.
- Уровень 1. Перед стартом —
loadAll(LEVEL_1)с полосой; музыка в конце манифеста, игра начинается, как только готовы картинки и данные. - Подгрузка. Когда игрок прошёл половину уровня, в фоне запускается
loadAll(LEVEL_2). - Переход. У двери выхода снова вызывается
loadAll(LEVEL_2)— он получает уже скачанные обещания из кэша и ждёт только остаток. - Проверка. В инструментах разработчика включите замедление сети и убедитесь, что полоса идёт ровно, без откатов, а на быстрой сети экран загрузки не мигает. Как искать просадки кадров при подгрузке — в статье про FPS по ссылке в конце.
Результат проверяется счётчиком запросов: на три уровня каждый файл запрашивается ровно один раз.
Когда вам это не подойдёт
Схема с манифестом и кэшем обещаний хороша для небольшой браузерной игры, но не всегда:
- Огромный открытый мир. Если мир подгружается кусками по мере движения камеры, нужен загрузчик с приоритетами по расстоянию до игрока и выгрузкой старых кусков, а не очередь по манифесту.
- Игра на один экран весом в 200 КБ. Экран загрузки и прогресс по байтам здесь лишние: загрузите всё одним
Promise.allи стартуйте. - Ресурсы, которые меняются без пересборки. Если файлы на сервере обновляются отдельно от игры, размеры в манифесте устареют; тогда либо генерируйте манифест на сервере, либо считайте прогресс по числу файлов и честно показывайте неточную полосу.
- Память важнее скорости. Кэш обещаний держит все ресурсы всех уровней; на слабых телефонах ресурсы прошедших уровней нужно удалять из кэша.
Мы разрабатываем J2DRU — браузерный 2D игровой движок, где потоковая загрузка ресурсов входит в проверенные внутри разработки основы. Движок в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).
Источники (проверено: ноябрь 2026): MDN — HTMLImageElement: decode() (обещание после загрузки и раскодирования, EncodingError, доступность с января 2020), MDN — Using readable streams (response.body как ReadableStream, перебор через for await...of), MDN — Content-Length (замена на Transfer-Encoding: chunked в HTTP/1.1, необязательность в HTTP/2). Размеры файлов, пороги 300 и 500 мс, лимит 4 запроса и время загрузки — наш пример и наш расчёт в Node.js 24.
Читайте также: