Files
wds/src/render/ui.rs
T
irrlicht f7bfdb4a0b Blender-Import-Pipeline: glTF/OBJ, col_*-Proxies, Trigger
Blender wird das Haupt-3D-Tool: zwei Loader (engine/gltf.rs für .glb als
Hauptpfad, engine/obj.rs aus irl3d eingebettet) liefern ein neutrales
engine/model.rs::Model. Loader sind reine Dekoder; das Frontend zieht sie
und schiebt das Ergebnis per Session::load_props in den State, der
Renderer baut sein Mesh (render/props.rs, verschmolzen mit der
Brush-Welt über eine geteilte Texturliste).

glTF trägt Blender-Custom-Properties (node.extras → Object::props) und
Empties (Nodes ohne Mesh) als Entity-Kanal. Namenskonvention: `col_*` =
unsichtbarer Collision-Proxy, aus dem collision::add_model konvexe
Ebenen-Sets baut (konvex-only, daher kein Default-Collider auf
Sichtgeometrie).

Trigger (engine/trigger.rs) hängen an der Custom Property `signal`:
mit Collider = Point-and-Click-Ziel (Session::pick raycastet aus der
Bildmitte, Treffer läuft durch den `use <name>`-Trichter), ohne Collider
= unsichtbare Betretens-Zone, die einmal je Eintritt feuert. Gefeuert
wird nur über Session::exec — der Eingabe-Trichter bleibt der einzige
Weg in den State.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00

422 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<SpriteVertex>,
pub(crate) batches: Vec<SpriteBatch>,
}
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<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.
// 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<String>) {
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<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)]
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);
}
}