diff --git a/apps/game/assets/audio/ambience/ashflow.wav b/apps/game/assets/audio/ambience/ashflow.wav new file mode 100644 index 0000000..4da5d21 --- /dev/null +++ b/apps/game/assets/audio/ambience/ashflow.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/meadows_night.wav b/apps/game/assets/audio/ambience/meadows_night.wav new file mode 100644 index 0000000..a8254ab --- /dev/null +++ b/apps/game/assets/audio/ambience/meadows_night.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/ponds_night.wav b/apps/game/assets/audio/ambience/ponds_night.wav new file mode 100644 index 0000000..4753f85 --- /dev/null +++ b/apps/game/assets/audio/ambience/ponds_night.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/zvenets_night.wav b/apps/game/assets/audio/ambience/zvenets_night.wav new file mode 100644 index 0000000..4e9740f --- /dev/null +++ b/apps/game/assets/audio/ambience/zvenets_night.wav Binary files differ diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts index b16f36b..7937dc7 100644 --- a/apps/game/src/data/audio.ts +++ b/apps/game/src/data/audio.ts @@ -11,15 +11,23 @@ /** Файловые sfx (WAV) — пусто: все sfx переведены на спек-синтез. */ export const SFX_KEYS: readonly string[] = []; -/** Амбиент-лупы областей. */ -export const AMBIENCE_KEYS = ['ambience/meadows', 'ambience/ponds', 'ambience/zvenets'] as const; +/** Амбиент-лупы областей (день + ночь: кроссфейд по GameClock). */ +export const AMBIENCE_KEYS = [ + 'ambience/meadows', + 'ambience/ponds', + 'ambience/zvenets', + 'ambience/meadows_night', + 'ambience/ponds_night', + 'ambience/zvenets_night' +] as const; -/** Локальные амбиент-слои (вода, гул, дома) и глобальные (ветер). */ +/** Локальные амбиент-слои (вода, гул, дома, накат) и глобальные (ветер). */ export const AMBIENCE_LAYER_KEYS = [ 'ambience/ponds_water', 'ambience/tower_hum', 'ambience/houses', - 'ambience/wind' + 'ambience/wind', + 'ambience/ashflow' ] as const; /** Все аудио-ключи игры (валидация контента; префетч только файловых). */ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index a5f14da..a40eb36 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -59,8 +59,10 @@ export interface AmbienceLayerDef { /** Ключ лупа (Game.AUDIO_KEYS). */ key: string; - /** Тайлы-источники (иды из TILES); пусто/нет — точка at. */ + /** Тайлы-источники (иды из TILES); пусто/нет — точки positions или точка at. */ tiles?: number[]; + /** Явные точки-источники в тайлах (например, тайлы зоны наката). */ + positions?: { x: number; y: number }[]; /** Точка-источник в тайлах (центр башни и т.п.). */ at?: { x: number; y: number }; /** Радиус слышимости в юнитах (за ним слой молчит); нет at/tiles — глобальный слой. */ @@ -130,6 +132,43 @@ fauna?: { x: number; y: number }[]; } +/** Зона наката прудов (низины у берегов): тайлы — ещё и источники слоя ashflow. */ +const HAZARD_PONDS: HazardDef = { + name: 'Свежий накат', + requiresItem: 'cloth', + tiles: [ + { x: 5, y: 16 }, + { x: 6, y: 17 }, + { x: 7, y: 17 }, + { x: 8, y: 18 }, + { x: 9, y: 18 }, + { x: 10, y: 18 }, + { x: 11, y: 19 }, + { x: 12, y: 19 }, + { x: 13, y: 18 }, + { x: 14, y: 18 }, + { x: 15, y: 17 }, + { x: 4, y: 9 }, + { x: 3, y: 8 }, + { x: 15, y: 6 }, + { x: 16, y: 4 } + ] +}; + +/** Зона наката разъезда (наметало у рельсов). */ +const HAZARD_SIDING: HazardDef = { + name: 'Накат у рельсов', + requiresItem: 'cloth', + tiles: [ + { x: 13, y: 15 }, + { x: 14, y: 15 }, + { x: 16, y: 15 }, + { x: 15, y: 14 }, + { x: 16, y: 16 }, + { x: 17, y: 15 } + ] +}; + export const AREAS: Record = { meadows: { id: 'meadows', @@ -222,32 +261,12 @@ ambienceLayers: [ { key: 'ambience/wind', volume: 0.3 }, // Вода — только вплотную к берегу и совсем тихо (у нас лужи, не море). - { key: 'ambience/ponds_water', tiles: [TILES.WATER], radius: 2.5, volume: 0.22 } + { key: 'ambience/ponds_water', tiles: [TILES.WATER], radius: 2.5, volume: 0.22 }, + // Зоны наката дышат пеплом: глухой шелест поверх (слышно в низинах). + { key: 'ambience/ashflow', positions: HAZARD_PONDS.tiles, radius: 2.5, volume: 0.3 } ], // Свежий накат — низины у берегов: без вощёного полотна здесь не дышать. - hazards: [ - { - name: 'Свежий накат', - requiresItem: 'cloth', - tiles: [ - { x: 5, y: 16 }, - { x: 6, y: 17 }, - { x: 7, y: 17 }, - { x: 8, y: 18 }, - { x: 9, y: 18 }, - { x: 10, y: 18 }, - { x: 11, y: 19 }, - { x: 12, y: 19 }, - { x: 13, y: 18 }, - { x: 14, y: 18 }, - { x: 15, y: 17 }, - { x: 4, y: 9 }, - { x: 3, y: 8 }, - { x: 15, y: 6 }, - { x: 16, y: 4 } - ] - } - ], + hazards: [HAZARD_PONDS], interactables: INTERACTABLES.ponds, // Лунные колокольчики собираются только у прудов (квест «Три цветка»). harvestTile: TILES.BELLFLOWER, @@ -386,23 +405,12 @@ // Гул снизу: Машина дышит у люка (та же нота, что башня Звенца). ambienceLayers: [ { key: 'ambience/wind', volume: 0.25 }, - { key: 'ambience/tower_hum', at: { x: 15, y: 15 }, radius: 7, volume: 0.45 } + { key: 'ambience/tower_hum', at: { x: 15, y: 15 }, radius: 7, volume: 0.45 }, + // Накат у рельсов дышит пеплом. + { key: 'ambience/ashflow', positions: HAZARD_SIDING.tiles, radius: 2.5, volume: 0.3 } ], // Свежий накат наметало у рельсов: без полотна рядом с люком не дышать. - hazards: [ - { - name: 'Накат у рельсов', - requiresItem: 'cloth', - tiles: [ - { x: 13, y: 15 }, - { x: 14, y: 15 }, - { x: 16, y: 15 }, - { x: 15, y: 14 }, - { x: 16, y: 16 }, - { x: 17, y: 15 } - ] - } - ] + hazards: [HAZARD_SIDING] }, machine_hall: { id: 'machine_hall', diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1e86c6f..1f97806 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -16,6 +16,7 @@ SpriteDebugView, VirtualJoystick, CutsceneRunner, + dayNightFactor, type Camera, type Invariant, type JsonValue, @@ -405,7 +406,7 @@ showToast: (t) => this.showToast(t) }); - // Аудио локации: roomtone, слои, шорохи, музыка угрозы. + // Аудио локации: roomtone, слои, шорохи, музыка угрозы, день/ночь. this.locationAudio = new LocationAudio({ audio: this.game.audio, worldAudio: this.worldAudio, @@ -413,6 +414,8 @@ layerTilePos: this.layerTilePos, heroPos: () => this.player.position, playTheme: (key) => this.game.playTheme(key), + playAmbience: (key) => this.game.playAmbience(key), + nightFactor: () => dayNightFactor(this.game.clock.hours), threat: () => this.flow.threat() }); @@ -511,8 +514,8 @@ enter(): void { console.log('[location]', this.area.id); // маяк для смоук-тестов - // Амбиент локации (кроссфейд; тот же ключ не перезапускается, '' — тишина). - this.game.playAmbience(this.area.ambience ?? ''); + // Амбиент локации: день/ночь по текущим часам (кроссфейд; '' — тишина). + this.locationAudio.startAmbient(); this.game.playTheme(this.area.theme ?? ''); // Файловые лупы области — в фоне (сцена загрузки декодировала только стартовую). this.game.preloadAudio(areaAudioKeys(this.area)); @@ -555,6 +558,7 @@ this.updateCrownAsh(dt); this.worldAudio.update(dt); this.locationAudio.updateLayers(); + this.locationAudio.updateDayNightAmbient(); this.locationAudio.updateAmbient(dt); this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов diff --git a/apps/game/src/systems/LocationAudio.ts b/apps/game/src/systems/LocationAudio.ts index 0fe59b4..bfd9361 100644 --- a/apps/game/src/systems/LocationAudio.ts +++ b/apps/game/src/systems/LocationAudio.ts @@ -22,6 +22,10 @@ heroPos: () => Vec2; /** Тема области/боя (Game.playTheme: кроссфейд, '' — тишина). */ playTheme: (key: string) => void; + /** Амбиент области (Game.playAmbience: кроссфейд 1.5 с, тот же ключ не перезапускает). */ + playAmbience: (key: string) => void; + /** Фактор ночи 0..1 (кривая суток, как у света). */ + nightFactor: () => number; /** Уровень угрозы боевого мира 0..1 (погоня — 1, wary — 0.4). */ threat: () => number; } @@ -40,6 +44,8 @@ private rustleIn = 6; /** exit() вызван: гасим хвосты асинхронных заводок (roomtone/стем). */ private exited = false; + /** Текущий выбор амбиента день/ночь (для гистерезиса порога). */ + private nightAmbient = false; constructor(private deps: LocationAudioDeps) {} @@ -93,12 +99,17 @@ /** * Дистанция до ближайшего источника слоя (юниты; тайл — центр ромба). - * Слой без at и tiles — глобальный (ветер): источников нет, дистанция 0. + * Слой без at, positions и tiles — глобальный (ветер): источников нет. */ private layerDistance(layer: AmbienceLayerDef, p: Vec2): number { - if (!layer.at && !(layer.tiles && layer.tiles.length > 0)) return 0; + if (!layer.at && !(layer.positions && layer.positions.length > 0) && !(layer.tiles && layer.tiles.length > 0)) + return 0; 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 tile of layer.positions ?? []) { + const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); + if (d < best) best = d; + } for (const id of layer.tiles ?? []) { for (const tile of this.deps.layerTilePos.get(id) ?? []) { const d = Math.hypot(p.x - (tile.x + 0.5), p.y - (tile.y + 0.5)); @@ -108,6 +119,27 @@ return best; } + /** + * Амбиент дня/ночи: за порогом кривой суток (с гистерезисом ±0.05, чтобы + * не трещало на закате) играем ночной луп области — кроссфейд даёт сам + * Game.playAmbience (1.5 с). Ночной ключ — суффикс `_night` у дневного. + */ + updateDayNightAmbient(): void { + const base = this.deps.area.ambience; + if (!base) return; // интерьеры без амбиента — нечего переключать + const night = this.deps.nightFactor() >= (this.nightAmbient ? 0.45 : 0.55); + if (night === this.nightAmbient) return; + this.nightAmbient = night; + this.deps.playAmbience(night ? `${base}_night` : base); + } + + /** Начальный амбиент (enter сцены): день или ночь по текущим часам. */ + startAmbient(): void { + this.nightAmbient = this.deps.nightFactor() >= 0.5; + const base = this.deps.area.ambience; + if (base) this.deps.playAmbience(this.nightAmbient ? `${base}_night` : base); + } + /** Редкие дальние шорохи пепла: панорама и высота — случайные (rng сцены). */ updateAmbient(dt: number): void { this.rustleIn -= dt; diff --git a/apps/game/tools/audio/gen.mjs b/apps/game/tools/audio/gen.mjs index 20c9dd2..598b36b 100644 --- a/apps/game/tools/audio/gen.mjs +++ b/apps/game/tools/audio/gen.mjs @@ -248,8 +248,111 @@ return loopify(normalize(s, 0.55)); } +/** + * Ночь на лугах: воздух ниже и плотнее, свиста нет; вдалеке гудит машина + * (Ржавая роща не спит) и изредка доходит колокол Звенца. + */ +function ambienceMeadowsNight() { + const dur = 8; + const air = bandNoise(72, 80, 700, dur); + const hum = bandNoise(73, 40, 160, dur); + const r = makeRng(17); + const gusts = []; + for (let i = 0; i < 3; i++) gusts.push({ at: r() * dur, len: 1 + r() * 1.5 }); + const bells = [2.5, 6.5]; + const s = synth(dur, (t) => { + // Ночное «дыхание» медленнее и глубже дневного. + const breath = 0.5 + 0.3 * Math.sin((2 * Math.PI * t) / dur + 2) + 0.2 * Math.sin((6 * Math.PI * t) / dur + 4); + let v = air() * 0.3 * breath; + for (const g of gusts) { + const d = t - g.at + (t < g.at ? dur : 0); + if (d >= 0 && d < g.len) v += air() * 0.2 * decay(d, g.len, 1.5); + } + // Дальний гул машины: 55 Гц с медленным тремоло, как будто земля дышит. + v += (Math.sin(2 * Math.PI * 55 * t) * 0.1 + hum() * 0.12) * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / 3)); + // Далёкий колокол: глухой удар, приходящий сквозь ветер. + for (const at of bells) { + const dt = t - at + (t < at ? dur : 0); + if (dt >= 0 && dt < 2) + v += (Math.sin(2 * Math.PI * 111 * dt) * 0.12 + Math.sin(2 * Math.PI * 111 * 2.76 * dt) * 0.04) * decay(dt, 2, 3); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** Ночь у прудов: ветер почти стих, вода слышна громче, капли чаще. */ +function ambiencePondsNight() { + const dur = 8; + const wind = bandNoise(83, 50, 250, dur); + const lap = bandNoise(84, 200, 900, dur); + const r = makeRng(19); + const drops = []; + for (let i = 0; i < 7; i++) drops.push({ at: r() * dur, f: 700 + r() * 500 }); + const s = synth(dur, (t) => { + let v = wind() * 0.22 * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur + 1)); + // Ночью вода «дышит» ровнее и ближе. + v += lap() * 0.14 * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur + 3)); + for (const d of drops) { + const dt = t - d.at + (t < d.at ? dur : 0); + if (dt >= 0 && dt < 0.15) v += Math.sin(2 * Math.PI * d.f * dt * (1 - dt * 3)) * 0.09 * decay(dt, 0.15, 2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + +/** + * Ночь в Звенце: ветер ниже, колокол бьёт реже и глуше, под ним — низкий + * гул башни, не смолкающий и ночью. + */ +function ambienceZvenetsNight() { + const dur = 8; + const wind = bandNoise(93, 60, 350, dur); + const strikes = [ + { at: 2.0, f: 111 }, + { at: 6.0, f: 98 } + ]; + const s = synth(dur, (t) => { + let v = wind() * 0.24 * (0.65 + 0.35 * Math.sin(2 * Math.PI * t / dur + 2.5)); + // Башня не спит: тихий двойной гул под всем прочим. + v += (Math.sin(2 * Math.PI * 55 * t) * 0.08 + Math.sin(2 * Math.PI * 82.5 * t) * 0.05) * (0.6 + 0.4 * Math.sin(2 * Math.PI * t / dur * 2)); + for (const s of strikes) { + const dt = t - s.at + (t < s.at ? dur : 0); + if (dt >= 0 && dt < 2.5) + v += + (Math.sin(2 * Math.PI * s.f * dt) * 0.2 + + Math.sin(2 * Math.PI * s.f * 2.76 * dt) * 0.07 + + Math.sin(2 * Math.PI * s.f * 5.4 * dt) * 0.03) * + decay(dt, 2.5, 3); + } + return v; + }); + return loopify(normalize(s, 0.55)); +} + // ---------- локальные амбиент-слои ---------- +/** Слой «зона наката»: низкий шелест пепла + глухой гул — пепел течёт. */ +function layerAshflow() { + const dur = 8; + const rustle = bandNoise(181, 100, 500, dur); + const r = makeRng(53); + const waves = []; + for (let i = 0; i < 3; i++) waves.push({ at: r() * dur, len: 1.5 + r() }); + const s = synth(dur, (t) => { + // Гул: два расстроенных низа с медленным биением. + let v = (Math.sin(2 * Math.PI * 45 * t) * 0.12 + Math.sin(2 * Math.PI * 47.5 * t) * 0.1) * (0.7 + 0.3 * Math.sin(2 * Math.PI * t / dur)); + // Шелест волнами, как будто пепел перекатывается. + for (const w of waves) { + const dt = t - w.at + (t < w.at ? dur : 0); + if (dt >= 0 && dt < w.len) v += rustle() * 0.2 * decay(dt, w.len, 1.2); + } + return v; + }); + return loopify(normalize(s, 0.5)); +} + /** Слой «вода прудов»: тихий плеск + редкие капли. */ function layerPondsWater() { const dur = 8; @@ -327,10 +430,15 @@ 'ambience/meadows': ambienceMeadows, 'ambience/ponds': ambiencePonds, 'ambience/zvenets': ambienceZvenets, + // Ночные варианты: ниже, плотнее, с дальними механизмами (кроссфейд по часам). + 'ambience/meadows_night': ambienceMeadowsNight, + 'ambience/ponds_night': ambiencePondsNight, + 'ambience/zvenets_night': ambienceZvenetsNight, 'ambience/ponds_water': layerPondsWater, 'ambience/tower_hum': layerTowerHum, 'ambience/houses': layerHouses, - 'ambience/wind': layerWind + 'ambience/wind': layerWind, + 'ambience/ashflow': layerAshflow }; // Записываем WAV только при запуске как CLI, а не при импорте (парити-тест // импортирует формулы bellHit/whoosh, чтобы сверить их со спек-синтезом).