Files
Jander_Semester2/Semesterprojekt/docs/superpowers/specs/2026-06-01-tutorial-design.md
Jean-Luc Makiola 055a256b03 docs: spec for interactive start-of-game tutorial
Interactive walkthrough layered on the real game loop via a TutorialGuide
(optional in Game): core arc look->examine->take->inventory->use->go->go to,
verb-based alias-aware completion (movement steps require an actual room
change), closing tips, skippable via 'skip'. Data-driven tutorial.yaml +
separate TutorialLoader (not in World). Shown only on New Game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:10:13 +02:00

255 lines
13 KiB
Markdown

# Spec: Interaktives Start-Tutorial
Stand: 2026-06-01. Dritte der verbleibenden Mechaniken (nach `use X on Y` und
Pathfinding). Setzt das vom Nutzer gewünschte **interaktive Tutorial beim
Spielstart, abbrechbar per `skip`** um. Baut auf dem geteilten Game-Loop,
`CommandRegistry`/`CommandParser`, `GameIO` und dem datengetriebenen Loader auf.
## 1. Kontext & Ziel
Neue Spieler kennen die Befehle nicht. Ziel: ein **interaktiver Walkthrough** zu
Beginn eines **Neuen Spiels**, der den Spieler im **echten Startraum** Schritt für
Schritt die Kernbefehle ausführen lässt (er wartet, bis der jeweilige Befehl kam),
mit Bestätigung/Hinweisen, und am Ende die übrigen Befehle als Tipps nennt.
Jederzeit per `skip` überspringbar. Geladene Spielstände zeigen kein Tutorial.
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
- **Stil**: interaktiv (wait-for-it) im echten Spiel, nicht in einer Sandbox.
- **Kern-Bogen** (interaktiv, in Reihenfolge): `look``examine``take`
`inventory``use``go` (Richtung) → `go to` (Pathfinding). Danach
**Abschluss-Tipps** für den Rest (read, drop, talk/give, use X on Y, map,
quests, save, menu).
- **Abschluss-Erkennung verb-basiert** (alias-fähig): jede gültige Nutzung des
erwarteten Verbs zählt; optional `minArgs`.
- **Datengetrieben**: `tutorial.yaml`; Logik (Verb-/Richtungs-Matching) im Code.
- **Häufigkeit**: bei jedem Neuen Spiel; `skip` überspringt; keine Persistenz.
- **Falscher (aber gültiger) Befehl**: wird **ausgeführt** (echtes Spiel) + sanfter
Hinweis, Schritt rückt **nicht** vor.
- **Platzierung**: **nach** Banner + Willkommen + erstem Raum-`look`.
## 2. Scope
**In Scope:**
- `TutorialGuide` (game), in den Game-Loop eingehängt (optional; null = kein Tutorial).
- `Tutorial`/`TutorialStep` (model) + `TutorialDto`/`TutorialStepDto` (loader/dto)
+ `TutorialLoader` (optionales `/world/tutorial.yaml`, eigener Loader — nicht in `World`).
- `Game`: optionaler Guide (`setTutorialGuide`) + `begin`/`onCommand`/`skip`-Anbindung.
- `App.play(..., withTutorial)`: Guide nur beim Neuen Spiel bauen.
- `tutorial.yaml`: Intro, 7 Kern-Schritte, Abschluss-Tipps.
- Tests: Guide-Logik, Loader, Game-Integration.
**Out of Scope:**
- Persistenz „Tutorial gesehen" / Settings-Toggle (eigene spätere Option).
- Sandbox-Welt / Zurücksetzen von Tutorial-Aktionen (es ist das echte Spiel).
- Erzwungenes `use X on Y` / `talk` etc. (nur in Abschluss-Tipps genannt).
- GUI-Sonderdarstellung (Tutorial nutzt die normale styled Ausgabe; `skip` per Eingabe).
## 3. Datenmodell (model)
```java
public record TutorialStep(
String instruction, // an den Spieler: was tun
String expect, // Verb-Name ("look"/"examine"/...) ODER "go_direction" / "go_to_room"
int minArgs, // Mindest-Argumentzahl (z.B. 1 für take/examine/use); 0 default
String confirm, // Bestätigung bei Erfolg
String hint // sanfter Hinweis bei gültigem-aber-falschem Befehl
) {}
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
public Tutorial {
steps = steps == null ? List.of() : List.copyOf(steps);
}
public boolean isEmpty() { return steps.isEmpty(); }
public static Tutorial none() { return new Tutorial(null, List.of(), null); }
}
```
`tutorial.yaml` (ein Mapping, optional):
```yaml
intro: |
Welcome. Let me show you the ropes — type the commands as I describe them.
(Type 'skip' at any time to jump straight into the game.)
steps:
- instruction: "Look around the room: type 'look'."
expect: look
confirm: "Good — 'look' (or 'l') always re-describes where you are."
hint: "When you're ready, just type: look"
- instruction: "Examine something you see: 'examine <thing>' (or 'x <thing>')."
expect: examine
minArgs: 1
confirm: "That's how you inspect items, exits and people up close."
hint: "Try examining something, e.g.: examine lamp"
- instruction: "Pick something up: 'take <item>'."
expect: take
minArgs: 1
confirm: "Taken. Items you carry go into your inventory."
hint: "Pick something up, e.g.: take lamp"
- instruction: "Check what you're carrying: 'inventory' (or 'i')."
expect: inventory
confirm: "That's your inventory."
hint: "Type: inventory"
- instruction: "Use an item: 'use <item>' (e.g. light the lamp)."
expect: use
minArgs: 1
confirm: "Some items toggle, read or react when used."
hint: "Use something, e.g.: use lamp"
- instruction: "Move to another room: 'go <direction>' (try 'go north')."
expect: go_direction
confirm: "You can move north/south/east/west between connected rooms."
hint: "Head somewhere, e.g.: go north"
- instruction: "Travel back to a room you've seen: 'go to <room>'."
expect: go_to_room
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
hint: "Try: go to <a room you've already visited>"
closingTips: |
That's the core. A few more you'll want:
read · drop · talk to <npc> · give <item> to <npc> ·
use <item> on <item> (combine things) · map · quests · save · menu.
Type 'help' anytime. Good luck.
```
## 4. `TutorialLoader` (loader)
- `loader/dto/TutorialDto` (`intro`, `List<TutorialStepDto> steps`, `closingTips`),
`loader/dto/TutorialStepDto` (`instruction`, `expect`, `Integer minArgs`, `confirm`, `hint`).
- `TutorialLoader.load(basePath)``Tutorial`: liest `basePath + "/tutorial.yaml"`
optional (fehlt → `Tutorial.none()`), mappt DTO→Modell (`minArgs` null → 0).
Nutzt einen eigenen Jackson-`YAMLFactory`-Mapper wie `WorldLoader`. Default-Base
`/world` (überschreibbar für Tests). Parse-Fehler → `WorldLoadException`.
- Bewusst **getrennt von `World`**: Tutorial ist Onboarding, kein Welt-Inhalt.
## 5. `TutorialGuide` (game)
Zustandsbehaftet (ein laufendes Tutorial), hält die Schritte + den Index + die
`CommandRegistry` (für alias-fähiges Verb-Matching).
```java
public final class TutorialGuide {
private final Tutorial tutorial;
private final CommandRegistry registry;
private int index = 0;
private boolean active;
private Room roomAtStepStart; // zum Erkennen echter Bewegung
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
this.tutorial = tutorial; this.registry = registry;
this.active = !tutorial.isEmpty();
}
public boolean isActive() { return active; }
/** Druckt Intro + erste Anweisung (am Loop-Start, nach dem ersten look). */
public void begin(GameContext ctx) { if (active) { print intro; enterStep(ctx); } }
/** Nach jedem ausgeführten Befehl: prüft den aktuellen Schritt. */
public void onCommand(GameContext ctx, ParsedCommand parsed) {
if (!active) return;
TutorialStep step = tutorial.steps().get(index);
if (satisfies(step, ctx, parsed)) {
ctx.getIo().write(step.confirm());
index++;
if (index >= tutorial.steps().size()) { ctx.getIo().print(closingTips); active = false; }
else enterStep(ctx);
} else {
ctx.getIo().write(step.hint()); // gültiger-aber-falscher Befehl → Nudge
}
}
/** 'skip' während des Tutorials: Abbruch ohne Zug. */
public void skip(GameContext ctx) { if (active) { ctx.getIo().write("Tutorial skipped."); active = false; } }
/** Merkt den aktuellen Raum (für Bewegungs-Erkennung) und druckt die Anweisung. */
private void enterStep(GameContext ctx) {
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
ctx.getIo().write(tutorial.steps().get(index).instruction());
}
}
```
**`satisfies(step, ctx, parsed)`**:
- `expect == "go_direction"``sameCommand(parsed.verb(), "go")` **und** `args` nicht leer
**und** erstes Arg ist eine Richtung (`Direction.fromString` wirft nicht) **und**
der Spieler ist **tatsächlich umgezogen** (`ctx.getPlayer().getCurrentRoom() != roomAtStepStart`).
- `expect == "go_to_room"``sameCommand(parsed.verb(), "go")` **und** `args` nicht leer
**und** erstes Arg ist **keine** Richtung **und** der Spieler ist tatsächlich umgezogen.
- sonst (Verb-Name) → `sameCommand(parsed.verb(), step.expect())` **und**
`args.size() >= step.minArgs()`.
- `sameCommand(a, b)` = `registry.find(a)` und `registry.find(b)` beide präsent und
**dieselbe** Command-Instanz (alias-fähig: `l`==`look`, `x`==`examine`, `get`==`take`,
`i`==`inventory`).
Die Bewegungs-Bedingung verhindert, dass ein **blockierter** Zug (z.B. `go south`
in den dunklen Dungeon ohne Licht → kein Raumwechsel) den Schritt vorrückt — wichtig,
damit für `go_to_room` danach wirklich zwei Räume besucht sind.
Hinweis: `onCommand` wird nur nach einem **erfolgreich aufgelösten** Befehl gerufen;
unbekannte Eingaben behandelt der Loop bereits („I don't understand …").
## 6. Game-Loop-Anbindung (game/Game.java)
- Neues optionales Feld `private TutorialGuide tutorialGuide;` + `setTutorialGuide(...)`
(Zwei-Phasen-Wiring wie `QuitCommand`/`MenuCommand`).
- In `run()` **vor** der `while`-Schleife (nach dem initialen `publishHud`/`maybeEnd`):
`if (tutorialGuide != null) tutorialGuide.begin(ctx);`
- In der Schleife, **vor** dem Dispatch: wenn `tutorialGuide != null &&
tutorialGuide.isActive() && parsed.verb().equals("skip")` → `tutorialGuide.skip(ctx);
publishHud(); continue;` (kein `turn++`).
- **Nach** `cmd.get().execute(...); turn++;`:
`if (tutorialGuide != null) tutorialGuide.onCommand(ctx, parsed);`
Der Guide ist in normalem/geladenem Spiel `null` → kein Overhead, kein Verhalten.
## 7. App-Anbindung (App.java)
- `App.run` lädt das Tutorial einmal beim Start: `Tutorial tutorial =
new TutorialLoader().load();` (wie Settings/SaveService).
- `play(io, saves, session, boolean withTutorial)`: nach dem Registry-Aufbau, wenn
`withTutorial && !tutorial.isEmpty()` → `game.setTutorialGuide(new TutorialGuide(
tutorial, registry))`.
- `case NEW_GAME -> play(io, saves, newSession(io), true);`
`LOAD -> play(io, saves, saves.load(slot.slug()), false);`
- Reihenfolge in `play`: Banner → Willkommen → erstes `look` → `game.run()` (darin
`guide.begin`). Damit erscheint das Tutorial **nach** dem ersten Raumbild.
## 8. Flow & Meldungen
- Neues Spiel → Name → Banner/Willkommen → Raum-`look` → Intro + 1. Anweisung.
- Spieler tippt Befehle (echte Ausführung); passender Befehl → `confirm` + nächste
Anweisung; falscher (gültiger) → `hint`. Nach dem letzten Schritt → `closingTips`,
Guide inaktiv, normales Spiel läuft weiter (Spieler steht, wo das Tutorial endete).
- `skip` jederzeit → „Tutorial skipped." → normales Spiel.
- Während des Tutorials laufen HUD/Karte/Quests/Autosave normal (es ist der echte Loop).
## 9. Tests
- **`TutorialGuideTest`** (Fake-`CommandRegistry` mit echten Command-Instanzen oder
reale Registry; `TestIO`):
- Alias-Match: `l` erfüllt `look`-Schritt; `x` erfüllt `examine`.
- `minArgs`: `take` ohne Arg erfüllt **nicht** (minArgs 1), `take lamp` schon.
- `go_direction` (Arg = Richtung) vs `go_to_room` (Arg = keine Richtung).
- Falscher gültiger Befehl → `hint`, kein Vorrücken.
- Vorrücken + `confirm`; nach letztem Schritt → `closingTips` + `isActive()==false`.
- `skip` → inaktiv, „Tutorial skipped.".
- **`TutorialLoaderTest`**: lädt Fixture-`tutorial.yaml` → Intro/Steps/ClosingTips;
fehlende Datei → `Tutorial.none()` (`isEmpty()`).
- **`GameTutorialTest`** (Integration): kleine Welt + reale Registry + `TestIO` mit
skript-Eingaben durch `game.run()`; prüft, dass `begin` druckt, Schritte bei
passenden Befehlen vorrücken, und `skip` beendet. (App-/GUI-Wiring manuell.)
- Konvention: keine GUI-/YAML-abhängigen Domain-Tests außer dem Loader-Fixture.
## 10. Architektur-Werte / Risiken
- **Geteilter Loop bleibt zentral**: der Guide hängt sich minimal in `Game` ein;
Konsole und GUI verhalten sich identisch (normale styled Ausgabe, `skip` per Eingabe).
- **Datengetrieben**: Schritte/Texte in `tutorial.yaml`; nur das Matching ist Code.
Verb-Matching nutzt die Registry (alias-fähig, keine hartkodierten Alias-Listen).
- **Geringe Kopplung an Content**: Schritte sind verb-/richtungs-basiert (kein fixer
Item-Name nötig); ändert sich der Start-Content, bleibt das Tutorial gültig.
`go_to_room` funktioniert, weil nach dem `go`-Schritt zwei Räume besucht sind.
- **Echtes Spiel, kein Sandbox**: Tutorial-Aktionen sind reale Züge (Turn/Quests/
Autosave laufen). Risiko: der Spieler „verbraucht" den ersten Raum — bewusst
akzeptiert (es ist die echte Welt; nichts geht verloren).
- **`skip`-Sonderfall**: nur während aktivem Tutorial abgefangen; außerhalb bleibt
`skip` ein unbekanntes Verb (harmlos).
- Risiko: `expect`-Tokens (`go_direction`/`go_to_room` + Verbnamen) sind ein kleines
bespoke Schema; dokumentiert in `tutorial.yaml`-Kommentar und im Loader.