diff --git a/apps/game/assets/audio/ambience/houses.wav b/apps/game/assets/audio/ambience/houses.wav new file mode 100644 index 0000000..98e3962 --- /dev/null +++ b/apps/game/assets/audio/ambience/houses.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/ponds_water.wav b/apps/game/assets/audio/ambience/ponds_water.wav new file mode 100644 index 0000000..e12bcf4 --- /dev/null +++ b/apps/game/assets/audio/ambience/ponds_water.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/tower_hum.wav b/apps/game/assets/audio/ambience/tower_hum.wav new file mode 100644 index 0000000..961b1ff --- /dev/null +++ b/apps/game/assets/audio/ambience/tower_hum.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/chest_open.wav b/apps/game/assets/audio/sfx/chest_open.wav new file mode 100644 index 0000000..635a913 --- /dev/null +++ b/apps/game/assets/audio/sfx/chest_open.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/deer_startle.wav b/apps/game/assets/audio/sfx/deer_startle.wav new file mode 100644 index 0000000..7a86c17 --- /dev/null +++ b/apps/game/assets/audio/sfx/deer_startle.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/door.wav b/apps/game/assets/audio/sfx/door.wav new file mode 100644 index 0000000..e8cfd2e --- /dev/null +++ b/apps/game/assets/audio/sfx/door.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/mote_pickup.wav b/apps/game/assets/audio/sfx/mote_pickup.wav new file mode 100644 index 0000000..5b6671b --- /dev/null +++ b/apps/game/assets/audio/sfx/mote_pickup.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/note.wav b/apps/game/assets/audio/sfx/note.wav new file mode 100644 index 0000000..293bb5f --- /dev/null +++ b/apps/game/assets/audio/sfx/note.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/pickup.wav b/apps/game/assets/audio/sfx/pickup.wav new file mode 100644 index 0000000..53e99f6 --- /dev/null +++ b/apps/game/assets/audio/sfx/pickup.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/step.wav b/apps/game/assets/audio/sfx/step.wav deleted file mode 100644 index c5e3023..0000000 --- a/apps/game/assets/audio/sfx/step.wav +++ /dev/null Binary files differ diff --git a/apps/game/assets/audio/sfx/step_ash.wav b/apps/game/assets/audio/sfx/step_ash.wav new file mode 100644 index 0000000..1579df1 --- /dev/null +++ b/apps/game/assets/audio/sfx/step_ash.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/step_floor.wav b/apps/game/assets/audio/sfx/step_floor.wav new file mode 100644 index 0000000..b5b7100 --- /dev/null +++ b/apps/game/assets/audio/sfx/step_floor.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/step_grass.wav b/apps/game/assets/audio/sfx/step_grass.wav new file mode 100644 index 0000000..5860506 --- /dev/null +++ b/apps/game/assets/audio/sfx/step_grass.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/step_water.wav b/apps/game/assets/audio/sfx/step_water.wav new file mode 100644 index 0000000..f96f331 --- /dev/null +++ b/apps/game/assets/audio/sfx/step_water.wav Binary files differ diff --git a/apps/game/assets/audio/sfx/whoosh.wav b/apps/game/assets/audio/sfx/whoosh.wav new file mode 100644 index 0000000..00e80e5 --- /dev/null +++ b/apps/game/assets/audio/sfx/whoosh.wav Binary files differ diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts index f8cc741..46b6606 100644 --- a/apps/game/src/data/audio.ts +++ b/apps/game/src/data/audio.ts @@ -12,7 +12,17 @@ 'sfx/ash_die', 'sfx/spit', 'sfx/hurt', - 'sfx/step', + 'sfx/step_grass', + 'sfx/step_ash', + 'sfx/step_water', + 'sfx/step_floor', + 'sfx/mote_pickup', + 'sfx/pickup', + 'sfx/chest_open', + 'sfx/door', + 'sfx/note', + 'sfx/whoosh', + 'sfx/deer_startle', 'sfx/ui_click', 'sfx/chime' ] as const; @@ -20,5 +30,8 @@ /** Амбиент-лупы областей. */ export const AMBIENCE_KEYS = ['ambience/meadows', 'ambience/ponds', 'ambience/zvenets'] as const; +/** Локальные амбиент-слои (вода, гул, дома). */ +export const AMBIENCE_LAYER_KEYS = ['ambience/ponds_water', 'ambience/tower_hum', 'ambience/houses'] as const; + /** Все аудио-ключи игры (префетч в BootScene). */ -export const AUDIO_KEYS: readonly string[] = [...SFX_KEYS, ...AMBIENCE_KEYS]; \ No newline at end of file +export const AUDIO_KEYS: readonly string[] = [...SFX_KEYS, ...AMBIENCE_KEYS, ...AMBIENCE_LAYER_KEYS]; \ No newline at end of file diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index e232256..a6a3bfe 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -115,7 +115,7 @@ responses: [ { text: '«Звенец» — стёрто, но различимо. Дальше на восток: иди на звон.', - sound: 'sfx/ui_click' + sound: 'sfx/note' } ] }, @@ -127,7 +127,7 @@ responses: [ { text: '«Серые пруды» — стрелка на северо-запад. Ниже приписка: «туман лжёт».', - sound: 'sfx/ui_click' + sound: 'sfx/note' } ] }, @@ -142,7 +142,7 @@ text: 'Сгусток серой земли с тёплой искрой. Мот — то, ради чего сажают колокольчики.', gives: 'mote', addVar: { id: VARS.motes, by: 1 }, - sound: 'sfx/ui_click' + sound: 'sfx/mote_pickup' } ] }, @@ -157,7 +157,7 @@ text: 'Ещё один мот. Земля здесь отдаёт их неохотно.', gives: 'mote', addVar: { id: VARS.motes, by: 1 }, - sound: 'sfx/ui_click' + sound: 'sfx/mote_pickup' } ] }, @@ -172,7 +172,7 @@ text: 'Третий мот за день. Сумка тихо позвякивает.', gives: 'mote', addVar: { id: VARS.motes, by: 1 }, - sound: 'sfx/ui_click' + sound: 'sfx/mote_pickup' } ] } @@ -186,7 +186,7 @@ responses: [ { text: '«Не дышать накатом» — буквы вросли в ржавчину. Кто-то дописал: «голос дороже соли».', - sound: 'sfx/ui_click' + sound: 'sfx/note' } ] } @@ -200,7 +200,7 @@ responses: [ { text: '«Звенец. Мастерская мира». Ниже чужим почерком: «кузня чинит резонаторы, лавка меняет соль».', - sound: 'sfx/ui_click' + sound: 'sfx/note' } ] }, @@ -234,7 +234,7 @@ { text: 'В сундуке — запас вощёного полотна.', gives: 'cloth', - sound: 'sfx/ui_click' + sound: 'sfx/chest_open' } ] }, @@ -262,7 +262,7 @@ { text: '«Тоны уходят вниз. Если колокол смолкнет — не буди Молчание».', setFlags: [FLAGS.read_note], - sound: 'sfx/ui_click' + sound: 'sfx/note' } ] } diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 7656305..b9439f7 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -2,6 +2,7 @@ import type { EnemyKindId, EnemyPatrolDef } from './enemies'; import { NPCS } from './npcs'; import type { ItemId } from './items'; +import { TILES } from './map'; import { INTERACTABLES, type InteractableDef } from './interactables'; /** @@ -45,6 +46,20 @@ requiresItem?: ItemId; } +/** Локальный амбиент-слой: громкость из дистанции до ближайшего тайла/точки. */ +export interface AmbienceLayerDef { + /** Ключ лупа (Game.AUDIO_KEYS). */ + key: string; + /** Тайлы-источники (иды из TILES); пусто/нет — точка at. */ + tiles?: number[]; + /** Точка-источник в тайлах (центр башни и т.п.). */ + at?: { x: number; y: number }; + /** Радиус слышимости в юнитах (за ним слой молчит). */ + radius: number; + /** Громкость вплотную (по умолчанию 0.5). */ + volume?: number; +} + export interface AreaDef { id: AreaId; name: string; @@ -59,6 +74,8 @@ interactables?: InteractableDef[]; /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов, 'none' — интерьер. */ atmosphere: 'ash' | 'fog' | 'none'; + /** Локальные амбиент-слои поверх областного (вода, гул башни, дома). */ + ambienceLayers?: AmbienceLayerDef[]; /** Зоны наката (необязательно). */ hazards?: HazardDef[]; /** Спавны пейзажных оленей (тайлы). */ @@ -129,6 +146,8 @@ { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'meadows', entry: { x: 3, y: 3 } } } ], atmosphere: 'fog', + // Вода прудов слышна у берега — тихий плеск поверх ветрового амбиента. + ambienceLayers: [{ key: 'ambience/ponds_water', tiles: [TILES.WATER], radius: 6, volume: 0.6 }], // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. hazards: [ { @@ -192,7 +211,12 @@ } ], interactables: INTERACTABLES.zvenets, - atmosphere: 'ash' + atmosphere: 'ash', + // Гул колокола башни и скрипы вагонных щитов — слышны вблизи источников. + ambienceLayers: [ + { key: 'ambience/tower_hum', at: { x: 14, y: 7 }, radius: 8, volume: 0.5 }, + { key: 'ambience/houses', tiles: [TILES.HOUSE], radius: 5, volume: 0.4 } + ] }, // --- интерьеры: рамка WALL, пол FLOOR, дверной проём снизу --- house_elder: { diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 4e1ce7e..6ca93c8 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -29,11 +29,13 @@ import { TILES, DECOR } from '../data/map'; import { areaOf, + type AmbienceLayerDef, type AreaDef, type AreaId, type HazardDef, type TransitionDef } from '../data/locations'; +import { AudioSystem, type AudioLayerDef } from '../systems/AudioSystem'; import { InteractionRouter } from '../systems/ClickRouting'; import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; @@ -51,7 +53,6 @@ import { HealthBar } from '../systems/combat/HealthBar'; import { PLAYER_COMBAT } from '../systems/combat/stats'; import { Interactables } from '../systems/Interactables'; -import { AudioSystem } from '../systems/AudioSystem'; import { SceneObjects } from '../systems/SceneObjects'; import type { InteractableDef } from '../data/interactables'; @@ -109,6 +110,8 @@ private lastToast: { text: string; tick: number } | null = null; /** Агентный мост сцены (снапшот/инварианты/команды). */ private agentView: SceneAgentView; + /** Позиции тайлов-источников амбиент-слоёв, по id (собираются один раз). */ + private layerTilePos = new Map(); constructor( private game: Game, @@ -129,6 +132,15 @@ 2 ); this.world.addChild(this.map.view, this.actors); + // Источники амбиент-слоёв: позиции тайлов по id — один проход по карте. + for (let y = 0; y < data.height; y++) { + for (let x = 0; x < data.width; x++) { + const id = data.tiles[y * data.width + x]!; + const list = this.layerTilePos.get(id) ?? []; + list.push({ x, y }); + this.layerTilePos.set(id, list); + } + } // Крупные объекты карты (дома) — в общий depth-слой по footprint'у. for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); this.game.renderer.worldRoot.addChild(this.world); @@ -149,7 +161,7 @@ }); this.audioOff = this.worldAudio.attach(); this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { - this.worldAudio.playStep('sfx/step', this.player.position, 0.35); + this.worldAudio.playStep(this.stepKey(this.player.currentTile()), this.player.position, 0.35); // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. this.combat.noise(this.player.position, 0.35); }); @@ -407,7 +419,7 @@ exit(): void { this.audioOff(); - this.worldAudio.setLayer(null); + this.worldAudio.setLayers([]); this.fauna.exit(); this.combatViews.exit(); this.ash?.clear(); @@ -426,6 +438,7 @@ update(dt: number): void { this.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций this.worldAudio.update(dt); + this.updateAudioLayers(); this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { @@ -557,6 +570,7 @@ /** Выполнить переход: новая сцена с fade (длительность — из определения). */ private useTransition(def: TransitionDef): void { const next = this.targetEntry(def); + void this.game.audio.play('sfx/whoosh', 0.5); void this.game.scenes.replace( new LocationScene(this.game, null, next.area, next.entry, { area: this.area.id, @@ -717,6 +731,43 @@ return this.map.data.tiles[y * this.map.data.width + x]; } + /** Ключ шага по поверхности под ногами (трава/пепел/вода/пол). */ + private stepKey(tile: Vec2): string { + const id = this.tileId(tile.x, tile.y); + if (id === TILES.WATER) return 'sfx/step_water'; + if (id === TILES.ASH || id === TILES.HOUSE || id === TILES.TOWER) return 'sfx/step_ash'; + if (id === TILES.FLOOR || id === TILES.PATH) return 'sfx/step_floor'; + return 'sfx/step_grass'; + } + + /** + * Тик амбиент-слоёв: громкость каждого — от дистанции героя до ближайшего + * источника (тайлы или точка). Пустой набор — setLayers сам снимет лишние. + */ + private updateAudioLayers(): void { + const p = this.player.position; + const defs: AudioLayerDef[] = []; + for (const layer of this.area.ambienceLayers ?? []) { + const d = this.layerDistance(layer, p); + const volume = (layer.volume ?? 0.5) * Math.max(0, 1 - d / layer.radius); + if (volume > 0.01) defs.push({ key: layer.key, volume, fade: 0.5 }); + } + this.worldAudio.setLayers(defs); + } + + /** Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба). */ + private layerDistance(layer: AmbienceLayerDef, p: Vec2): number { + if (layer.at) return Math.hypot(p.x - (layer.at.x + 0.5), p.y - (layer.at.y + 0.5)); + let best = Infinity; + for (const id of layer.tiles ?? []) { + for (const tile of this.layerTilePos.get(id) ?? []) { + const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); + if (d < best) best = d; + } + } + return best; + } + /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ private collectFlower(x: number, y: number): void { this.map.setTile(x, y, TILES.GRASS); diff --git a/apps/game/src/systems/AudioSystem.ts b/apps/game/src/systems/AudioSystem.ts index 6e2be28..e24b775 100644 --- a/apps/game/src/systems/AudioSystem.ts +++ b/apps/game/src/systems/AudioSystem.ts @@ -36,26 +36,26 @@ private clock = 0; private lastAt = new Map(); private offs: (() => void)[] = []; - private layer: { def: AudioLayerDef; handle: MusicHandle; volume: number } | null = null; - /** Ключ запрошенного слоя (и до асинхронного старта лупа) — дедуп setLayer. */ - private layerKey: string | null = null; - private layerSeq = 0; + private layers = new Map(); + /** Ключи запрошенных слоёв (и до асинхронного старта лупов). */ + private wanted = new Set(); private rng = createRng(20260905); constructor(private deps: AudioSystemDeps) {} - /** Тик: часы полифонии + доводка громкости слоя к цели (fade сек до target). */ + /** Тик: часы полифонии + доводка громкости слоёв к целям (fade сек до target). */ update(dt: number): void { this.clock += dt; - const layer = this.layer; - if (!layer) return; - const target = layer.def.volume ?? 0.5; - const rate = Math.abs(target) / Math.max(0.001, layer.def.fade ?? 1); - layer.volume = - layer.volume < target - ? Math.min(target, layer.volume + rate * dt) - : Math.max(target, layer.volume - rate * dt); - layer.handle.setVolume(layer.volume); + for (const layer of this.layers.values()) { + if (!layer.handle) continue; // луп ещё грузится + const target = layer.def.volume ?? 0.5; + const rate = Math.abs(target) / Math.max(0.001, layer.def.fade ?? 1); + layer.volume = + layer.volume < target + ? Math.min(target, layer.volume + rate * dt) + : Math.max(target, layer.volume - rate * dt); + layer.handle.setVolume(layer.volume); + } } /** @@ -84,7 +84,7 @@ this.play(key, { volume, rate: 0.92 + this.rng.next() * 0.16 }); } - /** Подписки на события боя; возвращает off() для exit() сцены. */ + /** Подписки на события боя и фауны; возвращает off() для exit() сцены. */ attach(): () => void { const on = this.deps.events; this.offs = [ @@ -95,7 +95,8 @@ on.on<{ pos: Vec2 }>('combat:spit', ({ pos }) => this.playAt('sfx/spit', pos, 0.6)), on.on<{ pos: Vec2 }>('combat:awake', ({ pos }) => this.playAt('sfx/ash_hiss', pos, 0.5)), on.on<{ tile: Vec2 }>('combat:kill', ({ tile }) => this.playAt('sfx/ash_die', tile, 0.8)), - on.on('combat:playerHit', () => this.play('sfx/hurt', { volume: 0.8 })) + on.on('combat:playerHit', () => this.play('sfx/hurt', { volume: 0.8 })), + on.on<{ pos: Vec2 }>('fauna:startle', ({ pos }) => this.playAt('sfx/deer_startle', pos, 0.7)) ]; return () => this.off(); } @@ -107,28 +108,44 @@ } /** - * Локальный амбиент-слой (вода, гул): луп в шине ambience поверх - * областного. Смена ключа — кроссфейд; громкость доводится в update. + * Локальные амбиент-слои (вода, гул, дома) — лупы в шине ambience поверх + * областного. Вызывать каждый тик с текущим набором: лишние стопятся, + * громкости доводятся в update. */ - setLayer(def: AudioLayerDef | null): void { - if ((def?.key ?? null) === this.layerKey) return; - this.layerKey = def?.key ?? null; - const seq = ++this.layerSeq; - if (this.layer) { - this.layer.handle.stop(1); - this.layer = null; + setLayers(defs: AudioLayerDef[]): void { + this.wanted = new Set(defs.map((d) => d.key)); + for (const [key, layer] of this.layers) { + if (!this.wanted.has(key)) { + layer.handle?.stop(1); // pending-луп остановит .then (слой уже не нужен) + this.layers.delete(key); + } } - if (!def) return; - void this.deps.audio - .playLoop(def.key, { bus: 'ambience', fade: def.fade ?? 1, volume: 0.001 }) - .then((handle) => { - // Слой сменили, пока луп грузился — не оставлять хвост. - if (seq !== this.layerSeq) { - handle?.stop(0.5); - return; - } - if (handle) this.layer = { def, handle, volume: 0.001 }; - }); + for (const def of defs) { + const existing = this.layers.get(def.key); + if (existing) { + existing.def = def; + continue; + } + // Синхронная пометка «слой занят»: повторный вызов до резолва лупа + // не перезапускает его (playLoop асинхронен). + const pending: { def: AudioLayerDef; handle: MusicHandle | null; volume: number } = { + def, + handle: null, + volume: 0.001 + }; + this.layers.set(def.key, pending); + void this.deps.audio + .playLoop(def.key, { bus: 'ambience', fade: def.fade ?? 1, volume: 0.001 }) + .then((handle) => { + // Слой уже не нужен или луп не декодировался — без хвостов. + if (!handle || !this.wanted.has(def.key)) { + if (this.layers.get(def.key) === pending) this.layers.delete(def.key); + handle?.stop(0.5); + return; + } + pending.handle = handle; + }); + } } private play(key: string, opts: { volume: number; rate?: number; pan?: number }): void { diff --git a/apps/game/src/systems/__tests__/AudioSystem.test.ts b/apps/game/src/systems/__tests__/AudioSystem.test.ts index dd3759d..86886dd 100644 --- a/apps/game/src/systems/__tests__/AudioSystem.test.ts +++ b/apps/game/src/systems/__tests__/AudioSystem.test.ts @@ -136,34 +136,49 @@ }); }); -describe('AudioSystem.setLayer — локальный амбиент', () => { +describe('AudioSystem.setLayers — локальные амбиенты', () => { it('слой — playLoop в шине ambience; повтор с тем же ключом не перезапускает', () => { const { deps, calls } = makeDeps(); const sys = new AudioSystem(deps); - sys.setLayer({ key: 'ambience/ponds_water' }); - sys.setLayer({ key: 'ambience/ponds_water' }); // тот же ключ — no-op + sys.setLayers([{ key: 'ambience/ponds_water' }]); + sys.setLayers([{ key: 'ambience/ponds_water' }]); // тот же ключ — no-op expect(calls.loops).toHaveLength(1); expect(calls.loops[0]!.key).toBe('ambience/ponds_water'); expect(calls.loops[0]!.opts!.bus).toBe('ambience'); }); - it('setLayer(null) останавливает слой, смена ключа — стоп прежнего', () => { + it('два слоя — два независимых лупа', () => { const { deps, calls } = makeDeps(); const sys = new AudioSystem(deps); - sys.setLayer({ key: 'ambience/tower_hum' }); - sys.setLayer({ key: 'ambience/ponds_water' }); - expect(calls.loops).toHaveLength(2); - // прежний хендлер получил stop - const firstHandle = (calls.audio.playLoop as ReturnType).mock.results[0]!.value as Promise; - void firstHandle; // хендлер внутри then — проверяем через setLayer(null) + sys.setLayers([{ key: 'ambience/tower_hum' }, { key: 'ambience/houses' }]); - sys.setLayer(null); - // после null слой снят: следующий вызов снова стартует луп - sys.setLayer({ key: 'ambience/tower_hum' }); - expect(calls.loops).toHaveLength(3); + expect(calls.loops).toHaveLength(2); + expect(calls.loops[0]!.key).toBe('ambience/tower_hum'); + expect(calls.loops[1]!.key).toBe('ambience/houses'); + }); + + it('пустой набор останавливает слои; повторный старт — новый луп', () => { + const { deps, calls } = makeDeps(); + const sys = new AudioSystem(deps); + const handles: MusicHandle[] = []; + (deps.audio.playLoop as ReturnType).mockImplementation( + async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => { + calls.loops.push({ key, opts }); + const h = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle; + handles.push(h); + return h; + } + ); + + sys.setLayers([{ key: 'ambience/tower_hum' }]); + sys.setLayers([]); + sys.setLayers([{ key: 'ambience/tower_hum' }]); + + expect(handles).toHaveLength(2); // второй старт после снятия + expect(calls.loops).toHaveLength(2); }); it('update доводит громкость слоя к цели', async () => { @@ -175,7 +190,7 @@ return handle; }); - sys.setLayer({ key: 'ambience/tower_hum', volume: 0.5, fade: 1 }); + sys.setLayers([{ key: 'ambience/tower_hum', volume: 0.5, fade: 1 }]); await Promise.resolve(); // луп стартовал sys.update(0.5); expect(handle!.setVolume).toHaveBeenCalledWith(expect.closeTo(0.25)); diff --git a/apps/game/src/systems/fauna/FaunaSystem.ts b/apps/game/src/systems/fauna/FaunaSystem.ts index 52ec67c..8d05ac8 100644 --- a/apps/game/src/systems/fauna/FaunaSystem.ts +++ b/apps/game/src/systems/fauna/FaunaSystem.ts @@ -75,6 +75,8 @@ if (worldDist(e.pos, origin) <= HEAR_RADIUS) { e.dest = { ...origin }; e.machine.change('approach'); + // Олень, услышав звон, вскрикивает — звук вешает AudioSystem. + events.emit('fauna:startle', { pos: { ...e.pos } }); } } }); diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs index 2a2e37e..147b810 100644 --- a/apps/game/tools/audio/gen.mjs +++ b/apps/game/tools/audio/gen.mjs @@ -85,14 +85,116 @@ return normalize(s, 0.65); } -/** Шаг по пеплу: короткий шорох. */ -function step() { +/** Шаг по траве: глухой шорох стеблей. */ +function stepGrass() { const dur = 0.12; - const noise = bandNoise(53, 300, 1500, dur); + const noise = bandNoise(153, 400, 2000, dur); const s = synth(dur, (t) => noise() * decay(t, dur, 2) * 0.5); return normalize(s, 0.3); } +/** Шаг по пеплу: низкое шуршание, чуть длиннее травы. */ +function stepAsh() { + const dur = 0.14; + const noise = bandNoise(154, 200, 1100, dur); + const s = synth(dur, (t) => noise() * decay(t, dur, 2.5) * 0.5); + return normalize(s, 0.3); +} + +/** Шаг по воде: короткий всплеск. */ +function stepWater() { + const dur = 0.18; + const noise = bandNoise(155, 600, 3200, dur); + const s = synth(dur, (t) => (noise() * 0.6 + Math.sin(2 * Math.PI * 300 * t) * 0.2) * decay(t, dur, 2)); + return normalize(s, 0.3); +} + +/** Шаг по полу/тропе: глухой стук. */ +function stepFloor() { + const dur = 0.1; + const noise = bandNoise(156, 150, 800, dur); + const s = synth(dur, (t) => (noise() * 0.4 + Math.sin(2 * Math.PI * 140 * t) * 0.5) * decay(t, dur, 3)); + return normalize(s, 0.3); +} + +/** Мот подобран: мерцающий двойной звон (тёплая искра в серой земле). */ +function motePickup() { + const dur = 0.45; + const s = synth(dur, (t) => { + const body = + Math.sin(2 * Math.PI * 1240 * t) * 0.4 + + Math.sin(2 * Math.PI * 1860 * t) * 0.2 * decay(t, dur, 4); + return body * decay(t, dur, 2.5); + }); + return normalize(s, 0.4); +} + +/** Предмет в сумку: мягкий «пок». */ +function pickup() { + const dur = 0.14; + const s = synth(dur, (t) => { + const f = 520 + 220 * (t / dur); // чуть взлетает + return Math.sin(2 * Math.PI * f * t) * decay(t, dur, 2.5); + }); + return normalize(s, 0.4); +} + +/** Сундук открылся: низкий удар крышки + скрип петель. */ +function chestOpen() { + const dur = 0.55; + const noise = bandNoise(157, 300, 1400, dur); + const s = synth(dur, (t) => { + const thump = Math.sin(2 * Math.PI * 110 * t) * decay(t, 0.12, 2) * 0.7; + const creak = noise() * Math.min(1, t / 0.1) * decay(t, dur, 1.5) * 0.35; + return thump + creak; + }); + return normalize(s, 0.45); +} + +/** Дверь: короткий скрип щитов на ржавых петлях. */ +function door() { + const dur = 0.5; + const noise = bandNoise(158, 250, 1200, dur); + const s = synth(dur, (t) => { + // Скрип с гуляющей частотой (воблер 7 Гц). + const wob = 1 + 0.4 * Math.sin(2 * Math.PI * 7 * t); + const creak = noise() * wob * decay(t, dur, 1.5); + return creak * 0.8; + }); + return normalize(s, 0.4); +} + +/** Записка: шелест бумаги. */ +function note() { + const dur = 0.35; + const noise = bandNoise(159, 1800, 6000, dur); + const s = synth(dur, (t) => noise() * (0.4 + 0.6 * Math.sin(2 * Math.PI * 3.2 * t + 1)) * decay(t, dur, 1.5)); + return normalize(s, 0.3); +} + +/** Переход локаций: воздух уносит — шум с подъёмом и спадом. */ +function whoosh() { + const dur = 0.7; + const noise = bandNoise(160, 200, 2400, dur); + const s = synth(dur, (t) => { + const swell = Math.sin(Math.PI * Math.min(1, t / dur)); // 0 → 1 → 0 + return noise() * swell; + }); + return normalize(s, 0.4); +} + +/** Олень вспугнут: глухой выдох + стук копыт. */ +function deerStartle() { + const dur = 0.4; + const noise = bandNoise(161, 150, 900, dur); + const s = synth(dur, (t) => { + const breath = noise() * Math.min(1, t / 0.05) * decay(t, dur, 2); + const hoof = t > 0.2 && t < 0.28 ? Math.sin(2 * Math.PI * 90 * (t - 0.2)) * decay(t - 0.2, 0.08, 2) * 0.6 : 0; + return breath * 0.7 + hoof; + }); + return normalize(s, 0.4); +} + /** Клик UI. */ function uiClick() { const dur = 0.06; @@ -178,6 +280,61 @@ return loopify(normalize(s, 0.55)); } +// ---------- локальные амбиент-слои ---------- + +/** Слой «вода прудов»: тихий плеск + редкие капли. */ +function layerPondsWater() { + const dur = 8; + const lap = bandNoise(172, 300, 1500, dur); + const r = makeRng(27); + const drops = []; + for (let i = 0; i < 6; i++) drops.push({ at: r() * dur, f: 800 + r() * 700 }); + const s = synth(dur, (t) => { + // Плебск «дышит» медленной волной. + let v = lap() * 0.12 * (0.5 + 0.5 * Math.sin(2 * Math.PI * t / dur + 2)); + for (const d of drops) { + const dt = t - d.at + (t < d.at ? dur : 0); // зациклить капли + if (dt >= 0 && dt < 0.12) v += Math.sin(2 * Math.PI * d.f * dt) * 0.1 * decay(dt, 0.12, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** Слой «башня Звенца»: низкий гул колокола (55+82.5 Гц) с биением. */ +function layerTowerHum() { + const dur = 8; + const s = synth(dur, (t) => { + const breath = 0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2 + 0.5); + const body = + Math.sin(2 * Math.PI * 55 * t) * 0.5 + + Math.sin(2 * Math.PI * 82.5 * t) * 0.3; // биение 27.5 Гц + return body * breath * 0.35; + }); + return loopify(normalize(s, 0.5)); +} + +/** Слой «дома Звенца»: редкие скрипы вагонных щитов. */ +function layerHouses() { + const dur = 8; + const noise = bandNoise(174, 200, 1000, dur); + const r = makeRng(41); + const creaks = []; + for (let i = 0; i < 4; i++) creaks.push({ at: r() * dur, len: 0.5 + r() * 0.6 }); + const s = synth(dur, (t) => { + let v = 0; + for (const c of creaks) { + const dt = t - c.at + (t < c.at ? dur : 0); // зациклить скрипы + if (dt >= 0 && dt < c.len) { + const wob = 1 + 0.5 * Math.sin(2 * Math.PI * 5 * dt); + v += noise() * wob * decay(dt, c.len, 1.5) * 0.25; + } + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + // ---------- запись ---------- const SFX = { @@ -187,12 +344,25 @@ 'sfx/ash_die': ashDie, 'sfx/spit': spit, 'sfx/hurt': hurt, - 'sfx/step': step, + 'sfx/step_grass': stepGrass, + 'sfx/step_ash': stepAsh, + 'sfx/step_water': stepWater, + 'sfx/step_floor': stepFloor, + 'sfx/mote_pickup': motePickup, + 'sfx/pickup': pickup, + 'sfx/chest_open': chestOpen, + 'sfx/door': door, + 'sfx/note': note, + 'sfx/whoosh': whoosh, + 'sfx/deer_startle': deerStartle, 'sfx/ui_click': uiClick, 'sfx/chime': chime, 'ambience/meadows': ambienceMeadows, 'ambience/ponds': ambiencePonds, - 'ambience/zvenets': ambienceZvenets + 'ambience/zvenets': ambienceZvenets, + 'ambience/ponds_water': layerPondsWater, + 'ambience/tower_hum': layerTowerHum, + 'ambience/houses': layerHouses }; mkdirSync(`${OUT}sfx`, { recursive: true });