Compare commits
13 Commits
f8f7eb90f0
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d7f91c2e47 | |||
| c205d1f49b | |||
| 526e3d4ee7 | |||
| 1b39eac90e | |||
| 4cf1b42254 | |||
| 73fc6eab68 | |||
| 8f6ee80f30 | |||
| cc2221121d | |||
| 285e68055e | |||
| 65a8935e32 | |||
| e5c87015fa | |||
| 8e63f21c7d | |||
| 41c79c3b96 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,4 +8,7 @@ out/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Nextcloud sync
|
||||||
|
.sync-exclude.lst
|
||||||
|
|||||||
9
Semesterprojekt/.gitignore
vendored
9
Semesterprojekt/.gitignore
vendored
@@ -38,12 +38,6 @@ build/
|
|||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
### Brainstorming visual companion ###
|
|
||||||
.superpowers/
|
|
||||||
|
|
||||||
# Planning specs & implementation plans (kept locally, not tracked)
|
|
||||||
docs/superpowers/
|
|
||||||
|
|
||||||
# Local save games and settings
|
# Local save games and settings
|
||||||
saves/
|
saves/
|
||||||
|
|
||||||
@@ -51,3 +45,6 @@ saves/
|
|||||||
# src/main/resources/music/ is tracked and bundled. Anchored so it
|
# src/main/resources/music/ is tracked and bundled. Anchored so it
|
||||||
# ignores the top-level source folder but not the resources path.
|
# ignores the top-level source folder but not the resources path.
|
||||||
/music/
|
/music/
|
||||||
|
|
||||||
|
# Built jars (kept on disk for the submission ZIP, not tracked)
|
||||||
|
release/*.jar
|
||||||
|
|||||||
@@ -1,113 +1,171 @@
|
|||||||
# Haunted Manor – Textadventure
|
# Haunted Manor – Textadventure
|
||||||
|
|
||||||
Semesterprojekt im Modul Programmierung 2 (Algorithmen und Datenstrukturen,
|
Semesterprojekt im Modul Algorithmen und Datenstrukturen (2. Semester).
|
||||||
2. Semester). Ein textbasiertes Adventure in Java: Der Spieler navigiert durch
|
Ein textbasiertes Adventure in Java: Der Spieler navigiert durch die Räume eines
|
||||||
Räume eines Spukhauses, sammelt Gegenstände, interagiert mit NPCs und löst so
|
Spukhauses, sammelt Gegenstände, spricht mit NPCs, löst Rätsel und findet einen
|
||||||
das Spiel.
|
Weg hinaus.
|
||||||
|
|
||||||
Die Spielwelt ist vollständig datengetrieben – Räume, Gegenstände und NPCs
|
Die Spielwelt ist vollständig datengetrieben – Räume, Gegenstände, NPCs, Quests
|
||||||
werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
|
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+
|
Es gibt **zwei Wege**, das Spiel zu starten. Wenn Sie nur spielen wollen, nehmen
|
||||||
- Maven 3.9+ **oder** der mitgelieferte Maven Wrapper (`./mvnw`) – damit ist
|
Sie Weg A – dafür wird kein Maven benötigt.
|
||||||
keine separate Maven-Installation nötig; der Wrapper lädt die passende
|
|
||||||
Maven-Version beim ersten Aufruf selbst herunter.
|
|
||||||
|
|
||||||
## Schnellstart ohne Maven
|
### Voraussetzung
|
||||||
|
|
||||||
Im Ordner [`release/`](release/) liegt eine fertig gebaute, eigenständige JAR
|
**Java 21 oder neuer** (LTS). Gebaut wird gegen `maven.compiler.release=21`;
|
||||||
(`HauntedManor.jar`) mit allen Abhängigkeiten. Sie braucht nur Java 25:
|
das neueste genutzte Sprachfeature ist `List.getFirst()` (SequencedCollection,
|
||||||
|
Java 21). Neuere JDKs (22–25) funktionieren ebenfalls.
|
||||||
|
|
||||||
|
Prüfen mit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -jar release/HauntedManor.jar # Konsolen-Version
|
java -version
|
||||||
java -cp release/HauntedManor.jar thb.jeanluc.adventure.AppGui # Swing-GUI
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bauen und Testen
|
### Weg A – fertige JARs starten (ohne Maven)
|
||||||
|
|
||||||
Mit dem Wrapper (empfohlen, keine Maven-Installation nötig) – unter Windows
|
Im Ordner [`release/`](release/) liegen zwei eigenständige JARs, die alle
|
||||||
`mvnw.cmd` statt `./mvnw`:
|
Abhängigkeiten bereits enthalten. Es wird **nur Java** gebraucht, kein Maven,
|
||||||
|
keine Internetverbindung:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./mvnw clean test # alle Tests ausführen (233 Tests)
|
java -jar release/HauntedManor-Console.jar # Konsolen-Version (~5 MB)
|
||||||
./mvnw clean package # baut die eigenständige JAR nach release/HauntedManor.jar
|
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
|
```bash
|
||||||
./mvnw exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
|
./mvnw clean test # alle 259 Tests ausführen
|
||||||
./mvnw exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
./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.
|
`./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 |
|
| **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. |
|
||||||
| `look` | `l` | Aktuellen Raum beschreiben |
|
| **`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. |
|
||||||
| `map` | `m` | Karte der erkundeten Räume anzeigen |
|
| **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`. |
|
||||||
| `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen |
|
| **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. |
|
||||||
| `inventory` | `inv`, `i` | Inventar anzeigen |
|
| **Wahl der Datenstrukturen** | [`docs/data-structures.md`](docs/data-structures.md) | Begründung je Anwendungsfall, plus Tabelle „Bewusst NICHT gewählt". |
|
||||||
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
|
| **NPCs** (Bonus) | [`model/Npc.java`](src/main/java/thb/jeanluc/adventure/model/Npc.java) | 3 NPCs, die auf den Spielerzustand **reagieren** (`talk`, `give`). |
|
||||||
| `drop <item>` | `put` | Gegenstand ablegen |
|
| **Swing-GUI** (Bonus) | [`io/SwingIO.java`](src/main/java/thb/jeanluc/adventure/io/SwingIO.java) | `JTextField` für die Eingabe (siehe Hinweis unten). |
|
||||||
| `use <item>` | | Gegenstand benutzen / schalten |
|
| **Javadoc** | gesamtes `src/main/java` | An Klassen, Methoden und Instanzvariablen. |
|
||||||
| `read <item>` | | Lesbaren Gegenstand lesen |
|
|
||||||
| `examine <item>` | `x`, `inspect` | Gegenstand genauer ansehen |
|
**Hinweis zur GUI:** Die Aufgabe nennt `JTextField` **und** `JTextArea`. Die
|
||||||
| `talk <npc>` | `speak` | Mit einem NPC sprechen |
|
Eingabe ist wie gefordert ein `JTextField`. Für die **Ausgabe** wird bewusst ein
|
||||||
| `give <item> <npc>` | | NPC einen Gegenstand geben |
|
`JTextPane` statt einer `JTextArea` verwendet: `JTextArea` kann ausschließlich
|
||||||
| `help` | `?` | Befehlsübersicht anzeigen |
|
unformatierten Text darstellen, das Spiel rendert aber farbig gestylte Ausgabe
|
||||||
| `quit` | `exit` | Spiel beenden |
|
(`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
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
Semesterprojekt/
|
Semesterprojekt/
|
||||||
├── src/main/java/thb/jeanluc/adventure/
|
├── src/main/java/thb/jeanluc/adventure/
|
||||||
│ ├── App.java - Einstiegspunkt Konsole
|
│ ├── App.java – Einstiegspunkt Konsole
|
||||||
│ ├── AppGui.java - Einstiegspunkt Swing-GUI
|
│ ├── AppGui.java – Einstiegspunkt Swing-GUI
|
||||||
│ ├── command/ - Befehlsparser, Registry + Command-Implementierungen
|
│ ├── model/ – Domänenmodell: Room, Player, Npc, item/ …
|
||||||
│ ├── game/ - Spielschleife (Game) und Kontext (GameContext)
|
│ ├── command/ – Parser, Registry + impl/ (15 Kommandos)
|
||||||
│ ├── io/ - GameIO-Abstraktion: ConsoleIO, SwingIO
|
│ ├── game/ – Spielschleife, Engines, Light, Pathfinder, MapLayout
|
||||||
│ ├── loader/ - YAML-Laden: DTOs, Factories, Resolver, Validator
|
│ ├── io/ – GameIO-Abstraktion: ConsoleIO, SwingIO + text/
|
||||||
│ └── model/ - Domänenmodell: Room, World, Player, Npc, Item-Hierarchie
|
│ ├── loader/ – YAML-Laden: dto/, Factories, Resolver, Validator
|
||||||
|
│ ├── save/ – Speicherstände und Einstellungen (JSON)
|
||||||
|
│ └── menu/ – Hauptmenü und Einstellungen
|
||||||
├── src/main/resources/world/
|
├── src/main/resources/world/
|
||||||
│ ├── game.yaml - Metadaten (Titel, Startraum, Begrüßung)
|
│ ├── game.yaml – Metadaten (Titel, Startraum, Begrüßung)
|
||||||
│ ├── rooms.yaml - Räume mit Ausgängen, Items, NPCs
|
│ ├── rooms.yaml – Räume mit Ausgängen, Gegenständen, NPCs
|
||||||
│ ├── items.yaml - Gegenstände
|
│ ├── items.yaml – Gegenstände
|
||||||
│ └── npcs.yaml - NPCs und ihre Reaktionen
|
│ ├── npcs.yaml – NPCs und ihre Reaktionen
|
||||||
└── docs/ - Design- und Architekturdokumentation
|
│ ├── 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
|
## Technologie-Stack
|
||||||
|
|
||||||
- **Java 25**
|
- **Java 21 (LTS)**
|
||||||
- **Jackson** (YAML) für das Laden der Spielwelt
|
- **Jackson** (YAML) für das Laden der Spielwelt
|
||||||
- **Lombok** zur Reduktion von Boilerplate
|
- **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
|
- **SLF4J + Logback** für Logging
|
||||||
- **Swing** für die GUI (Bonus)
|
- **Swing** für die GUI (Bonus)
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
Ausführliche Design- und Architekturdokumentation liegt unter
|
Ausführliche Design- und Architekturdokumentation liegt unter
|
||||||
[`docs/`](docs/README.md): Package-Struktur, Datenstrukturen, Item-Modell,
|
[`docs/`](docs/README.md): Package-Struktur, Wahl der Datenstrukturen,
|
||||||
YAML-Schemas, Lade-Ablauf, Befehle und NPC-Modell.
|
Item-Modell, Befehle, NPC-Modell, YAML-Schemas und Lade-Ablauf.
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
# Semesterprojekt – Textadventure
|
# 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
|
## Inhalt
|
||||||
|
|
||||||
| Datei | Inhalt |
|
| Datei | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [architecture.md](architecture.md) | Package-Struktur, Schichten, DTO-vs-Domain-Trennung |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [commands.md](commands.md) | Befehlsparser, Command-Pattern, Befehlsliste |
|
||||||
| [npcs.md](npcs.md) | NPC-Modell, Talk- und Give-Interaktion |
|
| [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 vs. Optional (laut Aufgabenstellung)
|
||||||
|
|
||||||
- **Pflicht:** ≥4 Räume mit Navigation, ≥3 Gegenstände mit Inventar
|
- **Pflicht:** ≥ 4 Räume mit Navigation, ≥ 3 Gegenstände mit Inventar —
|
||||||
- **Optional/Bonus:** NPCs, Swing-GUI
|
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
|
## Technologie-Stack
|
||||||
|
|
||||||
- Java 25
|
- Java 21 (LTS)
|
||||||
- Jackson (YAML) für Daten-Loading
|
- Jackson (YAML) für das Laden der Spielwelt
|
||||||
- Lombok für Boilerplate-Reduktion
|
- Lombok zur Reduktion von Boilerplate
|
||||||
- JUnit 5 + AssertJ + Mockito für Tests
|
- JUnit 5 + AssertJ + Mockito für Tests
|
||||||
- Logback + SLF4J für Logging
|
- SLF4J + Logback für Logging
|
||||||
- Swing für GUI (Bonus)
|
- Swing für die GUI (Bonus)
|
||||||
|
|||||||
@@ -33,19 +33,22 @@ flowchart LR
|
|||||||
model --> item
|
model --> item
|
||||||
item --> item_files["Item (abstract)<br/>ReadableItem<br/>SwitchableItem<br/>PlainItem"]
|
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
|
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 --> cmd_core["Command (Interface)<br/>CommandRegistry<br/>CommandParser"]
|
||||||
command --> impl
|
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
|
## DTO vs. Domain-Trennung
|
||||||
|
|
||||||
**Kernprinzip:** YAML wird in Records deserialisiert (DTOs), erst danach werden String-IDs zu Objekt-Referenzen aufgelöst.
|
**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)
|
## Game-Loop (vereinfacht)
|
||||||
|
|
||||||
```java
|
```java
|
||||||
while (!game.isOver()) {
|
while (running) {
|
||||||
String input = io.read();
|
String input = io.readLine();
|
||||||
|
if (input == null) break; // EOF
|
||||||
ParsedCommand parsed = parser.parse(input);
|
ParsedCommand parsed = parser.parse(input);
|
||||||
Command cmd = registry.get(parsed.verb());
|
registry.find(parsed.verb()).ifPresentOrElse(
|
||||||
if (cmd == null) {
|
cmd -> cmd.execute(ctx, parsed.args()),
|
||||||
io.write("Unbekannter Befehl.");
|
() -> io.write("I don't understand '" + parsed.verb() + "'."));
|
||||||
} else {
|
publishHud(); // Engines ticken, HUD/Map/Quests updaten
|
||||||
cmd.execute(context, parsed.args());
|
maybeEnd(); // Ending-Bedingungen prüfen
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -85,9 +88,25 @@ Konsole und GUI teilen sich `GameIO`:
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
public interface GameIO {
|
public interface GameIO {
|
||||||
String read(); // blockierende Leseoperation
|
String readLine(); // blockierende Leseoperation
|
||||||
void write(String text);
|
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.
|
||||||
|
|||||||
@@ -32,24 +32,28 @@ Geltungsbereich: id ist eindeutig **innerhalb seiner Entitätsart**. Ein Item un
|
|||||||
|
|
||||||
```
|
```
|
||||||
thb.jeanluc.adventure
|
thb.jeanluc.adventure
|
||||||
|
├── App, AppGui <- Entry Points (Konsole / Swing)
|
||||||
├── model
|
├── model
|
||||||
│ ├── Direction
|
│ ├── Direction, Room, Player, Npc
|
||||||
│ ├── Room
|
│ ├── Quest, QuestStage, Ending, Combination, Condition, Effect, …
|
||||||
│ ├── Player
|
|
||||||
│ ├── Npc
|
|
||||||
│ └── item <- eigener Subpackage wegen Hierarchie
|
│ └── item <- eigener Subpackage wegen Hierarchie
|
||||||
│ ├── Item (abstract)
|
│ ├── Item (abstract)
|
||||||
│ ├── ReadableItem
|
│ ├── ReadableItem
|
||||||
│ ├── SwitchableItem
|
│ ├── SwitchableItem
|
||||||
│ └── PlainItem
|
│ └── PlainItem
|
||||||
├── io
|
├── io <- ConsoleIO, SwingIO, GameIO, Music*, MapPanel, QuestPanel
|
||||||
├── command
|
│ └── text <- StyledText, Style, Span, Hud, *View (15 Klassen)
|
||||||
├── game
|
├── command <- Command, CommandParser, CommandRegistry, ParsedCommand
|
||||||
└── loader
|
│ └── impl <- die 15 konkreten Kommandos
|
||||||
└── dto
|
├── 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
|
## Naming
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,16 @@ Bewusste Wahl jeder Collection — der Dozent bewertet das laut Aufgabenstellung
|
|||||||
| Spieler-Inventar | `LinkedHashMap<String, Item>` | O(1) |
|
| Spieler-Inventar | `LinkedHashMap<String, Item>` | O(1) |
|
||||||
| Befehlsregistry | `HashMap<String, Command>` | O(1) |
|
| Befehlsregistry | `HashMap<String, Command>` | O(1) |
|
||||||
| NPC-Reaktionen | `HashMap<String, NpcReaction>` | 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
|
## Begründungen im Detail
|
||||||
|
|
||||||
### `EnumMap<Direction, Room>` für Raum-Ausgänge
|
### `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`
|
- `EnumMap` ist array-backed, kein Hashing nötig → schneller und kompakter als `HashMap`
|
||||||
- Iteration in Enum-Deklarationsreihenfolge (stabil)
|
- 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
|
- Entspricht dem expliziten Tipp aus der Aufgabenstellung
|
||||||
- Vermeidet wachsendes `switch`-Statement
|
- 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
|
- `Pathfinder` (Auto-Travel `go to <raum>`) und `MapLayout` (Karten-Layout) sind beides
|
||||||
- `ArrayDeque` ist `LinkedList` praktisch immer überlegen (bessere Cache-Lokalität, weniger Overhead)
|
Breitensuchen → brauchen eine **FIFO-Queue**
|
||||||
- Beidseitige O(1)-Operationen
|
- `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
|
## 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 |
|
| `ArrayList<Item>` für Inventar | O(n)-Lookup, Duplikat-Handling nötig |
|
||||||
| `HashMap<String, Item>` für Inventar | Anzeige-Reihenfolge instabil |
|
| `HashMap<String, Item>` für Inventar | Anzeige-Reihenfolge instabil |
|
||||||
| `TreeMap` irgendwo | Keine sortierte Iteration nötig, O(log n) ohne Nutzen |
|
| `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 |
|
| `Vector` / `Hashtable` | Legacy, synchronisiert (nicht gebraucht), langsamer |
|
||||||
| `Map<String, String>` für Exits in Domain | Direction sollte Enum sein, nicht String |
|
| `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.
|
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).
|
||||||
|
|||||||
@@ -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 1–7 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.*
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# Implementierungsstand & Reihenfolge
|
|
||||||
|
|
||||||
Stand: alle Phasen 1–7 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.
|
|
||||||
@@ -9,8 +9,10 @@
|
|||||||
<version>1.0-SNAPSHOT</version>
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>25</maven.compiler.source>
|
<!-- Java 21 (LTS): the newest feature actually used is List.getFirst()
|
||||||
<maven.compiler.target>25</maven.compiler.target>
|
(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>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
|
||||||
<jackson.version>2.18.2</jackson.version>
|
<jackson.version>2.18.2</jackson.version>
|
||||||
@@ -113,25 +115,65 @@
|
|||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-shade-plugin</artifactId>
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
<version>3.6.0</version>
|
<version>3.6.0</version>
|
||||||
|
<!-- Two self-contained jars in release/: one per entry point.
|
||||||
|
createDependencyReducedPom is off globally so no stray pom is written. -->
|
||||||
|
<configuration>
|
||||||
|
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||||
|
</configuration>
|
||||||
<executions>
|
<executions>
|
||||||
|
<!-- Console version -> release/HauntedManor-Console.jar -->
|
||||||
<execution>
|
<execution>
|
||||||
|
<id>shade-console</id>
|
||||||
<phase>package</phase>
|
<phase>package</phase>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>shade</goal>
|
<goal>shade</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<!-- Self-contained, runnable JAR with all dependencies bundled. -->
|
<outputFile>${project.basedir}/release/HauntedManor-Console.jar</outputFile>
|
||||||
<outputFile>${project.basedir}/release/HauntedManor.jar</outputFile>
|
<!-- Console has no audio (GameIO.setMusic is a no-op there), so leave
|
||||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
out the OGG decode libraries and the ~11 MB of music tracks. -->
|
||||||
|
<artifactSet>
|
||||||
|
<excludes>
|
||||||
|
<exclude>com.googlecode.soundlibs:vorbisspi</exclude>
|
||||||
|
<exclude>com.googlecode.soundlibs:jorbis</exclude>
|
||||||
|
</excludes>
|
||||||
|
</artifactSet>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>thb.jeanluc:Semesterprojekt</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>music/**</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
<transformers>
|
<transformers>
|
||||||
<!-- Default entry point: console version. GUI via -cp ... AppGui. -->
|
|
||||||
<transformer
|
<transformer
|
||||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
<manifestEntries>
|
<manifestEntries>
|
||||||
<Main-Class>thb.jeanluc.adventure.App</Main-Class>
|
<Main-Class>thb.jeanluc.adventure.App</Main-Class>
|
||||||
</manifestEntries>
|
</manifestEntries>
|
||||||
</transformer>
|
</transformer>
|
||||||
<!-- Merge META-INF/services so the OGG Vorbis SPI keeps working. -->
|
<transformer
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<!-- Swing GUI version -> release/HauntedManor-GUI.jar -->
|
||||||
|
<execution>
|
||||||
|
<id>shade-gui</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outputFile>${project.basedir}/release/HauntedManor-GUI.jar</outputFile>
|
||||||
|
<transformers>
|
||||||
|
<transformer
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<manifestEntries>
|
||||||
|
<Main-Class>thb.jeanluc.adventure.AppGui</Main-Class>
|
||||||
|
</manifestEntries>
|
||||||
|
</transformer>
|
||||||
<transformer
|
<transformer
|
||||||
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||||
</transformers>
|
</transformers>
|
||||||
|
|||||||
Binary file not shown.
@@ -47,10 +47,15 @@ import java.util.List;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public final class App {
|
public final class App {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private App() {
|
private App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard JVM entry point. @param args ignored */
|
/**
|
||||||
|
* Standard JVM entry point.
|
||||||
|
*
|
||||||
|
* @param args ignored
|
||||||
|
*/
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
GameIO io = new ConsoleIO();
|
GameIO io = new ConsoleIO();
|
||||||
try {
|
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) {
|
public static void run(GameIO io) {
|
||||||
run(io, new SaveService(), new SettingsStore());
|
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) {
|
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
|
||||||
Settings settings = settingsStore.load();
|
Settings settings = settingsStore.load();
|
||||||
SettingsMenu.apply(settings, io);
|
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();
|
CommandRegistry registry = new CommandRegistry();
|
||||||
registry.register(new GoCommand(), "go", "move", "walk");
|
registry.register(new GoCommand(), "go", "move", "walk", "gehe", "geh");
|
||||||
registry.register(new LookCommand(), "look", "l");
|
registry.register(new LookCommand(), "look", "l", "umsehen", "schau");
|
||||||
registry.register(new InventoryCommand(), "inventory", "inv", "i");
|
registry.register(new InventoryCommand(), "inventory", "inv", "i", "inventar");
|
||||||
registry.register(new TakeCommand(), "take", "pick", "get");
|
registry.register(new TakeCommand(), "take", "pick", "get", "nimm", "nehme");
|
||||||
registry.register(new DropCommand(), "drop", "put");
|
registry.register(new DropCommand(), "drop", "put", "lege", "ablegen");
|
||||||
registry.register(new UseCommand(), "use");
|
registry.register(new UseCommand(), "use", "benutze", "benutz");
|
||||||
registry.register(new ReadCommand(), "read");
|
registry.register(new ReadCommand(), "read", "lies", "lese");
|
||||||
registry.register(new ExamineCommand(), "examine", "x", "inspect");
|
registry.register(new ExamineCommand(), "examine", "x", "inspect", "untersuche");
|
||||||
registry.register(new MapCommand(), "map", "m");
|
registry.register(new MapCommand(), "map", "m", "karte");
|
||||||
registry.register(new QuestsCommand(), "quests", "log", "journal");
|
registry.register(new QuestsCommand(), "quests", "log", "journal", "aufgaben");
|
||||||
registry.register(new TalkCommand(), "talk", "speak");
|
registry.register(new TalkCommand(), "talk", "speak", "rede", "sprich");
|
||||||
registry.register(new GiveCommand(), "give");
|
registry.register(new GiveCommand(), "give", "gib");
|
||||||
registry.register(new SaveCommand(saves), "save");
|
registry.register(new SaveCommand(saves), "save", "speichern");
|
||||||
|
|
||||||
Game game = new Game(ctx, registry, new CommandParser());
|
Game game = new Game(ctx, registry, new CommandParser());
|
||||||
|
|
||||||
MenuCommand menu = new MenuCommand(saves);
|
MenuCommand menu = new MenuCommand(saves);
|
||||||
menu.bind(game);
|
menu.bind(game);
|
||||||
registry.register(menu, "menu", "quit", "exit");
|
registry.register(menu, "menu", "quit", "exit", "beenden");
|
||||||
registry.register(new HelpCommand(registry), "help", "?");
|
registry.register(new HelpCommand(registry), "help", "?", "hilfe");
|
||||||
|
|
||||||
if (withTutorial) {
|
if (withTutorial) {
|
||||||
Tutorial tutorial = new TutorialLoader().load();
|
Tutorial tutorial = new TutorialLoader().load();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import javax.swing.SwingUtilities;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public final class AppGui {
|
public final class AppGui {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private AppGui() {
|
private AppGui() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +30,13 @@ public final class AppGui {
|
|||||||
try {
|
try {
|
||||||
App.run(io);
|
App.run(io);
|
||||||
} catch (RuntimeException e) {
|
} 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);
|
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");
|
}, "game-loop");
|
||||||
worker.setDaemon(true);
|
worker.setDaemon(true);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public class CommandParser {
|
|||||||
* small — when adding more, watch out for words that might be valid
|
* small — when adding more, watch out for words that might be valid
|
||||||
* item ids ({@code key}, {@code lamp}, etc.).
|
* 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.
|
* Parses an input line into a verb and its arguments.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import thb.jeanluc.adventure.command.Command;
|
|||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.Effects;
|
import thb.jeanluc.adventure.game.Effects;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.NpcReaction;
|
import thb.jeanluc.adventure.model.NpcReaction;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
@@ -24,6 +25,10 @@ public class GiveCommand implements Command {
|
|||||||
ctx.getIo().write("Usage: give <item> <npc>.");
|
ctx.getIo().write("Usage: give <item> <npc>.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Light.canSeeRoom(ctx)) {
|
||||||
|
ctx.getIo().write("It's too dark to make out anyone here.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
String itemId = args.get(0);
|
String itemId = args.get(0);
|
||||||
String npcId = args.get(1);
|
String npcId = args.get(1);
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.MapLayout;
|
||||||
import thb.jeanluc.adventure.game.Light;
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.game.Pathfinder;
|
import thb.jeanluc.adventure.game.Pathfinder;
|
||||||
import thb.jeanluc.adventure.map.MapLayout;
|
|
||||||
import thb.jeanluc.adventure.model.Direction;
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
import thb.jeanluc.adventure.model.ExitLock;
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
|
import thb.jeanluc.adventure.game.EscortEngine;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.game.Light;
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.io.text.RoomView;
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
@@ -36,6 +37,10 @@ public class LookCommand implements Command {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
String followers = EscortEngine.followerNote(ctx);
|
||||||
|
if (followers != null) {
|
||||||
|
description = description.stripTrailing() + "\n\n" + followers;
|
||||||
|
}
|
||||||
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
|
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.MapLayout;
|
||||||
import thb.jeanluc.adventure.io.text.MapView;
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
import thb.jeanluc.adventure.map.MapLayout;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MenuCommand implements Command {
|
public class MenuCommand implements Command {
|
||||||
|
|
||||||
|
/** Persistence backend the active slot is written to before leaving. */
|
||||||
private final SaveService saves;
|
private final SaveService saves;
|
||||||
|
|
||||||
|
/** Running game whose loop is stopped; null until {@link #bind(Game)} is called. */
|
||||||
private Game game;
|
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) {
|
public void bind(Game game) {
|
||||||
this.game = game;
|
this.game = game;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
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.Item;
|
||||||
import thb.jeanluc.adventure.model.item.ReadableItem;
|
import thb.jeanluc.adventure.model.item.ReadableItem;
|
||||||
|
|
||||||
@@ -21,6 +22,11 @@ public class ReadCommand implements Command {
|
|||||||
ctx.getIo().write("Read what?");
|
ctx.getIo().write("Read what?");
|
||||||
return;
|
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();
|
String itemId = args.getFirst();
|
||||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||||
if (item.isEmpty()) {
|
if (item.isEmpty()) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SaveCommand implements Command {
|
public class SaveCommand implements Command {
|
||||||
|
|
||||||
|
/** Persistence backend the active slot is written to. */
|
||||||
private final SaveService saves;
|
private final SaveService saves;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -27,13 +27,19 @@ public class TakeCommand implements Command {
|
|||||||
}
|
}
|
||||||
String itemId = args.getFirst();
|
String itemId = args.getFirst();
|
||||||
Room room = ctx.getPlayer().getCurrentRoom();
|
Room room = ctx.getPlayer().getCurrentRoom();
|
||||||
Optional<Item> taken = room.removeItem(itemId);
|
Optional<Item> found = room.findItem(itemId);
|
||||||
if (taken.isEmpty()) {
|
if (found.isEmpty()) {
|
||||||
ctx.getIo().write("There is no '" + itemId + "' here.");
|
ctx.getIo().write("There is no '" + itemId + "' here.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.getPlayer().addItem(taken.get());
|
Item item = found.get();
|
||||||
ctx.getIo().write("You take the " + taken.get().getName() + ".");
|
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
|
@Override
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.model.DialogueLine;
|
import thb.jeanluc.adventure.model.DialogueLine;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ public class TalkCommand implements Command {
|
|||||||
ctx.getIo().write("Talk to whom?");
|
ctx.getIo().write("Talk to whom?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Light.canSeeRoom(ctx)) {
|
||||||
|
ctx.getIo().write("It's too dark to make out anyone here.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
String npcId = args.getFirst();
|
String npcId = args.getFirst();
|
||||||
Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
|
Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
|
||||||
if (npc.isEmpty()) {
|
if (npc.isEmpty()) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.Combinations;
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
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.Item;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -27,12 +28,16 @@ public class UseCommand implements Command {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String itemId = args.getFirst();
|
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);
|
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||||
if (item.isEmpty()) {
|
if (item.isEmpty() && Light.canSeeRoom(ctx)) {
|
||||||
item = ctx.getPlayer().getCurrentRoom().findItem(itemId);
|
item = ctx.getPlayer().getCurrentRoom().findItem(itemId);
|
||||||
}
|
}
|
||||||
if (item.isEmpty()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
item.get().use(ctx);
|
item.get().use(ctx);
|
||||||
|
|||||||
@@ -18,14 +18,18 @@ public final class Combinations {
|
|||||||
* operand is absent, the recipe's {@code failText} (or a generic line) if a
|
* 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,
|
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
|
||||||
* or applies consume/produce/effects + the response on success.
|
* 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) {
|
public static void tryUse(GameContext ctx, String idX, String idY) {
|
||||||
if (!present(ctx, idX)) {
|
if (!present(ctx, idX)) {
|
||||||
ctx.getIo().write(noSuch(idX));
|
ctx.getIo().write(unresolved(ctx, idX));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!present(ctx, idY)) {
|
if (!present(ctx, idY)) {
|
||||||
ctx.getIo().write(noSuch(idY));
|
ctx.getIo().write(unresolved(ctx, idY));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
|
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) {
|
private static boolean present(GameContext ctx, String id) {
|
||||||
return ctx.getPlayer().hasItem(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) {
|
private static void removeFromAnywhere(GameContext ctx, String id) {
|
||||||
if (ctx.getPlayer().removeItem(id).isEmpty()) {
|
if (ctx.getPlayer().removeItem(id).isEmpty()) {
|
||||||
ctx.getPlayer().getCurrentRoom().removeItem(id);
|
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.";
|
return "There is no '" + id + "' here or in your inventory.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ public final class Conditions {
|
|||||||
private 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) {
|
public static boolean holds(Condition c, GameContext ctx) {
|
||||||
return switch (c.type()) {
|
return switch (c.type()) {
|
||||||
case FLAG -> ctx.getState().isSet(c.arg());
|
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) {
|
public static boolean all(List<Condition> conditions, GameContext ctx) {
|
||||||
if (conditions == null) {
|
if (conditions == null) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ public final class Effects {
|
|||||||
private 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) {
|
public static void apply(Effect e, GameContext ctx) {
|
||||||
switch (e.type()) {
|
switch (e.type()) {
|
||||||
case SET_FLAG -> ctx.getState().set(e.arg());
|
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) {
|
public static void applyAll(List<Effect> effects, GameContext ctx) {
|
||||||
if (effects == null) {
|
if (effects == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ public final class EndingEngine {
|
|||||||
private 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) {
|
public static Ending triggered(GameContext ctx) {
|
||||||
for (Ending e : ctx.getWorld().getEndings()) {
|
for (Ending e : ctx.getWorld().getEndings()) {
|
||||||
if (Conditions.all(e.when(), ctx)) {
|
if (Conditions.all(e.when(), ctx)) {
|
||||||
@@ -19,7 +24,14 @@ public final class EndingEngine {
|
|||||||
return null;
|
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) {
|
public static StyledText render(Ending e, GameContext ctx, int turns) {
|
||||||
int total = ctx.getWorld().getQuests().size();
|
int total = ctx.getWorld().getQuests().size();
|
||||||
int done = ctx.getQuestLog().completed().size();
|
int done = ctx.getQuestLog().completed().size();
|
||||||
@@ -34,6 +46,7 @@ public final class EndingEngine {
|
|||||||
return b.build();
|
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) {
|
private static String rank(Ending e, int total, int done) {
|
||||||
if (e.victory() && total > 0 && done == total) {
|
if (e.victory() && total > 0 && done == total) {
|
||||||
return "Master of the Manor";
|
return "Master of the Manor";
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.StyledText;
|
||||||
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the optional "Lost Twins" escort side-quest. Once a twin is recruited
|
||||||
|
* (a {@code give} reaction sets its {@code *_following} flag) the twin tags
|
||||||
|
* along with the player: this engine removes them from their home room and
|
||||||
|
* renders them as a follower. When the player reaches the foyer with both
|
||||||
|
* twins in tow, the brothers are reunited — granting the reward and firing the
|
||||||
|
* {@code twins_reunited} flag that unlocks the bonus ending.
|
||||||
|
*
|
||||||
|
* <p>State lives entirely in flags, so a save/load round-trip needs no extra
|
||||||
|
* persistence: {@link #tick} reconciles follower placement from the restored
|
||||||
|
* flags on the first turn after loading.
|
||||||
|
*/
|
||||||
|
public final class EscortEngine {
|
||||||
|
|
||||||
|
/** Room the twins must be led to in order to reunite and escape. */
|
||||||
|
static final String REUNION_ROOM_ID = "foyer";
|
||||||
|
|
||||||
|
/** Flag set (and bonus-ending gate) once the brothers are reunited. */
|
||||||
|
static final String REUNITED_FLAG = "twins_reunited";
|
||||||
|
|
||||||
|
/** Item handed to the player as thanks; pure flavour, no mechanical effect. */
|
||||||
|
static final String REWARD_ITEM_ID = "brass_compass";
|
||||||
|
|
||||||
|
/** The two escortable twins, each pinned to a home room and a follow flag. */
|
||||||
|
private static final List<Twin> TWINS = List.of(
|
||||||
|
new Twin("marlon", "marlon_following", "conservatory"),
|
||||||
|
new Twin("malte", "malte_following", "boiler_room"));
|
||||||
|
|
||||||
|
private EscortEngine() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-turn escort progression: pull recruited twins out of their home rooms
|
||||||
|
* and, when both stand with the player in the foyer, reunite them.
|
||||||
|
*
|
||||||
|
* @param ctx the active game context
|
||||||
|
*/
|
||||||
|
public static void tick(GameContext ctx) {
|
||||||
|
for (Twin twin : TWINS) {
|
||||||
|
if (isFollowing(ctx, twin)) {
|
||||||
|
Room home = ctx.getWorld().getRooms().get(twin.homeRoomId());
|
||||||
|
if (home != null) {
|
||||||
|
home.removeNpc(twin.npcId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maybeReunite(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short line naming the twins currently at the player's heels, or
|
||||||
|
* {@code null} when nobody is following. Appended to room descriptions.
|
||||||
|
*
|
||||||
|
* @param ctx the active game context
|
||||||
|
* @return the follower note, or {@code null}
|
||||||
|
*/
|
||||||
|
public static String followerNote(GameContext ctx) {
|
||||||
|
if (ctx.getState().isSet(REUNITED_FLAG)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (Twin twin : TWINS) {
|
||||||
|
if (isFollowing(ctx, twin)) {
|
||||||
|
names.add(displayName(ctx, twin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (names.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (names.size() == 1) {
|
||||||
|
return names.getFirst() + " keeps close at your shoulder.";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
boolean inFoyer = REUNION_ROOM_ID.equals(ctx.getPlayer().getCurrentRoom().getId());
|
||||||
|
boolean bothFollowing = TWINS.stream().allMatch(t -> isFollowing(ctx, t));
|
||||||
|
if (!inFoyer || !bothFollowing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.getState().set(REUNITED_FLAG);
|
||||||
|
grantReward(ctx);
|
||||||
|
ctx.getIo().print(StyledText.builder()
|
||||||
|
.heading("Marlon and Malte are reunited! ")
|
||||||
|
.plain("The brothers crash together in the lamplight, clinging on as if "
|
||||||
|
+ "the dark might split them again. Then they're gone — out the "
|
||||||
|
+ "front door and into the night, hand in hand, free.")
|
||||||
|
.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)) {
|
||||||
|
ctx.getPlayer().addItem(reward);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isFollowing(GameContext ctx, Twin twin) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import thb.jeanluc.adventure.command.CommandRegistry;
|
|||||||
import thb.jeanluc.adventure.command.ParsedCommand;
|
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||||
import thb.jeanluc.adventure.io.text.Hud;
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
import thb.jeanluc.adventure.io.text.MapView;
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
import thb.jeanluc.adventure.map.MapLayout;
|
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -35,7 +34,11 @@ public class Game {
|
|||||||
/** Optional onboarding guide; null in normal/loaded play. */
|
/** Optional onboarding guide; null in normal/loaded play. */
|
||||||
private TutorialGuide tutorialGuide;
|
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) {
|
public void setTutorialGuide(TutorialGuide tutorialGuide) {
|
||||||
this.tutorialGuide = tutorialGuide;
|
this.tutorialGuide = tutorialGuide;
|
||||||
}
|
}
|
||||||
@@ -96,7 +99,14 @@ 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() {
|
private void publishHud() {
|
||||||
|
// 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);
|
QuestEngine.tick(ctx);
|
||||||
ctx.getIo().setHud(new Hud(
|
ctx.getIo().setHud(new Hud(
|
||||||
ctx.getPlayer().getCurrentRoom().getName(),
|
ctx.getPlayer().getCurrentRoom().getName(),
|
||||||
|
|||||||
@@ -11,47 +11,96 @@ import thb.jeanluc.adventure.model.World;
|
|||||||
*/
|
*/
|
||||||
public class GameContext {
|
public class GameContext {
|
||||||
|
|
||||||
|
/** The mutable state of the current playthrough. */
|
||||||
private final GameSession session;
|
private final GameSession session;
|
||||||
|
|
||||||
|
/** Channel commands read input from and write output to. */
|
||||||
private final GameIO io;
|
private final GameIO io;
|
||||||
|
|
||||||
/** Silent autosave hook, wired by the app; no-op by default (tests). */
|
/** Silent autosave hook, wired by the app; no-op by default (tests). */
|
||||||
private Runnable saveCallback = () -> { };
|
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) {
|
public GameContext(GameSession session, GameIO io) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.io = io;
|
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) {
|
public GameContext(World world, Player player, GameIO io) {
|
||||||
this(new GameSession(world, player, "session"), 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() {
|
public GameSession getSession() {
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegates to the session.
|
||||||
|
*
|
||||||
|
* @return the loaded world
|
||||||
|
*/
|
||||||
public World getWorld() {
|
public World getWorld() {
|
||||||
return session.getWorld();
|
return session.getWorld();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegates to the session.
|
||||||
|
*
|
||||||
|
* @return the player character
|
||||||
|
*/
|
||||||
public Player getPlayer() {
|
public Player getPlayer() {
|
||||||
return session.getPlayer();
|
return session.getPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegates to the session.
|
||||||
|
*
|
||||||
|
* @return the world flags
|
||||||
|
*/
|
||||||
public GameState getState() {
|
public GameState getState() {
|
||||||
return session.getState();
|
return session.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegates to the session.
|
||||||
|
*
|
||||||
|
* @return the quest progress
|
||||||
|
*/
|
||||||
public QuestLog getQuestLog() {
|
public QuestLog getQuestLog() {
|
||||||
return session.getQuestLog();
|
return session.getQuestLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the channel commands read from and write to.
|
||||||
|
*
|
||||||
|
* @return the IO channel
|
||||||
|
*/
|
||||||
public GameIO getIo() {
|
public GameIO getIo() {
|
||||||
return io;
|
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) {
|
public void setSaveCallback(Runnable saveCallback) {
|
||||||
this.saveCallback = saveCallback;
|
this.saveCallback = saveCallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,23 +13,47 @@ import thb.jeanluc.adventure.model.World;
|
|||||||
@Getter
|
@Getter
|
||||||
public class GameSession {
|
public class GameSession {
|
||||||
|
|
||||||
|
/** The loaded world this playthrough runs on. */
|
||||||
private final World world;
|
private final World world;
|
||||||
|
|
||||||
|
/** The player character, including inventory, position, and visited rooms. */
|
||||||
private final Player player;
|
private final Player player;
|
||||||
|
|
||||||
|
/** World flags set and cleared by effects and conditions. */
|
||||||
private final GameState state = new GameState();
|
private final GameState state = new GameState();
|
||||||
|
|
||||||
|
/** Quest progress: active stages and completed quests. */
|
||||||
private final QuestLog questLog = new QuestLog();
|
private final QuestLog questLog = new QuestLog();
|
||||||
|
|
||||||
|
/** Name of the save slot this session is bound to; save/load target. */
|
||||||
private final String slotName;
|
private final String slotName;
|
||||||
|
|
||||||
|
/** Number of commands executed so far; shown in the HUD and the ending summary. */
|
||||||
private int turn;
|
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) {
|
public GameSession(World world, Player player, String slotName) {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.player = player;
|
this.player = player;
|
||||||
this.slotName = slotName;
|
this.slotName = slotName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrites the turn counter (used when restoring a save).
|
||||||
|
*
|
||||||
|
* @param turn the turn count to restore
|
||||||
|
*/
|
||||||
public void setTurn(int turn) {
|
public void setTurn(int turn) {
|
||||||
this.turn = turn;
|
this.turn = turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Advances the turn counter by one. */
|
||||||
public void incrementTurn() {
|
public void incrementTurn() {
|
||||||
this.turn++;
|
this.turn++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,25 +8,51 @@ import java.util.Set;
|
|||||||
/** Named world flags. A flag is "set" (true) when present in the set. */
|
/** Named world flags. A flag is "set" (true) when present in the set. */
|
||||||
public class GameState {
|
public class GameState {
|
||||||
|
|
||||||
|
/** Ids of the currently set flags. Insertion-ordered for stable saves. */
|
||||||
private final Set<String> flags = new LinkedHashSet<>();
|
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) {
|
public boolean isSet(String flag) {
|
||||||
return flags.contains(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) {
|
public void set(String flag) {
|
||||||
flags.add(flag);
|
flags.add(flag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a flag. Clearing an unset flag has no effect.
|
||||||
|
*
|
||||||
|
* @param flag the flag id
|
||||||
|
*/
|
||||||
public void clear(String flag) {
|
public void clear(String flag) {
|
||||||
flags.remove(flag);
|
flags.remove(flag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All currently set flags.
|
||||||
|
*
|
||||||
|
* @return an unmodifiable view of the set flags
|
||||||
|
*/
|
||||||
public Set<String> all() {
|
public Set<String> all() {
|
||||||
return Collections.unmodifiableSet(flags);
|
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) {
|
public void restore(Collection<String> newFlags) {
|
||||||
flags.clear();
|
flags.clear();
|
||||||
flags.addAll(newFlags);
|
flags.addAll(newFlags);
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ public final class Light {
|
|||||||
private 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) {
|
public static boolean isLit(GameContext ctx, Room room) {
|
||||||
if (!room.isDark()) {
|
if (!room.isDark()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -21,11 +27,30 @@ public final class Light {
|
|||||||
|| hasActiveLight(room.getItems().values());
|
|| 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) {
|
public static boolean carryingLight(GameContext ctx) {
|
||||||
return hasActiveLight(ctx.getPlayer().getInventory().values());
|
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) {
|
private static boolean hasActiveLight(Collection<Item> items) {
|
||||||
for (Item it : items) {
|
for (Item it : items) {
|
||||||
if (isActiveLight(it)) {
|
if (isActiveLight(it)) {
|
||||||
@@ -35,6 +60,7 @@ public final class Light {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A light-emitting item counts only while switched on; non-switchable ones always count. */
|
||||||
private static boolean isActiveLight(Item it) {
|
private static boolean isActiveLight(Item it) {
|
||||||
if (!it.isLight()) {
|
if (!it.isLight()) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package thb.jeanluc.adventure.map;
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import thb.jeanluc.adventure.io.text.CellState;
|
import thb.jeanluc.adventure.io.text.CellState;
|
||||||
@@ -30,6 +30,16 @@ public final class MapLayout {
|
|||||||
private 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) {
|
public static MapView compute(World world, Set<String> visited, Room current) {
|
||||||
if (visited.isEmpty()) {
|
if (visited.isEmpty()) {
|
||||||
return new MapView(List.of(), List.of());
|
return new MapView(List.of(), List.of());
|
||||||
@@ -101,6 +111,12 @@ public final class MapLayout {
|
|||||||
return new MapView(List.copyOf(cells.values()), connections);
|
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) {
|
private static Map<String, int[]> bfs(Room anchor) {
|
||||||
Map<String, int[]> coords = new HashMap<>();
|
Map<String, int[]> coords = new HashMap<>();
|
||||||
Deque<Room> queue = new ArrayDeque<>();
|
Deque<Room> queue = new ArrayDeque<>();
|
||||||
@@ -126,6 +142,7 @@ public final class MapLayout {
|
|||||||
return coords;
|
return coords;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grid step for a direction as {@code {dx, dy}}; y grows southwards. */
|
||||||
private static int[] delta(Direction d) {
|
private static int[] delta(Direction d) {
|
||||||
return switch (d) {
|
return switch (d) {
|
||||||
case NORTH -> new int[]{0, -1};
|
case NORTH -> new int[]{0, -1};
|
||||||
@@ -16,6 +16,15 @@ public final class QuestEngine {
|
|||||||
private 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) {
|
public static void tick(GameContext ctx) {
|
||||||
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
||||||
for (Quest q : quests.values()) {
|
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) {
|
private static void finish(GameContext ctx, Quest q) {
|
||||||
if (ctx.getQuestLog().isCompleted(q.id())) {
|
if (ctx.getQuestLog().isCompleted(q.id())) {
|
||||||
return;
|
return;
|
||||||
@@ -65,6 +75,13 @@ public final class QuestEngine {
|
|||||||
ctx.save();
|
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) {
|
public static QuestView viewOf(GameContext ctx) {
|
||||||
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
||||||
List<QuestEntry> active = new ArrayList<>();
|
List<QuestEntry> active = new ArrayList<>();
|
||||||
@@ -85,6 +102,7 @@ public final class QuestEngine {
|
|||||||
return new QuestView(active, completed);
|
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) {
|
private static int totalStages(Map<String, Quest> quests) {
|
||||||
int total = 0;
|
int total = 0;
|
||||||
for (Quest q : quests.values()) {
|
for (Quest q : quests.values()) {
|
||||||
|
|||||||
@@ -10,45 +10,97 @@ import java.util.Set;
|
|||||||
/** Runtime quest progress: current stage per active quest, plus completed ids. */
|
/** Runtime quest progress: current stage per active quest, plus completed ids. */
|
||||||
public class QuestLog {
|
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<>();
|
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<>();
|
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) {
|
public void start(String id) {
|
||||||
if (!completed.contains(id)) {
|
if (!completed.contains(id)) {
|
||||||
stageIndex.putIfAbsent(id, 0);
|
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) {
|
public boolean isActive(String id) {
|
||||||
return stageIndex.containsKey(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) {
|
public boolean isCompleted(String id) {
|
||||||
return completed.contains(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) {
|
public int stageIndex(String id) {
|
||||||
return stageIndex.getOrDefault(id, 0);
|
return stageIndex.getOrDefault(id, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a quest on to its next stage.
|
||||||
|
*
|
||||||
|
* @param id the quest id
|
||||||
|
*/
|
||||||
public void advance(String id) {
|
public void advance(String id) {
|
||||||
stageIndex.merge(id, 1, Integer::sum);
|
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) {
|
public void complete(String id) {
|
||||||
stageIndex.remove(id);
|
stageIndex.remove(id);
|
||||||
completed.add(id);
|
completed.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of all currently active quests.
|
||||||
|
*
|
||||||
|
* @return an unmodifiable view of the active quest ids
|
||||||
|
*/
|
||||||
public Set<String> active() {
|
public Set<String> active() {
|
||||||
return Collections.unmodifiableSet(stageIndex.keySet());
|
return Collections.unmodifiableSet(stageIndex.keySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of all completed quests.
|
||||||
|
*
|
||||||
|
* @return an unmodifiable view of the completed quest ids
|
||||||
|
*/
|
||||||
public Set<String> completed() {
|
public Set<String> completed() {
|
||||||
return Collections.unmodifiableSet(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) {
|
public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
|
||||||
stageIndex.clear();
|
stageIndex.clear();
|
||||||
stageIndex.putAll(stages);
|
stageIndex.putAll(stages);
|
||||||
|
|||||||
@@ -18,25 +18,51 @@ import java.util.Optional;
|
|||||||
*/
|
*/
|
||||||
public final class TutorialGuide {
|
public final class TutorialGuide {
|
||||||
|
|
||||||
|
/** The walkthrough content: intro, steps, and closing tips. */
|
||||||
private final Tutorial tutorial;
|
private final Tutorial tutorial;
|
||||||
|
|
||||||
|
/** Command lookup, used to compare a typed verb against a step's expected command via aliases. */
|
||||||
private final CommandRegistry registry;
|
private final CommandRegistry registry;
|
||||||
|
|
||||||
|
/** Index of the step currently being taught. */
|
||||||
private int index;
|
private int index;
|
||||||
|
|
||||||
|
/** False once all steps are done or the walkthrough was skipped. */
|
||||||
private boolean active;
|
private boolean active;
|
||||||
|
|
||||||
|
/** True once {@link #begin(GameContext)} has printed the intro; guards against a second run. */
|
||||||
private boolean begun;
|
private boolean begun;
|
||||||
|
|
||||||
|
/** Room the player stood in when the current step began; used to detect movement steps. */
|
||||||
private Room roomAtStepStart;
|
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) {
|
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
|
||||||
this.tutorial = tutorial;
|
this.tutorial = tutorial;
|
||||||
this.registry = registry;
|
this.registry = registry;
|
||||||
this.active = !tutorial.isEmpty();
|
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() {
|
public boolean isActive() {
|
||||||
return active;
|
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) {
|
public void begin(GameContext ctx) {
|
||||||
if (!active || begun) {
|
if (!active || begun) {
|
||||||
return;
|
return;
|
||||||
@@ -48,7 +74,14 @@ public final class TutorialGuide {
|
|||||||
enterStep(ctx);
|
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) {
|
public void onCommand(GameContext ctx, ParsedCommand parsed) {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
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) {
|
public void skip(GameContext ctx) {
|
||||||
if (active) {
|
if (active) {
|
||||||
ctx.getIo().write("Tutorial skipped.");
|
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) {
|
private void enterStep(GameContext ctx) {
|
||||||
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
|
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
|
||||||
ctx.getIo().write(tutorial.steps().get(index).instruction());
|
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) {
|
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
|
||||||
String expect = step.expect();
|
String expect = step.expect();
|
||||||
if ("go_direction".equals(expect)) {
|
if ("go_direction".equals(expect)) {
|
||||||
@@ -98,6 +142,7 @@ public final class TutorialGuide {
|
|||||||
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
|
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) {
|
private boolean firstIsDirection(ParsedCommand p) {
|
||||||
if (p.args().isEmpty()) {
|
if (p.args().isEmpty()) {
|
||||||
return false;
|
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) {
|
private boolean moved(GameContext ctx) {
|
||||||
return roomAtStepStart != null
|
return roomAtStepStart != null
|
||||||
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
|
&& 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) {
|
private boolean sameCommand(String a, String b) {
|
||||||
Optional<Command> ca = registry.find(a);
|
Optional<Command> ca = registry.find(a);
|
||||||
Optional<Command> cb = registry.find(b);
|
Optional<Command> cb = registry.find(b);
|
||||||
|
|||||||
@@ -23,28 +23,70 @@ import java.util.List;
|
|||||||
public class ConsoleIO implements GameIO {
|
public class ConsoleIO implements GameIO {
|
||||||
|
|
||||||
/** Whether ANSI colour is emitted. */
|
/** 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. */
|
/** 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;
|
private static final int FRAME_WIDTH = 44;
|
||||||
|
|
||||||
|
/** Source the player's commands are read from. */
|
||||||
private final BufferedReader in;
|
private final BufferedReader in;
|
||||||
|
|
||||||
|
/** Sink all output is written to. */
|
||||||
private final PrintStream out;
|
private final PrintStream out;
|
||||||
|
|
||||||
|
/** Whether ANSI colour codes are currently emitted; resolved from a {@link ColorMode}. */
|
||||||
private boolean useColor;
|
private boolean useColor;
|
||||||
|
|
||||||
|
/** Frame/glyph fidelity tier currently in use. */
|
||||||
private GlyphMode glyphs;
|
private GlyphMode glyphs;
|
||||||
|
|
||||||
|
/** Reads from {@code System.in} and writes to {@code System.out}, colour auto-detected. */
|
||||||
public ConsoleIO() {
|
public ConsoleIO() {
|
||||||
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
|
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
|
||||||
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
|
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) {
|
public ConsoleIO(BufferedReader in, PrintStream out) {
|
||||||
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
|
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) {
|
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
|
||||||
this.in = in;
|
this.in = in;
|
||||||
this.out = out;
|
this.out = out;
|
||||||
@@ -52,7 +94,11 @@ public class ConsoleIO implements GameIO {
|
|||||||
setColorMode(colorMode);
|
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) {
|
public void setColorMode(ColorMode mode) {
|
||||||
this.useColor = switch (mode) {
|
this.useColor = switch (mode) {
|
||||||
case ON -> true;
|
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) {
|
public void setGlyphMode(GlyphMode mode) {
|
||||||
this.glyphs = mode;
|
this.glyphs = mode;
|
||||||
}
|
}
|
||||||
@@ -118,6 +168,14 @@ public class ConsoleIO implements GameIO {
|
|||||||
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
|
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) {
|
private String section(String label, List<String> xs, Style st) {
|
||||||
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
|
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
|
||||||
for (int i = 0; i < xs.size(); i++) {
|
for (int i = 0; i < xs.size(); i++) {
|
||||||
@@ -129,6 +187,10 @@ public class ConsoleIO implements GameIO {
|
|||||||
return sb.toString();
|
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) {
|
private String frameTop(String title) {
|
||||||
String tl = glyphs == GlyphMode.ASCII ? "+" : "┌";
|
String tl = glyphs == GlyphMode.ASCII ? "+" : "┌";
|
||||||
String tr = glyphs == GlyphMode.ASCII ? "+" : "┐";
|
String tr = glyphs == GlyphMode.ASCII ? "+" : "┐";
|
||||||
@@ -141,6 +203,9 @@ public class ConsoleIO implements GameIO {
|
|||||||
return sb.append(tr).toString();
|
return sb.append(tr).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the bottom border of a room frame, {@link #FRAME_WIDTH} characters wide
|
||||||
|
*/
|
||||||
private String frameBottom() {
|
private String frameBottom() {
|
||||||
String bl = glyphs == GlyphMode.ASCII ? "+" : "└";
|
String bl = glyphs == GlyphMode.ASCII ? "+" : "└";
|
||||||
String br = glyphs == GlyphMode.ASCII ? "+" : "┘";
|
String br = glyphs == GlyphMode.ASCII ? "+" : "┘";
|
||||||
@@ -152,6 +217,13 @@ public class ConsoleIO implements GameIO {
|
|||||||
return sb.append(br).toString();
|
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) {
|
private String paint(Style st, String s) {
|
||||||
if (!useColor) {
|
if (!useColor) {
|
||||||
return s;
|
return s;
|
||||||
|
|||||||
@@ -18,28 +18,52 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public interface GameIO {
|
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();
|
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);
|
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) {
|
default void write(String s) {
|
||||||
print(StyledText.of(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) {
|
default void showRoom(RoomView room) {
|
||||||
print(Renderings.roomToStyledText(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) {
|
default void setHud(Hud hud) {
|
||||||
// no-op
|
// 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) {
|
default void setMap(MapView view) {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
@@ -55,27 +79,47 @@ public interface GameIO {
|
|||||||
// no animation in text mode
|
// 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) {
|
default void showMap(MapView view) {
|
||||||
print(AsciiMap.render(view, true));
|
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) {
|
default void setQuests(QuestView view) {
|
||||||
// no-op
|
// 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) {
|
default void setMusic(String track) {
|
||||||
// no audio in text mode
|
// 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) {
|
default void setMusicLevel(MusicLevel level) {
|
||||||
// no audio in text mode
|
// 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) {
|
default void showQuests(QuestView view) {
|
||||||
print(QuestText.render(view));
|
print(QuestText.render(view));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,30 +31,64 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public class MapPanel extends JComponent {
|
public class MapPanel extends JComponent {
|
||||||
|
|
||||||
|
/** Unscaled room width, in pixels. */
|
||||||
private static final int BASE_CELL_W = 96;
|
private static final int BASE_CELL_W = 96;
|
||||||
|
|
||||||
|
/** Unscaled room height, in pixels. */
|
||||||
private static final int BASE_CELL_H = 46;
|
private static final int BASE_CELL_H = 46;
|
||||||
|
|
||||||
|
/** Unscaled horizontal gap between two rooms, in pixels. */
|
||||||
private static final int BASE_GAP_X = 34;
|
private static final int BASE_GAP_X = 34;
|
||||||
|
|
||||||
|
/** Unscaled vertical gap between two rooms, in pixels. */
|
||||||
private static final int BASE_GAP_Y = 30;
|
private static final int BASE_GAP_Y = 30;
|
||||||
|
|
||||||
|
/** Unscaled padding around the whole map, in pixels. */
|
||||||
private static final int BASE_PAD = 16;
|
private static final int BASE_PAD = 16;
|
||||||
|
|
||||||
|
/** Unscaled size of the room-label font, in points. */
|
||||||
private static final float BASE_FONT = 11f;
|
private static final float BASE_FONT = 11f;
|
||||||
|
|
||||||
|
/** Panel background. */
|
||||||
private static final Color BG = new Color(0x0e, 0x12, 0x18);
|
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);
|
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);
|
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);
|
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);
|
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
|
||||||
|
|
||||||
|
/** Font the room labels are derived from. */
|
||||||
private final Font baseFont;
|
private final Font baseFont;
|
||||||
|
|
||||||
|
/** User zoom multiplier, applied on top of the fit-to-panel scale. */
|
||||||
private float zoom = 1f;
|
private float zoom = 1f;
|
||||||
|
|
||||||
|
/** The map currently drawn; empty until the first {@link #show(MapView)}. */
|
||||||
private MapView view = new MapView(List.of(), List.of());
|
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) {
|
public MapPanel(Font font) {
|
||||||
this.baseFont = font;
|
this.baseFont = font;
|
||||||
setBackground(BG);
|
setBackground(BG);
|
||||||
setOpaque(true);
|
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) {
|
public void setZoom(float zoom) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
this.zoom = Math.max(0.4f, zoom);
|
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) {
|
public void show(MapView v) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
this.view = v;
|
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() {
|
private Dimension available() {
|
||||||
Container p = getParent();
|
Container p = getParent();
|
||||||
if (p instanceof JViewport && p.getParent() instanceof JScrollPane sp) {
|
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 (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() {
|
private int[] gridBounds() {
|
||||||
int maxX = 0;
|
int maxX = 0;
|
||||||
int maxY = 0;
|
int maxY = 0;
|
||||||
@@ -97,7 +143,13 @@ public class MapPanel extends JComponent {
|
|||||||
return new int[]{maxX, maxY};
|
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) {
|
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 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;
|
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);
|
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) {
|
private Dimension content(float eff, int[] b) {
|
||||||
int pad = Math.round(BASE_PAD * eff);
|
int pad = Math.round(BASE_PAD * eff);
|
||||||
int cw = Math.round(BASE_CELL_W * eff);
|
int cw = Math.round(BASE_CELL_W * eff);
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ package thb.jeanluc.adventure.io;
|
|||||||
/** Raw audio operations — the only part that touches sound hardware. */
|
/** Raw audio operations — the only part that touches sound hardware. */
|
||||||
public interface MusicBackend {
|
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);
|
void fadeTo(String track, float gainDb);
|
||||||
|
|
||||||
/** Fade the current track out and stop. */
|
/** Fade the current track out and stop. */
|
||||||
void fadeOut();
|
void fadeOut();
|
||||||
|
|
||||||
/** Set the playback volume immediately. */
|
/**
|
||||||
|
* Set the playback volume immediately.
|
||||||
|
*
|
||||||
|
* @param gainDb target volume in decibels
|
||||||
|
*/
|
||||||
void setGainDb(float gainDb);
|
void setGainDb(float gainDb);
|
||||||
|
|
||||||
/** Stop playback and release resources. */
|
/** Stop playback and release resources. */
|
||||||
|
|||||||
@@ -10,15 +10,31 @@ import java.util.Objects;
|
|||||||
*/
|
*/
|
||||||
public final class MusicController {
|
public final class MusicController {
|
||||||
|
|
||||||
|
/** Backend the raw playback operations are delegated to. */
|
||||||
private final MusicBackend backend;
|
private final MusicBackend backend;
|
||||||
|
|
||||||
|
/** Volume level currently selected in the Settings menu. */
|
||||||
private MusicLevel level = MusicLevel.MEDIUM;
|
private MusicLevel level = MusicLevel.MEDIUM;
|
||||||
|
|
||||||
|
/** Track currently selected, or null when silent. */
|
||||||
private String current; // currently selected track, or null = silence
|
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) {
|
public MusicController(MusicBackend backend) {
|
||||||
this.backend = 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) {
|
public void room(String track) {
|
||||||
if (!level.isOn()) {
|
if (!level.isOn()) {
|
||||||
return;
|
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) {
|
public void level(MusicLevel newLevel) {
|
||||||
if (newLevel == this.level) {
|
if (newLevel == this.level) {
|
||||||
return;
|
return;
|
||||||
@@ -49,6 +71,7 @@ public final class MusicController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stops playback and releases the backend's resources. */
|
||||||
public void shutdown() {
|
public void shutdown() {
|
||||||
backend.shutdown();
|
backend.shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,23 @@ package thb.jeanluc.adventure.io;
|
|||||||
|
|
||||||
/** Background-music volume level, cycled in the Settings menu. */
|
/** Background-music volume level, cycled in the Settings menu. */
|
||||||
public enum MusicLevel {
|
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() {
|
public float gainDb() {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
case OFF -> Float.NEGATIVE_INFINITY;
|
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() {
|
public boolean isOn() {
|
||||||
return this != OFF;
|
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() {
|
public MusicLevel next() {
|
||||||
return values()[(ordinal() + 1) % values().length];
|
return values()[(ordinal() + 1) % values().length];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import javax.sound.sampled.AudioInputStream;
|
|||||||
import javax.sound.sampled.AudioSystem;
|
import javax.sound.sampled.AudioSystem;
|
||||||
import javax.sound.sampled.FloatControl;
|
import javax.sound.sampled.FloatControl;
|
||||||
import javax.sound.sampled.SourceDataLine;
|
import javax.sound.sampled.SourceDataLine;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.io.BufferedInputStream;
|
import java.io.BufferedInputStream;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
|
||||||
@@ -20,17 +21,46 @@ import java.io.InputStream;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public final class OggMusicBackend implements MusicBackend {
|
public final class OggMusicBackend implements MusicBackend {
|
||||||
|
|
||||||
|
/** Classpath directory the bundled tracks are looked up in. */
|
||||||
private static final String MUSIC_DIR = "/music/";
|
private static final String MUSIC_DIR = "/music/";
|
||||||
|
|
||||||
|
/** Duration of a fade-in or fade-out, in milliseconds. */
|
||||||
private static final int FADE_MS = 400;
|
private static final int FADE_MS = 400;
|
||||||
|
|
||||||
|
/** Number of gain steps a fade is split into. */
|
||||||
private static final int FADE_STEPS = 20;
|
private static final int FADE_STEPS = 20;
|
||||||
|
|
||||||
|
/** Size of the PCM transfer buffer, in bytes. */
|
||||||
private static final int BUFFER = 4096;
|
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
|
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 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;
|
private volatile SourceDataLine line;
|
||||||
|
|
||||||
|
/** Gain most recently requested by the player, in decibels. */
|
||||||
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
|
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
|
@Override
|
||||||
public synchronized void fadeTo(String track, float gainDb) {
|
public synchronized void fadeTo(String track, float gainDb) {
|
||||||
stopCurrent();
|
stopCurrent();
|
||||||
@@ -40,17 +70,24 @@ public final class OggMusicBackend implements MusicBackend {
|
|||||||
log.warn("Music resource not found, staying silent: {}", resource);
|
log.warn("Music resource not found, staying silent: {}", resource);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopping = false;
|
AtomicBoolean stop = new AtomicBoolean(false);
|
||||||
playThread = new Thread(() -> playLoop(resource, gainDb), "music");
|
playStop = stop;
|
||||||
|
playThread = new Thread(() -> playLoop(resource, gainDb, stop), "music");
|
||||||
playThread.setDaemon(true);
|
playThread.setDaemon(true);
|
||||||
playThread.start();
|
playThread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fades the current track out and stops the play thread. */
|
||||||
@Override
|
@Override
|
||||||
public synchronized void fadeOut() {
|
public synchronized void fadeOut() {
|
||||||
stopCurrent();
|
stopCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a new gain to the running playback immediately, without a fade.
|
||||||
|
*
|
||||||
|
* @param gainDb target MASTER_GAIN in decibels
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public synchronized void setGainDb(float gainDb) {
|
public synchronized void setGainDb(float gainDb) {
|
||||||
targetGainDb = gainDb;
|
targetGainDb = gainDb;
|
||||||
@@ -60,30 +97,51 @@ public final class OggMusicBackend implements MusicBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stops playback and releases the audio line. */
|
||||||
@Override
|
@Override
|
||||||
public synchronized void shutdown() {
|
public synchronized void shutdown() {
|
||||||
stopCurrent();
|
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() {
|
private void stopCurrent() {
|
||||||
Thread t = playThread;
|
Thread t = playThread;
|
||||||
|
AtomicBoolean stop = playStop;
|
||||||
if (t == null) {
|
if (t == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopping = true;
|
if (stop != null) {
|
||||||
|
stop.set(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
t.join(FADE_MS + 600L);
|
t.join(FADE_MS + 600L);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
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;
|
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;
|
SourceDataLine l = null;
|
||||||
try {
|
try {
|
||||||
while (!stopping) {
|
while (!stop.get()) {
|
||||||
// Reopen the bundled resource each loop; BufferedInputStream gives
|
// Reopen the bundled resource each loop; BufferedInputStream gives
|
||||||
// the SPI the mark/reset support it needs to read the OGG header.
|
// the SPI the mark/reset support it needs to read the OGG header.
|
||||||
try (InputStream res = getClass().getResourceAsStream(resource);
|
try (InputStream res = getClass().getResourceAsStream(resource);
|
||||||
@@ -97,12 +155,15 @@ public final class OggMusicBackend implements MusicBackend {
|
|||||||
l = AudioSystem.getSourceDataLine(pcm);
|
l = AudioSystem.getSourceDataLine(pcm);
|
||||||
l.open(pcm);
|
l.open(pcm);
|
||||||
l.start();
|
l.start();
|
||||||
line = l;
|
// Only publish the line if this playback is still current.
|
||||||
ramp(l, FLOOR_DB, gainDb); // fade in once
|
if (playStop == stop) {
|
||||||
|
line = l;
|
||||||
|
}
|
||||||
|
ramp(l, FLOOR_DB, gainDb, stop); // fade in once
|
||||||
}
|
}
|
||||||
byte[] buf = new byte[BUFFER];
|
byte[] buf = new byte[BUFFER];
|
||||||
int n;
|
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);
|
l.write(buf, 0, n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,24 +174,44 @@ public final class OggMusicBackend implements MusicBackend {
|
|||||||
} finally {
|
} finally {
|
||||||
if (l != null) {
|
if (l != null) {
|
||||||
try {
|
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) {
|
} catch (RuntimeException ignored) {
|
||||||
// best-effort fade
|
// best-effort fade
|
||||||
}
|
}
|
||||||
l.stop();
|
l.stop();
|
||||||
l.close();
|
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++) {
|
for (int i = 0; i <= FADE_STEPS; i++) {
|
||||||
|
if (stop.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
|
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
|
||||||
sleep(FADE_MS / 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) {
|
private float currentGain(SourceDataLine l) {
|
||||||
try {
|
try {
|
||||||
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
|
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) {
|
private void applyGain(SourceDataLine l, float db) {
|
||||||
try {
|
try {
|
||||||
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
|
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) {
|
private void sleep(long ms) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(ms);
|
Thread.sleep(ms);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import java.awt.Insets;
|
|||||||
/** Read-only styled view of the quest log, shown under the map. */
|
/** Read-only styled view of the quest log, shown under the map. */
|
||||||
public class QuestPanel extends JTextPane {
|
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) {
|
public QuestPanel(Font font) {
|
||||||
setEditable(false);
|
setEditable(false);
|
||||||
setBackground(new Color(0x0b, 0x0e, 0x13));
|
setBackground(new Color(0x0b, 0x0e, 0x13));
|
||||||
@@ -26,6 +31,11 @@ public class QuestPanel extends JTextPane {
|
|||||||
setMargin(new Insets(8, 10, 8, 10));
|
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) {
|
public void show(QuestView view) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
StyledDocument doc = getStyledDocument();
|
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) {
|
private Color colorFor(Style s) {
|
||||||
return switch (s) {
|
return switch (s) {
|
||||||
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
|
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import javax.swing.BoxLayout;
|
|||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JComponent;
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JFrame;
|
import javax.swing.JFrame;
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JScrollPane;
|
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. */
|
/** Side-panel width as a fraction of the window width, with a lower bound only. */
|
||||||
private static final double SIDE_RATIO = 0.34;
|
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;
|
private static final int SIDE_MIN = 220;
|
||||||
|
|
||||||
|
/** Background music of the current room. */
|
||||||
private final MusicController music = new MusicController(new OggMusicBackend());
|
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<>();
|
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||||||
|
|
||||||
|
/** The game window. */
|
||||||
private final JFrame frame;
|
private final JFrame frame;
|
||||||
|
|
||||||
|
/** Centre region: the scrolling transcript of styled output. */
|
||||||
private final JTextPane output;
|
private final JTextPane output;
|
||||||
|
|
||||||
|
/** Document behind {@link #output}; all text is appended to it. */
|
||||||
private final StyledDocument doc;
|
private final StyledDocument doc;
|
||||||
|
|
||||||
|
/** Bottom region: the command line the player types into. */
|
||||||
private final JTextField input;
|
private final JTextField input;
|
||||||
|
|
||||||
|
/** Top region: the single-line status bar. */
|
||||||
private final JLabel hud;
|
private final JLabel hud;
|
||||||
|
|
||||||
|
/** Right region (upper): the drawn map. */
|
||||||
private final MapPanel map;
|
private final MapPanel map;
|
||||||
|
|
||||||
|
/** Scroll pane wrapping {@link #map}. */
|
||||||
private final JScrollPane sideScroll;
|
private final JScrollPane sideScroll;
|
||||||
|
|
||||||
|
/** Right region (lower): the quest log. */
|
||||||
private final QuestPanel quests;
|
private final QuestPanel quests;
|
||||||
|
|
||||||
|
/** Scroll pane wrapping {@link #quests}. */
|
||||||
private final JScrollPane questScroll;
|
private final JScrollPane questScroll;
|
||||||
|
|
||||||
|
/** Right region: container holding {@link #sideScroll} and {@link #questScroll}. */
|
||||||
private final JPanel sidePanel;
|
private final JPanel sidePanel;
|
||||||
|
|
||||||
|
/** Font all component fonts are derived from; bundled TTF or a monospaced fallback. */
|
||||||
private final Font baseFont;
|
private final Font baseFont;
|
||||||
|
|
||||||
/** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
|
/** 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. */
|
/** User zoom multiplier, adjusted live via keyboard. */
|
||||||
private float zoom = 1f;
|
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) {
|
public SwingIO(String title) {
|
||||||
baseFont = loadFont();
|
baseFont = loadFont();
|
||||||
uiScale = detectScale();
|
uiScale = detectScale();
|
||||||
@@ -156,7 +193,11 @@ public class SwingIO implements GameIO {
|
|||||||
input.requestFocusInWindow();
|
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() {
|
private Font loadFont() {
|
||||||
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
|
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
|
||||||
if (in != null) {
|
if (in != null) {
|
||||||
@@ -170,7 +211,11 @@ public class SwingIO implements GameIO {
|
|||||||
return new Font(Font.MONOSPACED, Font.PLAIN, 12);
|
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() {
|
private float detectScale() {
|
||||||
try {
|
try {
|
||||||
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
|
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
|
* Auto-scale derived from the window size relative to the design size
|
||||||
* (900x600). At the initial size this equals {@link #uiScale}; growing the
|
* (900x600). At the initial size this equals {@link #uiScale}; growing the
|
||||||
* window grows the text, mirroring how the map fits its panel.
|
* window grows the text, mirroring how the map fits its panel.
|
||||||
|
*
|
||||||
|
* @return the auto-scale, clamped to 0.6–2.6
|
||||||
*/
|
*/
|
||||||
private float frameScale() {
|
private float frameScale() {
|
||||||
double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0);
|
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)));
|
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) {
|
private void bind(JComponent c, KeyStroke ks, String name) {
|
||||||
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, 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) {
|
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
|
||||||
return new AbstractAction() {
|
return new AbstractAction() {
|
||||||
@Override
|
@Override
|
||||||
@@ -255,11 +315,20 @@ public class SwingIO implements GameIO {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the zoom, clamped to 0.6–3.0, and re-applies the fonts.
|
||||||
|
*
|
||||||
|
* @param z the requested zoom multiplier
|
||||||
|
*/
|
||||||
private void setZoom(float z) {
|
private void setZoom(float z) {
|
||||||
zoom = Math.max(0.6f, Math.min(3.0f, z));
|
zoom = Math.max(0.6f, Math.min(3.0f, z));
|
||||||
applyFonts();
|
applyFonts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param s the semantic role
|
||||||
|
* @return the colour this frontend paints that role in
|
||||||
|
*/
|
||||||
private Color colorFor(Style s) {
|
private Color colorFor(Style s) {
|
||||||
return switch (s) {
|
return switch (s) {
|
||||||
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
|
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) {
|
private void appendSpans(List<Span> spans) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
try {
|
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
|
@Override
|
||||||
public void setMap(MapView view) {
|
public void setMap(MapView view) {
|
||||||
map.show(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. */
|
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
|
||||||
private String menuTitle;
|
private String menuTitle;
|
||||||
|
|
||||||
|
/** Options of the menu currently shown, or null when no menu is visible. */
|
||||||
private List<String> menuOptions;
|
private List<String> menuOptions;
|
||||||
|
|
||||||
|
/** Queue the current menu's chosen index is offered to. */
|
||||||
private LinkedBlockingQueue<Integer> menuPicked;
|
private LinkedBlockingQueue<Integer> menuPicked;
|
||||||
|
|
||||||
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
|
/** 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) {
|
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
|
||||||
menuTitle = title;
|
menuTitle = title;
|
||||||
menuOptions = options;
|
menuOptions = options;
|
||||||
@@ -463,24 +567,55 @@ public class SwingIO implements GameIO {
|
|||||||
glass.add(card); // single child → centered by GridBagLayout
|
glass.add(card); // single child → centered by GridBagLayout
|
||||||
frame.setGlassPane(glass);
|
frame.setGlassPane(glass);
|
||||||
glass.setVisible(true);
|
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();
|
glass.requestFocusInWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the menu overlay, discards anything typed while it was up, and
|
||||||
|
* returns focus to the input field.
|
||||||
|
*/
|
||||||
private void hideMenuOverlay() {
|
private void hideMenuOverlay() {
|
||||||
menuOptions = null; // stop resize rebuilds once the menu is dismissed
|
menuOptions = null; // stop resize rebuilds once the menu is dismissed
|
||||||
frame.getGlassPane().setVisible(false);
|
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. */
|
/** Palette for the themed menu overlay, matching the game window's dark look. */
|
||||||
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
|
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);
|
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);
|
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);
|
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);
|
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);
|
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);
|
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) {
|
private JButton menuButton(String text, float scale) {
|
||||||
int padV = (int) (11 * scale);
|
int padV = (int) (11 * scale);
|
||||||
int padH = (int) (18 * scale);
|
int padH = (int) (18 * scale);
|
||||||
@@ -514,12 +649,25 @@ public class SwingIO implements GameIO {
|
|||||||
return b;
|
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) {
|
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
|
||||||
return BorderFactory.createCompoundBorder(
|
return BorderFactory.createCompoundBorder(
|
||||||
BorderFactory.createLineBorder(line, 1),
|
BorderFactory.createLineBorder(line, 1),
|
||||||
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
|
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) {
|
private void addList(List<Span> spans, List<String> xs, Style st) {
|
||||||
for (int i = 0; i < xs.size(); i++) {
|
for (int i = 0; i < xs.size(); i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
|
|||||||
@@ -11,13 +11,26 @@ import java.util.Set;
|
|||||||
*/
|
*/
|
||||||
public final class AsciiMap {
|
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
|
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
|
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
|
private static final int GAP = 3; // horizontal corridor width
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private AsciiMap() {
|
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) {
|
public static StyledText render(MapView view, boolean unicode) {
|
||||||
if (view.cells().isEmpty()) {
|
if (view.cells().isEmpty()) {
|
||||||
return StyledText.of("(no map yet)");
|
return StyledText.of("(no map yet)");
|
||||||
@@ -98,6 +111,13 @@ public final class AsciiMap {
|
|||||||
return b.build();
|
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) {
|
private static void styled(StyledText.Builder b, String s, CellState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case CURRENT -> b.heading(s);
|
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) {
|
private static String label(RoomCell c, int inner) {
|
||||||
String n = c.name();
|
String n = c.name();
|
||||||
if (n.length() > inner) {
|
if (n.length() > inner) {
|
||||||
@@ -114,6 +139,11 @@ public final class AsciiMap {
|
|||||||
return n;
|
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) {
|
private static String center(String s, int width) {
|
||||||
int total = width - s.length();
|
int total = width - s.length();
|
||||||
int left = total / 2;
|
int left = total / 2;
|
||||||
@@ -121,6 +151,11 @@ public final class AsciiMap {
|
|||||||
return " ".repeat(left) + s + " ".repeat(right);
|
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) {
|
private static long key(int x, int y) {
|
||||||
return (((long) x) << 32) ^ (y & 0xffffffffL);
|
return (((long) x) << 32) ^ (y & 0xffffffffL);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ package thb.jeanluc.adventure.io.text;
|
|||||||
/** Builds styled banners for big moments (game start, finale). */
|
/** Builds styled banners for big moments (game start, finale). */
|
||||||
public final class Banner {
|
public final class Banner {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private Banner() {
|
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) {
|
public static StyledText welcome(String title) {
|
||||||
String bar = "=".repeat(Math.max(8, title.length() + 8));
|
String bar = "=".repeat(Math.max(8, title.length() + 8));
|
||||||
return StyledText.builder()
|
return StyledText.builder()
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
package thb.jeanluc.adventure.io.text;
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
/** Fog-of-war state of a room on the map. */
|
/** 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
package thb.jeanluc.adventure.io.text;
|
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) {}
|
public record Connection(RoomCell from, RoomCell to) {}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
package thb.jeanluc.adventure.io.text;
|
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) {}
|
public record Hud(String location, int gold, int turn, boolean lightOn) {}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.io.text;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record MapView(List<RoomCell> cells, List<Connection> connections) {
|
||||||
|
/** Defensively copies both lists, making the snapshot immutable. */
|
||||||
public MapView {
|
public MapView {
|
||||||
cells = List.copyOf(cells);
|
cells = List.copyOf(cells);
|
||||||
connections = List.copyOf(connections);
|
connections = List.copyOf(connections);
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
package thb.jeanluc.adventure.io.text;
|
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) {}
|
public record QuestEntry(String title, String objective) {}
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ package thb.jeanluc.adventure.io.text;
|
|||||||
/** Renders a {@link QuestView} as styled text for the console quest log. */
|
/** Renders a {@link QuestView} as styled text for the console quest log. */
|
||||||
public final class QuestText {
|
public final class QuestText {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private QuestText() {
|
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) {
|
public static StyledText render(QuestView view) {
|
||||||
if (view.active().isEmpty() && view.completed().isEmpty()) {
|
if (view.active().isEmpty() && view.completed().isEmpty()) {
|
||||||
return StyledText.of("You have no quests yet.");
|
return StyledText.of("You have no quests yet.");
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.io.text;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record QuestView(List<QuestEntry> active, List<String> completed) {
|
||||||
|
/** Defensively copies both lists, making the snapshot immutable. */
|
||||||
public QuestView {
|
public QuestView {
|
||||||
active = List.copyOf(active);
|
active = List.copyOf(active);
|
||||||
completed = List.copyOf(completed);
|
completed = List.copyOf(completed);
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import java.util.List;
|
|||||||
/** Default, layout-free rendering of high-level views into {@link StyledText}. */
|
/** Default, layout-free rendering of high-level views into {@link StyledText}. */
|
||||||
public final class Renderings {
|
public final class Renderings {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private Renderings() {
|
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) {
|
public static StyledText roomToStyledText(RoomView r) {
|
||||||
StyledText.Builder b = StyledText.builder();
|
StyledText.Builder b = StyledText.builder();
|
||||||
b.heading(r.name()).plain("\n");
|
b.heading(r.name()).plain("\n");
|
||||||
@@ -33,6 +39,13 @@ public final class Renderings {
|
|||||||
return b.build();
|
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) {
|
private static void appendList(StyledText.Builder b, List<String> xs, Style st) {
|
||||||
for (int i = 0; i < xs.size(); i++) {
|
for (int i = 0; i < xs.size(); i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
package thb.jeanluc.adventure.io.text;
|
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) {}
|
public record RoomCell(String id, String name, int x, int y, CellState state) {}
|
||||||
|
|||||||
@@ -2,9 +2,18 @@ package thb.jeanluc.adventure.io.text;
|
|||||||
|
|
||||||
import java.util.List;
|
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,
|
public record RoomView(String name, String description,
|
||||||
List<String> items, List<String> npcs, List<String> exits) {
|
List<String> items, List<String> npcs, List<String> exits) {
|
||||||
|
/** Defensively copies the lists, making the snapshot immutable. */
|
||||||
public RoomView {
|
public RoomView {
|
||||||
items = List.copyOf(items);
|
items = List.copyOf(items);
|
||||||
npcs = List.copyOf(npcs);
|
npcs = List.copyOf(npcs);
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
package thb.jeanluc.adventure.io.text;
|
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) {
|
public record Span(String text, Style style) {
|
||||||
|
/**
|
||||||
|
* Rejects null components.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if {@code text} or {@code style} is null
|
||||||
|
*/
|
||||||
public Span {
|
public Span {
|
||||||
if (text == null) {
|
if (text == null) {
|
||||||
throw new IllegalArgumentException("text must not be null");
|
throw new IllegalArgumentException("text must not be null");
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
package thb.jeanluc.adventure.io.text;
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
|
/** 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,18 +6,30 @@ import java.util.List;
|
|||||||
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
|
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
|
||||||
public final class StyledText {
|
public final class StyledText {
|
||||||
|
|
||||||
|
/** The spans of this text, in output order; unmodifiable. */
|
||||||
private final List<Span> spans;
|
private final List<Span> spans;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param spans the spans to copy into this text
|
||||||
|
*/
|
||||||
private StyledText(List<Span> spans) {
|
private StyledText(List<Span> spans) {
|
||||||
this.spans = List.copyOf(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() {
|
public List<Span> spans() {
|
||||||
return 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() {
|
public String plainText() {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
for (Span s : spans) {
|
for (Span s : spans) {
|
||||||
@@ -26,32 +38,104 @@ public final class StyledText {
|
|||||||
return sb.toString();
|
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) {
|
public static StyledText of(String s) {
|
||||||
return new StyledText(List.of(new Span(s, Style.PLAIN)));
|
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() {
|
public static Builder builder() {
|
||||||
return new Builder();
|
return new Builder();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fluent builder; one method per {@link Style} role. */
|
/** Fluent builder; one method per {@link Style} role. */
|
||||||
public static final class Builder {
|
public static final class Builder {
|
||||||
|
|
||||||
|
/** Spans collected so far, in the order the append methods were called. */
|
||||||
private final List<Span> spans = new ArrayList<>();
|
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) {
|
private Builder add(String text, Style style) {
|
||||||
spans.add(new Span(text, style));
|
spans.add(new Span(text, style));
|
||||||
return this;
|
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); }
|
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); }
|
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); }
|
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); }
|
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); }
|
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); }
|
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); }
|
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() {
|
public StyledText build() {
|
||||||
return new StyledText(spans);
|
return new StyledText(spans);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,19 @@ import java.util.Map;
|
|||||||
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
|
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
|
||||||
public final class CombinationFactory {
|
public final class CombinationFactory {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private CombinationFactory() {
|
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) {
|
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
|
||||||
requireItem(items, dto.a(), "a");
|
requireItem(items, dto.a(), "a");
|
||||||
requireItem(items, dto.b(), "b");
|
requireItem(items, dto.b(), "b");
|
||||||
@@ -41,6 +51,14 @@ public final class CombinationFactory {
|
|||||||
dto.failText());
|
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) {
|
private static void requireItem(Map<String, Item> items, String id, String field) {
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
|
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ import thb.jeanluc.adventure.model.Ending;
|
|||||||
/** Builds {@link Ending} objects from {@link EndingDto}s. */
|
/** Builds {@link Ending} objects from {@link EndingDto}s. */
|
||||||
public final class EndingFactory {
|
public final class EndingFactory {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private EndingFactory() {
|
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) {
|
public static Ending fromDto(EndingDto dto) {
|
||||||
return new Ending(
|
return new Ending(
|
||||||
dto.id(),
|
dto.id(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import thb.jeanluc.adventure.model.item.SwitchableItem;
|
|||||||
*/
|
*/
|
||||||
public final class ItemFactory {
|
public final class ItemFactory {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private ItemFactory() {
|
private ItemFactory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public final class ItemFactory {
|
|||||||
.name(dto.name())
|
.name(dto.name())
|
||||||
.description(dto.description())
|
.description(dto.description())
|
||||||
.light(Boolean.TRUE.equals(dto.light()))
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
|
.fixed(Boolean.TRUE.equals(dto.fixed()))
|
||||||
.build();
|
.build();
|
||||||
case "readable" -> ReadableItem.builder()
|
case "readable" -> ReadableItem.builder()
|
||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
@@ -38,6 +40,7 @@ public final class ItemFactory {
|
|||||||
.description(dto.description())
|
.description(dto.description())
|
||||||
.readText(dto.readText())
|
.readText(dto.readText())
|
||||||
.light(Boolean.TRUE.equals(dto.light()))
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
|
.fixed(Boolean.TRUE.equals(dto.fixed()))
|
||||||
.build();
|
.build();
|
||||||
case "switchable" -> SwitchableItem.builder()
|
case "switchable" -> SwitchableItem.builder()
|
||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
@@ -48,6 +51,7 @@ public final class ItemFactory {
|
|||||||
.offText(dto.offText())
|
.offText(dto.offText())
|
||||||
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
|
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
|
||||||
.light(Boolean.TRUE.equals(dto.light()))
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
|
.fixed(Boolean.TRUE.equals(dto.fixed()))
|
||||||
.build();
|
.build();
|
||||||
default -> throw new WorldLoadException(
|
default -> throw new WorldLoadException(
|
||||||
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Npc;
|
|||||||
*/
|
*/
|
||||||
public final class NpcFactory {
|
public final class NpcFactory {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private NpcFactory() {
|
private NpcFactory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,17 @@ import java.util.List;
|
|||||||
/** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */
|
/** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */
|
||||||
public final class QuestFactory {
|
public final class QuestFactory {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private QuestFactory() {
|
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) {
|
public static Quest fromDto(QuestDto dto) {
|
||||||
List<QuestStage> stages = new ArrayList<>();
|
List<QuestStage> stages = new ArrayList<>();
|
||||||
if (dto.stages() != null) {
|
if (dto.stages() != null) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Room;
|
|||||||
*/
|
*/
|
||||||
public final class RoomFactory {
|
public final class RoomFactory {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private RoomFactory() {
|
private RoomFactory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,18 +22,33 @@ public class TutorialLoader {
|
|||||||
/** Default classpath base directory. */
|
/** Default classpath base directory. */
|
||||||
public static final String DEFAULT_BASE = "/world";
|
public static final String DEFAULT_BASE = "/world";
|
||||||
|
|
||||||
|
/** Single Jackson mapper, configured once for YAML input. */
|
||||||
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
||||||
|
|
||||||
|
/** Classpath base directory (typically {@link #DEFAULT_BASE} or a test override). */
|
||||||
private final String basePath;
|
private final String basePath;
|
||||||
|
|
||||||
|
/** Convenience constructor using {@link #DEFAULT_BASE}. */
|
||||||
public TutorialLoader() {
|
public TutorialLoader() {
|
||||||
this(DEFAULT_BASE);
|
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) {
|
public TutorialLoader(String basePath) {
|
||||||
this.basePath = 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() {
|
public Tutorial load() {
|
||||||
String resource = basePath + "/tutorial.yaml";
|
String resource = basePath + "/tutorial.yaml";
|
||||||
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ public class WorldLoader {
|
|||||||
resolver.resolveRooms(roomDtos);
|
resolver.resolveRooms(roomDtos);
|
||||||
resolver.resolveNpcs(npcDtos);
|
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());
|
Room start = rooms.get(gameDto.startRoom());
|
||||||
int gold = gameDto.startGold() == null ? 0 : gameDto.startGold();
|
int gold = gameDto.startGold() == null ? 0 : gameDto.startGold();
|
||||||
@@ -135,6 +135,15 @@ public class WorldLoader {
|
|||||||
return new LoadResult(world, player);
|
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) {
|
private <T> List<T> readList(String resource, Class<T> elementType) {
|
||||||
try (InputStream in = openResource(resource)) {
|
try (InputStream in = openResource(resource)) {
|
||||||
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
|
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) {
|
private <T> List<T> readListOptional(String resource, Class<T> elementType) {
|
||||||
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
||||||
if (in == null) {
|
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) {
|
private <T> T readSingle(String resource, Class<T> type) {
|
||||||
try (InputStream in = openResource(resource)) {
|
try (InputStream in = openResource(resource)) {
|
||||||
return yaml.readValue(in, type);
|
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) {
|
private InputStream openResource(String resource) {
|
||||||
InputStream in = getClass().getResourceAsStream(resource);
|
InputStream in = getClass().getResourceAsStream(resource);
|
||||||
if (in == null) {
|
if (in == null) {
|
||||||
@@ -174,6 +208,13 @@ public class WorldLoader {
|
|||||||
return in;
|
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) {
|
private void requireUniqueIds(String kind, List<String> ids) {
|
||||||
for (int i = 0; i < ids.size(); i++) {
|
for (int i = 0; i < ids.size(); i++) {
|
||||||
for (int j = i + 1; j < ids.size(); j++) {
|
for (int j = i + 1; j < ids.size(); j++) {
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
package thb.jeanluc.adventure.loader;
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
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.Npc;
|
||||||
|
import thb.jeanluc.adventure.model.Quest;
|
||||||
|
import thb.jeanluc.adventure.model.QuestStage;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
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.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 4 of the loading pipeline. Walks the fully wired world and
|
* Phase 4 of the loading pipeline. Walks the fully wired world and
|
||||||
* checks the invariants documented in {@code yaml-schemas.md}.
|
* 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 {
|
public class WorldValidator {
|
||||||
|
|
||||||
/** Regex every entity id must match. */
|
/** Regex every entity id must match. */
|
||||||
@@ -31,44 +44,216 @@ public class WorldValidator {
|
|||||||
/** Loaded game.yaml whose {@code startRoom} must exist. */
|
/** Loaded game.yaml whose {@code startRoom} must exist. */
|
||||||
private final GameDto gameDto;
|
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() {
|
public void validate() {
|
||||||
validateIds();
|
validateIds();
|
||||||
validateStartRoom();
|
validateStartRoom();
|
||||||
validateGreetings();
|
validateGreetings();
|
||||||
|
validateRequiredText();
|
||||||
|
validateQuests();
|
||||||
|
validateEndings();
|
||||||
|
validateCombinations();
|
||||||
|
|
||||||
|
if (!problems.isEmpty()) {
|
||||||
|
throw new WorldLoadException("Invalid world:\n - " + String.join("\n - ", problems));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateIds() {
|
private void validateIds() {
|
||||||
items.keySet().forEach(this::requireValidId);
|
items.keySet().forEach(this::requireValidId);
|
||||||
npcs.keySet().forEach(this::requireValidId);
|
npcs.keySet().forEach(this::requireValidId);
|
||||||
rooms.keySet().forEach(this::requireValidId);
|
rooms.keySet().forEach(this::requireValidId);
|
||||||
|
quests.keySet().forEach(this::requireValidId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireValidId(String id) {
|
private void requireValidId(String id) {
|
||||||
if (id == null || !ID_PATTERN.matcher(id).matches()) {
|
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() {
|
private void validateStartRoom() {
|
||||||
if (gameDto == null || gameDto.startRoom() == null) {
|
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())) {
|
if (!rooms.containsKey(gameDto.startRoom())) {
|
||||||
throw new WorldLoadException(
|
problems.add("game.yaml.startRoom '" + gameDto.startRoom()
|
||||||
"game.yaml.startRoom '" + gameDto.startRoom() + "' does not match any room");
|
+ "' does not match any room");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateGreetings() {
|
private void validateGreetings() {
|
||||||
for (Npc npc : npcs.values()) {
|
for (Npc npc : npcs.values()) {
|
||||||
if (npc.getGreeting() == null || npc.getGreeting().isBlank()) {
|
if (isBlank(npc.getGreeting())) {
|
||||||
throw new WorldLoadException("NPC '" + npc.getId() + "' has an empty greeting");
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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(
|
public record CombinationDto(
|
||||||
String a,
|
String a,
|
||||||
String b,
|
String b,
|
||||||
|
|||||||
@@ -6,9 +6,21 @@ import thb.jeanluc.adventure.model.Condition;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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) {
|
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() {
|
public Condition toModel() {
|
||||||
if (flag != null) {
|
if (flag != null) {
|
||||||
return new Condition(Condition.Type.FLAG, flag);
|
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");
|
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) {
|
public static List<Condition> toModelList(List<ConditionDto> dtos) {
|
||||||
List<Condition> out = new ArrayList<>();
|
List<Condition> out = new ArrayList<>();
|
||||||
if (dtos != null) {
|
if (dtos != null) {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record DescriptionStateDto(List<ConditionDto> when, String text) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record DialogueLineDto(List<ConditionDto> when, String text) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,38 @@ import thb.jeanluc.adventure.model.Effect;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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,
|
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem,
|
||||||
String say, String startQuest) {
|
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) {
|
public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
|
||||||
this(setFlag, clearFlag, giveItem, removeItem, say, null);
|
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() {
|
public Effect toModel() {
|
||||||
if (setFlag != null) {
|
if (setFlag != null) {
|
||||||
return new Effect(Effect.Type.SET_FLAG, setFlag);
|
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");
|
"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) {
|
public static List<Effect> toModelList(List<EffectDto> dtos) {
|
||||||
List<Effect> out = new ArrayList<>();
|
List<Effect> out = new ArrayList<>();
|
||||||
if (dtos != null) {
|
if (dtos != null) {
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record ExitLockDto(String direction, List<ConditionDto> requires, String blocked) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.util.List;
|
|||||||
* @param offText message printed when a switchable transitions to off
|
* @param offText message printed when a switchable transitions to off
|
||||||
* @param effects effects applied when a switchable transitions to on; nullable
|
* @param effects effects applied when a switchable transitions to on; nullable
|
||||||
* @param light whether this item is a light source; 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(
|
public record ItemDto(
|
||||||
String type,
|
String type,
|
||||||
@@ -27,11 +28,24 @@ public record ItemDto(
|
|||||||
String onText,
|
String onText,
|
||||||
String offText,
|
String offText,
|
||||||
List<EffectDto> effects,
|
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,
|
public ItemDto(String type, String id, String name, String description,
|
||||||
String readText, Boolean initialState, String onText, String offText) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ public record NpcDto(
|
|||||||
List<ReactionDto> reactions,
|
List<ReactionDto> reactions,
|
||||||
List<DialogueLineDto> dialogue
|
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) {
|
public NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) {
|
||||||
this(id, name, description, greeting, reactions, null);
|
this(id, name, description, greeting, reactions, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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,
|
public record QuestDto(String id, String title, Boolean autoStart,
|
||||||
List<QuestStageDto> stages, List<EffectDto> onComplete) {
|
List<QuestStageDto> stages, List<EffectDto> onComplete) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ public record ReactionDto(
|
|||||||
List<ConditionDto> requires,
|
List<ConditionDto> requires,
|
||||||
List<EffectDto> effects
|
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) {
|
public ReactionDto(String onReceive, String response, String gives, String consumes) {
|
||||||
this(onReceive, response, gives, consumes, null, null);
|
this(onReceive, response, gives, consumes, null, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,16 @@ public record RoomDto(
|
|||||||
Boolean dark,
|
Boolean dark,
|
||||||
String music
|
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,
|
public RoomDto(String id, String name, String description,
|
||||||
Map<String, String> exits, List<String> items, List<String> npcs) {
|
Map<String, String> exits, List<String> items, List<String> npcs) {
|
||||||
this(id, name, description, exits, items, npcs, null, null, null, null);
|
this(id, name, description, exits, items, npcs, null, null, null, null);
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
package thb.jeanluc.adventure.loader.dto;
|
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(
|
public record TutorialStepDto(
|
||||||
String instruction,
|
String instruction,
|
||||||
String expect,
|
String expect,
|
||||||
|
|||||||
@@ -10,17 +10,29 @@ import java.util.List;
|
|||||||
/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */
|
/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */
|
||||||
public final class MainMenu {
|
public final class MainMenu {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private MainMenu() {
|
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) {
|
public static MenuAction show(GameIO io) {
|
||||||
int i = io.choose("HAUNTED MANOR",
|
int i = io.choose("HAUNTED MANOR",
|
||||||
List.of("New Game", "Load Game", "Settings", "Quit"));
|
List.of("New Game", "Load Game", "Settings", "Quit"));
|
||||||
return MenuAction.values()[i];
|
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) {
|
public static String promptName(GameIO io, String defaultName) {
|
||||||
io.write("Name your save (Enter for \"" + defaultName + "\"):");
|
io.write("Name your save (Enter for \"" + defaultName + "\"):");
|
||||||
String line = io.readLine();
|
String line = io.readLine();
|
||||||
@@ -31,6 +43,10 @@ public final class MainMenu {
|
|||||||
* Lets the player pick a save slot to load. Returns the chosen
|
* Lets the player pick a save slot to load. Returns the chosen
|
||||||
* {@link SaveSlotInfo}, or {@code null} if there are none or the player
|
* {@link SaveSlotInfo}, or {@code null} if there are none or the player
|
||||||
* picks "Back".
|
* 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) {
|
public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) {
|
||||||
List<SaveSlotInfo> slots = saves.list();
|
List<SaveSlotInfo> slots = saves.list();
|
||||||
|
|||||||
@@ -2,5 +2,16 @@ package thb.jeanluc.adventure.menu;
|
|||||||
|
|
||||||
/** Top-level main-menu choices, in display order. */
|
/** Top-level main-menu choices, in display order. */
|
||||||
public enum MenuAction {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ import java.util.List;
|
|||||||
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
|
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
|
||||||
public final class SettingsMenu {
|
public final class SettingsMenu {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
private SettingsMenu() {
|
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) {
|
public static Settings show(GameIO io, Settings current, SettingsStore store) {
|
||||||
Settings s = current;
|
Settings s = current;
|
||||||
while (true) {
|
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) {
|
public static void apply(Settings s, GameIO io) {
|
||||||
io.setMusicLevel(s.musicLevel());
|
io.setMusicLevel(s.musicLevel());
|
||||||
if (io instanceof ConsoleIO c) {
|
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) {
|
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
|
||||||
return switch (m) {
|
return switch (m) {
|
||||||
case AUTO -> ConsoleIO.ColorMode.ON;
|
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) {
|
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
|
||||||
return switch (m) {
|
return switch (m) {
|
||||||
case UNICODE -> ConsoleIO.GlyphMode.ASCII;
|
case UNICODE -> ConsoleIO.GlyphMode.ASCII;
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import java.util.List;
|
|||||||
* A data-driven item-combination recipe. Identified by an unordered pair of
|
* A data-driven item-combination recipe. Identified by an unordered pair of
|
||||||
* item ids ({@code a}, {@code b}). When applied it may require conditions,
|
* item ids ({@code a}, {@code b}). When applied it may require conditions,
|
||||||
* consume items, produce an item, apply effects, and print a response.
|
* 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(
|
public record Combination(
|
||||||
String a,
|
String a,
|
||||||
@@ -17,18 +26,29 @@ public record Combination(
|
|||||||
String response,
|
String response,
|
||||||
String failText
|
String failText
|
||||||
) {
|
) {
|
||||||
|
/** Normalises the list components to immutable, non-null lists. */
|
||||||
public Combination {
|
public Combination {
|
||||||
requires = requires == null ? List.of() : List.copyOf(requires);
|
requires = requires == null ? List.of() : List.copyOf(requires);
|
||||||
consume = consume == null ? List.of() : List.copyOf(consume);
|
consume = consume == null ? List.of() : List.copyOf(consume);
|
||||||
effects = effects == null ? List.of() : List.copyOf(effects);
|
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) {
|
public static String key(String x, String y) {
|
||||||
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
|
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() {
|
public String key() {
|
||||||
return key(a, b);
|
return key(a, b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
package thb.jeanluc.adventure.model;
|
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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record DescriptionState(List<Condition> when, String text) {
|
||||||
|
/** Normalises {@code when} to an immutable, non-null list. */
|
||||||
public DescriptionState {
|
public DescriptionState {
|
||||||
when = when == null ? List.of() : List.copyOf(when);
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record DialogueLine(List<Condition> when, String text) {
|
||||||
|
/** Normalises {@code when} to an immutable, non-null list. */
|
||||||
public DialogueLine {
|
public DialogueLine {
|
||||||
when = when == null ? List.of() : List.copyOf(when);
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.model;
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compass directions used for navigating between rooms.
|
* Compass directions used for navigating between rooms.
|
||||||
* Each direction has an opposite, which is used when describing
|
* 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).
|
* entering the next room from the SOUTH).
|
||||||
*/
|
*/
|
||||||
public enum Direction {
|
public enum Direction {
|
||||||
|
/** Towards the top of the map; opposite of {@link #SOUTH}. */
|
||||||
NORTH,
|
NORTH,
|
||||||
|
/** Towards the bottom of the map; opposite of {@link #NORTH}. */
|
||||||
SOUTH,
|
SOUTH,
|
||||||
|
/** Towards the right of the map; opposite of {@link #WEST}. */
|
||||||
EAST,
|
EAST,
|
||||||
|
/** Towards the left of the map; opposite of {@link #EAST}. */
|
||||||
WEST;
|
WEST;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,13 +44,40 @@ public enum Direction {
|
|||||||
if (s == null) {
|
if (s == null) {
|
||||||
throw new IllegalArgumentException("Direction must not be 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 {
|
try {
|
||||||
return Direction.valueOf(s.trim().toUpperCase());
|
return Direction.valueOf(key.toUpperCase());
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw new IllegalArgumentException("Unknown direction: '" + s + "'");
|
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
|
* Returns the human-readable lowercase label, suitable for display
|
||||||
* to the player (e.g. "north"). Intentionally not a toString override
|
* to the player (e.g. "north"). Intentionally not a toString override
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
package thb.jeanluc.adventure.model;
|
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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
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 {
|
public Ending {
|
||||||
when = when == null ? List.of() : List.copyOf(when);
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record ExitLock(List<Condition> requires, String blocked) {
|
||||||
|
/** Normalises {@code requires} to an immutable, non-null list. */
|
||||||
public ExitLock {
|
public ExitLock {
|
||||||
requires = requires == null ? List.of() : List.copyOf(requires);
|
requires = requires == null ? List.of() : List.copyOf(requires);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import java.util.List;
|
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,
|
public record Quest(String id, String title, boolean autoStart,
|
||||||
List<QuestStage> stages, List<Effect> onComplete) {
|
List<QuestStage> stages, List<Effect> onComplete) {
|
||||||
|
/** Normalises the list components to immutable, non-null lists. */
|
||||||
public Quest {
|
public Quest {
|
||||||
stages = stages == null ? List.of() : List.copyOf(stages);
|
stages = stages == null ? List.of() : List.copyOf(stages);
|
||||||
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import java.util.List;
|
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) {
|
public record QuestStage(String objective, List<Condition> completion, List<Effect> onComplete) {
|
||||||
|
/** Normalises the list components to immutable, non-null lists. */
|
||||||
public QuestStage {
|
public QuestStage {
|
||||||
completion = completion == null ? List.of() : List.copyOf(completion);
|
completion = completion == null ? List.of() : List.copyOf(completion);
|
||||||
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
||||||
|
|||||||
@@ -147,4 +147,14 @@ public class Room {
|
|||||||
public Optional<Npc> findNpc(String npcId) {
|
public Optional<Npc> findNpc(String npcId) {
|
||||||
return Optional.ofNullable(npcs.get(npcId));
|
return Optional.ofNullable(npcs.get(npcId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an NPC from this room by id.
|
||||||
|
*
|
||||||
|
* @param npcId id of the NPC to remove
|
||||||
|
* @return the removed NPC, or empty if no such NPC was present
|
||||||
|
*/
|
||||||
|
public Optional<Npc> removeNpc(String npcId) {
|
||||||
|
return Optional.ofNullable(npcs.remove(npcId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user