Compare commits

...

11 Commits

Author SHA1 Message Date
d7f91c2e47 docs(readme): restructure around the two ways to run the game
The README buried the "just run it" path below the Maven section and left out
several commands the game actually supports.

- Split the quick start into Weg A (prebuilt jars, Java only, works offline) and
  Weg B (Maven wrapper: test, package, exec:java@run / @gui)
- State the Java 21 prerequisite up front, with how to check it
- Note that the console version runs headless while the GUI needs a desktop, and
  where saves/ is written
- Document the missing commands: 'go to <raum>', 'save'/'speichern', and the
  skippable tutorial
- Add the scope up front (14 rooms, 19 items, 3 NPCs, 259 tests)

All commands and links in the file were executed/resolved before committing.
2026-07-14 12:55:19 +02:00
c205d1f49b docs: correct stale documentation and drop planning artifacts
The docs had drifted from the code and described things that never existed.

- architecture.md documented a QuitCommand class (quit/exit are aliases on
  MenuCommand) and a GameIO interface of read()/write(String); the real one is
  readLine() plus print(StyledText) with presentation hooks. Both corrected,
  along with the package diagram and the game-loop sketch.
- conventions.md listed a package tree missing save, menu, io.text and
  command.impl. Updated, including why MapLayout lives in game.
- data-structures.md claimed LinkedList was deliberately avoided (Pathfinder
  uses one for O(1) addFirst during path reconstruction), named the wrong queue
  for the Swing bridge, and listed an input history that was never built.
- Removed enhancement-ideas.md and implementation-status.md: a backlog and a
  phase tracker, neither of which documents the delivered project.

Also stop tracking the built jars. They still ship in the submission, but a
22 MB binary that changes on every build does not belong in git history.
2026-07-14 12:40:09 +02:00
526e3d4ee7 docs(javadoc): document every class, method and instance variable
The assignment requires Javadoc on classes, methods and instance variables.
Coverage was at roughly 45%: enum constants were undocumented across the board
(0 of 37), record components — which are a record's instance variables — sat at
24%, and instance fields at 52%. GameSession was @Getter-exposed with all six
fields undocumented, the exact case docs/conventions.md calls out by name.

Backfilled across model, io, game, loader, command, save and menu:
all enum constants, all record components (@param blocks), all instance fields,
and the public/protected methods and constructors that lacked tags.

'javadoc -Xdoclint:missing' now reports zero missing-doc warnings for the public
API. The 30 remaining warnings are all "use of default constructor" on
Lombok-generated constructors, which would require adding code to silence.
2026-07-14 12:39:25 +02:00
1b39eac90e refactor: move MapLayout into the game package
The 'map' package held a single class, which breaks the project's own rule in
docs/conventions.md: a subpackage only pays for itself once a class family
reaches three classes and forms its own abstraction.

MapLayout is BFS grid-layout logic and belongs next to Pathfinder in 'game'.
2026-07-14 12:39:15 +02:00
4cf1b42254 fix(io,save): music thread leak, save-slot collision, unresponsive GUI
- OggMusicBackend shared one 'stopping' flag across playbacks and nulled the
  thread handle without checking whether the join had actually succeeded. A
  thread that outlived its join budget saw the flag flip back to false and
  resumed playing, so two tracks played at once and the orphan never died. Each
  playback now owns its stop token, and fades abort when it is set.

- SaveService.slug mapped "Bedroom Run" and "Bedroom_Run" to the same filename,
  so the second save silently overwrote the first. The slug is now injective: a
  short hash is appended whenever sanitising was lossy. Clean names keep clean
  filenames, so existing saves still load.

- The Swing worker is the only consumer of the input queue, so when it died on a
  loader error the window stayed open and ignored every command forever. It now
  reports the error and closes. Typing while the menu overlay is up no longer
  queues commands that get replayed once the menu closes.
2026-07-14 12:38:56 +02:00
73fc6eab68 feat(command): accept German command and direction aliases
The assignment's examples are German ("Gehe nach Norden", "Nimm Brief",
"Lies Brief", "Benutze Schaufel") but every verb was English-only, so none of
them parsed.

- Register German aliases alongside the English ones (gehe, nimm, lies, benutze,
  lege, gib, rede, schau, karte, inventar, hilfe, beenden)
- Direction.fromString accepts norden/sueden/süden/osten/westen and the n/s/e/w
  shorthands; YAML exits keep the canonical English names
- Treat "nach", "den", "die", "das" as filler words so "Gehe nach Norden" parses
2026-07-14 12:38:45 +02:00
8f6ee80f30 fix(quests): tick the escort engine before the quest engine; guard the valve
Two independent world-logic bugs:

- Game.publishHud ticked QuestEngine before EscortEngine, so the twins_reunited
  flag set by the escort was only observed a full turn later. On the turn the
  twins were reunited, 'quests' still listed the objective as active.

- The valve_wheel/valve recipe had neither 'consume' nor 'requires', so it could
  be repeated indefinitely, minting a fresh ceramic fuse every time. It now
  requires notFlag cellar_drained and explains itself once the cellar is dry.
2026-07-14 12:38:35 +02:00
cc2221121d fix(game): apply the darkness rule to every command, not just three
Only look, examine, take and go checked whether the room was lit. In a pitch
black room the player could still read a letter, talk to and bribe an NPC, drop
items, and even 'use valve_wheel on valve' to drain the cellar and complete a
quest objective — all in total darkness.

Light.canSeeRoom() now gates every command that resolves room contents. The
inventory stays reachable in the dark on purpose: otherwise switching the lamp
off inside a dark room would be an unrecoverable soft-lock.

Room-item lookups in UseCommand and Combinations are gated too, and the
"too dark" message does not reveal whether the item is actually there.
2026-07-14 12:38:26 +02:00
285e68055e fix(items): make scenery items fixed so they cannot be taken
Nothing stopped the player from picking up scenery. The front door is an
ordinary switchable item in the foyer whose on-transition sets the 'left_manor'
flag that every ending keys off, so it could be carried into the cellar and used
there to end the game two floors underground. The chapel shrine could likewise
be carried out and the locket/photograph ritual performed anywhere.

Item gains a 'fixed' flag; TakeCommand refuses fixed items and leaves them in
the room. Fixed items stay usable in place, so all recipes still work.

Marked fixed: front_door, shrine, generator, valve, portrait — their
descriptions already claimed they were bolted down.
2026-07-14 12:38:16 +02:00
65a8935e32 fix(loader): resolve every id reference at load time
WorldValidator only checked id syntax, startRoom and NPC greetings. Quests,
endings and combinations were never even passed to it, so a broken world could
load "successfully" and then fail during play:

- A room without a 'name' loaded cleanly and threw an NPE in ConsoleIO.frameTop
  the moment the player walked in.
- A typo'd item id in a quest condition silently evaluated to false forever,
  leaving the stage uncompletable with no diagnostic.

The validator now receives quests, endings and combinations, and resolves every
item and quest id used in conditions, effects and recipes. Rooms and items must
carry a non-blank name and description. All problems are collected and reported
together instead of one per run.
2026-07-14 12:38:00 +02:00
e5c87015fa build: target Java 21 (LTS) instead of Java 25
maven.compiler.source/target were set to 25, but the newest language feature
actually used is List.getFirst() (SequencedCollection, Java 21). On a JDK 17 or
21 the project would not compile at all.

