This commit is contained in:
2026-07-11 00:14:41 +02:00
parent 87dd8e1190
commit c9e463bb9a
15 changed files with 183 additions and 51 deletions
+93 -11
View File
@@ -5,8 +5,20 @@ 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;
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
pub const LOOK_SENS: f32 = 0.0025;
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25;
pub enum Mode {
FirstPerson,
@@ -29,6 +41,12 @@ pub struct Session {
pub player: Player,
pub collision: CollisionWorld,
signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32,
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
/// nächsten Sub-Schritt konsumiert.
pending_jump: bool,
}
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
@@ -66,6 +84,8 @@ impl Session {
player: Player::new([0.0, 1.0, 0.0]),
collision: CollisionWorld::empty(),
signals_path,
sim_accum: 0.0,
pending_jump: false,
}
}
@@ -81,9 +101,23 @@ impl Session {
/// `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::FirstPerson) {
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
self.player.step(&self.collision, input.fwd, input.right, input.jump, dt);
if !matches!(self.mode, Mode::FirstPerson) { return; }
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
// Sub-Schritte und die Empfindlichkeit schwankte mit der Framerate.
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
// Sprung-Flanke puffern, bis ein Sub-Schritt sie verbraucht.
self.pending_jump |= input.jump;
// Physik in festen FIXED_DT-Schritten treiben; Rest-Zeit über Frames
// tragen. Der Deckel verhindert die Spirale bei einem einzelnen Hänger.
self.sim_accum = (self.sim_accum + dt).min(MAX_ACCUM);
while self.sim_accum >= FIXED_DT {
let jump = std::mem::take(&mut self.pending_jump);
self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
self.sim_accum -= FIXED_DT;
}
}
@@ -277,12 +311,11 @@ 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]);
}
if e.classname() == Some("info_player_start")
&& 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]);
}
}
}
@@ -384,6 +417,55 @@ mod tests {
assert!(matches!(s.mode, Mode::FirstPerson));
}
#[test]
fn fixed_timestep_is_framerate_independent() {
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
// Frames → dieselbe Zahl fester Sim-Schritte, also (bis auf FP-Rauschen)
// dasselbe Ergebnis. Die leere Kollisionswelt lässt den Spieler frei
// fallen — genügt, um die Integration zu vergleichen.
let mut coarse = empty_session();
coarse.mode = Mode::FirstPerson;
let mut fine = empty_session();
fine.mode = Mode::FirstPerson;
let input = FrameInput::default();
coarse.tick(&input, 0.1);
for _ in 0..60 { fine.tick(&input, 0.1 / 60.0); }
assert!((coarse.player.pos[1] - fine.player.pos[1]).abs() < 1e-3,
"framerate-unabhängig: {} vs {}", coarse.player.pos[1], fine.player.pos[1]);
assert!((coarse.player.vel[1] - fine.player.vel[1]).abs() < 1e-3,
"gleiche Fallgeschwindigkeit: {} vs {}", coarse.player.vel[1], fine.player.vel[1]);
assert!(coarse.player.pos[1] < 1.0, "sollte gefallen sein: {}", coarse.player.pos[1]);
}
#[test]
fn jump_press_survives_a_substepless_frame() {
// Sprungtaste in einem Frame kürzer als FIXED_DT: es läuft noch kein
// Sim-Schritt, die Flanke darf aber nicht verfallen, sondern muss beim
// nächsten Schritt zünden.
let mut s = empty_session();
s.mode = Mode::FirstPerson;
s.player.grounded = true;
let jump = FrameInput { jump: true, ..Default::default() };
s.tick(&jump, FIXED_DT * 0.5);
assert!(s.player.vel[1] <= 0.0, "noch kein Schritt → noch kein Absprung");
s.tick(&FrameInput::default(), FIXED_DT * 0.6);
assert!(s.player.vel[1] > 0.0,
"gepufferter Sprung sollte abheben: {}", s.player.vel[1]);
}
#[test]
fn tick_only_runs_in_first_person() {
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
let mut s = empty_session();
s.mode = Mode::Menu;
let before = s.player.pos;
s.tick(&FrameInput::default(), 0.1);
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
}
#[test]
fn unknown_command_reports_and_stays_free() {
let mut s = empty_session();