From 247f7ef9fe02e3300cf558c9a639ba762b6273b6 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Tue, 1 Sep 2026 22:47:32 +0200 Subject: [PATCH] =?UTF-8?q?Post-Effekte=20=C3=BCber=20Action::SetPost=20st?= =?UTF-8?q?att=20Engine-Zustand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8 --- src/engine/game.rs | 4 ++ src/engine/mod.rs | 7 ++-- src/engine/post.rs | 74 -------------------------------- src/engine/signals.rs | 38 +++++++++++++++++ src/render/gpu.rs | 3 +- src/render/mod.rs | 22 +++++++--- src/render/post.rs | 66 +++++++++++++++++++++++++---- src/session.rs | 98 +++++++++++++++++++++---------------------- 8 files changed, 170 insertions(+), 142 deletions(-) delete mode 100644 src/engine/post.rs diff --git a/src/engine/game.rs b/src/engine/game.rs index 2c152f5..bcdafb4 100644 --- a/src/engine/game.rs +++ b/src/engine/game.rs @@ -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 diff --git a/src/engine/mod.rs b/src/engine/mod.rs index ce4780e..1f4897b 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -16,9 +16,9 @@ //! //! `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`. +//! 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 +29,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; diff --git a/src/engine/post.rs b/src/engine/post.rs deleted file mode 100644 index 733197b..0000000 --- a/src/engine/post.rs +++ /dev/null @@ -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) -> Option { - 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 { - 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")); - } -} diff --git a/src/engine/signals.rs b/src/engine/signals.rs index dca154b..0713de8 100644 --- a/src/engine/signals.rs +++ b/src/engine/signals.rs @@ -18,6 +18,9 @@ //! `assets/sounds/{name}.wav` //! `mode ` → Action::SetMode (deferred): Anzeige-/ //! Eingabemodus wechseln +//! `post ` → 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 } +/// `" on"` / `" 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. @@ -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 diff --git a/src/render/gpu.rs b/src/render/gpu.rs index 9b1ed09..c41edde 100644 --- a/src/render/gpu.rs +++ b/src/render/gpu.rs @@ -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; diff --git a/src/render/mod.rs b/src/render/mod.rs index 4247339..7945ec9 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -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, sfx_cache: HashMap>, + /// 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); } } } diff --git a/src/render/post.rs b/src/render/post.rs index 0331d82..46882be 100644 --- a/src/render/post.rs +++ b/src/render/post.rs @@ -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 { - 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")); + } +} diff --git a/src/session.rs b/src/session.rs index 9e7879e..be7f55a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -3,7 +3,6 @@ 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}; @@ -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, + /// 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 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 [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 { - if args.is_empty() { - let lines: Vec = 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 [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 @@ -404,10 +398,6 @@ impl Session { } } -fn on_off(on: bool) -> &'static str { - if on { "an" } else { "aus" } -} - fn help() -> Vec { vec![ " signal Signal feuern oder Action direkt ausführen".into(), @@ -417,7 +407,7 @@ fn help() -> Vec { " kv KV-Store anzeigen".into(), " menu Menü öffnen / ins Spiel zurück".into(), " mode Modus setzen: play | free (Noclip) | menu".into(), - " post [ on|off] Post-Effekte anzeigen / schalten".into(), + " post on|off Post-Effekt schalten (Fenster-Frontend)".into(), " reload signals.toml neu laden".into(), " quit beenden".into(), ] @@ -643,21 +633,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 on|off"]); + assert_eq!(s.exec("post outline wat").output, vec!["post 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]