diff --git a/Cargo.lock b/Cargo.lock index 7afbeb7..b0e5e9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2147,6 +2147,7 @@ dependencies = [ "bladeink", "bytemuck", "pollster", + "serde_json", "wgpu", "winit", ] diff --git a/Cargo.toml b/Cargo.toml index 736602e..ccd276b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,5 +7,6 @@ edition = "2024" bladeink = "1.2.5" bytemuck = { version = "1.25.0", features = ["derive"] } pollster = "0.4.0" +serde_json = "1" wgpu = "29.0.3" winit = "0.30.13" diff --git a/src/engine/collision.rs b/src/engine/collision.rs index 7b38198..87a85c8 100644 --- a/src/engine/collision.rs +++ b/src/engine/collision.rs @@ -13,11 +13,21 @@ //! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht //! sofort wieder im Kontakt steckt. //! -//! Headless wie der Rest von `engine`: hängt nur an `map` (für die Brush-Ebenen -//! und die geteilte Koordinaten-Umrechnung). Der Player ruft `trace` in -//! `player::step`; gebaut wird die Welt einmal vom Renderer aus der `Map`. +//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`]) +//! und konvexe `col_*`-Proxies aus Blender-OBJ-Exporten ([`add_model`]). +//! Beide enden als dasselbe Ebenen-Set — die Trace kennt keine Herkunft. +//! Proxy-Ebenen werden aus den Dreiecks-Flächen abgeleitet (koplanare +//! dedupliziert) und um die sechs achsparallelen AABB-Ebenen ergänzt +//! (Quake-„Bevel planes"): die `|n|·half`-Aufblasung ist nur eine +//! konservative Näherung der Minkowski-Summe; die Axial-Ebenen ziehen sie +//! an Kanten schräger Proxies wieder stramm. +//! +//! Headless wie der Rest von `engine`: hängt nur an `map`/`obj` (Quellen +//! der Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die +//! Welt einmal vom Frontend aus Map und Modellen. use crate::engine::map::{self, Map}; +use crate::engine::model::Model; /// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält — /// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar. @@ -66,12 +76,24 @@ impl CollisionWorld { Self { brushes: Vec::new() } } + /// Collision-Proxies eines Blender-Modells hinzufügen: jedes Objekt mit + /// `collider = true` wird als konvexer Brush übernommen (siehe + /// [`object_planes`]). + pub fn add_model(&mut self, model: &Model) { + for o in &model.objects { + if !o.collider { continue; } + if let Some(planes) = object_planes(o) { + self.brushes.push(ConvexBrush { planes }); + } + } + } + /// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte) /// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`. pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option { let mut nearest: Option = None; for b in &self.brushes { - if let Some(h) = trace_brush(b, start, end, half) + if let Some(h) = trace_planes(&b.planes, start, end, half) && nearest.as_ref().is_none_or(|n| h.frac < n.frac) { nearest = Some(h); } @@ -80,17 +102,67 @@ impl CollisionWorld { } } -/// Segment `start→end` gegen einen aufgeblasenen konvexen Brush clippen. -/// `None`, wenn das Segment den Brush verfehlt oder der Start schon drin steckt -/// (dann nicht blocken — sonst bliebe der Player hängen). -fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option { +/// Das **konvexe** Ebenen-Set eines Modell-Objekts: Ebenen aus den Dreiecks- +/// Flächen (koplanare dedupliziert) plus die sechs achsparallelen AABB-Ebenen +/// (Bevel-Ebenen, siehe Modul-Doc). `None`, wenn kein geschlossenes Volumen +/// entsteht. Konkave Objekte sind Autorinnen-Fehler und ergeben zu große +/// Volumen — die Konvention heißt deshalb `col_*`-*Boxen/Prismen*. +pub(crate) fn object_planes(o: &crate::engine::model::Object) -> Option> { + let mut planes: Vec = Vec::new(); + let (mut bb_min, mut bb_max) = ([f32::MAX; 3], [f32::MIN; 3]); + for v in &o.verts { + for k in 0..3 { + bb_min[k] = bb_min[k].min(v[k]); + bb_max[k] = bb_max[k].max(v[k]); + } + } + for t in &o.tris { + let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]); + // OBJ-/glTF-Winding ist CCW von außen → cross(b−a, c−a) zeigt nach + // außen. Degenerierte Dreiecke (Länge ~0) überspringen. + let n = cross(sub(b, a), sub(c, a)); + if dot(n, n) < 1e-12 { continue; } + let n = normalize(n); + let d = dot(n, a); + if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - d).abs() < 1e-4) { + planes.push(Plane { n, d }); + } + } + // Axiale Bevel-Ebenen (dedupe fängt achsparallele Duplikate). + for k in 0..3 { + let mut n = [0.0f32; 3]; + n[k] = 1.0; + if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - bb_max[k]).abs() < 1e-4) { + planes.push(Plane { n, d: bb_max[k] }); + } + n[k] = -1.0; + if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d + bb_min[k]).abs() < 1e-4) { + planes.push(Plane { n, d: -bb_min[k] }); + } + } + (planes.len() >= 4).then_some(planes) // sonst kein geschlossenes Volumen +} + +/// Überlappt eine AABB (Mittelpunkt/Halbmaße) das konvexe Volumen? Gleiche +/// Minkowski-Aufblasung wie die Trace — „berühren" zählt als drin. +pub(crate) fn box_touches(planes: &[Plane], center: [f32; 3], half: [f32; 3]) -> bool { + planes.iter().all(|p| { + let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2]; + dot(p.n, center) <= d + }) +} + +/// Segment `start→end` gegen ein aufgeblasenes konvexes Ebenen-Set clippen. +/// `None`, wenn das Segment das Volumen verfehlt oder der Start schon drin +/// steckt (dann nicht blocken — sonst bliebe der Player hängen). +pub(crate) fn trace_planes(planes: &[Plane], start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option { let mut enter = f32::NEG_INFINITY; // größter Eintritts-Bruch let mut leave = 1.0f32; // kleinster Austritts-Bruch let mut normal = [0.0f32; 3]; let mut entered = false; // überhaupt eine Eintrittsebene gefunden? let mut started_outside = false; - for p in &b.planes { + for p in planes { // Ebene um die Box-Halbgröße nach außen aufblasen (Minkowski). let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2]; let ds = dot(p.n, start) - d; @@ -206,6 +278,49 @@ mod tests { assert!((h.frac - 0.375).abs() < 0.02, "frac={}", h.frac); } + // Einheitswürfel [0,1]³ als col_-Proxy, Quads CCW von außen (wie ein + // Blender-Export), plus ein vis_-Objekt, das keinen Collider ergeben darf. + const PROXY_OBJ: &str = "\ +o col_Box +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0 0 1 +v 1 0 1 +v 1 1 1 +v 0 1 1 +f 5 6 7 8 +f 1 4 3 2 +f 2 3 7 6 +f 1 5 8 4 +f 4 8 7 3 +f 1 2 6 5 +o vis_Deko +v 5 5 5 +v 6 5 5 +v 6 6 5 +f 9 10 11 +"; + + #[test] + fn obj_proxy_becomes_convex_brush() { + use crate::engine::obj; + let model = obj::parse(PROXY_OBJ); + let mut w = CollisionWorld::empty(); + w.add_model(&model); + + // Nur col_Box (vis_ trägt nicht bei); 12 Dreiecks-Ebenen dedupen zu 6, + // die axialen Bevel-Ebenen sind mit ihnen identisch (achsparallel). + assert_eq!(w.brushes.len(), 1); + assert_eq!(w.brushes[0].planes.len(), 6); + + // Strahl von −X hindurch: Kontakt an x≈0 mit −X-Normale. + let h = w.trace([-1.0, 0.5, 0.5], [3.0, 0.5, 0.5], PT).unwrap(); + assert!((h.frac - 0.25).abs() < 0.02, "frac={}", h.frac); + assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal); + } + #[test] fn builds_brushes_from_map() { // Ein achsenparalleler Quader-Brush (Quake-Koords). diff --git a/src/engine/gltf.rs b/src/engine/gltf.rs new file mode 100644 index 0000000..0fd7222 --- /dev/null +++ b/src/engine/gltf.rs @@ -0,0 +1,425 @@ +//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`]. +//! +//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann — +//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export +//! „Include → Custom Properties" anhaken → landen in `node.extras`) und +//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/… +//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die +//! Konsumenten. +//! +//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen +//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren), +//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0` +//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix +//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken — +//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe +//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert. +//! +//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System, +//! Blenders Exporter konvertiert selbst. Maßstab 1:1. +//! +//! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum, +//! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders +//! als beim trivialen signals-TOML-Subset). + +use serde_json::Value; + +use crate::engine::model::{apply_signal_rule, classify, Empty, Model, Object, Props}; + +pub fn load(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?; + parse_glb(&bytes).map_err(|e| format!("gltf {path}: {e}")) +} + +/// GLB-Container: 12-Byte-Header (`glTF`, Version 2, Gesamtlänge), dann +/// Chunks aus Länge/Typ/Daten. Wir brauchen JSON (`JSON`) und BIN (`BIN\0`). +pub fn parse_glb(bytes: &[u8]) -> Result { + if bytes.len() < 12 || &bytes[0..4] != b"glTF" { + return Err("kein GLB (Magic fehlt) — in Blender als „glTF Binary (.glb)“ exportieren".into()); + } + let version = u32_at(bytes, 4)?; + if version != 2 { return Err(format!("glTF-Version {version}, unterstützt ist 2")); } + + let (mut json, mut bin): (Option<&[u8]>, &[u8]) = (None, &[]); + let mut off = 12; + while off + 8 <= bytes.len() { + let len = u32_at(bytes, off)? as usize; + let ty = &bytes[off + 4..off + 8]; + let data = bytes.get(off + 8..off + 8 + len).ok_or("Chunk länger als Datei")?; + match ty { + b"JSON" => json = Some(data), + b"BIN\0" => bin = data, + _ => {} // unbekannte Chunks per Spec ignorieren + } + off += 8 + len.next_multiple_of(4); // Chunks sind 4-Byte-aligned + } + let json = json.ok_or("kein JSON-Chunk")?; + let doc: Value = serde_json::from_slice(json).map_err(|e| format!("JSON: {e}"))?; + build(&doc, bin) +} + +fn build(doc: &Value, bin: &[u8]) -> Result { + let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() }; + + // Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die + // Welt-Transform wächst dabei Parent → Kind. + let scene = doc["scene"].as_u64().unwrap_or(0) as usize; + let roots = doc["scenes"][scene]["nodes"].as_array().cloned().unwrap_or_default(); + for r in roots { + let Some(i) = r.as_u64() else { continue; }; + walk_node(doc, bin, i as usize, IDENTITY, &mut model)?; + } + Ok(model) +} + +fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> { + let node = &doc["nodes"][idx]; + if node.is_null() { return Err(format!("Node {idx} fehlt")); } + let world = mul(parent, node_local(node)); + let name = node["name"].as_str().map_or_else(|| format!("node{idx}"), str::to_string); + let props = extras_props(&node["extras"]); + + match node["mesh"].as_u64() { + Some(mesh) => { + let (visible, collider) = classify(&name); + let mut o = Object { + name, visible, collider, props, + verts: Vec::new(), uvs: Vec::new(), + tris: Vec::new(), tri_mats: Vec::new(), + }; + append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?; + apply_signal_rule(&mut o); // Betretens-Zonen werden nie gerendert + if !o.tris.is_empty() { model.objects.push(o); } + } + // Node ohne Mesh = Empty (Entity-Marker); Position aus der + // Welt-Transform (Translationsspalte). + None => model.empties.push(Empty { + name, props, + pos: [world[12], world[13], world[14]], + }), + } + + for c in node["children"].as_array().into_iter().flatten() { + if let Some(ci) = c.as_u64() { + walk_node(doc, bin, ci as usize, world, model)?; + } + } + Ok(()) +} + +/// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein +/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen. +fn append_mesh( + doc: &Value, bin: &[u8], mesh: usize, world: M4, + o: &mut Object, materials: &mut Vec, +) -> Result<(), String> { + for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() { + if prim["mode"].as_u64().unwrap_or(4) != 4 { + eprintln!("[gltf] {}: Primitive-Mode ≠ Dreiecke — übersprungen", o.name); + continue; + } + let Some(pos_acc) = prim["attributes"]["POSITION"].as_u64() else { continue; }; + let positions = read_floats::<3>(doc, bin, pos_acc as usize)?; + let uvs = match prim["attributes"]["TEXCOORD_0"].as_u64() { + Some(a) => read_floats::<2>(doc, bin, a as usize)?, + None => vec![[0.0, 0.0]; positions.len()], + }; + + // Material-Name → geteilte Namensliste (wie usemtl); ohne Material "". + let mat_name = prim["material"].as_u64() + .and_then(|m| doc["materials"][m as usize]["name"].as_str()) + .unwrap_or(""); + let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| { + materials.push(mat_name.to_string()); + materials.len() - 1 + }); + + let base = o.verts.len(); + for (p, uv) in positions.iter().zip(&uvs) { + o.verts.push(transform(world, *p)); + o.uvs.push(*uv); + } + // glTF-UVs haben den Ursprung oben links — auf die Modell-Konvention + // (unten links, wie OBJ) spiegeln, damit der Render-Konsument beide + // Pfade gleich behandelt. + for uv in &mut o.uvs[base..] { uv[1] = 1.0 - uv[1]; } + + let idxs: Vec = match prim["indices"].as_u64() { + Some(a) => read_indices(doc, bin, a as usize)?, + None => (0..positions.len()).collect(), // non-indexed: sequenziell + }; + for t in idxs.chunks_exact(3) { + if t.iter().any(|&i| i >= positions.len()) { + return Err(format!("{}: Index außerhalb der Positionen", o.name)); + } + o.tris.push([base + t[0], base + t[1], base + t[2]]); + o.tri_mats.push(mi); + } + } + Ok(()) +} + +/// `node.extras` (Objekt) → Props; Skalare werden zu Strings vereinheitlicht, +/// verschachtelte Werte als kompaktes JSON durchgereicht. +fn extras_props(extras: &Value) -> Props { + let mut props = Props::new(); + if let Some(map) = extras.as_object() { + for (k, v) in map { + let s = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + props.insert(k.clone(), s); + } + } + props +} + +// --- Accessor-Dekodierung ---------------------------------------------------- + +/// Bytes eines Accessors samt Element-Stride auflösen (BufferView-Offset, +/// Accessor-Offset, optionaler byteStride — sonst dicht gepackt). +fn accessor_bytes<'a>(doc: &Value, bin: &'a [u8], acc: usize, elem_size: usize) + -> Result<(&'a [u8], usize, usize), String> +{ + let a = &doc["accessors"][acc]; + let count = a["count"].as_u64().ok_or("Accessor ohne count")? as usize; + let view = a["bufferView"].as_u64().ok_or("Accessor ohne bufferView")? as usize; + let v = &doc["bufferViews"][view]; + let v_off = v["byteOffset"].as_u64().unwrap_or(0) as usize; + let a_off = a["byteOffset"].as_u64().unwrap_or(0) as usize; + let stride = v["byteStride"].as_u64().map_or(elem_size, |s| s as usize); + let start = v_off + a_off; + let need = start + stride * count.saturating_sub(1) + elem_size; + if need > bin.len() { return Err("Accessor ragt aus dem BIN-Chunk".into()); } + Ok((&bin[start..], stride, count)) +} + +/// N-Komponenten-float-Accessor (POSITION: N=3, TEXCOORD: N=2) lesen. +fn read_floats(doc: &Value, bin: &[u8], acc: usize) -> Result, String> { + let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0); + if ctype != 5126 { + return Err(format!("Accessor {acc}: componentType {ctype}, erwartet float (5126)")); + } + let (bytes, stride, count) = accessor_bytes(doc, bin, acc, N * 4)?; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let mut e = [0.0f32; N]; + for (k, v) in e.iter_mut().enumerate() { + let o = i * stride + k * 4; + *v = f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()); + } + out.push(e); + } + Ok(out) +} + +/// Index-Accessor lesen (u8/u16/u32 → usize). +fn read_indices(doc: &Value, bin: &[u8], acc: usize) -> Result, String> { + let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0); + let size = match ctype { + 5121 => 1, // u8 + 5123 => 2, // u16 + 5125 => 4, // u32 + _ => return Err(format!("Index-Accessor {acc}: componentType {ctype}")), + }; + let (bytes, stride, count) = accessor_bytes(doc, bin, acc, size)?; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let o = i * stride; + out.push(match size { + 1 => bytes[o] as usize, + 2 => u16::from_le_bytes(bytes[o..o + 2].try_into().unwrap()) as usize, + _ => u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as usize, + }); + } + Ok(out) +} + +// --- Transform-Helfer (4×4 column-major, wie glTFs `matrix`) ----------------- + +type M4 = [f32; 16]; + +const IDENTITY: M4 = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, +]; + +/// Lokale Transform eines Nodes: explizite `matrix` oder T·R·S +/// (glTF-Reihenfolge; fehlende Anteile sind Identität). +fn node_local(node: &Value) -> M4 { + if let Some(m) = node["matrix"].as_array() { + let mut out = IDENTITY; + for (i, v) in m.iter().take(16).enumerate() { + out[i] = v.as_f64().unwrap_or(0.0) as f32; + } + return out; + } + let t = vecn::<3>(&node["translation"], [0.0, 0.0, 0.0]); + let q = vecn::<4>(&node["rotation"], [0.0, 0.0, 0.0, 1.0]); + let s = vecn::<3>(&node["scale"], [1.0, 1.0, 1.0]); + + // Rotationsmatrix aus dem Quaternion (x, y, z, w), Spalten skaliert, + // Translation in die vierte Spalte — direkt komponiertes T·R·S. + let (x, y, z, w) = (q[0], q[1], q[2], q[3]); + let r = [ + [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + z * w), 2.0 * (x * z - y * w)], + [2.0 * (x * y - z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + x * w)], + [2.0 * (x * z + y * w), 2.0 * (y * z - x * w), 1.0 - 2.0 * (x * x + y * y)], + ]; // r[spalte][zeile] + let mut out = IDENTITY; + for c in 0..3 { + for row in 0..3 { + out[c * 4 + row] = r[c][row] * s[c]; + } + } + out[12] = t[0]; out[13] = t[1]; out[14] = t[2]; + out +} + +fn vecn(v: &Value, default: [f32; N]) -> [f32; N] { + let Some(arr) = v.as_array() else { return default; }; + let mut out = default; + for (o, x) in out.iter_mut().zip(arr) { + if let Some(f) = x.as_f64() { *o = f as f32; } + } + out +} + +fn mul(a: M4, b: M4) -> M4 { + let mut out = [0.0; 16]; + for c in 0..4 { + for r in 0..4 { + out[c * 4 + r] = (0..4).map(|k| a[k * 4 + r] * b[c * 4 + k]).sum(); + } + } + out +} + +fn transform(m: M4, p: [f32; 3]) -> [f32; 3] { + let mut out = [0.0; 3]; + for (r, o) in out.iter_mut().enumerate() { + *o = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]; + } + out +} + +fn u32_at(bytes: &[u8], off: usize) -> Result { + bytes.get(off..off + 4) + .map(|b| u32::from_le_bytes(b.try_into().unwrap())) + .ok_or_else(|| "Datei zu kurz".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// GLB aus JSON-Text und BIN-Daten zusammensetzen (mit 4-Byte-Padding), + /// wie es ein Exporter täte. + fn glb(json: &str, bin: &[u8]) -> Vec { + let mut j = json.as_bytes().to_vec(); + while !j.len().is_multiple_of(4) { j.push(b' '); } + let mut b = bin.to_vec(); + while !b.len().is_multiple_of(4) { b.push(0); } + let total = 12 + 8 + j.len() + 8 + b.len(); + let mut out = Vec::new(); + out.extend(b"glTF"); + out.extend(2u32.to_le_bytes()); + out.extend((total as u32).to_le_bytes()); + out.extend((j.len() as u32).to_le_bytes()); + out.extend(b"JSON"); + out.extend(j); + out.extend((b.len() as u32).to_le_bytes()); + out.extend(b"BIN\0"); + out.extend(b); + out + } + + /// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein + /// Mesh-Node mit Translation und extras, ein Empty mit extras. + fn sample_glb() -> Vec { + let mut bin: Vec = Vec::new(); + for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); } + for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); } + for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); } + let json = r#"{ + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0, 2]}], + "nodes": [ + {"name": "Ding", "mesh": 0, "translation": [10, 0, 0], + "extras": {"signal": "tiffany", "hp": 3}, + "children": [1]}, + {"name": "col_Kind", "mesh": 0}, + {"name": "spawn", "translation": [1, 2, 3]} + ], + "meshes": [{"primitives": [{ + "attributes": {"POSITION": 0, "TEXCOORD_0": 1}, + "indices": 2, "material": 0 + }]}], + "materials": [{"name": "carpet"}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}, + {"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"} + ], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 24}, + {"buffer": 0, "byteOffset": 60, "byteLength": 6} + ], + "buffers": [{"byteLength": 66}] + }"#; + glb(json, &bin) + } + + #[test] + fn parses_geometry_with_baked_transforms() { + let m = parse_glb(&sample_glb()).unwrap(); + assert_eq!(m.objects.len(), 2); + + let d = &m.objects[0]; + assert_eq!(d.name, "Ding"); + assert_eq!(d.tris, vec![[0, 1, 2]]); + assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken + // signal-Property ohne Collider → Betretens-Zone, wird nie gerendert. + assert!(!d.visible && !d.collider); + assert_eq!(m.materials, vec!["carpet".to_string()]); + + // Kind erbt die Parent-Transform (10, 0, 0). + let k = &m.objects[1]; + assert_eq!(k.name, "col_Kind"); + assert!(!k.visible && k.collider); + assert_eq!(k.verts[0], [10.0, 0.0, 0.0]); + } + + #[test] + fn extras_become_props_and_empties_are_captured() { + let m = parse_glb(&sample_glb()).unwrap(); + let d = &m.objects[0]; + assert_eq!(d.props.get("signal").map(String::as_str), Some("tiffany")); + assert_eq!(d.props.get("hp").map(String::as_str), Some("3")); // Zahl → String + + assert_eq!(m.empties.len(), 1); + let e = &m.empties[0]; + assert_eq!(e.name, "spawn"); + assert_eq!(e.pos, [1.0, 2.0, 3.0]); + } + + #[test] + fn uv_origin_is_flipped_to_model_convention() { + // glTF-UV (1,1) (oben-links-Ursprung) → Modell-Konvention (1,0). + let m = parse_glb(&sample_glb()).unwrap(); + assert_eq!(m.objects[0].uvs[2], [1.0, 0.0]); + } + + #[test] + fn rejects_non_glb_and_wrong_version() { + assert!(parse_glb(b"PNG...whatever").is_err()); + let mut v1 = sample_glb(); + v1[4] = 1; // Version patchen + assert!(parse_glb(&v1).is_err()); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 36c08c2..38569d4 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -16,22 +16,29 @@ //! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst //! signals::dispatch zu rufen). //! -//! `map` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an -//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes Frontend -//! per Pull konsumiert. `map` ist zugleich der Anfang des headless -//! Datenmodells (Phase 2 des Renderer-Plans). +//! `map`, `obj`, `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, +//! hängen an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes +//! Frontend per Pull konsumiert. `map` ist zugleich der Anfang des headless +//! Datenmodells (Phase 2 des Renderer-Plans); `obj` und `gltf` sind der +//! Blender-Pfad (Sichtgeometrie + `col_*`-Collision-Proxies + Custom +//! Properties aus einem Export) und produzieren beide dasselbe neutrale +//! `model::Model`. //! //! `player` ist die First-Person-Physik, aus der der Renderer seine View -//! ableitet; `collision` (hängt an `map`) liefert ihr die Brush-Welt für den -//! Swept-AABB-Trace in `player::step`. Beide bleiben headless. +//! ableitet; `collision` (hängt an `map` und `model`) liefert ihr die Welt +//! für den Swept-AABB-Trace in `player::step`. Beide bleiben headless. pub mod assets; pub mod collision; pub mod game; +pub mod gltf; pub mod ink; pub mod kv; pub mod map; +pub mod model; +pub mod obj; pub mod player; pub mod signals; pub mod story_ctrl; pub mod tga; +pub mod trigger; diff --git a/src/engine/model.rs b/src/engine/model.rs new file mode 100644 index 0000000..c6f8afa --- /dev/null +++ b/src/engine/model.rs @@ -0,0 +1,128 @@ +//! Neutrales 3D-Modell — das gemeinsame Ergebnis der Blender-Loader +//! (engine::obj, engine::gltf). Konsumenten (render::props, collision) +//! kennen nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen +//! egal — so bleibt ein Format-Wechsel ein lokaler Loader-Tausch. +//! +//! Naming-Konvention (Objekt-Präfix, case-insensitiv): +//! `col_*` → unsichtbar, collidierbar (Collision-Proxy, konvex!) +//! sonst → nur sichtbar (`vis_*` sagt dasselbe explizit) +//! +//! Anders als irl3d („ohne Präfix = beides"): dessen Physik kollidierte per +//! Dreieck, unsere per konvexem Ebenen-Schnitt — konkave Sichtgeometrie als +//! Default-Collider ergäbe hier nur ein sinnloses Hüll-Volumen. Kollision +//! ist deshalb immer ein bewusst gebauter `col_*`-Proxy. +//! +//! Objektnamen sind zugleich die künftigen Interact-Keys: Blender-Suffixe +//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier — +//! die Loader bleiben frei von Signal-Wissen. + +use std::collections::HashMap; + +/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`), +/// Werte zu Strings vereinheitlicht. OBJ kann keine tragen → leer. +pub type Props = HashMap; + +pub struct Model { + pub objects: Vec, + /// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für + /// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten. + pub empties: Vec, + /// Distinkte Materialnamen in Auftrittsreihenfolge; `Object::tri_mats` + /// indiziert hierhin. `""` steht für „Faces ohne Material" — der + /// Konsument wählt dafür seinen Fallback (z.B. Platzhalter-Textur). + pub materials: Vec, +} + +pub struct Object { + pub name: String, + /// Positionen in Engine-Koords (Y-up, Blick −Z), Welt-Raum (Transforms + /// vom Loader eingebacken), Maßstab 1:1 (Blender-Meter = Engine-Unit). + pub verts: Vec<[f32; 3]>, + /// UVs parallel zu `verts`, V-Ursprung unten links (OBJ-/glTF-Rohform; + /// den Flip auf Bild-Konvention macht der Render-Konsument). + pub uvs: Vec<[f32; 2]>, + pub tris: Vec<[usize; 3]>, + /// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`). + pub tri_mats: Vec, + pub visible: bool, + pub collider: bool, + pub props: Props, +} + +pub struct Empty { + pub name: String, + /// Welt-Position (Engine-Koords). + pub pos: [f32; 3], + pub props: Props, +} + +/// Präfix-Klassifikation (case-insensitiv): `col_` = nur Collider, alles +/// andere nur sichtbar (siehe Modul-Doc, Abweichung von irl3d). +pub fn classify(name: &str) -> (bool, bool) { + let is_col = name.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("col_")); + (!is_col, is_col) +} + +/// Custom-Property-Schlüssel, der ein Objekt zum Trigger macht (Wert = +/// Signal-Name für `signals::dispatch`). Die Collider-Frage entscheidet die +/// Semantik — anfassbar = klickbar, durchlaufbar = Betretens-Zone: +/// `col_*` + `signal` → solide **und** klickbar (Point-and-Click) +/// sonst + `signal` → unsichtbare Trigger-Zone, feuert beim Betreten +/// (einmal je Eintritt; erneut erst nach Verlassen) +pub const SIGNAL_PROP: &str = "signal"; + +/// Die `signal`-Regel anwenden (nach `classify`, wenn `props` bekannt sind): +/// eine Betretens-Zone ist ein reines Trigger-Volumen und wird nie gerendert. +pub fn apply_signal_rule(o: &mut Object) { + if !o.collider && o.props.contains_key(SIGNAL_PROP) { + o.visible = false; + } +} + +/// Test-Helfer: Einheitswürfel-Objekt bei `min..min+1` (Quads CCW von außen, +/// Fan-trianguliert wie die Loader) mit optionaler `signal`-Property — +/// geteilt von trigger-/session-Tests. +#[cfg(test)] +pub(crate) fn test_cube(name: &str, min: [f32; 3], signal: Option<&str>) -> Object { + let v = |dx: f32, dy: f32, dz: f32| [min[0] + dx, min[1] + dy, min[2] + dz]; + let verts = vec![ + v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(1.0, 1.0, 0.0), v(0.0, 1.0, 0.0), + v(0.0, 0.0, 1.0), v(1.0, 0.0, 1.0), v(1.0, 1.0, 1.0), v(0.0, 1.0, 1.0), + ]; + let quads = [ + [4, 5, 6, 7], [0, 3, 2, 1], [1, 2, 6, 5], + [0, 4, 7, 3], [3, 7, 6, 2], [0, 1, 5, 4], + ]; + let mut tris = Vec::new(); + for q in quads { + tris.push([q[0], q[1], q[2]]); + tris.push([q[0], q[2], q[3]]); + } + let n = tris.len(); + let (visible, collider) = classify(name); + let mut props = Props::new(); + if let Some(s) = signal { props.insert(SIGNAL_PROP.into(), s.into()); } + let mut o = Object { + name: name.into(), + uvs: vec![[0.0, 0.0]; verts.len()], + verts, tris, + tri_mats: vec![0; n], + visible, collider, props, + }; + apply_signal_rule(&mut o); + o +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_is_prefix_and_case_insensitive() { + assert_eq!(classify("col_Wand"), (false, true)); + assert_eq!(classify("COL_Wand"), (false, true)); + assert_eq!(classify("vis_Deko"), (true, false)); + assert_eq!(classify("Suzanne"), (true, false)); + assert_eq!(classify(""), (true, false)); + } +} diff --git a/src/engine/obj.rs b/src/engine/obj.rs new file mode 100644 index 0000000..c9ef7a4 --- /dev/null +++ b/src/engine/obj.rs @@ -0,0 +1,242 @@ +//! Wavefront-OBJ-Loader (Blender-Export) → neutrales [`Model`]. +//! +//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/obj.rs), auf die +//! WDS-Loader-Regeln gebracht: reiner Decoder (Datei → CPU-Struct, kein +//! Textur-/Material-/GPU-Zugriff — Konsumenten laden selbst, wie bei +//! map/tga), f32 statt Fixed-Point, `Result` statt Panic (Blender-Exporte +//! sind Autorinnen-Content), und mit `vt`-UVs (irl3d ignorierte die). +//! +//! Subset: `v`, `vt`, `f`, `o`/`g`, `usemtl`. Alles andere ignoriert. +//! OBJ-Indizes sind 1-basiert und über die ganze Datei global; pro +//! `o`-Block wird lokal re-indexiert (Schlüssel ist das Paar Position/UV, +//! da dieselbe Position je Face verschiedene UVs tragen kann). N-gons +//! werden Fan-trianguliert. Kaputte Zeilen werden übersprungen (gemeldet), +//! nicht fatal. +//! +//! Achsen: Blenders OBJ-Exporter konvertiert per Default auf Forward=-Z, +//! Up=Y — das ist bereits unser Engine-System, kein Swap, Maßstab 1:1 +//! (Blender-Meter = Engine-Unit; `MAP_SCALE` gilt nur für Quake-Maps). +//! +//! Ergebnis ist das format-neutrale [`Model`] (siehe engine::model, dort +//! auch die `col_*`-Namenskonvention). OBJ kann keine Custom Properties und +//! keine Empties tragen — `props` bleiben leer, `empties` gibt es nur im +//! glTF-Pfad (engine::gltf). Faces ohne `vt` bekommen UV [0, 0]. + +use std::collections::HashMap; +use std::fs::read_to_string; + +use crate::engine::model::{classify, Model, Object, Props}; + +pub fn load(path: &str) -> Result { + let src = read_to_string(path).map_err(|e| format!("obj load {path}: {e}"))?; + Ok(parse(&src)) +} + +pub fn parse(src: &str) -> Model { + // Globale Pools (Datei-weite 1-basierte Indizes). + let mut positions: Vec<[f32; 3]> = Vec::new(); + let mut texcoords: Vec<[f32; 2]> = Vec::new(); + + let mut materials: Vec = Vec::new(); + let mut mat_by_name: HashMap = HashMap::new(); + let mut objects: Vec = Vec::new(); + + // Aktueller o-Block. `cur_mat` persistiert über Objektgrenzen (OBJ ist + // zustandsbehaftet); `remap` bildet globale (pos, uv)-Paare auf lokale + // Vertex-Indizes ab. + let mut cur = empty_object(); + let mut cur_mat: Option = None; + let mut remap: HashMap<(usize, Option), usize> = HashMap::new(); + + for line in src.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + let mut it = line.split_whitespace(); + let Some(kw) = it.next() else { continue; }; + match kw { + "v" => { + if let Some(p) = parse_floats::<3>(&mut it) { + positions.push(p); + } else { + eprintln!("[obj] kaputte v-Zeile übersprungen: {line:?}"); + } + } + "vt" => { + if let Some(t) = parse_floats::<2>(&mut it) { + texcoords.push(t); + } else { + eprintln!("[obj] kaputte vt-Zeile übersprungen: {line:?}"); + } + } + "o" | "g" => { + flush(&mut objects, &mut cur, &mut remap); + cur.name = it.collect::>().join(" "); + } + "usemtl" => { + let name = it.next().unwrap_or("").to_string(); + cur_mat = Some(*mat_by_name.entry(name.clone()).or_insert_with(|| { + materials.push(name); + materials.len() - 1 + })); + } + "f" => { + // Token-Formen: "i", "i/uv", "i//n", "i/uv/n". Wir nehmen + // Position und (falls da) UV. Negative (relative) Indizes + // schreibt Blender nicht — außerhalb des Subsets. + let idxs: Option> = it.map(|tok| { + let mut parts = tok.split('/'); + let vi = parts.next()?.parse::().ok()?.checked_sub(1)?; + if vi >= positions.len() { return None; } + let ti = match parts.next().filter(|s| !s.is_empty()) { + Some(s) => { + let t = s.parse::().ok()?.checked_sub(1)?; + if t >= texcoords.len() { return None; } + Some(t) + } + None => None, + }; + Some(*remap.entry((vi, ti)).or_insert_with(|| { + cur.verts.push(positions[vi]); + cur.uvs.push(ti.map_or([0.0, 0.0], |t| texcoords[t])); + cur.verts.len() - 1 + })) + }).collect(); + let Some(idxs) = idxs else { + eprintln!("[obj] kaputte f-Zeile übersprungen: {line:?}"); + continue; + }; + // Material erst bei der ersten Face festnageln: Faces ohne + // jedes usemtl laufen unter dem ""-Material. + let m = *cur_mat.get_or_insert_with(|| { + *mat_by_name.entry(String::new()).or_insert_with(|| { + materials.push(String::new()); + materials.len() - 1 + }) + }); + // Fan-Triangulierung: (0,1,2), (0,2,3), … + for k in 1..idxs.len().saturating_sub(1) { + cur.tris.push([idxs[0], idxs[k], idxs[k + 1]]); + cur.tri_mats.push(m); + } + } + _ => {} // vn, s, mtllib, … ignorieren + } + } + flush(&mut objects, &mut cur, &mut remap); + + Model { objects, empties: Vec::new(), materials } +} + +fn empty_object() -> Object { + Object { + name: String::new(), + verts: Vec::new(), uvs: Vec::new(), + tris: Vec::new(), tri_mats: Vec::new(), + visible: true, collider: false, + props: Props::new(), + } +} + +/// Fertigen o-Block als Objekt ablegen (leere verwerfen) und den Zustand +/// für den nächsten Block zurücksetzen. +fn flush(objects: &mut Vec, cur: &mut Object, remap: &mut HashMap<(usize, Option), usize>) { + let done = std::mem::replace(cur, empty_object()); + remap.clear(); + if done.tris.is_empty() { return; } + let (visible, collider) = classify(&done.name); + objects.push(Object { visible, collider, ..done }); +} + +fn parse_floats(it: &mut std::str::SplitWhitespace) -> Option<[f32; N]> { + let mut out = [0.0; N]; + for v in &mut out { + *v = it.next()?.parse().ok()?; + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# Kommentar +o Suzanne +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 1 1 +vt 0 1 +usemtl skin +f 1/1 2/2 3/3 4/4 +o col_Wand.001 +v 5 0 0 +v 6 0 0 +v 6 1 0 +f 5 6 7 +o VIS_Deko +v 7 7 7 +v 8 7 7 +v 8 8 7 +f 8/1 9/2 10/3 +"; + + #[test] + fn parses_objects_with_local_reindex_and_fan() { + let m = parse(SAMPLE); + assert_eq!(m.objects.len(), 3); + + let s = &m.objects[0]; + assert_eq!(s.name, "Suzanne"); + assert_eq!(s.verts.len(), 4); // lokal re-indexiert + assert_eq!(s.tris, vec![[0, 1, 2], [0, 2, 3]]); // Quad → 2 Dreiecke (Fan) + assert_eq!(s.uvs[2], [1.0, 1.0]); // vt übernommen + assert!(s.visible && !s.collider, "ohne Präfix: nur sichtbar"); + + let w = &m.objects[1]; + assert_eq!(w.name, "col_Wand.001"); + assert_eq!(w.verts[0], [5.0, 0.0, 0.0]); // globale Indizes → lokal + assert_eq!(w.uvs[0], [0.0, 0.0]); // kein vt → Default + } + + #[test] + fn prefix_classification_is_case_insensitive() { + let m = parse(SAMPLE); + let w = &m.objects[1]; + assert!(!w.visible && w.collider, "col_ = nur Collider"); + let d = &m.objects[2]; + assert!(d.visible && !d.collider, "VIS_ = nur sichtbar"); + } + + #[test] + fn materials_are_shared_and_persist_across_objects() { + let m = parse(SAMPLE); + // "skin" (usemtl) — der Zustand persistiert über o-Grenzen, also + // laufen auch Wand und Deko unter "skin"; kein ""-Material nötig. + assert_eq!(m.materials, vec!["skin".to_string()]); + assert!(m.objects.iter().all(|o| o.tri_mats.iter().all(|&i| i == 0))); + } + + #[test] + fn faces_without_usemtl_get_empty_material() { + let m = parse("o A\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n"); + assert_eq!(m.materials, vec![String::new()]); + } + + #[test] + fn broken_lines_are_skipped_not_fatal() { + // f verweist auf nicht existierende Indizes, v ist unvollständig — + // beides wird übersprungen, der Rest bleibt nutzbar. + let m = parse("o A\nv 0 0\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 99\nf 1 2 3\n"); + assert_eq!(m.objects.len(), 1); + assert_eq!(m.objects[0].tris.len(), 1); + } + + #[test] + fn load_missing_file_is_err() { + assert!(load("/nonexistent/x.obj").is_err()); + } +} diff --git a/src/engine/player.rs b/src/engine/player.rs index 776a0a4..f9d78d3 100644 --- a/src/engine/player.rs +++ b/src/engine/player.rs @@ -36,7 +36,8 @@ const JUMP_SPEED: f32 = 7.0; /// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`). const PITCH_LIMIT: f32 = 1.55; /// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch. -const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3]; +/// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft. +pub const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3]; /// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok). const GROUND_NORMAL_Y: f32 = 0.7; /// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich). @@ -70,6 +71,15 @@ impl Player { self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT); } + /// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/ + /// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den + /// Point-and-Click-Strahl aus der Bildmitte. + pub fn look_dir(&self) -> [f32; 3] { + let (sy, cy) = self.yaw.sin_cos(); + let (sp, cp) = self.pitch.sin_cos(); + [-sy * cp, sp, -cy * cp] + } + /// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt, /// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true = /// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden. diff --git a/src/engine/trigger.rs b/src/engine/trigger.rs new file mode 100644 index 0000000..58d2b64 --- /dev/null +++ b/src/engine/trigger.rs @@ -0,0 +1,155 @@ +//! Trigger aus Blender-Modellen: Objekte mit `signal`-Custom-Property +//! (siehe [`model::SIGNAL_PROP`]), aufgeteilt nach Collider-Frage — +//! **anfassbar = klickbar, durchlaufbar = Betretens-Zone**: +//! +//! - `col_*` + `signal` → *Klick-Ziel*: das solide Volumen ist zugleich +//! das Ziel des Point-and-Click-Raycasts (siehe [`Triggers::pick`]). +//! Feuern läuft über den `use `-Trichter in `Session::exec`. +//! - sonst + `signal` → *Zone*: unsichtbares konvexes Volumen; Betreten +//! feuert das Signal genau einmal je Eintritt ([`Triggers::enter_events`], +//! Flanken-Semantik: erneut erst nach Verlassen). „Einmal für immer" +//! baut die Autorin über KV-Flags/Ink, nicht hier. +//! +//! In beiden Fällen ist der Signal-Name der Property-*Wert*, `$self` der +//! Objektname. Volumen-Mathe kommt aus `collision` (dieselben konvexen +//! Ebenen-Sets wie die Proxies); gefeuert wird hier nichts — die Session +//! zieht Events/Treffer und schickt sie durch ihren Dispatch, damit der +//! Eingabe-Trichter der einzige Weg in den State bleibt. + +use crate::engine::collision::{self, Plane}; +use crate::engine::model::{Model, SIGNAL_PROP}; + +struct Clickable { + name: String, + signal: String, + planes: Vec, +} + +struct Zone { + name: String, + signal: String, + planes: Vec, + /// Flanken-Zustand: war der Spieler im letzten Check im Volumen? + inside: bool, +} + +#[derive(Default)] +pub struct Triggers { + clickables: Vec, + zones: Vec, +} + +impl Triggers { + pub fn new() -> Self { Self::default() } + + /// Trigger eines Modells übernehmen: alle Objekte mit `signal`-Property. + pub fn add_model(&mut self, model: &Model) { + for o in &model.objects { + let Some(signal) = o.props.get(SIGNAL_PROP) else { continue; }; + let Some(planes) = collision::object_planes(o) else { + eprintln!("[trigger] {}: kein geschlossenes konvexes Volumen — ignoriert", o.name); + continue; + }; + let (name, signal) = (o.name.clone(), signal.clone()); + if o.collider { + self.clickables.push(Clickable { name, signal, planes }); + } else { + self.zones.push(Zone { name, signal, planes, inside: false }); + } + } + } + + /// Signal eines Klick-Ziels (für die `use `-Auflösung der Session). + pub fn signal_for(&self, name: &str) -> Option<&str> { + self.clickables.iter() + .find(|c| c.name == name) + .map(|c| c.signal.as_str()) + } + + /// Nächstes Klick-Ziel entlang `start→end` (Punktstrahl): Name und + /// Trefferbruch. Ob Weltgeometrie davor liegt, prüft der Aufrufer. + pub fn pick(&self, start: [f32; 3], end: [f32; 3]) -> Option<(&str, f32)> { + let mut nearest: Option<(&str, f32)> = None; + for c in &self.clickables { + if let Some(hit) = collision::trace_planes(&c.planes, start, end, [0.0; 3]) + && nearest.is_none_or(|(_, f)| hit.frac < f) { + nearest = Some((&c.name, hit.frac)); + } + } + nearest + } + + /// Zonen gegen die Spieler-AABB prüfen und die Eintritts-Flanken melden: + /// `(Objektname, Signal)` je Zone, die diesen Check betreten wurde. + pub fn enter_events(&mut self, center: [f32; 3], half: [f32; 3]) -> Vec<(String, String)> { + let mut events = Vec::new(); + for z in &mut self.zones { + let inside = collision::box_touches(&z.planes, center, half); + if inside && !z.inside { + events.push((z.name.clone(), z.signal.clone())); + } + z.inside = inside; + } + events + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::model::{test_cube as cube, Object}; + + fn model(objects: Vec) -> Model { + Model { objects, empties: Vec::new(), materials: vec![String::new()] } + } + + #[test] + fn splits_by_collider_and_zone_is_invisible() { + let m = model(vec![ + cube("col_Kiste", [0.0, 0.0, 0.0], Some("kiste")), + cube("Zone.001", [5.0, 0.0, 0.0], Some("betreten")), + cube("Deko", [9.0, 0.0, 0.0], None), + ]); + assert!(!m.objects[1].visible, "Zone darf nicht gerendert werden"); + + let mut t = Triggers::new(); + t.add_model(&m); + assert_eq!(t.clickables.len(), 1); + assert_eq!(t.zones.len(), 1); + assert_eq!(t.signal_for("col_Kiste"), Some("kiste")); + assert_eq!(t.signal_for("Deko"), None, "ohne signal-Property kein Klick-Ziel"); + } + + #[test] + fn pick_finds_nearest_clickable() { + let mut t = Triggers::new(); + t.add_model(&model(vec![ + cube("col_Fern", [4.0, -0.5, -0.5], Some("f")), + cube("col_Nah", [1.0, -0.5, -0.5], Some("n")), + ])); + // Strahl entlang +X durch beide Würfel → der nahe gewinnt. + let (name, frac) = t.pick([0.0, 0.0, 0.0], [10.0, 0.0, 0.0]).unwrap(); + assert_eq!(name, "col_Nah"); + assert!((frac - 0.1).abs() < 0.01, "frac={frac}"); + // Strahl daneben → nichts. + assert!(t.pick([0.0, 5.0, 0.0], [10.0, 5.0, 0.0]).is_none()); + } + + #[test] + fn zone_fires_on_entry_edge_and_rearms_after_exit() { + let mut t = Triggers::new(); + t.add_model(&model(vec![cube("Zone", [0.0, 0.0, 0.0], Some("betreten"))])); + let half = [0.3, 0.9, 0.3]; + + // Außerhalb: nichts. + assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty()); + // Betreten: genau ein Event … + let ev = t.enter_events([0.5, 0.5, 0.5], half); + assert_eq!(ev, vec![("Zone".to_string(), "betreten".to_string())]); + // … und beim Verweilen keins. + assert!(t.enter_events([0.6, 0.5, 0.5], half).is_empty()); + // Verlassen und wieder betreten: feuert erneut. + assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty()); + assert_eq!(t.enter_events([0.5, 0.5, 0.5], half).len(), 1); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index e2e81b1..7467c93 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -19,6 +19,7 @@ mod camera; mod font; mod gpu; mod math; +mod props; mod scene; mod sprite; mod ui; @@ -35,7 +36,7 @@ use winit::window::{CursorGrabMode, Window, WindowId}; use crate::engine::player; use crate::engine::tga::Image; -use crate::engine::{assets, map, tga}; +use crate::engine::{assets, gltf, map, model, obj, tga}; use crate::session::{FrameInput, Mode, Session}; use camera::Camera; use gpu::Gpu; @@ -63,17 +64,28 @@ pub fn run(mut session: Session) { // auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben // getrennt. let world = map::load(&assets::path("assets/maps/test.map")); - let tex_names = brush::texture_names(&world); - let images: Vec = tex_names.iter() - .map(|n| tga::load(&assets::path(&format!("assets/textures/{n}.tga")))) - .collect(); + // Blender-Modelle: alle .glb/.obj unter assets/maps/props/. Autorinnen- + // Content — ein kaputtes File wird gemeldet und übersprungen, nie fatal. + let models = load_models(&assets::path("assets/maps/props")); + + // Eine geteilte Texturliste für Brush- und Modell-Geometrie, damit beide + // Meshes denselben Index-Raum benutzen und verschmelzen können. + let mut tex_names = brush::texture_names(&world); + for (_, m) in &models { tex_names.extend(props::texture_names(m)); } + tex_names.sort_unstable(); + tex_names.dedup(); + let images: Vec = tex_names.iter().map(|n| load_texture(n)).collect(); let dims: Vec<(u32, u32)> = images.iter().map(|i| (i.width, i.height)).collect(); - let mesh = brush::build(&world, &tex_names, &dims); + + let mut mesh = brush::build(&world, &tex_names, &dims); + for (_, m) in &models { mesh.append(props::build(m, &tex_names)); } report_map(&world, &tex_names, &mesh); - // Sim-Welt (Collision-Brushes + Spawn) in die Session einspielen — die - // Simulation gehört der Session, nicht dem Render-Frontend. + // Sim-Welt (Collision-Brushes + Spawn, dann col_*-Proxies der Modelle) in + // die Session einspielen — die Simulation gehört der Session, nicht dem + // Render-Frontend. session.load_world(&world); + for (_, m) in &models { session.load_props(m); } // UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA, // FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und @@ -122,6 +134,60 @@ pub fn run(mut session: Session) { event_loop.run_app(&mut app).expect("winit: run"); } +/// Alle Blender-Modelle (`.glb` und `.obj`) eines Verzeichnisses laden +/// (sortiert → deterministische Reihenfolge). Fehlende Verzeichnisse sind +/// kein Fehler (kein Props-Ordner = keine Props); kaputte Dateien werden +/// gemeldet und übersprungen. +fn load_models(dir: &str) -> Vec<(String, model::Model)> { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); }; + let mut paths: Vec = entries.flatten() + .map(|e| e.path()) + .filter(|p| matches!(p.extension().and_then(|s| s.to_str()), Some("obj" | "glb"))) + .collect(); + paths.sort(); + let mut models = Vec::new(); + for p in paths { + let path = p.to_string_lossy(); + let loaded = match p.extension().and_then(|s| s.to_str()) { + Some("glb") => gltf::load(&path), + _ => obj::load(&path), + }; + match loaded { + Ok(m) => { + let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned()); + let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum(); + println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties", + m.objects.len(), m.empties.len()); + // Custom Properties sichtbar machen — noch konsumiert sie + // niemand, aber die Autorin sieht so, dass sie ankommen. + for o in &m.objects { + for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); } + } + for e in &m.empties { + println!("[model] Empty {} @ {:?}", e.name, e.pos); + for (k, v) in &e.props { println!("[model] {} · {k} = {v}", e.name); } + } + models.push((name, m)); + } + Err(e) => eprintln!("[model] {e}"), + } + } + models +} + +/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei +/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den +/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content. +fn load_texture(name: &str) -> Image { + let path = assets::path(&format!("assets/textures/{name}.tga")); + if std::path::Path::new(&path).exists() { + tga::load(&path) + } else { + eprintln!("[tex] {name}: keine Datei unter {path} — Platzhalter"); + tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE))) + } +} + /// Kurzer Lade-Report: Map-Inhalt und resultierende Geometriegröße. fn report_map(world: &map::Map, tex_names: &[String], mesh: &Mesh) { let brushes: usize = world.entities.iter().map(|e| e.brushes.len()).sum(); @@ -359,7 +425,8 @@ impl App { look_dx: self.input.mouse_dx, look_dy: self.input.mouse_dy, }; - self.session.tick(&input, dt); + // Betretens-Zonen melden ihre Signal-Ausgaben wie exec-Befehle. + for out in self.session.tick(&input, dt) { println!("{out}"); } } // Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der // Blick beim Fortsetzen um die aufgestaute Bewegung. @@ -391,12 +458,25 @@ impl App { let p = self.session.player.pos; Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch) }; + // Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf + // Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und + // löst beim Klick unten das `use` aus. + let aim: Option = if play { + let p = self.session.player.pos; + let eye = [p[0], p[1] + player::EYE_HEIGHT, p[2]]; + let dir = self.session.player.look_dir(); + self.session.pick(eye, dir, crate::session::INTERACT_REACH).map(str::to_string) + } else { + None + }; + // Mausposition über die Letterbox in interne Pixel mappen. let cursor = ui::Cursor { pos: self.gpu.as_ref() .map(|g| g.map_cursor(self.cursor_win)) .unwrap_or([0.0, 0.0]), grabbed: self.input.grabbed, + hud_interact: aim.is_some(), }; // Overlay state-driven bauen (verzweigt nach session.mode); die // Hover-Aktion kommt aus der Maus-Position. @@ -404,12 +484,14 @@ impl App { [gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor, ); - // Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion - // durch denselben exec-Trichter wie die Konsole. Klick in der freien - // Welt tut (noch) nichts — hier käme später der `use`-Raycast. + // Vorgemerkten Klick auflösen — beides durch denselben exec-Trichter + // wie die Konsole: über einem UI-Klickziel dessen Aktion, sonst im + // Spielmodus das Fadenkreuz-Ziel als `use `. if self.pending_click { self.pending_click = false; - if let Some(action) = screen.hover_action.clone() { + let action = screen.hover_action.clone() + .or_else(|| aim.map(|name| format!("use {name}"))); + if let Some(action) = action { let r = self.session.exec(&action); for out in r.output { println!("{out}"); } if r.quit { event_loop.exit(); } diff --git a/src/render/props.rs b/src/render/props.rs new file mode 100644 index 0000000..05e2572 --- /dev/null +++ b/src/render/props.rs @@ -0,0 +1,123 @@ +//! Blender-Modell → Render-Geometrie. +//! +//! Gegenstück zu render::brush für den OBJ-Pfad: nimmt das neutrale +//! [`Model`] aus engine::obj und baut daraus ein [`Mesh`] (Vertices + nach +//! Textur gruppierte Index-Batches), das mit der Brush-Geometrie zu einem +//! Buffer verschmilzt (siehe `Mesh::append`). +//! +//! Texturen: der `usemtl`-Materialname *ist* der Texturname — aufgelöst wie +//! bei Brushes zu `assets/textures/{name}.tga` (macht der Aufrufer). Faces +//! ohne Material (`""`) laufen unter [`FALLBACK_TEXTURE`]. +//! +//! UVs: OBJ hat den V-Ursprung unten links, unsere `tga::Image`s oben links +//! — V wird hier gespiegelt (Achsen-Konvention ist Sache des Konsumenten, +//! siehe engine::tga). Nur `visible`-Objekte werden ausgegeben; reine +//! `col_*`-Proxies sind Sache von engine::collision. + +use crate::engine::model::Model; +use crate::render::scene::{Batch, Mesh, Vertex}; + +/// Texturname für Faces ohne `usemtl` (Material `""`). +pub const FALLBACK_TEXTURE: &str = "placeholder"; + +/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den +/// Fallback gemappt — dasselbe Kontrakt wie `brush::texture_names`. +pub fn texture_names(model: &Model) -> Vec { + let mut names: Vec = model.materials.iter() + .map(|m| resolve(m).to_string()) + .collect(); + names.sort_unstable(); + names.dedup(); + names +} + +fn resolve(material: &str) -> &str { + if material.is_empty() { FALLBACK_TEXTURE } else { material } +} + +/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt (wie bei +/// `brush::build`) die Textur-Indizes vor — darf eine Obermenge sein, so +/// teilen Brush- und Modell-Geometrie eine Texturliste. +pub fn build(model: &Model, tex_names: &[String]) -> Mesh { + let mut verts = Vec::new(); + let mut per_tex: Vec> = vec![Vec::new(); tex_names.len()]; + + for o in &model.objects { + if !o.visible { continue; } + let base = verts.len() as u32; + for (p, uv) in o.verts.iter().zip(&o.uvs) { + verts.push(Vertex { pos: *p, uv: [uv[0], 1.0 - uv[1]] }); // V-Flip + } + for (t, &m) in o.tris.iter().zip(&o.tri_mats) { + let name = resolve(&model.materials[m]); + let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; }; + per_tex[ti].extend(t.map(|i| base + i as u32)); + } + } + + // Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen (wie brush). + let mut indices = Vec::new(); + let mut batches = Vec::new(); + for (ti, list) in per_tex.into_iter().enumerate() { + if list.is_empty() { continue; } + batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 }); + indices.extend_from_slice(&list); + } + Mesh { verts, indices, batches } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::obj; + + const SAMPLE: &str = "\ +o Ding +v 0 0 0 +v 1 0 0 +v 1 1 0 +vt 0 0 +vt 1 0 +vt 1 1 +usemtl carpet +f 1/1 2/2 3/3 +o col_Proxy +v 5 0 0 +v 6 0 0 +v 6 1 0 +f 4 5 6 +"; + + #[test] + fn builds_only_visible_objects_with_v_flip() { + let m = obj::parse(SAMPLE); + let names = texture_names(&m); + assert_eq!(names, vec!["carpet".to_string()]); + let mesh = build(&m, &names); + + // Nur „Ding" (col_Proxy ist unsichtbar): 3 Vertices, 1 Dreieck. + assert_eq!(mesh.verts.len(), 3); + assert_eq!(mesh.indices, vec![0, 1, 2]); + assert_eq!(mesh.batches.len(), 1); + // vt (1,1) → V-Flip → uv (1,0). + assert_eq!(mesh.verts[2].uv, [1.0, 0.0]); + } + + #[test] + fn missing_material_maps_to_fallback() { + let m = obj::parse("o A\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n"); + assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]); + let mesh = build(&m, &texture_names(&m)); + assert_eq!(mesh.batches.len(), 1); + } + + #[test] + fn shares_texture_index_space_with_superset_list() { + // Obermengen-Liste (wie beim Merge mit Brush-Texturen): der Batch + // zeigt auf den richtigen Index in der geteilten Liste. + let m = obj::parse(SAMPLE); + let names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()]; + let mesh = build(&m, &names); + assert_eq!(mesh.batches[0].texture, 1); + } +} diff --git a/src/render/scene.rs b/src/render/scene.rs index 8bb0507..f46f82e 100644 --- a/src/render/scene.rs +++ b/src/render/scene.rs @@ -34,6 +34,24 @@ pub(crate) struct Mesh { pub(crate) batches: Vec, } +impl Mesh { + /// Ein zweites Mesh anhängen (ein Vertex-/Index-Buffer für alles). + /// Voraussetzung: beide wurden gegen *dieselbe* Texturliste gebaut — + /// die Batch-Texturindizes bleiben dann unverändert gültig; nur die + /// Buffer-Offsets verschieben sich. + pub(crate) fn append(&mut self, other: Mesh) { + let vbase = self.verts.len() as u32; + let ibase = self.indices.len() as u32; + self.verts.extend(other.verts); + self.indices.extend(other.indices.iter().map(|i| i + vbase)); + self.batches.extend(other.batches.iter().map(|b| Batch { + texture: b.texture, + start: b.start + ibase, + count: b.count, + })); + } +} + /// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe /// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln /// von WGSL verlangen. diff --git a/src/render/ui.rs b/src/render/ui.rs index 13aa015..bca2e23 100644 --- a/src/render/ui.rs +++ b/src/render/ui.rs @@ -30,8 +30,7 @@ pub(crate) const ORN: usize = 4; pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal -// Die interagierbare HUD-Variante (3) kommt mit dem Welt-Raycast. -#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3; +pub(crate) const CUR_HUD_INTERACT: usize = 3; // dito, über einem Klick-Ziel const CURSOR_PX: f32 = 16.0; const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel) @@ -61,6 +60,9 @@ pub(crate) struct Cursor { /// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt /// Maus-Cursor an `pos`. pub(crate) grabbed: bool, + /// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt- + /// Variante des HUD-Pointers. + pub(crate) hud_interact: bool, } /// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass. @@ -237,7 +239,8 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: // Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst // Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht. if cur.grabbed { - ui.cursor(CUR_HUD, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]); + let idx = if cur.hud_interact { CUR_HUD_INTERACT } else { CUR_HUD }; + ui.cursor(idx, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]); } else { let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE }; ui.cursor(idx, cur.pos, [1.0; 4]); diff --git a/src/session.rs b/src/session.rs index aef8801..3891fd4 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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, } @@ -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 { + 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 `. + 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();