//! 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);
    }

    /// Augenpunkt (Fußpunkt + [`EYE_HEIGHT`]) — Kamera- und Hörposition,
    /// Startpunkt des Point-and-Click-Strahls.
    pub fn eye(&self) -> [f32; 3] {
        [self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
    }

    /// 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]);
    }
}
