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>
13 KiB
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 inWorld).
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/talketc. (nur in Abschluss-Tipps genannt). - GUI-Sonderdarstellung (Tutorial nutzt die normale styled Ausgabe;
skipper Eingabe).
3. Datenmodell (model)
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):
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: liestbasePath + "/tutorial.yaml"optional (fehlt →Tutorial.none()), mappt DTO→Modell (minArgsnull → 0). Nutzt einen eigenen Jackson-YAMLFactory-Mapper wieWorldLoader. 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).
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")undargsnicht leer und erstes Arg ist eine Richtung (Direction.fromStringwirft nicht) und der Spieler ist tatsächlich umgezogen (ctx.getPlayer().getCurrentRoom() != roomAtStepStart).expect == "go_to_room"→sameCommand(parsed.verb(), "go")undargsnicht leer und erstes Arg ist keine Richtung und der Spieler ist tatsächlich umgezogen.- sonst (Verb-Name) →
sameCommand(parsed.verb(), step.expect())undargs.size() >= step.minArgs(). sameCommand(a, b)=registry.find(a)undregistry.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 wieQuitCommand/MenuCommand). - In
run()vor derwhile-Schleife (nach dem initialenpublishHud/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;(keinturn++). - 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.runlädt das Tutorial einmal beim Start:Tutorial tutorial = new TutorialLoader().load();(wie Settings/SaveService).play(io, saves, session, boolean withTutorial): nach dem Registry-Aufbau, wennwithTutorial && !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 → ersteslook→game.run()(daringuide.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). skipjederzeit → „Tutorial skipped." → normales Spiel.- Während des Tutorials laufen HUD/Karte/Quests/Autosave normal (es ist der echte Loop).
9. Tests
TutorialGuideTest(Fake-CommandRegistrymit echten Command-Instanzen oder reale Registry;TestIO):- Alias-Match:
lerfülltlook-Schritt;xerfülltexamine. minArgs:takeohne Arg erfüllt nicht (minArgs 1),take lampschon.go_direction(Arg = Richtung) vsgo_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.".
- Alias-Match:
TutorialLoaderTest: lädt Fixture-tutorial.yaml→ Intro/Steps/ClosingTips; fehlende Datei →Tutorial.none()(isEmpty()).GameTutorialTest(Integration): kleine Welt + reale Registry +TestIOmit skript-Eingaben durchgame.run(); prüft, dassbegindruckt, Schritte bei passenden Befehlen vorrücken, undskipbeendet. (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
Gameein; Konsole und GUI verhalten sich identisch (normale styled Ausgabe,skipper 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_roomfunktioniert, weil nach demgo-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 bleibtskipein unbekanntes Verb (harmlos).- Risiko:
expect-Tokens (go_direction/go_to_room+ Verbnamen) sind ein kleines bespoke Schema; dokumentiert intutorial.yaml-Kommentar und im Loader.