First Person Controller
This commit is contained in:
@@ -29,6 +29,7 @@ fn prompt_for(mode: &Mode) -> &'static str {
|
|||||||
Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ",
|
Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ",
|
||||||
Mode::Dialog(_) => " [Enter] ",
|
Mode::Dialog(_) => " [Enter] ",
|
||||||
Mode::Menu => "menü> ",
|
Mode::Menu => "menü> ",
|
||||||
|
Mode::Play => "spiel> ",
|
||||||
Mode::Free => "> ",
|
Mode::Free => "> ",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Plane>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die statische Kollisionswelt: alle soliden Brushes der Map.
|
||||||
|
pub struct CollisionWorld {
|
||||||
|
brushes: Vec<ConvexBrush>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Hit> {
|
||||||
|
let mut nearest: Option<Hit> = 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<Hit> {
|
||||||
|
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<Plane> {
|
||||||
|
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<Vec<Plane>>) -> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-3
@@ -26,9 +26,8 @@ pub struct Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Entity {
|
impl Entity {
|
||||||
// Wird beim Entity-/Metadaten-Schritt gebraucht (worldspawn vs. Props,
|
// Genutzt für Spawn-Auflösung (info_player_start) und künftige
|
||||||
// Signal-Bindung); bis dahin nur in Tests verwendet.
|
// Entity-/Signal-Bindung.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn classname(&self) -> Option<&str> {
|
pub fn classname(&self) -> Option<&str> {
|
||||||
self.props.get("classname").map(String::as_str)
|
self.props.get("classname").map(String::as_str)
|
||||||
}
|
}
|
||||||
@@ -52,6 +51,18 @@ pub struct Face {
|
|||||||
pub scale: [f32; 2],
|
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 {
|
pub fn load(path: &str) -> Map {
|
||||||
let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}"));
|
let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}"));
|
||||||
parse(&src)
|
parse(&src)
|
||||||
|
|||||||
@@ -20,12 +20,18 @@
|
|||||||
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes Frontend
|
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes Frontend
|
||||||
//! per Pull konsumiert. `map` ist zugleich der Anfang des headless
|
//! per Pull konsumiert. `map` ist zugleich der Anfang des headless
|
||||||
//! Datenmodells (Phase 2 des Renderer-Plans).
|
//! 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 assets;
|
||||||
|
pub mod collision;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod ink;
|
pub mod ink;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
pub mod player;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
pub mod story_ctrl;
|
pub mod story_ctrl;
|
||||||
pub mod tga;
|
pub mod tga;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-8
@@ -23,11 +23,9 @@
|
|||||||
//! Quake-System gerechnet (so sind Offsets/Scale definiert), die Position
|
//! Quake-System gerechnet (so sind Offsets/Scale definiert), die Position
|
||||||
//! danach umgerechnet: `(qx, qy, qz) → (qx, qz, -qy)`, mal `MAP_SCALE`.
|
//! 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};
|
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).
|
/// Punkt liegt „auf" einer Ebene / „innerhalb" eines Halbraums (Quake-Units).
|
||||||
const ON_EPS: f32 = 1e-2;
|
const ON_EPS: f32 = 1e-2;
|
||||||
|
|
||||||
@@ -207,7 +205,7 @@ fn emit_face(
|
|||||||
// UV in Texeln, dann auf 0..1 normalisiert.
|
// UV in Texeln, dann auf 0..1 normalisiert.
|
||||||
let u = (dot(*p, su) + fp.face.offset[0]) / tw as f32;
|
let u = (dot(*p, su) + fp.face.offset[0]) / tw as f32;
|
||||||
let v = (dot(*p, sv) + fp.face.offset[1]) / th 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) {
|
for t in 1..(fp.poly.len() as u32 - 1) {
|
||||||
per_tex[ti].extend([base, base + t, base + t + 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])
|
(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] ---------------------------------------
|
// --- kleine Vektor-Helfer auf [f32; 3] ---------------------------------------
|
||||||
|
|
||||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
||||||
|
|||||||
+70
-10
@@ -33,17 +33,25 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
|||||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||||
use winit::window::{CursorGrabMode, Window, WindowId};
|
use winit::window::{CursorGrabMode, Window, WindowId};
|
||||||
|
|
||||||
|
use crate::engine::player;
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::tga::Image;
|
||||||
use crate::engine::{assets, map, tga};
|
use crate::engine::{assets, map, tga};
|
||||||
use crate::session::{Mode, Session};
|
use crate::session::{FrameInput, Mode, Session};
|
||||||
use camera::Camera;
|
use camera::Camera;
|
||||||
use gpu::Gpu;
|
use gpu::Gpu;
|
||||||
|
use math::Mat4;
|
||||||
use scene::Mesh;
|
use scene::Mesh;
|
||||||
|
|
||||||
/// Radiant pro Maus-Pixel.
|
/// Radiant pro Maus-Pixel.
|
||||||
const MOUSE_SENS: f32 = 0.0025;
|
const MOUSE_SENS: f32 = 0.0025;
|
||||||
/// Welt-Einheiten pro Sekunde.
|
/// Welt-Einheiten pro Sekunde.
|
||||||
const MOVE_SPEED: f32 = 4.0;
|
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) {
|
pub fn run(mut session: Session) {
|
||||||
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
|
// 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);
|
let mesh = brush::build(&world, &tex_names, &dims);
|
||||||
report_map(&world, &tex_names, &mesh);
|
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,
|
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
||||||
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
|
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
|
||||||
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
|
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
|
||||||
@@ -134,6 +146,8 @@ struct Input {
|
|||||||
right: bool,
|
right: bool,
|
||||||
up: bool,
|
up: bool,
|
||||||
down: bool,
|
down: bool,
|
||||||
|
/// Sprung-Flanke: bei Space-Druck gesetzt, nach `player::step` verbraucht.
|
||||||
|
jump: bool,
|
||||||
mouse_dx: f32,
|
mouse_dx: f32,
|
||||||
mouse_dy: f32,
|
mouse_dy: f32,
|
||||||
/// Cursor gefangen → Maus-Look aktiv.
|
/// Cursor gefangen → Maus-Look aktiv.
|
||||||
@@ -145,7 +159,10 @@ 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,
|
camera: Camera,
|
||||||
|
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
|
||||||
|
eye_y: f32,
|
||||||
input: Input,
|
input: Input,
|
||||||
last: Option<Instant>,
|
last: Option<Instant>,
|
||||||
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
|
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
|
||||||
@@ -169,6 +186,8 @@ impl App {
|
|||||||
ui_textures: Vec<Image>,
|
ui_textures: Vec<Image>,
|
||||||
fonts: font::Fonts,
|
fonts: font::Fonts,
|
||||||
) -> 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;
|
||||||
Self {
|
Self {
|
||||||
window: None,
|
window: None,
|
||||||
gpu: None,
|
gpu: None,
|
||||||
@@ -176,6 +195,7 @@ impl App {
|
|||||||
rx,
|
rx,
|
||||||
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
|
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
|
||||||
camera: Camera::new([0.0, 1.0, 4.0]),
|
camera: Camera::new([0.0, 1.0, 4.0]),
|
||||||
|
eye_y,
|
||||||
input: Input::default(),
|
input: Input::default(),
|
||||||
last: None,
|
last: None,
|
||||||
cursor_win: [0.0, 0.0],
|
cursor_win: [0.0, 0.0],
|
||||||
@@ -298,7 +318,12 @@ 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,
|
||||||
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,
|
KeyCode::ShiftLeft => self.input.down = down,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -310,25 +335,60 @@ impl App {
|
|||||||
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: nur in Free ist die Flycam aktiv
|
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
|
||||||
// (Maus gefangen, relativer Blick). Menü/Dialog geben die Maus frei
|
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
|
||||||
// fürs UI. So gibt es keine parallele Lock-Logik mehr.
|
// gibt es keine parallele Lock-Logik.
|
||||||
let fly = matches!(self.session.mode, Mode::Free);
|
let fly = matches!(self.session.mode, Mode::Free);
|
||||||
if fly != self.input.grabbed { self.set_grab(fly); }
|
let play = matches!(self.session.mode, Mode::Play);
|
||||||
|
let grab = fly || play;
|
||||||
|
if grab != self.input.grabbed { self.set_grab(grab); }
|
||||||
|
|
||||||
if fly {
|
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);
|
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
|
||||||
let fwd = axis(self.input.fwd, self.input.back);
|
let fwd = axis(self.input.fwd, self.input.back);
|
||||||
let right = axis(self.input.right, self.input.left);
|
let right = axis(self.input.right, self.input.left);
|
||||||
let up = axis(self.input.up, self.input.down);
|
let up = axis(self.input.up, self.input.down);
|
||||||
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
|
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
|
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
||||||
// 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;
|
||||||
self.input.mouse_dy = 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.
|
// Mausposition über die Letterbox in interne Pixel mappen.
|
||||||
let cursor = ui::Cursor {
|
let cursor = ui::Cursor {
|
||||||
pos: self.gpu.as_ref()
|
pos: self.gpu.as_ref()
|
||||||
|
|||||||
+4
-2
@@ -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).
|
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
||||||
if !cur.grabbed {
|
if !cur.grabbed {
|
||||||
match &session.mode {
|
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::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
||||||
Mode::Dialog(d) => dialog(&mut ui, f, internal, d, 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.
|
/// Menu-Modus: das (noch demohafte) Menü, zentriert.
|
||||||
fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Option<String>) {
|
fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Option<String>) {
|
||||||
let items = [
|
let items = [
|
||||||
("Fortsetzen", "menu"), // schließt das Menü (Toggle zurück zu Free)
|
("Fortsetzen", "menu"), // schließt das Menü → ins Spiel (Play)
|
||||||
("Hilfe", "help"),
|
("Hilfe", "help"),
|
||||||
("KV anzeigen", "kv"),
|
("KV anzeigen", "kv"),
|
||||||
("Beenden", "quit"),
|
("Beenden", "quit"),
|
||||||
|
|||||||
+110
-15
@@ -9,20 +9,38 @@
|
|||||||
//! Frontend — `exec` nimmt eine Befehlszeile und *gibt* Ausgabezeilen
|
//! Frontend — `exec` nimmt eine Befehlszeile und *gibt* Ausgabezeilen
|
||||||
//! zurück (statt zu drucken), damit jedes Frontend sie frei darstellt
|
//! zurück (statt zu drucken), damit jedes Frontend sie frei darstellt
|
||||||
//! (Terminal jetzt, In-Fenster-Konsole später).
|
//! (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::game::{Action, Game};
|
||||||
use crate::engine::ink::StoryState;
|
use crate::engine::ink::StoryState;
|
||||||
|
use crate::engine::map::{self, Map};
|
||||||
|
use crate::engine::player::Player;
|
||||||
use crate::engine::{kv, signals, story_ctrl};
|
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
|
/// Was die Frontends gerade darstellen sollen. Geteilt, damit CLI und GUI
|
||||||
/// nie uneins sind, ob ein Dialog läuft.
|
/// nie uneins sind, ob ein Dialog läuft.
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
/// Freie Welt — das Fenster rendert die Szene, die Konsole nimmt
|
/// First-Person-Spiel — der Player bewegt sich mit Physik durch die Welt
|
||||||
/// Engine-Befehle.
|
/// (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,
|
Free,
|
||||||
/// Menü offen — wie der Dialog pausiert das Fenster die Welt und gibt die
|
/// 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
|
/// 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,
|
Menu,
|
||||||
/// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt Panels;
|
/// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt Panels;
|
||||||
/// Eingaben treiben den Dialog statt Befehle.
|
/// Eingaben treiben den Dialog statt Befehle.
|
||||||
@@ -42,9 +60,23 @@ pub struct Dialog {
|
|||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub game: Game,
|
pub game: Game,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
|
pub player: Player,
|
||||||
|
pub collision: CollisionWorld,
|
||||||
signals_path: String,
|
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
|
/// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend
|
||||||
/// beenden soll (`quit`/`exit`).
|
/// beenden soll (`quit`/`exit`).
|
||||||
pub struct ExecResult {
|
pub struct ExecResult {
|
||||||
@@ -60,7 +92,33 @@ impl ExecResult {
|
|||||||
impl Session {
|
impl Session {
|
||||||
pub fn new(signals_path: String) -> Self {
|
pub fn new(signals_path: String) -> Self {
|
||||||
let game = Game::new(signals::load_signals(&signals_path));
|
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).
|
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
|
||||||
@@ -76,8 +134,8 @@ impl Session {
|
|||||||
/// Klick denselben Dialog ohne Sonderpfad.
|
/// Klick denselben Dialog ohne Sonderpfad.
|
||||||
pub fn exec(&mut self, line: &str) -> ExecResult {
|
pub fn exec(&mut self, line: &str) -> ExecResult {
|
||||||
match self.mode {
|
match self.mode {
|
||||||
Mode::Dialog(_) => self.dialog_input(line),
|
Mode::Dialog(_) => self.dialog_input(line),
|
||||||
Mode::Free | Mode::Menu => self.command(line),
|
Mode::Play | Mode::Free | Mode::Menu => self.command(line),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +145,24 @@ impl Session {
|
|||||||
"" => ExecResult::empty(),
|
"" => ExecResult::empty(),
|
||||||
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
||||||
"menu" => {
|
"menu" => {
|
||||||
// Free ↔ Menu umschalten (erreichbar nur aus diesen beiden,
|
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
|
||||||
// da Dialog anders geroutet wird).
|
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
|
||||||
|
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
|
||||||
self.mode = match self.mode {
|
self.mode = match self.mode {
|
||||||
Mode::Menu => Mode::Free,
|
Mode::Menu => Mode::Play,
|
||||||
_ => Mode::Menu,
|
_ => Mode::Menu,
|
||||||
};
|
};
|
||||||
ExecResult::empty()
|
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()),
|
"help" => ExecResult::lines(help()),
|
||||||
"kv" => ExecResult::lines(self.dump_kv()),
|
"kv" => ExecResult::lines(self.dump_kv()),
|
||||||
"reload" => {
|
"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<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
||||||
|
if let [x, y, z] = v[..] {
|
||||||
|
return map::to_engine([x, y, z]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[0.0, 1.0, 0.0]
|
||||||
|
}
|
||||||
|
|
||||||
fn help() -> Vec<String> {
|
fn help() -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
||||||
@@ -201,7 +284,8 @@ fn help() -> Vec<String> {
|
|||||||
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
|
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
|
||||||
" 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/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(),
|
" reload signals.toml neu laden".into(),
|
||||||
" quit beenden".into(),
|
" quit beenden".into(),
|
||||||
]
|
]
|
||||||
@@ -239,19 +323,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn menu_command_toggles_free_and_menu() {
|
fn menu_command_opens_and_resumes_to_play() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
s.mode = Mode::Free;
|
s.mode = Mode::Free;
|
||||||
s.exec("menu");
|
s.exec("menu");
|
||||||
assert!(matches!(s.mode, Mode::Menu));
|
assert!(matches!(s.mode, Mode::Menu));
|
||||||
s.exec("menu");
|
s.exec("menu"); // „Fortsetzen" → ins Spiel
|
||||||
assert!(matches!(s.mode, Mode::Free));
|
assert!(matches!(s.mode, Mode::Play));
|
||||||
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Free).
|
s.exec("menu"); // aus dem Spiel wieder ins Menü
|
||||||
s.exec("menu");
|
assert!(matches!(s.mode, Mode::Menu));
|
||||||
|
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
|
||||||
s.exec("signal set in_menu true");
|
s.exec("signal set in_menu true");
|
||||||
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
|
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]
|
#[test]
|
||||||
fn unknown_command_reports_and_stays_free() {
|
fn unknown_command_reports_and_stays_free() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
|
|||||||
Reference in New Issue
Block a user