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
+3
View File
@@ -12,6 +12,9 @@ target/
/assets/maps/autosave /assets/maps/autosave
# Blender-Backup-Dateien (bei jedem Save neu geschrieben)
*.blend1
# Added by cargo # Added by cargo
/target /target
+11 -2
View File
@@ -2,7 +2,16 @@
## Anwendung ausführen ## Anwendung ausführen
- Cargo installieren (Falls noch nicht vorhanden): `sudo apt install cargo` Das Projekt nutzt Rust-Edition 2024 und braucht daher eine aktuelle
- Repository Klonen: `git clone https://git.bnd.wtf/irrlicht/wds.git` Toolchain (Rust ≥ 1.85). Das `cargo` aus den Distributions-Paketquellen
(`apt`) ist dafür in der Regel zu alt — stattdessen Rust über
[rustup](https://rustup.rs/) installieren:
- Rust installieren (falls noch nicht vorhanden):
`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
(danach neue Shell öffnen oder `source "$HOME/.cargo/env"`)
- Repository klonen: `git clone https://git.bnd.wtf/irrlicht/wds.git`
- In den Ordner wechseln: `cd wds` - In den Ordner wechseln: `cd wds`
- Anwendung bauen und ausführen: `cargo run` - Anwendung bauen und ausführen: `cargo run`
Alternativ ohne Fenster/GPU in der Konsole: `cargo run -- --cli`
-1
View File
@@ -1 +0,0 @@
asdsadas
+1 -2
View File
@@ -15,8 +15,7 @@ pub fn run(mut session: Session, signals_path: &str) {
// Reserviertes `[init]`-Signal: KV-Defaults setzen, bevor Stories laufen. // Reserviertes `[init]`-Signal: KV-Defaults setzen, bevor Stories laufen.
for line in session.start() { println!("{line}"); } for line in session.start() { println!("{line}"); }
loop { while let Some(input) = read_line(prompt_for(&session.mode)) {
let Some(input) = read_line(prompt_for(&session.mode)) else { break };
let r = session.exec(input.trim()); let r = session.exec(input.trim());
for line in r.output { println!("{line}"); } for line in r.output { println!("{line}"); }
if r.quit { break; } if r.quit { break; }
+4 -6
View File
@@ -8,12 +8,10 @@ static BASE: OnceLock<PathBuf> = OnceLock::new();
fn base() -> &'static PathBuf { fn base() -> &'static PathBuf {
BASE.get_or_init(|| { BASE.get_or_init(|| {
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe()
if let Some(dir) = exe.parent() { && let Some(dir) = exe.parent()
if dir.join("assets").exists() { && dir.join("assets").exists() {
return dir.to_path_buf(); return dir.to_path_buf();
}
}
} }
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}) })
+3 -4
View File
@@ -71,10 +71,9 @@ impl CollisionWorld {
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> { pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
let mut nearest: Option<Hit> = None; let mut nearest: Option<Hit> = None;
for b in &self.brushes { for b in &self.brushes {
if let Some(h) = trace_brush(b, start, end, half) { if let Some(h) = trace_brush(b, start, end, half)
if nearest.as_ref().map_or(true, |n| h.frac < n.frac) { && nearest.as_ref().is_none_or(|n| h.frac < n.frac) {
nearest = Some(h); nearest = Some(h);
}
} }
} }
nearest nearest
+7 -4
View File
@@ -29,14 +29,17 @@ pub struct Story {
} }
impl Story { impl Story {
pub fn load(path: &str, kv: &Store) -> Self { /// Lädt und parst ein Ink-Skript. Fehlende oder kaputte Datei liefert
/// `Err(msg)` statt zu panicken — ein Tippfehler in einem `start_ink`-Tag
/// darf das laufende Spiel nicht crashen; der Aufrufer meldet den Fehler.
pub fn load(path: &str, kv: &Store) -> Result<Self, String> {
let src = read_to_string(path) let src = read_to_string(path)
.unwrap_or_else(|e| panic!("ink load {}: {}", path, e)); .map_err(|e| format!("ink load {path}: {e}"))?;
let inner = BladeStory::new(&src) let inner = BladeStory::new(&src)
.unwrap_or_else(|e| panic!("ink parse {}: {:?}", path, e)); .map_err(|e| format!("ink parse {path}: {e:?}"))?;
let mut s = Self { inner, tags: Vec::new() }; let mut s = Self { inner, tags: Vec::new() };
s.sync_from_kv(kv); s.sync_from_kv(kv);
s Ok(s)
} }
pub fn take_tags(&mut self) -> Vec<String> { std::mem::take(&mut self.tags) } pub fn take_tags(&mut self) -> Vec<String> { std::mem::take(&mut self.tags) }
+4 -4
View File
@@ -102,10 +102,9 @@ pub fn parse(src: &str) -> Map {
if let Some(face) = parse_face(line) { if let Some(face) = parse_face(line) {
brush.faces.push(face); brush.faces.push(face);
} }
} else if let Some(entity) = cur_entity.as_mut() { } else if let Some(entity) = cur_entity.as_mut()
if let Some((k, v)) = parse_property(line) { && let Some((k, v)) = parse_property(line) {
entity.props.insert(k, v); entity.props.insert(k, v);
}
} }
} }
} }
@@ -124,6 +123,7 @@ fn parse_property(line: &str) -> Option<(String, String)> {
} }
/// `( x y z ) ( x y z ) ( x y z ) TEX offX offY rot sx sy` /// `( x y z ) ( x y z ) ( x y z ) TEX offX offY rot sx sy`
#[allow(clippy::needless_range_loop)] // Punkt-/Komponenten-Index steuert zugleich den Token-Cursor `i`
fn parse_face(line: &str) -> Option<Face> { fn parse_face(line: &str) -> Option<Face> {
let t: Vec<&str> = line.split_whitespace().collect(); let t: Vec<&str> = line.split_whitespace().collect();
let mut i = 0; let mut i = 0;
+1
View File
@@ -161,6 +161,7 @@ impl Player {
/// Reine Slide-Bewegung des Box-Mittelpunkts entlang `vel·dt` (keine Stufen): /// Reine Slide-Bewegung des Box-Mittelpunkts entlang `vel·dt` (keine Stufen):
/// bis zu [`MAX_SLIDES`] Iterationen, an Treffern entlanggleitend. Gibt /// bis zu [`MAX_SLIDES`] Iterationen, an Treffern entlanggleitend. Gibt
/// Endpunkt, Restgeschwindigkeit und Bodenkontakt zurück. /// 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) { fn slide(world: &CollisionWorld, mut center: [f32; 3], mut vel: [f32; 3], dt: f32) -> ([f32; 3], [f32; 3], bool) {
let half = HALF_EXTENTS; let half = HALF_EXTENTS;
let mut grounded = false; let mut grounded = false;
+39 -7
View File
@@ -27,7 +27,12 @@
//! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table, //! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table,
//! laufen alle gemappten Actions. Sonst wird der String direkt als //! laufen alle gemappten Actions. Sonst wird der String direkt als
//! Builtin-Aufruf probiert — so wirken Ink-Tags wie `# set has_key true` //! Builtin-Aufruf probiert — so wirken Ink-Tags wie `# set has_key true`
//! ohne Umweg über signals.toml. Unbekannte Verben werden still ignoriert. //! ohne Umweg über signals.toml.
//!
//! Unbekannte Verben: aus einer signals.toml-*Action* sind sie immer ein
//! Tippfehler und werden nach stderr gemeldet. Im Ink-Tag-Fall-Through bleiben
//! sie hingegen still — Tags dürfen bewusst reine Metadaten sein, die (noch)
//! kein Verb konsumiert (z.B. ein späteres `# speaker …`).
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::read_to_string; use std::fs::read_to_string;
@@ -51,17 +56,27 @@ pub fn signal_key(name: &str) -> &str {
} }
pub fn dispatch(signal: &str, ctx: &mut ActionCtx) { pub fn dispatch(signal: &str, ctx: &mut ActionCtx) {
// Signal-Name in der Table? Dann alle gemappten Actions ausführen. // Signal-Name in der Table? Dann alle gemappten Actions ausführen. Ein
// unbekanntes Verb in einer Action ist hier immer ein Tippfehler → melden.
if let Some(actions) = ctx.signals.get(signal) { if let Some(actions) = ctx.signals.get(signal) {
let actions = actions.clone(); // entkoppelt vom ctx.signals-Borrow let actions = actions.clone(); // entkoppelt vom ctx.signals-Borrow
for a in &actions { execute(a, ctx); } for a in &actions {
if !execute(a, ctx) {
eprintln!("[signal] {signal:?}: unbekanntes Verb in Action {a:?} \
— Tippfehler in signals.toml?");
}
}
return; return;
} }
// Sonst direkt als Builtin probieren (Ink-Tag-Pfad). // Sonst direkt als Builtin probieren (Ink-Tag-Pfad). Unbekanntes bleibt
execute(signal, ctx); // hier still (Tags dürfen bloße Metadaten sein) — Rückgabe verworfen.
let _ = execute(signal, ctx);
} }
fn execute(cmd: &str, ctx: &mut ActionCtx) { /// Führt einen einzelnen Befehl (`verb args`) aus. Rückgabe: `true`, wenn das
/// Verb bekannt war und lief; `false` bei unbekanntem Verb (der Aufrufer
/// entscheidet, ob das eine Meldung wert ist — siehe `dispatch`).
fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
// $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten, // $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten,
// damit Tokens wie `hide_object $self` einheitlich funktionieren. Ohne // damit Tokens wie `hide_object $self` einheitlich funktionieren. Ohne
// instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action // instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action
@@ -87,8 +102,9 @@ fn execute(cmd: &str, ctx: &mut ActionCtx) {
"mode" => if let Some(t) = ModeTarget::parse(args) { "mode" => if let Some(t) = ModeTarget::parse(args) {
ctx.actions.push(Action::SetMode(t)); ctx.actions.push(Action::SetMode(t));
}, },
_ => { /* Unbekannter Verb/Tag → still ignorieren */ } _ => return false, // Unbekanntes Verb — der Aufrufer meldet ggf.
} }
true
} }
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem /// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
@@ -195,6 +211,22 @@ mod tests {
assert!(game.actions.is_empty()); assert!(game.actions.is_empty());
} }
#[test]
fn typo_verb_in_table_is_tolerated_and_reported() {
// Ein Tippfehler-Verb in einer Table-Action wird gemeldet (stderr) und
// bleibt ohne Wirkung, während die gültige Nachbar-Action weiter läuft.
let mut signals = Signals::new();
signals.insert("thing".into(), vec![
"st has_key true".into(), // Tippfehler für `set` → No-Op + Meldung
"set ok true".into(), // gültig → wirkt
]);
let mut game = Game::new(signals);
let mut ctx = game.action_ctx(None);
dispatch("thing", &mut ctx);
assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
assert!(game.kv["ok"].coerce_to_bool().unwrap(), "gültige Action wirkt weiter");
}
#[test] #[test]
fn unknown_signal_falls_through_to_builtin() { fn unknown_signal_falls_through_to_builtin() {
let mut game = Game::new(Signals::new()); let mut game = Game::new(Signals::new());
+6 -1
View File
@@ -16,7 +16,12 @@ use crate::engine::ink::{Story, StoryState};
/// dafür `advance(None, …)`. /// dafür `advance(None, …)`.
pub fn start(script: &str, ctx: &mut ActionCtx) { pub fn start(script: &str, ctx: &mut ActionCtx) {
let path = assets::path(&format!("assets/interactions/{script}")); let path = assets::path(&format!("assets/interactions/{script}"));
*ctx.story = Some(Story::load(&path, ctx.kv)); match Story::load(&path, ctx.kv) {
Ok(story) => *ctx.story = Some(story),
// Kaputtes/fehlendes Skript darf das Spiel nicht crashen: den Slot
// leer lassen (kein Dialog öffnet) und den Fehler nach stderr melden.
Err(msg) => eprintln!("[start_ink] {msg}"),
}
} }
/// Treibt die laufende Story einen Schritt weiter. `sel` ist die /// Treibt die laufende Story einen Schritt weiter. `sel` ist die
+2
View File
@@ -21,6 +21,7 @@ impl Mat4 {
[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0],
]); ]);
#[allow(clippy::needless_range_loop)] // Zeilen-/Spalten-Index ist bei 4×4-Mathematik klarer als Iteratoren
pub fn mul(&self, rhs: &Mat4) -> Mat4 { pub fn mul(&self, rhs: &Mat4) -> Mat4 {
let mut out = [[0.0f32; 4]; 4]; let mut out = [[0.0f32; 4]; 4];
for c in 0..4 { for c in 0..4 {
@@ -34,6 +35,7 @@ impl Mat4 {
/// Punkt-Transformation auf der CPU — bisher nur von den Tests /// Punkt-Transformation auf der CPU — bisher nur von den Tests
/// gebraucht; der Renderer transformiert auf der GPU. /// gebraucht; der Renderer transformiert auf der GPU.
#[cfg(test)] #[cfg(test)]
#[allow(clippy::needless_range_loop)] // Zeilen-Index ist bei der Matrix-Vektor-Mathematik klarer
pub fn transform(&self, v: [f32; 4]) -> [f32; 4] { pub fn transform(&self, v: [f32; 4]) -> [f32; 4] {
let mut out = [0.0f32; 4]; let mut out = [0.0f32; 4];
for r in 0..4 { for r in 0..4 {
+7 -7
View File
@@ -42,8 +42,9 @@ use gpu::Gpu;
use math::Mat4; use math::Mat4;
use scene::Mesh; use scene::Mesh;
/// Radiant pro Maus-Pixel. /// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die
const MOUSE_SENS: f32 = 0.0025; /// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt.
const MOUSE_SENS: f32 = crate::session::LOOK_SENS;
/// Welt-Einheiten pro Sekunde. /// Welt-Einheiten pro Sekunde.
const MOVE_SPEED: f32 = 4.0; const MOVE_SPEED: f32 = 4.0;
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet /// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
@@ -252,11 +253,10 @@ impl ApplicationHandler for App {
/// Rohe Maus-Bewegung (unabhängig von Cursor-Position) — nur sinnvoll, /// Rohe Maus-Bewegung (unabhängig von Cursor-Position) — nur sinnvoll,
/// solange der Cursor gefangen ist. /// solange der Cursor gefangen ist.
fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) { fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
if let DeviceEvent::MouseMotion { delta } = event { if let DeviceEvent::MouseMotion { delta } = event
if self.input.grabbed { && self.input.grabbed {
self.input.mouse_dx += delta.0 as f32; self.input.mouse_dx += delta.0 as f32;
self.input.mouse_dy += delta.1 as f32; self.input.mouse_dy += delta.1 as f32;
}
} }
} }
+1 -1
View File
@@ -251,7 +251,7 @@ fn upload_texture(
/// verfälschen. Mittelung im (gamma-kodierten) Speicherraum — für den /// verfälschen. Mittelung im (gamma-kodierten) Speicherraum — für den
/// stilisierten Look unkritisch. /// stilisierten Look unkritisch.
fn downsample(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) { fn downsample(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
let (nw, nh) = ((w + 1) / 2, (h + 1) / 2); let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let mut dst = vec![0u8; (nw * nh * 4) as usize]; let mut dst = vec![0u8; (nw * nh * 4) as usize];
for y in 0..nh { for y in 0..nh {
for x in 0..nw { for x in 0..nw {
+93 -11
View File
@@ -5,8 +5,20 @@ use crate::engine::map::{self, Map};
use crate::engine::player::Player; use crate::engine::player::Player;
use crate::engine::{kv, signals, story_ctrl}; use crate::engine::{kv, signals, story_ctrl};
/// Maus-Empfindlichkeit der Spieler-Sicht (Radiant/Pixel). /// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
const LOOK_SENS: f32 = 0.0025; /// 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 { pub enum Mode {
FirstPerson, FirstPerson,
@@ -29,6 +41,12 @@ pub struct Session {
pub player: Player, pub player: Player,
pub collision: CollisionWorld, pub collision: CollisionWorld,
signals_path: String, 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 /// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
@@ -66,6 +84,8 @@ impl Session {
player: Player::new([0.0, 1.0, 0.0]), player: Player::new([0.0, 1.0, 0.0]),
collision: CollisionWorld::empty(), collision: CollisionWorld::empty(),
signals_path, 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 /// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). /// pausieren die Sim (die Flycam ist reine Frontend-Sicht).
pub fn tick(&mut self, input: &FrameInput, dt: f32) { pub fn tick(&mut self, input: &FrameInput, dt: f32) {
if matches!(self.mode, Mode::FirstPerson) { if !matches!(self.mode, Mode::FirstPerson) { return; }
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
self.player.step(&self.collision, input.fwd, input.right, input.jump, dt); // 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. /// Spawn-Fußpunkt (Engine-Koords) aus `info_player_start`; sonst ein Default.
fn player_spawn(map: &Map) -> [f32; 3] { fn player_spawn(map: &Map) -> [f32; 3] {
for e in &map.entities { for e in &map.entities {
if e.classname() == Some("info_player_start") { if e.classname() == Some("info_player_start")
if let Some(origin) = e.props.get("origin") { && let Some(origin) = e.props.get("origin") {
let v: Vec<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect(); let v: Vec<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect();
if let [x, y, z] = v[..] { if let [x, y, z] = v[..] {
return map::to_engine([x, y, z]); return map::to_engine([x, y, z]);
}
} }
} }
} }
@@ -384,6 +417,55 @@ mod tests {
assert!(matches!(s.mode, Mode::FirstPerson)); 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] #[test]
fn unknown_command_reports_and_stays_free() { fn unknown_command_reports_and_stays_free() {
let mut s = empty_session(); let mut s = empty_session();