centralized state
This commit is contained in:
Generated
+1
@@ -2145,6 +2145,7 @@ name = "wds"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bladeink",
|
"bladeink",
|
||||||
|
"bytemuck",
|
||||||
"pollster",
|
"pollster",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bladeink = "1.2.5"
|
bladeink = "1.2.5"
|
||||||
|
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||||
pollster = "0.4.0"
|
pollster = "0.4.0"
|
||||||
wgpu = "29.0.3"
|
wgpu = "29.0.3"
|
||||||
winit = "0.30.13"
|
winit = "0.30.13"
|
||||||
|
|||||||
+19
-111
@@ -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`,
|
//! Liest Zeilen von stdin, gibt sie an `Session::exec` und druckt die
|
||||||
//! Dialoge via `story_ctrl::advance`, deferred Actions aus `game.actions`.
|
//! Ausgabe. Befehls- und Dialoglogik liegen in `session`, damit das
|
||||||
//! Der Renderer wird später dieselben Schnittstellen konsumieren; dieses
|
//! Fenster-Frontend dieselbe Session ohne Duplikat steuern kann.
|
||||||
//! Modul bleibt daneben als Debug-Werkzeug bestehen.
|
|
||||||
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
use crate::engine::game::{Action, Game};
|
use crate::session::{Mode, Session};
|
||||||
use crate::engine::ink::StoryState;
|
|
||||||
use crate::engine::{kv, signals, story_ctrl};
|
|
||||||
|
|
||||||
/// REPL-Hauptschleife. Übernimmt das Game, feuert das reservierte
|
pub fn run(mut session: Session, signals_path: &str) {
|
||||||
/// `[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) {
|
|
||||||
println!("wds-Konsole — {} Signale aus {}. `help` für Befehle.",
|
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 <s> Signal feuern oder Action direkt ausführen");
|
|
||||||
println!(" (z.B. `signal Cube`, `signal set has_key true`)");
|
|
||||||
println!(" use <instance> 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<String>) {
|
|
||||||
{
|
|
||||||
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<usize> = None;
|
|
||||||
loop {
|
loop {
|
||||||
let state = {
|
let Some(input) = read_line(prompt_for(&session.mode)) else { break };
|
||||||
let mut ctx = game.action_ctx(None);
|
let r = session.exec(input.trim());
|
||||||
let (state, tags) = story_ctrl::advance(sel, &mut ctx);
|
for line in r.output { println!("{line}"); }
|
||||||
for t in &tags { signals::dispatch(t, &mut ctx); }
|
if r.quit { break; }
|
||||||
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::<usize>() {
|
|
||||||
if (1..=options.len()).contains(&n) { break Some(n - 1); }
|
|
||||||
}
|
|
||||||
println!(" (1..{})", options.len());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_actions(game: &mut Game) {
|
/// Prompt je nach Zustand: Befehl, Dialog-Weiterblättern oder Choice-Wahl.
|
||||||
for a in game.actions.drain(..) {
|
fn prompt_for(mode: &Mode) -> &'static str {
|
||||||
match a {
|
match mode {
|
||||||
Action::HideObject(n) => println!("[action] hide_object {n}"),
|
Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ",
|
||||||
Action::PlaySound(n) => println!("[action] play_sound {n}"),
|
Mode::Dialog(_) => " [Enter] ",
|
||||||
}
|
Mode::Free => "> ",
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-9
@@ -1,23 +1,25 @@
|
|||||||
//! wds — Weirdcore Dating Simulator.
|
//! wds — Weirdcore Dating Simulator.
|
||||||
//!
|
//!
|
||||||
//! Aufbau: `engine/` ist der headless Kern (Signal/Action-Dispatcher,
|
//! Aufbau: `engine/` ist der headless Kern (Signal/Action-Dispatcher,
|
||||||
//! KV-Store, Ink-Stories); Frontends konsumieren ihn über `StoryState`
|
//! KV-Store, Ink-Stories); `session` hält den geteilten Anwendungszustand
|
||||||
//! und die `Action`-Queue. Frontends: das Fenster (`render`, Default)
|
//! darüber (Game + Dialog-Modus + Befehls-Interpreter). Beide Frontends
|
||||||
//! und die Konsolen-REPL (`cli`, via `--cli`).
|
//! steuern dieselbe Session: das Fenster (`render`, Default) und die
|
||||||
|
//! Konsolen-REPL (`cli`, via `--cli`).
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod session;
|
||||||
|
|
||||||
use engine::game::Game;
|
use engine::assets;
|
||||||
use engine::{assets, signals};
|
use session::Session;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if std::env::args().any(|a| a == "--cli") {
|
|
||||||
let signals_path = assets::path("assets/signals.toml");
|
let signals_path = assets::path("assets/signals.toml");
|
||||||
let game = Game::new(signals::load_signals(&signals_path));
|
let session = Session::new(signals_path.clone());
|
||||||
cli::run(game, &signals_path);
|
if std::env::args().any(|a| a == "--cli") {
|
||||||
|
cli::run(session, &signals_path);
|
||||||
} else {
|
} else {
|
||||||
render::run();
|
render::run(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-44
@@ -1,9 +1,8 @@
|
|||||||
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
|
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
|
||||||
//! aus dem Renderer-Plan:
|
//! aus dem Renderer-Plan:
|
||||||
//!
|
//!
|
||||||
//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild.
|
//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild
|
||||||
//! Aktuell nur ein Testmuster-Shader; Schritt 3
|
//! ([`ScenePass`], PS1-Shader).
|
||||||
//! ersetzt ihn durch die Szenen-Pipeline.
|
|
||||||
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
|
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
|
||||||
//! 4:3-Letterbox (via Viewport) auf die Surface.
|
//! 4:3-Letterbox (via Viewport) auf die Surface.
|
||||||
|
|
||||||
@@ -11,8 +10,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use winit::window::Window;
|
use winit::window::Window;
|
||||||
|
|
||||||
pub const INTERNAL_W: u32 = 320;
|
use crate::render::math::Mat4;
|
||||||
pub const INTERNAL_H: u32 = 240;
|
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
|
/// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall
|
||||||
/// (auch GL-Fallback) unterstützte Depth-Format.
|
/// (auch GL-Fallback) unterstützte Depth-Format.
|
||||||
@@ -28,7 +30,7 @@ pub struct Gpu {
|
|||||||
internal_view: wgpu::TextureView,
|
internal_view: wgpu::TextureView,
|
||||||
depth_view: wgpu::TextureView,
|
depth_view: wgpu::TextureView,
|
||||||
|
|
||||||
pattern_pipeline: wgpu::RenderPipeline,
|
scene: ScenePass,
|
||||||
blit_pipeline: wgpu::RenderPipeline,
|
blit_pipeline: wgpu::RenderPipeline,
|
||||||
blit_bind: wgpu::BindGroup,
|
blit_bind: wgpu::BindGroup,
|
||||||
}
|
}
|
||||||
@@ -84,40 +86,8 @@ impl Gpu {
|
|||||||
|
|
||||||
let (internal_view, depth_view) = make_internal_targets(&device);
|
let (internal_view, depth_view) = make_internal_targets(&device);
|
||||||
|
|
||||||
// Pass 1: Testmuster auf das interne Target.
|
// Pass 1: die Szene.
|
||||||
let pattern_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let scene = ScenePass::new(&device, INTERNAL_FORMAT, DEPTH_FORMAT);
|
||||||
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 2: internes Target nearest-gesampelt auf die Surface.
|
// Pass 2: internes Target nearest-gesampelt auf die Surface.
|
||||||
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
@@ -169,7 +139,7 @@ impl Gpu {
|
|||||||
Self {
|
Self {
|
||||||
surface, device, queue, config,
|
surface, device, queue, config,
|
||||||
internal_view, depth_view,
|
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);
|
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;
|
use wgpu::CurrentSurfaceTexture as Cst;
|
||||||
let frame = match self.surface.get_current_texture() {
|
let frame = match self.surface.get_current_texture() {
|
||||||
Cst::Success(f) | Cst::Suboptimal(f) => f,
|
Cst::Success(f) | Cst::Suboptimal(f) => f,
|
||||||
@@ -221,8 +205,7 @@ impl Gpu {
|
|||||||
occlusion_query_set: None,
|
occlusion_query_set: None,
|
||||||
multiview_mask: None,
|
multiview_mask: None,
|
||||||
});
|
});
|
||||||
pass.set_pipeline(&self.pattern_pipeline);
|
self.scene.draw(&mut pass);
|
||||||
pass.draw(0..3, 0..1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: Letterbox-Blit aufs Fenster.
|
// Pass 2: Letterbox-Blit aufs Fenster.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-12
@@ -1,15 +1,26 @@
|
|||||||
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
||||||
//!
|
//!
|
||||||
//! Stand: Schritt 1+2 des Renderer-Plans (notes/renderer-plan.md) —
|
//! Stand: Schritt 3 des Renderer-Plans (notes/renderer-plan.md) —
|
||||||
//! Fenster, internes 320×240-Target mit Testmuster, Nearest-Upscale mit
|
//! internes 320×240-Target, PS1-Szenen-Pass (Pixel-Snap, affine
|
||||||
//! 4:3-Letterbox. Szene, PS1-Shader und Flycam folgen als nächste Schritte.
|
//! 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
|
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
||||||
//! dessen Schnittstellen (sobald hier eine Szene läuft) — nie umgekehrt.
|
//! 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 gpu;
|
||||||
|
mod math;
|
||||||
|
mod scene;
|
||||||
|
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use winit::application::ApplicationHandler;
|
use winit::application::ApplicationHandler;
|
||||||
use winit::event::{ElementState, KeyEvent, WindowEvent};
|
use winit::event::{ElementState, KeyEvent, WindowEvent};
|
||||||
@@ -17,20 +28,56 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
|||||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||||
use winit::window::{Window, WindowId};
|
use winit::window::{Window, WindowId};
|
||||||
|
|
||||||
|
use crate::session::{Mode, Session};
|
||||||
use gpu::Gpu;
|
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::<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");
|
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);
|
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");
|
event_loop.run_app(&mut app).expect("winit: run");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct App {
|
struct App {
|
||||||
window: Option<Arc<Window>>,
|
window: Option<Arc<Window>>,
|
||||||
gpu: Option<Gpu>,
|
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,
|
||||||
|
last: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new(session: Session, rx: Receiver<String>) -> Self {
|
||||||
|
Self { window: None, gpu: None, session, rx, sim_t: 0.0, last: None }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApplicationHandler for App {
|
impl ApplicationHandler for App {
|
||||||
@@ -48,6 +95,17 @@ impl ApplicationHandler for App {
|
|||||||
self.window = Some(window);
|
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) {
|
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CloseRequested => event_loop.exit(),
|
WindowEvent::CloseRequested => event_loop.exit(),
|
||||||
@@ -63,9 +121,15 @@ impl ApplicationHandler for App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
if let Some(gpu) = &mut self.gpu { gpu.frame(); }
|
// dt messen; im Dialog die Sim-Uhr anhalten → Welt pausiert,
|
||||||
// Sofort den nächsten Frame anfordern (Vsync drosselt).
|
// während die Konsole den Dialog treibt.
|
||||||
if let Some(w) = &self.window { w.request_redraw(); }
|
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); }
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, 3>(
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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::<Vertex>() 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::<Mat4>() 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<Vertex>, Vec<u16>) {
|
||||||
|
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<u16> = 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)
|
||||||
|
}
|
||||||
@@ -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<uniform> 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<u32, 16>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
+228
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
pub quit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecResult {
|
||||||
|
fn lines(output: Vec<String>) -> 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<String> {
|
||||||
|
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::<usize>() {
|
||||||
|
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<String>) -> Vec<String> {
|
||||||
|
{
|
||||||
|
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<usize>) -> Vec<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
vec![
|
||||||
|
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
||||||
|
" (z.B. `signal Cube`, `signal set has_key true`)".into(),
|
||||||
|
" use <instance> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user