Sounds for weather, rogues and groves: thunder, rain and wind ambience, coin theft, snares, sermons, spores

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-06 13:37:28 +01:00
1 parent 340014c582
commit bc1f89b4a7
6 files changed
+103 -4

No files matched your search

+10 -1
View File
@@ -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 {
+1
View File
@@ -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 {
+3 -2
View File
@@ -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));
}
+43
View File
@@ -164,6 +164,31 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
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<f32> {
pub fn generate_all(rate: f32) -> Vec<Vec<f32>> {
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);
}
}
+42 -1
View File
@@ -8,11 +8,52 @@ use macroquad::prelude::*;
pub struct SoundBank {
last: Vec<f64>,
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)
+4
View File
@@ -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 });
}
}
}