Compare commits

...
4 Commits
Author SHA1 Message Date
irrlicht 307b52ae1f Removed old demo scene 2026-09-01 23:08:06 +02:00
irrlichtandClaude Sonnet 5 6e84fe9b1b model::Model: Doku als Loader/Konsumenten-Vertrag, nicht Format-Abstraktion
Nach dem Rauswurf der OBJ- und Map-Loader-Pfade (glTF ist der einzige
3D-Pfad) verkaufte die model.rs-Doku eine Format-Neutralität, die es nicht
mehr gibt. Der Typ bleibt — er ist die legitime Entkopplungsgrenze
zwischen dem einen Produzenten (gltf) und seinen Konsumenten (collision,
trigger, audio, render::props, session::load_props), die alle auf einer
geparsten Struktur arbeiten statt selbst glTF zu lesen. Nur die
Formulierung wird ehrlich.

Reine Kommentaränderung in model.rs, gltf.rs, engine/mod.rs, render/props.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 23:04:31 +02:00
irrlichtandClaude Sonnet 5 236a643d04 kv::Store von bladeink entkoppeln
Der Kern-KV war HashMap<String, bladeink::ValueType> — jedes Modul, das
eine Spielvariable las, hing damit an der öffentlichen API der Story-Crate.
"Konvertierung gratis" war der Grund, aber der Preis war eine
Dependency-Kante quer durch engine/.

Neuer eigener kv::Value (Bool/Int/Float/Str). Die Umrechnung auf Inks
ValueType passiert jetzt allein in ink.rs (to_ink/from_ink) — die einzige
Stelle im Kern, die bladeink überhaupt kennt. from_ink verwirft
Ink-eigene Typen (Listen, Divert-Referenzen), die im KV nichts verloren
haben.

- format_value → Display für Value
- coerce_to_int → Value::as_int (gleiche Semantik); coerce_to_bool hatte
  außer Tests keinen Aufrufer und entfällt, die Tests prüfen jetzt die
  gespeicherte Variante direkt
- bladeink bleibt Dependency (ink.rs braucht es), nur nicht mehr im
  KV-Vertrag

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 22:54:52 +02:00
irrlichtandClaude Sonnet 5 247f7ef9fe Post-Effekte über Action::SetPost statt Engine-Zustand
Die PostChain lag als engine::post im Kern, nur damit die Konsole
`post` durch den exec-Trichter schalten konnte — Zustand, der allein
das Render-Frontend angeht und nichts mit der Simulation zu tun hat.

Jetzt kennt der Kern Post-Effekte nur als deferred Action (wie
play_sound): das `post <name> on|off`-Verb queued Action::SetPost,
die Session reicht sie als take_post()-Queue durch, das Fenster wendet
sie auf seine eigene PostChain an. Damit schalten Trigger und Ink-Tags
Effekte, die vorher nur der Konsolenbefehl erreichte.

- engine/post.rs entfällt; PostChain wandert nach render::post
- Session verliert das post-Feld, bekommt pending_post + take_post
- der `post`-Konsolenbefehl ist ein Shim wie `mode`; Ketten-Listing
  und nackter Toggle fallen weg (letzterer bewusst, vgl. 8bee832)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-01 22:47:32 +02:00
