Compare commits
12 Commits
31134f1494
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b7228a248 | |||
| 7fd0e3ec5b | |||
| 0f0795c47f | |||
| 0522bda250 | |||
| 075c49200e | |||
| 01f299aa4f | |||
| 31e1942870 | |||
| 56d92cdb46 | |||
| 186ee9a817 | |||
| 9db3929750 | |||
| f7bfdb4a0b | |||
| 67c4a366c7 |
Generated
+202
-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"
|
||||||
@@ -2147,6 +2322,8 @@ dependencies = [
|
|||||||
"bladeink",
|
"bladeink",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"pollster",
|
"pollster",
|
||||||
|
"serde_json",
|
||||||
|
"tinyaudio",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
@@ -2223,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",
|
||||||
@@ -2339,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"
|
||||||
@@ -2348,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"
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ edition = "2024"
|
|||||||
bladeink = "1.2.5"
|
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"
|
||||||
|
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`,
|
||||||
|
`mode play|free|menu`.
|
||||||
|
- `$self` = auslösender Objektname (für generische Einträge wie
|
||||||
|
`set $self true`).
|
||||||
|
- `[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.
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
# Blender 5.0.0 MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,48 +0,0 @@
|
|||||||
// Game: Generic
|
|
||||||
// Format: Standard
|
|
||||||
// entity 0
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
"wad" ""
|
|
||||||
// brush 0
|
|
||||||
{
|
|
||||||
( -80 -16 -32 ) ( -80 -15 -32 ) ( -80 -16 -31 ) 256_arcade_carpet 96 -16 0 0.2 0.2
|
|
||||||
( -112 -16 -32 ) ( -112 -16 -31 ) ( -111 -16 -32 ) 256_arcade_carpet 80 -16 0 0.2 0.2
|
|
||||||
( -112 -16 -32 ) ( -111 -16 -32 ) ( -112 -15 -32 ) 256_arcade_carpet 80 -96 0 0.2 0.2
|
|
||||||
( 48 96 -16 ) ( 48 97 -16 ) ( 49 96 -16 ) 256_arcade_carpet 5.3333435 -57.379303 0 0.2 0.2
|
|
||||||
( 48 112 -16 ) ( 49 112 -16 ) ( 48 112 -15 ) 256_arcade_carpet 80 -16 0 0.2 0.2
|
|
||||||
( 80 96 -16 ) ( 80 96 -15 ) ( 80 97 -16 ) 256_arcade_carpet 96 -16 0 0.2 0.2
|
|
||||||
}
|
|
||||||
// brush 1
|
|
||||||
{
|
|
||||||
( -48 0 4 ) ( -48 1 4 ) ( -48 0 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 48 -16 -16 ) ( 47 -16 -16 ) ( 48 -16 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 48 -16 -16 ) ( 48 -15 -16 ) ( 47 -16 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -48 0 80 ) ( -49 0 80 ) ( -48 1 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -48 0 4 ) ( -48 0 5 ) ( -49 0 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 48 -16 -16 ) ( 48 -16 -15 ) ( 48 -15 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
}
|
|
||||||
// brush 2
|
|
||||||
{
|
|
||||||
( -91.71281292110203 32 4 ) ( -91.21281292110203 32.86602540378445 4 ) ( -91.71281292110203 32 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -30.430780618346965 -21.856406460551035 -16 ) ( -31.296806022131403 -21.356406460551035 -16 ) ( -30.430780618346965 -21.856406460551035 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -30.430780618346965 -21.856406460551035 -16 ) ( -29.930780618346965 -20.990381056766598 -16 ) ( -31.296806022131403 -21.356406460551035 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -105.56921938165303 40 80 ) ( -106.43524478543748 40.500000000000014 80 ) ( -105.06921938165303 40.86602540378446 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -105.56921938165303 40 4 ) ( -105.56921938165303 40 5 ) ( -106.43524478543748 40.500000000000014 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( -44.28718707889798 -13.856406460551028 -16 ) ( -44.28718707889798 -13.856406460551028 -15 ) ( -43.78718707889798 -12.99038105676659 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
}
|
|
||||||
// brush 3
|
|
||||||
{
|
|
||||||
( 36.28718707889797 0 4 ) ( 35.787187078897965 0.866025403784441 4 ) ( 36.28718707889797 0 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 22.430780618346965 -8 4 ) ( 22.430780618346965 -8 5 ) ( 21.56475521456251 -8.499999999999979 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 113.56921938165306 26.14359353944895 -16 ) ( 113.06921938165306 27.0096189432334 -16 ) ( 112.70319397786861 25.643593539448965 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 22.430780618346965 -8 80 ) ( 21.56475521456251 -8.499999999999979 80 ) ( 21.930780618346944 -7.1339745962155305 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 113.56921938165306 26.14359353944895 -16 ) ( 112.70319397786861 25.643593539448965 -16 ) ( 113.56921938165306 26.14359353944895 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
( 99.71281292110204 18.143593539448965 -16 ) ( 99.71281292110204 18.143593539448965 -15 ) ( 99.21281292110204 19.009618943233413 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// entity 1
|
|
||||||
{
|
|
||||||
"classname" "info_player_start"
|
|
||||||
"origin" "2 52 8"
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// Game: Generic
|
|
||||||
// Format: Standard
|
|
||||||
// entity 0
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
"wad" ""
|
|
||||||
// brush 0
|
|
||||||
{
|
|
||||||
( -144 -64 -112 ) ( -144 -63 -112 ) ( -144 -64 -111 ) placeholder 0 -64 0 0.2 0.2
|
|
||||||
( -144 -304 -112 ) ( -144 -304 -111 ) ( -143 -304 -112 ) placeholder 224 -64 0 0.2 0.2
|
|
||||||
( -144 -64 -112 ) ( -143 -64 -112 ) ( -144 -63 -112 ) placeholder 224 0 0 0.2 0.2
|
|
||||||
( -16 64 -80 ) ( -16 65 -80 ) ( -15 64 -80 ) placeholder 224 0 0 0.2 0.2
|
|
||||||
( -16 192 -80 ) ( -15 192 -80 ) ( -16 192 -79 ) placeholder 224 -64 0 0.2 0.2
|
|
||||||
( 304 64 -80 ) ( 304 64 -79 ) ( 304 65 -80 ) placeholder 0 -64 0 0.2 0.2
|
|
||||||
}
|
|
||||||
// brush 1
|
|
||||||
{
|
|
||||||
( 0 -128 -16 ) ( 0 -127 -16 ) ( 0 -128 -15 ) placeholder 0 0 0 1 1
|
|
||||||
( 0 -128 -16 ) ( 0 -128 -15 ) ( 1 -128 -16 ) placeholder -32 0 0 1 1
|
|
||||||
( 0 -128 -16 ) ( 1 -128 -16 ) ( 0 -127 -16 ) placeholder -32 0 0 1 1
|
|
||||||
( 64 -96 0 ) ( 64 -95 0 ) ( 65 -96 0 ) placeholder -32 0 0 1 1
|
|
||||||
( 64 -96 0 ) ( 65 -96 0 ) ( 64 -96 1 ) placeholder -32 0 0 1 1
|
|
||||||
( 64 -96 0 ) ( 64 -96 1 ) ( 64 -95 0 ) placeholder 0 0 0 1 1
|
|
||||||
}
|
|
||||||
// brush 2
|
|
||||||
{
|
|
||||||
( 64 -144 -32 ) ( 64 -143 -32 ) ( 64 -144 -31 ) placeholder -16 -16 0 1 1
|
|
||||||
( 64 -144 -32 ) ( 64 -144 -31 ) ( 65 -144 -32 ) placeholder -32 -16 0 1 1
|
|
||||||
( 64 -144 -32 ) ( 65 -144 -32 ) ( 64 -143 -32 ) placeholder -32 16 0 1 1
|
|
||||||
( 192 -80 -16 ) ( 192 -79 -16 ) ( 193 -80 -16 ) placeholder -32 16 0 1 1
|
|
||||||
( 192 -80 -16 ) ( 193 -80 -16 ) ( 192 -80 -15 ) placeholder -32 -16 0 1 1
|
|
||||||
( 192 -80 -16 ) ( 192 -80 -15 ) ( 192 -79 -16 ) placeholder -16 -16 0 1 1
|
|
||||||
}
|
|
||||||
// brush 3
|
|
||||||
{
|
|
||||||
( 80 16 32 ) ( 80 15 32 ) ( 80 16 31 ) placeholder 0 -16 180 1 1
|
|
||||||
( 144 -96 16 ) ( 145 -96 16 ) ( 144 -96 15 ) placeholder 16 -16 0 1 -1
|
|
||||||
( 144 -96 16 ) ( 144 -97 16 ) ( 145 -96 16 ) placeholder 16 0 0 1 -1
|
|
||||||
( 80 16 32 ) ( 81 16 32 ) ( 80 15 32 ) placeholder 16 0 0 1 -1
|
|
||||||
( 80 16 32 ) ( 80 16 31 ) ( 81 16 32 ) placeholder 16 -16 0 1 -1
|
|
||||||
( 144 -96 16 ) ( 144 -96 15 ) ( 144 -97 16 ) placeholder 0 -16 180 1 1
|
|
||||||
}
|
|
||||||
// brush 4
|
|
||||||
{
|
|
||||||
( 224 -48 -80 ) ( 224 -47 -80 ) ( 224 -48 -79 ) placeholder -144 -96 0 1 1
|
|
||||||
( 224 -48 -80 ) ( 224 -48 -79 ) ( 225 -48 -80 ) placeholder -32 -96 0 1 1
|
|
||||||
( 224 -48 -80 ) ( 225 -48 -80 ) ( 224 -47 -80 ) placeholder -32 144 0 1 1
|
|
||||||
( 288 16 -64 ) ( 288 17 -64 ) ( 289 16 -64 ) placeholder -32 144 0 1 1
|
|
||||||
( 288 16 -64 ) ( 289 16 -64 ) ( 288 16 -63 ) placeholder -32 -96 0 1 1
|
|
||||||
( 288 16 -64 ) ( 288 16 -63 ) ( 288 17 -64 ) placeholder -144 -96 0 1 1
|
|
||||||
}
|
|
||||||
// brush 5
|
|
||||||
{
|
|
||||||
( -144 -560 -80 ) ( -144 -559 -80 ) ( -144 -560 -79 ) placeholder -80 96 0 0.2 0.2
|
|
||||||
( -144 -320 -80 ) ( -144 -320 -79 ) ( -143 -320 -80 ) placeholder -32 96 0 0.2 0.2
|
|
||||||
( -144 -560 -80 ) ( -143 -560 -80 ) ( -144 -559 -80 ) placeholder -32 80 0 0.2 0.2
|
|
||||||
( -16 -432 16 ) ( -16 -431 16 ) ( -15 -432 16 ) placeholder -32 80 0 0.2 0.2
|
|
||||||
( -16 -304 -48 ) ( -15 -304 -48 ) ( -16 -304 -47 ) placeholder -32 96 0 0.2 0.2
|
|
||||||
( 304 -432 -48 ) ( 304 -432 -47 ) ( 304 -431 -48 ) placeholder -80 96 0 0.2 0.2
|
|
||||||
}
|
|
||||||
// brush 6
|
|
||||||
{
|
|
||||||
( -160 -240 -16 ) ( -160 -241 -16 ) ( -160 -240 -17 ) placeholder -240 96 180 1 1
|
|
||||||
( -64 -304 -32 ) ( -63 -304 -32 ) ( -64 -304 -33 ) placeholder 208 96 0 1 -1
|
|
||||||
( -64 -352 -80 ) ( -64 -353 -80 ) ( -63 -352 -80 ) placeholder 208 240 0 1 -1
|
|
||||||
( -160 -240 16 ) ( -159 -240 16 ) ( -160 -241 16 ) placeholder 208 240 0 1 -1
|
|
||||||
( -160 192 -16 ) ( -160 192 -17 ) ( -159 192 -16 ) placeholder 208 96 0 1 -1
|
|
||||||
( -144 -352 -32 ) ( -144 -352 -33 ) ( -144 -353 -32 ) placeholder -240 96 180 1 1
|
|
||||||
}
|
|
||||||
// brush 7
|
|
||||||
{
|
|
||||||
( 176 -144 -80 ) ( 176 -143 -80 ) ( 176 -144 -79 ) placeholder 176 0 0 1 1
|
|
||||||
( 176 -144 -80 ) ( 176 -144 -79 ) ( 177 -144 -80 ) placeholder 16 0 0 1 1
|
|
||||||
( 176 -144 -80 ) ( 177 -144 -80 ) ( 176 -143 -80 ) placeholder 16 -176 0 1 1
|
|
||||||
( 240 -80 -48 ) ( 240 -79 -48 ) ( 241 -80 -48 ) placeholder 16 -176 0 1 1
|
|
||||||
( 240 -80 -64 ) ( 241 -80 -64 ) ( 240 -80 -63 ) placeholder 16 0 0 1 1
|
|
||||||
( 240 -80 -64 ) ( 240 -80 -63 ) ( 240 -79 -64 ) placeholder 176 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// entity 1
|
|
||||||
{
|
|
||||||
"classname" "info_player_start"
|
|
||||||
"origin" "112 -48 56"
|
|
||||||
}
|
|
||||||
+3
-5
@@ -8,8 +8,7 @@
|
|||||||
# set <name> <value> KV setzen (true/false/i32/f32/string)
|
# set <name> <value> KV setzen (true/false/i32/f32/string)
|
||||||
# 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
|
# play_sound <name> deferred (Frontend): SFX aus assets/sounds/{name}.wav
|
||||||
# play_sound <file> deferred (Frontend): SFX abspielen
|
|
||||||
#
|
#
|
||||||
# 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 +24,12 @@ actions = [
|
|||||||
[Mushroom]
|
[Mushroom]
|
||||||
actions = [
|
actions = [
|
||||||
"inc mushrooms_collected",
|
"inc mushrooms_collected",
|
||||||
"play_sound pickup.wav",
|
"play_sound pickup",
|
||||||
"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.
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+287
-66
@@ -1,9 +1,6 @@
|
|||||||
//! Brush-Collision: Swept-AABB-Trace gegen konvexe Brushes (Quake-Hull-Idee).
|
//! Collision: Swept-AABB-Trace gegen konvexe Volumen (Quake-Hull-Idee —
|
||||||
//!
|
//! „Brush" heißt hier: der Schnitt seiner Halbräume `{ n·x ≤ d }`,
|
||||||
//! Ein Brush ist der Schnitt seiner Halbräume `{ n·x ≤ d }` (n nach außen) —
|
//! n nach außen).
|
||||||
//! genau die Ebenen, die auch render::brush rekonstruiert, hier aber in
|
|
||||||
//! **Engine-Koords** und **vollständig** (keine Koplanar-Elimination, kein
|
|
||||||
//! Culling: für Collision zählt das ganze solide Volumen).
|
|
||||||
//!
|
//!
|
||||||
//! Die Box wird nicht selbst getract, sondern per Minkowski-Aufblasung in den
|
//! Die Box wird nicht selbst getract, sondern per Minkowski-Aufblasung in den
|
||||||
//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte
|
//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte
|
||||||
@@ -13,11 +10,19 @@
|
|||||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||||
//! sofort wieder im Kontakt steckt.
|
//! sofort wieder im Kontakt steckt.
|
||||||
//!
|
//!
|
||||||
//! Headless wie der Rest von `engine`: hängt nur an `map` (für die Brush-Ebenen
|
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
|
||||||
//! und die geteilte Koordinaten-Umrechnung). Der Player ruft `trace` in
|
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
|
||||||
//! `player::step`; gebaut wird die Welt einmal vom Renderer aus der `Map`.
|
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
|
||||||
|
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
||||||
|
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
||||||
|
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
||||||
|
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
|
||||||
|
//!
|
||||||
|
//! Headless wie der Rest von `engine`: hängt nur an `model` (Quelle der
|
||||||
|
//! Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
||||||
|
//! Welt einmal vom Frontend aus den Modellen.
|
||||||
|
|
||||||
use crate::engine::map::{self, Map};
|
use crate::engine::model::{Model, Object};
|
||||||
|
|
||||||
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
||||||
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
|
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
|
||||||
@@ -34,7 +39,7 @@ struct ConvexBrush {
|
|||||||
planes: Vec<Plane>,
|
planes: Vec<Plane>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die statische Kollisionswelt: alle soliden Brushes der Map.
|
/// Die statische Kollisionswelt: alle soliden Volumen der geladenen Modelle.
|
||||||
pub struct CollisionWorld {
|
pub struct CollisionWorld {
|
||||||
brushes: Vec<ConvexBrush>,
|
brushes: Vec<ConvexBrush>,
|
||||||
}
|
}
|
||||||
@@ -47,31 +52,30 @@ pub struct Hit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CollisionWorld {
|
impl CollisionWorld {
|
||||||
/// Kollisionswelt aus allen Brushes der Map bauen. Jede Brush-Face liefert
|
/// Leere Welt — Ausgangszustand, bis Modelle geladen sind.
|
||||||
/// eine Ebene (aus drei Punkten, nach Engine-Koords gedreht).
|
|
||||||
pub fn build(world: &Map) -> Self {
|
|
||||||
let mut brushes = Vec::new();
|
|
||||||
for ent in &world.entities {
|
|
||||||
for b in &ent.brushes {
|
|
||||||
if b.faces.len() < 4 { continue; } // kein geschlossenes Volumen
|
|
||||||
let planes = b.faces.iter().map(|f| plane_from(&f.plane)).collect();
|
|
||||||
brushes.push(ConvexBrush { planes });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Self { brushes }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Leere Welt (keine Brushes) — Default, bis eine Map geladen ist.
|
|
||||||
pub fn empty() -> Self {
|
pub fn empty() -> Self {
|
||||||
Self { brushes: Vec::new() }
|
Self { brushes: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collider eines Blender-Modells hinzufügen: jedes Objekt mit
|
||||||
|
/// `collider = true` (aus der `collide`-Property) wird als konvexer
|
||||||
|
/// Brush übernommen (siehe [`object_planes`]).
|
||||||
|
pub fn add_model(&mut self, model: &Model) {
|
||||||
|
for o in &model.objects {
|
||||||
|
if !o.collider { continue; }
|
||||||
|
match object_planes(o) {
|
||||||
|
Some(planes) => self.brushes.push(ConvexBrush { planes }),
|
||||||
|
None => eprintln!("[collision] {}: kein Volumen — ignoriert", o.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte)
|
/// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte)
|
||||||
/// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`.
|
/// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`.
|
||||||
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||||
let mut nearest: Option<Hit> = None;
|
let mut nearest: Option<Hit> = None;
|
||||||
for b in &self.brushes {
|
for b in &self.brushes {
|
||||||
if let Some(h) = trace_brush(b, start, end, half)
|
if let Some(h) = trace_planes(&b.planes, start, end, half)
|
||||||
&& nearest.as_ref().is_none_or(|n| h.frac < n.frac) {
|
&& nearest.as_ref().is_none_or(|n| h.frac < n.frac) {
|
||||||
nearest = Some(h);
|
nearest = Some(h);
|
||||||
}
|
}
|
||||||
@@ -80,17 +84,193 @@ impl CollisionWorld {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Segment `start→end` gegen einen aufgeblasenen konvexen Brush clippen.
|
/// Das konvexe Ebenen-Set eines Modell-Objekts: die **konvexe Hülle** seiner
|
||||||
/// `None`, wenn das Segment den Brush verfehlt oder der Start schon drin steckt
|
/// Vertices ([`hull_planes`]) plus die sechs achsparallelen AABB-Ebenen
|
||||||
/// (dann nicht blocken — sonst bliebe der Player hängen).
|
/// (Bevel-Ebenen, siehe Modul-Doc) — das Mesh darf also konkav sein. Flache
|
||||||
fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
/// Objekte (kein Hüll-Volumen) fallen auf ihre Face-Ebenen zurück, mit den
|
||||||
|
/// Bevel-Ebenen ergibt das die dünne AABB-Platte (brauchbar für flächige
|
||||||
|
/// Trigger-Zonen). `None`, wenn gar kein geschlossenes Volumen entsteht.
|
||||||
|
pub(crate) fn object_planes(o: &Object) -> Option<Vec<Plane>> {
|
||||||
|
if o.verts.is_empty() { return None; }
|
||||||
|
let mut planes = hull_planes(&o.verts).unwrap_or_else(|| face_planes(o));
|
||||||
|
|
||||||
|
// Axiale Bevel-Ebenen aus der AABB (push_plane dedupt achsparallele
|
||||||
|
// Duplikate weg).
|
||||||
|
let (mut bb_min, mut bb_max) = (o.verts[0], o.verts[0]);
|
||||||
|
for v in &o.verts {
|
||||||
|
for k in 0..3 {
|
||||||
|
bb_min[k] = bb_min[k].min(v[k]);
|
||||||
|
bb_max[k] = bb_max[k].max(v[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k in 0..3 {
|
||||||
|
let mut n = [0.0f32; 3];
|
||||||
|
n[k] = 1.0;
|
||||||
|
push_plane(&mut planes, n, bb_max[k]);
|
||||||
|
n[k] = -1.0;
|
||||||
|
push_plane(&mut planes, n, -bb_min[k]);
|
||||||
|
}
|
||||||
|
(planes.len() >= 4).then_some(planes) // sonst kein geschlossenes Volumen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ebene anfügen, wenn keine (nahezu) gleiche schon da ist — Koplanar-Dedupe
|
||||||
|
/// für Hüll-Dreiecke und Bevel-Ebenen.
|
||||||
|
fn push_plane(planes: &mut Vec<Plane>, n: [f32; 3], d: f32) {
|
||||||
|
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - d).abs() < 1e-4) {
|
||||||
|
planes.push(Plane { n, d });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback für degenerierte (flache) Objekte: Ebenen direkt aus den
|
||||||
|
/// Dreiecks-Flächen (koplanare dedupliziert). OBJ-/glTF-Winding ist CCW von
|
||||||
|
/// außen → cross(b−a, c−a) zeigt nach außen.
|
||||||
|
fn face_planes(o: &Object) -> Vec<Plane> {
|
||||||
|
let mut planes: Vec<Plane> = Vec::new();
|
||||||
|
for t in &o.tris {
|
||||||
|
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
|
||||||
|
let n = cross(sub(b, a), sub(c, a));
|
||||||
|
if dot(n, n) < 1e-12 { continue; } // degeneriert (Länge ~0)
|
||||||
|
let n = normalize(n);
|
||||||
|
push_plane(&mut planes, n, dot(n, a));
|
||||||
|
}
|
||||||
|
planes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toleranz der Hüllen-Konstruktion (units): Punkte näher als das an einer
|
||||||
|
/// Ebene gelten als koplanar. Absolut statt relativ — Maßstab ist fix
|
||||||
|
/// (1 unit = 1 Blender-Meter), und die Trace arbeitet mit ~SKIN-Auflösung.
|
||||||
|
const HULL_EPS: f32 = 1e-4;
|
||||||
|
|
||||||
|
/// Ein Hüll-Dreieck während der Konstruktion; `alive = false` = durch einen
|
||||||
|
/// später eingefügten Punkt ersetzt (Tombstone statt teurem Entfernen).
|
||||||
|
struct HullFace {
|
||||||
|
v: [usize; 3],
|
||||||
|
n: [f32; 3],
|
||||||
|
d: f32,
|
||||||
|
alive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hüll-Dreieck `(x, y, z)`, so orientiert, dass der Referenzpunkt `w`
|
||||||
|
/// (ein Punkt im Inneren des Start-Simplex) hinter der Ebene liegt.
|
||||||
|
fn hull_face(pts: &[[f32; 3]], x: usize, y: usize, z: usize, w: usize) -> HullFace {
|
||||||
|
let n = normalize(cross(sub(pts[y], pts[x]), sub(pts[z], pts[x])));
|
||||||
|
let d = dot(n, pts[x]);
|
||||||
|
if dot(n, pts[w]) > d {
|
||||||
|
HullFace { v: [x, z, y], n: [-n[0], -n[1], -n[2]], d: -d, alive: true }
|
||||||
|
} else {
|
||||||
|
HullFace { v: [x, y, z], n, d, alive: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Konvexe Hülle einer Punktwolke als nach außen orientierte Ebenen
|
||||||
|
/// (inkrementeller Quickhull: Start-Tetraeder aus Extrempunkten, dann jeden
|
||||||
|
/// Außenpunkt einfügen — sichtbare Faces sterben, der Horizont wird mit
|
||||||
|
/// neuen Dreiecken zum Punkt geschlossen). `None` bei degenerierter Eingabe
|
||||||
|
/// (alle Punkte ~kollinear/koplanar) — kein Volumen zum Aufspannen.
|
||||||
|
fn hull_planes(pts: &[[f32; 3]]) -> Option<Vec<Plane>> {
|
||||||
|
if pts.len() < 4 { return None; }
|
||||||
|
|
||||||
|
// Start-Simplex: das fernste Paar unter den sechs Achsen-Extrema, dazu
|
||||||
|
// der fernste Punkt zur Kante und der fernste zur aufgespannten Ebene.
|
||||||
|
let mut ext = [0usize; 6];
|
||||||
|
for (i, p) in pts.iter().enumerate() {
|
||||||
|
for k in 0..3 {
|
||||||
|
if p[k] < pts[ext[k]][k] { ext[k] = i; }
|
||||||
|
if p[k] > pts[ext[3 + k]][k] { ext[3 + k] = i; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (mut a, mut b, mut best) = (0, 0, -1.0);
|
||||||
|
for &i in &ext {
|
||||||
|
for &j in &ext {
|
||||||
|
let d = sub(pts[i], pts[j]);
|
||||||
|
let d = dot(d, d);
|
||||||
|
if d > best { best = d; (a, b) = (i, j); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < HULL_EPS * HULL_EPS { return None; } // alle Punkte ~identisch
|
||||||
|
|
||||||
|
let ab = sub(pts[b], pts[a]);
|
||||||
|
let (mut c, mut best) = (0, -1.0);
|
||||||
|
for (i, p) in pts.iter().enumerate() {
|
||||||
|
let x = cross(ab, sub(*p, pts[a]));
|
||||||
|
let x = dot(x, x);
|
||||||
|
if x > best { best = x; c = i; }
|
||||||
|
}
|
||||||
|
// |cross|² = Abstand² · |ab|² → Vergleich gegen EPS²·|ab|².
|
||||||
|
if best < HULL_EPS * HULL_EPS * dot(ab, ab) { return None; } // kollinear
|
||||||
|
|
||||||
|
let n = normalize(cross(ab, sub(pts[c], pts[a])));
|
||||||
|
let (mut e, mut best) = (0, -1.0);
|
||||||
|
for (i, p) in pts.iter().enumerate() {
|
||||||
|
let x = (dot(n, *p) - dot(n, pts[a])).abs();
|
||||||
|
if x > best { best = x; e = i; }
|
||||||
|
}
|
||||||
|
if best < HULL_EPS { return None; } // koplanar
|
||||||
|
|
||||||
|
// Tetraeder-Faces; als Innen-Referenz dient je der vierte Eckpunkt.
|
||||||
|
let mut faces = vec![
|
||||||
|
hull_face(pts, a, b, c, e),
|
||||||
|
hull_face(pts, a, b, e, c),
|
||||||
|
hull_face(pts, a, c, e, b),
|
||||||
|
hull_face(pts, b, c, e, a),
|
||||||
|
];
|
||||||
|
|
||||||
|
for p in 0..pts.len() {
|
||||||
|
// Faces, die den Punkt sehen (Punkt liegt vor ihrer Ebene).
|
||||||
|
let visible: Vec<usize> = faces.iter().enumerate()
|
||||||
|
.filter(|(_, f)| f.alive && dot(f.n, pts[p]) > f.d + HULL_EPS)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
if visible.is_empty() { continue; } // schon in der Hülle
|
||||||
|
|
||||||
|
// Horizont: gerichtete Kanten sichtbarer Faces, deren Gegenkante
|
||||||
|
// nicht selbst zu einem sichtbaren Face gehört. Weil die Windings
|
||||||
|
// konsistent sind, taucht jede innere Kante genau einmal je Richtung
|
||||||
|
// auf — der Horizont ist der Rest.
|
||||||
|
let mut edges: Vec<(usize, usize)> = Vec::new();
|
||||||
|
for &fi in &visible {
|
||||||
|
let [x, y, z] = faces[fi].v;
|
||||||
|
edges.extend([(x, y), (y, z), (z, x)]);
|
||||||
|
faces[fi].alive = false;
|
||||||
|
}
|
||||||
|
for &(x, y) in &edges {
|
||||||
|
if edges.contains(&(y, x)) { continue; } // innere Kante
|
||||||
|
// Horizont-Kante behält ihre Richtung → (x, y, p) zeigt nach
|
||||||
|
// außen. Degenerierte Dreiecke (p ~auf der Kante) überspringen.
|
||||||
|
let nn = cross(sub(pts[y], pts[x]), sub(pts[p], pts[x]));
|
||||||
|
if dot(nn, nn) < 1e-12 { continue; }
|
||||||
|
let n = normalize(nn);
|
||||||
|
faces.push(HullFace { v: [x, y, p], n, d: dot(n, pts[x]), alive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut planes: Vec<Plane> = Vec::new();
|
||||||
|
for f in faces.iter().filter(|f| f.alive) {
|
||||||
|
push_plane(&mut planes, f.n, f.d); // koplanare Hüll-Dreiecke dedupen
|
||||||
|
}
|
||||||
|
Some(planes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Überlappt eine AABB (Mittelpunkt/Halbmaße) das konvexe Volumen? Gleiche
|
||||||
|
/// Minkowski-Aufblasung wie die Trace — „berühren" zählt als drin.
|
||||||
|
pub(crate) fn box_touches(planes: &[Plane], center: [f32; 3], half: [f32; 3]) -> bool {
|
||||||
|
planes.iter().all(|p| {
|
||||||
|
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
|
||||||
|
dot(p.n, center) <= d
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segment `start→end` gegen ein aufgeblasenes konvexes Ebenen-Set clippen.
|
||||||
|
/// `None`, wenn das Segment das Volumen verfehlt oder der Start schon drin
|
||||||
|
/// steckt (dann nicht blocken — sonst bliebe der Player hängen).
|
||||||
|
pub(crate) fn trace_planes(planes: &[Plane], start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||||
let mut enter = f32::NEG_INFINITY; // größter Eintritts-Bruch
|
let mut enter = f32::NEG_INFINITY; // größter Eintritts-Bruch
|
||||||
let mut leave = 1.0f32; // kleinster Austritts-Bruch
|
let mut leave = 1.0f32; // kleinster Austritts-Bruch
|
||||||
let mut normal = [0.0f32; 3];
|
let mut normal = [0.0f32; 3];
|
||||||
let mut entered = false; // überhaupt eine Eintrittsebene gefunden?
|
let mut entered = false; // überhaupt eine Eintrittsebene gefunden?
|
||||||
let mut started_outside = false;
|
let mut started_outside = false;
|
||||||
|
|
||||||
for p in &b.planes {
|
for p in planes {
|
||||||
// Ebene um die Box-Halbgröße nach außen aufblasen (Minkowski).
|
// Ebene um die Box-Halbgröße nach außen aufblasen (Minkowski).
|
||||||
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
|
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
|
||||||
let ds = dot(p.n, start) - d;
|
let ds = dot(p.n, start) - d;
|
||||||
@@ -125,19 +305,8 @@ fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ebene aus drei Face-Punkten (Quake-Reihenfolge), gedreht nach Engine-Koords.
|
// --- kleine Vektor-Helfer (privat; ein gemeinsames engine::vec3 lohnt
|
||||||
/// Wie render::brush::plane, aber direkt im Engine-System: weil `to_engine` die
|
// erst, falls ein dritter Nutzer auftaucht) ----------------------------------
|
||||||
/// Orientierung erhält (det +1), zeigt `cross(c−a, b−a)` weiter nach außen.
|
|
||||||
fn plane_from(p: &[[f32; 3]; 3]) -> Plane {
|
|
||||||
let a = map::to_engine(p[0]);
|
|
||||||
let b = map::to_engine(p[1]);
|
|
||||||
let c = map::to_engine(p[2]);
|
|
||||||
let n = normalize(cross(sub(c, a), sub(b, a)));
|
|
||||||
Plane { n, d: dot(n, a) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- kleine Vektor-Helfer (privat, wie render::brush; ein gemeinsames
|
|
||||||
// engine::vec3 lohnt erst, falls ein dritter Nutzer auftaucht) ---------------
|
|
||||||
|
|
||||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
||||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
||||||
@@ -207,28 +376,80 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_brushes_from_map() {
|
fn collider_object_becomes_convex_brush() {
|
||||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
use crate::engine::model::{test_cube, Model};
|
||||||
let src = r#"
|
let model = Model {
|
||||||
{
|
objects: vec![
|
||||||
"classname" "worldspawn"
|
test_cube("Box", [0.0, 0.0, 0.0], Some("proxy"), None),
|
||||||
{
|
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
|
||||||
( 0 0 0 ) ( 0 1 0 ) ( 0 0 1 ) t 0 0 0 1 1
|
],
|
||||||
( 0 0 0 ) ( 0 0 1 ) ( 1 0 0 ) t 0 0 0 1 1
|
empties: Vec::new(),
|
||||||
( 0 0 0 ) ( 1 0 0 ) ( 0 1 0 ) t 0 0 0 1 1
|
materials: vec![String::new()],
|
||||||
( 64 64 64 ) ( 64 65 64 ) ( 65 64 64 ) t 0 0 0 1 1
|
};
|
||||||
( 64 64 64 ) ( 65 64 64 ) ( 64 64 65 ) t 0 0 0 1 1
|
let mut w = CollisionWorld::empty();
|
||||||
( 64 64 64 ) ( 64 64 65 ) ( 64 65 64 ) t 0 0 0 1 1
|
w.add_model(&model);
|
||||||
}
|
|
||||||
}
|
// Nur Box (Deko hat kein collide); die Hülle des Würfels dedupt auf
|
||||||
"#;
|
// 6 Ebenen, die axialen Bevel-Ebenen sind mit ihnen identisch.
|
||||||
let m = map::parse(src);
|
|
||||||
let w = CollisionWorld::build(&m);
|
|
||||||
assert_eq!(w.brushes.len(), 1);
|
assert_eq!(w.brushes.len(), 1);
|
||||||
assert_eq!(w.brushes[0].planes.len(), 6);
|
assert_eq!(w.brushes[0].planes.len(), 6);
|
||||||
// Engine-Koords des Quake-Würfels [0,64]³: x[0,2], y[0,2], z[-2,0]
|
|
||||||
// (Drehung (x,z,−y)·1/32). Strahl von außerhalb (−X) hindurch.
|
// Strahl von −X hindurch: Kontakt an x≈0 mit −X-Normale.
|
||||||
let h = w.trace([-1.0, 1.0, -1.0], [3.0, 1.0, -1.0], PT);
|
let h = w.trace([-1.0, 0.5, 0.5], [3.0, 0.5, 0.5], PT).unwrap();
|
||||||
assert!(h.is_some(), "Strahl sollte den Brush treffen");
|
assert!((h.frac - 0.25).abs() < 0.02, "frac={}", h.frac);
|
||||||
|
assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hull_of_concave_mesh_spans_the_notch() {
|
||||||
|
// Konkaves „L": zwei Würfel-Objekte liefern die Punktwolke eines
|
||||||
|
// L-Prismas — [0,2]×[0,1]×[0,1] plus [0,1]×[1,2]×[0,1]. Die Hülle
|
||||||
|
// muss die Kerbe (x>1, y>1) mit der Schräge x+y=3 überspannen.
|
||||||
|
let mut verts = Vec::new();
|
||||||
|
for (min, max) in [([0.0f32, 0.0, 0.0], [2.0f32, 1.0, 1.0]),
|
||||||
|
([0.0, 1.0, 0.0], [1.0, 2.0, 1.0])] {
|
||||||
|
for &x in &[min[0], max[0]] {
|
||||||
|
for &y in &[min[1], max[1]] {
|
||||||
|
for &z in &[min[2], max[2]] { verts.push([x, y, z]); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let planes = hull_planes(&verts).unwrap();
|
||||||
|
// 6 AABB-Seiten der Gesamtbox + 1 Schräge über der Kerbe.
|
||||||
|
assert_eq!(planes.len(), 7);
|
||||||
|
let diag = planes.iter()
|
||||||
|
.find(|p| p.n[0] > 0.5 && p.n[1] > 0.5)
|
||||||
|
.expect("Schräge über der Kerbe fehlt");
|
||||||
|
assert!((dot(diag.n, [2.0, 1.0, 0.5]) - diag.d).abs() < 1e-3,
|
||||||
|
"Schräge sollte durch (2,1,·) laufen");
|
||||||
|
|
||||||
|
// Ein Punkt in der Kerbe, unter der Schräge, liegt in der Hülle.
|
||||||
|
assert!(box_touches(&planes, [1.2, 1.2, 0.5], PT));
|
||||||
|
// Die Ecke der Kerbe (über der Schräge) liegt außerhalb.
|
||||||
|
assert!(!box_touches(&planes, [1.9, 1.9, 0.5], PT));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_objects_fall_back_or_are_rejected() {
|
||||||
|
use crate::engine::model::{Object, Props};
|
||||||
|
// Flaches Quad in der XY-Ebene: keine Hülle, aber der Face-Ebenen-
|
||||||
|
// Fallback + Bevels ergeben die dünne Platte.
|
||||||
|
let flat = Object {
|
||||||
|
name: "Zone".into(),
|
||||||
|
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
|
||||||
|
uvs: vec![[0.0, 0.0]; 4],
|
||||||
|
tris: vec![[0, 1, 2], [0, 2, 3]],
|
||||||
|
tri_mats: vec![0; 2],
|
||||||
|
visible: true, collider: false, props: Props::new(),
|
||||||
|
};
|
||||||
|
let planes = object_planes(&flat).unwrap();
|
||||||
|
assert!(box_touches(&planes, [0.5, 0.5, 0.0], PT));
|
||||||
|
assert!(!box_touches(&planes, [0.5, 0.5, 1.0], PT));
|
||||||
|
|
||||||
|
// Ohne Vertices gibt es nichts.
|
||||||
|
let empty = Object { verts: Vec::new(), uvs: Vec::new(), tris: Vec::new(),
|
||||||
|
tri_mats: Vec::new(), ..flat };
|
||||||
|
assert!(object_planes(&empty).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
+1
-3
@@ -26,9 +26,7 @@ pub type Signals = HashMap<String, Vec<String>>;
|
|||||||
/// geleert (konsumiert).
|
/// geleert (konsumiert).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
/// Instance unsichtbar + kollisionslos + nicht mehr interagierbar machen.
|
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
|
||||||
HideObject(String),
|
|
||||||
/// WAV unter `assets/audio/<name>` als SFX abspielen.
|
|
||||||
PlaySound(String),
|
PlaySound(String),
|
||||||
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
|
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
|
||||||
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
|
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
|
||||||
|
//!
|
||||||
|
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann —
|
||||||
|
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
|
||||||
|
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
||||||
|
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/…
|
||||||
|
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die
|
||||||
|
//! Konsumenten.
|
||||||
|
//!
|
||||||
|
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
|
||||||
|
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
|
||||||
|
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
||||||
|
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
||||||
|
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken —
|
||||||
|
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe
|
||||||
|
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
|
||||||
|
//!
|
||||||
|
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
||||||
|
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
|
||||||
|
//!
|
||||||
|
//! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum,
|
||||||
|
//! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders
|
||||||
|
//! als beim trivialen signals-TOML-Subset).
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::engine::model::{apply_props, Empty, Model, Object, Props};
|
||||||
|
|
||||||
|
pub fn load(path: &str) -> Result<Model, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
||||||
|
parse_glb(&bytes).map_err(|e| format!("gltf {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLB-Container: 12-Byte-Header (`glTF`, Version 2, Gesamtlänge), dann
|
||||||
|
/// Chunks aus Länge/Typ/Daten. Wir brauchen JSON (`JSON`) und BIN (`BIN\0`).
|
||||||
|
pub fn parse_glb(bytes: &[u8]) -> Result<Model, String> {
|
||||||
|
if bytes.len() < 12 || &bytes[0..4] != b"glTF" {
|
||||||
|
return Err("kein GLB (Magic fehlt) — in Blender als „glTF Binary (.glb)“ exportieren".into());
|
||||||
|
}
|
||||||
|
let version = u32_at(bytes, 4)?;
|
||||||
|
if version != 2 { return Err(format!("glTF-Version {version}, unterstützt ist 2")); }
|
||||||
|
|
||||||
|
let (mut json, mut bin): (Option<&[u8]>, &[u8]) = (None, &[]);
|
||||||
|
let mut off = 12;
|
||||||
|
while off + 8 <= bytes.len() {
|
||||||
|
let len = u32_at(bytes, off)? as usize;
|
||||||
|
let ty = &bytes[off + 4..off + 8];
|
||||||
|
let data = bytes.get(off + 8..off + 8 + len).ok_or("Chunk länger als Datei")?;
|
||||||
|
match ty {
|
||||||
|
b"JSON" => json = Some(data),
|
||||||
|
b"BIN\0" => bin = data,
|
||||||
|
_ => {} // unbekannte Chunks per Spec ignorieren
|
||||||
|
}
|
||||||
|
off += 8 + len.next_multiple_of(4); // Chunks sind 4-Byte-aligned
|
||||||
|
}
|
||||||
|
let json = json.ok_or("kein JSON-Chunk")?;
|
||||||
|
let doc: Value = serde_json::from_slice(json).map_err(|e| format!("JSON: {e}"))?;
|
||||||
|
build(&doc, bin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
||||||
|
let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() };
|
||||||
|
|
||||||
|
// Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die
|
||||||
|
// Welt-Transform wächst dabei Parent → Kind.
|
||||||
|
let scene = doc["scene"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let roots = doc["scenes"][scene]["nodes"].as_array().cloned().unwrap_or_default();
|
||||||
|
for r in roots {
|
||||||
|
let Some(i) = r.as_u64() else { continue; };
|
||||||
|
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
|
||||||
|
}
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> {
|
||||||
|
let node = &doc["nodes"][idx];
|
||||||
|
if node.is_null() { return Err(format!("Node {idx} fehlt")); }
|
||||||
|
let world = mul(parent, node_local(node));
|
||||||
|
let name = node["name"].as_str().map_or_else(|| format!("node{idx}"), str::to_string);
|
||||||
|
let props = extras_props(&node["extras"]);
|
||||||
|
|
||||||
|
match node["mesh"].as_u64() {
|
||||||
|
Some(mesh) => {
|
||||||
|
let mut o = Object {
|
||||||
|
name, props,
|
||||||
|
visible: true, collider: false, // bis apply_props entscheidet
|
||||||
|
verts: Vec::new(), uvs: Vec::new(),
|
||||||
|
tris: Vec::new(), tri_mats: Vec::new(),
|
||||||
|
};
|
||||||
|
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
|
||||||
|
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
|
||||||
|
if !o.tris.is_empty() { model.objects.push(o); }
|
||||||
|
}
|
||||||
|
// Node ohne Mesh = Empty (Entity-Marker); Position aus der
|
||||||
|
// Welt-Transform (Translationsspalte).
|
||||||
|
None => model.empties.push(Empty {
|
||||||
|
name, props,
|
||||||
|
pos: [world[12], world[13], world[14]],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for c in node["children"].as_array().into_iter().flatten() {
|
||||||
|
if let Some(ci) = c.as_u64() {
|
||||||
|
walk_node(doc, bin, ci as usize, world, model)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein
|
||||||
|
/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen.
|
||||||
|
fn append_mesh(
|
||||||
|
doc: &Value, bin: &[u8], mesh: usize, world: M4,
|
||||||
|
o: &mut Object, materials: &mut Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
|
||||||
|
if prim["mode"].as_u64().unwrap_or(4) != 4 {
|
||||||
|
eprintln!("[gltf] {}: Primitive-Mode ≠ Dreiecke — übersprungen", o.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(pos_acc) = prim["attributes"]["POSITION"].as_u64() else { continue; };
|
||||||
|
let positions = read_floats::<3>(doc, bin, pos_acc as usize)?;
|
||||||
|
let uvs = match prim["attributes"]["TEXCOORD_0"].as_u64() {
|
||||||
|
Some(a) => read_floats::<2>(doc, bin, a as usize)?,
|
||||||
|
None => vec![[0.0, 0.0]; positions.len()],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Material-Name → geteilte Namensliste (wie usemtl); ohne Material "".
|
||||||
|
let mat_name = prim["material"].as_u64()
|
||||||
|
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| {
|
||||||
|
materials.push(mat_name.to_string());
|
||||||
|
materials.len() - 1
|
||||||
|
});
|
||||||
|
|
||||||
|
let base = o.verts.len();
|
||||||
|
for (p, uv) in positions.iter().zip(&uvs) {
|
||||||
|
o.verts.push(transform(world, *p));
|
||||||
|
o.uvs.push(*uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idxs: Vec<usize> = match prim["indices"].as_u64() {
|
||||||
|
Some(a) => read_indices(doc, bin, a as usize)?,
|
||||||
|
None => (0..positions.len()).collect(), // non-indexed: sequenziell
|
||||||
|
};
|
||||||
|
for t in idxs.chunks_exact(3) {
|
||||||
|
if t.iter().any(|&i| i >= positions.len()) {
|
||||||
|
return Err(format!("{}: Index außerhalb der Positionen", o.name));
|
||||||
|
}
|
||||||
|
o.tris.push([base + t[0], base + t[1], base + t[2]]);
|
||||||
|
o.tri_mats.push(mi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `node.extras` (Objekt) → Props; Skalare werden zu Strings vereinheitlicht,
|
||||||
|
/// verschachtelte Werte als kompaktes JSON durchgereicht.
|
||||||
|
fn extras_props(extras: &Value) -> Props {
|
||||||
|
let mut props = Props::new();
|
||||||
|
if let Some(map) = extras.as_object() {
|
||||||
|
for (k, v) in map {
|
||||||
|
let s = match v {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
props.insert(k.clone(), s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
props
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accessor-Dekodierung ----------------------------------------------------
|
||||||
|
|
||||||
|
/// Bytes eines Accessors samt Element-Stride auflösen (BufferView-Offset,
|
||||||
|
/// Accessor-Offset, optionaler byteStride — sonst dicht gepackt).
|
||||||
|
fn accessor_bytes<'a>(doc: &Value, bin: &'a [u8], acc: usize, elem_size: usize)
|
||||||
|
-> Result<(&'a [u8], usize, usize), String>
|
||||||
|
{
|
||||||
|
let a = &doc["accessors"][acc];
|
||||||
|
let count = a["count"].as_u64().ok_or("Accessor ohne count")? as usize;
|
||||||
|
let view = a["bufferView"].as_u64().ok_or("Accessor ohne bufferView")? as usize;
|
||||||
|
let v = &doc["bufferViews"][view];
|
||||||
|
let v_off = v["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let a_off = a["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let stride = v["byteStride"].as_u64().map_or(elem_size, |s| s as usize);
|
||||||
|
let start = v_off + a_off;
|
||||||
|
let need = start + stride * count.saturating_sub(1) + elem_size;
|
||||||
|
if need > bin.len() { return Err("Accessor ragt aus dem BIN-Chunk".into()); }
|
||||||
|
Ok((&bin[start..], stride, count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N-Komponenten-float-Accessor (POSITION: N=3, TEXCOORD: N=2) lesen.
|
||||||
|
fn read_floats<const N: usize>(doc: &Value, bin: &[u8], acc: usize) -> Result<Vec<[f32; N]>, String> {
|
||||||
|
let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0);
|
||||||
|
if ctype != 5126 {
|
||||||
|
return Err(format!("Accessor {acc}: componentType {ctype}, erwartet float (5126)"));
|
||||||
|
}
|
||||||
|
let (bytes, stride, count) = accessor_bytes(doc, bin, acc, N * 4)?;
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let mut e = [0.0f32; N];
|
||||||
|
for (k, v) in e.iter_mut().enumerate() {
|
||||||
|
let o = i * stride + k * 4;
|
||||||
|
*v = f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||||
|
}
|
||||||
|
out.push(e);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index-Accessor lesen (u8/u16/u32 → usize).
|
||||||
|
fn read_indices(doc: &Value, bin: &[u8], acc: usize) -> Result<Vec<usize>, String> {
|
||||||
|
let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0);
|
||||||
|
let size = match ctype {
|
||||||
|
5121 => 1, // u8
|
||||||
|
5123 => 2, // u16
|
||||||
|
5125 => 4, // u32
|
||||||
|
_ => return Err(format!("Index-Accessor {acc}: componentType {ctype}")),
|
||||||
|
};
|
||||||
|
let (bytes, stride, count) = accessor_bytes(doc, bin, acc, size)?;
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let o = i * stride;
|
||||||
|
out.push(match size {
|
||||||
|
1 => bytes[o] as usize,
|
||||||
|
2 => u16::from_le_bytes(bytes[o..o + 2].try_into().unwrap()) as usize,
|
||||||
|
_ => u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as usize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Transform-Helfer (4×4 column-major, wie glTFs `matrix`) -----------------
|
||||||
|
|
||||||
|
type M4 = [f32; 16];
|
||||||
|
|
||||||
|
const IDENTITY: M4 = [
|
||||||
|
1.0, 0.0, 0.0, 0.0,
|
||||||
|
0.0, 1.0, 0.0, 0.0,
|
||||||
|
0.0, 0.0, 1.0, 0.0,
|
||||||
|
0.0, 0.0, 0.0, 1.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Lokale Transform eines Nodes: explizite `matrix` oder T·R·S
|
||||||
|
/// (glTF-Reihenfolge; fehlende Anteile sind Identität).
|
||||||
|
fn node_local(node: &Value) -> M4 {
|
||||||
|
if let Some(m) = node["matrix"].as_array() {
|
||||||
|
let mut out = IDENTITY;
|
||||||
|
for (i, v) in m.iter().take(16).enumerate() {
|
||||||
|
out[i] = v.as_f64().unwrap_or(0.0) as f32;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let t = vecn::<3>(&node["translation"], [0.0, 0.0, 0.0]);
|
||||||
|
let q = vecn::<4>(&node["rotation"], [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
let s = vecn::<3>(&node["scale"], [1.0, 1.0, 1.0]);
|
||||||
|
|
||||||
|
// Rotationsmatrix aus dem Quaternion (x, y, z, w), Spalten skaliert,
|
||||||
|
// Translation in die vierte Spalte — direkt komponiertes T·R·S.
|
||||||
|
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
|
||||||
|
let r = [
|
||||||
|
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + z * w), 2.0 * (x * z - y * w)],
|
||||||
|
[2.0 * (x * y - z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + x * w)],
|
||||||
|
[2.0 * (x * z + y * w), 2.0 * (y * z - x * w), 1.0 - 2.0 * (x * x + y * y)],
|
||||||
|
]; // r[spalte][zeile]
|
||||||
|
let mut out = IDENTITY;
|
||||||
|
for c in 0..3 {
|
||||||
|
for row in 0..3 {
|
||||||
|
out[c * 4 + row] = r[c][row] * s[c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[12] = t[0]; out[13] = t[1]; out[14] = t[2];
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vecn<const N: usize>(v: &Value, default: [f32; N]) -> [f32; N] {
|
||||||
|
let Some(arr) = v.as_array() else { return default; };
|
||||||
|
let mut out = default;
|
||||||
|
for (o, x) in out.iter_mut().zip(arr) {
|
||||||
|
if let Some(f) = x.as_f64() { *o = f as f32; }
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(a: M4, b: M4) -> M4 {
|
||||||
|
let mut out = [0.0; 16];
|
||||||
|
for c in 0..4 {
|
||||||
|
for r in 0..4 {
|
||||||
|
out[c * 4 + r] = (0..4).map(|k| a[k * 4 + r] * b[c * 4 + k]).sum();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
|
||||||
|
let mut out = [0.0; 3];
|
||||||
|
for (r, o) in out.iter_mut().enumerate() {
|
||||||
|
*o = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r];
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u32_at(bytes: &[u8], off: usize) -> Result<u32, String> {
|
||||||
|
bytes.get(off..off + 4)
|
||||||
|
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||||||
|
.ok_or_else(|| "Datei zu kurz".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// GLB aus JSON-Text und BIN-Daten zusammensetzen (mit 4-Byte-Padding),
|
||||||
|
/// wie es ein Exporter täte.
|
||||||
|
fn glb(json: &str, bin: &[u8]) -> Vec<u8> {
|
||||||
|
let mut j = json.as_bytes().to_vec();
|
||||||
|
while !j.len().is_multiple_of(4) { j.push(b' '); }
|
||||||
|
let mut b = bin.to_vec();
|
||||||
|
while !b.len().is_multiple_of(4) { b.push(0); }
|
||||||
|
let total = 12 + 8 + j.len() + 8 + b.len();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend(b"glTF");
|
||||||
|
out.extend(2u32.to_le_bytes());
|
||||||
|
out.extend((total as u32).to_le_bytes());
|
||||||
|
out.extend((j.len() as u32).to_le_bytes());
|
||||||
|
out.extend(b"JSON");
|
||||||
|
out.extend(j);
|
||||||
|
out.extend((b.len() as u32).to_le_bytes());
|
||||||
|
out.extend(b"BIN\0");
|
||||||
|
out.extend(b);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein
|
||||||
|
/// Mesh-Node mit Translation und extras, ein Empty mit extras.
|
||||||
|
fn sample_glb() -> Vec<u8> {
|
||||||
|
let mut bin: Vec<u8> = Vec::new();
|
||||||
|
for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); }
|
||||||
|
for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); }
|
||||||
|
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
|
||||||
|
let json = r#"{
|
||||||
|
"asset": {"version": "2.0"},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [{"nodes": [0, 2]}],
|
||||||
|
"nodes": [
|
||||||
|
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
||||||
|
"extras": {"signal": "tiffany", "hp": 3},
|
||||||
|
"children": [1]},
|
||||||
|
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}},
|
||||||
|
{"name": "spawn", "translation": [1, 2, 3]}
|
||||||
|
],
|
||||||
|
"meshes": [{"primitives": [{
|
||||||
|
"attributes": {"POSITION": 0, "TEXCOORD_0": 1},
|
||||||
|
"indices": 2, "material": 0
|
||||||
|
}]}],
|
||||||
|
"materials": [{"name": "carpet"}],
|
||||||
|
"accessors": [
|
||||||
|
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
|
||||||
|
{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"},
|
||||||
|
{"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"}
|
||||||
|
],
|
||||||
|
"bufferViews": [
|
||||||
|
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
||||||
|
{"buffer": 0, "byteOffset": 36, "byteLength": 24},
|
||||||
|
{"buffer": 0, "byteOffset": 60, "byteLength": 6}
|
||||||
|
],
|
||||||
|
"buffers": [{"byteLength": 66}]
|
||||||
|
}"#;
|
||||||
|
glb(json, &bin)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_geometry_with_baked_transforms() {
|
||||||
|
let m = parse_glb(&sample_glb()).unwrap();
|
||||||
|
assert_eq!(m.objects.len(), 2);
|
||||||
|
|
||||||
|
let d = &m.objects[0];
|
||||||
|
assert_eq!(d.name, "Ding");
|
||||||
|
assert_eq!(d.tris, vec![[0, 1, 2]]);
|
||||||
|
assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken
|
||||||
|
// signal-Property ohne Collider → Betretens-Zone, wird nie gerendert.
|
||||||
|
assert!(!d.visible && !d.collider);
|
||||||
|
assert_eq!(m.materials, vec!["carpet".to_string()]);
|
||||||
|
|
||||||
|
// Kind erbt die Parent-Transform (10, 0, 0);
|
||||||
|
// collide="proxy" → unsichtbarer Collider.
|
||||||
|
let k = &m.objects[1];
|
||||||
|
assert_eq!(k.name, "Kind");
|
||||||
|
assert!(!k.visible && k.collider);
|
||||||
|
assert_eq!(k.verts[0], [10.0, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extras_become_props_and_empties_are_captured() {
|
||||||
|
let m = parse_glb(&sample_glb()).unwrap();
|
||||||
|
let d = &m.objects[0];
|
||||||
|
assert_eq!(d.props.get("signal").map(String::as_str), Some("tiffany"));
|
||||||
|
assert_eq!(d.props.get("hp").map(String::as_str), Some("3")); // Zahl → String
|
||||||
|
|
||||||
|
assert_eq!(m.empties.len(), 1);
|
||||||
|
let e = &m.empties[0];
|
||||||
|
assert_eq!(e.name, "spawn");
|
||||||
|
assert_eq!(e.pos, [1.0, 2.0, 3.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uvs_pass_through_unchanged() {
|
||||||
|
// glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip.
|
||||||
|
let m = parse_glb(&sample_glb()).unwrap();
|
||||||
|
assert_eq!(m.objects[0].uvs[2], [1.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_glb_and_wrong_version() {
|
||||||
|
assert!(parse_glb(b"PNG...whatever").is_err());
|
||||||
|
let mut v1 = sample_glb();
|
||||||
|
v1[4] = 1; // Version patchen
|
||||||
|
assert!(parse_glb(&v1).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
-16
@@ -3,13 +3,19 @@
|
|||||||
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
|
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
|
||||||
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
|
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
|
||||||
//! (Skript sieht aktuelle Spielvariablen)
|
//! (Skript sieht aktuelle Spielvariablen)
|
||||||
//! - nach `continue`: alle KV-Keys, die das Skript kennt, zurücklesen
|
//! - nach `continue`: **alle** im Skript deklarierten `VAR`s zurücklesen
|
||||||
//! (Ink-`~ var = …`-Writes landen im KV)
|
//! (Auto-Import). So wandern Werte wie `snaks` oder `favor_nails` ohne
|
||||||
|
//! Registrierung automatisch ins nächste per `start_ink` verkettete
|
||||||
|
//! Skript — die Autorin legt einfach `VAR`s an, die Engine trägt sie.
|
||||||
//!
|
//!
|
||||||
//! Variablen die der KV kennt, das Skript aber nicht als `VAR` deklariert,
|
//! Ausnahme: `VAR`s mit `_`-Prefix sind skript-lokal und werden in keine
|
||||||
//! lehnt bladeink beim `set_variable` ab — wir ignorieren das still.
|
//! Richtung synchronisiert. KV-Variablen, die das Skript nicht als `VAR`
|
||||||
//! Variablen die nur im Skript existieren, werden beim Read-Back ignoriert
|
//! deklariert, lehnt bladeink beim `set_variable` ab — wir ignorieren das
|
||||||
//! (KV bleibt minimal: nur was Rust kennt).
|
//! still.
|
||||||
|
//!
|
||||||
|
//! bladeink bietet keine öffentliche Enumeration der Globals; die
|
||||||
|
//! Deklarationsliste wird deshalb beim Laden per Quelltext-Scan aus dem
|
||||||
|
//! kompilierten JSON gezogen (siehe [`global_var_names`]).
|
||||||
|
|
||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
|
|
||||||
@@ -26,6 +32,33 @@ pub enum StoryState {
|
|||||||
pub struct Story {
|
pub struct Story {
|
||||||
inner: BladeStory,
|
inner: BladeStory,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
|
/// Im Skript deklarierte globale `VAR`s (ohne `_`-lokale) — die Menge,
|
||||||
|
/// die `sync_to_kv` in den KV exportiert.
|
||||||
|
vars: Vec<String>,
|
||||||
|
/// Skript-Pfad, nur für Fehlermeldungen.
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle globalen `VAR`-Namen aus kompiliertem Ink-JSON ziehen. Inklecate
|
||||||
|
/// kodiert jede Global-Zuweisung — die Deklaration in `"global decl"` ebenso
|
||||||
|
/// wie jedes `~ var = …` — als `{"VAR=":"name"}`; ein simpler Scan über den
|
||||||
|
/// Quelltext findet also genau die Globals (temp-Variablen kodieren als
|
||||||
|
/// `"temp="` und Reads als `"VAR?"`). `_`-Prefix = skript-lokal → aussortiert.
|
||||||
|
fn global_var_names(src: &str) -> Vec<String> {
|
||||||
|
const PAT: &str = "\"VAR=\":\"";
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
let mut rest = src;
|
||||||
|
while let Some(i) = rest.find(PAT) {
|
||||||
|
rest = &rest[i + PAT.len()..];
|
||||||
|
if let Some(end) = rest.find('"') {
|
||||||
|
let name = &rest[..end];
|
||||||
|
if !name.starts_with('_') && !names.iter().any(|n| n == name) {
|
||||||
|
names.push(name.to_string());
|
||||||
|
}
|
||||||
|
rest = &rest[end..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Story {
|
impl Story {
|
||||||
@@ -35,9 +68,21 @@ impl Story {
|
|||||||
pub fn load(path: &str, kv: &Store) -> Result<Self, String> {
|
pub fn load(path: &str, kv: &Store) -> Result<Self, String> {
|
||||||
let src = read_to_string(path)
|
let src = read_to_string(path)
|
||||||
.map_err(|e| format!("ink load {path}: {e}"))?;
|
.map_err(|e| format!("ink load {path}: {e}"))?;
|
||||||
let inner = BladeStory::new(&src)
|
Self::from_src(&src, path, kv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [`load`], nur direkt aus dem JSON-Quelltext — `path` dient
|
||||||
|
/// ausschließlich Fehlermeldungen. Eigener Schritt, damit Tests ohne
|
||||||
|
/// Dateisystem arbeiten können.
|
||||||
|
fn from_src(src: &str, path: &str, kv: &Store) -> Result<Self, String> {
|
||||||
|
let inner = BladeStory::new(src)
|
||||||
.map_err(|e| format!("ink parse {path}: {e:?}"))?;
|
.map_err(|e| format!("ink parse {path}: {e:?}"))?;
|
||||||
let mut s = Self { inner, tags: Vec::new() };
|
let mut s = Self {
|
||||||
|
inner,
|
||||||
|
tags: Vec::new(),
|
||||||
|
vars: global_var_names(src),
|
||||||
|
path: path.to_string(),
|
||||||
|
};
|
||||||
s.sync_from_kv(kv);
|
s.sync_from_kv(kv);
|
||||||
Ok(s)
|
Ok(s)
|
||||||
}
|
}
|
||||||
@@ -54,8 +99,16 @@ impl Story {
|
|||||||
// Text verloren.
|
// Text verloren.
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
while self.inner.can_continue() {
|
while self.inner.can_continue() {
|
||||||
let chunk = self.inner.cont()
|
// Runtime-Fehler (z.B. „ran out of content" bei einem Skript-Pfad
|
||||||
.unwrap_or_else(|e| panic!("ink cont: {:?}", e));
|
// ohne Divert) beenden den Dialog und melden nach stderr — ein
|
||||||
|
// Autoren-Fehler im Ink darf das laufende Spiel nicht crashen.
|
||||||
|
let chunk = match self.inner.cont() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[ink] {}: {e:?}", self.path);
|
||||||
|
return StoryState::End;
|
||||||
|
}
|
||||||
|
};
|
||||||
text.push_str(&chunk);
|
text.push_str(&chunk);
|
||||||
if let Ok(t) = self.inner.get_current_tags() {
|
if let Ok(t) = self.inner.get_current_tags() {
|
||||||
self.tags.extend(t);
|
self.tags.extend(t);
|
||||||
@@ -75,24 +128,92 @@ impl Story {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn choose(&mut self, i: usize) {
|
pub fn choose(&mut self, i: usize) {
|
||||||
self.inner.choose_choice_index(i)
|
// Fehler (z.B. Index außerhalb der Choices) nur melden — der nächste
|
||||||
.unwrap_or_else(|e| panic!("ink choose {}: {:?}", i, e));
|
// `cont()` präsentiert dann denselben Zustand erneut.
|
||||||
|
if let Err(e) = self.inner.choose_choice_index(i) {
|
||||||
|
eprintln!("[ink] {}: choose {i}: {e:?}", self.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_from_kv(&mut self, kv: &Store) {
|
fn sync_from_kv(&mut self, kv: &Store) {
|
||||||
for (name, value) in kv {
|
for (name, value) in kv {
|
||||||
|
if name.starts_with('_') { continue; } // skript-lokal
|
||||||
// set_variable schlägt fehl wenn der Name kein deklariertes
|
// set_variable schlägt fehl wenn der Name kein deklariertes
|
||||||
// VAR im Skript ist — still ignorieren.
|
// VAR im Skript ist — still ignorieren.
|
||||||
let _ = self.inner.set_variable(name, value);
|
let _ = self.inner.set_variable(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-Import: alle deklarierten `VAR`s des Skripts in den KV übernehmen
|
||||||
|
/// (siehe Modul-Doc). Der KV wächst damit um jede neue Skript-Variable —
|
||||||
|
/// genau so wandern Werte in per `start_ink` verkettete Folgeskripte.
|
||||||
fn sync_to_kv(&self, kv: &mut Store) {
|
fn sync_to_kv(&self, kv: &mut Store) {
|
||||||
let keys: Vec<String> = kv.keys().cloned().collect();
|
for name in &self.vars {
|
||||||
for name in keys {
|
if let Some(v) = self.inner.get_variable(name) {
|
||||||
if let Some(v) = self.inner.get_variable(&name) {
|
kv.insert(name.clone(), v);
|
||||||
kv.insert(name, v);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bladeink::value_type::ValueType;
|
||||||
|
|
||||||
|
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
|
||||||
|
// VAR snaks = 0
|
||||||
|
// VAR _local = 0
|
||||||
|
// S={snaks} → gibt den (ggf. aus dem KV gesyncten) Wert aus
|
||||||
|
// ~ snaks = 1
|
||||||
|
// ~ _local = 5
|
||||||
|
// -> END
|
||||||
|
const MINIMAL: &str = r##"{"inkVersion":21,"root":[["^S=","ev",{"VAR?":"snaks"},"out","/ev","\n","ev",1,{"VAR=":"snaks","re":true},"/ev","ev",5,{"VAR=":"_local","re":true},"/ev","end",["done",{"#n":"g-0"}],null],"done",{"global decl":["ev",0,{"VAR=":"snaks"},0,{"VAR=":"_local"},"/ev","end",null]}],"listDefs":{}}"##;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_var_names_scans_dedupes_and_filters_locals() {
|
||||||
|
let names = global_var_names(MINIMAL);
|
||||||
|
assert_eq!(names, vec!["snaks"]); // dedupliziert, `_local` gefiltert
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declared_vars_auto_import_into_kv() {
|
||||||
|
// KV kennt `snaks` vorher nicht — nach dem Lauf steht es trotzdem
|
||||||
|
// drin (Auto-Import), `_local` bleibt draußen.
|
||||||
|
let mut kv = Store::new();
|
||||||
|
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
||||||
|
assert!(matches!(s.cont(&mut kv), StoryState::Text(_)));
|
||||||
|
assert!(matches!(kv["snaks"], ValueType::Int(1)));
|
||||||
|
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_values_flow_into_the_script() {
|
||||||
|
// Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`).
|
||||||
|
let mut kv = Store::new();
|
||||||
|
kv.insert("snaks".into(), ValueType::Int(7));
|
||||||
|
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
||||||
|
match s.cont(&mut kv) {
|
||||||
|
StoryState::Text(t) => assert_eq!(t, "S=7"),
|
||||||
|
_ => panic!("erwartete Textzeile"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_error_ends_story_instead_of_panicking() {
|
||||||
|
// Flow läuft ohne `done`/`end` aus (wie ein Skript-Pfad ohne Divert)
|
||||||
|
// → bladeink meldet „ran out of content". Erwartung: End, kein Panic.
|
||||||
|
let src = r#"{"inkVersion":21,"root":[["^Hi","\n",null],null],"listDefs":{}}"#;
|
||||||
|
let mut kv = Store::new();
|
||||||
|
let mut s = Story::from_src(src, "test", &kv).unwrap();
|
||||||
|
assert!(matches!(s.cont(&mut kv), StoryState::End));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_choice_is_reported_not_fatal() {
|
||||||
|
let mut kv = Store::new();
|
||||||
|
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
||||||
|
s.choose(3); // keine Choices vorhanden → Meldung, kein Panic
|
||||||
|
assert!(matches!(s.cont(&mut kv), StoryState::Text(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
//! Quake-`.map`-Parser (Standard-Format, wie TrenchBroom es mit dem
|
|
||||||
//! Generic-Game exportiert).
|
|
||||||
//!
|
|
||||||
//! Struktur: eine Datei ist eine Liste von Entities `{...}`. Eine Entity
|
|
||||||
//! hält Key/Value-Properties (`"key" "value"`) und null oder mehr Brushes
|
|
||||||
//! `{...}`. Ein Brush ist eine Liste von Faces; jede Face ist eine Ebene
|
|
||||||
//! aus drei Punkten plus Textur-Ausrichtung:
|
|
||||||
//!
|
|
||||||
//! ( x y z ) ( x y z ) ( x y z ) TEXTUR offX offY rot scaleX scaleY
|
|
||||||
//!
|
|
||||||
//! Hier wird nur geparst — Geometrie bleibt im Quake-Koordinatensystem
|
|
||||||
//! (Z-up, Brush = Schnitt der Halbräume); die Umrechnung ins Engine-System
|
|
||||||
//! und der Halbraum-Schnitt sind Sache des Konsumenten (render::brush).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs::read_to_string;
|
|
||||||
|
|
||||||
pub struct Map {
|
|
||||||
pub entities: Vec<Entity>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Entity {
|
|
||||||
pub props: HashMap<String, String>,
|
|
||||||
pub brushes: Vec<Brush>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Entity {
|
|
||||||
// Genutzt für Spawn-Auflösung (info_player_start) und künftige
|
|
||||||
// Entity-/Signal-Bindung.
|
|
||||||
pub fn classname(&self) -> Option<&str> {
|
|
||||||
self.props.get("classname").map(String::as_str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Brush {
|
|
||||||
pub faces: Vec<Face>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eine Brush-Face: Ebene aus drei Punkten (Quake-Reihenfolge, im
|
|
||||||
/// Uhrzeigersinn von vorn gesehen) plus Textur-Ausrichtung. Die
|
|
||||||
/// Ausrichtungsfelder werden erst vom Textur-Schritt gebraucht.
|
|
||||||
pub struct Face {
|
|
||||||
pub plane: [[f32; 3]; 3],
|
|
||||||
pub texture: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub offset: [f32; 2],
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub rotation: f32,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub scale: [f32; 2],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quake-Einheiten pro Engine-Einheit (32 ≈ klassische „1 Meter"-Annahme).
|
|
||||||
/// Single Source für Render- (render::brush) und Collision-Pfad
|
|
||||||
/// (engine::collision), damit beide denselben Maßstab benutzen.
|
|
||||||
pub const MAP_SCALE: f32 = 1.0 / 32.0;
|
|
||||||
|
|
||||||
/// Quake-Koords (Z-up) → Engine-Koords (Y-up, Blick −Z), skaliert:
|
|
||||||
/// `(x, y, z) → (x, z, −y) · MAP_SCALE`. Die Drehung erhält die Orientierung
|
|
||||||
/// (det +1) — nach außen zeigende Normalen bleiben außen.
|
|
||||||
pub fn to_engine(p: [f32; 3]) -> [f32; 3] {
|
|
||||||
[p[0] * MAP_SCALE, p[2] * MAP_SCALE, -p[1] * MAP_SCALE]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(path: &str) -> Map {
|
|
||||||
let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}"));
|
|
||||||
parse(&src)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(src: &str) -> Map {
|
|
||||||
let mut entities = Vec::new();
|
|
||||||
let mut cur_entity: Option<Entity> = None;
|
|
||||||
let mut cur_brush: Option<Brush> = None;
|
|
||||||
|
|
||||||
for raw in src.lines() {
|
|
||||||
// Kommentar (`// …`) abschneiden, dann trimmen.
|
|
||||||
let line = match raw.split_once("//") {
|
|
||||||
Some((code, _)) => code.trim(),
|
|
||||||
None => raw.trim(),
|
|
||||||
};
|
|
||||||
if line.is_empty() { continue; }
|
|
||||||
|
|
||||||
match line {
|
|
||||||
"{" => {
|
|
||||||
// Erstes `{` öffnet eine Entity, ein weiteres einen Brush.
|
|
||||||
if cur_entity.is_none() {
|
|
||||||
cur_entity = Some(Entity::default());
|
|
||||||
} else {
|
|
||||||
cur_brush = Some(Brush { faces: Vec::new() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"}" => {
|
|
||||||
if let Some(brush) = cur_brush.take() {
|
|
||||||
cur_entity.as_mut().unwrap().brushes.push(brush);
|
|
||||||
} else if let Some(entity) = cur_entity.take() {
|
|
||||||
entities.push(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(brush) = cur_brush.as_mut() {
|
|
||||||
if let Some(face) = parse_face(line) {
|
|
||||||
brush.faces.push(face);
|
|
||||||
}
|
|
||||||
} else if let Some(entity) = cur_entity.as_mut()
|
|
||||||
&& let Some((k, v)) = parse_property(line) {
|
|
||||||
entity.props.insert(k, v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Map { entities }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `"key" "value"` — Wert darf leer sein.
|
|
||||||
fn parse_property(line: &str) -> Option<(String, String)> {
|
|
||||||
let mut it = line.split('"');
|
|
||||||
it.next()?; // vor dem ersten "
|
|
||||||
let key = it.next()?; // key
|
|
||||||
it.next()?; // zwischen den Paaren
|
|
||||||
let val = it.next()?; // value
|
|
||||||
Some((key.to_string(), val.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `( x y z ) ( x y z ) ( x y z ) TEX offX offY rot sx sy`
|
|
||||||
#[allow(clippy::needless_range_loop)] // Punkt-/Komponenten-Index steuert zugleich den Token-Cursor `i`
|
|
||||||
fn parse_face(line: &str) -> Option<Face> {
|
|
||||||
let t: Vec<&str> = line.split_whitespace().collect();
|
|
||||||
let mut i = 0;
|
|
||||||
let mut plane = [[0.0f32; 3]; 3];
|
|
||||||
for p in 0..3 {
|
|
||||||
if *t.get(i)? != "(" { return None; }
|
|
||||||
for k in 0..3 {
|
|
||||||
plane[p][k] = t.get(i + 1 + k)?.parse().ok()?;
|
|
||||||
}
|
|
||||||
if *t.get(i + 4)? != ")" { return None; }
|
|
||||||
i += 5;
|
|
||||||
}
|
|
||||||
let texture = (*t.get(i)?).to_string();
|
|
||||||
let nums: Option<Vec<f32>> = t[i + 1..i + 6].iter().map(|s| s.parse().ok()).collect();
|
|
||||||
let n = nums?;
|
|
||||||
Some(Face {
|
|
||||||
plane,
|
|
||||||
texture,
|
|
||||||
offset: [n[0], n[1]],
|
|
||||||
rotation: n[2],
|
|
||||||
scale: [n[3], n[4]],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const SAMPLE: &str = r#"
|
|
||||||
// entity 0
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
"wad" ""
|
|
||||||
// brush 0
|
|
||||||
{
|
|
||||||
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) placeholder 32 -32 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) placeholder -16 -32 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) placeholder -16 -32 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) placeholder -16 -32 0 1 1
|
|
||||||
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) placeholder -16 -32 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) placeholder 32 -32 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_entity_brush_and_face() {
|
|
||||||
let m = parse(SAMPLE);
|
|
||||||
assert_eq!(m.entities.len(), 1);
|
|
||||||
let e = &m.entities[0];
|
|
||||||
assert_eq!(e.classname(), Some("worldspawn"));
|
|
||||||
assert_eq!(e.props.get("wad").map(String::as_str), Some(""));
|
|
||||||
assert_eq!(e.brushes.len(), 1);
|
|
||||||
let b = &e.brushes[0];
|
|
||||||
assert_eq!(b.faces.len(), 6);
|
|
||||||
|
|
||||||
let f = &b.faces[0];
|
|
||||||
assert_eq!(f.texture, "placeholder");
|
|
||||||
assert_eq!(f.plane[0], [-48.0, -64.0, -48.0]);
|
|
||||||
assert_eq!(f.plane[2], [-48.0, -64.0, -47.0]);
|
|
||||||
assert_eq!(f.offset, [32.0, -32.0]);
|
|
||||||
assert_eq!(f.scale, [1.0, 1.0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-6
@@ -16,22 +16,29 @@
|
|||||||
//! 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).
|
||||||
//!
|
//!
|
||||||
//! `map` 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 Frontend
|
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
||||||
//! per Pull konsumiert. `map` ist zugleich der Anfang des headless
|
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
|
||||||
//! Datenmodells (Phase 2 des Renderer-Plans).
|
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
||||||
|
//! 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 `map`) liefert ihr die Brush-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 ink;
|
pub mod ink;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod map;
|
pub mod model;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
pub mod story_ctrl;
|
pub mod story_ctrl;
|
||||||
pub mod tga;
|
pub mod tga;
|
||||||
|
pub mod trigger;
|
||||||
|
pub mod wav;
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//! Neutrales 3D-Modell — das gemeinsame Ergebnis des Blender-Loaders
|
||||||
|
//! (engine::gltf). Konsumenten (render::props, collision, trigger) kennen
|
||||||
|
//! nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen egal — so
|
||||||
|
//! bleibt ein Format-Wechsel ein lokaler Loader-Tausch.
|
||||||
|
//!
|
||||||
|
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
|
||||||
|
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
|
||||||
|
//! [`apply_props`]:
|
||||||
|
//! `collide = true` → sichtbar **und** Collider (konvexe Hülle des
|
||||||
|
//! Meshes, siehe collision::object_planes)
|
||||||
|
//! `collide = "proxy"` → unsichtbarer Collider (reines Kollisionsvolumen)
|
||||||
|
//! `signal = <name>` → Trigger; mit Collider Klick-Ziel, ohne Collider
|
||||||
|
//! unsichtbare Betretens-Zone (engine::trigger)
|
||||||
|
//! nichts davon → nur sichtbar
|
||||||
|
//!
|
||||||
|
//! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe
|
||||||
|
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
|
||||||
|
//! die Loader bleiben frei von Signal-Wissen.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
|
||||||
|
/// Werte zu Strings vereinheitlicht. OBJ kann keine tragen → leer.
|
||||||
|
pub type Props = HashMap<String, String>;
|
||||||
|
|
||||||
|
pub struct Model {
|
||||||
|
pub objects: Vec<Object>,
|
||||||
|
/// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für
|
||||||
|
/// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten.
|
||||||
|
pub empties: Vec<Empty>,
|
||||||
|
/// Distinkte Materialnamen in Auftrittsreihenfolge; `Object::tri_mats`
|
||||||
|
/// indiziert hierhin. `""` steht für „Faces ohne Material" — der
|
||||||
|
/// Konsument wählt dafür seinen Fallback (z.B. Platzhalter-Textur).
|
||||||
|
pub materials: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Object {
|
||||||
|
pub name: String,
|
||||||
|
/// Positionen in Engine-Koords (Y-up, Blick −Z), Welt-Raum (Transforms
|
||||||
|
/// vom Loader eingebacken), Maßstab 1:1 (Blender-Meter = Engine-Unit).
|
||||||
|
pub verts: Vec<[f32; 3]>,
|
||||||
|
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
|
||||||
|
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
|
||||||
|
pub uvs: Vec<[f32; 2]>,
|
||||||
|
pub tris: Vec<[usize; 3]>,
|
||||||
|
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
|
||||||
|
pub tri_mats: Vec<usize>,
|
||||||
|
pub visible: bool,
|
||||||
|
pub collider: bool,
|
||||||
|
pub props: Props,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Empty {
|
||||||
|
pub name: String,
|
||||||
|
/// Welt-Position (Engine-Koords).
|
||||||
|
pub pos: [f32; 3],
|
||||||
|
pub props: Props,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom-Property-Schlüssel, der ein Objekt zum Collider macht (siehe
|
||||||
|
/// Modul-Doc): truthy (`true`, `1`, …) = sichtbar + Collider, `"proxy"` =
|
||||||
|
/// unsichtbarer Collider. `0`/`false`/`no`/leer zählen als aus, damit die
|
||||||
|
/// Autorin die Property abschalten kann, ohne sie zu löschen.
|
||||||
|
pub const COLLIDE_PROP: &str = "collide";
|
||||||
|
|
||||||
|
/// Custom-Property-Schlüssel, der ein Objekt zum Trigger macht (Wert =
|
||||||
|
/// Signal-Name für `signals::dispatch`). Die Collider-Frage entscheidet die
|
||||||
|
/// Semantik — anfassbar = klickbar, durchlaufbar = Betretens-Zone:
|
||||||
|
/// `collide` + `signal` → solide **und** klickbar (Point-and-Click)
|
||||||
|
/// nur `signal` → unsichtbare Trigger-Zone, feuert beim Betreten
|
||||||
|
/// (einmal je Eintritt; erneut erst nach Verlassen)
|
||||||
|
pub const SIGNAL_PROP: &str = "signal";
|
||||||
|
|
||||||
|
/// Custom-Property-Schlüssel für Empty-Rollen (der Entity-Kanal):
|
||||||
|
/// `role = "spawn"` markiert den Spieler-Startpunkt (Fußpunkt =
|
||||||
|
/// Empty-Position). Weitere Rollen geben künftige Konsumenten.
|
||||||
|
pub const ROLE_PROP: &str = "role";
|
||||||
|
pub const ROLE_SPAWN: &str = "spawn";
|
||||||
|
|
||||||
|
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
|
||||||
|
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
||||||
|
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
|
||||||
|
pub fn apply_props(o: &mut Object) {
|
||||||
|
if let Some(v) = o.props.get(COLLIDE_PROP) {
|
||||||
|
let v = v.to_ascii_lowercase();
|
||||||
|
if !matches!(v.as_str(), "" | "0" | "false" | "no") {
|
||||||
|
o.collider = true;
|
||||||
|
if v == "proxy" { o.visible = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !o.collider && o.props.contains_key(SIGNAL_PROP) {
|
||||||
|
o.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-Helfer: Einheitswürfel-Objekt bei `min..min+1` (Quads CCW von außen,
|
||||||
|
/// Fan-trianguliert wie der Loader) mit optionalen `collide`-/`signal`-
|
||||||
|
/// Properties — geteilt von trigger-/session-/collision-Tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn test_cube(name: &str, min: [f32; 3], collide: Option<&str>, signal: Option<&str>) -> Object {
|
||||||
|
let v = |dx: f32, dy: f32, dz: f32| [min[0] + dx, min[1] + dy, min[2] + dz];
|
||||||
|
let verts = vec![
|
||||||
|
v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(1.0, 1.0, 0.0), v(0.0, 1.0, 0.0),
|
||||||
|
v(0.0, 0.0, 1.0), v(1.0, 0.0, 1.0), v(1.0, 1.0, 1.0), v(0.0, 1.0, 1.0),
|
||||||
|
];
|
||||||
|
let quads = [
|
||||||
|
[4, 5, 6, 7], [0, 3, 2, 1], [1, 2, 6, 5],
|
||||||
|
[0, 4, 7, 3], [3, 7, 6, 2], [0, 1, 5, 4],
|
||||||
|
];
|
||||||
|
let mut tris = Vec::new();
|
||||||
|
for q in quads {
|
||||||
|
tris.push([q[0], q[1], q[2]]);
|
||||||
|
tris.push([q[0], q[2], q[3]]);
|
||||||
|
}
|
||||||
|
let n = tris.len();
|
||||||
|
let mut props = Props::new();
|
||||||
|
if let Some(c) = collide { props.insert(COLLIDE_PROP.into(), c.into()); }
|
||||||
|
if let Some(s) = signal { props.insert(SIGNAL_PROP.into(), s.into()); }
|
||||||
|
let mut o = Object {
|
||||||
|
name: name.into(),
|
||||||
|
uvs: vec![[0.0, 0.0]; verts.len()],
|
||||||
|
verts, tris,
|
||||||
|
tri_mats: vec![0; n],
|
||||||
|
visible: true, collider: false, props,
|
||||||
|
};
|
||||||
|
apply_props(&mut o);
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collide_prop_rules() {
|
||||||
|
// truthy → sichtbar + Collider; "proxy" → unsichtbarer Collider.
|
||||||
|
let o = test_cube("Kiste", [0.0; 3], Some("true"), None);
|
||||||
|
assert!(o.visible && o.collider);
|
||||||
|
let o = test_cube("Wand", [0.0; 3], Some("proxy"), None);
|
||||||
|
assert!(!o.visible && o.collider);
|
||||||
|
// Abschaltwerte zählen als aus; ohne Property nur sichtbar.
|
||||||
|
let o = test_cube("Aus", [0.0; 3], Some("0"), None);
|
||||||
|
assert!(o.visible && !o.collider);
|
||||||
|
let o = test_cube("Deko", [0.0; 3], None, None);
|
||||||
|
assert!(o.visible && !o.collider);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signal_without_collider_is_invisible_zone() {
|
||||||
|
let o = test_cube("Zone", [0.0; 3], None, Some("betreten"));
|
||||||
|
assert!(!o.visible && !o.collider);
|
||||||
|
// Mit Collider bleibt das Objekt sichtbar (Klick-Ziel).
|
||||||
|
let o = test_cube("Kiste", [0.0; 3], Some("1"), Some("kiste"));
|
||||||
|
assert!(o.visible && o.collider);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-6
@@ -6,9 +6,9 @@
|
|||||||
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
||||||
//!
|
//!
|
||||||
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
|
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
|
||||||
//! Arithmetik. Vektor-Helfer werden nicht geteilt (brush.rs hält seine eigenen
|
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine
|
||||||
//! privat) — erst wenn Stufe 2 (Brush-Collision) echte dot/cross/normalize an
|
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
|
||||||
//! mehreren Stellen braucht, lohnt ein gemeinsames Modul.
|
//! Nutzer echte dot/cross/normalize braucht.
|
||||||
//!
|
//!
|
||||||
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
|
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
|
||||||
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
|
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
|
||||||
@@ -23,20 +23,21 @@
|
|||||||
use crate::engine::collision::CollisionWorld;
|
use crate::engine::collision::CollisionWorld;
|
||||||
|
|
||||||
/// Augenhöhe über dem Fußpunkt (units). Der Renderer setzt die Kamera auf
|
/// Augenhöhe über dem Fußpunkt (units). Der Renderer setzt die Kamera auf
|
||||||
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
pub const EYE_HEIGHT: f32 = 1.6;
|
pub const EYE_HEIGHT: f32 = 1.6;
|
||||||
|
|
||||||
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
||||||
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
||||||
const GRAVITY: f32 = 20.0;
|
const GRAVITY: f32 = 20.0;
|
||||||
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
const WALK_SPEED: f32 = 5.0;
|
const WALK_SPEED: f32 = 5.0;
|
||||||
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
|
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
|
||||||
const JUMP_SPEED: f32 = 7.0;
|
const JUMP_SPEED: f32 = 7.0;
|
||||||
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
||||||
const PITCH_LIMIT: f32 = 1.55;
|
const PITCH_LIMIT: f32 = 1.55;
|
||||||
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
||||||
const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
|
/// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft.
|
||||||
|
pub const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
|
||||||
/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok).
|
/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok).
|
||||||
const GROUND_NORMAL_Y: f32 = 0.7;
|
const GROUND_NORMAL_Y: f32 = 0.7;
|
||||||
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
|
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
|
||||||
@@ -70,6 +71,21 @@ impl Player {
|
|||||||
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Augenpunkt (Fußpunkt + [`EYE_HEIGHT`]) — Kamera- und Hörposition,
|
||||||
|
/// Startpunkt des Point-and-Click-Strahls.
|
||||||
|
pub fn eye(&self) -> [f32; 3] {
|
||||||
|
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/
|
||||||
|
/// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den
|
||||||
|
/// Point-and-Click-Strahl aus der Bildmitte.
|
||||||
|
pub fn look_dir(&self) -> [f32; 3] {
|
||||||
|
let (sy, cy) = self.yaw.sin_cos();
|
||||||
|
let (sp, cp) = self.pitch.sin_cos();
|
||||||
|
[-sy * cp, sp, -cy * cp]
|
||||||
|
}
|
||||||
|
|
||||||
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
||||||
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
||||||
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
||||||
|
|||||||
@@ -14,14 +14,14 @@
|
|||||||
//! `set <name> <value>` KV setzen (true/false/i32/f32/string)
|
//! `set <name> <value>` KV setzen (true/false/i32/f32/string)
|
||||||
//! `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)
|
//! `play_sound <name>` → Action::PlaySound (deferred): SFX aus
|
||||||
//! `play_sound <file>` → Action::PlaySound (deferred)
|
//! `assets/sounds/{name}.wav`
|
||||||
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
|
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
|
||||||
//! Eingabemodus wechseln
|
//! Eingabemodus wechseln
|
||||||
//!
|
//!
|
||||||
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
|
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
|
||||||
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
|
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
|
||||||
//! wie `hide_object $self` für viele duplizierte Instances wirken, ohne pro
|
//! (z.B. `set $self true`) für viele duplizierte Instances wirken, ohne pro
|
||||||
//! Instance einen Eintrag in signals.toml zu brauchen.
|
//! Instance einen Eintrag in signals.toml zu brauchen.
|
||||||
//!
|
//!
|
||||||
//! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table,
|
//! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table,
|
||||||
@@ -78,7 +78,7 @@ pub fn dispatch(signal: &str, ctx: &mut ActionCtx) {
|
|||||||
/// entscheidet, ob das eine Meldung wert ist — siehe `dispatch`).
|
/// entscheidet, ob das eine Meldung wert ist — siehe `dispatch`).
|
||||||
fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
|
fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
|
||||||
// $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten,
|
// $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten,
|
||||||
// damit Tokens wie `hide_object $self` einheitlich funktionieren. Ohne
|
// damit Tokens wie `set $self true` einheitlich funktionieren. Ohne
|
||||||
// instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action
|
// instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action
|
||||||
// zielt dann ins Leere, was für deferred Actions ein No-Op beim
|
// zielt dann ins Leere, was für deferred Actions ein No-Op beim
|
||||||
// Konsumenten ist.
|
// Konsumenten ist.
|
||||||
@@ -97,7 +97,6 @@ fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
|
|||||||
"set" => kv::apply_set(args, ctx.kv),
|
"set" => kv::apply_set(args, ctx.kv),
|
||||||
"inc" => kv::apply_inc(args, ctx.kv),
|
"inc" => kv::apply_inc(args, ctx.kv),
|
||||||
"clear" => kv::apply_clear(args, ctx.kv),
|
"clear" => kv::apply_clear(args, ctx.kv),
|
||||||
"hide_object" => ctx.actions.push(Action::HideObject(args.to_string())),
|
|
||||||
"play_sound" => ctx.actions.push(Action::PlaySound(args.to_string())),
|
"play_sound" => ctx.actions.push(Action::PlaySound(args.to_string())),
|
||||||
"mode" => if let Some(t) = ModeTarget::parse(args) {
|
"mode" => if let Some(t) = ModeTarget::parse(args) {
|
||||||
ctx.actions.push(Action::SetMode(t));
|
ctx.actions.push(Action::SetMode(t));
|
||||||
@@ -182,8 +181,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 $self".into(), // $self-Substitution in deferred Action
|
||||||
"hide_object $self".into(),
|
|
||||||
]);
|
]);
|
||||||
let mut game = Game::new(signals);
|
let mut game = Game::new(signals);
|
||||||
|
|
||||||
@@ -192,8 +190,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("Mushroom.005".into()),
|
||||||
Action::HideObject("Mushroom.005".into()),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -19,9 +19,9 @@ pub struct Image {
|
|||||||
pub rgba: Vec<u8>,
|
pub rgba: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(path: &str) -> Image {
|
pub fn load(path: &str) -> Result<Image, String> {
|
||||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("tga load {path}: {e}"));
|
let bytes = std::fs::read(path).map_err(|e| format!("tga load {path}: {e}"))?;
|
||||||
decode(&bytes).unwrap_or_else(|e| panic!("tga decode {path}: {e}"))
|
decode(&bytes).map_err(|e| format!("tga {path}: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode(d: &[u8]) -> Result<Image, String> {
|
pub fn decode(d: &[u8]) -> Result<Image, String> {
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Trigger aus Blender-Modellen: Objekte mit `signal`-Custom-Property
|
||||||
|
//! (siehe [`model::SIGNAL_PROP`]), aufgeteilt nach Collider-Frage —
|
||||||
|
//! **anfassbar = klickbar, durchlaufbar = Betretens-Zone**:
|
||||||
|
//!
|
||||||
|
//! - `collide` + `signal` → *Klick-Ziel*: das solide Volumen ist zugleich
|
||||||
|
//! das Ziel des Point-and-Click-Raycasts (siehe [`Triggers::pick`]).
|
||||||
|
//! Feuern läuft über den `use <name>`-Trichter in `Session::exec`.
|
||||||
|
//! - nur `signal` → *Zone*: unsichtbares konvexes Volumen; Betreten
|
||||||
|
//! feuert das Signal genau einmal je Eintritt ([`Triggers::enter_events`],
|
||||||
|
//! Flanken-Semantik: erneut erst nach Verlassen). „Einmal für immer"
|
||||||
|
//! baut die Autorin über KV-Flags/Ink, nicht hier.
|
||||||
|
//!
|
||||||
|
//! In beiden Fällen ist der Signal-Name der Property-*Wert*, `$self` der
|
||||||
|
//! Objektname. Volumen-Mathe kommt aus `collision` (dieselben konvexen
|
||||||
|
//! Ebenen-Sets wie die Proxies); gefeuert wird hier nichts — die Session
|
||||||
|
//! zieht Events/Treffer und schickt sie durch ihren Dispatch, damit der
|
||||||
|
//! Eingabe-Trichter der einzige Weg in den State bleibt.
|
||||||
|
|
||||||
|
use crate::engine::collision::{self, Plane};
|
||||||
|
use crate::engine::model::{Model, SIGNAL_PROP};
|
||||||
|
|
||||||
|
struct Clickable {
|
||||||
|
name: String,
|
||||||
|
signal: String,
|
||||||
|
planes: Vec<Plane>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Zone {
|
||||||
|
name: String,
|
||||||
|
signal: String,
|
||||||
|
planes: Vec<Plane>,
|
||||||
|
/// Flanken-Zustand: war der Spieler im letzten Check im Volumen?
|
||||||
|
inside: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Triggers {
|
||||||
|
clickables: Vec<Clickable>,
|
||||||
|
zones: Vec<Zone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Triggers {
|
||||||
|
pub fn new() -> Self { Self::default() }
|
||||||
|
|
||||||
|
/// Trigger eines Modells übernehmen: alle Objekte mit `signal`-Property.
|
||||||
|
pub fn add_model(&mut self, model: &Model) {
|
||||||
|
for o in &model.objects {
|
||||||
|
let Some(signal) = o.props.get(SIGNAL_PROP) else { continue; };
|
||||||
|
let Some(planes) = collision::object_planes(o) else {
|
||||||
|
eprintln!("[trigger] {}: kein geschlossenes konvexes Volumen — ignoriert", o.name);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (name, signal) = (o.name.clone(), signal.clone());
|
||||||
|
if o.collider {
|
||||||
|
self.clickables.push(Clickable { name, signal, planes });
|
||||||
|
} else {
|
||||||
|
self.zones.push(Zone { name, signal, planes, inside: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal eines Klick-Ziels (für die `use <name>`-Auflösung der Session).
|
||||||
|
pub fn signal_for(&self, name: &str) -> Option<&str> {
|
||||||
|
self.clickables.iter()
|
||||||
|
.find(|c| c.name == name)
|
||||||
|
.map(|c| c.signal.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nächstes Klick-Ziel entlang `start→end` (Punktstrahl): Name und
|
||||||
|
/// Trefferbruch. Ob Weltgeometrie davor liegt, prüft der Aufrufer.
|
||||||
|
pub fn pick(&self, start: [f32; 3], end: [f32; 3]) -> Option<(&str, f32)> {
|
||||||
|
let mut nearest: Option<(&str, f32)> = None;
|
||||||
|
for c in &self.clickables {
|
||||||
|
if let Some(hit) = collision::trace_planes(&c.planes, start, end, [0.0; 3])
|
||||||
|
&& nearest.is_none_or(|(_, f)| hit.frac < f) {
|
||||||
|
nearest = Some((&c.name, hit.frac));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nearest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zonen gegen die Spieler-AABB prüfen und die Eintritts-Flanken melden:
|
||||||
|
/// `(Objektname, Signal)` je Zone, die diesen Check betreten wurde.
|
||||||
|
pub fn enter_events(&mut self, center: [f32; 3], half: [f32; 3]) -> Vec<(String, String)> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for z in &mut self.zones {
|
||||||
|
let inside = collision::box_touches(&z.planes, center, half);
|
||||||
|
if inside && !z.inside {
|
||||||
|
events.push((z.name.clone(), z.signal.clone()));
|
||||||
|
}
|
||||||
|
z.inside = inside;
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::engine::model::{test_cube as cube, Object};
|
||||||
|
|
||||||
|
fn model(objects: Vec<Object>) -> Model {
|
||||||
|
Model { objects, empties: Vec::new(), materials: vec![String::new()] }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splits_by_collider_and_zone_is_invisible() {
|
||||||
|
let m = model(vec![
|
||||||
|
cube("Kiste", [0.0, 0.0, 0.0], Some("1"), Some("kiste")),
|
||||||
|
cube("Zone.001", [5.0, 0.0, 0.0], None, Some("betreten")),
|
||||||
|
cube("Deko", [9.0, 0.0, 0.0], None, None),
|
||||||
|
]);
|
||||||
|
assert!(!m.objects[1].visible, "Zone darf nicht gerendert werden");
|
||||||
|
|
||||||
|
let mut t = Triggers::new();
|
||||||
|
t.add_model(&m);
|
||||||
|
assert_eq!(t.clickables.len(), 1);
|
||||||
|
assert_eq!(t.zones.len(), 1);
|
||||||
|
assert_eq!(t.signal_for("Kiste"), Some("kiste"));
|
||||||
|
assert_eq!(t.signal_for("Deko"), None, "ohne signal-Property kein Klick-Ziel");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_finds_nearest_clickable() {
|
||||||
|
let mut t = Triggers::new();
|
||||||
|
t.add_model(&model(vec![
|
||||||
|
cube("Fern", [4.0, -0.5, -0.5], Some("1"), Some("f")),
|
||||||
|
cube("Nah", [1.0, -0.5, -0.5], Some("1"), Some("n")),
|
||||||
|
]));
|
||||||
|
// Strahl entlang +X durch beide Würfel → der nahe gewinnt.
|
||||||
|
let (name, frac) = t.pick([0.0, 0.0, 0.0], [10.0, 0.0, 0.0]).unwrap();
|
||||||
|
assert_eq!(name, "Nah");
|
||||||
|
assert!((frac - 0.1).abs() < 0.01, "frac={frac}");
|
||||||
|
// Strahl daneben → nichts.
|
||||||
|
assert!(t.pick([0.0, 5.0, 0.0], [10.0, 5.0, 0.0]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zone_fires_on_entry_edge_and_rearms_after_exit() {
|
||||||
|
let mut t = Triggers::new();
|
||||||
|
t.add_model(&model(vec![cube("Zone", [0.0, 0.0, 0.0], None, Some("betreten"))]));
|
||||||
|
let half = [0.3, 0.9, 0.3];
|
||||||
|
|
||||||
|
// Außerhalb: nichts.
|
||||||
|
assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty());
|
||||||
|
// Betreten: genau ein Event …
|
||||||
|
let ev = t.enter_events([0.5, 0.5, 0.5], half);
|
||||||
|
assert_eq!(ev, vec![("Zone".to_string(), "betreten".to_string())]);
|
||||||
|
// … und beim Verweilen keins.
|
||||||
|
assert!(t.enter_events([0.6, 0.5, 0.5], half).is_empty());
|
||||||
|
// Verlassen und wieder betreten: feuert erneut.
|
||||||
|
assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty());
|
||||||
|
assert_eq!(t.enter_events([0.5, 0.5, 0.5], half).len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Upscale-Pass: internes 320×240-Target nearest-gesampelt auf die
|
// Upscale-Pass: internes 640×480-Target nearest-gesampelt auf die
|
||||||
// Surface. Das 4:3-Letterbox-Rechteck setzt der Rust-Code als Viewport;
|
// Surface. Das 4:3-Letterbox-Rechteck setzt der Rust-Code als Viewport;
|
||||||
// hier ist es ein simples Fullscreen-Dreieck mit UVs.
|
// hier ist es ein simples Fullscreen-Dreieck mit UVs.
|
||||||
|
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
//! Brush → Render-Geometrie.
|
|
||||||
//!
|
|
||||||
//! Ein Quake-Brush ist der Schnitt der Halbräume seiner Face-Ebenen. Aus
|
|
||||||
//! den Ebenen rekonstruieren wir das konvexe Polyeder (Standard-Verfahren:
|
|
||||||
//! alle Ebenen-Tripel schneiden, nur Punkte behalten, die in *allen*
|
|
||||||
//! Halbräumen liegen), bilden pro Face das Polygon und fächern es zu
|
|
||||||
//! Dreiecken. Indizes werden nach Textur gruppiert (ein Draw-Batch je
|
|
||||||
//! Textur).
|
|
||||||
//!
|
|
||||||
//! Koplanar-Elimination: stoßen zwei Brushes aneinander, teilen sie eine
|
|
||||||
//! Fläche — beide Faces liegen auf derselben Ebene, mit entgegengesetzten
|
|
||||||
//! Normalen, und sind nie sichtbar. Wir verwerfen jede Face, die von einer
|
|
||||||
//! solchen gegenüberliegenden Face vollständig überdeckt wird (hinter ihr
|
|
||||||
//! liegt dann garantiert solides Brush-Inneres). Zusammen mit dem Back-face
|
|
||||||
//! Culling der Pipeline bleibt so nur die Außenhaut übrig — ohne BSP.
|
|
||||||
//!
|
|
||||||
//! UVs nach dem Standard-Map-Format: die Textur-Achsen ergeben sich aus der
|
|
||||||
//! dominanten Flächennormale (Quake-`baseaxis`-Tabelle), dann Rotation,
|
|
||||||
//! Skalierung und Offset der Face. Das erzeugt das klassische Quake-
|
|
||||||
//! Verziehen auf schrägen Flächen — gewollt fürs PS1-Gefühl.
|
|
||||||
//!
|
|
||||||
//! Achsen: Quake ist Z-up, unsere Engine Y-up mit Blick -Z. UVs werden im
|
|
||||||
//! Quake-System gerechnet (so sind Offsets/Scale definiert), die Position
|
|
||||||
//! danach umgerechnet: `(qx, qy, qz) → (qx, qz, -qy)`, mal `MAP_SCALE`.
|
|
||||||
|
|
||||||
use crate::engine::map::{self, Face, Map};
|
|
||||||
use crate::render::scene::{Batch, Mesh, Vertex};
|
|
||||||
|
|
||||||
/// Punkt liegt „auf" einer Ebene / „innerhalb" eines Halbraums (Quake-Units).
|
|
||||||
const ON_EPS: f32 = 1e-2;
|
|
||||||
|
|
||||||
struct Plane {
|
|
||||||
n: [f32; 3], // nach außen, im Quake-System
|
|
||||||
d: f32, // n · x = d
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ein fertig rekonstruiertes Face-Polygon (Quake-Koords, CCW um `n`),
|
|
||||||
/// vor der UV-/Engine-Umrechnung. Hält die Ebene für die Koplanar-Prüfung
|
|
||||||
/// und einen Verweis auf die Quell-Face (Textur, Offsets, Skalierung).
|
|
||||||
struct FacePoly<'a> {
|
|
||||||
n: [f32; 3],
|
|
||||||
d: f32,
|
|
||||||
poly: Vec<[f32; 3]>,
|
|
||||||
face: &'a Face,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Distinkte Texturnamen in der Map (sortiert) — die Indizes hier sind die
|
|
||||||
/// Textur-Indizes, auf die `Batch::texture` und der Bilder-Vec verweisen.
|
|
||||||
pub fn texture_names(map: &Map) -> Vec<String> {
|
|
||||||
let mut names: Vec<String> = map.entities.iter()
|
|
||||||
.flat_map(|e| &e.brushes)
|
|
||||||
.flat_map(|b| &b.faces)
|
|
||||||
.map(|f| f.texture.clone())
|
|
||||||
.collect();
|
|
||||||
names.sort_unstable();
|
|
||||||
names.dedup();
|
|
||||||
names
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alle Brushes zu einem Mesh. `tex_names` (aus [`texture_names`]) gibt die
|
|
||||||
/// Textur-Indizes, `dims` die Pixelmaße je Textur (für UV-Normalisierung).
|
|
||||||
pub fn build(map: &Map, tex_names: &[String], dims: &[(u32, u32)]) -> Mesh {
|
|
||||||
// 1) Alle Face-Polygone rekonstruieren (noch in Quake-Koords).
|
|
||||||
let mut faces: Vec<FacePoly> = Vec::new();
|
|
||||||
for ent in &map.entities {
|
|
||||||
for brush in &ent.brushes {
|
|
||||||
collect_faces(&brush.faces, &mut faces);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Koplanar verdeckte Faces markieren.
|
|
||||||
let hidden = mark_hidden(&faces);
|
|
||||||
|
|
||||||
// 3) Überlebende mit UV in den Vertex-Buffer ausgeben, nach Textur
|
|
||||||
// gruppiert.
|
|
||||||
let mut verts = Vec::new();
|
|
||||||
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
|
||||||
for (i, fp) in faces.iter().enumerate() {
|
|
||||||
if hidden[i] { continue; }
|
|
||||||
emit_face(fp, tex_names, dims, &mut verts, &mut per_tex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen.
|
|
||||||
let mut indices = Vec::new();
|
|
||||||
let mut batches = Vec::new();
|
|
||||||
for (ti, list) in per_tex.into_iter().enumerate() {
|
|
||||||
if list.is_empty() { continue; }
|
|
||||||
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
|
|
||||||
indices.extend_from_slice(&list);
|
|
||||||
}
|
|
||||||
Mesh { verts, indices, batches }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Die Face-Polygone *eines* Brushes rekonstruieren und an `out` anhängen.
|
|
||||||
fn collect_faces<'a>(faces: &'a [Face], out: &mut Vec<FacePoly<'a>>) {
|
|
||||||
let planes: Vec<Plane> = faces.iter().map(|f| plane(&f.plane)).collect();
|
|
||||||
let n = planes.len();
|
|
||||||
|
|
||||||
// Gültige Eckpunkte: Schnitt je dreier Ebenen, sofern in allen
|
|
||||||
// Halbräumen.
|
|
||||||
let mut corners: Vec<[f32; 3]> = Vec::new();
|
|
||||||
for i in 0..n {
|
|
||||||
for j in (i + 1)..n {
|
|
||||||
for k in (j + 1)..n {
|
|
||||||
let Some(p) = intersect(&planes[i], &planes[j], &planes[k]) else { continue; };
|
|
||||||
if planes.iter().all(|pl| dot(pl.n, p) <= pl.d + ON_EPS)
|
|
||||||
&& !corners.iter().any(|q| dist2(*q, p) < ON_EPS * ON_EPS)
|
|
||||||
{
|
|
||||||
corners.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pro Face: Eckpunkte auf der Ebene sammeln, CCW um die Normale sortieren.
|
|
||||||
for (fi, face) in faces.iter().enumerate() {
|
|
||||||
let pl = &planes[fi];
|
|
||||||
let mut poly: Vec<[f32; 3]> = corners.iter().copied()
|
|
||||||
.filter(|&p| (dot(pl.n, p) - pl.d).abs() < ON_EPS)
|
|
||||||
.collect();
|
|
||||||
if poly.len() < 3 { continue; }
|
|
||||||
|
|
||||||
let center = average(&poly);
|
|
||||||
let axis_u = any_perp(pl.n);
|
|
||||||
let axis_v = cross(pl.n, axis_u);
|
|
||||||
poly.sort_by(|a, b| {
|
|
||||||
angle(*a, center, axis_u, axis_v)
|
|
||||||
.partial_cmp(&angle(*b, center, axis_u, axis_v))
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
|
|
||||||
out.push(FacePoly { n: pl.n, d: pl.d, poly, face });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Markiert Faces, die von einer gegenüberliegenden, deckungsgleichen Face
|
|
||||||
/// (anderer Brush) vollständig überdeckt werden — also nie sichtbar sind.
|
|
||||||
fn mark_hidden(faces: &[FacePoly]) -> Vec<bool> {
|
|
||||||
let mut hidden = vec![false; faces.len()];
|
|
||||||
for i in 0..faces.len() {
|
|
||||||
let a = &faces[i];
|
|
||||||
for (j, b) in faces.iter().enumerate() {
|
|
||||||
if i == j { continue; }
|
|
||||||
// Gegenüberliegende, koplanare Ebene? (n_a ≈ -n_b und d_a ≈ -d_b,
|
|
||||||
// denn n_b·x = d_b ⇒ n_a·x = -d_b muss a.d treffen.)
|
|
||||||
if dot(a.n, b.n) > -0.999 { continue; }
|
|
||||||
if (a.d + b.d).abs() > ON_EPS { continue; }
|
|
||||||
if covered_by(&a.poly, &b.poly, a.n) {
|
|
||||||
hidden[i] = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Liegt jeder Eckpunkt von `inner` innerhalb des Polygons `outer`? Beide
|
|
||||||
/// liegen (per Aufruf) auf derselben Ebene mit Normale `n`; wir projizieren
|
|
||||||
/// in deren 2D-Basis. Testpunkte werden minimal zur Polygonmitte gezogen,
|
|
||||||
/// damit deckungsgleiche Ränder/Ecken eindeutig als „innen" zählen.
|
|
||||||
fn covered_by(inner: &[[f32; 3]], outer: &[[f32; 3]], n: [f32; 3]) -> bool {
|
|
||||||
let u = any_perp(n);
|
|
||||||
let v = cross(n, u);
|
|
||||||
let c = average(inner);
|
|
||||||
let proj = |p: [f32; 3]| -> [f32; 2] { let d = sub(p, c); [dot(d, u), dot(d, v)] };
|
|
||||||
let outer2: Vec<[f32; 2]> = outer.iter().map(|&p| proj(p)).collect();
|
|
||||||
inner.iter().all(|&p| {
|
|
||||||
let pp = [
|
|
||||||
p[0] + 1e-3 * (c[0] - p[0]),
|
|
||||||
p[1] + 1e-3 * (c[1] - p[1]),
|
|
||||||
p[2] + 1e-3 * (c[2] - p[2]),
|
|
||||||
];
|
|
||||||
in_poly_2d(proj(pp), &outer2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crossing-Number-Punkt-in-Polygon in 2D (orientierungsunabhängig).
|
|
||||||
fn in_poly_2d(p: [f32; 2], poly: &[[f32; 2]]) -> bool {
|
|
||||||
let mut inside = false;
|
|
||||||
let mut j = poly.len() - 1;
|
|
||||||
for i in 0..poly.len() {
|
|
||||||
let (a, b) = (poly[i], poly[j]);
|
|
||||||
if (a[1] > p[1]) != (b[1] > p[1]) {
|
|
||||||
let t = (p[1] - a[1]) / (b[1] - a[1]);
|
|
||||||
if p[0] < a[0] + t * (b[0] - a[0]) { inside = !inside; }
|
|
||||||
}
|
|
||||||
j = i;
|
|
||||||
}
|
|
||||||
inside
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ein Face-Polygon mit UV in den Vertex-Buffer fächern.
|
|
||||||
fn emit_face(
|
|
||||||
fp: &FacePoly,
|
|
||||||
tex_names: &[String],
|
|
||||||
dims: &[(u32, u32)],
|
|
||||||
verts: &mut Vec<Vertex>,
|
|
||||||
per_tex: &mut [Vec<u32>],
|
|
||||||
) {
|
|
||||||
let Some(ti) = tex_names.iter().position(|nm| nm == &fp.face.texture) else { return; };
|
|
||||||
let (tw, th) = dims[ti];
|
|
||||||
let (su, sv) = tex_axes(fp.n, fp.face); // skalierte Textur-Achsen (Quake)
|
|
||||||
let base = verts.len() as u32;
|
|
||||||
for p in &fp.poly {
|
|
||||||
// UV in Texeln, dann auf 0..1 normalisiert.
|
|
||||||
let u = (dot(*p, su) + fp.face.offset[0]) / tw as f32;
|
|
||||||
let v = (dot(*p, sv) + fp.face.offset[1]) / th as f32;
|
|
||||||
verts.push(Vertex { pos: map::to_engine(*p), uv: [u, v] });
|
|
||||||
}
|
|
||||||
for t in 1..(fp.poly.len() as u32 - 1) {
|
|
||||||
per_tex[ti].extend([base, base + t, base + t + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ebene aus drei Face-Punkten. Quake listet sie im Uhrzeigersinn von vorn,
|
|
||||||
/// damit `cross(c-a, b-a)` nach außen zeigt.
|
|
||||||
fn plane(p: &[[f32; 3]; 3]) -> Plane {
|
|
||||||
let n = normalize(cross(sub(p[2], p[0]), sub(p[1], p[0])));
|
|
||||||
Plane { n, d: dot(n, p[0]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schnittpunkt dreier Ebenen (Cramer); `None`, wenn sie ~parallel sind.
|
|
||||||
fn intersect(a: &Plane, b: &Plane, c: &Plane) -> Option<[f32; 3]> {
|
|
||||||
let denom = dot(a.n, cross(b.n, c.n));
|
|
||||||
if denom.abs() < 1e-6 { return None; }
|
|
||||||
let mut x = scale(cross(b.n, c.n), a.d);
|
|
||||||
x = add(x, scale(cross(c.n, a.n), b.d));
|
|
||||||
x = add(x, scale(cross(a.n, b.n), c.d));
|
|
||||||
Some(scale(x, 1.0 / denom))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Textur-Achsen (skaliert) im Quake-System nach dem Standard-Map-Format:
|
|
||||||
/// dominante Achse aus der Normale, dann Rotation und Skalierung.
|
|
||||||
fn tex_axes(n: [f32; 3], face: &Face) -> ([f32; 3], [f32; 3]) {
|
|
||||||
let (mut u, mut v) = base_axes(n);
|
|
||||||
|
|
||||||
let (sinv, cosv) = face.rotation.to_radians().sin_cos();
|
|
||||||
// Basis-Achsen sind achsen-ausgerichtet (genau eine Komponente ≠ 0);
|
|
||||||
// wir rotieren ihre beiden in-plane-Komponenten.
|
|
||||||
let su = nonzero_axis(u);
|
|
||||||
let sv = nonzero_axis(v);
|
|
||||||
for vec in [&mut u, &mut v] {
|
|
||||||
let a = cosv * vec[su] - sinv * vec[sv];
|
|
||||||
let b = sinv * vec[su] + cosv * vec[sv];
|
|
||||||
vec[su] = a;
|
|
||||||
vec[sv] = b;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sx = if face.scale[0] == 0.0 { 1.0 } else { face.scale[0] };
|
|
||||||
let sy = if face.scale[1] == 0.0 { 1.0 } else { face.scale[1] };
|
|
||||||
(scale(u, 1.0 / sx), scale(v, 1.0 / sy))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Quake-`baseaxis`-Tabelle: (Normalen-Kandidat, U-Achse, V-Achse) × 6.
|
|
||||||
/// Die U/V-Achsen der am besten passenden Normale bilden die Textur-Ebene.
|
|
||||||
fn base_axes(n: [f32; 3]) -> ([f32; 3], [f32; 3]) {
|
|
||||||
const BA: [[[f32; 3]; 3]; 6] = [
|
|
||||||
[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], // Boden (+Z)
|
|
||||||
[[0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], // Decke (-Z)
|
|
||||||
[[ 1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], // West (+X)
|
|
||||||
[[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], // Ost (-X)
|
|
||||||
[[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]], // Süd (+Y)
|
|
||||||
[[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]], // Nord (-Y)
|
|
||||||
];
|
|
||||||
let mut best = -1.0;
|
|
||||||
let mut bi = 0;
|
|
||||||
for (i, ax) in BA.iter().enumerate() {
|
|
||||||
let d = dot(n, ax[0]);
|
|
||||||
if d > best { best = d; bi = i; }
|
|
||||||
}
|
|
||||||
(BA[bi][1], BA[bi][2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- kleine Vektor-Helfer auf [f32; 3] ---------------------------------------
|
|
||||||
|
|
||||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
|
||||||
fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] + b[0], a[1] + b[1], a[2] + b[2]] }
|
|
||||||
fn scale(a: [f32; 3], s: f32) -> [f32; 3] { [a[0] * s, a[1] * s, a[2] * s] }
|
|
||||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
|
||||||
fn dist2(a: [f32; 3], b: [f32; 3]) -> f32 { let d = sub(a, b); dot(d, d) }
|
|
||||||
|
|
||||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
|
||||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize(a: [f32; 3]) -> [f32; 3] {
|
|
||||||
let len = dot(a, a).sqrt();
|
|
||||||
if len > 0.0 { scale(a, 1.0 / len) } else { a }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn average(pts: &[[f32; 3]]) -> [f32; 3] {
|
|
||||||
let mut c = [0.0; 3];
|
|
||||||
for p in pts { c = add(c, *p); }
|
|
||||||
scale(c, 1.0 / pts.len() as f32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Index der (einzigen) Nicht-Null-Komponente einer achsen-ausgerichteten Achse.
|
|
||||||
fn nonzero_axis(a: [f32; 3]) -> usize {
|
|
||||||
if a[0] != 0.0 { 0 } else if a[1] != 0.0 { 1 } else { 2 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Achse in der Ebene, möglichst weit weg von der Normale, als Sortier-Basis.
|
|
||||||
fn any_perp(n: [f32; 3]) -> [f32; 3] {
|
|
||||||
let a = if n[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
|
|
||||||
normalize(cross(n, a))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Winkel von `p` um `center` in der (u, v)-Basis — für CCW-Sortierung.
|
|
||||||
fn angle(p: [f32; 3], center: [f32; 3], u: [f32; 3], v: [f32; 3]) -> f32 {
|
|
||||||
let d = sub(p, center);
|
|
||||||
dot(d, v).atan2(dot(d, u))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::engine::map;
|
|
||||||
|
|
||||||
// Ein achsenparalleler Quader-Brush (x[-48,80], y[-64,64], z[-48,-16]).
|
|
||||||
const BOX: &str = r#"
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
{
|
|
||||||
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) t 0 0 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) t 0 0 0 1 1
|
|
||||||
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) t 0 0 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) t 0 0 0 1 1
|
|
||||||
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) t 0 0 0 1 1
|
|
||||||
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) t 0 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn box_brush_has_cuboid_geometry() {
|
|
||||||
let m = map::parse(BOX);
|
|
||||||
let names = texture_names(&m);
|
|
||||||
assert_eq!(names, vec!["t".to_string()]);
|
|
||||||
let mesh = build(&m, &names, &[(64, 64)]);
|
|
||||||
|
|
||||||
// Quader: 6 Faces · 4 Ecken = 24 Vertices, 6 · 2 Dreiecke · 3 = 36 Indizes.
|
|
||||||
assert_eq!(mesh.verts.len(), 24);
|
|
||||||
assert_eq!(mesh.indices.len(), 36);
|
|
||||||
// Eine Textur → ein Batch über alle Indizes.
|
|
||||||
assert_eq!(mesh.batches.len(), 1);
|
|
||||||
assert_eq!((mesh.batches[0].texture, mesh.batches[0].start, mesh.batches[0].count), (0, 0, 36));
|
|
||||||
|
|
||||||
// Engine-Bounds: x[-1.5,2.5], y(=quake z)[-1.5,-0.5], z(=-quake y)[-2,2].
|
|
||||||
let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]);
|
|
||||||
for v in &mesh.verts {
|
|
||||||
for k in 0..3 {
|
|
||||||
mn[k] = mn[k].min(v.pos[k]);
|
|
||||||
mx[k] = mx[k].max(v.pos[k]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let approx = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
|
||||||
assert!(approx(mn[0], -1.5) && approx(mx[0], 2.5), "x: {:?}..{:?}", mn[0], mx[0]);
|
|
||||||
assert!(approx(mn[1], -1.5) && approx(mx[1], -0.5), "y: {:?}..{:?}", mn[1], mx[1]);
|
|
||||||
assert!(approx(mn[2], -2.0) && approx(mx[2], 2.0), "z: {:?}..{:?}", mn[2], mx[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zwei achsenparallele Würfel, die sich die Ebene x=64 teilen:
|
|
||||||
// A = x[0,64], B = x[64,128], beide y[0,64] z[0,64].
|
|
||||||
const TWO_BOXES: &str = r#"
|
|
||||||
{
|
|
||||||
"classname" "worldspawn"
|
|
||||||
{
|
|
||||||
( 0 0 0 ) ( 0 1 0 ) ( 0 0 1 ) t 0 0 0 1 1
|
|
||||||
( 0 0 0 ) ( 0 0 1 ) ( 1 0 0 ) t 0 0 0 1 1
|
|
||||||
( 0 0 0 ) ( 1 0 0 ) ( 0 1 0 ) t 0 0 0 1 1
|
|
||||||
( 64 64 64 ) ( 64 65 64 ) ( 65 64 64 ) t 0 0 0 1 1
|
|
||||||
( 64 64 64 ) ( 65 64 64 ) ( 64 64 65 ) t 0 0 0 1 1
|
|
||||||
( 64 64 64 ) ( 64 64 65 ) ( 64 65 64 ) t 0 0 0 1 1
|
|
||||||
}
|
|
||||||
{
|
|
||||||
( 64 0 0 ) ( 64 1 0 ) ( 64 0 1 ) t 0 0 0 1 1
|
|
||||||
( 64 0 0 ) ( 64 0 1 ) ( 65 0 0 ) t 0 0 0 1 1
|
|
||||||
( 64 0 0 ) ( 65 0 0 ) ( 64 1 0 ) t 0 0 0 1 1
|
|
||||||
( 128 64 64 ) ( 128 65 64 ) ( 129 64 64 ) t 0 0 0 1 1
|
|
||||||
( 128 64 64 ) ( 129 64 64 ) ( 128 64 65 ) t 0 0 0 1 1
|
|
||||||
( 128 64 64 ) ( 128 64 65 ) ( 128 65 64 ) t 0 0 0 1 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shared_faces_are_eliminated() {
|
|
||||||
let m = map::parse(TWO_BOXES);
|
|
||||||
let names = texture_names(&m);
|
|
||||||
let mesh = build(&m, &names, &[(64, 64)]);
|
|
||||||
|
|
||||||
// 2 Würfel · 6 Faces = 12, minus die 2 deckungsgleichen an x=64 → 10.
|
|
||||||
// 10 Faces · 4 Ecken = 40 Vertices, 10 · 2 Dreiecke · 3 = 60 Indizes.
|
|
||||||
// (Ohne Elimination wären es 48 Vertices / 72 Indizes.)
|
|
||||||
assert_eq!(mesh.verts.len(), 40);
|
|
||||||
assert_eq!(mesh.indices.len(), 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uvs_scale_with_texture_size() {
|
|
||||||
// Auf der +Z-Boden-Face (baseaxis U=+X, V=-Y) ist U = qx/tw, V = -qy/th
|
|
||||||
// bei Offset 0, Scale 1. Prüft, dass UVs in Texeln/Größe gerechnet werden.
|
|
||||||
let m = map::parse(BOX);
|
|
||||||
let names = texture_names(&m);
|
|
||||||
let mesh = build(&m, &names, &[(64, 64)]);
|
|
||||||
// Alle UVs endlich und im erwarteten groben Bereich (Brush ~128 units
|
|
||||||
// breit / 64 Texel → bis ~2).
|
|
||||||
assert!(mesh.verts.iter().all(|v| v.uv[0].is_finite() && v.uv[1].is_finite()));
|
|
||||||
assert!(mesh.verts.iter().any(|v| v.uv[0].abs() > 0.5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -40,9 +40,9 @@ pub(crate) struct Font {
|
|||||||
/// Die im Projekt geladenen Fonts, gebaut in render::run und an `ui::layout`
|
/// Die im Projekt geladenen Fonts, gebaut in render::run und an `ui::layout`
|
||||||
/// gereicht.
|
/// gereicht.
|
||||||
pub(crate) struct Fonts {
|
pub(crate) struct Fonts {
|
||||||
/// Größerer Font, für spätere Überschriften/HUD bereitgehalten.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) ega: Font,
|
pub(crate) ega: Font,
|
||||||
|
/// Kleinerer Font, für späteres HUD-Kleinzeug bereitgehalten.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) cga: Font,
|
pub(crate) cga: Font,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -1,10 +1,14 @@
|
|||||||
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
|
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
|
||||||
//! aus dem Renderer-Plan:
|
//! aus dem Renderer-Plan:
|
||||||
//!
|
//!
|
||||||
//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild
|
//! Pass 1 (intern): 640×480 RGBA8 + Depth — hier entsteht das Bild
|
||||||
//! ([`ScenePass`], PS1-Shader).
|
//! ([`ScenePass`], PS1-Shader).
|
||||||
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
|
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
|
||||||
//! 4:3-Letterbox (via Viewport) auf die Surface.
|
//! 4:3-Letterbox (via Viewport) auf die Surface.
|
||||||
|
//!
|
||||||
|
//! 640×480 (VGA) statt 320×240: das Spiel ist text-getrieben — die höhere
|
||||||
|
//! Auflösung trägt den 80-Spalten-Textmodus-Look (EGA 8×14), der Lo-Fi-Rest
|
||||||
|
//! (RGB555 + Bayer-Dither, affines Mapping) bleibt.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -16,8 +20,8 @@ use crate::render::scene::{Mesh, ScenePass};
|
|||||||
use crate::render::sprite::SpritePass;
|
use crate::render::sprite::SpritePass;
|
||||||
use crate::render::ui::Ui;
|
use crate::render::ui::Ui;
|
||||||
|
|
||||||
pub const INTERNAL_W: u32 = 320;
|
pub const INTERNAL_W: u32 = 640;
|
||||||
pub const INTERNAL_H: u32 = 240;
|
pub const INTERNAL_H: u32 = 480;
|
||||||
|
|
||||||
/// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall
|
/// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall
|
||||||
/// (auch GL-Fallback) unterstützte Depth-Format.
|
/// (auch GL-Fallback) unterstützte Depth-Format.
|
||||||
|
|||||||
+147
-33
@@ -14,15 +14,17 @@
|
|||||||
//! 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 brush;
|
mod audio;
|
||||||
mod camera;
|
mod camera;
|
||||||
mod font;
|
mod font;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
mod math;
|
mod math;
|
||||||
|
mod props;
|
||||||
mod scene;
|
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, map, 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;
|
||||||
@@ -61,25 +63,49 @@ pub fn run(mut session: Session) {
|
|||||||
|
|
||||||
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
|
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
|
||||||
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
||||||
// getrennt.
|
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
|
||||||
let world = map::load(&assets::path("assets/maps/test.map"));
|
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
|
||||||
let tex_names = brush::texture_names(&world);
|
// nie fatal.
|
||||||
let images: Vec<Image> = tex_names.iter()
|
let models = load_models(&assets::path("assets/maps/props"));
|
||||||
.map(|n| tga::load(&assets::path(&format!("assets/textures/{n}.tga"))))
|
|
||||||
.collect();
|
|
||||||
let dims: Vec<(u32, u32)> = images.iter().map(|i| (i.width, i.height)).collect();
|
|
||||||
let mesh = brush::build(&world, &tex_names, &dims);
|
|
||||||
report_map(&world, &tex_names, &mesh);
|
|
||||||
|
|
||||||
// Sim-Welt (Collision-Brushes + Spawn) in die Session einspielen — die
|
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
|
||||||
// Simulation gehört der Session, nicht dem Render-Frontend.
|
// denselben Index-Raum benutzen und zu einem Buffer verschmelzen können.
|
||||||
session.load_world(&world);
|
let mut tex_names: Vec<String> = Vec::new();
|
||||||
|
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
|
||||||
|
tex_names.sort_unstable();
|
||||||
|
tex_names.dedup();
|
||||||
|
let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();
|
||||||
|
|
||||||
|
let mut mesh = Mesh::default();
|
||||||
|
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
||||||
|
println!("[scene] {} Modelle, Texturen {:?} → {} Vertices, {} Dreiecke",
|
||||||
|
models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);
|
||||||
|
|
||||||
|
// Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session
|
||||||
|
// einspielen — die Simulation gehört der Session, nicht dem
|
||||||
|
// Render-Frontend.
|
||||||
|
for (_, m) in &models { session.load_props(m); }
|
||||||
|
|
||||||
|
// Audio-Treiber öffnen (vor dem stdin-Thread, siehe Audio::open) und je
|
||||||
|
// Ambient-Emitter eine Loop-Voice anlegen; die Gains zieht redraw()
|
||||||
|
// dann jeden Frame aus der Session. Ohne Gerät läuft das Spiel stumm.
|
||||||
|
let audio = audio::Audio::open();
|
||||||
|
if let Some(a) = &audio {
|
||||||
|
let sounds: Vec<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
|
||||||
// Luminanz.
|
// Luminanz.
|
||||||
let load_keyed = |p: &str| ui::key_luminance(&tga::load(&assets::path(p)));
|
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie,
|
||||||
|
// ist die Installation kaputt, da darf es scheitern.
|
||||||
|
let load_keyed = |p: &str| {
|
||||||
|
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
|
||||||
|
};
|
||||||
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
|
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
|
||||||
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
|
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
|
||||||
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.tga");
|
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.tga");
|
||||||
@@ -118,22 +144,74 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kurzer Lade-Report: Map-Inhalt und resultierende Geometriegröße.
|
/// Alle Blender-Modelle (`.glb`) eines Verzeichnisses laden (sortiert →
|
||||||
fn report_map(world: &map::Map, tex_names: &[String], mesh: &Mesh) {
|
/// deterministische Reihenfolge). Fehlende Verzeichnisse sind kein Fehler
|
||||||
let brushes: usize = world.entities.iter().map(|e| e.brushes.len()).sum();
|
/// (kein Props-Ordner = keine Props); kaputte Dateien werden gemeldet und
|
||||||
let faces: usize = world.entities.iter()
|
/// übersprungen.
|
||||||
.flat_map(|e| &e.brushes)
|
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
||||||
.map(|b| b.faces.len())
|
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
|
||||||
.sum();
|
let mut paths: Vec<std::path::PathBuf> = entries.flatten()
|
||||||
println!(
|
.map(|e| e.path())
|
||||||
"[map] {} Entities, {} Brushes, {} Faces, Texturen {:?} → {} Vertices, {} Dreiecke",
|
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("glb"))
|
||||||
world.entities.len(), brushes, faces, tex_names,
|
.collect();
|
||||||
mesh.verts.len(), mesh.indices.len() / 3,
|
paths.sort();
|
||||||
);
|
let mut models = Vec::new();
|
||||||
|
for p in paths {
|
||||||
|
let path = p.to_string_lossy();
|
||||||
|
match gltf::load(&path) {
|
||||||
|
Ok(m) => {
|
||||||
|
let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
|
||||||
|
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
||||||
|
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
|
||||||
|
m.objects.len(), m.empties.len());
|
||||||
|
// Custom Properties sichtbar machen — noch konsumiert sie
|
||||||
|
// niemand, aber die Autorin sieht so, dass sie ankommen.
|
||||||
|
for o in &m.objects {
|
||||||
|
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
|
||||||
|
}
|
||||||
|
for e in &m.empties {
|
||||||
|
println!("[model] Empty {} @ {:?}", e.name, e.pos);
|
||||||
|
for (k, v) in &e.props { println!("[model] {} · {k} = {v}", e.name); }
|
||||||
|
}
|
||||||
|
models.push((name, m));
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("[model] {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
|
||||||
|
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
|
||||||
|
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
|
||||||
|
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
|
||||||
|
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
|
||||||
|
fn load_sound(name: &str) -> Option<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welt-Textur laden. Autorinnen-Content: fehlt oder klemmt die Datei, wird
|
||||||
|
/// gemeldet und der Platzhalter genommen — der selbst ist Projekt-Asset und
|
||||||
|
/// darf hart scheitern.
|
||||||
|
fn load_texture(name: &str) -> Image {
|
||||||
|
match tga::load(&assets::path(&format!("assets/textures/{name}.tga"))) {
|
||||||
|
Ok(img) => img,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[tex] {e} — Platzhalter");
|
||||||
|
tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE)))
|
||||||
|
.expect("Platzhalter-Textur (Projekt-Asset)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
||||||
@@ -176,6 +254,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 {
|
||||||
@@ -186,6 +269,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;
|
||||||
@@ -205,6 +289,8 @@ impl App {
|
|||||||
images,
|
images,
|
||||||
ui_textures,
|
ui_textures,
|
||||||
fonts,
|
fonts,
|
||||||
|
audio,
|
||||||
|
sfx_cache: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +445,8 @@ impl App {
|
|||||||
look_dx: self.input.mouse_dx,
|
look_dx: self.input.mouse_dx,
|
||||||
look_dy: self.input.mouse_dy,
|
look_dy: self.input.mouse_dy,
|
||||||
};
|
};
|
||||||
self.session.tick(&input, dt);
|
// Betretens-Zonen melden ihre Signal-Ausgaben wie exec-Befehle.
|
||||||
|
for out in self.session.tick(&input, dt) { println!("{out}"); }
|
||||||
}
|
}
|
||||||
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
||||||
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
||||||
@@ -367,6 +454,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).
|
||||||
@@ -391,12 +491,24 @@ impl App {
|
|||||||
let p = self.session.player.pos;
|
let p = self.session.player.pos;
|
||||||
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
|
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
|
||||||
};
|
};
|
||||||
|
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
|
||||||
|
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
|
||||||
|
// löst beim Klick unten das `use` aus.
|
||||||
|
let aim: Option<String> = if play {
|
||||||
|
let eye = self.session.player.eye();
|
||||||
|
let dir = self.session.player.look_dir();
|
||||||
|
self.session.pick(eye, dir, crate::session::INTERACT_REACH).map(str::to_string)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Mausposition über die Letterbox in interne Pixel mappen.
|
// Mausposition über die Letterbox in interne Pixel mappen.
|
||||||
let cursor = ui::Cursor {
|
let cursor = ui::Cursor {
|
||||||
pos: self.gpu.as_ref()
|
pos: self.gpu.as_ref()
|
||||||
.map(|g| g.map_cursor(self.cursor_win))
|
.map(|g| g.map_cursor(self.cursor_win))
|
||||||
.unwrap_or([0.0, 0.0]),
|
.unwrap_or([0.0, 0.0]),
|
||||||
grabbed: self.input.grabbed,
|
grabbed: self.input.grabbed,
|
||||||
|
hud_interact: aim.is_some(),
|
||||||
};
|
};
|
||||||
// Overlay state-driven bauen (verzweigt nach session.mode); die
|
// Overlay state-driven bauen (verzweigt nach session.mode); die
|
||||||
// Hover-Aktion kommt aus der Maus-Position.
|
// Hover-Aktion kommt aus der Maus-Position.
|
||||||
@@ -404,12 +516,14 @@ impl App {
|
|||||||
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
|
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion
|
// Vorgemerkten Klick auflösen — beides durch denselben exec-Trichter
|
||||||
// durch denselben exec-Trichter wie die Konsole. Klick in der freien
|
// wie die Konsole: über einem UI-Klickziel dessen Aktion, sonst im
|
||||||
// Welt tut (noch) nichts — hier käme später der `use`-Raycast.
|
// Spielmodus das Fadenkreuz-Ziel als `use <name>`.
|
||||||
if self.pending_click {
|
if self.pending_click {
|
||||||
self.pending_click = false;
|
self.pending_click = false;
|
||||||
if let Some(action) = screen.hover_action.clone() {
|
let action = screen.hover_action.clone()
|
||||||
|
.or_else(|| aim.map(|name| format!("use {name}")));
|
||||||
|
if let Some(action) = action {
|
||||||
let r = self.session.exec(&action);
|
let r = self.session.exec(&action);
|
||||||
for out in r.output { println!("{out}"); }
|
for out in r.output { println!("{out}"); }
|
||||||
if r.quit { event_loop.exit(); }
|
if r.quit { event_loop.exit(); }
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Blender-Modell → Render-Geometrie.
|
||||||
|
//!
|
||||||
|
//! Nimmt das neutrale [`Model`] aus engine::gltf und baut daraus ein
|
||||||
|
//! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere
|
||||||
|
//! Modelle verschmelzen über `Mesh::append` zu einem Buffer.
|
||||||
|
//!
|
||||||
|
//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst zu
|
||||||
|
//! `assets/textures/{name}.tga` (macht der Aufrufer). Faces ohne Material
|
||||||
|
//! (`""`) laufen unter [`FALLBACK_TEXTURE`].
|
||||||
|
//!
|
||||||
|
//! UVs kommen unverändert durch (Modell und `tga::Image` teilen den
|
||||||
|
//! Oben-links-Ursprung). Nur `visible`-Objekte werden ausgegeben;
|
||||||
|
//! Collider-/Zonen-Volumen sind Sache von engine::collision und
|
||||||
|
//! engine::trigger.
|
||||||
|
|
||||||
|
use crate::engine::model::Model;
|
||||||
|
use crate::render::scene::{Batch, Mesh, Vertex};
|
||||||
|
|
||||||
|
/// Texturname für Faces ohne `usemtl` (Material `""`).
|
||||||
|
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
||||||
|
|
||||||
|
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
||||||
|
/// Fallback gemappt.
|
||||||
|
pub fn texture_names(model: &Model) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = model.materials.iter()
|
||||||
|
.map(|m| resolve(m).to_string())
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(material: &str) -> &str {
|
||||||
|
if material.is_empty() { FALLBACK_TEXTURE } else { material }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt die Textur-
|
||||||
|
/// Indizes vor — darf eine Obermenge sein, so teilen alle Modelle eine
|
||||||
|
/// gemeinsame Texturliste.
|
||||||
|
pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
||||||
|
let mut verts = Vec::new();
|
||||||
|
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
||||||
|
|
||||||
|
for o in &model.objects {
|
||||||
|
if !o.visible { continue; }
|
||||||
|
let base = verts.len() as u32;
|
||||||
|
for (p, uv) in o.verts.iter().zip(&o.uvs) {
|
||||||
|
verts.push(Vertex { pos: *p, uv: *uv });
|
||||||
|
}
|
||||||
|
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
|
||||||
|
let name = resolve(&model.materials[m]);
|
||||||
|
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
|
||||||
|
per_tex[ti].extend(t.map(|i| base + i as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen.
|
||||||
|
let mut indices = Vec::new();
|
||||||
|
let mut batches = Vec::new();
|
||||||
|
for (ti, list) in per_tex.into_iter().enumerate() {
|
||||||
|
if list.is_empty() { continue; }
|
||||||
|
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
|
||||||
|
indices.extend_from_slice(&list);
|
||||||
|
}
|
||||||
|
Mesh { verts, indices, batches }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::engine::model::{Model, Object, Props};
|
||||||
|
|
||||||
|
/// Ein Dreieck mit UVs; `visible` steuert, ob es gerendert werden darf.
|
||||||
|
fn tri(name: &str, visible: bool) -> Object {
|
||||||
|
Object {
|
||||||
|
name: name.into(),
|
||||||
|
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
|
||||||
|
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
|
||||||
|
tris: vec![[0, 1, 2]],
|
||||||
|
tri_mats: vec![0],
|
||||||
|
visible, collider: !visible,
|
||||||
|
props: Props::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample() -> Model {
|
||||||
|
Model {
|
||||||
|
objects: vec![tri("Ding", true), tri("Proxy", false)],
|
||||||
|
empties: Vec::new(),
|
||||||
|
materials: vec!["carpet".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_only_visible_objects() {
|
||||||
|
let m = sample();
|
||||||
|
let names = texture_names(&m);
|
||||||
|
assert_eq!(names, vec!["carpet".to_string()]);
|
||||||
|
let mesh = build(&m, &names);
|
||||||
|
|
||||||
|
// Nur „Ding" (der Proxy ist unsichtbar): 3 Vertices, 1 Dreieck.
|
||||||
|
assert_eq!(mesh.verts.len(), 3);
|
||||||
|
assert_eq!(mesh.indices, vec![0, 1, 2]);
|
||||||
|
assert_eq!(mesh.batches.len(), 1);
|
||||||
|
// UVs unverändert (geteilte Oben-links-Konvention).
|
||||||
|
assert_eq!(mesh.verts[2].uv, [1.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_material_maps_to_fallback() {
|
||||||
|
let mut m = sample();
|
||||||
|
m.materials = vec![String::new()]; // Faces ohne Material
|
||||||
|
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]);
|
||||||
|
let mesh = build(&m, &texture_names(&m));
|
||||||
|
assert_eq!(mesh.batches.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shares_texture_index_space_with_superset_list() {
|
||||||
|
// Obermengen-Liste (wie beim Merge mehrerer Modelle): der Batch
|
||||||
|
// zeigt auf den richtigen Index in der geteilten Liste.
|
||||||
|
let names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()];
|
||||||
|
let mesh = build(&sample(), &names);
|
||||||
|
assert_eq!(mesh.batches[0].texture, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-10
@@ -1,9 +1,9 @@
|
|||||||
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
||||||
//!
|
//!
|
||||||
//! Stand Schritt 5: Geometrie + Texturen kommen vom Aufrufer (Brush-Welt
|
//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via
|
||||||
//! aus der `.map`, siehe render::brush; Bilder aus engine::tga). Pro Textur
|
//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group +
|
||||||
//! eine Bind-Group + ein Draw-Batch (Material-Batching „pro Textur ein
|
//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan).
|
||||||
//! Draw" wie im Plan). Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
//! Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
||||||
|
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
@@ -28,12 +28,31 @@ pub(crate) struct Batch {
|
|||||||
|
|
||||||
/// CPU-seitige Welt-Geometrie, fertig zum Hochladen. Die Indizes sind nach
|
/// CPU-seitige Welt-Geometrie, fertig zum Hochladen. Die Indizes sind nach
|
||||||
/// Textur gruppiert; `batches` zeigt in diese Reihenfolge.
|
/// Textur gruppiert; `batches` zeigt in diese Reihenfolge.
|
||||||
|
#[derive(Default)]
|
||||||
pub(crate) struct Mesh {
|
pub(crate) struct Mesh {
|
||||||
pub(crate) verts: Vec<Vertex>,
|
pub(crate) verts: Vec<Vertex>,
|
||||||
pub(crate) indices: Vec<u32>,
|
pub(crate) indices: Vec<u32>,
|
||||||
pub(crate) batches: Vec<Batch>,
|
pub(crate) batches: Vec<Batch>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Mesh {
|
||||||
|
/// Ein zweites Mesh anhängen (ein Vertex-/Index-Buffer für alles).
|
||||||
|
/// Voraussetzung: beide wurden gegen *dieselbe* Texturliste gebaut —
|
||||||
|
/// die Batch-Texturindizes bleiben dann unverändert gültig; nur die
|
||||||
|
/// Buffer-Offsets verschieben sich.
|
||||||
|
pub(crate) fn append(&mut self, other: Mesh) {
|
||||||
|
let vbase = self.verts.len() as u32;
|
||||||
|
let ibase = self.indices.len() as u32;
|
||||||
|
self.verts.extend(other.verts);
|
||||||
|
self.indices.extend(other.indices.iter().map(|i| i + vbase));
|
||||||
|
self.batches.extend(other.batches.iter().map(|b| Batch {
|
||||||
|
texture: b.texture,
|
||||||
|
start: b.start + ibase,
|
||||||
|
count: b.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe
|
/// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe
|
||||||
/// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln
|
/// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln
|
||||||
/// von WGSL verlangen.
|
/// von WGSL verlangen.
|
||||||
@@ -89,12 +108,9 @@ impl ScenePass {
|
|||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
targets: &[Some(color_format.into())],
|
targets: &[Some(color_format.into())],
|
||||||
}),
|
}),
|
||||||
// Back-face Culling: Brush-Polygone werden CCW um ihre nach
|
// Back-face Culling: glTF-Dreiecke sind CCW von außen
|
||||||
// außen zeigende Normale gewickelt (siehe render::brush), und
|
// gewickelt — die Außenflächen sind front-facing, Rückseiten
|
||||||
// die Z-up→Y-up-Drehung erhält die Orientierung (det +1). Also
|
// fallen weg. (Front-Face bleibt der wgpu-Default CCW.)
|
||||||
// sind die Außenflächen front-facing — Rückseiten und alle
|
|
||||||
// verdeckten Innenflächen zwischen Brushes fallen weg.
|
|
||||||
// (Front-Face bleibt der wgpu-Default CCW.)
|
|
||||||
primitive: wgpu::PrimitiveState {
|
primitive: wgpu::PrimitiveState {
|
||||||
cull_mode: Some(wgpu::Face::Back),
|
cull_mode: Some(wgpu::Face::Back),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+12
-7
@@ -30,8 +30,7 @@ pub(crate) const ORN: usize = 4;
|
|||||||
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
|
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
|
||||||
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
|
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
|
||||||
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
|
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
|
||||||
// Die interagierbare HUD-Variante (3) kommt mit dem Welt-Raycast.
|
pub(crate) const CUR_HUD_INTERACT: usize = 3; // dito, über einem Klick-Ziel
|
||||||
#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3;
|
|
||||||
|
|
||||||
const CURSOR_PX: f32 = 16.0;
|
const CURSOR_PX: f32 = 16.0;
|
||||||
const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel)
|
const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel)
|
||||||
@@ -61,6 +60,9 @@ pub(crate) struct Cursor {
|
|||||||
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
||||||
/// Maus-Cursor an `pos`.
|
/// Maus-Cursor an `pos`.
|
||||||
pub(crate) grabbed: bool,
|
pub(crate) grabbed: bool,
|
||||||
|
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
|
||||||
|
/// Variante des HUD-Pointers.
|
||||||
|
pub(crate) hud_interact: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass.
|
/// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass.
|
||||||
@@ -219,7 +221,9 @@ const TEXT: [f32; 4] = [0.92, 0.92, 1.0, 1.0];
|
|||||||
pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: &Cursor) -> Screen {
|
pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: &Cursor) -> Screen {
|
||||||
let mut ui = Ui::default();
|
let mut ui = Ui::default();
|
||||||
let mut hover: Option<String> = None;
|
let mut hover: Option<String> = None;
|
||||||
let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font.
|
// EGA 8×14 ist der UI-Standard-Font: bei 640×480 der klassische
|
||||||
|
// 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
|
||||||
|
let f = &fonts.ega;
|
||||||
|
|
||||||
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
||||||
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
||||||
@@ -237,7 +241,8 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
|
|||||||
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
|
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
|
||||||
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
|
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
|
||||||
if cur.grabbed {
|
if cur.grabbed {
|
||||||
ui.cursor(CUR_HUD, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
|
let idx = if cur.hud_interact { CUR_HUD_INTERACT } else { CUR_HUD };
|
||||||
|
ui.cursor(idx, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
|
||||||
} else {
|
} else {
|
||||||
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
|
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
|
||||||
ui.cursor(idx, cur.pos, [1.0; 4]);
|
ui.cursor(idx, cur.pos, [1.0; 4]);
|
||||||
@@ -254,7 +259,7 @@ fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Op
|
|||||||
("KV anzeigen", "kv"),
|
("KV anzeigen", "kv"),
|
||||||
("Beenden", "quit"),
|
("Beenden", "quit"),
|
||||||
];
|
];
|
||||||
let (bw, bh, gap) = (112.0, 14.0, 4.0);
|
let (bw, bh, gap) = (176.0, 20.0, 6.0);
|
||||||
let title_h = f.glyph_h;
|
let title_h = f.glyph_h;
|
||||||
let body_h = title_h + gap + items.len() as f32 * bh + (items.len() as f32 - 1.0) * gap;
|
let body_h = title_h + gap + items.len() as f32 * bh + (items.len() as f32 - 1.0) * gap;
|
||||||
let pw = bw + 2.0 * INSET;
|
let pw = bw + 2.0 * INSET;
|
||||||
@@ -277,8 +282,8 @@ fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Op
|
|||||||
/// ein „Weiter"-Button, dessen Aktion die Leereingabe ist (blättert weiter) —
|
/// ein „Weiter"-Button, dessen Aktion die Leereingabe ist (blättert weiter) —
|
||||||
/// derselbe Pfad wie Enter im Terminal (`dialog_input`).
|
/// derselbe Pfad wie Enter im Terminal (`dialog_input`).
|
||||||
fn dialog(ui: &mut Ui, f: &Font, internal: [f32; 2], d: &Dialog, cur: [f32; 2], hover: &mut Option<String>) {
|
fn dialog(ui: &mut Ui, f: &Font, internal: [f32; 2], d: &Dialog, cur: [f32; 2], hover: &mut Option<String>) {
|
||||||
let margin = 16.0;
|
let margin = 24.0;
|
||||||
let (bh, gap) = (14.0, 4.0);
|
let (bh, gap) = (20.0, 6.0);
|
||||||
let pw = internal[0] - 2.0 * margin;
|
let pw = internal[0] - 2.0 * margin;
|
||||||
let line_h = f.glyph_h + 2.0;
|
let line_h = f.glyph_h + 2.0;
|
||||||
|
|
||||||
|
|||||||
+190
-36
@@ -1,9 +1,10 @@
|
|||||||
|
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;
|
||||||
use crate::engine::map::{self, Map};
|
use crate::engine::player::{self, Player};
|
||||||
use crate::engine::player::Player;
|
use crate::engine::trigger::Triggers;
|
||||||
use crate::engine::{kv, signals, story_ctrl};
|
use crate::engine::{kv, model, signals, story_ctrl};
|
||||||
|
|
||||||
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
|
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
|
||||||
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
|
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
|
||||||
@@ -20,6 +21,9 @@ const FIXED_DT: f32 = 1.0 / 60.0;
|
|||||||
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
|
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
|
||||||
const MAX_ACCUM: f32 = 0.25;
|
const MAX_ACCUM: f32 = 0.25;
|
||||||
|
|
||||||
|
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
|
||||||
|
pub const INTERACT_REACH: f32 = 3.0;
|
||||||
|
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
FirstPerson,
|
FirstPerson,
|
||||||
Free,
|
Free,
|
||||||
@@ -28,9 +32,6 @@ pub enum Mode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Dialog {
|
pub struct Dialog {
|
||||||
// Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der
|
|
||||||
// Dialogtext über die Konsolen-Ausgabe (siehe `step`).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub choices: Vec<String>,
|
pub choices: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,16 @@ pub struct Session {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
pub collision: CollisionWorld,
|
pub collision: CollisionWorld,
|
||||||
|
/// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen
|
||||||
|
/// (`signal`-Property, siehe engine::trigger).
|
||||||
|
triggers: Triggers,
|
||||||
|
/// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
|
||||||
|
/// Empties, siehe engine::audio).
|
||||||
|
emitters: Emitters,
|
||||||
|
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
|
||||||
|
/// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt
|
||||||
|
/// sie liegen (ihr reicht die `[action]`-Textzeile).
|
||||||
|
pending_sounds: Vec<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,
|
||||||
@@ -79,29 +90,62 @@ impl Session {
|
|||||||
Self {
|
Self {
|
||||||
game,
|
game,
|
||||||
mode: Mode::Menu,
|
mode: Mode::Menu,
|
||||||
// Default-Welt, bis `load_world` eine Map einspielt (die CLI
|
// Default-Spawn, bis ein Modell einen `role=spawn`-Empty
|
||||||
// braucht keine — sie tickt nie).
|
// mitbringt (die CLI braucht keine Welt — sie tickt nie).
|
||||||
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(),
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den
|
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
|
||||||
/// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim
|
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
|
||||||
/// Start gerufen.
|
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
|
||||||
pub fn load_world(&mut self, map: &Map) {
|
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
|
||||||
self.collision = CollisionWorld::build(map);
|
/// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
|
||||||
self.player = Player::new(player_spawn(map));
|
/// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
|
||||||
|
/// desselben Modells ist Sache des Renderers (render::props).
|
||||||
|
pub fn load_props(&mut self, model: &model::Model) {
|
||||||
|
self.collision.add_model(model);
|
||||||
|
self.triggers.add_model(model);
|
||||||
|
self.emitters.add_model(model);
|
||||||
|
for e in &model.empties {
|
||||||
|
if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) {
|
||||||
|
self.player = Player::new(e.pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
self.emitters.gains(self.player.eye())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
|
||||||
pub fn tick(&mut self, input: &FrameInput, dt: f32) {
|
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
|
||||||
if !matches!(self.mode, Mode::FirstPerson) { return; }
|
/// zeigt sie wie `exec`-Ausgaben an.
|
||||||
|
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
|
||||||
|
if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); }
|
||||||
|
|
||||||
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
||||||
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
||||||
@@ -119,6 +163,38 @@ impl Session {
|
|||||||
self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
|
self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
|
||||||
self.sim_accum -= FIXED_DT;
|
self.sim_accum -= FIXED_DT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Betretens-Zonen nach der Bewegung prüfen (Flanke: einmal je
|
||||||
|
// Eintritt). Erst alle Events einsammeln, dann feuern — `fire` kann
|
||||||
|
// den Modus wechseln (z.B. start_ink → Dialog), die Sim dieses Frames
|
||||||
|
// ist da schon abgeschlossen.
|
||||||
|
let center = [
|
||||||
|
self.player.pos[0],
|
||||||
|
self.player.pos[1] + player::HALF_EXTENTS[1],
|
||||||
|
self.player.pos[2],
|
||||||
|
];
|
||||||
|
let events = self.triggers.enter_events(center, player::HALF_EXTENTS);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (name, signal) in events {
|
||||||
|
out.extend(self.fire(&signal, Some(name)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Klick-Ziel unterm Fadenkreuz: nächstes `signal`-Klick-Volumen entlang
|
||||||
|
/// des Blickstrahls (`origin` + `dir·reach`), sofern keine andere
|
||||||
|
/// Weltgeometrie davor liegt. Liefert den Objektnamen für `use <name>`.
|
||||||
|
pub fn pick(&self, origin: [f32; 3], dir: [f32; 3], reach: f32) -> Option<&str> {
|
||||||
|
let end = [
|
||||||
|
origin[0] + dir[0] * reach,
|
||||||
|
origin[1] + dir[1] * reach,
|
||||||
|
origin[2] + dir[2] * reach,
|
||||||
|
];
|
||||||
|
let (name, frac) = self.triggers.pick(origin, end)?;
|
||||||
|
// Das Klick-Volumen ist selbst Teil der Kollisionswelt → bei freier
|
||||||
|
// Sicht treffen beide Traces dieselbe Fläche (Toleranz für FP/SKIN).
|
||||||
|
let world = self.collision.trace(origin, end, [0.0; 3]).map_or(1.0, |h| h.frac);
|
||||||
|
(frac <= world + 1e-3).then_some(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
|
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
|
||||||
@@ -190,10 +266,14 @@ impl Session {
|
|||||||
} else if let Some(sig) = line.strip_prefix("signal ") {
|
} else if let Some(sig) = line.strip_prefix("signal ") {
|
||||||
ExecResult::lines(self.fire(sig.trim(), None))
|
ExecResult::lines(self.fire(sig.trim(), None))
|
||||||
} else if let Some(name) = line.strip_prefix("use ") {
|
} else if let Some(name) = line.strip_prefix("use ") {
|
||||||
// Objekt-Interaktion simulieren: Signal ist der gestrippte
|
// Objekt-Interaktion (LMB-Klick und Konsole laufen beide
|
||||||
// Name, $self der volle — wie der LMB-Klick-Pfad in irl3d.
|
// hier durch): Klick-Ziele mit `signal`-Property feuern
|
||||||
|
// dieses Signal; sonst den vom Blender-Suffix befreiten
|
||||||
|
// Objektnamen. $self ist immer der volle Name.
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
let key = signals::signal_key(name).to_string();
|
let key = self.triggers.signal_for(name)
|
||||||
|
.unwrap_or_else(|| signals::signal_key(name))
|
||||||
|
.to_string();
|
||||||
ExecResult::lines(self.fire(&key, Some(name.to_string())))
|
ExecResult::lines(self.fire(&key, Some(name.to_string())))
|
||||||
} else {
|
} else {
|
||||||
ExecResult::lines(vec![
|
ExecResult::lines(vec![
|
||||||
@@ -276,8 +356,10 @@ impl Session {
|
|||||||
let mut out = Vec::with_capacity(actions.len());
|
let mut out = Vec::with_capacity(actions.len());
|
||||||
for a in actions {
|
for a in actions {
|
||||||
match a {
|
match a {
|
||||||
Action::HideObject(n) => out.push(format!("[action] hide_object {n}")),
|
Action::PlaySound(n) => {
|
||||||
Action::PlaySound(n) => out.push(format!("[action] play_sound {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()));
|
||||||
@@ -308,20 +390,6 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn-Fußpunkt (Engine-Koords) aus `info_player_start`; sonst ein Default.
|
|
||||||
fn player_spawn(map: &Map) -> [f32; 3] {
|
|
||||||
for e in &map.entities {
|
|
||||||
if e.classname() == Some("info_player_start")
|
|
||||||
&& let Some(origin) = e.props.get("origin") {
|
|
||||||
let v: Vec<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
|
||||||
if let [x, y, z] = v[..] {
|
|
||||||
return map::to_engine([x, y, z]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[0.0, 1.0, 0.0]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn help() -> Vec<String> {
|
fn help() -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
||||||
@@ -466,6 +534,92 @@ mod tests {
|
|||||||
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
|
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
|
||||||
|
/// deren Signale als Builtin-Fall-Through direkt KV-Flags setzen.
|
||||||
|
fn trigger_model() -> crate::engine::model::Model {
|
||||||
|
crate::engine::model::Model {
|
||||||
|
objects: vec![
|
||||||
|
crate::engine::model::test_cube(
|
||||||
|
"Zone.001", [-0.5, 0.0, -3.0], None, Some("set zone_hit true")),
|
||||||
|
crate::engine::model::test_cube(
|
||||||
|
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
|
||||||
|
],
|
||||||
|
empties: Vec::new(),
|
||||||
|
materials: vec![String::new()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walking_into_zone_fires_signal_once() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.load_props(&trigger_model());
|
||||||
|
s.mode = Mode::FirstPerson;
|
||||||
|
s.player.grounded = true;
|
||||||
|
|
||||||
|
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
|
||||||
|
// Flanke genau einmal (danach: drin = still).
|
||||||
|
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
|
||||||
|
assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben");
|
||||||
|
|
||||||
|
// Weiterlaufen in der Zone darf nicht erneut feuern.
|
||||||
|
s.game.kv.remove("zone_hit");
|
||||||
|
for _ in 0..5 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
|
||||||
|
assert!(!s.game.kv.contains_key("zone_hit"), "Flanke: nur einmal je Eintritt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn use_resolves_signal_property_before_name() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.load_props(&trigger_model());
|
||||||
|
s.mode = Mode::FirstPerson;
|
||||||
|
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
|
||||||
|
s.exec("use Kiste");
|
||||||
|
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
|
||||||
|
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
|
||||||
|
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
|
||||||
|
let r = s.exec("use Irgendwas.003");
|
||||||
|
assert!(!r.quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_hits_clickable_within_reach() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.load_props(&trigger_model());
|
||||||
|
// Kiste bei [5..6, 0..1, 5..6]: Strahl von schräg oben davor.
|
||||||
|
let hit = s.pick([5.5, 0.5, 3.0], [0.0, 0.0, 1.0], INTERACT_REACH);
|
||||||
|
assert_eq!(hit, Some("Kiste"));
|
||||||
|
// Außer Reichweite → nichts.
|
||||||
|
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn play_sound_action_queues_for_frontend() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
// Fall-through-Builtin über den Signal-Trichter, wie ein Ink-Tag.
|
||||||
|
s.exec("signal play_sound klick");
|
||||||
|
assert_eq!(s.take_sounds(), vec!["klick".to_string()]);
|
||||||
|
assert!(s.take_sounds().is_empty(), "take leert die Queue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_empty_places_player() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
let mut props = crate::engine::model::Props::new();
|
||||||
|
props.insert("role".into(), "spawn".into());
|
||||||
|
s.load_props(&crate::engine::model::Model {
|
||||||
|
objects: Vec::new(),
|
||||||
|
empties: vec![
|
||||||
|
crate::engine::model::Empty { name: "start".into(), pos: [3.0, 0.5, -2.0], props },
|
||||||
|
crate::engine::model::Empty {
|
||||||
|
name: "deko".into(), pos: [9.0, 9.0, 9.0],
|
||||||
|
props: crate::engine::model::Props::new(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
materials: Vec::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_command_reports_and_stays_free() {
|
fn unknown_command_reports_and_stays_free() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
|
|||||||
Reference in New Issue
Block a user