Cleanup
This commit is contained in:
+1
-2
@@ -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; }
|
||||
|
||||
@@ -8,12 +8,10 @@ static BASE: OnceLock<PathBuf> = 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("."))
|
||||
})
|
||||
|
||||
@@ -71,10 +71,9 @@ impl CollisionWorld {
|
||||
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||
let mut nearest: Option<Hit> = 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
|
||||
|
||||
+7
-4
@@ -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<Self, String> {
|
||||
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<String> { std::mem::take(&mut self.tags) }
|
||||
|
||||
+4
-4
@@ -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<Face> {
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
let mut i = 0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+39
-7
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+7
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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<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];
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
|
||||
+93
-11
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user