Files
wds/src/render/mod.rs
T

365 lines
14 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.
//!
//! 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<Image> = 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<Image> = 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::<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);
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<Arc<Window>>,
gpu: Option<Gpu>,
session: Session,
rx: Receiver<String>,
camera: Camera,
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,
}
impl App {
fn new(
session: Session,
rx: Receiver<String>,
mesh: Mesh,
images: Vec<Image>,
ui_textures: Vec<Image>,
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
}