# Ассеты, аудио, сейвы

## AssetLoader

Загрузчик поверх `Assets` из Pixi. URL решает приложение — движок получает функцию-резолвер:

```ts
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));

assets.texture('tiles/grass'); // Texture; бросает, если не загружено
assets.has('tiles/water_1');
```

**Грабли**: `Assets.load` без предварительного `Assets.init({})` висит навсегда
без ошибок в консоли. AssetLoader делает `ensureInit()` сам.

### Атласы

```ts
// Spritesheet JSON должен ссылаться на PNG относительным путём — Pixi загрузит сам
const sheet = await assets.loadAtlas('chars/hero_sheet');

assets.frames('chars/hero_sheet', 'hero_walk');   // кадры hero_walk_1, hero_walk_2, ...
assets.animation('chars/hero_sheet', 'hero_walk_down'); // готовая анимация из sheet.animations
```

`frames()` сортирует кадры по числу в конце имени. `animation()` возвращает
кадры из `sheet.animations` (их пишут генераторы — `buildAtlas(..., { animations })`,
см. [art-pipeline.md](art-pipeline.md)); готовые списки удобно отдавать в
`SpriteAnimator` (`anim.md`). Пример генерации атласа — в [art-pipeline.md](art-pipeline.md).

Размер кадра в рантайме — из атласа, не из констант:

```ts
assets.frameSize('chars/hero_sheet.json');              // { w: 16, h: 24 } — первый кадр
assets.frameSize('chars/npcs_sheet.json', 'trader_mila_1'); // конкретный кадр (sourceSize)
```

Смещения над головой персонажа (маркер, полоска HP) считайте от `frameSize`,
а не от захардкоженной высоты. Источник номиналов для генераторов —
`apps/game/tools/pixelart/sizes.mjs` (см. [art-pipeline.md](art-pipeline.md)).

## FrameAnimation

```ts
import { FrameAnimation } from '@rpg/engine';

const sprite = new Sprite(frames[0]);
const anim = new FrameAnimation(sprite, frames, 8 /* fps */, true /* loop */);
anim.update(dt);          // каждый тик
anim.setFrames(otherFrames, true); // сменить кадры (поворот, состояние)
```

## AudioManager

Четыре шины (master/music/sfx/ambience), кроссфейд лупов, опции sfx
(громкость/скорость/панорама), кэш сэмплов. Фабрика AudioContext инъецируется
(в тестах — фейк), как `StorageLike` у `SaveManager`:

```ts
import { AudioManager } from '@rpg/engine';

const audio = new AudioManager((key) => `audio/${key}.wav`);

// Расблокировать из обработчика пользовательского ввода (требование браузеров):
uiButton.onSelect = () => void audio.unlock();

await audio.load('bell');
await audio.play('bell', 0.8);                            // sfx, громкость 0.8
await audio.play('bell', { volume: 0.8, rate: 1.1, pan: -0.4 }); // скорость + панорама
await audio.playMusic('meadows_theme', { fade: 2 });      // зациклится с нарастанием 2 сек
audio.stopMusic(1);                                       // затухание 1 сек
await audio.playAmbience('meadows', { fade: 3 });         // слот амбиента — отдельно от музыки
audio.setBusVolume('music', 0.6);                         // громкость шины
```

Опции `play` (`PlayOptions`): `volume` (по умолчанию 1), `rate` (0.5..2 —
джиттер шагов, вариации тона), `pan` (-1..1; при |pan| < 0.01 узел
StereoPanner не создаётся). Число вместо объекта — синтаксический сахар для
`{ volume }` (старые вызовы не надо мигрировать).

Слоты лупов: `playMusic` и `playAmbience` — независимые слоты с кроссфейдом
(повторный вызов вытесняет прежний трек своего слота; негодный ключ не глушит
текущий). `playLoop` — луп **без слота** (занимать нечего, сколько вызвали —
столько играет, `MusicHandle.stop` останавливает конкретный): база для
локальных амбиент-слоёв (вода, гул) — игра поверх амбиента области.
У `MusicHandle` есть `setVolume(v)` — мгновенная громкость для медленных
тик-фейдов поверх (сам движок фейд не тикает):

```ts
const layer = await audio.playLoop('ambience/ponds_water', { bus: 'ambience', fade: 2 });
// ...
layer?.stop(2);
```

Шпионский хук `onPlayed?: (key, opts) => void` вызывается при каждом
фактическом запуске sfx — для тестов и DEV-лога игры (кольцо
`window.__gameAudioLog`, им пользуется агентная проба `checks/audio.mjs`:
на слух в headless не проверить — смотрят фактические запуски).

Связка с Settings (громкости применяются при изменении):

```ts
settings.onChange((s) => {
    audio.setBusVolume('master', s.master);
    audio.setBusVolume('music', s.music);
    audio.setBusVolume('sfx', s.sfx);
    audio.setBusVolume('ambience', s.ambience);
});
```

`play`/`playMusic`/`playAmbience` не падают, если звук не загружен или
AudioContext не разблокирован — тихо ничего не делают. До `unlock` шин ещё
нет — `setBusVolume` игнорируется, громкости применяются после него.

## SaveManager

JSON-слоты в Storage-подобном хранилище (localStorage в браузере, Map в тестах):

```ts
import { SaveManager } from '@rpg/engine';

const saves = new SaveManager(localStorage);
saves.save('autosave', { pos: { x: 5, y: 5 }, state: gameState.serialize() });
const data = saves.load<typeof myData>('autosave');
saves.has('autosave');
saves.delete('autosave');
```

Что класть в сейв: позицию/прогресс игры + `gameState.serialize()`.
Настройки — отдельно через `Settings` (слот `settings`), в сейв они не попадают.

### Рекомендуемая схема сейва игры

```ts
interface SaveData {
    pos: { x: number; y: number };
    state: GameStateData;      // из gameState.serialize()
    savedAt: number;           // Date.now()
}

// Загрузка:
const data = saves.load<SaveData>('autosave');
if (data) gameState.load(data.state);
```

Версионирование контента — через `GameState.dataVersion` + `addMigration`:
при изменении формата флагов старые сейвы чинятся автоматически при загрузке.