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