Refactor
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
//! Asset-Pfadauflösung: Distribution-Layout (assets/ neben der Binary)
|
||||
//! oder Dev-Layout (assets/ im CWD, also Projektroot bei `cargo run`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static BASE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
fn base() -> &'static PathBuf {
|
||||
BASE.get_or_init(|| {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
if dir.join("assets").exists() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
})
|
||||
}
|
||||
|
||||
/// Wandelt einen relativen Pfad wie `"assets/signals.toml"` in einen
|
||||
/// absoluten Pfad relativ zum Asset-Basisverzeichnis um.
|
||||
pub fn path(rel: &str) -> String {
|
||||
base().join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Owner des dispatch-relevanten Spielzustands und der Borrow-View, durch
|
||||
//! die Signal-Actions und Ink-Story-Steps darauf zugreifen.
|
||||
//!
|
||||
//! `Game` hält die Slots, die Actions lesen oder mutieren dürfen.
|
||||
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
|
||||
//! konstruiert und durch die Dispatch-Pipeline gereicht.
|
||||
//!
|
||||
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
|
||||
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte
|
||||
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
|
||||
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
|
||||
//! testbar und kennt keines der Subsysteme.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::engine::ink;
|
||||
use crate::engine::kv::Store;
|
||||
|
||||
/// Signal-Table aus `assets/signals.toml`: Signal-Name → Action-Strings.
|
||||
/// Geladen/geparst von [`crate::engine::signals`]; definiert ist der Typ
|
||||
/// hier beim Zustand, damit `game` ← `signals` eine Einbahnstraße bleibt.
|
||||
pub type Signals = HashMap<String, Vec<String>>;
|
||||
|
||||
/// Action, die der Kern nicht selbst ausführt, sondern ans Frontend
|
||||
/// weiterreicht. Die Queue in `Game::actions` gehört nach jedem Dispatch
|
||||
/// geleert (konsumiert).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Instance unsichtbar + kollisionslos + nicht mehr interagierbar machen.
|
||||
HideObject(String),
|
||||
/// WAV unter `assets/audio/<name>` als SFX abspielen.
|
||||
PlaySound(String),
|
||||
}
|
||||
|
||||
pub struct Game {
|
||||
pub kv: Store,
|
||||
pub signals: Signals,
|
||||
pub story: Option<ink::Story>,
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
pub struct ActionCtx<'a> {
|
||||
pub kv: &'a mut Store,
|
||||
pub signals: &'a Signals,
|
||||
pub story: &'a mut Option<ink::Story>,
|
||||
pub actions: &'a mut Vec<Action>,
|
||||
/// Auslöser-Identifier: bei Objekt-Interaktion der volle Instance-Name
|
||||
/// (mit Blender-Suffix, z.B. `Mushroom.005`). `$self` in Action-Args
|
||||
/// wird damit substituiert. `None` bei Signalen ohne Quell-Instance
|
||||
/// (z.B. `init`, Story-Tags).
|
||||
pub instance_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn new(signals: Signals) -> Self {
|
||||
Self { kv: Store::new(), signals, story: None, actions: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn action_ctx(&mut self, instance_name: Option<String>) -> ActionCtx<'_> {
|
||||
ActionCtx {
|
||||
kv: &mut self.kv,
|
||||
signals: &self.signals,
|
||||
story: &mut self.story,
|
||||
actions: &mut self.actions,
|
||||
instance_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`.
|
||||
//!
|
||||
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
|
||||
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
|
||||
//! (Skript sieht aktuelle Spielvariablen)
|
||||
//! - nach `continue`: alle KV-Keys, die das Skript kennt, zurücklesen
|
||||
//! (Ink-`~ var = …`-Writes landen im KV)
|
||||
//!
|
||||
//! Variablen die der KV kennt, das Skript aber nicht als `VAR` deklariert,
|
||||
//! lehnt bladeink beim `set_variable` ab — wir ignorieren das still.
|
||||
//! Variablen die nur im Skript existieren, werden beim Read-Back ignoriert
|
||||
//! (KV bleibt minimal: nur was Rust kennt).
|
||||
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use bladeink::story::Story as BladeStory;
|
||||
|
||||
use crate::engine::kv::Store;
|
||||
|
||||
pub enum StoryState {
|
||||
Text(String),
|
||||
Choice { prompt: String, options: Vec<String> },
|
||||
End,
|
||||
}
|
||||
|
||||
pub struct Story {
|
||||
inner: BladeStory,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Story {
|
||||
pub fn load(path: &str, kv: &Store) -> Self {
|
||||
let src = read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("ink load {}: {}", path, e));
|
||||
let inner = BladeStory::new(&src)
|
||||
.unwrap_or_else(|e| panic!("ink parse {}: {:?}", path, e));
|
||||
let mut s = Self { inner, tags: Vec::new() };
|
||||
s.sync_from_kv(kv);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn take_tags(&mut self) -> Vec<String> { std::mem::take(&mut self.tags) }
|
||||
|
||||
pub fn cont(&mut self, kv: &mut Store) -> StoryState {
|
||||
self.sync_from_kv(kv);
|
||||
|
||||
// continue_maximally ruft intern reset_output() vor jedem cont() —
|
||||
// damit überschriebe der Tag der letzten Zeile alle vorherigen.
|
||||
// Wir loopen selbst und sammeln Tags nach jedem Schritt ein, sonst
|
||||
// gehen z.B. `# set has_key true` am Knoten-Anfang bei mehrzeiligem
|
||||
// Text verloren.
|
||||
let mut text = String::new();
|
||||
while self.inner.can_continue() {
|
||||
let chunk = self.inner.cont()
|
||||
.unwrap_or_else(|e| panic!("ink cont: {:?}", e));
|
||||
text.push_str(&chunk);
|
||||
if let Ok(t) = self.inner.get_current_tags() {
|
||||
self.tags.extend(t);
|
||||
}
|
||||
}
|
||||
let text = text.trim().to_string();
|
||||
|
||||
self.sync_to_kv(kv);
|
||||
|
||||
let choices = self.inner.get_current_choices();
|
||||
if !choices.is_empty() {
|
||||
let options = choices.iter().map(|c| c.text.clone()).collect();
|
||||
return StoryState::Choice { prompt: text, options };
|
||||
}
|
||||
if !text.is_empty() { return StoryState::Text(text); }
|
||||
StoryState::End
|
||||
}
|
||||
|
||||
pub fn choose(&mut self, i: usize) {
|
||||
self.inner.choose_choice_index(i)
|
||||
.unwrap_or_else(|e| panic!("ink choose {}: {:?}", i, e));
|
||||
}
|
||||
|
||||
fn sync_from_kv(&mut self, kv: &Store) {
|
||||
for (name, value) in kv {
|
||||
// set_variable schlägt fehl wenn der Name kein deklariertes
|
||||
// VAR im Skript ist — still ignorieren.
|
||||
let _ = self.inner.set_variable(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_to_kv(&self, kv: &mut Store) {
|
||||
let keys: Vec<String> = kv.keys().cloned().collect();
|
||||
for name in keys {
|
||||
if let Some(v) = self.inner.get_variable(&name) {
|
||||
kv.insert(name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Persistenter Game-State Key-Value-Store.
|
||||
//!
|
||||
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
|
||||
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
|
||||
//! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe
|
||||
//! [`crate::engine::ink`]). Damit der KV authoritativ bleibt: externe Actions
|
||||
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
|
||||
//!
|
||||
//! Wir nutzen `bladeink::ValueType` als Value, damit die Sync-Konvertierung
|
||||
//! gratis ist (kein Mapping zwischen Rust- und Ink-Typen nötig).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bladeink::value_type::ValueType;
|
||||
|
||||
pub type Store = HashMap<String, ValueType>;
|
||||
|
||||
/// Parst einen Action-Arg-String zu einem `ValueType`. Reihenfolge:
|
||||
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
|
||||
pub fn parse_value(s: &str) -> ValueType {
|
||||
let s = s.trim();
|
||||
match s {
|
||||
"true" => return ValueType::Bool(true),
|
||||
"false" => return ValueType::Bool(false),
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(i) = s.parse::<i32>() { return ValueType::Int(i); }
|
||||
if let Ok(f) = s.parse::<f32>() { return ValueType::Float(f); }
|
||||
ValueType::new(s)
|
||||
}
|
||||
|
||||
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
|
||||
pub fn format_value(v: &ValueType) -> String {
|
||||
match v {
|
||||
ValueType::Bool(b) => b.to_string(),
|
||||
ValueType::Int(i) => i.to_string(),
|
||||
ValueType::Float(f) => f.to_string(),
|
||||
other => other.coerce_to_string().unwrap_or_else(|_| "<?>".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_set(args: &str, store: &mut Store) {
|
||||
let Some((name, value)) = args.split_once(char::is_whitespace) else {
|
||||
eprintln!("[kv] set: expected 'name value', got {:?}", args);
|
||||
return;
|
||||
};
|
||||
store.insert(name.trim().to_string(), parse_value(value));
|
||||
}
|
||||
|
||||
pub fn apply_inc(args: &str, store: &mut Store) {
|
||||
let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1"));
|
||||
let name = name.trim();
|
||||
let delta = delta.trim().parse::<i32>().unwrap_or(1);
|
||||
let cur = store.get(name).and_then(|v| v.coerce_to_int().ok()).unwrap_or(0);
|
||||
store.insert(name.to_string(), ValueType::Int(cur + delta));
|
||||
}
|
||||
|
||||
pub fn apply_clear(args: &str, store: &mut Store) {
|
||||
store.remove(args.trim());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_value_types() {
|
||||
assert!(matches!(parse_value("true"), ValueType::Bool(true)));
|
||||
assert!(matches!(parse_value("false"), ValueType::Bool(false)));
|
||||
assert!(matches!(parse_value("42"), ValueType::Int(42)));
|
||||
assert!(matches!(parse_value("-7"), ValueType::Int(-7)));
|
||||
assert!(matches!(parse_value("1.5"), ValueType::Float(f) if f == 1.5));
|
||||
assert!(matches!(parse_value("hallo"), ValueType::String(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_inc_clear_roundtrip() {
|
||||
let mut s = Store::new();
|
||||
apply_set("clicks 3", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 3);
|
||||
|
||||
apply_inc("clicks", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
|
||||
|
||||
apply_inc("clicks -2", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 2);
|
||||
|
||||
// inc auf unbekannten Key startet bei 0
|
||||
apply_inc("fresh 5", &mut s);
|
||||
assert_eq!(s["fresh"].coerce_to_int().unwrap(), 5);
|
||||
|
||||
apply_clear("clicks", &mut s);
|
||||
assert!(!s.contains_key("clicks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_without_value_is_noop() {
|
||||
let mut s = Store::new();
|
||||
apply_set("only_name", &mut s);
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Headless-Kern: alles, was läuft, ohne dass ein Frontend existiert.
|
||||
//!
|
||||
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
|
||||
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
|
||||
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
|
||||
//! Frontends (CLI-REPL jetzt, Renderer später) sind Geschwister-Module,
|
||||
//! die `engine` konsumieren; die Richtung kehrt sich nie um.
|
||||
//!
|
||||
//! Interner Abhängigkeitsgraph (azyklisch, Pfeil = „benutzt"):
|
||||
//!
|
||||
//! kv ← ink ← game ← story_ctrl ← signals
|
||||
//! ↑ ↑
|
||||
//! kv assets
|
||||
//!
|
||||
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
|
||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
||||
//! signals::dispatch zu rufen).
|
||||
|
||||
pub mod assets;
|
||||
pub mod game;
|
||||
pub mod ink;
|
||||
pub mod kv;
|
||||
pub mod signals;
|
||||
pub mod story_ctrl;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
|
||||
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
|
||||
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
|
||||
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
|
||||
//!
|
||||
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
|
||||
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
|
||||
//! (vermeidet den Modul-Zyklus signals → story_ctrl → signals).
|
||||
|
||||
use crate::engine::assets;
|
||||
use crate::engine::game::ActionCtx;
|
||||
use crate::engine::ink::{Story, StoryState};
|
||||
|
||||
/// Lädt ein Ink-Skript (Pfad relativ zu `assets/interactions/`) in den
|
||||
/// Story-Slot. Der erste Schritt passiert noch nicht — das Frontend ruft
|
||||
/// dafür `advance(None, …)`.
|
||||
pub fn start(script: &str, ctx: &mut ActionCtx) {
|
||||
let path = assets::path(&format!("assets/interactions/{script}"));
|
||||
*ctx.story = Some(Story::load(&path, ctx.kv));
|
||||
}
|
||||
|
||||
/// Treibt die laufende Story einen Schritt weiter. `sel` ist die
|
||||
/// Choice-Auswahl bzw. `None` beim ersten Schritt oder Text-Weiterklicken.
|
||||
/// Bei `End` wird der Story-Slot geleert. Rückgabe: neuer Zustand (oder
|
||||
/// `None` wenn gar keine Story läuft) plus Tags zum Dispatchen.
|
||||
pub fn advance(sel: Option<usize>, ctx: &mut ActionCtx) -> (Option<StoryState>, Vec<String>) {
|
||||
let Some(st) = ctx.story.as_mut() else { return (None, Vec::new()); };
|
||||
if let Some(i) = sel { st.choose(i); }
|
||||
let state = st.cont(ctx.kv);
|
||||
let tags = st.take_tags();
|
||||
if matches!(state, StoryState::End) { *ctx.story = None; }
|
||||
(Some(state), tags)
|
||||
}
|
||||
Reference in New Issue
Block a user