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

## 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()` сам. Группа ключей
грузится параллельно (один вызов `Assets.load` с массивом, `onProgress` — доля
по всем ключам); все текстуры группы получают `nearest`.

### Атласы

```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)).

## 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();

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);                                        // параметры в полёте
h?.setRate(1.3);
h?.setPan(0.6);                                           // panner создастся, если его не было
h?.stop(0.05);                                            // короткое затухание
await audio.playMusic('meadows_theme', { fade: 2 });      // зациклится с нарастанием 2 сек
audio.stopMusic(1);                                       // затухание 1 сек
await audio.playAmbience('meadows', { fade: 3 });         // слот амбиента — отдельно от музыки
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` (заглушить прежние играющие экземпляры
того же ключа коротким фейдом), `bus` (шина голоса, по умолчанию sfx —
например UI-звук можно направить в master, минуя гейны sfx). Число вместо
объекта — синтаксический сахар для `{ volume }` (старые вызовы не надо
мигрировать).

`play` возвращает `SfxHandle | null` (null — ключ не декодировался, контекст
не готов). Хендл умеет `stop(fadeSeconds?)`, `setVolume`, `setPan`, `setRate`;
после `stop` и естественного конца звука хендл «мёртв» — все методы no-op.
Полифония ограничена `maxVoices` (третий аргумент конструктора, по умолчанию
24, 0 — без лимита): при переполнении воруется самый тихий играющий голос.

Затухания лупов (`stopMusic`, `stopAmbience`, `MusicHandle.stop`, вор и
кроссфейд) — экспоненциальные: слуху линейный спад слышен «ступенькой».
Нарастание при старте лупа остаётся линейным (экспонента не определена от
нуля).

Таб ушёл в фон — звук сам замолкает (`ctx.suspend`), вернулся — сам
возобновляется; опция конструктора `pauseOnHide` (по умолчанию true)
это отключает. В средах без DOM (тесты движка) listener не ставится.

`playBuffer(key, buffer, opts?)` — тот же sfx, но из **буфера в памяти**
(процедурные звуки, синтезированные в рантайме): ключ — логическое имя голоса
(дедуп, restart, полифония, шпион `onPlayed`), не файл. Пара к нему —
`createBuffer(data: Float32Array, sampleRate = 22050)`: моно-сэмплы →
`AudioBuffer` (null, пока контекст не разблокирован — после первого
`play`/`unlock` работает). Чистые примитивы синтеза (bandNoise, synth, decay,
normalize, makeRng) идут с движком в `@rpg/engine/tools/synth.mjs` — без
node-импортов, готовы для браузера:

```ts
import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs';

const samples = normalize(synth(0.12, (t) => noise() * decay(t, 0.12, 2)), 0.3);
const buf = audio.createBuffer(samples);
if (buf) await audio.playBuffer('sfx/step_grass#0', buf, { volume });
// до unlock буфер не создать — fallback на базовый спек audio.playSpec('sfx/step_grass')
```

### Спек-синтез: playSpec (звук по описанию)

`playSpec(key, spec, opts?)` — sfx из **описания звука** (`SoundSpec` из
`audio/SoundSpec.ts`, `renderSpec` детерминированно компилирует спек в сэмплы).
Адресат — ИИ-агент: описывает звук параметрами (без слуха и без библиотек
сэмплов), движок компилирует и играет; буфер кэшируется по ключу (компиляция
один раз), факт запуска виден в шпионе `onPlayed`. Примитивы те же, что у
файлового генератора, — спек-звук звучит в одной палитре с WAV-звуком.

```ts
// kind — форма звука; остальное — параметры с дефолтами по kind
await audio.playSpec('agent/door', {
    kind: 'scrape',        // hit | chime | scrape | hum | tone
    dur: 0.5,              // сек (клэмп 0.05..4; hum до 8)
    low: 80, high: 400,    // полоса шума (hit/scrape)
    tone: 60,              // низкая подложка / основной тон гула
    power: 1.5,            // крутизна затухания / «дыхание» hum
    seed: 7,               // сид шума/клика — детерминизм
    peak: 0.5,             // нормализация 0..1
    swell: false,          // огибающая «0 → 1 → 0» вместо спада (воздух, свуш)
    attack: 0.08,          // нарастание, сек (шипение, скрип)
    fadeTo: 0.5,           // линейный спад громкости к этому множителю к концу
    wobbleHz: 6,           // тремоло: амплитуда гуляет wobbleDepth вокруг wobbleBase
    wobbleDepth: 0.3,
    layers: [{ freq: 165, w: 0.25 }]  // доп. голоса: тон (freq) или шум (low/high)
});
await audio.playSpec('agent/coin', { kind: 'chime', dur: 0.6, freq: 880 }, 0.4);
await audio.playSpec('agent/pew', { kind: 'tone', dur: 0.2, freq: 700, freqTo: 350, power: 3 });
```

