Refactor
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
//! 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)
|
||||
//!
|
||||
//! 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, 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())),
|
||||
_ => { /* 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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user