Menu als state im Mode-System
This commit is contained in:
@@ -28,6 +28,7 @@ fn prompt_for(mode: &Mode) -> &'static str {
|
|||||||
match mode {
|
match mode {
|
||||||
Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ",
|
Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ",
|
||||||
Mode::Dialog(_) => " [Enter] ",
|
Mode::Dialog(_) => " [Enter] ",
|
||||||
|
Mode::Menu => "menü> ",
|
||||||
Mode::Free => "> ",
|
Mode::Free => "> ",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -280,9 +280,17 @@ impl App {
|
|||||||
let PhysicalKey::Code(code) = key.physical_key else { return; };
|
let PhysicalKey::Code(code) = key.physical_key else { return; };
|
||||||
let down = key.state == ElementState::Pressed;
|
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 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,9 +335,10 @@ impl App {
|
|||||||
.unwrap_or([0.0, 0.0]),
|
.unwrap_or([0.0, 0.0]),
|
||||||
grabbed: self.input.grabbed,
|
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(
|
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
|
// Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion
|
||||||
|
|||||||
+119
-34
@@ -14,6 +14,7 @@
|
|||||||
use crate::engine::tga::Image;
|
use crate::engine::tga::Image;
|
||||||
use crate::render::font::{self, Font, Fonts};
|
use crate::render::font::{self, Font, Fonts};
|
||||||
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
||||||
|
use crate::session::{Dialog, Mode, Session};
|
||||||
|
|
||||||
/// Feste Textur-Indizes im Sprite-Pass — Reihenfolge = `ui_textures` in
|
/// 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
|
/// 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]);
|
ui.text(font, tx.round(), ty.round(), label, [0.95, 0.95, 1.0, 1.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Das Overlay für den aktuellen Frame bauen.
|
/// Innenabstand vom Panelrand zum Inhalt: 8px Ornamentrahmen + 3px Luft.
|
||||||
//
|
const INSET: f32 = ORN_CORNER + 3.0;
|
||||||
// Stage 4: klickbare Demo-Buttons (nur wenn die Maus frei ist). Als Nächstes
|
const PANEL_BG: [f32; 4] = [0.06, 0.05, 0.10, 1.0];
|
||||||
// verzweigt das hier nach `session.mode` (Dialog-Panel, Menü) und liefert die
|
const PANEL_FRAME: [f32; 4] = [0.85, 0.75, 0.95, 1.0];
|
||||||
// jeweiligen Aktionen.
|
const TEXT: [f32; 4] = [0.92, 0.92, 1.0, 1.0];
|
||||||
pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, cur: &Cursor) -> Screen {
|
|
||||||
|
/// 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 ui = Ui::default();
|
||||||
let mut hover: Option<String> = None;
|
let mut hover: Option<String> = None;
|
||||||
let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font.
|
let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font.
|
||||||
|
|
||||||
// Im Flycam-Modus (Maus gefangen) ist die freie Sicht wichtiger → kein
|
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
||||||
// Panel; sonst die klickbare Demo. Hit-Tests laufen nur bei freier Maus.
|
// 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 {
|
if !cur.grabbed {
|
||||||
let demo = [
|
match &session.mode {
|
||||||
("Hilfe", "help"),
|
Mode::Free => {} // freie Welt: nur HUD/Cursor, kein Panel
|
||||||
("KV anzeigen", "kv"),
|
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
||||||
("Flag setzen", "signal set demo_flag true"),
|
Mode::Dialog(d) => dialog(&mut ui, f, internal, d, cur.pos, &mut hover),
|
||||||
("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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +244,96 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, cur: &Cursor) -> Screen
|
|||||||
Screen { ui, hover_action: hover }
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -312,6 +384,19 @@ mod tests {
|
|||||||
assert!(outside.is_none());
|
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]
|
#[test]
|
||||||
fn cursor_uv_picks_quadrant() {
|
fn cursor_uv_picks_quadrant() {
|
||||||
// idx 3 = (col 1, row 1) → rechte untere 16×16-Zelle.
|
// idx 3 = (col 1, row 1) → rechte untere 16×16-Zelle.
|
||||||
|
|||||||
+32
-5
@@ -20,9 +20,12 @@ pub enum Mode {
|
|||||||
/// Freie Welt — das Fenster rendert die Szene, die Konsole nimmt
|
/// Freie Welt — das Fenster rendert die Szene, die Konsole nimmt
|
||||||
/// Engine-Befehle.
|
/// Engine-Befehle.
|
||||||
Free,
|
Free,
|
||||||
/// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt (sobald
|
/// Menü offen — wie der Dialog pausiert das Fenster die Welt und gibt die
|
||||||
/// Text-Rendering existiert) Panels; Eingaben treiben den Dialog statt
|
/// Maus frei; Eingaben sind weiter Engine-Befehle (das Menü ist nur eine
|
||||||
/// Befehle.
|
/// 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),
|
Dialog(Dialog),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +76,8 @@ impl Session {
|
|||||||
/// Klick denselben Dialog ohne Sonderpfad.
|
/// Klick denselben Dialog ohne Sonderpfad.
|
||||||
pub fn exec(&mut self, line: &str) -> ExecResult {
|
pub fn exec(&mut self, line: &str) -> ExecResult {
|
||||||
match self.mode {
|
match self.mode {
|
||||||
Mode::Dialog(_) => self.dialog_input(line),
|
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 {
|
match line {
|
||||||
"" => ExecResult::empty(),
|
"" => ExecResult::empty(),
|
||||||
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
"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()),
|
"help" => ExecResult::lines(help()),
|
||||||
"kv" => ExecResult::lines(self.dump_kv()),
|
"kv" => ExecResult::lines(self.dump_kv()),
|
||||||
"reload" => {
|
"reload" => {
|
||||||
@@ -189,6 +201,7 @@ fn help() -> Vec<String> {
|
|||||||
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
|
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
|
||||||
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
||||||
" kv KV-Store anzeigen".into(),
|
" kv KV-Store anzeigen".into(),
|
||||||
|
" menu Menü öffnen/schließen".into(),
|
||||||
" reload signals.toml neu laden".into(),
|
" reload signals.toml neu laden".into(),
|
||||||
" quit beenden".into(),
|
" quit beenden".into(),
|
||||||
]
|
]
|
||||||
@@ -218,6 +231,20 @@ mod tests {
|
|||||||
assert!(matches!(s.mode, Mode::Free));
|
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]
|
#[test]
|
||||||
fn unknown_command_reports_and_stays_free() {
|
fn unknown_command_reports_and_stays_free() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
|
|||||||
Reference in New Issue
Block a user