Cleanup
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
asdsadas
|
|
||||||
+1
-2
@@ -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; }
|
||||||
|
|||||||
@@ -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("."))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user