---
title: "Загрузка ресурсов браузерной игры: экран загрузки, честный прогресс и подгрузка уровней по требованию"
description: "Как загружать картинки, звук и данные уровня в браузерной 2D-игре: манифест с размерами файлов, прогресс по байтам вместо числа файлов, не больше 4 запросов одновременно, полоса без откатов назад, экран загрузки только для долгих загрузок и подгрузка следующего уровня заранее без повторных запросов. Код проверен в Node.js 24."
slug: zagruzka-resursov-brauzernoj-igry-ekran-zagruzki-i-progress
date: 2026-11-06
updated: 2026-11-06
scheduled_at: "2026-11-06T09:00:00+03:00"
rubric: ustrojstvo-2d-igr
tags: ["zagruzka-resursov", "javascript", "brauzernye-igry", "optimizaciya", "razrabotchikam"]
reading_time: 10
draft: false
summary: "Ресурсы игры загружают по манифесту, где у каждого файла записан размер, поэтому прогресс считают по байтам, а не по числу файлов: в нашем примере три картинки — это 43% файлов, но только 26% байтов. Запросы идут не больше 4 одновременно, полоса прогресса не откатывается назад, экран загрузки появляется только если загрузка дольше 300 мс, а следующий уровень подгружается заранее через общий кэш обещаний, чтобы один файл не запрашивался дважды."
---

## Коротко

Ресурсы браузерной игры загружают по манифесту, где у каждого файла записан размер, и считают прогресс по байтам: в нашем примере три картинки — это 43% файлов, но только 26% байтов уровня. Запросы идут не больше 4 одновременно, полоса прогресса плавно догоняет реальное значение и никогда не откатывается, а экран загрузки появляется, только если ожидание дольше 300 мс. Следующий уровень подгружают заранее через общий кэш обещаний, поэтому один файл не скачивается дважды.

## Содержание

1. Манифест: что и сколько весит
2. Почему прогресс считают по байтам
3. Сколько запросов отправлять одновременно
4. Картинки, звук и данные: как дождаться готовности
5. Полоса прогресса без рывков и откатов
6. Когда показывать экран загрузки
7. Подгрузка следующего уровня по требованию
8. Код на JavaScript
9. Практический сценарий: игра из трёх уровней
10. Когда вам это не подойдёт

## Манифест: что и сколько весит

Манифест — это список файлов уровня с типом и размером в байтах, который собирается вместе с игрой. Размер в манифесте нужен, потому что сервер не обязан сообщать его заранее: по 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%. Дальше полоса быстро проскочит два маленьких звука и надолго застынет на музыке — игрок решит, что игра зависла.

```text
прогресс = Σ 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` и после каждой части прибавлять её длину.

## Полоса прогресса без рывков и откатов

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

```text
показано += (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, вывод совпадает с комментариями.

```js
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` заменяет настоящий загрузчик. Для картинки он выглядит так:

```js
// Только для браузера: картинка с прогрессом по байтам и раскодированием
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` и звуки общие. Входные условия: первый экран должен появиться быстро, переход между уровнями — без долгого ожидания. Порядок работы:

1. **Сборка.** Скрипт сборки проходит по папке ресурсов и пишет манифест каждого уровня с размерами файлов.
2. **Старт.** Меню грузит только свои картинки; экран загрузки не нужен, если они укладываются в 300 мс.
3. **Уровень 1.** Перед стартом — `loadAll(LEVEL_1)` с полосой; музыка в конце манифеста, игра начинается, как только готовы картинки и данные.
4. **Подгрузка.** Когда игрок прошёл половину уровня, в фоне запускается `loadAll(LEVEL_2)`.
5. **Переход.** У двери выхода снова вызывается `loadAll(LEVEL_2)` — он получает уже скачанные обещания из кэша и ждёт только остаток.
6. **Проверка.** В инструментах разработчика включите замедление сети и убедитесь, что полоса идёт ровно, без откатов, а на быстрой сети экран загрузки не мигает. Как искать просадки кадров при подгрузке — в статье про FPS по ссылке в конце.

Результат проверяется счётчиком запросов: на три уровня каждый файл запрашивается ровно один раз.

## Когда вам это не подойдёт

Схема с манифестом и кэшем обещаний хороша для небольшой браузерной игры, но не всегда:

- **Огромный открытый мир.** Если мир подгружается кусками по мере движения камеры, нужен загрузчик с приоритетами по расстоянию до игрока и выгрузкой старых кусков, а не очередь по манифесту.
- **Игра на один экран весом в 200 КБ.** Экран загрузки и прогресс по байтам здесь лишние: загрузите всё одним `Promise.all` и стартуйте.
- **Ресурсы, которые меняются без пересборки.** Если файлы на сервере обновляются отдельно от игры, размеры в манифесте устареют; тогда либо генерируйте манифест на сервере, либо считайте прогресс по числу файлов и честно показывайте неточную полосу.
- **Память важнее скорости.** Кэш обещаний держит все ресурсы всех уровней; на слабых телефонах ресурсы прошедших уровней нужно удалять из кэша.

Мы разрабатываем **J2DRU** — браузерный 2D игровой движок, где потоковая загрузка ресурсов входит в проверенные внутри разработки основы. Движок в разработке и публично пока не открыт, заявку можно оставить на [странице раннего доступа](/early-access?utm_source=blog&utm_medium=article&utm_campaign=zagruzka-resursov-brauzernoj-igry-ekran-zagruzki-i-progress) (подача заявки не гарантирует приглашение).

**Источники (проверено: ноябрь 2026):** [MDN — HTMLImageElement: decode()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decode) (обещание после загрузки и раскодирования, EncodingError, доступность с января 2020), [MDN — Using readable streams](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams) (response.body как ReadableStream, перебор через for await...of), [MDN — Content-Length](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Length) (замена на Transfer-Encoding: chunked в HTTP/1.1, необязательность в HTTP/2). Размеры файлов, пороги 300 и 500 мс, лимит 4 запроса и время загрузки — наш пример и наш расчёт в Node.js 24.

**Читайте также:**
- [Как найти просадку FPS в браузерной 2D-игре: бюджет кадра, Chrome DevTools Performance, долгие задачи и сборка мусора](/blog/prosadki-fps-v-brauzernoj-igre-chrome-devtools-performance)
- [Звук в браузерной игре на Web Audio API: шины громкости на GainNode, автозапуск, лимит голосов и децибелы](/blog/zvuk-v-brauzernoj-igre-web-audio-api-shiny-i-golosa)
- [Спрайтовые листы и покадровая анимация: атлас, опорная точка, частота](/blog/sprajty-kadry-i-2d-animaciya-v-brauzere)