Формы: `hit` — полоса шума с резким спадом + низкая подложка (удары);
`chime` — четыре негармоничных партиала + клик (звон, монеты, подбор);
`scrape` — низкая полоса с медленным спадом (дверь, куст, ветка), `swell: true`
превращает спад в «0 → 1 → 0» (воздух, свуш); `hum` — два тона
(tone и tone×1.5) на «дыхании», луп по умолчанию (гул, эмбиент-слой);
`tone` — чистый синус `freq` (со свипом `freqTo` — «пью», «пок») без шума.
Голос слоя (`SpecVoice`): `freq` или полоса `low`/`high`, вес `w`, своя
`power`/`dur`, `attack`, `delay` (блип в конце — стук копыт). Вес основного
тела — `w` спека. Тот же `renderSpec` экспортируется из публичного API —
можно компилировать в сэмплы без воспроизведения (офлайн-генерация, тесты).

Типы для `synth.mjs` лежат рядом в `synth.d.mts` — декларация **рядом с .mjs**
(ambient `declare module` не работает: TS резолвит specifier к реальному
файлу и игнорирует ambient-имя).

### Музыка: renderMusic/renderMusicStereo + playLoopBuffer (тема по партитуре)

Секвенсор движка (`audio/Music.ts`): партитура `MusicSpec` — темп (`bpm`),
длина лупа в долях (`beats`) и партии-стемы (`tracks`: инструмент + ноты
`{beat, dur, midi, vel}` + `gain` + `pan`). Инструменты — осциллятор +
огибающая: `pluck` (сумма партиалов с затуханием), `bell` (негармоничные
партиалы), `pad` (расстроенная пара синусов, мягкая атака/релиз).
`renderMusic(spec)` — чистая функция (без ГПСЧ: детерминизм мелодии
обеспечивают сиды генератора нот на стороне игры/агента): ноты суммируются,
луп сшивается кроссфейдом и нормализуется к пику 0.8. Стерео-вариант
`renderMusicStereo(spec)` возвращает interleaved L,R (моно-сэмплы прежние):
`pan` трека (-1..1) раскладывается по equal-power панораме, нормализация —
по общему пику каналов (баланс не искажается).

```ts
import { RATE, renderMusicStereo, type MusicSpec } from '@rpg/engine';

const spec: MusicSpec = {
    bpm: 70,
    beats: 16,
    tracks: [
        { instrument: 'pad', notes: [{ beat: 0, dur: 8, midi: 45, vel: 0.5 }], pan: -0.3 },
        { instrument: 'pluck', notes: [{ beat: 4, dur: 0.5, midi: 69, vel: 0.4 }], pan: 0.3 }
    ]
};
const buf = audio.createBuffer(renderMusicStereo(spec), RATE, 2); // до unlock — null
if (buf) await audio.playLoopBuffer('music/meadows', buf, { fade: 2, volume: 0.5 });
```

`playLoopBuffer(key, buffer, {loop, fade, volume, bus})` — луп из памяти
на любой шине (по умолчанию music), ключ — логическое имя слота для шпионов;
хендл тот же `MusicHandle`. Лупы-слои с независимой громкостью (спокойно/
настороженно/бой) — это тот же `playLoop`/`playLoopBuffer` без слота +
тик-фейд поверх `MusicHandle.setVolume` (см. `playLoop` ниже).

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

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

Шпионский хук `onPlayed?: (key, opts) => void` вызывается при каждом
фактическом запуске звука — и sfx, и лупов (музыка/амбиент/слои; опции
нормализованы, rate 1/pan 0) — для тестов и DEV-лога игры (кольцо
`window.__gameAudioLog`, им пользуется агентная проба `checks/audio.mjs`:
на слух в headless не проверить — смотрят фактические запуски).
`onEnded?(key)` зовётся, когда звук доиграл до конца; заглушенные голосом
(stop, воровство, restart) окончания событие не поднимают.
`duration(key)` — длительность буфера в секундах (null, если ещё не
декодирован) — для катсцен и генераторов, синхронизирующихся со звуком.

Связка с 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`), в сейв они не попадают.

### Метаданные слота

Заголовок/время слота — в параллельном ключе `meta:<slot>` (формат самих
сейвов не меняется; у сейвов, записанных без меты, `slotMeta` вернёт `null`):

```ts
saves.save('slot1', data, { title: 'Луга', savedAt: Date.now(), version: 3,
                            extras: { area: 'meadows' } });
const meta = saves.slotMeta('slot1'); // без полной загрузки сейва
const rows = saves.list();            // [{ slot, meta: SaveSlotMeta | null }]
saves.delete('slot1');                // чистит и сейв, и мету
```

`listSlots()` возвращает только сейвы — мета-ключи в список не попадают.

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

```ts
interface SaveData {
    pos: { x: number; y: number };
    state: GameStateData;      // из gameState.serialize()
    items: Record<string, number>; // сумка
    clock?: { minutes: number } | null; // время суток (v4; нет — старт 8:00)
    savedAt: number;           // Date.now()
}

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

Сейв старого формата дорастает до текущего `normalizeSave`-ом (дефолты без
записи) при загрузке. Время суток живёт в самом сейве (не во флагах) — оно
непрерывное и не принадлежит контентной оси флагов/варов.

Версионирование контента — через `GameState.dataVersion` + `addMigration`:
при изменении формата флагов старые сейвы чинятся автоматически при загрузке
(миграции игры регистрируются один раз при старте — `registerStateMigrations`
в `Game`; контракт — в core.md). Версия самого сейва (`version` в SaveData
и в мете) — отдельная ось от `GameStateData.version`.