//! Session: der geteilte Anwendungszustand über allen Frontends. //! //! Es existiert genau eine `Session` pro Lauf. CLI und Fenster steuern //! dieselbe — das Fenster besitzt sie, die Terminal-Eingabe schickt nur //! Befehlszeilen hinein. Dadurch kann es keinen Mismatch geben: ein //! `Game`-Zustand, ein `Mode`, beide Frontends stellen ihn nur dar. //! //! Schicht: `engine ← session ← { cli, render }`. Die Session kennt kein //! Frontend — `exec` nimmt eine Befehlszeile und *gibt* Ausgabezeilen //! zurück (statt zu drucken), damit jedes Frontend sie frei darstellt //! (Terminal jetzt, In-Fenster-Konsole später). //! //! Die Session ist auch der alleinige Besitzer der **Lauf-Simulation**: //! `Player` und `CollisionWorld`. Sie hat zwei Eingabe-Tore — `exec` für //! diskrete Befehle/Dialog und `tick` für die kontinuierliche Physik pro //! Frame. Die Frontends sammeln nur Eingaben und stellen den Zustand dar; //! die Flycam dagegen ist reine Frontend-Sicht (kein Sim-Zustand) und bleibt //! im `render`-Modul. use crate::engine::collision::CollisionWorld; use crate::engine::game::{Action, Game}; use crate::engine::ink::StoryState; 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; /// Was die Frontends gerade darstellen sollen. Geteilt, damit CLI und GUI /// nie uneins sind, ob ein Dialog läuft. pub enum Mode { /// First-Person-Spiel — der Player bewegt sich mit Physik durch die Welt /// (siehe engine::player). Der Renderer leitet seine View aus dem Player /// ab. Das ist der eigentliche Spielmodus. Play, /// Noclip-Debug-Flycam — frei schwebende Kamera ohne Physik. Per /// `noclip`-Befehl gegen `Play` getauscht. Free, /// Menü offen — wie der Dialog pausiert das Fenster die Welt und gibt die /// Maus frei; Eingaben sind weiter Engine-Befehle (das Menü ist nur eine /// andere Darstellung). Per `menu`-Befehl getoggelt; „Fortsetzen" führt /// ins Spiel (`Play`). Menu, /// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt Panels; /// Eingaben treiben den Dialog statt Befehle. Dialog(Dialog), } /// Aktueller Dialogschritt, frontend-neutral. `choices` leer = reiner Text, /// der auf „weiter" (Leerzeile/Enter) wartet. pub struct Dialog { // Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der // Dialogtext über die Konsolen-Ausgabe (siehe `step`). #[allow(dead_code)] pub text: String, pub choices: Vec, } pub struct Session { pub game: Game, pub mode: Mode, pub player: Player, pub collision: CollisionWorld, signals_path: String, } /// 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-Welt, bis `load_world` eine Map einspielt (die CLI // braucht keine — sie tickt nie). player: Player::new([0.0, 1.0, 0.0]), collision: CollisionWorld::empty(), signals_path, } } /// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den /// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim /// Start gerufen. pub fn load_world(&mut self, map: &Map) { self.collision = CollisionWorld::build(map); self.player = Player::new(player_spawn(map)); } /// 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). pub fn tick(&mut self, input: &FrameInput, dt: f32) { if matches!(self.mode, Mode::Play) { self.player.look(input.look_dx, input.look_dy, LOOK_SENS); self.player.step(&self.collision, input.fwd, input.right, input.jump, dt); } } /// 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::Play | 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.) self.mode = match self.mode { Mode::Menu => Mode::Play, _ => Mode::Menu, }; ExecResult::empty() } "noclip" => { // Debug: zwischen First-Person (Play) und freier Flycam (Free) // umschalten. self.mode = match self.mode { Mode::Play => Mode::Free, _ => Mode::Play, }; 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(sig) = line.strip_prefix("signal ") { ExecResult::lines(self.fire(sig.trim(), None)) } else if let Some(name) = line.strip_prefix("use ") { // Objekt-Interaktion simulieren: Signal ist der gestrippte // Name, $self der volle — wie der LMB-Klick-Pfad in irl3d. let name = name.trim(); let key = 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())]), } }; ExecResult::lines(self.step(sel)) } /// 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::Play; } 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 { self.game.actions.drain(..).map(|a| match a { Action::HideObject(n) => format!("[action] hide_object {n}"), Action::PlaySound(n) => format!("[action] play_sound {n}"), }).collect() } 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() } } /// 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]); } } } } [0.0, 1.0, 0.0] } 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(), " 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::Play)); 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 noclip_toggles_play_and_free() { let mut s = empty_session(); s.mode = Mode::Play; s.exec("noclip"); assert!(matches!(s.mode, Mode::Free)); s.exec("noclip"); assert!(matches!(s.mode, Mode::Play)); } #[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)); } }