Загрузчик поверх Assets из Pixi. URL решает приложение — движок получает функцию-резолвер:
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() сам.
// 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); готовые списки удобно отдавать в SpriteAnimator (anim.md). Пример генерации атласа — в art-pipeline.md.
Размер кадра в рантайме — из атласа, не из констант:
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).
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); // сменить кадры (поворот, состояние)
Четыре шины (master/music/sfx/ambience), кроссфейд лупов, опции sfx (громкость/скорость/панорама), кэш сэмплов. Фабрика AudioContext инъецируется (в тестах — фейк), как StorageLike у SaveManager:
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
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); // громкость шины
Опции 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-импортов, готовы для браузера:
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(key, spec, opts?) — sfx из описания звука (SoundSpec из audio/SoundSpec.ts, renderSpec детерминированно компилирует спек в сэмплы). Адресат — ИИ-агент: описывает звук параметрами (без слуха и без библиотек сэмплов), движок компилирует и играет; буфер кэшируется по ключу (компиляция один раз), факт запуска виден в шпионе onPlayed. Примитивы те же, что у файлового генератора, — спек-звук звучит в одной палитре с WAV-звуком.
// 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-имя).
Секвенсор движка (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 панораме, нормализация — по общему пику каналов (баланс не искажается).
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 останавливает конкретный): база для локальных амбиент-слоёв (вода, гул) — игра поверх амбиента области.
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 (громкости применяются при изменении):
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 игнорируется, громкости применяются после него.
JSON-слоты в Storage-подобном хранилище (localStorage в браузере, Map в тестах):
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):
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() возвращает только сейвы — мета-ключи в список не попадают.
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.