Gegenstandsliste mit Cursor, dazu heilen, absteigen und Raum leeren. Laeuft ueber ein Kommando Debug(Cheat) statt am Spielkern vorbei — die Zusage aus Ticket 11, dass Response::Command der einzige Weg nach game/ ist, gilt auch fuer Cheats. ClearRoom toetet die Gegner, statt die Liste zu leeren: So laeuft der normale Weg samt Todes-Triggern und "Der Raum ist geraeumt".
275 lines
16 KiB
Markdown
275 lines
16 KiB
Markdown
# 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.
|