Compare commits
4 Commits
3ac613cb58
...
4f9a792dea
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f9a792dea | |||
| a585dff09d | |||
| d584e418f5 | |||
| a41d39cc78 |
@@ -28,6 +28,7 @@ fn prompt_for(mode: &Mode) -> &'static str {
|
||||
match mode {
|
||||
Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ",
|
||||
Mode::Dialog(_) => " [Enter] ",
|
||||
Mode::Menu => "menü> ",
|
||||
Mode::Free => "> ",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ pub(crate) struct Font {
|
||||
/// Die im Projekt geladenen Fonts, gebaut in render::run und an `ui::layout`
|
||||
/// gereicht.
|
||||
pub(crate) struct Fonts {
|
||||
/// Größerer Font, für spätere Überschriften/HUD bereitgehalten.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) ega: Font,
|
||||
pub(crate) cga: Font,
|
||||
}
|
||||
|
||||
+43
-16
@@ -150,6 +150,8 @@ struct App {
|
||||
last: Option<Instant>,
|
||||
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
|
||||
cursor_win: [f32; 2],
|
||||
/// Linksklick steht aus, wird im nächsten redraw gegen das UI aufgelöst.
|
||||
pending_click: bool,
|
||||
/// CPU-seitige Welt-Geometrie + Texturen, in `resumed` einmal auf die
|
||||
/// GPU geladen.
|
||||
mesh: Mesh,
|
||||
@@ -177,6 +179,7 @@ impl App {
|
||||
input: Input::default(),
|
||||
last: None,
|
||||
cursor_win: [0.0, 0.0],
|
||||
pending_click: false,
|
||||
mesh,
|
||||
images,
|
||||
ui_textures,
|
||||
@@ -255,9 +258,10 @@ impl ApplicationHandler for App {
|
||||
self.cursor_win = [position.x as f32, position.y as f32];
|
||||
}
|
||||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||||
// Nur im freien Modus greift der Klick die Maus (Flycam). Im
|
||||
// UI-/Dialog-Modus bleibt die Maus frei (für Klicks aufs UI).
|
||||
if matches!(self.session.mode, Mode::Free) { self.set_grab(true); }
|
||||
// Klick nur vormerken; der nächste redraw entscheidet (mit der
|
||||
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
|
||||
// Flycam-Greifen.
|
||||
self.pending_click = true;
|
||||
}
|
||||
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
|
||||
WindowEvent::Resized(size) => {
|
||||
@@ -265,7 +269,7 @@ impl ApplicationHandler for App {
|
||||
gpu.resize(size.width, size.height);
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.redraw(),
|
||||
WindowEvent::RedrawRequested => self.redraw(event_loop),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -276,9 +280,16 @@ impl App {
|
||||
let PhysicalKey::Code(code) = key.physical_key else { return; };
|
||||
let down = key.state == ElementState::Pressed;
|
||||
|
||||
// Escape: erst den Cursor freigeben, sonst beenden.
|
||||
// Escape togglet das Menü über denselben exec-Trichter wie Konsole
|
||||
// und Klick. Der Lock-Zustand folgt danach automatisch dem Modus
|
||||
// (siehe redraw). Im Dialog tut Escape nichts (Dialog endet über
|
||||
// seine Choices).
|
||||
if code == KeyCode::Escape && down {
|
||||
if self.input.grabbed { self.set_grab(false); } else { event_loop.exit(); }
|
||||
if !matches!(self.session.mode, Mode::Dialog(_)) {
|
||||
let r = self.session.exec("menu");
|
||||
for out in r.output { println!("{out}"); }
|
||||
if r.quit { event_loop.exit(); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -293,22 +304,24 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn redraw(&mut self) {
|
||||
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert,
|
||||
// während die Konsole den Dialog treibt.
|
||||
let now = Instant::now();
|
||||
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
|
||||
|
||||
if matches!(self.session.mode, Mode::Free) {
|
||||
// Lock-Zustand folgt dem Modus: nur in Free ist die Flycam aktiv
|
||||
// (Maus gefangen, relativer Blick). Menü/Dialog geben die Maus frei
|
||||
// fürs UI. So gibt es keine parallele Lock-Logik mehr.
|
||||
let fly = matches!(self.session.mode, Mode::Free);
|
||||
if fly != self.input.grabbed { self.set_grab(fly); }
|
||||
|
||||
if fly {
|
||||
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
|
||||
let fwd = axis(self.input.fwd, self.input.back);
|
||||
let right = axis(self.input.right, self.input.left);
|
||||
let up = axis(self.input.up, self.input.down);
|
||||
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
|
||||
} else if self.input.grabbed {
|
||||
// Nicht-Free (Dialog/Menü) → Maus freigeben, damit das UI klickbar
|
||||
// ist und der Maus-Cursor statt des HUD-Pointers erscheint.
|
||||
self.set_grab(false);
|
||||
}
|
||||
// Delta immer verwerfen, auch im Dialog — sonst springt der Blick
|
||||
// beim Fortsetzen um die aufgestaute Bewegung.
|
||||
@@ -323,11 +336,25 @@ impl App {
|
||||
.unwrap_or([0.0, 0.0]),
|
||||
grabbed: self.input.grabbed,
|
||||
};
|
||||
// Overlay state-driven bauen (Stage 3: Demo-Panel + Cursor).
|
||||
let overlay = ui::layout(
|
||||
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &cursor,
|
||||
// Overlay state-driven bauen (verzweigt nach session.mode); die
|
||||
// Hover-Aktion kommt aus der Maus-Position.
|
||||
let screen = ui::layout(
|
||||
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
|
||||
);
|
||||
if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &overlay); }
|
||||
|
||||
// Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion
|
||||
// durch denselben exec-Trichter wie die Konsole. Klick in der freien
|
||||
// Welt tut (noch) nichts — hier käme später der `use`-Raycast.
|
||||
if self.pending_click {
|
||||
self.pending_click = false;
|
||||
if let Some(action) = screen.hover_action.clone() {
|
||||
let r = self.session.exec(&action);
|
||||
for out in r.output { println!("{out}"); }
|
||||
if r.quit { event_loop.exit(); }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &screen.ui); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// 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) UVs
|
||||
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader
|
||||
// - 1-Bit-Alpha-Test (Cutouts), kein Blending
|
||||
//
|
||||
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
|
||||
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
|
||||
// frei beweglichen Kamera und großen Faces zu störend — den Look tragen
|
||||
// Pixel-Snap und Dither.
|
||||
|
||||
struct Uniforms {
|
||||
mvp: mat4x4f,
|
||||
@@ -18,8 +22,8 @@ struct Uniforms {
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
// linear = affin interpoliert → das Textur-Wobbeln der PS1.
|
||||
@location(0) @interpolate(linear) uv: vec2f,
|
||||
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
|
||||
@location(0) @interpolate(perspective) uv: vec2f,
|
||||
};
|
||||
|
||||
@vertex
|
||||
|
||||
+177
-15
@@ -14,6 +14,7 @@
|
||||
use crate::engine::tga::Image;
|
||||
use crate::render::font::{self, Font, Fonts};
|
||||
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
||||
use crate::session::{Dialog, Mode, Session};
|
||||
|
||||
/// Feste Textur-Indizes im Sprite-Pass — Reihenfolge = `ui_textures` in
|
||||
/// render::run. `WHITE` ist ein 1×1-Weiß-Texel für Solid-Fills (Farbe kommt
|
||||
@@ -27,9 +28,9 @@ pub(crate) const ORN: usize = 4;
|
||||
|
||||
/// Cursor-Indizes im 2×2-Raster (Pivot mittig je 16×16-Zelle).
|
||||
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
|
||||
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
|
||||
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
|
||||
// Die „interagierbar"-Varianten (1, 3) kommen mit dem Hit-Testing in Stage 4.
|
||||
#[allow(dead_code)] pub(crate) const CUR_MOUSE_INTERACT: usize = 1;
|
||||
// Die interagierbare HUD-Variante (3) kommt mit dem Welt-Raycast.
|
||||
#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3;
|
||||
|
||||
const CURSOR_PX: f32 = 16.0;
|
||||
@@ -180,27 +181,157 @@ fn cursor_uv(idx: usize) -> [f32; 4] {
|
||||
[x0 / 32.0, y0 / 32.0, (x0 + CURSOR_PX) / 32.0, (y0 + CURSOR_PX) / 32.0]
|
||||
}
|
||||
|
||||
/// Das Overlay für den aktuellen Frame bauen.
|
||||
//
|
||||
// Stage 3: Demo-Panel mit Ornamentrahmen + Text, plus der Cursor. Ab Stage 4
|
||||
// verzweigt das hier nach `session.mode` (Dialog-Panel, Menü, HUD).
|
||||
pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, cur: &Cursor) -> Ui {
|
||||
/// Ergebnis eines Frames: die Overlay-Geometrie und — falls der Cursor über
|
||||
/// einem Klickziel steht — dessen Aktion (Eingabe-String für `Session::exec`).
|
||||
pub(crate) struct Screen {
|
||||
pub(crate) ui: Ui,
|
||||
pub(crate) hover_action: Option<String>,
|
||||
}
|
||||
|
||||
/// Punkt in Rechteck (x, y, w, h)?
|
||||
fn in_rect(p: [f32; 2], r: [f32; 4]) -> bool {
|
||||
p[0] >= r[0] && p[0] < r[0] + r[2] && p[1] >= r[1] && p[1] < r[1] + r[3]
|
||||
}
|
||||
|
||||
/// Ein klickbarer Button: Fläche + zentriertes Label. Steht der Cursor
|
||||
/// darüber, wird er hervorgehoben und seine `action` in `hover` vermerkt
|
||||
/// (der Klick wirkt dann auf genau diese Aktion).
|
||||
fn button(
|
||||
ui: &mut Ui, font: &Font, rect: [f32; 4], label: &str, action: &str,
|
||||
cur: [f32; 2], hover: &mut Option<String>,
|
||||
) {
|
||||
let hot = in_rect(cur, rect);
|
||||
if hot { *hover = Some(action.to_string()); }
|
||||
let bg = if hot { [0.34, 0.22, 0.46, 1.0] } else { [0.15, 0.12, 0.22, 1.0] };
|
||||
ui.fill(rect, bg);
|
||||
let tx = rect[0] + (rect[2] - font.text_width(label)) * 0.5;
|
||||
let ty = rect[1] + (rect[3] - font.glyph_h) * 0.5;
|
||||
ui.text(font, tx.round(), ty.round(), label, [0.95, 0.95, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// Innenabstand vom Panelrand zum Inhalt: 8px Ornamentrahmen + 3px Luft.
|
||||
const INSET: f32 = ORN_CORNER + 3.0;
|
||||
const PANEL_BG: [f32; 4] = [0.06, 0.05, 0.10, 1.0];
|
||||
const PANEL_FRAME: [f32; 4] = [0.85, 0.75, 0.95, 1.0];
|
||||
const TEXT: [f32; 4] = [0.92, 0.92, 1.0, 1.0];
|
||||
|
||||
/// Das Overlay für den aktuellen Frame bauen — verzweigt nach `session.mode`.
|
||||
pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: &Cursor) -> Screen {
|
||||
let mut ui = Ui::default();
|
||||
let mut hover: Option<String> = None;
|
||||
let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font.
|
||||
|
||||
// Demo-Panel (opak) mit Ornamentrahmen + beide Fonts zum Vergleich.
|
||||
ui.panel([8.0, 8.0, 216.0, 58.0], [0.06, 0.05, 0.10, 1.0], [0.85, 0.75, 0.95, 1.0]);
|
||||
ui.text(&fonts.ega, 16.0, 16.0, "EGA 8x14 — Schöne Grüße!", [0.9, 0.9, 1.0, 1.0]);
|
||||
ui.text(&fonts.cga, 16.0, 34.0, "CGA 8x8 — abcABC 0123 äöüß", [0.6, 1.0, 0.7, 1.0]);
|
||||
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
||||
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
||||
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
||||
if !cur.grabbed {
|
||||
match &session.mode {
|
||||
Mode::Free => {} // freie Welt: nur HUD/Cursor, kein Panel
|
||||
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
||||
Mode::Dialog(d) => dialog(&mut ui, f, internal, d, cur.pos, &mut hover),
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor zuletzt → liegt über allem. Gefangen (Flycam) → HUD-Pointer
|
||||
// mittig; sonst Maus-Cursor an der Mausposition.
|
||||
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
|
||||
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
|
||||
if cur.grabbed {
|
||||
ui.cursor(CUR_HUD, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
|
||||
} else {
|
||||
ui.cursor(CUR_MOUSE, cur.pos, [1.0; 4]);
|
||||
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
|
||||
ui.cursor(idx, cur.pos, [1.0; 4]);
|
||||
}
|
||||
|
||||
ui
|
||||
Screen { ui, hover_action: hover }
|
||||
}
|
||||
|
||||
/// Menu-Modus: das (noch demohafte) Menü, zentriert.
|
||||
fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Option<String>) {
|
||||
let items = [
|
||||
("Fortsetzen", "menu"), // schließt das Menü (Toggle zurück zu Free)
|
||||
("Hilfe", "help"),
|
||||
("KV anzeigen", "kv"),
|
||||
("Beenden", "quit"),
|
||||
];
|
||||
let (bw, bh, gap) = (112.0, 14.0, 4.0);
|
||||
let title_h = f.glyph_h;
|
||||
let body_h = title_h + gap + items.len() as f32 * bh + (items.len() as f32 - 1.0) * gap;
|
||||
let pw = bw + 2.0 * INSET;
|
||||
let ph = body_h + 2.0 * INSET;
|
||||
let px = ((internal[0] - pw) * 0.5).round();
|
||||
let py = ((internal[1] - ph) * 0.5).round();
|
||||
|
||||
ui.panel([px, py, pw, ph], PANEL_BG, PANEL_FRAME);
|
||||
ui.text(f, px + INSET, py + INSET, "Menü", [0.85, 0.8, 0.6, 1.0]);
|
||||
|
||||
let bx = px + INSET;
|
||||
let mut by = py + INSET + title_h + gap;
|
||||
for (label, action) in items {
|
||||
button(ui, f, [bx, by, bw, bh], label, action, cur, hover);
|
||||
by += bh + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog-Modus: Textpanel unten, darunter ein Button je Choice. Ohne Choices
|
||||
/// ein „Weiter"-Button, dessen Aktion die Leereingabe ist (blättert weiter) —
|
||||
/// derselbe Pfad wie Enter im Terminal (`dialog_input`).
|
||||
fn dialog(ui: &mut Ui, f: &Font, internal: [f32; 2], d: &Dialog, cur: [f32; 2], hover: &mut Option<String>) {
|
||||
let margin = 16.0;
|
||||
let (bh, gap) = (14.0, 4.0);
|
||||
let pw = internal[0] - 2.0 * margin;
|
||||
let line_h = f.glyph_h + 2.0;
|
||||
|
||||
// Text auf die Panelbreite umbrechen.
|
||||
let lines = wrap(f, &d.text, pw - 2.0 * INSET);
|
||||
let text_h = lines.len() as f32 * line_h;
|
||||
|
||||
// Buttonzeilen: je Choice eine, mindestens die „Weiter"-Zeile.
|
||||
let n = d.choices.len().max(1) as f32;
|
||||
let buttons_h = n * bh + (n - 1.0) * gap;
|
||||
|
||||
let ph = 2.0 * INSET + text_h + gap + buttons_h;
|
||||
let py = (internal[1] - margin - ph).max(margin).round();
|
||||
let px = margin;
|
||||
|
||||
ui.panel([px, py, pw, ph], PANEL_BG, PANEL_FRAME);
|
||||
|
||||
let mut ty = py + INSET;
|
||||
for line in &lines {
|
||||
ui.text(f, px + INSET, ty, line, TEXT);
|
||||
ty += line_h;
|
||||
}
|
||||
|
||||
let (cbx, cbw) = (px + INSET, pw - 2.0 * INSET);
|
||||
let mut cby = ty + gap;
|
||||
if d.choices.is_empty() {
|
||||
button(ui, f, [cbx, cby, cbw, bh], "Weiter", "", cur, hover);
|
||||
} else {
|
||||
for (i, opt) in d.choices.iter().enumerate() {
|
||||
let label = format!("{}) {opt}", i + 1);
|
||||
button(ui, f, [cbx, cby, cbw, bh], &label, &(i + 1).to_string(), cur, hover);
|
||||
cby += bh + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text in Zeilen umbrechen, die in `max_w` Pixel passen. Vorhandene
|
||||
/// `\n` sind harte Umbrüche; sonst wird wortweise gegriffen. Ein Wort breiter
|
||||
/// als `max_w` bleibt allein in seiner Zeile (Überlauf statt Endlosschleife).
|
||||
fn wrap(font: &Font, text: &str, max_w: f32) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
for word in para.split_whitespace() {
|
||||
let trial = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if line.is_empty() || font.text_width(&trial) <= max_w {
|
||||
line = trial;
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -235,6 +366,37 @@ mod tests {
|
||||
assert_eq!(&k.rgba[4..8], &[255, 255, 255, 0]); // schwarz → transparent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_registers_hover_only_when_cursor_inside() {
|
||||
// Leerer 256×64-Atlas → CGA-Font mit lauter leeren Glyphen; text_width
|
||||
// bleibt endlich, Button funktioniert trotzdem.
|
||||
let atlas = Image { width: 256, height: 64, rgba: vec![0; 256 * 64 * 4] };
|
||||
let font = Font::cga(FONT_CGA, &atlas);
|
||||
let rect = [10.0, 10.0, 100.0, 20.0];
|
||||
|
||||
let mut ui = Ui::default();
|
||||
let mut hover = None;
|
||||
button(&mut ui, &font, rect, "X", "do_x", [50.0, 15.0], &mut hover);
|
||||
assert_eq!(hover.as_deref(), Some("do_x"));
|
||||
|
||||
let mut outside = None;
|
||||
button(&mut ui, &font, rect, "X", "do_x", [200.0, 15.0], &mut outside);
|
||||
assert!(outside.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_breaks_on_width_and_hard_newlines() {
|
||||
// Leerer Atlas → jedes Zeichen rückt um space_adv (cga: 4px) vor.
|
||||
let atlas = Image { width: 256, height: 64, rgba: vec![0; 256 * 64 * 4] };
|
||||
let f = Font::cga(FONT_CGA, &atlas);
|
||||
// 3-Zeichen-Wörter = 12px. Bei max 12 passt je ein Wort pro Zeile.
|
||||
assert_eq!(wrap(&f, "aaa bbb ccc", 12.0), vec!["aaa", "bbb", "ccc"]);
|
||||
// Bei max 28 passen zwei ("aaa bbb" = 7·4 = 28).
|
||||
assert_eq!(wrap(&f, "aaa bbb ccc", 28.0), vec!["aaa bbb", "ccc"]);
|
||||
// Harte Umbrüche bleiben erhalten.
|
||||
assert_eq!(wrap(&f, "a\nb", 999.0), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_uv_picks_quadrant() {
|
||||
// idx 3 = (col 1, row 1) → rechte untere 16×16-Zelle.
|
||||
|
||||
+31
-4
@@ -20,9 +20,12 @@ 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.
|
||||
/// Menü offen — wie der Dialog pausiert das Fenster die Welt und gibt die
|
||||
/// Maus frei; Eingaben sind weiter Engine-Befehle (das Menü ist nur eine
|
||||
/// andere Darstellung). Per `menu`-Befehl getoggelt.
|
||||
Menu,
|
||||
/// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt Panels;
|
||||
/// Eingaben treiben den Dialog statt Befehle.
|
||||
Dialog(Dialog),
|
||||
}
|
||||
|
||||
@@ -74,7 +77,7 @@ impl Session {
|
||||
pub fn exec(&mut self, line: &str) -> ExecResult {
|
||||
match self.mode {
|
||||
Mode::Dialog(_) => self.dialog_input(line),
|
||||
Mode::Free => self.command(line),
|
||||
Mode::Free | Mode::Menu => self.command(line),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +86,15 @@ impl Session {
|
||||
match line {
|
||||
"" => ExecResult::empty(),
|
||||
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
||||
"menu" => {
|
||||
// Free ↔ Menu umschalten (erreichbar nur aus diesen beiden,
|
||||
// da Dialog anders geroutet wird).
|
||||
self.mode = match self.mode {
|
||||
Mode::Menu => Mode::Free,
|
||||
_ => Mode::Menu,
|
||||
};
|
||||
ExecResult::empty()
|
||||
}
|
||||
"help" => ExecResult::lines(help()),
|
||||
"kv" => ExecResult::lines(self.dump_kv()),
|
||||
"reload" => {
|
||||
@@ -189,6 +201,7 @@ fn help() -> Vec<String> {
|
||||
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
|
||||
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
||||
" kv KV-Store anzeigen".into(),
|
||||
" menu Menü öffnen/schließen".into(),
|
||||
" reload signals.toml neu laden".into(),
|
||||
" quit beenden".into(),
|
||||
]
|
||||
@@ -218,6 +231,20 @@ mod tests {
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_command_toggles_free_and_menu() {
|
||||
let mut s = empty_session();
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
s.exec("menu");
|
||||
assert!(matches!(s.mode, Mode::Menu));
|
||||
s.exec("menu");
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Free).
|
||||
s.exec("menu");
|
||||
s.exec("signal set in_menu true");
|
||||
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_reports_and_stays_free() {
|
||||
let mut s = empty_session();
|
||||
|
||||
Reference in New Issue
Block a user