//! 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, //! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und //! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die //! Welt (Sim-Uhr und Kamera stehen) — 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 brush; mod camera; mod font; mod gpu; mod math; mod scene; mod sprite; mod ui; 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::tga::Image; use crate::engine::{assets, map, tga}; use crate::session::{Mode, Session}; use camera::Camera; use gpu::Gpu; use scene::Mesh; /// Radiant pro Maus-Pixel. const MOUSE_SENS: f32 = 0.0025; /// Welt-Einheiten pro Sekunde. const MOVE_SPEED: f32 = 4.0; pub fn run(mut session: Session) { // 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}"); } // CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen // auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben // getrennt. let world = map::load(&assets::path("assets/maps/test.map")); let tex_names = brush::texture_names(&world); let images: Vec = tex_names.iter() .map(|n| tga::load(&assets::path(&format!("assets/textures/{n}.tga")))) .collect(); let dims: Vec<(u32, u32)> = images.iter().map(|i| (i.width, i.height)).collect(); let mesh = brush::build(&world, &tex_names, &dims); report_map(&world, &tex_names, &mesh); // UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA, // FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und // Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der // Luminanz. let load_keyed = |p: &str| ui::key_luminance(&tga::load(&assets::path(p))); // 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.tga"); let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.tga"); 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.tga"), load_keyed("assets/textures/ui/panel_ornaments.tga"), ]; // 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); event_loop.run_app(&mut app).expect("winit: run"); } /// Kurzer Lade-Report: Map-Inhalt und resultierende Geometriegröße. fn report_map(world: &map::Map, tex_names: &[String], mesh: &Mesh) { let brushes: usize = world.entities.iter().map(|e| e.brushes.len()).sum(); let faces: usize = world.entities.iter() .flat_map(|e| &e.brushes) .map(|b| b.faces.len()) .sum(); println!( "[map] {} Entities, {} Brushes, {} Faces, Texturen {:?} → {} Vertices, {} Dreiecke", world.entities.len(), brushes, faces, tex_names, mesh.verts.len(), mesh.indices.len() / 3, ); } /// 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, mouse_dx: f32, mouse_dy: f32, /// Cursor gefangen → Maus-Look aktiv. grabbed: bool, } struct App { window: Option>, gpu: Option, session: Session, rx: Receiver, camera: Camera, 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, } impl App { fn new( session: Session, rx: Receiver, mesh: Mesh, images: Vec, ui_textures: Vec, fonts: font::Fonts, ) -> Self { Self { window: None, gpu: None, session, rx, // Etwas zurück und erhöht, Blick Richtung Welt-Ursprung. camera: Camera::new([0.0, 1.0, 4.0]), input: Input::default(), last: None, cursor_win: [0.0, 0.0], pending_click: false, mesh, images, ui_textures, fonts, } } // Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) — // wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die // Greif-Art: Locked (Flycam, 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 { if 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 // Flycam-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, KeyCode::Space => self.input.up = down, KeyCode::ShiftLeft => self.input.down = down, _ => {} } } 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 dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32()); // Lock-Zustand folgt dem Modus: nur in Free ist die Flycam aktiv // (Maus gefangen, relativer Blick). Menü/Dialog geben die Maus frei // fürs UI. So gibt es keine parallele Lock-Logik mehr. let fly = matches!(self.session.mode, Mode::Free); if fly != self.input.grabbed { self.set_grab(fly); } if fly { self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS); let fwd = axis(self.input.fwd, self.input.back); let right = axis(self.input.right, self.input.left); let up = axis(self.input.up, self.input.down); self.camera.translate(fwd, right, up, MOVE_SPEED * dt); } // Delta 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; let view = self.camera.view(); // 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, }; // 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: über einem Klickziel → dessen Aktion // durch denselben exec-Trichter wie die Konsole. Klick in der freien // Welt tut (noch) nichts — hier käme später der `use`-Raycast. if self.pending_click { self.pending_click = false; if let Some(action) = screen.hover_action.clone() { 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); } } } /// 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 }