//! 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
` 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 ` ü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 = 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 = 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> = 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 = 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::();
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 = 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 {
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>,
gpu: Option,
session: Session,
rx: Receiver,
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
eye_y: f32,
input: Input,
last: Option,
/// 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,
ui_textures: Vec,
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,
sfx_cache: HashMap>,
/// 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,
mesh: Mesh,
images: Vec,
ui_textures: Vec,
fonts: font::Fonts,
audio: Option,
) -> 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 = 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 `.
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
}