212 lines
8.0 KiB
Rust
212 lines
8.0 KiB
Rust
//! Signal → Action Dispatcher.
|
|
//!
|
|
//! Triggers (Objekt-Interaktion, Zone-Enter, Ink-Tag, Konsole) feuern
|
|
//! *Signals* als Strings. Eine zentrale Table in `assets/signals.toml` mappt
|
|
//! jedes Signal auf eine Liste von Actions. Actions sind selbst wieder
|
|
//! Strings im Format `"verb args"` — dieselbe Vokabular-Familie wie
|
|
//! Ink-Tags, sodass beide auf dieselbe Execute-Pipeline laufen.
|
|
//!
|
|
//! Builtin-Verben:
|
|
//! `start_ink <script>` Story aus `assets/interactions/` in den
|
|
//! Story-Slot laden; das Frontend treibt sie
|
|
//! dann über `story_ctrl::advance`.
|
|
//! `debug_log <text>` eprintln!
|
|
//! `set <name> <value>` KV setzen (true/false/i32/f32/string)
|
|
//! `inc <name> [<delta>]` KV-Integer inkrementieren (Default +1)
|
|
//! `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
|
|
//! wie `hide_object $self` für viele duplizierte Instances wirken, ohne pro
|
|
//! Instance einen Eintrag in signals.toml zu brauchen.
|
|
//!
|
|
//! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table,
|
|
//! laufen alle gemappten Actions. Sonst wird der String direkt als
|
|
//! Builtin-Aufruf probiert — so wirken Ink-Tags wie `# set has_key true`
|
|
//! ohne Umweg über signals.toml. Unbekannte Verben werden still ignoriert.
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs::read_to_string;
|
|
|
|
use crate::engine::game::{Action, ActionCtx, ModeTarget, Signals};
|
|
use crate::engine::kv;
|
|
use crate::engine::story_ctrl;
|
|
|
|
/// Strippt Blenders Duplikat-Suffix (`.NNN` mit reinen Ziffern am Ende),
|
|
/// damit `Mushroom.001`, `Mushroom.042` etc. alle auf `[Mushroom]` in
|
|
/// signals.toml mappen. Andere Punkte im Namen bleiben unberührt, solange
|
|
/// der letzte Token nicht rein numerisch ist.
|
|
pub fn signal_key(name: &str) -> &str {
|
|
if let Some(dot) = name.rfind('.') {
|
|
let tail = &name[dot + 1..];
|
|
if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
|
|
return &name[..dot];
|
|
}
|
|
}
|
|
name
|
|
}
|
|
|
|
pub fn dispatch(signal: &str, ctx: &mut ActionCtx) {
|
|
// Signal-Name in der Table? Dann alle gemappten Actions ausführen.
|
|
if let Some(actions) = ctx.signals.get(signal) {
|
|
let actions = actions.clone(); // entkoppelt vom ctx.signals-Borrow
|
|
for a in &actions { execute(a, ctx); }
|
|
return;
|
|
}
|
|
// Sonst direkt als Builtin probieren (Ink-Tag-Pfad).
|
|
execute(signal, ctx);
|
|
}
|
|
|
|
fn execute(cmd: &str, ctx: &mut ActionCtx) {
|
|
// $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten,
|
|
// damit Tokens wie `hide_object $self` einheitlich funktionieren. Ohne
|
|
// instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action
|
|
// zielt dann ins Leere, was für deferred Actions ein No-Op beim
|
|
// Konsumenten ist.
|
|
let cmd_owned: String;
|
|
let cmd_ref: &str = if cmd.contains("$self") {
|
|
if let Some(name) = ctx.instance_name.as_deref() {
|
|
cmd_owned = cmd.replace("$self", name);
|
|
&cmd_owned
|
|
} else { cmd }
|
|
} else { cmd };
|
|
let (verb, args) = cmd_ref.split_once(char::is_whitespace).unwrap_or((cmd_ref, ""));
|
|
let args = args.trim();
|
|
match verb {
|
|
"start_ink" => story_ctrl::start(args, ctx),
|
|
"debug_log" => eprintln!("[signal] {args}"),
|
|
"set" => kv::apply_set(args, ctx.kv),
|
|
"inc" => kv::apply_inc(args, ctx.kv),
|
|
"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 */ }
|
|
}
|
|
}
|
|
|
|
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
|
|
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
|
|
/// das Format ist trivial und die Engine bleibt dependency-arm.
|
|
pub fn parse_signals(src: &str) -> Signals {
|
|
let mut map: Signals = HashMap::new();
|
|
let mut cur = String::new();
|
|
let mut in_arr = false;
|
|
for line in src.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') { continue; }
|
|
if !in_arr && line.starts_with('[') {
|
|
cur = line.trim_matches(|c| c == '[' || c == ']').to_string();
|
|
map.entry(cur.clone()).or_default();
|
|
continue;
|
|
}
|
|
if !in_arr && line.starts_with("actions") && line.contains('[') {
|
|
in_arr = true;
|
|
continue;
|
|
}
|
|
if in_arr {
|
|
if line.starts_with(']') { in_arr = false; continue; }
|
|
let s = line.trim_end_matches(',').trim().trim_matches('"');
|
|
if s.is_empty() { continue; }
|
|
map.entry(cur.clone()).or_default().push(s.to_string());
|
|
}
|
|
}
|
|
map
|
|
}
|
|
|
|
/// Lädt `signals.toml`. Fehlende Datei → leere Table (kein Fehler), damit
|
|
/// ein Projekt ohne Signals lauffähig bleibt.
|
|
pub fn load_signals(path: &str) -> Signals {
|
|
match read_to_string(path) {
|
|
Ok(src) => parse_signals(&src),
|
|
Err(_) => Signals::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::engine::game::Game;
|
|
|
|
#[test]
|
|
fn signal_key_strips_blender_suffix() {
|
|
assert_eq!(signal_key("Mushroom.005"), "Mushroom");
|
|
assert_eq!(signal_key("Mushroom"), "Mushroom");
|
|
assert_eq!(signal_key("my.thing.with.dots"), "my.thing.with.dots");
|
|
assert_eq!(signal_key("name."), "name.");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_signals_table() {
|
|
let src = r#"
|
|
# Kommentar
|
|
[init]
|
|
actions = [
|
|
"set foo 1",
|
|
"debug_log hi",
|
|
]
|
|
|
|
[Empty]
|
|
actions = [
|
|
]
|
|
"#;
|
|
let s = parse_signals(src);
|
|
assert_eq!(s["init"], vec!["set foo 1", "debug_log hi"]);
|
|
assert!(s["Empty"].is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_runs_actions_and_queues_deferred() {
|
|
let mut signals = Signals::new();
|
|
signals.insert("pickup".into(), vec![
|
|
"inc items".into(),
|
|
"play_sound pickup.wav".into(),
|
|
"hide_object $self".into(),
|
|
]);
|
|
let mut game = Game::new(signals);
|
|
|
|
let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
|
|
dispatch("pickup", &mut ctx);
|
|
|
|
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
|
|
assert_eq!(game.actions, vec![
|
|
Action::PlaySound("pickup.wav".into()),
|
|
Action::HideObject("Mushroom.005".into()),
|
|
]);
|
|
}
|
|
|
|
#[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());
|
|
let mut ctx = game.action_ctx(None);
|
|
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
|
|
dispatch("set has_key true", &mut ctx);
|
|
assert!(game.kv["has_key"].coerce_to_bool().unwrap());
|
|
|
|
// Unbekanntes Verb ist ein stilles No-Op.
|
|
let mut ctx = game.action_ctx(None);
|
|
dispatch("color story_a", &mut ctx);
|
|
assert!(game.actions.is_empty());
|
|
}
|
|
}
|