Compare commits

...

8 Commits

Author SHA1 Message Date
irrlicht 075c49200e nails refferenzbilder 2026-07-16 20:37:43 +02:00
irrlicht 01f299aa4f Hausputz: UV-Doppel-Flip raus, tga::load → Result, hide_object entfernt
UVs laufen jetzt durchgehend in glTF-/tga-Konvention (der Flip-Hin-und-
Zurück stammte vom gelöschten OBJ-Pfad). Kaputte Welt-Texturen fallen auf
den Platzhalter statt zu panicken. Player::eye() statt vierfacher
Augenpunkt-Rechnung. hide_object war ein irl3d-Demo-Relikt ohne
Konsument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00
irrlicht 31e1942870 Audio: WAV + tinyaudio, Ambient-Emitter mit Distance ramps; artist.md
engine/wav.rs (Decoder) und render/audio.rs (Mixer, 8 SFX-Voices +
Loop-Voice je Emitter) aus irl3d eingebettet; Emitter-Logik headless in
engine/audio.rs. Empties mit sound/radius-Property senden positional,
play_sound-Signale laufen als SFX über Session::take_sounds.
Platzhalter-Sounds + Emitter in der Testszene; artist.md fasst die
Content-Regeln für die Autorin zusammen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00
irrlicht 56d92cdb46 Map-Loader entfernt: glTF ist das einzige 3D-Format
engine/map.rs + render/brush.rs raus, Welt kommt komplett aus .glb.
Spawn via Empty mit role=spawn statt info_player_start; testscene.glb
(Boden, Wände, Spawn) ersetzt test.map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00
irrlicht 186ee9a817 640×480 (VGA) intern, UI auf EGA 8×14
Text-getrieben → 80-Spalten-Textmodus-Look; RGB555+Dither bleiben.
Menü-/Dialog-Metriken an die Fonthöhe angepasst, CGA bleibt fürs HUD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00
irrlicht 9db3929750 Collider via collide-Property + Quickhull, OBJ-Pfad entfernt
col_*-Namenspräfix raus: Rollen steuern nur noch Custom Properties
(collide=1 sichtbar+Collider, "proxy" unsichtbar, signal wie gehabt).
Collider sind die konvexe Hülle der Vertices — konkave Meshes erlaubt.
glb ist der einzige Modell-Pfad; suzanne.obj → suzanne.glb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00
irrlicht f7bfdb4a0b Blender-Import-Pipeline: glTF/OBJ, col_*-Proxies, Trigger
Blender wird das Haupt-3D-Tool: zwei Loader (engine/gltf.rs für .glb als
Hauptpfad, engine/obj.rs aus irl3d eingebettet) liefern ein neutrales
engine/model.rs::Model. Loader sind reine Dekoder; das Frontend zieht sie
und schiebt das Ergebnis per Session::load_props in den State, der
Renderer baut sein Mesh (render/props.rs, verschmolzen mit der
Brush-Welt über eine geteilte Texturliste).

glTF trägt Blender-Custom-Properties (node.extras → Object::props) und
Empties (Nodes ohne Mesh) als Entity-Kanal. Namenskonvention: `col_*` =
unsichtbarer Collision-Proxy, aus dem collision::add_model konvexe
Ebenen-Sets baut (konvex-only, daher kein Default-Collider auf
Sichtgeometrie).

Trigger (engine/trigger.rs) hängen an der Custom Property `signal`:
mit Collider = Point-and-Click-Ziel (Session::pick raycastet aus der
Bildmitte, Treffer läuft durch den `use <name>`-Trichter), ohne Collider
= unsichtbare Betretens-Zone, die einmal je Eintritt feuert. Gefeuert
wird nur über Session::exec — der Eingabe-Trichter bleibt der einzige
Weg in den State.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00
irrlicht 67c4a366c7 Ink: KV-Auto-Import und Crash-Härtung
sync_to_kv übernimmt jetzt alle im Skript deklarierten VARs in den
KV-Store (Auto-Import per Quelltext-Scan, da bladeink keine Enumeration
bietet); `_`-Prefix bleibt skript-lokal. So wandern Werte wie
snaks/favor_nails automatisch in per start_ink verkettete Folgeskripte.

