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:
+95
-13
@@ -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<Image> = 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<Image> = 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<std::path::PathBuf> = 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<String> = 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 <name>`.
|
||||
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(); }
|
||||
|
||||
Reference in New Issue
Block a user