Compare commits

...
21 Commits
Author SHA1 Message Date
irrlicht 04679fa897 Carpet texture 2026-09-09 13:52:40 +02:00
irrlichtandClaude Sonnet 5 331caf417a Blender-Welten aus der .blend im Map-Ordner bauen
Analog zur Ink-Pipeline: eine .blend neben ihrer .glb im Map-Ordner
(assets/maps/<map>/<name>.blend) wird von tools/build-models.sh headless
zu <name>.glb exportiert, inkrementell per mtime. build.rs stößt das beim
cargo-Build an, sobald sich eine .blend geändert hat.

- Export: blender -b --factory-startup ... export_scene.gltf mit
  export_extras (collide/signal/role), export_apply, yup, image AUTO.
- Blender über $BLENDER oder PATH; fehlt es, wird der Schritt still
  übersprungen — die committeten .glb reichen zum Bauen/Spielen.
- Ein Export-Fehler bei vorhandenem Blender ist fatal (wie bei Ink).
- build.rs teilt sich jetzt run_script/watch-Helfer für beide Pipelines.
- Cinemaroom.blend -> assets/maps/cinema/cinema.blend verschoben und die
  .glb einmal daraus gebaut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:53:29 +02:00
irrlichtandClaude Sonnet 5 f51f4e83fd Tote Welt-TGAs und notes/artist.md-Duplikat entfernt
Die Welt-Texturen kommen jetzt aus der .glb; die alten .tga waren nur
noch Quellmaterial für Blender und liegen dort in den .blend. notes/
artist.md war ein veraltetes Duplikat von artist.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:40:25 +02:00
irrlicht 737a297436 Texture tests 2026-09-02 00:35:29 +02:00
irrlichtandClaude Sonnet 5 b72b90c50d TGA-Loader raus, alles PNG
Der einzige verbliebene TGA-Nutzen — UI, Fonts, Platzhalter — läuft jetzt
über engine::image::load (derselbe PNG-Decoder wie für die .glb).
engine::tga ist damit weg.

- image::load(path): PNG von der Platte, Format per Signatur.
- Fonts/Cursor/Ornamente/Platzhalter als .png (Fonts + UI neu aus dem
  Quellmaterial exportiert, placeholder aus der alten .tga konvertiert).
- Test loads_the_real_project_assets deckt die fünf Projekt-Assets ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:33:04 +02:00
irrlichtandClaude Sonnet 5 fc3494466e Welt-Texturen aus der .glb statt aus assets/textures/
Der Loader zieht jetzt die baseColorTexture jedes Materials aus dem
BIN-Chunk und dekodiert sie (PNG, via png-Crate) → model::Texture mit
Pixeln. Die Namenskonvention "Materialname = Dateiname" und der
assets/textures/-Pfad für Welt-Texturen entfallen; assets/textures/
bleibt nur noch für UI/Fonts/Platzhalter.

- engine::image: neuer, geteilter RGBA8-Image-Typ (aus tga.rs gezogen)
  plus PNG-Decoder. tga.rs liefert weiter denselben Typ.
- gltf: material -> texture -> image -> bufferView auflösen, Bild aus
  BIN dekodieren; fehlt ein Glied, bleibt image None (Platzhalter).
  KHR_texture_transform (Blenders Mapping-Node) wird beim Laden in die
  UVs eingebacken.
