11 KiB
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 alscurrent_pageplusOption<Popup>(chess-tui)? Einer ist einfacher, zwei sind klarer bei Popup-über-Popup. - Wo lebt der Listen- und Tabellen-State?
ListStategehört nach ratatui-Logik in die UI. Wird er beimpop()verworfen oder aufbewahrt — steht der Cursor beim Zurückkehren ins Inventar wieder oben? KeyEventKind::Pressallein oder auchRepeat? Rundenbasiert spricht für nurPress, 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ß,Colornachgame/zu ziehen. Alternative: eigene Farb- und Symboltypen indata/, Übersetzung inui/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
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: 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:
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,Exploreund das Grid-Widget; alles andere ist Kulisse. - 14 Kampfformeln:
core:attackundcore:fleemüssen als Fähigkeiten im Kernspiel existieren — ohne sie hatUseAbilityweder Basisangriff noch Flucht.