From d7b6d6de7e05f63e85f29e8b0995b9bd0df634fb Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 8 Nov 2026 00:12:08 +0100 Subject: [PATCH] Heart sounds: the hurt soul core beats softly, the rival hearts creak, chime and bubble nearby Co-Authored-By: Claude Opus 5.5 --- src/game.rs | 9 +++- src/main.rs | 1 + src/sfx.rs | 38 ++++++++++++++ src/sound.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 184 insertions(+), 2 deletions(-) diff --git a/src/game.rs b/src/game.rs index 454ff7c..28a7664 100644 --- a/src/game.rs +++ b/src/game.rs @@ -70,11 +70,17 @@ pub enum Sfx { Rain, Wind, Spores, + // hearts (played by the sound module where the camera looks): the soul core when hurt, the + // other masters' hearts (Mossgrim's tree, Lady Nightwell's lily, Saffrax's sun) + Heartbeat, + HeartMoss, + HeartLily, + HeartSun, } pub const VOICE_SYLLABLES: [Sfx; 6] = [Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6]; -pub const SFX_ALL: [Sfx; 49] = [ +pub const SFX_ALL: [Sfx; 53] = [ 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, @@ -82,6 +88,7 @@ pub const SFX_ALL: [Sfx; 49] = [ 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, + Sfx::Heartbeat, Sfx::HeartMoss, Sfx::HeartLily, Sfx::HeartSun, ]; impl Sfx { diff --git a/src/main.rs b/src/main.rs index a42b283..91efe0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2044,6 +2044,7 @@ app.pending_briefing = briefing_of(&campaign, i); sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); } sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now); + sounds.hearts(&app.game, vec2(app.cam.rx, app.cam.rz), right, 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/sfx.rs b/src/sfx.rs index f3980eb..9964199 100644 --- a/src/sfx.rs +++ b/src/sfx.rs @@ -189,6 +189,44 @@ fn def(s: Sfx, r: f32) -> Vec { 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), + // the soul core: a soft, low lub-dub (a quiet click on top so small speakers carry it) + Sfx::Heartbeat => mix(vec![ + l(&[seg(Tri, 78.0, 50.0, 0.11, 0.55, 16.0), seg(Tri, 50.0, 50.0, 0.06, 0.0, 0.0), seg(Tri, 70.0, 46.0, 0.12, 0.38, 18.0)], 84), + l(&[seg(Noise, 600.0, 250.0, 0.04, 0.07, 45.0), seg(Noise, 250.0, 250.0, 0.13, 0.0, 0.0), seg(Noise, 500.0, 220.0, 0.04, 0.05, 45.0)], 85), + ]), + // Mossgrim's tree: old wood creaking under the beat, the moss and twigs rustling + Sfx::HeartMoss => mix(vec![ + l(&[seg(Tri, 90.0, 60.0, 0.12, 0.4, 14.0)], 86), + l(&[seg(Square(0.5), 40.0, 40.0, 0.08, 0.0, 0.0), vib(seg(Square(0.3), 36.0, 54.0, 0.45, 0.07, 3.0), 0.12), seg(Square(0.3), 54.0, 44.0, 0.2, 0.05, 6.0)], 87), + l(&[seg(Noise, 2600.0, 2600.0, 0.25, 0.0, 0.0), seg(Noise, 2600.0, 3400.0, 0.2, 0.05, -4.0), seg(Noise, 3400.0, 1800.0, 0.5, 0.06, 5.0)], 88), + ]), + // Lady Nightwell's lily: a soft beat and a glassy chime (inharmonic partials ringing out) + Sfx::HeartLily => mix(vec![ + l(&[seg(Sine, 110.0, 70.0, 0.12, 0.35, 14.0)], 89), + l(&[seg(Sine, 1568.0, 1568.0, 1.3, 0.09, 3.0)], 90), + l(&[seg(Sine, 2470.0, 2470.0, 1.0, 0.06, 4.5)], 91), + l(&[seg(Sine, 3729.0, 3729.0, 0.7, 0.04, 6.0)], 92), + l(&[seg(Sine, 2093.0, 2093.0, 0.18, 0.0, 0.0), seg(Sine, 2093.0, 2093.0, 0.9, 0.05, 4.0)], 93), + ]), + // Saffrax's sun: a warm beat, then bubbling and a faint fizz like a boiling crucible + Sfx::HeartSun => mix(vec![ + l(&[seg(Tri, 100.0, 64.0, 0.12, 0.42, 14.0)], 94), + l(&[ + seg(Sine, 400.0, 400.0, 0.1, 0.0, 0.0), + seg(Sine, 380.0, 720.0, 0.035, 0.1, 25.0), + seg(Sine, 380.0, 380.0, 0.05, 0.0, 0.0), + seg(Sine, 520.0, 950.0, 0.03, 0.08, 25.0), + seg(Sine, 380.0, 380.0, 0.03, 0.0, 0.0), + seg(Sine, 300.0, 600.0, 0.04, 0.1, 25.0), + seg(Sine, 380.0, 380.0, 0.07, 0.0, 0.0), + seg(Sine, 610.0, 1100.0, 0.03, 0.07, 25.0), + seg(Sine, 380.0, 380.0, 0.04, 0.0, 0.0), + seg(Sine, 450.0, 820.0, 0.035, 0.08, 25.0), + seg(Sine, 380.0, 380.0, 0.06, 0.0, 0.0), + seg(Sine, 700.0, 1250.0, 0.03, 0.06, 25.0), + ], 95), + l(&[seg(Noise, 9000.0, 9000.0, 0.1, 0.0, 0.0), seg(Noise, 9000.0, 11000.0, 0.25, 0.025, -3.0), seg(Noise, 11000.0, 8000.0, 0.35, 0.03, 6.0)], 96), + ]), } } diff --git a/src/sound.rs b/src/sound.rs index fef0fc3..3722c1b 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -11,11 +11,90 @@ pub struct SoundBank { next_rain: f64, next_wind: f64, next_spores: f64, + core_beat: i64, // the soul core's last heard beat + rival_beats: Vec<(u8, i64, f64)>, // per rival side: last beat seen, next time its sound may play +} + +// how far (tiles) a heart is heard from the camera's target: full up to NEAR, silent beyond FAR +const HEART_NEAR: f32 = 3.0; +const HEART_FAR: f32 = 10.0; +// the soul core beats audibly once it has lost this much of its life +const CORE_HURT: f32 = 0.85; +// at least this many seconds between two sounds of one rival heart +const RIVAL_HEART_GAP: f64 = 5.0; + +// a heart's beats per second by its life (0..1), as render3d's draw_heart shows them +fn heart_rate(hp: f32) -> f64 { + 0.9 + (1.0 - hp.clamp(0.0, 1.0) as f64) * 1.6 } impl SoundBank { pub fn new() -> SoundBank { - SoundBank { last: vec![-1.0; SFX_ALL.len()], next_rain: 0.0, next_wind: 0.0, next_spores: 0.0 } + SoundBank { last: vec![-1.0; SFX_ALL.len()], next_rain: 0.0, next_wind: 0.0, next_spores: 0.0, core_beat: i64::MIN, rival_beats: Vec::new() } + } + + // the hearts near the camera, in time with their drawn beat (`now`: the clock the renderer + // animates with): the soul core thumps softly once it is hurt (louder the worse), the other + // masters' hearts give their own sound every few beats. Returns what was played. + pub fn hearts(&mut self, g: &crate::game::Game, listener: Vec2, right: Vec2, now: f64) -> Vec<(Sfx, f32)> { + let mut out = Vec::new(); + if g.state != 0 { + return out; + } + let place = |x: f32, z: f32| { + let d = vec2(x, z) - listener; + let f = 1.0 - ((d.length() - HEART_NEAR) / (HEART_FAR - HEART_NEAR)).clamp(0.0, 1.0); + (f, (d.dot(right) / HEART_FAR).clamp(-1.0, 1.0) * 0.6) + }; + // the soul core + let hp = (g.core_hp / crate::game::CORE_HP).clamp(0.0, 1.0); + let beat = (now * heart_rate(hp)).floor() as i64; + if beat != self.core_beat { + let first = self.core_beat == i64::MIN; + self.core_beat = beat; + let (f, pan) = place(g.map.core.0 as f32 + 1.5, g.map.core.1 as f32 + 1.5); + if !first && hp < CORE_HURT && g.core_hp > 0.0 && f > 0.05 { + let vol = f * (0.25 + 0.45 * (CORE_HURT - hp) / CORE_HURT); + audio::play_sfx(Sfx::Heartbeat.index(), vol, pan); + out.push((Sfx::Heartbeat, vol)); + } + } + // the other masters' hearts (drawn with their clock shifted by side * 0.37) + for rv in g.rivals.iter().filter(|r| r.alive) { + let sfx = match rv.side { + 2 => Sfx::HeartMoss, + 3 => Sfx::HeartLily, + 4 => Sfx::HeartSun, + _ => continue, + }; + let hp = (rv.hp / crate::rival::RIVAL_CORE_HP).clamp(0.0, 1.0); + let beat = ((now + rv.side as f64 * 0.37) * heart_rate(hp)).floor() as i64; + let k = match self.rival_beats.iter().position(|b| b.0 == rv.side) { + Some(k) => k, + None => { + // the first sound only after a moment (no burst when the camera arrives) + self.rival_beats.push((rv.side, beat, now + 1.5)); + continue; + } + }; + let (_, last, next) = self.rival_beats[k]; + if beat == last { + continue; + } + self.rival_beats[k].1 = beat; + let (cx, cz) = rv.centre(); + let shown = g.map.shown(crate::map::idx(rv.core.0 + 1, rv.core.1 + 1)); + let (f, pan) = place(cx, cz); + if now < next || !shown || f <= 0.05 { + continue; + } + // every few seconds, a little irregular so it never sounds like a metronome + self.rival_beats[k].2 = now + RIVAL_HEART_GAP + ((beat as f64) * 1.7).sin().abs() * 2.5; + let vol = 0.5 * f; + audio::play_sfx(sfx.index(), vol, pan); + out.push((sfx, vol)); + } + out } // background sounds where the camera looks: rain and wind over the land above ground (the @@ -87,3 +166,60 @@ impl SoundBank { audio::play_sfx(i, vol, pan); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::{Game, CORE_HP}; + + // plays `secs` seconds at 60 frames per second with the camera at `cam` + fn run(b: &mut SoundBank, g: &Game, cam: Vec2, from: f64, secs: f64) -> Vec<(Sfx, f32)> { + let mut out = Vec::new(); + let mut t = from; + while t < from + secs { + out.extend(b.hearts(g, cam, vec2(1.0, 0.0), t)); + t += 1.0 / 60.0; + } + out + } + + #[test] + fn the_hurt_soul_core_beats_and_the_rival_hearts_sound_now_and_then() { + let mut g = Game::new(7, 64, 64); + let core = vec2(g.map.core.0 as f32 + 1.5, g.map.core.1 as f32 + 1.5); + let mut b = SoundBank::new(); + // a whole heart is silent + assert!(run(&mut b, &g, core, 0.0, 6.0).is_empty()); + // a hurt one thumps with its drawn beat, louder the worse, and only near the camera + g.core_hp = CORE_HP * 0.5; + let half = run(&mut b, &g, core, 10.0, 10.0); + let rate = heart_rate(0.5); + assert!(half.iter().all(|s| s.0 == Sfx::Heartbeat)); + assert!((half.len() as f64 - rate * 10.0).abs() <= 1.5, "{} beats", half.len()); + g.core_hp = CORE_HP * 0.1; + let worse = run(&mut b, &g, core, 30.0, 4.0); + assert!(worse[0].1 > half[0].1 && worse.iter().all(|s| s.1 <= 0.75)); + assert!(run(&mut b, &g, core + vec2(30.0, 0.0), 40.0, 5.0).is_empty()); + g.core_hp = CORE_HP; + // the rivals' hearts: each its own sound, not more often than every RIVAL_HEART_GAP seconds + g.add_rival(2, 50, 50, 1); + g.add_rival(3, 50, 14, 1); + g.add_rival(4, 14, 50, 1); + g.map.explored.clear(); // no fog: every heart in view + for (side, sfx) in [(2, Sfx::HeartMoss), (3, Sfx::HeartLily), (4, Sfx::HeartSun)] { + let rv = g.rivals.iter().find(|r| r.side == side).unwrap(); + let (x, z) = rv.centre(); + let mut b = SoundBank::new(); + let played = run(&mut b, &g, vec2(x, z), 100.0, 30.0); + assert!(played.len() >= 3 && played.len() <= 30 / RIVAL_HEART_GAP as usize + 1, "{:?}: {}", sfx, played.len()); + assert!(played.iter().all(|s| s.0 == sfx && s.1 <= 0.5)); + // far away or hidden in the fog: silence + assert!(run(&mut b, &g, vec2(x + 40.0, z), 200.0, 20.0).iter().all(|s| s.0 != sfx)); + } + g.init_fog(); + let rv = &g.rivals[0]; + let mut b = SoundBank::new(); + assert!(!g.map.shown(crate::map::idx(rv.core.0 + 1, rv.core.1 + 1))); + assert!(run(&mut b, &g, vec2(rv.centre().0, rv.centre().1), 300.0, 20.0).is_empty()); + } +}