205 lines
11 KiB
Markdown
205 lines
11 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`
|
|
|
|
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: Command }` 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). 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.
|
|
|
|
`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.
|
|
|
|
### 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.
|