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:
1 parent
9800ce7156
commit
d7b6d6de7e
4 files changed
+184
-2
No files matched your search
+8
-1
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user