Compare commits
134 Commits
3ae2dc2175
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d7f91c2e47 | |||
| c205d1f49b | |||
| 526e3d4ee7 | |||
| 1b39eac90e | |||
| 4cf1b42254 | |||
| 73fc6eab68 | |||
| 8f6ee80f30 | |||
| cc2221121d | |||
| 285e68055e | |||
| 65a8935e32 | |||
| e5c87015fa | |||
| 8e63f21c7d | |||
| 41c79c3b96 | |||
| f8f7eb90f0 | |||
| 675bf4c2ea | |||
| d0966c3ce4 | |||
| 1cbf606bd4 | |||
| 7ea11058a1 | |||
| aa802582ec | |||
| d8e1a662bc | |||
| 88cad77c83 | |||
| 702f7ac0d4 | |||
| 830a95c9f0 | |||
| f1aedebfcb | |||
| 7380dcae41 | |||
| c6ed199f7e | |||
| bf36730524 | |||
| fa7851b6e0 | |||
| 66e43830be | |||
| 977872e77a | |||
| 3dd27a1b63 | |||
| e7db45f98d | |||
| 705e515a8e | |||
| 00d3f97c9b | |||
| ebeda36b4a | |||
| 1ff475c085 | |||
| 236ed0a180 | |||
| c315d3f4b7 | |||
| e27a12e4fe | |||
| af319508f6 | |||
| dbfdf86810 | |||
| 91f79b1537 | |||
| 0250eadd50 | |||
| 0af172ff64 | |||
| d20f3fd7d6 | |||
| 97e2809719 | |||
| f1b9695c5e | |||
| 62b7663e34 | |||
| 86e138223a | |||
| e0e8612982 | |||
| 3711ef5a67 | |||
| 4ab5ad9d42 | |||
| 8257eb0c02 | |||
| 9152aded4b | |||
| 6eabebff00 | |||
| ae7f9d6b05 | |||
| 2747659800 | |||
| 100b54c4d1 | |||
| c91bc3afea | |||
| 022973dc75 | |||
| cf592edc57 | |||
| b41ac80194 | |||
| 1d0cbc25e4 | |||
| 182a6911a0 | |||
| 88e959adee | |||
| 98b1561ef2 | |||
| 361fd0939c | |||
| 4604952f63 | |||
| 084b39e501 | |||
| 0f05680170 | |||
| e4238e4553 | |||
| 687e88e20a | |||
| d847928587 | |||
| 206fc758a5 | |||
| ed41418434 | |||
| 6a68d7e2f5 | |||
| 3c7c9d2ad4 | |||
| 3be1036142 | |||
| c92c143058 | |||
| 310ee65285 | |||
| c392f80ed0 | |||
| 58352087c4 | |||
| 18336ecc44 | |||
| d34104f928 | |||
| 4ed1fa1010 | |||
| 569cef20fe | |||
| c3d80f86d7 | |||
| d7edc78882 | |||
| b23f706521 | |||
| 2238aa6ff5 | |||
| 3ec540e41b | |||
| c652c33a9a | |||
| 1b22c996e1 | |||
| e3fb0c9fa6 | |||
| 310d476264 | |||
| a3600eadd7 | |||
| 513c7cb8d9 | |||
| dd16d1326b | |||
| eeee609c26 | |||
| a0b80f2a4b | |||
| 00bad46661 | |||
| 258ab4f63b | |||
| 268c24d4e3 | |||
| c9c019cea1 | |||
| df4a46ae5b | |||
| 228efaffcb | |||
| 5546b63d47 | |||
| 9b28e2927e | |||
| 8ab39cd535 | |||
| e80c6b65bb | |||
| f6f62b66d8 | |||
| f1ec321e50 | |||
| 5ebbd2ce42 | |||
| 7948aa2088 | |||
| a0560a6ec5 | |||
| fcf8083b7c | |||
| 9ce939167c | |||
| c03123d5ae | |||
| e10ff2cf99 | |||
| 42dbcd9696 | |||
| f1b83f1f81 | |||
| 13f34e1694 | |||
| b042d7e0ef | |||
| 9316f67c31 | |||
| d3a9bd06cc | |||
| 650bbbe58c | |||
| 963ddfb89e | |||
| 5f9d52ca41 | |||
| 9274eef4c0 | |||
| 64cc654803 | |||
| 44fde78c64 | |||
| 02e448d61a | |||
| d57a1ab9a4 | |||
| 230177dfb5 |
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
|
||||||
|
|||||||
11
Semesterprojekt/.gitignore
vendored
11
Semesterprojekt/.gitignore
vendored
@@ -38,8 +38,13 @@ build/
|
|||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
### Brainstorming visual companion ###
|
|
||||||
.superpowers/
|
|
||||||
|
|
||||||
# Local save games and settings
|
# Local save games and settings
|
||||||
saves/
|
saves/
|
||||||
|
|
||||||
|
# Source audio (MP3 originals); only the converted .ogg under
|
||||||
|
# src/main/resources/music/ is tracked and bundled. Anchored so it
|
||||||
|
# ignores the top-level source folder but not the resources path.
|
||||||
|
/music/
|
||||||
|
|
||||||
|
# Built jars (kept on disk for the submission ZIP, not tracked)
|
||||||
|
release/*.jar
|
||||||
|
|||||||
3
Semesterprojekt/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
Semesterprojekt/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||||
@@ -1,95 +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+
|
Sie Weg A – dafür wird kein Maven benötigt.
|
||||||
|
|
||||||
## Bauen und Testen
|
### Voraussetzung
|
||||||
|
|
||||||
|
**Java 21 oder neuer** (LTS). Gebaut wird gegen `maven.compiler.release=21`;
|
||||||
|
das neueste genutzte Sprachfeature ist `List.getFirst()` (SequencedCollection,
|
||||||
|
Java 21). Neuere JDKs (22–25) funktionieren ebenfalls.
|
||||||
|
|
||||||
|
Prüfen mit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn clean test # alle Tests ausführen (67 Tests)
|
java -version
|
||||||
mvn clean package # JAR bauen
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Spielen
|
### Weg A – fertige JARs starten (ohne Maven)
|
||||||
|
|
||||||
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
|
Im Ordner [`release/`](release/) liegen zwei eigenständige JARs, die alle
|
||||||
|
Abhängigkeiten bereits enthalten. Es wird **nur Java** gebraucht, kein Maven,
|
||||||
|
keine Internetverbindung:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
|
java -jar release/HauntedManor-Console.jar # Konsolen-Version (~5 MB)
|
||||||
mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
java -jar release/HauntedManor-GUI.jar # Swing-GUI (~17 MB)
|
||||||
```
|
```
|
||||||
|
|
||||||
`mvn exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
|
- 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.
|
||||||
|
|
||||||
### Befehle im Spiel
|
### Weg B – mit Maven bauen und starten
|
||||||
|
|
||||||
| Befehl | Aliase | Wirkung |
|
Der mitgelieferte **Maven Wrapper** (`./mvnw`) lädt die passende Maven-Version
|
||||||
|
beim ersten Aufruf selbst herunter – eine separate Maven-Installation ist nicht
|
||||||
|
nötig. Unter Windows `mvnw.cmd` statt `./mvnw` verwenden. Ein systemweit
|
||||||
|
installiertes `mvn` funktioniert identisch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw clean test # alle 259 Tests ausführen
|
||||||
|
./mvnw clean package # baut beide JARs neu nach release/
|
||||||
|
|
||||||
|
./mvnw exec:java@run # Konsolen-Version starten (thb.jeanluc.adventure.App)
|
||||||
|
./mvnw exec:java@gui # Swing-GUI starten (thb.jeanluc.adventure.AppGui)
|
||||||
|
```
|
||||||
|
|
||||||
|
`./mvnw exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
|
||||||
|
|
||||||
|
> **Hinweis:** Der erste Maven-Aufruf lädt die Abhängigkeiten (Jackson, Lombok,
|
||||||
|
> JUnit, Logback) aus dem Internet. Wer offline ist, nimmt Weg A.
|
||||||
|
|
||||||
|
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,215 +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).
|
|
||||||
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)
|
|
||||||
|
|
||||||
- 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** (BFS `go to <raum>`, Trie-Autocomplete) bleibt für eine
|
|
||||||
> spätere Runde im Scope.
|
|
||||||
|
|
||||||
| 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.
|
|
||||||
32
Semesterprojekt/docs/music.md
Normal file
32
Semesterprojekt/docs/music.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Background music (GUI)
|
||||||
|
|
||||||
|
Background music plays only in the Swing GUI. It is **data-driven and bundled**:
|
||||||
|
|
||||||
|
- Tracks live as `.ogg` (OGG Vorbis) files in `src/main/resources/music/`, so they
|
||||||
|
are on the classpath and ship inside the JAR — the game plays music out of the box,
|
||||||
|
with no external folder to set up.
|
||||||
|
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
|
||||||
|
`music: manor-theme.ogg`. Entering a room with a different track fades the old one
|
||||||
|
out and the new one in (sequential, not an overlapping cross-fade);
|
||||||
|
a room with no `music:` field fades to silence; the same track keeps playing
|
||||||
|
seamlessly across rooms.
|
||||||
|
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
|
||||||
|
persisted to `saves/settings.json`. Off disables playback.
|
||||||
|
- Missing tracks are ignored (silent) — the game never crashes over absent audio.
|
||||||
|
- The console version is always silent.
|
||||||
|
|
||||||
|
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI;
|
||||||
|
`OggMusicBackend` streams each track from the classpath (`/music/<track>`) on a
|
||||||
|
daemon thread.
|
||||||
|
|
||||||
|
## Adding or replacing a track
|
||||||
|
|
||||||
|
1. Convert your source audio to OGG Vorbis. With ffmpeg:
|
||||||
|
`ffmpeg -i in.mp3 -c:a libvorbis -q:a 5 out.ogg`
|
||||||
|
or with gstreamer (no ffmpeg needed):
|
||||||
|
`gst-launch-1.0 filesrc location=in.mp3 ! decodebin ! audioconvert ! audioresample ! vorbisenc quality=0.5 ! oggmux ! filesink location=out.ogg`
|
||||||
|
2. Drop the `.ogg` into `src/main/resources/music/`.
|
||||||
|
3. Reference it from a room's `music:` field in `rooms.yaml`.
|
||||||
|
|
||||||
|
The four tracks shipped today: `manor-theme.ogg` (ground floor),
|
||||||
|
`cellar-drone.ogg` (cellar), `musicbox.ogg` (upper floor), `lament.ogg` (chapel).
|
||||||
@@ -1,458 +0,0 @@
|
|||||||
# Win-Condition & Endings Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Priority-ordered, condition-driven endings with an end-of-game summary; the game ends when an ending fires.
|
|
||||||
|
|
||||||
**Architecture:** `Ending` is data (condition + text), loaded from optional `endings.yaml` into an ordered `World.endings`. `EndingEngine.triggered(ctx)` returns the first matching ending; `Game` checks it each turn after the quest tick, prints the ending + summary, and stops. Reuses the existing condition engine and quest log.
|
|
||||||
|
|
||||||
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok, Jackson/SnakeYAML.
|
|
||||||
|
|
||||||
Spec: [docs/superpowers/specs/2026-05-31-endings-design.md](../specs/2026-05-31-endings-design.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Ending model, DTO/factory, World.endings, loading
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `model/Ending.java`, `loader/dto/EndingDto.java`, `loader/EndingFactory.java`
|
|
||||||
- Modify: `model/World.java`, `loader/WorldLoader.java`
|
|
||||||
- Test: `loader/EndingLoadingTest.java`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create `model/Ending.java`**
|
|
||||||
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.model;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** A game ending: shown (and ends the game) when its conditions hold. */
|
|
||||||
public record Ending(String id, String title, boolean victory, List<Condition> when, String text) {
|
|
||||||
public Ending {
|
|
||||||
when = when == null ? List.of() : List.copyOf(when);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `endings` to `World.java` with back-compat constructors**
|
|
||||||
|
|
||||||
Add import:
|
|
||||||
```java
|
|
||||||
import java.util.List;
|
|
||||||
```
|
|
||||||
Add the field after `quests`:
|
|
||||||
```java
|
|
||||||
/** Ordered list of endings (first matching one wins). */
|
|
||||||
private final List<Ending> endings;
|
|
||||||
```
|
|
||||||
Replace the existing back-compat constructor with two that both delegate to the full constructor:
|
|
||||||
```java
|
|
||||||
/** Backward-compatible constructor for worlds without quests or endings. */
|
|
||||||
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
|
||||||
String title, String welcomeMessage) {
|
|
||||||
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Backward-compatible constructor for worlds with quests but no endings. */
|
|
||||||
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
|
||||||
String title, String welcomeMessage, Map<String, Quest> quests) {
|
|
||||||
this(rooms, items, npcs, title, welcomeMessage, quests, List.of());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
(The 7-arg constructor is the Lombok-generated `@RequiredArgsConstructor` over all final fields in declaration order: rooms, items, npcs, title, welcomeMessage, quests, endings.)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create `loader/dto/EndingDto.java`**
|
|
||||||
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.loader.dto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** YAML representation of a game ending. */
|
|
||||||
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create `loader/EndingFactory.java`**
|
|
||||||
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.loader;
|
|
||||||
|
|
||||||
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
|
||||||
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
|
||||||
|
|
||||||
/** Builds {@link Ending} objects from {@link EndingDto}s. */
|
|
||||||
public final class EndingFactory {
|
|
||||||
|
|
||||||
private EndingFactory() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Ending fromDto(EndingDto dto) {
|
|
||||||
return new Ending(
|
|
||||||
dto.id(),
|
|
||||||
dto.title(),
|
|
||||||
Boolean.TRUE.equals(dto.victory()),
|
|
||||||
ConditionDto.toModelList(dto.when()),
|
|
||||||
dto.text());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Load `endings.yaml` in `WorldLoader.java`**
|
|
||||||
|
|
||||||
Add imports:
|
|
||||||
```java
|
|
||||||
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
|
||||||
```
|
|
||||||
After the `questDtos` read, add:
|
|
||||||
```java
|
|
||||||
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
|
||||||
```
|
|
||||||
After the quest `requireUniqueIds`, add:
|
|
||||||
```java
|
|
||||||
requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());
|
|
||||||
```
|
|
||||||
Build an **ordered** ending list (preserve YAML order) next to the quest map build:
|
|
||||||
```java
|
|
||||||
List<Ending> endings = new ArrayList<>();
|
|
||||||
for (EndingDto dto : endingDtos) {
|
|
||||||
endings.add(EndingFactory.fromDto(dto));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Add the import for `ArrayList` if missing:
|
|
||||||
```java
|
|
||||||
import java.util.ArrayList;
|
|
||||||
```
|
|
||||||
Change the `World` construction to the 7-arg form:
|
|
||||||
```java
|
|
||||||
World world = new World(rooms, items, npcs,
|
|
||||||
gameDto.title(), gameDto.welcomeMessage(), quests, endings);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Write the loading test**
|
|
||||||
|
|
||||||
`src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java`:
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.loader;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
|
||||||
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class EndingLoadingTest {
|
|
||||||
@Test
|
|
||||||
void factoryMapsFields() {
|
|
||||||
Ending e = EndingFactory.fromDto(new EndingDto("victory", "Won", true,
|
|
||||||
List.of(new ConditionDto("manor_secured", null, null)), "You win."));
|
|
||||||
assertThat(e.id()).isEqualTo("victory");
|
|
||||||
assertThat(e.victory()).isTrue();
|
|
||||||
assertThat(e.when()).hasSize(1);
|
|
||||||
assertThat(e.text()).isEqualTo("You win.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run tests**
|
|
||||||
|
|
||||||
Run: `mvn -q test -Dtest=EndingLoadingTest`
|
|
||||||
Expected: PASS.
|
|
||||||
Run: `mvn -q test`
|
|
||||||
Expected: PASS — the real `endings.yaml` does not exist yet, so `readListOptional` returns empty; existing `new World(...)` callers compile via the back-compat constructors.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/main/java/thb/jeanluc/adventure/model/Ending.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/loader/dto/EndingDto.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/loader/EndingFactory.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/model/World.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \
|
|
||||||
src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java
|
|
||||||
git commit -m "feat: Ending model + endings.yaml loading (ordered, optional)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: EndingEngine + Game wiring
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `game/EndingEngine.java`
|
|
||||||
- Modify: `game/Game.java`
|
|
||||||
- Test: `game/EndingEngineTest.java`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test**
|
|
||||||
|
|
||||||
`src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java`:
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.game;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import thb.jeanluc.adventure.io.TestIO;
|
|
||||||
import thb.jeanluc.adventure.model.Condition;
|
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
|
||||||
import thb.jeanluc.adventure.model.Player;
|
|
||||||
import thb.jeanluc.adventure.model.Room;
|
|
||||||
import thb.jeanluc.adventure.model.World;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class EndingEngineTest {
|
|
||||||
private GameContext ctx(List<Ending> endings) {
|
|
||||||
Player p = new Player(new Room("k", "K", "d"), 0);
|
|
||||||
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), endings);
|
|
||||||
return new GameContext(w, p, new TestIO());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Ending ending(String id, boolean victory, String flag) {
|
|
||||||
return new Ending(id, id + "-title", victory,
|
|
||||||
List.of(new Condition(Condition.Type.FLAG, flag)), id + " text");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void firstMatchingEndingWinsByOrder() {
|
|
||||||
GameContext ctx = ctx(List.of(ending("victory", true, "won"), ending("fled", false, "fled")));
|
|
||||||
ctx.getState().set("won");
|
|
||||||
ctx.getState().set("fled");
|
|
||||||
assertThat(EndingEngine.triggered(ctx).id()).isEqualTo("victory");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void noEndingWhenNoConditionHolds() {
|
|
||||||
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
|
|
||||||
assertThat(EndingEngine.triggered(ctx)).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void renderIncludesTitleTextAndSummary() {
|
|
||||||
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
|
|
||||||
ctx.getState().set("won");
|
|
||||||
String out = EndingEngine.render(EndingEngine.triggered(ctx), ctx, 7).plainText();
|
|
||||||
assertThat(out).contains("victory-title").contains("victory text");
|
|
||||||
assertThat(out).contains("Turns: 7").contains("Quests completed: 0 / 0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to verify failure**
|
|
||||||
|
|
||||||
Run: `mvn -q test -Dtest=EndingEngineTest`
|
|
||||||
Expected: FAIL — `EndingEngine` missing.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create `game/EndingEngine.java`**
|
|
||||||
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.game;
|
|
||||||
|
|
||||||
import thb.jeanluc.adventure.io.text.StyledText;
|
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
|
||||||
|
|
||||||
/** Detects the first matching ending and renders the end-of-game screen. */
|
|
||||||
public final class EndingEngine {
|
|
||||||
|
|
||||||
private EndingEngine() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** First ending whose conditions hold, in list order; null if none. */
|
|
||||||
public static Ending triggered(GameContext ctx) {
|
|
||||||
for (Ending e : ctx.getWorld().getEndings()) {
|
|
||||||
if (Conditions.all(e.when(), ctx)) {
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds the ending screen: title banner, text, and a run summary. */
|
|
||||||
public static StyledText render(Ending e, GameContext ctx, int turns) {
|
|
||||||
int total = ctx.getWorld().getQuests().size();
|
|
||||||
int done = ctx.getQuestLog().completed().size();
|
|
||||||
String bar = "═".repeat(Math.max(12, e.title().length() + 6));
|
|
||||||
|
|
||||||
StyledText.Builder b = StyledText.builder();
|
|
||||||
b.heading(bar + "\n").heading(" " + e.title() + "\n").heading(bar + "\n");
|
|
||||||
b.plain(e.text().stripTrailing()).plain("\n\n");
|
|
||||||
b.dim("Turns: " + turns + "\n");
|
|
||||||
b.dim("Quests completed: " + done + " / " + total + "\n");
|
|
||||||
b.heading("Rank: " + rank(e, total, done));
|
|
||||||
return b.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String rank(Ending e, int total, int done) {
|
|
||||||
if (e.victory() && total > 0 && done == total) {
|
|
||||||
return "Master of the Manor";
|
|
||||||
}
|
|
||||||
if (e.victory()) {
|
|
||||||
return "Manor Reclaimed";
|
|
||||||
}
|
|
||||||
return "Escaped — the manor keeps its secrets";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Wire endings into `Game.java`**
|
|
||||||
|
|
||||||
In `Game.java` add a `maybeEnd()` method and call it after each `publishHud()`. Add the import:
|
|
||||||
```java
|
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
|
||||||
```
|
|
||||||
(`EndingEngine` is in the same `game` package — no import.) Update `run()` so it calls `maybeEnd()` after the initial publish and after the per-iteration publish:
|
|
||||||
```java
|
|
||||||
public void run() {
|
|
||||||
publishHud();
|
|
||||||
maybeEnd();
|
|
||||||
while (running) {
|
|
||||||
String input = ctx.getIo().readLine();
|
|
||||||
if (input == null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
ParsedCommand parsed = parser.parse(input);
|
|
||||||
if (parsed.verb().isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Optional<Command> cmd = registry.find(parsed.verb());
|
|
||||||
if (cmd.isEmpty()) {
|
|
||||||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
|
||||||
} else {
|
|
||||||
cmd.get().execute(ctx, parsed.args());
|
|
||||||
turn++;
|
|
||||||
}
|
|
||||||
publishHud();
|
|
||||||
maybeEnd();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void maybeEnd() {
|
|
||||||
Ending e = EndingEngine.triggered(ctx);
|
|
||||||
if (e != null) {
|
|
||||||
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run tests**
|
|
||||||
|
|
||||||
Run: `mvn -q test -Dtest=EndingEngineTest`
|
|
||||||
Expected: PASS.
|
|
||||||
Run: `mvn -q test`
|
|
||||||
Expected: PASS — `GameTest`'s worlds have no endings (empty list), so `maybeEnd()` is a no-op there.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/main/java/thb/jeanluc/adventure/game/EndingEngine.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/game/Game.java \
|
|
||||||
src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java
|
|
||||||
git commit -m "feat(game): EndingEngine + per-turn end check with summary"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Demo endings + flee door + docs + verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/main/resources/world/endings.yaml`
|
|
||||||
- Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `docs/enhancement-ideas.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create `src/main/resources/world/endings.yaml`**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- id: victory
|
|
||||||
title: "The Manor Reclaimed"
|
|
||||||
victory: true
|
|
||||||
when: [{ flag: manor_secured }]
|
|
||||||
text: |
|
|
||||||
The lights hold steady and the whispers fade to nothing. Whatever held
|
|
||||||
this place has loosened its grip. The manor is yours now.
|
|
||||||
- id: fled
|
|
||||||
title: "Into the Night"
|
|
||||||
victory: false
|
|
||||||
when: [{ flag: fled }]
|
|
||||||
text: |
|
|
||||||
You wrench the front door open and bolt into the dark. Safe — but the
|
|
||||||
manor keeps its secrets, and they will keep you awake for years.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add a "front door" switch to `items.yaml`**
|
|
||||||
|
|
||||||
Append:
|
|
||||||
```yaml
|
|
||||||
- id: front_door
|
|
||||||
type: switchable
|
|
||||||
name: Front Door
|
|
||||||
description: The heavy front door. It would let you leave the manor for good.
|
|
||||||
state: false
|
|
||||||
onText: |
|
|
||||||
You haul the front door open and step out into the cold night.
|
|
||||||
offText: |
|
|
||||||
You ease the door shut again.
|
|
||||||
effects:
|
|
||||||
- { setFlag: fled }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Place the door in the kitchen (`rooms.yaml`)**
|
|
||||||
|
|
||||||
Change the kitchen's items line from:
|
|
||||||
```yaml
|
|
||||||
items: [letter, lamp]
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```yaml
|
|
||||||
items: [letter, lamp, front_door]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Mark 3.3 done in `docs/enhancement-ideas.md`**
|
|
||||||
|
|
||||||
Under the `### 5. Quest-System (NPC-Ausbau)` status block, append:
|
|
||||||
```markdown
|
|
||||||
> ✅ #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.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Full suite + end-to-end console**
|
|
||||||
|
|
||||||
Run: `mvn -q test`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
Victory path:
|
|
||||||
```bash
|
|
||||||
printf 'go south\nuse generator\ngo north\ntake lamp\ngive lamp old_man\n' | mvn -q -DskipTests exec:java@run
|
|
||||||
```
|
|
||||||
Expected: completing the `restore_power` quest sets `manor_secured`; on the next turn the game prints the **"The Manor Reclaimed"** ending banner, the summary (`Turns: …`, `Quests completed: 1 / 1`, `Rank: Master of the Manor`), and exits — no `quit` needed.
|
|
||||||
|
|
||||||
Flee path:
|
|
||||||
```bash
|
|
||||||
printf 'use front_door\n' | mvn -q -DskipTests exec:java@run
|
|
||||||
```
|
|
||||||
Expected: the **"Into the Night"** ending + summary (`Rank: Escaped …`), then exit.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/main/resources/world/endings.yaml \
|
|
||||||
src/main/resources/world/items.yaml \
|
|
||||||
src/main/resources/world/rooms.yaml \
|
|
||||||
docs/enhancement-ideas.md
|
|
||||||
git commit -m "feat(content): victory & flee endings; front-door flee switch"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review notes
|
|
||||||
|
|
||||||
- **Spec coverage:** model+loader (T1), engine+Game wiring+summary (T2), demo+docs (T3). Scoreboards/turn-conditions deferred.
|
|
||||||
- **Backward compatibility:** `World` gains 5- and 6-arg back-compat constructors delegating to the 7-arg; existing `new World(...)` callers (tests, prior loader pattern) compile unchanged. `endings.yaml` optional. `GameTest` worlds have empty endings → `maybeEnd()` no-op.
|
|
||||||
- **Type consistency:** `EndingEngine.triggered(ctx)`/`render(Ending, ctx, int)`, `Ending.when/victory/title/text`, `World.getEndings`, `EndingFactory.fromDto`, `EndingDto` used consistently.
|
|
||||||
- **Layering:** `Ending` (model) carries no `game` dependency; `EndingEngine` (game) depends on model + io.text.
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
# Light & Darkness Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Dark rooms you can't enter, see, or interact with unless you carry a lit light source (the lamp).
|
|
||||||
|
|
||||||
**Architecture:** A `Room.dark` flag and an `Item.light` flag, plus one `Light` helper (`isLit`/`carryingLight`). Four commands gate on it (`go` entry, `look`, `take`, `examine`); the HUD's `light` field is wired to it. Reuses the switchable lamp and is fully backward-compatible (optional flags).
|
|
||||||
|
|
||||||
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok.
|
|
||||||
|
|
||||||
Spec: [docs/superpowers/specs/2026-05-31-light-darkness-design.md](../specs/2026-05-31-light-darkness-design.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Data flags + `Light` helper
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `model/item/Item.java`, `loader/dto/ItemDto.java`, `loader/ItemFactory.java`, `model/Room.java`, `loader/dto/RoomDto.java`, `loader/RoomFactory.java`
|
|
||||||
- Create: `game/Light.java`
|
|
||||||
- Test: `game/LightTest.java`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test**
|
|
||||||
|
|
||||||
`src/test/java/thb/jeanluc/adventure/game/LightTest.java`:
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.game;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import thb.jeanluc.adventure.io.TestIO;
|
|
||||||
import thb.jeanluc.adventure.model.Player;
|
|
||||||
import thb.jeanluc.adventure.model.Room;
|
|
||||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
|
||||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class LightTest {
|
|
||||||
private GameContext ctx(Player p) {
|
|
||||||
return new GameContext(null, p, new TestIO());
|
|
||||||
}
|
|
||||||
|
|
||||||
private SwitchableItem lamp(boolean on) {
|
|
||||||
return SwitchableItem.builder().id("lamp").name("Lamp").description("d")
|
|
||||||
.state(on).onText("on").offText("off").light(true).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void litRoomIsAlwaysLit() {
|
|
||||||
Room bright = new Room("r", "R", "d"); // dark defaults to false
|
|
||||||
assertThat(Light.isLit(ctx(new Player(bright, 0)), bright)).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void darkRoomNeedsActiveLight() {
|
|
||||||
Room dark = new Room("r", "R", "d");
|
|
||||||
dark.setDark(true);
|
|
||||||
Player p = new Player(dark, 0);
|
|
||||||
GameContext ctx = ctx(p);
|
|
||||||
assertThat(Light.isLit(ctx, dark)).isFalse();
|
|
||||||
|
|
||||||
p.addItem(lamp(false));
|
|
||||||
assertThat(Light.isLit(ctx, dark)).isFalse(); // lamp off
|
|
||||||
|
|
||||||
p.removeItem("lamp");
|
|
||||||
p.addItem(lamp(true));
|
|
||||||
assertThat(Light.isLit(ctx, dark)).isTrue(); // lamp on, carried
|
|
||||||
assertThat(Light.carryingLight(ctx)).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void litLampLyingInRoomLightsIt() {
|
|
||||||
Room dark = new Room("r", "R", "d");
|
|
||||||
dark.setDark(true);
|
|
||||||
dark.addItem(lamp(true));
|
|
||||||
Player p = new Player(dark, 0);
|
|
||||||
assertThat(Light.isLit(ctx(p), dark)).isTrue();
|
|
||||||
assertThat(Light.carryingLight(ctx(p))).isFalse(); // not carried
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void nonLightItemDoesNotLight() {
|
|
||||||
Room dark = new Room("r", "R", "d");
|
|
||||||
dark.setDark(true);
|
|
||||||
Player p = new Player(dark, 0);
|
|
||||||
p.addItem(PlainItem.builder().id("rock").name("Rock").description("d").build());
|
|
||||||
assertThat(Light.isLit(ctx(p), dark)).isFalse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to verify failure**
|
|
||||||
|
|
||||||
Run: `mvn -q test -Dtest=LightTest`
|
|
||||||
Expected: FAIL — `Item.light` / `Room.setDark` / `Light` missing.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `light` to `Item.java`**
|
|
||||||
|
|
||||||
Add the field after `description`:
|
|
||||||
```java
|
|
||||||
/** Whether this item can serve as a light source (when on, for switchables). */
|
|
||||||
protected final boolean light;
|
|
||||||
```
|
|
||||||
(`@SuperBuilder` exposes `.light(...)` on every subclass builder; `@Getter` generates `isLight()`. Unset builders default to `false`.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add `light` to `ItemDto.java` and wire `ItemFactory.java`**
|
|
||||||
|
|
||||||
In `ItemDto.java` append a component (after `effects`) and update the back-compat constructor:
|
|
||||||
```java
|
|
||||||
List<EffectDto> effects,
|
|
||||||
Boolean light
|
|
||||||
```
|
|
||||||
```java
|
|
||||||
/** Backward-compatible constructor without effects/light. */
|
|
||||||
public ItemDto(String type, String id, String name, String description,
|
|
||||||
String readText, Boolean initialState, String onText, String offText) {
|
|
||||||
this(type, id, name, description, readText, initialState, onText, offText, null, null);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
In `ItemFactory.java`, add `.light(Boolean.TRUE.equals(dto.light()))` to **each** of the three builder cases (plain, readable, switchable), e.g.:
|
|
||||||
```java
|
|
||||||
case "plain" -> PlainItem.builder()
|
|
||||||
.id(dto.id())
|
|
||||||
.name(dto.name())
|
|
||||||
.description(dto.description())
|
|
||||||
.light(Boolean.TRUE.equals(dto.light()))
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
(and the same `.light(...)` line before `.build()` in the `readable` and `switchable` cases.)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Add `dark` to `Room.java`**
|
|
||||||
|
|
||||||
Add the import if missing:
|
|
||||||
```java
|
|
||||||
import lombok.Setter;
|
|
||||||
```
|
|
||||||
Add the field after `descriptionStates`:
|
|
||||||
```java
|
|
||||||
/** Whether this room is dark (needs a light source to enter/see). */
|
|
||||||
@Setter
|
|
||||||
private boolean dark;
|
|
||||||
```
|
|
||||||
(`@Getter` generates `isDark()`; not a constructor arg, so existing `new Room(id,name,desc)` calls are unaffected.)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add `dark` to `RoomDto.java` and wire `RoomFactory.java`**
|
|
||||||
|
|
||||||
In `RoomDto.java` append a component (after `descriptionStates`) and update the back-compat constructor:
|
|
||||||
```java
|
|
||||||
List<DescriptionStateDto> descriptionStates,
|
|
||||||
Boolean dark
|
|
||||||
```
|
|
||||||
```java
|
|
||||||
/** Backward-compatible constructor without the optional state fields. */
|
|
||||||
public RoomDto(String id, String name, String description,
|
|
||||||
Map<String, String> exits, List<String> items, List<String> npcs) {
|
|
||||||
this(id, name, description, exits, items, npcs, null, null, null);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
In `RoomFactory.java`, set `dark` after building the shell:
|
|
||||||
```java
|
|
||||||
public static Room shellFromDto(RoomDto dto) {
|
|
||||||
Room room = new Room(dto.id(), dto.name(), dto.description());
|
|
||||||
room.setDark(Boolean.TRUE.equals(dto.dark()));
|
|
||||||
return room;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Create `game/Light.java`**
|
|
||||||
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.game;
|
|
||||||
|
|
||||||
import thb.jeanluc.adventure.model.Room;
|
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
|
||||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
|
|
||||||
/** Decides whether a room is lit, given dark rooms and active light sources. */
|
|
||||||
public final class Light {
|
|
||||||
|
|
||||||
private Light() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A room is lit if it is not dark, or an active light source is carried or present. */
|
|
||||||
public static boolean isLit(GameContext ctx, Room room) {
|
|
||||||
if (!room.isDark()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return hasActiveLight(ctx.getPlayer().getInventory().values())
|
|
||||||
|| hasActiveLight(room.getItems().values());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if the player carries an active light source (for the HUD). */
|
|
||||||
public static boolean carryingLight(GameContext ctx) {
|
|
||||||
return hasActiveLight(ctx.getPlayer().getInventory().values());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean hasActiveLight(Collection<Item> items) {
|
|
||||||
for (Item it : items) {
|
|
||||||
if (isActiveLight(it)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isActiveLight(Item it) {
|
|
||||||
if (!it.isLight()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !(it instanceof SwitchableItem s) || s.isOn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Run tests**
|
|
||||||
|
|
||||||
Run: `mvn -q test -Dtest=LightTest`
|
|
||||||
Expected: PASS.
|
|
||||||
Run: `mvn -q test`
|
|
||||||
Expected: PASS — flags default off; existing item/room construction unchanged via back-compat constructors.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/main/java/thb/jeanluc/adventure/model/item/Item.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/model/Room.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/game/Light.java \
|
|
||||||
src/test/java/thb/jeanluc/adventure/game/LightTest.java
|
|
||||||
git commit -m "feat: Room.dark + Item.light flags and Light helper"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Command gating + HUD
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `command/impl/GoCommand.java`, `command/impl/LookCommand.java`, `command/impl/TakeCommand.java`, `command/impl/ExamineCommand.java`, `game/Game.java`
|
|
||||||
- Test: `command/impl/DarknessTest.java`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test**
|
|
||||||
|
|
||||||
`src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java`:
|
|
||||||
```java
|
|
||||||
package thb.jeanluc.adventure.command.impl;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
|
||||||
import thb.jeanluc.adventure.io.TestIO;
|
|
||||||
import thb.jeanluc.adventure.model.Direction;
|
|
||||||
import thb.jeanluc.adventure.model.Player;
|
|
||||||
import thb.jeanluc.adventure.model.Room;
|
|
||||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
class DarknessTest {
|
|
||||||
private SwitchableItem lamp(boolean on) {
|
|
||||||
return SwitchableItem.builder().id("lamp").name("Lamp").description("d")
|
|
||||||
.state(on).onText("on").offText("off").light(true).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void cannotEnterDarkRoomWithoutLight() {
|
|
||||||
Room hall = new Room("hall", "Hall", "d");
|
|
||||||
Room cave = new Room("cave", "Cave", "d");
|
|
||||||
cave.setDark(true);
|
|
||||||
hall.addExit(Direction.NORTH, cave);
|
|
||||||
Player p = new Player(hall, 0);
|
|
||||||
GameContext ctx = new GameContext(null, p, new TestIO());
|
|
||||||
|
|
||||||
new GoCommand().execute(ctx, List.of("north"));
|
|
||||||
assertThat(p.getCurrentRoom()).isEqualTo(hall);
|
|
||||||
assertThat(((TestIO) ctx.getIo()).allOutput()).containsIgnoringCase("pitch black");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void canEnterDarkRoomWithLitLamp() {
|
|
||||||
Room hall = new Room("hall", "Hall", "d");
|
|
||||||
Room cave = new Room("cave", "Cave", "d");
|
|
||||||
cave.setDark(true);
|
|
||||||
hall.addExit(Direction.NORTH, cave);
|
|
||||||
Player p = new Player(hall, 0);
|
|
||||||
p.addItem(lamp(true));
|
|
||||||
GameContext ctx = new GameContext(null, p, new TestIO());
|
|
||||||
|
|
||||||
new GoCommand().execute(ctx, List.of("north"));
|
|
||||||
assertThat(p.getCurrentRoom()).isEqualTo(cave);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void lookAndTakeAreBlockedInTheDark() {
|
|
||||||
Room cave = new Room("cave", "Cave", "d");
|
|
||||||
cave.setDark(true);
|
|
||||||
Player p = new Player(cave, 0);
|
|
||||||
TestIO io = new TestIO();
|
|
||||||
GameContext ctx = new GameContext(null, p, io);
|
|
||||||
|
|
||||||
new LookCommand().execute(ctx, List.of());
|
|
||||||
assertThat(io.allOutput()).containsIgnoringCase("pitch black");
|
|
||||||
|
|
||||||
io.outputs().clear();
|
|
||||||
new TakeCommand().execute(ctx, List.of("anything"));
|
|
||||||
assertThat(io.allOutput()).containsIgnoringCase("too dark");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to verify failure**
|
|
||||||
|
|
||||||
Run: `mvn -q test -Dtest=DarknessTest`
|
|
||||||
Expected: FAIL — no darkness gating yet (entering succeeds, look shows room).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Gate `GoCommand`**
|
|
||||||
|
|
||||||
Add the import:
|
|
||||||
```java
|
|
||||||
import thb.jeanluc.adventure.game.Light;
|
|
||||||
```
|
|
||||||
After the exit-lock check and before `ctx.getPlayer().setCurrentRoom(next.get())`, insert:
|
|
||||||
```java
|
|
||||||
if (!Light.isLit(ctx, next.get())) {
|
|
||||||
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
|
|
||||||
+ "(try lighting your lamp with 'use lamp').");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Gate `LookCommand`**
|
|
||||||
|
|
||||||
Add the import:
|
|
||||||
```java
|
|
||||||
import thb.jeanluc.adventure.game.Light;
|
|
||||||
```
|
|
||||||
At the very start of `execute`, after fetching the room:
|
|
||||||
```java
|
|
||||||
Room room = ctx.getPlayer().getCurrentRoom();
|
|
||||||
if (!Light.isLit(ctx, room)) {
|
|
||||||
ctx.getIo().write("It's pitch black; you can't make anything out. You need a light source.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
(The existing `Room room = ...` line is already there — replace it with this guarded version, keeping the rest below unchanged.)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Gate `TakeCommand`**
|
|
||||||
|
|
||||||
Add the import:
|
|
||||||
```java
|
|
||||||
import thb.jeanluc.adventure.game.Light;
|
|
||||||
```
|
|
||||||
After the `args.isEmpty()` check:
|
|
||||||
```java
|
|
||||||
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
|
||||||
ctx.getIo().write("It's too dark to see that.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Gate `ExamineCommand`**
|
|
||||||
|
|
||||||
Add the import:
|
|
||||||
```java
|
|
||||||
import thb.jeanluc.adventure.game.Light;
|
|
||||||
```
|
|
||||||
After the `args.isEmpty()` check:
|
|
||||||
```java
|
|
||||||
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
|
||||||
ctx.getIo().write("It's too dark to see that.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Wire the HUD `light` field in `Game.java`**
|
|
||||||
|
|
||||||
Add the import:
|
|
||||||
```java
|
|
||||||
import thb.jeanluc.adventure.game.Light; // same package 'game' — drop if redundant
|
|
||||||
```
|
|
||||||
(`Light` is in package `game`, same as `Game` — no import needed.) In `publishHud()`, change the `Hud` construction's last argument from `false` to `Light.carryingLight(ctx)`:
|
|
||||||
```java
|
|
||||||
ctx.getIo().setHud(new Hud(
|
|
||||||
ctx.getPlayer().getCurrentRoom().getName(),
|
|
||||||
ctx.getPlayer().getGold(),
|
|
||||||
turn,
|
|
||||||
Light.carryingLight(ctx)));
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Run tests**
|
|
||||||
|
|
||||||
Run: `mvn -q test -Dtest=DarknessTest`
|
|
||||||
Expected: PASS.
|
|
||||||
Run: `mvn -q test`
|
|
||||||
Expected: PASS (existing command tests use non-dark rooms → `isLit` true → no behaviour change).
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java \
|
|
||||||
src/main/java/thb/jeanluc/adventure/game/Game.java \
|
|
||||||
src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java
|
|
||||||
git commit -m "feat: darkness gating for go/look/take/examine + HUD light"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Demo (dark dungeon) + docs + verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `docs/enhancement-ideas.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Make the lamp a light source in `items.yaml`**
|
|
||||||
|
|
||||||
On the `lamp` item, add `light: true`:
|
|
||||||
```yaml
|
|
||||||
- type: switchable
|
|
||||||
id: lamp
|
|
||||||
name: Oil Lamp
|
|
||||||
description: An old oil lamp, heavy with fuel.
|
|
||||||
initialState: false
|
|
||||||
light: true
|
|
||||||
onText: The lamp flares to life, casting a warm glow.
|
|
||||||
offText: You snuff out the lamp.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Make the dungeon dark in `rooms.yaml`**
|
|
||||||
|
|
||||||
On the `dungeon` room, add `dark: true` and tweak the description:
|
|
||||||
```yaml
|
|
||||||
- id: dungeon
|
|
||||||
name: Dungeon
|
|
||||||
description: |
|
|
||||||
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
|
||||||
dark: true
|
|
||||||
exits:
|
|
||||||
north: kitchen
|
|
||||||
items: [generator]
|
|
||||||
npcs: []
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Mark the mechanic done in `docs/enhancement-ideas.md`**
|
|
||||||
|
|
||||||
Under the mechanic-spine section (point 3, "Licht & Dunkelheit"), append a status note:
|
|
||||||
```markdown
|
|
||||||
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
|
|
||||||
> `Light`-Helper, Gating in go/look/take/examine, HUD-Licht. Demo: dunkler
|
|
||||||
> Dungeon braucht die brennende Lampe für den Generator.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Full suite + end-to-end console**
|
|
||||||
|
|
||||||
Run: `mvn -q test`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
printf 'go south\ntake lamp\nuse lamp\ngo south\nuse generator\nquit\n' | mvn -q -DskipTests exec:java@run
|
|
||||||
```
|
|
||||||
Expected: the first `go south` is blocked ("pitch black beyond the doorway"); after `take lamp` and `use lamp` (HUD flips to `light: on`), `go south` enters the dungeon and `use generator` restores the power. No exceptions.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/main/resources/world/items.yaml \
|
|
||||||
src/main/resources/world/rooms.yaml \
|
|
||||||
docs/enhancement-ideas.md
|
|
||||||
git commit -m "feat(content): dark dungeon needs the lit lamp"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review notes
|
|
||||||
|
|
||||||
- **Spec coverage:** flags + `Light` (T1), command gating + HUD (T2), demo + docs (T3). Fuel/timed light, cross-room radius deferred.
|
|
||||||
- **Backward compatibility:** `Item.light` and `Room.dark` default off; `ItemDto`/`RoomDto` keep back-compat constructors; `Room.dark` is not a constructor arg. Existing tests and YAML unaffected (non-dark rooms are always `isLit`).
|
|
||||||
- **Type consistency:** `Light.isLit(ctx, Room)` / `carryingLight(ctx)`, `Item.isLight()`, `Room.isDark()/setDark()`, `ItemDto.light`, `RoomDto.dark` used consistently.
|
|
||||||
- **Soft-lock safety:** entry-gate model means dark rooms are entered only with light; lit rooms always re-enterable.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,116 +0,0 @@
|
|||||||
# Spec: Win-Condition & Enden (Teilprojekt 3.3)
|
|
||||||
|
|
||||||
Stand: 2026-05-31. Capstone des Quest-Systems. Baut auf Fundament (3.1) + Quests
|
|
||||||
(3.2) auf: Conditions/Effects/Flags + QuestLog sind vorhanden.
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
Das Spiel endet bisher nur per `quit`. Ziel: **mehrere, priorisierte Enden**,
|
|
||||||
condition-driven (Ansatz wie Quests), plus ein **End-Screen mit Zusammenfassung**
|
|
||||||
(Züge, abgeschlossene Quests, Rang). Bestätigt: **A mit Summary**.
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope:**
|
|
||||||
- `Ending`-Datenmodell + optionale `endings.yaml` (Reihenfolge = Priorität).
|
|
||||||
- `EndingEngine` (erstes passendes Ende ermitteln + End-Screen rendern).
|
|
||||||
- `Game`-Anbindung: pro Zug nach dem Quest-Tick prüfen; bei Treffer Ende +
|
|
||||||
Summary ausgeben und `stop()`.
|
|
||||||
- End-Summary: Züge, Quests X/Y, Rang.
|
|
||||||
- Loader: `EndingDto`, `EndingFactory`, `World.endings`.
|
|
||||||
- Demo: zwei spielbar erreichbare Enden ohne neue Mechanik.
|
|
||||||
|
|
||||||
**Out of Scope:** Scoreboard/Persistenz; zugbasierte Conditions; Spielstand-Reset.
|
|
||||||
|
|
||||||
## 3. Datenmodell (model)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record Ending(String id, String title, boolean victory,
|
|
||||||
List<Condition> when, String text) { /* when: List.copyOf */ }
|
|
||||||
```
|
|
||||||
`endings.yaml` (geordnete Liste, erstes passendes gewinnt):
|
|
||||||
```yaml
|
|
||||||
- id: victory
|
|
||||||
title: "The Manor Reclaimed"
|
|
||||||
victory: true
|
|
||||||
when: [{ flag: manor_secured }]
|
|
||||||
text: |
|
|
||||||
The lights hold steady, the whispers fade. You've made the manor yours.
|
|
||||||
- id: fled
|
|
||||||
title: "Into the Night"
|
|
||||||
victory: false
|
|
||||||
when: [{ flag: fled }]
|
|
||||||
text: |
|
|
||||||
You bolt through the front door. Safe — but the manor keeps its secrets.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Engine (game)
|
|
||||||
|
|
||||||
- `EndingEngine.triggered(GameContext ctx)` → erstes `Ending`, dessen `when` via
|
|
||||||
`Conditions.all` gilt, sonst `null`. Rein, testbar (Priorität/erster Treffer).
|
|
||||||
- `EndingEngine.render(Ending e, GameContext ctx, int turns)` → `StyledText`:
|
|
||||||
Titel-Banner + `text` + Summary.
|
|
||||||
- **Summary**: `Turns: N`, `Quests completed: X / Y`
|
|
||||||
(`X = questLog.completed().size()`, `Y = world.getQuests().size()`), plus
|
|
||||||
**Rang**:
|
|
||||||
- victory & alle Quests → „Master of the Manor"
|
|
||||||
- victory → „Manor Reclaimed"
|
|
||||||
- sonst → „Escaped — the manor keeps its secrets"
|
|
||||||
|
|
||||||
## 5. Game-Anbindung
|
|
||||||
|
|
||||||
In `Game.run` nach `publishHud()` (Initial **und** pro Schleifendurchlauf) ein
|
|
||||||
`maybeEnd()`:
|
|
||||||
```java
|
|
||||||
private void maybeEnd() {
|
|
||||||
Ending e = EndingEngine.triggered(ctx);
|
|
||||||
if (e != null) {
|
|
||||||
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`stop()` setzt `running=false`; die Schleife endet sauber, kein zusätzlicher Prompt.
|
|
||||||
Konsole: Programm endet. GUI: Worker-Thread endet, Fenster bleibt mit End-Screen.
|
|
||||||
|
|
||||||
## 6. Loading
|
|
||||||
|
|
||||||
- `EndingDto(id, title, Boolean victory, List<ConditionDto> when, String text)`.
|
|
||||||
- `EndingFactory.fromDto` baut `Ending` (`when` via `ConditionDto.toModelList`).
|
|
||||||
- `WorldLoader` liest `endings.yaml` **optional** (`readListOptional`), baut eine
|
|
||||||
**geordnete** `List<Ending>` (Reihenfolge der YAML-Liste), `requireUniqueIds("ending", …)`.
|
|
||||||
- `World` bekommt Feld `endings` (List) + **rückwärtskompatible 5- und
|
|
||||||
6-Arg-Konstruktoren** (leere Quests/Endings), damit bestehende `new World(...)`-
|
|
||||||
Aufrufe (Tests, bisheriger Loader-Pfad) unverändert kompilieren. Der Loader nutzt
|
|
||||||
den vollen 7-Arg-Konstruktor.
|
|
||||||
|
|
||||||
## 7. Fehlerbehandlung
|
|
||||||
|
|
||||||
| Fall | Verhalten |
|
|
||||||
|---|---|
|
|
||||||
| `endings.yaml` fehlt | leere Liste → Spiel endet nur per `quit` (wie bisher) |
|
|
||||||
| mehrere Conditions treffen | erstes Ende in Listenreihenfolge gewinnt |
|
|
||||||
| Ending ohne `when` | trifft sofort (leere Condition-Liste = true) – Autoren sollten Reihenfolge beachten |
|
|
||||||
| keine Quests definiert | Summary zeigt `0 / 0`, Rang nach victory-Flag |
|
|
||||||
|
|
||||||
## 8. Demonstration (zwei erreichbare Enden, keine neue Mechanik)
|
|
||||||
|
|
||||||
- **victory**: `manor_secured` wird gesetzt, wenn die `restore_power`-Quest endet
|
|
||||||
(bereits vorhanden) → Sieg-Ende.
|
|
||||||
- **fled**: neuer schaltbarer Gegenstand **Front Door** (`effects: setFlag fled`)
|
|
||||||
in der Küche → Flucht-Ende. `victory` steht vor `fled`, also schlägt das
|
|
||||||
Sichern der Villa die Flucht.
|
|
||||||
|
|
||||||
## 9. Testing
|
|
||||||
|
|
||||||
- `EndingEngine.triggered`: Priorität/erster Treffer; kein Treffer → `null`.
|
|
||||||
- `EndingEngine.render`: enthält Titel, Text, Züge, Quests-Zähler, Rang.
|
|
||||||
- `EndingFactory.fromDto`: Mapping inkl. victory/when/text.
|
|
||||||
- End-to-End (Konsole): Villa sichern → Sieg-Ende + Summary, Spiel endet;
|
|
||||||
alternativ Front Door benutzen → Flucht-Ende.
|
|
||||||
- `Game`-Integration: nicht separat unit-getestet (Loop/IO); über Konsolen-Smoke.
|
|
||||||
|
|
||||||
## 10. Offene Detailfragen (in Implementierung)
|
|
||||||
|
|
||||||
- Genaue Banner-Optik des End-Screens (HEADING-Rahmen).
|
|
||||||
- Rang-Schwellen (zunächst nur victory + alle-Quests-Abfrage).
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Spec: Licht & Dunkelheit (Mechanik-Erweiterung)
|
|
||||||
|
|
||||||
Stand: 2026-05-31. Kleine Mechanik aus dem Spine (#3 der Mechanik-Liste), die das
|
|
||||||
Fundament (Flags/Items) nutzt. Strenge: **Modell B** (Dunkelheit blockiert).
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
Dunkle Räume sollen den schaltbaren `lamp` sinnvoll machen: Ohne aktive
|
|
||||||
Lichtquelle kann man einen dunklen Raum **nicht betreten**, nichts sehen
|
|
||||||
(`look`), und nichts aufnehmen/untersuchen. Die HUD-Zeile `light: on/off` wird
|
|
||||||
endlich verdrahtet.
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope:**
|
|
||||||
- `Room.dark` (Bool, optional), `Item.light` (Bool, optional).
|
|
||||||
- `Light`-Helper: `isLit`, `carryingLight`.
|
|
||||||
- Command-Gating: `go` (Eintritt), `look`, `take`, `examine`; HUD-`lightOn`.
|
|
||||||
- Demo: Dungeon dunkel → Lampe nötig.
|
|
||||||
|
|
||||||
**Out of Scope:** Lampen-Brennstoff/zeitlich begrenztes Licht; Lichtradius über
|
|
||||||
Räume hinweg; Zufall.
|
|
||||||
|
|
||||||
## 3. Daten (rückwärtskompatibel)
|
|
||||||
|
|
||||||
- **`Room.dark`**: `@Setter`-Feld (default `false`), **kein** Konstruktor-Argument →
|
|
||||||
keine Test-Brüche. Vom `RoomFactory` aus `RoomDto.dark` gesetzt. YAML: `dark: true`.
|
|
||||||
- **`Item.light`**: `boolean` am abstrakten `Item` (via `@SuperBuilder`), default
|
|
||||||
`false`. Vom `ItemFactory` aus `ItemDto.light` gesetzt. YAML: `light: true`.
|
|
||||||
- **Aktive Lichtquelle** = `item.light` **und** (kein Switchable **oder**
|
|
||||||
`SwitchableItem.isOn()`). Die `lamp` leuchtet also nur eingeschaltet.
|
|
||||||
|
|
||||||
## 4. Helper `Light` (game)
|
|
||||||
|
|
||||||
```java
|
|
||||||
static boolean isLit(GameContext ctx, Room room) {
|
|
||||||
if (!room.isDark()) return true;
|
|
||||||
return hasActiveLight(player-inventory) || hasActiveLight(room-items);
|
|
||||||
}
|
|
||||||
static boolean carryingLight(GameContext ctx); // aktive Lichtquelle im Inventar
|
|
||||||
```
|
|
||||||
`hasActiveLight(items)` iteriert; `isActiveLight(item)` = `item.isLight()` &&
|
|
||||||
(`!(item instanceof SwitchableItem s)` || `s.isOn()`).
|
|
||||||
|
|
||||||
Räume gelten als beleuchtet, wenn sie nicht dunkel sind **oder** eine aktive
|
|
||||||
Lichtquelle getragen wird **oder** im Raum liegt.
|
|
||||||
|
|
||||||
## 5. Command-Gating (Modell B)
|
|
||||||
|
|
||||||
| Command | Verhalten bei dunklem, unbeleuchtetem Zielraum/aktuellem Raum |
|
|
||||||
|---|---|
|
|
||||||
| `GoCommand` | **Eintritt blockiert**, wenn `!isLit(ctx, target)`: Hinweis-Text, kein Wechsel. (Lichträume bleiben immer betretbar → **kein Soft-Lock**, Rückzug stets möglich.) |
|
|
||||||
| `LookCommand` | `!isLit(ctx, current)` → „It's pitch black; you can't make anything out." statt `RoomView` (Fall: Lampe ging im Raum aus). |
|
|
||||||
| `TakeCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
|
|
||||||
| `ExamineCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
|
|
||||||
| HUD | `lightOn` = `Light.carryingLight(ctx)` (in `Game.publishHud`). |
|
|
||||||
|
|
||||||
Erlaubt im Dunkeln bleiben: `inventory`, `drop`, **`use`** (z. B. Lampe anzünden),
|
|
||||||
`quests`, `map`, `help`. So kann man eine getragene Lampe im Dunkeln anzünden.
|
|
||||||
|
|
||||||
## 6. Fehlerbehandlung / Soft-Lock
|
|
||||||
|
|
||||||
Eintrittssperre statt Innen-Sperre: Man betritt einen dunklen Raum nur **mit**
|
|
||||||
Licht. Geht das Licht drinnen aus, sind `look`/`take`/`examine` blockiert, aber der
|
|
||||||
Rückweg in einen hellen Raum bleibt (Zielraum hell → `isLit` true). Kein Soft-Lock.
|
|
||||||
|
|
||||||
## 7. Demo
|
|
||||||
|
|
||||||
- `lamp`: `light: true` (bereits schaltbar; `use lamp` → an).
|
|
||||||
- `dungeon`: `dark: true`; der Generator steht dort. Ablauf:
|
|
||||||
`take lamp` → `use lamp` (an) → `go south` (jetzt betretbar) → `use generator`.
|
|
||||||
HUD zeigt `light: on`, sobald die Lampe brennt. Gibt man die Lampe später dem
|
|
||||||
alten Mann, verliert man das Licht (thematisch).
|
|
||||||
|
|
||||||
## 8. Testing
|
|
||||||
|
|
||||||
- `Light.isLit`: nicht dunkel → hell; dunkel ohne Licht → dunkel; dunkel + getragene
|
|
||||||
brennende Lampe → hell; dunkel + Lampe **aus** → dunkel; dunkel + brennende Lampe
|
|
||||||
**im Raum** → hell. `carryingLight`.
|
|
||||||
- Command-Gates: `go` in dunklen Raum ohne Licht blockt, mit brennender Lampe ok;
|
|
||||||
`look`/`take` im dunklen Raum blockt.
|
|
||||||
- End-to-End (Konsole): `go south` ohne Licht blockt; `take lamp`/`use lamp`/`go south`
|
|
||||||
betritt; HUD `light: on`.
|
|
||||||
|
|
||||||
## 9. Offene Detailfragen (in Implementierung)
|
|
||||||
|
|
||||||
- Genauer Hinweis-Text der Eintrittssperre (nennt `use lamp`).
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# Spec: Karte / Mini-Map (Teilprojekt 2)
|
|
||||||
|
|
||||||
Stand: 2026-05-31. Zweites Teilprojekt aus [enhancement-ideas.md](../../enhancement-ideas.md).
|
|
||||||
Baut auf der Präsentations-Schicht (Teilprojekt 1) auf, insbesondere dem
|
|
||||||
reservierten GUI-Seitenpanel und dem semantischen Modell-+-Renderer-Muster.
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
Räume bilden bereits einen gerichteten N/S/O/W-Graphen über `Room.exits`
|
|
||||||
(`EnumMap<Direction, Room>`). Ziel: eine **Karte** in beiden Frontends, die diesen
|
|
||||||
Graphen sichtbar macht – **Fog of War** (nur Entdecktes), Stil **„umrandete Räume +
|
|
||||||
Korridore"** (im Brainstorming bestätigt). Die GUI zeichnet grafisch (Graphics2D),
|
|
||||||
die Konsole spiegelt es als ASCII.
|
|
||||||
|
|
||||||
Bestätigte Entscheidungen:
|
|
||||||
- **Fog of War**: nur besuchte Räume + deren direkte Nachbarn als `?`-Stub.
|
|
||||||
- **Stil**: umrandete Räume, durch Korridore verbunden; aktueller Raum hervorgehoben.
|
|
||||||
- **Layout aus dem Graphen abgeleitet** (keine Koordinaten in YAML).
|
|
||||||
- GUI: persistentes Panel ersetzt die Exit-Zeile. Konsole: `map`-Befehl on demand,
|
|
||||||
Exit-Zeile im Raumtext bleibt.
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope:**
|
|
||||||
- `MapView`-Modell (platzierte Zellen + Verbindungen, Zustände).
|
|
||||||
- `MapLayout`-Service: BFS-Layout aus dem Exit-Graphen + Fog-of-War-Filter.
|
|
||||||
- **Besuchte-Räume-Tracking** auf `Player`.
|
|
||||||
- `GameIO`: `setMap` (Push pro Zug, GUI) + `showMap` (on demand, Konsole).
|
|
||||||
- `MapCommand` (`map`, `m`).
|
|
||||||
- `Game`-Loop ruft `setMap` pro Zug (neben `setHud`).
|
|
||||||
- GUI: `MapPanel` (Graphics2D) im Seitenpanel, scrollbar, auf aktuellen Raum zentriert.
|
|
||||||
- Konsole: ASCII-Karte (`AsciiMap`), respektiert `GlyphMode`.
|
|
||||||
|
|
||||||
**Out of Scope (spätere Teilprojekte):**
|
|
||||||
- Marker für Items/NPCs auf der Karte.
|
|
||||||
- Styling für verschlossene/blockierte Exits (Quest-Teilprojekt; Karte bekommt
|
|
||||||
später einen Hook dafür).
|
|
||||||
- Zoom über das Scrollen hinaus.
|
|
||||||
|
|
||||||
## 3. Architektur (Modell + Renderer pro Frontend)
|
|
||||||
|
|
||||||
`MapLayout` erzeugt aus `World` (Exit-Graph), der Menge besuchter Räume und dem
|
|
||||||
aktuellen Raum eine frontend-agnostische `MapView`. Layout und Fog-of-War-Logik
|
|
||||||
liegen ausschließlich hier; die Renderer kennen nur das fertige `MapView`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
W["World (Exit-Graph)"] --> L["MapLayout.compute()"]
|
|
||||||
V["Player.visitedRoomIds"] --> L
|
|
||||||
C["aktueller Raum"] --> L
|
|
||||||
L --> MV["MapView (Zellen + Verbindungen)"]
|
|
||||||
MV -- "setMap()" --> GUI["SwingIO → MapPanel (Graphics2D)"]
|
|
||||||
MV -- "showMap()" --> CON["ConsoleIO → AsciiMap (StyledText)"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Komponenten & Datenmodell
|
|
||||||
|
|
||||||
Views in `io.text`, Layout-Logik in neuem Package `map`, GUI-Panel in `io`.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// io.text
|
|
||||||
public enum CellState { CURRENT, VISITED, KNOWN }
|
|
||||||
|
|
||||||
public record RoomCell(String id, String name, int x, int y, CellState state) {}
|
|
||||||
|
|
||||||
public record Connection(RoomCell from, RoomCell to) {} // Stil via Zellzustand
|
|
||||||
|
|
||||||
public record MapView(java.util.List<RoomCell> cells,
|
|
||||||
java.util.List<Connection> connections) {
|
|
||||||
public MapView { cells = List.copyOf(cells); connections = List.copyOf(connections); }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `map.MapLayout` – `static MapView compute(World world, String startRoomId, Set<String> visited, Room current)`.
|
|
||||||
- `io.text.AsciiMap` – `static StyledText render(MapView, GlyphMode)`.
|
|
||||||
- `io.MapPanel` – `JComponent`, `void show(MapView)`; `paintComponent` zeichnet.
|
|
||||||
- `command.impl.MapCommand` – baut `MapView`, ruft `io.showMap(view)`.
|
|
||||||
- `model.Player` – `Set<String> visitedRoomIds` (LinkedHashSet, Reihenfolge stabil).
|
|
||||||
|
|
||||||
### GameIO-Erweiterung (additiv)
|
|
||||||
|
|
||||||
```java
|
|
||||||
/** Push pro Zug: GUI aktualisiert ihr Panel; Konsole ignoriert (kein Spam). */
|
|
||||||
default void setMap(MapView view) { /* no-op */ }
|
|
||||||
|
|
||||||
/** On demand (map-Befehl): rendert die Karte. Default = ASCII in UNICODE. */
|
|
||||||
default void showMap(MapView view) { print(AsciiMap.render(view, GlyphMode.UNICODE)); }
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`ConsoleIO`** überschreibt `showMap` → `print(AsciiMap.render(view, this.glyphs))`
|
|
||||||
(nutzt also seinen konfigurierten Glyph-Modus). `setMap` bleibt Default (no-op) →
|
|
||||||
kein Karten-Spam pro Zug in der Konsole.
|
|
||||||
- **`SwingIO`** überschreibt `setMap` → `MapPanel` neu zeichnen; `showMap` → no-op
|
|
||||||
(das Panel ist ohnehin sichtbar).
|
|
||||||
- **`TestIO`** nutzt beide Defaults: `setMap` = no-op, `showMap` druckt UNICODE-ASCII
|
|
||||||
in `outputs` → `MapCommand`-Test kann darauf assertieren. Kein Override nötig.
|
|
||||||
|
|
||||||
So liegt der Glyph-Modus dort, wo er hingehört (am Konsolen-Renderer), ohne die
|
|
||||||
`GameIO`-Schnittstelle um eine Glyph-Methode aufzublähen.
|
|
||||||
|
|
||||||
## 5. Fog-of-War-Regeln
|
|
||||||
|
|
||||||
| Zustand | Bedeutung | Darstellung |
|
|
||||||
|---|---|---|
|
|
||||||
| `CURRENT` | besuchter Raum, in dem der Spieler steht | Goldener Rahmen |
|
|
||||||
| `VISITED` | bereits betretener Raum | heller Rahmen + Name |
|
|
||||||
| `KNOWN` | direkter Exit-Nachbar eines besuchten Raums, noch nicht betreten | gestrichelter `?`-Stub |
|
|
||||||
| (hidden) | alles andere | nicht platziert |
|
|
||||||
|
|
||||||
Beim Betreten eines `?`-Raums wird er `VISITED`; seine Nachbarn werden zu neuen
|
|
||||||
`KNOWN`-Stubs. Die Karte wächst eine Ringschicht voraus.
|
|
||||||
|
|
||||||
## 6. Layout-Algorithmus & Konfliktbehandlung
|
|
||||||
|
|
||||||
BFS vom Startraum über den **vollständigen** Exit-Graphen; jede Richtung verschiebt
|
|
||||||
die Gitterkoordinate (N→y−1, S→y+1, O→x+1, W→x−1). Hand-gebaute Welten sind
|
|
||||||
geometrisch konsistent; falls BFS zwei Räume auf dieselbe Zelle legt (nicht-planarer
|
|
||||||
Graph), **gewinnt der erste**, die Kollision wird geloggt, das Rendern läuft weiter
|
|
||||||
(kein Crash). Die Fog-of-War-Filterung läuft nach dem Layout; danach werden die
|
|
||||||
Koordinaten normalisiert (Verschiebung, sodass min = 0,0).
|
|
||||||
|
|
||||||
## 7. Konsolen-Verhalten
|
|
||||||
|
|
||||||
`map` / `m` druckt die ASCII-Karte on demand (keine persistente Region in der
|
|
||||||
Konsole). Die Box-Raumansicht behält ihre Inline-`Exits:`-Zeile – Konsolenspieler
|
|
||||||
müssen also nicht `map` tippen, um zu navigieren; die Karte ist Überblick-Bonus. In
|
|
||||||
der GUI ersetzt das Dauerpanel die Exit-Zeile.
|
|
||||||
|
|
||||||
## 8. Fehlerbehandlung
|
|
||||||
|
|
||||||
| Fall | Verhalten |
|
|
||||||
|---|---|
|
|
||||||
| Koordinaten-Kollision (nicht-planar) | erste Zuordnung gewinnt, Warnung geloggt, Rendern fährt fort |
|
|
||||||
| Startraum hat keine Exits | Karte zeigt nur die aktuelle Zelle |
|
|
||||||
| Sehr große Karte (viele Räume) | GUI-Panel scrollbar, auf aktuelle Zelle zentriert; ASCII so groß wie nötig |
|
|
||||||
|
|
||||||
## 9. Testing
|
|
||||||
|
|
||||||
- `MapLayout` (reine Logik): Koordinaten aus bekanntem Graphen; Fog-Zustände für eine
|
|
||||||
gegebene Visited-Menge; Kollisionsbehandlung; Normalisierung.
|
|
||||||
- `MapView`/Records: Immutabilität.
|
|
||||||
- `AsciiMap`: strukturelle Assertions auf bekanntem `MapView` (Namen vorhanden,
|
|
||||||
aktueller markiert, `?` für `KNOWN`), Farbe aus.
|
|
||||||
- `MapCommand`: über `TestIO` – erzeugt Karten-Ausgabe.
|
|
||||||
- `Player`: Besuchte-Räume-Tracking (Start + nach Bewegung).
|
|
||||||
- `SwingIO`/`MapPanel`: nicht unit-getestet (Graphics2D/EDT); manueller Smoke-Test.
|
|
||||||
|
|
||||||
## 10. Offene Detailfragen (in Implementierung entscheidbar)
|
|
||||||
|
|
||||||
- ASCII-Zellbreite/-höhe und Verbindungslänge (Lesbarkeit vs. Kompaktheit).
|
|
||||||
- Genaue Farben/Strichstärken im `MapPanel` (an Präsentations-Palette angelehnt).
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# Spec: Präsentations-/Ausgabe-Schicht (Teilprojekt 1)
|
|
||||||
|
|
||||||
Stand: 2026-05-31. Erstes Teilprojekt aus [enhancement-ideas.md](../../enhancement-ideas.md).
|
|
||||||
Baut auf der v1.0-Baseline (Tag `v1.0`) auf.
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
`GameIO` bietet aktuell nur `readLine()` + `write(String)`; beide Frontends
|
|
||||||
(`ConsoleIO`, `SwingIO`) schieben rohen Text durch. Ziel: **reichere, strukturierte
|
|
||||||
Ausgabe** in beiden Modi, ohne den für beide Frontends **identischen Game-Loop**
|
|
||||||
aufzugeben (Kernwert der Architektur).
|
|
||||||
|
|
||||||
Zielbild (bestätigt im Brainstorming):
|
|
||||||
- Konsole: **Stufe B** – Boxed Heading, gelabelte Sektionen, persistente HUD-Zeile;
|
|
||||||
ASCII-Art nur für große Momente.
|
|
||||||
- GUI ist der **Fokus** (mehr Freiheit), Konsole wird weiter unterstützt.
|
|
||||||
- Semantische Farb-Rollen für Items / NPCs / Exits / Headings / Gefahr.
|
|
||||||
- **Progressive Disclosure** (nicht überladen) und **Info auf Regionen verteilen**
|
|
||||||
(Karte ersetzt später die Exit-Zeile).
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope:**
|
|
||||||
- Semantisches Ausgabe-Modell (`Style`, `Span`, `StyledText` + Builder).
|
|
||||||
- High-Level-Views: `RoomView`, `Hud`.
|
|
||||||
- Erweiterung der `GameIO`-Schnittstelle (additiv, `write(String)` bleibt als Default).
|
|
||||||
- `ConsoleIO` als Renderer: ANSI-Farben + Box-Drawing (Stufe B), HUD-Zeile,
|
|
||||||
ASCII-Art-Banner-Mechanismus, gestufte Glyph-Fähigkeit.
|
|
||||||
- `SwingIO` als Renderer: Region-Layout (HUD oben, Output mittig, Seitenpanel
|
|
||||||
rechts reserviert, Input unten), `JTextPane`/`StyledDocument`, **gebündelter Font**.
|
|
||||||
- Migration der bestehenden Commands auf die neue API; `TestIO` angepasst.
|
|
||||||
|
|
||||||
**Out of Scope (jeweils eigene spätere Specs):**
|
|
||||||
- Tatsächliches Zeichnen der **Karte** (Teilprojekt 2) – Seitenpanel ist nur Slot,
|
|
||||||
zeigt bis dahin die Exit-Liste.
|
|
||||||
- **Hauptmenü/Settings** (6), **Speichern/Laden** (7), **Musik** (8).
|
|
||||||
- Neue **Inhalte/Mechaniken** (Quests, Licht, Item-Kombination).
|
|
||||||
|
|
||||||
Die Schnittstellen lassen bewusst Slots (Seitenpanel, HUD-Felder) für diese
|
|
||||||
späteren Teile.
|
|
||||||
|
|
||||||
## 3. Architektur (Approach 2)
|
|
||||||
|
|
||||||
**Commands erzeugen Bedeutung, kein Format.** Ein Command baut ein semantisches
|
|
||||||
Objekt (`StyledText` oder eine View) und übergibt es an `GameIO`. Jedes Frontend
|
|
||||||
**rendert** es selbst: `ConsoleIO` → ANSI/Box-Drawing, `SwingIO` → Swing-Komponenten
|
|
||||||
und **eigenes Layout**. Der Game-Loop bleibt unverändert.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
CMD["Command<br/>(z.B. LookCommand)"] -- "baut" --> VIEW["RoomView / StyledText / Hud<br/>(semantisch, kein Format)"]
|
|
||||||
VIEW -- "GameIO.showRoom() / print() / setHud()" --> IO{{"GameIO"}}
|
|
||||||
IO --> CON["ConsoleIO<br/>ANSI + Box-Drawing"]
|
|
||||||
IO --> GUI["SwingIO<br/>JTextPane + Panels"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Komponenten & API
|
|
||||||
|
|
||||||
Neues Subpackage `io.text` für das Modell **und** die Views (`Style`, `Span`,
|
|
||||||
`StyledText`, `RoomView`, `Hud`); die Renderer (`ConsoleIO`, `SwingIO`) bleiben in `io`.
|
|
||||||
|
|
||||||
### 4.1 Style (enum)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }
|
|
||||||
```
|
|
||||||
|
|
||||||
Kleines, bewusst begrenztes Rollen-Set. Mapping auf konkrete Farben/Attribute
|
|
||||||
liegt im jeweiligen Renderer, nicht am Style.
|
|
||||||
|
|
||||||
### 4.2 Span & StyledText
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record Span(String text, Style style) {}
|
|
||||||
|
|
||||||
public final class StyledText {
|
|
||||||
private final List<Span> spans; // immutable
|
|
||||||
public List<Span> spans() { ... }
|
|
||||||
public String plainText() { ... } // Spans konkateniert, ohne Format
|
|
||||||
|
|
||||||
public static StyledText of(String s) { ... } // ein PLAIN-Span
|
|
||||||
public static Builder builder() { ... }
|
|
||||||
|
|
||||||
public static final class Builder {
|
|
||||||
Builder plain(String s); Builder heading(String s);
|
|
||||||
Builder item(String s); Builder npc(String s);
|
|
||||||
Builder exit(String s); Builder danger(String s);
|
|
||||||
Builder dim(String s); StyledText build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Beispiel:
|
|
||||||
```java
|
|
||||||
io.print(StyledText.builder()
|
|
||||||
.plain("You pick up ").item("the brass lamp").plain(".").build());
|
|
||||||
```
|
|
||||||
Zeilenumbrüche stehen als `\n` im Text der Spans.
|
|
||||||
|
|
||||||
### 4.3 Views
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record RoomView(String name, String description,
|
|
||||||
List<String> items, List<String> npcs,
|
|
||||||
List<String> exits) {}
|
|
||||||
|
|
||||||
public record Hud(String location, int gold, int turn, boolean lightOn) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Views tragen bereits **anzeigefertige Strings** (Namen aufgelöst), aber **kein
|
|
||||||
Layout**. `RoomView.exits` versorgt vorerst das GUI-Seitenpanel; sobald Teilprojekt 2
|
|
||||||
die Karte zeichnet, blendet der GUI-Renderer die Exit-Sektion aus.
|
|
||||||
|
|
||||||
### 4.4 GameIO (erweitert, additiv)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public interface GameIO {
|
|
||||||
String readLine();
|
|
||||||
void print(StyledText text); // gestylter Ausgabe-Block
|
|
||||||
void showRoom(RoomView room); // High-Level-Raumansicht
|
|
||||||
void setHud(Hud hud); // HUD-Region aktualisieren
|
|
||||||
|
|
||||||
default void write(String s) { // Komfort + Rückwärtskompatibilität
|
|
||||||
print(StyledText.of(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`write(String)` bleibt als Default → bestehende Aufrufstellen kompilieren weiter;
|
|
||||||
Migration kann schrittweise erfolgen.
|
|
||||||
|
|
||||||
### 4.5 ConsoleIO (Renderer)
|
|
||||||
|
|
||||||
- `print`: Spans → ANSI-Sequenzen (Rollen→Farbe), dann `println`.
|
|
||||||
- `showRoom`: Stufe-B-Layout mit Box-Drawing-Rahmen (Heading + Sektionen
|
|
||||||
„You see / Here is / Exits"), Rollen-Farben.
|
|
||||||
- `setHud`: dim gefärbte Statuszeile (`[ Kitchen · 0 gold · turn 7 · light: off ]`).
|
|
||||||
- **ASCII-Art-Banner**: Methode/Helfer für große Momente (Start, Finale); Kunst aus
|
|
||||||
Ressourcen geladen. Mechanismus in Scope, konkrete Kunstwerke folgen mit Inhalten.
|
|
||||||
- **Konfiguration** (Defaults sicher, Verdrahtung mit Settings-Menü später):
|
|
||||||
- `ColorMode`: an/aus; Auto-Erkennung (`System.console() == null` → keine ANSI).
|
|
||||||
- `GlyphMode`: `ASCII | UNICODE | GLYPH`, Default `UNICODE` (Box-Drawing breit
|
|
||||||
unterstützt; Nerd-Glyphen nur bei explizitem Opt-in). Bei `ASCII` degradieren
|
|
||||||
die Rahmen auf `+`, `-`, `|` statt der Unicode-Box-Drawing-Zeichen.
|
|
||||||
|
|
||||||
### 4.6 SwingIO (Renderer) + Layout
|
|
||||||
|
|
||||||
Region-Layout (bestätigt, Variante A):
|
|
||||||
- **NORTH**: HUD-Leiste (`JPanel` mit `JLabel`s) – Ort · Gold · Zug · Licht.
|
|
||||||
- **CENTER**: `JTextPane` mit `StyledDocument` (Farben/Stile via `AttributeSet` pro
|
|
||||||
`Style`), scrollbar.
|
|
||||||
- **EAST**: Seitenpanel, reserviert für Karte; zeigt bis dahin `RoomView.exits` als Liste.
|
|
||||||
- **SOUTH**: Eingabefeld (bestehende `LinkedBlockingQueue`-Brücke bleibt).
|
|
||||||
|
|
||||||
**Gebündelter Font:** `.ttf` unter `src/main/resources/fonts/`, via
|
|
||||||
`Font.createFont(Font.TRUETYPE_FONT, stream)` geladen und registriert → Farben und
|
|
||||||
**Glyphen funktionieren ohne Installation auf dem Spieler-System**. Fallback auf
|
|
||||||
`Font.MONOSPACED` bei Ladefehler (geloggt).
|
|
||||||
|
|
||||||
`print`/`showRoom` schreiben gestylte Spans in das `JTextPane`; `setHud` aktualisiert
|
|
||||||
die Labels. Alle Swing-Mutationen via `SwingUtilities.invokeLater` (wie bisher).
|
|
||||||
|
|
||||||
## 5. Datenfluss
|
|
||||||
|
|
||||||
```
|
|
||||||
Command → baut RoomView/StyledText/Hud → GameIO.{showRoom|print|setHud}
|
|
||||||
→ Frontend-Renderer → Konsole (ANSI) bzw. GUI (Swing)
|
|
||||||
```
|
|
||||||
Game-Loop, Parser, Registry, Domain: **unverändert**.
|
|
||||||
|
|
||||||
## 6. Nerd-Font / Glyph-Strategie (zusammengefasst)
|
|
||||||
|
|
||||||
- **GUI**: Font mitliefern (bundled `.ttf`) → Problem gelöst, keine Installation nötig.
|
|
||||||
- **Konsole**: Font nicht kontrollierbar → **gestufte Fallbacks** (`ASCII` →
|
|
||||||
`UNICODE`-Box-Drawing → `GLYPH`). Default `UNICODE`. Keine harte Abhängigkeit von
|
|
||||||
installierten Nerd Fonts.
|
|
||||||
|
|
||||||
## 7. Migration der Commands & Tests
|
|
||||||
|
|
||||||
- `LookCommand`: statt `StringBuilder` eine `RoomView` bauen → `io.showRoom(view)`.
|
|
||||||
- Übrige Commands (`Take`, `Drop`, `Use`, `Read`, `Examine`, `Talk`, `Give`,
|
|
||||||
`Inventory`, `Help`): `io.print(StyledText…)` mit Rollen, wo es Mehrwert bringt;
|
|
||||||
sonst genügt das Default-`write(String)`. Schrittweise, nicht Big-Bang.
|
|
||||||
- `TestIO`: implementiert `print`/`showRoom`/`setHud` durch **Flattening auf
|
|
||||||
Klartext** (`StyledText.plainText()` bzw. RoomView→Textblock), abgelegt in
|
|
||||||
`outputs`. `lastOutput()`/`allOutput()` liefern weiterhin Klartext → die
|
|
||||||
bestehenden `.contains(...)`-Assertions der 67 Tests bleiben gültig.
|
|
||||||
|
|
||||||
## 8. Fehlerbehandlung
|
|
||||||
|
|
||||||
| Fall | Verhalten |
|
|
||||||
|---|---|
|
|
||||||
| Gebündelter Font nicht ladbar | Log-Warnung, Fallback `Font.MONOSPACED` |
|
|
||||||
| Terminal ohne ANSI / Umleitung | Auto-Erkennung → Farben aus, reiner Text |
|
|
||||||
| `GlyphMode=GLYPH` ohne passende Terminal-Font | Nutzer-Opt-in; kein Auto-Fallback nötig, Default bleibt `UNICODE` |
|
|
||||||
| `null`-Text an `StyledText.of` | `IllegalArgumentException` (wie bestehende Konventionen) |
|
|
||||||
|
|
||||||
## 9. Testing-Strategie
|
|
||||||
|
|
||||||
- **Unit**: `StyledText`-Builder & `plainText()`, `Span`, Immutabilität von
|
|
||||||
`RoomView`/`Hud`.
|
|
||||||
- **ConsoleIO**: Rendern einer bekannten `RoomView`/`Hud` mit Farbe AUS gegen einen
|
|
||||||
`StringWriter`/`PrintStream` (Test-Konstruktor existiert bereits) → strukturelle
|
|
||||||
Assertions (Heading, Labels, Exit-Namen).
|
|
||||||
- **Command-Tests**: laufen unverändert über das angepasste `TestIO` (Klartext).
|
|
||||||
- **SwingIO**: nicht unit-getestet (EDT/Swing); Logik dünn halten, manueller
|
|
||||||
Smoke-Test. Verifikation per `mvn exec:java@gui`.
|
|
||||||
|
|
||||||
## 10. Handoffs an spätere Teilprojekte
|
|
||||||
|
|
||||||
- **Karte (2)**: GUI-Seitenpanel + künftige `showMap(MapView)`-Methode;
|
|
||||||
`RoomView.exits` ist bis dahin Platzhalter-Quelle.
|
|
||||||
- **Menü/Settings (6)**: verdrahtet später `ColorMode`/`GlyphMode`/Lautstärke.
|
|
||||||
- **Save (7)**, **Musik (8)**: nutzen die HUD/Region-Slots, hier nicht implementiert.
|
|
||||||
|
|
||||||
## 11. Offene Detailfragen (in Implementierung entscheidbar)
|
|
||||||
|
|
||||||
- Konkrete Wahl des gebündelten Fonts (freier Monospace bzw. Nerd-Font-Variante,
|
|
||||||
Lizenz prüfen).
|
|
||||||
- Exakte ANSI-Farbpalette pro Rolle.
|
|
||||||
- Ob `Hud` schon ein `lightOn` führt, obwohl die Licht-Mechanik erst mit den Inhalten
|
|
||||||
kommt → Feld vorsehen, vorerst konstant `false`/ausgeblendet.
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# Spec: Quest-Fundament – World-State & Bedingungen/Effekte (Teilprojekt 3.1)
|
|
||||||
|
|
||||||
Stand: 2026-05-31. Erstes Stück des Quest-Systems (Teilprojekt 3) aus
|
|
||||||
[enhancement-ideas.md](../../enhancement-ideas.md). Liefert die Zustands- und
|
|
||||||
Bedingungs-/Effekt-Engine, auf der Quests (#3.2) und Enden (#3.3) aufbauen.
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
Das NPC-System ist heute statisch: fester `greeting`, Reaktionen
|
|
||||||
(`consumes`/`gives`/`response`) ohne Bedingungen, kein Weltzustand. Ziel:
|
|
||||||
**gated progression** datengetrieben ermöglichen – verschlossene Türen,
|
|
||||||
zustandsabhängige Beschreibungen, reaktive NPCs, Schalter, die Weltzustand setzen –
|
|
||||||
über **eine** kleine, wiederverwendbare Bedingungs-/Effekt-Sprache (Ansatz A).
|
|
||||||
|
|
||||||
Scope **core-only** (bestätigt): Licht/Dunkelheit und Item-Kombination werden
|
|
||||||
bewusst auf spätere kleine Folge-Specs verschoben.
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope:**
|
|
||||||
- **`GameState`** – Flag-Speicher (benannte Booleans).
|
|
||||||
- **Condition/Effect-Modell** + Auswerter (`Conditions`) + Anwender (`Effects`).
|
|
||||||
- Fünf Integrationspunkte: verschlossene Exits, zustandsabhängige
|
|
||||||
Raumbeschreibungen, bedingte NPC-Dialoge, Reaktionen mit `requires`/`effects`,
|
|
||||||
Schalter mit `effects`.
|
|
||||||
- Loader/DTO/Factory/Resolver-Erweiterungen, alle neuen YAML-Felder **optional**
|
|
||||||
(Rückwärtskompatibilität).
|
|
||||||
|
|
||||||
**Out of Scope (eigene spätere Specs):**
|
|
||||||
- Licht & Dunkelheit; Item-Kombination (`use X on Y`).
|
|
||||||
- Quest-Objekte, Quest-Log, GUI-Quest-Box (Teilprojekt #3.2).
|
|
||||||
- Win-Condition / mehrere Enden (#3.3).
|
|
||||||
- `startFlags`-Seeding (YAGNI – Flags starten leer = alle false, was der
|
|
||||||
Ausgangslage „Strom aus, Keller geflutet" entspricht).
|
|
||||||
|
|
||||||
## 3. Kern-Engine (Ansatz A)
|
|
||||||
|
|
||||||
### 3.1 GameState
|
|
||||||
|
|
||||||
`GameState` (Package `game`) hält die gesetzten Flags als `Set<String>`
|
|
||||||
(vorhanden = true). API: `isSet(name)`, `set(name)`, `clear(name)`, `all()`.
|
|
||||||
|
|
||||||
`GameContext` bekommt ein **selbst-initialisiertes** Feld
|
|
||||||
`private final GameState state = new GameState();` (per `@Getter` lesbar). Da das
|
|
||||||
Feld initialisiert ist, bleibt der `@RequiredArgsConstructor` (world, player, io)
|
|
||||||
**unverändert** – keine Anpassung der bestehenden Konstruktionsstellen nötig.
|
|
||||||
|
|
||||||
### 3.2 Condition (Daten in `model`, Auswerter in `game`)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record Condition(Type type, String arg) {
|
|
||||||
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`Conditions.all(List<Condition>, GameContext)` → true, wenn alle gelten (leere
|
|
||||||
Liste = true):
|
|
||||||
- `FLAG` → `state.isSet(arg)`
|
|
||||||
- `NOT_FLAG` → `!state.isSet(arg)`
|
|
||||||
- `HAS_ITEM` → `player.hasItem(arg)`
|
|
||||||
|
|
||||||
### 3.3 Effect (Daten in `model`, Anwender in `game`)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record Effect(Type type, String arg) {
|
|
||||||
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
`Effects.applyAll(List<Effect>, GameContext)` wendet jeden an:
|
|
||||||
- `SET_FLAG`/`CLEAR_FLAG` → `state.set/clear(arg)`
|
|
||||||
- `GIVE_ITEM` → Item aus `world.getItems().get(arg)` ins Inventar (unbekannt →
|
|
||||||
Warnung geloggt, übersprungen)
|
|
||||||
- `REMOVE_ITEM` → `player.removeItem(arg)`
|
|
||||||
- `SAY` → `io.write(arg)`
|
|
||||||
|
|
||||||
## 4. Integrationspunkte
|
|
||||||
|
|
||||||
Jeder trägt nur `requires` (Conditions) und/oder `effects` und ruft die Helfer.
|
|
||||||
|
|
||||||
| # | Träger (model) | YAML-Feld | Konsument | Verhalten |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | `Room.exitLocks: EnumMap<Direction, ExitLock>` | `exitLocks` | `GoCommand` | Vor Bewegung: gilt `requires` nicht → `blocked` ausgeben, nicht bewegen |
|
|
||||||
| 2 | `Room.descriptionStates: List<DescriptionState>` | `descriptionStates` | `LookCommand` | Erste passende Variante als Beschreibung, sonst `description` |
|
|
||||||
| 3 | `Npc.dialogue: List<DialogueLine>` | `dialogue` | `TalkCommand` | Erste passende Zeile, sonst `greeting` |
|
|
||||||
| 4 | `NpcReaction.requires/effects` | `requires`, `effects` | `GiveCommand` | `requires` prüfen; bei Erfolg consume/give wie bisher + `effects` |
|
|
||||||
| 5 | `SwitchableItem.effects: List<Effect>` | `effects` | `use` | Beim Schalten **auf „on"**: `effects` anwenden |
|
|
||||||
|
|
||||||
Hilfstypen (records in `model`): `ExitLock(List<Condition> requires, String blocked)`,
|
|
||||||
`DescriptionState(List<Condition> when, String text)`,
|
|
||||||
`DialogueLine(List<Condition> when, String text)`.
|
|
||||||
|
|
||||||
### YAML-Beispiele
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# rooms.yaml
|
|
||||||
exits: { east: cellar }
|
|
||||||
exitLocks:
|
|
||||||
- direction: east
|
|
||||||
requires: [{ flag: power_on }]
|
|
||||||
blocked: "The cellar door won't budge without power."
|
|
||||||
descriptionStates:
|
|
||||||
- when: [{ flag: power_on }]
|
|
||||||
text: "The cellar, now lit, reveals a workbench."
|
|
||||||
|
|
||||||
# npcs.yaml
|
|
||||||
dialogue:
|
|
||||||
- when: [{ flag: power_on }]
|
|
||||||
text: '"You did it — the lights are back!"'
|
|
||||||
reactions:
|
|
||||||
- onReceive: fuse
|
|
||||||
requires: [{ flag: cellar_drained }]
|
|
||||||
response: "That'll hold now."
|
|
||||||
effects:
|
|
||||||
- { removeItem: fuse }
|
|
||||||
- { setFlag: fuse_installed }
|
|
||||||
|
|
||||||
# items.yaml
|
|
||||||
- id: breaker
|
|
||||||
type: switchable
|
|
||||||
name: Breaker
|
|
||||||
onText: "You flip the breaker. The manor hums to life."
|
|
||||||
offText: "You switch the breaker off."
|
|
||||||
state: false
|
|
||||||
effects:
|
|
||||||
- { setFlag: power_on }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Loading / DTO / Mapping
|
|
||||||
|
|
||||||
- Neue DTOs (`loader.dto`): `ConditionDto(flag, notFlag, hasItem)`,
|
|
||||||
`EffectDto(setFlag, clearFlag, giveItem, removeItem, say)`, `ExitLockDto`,
|
|
||||||
`DescriptionStateDto`, `DialogueLineDto`. Jeweils `toModel()`.
|
|
||||||
- Erweiterte DTOs: `RoomDto` (+`exitLocks`, +`descriptionStates`), `NpcDto`
|
|
||||||
(+`dialogue`), `ReactionDto` (+`requires`, +`effects`), `ItemDto` (+`effects`).
|
|
||||||
- `ConditionDto.toModel()`: genau ein Feld gesetzt → entsprechende `Condition`,
|
|
||||||
sonst `WorldLoadException`. Analog `EffectDto`.
|
|
||||||
- Verdrahtung: `ReferenceResolver.resolveRooms` baut `exitLocks` (Direction via
|
|
||||||
`Direction.fromString`) und `descriptionStates`; `resolveNpcs` baut `dialogue`
|
|
||||||
und erweitert die `NpcReaction` um `requires`/`effects`; `ItemFactory` setzt
|
|
||||||
`SwitchableItem.effects`.
|
|
||||||
- **Item-Referenzen in Conditions/Effects** (`hasItem`/`giveItem`/`removeItem`)
|
|
||||||
werden **nicht** beim Laden aufgelöst, sondern zur Laufzeit (lazy) – kein
|
|
||||||
Eingriff in die strikte Resolver-Validierung; `GIVE_ITEM` unbekannt ist
|
|
||||||
laufzeit-tolerant (Warnung).
|
|
||||||
|
|
||||||
## 6. Fehlerbehandlung
|
|
||||||
|
|
||||||
| Fall | Verhalten |
|
|
||||||
|---|---|
|
|
||||||
| Condition/Effect-DTO ohne gesetztes Feld | `WorldLoadException` beim Laden |
|
|
||||||
| `exitLocks` mit unbekannter Richtung | `WorldLoadException` (`Direction.fromString`) |
|
|
||||||
| `GIVE_ITEM` mit unbekannter Item-ID | Warnung geloggt, Effekt übersprungen |
|
|
||||||
| Neue Felder fehlen (alte YAMLs) | null → als leere Liste behandelt, lädt normal |
|
|
||||||
| Schalter `off`→`on` mehrfach | `effects` werden bei jedem Übergang auf „on" angewendet (idempotent bei `setFlag`) |
|
|
||||||
|
|
||||||
## 7. Rückwärtskompatibilität
|
|
||||||
|
|
||||||
Alle neuen Felder sind optional (nullable). Bestehende `rooms.yaml`, `npcs.yaml`,
|
|
||||||
`items.yaml` und alle Test-Fixtures laden unverändert; die 90 bestehenden Tests
|
|
||||||
bleiben grün. `NpcReaction` behält `consumes`/`gives`/`response`.
|
|
||||||
|
|
||||||
## 8. Testing
|
|
||||||
|
|
||||||
- **Unit:** `GameState` (set/clear/isSet); `Conditions.all` (FLAG/NOT_FLAG/HAS_ITEM,
|
|
||||||
AND, leer=true); `Effects.applyAll` (alle fünf Typen, unbekanntes Item tolerant);
|
|
||||||
`ConditionDto.toModel`/`EffectDto.toModel` (inkl. Fehlerfall).
|
|
||||||
- **Integration (in-Code-Welt wie bestehende Command-Tests):**
|
|
||||||
- `GoCommand`: gesperrter Exit blockt ohne Flag, passiert mit Flag.
|
|
||||||
- `LookCommand`: Beschreibung wechselt mit Flag.
|
|
||||||
- `TalkCommand`: Dialogzeile wechselt mit Flag, sonst `greeting`.
|
|
||||||
- `GiveCommand`: Reaktion mit `requires` blockt/erlaubt; `effects` setzen Flag.
|
|
||||||
- `SwitchableItem.use`: setzt Flag beim Einschalten.
|
|
||||||
- **Loader:** ein Round-Trip-Test, der ein YAML mit allen neuen Feldern lädt;
|
|
||||||
ein Test, dass alte YAMLs ohne neue Felder weiterhin laden.
|
|
||||||
|
|
||||||
## 9. Demonstration (am Ende, minimal)
|
|
||||||
|
|
||||||
Eine kleine, sichere Erweiterung der echten Welt-YAMLs, um die Kette
|
|
||||||
end-to-end spielbar zu zeigen (z. B. Schalter setzt ein Flag, das eine Tür
|
|
||||||
öffnet und eine Beschreibung ändert) – ohne bestehende Tests zu berühren.
|
|
||||||
|
|
||||||
## 10. Offene Detailfragen (in Implementierung entscheidbar)
|
|
||||||
|
|
||||||
- `SAY`-Effekt: schlicht `io.write` oder gestylt (`StyledText`) – zunächst `write`.
|
|
||||||
- Ob `ExitLock`/`DescriptionState`/`DialogueLine` als records in `model` oder
|
|
||||||
`model.state` liegen – im Plan festgelegt (`model`).
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# Spec: Quests & Quest-Log (Teilprojekt 3.2)
|
|
||||||
|
|
||||||
Stand: 2026-05-31. Baut auf dem Quest-Fundament (3.1) auf: Flags, Conditions,
|
|
||||||
Effects sind vorhanden. Liefert echte Quest-Objekte, eine Fortschritts-Engine und
|
|
||||||
die Anzeige (Konsolen-Befehl + GUI-Quest-Box unter der Karte).
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
Das Fundament erlaubt gated progression über Flags. Jetzt: **strukturierte Quests**
|
|
||||||
mit mehreren Stufen, deren Fortschritt **automatisch aus dem Weltzustand** erkannt
|
|
||||||
wird (bestätigt: Ansatz A, condition-driven), plus sichtbares **Quest-Log**.
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope:**
|
|
||||||
- Quest-Datenmodell (`Quest`, `QuestStage`) + `quests.yaml`.
|
|
||||||
- Runtime: `QuestLog` (Fortschritt), `QuestEngine` (Tick pro Zug, Auto-Advance,
|
|
||||||
Ansagen), `START_QUEST`-Effekt.
|
|
||||||
- Anzeige: `QuestView`-Modell, `GameIO.setQuests/showQuests`, Konsolen-Befehl
|
|
||||||
`quests`, GUI-Quest-Box **unter der Karte**.
|
|
||||||
- Loader: `QuestDto`/`QuestStageDto`, `QuestFactory`, `quests.yaml` (optional),
|
|
||||||
`World.quests`.
|
|
||||||
- Demo-Quest in der echten Welt.
|
|
||||||
|
|
||||||
**Out of Scope (3.3 / später):**
|
|
||||||
- Win-Condition / mehrere Enden (3.3) – eine Quest kann aber per `onComplete`-Effekt
|
|
||||||
ein Flag setzen, das 3.3 nutzt.
|
|
||||||
- Quest-Verzweigung, Fehlschlag-Zustände, Voraussetzungen (per `startQuest`-Effekt
|
|
||||||
ausdrückbar).
|
|
||||||
|
|
||||||
## 3. Datenmodell (model)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record Quest(String id, String title, boolean autoStart,
|
|
||||||
List<QuestStage> stages, List<Effect> onComplete) { /* List.copyOf */ }
|
|
||||||
|
|
||||||
public record QuestStage(String objective, List<Condition> completion,
|
|
||||||
List<Effect> onComplete) { /* List.copyOf */ }
|
|
||||||
```
|
|
||||||
`quests.yaml`:
|
|
||||||
```yaml
|
|
||||||
- id: restore_power
|
|
||||||
title: "Bring the Manor to Life"
|
|
||||||
autoStart: true
|
|
||||||
stages:
|
|
||||||
- objective: "Get the power running."
|
|
||||||
completion: [{ flag: power_on }]
|
|
||||||
- objective: "Earn the Old Man's brass key."
|
|
||||||
completion: [{ hasItem: key }]
|
|
||||||
onComplete:
|
|
||||||
- { setFlag: manor_secured }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Runtime (game)
|
|
||||||
|
|
||||||
### 4.1 QuestLog (in GameContext)
|
|
||||||
|
|
||||||
`QuestLog` (selbst-initialisiertes Feld in `GameContext`, wie `GameState` → kein
|
|
||||||
Konstruktor-Eingriff). Hält nur Laufzeit-Fortschritt:
|
|
||||||
- `Map<String, Integer> stageIndex` – aktuelle Stufe je *aktiver* Quest.
|
|
||||||
- `Set<String> completed` – abgeschlossene Quest-IDs.
|
|
||||||
- API: `start(id)`, `isActive(id)`, `isCompleted(id)`, `stageIndex(id)`,
|
|
||||||
`advance(id)`, `complete(id)`.
|
|
||||||
|
|
||||||
### 4.2 START_QUEST-Effekt
|
|
||||||
|
|
||||||
`Effect.Type.START_QUEST` (arg = Quest-ID). `Effects.apply` ruft
|
|
||||||
`ctx.getQuestLog().start(arg)`. `EffectDto` bekommt Feld `startQuest`. Damit können
|
|
||||||
Dialoge/Schalter/Reaktionen Quests starten.
|
|
||||||
|
|
||||||
### 4.3 QuestEngine.tick(ctx)
|
|
||||||
|
|
||||||
Pro Zug aus der `Game`-Schleife (neben `setHud`/`setMap`):
|
|
||||||
1. Auto-Start: jede `autoStart`-Quest, die weder aktiv noch abgeschlossen ist, starten.
|
|
||||||
2. Für jede **aktive** Quest die `completion` der **aktuellen** Stufe via
|
|
||||||
`Conditions.all` prüfen. Erfüllt →
|
|
||||||
- `onComplete`-Effekte der Stufe anwenden,
|
|
||||||
- Ansage (`io.print`, z. B. „✓ Objective complete: …"),
|
|
||||||
- `advance`; war es die letzte Stufe → Quest-`onComplete` anwenden, `complete`,
|
|
||||||
Ansage „Quest complete: …".
|
|
||||||
3. **Kaskade**: Schleife wiederholen, solange sich etwas geändert hat (begrenzt durch
|
|
||||||
Gesamt-Stufenzahl), damit ein Zug mehrere Stufen auslösen kann.
|
|
||||||
|
|
||||||
`tick` ist idempotent (abgeschlossene Stufen feuern nicht erneut). `QuestEngine.viewOf(ctx)`
|
|
||||||
baut die `QuestView` aus `World.getQuests()` + `QuestLog`.
|
|
||||||
|
|
||||||
## 5. Anzeige
|
|
||||||
|
|
||||||
### 5.1 Modell (io.text)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record QuestEntry(String title, String objective) {}
|
|
||||||
public record QuestView(List<QuestEntry> active, List<String> completed) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 GameIO (additiv, wie die Karte)
|
|
||||||
|
|
||||||
```java
|
|
||||||
default void setQuests(QuestView view) { /* no-op */ } // Push pro Zug (GUI)
|
|
||||||
default void showQuests(QuestView view) { print(QuestText.render(view)); } // on demand (Konsole)
|
|
||||||
```
|
|
||||||
- **Konsole**: nutzt beide Defaults. `setQuests` = no-op; `showQuests` druckt via
|
|
||||||
`QuestText.render` (io.text), ausgelöst durch `QuestsCommand`.
|
|
||||||
- **GUI**: überschreibt `setQuests` (Quest-Box neu zeichnen) und `showQuests` (no-op,
|
|
||||||
Box ist immer sichtbar).
|
|
||||||
- `TestIO`: Defaults; `showQuests` druckt in `outputs` → testbar.
|
|
||||||
|
|
||||||
### 5.3 Konsolen-Befehl
|
|
||||||
|
|
||||||
`QuestsCommand` (`quests`, `log`, `journal`) → `io.showQuests(QuestEngine.viewOf(ctx))`.
|
|
||||||
Ausgabe: aktive Quests (Titel + aktuelles Ziel), darunter abgeschlossene (dim, mit ✓).
|
|
||||||
|
|
||||||
### 5.4 GUI-Quest-Box
|
|
||||||
|
|
||||||
Rechter Seitenbereich wird zu einem Panel: **Karte oben, Quest-Box unten**
|
|
||||||
(`BorderLayout`: Map-Scroll CENTER, Quest-Scroll SOUTH mit fester/anteiliger Höhe).
|
|
||||||
Die Quest-Box ist ein gestyltes, schreibgeschütztes Textfeld (`JTextPane`): aktive
|
|
||||||
Quests fett (Titel) + Ziel; abgeschlossene dim mit ✓. Font folgt der Auto-Skalierung
|
|
||||||
(`applyFonts`). `setQuests` aktualisiert sie pro Zug.
|
|
||||||
|
|
||||||
## 6. Loading
|
|
||||||
|
|
||||||
- `QuestDto(id, title, Boolean autoStart, List<QuestStageDto> stages, List<EffectDto> onComplete)`,
|
|
||||||
`QuestStageDto(objective, List<ConditionDto> completion, List<EffectDto> onComplete)`.
|
|
||||||
- `QuestFactory.fromDto` baut `Quest` (Conditions/Effects via vorhandene
|
|
||||||
`toModelList`); keine Resolver-Phase nötig (Flags/Items lazy, Quest-IDs sind Strings).
|
|
||||||
- `WorldLoader` liest `quests.yaml` **optional** (fehlt die Datei → leere Liste),
|
|
||||||
baut `Map<String, Quest>` und übergibt sie an `World`.
|
|
||||||
- `World` bekommt Feld `quests` + **rückwärtskompatiblen 5-Arg-Konstruktor**
|
|
||||||
(delegiert mit leerer Quest-Map), damit bestehende `new World(...)`-Aufrufe in Tests
|
|
||||||
unverändert kompilieren.
|
|
||||||
|
|
||||||
## 7. Fehlerbehandlung
|
|
||||||
|
|
||||||
| Fall | Verhalten |
|
|
||||||
|---|---|
|
|
||||||
| `quests.yaml` fehlt | leere Quest-Map, Spiel läuft ohne Quests |
|
|
||||||
| `START_QUEST` mit unbekannter Quest-ID | `QuestLog.start` legt Eintrag an; `viewOf` ignoriert IDs ohne Definition (Warnung geloggt) |
|
|
||||||
| Quest ohne Stufen | gilt sofort als abgeschlossen beim ersten Tick |
|
|
||||||
| Doppelte Quest-IDs | `WorldLoader.requireUniqueIds` erweitert um Quests |
|
|
||||||
|
|
||||||
## 8. Testing
|
|
||||||
|
|
||||||
- **QuestLog**: start/advance/complete, isActive/isCompleted.
|
|
||||||
- **QuestEngine**: Auto-Start; Stufe rückt vor, wenn Condition gilt; Ansage; Quest
|
|
||||||
schließt nach letzter Stufe ab; Kaskade (eine Stufe löst die nächste); `viewOf`
|
|
||||||
liefert aktive + abgeschlossene korrekt.
|
|
||||||
- **Effects**: `START_QUEST` startet Quest.
|
|
||||||
- **EffectDto/QuestDto**: `toModel`/`fromDto`-Mapping.
|
|
||||||
- **QuestsCommand**: über `TestIO` – Ausgabe enthält aktives Ziel.
|
|
||||||
- **WorldLoader**: lädt `quests.yaml`; fehlende Datei → leer.
|
|
||||||
- **GUI**: nicht unit-getestet; manueller Smoke (`exec:java@gui`).
|
|
||||||
|
|
||||||
## 9. Demonstration
|
|
||||||
|
|
||||||
`quests.yaml` mit `restore_power` (autoStart, 2 Stufen): Strom anschalten →
|
|
||||||
Schlüssel vom alten Mann holen. Sichtbar im Konsolen-`quests`-Befehl und in der
|
|
||||||
GUI-Box; Ansagen beim Erfüllen.
|
|
||||||
|
|
||||||
## 10. Offene Detailfragen (in Implementierung)
|
|
||||||
|
|
||||||
- Höhe/Anteil der GUI-Quest-Box (ca. 30 % der Seitenleiste, scrollbar).
|
|
||||||
- Stil der Ansagen (zunächst `HEADING`).
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
# Spec: Hauptmenü + Speichern/Laden (+ minimale Settings)
|
|
||||||
|
|
||||||
Stand: 2026-06-01. Erstes Teilprojekt der Erweiterungsrunde nach Abschluss von
|
|
||||||
Phasen 1–7 (Backlog #6 + #7, Teil von #6-Settings). Baut auf vorhandenem
|
|
||||||
`GameIO`-Abstraktions-Modell, `GameContext` (World/Player/GameState/QuestLog) und
|
|
||||||
dem `App.run(io)`-Einstieg auf, der von Konsole und GUI geteilt wird.
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
Das Spiel startet bisher direkt in die Spielschleife und endet per `quit` /
|
|
||||||
Ending / EOF mit Prozess-Ende; es gibt keine Persistenz. Ziel:
|
|
||||||
|
|
||||||
- **Hauptmenü oberhalb der Spielschleife** (Neues Spiel · Laden · Einstellungen ·
|
|
||||||
Beenden), identisch in Konsole und GUI über die geteilte `GameIO`-Abstraktion.
|
|
||||||
- **Speichern/Laden** des veränderlichen Spielzustands als JSON, als **Delta über
|
|
||||||
die frisch aus YAML geladene Welt** (Welt-Definition bleibt datengetrieben; der
|
|
||||||
Spielstand enthält nur den mutierten Zustand).
|
|
||||||
- **Ein aktiver Slot pro laufendem Spiel** (beim Neues-Spiel benannt). Manuelles
|
|
||||||
`save`, Autosave und Speichern-beim-Verlassen schreiben alle in diesen Slot.
|
|
||||||
- **Minimale Settings**: Farb-Modus + Glyphen-Modus (ASCII/Unicode) umschalten,
|
|
||||||
persistiert.
|
|
||||||
|
|
||||||
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
|
|
||||||
- Menü-Integration: **eine neue `GameIO`-Primitive `choose(...)`** (Konsole-Default
|
|
||||||
= nummeriertes Textmenü, `SwingIO` überschreibt mit Buttons). Menü-Logik
|
|
||||||
einmalig geschrieben, Konsolen-Parität gratis.
|
|
||||||
- Save-Format: **JSON** (maschinengenerierter Zustand; klar getrennt vom
|
|
||||||
YAML-Content). Jackson ist bereits Abhängigkeit.
|
|
||||||
- Slot-Modell: **ein aktiver Slot für alles** (save + autosave + quit-save
|
|
||||||
überschreiben denselben Slot; Menü-Laden listet alle benannten Spielstände).
|
|
||||||
- `quit`/`exit`/`menu` in-game = **speichern + zurück ins Hauptmenü**; Prozess-Ende
|
|
||||||
nur über das Hauptmenü-„Beenden".
|
|
||||||
- Neues Spiel: **Slot-Name abfragen, mit Default** (Enter akzeptiert Default).
|
|
||||||
- Autosave: **Events (Quest abgeschlossen, quit-to-menu) + Intervall alle 10 Züge**.
|
|
||||||
- Settings-Datei: **`saves/settings.json`**.
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope:**
|
|
||||||
- Shell-Schleife in `App.run(io)` (Menü-Schicht über dem Game-Loop).
|
|
||||||
- `menu`-Paket: `MainMenu`, `SettingsMenu`, `MenuAction`.
|
|
||||||
- `GameIO.choose(String title, List<String> options) → int` + Konsole-Default
|
|
||||||
(nummeriert, Re-Prompt) + `SwingIO`-Override (Buttons).
|
|
||||||
- `GameSession` (Bündel des speicherbaren Zustands + Slot-Name + `turn`).
|
|
||||||
- `SaveData`-DTO (JSON) + `SaveService` (save/list/load, atomar) + `SaveSlotInfo`.
|
|
||||||
- In-Game-Befehle: `save`, `menu` (Aliase `quit`/`exit`). Kein `load`-Befehl.
|
|
||||||
- Autosave: Quest-Completion-Hook, quit-to-menu, Intervall alle 10 Züge.
|
|
||||||
- `Settings` + `SettingsStore` (`saves/settings.json`); `ConsoleIO`-Farb-/Glyphen-
|
|
||||||
Modus zur Laufzeit umschaltbar machen (Felder non-final + Setter).
|
|
||||||
- Tests: Round-Trip, Overlay, Failure-Fälle, Menü-Logik, Konsolen-`choose`.
|
|
||||||
|
|
||||||
**Out of Scope:**
|
|
||||||
- Mehrere Slots pro Spiel / getrennter Autosave-Slot (bewusst „ein aktiver Slot").
|
|
||||||
- Settings für Musik/Typewriter (Features existieren noch nicht).
|
|
||||||
- GUI-Hintergrundbilder/„echte" Menü-Screens (Buttons genügen).
|
|
||||||
- Pathfinding / `go to <raum>` (eigene spätere Runde, in Scope gehalten).
|
|
||||||
- Migration alter Save-Versionen (nur Versions-Check + graceful refuse).
|
|
||||||
|
|
||||||
## 3. Architektur: Shell-Schleife über dem Loop
|
|
||||||
|
|
||||||
`App.run(io)` wird zur **Shell-Schleife**, die den Lebenszyklus besitzt. Der
|
|
||||||
Prozess endet nur über Hauptmenü-„Beenden"; Spielende/`menu` kehren ins Menü
|
|
||||||
zurück.
|
|
||||||
|
|
||||||
```
|
|
||||||
App.run(io):
|
|
||||||
Settings s = SettingsStore.load(); // → auf io anwenden (Farbe/Glyphen)
|
|
||||||
loop:
|
|
||||||
switch (MainMenu.show(io)) {
|
|
||||||
NEW_GAME -> { GameSession g = newSession(promptName(io)); play(io, g); }
|
|
||||||
LOAD -> { SaveSlotInfo slot = pickSlot(io); // oder „zurück"
|
|
||||||
if (slot != null) {
|
|
||||||
try { play(io, SaveService.load(slot.slug())); }
|
|
||||||
catch (SaveException e) { io.write(e.getUserMessage()); }
|
|
||||||
} }
|
|
||||||
SETTINGS -> SettingsMenu.show(io, s); // toggelt + persistiert + wendet an
|
|
||||||
QUIT -> return;
|
|
||||||
}
|
|
||||||
play(io, session):
|
|
||||||
GameContext ctx = new GameContext(session); // s.u.
|
|
||||||
CommandRegistry registry = buildRegistry(...); // inkl. save/menu
|
|
||||||
Game game = new Game(ctx, registry, parser);
|
|
||||||
Banner + Welcome + erstes look;
|
|
||||||
game.run(); // kehrt zurück bei menu/quit/Ending/EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
`newSession(name)` lädt die Welt frisch aus YAML (`WorldLoader`) und baut eine
|
|
||||||
neue `GameSession`. `SaveService.load` lädt die Welt ebenso frisch und legt das
|
|
||||||
Save-Delta darüber.
|
|
||||||
|
|
||||||
## 4. `GameSession` (game)
|
|
||||||
|
|
||||||
Bündelt den **speicherbaren** Zustand an einem Ort (heute über `GameContext` +
|
|
||||||
privates `turn` in `Game` verstreut). `turn` wandert hierher, damit es gespeichert
|
|
||||||
und in der Slot-Metadata gezeigt werden kann.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class GameSession {
|
|
||||||
private final World world; // aus YAML geladen (statisch)
|
|
||||||
private final Player player;
|
|
||||||
private final GameState state;
|
|
||||||
private final QuestLog questLog;
|
|
||||||
private int turn;
|
|
||||||
private final String slotName; // gebundener aktiver Slot
|
|
||||||
// getter; getTurn/setTurn/incrementTurn
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`GameContext` bekommt einen Konstruktor `GameContext(GameSession, GameIO)` (oder
|
|
||||||
hält eine `GameSession`-Referenz statt der vier Einzelfelder). Bestehende
|
|
||||||
Command-Signaturen (`execute(ctx, args)`) bleiben unverändert; `ctx.getWorld()`
|
|
||||||
etc. delegieren an die Session. `Game` liest/erhöht `turn` über die Session statt
|
|
||||||
über ein eigenes Feld.
|
|
||||||
|
|
||||||
## 5. Menü-Schicht (menu)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public enum MenuAction { NEW_GAME, LOAD, SETTINGS, QUIT }
|
|
||||||
|
|
||||||
public final class MainMenu { // frontend-agnostisch
|
|
||||||
public static MenuAction show(GameIO io) {
|
|
||||||
int i = io.choose("Haunted Manor", List.of(
|
|
||||||
"New Game", "Load Game", "Settings", "Quit"));
|
|
||||||
return MenuAction.values()[i]; // Reihenfolge = Indizes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`SettingsMenu.show(GameIO, Settings)` toggelt Farbe/Glyphen über wiederholte
|
|
||||||
`choose`-Aufrufe (mit „zurück"-Option), persistiert via `SettingsStore` und wendet
|
|
||||||
live auf das `io` an. `pickSlot`/`promptName` nutzen `choose` bzw. `readLine`.
|
|
||||||
|
|
||||||
### `GameIO.choose` (io)
|
|
||||||
|
|
||||||
Neue Primitive auf `GameIO`:
|
|
||||||
|
|
||||||
```java
|
|
||||||
/** Zeigt eine Auswahl; gibt den 0-basierten Index der Wahl zurück. */
|
|
||||||
default int choose(String title, List<String> options) {
|
|
||||||
print(/* Titel + nummerierte Liste 1..n als StyledText */);
|
|
||||||
while (true) {
|
|
||||||
String line = readLine();
|
|
||||||
Integer n = parseInRange(line, 1, options.size());
|
|
||||||
if (n != null) return n - 1;
|
|
||||||
write("Please enter a number between 1 and " + options.size() + ".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Konsole** nutzt den Default (nummeriert + Re-Prompt). **EOF/`null` → letzte
|
|
||||||
Option** (Konvention: Menüs ordnen die sichere/abbrechende Wahl — „Quit" bzw.
|
|
||||||
„zurück" — stets als letzte Option, sodass EOF nie in einen Zustand führt).
|
|
||||||
- **`SwingIO` überschreibt** `choose`: rendert Buttons (ein Button je Option),
|
|
||||||
blockiert den Worker-Thread bis zum Klick — dasselbe Blocking-Handoff-Muster wie
|
|
||||||
das vorhandene `readLine` (EDT ↔ Worker). Kein Re-Prompt nötig (Buttons sind
|
|
||||||
immer gültig).
|
|
||||||
|
|
||||||
## 6. In-Game-Befehle (command/impl)
|
|
||||||
|
|
||||||
- **`SaveCommand`** (`save`): `SaveService.save(session)` → „Game saved." / bei
|
|
||||||
Fehler graceful Meldung, Loop läuft weiter.
|
|
||||||
- **`MenuCommand`** (`menu`, Aliase `quit`, `exit`): `SaveService.save(session)`
|
|
||||||
dann `game.stop()` → zurück ins Hauptmenü. Ersetzt den bisherigen `QuitCommand`
|
|
||||||
(Bindung an `Game` wie gehabt via `bind(game)`).
|
|
||||||
- **Kein `load`-Befehl** — Laden ausschließlich über das Hauptmenü.
|
|
||||||
|
|
||||||
## 7. Autosave
|
|
||||||
|
|
||||||
Ein aktiver Slot; alle Autosaves überschreiben ihn. Trigger:
|
|
||||||
- **Quest abgeschlossen** — Hook in `QuestEngine` (nach `questLog.complete(id)`),
|
|
||||||
ruft eine `Autosaver`/`SaveService.save(session)`-Callback auf. Der Engine darf
|
|
||||||
dafür Zugriff auf einen Save-Callback bekommen (z.B. via `GameContext`), um die
|
|
||||||
Schichtung (game → loader) nicht zu verletzen.
|
|
||||||
- **quit-to-menu** — `MenuCommand` speichert ohnehin.
|
|
||||||
- **Intervall** — in `Game.run()` nach jedem erfolgreichen Befehl: wenn
|
|
||||||
`turn % 10 == 0`, `SaveService.save(session)`.
|
|
||||||
|
|
||||||
Autosave-Fehler werden geloggt und schlucken (kein Loop-Abbruch); optional eine
|
|
||||||
dezente Meldung.
|
|
||||||
|
|
||||||
## 8. Save-Datenmodell: `SaveData` (loader/save)
|
|
||||||
|
|
||||||
JSON-DTO, reines Delta über die YAML-Welt:
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record SaveData(
|
|
||||||
int schemaVersion, // aktuell 1; Mismatch → refuse
|
|
||||||
String worldTitle, // Sanity-Check gegen geladene Welt
|
|
||||||
String slotName,
|
|
||||||
long savedAtEpochMillis, // System.currentTimeMillis()
|
|
||||||
int turn,
|
|
||||||
String currentRoomId,
|
|
||||||
List<String> visitedRoomIds, // geordnet
|
|
||||||
int gold,
|
|
||||||
List<String> inventoryItemIds, // geordnet
|
|
||||||
List<String> flags, // gesetzte World-Flags
|
|
||||||
Map<String,Integer> questStages, // aktive Quest → Stage-Index
|
|
||||||
List<String> questCompleted,
|
|
||||||
Map<String,List<String>> roomItemIds, // roomId → Item-Ids im Raum
|
|
||||||
Map<String,Boolean> switchStates // SwitchableItem-Id → on/off
|
|
||||||
) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
`inventoryItemIds` + `roomItemIds` erfassen die Item-Platzierung vollständig;
|
|
||||||
Items in keinem von beiden gelten als verbraucht/entfernt. Switch-Zustände decken
|
|
||||||
Lampe/Generator/Tür ab.
|
|
||||||
|
|
||||||
## 9. Persistenz: `SaveService` (loader/save)
|
|
||||||
|
|
||||||
- Verzeichnis `saves/` (relativ zum Arbeitsverzeichnis, on-demand angelegt, in
|
|
||||||
`.gitignore`). Ein Slot = `saves/<slug>.json`, `slug` = sanitisierter Name
|
|
||||||
(`[a-z0-9_-]`, Rest → `_`).
|
|
||||||
- **`save(GameSession)`**: `SaveData` aus Session bauen → Jackson → **atomar**
|
|
||||||
schreiben (Temp-Datei + `Files.move` ATOMIC_MOVE/REPLACE_EXISTING).
|
|
||||||
- **`list() → List<SaveSlotInfo>`**: `saves/*.json` scannen, Header-Felder lesen
|
|
||||||
(`slotName`, `currentRoomId`, `turn`, `savedAtEpochMillis`) → für die
|
|
||||||
Laden-Liste. Beschädigte Dateien überspringen (geloggt).
|
|
||||||
- **`load(slug) → GameSession`**: `SaveData` lesen → Welt frisch via `WorldLoader`
|
|
||||||
→ `SaveApplier` legt das Delta darüber → `GameSession`.
|
|
||||||
- **`SaveApplier`**: löst Ids gegen die frische Welt auf (Räume, Items). Setzt
|
|
||||||
Player-Raum/Gold/Inventar/Visited, World-Flags, QuestLog (Stages + completed),
|
|
||||||
verteilt Items in Räume/Inventar, setzt Switch-Zustände, `turn`.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record SaveSlotInfo(String slug, String slotName, String roomId,
|
|
||||||
int turn, long savedAtEpochMillis) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. Minimale Settings (game/io)
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
|
|
||||||
public static Settings defaults() { return new Settings(ColorMode.AUTO, GlyphMode.UNICODE); }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `SettingsStore.load()/save(Settings)` ↔ `saves/settings.json` (Jackson). Fehlend/
|
|
||||||
beschädigt → `Settings.defaults()`.
|
|
||||||
- **`ConsoleIO`**: `useColor`/`glyphs` von `final` auf veränderlich umstellen +
|
|
||||||
`setColorMode`/`setGlyphMode`, damit ein Settings-Toggle **live** wirkt (heute
|
|
||||||
konstruktor-fix). `App.main` lädt Settings vor dem Menü und konstruiert/füttert
|
|
||||||
`ConsoleIO` damit.
|
|
||||||
- `SwingIO`: Glyphen/Farbe für die GUI ggf. No-Op bzw. minimal (GUI rendert eigene
|
|
||||||
Stile); Settings-Wirkung primär Konsole. (Bestätigen im Plan.)
|
|
||||||
|
|
||||||
## 11. Fehlerbehandlung
|
|
||||||
|
|
||||||
- **`SaveException`** (eigene, mit `getUserMessage()`): beschädigtes/unlesbares
|
|
||||||
JSON, Schreibrechte fehlen, Schema-Mismatch, unbekannte Ids → gefangen, geloggt,
|
|
||||||
benutzerfreundliche Meldung; **nie** Absturz. Load-Fehler → zurück ins Menü.
|
|
||||||
- Konsolen-`choose`: ungültige Eingabe → Re-Prompt; `null`/EOF → sichere Vorgabe.
|
|
||||||
- Leere Slot-Liste beim Laden → „No saved games." → zurück ins Menü.
|
|
||||||
- GUI-Buttons können keine ungültige Wahl liefern.
|
|
||||||
|
|
||||||
## 12. Tests
|
|
||||||
|
|
||||||
- **`SaveServiceTest`** (Temp-Dir): Session bauen → `save` → `load` → volle
|
|
||||||
Zustandsgleichheit (Raum, Inventar, Item-Platzierung, Flags, Quest-Stages +
|
|
||||||
completed, Switch-Zustände, Gold, `turn`).
|
|
||||||
- **`SaveDataTest`**: Jackson (De-)Serialisierung stabil; Overlay auf frische Welt
|
|
||||||
(verschobene Items, Quest mitten in Stage).
|
|
||||||
- **Failure**: beschädigtes JSON, unbekannte Raum-Id, Schema-Mismatch → `SaveException`
|
|
||||||
mit sinnvoller Meldung; `list()` überspringt kaputte Dateien.
|
|
||||||
- **`MainMenu`/`SettingsMenu`**: Fake-`GameIO` mit skriptbaren `choose`-Ergebnissen;
|
|
||||||
prüft Aktions-Mapping + Settings-Persistenz.
|
|
||||||
- **Konsolen-`choose`-Default**: nummeriertes Rendering + Bad-Input-Re-Prompt über
|
|
||||||
gemocktes `readLine`.
|
|
||||||
- **Konvention**: `SwingIO.choose`/GUI von Unit-Tests ausgenommen (manuelle
|
|
||||||
Verifikation), wie etabliert keine YAML-/GUI-abhängigen Domain-Tests.
|
|
||||||
|
|
||||||
## 13. Architektur-Werte / Risiken
|
|
||||||
|
|
||||||
- **Geteilter Loop bleibt zentral**: nur *eine* neue IO-Primitive; Menü-*Logik* ist
|
|
||||||
frontend-agnostisch, nur das Rendering divergiert (Default vs. Override).
|
|
||||||
- **Datengetrieben**: Welt weiter aus YAML; Save ist reines Delta — Content-Updates
|
|
||||||
brechen alte Saves höchstens bei entfernten Ids (→ graceful refuse).
|
|
||||||
- **Schichtung**: `SaveService` lebt unter `loader` (kennt `WorldLoader`); der
|
|
||||||
Quest-Autosave-Hook braucht einen Save-Callback im `GameContext`, um `game` nicht
|
|
||||||
direkt von `loader` abhängig zu machen — im Plan sauber verdrahten.
|
|
||||||
- **GUI-Threading**: `SwingIO.choose` muss dasselbe EDT↔Worker-Blocking-Muster wie
|
|
||||||
`readLine` nutzen; sonst Deadlock-Gefahr.
|
|
||||||
295
Semesterprojekt/mvnw
vendored
Executable file
295
Semesterprojekt/mvnw
vendored
Executable file
@@ -0,0 +1,295 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
actualDistributionDir=""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||||
|
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$distributionUrlNameMain"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
# enable globbing to iterate over items
|
||||||
|
set +f
|
||||||
|
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$(basename "$dir")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
set -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||||
|
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||||
|
die "Could not find Maven distribution directory in extracted archive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
189
Semesterprojekt/mvnw.cmd
vendored
Normal file
189
Semesterprojekt/mvnw.cmd
vendored
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
|
||||||
|
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||||
|
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_WRAPPER_DISTS = $null
|
||||||
|
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||||
|
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||||
|
} else {
|
||||||
|
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
$actualDistributionDir = ""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||||
|
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||||
|
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||||
|
$actualDistributionDir = $distributionUrlNameMain
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||||
|
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||||
|
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||||
|
$actualDistributionDir = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
@@ -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>
|
||||||
@@ -49,6 +51,18 @@
|
|||||||
<version>${logback.version}</version>
|
<version>${logback.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OGG Vorbis decoding (GUI background music) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>vorbisspi</artifactId>
|
||||||
|
<version>1.0.3.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>jorbis</artifactId>
|
||||||
|
<version>0.0.17.4</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
@@ -97,6 +111,76 @@
|
|||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>3.5.2</version>
|
<version>3.5.2</version>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<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>
|
||||||
|
<!-- Console version -> release/HauntedManor-Console.jar -->
|
||||||
|
<execution>
|
||||||
|
<id>shade-console</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outputFile>${project.basedir}/release/HauntedManor-Console.jar</outputFile>
|
||||||
|
<!-- Console has no audio (GameIO.setMusic is a no-op there), so leave
|
||||||
|
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>
|
||||||
|
<transformer
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<manifestEntries>
|
||||||
|
<Main-Class>thb.jeanluc.adventure.App</Main-Class>
|
||||||
|
</manifestEntries>
|
||||||
|
</transformer>
|
||||||
|
<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
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>exec-maven-plugin</artifactId>
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ import thb.jeanluc.adventure.command.impl.UseCommand;
|
|||||||
import thb.jeanluc.adventure.game.Game;
|
import thb.jeanluc.adventure.game.Game;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.game.GameSession;
|
import thb.jeanluc.adventure.game.GameSession;
|
||||||
|
import thb.jeanluc.adventure.game.TutorialGuide;
|
||||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
import thb.jeanluc.adventure.io.GameIO;
|
import thb.jeanluc.adventure.io.GameIO;
|
||||||
import thb.jeanluc.adventure.io.text.Banner;
|
import thb.jeanluc.adventure.io.text.Banner;
|
||||||
|
import thb.jeanluc.adventure.loader.TutorialLoader;
|
||||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||||
import thb.jeanluc.adventure.menu.MainMenu;
|
import thb.jeanluc.adventure.menu.MainMenu;
|
||||||
import thb.jeanluc.adventure.menu.SettingsMenu;
|
import thb.jeanluc.adventure.menu.SettingsMenu;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
import thb.jeanluc.adventure.save.SaveException;
|
import thb.jeanluc.adventure.save.SaveException;
|
||||||
import thb.jeanluc.adventure.save.SaveService;
|
import thb.jeanluc.adventure.save.SaveService;
|
||||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||||
@@ -44,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 {
|
||||||
@@ -59,33 +67,46 @@ 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) {
|
||||||
SaveService saves = new SaveService();
|
run(io, new SaveService(), new SettingsStore());
|
||||||
SettingsStore settingsStore = new SettingsStore();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the menu shell with injectable persistence (for testing).
|
||||||
|
*
|
||||||
|
* @param io frontend the menu and the game are rendered on
|
||||||
|
* @param saves save-slot backend
|
||||||
|
* @param settingsStore settings backend
|
||||||
|
*/
|
||||||
|
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
|
||||||
Settings settings = settingsStore.load();
|
Settings settings = settingsStore.load();
|
||||||
ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;
|
SettingsMenu.apply(settings, io);
|
||||||
SettingsMenu.apply(settings, consoleIo);
|
|
||||||
|
|
||||||
boolean running = true;
|
boolean running = true;
|
||||||
while (running) {
|
while (running) {
|
||||||
switch (MainMenu.show(io)) {
|
switch (MainMenu.show(io)) {
|
||||||
case NEW_GAME -> play(io, saves, newSession(io));
|
case NEW_GAME -> play(io, saves, newSession(io), true);
|
||||||
case LOAD -> {
|
case LOAD -> {
|
||||||
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
||||||
if (slot != null) {
|
if (slot != null) {
|
||||||
try {
|
try {
|
||||||
play(io, saves, saves.load(slot.slug()));
|
play(io, saves, saves.load(slot.slug()), false);
|
||||||
} catch (SaveException e) {
|
} catch (SaveException e) {
|
||||||
io.write("Could not load: " + e.getUserMessage());
|
io.write("Could not load: " + e.getUserMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo);
|
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);
|
||||||
case QUIT -> running = false;
|
case QUIT -> running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
io.write("Farewell.");
|
io.write("Farewell.");
|
||||||
|
io.shutdown(); // GUI: close the window and exit; console: no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads a fresh world and binds it to a newly named session. */
|
/** Loads a fresh world and binds it to a newly named session. */
|
||||||
@@ -96,7 +117,7 @@ public final class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the registry, runs one game to completion, autosaving to its slot. */
|
/** Builds the registry, runs one game to completion, autosaving to its slot. */
|
||||||
private static void play(GameIO io, SaveService saves, GameSession session) {
|
private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {
|
||||||
GameContext ctx = new GameContext(session, io);
|
GameContext ctx = new GameContext(session, io);
|
||||||
ctx.setSaveCallback(() -> {
|
ctx.setSaveCallback(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -106,32 +127,42 @@ 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) {
|
||||||
|
Tutorial tutorial = new TutorialLoader().load();
|
||||||
|
if (!tutorial.isEmpty()) {
|
||||||
|
game.setTutorialGuide(new TutorialGuide(tutorial, registry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
io.print(Banner.welcome(session.getWorld().getTitle()));
|
io.print(Banner.welcome(session.getWorld().getTitle()));
|
||||||
io.write(session.getWorld().getWelcomeMessage());
|
io.write(session.getWorld().getWelcomeMessage());
|
||||||
new LookCommand().execute(ctx, List.of());
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
|
||||||
game.run();
|
game.run();
|
||||||
|
io.setMusic(null); // fade music out when returning to the menu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,39 +3,49 @@ 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.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;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves the player into the room reachable in a given direction.
|
* Moves the player. {@code go <direction>} steps once to a connected room.
|
||||||
* Usage: {@code go <direction>}.
|
* {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
|
||||||
|
* rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
|
||||||
|
* {@code go to <room>}. The parser strips the filler word "to", so "go to
|
||||||
|
* <room>" and "go <room>" are equivalent.
|
||||||
*/
|
*/
|
||||||
public class GoCommand implements Command {
|
public class GoCommand implements Command {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void execute(GameContext ctx, List<String> args) {
|
public void execute(GameContext ctx, List<String> args) {
|
||||||
if (args.isEmpty()) {
|
if (args.isEmpty()) {
|
||||||
ctx.getIo().write("Go where? Try 'go north'.");
|
ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Direction dir;
|
|
||||||
try {
|
try {
|
||||||
dir = Direction.fromString(args.getFirst());
|
Direction dir = Direction.fromString(args.getFirst());
|
||||||
} catch (IllegalArgumentException e) {
|
moveDirection(ctx, dir);
|
||||||
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is.");
|
} catch (IllegalArgumentException notADirection) {
|
||||||
return;
|
goToRoom(ctx, args);
|
||||||
}
|
}
|
||||||
Optional<Room> next = ctx.getPlayer().getCurrentRoom().getExit(dir);
|
}
|
||||||
|
|
||||||
|
/** Single-step move in a compass direction (the original behaviour). */
|
||||||
|
private void moveDirection(GameContext ctx, Direction dir) {
|
||||||
|
Room here = ctx.getPlayer().getCurrentRoom();
|
||||||
|
Optional<Room> next = here.getExit(dir);
|
||||||
if (next.isEmpty()) {
|
if (next.isEmpty()) {
|
||||||
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir);
|
ExitLock lock = here.getExitLocks().get(dir);
|
||||||
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||||
ctx.getIo().write(lock.blocked());
|
ctx.getIo().write(lock.blocked());
|
||||||
return;
|
return;
|
||||||
@@ -49,8 +59,70 @@ public class GoCommand implements Command {
|
|||||||
new LookCommand().execute(ctx, List.of());
|
new LookCommand().execute(ctx, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a visited room by id/name and auto-walk the BFS path to it. */
|
||||||
|
private void goToRoom(GameContext ctx, List<String> args) {
|
||||||
|
String name = String.join(" ", args).toLowerCase();
|
||||||
|
Room target = resolveVisited(ctx, name);
|
||||||
|
if (target == null) {
|
||||||
|
ctx.getIo().write("You don't know any direction or place called '" + name + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Room start = ctx.getPlayer().getCurrentRoom();
|
||||||
|
if (target == start) {
|
||||||
|
ctx.getIo().write("You're already in the " + target.getName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> path = Pathfinder.findPath(ctx, target);
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
ctx.getIo().write("You can't find a way there right now.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> route = new ArrayList<>();
|
||||||
|
route.add(start);
|
||||||
|
route.addAll(path);
|
||||||
|
for (Room step : path) {
|
||||||
|
ctx.getPlayer().setCurrentRoom(step);
|
||||||
|
ctx.getIo().travelStep(MapLayout.compute(
|
||||||
|
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
|
||||||
|
}
|
||||||
|
ctx.getIo().write(summary(route));
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds a visited room whose id or display name matches {@code name}. */
|
||||||
|
private Room resolveVisited(GameContext ctx, String name) {
|
||||||
|
// the command test harness (CommandTestSupport) uses a null world; production always has one
|
||||||
|
if (ctx.getWorld() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String id : ctx.getPlayer().getVisitedRoomIds()) {
|
||||||
|
Room r = ctx.getWorld().getRooms().get(id);
|
||||||
|
if (r == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.equalsIgnoreCase(name) || r.getName().equalsIgnoreCase(name)) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names start, destination, and at most one middle waypoint. */
|
||||||
|
private String summary(List<Room> route) {
|
||||||
|
int n = route.size();
|
||||||
|
String start = route.get(0).getName();
|
||||||
|
String dest = route.get(n - 1).getName();
|
||||||
|
if (n >= 3) {
|
||||||
|
String middle = route.get(n / 2).getName();
|
||||||
|
return "You make your way from the " + start + ", through the " + middle
|
||||||
|
+ ", to the " + dest + ".";
|
||||||
|
}
|
||||||
|
return "You make your way from the " + start + " to the " + dest + ".";
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String help() {
|
public String help() {
|
||||||
return "go <direction> - move to the connected room (north/south/east/west)";
|
return "go <direction> - move one room (north/south/east/west); "
|
||||||
|
+ "or go to <room> to travel to a known 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()) {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
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.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;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the item-specific {@link Item#use(GameContext)} action. The
|
* With one argument, invokes the item-specific {@link Item#use(GameContext)}
|
||||||
* item must either be in the player's inventory or in the current room.
|
* action; the item must be in the player's inventory or the current room.
|
||||||
* Usage: {@code use <item-id>}.
|
* With two arguments ({@code use <item> on <item>}), delegates to the
|
||||||
|
* combination engine. Usage: {@code use <item>} or {@code use <item> on <item>}.
|
||||||
*/
|
*/
|
||||||
public class UseCommand implements Command {
|
public class UseCommand implements Command {
|
||||||
|
|
||||||
@@ -20,13 +23,21 @@ public class UseCommand implements Command {
|
|||||||
ctx.getIo().write("Use what?");
|
ctx.getIo().write("Use what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (args.size() >= 2) {
|
||||||
|
Combinations.tryUse(ctx, args.get(0), args.get(1));
|
||||||
|
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);
|
||||||
@@ -34,6 +45,6 @@ public class UseCommand implements Command {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String help() {
|
public String help() {
|
||||||
return "use <item> - use an item (read it, switch it on/off, ...)";
|
return "use <item> - use an item; or use <item> on <item> to combine them";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies {@code use X on Y} item combinations. Stateless utility, mirroring
|
||||||
|
* {@link Conditions}/{@link Effects}/{@link Light}. Operands resolve from the
|
||||||
|
* player's inventory or the current room.
|
||||||
|
*/
|
||||||
|
public final class Combinations {
|
||||||
|
|
||||||
|
private Combinations() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to combine the two named items. Prints a resolution error if an
|
||||||
|
* operand is absent, the recipe's {@code failText} (or a generic line) if a
|
||||||
|
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
|
||||||
|
* or applies consume/produce/effects + the response on success.
|
||||||
|
*
|
||||||
|
* @param ctx the active game context
|
||||||
|
* @param idX id of the first operand item
|
||||||
|
* @param idY id of the second operand item
|
||||||
|
*/
|
||||||
|
public static void tryUse(GameContext ctx, String idX, String idY) {
|
||||||
|
if (!present(ctx, idX)) {
|
||||||
|
ctx.getIo().write(unresolved(ctx, idX));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present(ctx, idY)) {
|
||||||
|
ctx.getIo().write(unresolved(ctx, idY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
|
||||||
|
if (c == null) {
|
||||||
|
ctx.getIo().write("Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Conditions.all(c.requires(), ctx)) {
|
||||||
|
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String id : c.consume()) {
|
||||||
|
removeFromAnywhere(ctx, id);
|
||||||
|
}
|
||||||
|
if (c.produce() != null) {
|
||||||
|
Item produced = ctx.getWorld().getItems().get(c.produce());
|
||||||
|
if (produced != null) {
|
||||||
|
ctx.getPlayer().addItem(produced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effects.applyAll(c.effects(), ctx);
|
||||||
|
if (c.response() != null && !c.response().isBlank()) {
|
||||||
|
ctx.getIo().write(c.response());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An operand resolves from the inventory always, but from the room only
|
||||||
|
* while the room is lit — otherwise combining with room scenery would
|
||||||
|
* work in pitch darkness.
|
||||||
|
*/
|
||||||
|
private static boolean present(GameContext ctx, String id) {
|
||||||
|
return ctx.getPlayer().hasItem(id)
|
||||||
|
|| (Light.canSeeRoom(ctx)
|
||||||
|
&& ctx.getPlayer().getCurrentRoom().findItem(id).isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consumes an operand from the inventory, falling back to the current room. */
|
||||||
|
private static void removeFromAnywhere(GameContext ctx, String id) {
|
||||||
|
if (ctx.getPlayer().removeItem(id).isEmpty()) {
|
||||||
|
ctx.getPlayer().getCurrentRoom().removeItem(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -32,12 +31,27 @@ public class Game {
|
|||||||
/** Loop flag. Flipped to false by {@link #stop()}. */
|
/** Loop flag. Flipped to false by {@link #stop()}. */
|
||||||
private boolean running = true;
|
private boolean running = true;
|
||||||
|
|
||||||
|
/** Optional onboarding guide; null in normal/loaded play. */
|
||||||
|
private TutorialGuide tutorialGuide;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the interactive tutorial (New Game only).
|
||||||
|
*
|
||||||
|
* @param tutorialGuide the guide to run alongside the loop
|
||||||
|
*/
|
||||||
|
public void setTutorialGuide(TutorialGuide tutorialGuide) {
|
||||||
|
this.tutorialGuide = tutorialGuide;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
||||||
*/
|
*/
|
||||||
public void run() {
|
public void run() {
|
||||||
publishHud();
|
publishHud();
|
||||||
maybeEnd();
|
maybeEnd();
|
||||||
|
if (tutorialGuide != null) {
|
||||||
|
tutorialGuide.begin(ctx);
|
||||||
|
}
|
||||||
while (running) {
|
while (running) {
|
||||||
String input = ctx.getIo().readLine();
|
String input = ctx.getIo().readLine();
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -47,6 +61,12 @@ public class Game {
|
|||||||
if (parsed.verb().isEmpty()) {
|
if (parsed.verb().isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()
|
||||||
|
&& parsed.verb().equals("skip")) {
|
||||||
|
tutorialGuide.skip(ctx);
|
||||||
|
publishHud();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Optional<Command> cmd = registry.find(parsed.verb());
|
Optional<Command> cmd = registry.find(parsed.verb());
|
||||||
if (cmd.isEmpty()) {
|
if (cmd.isEmpty()) {
|
||||||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||||||
@@ -56,6 +76,9 @@ public class Game {
|
|||||||
if (ctx.getSession().getTurn() % 10 == 0) {
|
if (ctx.getSession().getTurn() % 10 == 0) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
}
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()) {
|
||||||
|
tutorialGuide.onCommand(ctx, parsed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
publishHud();
|
publishHud();
|
||||||
maybeEnd();
|
maybeEnd();
|
||||||
@@ -64,6 +87,11 @@ public class Game {
|
|||||||
|
|
||||||
/** Ends the game if any ending's conditions are met, printing its screen. */
|
/** Ends the game if any ending's conditions are met, printing its screen. */
|
||||||
private void maybeEnd() {
|
private void maybeEnd() {
|
||||||
|
// Don't end the game (win or lose) while the player is still in the tutorial —
|
||||||
|
// a pending ending fires once the walkthrough finishes or is skipped.
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Ending e = EndingEngine.triggered(ctx);
|
Ending e = EndingEngine.triggered(ctx);
|
||||||
if (e != null) {
|
if (e != null) {
|
||||||
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
|
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
|
||||||
@@ -71,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(),
|
||||||
@@ -84,6 +119,7 @@ public class Game {
|
|||||||
ctx.getPlayer().getCurrentRoom());
|
ctx.getPlayer().getCurrentRoom());
|
||||||
ctx.getIo().setMap(map);
|
ctx.getIo().setMap(map);
|
||||||
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
|
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
|
||||||
|
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadth-first shortest-path search over the rooms the player has already
|
||||||
|
* visited, honouring the same gates as a manual move: locked exits and
|
||||||
|
* darkness. Stateless utility, alongside {@link Conditions}/{@link Effects}/
|
||||||
|
* {@link Light}.
|
||||||
|
*/
|
||||||
|
public final class Pathfinder {
|
||||||
|
|
||||||
|
private Pathfinder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the shortest path from the player's current room to {@code target}.
|
||||||
|
*
|
||||||
|
* @param ctx active game context
|
||||||
|
* @param target destination room (expected to be a visited room)
|
||||||
|
* @return the rooms to step through, excluding the start and ending at
|
||||||
|
* {@code target}; empty if there is no currently-passable path
|
||||||
|
* (or {@code target} is the current room)
|
||||||
|
*/
|
||||||
|
public static List<Room> findPath(GameContext ctx, Room target) {
|
||||||
|
Room start = ctx.getPlayer().getCurrentRoom();
|
||||||
|
Set<String> visited = ctx.getPlayer().getVisitedRoomIds();
|
||||||
|
|
||||||
|
Queue<Room> queue = new ArrayDeque<>();
|
||||||
|
Map<Room, Room> cameFrom = new HashMap<>();
|
||||||
|
queue.add(start);
|
||||||
|
cameFrom.put(start, null);
|
||||||
|
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
Room current = queue.poll();
|
||||||
|
if (current == target) {
|
||||||
|
return reconstruct(cameFrom, target);
|
||||||
|
}
|
||||||
|
for (Map.Entry<Direction, Room> exit : current.getExits().entrySet()) {
|
||||||
|
Room next = exit.getValue();
|
||||||
|
if (cameFrom.containsKey(next) || !visited.contains(next.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ExitLock lock = current.getExitLocks().get(exit.getKey());
|
||||||
|
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Light.isLit(ctx, next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cameFrom.put(next, current);
|
||||||
|
queue.add(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks predecessors back from target to start; returns steps after start. */
|
||||||
|
private static List<Room> reconstruct(Map<Room, Room> cameFrom, Room target) {
|
||||||
|
LinkedList<Room> path = new LinkedList<>();
|
||||||
|
for (Room r = target; r != null && cameFrom.get(r) != null; r = cameFrom.get(r)) {
|
||||||
|
path.addFirst(r);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||||
|
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the interactive start-of-game walkthrough, layered onto the real game
|
||||||
|
* loop. Prints an instruction, then on each executed command decides whether the
|
||||||
|
* current step is satisfied (confirm + advance) or not (hint). Movement steps
|
||||||
|
* require an actual room change. Alias-aware via the {@link CommandRegistry}.
|
||||||
|
*/
|
||||||
|
public final class TutorialGuide {
|
||||||
|
|
||||||
|
/** The walkthrough content: intro, steps, and closing tips. */
|
||||||
|
private final Tutorial tutorial;
|
||||||
|
|
||||||
|
/** Command lookup, used to compare a typed verb against a step's expected command via aliases. */
|
||||||
|
private final CommandRegistry registry;
|
||||||
|
|
||||||
|
/** Index of the step currently being taught. */
|
||||||
|
private int index;
|
||||||
|
|
||||||
|
/** False once all steps are done or the walkthrough was skipped. */
|
||||||
|
private boolean active;
|
||||||
|
|
||||||
|
/** True once {@link #begin(GameContext)} has printed the intro; guards against a second run. */
|
||||||
|
private boolean begun;
|
||||||
|
|
||||||
|
/** Room the player stood in when the current step began; used to detect movement steps. */
|
||||||
|
private Room roomAtStepStart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a guide for the given walkthrough. The guide starts inactive if
|
||||||
|
* the tutorial has no steps.
|
||||||
|
*
|
||||||
|
* @param tutorial the walkthrough content
|
||||||
|
* @param registry command lookup for alias-aware verb comparison
|
||||||
|
*/
|
||||||
|
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
|
||||||
|
this.tutorial = tutorial;
|
||||||
|
this.registry = registry;
|
||||||
|
this.active = !tutorial.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether the guide still has steps left to hand out.
|
||||||
|
*
|
||||||
|
* @return true while the walkthrough is still running
|
||||||
|
*/
|
||||||
|
public boolean isActive() {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the intro and the first instruction (call once at loop start).
|
||||||
|
*
|
||||||
|
* @param ctx the active game context
|
||||||
|
*/
|
||||||
|
public void begin(GameContext ctx) {
|
||||||
|
if (!active || begun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
begun = true;
|
||||||
|
if (tutorial.intro() != null && !tutorial.intro().isBlank()) {
|
||||||
|
ctx.getIo().write(tutorial.intro());
|
||||||
|
}
|
||||||
|
enterStep(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates the current step against the command that was just executed:
|
||||||
|
* on success prints the confirmation and advances (ending the walkthrough
|
||||||
|
* after the last step), otherwise prints the step's hint.
|
||||||
|
*
|
||||||
|
* @param ctx the active game context
|
||||||
|
* @param parsed the command that was just executed
|
||||||
|
*/
|
||||||
|
public void onCommand(GameContext ctx, ParsedCommand parsed) {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TutorialStep step = tutorial.steps().get(index);
|
||||||
|
if (satisfies(step, ctx, parsed)) {
|
||||||
|
ctx.getIo().write(step.confirm());
|
||||||
|
index++;
|
||||||
|
if (index >= tutorial.steps().size()) {
|
||||||
|
if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) {
|
||||||
|
ctx.getIo().write(tutorial.closingTips());
|
||||||
|
}
|
||||||
|
active = false;
|
||||||
|
} else {
|
||||||
|
enterStep(ctx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.getIo().write(step.hint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the tutorial early (the 'skip' command).
|
||||||
|
*
|
||||||
|
* @param ctx the active game context
|
||||||
|
*/
|
||||||
|
public void skip(GameContext ctx) {
|
||||||
|
if (active) {
|
||||||
|
ctx.getIo().write("Tutorial skipped.");
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the current step's instruction and records the room it started in. */
|
||||||
|
private void enterStep(GameContext ctx) {
|
||||||
|
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
|
||||||
|
ctx.getIo().write(tutorial.steps().get(index).instruction());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the executed command fulfils the step. The pseudo-expectations
|
||||||
|
* {@code go_direction} and {@code go_to_room} additionally require an actual
|
||||||
|
* room change; every other expectation matches on the command (aliases
|
||||||
|
* included) and the step's minimum argument count.
|
||||||
|
*/
|
||||||
|
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
|
||||||
|
String expect = step.expect();
|
||||||
|
if ("go_direction".equals(expect)) {
|
||||||
|
return isGo(parsed) && firstIsDirection(parsed) && moved(ctx);
|
||||||
|
}
|
||||||
|
if ("go_to_room".equals(expect)) {
|
||||||
|
return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx);
|
||||||
|
}
|
||||||
|
return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGo(ParsedCommand p) {
|
||||||
|
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the first argument parses as a {@link Direction} (rather than a room name). */
|
||||||
|
private boolean firstIsDirection(ParsedCommand p) {
|
||||||
|
if (p.args().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Direction.fromString(p.args().get(0));
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the player left the room the current step started in. */
|
||||||
|
private boolean moved(GameContext ctx) {
|
||||||
|
return roomAtStepStart != null
|
||||||
|
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether both verbs resolve to the same registered command, so aliases count as a match. */
|
||||||
|
private boolean sameCommand(String a, String b) {
|
||||||
|
Optional<Command> ca = registry.find(a);
|
||||||
|
Optional<Command> cb = registry.find(b);
|
||||||
|
return ca.isPresent() && cb.isPresent() && ca.get() == cb.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,43 +18,108 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
|
/**
|
||||||
|
* One frame of an auto-walk (the {@code go to} command), as the player moves
|
||||||
|
* through a room en route. Default is a no-op: text mode travels silently.
|
||||||
|
* The GUI overrides this to repaint the map and pace the animation.
|
||||||
|
*
|
||||||
|
* @param view the map snapshot at this step
|
||||||
|
*/
|
||||||
|
default void travelStep(MapView view) {
|
||||||
|
// no animation in text mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** On-demand quest log (the 'quests' command). Default renders styled text. */
|
/**
|
||||||
|
* Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays.
|
||||||
|
*
|
||||||
|
* @param track the current room's track name; null or blank means silence
|
||||||
|
*/
|
||||||
|
default void setMusic(String track) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the music volume/level. Console no-op; GUI sets it.
|
||||||
|
*
|
||||||
|
* @param level the level to apply
|
||||||
|
*/
|
||||||
|
default void setMusicLevel(MusicLevel level) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-demand quest log (the 'quests' command). Default renders styled text.
|
||||||
|
*
|
||||||
|
* @param view the quest snapshot
|
||||||
|
*/
|
||||||
default void showQuests(QuestView view) {
|
default void showQuests(QuestView view) {
|
||||||
print(QuestText.render(view));
|
print(QuestText.render(view));
|
||||||
}
|
}
|
||||||
@@ -92,4 +157,14 @@ public interface GameIO {
|
|||||||
write("Please enter a number between 1 and " + options.size() + ".");
|
write("Please enter a number between 1 and " + options.size() + ".");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears the frontend down after the main-menu loop ends (the player chose
|
||||||
|
* Quit). Console mode has nothing to release and exits naturally when
|
||||||
|
* {@code main} returns; the GUI overrides this to close its window and
|
||||||
|
* terminate the JVM, which would otherwise stay alive on the Swing EDT.
|
||||||
|
*/
|
||||||
|
default void shutdown() {
|
||||||
|
// no-op in text mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
/** Raw audio operations — the only part that touches sound hardware. */
|
||||||
|
public interface MusicBackend {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}.
|
||||||
|
*
|
||||||
|
* @param track name of the track to play
|
||||||
|
* @param gainDb target volume in decibels
|
||||||
|
*/
|
||||||
|
void fadeTo(String track, float gainDb);
|
||||||
|
|
||||||
|
/** Fade the current track out and stop. */
|
||||||
|
void fadeOut();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the playback volume immediately.
|
||||||
|
*
|
||||||
|
* @param gainDb target volume in decibels
|
||||||
|
*/
|
||||||
|
void setGainDb(float gainDb);
|
||||||
|
|
||||||
|
/** Stop playback and release resources. */
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
|
||||||
|
* the same track), fades to silence on a null/blank track, and honours the
|
||||||
|
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
|
||||||
|
* {@link MusicBackend}.
|
||||||
|
*/
|
||||||
|
public final class MusicController {
|
||||||
|
|
||||||
|
/** Backend the raw playback operations are delegated to. */
|
||||||
|
private final MusicBackend backend;
|
||||||
|
|
||||||
|
/** Volume level currently selected in the Settings menu. */
|
||||||
|
private MusicLevel level = MusicLevel.MEDIUM;
|
||||||
|
|
||||||
|
/** Track currently selected, or null when silent. */
|
||||||
|
private String current; // currently selected track, or null = silence
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a controller starting silent at {@link MusicLevel#MEDIUM}.
|
||||||
|
*
|
||||||
|
* @param backend backend that performs the actual playback
|
||||||
|
*/
|
||||||
|
public MusicController(MusicBackend backend) {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called per turn with the current room's music field (may be null/blank).
|
||||||
|
* Does nothing while the level is {@link MusicLevel#OFF} or the track is
|
||||||
|
* already playing.
|
||||||
|
*
|
||||||
|
* @param track the room's track name; null or blank means silence
|
||||||
|
*/
|
||||||
|
public void room(String track) {
|
||||||
|
if (!level.isOn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String next = (track == null || track.isBlank()) ? null : track;
|
||||||
|
if (Objects.equals(next, current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
if (next == null) {
|
||||||
|
backend.fadeOut();
|
||||||
|
} else {
|
||||||
|
backend.fadeTo(next, level.gainDb());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a new music level (volume / off). Switching to
|
||||||
|
* {@link MusicLevel#OFF} fades out; switching back on replays on the next
|
||||||
|
* {@link #room(String)} call.
|
||||||
|
*
|
||||||
|
* @param newLevel the level to apply
|
||||||
|
*/
|
||||||
|
public void level(MusicLevel newLevel) {
|
||||||
|
if (newLevel == this.level) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.level = newLevel;
|
||||||
|
if (!newLevel.isOn()) {
|
||||||
|
backend.fadeOut();
|
||||||
|
current = null; // turning back on replays on the next room() call
|
||||||
|
} else {
|
||||||
|
backend.setGainDb(newLevel.gainDb());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops playback and releases the backend's resources. */
|
||||||
|
public void shutdown() {
|
||||||
|
backend.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
/** Background-music volume level, cycled in the Settings menu. */
|
||||||
|
public enum MusicLevel {
|
||||||
|
/** Music disabled; nothing is played. */
|
||||||
|
OFF,
|
||||||
|
|
||||||
|
/** Quiet background music. */
|
||||||
|
LOW,
|
||||||
|
|
||||||
|
/** Default volume. */
|
||||||
|
MEDIUM,
|
||||||
|
|
||||||
|
/** Full volume, without attenuation. */
|
||||||
|
HIGH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled).
|
||||||
|
*
|
||||||
|
* @return the gain of this level
|
||||||
|
*/
|
||||||
|
public float gainDb() {
|
||||||
|
return switch (this) {
|
||||||
|
case OFF -> Float.NEGATIVE_INFINITY;
|
||||||
|
case LOW -> -20f;
|
||||||
|
case MEDIUM -> -10f;
|
||||||
|
case HIGH -> 0f;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether this level permits playback.
|
||||||
|
*
|
||||||
|
* @return true when music should play
|
||||||
|
*/
|
||||||
|
public boolean isOn() {
|
||||||
|
return this != OFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next level in the cycle (wraps HIGH → OFF).
|
||||||
|
*
|
||||||
|
* @return the following level
|
||||||
|
*/
|
||||||
|
public MusicLevel next() {
|
||||||
|
return values()[(ordinal() + 1) % values().length];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.sound.sampled.AudioFormat;
|
||||||
|
import javax.sound.sampled.AudioInputStream;
|
||||||
|
import javax.sound.sampled.AudioSystem;
|
||||||
|
import javax.sound.sampled.FloatControl;
|
||||||
|
import javax.sound.sampled.SourceDataLine;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams OGG Vorbis tracks bundled on the classpath under {@code /music/}
|
||||||
|
* through a {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding
|
||||||
|
* uses the vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a
|
||||||
|
* daemon thread; missing/undecodable files are logged and stay silent. GUI-only
|
||||||
|
* — verified manually.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class OggMusicBackend implements MusicBackend {
|
||||||
|
|
||||||
|
/** Classpath directory the bundled tracks are looked up in. */
|
||||||
|
private static final String MUSIC_DIR = "/music/";
|
||||||
|
|
||||||
|
/** Duration of a fade-in or fade-out, in milliseconds. */
|
||||||
|
private static final int FADE_MS = 400;
|
||||||
|
|
||||||
|
/** Number of gain steps a fade is split into. */
|
||||||
|
private static final int FADE_STEPS = 20;
|
||||||
|
|
||||||
|
/** Size of the PCM transfer buffer, in bytes. */
|
||||||
|
private static final int BUFFER = 4096;
|
||||||
|
|
||||||
|
/** Gain a fade starts from and ends at, in decibels. */
|
||||||
|
private static final float FLOOR_DB = -40f; // near-silence for fades
|
||||||
|
|
||||||
|
/** The currently playing thread, or null when silent. */
|
||||||
|
private volatile Thread playThread;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop token of the current play thread. Each playback owns its own token
|
||||||
|
* rather than sharing one flag: if {@link #stopCurrent()} times out waiting
|
||||||
|
* for a thread, that thread's token stays set, so it still winds down and
|
||||||
|
* can never be revived by the next {@link #fadeTo}.
|
||||||
|
*/
|
||||||
|
private volatile AtomicBoolean playStop;
|
||||||
|
|
||||||
|
/** Output line of the current playback, used by {@link #setGainDb}. */
|
||||||
|
private volatile SourceDataLine line;
|
||||||
|
|
||||||
|
/** Gain most recently requested by the player, in decibels. */
|
||||||
|
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the current track and starts {@code track} on a fresh daemon thread,
|
||||||
|
* fading in to {@code gainDb}. A track that is not on the classpath is logged
|
||||||
|
* and leaves playback silent.
|
||||||
|
*
|
||||||
|
* @param track file name below {@code /music/}
|
||||||
|
* @param gainDb target MASTER_GAIN in decibels
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public synchronized void fadeTo(String track, float gainDb) {
|
||||||
|
stopCurrent();
|
||||||
|
targetGainDb = gainDb;
|
||||||
|
String resource = MUSIC_DIR + track;
|
||||||
|
if (getClass().getResource(resource) == null) {
|
||||||
|
log.warn("Music resource not found, staying silent: {}", resource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AtomicBoolean stop = new AtomicBoolean(false);
|
||||||
|
playStop = stop;
|
||||||
|
playThread = new Thread(() -> playLoop(resource, gainDb, stop), "music");
|
||||||
|
playThread.setDaemon(true);
|
||||||
|
playThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fades the current track out and stops the play thread. */
|
||||||
|
@Override
|
||||||
|
public synchronized void fadeOut() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a new gain to the running playback immediately, without a fade.
|
||||||
|
*
|
||||||
|
* @param gainDb target MASTER_GAIN in decibels
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public synchronized void setGainDb(float gainDb) {
|
||||||
|
targetGainDb = gainDb;
|
||||||
|
SourceDataLine l = line;
|
||||||
|
if (l != null) {
|
||||||
|
applyGain(l, gainDb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops playback and releases the audio line. */
|
||||||
|
@Override
|
||||||
|
public synchronized void shutdown() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals the play thread to stop and waits for its fade-out + cleanup.
|
||||||
|
* The thread's stop token stays set even if the join times out, so a slow
|
||||||
|
* thread still terminates on its own instead of playing on forever.
|
||||||
|
*/
|
||||||
|
private void stopCurrent() {
|
||||||
|
Thread t = playThread;
|
||||||
|
AtomicBoolean stop = playStop;
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stop != null) {
|
||||||
|
stop.set(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
t.join(FADE_MS + 600L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
if (t.isAlive()) {
|
||||||
|
log.warn("Music thread did not stop within the join budget; it will wind down on its own");
|
||||||
|
}
|
||||||
|
playThread = null;
|
||||||
|
playStop = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the play thread: decodes the resource to PCM and writes it to the
|
||||||
|
* output line, restarting at the end of the stream until {@code stop} is set.
|
||||||
|
* Decoding failures are logged and end playback. Runs a fade-out on exit.
|
||||||
|
*
|
||||||
|
* @param resource classpath path of the OGG file
|
||||||
|
* @param gainDb gain to fade in to
|
||||||
|
* @param stop this playback's stop token
|
||||||
|
*/
|
||||||
|
private void playLoop(String resource, float gainDb, AtomicBoolean stop) {
|
||||||
|
SourceDataLine l = null;
|
||||||
|
try {
|
||||||
|
while (!stop.get()) {
|
||||||
|
// Reopen the bundled resource each loop; BufferedInputStream gives
|
||||||
|
// the SPI the mark/reset support it needs to read the OGG header.
|
||||||
|
try (InputStream res = getClass().getResourceAsStream(resource);
|
||||||
|
AudioInputStream in = AudioSystem.getAudioInputStream(new BufferedInputStream(res))) {
|
||||||
|
AudioFormat base = in.getFormat();
|
||||||
|
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
|
||||||
|
base.getSampleRate(), 16, base.getChannels(),
|
||||||
|
base.getChannels() * 2, base.getSampleRate(), false);
|
||||||
|
try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
|
||||||
|
if (l == null) {
|
||||||
|
l = AudioSystem.getSourceDataLine(pcm);
|
||||||
|
l.open(pcm);
|
||||||
|
l.start();
|
||||||
|
// Only publish the line if this playback is still current.
|
||||||
|
if (playStop == stop) {
|
||||||
|
line = l;
|
||||||
|
}
|
||||||
|
ramp(l, FLOOR_DB, gainDb, stop); // fade in once
|
||||||
|
}
|
||||||
|
byte[] buf = new byte[BUFFER];
|
||||||
|
int n;
|
||||||
|
while (!stop.get() && (n = din.read(buf, 0, buf.length)) != -1) {
|
||||||
|
l.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Music playback failed for {}: {}", resource, e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (l != null) {
|
||||||
|
try {
|
||||||
|
// Fade-out must run to completion, so it gets a fresh token.
|
||||||
|
ramp(l, currentGain(l), FLOOR_DB, new AtomicBoolean(false));
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// best-effort fade
|
||||||
|
}
|
||||||
|
l.stop();
|
||||||
|
l.close();
|
||||||
|
}
|
||||||
|
// Only clear the shared line if it still belongs to this playback.
|
||||||
|
if (playStop == stop) {
|
||||||
|
line = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ramps the gain, aborting early if this playback has been stopped.
|
||||||
|
*
|
||||||
|
* @param l the line to ramp
|
||||||
|
* @param fromDb starting gain in decibels
|
||||||
|
* @param toDb final gain in decibels
|
||||||
|
* @param stop stop token checked between steps
|
||||||
|
*/
|
||||||
|
private void ramp(SourceDataLine l, float fromDb, float toDb, AtomicBoolean stop) {
|
||||||
|
for (int i = 0; i <= FADE_STEPS; i++) {
|
||||||
|
if (stop.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
|
||||||
|
sleep(FADE_MS / FADE_STEPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param l the line to read
|
||||||
|
* @return the line's current gain, or {@link #targetGainDb} if the platform
|
||||||
|
* offers no master-gain control
|
||||||
|
*/
|
||||||
|
private float currentGain(SourceDataLine l) {
|
||||||
|
try {
|
||||||
|
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return targetGainDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the line's gain, clamped to the range it supports. Lines without a
|
||||||
|
* master-gain control are left untouched.
|
||||||
|
*
|
||||||
|
* @param l the line to adjust
|
||||||
|
* @param db requested gain in decibels
|
||||||
|
*/
|
||||||
|
private void applyGain(SourceDataLine l, float db) {
|
||||||
|
try {
|
||||||
|
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
|
||||||
|
gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// line has no master-gain control on this platform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleeps, restoring the interrupt flag instead of propagating.
|
||||||
|
*
|
||||||
|
* @param ms milliseconds to sleep
|
||||||
|
*/
|
||||||
|
private void sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ import javax.swing.Box;
|
|||||||
import javax.swing.BoxLayout;
|
import javax.swing.BoxLayout;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JComponent;
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JDialog;
|
|
||||||
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;
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.JTextPane;
|
import javax.swing.JTextPane;
|
||||||
import javax.swing.KeyStroke;
|
import javax.swing.KeyStroke;
|
||||||
|
import javax.swing.SwingConstants;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.text.BadLocationException;
|
import javax.swing.text.BadLocationException;
|
||||||
import javax.swing.text.SimpleAttributeSet;
|
import javax.swing.text.SimpleAttributeSet;
|
||||||
@@ -29,10 +30,13 @@ import javax.swing.text.StyleConstants;
|
|||||||
import javax.swing.text.StyledDocument;
|
import javax.swing.text.StyledDocument;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Cursor;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.Font;
|
import java.awt.Font;
|
||||||
import java.awt.GraphicsConfiguration;
|
import java.awt.GraphicsConfiguration;
|
||||||
import java.awt.GraphicsEnvironment;
|
import java.awt.GraphicsEnvironment;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
import java.awt.Toolkit;
|
import java.awt.Toolkit;
|
||||||
import java.awt.event.ActionEvent;
|
import java.awt.event.ActionEvent;
|
||||||
@@ -63,22 +67,55 @@ public class SwingIO implements GameIO {
|
|||||||
/** Unscaled size for the HUD and side-panel font, in points. */
|
/** Unscaled size for the HUD and side-panel font, in points. */
|
||||||
private static final float SMALL_SIZE = 12.5f;
|
private static final float SMALL_SIZE = 12.5f;
|
||||||
|
|
||||||
/** Side-panel width as a fraction of the window width, clamped by the bounds below. */
|
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
||||||
private static final double SIDE_RATIO = 0.28;
|
private static final long TRAVEL_STEP_MS = 300L;
|
||||||
private static final int SIDE_MIN = 180;
|
|
||||||
private static final int SIDE_MAX = 460;
|
|
||||||
|
|
||||||
|
/** Side-panel width as a fraction of the window width, with a lower bound only. */
|
||||||
|
private static final double SIDE_RATIO = 0.34;
|
||||||
|
|
||||||
|
/** Lower bound for the side-panel width, in unscaled pixels. */
|
||||||
|
private static final int SIDE_MIN = 220;
|
||||||
|
|
||||||
|
/** Background music of the current room. */
|
||||||
|
private final MusicController music = new MusicController(new OggMusicBackend());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines typed into the input field, handed from the Swing EDT to the game
|
||||||
|
* loop, which blocks on {@link #readLine()}.
|
||||||
|
*/
|
||||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
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. */
|
||||||
@@ -87,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();
|
||||||
@@ -138,6 +181,7 @@ public class SwingIO implements GameIO {
|
|||||||
public void componentResized(ComponentEvent e) {
|
public void componentResized(ComponentEvent e) {
|
||||||
updateSideWidth();
|
updateSideWidth();
|
||||||
applyFonts();
|
applyFonts();
|
||||||
|
rescaleMenuOverlayIfVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,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) {
|
||||||
@@ -163,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()
|
||||||
@@ -183,11 +235,10 @@ public class SwingIO implements GameIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sizes the side panel to a fraction of the window width, clamped to [min, max] (scaled). */
|
/** Sizes the side panel to a fraction of the window width, with a scaled lower bound. */
|
||||||
private void updateSideWidth() {
|
private void updateSideWidth() {
|
||||||
int min = (int) (SIDE_MIN * uiScale);
|
int min = (int) (SIDE_MIN * uiScale);
|
||||||
int max = (int) (SIDE_MAX * uiScale);
|
int target = Math.max(min, (int) (frame.getWidth() * SIDE_RATIO));
|
||||||
int target = Math.max(min, Math.min(max, (int) (frame.getWidth() * SIDE_RATIO)));
|
|
||||||
sidePanel.setPreferredSize(new Dimension(target, 0));
|
sidePanel.setPreferredSize(new Dimension(target, 0));
|
||||||
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
|
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
|
||||||
sidePanel.revalidate();
|
sidePanel.revalidate();
|
||||||
@@ -201,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);
|
||||||
@@ -236,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
|
||||||
@@ -249,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);
|
||||||
@@ -266,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 {
|
||||||
@@ -326,11 +407,63 @@ public class SwingIO implements GameIO {
|
|||||||
SwingUtilities.invokeLater(() -> hud.setText(txt));
|
SwingUtilities.invokeLater(() -> hud.setText(txt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMusic(String track) {
|
||||||
|
music.room(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMusicLevel(MusicLevel level) {
|
||||||
|
music.level(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the GUI when the player quits from the main menu. Stops music,
|
||||||
|
* disposes the window, and terminates the JVM — mirroring the native
|
||||||
|
* window-close (EXIT_ON_CLOSE); without this the EDT keeps the process
|
||||||
|
* alive and the dismissed menu overlay just reveals the game underneath.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
music.shutdown();
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
frame.dispose();
|
||||||
|
System.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void travelStep(MapView view) {
|
||||||
|
setMap(view);
|
||||||
|
try {
|
||||||
|
Thread.sleep(TRAVEL_STEP_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setQuests(QuestView view) {
|
public void setQuests(QuestView view) {
|
||||||
quests.show(view);
|
quests.show(view);
|
||||||
@@ -348,41 +481,12 @@ public class SwingIO implements GameIO {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int choose(String title, List<String> options) {
|
public int choose(String title, List<String> options) {
|
||||||
// Same EDT↔worker handoff as readLine: the worker blocks on take(),
|
// Full-window overlay (glass pane), not a floating dialog: it covers the game
|
||||||
// the EDT (button click / window close) offers the chosen index.
|
// chrome so no panels/outlines show behind the menu, and it fills + scales with
|
||||||
|
// the window. EDT↔worker handoff as in readLine: the worker blocks on take(),
|
||||||
|
// a button click / Escape offers the chosen index.
|
||||||
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
|
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
|
||||||
JDialog dialog = new JDialog(frame, title, true);
|
|
||||||
JPanel panel = new JPanel();
|
|
||||||
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
|
|
||||||
panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
|
||||||
panel.add(new JLabel(title));
|
|
||||||
panel.add(Box.createVerticalStrut(8));
|
|
||||||
for (int i = 0; i < options.size(); i++) {
|
|
||||||
int idx = i;
|
|
||||||
JButton b = new JButton(options.get(i));
|
|
||||||
b.setAlignmentX(0f);
|
|
||||||
b.addActionListener(e -> {
|
|
||||||
dialog.dispose();
|
|
||||||
picked.offer(idx);
|
|
||||||
});
|
|
||||||
panel.add(b);
|
|
||||||
panel.add(Box.createVerticalStrut(4));
|
|
||||||
}
|
|
||||||
// Closing the dialog counts as the last (safe/cancel) option.
|
|
||||||
dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
|
|
||||||
dialog.addWindowListener(new java.awt.event.WindowAdapter() {
|
|
||||||
@Override
|
|
||||||
public void windowClosing(java.awt.event.WindowEvent e) {
|
|
||||||
dialog.dispose();
|
|
||||||
picked.offer(options.size() - 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
dialog.setContentPane(panel);
|
|
||||||
dialog.pack();
|
|
||||||
dialog.setLocationRelativeTo(frame);
|
|
||||||
dialog.setVisible(true);
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
return picked.take();
|
return picked.take();
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
@@ -391,6 +495,179 @@ public class SwingIO implements GameIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
|
||||||
|
private String menuTitle;
|
||||||
|
|
||||||
|
/** Options of the menu currently shown, or null when no menu is visible. */
|
||||||
|
private List<String> menuOptions;
|
||||||
|
|
||||||
|
/** Queue the current menu's chosen index is offered to. */
|
||||||
|
private LinkedBlockingQueue<Integer> menuPicked;
|
||||||
|
|
||||||
|
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
|
||||||
|
private void rescaleMenuOverlayIfVisible() {
|
||||||
|
if (menuOptions != null && frame.getGlassPane().isVisible()) {
|
||||||
|
showMenuOverlay(menuTitle, menuOptions, menuPicked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the menu as the frame's glass pane (covers everything) and shows it.
|
||||||
|
* Disables the input field while the menu is up. A button click offers that
|
||||||
|
* button's index to {@code picked}; Escape offers the last option.
|
||||||
|
*
|
||||||
|
* @param title heading shown above the options
|
||||||
|
* @param options option labels, one button each
|
||||||
|
* @param picked queue the chosen index is offered to
|
||||||
|
*/
|
||||||
|
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
|
||||||
|
menuTitle = title;
|
||||||
|
menuOptions = options;
|
||||||
|
menuPicked = picked;
|
||||||
|
float scale = frameScale() * zoom; // same scaling the rest of the UI uses
|
||||||
|
|
||||||
|
JPanel glass = new JPanel(new GridBagLayout());
|
||||||
|
glass.setOpaque(true);
|
||||||
|
glass.setBackground(MENU_BG);
|
||||||
|
glass.addMouseListener(new java.awt.event.MouseAdapter() { }); // swallow backdrop clicks
|
||||||
|
|
||||||
|
JPanel card = new JPanel();
|
||||||
|
card.setOpaque(false);
|
||||||
|
card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS));
|
||||||
|
|
||||||
|
JLabel heading = new JLabel(title.toUpperCase());
|
||||||
|
heading.setForeground(MENU_TITLE);
|
||||||
|
heading.setFont(baseFont.deriveFont(Font.BOLD, BASE_SIZE * scale * 1.6f));
|
||||||
|
heading.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||||
|
heading.setBorder(BorderFactory.createEmptyBorder(0, 0, (int) (22 * scale), 0));
|
||||||
|
card.add(heading);
|
||||||
|
|
||||||
|
Runnable cancel = () -> {
|
||||||
|
hideMenuOverlay();
|
||||||
|
picked.offer(options.size() - 1);
|
||||||
|
};
|
||||||
|
for (int i = 0; i < options.size(); i++) {
|
||||||
|
int idx = i;
|
||||||
|
JButton b = menuButton(options.get(i), scale);
|
||||||
|
b.addActionListener(e -> {
|
||||||
|
hideMenuOverlay();
|
||||||
|
picked.offer(idx);
|
||||||
|
});
|
||||||
|
card.add(b);
|
||||||
|
if (i < options.size() - 1) {
|
||||||
|
card.add(Box.createVerticalStrut((int) (10 * scale)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape selects the last (safe/cancel) option — mirrors the console EOF convention.
|
||||||
|
glass.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
|
||||||
|
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "menuCancel");
|
||||||
|
glass.getActionMap().put("menuCancel", action(e -> cancel.run()));
|
||||||
|
|
||||||
|
glass.add(card); // single child → centered by GridBagLayout
|
||||||
|
frame.setGlassPane(glass);
|
||||||
|
glass.setVisible(true);
|
||||||
|
// The glass pane swallows mouse events but cannot take keyboard focus,
|
||||||
|
// so the input field would stay live behind the menu and queue commands
|
||||||
|
// that get replayed once the menu closes. Disable it for the duration.
|
||||||
|
input.setEnabled(false);
|
||||||
|
glass.requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the menu overlay, discards anything typed while it was up, and
|
||||||
|
* returns focus to the input field.
|
||||||
|
*/
|
||||||
|
private void hideMenuOverlay() {
|
||||||
|
menuOptions = null; // stop resize rebuilds once the menu is dismissed
|
||||||
|
frame.getGlassPane().setVisible(false);
|
||||||
|
// Drop anything typed before the field was disabled, so a stale line
|
||||||
|
// cannot be replayed as a game command.
|
||||||
|
inputs.clear();
|
||||||
|
input.setEnabled(true);
|
||||||
|
input.requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Palette for the themed menu overlay, matching the game window's dark look. */
|
||||||
|
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
|
||||||
|
|
||||||
|
/** Colour of the menu heading. */
|
||||||
|
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
|
||||||
|
|
||||||
|
/** Menu-button background at rest. */
|
||||||
|
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
|
||||||
|
|
||||||
|
/** Menu-button background under the mouse. */
|
||||||
|
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
|
||||||
|
|
||||||
|
/** Menu-button label colour. */
|
||||||
|
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
|
||||||
|
|
||||||
|
/** Menu-button outline at rest. */
|
||||||
|
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
|
||||||
|
|
||||||
|
/** Menu-button outline under the mouse. */
|
||||||
|
private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flat, dark, fixed-width menu button matching the game palette, with hover feedback.
|
||||||
|
*
|
||||||
|
* @param text the button label
|
||||||
|
* @param scale the scale its size and font are derived from
|
||||||
|
* @return the button
|
||||||
|
*/
|
||||||
|
private JButton menuButton(String text, float scale) {
|
||||||
|
int padV = (int) (11 * scale);
|
||||||
|
int padH = (int) (18 * scale);
|
||||||
|
JButton b = new JButton(text);
|
||||||
|
b.setFont(baseFont.deriveFont(BASE_SIZE * scale));
|
||||||
|
b.setForeground(BTN_FG);
|
||||||
|
b.setBackground(BTN_BG);
|
||||||
|
b.setFocusPainted(false);
|
||||||
|
b.setOpaque(true);
|
||||||
|
b.setHorizontalAlignment(SwingConstants.LEFT);
|
||||||
|
b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||||
|
b.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||||
|
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
|
||||||
|
Dimension size = new Dimension((int) (340 * scale), b.getPreferredSize().height);
|
||||||
|
b.setPreferredSize(size);
|
||||||
|
b.setMinimumSize(size);
|
||||||
|
b.setMaximumSize(size);
|
||||||
|
b.addMouseListener(new java.awt.event.MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseEntered(java.awt.event.MouseEvent e) {
|
||||||
|
b.setBackground(BTN_BG_HOVER);
|
||||||
|
b.setBorder(menuButtonBorder(BTN_LINE_HOVER, padV, padH));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseExited(java.awt.event.MouseEvent e) {
|
||||||
|
b.setBackground(BTN_BG);
|
||||||
|
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param line outline colour
|
||||||
|
* @param padV vertical inner padding, in pixels
|
||||||
|
* @param padH horizontal inner padding, in pixels
|
||||||
|
* @return a one-pixel outline with the given inner padding
|
||||||
|
*/
|
||||||
|
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
|
||||||
|
return BorderFactory.createCompoundBorder(
|
||||||
|
BorderFactory.createLineBorder(line, 1),
|
||||||
|
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the entries as comma-separated spans of the given style.
|
||||||
|
*
|
||||||
|
* @param spans target list the spans are added to
|
||||||
|
* @param xs the entries
|
||||||
|
* @param st style applied to each entry
|
||||||
|
*/
|
||||||
private void addList(List<Span> spans, List<String> xs, Style st) {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EffectDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
|
||||||
|
public final class CombinationFactory {
|
||||||
|
|
||||||
|
/** Utility class; not instantiable. */
|
||||||
|
private CombinationFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a combination and checks every referenced item id against the registry.
|
||||||
|
*
|
||||||
|
* @param dto data read from {@code combinations.yaml}; must not be null
|
||||||
|
* @param items the global item registry
|
||||||
|
* @return the freshly built combination
|
||||||
|
* @throws WorldLoadException if an item id is missing, unknown, or both
|
||||||
|
* ingredients are the same item
|
||||||
|
*/
|
||||||
|
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
|
||||||
|
requireItem(items, dto.a(), "a");
|
||||||
|
requireItem(items, dto.b(), "b");
|
||||||
|
if (dto.a().equals(dto.b())) {
|
||||||
|
throw new WorldLoadException(
|
||||||
|
"Combination cannot combine an item with itself: '" + dto.a() + "'");
|
||||||
|
}
|
||||||
|
if (dto.consume() != null) {
|
||||||
|
for (String id : dto.consume()) {
|
||||||
|
requireItem(items, id, "consume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.produce() != null) {
|
||||||
|
requireItem(items, dto.produce(), "produce");
|
||||||
|
}
|
||||||
|
return new Combination(
|
||||||
|
dto.a(),
|
||||||
|
dto.b(),
|
||||||
|
ConditionDto.toModelList(dto.requires()),
|
||||||
|
dto.consume() == null ? List.of() : List.copyOf(dto.consume()),
|
||||||
|
dto.produce(),
|
||||||
|
EffectDto.toModelList(dto.effects()),
|
||||||
|
dto.response(),
|
||||||
|
dto.failText());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that {@code id} is present and known to the item registry.
|
||||||
|
*
|
||||||
|
* @param items the global item registry
|
||||||
|
* @param id the item id to check
|
||||||
|
* @param field name of the DTO field, used in the error message
|
||||||
|
* @throws WorldLoadException if the id is null or unknown
|
||||||
|
*/
|
||||||
|
private static void requireItem(Map<String, Item> items, String id, String field) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
|
||||||
|
}
|
||||||
|
if (!items.containsKey(id)) {
|
||||||
|
throw new WorldLoadException(
|
||||||
|
"Combination references unknown item '" + id + "' (field '" + field + "')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ public final class RoomFactory {
|
|||||||
public static Room shellFromDto(RoomDto dto) {
|
public static Room shellFromDto(RoomDto dto) {
|
||||||
Room room = new Room(dto.id(), dto.name(), dto.description());
|
Room room = new Room(dto.id(), dto.name(), dto.description());
|
||||||
room.setDark(Boolean.TRUE.equals(dto.dark()));
|
room.setDark(Boolean.TRUE.equals(dto.dark()));
|
||||||
|
room.setMusic(dto.music());
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.TutorialDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.TutorialStepDto;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the optional onboarding tutorial from {@code <base>/tutorial.yaml}.
|
||||||
|
* Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world
|
||||||
|
* content. Returns {@link Tutorial#none()} when the file is absent.
|
||||||
|
*/
|
||||||
|
public class TutorialLoader {
|
||||||
|
|
||||||
|
/** Default classpath base directory. */
|
||||||
|
public static final String DEFAULT_BASE = "/world";
|
||||||
|
|
||||||
|
/** Single Jackson mapper, configured once for YAML input. */
|
||||||
|
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
||||||
|
|
||||||
|
/** Classpath base directory (typically {@link #DEFAULT_BASE} or a test override). */
|
||||||
|
private final String basePath;
|
||||||
|
|
||||||
|
/** Convenience constructor using {@link #DEFAULT_BASE}. */
|
||||||
|
public TutorialLoader() {
|
||||||
|
this(DEFAULT_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a loader reading from the given classpath base directory.
|
||||||
|
*
|
||||||
|
* @param basePath classpath base directory holding {@code tutorial.yaml}
|
||||||
|
*/
|
||||||
|
public TutorialLoader(String basePath) {
|
||||||
|
this.basePath = basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the tutorial; absent file → {@link Tutorial#none()}.
|
||||||
|
*
|
||||||
|
* @return the parsed tutorial, or {@link Tutorial#none()} if the file is
|
||||||
|
* missing or empty
|
||||||
|
* @throws WorldLoadException if the file exists but cannot be parsed
|
||||||
|
*/
|
||||||
|
public Tutorial load() {
|
||||||
|
String resource = basePath + "/tutorial.yaml";
|
||||||
|
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
||||||
|
if (in == null) {
|
||||||
|
return Tutorial.none();
|
||||||
|
}
|
||||||
|
TutorialDto dto = yaml.readValue(in, TutorialDto.class);
|
||||||
|
if (dto == null) {
|
||||||
|
return Tutorial.none();
|
||||||
|
}
|
||||||
|
List<TutorialStep> steps = new ArrayList<>();
|
||||||
|
if (dto.steps() != null) {
|
||||||
|
for (TutorialStepDto s : dto.steps()) {
|
||||||
|
steps.add(new TutorialStep(
|
||||||
|
s.instruction(),
|
||||||
|
s.expect(),
|
||||||
|
s.minArgs() == null ? 0 : s.minArgs(),
|
||||||
|
s.confirm(),
|
||||||
|
s.hint()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Tutorial(dto.intro(), steps, dto.closingTips());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.type.CollectionType;
|
|||||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.Player;
|
import thb.jeanluc.adventure.model.Player;
|
||||||
@@ -75,6 +77,8 @@ public class WorldLoader {
|
|||||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||||
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
||||||
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
||||||
|
List<CombinationDto> combinationDtos =
|
||||||
|
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
|
||||||
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
||||||
|
|
||||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||||
@@ -104,23 +108,42 @@ public class WorldLoader {
|
|||||||
endings.add(EndingFactory.fromDto(dto));
|
endings.add(EndingFactory.fromDto(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, Combination> combinations = new HashMap<>();
|
||||||
|
for (CombinationDto dto : combinationDtos) {
|
||||||
|
Combination c = CombinationFactory.fromDto(dto, items);
|
||||||
|
String key = c.key();
|
||||||
|
if (combinations.containsKey(key)) {
|
||||||
|
throw new WorldLoadException("Duplicate combination for pair '" + key + "'");
|
||||||
|
}
|
||||||
|
combinations.put(key, c);
|
||||||
|
}
|
||||||
|
|
||||||
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||||
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();
|
||||||
Player player = new Player(start, gold);
|
Player player = new Player(start, gold);
|
||||||
|
|
||||||
World world = new World(rooms, items, npcs,
|
World world = new World(rooms, items, npcs,
|
||||||
gameDto.title(), gameDto.welcomeMessage(), quests, endings);
|
gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
|
||||||
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||||
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);
|
||||||
@@ -131,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) {
|
||||||
@@ -144,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);
|
||||||
@@ -152,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) {
|
||||||
@@ -160,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YAML representation of an item-combination recipe.
|
||||||
|
*
|
||||||
|
* @param a id of the first ingredient item
|
||||||
|
* @param b id of the second ingredient item
|
||||||
|
* @param requires conditions that must hold for the recipe to apply; nullable
|
||||||
|
* @param consume ids of the items removed from the inventory on success; nullable
|
||||||
|
* @param produce id of the item handed to the player on success; nullable
|
||||||
|
* @param effects effects applied on success; nullable
|
||||||
|
* @param response text printed on success
|
||||||
|
* @param failText text printed when the required conditions are not met; nullable
|
||||||
|
*/
|
||||||
|
public record CombinationDto(
|
||||||
|
String a,
|
||||||
|
String b,
|
||||||
|
List<ConditionDto> requires,
|
||||||
|
List<String> consume,
|
||||||
|
String produce,
|
||||||
|
List<EffectDto> effects,
|
||||||
|
String response,
|
||||||
|
String failText
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user