This commit is contained in:
2026-08-28 23:31:48 +02:00
parent 3571106251
commit 50b7b86944
19 changed files with 274 additions and 449 deletions
+4 -8
View File
@@ -10,17 +10,13 @@
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht //! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
//! sofort wieder im Kontakt steckt. //! sofort wieder im Kontakt steckt.
//! //!
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt //! Gefüllt wird die Welt aus Blender-Modellen ([`CollisionWorld::add_model`]):
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices //! jedes Objekt mit `collide`-Property wird zur **konvexen Hülle** seiner
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also //! Vertices ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs //! also konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`- //! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die //! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm. //! 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}; use crate::engine::model::{Model, Object};
+8 -12
View File
@@ -5,11 +5,9 @@
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()` //! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
//! konstruiert und durch die Dispatch-Pipeline gereicht. //! konstruiert und durch die Dispatch-Pipeline gereicht.
//! //!
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht //! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) den Kern nichts
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte //! angeht, produzieren [`Action`]-Werte statt direkt zu wirken. Frontends
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt //! konsumieren die Queue: die CLI druckt sie, das Fenster führt sie aus.
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
//! testbar und kennt keines der Subsysteme.
use std::collections::HashMap; use std::collections::HashMap;
@@ -28,20 +26,18 @@ pub type Signals = HashMap<String, Vec<String>>;
pub enum Action { pub enum Action {
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen. /// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
PlaySound(String), PlaySound(String),
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend /// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht /// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
/// wählbar — der entsteht nur aus dem Story-Ablauf.
SetMode(ModeTarget), SetMode(ModeTarget),
} }
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog- /// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten /// `session::Mode`, ohne dass der Kern dessen Dialog-Daten kennen muss.
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeTarget { pub enum ModeTarget {
/// First-Person-Spiel. /// First-Person-Spiel.
Play, Play,
/// Noclip-Debug-Flycam. /// Noclip: freies Fliegen ohne Kollision.
Free, Free,
/// Menü (pausiert die Welt, gibt die Maus frei). /// Menü (pausiert die Welt, gibt die Maus frei).
Menu, Menu,
+6 -13
View File
@@ -1,26 +1,19 @@
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`]. //! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
//! //!
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann — //! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und //! „Include → Custom Properties" anhaken → landen in `node.extras`) und
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/… //! **Empties** (Nodes ohne Mesh) als Entity-Marker. Beide kommen als
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die //! `props`/`empties` im Modell an; Bedeutung geben die Konsumenten.
//! Konsumenten.
//! //!
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen //! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren), //! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0` //! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix //! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken — //! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe //! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt.
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
//! //!
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System, //! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
//! Blenders Exporter konvertiert selbst. Maßstab 1:1. //! Blenders Exporter konvertiert selbst. Maßstab 1:1.
//!
//! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum,
//! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders
//! als beim trivialen signals-TOML-Subset).
use serde_json::Value; use serde_json::Value;
@@ -125,7 +118,7 @@ fn append_mesh(
None => vec![[0.0, 0.0]; positions.len()], 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() let mat_name = prim["material"].as_u64()
.and_then(|m| doc["materials"][m as usize]["name"].as_str()) .and_then(|m| doc["materials"][m as usize]["name"].as_str())
.unwrap_or(""); .unwrap_or("");
+8 -17
View File
@@ -3,30 +3,21 @@
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert //! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die //! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module. //! [`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 ← ink ← game ← story_ctrl ← signals
//! ↑ ↑ //! ↑ ↑
//! kv assets //! kv assets
//! //!
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte //! Neue Querverbindungen lieber über Rückgabewerte an den Aufrufer lösen —
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst //! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
//! signals::dispatch zu rufen). //! rufen.
//! //!
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen //! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes //! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first: //! die Emitter-Logik zum Frontend-Treiber render::audio, `player` die
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus //! First-Person-Physik auf der Welt aus `collision`.
//! 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.
pub mod assets; pub mod assets;
pub mod audio; pub mod audio;
+1 -1
View File
@@ -20,7 +20,7 @@
use std::collections::HashMap; use std::collections::HashMap;
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`), /// 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 type Props = HashMap<String, String>;
pub struct Model { pub struct Model {
+69 -55
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 //! `step` sweept die Spieler-AABB gegen die [`CollisionWorld`] und gleitet an
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`), //! Treffern entlang (move-and-slide). Getract wird der Box-Mittelpunkt
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal //! (Fußpunkt + halbe Höhe), danach geht es zurück auf den Fußpunkt.
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um. //! 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- //! Der Renderer leitet seine View hier ab (`Mat4::view(eye, yaw, pitch)`), der
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine //! Player kennt ihn nie. Konvention: yaw=0 blickt −Z, positiver Yaw dreht nach
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter //! links, positiver Pitch hebt den Blick. Gelaufen wird horizontal aus dem Yaw
//! Nutzer echte dot/cross/normalize braucht. //! — Hochschauen hebt nicht ab, Fliegen dagegen folgt dem vollen Blick.
//!
//! 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.
use crate::engine::collision::CollisionWorld; 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). /// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
pub const EYE_HEIGHT: f32 = 1.6; pub const EYE_HEIGHT: f32 = 1.6;
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges /// Fallbeschleunigung (units/s²). Über realem g für knackiges Spielgefühl.
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
const GRAVITY: f32 = 20.0; const GRAVITY: f32 = 20.0;
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter). /// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
const WALK_SPEED: f32 = 5.0; const WALK_SPEED: f32 = 5.0;
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert. /// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe.
const JUMP_SPEED: f32 = 7.0; 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`). /// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
const PITCH_LIMIT: f32 = 1.55; const PITCH_LIMIT: f32 = 1.55;
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch. /// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
@@ -81,43 +73,52 @@ impl Player {
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]] [self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
} }
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/ /// Blickrichtung inkl. Pitch — dieselbe Formel wie `math::view`. Trägt den
/// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den /// Point-and-Click-Strahl aus der Bildmitte und das Vorwärts im Noclip.
/// Point-and-Click-Strahl aus der Bildmitte.
pub fn look_dir(&self) -> [f32; 3] { pub fn look_dir(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos(); let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos(); let (sp, cp) = self.pitch.sin_cos();
[-sy * cp, sp, -cy * cp] [-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, /// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true = /// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden. /// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) { pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
let was_grounded = self.grounded; let was_grounded = self.grounded;
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0, // Wunschgeschwindigkeit sofort setzen — ohne Beschleunigung/Reibung,
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach. // mit voller Luftkontrolle. Vorwärts ist der flache Blick, damit
// Hochschauen nicht abhebt.
let (sy, cy) = self.yaw.sin_cos(); let (sy, cy) = self.yaw.sin_cos();
let f = [-sy, -cy]; // (x, z) let r = self.right_dir();
let r = [ cy, -sy]; // (x, z) 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 — // Sprung nur vom Boden — kein Doppelsprung. Gravitation wirkt nur in
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst. // der Luft: am Boden bliebe sonst nach der Slide-Projektion ein
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED; // hangparalleler Rest stehen, der sich über die Frames zum Hangabtrieb
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED; // aufschaukelt. Steiler als [`GROUND_NORMAL_Y`] ist `grounded` ohnehin
// nicht gesetzt — dort greift der Luft-Zweig und man rutscht ab.
// 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.
let jumped = jump && was_grounded; let jumped = jump && was_grounded;
if jumped { if jumped {
self.vel[1] = JUMP_SPEED; self.vel[1] = JUMP_SPEED;
@@ -130,11 +131,10 @@ impl Player {
self.move_and_slide(world, dt); self.move_and_slide(world, dt);
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen // Ground-Snap: stand er eben noch am Boden und schwebt nach dem Zug
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter, // knapp darüber (abwärts-Slope, Stufe runter, Brush-Naht), ziehen wir
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über // ihn hinunter. Nicht beim Aufsteigen — sonst gäbe es keine
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim // Rampen-Absprünge mehr.
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 { if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
self.snap_to_ground(world); self.snap_to_ground(world);
} }
@@ -158,11 +158,9 @@ impl Player {
/// ///
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich /// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder /// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und /// runter auf Boden. Kommt das horizontal weiter und endet auf begehbarem
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box- /// Boden, gewinnt es. Das hebt die flache Box-Unterkante über Brush-Nähte
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst /// (Slope-Kanten, Stufen), an denen sie sonst als „Wand" verkantet.
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) { fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
let half = HALF_EXTENTS; let half = HALF_EXTENTS;
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt 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]); 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] #[test]
fn wall_blocks_horizontal_movement() { fn wall_blocks_horizontal_movement() {
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen. // Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
+2 -3
View File
@@ -1,7 +1,6 @@
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise //! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState` //! treiben. Frontend-agnostisch — den `StoryState` gießt der Aufrufer selbst
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer //! in seine Darstellung.
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
//! //!
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der //! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht //! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
+1 -5
View File
@@ -2,11 +2,7 @@
//! //!
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8, //! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine //! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die //! Nachbearbeitung, nur das Sampling (und damit die UV-Konvention) gehört ihm.
//! 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 //! 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 //! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
+2 -4
View File
@@ -1,9 +1,7 @@
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples. //! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
//! //!
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die //! Reiner Decoder (Bytes → owned Daten), `Result` statt Panic — Sounds sind
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten), //! Autorinnen-Content, ein kaputtes File wird gemeldet und bleibt still.
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
//! File wird gemeldet und bleibt still, nie fatal.
//! //!
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz, //! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV //! little-endian — das feste Format des Mixers (render::audio). „Als WAV
+3 -4
View File
@@ -2,10 +2,9 @@
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt //! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
//! dieses Modul nie). //! dieses Modul nie).
//! //!
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die //! Kein Panic: kein Gerät oder fehlende/kaputte WAVs sind eine Meldung und
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte //! Stille, nie fatal ([`Audio::open`] liefert `None`, alle Aufrufe darauf
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert //! entfallen still beim Aufrufer).
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
//! //!
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen: //! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt, //! - 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:?}");
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ impl Gpu {
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)] [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 /// die Projektion gehört hierher, weil sie an der internen Auflösung
/// hängt. /// hängt.
pub fn frame(&mut self, view: &Mat4, ui: &Ui) { pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
+28 -8
View File
@@ -1,10 +1,8 @@
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt //! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective. //! statt glam: nötig sind nur Multiply, View und Perspective.
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
//! ist nur der GPU-Pfad.)
//! //!
//! Konventionen: //! 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]`, //! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
//! bytemuck-kompatibel zu WGSL `mat4x4f` //! bytemuck-kompatibel zu WGSL `mat4x4f`
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1]) //! - 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 /// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z, /// herausdrehen. yaw=0 blickt -Z, positiver Pitch hebt den Blick.
/// positiver Pitch hebt den Blick.
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 { pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
Mat4::rot_x(-pitch) Mat4::rot_x(-pitch)
.mul(&Mat4::rot_y(-yaw)) .mul(&Mat4::rot_y(-yaw))
@@ -104,8 +101,7 @@ mod tests {
#[test] #[test]
fn view_yaw_quarter_turn_looks_minus_x() { fn view_yaw_quarter_turn_looks_minus_x() {
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf // yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt vor der Kamera.
// -X liegt dann vor der Kamera.
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0); 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])); 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])); 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] #[test]
fn perspective_maps_near_far_to_0_1() { fn perspective_maps_near_far_to_0_1() {
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0); let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
+26 -68
View File
@@ -1,21 +1,14 @@
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer. //! 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, //! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und //! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die //! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen //! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
//! anderen Zustand als die Konsole.
//! //!
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert //! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt. //! deren Schnittstellen — nie umgekehrt.
mod audio; mod audio;
mod camera;
mod font; mod font;
mod gpu; mod gpu;
mod math; mod math;
@@ -39,19 +32,13 @@ use crate::engine::player;
use crate::engine::tga::Image; use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga, wav}; use crate::engine::{assets, gltf, model, tga, wav};
use crate::session::{FrameInput, Mode, Session}; use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu; use gpu::Gpu;
use math::Mat4; use math::Mat4;
use scene::Mesh; 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 /// 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 /// 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; const EYE_SMOOTH_TAU: f32 = 0.06;
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units). /// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
const EYE_MAX_LAG: f32 = 0.6; 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). // Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); } for line in session.start() { println!("{line}"); }
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen // Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier,
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben // Upload (GPU) später in `Gpu` — die beiden bleiben getrennt.
// 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")); let models = load_models(&assets::path("assets/maps/props"));
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes // 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, // UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und // FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der // und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
// Luminanz. // Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie, // Installation kaputt und es darf scheitern.
// ist die Installation kaputt, da darf es scheitern.
let load_keyed = |p: &str| { let load_keyed = |p: &str| {
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)")) 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(); let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties", println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
m.objects.len(), m.empties.len()); m.objects.len(), m.empties.len());
// Custom Properties sichtbar machen — noch konsumiert sie // Properties mitloggen: so sieht die Autorin, ob ihr Export
// niemand, aber die Autorin sieht so, dass sie ankommen. // `collide`, `signal` und `role` mitgenommen hat.
for o in &m.objects { for o in &m.objects {
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); } 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 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 — /// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz. /// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> { fn load_sound(name: &str) -> Option<audio::Sound> {
@@ -238,8 +218,6 @@ struct App {
gpu: Option<Gpu>, gpu: Option<Gpu>,
session: Session, session: Session,
rx: Receiver<String>, 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. /// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
eye_y: f32, eye_y: f32,
input: Input, input: Input,
@@ -271,15 +249,12 @@ impl App {
fonts: font::Fonts, fonts: font::Fonts,
audio: Option<audio::Audio>, audio: Option<audio::Audio>,
) -> Self { ) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT; let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
Self { Self {
window: None, window: None,
gpu: None, gpu: None,
session, session,
rx, rx,
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
camera: Camera::new([0.0, 1.0, 4.0]),
eye_y, eye_y,
input: Input::default(), input: Input::default(),
last: None, last: None,
@@ -296,7 +271,7 @@ impl App {
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) — // Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die // 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) { fn set_grab(&mut self, grab: bool) {
let Some(window) = &self.window else { return; }; let Some(window) = &self.window else { return; };
if grab { if grab {
@@ -366,7 +341,7 @@ impl ApplicationHandler for App {
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => { WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
// Klick nur vormerken; der nächste redraw entscheidet (mit der // Klick nur vormerken; der nächste redraw entscheidet (mit der
// frisch berechneten Hover-Aktion) zwischen UI-Klick und // frisch berechneten Hover-Aktion) zwischen UI-Klick und
// Flycam-Greifen. // Maus-Greifen.
self.pending_click = true; self.pending_click = true;
} }
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key), WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
@@ -404,7 +379,7 @@ impl App {
KeyCode::KeyS => self.input.back = down, KeyCode::KeyS => self.input.back = down,
KeyCode::KeyA => self.input.left = down, KeyCode::KeyA => self.input.left = down,
KeyCode::KeyD => self.input.right = 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. // nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
KeyCode::Space => { KeyCode::Space => {
self.input.up = down; self.input.up = down;
@@ -416,38 +391,28 @@ impl App {
} }
fn redraw(&mut self, event_loop: &ActiveEventLoop) { 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 now = Instant::now();
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32()); 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 // Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So // (relativer Blick), Menü/Dialog geben sie frei fürs UI.
// gibt es keine parallele Lock-Logik.
let fly = matches!(self.session.mode, Mode::Free); let fly = matches!(self.session.mode, Mode::Free);
let play = matches!(self.session.mode, Mode::FirstPerson); let play = matches!(self.session.mode, Mode::FirstPerson);
let grab = fly || play; let grab = fly || play;
if grab != self.input.grabbed { self.set_grab(grab); } if grab != self.input.grabbed { self.set_grab(grab); }
if fly { // Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt. // die Session an ihrem Modus. Betretens-Zonen melden ihre
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS); // Signal-Ausgaben wie exec-Befehle.
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 { let input = FrameInput {
fwd: axis(self.input.fwd, self.input.back), fwd: axis(self.input.fwd, self.input.back),
right: axis(self.input.right, self.input.left), right: axis(self.input.right, self.input.left),
up: axis(self.input.up, self.input.down),
jump: self.input.jump, jump: self.input.jump,
look_dx: self.input.mouse_dx, look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy, 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}"); } for out in self.session.tick(&input, dt) { println!("{out}"); }
}
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der // Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
// Blick beim Fortsetzen um die aufgestaute Bewegung. // Blick beim Fortsetzen um die aufgestaute Bewegung.
self.input.mouse_dx = 0.0; self.input.mouse_dx = 0.0;
@@ -467,11 +432,8 @@ impl App {
} }
} }
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf // Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu // der Luft und außerhalb des Spiels folgt sie exakt.
// 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.
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT; let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
if play && self.session.player.grounded { if play && self.session.player.grounded {
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp(); let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
@@ -481,16 +443,12 @@ impl App {
self.eye_y = target_eye; self.eye_y = target_eye;
} }
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles // Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players // nur die Sim ein, nicht die Sicht.
// (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; 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 // Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und // Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
// löst beim Klick unten das `use` aus. // löst beim Klick unten das `use` aus.
+1 -1
View File
@@ -16,7 +16,7 @@
use crate::engine::model::Model; use crate::engine::model::Model;
use crate::render::scene::{Batch, Mesh, Vertex}; 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"; pub const FALLBACK_TEXTURE: &str = "placeholder";
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den /// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
+14 -8
View File
@@ -168,7 +168,7 @@ impl ScenePass {
}); });
let tex_layout = pipeline.get_bind_group_layout(1); let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = images.iter() 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(); .collect();
Self { Self {
@@ -196,20 +196,26 @@ impl ScenePass {
} }
} }
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige /// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige Bind-Group
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle /// (Textur + Sampler, group 1) bauen — geteilt mit [`crate::render::sprite`].
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch. /// Mit `mips` entsteht die volle Mipchain (bis 1×1) per Box-Filter auf der
fn upload_texture( /// CPU; UI-Sprites brauchen sie nicht, sie werden nie verkleinert.
pub(crate) fn upload_texture(
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout, layout: &wgpu::BindGroupLayout,
sampler: &wgpu::Sampler, sampler: &wgpu::Sampler,
img: &Image, img: &Image,
label: &str,
mips: bool,
) -> wgpu::BindGroup { ) -> wgpu::BindGroup {
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene. // 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 { let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene texture"), label: Some(label),
size: wgpu::Extent3d { size: wgpu::Extent3d {
width: img.width, width: img.width,
height: img.height, height: img.height,
@@ -251,7 +257,7 @@ fn upload_texture(
let view = texture.create_view(&Default::default()); let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor { device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene texture"), label: Some(label),
layout, layout,
entries: &[ entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
+4 -47
View File
@@ -4,9 +4,11 @@
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige //! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die //! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau //! 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::tga::Image;
use crate::render::scene::upload_texture;
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -134,7 +136,7 @@ impl SpritePass {
}); });
let tex_layout = pipeline.get_bind_group_layout(1); let tex_layout = pipeline.get_bind_group_layout(1);
let tex_binds = textures.iter() 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(); .collect();
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 } 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) },
],
})
}
+9 -10
View File
@@ -1,12 +1,11 @@
//! UI-Overlay: **state-driven** und **immediate-mode**. //! UI-Overlay: **state-driven** und **immediate-mode**.
//! //!
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält //! [`layout`] ist eine reine Funktion des [`Session`]-Zustands und hält
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die //! keinen eigenen mutierbaren Zustand, der davon desyncen könnte. Jeden
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem //! Frame wird aus dem aktuellen [`Mode`] neu gebaut: Dialog → Panel +
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü → //! Choice-Buttons, Menü → Menü-Buttons, usw. Klicks erzeugen denselben
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch //! Eingabe-String, den auch CLI und stdin liefern, und laufen durch dasselbe
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein //! `Session::exec` — ein Trichter in den State, kein UI-Backdoor.
//! einziger Trichter in den State, kein UI-Backdoor.
//! //!
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet, //! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`]. //! 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 { pub(crate) struct Cursor {
/// Position in internen Pixeln (über die Letterbox gemappt). /// Position in internen Pixeln (über die Letterbox gemappt).
pub(crate) pos: [f32; 2], 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`. /// Maus-Cursor an `pos`.
pub(crate) grabbed: bool, pub(crate) grabbed: bool,
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt- /// 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. // 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
let f = &fonts.ega; 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 // Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
// Maus (im Dialog ist die Maus ohnehin nie gefangen). // Maus (im Dialog ist die Maus ohnehin nie gefangen).
if !cur.grabbed { if !cur.grabbed {
match &session.mode { 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). // ohnehin gefangen — dieser Zweig greift nur im Übergang).
Mode::FirstPerson | Mode::Free => {} Mode::FirstPerson | Mode::Free => {}
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover), Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
+70 -65
View File
@@ -6,19 +6,15 @@ use crate::engine::player::{self, Player};
use crate::engine::trigger::Triggers; use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl}; use crate::engine::{kv, model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der /// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich const LOOK_SENS: f32 = 0.0025;
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
pub const LOOK_SENS: f32 = 0.0025;
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in /// Fester Simulationsschritt (s) = 60 Hz, unabhängig von der Render-Framerate:
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und /// Step-up und Ground-Snap verhalten sich sonst bei 144 Hz anders als in den
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen) /// (bei 1/60 geschriebenen) Player-Tests.
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
const FIXED_DT: f32 = 1.0 / 60.0; const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir /// Deckel für den Sim-Akkumulator (s): nach einem Hänger höchstens so viel
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon /// Zeit nachholen, sonst spiralt die Sim davon. ~15 Schritte pro Frame.
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25; const MAX_ACCUM: f32 = 0.25;
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele. /// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
@@ -48,25 +44,26 @@ pub struct Session {
/// Empties, siehe engine::audio). /// Empties, siehe engine::audio).
emitters: Emitters, emitters: Emitters,
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend /// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
/// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt /// zieht sie per [`Session::take_sounds`]; der CLI reicht die
/// sie liegen (ihr reicht die `[action]`-Textzeile). /// `[action]`-Textzeile.
pending_sounds: Vec<String>, pending_sounds: Vec<String>,
signals_path: String, signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep. /// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32, sim_accum: f32,
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen /// Gepufferte Sprung-Flanke: bei hohen FPS fällt eine Sprungtaste sonst in
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim /// einen Frame ohne Sim-Schritt und verfällt.
/// nächsten Sub-Schritt konsumiert.
pending_jump: bool, pending_jump: bool,
} }
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro /// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*` /// 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)] #[derive(Default)]
pub struct FrameInput { pub struct FrameInput {
pub fwd: f32, pub fwd: f32,
pub right: f32, pub right: f32,
pub up: f32,
pub jump: bool, pub jump: bool,
pub look_dx: f32, pub look_dx: f32,
pub look_dy: f32, pub look_dy: f32,
@@ -103,13 +100,10 @@ impl Session {
} }
} }
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro /// Sim-Seite eines Blender-Modells in die Welt einfügen (die Sichtgeometrie
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`- /// ist Sache von render::props): Collider aus `collide`, Trigger aus
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/ /// `signal`, Ambient-Emitter aus `sound` und der Spawn aus `role = spawn`.
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der /// Bringen mehrere Modelle einen Spawn mit, gewinnt der zuletzt geladene.
/// 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).
pub fn load_props(&mut self, model: &model::Model) { pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model); self.collision.add_model(model);
self.triggers.add_model(model); self.triggers.add_model(model);
@@ -140,12 +134,20 @@ impl Session {
} }
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu /// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam /// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe: /// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend /// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
/// zeigt sie wie `exec`-Ausgaben an. /// zeigt sie wie `exec`-Ausgaben an.
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> { 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 // Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der // einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
@@ -217,12 +219,13 @@ impl Session {
fn command(&mut self, line: &str) -> ExecResult { fn command(&mut self, line: &str) -> ExecResult {
let line = line.trim(); let line = line.trim();
match line { let (verb, args) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
"" => ExecResult::empty(), match (verb, args.trim()) {
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true }, ("", _) => ExecResult::empty(),
"menu" => { ("quit" | "exit", _) => ExecResult { output: Vec::new(), quit: true },
("menu", _) => {
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im // 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.) // Free öffnet es das Menü. (Dialog wird anders geroutet.)
let t = match self.mode { let t = match self.mode {
Mode::Menu => ModeTarget::Play, Mode::Menu => ModeTarget::Play,
@@ -231,9 +234,7 @@ impl Session {
self.set_mode(t); self.set_mode(t);
ExecResult::empty() ExecResult::empty()
} }
"noclip" => { ("noclip", _) => {
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
// umschalten.
let t = match self.mode { let t = match self.mode {
Mode::FirstPerson => ModeTarget::Free, Mode::FirstPerson => ModeTarget::Free,
_ => ModeTarget::Play, _ => ModeTarget::Play,
@@ -241,46 +242,36 @@ impl Session {
self.set_mode(t); self.set_mode(t);
ExecResult::empty() ExecResult::empty()
} }
"help" => ExecResult::lines(help()), ("help", _) => ExecResult::lines(help()),
"kv" => ExecResult::lines(self.dump_kv()), ("kv", _) => ExecResult::lines(self.dump_kv()),
"reload" => { ("reload", _) => {
self.game.signals = signals::load_signals(&self.signals_path); self.game.signals = signals::load_signals(&self.signals_path);
ExecResult::lines(vec![ ExecResult::lines(vec![
format!("{} Signale geladen.", self.game.signals.len()), format!("{} Signale geladen.", self.game.signals.len()),
]) ])
} }
_ => { // Modus über dieselbe Action wie das `mode`-Verb aus
if let Some(m) = line.strip_prefix("mode ") { // signals.toml/Ink-Tags — ein Wechsel-Pfad für alle Quellen.
// Modus explizit setzen — über dieselbe Action wie das ("mode", m) => match ModeTarget::parse(m) {
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
// Signals genau einen Wechsel-Pfad teilen.
match ModeTarget::parse(m) {
Some(t) => { Some(t) => {
self.game.actions.push(Action::SetMode(t)); self.game.actions.push(Action::SetMode(t));
ExecResult::lines(self.drain_actions()) ExecResult::lines(self.drain_actions())
} }
None => ExecResult::lines(vec![ None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
"Modus: play | free | menu".into(), },
]), ("signal", sig) => ExecResult::lines(self.fire(sig, None)),
} // Objekt-Interaktion (Klick wie Konsole): Klick-Ziele feuern ihre
} else if let Some(sig) = line.strip_prefix("signal ") { // `signal`-Property, sonst den suffix-befreiten Objektnamen.
ExecResult::lines(self.fire(sig.trim(), None)) // `$self` ist immer der volle Name.
} else if let Some(name) = line.strip_prefix("use ") { ("use", name) => {
// 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) let key = self.triggers.signal_for(name)
.unwrap_or_else(|| signals::signal_key(name)) .unwrap_or_else(|| signals::signal_key(name))
.to_string(); .to_string();
ExecResult::lines(self.fire(&key, Some(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"), format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
]) ]),
}
}
} }
} }
@@ -398,7 +389,7 @@ fn help() -> Vec<String> {
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(), " feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
" kv KV-Store anzeigen".into(), " kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".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 <m> Modus direkt setzen: play | free | menu".into(), " mode <m> Modus direkt setzen: play | free | menu".into(),
" reload signals.toml neu laden".into(), " reload signals.toml neu laden".into(),
" quit beenden".into(), " quit beenden".into(),
@@ -525,13 +516,27 @@ mod tests {
} }
#[test] #[test]
fn tick_only_runs_in_first_person() { fn menu_pauses_the_sim() {
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
let mut s = empty_session(); let mut s = empty_session();
s.mode = Mode::Menu; s.mode = Mode::Menu;
let before = s.player.pos; let before = s.player.pos;
s.tick(&FrameInput::default(), 0.1); 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, /// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,