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

293 lines
17 KiB
Markdown
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.
# 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](02-typ-registry.md).
- **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](../../../CONTEXT.md).
### 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.
```rust
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
```rust
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:
```toml
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.