From 50b7b869447e39f95285dc32bc4c914bf70dba3b Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sun, 2 Aug 2026 23:05:41 +0200 Subject: [PATCH] refactor --- src/engine/collision.rs | 12 ++-- src/engine/game.rs | 20 +++--- src/engine/gltf.rs | 19 ++--- src/engine/mod.rs | 25 +++---- src/engine/model.rs | 2 +- src/engine/player.rs | 124 ++++++++++++++++++-------------- src/engine/story_ctrl.rs | 5 +- src/engine/tga.rs | 6 +- src/engine/wav.rs | 6 +- src/render/audio.rs | 7 +- src/render/camera.rs | 102 -------------------------- src/render/gpu.rs | 2 +- src/render/math.rs | 36 +++++++--- src/render/mod.rs | 112 +++++++++-------------------- src/render/props.rs | 2 +- src/render/scene.rs | 22 +++--- src/render/sprite.rs | 51 ++----------- src/render/ui.rs | 19 +++-- src/session.rs | 151 ++++++++++++++++++++------------------- 19 files changed, 274 insertions(+), 449 deletions(-) delete mode 100644 src/render/camera.rs diff --git a/src/engine/collision.rs b/src/engine/collision.rs index 7f1335d..3b3c7f2 100644 --- a/src/engine/collision.rs +++ b/src/engine/collision.rs @@ -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}; diff --git a/src/engine/game.rs b/src/engine/game.rs index a6b0521..2c152f5 100644 --- a/src/engine/game.rs +++ b/src/engine/game.rs @@ -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,18 @@ pub type Signals = HashMap>; 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), } -/// 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, diff --git a/src/engine/gltf.rs b/src/engine/gltf.rs index d2a2369..b16e941 100644 --- a/src/engine/gltf.rs +++ b/src/engine/gltf.rs @@ -1,26 +1,19 @@ //! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`]. //! -//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann — -//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export +//! 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. +//! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben 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 serde_json::Value; @@ -125,7 +118,7 @@ fn append_mesh( None => vec![[0.0, 0.0]; positions.len()], }; - // Material-Name → geteilte Namensliste (wie usemtl); ohne Material "". + // Material-Name → geteilte Namensliste; ohne Material "". let mat_name = prim["material"].as_u64() .and_then(|m| doc["materials"][m as usize]["name"].as_str()) .unwrap_or(""); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 846f78f..6ddf02d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -3,30 +3,21 @@ //! 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`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf` +//! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist +//! die Emitter-Logik zum Frontend-Treiber render::audio, `player` die +//! First-Person-Physik auf der Welt aus `collision`. pub mod assets; pub mod audio; diff --git a/src/engine/model.rs b/src/engine/model.rs index a747bd5..02c0b82 100644 --- a/src/engine/model.rs +++ b/src/engine/model.rs @@ -20,7 +20,7 @@ use std::collections::HashMap; /// 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; pub struct Model { diff --git a/src/engine/player.rs b/src/engine/player.rs index 554e768..a1a9b1b 100644 --- a/src/engine/player.rs +++ b/src/engine/player.rs @@ -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,13 +17,14 @@ 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. @@ -81,43 +73,52 @@ 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 nur vom Boden (Vorframe-`grounded`) — kein Doppelsprung. - // - // Gravitation wirkt *nur in der Luft*. Am Boden wird die Vertikal- - // geschwindigkeit stattdessen genullt, denn `slide` projiziert sie auf - // die Trefferebene und lässt auf einer Schräge den hangparallelen Rest - // stehen — Hangabtrieb, der sich über die Frames aufschaukelt und den - // Spieler auch sanfte Rampen hinabkriechen lässt. Ohne vertikale - // Geschwindigkeit gibt es nichts zu projizieren, er bleibt stehen; ans - // Gefälle heftet ihn der Ground-Snap unten. Steiler als - // [`GROUND_NORMAL_Y`] wird `grounded` gar nicht erst gesetzt, dort - // greift der Luft-Zweig und der Spieler rutscht wie gewünscht ab. + // 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; @@ -130,11 +131,10 @@ impl Player { 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); } @@ -158,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 @@ -362,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. diff --git a/src/engine/story_ctrl.rs b/src/engine/story_ctrl.rs index 8e819df..0e0a7cb 100644 --- a/src/engine/story_ctrl.rs +++ b/src/engine/story_ctrl.rs @@ -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` zurückgegeben — der //! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht diff --git a/src/engine/tga.rs b/src/engine/tga.rs index 51ae889..889d82f 100644 --- a/src/engine/tga.rs +++ b/src/engine/tga.rs @@ -2,11 +2,7 @@ //! //! 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. +//! Nachbearbeitung, nur das Sampling (und damit die UV-Konvention) gehört ihm. //! //! 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 diff --git a/src/engine/wav.rs b/src/engine/wav.rs index a740d25..1234b5f 100644 --- a/src/engine/wav.rs +++ b/src/engine/wav.rs @@ -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 diff --git a/src/render/audio.rs b/src/render/audio.rs index 820e917..e006412 100644 --- a/src/render/audio.rs +++ b/src/render/audio.rs @@ -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, diff --git a/src/render/camera.rs b/src/render/camera.rs deleted file mode 100644 index a3856ce..0000000 --- a/src/render/camera.rs +++ /dev/null @@ -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:?}"); - } -} diff --git a/src/render/gpu.rs b/src/render/gpu.rs index 4c08684..98d25cf 100644 --- a/src/render/gpu.rs +++ b/src/render/gpu.rs @@ -179,7 +179,7 @@ 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) { diff --git a/src/render/math.rs b/src/render/math.rs index 86d9f57..2e4cc8d 100644 --- a/src/render/math.rs +++ b/src/render/math.rs @@ -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); diff --git a/src/render/mod.rs b/src/render/mod.rs index f782c36..9c2ff0f 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -1,21 +1,14 @@ //! 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; @@ -39,19 +32,13 @@ use crate::engine::player; use crate::engine::tga::Image; use crate::engine::{assets, gltf, model, tga, 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; @@ -61,11 +48,8 @@ pub fn run(mut session: Session) { // 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. + // Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier, + // Upload (GPU) später in `Gpu` — die beiden bleiben getrennt. let models = load_models(&assets::path("assets/maps/props")); // Eine geteilte Texturliste über alle Modelle, damit ihre Meshes @@ -98,11 +82,10 @@ 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)")) }; @@ -168,8 +151,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 +168,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 { @@ -238,8 +218,6 @@ struct App { gpu: Option, session: Session, rx: Receiver, - /// 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, @@ -271,15 +249,12 @@ impl App { fonts: font::Fonts, audio: Option, ) -> 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, @@ -296,7 +271,7 @@ impl App { // 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 +341,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 +379,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 +391,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. - let input = FrameInput { - fwd: axis(self.input.fwd, self.input.back), - right: axis(self.input.right, self.input.left), - 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}"); } - } + // 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, + }; + 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 +432,8 @@ 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. + // 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 +443,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 { - let p = self.session.player.pos; - Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch) - }; + // 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; + 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. diff --git a/src/render/props.rs b/src/render/props.rs index 0758612..fb04807 100644 --- a/src/render/props.rs +++ b/src/render/props.rs @@ -16,7 +16,7 @@ use crate::engine::model::Model; use crate::render::scene::{Batch, Mesh, Vertex}; -/// Texturname für Faces ohne `usemtl` (Material `""`). +/// Texturname für Faces ohne Material (`""`). pub const FALLBACK_TEXTURE: &str = "placeholder"; /// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den diff --git a/src/render/scene.rs b/src/render/scene.rs index 0f2f7d5..11a6918 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -168,7 +168,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 +196,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 +257,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) }, diff --git a/src/render/sprite.rs b/src/render/sprite.rs index 86264cd..6a6f02d 100644 --- a/src/render/sprite.rs +++ b/src/render/sprite.rs @@ -4,9 +4,11 @@ //! 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::render::scene::upload_texture; #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] @@ -134,7 +136,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 +162,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) }, - ], - }) -} diff --git a/src/render/ui.rs b/src/render/ui.rs index ae88429..ecf76d9 100644 --- a/src/render/ui.rs +++ b/src/render/ui.rs @@ -1,12 +1,11 @@ //! 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`]. @@ -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), diff --git a/src/session.rs b/src/session.rs index 8358223..592d590 100644 --- a/src/session.rs +++ b/src/session.rs @@ -6,19 +6,15 @@ use crate::engine::player::{self, Player}; use crate::engine::trigger::Triggers; use crate::engine::{kv, 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,26 @@ 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, 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, @@ -103,13 +100,10 @@ impl Session { } } - /// 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); @@ -140,12 +134,20 @@ impl Session { } /// 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 { - 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,12 +219,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" => { + 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 im - // Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder + // Spielmodus (Play), nicht im Noclip; aus Play oder // Free öffnet es das Menü. (Dialog wird anders geroutet.) let t = match self.mode { Mode::Menu => ModeTarget::Play, @@ -231,56 +234,44 @@ impl Session { self.set_mode(t); ExecResult::empty() } - "noclip" => { - // Debug: zwischen First-Person (Play) und freier Flycam (Free) - // umschalten. + ("noclip", _) => { let t = match self.mode { Mode::FirstPerson => ModeTarget::Free, - _ => ModeTarget::Play, + _ => 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) { - Some(t) => { - self.game.actions.push(Action::SetMode(t)); - ExecResult::lines(self.drain_actions()) - } - None => ExecResult::lines(vec![ - "Modus: play | free | menu".into(), - ]), - } - } 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(); - 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![ - format!("unbekannter Befehl: {line:?} — `help` für Befehle"), - ]) + // 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()]), + }, + ("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()))) } + _ => ExecResult::lines(vec![ + format!("unbekannter Befehl: {line:?} — `help` für Befehle"), + ]), } } @@ -398,7 +389,7 @@ fn help() -> Vec { " 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(), + " noclip zwischen First-Person und Noclip wechseln".into(), " mode Modus direkt setzen: play | free | menu".into(), " reload signals.toml neu laden".into(), " quit beenden".into(), @@ -525,13 +516,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("noclip"); + 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,