diff --git a/src/cli.rs b/src/cli.rs index 13c9bef..c627f72 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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 => "> ", } } diff --git a/src/render/mod.rs b/src/render/mod.rs index 4c338b8..f0a12bf 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -280,9 +280,17 @@ 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: erst die (Flycam-)Maus freigeben; sonst das Menü + // umschalten — über denselben exec-Trichter wie Konsole und Klick. + // Im Dialog tut Escape nichts (der Dialog endet über seine Choices). if code == KeyCode::Escape && down { - if self.input.grabbed { self.set_grab(false); } else { event_loop.exit(); } + if self.input.grabbed { + self.set_grab(false); + } else 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; } @@ -327,9 +335,10 @@ impl App { .unwrap_or([0.0, 0.0]), grabbed: self.input.grabbed, }; - // Overlay state-driven bauen; Hover-Aktion kommt aus der Maus-Position. + // 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, &cursor, + [gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor, ); // Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion diff --git a/src/render/ui.rs b/src/render/ui.rs index c4cc8ff..949e123 100644 --- a/src/render/ui.rs +++ b/src/render/ui.rs @@ -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 @@ -208,45 +209,26 @@ fn button( ui.text(font, tx.round(), ty.round(), label, [0.95, 0.95, 1.0, 1.0]); } -/// Das Overlay für den aktuellen Frame bauen. -// -// Stage 4: klickbare Demo-Buttons (nur wenn die Maus frei ist). Als Nächstes -// verzweigt das hier nach `session.mode` (Dialog-Panel, Menü) und liefert die -// jeweiligen Aktionen. -pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, cur: &Cursor) -> Screen { +/// 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 = None; let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font. - // Im Flycam-Modus (Maus gefangen) ist die freie Sicht wichtiger → kein - // Panel; sonst die klickbare Demo. Hit-Tests laufen nur bei freier Maus. + // 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 { - let demo = [ - ("Hilfe", "help"), - ("KV anzeigen", "kv"), - ("Flag setzen", "signal set demo_flag true"), - ("Beenden", "quit"), - ]; - // Innenmaße aus Inhalt: der Inhalt sitzt um `inset` (Rahmendicke + - // etwas Padding) vom Panelrand weg. Panel daraus zentriert auf der - // internen Auflösung. - let (bw, bh, gap) = (112.0, 14.0, 4.0); - let inset = ORN_CORNER + 3.0; // 8px Ornamentrahmen + 3px Luft - let title_h = f.glyph_h; - let body_h = title_h + gap + demo.len() as f32 * bh + (demo.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], [0.06, 0.05, 0.10, 1.0], [0.85, 0.75, 0.95, 1.0]); - ui.text(f, px + inset, py + inset, "Klick-Demo", [0.85, 0.8, 0.6, 1.0]); - - let bx = px + inset; - let mut by = py + inset + title_h + gap; - for (label, action) in demo { - button(&mut ui, f, [bx, by, bw, bh], label, action, cur.pos, &mut hover); - by += bh + gap; + 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), } } @@ -262,6 +244,96 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, cur: &Cursor) -> Screen 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) { + 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) { + 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 { + 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)] mod tests { use super::*; @@ -312,6 +384,19 @@ mod tests { 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. diff --git a/src/session.rs b/src/session.rs index 297ad8c..fb14bc1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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), } @@ -73,8 +76,8 @@ impl Session { /// 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), + Mode::Dialog(_) => self.dialog_input(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 { " use 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();