From a8bfaaee365b3b2d38d8cb39b0ce3eb0499fd1ab Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sun, 14 Jun 2026 16:20:47 +0200 Subject: [PATCH] First Person Controller --- src/cli.rs | 1 + src/engine/collision.rs | 235 ++++++++++++++++++++ src/engine/map.rs | 17 +- src/engine/mod.rs | 6 + src/engine/player.rs | 479 ++++++++++++++++++++++++++++++++++++++++ src/render/brush.rs | 10 +- src/render/mod.rs | 80 ++++++- src/render/ui.rs | 6 +- src/session.rs | 125 +++++++++-- 9 files changed, 921 insertions(+), 38 deletions(-) create mode 100644 src/engine/collision.rs create mode 100644 src/engine/player.rs diff --git a/src/cli.rs b/src/cli.rs index c627f72..af322dd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -29,6 +29,7 @@ fn prompt_for(mode: &Mode) -> &'static str { Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ", Mode::Dialog(_) => " [Enter] ", Mode::Menu => "menü> ", + Mode::Play => "spiel> ", Mode::Free => "> ", } } diff --git a/src/engine/collision.rs b/src/engine/collision.rs new file mode 100644 index 0000000..ddad3d6 --- /dev/null +++ b/src/engine/collision.rs @@ -0,0 +1,235 @@ +//! Brush-Collision: Swept-AABB-Trace gegen konvexe Brushes (Quake-Hull-Idee). +//! +//! Ein Brush ist der Schnitt seiner Halbräume `{ n·x ≤ d }` (n nach außen) — +//! genau die Ebenen, die auch render::brush rekonstruiert, hier aber in +//! **Engine-Koords** und **vollständig** (keine Koplanar-Elimination, kein +//! Culling: für Collision zählt das ganze solide Volumen). +//! +//! Die Box wird nicht selbst getract, sondern per Minkowski-Aufblasung in den +//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte +//! Box-Halbgröße nach außen (`d' = d + |n|·half`). Damit wird Box-vs-Brush zur +//! Punkt-vs-aufgeblasener-Brush-Frage, und die Trace ist ein simpler +//! Halbraum-Clip des Segments (Eintritts-/Austritts-Bruch). Ein `SKIN` hält +//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht +//! sofort wieder im Kontakt steckt. +//! +//! Headless wie der Rest von `engine`: hängt nur an `map` (für die Brush-Ebenen +//! und die geteilte Koordinaten-Umrechnung). Der Player ruft `trace` in +//! `player::step`; gebaut wird die Welt einmal vom Renderer aus der `Map`. + +use crate::engine::map::{self, Map}; + +/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält — +/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar. +const SKIN: f32 = 0.01; + +/// Eine nach außen orientierte Ebene `n·x ≤ d` (innen = Halbraum). +pub(crate) struct Plane { + pub(crate) n: [f32; 3], + pub(crate) d: f32, +} + +/// Ein konvexer Brush = Schnitt seiner Halbräume. +struct ConvexBrush { + planes: Vec, +} + +/// Die statische Kollisionswelt: alle soliden Brushes der Map. +pub struct CollisionWorld { + brushes: Vec, +} + +/// Ergebnis einer Trace: Bruchteil entlang des Segments bis zum Kontakt und +/// die nach außen zeigende Trefferebenen-Normale (zum Gleiten/Boden-Erkennen). +pub struct Hit { + pub frac: f32, + pub normal: [f32; 3], +} + +impl CollisionWorld { + /// Kollisionswelt aus allen Brushes der Map bauen. Jede Brush-Face liefert + /// eine Ebene (aus drei Punkten, nach Engine-Koords gedreht). + pub fn build(world: &Map) -> Self { + let mut brushes = Vec::new(); + for ent in &world.entities { + for b in &ent.brushes { + if b.faces.len() < 4 { continue; } // kein geschlossenes Volumen + let planes = b.faces.iter().map(|f| plane_from(&f.plane)).collect(); + brushes.push(ConvexBrush { planes }); + } + } + Self { brushes } + } + + /// Leere Welt (keine Brushes) — Default, bis eine Map geladen ist. + pub fn empty() -> Self { + Self { brushes: Vec::new() } + } + + /// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte) + /// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`. + pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option { + let mut nearest: Option = None; + for b in &self.brushes { + if let Some(h) = trace_brush(b, start, end, half) { + if nearest.as_ref().map_or(true, |n| h.frac < n.frac) { + nearest = Some(h); + } + } + } + nearest + } +} + +/// Segment `start→end` gegen einen aufgeblasenen konvexen Brush clippen. +/// `None`, wenn das Segment den Brush verfehlt oder der Start schon drin steckt +/// (dann nicht blocken — sonst bliebe der Player hängen). +fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option { + let mut enter = f32::NEG_INFINITY; // größter Eintritts-Bruch + let mut leave = 1.0f32; // kleinster Austritts-Bruch + let mut normal = [0.0f32; 3]; + let mut entered = false; // überhaupt eine Eintrittsebene gefunden? + let mut started_outside = false; + + for p in &b.planes { + // Ebene um die Box-Halbgröße nach außen aufblasen (Minkowski). + let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2]; + let ds = dot(p.n, start) - d; + let de = dot(p.n, end) - d; + + if ds > 0.0 { started_outside = true; } + if ds > 0.0 && de > 0.0 { return None; } // ganz außerhalb dieser Ebene + if ds <= 0.0 && de <= 0.0 { continue; } // ganz innerhalb dieser Ebene + + if ds > de { + // Eintritt (außen → innen): SKIN-Rückzug, damit der Mittelpunkt + // knapp vor der Fläche stoppt. `enter` darf dabei leicht negativ + // werden (Kontakt liegt im SKIN-Band) — das wird unten auf 0 + // geklemmt, nicht verworfen, sonst rutschte ein ruhender Körper + // im Folgeframe durch die Fläche. + let f = (ds - SKIN) / (ds - de); + if f > enter { enter = f; normal = p.n; entered = true; } + } else { + // Austritt (innen → außen). + let f = ds / (ds - de); + if f < leave { leave = f; } + } + } + + // Treffer nur, wenn der Start außerhalb lag (sonst säße man fest), eine + // Eintrittsebene existiert und das Eintrittsintervall vor dem Segmentende + // beginnt. + if started_outside && entered && enter < leave && enter < 1.0 { + Some(Hit { frac: enter.max(0.0), normal }) + } else { + None + } +} + +/// Ebene aus drei Face-Punkten (Quake-Reihenfolge), gedreht nach Engine-Koords. +/// Wie render::brush::plane, aber direkt im Engine-System: weil `to_engine` die +/// Orientierung erhält (det +1), zeigt `cross(c−a, b−a)` weiter nach außen. +fn plane_from(p: &[[f32; 3]; 3]) -> Plane { + let a = map::to_engine(p[0]); + let b = map::to_engine(p[1]); + let c = map::to_engine(p[2]); + let n = normalize(cross(sub(c, a), sub(b, a))); + Plane { n, d: dot(n, a) } +} + +// --- kleine Vektor-Helfer (privat, wie render::brush; ein gemeinsames +// engine::vec3 lohnt erst, falls ein dritter Nutzer auftaucht) --------------- + +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] } +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +fn normalize(a: [f32; 3]) -> [f32; 3] { + let len = dot(a, a).sqrt(); + if len > 0.0 { [a[0] / len, a[1] / len, a[2] / len] } else { a } +} + +#[cfg(test)] +pub(crate) fn aabb_brush(min: [f32; 3], max: [f32; 3]) -> Vec { + vec![ + Plane { n: [ 1.0, 0.0, 0.0], d: max[0] }, Plane { n: [-1.0, 0.0, 0.0], d: -min[0] }, + Plane { n: [0.0, 1.0, 0.0], d: max[1] }, Plane { n: [0.0, -1.0, 0.0], d: -min[1] }, + Plane { n: [0.0, 0.0, 1.0], d: max[2] }, Plane { n: [0.0, 0.0, -1.0], d: -min[2] }, + ] +} + +#[cfg(test)] +pub(crate) fn from_brushes(brushes: Vec>) -> CollisionWorld { + CollisionWorld { + brushes: brushes.into_iter().map(|planes| ConvexBrush { planes }).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Punktförmige Box (half=0) → reine Strahl-vs-Brush-Trace. + const PT: [f32; 3] = [0.0, 0.0, 0.0]; + + #[test] + fn trace_hits_near_face_with_normal() { + let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]); + // Von x=-1 nach x=3 (Gesamtweg 4), trifft die −X-Fläche bei x≈0. + let h = w.trace([-1.0, 1.0, 1.0], [3.0, 1.0, 1.0], PT).unwrap(); + assert!((h.frac - 0.25).abs() < 0.02, "frac={}", h.frac); + assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal); + } + + #[test] + fn trace_misses_returns_none() { + let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]); + // Läuft oberhalb der Box vorbei. + assert!(w.trace([-1.0, 5.0, 1.0], [3.0, 5.0, 1.0], PT).is_none()); + } + + #[test] + fn trace_starting_inside_does_not_block() { + let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]); + // Start mitten im Brush → kein Hit (sonst säße man fest). + assert!(w.trace([1.0, 1.0, 1.0], [5.0, 1.0, 1.0], PT).is_none()); + } + + #[test] + fn aabb_expands_by_half_extents() { + let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]); + // Box mit halber Breite 0.5: Kontakt schon bei x≈-0.5 statt 0. + let h = w.trace([-2.0, 1.0, 1.0], [2.0, 1.0, 1.0], [0.5, 0.5, 0.5]).unwrap(); + // Weg 4, Kontakt bei x≈-0.5 → frac≈(−0.5−(−2))/4 = 0.375. + assert!((h.frac - 0.375).abs() < 0.02, "frac={}", h.frac); + } + + #[test] + fn builds_brushes_from_map() { + // Ein achsenparalleler Quader-Brush (Quake-Koords). + let src = r#" +{ +"classname" "worldspawn" +{ +( 0 0 0 ) ( 0 1 0 ) ( 0 0 1 ) t 0 0 0 1 1 +( 0 0 0 ) ( 0 0 1 ) ( 1 0 0 ) t 0 0 0 1 1 +( 0 0 0 ) ( 1 0 0 ) ( 0 1 0 ) t 0 0 0 1 1 +( 64 64 64 ) ( 64 65 64 ) ( 65 64 64 ) t 0 0 0 1 1 +( 64 64 64 ) ( 65 64 64 ) ( 64 64 65 ) t 0 0 0 1 1 +( 64 64 64 ) ( 64 64 65 ) ( 64 65 64 ) t 0 0 0 1 1 +} +} +"#; + let m = map::parse(src); + let w = CollisionWorld::build(&m); + assert_eq!(w.brushes.len(), 1); + assert_eq!(w.brushes[0].planes.len(), 6); + // Engine-Koords des Quake-Würfels [0,64]³: x[0,2], y[0,2], z[-2,0] + // (Drehung (x,z,−y)·1/32). Strahl von außerhalb (−X) hindurch. + let h = w.trace([-1.0, 1.0, -1.0], [3.0, 1.0, -1.0], PT); + assert!(h.is_some(), "Strahl sollte den Brush treffen"); + } +} diff --git a/src/engine/map.rs b/src/engine/map.rs index e71beee..abc6b7c 100644 --- a/src/engine/map.rs +++ b/src/engine/map.rs @@ -26,9 +26,8 @@ pub struct Entity { } impl Entity { - // Wird beim Entity-/Metadaten-Schritt gebraucht (worldspawn vs. Props, - // Signal-Bindung); bis dahin nur in Tests verwendet. - #[allow(dead_code)] + // Genutzt für Spawn-Auflösung (info_player_start) und künftige + // Entity-/Signal-Bindung. pub fn classname(&self) -> Option<&str> { self.props.get("classname").map(String::as_str) } @@ -52,6 +51,18 @@ pub struct Face { pub scale: [f32; 2], } +/// Quake-Einheiten pro Engine-Einheit (32 ≈ klassische „1 Meter"-Annahme). +/// Single Source für Render- (render::brush) und Collision-Pfad +/// (engine::collision), damit beide denselben Maßstab benutzen. +pub const MAP_SCALE: f32 = 1.0 / 32.0; + +/// Quake-Koords (Z-up) → Engine-Koords (Y-up, Blick −Z), skaliert: +/// `(x, y, z) → (x, z, −y) · MAP_SCALE`. Die Drehung erhält die Orientierung +/// (det +1) — nach außen zeigende Normalen bleiben außen. +pub fn to_engine(p: [f32; 3]) -> [f32; 3] { + [p[0] * MAP_SCALE, p[2] * MAP_SCALE, -p[1] * MAP_SCALE] +} + pub fn load(path: &str) -> Map { let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}")); parse(&src) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 1a49c51..36c08c2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -20,12 +20,18 @@ //! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes Frontend //! per Pull konsumiert. `map` ist zugleich der Anfang des headless //! Datenmodells (Phase 2 des Renderer-Plans). +//! +//! `player` ist die First-Person-Physik, aus der der Renderer seine View +//! ableitet; `collision` (hängt an `map`) liefert ihr die Brush-Welt für den +//! Swept-AABB-Trace in `player::step`. Beide bleiben headless. pub mod assets; +pub mod collision; pub mod game; pub mod ink; pub mod kv; pub mod map; +pub mod player; pub mod signals; pub mod story_ctrl; pub mod tga; diff --git a/src/engine/player.rs b/src/engine/player.rs new file mode 100644 index 0000000..04b673d --- /dev/null +++ b/src/engine/player.rs @@ -0,0 +1,479 @@ +//! First-Person-Player: headless Bewegungsphysik (Stufe 1). +//! +//! 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. +//! +//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten- +//! Arithmetik. Vektor-Helfer werden nicht geteilt (brush.rs hält seine eigenen +//! privat) — erst wenn Stufe 2 (Brush-Collision) echte dot/cross/normalize an +//! mehreren Stellen braucht, lohnt ein gemeinsames Modul. +//! +//! 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; + +/// Augenhöhe über dem Fußpunkt (units). Der Renderer setzt die Kamera auf +/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE). +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. +const GRAVITY: f32 = 20.0; +/// Laufgeschwindigkeit (units/s). 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE). +const WALK_SPEED: f32 = 5.0; +/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert. +const JUMP_SPEED: f32 = 7.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. +const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3]; +/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok). +const GROUND_NORMAL_Y: f32 = 0.7; +/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich). +const MAX_SLIDES: u32 = 4; +/// Wie weit der Ground-Snap nach unten sucht (units). Überbrückt Stufen, +/// abwärts-Slopes und Brush-Nähte, ohne echte Abgründe zu „verschlucken". +const SNAP_DIST: f32 = 0.5; +/// Maximale Stufenhöhe, die der Spieler ohne Sprung übersteigt (units). +const STEP_HEIGHT: f32 = 0.5; + +pub struct Player { + /// Fußpunkt in Engine-Koords (Y-up). + pub pos: [f32; 3], + /// Geschwindigkeit in units/s (Y trägt Gravitation/Sprung). + pub vel: [f32; 3], + pub yaw: f32, + pub pitch: f32, + /// Steht auf Boden? Setzt der Boden-/Collision-Schritt; Gattung für Sprung. + pub grounded: bool, +} + +impl Player { + pub fn new(pos: [f32; 3]) -> Self { + Self { pos, vel: [0.0; 3], yaw: 0.0, pitch: 0.0, grounded: false } + } + + /// Roh-Maus-Delta (Pixel) → Yaw/Pitch, identisch zu `camera::apply_mouse`, + /// damit Bewegung und Bild dieselbe Konvention teilen. + pub fn look(&mut self, dx: f32, dy: f32, sens: f32) { + self.yaw -= dx * sens; + self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT); + } + + /// 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. + let (sy, cy) = self.yaw.sin_cos(); + let f = [-sy, -cy]; // (x, z) + let r = [ cy, -sy]; // (x, z) + + // Wunschgeschwindigkeit sofort setzen (ohne Beschleunigung/Reibung — + // das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst. + self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED; + self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED; + + // Sprung vor der Gravitation, nur vom Boden (Vorframe-`grounded`) — + // kein Doppelsprung. + let jumped = jump && was_grounded; + if jumped { + self.vel[1] = JUMP_SPEED; + self.grounded = false; + } + self.vel[1] -= GRAVITY * dt; + + self.move_and_slide(world, dt); + + // Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen + // und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter, + // Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über + // Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim + // Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben. + if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 { + self.snap_to_ground(world); + } + } + + /// Die Box senkrecht bis zu [`SNAP_DIST`] nach unten tracen; trifft sie + /// dabei echten Boden, den Spieler dorthin heften und `grounded` setzen. + fn snap_to_ground(&mut self, world: &CollisionWorld) { + let half = HALF_EXTENTS; + let center = [self.pos[0], self.pos[1] + half[1], self.pos[2]]; + let down = [center[0], center[1] - SNAP_DIST, center[2]]; + let Some(hit) = world.trace(center, down, half) else { return; }; + if hit.normal[1] <= GROUND_NORMAL_Y { return; } // nur Boden, keine Wand + self.pos[1] = (center[1] - SNAP_DIST * hit.frac) - half[1]; + self.grounded = true; + if self.vel[1] < 0.0 { self.vel[1] = 0.0; } + } + + /// Den Box-Mittelpunkt gegen die Welt bewegen, mit Step-up. Aktualisiert + /// `pos`, `vel` und `grounded`. + /// + /// 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. + 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 + let start = [self.pos[0], self.pos[1] + half[1], self.pos[2]]; + + let (flat_c, flat_v, flat_g) = slide(world, start, self.vel, dt); + let (mut best_c, mut best_v, mut best_g) = (flat_c, flat_v, flat_g); + + if was_grounded { + let (up, _) = trace_step(world, start, [0.0, STEP_HEIGHT, 0.0]); + let (stepped, step_v, _) = slide(world, up, self.vel, dt); + let down_dist = (up[1] - start[1]) + STEP_HEIGHT; + let (landed, normal) = trace_step(world, stepped, [0.0, -down_dist, 0.0]); + let on_ground = normal.is_some_and(|n| n[1] > GROUND_NORMAL_Y); + if on_ground && horiz_dist2(landed, start) > horiz_dist2(flat_c, start) + 1e-6 { + best_c = landed; + best_v = step_v; + best_g = true; + } + } + + self.pos = [best_c[0], best_c[1] - half[1], best_c[2]]; + self.vel = best_v; + self.grounded = best_g; + } +} + +/// Reine Slide-Bewegung des Box-Mittelpunkts entlang `vel·dt` (keine Stufen): +/// bis zu [`MAX_SLIDES`] Iterationen, an Treffern entlanggleitend. Gibt +/// Endpunkt, Restgeschwindigkeit und Bodenkontakt zurück. +fn slide(world: &CollisionWorld, mut center: [f32; 3], mut vel: [f32; 3], dt: f32) -> ([f32; 3], [f32; 3], bool) { + let half = HALF_EXTENTS; + let mut grounded = false; + let mut remaining = dt; + for _ in 0..MAX_SLIDES { + if remaining <= 0.0 { break; } + let disp = [vel[0] * remaining, vel[1] * remaining, vel[2] * remaining]; + let end = [center[0] + disp[0], center[1] + disp[1], center[2] + disp[2]]; + let Some(hit) = world.trace(center, end, half) else { + center = end; // freie Bahn — Rest des Schritts ausführen + break; + }; + for i in 0..3 { center[i] += disp[i] * hit.frac; } + remaining *= 1.0 - hit.frac; + if hit.normal[1] > GROUND_NORMAL_Y { grounded = true; } + let into = vel[0] * hit.normal[0] + vel[1] * hit.normal[1] + vel[2] * hit.normal[2]; + for i in 0..3 { vel[i] -= hit.normal[i] * into; } + } + (center, vel, grounded) +} + +/// Den Mittelpunkt um `delta` (ein Achsen-Versatz) bewegen, an Geometrie +/// gestoppt. Gibt den erreichten Punkt und — falls gestoppt — die Treffer- +/// Normale zurück. +fn trace_step(world: &CollisionWorld, from: [f32; 3], delta: [f32; 3]) -> ([f32; 3], Option<[f32; 3]>) { + let to = [from[0] + delta[0], from[1] + delta[1], from[2] + delta[2]]; + match world.trace(from, to, HALF_EXTENTS) { + None => (to, None), + Some(hit) => ( + [from[0] + delta[0] * hit.frac, from[1] + delta[1] * hit.frac, from[2] + delta[2] * hit.frac], + Some(hit.normal), + ), + } +} + +/// Quadrierte horizontale Distanz (X/Z) — fürs „weiter gekommen?"-Vergleichen. +fn horiz_dist2(a: [f32; 3], b: [f32; 3]) -> f32 { + (a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::collision::{self, CollisionWorld}; + + /// Leere Welt (kein Boden) → freier Fall, gut für Luft-Tests. + fn void() -> CollisionWorld { + collision::from_brushes(vec![]) + } + + /// Großer Boden-Brush mit Oberkante bei y=0. + fn floor() -> CollisionWorld { + collision::from_brushes(vec![ + collision::aabb_brush([-50.0, -1.0, -50.0], [50.0, 0.0, 50.0]), + ]) + } + + /// Brush-Naht mit kleinem Höhensprung: obere Fläche (Oberkante y=0.3, x≤0) + /// trifft auf untere (Oberkante y=0, x≥0). Sprung 0.3 < SNAP_DIST. + fn step_down() -> CollisionWorld { + collision::from_brushes(vec![ + collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.3, 50.0]), + collision::aabb_brush([0.0, -1.0, -50.0], [50.0, 0.0, 50.0]), + ]) + } + + /// Wie `step_down`, aber der Absatz ist 2.0 tief — größer als SNAP_DIST, + /// also ein echter Abgrund. + fn tall_ledge() -> CollisionWorld { + collision::from_brushes(vec![ + collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]), + collision::aabb_brush([0.0, -3.0, -50.0], [50.0, -2.0, 50.0]), + ]) + } + + /// Solides Dreiecksprisma (Slope steigt von (x=0,y=0) auf (x=2,y=1)) plus + /// untere Fläche (Oberkante y=0, x≤0), die am Slope-Fuß bündig anschließt. + fn ramp() -> CollisionWorld { + let s = 5.0f32.sqrt(); + collision::from_brushes(vec![ + vec![ + collision::Plane { n: [-1.0 / s, 2.0 / s, 0.0], d: 0.0 }, // Slope-Oberfläche + collision::Plane { n: [0.0, -1.0, 0.0], d: 0.0 }, // Unterseite (y≥0) + collision::Plane { n: [1.0, 0.0, 0.0], d: 2.0 }, // x≤2 + collision::Plane { n: [-1.0, 0.0, 0.0], d: 0.0 }, // x≥0 (Slope-Fuß) + collision::Plane { n: [0.0, 0.0, 1.0], d: 50.0 }, + collision::Plane { n: [0.0, 0.0, -1.0], d: 50.0 }, + ], + collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]), + ]) + } + + #[test] + fn falls_under_gravity() { + let mut p = Player::new([0.0, 5.0, 0.0]); + p.step(&void(), 0.0, 0.0, false, 0.1); + assert!(p.pos[1] < 5.0, "sollte fallen: y={}", p.pos[1]); + assert!(p.vel[1] < 0.0, "vel.y sollte negativ sein: {}", p.vel[1]); + assert!(!p.grounded); + } + + #[test] + fn lands_and_rests_on_ground() { + let w = floor(); + let mut p = Player::new([0.0, 0.5, 0.0]); + p.vel[1] = -10.0; + // Mehrere Schritte fallen + setzen lassen. + for _ in 0..5 { p.step(&w, 0.0, 0.0, false, 0.1); } + assert!(p.grounded, "sollte auf dem Boden stehen"); + assert!(p.pos[1] >= 0.0 && p.pos[1] < 0.05, "Füße ~ auf y=0: {}", p.pos[1]); + assert!(p.vel[1].abs() < 1e-3, "vertikal in Ruhe: {}", p.vel[1]); + } + + #[test] + fn jump_only_when_grounded() { + // Am Boden: Sprung hebt ab. + let mut p = Player::new([0.0, 0.0, 0.0]); + p.grounded = true; + p.step(&void(), 0.0, 0.0, true, 0.016); + assert!(p.vel[1] > 0.0, "Sprung sollte vel.y heben: {}", p.vel[1]); + assert!(!p.grounded); + + // In der Luft: kein zweiter Sprung — vel.y bleibt von Gravitation + // bestimmt (negativ), nicht auf JUMP_SPEED gesetzt. + let mut a = Player::new([0.0, 5.0, 0.0]); + a.grounded = false; + a.step(&void(), 0.0, 0.0, true, 0.016); + assert!(a.vel[1] < 0.0, "kein Doppelsprung: {}", a.vel[1]); + } + + #[test] + fn walks_horizontally_along_yaw() { + // yaw=0: vorwärts → -Z, strafe → +X; y bleibt am Boden (kein Drift). + let w = floor(); + let mut p = Player::new([0.0, 0.0, 0.0]); + p.grounded = true; + p.step(&w, 1.0, 0.0, false, 0.1); + assert!(p.pos[2] < 0.0, "vorwärts sollte -Z sein: {}", p.pos[2]); + assert!(p.pos[0].abs() < 1e-6, "kein Seitversatz: {}", p.pos[0]); + assert!(p.pos[1] < 0.05, "kein Y-Drift: {}", p.pos[1]); + + let mut q = Player::new([0.0, 0.0, 0.0]); + q.grounded = true; + q.step(&w, 0.0, 1.0, false, 0.1); + assert!(q.pos[0] > 0.0, "strafe rechts sollte +X sein: {}", q.pos[0]); + } + + #[test] + fn looking_up_does_not_lift_movement() { + // Pitch nach oben darf die Horizontalbewegung nicht kippen. + let mut p = Player::new([0.0, 0.0, 0.0]); + p.grounded = true; + p.pitch = 1.0; + p.step(&floor(), 1.0, 0.0, false, 0.1); + assert!(p.pos[1] < 0.05, "Hochschauen darf nicht abheben: y={}", p.pos[1]); + } + + #[test] + fn yaw_quarter_turn_walks_minus_x() { + // yaw=90° blickt -X (wie math::view) → vorwärts bewegt nach -X. + let mut p = Player::new([0.0, 0.0, 0.0]); + p.grounded = true; + p.yaw = std::f32::consts::FRAC_PI_2; + p.step(&floor(), 1.0, 0.0, false, 0.1); + assert!(p.pos[0] < 0.0, "yaw=90° vorwärts sollte -X sein: {}", p.pos[0]); + assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]); + } + + #[test] + fn wall_blocks_horizontal_movement() { + // Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen. + let w = collision::from_brushes(vec![ + collision::aabb_brush([1.0, -1.0, -50.0], [3.0, 5.0, 50.0]), + ]); + let mut p = Player::new([0.0, 0.0, 0.0]); + p.grounded = true; + p.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X + for _ in 0..10 { p.step(&w, 1.0, 0.0, false, 0.05); } + // AABB-Halbbreite 0.3 → Füße stoppen vor x = 1 - 0.3 = 0.7. + assert!(p.pos[0] < 0.72, "sollte an der Wand stoppen: x={}", p.pos[0]); + } + + #[test] + fn ground_snap_keeps_grounded_over_step_down() { + let w = step_down(); + let mut p = Player::new([-1.0, 0.6, 0.0]); + for _ in 0..6 { p.step(&w, 0.0, 0.0, false, 0.1); } // auf obere Fläche fallen + assert!(p.grounded && (p.pos[1] - 0.3).abs() < 0.05, "auf oberer Fläche: y={}", p.pos[1]); + + p.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X über die Naht + for _ in 0..8 { + p.step(&w, 1.0, 0.0, false, 0.1); + assert!(p.grounded, "Snap soll über die Stufe am Boden halten: y={}", p.pos[1]); + } + assert!(p.pos[0] > 0.3, "sollte die Naht überquert haben: x={}", p.pos[0]); + assert!(p.pos[1] < 0.05, "auf der unteren Fläche angekommen: y={}", p.pos[1]); + } + + #[test] + fn ground_snap_follows_downhill_slope() { + let w = ramp(); + let mut p = Player::new([1.7, 1.3, 0.0]); + for _ in 0..8 { p.step(&w, 0.0, 0.0, false, 0.1); } // auf die Slope setzen + assert!(p.grounded, "auf der Slope gelandet: y={}", p.pos[1]); + + p.yaw = std::f32::consts::FRAC_PI_2; // vorwärts → -X (abwärts) + let y0 = p.pos[1]; + for _ in 0..3 { + p.step(&w, 1.0, 0.0, false, 0.1); + assert!(p.grounded, "Snap soll auf der Slope am Boden halten: y={}", p.pos[1]); + } + assert!(p.pos[1] < y0 - 0.1, "sollte die Slope hinab (y sinkt): {y0} -> {}", p.pos[1]); + assert!(p.pos[0] < 1.7, "bewegt sich abwärts (-X): x={}", p.pos[0]); + } + + #[test] + fn no_snap_off_tall_ledge() { + let w = tall_ledge(); + let mut p = Player::new([-1.0, 0.3, 0.0]); + for _ in 0..5 { p.step(&w, 0.0, 0.0, false, 0.1); } // auf obere Fläche setzen + p.yaw = -std::f32::consts::FRAC_PI_2; // über die hohe Kante nach +X + for _ in 0..6 { p.step(&w, 1.0, 0.0, false, 0.1); } + assert!(!p.grounded, "über die hohe Kante (> SNAP_DIST) soll er fallen"); + assert!(p.pos[1] < 0.0, "fällt ins Tiefere: y={}", p.pos[1]); + } + + #[test] + fn jump_overrides_snap() { + let w = floor(); + let mut p = Player::new([0.0, 0.5, 0.0]); // von oben einpendeln (ds>0) + for _ in 0..5 { p.step(&w, 0.0, 0.0, false, 0.1); } // am Boden setzen + assert!(p.grounded); + p.step(&w, 0.0, 0.0, true, 0.1); // springen + assert!(!p.grounded, "Sprung darf nicht vom Snap annulliert werden"); + assert!(p.vel[1] > 0.0, "steigt nach dem Sprung: {}", p.vel[1]); + } + + #[test] + fn step_up_over_curb() { + // 0.4 hohe Kante (< STEP_HEIGHT) vor einer Fläche: muss erstiegen werden. + let w = collision::from_brushes(vec![ + collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]), // Boden y=0, x≤0 + collision::aabb_brush([0.0, -1.0, -50.0], [50.0, 0.4, 50.0]), // Stufe y=0.4, x≥0 + ]); + let mut p = Player::new([-1.0, 0.3, 0.0]); + for _ in 0..30 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } + p.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X gegen die Stufe + for _ in 0..120 { p.step(&w, 1.0, 0.0, false, 1.0 / 60.0); } + assert!(p.pos[0] > 0.3, "sollte die Stufe erstiegen haben: x={}", p.pos[0]); + assert!((p.pos[1] - 0.4).abs() < 0.05, "steht auf der Stufe: y={}", p.pos[1]); + } + + #[test] + fn tall_step_blocks() { + // 1.0 hohe Wand (> STEP_HEIGHT): kein Erklimmen. + let w = collision::from_brushes(vec![ + collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]), + collision::aabb_brush([0.0, -1.0, -50.0], [50.0, 1.0, 50.0]), + ]); + let mut p = Player::new([-1.0, 0.3, 0.0]); + for _ in 0..30 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } + p.yaw = -std::f32::consts::FRAC_PI_2; + for _ in 0..120 { p.step(&w, 1.0, 0.0, false, 1.0 / 60.0); } + assert!(p.pos[0] < -0.25, "hohe Wand muss blocken: x={}", p.pos[0]); + } + + /// Slope (von (0,1) hinab auf (2,0)) zwischen oberer Fläche (y=1, x≤0) und + /// unterer (y=0, x≥2). Genau der reale Naht-Fall — bei 60 fps getestet. + fn slope_between_floors() -> CollisionWorld { + let s = 5.0f32.sqrt(); + collision::from_brushes(vec![ + collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 1.0, 50.0]), + vec![ + collision::Plane { n: [1.0 / s, 2.0 / s, 0.0], d: 2.0 / s }, // Slope-Oberfläche + collision::Plane { n: [0.0, -1.0, 0.0], d: 0.0 }, + collision::Plane { n: [-1.0, 0.0, 0.0], d: 0.0 }, + collision::Plane { n: [1.0, 0.0, 0.0], d: 2.0 }, + collision::Plane { n: [0.0, 0.0, 1.0], d: 50.0 }, + collision::Plane { n: [0.0, 0.0, -1.0], d: 50.0 }, + ], + collision::aabb_brush([2.0, -1.0, -50.0], [50.0, 0.0, 50.0]), + ]) + } + + #[test] + fn walks_down_slope_across_seams_at_60fps() { + let w = slope_between_floors(); + let mut p = Player::new([-1.0, 1.3, 0.0]); + for _ in 0..60 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } // obere Fläche + assert!(p.grounded && (p.pos[1] - 1.0).abs() < 0.05, "auf oberer Fläche: y={}", p.pos[1]); + + p.yaw = -std::f32::consts::FRAC_PI_2; // +X, abwärts über beide Nähte + for i in 0..180 { + p.step(&w, 1.0, 0.0, false, 1.0 / 60.0); + assert!(p.grounded, "darf an der Naht nicht hängenbleiben (i={i}, x={}, y={})", p.pos[0], p.pos[1]); + } + assert!(p.pos[0] > 2.0, "sollte auf die untere Fläche gelangt sein: x={}", p.pos[0]); + assert!((p.pos[1]).abs() < 0.05, "auf der unteren Fläche: y={}", p.pos[1]); + } + + #[test] + fn walks_up_slope_across_seams_at_60fps() { + let w = slope_between_floors(); + let mut p = Player::new([4.0, 0.3, 0.0]); + for _ in 0..60 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } // untere Fläche + assert!(p.grounded && p.pos[1].abs() < 0.05, "auf unterer Fläche: y={}", p.pos[1]); + + p.yaw = std::f32::consts::FRAC_PI_2; // -X, aufwärts über beide Nähte + for i in 0..180 { + p.step(&w, 1.0, 0.0, false, 1.0 / 60.0); + assert!(p.grounded, "darf an der Naht nicht hängenbleiben (i={i}, x={}, y={})", p.pos[0], p.pos[1]); + } + assert!(p.pos[0] < 0.0, "sollte auf die obere Fläche gelangt sein: x={}", p.pos[0]); + assert!((p.pos[1] - 1.0).abs() < 0.05, "auf der oberen Fläche: y={}", p.pos[1]); + } +} diff --git a/src/render/brush.rs b/src/render/brush.rs index 9509612..c6c122e 100644 --- a/src/render/brush.rs +++ b/src/render/brush.rs @@ -23,11 +23,9 @@ //! Quake-System gerechnet (so sind Offsets/Scale definiert), die Position //! danach umgerechnet: `(qx, qy, qz) → (qx, qz, -qy)`, mal `MAP_SCALE`. -use crate::engine::map::{Face, Map}; +use crate::engine::map::{self, Face, Map}; use crate::render::scene::{Batch, Mesh, Vertex}; -/// Quake-Einheiten pro Engine-Einheit (32 ≈ klassische „1 Meter"-Annahme). -const MAP_SCALE: f32 = 1.0 / 32.0; /// Punkt liegt „auf" einer Ebene / „innerhalb" eines Halbraums (Quake-Units). const ON_EPS: f32 = 1e-2; @@ -207,7 +205,7 @@ fn emit_face( // UV in Texeln, dann auf 0..1 normalisiert. let u = (dot(*p, su) + fp.face.offset[0]) / tw as f32; let v = (dot(*p, sv) + fp.face.offset[1]) / th as f32; - verts.push(Vertex { pos: to_engine(*p), uv: [u, v] }); + verts.push(Vertex { pos: map::to_engine(*p), uv: [u, v] }); } for t in 1..(fp.poly.len() as u32 - 1) { per_tex[ti].extend([base, base + t, base + t + 1]); @@ -273,10 +271,6 @@ fn base_axes(n: [f32; 3]) -> ([f32; 3], [f32; 3]) { (BA[bi][1], BA[bi][2]) } -fn to_engine(p: [f32; 3]) -> [f32; 3] { - [p[0] * MAP_SCALE, p[2] * MAP_SCALE, -p[1] * MAP_SCALE] -} - // --- kleine Vektor-Helfer auf [f32; 3] --------------------------------------- fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] } diff --git a/src/render/mod.rs b/src/render/mod.rs index 930e52d..4a25ecf 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -33,17 +33,25 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::keyboard::{KeyCode, PhysicalKey}; use winit::window::{CursorGrabMode, Window, WindowId}; +use crate::engine::player; use crate::engine::tga::Image; use crate::engine::{assets, map, tga}; -use crate::session::{Mode, Session}; +use crate::session::{FrameInput, Mode, Session}; use camera::Camera; use gpu::Gpu; +use math::Mat4; use scene::Mesh; /// Radiant pro Maus-Pixel. const MOUSE_SENS: f32 = 0.0025; /// 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. +const EYE_SMOOTH_TAU: f32 = 0.06; +/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units). +const EYE_MAX_LAG: f32 = 0.6; pub fn run(mut session: Session) { // Init-Signal feuern, bevor das Fenster steht (kann bereits einen @@ -62,6 +70,10 @@ pub fn run(mut session: Session) { let mesh = brush::build(&world, &tex_names, &dims); report_map(&world, &tex_names, &mesh); + // Sim-Welt (Collision-Brushes + Spawn) in die Session einspielen — die + // Simulation gehört der Session, nicht dem Render-Frontend. + session.load_world(&world); + // 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 @@ -134,6 +146,8 @@ struct Input { right: bool, up: bool, down: bool, + /// Sprung-Flanke: bei Space-Druck gesetzt, nach `player::step` verbraucht. + jump: bool, mouse_dx: f32, mouse_dy: f32, /// Cursor gefangen → Maus-Look aktiv. @@ -145,7 +159,10 @@ 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, last: Option, /// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor). @@ -169,6 +186,8 @@ impl App { ui_textures: Vec, fonts: font::Fonts, ) -> 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, @@ -176,6 +195,7 @@ impl App { 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, cursor_win: [0.0, 0.0], @@ -298,7 +318,12 @@ impl App { KeyCode::KeyS => self.input.back = down, KeyCode::KeyA => self.input.left = down, KeyCode::KeyD => self.input.right = down, - KeyCode::Space => self.input.up = down, + // Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke, + // nicht Auto-Repeat). Beide Deutungen aus derselben Taste. + KeyCode::Space => { + self.input.up = down; + if down && !key.repeat { self.input.jump = true; } + } KeyCode::ShiftLeft => self.input.down = down, _ => {} } @@ -310,25 +335,60 @@ impl App { 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: nur in Free ist die Flycam aktiv - // (Maus gefangen, relativer Blick). Menü/Dialog geben die Maus frei - // fürs UI. So gibt es keine parallele Lock-Logik mehr. - let fly = matches!(self.session.mode, Mode::Free); - if fly != self.input.grabbed { self.set_grab(fly); } + // 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. + let fly = matches!(self.session.mode, Mode::Free); + let play = matches!(self.session.mode, Mode::Play); + 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, + }; + self.session.tick(&input, dt); } - // Delta immer verwerfen, auch im Dialog — sonst springt der Blick - // beim Fortsetzen um die aufgestaute Bewegung. + // Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der + // Blick beim Fortsetzen um die aufgestaute Bewegung. self.input.mouse_dx = 0.0; self.input.mouse_dy = 0.0; + self.input.jump = false; - let view = self.camera.view(); + // 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. + 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(); + self.eye_y += (target_eye - self.eye_y) * a; + self.eye_y = self.eye_y.clamp(target_eye - EYE_MAX_LAG, target_eye + EYE_MAX_LAG); + } else { + self.eye_y = target_eye; + } + + // View: im Spiel aus dem Player der Session (geglättete Augenhöhe), + // sonst aus der Flycam. + let view = if play { + let p = self.session.player.pos; + Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch) + } else { + self.camera.view() + }; // Mausposition über die Letterbox in interne Pixel mappen. let cursor = ui::Cursor { pos: self.gpu.as_ref() diff --git a/src/render/ui.rs b/src/render/ui.rs index 949e123..e9f7c52 100644 --- a/src/render/ui.rs +++ b/src/render/ui.rs @@ -226,7 +226,9 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: // Maus (im Dialog ist die Maus ohnehin nie gefangen). if !cur.grabbed { match &session.mode { - Mode::Free => {} // freie Welt: nur HUD/Cursor, kein Panel + // Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen + // ohnehin gefangen — dieser Zweig greift nur im Übergang). + Mode::Play | Mode::Free => {} Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover), Mode::Dialog(d) => dialog(&mut ui, f, internal, d, cur.pos, &mut hover), } @@ -247,7 +249,7 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: /// Menu-Modus: das (noch demohafte) Menü, zentriert. fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Option) { let items = [ - ("Fortsetzen", "menu"), // schließt das Menü (Toggle zurück zu Free) + ("Fortsetzen", "menu"), // schließt das Menü → ins Spiel (Play) ("Hilfe", "help"), ("KV anzeigen", "kv"), ("Beenden", "quit"), diff --git a/src/session.rs b/src/session.rs index 515421f..50e31e9 100644 --- a/src/session.rs +++ b/src/session.rs @@ -9,20 +9,38 @@ //! Frontend — `exec` nimmt eine Befehlszeile und *gibt* Ausgabezeilen //! zurück (statt zu drucken), damit jedes Frontend sie frei darstellt //! (Terminal jetzt, In-Fenster-Konsole später). +//! +//! Die Session ist auch der alleinige Besitzer der **Lauf-Simulation**: +//! `Player` und `CollisionWorld`. Sie hat zwei Eingabe-Tore — `exec` für +//! diskrete Befehle/Dialog und `tick` für die kontinuierliche Physik pro +//! Frame. Die Frontends sammeln nur Eingaben und stellen den Zustand dar; +//! die Flycam dagegen ist reine Frontend-Sicht (kein Sim-Zustand) und bleibt +//! im `render`-Modul. +use crate::engine::collision::CollisionWorld; use crate::engine::game::{Action, Game}; use crate::engine::ink::StoryState; +use crate::engine::map::{self, Map}; +use crate::engine::player::Player; use crate::engine::{kv, signals, story_ctrl}; +/// Maus-Empfindlichkeit der Spieler-Sicht (Radiant/Pixel). +const LOOK_SENS: f32 = 0.0025; + /// Was die Frontends gerade darstellen sollen. Geteilt, damit CLI und GUI /// nie uneins sind, ob ein Dialog läuft. pub enum Mode { - /// Freie Welt — das Fenster rendert die Szene, die Konsole nimmt - /// Engine-Befehle. + /// First-Person-Spiel — der Player bewegt sich mit Physik durch die Welt + /// (siehe engine::player). Der Renderer leitet seine View aus dem Player + /// ab. Das ist der eigentliche Spielmodus. + Play, + /// Noclip-Debug-Flycam — frei schwebende Kamera ohne Physik. Per + /// `noclip`-Befehl gegen `Play` getauscht. Free, /// Menü offen — wie der Dialog pausiert das Fenster die Welt und gibt die /// Maus frei; Eingaben sind weiter Engine-Befehle (das Menü ist nur eine - /// andere Darstellung). Per `menu`-Befehl getoggelt. + /// andere Darstellung). Per `menu`-Befehl getoggelt; „Fortsetzen" führt + /// ins Spiel (`Play`). Menu, /// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt Panels; /// Eingaben treiben den Dialog statt Befehle. @@ -42,9 +60,23 @@ pub struct Dialog { pub struct Session { pub game: Game, pub mode: Mode, + pub player: Player, + pub collision: CollisionWorld, signals_path: String, } +/// 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]. +#[derive(Default)] +pub struct FrameInput { + pub fwd: f32, + pub right: f32, + pub jump: bool, + pub look_dx: f32, + pub look_dy: f32, +} + /// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend /// beenden soll (`quit`/`exit`). pub struct ExecResult { @@ -60,7 +92,33 @@ impl ExecResult { impl Session { pub fn new(signals_path: String) -> Self { let game = Game::new(signals::load_signals(&signals_path)); - Self { game, mode: Mode::Menu, signals_path } + Self { + game, + mode: Mode::Menu, + // Default-Welt, bis `load_world` eine Map einspielt (die CLI + // braucht keine — sie tickt nie). + player: Player::new([0.0, 1.0, 0.0]), + collision: CollisionWorld::empty(), + signals_path, + } + } + + /// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den + /// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim + /// Start gerufen. + pub fn load_world(&mut self, map: &Map) { + self.collision = CollisionWorld::build(map); + self.player = Player::new(player_spawn(map)); + } + + /// 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). + pub fn tick(&mut self, input: &FrameInput, dt: f32) { + if matches!(self.mode, Mode::Play) { + self.player.look(input.look_dx, input.look_dy, LOOK_SENS); + self.player.step(&self.collision, input.fwd, input.right, input.jump, dt); + } } /// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft). @@ -76,8 +134,8 @@ impl Session { /// Klick denselben Dialog ohne Sonderpfad. pub fn exec(&mut self, line: &str) -> ExecResult { match self.mode { - Mode::Dialog(_) => self.dialog_input(line), - Mode::Free | Mode::Menu => self.command(line), + Mode::Dialog(_) => self.dialog_input(line), + Mode::Play | Mode::Free | Mode::Menu => self.command(line), } } @@ -87,14 +145,24 @@ impl Session { "" => ExecResult::empty(), "quit" | "exit" => ExecResult { output: Vec::new(), quit: true }, "menu" => { - // Free ↔ Menu umschalten (erreichbar nur aus diesen beiden, - // da Dialog anders geroutet wird). + // Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im + // Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder + // Free öffnet es das Menü. (Dialog wird anders geroutet.) self.mode = match self.mode { - Mode::Menu => Mode::Free, + Mode::Menu => Mode::Play, _ => Mode::Menu, }; ExecResult::empty() } + "noclip" => { + // Debug: zwischen First-Person (Play) und freier Flycam (Free) + // umschalten. + self.mode = match self.mode { + Mode::Play => Mode::Free, + _ => Mode::Play, + }; + ExecResult::empty() + } "help" => ExecResult::lines(help()), "kv" => ExecResult::lines(self.dump_kv()), "reload" => { @@ -194,6 +262,21 @@ impl Session { } } +/// Spawn-Fußpunkt (Engine-Koords) aus `info_player_start`; sonst ein Default. +fn player_spawn(map: &Map) -> [f32; 3] { + for e in &map.entities { + if e.classname() == Some("info_player_start") { + if let Some(origin) = e.props.get("origin") { + let v: Vec = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect(); + if let [x, y, z] = v[..] { + return map::to_engine([x, y, z]); + } + } + } + } + [0.0, 1.0, 0.0] +} + fn help() -> Vec { vec![ " signal Signal feuern oder Action direkt ausführen".into(), @@ -201,7 +284,8 @@ fn help() -> Vec { " use Objekt-Interaktion simulieren — `use Mushroom.005`".into(), " feuert Signal `Mushroom` mit $self = Mushroom.005".into(), " kv KV-Store anzeigen".into(), - " menu Menü öffnen/schließen".into(), + " menu Menü öffnen / ins Spiel zurück".into(), + " noclip zwischen First-Person und Debug-Flycam wechseln".into(), " reload signals.toml neu laden".into(), " quit beenden".into(), ] @@ -239,19 +323,30 @@ mod tests { } #[test] - fn menu_command_toggles_free_and_menu() { + fn menu_command_opens_and_resumes_to_play() { let mut s = empty_session(); s.mode = Mode::Free; s.exec("menu"); assert!(matches!(s.mode, Mode::Menu)); - s.exec("menu"); - assert!(matches!(s.mode, Mode::Free)); - // Im Menü laufen Engine-Befehle weiter (gleiche Route wie Free). - s.exec("menu"); + s.exec("menu"); // „Fortsetzen" → ins Spiel + assert!(matches!(s.mode, Mode::Play)); + s.exec("menu"); // aus dem Spiel wieder ins Menü + assert!(matches!(s.mode, Mode::Menu)); + // Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free). s.exec("signal set in_menu true"); assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap()); } + #[test] + fn noclip_toggles_play_and_free() { + let mut s = empty_session(); + s.mode = Mode::Play; + s.exec("noclip"); + assert!(matches!(s.mode, Mode::Free)); + s.exec("noclip"); + assert!(matches!(s.mode, Mode::Play)); + } + #[test] fn unknown_command_reports_and_stays_free() { let mut s = empty_session();