Files
rustkeep/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md
MDietermann 6f91b44e50 fix(bogen): vorgemerkte Punkte beim Abschicken verbrauchen
Nach Enter stand unspent_points auf 0, waehrend der Screen den Punkt noch
vorgemerkt hielt — die Anzeige rechnete 0 - 1 auf u32 und stuerzte ab.

Der Screen leert sein Feld, waehrend er das Kommando uebergibt, dazu
saturating_sub an den zwei Lesestellen: unspent_points kann sich auch
unter dem offenen Screen aendern.

Erster UI-Test des Projekts. handle_key ist ohne Terminal aufrufbar, und
sobald ein Screen Zustand haelt, ist die Naht zum Spielzustand eine
Fehlerquelle wie jede andere.
2026-08-12 21:20:02 +02:00

17 KiB
Raw Permalink Blame History

11 — UI-Gerüst und Screen-Modell

Type: grilling Status: resolved Blocked by: —

Question

Wie sieht das konkrete Screen-Modell aus, das auf der Empfehlung aus 06 aufsetzt?

Ticket 06 hat die Fakten geliefert und Screen-Stack über zentralem Game-State empfohlen. Die Entscheidungen darüber stehen noch aus:

  • Ein Stack oder zwei? Vec<Screen> für Screens und Popups gemeinsam (sxtetris), oder getrennt als current_page plus Option<Popup> (chess-tui)? Einer ist einfacher, zwei sind klarer bei Popup-über-Popup.
  • Wo lebt der Listen- und Tabellen-State? ListState gehört nach ratatui-Logik in die UI. Wird er beim pop() verworfen oder aufbewahrt — steht der Cursor beim Zurückkehren ins Inventar wieder oben?
  • KeyEventKind::Press allein oder auch Repeat? Rundenbasiert spricht für nur Press, aber ein Grid, das man Feld für Feld antippt, fühlt sich zäh an. UX-Entscheidung, keine technische.
  • Bleibt game/ wirklich ratatui-frei? Sobald Effekte Farben oder Symbole aus TOML mitbringen (Ticket 01, 02), ist die Versuchung groß, Color nach game/ zu ziehen. Alternative: eigene Farb- und Symboltypen in data/, Übersetzung in ui/theme.rs.
  • Welche Screens gibt es zum Start, und welche Aktionen kennt Game::apply(Action)?

Ergebnis: Screen-Enum, Stack-Modell, Action-Liste und die Regel, was game/ nicht importieren darf.