cont()/choose() melden Ink-Runtime-Fehler nach stderr und beenden den
Dialog, statt zu panicken — ein auslaufender Skript-Pfad darf das
laufende Spiel nicht crashen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00
39 changed files with 2402 additions and 3010 deletions
Generated
+202 -3
View File
@@ -31,6 +31,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -122,6 +131,24 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.13.0",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.3",
"shlex 1.3.0",
"syn",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.9.1" version = "0.9.1"
@@ -249,7 +276,16 @@ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
"libc", "libc",
"shlex", "shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
] ]
[[package]] [[package]]
@@ -275,6 +311,17 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.13.1" version = "0.13.1"
@@ -345,6 +392,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
dependencies = [
"bindgen",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.0" version = "0.3.0"
@@ -418,6 +474,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -552,6 +614,12 @@ dependencies = [
"xml-rs", "xml-rs",
] ]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.17.0" version = "0.17.0"
@@ -681,6 +749,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -811,6 +888,19 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libpulse-sys"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61"
dependencies = [
"libc",
"num-derive",
"num-traits",
"pkg-config",
"winapi",
]
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.17" version = "0.1.17"
@@ -871,6 +961,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "naga" name = "naga"
version = "29.0.3" version = "29.0.3"
@@ -891,7 +987,7 @@ dependencies = [
"log", "log",
"num-traits", "num-traits",
"once_cell", "once_cell",
"rustc-hash", "rustc-hash 1.1.0",
"spirv", "spirv",
"thiserror 2.0.18", "thiserror 2.0.18",
"unicode-ident", "unicode-ident",
@@ -927,6 +1023,27 @@ dependencies = [
"jni-sys 0.3.1", "jni-sys 0.3.1",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1492,6 +1609,35 @@ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
] ]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@@ -1504,6 +1650,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1628,6 +1780,12 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -1832,6 +1990,23 @@ dependencies = [
"strict-num", "strict-num",
] ]
[[package]]
name = "tinyaudio"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2bb6a423a786a941cbc5a6544693727d832ec2c088d7da097a9d3bcf0431b5"
dependencies = [
"core-foundation-sys",
"coreaudio-sys",
"js-sys",
"libpulse-sys",
"ndk",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -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"
+2
View File
@@ -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"
+74
View File
@@ -0,0 +1,74 @@
Alles Inhaltliche kommt aus vier Quellen: **Blender** (Welt), **Ink**
(Dialoge/Story), **signals.toml** (Verdrahtung), **Assets-Ordner**
(Texturen/Sounds). Fehler in Inhalten stürzen nie ab — sie werden beim
Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
## 3D / Blender
- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien
dort werden beim Start geladen.
- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen
keine der Properties unten an!).
- **Maßstab:** 1 Blender-Meter = 1 Engine-Meter. Augenhöhe 1,6 m,
Türen/Durchgänge ≥ 2 m hoch und ≥ 0,8 m breit denken.
- **Material-Name = Textur-Name:** Material `holz``assets/textures/holz.tga`.
Fehlt die Textur (oder das Material), erscheint die Platzhalter-Textur.
- **Texturen:** TGA, unkomprimiert oder RLE, 24/32 bit.
### Custom Properties auf Mesh-Objekten
| Property | Wirkung |
|---|---|
| *(keine)* | nur sichtbar, kein Anfassen |
| `collide = 1` | sichtbar **und** solide (Spieler kollidiert mit der konvexen Hülle des Meshes — Dellen/Löcher werden überspannt) |
| `collide = proxy` | unsichtbares Kollisionsvolumen (für Fein-Collider neben Sichtgeometrie) |
| `signal = <name>` **mit** collide | Klick-Ziel: Anklicken feuert das Signal `<name>` |
| `signal = <name>` **ohne** collide | unsichtbare Trigger-Zone: Betreten feuert `<name>` einmal (erneut erst nach Verlassen; „nur einmal überhaupt" selbst per KV-Flag bauen) |
- `collide = 0` schaltet ab, ohne die Property zu löschen.
- Objektname ist der Interact-Key: Blender-Suffixe (`Ding.001`) werden beim
Signal-Lookup automatisch gestrippt — `[Ding]` in signals.toml trifft alle.
### Custom Properties auf Empties
| Property | Wirkung |
|---|---|
| `role = spawn` | Spieler-Startpunkt (Fußpunkt = Empty-Position) |
| `sound = <name>` | Ambient-Loop `assets/sounds/<name>.wav` an dieser Position |
| `radius = <m>` | Hörradius des Sounds (linear leiser bis Stille; Default 8) |
## Audio
- **Format:** WAV, **mono, 16 bit, 44100 Hz** — nichts anderes. Ablage in
`assets/sounds/`, angesprochen ohne Endung (`pickup``pickup.wav`).
- **Ambient:** per Empty (siehe oben), loopt immer — Loop-Punkt sauber schneiden.
- **Einmal-SFX:** per Action `play_sound <name>` (aus signals.toml oder Ink-Tag).
## Dialoge / Ink
- `.ink` nach `assets/interactions/` — wird beim Build automatisch
kompiliert (bei Bedarf von Hand: `./tools/build-dialogues.sh`).
- Gestartet per Action `start_ink <datei>.ink.json`.
- **Variablen:** alle Ink-`VAR` landen automatisch im Spiel-KV und synchen
in beide Richtungen — `_`-Prefix (`VAR _tmp = 0`) bleibt Ink-lokal.
- **Tags feuern Actions:** `# set has_key true`, `# play_sound klick`,
`# mode play` … (gleiche Verben wie signals.toml). Unbekannte Tags sind
erlaubt (reine Metadaten).
## signals.toml (`assets/signals.toml`)
- Verdrahtet Signal → Aktionen. Signal-Namen: Objektname (Klick), Wert der
`signal`-Property (Zonen/Klick-Ziele), oder frei (aus Ink).
- Verben: `start_ink`, `set`, `inc`, `clear`, `debug_log`, `play_sound`,
`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.
-2
View File
@@ -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.
-48
View File
@@ -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"
}
-84
View File
@@ -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
View File
@@ -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

+125
View File
@@ -0,0 +1,125 @@
//! Ambient-Emitter aus Blender-Modellen: Empties mit `sound`-Custom-Property
//! sind positionale Dauerschleifen — `sound = <name>` löst zu
//! `assets/sounds/{name}.wav` auf (macht der Frontend-Treiber), `radius`
//! (Meter, optional) bestimmt die Distance ramp.
//!
//! Headless wie `trigger`: hier lebt nur die *Logik* — wer sendet wo, und
//! wie laut ist das gerade für den Hörer ([`Emitters::gains`], lineare
//! Rampe [`distance_gain`]). Gerätezugriff und Mixing sind Sache des
//! Frontend-Treibers (render::audio), der die Gains jeden Frame zieht und
//! auf seine Voices legt — die Engine ruft nie ins Frontend.
//!
//! Einmal-SFX laufen nicht hier, sondern über den Signal-Dispatcher
//! (`play_sound <name>` → `Action::PlaySound` → `Session::take_sounds`).
use crate::engine::model::Model;
/// Custom-Property-Schlüssel, der ein Empty zum Ambient-Emitter macht
/// (Wert = Sound-Name). Emitter loopen immer; „an/aus" macht die Autorin
/// über Distanz/Radius oder (später) per Signal.
pub const SOUND_PROP: &str = "sound";
/// Optionaler Hörradius in Metern (Custom Property am selben Empty).
pub const RADIUS_PROP: &str = "radius";
const DEFAULT_RADIUS: f32 = 8.0;
pub struct Emitter {
pub sound: String,
pos: [f32; 3],
radius: f32,
}
#[derive(Default)]
pub struct Emitters {
list: Vec<Emitter>,
}
impl Emitters {
pub fn new() -> Self { Self::default() }
/// Emitter eines Modells übernehmen: alle Empties mit `sound`-Property.
pub fn add_model(&mut self, model: &Model) {
for e in &model.empties {
let Some(sound) = e.props.get(SOUND_PROP) else { continue; };
let radius = e.props.get(RADIUS_PROP)
.and_then(|r| r.parse::<f32>().ok())
.filter(|r| *r > 0.0)
.unwrap_or(DEFAULT_RADIUS);
self.list.push(Emitter { sound: sound.clone(), pos: e.pos, radius });
}
}
/// Die Emitter in fester Reihenfolge (Voice-Zuordnung des Treibers).
pub fn list(&self) -> &[Emitter] { &self.list }
/// Aktueller Gain je Emitter (parallel zu [`Emitters::list`]) für einen
/// Hörer bei `listener` — jeden Frame ziehen, auf die Voices legen.
pub fn gains(&self, listener: [f32; 3]) -> Vec<f32> {
self.list.iter().map(|e| {
let d = [
e.pos[0] - listener[0],
e.pos[1] - listener[1],
e.pos[2] - listener[2],
];
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
distance_gain(dist, e.radius)
}).collect()
}
}
/// Lineare Distance ramp: volle Lautstärke am Emitter, Stille ab `radius`.
/// Bewusst linear statt 1/d² — bei Zimmer-Skalen klingt die gerade Rampe
/// kontrollierbarer, und die Autorin kann per `radius` direkt denken.
pub fn distance_gain(dist: f32, radius: f32) -> f32 {
(1.0 - dist / radius).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::model::{Empty, Props};
fn model(empties: Vec<Empty>) -> Model {
Model { objects: Vec::new(), empties, materials: Vec::new() }
}
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
let mut p = Props::new();
for (k, v) in props { p.insert((*k).into(), (*v).into()); }
Empty { name: name.into(), pos, props: p }
}
#[test]
fn collects_sound_empties_with_radius() {
let mut em = Emitters::new();
em.add_model(&model(vec![
empty("Radio", [5.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
empty("spawn", [0.0, 0.0, 0.0], &[("role", "spawn")]),
empty("Bach", [9.0, 0.0, 0.0], &[("sound", "wasser")]),
]));
assert_eq!(em.list().len(), 2, "nur Empties mit sound-Property");
assert_eq!(em.list()[0].sound, "hum");
assert_eq!(em.list()[1].radius, DEFAULT_RADIUS);
}
#[test]
fn gains_ramp_linearly_to_radius() {
let mut em = Emitters::new();
em.add_model(&model(vec![
empty("Radio", [0.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
]));
assert_eq!(em.gains([0.0, 0.0, 0.0]), vec![1.0]);
assert_eq!(em.gains([2.0, 0.0, 0.0]), vec![0.5]);
assert_eq!(em.gains([4.0, 0.0, 0.0]), vec![0.0]);
assert_eq!(em.gains([9.0, 0.0, 0.0]), vec![0.0], "hinter dem Radius still");
}
#[test]
fn broken_radius_falls_back_to_default() {
let mut em = Emitters::new();
em.add_model(&model(vec![
empty("A", [0.0; 3], &[("sound", "x"), ("radius", "kaputt")]),
empty("B", [0.0; 3], &[("sound", "y"), ("radius", "-2")]),
]));
assert!(em.list().iter().all(|e| e.radius == DEFAULT_RADIUS));
}
}
+287 -66
View File
@@ -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(ba, ca) 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(ca, ba)` 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
View File
@@ -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
+422
View File
@@ -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
View File
@@ -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(_)));
}
}
-190
View File
@@ -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
View File
@@ -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;
+156
View File
@@ -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
View File
@@ -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.
+6 -9
View File
@@ -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
View File
@@ -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> {
+155
View File
@@ -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);
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
//!
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten),
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
//! File wird gemeldet und bleibt still, nie fatal.
//!
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
//! mono 16-bit 44,1 kHz exportieren" ist die ganze Anleitung; alles andere
//! ist eine klare Fehlermeldung mit Pfad. Über unbekannte RIFF-Chunks
//! (`LIST`, `INFO`, `bext`, …) wird hinweggesprungen.
/// Sample-Rate des gesamten Audio-Pfads (Decoder-Kontrakt und Mixer-Takt).
pub const SAMPLE_RATE: u32 = 44100;
pub struct Wav {
pub samples: Vec<i16>,
}
pub fn load(path: &str) -> Result<Wav, String> {
let bytes = std::fs::read(path).map_err(|e| format!("wav load {path}: {e}"))?;
parse(&bytes).map_err(|e| format!("wav {path}: {e}"))
}
pub fn parse(data: &[u8]) -> Result<Wav, String> {
if data.len() < 12 || &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" {
return Err("kein RIFF/WAVE".into());
}
let mut fmt_found = false;
let mut samples: Option<Vec<i16>> = None;
let mut i = 12;
while i + 8 <= data.len() {
let id = &data[i..i + 4];
let size = u32::from_le_bytes(data[i + 4..i + 8].try_into().unwrap()) as usize;
let body = i + 8;
let end = body + size;
if end > data.len() { return Err("Chunk länger als Datei".into()); }
match id {
b"fmt " => {
if size < 16 { return Err("fmt-Chunk zu kurz".into()); }
let format = u16::from_le_bytes(data[body..body + 2].try_into().unwrap());
let channels = u16::from_le_bytes(data[body + 2..body + 4].try_into().unwrap());
let rate = u32::from_le_bytes(data[body + 4..body + 8].try_into().unwrap());
let bits = u16::from_le_bytes(data[body + 14..body + 16].try_into().unwrap());
if format != 1 || channels != 1 || bits != 16 || rate != SAMPLE_RATE {
return Err(format!(
"brauche PCM mono 16-bit {SAMPLE_RATE} Hz, ist format={format} \
channels={channels} bits={bits} rate={rate}"));
}
fmt_found = true;
}
b"data" => {
if !fmt_found { return Err("data-Chunk vor fmt".into()); }
samples = Some(data[body..end].chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect());
}
_ => {} // LIST, INFO, bext, … überspringen
}
// Chunks sind word-aligned (Padding-Byte bei ungerader Größe).
i = end + (size & 1);
}
match samples {
Some(samples) if !samples.is_empty() => Ok(Wav { samples }),
Some(_) => Err("data-Chunk leer".into()),
None => Err("kein data-Chunk".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimale WAV-Datei bauen, wie ein Exporter sie schriebe.
fn wav_bytes(format: u16, channels: u16, rate: u32, bits: u16, samples: &[i16]) -> Vec<u8> {
let data_len = samples.len() * 2;
let mut out = Vec::new();
out.extend(b"RIFF");
out.extend(((4 + 8 + 16 + 8 + data_len) as u32).to_le_bytes());
out.extend(b"WAVE");
out.extend(b"fmt ");
out.extend(16u32.to_le_bytes());
out.extend(format.to_le_bytes());
out.extend(channels.to_le_bytes());
out.extend(rate.to_le_bytes());
out.extend((rate * 2).to_le_bytes()); // byte rate
out.extend(2u16.to_le_bytes()); // block align
out.extend(bits.to_le_bytes());
out.extend(b"data");
out.extend((data_len as u32).to_le_bytes());
for s in samples { out.extend(s.to_le_bytes()); }
out
}
#[test]
fn parses_pcm_mono_16bit() {
let w = parse(&wav_bytes(1, 1, SAMPLE_RATE, 16, &[0, 1000, -1000])).unwrap();
assert_eq!(w.samples, vec![0, 1000, -1000]);
}
#[test]
fn skips_unknown_chunks() {
// LIST-Chunk zwischen fmt und data.
let base = wav_bytes(1, 1, SAMPLE_RATE, 16, &[7]);
let mut out = base[..36].to_vec(); // bis inkl. fmt
out.extend(b"LIST");
out.extend(4u32.to_le_bytes());
out.extend(b"INFO");
out.extend(&base[36..]); // data-Chunk
let len = (out.len() - 8) as u32;
out[4..8].copy_from_slice(&len.to_le_bytes());
assert_eq!(parse(&out).unwrap().samples, vec![7]);
}
#[test]
fn rejects_wrong_formats() {
assert!(parse(b"OggS...nope").is_err());
assert!(parse(&wav_bytes(1, 2, SAMPLE_RATE, 16, &[0])).is_err(), "stereo");
assert!(parse(&wav_bytes(1, 1, 22050, 16, &[0])).is_err(), "falsche Rate");
assert!(parse(&wav_bytes(1, 1, SAMPLE_RATE, 8, &[0])).is_err(), "8 bit");
assert!(parse(&wav_bytes(3, 1, SAMPLE_RATE, 16, &[0])).is_err(), "float-PCM");
}
#[test]
fn load_missing_file_is_err() {
assert!(load("/nonexistent/x.wav").is_err());
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Audio-Treiber auf tinyaudio — Frontend-Gegenstück zur headless
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
//! dieses Modul nie).
//!
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
//!
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
//! wird die am weitesten gespielte verdrängt (`play`).
//! - Ambient-Voices: eine je Emitter, loopen immer; ihr Gain ist die
//! Distance ramp, die das Frontend jeden Frame aus
//! `Session::emitter_gains` zieht ([`Audio::set_ambient_gains`]).
//!
//! Gemischt wird im Callback-Thread in f32 über alle aktiven Voices
//! (Sample × Gain), am Ende auf [-1, 1] geklemmt. Der Mixer hängt hinter
//! einem Mutex; Frontend-Seite fasst ihn nur kurz pro Frame/SFX an.
use std::sync::{Arc, Mutex};
use tinyaudio::{run_output_device, OutputDevice, OutputDeviceParameters};
use crate::engine::wav;
const SFX_VOICES: usize = 8;
/// Callback-Puffergröße in Samples (~6 ms bei 44,1 kHz).
const BUFFER_LEN: usize = 256;
/// Dekodierte Mono-Samples, geteilt zwischen Cache und laufenden Voices.
#[derive(Clone)]
pub struct Sound {
samples: Arc<Vec<i16>>,
}
impl Sound {
pub fn new(w: wav::Wav) -> Self {
Sound { samples: Arc::new(w.samples) }
}
}
struct Voice {
samples: Arc<Vec<i16>>,
cursor: usize,
gain: f32,
}
#[derive(Default)]
struct Mixer {
sfx: Vec<Option<Voice>>,
/// Loop-Voices der Ambient-Emitter, Index = Emitter-Reihenfolge.
ambient: Vec<Voice>,
}
impl Mixer {
fn mix(&mut self, out: &mut [f32]) {
for s in out.iter_mut() {
let mut acc = 0.0f32;
for slot in self.sfx.iter_mut() {
if let Some(v) = slot {
acc += v.samples[v.cursor] as f32 * v.gain;
v.cursor += 1;
if v.cursor >= v.samples.len() { *slot = None; }
}
}
for v in self.ambient.iter_mut() {
// Auch bei Gain 0 weiterlaufen lassen: ein Emitter, in dessen
// Radius man zurückkehrt, spielt mitten in seiner Schleife —
// nicht jedes Mal von vorn.
acc += v.samples[v.cursor] as f32 * v.gain;
v.cursor = (v.cursor + 1) % v.samples.len();
}
*s = (acc / 32768.0).clamp(-1.0, 1.0);
}
}
}
pub struct Audio {
mixer: Arc<Mutex<Mixer>>,
_device: OutputDevice, // lebt, solange Audio lebt
}
impl Audio {
/// Ausgabegerät öffnen. `None`, wenn keins da ist (Meldung, Spiel läuft
/// stumm weiter).
pub fn open() -> Option<Self> {
// pipewire-pulse/PulseAudio respektiert diese Variable als
// Ziel-Latenz des Streams (Default ~50200 ms) — herabsetzen für
// snappige SFX. Muss vor `run_output_device` gesetzt sein; auf
// Windows/macOS ignoriert.
// SAFETY: einmalig beim Start, bevor weitere Threads laufen (wird
// in render::run vor dem stdin-Thread gerufen).
unsafe { std::env::set_var("PULSE_LATENCY_MSEC", "15"); }
let mixer = Arc::new(Mutex::new(Mixer {
sfx: (0..SFX_VOICES).map(|_| None).collect(),
ambient: Vec::new(),
}));
let cb_mixer = Arc::clone(&mixer);
let device = run_output_device(
OutputDeviceParameters {
channels_count: 1,
sample_rate: wav::SAMPLE_RATE as usize,
channel_sample_count: BUFFER_LEN,
},
move |buf: &mut [f32]| { cb_mixer.lock().unwrap().mix(buf); },
);
match device {
Ok(device) => Some(Audio { mixer, _device: device }),
Err(e) => {
eprintln!("[audio] kein Ausgabegerät ({e}) — Spiel läuft stumm");
None
}
}
}
/// Einmal-SFX starten: freier Slot, sonst wird die am weitesten
/// gespielte Voice verdrängt.
pub fn play(&self, s: &Sound) {
if s.samples.is_empty() { return; }
let mut m = self.mixer.lock().unwrap();
let target = m.sfx.iter().enumerate()
.max_by_key(|(_, slot)| slot.as_ref().map_or(usize::MAX, |v| v.cursor))
.map_or(0, |(i, _)| i);
m.sfx[target] = Some(Voice {
samples: Arc::clone(&s.samples), cursor: 0, gain: 1.0,
});
}
/// Die Ambient-Loop-Voices anlegen — einmal beim Start, Reihenfolge =
/// `Session::emitters` (die Gains kommen dann parallel dazu, deshalb
/// bekommt auch ein nicht ladbarer Sound seine — stille — Voice).
pub fn set_ambients(&self, sounds: &[Option<Sound>]) {
let mut m = self.mixer.lock().unwrap();
m.ambient = sounds.iter().map(|s| Voice {
samples: s.as_ref().map_or_else(|| Arc::new(vec![0]), |s| Arc::clone(&s.samples)),
cursor: 0,
gain: 0.0,
}).collect();
}
/// Distance-ramp-Gains auf die Ambient-Voices legen — jeden Frame, aus
/// `Session::emitter_gains`.
pub fn set_ambient_gains(&self, gains: &[f32]) {
let mut m = self.mixer.lock().unwrap();
for (v, g) in m.ambient.iter_mut().zip(gains) {
v.gain = *g;
}
}
}
+1 -1
View File
@@ -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.
-411
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(); }
+126
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();