//! Ambient-Emitter aus Blender-Modellen: Empties mit `sound`-Custom-Property //! sind positionale Dauerschleifen — `sound = ` löst zu //! `assets/sounds/{name}.wav` auf (macht der Frontend-Treiber), `radius` //! (Meter, optional) bestimmt die Distance ramp. //! //! Headless wie `trigger`: hier lebt nur die *Logik* — wer sendet wo, und //! wie laut ist das gerade für den Hörer ([`Emitters::gains`], lineare //! Rampe [`distance_gain`]). Gerätezugriff und Mixing sind Sache des //! Frontend-Treibers (render::audio), der die Gains jeden Frame zieht und //! auf seine Voices legt — die Engine ruft nie ins Frontend. //! //! Einmal-SFX laufen nicht hier, sondern über den Signal-Dispatcher //! (`play_sound ` → `Action::PlaySound` → `Session::take_sounds`). use crate::engine::model::Model; /// Custom-Property-Schlüssel, der ein Empty zum Ambient-Emitter macht /// (Wert = Sound-Name). Emitter loopen immer; „an/aus" macht die Autorin /// über Distanz/Radius oder (später) per Signal. pub const SOUND_PROP: &str = "sound"; /// Optionaler Hörradius in Metern (Custom Property am selben Empty). pub const RADIUS_PROP: &str = "radius"; const DEFAULT_RADIUS: f32 = 8.0; pub struct Emitter { pub sound: String, pos: [f32; 3], radius: f32, } #[derive(Default)] pub struct Emitters { list: Vec, } impl Emitters { pub fn new() -> Self { Self::default() } /// Emitter eines Modells übernehmen: alle Empties mit `sound`-Property. pub fn add_model(&mut self, model: &Model) { for e in &model.empties { let Some(sound) = e.props.get(SOUND_PROP) else { continue; }; let radius = e.props.get(RADIUS_PROP) .and_then(|r| r.parse::().ok()) .filter(|r| *r > 0.0) .unwrap_or(DEFAULT_RADIUS); self.list.push(Emitter { sound: sound.clone(), pos: e.pos, radius }); } } /// Die Emitter in fester Reihenfolge (Voice-Zuordnung des Treibers). pub fn list(&self) -> &[Emitter] { &self.list } /// Aktueller Gain je Emitter (parallel zu [`Emitters::list`]) für einen /// Hörer bei `listener` — jeden Frame ziehen, auf die Voices legen. pub fn gains(&self, listener: [f32; 3]) -> Vec { self.list.iter().map(|e| { let d = [ e.pos[0] - listener[0], e.pos[1] - listener[1], e.pos[2] - listener[2], ]; let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); distance_gain(dist, e.radius) }).collect() } } /// Lineare Distance ramp: volle Lautstärke am Emitter, Stille ab `radius`. /// Bewusst linear statt 1/d² — bei Zimmer-Skalen klingt die gerade Rampe /// kontrollierbarer, und die Autorin kann per `radius` direkt denken. pub fn distance_gain(dist: f32, radius: f32) -> f32 { (1.0 - dist / radius).clamp(0.0, 1.0) } #[cfg(test)] mod tests { use super::*; use crate::engine::model::{Empty, Props}; fn model(empties: Vec) -> Model { Model { objects: Vec::new(), empties, textures: Vec::new() } } fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty { let mut p = Props::new(); for (k, v) in props { p.insert((*k).into(), (*v).into()); } Empty { name: name.into(), pos, props: p } } #[test] fn collects_sound_empties_with_radius() { let mut em = Emitters::new(); em.add_model(&model(vec![ empty("Radio", [5.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]), empty("spawn", [0.0, 0.0, 0.0], &[("role", "spawn")]), empty("Bach", [9.0, 0.0, 0.0], &[("sound", "wasser")]), ])); assert_eq!(em.list().len(), 2, "nur Empties mit sound-Property"); assert_eq!(em.list()[0].sound, "hum"); assert_eq!(em.list()[1].radius, DEFAULT_RADIUS); } #[test] fn gains_ramp_linearly_to_radius() { let mut em = Emitters::new(); em.add_model(&model(vec![ empty("Radio", [0.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]), ])); assert_eq!(em.gains([0.0, 0.0, 0.0]), vec![1.0]); assert_eq!(em.gains([2.0, 0.0, 0.0]), vec![0.5]); assert_eq!(em.gains([4.0, 0.0, 0.0]), vec![0.0]); assert_eq!(em.gains([9.0, 0.0, 0.0]), vec![0.0], "hinter dem Radius still"); } #[test] fn broken_radius_falls_back_to_default() { let mut em = Emitters::new(); em.add_model(&model(vec![ empty("A", [0.0; 3], &[("sound", "x"), ("radius", "kaputt")]), empty("B", [0.0; 3], &[("sound", "y"), ("radius", "-2")]), ])); assert!(em.list().iter().all(|e| e.radius == DEFAULT_RADIUS)); } }