diff --git a/apps/game/assets/audio/ambience/meadows.wav b/apps/game/assets/audio/ambience/meadows.wav new file mode 100644 index 0000000..3b958b8 --- /dev/null +++ b/apps/game/assets/audio/ambience/meadows.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/ponds.wav b/apps/game/assets/audio/ambience/ponds.wav new file mode 100644 index 0000000..f18cbe8 --- /dev/null +++ b/apps/game/assets/audio/ambience/ponds.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/zvenets.wav b/apps/game/assets/audio/ambience/zvenets.wav new file mode 100644 index 0000000..00ab46b --- /dev/null +++ b/apps/game/assets/audio/ambience/zvenets.wav Binary files differ diff --git a/apps/game/assets/audio/music/meadows.wav b/apps/game/assets/audio/music/meadows.wav deleted file mode 100644 index 3b958b8..0000000 --- a/apps/game/assets/audio/music/meadows.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/music/ponds.wav b/apps/game/assets/audio/music/ponds.wav deleted file mode 100644 index f18cbe8..0000000 --- a/apps/game/assets/audio/music/ponds.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/music/zvenets.wav b/apps/game/assets/audio/music/zvenets.wav deleted file mode 100644 index 00ab46b..0000000 --- a/apps/game/assets/audio/music/zvenets.wav +++ /dev/null Binary files differ diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index fed40c9..32ff004 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -11,6 +11,7 @@ type SceneManager, type TileMapData } from '@rpg/engine'; +import { AUDIO_KEYS } from './data/audio'; // Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; @@ -57,21 +58,8 @@ 'tiles/well' ]; - /** Ключи аудио (WAV): sfx + амбиент-лупы. */ - static readonly AUDIO_KEYS = [ - 'sfx/bell_hit', - 'sfx/bell_low', - 'sfx/ash_hiss', - 'sfx/ash_die', - 'sfx/spit', - 'sfx/hurt', - 'sfx/step', - 'sfx/ui_click', - 'sfx/chime', - 'music/meadows', - 'music/ponds', - 'music/zvenets' - ]; + /** Ключи аудио (WAV): sfx + амбиент-лупы областей (реестр в data/audio.ts). */ + static readonly AUDIO_KEYS = AUDIO_KEYS; constructor(readonly engine: Engine) { this.saves = new SaveManager(window.localStorage); @@ -80,15 +68,15 @@ key.endsWith('.json') ? `${import.meta.env.BASE_URL}${key}` : `${import.meta.env.BASE_URL}${key}.png` ); this.settings.load(); - // Настройки громкости -> шины аудио (работает и до unlock — запомнится). + // Настройки громкости -> шины аудио (до unlock шин нет — применится после). this.settings.onChange((s) => { this.audio.setBusVolume('master', s.master); - this.audio.setBusVolume('music', s.music); + this.audio.setBusVolume('ambience', s.ambience); this.audio.setBusVolume('sfx', s.sfx); }); // Применить текущие значения сразу. this.audio.setBusVolume('master', this.settings.data.master); - this.audio.setBusVolume('music', this.settings.data.music); + 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) { @@ -129,13 +117,17 @@ return this.engine.renderer.worldRoot; } - /** Музыка с кроссфейдом; повторный вызов с тем же треком не перезапускает его. */ - playMusic(key: string, volume = 0.7): void { - if (this.musicKey === key) return; - this.musicKey = key; - void this.audio.playMusic(key, { fade: 1.5, volume }); + /** Амбиент области с кроссфейдом; повторный вызов с тем же ключом не перезапускает его. */ + 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 musicKey: string | null = null; + /** Текущий амбиент (защита от перезапуска того же ключа; '' — тишина). */ + private ambienceKey: string | null = null; } \ No newline at end of file diff --git a/apps/game/src/data/__tests__/validate.test.ts b/apps/game/src/data/__tests__/validate.test.ts index cb3c3d3..e451375 100644 --- a/apps/game/src/data/__tests__/validate.test.ts +++ b/apps/game/src/data/__tests__/validate.test.ts @@ -2,6 +2,8 @@ import type { DialogueGraph, TileMapData } from '@rpg/engine'; import { TILES, buildMeadowsMap, buildPondsMap, buildZvenetsMap } from '../map'; import { AREAS } from '../locations'; +import { INTERACTABLES } from '../interactables'; +import { AUDIO_KEYS } from '../audio'; import { NPCS } from '../npcs'; import { DIALOGUES } from '../dialogues'; import { FLAGS } from '../ids'; @@ -118,4 +120,23 @@ const inv = validateLocations(maps); expect(inv.some((i) => i.id === 'in-wall' && i.message.includes('spawn'))).toBe(true); }); -}); \ No newline at end of file +}); +describe('аудио-ключи контента', () => { + it('ambience областей и sound интерактивов есть в AUDIO_KEYS', () => { + const keys = new Set(AUDIO_KEYS); + for (const area of Object.values(AREAS)) { + if (area.ambience) { + expect(keys.has(area.ambience), `${area.id}: амбиент ${area.ambience} не в AUDIO_KEYS`).toBe(true); + } + } + for (const list of Object.values(INTERACTABLES)) { + for (const def of list) { + for (const r of def.responses) { + if (r.sound) { + expect(keys.has(r.sound), `${def.id}: звук ${r.sound} не в AUDIO_KEYS`).toBe(true); + } + } + } + } + }); +}); diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts new file mode 100644 index 0000000..f8cc741 --- /dev/null +++ b/apps/game/src/data/audio.ts @@ -0,0 +1,24 @@ +/** + * Ключи аудио (WAV из tools/audio/gen.mjs): sfx + амбиент-лупы областей. + * Отдельный модуль (без `?url`-импортов Game) — чтобы валидатор мог сверять + * ключи контента с реестром в node-тестах. + */ + +/** Sfx. */ +export const SFX_KEYS = [ + 'sfx/bell_hit', + 'sfx/bell_low', + 'sfx/ash_hiss', + 'sfx/ash_die', + 'sfx/spit', + 'sfx/hurt', + 'sfx/step', + 'sfx/ui_click', + 'sfx/chime' +] as const; + +/** Амбиент-лупы областей. */ +export const AMBIENCE_KEYS = ['ambience/meadows', 'ambience/ponds', 'ambience/zvenets'] as const; + +/** Все аудио-ключи игры (префетч в BootScene). */ +export const AUDIO_KEYS: readonly string[] = [...SFX_KEYS, ...AMBIENCE_KEYS]; \ No newline at end of file diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 864038d..7656305 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -48,8 +48,8 @@ export interface AreaDef { id: AreaId; name: string; - /** Музыка области (кроссфейд при переходе). */ - music: string; + /** Амбиент области (кроссфейд при переходе, ключ из Game.AUDIO_KEYS); нет — тишина. */ + ambience?: string; /** Стартовый тайл (новая игра / респаун / фолбэк return). */ spawn: { x: number; y: number }; npcs: NpcDef[]; @@ -58,7 +58,7 @@ /** Интерактивные объекты (сундуки, очаги, прилавки). */ interactables?: InteractableDef[]; /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов, 'none' — интерьер. */ - ambience: 'ash' | 'fog' | 'none'; + atmosphere: 'ash' | 'fog' | 'none'; /** Зоны наката (необязательно). */ hazards?: HazardDef[]; /** Спавны пейзажных оленей (тайлы). */ @@ -69,7 +69,7 @@ meadows: { id: 'meadows', name: 'Выжженные луга', - music: 'music/meadows', + ambience: 'ambience/meadows', spawn: { x: 14, y: 14 }, npcs: [], enemies: [ @@ -103,7 +103,7 @@ duration: 0.2 } ], - ambience: 'ash', + atmosphere: 'ash', interactables: INTERACTABLES.meadows, // Безгласные олени у берегов прудов — подходят к звону. fauna: [ @@ -114,7 +114,7 @@ ponds: { id: 'ponds', name: 'Серые пруды', - music: 'music/ponds', + ambience: 'ambience/ponds', spawn: { x: 3, y: 3 }, npcs: [], enemies: [ @@ -128,7 +128,7 @@ // Обратно на луга. { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'meadows', entry: { x: 3, y: 3 } } } ], - ambience: 'fog', + atmosphere: 'fog', // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. hazards: [ { @@ -160,7 +160,7 @@ zvenets: { id: 'zvenets', name: 'Звенец', - music: 'music/zvenets', + ambience: 'ambience/zvenets', spawn: { x: 14, y: 10 }, npcs: NPCS, enemies: [], @@ -192,13 +192,12 @@ } ], interactables: INTERACTABLES.zvenets, - ambience: 'ash' + atmosphere: 'ash' }, // --- интерьеры: рамка WALL, пол FLOOR, дверной проём снизу --- house_elder: { id: 'house_elder', name: 'Дом Ирвина', - music: 'music/zvenets', spawn: { x: 5, y: 7 }, npcs: [], enemies: [], @@ -207,18 +206,17 @@ { tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 } ], interactables: INTERACTABLES.house_elder, - ambience: 'none' + atmosphere: 'none' }, shop: { id: 'shop', name: 'Лавка Милы', - music: 'music/zvenets', spawn: { x: 5, y: 7 }, npcs: [], enemies: [], transitions: [{ tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 }], interactables: INTERACTABLES.shop, - ambience: 'none' + atmosphere: 'none' } }; diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 4e11dd5..25d3f97 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -293,9 +293,9 @@ // Атмосфера локации: пепел на лугах, туман над прудами, в интерьере — ничего. this.ash = - area.ambience === 'none' + area.atmosphere === 'none' ? null - : area.ambience === 'fog' + : area.atmosphere === 'fog' ? new ParticleEmitter({ color: 0x7a7a88, rate: 6, @@ -323,7 +323,7 @@ // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет. this.moths = - area.ambience === 'ash' + area.atmosphere === 'ash' ? new ParticleEmitter({ colors: [0xf0d878, 0xd8b050], blend: 'add', @@ -390,8 +390,8 @@ enter(): void { console.log('[location]', this.area.id); // маяк для смоук-тестов - // Амбиент локации (кроссфейд; тот же трек не перезапускается). - this.game.playMusic(this.area.music); + // Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина). + this.game.playAmbience(this.area.ambience ?? ''); } exit(): void { diff --git a/apps/game/src/scenes/SettingsScene.ts b/apps/game/src/scenes/SettingsScene.ts index 5d944f9..e2a77fa 100644 --- a/apps/game/src/scenes/SettingsScene.ts +++ b/apps/game/src/scenes/SettingsScene.ts @@ -66,7 +66,7 @@ const s = this.game.settings.data; this.menu.setItems([ { label: this.volumeLabel('Общая', s.master), onSelect: () => this.adjust(0, 0.1) }, - { label: this.volumeLabel('Музыка', s.music), onSelect: () => this.adjust(1, 0.1) }, + { label: this.volumeLabel('Амбиент', s.ambience), onSelect: () => this.adjust(1, 0.1) }, { label: this.volumeLabel('Звуки', s.sfx), onSelect: () => this.adjust(2, 0.1) }, { label: 'Назад', onSelect: () => this.back() } ]); @@ -83,7 +83,7 @@ return; } const s = this.game.settings.data; - const key = (['master', 'music', 'sfx'] as const)[index]; + const key = (['master', 'ambience', 'sfx'] as const)[index]; const value = Math.round(Math.min(1, Math.max(0, s[key] + delta)) * 10) / 10; this.game.settings.update({ [key]: value }); if (delta > 0) void this.game.audio.play('sfx/ui_click'); @@ -97,4 +97,4 @@ } /** Тип громкости в SettingsData (для adjust). */ -export type VolumeKey = keyof Pick; \ No newline at end of file +export type VolumeKey = keyof Pick; \ No newline at end of file diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs index 4950426..2a2e37e 100644 --- a/apps/game/tools/audio/gen.mjs +++ b/apps/game/tools/audio/gen.mjs @@ -1,7 +1,7 @@ /** * Генератор звуков «Пепельных лугов» — WAV из кода, без зависимостей. * Запуск: node tools/audio/gen.mjs - * Выход: apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/music/*.wav + * Выход: apps/game/assets/audio/sfx/*.wav, apps/game/assets/audio/ambience/*.wav * * Формат: моно, 22050 Гц, 16-бит PCM (хватает для пиксель-арта со звуком, * файлы вдвое меньше). Меланхоличный пепельный саунд: глухие удары, шум, низкие пады. @@ -113,7 +113,7 @@ } /** Амбиент лугов: низкий ветер + редкий гул, зациклен. */ -function musicMeadows() { +function ambienceMeadows() { const dur = 8; const wind = bandNoise(61, 100, 600, dur); const r = makeRng(7); @@ -133,7 +133,7 @@ } /** Амбиент прудов: ниже, влажнее, с редкой капелью. */ -function musicPonds() { +function ambiencePonds() { const dur = 8; const wind = bandNoise(71, 60, 400, dur); const r = makeRng(13); @@ -154,7 +154,7 @@ * Амбиент Звенца: низкий колокольный тон (два удара за цикл) над тихим * ветром. Колокол башни отлит из рельсов — голос ниже лугового гула. */ -function musicZvenets() { +function ambienceZvenets() { const dur = 8; const wind = bandNoise(81, 80, 500, dur); const strikes = [ @@ -190,13 +190,13 @@ 'sfx/step': step, 'sfx/ui_click': uiClick, 'sfx/chime': chime, - 'music/meadows': musicMeadows, - 'music/ponds': musicPonds, - 'music/zvenets': musicZvenets + 'ambience/meadows': ambienceMeadows, + 'ambience/ponds': ambiencePonds, + 'ambience/zvenets': ambienceZvenets }; mkdirSync(`${OUT}sfx`, { recursive: true }); -mkdirSync(`${OUT}music`, { recursive: true }); +mkdirSync(`${OUT}ambience`, { recursive: true }); for (const [name, gen] of Object.entries(SFX)) { const buf = encodeWav(gen()); writeFileSync(`${OUT}${name}.wav`, buf); diff --git a/docs/demo.md b/docs/demo.md index 9693500..c0eb1e2 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -21,8 +21,8 @@ | EventBus | `combat:hurt/attack/playerHit`, `quest:flower` | готово | | GameState (флаги/вары) | прогресс квеста, hp/kills/motes/deaths; сериализация в сейвы | готово | | SaveManager (слоты) | autosave (Esc) + 3 слота, `SaveSlotsScene` | готово | -| Settings + onChange | громкости master/music/sfx, `SettingsScene` | готово | -| AudioManager (шины) | музыка локаций с кроссфейдом, sfx боя/шагов/UI | готово | +| Settings + onChange | громкости master/ambience/sfx, `SettingsScene` | готово | +| AudioManager (шины) | амбиент локаций с кроссфейдом, sfx боя/шагов/UI | готово | | Tween + easing | хит-флэш, кольца резонанса, всплывающие тосты, кат-сцены | готово | | Particles (атмосфера + burst) | пепел лугов, туман прудов, удары/смерти сгустков | готово | | StateMachine | `EnemyBrain` — ИИ сгустков (dormant/rise/chase/…/sleep/dead) | готово | diff --git a/docs/plan.md b/docs/plan.md index d81abca..053c4f9 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -241,12 +241,12 @@ ``` tools/aiaudio/gen.mjs # вызов модели, seed, промпт из манифеста (аналог aiart) - → post: ffmpeg → 22050 моно 16-bit → нормализация → crossfade-луп для music/ambience - → review-каталог → apps/game/assets/audio/{sfx,music}/*.wav + → post: ffmpeg → 22050 моно 16-bit → нормализация → crossfade-луп для ambience/sfx + → review-каталог → apps/game/assets/audio/{sfx,ambience}/*.wav ``` Принципы те же: манифест, апрув-гейт, seed-детерминизм, формат игры не меняется -(шины master/music/sfx в движке уже готовы). Лупы проверять скриптом +(шины master/music/sfx/ambience в движке уже готовы). Лупы проверять скриптом (стык начала/конца без щелчка). ### Этапы