Compare commits

...

4 Commits

Author SHA1 Message Date
irrlicht a8bfaaee36 First Person Controller 2026-06-14 16:20:47 +02:00
irrlicht 902da64588 Ignore TrenchBroom autosave 2026-06-14 15:38:58 +02:00
irrlicht e27eedb654 Neue Test-Szene 2026-06-14 14:51:23 +02:00
irrlicht a07e2e567f TrenchBroom autosave in Git-ignore 2026-06-13 22:46:19 +02:00
13 changed files with 3015 additions and 70 deletions
+1 -7
View File
@@ -10,13 +10,7 @@ target/
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/assets/maps/autosave
# Added by cargo
+2
View File
@@ -0,0 +1,2 @@
# Blender 5.0.0 MTL File: 'None'
# www.blender.org
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -6,43 +6,43 @@
"wad" ""
// brush 0
{
( 0 -16 -16 ) ( 0 -15 -16 ) ( 0 -16 -15 ) carpet -48 16 0 1 1
( 0 0 -16 ) ( 0 0 -15 ) ( 1 0 -16 ) carpet 32 16 0 1 1
( 0 -16 -16 ) ( 1 -16 -16 ) ( 0 -15 -16 ) carpet 32 48 0 1 1
( 160 96 0 ) ( 160 97 0 ) ( 161 96 0 ) carpet 0 0 0 0.6 0.6041667
( 160 112 0 ) ( 161 112 0 ) ( 160 112 1 ) carpet 0 16 0 1 1
( 192 96 0 ) ( 192 96 1 ) ( 192 97 0 ) carpet -48 16 0 1 1
( 0 -16 -48 ) ( 0 -15 -48 ) ( 0 -16 -47 ) tarmac -16 -16 0 1 1
( 0 0 -48 ) ( 0 0 -47 ) ( 1 0 -48 ) tarmac 0 -16 0 1 1
( 0 -16 -48 ) ( 1 -16 -48 ) ( 0 -15 -48 ) tarmac 0 16 0 1 1
( 160 96 -32 ) ( 160 97 -32 ) ( 161 96 -32 ) tarmac 0 0 0 0.6 0.6041667
( 160 112 -32 ) ( 161 112 -32 ) ( 160 112 -31 ) tarmac 0 -16 0 1 1
( 192 96 -32 ) ( 192 96 -31 ) ( 192 97 -32 ) tarmac -16 -16 0 1 1
}
// brush 1
{
( 0 106 -16 ) ( 0 107 -16 ) ( 0 106 -15 ) carpet -26 0 0 1 1
( 0 106 -16 ) ( 0 106 -15 ) ( 1 106 -16 ) carpet 0 0 0 1 1
( 0 106 0 ) ( 1 106 0 ) ( 0 107 0 ) carpet 0 26 0 1 1
( 208 170 96 ) ( 208 171 96 ) ( 209 170 96 ) carpet 0 26 0 1 1
( 208 122 0 ) ( 209 122 0 ) ( 208 122 1 ) carpet 0 0 0 1 1
( 192 170 0 ) ( 192 170 1 ) ( 192 171 0 ) carpet -26 0 0 1 1
( 16 192 0 ) ( 16 112 -32 ) ( 16 112 -48 ) tarmac 38.4 -50.06682 26.565052 1.118034 0.9533479
( 16 112 -32 ) ( 176 112 -32 ) ( 176 112 -48 ) tarmac 0 0 0 1 1
( 16 192 0 ) ( 176 192 0 ) ( 176 112 -32 ) tarmac 0 -8.470581 0 1 1.0625
( 176 112 -48 ) ( 176 192 -16 ) ( 16 192 -16 ) tarmac 0 -8.470581 0 1 1.0625
( 176 192 -16 ) ( 176 192 0 ) ( 16 192 0 ) tarmac 0 32 0 1 1
( 176 112 -32 ) ( 176 192 0 ) ( 176 192 -16 ) tarmac 38.4 -50.06682 26.565052 1.118034 0.9533479
}
// brush 2
{
( 0 -16 -16 ) ( 0 -15 -16 ) ( 0 -16 -15 ) carpet 0 0 0 1 1
( 0 -16 -16 ) ( 0 -16 -15 ) ( 1 -16 -16 ) carpet 0 0 0 1 1
( 0 -16 0 ) ( 1 -16 0 ) ( 0 -15 0 ) carpet 0 0 0 1 1
( 208 48 96 ) ( 208 49 96 ) ( 209 48 96 ) carpet 0 0 0 1 1
( 208 0 0 ) ( 209 0 0 ) ( 208 0 1 ) carpet 0 0 0 1 1
( 192 48 0 ) ( 192 48 1 ) ( 192 49 0 ) carpet 0 0 0 1 1
( 16 192 0 ) ( 16 256 -16 ) ( 16 256 0 ) tarmac 0 0 0 1 1
( 176 192 0 ) ( 16 192 -16 ) ( 16 192 0 ) tarmac 0 0 0 1 1
( 176 256 -16 ) ( 16 192 -16 ) ( 176 192 -16 ) tarmac 0 0 0 1 1
( 176 256 0 ) ( 16 192 0 ) ( 16 256 0 ) tarmac 0 0 0 1 1
( 176 256 0 ) ( 16 256 -16 ) ( 176 256 -16 ) tarmac 0 0 0 1 1
( 176 256 0 ) ( 176 192 -16 ) ( 176 192 0 ) tarmac 0 0 0 1 1
}
// brush 3
{
( 0 -16 96 ) ( 0 -15 96 ) ( 0 -16 97 ) carpet -16 0 0 1 1
( 0 0 96 ) ( 0 0 97 ) ( 1 0 96 ) carpet 0 0 0 1 1
( 0 -16 96 ) ( 1 -16 96 ) ( 0 -15 96 ) carpet 0 16 0 1 1
( 160 96 112 ) ( 160 97 112 ) ( 161 96 112 ) carpet 0 0 0 0.6 0.6041667
( 160 112 112 ) ( 161 112 112 ) ( 160 112 113 ) carpet 0 0 0 1 1
( 192 96 112 ) ( 192 96 113 ) ( 192 97 112 ) carpet -16 0 0 1 1
( 9.372583002030481 48 -32 ) ( 10.079689783217027 48.70710678118655 -32 ) ( 9.372583002030481 48 -31 ) placeholder 31.057983 0 0 -0.08838835 0.125
( 9.372583002030481 48 -32 ) ( 9.372583002030481 48 -31 ) ( 10.079689783217027 47.29289321881346 -32 ) placeholder 149.9613 256 0 0.088388346 0.125
( 9.372583002030481 48 -32 ) ( 10.079689783217027 47.29289321881346 -32 ) ( 10.079689783217027 48.70710678118655 -32 ) placeholder 218.50967 68.54834 315 0.125 0.125
( 54.62741699796952 48 0 ) ( 55.33452377915606 48.707106781186546 0 ) ( 55.33452377915606 47.29289321881345 0 ) placeholder 218.50967 68.54834 315 0.125 0.125
( 54.62741699796952 48 -16 ) ( 54.62741699796952 48 -15 ) ( 55.33452377915606 48.707106781186546 -16 ) placeholder -31.058044 256 0 0.088388346 0.125
( 54.62741699796952 48 -16 ) ( 55.33452377915606 47.29289321881345 -16 ) ( 54.62741699796952 48 -15 ) placeholder 224.94202 0 180 -0.08838835 -0.125
}
}
// entity 1
{
"classname" "info_player_start"
"origin" "98 36 24"
"origin" "114 228 24"
}
+1
View File
@@ -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 => "> ",
}
}
+235
View File
@@ -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(ca, ba)` 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
View File
@@ -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)
+6
View File
@@ -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;
+479
View File
@@ -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
View File
@@ -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]] }
+70 -10
View File
@@ -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<Gpu>,
session: Session,
rx: Receiver<String>,
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
camera: Camera,
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
eye_y: f32,
input: Input,
last: Option<Instant>,
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
@@ -169,6 +186,8 @@ impl App {
ui_textures: Vec<Image>,
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()
+4 -2
View File
@@ -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<String>) {
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"),
+110 -15
View File
@@ -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<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> {
vec![
" 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(),
" 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();