idk shitload of stuff
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
//! 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};
|
||||
|
||||
/// 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_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;
|
||||
#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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]
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let mut ui = Ui::default();
|
||||
|
||||
// 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]);
|
||||
|
||||
// Cursor zuletzt → liegt über allem. Gefangen (Flycam) → HUD-Pointer
|
||||
// mittig; sonst Maus-Cursor an der Mausposition.
|
||||
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]);
|
||||
}
|
||||
|
||||
ui
|
||||
}
|
||||
|
||||
#[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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user