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
This commit is contained in:
irrlicht
2026-09-01 22:47:32 +02:00
co-authored by Claude Sonnet 5
parent b3d08d4a8c
commit 247f7ef9fe
8 changed files with 170 additions and 142 deletions
+48 -50
View File
@@ -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<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
@@ -404,10 +398,6 @@ impl Session {
}
}
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(),
]
@@ -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 <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]