359 lines
14 KiB
Rust
359 lines
14 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
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<String>,
|
|
pub quit: bool,
|
|
}
|
|
|
|
impl ExecResult {
|
|
fn lines(output: Vec<String>) -> 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<String> {
|
|
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::<usize>() {
|
|
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<String>) -> Vec<String> {
|
|
{
|
|
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<usize>) -> Vec<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<f32> = 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<String> {
|
|
vec![
|
|
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
|
" (z.B. `signal Cube`, `signal set has_key true`)".into(),
|
|
" use <instance> 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));
|
|
}
|
|
}
|