diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 0fbfa8f..0ce79e3 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -147,6 +147,18 @@ void this.audio.playAmbience(key, { fade: 1.5, volume }); } + /** + * Зарегистрировать и декодировать аудио в фоне (ленивый префетч амбиента + * области при входе; playAmbience/playLoop и сами умеют грузить на месте — + * это только чтобы первый вход в область не тормозил). + */ + preloadAudio(keys: readonly string[]): void { + for (const key of keys) { + this.audio.register(key); + void this.audio.preload(key); + } + } + /** Текущий амбиент (защита от перезапуска того же ключа; '' — тишина). */ private ambienceKey: string | null = null; diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts index 72ac17e..b16f36b 100644 --- a/apps/game/src/data/audio.ts +++ b/apps/game/src/data/audio.ts @@ -28,4 +28,16 @@ ...SPEC_SFX_KEYS, ...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS -]; \ No newline at end of file +]; + +/** + * Файловые ключи аудио области (ambience + локальные слои) — для ленивого + * префетча: сцена загрузки декодирует только стартовую область, остальные + * декодируются при входе (playAmbience/playLoop умеют грузить на месте). + */ +export function areaAudioKeys(area: { + ambience?: string; + ambienceLayers?: readonly { key: string }[]; +}): string[] { + return [area.ambience ?? '', ...(area.ambienceLayers ?? []).map((l) => l.key)].filter((k) => k.length > 0); +} \ No newline at end of file diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index b694cd2..b772595 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -1,9 +1,12 @@ import { Container, Graphics, PixelText, ensurePixelFont, type Scene } from '@rpg/engine'; import { Game } from '../Game'; -import { AREAS } from '../data/locations'; -import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS } from '../data/audio'; +import { AREAS, areaOf } from '../data/locations'; +import { AMBIENCE_KEYS, AMBIENCE_LAYER_KEYS, areaAudioKeys } from '../data/audio'; import { MenuScene } from './MenuScene'; +/** Атласы персонажей (Spritesheet JSON + PNG). */ +const ATLAS_KEYS = ['chars/hero_sheet.json', 'chars/clumps_sheet.json', 'chars/fauna_sheet.json', 'chars/npcs_sheet.json']; + /** * Загрузочная сцена: грузит ассеты с прогресс-баром, затем меню. */ @@ -35,37 +38,7 @@ update(_dt: number): void { if (!this.started) { this.started = true; - void this.game.assets - .load(Game.ASSET_KEYS, (p) => { - this.progress = p; - }) - .then(async () => await this.game.assets.loadAtlas('chars/hero_sheet.json')) - .then(async () => await this.game.assets.loadAtlas('chars/clumps_sheet.json')) - .then(async () => await this.game.assets.loadAtlas('chars/fauna_sheet.json')) - .then(async () => await this.game.assets.loadAtlas('chars/npcs_sheet.json')) - .then(async () => { - // Карты областей: файлы .map (rpg-map, RLE) -> parseMap. - for (const id of Object.keys(AREAS)) { - await this.game.loadMap(id); - } - }) - .then(() => ensurePixelFont(Game.FONT_URL)) - // Звуки: декодируем заранее, чтобы первый play не тормозил. - // Sfx на спек-синтезе не грузим — их компилирует движок. - .then(async () => { - for (const key of [...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]) { - await this.game.audio.register(key); - await this.game.audio.preload(key); - } - }) - .then(() => { - this.progress = 1; - console.log('[boot] ассеты загружены, меню...'); // маяк для смоук-тестов - void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); - }) - .catch((err) => { - console.error('[boot] ошибка загрузки:', err); - }); + void this.loadAll(); } if (this.progress >= 0) { this.drawBar(this.progress); @@ -73,6 +46,56 @@ } } + /** + * Параллельная загрузка всех групп с общим прогресс-баром: текстуры (0.6), + * атласы (0.15), карты (0.15), шрифт + амбиент стартовой области (0.1). + * Остальные лупы амбиента декодируются лениво при входе в область + * (LocationScene.enter -> game.preloadAudio) — до меню их ждать не нужно. + */ + private async loadAll(): Promise { + const areaIds = Object.keys(AREAS); + let texP = 0; + let atlasP = 0; + let mapP = 0; + const setProgress = () => { + this.progress = texP * 0.6 + atlasP * 0.15 + mapP * 0.15; + }; + try { + await Promise.all([ + this.game.assets.load(Game.ASSET_KEYS, (p) => { + texP = p; + setProgress(); + }), + ...ATLAS_KEYS.map((key) => + this.game.assets.loadAtlas(key).then(() => { + atlasP += 1 / ATLAS_KEYS.length; + setProgress(); + }) + ), + ...areaIds.map((id) => + this.game.loadMap(id).then(() => { + mapP += 1 / areaIds.length; + setProgress(); + }) + ), + ensurePixelFont(Game.FONT_URL), + (async () => { + // Регистрация ключей дешёвая (URL в карту); декодируем только + // лупы стартовой области, чтобы амбиент сразу не тормозил. + for (const key of [...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]) { + this.game.audio.register(key); + } + await Promise.all(areaAudioKeys(areaOf(undefined)).map((k) => this.game.audio.preload(k))); + })() + ]); + this.progress = 1; + console.log('[boot] ассеты загружены, меню...'); // маяк для смоук-тестов + void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); + } catch (err) { + console.error('[boot] ошибка загрузки:', err); + } + } + private drawBar(p: number): void { this.bar.clear(); this.bar.rect(180, 136, 120, 6).fill(0x23232b); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index cecebe1..c7b4211 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -28,6 +28,7 @@ import { SAVE_VERSION, type SaveData } from './saveData'; import { InventoryScene } from './InventoryScene'; import { TILES } from '../data/map'; +import { areaAudioKeys } from '../data/audio'; import { playSfx } from '../data/sfxSpecs'; import { areaOf, type AreaDef, type AreaId, type TransitionDef } from '../data/locations'; import { AudioSystem } from '../systems/AudioSystem'; @@ -451,6 +452,8 @@ // Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина). this.game.playAmbience(this.area.ambience ?? ''); this.game.playTheme(this.area.theme ?? ''); + // Файловые лупы области — в фоне (сцена загрузки декодировала только стартовую). + this.game.preloadAudio(areaAudioKeys(this.area)); this.locationAudio.startRoomTone(); // Первая подсказка сбора: как брать лунные колокольчики (один раз). if (this.area.id === 'ponds' && !this.game.state.hasFlag(FLAGS.hint_bells)) { diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 6ecf1c4..6fea618 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -8,14 +8,16 @@ import { AssetLoader } from '@rpg/engine'; const assets = new AssetLoader((key) => `assets/${key}.png`); -await assets.load(['tiles/grass', 'chars/hero_down_1'], (p) => setProgressBar(p)); +await assets.load(['tiles/grass', 'chars/hero_down_1'], (p) => setProgressBar(p)); // параллельно assets.texture('tiles/grass'); // Texture; бросает, если не загружено assets.has('tiles/water_1'); ``` **Грабли**: `Assets.load` без предварительного `Assets.init({})` висит навсегда -без ошибок в консоли. AssetLoader делает `ensureInit()` сам. +без ошибок в консоли. AssetLoader делает `ensureInit()` сам. Группа ключей +грузится параллельно (один вызов `Assets.load` с массивом, `onProgress` — доля +по всем ключам); все текстуры группы получают `nearest`. ### Атласы @@ -57,7 +59,8 @@ // Расблокировать из обработчика пользовательского ввода (требование браузеров): uiButton.onSelect = () => void audio.unlock(); -await audio.load('bell'); +audio.register('bell'); // URL ключа (дёшево, без загрузки) +await audio.preload('bell'); // декод заранее (необязательно — см. ниже) await audio.play('bell', 0.8); // sfx, громкость 0.8 const h = await audio.play('bell', { volume: 0.8, rate: 1.1, pan: -0.4 }); // хендл голоса h?.setVolume(0.5); // параметры в полёте @@ -70,6 +73,11 @@ audio.setBusVolume('music', 0.6); // громкость шины ``` +`register` только сопоставляет ключу URL (без IO); файл читается и декодируется +при первом `play`/`preload`/`playLoop` (кэш с дедупликацией in-flight). Поэтому +большой амбиент можно грузить лениво: префетч только ключей стартовой области, +остальные — фоновым `preload` при входе в область. + Опции `play` (`PlayOptions`): `volume` (по умолчанию 1), `rate` (0.5..2 — джиттер шагов, вариации тона), `pan` (-1..1; при |pan| < 0.01 узел StereoPanner не создаётся), `restart` (заглушить прежние играющие экземпляры diff --git a/packages/engine/src/assets/AssetLoader.ts b/packages/engine/src/assets/AssetLoader.ts index 20d1f71..cad6f93 100644 --- a/packages/engine/src/assets/AssetLoader.ts +++ b/packages/engine/src/assets/AssetLoader.ts @@ -20,22 +20,23 @@ return this.initPromise; } - /** Загрузить группу ключей; onProgress получает долю 0..1. */ + /** Загрузить группу ключей параллельно; onProgress получает долю 0..1. */ async load(keys: string[], onProgress?: (p: number) => void): Promise { await this.ensureInit(); - let done = 0; - for (const key of keys) { - try { - const tex = await Assets.load(this.resolveUrl(key)); - // Пиксель-арт мира: nearest — чёткие квадратные пиксели на любом масштабе. - tex.source.scaleMode = 'nearest'; - this.textures.set(key, tex); - } catch (err) { - throw new Error(`Не удалось загрузить ${this.resolveUrl(key)}: ${String(err)}`, { cause: err }); - } - done++; - onProgress?.(done / keys.length); + const urls = keys.map((key) => this.resolveUrl(key)); + let loaded: Record; + try { + loaded = await Assets.load(urls, onProgress); + } catch (err) { + throw new Error(`Не удалось загрузить ассеты (${urls.join(', ')}): ${String(err)}`, { cause: err }); } + keys.forEach((key, i) => { + const tex = loaded[urls[i]!]; + if (!tex) throw new Error(`Не удалось загрузить ${urls[i]}: нет результата по URL`); + // Пиксель-арт мира: nearest — чёткие квадратные пиксели на любом масштабе. + tex.source.scaleMode = 'nearest'; + this.textures.set(key, tex); + }); } /**