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>
255 lines
13 KiB
Markdown
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.
|