import {
Engine,
SaveManager,
AssetLoader,
AudioManager,
GameState,
Settings,
Inventory,
parseMap,
RATE,
renderMusicStereo,
type MusicHandle,
type Renderer,
type SceneManager,
type TileMapData
} from '@rpg/engine';
import { AUDIO_KEYS } from './data/audio';
import { THEMES } from './data/music';
// Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL.
import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url';
/**
* Игровой контекст: движок, сохранения, загрузчик ассетов, состояние, настройки.
* Движок ничего не знает об этом классе — он только потребляет его API.
*/
export class Game {
/** Виртуальное разрешение (пиксель-арт). */
static readonly VIRTUAL_W = 480;
static readonly VIRTUAL_H = 270;
readonly saves: SaveManager;
readonly assets: AssetLoader;
/** Звуки и музыка (WAV из apps/game/tools/audio/gen.mjs). */
readonly audio = new AudioManager((key) => `${import.meta.env.BASE_URL}audio/${key}.wav`);
/** Флаги и переменные прохождения. */
readonly state = new GameState();
/** Сумка героя (предметы квестов и торговли). */
readonly inventory = new Inventory();
/** Настройки игрока (громкости и т.п.) — вне сейвов. */
readonly settings = new Settings(window.localStorage);
/** Текстуры тайлов и одиночные спрайты NPC. */
static readonly ASSET_KEYS = [
'tiles/grass',
'tiles/grass_2',
'tiles/grass_3',
'tiles/path',
'tiles/path_2',
'tiles/water_1',
'tiles/water_2',
'tiles/ash',
'tiles/ash_2',
'tiles/decor_tuft',
'tiles/decor_stone',
'tiles/decor_bald',
'tiles/bellflower',
'tiles/tree',
'tiles/house',
'tiles/tower',
'tiles/floor',
'tiles/wall',
'tiles/well'
];
/** Ключи аудио (WAV): sfx + амбиент-лупы областей (реестр в data/audio.ts). */
static readonly AUDIO_KEYS = AUDIO_KEYS;
constructor(readonly engine: Engine) {
this.saves = new SaveManager(window.localStorage);
// Ключи без расширения -> .png; ключи .json (атласы) резолвятся как есть.
this.assets = new AssetLoader((key) =>
key.endsWith('.json') ? `${import.meta.env.BASE_URL}${key}` : `${import.meta.env.BASE_URL}${key}.png`
);
this.settings.load();
// Настройки громкости -> шины аудио (до unlock шин нет — применится после).
this.settings.onChange((s) => {
this.audio.setBusVolume('master', s.master);
this.audio.setBusVolume('ambience', s.ambience);
this.audio.setBusVolume('sfx', s.sfx);
});
// Применить текущие значения сразу.
this.audio.setBusVolume('master', this.settings.data.master);
this.audio.setBusVolume('ambience', this.settings.data.ambience);
this.audio.setBusVolume('sfx', this.settings.data.sfx);
// Хук для смоук-тестов (apps/game/tools/smoke*.mjs): доступ к контексту из страницы.
if (import.meta.env.DEV) {
(window as unknown as { __game?: Game }).__game = this;
// Шпион аудио: последние 24 запуска (sfx + лупы) — для агентных
// проверок звука (на слух в headless не проверить, tools/checks/audio.mjs).
const audioLog: { key: string; t: number; volume: number; pan: number }[] = [];
this.audio.onPlayed = (key, opts) => {
audioLog.push({ key, t: Math.round(performance.now()), volume: opts.volume ?? 1, pan: opts.pan ?? 0 });
if (audioLog.length > 24) audioLog.shift();
};
(window as unknown as { __gameAudioLog?: typeof audioLog }).__gameAudioLog = audioLog;
// Агентный мост: window.__agent (см. docs/engine/agent.md, apps/game/tools/agent.mjs).
import('./agent/GameAgent').then(({ registerAgent }) => registerAgent(this));
}
}
/** URL пиксельного шрифта (VT323 идёт вместе с движком, OFL). */
static readonly FONT_URL: string = fontUrl;
/** Карты локаций, распарсенные из assets/maps/<id>.map (формат rpg-map). */
readonly mapFiles = new Map<string, TileMapData>();
/** Загрузить и распарсить карту локации (вызывается в BootScene). */
async loadMap(id: string): Promise<TileMapData> {
const res = await fetch(`${import.meta.env.BASE_URL}maps/${id}.map`);
if (!res.ok) throw new Error(`Карта ${id}: HTTP ${res.status}`);
const data = parseMap(await res.json());
this.mapFiles.set(id, data);
return data;
}
get scenes(): SceneManager {
return this.engine.scenes;
}
get renderer(): Renderer {
return this.engine.renderer;
}
get camera() {
return this.engine.camera;
}
get worldRoot() {
return this.engine.renderer.worldRoot;
}
/** Амбиент области с кроссфейдом; повторный вызов с тем же ключом не перезапускает его. */
playAmbience(key: string, volume = 0.7): void {
if (this.ambienceKey === key) return;
this.ambienceKey = key;
if (key === '') {
this.audio.stopAmbience(1.5);
return;
}
void this.audio.playAmbience(key, { fade: 1.5, volume });
}
/** Текущий амбиент (защита от перезапуска того же ключа; '' — тишина). */
private ambienceKey: string | null = null;
/**
* Тема области (спек-музыка из data/music.ts, компилируется в рантайме):
* кроссфейд при смене, повторный вызов с тем же ключом не перезапускает,
* '' — тишина. Буфер темы кэшируется — компиляция один раз на ключ.
*/
playTheme(key: string): void {
if (this.themeKey === key) return;
this.themeKey = key;
this.themeHandle?.stop(2);
this.themeHandle = null;
const spec = THEMES[key];
if (!spec) return;
let buf = this.themeBuffers.get(key);
if (!buf) {
const created = this.audio.createBuffer(renderMusicStereo(spec), RATE, 2);
if (!created) {
this.themeKey = null; // до unlock — компиляция при следующем входе
return;
}
buf = created;
this.themeBuffers.set(key, buf);
}
void this.audio.playLoopBuffer(`music/${key}`, buf, { fade: 2, volume: 0.5 }).then((handle) => {
// Тема сменилась, пока луп заводился, — гасим без хвостов.
if (this.themeKey !== key) {
handle?.stop(1);
return;
}
this.themeHandle = handle;
});
}
private themeKey: string | null = null;
private themeHandle: MusicHandle | null = null;
private themeBuffers = new Map<string, AudioBuffer>();
}