use crate::engine::audio::Emitters; use crate::engine::collision::CollisionWorld; use crate::engine::game::{Action, Game, ModeTarget}; use crate::engine::ink::StoryState; use crate::engine::player::{self, Player}; use crate::engine::trigger::Triggers; use crate::engine::{kv, model, signals, story_ctrl}; /// 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; /// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele. pub const INTERACT_REACH: f32 = 3.0; pub enum Mode { FirstPerson, Free, Menu, Dialog(Dialog), } pub struct Dialog { pub text: String, pub choices: Vec, } pub struct Session { pub game: Game, pub mode: Mode, pub player: Player, pub collision: CollisionWorld, /// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen /// (`signal`-Property, siehe engine::trigger). triggers: Triggers, /// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf /// Empties, siehe engine::audio). emitters: Emitters, /// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend /// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt /// sie liegen (ihr reicht die `[action]`-Textzeile). pending_sounds: Vec, 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 /// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*` /// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1]. #[derive(Default)] pub struct FrameInput { pub fwd: f32, pub right: f32, pub jump: bool, pub look_dx: f32, pub look_dy: f32, } /// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend /// beenden soll (`quit`/`exit`). pub struct ExecResult { pub output: Vec, pub quit: bool, } impl ExecResult { fn lines(output: Vec) -> Self { Self { output, quit: false } } fn empty() -> Self { Self { output: Vec::new(), quit: false } } } impl Session { pub fn new(signals_path: String) -> Self { let game = Game::new(signals::load_signals(&signals_path)); Self { game, mode: Mode::Menu, // Default-Spawn, bis ein Modell einen `role=spawn`-Empty // mitbringt (die CLI braucht keine Welt — sie tickt nie). player: Player::new([0.0, 1.0, 0.0]), collision: CollisionWorld::empty(), triggers: Triggers::new(), emitters: Emitters::new(), pending_sounds: Vec::new(), signals_path, sim_accum: 0.0, pending_jump: false, } } /// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro /// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`- /// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/ /// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der /// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle /// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie /// desselben Modells ist Sache des Renderers (render::props). pub fn load_props(&mut self, model: &model::Model) { self.collision.add_model(model); self.triggers.add_model(model); self.emitters.add_model(model); for e in &model.empties { if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) { self.player = Player::new(e.pos); } } } /// Die Ambient-Emitter in fester Reihenfolge — der Audio-Treiber legt /// beim Start je Emitter eine Loop-Voice an. pub fn emitters(&self) -> &[crate::engine::audio::Emitter] { self.emitters.list() } /// Aktuelle Emitter-Gains (parallel zu [`Session::emitters`]) für die /// Ohrposition des Spielers — jeden Frame vom Audio-Treiber gezogen. pub fn emitter_gains(&self) -> Vec { self.emitters.gains(self.player.eye()) } /// Aufgelaufene Einmal-SFX (`play_sound`-Actions) abholen — jeden Frame /// vom Fenster-Frontend gerufen, das sie an den Audio-Treiber gibt. pub fn take_sounds(&mut self) -> Vec { std::mem::take(&mut self.pending_sounds) } /// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu /// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam /// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe: /// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend /// zeigt sie wie `exec`-Ausgaben an. pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec { if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); } // 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; } // Betretens-Zonen nach der Bewegung prüfen (Flanke: einmal je // Eintritt). Erst alle Events einsammeln, dann feuern — `fire` kann // den Modus wechseln (z.B. start_ink → Dialog), die Sim dieses Frames // ist da schon abgeschlossen. let center = [ self.player.pos[0], self.player.pos[1] + player::HALF_EXTENTS[1], self.player.pos[2], ]; let events = self.triggers.enter_events(center, player::HALF_EXTENTS); let mut out = Vec::new(); for (name, signal) in events { out.extend(self.fire(&signal, Some(name))); } out } /// Klick-Ziel unterm Fadenkreuz: nächstes `signal`-Klick-Volumen entlang /// des Blickstrahls (`origin` + `dir·reach`), sofern keine andere /// Weltgeometrie davor liegt. Liefert den Objektnamen für `use `. pub fn pick(&self, origin: [f32; 3], dir: [f32; 3], reach: f32) -> Option<&str> { let end = [ origin[0] + dir[0] * reach, origin[1] + dir[1] * reach, origin[2] + dir[2] * reach, ]; let (name, frac) = self.triggers.pick(origin, end)?; // Das Klick-Volumen ist selbst Teil der Kollisionswelt → bei freier // Sicht treffen beide Traces dieselbe Fläche (Toleranz für FP/SKIN). let world = self.collision.trace(origin, end, [0.0; 3]).map_or(1.0, |h| h.frac); (frac <= world + 1e-3).then_some(name) } /// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft). /// Eigene Methode statt im Konstruktor, damit das Frontend die Ausgabe /// darstellen kann — und damit `init` bereits einen Dialog öffnen darf. pub fn start(&mut self) -> Vec { self.fire("init", None) } /// Eine Eingabezeile verarbeiten. Im Dialog treibt sie den Dialog, /// sonst ist sie ein Engine-Befehl. Dieselbe Eingabequelle, je nach /// `mode` unterschiedlich gedeutet — so steuert Terminal *und* (später) /// Klick denselben Dialog ohne Sonderpfad. pub fn exec(&mut self, line: &str) -> ExecResult { match self.mode { Mode::Dialog(_) => self.dialog_input(line), Mode::FirstPerson | Mode::Free | Mode::Menu => self.command(line), } } fn command(&mut self, line: &str) -> ExecResult { let line = line.trim(); match line { "" => ExecResult::empty(), "quit" | "exit" => ExecResult { output: Vec::new(), quit: true }, "menu" => { // Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im // Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder // Free öffnet es das Menü. (Dialog wird anders geroutet.) let t = match self.mode { Mode::Menu => ModeTarget::Play, _ => ModeTarget::Menu, }; self.set_mode(t); ExecResult::empty() } "noclip" => { // Debug: zwischen First-Person (Play) und freier Flycam (Free) // umschalten. let t = match self.mode { Mode::FirstPerson => ModeTarget::Free, _ => ModeTarget::Play, }; self.set_mode(t); ExecResult::empty() } "help" => ExecResult::lines(help()), "kv" => ExecResult::lines(self.dump_kv()), "reload" => { self.game.signals = signals::load_signals(&self.signals_path); ExecResult::lines(vec![ format!("{} Signale geladen.", self.game.signals.len()), ]) } _ => { if let Some(m) = line.strip_prefix("mode ") { // Modus explizit setzen — über dieselbe Action wie das // `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und // Signals genau einen Wechsel-Pfad teilen. match ModeTarget::parse(m) { Some(t) => { self.game.actions.push(Action::SetMode(t)); ExecResult::lines(self.drain_actions()) } None => ExecResult::lines(vec![ "Modus: play | free | menu".into(), ]), } } else if let Some(sig) = line.strip_prefix("signal ") { ExecResult::lines(self.fire(sig.trim(), None)) } else if let Some(name) = line.strip_prefix("use ") { // Objekt-Interaktion (LMB-Klick und Konsole laufen beide // hier durch): Klick-Ziele mit `signal`-Property feuern // dieses Signal; sonst den vom Blender-Suffix befreiten // Objektnamen. $self ist immer der volle Name. let name = name.trim(); let key = self.triggers.signal_for(name) .unwrap_or_else(|| signals::signal_key(name)) .to_string(); ExecResult::lines(self.fire(&key, Some(name.to_string()))) } else { ExecResult::lines(vec![ format!("unbekannter Befehl: {line:?} — `help` für Befehle"), ]) } } } } fn dialog_input(&mut self, line: &str) -> ExecResult { let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); }; let sel = if d.choices.is_empty() { None // reiner Text → Leerzeile/Enter blättert weiter } else { match line.trim().parse::() { Ok(n) if (1..=d.choices.len()).contains(&n) => Some(n - 1), _ => return ExecResult::lines(vec![format!(" (1..{})", d.choices.len())]), } }; let mut out = self.step(sel); // Ein start_ink-Tag kann während step() eine neue Story in den Slot // geladen haben, während die alte auf End lief (mode → Play). Falls // das passiert ist, sofort den ersten Schritt der neuen Story ziehen // statt den Spieler im Play-Modus hängen zu lassen. if self.game.story.is_some() && matches!(self.mode, Mode::FirstPerson) { out.extend(self.step(None)); } ExecResult::lines(out) } /// Signal dispatchen, deferred Actions einsammeln und — falls dadurch /// eine Story startete — den ersten Dialogschritt ziehen. fn fire(&mut self, signal: &str, instance: Option) -> Vec { { let mut ctx = self.game.action_ctx(instance); signals::dispatch(signal, &mut ctx); } let mut out = self.drain_actions(); if self.game.story.is_some() { out.extend(self.step(None)); } out } /// Einen Story-Schritt treiben und `mode` daran anpassen. `sel` ist die /// Choice-Auswahl bzw. `None` für ersten Schritt / Text-Weiterblättern. /// Tags gehen wie gehabt zurück in den Dispatcher (siehe story_ctrl). fn step(&mut self, sel: Option) -> Vec { let state = { let mut ctx = self.game.action_ctx(None); let (state, tags) = story_ctrl::advance(sel, &mut ctx); for t in &tags { signals::dispatch(t, &mut ctx); } state }; let mut out = self.drain_actions(); match state { None | Some(StoryState::End) => { self.mode = Mode::FirstPerson; } Some(StoryState::Text(text)) => { out.push(text.clone()); self.mode = Mode::Dialog(Dialog { text, choices: Vec::new() }); } Some(StoryState::Choice { prompt, options }) => { if !prompt.is_empty() { out.push(prompt.clone()); } for (i, o) in options.iter().enumerate() { out.push(format!(" {}) {o}", i + 1)); } self.mode = Mode::Dialog(Dialog { text: prompt, choices: options }); } } out } fn drain_actions(&mut self) -> Vec { // Erst aus der Queue ziehen (gibt den `&mut self.game`-Borrow frei), // dann anwenden — `SetMode` braucht `&mut self.mode`. let actions: Vec = self.game.actions.drain(..).collect(); let mut out = Vec::with_capacity(actions.len()); for a in actions { match a { Action::PlaySound(n) => { out.push(format!("[action] play_sound {n}")); self.pending_sounds.push(n); } Action::SetMode(t) => { self.set_mode(t); out.push(format!("[action] mode {}", t.label())); } } } out } /// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen. /// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode` /// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus /// entsteht ausschließlich in `step` aus dem Story-Ablauf.) fn set_mode(&mut self, t: ModeTarget) { self.mode = match t { ModeTarget::Play => Mode::FirstPerson, ModeTarget::Free => Mode::Free, ModeTarget::Menu => Mode::Menu, }; } fn dump_kv(&self) -> Vec { let mut keys: Vec<&String> = self.game.kv.keys().collect(); keys.sort(); keys.into_iter() .map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k]))) .collect() } } fn help() -> Vec { vec![ " signal Signal feuern oder Action direkt ausführen".into(), " (z.B. `signal Cube`, `signal set has_key true`)".into(), " use Objekt-Interaktion simulieren — `use Mushroom.005`".into(), " feuert Signal `Mushroom` mit $self = Mushroom.005".into(), " kv KV-Store anzeigen".into(), " menu Menü öffnen / ins Spiel zurück".into(), " noclip zwischen First-Person und Debug-Flycam wechseln".into(), " mode Modus direkt setzen: play | free | menu".into(), " reload signals.toml neu laden".into(), " quit beenden".into(), ] } #[cfg(test)] mod tests { use super::*; // Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler), // also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet // im Menü (Titelbildschirm); Tests des Befehlspfads setzen vorab `Free`. fn empty_session() -> Session { Session::new("/nonexistent/signals.toml".into()) } #[test] fn starts_in_menu() { assert!(matches!(empty_session().mode, Mode::Menu)); } #[test] fn quit_sets_flag() { let mut s = empty_session(); assert!(s.exec("quit").quit); } #[test] fn builtin_command_mutates_kv_and_stays_free() { let mut s = empty_session(); s.mode = Mode::Free; // Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`. let r = s.exec("signal set has_key true"); assert!(!r.quit); assert!(s.game.kv["has_key"].coerce_to_bool().unwrap()); assert!(matches!(s.mode, Mode::Free)); } #[test] fn menu_command_opens_and_resumes_to_play() { let mut s = empty_session(); s.mode = Mode::Free; s.exec("menu"); assert!(matches!(s.mode, Mode::Menu)); s.exec("menu"); // „Fortsetzen" → ins Spiel assert!(matches!(s.mode, Mode::FirstPerson)); s.exec("menu"); // aus dem Spiel wieder ins Menü assert!(matches!(s.mode, Mode::Menu)); // Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free). s.exec("signal set in_menu true"); assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap()); } #[test] fn mode_command_sets_mode_directly() { let mut s = empty_session(); s.exec("mode play"); assert!(matches!(s.mode, Mode::FirstPerson)); s.exec("mode free"); assert!(matches!(s.mode, Mode::Free)); s.exec("mode menu"); assert!(matches!(s.mode, Mode::Menu)); // Müll-Argument lässt den Modus stehen und meldet die Nutzung. let r = s.exec("mode dialog"); assert!(matches!(s.mode, Mode::Menu)); assert_eq!(r.output.len(), 1); } #[test] fn signal_can_switch_mode() { let mut s = empty_session(); s.mode = Mode::Free; // `mode`-Verb direkt als Action (Ink-Tag-/signals.toml-Pfad). s.exec("signal mode play"); assert!(matches!(s.mode, Mode::FirstPerson)); } #[test] fn noclip_toggles_play_and_free() { let mut s = empty_session(); s.mode = Mode::FirstPerson; s.exec("noclip"); assert!(matches!(s.mode, Mode::Free)); s.exec("noclip"); 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"); } /// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel, /// deren Signale als Builtin-Fall-Through direkt KV-Flags setzen. fn trigger_model() -> crate::engine::model::Model { crate::engine::model::Model { objects: vec![ crate::engine::model::test_cube( "Zone.001", [-0.5, 0.0, -3.0], None, Some("set zone_hit true")), crate::engine::model::test_cube( "Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")), ], empties: Vec::new(), materials: vec![String::new()], } } #[test] fn walking_into_zone_fires_signal_once() { let mut s = empty_session(); s.load_props(&trigger_model()); s.mode = Mode::FirstPerson; s.player.grounded = true; // Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die // Flanke genau einmal (danach: drin = still). for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); } assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben"); // Weiterlaufen in der Zone darf nicht erneut feuern. s.game.kv.remove("zone_hit"); for _ in 0..5 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); } assert!(!s.game.kv.contains_key("zone_hit"), "Flanke: nur einmal je Eintritt"); } #[test] fn use_resolves_signal_property_before_name() { let mut s = empty_session(); s.load_props(&trigger_model()); s.mode = Mode::FirstPerson; // Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste"). s.exec("use Kiste"); assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap()); // Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier // unbekannt und ohne Table ein stilles No-Op, aber kein Fehler. let r = s.exec("use Irgendwas.003"); assert!(!r.quit); } #[test] fn pick_hits_clickable_within_reach() { let mut s = empty_session(); s.load_props(&trigger_model()); // Kiste bei [5..6, 0..1, 5..6]: Strahl von schräg oben davor. let hit = s.pick([5.5, 0.5, 3.0], [0.0, 0.0, 1.0], INTERACT_REACH); assert_eq!(hit, Some("Kiste")); // Außer Reichweite → nichts. assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None); } #[test] fn play_sound_action_queues_for_frontend() { let mut s = empty_session(); // Fall-through-Builtin über den Signal-Trichter, wie ein Ink-Tag. s.exec("signal play_sound klick"); assert_eq!(s.take_sounds(), vec!["klick".to_string()]); assert!(s.take_sounds().is_empty(), "take leert die Queue"); } #[test] fn spawn_empty_places_player() { let mut s = empty_session(); let mut props = crate::engine::model::Props::new(); props.insert("role".into(), "spawn".into()); s.load_props(&crate::engine::model::Model { objects: Vec::new(), empties: vec![ crate::engine::model::Empty { name: "start".into(), pos: [3.0, 0.5, -2.0], props }, crate::engine::model::Empty { name: "deko".into(), pos: [9.0, 9.0, 9.0], props: crate::engine::model::Props::new(), }, ], materials: Vec::new(), }); assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt"); } #[test] fn unknown_command_reports_and_stays_free() { let mut s = empty_session(); s.mode = Mode::Free; let r = s.exec("frobnicate"); assert_eq!(r.output.len(), 1); assert!(matches!(s.mode, Mode::Free)); } }