- Use maven.compiler.release=21 instead of the source/target pair
- README: update prerequisites, tech stack and test count
- .gitignore: ignore the Nextcloud sync file and release/*.jar
2026-07-14 12:37:45 +02:00
118 changed files with 2613 additions and 678 deletions

5
.gitignore vendored
View File

@@ -8,4 +8,7 @@ out/
# OS
.DS_Store
Thumbs.db
Thumbs.db
# Nextcloud sync
.sync-exclude.lst

View File

@@ -38,12 +38,6 @@ build/
### Mac OS ###
.DS_Store
### Brainstorming visual companion ###
.superpowers/
# Planning specs & implementation plans (kept locally, not tracked)
docs/superpowers/
# Local save games and settings
saves/
@@ -51,3 +45,6 @@ saves/
# src/main/resources/music/ is tracked and bundled. Anchored so it
# ignores the top-level source folder but not the resources path.
/music/
# Built jars (kept on disk for the submission ZIP, not tracked)
release/*.jar

View File

@@ -1,116 +1,171 @@
# Haunted Manor Textadventure
Semesterprojekt im Modul Programmierung 2 (Algorithmen und Datenstrukturen,
2. Semester). Ein textbasiertes Adventure in Java: Der Spieler navigiert durch
Räume eines Spukhauses, sammelt Gegenstände, interagiert mit NPCs und löst so
das Spiel.
Semesterprojekt im Modul Algorithmen und Datenstrukturen (2. Semester).
Ein textbasiertes Adventure in Java: Der Spieler navigiert durch die Räume eines
Spukhauses, sammelt Gegenstände, spricht mit NPCs, löst Rätsel und findet einen
Weg hinaus.
Die Spielwelt ist vollständig datengetrieben Räume, Gegenstände und NPCs
werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
Die Spielwelt ist vollständig datengetrieben Räume, Gegenstände, NPCs, Quests
und Enden werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
## Features
**Umfang:** 14 Räume, 19 Gegenstände, 3 NPCs, 259 Tests.
- **Navigation** durch beliebig viele Räume in den Himmelsrichtungen
- **Inventar** mit Aufnehmen (`take`) und Ablegen (`drop`) von Gegenständen
- **Item-Hierarchie**: einfache, lesbare (`read`) und schaltbare (`use`) Gegenstände
- **NPCs** mit Gesprächen (`talk`) und Geschenk-Reaktionen (`give`)
- **Zwei Frontends**: klassische Konsole und Swing-GUI (Bonus)
- **Datengetriebene Welt** aus YAML inkl. Referenzauflösung und Validierung beim Laden
---
## Voraussetzungen
## Schnellstart
- Java 25+
- Maven 3.9+ **oder** der mitgelieferte Maven Wrapper (`./mvnw`) damit ist
keine separate Maven-Installation nötig; der Wrapper lädt die passende
Maven-Version beim ersten Aufruf selbst herunter.
Es gibt **zwei Wege**, das Spiel zu starten. Wenn Sie nur spielen wollen, nehmen
Sie Weg A dafür wird kein Maven benötigt.
## Schnellstart ohne Maven
### Voraussetzung
Im Ordner [`release/`](release/) liegen zwei fertig gebaute, eigenständige
JARs mit allen Abhängigkeiten je eine pro Frontend. Sie brauchen nur Java 25:
**Java 21 oder neuer** (LTS). Gebaut wird gegen `maven.compiler.release=21`;
das neueste genutzte Sprachfeature ist `List.getFirst()` (SequencedCollection,
Java 21). Neuere JDKs (2225) funktionieren ebenfalls.
Prüfen mit:
```bash
java -jar release/HauntedManor-Console.jar # Konsolen-Version (~5 MB, ohne Audio)
java -jar release/HauntedManor-GUI.jar # Swing-GUI (~17 MB, mit Musik)
java -version
```
Die Konsolen-JAR enthält weder die Musikdateien noch die OGG-Bibliotheken
der Textmodus spielt keine Musik, daher werden sie dort weggelassen.
### Weg A fertige JARs starten (ohne Maven)
## Bauen und Testen
Mit dem Wrapper (empfohlen, keine Maven-Installation nötig) unter Windows
`mvnw.cmd` statt `./mvnw`:
Im Ordner [`release/`](release/) liegen zwei eigenständige JARs, die alle
Abhängigkeiten bereits enthalten. Es wird **nur Java** gebraucht, kein Maven,
keine Internetverbindung:
```bash
./mvnw clean test # alle Tests ausführen (233 Tests)
./mvnw clean package # baut beide JARs nach release/ (Console + GUI)
java -jar release/HauntedManor-Console.jar # Konsolen-Version (~5 MB)
java -jar release/HauntedManor-GUI.jar # Swing-GUI (~17 MB)
```
Ein systemweit installiertes `mvn` funktioniert identisch (`mvn clean test` …).
- Die **Konsolen-Version** läuft in jedem Terminal, auch ohne grafische
Oberfläche.
- Die **GUI-Version** (Bonus) braucht einen Desktop und spielt zusätzlich
Hintergrundmusik. Die Konsolen-JAR enthält weder die Musikdateien noch die
OGG-Bibliotheken im Textmodus gibt es keine Musik, daher sind sie dort
weggelassen.
## Spielen
### Weg B mit Maven bauen und starten
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
Der mitgelieferte **Maven Wrapper** (`./mvnw`) lädt die passende Maven-Version
beim ersten Aufruf selbst herunter eine separate Maven-Installation ist nicht
nötig. Unter Windows `mvnw.cmd` statt `./mvnw` verwenden. Ein systemweit
installiertes `mvn` funktioniert identisch.
```bash
./mvnw exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
./mvnw exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
./mvnw clean test # alle 259 Tests ausführen
./mvnw clean package # baut beide JARs neu nach release/
./mvnw exec:java@run # Konsolen-Version starten (thb.jeanluc.adventure.App)
./mvnw exec:java@gui # Swing-GUI starten (thb.jeanluc.adventure.AppGui)
```
`./mvnw exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
Alternativ direkt aus der gebauten JAR (siehe [Schnellstart](#schnellstart-ohne-maven)).
### Befehle im Spiel
> **Hinweis:** Der erste Maven-Aufruf lädt die Abhängigkeiten (Jackson, Lombok,
> JUnit, Logback) aus dem Internet. Wer offline ist, nimmt Weg A.
| Befehl | Aliase | Wirkung |
Speicherstände und Einstellungen legt das Spiel in einem Ordner `saves/` neben
dem Arbeitsverzeichnis an, aus dem es gestartet wurde.
---
## Befehle im Spiel
Befehle funktionieren auf **Englisch und Deutsch** die deutschen Beispiele aus
der Aufgabenstellung (`Gehe nach Norden`, `Nimm Brief`, `Lies Brief`,
`Benutze Schaufel`) laufen also direkt. Gegenstands-IDs bleiben englisch
(`letter`, `shovel`), Himmelsrichtungen verstehen beide Sprachen
(`north` / `norden`).
| Befehl | Aliase | Deutsch | Wirkung |
|---|---|---|---|
| `go <richtung>` | `move`, `walk` | `gehe`, `geh` | In eine Himmelsrichtung gehen |
| `go to <raum>` | | | Automatisch zu einem bekannten Raum laufen |
| `look` | `l` | `schau`, `umsehen` | Aktuellen Raum beschreiben |
| `map` | `m` | `karte` | Karte der erkundeten Räume anzeigen |
| `quests` | `log`, `journal` | `aufgaben` | Aktive und erledigte Quests anzeigen |
| `inventory` | `inv`, `i` | `inventar` | Inventar anzeigen |
| `take <item>` | `pick`, `get` | `nimm`, `nehme` | Gegenstand aufnehmen |
| `drop <item>` | `put` | `lege`, `ablegen` | Gegenstand ablegen |
| `use <item>` | | `benutze` | Gegenstand benutzen / schalten |
| `use <a> on <b>` | | | Zwei Gegenstände kombinieren |
| `read <item>` | | `lies`, `lese` | Lesbaren Gegenstand lesen |
| `examine <item>` | `x`, `inspect` | `untersuche` | Gegenstand genauer ansehen |
| `talk <npc>` | `speak` | `rede`, `sprich` | Mit einem NPC sprechen |
| `give <item> <npc>` | | `gib` | NPC einen Gegenstand geben |
| `save` | | `speichern` | Spielstand speichern |
| `help` | `?` | `hilfe` | Befehlsübersicht anzeigen |
| `quit` | `exit` | `beenden` | Speichern und zurück ins Hauptmenü |
Zu Beginn führt ein kurzes, überspringbares Tutorial (`skip`) durch die
wichtigsten Befehle.
---
## Wo die Aufgabenstellung umgesetzt ist
| Anforderung | Wo im Code | Umsetzung |
|---|---|---|
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
| `look` | `l` | Aktuellen Raum beschreiben |
| `map` | `m` | Karte der erkundeten Räume anzeigen |
| `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen |
| `inventory` | `inv`, `i` | Inventar anzeigen |
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
| `drop <item>` | `put` | Gegenstand ablegen |
| `use <item>` | | Gegenstand benutzen / schalten |
| `read <item>` | | Lesbaren Gegenstand lesen |
| `examine <item>` | `x`, `inspect` | Gegenstand genauer ansehen |
| `talk <npc>` | `speak` | Mit einem NPC sprechen |
| `give <item> <npc>` | | NPC einen Gegenstand geben |
| `help` | `?` | Befehlsübersicht anzeigen |
| `quit` | `exit` | Spiel beenden |
| **Raum-Klasse + Navigation**, mind. 4 Räume | [`model/Room.java`](src/main/java/thb/jeanluc/adventure/model/Room.java), [`model/Direction.java`](src/main/java/thb/jeanluc/adventure/model/Direction.java) | **14 Räume.** `Room` hält direkte Referenzen auf seine Nachbarräume in einer `EnumMap<Direction, Room>` ein echter Objektgraph, keine String-IDs. |
| **`gehen`-Befehl** (Tipp: switch / if-else / HashMap) | [`command/CommandRegistry.java`](src/main/java/thb/jeanluc/adventure/command/CommandRegistry.java), [`command/impl/GoCommand.java`](src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java) | Dispatch über `HashMap<String, Command>` (O(1); Aliase = mehrere Keys auf dieselbe Instanz, statt eines wachsenden `switch`). Die Richtung danach O(1) per `EnumMap`-Lookup. |
| **Gegenstände + Inventar**, mind. 3 Items | [`model/Player.java`](src/main/java/thb/jeanluc/adventure/model/Player.java), [`model/item/`](src/main/java/thb/jeanluc/adventure/model/item/) | **19 Gegenstände.** Inventar = `LinkedHashMap<String, Item>` (O(1)-Zugriff *und* stabile Anzeigereihenfolge). Hierarchie: `PlainItem`, `ReadableItem`, `SwitchableItem`. |
| **Aufnehmen / Ablegen / Benutzen / Lesen** | [`command/impl/`](src/main/java/thb/jeanluc/adventure/command/impl/) | `TakeCommand`, `DropCommand`, `UseCommand`, `ReadCommand` inklusive `letter` („Brief") und `shovel` („Schaufel") aus den Beispielen der Aufgabenstellung. |
| **Wahl der Datenstrukturen** | [`docs/data-structures.md`](docs/data-structures.md) | Begründung je Anwendungsfall, plus Tabelle „Bewusst NICHT gewählt". |
| **NPCs** (Bonus) | [`model/Npc.java`](src/main/java/thb/jeanluc/adventure/model/Npc.java) | 3 NPCs, die auf den Spielerzustand **reagieren** (`talk`, `give`). |
| **Swing-GUI** (Bonus) | [`io/SwingIO.java`](src/main/java/thb/jeanluc/adventure/io/SwingIO.java) | `JTextField` für die Eingabe (siehe Hinweis unten). |
| **Javadoc** | gesamtes `src/main/java` | An Klassen, Methoden und Instanzvariablen. |
**Hinweis zur GUI:** Die Aufgabe nennt `JTextField` **und** `JTextArea`. Die
Eingabe ist wie gefordert ein `JTextField`. Für die **Ausgabe** wird bewusst ein
`JTextPane` statt einer `JTextArea` verwendet: `JTextArea` kann ausschließlich
unformatierten Text darstellen, das Spiel rendert aber farbig gestylte Ausgabe
(`StyledText` `StyledDocument`), damit Raumnamen, Gegenstände und Hinweise
optisch unterscheidbar sind. `JTextPane` ist dafür die passende Swing-Komponente
und funktional eine Obermenge von `JTextArea`.
---
## Projektstruktur
```
Semesterprojekt/
├── src/main/java/thb/jeanluc/adventure/
│ ├── App.java - Einstiegspunkt Konsole
│ ├── AppGui.java - Einstiegspunkt Swing-GUI
│ ├── command/ - Befehlsparser, Registry + Command-Implementierungen
│ ├── game/ - Spielschleife (Game) und Kontext (GameContext)
│ ├── io/ - GameIO-Abstraktion: ConsoleIO, SwingIO
│ ├── loader/ - YAML-Laden: DTOs, Factories, Resolver, Validator
── model/ - Domänenmodell: Room, World, Player, Npc, Item-Hierarchie
│ ├── App.java Einstiegspunkt Konsole
│ ├── AppGui.java Einstiegspunkt Swing-GUI
│ ├── model/ Domänenmodell: Room, Player, Npc, item/ …
│ ├── command/ Parser, Registry + impl/ (15 Kommandos)
│ ├── game/ Spielschleife, Engines, Light, Pathfinder, MapLayout
│ ├── io/ GameIO-Abstraktion: ConsoleIO, SwingIO + text/
── loader/ YAML-Laden: dto/, Factories, Resolver, Validator
│ ├── save/ Speicherstände und Einstellungen (JSON)
│ └── menu/ Hauptmenü und Einstellungen
├── src/main/resources/world/
│ ├── game.yaml - Metadaten (Titel, Startraum, Begrüßung)
│ ├── rooms.yaml - Räume mit Ausgängen, Items, NPCs
│ ├── items.yaml - Gegenstände
── npcs.yaml - NPCs und ihre Reaktionen
└── docs/ - Design- und Architekturdokumentation
│ ├── game.yaml Metadaten (Titel, Startraum, Begrüßung)
│ ├── rooms.yaml Räume mit Ausgängen, Gegenständen, NPCs
│ ├── items.yaml Gegenstände
── npcs.yaml NPCs und ihre Reaktionen
│ ├── quests.yaml Quests und ihre Stufen
│ ├── endings.yaml Spielenden
│ └── combinations.yaml Gegenstands-Kombinationen (`use X on Y`)
├── src/test/java/ 259 Tests (JUnit 5 + AssertJ)
├── release/ fertig gebaute JARs (Console + GUI)
└── docs/ Design- und Architekturdokumentation
```
## Technologie-Stack
- **Java 25**
- **Java 21 (LTS)**
- **Jackson** (YAML) für das Laden der Spielwelt
- **Lombok** zur Reduktion von Boilerplate
- **JUnit 5 + AssertJ + Mockito** für Tests
- **JUnit 5 + AssertJ + Mockito** für die Tests
- **SLF4J + Logback** für Logging
- **Swing** für die GUI (Bonus)
## Dokumentation
Ausführliche Design- und Architekturdokumentation liegt unter
[`docs/`](docs/README.md): Package-Struktur, Datenstrukturen, Item-Modell,
YAML-Schemas, Lade-Ablauf, Befehle und NPC-Modell.
[`docs/`](docs/README.md): Package-Struktur, Wahl der Datenstrukturen,
Item-Modell, Befehle, NPC-Modell, YAML-Schemas und Lade-Ablauf.

View File

@@ -1,33 +1,36 @@
# Semesterprojekt Textadventure
Design- und Architekturdokumentation. Dient als Spec während der Implementierung.
Design- und Architekturdokumentation. Bedienung und Build stehen im
[Haupt-README](../README.md).
## Inhalt
| Datei | Inhalt |
|---|---|
| [architecture.md](architecture.md) | Package-Struktur, Schichten, DTO-vs-Domain-Trennung |
| [conventions.md](conventions.md) | Sprache, ID-Format, Naming, Lombok-Cheatsheet |
| [data-structures.md](data-structures.md) | Alle gewählten Collection-Typen mit Begründung |
| [conventions.md](conventions.md) | Sprache, ID-Format, Naming, Lombok-Cheatsheet |
| [item-model.md](item-model.md) | Item-Hierarchie (abstract + 3 Subtypen), Lombok-Inheritance |
| [yaml-schemas.md](yaml-schemas.md) | Schemas für `items.yaml`, `rooms.yaml`, `npcs.yaml`, `game.yaml` |
| [loading-flow.md](loading-flow.md) | Lade-Reihenfolge, Referenz-Auflösung, Validierung |
| [commands.md](commands.md) | Befehlsparser, Command-Pattern, Befehlsliste |
| [npcs.md](npcs.md) | NPC-Modell, Talk- und Give-Interaktion |
| [implementation-status.md](implementation-status.md) | Aktueller Stand, Phasen-Checkliste, festgelegte Entscheidungen |
| [yaml-schemas.md](yaml-schemas.md) | Schemas der YAML-Dateien unter `resources/world/` |
| [loading-flow.md](loading-flow.md) | Lade-Reihenfolge, Referenz-Auflösung, Validierung |
| [music.md](music.md) | Hintergrundmusik in der GUI (Bonus) |
## Pflicht vs. Optional (laut Aufgabenstellung)
- **Pflicht:** ≥4 Räume mit Navigation, ≥3 Gegenstände mit Inventar
- **Optional/Bonus:** NPCs, Swing-GUI
- **Pflicht:** ≥ 4 Räume mit Navigation, ≥ 3 Gegenstände mit Inventar
erfüllt mit 14 Räumen und 19 Gegenständen.
- **Optional/Bonus:** NPCs (3) und Swing-GUI — beides umgesetzt.
Beide optionalen Teile sind hier eingeplant.
Welche Klasse welche Anforderung umsetzt, steht als Tabelle im
[Haupt-README](../README.md).
## Technologie-Stack
- Java 25
- Jackson (YAML) für Daten-Loading
- Lombok für Boilerplate-Reduktion
- Java 21 (LTS)
- Jackson (YAML) für das Laden der Spielwelt
- Lombok zur Reduktion von Boilerplate
- JUnit 5 + AssertJ + Mockito für Tests
- Logback + SLF4J für Logging
- Swing für GUI (Bonus)
- SLF4J + Logback für Logging
- Swing für die GUI (Bonus)

View File

@@ -33,19 +33,22 @@ flowchart LR
model --> item
item --> item_files["Item (abstract)<br/>ReadableItem<br/>SwitchableItem<br/>PlainItem"]
loader --> loader_files["WorldLoader<br/>ReferenceResolver<br/>WorldValidator"]
loader --> loader_files["WorldLoader<br/>ReferenceResolver<br/>WorldValidator<br/>*Factory"]
loader --> dto
dto --> dto_files["RoomDto, ItemDto,<br/>NpcDto, GameDto"]
dto --> dto_files["RoomDto, ItemDto,<br/>NpcDto, GameDto,<br/>QuestDto, EndingDto, …"]
command --> cmd_core["Command (Interface)<br/>CommandRegistry<br/>CommandParser"]
command --> impl
impl --> impl_files["GoCommand, TakeCommand,<br/>DropCommand, UseCommand,<br/>InventoryCommand, LookCommand,<br/>TalkCommand, GiveCommand,<br/>HelpCommand, QuitCommand"]
impl --> impl_files["GoCommand, TakeCommand,<br/>DropCommand, UseCommand,<br/>ReadCommand, ExamineCommand,<br/>InventoryCommand, LookCommand,<br/>TalkCommand, GiveCommand,<br/>MapCommand, QuestsCommand,<br/>SaveCommand, MenuCommand,<br/>HelpCommand"]
game --> game_files["Game (Loop)<br/>GameContext"]
game --> game_files["Game (Loop)<br/>GameContext/Session/State<br/>QuestEngine, EndingEngine,<br/>EscortEngine, Light,<br/>Pathfinder, MapLayout"]
io --> io_files["GameIO (Interface)<br/>ConsoleIO<br/>SwingIO"]
io --> io_files["GameIO (Interface)<br/>ConsoleIO<br/>SwingIO<br/>io.text (Styled Output)"]
```
> **Hinweis:** Es gibt **keine** `QuitCommand`-Klasse. `quit`/`exit`/`beenden` sind Aliase,
> die in `App` auf `MenuCommand` registriert werden (siehe `App.play`).
## DTO vs. Domain-Trennung
**Kernprinzip:** YAML wird in Records deserialisiert (DTOs), erst danach werden String-IDs zu Objekt-Referenzen aufgelöst.
@@ -67,15 +70,15 @@ flowchart LR
## Game-Loop (vereinfacht)
```java
while (!game.isOver()) {
String input = io.read();
while (running) {
String input = io.readLine();
if (input == null) break; // EOF
ParsedCommand parsed = parser.parse(input);
Command cmd = registry.get(parsed.verb());
if (cmd == null) {
io.write("Unbekannter Befehl.");
} else {
cmd.execute(context, parsed.args());
}
registry.find(parsed.verb()).ifPresentOrElse(
cmd -> cmd.execute(ctx, parsed.args()),
() -> io.write("I don't understand '" + parsed.verb() + "'."));
publishHud(); // Engines ticken, HUD/Map/Quests updaten
maybeEnd(); // Ending-Bedingungen prüfen
}
```
@@ -85,9 +88,25 @@ Konsole und GUI teilen sich `GameIO`:
```java
public interface GameIO {
String read(); // blockierende Leseoperation
void write(String text);
String readLine(); // blockierende Leseoperation
void print(StyledText text); // einzige Ausgabe-Primitive
// Convenience + Presentation-Hooks als default-Methoden:
default void write(String text) { } // Kurzform für ungestylten Text
default void showRoom(RoomView v) { }
default void setHud(Hud hud) { }
default void setMap(MapView map) { }
default void setQuests(QuestView quests) { }
default void setMusic(String track) { }
default int choose(String title, List<String> options) { }
default void shutdown() { }
}
```
Damit ist der Game-Loop **identisch** für beide Modi. `SwingIO` blockiert intern mit einer `BlockingQueue<String>`, die vom JTextField-ActionListener gefüllt wird.
Ausgabe läuft **immer** über `print(StyledText)` — eine Liste von `Span`s mit `Style`.
`ConsoleIO` rendert sie als ANSI-Farben (oder ASCII, je nach Settings), `SwingIO` als
`StyledDocument` im `JTextPane`. Die `default`-Methoden geben der GUI Extra-Hooks
(Karte, Quest-Panel, Musik), die die Konsole schlicht ignoriert oder als Text ausgibt.
Damit ist der Game-Loop **identisch** für beide Modi. `SwingIO` blockiert intern mit einer
`LinkedBlockingQueue<String>`, die vom JTextField-ActionListener gefüllt wird.

View File

@@ -32,24 +32,28 @@ Geltungsbereich: id ist eindeutig **innerhalb seiner Entitätsart**. Ein Item un
```
thb.jeanluc.adventure
├── App, AppGui <- Entry Points (Konsole / Swing)
├── model
│ ├── Direction
│ ├── Room
│ ├── Player
│ ├── Npc
│ ├── Direction, Room, Player, Npc
│ ├── Quest, QuestStage, Ending, Combination, Condition, Effect, …
│ └── item <- eigener Subpackage wegen Hierarchie
│ ├── Item (abstract)
│ ├── ReadableItem
│ ├── SwitchableItem
│ └── PlainItem
├── io
├── command
├── game
└── loader
└── dto
├── io <- ConsoleIO, SwingIO, GameIO, Music*, MapPanel, QuestPanel
│ └── text <- StyledText, Style, Span, Hud, *View (15 Klassen)
├── command <- Command, CommandParser, CommandRegistry, ParsedCommand
│ └── impl <- die 15 konkreten Kommandos
├── game <- Game-Loop, Engines (Quest, Ending, Escort), Light,
│ Pathfinder, MapLayout, GameState/Session/Context
├── loader
│ └── dto <- die YAML-Records
├── save <- SaveService, SaveCodec, SaveData, Settings*
└── menu <- MainMenu, SettingsMenu, MenuAction
```
**Wann ein Subpackage:** wenn eine Klassenfamilie ≥ 3 Klassen umfasst und eine eigene Abstraktion bildet (`item`, evtl. später `command.impl`). Sonst flach lassen.
**Wann ein Subpackage:** wenn eine Klassenfamilie ≥ 3 Klassen umfasst und eine eigene Abstraktion bildet. Erfüllt von `item` (4), `command.impl` (15), `io.text` (15), `loader.dto` (16), `save` (7), `menu` (3). Sonst flach lassen — `MapLayout` liegt deshalb in `game` neben `Pathfinder` und **nicht** in einem eigenen `map`-Package (eine Klasse reicht nicht).
## Naming

View File

@@ -15,13 +15,16 @@ Bewusste Wahl jeder Collection — der Dozent bewertet das laut Aufgabenstellung
| Spieler-Inventar | `LinkedHashMap<String, Item>` | O(1) |
| Befehlsregistry | `HashMap<String, Command>` | O(1) |
| NPC-Reaktionen | `HashMap<String, NpcReaction>` | O(1) |
| Eingabehistorie (optional) | `ArrayDeque<String>` | O(1) Front/Back |
| Besuchte Räume (für die Karte) | `LinkedHashSet<String>` | O(1) + Besuchsreihenfolge |
| BFS-Queue (Pathfinder, MapLayout) | `ArrayDeque<Room>` | O(1) FIFO |
| BFS-Pfadrekonstruktion | `LinkedList<Room>` | O(1) `addFirst` |
| GUI-Eingabebrücke (EDT → Worker) | `LinkedBlockingQueue<String>` | blockierend, unbegrenzt |
## Begründungen im Detail
### `EnumMap<Direction, Room>` für Raum-Ausgänge
- **Direction** ist Enum (`NORTH`, `SOUTH`, `EAST`, `WEST`, evtl. `UP`, `DOWN`)
- **Direction** ist Enum (`NORTH`, `SOUTH`, `EAST`, `WEST`)
- `EnumMap` ist array-backed, kein Hashing nötig → schneller und kompakter als `HashMap`
- Iteration in Enum-Deklarationsreihenfolge (stabil)
@@ -49,11 +52,19 @@ Entscheidung "keine Stapel" (1 Item pro id) macht das Map-basierte Modell sauber
- Entspricht dem expliziten Tipp aus der Aufgabenstellung
- Vermeidet wachsendes `switch`-Statement
### `ArrayDeque<String>` für Historie (optional)
### `ArrayDeque<Room>` als BFS-Queue
- Falls Up-Arrow in der GUI gewünscht oder Befehlsverlauf
- `ArrayDeque` ist `LinkedList` praktisch immer überlegen (bessere Cache-Lokalität, weniger Overhead)
- Beidseitige O(1)-Operationen
- `Pathfinder` (Auto-Travel `go to <raum>`) und `MapLayout` (Karten-Layout) sind beides
Breitensuchen → brauchen eine **FIFO-Queue**
- `ArrayDeque` ist die Standardwahl dafür: O(1) an beiden Enden, keine Node-Allokation
wie bei `LinkedList`, bessere Cache-Lokalität
### `LinkedList<Room>` für die Pfadrekonstruktion
Die eine Stelle, an der `LinkedList` die richtige Wahl ist (`Pathfinder`):
Nach der BFS läuft man über `cameFrom` **rückwärts** vom Ziel zum Start und will den
Pfad in Vorwärtsreihenfolge. `addFirst()` ist bei `LinkedList` O(1) — bei `ArrayList`
wäre `add(0, x)` O(n), weil alles verschoben werden müsste.
## Bewusst NICHT gewählt
@@ -62,7 +73,7 @@ Entscheidung "keine Stapel" (1 Item pro id) macht das Map-basierte Modell sauber
| `ArrayList<Item>` für Inventar | O(n)-Lookup, Duplikat-Handling nötig |
| `HashMap<String, Item>` für Inventar | Anzeige-Reihenfolge instabil |
| `TreeMap` irgendwo | Keine sortierte Iteration nötig, O(log n) ohne Nutzen |
| `LinkedList` | `ArrayDeque` ist fast immer besser |
| `LinkedList` als Queue | dafür ist `ArrayDeque` besser (s.o.) — als `addFirst`-Liste in der Pfadrekonstruktion aber sehr wohl genutzt |
| `Vector` / `Hashtable` | Legacy, synchronisiert (nicht gebraucht), langsamer |
| `Map<String, String>` für Exits in Domain | Direction sollte Enum sein, nicht String |
@@ -70,4 +81,9 @@ Entscheidung "keine Stapel" (1 Item pro id) macht das Map-basierte Modell sauber
Single-threaded: Game-Loop liest, dispatcht, schreibt — keine parallelen Mutationen.
**Ausnahme:** Bei Swing-GUI läuft Input über den Event-Dispatch-Thread, der Game-Loop in einem Worker-Thread. Hier kommt `BlockingQueue<String>` (`ArrayBlockingQueue` reicht) als Brücke ins Spiel — siehe [architecture.md](architecture.md).
**Ausnahme:** Bei Swing-GUI läuft Input über den Event-Dispatch-Thread, der Game-Loop in
einem Worker-Thread. Als Brücke dient eine `LinkedBlockingQueue<String>` (`SwingIO`):
Der `JTextField`-ActionListener (EDT) legt die Zeile ab, der Worker blockiert in
`take()`. Unbegrenzt statt `ArrayBlockingQueue`, weil es keine sinnvolle Obergrenze für
getippte Zeilen gibt und ein volles `offer()` Eingaben verlieren würde — siehe
[architecture.md](architecture.md).

View File

@@ -1,239 +0,0 @@
# Enhancement-Ideen (Backlog)
> Roh-Sammlung von Ausbau-Ideen über die Abgabe hinaus. Lebendes Dokument
> noch kein Design, noch keine Festlegung. Wird in Teilprojekte zerlegt und
> nacheinander über den Brainstorming → Spec → Plan → Implementierung-Zyklus
> verfeinert.
Stand: 2026-05-31. Ausgangslage: Pflichtumfang ("bare minimum") erfüllt,
alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
## Leitziele
- **Reichere Ausgabe** statt nacktem Konsolentext sowohl Konsole als auch GUI.
- **GUI soll noch mehr bieten** als die Konsole (mehr Raum für visuelle Mittel).
- **~60 Minuten Spielzeit** anpeilen. Viel mehr Inhalt, viel mehr zu tun.
- Bestehendes **NPC-System zu einem tieferen Quest-System** ausbauen.
## Themenblöcke
### 1. Reichere Präsentation / Ausgabe-Schicht ✅ umgesetzt (Branch `feature/presentation-layer`)
> Umgesetzt: semantisches Modell (`io.text`), `ConsoleIO` (ANSI + Box-Drawing,
> Color/Glyph-Modi), `SwingIO` (4 Regionen, `JTextPane`, gebündelter Font,
> HiDPI-Skalierung + Zoom `Strg +/-/0`), HUD pro Zug, Welcome-Banner. 79 Tests grün.
> Spec/Plan unter `docs/superpowers/`. Karte/Menü/Save/Musik weiterhin offen.
- Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner,
Hervorhebungen für Items/NPCs/Richtungen).
- **Konsole**: ANSI-Farben, Box-Drawing-Zeichen für Rahmen, evtl. ASCII-Art /
Banner für Räume oder Spielstart.
- **GUI (Swing)**: mehr als ein `JTextArea` Farben, Stile, evtl. Panels,
Statusanzeige, Bilder/Icons.
**Festgelegt (Zielbild Konsole):** Stufe **B als Baseline** (Boxed Heading,
gelabelte Sektionen, persistente HUD-Zeile: Ort · Gold · Zug · Licht), **ASCII-Art
nur für große Momente** (Spielstart, Finale, Schlüsselräume). Semantische
Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
**Designprinzipien:**
- **Nicht überladen / Progressive Disclosure** Raumeintritt zeigt nur das
Wesentliche (Ort, kurze Beschreibung, Auffälliges); Details auf Abruf
(`examine`, `look`).
- **Info auf Regionen verteilen statt Text stapeln** die **Karte ersetzt die
„Exits:"-Zeile**. GUI: persistentes Map-Panel macht die Exit-Zeile überflüssig.
Konsole: kompakte Mini-Map (bei `map` bzw. knapp beim Raumeintritt).
### 2. Nerd Fonts / Glyphen das Installationsproblem
- Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des
Spielers installiert sein, sonst Tofu-Kästchen.
- Offene Frage: gute Lösung finden, damit es überall funktioniert.
- Erste Gedanken:
- **GUI**: Font ließe sich *mitliefern* (`.ttf` in Resources, via
`Font.createFont` laden) → Installationsproblem entfällt für die GUI.
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
### 3. Karte / Map ✅ umgesetzt (Branch `feature/map`)
> Umgesetzt: `MapLayout` (BFS-Gitter-Layout + Fog of War), `MapView`-Modell,
> GUI-`MapPanel` (Graphics2D, ersetzt die Exit-Liste), Konsolen-ASCII via
> `AsciiMap` + `map`-Befehl, Besuchte-Räume-Tracking auf `Player`, Map-Push pro Zug.
> Item/NPC-Marker und Türen-Styling offen (Quest-Teilprojekt). Spec/Plan unter
> `docs/superpowers/`.
- Übersichtskarte der Räume in **beiden** Modi.
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
- Offene Fragen: nur bereits besuchte Räume zeigen? aktuelle Position markieren?
fester Grundriss oder dynamisch aus den Exits berechnet?
### 4. Gameplay-Ausbau (Inhalt)
- Deutlich mehr Räume, Items, NPCs.
- Mehr zu *tun*: Rätsel, Mechaniken, Abhängigkeiten zwischen Objekten.
- Zielgröße: ~60 Min. Spielzeit (laut Annahme machbar, da überwiegend Text).
### 5. Quest-System (NPC-Ausbau)
> ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags +
> Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen,
> bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Demo:
> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet.
>
> ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests
> (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt,
> Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest
> `restore_power`.
>
> ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven
> Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang).
> Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`).
> **Damit ist das Quest-System (#3) komplett.** Offen: Licht/Dunkelheit &
> Item-Kombination sowie #4 Content-Ausbau.
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
Quest-Log, evtl. Win-Condition daran gekoppelt.
**GUI-Layout (festgelegt):** Quest-Log als **dauerhaft sichtbare Box unterhalb der
Karte** im rechten Seitenbereich (Map oben, Quests darunter). Gehört zu Quest-
Teilprojekt #2 (Quest-Log); das Fundament #1 reserviert/plant den Slot.
### 5b. Spielkern: zentraler Bogen + Mechanik-Spine
**Leitidee (bestätigt):** Bewusst *kondensiert* wenige Mechaniken, die sich
vielfach kombinieren, statt vieler Einzel-Gimmicks. Ein 60-Min-Spiel soll
*designt* wirken, nicht gestreckt.
**Hauptziel:** *Den Strom wieder anschalten* (`power_on`). Der Weg dahin ist eine
**Quest-Kette, die den Spieler Raum für Raum führt** (Raum X freischalten → führt
zu Raum Y → … → Generator/Sicherungskasten). Das Hauptziel selbst ist das Ende
des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei.
**Mechanik-Spine (5 Bausteine, die sich kombinieren):**
1. **World-Flags / Zustand** Rückgrat (`power_on`, `cellar_drained`,
`ghost_banished`). Beschreibungen, Exits und NPC-Reaktionen lesen/schreiben sie.
2. **Verschlossene Exits & Schalter** Türen brauchen Schlüssel *oder* Flag;
Hebel/Ventile setzen Flags und öffnen Bereiche.
3. **Licht & Dunkelheit** dunkle Räume brauchen eine brennende Lichtquelle,
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
> `Light`-Helper, Gating in go/look/take/examine (Modell B: kein Eintritt ohne
> Licht), HUD-Licht verdrahtet. Demo: dunkler Dungeon braucht die brennende
> Lampe für den Generator.
4. **Item-Kombination** z.B. `match + candle → lit candle`. Achtung: v1.0 hat
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
werden (jetzt erlaubt, da über MVP hinaus).
> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene
> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText),
> Operanden aus Inventar oder Raum, reihenfolge-unabhängiger Paar-Key. Kein
> Parser-Eingriff (`on`/`with`/`to` sind bereits Filler). Selbst-Kombination
> (a==b) wird beim Laden abgelehnt. Demo: `matches` + `torch` → `lit_torch`
> (dauerhafte Lichtquelle).
5. **Quest-Ketten + mehrere Enden** Teilziele referenzieren Flags; Kette
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
**Beispiel-Puzzle (zeigt das Stapeln):** *Strom wiederherstellen* Sicherung im
gefluteten Keller; erst Ventil → `cellar_drained`; Sicherung einsetzen → Schalter
`power_on` → oberes Stockwerk/Tür öffnet sich. Ein Puzzle, drei Mechaniken,
schaltet einen ganzen Flügel frei.
*Weitere Puzzle-Ideen im Backlog (optional, nach Bedarf):* Zahlenschloss mit
Code aus Lore, Caesar-Chiffre-Brief (`decode`), Geist bannen (Reliquien-Fetch),
Melodie-/Sequenz-Rätsel, dunkler Gang. Resource-Meter (Sanity/Öl) **offen**
vorerst puzzle-fokussiert ohne Survival-Druck.
### 6. Hauptmenü & Settings
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). Hauptmenü-Shell oberhalb
> des Game-Loops (Neues Spiel / Laden / Einstellungen / Beenden) über neue
> `GameIO.choose`-Primitive (Konsole: nummeriertes Menü; GUI: Buttons).
> **Minimale Settings**: nur Farb-Modus + Glyphen-Modus (ASCII/Unicode), live
> umschaltbar, in `saves/settings.json` persistiert. Musik-/Typewriter-Toggles
> weiter zurückgestellt (Features existieren noch nicht).
- **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*.
- **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl,
Zeitstempel) anzeigen und hineinladen.
- **Settings-Untermenü**: z.B. Farben an/aus, Typewriter-Tempo, Musik-Lautstärke
an/aus, ASCII vs. Unicode.
- Sollte in beiden Modi funktionieren: Konsole = nummeriertes Textmenü, GUI =
Buttons/Liste. Architektur: Menü-Schicht *oberhalb* des Game-Loops.
### 7. Speichern / Laden
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). JSON-Spielstand als
> Delta über die frisch aus YAML geladene Welt (`SaveData`/`SaveCodec`/
> `SaveService`, atomare Writes). **Ein aktiver Slot**: manuelles `save`,
> Autosave (Quest-Completion + alle 10 Züge) und `quit`/`menu` (speichern +
> zurück ins Menü) schreiben denselben Slot; Laden nur über das Menü.
> `saves/` ist gitignored.
- Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume,
Quest-Fortschritt) Format z.B. YAML/JSON.
- Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü.
### 8. Musik (GUI)
> ✅ umgesetzt (Branch `feature/music`): OGG Vorbis (vorbisspi/jorbis), pro Raum
> via `music:`-Feld in `rooms.yaml`, externer gitignored `music/`-Ordner (nicht im
> JAR). Idempotenter Track-Wechsel mit ~400 ms Fades; Räume ohne `music` blenden zu
> Stille; gleicher Track läuft nahtlos weiter. Music-Stufe Off/Low/Med/High in den
> Settings (Default Medium), persistiert. Konsole stumm. Testbarer `MusicController`
> über isoliertes `OggMusicBackend`; fehlende Datei → still, kein Absturz. Siehe
> `docs/music.md`.
- Hintergrundmusik **nur in der GUI**.
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
- **Dateigröße** ist ein Thema → Lösungsrichtung:
- Audiodateien **nicht** in JAR/Repo bündeln, sondern externer `music/`-Ordner;
Audio ggf. via `.gitignore` ausschließen + dokumentieren, wo Dateien hinkommen.
- **Komprimierte Formate** (OGG/MP3) statt WAV, **gestreamt** statt komplett geladen.
- Bibliothek erlaubt → kleinen Audio-Decoder einbinden (Java `javax.sound` kann
OGG/MP3 nicht nativ).
- Loop pro Track, sanfter Wechsel beim Raumwechsel, graceful fallback bei
fehlender Datei, Lautstärke aus den Settings.
- Architektur: `MusicPlayer`-Komponente hinter Interface; Konsole = No-Op.
## Festgelegte Erweiterungs-Entscheidungen
> ✅ **Pathfinding** umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>`
> per BFS über besuchte Räume (Locks + Licht beachtet), via `GoCommand`
> (Richtung vs. Raumziel). Konsole still + Zusammenfassung (Start/Mitte/Ziel),
> GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms).
> **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt.
> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough
> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte
> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel),
> Abschluss-Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`,
> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem
> Spiel, nicht beim Laden.
| Entscheidung | Wert |
|---|---|
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** Standard-Collections (vom Prof bestätigt) |
| Algorithmen-Showcase | weiterhin willkommen, aber mit Standard-Datenstrukturen (z.B. BFS `go to <raum>`, Trie-Autocomplete) |
| Bibliotheken erlaubt? | **Ja** (Pflichtteil war bewusst ohne; Erweiterung darf welche nutzen) |
| Hauptmenü | ja: Neues Spiel / Laden / Settings / Beenden |
| Speichern/Laden | ja, mehrere benannte Spielstände |
| Musik | ja, **nur GUI**, pro Raum via YAML, externer Ordner, komprimiert + gestreamt |
## Offene Designfragen (zu klären)
- Wie weit darf die `GameIO`-Abstraktion aufgebohrt werden, ohne dass Konsole
und GUI auseinanderlaufen? (Gemeinsamer Loop ist ein Kernwert der Architektur.)
- Bleibt alles datengetrieben (YAML), auch Quests und Map-Layout?
- Reihenfolge / Abhängigkeiten der Teilprojekte.
---
*Notiz: Datengetriebenheit (YAML), getrennte DTO/Domain-Schicht und ein für
beide Frontends identischer Game-Loop sind etablierte Architekturwerte neue
Features sollten sie respektieren, nicht umgehen.*

View File

@@ -1,135 +0,0 @@
# Implementierungsstand & Reihenfolge
Stand: alle Phasen 17 implementiert, 67 Tests grün, End-to-End-Smoke-Test des Walking-Skeletons + YAML-Load erfolgreich.
## Phasen-Überblick
```mermaid
flowchart TD
P1["Phase 1<br/>Domain-Fundament"]
P2["Phase 2<br/>Command-Schicht"]
P3["Phase 3<br/>Walking Skeleton<br/>(Konsole + handgebaute Welt)"]
P4["Phase 4<br/>YAML-Loading + Validator"]
P5["Phase 5<br/>Restliche Commands"]
P6["Phase 6<br/>NPCs end-to-end"]
P7["Phase 7<br/>Swing-GUI (Bonus)"]
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
```
## Checkliste Phase 1: Domain
- [x] `Direction` (`model.Direction`)
- [x] `Room` (`model.Room`)
- [x] `Item` abstract (`model.item.Item`) mit `abstract use(GameContext)`
- [x] `Npc` (`model.Npc`) inklusive `shell()`-Factory und `putReaction()`
- [x] `NpcReaction` (`model.NpcReaction`)
- [x] `GameIO` interface (`io.GameIO`)
- [x] `Player` (`model.Player`)
- [x] `World` (`model.World`)
- [x] `GameContext` (`game.GameContext`)
- [x] `ReadableItem` (`model.item.ReadableItem`)
- [x] `SwitchableItem` (`model.item.SwitchableItem`)
- [x] `PlainItem` (`model.item.PlainItem`)
## Checkliste Phase 2: Commands
- [x] `Command` interface (`command.Command`)
- [x] `ParsedCommand` (record)
- [x] `CommandRegistry` (`command.CommandRegistry`)
- [x] `CommandParser` (`command.CommandParser`) mit Filler-Words
- [x] `LookCommand`
- [x] `GoCommand`
- [x] `InventoryCommand`
## Checkliste Phase 3: Walking Skeleton
- [x] `ConsoleIO`
- [x] `Game` (Loop)
- [x] `App.main` (lädt sofort über YAML; der Walking-Skeleton-Zustand mit hartkodierter Welt wurde übersprungen, weil YAML-Load und Loop schon zusammen funktionieren)
- [x] Probelauf: `look`, `go north`, `inventory` (siehe `GameTest`, `LookCommandTest`)
## Checkliste Phase 4: YAML-Loading
- [x] DTOs: `GameDto`, `ItemDto`, `RoomDto`, `NpcDto`, `ReactionDto`
- [x] Test-Fixtures unter `src/test/resources/world/`
- [x] `WorldLoader` (Happy-Path)
- [x] `ReferenceResolver`
- [x] `WorldValidator` (eine Validierungsregel pro Test)
- [x] Echte Welt-YAMLs unter `src/main/resources/world/`
- [x] `App.main` läuft direkt gegen YAML-Load
## Checkliste Phase 5: Restliche Commands
- [x] `TakeCommand`
- [x] `DropCommand`
- [x] `UseCommand`
- [x] `ReadCommand`
- [x] `ExamineCommand`
- [x] `HelpCommand`
- [x] `QuitCommand`
## Checkliste Phase 6: NPCs
- [x] `Npc` voll ausgebaut (greeting, reactions)
- [x] `NpcReaction`
- [x] `TalkCommand`
- [x] `GiveCommand`
- [x] NPCs in `WorldLoader` integriert
- [x] End-to-End-Test: Lampe geben → Schlüssel bekommen (`TalkGiveCommandTest`)
## Checkliste Phase 7: Swing-GUI
- [x] `SwingIO` mit `LinkedBlockingQueue`-Brücke
- [x] `AppGui.main`
- [x] Game-Loop in Worker-Thread
## Build & Run
```sh
mvn test # 67 Tests
mvn -DskipTests exec:java -Dexec.mainClass=thb.jeanluc.adventure.App # Konsole
mvn -DskipTests exec:java -Dexec.mainClass=thb.jeanluc.adventure.AppGui # Swing
```
## Festgelegte Designentscheidungen
Nicht mehr offen, nicht nochmal diskutieren:
| Entscheidung | Wert |
|---|---|
| Item-Hierarchie | abstract Item + ReadableItem/SwitchableItem/PlainItem |
| Item-Package | `model.item` (Subpackage) |
| Switchable-State-Typ | `boolean` (kein Enum) |
| Switchable-Felder | nur `state` (Builder mappt YAML `initialState`); kein separates `initialState`-Feld am Domain-Objekt |
| use-Targets | argless `use X`, kein `use X on Y` |
| Item kennt Standort | nein, „dumme" Items |
| Hidden Items | nein |
| State→Raum-Beschreibung | nein im MVP |
| Room.description | `final`, immutable |
| Room.describe() | nicht auf Room, im LookCommand |
| Room.equals/hashCode | nicht überschreiben, Identity |
| Room-NPCs-Feld | von Anfang an drin |
| Bidirektionale Exits | manuell, kein Auto-Spiegeln |
| GameContext-Inhalt | minimal: World + Player + GameIO |
| IDs | lowercase snake_case slugs, kein UUID |
| ID-Regex | `^[a-z][a-z0-9_]*$` |
| YAML-Aufteilung | `game.yaml`, `items.yaml`, `rooms.yaml`, `npcs.yaml` |
| DTO ↔ Domain | getrennt, Resolver-Phase löst String-IDs zu Referenzen auf |
| Item-Type-Discriminator | YAML-Feld `type: plain|readable|switchable`, in `ItemFactory` als switch |
| Codebase-Sprache | Englisch (Identifier, YAML, User-Strings) |
| Doku-Sprache | Deutsch (Prose), Englisch (Code-Beispiele) |
| Diagramme | Mermaid |
| Lombok-Inheritance | `@SuperBuilder` |
| `@Data` | vermeiden, einzelne Annotations bevorzugen |
| Quit-Wiring | `QuitCommand.bind(Game)` nach Registry-Aufbau |
| Help-Quelle | `HelpCommand` zieht aus `CommandRegistry.distinctCommands()` |
| GameIO-Methodennamen | `readLine()` / `write(String)` (Java-üblich, konsistent) |
| Lombok-Version | 1.18.42 (1.18.36 ist nicht Java-26-kompatibel) |
## Offen / nicht im MVP
- **Win-Condition**: Spiel endet nur per `quit`. Optionale Erweiterung: Bedingung in `game.yaml` (`winRoom`, `requiredItem`).
- **Bedingte NPC-Reaktionen**, NPC-Memory, Quests — bewusst ausgelassen (siehe `npcs.md`).
- **Item-Aliases** (z.B. `lamp``oil_lamp`) — YAGNI bis konkreter Bedarf.
- **Eingabehistorie** in der GUI — `ArrayDeque` ist vorgesehen, nicht umgesetzt.

View File

@@ -9,8 +9,10 @@
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<!-- Java 21 (LTS): the newest feature actually used is List.getFirst()
(SequencedCollection, Java 21). Building against an LTS keeps the
project compilable on any current JDK. -->
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jackson.version>2.18.2</jackson.version>

View File

@@ -47,10 +47,15 @@ import java.util.List;
@Slf4j
public final class App {
/** Utility class; not instantiable. */
private App() {
}
/** Standard JVM entry point. @param args ignored */
/**
* Standard JVM entry point.
*
* @param args ignored
*/
public static void main(String[] args) {
GameIO io = new ConsoleIO();
try {
@@ -62,12 +67,22 @@ public final class App {
}
}
/** Runs the menu shell on the given IO until the player quits. */
/**
* Runs the menu shell on the given IO until the player quits.
*
* @param io frontend the menu and the game are rendered on
*/
public static void run(GameIO io) {
run(io, new SaveService(), new SettingsStore());
}
/** Runs the menu shell with injectable persistence (for testing). */
/**
* Runs the menu shell with injectable persistence (for testing).
*
* @param io frontend the menu and the game are rendered on
* @param saves save-slot backend
* @param settingsStore settings backend
*/
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
Settings settings = settingsStore.load();
SettingsMenu.apply(settings, io);
@@ -112,27 +127,29 @@ public final class App {
}
});
// German aliases mirror the examples in the assignment ("Gehe nach
// Norden", "Nimm Brief", "Lies Brief", "Benutze Schaufel").
CommandRegistry registry = new CommandRegistry();
registry.register(new GoCommand(), "go", "move", "walk");
registry.register(new LookCommand(), "look", "l");
registry.register(new InventoryCommand(), "inventory", "inv", "i");
registry.register(new TakeCommand(), "take", "pick", "get");
registry.register(new DropCommand(), "drop", "put");
registry.register(new UseCommand(), "use");
registry.register(new ReadCommand(), "read");
registry.register(new ExamineCommand(), "examine", "x", "inspect");
registry.register(new MapCommand(), "map", "m");
registry.register(new QuestsCommand(), "quests", "log", "journal");
registry.register(new TalkCommand(), "talk", "speak");
registry.register(new GiveCommand(), "give");
registry.register(new SaveCommand(saves), "save");
registry.register(new GoCommand(), "go", "move", "walk", "gehe", "geh");
registry.register(new LookCommand(), "look", "l", "umsehen", "schau");
registry.register(new InventoryCommand(), "inventory", "inv", "i", "inventar");
registry.register(new TakeCommand(), "take", "pick", "get", "nimm", "nehme");
registry.register(new DropCommand(), "drop", "put", "lege", "ablegen");
registry.register(new UseCommand(), "use", "benutze", "benutz");
registry.register(new ReadCommand(), "read", "lies", "lese");
registry.register(new ExamineCommand(), "examine", "x", "inspect", "untersuche");
registry.register(new MapCommand(), "map", "m", "karte");
registry.register(new QuestsCommand(), "quests", "log", "journal", "aufgaben");
registry.register(new TalkCommand(), "talk", "speak", "rede", "sprich");
registry.register(new GiveCommand(), "give", "gib");
registry.register(new SaveCommand(saves), "save", "speichern");
Game game = new Game(ctx, registry, new CommandParser());
MenuCommand menu = new MenuCommand(saves);
menu.bind(game);
registry.register(menu, "menu", "quit", "exit");
registry.register(new HelpCommand(registry), "help", "?");
registry.register(menu, "menu", "quit", "exit", "beenden");
registry.register(new HelpCommand(registry), "help", "?", "hilfe");
if (withTutorial) {
Tutorial tutorial = new TutorialLoader().load();

View File

@@ -14,6 +14,7 @@ import javax.swing.SwingUtilities;
@Slf4j
public final class AppGui {
/** Utility class; not instantiable. */
private AppGui() {
}
@@ -29,8 +30,13 @@ public final class AppGui {
try {
App.run(io);
} catch (RuntimeException e) {
// The worker is the only consumer of the input queue, so once
// it dies nothing can service the window again. Tell the player
// what happened and close down rather than leaving a live-looking
// window that silently ignores every command.
log.error("Fatal error during game startup", e);
io.write("Fatal error: " + e.getMessage());
io.fatal("Fatal error: " + e.getMessage()
+ "\n\nThe game cannot continue and will close.");
}
}, "game-loop");
worker.setDaemon(true);

View File

@@ -16,7 +16,8 @@ public class CommandParser {
* small — when adding more, watch out for words that might be valid
* item ids ({@code key}, {@code lamp}, etc.).
*/
private static final Set<String> FILLERS = Set.of("to", "with", "at", "the", "a", "an", "on");
private static final Set<String> FILLERS =
Set.of("to", "with", "at", "the", "a", "an", "on", "nach", "den", "die", "das");
/**
* Parses an input line into a verb and its arguments.

View File

@@ -4,6 +4,7 @@ import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.Effects;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.NpcReaction;
import thb.jeanluc.adventure.model.item.Item;
@@ -24,6 +25,10 @@ public class GiveCommand implements Command {
ctx.getIo().write("Usage: give <item> <npc>.");
return;
}
if (!Light.canSeeRoom(ctx)) {
ctx.getIo().write("It's too dark to make out anyone here.");
return;
}
String itemId = args.get(0);
String npcId = args.get(1);

View File

@@ -3,9 +3,9 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.MapLayout;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.game.Pathfinder;
import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room;

View File

@@ -2,8 +2,8 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.MapLayout;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;
import java.util.List;

View File

@@ -16,10 +16,17 @@ import java.util.List;
@RequiredArgsConstructor
public class MenuCommand implements Command {
/** Persistence backend the active slot is written to before leaving. */
private final SaveService saves;
/** Running game whose loop is stopped; null until {@link #bind(Game)} is called. */
private Game game;
/** Binds the running game (two-phase wiring, as the registry is built first). */
/**
* Binds the running game (two-phase wiring, as the registry is built first).
*
* @param game the game loop to stop when this command runs
*/
public void bind(Game game) {
this.game = game;
}

View File

@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.ReadableItem;
@@ -21,6 +22,11 @@ public class ReadCommand implements Command {
ctx.getIo().write("Read what?");
return;
}
// Reading needs light even for a carried item.
if (!Light.canSeeRoom(ctx)) {
ctx.getIo().write("It's too dark to read.");
return;
}
String itemId = args.getFirst();
Optional<Item> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) {

View File

@@ -12,6 +12,7 @@ import java.util.List;
@RequiredArgsConstructor
public class SaveCommand implements Command {
/** Persistence backend the active slot is written to. */
private final SaveService saves;
@Override

View File

@@ -27,13 +27,19 @@ public class TakeCommand implements Command {
}
String itemId = args.getFirst();
Room room = ctx.getPlayer().getCurrentRoom();
Optional<Item> taken = room.removeItem(itemId);
if (taken.isEmpty()) {
Optional<Item> found = room.findItem(itemId);
if (found.isEmpty()) {
ctx.getIo().write("There is no '" + itemId + "' here.");
return;
}
ctx.getPlayer().addItem(taken.get());
ctx.getIo().write("You take the " + taken.get().getName() + ".");
Item item = found.get();
if (item.isFixed()) {
ctx.getIo().write("The " + item.getName() + " is part of the room — you can't take it.");
return;
}
room.removeItem(itemId);
ctx.getPlayer().addItem(item);
ctx.getIo().write("You take the " + item.getName() + ".");
}
@Override

View File

@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.model.DialogueLine;
import thb.jeanluc.adventure.model.Npc;
@@ -20,6 +21,10 @@ public class TalkCommand implements Command {
ctx.getIo().write("Talk to whom?");
return;
}
if (!Light.canSeeRoom(ctx)) {
ctx.getIo().write("It's too dark to make out anyone here.");
return;
}
String npcId = args.getFirst();
Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
if (npc.isEmpty()) {

View File

@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Combinations;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.model.item.Item;
import java.util.List;
@@ -27,12 +28,16 @@ public class UseCommand implements Command {
return;
}
String itemId = args.getFirst();
// Inventory stays reachable in the dark (so a lamp can be relit);
// room items may only be resolved when the room is lit.
Optional<Item> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) {
if (item.isEmpty() && Light.canSeeRoom(ctx)) {
item = ctx.getPlayer().getCurrentRoom().findItem(itemId);
}
if (item.isEmpty()) {
ctx.getIo().write("There is no '" + itemId + "' here or in your inventory.");
ctx.getIo().write(Light.canSeeRoom(ctx)
? "There is no '" + itemId + "' here or in your inventory."
: "It's too dark to see that.");
return;
}
item.get().use(ctx);

View File

@@ -18,14 +18,18 @@ public final class Combinations {
* operand is absent, the recipe's {@code failText} (or a generic line) if a
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
* or applies consume/produce/effects + the response on success.
*
* @param ctx the active game context
* @param idX id of the first operand item
* @param idY id of the second operand item
*/
public static void tryUse(GameContext ctx, String idX, String idY) {
if (!present(ctx, idX)) {
ctx.getIo().write(noSuch(idX));
ctx.getIo().write(unresolved(ctx, idX));
return;
}
if (!present(ctx, idY)) {
ctx.getIo().write(noSuch(idY));
ctx.getIo().write(unresolved(ctx, idY));
return;
}
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
@@ -52,18 +56,33 @@ public final class Combinations {
}
}
/**
* An operand resolves from the inventory always, but from the room only
* while the room is lit — otherwise combining with room scenery would
* work in pitch darkness.
*/
private static boolean present(GameContext ctx, String id) {
return ctx.getPlayer().hasItem(id)
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent();
|| (Light.canSeeRoom(ctx)
&& ctx.getPlayer().getCurrentRoom().findItem(id).isPresent());
}
/** Consumes an operand from the inventory, falling back to the current room. */
private static void removeFromAnywhere(GameContext ctx, String id) {
if (ctx.getPlayer().removeItem(id).isEmpty()) {
ctx.getPlayer().getCurrentRoom().removeItem(id);
}
}
private static String noSuch(String id) {
/**
* Message for an operand that could not be resolved. In a dark room the
* reason is always darkness, and the wording must not reveal whether the
* item is actually there.
*/
private static String unresolved(GameContext ctx, String id) {
if (!Light.canSeeRoom(ctx)) {
return "It's too dark to see that.";
}
return "There is no '" + id + "' here or in your inventory.";
}
}

View File

@@ -10,6 +10,13 @@ public final class Conditions {
private Conditions() {
}
/**
* Evaluates a single condition.
*
* @param c the condition to evaluate
* @param ctx the active game context
* @return true if the condition holds
*/
public static boolean holds(Condition c, GameContext ctx) {
return switch (c.type()) {
case FLAG -> ctx.getState().isSet(c.arg());
@@ -18,7 +25,13 @@ public final class Conditions {
};
}
/** True iff every condition holds. A null or empty list is vacuously true. */
/**
* True iff every condition holds. A null or empty list is vacuously true.
*
* @param conditions the conditions to evaluate; may be null
* @param ctx the active game context
* @return true if all conditions hold
*/
public static boolean all(List<Condition> conditions, GameContext ctx) {
if (conditions == null) {
return true;

View File

@@ -13,6 +13,13 @@ public final class Effects {
private Effects() {
}
/**
* Applies a single effect. An effect referencing an unknown item is logged
* and skipped rather than failing the turn.
*
* @param e the effect to apply
* @param ctx the active game context
*/
public static void apply(Effect e, GameContext ctx) {
switch (e.type()) {
case SET_FLAG -> ctx.getState().set(e.arg());
@@ -31,7 +38,12 @@ public final class Effects {
}
}
/** Applies each effect in order. A null list is a no-op. */
/**
* Applies each effect in order. A null list is a no-op.
*
* @param effects the effects to apply; may be null
* @param ctx the active game context
*/
public static void applyAll(List<Effect> effects, GameContext ctx) {
if (effects == null) {
return;

View File

@@ -9,7 +9,12 @@ public final class EndingEngine {
private EndingEngine() {
}
/** First ending whose conditions hold, in list order; null if none. */
/**
* First ending whose conditions hold, in list order; null if none.
*
* @param ctx the active game context
* @return the triggered ending, or null if no ending's conditions hold
*/
public static Ending triggered(GameContext ctx) {
for (Ending e : ctx.getWorld().getEndings()) {
if (Conditions.all(e.when(), ctx)) {
@@ -19,7 +24,14 @@ public final class EndingEngine {
return null;
}
/** Builds the ending screen: title banner, text, and a run summary. */
/**
* Builds the ending screen: title banner, text, and a run summary.
*
* @param e the triggered ending
* @param ctx the active game context
* @param turns number of turns the run took
* @return the rendered end-of-game screen
*/
public static StyledText render(Ending e, GameContext ctx, int turns) {
int total = ctx.getWorld().getQuests().size();
int done = ctx.getQuestLog().completed().size();
@@ -34,6 +46,7 @@ public final class EndingEngine {
return b.build();
}
/** Rank line for the summary: derived from the ending's outcome and quest completeness. */
private static String rank(Ending e, int total, int done) {
if (e.victory() && total > 0 && done == total) {
return "Master of the Manor";

View File

@@ -39,7 +39,13 @@ public final class EscortEngine {
private EscortEngine() {
}
/** A recruitable twin and the state that tracks their escort. */
/**
* A recruitable twin and the state that tracks their escort.
*
* @param npcId id of the twin's NPC in the world
* @param followingFlag flag that is set while this twin follows the player
* @param homeRoomId id of the room the twin waits in until recruited
*/
private record Twin(String npcId, String followingFlag, String homeRoomId) {
}
@@ -87,6 +93,11 @@ public final class EscortEngine {
return String.join(" and ", names) + " keep close at your shoulders.";
}
/**
* Reunites the brothers once both follow the player into the reunion room:
* sets {@link #REUNITED_FLAG}, grants the reward, and prints the scene. Runs
* at most once per playthrough.
*/
private static void maybeReunite(GameContext ctx) {
if (ctx.getState().isSet(REUNITED_FLAG)) {
return;
@@ -107,6 +118,7 @@ public final class EscortEngine {
.build());
}
/** Adds the reward item to the inventory unless the player already carries it. */
private static void grantReward(GameContext ctx) {
Item reward = ctx.getWorld().getItems().get(REWARD_ITEM_ID);
if (reward != null && !ctx.getPlayer().hasItem(REWARD_ITEM_ID)) {
@@ -118,6 +130,7 @@ public final class EscortEngine {
return ctx.getState().isSet(twin.followingFlag());
}
/** The twin's NPC name, falling back to their id if the NPC is missing from the world. */
private static String displayName(GameContext ctx, Twin twin) {
Npc npc = ctx.getWorld().getNpcs().get(twin.npcId());
return npc != null ? npc.getName() : twin.npcId();

View File

@@ -7,7 +7,6 @@ import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Ending;
import java.util.Optional;
@@ -35,7 +34,11 @@ public class Game {
/** Optional onboarding guide; null in normal/loaded play. */
private TutorialGuide tutorialGuide;
/** Wires the interactive tutorial (New Game only). */
/**
* Wires the interactive tutorial (New Game only).
*
* @param tutorialGuide the guide to run alongside the loop
*/
public void setTutorialGuide(TutorialGuide tutorialGuide) {
this.tutorialGuide = tutorialGuide;
}
@@ -96,9 +99,15 @@ public class Game {
}
}
/**
* Ticks the per-turn engines and republishes the whole view state — HUD,
* map, quest panel, and room music — to the IO channel.
*/
private void publishHud() {
QuestEngine.tick(ctx);
// EscortEngine first: it sets flags (e.g. twins_reunited) that
// QuestEngine's stage-completion conditions read in the same turn.
EscortEngine.tick(ctx);
QuestEngine.tick(ctx);
ctx.getIo().setHud(new Hud(
ctx.getPlayer().getCurrentRoom().getName(),
ctx.getPlayer().getGold(),

View File

@@ -11,47 +11,96 @@ import thb.jeanluc.adventure.model.World;
*/
public class GameContext {
/** The mutable state of the current playthrough. */
private final GameSession session;
/** Channel commands read input from and write output to. */
private final GameIO io;
/** Silent autosave hook, wired by the app; no-op by default (tests). */
private Runnable saveCallback = () -> { };
/**
* Creates a context over an existing session.
*
* @param session the playthrough state
* @param io the IO channel
*/
public GameContext(GameSession session, GameIO io) {
this.session = session;
this.io = io;
}
/** Convenience for tests/legacy callers: wraps a fresh single-use session. */
/**
* Convenience for tests/legacy callers: wraps a fresh single-use session.
*
* @param world the loaded world
* @param player the player character
* @param io the IO channel
*/
public GameContext(World world, Player player, GameIO io) {
this(new GameSession(world, player, "session"), io);
}
/**
* Returns the session this context was built over.
*
* @return the playthrough state backing this context
*/
public GameSession getSession() {
return session;
}
/**
* Delegates to the session.
*
* @return the loaded world
*/
public World getWorld() {
return session.getWorld();
}
/**
* Delegates to the session.
*
* @return the player character
*/
public Player getPlayer() {
return session.getPlayer();
}
/**
* Delegates to the session.
*
* @return the world flags
*/
public GameState getState() {
return session.getState();
}
/**
* Delegates to the session.
*
* @return the quest progress
*/
public QuestLog getQuestLog() {
return session.getQuestLog();
}
/**
* Returns the channel commands read from and write to.
*
* @return the IO channel
*/
public GameIO getIo() {
return io;
}
/** Sets the silent autosave hook (called by interval/quest autosave). */
/**
* Sets the silent autosave hook (called by interval/quest autosave).
*
* @param saveCallback the hook to run on {@link #save()}
*/
public void setSaveCallback(Runnable saveCallback) {
this.saveCallback = saveCallback;
}

View File

@@ -13,23 +13,47 @@ import thb.jeanluc.adventure.model.World;
@Getter
public class GameSession {
/** The loaded world this playthrough runs on. */
private final World world;
/** The player character, including inventory, position, and visited rooms. */
private final Player player;
/** World flags set and cleared by effects and conditions. */
private final GameState state = new GameState();
/** Quest progress: active stages and completed quests. */
private final QuestLog questLog = new QuestLog();
/** Name of the save slot this session is bound to; save/load target. */
private final String slotName;
/** Number of commands executed so far; shown in the HUD and the ending summary. */
private int turn;
/**
* Creates a session bound to a save slot.
*
* @param world the loaded world
* @param player the player character
* @param slotName the save slot this session reads from and writes to
*/
public GameSession(World world, Player player, String slotName) {
this.world = world;
this.player = player;
this.slotName = slotName;
}
/**
* Overwrites the turn counter (used when restoring a save).
*
* @param turn the turn count to restore
*/
public void setTurn(int turn) {
this.turn = turn;
}
/** Advances the turn counter by one. */
public void incrementTurn() {
this.turn++;
}

View File

@@ -8,25 +8,51 @@ import java.util.Set;
/** Named world flags. A flag is "set" (true) when present in the set. */
public class GameState {
/** Ids of the currently set flags. Insertion-ordered for stable saves. */
private final Set<String> flags = new LinkedHashSet<>();
/**
* Tests whether a flag is set.
*
* @param flag the flag id
* @return true if the flag is set
*/
public boolean isSet(String flag) {
return flags.contains(flag);
}
/**
* Sets a flag. Setting an already-set flag has no effect.
*
* @param flag the flag id
*/
public void set(String flag) {
flags.add(flag);
}
/**
* Clears a flag. Clearing an unset flag has no effect.
*
* @param flag the flag id
*/
public void clear(String flag) {
flags.remove(flag);
}
/**
* All currently set flags.
*
* @return an unmodifiable view of the set flags
*/
public Set<String> all() {
return Collections.unmodifiableSet(flags);
}
/** Replaces all flags with the given collection (used by load). */
/**
* Replaces all flags with the given collection (used by load).
*
* @param newFlags the flags to restore
*/
public void restore(Collection<String> newFlags) {
flags.clear();
flags.addAll(newFlags);

View File

@@ -12,7 +12,13 @@ public final class Light {
private Light() {
}
/** A room is lit if it is not dark, or an active light source is carried or present. */
/**
* A room is lit if it is not dark, or an active light source is carried or present.
*
* @param ctx the active game context
* @param room the room to test
* @return true if the room is lit
*/
public static boolean isLit(GameContext ctx, Room room) {
if (!room.isDark()) {
return true;
@@ -21,11 +27,30 @@ public final class Light {
|| hasActiveLight(room.getItems().values());
}
/** True if the player carries an active light source (for the HUD). */
/**
* True if the player carries an active light source (for the HUD).
*
* @param ctx the active game context
* @return true if an active light source is in the inventory
*/
public static boolean carryingLight(GameContext ctx) {
return hasActiveLight(ctx.getPlayer().getInventory().values());
}
/**
* True if the player can see the contents of the room they stand in.
* Room items and NPCs may only be resolved when this holds; inventory
* items stay reachable in the dark, so a lamp can always be switched
* back on.
*
* @param ctx the active game context
* @return true if the player's current room is lit
*/
public static boolean canSeeRoom(GameContext ctx) {
return isLit(ctx, ctx.getPlayer().getCurrentRoom());
}
/** Whether any of the given items is an active light source. */
private static boolean hasActiveLight(Collection<Item> items) {
for (Item it : items) {
if (isActiveLight(it)) {
@@ -35,6 +60,7 @@ public final class Light {
return false;
}
/** A light-emitting item counts only while switched on; non-switchable ones always count. */
private static boolean isActiveLight(Item it) {
if (!it.isLight()) {
return false;

View File

@@ -1,4 +1,4 @@
package thb.jeanluc.adventure.map;
package thb.jeanluc.adventure.game;
import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.io.text.CellState;
@@ -30,6 +30,16 @@ public final class MapLayout {
private MapLayout() {
}
/**
* Builds the map view for the current fog-of-war state. Visited rooms are
* shown by name, their unentered neighbours as unknown cells; rooms beyond
* those are omitted.
*
* @param world the loaded world
* @param visited ids of the rooms the player has entered
* @param current the room the player stands in
* @return the map view; empty if nothing has been visited yet
*/
public static MapView compute(World world, Set<String> visited, Room current) {
if (visited.isEmpty()) {
return new MapView(List.of(), List.of());
@@ -101,6 +111,12 @@ public final class MapLayout {
return new MapView(List.copyOf(cells.values()), connections);
}
/**
* Assigns grid coordinates to every room reachable from the anchor, which
* sits at the origin. A room reached twice at conflicting coordinates keeps
* its first placement; the world geometry is then not planar and the
* collision is logged.
*/
private static Map<String, int[]> bfs(Room anchor) {
Map<String, int[]> coords = new HashMap<>();
Deque<Room> queue = new ArrayDeque<>();
@@ -126,6 +142,7 @@ public final class MapLayout {
return coords;
}
/** Grid step for a direction as {@code {dx, dy}}; y grows southwards. */
private static int[] delta(Direction d) {
return switch (d) {
case NORTH -> new int[]{0, -1};

View File

@@ -16,6 +16,15 @@ public final class QuestEngine {
private QuestEngine() {
}
/**
* Per-turn quest progression: starts every auto-start quest that is neither
* active nor completed, then repeatedly advances active quests whose current
* stage's completion conditions hold, announcing each finished objective and
* quest. Repeats until nothing changes, so a stage whose conditions are met
* by another stage's effects still resolves in the same turn.
*
* @param ctx the active game context
*/
public static void tick(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests();
for (Quest q : quests.values()) {
@@ -54,6 +63,7 @@ public final class QuestEngine {
}
}
/** Applies a quest's completion effects, marks it done, announces it, and autosaves. */
private static void finish(GameContext ctx, Quest q) {
if (ctx.getQuestLog().isCompleted(q.id())) {
return;
@@ -65,6 +75,13 @@ public final class QuestEngine {
ctx.save();
}
/**
* Builds the quest panel view: each active quest with its current objective,
* plus the titles of the completed quests.
*
* @param ctx the active game context
* @return the current quest view
*/
public static QuestView viewOf(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests();
List<QuestEntry> active = new ArrayList<>();
@@ -85,6 +102,7 @@ public final class QuestEngine {
return new QuestView(active, completed);
}
/** Stage count across all quests; bounds the iteration guard in {@link #tick}. */
private static int totalStages(Map<String, Quest> quests) {
int total = 0;
for (Quest q : quests.values()) {

View File

@@ -10,45 +10,97 @@ import java.util.Set;
/** Runtime quest progress: current stage per active quest, plus completed ids. */
public class QuestLog {
/** Index of the current stage per active quest id. Insertion-ordered for stable saves. */
private final Map<String, Integer> stageIndex = new LinkedHashMap<>();
/** Ids of quests that have been completed. Insertion-ordered for stable saves. */
private final Set<String> completed = new LinkedHashSet<>();
/**
* Activates a quest at its first stage. Already active or completed quests
* are left untouched.
*
* @param id the quest id
*/
public void start(String id) {
if (!completed.contains(id)) {
stageIndex.putIfAbsent(id, 0);
}
}
/**
* Tests whether a quest is currently active.
*
* @param id the quest id
* @return true if the quest is active
*/
public boolean isActive(String id) {
return stageIndex.containsKey(id);
}
/**
* Tests whether a quest has been completed.
*
* @param id the quest id
* @return true if the quest is completed
*/
public boolean isCompleted(String id) {
return completed.contains(id);
}
/**
* Current stage index of a quest.
*
* @param id the quest id
* @return the stage index, or 0 if the quest is not active
*/
public int stageIndex(String id) {
return stageIndex.getOrDefault(id, 0);
}
/**
* Moves a quest on to its next stage.
*
* @param id the quest id
*/
public void advance(String id) {
stageIndex.merge(id, 1, Integer::sum);
}
/**
* Marks a quest completed and removes it from the active set.
*
* @param id the quest id
*/
public void complete(String id) {
stageIndex.remove(id);
completed.add(id);
}
/**
* Ids of all currently active quests.
*
* @return an unmodifiable view of the active quest ids
*/
public Set<String> active() {
return Collections.unmodifiableSet(stageIndex.keySet());
}
/**
* Ids of all completed quests.
*
* @return an unmodifiable view of the completed quest ids
*/
public Set<String> completed() {
return Collections.unmodifiableSet(completed);
}
/** Replaces all progress with the given stage map and completed set (load). */
/**
* Replaces all progress with the given stage map and completed set (load).
*
* @param stages stage index per active quest id
* @param completedIds ids of the completed quests
*/
public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
stageIndex.clear();
stageIndex.putAll(stages);

View File

@@ -18,25 +18,51 @@ import java.util.Optional;
*/
public final class TutorialGuide {
/** The walkthrough content: intro, steps, and closing tips. */
private final Tutorial tutorial;
/** Command lookup, used to compare a typed verb against a step's expected command via aliases. */
private final CommandRegistry registry;
/** Index of the step currently being taught. */
private int index;
/** False once all steps are done or the walkthrough was skipped. */
private boolean active;
/** True once {@link #begin(GameContext)} has printed the intro; guards against a second run. */
private boolean begun;
/** Room the player stood in when the current step began; used to detect movement steps. */
private Room roomAtStepStart;
/**
* Creates a guide for the given walkthrough. The guide starts inactive if
* the tutorial has no steps.
*
* @param tutorial the walkthrough content
* @param registry command lookup for alias-aware verb comparison
*/
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
this.tutorial = tutorial;
this.registry = registry;
this.active = !tutorial.isEmpty();
}
/** @return true while the walkthrough is still running. */
/**
* Reports whether the guide still has steps left to hand out.
*
* @return true while the walkthrough is still running
*/
public boolean isActive() {
return active;
}
/** Prints the intro and the first instruction (call once at loop start). */
/**
* Prints the intro and the first instruction (call once at loop start).
*
* @param ctx the active game context
*/
public void begin(GameContext ctx) {
if (!active || begun) {
return;
@@ -48,7 +74,14 @@ public final class TutorialGuide {
enterStep(ctx);
}
/** Evaluates the current step against the command that was just executed. */
/**
* Evaluates the current step against the command that was just executed:
* on success prints the confirmation and advances (ending the walkthrough
* after the last step), otherwise prints the step's hint.
*
* @param ctx the active game context
* @param parsed the command that was just executed
*/
public void onCommand(GameContext ctx, ParsedCommand parsed) {
if (!active) {
return;
@@ -70,7 +103,11 @@ public final class TutorialGuide {
}
}
/** Ends the tutorial early (the 'skip' command). */
/**
* Ends the tutorial early (the 'skip' command).
*
* @param ctx the active game context
*/
public void skip(GameContext ctx) {
if (active) {
ctx.getIo().write("Tutorial skipped.");
@@ -78,11 +115,18 @@ public final class TutorialGuide {
}
}
/** Prints the current step's instruction and records the room it started in. */
private void enterStep(GameContext ctx) {
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
ctx.getIo().write(tutorial.steps().get(index).instruction());
}
/**
* Whether the executed command fulfils the step. The pseudo-expectations
* {@code go_direction} and {@code go_to_room} additionally require an actual
* room change; every other expectation matches on the command (aliases
* included) and the step's minimum argument count.
*/
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
String expect = step.expect();
if ("go_direction".equals(expect)) {
@@ -98,6 +142,7 @@ public final class TutorialGuide {
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
}
/** Whether the first argument parses as a {@link Direction} (rather than a room name). */
private boolean firstIsDirection(ParsedCommand p) {
if (p.args().isEmpty()) {
return false;
@@ -110,11 +155,13 @@ public final class TutorialGuide {
}
}
/** Whether the player left the room the current step started in. */
private boolean moved(GameContext ctx) {
return roomAtStepStart != null
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
}
/** Whether both verbs resolve to the same registered command, so aliases count as a match. */
private boolean sameCommand(String a, String b) {
Optional<Command> ca = registry.find(a);
Optional<Command> cb = registry.find(b);

View File

@@ -23,28 +23,70 @@ import java.util.List;
public class ConsoleIO implements GameIO {
/** Whether ANSI colour is emitted. */
public enum ColorMode { ON, OFF, AUTO }
public enum ColorMode {
/** Always emit ANSI escape codes. */
ON,
/** Never emit ANSI escape codes; plain text only. */
OFF,
/** Emit colour only when attached to a real console, not when piped. */
AUTO
}
/** Frame/glyph fidelity tier. */
public enum GlyphMode { ASCII, UNICODE, GLYPH }
public enum GlyphMode {
/** Frames drawn with {@code +} and {@code -}, for terminals without Unicode. */
ASCII,
/** Frames drawn with Unicode box-drawing characters. */
UNICODE,
/** Highest tier: Unicode frames plus decorative glyphs. */
GLYPH
}
/** Total width of a room frame, in characters. */
private static final int FRAME_WIDTH = 44;
/** Source the player's commands are read from. */
private final BufferedReader in;
/** Sink all output is written to. */
private final PrintStream out;
/** Whether ANSI colour codes are currently emitted; resolved from a {@link ColorMode}. */
private boolean useColor;
/** Frame/glyph fidelity tier currently in use. */
private GlyphMode glyphs;
/** Reads from {@code System.in} and writes to {@code System.out}, colour auto-detected. */
public ConsoleIO() {
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
}
/** Backward-compatible stream constructor (colour off, unicode frames). */
/**
* Backward-compatible stream constructor (colour off, unicode frames).
*
* @param in source of the player's commands
* @param out sink for all output
*/
public ConsoleIO(BufferedReader in, PrintStream out) {
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
}
/**
* Creates a console IO bound to the given streams and fidelity settings.
*
* @param in source of the player's commands
* @param out sink for all output
* @param colorMode whether ANSI colour is emitted
* @param glyphs frame/glyph fidelity tier
*/
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
this.in = in;
this.out = out;
@@ -52,7 +94,11 @@ public class ConsoleIO implements GameIO {
setColorMode(colorMode);
}
/** Re-resolves colour emission from the given mode (AUTO checks the console). */
/**
* Re-resolves colour emission from the given mode (AUTO checks the console).
*
* @param mode the colour mode to apply
*/
public void setColorMode(ColorMode mode) {
this.useColor = switch (mode) {
case ON -> true;
@@ -61,7 +107,11 @@ public class ConsoleIO implements GameIO {
};
}
/** Switches the frame/glyph fidelity tier live. */
/**
* Switches the frame/glyph fidelity tier live.
*
* @param mode the tier to apply
*/
public void setGlyphMode(GlyphMode mode) {
this.glyphs = mode;
}
@@ -118,6 +168,14 @@ public class ConsoleIO implements GameIO {
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
}
/**
* Builds one indented, labelled line listing the given entries.
*
* @param label dim heading in front of the entries
* @param xs the entries
* @param st style applied to each entry
* @return the rendered line
*/
private String section(String label, List<String> xs, Style st) {
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
for (int i = 0; i < xs.size(); i++) {
@@ -129,6 +187,10 @@ public class ConsoleIO implements GameIO {
return sb.toString();
}
/**
* @param title room name embedded in the top border
* @return the top border of a room frame, {@link #FRAME_WIDTH} characters wide
*/
private String frameTop(String title) {
String tl = glyphs == GlyphMode.ASCII ? "+" : "";
String tr = glyphs == GlyphMode.ASCII ? "+" : "";
@@ -141,6 +203,9 @@ public class ConsoleIO implements GameIO {
return sb.append(tr).toString();
}
/**
* @return the bottom border of a room frame, {@link #FRAME_WIDTH} characters wide
*/
private String frameBottom() {
String bl = glyphs == GlyphMode.ASCII ? "+" : "";
String br = glyphs == GlyphMode.ASCII ? "+" : "";
@@ -152,6 +217,13 @@ public class ConsoleIO implements GameIO {
return sb.append(br).toString();
}
/**
* Wraps the text in the ANSI escape codes of the given role.
*
* @param st the semantic role
* @param s the text
* @return the text unchanged when colour is disabled, else the coloured text
*/
private String paint(Style st, String s) {
if (!useColor) {
return s;

View File

@@ -18,28 +18,52 @@ import java.util.List;
*/
public interface GameIO {
/** Blocks until a line of input is available; empty string at end of input. */
/**
* Blocks until a line of input is available; empty string at end of input.
*
* @return the line the player entered
*/
String readLine();
/** Renders one styled output block. The only method implementors must provide. */
/**
* Renders one styled output block. The only method implementors must provide.
*
* @param text the block to render
*/
void print(StyledText text);
/** Convenience: a plain, unstyled line. */
/**
* Convenience: a plain, unstyled line.
*
* @param s the text to write
*/
default void write(String s) {
print(StyledText.of(s));
}
/** Renders a room. Default flattens via {@link Renderings}; renderers may override. */
/**
* Renders a room. Default flattens via {@link Renderings}; renderers may override.
*
* @param room the room snapshot to render
*/
default void showRoom(RoomView room) {
print(Renderings.roomToStyledText(room));
}
/** Updates the status/HUD region. No-op by default; renderers may override. */
/**
* Updates the status/HUD region. No-op by default; renderers may override.
*
* @param hud the status snapshot
*/
default void setHud(Hud hud) {
// no-op
}
/** Per-turn push of the current map. GUI repaints its panel; console ignores. */
/**
* Per-turn push of the current map. GUI repaints its panel; console ignores.
*
* @param view the map snapshot
*/
default void setMap(MapView view) {
// no-op
}
@@ -55,27 +79,47 @@ public interface GameIO {
// no animation in text mode
}
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
/**
* On-demand map (the 'map' command). Default renders ASCII (Unicode frames).
*
* @param view the map snapshot
*/
default void showMap(MapView view) {
print(AsciiMap.render(view, true));
}
/** Per-turn push of the quest log. GUI repaints its box; console ignores. */
/**
* Per-turn push of the quest log. GUI repaints its box; console ignores.
*
* @param view the quest snapshot
*/
default void setQuests(QuestView view) {
// no-op
}
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
/**
* Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays.
*
* @param track the current room's track name; null or blank means silence
*/
default void setMusic(String track) {
// no audio in text mode
}
/** Applies the music volume/level. Console no-op; GUI sets it. */
/**
* Applies the music volume/level. Console no-op; GUI sets it.
*
* @param level the level to apply
*/
default void setMusicLevel(MusicLevel level) {
// no audio in text mode
}
/** On-demand quest log (the 'quests' command). Default renders styled text. */
/**
* On-demand quest log (the 'quests' command). Default renders styled text.
*
* @param view the quest snapshot
*/
default void showQuests(QuestView view) {
print(QuestText.render(view));
}

View File

@@ -31,30 +31,64 @@ import java.util.List;
*/
public class MapPanel extends JComponent {
/** Unscaled room width, in pixels. */
private static final int BASE_CELL_W = 96;
/** Unscaled room height, in pixels. */
private static final int BASE_CELL_H = 46;
/** Unscaled horizontal gap between two rooms, in pixels. */
private static final int BASE_GAP_X = 34;
/** Unscaled vertical gap between two rooms, in pixels. */
private static final int BASE_GAP_Y = 30;
/** Unscaled padding around the whole map, in pixels. */
private static final int BASE_PAD = 16;
/** Unscaled size of the room-label font, in points. */
private static final float BASE_FONT = 11f;
/** Panel background. */
private static final Color BG = new Color(0x0e, 0x12, 0x18);
/** Line colour of a corridor between two visited rooms. */
private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a);
/** Outline colour of a {@link CellState#VISITED} room. */
private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0);
/** Outline colour of the {@link CellState#CURRENT} room. */
private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a);
/** Outline colour of a {@link CellState#KNOWN} but unvisited room. */
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
/** Font the room labels are derived from. */
private final Font baseFont;
/** User zoom multiplier, applied on top of the fit-to-panel scale. */
private float zoom = 1f;
/** The map currently drawn; empty until the first {@link #show(MapView)}. */
private MapView view = new MapView(List.of(), List.of());
/**
* Creates an empty map panel; a map appears with the first {@link #show(MapView)}.
*
* @param font font the room labels are derived from
*/
public MapPanel(Font font) {
this.baseFont = font;
setBackground(BG);
setOpaque(true);
}
/** Sets the user zoom multiplier (applied on top of the fit-to-panel scale). */
/**
* Sets the user zoom multiplier (applied on top of the fit-to-panel scale).
*
* @param zoom the multiplier; clamped to a minimum of 0.4
*/
public void setZoom(float zoom) {
SwingUtilities.invokeLater(() -> {
this.zoom = Math.max(0.4f, zoom);
@@ -63,7 +97,11 @@ public class MapPanel extends JComponent {
});
}
/** Replaces the rendered map. */
/**
* Replaces the rendered map.
*
* @param v the new map snapshot
*/
public void show(MapView v) {
SwingUtilities.invokeLater(() -> {
this.view = v;
@@ -72,7 +110,11 @@ public class MapPanel extends JComponent {
});
}
/** The space the map should fit into: the enclosing scroll pane's viewport area. */
/**
* The space the map should fit into: the enclosing scroll pane's viewport area.
*
* @return the viewport area, this panel's own size, or a fixed fallback
*/
private Dimension available() {
Container p = getParent();
if (p instanceof JViewport && p.getParent() instanceof JScrollPane sp) {
@@ -87,6 +129,10 @@ public class MapPanel extends JComponent {
return (s.width > 0 && s.height > 0) ? s : new Dimension(220, 320);
}
/**
* @return the highest occupied column and row of the map grid, as
* {@code [maxX, maxY]}
*/
private int[] gridBounds() {
int maxX = 0;
int maxY = 0;
@@ -97,7 +143,13 @@ public class MapPanel extends JComponent {
return new int[]{maxX, maxY};
}
/** Fit-to-panel scale times the user zoom. */
/**
* Fit-to-panel scale times the user zoom.
*
* @param avail the space available to the map
* @param b grid bounds as returned by {@link #gridBounds()}
* @return the effective drawing scale
*/
private float effScale(Dimension avail, int[] b) {
double natW = 2.0 * BASE_PAD + (b[0] + 1) * BASE_CELL_W + b[0] * BASE_GAP_X;
double natH = 2.0 * BASE_PAD + (b[1] + 1) * BASE_CELL_H + b[1] * BASE_GAP_Y;
@@ -109,6 +161,11 @@ public class MapPanel extends JComponent {
return (float) (fit * zoom);
}
/**
* @param eff the effective drawing scale
* @param b grid bounds as returned by {@link #gridBounds()}
* @return the size of the drawn map at that scale, including padding
*/
private Dimension content(float eff, int[] b) {
int pad = Math.round(BASE_PAD * eff);
int cw = Math.round(BASE_CELL_W * eff);

View File

@@ -3,13 +3,22 @@ package thb.jeanluc.adventure.io;
/** Raw audio operations — the only part that touches sound hardware. */
public interface MusicBackend {
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
/**
* Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}.
*
* @param track name of the track to play
* @param gainDb target volume in decibels
*/
void fadeTo(String track, float gainDb);
/** Fade the current track out and stop. */
void fadeOut();
/** Set the playback volume immediately. */
/**
* Set the playback volume immediately.
*
* @param gainDb target volume in decibels
*/
void setGainDb(float gainDb);
/** Stop playback and release resources. */

View File

@@ -10,15 +10,31 @@ import java.util.Objects;
*/
public final class MusicController {
/** Backend the raw playback operations are delegated to. */
private final MusicBackend backend;
/** Volume level currently selected in the Settings menu. */
private MusicLevel level = MusicLevel.MEDIUM;
/** Track currently selected, or null when silent. */
private String current; // currently selected track, or null = silence
/**
* Creates a controller starting silent at {@link MusicLevel#MEDIUM}.
*
* @param backend backend that performs the actual playback
*/
public MusicController(MusicBackend backend) {
this.backend = backend;
}
/** Called per turn with the current room's music field (may be null/blank). */
/**
* Called per turn with the current room's music field (may be null/blank).
* Does nothing while the level is {@link MusicLevel#OFF} or the track is
* already playing.
*
* @param track the room's track name; null or blank means silence
*/
public void room(String track) {
if (!level.isOn()) {
return;
@@ -35,7 +51,13 @@ public final class MusicController {
}
}
/** Applies a new music level (volume / off). */
/**
* Applies a new music level (volume / off). Switching to
* {@link MusicLevel#OFF} fades out; switching back on replays on the next
* {@link #room(String)} call.
*
* @param newLevel the level to apply
*/
public void level(MusicLevel newLevel) {
if (newLevel == this.level) {
return;
@@ -49,6 +71,7 @@ public final class MusicController {
}
}
/** Stops playback and releases the backend's resources. */
public void shutdown() {
backend.shutdown();
}

View File

@@ -2,9 +2,23 @@ package thb.jeanluc.adventure.io;
/** Background-music volume level, cycled in the Settings menu. */
public enum MusicLevel {
OFF, LOW, MEDIUM, HIGH;
/** Music disabled; nothing is played. */
OFF,
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
/** Quiet background music. */
LOW,
/** Default volume. */
MEDIUM,
/** Full volume, without attenuation. */
HIGH;
/**
* Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled).
*
* @return the gain of this level
*/
public float gainDb() {
return switch (this) {
case OFF -> Float.NEGATIVE_INFINITY;
@@ -14,12 +28,20 @@ public enum MusicLevel {
};
}
/** @return true when music should play. */
/**
* Reports whether this level permits playback.
*
* @return true when music should play
*/
public boolean isOn() {
return this != OFF;
}
/** Next level in the cycle (wraps HIGH → OFF). */
/**
* Next level in the cycle (wraps HIGH → OFF).
*
* @return the following level
*/
public MusicLevel next() {
return values()[(ordinal() + 1) % values().length];
}

View File

@@ -7,6 +7,7 @@ import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.SourceDataLine;
import java.util.concurrent.atomic.AtomicBoolean;
import java.io.BufferedInputStream;
import java.io.InputStream;
@@ -20,17 +21,46 @@ import java.io.InputStream;
@Slf4j
public final class OggMusicBackend implements MusicBackend {
/** Classpath directory the bundled tracks are looked up in. */
private static final String MUSIC_DIR = "/music/";
/** Duration of a fade-in or fade-out, in milliseconds. */
private static final int FADE_MS = 400;
/** Number of gain steps a fade is split into. */
private static final int FADE_STEPS = 20;
/** Size of the PCM transfer buffer, in bytes. */
private static final int BUFFER = 4096;
/** Gain a fade starts from and ends at, in decibels. */
private static final float FLOOR_DB = -40f; // near-silence for fades
/** The currently playing thread, or null when silent. */
private volatile Thread playThread;
private volatile boolean stopping;
/**
* Stop token of the current play thread. Each playback owns its own token
* rather than sharing one flag: if {@link #stopCurrent()} times out waiting
* for a thread, that thread's token stays set, so it still winds down and
* can never be revived by the next {@link #fadeTo}.
*/
private volatile AtomicBoolean playStop;
/** Output line of the current playback, used by {@link #setGainDb}. */
private volatile SourceDataLine line;
/** Gain most recently requested by the player, in decibels. */
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
/**
* Stops the current track and starts {@code track} on a fresh daemon thread,
* fading in to {@code gainDb}. A track that is not on the classpath is logged
* and leaves playback silent.
*
* @param track file name below {@code /music/}
* @param gainDb target MASTER_GAIN in decibels
*/
@Override
public synchronized void fadeTo(String track, float gainDb) {
stopCurrent();
@@ -40,17 +70,24 @@ public final class OggMusicBackend implements MusicBackend {
log.warn("Music resource not found, staying silent: {}", resource);
return;
}
stopping = false;
playThread = new Thread(() -> playLoop(resource, gainDb), "music");
AtomicBoolean stop = new AtomicBoolean(false);
playStop = stop;
playThread = new Thread(() -> playLoop(resource, gainDb, stop), "music");
playThread.setDaemon(true);
playThread.start();
}
/** Fades the current track out and stops the play thread. */
@Override
public synchronized void fadeOut() {
stopCurrent();
}
/**
* Applies a new gain to the running playback immediately, without a fade.
*
* @param gainDb target MASTER_GAIN in decibels
*/
@Override
public synchronized void setGainDb(float gainDb) {
targetGainDb = gainDb;
@@ -60,30 +97,51 @@ public final class OggMusicBackend implements MusicBackend {
}
}
/** Stops playback and releases the audio line. */
@Override
public synchronized void shutdown() {
stopCurrent();
}
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
/**
* Signals the play thread to stop and waits for its fade-out + cleanup.
* The thread's stop token stays set even if the join times out, so a slow
* thread still terminates on its own instead of playing on forever.
*/
private void stopCurrent() {
Thread t = playThread;
AtomicBoolean stop = playStop;
if (t == null) {
return;
}
stopping = true;
if (stop != null) {
stop.set(true);
}
try {
t.join(FADE_MS + 600L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (t.isAlive()) {
log.warn("Music thread did not stop within the join budget; it will wind down on its own");
}
playThread = null;
playStop = null;
}
private void playLoop(String resource, float gainDb) {
/**
* Body of the play thread: decodes the resource to PCM and writes it to the
* output line, restarting at the end of the stream until {@code stop} is set.
* Decoding failures are logged and end playback. Runs a fade-out on exit.
*
* @param resource classpath path of the OGG file
* @param gainDb gain to fade in to
* @param stop this playback's stop token
*/
private void playLoop(String resource, float gainDb, AtomicBoolean stop) {
SourceDataLine l = null;
try {
while (!stopping) {
while (!stop.get()) {
// Reopen the bundled resource each loop; BufferedInputStream gives
// the SPI the mark/reset support it needs to read the OGG header.
try (InputStream res = getClass().getResourceAsStream(resource);
@@ -97,12 +155,15 @@ public final class OggMusicBackend implements MusicBackend {
l = AudioSystem.getSourceDataLine(pcm);
l.open(pcm);
l.start();
line = l;
ramp(l, FLOOR_DB, gainDb); // fade in once
// Only publish the line if this playback is still current.
if (playStop == stop) {
line = l;
}
ramp(l, FLOOR_DB, gainDb, stop); // fade in once
}
byte[] buf = new byte[BUFFER];
int n;
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
while (!stop.get() && (n = din.read(buf, 0, buf.length)) != -1) {
l.write(buf, 0, n);
}
}
@@ -113,24 +174,44 @@ public final class OggMusicBackend implements MusicBackend {
} finally {
if (l != null) {
try {
ramp(l, currentGain(l), FLOOR_DB); // fade out
// Fade-out must run to completion, so it gets a fresh token.
ramp(l, currentGain(l), FLOOR_DB, new AtomicBoolean(false));
} catch (RuntimeException ignored) {
// best-effort fade
}
l.stop();
l.close();
}
line = null;
// Only clear the shared line if it still belongs to this playback.
if (playStop == stop) {
line = null;
}
}
}
private void ramp(SourceDataLine l, float fromDb, float toDb) {
/**
* Ramps the gain, aborting early if this playback has been stopped.
*
* @param l the line to ramp
* @param fromDb starting gain in decibels
* @param toDb final gain in decibels
* @param stop stop token checked between steps
*/
private void ramp(SourceDataLine l, float fromDb, float toDb, AtomicBoolean stop) {
for (int i = 0; i <= FADE_STEPS; i++) {
if (stop.get()) {
return;
}
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
sleep(FADE_MS / FADE_STEPS);
}
}
/**
* @param l the line to read
* @return the line's current gain, or {@link #targetGainDb} if the platform
* offers no master-gain control
*/
private float currentGain(SourceDataLine l) {
try {
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
@@ -139,6 +220,13 @@ public final class OggMusicBackend implements MusicBackend {
}
}
/**
* Sets the line's gain, clamped to the range it supports. Lines without a
* master-gain control are left untouched.
*
* @param l the line to adjust
* @param db requested gain in decibels
*/
private void applyGain(SourceDataLine l, float db) {
try {
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
@@ -148,6 +236,11 @@ public final class OggMusicBackend implements MusicBackend {
}
}
/**
* Sleeps, restoring the interrupt flag instead of propagating.
*
* @param ms milliseconds to sleep
*/
private void sleep(long ms) {
try {
Thread.sleep(ms);

View File

@@ -18,6 +18,11 @@ import java.awt.Insets;
/** Read-only styled view of the quest log, shown under the map. */
public class QuestPanel extends JTextPane {
/**
* Creates an empty, read-only panel; content arrives via {@link #show(QuestView)}.
*
* @param font font the panel text is derived from
*/
public QuestPanel(Font font) {
setEditable(false);
setBackground(new Color(0x0b, 0x0e, 0x13));
@@ -26,6 +31,11 @@ public class QuestPanel extends JTextPane {
setMargin(new Insets(8, 10, 8, 10));
}
/**
* Replaces the panel's content with the given quest log.
*
* @param view the quest snapshot to render
*/
public void show(QuestView view) {
SwingUtilities.invokeLater(() -> {
StyledDocument doc = getStyledDocument();
@@ -46,6 +56,10 @@ public class QuestPanel extends JTextPane {
});
}
/**
* @param s the semantic role
* @return the colour this panel paints that role in
*/
private Color colorFor(Style s) {
return switch (s) {
case HEADING -> new Color(0xc9, 0x8a, 0xe0);

View File

@@ -15,6 +15,7 @@ import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
@@ -71,20 +72,50 @@ public class SwingIO implements GameIO {
/** Side-panel width as a fraction of the window width, with a lower bound only. */
private static final double SIDE_RATIO = 0.34;
/** Lower bound for the side-panel width, in unscaled pixels. */
private static final int SIDE_MIN = 220;
/** Background music of the current room. */
private final MusicController music = new MusicController(new OggMusicBackend());
/**
* Lines typed into the input field, handed from the Swing EDT to the game
* loop, which blocks on {@link #readLine()}.
*/
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
/** The game window. */
private final JFrame frame;
/** Centre region: the scrolling transcript of styled output. */
private final JTextPane output;
/** Document behind {@link #output}; all text is appended to it. */
private final StyledDocument doc;
/** Bottom region: the command line the player types into. */
private final JTextField input;
/** Top region: the single-line status bar. */
private final JLabel hud;
/** Right region (upper): the drawn map. */
private final MapPanel map;
/** Scroll pane wrapping {@link #map}. */
private final JScrollPane sideScroll;
/** Right region (lower): the quest log. */
private final QuestPanel quests;
/** Scroll pane wrapping {@link #quests}. */
private final JScrollPane questScroll;
/** Right region: container holding {@link #sideScroll} and {@link #questScroll}. */
private final JPanel sidePanel;
/** Font all component fonts are derived from; bundled TTF or a monospaced fallback. */
private final Font baseFont;
/** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
@@ -93,6 +124,12 @@ public class SwingIO implements GameIO {
/** User zoom multiplier, adjusted live via keyboard. */
private float zoom = 1f;
/**
* Builds the window, wires the input field to the input queue, installs the
* zoom keys, and shows the frame.
*
* @param title window title
*/
public SwingIO(String title) {
baseFont = loadFont();
uiScale = detectScale();
@@ -156,7 +193,11 @@ public class SwingIO implements GameIO {
input.requestFocusInWindow();
}
/** Loads the bundled font if present, else falls back to a logical monospaced font. */
/**
* Loads the bundled font if present, else falls back to a logical monospaced font.
*
* @return the font all component fonts are derived from
*/
private Font loadFont() {
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
if (in != null) {
@@ -170,7 +211,11 @@ public class SwingIO implements GameIO {
return new Font(Font.MONOSPACED, Font.PLAIN, 12);
}
/** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */
/**
* Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI.
*
* @return the display scale, at least 1.0
*/
private float detectScale() {
try {
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
@@ -207,6 +252,8 @@ public class SwingIO implements GameIO {
* Auto-scale derived from the window size relative to the design size
* (900x600). At the initial size this equals {@link #uiScale}; growing the
* window grows the text, mirroring how the map fits its panel.
*
* @return the auto-scale, clamped to 0.62.6
*/
private float frameScale() {
double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0);
@@ -242,10 +289,23 @@ public class SwingIO implements GameIO {
root.getActionMap().put("zoomReset", action(e -> setZoom(1f)));
}
/**
* Maps a key stroke to an action name on the component's window-scoped input map.
*
* @param c the component whose input map is extended
* @param ks the key stroke to bind
* @param name the action-map key it triggers
*/
private void bind(JComponent c, KeyStroke ks, String name) {
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
}
/**
* Adapts a lambda to a Swing action.
*
* @param body code run when the action fires
* @return the action
*/
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
return new AbstractAction() {
@Override
@@ -255,11 +315,20 @@ public class SwingIO implements GameIO {
};
}
/**
* Sets the zoom, clamped to 0.63.0, and re-applies the fonts.
*
* @param z the requested zoom multiplier
*/
private void setZoom(float z) {
zoom = Math.max(0.6f, Math.min(3.0f, z));
applyFonts();
}
/**
* @param s the semantic role
* @return the colour this frontend paints that role in
*/
private Color colorFor(Style s) {
return switch (s) {
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
@@ -272,6 +341,12 @@ public class SwingIO implements GameIO {
};
}
/**
* Appends the spans to the output pane and scrolls to the end. Runs on the
* Swing EDT; safe to call from the game loop.
*
* @param spans the styled runs to append
*/
private void appendSpans(List<Span> spans) {
SwingUtilities.invokeLater(() -> {
try {
@@ -357,6 +432,23 @@ public class SwingIO implements GameIO {
});
}
/**
* Reports an unrecoverable error and closes the GUI. The game loop runs on
* the only thread that consumes player input, so if it dies the window would
* otherwise stay open and silently swallow every command. Shows a modal
* dialog, then exits.
*
* @param message text shown to the player
*/
public void fatal(String message) {
music.shutdown();
SwingUtilities.invokeLater(() -> {
JOptionPane.showMessageDialog(frame, message, "Haunted Manor", JOptionPane.ERROR_MESSAGE);
frame.dispose();
System.exit(1);
});
}
@Override
public void setMap(MapView view) {
map.show(view);
@@ -405,7 +497,11 @@ public class SwingIO implements GameIO {
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
private String menuTitle;
/** Options of the menu currently shown, or null when no menu is visible. */
private List<String> menuOptions;
/** Queue the current menu's chosen index is offered to. */
private LinkedBlockingQueue<Integer> menuPicked;
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
@@ -415,7 +511,15 @@ public class SwingIO implements GameIO {
}
}
/** Installs the menu as the frame's glass pane (covers everything) and shows it. */
/**
* Installs the menu as the frame's glass pane (covers everything) and shows it.
* Disables the input field while the menu is up. A button click offers that
* button's index to {@code picked}; Escape offers the last option.
*
* @param title heading shown above the options
* @param options option labels, one button each
* @param picked queue the chosen index is offered to
*/
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
menuTitle = title;
menuOptions = options;
@@ -463,24 +567,55 @@ public class SwingIO implements GameIO {
glass.add(card); // single child → centered by GridBagLayout
frame.setGlassPane(glass);
glass.setVisible(true);
// The glass pane swallows mouse events but cannot take keyboard focus,
// so the input field would stay live behind the menu and queue commands
// that get replayed once the menu closes. Disable it for the duration.
input.setEnabled(false);
glass.requestFocusInWindow();
}
/**
* Hides the menu overlay, discards anything typed while it was up, and
* returns focus to the input field.
*/
private void hideMenuOverlay() {
menuOptions = null; // stop resize rebuilds once the menu is dismissed
frame.getGlassPane().setVisible(false);
// Drop anything typed before the field was disabled, so a stale line
// cannot be replayed as a game command.
inputs.clear();
input.setEnabled(true);
input.requestFocusInWindow();
}
/** Palette for the themed menu overlay, matching the game window's dark look. */
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
/** Colour of the menu heading. */
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
/** Menu-button background at rest. */
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
/** Menu-button background under the mouse. */
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
/** Menu-button label colour. */
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
/** Menu-button outline at rest. */
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
/** Menu-button outline under the mouse. */
private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);
/** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */
/**
* A flat, dark, fixed-width menu button matching the game palette, with hover feedback.
*
* @param text the button label
* @param scale the scale its size and font are derived from
* @return the button
*/
private JButton menuButton(String text, float scale) {
int padV = (int) (11 * scale);
int padH = (int) (18 * scale);
@@ -514,12 +649,25 @@ public class SwingIO implements GameIO {
return b;
}
/**
* @param line outline colour
* @param padV vertical inner padding, in pixels
* @param padH horizontal inner padding, in pixels
* @return a one-pixel outline with the given inner padding
*/
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
return BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(line, 1),
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
}
/**
* Appends the entries as comma-separated spans of the given style.
*
* @param spans target list the spans are added to
* @param xs the entries
* @param st style applied to each entry
*/
private void addList(List<Span> spans, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {

View File

@@ -11,13 +11,26 @@ import java.util.Set;
*/
public final class AsciiMap {
/** Lower bound for the room-name field width, in characters. */
private static final int MIN_INNER = 7; // minimum room name field width
/** Upper bound for the room-name field width, in characters. */
private static final int MAX_INNER = 14; // cap so the map stays compact
/** Width of a horizontal corridor, in characters. */
private static final int GAP = 3; // horizontal corridor width
/** Utility class; not instantiable. */
private AsciiMap() {
}
/**
* Renders the map as a styled grid of boxed rooms and corridor lines.
*
* @param view the map snapshot to draw
* @param unicode true for box-drawing characters, false for pure ASCII
* @return the rendered grid, or a placeholder when the map is empty
*/
public static StyledText render(MapView view, boolean unicode) {
if (view.cells().isEmpty()) {
return StyledText.of("(no map yet)");
@@ -98,6 +111,13 @@ public final class AsciiMap {
return b.build();
}
/**
* Appends the text in the style that matches the cell's fog-of-war state.
*
* @param b the builder to append to
* @param s the text
* @param state the cell's state
*/
private static void styled(StyledText.Builder b, String s, CellState state) {
switch (state) {
case CURRENT -> b.heading(s);
@@ -106,6 +126,11 @@ public final class AsciiMap {
}
}
/**
* @param c the cell
* @param inner width of the name field, in characters
* @return the room name, truncated to {@code inner}
*/
private static String label(RoomCell c, int inner) {
String n = c.name();
if (n.length() > inner) {
@@ -114,6 +139,11 @@ public final class AsciiMap {
return n;
}
/**
* @param s the text
* @param width target width, at least the text's length
* @return the text padded with spaces so it sits centred in {@code width}
*/
private static String center(String s, int width) {
int total = width - s.length();
int left = total / 2;
@@ -121,6 +151,11 @@ public final class AsciiMap {
return " ".repeat(left) + s + " ".repeat(right);
}
/**
* @param x column on the grid
* @param y row on the grid
* @return the two coordinates packed into one map/set key
*/
private static long key(int x, int y) {
return (((long) x) << 32) ^ (y & 0xffffffffL);
}

View File

@@ -3,10 +3,16 @@ package thb.jeanluc.adventure.io.text;
/** Builds styled banners for big moments (game start, finale). */
public final class Banner {
/** Utility class; not instantiable. */
private Banner() {
}
/** A framed, heading-styled welcome banner for the given title. */
/**
* A framed, heading-styled welcome banner for the given title.
*
* @param title the game title to frame
* @return the banner
*/
public static StyledText welcome(String title) {
String bar = "=".repeat(Math.max(8, title.length() + 8));
return StyledText.builder()

View File

@@ -1,4 +1,14 @@
package thb.jeanluc.adventure.io.text;
/** Fog-of-war state of a room on the map. */
public enum CellState { CURRENT, VISITED, KNOWN }
public enum CellState {
/** The room the player is standing in. */
CURRENT,
/** A room the player has already entered. */
VISITED,
/** A room that is only known as the neighbour of a visited room, never entered. */
KNOWN
}

View File

@@ -1,4 +1,9 @@
package thb.jeanluc.adventure.io.text;
/** A corridor between two placed rooms. */
/**
* A corridor between two placed rooms.
*
* @param from cell the corridor starts at
* @param to cell the corridor leads to
*/
public record Connection(RoomCell from, RoomCell to) {}

View File

@@ -1,4 +1,11 @@
package thb.jeanluc.adventure.io.text;
/** Status snapshot for the HUD region. */
/**
* Status snapshot for the HUD region.
*
* @param location display name of the room the player is in
* @param gold gold currently carried by the player
* @param turn number of turns played so far
* @param lightOn whether the player carries an active light source
*/
public record Hud(String location, int gold, int turn, boolean lightOn) {}

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Frontend-agnostic snapshot of the visible map: placed cells + connections. */
/**
* Frontend-agnostic snapshot of the visible map: placed cells + connections.
*
* @param cells the rooms the player knows about, placed on the grid
* @param connections the corridors between those rooms
*/
public record MapView(List<RoomCell> cells, List<Connection> connections) {
/** Defensively copies both lists, making the snapshot immutable. */
public MapView {
cells = List.copyOf(cells);
connections = List.copyOf(connections);

View File

@@ -1,4 +1,9 @@
package thb.jeanluc.adventure.io.text;
/** An active quest's title and its current objective. */
/**
* An active quest's title and its current objective.
*
* @param title display name of the quest
* @param objective description of the step the player has to complete next
*/
public record QuestEntry(String title, String objective) {}

View File

@@ -3,9 +3,17 @@ package thb.jeanluc.adventure.io.text;
/** Renders a {@link QuestView} as styled text for the console quest log. */
public final class QuestText {
/** Utility class; not instantiable. */
private QuestText() {
}
/**
* Renders the quest log: active quests with their objective, then the
* completed titles.
*
* @param view the quest snapshot to render
* @return the rendered log, or a placeholder when there are no quests
*/
public static StyledText render(QuestView view) {
if (view.active().isEmpty() && view.completed().isEmpty()) {
return StyledText.of("You have no quests yet.");

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Frontend-agnostic snapshot of the quest log. */
/**
* Frontend-agnostic snapshot of the quest log.
*
* @param active quests in progress, with their current objective
* @param completed titles of the quests already finished
*/
public record QuestView(List<QuestEntry> active, List<String> completed) {
/** Defensively copies both lists, making the snapshot immutable. */
public QuestView {
active = List.copyOf(active);
completed = List.copyOf(completed);

View File

@@ -5,10 +5,16 @@ import java.util.List;
/** Default, layout-free rendering of high-level views into {@link StyledText}. */
public final class Renderings {
/** Utility class; not instantiable. */
private Renderings() {
}
/** Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests). */
/**
* Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests).
*
* @param r the room snapshot to render
* @return name, description, items, NPCs and exits as styled text
*/
public static StyledText roomToStyledText(RoomView r) {
StyledText.Builder b = StyledText.builder();
b.heading(r.name()).plain("\n");
@@ -33,6 +39,13 @@ public final class Renderings {
return b.build();
}
/**
* Appends the entries as a comma-separated list in the given style.
*
* @param b the builder to append to
* @param xs the entries
* @param st style applied to each entry
*/
private static void appendList(StyledText.Builder b, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {

View File

@@ -1,4 +1,12 @@
package thb.jeanluc.adventure.io.text;
/** A room placed on the map grid at integer coords, with its fog-of-war state. */
/**
* A room placed on the map grid at integer coords, with its fog-of-war state.
*
* @param id unique slug of the room
* @param name display name shown inside the cell
* @param x column on the map grid, 0-based
* @param y row on the map grid, 0-based
* @param state how much the player knows about this room
*/
public record RoomCell(String id, String name, int x, int y, CellState state) {}

View File

@@ -2,9 +2,18 @@ package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Semantic snapshot of a room for rendering. Display-ready strings, no layout. */
/**
* Semantic snapshot of a room for rendering. Display-ready strings, no layout.
*
* @param name display name of the room
* @param description room description text
* @param items display names of the items visible in the room
* @param npcs display names of the NPCs present in the room
* @param exits directions the player can leave by
*/
public record RoomView(String name, String description,
List<String> items, List<String> npcs, List<String> exits) {
/** Defensively copies the lists, making the snapshot immutable. */
public RoomView {
items = List.copyOf(items);
npcs = List.copyOf(npcs);

View File

@@ -1,7 +1,17 @@
package thb.jeanluc.adventure.io.text;
/** A run of text carrying a single semantic {@link Style}. */
/**
* A run of text carrying a single semantic {@link Style}.
*
* @param text the text of this run; never null
* @param style the semantic role applied to the whole run; never null
*/
public record Span(String text, Style style) {
/**
* Rejects null components.
*
* @throws IllegalArgumentException if {@code text} or {@code style} is null
*/
public Span {
if (text == null) {
throw new IllegalArgumentException("text must not be null");

View File

@@ -1,4 +1,26 @@
package thb.jeanluc.adventure.io.text;
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }
public enum Style {
/** Body text without emphasis. */
PLAIN,
/** Titles and section headers. */
HEADING,
/** Item names. */
ITEM,
/** NPC names. */
NPC,
/** Exit directions and room links. */
EXIT,
/** Warnings, failures and threats. */
DANGER,
/** Secondary text of lower importance, e.g. the HUD line or echoed input. */
DIM
}

View File

@@ -6,18 +6,30 @@ import java.util.List;
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
public final class StyledText {
/** The spans of this text, in output order; unmodifiable. */
private final List<Span> spans;
/**
* @param spans the spans to copy into this text
*/
private StyledText(List<Span> spans) {
this.spans = List.copyOf(spans);
}
/** @return the spans in order (unmodifiable). */
/**
* Exposes the spans for rendering.
*
* @return the spans in order (unmodifiable)
*/
public List<Span> spans() {
return spans;
}
/** @return all span text concatenated, without any styling. */
/**
* Flattens this text for frontends that cannot render styles.
*
* @return all span text concatenated, without any styling
*/
public String plainText() {
StringBuilder sb = new StringBuilder();
for (Span s : spans) {
@@ -26,32 +38,104 @@ public final class StyledText {
return sb.toString();
}
/** @return a styled text of a single {@link Style#PLAIN} span. */
/**
* Wraps an unstyled string.
*
* @param s the text
* @return a styled text of a single {@link Style#PLAIN} span
*/
public static StyledText of(String s) {
return new StyledText(List.of(new Span(s, Style.PLAIN)));
}
/**
* Starts assembling a styled text span by span.
*
* @return a new, empty builder
*/
public static Builder builder() {
return new Builder();
}
/** Fluent builder; one method per {@link Style} role. */
public static final class Builder {
/** Spans collected so far, in the order the append methods were called. */
private final List<Span> spans = new ArrayList<>();
/**
* Appends one span.
*
* @param text the text to append
* @param style the role to apply to it
* @return this builder
*/
private Builder add(String text, Style style) {
spans.add(new Span(text, style));
return this;
}
/**
* Appends a span in the {@link Style#PLAIN} role.
*
* @param s text to append as {@link Style#PLAIN}
* @return this builder
*/
public Builder plain(String s) { return add(s, Style.PLAIN); }
/**
* Appends a span in the {@link Style#HEADING} role.
*
* @param s text to append as {@link Style#HEADING}
* @return this builder
*/
public Builder heading(String s) { return add(s, Style.HEADING); }
/**
* Appends a span in the {@link Style#ITEM} role.
*
* @param s text to append as {@link Style#ITEM}
* @return this builder
*/
public Builder item(String s) { return add(s, Style.ITEM); }
/**
* Appends a span in the {@link Style#NPC} role.
*
* @param s text to append as {@link Style#NPC}
* @return this builder
*/
public Builder npc(String s) { return add(s, Style.NPC); }
/**
* Appends a span in the {@link Style#EXIT} role.
*
* @param s text to append as {@link Style#EXIT}
* @return this builder
*/
public Builder exit(String s) { return add(s, Style.EXIT); }
/**
* Appends a span in the {@link Style#DANGER} role.
*
* @param s text to append as {@link Style#DANGER}
* @return this builder
*/
public Builder danger(String s) { return add(s, Style.DANGER); }
/**
* Appends a span in the {@link Style#DIM} role.
*
* @param s text to append as {@link Style#DIM}
* @return this builder
*/
public Builder dim(String s) { return add(s, Style.DIM); }
/**
* Finishes the build; the builder may not be reused meaningfully afterwards.
*
* @return an immutable styled text of the collected spans
*/
public StyledText build() {
return new StyledText(spans);
}

View File

@@ -12,9 +12,19 @@ import java.util.Map;
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
public final class CombinationFactory {
/** Utility class; not instantiable. */
private CombinationFactory() {
}
/**
* Builds a combination and checks every referenced item id against the registry.
*
* @param dto data read from {@code combinations.yaml}; must not be null
* @param items the global item registry
* @return the freshly built combination
* @throws WorldLoadException if an item id is missing, unknown, or both
* ingredients are the same item
*/
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
requireItem(items, dto.a(), "a");
requireItem(items, dto.b(), "b");
@@ -41,6 +51,14 @@ public final class CombinationFactory {
dto.failText());
}
/**
* Asserts that {@code id} is present and known to the item registry.
*
* @param items the global item registry
* @param id the item id to check
* @param field name of the DTO field, used in the error message
* @throws WorldLoadException if the id is null or unknown
*/
private static void requireItem(Map<String, Item> items, String id, String field) {
if (id == null) {
throw new WorldLoadException("Combination '" + field + "' is missing an item id");

View File

@@ -7,9 +7,17 @@ import thb.jeanluc.adventure.model.Ending;
/** Builds {@link Ending} objects from {@link EndingDto}s. */
public final class EndingFactory {
/** Utility class; not instantiable. */
private EndingFactory() {
}
/**
* Builds an ending, defaulting a null {@code victory} flag to false.
*
* @param dto data read from {@code endings.yaml}; must not be null
* @return the freshly built ending
* @throws WorldLoadException if a condition sets none of its fields
*/
public static Ending fromDto(EndingDto dto) {
return new Ending(
dto.id(),

View File

@@ -13,6 +13,7 @@ import thb.jeanluc.adventure.model.item.SwitchableItem;
*/
public final class ItemFactory {
/** Utility class; not instantiable. */
private ItemFactory() {
}
@@ -31,6 +32,7 @@ public final class ItemFactory {
.name(dto.name())
.description(dto.description())
.light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build();
case "readable" -> ReadableItem.builder()
.id(dto.id())
@@ -38,6 +40,7 @@ public final class ItemFactory {
.description(dto.description())
.readText(dto.readText())
.light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build();
case "switchable" -> SwitchableItem.builder()
.id(dto.id())
@@ -48,6 +51,7 @@ public final class ItemFactory {
.offText(dto.offText())
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
.light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build();
default -> throw new WorldLoadException(
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");

View File

@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Npc;
*/
public final class NpcFactory {
/** Utility class; not instantiable. */
private NpcFactory() {
}

View File

@@ -13,9 +13,17 @@ import java.util.List;
/** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */
public final class QuestFactory {
/** Utility class; not instantiable. */
private QuestFactory() {
}
/**
* Builds a quest including all of its stages.
*
* @param dto data read from {@code quests.yaml}; must not be null
* @return the freshly built quest
* @throws WorldLoadException if a condition or effect sets none of its fields
*/
public static Quest fromDto(QuestDto dto) {
List<QuestStage> stages = new ArrayList<>();
if (dto.stages() != null) {

View File

@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Room;
*/
public final class RoomFactory {
/** Utility class; not instantiable. */
private RoomFactory() {
}

View File

@@ -22,18 +22,33 @@ public class TutorialLoader {
/** Default classpath base directory. */
public static final String DEFAULT_BASE = "/world";
/** Single Jackson mapper, configured once for YAML input. */
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
/** Classpath base directory (typically {@link #DEFAULT_BASE} or a test override). */
private final String basePath;
/** Convenience constructor using {@link #DEFAULT_BASE}. */
public TutorialLoader() {
this(DEFAULT_BASE);
}
/**
* Creates a loader reading from the given classpath base directory.
*
* @param basePath classpath base directory holding {@code tutorial.yaml}
*/
public TutorialLoader(String basePath) {
this.basePath = basePath;
}
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */
/**
* Reads the tutorial; absent file → {@link Tutorial#none()}.
*
* @return the parsed tutorial, or {@link Tutorial#none()} if the file is
* missing or empty
* @throws WorldLoadException if the file exists but cannot be parsed
*/
public Tutorial load() {
String resource = basePath + "/tutorial.yaml";
try (InputStream in = getClass().getResourceAsStream(resource)) {

View File

@@ -122,7 +122,7 @@ public class WorldLoader {
resolver.resolveRooms(roomDtos);
resolver.resolveNpcs(npcDtos);
new WorldValidator(items, npcs, rooms, gameDto).validate();
new WorldValidator(items, npcs, rooms, gameDto, quests, endings, combinations).validate();
Room start = rooms.get(gameDto.startRoom());
int gold = gameDto.startGold() == null ? 0 : gameDto.startGold();
@@ -135,6 +135,15 @@ public class WorldLoader {
return new LoadResult(world, player);
}
/**
* Reads a required YAML list resource.
*
* @param <T> element type
* @param resource classpath path of the YAML file
* @param elementType class of the list elements
* @return the parsed elements, empty if the document is blank
* @throws WorldLoadException if the resource is missing or unparsable
*/
private <T> List<T> readList(String resource, Class<T> elementType) {
try (InputStream in = openResource(resource)) {
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
@@ -145,6 +154,15 @@ public class WorldLoader {
}
}
/**
* Reads an optional YAML list resource.
*
* @param <T> element type
* @param resource classpath path of the YAML file
* @param elementType class of the list elements
* @return the parsed elements, empty if the resource is absent or blank
* @throws WorldLoadException if the resource exists but is unparsable
*/
private <T> List<T> readListOptional(String resource, Class<T> elementType) {
try (InputStream in = getClass().getResourceAsStream(resource)) {
if (in == null) {
@@ -158,6 +176,15 @@ public class WorldLoader {
}
}
/**
* Reads a required YAML resource holding a single object.
*
* @param <T> target type
* @param resource classpath path of the YAML file
* @param type class to deserialize into
* @return the parsed object
* @throws WorldLoadException if the resource is missing or unparsable
*/
private <T> T readSingle(String resource, Class<T> type) {
try (InputStream in = openResource(resource)) {
return yaml.readValue(in, type);
@@ -166,6 +193,13 @@ public class WorldLoader {
}
}
/**
* Opens a classpath resource.
*
* @param resource classpath path of the resource
* @return an open stream; the caller closes it
* @throws WorldLoadException if the resource is not on the classpath
*/
private InputStream openResource(String resource) {
InputStream in = getClass().getResourceAsStream(resource);
if (in == null) {
@@ -174,6 +208,13 @@ public class WorldLoader {
return in;
}
/**
* Asserts that no id occurs twice.
*
* @param kind label of the entity type, used in the error message
* @param ids the ids to check
* @throws WorldLoadException on the first duplicate id
*/
private void requireUniqueIds(String kind, List<String> ids) {
for (int i = 0; i < ids.size(); i++) {
for (int j = i + 1; j < ids.size(); j++) {

View File

@@ -1,19 +1,32 @@
package thb.jeanluc.adventure.loader;
import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.loader.dto.GameDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.Condition;
import thb.jeanluc.adventure.model.Effect;
import thb.jeanluc.adventure.model.Ending;
import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.QuestStage;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.Item;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Phase 4 of the loading pipeline. Walks the fully wired world and
* checks the invariants documented in {@code yaml-schemas.md}.
*
* <p>Every reference an id can point at is resolved here — item ids in
* conditions, effects and recipes, quest ids in {@code START_QUEST} — so a
* typo in the world files fails at load time with a precise message rather
* than silently soft-locking a quest or throwing an NPE mid-game. All
* problems found are reported together, not one per run.
*/
@RequiredArgsConstructor
public class WorldValidator {
/** Regex every entity id must match. */
@@ -31,44 +44,216 @@ public class WorldValidator {
/** Loaded game.yaml whose {@code startRoom} must exist. */
private final GameDto gameDto;
/** Quest registry; quest ids are the targets of {@code START_QUEST} effects. */
private final Map<String, Quest> quests;
/** Endings whose trigger conditions must reference known items. */
private final List<Ending> endings;
/** Combination recipes, keyed by item pair. */
private final Map<String, Combination> combinations;
/** Problems found so far; all are reported in one exception. */
private final List<String> problems = new ArrayList<>();
/**
* Runs every validation rule.
* Full constructor covering every entity kind in the world.
*
* @throws WorldLoadException on the first failure
* @param items item registry
* @param npcs NPC registry
* @param rooms room registry
* @param gameDto loaded {@code game.yaml}
* @param quests quest registry
* @param endings loaded endings
* @param combinations recipe registry, keyed by {@link Combination#key()}
*/
public WorldValidator(Map<String, Item> items,
Map<String, Npc> npcs,
Map<String, Room> rooms,
GameDto gameDto,
Map<String, Quest> quests,
List<Ending> endings,
Map<String, Combination> combinations) {
this.items = items;
this.npcs = npcs;
this.rooms = rooms;
this.gameDto = gameDto;
this.quests = quests;
this.endings = endings;
this.combinations = combinations;
}
/**
* Convenience constructor for a world without quests, endings or recipes.
*
* @param items item registry
* @param npcs NPC registry
* @param rooms room registry
* @param gameDto loaded {@code game.yaml}
*/
public WorldValidator(Map<String, Item> items,
Map<String, Npc> npcs,
Map<String, Room> rooms,
GameDto gameDto) {
this(items, npcs, rooms, gameDto, Map.of(), List.of(), Map.of());
}
/**
* Runs every validation rule and reports all problems at once.
*
* @throws WorldLoadException if any rule fails
*/
public void validate() {
validateIds();
validateStartRoom();
validateGreetings();
validateRequiredText();
validateQuests();
validateEndings();
validateCombinations();
if (!problems.isEmpty()) {
throw new WorldLoadException("Invalid world:\n - " + String.join("\n - ", problems));
}
}
private void validateIds() {
items.keySet().forEach(this::requireValidId);
npcs.keySet().forEach(this::requireValidId);
rooms.keySet().forEach(this::requireValidId);
quests.keySet().forEach(this::requireValidId);
}
private void requireValidId(String id) {
if (id == null || !ID_PATTERN.matcher(id).matches()) {
throw new WorldLoadException("Invalid id '" + id + "': must match " + ID_PATTERN);
problems.add("Invalid id '" + id + "': must match " + ID_PATTERN);
}
}
private void validateStartRoom() {
if (gameDto == null || gameDto.startRoom() == null) {
throw new WorldLoadException("game.yaml is missing 'startRoom'");
problems.add("game.yaml is missing 'startRoom'");
return;
}
if (!rooms.containsKey(gameDto.startRoom())) {
throw new WorldLoadException(
"game.yaml.startRoom '" + gameDto.startRoom() + "' does not match any room");
problems.add("game.yaml.startRoom '" + gameDto.startRoom()
+ "' does not match any room");
}
}
private void validateGreetings() {
for (Npc npc : npcs.values()) {
if (npc.getGreeting() == null || npc.getGreeting().isBlank()) {
throw new WorldLoadException("NPC '" + npc.getId() + "' has an empty greeting");
if (isBlank(npc.getGreeting())) {
problems.add("NPC '" + npc.getId() + "' has an empty greeting");
}
}
}
/**
* Names and descriptions are rendered unconditionally by the IO layer, so a
* missing one is an NPE waiting to happen the moment the player walks in.
*/
private void validateRequiredText() {
for (Room room : rooms.values()) {
if (isBlank(room.getName())) {
problems.add("Room '" + room.getId() + "' is missing 'name'");
}
if (isBlank(room.getDescription())) {
problems.add("Room '" + room.getId() + "' is missing 'description'");
}
}
for (Item item : items.values()) {
if (isBlank(item.getName())) {
problems.add("Item '" + item.getId() + "' is missing 'name'");
}
if (isBlank(item.getDescription())) {
problems.add("Item '" + item.getId() + "' is missing 'description'");
}
}
}
private void validateQuests() {
for (Quest quest : quests.values()) {
String where = "quest '" + quest.id() + "'";
if (isBlank(quest.title())) {
problems.add(where + " is missing 'title'");
}
if (quest.stages().isEmpty()) {
problems.add(where + " has no stages");
}
checkEffects(quest.onComplete(), where + " onComplete");
for (int i = 0; i < quest.stages().size(); i++) {
QuestStage stage = quest.stages().get(i);
String stageWhere = where + " stage " + i;
if (isBlank(stage.objective())) {
problems.add(stageWhere + " is missing 'objective'");
}
checkConditions(stage.completion(), stageWhere + " completion");
checkEffects(stage.onComplete(), stageWhere + " onComplete");
}
}
}
private void validateEndings() {
for (Ending ending : endings) {
String where = "ending '" + ending.id() + "'";
requireValidId(ending.id());
if (isBlank(ending.text())) {
problems.add(where + " is missing 'text'");
}
checkConditions(ending.when(), where + " when");
}
}
private void validateCombinations() {
for (Combination c : combinations.values()) {
String where = "combination '" + c.key() + "'";
requireKnownItem(c.a(), where + " operand 'a'");
requireKnownItem(c.b(), where + " operand 'b'");
if (c.produce() != null) {
requireKnownItem(c.produce(), where + " 'produce'");
}
for (String id : c.consume()) {
requireKnownItem(id, where + " 'consume'");
}
checkConditions(c.requires(), where + " requires");
checkEffects(c.effects(), where + " effects");
}
}
/** Resolves the item ids referenced by {@code HAS_ITEM} conditions. */
private void checkConditions(Collection<Condition> conditions, String where) {
for (Condition c : conditions) {
if (c.type() == Condition.Type.HAS_ITEM) {
requireKnownItem(c.arg(), where);
}
}
}
/** Resolves the item and quest ids referenced by effects. */
private void checkEffects(Collection<Effect> effects, String where) {
for (Effect e : effects) {
switch (e.type()) {
case GIVE_ITEM, REMOVE_ITEM -> requireKnownItem(e.arg(), where);
case START_QUEST -> {
if (!quests.containsKey(e.arg())) {
problems.add(where + " starts unknown quest '" + e.arg() + "'");
}
}
default -> {
// SET_FLAG / CLEAR_FLAG / SAY carry free-form text.
}
}
}
}
private void requireKnownItem(String id, String where) {
if (!items.containsKey(id)) {
problems.add(where + " references unknown item '" + id + "'");
}
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
}

View File

@@ -2,7 +2,18 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of an item-combination recipe. */
/**
* YAML representation of an item-combination recipe.
*
* @param a id of the first ingredient item
* @param b id of the second ingredient item
* @param requires conditions that must hold for the recipe to apply; nullable
* @param consume ids of the items removed from the inventory on success; nullable
* @param produce id of the item handed to the player on success; nullable
* @param effects effects applied on success; nullable
* @param response text printed on success
* @param failText text printed when the required conditions are not met; nullable
*/
public record CombinationDto(
String a,
String b,

View File

@@ -6,9 +6,21 @@ import thb.jeanluc.adventure.model.Condition;
import java.util.ArrayList;
import java.util.List;
/** YAML condition: exactly one of the fields is set. */
/**
* YAML condition: exactly one of the fields is set.
*
* @param flag name of a flag that must be set; nullable
* @param notFlag name of a flag that must not be set; nullable
* @param hasItem id of an item the player must carry; nullable
*/
public record ConditionDto(String flag, String notFlag, String hasItem) {
/**
* Converts this DTO into its domain condition.
*
* @return the matching {@link Condition}
* @throws WorldLoadException if none of the fields is set
*/
public Condition toModel() {
if (flag != null) {
return new Condition(Condition.Type.FLAG, flag);
@@ -22,6 +34,13 @@ public record ConditionDto(String flag, String notFlag, String hasItem) {
throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem");
}
/**
* Converts a list of DTOs into domain conditions.
*
* @param dtos the DTOs to convert; null is treated as empty
* @return the converted conditions, never null
* @throws WorldLoadException if any DTO sets none of its fields
*/
public static List<Condition> toModelList(List<ConditionDto> dtos) {
List<Condition> out = new ArrayList<>();
if (dtos != null) {

View File

@@ -2,6 +2,11 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a conditional room description. */
/**
* YAML representation of a conditional room description.
*
* @param when conditions that must all hold for this variant to be used
* @param text description text used instead of the room's default
*/
public record DescriptionStateDto(List<ConditionDto> when, String text) {
}

View File

@@ -2,6 +2,11 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a conditional NPC line. */
/**
* YAML representation of a conditional NPC line.
*
* @param when conditions that must all hold for this line to be spoken
* @param text text the NPC says
*/
public record DialogueLineDto(List<ConditionDto> when, String text) {
}

View File

@@ -6,15 +6,38 @@ import thb.jeanluc.adventure.model.Effect;
import java.util.ArrayList;
import java.util.List;
/** YAML effect: exactly one of the fields is set. */
/**
* YAML effect: exactly one of the fields is set.
*
* @param setFlag name of a flag to set; nullable
* @param clearFlag name of a flag to clear; nullable
* @param giveItem id of an item to add to the inventory; nullable
* @param removeItem id of an item to take from the inventory; nullable
* @param say text to print; nullable
* @param startQuest id of a quest to start; nullable
*/
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem,
String say, String startQuest) {
/** Backward-compatible constructor without startQuest. */
/**
* Backward-compatible constructor without startQuest.
*
* @param setFlag name of a flag to set; nullable
* @param clearFlag name of a flag to clear; nullable
* @param giveItem id of an item to add to the inventory; nullable
* @param removeItem id of an item to take from the inventory; nullable
* @param say text to print; nullable
*/
public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
this(setFlag, clearFlag, giveItem, removeItem, say, null);
}
/**
* Converts this DTO into its domain effect.
*
* @return the matching {@link Effect}
* @throws WorldLoadException if none of the fields is set
*/
public Effect toModel() {
if (setFlag != null) {
return new Effect(Effect.Type.SET_FLAG, setFlag);
@@ -38,6 +61,13 @@ public record EffectDto(String setFlag, String clearFlag, String giveItem, Strin
"Effect must set one of setFlag/clearFlag/giveItem/removeItem/say/startQuest");
}
/**
* Converts a list of DTOs into domain effects.
*
* @param dtos the DTOs to convert; null is treated as empty
* @return the converted effects, never null
* @throws WorldLoadException if any DTO sets none of its fields
*/
public static List<Effect> toModelList(List<EffectDto> dtos) {
List<Effect> out = new ArrayList<>();
if (dtos != null) {

View File

@@ -2,6 +2,14 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a game ending. */
/**
* YAML representation of a game ending.
*
* @param id unique slug
* @param title display title of the ending
* @param victory whether this ending counts as a win; defaults to false if null
* @param when conditions that must all hold for this ending to trigger
* @param text text printed when the ending triggers
*/
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
}

View File

@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a single condition-gated exit. */
/**
* YAML representation of a single condition-gated exit.
*
* @param direction name of the gated exit direction
* @param requires conditions that must all hold for the exit to be passable
* @param blocked text printed when the exit is used while still locked
*/
public record ExitLockDto(String direction, List<ConditionDto> requires, String blocked) {
}

View File

@@ -16,6 +16,7 @@ import java.util.List;
* @param offText message printed when a switchable transitions to off
* @param effects effects applied when a switchable transitions to on; nullable
* @param light whether this item is a light source; nullable
* @param fixed whether this item is scenery that cannot be taken; nullable
*/
public record ItemDto(
String type,
@@ -27,11 +28,24 @@ public record ItemDto(
String onText,
String offText,
List<EffectDto> effects,
Boolean light
Boolean light,
Boolean fixed
) {
/** Backward-compatible constructor without effects/light. */
/**
* Backward-compatible constructor without effects/light/fixed, which default to null.
*
* @param type discriminator: {@code plain | readable | switchable}
* @param id unique slug
* @param name display name
* @param description examine description
* @param readText text printed when a readable item is used
* @param initialState initial on/off state of a switchable item
* @param onText message printed when a switchable transitions to on
* @param offText message printed when a switchable transitions to off
*/
public ItemDto(String type, String id, String name, String description,
String readText, Boolean initialState, String onText, String offText) {
this(type, id, name, description, readText, initialState, onText, offText, null, null);
this(type, id, name, description, readText, initialState, onText, offText,
null, null, null);
}
}

View File

@@ -20,7 +20,15 @@ public record NpcDto(
List<ReactionDto> reactions,
List<DialogueLineDto> dialogue
) {
/** Backward-compatible constructor without dialogue. */
/**
* Backward-compatible constructor without dialogue, which defaults to null.
*
* @param id unique slug
* @param name display name
* @param description examine description
* @param greeting text the NPC says on {@code talk}
* @param reactions list of trigger/response definitions; may be null
*/
public NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) {
this(id, name, description, greeting, reactions, null);
}

View File

@@ -2,7 +2,15 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a quest. */
/**
* YAML representation of a quest.
*
* @param id unique slug
* @param title display title
* @param autoStart whether the quest starts with the game; defaults to false if null
* @param stages ordered stages the player works through
* @param onComplete effects applied once the last stage is done; nullable
*/
public record QuestDto(String id, String title, Boolean autoStart,
List<QuestStageDto> stages, List<EffectDto> onComplete) {
}

View File

@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a quest stage. */
/**
* YAML representation of a quest stage.
*
* @param objective objective text shown in the quest log
* @param completion conditions that must all hold for the stage to be done
* @param onComplete effects applied when the stage completes; nullable
*/
public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) {
}

View File

@@ -20,7 +20,14 @@ public record ReactionDto(
List<ConditionDto> requires,
List<EffectDto> effects
) {
/** Backward-compatible constructor without requires/effects. */
/**
* Backward-compatible constructor without requires/effects, which default to null.
*
* @param onReceive id of the item that triggers this reaction
* @param response text the NPC says after the exchange
* @param gives id of the item handed back; nullable
* @param consumes id of the item taken from the player; nullable
*/
public ReactionDto(String onReceive, String response, String gives, String consumes) {
this(onReceive, response, gives, consumes, null, null);
}

View File

@@ -29,7 +29,16 @@ public record RoomDto(
Boolean dark,
String music
) {
/** Backward-compatible constructor without the optional state fields. */
/**
* Backward-compatible constructor without the optional state fields, which default to null.
*
* @param id unique slug
* @param name display name
* @param description description shown by {@code look}
* @param exits direction-name to target-room-id map
* @param items ids of items initially in this room
* @param npcs ids of NPCs initially in this room
*/
public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null, null, null);

View File

@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of the tutorial document. */
/**
* YAML representation of the tutorial document.
*
* @param intro text shown before the first step
* @param steps ordered tutorial steps
* @param closingTips text shown after the last step
*/
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
}

View File

@@ -1,6 +1,14 @@
package thb.jeanluc.adventure.loader.dto;
/** YAML representation of a tutorial step. */
/**
* YAML representation of a tutorial step.
*
* @param instruction text telling the player what to type
* @param expect command verb the step accepts
* @param minArgs minimum number of arguments the command must carry; 0 if null
* @param confirm text printed once the step is passed
* @param hint text printed when the player types something else
*/
public record TutorialStepDto(
String instruction,
String expect,

View File

@@ -10,17 +10,29 @@ import java.util.List;
/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */
public final class MainMenu {
/** Utility class; not instantiable. */
private MainMenu() {
}
/** Shows the main menu and returns the chosen action. */
/**
* Shows the main menu and returns the chosen action.
*
* @param io frontend to render the menu on
* @return the action the player picked
*/
public static MenuAction show(GameIO io) {
int i = io.choose("HAUNTED MANOR",
List.of("New Game", "Load Game", "Settings", "Quit"));
return MenuAction.values()[i];
}
/** Prompts for a new save name; blank input yields {@code defaultName}. */
/**
* Prompts for a new save name; blank input yields {@code defaultName}.
*
* @param io frontend to prompt on
* @param defaultName name used when the player enters nothing
* @return the trimmed save name
*/
public static String promptName(GameIO io, String defaultName) {
io.write("Name your save (Enter for \"" + defaultName + "\"):");
String line = io.readLine();
@@ -31,6 +43,10 @@ public final class MainMenu {
* Lets the player pick a save slot to load. Returns the chosen
* {@link SaveSlotInfo}, or {@code null} if there are none or the player
* picks "Back".
*
* @param io frontend to render the slot list on
* @param saves source of the available slots
* @return the selected slot, or {@code null} if nothing was selected
*/
public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) {
List<SaveSlotInfo> slots = saves.list();

View File

@@ -2,5 +2,16 @@ package thb.jeanluc.adventure.menu;
/** Top-level main-menu choices, in display order. */
public enum MenuAction {
NEW_GAME, LOAD, SETTINGS, QUIT
/** Start a new game in a freshly named save slot. */
NEW_GAME,
/** Continue from an existing save slot. */
LOAD,
/** Open the settings screen. */
SETTINGS,
/** Leave the application. */
QUIT
}

View File

@@ -10,10 +10,18 @@ import java.util.List;
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
public final class SettingsMenu {
/** Utility class; not instantiable. */
private SettingsMenu() {
}
/** Loops the settings screen until "Back", persisting + applying each change. */
/**
* Loops the settings screen until "Back", persisting + applying each change.
*
* @param io frontend to render the screen on
* @param current settings the screen starts from
* @param store destination the changed settings are written to
* @return the settings in effect when the player left the screen
*/
public static Settings show(GameIO io, Settings current, SettingsStore store) {
Settings s = current;
while (true) {
@@ -36,7 +44,12 @@ public final class SettingsMenu {
}
}
/** Applies settings to the IO: music level always; colour/glyph only on the console. */
/**
* Applies settings to the IO: music level always; colour/glyph only on the console.
*
* @param s settings to apply
* @param io frontend the settings are applied to
*/
public static void apply(Settings s, GameIO io) {
io.setMusicLevel(s.musicLevel());
if (io instanceof ConsoleIO c) {
@@ -45,6 +58,7 @@ public final class SettingsMenu {
}
}
/** Next colour mode in the cycle AUTO {@literal ->} ON {@literal ->} OFF. */
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
return switch (m) {
case AUTO -> ConsoleIO.ColorMode.ON;
@@ -53,6 +67,7 @@ public final class SettingsMenu {
};
}
/** Next glyph mode in the cycle UNICODE {@literal ->} ASCII {@literal ->} GLYPH. */
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
return switch (m) {
case UNICODE -> ConsoleIO.GlyphMode.ASCII;

View File

@@ -6,6 +6,15 @@ import java.util.List;
* A data-driven item-combination recipe. Identified by an unordered pair of
* item ids ({@code a}, {@code b}). When applied it may require conditions,
* consume items, produce an item, apply effects, and print a response.
*
* @param a id of the first item of the pair
* @param b id of the second item of the pair
* @param requires conditions that must hold for the recipe to apply; empty = always
* @param consume ids of the items removed from the player's inventory on success
* @param produce id of the item handed to the player on success; nullable
* @param effects effects applied on success
* @param response text written on success
* @param failText text written when {@code requires} does not hold; nullable
*/
public record Combination(
String a,
@@ -17,18 +26,29 @@ public record Combination(
String response,
String failText
) {
/** Normalises the list components to immutable, non-null lists. */
public Combination {
requires = requires == null ? List.of() : List.copyOf(requires);
consume = consume == null ? List.of() : List.copyOf(consume);
effects = effects == null ? List.of() : List.copyOf(effects);
}
/** Order-independent key for an item pair (sorted, '|'-joined). */
/**
* Order-independent key for an item pair (sorted, '|'-joined).
*
* @param x id of one item
* @param y id of the other item
* @return the canonical key identifying the pair regardless of argument order
*/
public static String key(String x, String y) {
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
}
/** This recipe's canonical pair key. */
/**
* This recipe's canonical pair key.
*
* @return the key under which the recipe is registered in the world
*/
public String key() {
return key(a, b);
}

View File

@@ -1,6 +1,21 @@
package thb.jeanluc.adventure.model;
/** A single boolean test evaluated against world state and the player. */
/**
* A single boolean test evaluated against world state and the player.
*
* @param type the kind of test to perform
* @param arg the operand the test applies to: a flag name for {@link Type#FLAG}
* and {@link Type#NOT_FLAG}, an item id for {@link Type#HAS_ITEM}
*/
public record Condition(Type type, String arg) {
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
/** The kinds of test a {@link Condition} can perform. */
public enum Type {
/** Holds while the named flag is set in the world state. */
FLAG,
/** Holds while the named flag is not set in the world state. */
NOT_FLAG,
/** Holds while the player carries the named item. */
HAS_ITEM
}
}

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
import java.util.List;
/** An alternate room description shown while its conditions hold. */
/**
* An alternate room description shown while its conditions hold.
*
* @param when conditions that must all hold for this variant to be selected; empty = always
* @param text description text shown instead of the room's default description
*/
public record DescriptionState(List<Condition> when, String text) {
/** Normalises {@code when} to an immutable, non-null list. */
public DescriptionState {
when = when == null ? List.of() : List.copyOf(when);
}

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
import java.util.List;
/** A conditional line an NPC says on {@code talk}, first match wins. */
/**
* A conditional line an NPC says on {@code talk}, first match wins.
*
* @param when conditions that must all hold for this line to be selected; empty = always
* @param text text the NPC says when this line is selected
*/
public record DialogueLine(List<Condition> when, String text) {
/** Normalises {@code when} to an immutable, non-null list. */
public DialogueLine {
when = when == null ? List.of() : List.copyOf(when);
}

View File

@@ -1,5 +1,7 @@
package thb.jeanluc.adventure.model;
import java.util.Map;
/**
* Compass directions used for navigating between rooms.
* Each direction has an opposite, which is used when describing
@@ -7,9 +9,13 @@ package thb.jeanluc.adventure.model;
* entering the next room from the SOUTH).
*/
public enum Direction {
/** Towards the top of the map; opposite of {@link #SOUTH}. */
NORTH,
/** Towards the bottom of the map; opposite of {@link #NORTH}. */
SOUTH,
/** Towards the right of the map; opposite of {@link #WEST}. */
EAST,
/** Towards the left of the map; opposite of {@link #EAST}. */
WEST;
/**
@@ -38,13 +44,40 @@ public enum Direction {
if (s == null) {
throw new IllegalArgumentException("Direction must not be null");
}
String key = s.trim().toLowerCase();
Direction alias = ALIASES.get(key);
if (alias != null) {
return alias;
}
try {
return Direction.valueOf(s.trim().toUpperCase());
return Direction.valueOf(key.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Unknown direction: '" + s + "'");
}
}
/**
* Alternative spellings accepted from player input: single-letter
* shorthands and the German direction names used in the assignment's
* examples ("Gehe nach Norden"). YAML exits still use the canonical
* English names.
*/
private static final Map<String, Direction> ALIASES = Map.ofEntries(
Map.entry("n", NORTH),
Map.entry("s", SOUTH),
Map.entry("e", EAST),
Map.entry("w", WEST),
Map.entry("norden", NORTH),
Map.entry("sueden", SOUTH),
Map.entry("süden", SOUTH),
Map.entry("osten", EAST),
Map.entry("westen", WEST),
Map.entry("nord", NORTH),
Map.entry("sued", SOUTH),
Map.entry("süd", SOUTH),
Map.entry("ost", EAST),
Map.entry("west", WEST));
/**
* Returns the human-readable lowercase label, suitable for display
* to the player (e.g. "north"). Intentionally not a toString override

View File

@@ -1,6 +1,29 @@
package thb.jeanluc.adventure.model;
/** A single state mutation or message. */
/**
* A single state mutation or message.
*
* @param type the kind of mutation to apply
* @param arg the operand the mutation applies to: a flag name for
* {@link Type#SET_FLAG} and {@link Type#CLEAR_FLAG}, an item id for
* {@link Type#GIVE_ITEM} and {@link Type#REMOVE_ITEM}, the literal text
* for {@link Type#SAY}, a quest id for {@link Type#START_QUEST}
*/
public record Effect(Type type, String arg) {
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY, START_QUEST }
/** The kinds of mutation an {@link Effect} can apply. */
public enum Type {
/** Sets the named flag in the world state. */
SET_FLAG,
/** Clears the named flag from the world state. */
CLEAR_FLAG,
/** Adds the named item to the player's inventory. */
GIVE_ITEM,
/** Takes the named item out of the player's inventory. */
REMOVE_ITEM,
/** Writes the argument to the player's output. */
SAY,
/** Activates the named quest. */
START_QUEST
}
}

View File

@@ -2,8 +2,17 @@ package thb.jeanluc.adventure.model;
import java.util.List;
/** A game ending: shown (and ends the game) when its conditions hold. */
/**
* A game ending: shown (and ends the game) when its conditions hold.
*
* @param id unique identifier of this ending
* @param title headline shown above the ending text
* @param victory whether reaching this ending counts as winning
* @param when conditions that must all hold for this ending to trigger
* @param text closing text shown to the player
*/
public record Ending(String id, String title, boolean victory, List<Condition> when, String text) {
/** Normalises {@code when} to an immutable, non-null list. */
public Ending {
when = when == null ? List.of() : List.copyOf(when);
}

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
import java.util.List;
/** A condition-gated exit with a message shown when it is blocked. */
/**
* A condition-gated exit with a message shown when it is blocked.
*
* @param requires conditions that must all hold for the exit to be passable
* @param blocked text written when the player tries the exit while it is blocked
*/
public record ExitLock(List<Condition> requires, String blocked) {
/** Normalises {@code requires} to an immutable, non-null list. */
public ExitLock {
requires = requires == null ? List.of() : List.copyOf(requires);
}

View File

@@ -2,9 +2,19 @@ package thb.jeanluc.adventure.model;
import java.util.List;
/** A multi-stage quest. Stage completion is condition-driven; rewards are effects. */
/**
* A multi-stage quest. Stage completion is condition-driven; rewards are effects.
*
* @param id unique identifier of this quest, referenced by
* {@link Effect.Type#START_QUEST}
* @param title display name shown in the quest log
* @param autoStart whether the quest is active from the start of the game
* @param stages the stages in the order they must be completed
* @param onComplete effects applied once the final stage is completed
*/
public record Quest(String id, String title, boolean autoStart,
List<QuestStage> stages, List<Effect> onComplete) {
/** Normalises the list components to immutable, non-null lists. */
public Quest {
stages = stages == null ? List.of() : List.copyOf(stages);
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);

View File

@@ -2,8 +2,15 @@ package thb.jeanluc.adventure.model;
import java.util.List;
/** One stage of a {@link Quest}: an objective, a completion condition, optional rewards. */
/**
* One stage of a {@link Quest}: an objective, a completion condition, optional rewards.
*
* @param objective task description shown in the quest log while this stage is active
* @param completion conditions that must all hold for this stage to count as done
* @param onComplete effects applied when this stage is completed
*/
public record QuestStage(String objective, List<Condition> completion, List<Effect> onComplete) {
/** Normalises the list components to immutable, non-null lists. */
public QuestStage {
completion = completion == null ? List.of() : List.copyOf(completion);
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);

View File

@@ -2,18 +2,34 @@ package thb.jeanluc.adventure.model;
import java.util.List;
/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */
/**
* Onboarding walkthrough: an intro, ordered steps, and closing tips.
*
* @param intro text shown before the first step; nullable
* @param steps the steps in the order the player must complete them
* @param closingTips text shown after the last step; nullable
*/
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
/** Normalises {@code steps} to an immutable, non-null list. */
public Tutorial {
steps = steps == null ? List.of() : List.copyOf(steps);
}
/**
* Whether this tutorial has anything to walk the player through.
*
* @return {@code true} if there are no steps
*/
public boolean isEmpty() {
return steps.isEmpty();
}
/** An absent tutorial (no steps). */
/**
* An absent tutorial (no steps).
*
* @return a tutorial that {@link #isEmpty()} reports as empty
*/
public static Tutorial none() {
return new Tutorial(null, List.of(), null);
}

Some files were not shown because too many files have changed in this diff Show More