Files
wds/src/render/mod.rs
T
irrlichtandClaude Sonnet 5 b72b90c50d TGA-Loader raus, alles PNG
Der einzige verbliebene TGA-Nutzen — UI, Fonts, Platzhalter — läuft jetzt
über engine::image::load (derselbe PNG-Decoder wie für die .glb).
engine::tga ist damit weg.

- image::load(path): PNG von der Platte, Format per Signatur.
- Fonts/Cursor/Ornamente/Platzhalter als .png (Fonts + UI neu aus dem
  Quellmaterial exportiert, placeholder aus der alten .tga konvertiert).
- Test loads_the_real_project_assets deckt die fünf Projekt-Assets ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
2026-09-02 00:33:04 +02:00

530 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
//!
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
//! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
//!
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt.
mod audio;
mod font;
mod gpu;
mod math;
mod post;
mod props;
mod scene;
mod sprite;
mod ui;
use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver};
use std::sync::Arc;
use std::time::Instant;
use winit::application::ApplicationHandler;
use winit::event::{DeviceEvent, DeviceId, ElementState, KeyEvent, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{CursorGrabMode, Window, WindowId};
use crate::engine::image::{self, Image};
use crate::engine::player;
use crate::engine::{assets, gltf, model, wav};
use crate::session::{FrameInput, Mode, Session};
use gpu::Gpu;
use math::Mat4;
use scene::Mesh;
/// 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
/// folgen. Größer = weicher, aber träger.
const EYE_SMOOTH_TAU: f32 = 0.06;
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
const EYE_MAX_LAG: f32 = 0.6;
/// Default-Verzeichnis der Welt-`.glb` (relativ zum Asset-Basisverzeichnis).
/// `main` löst es auf und lässt `--maps <dir>` es überschreiben.
pub const DEFAULT_MAPS_DIR: &str = "assets/maps/props";
pub fn run(mut session: Session, maps_dir: &str) {
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
// Dialog öffnen — dann startet die Welt eben pausiert).
for line in session.start() { println!("{line}"); }
// Die Welt sind alle .glb unter `maps_dir` (Default DEFAULT_MAPS_DIR, per
// `--maps <dir>` überschrieben). Decode (CPU) hier, Upload (GPU) später in
// `Gpu` — die beiden bleiben getrennt.
let models = load_models(maps_dir);
// Eine geteilte Textur-Schlüsselliste über alle Modelle, damit ihre
// Meshes denselben Index-Raum benutzen und zu einem Buffer verschmelzen.
let mut tex_names: Vec<String> = Vec::new();
for (_, m) in &models { tex_names.extend(props::texture_keys(m)); }
tex_names.sort_unstable();
tex_names.dedup();
// Die Pixel liegen in den `.glb` selbst; gleicher Schlüssel = eine Textur
// (erstes Vorkommen gewinnt). Schlüssel ohne Bild treffen den Platzhalter
// (Projekt-Asset, darf hart scheitern).
let mut by_key: HashMap<&str, &Image> = HashMap::new();
for (_, m) in &models {
for t in &m.textures {
if let Some(img) = &t.image { by_key.entry(props::texture_key(t)).or_insert(img); }
}
}
let placeholder = placeholder();
let images: Vec<Image> = tex_names.iter()
.map(|k| by_key.get(k.as_str()).map_or_else(|| placeholder.clone(), |i| (*i).clone()))
.collect();
let mut mesh = Mesh::default();
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
println!("[scene] {} Modelle, Texturen {:?} → {} Vertices, {} Dreiecke",
models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);
// Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session
// einspielen — die Simulation gehört der Session, nicht dem
// Render-Frontend.
for (_, m) in &models { session.load_props(m); }
// Audio-Treiber öffnen (vor dem stdin-Thread, siehe Audio::open) und je
// Ambient-Emitter eine Loop-Voice anlegen; die Gains zieht redraw()
// dann jeden Frame aus der Session. Ohne Gerät läuft das Spiel stumm.
let audio = audio::Audio::open();
if let Some(a) = &audio {
let sounds: Vec<Option<audio::Sound>> = session.emitters().iter()
.map(|e| load_sound(&e.sound))
.collect();
a.set_ambients(&sounds);
}
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
// und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
// Installation kaputt und es darf scheitern.
let load_keyed = |p: &str| {
ui::key_luminance(&image::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
};
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.png");
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.png");
let fonts = font::Fonts {
ega: font::Font::ega(ui::FONT_EGA, &ega_img),
cga: font::Font::cga(ui::FONT_CGA, &cga_img),
};
let ui_textures: Vec<Image> = vec![
ui::white_pixel(),
ega_img,
cga_img,
load_keyed("assets/textures/ui/cursors.png"),
load_keyed("assets/textures/ui/panel_ornaments.png"),
];
// stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
// (winit) nicht. Es queren nur Strings die Thread-Grenze, kein Zustand
// — der bleibt allein auf dem Main-Thread.
let (tx, rx) = mpsc::channel::<String>();
std::thread::spawn(move || {
let stdin = std::io::stdin();
let mut buf = String::new();
loop {
buf.clear();
match stdin.read_line(&mut buf) {
Ok(0) | Err(_) => break, // EOF (Ctrl-D) → Thread endet
Ok(_) => {
if tx.send(buf.trim_end().to_string()).is_err() { break; }
}
}
}
});
let event_loop = EventLoop::new().expect("winit: Event-Loop");
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
// pollen nebenbei den Befehls-Channel.
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App::new(session, rx, mesh, images, ui_textures, fonts, audio);
event_loop.run_app(&mut app).expect("winit: run");
}
/// Alle Blender-Modelle (`.glb`) eines Verzeichnisses laden (sortiert →
/// deterministische Reihenfolge). Fehlende Verzeichnisse sind kein Fehler
/// (kein Props-Ordner = keine Props); kaputte Dateien werden gemeldet und
/// übersprungen.
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
let mut paths: Vec<std::path::PathBuf> = entries.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("glb"))
.collect();
paths.sort();
let mut models = Vec::new();
for p in paths {
let path = p.to_string_lossy();
match gltf::load(&path) {
Ok(m) => {
let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
m.objects.len(), m.empties.len());
// Properties mitloggen: so sieht die Autorin, ob ihr Export
// `collide`, `signal` und `role` mitgenommen hat.
for o in &m.objects {
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
}
for e in &m.empties {
println!("[model] Empty {} @ {:?}", e.name, e.pos);
for (k, v) in &e.props { println!("[model] {} · {k} = {v}", e.name); }
}
models.push((name, m));
}
Err(e) => eprintln!("[model] {e}"),
}
}
models
}
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> {
match wav::load(&assets::path(&format!("assets/sounds/{name}.wav"))) {
Ok(w) => Some(audio::Sound::new(w)),
Err(e) => {
eprintln!("[audio] {e}");
None
}
}
}
/// Die Platzhalter-Textur für Schlüssel ohne eigenes Bild in der `.glb`.
/// Projekt-Asset: fehlt sie, ist die Installation kaputt und es darf scheitern.
fn placeholder() -> Image {
let name = props::FALLBACK_TEXTURE;
image::load(&assets::path(&format!("assets/textures/{name}.png")))
.expect("Platzhalter-Textur (Projekt-Asset)")
}
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
/// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen
/// den Frames akkumuliert und nach dem Anwenden genullt.
#[derive(Default)]
struct Input {
fwd: bool,
back: bool,
left: bool,
right: bool,
up: bool,
down: bool,
/// Sprung-Flanke: bei Space-Druck gesetzt, nach `player::step` verbraucht.
jump: bool,
mouse_dx: f32,
mouse_dy: f32,
/// Cursor gefangen → Maus-Look aktiv.
grabbed: bool,
}
struct App {
window: Option<Arc<Window>>,
gpu: Option<Gpu>,
session: Session,
rx: Receiver<String>,
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
eye_y: f32,
input: Input,
last: Option<Instant>,
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
cursor_win: [f32; 2],
/// Linksklick steht aus, wird im nächsten redraw gegen das UI aufgelöst.
pending_click: bool,
/// CPU-seitige Welt-Geometrie + Texturen, in `resumed` einmal auf die
/// GPU geladen.
mesh: Mesh,
images: Vec<Image>,
ui_textures: Vec<Image>,
fonts: font::Fonts,
/// Audio-Treiber (`None` = kein Gerät, Spiel läuft stumm) und der
/// Lazy-Cache der Einmal-SFX (`None` = Laden schlug fehl, nur einmal
/// melden, nicht jeden Klick).
audio: Option<audio::Audio>,
sfx_cache: HashMap<String, Option<audio::Sound>>,
/// Post-Effekt-Kette (Renderer-Zustand, nicht im Kern). Pro Frame aus
/// `session.take_post()` nachgeführt, dann an `gpu.frame`.
post: post::PostChain,
}
impl App {
fn new(
session: Session,
rx: Receiver<String>,
mesh: Mesh,
images: Vec<Image>,
ui_textures: Vec<Image>,
fonts: font::Fonts,
audio: Option<audio::Audio>,
) -> Self {
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
Self {
window: None,
gpu: None,
session,
rx,
eye_y,
input: Input::default(),
last: None,
cursor_win: [0.0, 0.0],
pending_click: false,
mesh,
images,
ui_textures,
fonts,
audio,
sfx_cache: HashMap::new(),
// Die Post-Kette gehört dem Renderer, nicht dem Kern: geschaltet
// wird sie über `Action::SetPost` (Trigger/Ink/Konsole), die die
// Session als `take_post`-Queue durchreicht.
post: post::PostChain::defaults(),
}
}
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
// Greif-Art: Locked (Spiel/Noclip, relativer Blick) ↔ None (freie Maus, UI).
fn set_grab(&mut self, grab: bool) {
let Some(window) = &self.window else { return; };
if grab {
// Locked (Cursor fixiert) ist am angenehmsten, wird aber nicht
// überall unterstützt → auf Confined zurückfallen.
let ok = window.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
.is_ok();
if ok { self.input.grabbed = true; }
} else {
let _ = window.set_cursor_grab(CursorGrabMode::None);
self.input.grabbed = false;
}
}
}
impl ApplicationHandler for App {
/// winit liefert das Fenster erst hier, nicht beim Loop-Start —
/// deshalb sind `window`/`gpu` Options statt Konstruktor-Felder.
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() { return; }
let attrs = Window::default_attributes()
.with_title("wds")
// 2× interne Auflösung als Startgröße; frei resizebar.
.with_inner_size(winit::dpi::LogicalSize::new(
gpu::INTERNAL_W * 2, gpu::INTERNAL_H * 2));
let window = Arc::new(event_loop.create_window(attrs).expect("winit: Fenster"));
// OS-Cursor dauerhaft aus — das Overlay zeichnet immer einen eigenen.
window.set_cursor_visible(false);
self.gpu = Some(Gpu::new(
window.clone(),
event_loop.owned_display_handle(),
&self.mesh,
&self.images,
&self.ui_textures,
));
self.window = Some(window);
}
/// Rohe Maus-Bewegung (unabhängig von Cursor-Position) — nur sinnvoll,
/// solange der Cursor gefangen ist.
fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
if let DeviceEvent::MouseMotion { delta } = event
&& self.input.grabbed {
self.input.mouse_dx += delta.0 as f32;
self.input.mouse_dy += delta.1 as f32;
}
}
/// Läuft einmal pro Loop-Durchlauf, nachdem die Events abgearbeitet
/// sind: Konsolen-Befehle einspielen und den nächsten Frame anfordern.
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
while let Ok(line) = self.rx.try_recv() {
let r = self.session.exec(&line);
for out in r.output { println!("{out}"); }
if r.quit { event_loop.exit(); }
}
if let Some(w) = &self.window { w.request_redraw(); }
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::CursorMoved { position, .. } => {
self.cursor_win = [position.x as f32, position.y as f32];
}
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
// Klick nur vormerken; der nächste redraw entscheidet (mit der
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
// Maus-Greifen.
self.pending_click = true;
}
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
WindowEvent::Resized(size) => {
if let Some(gpu) = &mut self.gpu {
gpu.resize(size.width, size.height);
}
}
WindowEvent::RedrawRequested => self.redraw(event_loop),
_ => {}
}
}
}
impl App {
fn on_key(&mut self, event_loop: &ActiveEventLoop, key: KeyEvent) {
let PhysicalKey::Code(code) = key.physical_key else { return; };
let down = key.state == ElementState::Pressed;
// Escape togglet das Menü über denselben exec-Trichter wie Konsole
// und Klick. Der Lock-Zustand folgt danach automatisch dem Modus
// (siehe redraw). Im Dialog tut Escape nichts (Dialog endet über
// seine Choices).
if code == KeyCode::Escape && down {
if !matches!(self.session.mode, Mode::Dialog(_)) {
let r = self.session.exec("menu");
for out in r.output { println!("{out}"); }
if r.quit { event_loop.exit(); }
}
return;
}
match code {
KeyCode::KeyW => self.input.fwd = down,
KeyCode::KeyS => self.input.back = down,
KeyCode::KeyA => self.input.left = down,
KeyCode::KeyD => self.input.right = down,
// Space: im Noclip „hoch" (gehalten), im Spiel Sprung (Flanke,
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
KeyCode::Space => {
self.input.up = down;
if down && !key.repeat { self.input.jump = true; }
}
KeyCode::ShiftLeft => self.input.down = down,
_ => {}
}
}
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
let now = Instant::now();
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
// Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
// (relativer Blick), Menü/Dialog geben sie frei fürs UI.
let fly = matches!(self.session.mode, Mode::Free);
let play = matches!(self.session.mode, Mode::FirstPerson);
let grab = fly || play;
if grab != self.input.grabbed { self.set_grab(grab); }
// Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
// die Session an ihrem Modus. Betretens-Zonen melden ihre
// Signal-Ausgaben wie exec-Befehle.
let input = FrameInput {
fwd: axis(self.input.fwd, self.input.back),
right: axis(self.input.right, self.input.left),
up: axis(self.input.up, self.input.down),
jump: self.input.jump,
look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy,
};
for out in self.session.tick(&input, dt) { println!("{out}"); }
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
// Blick beim Fortsetzen um die aufgestaute Bewegung.
self.input.mouse_dx = 0.0;
self.input.mouse_dy = 0.0;
self.input.jump = false;
// Audio: Distance ramps der Ambient-Emitter nachführen und
// aufgelaufene Einmal-SFX (play_sound-Actions) starten. Die Queue
// immer leeren, auch ohne Gerät — sonst wüchse sie unbegrenzt.
let sfx = self.session.take_sounds();
if let Some(a) = &self.audio {
a.set_ambient_gains(&self.session.emitter_gains());
for name in sfx {
let sound = self.sfx_cache.entry(name.clone())
.or_insert_with(|| load_sound(&name));
if let Some(s) = sound { a.play(s); }
}
}
// Post-Effekt-Schaltungen (SetPost-Actions von Trigger/Ink/Konsole)
// auf die eigene Kette anwenden; unbekannter Name wird gemeldet, wie
// ein fehlendes WAV bei play_sound.
for (name, on) in self.session.take_post() {
if !self.post.set(&name, on) {
eprintln!("[post] unbekannter Effekt {name:?}");
}
}
// Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
// der Luft und außerhalb des Spiels folgt sie exakt.
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
if play && self.session.player.grounded {
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
self.eye_y += (target_eye - self.eye_y) * a;
self.eye_y = self.eye_y.clamp(target_eye - EYE_MAX_LAG, target_eye + EYE_MAX_LAG);
} else {
self.eye_y = target_eye;
}
// Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
// nur die Sim ein, nicht die Sicht.
let p = self.session.player.pos;
let view = Mat4::view(
[p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch);
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
// löst beim Klick unten das `use` aus.
let aim: Option<String> = if play {
let eye = self.session.player.eye();
let dir = self.session.player.look_dir();
self.session.pick(eye, dir, crate::session::INTERACT_REACH).map(str::to_string)
} else {
None
};
// Mausposition über die Letterbox in interne Pixel mappen.
let cursor = ui::Cursor {
pos: self.gpu.as_ref()
.map(|g| g.map_cursor(self.cursor_win))
.unwrap_or([0.0, 0.0]),
grabbed: self.input.grabbed,
hud_interact: aim.is_some(),
};
// Overlay state-driven bauen (verzweigt nach session.mode); die
// Hover-Aktion kommt aus der Maus-Position.
let screen = ui::layout(
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
);
// Vorgemerkten Klick auflösen — beides durch denselben exec-Trichter
// wie die Konsole: über einem UI-Klickziel dessen Aktion, sonst im
// Spielmodus das Fadenkreuz-Ziel als `use <name>`.
if self.pending_click {
self.pending_click = false;
let action = screen.hover_action.clone()
.or_else(|| aim.map(|name| format!("use {name}")));
if let Some(action) = action {
let r = self.session.exec(&action);
for out in r.output { println!("{out}"); }
if r.quit { event_loop.exit(); }
}
}
if let Some(gpu) = &mut self.gpu {
gpu.frame(&view, &screen.ui, &self.post);
}
}
}
/// Zwei gegensätzliche Tasten zu einer Achse in {-1, 0, 1}.
fn axis(positive: bool, negative: bool) -> f32 {
(positive as i32 - negative as i32) as f32
}