365 lines
14 KiB
Rust
365 lines
14 KiB
Rust
//! 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
|
||
}
|