- model::Model: materials: Vec<String> -> textures: Vec<Texture>.
- render: die Pixel je Schlüssel aus den Modellen sammeln (erstes
  Vorkommen gewinnt, Schlüssel ohne Bild -> Platzhalter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:29:46 +02:00
irrlichtandClaude Sonnet 5 937fbd6ede maps-Verzeichnis per --maps <dir> statt hartkodiert
Das Fenster-Frontend lud die Welt-.glb aus dem festen String
"assets/maps/props". Jetzt ist das nur noch der Default
(render::DEFAULT_MAPS_DIR); `main` parst `--maps <dir>` und reicht das
Verzeichnis an render::run weiter. Der übergebene Pfad wird wie angegeben
benutzt (relativ zum CWD), der Default wie bisher relativ zum
Asset-Basisverzeichnis.

Damit lässt sich z.B. `assets/maps/inactive` ohne Neubau laden. Die CLI
lädt weiterhin keine Modelle und ignoriert das Flag.

README + artist.md ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 23:24:17 +02:00
irrlicht 307b52ae1f Removed old demo scene 2026-09-01 23:08:06 +02:00
irrlichtandClaude Sonnet 5 6e84fe9b1b model::Model: Doku als Loader/Konsumenten-Vertrag, nicht Format-Abstraktion
Nach dem Rauswurf der OBJ- und Map-Loader-Pfade (glTF ist der einzige
3D-Pfad) verkaufte die model.rs-Doku eine Format-Neutralität, die es nicht
mehr gibt. Der Typ bleibt — er ist die legitime Entkopplungsgrenze
zwischen dem einen Produzenten (gltf) und seinen Konsumenten (collision,
trigger, audio, render::props, session::load_props), die alle auf einer
geparsten Struktur arbeiten statt selbst glTF zu lesen. Nur die
Formulierung wird ehrlich.

Reine Kommentaränderung in model.rs, gltf.rs, engine/mod.rs, render/props.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 23:04:31 +02:00
irrlichtandClaude Sonnet 5 236a643d04 kv::Store von bladeink entkoppeln
Der Kern-KV war HashMap<String, bladeink::ValueType> — jedes Modul, das
eine Spielvariable las, hing damit an der öffentlichen API der Story-Crate.
"Konvertierung gratis" war der Grund, aber der Preis war eine
Dependency-Kante quer durch engine/.

Neuer eigener kv::Value (Bool/Int/Float/Str). Die Umrechnung auf Inks
ValueType passiert jetzt allein in ink.rs (to_ink/from_ink) — die einzige
Stelle im Kern, die bladeink überhaupt kennt. from_ink verwirft
Ink-eigene Typen (Listen, Divert-Referenzen), die im KV nichts verloren
haben.

- format_value → Display für Value
- coerce_to_int → Value::as_int (gleiche Semantik); coerce_to_bool hatte
  außer Tests keinen Aufrufer und entfällt, die Tests prüfen jetzt die
  gespeicherte Variante direkt
- bladeink bleibt Dependency (ink.rs braucht es), nur nicht mehr im
  KV-Vertrag

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 22:54:52 +02:00
irrlichtandClaude Sonnet 5 247f7ef9fe Post-Effekte über Action::SetPost statt Engine-Zustand
Die PostChain lag als engine::post im Kern, nur damit die Konsole
`post` durch den exec-Trichter schalten konnte — Zustand, der allein
das Render-Frontend angeht und nichts mit der Simulation zu tun hat.

Jetzt kennt der Kern Post-Effekte nur als deferred Action (wie
play_sound): das `post <name> on|off`-Verb queued Action::SetPost,
die Session reicht sie als take_post()-Queue durch, das Fenster wendet
sie auf seine eigene PostChain an. Damit schalten Trigger und Ink-Tags
Effekte, die vorher nur der Konsolenbefehl erreichte.

- engine/post.rs entfällt; PostChain wandert nach render::post
- Session verliert das post-Feld, bekommt pending_post + take_post
- der `post`-Konsolenbefehl ist ein Shim wie `mode`; Ketten-Listing
  und nackter Toggle fallen weg (letzterer bewusst, vgl. 8bee832)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 22:47:32 +02:00
irrlichtandClaude Opus 5 b3d08d4a8c Testscene aktiv, Cinema Room stillgelegt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:30:57 +02:00
irrlichtandClaude Opus 5 8ddbd6f514 Outline-Pass: Kanten aus dem G-Buffer
Erster Effekt, der das Bild verändert. Gesucht werden Kanten der
Geometrie, nicht des Bildes — auf dem fertigen Bild zu suchen wäre
falsch, weil der Dither Flächen in Muster zerlegt und jede Texturkante
als Kante zählte. Drei unabhängige Signale, genommen wird das stärkste:
Tiefe (Silhouetten) und Normale (Knicke) über einen 3x3-Sobel, die
Objekt-ID über einen Vergleich mit den direkten Nachbarn. Läuft vor
dither, damit die Linie mitquantisiert wird wie alles andere.

Zwei Vorarbeiten, die nur für diesen Pass existieren und deshalb hier
mit drinstehen:

Der Post-Vertrag bekommt group 2 mit Uniforms (inv_res, near, far,
time). Das räumt near/far aus normals.wgsl heraus, wo ich sie als
Literale abgeschrieben hatte — der Debug-View hätte stillschweigend
gelogen, sobald jemand die Projektion ändert, ausgerechnet das
Werkzeug, dem man beim Debuggen glauben muss. Sie kommen jetzt aus
NEAR_FAR in gpu.rs, derselben Konstante wie die Projektion. time ist
Vorrat für animierte Effekte; jetzt mitgenommen, weil eine spätere
Vertragsänderung alle Bind-Group-Layouts anfasst.

Die Objektnummer füllt das reservierte Alpha des Normal-Buffers. Sie
läuft fortlaufend statt als Namens-Hash: benachbarte Nummern
unterscheiden sich damit garantiert, ein Hash hätte mit ~1/256 still
kollidiert. Mesh::append verschiebt die Nummern des Anhängsels, sonst
trügen die ersten Objekte zweier .glb dieselbe und die Kante zwischen
ihnen fehlte. Ab 1, damit die 0 "kein Objekt" heißt — der Outline-Pass
zeichnet auf dem Hintergrund nichts, sonst läge jede Silhouette
doppelt.

Nicht getrennt committet, weil Uniform und ID für sich nichts leisten:
vor diesem Pass liest sie niemand außer dem Debug-View.

Strichstärke geht über den Abtastabstand des Kernels (WIDTH, aktuell 2)
statt über nachträgliches Aufdicken — das kostet keinen zusätzlichen
Texturzugriff, wo Dilatation das Neunfache gebraucht hätte. Die
Gradienten werden durch den Abstand geteilt, damit die Schwellwerte
beim Breitenwechsel ihre Bedeutung behalten.

DEPTH_T und NORMAL_T sind aus der Kernel-Normierung hergeleitet, nicht
an der Szene erprobt; Fehlerbilder und Richtung stehen als Kommentar
daneben. normals.wgsl zeigt jetzt drei Bänder (Normale, ID, Tiefe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00
irrlichtandClaude Opus 5 b9258a68fd G-Buffer: Normalen aus glTF bis in den Post-Vertrag
Cel-Ramp und ein Sobel, der auch Innenkanten findet, brauchen beide
Normalen — die gab es in der Pipeline bisher nirgends. Der Strang geht
quer durch alle Schichten, deshalb als eigener Schritt, bevor ein
Effekt darauf zugreift.

Loader: NORMAL wird gelesen und mit der Kofaktormatrix des 3x3-Teils
transformiert, nicht mit der Welt-Matrix. Das ist die inverse
Transponierte bis auf den Faktor det, den das Normieren frisst — nur so
bleibt eine Normale bei ungleichmäßiger Skalierung senkrecht auf ihrer
Fläche, und bei Spiegelungen dreht sie korrekt mit. glTF erlaubt Meshes
ohne NORMAL; die rechnet fill_missing_normals aus den angrenzenden
Flächen (Nullvektor als Sentinel). Beide Szenen-Modelle bringen eigene
Normalen mit, der Fallback ist reine Absicherung.

Szenen-Pass schreibt jetzt zwei Ziele: Albedo und n*0.5+0.5. Das Alpha
des Normal-Buffers ist für eine Objekt-ID reserviert und noch leer. Die
Depth-Textur bekommt TEXTURE_BINDING, und der Post-Vertrag wächst um
group 1 (Normale + Tiefe), die jeder Effekt ignorieren darf — dither
tut das.

Statt eines cel.wgsl-Durchreichers gibt es normals.wgsl, per Default
aus: `post normals on` zeigt oben die Normalen, unten die linearisierte
Tiefe. Ein Effekt, der nichts tut, wäre von einem kaputten nicht zu
unterscheiden; dieser hier trennt später "Loader kaputt" von "Effekt
kaputt".

Das Standard-Bild bleibt unverändert — die Normalen werden geschrieben,
aber von keinem aktiven Effekt gelesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00
irrlichtandClaude Opus 5 7f87dbca35 Post-Effekt-Kette als Gerüst, Dither als erstes Glied
Bildweite Effekte gehören nicht in den Szenen-Shader. Cel-Ramp und
Sobel-Outline sollen dazukommen, ohne einander (oder scene.wgsl) zu
kennen — also eine Kette gleichrangiger Fullscreen-Pässe zwischen
Szene und Blit, Ping-Pong über zwei interne Targets.

Der Vertrag ist für alle gleich: Farbtextur rein (group 0/binding 0),
Farbe raus. Die Vertex-Stufe teilen sich alle über fullscreen.wgsl,
damit eine Effekt-Datei nur aus ihrem fs_main besteht. Ein neuer Effekt
ist eine .wgsl plus eine Zeile in EFFECTS. Bei 640×480 kostet ein
eigener Pass je Effekt praktisch nichts — deshalb kein Über-Shader.

Erster Bewohner ist die RGB555-Quantisierung samt Bayer-Dither, aus
scene.wgsl herausgelöst. Das UI zeichnet jetzt in einem eigenen Pass
hinter der Kette, sonst würde der Dither auch den Text quantisieren,
den er bisher nicht anfasst; damit braucht SpritePass kein
Tiefen-Attachment mehr.

Geschaltet wird über den exec-Trichter (`post`, `post <e> on|off`).
Die Namen kennt nur das Frontend: es meldet seine Kette beim Start in
engine::post an, wie render::audio seine Emitter aus engine::audio
zieht. Der Kern bekommt dadurch keine GPU zu sehen.

Das Bild soll unverändert bleiben — gleicher Bayer-Kernel, gleiche
Koordinaten, gleiche Reihenfolge relativ zum UI. Verifiziert sind
Tests, clippy und ein Start ohne Validation-Fehler; ein Pixelvergleich
steht aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00
irrlichtandClaude Opus 5 8bee8322c3 noclip gestrichen
Der Befehl war ein Toggle zwischen Play und Free — als getippter
Konsolen-Befehl die falsche Form (man sieht nicht, wohin er schaltet)
und deckungsgleich mit `mode play` / `mode free`. Keine Taste, kein
Menü-Knopf, keine Referenz in assets/ hing daran.

Damit stimmt auch die Doku von set_mode wieder: `menu` und die
SetMode-Action sind jetzt tatsächlich die einzigen Schreibpfade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:49 +02:00
irrlicht a3ace75c81 Collider an Cinema Scene 2026-08-28 23:31:49 +02:00
irrlicht 87500e13da Dokumentation der Blender-Interaktion 2026-08-28 23:31:48 +02:00
irrlicht bf2cb25836 map builts 2026-08-28 23:31:48 +02:00
irrlicht 50b7b86944 refactor 2026-08-28 23:31:48 +02:00
irrlicht 3571106251 block slope sliding 2026-08-28 23:31:48 +02:00
63 changed files with 2270 additions and 969 deletions
Generated
+81
View File
@@ -18,6 +18,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
@@ -410,6 +416,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -496,12 +511,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -967,6 +1002,26 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "naga"
version = "29.0.3"
@@ -1436,6 +1491,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -1792,6 +1860,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
@@ -2321,6 +2395,7 @@ version = "0.1.0"
dependencies = [
"bladeink",
"bytemuck",
"png",
"pollster",
"serde_json",
"tinyaudio",
@@ -2986,6 +3061,12 @@ dependencies = [
"syn",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.21"
+1
View File
@@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
bladeink = "1.2.5"
bytemuck = { version = "1.25.0", features = ["derive"] }
png = "0.18.1"
pollster = "0.4.0"
serde_json = "1"
tinyaudio = { version = "2", default-features = false, features = ["pulse"] }
+7
View File
@@ -15,3 +15,10 @@ Toolchain (Rust ≥ 1.85). Das `cargo` aus den Distributions-Paketquellen
- Anwendung bauen und ausführen: `cargo run`
Alternativ ohne Fenster/GPU in der Konsole: `cargo run -- --cli`
Ein anderes Welt-Verzeichnis laden (Default `assets/maps/props/`):
`cargo run -- --maps <ordner>` (Pfad relativ zum aktuellen Verzeichnis).
Assets (Ink-Dialoge, Blender-Welten) baut `cargo build` bei Bedarf selbst,
sofern die Werkzeuge da sind (`inklecate` liegt bei, Blender über `$BLENDER`
oder `PATH`). Ohne sie werden die eingecheckten `.ink.json` / `.glb` benutzt.
+18 -9
View File
@@ -5,15 +5,22 @@ 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!).
- **Ablage:** die `.blend` in ihren Map-Ordner legen —
`assets/maps/<map>/<name>.blend`. `cargo build` (oder
`./tools/build-models.sh`) exportiert sie zu `<name>.glb` daneben; von
Hand exportieren musst du nichts. Die Welt sind alle `.glb` unter
`assets/maps/props/`; ein anderer Ordner per `cargo run -- --maps <dir>`.
- **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.
- **Texturen:** Bild im Material als Base Color zuweisen (PNG) — es wird
beim Export in die `.glb` eingebettet. Damit die Datei self-contained
bleibt: „File → External Data → Pack Resources". Fehlt das Bild,
erscheint die Platzhalter-Textur.
- **Kacheln / Skalieren:** über einen **Mapping-Node** vor dem
Image-Texture-Node (Location/Scale). Die Engine backt das
(`KHR_texture_transform`) beim Laden in die UVs ein.
- **Custom Properties** (unten) kommen automatisch mit; Modifier werden
beim Export angewandt.
### Custom Properties auf Mesh-Objekten
@@ -67,8 +74,10 @@ Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
## Testen
- `cargo run` — Konsole zeigt beim Start, was ankam: Objekte, Dreiecke,
Empties und **jede Custom Property**. Fehlt eine → Export-Haken prüfen.
- `cargo run` — baut geänderte `.blend` neu und zeigt beim Start, was
ankam: Objekte, Dreiecke, Empties und **jede Custom Property**. Fehlt
eine, sitzt sie in Blender nicht am Objekt (Object Properties → Custom
Properties), nicht am Mesh-Data.
- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum
freien Fliegen, `kv` zeigt alle Variablen, `signal <name>` feuert jedes
Signal von Hand, `reload` lädt signals.toml neu.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -32
View File
@@ -9,44 +9,14 @@
# inc <name> [<delta>] KV-Integer +Delta (Default +1)
# clear <name> KV-Eintrag entfernen
# play_sound <name> deferred (Frontend): SFX aus assets/sounds/{name}.wav
# mode <play|free|menu> deferred (Frontend): Anzeige-/Eingabemodus
# post <name> <on|off> deferred (Frontend): Renderer-Post-Effekt schalten
#
# Reserviertes Signal `[init]` feuert einmal vor der Game-Loop —
# idealer Ort für KV-Defaults.
[init]
actions = [
"set cube_clicks 0",
"set mushrooms_collected 0",
"set quest_mushroom_active false",
"set quest_mushroom_done false",
]
[Mushroom]
actions = [
"inc mushrooms_collected",
"play_sound pickup",
]
[Person]
actions = [
"play_sound greet",
"start_ink person.ink.json",
]
[Suzanne]
actions = [
"start_ink suzanne.ink.json",
]
[Cube]
actions = [
"inc cube_clicks",
"start_ink cube.ink.json",
]
[person_farewell]
actions = [
"debug_log Person hat sich verabschiedet",
]
[tiffany]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+50 -27
View File
@@ -1,59 +1,82 @@
//! Build-Hook: kompiliert geänderte Ink-Dialoge automatisch beim Cargo-Build.
//! Build-Hook: baut geänderte Assets automatisch beim Cargo-Build.
//!
//! Reines „Wann"-Stück — das eigentliche Bauen macht `tools/build-dialogues.sh`
//! (und bleibt von Hand aufrufbar). Hier melden wir Cargo nur, wann es uns
//! erneut ausführen soll, und stoßen das Skript an.
//! Reines „Wann"-Stück — das eigentliche Bauen machen die Skripte unter
//! `tools/` (und bleiben von Hand aufrufbar). Hier melden wir Cargo nur, wann
//! es uns erneut ausführen soll, und stoßen die Skripte an:
//!
//! * `build-dialogues.sh` — `.ink` → `.ink.json` (inklecate)
//! * `build-models.sh` — `assets/maps/**/*.blend` → `.glb` (Blender)
//!
//! Cargo führt build.rs ausschließlich dann erneut aus, wenn eine der unten
//! via `rerun-if-changed` gemeldeten Dateien sich ändert. Unveränderte
//! Rebuilds kosten also nichts; nur nach einer `.ink`-Änderung läuft
//! inklecate (inkrementell nur für die betroffene Datei).
//! Rebuilds kosten also nichts; nur nach einer Quell-Änderung läuft das
//! jeweilige Werkzeug (beide Skripte bauen intern inkrementell).
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
let root = env!("CARGO_MANIFEST_DIR");
let script = Path::new(root).join("tools/build-dialogues.sh");
let ink_dir = Path::new(root).join("assets/interactions");
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Wann soll Cargo build.rs erneut ausführen?
// --- Ink-Dialoge ------------------------------------------------------
println!("cargo:rerun-if-changed=tools/build-dialogues.sh");
println!("cargo:rerun-if-changed=tools/inklecate");
// Verzeichnis-mtime fängt neu hinzugekommene/gelöschte .ink-Dateien.
println!("cargo:rerun-if-changed=assets/interactions");
// Inhaltsänderungen einzelner .ink-Dateien einzeln melden.
if let Ok(entries) = std::fs::read_dir(&ink_dir) {
watch_ext(&root.join("assets/interactions"), "ink");
run_script(&root, "tools/build-dialogues.sh", "Dialog");
// --- Blender-Welten -------------------------------------------------
println!("cargo:rerun-if-changed=tools/build-models.sh");
// Ordner-mtime fängt neue/gelöschte Maps; die .blend selbst einzeln.
// Die erzeugten .glb werden NICHT beobachtet (das Skript schreibt sie).
watch_dirs_and_ext(&root.join("assets/maps"), "blend");
run_script(&root, "tools/build-models.sh", "Modell");
}
/// Jede Datei mit Endung `ext` direkt in `dir` einzeln bei Cargo anmelden.
fn watch_ext(dir: &Path, ext: &str) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) == Some("ink") {
if p.extension().and_then(|s| s.to_str()) == Some(ext) {
println!("cargo:rerun-if-changed={}", p.display());
}
}
}
// Skript ausführen. Ausgabe einsammeln, damit ein Dialog-Fehler sichtbar
// wird (Cargo blendet Build-Script-stdout sonst aus).
/// Wie [`watch_ext`], aber rekursiv — und jedes Unterverzeichnis wird selbst
/// beobachtet (fängt neu angelegte/entfernte Maps).
fn watch_dirs_and_ext(dir: &Path, ext: &str) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
println!("cargo:rerun-if-changed={}", dir.display());
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
watch_dirs_and_ext(&p, ext);
} else if p.extension().and_then(|s| s.to_str()) == Some(ext) {
println!("cargo:rerun-if-changed={}", p.display());
}
}
}
/// Ein `tools/`-Skript laufen lassen. Ausgabe einsammeln (Cargo blendet
/// Build-Script-stdout sonst aus); Fehlschlag ist fatal, ein nicht startbares
/// bash nur eine Warnung — die committeten Artefakte reichen dann.
fn run_script(root: &Path, rel: &str, label: &str) {
let script = root.join(rel);
match Command::new("bash").arg(&script).output() {
Ok(out) if out.status.success() => { /* still bei Erfolg */ }
Ok(out) if out.status.success() => { /* still bei Erfolg — Detail via Hand-Aufruf */ }
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
for line in stdout.lines().chain(stderr.lines()) {
println!("cargo:warning={line}");
}
panic!(
"Dialog-Build fehlgeschlagen — siehe Warnungen oben. \
Einzeln debuggen: ./tools/build-dialogues.sh"
);
panic!("{label}-Build fehlgeschlagen — siehe Warnungen oben. Einzeln debuggen: ./{rel}");
}
Err(e) => {
// bash/Skript nicht startbar (z.B. fremdes OS): den Build nicht
// blockieren, nur warnen — die committeten .ink.json reichen.
println!(
"cargo:warning=Dialog-Build übersprungen ({e}). \
Bei Bedarf von Hand: ./tools/build-dialogues.sh"
);
println!("cargo:warning={label}-Build übersprungen ({e}). Bei Bedarf von Hand: ./{rel}");
}
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ mod tests {
use crate::engine::model::{Empty, Props};
fn model(empties: Vec<Empty>) -> Model {
Model { objects: Vec::new(), empties, materials: Vec::new() }
Model { objects: Vec::new(), empties, textures: Vec::new() }
}
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
+6 -9
View File
@@ -10,17 +10,13 @@
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
//! sofort wieder im Kontakt steckt.
//!
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
//! Gefüllt wird die Welt aus Blender-Modellen ([`CollisionWorld::add_model`]):
//! jedes Objekt mit `collide`-Property wird zur **konvexen Hülle** seiner
//! Vertices ([`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::model::{Model, Object};
@@ -384,7 +380,7 @@ mod tests {
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
],
empties: Vec::new(),
materials: vec![String::new()],
textures: Vec::new(),
};
let mut w = CollisionWorld::empty();
w.add_model(&model);
@@ -438,6 +434,7 @@ mod tests {
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],
normals: vec![[0.0, 0.0, 1.0]; 4],
tris: vec![[0, 1, 2], [0, 2, 3]],
tri_mats: vec![0; 2],
visible: true, collider: false, props: Props::new(),
+12 -12
View File
@@ -5,11 +5,9 @@
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
//! konstruiert und durch die Dispatch-Pipeline gereicht.
//!
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
//! testbar und kennt keines der Subsysteme.
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) den Kern nichts
//! angeht, produzieren [`Action`]-Werte statt direkt zu wirken. Frontends
//! konsumieren die Queue: die CLI druckt sie, das Fenster führt sie aus.
use std::collections::HashMap;
@@ -28,20 +26,22 @@ pub type Signals = HashMap<String, Vec<String>>;
pub enum Action {
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
PlaySound(String),
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
/// wählbar — der entsteht nur aus dem Story-Ablauf.
/// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
/// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
SetMode(ModeTarget),
/// Renderer-Post-Effekt schalten. Der Kern kennt weder die Kette noch die
/// Effektnamen — das Fenster löst `name` gegen seine Kette auf (unbekannt
/// → Meldung), die CLI reicht nur die `[action]`-Zeile.
SetPost { name: String, on: bool },
}
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog-
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
/// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
/// `session::Mode`, ohne dass der Kern dessen Dialog-Daten kennen muss.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeTarget {
/// First-Person-Spiel.
Play,
/// Noclip-Debug-Flycam.
/// Noclip: freies Fliegen ohne Kollision.
Free,
/// Menü (pausiert die Welt, gibt die Maus frei).
Menu,
+288 -41
View File
@@ -1,30 +1,30 @@
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
//! glTF-Binary-Loader (.glb, Blender-Export) → geparstes [`Model`].
//!
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann —
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
//! Die tragenden Kanäle: **Custom Properties** (in Blender 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.
//! **Empties** (Nodes ohne Mesh) als Entity-Marker. 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.
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
//!
//! Texturen: die `baseColorTexture` jedes Materials wird aus dem BIN-Chunk
//! geholt und dekodiert (PNG, wie Blender es einbettet) → `model::Texture`.
//! `KHR_texture_transform` (Blenders Mapping-Node) wird beim Laden in die UVs
//! eingebacken. Der Rest von glTFs PBR-Modell bleibt ungenutzt.
//!
//! 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 std::collections::HashMap;
use serde_json::Value;
use crate::engine::model::{apply_props, Empty, Model, Object, Props};
use crate::engine::image::{self, Image};
use crate::engine::model::{apply_props, fill_missing_normals, norm, Empty, Model, Object, Props, Texture};
pub fn load(path: &str) -> Result<Model, String> {
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
@@ -59,7 +59,7 @@ pub fn parse_glb(bytes: &[u8]) -> Result<Model, String> {
}
fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() };
let mut model = Model { objects: Vec::new(), empties: Vec::new(), textures: Vec::new() };
// Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die
// Welt-Transform wächst dabei Parent → Kind.
@@ -69,9 +69,109 @@ fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
let Some(i) = r.as_u64() else { continue; };
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
}
resolve_textures(doc, bin, &mut model);
Ok(model)
}
/// Nach dem Walk tragen die `tri_mats` rohe glTF-Material-Indizes
/// (`usize::MAX` = kein Material). Daraus die deduplizierte Texturliste bauen
/// (erste-Sicht-Reihenfolge) und die `tri_mats` auf ihre Indizes umschreiben.
fn resolve_textures(doc: &Value, bin: &[u8], model: &mut Model) {
let mut order: Vec<usize> = Vec::new();
let mut local: HashMap<usize, usize> = HashMap::new();
for o in &model.objects {
for &m in &o.tri_mats {
local.entry(m).or_insert_with(|| { order.push(m); order.len() - 1 });
}
}
model.textures = order.iter().map(|&raw| build_texture(doc, bin, raw)).collect();
for o in &mut model.objects {
for m in &mut o.tri_mats { *m = local[m]; }
}
}
/// Das `model::Texture` für einen rohen glTF-Material-Index:
/// `material → pbrMetallicRoughness.baseColorTexture → texture → image`, Bild
/// aus dem BIN-Chunk dekodiert. Fehlt ein Glied, bleibt `image: None` (der
/// Konsument nimmt seinen Platzhalter), der Schlüssel wird trotzdem gesetzt.
fn build_texture(doc: &Value, bin: &[u8], raw_mat: usize) -> Texture {
if raw_mat == usize::MAX {
return Texture { key: String::new(), image: None };
}
let mat = &doc["materials"][raw_mat];
let mat_name = mat["name"].as_str().unwrap_or("");
let tex_ref = &mat["pbrMetallicRoughness"]["baseColorTexture"];
let img_node = tex_ref["index"].as_u64()
.and_then(|t| doc["textures"][t as usize]["source"].as_u64())
.map(|s| &doc["images"][s as usize]);
let Some(img_node) = img_node else {
return Texture { key: mat_name.to_string(), image: None };
};
let key = img_node["name"].as_str()
.filter(|s| !s.is_empty())
.unwrap_or(mat_name)
.to_string();
let image = decode_embedded_image(doc, bin, img_node, &key);
Texture { key, image }
}
/// Ein `images[]`-Node aus dem BIN-Chunk dekodieren. Externe Dateien und
/// Daten-URIs unterstützt der GLB-Pfad nicht — sie werden gemeldet.
fn decode_embedded_image(doc: &Value, bin: &[u8], node: &Value, key: &str) -> Option<Image> {
let Some(view) = node["bufferView"].as_u64() else {
eprintln!("[tex] {key}: Bild ohne bufferView (externe Datei/Daten-URI wird nicht gelesen)");
return None;
};
let bytes = match buffer_view_bytes(doc, bin, view as usize) {
Ok(b) => b,
Err(e) => { eprintln!("[tex] {key}: {e}"); return None; }
};
match image::decode(bytes, node["mimeType"].as_str().unwrap_or("")) {
Ok(img) => Some(img),
Err(e) => { eprintln!("[tex] {key}: {e}"); None }
}
}
/// Rohe Bytes eines bufferViews (ohne Accessor-Stride) aus dem BIN-Chunk.
fn buffer_view_bytes<'a>(doc: &Value, bin: &'a [u8], view: usize) -> Result<&'a [u8], String> {
let v = &doc["bufferViews"][view];
if v.is_null() { return Err(format!("bufferView {view} fehlt")); }
let off = v["byteOffset"].as_u64().unwrap_or(0) as usize;
let len = v["byteLength"].as_u64().ok_or("bufferView ohne byteLength")? as usize;
bin.get(off..off + len).ok_or_else(|| "bufferView ragt aus dem BIN-Chunk".into())
}
/// KHR_texture_transform einer Textur-Referenz — hier landet Blenders
/// Mapping-Node. Fehlt die Extension: `None`.
struct UvTransform {
offset: [f32; 2],
scale: [f32; 2],
rotation: f32,
}
fn uv_transform(tex_ref: &Value) -> Option<UvTransform> {
let x = &tex_ref["extensions"]["KHR_texture_transform"];
if x.is_null() { return None; }
Some(UvTransform {
offset: vecn::<2>(&x["offset"], [0.0, 0.0]),
scale: vecn::<2>(&x["scale"], [1.0, 1.0]),
rotation: x["rotation"].as_f64().unwrap_or(0.0) as f32,
})
}
impl UvTransform {
/// `uv' = translation · rotation · scale · uv` (KHR-Referenz: die
/// Rotationsmatrix dreht mit negativem Winkel).
fn apply(&self, uv: [f32; 2]) -> [f32; 2] {
let (a, b) = (uv[0] * self.scale[0], uv[1] * self.scale[1]);
let (s, c) = self.rotation.sin_cos();
[
c * a + s * b + self.offset[0],
-s * a + c * b + self.offset[1],
]
}
}
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")); }
@@ -84,10 +184,11 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
let mut o = Object {
name, props,
visible: true, collider: false, // bis apply_props entscheidet
verts: Vec::new(), uvs: Vec::new(),
verts: Vec::new(), uvs: Vec::new(), normals: Vec::new(),
tris: Vec::new(), tri_mats: Vec::new(),
};
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
append_mesh(doc, bin, mesh as usize, world, &mut o)?;
fill_missing_normals(&mut o); // Primitives ohne NORMAL
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
if !o.tris.is_empty() { model.objects.push(o); }
}
@@ -110,8 +211,7 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
/// 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>,
doc: &Value, bin: &[u8], mesh: usize, world: M4, o: &mut Object,
) -> Result<(), String> {
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
if prim["mode"].as_u64().unwrap_or(4) != 4 {
@@ -124,20 +224,32 @@ fn append_mesh(
Some(a) => read_floats::<2>(doc, bin, a as usize)?,
None => vec![[0.0, 0.0]; positions.len()],
};
// Ohne NORMAL bleibt der Nullvektor stehen; fill_missing_normals
// rechnet ihn später aus den Flächen.
let normals = match prim["attributes"]["NORMAL"].as_u64() {
Some(a) => read_floats::<3>(doc, bin, a as usize)?,
None => vec![[0.0, 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
});
// Rohen glTF-Material-Index behalten; resolve_textures baut daraus
// nach dem Walk die deduplizierte Texturliste und schreibt die
// tri_mats auf deren Indizes um. usize::MAX = kein Material.
let raw_mat = prim["material"].as_u64().map_or(usize::MAX, |m| m as usize);
let base = o.verts.len();
for (p, uv) in positions.iter().zip(&uvs) {
let nm = normal_matrix(world);
for ((p, uv), n) in positions.iter().zip(&uvs).zip(&normals) {
o.verts.push(transform(world, *p));
o.uvs.push(*uv);
o.normals.push(norm(transform3(nm, *n)));
}
// KHR_texture_transform (Blenders Mapping-Node) in die UVs dieses
// Primitives einbacken — der Shader bleibt UV-agnostisch.
if raw_mat != usize::MAX {
let tex_ref = &doc["materials"][raw_mat]["pbrMetallicRoughness"]["baseColorTexture"];
if let Some(xf) = uv_transform(tex_ref) {
for uv in &mut o.uvs[base..] { *uv = xf.apply(*uv); }
}
}
let idxs: Vec<usize> = match prim["indices"].as_u64() {
@@ -149,7 +261,7 @@ fn append_mesh(
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);
o.tri_mats.push(raw_mat);
}
}
Ok(())
@@ -294,6 +406,36 @@ fn mul(a: M4, b: M4) -> M4 {
out
}
/// Normalen-Transform zur Welt-Matrix: die Kofaktormatrix ihres 3×3-Teils
/// (zeilenweise). Das ist die inverse Transponierte bis auf den Faktor
/// `det` — den frisst das anschließende Normieren, und im Gegensatz zur
/// Matrix selbst hält sie Normalen auch bei ungleichmäßiger Skalierung
/// senkrecht auf der Fläche. Bei Spiegelungen (det < 0) dreht sie die
/// Normale korrekt mit.
fn normal_matrix(m: M4) -> [f32; 9] {
let a = |r: usize, c: usize| m[c * 4 + r];
let cof = |r: usize, c: usize| {
let (r0, r1) = ((r + 1) % 3, (r + 2) % 3);
let (c0, c1) = ((c + 1) % 3, (c + 2) % 3);
a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
};
let mut out = [0.0; 9];
for r in 0..3 {
for c in 0..3 { out[r * 3 + c] = cof(r, c); }
}
out
}
/// Richtungsvektor mit einer 3×3-Matrix (zeilenweise) drehen — ohne
/// Translation, anders als [`transform`].
fn transform3(m: [f32; 9], v: [f32; 3]) -> [f32; 3] {
let mut out = [0.0; 3];
for (r, o) in out.iter_mut().enumerate() {
*o = m[r * 3] * v[0] + m[r * 3 + 1] * v[1] + m[r * 3 + 2] * v[2];
}
out
}
fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
let mut out = [0.0; 3];
for (r, o) in out.iter_mut().enumerate() {
@@ -333,14 +475,22 @@ mod tests {
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> {
/// Diagonale Normale — unter ungleichmäßiger Skalierung unterscheidet
/// sie die korrekte Kofaktor-Transform von der (falschen) Matrix.
const DIAG: f32 = std::f32::consts::FRAC_1_SQRT_2;
/// Ein Dreieck (Positionen + UVs + Normalen + u16-Indizes) im BIN-Chunk.
fn sample_bin() -> 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 _ in 0..3 { for f in [DIAG, DIAG, 0.0] { bin.extend(f.to_le_bytes()); } }
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
let json = r#"{
bin
}
/// Mesh-Node mit Translation und extras, ein skaliertes Kind, ein Empty.
const SAMPLE_JSON: &str = r#"{
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 2]}],
@@ -348,27 +498,36 @@ mod tests {
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
"extras": {"signal": "tiffany", "hp": 3},
"children": [1]},
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}},
{"name": "Kind", "mesh": 0, "scale": [2, 1, 1],
"extras": {"collide": "proxy"}},
{"name": "spawn", "translation": [1, 2, 3]}
],
"meshes": [{"primitives": [{
"attributes": {"POSITION": 0, "TEXCOORD_0": 1},
"indices": 2, "material": 0
"attributes": {"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2},
"indices": 3, "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"}
{"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"},
{"bufferView": 3, "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}
{"buffer": 0, "byteOffset": 60, "byteLength": 36},
{"buffer": 0, "byteOffset": 96, "byteLength": 6}
],
"buffers": [{"byteLength": 66}]
"buffers": [{"byteLength": 102}]
}"#;
glb(json, &bin)
fn sample_glb() -> Vec<u8> { glb(SAMPLE_JSON, &sample_bin()) }
/// Dasselbe Modell ohne `NORMAL` — glTF erlaubt das, der Loader muss die
/// Normalen dann selbst rechnen.
fn sample_glb_without_normals() -> Vec<u8> {
glb(&SAMPLE_JSON.replace(r#", "NORMAL": 2"#, ""), &sample_bin())
}
#[test]
@@ -382,7 +541,10 @@ mod tests {
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()]);
// Material "carpet" ohne baseColorTexture → Schlüssel gesetzt, kein Bild.
assert_eq!(m.textures.len(), 1);
assert_eq!(m.textures[0].key, "carpet");
assert!(m.textures[0].image.is_none());
// Kind erbt die Parent-Transform (10, 0, 0);
// collide="proxy" → unsichtbarer Collider.
@@ -405,6 +567,29 @@ mod tests {
assert_eq!(e.pos, [1.0, 2.0, 3.0]);
}
#[test]
fn normals_survive_nonuniform_scale() {
let m = parse_glb(&sample_glb()).unwrap();
// „Ding": nur Translation → Normale unverändert.
let n = m.objects[0].normals[0];
assert!((n[0] - DIAG).abs() < 1e-5 && (n[1] - DIAG).abs() < 1e-5, "{n:?}");
// „Kind": Skalierung [2,1,1]. Richtig ist die inverse Transponierte
// (x wird gestaucht → [0.447, 0.894, 0]); die Matrix selbst ergäbe
// fälschlich [0.894, 0.447, 0], die Normale stünde schief zur Fläche.
let n = m.objects[1].normals[0];
assert!((n[0] - 0.4472).abs() < 1e-3, "x sollte gestaucht sein: {n:?}");
assert!((n[1] - 0.8944).abs() < 1e-3, "y sollte überwiegen: {n:?}");
}
#[test]
fn missing_normals_are_computed_from_faces() {
let m = parse_glb(&sample_glb_without_normals()).unwrap();
// Dreieck liegt in der XY-Ebene, CCW → Normale +Z.
assert_eq!(m.objects[0].normals[0], [0.0, 0.0, 1.0]);
assert!(m.objects[0].normals.iter().all(|n| *n != [0.0; 3]));
}
#[test]
fn uvs_pass_through_unchanged() {
// glTF-UVs (Ursprung oben links) sind die Modell-Konvention — kein Flip.
@@ -419,4 +604,66 @@ mod tests {
v1[4] = 1; // Version patchen
assert!(parse_glb(&v1).is_err());
}
#[test]
fn embedded_png_is_decoded_and_uv_transform_baked_in() {
// 2×2-RGB-PNG (schwarz, rot, grün, blau) in den BIN-Chunk legen.
let mut png_bytes = Vec::new();
{
let mut enc = png::Encoder::new(&mut png_bytes, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
enc.write_header().unwrap()
.write_image_data(&[0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255])
.unwrap();
}
let mut bin = sample_bin();
while !bin.len().is_multiple_of(4) { bin.push(0); }
let img_off = bin.len();
bin.extend_from_slice(&png_bytes);
let json = format!(r#"{{
"asset": {{"version": "2.0"}},
"scene": 0,
"scenes": [{{"nodes": [0]}}],
"nodes": [{{"name": "Boden", "mesh": 0}}],
"meshes": [{{"primitives": [{{
"attributes": {{"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2}},
"indices": 3, "material": 0
}}]}}],
"materials": [{{"name": "Bodenmat", "pbrMetallicRoughness": {{"baseColorTexture": {{
"index": 0,
"extensions": {{"KHR_texture_transform": {{"scale": [2.0, 4.0], "offset": [0.5, 0.0]}}}}
}}}}}}],
"textures": [{{"source": 0}}],
"images": [{{"name": "boden_tex", "mimeType": "image/png", "bufferView": 4}}],
"accessors": [
{{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}},
{{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}},
{{"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"}},
{{"bufferView": 3, "componentType": 5123, "count": 3, "type": "SCALAR"}}
],
"bufferViews": [
{{"buffer": 0, "byteOffset": 0, "byteLength": 36}},
{{"buffer": 0, "byteOffset": 36, "byteLength": 24}},
{{"buffer": 0, "byteOffset": 60, "byteLength": 36}},
{{"buffer": 0, "byteOffset": 96, "byteLength": 6}},
{{"buffer": 0, "byteOffset": {img_off}, "byteLength": {png_len}}}
],
"buffers": [{{"byteLength": {bin_len}}}]
}}"#, img_off = img_off, png_len = png_bytes.len(), bin_len = bin.len());
let m = parse_glb(&glb(&json, &bin)).unwrap();
assert_eq!(m.textures.len(), 1);
let t = &m.textures[0];
assert_eq!(t.key, "boden_tex"); // glTF-Bildname, nicht Materialname
let img = t.image.as_ref().expect("PNG dekodiert");
assert_eq!((img.width, img.height), (2, 2));
assert_eq!(&img.rgba[0..8], &[0, 0, 0, 255, 255, 0, 0, 255]);
// KHR_texture_transform eingebacken: UV (1,1) → (1·2 + 0.5, 1·4) = (2.5, 4.0).
let uv = m.objects[0].uvs[2];
assert!((uv[0] - 2.5).abs() < 1e-5 && (uv[1] - 4.0).abs() < 1e-5, "{uv:?}");
}
}
+118
View File
@@ -0,0 +1,118 @@
//! RGBA8-`Image` — der geteilte Pixel-Container der Engine, dazu der Decoder
//! für eingebettete glTF-Bilder. Immer 32 bit, Ursprung oben-links, fertig
//! konvertiert; der Aufrufer macht nur noch das Sampling (und damit die
//! UV-Konvention) selbst.
//!
//! [`decode`] liest die Bilder, die Blender beim glTF-Export in die `.glb`
//! packt; [`load`] dieselbe Sorte Bild von der Platte (UI, Fonts,
//! Platzhalter). Beides PNG — ein Decoder für alles.
#[derive(Clone)]
pub struct Image {
pub width: u32,
pub height: u32,
/// `width * height * 4`, Ursprung oben-links.
pub rgba: Vec<u8>,
}
/// Eine PNG-Datei von der Platte laden.
pub fn load(path: &str) -> Result<Image, String> {
let bytes = std::fs::read(path).map_err(|e| format!("image load {path}: {e}"))?;
decode(&bytes, "").map_err(|e| format!("image {path}: {e}"))
}
/// Ein eingebettetes glTF-Bild dekodieren. `mime` ist der glTF-`mimeType`
/// (darf leer sein — dann entscheidet die Datei-Signatur). Unterstützt wird
/// nur PNG; JPEG/WebP werden gemeldet, nicht dekodiert.
pub fn decode(bytes: &[u8], mime: &str) -> Result<Image, String> {
const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n";
let is_png = mime == "image/png" || (mime.is_empty() && bytes.starts_with(PNG_MAGIC));
if !is_png {
let what = if mime.is_empty() { "unbekanntes Format" } else { mime };
return Err(format!("{what} — nur PNG (in Blender „Images: PNG“ beim glTF-Export)"));
}
decode_png(bytes)
}
fn decode_png(bytes: &[u8]) -> Result<Image, String> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
// EXPAND hebt Palette/Low-Bit-Grau auf 8 bit, ALPHA erzwingt einen
// Alpha-Kanal, STRIP_16 wirft die zweite Byte-Hälfte weg. Damit bleiben
// als Ausgabe nur noch RGBA8 und Grau+Alpha8.
decoder.set_transformations(
png::Transformations::EXPAND | png::Transformations::ALPHA | png::Transformations::STRIP_16,
);
let mut reader = decoder.read_info().map_err(|e| format!("PNG: {e}"))?;
let mut buf = vec![0u8; reader.output_buffer_size().ok_or("PNG: Bild zu groß")?];
let info = reader.next_frame(&mut buf).map_err(|e| format!("PNG: {e}"))?;
let (width, height) = (info.width, info.height);
let src = &buf[..info.buffer_size()];
let n = (width as usize) * (height as usize);
let rgba = match info.color_type {
png::ColorType::Rgba => src.to_vec(),
png::ColorType::GrayscaleAlpha => {
let mut out = vec![0u8; n * 4];
for i in 0..n {
let (v, a) = (src[2 * i], src[2 * i + 1]);
let o = i * 4;
out[o] = v; out[o + 1] = v; out[o + 2] = v; out[o + 3] = a;
}
out
}
other => return Err(format!("PNG: unerwarteter Ausgabetyp {other:?}")),
};
Ok(Image { width, height, rgba })
}
#[cfg(test)]
mod tests {
use super::*;
/// Ein `w`×`h`-PNG mit gegebenem Farbtyp im Speicher erzeugen.
fn png(w: u32, h: u32, color: png::ColorType, data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, w, h);
enc.set_color(color);
enc.set_depth(png::BitDepth::Eight);
enc.write_header().unwrap().write_image_data(data).unwrap();
out
}
#[test]
fn rgb_png_gets_opaque_alpha() {
// 2×1, zwei RGB-Pixel.
let bytes = png(2, 1, png::ColorType::Rgb, &[10, 20, 30, 40, 50, 60]);
let img = decode(&bytes, "image/png").unwrap();
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(img.rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]);
}
#[test]
fn grayscale_png_expands_to_rgba() {
let bytes = png(2, 1, png::ColorType::Grayscale, &[128, 255]);
let img = decode(&bytes, "").unwrap(); // mime leer → Signatur greift
assert_eq!(img.rgba, vec![128, 128, 128, 255, 255, 255, 255, 255]);
}
#[test]
fn non_png_is_reported_not_decoded() {
assert!(decode(b"\xff\xd8\xff\xe0junk", "image/jpeg").is_err());
assert!(decode(b"whatever", "").is_err());
}
#[test]
fn loads_the_real_project_assets() {
for (path, w, h) in [
("assets/textures/placeholder.png", 256, 256),
("assets/textures/fonts/oldschool-ega-8x14.png", 256, 112),
("assets/textures/fonts/oldschool-cga-8x8.png", 256, 64),
("assets/textures/ui/cursors.png", 32, 32),
("assets/textures/ui/panel_ornaments.png", 32, 32),
] {
let img = load(path).unwrap_or_else(|e| panic!("{path}: {e}"));
assert_eq!((img.width, img.height), (w, h), "{path}");
assert_eq!(img.rgba.len(), (w * h * 4) as usize, "{path}");
}
}
}
+32 -7
View File
@@ -1,4 +1,6 @@
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`.
//! Dünner Adapter um die `bladeink`-Crate — und die einzige Stelle im Kern,
//! die sie kennt. `to_ink`/`from_ink` rechnen zwischen `kv::Value` und Inks
//! `ValueType` um; alles andere spricht nur den neutralen KV-Typ.
//!
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
@@ -20,8 +22,9 @@
use std::fs::read_to_string;
use bladeink::story::Story as BladeStory;
use bladeink::value_type::ValueType;
use crate::engine::kv::Store;
use crate::engine::kv::{Store, Value};
pub enum StoryState {
Text(String),
@@ -140,7 +143,7 @@ impl Story {
if name.starts_with('_') { continue; } // skript-lokal
// set_variable schlägt fehl wenn der Name kein deklariertes
// VAR im Skript ist — still ignorieren.
let _ = self.inner.set_variable(name, value);
let _ = self.inner.set_variable(name, &to_ink(value));
}
}
@@ -149,17 +152,39 @@ impl Story {
/// genau so wandern Werte in per `start_ink` verkettete Folgeskripte.
fn sync_to_kv(&self, kv: &mut Store) {
for name in &self.vars {
if let Some(v) = self.inner.get_variable(name) {
if let Some(v) = self.inner.get_variable(name).as_ref().and_then(from_ink) {
kv.insert(name.clone(), v);
}
}
}
}
/// [`Value`] → Inks `ValueType`. Zusammen mit [`from_ink`] die einzige Stelle,
/// an der der Kern-KV die Story-Crate berührt.
fn to_ink(v: &Value) -> ValueType {
match v {
Value::Bool(b) => ValueType::Bool(*b),
Value::Int(i) => ValueType::Int(*i),
Value::Float(f) => ValueType::Float(*f),
Value::Str(s) => ValueType::new(s.as_str()),
}
}
/// Inks `ValueType` → [`Value`]. `None` für Ink-eigene Typen (Listen, Divert-
/// und Variablen-Referenzen), die im KV nichts verloren haben.
fn from_ink(v: &ValueType) -> Option<Value> {
Some(match v {
ValueType::Bool(b) => Value::Bool(*b),
ValueType::Int(i) => Value::Int(*i),
ValueType::Float(f) => Value::Float(*f),
ValueType::String(s) => Value::Str(s.string.clone()),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use bladeink::value_type::ValueType;
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
// VAR snaks = 0
@@ -183,7 +208,7 @@ mod tests {
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!(matches!(kv["snaks"], Value::Int(1)));
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
}
@@ -191,7 +216,7 @@ mod tests {
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));
kv.insert("snaks".into(), Value::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"),
+76 -34
View File
@@ -3,41 +3,68 @@
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
//! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe
//! [`crate::engine::ink`]). Damit der KV authoritativ bleibt: externe Actions
//! [`crate::engine::ink`]). Damit der KV autoritativ bleibt: externe Actions
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
//!
//! Wir nutzen `bladeink::ValueType` als Value, damit die Sync-Konvertierung
//! gratis ist (kein Mapping zwischen Rust- und Ink-Typen nötig).
//! [`Value`] ist ein eigener, Ink- und frontend-neutraler Typ. Die Umrechnung
//! auf `bladeink::ValueType` passiert allein an der Ink-Grenze in
//! [`crate::engine::ink`] — so hängt der Kern-KV nicht an der Story-Crate.
use std::collections::HashMap;
use std::fmt;
use bladeink::value_type::ValueType;
pub type Store = HashMap<String, Value>;
pub type Store = HashMap<String, ValueType>;
/// Ein KV-Wert. Deckt die Skalare ab, die signals.toml-Actions und Ink-`VAR`s
/// austauschen; Ink-eigene Typen (Listen, Divert-Referenzen) haben hier
/// bewusst keinen Platz.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Bool(bool),
Int(i32),
Float(f32),
Str(String),
}
/// Parst einen Action-Arg-String zu einem `ValueType`. Reihenfolge:
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
pub fn parse_value(s: &str) -> ValueType {
let s = s.trim();
match s {
"true" => return ValueType::Bool(true),
"false" => return ValueType::Bool(false),
_ => {}
impl Value {
/// Als Ganzzahl lesen (für `inc`): `Bool` zählt als 0/1, `Float` schneidet
/// ab, `Str` hat keinen Zahlwert. Spiegelt die Coercion, die vorher
/// bladeink lieferte.
pub fn as_int(&self) -> Option<i32> {
match self {
Value::Bool(b) => Some(*b as i32),
Value::Int(i) => Some(*i),
Value::Float(f) => Some(*f as i32),
Value::Str(_) => None,
}
}
if let Ok(i) = s.parse::<i32>() { return ValueType::Int(i); }
if let Ok(f) = s.parse::<f32>() { return ValueType::Float(f); }
ValueType::new(s)
}
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
pub fn format_value(v: &ValueType) -> String {
match v {
ValueType::Bool(b) => b.to_string(),
ValueType::Int(i) => i.to_string(),
ValueType::Float(f) => f.to_string(),
other => other.coerce_to_string().unwrap_or_else(|_| "<?>".into()),
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Bool(b) => write!(f, "{b}"),
Value::Int(i) => write!(f, "{i}"),
Value::Float(x) => write!(f, "{x}"),
Value::Str(s) => write!(f, "{s}"),
}
}
}
/// Parst einen Action-Arg-String zu einem [`Value`]. Reihenfolge:
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
pub fn parse_value(s: &str) -> Value {
let s = s.trim();
match s {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
if let Ok(i) = s.parse::<i32>() { return Value::Int(i); }
if let Ok(x) = s.parse::<f32>() { return Value::Float(x); }
Value::Str(s.to_string())
}
pub fn apply_set(args: &str, store: &mut Store) {
let Some((name, value)) = args.split_once(char::is_whitespace) else {
@@ -51,8 +78,8 @@ pub fn apply_inc(args: &str, store: &mut Store) {
let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1"));
let name = name.trim();
let delta = delta.trim().parse::<i32>().unwrap_or(1);
let cur = store.get(name).and_then(|v| v.coerce_to_int().ok()).unwrap_or(0);
store.insert(name.to_string(), ValueType::Int(cur + delta));
let cur = store.get(name).and_then(Value::as_int).unwrap_or(0);
store.insert(name.to_string(), Value::Int(cur + delta));
}
pub fn apply_clear(args: &str, store: &mut Store) {
@@ -65,29 +92,37 @@ mod tests {
#[test]
fn parse_value_types() {
assert!(matches!(parse_value("true"), ValueType::Bool(true)));
assert!(matches!(parse_value("false"), ValueType::Bool(false)));
assert!(matches!(parse_value("42"), ValueType::Int(42)));
assert!(matches!(parse_value("-7"), ValueType::Int(-7)));
assert!(matches!(parse_value("1.5"), ValueType::Float(f) if f == 1.5));
assert!(matches!(parse_value("hallo"), ValueType::String(_)));
assert!(matches!(parse_value("true"), Value::Bool(true)));
assert!(matches!(parse_value("false"), Value::Bool(false)));
assert!(matches!(parse_value("42"), Value::Int(42)));
assert!(matches!(parse_value("-7"), Value::Int(-7)));
assert!(matches!(parse_value("1.5"), Value::Float(f) if f == 1.5));
assert!(matches!(parse_value("hallo"), Value::Str(_)));
}
#[test]
fn as_int_coerces_like_ink_did() {
assert_eq!(Value::Bool(true).as_int(), Some(1));
assert_eq!(Value::Int(-7).as_int(), Some(-7));
assert_eq!(Value::Float(2.9).as_int(), Some(2));
assert_eq!(Value::Str("x".into()).as_int(), None);
}
#[test]
fn set_inc_clear_roundtrip() {
let mut s = Store::new();
apply_set("clicks 3", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 3);
assert_eq!(s["clicks"].as_int().unwrap(), 3);
apply_inc("clicks", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
assert_eq!(s["clicks"].as_int().unwrap(), 4);
apply_inc("clicks -2", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 2);
assert_eq!(s["clicks"].as_int().unwrap(), 2);
// inc auf unbekannten Key startet bei 0
apply_inc("fresh 5", &mut s);
assert_eq!(s["fresh"].coerce_to_int().unwrap(), 5);
assert_eq!(s["fresh"].as_int().unwrap(), 5);
apply_clear("clicks", &mut s);
assert!(!s.contains_key("clicks"));
@@ -99,4 +134,11 @@ mod tests {
apply_set("only_name", &mut s);
assert!(s.is_empty());
}
#[test]
fn display_is_plain_scalar() {
assert_eq!(Value::Bool(true).to_string(), "true");
assert_eq!(Value::Int(-3).to_string(), "-3");
assert_eq!(Value::Str("hi".into()).to_string(), "hi");
}
}
+14 -18
View File
@@ -3,42 +3,38 @@
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
//! Frontends (CLI-REPL jetzt, Renderer später) sind Geschwister-Module,
//! die `engine` konsumieren; die Richtung kehrt sich nie um.
//!
//! Interner Abhängigkeitsgraph (azyklisch, Pfeil = „benutzt"):
//! Interner Abhängigkeitsgraph (azyklisch halten, Pfeil = „benutzt"):
//!
//! kv ← ink ← game ← story_ctrl ← signals
//! ↑ ↑
//! kv assets
//!
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
//! signals::dispatch zu rufen).
//! Neue Querverbindungen lieber über Rückgabewerte an den Aufrufer lösen —
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
//! rufen.
//!
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
//! einem Export) und produziert das neutrale `model::Model`. `audio` ist
//! die headless Emitter-Logik (Distance ramps) zum Frontend-Treiber
//! render::audio.
//!
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
//! `gltf`, `image` und `wav` sind reine Decoder (Bytes → owned Daten);
//! `gltf` ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag
//! zwischen Loader und seinen Konsumenten (collision, trigger, audio,
//! render::props) — samt der Welt-Texturen, die als PNG in der `.glb` liegen.
//! `image` dekodiert PNG (eingebettet wie von der Platte) und stellt den
//! RGBA8-`Image`-Typ für alle.
//! `audio` ist die Emitter-Logik zum Frontend-Treiber render::audio, `player`
//! die First-Person-Physik auf der Welt aus `collision`. Post-Effekte kennt
//! der Kern nur als `game::Action::SetPost` — die Kette lebt in render::post.
pub mod assets;
pub mod audio;
pub mod collision;
pub mod game;
pub mod gltf;
pub mod image;
pub mod ink;
pub mod kv;
pub mod model;
pub mod player;
pub mod signals;
pub mod story_ctrl;
pub mod tga;
pub mod trigger;
pub mod wav;
+74 -12
View File
@@ -1,7 +1,14 @@
//! 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.
//! Das geparste Blender-Modell: Ergebnis des Loaders (engine::gltf) und der
//! Vertrag, auf den sich seine Konsumenten stützen — collision, trigger,
//! audio, render::props und session::load_props arbeiten alle auf dieser
//! einen Struktur, statt selbst glTF zu lesen. Eine Entkopplungsgrenze
//! zwischen dem einen Loader und seinen Konsumenten, keine Format-Abstraktion:
//! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind
//! Geschichte), `gltf` der einzige Produzent.
//!
//! Texturen kommen als Pixel mit — der Loader zieht sie aus der `.glb`
//! (baseColorTexture, PNG-Chunk im BIN), siehe [`Texture`]. Keine externe
//! Namenskonvention mehr.
//!
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
@@ -15,12 +22,14 @@
//!
//! 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.
//! der Loader bleibt frei von Signal-Wissen.
use std::collections::HashMap;
use crate::engine::image::Image;
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
/// Werte zu Strings vereinheitlicht. OBJ kann keine tragen → leer.
/// Werte zu Strings vereinheitlicht.
pub type Props = HashMap<String, String>;
pub struct Model {
@@ -28,10 +37,22 @@ pub struct Model {
/// 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>,
/// Distinkte Texturen in Auftrittsreihenfolge; `Object::tri_mats`
/// indiziert hierhin.
pub textures: Vec<Texture>,
}
/// Eine baseColorTexture aus der `.glb` (bzw. ein Material ohne Bild).
pub struct Texture {
/// Dedup-/Log-Schlüssel: der glTF-Bildname (Blender: Name des
/// Bild-Datablocks), sonst der Materialname, sonst `""`. Texturen mit
/// gleichem Schlüssel — auch über mehrere `.glb` — teilen sich auf der
/// GPU eine Textur.
pub key: String,
/// Dekodierte Pixel (RGBA8, Ursprung oben links). `None`: Material ohne
/// baseColorTexture oder unlesbares Bild → der Konsument nimmt seine
/// Platzhalter-Textur.
pub image: Option<Image>,
}
pub struct Object {
@@ -40,10 +61,13 @@ pub struct Object {
/// 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.
/// `image::Image`-Konvention, direkt sampelbar ohne Flip.
pub uvs: Vec<[f32; 2]>,
/// Normalen parallel zu `verts`, normiert, Welt-Raum. Fehlen sie im
/// Modell, rechnet der Loader sie aus den Flächen.
pub normals: Vec<[f32; 3]>,
pub tris: Vec<[usize; 3]>,
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
/// Textur-Index je Dreieck, parallel zu `tris` (→ `Model::textures`).
pub tri_mats: Vec<usize>,
pub visible: bool,
pub collider: bool,
@@ -77,6 +101,39 @@ pub const SIGNAL_PROP: &str = "signal";
pub const ROLE_PROP: &str = "role";
pub const ROLE_SPAWN: &str = "spawn";
/// Auf Länge 1 bringen; der Nullvektor bleibt der Nullvektor (Sentinel für
/// „noch keine Normale" in [`fill_missing_normals`]).
pub fn norm(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if l > 0.0 { [v[0] / l, v[1] / l, v[2] / l] } else { v }
}
/// Fehlende Normalen (Nullvektoren) aus den angrenzenden Flächen füllen —
/// glTF erlaubt Meshes ohne `NORMAL`. Die Flächennormalen werden je Vertex
/// aufsummiert, was an Kanten weich mittelt; Alternative wäre, die Vertices
/// je Fläche aufzuspalten (hartes Flat-Shading) — dafür ist der Look hier
/// nicht empfindlich genug.
pub fn fill_missing_normals(o: &mut Object) {
if !o.normals.contains(&[0.0; 3]) { return; }
let mut acc = vec![[0.0f32; 3]; o.verts.len()];
for t in &o.tris {
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let n = [
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
for &i in t {
for k in 0..3 { acc[i][k] += n[k]; }
}
}
for (i, n) in o.normals.iter_mut().enumerate() {
if *n == [0.0; 3] { *n = norm(acc[i]); }
}
}
/// 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.
@@ -119,6 +176,11 @@ pub(crate) fn test_cube(name: &str, min: [f32; 3], collide: Option<&str>, signal
let mut o = Object {
name: name.into(),
uvs: vec![[0.0, 0.0]; verts.len()],
// Vom Mittelpunkt nach außen — die Collider-Tests rendern nie, aber
// so ist die Richtung wenigstens plausibel.
normals: verts.iter()
.map(|v| norm([v[0] - min[0] - 0.5, v[1] - min[1] - 0.5, v[2] - min[2] - 0.5]))
.collect(),
verts, tris,
tri_mats: vec![0; n],
visible: true, collider: false, props,
+149 -48
View File
@@ -1,24 +1,15 @@
//! First-Person-Player: headless Bewegungsphysik (Stufe 1).
//! First-Person-Player: headless Bewegungsphysik.
//!
//! Bewusst frontend- und GPU-frei — wie der Rest von `engine`. Der Renderer
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`),
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
//! `step` sweept die Spieler-AABB gegen die [`CollisionWorld`] und gleitet an
//! Treffern entlang (move-and-slide). Getract wird der Box-Mittelpunkt
//! (Fußpunkt + halbe Höhe), danach geht es zurück auf den Fußpunkt.
//! Bodenkontakt fällt aus nach oben zeigenden Trefferflächen ab. `fly` ist der
//! Noclip-Gegenpart ohne Welt.
//!
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
//! Nutzer echte dot/cross/normalize braucht.
//!
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
//! Getract wird der Box-Mittelpunkt (Fußpunkt + halbe Höhe), danach geht es
//! zurück auf den Fußpunkt. Bodenkontakt fällt aus nach oben zeigenden
//! Trefferflächen ab.
//!
//! Konvention wie `render::math::view`/`camera.rs`: yaw=0 blickt −Z, positiver
//! Yaw dreht nach links, positiver Pitch hebt den Blick. Die Bewegung ist
//! horizontal aus dem Yaw (ohne Pitch) — Hochschauen lässt einen nicht abheben.
//! Der Renderer leitet seine View hier ab (`Mat4::view(eye, yaw, pitch)`), der
//! Player kennt ihn nie. Konvention: yaw=0 blickt −Z, positiver Yaw dreht nach
//! links, positiver Pitch hebt den Blick. Gelaufen wird horizontal aus dem Yaw
//! — Hochschauen hebt nicht ab, Fliegen dagegen folgt dem vollen Blick.
use crate::engine::collision::CollisionWorld;
@@ -26,19 +17,24 @@ use crate::engine::collision::CollisionWorld;
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
pub const EYE_HEIGHT: f32 = 1.6;
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
/// Fallbeschleunigung (units/s²). Über realem g für knackiges Spielgefühl.
const GRAVITY: f32 = 20.0;
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
const WALK_SPEED: f32 = 5.0;
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe.
const JUMP_SPEED: f32 = 7.0;
/// Flug-Geschwindigkeit im Noclip (units/s).
const FLY_SPEED: f32 = 4.0;
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
const PITCH_LIMIT: f32 = 1.55;
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
/// Ö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).
/// Grenzwinkel für begehbaren Boden, als Kosinus (= Y-Komponente der
/// Trefferormale einer Fläche mit diesem Neigungswinkel). 0.7 ≈ **45°**:
/// flacher wird gelaufen, steiler wird gerutscht. Das ist *der* Regler fürs
/// Hangverhalten — Wunschwinkel als Kosinus eintragen (30° → 0.866,
/// 50° → 0.643, 60° → 0.5).
const GROUND_NORMAL_Y: f32 = 0.7;
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
const MAX_SLIDES: u32 = 4;
@@ -77,48 +73,68 @@ impl Player {
[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.
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `math::view`. Trägt den
/// Point-and-Click-Strahl aus der Bildmitte und das Vorwärts im Noclip.
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]
}
/// Horizontales Rechts (ohne Pitch) — Strafen kippt nie mit dem Blick.
pub fn right_dir(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
[cy, 0.0, -sy]
}
/// Noclip: frei durch die Welt, ohne Kollision und ohne Gravitation.
/// Vorwärts folgt dem vollen Blick, `up` ist Welt-Y (Auf-/Absteigen bleibt
/// unabhängig vom Blickwinkel).
pub fn fly(&mut self, fwd: f32, right: f32, up: f32, dt: f32) {
let (f, r) = (self.look_dir(), self.right_dir());
for i in 0..3 {
self.pos[i] += (f[i] * fwd + r[i] * right) * FLY_SPEED * dt;
}
self.pos[1] += up * FLY_SPEED * dt;
self.vel = [0.0; 3];
self.grounded = false;
}
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
let was_grounded = self.grounded;
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0,
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach.
// Wunschgeschwindigkeit sofort setzen — ohne Beschleunigung/Reibung,
// mit voller Luftkontrolle. Vorwärts ist der flache Blick, damit
// Hochschauen nicht abhebt.
let (sy, cy) = self.yaw.sin_cos();
let f = [-sy, -cy]; // (x, z)
let r = [ cy, -sy]; // (x, z)
let r = self.right_dir();
self.vel[0] = (-sy * fwd + r[0] * right) * WALK_SPEED;
self.vel[2] = (-cy * fwd + r[2] * right) * WALK_SPEED;
// Wunschgeschwindigkeit sofort setzen (ohne Beschleunigung/Reibung —
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst.
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED;
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED;
// Sprung vor der Gravitation, nur vom Boden (Vorframe-`grounded`) —
// kein Doppelsprung.
// Sprung nur vom Boden — kein Doppelsprung. Gravitation wirkt nur in
// der Luft: am Boden bliebe sonst nach der Slide-Projektion ein
// hangparalleler Rest stehen, der sich über die Frames zum Hangabtrieb
// aufschaukelt. Steiler als [`GROUND_NORMAL_Y`] ist `grounded` ohnehin
// nicht gesetzt — dort greift der Luft-Zweig und man rutscht ab.
let jumped = jump && was_grounded;
if jumped {
self.vel[1] = JUMP_SPEED;
self.grounded = false;
}
} else if was_grounded {
self.vel[1] = 0.0;
} else {
self.vel[1] -= GRAVITY * dt;
}
self.move_and_slide(world, dt);
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter,
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
// Ground-Snap: stand er eben noch am Boden und schwebt nach dem Zug
// knapp darüber (abwärts-Slope, Stufe runter, Brush-Naht), ziehen wir
// ihn hinunter. Nicht beim Aufsteigen — sonst gäbe es keine
// Rampen-Absprünge mehr.
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
self.snap_to_ground(world);
}
@@ -142,11 +158,9 @@ impl Player {
///
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box-
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
/// runter auf Boden. Kommt das horizontal weiter und endet auf begehbarem
/// Boden, gewinnt es. Das hebt die flache Box-Unterkante über Brush-Nähte
/// (Slope-Kanten, Stufen), an denen sie sonst als „Wand" verkantet.
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
let half = HALF_EXTENTS;
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt
@@ -346,6 +360,22 @@ mod tests {
assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]);
}
#[test]
fn fly_ignores_gravity_and_geometry() {
// Noclip kennt die Welt gar nicht: nach unten durch jeden Boden, und
// vorwärts dem vollen Blick nach — ohne Gravitation.
let mut p = Player::new([0.0, 2.0, 0.0]);
p.fly(0.0, 0.0, -1.0, 1.0);
assert!(p.pos[1] < -1.0, "sollte durch den Boden sinken: {}", p.pos[1]);
assert_eq!(p.vel, [0.0; 3], "Flug trägt keine Geschwindigkeit");
p.pitch = std::f32::consts::FRAC_PI_2; // gerade nach oben schauen
let y0 = p.pos[1];
p.fly(1.0, 0.0, 0.0, 1.0);
assert!(p.pos[1] > y0 + 1.0, "vorwärts folgt dem Blick: {y0} -> {}", p.pos[1]);
assert!(!p.grounded);
}
#[test]
fn wall_blocks_horizontal_movement() {
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
@@ -462,6 +492,77 @@ mod tests {
])
}
/// Keilförmige Rampe, die in +X mit `deg` Grad ansteigt (Fuß bei x=0,
/// Länge 10). Bergab ist damit -X.
fn slope_world(deg: f32) -> CollisionWorld {
let (s, c) = deg.to_radians().sin_cos();
collision::from_brushes(vec![vec![
collision::Plane { n: [-s, c, 0.0], d: 0.0 }, // Rampenoberfläche
collision::Plane { n: [0.0, -1.0, 0.0], d: 0.0 }, // Unterseite (y≥0)
collision::Plane { n: [1.0, 0.0, 0.0], d: 10.0 }, // x≤10
collision::Plane { n: [-1.0, 0.0, 0.0], d: 0.0 }, // x≥0
collision::Plane { n: [0.0, 0.0, 1.0], d: 50.0 },
collision::Plane { n: [0.0, 0.0, -1.0], d: 50.0 },
]])
}
/// Auf eine Rampe fallen lassen und setzen — gibt den Ruhepunkt zurück.
fn settle(w: &CollisionWorld, x: f32, deg: f32) -> Player {
// Über der Fläche starten — mit Abstand, sonst steckt die aufgeblasene
// Box schon in der Rampe und die Trace lässt sie durchfallen.
let y = x * deg.to_radians().tan() + 1.0;
let mut p = Player::new([x, y, 0.0]);
for _ in 0..60 { p.step(w, 0.0, 0.0, false, 1.0 / 60.0); }
p
}
#[test]
fn stands_still_on_walkable_slope() {
// 30° < Grenzwinkel: ohne Eingabe darf der Spieler nicht abwärts
// kriechen — sonst ist der Hangabtrieb zurück.
let w = slope_world(30.0);
let mut p = settle(&w, 5.0, 30.0);
assert!(p.grounded, "sollte auf der 30°-Rampe stehen: {:?}", p.pos);
let (x0, y0) = (p.pos[0], p.pos[1]);
for _ in 0..300 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } // 5 Sekunden
assert!((p.pos[0] - x0).abs() < 0.01, "kein Hangabtrieb: x {x0} -> {}", p.pos[0]);
assert!((p.pos[1] - y0).abs() < 0.01, "keine Höhendrift: y {y0} -> {}", p.pos[1]);
assert!(p.grounded, "bleibt am Boden");
}
#[test]
fn slides_down_steep_slope() {
// 60° > Grenzwinkel: hier soll er abrutschen (bergab = -X).
let w = slope_world(60.0);
let mut p = settle(&w, 5.0, 60.0);
assert!(!p.grounded, "zu steil zum Stehen: {:?}", p.pos);
let (x0, y0) = (p.pos[0], p.pos[1]);
for _ in 0..60 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); }
assert!(p.pos[0] < x0 - 0.5, "sollte hangabwärts rutschen: x {x0} -> {}", p.pos[0]);
assert!(p.pos[1] < y0 - 0.5, "und dabei sinken: y {y0} -> {}", p.pos[1]);
}
#[test]
fn walks_up_and_down_walkable_slope() {
// Der stehenbleibende Spieler muss trotzdem beide Richtungen laufen.
let w = slope_world(30.0);
let mut up = settle(&w, 5.0, 30.0);
up.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X, bergauf
let y0 = up.pos[1];
for _ in 0..60 { up.step(&w, 1.0, 0.0, false, 1.0 / 60.0); }
assert!(up.pos[1] > y0 + 0.5, "bergauf steigt y: {y0} -> {}", up.pos[1]);
assert!(up.grounded, "bleibt bergauf am Boden");
let mut down = settle(&w, 5.0, 30.0);
down.yaw = std::f32::consts::FRAC_PI_2; // vorwärts → -X, bergab
let y0 = down.pos[1];
for i in 0..60 {
down.step(&w, 1.0, 0.0, false, 1.0 / 60.0);
assert!(down.grounded, "bergab am Boden kleben (i={i}, {:?})", down.pos);
}
assert!(down.pos[1] < y0 - 0.5, "bergab sinkt y: {y0} -> {}", down.pos[1]);
}
#[test]
fn walks_down_slope_across_seams_at_60fps() {
let w = slope_between_floors();
+41 -3
View File
@@ -18,6 +18,9 @@
//! `assets/sounds/{name}.wav`
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
//! Eingabemodus wechseln
//! `post <name> <on|off>` → Action::SetPost (deferred): Renderer-
//! Post-Effekt schalten (Name = Kettenglied
//! im Fenster-Frontend)
//!
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
@@ -101,11 +104,29 @@ fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
"mode" => if let Some(t) = ModeTarget::parse(args) {
ctx.actions.push(Action::SetMode(t));
},
"post" => if let Some((name, on)) = parse_post_arg(args) {
ctx.actions.push(Action::SetPost { name, on });
},
_ => return false, // Unbekanntes Verb — der Aufrufer meldet ggf.
}
true
}
/// `"<name> on"` / `"<name> off"` → `(name, bool)`; sonst `None`. Kein
/// nackter Toggle — eine geskriptete Schaltung soll lesbar sagen, wohin sie
/// schaltet. Geteilt von der Dispatch-Pipeline (`post`-Verb) und dem
/// `post`-Konsolenbefehl in `session`.
pub fn parse_post_arg(args: &str) -> Option<(String, bool)> {
let (name, state) = args.trim().split_once(char::is_whitespace)?;
let on = match state.trim() {
"on" => true,
"off" => false,
_ => return None,
};
let name = name.trim();
(!name.is_empty()).then(|| (name.to_string(), on))
}
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
/// das Format ist trivial und die Engine bleibt dependency-arm.
@@ -188,7 +209,7 @@ mod tests {
let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
dispatch("pickup", &mut ctx);
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
assert_eq!(game.kv["items"].as_int().unwrap(), 1);
assert_eq!(game.actions, vec![
Action::PlaySound("Mushroom.005".into()),
]);
@@ -208,6 +229,23 @@ mod tests {
assert!(game.actions.is_empty());
}
#[test]
fn post_verb_queues_setpost_action() {
let mut game = Game::new(Signals::new());
let mut ctx = game.action_ctx(None);
dispatch("post outline off", &mut ctx);
assert_eq!(game.actions, vec![
Action::SetPost { name: "outline".into(), on: false },
]);
// Fehlendes/ungültiges on|off → kein Action (stilles No-Op wie `mode`).
game.actions.clear();
let mut ctx = game.action_ctx(None);
dispatch("post outline", &mut ctx);
dispatch("post outline wat", &mut ctx);
assert!(game.actions.is_empty());
}
#[test]
fn typo_verb_in_table_is_tolerated_and_reported() {
// Ein Tippfehler-Verb in einer Table-Action wird gemeldet (stderr) und
@@ -221,7 +259,7 @@ mod tests {
let mut ctx = game.action_ctx(None);
dispatch("thing", &mut ctx);
assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
assert!(game.kv["ok"].coerce_to_bool().unwrap(), "gültige Action wirkt weiter");
assert!(matches!(game.kv["ok"], kv::Value::Bool(true)), "gültige Action wirkt weiter");
}
#[test]
@@ -230,7 +268,7 @@ mod tests {
let mut ctx = game.action_ctx(None);
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
dispatch("set has_key true", &mut ctx);
assert!(game.kv["has_key"].coerce_to_bool().unwrap());
assert!(matches!(game.kv["has_key"], kv::Value::Bool(true)));
// Unbekanntes Verb ist ein stilles No-Op.
let mut ctx = game.action_ctx(None);
+2 -3
View File
@@ -1,7 +1,6 @@
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
//! treiben. Frontend-agnostisch — den `StoryState` gießt der Aufrufer selbst
//! in seine Darstellung.
//!
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
-150
View File
@@ -1,150 +0,0 @@
//! TGA-Decoder → fertig dekodiertes RGBA8-`Image`.
//!
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die
//! RGB-Umrechnung jedem Konsumenten einzeln überließ.) Konsumenten ziehen
//! ein `Image` und legen es ab, wie sie es brauchen: Renderer → GPU-Textur,
//! UI/Font → eigene Interpretation. Die UV-/V-Achsen-Konvention ist damit
//! Sache des Konsumenten beim Sampling, nicht des Loaders.
//!
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
//! Font-Maps. Paletten-TGAs gibt es nicht mehr (wds ist 15-bit True-Color).
pub struct Image {
pub width: u32,
pub height: u32,
/// `width * height * 4`, Ursprung oben-links.
pub rgba: Vec<u8>,
}
pub fn load(path: &str) -> Result<Image, String> {
let bytes = std::fs::read(path).map_err(|e| format!("tga load {path}: {e}"))?;
decode(&bytes).map_err(|e| format!("tga {path}: {e}"))
}
pub fn decode(d: &[u8]) -> Result<Image, String> {
if d.len() < 18 { return Err("Header zu kurz".into()); }
let id_len = d[0] as usize;
let cmap_type = d[1];
let image_type = d[2];
let width = u16::from_le_bytes([d[12], d[13]]) as u32;
let height = u16::from_le_bytes([d[14], d[15]]) as u32;
let depth = d[16];
let descriptor = d[17];
if cmap_type != 0 {
return Err("Color-Map-TGAs nicht unterstützt (True-Color only)".into());
}
// Pixeldaten beginnen nach Header + ID-Feld (Color-Map ist leer).
let off = 18 + id_len;
let channels = match (image_type, depth) {
(2 | 10, 24) => 3,
(2 | 10, 32) => 4,
(3 | 11, 8) => 1,
(t, b) => return Err(format!("nicht unterstützt: Typ {t}, {b} bpp")),
};
let n = (width as usize) * (height as usize);
if n == 0 { return Err("Nullgröße".into()); }
// Rohpixel (channels Bytes je Pixel), bei RLE entpackt.
let raw_len = n * channels;
let raw: Vec<u8> = match image_type {
2 | 3 => d.get(off..off + raw_len).ok_or("Pixeldaten zu kurz")?.to_vec(),
10 | 11 => decode_rle(d.get(off..).ok_or("RLE-Daten fehlen")?, raw_len, channels)?,
_ => unreachable!(),
};
// → RGBA8. True-Color liegt im TGA als BGR(A) vor; Graustufen replizieren.
let mut rgba = vec![0u8; n * 4];
for i in 0..n {
let (r, g, b, a) = match channels {
1 => { let v = raw[i]; (v, v, v, 255) }
3 => (raw[i * 3 + 2], raw[i * 3 + 1], raw[i * 3], 255),
4 => (raw[i * 4 + 2], raw[i * 4 + 1], raw[i * 4], raw[i * 4 + 3]),
_ => unreachable!(),
};
let o = i * 4;
rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = a;
}
// Ursprung oben-links erzwingen. Descriptor-Bit 5 gesetzt = top-origin.
if (descriptor >> 5) & 1 == 0 {
flip_vertical(&mut rgba, width as usize, height as usize);
}
Ok(Image { width, height, rgba })
}
/// TGA-RLE: Pakete operieren auf ganzen Pixeln (`ch` Bytes).
fn decode_rle(src: &[u8], raw_len: usize, ch: usize) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(raw_len);
let mut si = 0;
while out.len() < raw_len {
let header = *src.get(si).ok_or("RLE: Quelle zu kurz")?;
si += 1;
let count = (header & 0x7f) as usize + 1;
if header & 0x80 != 0 {
// RLE-Paket: ein Pixel count-mal.
let px = src.get(si..si + ch).ok_or("RLE: Pixel zu kurz")?;
si += ch;
for _ in 0..count { out.extend_from_slice(px); }
} else {
// Raw-Paket: count Pixel am Stück.
let bytes = count * ch;
let chunk = src.get(si..si + bytes).ok_or("RLE: Chunk zu kurz")?;
si += bytes;
out.extend_from_slice(chunk);
}
}
out.truncate(raw_len);
Ok(out)
}
fn flip_vertical(rgba: &mut [u8], w: usize, h: usize) {
let row = w * 4;
for y in 0..h / 2 {
let (a, b) = (y * row, (h - 1 - y) * row);
for x in 0..row { rgba.swap(a + x, b + x); }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bgr_to_rgba_uncompressed() {
// Minimal-TGA: Typ 2, 2×1, 24 bpp, bottom-origin (descriptor 0).
// Zwei Pixel BGR (10,20,30) und (40,50,60).
let mut d = vec![0u8; 18];
d[2] = 2; d[12] = 2; d[14] = 1; d[16] = 24;
d.extend_from_slice(&[10, 20, 30, 40, 50, 60]);
let img = decode(&d).unwrap();
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(&img.rgba[0..4], &[30, 20, 10, 255]);
assert_eq!(&img.rgba[4..8], &[60, 50, 40, 255]);
}
#[test]
fn rle_truecolor_expands() {
// Typ 10, 4×1, 24 bpp: ein RLE-Paket (4× BGR 1,2,3).
let mut d = vec![0u8; 18];
d[2] = 10; d[12] = 4; d[14] = 1; d[16] = 24;
d.push(0x80 | 3); // RLE, count = 4
d.extend_from_slice(&[1, 2, 3]); // ein Pixel
let img = decode(&d).unwrap();
assert_eq!(img.rgba.len(), 4 * 4);
assert!(img.rgba.chunks(4).all(|p| p == [3, 2, 1, 255]));
}
#[test]
fn decodes_real_placeholder() {
let bytes = std::fs::read("assets/textures/placeholder.tga").unwrap();
let img = decode(&bytes).unwrap();
assert_eq!((img.width, img.height), (256, 256));
assert_eq!(img.rgba.len(), 256 * 256 * 4);
assert!(img.rgba.chunks(4).all(|p| p[3] == 255)); // 24bpp → Alpha 255
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ mod tests {
use crate::engine::model::{test_cube as cube, Object};
fn model(objects: Vec<Object>) -> Model {
Model { objects, empties: Vec::new(), materials: vec![String::new()] }
Model { objects, empties: Vec::new(), textures: Vec::new() }
}
#[test]
+2 -4
View File
@@ -1,9 +1,7 @@
//! 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.
//! Reiner Decoder (Bytes → owned Daten), `Result` statt Panic — Sounds sind
//! Autorinnen-Content, ein kaputtes File wird gemeldet und bleibt still.
//!
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
+17 -2
View File
@@ -5,6 +5,10 @@
//! darüber (Game + Dialog-Modus + Befehls-Interpreter). Beide Frontends
//! steuern dieselbe Session: das Fenster (`render`, Default) und die
//! Konsolen-REPL (`cli`, via `--cli`).
//!
//! Flags: `--cli` startet die Konsolen-REPL statt des Fensters; `--maps <dir>`
//! überschreibt das Verzeichnis der Welt-`.glb` (Pfad wie angegeben, also
//! relativ zum CWD).
mod cli;
mod engine;
@@ -15,11 +19,22 @@ use engine::assets;
use session::Session;
fn main() {
let args: Vec<String> = std::env::args().collect();
let signals_path = assets::path("assets/signals.toml");
let session = Session::new(signals_path.clone());
if std::env::args().any(|a| a == "--cli") {
if args.iter().any(|a| a == "--cli") {
cli::run(session, &signals_path);
} else {
render::run(session);
let maps_dir = arg_value(&args, "--maps")
.unwrap_or_else(|| assets::path(render::DEFAULT_MAPS_DIR));
render::run(session, &maps_dir);
}
}
/// Wert eines `--flag <wert>`-Paars aus `args`, falls das Flag vorkommt und
/// ein Wort folgt.
fn arg_value(args: &[String], flag: &str) -> Option<String> {
args.iter().position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
}
+3 -4
View File
@@ -2,10 +2,9 @@
//! 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).
//! 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,
-102
View File
@@ -1,102 +0,0 @@
//! Flycam: freie Debug-/Entwicklungskamera (Schritt 4). Position, Yaw und
//! Pitch in f32 — Port der irl3d-Kamera-Idee, aber ohne Q16.16: an dieser
//! Kamera hängt keine Physik, und der Renderer rechnet ohnehin f32.
//!
//! Konvention wie `math::Mat4::view`: yaw=0 blickt -Z, positiver Yaw dreht
//! nach links, positiver Pitch hebt den Blick.
use crate::render::math::Mat4;
/// Knapp unter 90°: hält den Blick aus der Senkrechten, wo Yaw bedeutungslos
/// würde (und `right`/`forward` zusammenfielen).
const PITCH_LIMIT: f32 = 1.55;
pub struct Camera {
pub pos: [f32; 3],
pub yaw: f32,
pub pitch: f32,
}
impl Camera {
pub fn new(pos: [f32; 3]) -> Self {
Self { pos, yaw: 0.0, pitch: 0.0 }
}
/// Roh-Maus-Delta (Pixel) → Yaw/Pitch. `sens` in Radiant pro Pixel.
/// Maus nach rechts dreht den Blick nach rechts (Yaw sinkt), Maus nach
/// unten senkt ihn; Pitch wird geclamped.
pub fn apply_mouse(&mut self, dx: f32, dy: f32, sens: f32) {
self.yaw -= dx * sens;
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
/// Blickrichtung inkl. Pitch — W/S bewegen sich hier entlang.
fn forward(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos();
[-sy * cp, sp, -cy * cp]
}
/// Horizontales Rechts (ohne Pitch) — A/D strafen hier entlang, ohne
/// dass der Blick nach oben/unten die Bewegung kippt.
fn right(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
[cy, 0.0, -sy]
}
/// Bewegung integrieren. `fwd`/`right`/`up` je in [-1, 1] (Tastenachsen),
/// `dist` ist die Strecke diesen Frame (Speed · dt). Hoch/runter ist
/// Welt-Y, damit Auf-/Absteigen unabhängig vom Blickwinkel bleibt.
pub fn translate(&mut self, fwd: f32, right: f32, up: f32, dist: f32) {
let f = self.forward();
let r = self.right();
for i in 0..3 {
self.pos[i] += (f[i] * fwd + r[i] * right) * dist;
}
self.pos[1] += up * dist;
}
pub fn view(&self) -> Mat4 {
Mat4::view(self.pos, self.yaw, self.pitch)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Die Bewegungs-Basis (`forward`) muss exakt zu der Richtung passen,
/// die `view()` als „vor der Kamera" definiert — sonst driften
/// Steuerung und Bild auseinander. Ein Punkt `d` vor der Kamera muss
/// in View-Space auf (0, 0, -d) landen.
#[test]
fn forward_matches_view() {
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
let c = Camera { pos: [1.0, 2.0, 3.0], yaw, pitch };
let f = c.forward();
let ahead = [
c.pos[0] + f[0] * 5.0,
c.pos[1] + f[1] * 5.0,
c.pos[2] + f[2] * 5.0,
1.0,
];
let v = c.view().transform(ahead);
assert!(
(v[0]).abs() < 1e-4 && (v[1]).abs() < 1e-4 && (v[2] + 5.0).abs() < 1e-4,
"yaw={yaw} pitch={pitch}: {v:?}",
);
}
}
/// `right` muss horizontal sein und im View-Space nach +X zeigen
/// (sonst wären A/D vertauscht oder gekippt).
#[test]
fn right_is_horizontal_and_points_view_right() {
let c = Camera { pos: [0.0, 0.0, 0.0], yaw: 0.6, pitch: 0.3 };
let r = c.right();
assert!(r[1].abs() < 1e-6, "right darf kein Y haben: {r:?}");
let p = [r[0] * 5.0, r[1] * 5.0, r[2] * 5.0, 1.0];
let v = c.view().transform(p);
assert!(v[0] > 4.0, "right sollte nach View-+X zeigen: {v:?}");
}
}
+30
View File
@@ -0,0 +1,30 @@
// Post-Effekt „dither": RGB555-Quantisierung mit 4×4-Bayer-Ordered-Dither —
// die PS1-Farbtiefe. Gehört ans Ende der Kette: quantisieren ist der letzte
// Schritt vor der Ausgabe.
//
// Vertex-Stufe kommt aus fullscreen.wgsl. Eingang ist das Bild des
// vorherigen Kettenglieds, 1:1 per textureLoad — kein Sampler, keine
// Filterung, ein Ziel-Pixel liest genau ein Quell-Pixel.
@group(0) @binding(0) var src: texture_2d<f32>;
// 4×4-Bayer-Matrix (Werte 0..15).
fn bayer4(px: vec2u) -> f32 {
var m = array<u32, 16>(
0u, 8u, 2u, 10u,
12u, 4u, 14u, 6u,
3u, 11u, 1u, 9u,
15u, 7u, 13u, 5u,
);
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
}
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let px = vec2u(pos.xy);
let c = clamp(textureLoad(src, vec2i(px), 0).rgb, vec3f(0.0), vec3f(1.0));
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
// Verläufe zerfallen ins typische Dither-Muster.
let t = (bayer4(px) + 0.5) / 16.0;
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
}
+3 -3
View File
@@ -17,7 +17,7 @@
//! seinen GPU-Textur-Index, damit `ui::Ui::text` ohne Zusatzargumente damit
//! zeichnen kann.
use crate::engine::tga::Image;
use crate::engine::image::Image;
const COLS: u32 = 32;
@@ -47,11 +47,11 @@ pub(crate) struct Fonts {
}
impl Font {
/// EGA 8×14 (`assets/textures/fonts/oldschool-ega-8x14.tga`, 256×112).
/// EGA 8×14 (`assets/textures/fonts/oldschool-ega-8x14.png`, 256×112).
pub(crate) fn ega(tex: usize, atlas: &Image) -> Font {
Font::measure(8.0, 14.0, 256.0, 112.0, tex, atlas)
}
/// CGA 8×8 (`assets/textures/fonts/oldschool-cga-8x8.tga`, 256×64).
/// CGA 8×8 (`assets/textures/fonts/oldschool-cga-8x8.png`, 256×64).
pub(crate) fn cga(tex: usize, atlas: &Image) -> Font {
Font::measure(8.0, 8.0, 256.0, 64.0, tex, atlas)
}
+19
View File
@@ -0,0 +1,19 @@
// Vertex-Stufe *aller* Post-Effekte: ein Dreieck über den ganzen Schirm.
//
// Bewusst ein eigenes Modul: die Pipeline nimmt die Vertex-Stufe von hier und
// die Fragment-Stufe aus der Effekt-Datei. So besteht eine Effekt-Datei nur
// aus ihrem `fs_main` und kennt keinen anderen Shader.
struct VsOut {
@builtin(position) pos: vec4f,
};
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VsOut {
var xy = array<vec2f, 3>(
vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0),
);
var o: VsOut;
o.pos = vec4f(xy[i], 0.0, 1.0);
return o;
}
+84 -50
View File
@@ -1,10 +1,12 @@
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
//! aus dem Renderer-Plan:
//! wgpu-Zustand: Surface, Device und der interne Render-Pfad:
//!
//! Pass 1 (intern): 640×480 RGBA8 + Depth — hier entsteht das Bild
//! ([`ScenePass`], PS1-Shader).
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
//! 4:3-Letterbox (via Viewport) auf die Surface.
//! Szene (intern): 640×480 RGBA8 + Depth — Geometrie und Textur
//! ([`ScenePass`]).
//! Post (intern): die Effekt-Kette ([`Post`]), Ping-Pong zwischen zwei
//! Targets; jeder Effekt einzeln schaltbar.
//! Overlay (intern): das UI ([`SpritePass`]) — *nach* der Kette, damit
//! Bild-Effekte nur die Welt treffen, nie den Text.
//! Blit (Fenster): Nearest-Upscale mit 4:3-Letterbox (via Viewport).
//!
//! 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
@@ -14,8 +16,9 @@ use std::sync::Arc;
use winit::window::Window;
use crate::engine::tga::Image;
use crate::engine::image::Image;
use crate::render::math::Mat4;
use crate::render::post::{Post, PostChain};
use crate::render::scene::{Mesh, ScenePass};
use crate::render::sprite::SpritePass;
use crate::render::ui::Ui;
@@ -28,19 +31,27 @@ pub const INTERNAL_H: u32 = 480;
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm;
const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Near-/Far-Ebene der Projektion. Auch die Post-Kette bekommt sie (als
/// Uniform), damit kein Effekt-Shader die Werte abschreiben muss, um die
/// Tiefe zu linearisieren — sonst lügen sie, sobald die Kamera sich ändert.
const NEAR_FAR: [f32; 2] = [0.1, 100.0];
pub struct Gpu {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
internal_view: wgpu::TextureView,
depth_view: wgpu::TextureView,
scene: ScenePass,
post: Post,
sprite: SpritePass,
blit_pipeline: wgpu::RenderPipeline,
blit_bind: wgpu::BindGroup,
/// Blit-Bind-Group je Post-Target — welche gilt, sagt [`Post::run`].
blit_binds: [wgpu::BindGroup; 2],
/// Nullpunkt der Shader-Zeit (Post-Uniform).
start: std::time::Instant,
}
impl Gpu {
@@ -98,19 +109,21 @@ impl Gpu {
};
surface.configure(&device, &config);
let (internal_view, depth_view) = make_internal_targets(&device);
let depth_view = make_depth_target(&device);
// Pass 1: die Szene. Geometrie + Bilder kommen fertig vom Aufrufer
// (Decode in render::run); hier wird nur hochgeladen.
// Szene: Geometrie + Bilder kommen fertig vom Aufrufer (Decode in
// render::run); hier wird nur hochgeladen.
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
let post = Post::new(
&device, INTERNAL_FORMAT, [INTERNAL_W, INTERNAL_H], &depth_view, NEAR_FAR);
// Overlay-Pass: zeichnet nach der Szene ins selbe interne Target.
// Overlay: eigener Pass hinter der Kette, ohne Tiefe.
let sprite = SpritePass::new(
&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT,
&device, &queue, INTERNAL_FORMAT,
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures,
);
// Pass 2: internes Target nearest-gesampelt auf die Surface.
// Blit: das fertige interne Bild nearest-gesampelt auf die Surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
@@ -142,25 +155,30 @@ impl Gpu {
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
let blit_layout = blit_pipeline.get_bind_group_layout(0);
let blit_bind = |view: &wgpu::TextureView| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit"),
layout: &blit_pipeline.get_bind_group_layout(0),
layout: &blit_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&internal_view),
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
})
};
let blit_binds = [blit_bind(post.view(0)), blit_bind(post.view(1))];
Self {
surface, device, queue, config,
internal_view, depth_view,
scene, sprite, blit_pipeline, blit_bind,
depth_view,
scene, post, sprite, blit_pipeline, blit_binds,
start: std::time::Instant::now(),
}
}
@@ -179,17 +197,18 @@ impl Gpu {
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
}
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer (Flycam);
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer;
/// die Projektion gehört hierher, weil sie an der internen Auflösung
/// hängt.
pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
pub fn frame(&mut self, view: &Mat4, ui: &Ui, post: &PostChain) {
let proj = Mat4::perspective(
60f32.to_radians(),
INTERNAL_W as f32 / INTERNAL_H as f32,
0.1, 100.0,
NEAR_FAR[0], NEAR_FAR[1],
);
let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5];
self.scene.prepare(&self.queue, &proj.mul(view), half_res);
self.post.prepare(&self.queue, self.start.elapsed().as_secs_f32());
self.sprite.prepare(&self.queue, &ui.verts);
use wgpu::CurrentSurfaceTexture as Cst;
@@ -208,19 +227,22 @@ impl Gpu {
let surface_view = frame.texture.create_view(&Default::default());
let mut enc = self.device.create_command_encoder(&Default::default());
// Pass 1: intern.
// Szene füllt den G-Buffer: Albedo ins erste Post-Target, Normale ins
// zweite Attachment.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("internal"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.internal_view,
let [albedo, normal] = self.post.scene_targets();
let attach = |view| Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
});
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene"),
color_attachments: &[attach(albedo), attach(normal)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
@@ -234,11 +256,33 @@ impl Gpu {
multiview_mask: None,
});
self.scene.draw(&mut pass);
// Overlay obenauf, im selben Pass (kein Tiefentest).
}
// Effekt-Kette; sie sagt, welches Target das Bild jetzt trägt.
let out = self.post.run(&mut enc, post);
// Overlay obendrauf — nach der Kette, also von keinem Effekt berührt.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("overlay"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: self.post.view(out),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.sprite.draw(&mut pass, &ui.batches);
}
// Pass 2: Letterbox-Blit aufs Fenster.
// Letterbox-Blit aufs Fenster.
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blit"),
@@ -260,7 +304,7 @@ impl Gpu {
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
pass.set_pipeline(&self.blit_pipeline);
pass.set_bind_group(0, &self.blit_bind, &[]);
pass.set_bind_group(0, &self.blit_binds[out], &[]);
pass.draw(0..3, 0..1);
}
@@ -269,31 +313,21 @@ impl Gpu {
}
}
fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::TextureView) {
let size = wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
};
let color = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal color"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERNAL_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
fn make_depth_target(device: &wgpu::Device) -> wgpu::TextureView {
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: Some("internal depth"),
size,
size: wgpu::Extent3d {
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
// TEXTURE_BINDING: die Post-Kette liest die Tiefe (Outline, Nebel).
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
(color.create_view(&Default::default()), depth.create_view(&Default::default()))
depth.create_view(&Default::default())
}
/// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert.
+28 -8
View File
@@ -1,10 +1,8 @@
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective.
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
//! ist nur der GPU-Pfad.)
//! statt glam: nötig sind nur Multiply, View und Perspective.
//!
//! Konventionen:
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export)
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie der Blender-Export)
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
//! bytemuck-kompatibel zu WGSL `mat4x4f`
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
@@ -67,8 +65,7 @@ impl Mat4 {
}
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z,
/// positiver Pitch hebt den Blick.
/// herausdrehen. yaw=0 blickt -Z, positiver Pitch hebt den Blick.
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
Mat4::rot_x(-pitch)
.mul(&Mat4::rot_y(-yaw))
@@ -104,8 +101,7 @@ mod tests {
#[test]
fn view_yaw_quarter_turn_looks_minus_x() {
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf
// -X liegt dann vor der Kamera.
// yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt vor der Kamera.
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
}
@@ -116,6 +112,30 @@ mod tests {
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
}
/// Die Bewegungs-Basis des Players muss exakt zu der Richtung passen, die
/// `view` als „vor der Kamera" definiert — sonst driften Steuerung und Bild
/// auseinander. Ein Punkt `d` voraus muss im View-Space auf (0, 0, -d)
/// landen, `right_dir` auf +X und ohne Y-Anteil.
#[test]
fn view_matches_player_basis() {
use crate::engine::player::Player;
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
let mut p = Player::new([1.0, 2.0, 3.0]);
(p.yaw, p.pitch) = (yaw, pitch);
let v = Mat4::view(p.pos, p.yaw, p.pitch);
let f = p.look_dir();
let ahead = [p.pos[0] + f[0] * 5.0, p.pos[1] + f[1] * 5.0, p.pos[2] + f[2] * 5.0, 1.0];
assert!(close(v.transform(ahead), [0.0, 0.0, -5.0, 1.0]),
"look_dir bei yaw={yaw} pitch={pitch}: {:?}", v.transform(ahead));
let r = p.right_dir();
assert!(r[1].abs() < 1e-6, "right_dir darf kein Y haben: {r:?}");
let side = v.transform([p.pos[0] + r[0] * 5.0, p.pos[1], p.pos[2] + r[2] * 5.0, 1.0]);
assert!(side[0] > 4.0, "right_dir sollte nach View-+X zeigen: {side:?}");
}
}
#[test]
fn perspective_maps_near_far_to_0_1() {
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
+82 -93
View File
@@ -1,24 +1,18 @@
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
//!
//! Stand: Schritt 4 des Renderer-Plans (notes/renderer-plan.md) — Flycam
//! über dem PS1-Szenen-Pass. WASD bewegt, Maus blickt (Klick fängt den
//! Cursor, Escape gibt ihn frei bzw. beendet), Space/Shift hoch/runter.
//! Als Nächstes: OBJ-Szene (Schritt 5).
//!
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen
//! anderen Zustand als die Konsole.
//! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
//!
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt.
mod audio;
mod camera;
mod font;
mod gpu;
mod math;
mod post;
mod props;
mod scene;
mod sprite;
@@ -35,46 +29,55 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{CursorGrabMode, Window, WindowId};
use crate::engine::image::{self, Image};
use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga, wav};
use crate::engine::{assets, gltf, model, wav};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
use math::Mat4;
use scene::Mesh;
/// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die
/// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt.
const MOUSE_SENS: f32 = crate::session::LOOK_SENS;
/// Welt-Einheiten pro Sekunde.
const MOVE_SPEED: f32 = 4.0;
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
/// folgen (wie Source/HL2). Größer = weicher, aber träger.
/// folgen. Größer = weicher, aber träger.
const EYE_SMOOTH_TAU: f32 = 0.06;
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
const EYE_MAX_LAG: f32 = 0.6;
pub fn run(mut session: Session) {
/// Default-Verzeichnis der Welt-`.glb` (relativ zum Asset-Basisverzeichnis).
/// `main` löst es auf und lässt `--maps <dir>` es überschreiben.
pub const DEFAULT_MAPS_DIR: &str = "assets/maps/props";
pub fn run(mut session: Session, maps_dir: &str) {
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); }
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
// nie fatal.
let models = load_models(&assets::path("assets/maps/props"));
// Die Welt sind alle .glb unter `maps_dir` (Default DEFAULT_MAPS_DIR, per
// `--maps <dir>` überschrieben). Decode (CPU) hier, Upload (GPU) später in
// `Gpu` — die beiden bleiben getrennt.
let models = load_models(maps_dir);
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
// denselben Index-Raum benutzen und zu einem Buffer verschmelzen können.
// Eine geteilte Textur-Schlüsselliste über alle Modelle, damit ihre
// Meshes denselben Index-Raum benutzen und zu einem Buffer verschmelzen.
let mut tex_names: Vec<String> = Vec::new();
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
for (_, m) in &models { tex_names.extend(props::texture_keys(m)); }
tex_names.sort_unstable();
tex_names.dedup();
let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();
// Die Pixel liegen in den `.glb` selbst; gleicher Schlüssel = eine Textur
// (erstes Vorkommen gewinnt). Schlüssel ohne Bild treffen den Platzhalter
// (Projekt-Asset, darf hart scheitern).
let mut by_key: HashMap<&str, &Image> = HashMap::new();
for (_, m) in &models {
for t in &m.textures {
if let Some(img) = &t.image { by_key.entry(props::texture_key(t)).or_insert(img); }
}
}
let placeholder = placeholder();
let images: Vec<Image> = tex_names.iter()
.map(|k| by_key.get(k.as_str()).map_or_else(|| placeholder.clone(), |i| (*i).clone()))
.collect();
let mut mesh = Mesh::default();
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
@@ -98,18 +101,17 @@ pub fn run(mut session: Session) {
}
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
// Luminanz.
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie,
// ist die Installation kaputt, da darf es scheitern.
// FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
// und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
// Installation kaputt und es darf scheitern.
let load_keyed = |p: &str| {
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
ui::key_luminance(&image::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
};
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.tga");
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.tga");
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.png");
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.png");
let fonts = font::Fonts {
ega: font::Font::ega(ui::FONT_EGA, &ega_img),
cga: font::Font::cga(ui::FONT_CGA, &cga_img),
@@ -118,8 +120,8 @@ pub fn run(mut session: Session) {
ui::white_pixel(),
ega_img,
cga_img,
load_keyed("assets/textures/ui/cursors.tga"),
load_keyed("assets/textures/ui/panel_ornaments.tga"),
load_keyed("assets/textures/ui/cursors.png"),
load_keyed("assets/textures/ui/panel_ornaments.png"),
];
// stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
@@ -168,8 +170,8 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
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.
// Properties mitloggen: so sieht die Autorin, ob ihr Export
// `collide`, `signal` und `role` mitgenommen hat.
for o in &m.objects {
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
}
@@ -185,9 +187,6 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
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> {
@@ -200,19 +199,13 @@ fn load_sound(name: &str) -> Option<audio::Sound> {
}
}
/// 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)))
/// Die Platzhalter-Textur für Schlüssel ohne eigenes Bild in der `.glb`.
/// Projekt-Asset: fehlt sie, ist die Installation kaputt und es darf scheitern.
fn placeholder() -> Image {
let name = props::FALLBACK_TEXTURE;
image::load(&assets::path(&format!("assets/textures/{name}.png")))
.expect("Platzhalter-Textur (Projekt-Asset)")
}
}
}
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
/// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen
@@ -238,8 +231,6 @@ struct App {
gpu: Option<Gpu>,
session: Session,
rx: Receiver<String>,
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
camera: Camera,
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
eye_y: f32,
input: Input,
@@ -259,6 +250,9 @@ struct App {
/// melden, nicht jeden Klick).
audio: Option<audio::Audio>,
sfx_cache: HashMap<String, Option<audio::Sound>>,
/// Post-Effekt-Kette (Renderer-Zustand, nicht im Kern). Pro Frame aus
/// `session.take_post()` nachgeführt, dann an `gpu.frame`.
post: post::PostChain,
}
impl App {
@@ -271,15 +265,12 @@ impl App {
fonts: font::Fonts,
audio: Option<audio::Audio>,
) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
Self {
window: None,
gpu: None,
session,
rx,
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
camera: Camera::new([0.0, 1.0, 4.0]),
eye_y,
input: Input::default(),
last: None,
@@ -291,12 +282,16 @@ impl App {
fonts,
audio,
sfx_cache: HashMap::new(),
// Die Post-Kette gehört dem Renderer, nicht dem Kern: geschaltet
// wird sie über `Action::SetPost` (Trigger/Ink/Konsole), die die
// Session als `take_post`-Queue durchreicht.
post: post::PostChain::defaults(),
}
}
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
// Greif-Art: Locked (Flycam, relativer Blick) ↔ None (freie Maus, UI).
// Greif-Art: Locked (Spiel/Noclip, relativer Blick) ↔ None (freie Maus, UI).
fn set_grab(&mut self, grab: bool) {
let Some(window) = &self.window else { return; };
if grab {
@@ -366,7 +361,7 @@ impl ApplicationHandler for App {
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
// Klick nur vormerken; der nächste redraw entscheidet (mit der
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
// Flycam-Greifen.
// Maus-Greifen.
self.pending_click = true;
}
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
@@ -404,7 +399,7 @@ impl App {
KeyCode::KeyS => self.input.back = down,
KeyCode::KeyA => self.input.left = down,
KeyCode::KeyD => self.input.right = down,
// Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke,
// Space: im Noclip „hoch" (gehalten), im Spiel Sprung (Flanke,
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
KeyCode::Space => {
self.input.up = down;
@@ -416,38 +411,28 @@ impl App {
}
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
// dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert,
// während die Konsole den Dialog treibt.
let now = Instant::now();
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
// gibt es keine parallele Lock-Logik.
// Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
// (relativer Blick), Menü/Dialog geben sie frei fürs UI.
let fly = matches!(self.session.mode, Mode::Free);
let play = matches!(self.session.mode, Mode::FirstPerson);
let grab = fly || play;
if grab != self.input.grabbed { self.set_grab(grab); }
if fly {
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
let fwd = axis(self.input.fwd, self.input.back);
let right = axis(self.input.right, self.input.left);
let up = axis(self.input.up, self.input.down);
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
} else if play {
// Eingaben einsammeln und die Simulation in der Session treiben.
// Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
// die Session an ihrem Modus. Betretens-Zonen melden ihre
// Signal-Ausgaben wie exec-Befehle.
let input = FrameInput {
fwd: axis(self.input.fwd, self.input.back),
right: axis(self.input.right, self.input.left),
up: axis(self.input.up, self.input.down),
jump: self.input.jump,
look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy,
};
// 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
// Blick beim Fortsetzen um die aufgestaute Bewegung.
self.input.mouse_dx = 0.0;
@@ -467,11 +452,17 @@ impl App {
}
}
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
// In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
// die Kamera reagieren bzw. mit der Flycam synchron bleiben.
// Post-Effekt-Schaltungen (SetPost-Actions von Trigger/Ink/Konsole)
// auf die eigene Kette anwenden; unbekannter Name wird gemeldet, wie
// ein fehlendes WAV bei play_sound.
for (name, on) in self.session.take_post() {
if !self.post.set(&name, on) {
eprintln!("[post] unbekannter Effekt {name:?}");
}
}
// Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
// der Luft und außerhalb des Spiels folgt sie exakt.
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
if play && self.session.player.grounded {
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
@@ -481,16 +472,12 @@ impl App {
self.eye_y = target_eye;
}
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
// Sicht bleibt die First-Person-Sicht.
let view = if fly {
self.camera.view()
} else {
// Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
// nur die Sim ein, nicht die Sicht.
let p = self.session.player.pos;
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
};
let view = 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.
@@ -530,7 +517,9 @@ impl App {
}
}
if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &screen.ui); }
if let Some(gpu) = &mut self.gpu {
gpu.frame(&view, &screen.ui, &self.post);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// Post-Effekt „normals": zeigt den G-Buffer statt des Bildes, in drei
// waagerechten Bändern — Normale, Objektnummer, Tiefe. Standardmäßig aus;
// `post normals on` schaltet ihn zu.
//
// Kein Debug-Beiwerk, sondern die Probe aufs Exempel: Cel-Ramp und Outline
// rechnen aus genau diesen Puffern. Sieht das Bild hier falsch aus, liegt der
// Fehler im Loader oder im Szenen-Pass, nicht im Effekt.
@group(1) @binding(0) var gnormal: texture_2d<f32>;
@group(1) @binding(1) var gdepth: texture_depth_2d;
struct Uniforms {
inv_res: vec2f,
near: f32,
far: f32,
time: f32,
};
@group(2) @binding(0) var<uniform> u: Uniforms;
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let px = vec2i(pos.xy);
let band = i32(textureDimensions(gnormal).y) / 3;
let g = textureLoad(gnormal, px, 0);
if px.y < band {
// Kodierung rückgängig: [0,1] → [-1,1] → wieder in [0,1] zum Ansehen.
let n = g.xyz * 2.0 - 1.0;
return vec4f(n * 0.5 + 0.5, 1.0);
}
if px.y < 2 * band {
// Objektnummer: benachbarte Nummern sollen *verschieden aussehen*,
// deshalb drei teilerfremde Faktoren statt eines Grauverlaufs.
let id = g.w * 255.0;
return vec4f(fract(id * vec3f(0.13, 0.37, 0.71)), 1.0);
}
// Tiefe ist perspektivisch verteilt — linearisieren, sonst ist ab zwei
// Metern alles weiß. Near/Far kommen aus dem Uniform, nicht aus dem
// Shader: sie gehören zur Projektion des Renderers.
let z = textureLoad(gdepth, px, 0);
let lin = u.near * u.far / (u.far - z * (u.far - u.near));
return vec4f(vec3f(1.0 - lin / u.far), 1.0);
}
+145
View File
@@ -0,0 +1,145 @@
// Post-Effekt „outline": Kantenlinien aus dem G-Buffer, nicht aus dem Bild.
//
// Auf dem fertigen Bild zu suchen wäre falsch — der Dither zerlegt Flächen in
// Muster, und jede Texturkante wäre eine Kante. Gesucht sind Kanten der
// *Geometrie*, also drei unabhängige Signale:
//
// Tiefe — Sprünge in der Entfernung (Silhouetten, Vorder-/Hintergrund)
// Normale — Knicke in der Fläche (Zimmerecken, Kanten eines Würfels)
// ID — Objektgrenzen, auch wenn Tiefe *und* Normale gleich sind
// (Bild an der Wand, Teppich auf dem Boden)
//
// Jedes für sich reicht; genommen wird das stärkste. Tiefe und Normale laufen
// über einen 3×3-Sobel, die ID über einen simplen Vergleich mit den vier
// direkten Nachbarn — auf Ganzzahlen ist ein Gradient sinnlos.
@group(0) @binding(0) var src: texture_2d<f32>;
@group(1) @binding(0) var gnormal: texture_2d<f32>;
@group(1) @binding(1) var gdepth: texture_depth_2d;
struct Uniforms {
inv_res: vec2f,
near: f32,
far: f32,
time: f32,
};
@group(2) @binding(0) var<uniform> u: Uniforms;
// --- Regler ------------------------------------------------------------
// Beide Schwellen sind auf „Sobel geteilt durch 4" normiert, also grob:
// 1.0 = maximal möglicher Unterschied zwischen zwei Nachbarn.
/// Tiefenstufe *relativ zur Entfernung* — relativ, damit eine Kante in 20 m
/// genauso zählt wie dieselbe Kante in 2 m. Das schwächste der drei Signale:
/// flach gesehene Böden ändern ihre Tiefe von Pixel zu Pixel stark, ohne dass
/// dort eine Kante wäre. Zu niedrig → der Boden bekommt Streifen.
const DEPTH_T: f32 = 0.10;
/// Knick in der Normale. Ein rechter Winkel liegt bei ~1.4, eine sanfte
/// Wölbung weit darunter. Zu niedrig → Suzanne wird schraffiert.
const NORMAL_T: f32 = 0.40;
/// Strichstärke in Pixeln: Abstand, in dem der Kernel abtastet. 1 = dünnste
/// Linie, 2–3 wird deutlich. Die Gradienten werden durch den Abstand geteilt,
/// damit die beiden Schwellen oben ihre Bedeutung behalten und man nicht bei
/// jeder Breitenänderung neu justieren muss.
///
/// Verbreitert wird über den Abtastabstand, nicht durch Aufdicken der
/// gefundenen Linie: das kostet keinen einzigen zusätzlichen Texturzugriff.
/// Der Preis ist, dass Details, die schmaler als die Strichstärke sind, eine
/// doppelte Linie bekommen können — bei 1–3 Pixeln kein Thema.
const WIDTH: i32 = 2;
/// Farbe der Linie und wie stark sie deckt (1.0 = voll).
const LINE: vec3f = vec3f(0.0);
const STRENGTH: f32 = 1.0;
/// Objektgrenzen mitzeichnen. Aus wirkt es wie klassisches Cel-Shading
/// (nur Silhouetten und Knicke), an eher wie ein Comic-Panel, in dem jedes
/// Ding umrandet ist.
const USE_ID: bool = true;
// --- G-Buffer-Zugriff --------------------------------------------------
/// Tiefe in Metern statt im perspektivisch verteilten [0,1] des Puffers.
fn linear_depth(px: vec2i) -> f32 {
let z = textureLoad(gdepth, px, 0);
return u.near * u.far / (u.far - z * (u.far - u.near));
}
fn normal_at(px: vec2i) -> vec3f {
return textureLoad(gnormal, px, 0).xyz * 2.0 - 1.0;
}
fn id_at(px: vec2i) -> f32 {
return textureLoad(gnormal, px, 0).w;
}
// --- Kantensuche -------------------------------------------------------
/// Sobel-Betrag über die 3×3-Nachbarschaft, auf ~0..1 normiert (geteilt
/// durch 4, die Gewichtssumme einer Kernelhälfte).
fn sobel_depth(px: vec2i) -> f32 {
var gx = 0.0;
var gy = 0.0;
// Sobel-Gewichte, Zeile für Zeile: [-1 0 1] × [1 2 1].
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
let v = linear_depth(px + vec2i(dx, dy) * WIDTH);
let wx = f32(dx) * select(1.0, 2.0, dy == 0);
let wy = f32(dy) * select(1.0, 2.0, dx == 0);
gx += v * wx;
gy += v * wy;
}
}
return sqrt(gx * gx + gy * gy) / (4.0 * f32(WIDTH));
}
/// Dasselbe für die Normale: jede Komponente einzeln gefiltert, am Ende die
/// Länge über beide Richtungen.
fn sobel_normal(px: vec2i) -> f32 {
var gx = vec3f(0.0);
var gy = vec3f(0.0);
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
let v = normal_at(px + vec2i(dx, dy) * WIDTH);
let wx = f32(dx) * select(1.0, 2.0, dy == 0);
let wy = f32(dy) * select(1.0, 2.0, dx == 0);
gx += v * wx;
gy += v * wy;
}
}
return sqrt(dot(gx, gx) + dot(gy, gy)) / (4.0 * f32(WIDTH));
}
/// 1.0, sobald einer der vier direkten Nachbarn zu einem anderen Objekt
/// gehört. Nur ganze Pixel, keine Gradienten.
fn id_edge(px: vec2i) -> f32 {
let c = id_at(px);
let d = abs(id_at(px + vec2i( WIDTH, 0)) - c)
+ abs(id_at(px + vec2i(-WIDTH, 0)) - c)
+ abs(id_at(px + vec2i( 0, WIDTH)) - c)
+ abs(id_at(px + vec2i( 0,-WIDTH)) - c);
// Eine Nummer Unterschied sind 1/255 — alles darüber ist eine Grenze.
return select(0.0, 1.0, d > 0.5 / 255.0);
}
@fragment
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let px = vec2i(pos.xy);
let color = textureLoad(src, px, 0);
// Auf dem Hintergrund (nichts gezeichnet) keine Linie: sonst läge die
// Silhouette doppelt, je einmal innen und außen.
if id_at(px) == 0.0 { return color; }
let depth = sobel_depth(px) / linear_depth(px);
var edge = max(
step(DEPTH_T, depth),
step(NORMAL_T, sobel_normal(px)),
);
if USE_ID { edge = max(edge, id_edge(px)); }
return vec4f(mix(color.rgb, LINE, edge * STRENGTH), color.a);
}
+358
View File
@@ -0,0 +1,358 @@
//! Post-Kette: eigenständige Fullscreen-Effekte zwischen Szene und Overlay.
//!
//! Jeder Effekt ist eine eigene `.wgsl` mit *nur* einem `fs_main`; die
//! Vertex-Stufe teilen sich alle über `fullscreen.wgsl`. Der Vertrag ist
//! immer derselbe:
//!
//! group 0, binding 0: Farbe des vorherigen Kettenglieds
//! group 1, binding 0: Normale (`n*0.5+0.5`, w = Objekt-ID, reserviert)
//! group 1, binding 1: Tiefe
//! group 2, binding 0: [`Uniforms`] — Auflösung, Near/Far, Zeit
//!
//! Group 1 ist der G-Buffer aus dem Szenen-Pass, group 2 das, was jeder
//! Effekt über den Frame wissen kann; beide darf ein Effekt ignorieren. Kein
//! Effekt kennt einen anderen, keiner darf einen anderen voraussetzen. Ein
//! Effekt wird hinzugefügt, indem er in [`EFFECTS`] auftaucht; geschaltet
//! wird er über [`PostChain`], die Trigger, Ink-Tags und die Konsole per
//! `game::Action::SetPost` erreichen. Der Kern kennt die Kette nicht.
//!
//! Gerechnet wird auf zwei Ping-Pong-Targets in interner Auflösung. Bei
//! 640×480 kostet ein zusätzlicher Fullscreen-Pass praktisch nichts —
//! deshalb darf jeder Effekt seinen eigenen behalten, statt sie zu einem
//! Über-Shader zu verschmelzen.
/// Die Kette in Ausführungsreihenfolge, mit ihrem Startzustand. `dither`
/// quantisiert die Farbtiefe und muss deshalb zuletzt laufen.
const EFFECTS: &[(&str, bool, &str)] = &[
("normals", false, include_str!("normals.wgsl")),
("outline", true, include_str!("outline.wgsl")),
("dither", true, include_str!("dither.wgsl")),
];
/// An/Aus je Post-Effekt, in Ketten-Reihenfolge. Das Fenster hält sie als
/// eigenen Zustand (nicht der Kern), wendet die per `Session::take_post`
/// aufgelaufenen `SetPost`-Schaltungen jeden Frame an und gibt die Kette an
/// [`Post::run`].
pub struct PostChain {
effects: Vec<(String, bool)>,
}
impl PostChain {
/// Die Kette aus [`EFFECTS`] mit ihren Startzuständen.
pub fn defaults() -> Self {
Self {
effects: EFFECTS.iter().map(|(n, on, _)| ((*n).to_string(), *on)).collect(),
}
}
pub fn enabled(&self, name: &str) -> bool {
self.effects.iter().any(|(n, on)| n == name && *on)
}
/// Effekt `name` auf `on` setzen. Rückgabe: `false`, wenn kein Effekt so
/// heißt — der Aufrufer meldet das (wie ein fehlendes WAV bei `play_sound`).
pub fn set(&mut self, name: &str, on: bool) -> bool {
match self.effects.iter_mut().find(|(n, _)| n == name) {
Some(e) => { e.1 = on; true }
None => false,
}
}
}
/// Spiegelt `Uniforms` in den Effekt-Shadern. `near`/`far` müssen zur
/// Projektion des Aufrufers passen — sie kommen von dort, damit kein Shader
/// sie abschreibt und beim nächsten Ändern der Kamera stillschweigend lügt.
/// `_pad` rundet auf 32 Byte (16er-Vielfaches, WGSL-Uniform-Regel).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniforms {
inv_res: [f32; 2],
near: f32,
far: f32,
/// Sekunden seit Start — für animierte Effekte (Flackern, Wasser).
time: f32,
_pad: [f32; 3],
}
pub struct Post {
/// Ping-Pong-Targets. Die Szene zeichnet in `[0]`, jeder aktive Effekt
/// liest das eine und schreibt ins andere.
views: [wgpu::TextureView; 2],
/// Zweites Ziel des Szenen-Passes (Normale + ID).
normal_view: wgpu::TextureView,
/// Eingangs-Bind-Group je Target (group 0).
in_binds: [wgpu::BindGroup; 2],
/// Normale + Tiefe (group 1) — für alle Effekte dieselbe.
gbuf_bind: wgpu::BindGroup,
/// Frame-Uniforms (group 2), jeden Frame per [`Post::prepare`] gefüllt.
ubuf: wgpu::Buffer,
uniform_bind: wgpu::BindGroup,
inv_res: [f32; 2],
near_far: [f32; 2],
pipelines: Vec<(&'static str, wgpu::RenderPipeline)>,
}
impl Post {
pub fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
size: [u32; 2],
depth_view: &wgpu::TextureView,
near_far: [f32; 2],
) -> Self {
let views = [make_target(device, format, size, "post a"),
make_target(device, format, size, "post b")];
let normal_view = make_target(device, format, size, "gbuffer normal");
// Expliziter Layout statt `layout: None`: *der* Vertrag der Kette —
// alle Effekte teilen ihn, damit dieselben Bind-Groups für jede
// Pipeline passen.
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post input"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let in_binds = [make_bind(device, &bind_layout, &views[0]),
make_bind(device, &bind_layout, &views[1])];
// Der G-Buffer. Beide Texturen werden per `textureLoad` gelesen
// (1:1, kein Sampler) — deshalb steht hier keiner.
let gbuf_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post gbuffer"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let gbuf_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post gbuffer"),
layout: &gbuf_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&normal_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth_view),
},
],
});
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("post uniforms"),
size: size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post uniforms"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post uniforms"),
layout: &uniform_layout,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: ubuf.as_entire_binding() }],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("post"),
bind_group_layouts: &[Some(&bind_layout), Some(&gbuf_layout), Some(&uniform_layout)],
immediate_size: 0,
});
// Ein Fullscreen-Dreieck für alle: die Vertex-Stufe kommt aus einem
// anderen Modul als die Fragment-Stufe.
let fullscreen = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("fullscreen"),
source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen.wgsl").into()),
});
let pipelines = EFFECTS.iter().map(|(name, _, src)| {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(name),
source: wgpu::ShaderSource::Wgsl((*src).into()),
});
let p = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(name),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &fullscreen,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
(*name, p)
}).collect();
Self {
views, normal_view, in_binds, gbuf_bind,
ubuf, uniform_bind,
inv_res: [1.0 / size[0] as f32, 1.0 / size[1] as f32],
near_far,
pipelines,
}
}
/// Frame-Uniforms hochladen — vor [`Post::run`] rufen.
pub fn prepare(&self, queue: &wgpu::Queue, time: f32) {
let u = Uniforms {
inv_res: self.inv_res,
near: self.near_far[0],
far: self.near_far[1],
time,
_pad: [0.0; 3],
};
queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(&u));
}
/// Die Ziele des Szenen-Passes: Albedo (= Anfang der Kette) und Normale.
pub fn scene_targets(&self) -> [&wgpu::TextureView; 2] {
[&self.views[0], &self.normal_view]
}
pub fn view(&self, i: usize) -> &wgpu::TextureView { &self.views[i] }
/// Die aktiven Effekte der Reihe nach ausführen. Rückgabe: Index des
/// Targets, das jetzt das Bild trägt (0, wenn nichts aktiv war).
pub fn run(&self, enc: &mut wgpu::CommandEncoder, chain: &PostChain) -> usize {
let mut src = 0;
for (name, pipeline) in &self.pipelines {
if !chain.enabled(name) { continue; }
let dst = 1 - src;
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(name),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.views[dst],
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
// Jedes Pixel wird geschrieben — Clear ist nur der
// billigste definierte Startzustand.
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.in_binds[src], &[]);
pass.set_bind_group(1, &self.gbuf_bind, &[]);
pass.set_bind_group(2, &self.uniform_bind, &[]);
pass.draw(0..3, 0..1);
src = dst;
}
src
}
}
fn make_target(
device: &wgpu::Device,
format: wgpu::TextureFormat,
size: [u32; 2],
label: &str,
) -> wgpu::TextureView {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
tex.create_view(&Default::default())
}
fn make_bind(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
view: &wgpu::TextureView,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post input"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
}],
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_follow_effects_table() {
let c = PostChain::defaults();
// Startzustände aus EFFECTS.
assert!(!c.enabled("normals"));
assert!(c.enabled("outline"));
assert!(c.enabled("dither"));
}
#[test]
fn set_switches_and_reports_unknown() {
let mut c = PostChain::defaults();
assert!(c.set("outline", false));
assert!(!c.enabled("outline"));
assert!(c.set("outline", true));
assert!(c.enabled("outline"));
assert!(!c.set("bloom", true), "unbekannter Effekt");
}
#[test]
fn unknown_effect_is_off() {
assert!(!PostChain::defaults().enabled("bloom"));
}
}
+74 -28
View File
@@ -1,37 +1,45 @@
//! Blender-Modell → Render-Geometrie.
//!
//! Nimmt das neutrale [`Model`] aus engine::gltf und baut daraus ein
//! Nimmt das geparste [`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`].
//! Texturen liegen als Pixel im Modell (`model::Texture`, aus der `.glb`).
//! Hier wird nur nach ihrem Schlüssel gruppiert; Texturen ohne Bild —
//! Material ohne baseColorTexture oder unlesbar — landen unter
//! [`FALLBACK_TEXTURE`], dessen Pixel der Aufrufer stellt.
//!
//! UVs kommen unverändert durch (Modell und `tga::Image` teilen den
//! Oben-links-Ursprung). Nur `visible`-Objekte werden ausgegeben;
//! UVs kommen unverändert durch (Modell und `Image` teilen den
//! Oben-links-Ursprung; `KHR_texture_transform` hat der Loader schon
//! eingebacken). Nur `visible`-Objekte werden ausgegeben;
//! Collider-/Zonen-Volumen sind Sache von engine::collision und
//! engine::trigger.
use crate::engine::model::Model;
use crate::engine::model::{Model, Texture};
use crate::render::scene::{Batch, Mesh, Vertex};
/// Texturname für Faces ohne `usemtl` (Material `""`).
/// Schlüssel für Texturen ohne eigenes Bild; der Aufrufer lädt die Pixel
/// dazu (`assets/textures/placeholder.png`).
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())
/// Distinkte Textur-Schlüssel des Modells (sortiert), bildlose bereits auf
/// den Fallback gemappt.
pub fn texture_keys(model: &Model) -> Vec<String> {
let mut keys: Vec<String> = model.textures.iter()
.map(|t| texture_key(t).to_string())
.collect();
names.sort_unstable();
names.dedup();
names
keys.sort_unstable();
keys.dedup();
keys
}
fn resolve(material: &str) -> &str {
if material.is_empty() { FALLBACK_TEXTURE } else { material }
/// Gruppierungs-Schlüssel einer Textur: ihr eigener, sofern sie Pixel hat —
/// sonst der Fallback. Der Renderer sammelt darüber die Pixel je Schlüssel.
pub fn texture_key(t: &Texture) -> &str {
match &t.image {
Some(_) if !t.key.is_empty() => &t.key,
_ => FALLBACK_TEXTURE,
}
}
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt die Textur-
@@ -41,14 +49,19 @@ 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()];
// Laufende Objektnummer für den G-Buffer; nur sichtbare Objekte zählen,
// unsichtbare Collider tauchen im Bild ja nie auf. Ab 1, damit die 0 im
// Buffer „kein Objekt" heißt (der Hintergrund wird auf 0 geleert).
let mut id = 1;
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 ((p, uv), n) in o.verts.iter().zip(&o.uvs).zip(&o.normals) {
verts.push(Vertex { pos: *p, uv: *uv, normal: *n, id });
}
id += 1;
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
let name = resolve(&model.materials[m]);
let name = texture_key(&model.textures[m]);
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
per_tex[ti].extend(t.map(|i| base + i as u32));
}
@@ -62,20 +75,32 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
indices.extend_from_slice(&list);
}
Mesh { verts, indices, batches }
Mesh { verts, indices, batches, objects: id - 1 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::image::Image;
use crate::engine::model::{Model, Object, Props};
/// 1×1-Platzhalterbild, damit eine Textur „Pixel hat".
fn pixel() -> Image {
Image { width: 1, height: 1, rgba: vec![255, 255, 255, 255] }
}
/// Textur mit Bild und Schlüssel `key`.
fn tex(key: &str) -> Texture {
Texture { key: key.into(), image: Some(pixel()) }
}
/// 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]],
normals: vec![[0.0, 0.0, 1.0]; 3],
tris: vec![[0, 1, 2]],
tri_mats: vec![0],
visible, collider: !visible,
@@ -87,14 +112,14 @@ mod tests {
Model {
objects: vec![tri("Ding", true), tri("Proxy", false)],
empties: Vec::new(),
materials: vec!["carpet".to_string()],
textures: vec![tex("carpet")],
}
}
#[test]
fn builds_only_visible_objects() {
let m = sample();
let names = texture_names(&m);
let names = texture_keys(&m);
assert_eq!(names, vec!["carpet".to_string()]);
let mesh = build(&m, &names);
@@ -107,11 +132,32 @@ mod tests {
}
#[test]
fn missing_material_maps_to_fallback() {
fn visible_objects_get_distinct_ids_across_models() {
let names = texture_keys(&sample());
// Zwei sichtbare Objekte + ein unsichtbarer Proxy je Modell.
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));
m.objects.insert(1, tri("Zweites", true));
let mut mesh = build(&m, &names);
assert_eq!(mesh.objects, 2, "nur sichtbare zählen");
assert_eq!(mesh.verts[0].id, 1, "0 bleibt dem Hintergrund vorbehalten");
assert_eq!(mesh.verts[3].id, 2, "zweites Objekt, nächste Nummer");
// Zweites Modell anhängen: seine Nummern liegen über den bisherigen,
// sonst hielte der Outline-Pass Objekte aus zwei .glb für dasselbe.
mesh.append(build(&m, &names));
assert_eq!(mesh.objects, 4);
assert_eq!(mesh.verts[6].id, 3);
assert_eq!(mesh.verts[9].id, 4);
}
#[test]
fn imageless_texture_maps_to_fallback() {
let mut m = sample();
// Material ohne baseColorTexture: Schlüssel gesetzt, aber kein Bild.
m.textures = vec![Texture { key: "nackt".into(), image: None }];
assert_eq!(texture_keys(&m), vec![FALLBACK_TEXTURE.to_string()]);
let mesh = build(&m, &texture_keys(&m));
assert_eq!(mesh.batches.len(), 1);
}
+42 -14
View File
@@ -1,13 +1,13 @@
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
//!
//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via
//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group +
//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan).
//! render::props; Bilder als RGBA8-`Image`, aus der `.glb` dekodiert). Pro
//! Textur eine Bind-Group + ein Draw-Batch („pro Textur ein Draw" wie im Plan).
//! Die Shader (scene.wgsl) sind die echten PS1-Shader.
use wgpu::util::DeviceExt;
use crate::engine::tga::Image;
use crate::engine::image::Image;
use crate::render::math::Mat4;
#[repr(C)]
@@ -15,6 +15,14 @@ use crate::render::math::Mat4;
pub(crate) struct Vertex {
pub(crate) pos: [f32; 3],
pub(crate) uv: [f32; 2],
/// Welt-Normale, normiert — Eingang des G-Buffers (siehe render::post).
pub(crate) normal: [f32; 3],
/// Laufende Objektnummer. Der Szenen-Pass legt sie (mod 256) ins Alpha
/// des Normal-Buffers, damit der Outline-Pass Kanten zwischen zwei
/// Objekten findet, die sich berühren und gleich ausgerichtet sind —
/// Stuhl auf Boden, Bild an Wand. Benachbarte Nummern unterscheiden sich
/// immer; erst 256 Objekte auseinander fallen zwei zusammen.
pub(crate) id: u32,
}
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
@@ -33,6 +41,10 @@ pub(crate) struct Mesh {
pub(crate) verts: Vec<Vertex>,
pub(crate) indices: Vec<u32>,
pub(crate) batches: Vec<Batch>,
/// Vergebene Objektnummern (siehe [`Vertex::id`]) — [`Mesh::append`]
/// verschiebt die des Anhängsels darüber, damit Objekte aus verschiedenen
/// Modellen nicht dieselbe Nummer tragen.
pub(crate) objects: u32,
}
impl Mesh {
@@ -43,7 +55,12 @@ impl Mesh {
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);
let idbase = self.objects;
self.objects += other.objects;
self.verts.extend(other.verts.into_iter().map(|v| Vertex {
id: v.id + idbase,
..v
}));
self.indices.extend(other.indices.iter().map(|i| i + vbase));
self.batches.extend(other.batches.iter().map(|b| Batch {
texture: b.texture,
@@ -67,7 +84,9 @@ struct Uniforms {
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
attributes: &wgpu::vertex_attr_array![
0 => Float32x3, 1 => Float32x2, 2 => Float32x3, 3 => Uint32,
],
};
pub struct ScenePass {
@@ -106,7 +125,10 @@ impl ScenePass {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(color_format.into())],
// Zwei Ziele (MRT): Albedo und der Normal-/ID-Buffer. Beide
// im internen Format; zusammen sind sie der G-Buffer, aus dem
// die Post-Kette Beleuchtung und Kanten rechnet.
targets: &[Some(color_format.into()), Some(color_format.into())],
}),
// Back-face Culling: glTF-Dreiecke sind CCW von außen
// gewickelt — die Außenflächen sind front-facing, Rückseiten
@@ -168,7 +190,7 @@ impl ScenePass {
});
let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = images.iter()
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "scene texture", true))
.collect();
Self {
@@ -196,20 +218,26 @@ impl ScenePass {
}
}
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch.
fn upload_texture(
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige Bind-Group
/// (Textur + Sampler, group 1) bauen — geteilt mit [`crate::render::sprite`].
/// Mit `mips` entsteht die volle Mipchain (bis 1×1) per Box-Filter auf der
/// CPU; UI-Sprites brauchen sie nicht, sie werden nie verkleinert.
pub(crate) fn upload_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
img: &Image,
label: &str,
mips: bool,
) -> wgpu::BindGroup {
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
let mip_level_count = 32 - img.width.max(img.height).leading_zeros();
let mip_level_count = match mips {
true => 32 - img.width.max(img.height).leading_zeros(),
false => 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene texture"),
label: Some(label),
size: wgpu::Extent3d {
width: img.width,
height: img.height,
@@ -251,7 +279,7 @@ fn upload_texture(
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene texture"),
label: Some(label),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
+37 -25
View File
@@ -1,13 +1,14 @@
// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch
// gerechnet statt nachgestellt:
// - Pixel-Snap im Vertex-Shader → Vertex-Jitter
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader
// - 1-Bit-Alpha-Test (Cutouts), kein Blending
// Szenen-Pipeline: füllt den G-Buffer — Albedo (Ziel 0) und Normale (Ziel 1),
// dazu die Tiefe. Beleuchtet wird hier nichts; alles Bildweite — Farbtiefe,
// Dither, später Cel-Ramp und Outline — läuft als eigenes Kettenglied in
// render::post. Der Pixel-Snap im Vertex-Shader (PS1-Vertex-Jitter) gehört
// dagegen hierher, weil er Geometrie ist.
//
// 1-Bit-Alpha-Test (Cutouts), kein Blending.
//
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
// frei beweglichen Kamera und großen Faces zu störend — den Look tragen
// Pixel-Snap und Dither.
// frei beweglichen Kamera und großen Faces zu störend.
struct Uniforms {
mvp: mat4x4f,
@@ -24,10 +25,27 @@ struct VsOut {
@builtin(position) pos: vec4f,
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
@location(0) @interpolate(perspective) uv: vec2f,
@location(1) normal: vec3f,
// Ganzzahl → flat; interpolieren würde zwischen zwei Objektnummern
// Zwischenwerte erfinden.
@location(2) @interpolate(flat) id: u32,
};
struct FsOut {
@location(0) albedo: vec4f,
// xyz = Normale, aus [-1,1] in [0,1] geschoben (das Ziel ist unsigned).
// w = Objektnummer mod 256, damit der Outline-Pass auch Kanten zwischen
// gleich ausgerichteten Nachbarn findet.
@location(1) normal: vec4f,
};
@vertex
fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
fn vs_main(
@location(0) pos: vec3f,
@location(1) uv: vec2f,
@location(2) normal: vec3f,
@location(3) id: u32,
) -> VsOut {
var clip = u.mvp * vec4f(pos, 1.0);
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
@@ -39,20 +57,11 @@ fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
var out: VsOut;
out.pos = clip;
out.uv = uv;
out.normal = normal;
out.id = id;
return out;
}
// 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering.
fn bayer4(px: vec2u) -> f32 {
var m = array<u32, 16>(
0u, 8u, 2u, 10u,
12u, 4u, 14u, 6u,
3u, 11u, 1u, 9u,
15u, 7u, 13u, 5u,
);
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
}
// LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher
// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher.
// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr
@@ -60,13 +69,16 @@ fn bayer4(px: vec2u) -> f32 {
const MIP_BIAS: f32 = -1.0;
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4f {
fn fs_main(in: VsOut) -> FsOut {
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
// Verläufe zerfallen ins typische Dither-Muster.
let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0;
let c = clamp(texel.rgb, vec3f(0.0), vec3f(1.0));
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
var out: FsOut;
out.albedo = vec4f(texel.rgb, 1.0);
// Interpolieren verkürzt die Normale — vor dem Kodieren neu normieren.
// Das Ziel ist 8 Bit pro Kanal: k/255 kommt exakt wieder heraus, aber
// zwei Objekte 256 Nummern auseinander teilen sich einen Wert. Bei
// gerade ~175 Objekten in der Szene ist das Kopfrechnen, kein Problem.
out.normal = vec4f(normalize(in.normal) * 0.5 + 0.5, f32(in.id % 256u) / 255.0);
return out;
}
+11 -59
View File
@@ -1,12 +1,17 @@
//! Sprite-Pass: das 2D-Overlay (UI) als getintete Textur-Quads.
//!
//! Zeichnet in einem eigenen Pass *hinter* der Post-Kette: Bild-Effekte
//! (Farbtiefe, Dither, später Outline) treffen so nur die Welt, nie den Text.
//!
//! Immediate-Mode: der Vertex-Buffer wird jeden Frame neu befüllt (kleine
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU).
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über
//! dieselbe `scene::upload_texture`, nur ohne Mipchain.
use crate::engine::tga::Image;
use crate::engine::image::Image;
use crate::render::scene::upload_texture;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -58,7 +63,6 @@ impl SpritePass {
device: &wgpu::Device,
queue: &wgpu::Queue,
color_format: wgpu::TextureFormat,
depth_format: wgpu::TextureFormat,
internal: [f32; 2],
textures: &[Image],
) -> Self {
@@ -87,16 +91,9 @@ impl SpritePass {
})],
}),
primitive: wgpu::PrimitiveState::default(),
// Der interne Pass hat ein Tiefen-Attachment → die Pipeline muss
// ein kompatibles Format deklarieren. UI testet/schreibt aber
// keine Tiefe (Always, kein Write) → liegt immer obenauf.
depth_stencil: Some(wgpu::DepthStencilState {
format: depth_format,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
// Eigener Pass hinter der Post-Kette, ohne Tiefen-Attachment:
// das Overlay liegt per Reihenfolge obenauf.
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
@@ -134,7 +131,7 @@ impl SpritePass {
});
let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = textures.iter()
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "sprite texture", false))
.collect();
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
@@ -160,48 +157,3 @@ impl SpritePass {
}
}
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die Bind-Group (Textur +
/// Sampler, group 1) bauen.
fn upload_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler,
img: &Image,
) -> wgpu::BindGroup {
let size = wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 };
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sprite texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&img.rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(img.width * 4),
rows_per_image: Some(img.height),
},
size,
);
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sprite texture"),
layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
],
})
}
+4 -4
View File
@@ -1,8 +1,8 @@
// 2D-Sprite-Pass für das UI-Overlay: texturierte Quads in internen Pixeln
// (Ursprung oben-links), getintet. Zeichnet ins interne Target *nach* der
// Szene, ohne Tiefentest → liegt immer obenauf. Echtes Alpha-Blending
// (anders als der 1-Bit-Alphatest der Welt), damit Panels und Glyph-Ränder
// weich überblenden.
// (Ursprung oben-links), getintet. Läuft als letzter interner Pass, hinter
// der Post-Kette → liegt immer obenauf und bleibt von Bild-Effekten
// unberührt. Echtes Alpha-Blending (anders als der 1-Bit-Alphatest der
// Welt), damit Panels und Glyph-Ränder weich überblenden.
struct Uniforms {
inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets
+10 -11
View File
@@ -1,17 +1,16 @@
//! UI-Overlay: **state-driven** und **immediate-mode**.
//!
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü →
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein
//! einziger Trichter in den State, kein UI-Backdoor.
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands und hält
//! keinen eigenen mutierbaren Zustand, der davon desyncen könnte. Jeden
//! Frame wird aus dem aktuellen [`Mode`] neu gebaut: Dialog → Panel +
//! Choice-Buttons, Menü → Menü-Buttons, usw. Klicks erzeugen denselben
//! Eingabe-String, den auch CLI und stdin liefern, und laufen durch dasselbe
//! `Session::exec` — ein Trichter in den State, kein UI-Backdoor.
//!
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
use crate::engine::tga::Image;
use crate::engine::image::Image;
use crate::render::font::{self, Font, Fonts};
use crate::render::sprite::{SpriteBatch, SpriteVertex};
use crate::session::{Dialog, Mode, Session};
@@ -57,7 +56,7 @@ pub(crate) fn key_luminance(img: &Image) -> Image {
pub(crate) struct Cursor {
/// Position in internen Pixeln (über die Letterbox gemappt).
pub(crate) pos: [f32; 2],
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
/// Maus gefangen (Spiel oder Noclip) → First-Person-Pointer mittig statt
/// Maus-Cursor an `pos`.
pub(crate) grabbed: bool,
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
@@ -225,12 +224,12 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
// 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.
// Bei gefangener Maus zählt die freie Sicht → kein Panel.
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
if !cur.grabbed {
match &session.mode {
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen
// Spiel und Noclip: nur HUD/Cursor, kein Panel (beide laufen
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
Mode::FirstPerson | Mode::Free => {}
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
+134 -95
View File
@@ -4,21 +4,17 @@ use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::player::{self, Player};
use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl};
use crate::engine::{model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
pub const LOOK_SENS: f32 = 0.0025;
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
const LOOK_SENS: f32 = 0.0025;
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
/// Fester Simulationsschritt (s) = 60 Hz, unabhängig von der Render-Framerate:
/// Step-up und Ground-Snap verhalten sich sonst bei 144 Hz anders als in den
/// (bei 1/60 geschriebenen) Player-Tests.
const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
/// Deckel für den Sim-Akkumulator (s): nach einem Hänger höchstens so viel
/// Zeit nachholen, sonst spiralt die Sim davon. ~15 Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25;
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
@@ -48,25 +44,30 @@ pub struct Session {
/// 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).
/// zieht sie per [`Session::take_sounds`]; der CLI reicht die
/// `[action]`-Textzeile.
pending_sounds: Vec<String>,
/// Von `Action::SetPost` aufgelaufene Post-Effekt-Schaltungen (Name,
/// Zielzustand) — das Fenster zieht sie per [`Session::take_post`] und
/// wendet sie auf seine Kette an; der CLI reicht die `[action]`-Textzeile.
pending_post: Vec<(String, bool)>,
signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32,
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
/// nächsten Sub-Schritt konsumiert.
/// Gepufferte Sprung-Flanke: bei hohen FPS fällt eine Sprungtaste sonst in
/// einen Frame ohne Sim-Schritt und verfällt.
pending_jump: bool,
}
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
/// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1].
/// sind rohe Maus-Pixel, `fwd`/`right`/`up` Tastenachsen in [-1, 1]; `up`
/// wirkt nur im Noclip.
#[derive(Default)]
pub struct FrameInput {
pub fwd: f32,
pub right: f32,
pub up: f32,
pub jump: bool,
pub look_dx: f32,
pub look_dy: f32,
@@ -97,19 +98,17 @@ impl Session {
triggers: Triggers::new(),
emitters: Emitters::new(),
pending_sounds: Vec::new(),
pending_post: Vec::new(),
signals_path,
sim_accum: 0.0,
pending_jump: false,
}
}
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
/// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
/// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
/// desselben Modells ist Sache des Renderers (render::props).
/// Sim-Seite eines Blender-Modells in die Welt einfügen (die Sichtgeometrie
/// ist Sache von render::props): Collider aus `collide`, Trigger aus
/// `signal`, Ambient-Emitter aus `sound` und der Spawn aus `role = spawn`.
/// Bringen mehrere Modelle einen Spawn mit, gewinnt der zuletzt geladene.
pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model);
self.triggers.add_model(model);
@@ -139,13 +138,28 @@ impl Session {
std::mem::take(&mut self.pending_sounds)
}
/// Aufgelaufene Post-Effekt-Schaltungen (`SetPost`-Actions) abholen — jeden
/// Frame vom Fenster-Frontend, das sie auf seine Kette anwendet. Headless
/// bleibt die Queue liegen (die CLI hat keine Kette) und wird nie gezogen.
pub fn take_post(&mut self) -> Vec<(String, bool)> {
std::mem::take(&mut self.pending_post)
}
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
/// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
/// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
/// 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(); }
match self.mode {
Mode::FirstPerson => {}
Mode::Free => {
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
self.player.fly(input.fwd, input.right, input.up, dt);
return Vec::new();
}
_ => return Vec::new(),
}
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
@@ -217,13 +231,13 @@ impl Session {
fn command(&mut self, line: &str) -> ExecResult {
let line = line.trim();
match line {
"" => ExecResult::empty(),
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
"menu" => {
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
let (verb, args) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
match (verb, args.trim()) {
("", _) => ExecResult::empty(),
("quit" | "exit", _) => ExecResult { output: Vec::new(), quit: true },
("menu", _) => {
// Menü öffnen bzw. ins Spiel zurück; „Fortsetzen" landet immer
// im Spielmodus, nie im Noclip. (Dialog wird anders geroutet.)
let t = match self.mode {
Mode::Menu => ModeTarget::Play,
_ => ModeTarget::Menu,
@@ -231,56 +245,46 @@ impl Session {
self.set_mode(t);
ExecResult::empty()
}
"noclip" => {
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
// umschalten.
let t = match self.mode {
Mode::FirstPerson => ModeTarget::Free,
_ => ModeTarget::Play,
};
self.set_mode(t);
ExecResult::empty()
}
"help" => ExecResult::lines(help()),
"kv" => ExecResult::lines(self.dump_kv()),
"reload" => {
("help", _) => ExecResult::lines(help()),
("kv", _) => ExecResult::lines(self.dump_kv()),
("reload", _) => {
self.game.signals = signals::load_signals(&self.signals_path);
ExecResult::lines(vec![
format!("{} Signale geladen.", self.game.signals.len()),
])
}
_ => {
if let Some(m) = line.strip_prefix("mode ") {
// Modus explizit setzen — über dieselbe Action wie das
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
// Signals genau einen Wechsel-Pfad teilen.
match ModeTarget::parse(m) {
// Modus über dieselbe Action wie das `mode`-Verb aus
// signals.toml/Ink-Tags — ein Wechsel-Pfad für alle Quellen.
("mode", m) => match ModeTarget::parse(m) {
Some(t) => {
self.game.actions.push(Action::SetMode(t));
ExecResult::lines(self.drain_actions())
}
None => ExecResult::lines(vec![
"Modus: play | free | menu".into(),
]),
None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
},
// Post-Effekt über dieselbe Action wie das `post`-Verb aus
// signals.toml/Ink-Tags. Das Fenster löst den Namen gegen seine
// Kette auf; headless bleibt es bei der `[action]`-Zeile.
("post", args) => match signals::parse_post_arg(args) {
Some((name, on)) => {
self.game.actions.push(Action::SetPost { name, on });
ExecResult::lines(self.drain_actions())
}
} else if let Some(sig) = line.strip_prefix("signal ") {
ExecResult::lines(self.fire(sig.trim(), None))
} else if let Some(name) = line.strip_prefix("use ") {
// Objekt-Interaktion (LMB-Klick und Konsole laufen beide
// 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();
None => ExecResult::lines(vec!["post <name> on|off".into()]),
},
("signal", sig) => ExecResult::lines(self.fire(sig, None)),
// Objekt-Interaktion (Klick wie Konsole): Klick-Ziele feuern ihre
// `signal`-Property, sonst den suffix-befreiten Objektnamen.
// `$self` ist immer der volle Name.
("use", name) => {
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())))
} else {
ExecResult::lines(vec![
}
_ => ExecResult::lines(vec![
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
])
}
}
]),
}
}
@@ -364,15 +368,19 @@ impl Session {
self.set_mode(t);
out.push(format!("[action] mode {}", t.label()));
}
Action::SetPost { name, on } => {
out.push(format!("[action] post {name} {}", if on { "on" } else { "off" }));
self.pending_post.push((name, on));
}
}
}
out
}
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode`
/// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus
/// entsteht ausschließlich in `step` aus dem Story-Ablauf.)
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu` und die
/// `SetMode`-Action laufen beide hier durch. (Der Dialog-Modus entsteht
/// ausschließlich in `step` aus dem Story-Ablauf.)
fn set_mode(&mut self, t: ModeTarget) {
self.mode = match t {
ModeTarget::Play => Mode::FirstPerson,
@@ -385,7 +393,7 @@ impl Session {
let mut keys: Vec<&String> = self.game.kv.keys().collect();
keys.sort();
keys.into_iter()
.map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k])))
.map(|k| format!(" {k} = {}", self.game.kv[k]))
.collect()
}
}
@@ -398,8 +406,8 @@ fn help() -> Vec<String> {
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
" kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".into(),
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
" mode <m> Modus direkt setzen: play | free | menu".into(),
" mode <m> Modus setzen: play | free (Noclip) | menu".into(),
" post <e> on|off Post-Effekt schalten (Fenster-Frontend)".into(),
" reload signals.toml neu laden".into(),
" quit beenden".into(),
]
@@ -408,6 +416,7 @@ fn help() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::kv::Value;
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
@@ -432,7 +441,7 @@ mod tests {
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
let r = s.exec("signal set has_key true");
assert!(!r.quit);
assert!(s.game.kv["has_key"].coerce_to_bool().unwrap());
assert!(matches!(s.game.kv["has_key"], Value::Bool(true)));
assert!(matches!(s.mode, Mode::Free));
}
@@ -448,7 +457,7 @@ mod tests {
assert!(matches!(s.mode, Mode::Menu));
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
s.exec("signal set in_menu true");
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
assert!(matches!(s.game.kv["in_menu"], Value::Bool(true)));
}
#[test]
@@ -475,16 +484,6 @@ mod tests {
assert!(matches!(s.mode, Mode::FirstPerson));
}
#[test]
fn noclip_toggles_play_and_free() {
let mut s = empty_session();
s.mode = Mode::FirstPerson;
s.exec("noclip");
assert!(matches!(s.mode, Mode::Free));
s.exec("noclip");
assert!(matches!(s.mode, Mode::FirstPerson));
}
#[test]
fn fixed_timestep_is_framerate_independent() {
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
@@ -525,13 +524,27 @@ mod tests {
}
#[test]
fn tick_only_runs_in_first_person() {
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
fn menu_pauses_the_sim() {
let mut s = empty_session();
s.mode = Mode::Menu;
let before = s.player.pos;
s.tick(&FrameInput::default(), 0.1);
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
assert_eq!(s.player.pos, before);
}
#[test]
fn noclip_flies_the_player_without_physics() {
// Free bewegt denselben Spieler, nur ohne Welt: `up` steigt, und beim
// Zurückschalten steht er dort, wo er hingeflogen ist.
let mut s = empty_session();
s.mode = Mode::Free;
s.tick(&FrameInput { up: 1.0, ..Default::default() }, 1.0);
assert!(s.player.pos[1] > 1.5, "sollte gestiegen sein: {}", s.player.pos[1]);
assert!(!s.player.grounded);
let flown_to = s.player.pos;
s.exec("mode play");
assert_eq!(s.player.pos, flown_to, "Noclip-Ende teleportiert nicht zurück");
}
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
@@ -545,7 +558,7 @@ mod tests {
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
],
empties: Vec::new(),
materials: vec![String::new()],
textures: Vec::new(),
}
}
@@ -559,7 +572,7 @@ mod tests {
// 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");
assert!(matches!(s.game.kv["zone_hit"], Value::Bool(true)), "Zone sollte gefeuert haben");
// Weiterlaufen in der Zone darf nicht erneut feuern.
s.game.kv.remove("zone_hit");
@@ -574,7 +587,7 @@ mod tests {
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());
assert!(matches!(s.game.kv["kiste_geklickt"], Value::Bool(true)));
// 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");
@@ -615,11 +628,37 @@ mod tests {
props: crate::engine::model::Props::new(),
},
],
materials: Vec::new(),
textures: Vec::new(),
});
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
}
#[test]
fn post_command_queues_setpost_for_the_frontend() {
let mut s = empty_session();
s.mode = Mode::Free;
// Der Befehl kennt die Kette nicht — er reiht die Schaltung ein, das
// Fenster zieht sie per take_post().
let r = s.exec("post outline off");
assert_eq!(r.output, vec!["[action] post outline off"]);
assert_eq!(s.take_post(), vec![("outline".to_string(), false)]);
assert!(s.take_post().is_empty(), "abgeholt = geleert");
// Fehlendes/ungültiges on|off → Nutzungshinweis, nichts eingereiht.
assert_eq!(s.exec("post outline").output, vec!["post <name> on|off"]);
assert_eq!(s.exec("post outline wat").output, vec!["post <name> on|off"]);
assert!(s.take_post().is_empty());
}
#[test]
fn post_signal_from_table_queues_setpost() {
let mut s = empty_session();
s.mode = Mode::Free;
// Ink-Tag-/signals.toml-Pfad: `post`-Verb direkt als Action.
s.exec("signal post dither on");
assert_eq!(s.take_post(), vec![("dither".to_string(), true)]);
}
#[test]
fn unknown_command_reports_and_stays_free() {
let mut s = empty_session();
+26
View File
@@ -27,3 +27,29 @@ Das Skript sucht den Ink-Compiler in dieser Reihenfolge:
1. `$INKLECATE` (Pfad zum Binary)
2. `tools/inklecate` (mitgeliefert)
3. `inklecate` im `PATH`
## build-models.sh
Exportiert die Blender-Welten zu den `.glb`, die das Spiel lädt.
```sh
./tools/build-models.sh # nur Geändertes neu bauen
./tools/build-models.sh -f # alles neu bauen
```
Workflow für die Autorin: `.blend` in den jeweiligen Map-Ordner legen —
`assets/maps/<map>/<name>.blend` — Skript laufen lassen, fertig. Die
`.glb` landen direkt daneben (`assets/maps/<map>/<name>.glb`) und werden
mit ins Repo eingecheckt, damit ein frischer Clone ohne Blender spielbar
ist. Ein `.glb` ohne `.blend` daneben (z.B. `props/testscene.glb`) bleibt
unangetastet.
**Automatisch beim Build:** `cargo build`/`cargo run` ruft dieses Skript
über `build.rs` selbst auf, sobald sich eine `.blend` geändert hat.
Blender wird über `$BLENDER` oder den `PATH` gefunden. Fehlt es, wird der
Schritt übersprungen (kein Fehler — die committeten `.glb` reichen).
Bundeln geht nicht, dafür ist Blender zu groß.
Tipp: in Blender „File → External Data → Pack Resources", dann liegen die
Texturen in der `.blend` und der Export ist self-contained.
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Exportiert alle Blender-Welten unter assets/maps/ zu den .glb, die das
# Spiel lädt.
#
# Für die Autorin: Leg deine .blend in den jeweiligen Map-Ordner
# assets/maps/<map>/<name>.blend
# und führ danach dieses Skript aus:
# ./tools/build-models.sh
# Fertig — die .glb landen direkt daneben (assets/maps/<map>/<name>.glb).
#
# Standardmäßig wird nur exportiert, was sich seit dem letzten Mal geändert
# hat. Mit -f / --force wird alles neu gebaut.
#
# Tipp: in Blender „File -> External Data -> Pack Resources", dann sind die
# Texturen in der .blend und der Export ist self-contained.
set -euo pipefail
# Repo-Wurzel relativ zum Skript bestimmen — egal, von wo aufgerufen.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$script_dir/.." && pwd)"
maps_dir="$root/assets/maps"
force=0
case "${1:-}" in
-f|--force) force=1 ;;
"") ;;
*) echo "Unbekannte Option: $1 (nur -f/--force)" >&2; exit 2 ;;
esac
# blender finden: $BLENDER -> PATH. Bundeln geht nicht (zu groß) — fehlt es,
# ist das kein Fehler: die committeten .glb reichen zum Bauen und Spielen.
if [ -n "${BLENDER:-}" ] && [ -x "$BLENDER" ]; then
blender="$BLENDER"
elif command -v blender >/dev/null 2>&1; then
blender="$(command -v blender)"
else
echo " übersprungen blender nicht gefunden (\$BLENDER oder PATH) — .glb bleiben wie committet" >&2
exit 0
fi
if [ ! -d "$maps_dir" ]; then
echo "Kein Map-Ordner: $maps_dir" >&2
exit 1
fi
# Der Export als Python-Einzeiler. filepath ohne Endung — export_format='GLB'
# hängt .glb an. export_extras trägt collide/signal/role mit.
gltf_expr='
import bpy, sys
out = sys.argv[-1]
bpy.ops.export_scene.gltf(
filepath=out,
export_format="GLB",
export_extras=True,
export_yup=True,
export_apply=True,
export_image_format="AUTO",
)'
built=0 skipped=0 failed=0
while IFS= read -r -d '' src; do
out="${src%.blend}.glb"
rel="${src#"$root"/}"
# Inkrementell: überspringen, wenn .glb existiert und neuer ist.
if [ "$force" -eq 0 ] && [ -f "$out" ] && [ "$out" -nt "$src" ]; then
printf ' aktuell %s\n' "$rel"
skipped=$((skipped + 1))
continue
fi
if log="$("$blender" -b --factory-startup "$src" \
--python-expr "$gltf_expr" -- "${out%.glb}" 2>&1)"; then
printf ' gebaut %s\n' "$rel"
built=$((built + 1))
else
printf ' FEHLER %s\n' "$rel"
printf '%s\n' "$log" | sed 's/^/ /'
failed=$((failed + 1))
fi
done < <(find "$maps_dir" -type f -name '*.blend' -print0 | sort -z)
echo "—— $built gebaut, $skipped aktuell, $failed fehlgeschlagen ——"
[ "$failed" -eq 0 ]