Vorgaben aus anderen Tickets

  • 02: Charakterbogen und Charakterbau sind Registry-Schleifen, keine gemauerten Layouts — Begründung in Ticket 02.
  • 04: Der Startbildschirm trägt eine Zeile mit Warnungsanzahl und Logdateiname („12 Ladewarnungen — logs/load-20260812-112305.log"). Eine Zeile, kein Screen — der Inhalt gehört in einen Texteditor, nicht in ein TUI mit Scrolling und Navigation.

Answer

Begriffe im Glossar.

Zwei Vokabulare, zwei Namen

Der Bericht aus 06 empfahl „genau einen Eintrittspunkt Game::apply(Action)" — Ticket 07 hat Game::apply inzwischen aber für die Aktionen aus 01 vergeben. „Geh nach Norden" ist keine der sieben Aktionen und wird nie eine.

Action TOML-Effektvokabular aus 01 — Game::apply(action, source, target, reg)
Command Kommando der UI ans Spiel — Game::execute(cmd, reg)

Game::execute ist die eine Stelle, an der ein Zug endet: Zähler hoch, befristete Modifikatoren ticken, turn_started feuern, Event-Warteschlange aus 08 leerarbeiten. Direkte Methoden auf Game (game.move_player(dir)) wären lazier gewesen und hätten dasselbe zwölfmal gebraucht — eine Methode hätte es vergessen.

Zwölf Commands, alle kosten einen Zug

Move(Direction) · UseAbility(AbilityId, CreatureRef) · UseItem(usize) · Equip(usize) · Unequip(SlotId) · Drop(usize, u32) · PickUp(usize) · Descend · Talk(NpcId) · Buy(ItemId, u32) · Sell(usize, u32) · Wait

Nachtrag aus Ticket 15: AssignPoints kommt als dreizehnter Command dazu — ein Aufstieg gibt einen frei verteilbaren Attributpunkt, verteilt wird im CharacterSheet. Bewusst ein Command mit allen Punkten auf einmal statt einer je Punkt: Drei Stufen wären sonst drei Züge, in denen ein Gift-Debuff tickt, während man ein Menü bedient. Dasselbe Muster wie Buy(item, 10), und damit ohne Ausnahme von der Zugregel.

Weder Angriff noch Flucht sind eigene Varianten — beides sind Fähigkeiten: UseAbility("core:attack", ziel) und UseAbility("core:flee", …). Ticket 02 hat Fähigkeiten als Registry festgelegt, also gehören sie dorthin, und ein Mod kann beide ersetzen: eine Klasse, die besser flieht, ist damit eine Datenänderung statt eines Engine-Sonderfalls.

Korrektur aus dem Durchstich (Ticket 12): „alle Commands" heißt „alle ausgeführten". Gegen eine Wand zu laufen darf keinen Zug kosten — sonst tickt ein Gift-Debuff, weil jemand danebengetippt hat.

pub enum Outcome { TurnPassed, Rejected(&'static str) }
fn execute(&mut self, cmd: Command) -> Outcome

Der Zug endet nur bei TurnPassed, und der Grund reist mit zurück in die Statuszeile — sonst tut das Spiel schweigend nichts. Das betrifft mehr als Wände: kaufen ohne Gold, eine Fähigkeit ohne Ausdauer, absteigen ohne Treppe. Overlays schließen sich trotzdem, auch bei Rejected: Die Rückfrage wurde beantwortet.

Davon unberührt: Auch Handel kostet einen Zug. Ein fn costs_turn(&self) -> bool wäre eine Ausnahmeliste, die mit jeder neuen Variante gepflegt werden muss und bei der irgendwann jemand falsch rät. Zehn Züge beim Einkauf stören nur, wenn im Laden gekämpft wird; dagegen hilft, dass Buy(item, 10) bereits ein Command ist.

Screen-Navigation ist reine UI und fasst Game nie an — Inventar öffnen ist push(Screen::Inventory), kein Command. Speichern und Laden sind ebenfalls keine Commands: Sie kosten keinen Zug und bekommen ihren Weg von Ticket 09.

Ein Stack, eine Antwort

enum Response { None, Pop, Push(Screen), Command(Command), Quit }
fn handle_key(&mut self, key: KeyEvent) -> Response

Vec<Screen> für Screens und Popups gemeinsam. Popup über Popup ist real und kommt früher als gedacht — Inventar → Gegenstandsdetail → „wirklich wegwerfen?"; getrennte Felder (current plus Option<Popup> wie chess-tui) bräuchten für den dritten Layer eine zweite Mechanik. Der Rendering-Befund aus 06 bleibt unberührt: Clear auf ein zentriertes Sub-Rect ist eine Eigenschaft des Screens, nicht des Stacks. Gerendert wird vom letzten Vollbild-Screen aufwärts.

Der Screen gibt zurück, statt selbst zu schieben — sonst hielte er ein &mut auf genau die Vec, in der er liegt, und die Schleife müsste ihn vorher herausnehmen und danach zurücklegen. Dasselbe Muster wie die Event-Warteschlange in 08: nicht mitten in der Iteration mutieren, sondern zurückgeben und danach ausführen. Nebeneffekt: Response::Command ist der einzige Weg nach game/, ein Screen ohne Command hat den Spielzustand also nachweislich nicht angefasst.

Ein Response::Command schließt alle Overlay-Screens. Das Flag existiert bereits fürs Rendern und beantwortet damit auch „schließt sich nach einer Rückfrage von selbst" — eine Zeile in der Schleife, keine zusätzliche Response-Variante und keine Antwortliste. Confirm { text, on_yes: Box<Response> } wird dadurch für jede Rückfrage wiederverwendbar, ohne eigene Variante je Anlass — Command muss dafür Clone sein, sonst kommt das Kommando nicht aus dem Screen heraus, ohne ihn zu zerstören (Befund aus Ticket 12).

Nachtrag aus Ticket 09: Das Feld hieß ursprünglich on_yes: Command. Die Rückfrage beim Laden eines Speicherstands („Einträge fehlen, fortfahren?") braucht aber keinen Command, sondern einen Screen-Wechsel. Response ist die allgemeinere Form und enthält Command bereits als Variante — der bisherige Fall bleibt erhalten, der Sonderfall entfällt, und die Struktur wird dabei kleiner statt größer. Dass Inventory als Vollbild-Screen nach UseItem offen bleibt, fällt aus derselben Regel — man trinkt selten genau einen Trank.

Zehn Screens

MainMenu (trägt die Ladewarnungs-Zeile aus 04) · CharacterCreation (Registry-Schleife aus 02) · Explore · Combat · Inventory · CharacterSheet · QuestLog · Shop · Dialog · Confirm

Nachtrag aus Ticket 14: GameOver { outcome: Died | Won } als elfter Screen, Vollbild. Das outcome kam aus Ticket 10 dazu — der Abstieg von der letzten definierten Etage gewinnt den Lauf, und Sieg wie Tod zeigen dieselben Zahlen unter einer anderen Überschrift. Die lazy Variante — zurück ins MainMenu mit einer Meldungszeile — wurde bewusst verworfen: Ein Abstiegs-Roguelike lebt davon, dass der Lauf am Ende erzählt wird (Etage, Zug, Gold, Todesursache). Das ist der Moment, für den man fünf Etagen gespielt hat, und kein anderer Screen kann ihn übernehmen.

Kein ItemDetail-Popup: Die Inventarliste zeigt Details in einer Seitenspalte, das ist ein Layout und kein Screen. Shop bleibt getrennt von Dialog, weil Handel zwei Listen nebeneinander braucht und der Dialog eine Textfläche mit Antwortoptionen.

Nachtrag aus M4: Der Dialog-Screen zeigt eine flache Optionsliste, keinen Knotengraph — Text des NPC oben, darunter die Optionen, deren condition gerade hält. Ein Graph waere die Maschine fuers Story-Modul, und das steht unter Out of scope. Talk wirkt dabei auf das Feld, auf dem der Held steht: dieselbe Regel wie PickUp und Descend.

ListState lebt in der Screen-Variante und wird beim pop() mitverworfen. Der Stack macht den wichtigen Fall gratis richtig: Solange ein Screen im Stack liegt, existiert sein Zustand weiter — Inventar → Detail → zurück landet wieder auf demselben Eintrag. Zurückgesetzt wird nur beim echten Schließen und Neuöffnen, und das hat noch nie jemanden gestört. Eine Map ScreenKind → ListState wäre eine Zustandsverwaltung neben dem Stack für genau diesen Randfall.

Eingabe

Nur KeyEventKind::Press. Der Filter ist unter Windows ohnehin Pflicht (06), aber der Grund gegen Repeat ist schwerer als die UX: Ein Schritt ist ein Zug, also bestimmte die Tastaturwiederholrate des Betriebssystems die Spielzeit — ein gedrückt gehaltener Pfeil verbrennte ein Gift-Debuff mit dreißig Zügen pro Sekunde und feuerte turn_started ebenso oft.

Die Decke, falls sich Laufen zäh anfühlt: ein Renn-Kommando („laufe, bis etwas passiert"), das ein Tastendruck ist und in game/ viele Züge auflöst — dort kann es an Gegnern und Abzweigungen anhalten. Das ist die Lösung, die Roguelikes seit Jahrzehnten nehmen, und sie gehört nicht in die Eingabeschleife.

Tastenbelegung fest im Code, Pfeiltasten und hjkl — drei zusätzliche match-Arme sind die billige Höflichkeit gegenüber Vim-Fingern. Eine keybindings.toml bräuchte Parser, Konfliktprüfung („zwei Commands auf i") und einen Einstellungs-Screen, damit sie überhaupt auffindbar ist. Kommt sie doch, wird der match zu einem HashMap<KeyCode, Command>-Lookup und sonst ändert sich nichts.

Modulstruktur

src/
  main.rs          # Laden, Fehlerpfad aus 04, dann ui::run
  game/            # kennt ratatui nicht — Test prueft das
    creature.rs  floor.rs  command.rs  action.rs  event.rs  condition.rs
  data/            # Definitionen, Registries, Mod-Lader aus 03/04
    loader.rs  registries.rs  item.rs  affix.rs  quest.rs
  ui/
    app.rs         # App { game, registries, screens: Vec<Screen> } + Schleife
    input.rs  theme.rs
    screens/  widgets/grid.rs

App hält game und registries als getrennte Felder, damit die disjunkten Borrows aus 07 funktionieren. Command liegt in game/, obwohl er von der UI kommt — er ist Vokabular des Spiels, nicht der Darstellung, und game/ muss ihn ausführen können. Andersherum wäre ui/ die Heimat eines Typs, den game/ importieren muss, und die Abhängigkeit zeigte in die falsche Richtung.

Die ratatui-Freiheit prüft ein #[test], der src/game/**/*.rs liest und auf ratatui sucht. Fünf Zeilen; ein Cargo-Workspace mit game als eigener Crate wäre echte Compiler-Durchsetzung und ist die Decke, falls es je ernst wird.

Befund aus M2: das Grid braucht einen mitlaufenden Bildausschnitt. Ticket 06 hat recherchiert, dass es kein Grid-Widget gibt und Eigenbau nötig ist — aber niemand hat gefragt, was passiert, wenn die Karte größer als der Bildschirm ist. Etage 5 ist 88 Felder breit, ein 80-Spalten-Terminal schneidet ab, und der Held läuft aus dem Bild.

Der Ausschnitt folgt dem Helden und wird am Kartenrand geklemmt; passt die Karte ganz hinein, bleibt er bei (0, 0). Zehn Zeilen in ui/grid.rs, getestet. Kein Scrollbalken, keine Minimap — das wäre die Decke, falls Orientierung je zum Problem wird.

Befund aus M1: Die wörtliche Fassung des Grenztests trägt nicht. Sie schlägt sofort an — nämlich beim Doc-Kommentar von game/mod.rs, der erklärt, dass die Grenze existiert. Der Test muss Kommentarzeilen ausnehmen und sollte Datei, Zeile und Inhalt nennen statt nur „ist gefallen". Steht so in tests/boundaries.rs.

Nachtrag an Ticket 01: Darstellung muss moddbar sein

01 hat symbol als Schlüssel definiert, dessen Zuordnung ui/theme.rs macht. Darin steckt eine Lücke: ui/theme.rs ist Code. Ein Modder, der meinmod:frostbite anlegt, kann dort nichts eintragen — sein Statuseffekt hätte weder Zeichen noch Farbe. Das widerspricht dem Grundsatz aus 02 und 03, dass Inhalt moddbar ist.

Stattdessen trägt die TOML das Zeichen selbst und einen Farbnamen aus fester Liste:

symbol = "†"
color  = "cyan"

data/ kennt nur char und ein Farbnamen-Enum, ui/theme.rs übersetzt in ratatui::Color. game/ und data/ bleiben ratatui-frei, weil ein Farbname eine Zeichenkette ist.

Auflagen an andere Tickets

  • 09 Save-Schema: Speichern und Laden sind keine Commands und brauchen einen eigenen Weg.
  • 12 Prototyp: Der Durchstich baut MainMenu, Explore und das Grid-Widget; alles andere ist Kulisse.
  • 14 Kampfformeln: core:attack und core:flee müssen als Fähigkeiten im Kernspiel existieren — ohne sie hat UseAbility weder Basisangriff noch Flucht.

Nachtrag — zwei Cursor im Kampf-Screen (nach M4)

Ticket 11 hatte die Zielwahl den Zifferntasten überlassen: Enter traf den ersten Gegner, 1–9 feuerten sofort auf den n-ten. Das reicht, solange man nur zuschlägt, und bricht, sobald die Wahl des Ziels eine Entscheidung ist — dieselbe Taste hätte je nach Absicht zwei Bedeutungen.

Der Screen trägt jetzt zwei Cursor, pick für die Fähigkeit und target für den Gegner, und ein targeting: bool sagt, welcher aktiv ist. Tab schaltet um, der Titel des aktiven Rahmens trägt · aktiv. Das ist dieselbe Bedienung wie im Laden — eine Umschaltregel für alle zweispaltigen Screens statt einer je Screen.

Die Ziffern bleiben, aber sie springen nur noch aufs Ziel, statt zu feuern; ausgelöst wird ausschließlich mit Enter. Zwei gleichzeitig sichtbare Cursor mit Pfeilen auf beiden Achsen wären die Alternative gewesen — weniger Tastendrücke, aber eine Bedienung, die es sonst nirgends im Spiel gibt.

Dazu zeigt die Gegnerliste unter jedem Lebensbalken die Ansage aus Ticket 14 (↳ Wuchtschlag → dich, sonst ↳ zögert). Sie steht bewusst als eigene Zeile und nicht als Kürzel hinter dem Namen: Sie ist die Information, auf der die Entscheidung dieses Zuges beruht.

Nachtrag — die Werkbank (Debug-Screen, nach M4)

Ein zwölfter Screen, F12 aus Erkundung und Kampf: Gegenstandsliste mit Cursor, dazu vier Tasten für heilen, absteigen, Raum leeren.

Sie geht durch dieselbe Tür wie alles andere. Es wäre kürzer gewesen, den Screen direkt auf Game schreiben zu lassen — er hat den &mut ohnehin nicht, aber man hätte ihn ihm geben können. Das hätte die Zusage aus Ticket 11 gebrochen, dass Response::Command der einzige Weg nach game/ ist, und zwar ausgerechnet an der Stelle, die am seltensten gelesen wird. Stattdessen ein Kommando Debug(Cheat) mit vier Varianten — eins, nicht vier, sonst zögen sich Cheats durch rejects und perform wie echte Kommandos. In rejects steht genau eine Zeile: Ein Cheat wird nie abgewiesen.

ClearRoom setzt die Lebenspunkte auf null, statt enemies zu leeren. So läuft der normale Weg samt Todes-Triggern, Erfahrung (später) und „Der Raum ist geräumt" — ein Cheat, der am Spielablauf vorbeigeht, testet den Spielablauf nicht.

Immer mitgebaut, nicht hinter cfg(debug_assertions). Das wäre ein #[cfg] an der Variante und an jedem match darüber — für ein Spiel, das niemand ausliefert. Ein #[cfg] reicht später nach, wenn es je einen Release-Build gibt.

Befund aus M5 — die Naht zwischen Screen-Zustand und Spielzustand

Ticket 11 trennt sauber: Ein Screen fasst Game nicht an, er gibt ein Command zurück. Der Charakterbogen hält seit M5 aber selbst Zustand — die vorgemerkten Punkte, die erst mit Enter abgeschickt werden. Beim Abschicken zieht das Spiel sie ab, der Screen behielt sie: unspent_points stand auf 0, pending auf 1, und die Anzeige rechnete 0 - 1 auf u32. Absturz.

Das ist keine Schwäche der Trennung, sondern ihr Preis: Wer Zustand doppelt hält, muss sagen, wann er abgeglichen wird. Die Antwort hier ist die einfachste mögliche — vorgemerkt wird beim Abschicken verbraucht, der Screen leert sein Feld, während er das Kommando übergibt. Dazu saturating_sub an den zwei Lesestellen, weil unspent_points sich auch unter dem offenen Screen ändern kann (ein Aufstieg im selben Zug).

Dabei ist der erste UI-Test des Projekts entstanden. Bis M5 war ui/ ungetestet, weil dort angeblich nur Anzeige liegt — der Absturz zeigt, dass das nicht stimmt: Sobald ein Screen Zustand hält, ist die Naht zum Spielzustand eine Fehlerquelle wie jede andere. handle_key ist ohne Terminal aufrufbar, der Test kostet zwanzig Zeilen.