centralized state
This commit is contained in:
Generated
+1
@@ -2145,6 +2145,7 @@ name = "wds"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bladeink",
|
||||
"bytemuck",
|
||||
"pollster",
|
||||
"wgpu",
|
||||
"winit",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+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`,
|
||||
//! 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 <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 {
|
||||
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::<usize>() {
|
||||
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 => "> ",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -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() {
|
||||
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);
|
||||
let session = Session::new(signals_path.clone());
|
||||
if std::env::args().any(|a| a == "--cli") {
|
||||
cli::run(session, &signals_path);
|
||||
} else {
|
||||
render::run();
|
||||
render::run(session);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-44
@@ -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,7 +30,7 @@ pub struct Gpu {
|
||||
internal_view: wgpu::TextureView,
|
||||
depth_view: wgpu::TextureView,
|
||||
|
||||
pattern_pipeline: wgpu::RenderPipeline,
|
||||
scene: ScenePass,
|
||||
blit_pipeline: wgpu::RenderPipeline,
|
||||
blit_bind: wgpu::BindGroup,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
//! 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::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
match stdin.read_line(&mut buf) {
|
||||
Ok(0) | Err(_) => break, // EOF (Ctrl-D) → Thread endet
|
||||
Ok(_) => {
|
||||
if tx.send(buf.trim_end().to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let event_loop = EventLoop::new().expect("winit: Event-Loop");
|
||||
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor).
|
||||
// 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<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,
|
||||
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 {
|
||||
@@ -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); }
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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