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:
Generated
+201
-3
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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.
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ~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<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
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user