Compare commits

..

6 Commits

Author SHA1 Message Date
irrlicht c326c363a0 Tiffany Signal 2026-06-15 10:39:30 +02:00
irrlicht 7543fe3cb7 Cinema Inkscript 2026-06-15 10:39:25 +02:00
irrlicht 93480150e5 Mode als Action 2026-06-15 09:46:13 +02:00
irrlicht 201d3be322 Ink-Script PDF in notes 2026-06-15 09:45:59 +02:00
irrlicht b6aca99d0c First Person controller nach Dialog 2026-06-15 09:41:02 +02:00
irrlicht 44f58bc9c2 First Person Cam im Menu 2026-06-15 09:40:51 +02:00
8 changed files with 11149 additions and 19 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+5
View File
@@ -50,3 +50,8 @@ actions = [
actions = [
"debug_log Person hat sich verabschiedet",
]
[tiffany]
actions = [
"start_ink cinema.ink.json",
]
File diff suppressed because it is too large Load Diff
+38
View File
@@ -30,6 +30,44 @@ pub enum Action {
HideObject(String),
/// WAV unter `assets/audio/<name>` 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<Self> {
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 {
+20 -1
View File
@@ -16,6 +16,8 @@
//! `clear <name>` KV-Eintrag entfernen
//! `hide_object <name>` → Action::HideObject (deferred)
//! `play_sound <file>` → Action::PlaySound (deferred)
//! `mode <play|free|menu>` → 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());
+7 -5
View File
@@ -381,13 +381,15 @@ impl App {
self.eye_y = target_eye;
}
// View: im Spiel aus dem Player der Session (geglättete Augenhöhe),
// sonst aus der Flycam.
let view = if play {
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
// Sicht bleibt die First-Person-Sicht.
let view = if fly {
self.camera.view()
} else {
let p = self.session.player.pos;
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
} else {
self.camera.view()
};
// Mausposition über die Letterbox in interne Pixel mappen.
let cursor = ui::Cursor {
+76 -13
View File
@@ -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
@@ -229,7 +244,7 @@ impl Session {
let mut out = self.drain_actions();
match state {
None | Some(StoryState::End) => {
self.mode = Mode::Free;
self.mode = Mode::Play;
}
Some(StoryState::Text(text)) => {
out.push(text.clone());
@@ -247,10 +262,33 @@ impl Session {
}
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()
// Erst aus der Queue ziehen (gibt den `&mut self.game`-Borrow frei),
// dann anwenden — `SetMode` braucht `&mut self.mode`.
let actions: Vec<Action> = 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<String> {
@@ -286,6 +324,7 @@ fn help() -> Vec<String> {
" kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".into(),
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
" mode <m> 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();