This commit is contained in:
2026-08-28 23:31:48 +02:00
parent 3571106251
commit 50b7b86944
19 changed files with 274 additions and 449 deletions
+4 -8
View File
@@ -10,17 +10,13 @@
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
//! sofort wieder im Kontakt steckt.
//!
//! 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
//! Gefüllt wird die Welt aus Blender-Modellen ([`CollisionWorld::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 `model` (Quelle der
//! Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
//! Welt einmal vom Frontend aus den Modellen.
use crate::engine::model::{Model, Object};
+8 -12
View File
@@ -5,11 +5,9 @@
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
//! konstruiert und durch die Dispatch-Pipeline gereicht.
//!
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
//! testbar und kennt keines der Subsysteme.
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) den Kern nichts
//! angeht, produzieren [`Action`]-Werte statt direkt zu wirken. Frontends
//! konsumieren die Queue: die CLI druckt sie, das Fenster führt sie aus.
use std::collections::HashMap;
@@ -28,20 +26,18 @@ pub type Signals = HashMap<String, Vec<String>>;
pub enum Action {
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
PlaySound(String),
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
/// wählbar — der entsteht nur aus dem Story-Ablauf.
/// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
/// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
SetMode(ModeTarget),
}
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog-
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
/// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
/// `session::Mode`, ohne dass der Kern dessen Dialog-Daten kennen muss.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeTarget {
/// First-Person-Spiel.
Play,
/// Noclip-Debug-Flycam.
/// Noclip: freies Fliegen ohne Kollision.
Free,
/// Menü (pausiert die Welt, gibt die Maus frei).
Menu,
+6 -13
View File
@@ -1,26 +1,19 @@
//! 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
//! Die tragenden Kanäle: **Custom Properties** (in Blender 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.
//! **Empties** (Nodes ohne Mesh) als Entity-Marker. 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.
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
//! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt.
//!
//! 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;
@@ -125,7 +118,7 @@ fn append_mesh(
None => vec![[0.0, 0.0]; positions.len()],
};
// Material-Name → geteilte Namensliste (wie usemtl); ohne Material "".
// Material-Name → geteilte Namensliste; ohne Material "".
let mat_name = prim["material"].as_u64()
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
.unwrap_or("");
+8 -17
View File
@@ -3,30 +3,21 @@
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
//! Frontends (CLI-REPL jetzt, Renderer später) sind Geschwister-Module,
//! die `engine` konsumieren; die Richtung kehrt sich nie um.
//!
//! Interner Abhängigkeitsgraph (azyklisch, Pfeil = „benutzt"):
//! Interner Abhängigkeitsgraph (azyklisch halten, Pfeil = „benutzt"):
//!
//! kv ← ink ← game ← story_ctrl ← signals
//! ↑ ↑
//! kv assets
//!
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
//! signals::dispatch zu rufen).
//! Neue Querverbindungen lieber über Rückgabewerte an den Aufrufer lösen —
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
//! rufen.
//!
//! `gltf`, `tga` und `wav` 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`. `audio` ist
//! die headless Emitter-Logik (Distance ramps) zum Frontend-Treiber
//! render::audio.
//!
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
//! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
//! die Emitter-Logik zum Frontend-Treiber render::audio, `player` die
//! First-Person-Physik auf der Welt aus `collision`.
pub mod assets;
pub mod audio;
+1 -1
View File
@@ -20,7 +20,7 @@
use std::collections::HashMap;
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
/// Werte zu Strings vereinheitlicht. OBJ kann keine tragen → leer.
/// Werte zu Strings vereinheitlicht.
pub type Props = HashMap<String, String>;
pub struct Model {
+69 -55
View File
@@ -1,24 +1,15 @@
//! First-Person-Player: headless Bewegungsphysik (Stufe 1).
//! First-Person-Player: headless Bewegungsphysik.
//!
//! Bewusst frontend- und GPU-frei — wie der Rest von `engine`. Der Renderer
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`),
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
//! `step` sweept die Spieler-AABB gegen die [`CollisionWorld`] und gleitet an
//! Treffern entlang (move-and-slide). Getract wird der Box-Mittelpunkt
//! (Fußpunkt + halbe Höhe), danach geht es zurück auf den Fußpunkt.
//! Bodenkontakt fällt aus nach oben zeigenden Trefferflächen ab. `fly` ist der
//! Noclip-Gegenpart ohne Welt.
//!
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
//! 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).
//! Getract wird der Box-Mittelpunkt (Fußpunkt + halbe Höhe), danach geht es
//! zurück auf den Fußpunkt. Bodenkontakt fällt aus nach oben zeigenden
//! Trefferflächen ab.
//!
//! Konvention wie `render::math::view`/`camera.rs`: yaw=0 blickt −Z, positiver
//! Yaw dreht nach links, positiver Pitch hebt den Blick. Die Bewegung ist
//! horizontal aus dem Yaw (ohne Pitch) — Hochschauen lässt einen nicht abheben.
//! Der Renderer leitet seine View hier ab (`Mat4::view(eye, yaw, pitch)`), der
//! Player kennt ihn nie. Konvention: yaw=0 blickt −Z, positiver Yaw dreht nach
//! links, positiver Pitch hebt den Blick. Gelaufen wird horizontal aus dem Yaw
//! — Hochschauen hebt nicht ab, Fliegen dagegen folgt dem vollen Blick.
use crate::engine::collision::CollisionWorld;
@@ -26,13 +17,14 @@ use crate::engine::collision::CollisionWorld;
/// `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.
/// Fallbeschleunigung (units/s²). Über realem g für knackiges Spielgefühl.
const GRAVITY: f32 = 20.0;
/// 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.
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe.
const JUMP_SPEED: f32 = 7.0;
/// Flug-Geschwindigkeit im Noclip (units/s).
const FLY_SPEED: f32 = 4.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.
@@ -81,43 +73,52 @@ impl Player {
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
}
/// 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.
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `math::view`. Trägt den
/// Point-and-Click-Strahl aus der Bildmitte und das Vorwärts im Noclip.
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]
}
/// Horizontales Rechts (ohne Pitch) — Strafen kippt nie mit dem Blick.
pub fn right_dir(&self) -> [f32; 3] {
let (sy, cy) = self.yaw.sin_cos();
[cy, 0.0, -sy]
}
/// Noclip: frei durch die Welt, ohne Kollision und ohne Gravitation.
/// Vorwärts folgt dem vollen Blick, `up` ist Welt-Y (Auf-/Absteigen bleibt
/// unabhängig vom Blickwinkel).
pub fn fly(&mut self, fwd: f32, right: f32, up: f32, dt: f32) {
let (f, r) = (self.look_dir(), self.right_dir());
for i in 0..3 {
self.pos[i] += (f[i] * fwd + r[i] * right) * FLY_SPEED * dt;
}
self.pos[1] += up * FLY_SPEED * dt;
self.vel = [0.0; 3];
self.grounded = false;
}
/// 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.
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
let was_grounded = self.grounded;
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0,
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach.
// Wunschgeschwindigkeit sofort setzen — ohne Beschleunigung/Reibung,
// mit voller Luftkontrolle. Vorwärts ist der flache Blick, damit
// Hochschauen nicht abhebt.
let (sy, cy) = self.yaw.sin_cos();
let f = [-sy, -cy]; // (x, z)
let r = [ cy, -sy]; // (x, z)
let r = self.right_dir();
self.vel[0] = (-sy * fwd + r[0] * right) * WALK_SPEED;
self.vel[2] = (-cy * fwd + r[2] * right) * WALK_SPEED;
// Wunschgeschwindigkeit sofort setzen (ohne Beschleunigung/Reibung —
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst.
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED;
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED;
// Sprung nur vom Boden (Vorframe-`grounded`) — kein Doppelsprung.
//
// Gravitation wirkt *nur in der Luft*. Am Boden wird die Vertikal-
// geschwindigkeit stattdessen genullt, denn `slide` projiziert sie auf
// die Trefferebene und lässt auf einer Schräge den hangparallelen Rest
// stehen — Hangabtrieb, der sich über die Frames aufschaukelt und den
// Spieler auch sanfte Rampen hinabkriechen lässt. Ohne vertikale
// Geschwindigkeit gibt es nichts zu projizieren, er bleibt stehen; ans
// Gefälle heftet ihn der Ground-Snap unten. Steiler als
// [`GROUND_NORMAL_Y`] wird `grounded` gar nicht erst gesetzt, dort
// greift der Luft-Zweig und der Spieler rutscht wie gewünscht ab.
// Sprung nur vom Boden — kein Doppelsprung. Gravitation wirkt nur in
// der Luft: am Boden bliebe sonst nach der Slide-Projektion ein
// hangparalleler Rest stehen, der sich über die Frames zum Hangabtrieb
// aufschaukelt. Steiler als [`GROUND_NORMAL_Y`] ist `grounded` ohnehin
// nicht gesetzt — dort greift der Luft-Zweig und man rutscht ab.
let jumped = jump && was_grounded;
if jumped {
self.vel[1] = JUMP_SPEED;
@@ -130,11 +131,10 @@ impl Player {
self.move_and_slide(world, dt);
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter,
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
// Ground-Snap: stand er eben noch am Boden und schwebt nach dem Zug
// knapp darüber (abwärts-Slope, Stufe runter, Brush-Naht), ziehen wir
// ihn hinunter. Nicht beim Aufsteigen — sonst gäbe es keine
// Rampen-Absprünge mehr.
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
self.snap_to_ground(world);
}
@@ -158,11 +158,9 @@ impl Player {
///
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box-
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
/// runter auf Boden. Kommt das horizontal weiter und endet auf begehbarem
/// Boden, gewinnt es. Das hebt die flache Box-Unterkante über Brush-Nähte
/// (Slope-Kanten, Stufen), an denen sie sonst als „Wand" verkantet.
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
let half = HALF_EXTENTS;
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt
@@ -362,6 +360,22 @@ mod tests {
assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]);
}
#[test]
fn fly_ignores_gravity_and_geometry() {
// Noclip kennt die Welt gar nicht: nach unten durch jeden Boden, und
// vorwärts dem vollen Blick nach — ohne Gravitation.
let mut p = Player::new([0.0, 2.0, 0.0]);
p.fly(0.0, 0.0, -1.0, 1.0);
assert!(p.pos[1] < -1.0, "sollte durch den Boden sinken: {}", p.pos[1]);
assert_eq!(p.vel, [0.0; 3], "Flug trägt keine Geschwindigkeit");
p.pitch = std::f32::consts::FRAC_PI_2; // gerade nach oben schauen
let y0 = p.pos[1];
p.fly(1.0, 0.0, 0.0, 1.0);
assert!(p.pos[1] > y0 + 1.0, "vorwärts folgt dem Blick: {y0} -> {}", p.pos[1]);
assert!(!p.grounded);
}
#[test]
fn wall_blocks_horizontal_movement() {
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
+2 -3
View File
@@ -1,7 +1,6 @@
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
//! treiben. Frontend-agnostisch — den `StoryState` gießt der Aufrufer selbst
//! in seine Darstellung.
//!
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
+1 -5
View File
@@ -2,11 +2,7 @@
//!
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die
//! RGB-Umrechnung jedem Konsumenten einzeln überließ.) Konsumenten ziehen
//! ein `Image` und legen es ab, wie sie es brauchen: Renderer → GPU-Textur,
//! UI/Font → eigene Interpretation. Die UV-/V-Achsen-Konvention ist damit
//! Sache des Konsumenten beim Sampling, nicht des Loaders.
//! Nachbearbeitung, nur das Sampling (und damit die UV-Konvention) gehört ihm.
//!
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
+2 -4
View File
@@ -1,9 +1,7 @@
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
//!
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten),
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
//! File wird gemeldet und bleibt still, nie fatal.
//! Reiner Decoder (Bytes → owned Daten), `Result` statt Panic — Sounds sind
//! Autorinnen-Content, ein kaputtes File wird gemeldet und bleibt still.
//!
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV