//! UI-Overlay: **state-driven** und **immediate-mode**. //! //! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält //! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die //! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem //! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü → //! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch //! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein //! einziger Trichter in den State, kein UI-Backdoor. //! //! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet, //! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`]. 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 /// rein aus dem Tint), `FONT_*` die CP437-Glyph-Atlanten, `CURSORS` das /// 2×2-Raster der 16×16-Cursor, `ORN` der 9-Slice-Ornamentrahmen. pub(crate) const WHITE: usize = 0; pub(crate) const FONT_EGA: usize = 1; pub(crate) const FONT_CGA: usize = 2; pub(crate) const CURSORS: usize = 3; 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 pub(crate) const CUR_HUD_INTERACT: usize = 3; // dito, über einem Klick-Ziel const CURSOR_PX: f32 = 16.0; const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel) /// 1×1 weißes RGBA8-`Image` für getintete Solid-Flächen. pub(crate) fn white_pixel() -> Image { Image { width: 1, height: 1, rgba: vec![255, 255, 255, 255] } } /// Weiß-auf-schwarz-Sprite (Font, Cursor, Ornament) → getintbare Maske: /// rgb = weiß, alpha = Luminanz. So blendet der Sprite-Pass den schwarzen /// Hintergrund weg und der Tint setzt die Farbe. Reine CPU-Nachbearbeitung /// beim Laden. pub(crate) fn key_luminance(img: &Image) -> Image { let mut rgba = img.rgba.clone(); for px in rgba.chunks_mut(4) { let lum = px[0].max(px[1]).max(px[2]); px[0] = 255; px[1] = 255; px[2] = 255; px[3] = lum; } Image { width: img.width, height: img.height, rgba } } /// Cursor, wie ihn die App pro Frame an [`layout`] gibt. pub(crate) struct Cursor { /// Position in internen Pixeln (über die Letterbox gemappt). pub(crate) pos: [f32; 2], /// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt /// Maus-Cursor an `pos`. pub(crate) grabbed: bool, /// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt- /// Variante des HUD-Pointers. pub(crate) hud_interact: bool, } /// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass. #[derive(Default)] pub(crate) struct Ui { pub(crate) verts: Vec, pub(crate) batches: Vec, } impl Ui { /// Ein getintetes Quad. `rect` = (x, y, w, h) in internen Pixeln /// (oben-links), `uv` = (u0, v0, u1, v1) in 0..1. Aufeinanderfolgende /// Quads derselben Textur landen im selben Batch. pub(crate) fn quad(&mut self, tex: usize, rect: [f32; 4], uv: [f32; 4], color: [f32; 4]) { let [x, y, w, h] = rect; let [u0, v0, u1, v1] = uv; let base = self.verts.len() as u32; // Zwei Dreiecke (TL, BL, BR / TL, BR, TR). let tl = SpriteVertex { pos: [x, y ], uv: [u0, v0], color }; let bl = SpriteVertex { pos: [x, y + h], uv: [u0, v1], color }; let br = SpriteVertex { pos: [x + w, y + h], uv: [u1, v1], color }; let tr = SpriteVertex { pos: [x + w, y ], uv: [u1, v0], color }; self.verts.extend([tl, bl, br, tl, br, tr]); // An letzten Batch anhängen, wenn gleiche Textur, sonst neuen öffnen. match self.batches.last_mut() { Some(b) if b.texture == tex => b.count += 6, _ => self.batches.push(SpriteBatch { texture: tex, start: base, count: 6 }), } } /// Getintetes Solid-Rechteck (weißer Texel). pub(crate) fn fill(&mut self, rect: [f32; 4], color: [f32; 4]) { self.quad(WHITE, rect, [0.0, 0.0, 1.0, 1.0], color); } /// Eine Textzeile ab (x, y) oben-links in `font`, proportional gesetzt /// (variable Laufweite, leere Glyphen werden übersprungen). `\n` springt /// eine Zeile weiter. Gibt die x-Endposition zurück. pub(crate) fn text(&mut self, font: &Font, x: f32, y: f32, s: &str, color: [f32; 4]) -> f32 { let (mut cx, mut cy) = (x, y); for ch in s.chars() { if ch == '\n' { cx = x; cy += font.glyph_h; continue; } let code = font::cp437(ch); if let Some((uv, w)) = font.glyph_quad(code) { self.quad(font.tex(), [cx, cy, w, font.glyph_h], uv, color); } cx += font.advance(code); } cx } /// Ein Cursor-Sprite (16×16, Pivot mittig) an `pos`, getintet. pub(crate) fn cursor(&mut self, idx: usize, pos: [f32; 2], color: [f32; 4]) { let rect = [pos[0] - CURSOR_PX * 0.5, pos[1] - CURSOR_PX * 0.5, CURSOR_PX, CURSOR_PX]; self.quad(CURSORS, rect, cursor_uv(idx), color); } /// Opakes Panel mit Ornamentrahmen. `rect` = Außenbounds. Erst der /// Hintergrund (Solid, getintet), dann der 9-Slice-Rahmen darüber: feste /// 8×8-Ecken, die Kanten dazwischen gekachelt bis zur nächsten Ecke. pub(crate) fn panel(&mut self, rect: [f32; 4], bg: [f32; 4], frame: [f32; 4]) { self.fill(rect, bg); self.ornament_frame(rect, frame); } fn ornament_frame(&mut self, rect: [f32; 4], color: [f32; 4]) { let [x, y, w, h] = rect; let c = ORN_CORNER; let a = ORN_ATLAS; // Vier Ecken (1:1 aus dem Atlas). self.quad(ORN, [x, y, c, c], orn_uv(0.0, 0.0, c, c), color); self.quad(ORN, [x + w - c, y, c, c], orn_uv(a - c, 0.0, c, c), color); self.quad(ORN, [x, y + h - c, c, c], orn_uv(0.0, a - c, c, c), color); self.quad(ORN, [x + w - c, y + h - c, c, c], orn_uv(a - c, a - c, c, c), color); // Kanten zwischen den Ecken kacheln. self.tile_h(x + c, x + w - c, y, c, [c, 0.0], color); // oben self.tile_h(x + c, x + w - c, y + h - c, c, [c, a - c], color); // unten self.tile_v(y + c, y + h - c, x, c, [0.0, c], color); // links self.tile_v(y + c, y + h - c, x + w - c, c, [a - c, c], color); // rechts } /// Horizontale Kante von `x0` bis `x1` bei `y` (Höhe `c`) mit dem /// `c`-breiten Atlas-Sample ab `src` (Pixel) kacheln; letzte Kachel wird /// passend beschnitten. fn tile_h(&mut self, x0: f32, x1: f32, y: f32, c: f32, src: [f32; 2], color: [f32; 4]) { let mut cx = x0; while cx < x1 - 0.5 { let seg = (x1 - cx).min(ORN_EDGE); self.quad(ORN, [cx, y, seg, c], orn_uv(src[0], src[1], seg, c), color); cx += seg; } } fn tile_v(&mut self, y0: f32, y1: f32, x: f32, c: f32, src: [f32; 2], color: [f32; 4]) { let mut cy = y0; while cy < y1 - 0.5 { let seg = (y1 - cy).min(ORN_EDGE); self.quad(ORN, [x, cy, c, seg], orn_uv(src[0], src[1], c, seg), color); cy += seg; } } } const ORN_ATLAS: f32 = 32.0; // Ornament-Atlasgröße (32×32) const ORN_EDGE: f32 = 16.0; // Kanten-Sample-Länge (zwischen den 8px-Ecken) /// Atlas-Pixel-Rechteck → UV (0..1) im 32×32-Ornamentatlas. fn orn_uv(x: f32, y: f32, w: f32, h: f32) -> [f32; 4] { [x / ORN_ATLAS, y / ORN_ATLAS, (x + w) / ORN_ATLAS, (y + h) / ORN_ATLAS] } /// UV-Rechteck einer Cursor-Zelle im 2×2-Raster (16×16 je Zelle, Atlas 32×32). fn cursor_uv(idx: usize) -> [f32; 4] { let col = (idx % 2) as f32; let row = (idx / 2) as f32; let (x0, y0) = (col * CURSOR_PX, row * CURSOR_PX); [x0 / 32.0, y0 / 32.0, (x0 + CURSOR_PX) / 32.0, (y0 + CURSOR_PX) / 32.0] } /// 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, } /// 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, ) { 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 = None; let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font. // 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 { // Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen // ohnehin gefangen — dieser Zweig greift nur im Übergang). Mode::FirstPerson | Mode::Free => {} 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 → über allem. Gefangen → HUD-Pointer mittig; sonst // Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht. if cur.grabbed { let idx = if cur.hud_interact { CUR_HUD_INTERACT } else { CUR_HUD }; ui.cursor(idx, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]); } else { let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE }; ui.cursor(idx, cur.pos, [1.0; 4]); } 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ü → ins Spiel (Play) ("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::*; #[test] fn same_texture_quads_merge_into_one_batch() { let mut ui = Ui::default(); ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]); ui.fill([20.0, 0.0, 10.0, 10.0], [1.0; 4]); assert_eq!(ui.verts.len(), 12); // 2 Quads · 6 Verts assert_eq!(ui.batches.len(), 1); // gleiche Textur → ein Batch assert_eq!(ui.batches[0].count, 12); } #[test] fn different_textures_split_batches() { let mut ui = Ui::default(); ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]); // WHITE ui.quad(1, [0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 1.0, 1.0], [1.0; 4]); // Textur 1 ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]); // WHITE wieder assert_eq!(ui.batches.len(), 3); assert_eq!((ui.batches[0].start, ui.batches[1].start, ui.batches[2].start), (0, 6, 12)); } #[test] fn key_luminance_white_opaque_black_transparent() { let img = Image { width: 2, height: 1, rgba: vec![255, 255, 255, 255, 0, 0, 0, 255] }; let k = key_luminance(&img); assert_eq!(&k.rgba[0..4], &[255, 255, 255, 255]); // weiß → opak 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. assert_eq!(cursor_uv(3), [0.5, 0.5, 1.0, 1.0]); assert_eq!(cursor_uv(0), [0.0, 0.0, 0.5, 0.5]); } #[test] fn ornament_frame_tiles_edges_and_places_corners() { let mut ui = Ui::default(); // 48×48-Panel: Kanten je 48-16=32 px → 32/16 = 2 Kacheln pro Seite. // Erwartung: 4 Ecken + 4·2 Kanten = 12 Ornament-Quads (nach dem fill). ui.panel([0.0, 0.0, 48.0, 48.0], [0.0; 4], [1.0; 4]); let orn_quads = ui.verts.len() / 6 - 1; // minus das Hintergrund-fill assert_eq!(orn_quads, 12); } }