01f299aa4f
UVs laufen jetzt durchgehend in glTF-/tga-Konvention (der Flip-Hin-und- Zurück stammte vom gelöschten OBJ-Pfad). Kaputte Welt-Texturen fallen auf den Platzhalter statt zu panicken. Player::eye() statt vierfacher Augenpunkt-Rechnung. hide_object war ein irl3d-Demo-Relikt ohne Konsument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
632 lines
26 KiB
Rust
632 lines
26 KiB
Rust
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::{kv, model, signals, story_ctrl};
|
||
|
||
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
|
||
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
|
||
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
|
||
pub const LOOK_SENS: f32 = 0.0025;
|
||
|
||
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
|
||
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
|
||
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
|
||
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
|
||
const FIXED_DT: f32 = 1.0 / 60.0;
|
||
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
|
||
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
|
||
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-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`] und spielt sie; die CLI lässt
|
||
/// sie liegen (ihr reicht die `[action]`-Textzeile).
|
||
pending_sounds: Vec<String>,
|
||
signals_path: String,
|
||
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
||
sim_accum: f32,
|
||
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
|
||
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
|
||
/// nächsten Sub-Schritt konsumiert.
|
||
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` Tastenachsen in [-1, 1].
|
||
#[derive(Default)]
|
||
pub struct FrameInput {
|
||
pub fwd: f32,
|
||
pub right: 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(),
|
||
signals_path,
|
||
sim_accum: 0.0,
|
||
pending_jump: false,
|
||
}
|
||
}
|
||
|
||
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
|
||
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
|
||
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
|
||
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
|
||
/// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
|
||
/// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
|
||
/// desselben Modells ist Sache des Renderers (render::props).
|
||
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)
|
||
}
|
||
|
||
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
||
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
|
||
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). 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> {
|
||
if !matches!(self.mode, Mode::FirstPerson) { 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();
|
||
match line {
|
||
"" => ExecResult::empty(),
|
||
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
||
"menu" => {
|
||
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
|
||
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
|
||
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
|
||
let t = match self.mode {
|
||
Mode::Menu => ModeTarget::Play,
|
||
_ => ModeTarget::Menu,
|
||
};
|
||
self.set_mode(t);
|
||
ExecResult::empty()
|
||
}
|
||
"noclip" => {
|
||
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
|
||
// umschalten.
|
||
let t = match self.mode {
|
||
Mode::FirstPerson => ModeTarget::Free,
|
||
_ => ModeTarget::Play,
|
||
};
|
||
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()),
|
||
])
|
||
}
|
||
_ => {
|
||
if let Some(m) = line.strip_prefix("mode ") {
|
||
// Modus explizit setzen — über dieselbe Action wie das
|
||
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
|
||
// Signals genau einen Wechsel-Pfad teilen.
|
||
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(),
|
||
]),
|
||
}
|
||
} else if let Some(sig) = line.strip_prefix("signal ") {
|
||
ExecResult::lines(self.fire(sig.trim(), None))
|
||
} else if let Some(name) = line.strip_prefix("use ") {
|
||
// Objekt-Interaktion (LMB-Klick und Konsole laufen beide
|
||
// hier durch): Klick-Ziele mit `signal`-Property feuern
|
||
// dieses Signal; sonst den vom Blender-Suffix befreiten
|
||
// Objektnamen. $self ist immer der volle Name.
|
||
let name = name.trim();
|
||
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())))
|
||
} else {
|
||
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()));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
|
||
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode`
|
||
/// und die `SetMode`-Action laufen alle 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} = {}", kv::format_value(&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(),
|
||
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
|
||
" mode <m> Modus direkt setzen: play | free | menu".into(),
|
||
" reload signals.toml neu laden".into(),
|
||
" quit beenden".into(),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// 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!(s.game.kv["has_key"].coerce_to_bool().unwrap());
|
||
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!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
|
||
}
|
||
|
||
#[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 noclip_toggles_play_and_free() {
|
||
let mut s = empty_session();
|
||
s.mode = Mode::FirstPerson;
|
||
s.exec("noclip");
|
||
assert!(matches!(s.mode, Mode::Free));
|
||
s.exec("noclip");
|
||
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 tick_only_runs_in_first_person() {
|
||
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
|
||
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, "Menü pausiert die Sim");
|
||
}
|
||
|
||
/// 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(),
|
||
materials: vec![String::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!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "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!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
|
||
// 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(),
|
||
},
|
||
],
|
||
materials: Vec::new(),
|
||
});
|
||
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
}
|