Blender-Import-Pipeline: glTF/OBJ, col_*-Proxies, Trigger

Blender wird das Haupt-3D-Tool: zwei Loader (engine/gltf.rs für .glb als
Hauptpfad, engine/obj.rs aus irl3d eingebettet) liefern ein neutrales
engine/model.rs::Model. Loader sind reine Dekoder; das Frontend zieht sie
und schiebt das Ergebnis per Session::load_props in den State, der
Renderer baut sein Mesh (render/props.rs, verschmolzen mit der
Brush-Welt über eine geteilte Texturliste).

glTF trägt Blender-Custom-Properties (node.extras → Object::props) und
Empties (Nodes ohne Mesh) als Entity-Kanal. Namenskonvention: `col_*` =
unsichtbarer Collision-Proxy, aus dem collision::add_model konvexe
Ebenen-Sets baut (konvex-only, daher kein Default-Collider auf
Sichtgeometrie).

Trigger (engine/trigger.rs) hängen an der Custom Property `signal`:
mit Collider = Point-and-Click-Ziel (Session::pick raycastet aus der
Bildmitte, Treffer läuft durch den `use <name>`-Trichter), ohne Collider
= unsichtbare Betretens-Zone, die einmal je Eintritt feuert. Gefeuert
wird nur über Session::exec — der Eingabe-Trichter bleibt der einzige
Weg in den State.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:20:00 +02:00
parent 67c4a366c7
commit f7bfdb4a0b
14 changed files with 1465 additions and 43 deletions
+123 -11
View File
@@ -2,8 +2,9 @@ use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::map::{self, Map};
use crate::engine::player::Player;
use crate::engine::{kv, signals, story_ctrl};
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
@@ -20,6 +21,9 @@ const FIXED_DT: f32 = 1.0 / 60.0;
/// („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,
@@ -28,9 +32,6 @@ pub enum Mode {
}
pub struct Dialog {
// Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der
// Dialogtext über die Konsolen-Ausgabe (siehe `step`).
#[allow(dead_code)]
pub text: String,
pub choices: Vec<String>,
}
@@ -40,6 +41,9 @@ pub struct Session {
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,
signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32,
@@ -83,6 +87,7 @@ impl Session {
// braucht keine — sie tickt nie).
player: Player::new([0.0, 1.0, 0.0]),
collision: CollisionWorld::empty(),
triggers: Triggers::new(),
signals_path,
sim_accum: 0.0,
pending_jump: false,
@@ -94,14 +99,27 @@ impl Session {
/// Start gerufen.
pub fn load_world(&mut self, map: &Map) {
self.collision = CollisionWorld::build(map);
self.triggers = Triggers::new();
self.player = Player::new(player_spawn(map));
}
/// Sim-Seite eines Blender-Modells in die bestehende Welt einfügen —
/// nach `load_world` rufen, einmal pro Modell: Collision-Proxies
/// (`col_*`) und Trigger (`signal`-Property → Klick-Ziele/Zonen). 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);
}
/// 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).
pub fn tick(&mut self, input: &FrameInput, dt: f32) {
if !matches!(self.mode, Mode::FirstPerson) { return; }
/// 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
@@ -119,6 +137,38 @@ impl Session {
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).
@@ -190,10 +240,14 @@ impl Session {
} 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 simulieren: Signal ist der gestrippte
// Name, $self der volle — wie der LMB-Klick-Pfad in irl3d.
// 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 = signals::signal_key(name).to_string();
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![
@@ -466,6 +520,64 @@ mod tests {
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], Some("set zone_hit true")),
crate::engine::model::test_cube(
"col_Kiste", [5.0, 0.0, 5.0], 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("col_Kiste").
s.exec("use col_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());
// col_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("col_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 unknown_command_reports_and_stays_free() {
let mut s = empty_session();