Files
wds/src/session.rs
T
irrlichtandClaude Sonnet 5 fc3494466e Welt-Texturen aus der .glb statt aus assets/textures/
Der Loader zieht jetzt die baseColorTexture jedes Materials aus dem
BIN-Chunk und dekodiert sie (PNG, via png-Crate) → model::Texture mit
Pixeln. Die Namenskonvention "Materialname = Dateiname" und der
assets/textures/-Pfad für Welt-Texturen entfallen; assets/textures/
bleibt nur noch für UI/Fonts/Platzhalter.

- engine::image: neuer, geteilter RGBA8-Image-Typ (aus tga.rs gezogen)
  plus PNG-Decoder. tga.rs liefert weiter denselben Typ.
- gltf: material -> texture -> image -> bufferView auflösen, Bild aus
  BIN dekodieren; fehlt ein Glied, bleibt image None (Platzhalter).
  KHR_texture_transform (Blenders Mapping-Node) wird beim Laden in die
  UVs eingebacken.
- model::Model: materials: Vec<String> -> textures: Vec<Texture>.
- render: die Pixel je Schlüssel aus den Modellen sammeln (erstes
  Vorkommen gewinnt, Schlüssel ohne Bild -> Platzhalter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:29:46 +02:00

671 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::engine::audio::Emitters;
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::trigger::Triggers;
use crate::engine::{model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
const LOOK_SENS: f32 = 0.0025;
/// Fester Simulationsschritt (s) = 60 Hz, unabhängig von der Render-Framerate:
/// Step-up und Ground-Snap verhalten sich sonst bei 144 Hz anders als in den
/// (bei 1/60 geschriebenen) Player-Tests.
const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): nach einem Hänger höchstens so viel
/// Zeit nachholen, sonst spiralt die Sim davon. ~15 Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25;
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
pub const INTERACT_REACH: f32 = 3.0;
pub enum Mode {
FirstPerson,
Free,
Menu,
Dialog(Dialog),
}
pub struct Dialog {
pub text: String,
pub choices: Vec<String>,
}
pub struct Session {
pub game: Game,
pub mode: Mode,
pub player: Player,
pub collision: CollisionWorld,
/// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen
/// (`signal`-Property, siehe engine::trigger).
triggers: Triggers,
/// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
/// Empties, siehe engine::audio).
emitters: Emitters,
/// 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,
/// Gepufferte Sprung-Flanke: bei hohen FPS fällt eine Sprungtaste sonst in
/// einen Frame ohne Sim-Schritt und verfällt.
pending_jump: bool,
}
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
/// sind rohe Maus-Pixel, `fwd`/`right`/`up` Tastenachsen in [-1, 1]; `up`
/// wirkt nur im Noclip.
#[derive(Default)]
pub struct FrameInput {
pub fwd: f32,
pub right: f32,
pub up: f32,
pub jump: bool,
pub look_dx: f32,
pub look_dy: f32,
}
/// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend
/// beenden soll (`quit`/`exit`).
pub struct ExecResult {
pub output: Vec<String>,
pub quit: bool,
}
impl ExecResult {
fn lines(output: Vec<String>) -> Self { Self { output, quit: false } }
fn empty() -> Self { Self { output: Vec::new(), quit: false } }
}
impl Session {
pub fn new(signals_path: String) -> Self {
let game = Game::new(signals::load_signals(&signals_path));
Self {
game,
mode: Mode::Menu,
// Default-Spawn, bis ein Modell einen `role=spawn`-Empty
// mitbringt (die CLI braucht keine Welt — sie tickt nie).
player: Player::new([0.0, 1.0, 0.0]),
collision: CollisionWorld::empty(),
triggers: Triggers::new(),
emitters: Emitters::new(),
pending_sounds: Vec::new(),
pending_post: Vec::new(),
signals_path,
sim_accum: 0.0,
pending_jump: false,
}
}
/// Sim-Seite eines Blender-Modells in die Welt einfügen (die Sichtgeometrie
/// ist Sache von render::props): Collider aus `collide`, Trigger aus
/// `signal`, Ambient-Emitter aus `sound` und der Spawn aus `role = spawn`.
/// Bringen mehrere Modelle einen Spawn mit, gewinnt der zuletzt geladene.
pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model);
self.triggers.add_model(model);
self.emitters.add_model(model);
for e in &model.empties {
if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) {
self.player = Player::new(e.pos);
}
}
}
/// Die Ambient-Emitter in fester Reihenfolge — der Audio-Treiber legt
/// beim Start je Emitter eine Loop-Voice an.
pub fn emitters(&self) -> &[crate::engine::audio::Emitter] {
self.emitters.list()
}
/// Aktuelle Emitter-Gains (parallel zu [`Session::emitters`]) für die
/// Ohrposition des Spielers — jeden Frame vom Audio-Treiber gezogen.
pub fn emitter_gains(&self) -> Vec<f32> {
self.emitters.gains(self.player.eye())
}
/// Aufgelaufene Einmal-SFX (`play_sound`-Actions) abholen — jeden Frame
/// vom Fenster-Frontend gerufen, das sie an den Audio-Treiber gibt.
pub fn take_sounds(&mut self) -> Vec<String> {
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:
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
/// zeigt sie wie `exec`-Ausgaben an.
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
match self.mode {
Mode::FirstPerson => {}
Mode::Free => {
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
self.player.fly(input.fwd, input.right, input.up, dt);
return Vec::new();
}
_ => return Vec::new(),
}
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
// Sub-Schritte und die Empfindlichkeit schwankte mit der Framerate.
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
// Sprung-Flanke puffern, bis ein Sub-Schritt sie verbraucht.
self.pending_jump |= input.jump;
// Physik in festen FIXED_DT-Schritten treiben; Rest-Zeit über Frames
// tragen. Der Deckel verhindert die Spirale bei einem einzelnen Hänger.
self.sim_accum = (self.sim_accum + dt).min(MAX_ACCUM);
while self.sim_accum >= FIXED_DT {
let jump = std::mem::take(&mut self.pending_jump);
self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
self.sim_accum -= FIXED_DT;
}
// Betretens-Zonen nach der Bewegung prüfen (Flanke: einmal je
// Eintritt). Erst alle Events einsammeln, dann feuern — `fire` kann
// den Modus wechseln (z.B. start_ink → Dialog), die Sim dieses Frames
// ist da schon abgeschlossen.
let center = [
self.player.pos[0],
self.player.pos[1] + player::HALF_EXTENTS[1],
self.player.pos[2],
];
let events = self.triggers.enter_events(center, player::HALF_EXTENTS);
let mut out = Vec::new();
for (name, signal) in events {
out.extend(self.fire(&signal, Some(name)));
}
out
}
/// Klick-Ziel unterm Fadenkreuz: nächstes `signal`-Klick-Volumen entlang
/// des Blickstrahls (`origin` + `dir·reach`), sofern keine andere
/// Weltgeometrie davor liegt. Liefert den Objektnamen für `use <name>`.
pub fn pick(&self, origin: [f32; 3], dir: [f32; 3], reach: f32) -> Option<&str> {
let end = [
origin[0] + dir[0] * reach,
origin[1] + dir[1] * reach,
origin[2] + dir[2] * reach,
];
let (name, frac) = self.triggers.pick(origin, end)?;
// Das Klick-Volumen ist selbst Teil der Kollisionswelt → bei freier
// Sicht treffen beide Traces dieselbe Fläche (Toleranz für FP/SKIN).
let world = self.collision.trace(origin, end, [0.0; 3]).map_or(1.0, |h| h.frac);
(frac <= world + 1e-3).then_some(name)
}
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
/// Eigene Methode statt im Konstruktor, damit das Frontend die Ausgabe
/// darstellen kann — und damit `init` bereits einen Dialog öffnen darf.
pub fn start(&mut self) -> Vec<String> {
self.fire("init", None)
}
/// Eine Eingabezeile verarbeiten. Im Dialog treibt sie den Dialog,
/// sonst ist sie ein Engine-Befehl. Dieselbe Eingabequelle, je nach
/// `mode` unterschiedlich gedeutet — so steuert Terminal *und* (später)
/// Klick denselben Dialog ohne Sonderpfad.
pub fn exec(&mut self, line: &str) -> ExecResult {
match self.mode {
Mode::Dialog(_) => self.dialog_input(line),
Mode::FirstPerson | Mode::Free | Mode::Menu => self.command(line),
}
}
fn command(&mut self, line: &str) -> ExecResult {
let line = line.trim();
let (verb, args) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
match (verb, args.trim()) {
("", _) => ExecResult::empty(),
("quit" | "exit", _) => ExecResult { output: Vec::new(), quit: true },
("menu", _) => {
// Menü öffnen bzw. ins Spiel zurück; „Fortsetzen" landet immer
// im Spielmodus, nie im Noclip. (Dialog wird anders geroutet.)
let t = match self.mode {
Mode::Menu => ModeTarget::Play,
_ => ModeTarget::Menu,
};
self.set_mode(t);
ExecResult::empty()
}
("help", _) => ExecResult::lines(help()),
("kv", _) => ExecResult::lines(self.dump_kv()),
("reload", _) => {
self.game.signals = signals::load_signals(&self.signals_path);
ExecResult::lines(vec![
format!("{} Signale geladen.", self.game.signals.len()),
])
}
// Modus über dieselbe Action wie das `mode`-Verb aus
// signals.toml/Ink-Tags — ein Wechsel-Pfad für alle Quellen.
("mode", m) => match ModeTarget::parse(m) {
Some(t) => {
self.game.actions.push(Action::SetMode(t));
ExecResult::lines(self.drain_actions())
}
None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
},
// 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.
// `$self` ist immer der volle Name.
("use", name) => {
let key = self.triggers.signal_for(name)
.unwrap_or_else(|| signals::signal_key(name))
.to_string();
ExecResult::lines(self.fire(&key, Some(name.to_string())))
}
_ => ExecResult::lines(vec![
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
]),
}
}
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() {
None // reiner Text → Leerzeile/Enter blättert weiter
} else {
match line.trim().parse::<usize>() {
Ok(n) if (1..=d.choices.len()).contains(&n) => Some(n - 1),
_ => return ExecResult::lines(vec![format!(" (1..{})", d.choices.len())]),
}
};
let mut out = self.step(sel);
// Ein start_ink-Tag kann während step() eine neue Story in den Slot
// geladen haben, während die alte auf End lief (mode → Play). Falls
// das passiert ist, sofort den ersten Schritt der neuen Story ziehen
// statt den Spieler im Play-Modus hängen zu lassen.
if self.game.story.is_some() && matches!(self.mode, Mode::FirstPerson) {
out.extend(self.step(None));
}
ExecResult::lines(out)
}
/// Signal dispatchen, deferred Actions einsammeln und — falls dadurch
/// eine Story startete — den ersten Dialogschritt ziehen.
fn fire(&mut self, signal: &str, instance: Option<String>) -> Vec<String> {
{
let mut ctx = self.game.action_ctx(instance);
signals::dispatch(signal, &mut ctx);
}
let mut out = self.drain_actions();
if self.game.story.is_some() {
out.extend(self.step(None));
}
out
}
/// Einen Story-Schritt treiben und `mode` daran anpassen. `sel` ist die
/// Choice-Auswahl bzw. `None` für ersten Schritt / Text-Weiterblättern.
/// Tags gehen wie gehabt zurück in den Dispatcher (siehe story_ctrl).
fn step(&mut self, sel: Option<usize>) -> Vec<String> {
let state = {
let mut ctx = self.game.action_ctx(None);
let (state, tags) = story_ctrl::advance(sel, &mut ctx);
for t in &tags { signals::dispatch(t, &mut ctx); }
state
};
let mut out = self.drain_actions();
match state {
None | Some(StoryState::End) => {
self.mode = Mode::FirstPerson;
}
Some(StoryState::Text(text)) => {
out.push(text.clone());
self.mode = Mode::Dialog(Dialog { text, choices: Vec::new() });
}
Some(StoryState::Choice { prompt, options }) => {
if !prompt.is_empty() { out.push(prompt.clone()); }
for (i, o) in options.iter().enumerate() {
out.push(format!(" {}) {o}", i + 1));
}
self.mode = Mode::Dialog(Dialog { text: prompt, choices: options });
}
}
out
}
fn drain_actions(&mut self) -> Vec<String> {
// Erst aus der Queue ziehen (gibt den `&mut self.game`-Borrow frei),
// dann anwenden — `SetMode` braucht `&mut self.mode`.
let actions: Vec<Action> = self.game.actions.drain(..).collect();
let mut out = Vec::with_capacity(actions.len());
for a in actions {
match a {
Action::PlaySound(n) => {
out.push(format!("[action] play_sound {n}"));
self.pending_sounds.push(n);
}
Action::SetMode(t) => {
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
}
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu` und die
/// `SetMode`-Action laufen beide hier durch. (Der Dialog-Modus entsteht
/// ausschließlich in `step` aus dem Story-Ablauf.)
fn set_mode(&mut self, t: ModeTarget) {
self.mode = match t {
ModeTarget::Play => Mode::FirstPerson,
ModeTarget::Free => Mode::Free,
ModeTarget::Menu => Mode::Menu,
};
}
fn dump_kv(&self) -> Vec<String> {
let mut keys: Vec<&String> = self.game.kv.keys().collect();
keys.sort();
keys.into_iter()
.map(|k| format!(" {k} = {}", self.game.kv[k]))
.collect()
}
}
fn help() -> Vec<String> {
vec![
" signal <s> Signal feuern oder Action direkt ausführen".into(),
" (z.B. `signal Cube`, `signal set has_key true`)".into(),
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
" 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-Effekt schalten (Fenster-Frontend)".into(),
" reload signals.toml neu laden".into(),
" quit beenden".into(),
]
}
#[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
// im Menü (Titelbildschirm); Tests des Befehlspfads setzen vorab `Free`.
fn empty_session() -> Session { Session::new("/nonexistent/signals.toml".into()) }
#[test]
fn starts_in_menu() {
assert!(matches!(empty_session().mode, Mode::Menu));
}
#[test]
fn quit_sets_flag() {
let mut s = empty_session();
assert!(s.exec("quit").quit);
}
#[test]
fn builtin_command_mutates_kv_and_stays_free() {
let mut s = empty_session();
s.mode = Mode::Free;
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
let r = s.exec("signal set has_key true");
assert!(!r.quit);
assert!(matches!(s.game.kv["has_key"], Value::Bool(true)));
assert!(matches!(s.mode, Mode::Free));
}
#[test]
fn menu_command_opens_and_resumes_to_play() {
let mut s = empty_session();
s.mode = Mode::Free;
s.exec("menu");
assert!(matches!(s.mode, Mode::Menu));
s.exec("menu"); // „Fortsetzen" → ins Spiel
assert!(matches!(s.mode, Mode::FirstPerson));
s.exec("menu"); // aus dem Spiel wieder ins Menü
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!(matches!(s.game.kv["in_menu"], Value::Bool(true)));
}
#[test]
fn mode_command_sets_mode_directly() {
let mut s = empty_session();
s.exec("mode play");
assert!(matches!(s.mode, Mode::FirstPerson));
s.exec("mode free");
assert!(matches!(s.mode, Mode::Free));
s.exec("mode menu");
assert!(matches!(s.mode, Mode::Menu));
// Müll-Argument lässt den Modus stehen und meldet die Nutzung.
let r = s.exec("mode dialog");
assert!(matches!(s.mode, Mode::Menu));
assert_eq!(r.output.len(), 1);
}
#[test]
fn signal_can_switch_mode() {
let mut s = empty_session();
s.mode = Mode::Free;
// `mode`-Verb direkt als Action (Ink-Tag-/signals.toml-Pfad).
s.exec("signal mode play");
assert!(matches!(s.mode, Mode::FirstPerson));
}
#[test]
fn fixed_timestep_is_framerate_independent() {
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
// Frames → dieselbe Zahl fester Sim-Schritte, also (bis auf FP-Rauschen)
// dasselbe Ergebnis. Die leere Kollisionswelt lässt den Spieler frei
// fallen — genügt, um die Integration zu vergleichen.
let mut coarse = empty_session();
coarse.mode = Mode::FirstPerson;
let mut fine = empty_session();
fine.mode = Mode::FirstPerson;
let input = FrameInput::default();
coarse.tick(&input, 0.1);
for _ in 0..60 { fine.tick(&input, 0.1 / 60.0); }
assert!((coarse.player.pos[1] - fine.player.pos[1]).abs() < 1e-3,
"framerate-unabhängig: {} vs {}", coarse.player.pos[1], fine.player.pos[1]);
assert!((coarse.player.vel[1] - fine.player.vel[1]).abs() < 1e-3,
"gleiche Fallgeschwindigkeit: {} vs {}", coarse.player.vel[1], fine.player.vel[1]);
assert!(coarse.player.pos[1] < 1.0, "sollte gefallen sein: {}", coarse.player.pos[1]);
}
#[test]
fn jump_press_survives_a_substepless_frame() {
// Sprungtaste in einem Frame kürzer als FIXED_DT: es läuft noch kein
// Sim-Schritt, die Flanke darf aber nicht verfallen, sondern muss beim
// nächsten Schritt zünden.
let mut s = empty_session();
s.mode = Mode::FirstPerson;
s.player.grounded = true;
let jump = FrameInput { jump: true, ..Default::default() };
s.tick(&jump, FIXED_DT * 0.5);
assert!(s.player.vel[1] <= 0.0, "noch kein Schritt → noch kein Absprung");
s.tick(&FrameInput::default(), FIXED_DT * 0.6);
assert!(s.player.vel[1] > 0.0,
"gepufferter Sprung sollte abheben: {}", s.player.vel[1]);
}
#[test]
fn menu_pauses_the_sim() {
let mut s = empty_session();
s.mode = Mode::Menu;
let before = s.player.pos;
s.tick(&FrameInput::default(), 0.1);
assert_eq!(s.player.pos, before);
}
#[test]
fn noclip_flies_the_player_without_physics() {
// Free bewegt denselben Spieler, nur ohne Welt: `up` steigt, und beim
// Zurückschalten steht er dort, wo er hingeflogen ist.
let mut s = empty_session();
s.mode = Mode::Free;
s.tick(&FrameInput { up: 1.0, ..Default::default() }, 1.0);
assert!(s.player.pos[1] > 1.5, "sollte gestiegen sein: {}", s.player.pos[1]);
assert!(!s.player.grounded);
let flown_to = s.player.pos;
s.exec("mode play");
assert_eq!(s.player.pos, flown_to, "Noclip-Ende teleportiert nicht zurück");
}
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
/// deren Signale als Builtin-Fall-Through direkt KV-Flags setzen.
fn trigger_model() -> crate::engine::model::Model {
crate::engine::model::Model {
objects: vec![
crate::engine::model::test_cube(
"Zone.001", [-0.5, 0.0, -3.0], None, Some("set zone_hit true")),
crate::engine::model::test_cube(
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
],
empties: Vec::new(),
textures: Vec::new(),
}
}
#[test]
fn walking_into_zone_fires_signal_once() {
let mut s = empty_session();
s.load_props(&trigger_model());
s.mode = Mode::FirstPerson;
s.player.grounded = true;
// 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!(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");
for _ in 0..5 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
assert!(!s.game.kv.contains_key("zone_hit"), "Flanke: nur einmal je Eintritt");
}
#[test]
fn use_resolves_signal_property_before_name() {
let mut s = empty_session();
s.load_props(&trigger_model());
s.mode = Mode::FirstPerson;
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
s.exec("use Kiste");
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");
assert!(!r.quit);
}
#[test]
fn pick_hits_clickable_within_reach() {
let mut s = empty_session();
s.load_props(&trigger_model());
// Kiste bei [5..6, 0..1, 5..6]: Strahl von schräg oben davor.
let hit = s.pick([5.5, 0.5, 3.0], [0.0, 0.0, 1.0], INTERACT_REACH);
assert_eq!(hit, Some("Kiste"));
// Außer Reichweite → nichts.
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
}
#[test]
fn play_sound_action_queues_for_frontend() {
let mut s = empty_session();
// Fall-through-Builtin über den Signal-Trichter, wie ein Ink-Tag.
s.exec("signal play_sound klick");
assert_eq!(s.take_sounds(), vec!["klick".to_string()]);
assert!(s.take_sounds().is_empty(), "take leert die Queue");
}
#[test]
fn spawn_empty_places_player() {
let mut s = empty_session();
let mut props = crate::engine::model::Props::new();
props.insert("role".into(), "spawn".into());
s.load_props(&crate::engine::model::Model {
objects: Vec::new(),
empties: vec![
crate::engine::model::Empty { name: "start".into(), pos: [3.0, 0.5, -2.0], props },
crate::engine::model::Empty {
name: "deko".into(), pos: [9.0, 9.0, 9.0],
props: crate::engine::model::Props::new(),
},
],
textures: Vec::new(),
});
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
}
#[test]
fn post_command_queues_setpost_for_the_frontend() {
let mut s = empty_session();
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");
// 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]
fn unknown_command_reports_and_stays_free() {
let mut s = empty_session();
s.mode = Mode::Free;
let r = s.exec("frobnicate");
assert_eq!(r.output.len(), 1);
assert!(matches!(s.mode, Mode::Free));
}
}