diff --git a/Cargo.lock b/Cargo.lock index c384b06..7afbeb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2145,6 +2145,7 @@ name = "wds" version = "0.1.0" dependencies = [ "bladeink", + "bytemuck", "pollster", "wgpu", "winit", diff --git a/Cargo.toml b/Cargo.toml index 4d9638d..736602e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] bladeink = "1.2.5" +bytemuck = { version = "1.25.0", features = ["derive"] } pollster = "0.4.0" wgpu = "29.0.3" winit = "0.30.13" diff --git a/src/cli.rs b/src/cli.rs index 58bb613..13c9bef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,126 +1,34 @@ -//! Konsolen-REPL — das erste Frontend des Kerns. +//! Konsolen-REPL — Headless-Treiber für Tests und Betrieb ohne GPU. //! -//! Konsumiert die `engine`-Schnittstellen: Signale via `signals::dispatch`, -//! Dialoge via `story_ctrl::advance`, deferred Actions aus `game.actions`. -//! Der Renderer wird später dieselben Schnittstellen konsumieren; dieses -//! Modul bleibt daneben als Debug-Werkzeug bestehen. +//! Liest Zeilen von stdin, gibt sie an `Session::exec` und druckt die +//! Ausgabe. Befehls- und Dialoglogik liegen in `session`, damit das +//! Fenster-Frontend dieselbe Session ohne Duplikat steuern kann. use std::io::{self, Write}; -use crate::engine::game::{Action, Game}; -use crate::engine::ink::StoryState; -use crate::engine::{kv, signals, story_ctrl}; +use crate::session::{Mode, Session}; -/// REPL-Hauptschleife. Übernimmt das Game, feuert das reservierte -/// `[init]`-Signal (KV-Defaults, bevor Stories laufen) und liest dann -/// Befehle bis `quit`/EOF. `signals_path` wird für `reload` gebraucht. -pub fn run(mut game: Game, signals_path: &str) { +pub fn run(mut session: Session, signals_path: &str) { println!("wds-Konsole — {} Signale aus {}. `help` für Befehle.", - game.signals.len(), signals_path); + session.game.signals.len(), signals_path); - fire(&mut game, "init", None); + // Reserviertes `[init]`-Signal: KV-Defaults setzen, bevor Stories laufen. + for line in session.start() { println!("{line}"); } - while let Some(line) = read_line("> ") { - let line = line.trim(); - match line { - "" => {} - "quit" | "exit" => break, - "help" => print_help(), - "kv" => dump_kv(&game), - "reload" => { - game.signals = signals::load_signals(signals_path); - println!("{} Signale geladen.", game.signals.len()); - } - _ => { - if let Some(sig) = line.strip_prefix("signal ") { - fire(&mut game, sig.trim(), None); - } else if let Some(name) = line.strip_prefix("use ") { - // Objekt-Interaktion simulieren: Signal ist der gestrippte - // Name, $self der volle — wie der LMB-Klick-Pfad in irl3d. - let name = name.trim(); - let key = signals::signal_key(name).to_string(); - fire(&mut game, &key, Some(name.to_string())); - } else { - println!("unbekannter Befehl: {line:?} — `help` für Befehle"); - } - } - } - } -} - -fn print_help() { - println!(" signal Signal feuern oder Action direkt ausführen"); - println!(" (z.B. `signal Cube`, `signal set has_key true`)"); - println!(" use Objekt-Interaktion simulieren — `use Mushroom.005`"); - println!(" feuert Signal `Mushroom` mit $self = Mushroom.005"); - println!(" kv KV-Store anzeigen"); - println!(" reload signals.toml neu laden"); - println!(" quit beenden"); -} - -/// Signal dispatchen, deferred Actions ausgeben und — falls eine Action -/// eine Story gestartet hat — den Dialog-Loop fahren. -fn fire(game: &mut Game, signal: &str, instance: Option) { - { - let mut ctx = game.action_ctx(instance); - signals::dispatch(signal, &mut ctx); - } - drain_actions(game); - if game.story.is_some() { - run_story(game); - } -} - -/// Dialog-Loop: Story schrittweise treiben, Tags zurück in den Dispatcher, -/// Choices über nummerierte Eingabe. Läuft bis `End` (oder EOF). -fn run_story(game: &mut Game) { - let mut sel: Option = None; loop { - let state = { - let mut ctx = game.action_ctx(None); - let (state, tags) = story_ctrl::advance(sel, &mut ctx); - for t in &tags { signals::dispatch(t, &mut ctx); } - state - }; - drain_actions(game); - match state { - None | Some(StoryState::End) => break, - Some(StoryState::Text(text)) => { - println!("\n{text}"); - if read_line(" [Enter] ").is_none() { break; } - sel = None; - } - Some(StoryState::Choice { prompt, options }) => { - if !prompt.is_empty() { println!("\n{prompt}"); } - for (i, o) in options.iter().enumerate() { - println!(" {}) {o}", i + 1); - } - sel = loop { - let Some(line) = read_line(" wahl> ") else { return; }; - if let Ok(n) = line.trim().parse::() { - if (1..=options.len()).contains(&n) { break Some(n - 1); } - } - println!(" (1..{})", options.len()); - }; - } - } + let Some(input) = read_line(prompt_for(&session.mode)) else { break }; + let r = session.exec(input.trim()); + for line in r.output { println!("{line}"); } + if r.quit { break; } } } -fn drain_actions(game: &mut Game) { - for a in game.actions.drain(..) { - match a { - Action::HideObject(n) => println!("[action] hide_object {n}"), - Action::PlaySound(n) => println!("[action] play_sound {n}"), - } - } -} - -fn dump_kv(game: &Game) { - let mut keys: Vec<&String> = game.kv.keys().collect(); - keys.sort(); - for k in keys { - println!(" {k} = {}", kv::format_value(&game.kv[k])); +/// Prompt je nach Zustand: Befehl, Dialog-Weiterblättern oder Choice-Wahl. +fn prompt_for(mode: &Mode) -> &'static str { + match mode { + Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ", + Mode::Dialog(_) => " [Enter] ", + Mode::Free => "> ", } } diff --git a/src/main.rs b/src/main.rs index 22caee3..a34fd87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,25 @@ //! wds — Weirdcore Dating Simulator. //! //! Aufbau: `engine/` ist der headless Kern (Signal/Action-Dispatcher, -//! KV-Store, Ink-Stories); Frontends konsumieren ihn über `StoryState` -//! und die `Action`-Queue. Frontends: das Fenster (`render`, Default) -//! und die Konsolen-REPL (`cli`, via `--cli`). +//! KV-Store, Ink-Stories); `session` hält den geteilten Anwendungszustand +//! darüber (Game + Dialog-Modus + Befehls-Interpreter). Beide Frontends +//! steuern dieselbe Session: das Fenster (`render`, Default) und die +//! Konsolen-REPL (`cli`, via `--cli`). mod cli; mod engine; mod render; +mod session; -use engine::game::Game; -use engine::{assets, signals}; +use engine::assets; +use session::Session; fn main() { + let signals_path = assets::path("assets/signals.toml"); + let session = Session::new(signals_path.clone()); if std::env::args().any(|a| a == "--cli") { - let signals_path = assets::path("assets/signals.toml"); - let game = Game::new(signals::load_signals(&signals_path)); - cli::run(game, &signals_path); + cli::run(session, &signals_path); } else { - render::run(); + render::run(session); } } diff --git a/src/render/gpu.rs b/src/render/gpu.rs index 20577e8..ddeb397 100644 --- a/src/render/gpu.rs +++ b/src/render/gpu.rs @@ -1,9 +1,8 @@ //! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad //! aus dem Renderer-Plan: //! -//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild. -//! Aktuell nur ein Testmuster-Shader; Schritt 3 -//! ersetzt ihn durch die Szenen-Pipeline. +//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild +//! ([`ScenePass`], PS1-Shader). //! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit //! 4:3-Letterbox (via Viewport) auf die Surface. @@ -11,8 +10,11 @@ use std::sync::Arc; use winit::window::Window; -pub const INTERNAL_W: u32 = 320; -pub const INTERNAL_H: u32 = 240; +use crate::render::math::Mat4; +use crate::render::scene::ScenePass; + +pub const INTERNAL_W: u32 = 480; +pub const INTERNAL_H: u32 = 360; /// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall /// (auch GL-Fallback) unterstützte Depth-Format. @@ -28,9 +30,9 @@ pub struct Gpu { internal_view: wgpu::TextureView, depth_view: wgpu::TextureView, - pattern_pipeline: wgpu::RenderPipeline, - blit_pipeline: wgpu::RenderPipeline, - blit_bind: wgpu::BindGroup, + scene: ScenePass, + blit_pipeline: wgpu::RenderPipeline, + blit_bind: wgpu::BindGroup, } impl Gpu { @@ -84,40 +86,8 @@ impl Gpu { let (internal_view, depth_view) = make_internal_targets(&device); - // Pass 1: Testmuster auf das interne Target. - let pattern_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("pattern"), - source: wgpu::ShaderSource::Wgsl(include_str!("pattern.wgsl").into()), - }); - let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("pattern"), - layout: None, - vertex: wgpu::VertexState { - module: &pattern_shader, - entry_point: Some("vs_main"), - compilation_options: Default::default(), - buffers: &[], - }, - fragment: Some(wgpu::FragmentState { - module: &pattern_shader, - entry_point: Some("fs_main"), - compilation_options: Default::default(), - targets: &[Some(INTERNAL_FORMAT.into())], - }), - primitive: wgpu::PrimitiveState::default(), - // Depth hängt am Pass (Schritt 3 braucht es); das Muster - // selbst schreibt/testet nicht. - depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH_FORMAT, - depth_write_enabled: Some(false), - depth_compare: Some(wgpu::CompareFunction::Always), - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + // Pass 1: die Szene. + let scene = ScenePass::new(&device, INTERNAL_FORMAT, DEPTH_FORMAT); // Pass 2: internes Target nearest-gesampelt auf die Surface. let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -169,7 +139,7 @@ impl Gpu { Self { surface, device, queue, config, internal_view, depth_view, - pattern_pipeline, blit_pipeline, blit_bind, + scene, blit_pipeline, blit_bind, } } @@ -179,7 +149,21 @@ impl Gpu { self.surface.configure(&self.device, &self.config); } - pub fn frame(&mut self) { + /// Einen Frame rendern. `t` = Sekunden seit Start (treibt vorerst + /// die Orbit-Kamera; ab Schritt 4 kommt die Kamera vom Aufrufer). + pub fn frame(&mut self, t: f32) { + // Hartkodierte Orbit-Kamera um den Testwürfel — macht Vertex-Snap + // und Dither in Bewegung sichtbar. Die Flycam ersetzt sie. + let yaw = t * 0.4; + let eye = [2.2 * yaw.sin(), 1.3, 2.2 * yaw.cos()]; + let view = Mat4::view(eye, yaw, -0.5); + let proj = Mat4::perspective( + 60f32.to_radians(), + INTERNAL_W as f32 / INTERNAL_H as f32, + 0.1, 100.0, + ); + self.scene.prepare(&self.queue, &proj.mul(&view)); + use wgpu::CurrentSurfaceTexture as Cst; let frame = match self.surface.get_current_texture() { Cst::Success(f) | Cst::Suboptimal(f) => f, @@ -221,8 +205,7 @@ impl Gpu { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&self.pattern_pipeline); - pass.draw(0..3, 0..1); + self.scene.draw(&mut pass); } // Pass 2: Letterbox-Blit aufs Fenster. diff --git a/src/render/math.rs b/src/render/math.rs new file mode 100644 index 0000000..a570d2c --- /dev/null +++ b/src/render/math.rs @@ -0,0 +1,127 @@ +//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt +//! statt glam: PS1-Style braucht nur Multiply, View und Perspective. +//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier +//! ist nur der GPU-Pfad.) +//! +//! Konventionen: +//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export) +//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`, +//! bytemuck-kompatibel zu WGSL `mat4x4f` +//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1]) + +#[repr(C)] +#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Mat4(pub [[f32; 4]; 4]); + +impl Mat4 { + pub const IDENT: Mat4 = Mat4([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]); + + pub fn mul(&self, rhs: &Mat4) -> Mat4 { + let mut out = [[0.0f32; 4]; 4]; + for c in 0..4 { + for r in 0..4 { + out[c][r] = (0..4).map(|k| self.0[k][r] * rhs.0[c][k]).sum(); + } + } + Mat4(out) + } + + /// Punkt-Transformation auf der CPU — bisher nur von den Tests + /// gebraucht; der Renderer transformiert auf der GPU. + #[cfg(test)] + pub fn transform(&self, v: [f32; 4]) -> [f32; 4] { + let mut out = [0.0f32; 4]; + for r in 0..4 { + out[r] = (0..4).map(|k| self.0[k][r] * v[k]).sum(); + } + out + } + + pub fn translate(x: f32, y: f32, z: f32) -> Mat4 { + let mut m = Mat4::IDENT; + m.0[3] = [x, y, z, 1.0]; + m + } + + pub fn rot_x(a: f32) -> Mat4 { + let (s, c) = a.sin_cos(); + let mut m = Mat4::IDENT; + m.0[1] = [0.0, c, s, 0.0]; + m.0[2] = [0.0, -s, c, 0.0]; + m + } + + pub fn rot_y(a: f32) -> Mat4 { + let (s, c) = a.sin_cos(); + let mut m = Mat4::IDENT; + m.0[0] = [c, 0.0, -s, 0.0]; + m.0[2] = [s, 0.0, c, 0.0]; + m + } + + /// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch + /// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z, + /// positiver Pitch hebt den Blick. + pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 { + Mat4::rot_x(-pitch) + .mul(&Mat4::rot_y(-yaw)) + .mul(&Mat4::translate(-pos[0], -pos[1], -pos[2])) + } + + /// Perspektive mit Clip-Z in [0,1]. `fovy` in Radiant. + pub fn perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> Mat4 { + let f = 1.0 / (fovy * 0.5).tan(); + Mat4([ + [f / aspect, 0.0, 0.0, 0.0], + [0.0, f, 0.0, 0.0], + [0.0, 0.0, far / (near - far), -1.0], + [0.0, 0.0, near * far / (near - far), 0.0], + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) + } + + #[test] + fn view_translates_origin_in_front() { + // Kamera bei z=+5, Blick -Z → Origin liegt 5 vor der Kamera. + let v = Mat4::view([0.0, 0.0, 5.0], 0.0, 0.0); + assert!(close(v.transform([0.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -5.0, 1.0])); + } + + #[test] + fn view_yaw_quarter_turn_looks_minus_x() { + // yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf + // -X liegt dann vor der Kamera. + let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0); + assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0])); + } + + #[test] + fn view_pitch_up_looks_plus_y() { + let v = Mat4::view([0.0, 0.0, 0.0], 0.0, std::f32::consts::FRAC_PI_2); + assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0])); + } + + #[test] + fn perspective_maps_near_far_to_0_1() { + let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0); + let n = p.transform([0.0, 0.0, -0.1, 1.0]); + let f = p.transform([0.0, 0.0, -100.0, 1.0]); + assert!((n[2] / n[3] - 0.0).abs() < 1e-5); + assert!((f[2] / f[3] - 1.0).abs() < 1e-4); + // w = Abstand vor der Kamera + assert!((n[3] - 0.1).abs() < 1e-6 && (f[3] - 100.0).abs() < 1e-4); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index f40e0a2..8dddb3d 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -1,15 +1,26 @@ //! Fenster-Frontend: winit-Loop um den wgpu-Renderer. //! -//! Stand: Schritt 1+2 des Renderer-Plans (notes/renderer-plan.md) — -//! Fenster, internes 320×240-Target mit Testmuster, Nearest-Upscale mit -//! 4:3-Letterbox. Szene, PS1-Shader und Flycam folgen als nächste Schritte. +//! 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). //! -//! Gleiche Schicht-Regel wie `cli`: Geschwister von `engine`, konsumiert -//! dessen Schnittstellen (sobald hier eine Szene läuft) — nie umgekehrt. +//! 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 +//! anderen Zustand als die Konsole. +//! +//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert +//! deren Schnittstellen — nie umgekehrt. mod gpu; +mod math; +mod scene; +use std::sync::mpsc::{self, Receiver}; use std::sync::Arc; +use std::time::Instant; use winit::application::ApplicationHandler; use winit::event::{ElementState, KeyEvent, WindowEvent}; @@ -17,20 +28,56 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::keyboard::{KeyCode, PhysicalKey}; use winit::window::{Window, WindowId}; +use crate::session::{Mode, Session}; use gpu::Gpu; -pub fn run() { +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}"); } + + // 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). + // 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::default(); + let mut app = App::new(session, rx); event_loop.run_app(&mut app).expect("winit: run"); } -#[derive(Default)] struct App { - window: Option>, - gpu: Option, + window: Option>, + gpu: Option, + session: Session, + /// Befehlszeilen vom stdin-Thread. + rx: Receiver, + /// 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, + last: Option, +} + +impl App { + fn new(session: Session, rx: Receiver) -> Self { + Self { window: None, gpu: None, session, rx, sim_t: 0.0, last: None } + } } impl ApplicationHandler for App { @@ -48,6 +95,17 @@ impl ApplicationHandler for App { self.window = Some(window); } + /// 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(), @@ -63,9 +121,15 @@ impl ApplicationHandler for App { } } WindowEvent::RedrawRequested => { - if let Some(gpu) = &mut self.gpu { gpu.frame(); } - // Sofort den nächsten Frame anfordern (Vsync drosselt). - if let Some(w) = &self.window { w.request_redraw(); } + // 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); } } _ => {} } diff --git a/src/render/pattern.wgsl b/src/render/pattern.wgsl deleted file mode 100644 index 4605b8c..0000000 --- a/src/render/pattern.wgsl +++ /dev/null @@ -1,26 +0,0 @@ -// Testmuster für Schritt 2: 8px-Schachbrett mit Farbverlauf, damit -// Auflösung (320×240) und Orientierung des internen Targets sichtbar -// sind. Wird in Schritt 3 durch die Szenen-Pipeline ersetzt. - -// Fullscreen-Dreieck ohne Vertex-Buffer: überdeckt (-1,-1)..(1,1). -@vertex -fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { - var pos = array( - vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0), - ); - return vec4f(pos[i], 0.0, 1.0); -} - -@fragment -fn fs_main(@builtin(position) p: vec4f) -> @location(0) vec4f { - // p.xy = Pixelkoordinaten im internen Target (0..320, 0..240). - let checker = (u32(p.x / 8.0) + u32(p.y / 8.0)) % 2u; - let base = select(0.35, 0.65, checker == 1u); - // Verlauf: rot wächst nach rechts, grün nach unten. - return vec4f( - base * (0.5 + 0.5 * p.x / 320.0), - base * (0.5 + 0.5 * p.y / 240.0), - base * 0.5, - 1.0, - ); -} diff --git a/src/render/scene.rs b/src/render/scene.rs new file mode 100644 index 0000000..4c74b07 --- /dev/null +++ b/src/render/scene.rs @@ -0,0 +1,142 @@ +//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target. +//! +//! Stand Schritt 3: ein hartkodierter Testwürfel mit Vertex-Colors — +//! Meshes aus OBJ und Texturen kommen in Schritt 5. Die Shader +//! (scene.wgsl) sind dagegen schon die echten PS1-Shader. + +use wgpu::util::DeviceExt; + +use crate::render::math::Mat4; + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Vertex { + pos: [f32; 3], + color: [f32; 3], +} + +const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { + array_stride: size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3], +}; + +pub struct ScenePass { + pipeline: wgpu::RenderPipeline, + vbuf: wgpu::Buffer, + ibuf: wgpu::Buffer, + ubuf: wgpu::Buffer, + bind: wgpu::BindGroup, + index_count: u32, +} + +impl ScenePass { + pub fn new( + device: &wgpu::Device, + color_format: wgpu::TextureFormat, + depth_format: wgpu::TextureFormat, + ) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene"), + source: wgpu::ShaderSource::Wgsl(include_str!("scene.wgsl").into()), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene"), + layout: None, + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[VERTEX_LAYOUT], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(color_format.into())], + }), + // Cull aus: der Z-Buffer sortiert auch so korrekt, und die + // irl3d-Materialien sind teils two-sided. Entscheidung pro + // Material fällt mit dem Szenen-Loader (Schritt 5). + primitive: wgpu::PrimitiveState::default(), + depth_stencil: Some(wgpu::DepthStencilState { + format: depth_format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let (verts, indices) = cube(); + let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cube vertices"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + let ibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cube indices"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }); + let ubuf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene uniforms"), + size: size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: ubuf.as_entire_binding(), + }], + }); + + Self { pipeline, vbuf, ibuf, ubuf, bind, index_count: indices.len() as u32 } + } + + /// Uniforms für diesen Frame hochladen — vor dem Render-Pass rufen. + pub fn prepare(&self, queue: &wgpu::Queue, mvp: &Mat4) { + queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(mvp)); + } + + pub fn draw(&self, pass: &mut wgpu::RenderPass) { + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind, &[]); + pass.set_vertex_buffer(0, self.vbuf.slice(..)); + pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint16); + pass.draw_indexed(0..self.index_count, 0, 0..1); + } +} + +/// Einheitswürfel um den Ursprung, jede Seite eine Farbe. Der +/// Helligkeitsverlauf über die Ecken erzeugt Gradienten, an denen +/// Dither und affine Interpolation sichtbar werden. +fn cube() -> (Vec, Vec) { + const S: f32 = 0.5; + let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ + ([0.9, 0.2, 0.2], [[ S, -S, -S], [ S, S, -S], [ S, S, S], [ S, -S, S]]), // +X + ([0.2, 0.9, 0.9], [[-S, -S, -S], [-S, S, -S], [-S, S, S], [-S, -S, S]]), // -X + ([0.2, 0.9, 0.2], [[-S, S, -S], [ S, S, -S], [ S, S, S], [-S, S, S]]), // +Y + ([0.9, 0.2, 0.9], [[-S, -S, -S], [ S, -S, -S], [ S, -S, S], [-S, -S, S]]), // -Y + ([0.3, 0.3, 0.9], [[-S, -S, S], [ S, -S, S], [ S, S, S], [-S, S, S]]), // +Z + ([0.9, 0.8, 0.2], [[-S, -S, -S], [ S, -S, -S], [ S, S, -S], [-S, S, -S]]), // -Z + ]; + const SHADE: [f32; 4] = [1.0, 0.65, 0.4, 0.65]; + + let mut verts = Vec::with_capacity(24); + let mut idx: Vec = Vec::with_capacity(36); + for (base, corners) in faces { + let b = verts.len() as u16; + for (i, pos) in corners.into_iter().enumerate() { + verts.push(Vertex { pos, color: base.map(|c| c * SHADE[i]) }); + } + idx.extend([b, b + 1, b + 2, b, b + 2, b + 3]); + } + (verts, idx) +} diff --git a/src/render/scene.wgsl b/src/render/scene.wgsl new file mode 100644 index 0000000..f9c1696 --- /dev/null +++ b/src/render/scene.wgsl @@ -0,0 +1,57 @@ +// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch +// gerechnet statt nachgestellt: +// - Pixel-Snap im Vertex-Shader → Vertex-Jitter +// - @interpolate(linear) → affine (nicht perspektivkorrigierte) Varyings +// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader + +struct Uniforms { + mvp: mat4x4f, +}; +@group(0) @binding(0) var u: Uniforms; + +struct VsOut { + @builtin(position) pos: vec4f, + // linear = affin interpoliert; Vertex-Colors (und später UVs) + // wobbeln dadurch wie auf der PS1. + @location(0) @interpolate(linear) color: vec3f, +}; + +// Halbe interne Auflösung (320×240). Bei umschaltbarer Auflösung später +// in die Uniforms verschieben. +const HALF_RES = vec2f(160.0, 120.0); + +@vertex +fn vs_main(@location(0) pos: vec3f, @location(1) color: vec3f) -> VsOut { + var clip = u.mvp * vec4f(pos, 1.0); + // Pixel-Snap: xy nach der Projektion aufs interne Raster runden und + // zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde + // die Division Unsinn liefern, das Hardware-Clipping übernimmt. + if clip.w > 0.0 { + let ndc = clip.xy / clip.w; + clip = vec4f(round(ndc * HALF_RES) / HALF_RES * clip.w, clip.zw); + } + var out: VsOut; + out.pos = clip; + out.color = color; + return out; +} + +// 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering. +fn bayer4(px: vec2u) -> f32 { + var m = array( + 0u, 8u, 2u, 10u, + 12u, 4u, 14u, 6u, + 3u, 11u, 1u, 9u, + 15u, 7u, 13u, 5u, + ); + return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]); +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4f { + // RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden → + // Verläufe zerfallen ins typische Dither-Muster. + let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0; + let c = clamp(in.color, vec3f(0.0), vec3f(1.0)); + return vec4f(floor(c * 31.0 + t) / 31.0, 1.0); +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..297ad8c --- /dev/null +++ b/src/session.rs @@ -0,0 +1,228 @@ +//! Session: der geteilte Anwendungszustand über allen Frontends. +//! +//! Es existiert genau eine `Session` pro Lauf. CLI und Fenster steuern +//! dieselbe — das Fenster besitzt sie, die Terminal-Eingabe schickt nur +//! Befehlszeilen hinein. Dadurch kann es keinen Mismatch geben: ein +//! `Game`-Zustand, ein `Mode`, beide Frontends stellen ihn nur dar. +//! +//! Schicht: `engine ← session ← { cli, render }`. Die Session kennt kein +//! Frontend — `exec` nimmt eine Befehlszeile und *gibt* Ausgabezeilen +//! zurück (statt zu drucken), damit jedes Frontend sie frei darstellt +//! (Terminal jetzt, In-Fenster-Konsole später). + +use crate::engine::game::{Action, Game}; +use crate::engine::ink::StoryState; +use crate::engine::{kv, signals, story_ctrl}; + +/// Was die Frontends gerade darstellen sollen. Geteilt, damit CLI und GUI +/// nie uneins sind, ob ein Dialog läuft. +pub enum Mode { + /// Freie Welt — das Fenster rendert die Szene, die Konsole nimmt + /// Engine-Befehle. + Free, + /// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt (sobald + /// Text-Rendering existiert) Panels; Eingaben treiben den Dialog statt + /// Befehle. + Dialog(Dialog), +} + +/// Aktueller Dialogschritt, frontend-neutral. `choices` leer = reiner Text, +/// der auf „weiter" (Leerzeile/Enter) wartet. +pub struct Dialog { + // Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der + // Dialogtext über die Konsolen-Ausgabe (siehe `step`). + #[allow(dead_code)] + pub text: String, + pub choices: Vec, +} + +pub struct Session { + pub game: Game, + pub mode: Mode, + signals_path: String, +} + +/// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend +/// beenden soll (`quit`/`exit`). +pub struct ExecResult { + pub output: Vec, + pub quit: bool, +} + +impl ExecResult { + fn lines(output: Vec) -> Self { Self { output, quit: false } } + fn empty() -> Self { Self { output: Vec::new(), quit: false } } +} + +impl Session { + pub fn new(signals_path: String) -> Self { + let game = Game::new(signals::load_signals(&signals_path)); + Self { game, mode: Mode::Free, signals_path } + } + + /// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft). + /// Eigene Methode statt im Konstruktor, damit das Frontend die Ausgabe + /// darstellen kann — und damit `init` bereits einen Dialog öffnen darf. + pub fn start(&mut self) -> Vec { + self.fire("init", None) + } + + /// Eine Eingabezeile verarbeiten. Im Dialog treibt sie den Dialog, + /// sonst ist sie ein Engine-Befehl. Dieselbe Eingabequelle, je nach + /// `mode` unterschiedlich gedeutet — so steuert Terminal *und* (später) + /// Klick denselben Dialog ohne Sonderpfad. + pub fn exec(&mut self, line: &str) -> ExecResult { + match self.mode { + Mode::Dialog(_) => self.dialog_input(line), + Mode::Free => self.command(line), + } + } + + fn command(&mut self, line: &str) -> ExecResult { + let line = line.trim(); + match line { + "" => ExecResult::empty(), + "quit" | "exit" => ExecResult { output: Vec::new(), quit: true }, + "help" => ExecResult::lines(help()), + "kv" => ExecResult::lines(self.dump_kv()), + "reload" => { + self.game.signals = signals::load_signals(&self.signals_path); + ExecResult::lines(vec![ + format!("{} Signale geladen.", self.game.signals.len()), + ]) + } + _ => { + if let Some(sig) = line.strip_prefix("signal ") { + ExecResult::lines(self.fire(sig.trim(), None)) + } else if let Some(name) = line.strip_prefix("use ") { + // Objekt-Interaktion simulieren: Signal ist der gestrippte + // Name, $self der volle — wie der LMB-Klick-Pfad in irl3d. + let name = name.trim(); + let key = signals::signal_key(name).to_string(); + ExecResult::lines(self.fire(&key, Some(name.to_string()))) + } else { + ExecResult::lines(vec![ + format!("unbekannter Befehl: {line:?} — `help` für Befehle"), + ]) + } + } + } + } + + fn dialog_input(&mut self, line: &str) -> ExecResult { + let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); }; + let sel = if d.choices.is_empty() { + None // reiner Text → Leerzeile/Enter blättert weiter + } else { + match line.trim().parse::() { + Ok(n) if (1..=d.choices.len()).contains(&n) => Some(n - 1), + _ => return ExecResult::lines(vec![format!(" (1..{})", d.choices.len())]), + } + }; + ExecResult::lines(self.step(sel)) + } + + /// Signal dispatchen, deferred Actions einsammeln und — falls dadurch + /// eine Story startete — den ersten Dialogschritt ziehen. + fn fire(&mut self, signal: &str, instance: Option) -> Vec { + { + let mut ctx = self.game.action_ctx(instance); + signals::dispatch(signal, &mut ctx); + } + let mut out = self.drain_actions(); + if self.game.story.is_some() { + out.extend(self.step(None)); + } + out + } + + /// Einen Story-Schritt treiben und `mode` daran anpassen. `sel` ist die + /// Choice-Auswahl bzw. `None` für ersten Schritt / Text-Weiterblättern. + /// Tags gehen wie gehabt zurück in den Dispatcher (siehe story_ctrl). + fn step(&mut self, sel: Option) -> Vec { + let state = { + let mut ctx = self.game.action_ctx(None); + let (state, tags) = story_ctrl::advance(sel, &mut ctx); + for t in &tags { signals::dispatch(t, &mut ctx); } + state + }; + let mut out = self.drain_actions(); + match state { + None | Some(StoryState::End) => { + self.mode = Mode::Free; + } + Some(StoryState::Text(text)) => { + out.push(text.clone()); + self.mode = Mode::Dialog(Dialog { text, choices: Vec::new() }); + } + Some(StoryState::Choice { prompt, options }) => { + if !prompt.is_empty() { out.push(prompt.clone()); } + for (i, o) in options.iter().enumerate() { + out.push(format!(" {}) {o}", i + 1)); + } + self.mode = Mode::Dialog(Dialog { text: prompt, choices: options }); + } + } + out + } + + fn drain_actions(&mut self) -> Vec { + self.game.actions.drain(..).map(|a| match a { + Action::HideObject(n) => format!("[action] hide_object {n}"), + Action::PlaySound(n) => format!("[action] play_sound {n}"), + }).collect() + } + + fn dump_kv(&self) -> Vec { + let mut keys: Vec<&String> = self.game.kv.keys().collect(); + keys.sort(); + keys.into_iter() + .map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k]))) + .collect() + } +} + +fn help() -> Vec { + vec![ + " signal Signal feuern oder Action direkt ausführen".into(), + " (z.B. `signal Cube`, `signal set has_key true`)".into(), + " use Objekt-Interaktion simulieren — `use Mushroom.005`".into(), + " feuert Signal `Mushroom` mit $self = Mushroom.005".into(), + " kv KV-Store anzeigen".into(), + " reload signals.toml neu laden".into(), + " quit beenden".into(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + // Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler), + // also keine Asset-Abhängigkeit für diese Tests. + fn empty_session() -> Session { Session::new("/nonexistent/signals.toml".into()) } + + #[test] + fn quit_sets_flag() { + let mut s = empty_session(); + assert!(s.exec("quit").quit); + } + + #[test] + fn builtin_command_mutates_kv_and_stays_free() { + let mut s = empty_session(); + // Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`. + let r = s.exec("signal set has_key true"); + assert!(!r.quit); + assert!(s.game.kv["has_key"].coerce_to_bool().unwrap()); + assert!(matches!(s.mode, Mode::Free)); + } + + #[test] + fn unknown_command_reports_and_stays_free() { + let mut s = empty_session(); + let r = s.exec("frobnicate"); + assert_eq!(r.output.len(), 1); + assert!(matches!(s.mode, Mode::Free)); + } +}