Compare commits
137 Commits
83643a192f
...
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 | |||
| 0b9f55d0b1 | |||
| a130cf329b | |||
| 3225599412 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,4 +8,7 @@ out/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Nextcloud sync
|
||||
.sync-exclude.lst
|
||||
|
||||
13
Semesterprojekt/.gitignore
vendored
13
Semesterprojekt/.gitignore
vendored
@@ -36,4 +36,15 @@ build/
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Local save games and settings
|
||||
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
|
||||
171
Semesterprojekt/README.md
Normal file
171
Semesterprojekt/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Haunted Manor – Textadventure
|
||||
|
||||
Semesterprojekt im Modul Algorithmen und Datenstrukturen (2. Semester).
|
||||
Ein textbasiertes Adventure in Java: Der Spieler navigiert durch die Räume eines
|
||||
Spukhauses, sammelt Gegenstände, spricht mit NPCs, löst Rätsel und findet einen
|
||||
Weg hinaus.
|
||||
|
||||
Die Spielwelt ist vollständig datengetrieben – Räume, Gegenstände, NPCs, Quests
|
||||
und Enden werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
|
||||
|
||||
**Umfang:** 14 Räume, 19 Gegenstände, 3 NPCs, 259 Tests.
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Es gibt **zwei Wege**, das Spiel zu starten. Wenn Sie nur spielen wollen, nehmen
|
||||
Sie Weg A – dafür wird kein Maven benötigt.
|
||||
|
||||
### 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
|
||||
java -version
|
||||
```
|
||||
|
||||
### Weg A – fertige JARs starten (ohne Maven)
|
||||
|
||||
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
|
||||
java -jar release/HauntedManor-Console.jar # Konsolen-Version (~5 MB)
|
||||
java -jar release/HauntedManor-GUI.jar # Swing-GUI (~17 MB)
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
### Weg B – mit Maven bauen und starten
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| **Raum-Klasse + Navigation**, mind. 4 Räume | [`model/Room.java`](src/main/java/thb/jeanluc/adventure/model/Room.java), [`model/Direction.java`](src/main/java/thb/jeanluc/adventure/model/Direction.java) | **14 Räume.** `Room` hält direkte Referenzen auf seine Nachbarräume in einer `EnumMap<Direction, Room>` – ein echter Objektgraph, keine String-IDs. |
|
||||
| **`gehen`-Befehl** (Tipp: switch / if-else / HashMap) | [`command/CommandRegistry.java`](src/main/java/thb/jeanluc/adventure/command/CommandRegistry.java), [`command/impl/GoCommand.java`](src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java) | Dispatch über `HashMap<String, Command>` (O(1); Aliase = mehrere Keys auf dieselbe Instanz, statt eines wachsenden `switch`). Die Richtung danach O(1) per `EnumMap`-Lookup. |
|
||||
| **Gegenstände + Inventar**, mind. 3 Items | [`model/Player.java`](src/main/java/thb/jeanluc/adventure/model/Player.java), [`model/item/`](src/main/java/thb/jeanluc/adventure/model/item/) | **19 Gegenstände.** Inventar = `LinkedHashMap<String, Item>` (O(1)-Zugriff *und* stabile Anzeigereihenfolge). Hierarchie: `PlainItem`, `ReadableItem`, `SwitchableItem`. |
|
||||
| **Aufnehmen / Ablegen / Benutzen / Lesen** | [`command/impl/`](src/main/java/thb/jeanluc/adventure/command/impl/) | `TakeCommand`, `DropCommand`, `UseCommand`, `ReadCommand` – inklusive `letter` („Brief") und `shovel` („Schaufel") aus den Beispielen der Aufgabenstellung. |
|
||||
| **Wahl der Datenstrukturen** | [`docs/data-structures.md`](docs/data-structures.md) | Begründung je Anwendungsfall, plus Tabelle „Bewusst NICHT gewählt". |
|
||||
| **NPCs** (Bonus) | [`model/Npc.java`](src/main/java/thb/jeanluc/adventure/model/Npc.java) | 3 NPCs, die auf den Spielerzustand **reagieren** (`talk`, `give`). |
|
||||
| **Swing-GUI** (Bonus) | [`io/SwingIO.java`](src/main/java/thb/jeanluc/adventure/io/SwingIO.java) | `JTextField` für die Eingabe (siehe Hinweis unten). |
|
||||
| **Javadoc** | gesamtes `src/main/java` | An Klassen, Methoden und Instanzvariablen. |
|
||||
|
||||
**Hinweis zur GUI:** Die Aufgabe nennt `JTextField` **und** `JTextArea`. Die
|
||||
Eingabe ist wie gefordert ein `JTextField`. Für die **Ausgabe** wird bewusst ein
|
||||
`JTextPane` statt einer `JTextArea` verwendet: `JTextArea` kann ausschließlich
|
||||
unformatierten Text darstellen, das Spiel rendert aber farbig gestylte Ausgabe
|
||||
(`StyledText` → `StyledDocument`), damit Raumnamen, Gegenstände und Hinweise
|
||||
optisch unterscheidbar sind. `JTextPane` ist dafür die passende Swing-Komponente
|
||||
und funktional eine Obermenge von `JTextArea`.
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
Semesterprojekt/
|
||||
├── src/main/java/thb/jeanluc/adventure/
|
||||
│ ├── App.java – Einstiegspunkt Konsole
|
||||
│ ├── AppGui.java – Einstiegspunkt Swing-GUI
|
||||
│ ├── model/ – Domänenmodell: Room, Player, Npc, item/ …
|
||||
│ ├── command/ – Parser, Registry + impl/ (15 Kommandos)
|
||||
│ ├── game/ – Spielschleife, Engines, Light, Pathfinder, MapLayout
|
||||
│ ├── io/ – GameIO-Abstraktion: ConsoleIO, SwingIO + text/
|
||||
│ ├── loader/ – YAML-Laden: dto/, Factories, Resolver, Validator
|
||||
│ ├── save/ – Speicherstände und Einstellungen (JSON)
|
||||
│ └── menu/ – Hauptmenü und Einstellungen
|
||||
├── src/main/resources/world/
|
||||
│ ├── game.yaml – Metadaten (Titel, Startraum, Begrüßung)
|
||||
│ ├── rooms.yaml – Räume mit Ausgängen, Gegenständen, NPCs
|
||||
│ ├── items.yaml – Gegenstände
|
||||
│ ├── npcs.yaml – NPCs und ihre Reaktionen
|
||||
│ ├── quests.yaml – Quests und ihre Stufen
|
||||
│ ├── endings.yaml – Spielenden
|
||||
│ └── combinations.yaml – Gegenstands-Kombinationen (`use X on Y`)
|
||||
├── src/test/java/ – 259 Tests (JUnit 5 + AssertJ)
|
||||
├── release/ – fertig gebaute JARs (Console + GUI)
|
||||
└── docs/ – Design- und Architekturdokumentation
|
||||
```
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- **Java 21 (LTS)**
|
||||
- **Jackson** (YAML) für das Laden der Spielwelt
|
||||
- **Lombok** zur Reduktion von Boilerplate
|
||||
- **JUnit 5 + AssertJ + Mockito** für die Tests
|
||||
- **SLF4J + Logback** für Logging
|
||||
- **Swing** für die GUI (Bonus)
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Ausführliche Design- und Architekturdokumentation liegt unter
|
||||
[`docs/`](docs/README.md): Package-Struktur, Wahl der Datenstrukturen,
|
||||
Item-Modell, Befehle, NPC-Modell, YAML-Schemas und Lade-Ablauf.
|
||||
@@ -1,33 +1,36 @@
|
||||
# Semesterprojekt – Textadventure
|
||||
|
||||
Design- und Architekturdokumentation. Dient als Spec während der Implementierung.
|
||||
Design- und Architekturdokumentation. Bedienung und Build stehen im
|
||||
[Haupt-README](../README.md).
|
||||
|
||||
## Inhalt
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | Package-Struktur, Schichten, DTO-vs-Domain-Trennung |
|
||||
| [conventions.md](conventions.md) | Sprache, ID-Format, Naming, Lombok-Cheatsheet |
|
||||
| [data-structures.md](data-structures.md) | Alle gewählten Collection-Typen mit Begründung |
|
||||
| [conventions.md](conventions.md) | Sprache, ID-Format, Naming, Lombok-Cheatsheet |
|
||||
| [item-model.md](item-model.md) | Item-Hierarchie (abstract + 3 Subtypen), Lombok-Inheritance |
|
||||
| [yaml-schemas.md](yaml-schemas.md) | Schemas für `items.yaml`, `rooms.yaml`, `npcs.yaml`, `game.yaml` |
|
||||
| [loading-flow.md](loading-flow.md) | Lade-Reihenfolge, Referenz-Auflösung, Validierung |
|
||||
| [commands.md](commands.md) | Befehlsparser, Command-Pattern, Befehlsliste |
|
||||
| [npcs.md](npcs.md) | NPC-Modell, Talk- und Give-Interaktion |
|
||||
| [implementation-status.md](implementation-status.md) | Aktueller Stand, Phasen-Checkliste, festgelegte Entscheidungen |
|
||||
| [yaml-schemas.md](yaml-schemas.md) | Schemas der YAML-Dateien unter `resources/world/` |
|
||||
| [loading-flow.md](loading-flow.md) | Lade-Reihenfolge, Referenz-Auflösung, Validierung |
|
||||
| [music.md](music.md) | Hintergrundmusik in der GUI (Bonus) |
|
||||
|
||||
## Pflicht vs. Optional (laut Aufgabenstellung)
|
||||
|
||||
- **Pflicht:** ≥4 Räume mit Navigation, ≥3 Gegenstände mit Inventar
|
||||
- **Optional/Bonus:** NPCs, Swing-GUI
|
||||
- **Pflicht:** ≥ 4 Räume mit Navigation, ≥ 3 Gegenstände mit Inventar —
|
||||
erfüllt mit 14 Räumen und 19 Gegenständen.
|
||||
- **Optional/Bonus:** NPCs (3) und Swing-GUI — beides umgesetzt.
|
||||
|
||||
Beide optionalen Teile sind hier eingeplant.
|
||||
Welche Klasse welche Anforderung umsetzt, steht als Tabelle im
|
||||
[Haupt-README](../README.md).
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- Java 25
|
||||
- Jackson (YAML) für Daten-Loading
|
||||
- Lombok für Boilerplate-Reduktion
|
||||
- Java 21 (LTS)
|
||||
- Jackson (YAML) für das Laden der Spielwelt
|
||||
- Lombok zur Reduktion von Boilerplate
|
||||
- JUnit 5 + AssertJ + Mockito für Tests
|
||||
- Logback + SLF4J für Logging
|
||||
- Swing für GUI (Bonus)
|
||||
- SLF4J + Logback für Logging
|
||||
- Swing für die GUI (Bonus)
|
||||
|
||||
@@ -33,19 +33,22 @@ flowchart LR
|
||||
model --> item
|
||||
item --> item_files["Item (abstract)<br/>ReadableItem<br/>SwitchableItem<br/>PlainItem"]
|
||||
|
||||
loader --> loader_files["WorldLoader<br/>ReferenceResolver<br/>WorldValidator"]
|
||||
loader --> loader_files["WorldLoader<br/>ReferenceResolver<br/>WorldValidator<br/>*Factory"]
|
||||
loader --> dto
|
||||
dto --> dto_files["RoomDto, ItemDto,<br/>NpcDto, GameDto"]
|
||||
dto --> dto_files["RoomDto, ItemDto,<br/>NpcDto, GameDto,<br/>QuestDto, EndingDto, …"]
|
||||
|
||||
command --> cmd_core["Command (Interface)<br/>CommandRegistry<br/>CommandParser"]
|
||||
command --> impl
|
||||
impl --> impl_files["GoCommand, TakeCommand,<br/>DropCommand, UseCommand,<br/>InventoryCommand, LookCommand,<br/>TalkCommand, GiveCommand,<br/>HelpCommand, QuitCommand"]
|
||||
impl --> impl_files["GoCommand, TakeCommand,<br/>DropCommand, UseCommand,<br/>ReadCommand, ExamineCommand,<br/>InventoryCommand, LookCommand,<br/>TalkCommand, GiveCommand,<br/>MapCommand, QuestsCommand,<br/>SaveCommand, MenuCommand,<br/>HelpCommand"]
|
||||
|
||||
game --> game_files["Game (Loop)<br/>GameContext"]
|
||||
game --> game_files["Game (Loop)<br/>GameContext/Session/State<br/>QuestEngine, EndingEngine,<br/>EscortEngine, Light,<br/>Pathfinder, MapLayout"]
|
||||
|
||||
io --> io_files["GameIO (Interface)<br/>ConsoleIO<br/>SwingIO"]
|
||||
io --> io_files["GameIO (Interface)<br/>ConsoleIO<br/>SwingIO<br/>io.text (Styled Output)"]
|
||||
```
|
||||
|
||||
> **Hinweis:** Es gibt **keine** `QuitCommand`-Klasse. `quit`/`exit`/`beenden` sind Aliase,
|
||||
> die in `App` auf `MenuCommand` registriert werden (siehe `App.play`).
|
||||
|
||||
## DTO vs. Domain-Trennung
|
||||
|
||||
**Kernprinzip:** YAML wird in Records deserialisiert (DTOs), erst danach werden String-IDs zu Objekt-Referenzen aufgelöst.
|
||||
@@ -67,15 +70,15 @@ flowchart LR
|
||||
## Game-Loop (vereinfacht)
|
||||
|
||||
```java
|
||||
while (!game.isOver()) {
|
||||
String input = io.read();
|
||||
while (running) {
|
||||
String input = io.readLine();
|
||||
if (input == null) break; // EOF
|
||||
ParsedCommand parsed = parser.parse(input);
|
||||
Command cmd = registry.get(parsed.verb());
|
||||
if (cmd == null) {
|
||||
io.write("Unbekannter Befehl.");
|
||||
} else {
|
||||
cmd.execute(context, parsed.args());
|
||||
}
|
||||
registry.find(parsed.verb()).ifPresentOrElse(
|
||||
cmd -> cmd.execute(ctx, parsed.args()),
|
||||
() -> io.write("I don't understand '" + parsed.verb() + "'."));
|
||||
publishHud(); // Engines ticken, HUD/Map/Quests updaten
|
||||
maybeEnd(); // Ending-Bedingungen prüfen
|
||||
}
|
||||
```
|
||||
|
||||
@@ -85,9 +88,25 @@ Konsole und GUI teilen sich `GameIO`:
|
||||
|
||||
```java
|
||||
public interface GameIO {
|
||||
String read(); // blockierende Leseoperation
|
||||
void write(String text);
|
||||
String readLine(); // blockierende Leseoperation
|
||||
void print(StyledText text); // einzige Ausgabe-Primitive
|
||||
|
||||
// Convenience + Presentation-Hooks als default-Methoden:
|
||||
default void write(String text) { … } // Kurzform für ungestylten Text
|
||||
default void showRoom(RoomView v) { … }
|
||||
default void setHud(Hud hud) { … }
|
||||
default void setMap(MapView map) { … }
|
||||
default void setQuests(QuestView quests) { … }
|
||||
default void setMusic(String track) { … }
|
||||
default int choose(String title, List<String> options) { … }
|
||||
default void shutdown() { … }
|
||||
}
|
||||
```
|
||||
|
||||
Damit ist der Game-Loop **identisch** für beide Modi. `SwingIO` blockiert intern mit einer `BlockingQueue<String>`, die vom JTextField-ActionListener gefüllt wird.
|
||||
Ausgabe läuft **immer** über `print(StyledText)` — eine Liste von `Span`s mit `Style`.
|
||||
`ConsoleIO` rendert sie als ANSI-Farben (oder ASCII, je nach Settings), `SwingIO` als
|
||||
`StyledDocument` im `JTextPane`. Die `default`-Methoden geben der GUI Extra-Hooks
|
||||
(Karte, Quest-Panel, Musik), die die Konsole schlicht ignoriert oder als Text ausgibt.
|
||||
|
||||
Damit ist der Game-Loop **identisch** für beide Modi. `SwingIO` blockiert intern mit einer
|
||||
`LinkedBlockingQueue<String>`, die vom JTextField-ActionListener gefüllt wird.
|
||||
|
||||
@@ -32,24 +32,28 @@ Geltungsbereich: id ist eindeutig **innerhalb seiner Entitätsart**. Ein Item un
|
||||
|
||||
```
|
||||
thb.jeanluc.adventure
|
||||
├── App, AppGui <- Entry Points (Konsole / Swing)
|
||||
├── model
|
||||
│ ├── Direction
|
||||
│ ├── Room
|
||||
│ ├── Player
|
||||
│ ├── Npc
|
||||
│ ├── Direction, Room, Player, Npc
|
||||
│ ├── Quest, QuestStage, Ending, Combination, Condition, Effect, …
|
||||
│ └── item <- eigener Subpackage wegen Hierarchie
|
||||
│ ├── Item (abstract)
|
||||
│ ├── ReadableItem
|
||||
│ ├── SwitchableItem
|
||||
│ └── PlainItem
|
||||
├── io
|
||||
├── command
|
||||
├── game
|
||||
└── loader
|
||||
└── dto
|
||||
├── io <- ConsoleIO, SwingIO, GameIO, Music*, MapPanel, QuestPanel
|
||||
│ └── text <- StyledText, Style, Span, Hud, *View (15 Klassen)
|
||||
├── command <- Command, CommandParser, CommandRegistry, ParsedCommand
|
||||
│ └── impl <- die 15 konkreten Kommandos
|
||||
├── game <- Game-Loop, Engines (Quest, Ending, Escort), Light,
|
||||
│ Pathfinder, MapLayout, GameState/Session/Context
|
||||
├── loader
|
||||
│ └── dto <- die YAML-Records
|
||||
├── save <- SaveService, SaveCodec, SaveData, Settings*
|
||||
└── menu <- MainMenu, SettingsMenu, MenuAction
|
||||
```
|
||||
|
||||
**Wann ein Subpackage:** wenn eine Klassenfamilie ≥ 3 Klassen umfasst und eine eigene Abstraktion bildet (`item`, evtl. später `command.impl`). Sonst flach lassen.
|
||||
**Wann ein Subpackage:** wenn eine Klassenfamilie ≥ 3 Klassen umfasst und eine eigene Abstraktion bildet. Erfüllt von `item` (4), `command.impl` (15), `io.text` (15), `loader.dto` (16), `save` (7), `menu` (3). Sonst flach lassen — `MapLayout` liegt deshalb in `game` neben `Pathfinder` und **nicht** in einem eigenen `map`-Package (eine Klasse reicht nicht).
|
||||
|
||||
## Naming
|
||||
|
||||
|
||||
@@ -15,13 +15,16 @@ Bewusste Wahl jeder Collection — der Dozent bewertet das laut Aufgabenstellung
|
||||
| Spieler-Inventar | `LinkedHashMap<String, Item>` | O(1) |
|
||||
| Befehlsregistry | `HashMap<String, Command>` | O(1) |
|
||||
| NPC-Reaktionen | `HashMap<String, NpcReaction>` | O(1) |
|
||||
| Eingabehistorie (optional) | `ArrayDeque<String>` | O(1) Front/Back |
|
||||
| Besuchte Räume (für die Karte) | `LinkedHashSet<String>` | O(1) + Besuchsreihenfolge |
|
||||
| BFS-Queue (Pathfinder, MapLayout) | `ArrayDeque<Room>` | O(1) FIFO |
|
||||
| BFS-Pfadrekonstruktion | `LinkedList<Room>` | O(1) `addFirst` |
|
||||
| GUI-Eingabebrücke (EDT → Worker) | `LinkedBlockingQueue<String>` | blockierend, unbegrenzt |
|
||||
|
||||
## Begründungen im Detail
|
||||
|
||||
### `EnumMap<Direction, Room>` für Raum-Ausgänge
|
||||
|
||||
- **Direction** ist Enum (`NORTH`, `SOUTH`, `EAST`, `WEST`, evtl. `UP`, `DOWN`)
|
||||
- **Direction** ist Enum (`NORTH`, `SOUTH`, `EAST`, `WEST`)
|
||||
- `EnumMap` ist array-backed, kein Hashing nötig → schneller und kompakter als `HashMap`
|
||||
- Iteration in Enum-Deklarationsreihenfolge (stabil)
|
||||
|
||||
@@ -49,11 +52,19 @@ Entscheidung "keine Stapel" (1 Item pro id) macht das Map-basierte Modell sauber
|
||||
- Entspricht dem expliziten Tipp aus der Aufgabenstellung
|
||||
- Vermeidet wachsendes `switch`-Statement
|
||||
|
||||
### `ArrayDeque<String>` für Historie (optional)
|
||||
### `ArrayDeque<Room>` als BFS-Queue
|
||||
|
||||
- Falls Up-Arrow in der GUI gewünscht oder Befehlsverlauf
|
||||
- `ArrayDeque` ist `LinkedList` praktisch immer überlegen (bessere Cache-Lokalität, weniger Overhead)
|
||||
- Beidseitige O(1)-Operationen
|
||||
- `Pathfinder` (Auto-Travel `go to <raum>`) und `MapLayout` (Karten-Layout) sind beides
|
||||
Breitensuchen → brauchen eine **FIFO-Queue**
|
||||
- `ArrayDeque` ist die Standardwahl dafür: O(1) an beiden Enden, keine Node-Allokation
|
||||
wie bei `LinkedList`, bessere Cache-Lokalität
|
||||
|
||||
### `LinkedList<Room>` für die Pfadrekonstruktion
|
||||
|
||||
Die eine Stelle, an der `LinkedList` die richtige Wahl ist (`Pathfinder`):
|
||||
Nach der BFS läuft man über `cameFrom` **rückwärts** vom Ziel zum Start und will den
|
||||
Pfad in Vorwärtsreihenfolge. `addFirst()` ist bei `LinkedList` O(1) — bei `ArrayList`
|
||||
wäre `add(0, x)` O(n), weil alles verschoben werden müsste.
|
||||
|
||||
## Bewusst NICHT gewählt
|
||||
|
||||
@@ -62,7 +73,7 @@ Entscheidung "keine Stapel" (1 Item pro id) macht das Map-basierte Modell sauber
|
||||
| `ArrayList<Item>` für Inventar | O(n)-Lookup, Duplikat-Handling nötig |
|
||||
| `HashMap<String, Item>` für Inventar | Anzeige-Reihenfolge instabil |
|
||||
| `TreeMap` irgendwo | Keine sortierte Iteration nötig, O(log n) ohne Nutzen |
|
||||
| `LinkedList` | `ArrayDeque` ist fast immer besser |
|
||||
| `LinkedList` als Queue | dafür ist `ArrayDeque` besser (s.o.) — als `addFirst`-Liste in der Pfadrekonstruktion aber sehr wohl genutzt |
|
||||
| `Vector` / `Hashtable` | Legacy, synchronisiert (nicht gebraucht), langsamer |
|
||||
| `Map<String, String>` für Exits in Domain | Direction sollte Enum sein, nicht String |
|
||||
|
||||
@@ -70,4 +81,9 @@ Entscheidung "keine Stapel" (1 Item pro id) macht das Map-basierte Modell sauber
|
||||
|
||||
Single-threaded: Game-Loop liest, dispatcht, schreibt — keine parallelen Mutationen.
|
||||
|
||||
**Ausnahme:** Bei Swing-GUI läuft Input über den Event-Dispatch-Thread, der Game-Loop in einem Worker-Thread. Hier kommt `BlockingQueue<String>` (`ArrayBlockingQueue` reicht) als Brücke ins Spiel — siehe [architecture.md](architecture.md).
|
||||
**Ausnahme:** Bei Swing-GUI läuft Input über den Event-Dispatch-Thread, der Game-Loop in
|
||||
einem Worker-Thread. Als Brücke dient eine `LinkedBlockingQueue<String>` (`SwingIO`):
|
||||
Der `JTextField`-ActionListener (EDT) legt die Zeile ab, der Worker blockiert in
|
||||
`take()`. Unbegrenzt statt `ArrayBlockingQueue`, weil es keine sinnvolle Obergrenze für
|
||||
getippte Zeilen gibt und ein volles `offer()` Eingaben verlieren würde — siehe
|
||||
[architecture.md](architecture.md).
|
||||
|
||||
@@ -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).
|
||||
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>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
<maven.compiler.target>25</maven.compiler.target>
|
||||
<!-- Java 21 (LTS): the newest feature actually used is List.getFirst()
|
||||
(SequencedCollection, Java 21). Building against an LTS keeps the
|
||||
project compilable on any current JDK. -->
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<jackson.version>2.18.2</jackson.version>
|
||||
@@ -49,6 +51,18 @@
|
||||
<version>${logback.version}</version>
|
||||
</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 -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
@@ -97,6 +111,98 @@
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
</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>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>run</id>
|
||||
<configuration>
|
||||
<mainClass>thb.jeanluc.adventure.App</mainClass>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>gui</id>
|
||||
<configuration>
|
||||
<mainClass>thb.jeanluc.adventure.AppGui</mainClass>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<mainClass>thb.jeanluc.adventure.App</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
@@ -10,25 +10,44 @@ import thb.jeanluc.adventure.command.impl.GoCommand;
|
||||
import thb.jeanluc.adventure.command.impl.HelpCommand;
|
||||
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
||||
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
||||
import thb.jeanluc.adventure.command.impl.MapCommand;
|
||||
import thb.jeanluc.adventure.command.impl.MenuCommand;
|
||||
import thb.jeanluc.adventure.command.impl.QuestsCommand;
|
||||
import thb.jeanluc.adventure.command.impl.ReadCommand;
|
||||
import thb.jeanluc.adventure.command.impl.SaveCommand;
|
||||
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||
import thb.jeanluc.adventure.command.impl.TalkCommand;
|
||||
import thb.jeanluc.adventure.command.impl.UseCommand;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.game.TutorialGuide;
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.io.text.Banner;
|
||||
import thb.jeanluc.adventure.loader.TutorialLoader;
|
||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||
import thb.jeanluc.adventure.menu.MainMenu;
|
||||
import thb.jeanluc.adventure.menu.SettingsMenu;
|
||||
import thb.jeanluc.adventure.model.Tutorial;
|
||||
import thb.jeanluc.adventure.save.SaveException;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||
import thb.jeanluc.adventure.save.Settings;
|
||||
import thb.jeanluc.adventure.save.SettingsStore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entry point for the console version of the game. Loads the world from
|
||||
* YAML, wires up the command registry, and hands control to the
|
||||
* {@link Game} loop.
|
||||
* Console entry point. Loads settings, then runs the main-menu shell loop:
|
||||
* New Game / Load / Settings / Quit. Each game runs the shared {@link Game}
|
||||
* loop and returns here when the player saves-and-exits, reaches an ending,
|
||||
* or input ends. Shared by console and GUI via {@link #run(GameIO)}.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class App {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private App() {
|
||||
}
|
||||
|
||||
@@ -49,38 +68,101 @@ public final class App {
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the game on the given IO channel. Reusable by both
|
||||
* {@link App} (console) and {@code AppGui} (Swing worker thread).
|
||||
* Runs the menu shell on the given IO until the player quits.
|
||||
*
|
||||
* @param io the IO channel to use
|
||||
* @param io frontend the menu and the game are rendered on
|
||||
*/
|
||||
public static void run(GameIO io) {
|
||||
run(io, new SaveService(), new SettingsStore());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the menu shell with injectable persistence (for testing).
|
||||
*
|
||||
* @param io frontend the menu and the game are rendered on
|
||||
* @param saves save-slot backend
|
||||
* @param settingsStore settings backend
|
||||
*/
|
||||
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
|
||||
Settings settings = settingsStore.load();
|
||||
SettingsMenu.apply(settings, io);
|
||||
|
||||
boolean running = true;
|
||||
while (running) {
|
||||
switch (MainMenu.show(io)) {
|
||||
case NEW_GAME -> play(io, saves, newSession(io), true);
|
||||
case LOAD -> {
|
||||
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
||||
if (slot != null) {
|
||||
try {
|
||||
play(io, saves, saves.load(slot.slug()), false);
|
||||
} catch (SaveException e) {
|
||||
io.write("Could not load: " + e.getUserMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);
|
||||
case QUIT -> running = false;
|
||||
}
|
||||
}
|
||||
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. */
|
||||
private static GameSession newSession(GameIO io) {
|
||||
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||
GameContext ctx = new GameContext(loaded.world(), loaded.player(), io);
|
||||
String name = MainMenu.promptName(io, "Manor");
|
||||
return new GameSession(loaded.world(), loaded.player(), name);
|
||||
}
|
||||
|
||||
/** Builds the registry, runs one game to completion, autosaving to its slot. */
|
||||
private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {
|
||||
GameContext ctx = new GameContext(session, io);
|
||||
ctx.setSaveCallback(() -> {
|
||||
try {
|
||||
saves.save(session);
|
||||
} catch (SaveException e) {
|
||||
log.warn("Autosave failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
// German aliases mirror the examples in the assignment ("Gehe nach
|
||||
// Norden", "Nimm Brief", "Lies Brief", "Benutze Schaufel").
|
||||
CommandRegistry registry = new CommandRegistry();
|
||||
registry.register(new GoCommand(), "go", "move", "walk");
|
||||
registry.register(new LookCommand(), "look", "l");
|
||||
registry.register(new InventoryCommand(), "inventory", "inv", "i");
|
||||
registry.register(new TakeCommand(), "take", "pick", "get");
|
||||
registry.register(new DropCommand(), "drop", "put");
|
||||
registry.register(new UseCommand(), "use");
|
||||
registry.register(new ReadCommand(), "read");
|
||||
registry.register(new ExamineCommand(), "examine", "x", "inspect");
|
||||
registry.register(new TalkCommand(), "talk", "speak");
|
||||
registry.register(new GiveCommand(), "give");
|
||||
registry.register(new HelpCommand(registry), "help", "?");
|
||||
|
||||
QuitCommand quit = new QuitCommand();
|
||||
registry.register(quit, "quit", "exit");
|
||||
registry.register(new GoCommand(), "go", "move", "walk", "gehe", "geh");
|
||||
registry.register(new LookCommand(), "look", "l", "umsehen", "schau");
|
||||
registry.register(new InventoryCommand(), "inventory", "inv", "i", "inventar");
|
||||
registry.register(new TakeCommand(), "take", "pick", "get", "nimm", "nehme");
|
||||
registry.register(new DropCommand(), "drop", "put", "lege", "ablegen");
|
||||
registry.register(new UseCommand(), "use", "benutze", "benutz");
|
||||
registry.register(new ReadCommand(), "read", "lies", "lese");
|
||||
registry.register(new ExamineCommand(), "examine", "x", "inspect", "untersuche");
|
||||
registry.register(new MapCommand(), "map", "m", "karte");
|
||||
registry.register(new QuestsCommand(), "quests", "log", "journal", "aufgaben");
|
||||
registry.register(new TalkCommand(), "talk", "speak", "rede", "sprich");
|
||||
registry.register(new GiveCommand(), "give", "gib");
|
||||
registry.register(new SaveCommand(saves), "save", "speichern");
|
||||
|
||||
Game game = new Game(ctx, registry, new CommandParser());
|
||||
quit.bind(game);
|
||||
|
||||
io.write(loaded.world().getTitle());
|
||||
io.write(loaded.world().getWelcomeMessage());
|
||||
new LookCommand().execute(ctx, java.util.List.of());
|
||||
MenuCommand menu = new MenuCommand(saves);
|
||||
menu.bind(game);
|
||||
registry.register(menu, "menu", "quit", "exit", "beenden");
|
||||
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.write(session.getWorld().getWelcomeMessage());
|
||||
new LookCommand().execute(ctx, List.of());
|
||||
|
||||
game.run();
|
||||
io.setMusic(null); // fade music out when returning to the menu
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import javax.swing.SwingUtilities;
|
||||
@Slf4j
|
||||
public final class AppGui {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private AppGui() {
|
||||
}
|
||||
|
||||
@@ -29,8 +30,13 @@ public final class AppGui {
|
||||
try {
|
||||
App.run(io);
|
||||
} catch (RuntimeException e) {
|
||||
// The worker is the only consumer of the input queue, so once
|
||||
// it dies nothing can service the window again. Tell the player
|
||||
// what happened and close down rather than leaving a live-looking
|
||||
// window that silently ignores every command.
|
||||
log.error("Fatal error during game startup", e);
|
||||
io.write("Fatal error: " + e.getMessage());
|
||||
io.fatal("Fatal error: " + e.getMessage()
|
||||
+ "\n\nThe game cannot continue and will close.");
|
||||
}
|
||||
}, "game-loop");
|
||||
worker.setDaemon(true);
|
||||
|
||||
@@ -16,7 +16,8 @@ public class CommandParser {
|
||||
* small — when adding more, watch out for words that might be valid
|
||||
* item ids ({@code key}, {@code lamp}, etc.).
|
||||
*/
|
||||
private static final Set<String> FILLERS = Set.of("to", "with", "at", "the", "a", "an", "on");
|
||||
private static final Set<String> FILLERS =
|
||||
Set.of("to", "with", "at", "the", "a", "an", "on", "nach", "den", "die", "das");
|
||||
|
||||
/**
|
||||
* Parses an input line into a verb and its arguments.
|
||||
|
||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
@@ -20,6 +21,10 @@ public class ExamineCommand implements Command {
|
||||
ctx.getIo().write("Examine what?");
|
||||
return;
|
||||
}
|
||||
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||
ctx.getIo().write("It's too dark to see that.");
|
||||
return;
|
||||
}
|
||||
String id = args.getFirst();
|
||||
Optional<Item> item = ctx.getPlayer().findItem(id);
|
||||
if (item.isEmpty()) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Conditions;
|
||||
import thb.jeanluc.adventure.game.Effects;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.NpcReaction;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
@@ -22,6 +25,10 @@ public class GiveCommand implements Command {
|
||||
ctx.getIo().write("Usage: give <item> <npc>.");
|
||||
return;
|
||||
}
|
||||
if (!Light.canSeeRoom(ctx)) {
|
||||
ctx.getIo().write("It's too dark to make out anyone here.");
|
||||
return;
|
||||
}
|
||||
String itemId = args.get(0);
|
||||
String npcId = args.get(1);
|
||||
|
||||
@@ -44,12 +51,17 @@ public class GiveCommand implements Command {
|
||||
}
|
||||
|
||||
NpcReaction reaction = reactionOpt.get();
|
||||
if (!Conditions.all(reaction.getRequires(), ctx)) {
|
||||
ctx.getIo().write(npc.getName() + " isn't ready for that yet.");
|
||||
return;
|
||||
}
|
||||
if (reaction.getConsumes() != null) {
|
||||
ctx.getPlayer().removeItem(reaction.getConsumes().getId());
|
||||
}
|
||||
if (reaction.getGives() != null) {
|
||||
ctx.getPlayer().addItem(reaction.getGives());
|
||||
}
|
||||
Effects.applyAll(reaction.getEffects(), ctx);
|
||||
ctx.getIo().write(npc.getName() + ": " + reaction.getResponse());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,128 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Conditions;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.MapLayout;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.game.Pathfinder;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.ExitLock;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Moves the player into the room reachable in a given direction.
|
||||
* Usage: {@code go <direction>}.
|
||||
* Moves the player. {@code go <direction>} steps once to a connected room.
|
||||
* {@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 {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Go where? Try 'go north'.");
|
||||
ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
|
||||
return;
|
||||
}
|
||||
Direction dir;
|
||||
try {
|
||||
dir = Direction.fromString(args.getFirst());
|
||||
} catch (IllegalArgumentException e) {
|
||||
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is.");
|
||||
return;
|
||||
Direction dir = Direction.fromString(args.getFirst());
|
||||
moveDirection(ctx, dir);
|
||||
} catch (IllegalArgumentException notADirection) {
|
||||
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()) {
|
||||
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||
return;
|
||||
}
|
||||
ExitLock lock = here.getExitLocks().get(dir);
|
||||
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||
ctx.getIo().write(lock.blocked());
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
ctx.getPlayer().setCurrentRoom(next.get());
|
||||
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
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Conditions;
|
||||
import thb.jeanluc.adventure.game.EscortEngine;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.io.text.RoomView;
|
||||
import thb.jeanluc.adventure.model.DescriptionState;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Prints a full description of the room the player is currently in:
|
||||
* name, description, visible items, visible NPCs, and available exits.
|
||||
* Usage: {@code look}.
|
||||
* Describes the current room by emitting a {@link RoomView}; the active
|
||||
* frontend decides how to render it. Usage: {@code look}.
|
||||
*/
|
||||
public class LookCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
Room room = ctx.getPlayer().getCurrentRoom();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("== ").append(room.getName()).append(" ==\n");
|
||||
sb.append(room.getDescription().stripTrailing());
|
||||
|
||||
if (!room.getItems().isEmpty()) {
|
||||
String items = room.getItems().values().stream()
|
||||
.map(Item::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append("\nYou see: ").append(items).append('.');
|
||||
if (!Light.isLit(ctx, room)) {
|
||||
ctx.getIo().write("It's pitch black; you can't make anything out. You need a light source.");
|
||||
return;
|
||||
}
|
||||
if (!room.getNpcs().isEmpty()) {
|
||||
String npcs = room.getNpcs().values().stream()
|
||||
.map(Npc::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append("\nHere is: ").append(npcs).append('.');
|
||||
List<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
||||
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
||||
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
||||
String description = room.getDescription();
|
||||
for (DescriptionState ds : room.getDescriptionStates()) {
|
||||
if (Conditions.all(ds.when(), ctx)) {
|
||||
description = ds.text();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (room.getExits().isEmpty()) {
|
||||
sb.append("\nThere are no obvious exits.");
|
||||
} else {
|
||||
String exits = room.getExits().keySet().stream()
|
||||
.map(Direction::getLabel)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append("\nExits: ").append(exits).append('.');
|
||||
String followers = EscortEngine.followerNote(ctx);
|
||||
if (followers != null) {
|
||||
description = description.stripTrailing() + "\n\n" + followers;
|
||||
}
|
||||
ctx.getIo().write(sb.toString());
|
||||
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.MapLayout;
|
||||
import thb.jeanluc.adventure.io.text.MapView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Shows the fog-of-war map of explored rooms. Usage: {@code map}. */
|
||||
public class MapCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
MapView view = MapLayout.compute(
|
||||
ctx.getWorld(),
|
||||
ctx.getPlayer().getVisitedRoomIds(),
|
||||
ctx.getPlayer().getCurrentRoom());
|
||||
ctx.getIo().showMap(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "map - show the map of explored rooms";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.save.SaveException;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Saves the active slot and returns to the main menu by stopping the loop.
|
||||
* Usage: {@code menu} (aliases {@code quit}, {@code exit}).
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class MenuCommand implements Command {
|
||||
|
||||
/** Persistence backend the active slot is written to before leaving. */
|
||||
private final SaveService saves;
|
||||
|
||||
/** Running game whose loop is stopped; null until {@link #bind(Game)} is called. */
|
||||
private Game game;
|
||||
|
||||
/**
|
||||
* Binds the running game (two-phase wiring, as the registry is built first).
|
||||
*
|
||||
* @param game the game loop to stop when this command runs
|
||||
*/
|
||||
public void bind(Game game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
try {
|
||||
saves.save(ctx.getSession());
|
||||
ctx.getIo().write("Game saved. Returning to the main menu.");
|
||||
} catch (SaveException e) {
|
||||
ctx.getIo().write("Could not save: " + e.getUserMessage());
|
||||
}
|
||||
if (game != null) {
|
||||
game.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "menu - save and return to the main menu (aliases: quit, exit)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.QuestEngine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Shows the active and completed quests. Usage: {@code quests}. */
|
||||
public class QuestsCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
ctx.getIo().showQuests(QuestEngine.viewOf(ctx));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "quests - show your active and completed quests (alias: log, journal)";
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ends the game loop. Holds a reference to the {@link Game} so it can
|
||||
* flip the loop flag. Usage: {@code quit}.
|
||||
*/
|
||||
public class QuitCommand implements Command {
|
||||
|
||||
/** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */
|
||||
private Game game;
|
||||
|
||||
/**
|
||||
* Binds the game instance after construction. Two-phase wiring is
|
||||
* necessary because the registry is typically built before the game
|
||||
* starts running.
|
||||
*
|
||||
* @param game the running game; must not be null
|
||||
*/
|
||||
public void bind(Game game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
ctx.getIo().write("Goodbye.");
|
||||
if (game != null) {
|
||||
game.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "quit - exit the game (alias: exit)";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.ReadableItem;
|
||||
|
||||
@@ -21,6 +22,11 @@ public class ReadCommand implements Command {
|
||||
ctx.getIo().write("Read what?");
|
||||
return;
|
||||
}
|
||||
// Reading needs light even for a carried item.
|
||||
if (!Light.canSeeRoom(ctx)) {
|
||||
ctx.getIo().write("It's too dark to read.");
|
||||
return;
|
||||
}
|
||||
String itemId = args.getFirst();
|
||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||
if (item.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.save.SaveException;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Manually saves the active slot. Usage: {@code save}. */
|
||||
@RequiredArgsConstructor
|
||||
public class SaveCommand implements Command {
|
||||
|
||||
/** Persistence backend the active slot is written to. */
|
||||
private final SaveService saves;
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
try {
|
||||
saves.save(ctx.getSession());
|
||||
ctx.getIo().write("Game saved.");
|
||||
} catch (SaveException e) {
|
||||
ctx.getIo().write(e.getUserMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "save - save your game to the current slot";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
@@ -20,15 +21,25 @@ public class TakeCommand implements Command {
|
||||
ctx.getIo().write("Take what?");
|
||||
return;
|
||||
}
|
||||
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||
ctx.getIo().write("It's too dark to see that.");
|
||||
return;
|
||||
}
|
||||
String itemId = args.getFirst();
|
||||
Room room = ctx.getPlayer().getCurrentRoom();
|
||||
Optional<Item> taken = room.removeItem(itemId);
|
||||
if (taken.isEmpty()) {
|
||||
Optional<Item> found = room.findItem(itemId);
|
||||
if (found.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + itemId + "' here.");
|
||||
return;
|
||||
}
|
||||
ctx.getPlayer().addItem(taken.get());
|
||||
ctx.getIo().write("You take the " + taken.get().getName() + ".");
|
||||
Item item = found.get();
|
||||
if (item.isFixed()) {
|
||||
ctx.getIo().write("The " + item.getName() + " is part of the room — you can't take it.");
|
||||
return;
|
||||
}
|
||||
room.removeItem(itemId);
|
||||
ctx.getPlayer().addItem(item);
|
||||
ctx.getIo().write("You take the " + item.getName() + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Conditions;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.model.DialogueLine;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
|
||||
import java.util.List;
|
||||
@@ -18,13 +21,25 @@ public class TalkCommand implements Command {
|
||||
ctx.getIo().write("Talk to whom?");
|
||||
return;
|
||||
}
|
||||
if (!Light.canSeeRoom(ctx)) {
|
||||
ctx.getIo().write("It's too dark to make out anyone here.");
|
||||
return;
|
||||
}
|
||||
String npcId = args.getFirst();
|
||||
Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
|
||||
if (npc.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + npcId + "' here to talk to.");
|
||||
return;
|
||||
}
|
||||
ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting());
|
||||
Npc found = npc.get();
|
||||
String line = found.getGreeting();
|
||||
for (DialogueLine dl : found.getDialogue()) {
|
||||
if (Conditions.all(dl.when(), ctx)) {
|
||||
line = dl.text();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.getIo().write(found.getName() + " says: " + line);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Combinations;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.Light;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Invokes the item-specific {@link Item#use(GameContext)} action. The
|
||||
* item must either be in the player's inventory or in the current room.
|
||||
* Usage: {@code use <item-id>}.
|
||||
* With one argument, invokes the item-specific {@link Item#use(GameContext)}
|
||||
* action; the item must be in the player's inventory or the current room.
|
||||
* 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 {
|
||||
|
||||
@@ -20,13 +23,21 @@ public class UseCommand implements Command {
|
||||
ctx.getIo().write("Use what?");
|
||||
return;
|
||||
}
|
||||
if (args.size() >= 2) {
|
||||
Combinations.tryUse(ctx, args.get(0), args.get(1));
|
||||
return;
|
||||
}
|
||||
String itemId = args.getFirst();
|
||||
// Inventory stays reachable in the dark (so a lamp can be relit);
|
||||
// room items may only be resolved when the room is lit.
|
||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||
if (item.isEmpty()) {
|
||||
if (item.isEmpty() && Light.canSeeRoom(ctx)) {
|
||||
item = ctx.getPlayer().getCurrentRoom().findItem(itemId);
|
||||
}
|
||||
if (item.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + itemId + "' here or in your inventory.");
|
||||
ctx.getIo().write(Light.canSeeRoom(ctx)
|
||||
? "There is no '" + itemId + "' here or in your inventory."
|
||||
: "It's too dark to see that.");
|
||||
return;
|
||||
}
|
||||
item.get().use(ctx);
|
||||
@@ -34,6 +45,6 @@ public class UseCommand implements Command {
|
||||
|
||||
@Override
|
||||
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.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import thb.jeanluc.adventure.model.Condition;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Evaluates {@link Condition}s against a {@link GameContext}. */
|
||||
public final class Conditions {
|
||||
|
||||
private Conditions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a single condition.
|
||||
*
|
||||
* @param c the condition to evaluate
|
||||
* @param ctx the active game context
|
||||
* @return true if the condition holds
|
||||
*/
|
||||
public static boolean holds(Condition c, GameContext ctx) {
|
||||
return switch (c.type()) {
|
||||
case FLAG -> ctx.getState().isSet(c.arg());
|
||||
case NOT_FLAG -> !ctx.getState().isSet(c.arg());
|
||||
case HAS_ITEM -> ctx.getPlayer().hasItem(c.arg());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff every condition holds. A null or empty list is vacuously true.
|
||||
*
|
||||
* @param conditions the conditions to evaluate; may be null
|
||||
* @param ctx the active game context
|
||||
* @return true if all conditions hold
|
||||
*/
|
||||
public static boolean all(List<Condition> conditions, GameContext ctx) {
|
||||
if (conditions == null) {
|
||||
return true;
|
||||
}
|
||||
for (Condition c : conditions) {
|
||||
if (!holds(c, ctx)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import thb.jeanluc.adventure.model.Effect;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Applies {@link Effect}s to a {@link GameContext}. */
|
||||
@Slf4j
|
||||
public final class Effects {
|
||||
|
||||
private Effects() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a single effect. An effect referencing an unknown item is logged
|
||||
* and skipped rather than failing the turn.
|
||||
*
|
||||
* @param e the effect to apply
|
||||
* @param ctx the active game context
|
||||
*/
|
||||
public static void apply(Effect e, GameContext ctx) {
|
||||
switch (e.type()) {
|
||||
case SET_FLAG -> ctx.getState().set(e.arg());
|
||||
case CLEAR_FLAG -> ctx.getState().clear(e.arg());
|
||||
case GIVE_ITEM -> {
|
||||
Item it = ctx.getWorld().getItems().get(e.arg());
|
||||
if (it == null) {
|
||||
log.warn("Effect GIVE_ITEM references unknown item '{}'", e.arg());
|
||||
} else {
|
||||
ctx.getPlayer().addItem(it);
|
||||
}
|
||||
}
|
||||
case REMOVE_ITEM -> ctx.getPlayer().removeItem(e.arg());
|
||||
case SAY -> ctx.getIo().write(e.arg());
|
||||
case START_QUEST -> ctx.getQuestLog().start(e.arg());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies each effect in order. A null list is a no-op.
|
||||
*
|
||||
* @param effects the effects to apply; may be null
|
||||
* @param ctx the active game context
|
||||
*/
|
||||
public static void applyAll(List<Effect> effects, GameContext ctx) {
|
||||
if (effects == null) {
|
||||
return;
|
||||
}
|
||||
for (Effect e : effects) {
|
||||
apply(e, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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.
|
||||
*
|
||||
* @param ctx the active game context
|
||||
* @return the triggered ending, or null if no ending's conditions hold
|
||||
*/
|
||||
public static Ending triggered(GameContext ctx) {
|
||||
for (Ending e : ctx.getWorld().getEndings()) {
|
||||
if (Conditions.all(e.when(), ctx)) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ending screen: title banner, text, and a run summary.
|
||||
*
|
||||
* @param e the triggered ending
|
||||
* @param ctx the active game context
|
||||
* @param turns number of turns the run took
|
||||
* @return the rendered end-of-game screen
|
||||
*/
|
||||
public static StyledText render(Ending e, GameContext ctx, int turns) {
|
||||
int total = ctx.getWorld().getQuests().size();
|
||||
int done = ctx.getQuestLog().completed().size();
|
||||
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();
|
||||
}
|
||||
|
||||
/** Rank line for the summary: derived from the ending's outcome and quest completeness. */
|
||||
private static String rank(Ending e, int total, int done) {
|
||||
if (e.victory() && total > 0 && done == total) {
|
||||
return "Master of the Manor";
|
||||
}
|
||||
if (e.victory()) {
|
||||
return "Manor Reclaimed";
|
||||
}
|
||||
return "Escaped — the manor keeps its secrets";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.command.CommandParser;
|
||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||
import thb.jeanluc.adventure.io.text.Hud;
|
||||
import thb.jeanluc.adventure.io.text.MapView;
|
||||
import thb.jeanluc.adventure.model.Ending;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -28,10 +31,27 @@ public class Game {
|
||||
/** Loop flag. Flipped to false by {@link #stop()}. */
|
||||
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.
|
||||
*/
|
||||
public void run() {
|
||||
publishHud();
|
||||
maybeEnd();
|
||||
if (tutorialGuide != null) {
|
||||
tutorialGuide.begin(ctx);
|
||||
}
|
||||
while (running) {
|
||||
String input = ctx.getIo().readLine();
|
||||
if (input == null) {
|
||||
@@ -41,15 +61,67 @@ public class Game {
|
||||
if (parsed.verb().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (tutorialGuide != null && tutorialGuide.isActive()
|
||||
&& parsed.verb().equals("skip")) {
|
||||
tutorialGuide.skip(ctx);
|
||||
publishHud();
|
||||
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());
|
||||
ctx.getSession().incrementTurn();
|
||||
if (ctx.getSession().getTurn() % 10 == 0) {
|
||||
ctx.save();
|
||||
}
|
||||
if (tutorialGuide != null && tutorialGuide.isActive()) {
|
||||
tutorialGuide.onCommand(ctx, parsed);
|
||||
}
|
||||
}
|
||||
publishHud();
|
||||
maybeEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends the game if any ending's conditions are met, printing its screen. */
|
||||
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);
|
||||
if (e != null) {
|
||||
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
// EscortEngine first: it sets flags (e.g. twins_reunited) that
|
||||
// QuestEngine's stage-completion conditions read in the same turn.
|
||||
EscortEngine.tick(ctx);
|
||||
QuestEngine.tick(ctx);
|
||||
ctx.getIo().setHud(new Hud(
|
||||
ctx.getPlayer().getCurrentRoom().getName(),
|
||||
ctx.getPlayer().getGold(),
|
||||
ctx.getSession().getTurn(),
|
||||
Light.carryingLight(ctx)));
|
||||
MapView map = MapLayout.compute(
|
||||
ctx.getWorld(),
|
||||
ctx.getPlayer().getVisitedRoomIds(),
|
||||
ctx.getPlayer().getCurrentRoom());
|
||||
ctx.getIo().setMap(map);
|
||||
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
|
||||
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals the loop to terminate after the current iteration.
|
||||
*/
|
||||
|
||||
@@ -1,26 +1,112 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
/**
|
||||
* Bundle of everything a command needs to do its work: the world, the
|
||||
* player, and the IO channel. Passing this single object keeps command
|
||||
* signatures small and consistent.
|
||||
* Bundle of everything a command needs: the active {@link GameSession}
|
||||
* (world, player, flags, quests, turn) and the IO channel. Getters delegate
|
||||
* to the session so existing command code keeps working unchanged.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class GameContext {
|
||||
|
||||
/** Loaded world (rooms, items, npcs). */
|
||||
private final World world;
|
||||
/** The mutable state of the current playthrough. */
|
||||
private final GameSession session;
|
||||
|
||||
/** The player avatar. */
|
||||
private final Player player;
|
||||
|
||||
/** IO channel for reading input and writing output. */
|
||||
/** Channel commands read input from and write output to. */
|
||||
private final GameIO io;
|
||||
|
||||
/** Silent autosave hook, wired by the app; no-op by default (tests). */
|
||||
private Runnable saveCallback = () -> { };
|
||||
|
||||
/**
|
||||
* Creates a context over an existing session.
|
||||
*
|
||||
* @param session the playthrough state
|
||||
* @param io the IO channel
|
||||
*/
|
||||
public GameContext(GameSession session, GameIO io) {
|
||||
this.session = session;
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for tests/legacy callers: wraps a fresh single-use session.
|
||||
*
|
||||
* @param world the loaded world
|
||||
* @param player the player character
|
||||
* @param io the IO channel
|
||||
*/
|
||||
public GameContext(World world, Player player, GameIO io) {
|
||||
this(new GameSession(world, player, "session"), io);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session this context was built over.
|
||||
*
|
||||
* @return the playthrough state backing this context
|
||||
*/
|
||||
public GameSession getSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates to the session.
|
||||
*
|
||||
* @return the loaded world
|
||||
*/
|
||||
public World getWorld() {
|
||||
return session.getWorld();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates to the session.
|
||||
*
|
||||
* @return the player character
|
||||
*/
|
||||
public Player getPlayer() {
|
||||
return session.getPlayer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates to the session.
|
||||
*
|
||||
* @return the world flags
|
||||
*/
|
||||
public GameState getState() {
|
||||
return session.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates to the session.
|
||||
*
|
||||
* @return the quest progress
|
||||
*/
|
||||
public QuestLog getQuestLog() {
|
||||
return session.getQuestLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the channel commands read from and write to.
|
||||
*
|
||||
* @return the IO channel
|
||||
*/
|
||||
public GameIO getIo() {
|
||||
return io;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the silent autosave hook (called by interval/quest autosave).
|
||||
*
|
||||
* @param saveCallback the hook to run on {@link #save()}
|
||||
*/
|
||||
public void setSaveCallback(Runnable saveCallback) {
|
||||
this.saveCallback = saveCallback;
|
||||
}
|
||||
|
||||
/** Runs the silent autosave hook. */
|
||||
public void save() {
|
||||
saveCallback.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.Getter;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
/**
|
||||
* Bundle of the mutable, savable game state for one playthrough: the
|
||||
* loaded {@link World}, the {@link Player}, world {@link GameState} flags,
|
||||
* {@link QuestLog} progress, the turn counter, and the bound save-slot name.
|
||||
* Save/load read and write exactly this object.
|
||||
*/
|
||||
@Getter
|
||||
public class GameSession {
|
||||
|
||||
/** The loaded world this playthrough runs on. */
|
||||
private final World world;
|
||||
|
||||
/** The player character, including inventory, position, and visited rooms. */
|
||||
private final Player player;
|
||||
|
||||
/** World flags set and cleared by effects and conditions. */
|
||||
private final GameState state = new GameState();
|
||||
|
||||
/** Quest progress: active stages and completed quests. */
|
||||
private final QuestLog questLog = new QuestLog();
|
||||
|
||||
/** Name of the save slot this session is bound to; save/load target. */
|
||||
private final String slotName;
|
||||
|
||||
/** Number of commands executed so far; shown in the HUD and the ending summary. */
|
||||
private int turn;
|
||||
|
||||
/**
|
||||
* Creates a session bound to a save slot.
|
||||
*
|
||||
* @param world the loaded world
|
||||
* @param player the player character
|
||||
* @param slotName the save slot this session reads from and writes to
|
||||
*/
|
||||
public GameSession(World world, Player player, String slotName) {
|
||||
this.world = world;
|
||||
this.player = player;
|
||||
this.slotName = slotName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites the turn counter (used when restoring a save).
|
||||
*
|
||||
* @param turn the turn count to restore
|
||||
*/
|
||||
public void setTurn(int turn) {
|
||||
this.turn = turn;
|
||||
}
|
||||
|
||||
/** Advances the turn counter by one. */
|
||||
public void incrementTurn() {
|
||||
this.turn++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** Named world flags. A flag is "set" (true) when present in the set. */
|
||||
public class GameState {
|
||||
|
||||
/** Ids of the currently set flags. Insertion-ordered for stable saves. */
|
||||
private final Set<String> flags = new LinkedHashSet<>();
|
||||
|
||||
/**
|
||||
* Tests whether a flag is set.
|
||||
*
|
||||
* @param flag the flag id
|
||||
* @return true if the flag is set
|
||||
*/
|
||||
public boolean isSet(String flag) {
|
||||
return flags.contains(flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a flag. Setting an already-set flag has no effect.
|
||||
*
|
||||
* @param flag the flag id
|
||||
*/
|
||||
public void set(String flag) {
|
||||
flags.add(flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a flag. Clearing an unset flag has no effect.
|
||||
*
|
||||
* @param flag the flag id
|
||||
*/
|
||||
public void clear(String flag) {
|
||||
flags.remove(flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* All currently set flags.
|
||||
*
|
||||
* @return an unmodifiable view of the set flags
|
||||
*/
|
||||
public Set<String> all() {
|
||||
return Collections.unmodifiableSet(flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all flags with the given collection (used by load).
|
||||
*
|
||||
* @param newFlags the flags to restore
|
||||
*/
|
||||
public void restore(Collection<String> newFlags) {
|
||||
flags.clear();
|
||||
flags.addAll(newFlags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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.
|
||||
*
|
||||
* @param ctx the active game context
|
||||
* @param room the room to test
|
||||
* @return true if the room is lit
|
||||
*/
|
||||
public static boolean isLit(GameContext ctx, Room room) {
|
||||
if (!room.isDark()) {
|
||||
return true;
|
||||
}
|
||||
return hasActiveLight(ctx.getPlayer().getInventory().values())
|
||||
|| hasActiveLight(room.getItems().values());
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the player carries an active light source (for the HUD).
|
||||
*
|
||||
* @param ctx the active game context
|
||||
* @return true if an active light source is in the inventory
|
||||
*/
|
||||
public static boolean carryingLight(GameContext ctx) {
|
||||
return hasActiveLight(ctx.getPlayer().getInventory().values());
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the player can see the contents of the room they stand in.
|
||||
* Room items and NPCs may only be resolved when this holds; inventory
|
||||
* items stay reachable in the dark, so a lamp can always be switched
|
||||
* back on.
|
||||
*
|
||||
* @param ctx the active game context
|
||||
* @return true if the player's current room is lit
|
||||
*/
|
||||
public static boolean canSeeRoom(GameContext ctx) {
|
||||
return isLit(ctx, ctx.getPlayer().getCurrentRoom());
|
||||
}
|
||||
|
||||
/** Whether any of the given items is an active light source. */
|
||||
private static boolean hasActiveLight(Collection<Item> items) {
|
||||
for (Item it : items) {
|
||||
if (isActiveLight(it)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A light-emitting item counts only while switched on; non-switchable ones always count. */
|
||||
private static boolean isActiveLight(Item it) {
|
||||
if (!it.isLight()) {
|
||||
return false;
|
||||
}
|
||||
return !(it instanceof SwitchableItem s) || s.isOn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import thb.jeanluc.adventure.io.text.CellState;
|
||||
import thb.jeanluc.adventure.io.text.Connection;
|
||||
import thb.jeanluc.adventure.io.text.MapView;
|
||||
import thb.jeanluc.adventure.io.text.RoomCell;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Computes a {@link MapView} from the room graph: BFS assigns grid coordinates
|
||||
* from exit directions, then fog-of-war keeps only visited rooms and their
|
||||
* directly-reachable (but unentered) neighbours.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class MapLayout {
|
||||
|
||||
private MapLayout() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the map view for the current fog-of-war state. Visited rooms are
|
||||
* shown by name, their unentered neighbours as unknown cells; rooms beyond
|
||||
* those are omitted.
|
||||
*
|
||||
* @param world the loaded world
|
||||
* @param visited ids of the rooms the player has entered
|
||||
* @param current the room the player stands in
|
||||
* @return the map view; empty if nothing has been visited yet
|
||||
*/
|
||||
public static MapView compute(World world, Set<String> visited, Room current) {
|
||||
if (visited.isEmpty()) {
|
||||
return new MapView(List.of(), List.of());
|
||||
}
|
||||
String anchorId = visited.iterator().next();
|
||||
Room anchor = world.getRooms().get(anchorId);
|
||||
if (anchor == null) {
|
||||
return new MapView(List.of(), List.of());
|
||||
}
|
||||
|
||||
Map<String, int[]> coords = bfs(anchor);
|
||||
|
||||
// Determine which rooms are placed: visited (with coords) + their unentered neighbours.
|
||||
Map<String, CellState> placed = new LinkedHashMap<>();
|
||||
for (String id : visited) {
|
||||
if (coords.containsKey(id)) {
|
||||
placed.put(id, id.equals(current.getId()) ? CellState.CURRENT : CellState.VISITED);
|
||||
}
|
||||
}
|
||||
for (String id : visited) {
|
||||
Room r = world.getRooms().get(id);
|
||||
if (r == null) {
|
||||
continue;
|
||||
}
|
||||
for (Room nb : r.getExits().values()) {
|
||||
if (!placed.containsKey(nb.getId()) && coords.containsKey(nb.getId())) {
|
||||
placed.put(nb.getId(), CellState.KNOWN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalise coordinates to non-negative.
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
for (String id : placed.keySet()) {
|
||||
int[] c = coords.get(id);
|
||||
minX = Math.min(minX, c[0]);
|
||||
minY = Math.min(minY, c[1]);
|
||||
}
|
||||
|
||||
Map<String, RoomCell> cells = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, CellState> e : placed.entrySet()) {
|
||||
int[] c = coords.get(e.getKey());
|
||||
String name = e.getValue() == CellState.KNOWN ? "?" : world.getRooms().get(e.getKey()).getName();
|
||||
cells.put(e.getKey(), new RoomCell(e.getKey(), name, c[0] - minX, c[1] - minY, e.getValue()));
|
||||
}
|
||||
|
||||
// Connections: from each visited room to any placed neighbour, de-duplicated.
|
||||
List<Connection> connections = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (String id : visited) {
|
||||
Room r = world.getRooms().get(id);
|
||||
RoomCell from = cells.get(id);
|
||||
if (r == null || from == null) {
|
||||
continue;
|
||||
}
|
||||
for (Room nb : r.getExits().values()) {
|
||||
RoomCell to = cells.get(nb.getId());
|
||||
if (to == null) {
|
||||
continue;
|
||||
}
|
||||
String key = id.compareTo(nb.getId()) < 0 ? id + "|" + nb.getId() : nb.getId() + "|" + id;
|
||||
if (seen.add(key)) {
|
||||
connections.add(new Connection(from, to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new MapView(List.copyOf(cells.values()), connections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns grid coordinates to every room reachable from the anchor, which
|
||||
* sits at the origin. A room reached twice at conflicting coordinates keeps
|
||||
* its first placement; the world geometry is then not planar and the
|
||||
* collision is logged.
|
||||
*/
|
||||
private static Map<String, int[]> bfs(Room anchor) {
|
||||
Map<String, int[]> coords = new HashMap<>();
|
||||
Deque<Room> queue = new ArrayDeque<>();
|
||||
coords.put(anchor.getId(), new int[]{0, 0});
|
||||
queue.add(anchor);
|
||||
while (!queue.isEmpty()) {
|
||||
Room r = queue.poll();
|
||||
int[] c = coords.get(r.getId());
|
||||
for (Map.Entry<Direction, Room> e : r.getExits().entrySet()) {
|
||||
Room nb = e.getValue();
|
||||
int[] d = delta(e.getKey());
|
||||
int[] nc = {c[0] + d[0], c[1] + d[1]};
|
||||
int[] existing = coords.get(nb.getId());
|
||||
if (existing == null) {
|
||||
coords.put(nb.getId(), nc);
|
||||
queue.add(nb);
|
||||
} else if (existing[0] != nc[0] || existing[1] != nc[1]) {
|
||||
log.warn("Map layout collision at room '{}' ({} vs {},{}); keeping first placement",
|
||||
nb.getId(), java.util.Arrays.toString(existing), nc[0], nc[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
/** Grid step for a direction as {@code {dx, dy}}; y grows southwards. */
|
||||
private static int[] delta(Direction d) {
|
||||
return switch (d) {
|
||||
case NORTH -> new int[]{0, -1};
|
||||
case SOUTH -> new int[]{0, 1};
|
||||
case EAST -> new int[]{1, 0};
|
||||
case WEST -> new int[]{-1, 0};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import thb.jeanluc.adventure.io.text.QuestEntry;
|
||||
import thb.jeanluc.adventure.io.text.QuestView;
|
||||
import thb.jeanluc.adventure.io.text.StyledText;
|
||||
import thb.jeanluc.adventure.model.Quest;
|
||||
import thb.jeanluc.adventure.model.QuestStage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Per-turn quest progression and view building, driven by world conditions. */
|
||||
public final class QuestEngine {
|
||||
|
||||
private QuestEngine() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn quest progression: starts every auto-start quest that is neither
|
||||
* active nor completed, then repeatedly advances active quests whose current
|
||||
* stage's completion conditions hold, announcing each finished objective and
|
||||
* quest. Repeats until nothing changes, so a stage whose conditions are met
|
||||
* by another stage's effects still resolves in the same turn.
|
||||
*
|
||||
* @param ctx the active game context
|
||||
*/
|
||||
public static void tick(GameContext ctx) {
|
||||
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
||||
for (Quest q : quests.values()) {
|
||||
if (q.autoStart() && !ctx.getQuestLog().isActive(q.id()) && !ctx.getQuestLog().isCompleted(q.id())) {
|
||||
ctx.getQuestLog().start(q.id());
|
||||
}
|
||||
}
|
||||
int guard = 0;
|
||||
int max = totalStages(quests) + quests.size() + 1;
|
||||
boolean changed = true;
|
||||
while (changed && guard++ < max) {
|
||||
changed = false;
|
||||
for (String id : new ArrayList<>(ctx.getQuestLog().active())) {
|
||||
Quest q = quests.get(id);
|
||||
if (q == null) {
|
||||
continue;
|
||||
}
|
||||
int idx = ctx.getQuestLog().stageIndex(id);
|
||||
if (idx >= q.stages().size()) {
|
||||
finish(ctx, q);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
QuestStage stage = q.stages().get(idx);
|
||||
if (Conditions.all(stage.completion(), ctx)) {
|
||||
Effects.applyAll(stage.onComplete(), ctx);
|
||||
ctx.getIo().print(StyledText.builder()
|
||||
.heading("✓ Objective complete: ").plain(stage.objective()).build());
|
||||
ctx.getQuestLog().advance(id);
|
||||
if (ctx.getQuestLog().stageIndex(id) >= q.stages().size()) {
|
||||
finish(ctx, q);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a quest's completion effects, marks it done, announces it, and autosaves. */
|
||||
private static void finish(GameContext ctx, Quest q) {
|
||||
if (ctx.getQuestLog().isCompleted(q.id())) {
|
||||
return;
|
||||
}
|
||||
Effects.applyAll(q.onComplete(), ctx);
|
||||
ctx.getQuestLog().complete(q.id());
|
||||
ctx.getIo().print(StyledText.builder()
|
||||
.heading("★ Quest complete: ").plain(q.title()).build());
|
||||
ctx.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the quest panel view: each active quest with its current objective,
|
||||
* plus the titles of the completed quests.
|
||||
*
|
||||
* @param ctx the active game context
|
||||
* @return the current quest view
|
||||
*/
|
||||
public static QuestView viewOf(GameContext ctx) {
|
||||
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
||||
List<QuestEntry> active = new ArrayList<>();
|
||||
for (String id : ctx.getQuestLog().active()) {
|
||||
Quest q = quests.get(id);
|
||||
if (q == null) {
|
||||
continue;
|
||||
}
|
||||
int idx = ctx.getQuestLog().stageIndex(id);
|
||||
String objective = idx < q.stages().size() ? q.stages().get(idx).objective() : "";
|
||||
active.add(new QuestEntry(q.title(), objective));
|
||||
}
|
||||
List<String> completed = new ArrayList<>();
|
||||
for (String id : ctx.getQuestLog().completed()) {
|
||||
Quest q = quests.get(id);
|
||||
completed.add(q == null ? id : q.title());
|
||||
}
|
||||
return new QuestView(active, completed);
|
||||
}
|
||||
|
||||
/** Stage count across all quests; bounds the iteration guard in {@link #tick}. */
|
||||
private static int totalStages(Map<String, Quest> quests) {
|
||||
int total = 0;
|
||||
for (Quest q : quests.values()) {
|
||||
total += q.stages().size();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Runtime quest progress: current stage per active quest, plus completed ids. */
|
||||
public class QuestLog {
|
||||
|
||||
/** Index of the current stage per active quest id. Insertion-ordered for stable saves. */
|
||||
private final Map<String, Integer> stageIndex = new LinkedHashMap<>();
|
||||
|
||||
/** Ids of quests that have been completed. Insertion-ordered for stable saves. */
|
||||
private final Set<String> completed = new LinkedHashSet<>();
|
||||
|
||||
/**
|
||||
* Activates a quest at its first stage. Already active or completed quests
|
||||
* are left untouched.
|
||||
*
|
||||
* @param id the quest id
|
||||
*/
|
||||
public void start(String id) {
|
||||
if (!completed.contains(id)) {
|
||||
stageIndex.putIfAbsent(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether a quest is currently active.
|
||||
*
|
||||
* @param id the quest id
|
||||
* @return true if the quest is active
|
||||
*/
|
||||
public boolean isActive(String id) {
|
||||
return stageIndex.containsKey(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether a quest has been completed.
|
||||
*
|
||||
* @param id the quest id
|
||||
* @return true if the quest is completed
|
||||
*/
|
||||
public boolean isCompleted(String id) {
|
||||
return completed.contains(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current stage index of a quest.
|
||||
*
|
||||
* @param id the quest id
|
||||
* @return the stage index, or 0 if the quest is not active
|
||||
*/
|
||||
public int stageIndex(String id) {
|
||||
return stageIndex.getOrDefault(id, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a quest on to its next stage.
|
||||
*
|
||||
* @param id the quest id
|
||||
*/
|
||||
public void advance(String id) {
|
||||
stageIndex.merge(id, 1, Integer::sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a quest completed and removes it from the active set.
|
||||
*
|
||||
* @param id the quest id
|
||||
*/
|
||||
public void complete(String id) {
|
||||
stageIndex.remove(id);
|
||||
completed.add(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of all currently active quests.
|
||||
*
|
||||
* @return an unmodifiable view of the active quest ids
|
||||
*/
|
||||
public Set<String> active() {
|
||||
return Collections.unmodifiableSet(stageIndex.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of all completed quests.
|
||||
*
|
||||
* @return an unmodifiable view of the completed quest ids
|
||||
*/
|
||||
public Set<String> completed() {
|
||||
return Collections.unmodifiableSet(completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all progress with the given stage map and completed set (load).
|
||||
*
|
||||
* @param stages stage index per active quest id
|
||||
* @param completedIds ids of the completed quests
|
||||
*/
|
||||
public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
|
||||
stageIndex.clear();
|
||||
stageIndex.putAll(stages);
|
||||
completed.clear();
|
||||
completed.addAll(completedIds);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,119 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import thb.jeanluc.adventure.io.text.AsciiMap;
|
||||
import thb.jeanluc.adventure.io.text.Hud;
|
||||
import thb.jeanluc.adventure.io.text.MapView;
|
||||
import thb.jeanluc.adventure.io.text.RoomView;
|
||||
import thb.jeanluc.adventure.io.text.Span;
|
||||
import thb.jeanluc.adventure.io.text.Style;
|
||||
import thb.jeanluc.adventure.io.text.StyledText;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link GameIO} backed by {@code System.in} and {@code System.out}.
|
||||
* Each {@code write} appends a newline so output blocks are visually
|
||||
* separated. {@code readLine} prints a {@code > } prompt before
|
||||
* blocking.
|
||||
* {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
|
||||
* (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
|
||||
* and degrade safely (no colours when output is piped; ASCII frames on request).
|
||||
*/
|
||||
public class ConsoleIO implements GameIO {
|
||||
|
||||
/** Input source. */
|
||||
/** Whether ANSI colour is emitted. */
|
||||
public enum ColorMode {
|
||||
|
||||
/** Always emit ANSI escape codes. */
|
||||
ON,
|
||||
|
||||
/** Never emit ANSI escape codes; plain text only. */
|
||||
OFF,
|
||||
|
||||
/** Emit colour only when attached to a real console, not when piped. */
|
||||
AUTO
|
||||
}
|
||||
|
||||
/** Frame/glyph fidelity tier. */
|
||||
public enum GlyphMode {
|
||||
|
||||
/** Frames drawn with {@code +} and {@code -}, for terminals without Unicode. */
|
||||
ASCII,
|
||||
|
||||
/** Frames drawn with Unicode box-drawing characters. */
|
||||
UNICODE,
|
||||
|
||||
/** Highest tier: Unicode frames plus decorative glyphs. */
|
||||
GLYPH
|
||||
}
|
||||
|
||||
/** Total width of a room frame, in characters. */
|
||||
private static final int FRAME_WIDTH = 44;
|
||||
|
||||
/** Source the player's commands are read from. */
|
||||
private final BufferedReader in;
|
||||
|
||||
/** Output sink. */
|
||||
/** Sink all output is written to. */
|
||||
private final PrintStream out;
|
||||
|
||||
/**
|
||||
* Creates a console IO using standard input and output.
|
||||
*/
|
||||
/** Whether ANSI colour codes are currently emitted; resolved from a {@link ColorMode}. */
|
||||
private boolean useColor;
|
||||
|
||||
/** Frame/glyph fidelity tier currently in use. */
|
||||
private GlyphMode glyphs;
|
||||
|
||||
/** Reads from {@code System.in} and writes to {@code System.out}, colour auto-detected. */
|
||||
public ConsoleIO() {
|
||||
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out);
|
||||
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
|
||||
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-friendly constructor allowing custom streams.
|
||||
* Backward-compatible stream constructor (colour off, unicode frames).
|
||||
*
|
||||
* @param in reader to consume player input
|
||||
* @param out stream to write player output
|
||||
* @param in source of the player's commands
|
||||
* @param out sink for all output
|
||||
*/
|
||||
public ConsoleIO(BufferedReader in, PrintStream out) {
|
||||
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a console IO bound to the given streams and fidelity settings.
|
||||
*
|
||||
* @param in source of the player's commands
|
||||
* @param out sink for all output
|
||||
* @param colorMode whether ANSI colour is emitted
|
||||
* @param glyphs frame/glyph fidelity tier
|
||||
*/
|
||||
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
this.glyphs = glyphs;
|
||||
setColorMode(colorMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolves colour emission from the given mode (AUTO checks the console).
|
||||
*
|
||||
* @param mode the colour mode to apply
|
||||
*/
|
||||
public void setColorMode(ColorMode mode) {
|
||||
this.useColor = switch (mode) {
|
||||
case ON -> true;
|
||||
case OFF -> false;
|
||||
case AUTO -> System.console() != null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the frame/glyph fidelity tier live.
|
||||
*
|
||||
* @param mode the tier to apply
|
||||
*/
|
||||
public void setGlyphMode(GlyphMode mode) {
|
||||
this.glyphs = mode;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -43,15 +121,122 @@ public class ConsoleIO implements GameIO {
|
||||
out.print("> ");
|
||||
out.flush();
|
||||
try {
|
||||
String line = in.readLine();
|
||||
return line == null ? "" : line;
|
||||
// null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts).
|
||||
return in.readLine();
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
// Treat a stream failure as EOF so input ends cleanly rather than spinning.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String s) {
|
||||
out.println(s);
|
||||
public void print(StyledText text) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Span s : text.spans()) {
|
||||
sb.append(paint(s.style(), s.text()));
|
||||
}
|
||||
out.println(sb);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showRoom(RoomView room) {
|
||||
out.println(paint(Style.HEADING, frameTop(room.name())));
|
||||
out.println(room.description().stripTrailing());
|
||||
if (!room.items().isEmpty()) {
|
||||
out.println(section("Items", room.items(), Style.ITEM));
|
||||
}
|
||||
if (!room.npcs().isEmpty()) {
|
||||
out.println(section("People", room.npcs(), Style.NPC));
|
||||
}
|
||||
if (!room.exits().isEmpty()) {
|
||||
out.println(section("Exits", room.exits(), Style.EXIT));
|
||||
} else {
|
||||
out.println(" " + paint(Style.DIM, "no obvious exits"));
|
||||
}
|
||||
out.println(paint(Style.HEADING, frameBottom()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setHud(Hud h) {
|
||||
String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn()
|
||||
+ " · light: " + (h.lightOn() ? "on" : "off") + " ]";
|
||||
out.println(paint(Style.DIM, line));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showMap(MapView view) {
|
||||
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one indented, labelled line listing the given entries.
|
||||
*
|
||||
* @param label dim heading in front of the entries
|
||||
* @param xs the entries
|
||||
* @param st style applied to each entry
|
||||
* @return the rendered line
|
||||
*/
|
||||
private String section(String label, List<String> xs, Style st) {
|
||||
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
|
||||
for (int i = 0; i < xs.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(" · ");
|
||||
}
|
||||
sb.append(paint(st, xs.get(i)));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param title room name embedded in the top border
|
||||
* @return the top border of a room frame, {@link #FRAME_WIDTH} characters wide
|
||||
*/
|
||||
private String frameTop(String title) {
|
||||
String tl = glyphs == GlyphMode.ASCII ? "+" : "┌";
|
||||
String tr = glyphs == GlyphMode.ASCII ? "+" : "┐";
|
||||
String h = glyphs == GlyphMode.ASCII ? "-" : "─";
|
||||
StringBuilder sb = new StringBuilder(tl).append(h).append(' ')
|
||||
.append(title.toUpperCase()).append(' ');
|
||||
while (sb.length() < FRAME_WIDTH - 1) {
|
||||
sb.append(h);
|
||||
}
|
||||
return sb.append(tr).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the bottom border of a room frame, {@link #FRAME_WIDTH} characters wide
|
||||
*/
|
||||
private String frameBottom() {
|
||||
String bl = glyphs == GlyphMode.ASCII ? "+" : "└";
|
||||
String br = glyphs == GlyphMode.ASCII ? "+" : "┘";
|
||||
String h = glyphs == GlyphMode.ASCII ? "-" : "─";
|
||||
StringBuilder sb = new StringBuilder(bl);
|
||||
while (sb.length() < FRAME_WIDTH - 1) {
|
||||
sb.append(h);
|
||||
}
|
||||
return sb.append(br).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the text in the ANSI escape codes of the given role.
|
||||
*
|
||||
* @param st the semantic role
|
||||
* @param s the text
|
||||
* @return the text unchanged when colour is disabled, else the coloured text
|
||||
*/
|
||||
private String paint(Style st, String s) {
|
||||
if (!useColor) {
|
||||
return s;
|
||||
}
|
||||
String code = switch (st) {
|
||||
case HEADING -> "[1;35m";
|
||||
case ITEM -> "[36m";
|
||||
case NPC -> "[33m";
|
||||
case EXIT -> "[32m";
|
||||
case DANGER -> "[31m";
|
||||
case DIM -> "[90m";
|
||||
case PLAIN -> "";
|
||||
};
|
||||
return code.isEmpty() ? s : code + s + "[0m";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,170 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import thb.jeanluc.adventure.io.text.AsciiMap;
|
||||
import thb.jeanluc.adventure.io.text.Hud;
|
||||
import thb.jeanluc.adventure.io.text.MapView;
|
||||
import thb.jeanluc.adventure.io.text.QuestText;
|
||||
import thb.jeanluc.adventure.io.text.QuestView;
|
||||
import thb.jeanluc.adventure.io.text.Renderings;
|
||||
import thb.jeanluc.adventure.io.text.RoomView;
|
||||
import thb.jeanluc.adventure.io.text.StyledText;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bidirectional text channel between the engine and the player. Implemented
|
||||
* by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic
|
||||
* to the actual interface.
|
||||
* Bidirectional channel between the engine and the player. Commands emit
|
||||
* semantic, role-styled output via {@link #print(StyledText)} (the primitive)
|
||||
* and the {@link #showRoom}/{@link #setHud} views; each frontend renders them.
|
||||
*/
|
||||
public interface GameIO {
|
||||
|
||||
/**
|
||||
* Blocks until a line of input is available.
|
||||
* Blocks until a line of input is available; empty string at end of input.
|
||||
*
|
||||
* @return the next user input line, or an empty string at end of input
|
||||
* @return the line the player entered
|
||||
*/
|
||||
String readLine();
|
||||
|
||||
/**
|
||||
* Writes a message to the player. Each call corresponds to one logical
|
||||
* output block; the implementation handles line breaks at the boundary.
|
||||
* Renders one styled output block. The only method implementors must provide.
|
||||
*
|
||||
* @param s message text; must not be null
|
||||
* @param text the block to render
|
||||
*/
|
||||
void write(String s);
|
||||
void print(StyledText text);
|
||||
|
||||
/**
|
||||
* Convenience: a plain, unstyled line.
|
||||
*
|
||||
* @param s the text to write
|
||||
*/
|
||||
default void write(String s) {
|
||||
print(StyledText.of(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a room. Default flattens via {@link Renderings}; renderers may override.
|
||||
*
|
||||
* @param room the room snapshot to render
|
||||
*/
|
||||
default void showRoom(RoomView room) {
|
||||
print(Renderings.roomToStyledText(room));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status/HUD region. No-op by default; renderers may override.
|
||||
*
|
||||
* @param hud the status snapshot
|
||||
*/
|
||||
default void setHud(Hud hud) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn push of the current map. GUI repaints its panel; console ignores.
|
||||
*
|
||||
* @param view the map snapshot
|
||||
*/
|
||||
default void setMap(MapView view) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
print(AsciiMap.render(view, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn push of the quest log. GUI repaints its box; console ignores.
|
||||
*
|
||||
* @param view the quest snapshot
|
||||
*/
|
||||
default void setQuests(QuestView view) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays.
|
||||
*
|
||||
* @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) {
|
||||
print(QuestText.render(view));
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents a titled list of options and returns the 0-based index of the
|
||||
* player's choice. Default (console) prints a numbered list and reads a
|
||||
* number, re-prompting on invalid input. EOF / empty input returns the
|
||||
* LAST option (menus place the safe/cancel option last). Frontends with
|
||||
* native widgets (e.g. buttons) may override.
|
||||
*
|
||||
* @param title heading shown above the options
|
||||
* @param options non-empty list of option labels
|
||||
* @return 0-based index into {@code options}
|
||||
*/
|
||||
default int choose(String title, List<String> options) {
|
||||
StyledText.Builder b = StyledText.builder().heading(title).plain("\n");
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
b.plain(" " + (i + 1) + ") " + options.get(i) + "\n");
|
||||
}
|
||||
print(b.build());
|
||||
while (true) {
|
||||
String line = readLine();
|
||||
if (line == null || line.isBlank()) {
|
||||
return options.size() - 1;
|
||||
}
|
||||
try {
|
||||
int n = Integer.parseInt(line.trim());
|
||||
if (n >= 1 && n <= options.size()) {
|
||||
return n - 1;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// fall through to re-prompt
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import thb.jeanluc.adventure.io.text.CellState;
|
||||
import thb.jeanluc.adventure.io.text.Connection;
|
||||
import thb.jeanluc.adventure.io.text.MapView;
|
||||
import thb.jeanluc.adventure.io.text.RoomCell;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JViewport;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Container;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Insets;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.Stroke;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Draws a {@link MapView} as outlined rooms linked by corridors (Graphics2D).
|
||||
*
|
||||
* <p>The map is scaled to <em>fit</em> the side panel, so it never needs
|
||||
* scrollbars at normal zoom and re-fits whenever the window (and thus the
|
||||
* panel) is resized. The {@link #setZoom(float) zoom} from {@code Ctrl +/-}
|
||||
* multiplies on top of the fit scale (zoom > 1 enlarges and may scroll).</p>
|
||||
*/
|
||||
public class MapPanel extends JComponent {
|
||||
|
||||
/** Unscaled room width, in pixels. */
|
||||
private static final int BASE_CELL_W = 96;
|
||||
|
||||
/** Unscaled room height, in pixels. */
|
||||
private static final int BASE_CELL_H = 46;
|
||||
|
||||
/** Unscaled horizontal gap between two rooms, in pixels. */
|
||||
private static final int BASE_GAP_X = 34;
|
||||
|
||||
/** Unscaled vertical gap between two rooms, in pixels. */
|
||||
private static final int BASE_GAP_Y = 30;
|
||||
|
||||
/** Unscaled padding around the whole map, in pixels. */
|
||||
private static final int BASE_PAD = 16;
|
||||
|
||||
/** Unscaled size of the room-label font, in points. */
|
||||
private static final float BASE_FONT = 11f;
|
||||
|
||||
/** Panel background. */
|
||||
private static final Color BG = new Color(0x0e, 0x12, 0x18);
|
||||
|
||||
/** Line colour of a corridor between two visited rooms. */
|
||||
private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a);
|
||||
|
||||
/** Outline colour of a {@link CellState#VISITED} room. */
|
||||
private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0);
|
||||
|
||||
/** Outline colour of the {@link CellState#CURRENT} room. */
|
||||
private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a);
|
||||
|
||||
/** Outline colour of a {@link CellState#KNOWN} but unvisited room. */
|
||||
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
|
||||
|
||||
/** Font the room labels are derived from. */
|
||||
private final Font baseFont;
|
||||
|
||||
/** User zoom multiplier, applied on top of the fit-to-panel scale. */
|
||||
private float zoom = 1f;
|
||||
|
||||
/** The map currently drawn; empty until the first {@link #show(MapView)}. */
|
||||
private MapView view = new MapView(List.of(), List.of());
|
||||
|
||||
/**
|
||||
* Creates an empty map panel; a map appears with the first {@link #show(MapView)}.
|
||||
*
|
||||
* @param font font the room labels are derived from
|
||||
*/
|
||||
public MapPanel(Font font) {
|
||||
this.baseFont = font;
|
||||
setBackground(BG);
|
||||
setOpaque(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user zoom multiplier (applied on top of the fit-to-panel scale).
|
||||
*
|
||||
* @param zoom the multiplier; clamped to a minimum of 0.4
|
||||
*/
|
||||
public void setZoom(float zoom) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
this.zoom = Math.max(0.4f, zoom);
|
||||
revalidate();
|
||||
repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the rendered map.
|
||||
*
|
||||
* @param v the new map snapshot
|
||||
*/
|
||||
public void show(MapView v) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
this.view = v;
|
||||
revalidate();
|
||||
repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The space the map should fit into: the enclosing scroll pane's viewport area.
|
||||
*
|
||||
* @return the viewport area, this panel's own size, or a fixed fallback
|
||||
*/
|
||||
private Dimension available() {
|
||||
Container p = getParent();
|
||||
if (p instanceof JViewport && p.getParent() instanceof JScrollPane sp) {
|
||||
Insets in = sp.getInsets();
|
||||
int w = sp.getWidth() - in.left - in.right;
|
||||
int h = sp.getHeight() - in.top - in.bottom;
|
||||
if (w > 0 && h > 0) {
|
||||
return new Dimension(w, h);
|
||||
}
|
||||
}
|
||||
Dimension s = getSize();
|
||||
return (s.width > 0 && s.height > 0) ? s : new Dimension(220, 320);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the highest occupied column and row of the map grid, as
|
||||
* {@code [maxX, maxY]}
|
||||
*/
|
||||
private int[] gridBounds() {
|
||||
int maxX = 0;
|
||||
int maxY = 0;
|
||||
for (RoomCell c : view.cells()) {
|
||||
maxX = Math.max(maxX, c.x());
|
||||
maxY = Math.max(maxY, c.y());
|
||||
}
|
||||
return new int[]{maxX, maxY};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit-to-panel scale times the user zoom.
|
||||
*
|
||||
* @param avail the space available to the map
|
||||
* @param b grid bounds as returned by {@link #gridBounds()}
|
||||
* @return the effective drawing scale
|
||||
*/
|
||||
private float effScale(Dimension avail, int[] b) {
|
||||
double natW = 2.0 * BASE_PAD + (b[0] + 1) * BASE_CELL_W + b[0] * BASE_GAP_X;
|
||||
double natH = 2.0 * BASE_PAD + (b[1] + 1) * BASE_CELL_H + b[1] * BASE_GAP_Y;
|
||||
if (natW <= 0 || natH <= 0) {
|
||||
return zoom;
|
||||
}
|
||||
double fit = Math.min(avail.width / natW, avail.height / natH);
|
||||
fit = Math.max(0.3, Math.min(3.0, fit));
|
||||
return (float) (fit * zoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param eff the effective drawing scale
|
||||
* @param b grid bounds as returned by {@link #gridBounds()}
|
||||
* @return the size of the drawn map at that scale, including padding
|
||||
*/
|
||||
private Dimension content(float eff, int[] b) {
|
||||
int pad = Math.round(BASE_PAD * eff);
|
||||
int cw = Math.round(BASE_CELL_W * eff);
|
||||
int ch = Math.round(BASE_CELL_H * eff);
|
||||
int gx = Math.round(BASE_GAP_X * eff);
|
||||
int gy = Math.round(BASE_GAP_Y * eff);
|
||||
return new Dimension(pad * 2 + (b[0] + 1) * cw + b[0] * gx,
|
||||
pad * 2 + (b[1] + 1) * ch + b[1] * gy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize() {
|
||||
if (view.cells().isEmpty()) {
|
||||
return available();
|
||||
}
|
||||
Dimension avail = available();
|
||||
int[] b = gridBounds();
|
||||
Dimension c = content(effScale(avail, b), b);
|
||||
// Fill the viewport when the map is smaller; allow growth (scroll) when zoomed in.
|
||||
return new Dimension(Math.max(c.width, avail.width), Math.max(c.height, avail.height));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0) {
|
||||
Graphics2D g = (Graphics2D) g0.create();
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g.setColor(BG);
|
||||
g.fillRect(0, 0, getWidth(), getHeight());
|
||||
if (view.cells().isEmpty()) {
|
||||
g.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
int[] b = gridBounds();
|
||||
float eff = effScale(available(), b);
|
||||
g.setFont(baseFont.deriveFont(BASE_FONT * eff));
|
||||
|
||||
int cellW = Math.round(BASE_CELL_W * eff);
|
||||
int cellH = Math.round(BASE_CELL_H * eff);
|
||||
int gapX = Math.round(BASE_GAP_X * eff);
|
||||
int gapY = Math.round(BASE_GAP_Y * eff);
|
||||
int pad = Math.round(BASE_PAD * eff);
|
||||
int arc = Math.round(12 * eff);
|
||||
|
||||
Dimension c = content(eff, b);
|
||||
int offX = Math.max(0, (getWidth() - c.width) / 2);
|
||||
int offY = Math.max(0, (getHeight() - c.height) / 2);
|
||||
|
||||
Stroke solid = new BasicStroke(2f * eff);
|
||||
Stroke dashed = new BasicStroke(1.5f * eff, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
|
||||
10f, new float[]{4f * eff, 3f * eff}, 0f);
|
||||
|
||||
// corridors first
|
||||
for (Connection cn : view.connections()) {
|
||||
boolean known = cn.from().state() == CellState.KNOWN || cn.to().state() == CellState.KNOWN;
|
||||
g.setStroke(known ? dashed : solid);
|
||||
g.setColor(known ? KNOWN : CORRIDOR);
|
||||
int x1 = offX + pad + cn.from().x() * (cellW + gapX) + cellW / 2;
|
||||
int y1 = offY + pad + cn.from().y() * (cellH + gapY) + cellH / 2;
|
||||
int x2 = offX + pad + cn.to().x() * (cellW + gapX) + cellW / 2;
|
||||
int y2 = offY + pad + cn.to().y() * (cellH + gapY) + cellH / 2;
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
// rooms on top
|
||||
for (RoomCell cell : view.cells()) {
|
||||
int x = offX + pad + cell.x() * (cellW + gapX);
|
||||
int y = offY + pad + cell.y() * (cellH + gapY);
|
||||
Color stroke = switch (cell.state()) {
|
||||
case CURRENT -> CURRENT;
|
||||
case VISITED -> VISITED;
|
||||
case KNOWN -> KNOWN;
|
||||
};
|
||||
g.setColor(cell.state() == CellState.CURRENT
|
||||
? new Color(0x27, 0x21, 0x10)
|
||||
: new Color(0x11, 0x16, 0x1f));
|
||||
g.fillRoundRect(x, y, cellW, cellH, arc, arc);
|
||||
|
||||
g.setStroke(cell.state() == CellState.KNOWN
|
||||
? dashed
|
||||
: new BasicStroke((cell.state() == CellState.CURRENT ? 2.5f : 1.5f) * eff));
|
||||
g.setColor(stroke);
|
||||
g.drawRoundRect(x, y, cellW, cellH, arc, arc);
|
||||
|
||||
String label = cell.state() == CellState.KNOWN ? "?" : cell.name();
|
||||
int tw = g.getFontMetrics().stringWidth(label);
|
||||
int ascent = g.getFontMetrics().getAscent();
|
||||
g.setColor(cell.state() == CellState.KNOWN ? KNOWN.brighter() : stroke);
|
||||
g.drawString(label, x + (cellW - tw) / 2, y + (cellH + ascent) / 2 - 2);
|
||||
}
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import thb.jeanluc.adventure.io.text.QuestText;
|
||||
import thb.jeanluc.adventure.io.text.QuestView;
|
||||
import thb.jeanluc.adventure.io.text.Span;
|
||||
import thb.jeanluc.adventure.io.text.Style;
|
||||
|
||||
import javax.swing.JTextPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.text.BadLocationException;
|
||||
import javax.swing.text.SimpleAttributeSet;
|
||||
import javax.swing.text.StyleConstants;
|
||||
import javax.swing.text.StyledDocument;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Insets;
|
||||
|
||||
/** Read-only styled view of the quest log, shown under the map. */
|
||||
public class QuestPanel extends JTextPane {
|
||||
|
||||
/**
|
||||
* Creates an empty, read-only panel; content arrives via {@link #show(QuestView)}.
|
||||
*
|
||||
* @param font font the panel text is derived from
|
||||
*/
|
||||
public QuestPanel(Font font) {
|
||||
setEditable(false);
|
||||
setBackground(new Color(0x0b, 0x0e, 0x13));
|
||||
setForeground(new Color(0xcf, 0xd6, 0xe0));
|
||||
setFont(font.deriveFont(12f));
|
||||
setMargin(new Insets(8, 10, 8, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the panel's content with the given quest log.
|
||||
*
|
||||
* @param view the quest snapshot to render
|
||||
*/
|
||||
public void show(QuestView view) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
StyledDocument doc = getStyledDocument();
|
||||
try {
|
||||
doc.remove(0, doc.getLength());
|
||||
for (Span sp : QuestText.render(view).spans()) {
|
||||
SimpleAttributeSet as = new SimpleAttributeSet();
|
||||
StyleConstants.setForeground(as, colorFor(sp.style()));
|
||||
if (sp.style() == Style.HEADING) {
|
||||
StyleConstants.setBold(as, true);
|
||||
}
|
||||
doc.insertString(doc.getLength(), sp.text(), as);
|
||||
}
|
||||
setCaretPosition(0);
|
||||
} catch (BadLocationException ignored) {
|
||||
// replace-only; cannot occur with getLength()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param s the semantic role
|
||||
* @return the colour this panel paints that role in
|
||||
*/
|
||||
private Color colorFor(Style s) {
|
||||
return switch (s) {
|
||||
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
|
||||
case ITEM -> new Color(0x46, 0xc8, 0xd8);
|
||||
case NPC -> new Color(0xe6, 0xc3, 0x4a);
|
||||
case EXIT -> new Color(0x6f, 0xcf, 0x73);
|
||||
case DANGER -> new Color(0xe0, 0x6c, 0x6c);
|
||||
case DIM -> new Color(0x6b, 0x72, 0x80);
|
||||
case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,370 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import thb.jeanluc.adventure.io.text.Hud;
|
||||
import thb.jeanluc.adventure.io.text.MapView;
|
||||
import thb.jeanluc.adventure.io.text.QuestView;
|
||||
import thb.jeanluc.adventure.io.text.RoomView;
|
||||
import thb.jeanluc.adventure.io.text.Span;
|
||||
import thb.jeanluc.adventure.io.text.Style;
|
||||
import thb.jeanluc.adventure.io.text.StyledText;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JTextPane;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.text.BadLocationException;
|
||||
import javax.swing.text.SimpleAttributeSet;
|
||||
import javax.swing.text.StyleConstants;
|
||||
import javax.swing.text.StyledDocument;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Component;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.GraphicsConfiguration;
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Toolkit;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ComponentAdapter;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
/**
|
||||
* {@link GameIO} backed by a Swing window. A read-only output area shows
|
||||
* game text; a bottom text field collects player input. The input from
|
||||
* the EDT is handed to the (worker-thread) game loop through a
|
||||
* {@link LinkedBlockingQueue}.
|
||||
* {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
|
||||
* a styled output pane (centre), a side panel for exits / future map (right),
|
||||
* and an input field (bottom). A bundled TrueType font is loaded if present so
|
||||
* colours and glyphs work without the player installing anything.
|
||||
*
|
||||
* <p>Font sizes are multiplied by a detected display scale (HiDPI) and by a
|
||||
* user-adjustable zoom ({@code Ctrl +} / {@code Ctrl -} / {@code Ctrl 0}), so
|
||||
* text stays readable on high-DPI laptops even when the JVM does not pick up
|
||||
* the desktop scaling factor.</p>
|
||||
*/
|
||||
public class SwingIO implements GameIO {
|
||||
|
||||
/** Bridge between the EDT and the game loop. One element = one line. */
|
||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||||
/** Unscaled base size for the main output/input font, in points. */
|
||||
private static final float BASE_SIZE = 15f;
|
||||
|
||||
/** Main window. */
|
||||
private final JFrame frame;
|
||||
/** Unscaled size for the HUD and side-panel font, in points. */
|
||||
private static final float SMALL_SIZE = 12.5f;
|
||||
|
||||
/** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */
|
||||
private final JTextArea output;
|
||||
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
||||
private static final long TRAVEL_STEP_MS = 300L;
|
||||
|
||||
/** Input text field; ENTER pushes the line into {@link #inputs}. */
|
||||
private final JTextField input;
|
||||
/** 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());
|
||||
|
||||
/**
|
||||
* Creates and shows the window with the given title. Must be called on
|
||||
* the EDT (typical pattern: from {@code SwingUtilities.invokeLater}).
|
||||
* Lines typed into the input field, handed from the Swing EDT to the game
|
||||
* loop, which blocks on {@link #readLine()}.
|
||||
*/
|
||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||||
|
||||
/** The game window. */
|
||||
private final JFrame frame;
|
||||
|
||||
/** Centre region: the scrolling transcript of styled output. */
|
||||
private final JTextPane output;
|
||||
|
||||
/** Document behind {@link #output}; all text is appended to it. */
|
||||
private final StyledDocument doc;
|
||||
|
||||
/** Bottom region: the command line the player types into. */
|
||||
private final JTextField input;
|
||||
|
||||
/** Top region: the single-line status bar. */
|
||||
private final JLabel hud;
|
||||
|
||||
/** Right region (upper): the drawn map. */
|
||||
private final MapPanel map;
|
||||
|
||||
/** Scroll pane wrapping {@link #map}. */
|
||||
private final JScrollPane sideScroll;
|
||||
|
||||
/** Right region (lower): the quest log. */
|
||||
private final QuestPanel quests;
|
||||
|
||||
/** Scroll pane wrapping {@link #quests}. */
|
||||
private final JScrollPane questScroll;
|
||||
|
||||
/** Right region: container holding {@link #sideScroll} and {@link #questScroll}. */
|
||||
private final JPanel sidePanel;
|
||||
|
||||
/** Font all component fonts are derived from; bundled TTF or a monospaced fallback. */
|
||||
private final Font baseFont;
|
||||
|
||||
/** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
|
||||
private final float uiScale;
|
||||
|
||||
/** User zoom multiplier, adjusted live via keyboard. */
|
||||
private float zoom = 1f;
|
||||
|
||||
/**
|
||||
* Builds the window, wires the input field to the input queue, installs the
|
||||
* zoom keys, and shows the frame.
|
||||
*
|
||||
* @param title window title
|
||||
*/
|
||||
public SwingIO(String title) {
|
||||
frame = new JFrame(title);
|
||||
output = new JTextArea();
|
||||
input = new JTextField();
|
||||
baseFont = loadFont();
|
||||
uiScale = detectScale();
|
||||
|
||||
output = new JTextPane();
|
||||
output.setEditable(false);
|
||||
output.setLineWrap(true);
|
||||
output.setWrapStyleWord(true);
|
||||
output.setBackground(Color.BLACK);
|
||||
output.setForeground(Color.LIGHT_GRAY);
|
||||
output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
|
||||
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
output.setBackground(new Color(0x0b, 0x0e, 0x13));
|
||||
output.setForeground(new Color(0xcf, 0xd6, 0xe0));
|
||||
output.setMargin(new Insets(8, 8, 8, 8));
|
||||
doc = output.getStyledDocument();
|
||||
|
||||
input.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
|
||||
hud = new JLabel(" ");
|
||||
hud.setOpaque(true);
|
||||
hud.setBackground(new Color(0x11, 0x15, 0x1c));
|
||||
hud.setForeground(new Color(0x8b, 0x94, 0xa3));
|
||||
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
|
||||
|
||||
map = new MapPanel(baseFont);
|
||||
sideScroll = new JScrollPane(map);
|
||||
sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18));
|
||||
|
||||
quests = new QuestPanel(baseFont);
|
||||
questScroll = new JScrollPane(quests);
|
||||
questScroll.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(0x2a, 0x2f, 0x3a)));
|
||||
|
||||
sidePanel = new JPanel(new BorderLayout());
|
||||
sidePanel.add(sideScroll, BorderLayout.CENTER);
|
||||
sidePanel.add(questScroll, BorderLayout.SOUTH);
|
||||
|
||||
input = new JTextField();
|
||||
input.addActionListener(e -> {
|
||||
String line = input.getText();
|
||||
input.setText("");
|
||||
output.append("> " + line + "\n");
|
||||
appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
|
||||
inputs.offer(line);
|
||||
});
|
||||
|
||||
frame = new JFrame(title);
|
||||
frame.setLayout(new BorderLayout());
|
||||
frame.add(hud, BorderLayout.NORTH);
|
||||
frame.add(new JScrollPane(output), BorderLayout.CENTER);
|
||||
frame.add(sidePanel, BorderLayout.EAST);
|
||||
frame.add(input, BorderLayout.SOUTH);
|
||||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||
frame.setSize(800, 600);
|
||||
frame.setSize((int) (900 * uiScale), (int) (600 * uiScale));
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentResized(ComponentEvent e) {
|
||||
updateSideWidth();
|
||||
applyFonts();
|
||||
rescaleMenuOverlayIfVisible();
|
||||
}
|
||||
});
|
||||
|
||||
installZoomKeys();
|
||||
applyFonts();
|
||||
updateSideWidth();
|
||||
|
||||
frame.setVisible(true);
|
||||
input.requestFocusInWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the bundled font if present, else falls back to a logical monospaced font.
|
||||
*
|
||||
* @return the font all component fonts are derived from
|
||||
*/
|
||||
private Font loadFont() {
|
||||
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
|
||||
if (in != null) {
|
||||
Font f = Font.createFont(Font.TRUETYPE_FONT, in);
|
||||
GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
|
||||
return f;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// fall through to the logical monospaced fallback
|
||||
}
|
||||
return new Font(Font.MONOSPACED, Font.PLAIN, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI.
|
||||
*
|
||||
* @return the display scale, at least 1.0
|
||||
*/
|
||||
private float detectScale() {
|
||||
try {
|
||||
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||
.getDefaultScreenDevice().getDefaultConfiguration();
|
||||
double transform = gc.getDefaultTransform().getScaleX();
|
||||
if (transform > 1.0) {
|
||||
return (float) transform;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fall through to DPI heuristic
|
||||
}
|
||||
try {
|
||||
int dpi = Toolkit.getDefaultToolkit().getScreenResolution();
|
||||
return Math.max(1f, dpi / 96f);
|
||||
} catch (Exception ignored) {
|
||||
return 1f;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes the side panel to a fraction of the window width, with a scaled lower bound. */
|
||||
private void updateSideWidth() {
|
||||
int min = (int) (SIDE_MIN * uiScale);
|
||||
int target = Math.max(min, (int) (frame.getWidth() * SIDE_RATIO));
|
||||
sidePanel.setPreferredSize(new Dimension(target, 0));
|
||||
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
|
||||
sidePanel.revalidate();
|
||||
frame.validate();
|
||||
// The side panel changed size: let the map re-fit into it.
|
||||
map.revalidate();
|
||||
map.repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-scale derived from the window size relative to the design size
|
||||
* (900x600). At the initial size this equals {@link #uiScale}; growing the
|
||||
* window grows the text, mirroring how the map fits its panel.
|
||||
*
|
||||
* @return the auto-scale, clamped to 0.6–2.6
|
||||
*/
|
||||
private float frameScale() {
|
||||
double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0);
|
||||
return (float) Math.max(0.6, Math.min(2.6, s));
|
||||
}
|
||||
|
||||
/** Re-derives and applies all component fonts from the window auto-scale and zoom. */
|
||||
private void applyFonts() {
|
||||
float auto = frameScale();
|
||||
float main = BASE_SIZE * auto * zoom;
|
||||
float small = SMALL_SIZE * auto * zoom;
|
||||
output.setFont(baseFont.deriveFont(main));
|
||||
input.setFont(baseFont.deriveFont(main));
|
||||
hud.setFont(baseFont.deriveFont(small));
|
||||
quests.setFont(baseFont.deriveFont(small));
|
||||
map.setZoom(zoom);
|
||||
frame.revalidate();
|
||||
frame.repaint();
|
||||
}
|
||||
|
||||
/** Binds Ctrl +, Ctrl - and Ctrl 0 to live font zoom. */
|
||||
private void installZoomKeys() {
|
||||
JComponent root = frame.getRootPane();
|
||||
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
|
||||
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
|
||||
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
|
||||
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
|
||||
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
|
||||
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_0, KeyEvent.CTRL_DOWN_MASK), "zoomReset");
|
||||
|
||||
root.getActionMap().put("zoomIn", action(e -> setZoom(zoom + 0.1f)));
|
||||
root.getActionMap().put("zoomOut", action(e -> setZoom(zoom - 0.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) {
|
||||
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a lambda to a Swing action.
|
||||
*
|
||||
* @param body code run when the action fires
|
||||
* @return the action
|
||||
*/
|
||||
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
|
||||
return new AbstractAction() {
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
body.accept(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
zoom = Math.max(0.6f, Math.min(3.0f, z));
|
||||
applyFonts();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param s the semantic role
|
||||
* @return the colour this frontend paints that role in
|
||||
*/
|
||||
private Color colorFor(Style s) {
|
||||
return switch (s) {
|
||||
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
|
||||
case ITEM -> new Color(0x46, 0xc8, 0xd8);
|
||||
case NPC -> new Color(0xe6, 0xc3, 0x4a);
|
||||
case EXIT -> new Color(0x6f, 0xcf, 0x73);
|
||||
case DANGER -> new Color(0xe0, 0x6c, 0x6c);
|
||||
case DIM -> new Color(0x6b, 0x72, 0x80);
|
||||
case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the spans to the output pane and scrolls to the end. Runs on the
|
||||
* Swing EDT; safe to call from the game loop.
|
||||
*
|
||||
* @param spans the styled runs to append
|
||||
*/
|
||||
private void appendSpans(List<Span> spans) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
for (Span sp : spans) {
|
||||
SimpleAttributeSet as = new SimpleAttributeSet();
|
||||
StyleConstants.setForeground(as, colorFor(sp.style()));
|
||||
if (sp.style() == Style.HEADING) {
|
||||
StyleConstants.setBold(as, true);
|
||||
}
|
||||
doc.insertString(doc.getLength(), sp.text(), as);
|
||||
}
|
||||
output.setCaretPosition(doc.getLength());
|
||||
} catch (BadLocationException ignored) {
|
||||
// append-only; cannot occur with getLength()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readLine() {
|
||||
try {
|
||||
@@ -79,7 +376,304 @@ public class SwingIO implements GameIO {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String s) {
|
||||
SwingUtilities.invokeLater(() -> output.append(s + "\n"));
|
||||
public void print(StyledText text) {
|
||||
List<Span> spans = new ArrayList<>(text.spans());
|
||||
spans.add(new Span("\n", Style.PLAIN));
|
||||
appendSpans(spans);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showRoom(RoomView room) {
|
||||
List<Span> spans = new ArrayList<>();
|
||||
spans.add(new Span(room.name() + "\n", Style.HEADING));
|
||||
spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN));
|
||||
if (!room.items().isEmpty()) {
|
||||
spans.add(new Span("You see ", Style.PLAIN));
|
||||
addList(spans, room.items(), Style.ITEM);
|
||||
spans.add(new Span(".\n", Style.PLAIN));
|
||||
}
|
||||
if (!room.npcs().isEmpty()) {
|
||||
addList(spans, room.npcs(), Style.NPC);
|
||||
spans.add(new Span(" is here.\n", Style.PLAIN));
|
||||
}
|
||||
appendSpans(spans);
|
||||
// Exits are shown by the map panel, not as text.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setHud(Hud h) {
|
||||
String txt = " " + h.location() + " gold " + h.gold() + " turn " + h.turn()
|
||||
+ " light " + (h.lightOn() ? "on" : "off");
|
||||
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
|
||||
public void setMap(MapView 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
|
||||
public void setQuests(QuestView view) {
|
||||
quests.show(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showQuests(QuestView view) {
|
||||
// The GUI quest box is always visible; nothing extra to do.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showMap(MapView view) {
|
||||
// The GUI map panel is always visible; nothing extra to do on the 'map' command.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int choose(String title, List<String> options) {
|
||||
// Full-window overlay (glass pane), not a floating dialog: it covers the game
|
||||
// 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<>();
|
||||
SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
|
||||
try {
|
||||
return picked.take();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return options.size() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
for (int i = 0; i < xs.size(); i++) {
|
||||
if (i > 0) {
|
||||
spans.add(new Span(", ", Style.PLAIN));
|
||||
}
|
||||
spans.add(new Span(xs.get(i), st));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Renders a {@link MapView} as a styled ASCII grid: boxed rooms connected by
|
||||
* corridor lines. Box-drawing uses Unicode or pure ASCII depending on {@code unicode}.
|
||||
*/
|
||||
public final class AsciiMap {
|
||||
|
||||
/** Lower bound for the room-name field width, in characters. */
|
||||
private static final int MIN_INNER = 7; // minimum room name field width
|
||||
|
||||
/** Upper bound for the room-name field width, in characters. */
|
||||
private static final int MAX_INNER = 14; // cap so the map stays compact
|
||||
|
||||
/** Width of a horizontal corridor, in characters. */
|
||||
private static final int GAP = 3; // horizontal corridor width
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private AsciiMap() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the map as a styled grid of boxed rooms and corridor lines.
|
||||
*
|
||||
* @param view the map snapshot to draw
|
||||
* @param unicode true for box-drawing characters, false for pure ASCII
|
||||
* @return the rendered grid, or a placeholder when the map is empty
|
||||
*/
|
||||
public static StyledText render(MapView view, boolean unicode) {
|
||||
if (view.cells().isEmpty()) {
|
||||
return StyledText.of("(no map yet)");
|
||||
}
|
||||
|
||||
int maxX = 0;
|
||||
int maxY = 0;
|
||||
int inner = MIN_INNER;
|
||||
Map<Long, RoomCell> at = new HashMap<>();
|
||||
for (RoomCell c : view.cells()) {
|
||||
maxX = Math.max(maxX, c.x());
|
||||
maxY = Math.max(maxY, c.y());
|
||||
inner = Math.max(inner, Math.min(MAX_INNER, c.name().length()));
|
||||
at.put(key(c.x(), c.y()), c);
|
||||
}
|
||||
|
||||
Set<Long> hEdge = new HashSet<>();
|
||||
Set<Long> vEdge = new HashSet<>();
|
||||
for (Connection cn : view.connections()) {
|
||||
RoomCell a = cn.from();
|
||||
RoomCell b = cn.to();
|
||||
if (a.y() == b.y()) {
|
||||
hEdge.add(key(Math.min(a.x(), b.x()), a.y()));
|
||||
} else if (a.x() == b.x()) {
|
||||
vEdge.add(key(a.x(), Math.min(a.y(), b.y())));
|
||||
}
|
||||
}
|
||||
|
||||
String tl = unicode ? "┌" : "+";
|
||||
String tr = unicode ? "┐" : "+";
|
||||
String bl = unicode ? "└" : "+";
|
||||
String br = unicode ? "┘" : "+";
|
||||
String hz = unicode ? "─" : "-";
|
||||
String vt = unicode ? "│" : "|";
|
||||
|
||||
int boxW = inner + 2;
|
||||
int leftPad = (boxW - 1) / 2;
|
||||
int rightPad = boxW - 1 - leftPad;
|
||||
|
||||
StyledText.Builder b = StyledText.builder();
|
||||
for (int gy = 0; gy <= maxY; gy++) {
|
||||
for (int line = 0; line < 3; line++) {
|
||||
for (int gx = 0; gx <= maxX; gx++) {
|
||||
RoomCell c = at.get(key(gx, gy));
|
||||
if (c == null) {
|
||||
b.plain(" ".repeat(boxW));
|
||||
} else if (line == 0) {
|
||||
styled(b, tl + hz.repeat(inner) + tr, c.state());
|
||||
} else if (line == 2) {
|
||||
styled(b, bl + hz.repeat(inner) + br, c.state());
|
||||
} else {
|
||||
styled(b, vt + center(label(c, inner), inner) + vt, c.state());
|
||||
}
|
||||
if (gx < maxX) {
|
||||
if (line == 1 && hEdge.contains(key(gx, gy))) {
|
||||
b.dim(hz.repeat(GAP));
|
||||
} else {
|
||||
b.plain(" ".repeat(GAP));
|
||||
}
|
||||
}
|
||||
}
|
||||
b.plain("\n");
|
||||
}
|
||||
if (gy < maxY) {
|
||||
for (int gx = 0; gx <= maxX; gx++) {
|
||||
if (vEdge.contains(key(gx, gy))) {
|
||||
b.plain(" ".repeat(leftPad)).dim(vt).plain(" ".repeat(rightPad));
|
||||
} else {
|
||||
b.plain(" ".repeat(boxW));
|
||||
}
|
||||
if (gx < maxX) {
|
||||
b.plain(" ".repeat(GAP));
|
||||
}
|
||||
}
|
||||
b.plain("\n");
|
||||
}
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the text in the style that matches the cell's fog-of-war state.
|
||||
*
|
||||
* @param b the builder to append to
|
||||
* @param s the text
|
||||
* @param state the cell's state
|
||||
*/
|
||||
private static void styled(StyledText.Builder b, String s, CellState state) {
|
||||
switch (state) {
|
||||
case CURRENT -> b.heading(s);
|
||||
case KNOWN -> b.dim(s);
|
||||
default -> b.plain(s);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param c the cell
|
||||
* @param inner width of the name field, in characters
|
||||
* @return the room name, truncated to {@code inner}
|
||||
*/
|
||||
private static String label(RoomCell c, int inner) {
|
||||
String n = c.name();
|
||||
if (n.length() > inner) {
|
||||
n = n.substring(0, inner);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param s the text
|
||||
* @param width target width, at least the text's length
|
||||
* @return the text padded with spaces so it sits centred in {@code width}
|
||||
*/
|
||||
private static String center(String s, int width) {
|
||||
int total = width - s.length();
|
||||
int left = total / 2;
|
||||
int right = total - left;
|
||||
return " ".repeat(left) + s + " ".repeat(right);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param x column on the grid
|
||||
* @param y row on the grid
|
||||
* @return the two coordinates packed into one map/set key
|
||||
*/
|
||||
private static long key(int x, int y) {
|
||||
return (((long) x) << 32) ^ (y & 0xffffffffL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/** Builds styled banners for big moments (game start, finale). */
|
||||
public final class Banner {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private Banner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A framed, heading-styled welcome banner for the given title.
|
||||
*
|
||||
* @param title the game title to frame
|
||||
* @return the banner
|
||||
*/
|
||||
public static StyledText welcome(String title) {
|
||||
String bar = "=".repeat(Math.max(8, title.length() + 8));
|
||||
return StyledText.builder()
|
||||
.heading(bar + "\n")
|
||||
.heading(" " + title + "\n")
|
||||
.heading(bar)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/** Fog-of-war state of a room on the map. */
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/**
|
||||
* 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) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/**
|
||||
* 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) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Frontend-agnostic snapshot of the visible map: placed cells + connections.
|
||||
*
|
||||
* @param cells the rooms the player knows about, placed on the grid
|
||||
* @param connections the corridors between those rooms
|
||||
*/
|
||||
public record MapView(List<RoomCell> cells, List<Connection> connections) {
|
||||
/** Defensively copies both lists, making the snapshot immutable. */
|
||||
public MapView {
|
||||
cells = List.copyOf(cells);
|
||||
connections = List.copyOf(connections);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/**
|
||||
* 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) {}
|
||||
@@ -0,0 +1,37 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/** Renders a {@link QuestView} as styled text for the console quest log. */
|
||||
public final class QuestText {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private QuestText() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the quest log: active quests with their objective, then the
|
||||
* completed titles.
|
||||
*
|
||||
* @param view the quest snapshot to render
|
||||
* @return the rendered log, or a placeholder when there are no quests
|
||||
*/
|
||||
public static StyledText render(QuestView view) {
|
||||
if (view.active().isEmpty() && view.completed().isEmpty()) {
|
||||
return StyledText.of("You have no quests yet.");
|
||||
}
|
||||
StyledText.Builder b = StyledText.builder();
|
||||
b.heading("QUESTS");
|
||||
if (view.active().isEmpty()) {
|
||||
b.plain("\n").dim(" (none active)");
|
||||
}
|
||||
for (QuestEntry e : view.active()) {
|
||||
b.plain("\n").heading(e.title()).plain("\n").exit(" -> ").plain(e.objective());
|
||||
}
|
||||
if (!view.completed().isEmpty()) {
|
||||
b.plain("\n\n").dim("Completed:");
|
||||
for (String t : view.completed()) {
|
||||
b.plain("\n").dim(" ✓ " + t);
|
||||
}
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Frontend-agnostic snapshot of the quest log.
|
||||
*
|
||||
* @param active quests in progress, with their current objective
|
||||
* @param completed titles of the quests already finished
|
||||
*/
|
||||
public record QuestView(List<QuestEntry> active, List<String> completed) {
|
||||
/** Defensively copies both lists, making the snapshot immutable. */
|
||||
public QuestView {
|
||||
active = List.copyOf(active);
|
||||
completed = List.copyOf(completed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Default, layout-free rendering of high-level views into {@link StyledText}. */
|
||||
public final class Renderings {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private Renderings() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests).
|
||||
*
|
||||
* @param r the room snapshot to render
|
||||
* @return name, description, items, NPCs and exits as styled text
|
||||
*/
|
||||
public static StyledText roomToStyledText(RoomView r) {
|
||||
StyledText.Builder b = StyledText.builder();
|
||||
b.heading(r.name()).plain("\n");
|
||||
b.plain(r.description().stripTrailing());
|
||||
if (!r.items().isEmpty()) {
|
||||
b.plain("\nYou see: ");
|
||||
appendList(b, r.items(), Style.ITEM);
|
||||
b.plain(".");
|
||||
}
|
||||
if (!r.npcs().isEmpty()) {
|
||||
b.plain("\nHere is: ");
|
||||
appendList(b, r.npcs(), Style.NPC);
|
||||
b.plain(".");
|
||||
}
|
||||
if (r.exits().isEmpty()) {
|
||||
b.plain("\nThere are no obvious exits.");
|
||||
} else {
|
||||
b.plain("\nExits: ");
|
||||
appendList(b, r.exits(), Style.EXIT);
|
||||
b.plain(".");
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the entries as a comma-separated list in the given style.
|
||||
*
|
||||
* @param b the builder to append to
|
||||
* @param xs the entries
|
||||
* @param st style applied to each entry
|
||||
*/
|
||||
private static void appendList(StyledText.Builder b, List<String> xs, Style st) {
|
||||
for (int i = 0; i < xs.size(); i++) {
|
||||
if (i > 0) {
|
||||
b.plain(", ");
|
||||
}
|
||||
switch (st) {
|
||||
case ITEM -> b.item(xs.get(i));
|
||||
case NPC -> b.npc(xs.get(i));
|
||||
case EXIT -> b.exit(xs.get(i));
|
||||
default -> b.plain(xs.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/**
|
||||
* 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) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Semantic snapshot of a room for rendering. Display-ready strings, no layout.
|
||||
*
|
||||
* @param name display name of the room
|
||||
* @param description room description text
|
||||
* @param items display names of the items visible in the room
|
||||
* @param npcs display names of the NPCs present in the room
|
||||
* @param exits directions the player can leave by
|
||||
*/
|
||||
public record RoomView(String name, String description,
|
||||
List<String> items, List<String> npcs, List<String> exits) {
|
||||
/** Defensively copies the lists, making the snapshot immutable. */
|
||||
public RoomView {
|
||||
items = List.copyOf(items);
|
||||
npcs = List.copyOf(npcs);
|
||||
exits = List.copyOf(exits);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/**
|
||||
* A run of text carrying a single semantic {@link Style}.
|
||||
*
|
||||
* @param text the text of this run; never null
|
||||
* @param style the semantic role applied to the whole run; never null
|
||||
*/
|
||||
public record Span(String text, Style style) {
|
||||
/**
|
||||
* Rejects null components.
|
||||
*
|
||||
* @throws IllegalArgumentException if {@code text} or {@code style} is null
|
||||
*/
|
||||
public Span {
|
||||
if (text == null) {
|
||||
throw new IllegalArgumentException("text must not be null");
|
||||
}
|
||||
if (style == null) {
|
||||
throw new IllegalArgumentException("style must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package thb.jeanluc.adventure.io.text;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
|
||||
public final class StyledText {
|
||||
|
||||
/** The spans of this text, in output order; unmodifiable. */
|
||||
private final List<Span> spans;
|
||||
|
||||
/**
|
||||
* @param spans the spans to copy into this text
|
||||
*/
|
||||
private StyledText(List<Span> spans) {
|
||||
this.spans = List.copyOf(spans);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the spans for rendering.
|
||||
*
|
||||
* @return the spans in order (unmodifiable)
|
||||
*/
|
||||
public List<Span> spans() {
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens this text for frontends that cannot render styles.
|
||||
*
|
||||
* @return all span text concatenated, without any styling
|
||||
*/
|
||||
public String plainText() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Span s : spans) {
|
||||
sb.append(s.text());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an unstyled string.
|
||||
*
|
||||
* @param s the text
|
||||
* @return a styled text of a single {@link Style#PLAIN} span
|
||||
*/
|
||||
public static StyledText of(String s) {
|
||||
return new StyledText(List.of(new Span(s, Style.PLAIN)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts assembling a styled text span by span.
|
||||
*
|
||||
* @return a new, empty builder
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/** Fluent builder; one method per {@link Style} role. */
|
||||
public static final class Builder {
|
||||
|
||||
/** Spans collected so far, in the order the append methods were called. */
|
||||
private final List<Span> spans = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Appends one span.
|
||||
*
|
||||
* @param text the text to append
|
||||
* @param style the role to apply to it
|
||||
* @return this builder
|
||||
*/
|
||||
private Builder add(String text, Style style) {
|
||||
spans.add(new Span(text, style));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a span in the {@link Style#PLAIN} role.
|
||||
*
|
||||
* @param s text to append as {@link Style#PLAIN}
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder plain(String s) { return add(s, Style.PLAIN); }
|
||||
|
||||
/**
|
||||
* Appends a span in the {@link Style#HEADING} role.
|
||||
*
|
||||
* @param s text to append as {@link Style#HEADING}
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder heading(String s) { return add(s, Style.HEADING); }
|
||||
|
||||
/**
|
||||
* Appends a span in the {@link Style#ITEM} role.
|
||||
*
|
||||
* @param s text to append as {@link Style#ITEM}
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder item(String s) { return add(s, Style.ITEM); }
|
||||
|
||||
/**
|
||||
* Appends a span in the {@link Style#NPC} role.
|
||||
*
|
||||
* @param s text to append as {@link Style#NPC}
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder npc(String s) { return add(s, Style.NPC); }
|
||||
|
||||
/**
|
||||
* Appends a span in the {@link Style#EXIT} role.
|
||||
*
|
||||
* @param s text to append as {@link Style#EXIT}
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder exit(String s) { return add(s, Style.EXIT); }
|
||||
|
||||
/**
|
||||
* Appends a span in the {@link Style#DANGER} role.
|
||||
*
|
||||
* @param s text to append as {@link Style#DANGER}
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder danger(String s) { return add(s, Style.DANGER); }
|
||||
|
||||
/**
|
||||
* Appends a span in the {@link Style#DIM} role.
|
||||
*
|
||||
* @param s text to append as {@link Style#DIM}
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder dim(String s) { return add(s, Style.DIM); }
|
||||
|
||||
/**
|
||||
* Finishes the build; the builder may not be reused meaningfully afterwards.
|
||||
*
|
||||
* @return an immutable styled text of the collected spans
|
||||
*/
|
||||
public StyledText build() {
|
||||
return new StyledText(spans);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 + "')");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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 {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private EndingFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an ending, defaulting a null {@code victory} flag to false.
|
||||
*
|
||||
* @param dto data read from {@code endings.yaml}; must not be null
|
||||
* @return the freshly built ending
|
||||
* @throws WorldLoadException if a condition sets none of its fields
|
||||
*/
|
||||
public static Ending fromDto(EndingDto dto) {
|
||||
return new Ending(
|
||||
dto.id(),
|
||||
dto.title(),
|
||||
Boolean.TRUE.equals(dto.victory()),
|
||||
ConditionDto.toModelList(dto.when()),
|
||||
dto.text());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||
*/
|
||||
public final class ItemFactory {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private ItemFactory() {
|
||||
}
|
||||
|
||||
@@ -30,12 +31,16 @@ public final class ItemFactory {
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.light(Boolean.TRUE.equals(dto.light()))
|
||||
.fixed(Boolean.TRUE.equals(dto.fixed()))
|
||||
.build();
|
||||
case "readable" -> ReadableItem.builder()
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.readText(dto.readText())
|
||||
.light(Boolean.TRUE.equals(dto.light()))
|
||||
.fixed(Boolean.TRUE.equals(dto.fixed()))
|
||||
.build();
|
||||
case "switchable" -> SwitchableItem.builder()
|
||||
.id(dto.id())
|
||||
@@ -44,6 +49,9 @@ public final class ItemFactory {
|
||||
.state(Boolean.TRUE.equals(dto.initialState()))
|
||||
.onText(dto.onText())
|
||||
.offText(dto.offText())
|
||||
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
|
||||
.light(Boolean.TRUE.equals(dto.light()))
|
||||
.fixed(Boolean.TRUE.equals(dto.fixed()))
|
||||
.build();
|
||||
default -> throw new WorldLoadException(
|
||||
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
||||
|
||||
@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Npc;
|
||||
*/
|
||||
public final class NpcFactory {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private NpcFactory() {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||
import thb.jeanluc.adventure.loader.dto.EffectDto;
|
||||
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
||||
import thb.jeanluc.adventure.loader.dto.QuestStageDto;
|
||||
import thb.jeanluc.adventure.model.Quest;
|
||||
import thb.jeanluc.adventure.model.QuestStage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */
|
||||
public final class QuestFactory {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private QuestFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a quest including all of its stages.
|
||||
*
|
||||
* @param dto data read from {@code quests.yaml}; must not be null
|
||||
* @return the freshly built quest
|
||||
* @throws WorldLoadException if a condition or effect sets none of its fields
|
||||
*/
|
||||
public static Quest fromDto(QuestDto dto) {
|
||||
List<QuestStage> stages = new ArrayList<>();
|
||||
if (dto.stages() != null) {
|
||||
for (QuestStageDto s : dto.stages()) {
|
||||
stages.add(new QuestStage(
|
||||
s.objective(),
|
||||
ConditionDto.toModelList(s.completion()),
|
||||
EffectDto.toModelList(s.onComplete())));
|
||||
}
|
||||
}
|
||||
return new Quest(
|
||||
dto.id(),
|
||||
dto.title(),
|
||||
Boolean.TRUE.equals(dto.autoStart()),
|
||||
stages,
|
||||
EffectDto.toModelList(dto.onComplete()));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||
import thb.jeanluc.adventure.loader.dto.DescriptionStateDto;
|
||||
import thb.jeanluc.adventure.loader.dto.DialogueLineDto;
|
||||
import thb.jeanluc.adventure.loader.dto.EffectDto;
|
||||
import thb.jeanluc.adventure.loader.dto.ExitLockDto;
|
||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||
import thb.jeanluc.adventure.loader.dto.ReactionDto;
|
||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||
import thb.jeanluc.adventure.model.DescriptionState;
|
||||
import thb.jeanluc.adventure.model.DialogueLine;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.ExitLock;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.NpcReaction;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
@@ -75,6 +83,25 @@ public class ReferenceResolver {
|
||||
room.addNpc(npc);
|
||||
}
|
||||
}
|
||||
if (dto.exitLocks() != null) {
|
||||
for (ExitLockDto lock : dto.exitLocks()) {
|
||||
Direction direction;
|
||||
try {
|
||||
direction = Direction.fromString(lock.direction());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' has exit lock with unknown direction '" + lock.direction() + "'");
|
||||
}
|
||||
room.addExitLock(direction, new ExitLock(
|
||||
ConditionDto.toModelList(lock.requires()), lock.blocked()));
|
||||
}
|
||||
}
|
||||
if (dto.descriptionStates() != null) {
|
||||
for (DescriptionStateDto ds : dto.descriptionStates()) {
|
||||
room.addDescriptionState(new DescriptionState(
|
||||
ConditionDto.toModelList(ds.when()), ds.text()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +113,15 @@ public class ReferenceResolver {
|
||||
*/
|
||||
public void resolveNpcs(List<NpcDto> npcDtos) {
|
||||
for (NpcDto dto : npcDtos) {
|
||||
Npc npc = npcs.get(dto.id());
|
||||
if (dto.dialogue() != null) {
|
||||
for (DialogueLineDto dl : dto.dialogue()) {
|
||||
npc.addDialogue(new DialogueLine(ConditionDto.toModelList(dl.when()), dl.text()));
|
||||
}
|
||||
}
|
||||
if (dto.reactions() == null) {
|
||||
continue;
|
||||
}
|
||||
Npc npc = npcs.get(dto.id());
|
||||
for (ReactionDto r : dto.reactions()) {
|
||||
if (r.onReceive() == null) {
|
||||
throw new WorldLoadException(
|
||||
@@ -123,6 +155,8 @@ public class ReferenceResolver {
|
||||
.consumes(consumes)
|
||||
.gives(gives)
|
||||
.response(r.response())
|
||||
.requires(ConditionDto.toModelList(r.requires()))
|
||||
.effects(EffectDto.toModelList(r.effects()))
|
||||
.build();
|
||||
npc.putReaction(r.onReceive(), reaction);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Room;
|
||||
*/
|
||||
public final class RoomFactory {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private RoomFactory() {
|
||||
}
|
||||
|
||||
@@ -19,6 +20,9 @@ public final class RoomFactory {
|
||||
* @return the freshly built room shell
|
||||
*/
|
||||
public static Room shellFromDto(RoomDto dto) {
|
||||
return 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.setMusic(dto.music());
|
||||
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,18 +5,25 @@ import com.fasterxml.jackson.databind.type.CollectionType;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.GameDto;
|
||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||
import thb.jeanluc.adventure.model.Combination;
|
||||
import thb.jeanluc.adventure.model.Ending;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Quest;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -68,11 +75,17 @@ public class WorldLoader {
|
||||
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
|
||||
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.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);
|
||||
|
||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
|
||||
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
|
||||
requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList());
|
||||
requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());
|
||||
|
||||
Map<String, Item> items = new HashMap<>();
|
||||
for (ItemDto dto : itemDtos) {
|
||||
@@ -86,24 +99,51 @@ public class WorldLoader {
|
||||
for (RoomDto dto : roomDtos) {
|
||||
rooms.put(dto.id(), RoomFactory.shellFromDto(dto));
|
||||
}
|
||||
Map<String, Quest> quests = new HashMap<>();
|
||||
for (QuestDto dto : questDtos) {
|
||||
quests.put(dto.id(), QuestFactory.fromDto(dto));
|
||||
}
|
||||
List<Ending> endings = new ArrayList<>();
|
||||
for (EndingDto dto : endingDtos) {
|
||||
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);
|
||||
resolver.resolveRooms(roomDtos);
|
||||
resolver.resolveNpcs(npcDtos);
|
||||
|
||||
new WorldValidator(items, npcs, rooms, gameDto).validate();
|
||||
new WorldValidator(items, npcs, rooms, gameDto, quests, endings, combinations).validate();
|
||||
|
||||
Room start = rooms.get(gameDto.startRoom());
|
||||
int gold = gameDto.startGold() == null ? 0 : gameDto.startGold();
|
||||
Player player = new Player(start, gold);
|
||||
|
||||
World world = new World(rooms, items, npcs,
|
||||
gameDto.title(), gameDto.welcomeMessage());
|
||||
gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
|
||||
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||
return new LoadResult(world, player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a required YAML list resource.
|
||||
*
|
||||
* @param <T> element type
|
||||
* @param resource classpath path of the YAML file
|
||||
* @param elementType class of the list elements
|
||||
* @return the parsed elements, empty if the document is blank
|
||||
* @throws WorldLoadException if the resource is missing or unparsable
|
||||
*/
|
||||
private <T> List<T> readList(String resource, Class<T> elementType) {
|
||||
try (InputStream in = openResource(resource)) {
|
||||
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
|
||||
@@ -114,6 +154,37 @@ public class WorldLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an optional YAML list resource.
|
||||
*
|
||||
* @param <T> element type
|
||||
* @param resource classpath path of the YAML file
|
||||
* @param elementType class of the list elements
|
||||
* @return the parsed elements, empty if the resource is absent or blank
|
||||
* @throws WorldLoadException if the resource exists but is unparsable
|
||||
*/
|
||||
private <T> List<T> readListOptional(String resource, Class<T> elementType) {
|
||||
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
||||
if (in == null) {
|
||||
return List.of();
|
||||
}
|
||||
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
|
||||
List<T> result = yaml.readValue(in, type);
|
||||
return result == null ? List.of() : result;
|
||||
} catch (IOException e) {
|
||||
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a required YAML resource holding a single object.
|
||||
*
|
||||
* @param <T> target type
|
||||
* @param resource classpath path of the YAML file
|
||||
* @param type class to deserialize into
|
||||
* @return the parsed object
|
||||
* @throws WorldLoadException if the resource is missing or unparsable
|
||||
*/
|
||||
private <T> T readSingle(String resource, Class<T> type) {
|
||||
try (InputStream in = openResource(resource)) {
|
||||
return yaml.readValue(in, type);
|
||||
@@ -122,6 +193,13 @@ public class WorldLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a classpath resource.
|
||||
*
|
||||
* @param resource classpath path of the resource
|
||||
* @return an open stream; the caller closes it
|
||||
* @throws WorldLoadException if the resource is not on the classpath
|
||||
*/
|
||||
private InputStream openResource(String resource) {
|
||||
InputStream in = getClass().getResourceAsStream(resource);
|
||||
if (in == null) {
|
||||
@@ -130,6 +208,13 @@ public class WorldLoader {
|
||||
return in;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that no id occurs twice.
|
||||
*
|
||||
* @param kind label of the entity type, used in the error message
|
||||
* @param ids the ids to check
|
||||
* @throws WorldLoadException on the first duplicate id
|
||||
*/
|
||||
private void requireUniqueIds(String kind, List<String> ids) {
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
for (int j = i + 1; j < ids.size(); j++) {
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||
import thb.jeanluc.adventure.model.Combination;
|
||||
import thb.jeanluc.adventure.model.Condition;
|
||||
import thb.jeanluc.adventure.model.Effect;
|
||||
import thb.jeanluc.adventure.model.Ending;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Quest;
|
||||
import thb.jeanluc.adventure.model.QuestStage;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Phase 4 of the loading pipeline. Walks the fully wired world and
|
||||
* checks the invariants documented in {@code yaml-schemas.md}.
|
||||
*
|
||||
* <p>Every reference an id can point at is resolved here — item ids in
|
||||
* conditions, effects and recipes, quest ids in {@code START_QUEST} — so a
|
||||
* typo in the world files fails at load time with a precise message rather
|
||||
* than silently soft-locking a quest or throwing an NPE mid-game. All
|
||||
* problems found are reported together, not one per run.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class WorldValidator {
|
||||
|
||||
/** Regex every entity id must match. */
|
||||
@@ -31,44 +44,216 @@ public class WorldValidator {
|
||||
/** Loaded game.yaml whose {@code startRoom} must exist. */
|
||||
private final GameDto gameDto;
|
||||
|
||||
/** Quest registry; quest ids are the targets of {@code START_QUEST} effects. */
|
||||
private final Map<String, Quest> quests;
|
||||
|
||||
/** Endings whose trigger conditions must reference known items. */
|
||||
private final List<Ending> endings;
|
||||
|
||||
/** Combination recipes, keyed by item pair. */
|
||||
private final Map<String, Combination> combinations;
|
||||
|
||||
/** Problems found so far; all are reported in one exception. */
|
||||
private final List<String> problems = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Runs every validation rule.
|
||||
* Full constructor covering every entity kind in the world.
|
||||
*
|
||||
* @throws WorldLoadException on the first failure
|
||||
* @param items item registry
|
||||
* @param npcs NPC registry
|
||||
* @param rooms room registry
|
||||
* @param gameDto loaded {@code game.yaml}
|
||||
* @param quests quest registry
|
||||
* @param endings loaded endings
|
||||
* @param combinations recipe registry, keyed by {@link Combination#key()}
|
||||
*/
|
||||
public WorldValidator(Map<String, Item> items,
|
||||
Map<String, Npc> npcs,
|
||||
Map<String, Room> rooms,
|
||||
GameDto gameDto,
|
||||
Map<String, Quest> quests,
|
||||
List<Ending> endings,
|
||||
Map<String, Combination> combinations) {
|
||||
this.items = items;
|
||||
this.npcs = npcs;
|
||||
this.rooms = rooms;
|
||||
this.gameDto = gameDto;
|
||||
this.quests = quests;
|
||||
this.endings = endings;
|
||||
this.combinations = combinations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience constructor for a world without quests, endings or recipes.
|
||||
*
|
||||
* @param items item registry
|
||||
* @param npcs NPC registry
|
||||
* @param rooms room registry
|
||||
* @param gameDto loaded {@code game.yaml}
|
||||
*/
|
||||
public WorldValidator(Map<String, Item> items,
|
||||
Map<String, Npc> npcs,
|
||||
Map<String, Room> rooms,
|
||||
GameDto gameDto) {
|
||||
this(items, npcs, rooms, gameDto, Map.of(), List.of(), Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every validation rule and reports all problems at once.
|
||||
*
|
||||
* @throws WorldLoadException if any rule fails
|
||||
*/
|
||||
public void validate() {
|
||||
validateIds();
|
||||
validateStartRoom();
|
||||
validateGreetings();
|
||||
validateRequiredText();
|
||||
validateQuests();
|
||||
validateEndings();
|
||||
validateCombinations();
|
||||
|
||||
if (!problems.isEmpty()) {
|
||||
throw new WorldLoadException("Invalid world:\n - " + String.join("\n - ", problems));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateIds() {
|
||||
items.keySet().forEach(this::requireValidId);
|
||||
npcs.keySet().forEach(this::requireValidId);
|
||||
rooms.keySet().forEach(this::requireValidId);
|
||||
quests.keySet().forEach(this::requireValidId);
|
||||
}
|
||||
|
||||
private void requireValidId(String id) {
|
||||
if (id == null || !ID_PATTERN.matcher(id).matches()) {
|
||||
throw new WorldLoadException("Invalid id '" + id + "': must match " + ID_PATTERN);
|
||||
problems.add("Invalid id '" + id + "': must match " + ID_PATTERN);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateStartRoom() {
|
||||
if (gameDto == null || gameDto.startRoom() == null) {
|
||||
throw new WorldLoadException("game.yaml is missing 'startRoom'");
|
||||
problems.add("game.yaml is missing 'startRoom'");
|
||||
return;
|
||||
}
|
||||
if (!rooms.containsKey(gameDto.startRoom())) {
|
||||
throw new WorldLoadException(
|
||||
"game.yaml.startRoom '" + gameDto.startRoom() + "' does not match any room");
|
||||
problems.add("game.yaml.startRoom '" + gameDto.startRoom()
|
||||
+ "' does not match any room");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGreetings() {
|
||||
for (Npc npc : npcs.values()) {
|
||||
if (npc.getGreeting() == null || npc.getGreeting().isBlank()) {
|
||||
throw new WorldLoadException("NPC '" + npc.getId() + "' has an empty greeting");
|
||||
if (isBlank(npc.getGreeting())) {
|
||||
problems.add("NPC '" + npc.getId() + "' has an empty greeting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Names and descriptions are rendered unconditionally by the IO layer, so a
|
||||
* missing one is an NPE waiting to happen the moment the player walks in.
|
||||
*/
|
||||
private void validateRequiredText() {
|
||||
for (Room room : rooms.values()) {
|
||||
if (isBlank(room.getName())) {
|
||||
problems.add("Room '" + room.getId() + "' is missing 'name'");
|
||||
}
|
||||
if (isBlank(room.getDescription())) {
|
||||
problems.add("Room '" + room.getId() + "' is missing 'description'");
|
||||
}
|
||||
}
|
||||
for (Item item : items.values()) {
|
||||
if (isBlank(item.getName())) {
|
||||
problems.add("Item '" + item.getId() + "' is missing 'name'");
|
||||
}
|
||||
if (isBlank(item.getDescription())) {
|
||||
problems.add("Item '" + item.getId() + "' is missing 'description'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateQuests() {
|
||||
for (Quest quest : quests.values()) {
|
||||
String where = "quest '" + quest.id() + "'";
|
||||
if (isBlank(quest.title())) {
|
||||
problems.add(where + " is missing 'title'");
|
||||
}
|
||||
if (quest.stages().isEmpty()) {
|
||||
problems.add(where + " has no stages");
|
||||
}
|
||||
checkEffects(quest.onComplete(), where + " onComplete");
|
||||
for (int i = 0; i < quest.stages().size(); i++) {
|
||||
QuestStage stage = quest.stages().get(i);
|
||||
String stageWhere = where + " stage " + i;
|
||||
if (isBlank(stage.objective())) {
|
||||
problems.add(stageWhere + " is missing 'objective'");
|
||||
}
|
||||
checkConditions(stage.completion(), stageWhere + " completion");
|
||||
checkEffects(stage.onComplete(), stageWhere + " onComplete");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEndings() {
|
||||
for (Ending ending : endings) {
|
||||
String where = "ending '" + ending.id() + "'";
|
||||
requireValidId(ending.id());
|
||||
if (isBlank(ending.text())) {
|
||||
problems.add(where + " is missing 'text'");
|
||||
}
|
||||
checkConditions(ending.when(), where + " when");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCombinations() {
|
||||
for (Combination c : combinations.values()) {
|
||||
String where = "combination '" + c.key() + "'";
|
||||
requireKnownItem(c.a(), where + " operand 'a'");
|
||||
requireKnownItem(c.b(), where + " operand 'b'");
|
||||
if (c.produce() != null) {
|
||||
requireKnownItem(c.produce(), where + " 'produce'");
|
||||
}
|
||||
for (String id : c.consume()) {
|
||||
requireKnownItem(id, where + " 'consume'");
|
||||
}
|
||||
checkConditions(c.requires(), where + " requires");
|
||||
checkEffects(c.effects(), where + " effects");
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the item ids referenced by {@code HAS_ITEM} conditions. */
|
||||
private void checkConditions(Collection<Condition> conditions, String where) {
|
||||
for (Condition c : conditions) {
|
||||
if (c.type() == Condition.Type.HAS_ITEM) {
|
||||
requireKnownItem(c.arg(), where);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the item and quest ids referenced by effects. */
|
||||
private void checkEffects(Collection<Effect> effects, String where) {
|
||||
for (Effect e : effects) {
|
||||
switch (e.type()) {
|
||||
case GIVE_ITEM, REMOVE_ITEM -> requireKnownItem(e.arg(), where);
|
||||
case START_QUEST -> {
|
||||
if (!quests.containsKey(e.arg())) {
|
||||
problems.add(where + " starts unknown quest '" + e.arg() + "'");
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
// SET_FLAG / CLEAR_FLAG / SAY carry free-form text.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireKnownItem(String id, String where) {
|
||||
if (!items.containsKey(id)) {
|
||||
problems.add(where + " references unknown item '" + id + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import thb.jeanluc.adventure.loader.WorldLoadException;
|
||||
import thb.jeanluc.adventure.model.Condition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML condition: exactly one of the fields is set.
|
||||
*
|
||||
* @param flag name of a flag that must be set; nullable
|
||||
* @param notFlag name of a flag that must not be set; nullable
|
||||
* @param hasItem id of an item the player must carry; nullable
|
||||
*/
|
||||
public record ConditionDto(String flag, String notFlag, String hasItem) {
|
||||
|
||||
/**
|
||||
* Converts this DTO into its domain condition.
|
||||
*
|
||||
* @return the matching {@link Condition}
|
||||
* @throws WorldLoadException if none of the fields is set
|
||||
*/
|
||||
public Condition toModel() {
|
||||
if (flag != null) {
|
||||
return new Condition(Condition.Type.FLAG, flag);
|
||||
}
|
||||
if (notFlag != null) {
|
||||
return new Condition(Condition.Type.NOT_FLAG, notFlag);
|
||||
}
|
||||
if (hasItem != null) {
|
||||
return new Condition(Condition.Type.HAS_ITEM, hasItem);
|
||||
}
|
||||
throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a list of DTOs into domain conditions.
|
||||
*
|
||||
* @param dtos the DTOs to convert; null is treated as empty
|
||||
* @return the converted conditions, never null
|
||||
* @throws WorldLoadException if any DTO sets none of its fields
|
||||
*/
|
||||
public static List<Condition> toModelList(List<ConditionDto> dtos) {
|
||||
List<Condition> out = new ArrayList<>();
|
||||
if (dtos != null) {
|
||||
for (ConditionDto d : dtos) {
|
||||
out.add(d.toModel());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import thb.jeanluc.adventure.loader.WorldLoadException;
|
||||
import thb.jeanluc.adventure.model.Effect;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML effect: exactly one of the fields is set.
|
||||
*
|
||||
* @param setFlag name of a flag to set; nullable
|
||||
* @param clearFlag name of a flag to clear; nullable
|
||||
* @param giveItem id of an item to add to the inventory; nullable
|
||||
* @param removeItem id of an item to take from the inventory; nullable
|
||||
* @param say text to print; nullable
|
||||
* @param startQuest id of a quest to start; nullable
|
||||
*/
|
||||
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem,
|
||||
String say, String startQuest) {
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor without startQuest.
|
||||
*
|
||||
* @param setFlag name of a flag to set; nullable
|
||||
* @param clearFlag name of a flag to clear; nullable
|
||||
* @param giveItem id of an item to add to the inventory; nullable
|
||||
* @param removeItem id of an item to take from the inventory; nullable
|
||||
* @param say text to print; nullable
|
||||
*/
|
||||
public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
|
||||
this(setFlag, clearFlag, giveItem, removeItem, say, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this DTO into its domain effect.
|
||||
*
|
||||
* @return the matching {@link Effect}
|
||||
* @throws WorldLoadException if none of the fields is set
|
||||
*/
|
||||
public Effect toModel() {
|
||||
if (setFlag != null) {
|
||||
return new Effect(Effect.Type.SET_FLAG, setFlag);
|
||||
}
|
||||
if (clearFlag != null) {
|
||||
return new Effect(Effect.Type.CLEAR_FLAG, clearFlag);
|
||||
}
|
||||
if (giveItem != null) {
|
||||
return new Effect(Effect.Type.GIVE_ITEM, giveItem);
|
||||
}
|
||||
if (removeItem != null) {
|
||||
return new Effect(Effect.Type.REMOVE_ITEM, removeItem);
|
||||
}
|
||||
if (say != null) {
|
||||
return new Effect(Effect.Type.SAY, say);
|
||||
}
|
||||
if (startQuest != null) {
|
||||
return new Effect(Effect.Type.START_QUEST, startQuest);
|
||||
}
|
||||
throw new WorldLoadException(
|
||||
"Effect must set one of setFlag/clearFlag/giveItem/removeItem/say/startQuest");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a list of DTOs into domain effects.
|
||||
*
|
||||
* @param dtos the DTOs to convert; null is treated as empty
|
||||
* @return the converted effects, never null
|
||||
* @throws WorldLoadException if any DTO sets none of its fields
|
||||
*/
|
||||
public static List<Effect> toModelList(List<EffectDto> dtos) {
|
||||
List<Effect> out = new ArrayList<>();
|
||||
if (dtos != null) {
|
||||
for (EffectDto d : dtos) {
|
||||
out.add(d.toModel());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML representation of a single item. Fields outside of the type's
|
||||
* scope (e.g. {@code readText} on a switchable) are simply ignored.
|
||||
@@ -12,6 +14,9 @@ package thb.jeanluc.adventure.loader.dto;
|
||||
* @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
|
||||
* @param effects effects applied when a switchable transitions to on; nullable
|
||||
* @param light whether this item is a light source; nullable
|
||||
* @param fixed whether this item is scenery that cannot be taken; nullable
|
||||
*/
|
||||
public record ItemDto(
|
||||
String type,
|
||||
@@ -21,6 +26,26 @@ public record ItemDto(
|
||||
String readText,
|
||||
Boolean initialState,
|
||||
String onText,
|
||||
String offText
|
||||
String offText,
|
||||
List<EffectDto> effects,
|
||||
Boolean light,
|
||||
Boolean fixed
|
||||
) {
|
||||
/**
|
||||
* Backward-compatible constructor without effects/light/fixed, which default to null.
|
||||
*
|
||||
* @param type discriminator: {@code plain | readable | switchable}
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description examine description
|
||||
* @param readText text printed when a readable item is used
|
||||
* @param initialState initial on/off state of a switchable item
|
||||
* @param onText message printed when a switchable transitions to on
|
||||
* @param offText message printed when a switchable transitions to off
|
||||
*/
|
||||
public ItemDto(String type, String id, String name, String description,
|
||||
String readText, Boolean initialState, String onText, String offText) {
|
||||
this(type, id, name, description, readText, initialState, onText, offText,
|
||||
null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,26 @@ import java.util.List;
|
||||
* @param description examine description
|
||||
* @param greeting text the NPC says on {@code talk}
|
||||
* @param reactions list of trigger/response definitions; may be null
|
||||
* @param dialogue optional condition-gated dialogue lines; may be null
|
||||
*/
|
||||
public record NpcDto(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String greeting,
|
||||
List<ReactionDto> reactions
|
||||
List<ReactionDto> reactions,
|
||||
List<DialogueLineDto> dialogue
|
||||
) {
|
||||
/**
|
||||
* Backward-compatible constructor without dialogue, which defaults to null.
|
||||
*
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description examine description
|
||||
* @param greeting text the NPC says on {@code talk}
|
||||
* @param reactions list of trigger/response definitions; may be null
|
||||
*/
|
||||
public NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) {
|
||||
this(id, name, description, greeting, reactions, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML representation of a quest.
|
||||
*
|
||||
* @param id unique slug
|
||||
* @param title display title
|
||||
* @param autoStart whether the quest starts with the game; defaults to false if null
|
||||
* @param stages ordered stages the player works through
|
||||
* @param onComplete effects applied once the last stage is done; nullable
|
||||
*/
|
||||
public record QuestDto(String id, String title, Boolean autoStart,
|
||||
List<QuestStageDto> stages, List<EffectDto> onComplete) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML representation of a single NPC reaction.
|
||||
*
|
||||
@@ -7,11 +9,26 @@ package thb.jeanluc.adventure.loader.dto;
|
||||
* @param response text the NPC says after the exchange
|
||||
* @param gives id of the item handed back; nullable
|
||||
* @param consumes id of the item taken from the player; nullable
|
||||
* @param requires conditions that must hold; nullable
|
||||
* @param effects effects applied after a successful exchange; nullable
|
||||
*/
|
||||
public record ReactionDto(
|
||||
String onReceive,
|
||||
String response,
|
||||
String gives,
|
||||
String consumes
|
||||
String consumes,
|
||||
List<ConditionDto> requires,
|
||||
List<EffectDto> effects
|
||||
) {
|
||||
/**
|
||||
* Backward-compatible constructor without requires/effects, which default to null.
|
||||
*
|
||||
* @param onReceive id of the item that triggers this reaction
|
||||
* @param response text the NPC says after the exchange
|
||||
* @param gives id of the item handed back; nullable
|
||||
* @param consumes id of the item taken from the player; nullable
|
||||
*/
|
||||
public ReactionDto(String onReceive, String response, String gives, String consumes) {
|
||||
this(onReceive, response, gives, consumes, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import java.util.Map;
|
||||
* @param exits direction-name to target-room-id map
|
||||
* @param items ids of items initially in this room
|
||||
* @param npcs ids of NPCs initially in this room
|
||||
* @param exitLocks optional condition-gates per exit direction
|
||||
* @param descriptionStates optional condition-gated description variants
|
||||
* @param dark whether this room is dark; nullable
|
||||
* @param music optional GUI background-music track filename; null = none
|
||||
*/
|
||||
public record RoomDto(
|
||||
String id,
|
||||
@@ -19,6 +23,24 @@ public record RoomDto(
|
||||
String description,
|
||||
Map<String, String> exits,
|
||||
List<String> items,
|
||||
List<String> npcs
|
||||
List<String> npcs,
|
||||
List<ExitLockDto> exitLocks,
|
||||
List<DescriptionStateDto> descriptionStates,
|
||||
Boolean dark,
|
||||
String music
|
||||
) {
|
||||
/**
|
||||
* Backward-compatible constructor without the optional state fields, which default to null.
|
||||
*
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description description shown by {@code look}
|
||||
* @param exits direction-name to target-room-id map
|
||||
* @param items ids of items initially in this room
|
||||
* @param npcs ids of NPCs initially in this room
|
||||
*/
|
||||
public RoomDto(String id, String name, String description,
|
||||
Map<String, String> exits, List<String> items, List<String> npcs) {
|
||||
this(id, name, description, exits, items, npcs, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML representation of the tutorial document.
|
||||
*
|
||||
* @param intro text shown before the first step
|
||||
* @param steps ordered tutorial steps
|
||||
* @param closingTips text shown after the last step
|
||||
*/
|
||||
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
/**
|
||||
* YAML representation of a tutorial step.
|
||||
*
|
||||
* @param instruction text telling the player what to type
|
||||
* @param expect command verb the step accepts
|
||||
* @param minArgs minimum number of arguments the command must carry; 0 if null
|
||||
* @param confirm text printed once the step is passed
|
||||
* @param hint text printed when the player types something else
|
||||
*/
|
||||
public record TutorialStepDto(
|
||||
String instruction,
|
||||
String expect,
|
||||
Integer minArgs,
|
||||
String confirm,
|
||||
String hint
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package thb.jeanluc.adventure.menu;
|
||||
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */
|
||||
public final class MainMenu {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private MainMenu() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the main menu and returns the chosen action.
|
||||
*
|
||||
* @param io frontend to render the menu on
|
||||
* @return the action the player picked
|
||||
*/
|
||||
public static MenuAction show(GameIO io) {
|
||||
int i = io.choose("HAUNTED MANOR",
|
||||
List.of("New Game", "Load Game", "Settings", "Quit"));
|
||||
return MenuAction.values()[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for a new save name; blank input yields {@code defaultName}.
|
||||
*
|
||||
* @param io frontend to prompt on
|
||||
* @param defaultName name used when the player enters nothing
|
||||
* @return the trimmed save name
|
||||
*/
|
||||
public static String promptName(GameIO io, String defaultName) {
|
||||
io.write("Name your save (Enter for \"" + defaultName + "\"):");
|
||||
String line = io.readLine();
|
||||
return (line == null || line.isBlank()) ? defaultName : line.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the player pick a save slot to load. Returns the chosen
|
||||
* {@link SaveSlotInfo}, or {@code null} if there are none or the player
|
||||
* picks "Back".
|
||||
*
|
||||
* @param io frontend to render the slot list on
|
||||
* @param saves source of the available slots
|
||||
* @return the selected slot, or {@code null} if nothing was selected
|
||||
*/
|
||||
public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) {
|
||||
List<SaveSlotInfo> slots = saves.list();
|
||||
if (slots.isEmpty()) {
|
||||
io.write("No saved games.");
|
||||
return null;
|
||||
}
|
||||
List<String> labels = new ArrayList<>();
|
||||
for (SaveSlotInfo s : slots) {
|
||||
labels.add(s.slotName() + " (" + s.roomId() + ", turn " + s.turn() + ")");
|
||||
}
|
||||
labels.add("Back");
|
||||
int i = io.choose("LOAD GAME", labels);
|
||||
return i < slots.size() ? slots.get(i) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package thb.jeanluc.adventure.menu;
|
||||
|
||||
/** Top-level main-menu choices, in display order. */
|
||||
public enum MenuAction {
|
||||
|
||||
/** Start a new game in a freshly named save slot. */
|
||||
NEW_GAME,
|
||||
|
||||
/** Continue from an existing save slot. */
|
||||
LOAD,
|
||||
|
||||
/** Open the settings screen. */
|
||||
SETTINGS,
|
||||
|
||||
/** Leave the application. */
|
||||
QUIT
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package thb.jeanluc.adventure.menu;
|
||||
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.save.Settings;
|
||||
import thb.jeanluc.adventure.save.SettingsStore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
|
||||
public final class SettingsMenu {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private SettingsMenu() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loops the settings screen until "Back", persisting + applying each change.
|
||||
*
|
||||
* @param io frontend to render the screen on
|
||||
* @param current settings the screen starts from
|
||||
* @param store destination the changed settings are written to
|
||||
* @return the settings in effect when the player left the screen
|
||||
*/
|
||||
public static Settings show(GameIO io, Settings current, SettingsStore store) {
|
||||
Settings s = current;
|
||||
while (true) {
|
||||
int i = io.choose("SETTINGS", List.of(
|
||||
"Colour: " + s.colorMode(),
|
||||
"Glyphs: " + s.glyphMode(),
|
||||
"Music: " + s.musicLevel(),
|
||||
"Back"));
|
||||
if (i == 0) {
|
||||
s = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
|
||||
} else if (i == 1) {
|
||||
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()), s.musicLevel());
|
||||
} else if (i == 2) {
|
||||
s = new Settings(s.colorMode(), s.glyphMode(), s.musicLevel().next());
|
||||
} else {
|
||||
return s;
|
||||
}
|
||||
store.save(s);
|
||||
apply(s, io);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies settings to the IO: music level always; colour/glyph only on the console.
|
||||
*
|
||||
* @param s settings to apply
|
||||
* @param io frontend the settings are applied to
|
||||
*/
|
||||
public static void apply(Settings s, GameIO io) {
|
||||
io.setMusicLevel(s.musicLevel());
|
||||
if (io instanceof ConsoleIO c) {
|
||||
c.setColorMode(s.colorMode());
|
||||
c.setGlyphMode(s.glyphMode());
|
||||
}
|
||||
}
|
||||
|
||||
/** Next colour mode in the cycle AUTO {@literal ->} ON {@literal ->} OFF. */
|
||||
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
|
||||
return switch (m) {
|
||||
case AUTO -> ConsoleIO.ColorMode.ON;
|
||||
case ON -> ConsoleIO.ColorMode.OFF;
|
||||
case OFF -> ConsoleIO.ColorMode.AUTO;
|
||||
};
|
||||
}
|
||||
|
||||
/** Next glyph mode in the cycle UNICODE {@literal ->} ASCII {@literal ->} GLYPH. */
|
||||
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
|
||||
return switch (m) {
|
||||
case UNICODE -> ConsoleIO.GlyphMode.ASCII;
|
||||
case ASCII -> ConsoleIO.GlyphMode.GLYPH;
|
||||
case GLYPH -> ConsoleIO.GlyphMode.UNICODE;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A data-driven item-combination recipe. Identified by an unordered pair of
|
||||
* item ids ({@code a}, {@code b}). When applied it may require conditions,
|
||||
* consume items, produce an item, apply effects, and print a response.
|
||||
*
|
||||
* @param a id of the first item of the pair
|
||||
* @param b id of the second item of the pair
|
||||
* @param requires conditions that must hold for the recipe to apply; empty = always
|
||||
* @param consume ids of the items removed from the player's inventory on success
|
||||
* @param produce id of the item handed to the player on success; nullable
|
||||
* @param effects effects applied on success
|
||||
* @param response text written on success
|
||||
* @param failText text written when {@code requires} does not hold; nullable
|
||||
*/
|
||||
public record Combination(
|
||||
String a,
|
||||
String b,
|
||||
List<Condition> requires,
|
||||
List<String> consume,
|
||||
String produce,
|
||||
List<Effect> effects,
|
||||
String response,
|
||||
String failText
|
||||
) {
|
||||
/** Normalises the list components to immutable, non-null lists. */
|
||||
public Combination {
|
||||
requires = requires == null ? List.of() : List.copyOf(requires);
|
||||
consume = consume == null ? List.of() : List.copyOf(consume);
|
||||
effects = effects == null ? List.of() : List.copyOf(effects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Order-independent key for an item pair (sorted, '|'-joined).
|
||||
*
|
||||
* @param x id of one item
|
||||
* @param y id of the other item
|
||||
* @return the canonical key identifying the pair regardless of argument order
|
||||
*/
|
||||
public static String key(String x, String y) {
|
||||
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
|
||||
}
|
||||
|
||||
/**
|
||||
* This recipe's canonical pair key.
|
||||
*
|
||||
* @return the key under which the recipe is registered in the world
|
||||
*/
|
||||
public String key() {
|
||||
return key(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
/**
|
||||
* A single boolean test evaluated against world state and the player.
|
||||
*
|
||||
* @param type the kind of test to perform
|
||||
* @param arg the operand the test applies to: a flag name for {@link Type#FLAG}
|
||||
* and {@link Type#NOT_FLAG}, an item id for {@link Type#HAS_ITEM}
|
||||
*/
|
||||
public record Condition(Type type, String arg) {
|
||||
|
||||
/** The kinds of test a {@link Condition} can perform. */
|
||||
public enum Type {
|
||||
/** Holds while the named flag is set in the world state. */
|
||||
FLAG,
|
||||
/** Holds while the named flag is not set in the world state. */
|
||||
NOT_FLAG,
|
||||
/** Holds while the player carries the named item. */
|
||||
HAS_ITEM
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An alternate room description shown while its conditions hold.
|
||||
*
|
||||
* @param when conditions that must all hold for this variant to be selected; empty = always
|
||||
* @param text description text shown instead of the room's default description
|
||||
*/
|
||||
public record DescriptionState(List<Condition> when, String text) {
|
||||
/** Normalises {@code when} to an immutable, non-null list. */
|
||||
public DescriptionState {
|
||||
when = when == null ? List.of() : List.copyOf(when);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A conditional line an NPC says on {@code talk}, first match wins.
|
||||
*
|
||||
* @param when conditions that must all hold for this line to be selected; empty = always
|
||||
* @param text text the NPC says when this line is selected
|
||||
*/
|
||||
public record DialogueLine(List<Condition> when, String text) {
|
||||
/** Normalises {@code when} to an immutable, non-null list. */
|
||||
public DialogueLine {
|
||||
when = when == null ? List.of() : List.copyOf(when);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user