diff --git a/src/engine/game.rs b/src/engine/game.rs index cd17939..ea730a7 100644 --- a/src/engine/game.rs +++ b/src/engine/game.rs @@ -30,6 +30,44 @@ pub enum Action { HideObject(String), /// WAV unter `assets/audio/` als SFX abspielen. PlaySound(String), + /// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend + /// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht + /// wählbar — der entsteht nur aus dem Story-Ablauf. + SetMode(ModeTarget), +} + +/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog- +/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten +/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModeTarget { + /// First-Person-Spiel. + Play, + /// Noclip-Debug-Flycam. + Free, + /// Menü (pausiert die Welt, gibt die Maus frei). + Menu, +} + +impl ModeTarget { + /// `play`/`free`/`menu` (case-insensitiv) → Ziel; sonst `None`. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "play" => Some(Self::Play), + "free" => Some(Self::Free), + "menu" => Some(Self::Menu), + _ => None, + } + } + + /// Kanonischer Name fürs Echo/Logging. + pub fn label(self) -> &'static str { + match self { + Self::Play => "play", + Self::Free => "free", + Self::Menu => "menu", + } + } } pub struct Game { diff --git a/src/engine/signals.rs b/src/engine/signals.rs index a2f70e1..c123651 100644 --- a/src/engine/signals.rs +++ b/src/engine/signals.rs @@ -16,6 +16,8 @@ //! `clear ` KV-Eintrag entfernen //! `hide_object ` → Action::HideObject (deferred) //! `play_sound ` → Action::PlaySound (deferred) +//! `mode ` → Action::SetMode (deferred): Anzeige-/ +//! Eingabemodus wechseln //! //! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den //! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action @@ -30,7 +32,7 @@ use std::collections::HashMap; use std::fs::read_to_string; -use crate::engine::game::{Action, ActionCtx, Signals}; +use crate::engine::game::{Action, ActionCtx, ModeTarget, Signals}; use crate::engine::kv; use crate::engine::story_ctrl; @@ -82,6 +84,9 @@ fn execute(cmd: &str, ctx: &mut ActionCtx) { "clear" => kv::apply_clear(args, ctx.kv), "hide_object" => ctx.actions.push(Action::HideObject(args.to_string())), "play_sound" => ctx.actions.push(Action::PlaySound(args.to_string())), + "mode" => if let Some(t) = ModeTarget::parse(args) { + ctx.actions.push(Action::SetMode(t)); + }, _ => { /* Unbekannter Verb/Tag → still ignorieren */ } } } @@ -176,6 +181,20 @@ mod tests { ]); } + #[test] + fn mode_verb_queues_setmode_action() { + let mut game = Game::new(Signals::new()); + let mut ctx = game.action_ctx(None); + dispatch("mode free", &mut ctx); + assert_eq!(game.actions, vec![Action::SetMode(ModeTarget::Free)]); + + // Unbekanntes Ziel → kein Action (stilles No-Op). + game.actions.clear(); + let mut ctx = game.action_ctx(None); + dispatch("mode wat", &mut ctx); + assert!(game.actions.is_empty()); + } + #[test] fn unknown_signal_falls_through_to_builtin() { let mut game = Game::new(Signals::new()); diff --git a/src/session.rs b/src/session.rs index 67aa506..85a8221 100644 --- a/src/session.rs +++ b/src/session.rs @@ -18,7 +18,7 @@ //! im `render`-Modul. use crate::engine::collision::CollisionWorld; -use crate::engine::game::{Action, Game}; +use crate::engine::game::{Action, Game, ModeTarget}; use crate::engine::ink::StoryState; use crate::engine::map::{self, Map}; use crate::engine::player::Player; @@ -148,19 +148,21 @@ impl Session { // 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, + 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. - self.mode = match self.mode { - Mode::Play => Mode::Free, - _ => Mode::Play, + let t = match self.mode { + Mode::Play => ModeTarget::Free, + _ => ModeTarget::Play, }; + self.set_mode(t); ExecResult::empty() } "help" => ExecResult::lines(help()), @@ -172,7 +174,20 @@ impl Session { ]) } _ => { - if let Some(sig) = line.strip_prefix("signal ") { + 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 simulieren: Signal ist der gestrippte @@ -247,10 +262,33 @@ impl Session { } 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() + // 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::HideObject(n) => out.push(format!("[action] hide_object {n}")), + Action::PlaySound(n) => out.push(format!("[action] play_sound {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::Play, + ModeTarget::Free => Mode::Free, + ModeTarget::Menu => Mode::Menu, + }; } fn dump_kv(&self) -> Vec { @@ -286,6 +324,7 @@ fn help() -> Vec { " 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(), ] @@ -337,6 +376,30 @@ mod tests { 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::Play)); + 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::Play)); + } + #[test] fn noclip_toggles_play_and_free() { let mut s = empty_session();