idk shitload of stuff

This commit is contained in:
2026-06-13 18:18:17 +02:00
parent 48c499e356
commit 6bb9b009c4
24 changed files with 3221 additions and 119 deletions
+234 -34
View File
@@ -1,41 +1,88 @@
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
//!
//! Stand: Schritt 3 des Renderer-Plans (notes/renderer-plan.md) —
//! internes 320×240-Target, PS1-Szenen-Pass (Pixel-Snap, affine
//! Interpolation, RGB555+Bayer) am Testwürfel, Letterbox-Blit.
//! Als Nächstes: Flycam (Schritt 4), dann OBJ-Szene (Schritt 5).
//! 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 (die Sim-Uhr läuft nicht weiter) — so sieht das Fenster nie einen
//! 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::{ElementState, KeyEvent, WindowEvent};
use winit::event::{DeviceEvent, DeviceId, ElementState, KeyEvent, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{Window, WindowId};
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.
@@ -58,25 +105,101 @@ pub fn run(mut session: Session) {
// 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);
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,
/// Befehlszeilen vom stdin-Thread.
rx: Receiver<String>,
/// Sim-Zeit (Sekunden), läuft nur im `Free`-Modus weiter → Welt-Pause
/// im Dialog. Treibt aktuell die Orbit-Kamera (bis Flycam, Schritt 4).
sim_t: f32,
camera: Camera,
input: Input,
last: Option<Instant>,
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
cursor_win: [f32; 2],
/// 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>) -> Self {
Self { window: None, gpu: None, session, rx, sim_t: 0.0, last: None }
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],
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;
}
}
}
@@ -87,14 +210,33 @@ impl ApplicationHandler for App {
if self.window.is_some() { return; }
let attrs = Window::default_attributes()
.with_title("wds")
// 3× interne Auflösung als Startgröße; frei resizebar.
// 2× interne Auflösung als Startgröße; frei resizebar.
.with_inner_size(winit::dpi::LogicalSize::new(
gpu::INTERNAL_W * 3, gpu::INTERNAL_H * 3));
gpu::INTERNAL_W * 2, gpu::INTERNAL_H * 2));
let window = Arc::new(event_loop.create_window(attrs).expect("winit: Fenster"));
self.gpu = Some(Gpu::new(window.clone(), event_loop.owned_display_handle()));
// 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) {
@@ -109,29 +251,87 @@ impl ApplicationHandler for App {
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::KeyboardInput {
event: KeyEvent {
physical_key: PhysicalKey::Code(KeyCode::Escape),
state: ElementState::Pressed, ..
}, ..
} => 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, .. } => {
// Nur im freien Modus greift der Klick die Maus (Flycam). Im
// UI-/Dialog-Modus bleibt die Maus frei (für Klicks aufs UI).
if matches!(self.session.mode, Mode::Free) { self.set_grab(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 => {
// dt messen; im Dialog die Sim-Uhr 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());
if matches!(self.session.mode, Mode::Free) {
self.sim_t += dt;
}
if let Some(gpu) = &mut self.gpu { gpu.frame(self.sim_t); }
}
WindowEvent::RedrawRequested => self.redraw(),
_ => {}
}
}
}
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: erst den Cursor freigeben, sonst beenden.
if code == KeyCode::Escape && down {
if self.input.grabbed { self.set_grab(false); } else { 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) {
// 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());
if matches!(self.session.mode, Mode::Free) {
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);
} else if self.input.grabbed {
// Nicht-Free (Dialog/Menü) → Maus freigeben, damit das UI klickbar
// ist und der Maus-Cursor statt des HUD-Pointers erscheint.
self.set_grab(false);
}
// 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 (Stage 3: Demo-Panel + Cursor).
let overlay = ui::layout(
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &cursor,
);
if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &overlay); }
}
}
/// 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
}