From c9e463bb9a061e60df94fa961fa0a62894ff9647 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 11 Jul 2026 00:14:41 +0200 Subject: [PATCH] Cleanup --- .gitignore | 3 ++ README.md | 15 ++++-- assets/interactions/asdsad | 1 - src/cli.rs | 3 +- src/engine/assets.rs | 10 ++-- src/engine/collision.rs | 7 ++- src/engine/ink.rs | 11 ++-- src/engine/map.rs | 8 +-- src/engine/player.rs | 1 + src/engine/signals.rs | 46 +++++++++++++--- src/engine/story_ctrl.rs | 7 ++- src/render/math.rs | 2 + src/render/mod.rs | 14 ++--- src/render/scene.rs | 2 +- src/session.rs | 104 +++++++++++++++++++++++++++++++++---- 15 files changed, 183 insertions(+), 51 deletions(-) delete mode 100644 assets/interactions/asdsad diff --git a/.gitignore b/.gitignore index 392d0b4..ea4c4b3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ target/ /assets/maps/autosave +# Blender-Backup-Dateien (bei jedem Save neu geschrieben) +*.blend1 + # Added by cargo /target diff --git a/README.md b/README.md index a57583e..5795d6e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,16 @@ ## Anwendung ausführen -- Cargo installieren (Falls noch nicht vorhanden): `sudo apt install cargo` -- Repository Klonen: `git clone https://git.bnd.wtf/irrlicht/wds.git` +Das Projekt nutzt Rust-Edition 2024 und braucht daher eine aktuelle +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` -- Anwendung bauen und ausführen: `cargo run` \ No newline at end of file +- Anwendung bauen und ausführen: `cargo run` + +Alternativ ohne Fenster/GPU in der Konsole: `cargo run -- --cli` diff --git a/assets/interactions/asdsad b/assets/interactions/asdsad deleted file mode 100644 index 64908cd..0000000 --- a/assets/interactions/asdsad +++ /dev/null @@ -1 +0,0 @@ -asdsadas diff --git a/src/cli.rs b/src/cli.rs index 36bcdc5..0a31ba9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,8 +15,7 @@ pub fn run(mut session: Session, signals_path: &str) { // Reserviertes `[init]`-Signal: KV-Defaults setzen, bevor Stories laufen. for line in session.start() { println!("{line}"); } - loop { - let Some(input) = read_line(prompt_for(&session.mode)) else { break }; + while let Some(input) = read_line(prompt_for(&session.mode)) { let r = session.exec(input.trim()); for line in r.output { println!("{line}"); } if r.quit { break; } diff --git a/src/engine/assets.rs b/src/engine/assets.rs index a1df8e5..9c01bde 100644 --- a/src/engine/assets.rs +++ b/src/engine/assets.rs @@ -8,12 +8,10 @@ static BASE: OnceLock = OnceLock::new(); fn base() -> &'static PathBuf { BASE.get_or_init(|| { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - if dir.join("assets").exists() { - return dir.to_path_buf(); - } - } + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + && dir.join("assets").exists() { + return dir.to_path_buf(); } std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }) diff --git a/src/engine/collision.rs b/src/engine/collision.rs index ddad3d6..7b38198 100644 --- a/src/engine/collision.rs +++ b/src/engine/collision.rs @@ -71,10 +71,9 @@ impl CollisionWorld { pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option { let mut nearest: Option = None; for b in &self.brushes { - if let Some(h) = trace_brush(b, start, end, half) { - if nearest.as_ref().map_or(true, |n| h.frac < n.frac) { - nearest = Some(h); - } + if let Some(h) = trace_brush(b, start, end, half) + && nearest.as_ref().is_none_or(|n| h.frac < n.frac) { + nearest = Some(h); } } nearest diff --git a/src/engine/ink.rs b/src/engine/ink.rs index e08bb4e..6e47e3d 100644 --- a/src/engine/ink.rs +++ b/src/engine/ink.rs @@ -29,14 +29,17 @@ pub struct 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 { 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) - .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() }; s.sync_from_kv(kv); - s + Ok(s) } pub fn take_tags(&mut self) -> Vec { std::mem::take(&mut self.tags) } diff --git a/src/engine/map.rs b/src/engine/map.rs index abc6b7c..54027ea 100644 --- a/src/engine/map.rs +++ b/src/engine/map.rs @@ -102,10 +102,9 @@ pub fn parse(src: &str) -> Map { if let Some(face) = parse_face(line) { brush.faces.push(face); } - } else if let Some(entity) = cur_entity.as_mut() { - if let Some((k, v)) = parse_property(line) { - entity.props.insert(k, v); - } + } else if let Some(entity) = cur_entity.as_mut() + && let Some((k, v)) = parse_property(line) { + 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` +#[allow(clippy::needless_range_loop)] // Punkt-/Komponenten-Index steuert zugleich den Token-Cursor `i` fn parse_face(line: &str) -> Option { let t: Vec<&str> = line.split_whitespace().collect(); let mut i = 0; diff --git a/src/engine/player.rs b/src/engine/player.rs index 04b673d..776a0a4 100644 --- a/src/engine/player.rs +++ b/src/engine/player.rs @@ -161,6 +161,7 @@ impl Player { /// 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; diff --git a/src/engine/signals.rs b/src/engine/signals.rs index c123651..a37a3ae 100644 --- a/src/engine/signals.rs +++ b/src/engine/signals.rs @@ -27,7 +27,12 @@ //! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table, //! laufen alle gemappten Actions. Sonst wird der String direkt als //! 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::fs::read_to_string; @@ -51,17 +56,27 @@ pub fn signal_key(name: &str) -> &str { } 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) { 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; } - // Sonst direkt als Builtin probieren (Ink-Tag-Pfad). - execute(signal, ctx); + // Sonst direkt als Builtin probieren (Ink-Tag-Pfad). Unbekanntes bleibt + // 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, // damit Tokens wie `hide_object $self` einheitlich funktionieren. Ohne // 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) { 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 @@ -195,6 +211,22 @@ mod tests { 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] fn unknown_signal_falls_through_to_builtin() { let mut game = Game::new(Signals::new()); diff --git a/src/engine/story_ctrl.rs b/src/engine/story_ctrl.rs index 53ee29a..8e819df 100644 --- a/src/engine/story_ctrl.rs +++ b/src/engine/story_ctrl.rs @@ -16,7 +16,12 @@ use crate::engine::ink::{Story, StoryState}; /// dafür `advance(None, …)`. pub fn start(script: &str, ctx: &mut ActionCtx) { 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 diff --git a/src/render/math.rs b/src/render/math.rs index a570d2c..86d9f57 100644 --- a/src/render/math.rs +++ b/src/render/math.rs @@ -21,6 +21,7 @@ impl Mat4 { [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 { let mut out = [[0.0f32; 4]; 4]; for c in 0..4 { @@ -34,6 +35,7 @@ impl Mat4 { /// Punkt-Transformation auf der CPU — bisher nur von den Tests /// gebraucht; der Renderer transformiert auf der GPU. #[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] { let mut out = [0.0f32; 4]; for r in 0..4 { diff --git a/src/render/mod.rs b/src/render/mod.rs index a831cc1..e2e81b1 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -42,8 +42,9 @@ use gpu::Gpu; use math::Mat4; use scene::Mesh; -/// Radiant pro Maus-Pixel. -const MOUSE_SENS: f32 = 0.0025; +/// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die +/// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt. +const MOUSE_SENS: f32 = crate::session::LOOK_SENS; /// Welt-Einheiten pro Sekunde. const MOVE_SPEED: f32 = 4.0; /// 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, /// solange der Cursor gefangen ist. fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) { - if let DeviceEvent::MouseMotion { delta } = event { - if self.input.grabbed { - self.input.mouse_dx += delta.0 as f32; - self.input.mouse_dy += delta.1 as f32; - } + if let DeviceEvent::MouseMotion { delta } = event + && self.input.grabbed { + self.input.mouse_dx += delta.0 as f32; + self.input.mouse_dy += delta.1 as f32; } } diff --git a/src/render/scene.rs b/src/render/scene.rs index 9d6e222..8bb0507 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -251,7 +251,7 @@ fn upload_texture( /// verfälschen. Mittelung im (gamma-kodierten) Speicherraum — für den /// stilisierten Look unkritisch. fn downsample(src: &[u8], w: u32, h: u32) -> (Vec, 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]; for y in 0..nh { for x in 0..nw { diff --git a/src/session.rs b/src/session.rs index 6f70d91..aef8801 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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 = 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 = 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();