//! 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 }