From bc1f89b4a79355bb83a78565340789520f5fc7a8 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Fri, 6 Nov 2026 13:37:28 +0100 Subject: [PATCH] Sounds for weather, rogues and groves: thunder, rain and wind ambience, coin theft, snares, sermons, spores Co-Authored-By: Claude Opus 5.5 --- src/game.rs | 11 ++++++++++- src/main.rs | 1 + src/rogues.rs | 5 +++-- src/sfx.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/sound.rs | 43 ++++++++++++++++++++++++++++++++++++++++++- src/weather.rs | 4 ++++ 6 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/game.rs b/src/game.rs index f814018..bc3eb6f 100644 --- a/src/game.rs +++ b/src/game.rs @@ -62,17 +62,26 @@ pub enum Sfx { Voice4, Voice5, Voice6, + // weather, rogues and groves + Thunder, + Steal, + Snare, + Sermon, + Rain, + Wind, + Spores, } pub const VOICE_SYLLABLES: [Sfx; 6] = [Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6]; -pub const SFX_ALL: [Sfx; 42] = [ +pub const SFX_ALL: [Sfx; 49] = [ Sfx::Dig, Sfx::GoldDig, Sfx::DigDone, Sfx::Claim, Sfx::Deposit, Sfx::Build, Sfx::Sell, Sfx::MeleeHit, Sfx::Shoot, Sfx::Death, Sfx::HeroDeath, Sfx::Lightning, Sfx::Heal, Sfx::Summon, Sfx::PortalArrive, Sfx::WaveAlarm, Sfx::WaveStart, Sfx::CoreHit, Sfx::GateHit, Sfx::GateDefenders, Sfx::LevelUp, Sfx::Pickup, Sfx::Drop, Sfx::Payday, Sfx::FlagSet, Sfx::Victory, Sfx::Defeat, Sfx::Click, Sfx::Error, Sfx::Reinforce, Sfx::Cannon, Sfx::Magic, Sfx::Capture, Sfx::Convert, Sfx::Slap, Sfx::Punch, Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6, + Sfx::Thunder, Sfx::Steal, Sfx::Snare, Sfx::Sermon, Sfx::Rain, Sfx::Wind, Sfx::Spores, ]; impl Sfx { diff --git a/src/main.rs b/src/main.rs index ce8c155..dc068c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1992,6 +1992,7 @@ app.pending_briefing = briefing_of(&campaign, i); for ev in app.game.events.drain(..) { sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); } + sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now); // a won mission goes into the campaign progress (with the carry-over); a co-op mission of an // editor campaign keeps no progress (its index is not a built-in mission) if app.coop_editor { diff --git a/src/rogues.rs b/src/rogues.rs index 6f11d4d..dda59ee 100644 --- a/src/rogues.rs +++ b/src/rogues.rs @@ -94,6 +94,7 @@ impl Game { } e.carry += got; self.fx(5, e.x, e.y, 0.0, 0.0, 0.5, 0xff30d0ff); + self.sfx(Sfx::Steal, e.x, e.y); } return true; } @@ -195,7 +196,7 @@ impl Game { e.flash = 0.2; let (x, y, side, kind, dead) = (e.x, e.y, e.side, e.kind, e.hp <= 0.0); self.fx(2, x, y, 0.0, 0.0, 0.4, 0xff4040ff); - self.sfx(Sfx::MeleeHit, x, y); + self.sfx(Sfx::Snare, x, y); if side == self.local_side { self.tell_local("msg.snare_caught", vec![format!("@{}", st(kind).name)], None); } @@ -244,7 +245,7 @@ impl Game { self.shot(e.x, e.y, 0.6, x, y, 0.45, 0.35, 0xff40d0ff); self.style_last_fx(crate::particles::ST_HOLY); self.fx(4, x, y, 0.0, 0.0, 0.9, 0xffffeecc); - self.sfx(Sfx::Capture, x, y); + self.sfx(Sfx::Sermon, x, y); if side == self.local_side { self.tell_local("msg.converted", vec![format!("@{}", st(kind).name)], Some(Sfx::WaveAlarm)); } diff --git a/src/sfx.rs b/src/sfx.rs index 1b85af7..f3980eb 100644 --- a/src/sfx.rs +++ b/src/sfx.rs @@ -164,6 +164,31 @@ fn def(s: Sfx, r: f32) -> Vec { Sfx::Voice4 => l(&[seg(Square(0.15), 900.0, 980.0, 0.05, 0.13, 10.0), vib(seg(Square(0.2), 700.0, 560.0, 0.06, 0.14, 8.0), 0.04)], 65), Sfx::Voice5 => mix(vec![l(&[vib(seg(Square(0.25), 470.0, 560.0, 0.1, 0.15, 5.0), 0.06)], 66), l(&[seg(Noise, 3000.0, 2000.0, 0.025, 0.06, 30.0)], 67)]), Sfx::Voice6 => l(&[vib(seg(Square(0.2), 680.0, 820.0, 0.06, 0.15, 9.0), 0.05), seg(Square(0.2), 820.0, 700.0, 0.04, 0.12, 12.0)], 68), + // a long rumble rolling away after the crack + Sfx::Thunder => mix(vec![ + l(&[seg(Noise, 420.0, 70.0, 2.4, 0.55, 1.1)], 70), + l(&[vib(seg(Tri, 55.0, 32.0, 2.2, 0.5, 1.2), 0.08)], 71), + l(&[seg(Noise, 160.0, 160.0, 0.35, 0.0, 0.0), seg(Noise, 160.0, 90.0, 1.4, 0.3, 1.6)], 72), + ]), + // the thief's fingers in the coins: a quick, soft jingle + Sfx::Steal => mix(vec![l(&arp(Square(0.25), &[2093.0, 1760.0, 2349.0], 0.04, 0.1), 73), l(&[seg(Noise, 7000.0, 7000.0, 0.03, 0.08, 60.0)], 74)]), + // iron jaws snap shut + Sfx::Snare => mix(vec![l(&[seg(Noise, 8000.0, 3000.0, 0.04, 0.5, 50.0)], 75), l(&[seg(Square(0.5), 1400.0, 300.0, 0.09, 0.22, 25.0)], 76)]), + // the inquisitor's sermon: a short, hollow chant + Sfx::Sermon => mix(vec![ + l(&[vib(seg(Sine, 330.0, 330.0, 0.7, 0.28, 2.0), 0.02)], 77), + l(&[vib(seg(Sine, 392.0, 415.0, 0.7, 0.2, 2.0), 0.02)], 78), + l(&[vib(seg(Sine, 494.0, 494.0, 0.7, 0.16, 2.0), 0.03)], 79), + ]), + // rain on the land: fades in and out, played over and over while it rains + Sfx::Rain => mix(vec![ + l(&[seg(Noise, 7000.0, 7000.0, 0.3, 0.04, -6.0), seg(Noise, 7000.0, 7000.0, 0.9, 0.24, 0.0), seg(Noise, 7000.0, 7000.0, 0.3, 0.24, 6.0)], 80), + l(&[seg(Noise, 2500.0, 2500.0, 0.3, 0.02, -6.0), seg(Noise, 2500.0, 2500.0, 0.9, 0.12, 0.0), seg(Noise, 2500.0, 2500.0, 0.3, 0.12, 6.0)], 81), + ]), + // wind over the land: a slow, howling sweep + Sfx::Wind => l(&[seg(Noise, 300.0, 700.0, 0.6, 0.03, -3.0), seg(Noise, 700.0, 500.0, 1.4, 0.18, 0.2), seg(Noise, 500.0, 250.0, 0.6, 0.15, 3.0)], 82), + // the spores of a grove: a faint chime + Sfx::Spores => l(&arp(Sine, &[2093.0, 2637.0, 3136.0, 2637.0], 0.09, 0.07), 83), } } @@ -171,3 +196,21 @@ fn def(s: Sfx, r: f32) -> Vec { pub fn generate_all(rate: f32) -> Vec> { SFX_ALL.iter().map(|&s| def(s, rate)).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_effect_is_audible_and_does_not_clip() { + let all = generate_all(22050.0); + for (s, buf) in SFX_ALL.iter().zip(all.iter()) { + let peak = buf.iter().fold(0.0f32, |m, v| m.max(v.abs())); + assert!(!buf.is_empty() && peak > 0.02, "{:?} is silent", s); + assert!(peak < 1.5, "{:?} is too loud: {}", s, peak); + } + // the rain loops: it is long enough to overlap when played every 1.25 s + let rain = &all[Sfx::Rain.index()]; + assert!(rain.len() as f32 / 22050.0 > 1.3); + } +} diff --git a/src/sound.rs b/src/sound.rs index e5e1cee..fef0fc3 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -8,11 +8,52 @@ use macroquad::prelude::*; pub struct SoundBank { last: Vec, + next_rain: f64, + next_wind: f64, + next_spores: f64, } impl SoundBank { pub fn new() -> SoundBank { - SoundBank { last: vec![-1.0; SFX_ALL.len()] } + SoundBank { last: vec![-1.0; SFX_ALL.len()], next_rain: 0.0, next_wind: 0.0, next_spores: 0.0 } + } + + // background sounds where the camera looks: rain and wind over the land above ground (the + // samples fade in and out and overlap a little), the chime of a mushroom grove now and then + pub fn ambience(&mut self, g: &crate::game::Game, (cx, cz): (f32, f32), now: f64) { + use crate::map::{idx, inb, Kind}; + let near = |r: i32, f: &dyn Fn(usize) -> bool| { + let (x, z) = (cx as i32, cz as i32); + let mut n = 0; + for dz in (-r..=r).step_by(2) { + for dx in (-r..=r).step_by(2) { + if inb(x + dx, z + dz) && f(idx(x + dx, z + dz)) { + n += 1; + } + } + } + n + }; + if g.state != 0 { + return; + } + let outside = !g.map.sky.is_empty() && near(6, &|i| g.map.sky(i)) >= 6; + let w = &g.weather; + if outside && w.rain() > 0.05 && now >= self.next_rain { + self.next_rain = now + 1.25; + audio::play_sfx(Sfx::Rain.index(), 0.55 * w.rain(), 0.0); + } + let windy = if w.shown == crate::weather::STORM { w.level } else { w.fog() * 0.5 }; + if outside && windy > 0.05 && now >= self.next_wind { + self.next_wind = now + 2.2; + audio::play_sfx(Sfx::Wind.index(), 0.45 * windy, (now.sin() * 0.4) as f32); + } + if now >= self.next_spores { + self.next_spores = now + 3.0 + (now * 7.3).sin().abs() * 3.0; + if near(5, &|i| matches!(g.map.t[i].kind, Kind::Grove)) >= 3 { + audio::play_sfx(Sfx::Spores.index(), 0.35, ((now * 1.7).sin() * 0.5) as f32); + } + } } // minimum gap between two identical sounds (avoids a wall of noise) diff --git a/src/weather.rs b/src/weather.rs index b1ff90a..8aaef26 100644 --- a/src/weather.rs +++ b/src/weather.rs @@ -175,6 +175,10 @@ impl Game { self.weather.flash = self.time; self.fx(1, x, y, 0.0, 0.0, 0.5, 0xffffeeaa); self.sfx(Sfx::Lightning, x, y); + // the thunder rolls over the whole land + if self.events.len() < 64 { + self.events.push(SfxEvent { sfx: Sfx::Thunder, pos: None }); + } } }