Heart sounds: the hurt soul core beats softly, the rival hearts creak, chime and bubble nearby

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 00:12:08 +01:00
1 parent 9800ce7156
commit d7b6d6de7e
4 files changed
+184 -2

No files matched your search

+8 -1
View File
@@ -70,11 +70,17 @@ pub enum Sfx {
Rain, Rain,
Wind, Wind,
Spores, 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 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::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::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::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::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::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::Thunder, Sfx::Steal, Sfx::Snare, Sfx::Sermon, Sfx::Rain, Sfx::Wind, Sfx::Spores,
Sfx::Heartbeat, Sfx::HeartMoss, Sfx::HeartLily, Sfx::HeartSun,
]; ];
impl Sfx { impl Sfx {
+1
View File
@@ -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.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
} }
sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), 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 // 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) // editor campaign keeps no progress (its index is not a built-in mission)
if app.coop_editor { if app.coop_editor {
+38
View File
@@ -189,6 +189,44 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
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), 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 // 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), 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),
]),
} }
} }
+137 -1
View File
@@ -11,11 +11,90 @@ pub struct SoundBank {
next_rain: f64, next_rain: f64,
next_wind: f64, next_wind: f64,
next_spores: 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 { impl SoundBank {
pub fn new() -> 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 // 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); 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());
}
}