14 changed files with 302 additions and 232 deletions
+2 -32
View File
@@ -9,44 +9,14 @@
# inc <name> [<delta>] KV-Integer +Delta (Default +1)
# clear <name> KV-Eintrag entfernen
# play_sound <name> deferred (Frontend): SFX aus assets/sounds/{name}.wav
# mode <play|free|menu> deferred (Frontend): Anzeige-/Eingabemodus
# post <name> <on|off> deferred (Frontend): Renderer-Post-Effekt schalten
#
# Reserviertes Signal `[init]` feuert einmal vor der Game-Loop —
# idealer Ort für KV-Defaults.
[init]
actions = [
"set cube_clicks 0",
"set mushrooms_collected 0",
"set quest_mushroom_active false",
"set quest_mushroom_done false",
]
[Mushroom]
actions = [
"inc mushrooms_collected",
"play_sound pickup",
]
[Person]
actions = [
"play_sound greet",
"start_ink person.ink.json",
]
[Suzanne]
actions = [
"start_ink suzanne.ink.json",
]
[Cube]
actions = [
"inc cube_clicks",
"start_ink cube.ink.json",
]
[person_farewell]
actions = [
"debug_log Person hat sich verabschiedet",
]
[tiffany]
+4
View File
@@ -29,6 +29,10 @@ pub enum Action {
/// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
/// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
SetMode(ModeTarget),
/// Renderer-Post-Effekt schalten. Der Kern kennt weder die Kette noch die
/// Effektnamen — das Fenster löst `name` gegen seine Kette auf (unbekannt
/// → Meldung), die CLI reicht nur die `[action]`-Zeile.
SetPost { name: String, on: bool },
}
/// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
+1 -1
View File
@@ -1,4 +1,4 @@
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
//! glTF-Binary-Loader (.glb, Blender-Export) → geparstes [`Model`].
//!
//! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
+32 -7
View File
@@ -1,4 +1,6 @@
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`.
//! Dünner Adapter um die `bladeink`-Crate — und die einzige Stelle im Kern,
//! die sie kennt. `to_ink`/`from_ink` rechnen zwischen `kv::Value` und Inks
//! `ValueType` um; alles andere spricht nur den neutralen KV-Typ.
//!
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
@@ -20,8 +22,9 @@
use std::fs::read_to_string;
use bladeink::story::Story as BladeStory;
use bladeink::value_type::ValueType;
use crate::engine::kv::Store;
use crate::engine::kv::{Store, Value};
pub enum StoryState {
Text(String),
@@ -140,7 +143,7 @@ impl Story {
if name.starts_with('_') { continue; } // skript-lokal
// set_variable schlägt fehl wenn der Name kein deklariertes
// VAR im Skript ist — still ignorieren.
let _ = self.inner.set_variable(name, value);
let _ = self.inner.set_variable(name, &to_ink(value));
}
}
@@ -149,17 +152,39 @@ impl Story {
/// genau so wandern Werte in per `start_ink` verkettete Folgeskripte.
fn sync_to_kv(&self, kv: &mut Store) {
for name in &self.vars {
if let Some(v) = self.inner.get_variable(name) {
if let Some(v) = self.inner.get_variable(name).as_ref().and_then(from_ink) {
kv.insert(name.clone(), v);
}
}
}
}
/// [`Value`] → Inks `ValueType`. Zusammen mit [`from_ink`] die einzige Stelle,
/// an der der Kern-KV die Story-Crate berührt.
fn to_ink(v: &Value) -> ValueType {
match v {
Value::Bool(b) => ValueType::Bool(*b),
Value::Int(i) => ValueType::Int(*i),
Value::Float(f) => ValueType::Float(*f),
Value::Str(s) => ValueType::new(s.as_str()),
}
}
/// Inks `ValueType` → [`Value`]. `None` für Ink-eigene Typen (Listen, Divert-
/// und Variablen-Referenzen), die im KV nichts verloren haben.
fn from_ink(v: &ValueType) -> Option<Value> {
Some(match v {
ValueType::Bool(b) => Value::Bool(*b),
ValueType::Int(i) => Value::Int(*i),
ValueType::Float(f) => Value::Float(*f),
ValueType::String(s) => Value::Str(s.string.clone()),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use bladeink::value_type::ValueType;
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
// VAR snaks = 0
@@ -183,7 +208,7 @@ mod tests {
let mut kv = Store::new();
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
assert!(matches!(s.cont(&mut kv), StoryState::Text(_)));
assert!(matches!(kv["snaks"], ValueType::Int(1)));
assert!(matches!(kv["snaks"], Value::Int(1)));
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
}
@@ -191,7 +216,7 @@ mod tests {
fn kv_values_flow_into_the_script() {
// Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`).
let mut kv = Store::new();
kv.insert("snaks".into(), ValueType::Int(7));
kv.insert("snaks".into(), Value::Int(7));
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
match s.cont(&mut kv) {
StoryState::Text(t) => assert_eq!(t, "S=7"),
+76 -34
View File
@@ -3,42 +3,69 @@
//! 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
//! [`crate::engine::ink`]). Damit der KV autoritativ 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).
//! [`Value`] ist ein eigener, Ink- und frontend-neutraler Typ. Die Umrechnung
//! auf `bladeink::ValueType` passiert allein an der Ink-Grenze in
//! [`crate::engine::ink`] — so hängt der Kern-KV nicht an der Story-Crate.
use std::collections::HashMap;
use std::fmt;
use bladeink::value_type::ValueType;
pub type Store = HashMap<String, Value>;
pub type Store = HashMap<String, ValueType>;
/// Ein KV-Wert. Deckt die Skalare ab, die signals.toml-Actions und Ink-`VAR`s
/// austauschen; Ink-eigene Typen (Listen, Divert-Referenzen) haben hier
/// bewusst keinen Platz.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Bool(bool),
Int(i32),
Float(f32),
Str(String),
}
/// 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),
_ => {}
impl Value {
/// Als Ganzzahl lesen (für `inc`): `Bool` zählt als 0/1, `Float` schneidet
/// ab, `Str` hat keinen Zahlwert. Spiegelt die Coercion, die vorher
/// bladeink lieferte.
pub fn as_int(&self) -> Option<i32> {
match self {
Value::Bool(b) => Some(*b as i32),
Value::Int(i) => Some(*i),
Value::Float(f) => Some(*f as i32),
Value::Str(_) => None,
}
}
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()),
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Bool(b) => write!(f, "{b}"),
Value::Int(i) => write!(f, "{i}"),
Value::Float(x) => write!(f, "{x}"),
Value::Str(s) => write!(f, "{s}"),
}
}
}
/// Parst einen Action-Arg-String zu einem [`Value`]. Reihenfolge:
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
pub fn parse_value(s: &str) -> Value {
let s = s.trim();
match s {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
if let Ok(i) = s.parse::<i32>() { return Value::Int(i); }
if let Ok(x) = s.parse::<f32>() { return Value::Float(x); }
Value::Str(s.to_string())
}
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);
@@ -51,8 +78,8 @@ 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));
let cur = store.get(name).and_then(Value::as_int).unwrap_or(0);
store.insert(name.to_string(), Value::Int(cur + delta));
}
pub fn apply_clear(args: &str, store: &mut Store) {
@@ -65,29 +92,37 @@ mod tests {
#[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(_)));
assert!(matches!(parse_value("true"), Value::Bool(true)));
assert!(matches!(parse_value("false"), Value::Bool(false)));
assert!(matches!(parse_value("42"), Value::Int(42)));
assert!(matches!(parse_value("-7"), Value::Int(-7)));
assert!(matches!(parse_value("1.5"), Value::Float(f) if f == 1.5));
assert!(matches!(parse_value("hallo"), Value::Str(_)));
}
#[test]
fn as_int_coerces_like_ink_did() {
assert_eq!(Value::Bool(true).as_int(), Some(1));
assert_eq!(Value::Int(-7).as_int(), Some(-7));
assert_eq!(Value::Float(2.9).as_int(), Some(2));
assert_eq!(Value::Str("x".into()).as_int(), None);
}
#[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);
assert_eq!(s["clicks"].as_int().unwrap(), 3);
apply_inc("clicks", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
assert_eq!(s["clicks"].as_int().unwrap(), 4);
apply_inc("clicks -2", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 2);
assert_eq!(s["clicks"].as_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);
assert_eq!(s["fresh"].as_int().unwrap(), 5);
apply_clear("clicks", &mut s);
assert!(!s.contains_key("clicks"));
@@ -99,4 +134,11 @@ mod tests {
apply_set("only_name", &mut s);
assert!(s.is_empty());
}
#[test]
fn display_is_plain_scalar() {
assert_eq!(Value::Bool(true).to_string(), "true");
assert_eq!(Value::Int(-3).to_string(), "-3");
assert_eq!(Value::Str("hi".into()).to_string(), "hi");
}
}
+5 -5
View File
@@ -15,10 +15,11 @@
//! rufen.
//!
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
//! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
//! die Emitter-Logik zum Frontend-Treiber render::audio, `post` analog die
//! Effekt-Schalter zu render::post, `player` die First-Person-Physik auf der
//! Welt aus `collision`.
//! ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag zwischen
//! Loader und seinen Konsumenten (collision, trigger, audio, render::props).
//! `audio` ist die Emitter-Logik zum Frontend-Treiber render::audio, `player`
//! die First-Person-Physik auf der Welt aus `collision`. Post-Effekte kennt
//! der Kern nur als `game::Action::SetPost` — die Kette lebt in render::post.
pub mod assets;
pub mod audio;
@@ -29,7 +30,6 @@ pub mod ink;
pub mod kv;
pub mod model;
pub mod player;
pub mod post;
pub mod signals;
pub mod story_ctrl;
pub mod tga;
+8 -5
View File
@@ -1,7 +1,10 @@
//! Neutrales 3D-Modell — das gemeinsame Ergebnis des Blender-Loaders
//! (engine::gltf). Konsumenten (render::props, collision, trigger) kennen
//! nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen egal — so
//! bleibt ein Format-Wechsel ein lokaler Loader-Tausch.
//! Das geparste Blender-Modell: Ergebnis des Loaders (engine::gltf) und der
//! Vertrag, auf den sich seine Konsumenten stützen — collision, trigger,
//! audio, render::props und session::load_props arbeiten alle auf dieser
//! einen Struktur, statt selbst glTF zu lesen. Eine Entkopplungsgrenze
//! zwischen dem einen Loader und seinen Konsumenten, keine Format-Abstraktion:
//! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind
//! Geschichte), `gltf` der einzige Produzent.
//!
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
@@ -15,7 +18,7 @@
//!
//! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
//! die Loader bleiben frei von Signal-Wissen.
//! der Loader bleibt frei von Signal-Wissen.
use std::collections::HashMap;
-74
View File
@@ -1,74 +0,0 @@
//! Post-Effekt-Schalter: welches Kettenglied des Renderers ist an?
//!
//! Headless-Gegenstück zu `render::post`, so wie `audio` zum Audio-Treiber.
//! Hier liegen nur die *Namen* und ihr An/Aus-Zustand — damit die Konsole sie
//! durch denselben `exec`-Trichter schalten kann wie alles andere, ohne dass
//! der Kern eine GPU kennt. Was ein Effekt tut und in welcher Reihenfolge er
//! läuft, weiß allein das Render-Frontend; es meldet seine Effekte beim Start
//! per [`PostChain::register`] an. Ohne Fenster-Frontend bleibt die Liste leer.
#[derive(Default)]
pub struct PostChain {
/// In Ketten-Reihenfolge, wie vom Frontend angemeldet.
effects: Vec<(String, bool)>,
}
impl PostChain {
pub fn new() -> Self { Self::default() }
/// Einen Effekt anmelden (Frontend beim Start, in Ketten-Reihenfolge).
pub fn register(&mut self, name: &str, on: bool) {
self.effects.push((name.to_string(), on));
}
pub fn enabled(&self, name: &str) -> bool {
self.effects.iter().any(|(n, on)| n == name && *on)
}
/// Schalten; `on = None` togglet. Gibt den neuen Zustand zurück, `None`
/// bei unbekanntem Namen.
pub fn set(&mut self, name: &str, on: Option<bool>) -> Option<bool> {
let e = self.effects.iter_mut().find(|(n, _)| n == name)?;
e.1 = on.unwrap_or(!e.1);
Some(e.1)
}
/// Alle Effekte in Ketten-Reihenfolge.
pub fn list(&self) -> impl Iterator<Item = (&str, bool)> {
self.effects.iter().map(|(n, on)| (n.as_str(), *on))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> PostChain {
let mut c = PostChain::new();
c.register("cel", false);
c.register("dither", true);
c
}
#[test]
fn keeps_registration_order() {
let c = chain();
let names: Vec<&str> = c.list().map(|(n, _)| n).collect();
assert_eq!(names, vec!["cel", "dither"]);
}
#[test]
fn set_toggles_and_reports_unknown() {
let mut c = chain();
assert_eq!(c.set("cel", Some(true)), Some(true));
assert!(c.enabled("cel"));
assert_eq!(c.set("cel", None), Some(false), "ohne Argument togglen");
assert!(!c.enabled("cel"));
assert_eq!(c.set("bloom", Some(true)), None, "unbekannt");
}
#[test]
fn unregistered_effect_is_off() {
assert!(!PostChain::new().enabled("dither"));
}
}
+41 -3
View File
@@ -18,6 +18,9 @@
//! `assets/sounds/{name}.wav`
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
//! Eingabemodus wechseln
//! `post <name> <on|off>` → Action::SetPost (deferred): Renderer-
//! Post-Effekt schalten (Name = Kettenglied
//! im Fenster-Frontend)
//!
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
@@ -101,11 +104,29 @@ fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
"mode" => if let Some(t) = ModeTarget::parse(args) {
ctx.actions.push(Action::SetMode(t));
},
"post" => if let Some((name, on)) = parse_post_arg(args) {
ctx.actions.push(Action::SetPost { name, on });
},
_ => return false, // Unbekanntes Verb — der Aufrufer meldet ggf.
}
true
}
/// `"<name> on"` / `"<name> off"` → `(name, bool)`; sonst `None`. Kein
/// nackter Toggle — eine geskriptete Schaltung soll lesbar sagen, wohin sie
/// schaltet. Geteilt von der Dispatch-Pipeline (`post`-Verb) und dem
/// `post`-Konsolenbefehl in `session`.
pub fn parse_post_arg(args: &str) -> Option<(String, bool)> {
let (name, state) = args.trim().split_once(char::is_whitespace)?;
let on = match state.trim() {
"on" => true,
"off" => false,
_ => return None,
};
let name = name.trim();
(!name.is_empty()).then(|| (name.to_string(), on))
}
/// 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.
@@ -188,7 +209,7 @@ mod tests {
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.kv["items"].as_int().unwrap(), 1);
assert_eq!(game.actions, vec![
Action::PlaySound("Mushroom.005".into()),
]);
@@ -208,6 +229,23 @@ mod tests {
assert!(game.actions.is_empty());
}
#[test]
fn post_verb_queues_setpost_action() {
let mut game = Game::new(Signals::new());
let mut ctx = game.action_ctx(None);
dispatch("post outline off", &mut ctx);
assert_eq!(game.actions, vec![
Action::SetPost { name: "outline".into(), on: false },
]);
// Fehlendes/ungültiges on|off → kein Action (stilles No-Op wie `mode`).
game.actions.clear();
let mut ctx = game.action_ctx(None);
dispatch("post outline", &mut ctx);
dispatch("post outline wat", &mut ctx);
assert!(game.actions.is_empty());
}
#[test]
fn typo_verb_in_table_is_tolerated_and_reported() {
// Ein Tippfehler-Verb in einer Table-Action wird gemeldet (stderr) und
@@ -221,7 +259,7 @@ mod tests {
let mut ctx = game.action_ctx(None);
dispatch("thing", &mut ctx);
assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
assert!(game.kv["ok"].coerce_to_bool().unwrap(), "gültige Action wirkt weiter");
assert!(matches!(game.kv["ok"], kv::Value::Bool(true)), "gültige Action wirkt weiter");
}
#[test]
@@ -230,7 +268,7 @@ mod tests {
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());
assert!(matches!(game.kv["has_key"], kv::Value::Bool(true)));
// Unbekanntes Verb ist ein stilles No-Op.
let mut ctx = game.action_ctx(None);
+1 -2
View File
@@ -16,10 +16,9 @@ use std::sync::Arc;
use winit::window::Window;
use crate::engine::post::PostChain;
use crate::engine::tga::Image;
use crate::render::math::Mat4;
use crate::render::post::Post;
use crate::render::post::{Post, PostChain};
use crate::render::scene::{Mesh, ScenePass};
use crate::render::sprite::SpritePass;
use crate::render::ui::Ui;
+17 -5
View File
@@ -45,10 +45,6 @@ const EYE_SMOOTH_TAU: f32 = 0.06;
const EYE_MAX_LAG: f32 = 0.6;
pub fn run(mut session: Session) {
// Post-Effekte anmelden: die Kette gehört dem Renderer, geschaltet wird
// sie über denselben exec-Trichter wie alles andere (`post`).
for (name, on) in post::effects() { session.post.register(name, on); }
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); }
@@ -242,6 +238,9 @@ struct App {
/// melden, nicht jeden Klick).
audio: Option<audio::Audio>,
sfx_cache: HashMap<String, Option<audio::Sound>>,
/// Post-Effekt-Kette (Renderer-Zustand, nicht im Kern). Pro Frame aus
/// `session.take_post()` nachgeführt, dann an `gpu.frame`.
post: post::PostChain,
}
impl App {
@@ -271,6 +270,10 @@ impl App {
fonts,
audio,
sfx_cache: HashMap::new(),
// Die Post-Kette gehört dem Renderer, nicht dem Kern: geschaltet
// wird sie über `Action::SetPost` (Trigger/Ink/Konsole), die die
// Session als `take_post`-Queue durchreicht.
post: post::PostChain::defaults(),
}
}
@@ -437,6 +440,15 @@ impl App {
}
}
// Post-Effekt-Schaltungen (SetPost-Actions von Trigger/Ink/Konsole)
// auf die eigene Kette anwenden; unbekannter Name wird gemeldet, wie
// ein fehlendes WAV bei play_sound.
for (name, on) in self.session.take_post() {
if !self.post.set(&name, on) {
eprintln!("[post] unbekannter Effekt {name:?}");
}
}
// Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
// der Luft und außerhalb des Spiels folgt sie exakt.
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
@@ -494,7 +506,7 @@ impl App {
}
if let Some(gpu) = &mut self.gpu {
gpu.frame(&view, &screen.ui, &self.session.post);
gpu.frame(&view, &screen.ui, &self.post);
}
}
}
+59 -7
View File
@@ -13,15 +13,14 @@
//! Effekt über den Frame wissen kann; beide darf ein Effekt ignorieren. Kein
//! Effekt kennt einen anderen, keiner darf einen anderen voraussetzen. Ein
//! Effekt wird hinzugefügt, indem er in [`EFFECTS`] auftaucht; geschaltet
//! wird er über `engine::post` (Konsole: `post`).
//! wird er über [`PostChain`], die Trigger, Ink-Tags und die Konsole per
//! `game::Action::SetPost` erreichen. Der Kern kennt die Kette nicht.
//!
//! Gerechnet wird auf zwei Ping-Pong-Targets in interner Auflösung. Bei
//! 640×480 kostet ein zusätzlicher Fullscreen-Pass praktisch nichts —
//! deshalb darf jeder Effekt seinen eigenen behalten, statt sie zu einem
//! Über-Shader zu verschmelzen.
use crate::engine::post::PostChain;
/// Die Kette in Ausführungsreihenfolge, mit ihrem Startzustand. `dither`
/// quantisiert die Farbtiefe und muss deshalb zuletzt laufen.
const EFFECTS: &[(&str, bool, &str)] = &[
@@ -30,10 +29,34 @@ const EFFECTS: &[(&str, bool, &str)] = &[
("dither", true, include_str!("dither.wgsl")),
];
/// Effektnamen samt Startzustand — das Frontend meldet sie beim Start in der
/// Session an.
pub fn effects() -> impl Iterator<Item = (&'static str, bool)> {
EFFECTS.iter().map(|(n, on, _)| (*n, *on))
/// An/Aus je Post-Effekt, in Ketten-Reihenfolge. Das Fenster hält sie als
/// eigenen Zustand (nicht der Kern), wendet die per `Session::take_post`
/// aufgelaufenen `SetPost`-Schaltungen jeden Frame an und gibt die Kette an
/// [`Post::run`].
pub struct PostChain {
effects: Vec<(String, bool)>,
}
impl PostChain {
/// Die Kette aus [`EFFECTS`] mit ihren Startzuständen.
pub fn defaults() -> Self {
Self {
effects: EFFECTS.iter().map(|(n, on, _)| ((*n).to_string(), *on)).collect(),
}
}
pub fn enabled(&self, name: &str) -> bool {
self.effects.iter().any(|(n, on)| n == name && *on)
}
/// Effekt `name` auf `on` setzen. Rückgabe: `false`, wenn kein Effekt so
/// heißt — der Aufrufer meldet das (wie ein fehlendes WAV bei `play_sound`).
pub fn set(&mut self, name: &str, on: bool) -> bool {
match self.effects.iter_mut().find(|(n, _)| n == name) {
Some(e) => { e.1 = on; true }
None => false,
}
}
}
/// Spiegelt `Uniforms` in den Effekt-Shadern. `near`/`far` müssen zur
@@ -304,3 +327,32 @@ fn make_bind(
}],
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_follow_effects_table() {
let c = PostChain::defaults();
// Startzustände aus EFFECTS.
assert!(!c.enabled("normals"));
assert!(c.enabled("outline"));
assert!(c.enabled("dither"));
}
#[test]
fn set_switches_and_reports_unknown() {
let mut c = PostChain::defaults();
assert!(c.set("outline", false));
assert!(!c.enabled("outline"));
assert!(c.set("outline", true));
assert!(c.enabled("outline"));
assert!(!c.set("bloom", true), "unbekannter Effekt");
}
#[test]
fn unknown_effect_is_off() {
assert!(!PostChain::defaults().enabled("bloom"));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! Blender-Modell → Render-Geometrie.
//!
//! Nimmt das neutrale [`Model`] aus engine::gltf und baut daraus ein
//! Nimmt das geparste [`Model`] aus engine::gltf und baut daraus ein
//! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere
//! Modelle verschmelzen über `Mesh::append` zu einem Buffer.
//!
+55 -56
View File
@@ -3,9 +3,8 @@ use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::player::{self, Player};
use crate::engine::post::PostChain;
use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl};
use crate::engine::{model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
const LOOK_SENS: f32 = 0.0025;
@@ -44,13 +43,14 @@ pub struct Session {
/// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
/// Empties, siehe engine::audio).
emitters: Emitters,
/// An/Aus der Render-Post-Effekte. Das Fenster-Frontend meldet seine Kette
/// beim Start an und liest sie jeden Frame; die CLI lässt sie leer.
pub post: PostChain,
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
/// zieht sie per [`Session::take_sounds`]; der CLI reicht die
/// `[action]`-Textzeile.
pending_sounds: Vec<String>,
/// Von `Action::SetPost` aufgelaufene Post-Effekt-Schaltungen (Name,
/// Zielzustand) — das Fenster zieht sie per [`Session::take_post`] und
/// wendet sie auf seine Kette an; der CLI reicht die `[action]`-Textzeile.
pending_post: Vec<(String, bool)>,
signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32,
@@ -97,8 +97,8 @@ impl Session {
collision: CollisionWorld::empty(),
triggers: Triggers::new(),
emitters: Emitters::new(),
post: PostChain::new(),
pending_sounds: Vec::new(),
pending_post: Vec::new(),
signals_path,
sim_accum: 0.0,
pending_jump: false,
@@ -138,6 +138,13 @@ impl Session {
std::mem::take(&mut self.pending_sounds)
}
/// Aufgelaufene Post-Effekt-Schaltungen (`SetPost`-Actions) abholen — jeden
/// Frame vom Fenster-Frontend, das sie auf seine Kette anwendet. Headless
/// bleibt die Queue liegen (die CLI hat keine Kette) und wird nie gezogen.
pub fn take_post(&mut self) -> Vec<(String, bool)> {
std::mem::take(&mut self.pending_post)
}
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
/// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
@@ -255,7 +262,16 @@ impl Session {
}
None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
},
("post", args) => ExecResult::lines(self.post_cmd(args)),
// Post-Effekt über dieselbe Action wie das `post`-Verb aus
// signals.toml/Ink-Tags. Das Fenster löst den Namen gegen seine
// Kette auf; headless bleibt es bei der `[action]`-Zeile.
("post", args) => match signals::parse_post_arg(args) {
Some((name, on)) => {
self.game.actions.push(Action::SetPost { name, on });
ExecResult::lines(self.drain_actions())
}
None => ExecResult::lines(vec!["post <name> on|off".into()]),
},
("signal", sig) => ExecResult::lines(self.fire(sig, None)),
// Objekt-Interaktion (Klick wie Konsole): Klick-Ziele feuern ihre
// `signal`-Property, sonst den suffix-befreiten Objektnamen.
@@ -272,32 +288,6 @@ impl Session {
}
}
/// `post` listet die Kette, `post <name> [on|off]` schaltet sie (ohne
/// Zustand: toggle). Die Namen kommen vom Frontend — die Session validiert
/// nur gegen das, was angemeldet wurde.
fn post_cmd(&mut self, args: &str) -> Vec<String> {
if args.is_empty() {
let lines: Vec<String> = self.post.list()
.map(|(n, on)| format!(" {n:<10} {}", on_off(on)))
.collect();
return match lines.is_empty() {
true => vec!["keine Post-Effekte (nur im Fenster-Frontend)".into()],
false => lines,
};
}
let (name, state) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
let on = match state.trim() {
"" => None,
"on" | "an" => Some(true),
"off" | "aus" => Some(false),
_ => return vec!["post <name> [on|off]".into()],
};
match self.post.set(name, on) {
Some(now) => vec![format!("{name}: {}", on_off(now))],
None => vec![format!("unbekannter Effekt: {name:?} — `post` listet sie")],
}
}
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() {
@@ -378,6 +368,10 @@ impl Session {
self.set_mode(t);
out.push(format!("[action] mode {}", t.label()));
}
Action::SetPost { name, on } => {
out.push(format!("[action] post {name} {}", if on { "on" } else { "off" }));
self.pending_post.push((name, on));
}
}
}
out
@@ -399,15 +393,11 @@ impl Session {
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])))
.map(|k| format!(" {k} = {}", self.game.kv[k]))
.collect()
}
}
fn on_off(on: bool) -> &'static str {
if on { "an" } else { "aus" }
}
fn help() -> Vec<String> {
vec![
" signal <s> Signal feuern oder Action direkt ausführen".into(),
@@ -417,7 +407,7 @@ fn help() -> Vec<String> {
" kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".into(),
" mode <m> Modus setzen: play | free (Noclip) | menu".into(),
" post [<e> on|off] Post-Effekte anzeigen / schalten".into(),
" post <e> on|off Post-Effekt schalten (Fenster-Frontend)".into(),
" reload signals.toml neu laden".into(),
" quit beenden".into(),
]
@@ -426,6 +416,7 @@ fn help() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::kv::Value;
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
@@ -450,7 +441,7 @@ mod tests {
// 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.game.kv["has_key"], Value::Bool(true)));
assert!(matches!(s.mode, Mode::Free));
}
@@ -466,7 +457,7 @@ mod tests {
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());
assert!(matches!(s.game.kv["in_menu"], Value::Bool(true)));
}
#[test]
@@ -581,7 +572,7 @@ mod tests {
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
// Flanke genau einmal (danach: drin = still).
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben");
assert!(matches!(s.game.kv["zone_hit"], Value::Bool(true)), "Zone sollte gefeuert haben");
// Weiterlaufen in der Zone darf nicht erneut feuern.
s.game.kv.remove("zone_hit");
@@ -596,7 +587,7 @@ mod tests {
s.mode = Mode::FirstPerson;
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
s.exec("use Kiste");
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
assert!(matches!(s.game.kv["kiste_geklickt"], Value::Bool(true)));
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
let r = s.exec("use Irgendwas.003");
@@ -643,21 +634,29 @@ mod tests {
}
#[test]
fn post_command_switches_registered_effects() {
fn post_command_queues_setpost_for_the_frontend() {
let mut s = empty_session();
// Ohne Frontend ist die Kette leer — der Befehl sagt das, statt zu lügen.
assert_eq!(s.exec("post").output.len(), 1);
assert!(!s.post.enabled("dither"));
s.mode = Mode::Free;
// Der Befehl kennt die Kette nicht — er reiht die Schaltung ein, das
// Fenster zieht sie per take_post().
let r = s.exec("post outline off");
assert_eq!(r.output, vec!["[action] post outline off"]);
assert_eq!(s.take_post(), vec![("outline".to_string(), false)]);
assert!(s.take_post().is_empty(), "abgeholt = geleert");
s.post.register("dither", true);
assert_eq!(s.exec("post").output.len(), 1, "eine Zeile je Effekt");
s.exec("post dither off");
assert!(!s.post.enabled("dither"));
s.exec("post dither");
assert!(s.post.enabled("dither"), "ohne Zustand togglet es");
// Unbekannter Effekt meldet sich und lässt die Kette in Ruhe.
assert_eq!(s.exec("post bloom on").output.len(), 1);
assert!(s.post.enabled("dither"));
// Fehlendes/ungültiges on|off → Nutzungshinweis, nichts eingereiht.
assert_eq!(s.exec("post outline").output, vec!["post <name> on|off"]);
assert_eq!(s.exec("post outline wat").output, vec!["post <name> on|off"]);
assert!(s.take_post().is_empty());
}
#[test]
fn post_signal_from_table_queues_setpost() {
let mut s = empty_session();
s.mode = Mode::Free;
// Ink-Tag-/signals.toml-Pfad: `post`-Verb direkt als Action.
s.exec("signal post dither on");
assert_eq!(s.take_post(), vec![("dither".to_string(), true)]);
}
#[test]