Files
wds/src/engine/player.rs
T
irrlicht 56d92cdb46 Map-Loader entfernt: glTF ist das einzige 3D-Format
engine/map.rs + render/brush.rs raus, Welt kommt komplett aus .glb.
Spawn via Empty mit role=spawn statt info_player_start; testscene.glb
(Boden, Wände, Spawn) ersetzt test.map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00

491 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (collision.rs hält seine
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
//! Nutzer echte dot/cross/normalize braucht.
//!
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
//! Getract wird der Box-Mittelpunkt (Fußpunkt + halbe Höhe), danach geht es
//! zurück auf den Fußpunkt. Bodenkontakt fällt aus nach oben zeigenden
//! Trefferflächen ab.
//!
//! Konvention wie `render::math::view`/`camera.rs`: yaw=0 blickt Z, positiver
//! Yaw dreht nach links, positiver Pitch hebt den Blick. Die Bewegung ist
//! horizontal aus dem Yaw (ohne Pitch) — Hochschauen lässt einen nicht abheben.
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 (= 1 Blender-Meter).
pub const EYE_HEIGHT: f32 = 1.6;
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
const GRAVITY: f32 = 20.0;
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
const WALK_SPEED: f32 = 5.0;
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
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.
/// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft.
pub 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);
}
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/
/// `math::view` (yaw=0 → Z, positiver Pitch hebt). Für den
/// Point-and-Click-Strahl aus der Bildmitte.
pub fn look_dir(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos();
[-sy * cp, sp, -cy * cp]
}
/// 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.
#[allow(clippy::needless_range_loop)] // Komponenten-Index läuft parallel über center/disp bzw. vel/normal
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]);
}
}