Map-Loader entfernt: glTF ist das einzige 3D-Format
engine/map.rs + render/brush.rs raus, Welt kommt komplett aus .glb. Spawn via Empty mit role=spawn statt info_player_start; testscene.glb (Boden, Wände, Spawn) ersetzt test.map. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+12
-68
@@ -1,9 +1,6 @@
|
||||
//! Brush-Collision: Swept-AABB-Trace gegen konvexe Brushes (Quake-Hull-Idee).
|
||||
//!
|
||||
//! Ein Brush ist der Schnitt seiner Halbräume `{ n·x ≤ d }` (n nach außen) —
|
||||
//! genau die Ebenen, die auch render::brush rekonstruiert, hier aber in
|
||||
//! **Engine-Koords** und **vollständig** (keine Koplanar-Elimination, kein
|
||||
//! Culling: für Collision zählt das ganze solide Volumen).
|
||||
//! Collision: Swept-AABB-Trace gegen konvexe Volumen (Quake-Hull-Idee —
|
||||
//! „Brush" heißt hier: der Schnitt seiner Halbräume `{ n·x ≤ d }`,
|
||||
//! n nach außen).
|
||||
//!
|
||||
//! Die Box wird nicht selbst getract, sondern per Minkowski-Aufblasung in den
|
||||
//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte
|
||||
@@ -13,21 +10,18 @@
|
||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||
//! sofort wieder im Kontakt steckt.
|
||||
//!
|
||||
//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`])
|
||||
//! und Collider-Objekte (`collide`-Property) aus Blender-Modellen
|
||||
//! ([`add_model`]). Beide enden als dasselbe Ebenen-Set — die Trace kennt
|
||||
//! keine Herkunft. Modell-Ebenen sind die **konvexe Hülle** der Vertices
|
||||
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
|
||||
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
|
||||
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
|
||||
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
||||
//! achsparallelen AABB-Ebenen (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 Hüllen wieder stramm.
|
||||
//!
|
||||
//! Headless wie der Rest von `engine`: hängt nur an `map`/`model` (Quellen
|
||||
//! der Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
||||
//! Welt einmal vom Frontend aus Map und Modellen.
|
||||
//! Headless wie der Rest von `engine`: hängt nur an `model` (Quelle der
|
||||
//! Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
||||
//! Welt einmal vom Frontend aus den Modellen.
|
||||
|
||||
use crate::engine::map::{self, Map};
|
||||
use crate::engine::model::{Model, Object};
|
||||
|
||||
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
||||
@@ -45,7 +39,7 @@ struct ConvexBrush {
|
||||
planes: Vec<Plane>,
|
||||
}
|
||||
|
||||
/// Die statische Kollisionswelt: alle soliden Brushes der Map.
|
||||
/// Die statische Kollisionswelt: alle soliden Volumen der geladenen Modelle.
|
||||
pub struct CollisionWorld {
|
||||
brushes: Vec<ConvexBrush>,
|
||||
}
|
||||
@@ -58,21 +52,7 @@ pub struct Hit {
|
||||
}
|
||||
|
||||
impl CollisionWorld {
|
||||
/// Kollisionswelt aus allen Brushes der Map bauen. Jede Brush-Face liefert
|
||||
/// eine Ebene (aus drei Punkten, nach Engine-Koords gedreht).
|
||||
pub fn build(world: &Map) -> Self {
|
||||
let mut brushes = Vec::new();
|
||||
for ent in &world.entities {
|
||||
for b in &ent.brushes {
|
||||
if b.faces.len() < 4 { continue; } // kein geschlossenes Volumen
|
||||
let planes = b.faces.iter().map(|f| plane_from(&f.plane)).collect();
|
||||
brushes.push(ConvexBrush { planes });
|
||||
}
|
||||
}
|
||||
Self { brushes }
|
||||
}
|
||||
|
||||
/// Leere Welt (keine Brushes) — Default, bis eine Map geladen ist.
|
||||
/// Leere Welt — Ausgangszustand, bis Modelle geladen sind.
|
||||
pub fn empty() -> Self {
|
||||
Self { brushes: Vec::new() }
|
||||
}
|
||||
@@ -325,19 +305,8 @@ pub(crate) fn trace_planes(planes: &[Plane], start: [f32; 3], end: [f32; 3], hal
|
||||
}
|
||||
}
|
||||
|
||||
/// Ebene aus drei Face-Punkten (Quake-Reihenfolge), gedreht nach Engine-Koords.
|
||||
/// Wie render::brush::plane, aber direkt im Engine-System: weil `to_engine` die
|
||||
/// Orientierung erhält (det +1), zeigt `cross(c−a, b−a)` weiter nach außen.
|
||||
fn plane_from(p: &[[f32; 3]; 3]) -> Plane {
|
||||
let a = map::to_engine(p[0]);
|
||||
let b = map::to_engine(p[1]);
|
||||
let c = map::to_engine(p[2]);
|
||||
let n = normalize(cross(sub(c, a), sub(b, a)));
|
||||
Plane { n, d: dot(n, a) }
|
||||
}
|
||||
|
||||
// --- kleine Vektor-Helfer (privat, wie render::brush; ein gemeinsames
|
||||
// engine::vec3 lohnt erst, falls ein dritter Nutzer auftaucht) ---------------
|
||||
// --- kleine Vektor-Helfer (privat; ein gemeinsames engine::vec3 lohnt
|
||||
// erst, falls ein dritter Nutzer auftaucht) ----------------------------------
|
||||
|
||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
||||
@@ -483,29 +452,4 @@ mod tests {
|
||||
assert!(object_planes(&empty).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_brushes_from_map() {
|
||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
||||
let src = r#"
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
{
|
||||
( 0 0 0 ) ( 0 1 0 ) ( 0 0 1 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 0 0 1 ) ( 1 0 0 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 1 0 0 ) ( 0 1 0 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 65 64 ) ( 65 64 64 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 65 64 64 ) ( 64 64 65 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 64 65 ) ( 64 65 64 ) t 0 0 0 1 1
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let m = map::parse(src);
|
||||
let w = CollisionWorld::build(&m);
|
||||
assert_eq!(w.brushes.len(), 1);
|
||||
assert_eq!(w.brushes[0].planes.len(), 6);
|
||||
// Engine-Koords des Quake-Würfels [0,64]³: x[0,2], y[0,2], z[-2,0]
|
||||
// (Drehung (x,z,−y)·1/32). Strahl von außerhalb (−X) hindurch.
|
||||
let h = w.trace([-1.0, 1.0, -1.0], [3.0, 1.0, -1.0], PT);
|
||||
assert!(h.is_some(), "Strahl sollte den Brush treffen");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
//! Quake-`.map`-Parser (Standard-Format, wie TrenchBroom es mit dem
|
||||
//! Generic-Game exportiert).
|
||||
//!
|
||||
//! Struktur: eine Datei ist eine Liste von Entities `{...}`. Eine Entity
|
||||
//! hält Key/Value-Properties (`"key" "value"`) und null oder mehr Brushes
|
||||
//! `{...}`. Ein Brush ist eine Liste von Faces; jede Face ist eine Ebene
|
||||
//! aus drei Punkten plus Textur-Ausrichtung:
|
||||
//!
|
||||
//! ( x y z ) ( x y z ) ( x y z ) TEXTUR offX offY rot scaleX scaleY
|
||||
//!
|
||||
//! Hier wird nur geparst — Geometrie bleibt im Quake-Koordinatensystem
|
||||
//! (Z-up, Brush = Schnitt der Halbräume); die Umrechnung ins Engine-System
|
||||
//! und der Halbraum-Schnitt sind Sache des Konsumenten (render::brush).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
pub struct Map {
|
||||
pub entities: Vec<Entity>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Entity {
|
||||
pub props: HashMap<String, String>,
|
||||
pub brushes: Vec<Brush>,
|
||||
}
|
||||
|
||||
impl Entity {
|
||||
// Genutzt für Spawn-Auflösung (info_player_start) und künftige
|
||||
// Entity-/Signal-Bindung.
|
||||
pub fn classname(&self) -> Option<&str> {
|
||||
self.props.get("classname").map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Brush {
|
||||
pub faces: Vec<Face>,
|
||||
}
|
||||
|
||||
/// Eine Brush-Face: Ebene aus drei Punkten (Quake-Reihenfolge, im
|
||||
/// Uhrzeigersinn von vorn gesehen) plus Textur-Ausrichtung. Die
|
||||
/// Ausrichtungsfelder werden erst vom Textur-Schritt gebraucht.
|
||||
pub struct Face {
|
||||
pub plane: [[f32; 3]; 3],
|
||||
pub texture: String,
|
||||
#[allow(dead_code)]
|
||||
pub offset: [f32; 2],
|
||||
#[allow(dead_code)]
|
||||
pub rotation: f32,
|
||||
#[allow(dead_code)]
|
||||
pub scale: [f32; 2],
|
||||
}
|
||||
|
||||
/// Quake-Einheiten pro Engine-Einheit (32 ≈ klassische „1 Meter"-Annahme).
|
||||
/// Single Source für Render- (render::brush) und Collision-Pfad
|
||||
/// (engine::collision), damit beide denselben Maßstab benutzen.
|
||||
pub const MAP_SCALE: f32 = 1.0 / 32.0;
|
||||
|
||||
/// Quake-Koords (Z-up) → Engine-Koords (Y-up, Blick −Z), skaliert:
|
||||
/// `(x, y, z) → (x, z, −y) · MAP_SCALE`. Die Drehung erhält die Orientierung
|
||||
/// (det +1) — nach außen zeigende Normalen bleiben außen.
|
||||
pub fn to_engine(p: [f32; 3]) -> [f32; 3] {
|
||||
[p[0] * MAP_SCALE, p[2] * MAP_SCALE, -p[1] * MAP_SCALE]
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Map {
|
||||
let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}"));
|
||||
parse(&src)
|
||||
}
|
||||
|
||||
pub fn parse(src: &str) -> Map {
|
||||
let mut entities = Vec::new();
|
||||
let mut cur_entity: Option<Entity> = None;
|
||||
let mut cur_brush: Option<Brush> = None;
|
||||
|
||||
for raw in src.lines() {
|
||||
// Kommentar (`// …`) abschneiden, dann trimmen.
|
||||
let line = match raw.split_once("//") {
|
||||
Some((code, _)) => code.trim(),
|
||||
None => raw.trim(),
|
||||
};
|
||||
if line.is_empty() { continue; }
|
||||
|
||||
match line {
|
||||
"{" => {
|
||||
// Erstes `{` öffnet eine Entity, ein weiteres einen Brush.
|
||||
if cur_entity.is_none() {
|
||||
cur_entity = Some(Entity::default());
|
||||
} else {
|
||||
cur_brush = Some(Brush { faces: Vec::new() });
|
||||
}
|
||||
}
|
||||
"}" => {
|
||||
if let Some(brush) = cur_brush.take() {
|
||||
cur_entity.as_mut().unwrap().brushes.push(brush);
|
||||
} else if let Some(entity) = cur_entity.take() {
|
||||
entities.push(entity);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(brush) = cur_brush.as_mut() {
|
||||
if let Some(face) = parse_face(line) {
|
||||
brush.faces.push(face);
|
||||
}
|
||||
} else if let Some(entity) = cur_entity.as_mut()
|
||||
&& let Some((k, v)) = parse_property(line) {
|
||||
entity.props.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Map { entities }
|
||||
}
|
||||
|
||||
/// `"key" "value"` — Wert darf leer sein.
|
||||
fn parse_property(line: &str) -> Option<(String, String)> {
|
||||
let mut it = line.split('"');
|
||||
it.next()?; // vor dem ersten "
|
||||
let key = it.next()?; // key
|
||||
it.next()?; // zwischen den Paaren
|
||||
let val = it.next()?; // value
|
||||
Some((key.to_string(), val.to_string()))
|
||||
}
|
||||
|
||||
/// `( x y z ) ( x y z ) ( x y z ) TEX offX offY rot sx sy`
|
||||
#[allow(clippy::needless_range_loop)] // Punkt-/Komponenten-Index steuert zugleich den Token-Cursor `i`
|
||||
fn parse_face(line: &str) -> Option<Face> {
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
let mut i = 0;
|
||||
let mut plane = [[0.0f32; 3]; 3];
|
||||
for p in 0..3 {
|
||||
if *t.get(i)? != "(" { return None; }
|
||||
for k in 0..3 {
|
||||
plane[p][k] = t.get(i + 1 + k)?.parse().ok()?;
|
||||
}
|
||||
if *t.get(i + 4)? != ")" { return None; }
|
||||
i += 5;
|
||||
}
|
||||
let texture = (*t.get(i)?).to_string();
|
||||
let nums: Option<Vec<f32>> = t[i + 1..i + 6].iter().map(|s| s.parse().ok()).collect();
|
||||
let n = nums?;
|
||||
Some(Face {
|
||||
plane,
|
||||
texture,
|
||||
offset: [n[0], n[1]],
|
||||
rotation: n[2],
|
||||
scale: [n[3], n[4]],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
// entity 0
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
"wad" ""
|
||||
// brush 0
|
||||
{
|
||||
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) placeholder 32 -32 0 1 1
|
||||
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) placeholder -16 -32 0 1 1
|
||||
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) placeholder -16 -32 0 1 1
|
||||
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) placeholder -16 -32 0 1 1
|
||||
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) placeholder -16 -32 0 1 1
|
||||
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) placeholder 32 -32 0 1 1
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_entity_brush_and_face() {
|
||||
let m = parse(SAMPLE);
|
||||
assert_eq!(m.entities.len(), 1);
|
||||
let e = &m.entities[0];
|
||||
assert_eq!(e.classname(), Some("worldspawn"));
|
||||
assert_eq!(e.props.get("wad").map(String::as_str), Some(""));
|
||||
assert_eq!(e.brushes.len(), 1);
|
||||
let b = &e.brushes[0];
|
||||
assert_eq!(b.faces.len(), 6);
|
||||
|
||||
let f = &b.faces[0];
|
||||
assert_eq!(f.texture, "placeholder");
|
||||
assert_eq!(f.plane[0], [-48.0, -64.0, -48.0]);
|
||||
assert_eq!(f.plane[2], [-48.0, -64.0, -47.0]);
|
||||
assert_eq!(f.offset, [32.0, -32.0]);
|
||||
assert_eq!(f.scale, [1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -16,16 +16,15 @@
|
||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
||||
//! signals::dispatch zu rufen).
|
||||
//!
|
||||
//! `map`, `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); `gltf` ist der Blender-Pfad
|
||||
//! (Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
||||
//! `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. `gltf` ist DER 3D-Pfad (Blender-first:
|
||||
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
||||
//! einem Export) und produziert das neutrale `model::Model`.
|
||||
//!
|
||||
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
||||
//! ableitet; `collision` (hängt an `map` und `model`) liefert ihr die Welt
|
||||
//! für den Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
||||
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
|
||||
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
||||
|
||||
pub mod assets;
|
||||
pub mod collision;
|
||||
@@ -33,7 +32,6 @@ pub mod game;
|
||||
pub mod gltf;
|
||||
pub mod ink;
|
||||
pub mod kv;
|
||||
pub mod map;
|
||||
pub mod model;
|
||||
pub mod player;
|
||||
pub mod signals;
|
||||
|
||||
@@ -71,6 +71,12 @@ pub const COLLIDE_PROP: &str = "collide";
|
||||
/// (einmal je Eintritt; erneut erst nach Verlassen)
|
||||
pub const SIGNAL_PROP: &str = "signal";
|
||||
|
||||
/// Custom-Property-Schlüssel für Empty-Rollen (der Entity-Kanal):
|
||||
/// `role = "spawn"` markiert den Spieler-Startpunkt (Fußpunkt =
|
||||
/// Empty-Position). Weitere Rollen geben künftige Konsumenten.
|
||||
pub const ROLE_PROP: &str = "role";
|
||||
pub const ROLE_SPAWN: &str = "spawn";
|
||||
|
||||
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
|
||||
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
||||
/// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert.
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
||||
//!
|
||||
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
|
||||
//! Arithmetik. Vektor-Helfer werden nicht geteilt (brush.rs hält seine eigenen
|
||||
//! privat) — erst wenn Stufe 2 (Brush-Collision) echte dot/cross/normalize an
|
||||
//! mehreren Stellen braucht, lohnt ein gemeinsames Modul.
|
||||
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine
|
||||
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
|
||||
//! Nutzer echte dot/cross/normalize braucht.
|
||||
//!
|
||||
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
|
||||
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
|
||||
@@ -23,13 +23,13 @@
|
||||
use crate::engine::collision::CollisionWorld;
|
||||
|
||||
/// Augenhöhe über dem Fußpunkt (units). Der Renderer setzt die Kamera auf
|
||||
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
||||
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||
pub const EYE_HEIGHT: f32 = 1.6;
|
||||
|
||||
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
||||
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
||||
const GRAVITY: f32 = 20.0;
|
||||
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
||||
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||
const WALK_SPEED: f32 = 5.0;
|
||||
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
|
||||
const JUMP_SPEED: f32 = 7.0;
|
||||
|
||||
Reference in New Issue
Block a user