Brainstormed design for backlog #6/#7: shell loop above the game loop, GameIO.choose() primitive (console default + Swing buttons), GameSession bundling savable state, JSON SaveData delta over the YAML world, single active slot, autosave (quest-complete + quit + every 10 turns), and minimal runtime-togglable settings (color/glyph mode). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
Spec: Hauptmenü + Speichern/Laden (+ minimale Settings)
Stand: 2026-06-01. Erstes Teilprojekt der Erweiterungsrunde nach Abschluss von
Phasen 1–7 (Backlog #6 + #7, Teil von #6-Settings). Baut auf vorhandenem
GameIO-Abstraktions-Modell, GameContext (World/Player/GameState/QuestLog) und
dem App.run(io)-Einstieg auf, der von Konsole und GUI geteilt wird.
1. Kontext & Ziel
Das Spiel startet bisher direkt in die Spielschleife und endet per quit /
Ending / EOF mit Prozess-Ende; es gibt keine Persistenz. Ziel:
- Hauptmenü oberhalb der Spielschleife (Neues Spiel · Laden · Einstellungen ·
Beenden), identisch in Konsole und GUI über die geteilte
GameIO-Abstraktion. - Speichern/Laden des veränderlichen Spielzustands als JSON, als Delta über die frisch aus YAML geladene Welt (Welt-Definition bleibt datengetrieben; der Spielstand enthält nur den mutierten Zustand).
- Ein aktiver Slot pro laufendem Spiel (beim Neues-Spiel benannt). Manuelles
save, Autosave und Speichern-beim-Verlassen schreiben alle in diesen Slot. - Minimale Settings: Farb-Modus + Glyphen-Modus (ASCII/Unicode) umschalten, persistiert.
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
- Menü-Integration: eine neue
GameIO-Primitivechoose(...)(Konsole-Default = nummeriertes Textmenü,SwingIOüberschreibt mit Buttons). Menü-Logik einmalig geschrieben, Konsolen-Parität gratis. - Save-Format: JSON (maschinengenerierter Zustand; klar getrennt vom YAML-Content). Jackson ist bereits Abhängigkeit.
- Slot-Modell: ein aktiver Slot für alles (save + autosave + quit-save überschreiben denselben Slot; Menü-Laden listet alle benannten Spielstände).
quit/exit/menuin-game = speichern + zurück ins Hauptmenü; Prozess-Ende nur über das Hauptmenü-„Beenden".- Neues Spiel: Slot-Name abfragen, mit Default (Enter akzeptiert Default).
- Autosave: Events (Quest abgeschlossen, quit-to-menu) + Intervall alle 10 Züge.
- Settings-Datei:
saves/settings.json.
2. Scope
In Scope:
- Shell-Schleife in
App.run(io)(Menü-Schicht über dem Game-Loop). menu-Paket:MainMenu,SettingsMenu,MenuAction.GameIO.choose(String title, List<String> options) → int+ Konsole-Default (nummeriert, Re-Prompt) +SwingIO-Override (Buttons).GameSession(Bündel des speicherbaren Zustands + Slot-Name +turn).SaveData-DTO (JSON) +SaveService(save/list/load, atomar) +SaveSlotInfo.- In-Game-Befehle:
save,menu(Aliasequit/exit). Keinload-Befehl. - Autosave: Quest-Completion-Hook, quit-to-menu, Intervall alle 10 Züge.
Settings+SettingsStore(saves/settings.json);ConsoleIO-Farb-/Glyphen- Modus zur Laufzeit umschaltbar machen (Felder non-final + Setter).- Tests: Round-Trip, Overlay, Failure-Fälle, Menü-Logik, Konsolen-
choose.
Out of Scope:
- Mehrere Slots pro Spiel / getrennter Autosave-Slot (bewusst „ein aktiver Slot").
- Settings für Musik/Typewriter (Features existieren noch nicht).
- GUI-Hintergrundbilder/„echte" Menü-Screens (Buttons genügen).
- Pathfinding /
go to <raum>(eigene spätere Runde, in Scope gehalten). - Migration alter Save-Versionen (nur Versions-Check + graceful refuse).
3. Architektur: Shell-Schleife über dem Loop
App.run(io) wird zur Shell-Schleife, die den Lebenszyklus besitzt. Der
Prozess endet nur über Hauptmenü-„Beenden"; Spielende/menu kehren ins Menü
zurück.
App.run(io):
Settings s = SettingsStore.load(); // → auf io anwenden (Farbe/Glyphen)
loop:
switch (MainMenu.show(io)) {
NEW_GAME -> { GameSession g = newSession(promptName(io)); play(io, g); }
LOAD -> { SaveSlotInfo slot = pickSlot(io); // oder „zurück"
if (slot != null) {
try { play(io, SaveService.load(slot.slug())); }
catch (SaveException e) { io.write(e.getUserMessage()); }
} }
SETTINGS -> SettingsMenu.show(io, s); // toggelt + persistiert + wendet an
QUIT -> return;
}
play(io, session):
GameContext ctx = new GameContext(session); // s.u.
CommandRegistry registry = buildRegistry(...); // inkl. save/menu
Game game = new Game(ctx, registry, parser);
Banner + Welcome + erstes look;
game.run(); // kehrt zurück bei menu/quit/Ending/EOF
newSession(name) lädt die Welt frisch aus YAML (WorldLoader) und baut eine
neue GameSession. SaveService.load lädt die Welt ebenso frisch und legt das
Save-Delta darüber.
4. GameSession (game)
Bündelt den speicherbaren Zustand an einem Ort (heute über GameContext +
privates turn in Game verstreut). turn wandert hierher, damit es gespeichert
und in der Slot-Metadata gezeigt werden kann.
public class GameSession {
private final World world; // aus YAML geladen (statisch)
private final Player player;
private final GameState state;
private final QuestLog questLog;
private int turn;
private final String slotName; // gebundener aktiver Slot
// getter; getTurn/setTurn/incrementTurn
}
GameContext bekommt einen Konstruktor GameContext(GameSession, GameIO) (oder
hält eine GameSession-Referenz statt der vier Einzelfelder). Bestehende
Command-Signaturen (execute(ctx, args)) bleiben unverändert; ctx.getWorld()
etc. delegieren an die Session. Game liest/erhöht turn über die Session statt
über ein eigenes Feld.
5. Menü-Schicht (menu)
public enum MenuAction { NEW_GAME, LOAD, SETTINGS, QUIT }
public final class MainMenu { // frontend-agnostisch
public static MenuAction show(GameIO io) {
int i = io.choose("Haunted Manor", List.of(
"New Game", "Load Game", "Settings", "Quit"));
return MenuAction.values()[i]; // Reihenfolge = Indizes
}
}
SettingsMenu.show(GameIO, Settings) toggelt Farbe/Glyphen über wiederholte
choose-Aufrufe (mit „zurück"-Option), persistiert via SettingsStore und wendet
live auf das io an. pickSlot/promptName nutzen choose bzw. readLine.
GameIO.choose (io)
Neue Primitive auf GameIO:
/** Zeigt eine Auswahl; gibt den 0-basierten Index der Wahl zurück. */
default int choose(String title, List<String> options) {
print(/* Titel + nummerierte Liste 1..n als StyledText */);
while (true) {
String line = readLine();
Integer n = parseInRange(line, 1, options.size());
if (n != null) return n - 1;
write("Please enter a number between 1 and " + options.size() + ".");
}
}
- Konsole nutzt den Default (nummeriert + Re-Prompt). EOF/
null→ letzte Option (Konvention: Menüs ordnen die sichere/abbrechende Wahl — „Quit" bzw. „zurück" — stets als letzte Option, sodass EOF nie in einen Zustand führt). SwingIOüberschreibtchoose: rendert Buttons (ein Button je Option), blockiert den Worker-Thread bis zum Klick — dasselbe Blocking-Handoff-Muster wie das vorhandenereadLine(EDT ↔ Worker). Kein Re-Prompt nötig (Buttons sind immer gültig).
6. In-Game-Befehle (command/impl)
SaveCommand(save):SaveService.save(session)→ „Game saved." / bei Fehler graceful Meldung, Loop läuft weiter.MenuCommand(menu, Aliasequit,exit):SaveService.save(session)danngame.stop()→ zurück ins Hauptmenü. Ersetzt den bisherigenQuitCommand(Bindung anGamewie gehabt viabind(game)).- Kein
load-Befehl — Laden ausschließlich über das Hauptmenü.
7. Autosave
Ein aktiver Slot; alle Autosaves überschreiben ihn. Trigger:
- Quest abgeschlossen — Hook in
QuestEngine(nachquestLog.complete(id)), ruft eineAutosaver/SaveService.save(session)-Callback auf. Der Engine darf dafür Zugriff auf einen Save-Callback bekommen (z.B. viaGameContext), um die Schichtung (game → loader) nicht zu verletzen. - quit-to-menu —
MenuCommandspeichert ohnehin. - Intervall — in
Game.run()nach jedem erfolgreichen Befehl: wennturn % 10 == 0,SaveService.save(session).
Autosave-Fehler werden geloggt und schlucken (kein Loop-Abbruch); optional eine dezente Meldung.
8. Save-Datenmodell: SaveData (loader/save)
JSON-DTO, reines Delta über die YAML-Welt:
public record SaveData(
int schemaVersion, // aktuell 1; Mismatch → refuse
String worldTitle, // Sanity-Check gegen geladene Welt
String slotName,
long savedAtEpochMillis, // System.currentTimeMillis()
int turn,
String currentRoomId,
List<String> visitedRoomIds, // geordnet
int gold,
List<String> inventoryItemIds, // geordnet
List<String> flags, // gesetzte World-Flags
Map<String,Integer> questStages, // aktive Quest → Stage-Index
List<String> questCompleted,
Map<String,List<String>> roomItemIds, // roomId → Item-Ids im Raum
Map<String,Boolean> switchStates // SwitchableItem-Id → on/off
) {}
inventoryItemIds + roomItemIds erfassen die Item-Platzierung vollständig;
Items in keinem von beiden gelten als verbraucht/entfernt. Switch-Zustände decken
Lampe/Generator/Tür ab.
9. Persistenz: SaveService (loader/save)
- Verzeichnis
saves/(relativ zum Arbeitsverzeichnis, on-demand angelegt, in.gitignore). Ein Slot =saves/<slug>.json,slug= sanitisierter Name ([a-z0-9_-], Rest →_). save(GameSession):SaveDataaus Session bauen → Jackson → atomar schreiben (Temp-Datei +Files.moveATOMIC_MOVE/REPLACE_EXISTING).list() → List<SaveSlotInfo>:saves/*.jsonscannen, Header-Felder lesen (slotName,currentRoomId,turn,savedAtEpochMillis) → für die Laden-Liste. Beschädigte Dateien überspringen (geloggt).load(slug) → GameSession:SaveDatalesen → Welt frisch viaWorldLoader→SaveApplierlegt das Delta darüber →GameSession.SaveApplier: löst Ids gegen die frische Welt auf (Räume, Items). Setzt Player-Raum/Gold/Inventar/Visited, World-Flags, QuestLog (Stages + completed), verteilt Items in Räume/Inventar, setzt Switch-Zustände,turn.
public record SaveSlotInfo(String slug, String slotName, String roomId,
int turn, long savedAtEpochMillis) {}
10. Minimale Settings (game/io)
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
public static Settings defaults() { return new Settings(ColorMode.AUTO, GlyphMode.UNICODE); }
}
SettingsStore.load()/save(Settings)↔saves/settings.json(Jackson). Fehlend/ beschädigt →Settings.defaults().ConsoleIO:useColor/glyphsvonfinalauf veränderlich umstellen +setColorMode/setGlyphMode, damit ein Settings-Toggle live wirkt (heute konstruktor-fix).App.mainlädt Settings vor dem Menü und konstruiert/füttertConsoleIOdamit.SwingIO: Glyphen/Farbe für die GUI ggf. No-Op bzw. minimal (GUI rendert eigene Stile); Settings-Wirkung primär Konsole. (Bestätigen im Plan.)
11. Fehlerbehandlung
SaveException(eigene, mitgetUserMessage()): beschädigtes/unlesbares JSON, Schreibrechte fehlen, Schema-Mismatch, unbekannte Ids → gefangen, geloggt, benutzerfreundliche Meldung; nie Absturz. Load-Fehler → zurück ins Menü.- Konsolen-
choose: ungültige Eingabe → Re-Prompt;null/EOF → sichere Vorgabe. - Leere Slot-Liste beim Laden → „No saved games." → zurück ins Menü.
- GUI-Buttons können keine ungültige Wahl liefern.
12. Tests
SaveServiceTest(Temp-Dir): Session bauen →save→load→ volle Zustandsgleichheit (Raum, Inventar, Item-Platzierung, Flags, Quest-Stages + completed, Switch-Zustände, Gold,turn).SaveDataTest: Jackson (De-)Serialisierung stabil; Overlay auf frische Welt (verschobene Items, Quest mitten in Stage).- Failure: beschädigtes JSON, unbekannte Raum-Id, Schema-Mismatch →
SaveExceptionmit sinnvoller Meldung;list()überspringt kaputte Dateien. MainMenu/SettingsMenu: Fake-GameIOmit skriptbarenchoose-Ergebnissen; prüft Aktions-Mapping + Settings-Persistenz.- Konsolen-
choose-Default: nummeriertes Rendering + Bad-Input-Re-Prompt über gemocktesreadLine. - Konvention:
SwingIO.choose/GUI von Unit-Tests ausgenommen (manuelle Verifikation), wie etabliert keine YAML-/GUI-abhängigen Domain-Tests.
13. Architektur-Werte / Risiken
- Geteilter Loop bleibt zentral: nur eine neue IO-Primitive; Menü-Logik ist frontend-agnostisch, nur das Rendering divergiert (Default vs. Override).
- Datengetrieben: Welt weiter aus YAML; Save ist reines Delta — Content-Updates brechen alte Saves höchstens bei entfernten Ids (→ graceful refuse).
- Schichtung:
SaveServicelebt unterloader(kenntWorldLoader); der Quest-Autosave-Hook braucht einen Save-Callback imGameContext, umgamenicht direkt vonloaderabhängig zu machen — im Plan sauber verdrahten. - GUI-Threading:
SwingIO.choosemuss dasselbe EDT↔Worker-Blocking-Muster wiereadLinenutzen; sonst Deadlock-Gefahr.