refactor
This commit is contained in:
@@ -10,17 +10,13 @@
|
|||||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||||
//! sofort wieder im Kontakt steckt.
|
//! sofort wieder im Kontakt steckt.
|
||||||
//!
|
//!
|
||||||
//! Gefüllt wird die Welt aus Blender-Modellen ([`add_model`]): jedes Objekt
|
//! Gefüllt wird die Welt aus Blender-Modellen ([`CollisionWorld::add_model`]):
|
||||||
//! mit `collide`-Property wird zur **konvexen Hülle** seiner Vertices
|
//! jedes Objekt mit `collide`-Property wird zur **konvexen Hülle** seiner
|
||||||
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
|
//! Vertices ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf
|
||||||
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
//! also konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
||||||
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
||||||
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
||||||
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
|
//! 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};
|
use crate::engine::model::{Model, Object};
|
||||||
|
|
||||||
|
|||||||
+8
-12
@@ -5,11 +5,9 @@
|
|||||||
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
|
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
|
||||||
//! konstruiert und durch die Dispatch-Pipeline gereicht.
|
//! konstruiert und durch die Dispatch-Pipeline gereicht.
|
||||||
//!
|
//!
|
||||||
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
|
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) den Kern nichts
|
||||||
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte
|
//! angeht, produzieren [`Action`]-Werte statt direkt zu wirken. Frontends
|
||||||
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
|
//! konsumieren die Queue: die CLI druckt sie, das Fenster führt sie aus.
|
||||||
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
|
|
||||||
//! testbar und kennt keines der Subsysteme.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -28,20 +26,18 @@ pub type Signals = HashMap<String, Vec<String>>;
|
|||||||
pub enum Action {
|
pub enum Action {
|
||||||
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
|
/// `assets/sounds/{name}.wav` als Einmal-SFX abspielen.
|
||||||
PlaySound(String),
|
PlaySound(String),
|
||||||
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
|
/// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
|
||||||
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
|
/// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
|
||||||
/// wählbar — der entsteht nur aus dem Story-Ablauf.
|
|
||||||
SetMode(ModeTarget),
|
SetMode(ModeTarget),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog-
|
/// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
|
||||||
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten
|
/// `session::Mode`, ohne dass der Kern dessen Dialog-Daten kennen muss.
|
||||||
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ModeTarget {
|
pub enum ModeTarget {
|
||||||
/// First-Person-Spiel.
|
/// First-Person-Spiel.
|
||||||
Play,
|
Play,
|
||||||
/// Noclip-Debug-Flycam.
|
/// Noclip: freies Fliegen ohne Kollision.
|
||||||
Free,
|
Free,
|
||||||
/// Menü (pausiert die Welt, gibt die Maus frei).
|
/// Menü (pausiert die Welt, gibt die Maus frei).
|
||||||
Menu,
|
Menu,
|
||||||
|
|||||||
+6
-13
@@ -1,26 +1,19 @@
|
|||||||
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
|
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
|
||||||
//!
|
//!
|
||||||
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann —
|
//! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
|
||||||
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
|
|
||||||
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
||||||
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/…
|
//! **Empties** (Nodes ohne Mesh) als Entity-Marker. Beide kommen als
|
||||||
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die
|
//! `props`/`empties` im Modell an; Bedeutung geben die Konsumenten.
|
||||||
//! Konsumenten.
|
|
||||||
//!
|
//!
|
||||||
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
|
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
|
||||||
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
|
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
|
||||||
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
||||||
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
||||||
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken —
|
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
|
||||||
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe
|
//! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt.
|
||||||
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
|
|
||||||
//!
|
//!
|
||||||
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
||||||
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
|
//! 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 serde_json::Value;
|
||||||
|
|
||||||
@@ -125,7 +118,7 @@ fn append_mesh(
|
|||||||
None => vec![[0.0, 0.0]; positions.len()],
|
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()
|
let mat_name = prim["material"].as_u64()
|
||||||
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
|
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|||||||
+8
-17
@@ -3,30 +3,21 @@
|
|||||||
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
|
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
|
||||||
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
|
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
|
||||||
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
|
//! [`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 ← ink ← game ← story_ctrl ← signals
|
||||||
//! ↑ ↑
|
//! ↑ ↑
|
||||||
//! kv assets
|
//! kv assets
|
||||||
//!
|
//!
|
||||||
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
|
//! Neue Querverbindungen lieber über Rückgabewerte an den Aufrufer lösen —
|
||||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
|
||||||
//! signals::dispatch zu rufen).
|
//! rufen.
|
||||||
//!
|
//!
|
||||||
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen
|
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
|
||||||
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
//! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
|
||||||
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
|
//! die Emitter-Logik zum Frontend-Treiber render::audio, `player` die
|
||||||
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
//! First-Person-Physik auf der Welt aus `collision`.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
|
/// 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 type Props = HashMap<String, String>;
|
||||||
|
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
|
|||||||
+69
-55
@@ -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
|
//! `step` sweept die Spieler-AABB gegen die [`CollisionWorld`] und gleitet an
|
||||||
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`),
|
//! Treffern entlang (move-and-slide). Getract wird der Box-Mittelpunkt
|
||||||
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal
|
//! (Fußpunkt + halbe Höhe), danach geht es zurück auf den Fußpunkt.
|
||||||
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
//! 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-
|
//! Der Renderer leitet seine View hier ab (`Mat4::view(eye, yaw, pitch)`), der
|
||||||
//! Arithmetik. Vektor-Helfer werden nicht geteilt (collision.rs hält seine
|
//! Player kennt ihn nie. Konvention: yaw=0 blickt −Z, positiver Yaw dreht nach
|
||||||
//! eigenen privat) — ein gemeinsames Modul lohnt erst, wenn ein dritter
|
//! links, positiver Pitch hebt den Blick. Gelaufen wird horizontal aus dem Yaw
|
||||||
//! Nutzer echte dot/cross/normalize braucht.
|
//! — Hochschauen hebt nicht ab, Fliegen dagegen folgt dem vollen Blick.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use crate::engine::collision::CollisionWorld;
|
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).
|
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
pub const EYE_HEIGHT: f32 = 1.6;
|
pub const EYE_HEIGHT: f32 = 1.6;
|
||||||
|
|
||||||
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
/// Fallbeschleunigung (units/s²). Über realem g für knackiges Spielgefühl.
|
||||||
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
|
||||||
const GRAVITY: f32 = 20.0;
|
const GRAVITY: f32 = 20.0;
|
||||||
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit = 1 m (= 1 Blender-Meter).
|
||||||
const WALK_SPEED: f32 = 5.0;
|
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;
|
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`).
|
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
||||||
const PITCH_LIMIT: f32 = 1.55;
|
const PITCH_LIMIT: f32 = 1.55;
|
||||||
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
/// 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]]
|
[self.pos[0], self.pos[1] + EYE_HEIGHT, self.pos[2]]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/
|
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `math::view`. Trägt den
|
||||||
/// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den
|
/// Point-and-Click-Strahl aus der Bildmitte und das Vorwärts im Noclip.
|
||||||
/// Point-and-Click-Strahl aus der Bildmitte.
|
|
||||||
pub fn look_dir(&self) -> [f32; 3] {
|
pub fn look_dir(&self) -> [f32; 3] {
|
||||||
let (sy, cy) = self.yaw.sin_cos();
|
let (sy, cy) = self.yaw.sin_cos();
|
||||||
let (sp, cp) = self.pitch.sin_cos();
|
let (sp, cp) = self.pitch.sin_cos();
|
||||||
[-sy * cp, sp, -cy * cp]
|
[-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,
|
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
||||||
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
||||||
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
||||||
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
|
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
|
||||||
let was_grounded = self.grounded;
|
let was_grounded = self.grounded;
|
||||||
|
|
||||||
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0,
|
// Wunschgeschwindigkeit sofort setzen — ohne Beschleunigung/Reibung,
|
||||||
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach.
|
// mit voller Luftkontrolle. Vorwärts ist der flache Blick, damit
|
||||||
|
// Hochschauen nicht abhebt.
|
||||||
let (sy, cy) = self.yaw.sin_cos();
|
let (sy, cy) = self.yaw.sin_cos();
|
||||||
let f = [-sy, -cy]; // (x, z)
|
let r = self.right_dir();
|
||||||
let r = [ cy, -sy]; // (x, z)
|
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 —
|
// Sprung nur vom Boden — kein Doppelsprung. Gravitation wirkt nur in
|
||||||
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst.
|
// der Luft: am Boden bliebe sonst nach der Slide-Projektion ein
|
||||||
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED;
|
// hangparalleler Rest stehen, der sich über die Frames zum Hangabtrieb
|
||||||
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED;
|
// aufschaukelt. Steiler als [`GROUND_NORMAL_Y`] ist `grounded` ohnehin
|
||||||
|
// nicht gesetzt — dort greift der Luft-Zweig und man rutscht ab.
|
||||||
// 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.
|
|
||||||
let jumped = jump && was_grounded;
|
let jumped = jump && was_grounded;
|
||||||
if jumped {
|
if jumped {
|
||||||
self.vel[1] = JUMP_SPEED;
|
self.vel[1] = JUMP_SPEED;
|
||||||
@@ -130,11 +131,10 @@ impl Player {
|
|||||||
|
|
||||||
self.move_and_slide(world, dt);
|
self.move_and_slide(world, dt);
|
||||||
|
|
||||||
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen
|
// Ground-Snap: stand er eben noch am Boden und schwebt nach dem Zug
|
||||||
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter,
|
// knapp darüber (abwärts-Slope, Stufe runter, Brush-Naht), ziehen wir
|
||||||
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über
|
// ihn hinunter. Nicht beim Aufsteigen — sonst gäbe es keine
|
||||||
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim
|
// Rampen-Absprünge mehr.
|
||||||
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
|
|
||||||
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
|
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
|
||||||
self.snap_to_ground(world);
|
self.snap_to_ground(world);
|
||||||
}
|
}
|
||||||
@@ -158,11 +158,9 @@ impl Player {
|
|||||||
///
|
///
|
||||||
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
|
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
|
||||||
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
|
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
|
||||||
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und
|
/// runter auf Boden. Kommt das horizontal weiter und endet auf begehbarem
|
||||||
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box-
|
/// Boden, gewinnt es. Das hebt die flache Box-Unterkante über Brush-Nähte
|
||||||
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst
|
/// (Slope-Kanten, Stufen), an denen sie sonst als „Wand" verkantet.
|
||||||
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
|
|
||||||
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
|
|
||||||
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
|
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
|
||||||
let half = HALF_EXTENTS;
|
let half = HALF_EXTENTS;
|
||||||
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt
|
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]);
|
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]
|
#[test]
|
||||||
fn wall_blocks_horizontal_movement() {
|
fn wall_blocks_horizontal_movement() {
|
||||||
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
|
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
|
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
|
||||||
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
|
//! treiben. Frontend-agnostisch — den `StoryState` gießt der Aufrufer selbst
|
||||||
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
|
//! in seine Darstellung.
|
||||||
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
|
|
||||||
//!
|
//!
|
||||||
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
|
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
|
||||||
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
|
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
|
||||||
|
|||||||
+1
-5
@@ -2,11 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
|
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
|
||||||
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
|
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
|
||||||
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die
|
//! Nachbearbeitung, nur das Sampling (und damit die UV-Konvention) gehört ihm.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
|
//! 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
|
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
|
||||||
|
|||||||
+2
-4
@@ -1,9 +1,7 @@
|
|||||||
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
|
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
|
||||||
//!
|
//!
|
||||||
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die
|
//! Reiner Decoder (Bytes → owned Daten), `Result` statt Panic — Sounds sind
|
||||||
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten),
|
//! Autorinnen-Content, ein kaputtes File wird gemeldet und bleibt still.
|
||||||
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
|
|
||||||
//! File wird gemeldet und bleibt still, nie fatal.
|
|
||||||
//!
|
//!
|
||||||
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
|
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
|
||||||
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
|
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
|
||||||
|
|||||||
+3
-4
@@ -2,10 +2,9 @@
|
|||||||
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
|
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
|
||||||
//! dieses Modul nie).
|
//! dieses Modul nie).
|
||||||
//!
|
//!
|
||||||
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die
|
//! Kein Panic: kein Gerät oder fehlende/kaputte WAVs sind eine Meldung und
|
||||||
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte
|
//! Stille, nie fatal ([`Audio::open`] liefert `None`, alle Aufrufe darauf
|
||||||
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert
|
//! entfallen still beim Aufrufer).
|
||||||
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
|
|
||||||
//!
|
//!
|
||||||
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
|
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
|
||||||
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
|
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
//! Flycam: freie Debug-/Entwicklungskamera (Schritt 4). Position, Yaw und
|
|
||||||
//! Pitch in f32 — Port der irl3d-Kamera-Idee, aber ohne Q16.16: an dieser
|
|
||||||
//! Kamera hängt keine Physik, und der Renderer rechnet ohnehin f32.
|
|
||||||
//!
|
|
||||||
//! Konvention wie `math::Mat4::view`: yaw=0 blickt -Z, positiver Yaw dreht
|
|
||||||
//! nach links, positiver Pitch hebt den Blick.
|
|
||||||
|
|
||||||
use crate::render::math::Mat4;
|
|
||||||
|
|
||||||
/// Knapp unter 90°: hält den Blick aus der Senkrechten, wo Yaw bedeutungslos
|
|
||||||
/// würde (und `right`/`forward` zusammenfielen).
|
|
||||||
const PITCH_LIMIT: f32 = 1.55;
|
|
||||||
|
|
||||||
pub struct Camera {
|
|
||||||
pub pos: [f32; 3],
|
|
||||||
pub yaw: f32,
|
|
||||||
pub pitch: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Camera {
|
|
||||||
pub fn new(pos: [f32; 3]) -> Self {
|
|
||||||
Self { pos, yaw: 0.0, pitch: 0.0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Roh-Maus-Delta (Pixel) → Yaw/Pitch. `sens` in Radiant pro Pixel.
|
|
||||||
/// Maus nach rechts dreht den Blick nach rechts (Yaw sinkt), Maus nach
|
|
||||||
/// unten senkt ihn; Pitch wird geclamped.
|
|
||||||
pub fn apply_mouse(&mut self, dx: f32, dy: f32, sens: f32) {
|
|
||||||
self.yaw -= dx * sens;
|
|
||||||
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blickrichtung inkl. Pitch — W/S bewegen sich hier entlang.
|
|
||||||
fn forward(&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) — A/D strafen hier entlang, ohne
|
|
||||||
/// dass der Blick nach oben/unten die Bewegung kippt.
|
|
||||||
fn right(&self) -> [f32; 3] {
|
|
||||||
let (sy, cy) = self.yaw.sin_cos();
|
|
||||||
[cy, 0.0, -sy]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bewegung integrieren. `fwd`/`right`/`up` je in [-1, 1] (Tastenachsen),
|
|
||||||
/// `dist` ist die Strecke diesen Frame (Speed · dt). Hoch/runter ist
|
|
||||||
/// Welt-Y, damit Auf-/Absteigen unabhängig vom Blickwinkel bleibt.
|
|
||||||
pub fn translate(&mut self, fwd: f32, right: f32, up: f32, dist: f32) {
|
|
||||||
let f = self.forward();
|
|
||||||
let r = self.right();
|
|
||||||
for i in 0..3 {
|
|
||||||
self.pos[i] += (f[i] * fwd + r[i] * right) * dist;
|
|
||||||
}
|
|
||||||
self.pos[1] += up * dist;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn view(&self) -> Mat4 {
|
|
||||||
Mat4::view(self.pos, self.yaw, self.pitch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Die Bewegungs-Basis (`forward`) muss exakt zu der Richtung passen,
|
|
||||||
/// die `view()` als „vor der Kamera" definiert — sonst driften
|
|
||||||
/// Steuerung und Bild auseinander. Ein Punkt `d` vor der Kamera muss
|
|
||||||
/// in View-Space auf (0, 0, -d) landen.
|
|
||||||
#[test]
|
|
||||||
fn forward_matches_view() {
|
|
||||||
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
|
|
||||||
let c = Camera { pos: [1.0, 2.0, 3.0], yaw, pitch };
|
|
||||||
let f = c.forward();
|
|
||||||
let ahead = [
|
|
||||||
c.pos[0] + f[0] * 5.0,
|
|
||||||
c.pos[1] + f[1] * 5.0,
|
|
||||||
c.pos[2] + f[2] * 5.0,
|
|
||||||
1.0,
|
|
||||||
];
|
|
||||||
let v = c.view().transform(ahead);
|
|
||||||
assert!(
|
|
||||||
(v[0]).abs() < 1e-4 && (v[1]).abs() < 1e-4 && (v[2] + 5.0).abs() < 1e-4,
|
|
||||||
"yaw={yaw} pitch={pitch}: {v:?}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `right` muss horizontal sein und im View-Space nach +X zeigen
|
|
||||||
/// (sonst wären A/D vertauscht oder gekippt).
|
|
||||||
#[test]
|
|
||||||
fn right_is_horizontal_and_points_view_right() {
|
|
||||||
let c = Camera { pos: [0.0, 0.0, 0.0], yaw: 0.6, pitch: 0.3 };
|
|
||||||
let r = c.right();
|
|
||||||
assert!(r[1].abs() < 1e-6, "right darf kein Y haben: {r:?}");
|
|
||||||
let p = [r[0] * 5.0, r[1] * 5.0, r[2] * 5.0, 1.0];
|
|
||||||
let v = c.view().transform(p);
|
|
||||||
assert!(v[0] > 4.0, "right sollte nach View-+X zeigen: {v:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -179,7 +179,7 @@ impl Gpu {
|
|||||||
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
|
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer (Flycam);
|
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer;
|
||||||
/// die Projektion gehört hierher, weil sie an der internen Auflösung
|
/// die Projektion gehört hierher, weil sie an der internen Auflösung
|
||||||
/// hängt.
|
/// hängt.
|
||||||
pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
|
pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
|
||||||
|
|||||||
+28
-8
@@ -1,10 +1,8 @@
|
|||||||
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
|
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
|
||||||
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective.
|
//! statt glam: nötig sind nur Multiply, View und Perspective.
|
||||||
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
|
|
||||||
//! ist nur der GPU-Pfad.)
|
|
||||||
//!
|
//!
|
||||||
//! Konventionen:
|
//! Konventionen:
|
||||||
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export)
|
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie der Blender-Export)
|
||||||
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
|
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
|
||||||
//! bytemuck-kompatibel zu WGSL `mat4x4f`
|
//! bytemuck-kompatibel zu WGSL `mat4x4f`
|
||||||
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
|
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
|
||||||
@@ -67,8 +65,7 @@ impl Mat4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
|
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
|
||||||
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z,
|
/// herausdrehen. yaw=0 blickt -Z, positiver Pitch hebt den Blick.
|
||||||
/// positiver Pitch hebt den Blick.
|
|
||||||
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
|
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
|
||||||
Mat4::rot_x(-pitch)
|
Mat4::rot_x(-pitch)
|
||||||
.mul(&Mat4::rot_y(-yaw))
|
.mul(&Mat4::rot_y(-yaw))
|
||||||
@@ -104,8 +101,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn view_yaw_quarter_turn_looks_minus_x() {
|
fn view_yaw_quarter_turn_looks_minus_x() {
|
||||||
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf
|
// yaw=90°: Blick Richtung -X — ein Punkt auf -X liegt vor der Kamera.
|
||||||
// -X liegt dann vor der Kamera.
|
|
||||||
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
|
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
|
||||||
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
|
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
|
||||||
}
|
}
|
||||||
@@ -116,6 +112,30 @@ mod tests {
|
|||||||
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
|
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Bewegungs-Basis des Players muss exakt zu der Richtung passen, die
|
||||||
|
/// `view` als „vor der Kamera" definiert — sonst driften Steuerung und Bild
|
||||||
|
/// auseinander. Ein Punkt `d` voraus muss im View-Space auf (0, 0, -d)
|
||||||
|
/// landen, `right_dir` auf +X und ohne Y-Anteil.
|
||||||
|
#[test]
|
||||||
|
fn view_matches_player_basis() {
|
||||||
|
use crate::engine::player::Player;
|
||||||
|
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
|
||||||
|
let mut p = Player::new([1.0, 2.0, 3.0]);
|
||||||
|
(p.yaw, p.pitch) = (yaw, pitch);
|
||||||
|
let v = Mat4::view(p.pos, p.yaw, p.pitch);
|
||||||
|
|
||||||
|
let f = p.look_dir();
|
||||||
|
let ahead = [p.pos[0] + f[0] * 5.0, p.pos[1] + f[1] * 5.0, p.pos[2] + f[2] * 5.0, 1.0];
|
||||||
|
assert!(close(v.transform(ahead), [0.0, 0.0, -5.0, 1.0]),
|
||||||
|
"look_dir bei yaw={yaw} pitch={pitch}: {:?}", v.transform(ahead));
|
||||||
|
|
||||||
|
let r = p.right_dir();
|
||||||
|
assert!(r[1].abs() < 1e-6, "right_dir darf kein Y haben: {r:?}");
|
||||||
|
let side = v.transform([p.pos[0] + r[0] * 5.0, p.pos[1], p.pos[2] + r[2] * 5.0, 1.0]);
|
||||||
|
assert!(side[0] > 4.0, "right_dir sollte nach View-+X zeigen: {side:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn perspective_maps_near_far_to_0_1() {
|
fn perspective_maps_near_far_to_0_1() {
|
||||||
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
|
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
|
||||||
|
|||||||
+35
-77
@@ -1,21 +1,14 @@
|
|||||||
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
||||||
//!
|
//!
|
||||||
//! Stand: Schritt 4 des Renderer-Plans (notes/renderer-plan.md) — Flycam
|
|
||||||
//! über dem PS1-Szenen-Pass. WASD bewegt, Maus blickt (Klick fängt den
|
|
||||||
//! Cursor, Escape gibt ihn frei bzw. beendet), Space/Shift hoch/runter.
|
|
||||||
//! Als Nächstes: OBJ-Szene (Schritt 5).
|
|
||||||
//!
|
|
||||||
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
||||||
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
|
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
|
||||||
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
|
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
|
||||||
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen
|
//! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
|
||||||
//! anderen Zustand als die Konsole.
|
|
||||||
//!
|
//!
|
||||||
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
||||||
//! deren Schnittstellen — nie umgekehrt.
|
//! deren Schnittstellen — nie umgekehrt.
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod camera;
|
|
||||||
mod font;
|
mod font;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
mod math;
|
mod math;
|
||||||
@@ -39,19 +32,13 @@ use crate::engine::player;
|
|||||||
use crate::engine::tga::Image;
|
use crate::engine::tga::Image;
|
||||||
use crate::engine::{assets, gltf, model, tga, wav};
|
use crate::engine::{assets, gltf, model, tga, wav};
|
||||||
use crate::session::{FrameInput, Mode, Session};
|
use crate::session::{FrameInput, Mode, Session};
|
||||||
use camera::Camera;
|
|
||||||
use gpu::Gpu;
|
use gpu::Gpu;
|
||||||
use math::Mat4;
|
use math::Mat4;
|
||||||
use scene::Mesh;
|
use scene::Mesh;
|
||||||
|
|
||||||
/// Radiant pro Maus-Pixel für die Flycam — dieselbe Empfindlichkeit wie die
|
|
||||||
/// First-Person-Sicht, damit sich der Wechsel nahtlos anfühlt.
|
|
||||||
const MOUSE_SENS: f32 = crate::session::LOOK_SENS;
|
|
||||||
/// Welt-Einheiten pro Sekunde.
|
|
||||||
const MOVE_SPEED: f32 = 4.0;
|
|
||||||
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
|
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
|
||||||
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
|
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
|
||||||
/// folgen (wie Source/HL2). Größer = weicher, aber träger.
|
/// folgen. Größer = weicher, aber träger.
|
||||||
const EYE_SMOOTH_TAU: f32 = 0.06;
|
const EYE_SMOOTH_TAU: f32 = 0.06;
|
||||||
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
|
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
|
||||||
const EYE_MAX_LAG: f32 = 0.6;
|
const EYE_MAX_LAG: f32 = 0.6;
|
||||||
@@ -61,11 +48,8 @@ pub fn run(mut session: Session) {
|
|||||||
// Dialog öffnen — dann startet die Welt eben pausiert).
|
// Dialog öffnen — dann startet die Welt eben pausiert).
|
||||||
for line in session.start() { println!("{line}"); }
|
for line in session.start() { println!("{line}"); }
|
||||||
|
|
||||||
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
|
// Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier,
|
||||||
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
// Upload (GPU) später in `Gpu` — die beiden bleiben getrennt.
|
||||||
// getrennt. Die Welt sind alle .glb unter assets/maps/props/ —
|
|
||||||
// Autorinnen-Content, ein kaputtes File wird gemeldet und übersprungen,
|
|
||||||
// nie fatal.
|
|
||||||
let models = load_models(&assets::path("assets/maps/props"));
|
let models = load_models(&assets::path("assets/maps/props"));
|
||||||
|
|
||||||
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
|
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
|
||||||
@@ -98,11 +82,10 @@ pub fn run(mut session: Session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
||||||
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
|
// FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
|
||||||
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
|
// und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
|
||||||
// Luminanz.
|
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
|
||||||
// UI-Texturen sind Projekt-Assets (kein Autorinnen-Content) — fehlen sie,
|
// Installation kaputt und es darf scheitern.
|
||||||
// ist die Installation kaputt, da darf es scheitern.
|
|
||||||
let load_keyed = |p: &str| {
|
let load_keyed = |p: &str| {
|
||||||
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
|
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
|
||||||
};
|
};
|
||||||
@@ -168,8 +151,8 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
|||||||
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
||||||
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
|
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
|
||||||
m.objects.len(), m.empties.len());
|
m.objects.len(), m.empties.len());
|
||||||
// Custom Properties sichtbar machen — noch konsumiert sie
|
// Properties mitloggen: so sieht die Autorin, ob ihr Export
|
||||||
// niemand, aber die Autorin sieht so, dass sie ankommen.
|
// `collide`, `signal` und `role` mitgenommen hat.
|
||||||
for o in &m.objects {
|
for o in &m.objects {
|
||||||
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
|
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
|
||||||
}
|
}
|
||||||
@@ -185,9 +168,6 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
|||||||
models
|
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.
|
|
||||||
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
|
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
|
||||||
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
|
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
|
||||||
fn load_sound(name: &str) -> Option<audio::Sound> {
|
fn load_sound(name: &str) -> Option<audio::Sound> {
|
||||||
@@ -238,8 +218,6 @@ struct App {
|
|||||||
gpu: Option<Gpu>,
|
gpu: Option<Gpu>,
|
||||||
session: Session,
|
session: Session,
|
||||||
rx: Receiver<String>,
|
rx: Receiver<String>,
|
||||||
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
|
|
||||||
camera: Camera,
|
|
||||||
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
|
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
|
||||||
eye_y: f32,
|
eye_y: f32,
|
||||||
input: Input,
|
input: Input,
|
||||||
@@ -271,15 +249,12 @@ impl App {
|
|||||||
fonts: font::Fonts,
|
fonts: font::Fonts,
|
||||||
audio: Option<audio::Audio>,
|
audio: Option<audio::Audio>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
|
|
||||||
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
|
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
|
||||||
Self {
|
Self {
|
||||||
window: None,
|
window: None,
|
||||||
gpu: None,
|
gpu: None,
|
||||||
session,
|
session,
|
||||||
rx,
|
rx,
|
||||||
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
|
|
||||||
camera: Camera::new([0.0, 1.0, 4.0]),
|
|
||||||
eye_y,
|
eye_y,
|
||||||
input: Input::default(),
|
input: Input::default(),
|
||||||
last: None,
|
last: None,
|
||||||
@@ -296,7 +271,7 @@ impl App {
|
|||||||
|
|
||||||
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
|
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
|
||||||
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
|
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
|
||||||
// Greif-Art: Locked (Flycam, relativer Blick) ↔ None (freie Maus, UI).
|
// Greif-Art: Locked (Spiel/Noclip, relativer Blick) ↔ None (freie Maus, UI).
|
||||||
fn set_grab(&mut self, grab: bool) {
|
fn set_grab(&mut self, grab: bool) {
|
||||||
let Some(window) = &self.window else { return; };
|
let Some(window) = &self.window else { return; };
|
||||||
if grab {
|
if grab {
|
||||||
@@ -366,7 +341,7 @@ impl ApplicationHandler for App {
|
|||||||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||||||
// Klick nur vormerken; der nächste redraw entscheidet (mit der
|
// Klick nur vormerken; der nächste redraw entscheidet (mit der
|
||||||
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
|
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
|
||||||
// Flycam-Greifen.
|
// Maus-Greifen.
|
||||||
self.pending_click = true;
|
self.pending_click = true;
|
||||||
}
|
}
|
||||||
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
|
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
|
||||||
@@ -404,7 +379,7 @@ impl App {
|
|||||||
KeyCode::KeyS => self.input.back = down,
|
KeyCode::KeyS => self.input.back = down,
|
||||||
KeyCode::KeyA => self.input.left = down,
|
KeyCode::KeyA => self.input.left = down,
|
||||||
KeyCode::KeyD => self.input.right = down,
|
KeyCode::KeyD => self.input.right = down,
|
||||||
// Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke,
|
// Space: im Noclip „hoch" (gehalten), im Spiel Sprung (Flanke,
|
||||||
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
|
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
|
||||||
KeyCode::Space => {
|
KeyCode::Space => {
|
||||||
self.input.up = down;
|
self.input.up = down;
|
||||||
@@ -416,38 +391,28 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
// dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert,
|
|
||||||
// während die Konsole den Dialog treibt.
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
|
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
|
||||||
|
|
||||||
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
|
// Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
|
||||||
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
|
// (relativer Blick), Menü/Dialog geben sie frei fürs UI.
|
||||||
// gibt es keine parallele Lock-Logik.
|
|
||||||
let fly = matches!(self.session.mode, Mode::Free);
|
let fly = matches!(self.session.mode, Mode::Free);
|
||||||
let play = matches!(self.session.mode, Mode::FirstPerson);
|
let play = matches!(self.session.mode, Mode::FirstPerson);
|
||||||
let grab = fly || play;
|
let grab = fly || play;
|
||||||
if grab != self.input.grabbed { self.set_grab(grab); }
|
if grab != self.input.grabbed { self.set_grab(grab); }
|
||||||
|
|
||||||
if fly {
|
// Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
|
||||||
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
|
// die Session an ihrem Modus. Betretens-Zonen melden ihre
|
||||||
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
|
// Signal-Ausgaben wie exec-Befehle.
|
||||||
let fwd = axis(self.input.fwd, self.input.back);
|
let input = FrameInput {
|
||||||
let right = axis(self.input.right, self.input.left);
|
fwd: axis(self.input.fwd, self.input.back),
|
||||||
let up = axis(self.input.up, self.input.down);
|
right: axis(self.input.right, self.input.left),
|
||||||
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
|
up: axis(self.input.up, self.input.down),
|
||||||
} else if play {
|
jump: self.input.jump,
|
||||||
// Eingaben einsammeln und die Simulation in der Session treiben.
|
look_dx: self.input.mouse_dx,
|
||||||
let input = FrameInput {
|
look_dy: self.input.mouse_dy,
|
||||||
fwd: axis(self.input.fwd, self.input.back),
|
};
|
||||||
right: axis(self.input.right, self.input.left),
|
for out in self.session.tick(&input, dt) { println!("{out}"); }
|
||||||
jump: self.input.jump,
|
|
||||||
look_dx: self.input.mouse_dx,
|
|
||||||
look_dy: self.input.mouse_dy,
|
|
||||||
};
|
|
||||||
// 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
|
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
||||||
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
||||||
self.input.mouse_dx = 0.0;
|
self.input.mouse_dx = 0.0;
|
||||||
@@ -467,11 +432,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
|
// Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
|
||||||
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
|
// der Luft und außerhalb des Spiels folgt sie exakt.
|
||||||
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
|
|
||||||
// In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
|
|
||||||
// die Kamera reagieren bzw. mit der Flycam synchron bleiben.
|
|
||||||
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
|
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
|
||||||
if play && self.session.player.grounded {
|
if play && self.session.player.grounded {
|
||||||
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
|
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
|
||||||
@@ -481,16 +443,12 @@ impl App {
|
|||||||
self.eye_y = target_eye;
|
self.eye_y = target_eye;
|
||||||
}
|
}
|
||||||
|
|
||||||
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
|
// Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
|
||||||
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
|
// nur die Sim ein, nicht die Sicht.
|
||||||
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
|
let p = self.session.player.pos;
|
||||||
// Sicht bleibt die First-Person-Sicht.
|
let view = Mat4::view(
|
||||||
let view = if fly {
|
[p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch);
|
||||||
self.camera.view()
|
|
||||||
} else {
|
|
||||||
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
|
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
|
||||||
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
|
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
|
||||||
// löst beim Klick unten das `use` aus.
|
// löst beim Klick unten das `use` aus.
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
use crate::engine::model::Model;
|
use crate::engine::model::Model;
|
||||||
use crate::render::scene::{Batch, Mesh, Vertex};
|
use crate::render::scene::{Batch, Mesh, Vertex};
|
||||||
|
|
||||||
/// Texturname für Faces ohne `usemtl` (Material `""`).
|
/// Texturname für Faces ohne Material (`""`).
|
||||||
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
||||||
|
|
||||||
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
||||||
|
|||||||
+14
-8
@@ -168,7 +168,7 @@ impl ScenePass {
|
|||||||
});
|
});
|
||||||
let tex_layout = pipeline.get_bind_group_layout(1);
|
let tex_layout = pipeline.get_bind_group_layout(1);
|
||||||
let tex_binds = images.iter()
|
let tex_binds = images.iter()
|
||||||
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
|
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "scene texture", true))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -196,20 +196,26 @@ impl ScenePass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige
|
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige Bind-Group
|
||||||
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle
|
/// (Textur + Sampler, group 1) bauen — geteilt mit [`crate::render::sprite`].
|
||||||
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch.
|
/// Mit `mips` entsteht die volle Mipchain (bis 1×1) per Box-Filter auf der
|
||||||
fn upload_texture(
|
/// CPU; UI-Sprites brauchen sie nicht, sie werden nie verkleinert.
|
||||||
|
pub(crate) fn upload_texture(
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
layout: &wgpu::BindGroupLayout,
|
layout: &wgpu::BindGroupLayout,
|
||||||
sampler: &wgpu::Sampler,
|
sampler: &wgpu::Sampler,
|
||||||
img: &Image,
|
img: &Image,
|
||||||
|
label: &str,
|
||||||
|
mips: bool,
|
||||||
) -> wgpu::BindGroup {
|
) -> wgpu::BindGroup {
|
||||||
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
|
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
|
||||||
let mip_level_count = 32 - img.width.max(img.height).leading_zeros();
|
let mip_level_count = match mips {
|
||||||
|
true => 32 - img.width.max(img.height).leading_zeros(),
|
||||||
|
false => 1,
|
||||||
|
};
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
label: Some("scene texture"),
|
label: Some(label),
|
||||||
size: wgpu::Extent3d {
|
size: wgpu::Extent3d {
|
||||||
width: img.width,
|
width: img.width,
|
||||||
height: img.height,
|
height: img.height,
|
||||||
@@ -251,7 +257,7 @@ fn upload_texture(
|
|||||||
|
|
||||||
let view = texture.create_view(&Default::default());
|
let view = texture.create_view(&Default::default());
|
||||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("scene texture"),
|
label: Some(label),
|
||||||
layout,
|
layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||||
|
|||||||
+4
-47
@@ -4,9 +4,11 @@
|
|||||||
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
|
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
|
||||||
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
|
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
|
||||||
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
|
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
|
||||||
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU).
|
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über
|
||||||
|
//! dieselbe `scene::upload_texture`, nur ohne Mipchain.
|
||||||
|
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::tga::Image;
|
||||||
|
use crate::render::scene::upload_texture;
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
@@ -134,7 +136,7 @@ impl SpritePass {
|
|||||||
});
|
});
|
||||||
let tex_layout = pipeline.get_bind_group_layout(1);
|
let tex_layout = pipeline.get_bind_group_layout(1);
|
||||||
let tex_binds = textures.iter()
|
let tex_binds = textures.iter()
|
||||||
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
|
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img, "sprite texture", false))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
|
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
|
||||||
@@ -160,48 +162,3 @@ impl SpritePass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die Bind-Group (Textur +
|
|
||||||
/// Sampler, group 1) bauen.
|
|
||||||
fn upload_texture(
|
|
||||||
device: &wgpu::Device,
|
|
||||||
queue: &wgpu::Queue,
|
|
||||||
layout: &wgpu::BindGroupLayout,
|
|
||||||
sampler: &wgpu::Sampler,
|
|
||||||
img: &Image,
|
|
||||||
) -> wgpu::BindGroup {
|
|
||||||
let size = wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 };
|
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
|
||||||
label: Some("sprite texture"),
|
|
||||||
size,
|
|
||||||
mip_level_count: 1,
|
|
||||||
sample_count: 1,
|
|
||||||
dimension: wgpu::TextureDimension::D2,
|
|
||||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
|
||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
|
||||||
view_formats: &[],
|
|
||||||
});
|
|
||||||
queue.write_texture(
|
|
||||||
wgpu::TexelCopyTextureInfo {
|
|
||||||
texture: &texture,
|
|
||||||
mip_level: 0,
|
|
||||||
origin: wgpu::Origin3d::ZERO,
|
|
||||||
aspect: wgpu::TextureAspect::All,
|
|
||||||
},
|
|
||||||
&img.rgba,
|
|
||||||
wgpu::TexelCopyBufferLayout {
|
|
||||||
offset: 0,
|
|
||||||
bytes_per_row: Some(img.width * 4),
|
|
||||||
rows_per_image: Some(img.height),
|
|
||||||
},
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
let view = texture.create_view(&Default::default());
|
|
||||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("sprite texture"),
|
|
||||||
layout,
|
|
||||||
entries: &[
|
|
||||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
|
||||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
+9
-10
@@ -1,12 +1,11 @@
|
|||||||
//! UI-Overlay: **state-driven** und **immediate-mode**.
|
//! UI-Overlay: **state-driven** und **immediate-mode**.
|
||||||
//!
|
//!
|
||||||
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält
|
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands und hält
|
||||||
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die
|
//! keinen eigenen mutierbaren Zustand, der davon desyncen könnte. Jeden
|
||||||
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem
|
//! Frame wird aus dem aktuellen [`Mode`] neu gebaut: Dialog → Panel +
|
||||||
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü →
|
//! Choice-Buttons, Menü → Menü-Buttons, usw. Klicks erzeugen denselben
|
||||||
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch
|
//! Eingabe-String, den auch CLI und stdin liefern, und laufen durch dasselbe
|
||||||
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein
|
//! `Session::exec` — ein Trichter in den State, kein UI-Backdoor.
|
||||||
//! einziger Trichter in den State, kein UI-Backdoor.
|
|
||||||
//!
|
//!
|
||||||
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
|
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
|
||||||
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
|
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
|
||||||
@@ -57,7 +56,7 @@ pub(crate) fn key_luminance(img: &Image) -> Image {
|
|||||||
pub(crate) struct Cursor {
|
pub(crate) struct Cursor {
|
||||||
/// Position in internen Pixeln (über die Letterbox gemappt).
|
/// Position in internen Pixeln (über die Letterbox gemappt).
|
||||||
pub(crate) pos: [f32; 2],
|
pub(crate) pos: [f32; 2],
|
||||||
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
/// Maus gefangen (Spiel oder Noclip) → First-Person-Pointer mittig statt
|
||||||
/// Maus-Cursor an `pos`.
|
/// Maus-Cursor an `pos`.
|
||||||
pub(crate) grabbed: bool,
|
pub(crate) grabbed: bool,
|
||||||
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
|
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
|
||||||
@@ -225,12 +224,12 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
|
|||||||
// 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
|
// 80-Spalten-VGA-Textmodus. CGA 8×8 bleibt für kleines HUD-Zeug.
|
||||||
let f = &fonts.ega;
|
let f = &fonts.ega;
|
||||||
|
|
||||||
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
// Bei gefangener Maus zählt die freie Sicht → kein Panel.
|
||||||
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
||||||
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
||||||
if !cur.grabbed {
|
if !cur.grabbed {
|
||||||
match &session.mode {
|
match &session.mode {
|
||||||
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen
|
// Spiel und Noclip: nur HUD/Cursor, kein Panel (beide laufen
|
||||||
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
|
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
|
||||||
Mode::FirstPerson | Mode::Free => {}
|
Mode::FirstPerson | Mode::Free => {}
|
||||||
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
||||||
|
|||||||
+78
-73
@@ -6,19 +6,15 @@ use crate::engine::player::{self, Player};
|
|||||||
use crate::engine::trigger::Triggers;
|
use crate::engine::trigger::Triggers;
|
||||||
use crate::engine::{kv, model, signals, story_ctrl};
|
use crate::engine::{kv, model, signals, story_ctrl};
|
||||||
|
|
||||||
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
|
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
|
||||||
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
|
const LOOK_SENS: f32 = 0.0025;
|
||||||
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
|
|
||||||
pub const LOOK_SENS: f32 = 0.0025;
|
|
||||||
|
|
||||||
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
|
/// Fester Simulationsschritt (s) = 60 Hz, unabhängig von der Render-Framerate:
|
||||||
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
|
/// Step-up und Ground-Snap verhalten sich sonst bei 144 Hz anders als in den
|
||||||
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
|
/// (bei 1/60 geschriebenen) Player-Tests.
|
||||||
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
|
|
||||||
const FIXED_DT: f32 = 1.0 / 60.0;
|
const FIXED_DT: f32 = 1.0 / 60.0;
|
||||||
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
|
/// Deckel für den Sim-Akkumulator (s): nach einem Hänger höchstens so viel
|
||||||
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
|
/// Zeit nachholen, sonst spiralt die Sim davon. ~15 Schritte pro Frame.
|
||||||
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
|
|
||||||
const MAX_ACCUM: f32 = 0.25;
|
const MAX_ACCUM: f32 = 0.25;
|
||||||
|
|
||||||
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
|
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
|
||||||
@@ -48,25 +44,26 @@ pub struct Session {
|
|||||||
/// Empties, siehe engine::audio).
|
/// Empties, siehe engine::audio).
|
||||||
emitters: Emitters,
|
emitters: Emitters,
|
||||||
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
|
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
|
||||||
/// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt
|
/// zieht sie per [`Session::take_sounds`]; der CLI reicht die
|
||||||
/// sie liegen (ihr reicht die `[action]`-Textzeile).
|
/// `[action]`-Textzeile.
|
||||||
pending_sounds: Vec<String>,
|
pending_sounds: Vec<String>,
|
||||||
signals_path: String,
|
signals_path: String,
|
||||||
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
||||||
sim_accum: f32,
|
sim_accum: f32,
|
||||||
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
|
/// Gepufferte Sprung-Flanke: bei hohen FPS fällt eine Sprungtaste sonst in
|
||||||
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
|
/// einen Frame ohne Sim-Schritt und verfällt.
|
||||||
/// nächsten Sub-Schritt konsumiert.
|
|
||||||
pending_jump: bool,
|
pending_jump: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
|
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
|
||||||
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
|
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
|
||||||
/// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1].
|
/// sind rohe Maus-Pixel, `fwd`/`right`/`up` Tastenachsen in [-1, 1]; `up`
|
||||||
|
/// wirkt nur im Noclip.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct FrameInput {
|
pub struct FrameInput {
|
||||||
pub fwd: f32,
|
pub fwd: f32,
|
||||||
pub right: f32,
|
pub right: f32,
|
||||||
|
pub up: f32,
|
||||||
pub jump: bool,
|
pub jump: bool,
|
||||||
pub look_dx: f32,
|
pub look_dx: f32,
|
||||||
pub look_dy: f32,
|
pub look_dy: f32,
|
||||||
@@ -103,13 +100,10 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
|
/// Sim-Seite eines Blender-Modells in die Welt einfügen (die Sichtgeometrie
|
||||||
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
|
/// ist Sache von render::props): Collider aus `collide`, Trigger aus
|
||||||
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
|
/// `signal`, Ambient-Emitter aus `sound` und der Spawn aus `role = spawn`.
|
||||||
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
|
/// Bringen mehrere Modelle einen Spawn mit, gewinnt der zuletzt geladene.
|
||||||
/// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
|
|
||||||
/// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
|
|
||||||
/// desselben Modells ist Sache des Renderers (render::props).
|
|
||||||
pub fn load_props(&mut self, model: &model::Model) {
|
pub fn load_props(&mut self, model: &model::Model) {
|
||||||
self.collision.add_model(model);
|
self.collision.add_model(model);
|
||||||
self.triggers.add_model(model);
|
self.triggers.add_model(model);
|
||||||
@@ -140,12 +134,20 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
||||||
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
|
/// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
|
||||||
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
|
/// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
|
||||||
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
|
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
|
||||||
/// zeigt sie wie `exec`-Ausgaben an.
|
/// zeigt sie wie `exec`-Ausgaben an.
|
||||||
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
|
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
|
||||||
if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); }
|
match self.mode {
|
||||||
|
Mode::FirstPerson => {}
|
||||||
|
Mode::Free => {
|
||||||
|
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
|
||||||
|
self.player.fly(input.fwd, input.right, input.up, dt);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
_ => return Vec::new(),
|
||||||
|
}
|
||||||
|
|
||||||
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
||||||
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
||||||
@@ -217,12 +219,13 @@ impl Session {
|
|||||||
|
|
||||||
fn command(&mut self, line: &str) -> ExecResult {
|
fn command(&mut self, line: &str) -> ExecResult {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
match line {
|
let (verb, args) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||||
"" => ExecResult::empty(),
|
match (verb, args.trim()) {
|
||||||
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
("", _) => ExecResult::empty(),
|
||||||
"menu" => {
|
("quit" | "exit", _) => ExecResult { output: Vec::new(), quit: true },
|
||||||
|
("menu", _) => {
|
||||||
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
|
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
|
||||||
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
|
// Spielmodus (Play), nicht im Noclip; aus Play oder
|
||||||
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
|
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
|
||||||
let t = match self.mode {
|
let t = match self.mode {
|
||||||
Mode::Menu => ModeTarget::Play,
|
Mode::Menu => ModeTarget::Play,
|
||||||
@@ -231,56 +234,44 @@ impl Session {
|
|||||||
self.set_mode(t);
|
self.set_mode(t);
|
||||||
ExecResult::empty()
|
ExecResult::empty()
|
||||||
}
|
}
|
||||||
"noclip" => {
|
("noclip", _) => {
|
||||||
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
|
|
||||||
// umschalten.
|
|
||||||
let t = match self.mode {
|
let t = match self.mode {
|
||||||
Mode::FirstPerson => ModeTarget::Free,
|
Mode::FirstPerson => ModeTarget::Free,
|
||||||
_ => ModeTarget::Play,
|
_ => ModeTarget::Play,
|
||||||
};
|
};
|
||||||
self.set_mode(t);
|
self.set_mode(t);
|
||||||
ExecResult::empty()
|
ExecResult::empty()
|
||||||
}
|
}
|
||||||
"help" => ExecResult::lines(help()),
|
("help", _) => ExecResult::lines(help()),
|
||||||
"kv" => ExecResult::lines(self.dump_kv()),
|
("kv", _) => ExecResult::lines(self.dump_kv()),
|
||||||
"reload" => {
|
("reload", _) => {
|
||||||
self.game.signals = signals::load_signals(&self.signals_path);
|
self.game.signals = signals::load_signals(&self.signals_path);
|
||||||
ExecResult::lines(vec![
|
ExecResult::lines(vec![
|
||||||
format!("{} Signale geladen.", self.game.signals.len()),
|
format!("{} Signale geladen.", self.game.signals.len()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
_ => {
|
// Modus über dieselbe Action wie das `mode`-Verb aus
|
||||||
if let Some(m) = line.strip_prefix("mode ") {
|
// signals.toml/Ink-Tags — ein Wechsel-Pfad für alle Quellen.
|
||||||
// Modus explizit setzen — über dieselbe Action wie das
|
("mode", m) => match ModeTarget::parse(m) {
|
||||||
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
|
Some(t) => {
|
||||||
// Signals genau einen Wechsel-Pfad teilen.
|
self.game.actions.push(Action::SetMode(t));
|
||||||
match ModeTarget::parse(m) {
|
ExecResult::lines(self.drain_actions())
|
||||||
Some(t) => {
|
|
||||||
self.game.actions.push(Action::SetMode(t));
|
|
||||||
ExecResult::lines(self.drain_actions())
|
|
||||||
}
|
|
||||||
None => ExecResult::lines(vec![
|
|
||||||
"Modus: play | free | menu".into(),
|
|
||||||
]),
|
|
||||||
}
|
|
||||||
} 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 (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 = 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![
|
|
||||||
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
|
||||||
|
},
|
||||||
|
("signal", sig) => ExecResult::lines(self.fire(sig, None)),
|
||||||
|
// Objekt-Interaktion (Klick wie Konsole): Klick-Ziele feuern ihre
|
||||||
|
// `signal`-Property, sonst den suffix-befreiten Objektnamen.
|
||||||
|
// `$self` ist immer der volle Name.
|
||||||
|
("use", name) => {
|
||||||
|
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())))
|
||||||
}
|
}
|
||||||
|
_ => ExecResult::lines(vec![
|
||||||
|
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
|
||||||
|
]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +389,7 @@ fn help() -> Vec<String> {
|
|||||||
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
||||||
" kv KV-Store anzeigen".into(),
|
" kv KV-Store anzeigen".into(),
|
||||||
" menu Menü öffnen / ins Spiel zurück".into(),
|
" menu Menü öffnen / ins Spiel zurück".into(),
|
||||||
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
|
" noclip zwischen First-Person und Noclip wechseln".into(),
|
||||||
" mode <m> Modus direkt setzen: play | free | menu".into(),
|
" mode <m> Modus direkt setzen: play | free | menu".into(),
|
||||||
" reload signals.toml neu laden".into(),
|
" reload signals.toml neu laden".into(),
|
||||||
" quit beenden".into(),
|
" quit beenden".into(),
|
||||||
@@ -525,13 +516,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tick_only_runs_in_first_person() {
|
fn menu_pauses_the_sim() {
|
||||||
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
|
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
s.mode = Mode::Menu;
|
s.mode = Mode::Menu;
|
||||||
let before = s.player.pos;
|
let before = s.player.pos;
|
||||||
s.tick(&FrameInput::default(), 0.1);
|
s.tick(&FrameInput::default(), 0.1);
|
||||||
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
|
assert_eq!(s.player.pos, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noclip_flies_the_player_without_physics() {
|
||||||
|
// Free bewegt denselben Spieler, nur ohne Welt: `up` steigt, und beim
|
||||||
|
// Zurückschalten steht er dort, wo er hingeflogen ist.
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.mode = Mode::Free;
|
||||||
|
s.tick(&FrameInput { up: 1.0, ..Default::default() }, 1.0);
|
||||||
|
assert!(s.player.pos[1] > 1.5, "sollte gestiegen sein: {}", s.player.pos[1]);
|
||||||
|
assert!(!s.player.grounded);
|
||||||
|
|
||||||
|
let flown_to = s.player.pos;
|
||||||
|
s.exec("noclip");
|
||||||
|
assert_eq!(s.player.pos, flown_to, "Noclip-Ende teleportiert nicht zurück");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
|
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
|
||||||
|
|||||||
Reference in New Issue
Block a user