diff --git a/Cargo.lock b/Cargo.lock index b0e5e9b..6977e87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -122,6 +131,24 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -249,7 +276,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -275,6 +311,17 @@ dependencies = [ "rand_core", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -345,6 +392,15 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -418,6 +474,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -552,6 +614,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.17.0" @@ -681,6 +749,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -811,6 +888,19 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libpulse-sys" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" +dependencies = [ + "libc", + "num-derive", + "num-traits", + "pkg-config", + "winapi", +] + [[package]] name = "libredox" version = "0.1.17" @@ -871,6 +961,12 @@ dependencies = [ "libc", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "naga" version = "29.0.3" @@ -891,7 +987,7 @@ dependencies = [ "log", "num-traits", "once_cell", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "thiserror 2.0.18", "unicode-ident", @@ -927,6 +1023,27 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1492,6 +1609,35 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -1504,6 +1650,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1628,6 +1780,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -1832,6 +1990,23 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinyaudio" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2bb6a423a786a941cbc5a6544693727d832ec2c088d7da097a9d3bcf0431b5" +dependencies = [ + "core-foundation-sys", + "coreaudio-sys", + "js-sys", + "libpulse-sys", + "ndk", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winapi", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2148,6 +2323,7 @@ dependencies = [ "bytemuck", "pollster", "serde_json", + "tinyaudio", "wgpu", "winit", ] @@ -2224,7 +2400,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", @@ -2340,6 +2516,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2349,6 +2541,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index ccd276b..d3a41d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,5 +8,6 @@ bladeink = "1.2.5" bytemuck = { version = "1.25.0", features = ["derive"] } pollster = "0.4.0" serde_json = "1" +tinyaudio = { version = "2", default-features = false, features = ["pulse"] } wgpu = "29.0.3" winit = "0.30.13" diff --git a/artist.md b/artist.md new file mode 100644 index 0000000..8e31579 --- /dev/null +++ b/artist.md @@ -0,0 +1,74 @@ +Alles Inhaltliche kommt aus vier Quellen: **Blender** (Welt), **Ink** +(Dialoge/Story), **signals.toml** (Verdrahtung), **Assets-Ordner** +(Texturen/Sounds). Fehler in Inhalten stürzen nie ab — sie werden beim +Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …). + +## 3D / Blender + +- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien + dort werden beim Start geladen. +- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen + keine der Properties unten an!). +- **Maßstab:** 1 Blender-Meter = 1 Engine-Meter. Augenhöhe 1,6 m, + Türen/Durchgänge ≥ 2 m hoch und ≥ 0,8 m breit denken. +- **Material-Name = Textur-Name:** Material `holz` → `assets/textures/holz.tga`. + Fehlt die Textur (oder das Material), erscheint die Platzhalter-Textur. +- **Texturen:** TGA, unkomprimiert oder RLE, 24/32 bit. + +### Custom Properties auf Mesh-Objekten + +| Property | Wirkung | +|---|---| +| *(keine)* | nur sichtbar, kein Anfassen | +| `collide = 1` | sichtbar **und** solide (Spieler kollidiert mit der konvexen Hülle des Meshes — Dellen/Löcher werden überspannt) | +| `collide = proxy` | unsichtbares Kollisionsvolumen (für Fein-Collider neben Sichtgeometrie) | +| `signal = ` **mit** collide | Klick-Ziel: Anklicken feuert das Signal `` | +| `signal = ` **ohne** collide | unsichtbare Trigger-Zone: Betreten feuert `` einmal (erneut erst nach Verlassen; „nur einmal überhaupt" selbst per KV-Flag bauen) | + +- `collide = 0` schaltet ab, ohne die Property zu löschen. +- Objektname ist der Interact-Key: Blender-Suffixe (`Ding.001`) werden beim + Signal-Lookup automatisch gestrippt — `[Ding]` in signals.toml trifft alle. + +### Custom Properties auf Empties + +| Property | Wirkung | +|---|---| +| `role = spawn` | Spieler-Startpunkt (Fußpunkt = Empty-Position) | +| `sound = ` | Ambient-Loop `assets/sounds/.wav` an dieser Position | +| `radius = ` | Hörradius des Sounds (linear leiser bis Stille; Default 8) | + +## Audio + +- **Format:** WAV, **mono, 16 bit, 44100 Hz** — nichts anderes. Ablage in + `assets/sounds/`, angesprochen ohne Endung (`pickup` → `pickup.wav`). +- **Ambient:** per Empty (siehe oben), loopt immer — Loop-Punkt sauber schneiden. +- **Einmal-SFX:** per Action `play_sound ` (aus signals.toml oder Ink-Tag). + +## Dialoge / Ink + +- `.ink` nach `assets/interactions/` — wird beim Build automatisch + kompiliert (bei Bedarf von Hand: `./tools/build-dialogues.sh`). +- Gestartet per Action `start_ink .ink.json`. +- **Variablen:** alle Ink-`VAR` landen automatisch im Spiel-KV und synchen + in beide Richtungen — `_`-Prefix (`VAR _tmp = 0`) bleibt Ink-lokal. +- **Tags feuern Actions:** `# set has_key true`, `# play_sound klick`, + `# mode play` … (gleiche Verben wie signals.toml). Unbekannte Tags sind + erlaubt (reine Metadaten). + +## signals.toml (`assets/signals.toml`) + +- Verdrahtet Signal → Aktionen. Signal-Namen: Objektname (Klick), Wert der + `signal`-Property (Zonen/Klick-Ziele), oder frei (aus Ink). +- Verben: `start_ink`, `set`, `inc`, `clear`, `debug_log`, `play_sound`, + `hide_object`, `mode play|free|menu`. +- `$self` = auslösender Objektname (für generische Einträge wie + `hide_object $self`). +- `[init]` feuert einmal beim Start — Ort für KV-Defaults. + +## Testen + +- `cargo run` — Konsole zeigt beim Start, was ankam: Objekte, Dreiecke, + Empties und **jede Custom Property**. Fehlt eine → Export-Haken prüfen. +- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum + freien Fliegen, `kv` zeigt alle Variablen, `signal ` feuert jedes + Signal von Hand, `reload` lädt signals.toml neu. diff --git a/assets/maps/props/testscene.glb b/assets/maps/props/testscene.glb index 25a4536..1cf9946 100644 Binary files a/assets/maps/props/testscene.glb and b/assets/maps/props/testscene.glb differ diff --git a/assets/signals.toml b/assets/signals.toml index 63da974..00f604b 100644 --- a/assets/signals.toml +++ b/assets/signals.toml @@ -9,7 +9,7 @@ # inc [] KV-Integer +Delta (Default +1) # clear KV-Eintrag entfernen # hide_object deferred (Frontend): Instance ausblenden -# play_sound deferred (Frontend): SFX abspielen +# play_sound deferred (Frontend): SFX aus assets/sounds/{name}.wav # # Reserviertes Signal `[init]` feuert einmal vor der Game-Loop — # idealer Ort für KV-Defaults. @@ -25,13 +25,13 @@ actions = [ [Mushroom] actions = [ "inc mushrooms_collected", - "play_sound pickup.wav", + "play_sound pickup", "hide_object $self", ] [Person] actions = [ - "play_sound greet.wav", + "play_sound greet", "start_ink person.ink.json", ] diff --git a/assets/sounds/greet.wav b/assets/sounds/greet.wav new file mode 100644 index 0000000..57148d5 Binary files /dev/null and b/assets/sounds/greet.wav differ diff --git a/assets/sounds/hum.wav b/assets/sounds/hum.wav new file mode 100644 index 0000000..418674e Binary files /dev/null and b/assets/sounds/hum.wav differ diff --git a/assets/sounds/klick.wav b/assets/sounds/klick.wav new file mode 100644 index 0000000..f9527c6 Binary files /dev/null and b/assets/sounds/klick.wav differ diff --git a/assets/sounds/pickup.wav b/assets/sounds/pickup.wav new file mode 100644 index 0000000..bac0fe7 Binary files /dev/null and b/assets/sounds/pickup.wav differ diff --git a/src/engine/audio.rs b/src/engine/audio.rs new file mode 100644 index 0000000..97c2195 --- /dev/null +++ b/src/engine/audio.rs @@ -0,0 +1,125 @@ +//! 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, materials: 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)); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 022787f..846f78f 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -16,17 +16,20 @@ //! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst //! signals::dispatch zu rufen). //! -//! `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an -//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes +//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen +//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes //! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first: //! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus -//! einem Export) und produziert das neutrale `model::Model`. +//! einem Export) und produziert das neutrale `model::Model`. `audio` ist +//! die headless Emitter-Logik (Distance ramps) zum Frontend-Treiber +//! render::audio. //! //! `player` ist die First-Person-Physik, aus der der Renderer seine View //! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den //! Swept-AABB-Trace in `player::step`. Beide bleiben headless. pub mod assets; +pub mod audio; pub mod collision; pub mod game; pub mod gltf; @@ -38,3 +41,4 @@ pub mod signals; pub mod story_ctrl; pub mod tga; pub mod trigger; +pub mod wav; diff --git a/src/engine/signals.rs b/src/engine/signals.rs index a37a3ae..3f26e3d 100644 --- a/src/engine/signals.rs +++ b/src/engine/signals.rs @@ -15,7 +15,8 @@ //! `inc []` KV-Integer inkrementieren (Default +1) //! `clear ` KV-Eintrag entfernen //! `hide_object ` → Action::HideObject (deferred) -//! `play_sound ` → Action::PlaySound (deferred) +//! `play_sound ` → Action::PlaySound (deferred): SFX aus +//! `assets/sounds/{name}.wav` //! `mode ` → Action::SetMode (deferred): Anzeige-/ //! Eingabemodus wechseln //! @@ -182,7 +183,7 @@ mod tests { let mut signals = Signals::new(); signals.insert("pickup".into(), vec![ "inc items".into(), - "play_sound pickup.wav".into(), + "play_sound pickup".into(), "hide_object $self".into(), ]); let mut game = Game::new(signals); @@ -192,7 +193,7 @@ mod tests { assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1); assert_eq!(game.actions, vec![ - Action::PlaySound("pickup.wav".into()), + Action::PlaySound("pickup".into()), Action::HideObject("Mushroom.005".into()), ]); } diff --git a/src/engine/wav.rs b/src/engine/wav.rs new file mode 100644 index 0000000..a740d25 --- /dev/null +++ b/src/engine/wav.rs @@ -0,0 +1,132 @@ +//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples. +//! +//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die +//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten), +//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes +//! File wird gemeldet und bleibt still, nie fatal. +//! +//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz, +//! little-endian — das feste Format des Mixers (render::audio). „Als WAV +//! mono 16-bit 44,1 kHz exportieren" ist die ganze Anleitung; alles andere +//! ist eine klare Fehlermeldung mit Pfad. Über unbekannte RIFF-Chunks +//! (`LIST`, `INFO`, `bext`, …) wird hinweggesprungen. + +/// Sample-Rate des gesamten Audio-Pfads (Decoder-Kontrakt und Mixer-Takt). +pub const SAMPLE_RATE: u32 = 44100; + +pub struct Wav { + pub samples: Vec, +} + +pub fn load(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("wav load {path}: {e}"))?; + parse(&bytes).map_err(|e| format!("wav {path}: {e}")) +} + +pub fn parse(data: &[u8]) -> Result { + if data.len() < 12 || &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" { + return Err("kein RIFF/WAVE".into()); + } + + let mut fmt_found = false; + let mut samples: Option> = None; + + let mut i = 12; + while i + 8 <= data.len() { + let id = &data[i..i + 4]; + let size = u32::from_le_bytes(data[i + 4..i + 8].try_into().unwrap()) as usize; + let body = i + 8; + let end = body + size; + if end > data.len() { return Err("Chunk länger als Datei".into()); } + match id { + b"fmt " => { + if size < 16 { return Err("fmt-Chunk zu kurz".into()); } + let format = u16::from_le_bytes(data[body..body + 2].try_into().unwrap()); + let channels = u16::from_le_bytes(data[body + 2..body + 4].try_into().unwrap()); + let rate = u32::from_le_bytes(data[body + 4..body + 8].try_into().unwrap()); + let bits = u16::from_le_bytes(data[body + 14..body + 16].try_into().unwrap()); + if format != 1 || channels != 1 || bits != 16 || rate != SAMPLE_RATE { + return Err(format!( + "brauche PCM mono 16-bit {SAMPLE_RATE} Hz, ist format={format} \ + channels={channels} bits={bits} rate={rate}")); + } + fmt_found = true; + } + b"data" => { + if !fmt_found { return Err("data-Chunk vor fmt".into()); } + samples = Some(data[body..end].chunks_exact(2) + .map(|c| i16::from_le_bytes([c[0], c[1]])) + .collect()); + } + _ => {} // LIST, INFO, bext, … überspringen + } + // Chunks sind word-aligned (Padding-Byte bei ungerader Größe). + i = end + (size & 1); + } + + match samples { + Some(samples) if !samples.is_empty() => Ok(Wav { samples }), + Some(_) => Err("data-Chunk leer".into()), + None => Err("kein data-Chunk".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimale WAV-Datei bauen, wie ein Exporter sie schriebe. + fn wav_bytes(format: u16, channels: u16, rate: u32, bits: u16, samples: &[i16]) -> Vec { + let data_len = samples.len() * 2; + let mut out = Vec::new(); + out.extend(b"RIFF"); + out.extend(((4 + 8 + 16 + 8 + data_len) as u32).to_le_bytes()); + out.extend(b"WAVE"); + out.extend(b"fmt "); + out.extend(16u32.to_le_bytes()); + out.extend(format.to_le_bytes()); + out.extend(channels.to_le_bytes()); + out.extend(rate.to_le_bytes()); + out.extend((rate * 2).to_le_bytes()); // byte rate + out.extend(2u16.to_le_bytes()); // block align + out.extend(bits.to_le_bytes()); + out.extend(b"data"); + out.extend((data_len as u32).to_le_bytes()); + for s in samples { out.extend(s.to_le_bytes()); } + out + } + + #[test] + fn parses_pcm_mono_16bit() { + let w = parse(&wav_bytes(1, 1, SAMPLE_RATE, 16, &[0, 1000, -1000])).unwrap(); + assert_eq!(w.samples, vec![0, 1000, -1000]); + } + + #[test] + fn skips_unknown_chunks() { + // LIST-Chunk zwischen fmt und data. + let base = wav_bytes(1, 1, SAMPLE_RATE, 16, &[7]); + let mut out = base[..36].to_vec(); // bis inkl. fmt + out.extend(b"LIST"); + out.extend(4u32.to_le_bytes()); + out.extend(b"INFO"); + out.extend(&base[36..]); // data-Chunk + let len = (out.len() - 8) as u32; + out[4..8].copy_from_slice(&len.to_le_bytes()); + assert_eq!(parse(&out).unwrap().samples, vec![7]); + } + + #[test] + fn rejects_wrong_formats() { + assert!(parse(b"OggS...nope").is_err()); + assert!(parse(&wav_bytes(1, 2, SAMPLE_RATE, 16, &[0])).is_err(), "stereo"); + assert!(parse(&wav_bytes(1, 1, 22050, 16, &[0])).is_err(), "falsche Rate"); + assert!(parse(&wav_bytes(1, 1, SAMPLE_RATE, 8, &[0])).is_err(), "8 bit"); + assert!(parse(&wav_bytes(3, 1, SAMPLE_RATE, 16, &[0])).is_err(), "float-PCM"); + } + + #[test] + fn load_missing_file_is_err() { + assert!(load("/nonexistent/x.wav").is_err()); + } +} diff --git a/src/render/audio.rs b/src/render/audio.rs new file mode 100644 index 0000000..820e917 --- /dev/null +++ b/src/render/audio.rs @@ -0,0 +1,151 @@ +//! Audio-Treiber auf tinyaudio — Frontend-Gegenstück zur headless +//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt +//! dieses Modul nie). +//! +//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die +//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte +//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert +//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer). +//! +//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen: +//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt, +//! wird die am weitesten gespielte verdrängt (`play`). +//! - Ambient-Voices: eine je Emitter, loopen immer; ihr Gain ist die +//! Distance ramp, die das Frontend jeden Frame aus +//! `Session::emitter_gains` zieht ([`Audio::set_ambient_gains`]). +//! +//! Gemischt wird im Callback-Thread in f32 über alle aktiven Voices +//! (Sample × Gain), am Ende auf [-1, 1] geklemmt. Der Mixer hängt hinter +//! einem Mutex; Frontend-Seite fasst ihn nur kurz pro Frame/SFX an. + +use std::sync::{Arc, Mutex}; + +use tinyaudio::{run_output_device, OutputDevice, OutputDeviceParameters}; + +use crate::engine::wav; + +const SFX_VOICES: usize = 8; +/// Callback-Puffergröße in Samples (~6 ms bei 44,1 kHz). +const BUFFER_LEN: usize = 256; + +/// Dekodierte Mono-Samples, geteilt zwischen Cache und laufenden Voices. +#[derive(Clone)] +pub struct Sound { + samples: Arc>, +} + +impl Sound { + pub fn new(w: wav::Wav) -> Self { + Sound { samples: Arc::new(w.samples) } + } +} + +struct Voice { + samples: Arc>, + cursor: usize, + gain: f32, +} + +#[derive(Default)] +struct Mixer { + sfx: Vec>, + /// Loop-Voices der Ambient-Emitter, Index = Emitter-Reihenfolge. + ambient: Vec, +} + +impl Mixer { + fn mix(&mut self, out: &mut [f32]) { + for s in out.iter_mut() { + let mut acc = 0.0f32; + for slot in self.sfx.iter_mut() { + if let Some(v) = slot { + acc += v.samples[v.cursor] as f32 * v.gain; + v.cursor += 1; + if v.cursor >= v.samples.len() { *slot = None; } + } + } + for v in self.ambient.iter_mut() { + // Auch bei Gain 0 weiterlaufen lassen: ein Emitter, in dessen + // Radius man zurückkehrt, spielt mitten in seiner Schleife — + // nicht jedes Mal von vorn. + acc += v.samples[v.cursor] as f32 * v.gain; + v.cursor = (v.cursor + 1) % v.samples.len(); + } + *s = (acc / 32768.0).clamp(-1.0, 1.0); + } + } +} + +pub struct Audio { + mixer: Arc>, + _device: OutputDevice, // lebt, solange Audio lebt +} + +impl Audio { + /// Ausgabegerät öffnen. `None`, wenn keins da ist (Meldung, Spiel läuft + /// stumm weiter). + pub fn open() -> Option { + // pipewire-pulse/PulseAudio respektiert diese Variable als + // Ziel-Latenz des Streams (Default ~50–200 ms) — herabsetzen für + // snappige SFX. Muss vor `run_output_device` gesetzt sein; auf + // Windows/macOS ignoriert. + // SAFETY: einmalig beim Start, bevor weitere Threads laufen (wird + // in render::run vor dem stdin-Thread gerufen). + unsafe { std::env::set_var("PULSE_LATENCY_MSEC", "15"); } + + let mixer = Arc::new(Mutex::new(Mixer { + sfx: (0..SFX_VOICES).map(|_| None).collect(), + ambient: Vec::new(), + })); + let cb_mixer = Arc::clone(&mixer); + let device = run_output_device( + OutputDeviceParameters { + channels_count: 1, + sample_rate: wav::SAMPLE_RATE as usize, + channel_sample_count: BUFFER_LEN, + }, + move |buf: &mut [f32]| { cb_mixer.lock().unwrap().mix(buf); }, + ); + match device { + Ok(device) => Some(Audio { mixer, _device: device }), + Err(e) => { + eprintln!("[audio] kein Ausgabegerät ({e}) — Spiel läuft stumm"); + None + } + } + } + + /// Einmal-SFX starten: freier Slot, sonst wird die am weitesten + /// gespielte Voice verdrängt. + pub fn play(&self, s: &Sound) { + if s.samples.is_empty() { return; } + let mut m = self.mixer.lock().unwrap(); + let target = m.sfx.iter().enumerate() + .max_by_key(|(_, slot)| slot.as_ref().map_or(usize::MAX, |v| v.cursor)) + .map_or(0, |(i, _)| i); + m.sfx[target] = Some(Voice { + samples: Arc::clone(&s.samples), cursor: 0, gain: 1.0, + }); + } + + /// Die Ambient-Loop-Voices anlegen — einmal beim Start, Reihenfolge = + /// `Session::emitters` (die Gains kommen dann parallel dazu, deshalb + /// bekommt auch ein nicht ladbarer Sound seine — stille — Voice). + pub fn set_ambients(&self, sounds: &[Option]) { + let mut m = self.mixer.lock().unwrap(); + m.ambient = sounds.iter().map(|s| Voice { + samples: s.as_ref().map_or_else(|| Arc::new(vec![0]), |s| Arc::clone(&s.samples)), + cursor: 0, + gain: 0.0, + }).collect(); + } + + /// Distance-ramp-Gains auf die Ambient-Voices legen — jeden Frame, aus + /// `Session::emitter_gains`. + pub fn set_ambient_gains(&self, gains: &[f32]) { + let mut m = self.mixer.lock().unwrap(); + for (v, g) in m.ambient.iter_mut().zip(gains) { + v.gain = *g; + } + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index 6849194..0a75a41 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -14,6 +14,7 @@ //! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert //! deren Schnittstellen — nie umgekehrt. +mod audio; mod camera; mod font; mod gpu; @@ -23,6 +24,7 @@ mod scene; mod sprite; mod ui; +use std::collections::HashMap; use std::sync::mpsc::{self, Receiver}; use std::sync::Arc; use std::time::Instant; @@ -35,7 +37,7 @@ use winit::window::{CursorGrabMode, Window, WindowId}; use crate::engine::player; use crate::engine::tga::Image; -use crate::engine::{assets, gltf, model, tga}; +use crate::engine::{assets, gltf, model, tga, wav}; use crate::session::{FrameInput, Mode, Session}; use camera::Camera; use gpu::Gpu; @@ -79,10 +81,22 @@ pub fn run(mut session: Session) { println!("[scene] {} Modelle, Texturen {:?} → {} Vertices, {} Dreiecke", models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3); - // Sim-Welt (Collider, Trigger, Spawn-Empty) in die Session einspielen — - // die Simulation gehört der Session, nicht dem Render-Frontend. + // Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session + // einspielen — die Simulation gehört der Session, nicht dem + // Render-Frontend. for (_, m) in &models { session.load_props(m); } + // Audio-Treiber öffnen (vor dem stdin-Thread, siehe Audio::open) und je + // Ambient-Emitter eine Loop-Voice anlegen; die Gains zieht redraw() + // dann jeden Frame aus der Session. Ohne Gerät läuft das Spiel stumm. + let audio = audio::Audio::open(); + if let Some(a) = &audio { + let sounds: Vec> = session.emitters().iter() + .map(|e| load_sound(&e.sound)) + .collect(); + a.set_ambients(&sounds); + } + // UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA, // FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und // Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der @@ -126,7 +140,7 @@ pub fn run(mut session: Session) { // Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und // pollen nebenbei den Befehls-Channel. event_loop.set_control_flow(ControlFlow::Poll); - let mut app = App::new(session, rx, mesh, images, ui_textures, fonts); + let mut app = App::new(session, rx, mesh, images, ui_textures, fonts, audio); event_loop.run_app(&mut app).expect("winit: run"); } @@ -170,6 +184,18 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> { /// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei /// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den /// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content. +/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content — +/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz. +fn load_sound(name: &str) -> Option { + match wav::load(&assets::path(&format!("assets/sounds/{name}.wav"))) { + Ok(w) => Some(audio::Sound::new(w)), + Err(e) => { + eprintln!("[audio] {e}"); + None + } + } +} + fn load_texture(name: &str) -> Image { let path = assets::path(&format!("assets/textures/{name}.tga")); if std::path::Path::new(&path).exists() { @@ -220,6 +246,11 @@ struct App { images: Vec, ui_textures: Vec, fonts: font::Fonts, + /// Audio-Treiber (`None` = kein Gerät, Spiel läuft stumm) und der + /// Lazy-Cache der Einmal-SFX (`None` = Laden schlug fehl, nur einmal + /// melden, nicht jeden Klick). + audio: Option, + sfx_cache: HashMap>, } impl App { @@ -230,6 +261,7 @@ impl App { images: Vec, ui_textures: Vec, fonts: font::Fonts, + audio: Option, ) -> Self { // Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session). let eye_y = session.player.pos[1] + player::EYE_HEIGHT; @@ -249,6 +281,8 @@ impl App { images, ui_textures, fonts, + audio, + sfx_cache: HashMap::new(), } } @@ -412,6 +446,19 @@ impl App { self.input.mouse_dy = 0.0; self.input.jump = false; + // Audio: Distance ramps der Ambient-Emitter nachführen und + // aufgelaufene Einmal-SFX (play_sound-Actions) starten. Die Queue + // immer leeren, auch ohne Gerät — sonst wüchse sie unbegrenzt. + let sfx = self.session.take_sounds(); + if let Some(a) = &self.audio { + a.set_ambient_gains(&self.session.emitter_gains()); + for name in sfx { + let sound = self.sfx_cache.entry(name.clone()) + .or_insert_with(|| load_sound(&name)); + if let Some(s) = sound { a.play(s); } + } + } + // Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf // den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu // folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich). diff --git a/src/session.rs b/src/session.rs index 4a2c733..ee6f611 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,3 +1,4 @@ +use crate::engine::audio::Emitters; use crate::engine::collision::CollisionWorld; use crate::engine::game::{Action, Game, ModeTarget}; use crate::engine::ink::StoryState; @@ -43,6 +44,13 @@ pub struct Session { /// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen /// (`signal`-Property, siehe engine::trigger). triggers: Triggers, + /// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf + /// Empties, siehe engine::audio). + emitters: Emitters, + /// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend + /// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt + /// sie liegen (ihr reicht die `[action]`-Textzeile). + pending_sounds: Vec, signals_path: String, /// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep. sim_accum: f32, @@ -87,6 +95,8 @@ impl Session { player: Player::new([0.0, 1.0, 0.0]), collision: CollisionWorld::empty(), triggers: Triggers::new(), + emitters: Emitters::new(), + pending_sounds: Vec::new(), signals_path, sim_accum: 0.0, pending_jump: false, @@ -96,13 +106,14 @@ impl Session { /// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro /// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`- /// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/ - /// Zonen) und der Spieler-Spawn (Empty mit `role = spawn`; bringen - /// mehrere Modelle einen mit, gewinnt der zuletzt geladene). Die - /// Sichtgeometrie desselben Modells ist Sache des Renderers - /// (render::props). + /// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der + /// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle + /// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie + /// desselben Modells ist Sache des Renderers (render::props). pub fn load_props(&mut self, model: &model::Model) { self.collision.add_model(model); self.triggers.add_model(model); + self.emitters.add_model(model); for e in &model.empties { if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) { self.player = Player::new(e.pos); @@ -110,6 +121,25 @@ impl Session { } } + /// Die Ambient-Emitter in fester Reihenfolge — der Audio-Treiber legt + /// beim Start je Emitter eine Loop-Voice an. + pub fn emitters(&self) -> &[crate::engine::audio::Emitter] { + self.emitters.list() + } + + /// Aktuelle Emitter-Gains (parallel zu [`Session::emitters`]) für die + /// Ohrposition des Spielers — jeden Frame vom Audio-Treiber gezogen. + pub fn emitter_gains(&self) -> Vec { + let p = self.player.pos; + self.emitters.gains([p[0], p[1] + player::EYE_HEIGHT, p[2]]) + } + + /// Aufgelaufene Einmal-SFX (`play_sound`-Actions) abholen — jeden Frame + /// vom Fenster-Frontend gerufen, das sie an den Audio-Treiber gibt. + pub fn take_sounds(&mut self) -> Vec { + std::mem::take(&mut self.pending_sounds) + } + /// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu /// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam /// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe: @@ -328,7 +358,10 @@ impl Session { for a in actions { match a { Action::HideObject(n) => out.push(format!("[action] hide_object {n}")), - Action::PlaySound(n) => out.push(format!("[action] play_sound {n}")), + Action::PlaySound(n) => { + out.push(format!("[action] play_sound {n}")); + self.pending_sounds.push(n); + } Action::SetMode(t) => { self.set_mode(t); out.push(format!("[action] mode {}", t.label())); @@ -561,6 +594,15 @@ mod tests { assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None); } + #[test] + fn play_sound_action_queues_for_frontend() { + let mut s = empty_session(); + // Fall-through-Builtin über den Signal-Trichter, wie ein Ink-Tag. + s.exec("signal play_sound klick"); + assert_eq!(s.take_sounds(), vec!["klick".to_string()]); + assert!(s.take_sounds().is_empty(), "take leert die Queue"); + } + #[test] fn spawn_empty_places_player() { let mut s = empty_session();