Audio: WAV + tinyaudio, Ambient-Emitter mit Distance ramps; artist.md

engine/wav.rs (Decoder) und render/audio.rs (Mixer, 8 SFX-Voices +
Loop-Voice je Emitter) aus irl3d eingebettet; Emitter-Logik headless in
engine/audio.rs. Empties mit sound/radius-Property senden positional,
play_sound-Signale laufen als SFX über Session::take_sounds.
Platzhalter-Sounds + Emitter in der Testszene; artist.md fasst die
Content-Regeln für die Autorin zusammen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:08:13 +02:00
parent 56d92cdb46
commit 31e1942870
16 changed files with 796 additions and 21 deletions
Generated
+201 -3
View File
@@ -31,6 +31,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -122,6 +131,24 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" 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]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.9.1" version = "0.9.1"
@@ -249,7 +276,16 @@ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
"libc", "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]] [[package]]
@@ -275,6 +311,17 @@ dependencies = [
"rand_core", "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]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.13.1" version = "0.13.1"
@@ -345,6 +392,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
dependencies = [
"bindgen",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.0" version = "0.3.0"
@@ -418,6 +474,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -552,6 +614,12 @@ dependencies = [
"xml-rs", "xml-rs",
] ]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.17.0" version = "0.17.0"
@@ -681,6 +749,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -811,6 +888,19 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 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]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.17" version = "0.1.17"
@@ -871,6 +961,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "naga" name = "naga"
version = "29.0.3" version = "29.0.3"
@@ -891,7 +987,7 @@ dependencies = [
"log", "log",
"num-traits", "num-traits",
"once_cell", "once_cell",
"rustc-hash", "rustc-hash 1.1.0",
"spirv", "spirv",
"thiserror 2.0.18", "thiserror 2.0.18",
"unicode-ident", "unicode-ident",
@@ -927,6 +1023,27 @@ dependencies = [
"jni-sys 0.3.1", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1492,6 +1609,35 @@ dependencies = [
"bitflags 2.13.0", "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]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@@ -1504,6 +1650,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1628,6 +1780,12 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -1832,6 +1990,23 @@ dependencies = [
"strict-num", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -2148,6 +2323,7 @@ dependencies = [
"bytemuck", "bytemuck",
"pollster", "pollster",
"serde_json", "serde_json",
"tinyaudio",
"wgpu", "wgpu",
"winit", "winit",
] ]
@@ -2224,7 +2400,7 @@ dependencies = [
"portable-atomic", "portable-atomic",
"profiling", "profiling",
"raw-window-handle", "raw-window-handle",
"rustc-hash", "rustc-hash 1.1.0",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"wgpu-core-deps-apple", "wgpu-core-deps-apple",
@@ -2340,6 +2516,22 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -2349,6 +2541,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "windows" name = "windows"
version = "0.62.2" version = "0.62.2"
+1
View File
@@ -8,5 +8,6 @@ bladeink = "1.2.5"
bytemuck = { version = "1.25.0", features = ["derive"] } bytemuck = { version = "1.25.0", features = ["derive"] }
pollster = "0.4.0" pollster = "0.4.0"
serde_json = "1" serde_json = "1"
tinyaudio = { version = "2", default-features = false, features = ["pulse"] }
wgpu = "29.0.3" wgpu = "29.0.3"
winit = "0.30.13" winit = "0.30.13"
+74
View File
@@ -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 = <name>` **mit** collide | Klick-Ziel: Anklicken feuert das Signal `<name>` |
| `signal = <name>` **ohne** collide | unsichtbare Trigger-Zone: Betreten feuert `<name>` 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 = <name>` | Ambient-Loop `assets/sounds/<name>.wav` an dieser Position |
| `radius = <m>` | 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 <name>` (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 <datei>.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 <name>` feuert jedes
Signal von Hand, `reload` lädt signals.toml neu.
Binary file not shown.
+3 -3
View File
@@ -9,7 +9,7 @@
# inc <name> [<delta>] KV-Integer +Delta (Default +1) # inc <name> [<delta>] KV-Integer +Delta (Default +1)
# clear <name> KV-Eintrag entfernen # clear <name> KV-Eintrag entfernen
# hide_object <name> deferred (Frontend): Instance ausblenden # hide_object <name> deferred (Frontend): Instance ausblenden
# play_sound <file> deferred (Frontend): SFX abspielen # play_sound <name> deferred (Frontend): SFX aus assets/sounds/{name}.wav
# #
# Reserviertes Signal `[init]` feuert einmal vor der Game-Loop — # Reserviertes Signal `[init]` feuert einmal vor der Game-Loop —
# idealer Ort für KV-Defaults. # idealer Ort für KV-Defaults.
@@ -25,13 +25,13 @@ actions = [
[Mushroom] [Mushroom]
actions = [ actions = [
"inc mushrooms_collected", "inc mushrooms_collected",
"play_sound pickup.wav", "play_sound pickup",
"hide_object $self", "hide_object $self",
] ]
[Person] [Person]
actions = [ actions = [
"play_sound greet.wav", "play_sound greet",
"start_ink person.ink.json", "start_ink person.ink.json",
] ]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
//! Ambient-Emitter aus Blender-Modellen: Empties mit `sound`-Custom-Property
//! sind positionale Dauerschleifen — `sound = <name>` 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 <name>` → `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<Emitter>,
}
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::<f32>().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<f32> {
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<Empty>) -> 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));
}
}
+7 -3
View File
@@ -16,17 +16,20 @@
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst //! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
//! signals::dispatch zu rufen). //! signals::dispatch zu rufen).
//! //!
//! `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an //! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes //! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first: //! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus //! 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 //! `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 //! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless. //! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
pub mod assets; pub mod assets;
pub mod audio;
pub mod collision; pub mod collision;
pub mod game; pub mod game;
pub mod gltf; pub mod gltf;
@@ -38,3 +41,4 @@ pub mod signals;
pub mod story_ctrl; pub mod story_ctrl;
pub mod tga; pub mod tga;
pub mod trigger; pub mod trigger;
pub mod wav;
+4 -3
View File
@@ -15,7 +15,8 @@
//! `inc <name> [<delta>]` KV-Integer inkrementieren (Default +1) //! `inc <name> [<delta>]` KV-Integer inkrementieren (Default +1)
//! `clear <name>` KV-Eintrag entfernen //! `clear <name>` KV-Eintrag entfernen
//! `hide_object <name>` → Action::HideObject (deferred) //! `hide_object <name>` → Action::HideObject (deferred)
//! `play_sound <file>` → Action::PlaySound (deferred) //! `play_sound <name>` → Action::PlaySound (deferred): SFX aus
//! `assets/sounds/{name}.wav`
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/ //! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
//! Eingabemodus wechseln //! Eingabemodus wechseln
//! //!
@@ -182,7 +183,7 @@ mod tests {
let mut signals = Signals::new(); let mut signals = Signals::new();
signals.insert("pickup".into(), vec![ signals.insert("pickup".into(), vec![
"inc items".into(), "inc items".into(),
"play_sound pickup.wav".into(), "play_sound pickup".into(),
"hide_object $self".into(), "hide_object $self".into(),
]); ]);
let mut game = Game::new(signals); 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.kv["items"].coerce_to_int().unwrap(), 1);
assert_eq!(game.actions, vec![ assert_eq!(game.actions, vec![
Action::PlaySound("pickup.wav".into()), Action::PlaySound("pickup".into()),
Action::HideObject("Mushroom.005".into()), Action::HideObject("Mushroom.005".into()),
]); ]);
} }
+132
View File
@@ -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<i16>,
}
pub fn load(path: &str) -> Result<Wav, String> {
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<Wav, String> {
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<Vec<i16>> = 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<u8> {
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());
}
}
+151
View File
@@ -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<Vec<i16>>,
}
impl Sound {
pub fn new(w: wav::Wav) -> Self {
Sound { samples: Arc::new(w.samples) }
}
}
struct Voice {
samples: Arc<Vec<i16>>,
cursor: usize,
gain: f32,
}
#[derive(Default)]
struct Mixer {
sfx: Vec<Option<Voice>>,
/// Loop-Voices der Ambient-Emitter, Index = Emitter-Reihenfolge.
ambient: Vec<Voice>,
}
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<Mutex<Mixer>>,
_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<Self> {
// pipewire-pulse/PulseAudio respektiert diese Variable als
// Ziel-Latenz des Streams (Default ~50200 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<Sound>]) {
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;
}
}
}
+51 -4
View File
@@ -14,6 +14,7 @@
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert //! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt. //! deren Schnittstellen — nie umgekehrt.
mod audio;
mod camera; mod camera;
mod font; mod font;
mod gpu; mod gpu;
@@ -23,6 +24,7 @@ mod scene;
mod sprite; mod sprite;
mod ui; mod ui;
use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver}; use std::sync::mpsc::{self, Receiver};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -35,7 +37,7 @@ use winit::window::{CursorGrabMode, Window, WindowId};
use crate::engine::player; use crate::engine::player;
use crate::engine::tga::Image; 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 crate::session::{FrameInput, Mode, Session};
use camera::Camera; use camera::Camera;
use gpu::Gpu; use gpu::Gpu;
@@ -79,10 +81,22 @@ pub fn run(mut session: Session) {
println!("[scene] {} Modelle, Texturen {:?}{} Vertices, {} Dreiecke", println!("[scene] {} Modelle, Texturen {:?}{} Vertices, {} Dreiecke",
models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3); models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);
// Sim-Welt (Collider, Trigger, Spawn-Empty) in die Session einspielen — // Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session
// die Simulation gehört der Session, nicht dem Render-Frontend. // einspielen — die Simulation gehört der Session, nicht dem
// Render-Frontend.
for (_, m) in &models { session.load_props(m); } 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<Option<audio::Sound>> = 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, // UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und // FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der // 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 // Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
// pollen nebenbei den Befehls-Channel. // pollen nebenbei den Befehls-Channel.
event_loop.set_control_flow(ControlFlow::Poll); 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"); 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 /// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den /// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content. /// 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<audio::Sound> {
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 { fn load_texture(name: &str) -> Image {
let path = assets::path(&format!("assets/textures/{name}.tga")); let path = assets::path(&format!("assets/textures/{name}.tga"));
if std::path::Path::new(&path).exists() { if std::path::Path::new(&path).exists() {
@@ -220,6 +246,11 @@ struct App {
images: Vec<Image>, images: Vec<Image>,
ui_textures: Vec<Image>, ui_textures: Vec<Image>,
fonts: font::Fonts, 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<audio::Audio>,
sfx_cache: HashMap<String, Option<audio::Sound>>,
} }
impl App { impl App {
@@ -230,6 +261,7 @@ impl App {
images: Vec<Image>, images: Vec<Image>,
ui_textures: Vec<Image>, ui_textures: Vec<Image>,
fonts: font::Fonts, fonts: font::Fonts,
audio: Option<audio::Audio>,
) -> Self { ) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session). // Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT; let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
@@ -249,6 +281,8 @@ impl App {
images, images,
ui_textures, ui_textures,
fonts, fonts,
audio,
sfx_cache: HashMap::new(),
} }
} }
@@ -412,6 +446,19 @@ impl App {
self.input.mouse_dy = 0.0; self.input.mouse_dy = 0.0;
self.input.jump = false; 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 // Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu // den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich). // folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
+47 -5
View File
@@ -1,3 +1,4 @@
use crate::engine::audio::Emitters;
use crate::engine::collision::CollisionWorld; use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget}; use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState; use crate::engine::ink::StoryState;
@@ -43,6 +44,13 @@ pub struct Session {
/// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen /// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen
/// (`signal`-Property, siehe engine::trigger). /// (`signal`-Property, siehe engine::trigger).
triggers: Triggers, 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<String>,
signals_path: String, signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep. /// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32, sim_accum: f32,
@@ -87,6 +95,8 @@ impl Session {
player: Player::new([0.0, 1.0, 0.0]), player: Player::new([0.0, 1.0, 0.0]),
collision: CollisionWorld::empty(), collision: CollisionWorld::empty(),
triggers: Triggers::new(), triggers: Triggers::new(),
emitters: Emitters::new(),
pending_sounds: Vec::new(),
signals_path, signals_path,
sim_accum: 0.0, sim_accum: 0.0,
pending_jump: false, pending_jump: false,
@@ -96,13 +106,14 @@ impl Session {
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro /// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`- /// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/ /// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
/// Zonen) und der Spieler-Spawn (Empty mit `role = spawn`; bringen /// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
/// mehrere Modelle einen mit, gewinnt der zuletzt geladene). Die /// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
/// Sichtgeometrie desselben Modells ist Sache des Renderers /// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
/// (render::props). /// desselben Modells ist Sache des Renderers (render::props).
pub fn load_props(&mut self, model: &model::Model) { pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model); self.collision.add_model(model);
self.triggers.add_model(model); self.triggers.add_model(model);
self.emitters.add_model(model);
for e in &model.empties { for e in &model.empties {
if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) { if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) {
self.player = Player::new(e.pos); 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<f32> {
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<String> {
std::mem::take(&mut self.pending_sounds)
}
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu /// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam /// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe: /// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
@@ -328,7 +358,10 @@ impl Session {
for a in actions { for a in actions {
match a { match a {
Action::HideObject(n) => out.push(format!("[action] hide_object {n}")), 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) => { Action::SetMode(t) => {
self.set_mode(t); self.set_mode(t);
out.push(format!("[action] mode {}", t.label())); 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); 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] #[test]
fn spawn_empty_places_player() { fn spawn_empty_places_player() {
let mut s = empty_session(); let mut s = empty_session();