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:
+123
-11
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user