Compare commits
121 Commits
92005c72f5
...
f8f7eb90f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
16
Semesterprojekt/.gitignore
vendored
16
Semesterprojekt/.gitignore
vendored
@@ -36,4 +36,18 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store.superpowers/
|
.DS_Store
|
||||||
|
|
||||||
|
### Brainstorming visual companion ###
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# Planning specs & implementation plans (kept locally, not tracked)
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
|||||||
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
|
||||||
@@ -20,25 +20,43 @@ werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
|
|||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Java 25+
|
- Java 25+
|
||||||
- Maven 3.9+
|
- Maven 3.9+ **oder** der mitgelieferte Maven Wrapper (`./mvnw`) – damit ist
|
||||||
|
keine separate Maven-Installation nötig; der Wrapper lädt die passende
|
||||||
|
Maven-Version beim ersten Aufruf selbst herunter.
|
||||||
|
|
||||||
|
## Schnellstart ohne Maven
|
||||||
|
|
||||||
|
Im Ordner [`release/`](release/) liegt eine fertig gebaute, eigenständige JAR
|
||||||
|
(`HauntedManor.jar`) mit allen Abhängigkeiten. Sie braucht nur Java 25:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar release/HauntedManor.jar # Konsolen-Version
|
||||||
|
java -cp release/HauntedManor.jar thb.jeanluc.adventure.AppGui # Swing-GUI
|
||||||
|
```
|
||||||
|
|
||||||
## Bauen und Testen
|
## Bauen und Testen
|
||||||
|
|
||||||
|
Mit dem Wrapper (empfohlen, keine Maven-Installation nötig) – unter Windows
|
||||||
|
`mvnw.cmd` statt `./mvnw`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn clean test # alle Tests ausführen (67 Tests)
|
./mvnw clean test # alle Tests ausführen (233 Tests)
|
||||||
mvn clean package # JAR bauen
|
./mvnw clean package # baut die eigenständige JAR nach release/HauntedManor.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ein systemweit installiertes `mvn` funktioniert identisch (`mvn clean test` …).
|
||||||
|
|
||||||
## Spielen
|
## Spielen
|
||||||
|
|
||||||
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
|
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
|
./mvnw exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
|
||||||
mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
./mvnw exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
||||||
```
|
```
|
||||||
|
|
||||||
`mvn exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
|
`./mvnw exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
|
||||||
|
Alternativ direkt aus der gebauten JAR (siehe [Schnellstart](#schnellstart-ohne-maven)).
|
||||||
|
|
||||||
### Befehle im Spiel
|
### Befehle im Spiel
|
||||||
|
|
||||||
@@ -46,6 +64,8 @@ mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
|
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
|
||||||
| `look` | `l` | Aktuellen Raum beschreiben |
|
| `look` | `l` | Aktuellen Raum beschreiben |
|
||||||
|
| `map` | `m` | Karte der erkundeten Räume anzeigen |
|
||||||
|
| `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen |
|
||||||
| `inventory` | `inv`, `i` | Inventar anzeigen |
|
| `inventory` | `inv`, `i` | Inventar anzeigen |
|
||||||
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
|
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
|
||||||
| `drop <item>` | `put` | Gegenstand ablegen |
|
| `drop <item>` | `put` | Gegenstand ablegen |
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ alle Phasen 1–7 grün. Ziel: deutlich mehr Tiefe und Politur.
|
|||||||
|
|
||||||
## Themenblöcke
|
## Themenblöcke
|
||||||
|
|
||||||
### 1. Reichere Präsentation / Ausgabe-Schicht
|
### 1. Reichere Präsentation / Ausgabe-Schicht ✅ umgesetzt (Branch `feature/presentation-layer`)
|
||||||
|
|
||||||
|
> Umgesetzt: semantisches Modell (`io.text`), `ConsoleIO` (ANSI + Box-Drawing,
|
||||||
|
> Color/Glyph-Modi), `SwingIO` (4 Regionen, `JTextPane`, gebündelter Font,
|
||||||
|
> HiDPI-Skalierung + Zoom `Strg +/-/0`), HUD pro Zug, Welcome-Banner. 79 Tests grün.
|
||||||
|
> Spec/Plan unter `docs/superpowers/`. Karte/Menü/Save/Musik weiterhin offen.
|
||||||
|
|
||||||
|
|
||||||
- Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner,
|
- Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner,
|
||||||
Hervorhebungen für Items/NPCs/Richtungen).
|
Hervorhebungen für Items/NPCs/Richtungen).
|
||||||
@@ -26,6 +32,19 @@ alle Phasen 1–7 grün. Ziel: deutlich mehr Tiefe und Politur.
|
|||||||
- **GUI (Swing)**: mehr als ein `JTextArea` – Farben, Stile, evtl. Panels,
|
- **GUI (Swing)**: mehr als ein `JTextArea` – Farben, Stile, evtl. Panels,
|
||||||
Statusanzeige, Bilder/Icons.
|
Statusanzeige, Bilder/Icons.
|
||||||
|
|
||||||
|
**Festgelegt (Zielbild Konsole):** Stufe **B als Baseline** (Boxed Heading,
|
||||||
|
gelabelte Sektionen, persistente HUD-Zeile: Ort · Gold · Zug · Licht), **ASCII-Art
|
||||||
|
nur für große Momente** (Spielstart, Finale, Schlüsselräume). Semantische
|
||||||
|
Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
|
||||||
|
|
||||||
|
**Designprinzipien:**
|
||||||
|
- **Nicht überladen / Progressive Disclosure** – Raumeintritt zeigt nur das
|
||||||
|
Wesentliche (Ort, kurze Beschreibung, Auffälliges); Details auf Abruf
|
||||||
|
(`examine`, `look`).
|
||||||
|
- **Info auf Regionen verteilen statt Text stapeln** – die **Karte ersetzt die
|
||||||
|
„Exits:"-Zeile**. GUI: persistentes Map-Panel macht die Exit-Zeile überflüssig.
|
||||||
|
Konsole: kompakte Mini-Map (bei `map` bzw. knapp beim Raumeintritt).
|
||||||
|
|
||||||
### 2. Nerd Fonts / Glyphen – das Installationsproblem
|
### 2. Nerd Fonts / Glyphen – das Installationsproblem
|
||||||
|
|
||||||
- Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des
|
- Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des
|
||||||
@@ -37,7 +56,14 @@ alle Phasen 1–7 grün. Ziel: deutlich mehr Tiefe und Politur.
|
|||||||
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
|
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
|
||||||
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
|
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
|
||||||
|
|
||||||
### 3. Karte / Map
|
### 3. Karte / Map ✅ umgesetzt (Branch `feature/map`)
|
||||||
|
|
||||||
|
> Umgesetzt: `MapLayout` (BFS-Gitter-Layout + Fog of War), `MapView`-Modell,
|
||||||
|
> GUI-`MapPanel` (Graphics2D, ersetzt die Exit-Liste), Konsolen-ASCII via
|
||||||
|
> `AsciiMap` + `map`-Befehl, Besuchte-Räume-Tracking auf `Player`, Map-Push pro Zug.
|
||||||
|
> Item/NPC-Marker und Türen-Styling offen (Quest-Teilprojekt). Spec/Plan unter
|
||||||
|
> `docs/superpowers/`.
|
||||||
|
|
||||||
|
|
||||||
- Übersichtskarte der Räume – in **beiden** Modi.
|
- Übersichtskarte der Räume – in **beiden** Modi.
|
||||||
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
|
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
|
||||||
@@ -52,10 +78,31 @@ alle Phasen 1–7 grün. Ziel: deutlich mehr Tiefe und Politur.
|
|||||||
|
|
||||||
### 5. Quest-System (NPC-Ausbau)
|
### 5. Quest-System (NPC-Ausbau)
|
||||||
|
|
||||||
|
> ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags +
|
||||||
|
> Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen,
|
||||||
|
> bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Demo:
|
||||||
|
> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet.
|
||||||
|
>
|
||||||
|
> ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests
|
||||||
|
> (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt,
|
||||||
|
> Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest
|
||||||
|
> `restore_power`.
|
||||||
|
>
|
||||||
|
> ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven
|
||||||
|
> Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang).
|
||||||
|
> Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`).
|
||||||
|
> **Damit ist das Quest-System (#3) komplett.** Offen: Licht/Dunkelheit &
|
||||||
|
> Item-Kombination sowie #4 Content-Ausbau.
|
||||||
|
|
||||||
|
|
||||||
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
|
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
|
||||||
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
|
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
|
||||||
Quest-Log, evtl. Win-Condition daran gekoppelt.
|
Quest-Log, evtl. Win-Condition daran gekoppelt.
|
||||||
|
|
||||||
|
**GUI-Layout (festgelegt):** Quest-Log als **dauerhaft sichtbare Box unterhalb der
|
||||||
|
Karte** im rechten Seitenbereich (Map oben, Quests darunter). Gehört zu Quest-
|
||||||
|
Teilprojekt #2 (Quest-Log); das Fundament #1 reserviert/plant den Slot.
|
||||||
|
|
||||||
### 5b. Spielkern: zentraler Bogen + Mechanik-Spine
|
### 5b. Spielkern: zentraler Bogen + Mechanik-Spine
|
||||||
|
|
||||||
**Leitidee (bestätigt):** Bewusst *kondensiert* – wenige Mechaniken, die sich
|
**Leitidee (bestätigt):** Bewusst *kondensiert* – wenige Mechaniken, die sich
|
||||||
@@ -75,9 +122,19 @@ des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei.
|
|||||||
Hebel/Ventile setzen Flags und öffnen Bereiche.
|
Hebel/Ventile setzen Flags und öffnen Bereiche.
|
||||||
3. **Licht & Dunkelheit** – dunkle Räume brauchen eine brennende Lichtquelle,
|
3. **Licht & Dunkelheit** – dunkle Räume brauchen eine brennende Lichtquelle,
|
||||||
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
|
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
|
||||||
|
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
|
||||||
|
> `Light`-Helper, Gating in go/look/take/examine (Modell B: kein Eintritt ohne
|
||||||
|
> Licht), HUD-Licht verdrahtet. Demo: dunkler Dungeon braucht die brennende
|
||||||
|
> Lampe für den Generator.
|
||||||
4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat
|
4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat
|
||||||
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
||||||
werden (jetzt erlaubt, da über MVP hinaus).
|
werden (jetzt erlaubt, da über MVP hinaus).
|
||||||
|
> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene
|
||||||
|
> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText),
|
||||||
|
> Operanden aus Inventar oder Raum, reihenfolge-unabhängiger Paar-Key. Kein
|
||||||
|
> Parser-Eingriff (`on`/`with`/`to` sind bereits Filler). Selbst-Kombination
|
||||||
|
> (a==b) wird beim Laden abgelehnt. Demo: `matches` + `torch` → `lit_torch`
|
||||||
|
> (dauerhafte Lichtquelle).
|
||||||
5. **Quest-Ketten + mehrere Enden** – Teilziele referenzieren Flags; Kette
|
5. **Quest-Ketten + mehrere Enden** – Teilziele referenzieren Flags; Kette
|
||||||
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
|
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
|
||||||
|
|
||||||
@@ -93,6 +150,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
|||||||
|
|
||||||
### 6. Hauptmenü & Settings
|
### 6. Hauptmenü & Settings
|
||||||
|
|
||||||
|
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). Hauptmenü-Shell oberhalb
|
||||||
|
> des Game-Loops (Neues Spiel / Laden / Einstellungen / Beenden) über neue
|
||||||
|
> `GameIO.choose`-Primitive (Konsole: nummeriertes Menü; GUI: Buttons).
|
||||||
|
> **Minimale Settings**: nur Farb-Modus + Glyphen-Modus (ASCII/Unicode), live
|
||||||
|
> umschaltbar, in `saves/settings.json` persistiert. Musik-/Typewriter-Toggles
|
||||||
|
> weiter zurückgestellt (Features existieren noch nicht).
|
||||||
|
|
||||||
- **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*.
|
- **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*.
|
||||||
- **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl,
|
- **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl,
|
||||||
Zeitstempel) anzeigen und hineinladen.
|
Zeitstempel) anzeigen und hineinladen.
|
||||||
@@ -103,12 +167,27 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
|||||||
|
|
||||||
### 7. Speichern / Laden
|
### 7. Speichern / Laden
|
||||||
|
|
||||||
|
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). JSON-Spielstand als
|
||||||
|
> Delta über die frisch aus YAML geladene Welt (`SaveData`/`SaveCodec`/
|
||||||
|
> `SaveService`, atomare Writes). **Ein aktiver Slot**: manuelles `save`,
|
||||||
|
> Autosave (Quest-Completion + alle 10 Züge) und `quit`/`menu` (speichern +
|
||||||
|
> zurück ins Menü) schreiben denselben Slot; Laden nur über das Menü.
|
||||||
|
> `saves/` ist gitignored.
|
||||||
|
|
||||||
- Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume,
|
- Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume,
|
||||||
Quest-Fortschritt) – Format z.B. YAML/JSON.
|
Quest-Fortschritt) – Format z.B. YAML/JSON.
|
||||||
- Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü.
|
- Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü.
|
||||||
|
|
||||||
### 8. Musik (GUI)
|
### 8. Musik (GUI)
|
||||||
|
|
||||||
|
> ✅ umgesetzt (Branch `feature/music`): OGG Vorbis (vorbisspi/jorbis), pro Raum
|
||||||
|
> via `music:`-Feld in `rooms.yaml`, externer gitignored `music/`-Ordner (nicht im
|
||||||
|
> JAR). Idempotenter Track-Wechsel mit ~400 ms Fades; Räume ohne `music` blenden zu
|
||||||
|
> Stille; gleicher Track läuft nahtlos weiter. Music-Stufe Off/Low/Med/High in den
|
||||||
|
> Settings (Default Medium), persistiert. Konsole stumm. Testbarer `MusicController`
|
||||||
|
> über isoliertes `OggMusicBackend`; fehlende Datei → still, kein Absturz. Siehe
|
||||||
|
> `docs/music.md`.
|
||||||
|
|
||||||
- Hintergrundmusik **nur in der GUI**.
|
- Hintergrundmusik **nur in der GUI**.
|
||||||
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
|
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
|
||||||
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
|
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
|
||||||
@@ -124,6 +203,19 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
|||||||
|
|
||||||
## Festgelegte Erweiterungs-Entscheidungen
|
## Festgelegte Erweiterungs-Entscheidungen
|
||||||
|
|
||||||
|
> ✅ **Pathfinding** umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>`
|
||||||
|
> per BFS über besuchte Räume (Locks + Licht beachtet), via `GoCommand`
|
||||||
|
> (Richtung vs. Raumziel). Konsole still + Zusammenfassung (Start/Mitte/Ziel),
|
||||||
|
> GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms).
|
||||||
|
> **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt.
|
||||||
|
|
||||||
|
> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough
|
||||||
|
> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte
|
||||||
|
> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel),
|
||||||
|
> Abschluss-Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`,
|
||||||
|
> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem
|
||||||
|
> Spiel, nicht beim Laden.
|
||||||
|
|
||||||
| Entscheidung | Wert |
|
| Entscheidung | Wert |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |
|
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |
|
||||||
|
|||||||
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"
|
||||||
@@ -49,6 +49,18 @@
|
|||||||
<version>${logback.version}</version>
|
<version>${logback.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OGG Vorbis decoding (GUI background music) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>vorbisspi</artifactId>
|
||||||
|
<version>1.0.3.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>jorbis</artifactId>
|
||||||
|
<version>0.0.17.4</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
@@ -97,6 +109,36 @@
|
|||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>3.5.2</version>
|
<version>3.5.2</version>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!-- Self-contained, runnable JAR with all dependencies bundled. -->
|
||||||
|
<outputFile>${project.basedir}/release/HauntedManor.jar</outputFile>
|
||||||
|
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||||
|
<transformers>
|
||||||
|
<!-- Default entry point: console version. GUI via -cp ... AppGui. -->
|
||||||
|
<transformer
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<manifestEntries>
|
||||||
|
<Main-Class>thb.jeanluc.adventure.App</Main-Class>
|
||||||
|
</manifestEntries>
|
||||||
|
</transformer>
|
||||||
|
<!-- Merge META-INF/services so the OGG Vorbis SPI keeps working. -->
|
||||||
|
<transformer
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>exec-maven-plugin</artifactId>
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
|||||||
BIN
Semesterprojekt/release/HauntedManor.jar
Normal file
BIN
Semesterprojekt/release/HauntedManor.jar
Normal file
Binary file not shown.
@@ -10,21 +10,39 @@ import thb.jeanluc.adventure.command.impl.GoCommand;
|
|||||||
import thb.jeanluc.adventure.command.impl.HelpCommand;
|
import thb.jeanluc.adventure.command.impl.HelpCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.LookCommand;
|
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.ReadCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.SaveCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.TalkCommand;
|
import thb.jeanluc.adventure.command.impl.TalkCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.UseCommand;
|
import thb.jeanluc.adventure.command.impl.UseCommand;
|
||||||
import thb.jeanluc.adventure.game.Game;
|
import thb.jeanluc.adventure.game.Game;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.GameSession;
|
||||||
|
import thb.jeanluc.adventure.game.TutorialGuide;
|
||||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
import thb.jeanluc.adventure.io.GameIO;
|
import thb.jeanluc.adventure.io.GameIO;
|
||||||
|
import thb.jeanluc.adventure.io.text.Banner;
|
||||||
|
import thb.jeanluc.adventure.loader.TutorialLoader;
|
||||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||||
|
import thb.jeanluc.adventure.menu.MainMenu;
|
||||||
|
import thb.jeanluc.adventure.menu.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
|
* Console entry point. Loads settings, then runs the main-menu shell loop:
|
||||||
* YAML, wires up the command registry, and hands control to the
|
* New Game / Load / Settings / Quit. Each game runs the shared {@link Game}
|
||||||
* {@link Game} loop.
|
* 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
|
@Slf4j
|
||||||
public final class App {
|
public final class App {
|
||||||
@@ -32,11 +50,7 @@ public final class App {
|
|||||||
private App() {
|
private App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Standard JVM entry point. @param args ignored */
|
||||||
* Standard JVM entry point.
|
|
||||||
*
|
|
||||||
* @param args ignored
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
GameIO io = new ConsoleIO();
|
GameIO io = new ConsoleIO();
|
||||||
try {
|
try {
|
||||||
@@ -48,15 +62,55 @@ public final class App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Runs the menu shell on the given IO until the player quits. */
|
||||||
* Boots the game on the given IO channel. Reusable by both
|
|
||||||
* {@link App} (console) and {@code AppGui} (Swing worker thread).
|
|
||||||
*
|
|
||||||
* @param io the IO channel to use
|
|
||||||
*/
|
|
||||||
public static void run(GameIO io) {
|
public static void run(GameIO io) {
|
||||||
|
run(io, new SaveService(), new SettingsStore());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the menu shell with injectable persistence (for testing). */
|
||||||
|
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();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
CommandRegistry registry = new CommandRegistry();
|
CommandRegistry registry = new CommandRegistry();
|
||||||
registry.register(new GoCommand(), "go", "move", "walk");
|
registry.register(new GoCommand(), "go", "move", "walk");
|
||||||
@@ -67,20 +121,31 @@ public final class App {
|
|||||||
registry.register(new UseCommand(), "use");
|
registry.register(new UseCommand(), "use");
|
||||||
registry.register(new ReadCommand(), "read");
|
registry.register(new ReadCommand(), "read");
|
||||||
registry.register(new ExamineCommand(), "examine", "x", "inspect");
|
registry.register(new ExamineCommand(), "examine", "x", "inspect");
|
||||||
|
registry.register(new MapCommand(), "map", "m");
|
||||||
|
registry.register(new QuestsCommand(), "quests", "log", "journal");
|
||||||
registry.register(new TalkCommand(), "talk", "speak");
|
registry.register(new TalkCommand(), "talk", "speak");
|
||||||
registry.register(new GiveCommand(), "give");
|
registry.register(new GiveCommand(), "give");
|
||||||
registry.register(new HelpCommand(registry), "help", "?");
|
registry.register(new SaveCommand(saves), "save");
|
||||||
|
|
||||||
QuitCommand quit = new QuitCommand();
|
|
||||||
registry.register(quit, "quit", "exit");
|
|
||||||
|
|
||||||
Game game = new Game(ctx, registry, new CommandParser());
|
Game game = new Game(ctx, registry, new CommandParser());
|
||||||
quit.bind(game);
|
|
||||||
|
|
||||||
io.write(loaded.world().getTitle());
|
MenuCommand menu = new MenuCommand(saves);
|
||||||
io.write(loaded.world().getWelcomeMessage());
|
menu.bind(game);
|
||||||
new LookCommand().execute(ctx, java.util.List.of());
|
registry.register(menu, "menu", "quit", "exit");
|
||||||
|
registry.register(new HelpCommand(registry), "help", "?");
|
||||||
|
|
||||||
|
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();
|
game.run();
|
||||||
|
io.setMusic(null); // fade music out when returning to the menu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ public class ExamineCommand implements Command {
|
|||||||
ctx.getIo().write("Examine what?");
|
ctx.getIo().write("Examine what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||||
|
ctx.getIo().write("It's too dark to see that.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
String id = args.getFirst();
|
String id = args.getFirst();
|
||||||
Optional<Item> item = ctx.getPlayer().findItem(id);
|
Optional<Item> item = ctx.getPlayer().findItem(id);
|
||||||
if (item.isEmpty()) {
|
if (item.isEmpty()) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
|
import thb.jeanluc.adventure.game.Effects;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.NpcReaction;
|
import thb.jeanluc.adventure.model.NpcReaction;
|
||||||
@@ -44,12 +46,17 @@ public class GiveCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NpcReaction reaction = reactionOpt.get();
|
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) {
|
if (reaction.getConsumes() != null) {
|
||||||
ctx.getPlayer().removeItem(reaction.getConsumes().getId());
|
ctx.getPlayer().removeItem(reaction.getConsumes().getId());
|
||||||
}
|
}
|
||||||
if (reaction.getGives() != null) {
|
if (reaction.getGives() != null) {
|
||||||
ctx.getPlayer().addItem(reaction.getGives());
|
ctx.getPlayer().addItem(reaction.getGives());
|
||||||
}
|
}
|
||||||
|
Effects.applyAll(reaction.getEffects(), ctx);
|
||||||
ctx.getIo().write(npc.getName() + ": " + reaction.getResponse());
|
ctx.getIo().write(npc.getName() + ": " + reaction.getResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,128 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
import thb.jeanluc.adventure.game.Pathfinder;
|
||||||
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
import thb.jeanluc.adventure.model.Direction;
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves the player into the room reachable in a given direction.
|
* Moves the player. {@code go <direction>} steps once to a connected room.
|
||||||
* Usage: {@code go <direction>}.
|
* {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
|
||||||
|
* rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
|
||||||
|
* {@code go to <room>}. The parser strips the filler word "to", so "go to
|
||||||
|
* <room>" and "go <room>" are equivalent.
|
||||||
*/
|
*/
|
||||||
public class GoCommand implements Command {
|
public class GoCommand implements Command {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void execute(GameContext ctx, List<String> args) {
|
public void execute(GameContext ctx, List<String> args) {
|
||||||
if (args.isEmpty()) {
|
if (args.isEmpty()) {
|
||||||
ctx.getIo().write("Go where? Try 'go north'.");
|
ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Direction dir;
|
|
||||||
try {
|
try {
|
||||||
dir = Direction.fromString(args.getFirst());
|
Direction dir = Direction.fromString(args.getFirst());
|
||||||
} catch (IllegalArgumentException e) {
|
moveDirection(ctx, dir);
|
||||||
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is.");
|
} catch (IllegalArgumentException notADirection) {
|
||||||
return;
|
goToRoom(ctx, args);
|
||||||
}
|
}
|
||||||
Optional<Room> next = ctx.getPlayer().getCurrentRoom().getExit(dir);
|
}
|
||||||
|
|
||||||
|
/** Single-step move in a compass direction (the original behaviour). */
|
||||||
|
private void moveDirection(GameContext ctx, Direction dir) {
|
||||||
|
Room here = ctx.getPlayer().getCurrentRoom();
|
||||||
|
Optional<Room> next = here.getExit(dir);
|
||||||
if (next.isEmpty()) {
|
if (next.isEmpty()) {
|
||||||
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ExitLock lock = 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());
|
ctx.getPlayer().setCurrentRoom(next.get());
|
||||||
new LookCommand().execute(ctx, List.of());
|
new LookCommand().execute(ctx, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a visited room by id/name and auto-walk the BFS path to it. */
|
||||||
|
private void goToRoom(GameContext ctx, List<String> args) {
|
||||||
|
String name = String.join(" ", args).toLowerCase();
|
||||||
|
Room target = resolveVisited(ctx, name);
|
||||||
|
if (target == null) {
|
||||||
|
ctx.getIo().write("You don't know any direction or place called '" + name + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Room start = ctx.getPlayer().getCurrentRoom();
|
||||||
|
if (target == start) {
|
||||||
|
ctx.getIo().write("You're already in the " + target.getName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> path = Pathfinder.findPath(ctx, target);
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
ctx.getIo().write("You can't find a way there right now.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> route = new ArrayList<>();
|
||||||
|
route.add(start);
|
||||||
|
route.addAll(path);
|
||||||
|
for (Room step : path) {
|
||||||
|
ctx.getPlayer().setCurrentRoom(step);
|
||||||
|
ctx.getIo().travelStep(MapLayout.compute(
|
||||||
|
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
|
||||||
|
}
|
||||||
|
ctx.getIo().write(summary(route));
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds a visited room whose id or display name matches {@code name}. */
|
||||||
|
private Room resolveVisited(GameContext ctx, String name) {
|
||||||
|
// the command test harness (CommandTestSupport) uses a null world; production always has one
|
||||||
|
if (ctx.getWorld() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String id : ctx.getPlayer().getVisitedRoomIds()) {
|
||||||
|
Room r = ctx.getWorld().getRooms().get(id);
|
||||||
|
if (r == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.equalsIgnoreCase(name) || r.getName().equalsIgnoreCase(name)) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names start, destination, and at most one middle waypoint. */
|
||||||
|
private String summary(List<Room> route) {
|
||||||
|
int n = route.size();
|
||||||
|
String start = route.get(0).getName();
|
||||||
|
String dest = route.get(n - 1).getName();
|
||||||
|
if (n >= 3) {
|
||||||
|
String middle = route.get(n / 2).getName();
|
||||||
|
return "You make your way from the " + start + ", through the " + middle
|
||||||
|
+ ", to the " + dest + ".";
|
||||||
|
}
|
||||||
|
return "You make your way from the " + start + " to the " + dest + ".";
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String help() {
|
public String help() {
|
||||||
return "go <direction> - move to the connected room (north/south/east/west)";
|
return "go <direction> - move one room (north/south/east/west); "
|
||||||
|
+ "or go to <room> to travel to a known room";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,42 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
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.Direction;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints a full description of the room the player is currently in:
|
* Describes the current room by emitting a {@link RoomView}; the active
|
||||||
* name, description, visible items, visible NPCs, and available exits.
|
* frontend decides how to render it. Usage: {@code look}.
|
||||||
* Usage: {@code look}.
|
|
||||||
*/
|
*/
|
||||||
public class LookCommand implements Command {
|
public class LookCommand implements Command {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void execute(GameContext ctx, List<String> args) {
|
public void execute(GameContext ctx, List<String> args) {
|
||||||
Room room = ctx.getPlayer().getCurrentRoom();
|
Room room = ctx.getPlayer().getCurrentRoom();
|
||||||
StringBuilder sb = new StringBuilder();
|
if (!Light.isLit(ctx, room)) {
|
||||||
sb.append("== ").append(room.getName()).append(" ==\n");
|
ctx.getIo().write("It's pitch black; you can't make anything out. You need a light source.");
|
||||||
sb.append(room.getDescription().stripTrailing());
|
return;
|
||||||
|
|
||||||
if (!room.getItems().isEmpty()) {
|
|
||||||
String items = room.getItems().values().stream()
|
|
||||||
.map(Item::getName)
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
sb.append("\nYou see: ").append(items).append('.');
|
|
||||||
}
|
}
|
||||||
if (!room.getNpcs().isEmpty()) {
|
List<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
||||||
String npcs = room.getNpcs().values().stream()
|
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
||||||
.map(Npc::getName)
|
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
||||||
.collect(Collectors.joining(", "));
|
String description = room.getDescription();
|
||||||
sb.append("\nHere is: ").append(npcs).append('.');
|
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('.');
|
|
||||||
}
|
}
|
||||||
ctx.getIo().write(sb.toString());
|
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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.io.text.MapView;
|
||||||
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
|
|
||||||
|
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,44 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private final SaveService saves;
|
||||||
|
private Game game;
|
||||||
|
|
||||||
|
/** Binds the running game (two-phase wiring, as the registry is built first). */
|
||||||
|
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)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
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.command.Command;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ public class TakeCommand implements Command {
|
|||||||
ctx.getIo().write("Take what?");
|
ctx.getIo().write("Take what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||||
|
ctx.getIo().write("It's too dark to see that.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
String itemId = args.getFirst();
|
String itemId = args.getFirst();
|
||||||
Room room = ctx.getPlayer().getCurrentRoom();
|
Room room = ctx.getPlayer().getCurrentRoom();
|
||||||
Optional<Item> taken = room.removeItem(itemId);
|
Optional<Item> taken = room.removeItem(itemId);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.model.DialogueLine;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -24,7 +26,15 @@ public class TalkCommand implements Command {
|
|||||||
ctx.getIo().write("There is no '" + npcId + "' here to talk to.");
|
ctx.getIo().write("There is no '" + npcId + "' here to talk to.");
|
||||||
return;
|
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
|
@Override
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
@@ -8,9 +9,10 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the item-specific {@link Item#use(GameContext)} action. The
|
* With one argument, invokes the item-specific {@link Item#use(GameContext)}
|
||||||
* item must either be in the player's inventory or in the current room.
|
* action; the item must be in the player's inventory or the current room.
|
||||||
* Usage: {@code use <item-id>}.
|
* With two arguments ({@code use <item> on <item>}), delegates to the
|
||||||
|
* combination engine. Usage: {@code use <item>} or {@code use <item> on <item>}.
|
||||||
*/
|
*/
|
||||||
public class UseCommand implements Command {
|
public class UseCommand implements Command {
|
||||||
|
|
||||||
@@ -20,6 +22,10 @@ public class UseCommand implements Command {
|
|||||||
ctx.getIo().write("Use what?");
|
ctx.getIo().write("Use what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (args.size() >= 2) {
|
||||||
|
Combinations.tryUse(ctx, args.get(0), args.get(1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
String itemId = args.getFirst();
|
String itemId = args.getFirst();
|
||||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||||
if (item.isEmpty()) {
|
if (item.isEmpty()) {
|
||||||
@@ -34,6 +40,6 @@ public class UseCommand implements Command {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String help() {
|
public String help() {
|
||||||
return "use <item> - use an item (read it, switch it on/off, ...)";
|
return "use <item> - use an item; or use <item> on <item> to combine them";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
public static void tryUse(GameContext ctx, String idX, String idY) {
|
||||||
|
if (!present(ctx, idX)) {
|
||||||
|
ctx.getIo().write(noSuch(idX));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present(ctx, idY)) {
|
||||||
|
ctx.getIo().write(noSuch(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean present(GameContext ctx, String id) {
|
||||||
|
return ctx.getPlayer().hasItem(id)
|
||||||
|
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeFromAnywhere(GameContext ctx, String id) {
|
||||||
|
if (ctx.getPlayer().removeItem(id).isEmpty()) {
|
||||||
|
ctx.getPlayer().getCurrentRoom().removeItem(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String noSuch(String id) {
|
||||||
|
return "There is no '" + id + "' here or in your inventory.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
||||||
|
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,43 @@
|
|||||||
|
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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
||||||
|
public static void applyAll(List<Effect> effects, GameContext ctx) {
|
||||||
|
if (effects == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Effect e : effects) {
|
||||||
|
apply(e, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.StyledText;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
|
/** Detects the first matching ending and renders the end-of-game screen. */
|
||||||
|
public final class EndingEngine {
|
||||||
|
|
||||||
|
private EndingEngine() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First ending whose conditions hold, in list order; null if none. */
|
||||||
|
public static Ending triggered(GameContext ctx) {
|
||||||
|
for (Ending e : ctx.getWorld().getEndings()) {
|
||||||
|
if (Conditions.all(e.when(), ctx)) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the ending screen: title banner, text, and a run summary. */
|
||||||
|
public static StyledText render(Ending e, GameContext ctx, int turns) {
|
||||||
|
int total = ctx.getWorld().getQuests().size();
|
||||||
|
int done = ctx.getQuestLog().completed().size();
|
||||||
|
String bar = "═".repeat(Math.max(12, e.title().length() + 6));
|
||||||
|
|
||||||
|
StyledText.Builder b = StyledText.builder();
|
||||||
|
b.heading(bar + "\n").heading(" " + e.title() + "\n").heading(bar + "\n");
|
||||||
|
b.plain(e.text().stripTrailing()).plain("\n\n");
|
||||||
|
b.dim("Turns: " + turns + "\n");
|
||||||
|
b.dim("Quests completed: " + done + " / " + total + "\n");
|
||||||
|
b.heading("Rank: " + rank(e, total, done));
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rank(Ending e, int total, int done) {
|
||||||
|
if (e.victory() && total > 0 && done == total) {
|
||||||
|
return "Master of the Manor";
|
||||||
|
}
|
||||||
|
if (e.victory()) {
|
||||||
|
return "Manor Reclaimed";
|
||||||
|
}
|
||||||
|
return "Escaped — the manor keeps its secrets";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import thb.jeanluc.adventure.command.Command;
|
|||||||
import thb.jeanluc.adventure.command.CommandParser;
|
import thb.jeanluc.adventure.command.CommandParser;
|
||||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||||
import thb.jeanluc.adventure.command.ParsedCommand;
|
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||||
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -28,10 +32,23 @@ public class Game {
|
|||||||
/** Loop flag. Flipped to false by {@link #stop()}. */
|
/** Loop flag. Flipped to false by {@link #stop()}. */
|
||||||
private boolean running = true;
|
private boolean running = true;
|
||||||
|
|
||||||
|
/** Optional onboarding guide; null in normal/loaded play. */
|
||||||
|
private TutorialGuide tutorialGuide;
|
||||||
|
|
||||||
|
/** Wires the interactive tutorial (New Game only). */
|
||||||
|
public void setTutorialGuide(TutorialGuide tutorialGuide) {
|
||||||
|
this.tutorialGuide = tutorialGuide;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
||||||
*/
|
*/
|
||||||
public void run() {
|
public void run() {
|
||||||
|
publishHud();
|
||||||
|
maybeEnd();
|
||||||
|
if (tutorialGuide != null) {
|
||||||
|
tutorialGuide.begin(ctx);
|
||||||
|
}
|
||||||
while (running) {
|
while (running) {
|
||||||
String input = ctx.getIo().readLine();
|
String input = ctx.getIo().readLine();
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -41,13 +58,58 @@ public class Game {
|
|||||||
if (parsed.verb().isEmpty()) {
|
if (parsed.verb().isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()
|
||||||
|
&& parsed.verb().equals("skip")) {
|
||||||
|
tutorialGuide.skip(ctx);
|
||||||
|
publishHud();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Optional<Command> cmd = registry.find(parsed.verb());
|
Optional<Command> cmd = registry.find(parsed.verb());
|
||||||
if (cmd.isEmpty()) {
|
if (cmd.isEmpty()) {
|
||||||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||||||
} else {
|
} else {
|
||||||
cmd.get().execute(ctx, parsed.args());
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishHud() {
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,26 +1,63 @@
|
|||||||
package thb.jeanluc.adventure.game;
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import thb.jeanluc.adventure.io.GameIO;
|
import thb.jeanluc.adventure.io.GameIO;
|
||||||
import thb.jeanluc.adventure.model.Player;
|
import thb.jeanluc.adventure.model.Player;
|
||||||
import thb.jeanluc.adventure.model.World;
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bundle of everything a command needs to do its work: the world, the
|
* Bundle of everything a command needs: the active {@link GameSession}
|
||||||
* player, and the IO channel. Passing this single object keeps command
|
* (world, player, flags, quests, turn) and the IO channel. Getters delegate
|
||||||
* signatures small and consistent.
|
* to the session so existing command code keeps working unchanged.
|
||||||
*/
|
*/
|
||||||
@Getter
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class GameContext {
|
public class GameContext {
|
||||||
|
|
||||||
/** Loaded world (rooms, items, npcs). */
|
private final GameSession session;
|
||||||
private final World world;
|
|
||||||
|
|
||||||
/** The player avatar. */
|
|
||||||
private final Player player;
|
|
||||||
|
|
||||||
/** IO channel for reading input and writing output. */
|
|
||||||
private final GameIO io;
|
private final GameIO io;
|
||||||
|
|
||||||
|
/** Silent autosave hook, wired by the app; no-op by default (tests). */
|
||||||
|
private Runnable saveCallback = () -> { };
|
||||||
|
|
||||||
|
public GameContext(GameSession session, GameIO io) {
|
||||||
|
this.session = session;
|
||||||
|
this.io = io;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for tests/legacy callers: wraps a fresh single-use session. */
|
||||||
|
public GameContext(World world, Player player, GameIO io) {
|
||||||
|
this(new GameSession(world, player, "session"), io);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameSession getSession() {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public World getWorld() {
|
||||||
|
return session.getWorld();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Player getPlayer() {
|
||||||
|
return session.getPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameState getState() {
|
||||||
|
return session.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public QuestLog getQuestLog() {
|
||||||
|
return session.getQuestLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameIO getIo() {
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the silent autosave hook (called by interval/quest autosave). */
|
||||||
|
public void setSaveCallback(Runnable saveCallback) {
|
||||||
|
this.saveCallback = saveCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the silent autosave hook. */
|
||||||
|
public void save() {
|
||||||
|
saveCallback.run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private final World world;
|
||||||
|
private final Player player;
|
||||||
|
private final GameState state = new GameState();
|
||||||
|
private final QuestLog questLog = new QuestLog();
|
||||||
|
private final String slotName;
|
||||||
|
private int turn;
|
||||||
|
|
||||||
|
public GameSession(World world, Player player, String slotName) {
|
||||||
|
this.world = world;
|
||||||
|
this.player = player;
|
||||||
|
this.slotName = slotName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTurn(int turn) {
|
||||||
|
this.turn = turn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void incrementTurn() {
|
||||||
|
this.turn++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private final Set<String> flags = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
public boolean isSet(String flag) {
|
||||||
|
return flags.contains(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(String flag) {
|
||||||
|
flags.add(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear(String flag) {
|
||||||
|
flags.remove(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> all() {
|
||||||
|
return Collections.unmodifiableSet(flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces all flags with the given collection (used by load). */
|
||||||
|
public void restore(Collection<String> newFlags) {
|
||||||
|
flags.clear();
|
||||||
|
flags.addAll(newFlags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
/** Decides whether a room is lit, given dark rooms and active light sources. */
|
||||||
|
public final class Light {
|
||||||
|
|
||||||
|
private Light() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room is lit if it is not dark, or an active light source is carried or present. */
|
||||||
|
public static boolean isLit(GameContext ctx, Room room) {
|
||||||
|
if (!room.isDark()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasActiveLight(ctx.getPlayer().getInventory().values())
|
||||||
|
|| hasActiveLight(room.getItems().values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the player carries an active light source (for the HUD). */
|
||||||
|
public static boolean carryingLight(GameContext ctx) {
|
||||||
|
return hasActiveLight(ctx.getPlayer().getInventory().values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasActiveLight(Collection<Item> items) {
|
||||||
|
for (Item it : items) {
|
||||||
|
if (isActiveLight(it)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isActiveLight(Item it) {
|
||||||
|
if (!it.isLight()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(it instanceof SwitchableItem s) || s.isOn();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,95 @@
|
|||||||
|
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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,58 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private final Map<String, Integer> stageIndex = new LinkedHashMap<>();
|
||||||
|
private final Set<String> completed = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
public void start(String id) {
|
||||||
|
if (!completed.contains(id)) {
|
||||||
|
stageIndex.putIfAbsent(id, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive(String id) {
|
||||||
|
return stageIndex.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCompleted(String id) {
|
||||||
|
return completed.contains(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int stageIndex(String id) {
|
||||||
|
return stageIndex.getOrDefault(id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void advance(String id) {
|
||||||
|
stageIndex.merge(id, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void complete(String id) {
|
||||||
|
stageIndex.remove(id);
|
||||||
|
completed.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> active() {
|
||||||
|
return Collections.unmodifiableSet(stageIndex.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> completed() {
|
||||||
|
return Collections.unmodifiableSet(completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces all progress with the given stage map and completed set (load). */
|
||||||
|
public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
|
||||||
|
stageIndex.clear();
|
||||||
|
stageIndex.putAll(stages);
|
||||||
|
completed.clear();
|
||||||
|
completed.addAll(completedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private final Tutorial tutorial;
|
||||||
|
private final CommandRegistry registry;
|
||||||
|
private int index;
|
||||||
|
private boolean active;
|
||||||
|
private boolean begun;
|
||||||
|
private Room roomAtStepStart;
|
||||||
|
|
||||||
|
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
|
||||||
|
this.tutorial = tutorial;
|
||||||
|
this.registry = registry;
|
||||||
|
this.active = !tutorial.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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). */
|
||||||
|
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. */
|
||||||
|
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). */
|
||||||
|
public void skip(GameContext ctx) {
|
||||||
|
if (active) {
|
||||||
|
ctx.getIo().write("Tutorial skipped.");
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enterStep(GameContext ctx) {
|
||||||
|
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
|
||||||
|
ctx.getIo().write(tutorial.steps().get(index).instruction());
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean moved(GameContext ctx) {
|
||||||
|
return roomAtStepStart != null
|
||||||
|
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,69 @@
|
|||||||
package thb.jeanluc.adventure.io;
|
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.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.PrintStream;
|
import java.io.PrintStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link GameIO} backed by {@code System.in} and {@code System.out}.
|
* {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
|
||||||
* Each {@code write} appends a newline so output blocks are visually
|
* (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
|
||||||
* separated. {@code readLine} prints a {@code > } prompt before
|
* and degrade safely (no colours when output is piped; ASCII frames on request).
|
||||||
* blocking.
|
|
||||||
*/
|
*/
|
||||||
public class ConsoleIO implements GameIO {
|
public class ConsoleIO implements GameIO {
|
||||||
|
|
||||||
/** Input source. */
|
/** Whether ANSI colour is emitted. */
|
||||||
|
public enum ColorMode { ON, OFF, AUTO }
|
||||||
|
|
||||||
|
/** Frame/glyph fidelity tier. */
|
||||||
|
public enum GlyphMode { ASCII, UNICODE, GLYPH }
|
||||||
|
|
||||||
|
private static final int FRAME_WIDTH = 44;
|
||||||
|
|
||||||
private final BufferedReader in;
|
private final BufferedReader in;
|
||||||
|
|
||||||
/** Output sink. */
|
|
||||||
private final PrintStream out;
|
private final PrintStream out;
|
||||||
|
private boolean useColor;
|
||||||
|
private GlyphMode glyphs;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a console IO using standard input and output.
|
|
||||||
*/
|
|
||||||
public ConsoleIO() {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Backward-compatible stream constructor (colour off, unicode frames). */
|
||||||
* Test-friendly constructor allowing custom streams.
|
|
||||||
*
|
|
||||||
* @param in reader to consume player input
|
|
||||||
* @param out stream to write player output
|
|
||||||
*/
|
|
||||||
public ConsoleIO(BufferedReader in, PrintStream out) {
|
public ConsoleIO(BufferedReader in, PrintStream out) {
|
||||||
|
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
|
||||||
this.in = in;
|
this.in = in;
|
||||||
this.out = out;
|
this.out = out;
|
||||||
|
this.glyphs = glyphs;
|
||||||
|
setColorMode(colorMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-resolves colour emission from the given mode (AUTO checks the console). */
|
||||||
|
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. */
|
||||||
|
public void setGlyphMode(GlyphMode mode) {
|
||||||
|
this.glyphs = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -43,15 +71,100 @@ public class ConsoleIO implements GameIO {
|
|||||||
out.print("> ");
|
out.print("> ");
|
||||||
out.flush();
|
out.flush();
|
||||||
try {
|
try {
|
||||||
String line = in.readLine();
|
// null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts).
|
||||||
return line == null ? "" : line;
|
return in.readLine();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return "";
|
// Treat a stream failure as EOF so input ends cleanly rather than spinning.
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void write(String s) {
|
public void print(StyledText text) {
|
||||||
out.println(s);
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,126 @@
|
|||||||
package thb.jeanluc.adventure.io;
|
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
|
* Bidirectional channel between the engine and the player. Commands emit
|
||||||
* by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic
|
* semantic, role-styled output via {@link #print(StyledText)} (the primitive)
|
||||||
* to the actual interface.
|
* and the {@link #showRoom}/{@link #setHud} views; each frontend renders them.
|
||||||
*/
|
*/
|
||||||
public interface GameIO {
|
public interface GameIO {
|
||||||
|
|
||||||
/**
|
/** Blocks until a line of input is available; empty string at end of input. */
|
||||||
* Blocks until a line of input is available.
|
|
||||||
*
|
|
||||||
* @return the next user input line, or an empty string at end of input
|
|
||||||
*/
|
|
||||||
String readLine();
|
String readLine();
|
||||||
|
|
||||||
/**
|
/** Renders one styled output block. The only method implementors must provide. */
|
||||||
* Writes a message to the player. Each call corresponds to one logical
|
void print(StyledText text);
|
||||||
* output block; the implementation handles line breaks at the boundary.
|
|
||||||
*
|
/** Convenience: a plain, unstyled line. */
|
||||||
* @param s message text; must not be null
|
default void write(String s) {
|
||||||
*/
|
print(StyledText.of(s));
|
||||||
void write(String s);
|
}
|
||||||
|
|
||||||
|
/** Renders a room. Default flattens via {@link Renderings}; renderers may override. */
|
||||||
|
default void showRoom(RoomView room) {
|
||||||
|
print(Renderings.roomToStyledText(room));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates the status/HUD region. No-op by default; renderers may override. */
|
||||||
|
default void setHud(Hud hud) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-turn push of the current map. GUI repaints its panel; console ignores. */
|
||||||
|
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). */
|
||||||
|
default void showMap(MapView view) {
|
||||||
|
print(AsciiMap.render(view, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-turn push of the quest log. GUI repaints its box; console ignores. */
|
||||||
|
default void setQuests(QuestView view) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
|
||||||
|
default void setMusic(String track) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the music volume/level. Console no-op; GUI sets it. */
|
||||||
|
default void setMusicLevel(MusicLevel level) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-demand quest log (the 'quests' command). Default renders styled text. */
|
||||||
|
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,204 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private static final int BASE_CELL_W = 96;
|
||||||
|
private static final int BASE_CELL_H = 46;
|
||||||
|
private static final int BASE_GAP_X = 34;
|
||||||
|
private static final int BASE_GAP_Y = 30;
|
||||||
|
private static final int BASE_PAD = 16;
|
||||||
|
private static final float BASE_FONT = 11f;
|
||||||
|
|
||||||
|
private static final Color BG = new Color(0x0e, 0x12, 0x18);
|
||||||
|
private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a);
|
||||||
|
private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0);
|
||||||
|
private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a);
|
||||||
|
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
|
||||||
|
|
||||||
|
private final Font baseFont;
|
||||||
|
private float zoom = 1f;
|
||||||
|
private MapView view = new MapView(List.of(), List.of());
|
||||||
|
|
||||||
|
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). */
|
||||||
|
public void setZoom(float zoom) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
this.zoom = Math.max(0.4f, zoom);
|
||||||
|
revalidate();
|
||||||
|
repaint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the rendered map. */
|
||||||
|
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. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,17 @@
|
|||||||
|
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}. */
|
||||||
|
void fadeTo(String track, float gainDb);
|
||||||
|
|
||||||
|
/** Fade the current track out and stop. */
|
||||||
|
void fadeOut();
|
||||||
|
|
||||||
|
/** Set the playback volume immediately. */
|
||||||
|
void setGainDb(float gainDb);
|
||||||
|
|
||||||
|
/** Stop playback and release resources. */
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private final MusicBackend backend;
|
||||||
|
private MusicLevel level = MusicLevel.MEDIUM;
|
||||||
|
private String current; // currently selected track, or null = silence
|
||||||
|
|
||||||
|
public MusicController(MusicBackend backend) {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called per turn with the current room's music field (may be null/blank). */
|
||||||
|
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). */
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
backend.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
/** Background-music volume level, cycled in the Settings menu. */
|
||||||
|
public enum MusicLevel {
|
||||||
|
OFF, LOW, MEDIUM, HIGH;
|
||||||
|
|
||||||
|
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
|
||||||
|
public float gainDb() {
|
||||||
|
return switch (this) {
|
||||||
|
case OFF -> Float.NEGATIVE_INFINITY;
|
||||||
|
case LOW -> -20f;
|
||||||
|
case MEDIUM -> -10f;
|
||||||
|
case HIGH -> 0f;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true when music should play. */
|
||||||
|
public boolean isOn() {
|
||||||
|
return this != OFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next level in the cycle (wraps HIGH → OFF). */
|
||||||
|
public MusicLevel next() {
|
||||||
|
return values()[(ordinal() + 1) % values().length];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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.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 {
|
||||||
|
|
||||||
|
private static final String MUSIC_DIR = "/music/";
|
||||||
|
private static final int FADE_MS = 400;
|
||||||
|
private static final int FADE_STEPS = 20;
|
||||||
|
private static final int BUFFER = 4096;
|
||||||
|
private static final float FLOOR_DB = -40f; // near-silence for fades
|
||||||
|
|
||||||
|
private volatile Thread playThread;
|
||||||
|
private volatile boolean stopping;
|
||||||
|
private volatile SourceDataLine line;
|
||||||
|
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
stopping = false;
|
||||||
|
playThread = new Thread(() -> playLoop(resource, gainDb), "music");
|
||||||
|
playThread.setDaemon(true);
|
||||||
|
playThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void fadeOut() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void setGainDb(float gainDb) {
|
||||||
|
targetGainDb = gainDb;
|
||||||
|
SourceDataLine l = line;
|
||||||
|
if (l != null) {
|
||||||
|
applyGain(l, gainDb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void shutdown() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
|
||||||
|
private void stopCurrent() {
|
||||||
|
Thread t = playThread;
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopping = true;
|
||||||
|
try {
|
||||||
|
t.join(FADE_MS + 600L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
playThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void playLoop(String resource, float gainDb) {
|
||||||
|
SourceDataLine l = null;
|
||||||
|
try {
|
||||||
|
while (!stopping) {
|
||||||
|
// 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();
|
||||||
|
line = l;
|
||||||
|
ramp(l, FLOOR_DB, gainDb); // fade in once
|
||||||
|
}
|
||||||
|
byte[] buf = new byte[BUFFER];
|
||||||
|
int n;
|
||||||
|
while (!stopping && (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 {
|
||||||
|
ramp(l, currentGain(l), FLOOR_DB); // fade out
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// best-effort fade
|
||||||
|
}
|
||||||
|
l.stop();
|
||||||
|
l.close();
|
||||||
|
}
|
||||||
|
line = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ramp(SourceDataLine l, float fromDb, float toDb) {
|
||||||
|
for (int i = 0; i <= FADE_STEPS; i++) {
|
||||||
|
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
|
||||||
|
sleep(FADE_MS / FADE_STEPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float currentGain(SourceDataLine l) {
|
||||||
|
try {
|
||||||
|
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return targetGainDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,295 @@
|
|||||||
package thb.jeanluc.adventure.io;
|
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.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JFrame;
|
import javax.swing.JFrame;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JScrollPane;
|
import javax.swing.JScrollPane;
|
||||||
import javax.swing.JTextArea;
|
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.JTextPane;
|
||||||
|
import javax.swing.KeyStroke;
|
||||||
|
import javax.swing.SwingConstants;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.text.BadLocationException;
|
||||||
|
import javax.swing.text.SimpleAttributeSet;
|
||||||
|
import javax.swing.text.StyleConstants;
|
||||||
|
import javax.swing.text.StyledDocument;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Cursor;
|
||||||
|
import java.awt.Dimension;
|
||||||
import java.awt.Font;
|
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;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link GameIO} backed by a Swing window. A read-only output area shows
|
* {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
|
||||||
* game text; a bottom text field collects player input. The input from
|
* a styled output pane (centre), a side panel for exits / future map (right),
|
||||||
* the EDT is handed to the (worker-thread) game loop through a
|
* and an input field (bottom). A bundled TrueType font is loaded if present so
|
||||||
* {@link LinkedBlockingQueue}.
|
* 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 {
|
public class SwingIO implements GameIO {
|
||||||
|
|
||||||
/** Bridge between the EDT and the game loop. One element = one line. */
|
/** Unscaled base size for the main output/input font, in points. */
|
||||||
|
private static final float BASE_SIZE = 15f;
|
||||||
|
|
||||||
|
/** Unscaled size for the HUD and side-panel font, in points. */
|
||||||
|
private static final float SMALL_SIZE = 12.5f;
|
||||||
|
|
||||||
|
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
||||||
|
private static final long TRAVEL_STEP_MS = 300L;
|
||||||
|
|
||||||
|
/** Side-panel width as a fraction of the window width, with a lower bound only. */
|
||||||
|
private static final double SIDE_RATIO = 0.34;
|
||||||
|
private static final int SIDE_MIN = 220;
|
||||||
|
|
||||||
|
private final MusicController music = new MusicController(new OggMusicBackend());
|
||||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||||||
|
|
||||||
/** Main window. */
|
|
||||||
private final JFrame frame;
|
private final JFrame frame;
|
||||||
|
private final JTextPane output;
|
||||||
/** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */
|
private final StyledDocument doc;
|
||||||
private final JTextArea output;
|
|
||||||
|
|
||||||
/** Input text field; ENTER pushes the line into {@link #inputs}. */
|
|
||||||
private final JTextField input;
|
private final JTextField input;
|
||||||
|
private final JLabel hud;
|
||||||
|
private final MapPanel map;
|
||||||
|
private final JScrollPane sideScroll;
|
||||||
|
private final QuestPanel quests;
|
||||||
|
private final JScrollPane questScroll;
|
||||||
|
private final JPanel sidePanel;
|
||||||
|
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;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and shows the window with the given title. Must be called on
|
|
||||||
* the EDT (typical pattern: from {@code SwingUtilities.invokeLater}).
|
|
||||||
*
|
|
||||||
* @param title window title
|
|
||||||
*/
|
|
||||||
public SwingIO(String title) {
|
public SwingIO(String title) {
|
||||||
frame = new JFrame(title);
|
baseFont = loadFont();
|
||||||
output = new JTextArea();
|
uiScale = detectScale();
|
||||||
input = new JTextField();
|
|
||||||
|
|
||||||
|
output = new JTextPane();
|
||||||
output.setEditable(false);
|
output.setEditable(false);
|
||||||
output.setLineWrap(true);
|
output.setBackground(new Color(0x0b, 0x0e, 0x13));
|
||||||
output.setWrapStyleWord(true);
|
output.setForeground(new Color(0xcf, 0xd6, 0xe0));
|
||||||
output.setBackground(Color.BLACK);
|
output.setMargin(new Insets(8, 8, 8, 8));
|
||||||
output.setForeground(Color.LIGHT_GRAY);
|
doc = output.getStyledDocument();
|
||||||
output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
|
|
||||||
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
|
||||||
|
|
||||||
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 -> {
|
input.addActionListener(e -> {
|
||||||
String line = input.getText();
|
String line = input.getText();
|
||||||
input.setText("");
|
input.setText("");
|
||||||
output.append("> " + line + "\n");
|
appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
|
||||||
inputs.offer(line);
|
inputs.offer(line);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
frame = new JFrame(title);
|
||||||
frame.setLayout(new BorderLayout());
|
frame.setLayout(new BorderLayout());
|
||||||
|
frame.add(hud, BorderLayout.NORTH);
|
||||||
frame.add(new JScrollPane(output), BorderLayout.CENTER);
|
frame.add(new JScrollPane(output), BorderLayout.CENTER);
|
||||||
|
frame.add(sidePanel, BorderLayout.EAST);
|
||||||
frame.add(input, BorderLayout.SOUTH);
|
frame.add(input, BorderLayout.SOUTH);
|
||||||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||||
frame.setSize(800, 600);
|
frame.setSize((int) (900 * uiScale), (int) (600 * uiScale));
|
||||||
frame.setLocationRelativeTo(null);
|
frame.setLocationRelativeTo(null);
|
||||||
|
frame.addComponentListener(new ComponentAdapter() {
|
||||||
|
@Override
|
||||||
|
public void componentResized(ComponentEvent e) {
|
||||||
|
updateSideWidth();
|
||||||
|
applyFonts();
|
||||||
|
rescaleMenuOverlayIfVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installZoomKeys();
|
||||||
|
applyFonts();
|
||||||
|
updateSideWidth();
|
||||||
|
|
||||||
frame.setVisible(true);
|
frame.setVisible(true);
|
||||||
input.requestFocusInWindow();
|
input.requestFocusInWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Loads the bundled font if present, else falls back to a logical monospaced font. */
|
||||||
|
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. */
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bind(JComponent c, KeyStroke ks, String name) {
|
||||||
|
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
|
||||||
|
return new AbstractAction() {
|
||||||
|
@Override
|
||||||
|
public void actionPerformed(ActionEvent e) {
|
||||||
|
body.accept(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setZoom(float z) {
|
||||||
|
zoom = Math.max(0.6f, Math.min(3.0f, z));
|
||||||
|
applyFonts();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
@Override
|
||||||
public String readLine() {
|
public String readLine() {
|
||||||
try {
|
try {
|
||||||
@@ -79,7 +301,231 @@ public class SwingIO implements GameIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void write(String s) {
|
public void print(StyledText text) {
|
||||||
SwingUtilities.invokeLater(() -> output.append(s + "\n"));
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
private List<String> menuOptions;
|
||||||
|
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. */
|
||||||
|
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);
|
||||||
|
glass.requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void hideMenuOverlay() {
|
||||||
|
menuOptions = null; // stop resize rebuilds once the menu is dismissed
|
||||||
|
frame.getGlassPane().setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Palette for the themed menu overlay, matching the game window's dark look. */
|
||||||
|
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
|
||||||
|
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
|
||||||
|
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
|
||||||
|
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
|
||||||
|
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
|
||||||
|
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
|
||||||
|
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. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,127 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private static final int MIN_INNER = 7; // minimum room name field width
|
||||||
|
private static final int MAX_INNER = 14; // cap so the map stays compact
|
||||||
|
private static final int GAP = 3; // horizontal corridor width
|
||||||
|
|
||||||
|
private AsciiMap() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String label(RoomCell c, int inner) {
|
||||||
|
String n = c.name();
|
||||||
|
if (n.length() > inner) {
|
||||||
|
n = n.substring(0, inner);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long key(int x, int y) {
|
||||||
|
return (((long) x) << 32) ^ (y & 0xffffffffL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** Builds styled banners for big moments (game start, finale). */
|
||||||
|
public final class Banner {
|
||||||
|
|
||||||
|
private Banner() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A framed, heading-styled welcome banner for the given title. */
|
||||||
|
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,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** Fog-of-war state of a room on the map. */
|
||||||
|
public enum CellState { CURRENT, VISITED, KNOWN }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** A corridor between two placed rooms. */
|
||||||
|
public record Connection(RoomCell from, RoomCell to) {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** Status snapshot for the HUD region. */
|
||||||
|
public record Hud(String location, int gold, int turn, boolean lightOn) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Frontend-agnostic snapshot of the visible map: placed cells + connections. */
|
||||||
|
public record MapView(List<RoomCell> cells, List<Connection> connections) {
|
||||||
|
public MapView {
|
||||||
|
cells = List.copyOf(cells);
|
||||||
|
connections = List.copyOf(connections);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** An active quest's title and its current objective. */
|
||||||
|
public record QuestEntry(String title, String objective) {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** Renders a {@link QuestView} as styled text for the console quest log. */
|
||||||
|
public final class QuestText {
|
||||||
|
|
||||||
|
private QuestText() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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,11 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Frontend-agnostic snapshot of the quest log. */
|
||||||
|
public record QuestView(List<QuestEntry> active, List<String> completed) {
|
||||||
|
public QuestView {
|
||||||
|
active = List.copyOf(active);
|
||||||
|
completed = List.copyOf(completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private Renderings() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests). */
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** A room placed on the map grid at integer coords, with its fog-of-war state. */
|
||||||
|
public record RoomCell(String id, String name, int x, int y, CellState state) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Semantic snapshot of a room for rendering. Display-ready strings, no layout. */
|
||||||
|
public record RoomView(String name, String description,
|
||||||
|
List<String> items, List<String> npcs, List<String> exits) {
|
||||||
|
public RoomView {
|
||||||
|
items = List.copyOf(items);
|
||||||
|
npcs = List.copyOf(npcs);
|
||||||
|
exits = List.copyOf(exits);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** A run of text carrying a single semantic {@link Style}. */
|
||||||
|
public record Span(String text, Style style) {
|
||||||
|
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,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
|
||||||
|
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private final List<Span> spans;
|
||||||
|
|
||||||
|
private StyledText(List<Span> spans) {
|
||||||
|
this.spans = List.copyOf(spans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the spans in order (unmodifiable). */
|
||||||
|
public List<Span> spans() {
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fluent builder; one method per {@link Style} role. */
|
||||||
|
public static final class Builder {
|
||||||
|
private final List<Span> spans = new ArrayList<>();
|
||||||
|
|
||||||
|
private Builder add(String text, Style style) {
|
||||||
|
spans.add(new Span(text, style));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder plain(String s) { return add(s, Style.PLAIN); }
|
||||||
|
public Builder heading(String s) { return add(s, Style.HEADING); }
|
||||||
|
public Builder item(String s) { return add(s, Style.ITEM); }
|
||||||
|
public Builder npc(String s) { return add(s, Style.NPC); }
|
||||||
|
public Builder exit(String s) { return add(s, Style.EXIT); }
|
||||||
|
public Builder danger(String s) { return add(s, Style.DANGER); }
|
||||||
|
public Builder dim(String s) { return add(s, Style.DIM); }
|
||||||
|
|
||||||
|
public StyledText build() {
|
||||||
|
return new StyledText(spans);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private CombinationFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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,21 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
|
/** Builds {@link Ending} objects from {@link EndingDto}s. */
|
||||||
|
public final class EndingFactory {
|
||||||
|
|
||||||
|
private EndingFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Ending fromDto(EndingDto dto) {
|
||||||
|
return new Ending(
|
||||||
|
dto.id(),
|
||||||
|
dto.title(),
|
||||||
|
Boolean.TRUE.equals(dto.victory()),
|
||||||
|
ConditionDto.toModelList(dto.when()),
|
||||||
|
dto.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,12 +30,14 @@ public final class ItemFactory {
|
|||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
.name(dto.name())
|
.name(dto.name())
|
||||||
.description(dto.description())
|
.description(dto.description())
|
||||||
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
.build();
|
.build();
|
||||||
case "readable" -> ReadableItem.builder()
|
case "readable" -> ReadableItem.builder()
|
||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
.name(dto.name())
|
.name(dto.name())
|
||||||
.description(dto.description())
|
.description(dto.description())
|
||||||
.readText(dto.readText())
|
.readText(dto.readText())
|
||||||
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
.build();
|
.build();
|
||||||
case "switchable" -> SwitchableItem.builder()
|
case "switchable" -> SwitchableItem.builder()
|
||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
@@ -44,6 +46,8 @@ public final class ItemFactory {
|
|||||||
.state(Boolean.TRUE.equals(dto.initialState()))
|
.state(Boolean.TRUE.equals(dto.initialState()))
|
||||||
.onText(dto.onText())
|
.onText(dto.onText())
|
||||||
.offText(dto.offText())
|
.offText(dto.offText())
|
||||||
|
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
|
||||||
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
.build();
|
.build();
|
||||||
default -> throw new WorldLoadException(
|
default -> throw new WorldLoadException(
|
||||||
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private QuestFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
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.NpcDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.ReactionDto;
|
import thb.jeanluc.adventure.loader.dto.ReactionDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
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.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.NpcReaction;
|
import thb.jeanluc.adventure.model.NpcReaction;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
@@ -75,6 +83,25 @@ public class ReferenceResolver {
|
|||||||
room.addNpc(npc);
|
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) {
|
public void resolveNpcs(List<NpcDto> npcDtos) {
|
||||||
for (NpcDto dto : 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) {
|
if (dto.reactions() == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Npc npc = npcs.get(dto.id());
|
|
||||||
for (ReactionDto r : dto.reactions()) {
|
for (ReactionDto r : dto.reactions()) {
|
||||||
if (r.onReceive() == null) {
|
if (r.onReceive() == null) {
|
||||||
throw new WorldLoadException(
|
throw new WorldLoadException(
|
||||||
@@ -123,6 +155,8 @@ public class ReferenceResolver {
|
|||||||
.consumes(consumes)
|
.consumes(consumes)
|
||||||
.gives(gives)
|
.gives(gives)
|
||||||
.response(r.response())
|
.response(r.response())
|
||||||
|
.requires(ConditionDto.toModelList(r.requires()))
|
||||||
|
.effects(EffectDto.toModelList(r.effects()))
|
||||||
.build();
|
.build();
|
||||||
npc.putReaction(r.onReceive(), reaction);
|
npc.putReaction(r.onReceive(), reaction);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ public final class RoomFactory {
|
|||||||
* @return the freshly built room shell
|
* @return the freshly built room shell
|
||||||
*/
|
*/
|
||||||
public static Room shellFromDto(RoomDto dto) {
|
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,63 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
||||||
|
private final String basePath;
|
||||||
|
|
||||||
|
public TutorialLoader() {
|
||||||
|
this(DEFAULT_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TutorialLoader(String basePath) {
|
||||||
|
this.basePath = basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */
|
||||||
|
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 com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.Player;
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Quest;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
import thb.jeanluc.adventure.model.World;
|
import thb.jeanluc.adventure.model.World;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -68,11 +75,17 @@ public class WorldLoader {
|
|||||||
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
|
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
|
||||||
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
||||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||||
|
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
||||||
|
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
||||||
|
List<CombinationDto> combinationDtos =
|
||||||
|
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
|
||||||
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
||||||
|
|
||||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||||
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
|
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
|
||||||
requireUniqueIds("room", roomDtos.stream().map(RoomDto::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<>();
|
Map<String, Item> items = new HashMap<>();
|
||||||
for (ItemDto dto : itemDtos) {
|
for (ItemDto dto : itemDtos) {
|
||||||
@@ -86,6 +99,24 @@ public class WorldLoader {
|
|||||||
for (RoomDto dto : roomDtos) {
|
for (RoomDto dto : roomDtos) {
|
||||||
rooms.put(dto.id(), RoomFactory.shellFromDto(dto));
|
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);
|
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||||
resolver.resolveRooms(roomDtos);
|
resolver.resolveRooms(roomDtos);
|
||||||
@@ -98,7 +129,7 @@ public class WorldLoader {
|
|||||||
Player player = new Player(start, gold);
|
Player player = new Player(start, gold);
|
||||||
|
|
||||||
World world = new World(rooms, items, npcs,
|
World world = new World(rooms, items, npcs,
|
||||||
gameDto.title(), gameDto.welcomeMessage());
|
gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
|
||||||
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||||
return new LoadResult(world, player);
|
return new LoadResult(world, player);
|
||||||
@@ -114,6 +145,19 @@ public class WorldLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private <T> T readSingle(String resource, Class<T> type) {
|
private <T> T readSingle(String resource, Class<T> type) {
|
||||||
try (InputStream in = openResource(resource)) {
|
try (InputStream in = openResource(resource)) {
|
||||||
return yaml.readValue(in, type);
|
return yaml.readValue(in, type);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of an item-combination recipe. */
|
||||||
|
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,34 @@
|
|||||||
|
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. */
|
||||||
|
public record ConditionDto(String flag, String notFlag, String hasItem) {
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,7 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of a conditional room description. */
|
||||||
|
public record DescriptionStateDto(List<ConditionDto> when, String text) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of a conditional NPC line. */
|
||||||
|
public record DialogueLineDto(List<ConditionDto> when, String text) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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. */
|
||||||
|
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem,
|
||||||
|
String say, String startQuest) {
|
||||||
|
|
||||||
|
/** Backward-compatible constructor without startQuest. */
|
||||||
|
public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
|
||||||
|
this(setFlag, clearFlag, giveItem, removeItem, say, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,7 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of a game ending. */
|
||||||
|
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of a single condition-gated exit. */
|
||||||
|
public record ExitLockDto(String direction, List<ConditionDto> requires, String blocked) {
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.loader.dto;
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YAML representation of a single item. Fields outside of the type's
|
* YAML representation of a single item. Fields outside of the type's
|
||||||
* scope (e.g. {@code readText} on a switchable) are simply ignored.
|
* scope (e.g. {@code readText} on a switchable) are simply ignored.
|
||||||
@@ -12,6 +14,8 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
* @param initialState initial on/off state of a switchable item
|
* @param initialState initial on/off state of a switchable item
|
||||||
* @param onText message printed when a switchable transitions to on
|
* @param onText message printed when a switchable transitions to on
|
||||||
* @param offText message printed when a switchable transitions to off
|
* @param offText message printed when a switchable transitions to off
|
||||||
|
* @param effects effects applied when a switchable transitions to on; nullable
|
||||||
|
* @param light whether this item is a light source; nullable
|
||||||
*/
|
*/
|
||||||
public record ItemDto(
|
public record ItemDto(
|
||||||
String type,
|
String type,
|
||||||
@@ -21,6 +25,13 @@ public record ItemDto(
|
|||||||
String readText,
|
String readText,
|
||||||
Boolean initialState,
|
Boolean initialState,
|
||||||
String onText,
|
String onText,
|
||||||
String offText
|
String offText,
|
||||||
|
List<EffectDto> effects,
|
||||||
|
Boolean light
|
||||||
) {
|
) {
|
||||||
|
/** Backward-compatible constructor without effects/light. */
|
||||||
|
public ItemDto(String type, String id, String name, String description,
|
||||||
|
String readText, Boolean initialState, String onText, String offText) {
|
||||||
|
this(type, id, name, description, readText, initialState, onText, offText, null, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ import java.util.List;
|
|||||||
* @param description examine description
|
* @param description examine description
|
||||||
* @param greeting text the NPC says on {@code talk}
|
* @param greeting text the NPC says on {@code talk}
|
||||||
* @param reactions list of trigger/response definitions; may be null
|
* @param reactions list of trigger/response definitions; may be null
|
||||||
|
* @param dialogue optional condition-gated dialogue lines; may be null
|
||||||
*/
|
*/
|
||||||
public record NpcDto(
|
public record NpcDto(
|
||||||
String id,
|
String id,
|
||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
String greeting,
|
String greeting,
|
||||||
List<ReactionDto> reactions
|
List<ReactionDto> reactions,
|
||||||
|
List<DialogueLineDto> dialogue
|
||||||
) {
|
) {
|
||||||
|
/** Backward-compatible constructor without dialogue. */
|
||||||
|
public NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) {
|
||||||
|
this(id, name, description, greeting, reactions, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of a quest. */
|
||||||
|
public record QuestDto(String id, String title, Boolean autoStart,
|
||||||
|
List<QuestStageDto> stages, List<EffectDto> onComplete) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of a quest stage. */
|
||||||
|
public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) {
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.loader.dto;
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YAML representation of a single NPC reaction.
|
* YAML representation of a single NPC reaction.
|
||||||
*
|
*
|
||||||
@@ -7,11 +9,19 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
* @param response text the NPC says after the exchange
|
* @param response text the NPC says after the exchange
|
||||||
* @param gives id of the item handed back; nullable
|
* @param gives id of the item handed back; nullable
|
||||||
* @param consumes id of the item taken from the player; 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(
|
public record ReactionDto(
|
||||||
String onReceive,
|
String onReceive,
|
||||||
String response,
|
String response,
|
||||||
String gives,
|
String gives,
|
||||||
String consumes
|
String consumes,
|
||||||
|
List<ConditionDto> requires,
|
||||||
|
List<EffectDto> effects
|
||||||
) {
|
) {
|
||||||
|
/** Backward-compatible constructor without requires/effects. */
|
||||||
|
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 exits direction-name to target-room-id map
|
||||||
* @param items ids of items initially in this room
|
* @param items ids of items initially in this room
|
||||||
* @param npcs ids of NPCs 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(
|
public record RoomDto(
|
||||||
String id,
|
String id,
|
||||||
@@ -19,6 +23,15 @@ public record RoomDto(
|
|||||||
String description,
|
String description,
|
||||||
Map<String, String> exits,
|
Map<String, String> exits,
|
||||||
List<String> items,
|
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. */
|
||||||
|
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,7 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of the tutorial document. */
|
||||||
|
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
/** YAML representation of a tutorial step. */
|
||||||
|
public record TutorialStepDto(
|
||||||
|
String instruction,
|
||||||
|
String expect,
|
||||||
|
Integer minArgs,
|
||||||
|
String confirm,
|
||||||
|
String hint
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package thb.jeanluc.adventure.map;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,49 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private MainMenu() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the main menu and returns the chosen action. */
|
||||||
|
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}. */
|
||||||
|
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".
|
||||||
|
*/
|
||||||
|
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,6 @@
|
|||||||
|
package thb.jeanluc.adventure.menu;
|
||||||
|
|
||||||
|
/** Top-level main-menu choices, in display order. */
|
||||||
|
public enum MenuAction {
|
||||||
|
NEW_GAME, LOAD, SETTINGS, QUIT
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
private SettingsMenu() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loops the settings screen until "Back", persisting + applying each change. */
|
||||||
|
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. */
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,35 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
public record Combination(
|
||||||
|
String a,
|
||||||
|
String b,
|
||||||
|
List<Condition> requires,
|
||||||
|
List<String> consume,
|
||||||
|
String produce,
|
||||||
|
List<Effect> effects,
|
||||||
|
String response,
|
||||||
|
String failText
|
||||||
|
) {
|
||||||
|
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). */
|
||||||
|
public static String key(String x, String y) {
|
||||||
|
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This recipe's canonical pair key. */
|
||||||
|
public String key() {
|
||||||
|
return key(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/** A single boolean test evaluated against world state and the player. */
|
||||||
|
public record Condition(Type type, String arg) {
|
||||||
|
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** An alternate room description shown while its conditions hold. */
|
||||||
|
public record DescriptionState(List<Condition> when, String text) {
|
||||||
|
public DescriptionState {
|
||||||
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A conditional line an NPC says on {@code talk}, first match wins. */
|
||||||
|
public record DialogueLine(List<Condition> when, String text) {
|
||||||
|
public DialogueLine {
|
||||||
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/** A single state mutation or message. */
|
||||||
|
public record Effect(Type type, String arg) {
|
||||||
|
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY, START_QUEST }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A game ending: shown (and ends the game) when its conditions hold. */
|
||||||
|
public record Ending(String id, String title, boolean victory, List<Condition> when, String text) {
|
||||||
|
public Ending {
|
||||||
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A condition-gated exit with a message shown when it is blocked. */
|
||||||
|
public record ExitLock(List<Condition> requires, String blocked) {
|
||||||
|
public ExitLock {
|
||||||
|
requires = requires == null ? List.of() : List.copyOf(requires);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package thb.jeanluc.adventure.model;
|
|||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -34,6 +36,19 @@ public class Npc {
|
|||||||
*/
|
*/
|
||||||
private final Map<String, NpcReaction> reactions;
|
private final Map<String, NpcReaction> reactions;
|
||||||
|
|
||||||
|
/** Optional condition-gated dialogue lines, first match wins; else {@link #greeting}. */
|
||||||
|
@Builder.Default
|
||||||
|
private final List<DialogueLine> dialogue = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a conditional dialogue line.
|
||||||
|
*
|
||||||
|
* @param line the dialogue line
|
||||||
|
*/
|
||||||
|
public void addDialogue(DialogueLine line) {
|
||||||
|
dialogue.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up a reaction triggered by an item.
|
* Looks up a reaction triggered by an item.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import lombok.Builder;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single trigger/response pair on an NPC. When the player gives the
|
* A single trigger/response pair on an NPC. When the player gives the
|
||||||
* NPC an item matching {@link #consumes}, the NPC writes {@link #response}
|
* NPC an item matching {@link #consumes}, the NPC writes {@link #response}
|
||||||
@@ -21,4 +23,10 @@ public class NpcReaction {
|
|||||||
|
|
||||||
/** Text the NPC says after a successful exchange. */
|
/** Text the NPC says after a successful exchange. */
|
||||||
private final String response;
|
private final String response;
|
||||||
|
|
||||||
|
/** Conditions that must hold for the exchange; null/empty = always. */
|
||||||
|
private final List<Condition> requires;
|
||||||
|
|
||||||
|
/** Effects applied after a successful exchange; null = none. */
|
||||||
|
private final List<Effect> effects;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import lombok.Setter;
|
|||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player avatar. Tracks the current room, an ordered inventory, and a
|
* The player avatar. Tracks the current room, an ordered inventory, and a
|
||||||
@@ -15,9 +17,11 @@ import java.util.Optional;
|
|||||||
public class Player {
|
public class Player {
|
||||||
|
|
||||||
/** Room the player is currently standing in. */
|
/** Room the player is currently standing in. */
|
||||||
@Setter
|
|
||||||
private Room currentRoom;
|
private Room currentRoom;
|
||||||
|
|
||||||
|
/** Ids of rooms the player has entered, in first-visit order (for the map). */
|
||||||
|
private final Set<String> visitedRoomIds = new LinkedHashSet<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory keyed by item id. {@link LinkedHashMap} keeps insertion
|
* Inventory keyed by item id. {@link LinkedHashMap} keeps insertion
|
||||||
* order for stable {@code inventory} listings while still providing
|
* order for stable {@code inventory} listings while still providing
|
||||||
@@ -44,6 +48,17 @@ public class Player {
|
|||||||
}
|
}
|
||||||
this.currentRoom = startRoom;
|
this.currentRoom = startRoom;
|
||||||
this.gold = startGold;
|
this.gold = startGold;
|
||||||
|
this.visitedRoomIds.add(startRoom.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the player into a room and records it as visited.
|
||||||
|
*
|
||||||
|
* @param room the room now occupied; must not be null
|
||||||
|
*/
|
||||||
|
public void setCurrentRoom(Room room) {
|
||||||
|
this.currentRoom = room;
|
||||||
|
this.visitedRoomIds.add(room.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A multi-stage quest. Stage completion is condition-driven; rewards are effects. */
|
||||||
|
public record Quest(String id, String title, boolean autoStart,
|
||||||
|
List<QuestStage> stages, List<Effect> onComplete) {
|
||||||
|
public Quest {
|
||||||
|
stages = stages == null ? List.of() : List.copyOf(stages);
|
||||||
|
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** One stage of a {@link Quest}: an objective, a completion condition, optional rewards. */
|
||||||
|
public record QuestStage(String objective, List<Condition> completion, List<Effect> onComplete) {
|
||||||
|
public QuestStage {
|
||||||
|
completion = completion == null ? List.of() : List.copyOf(completion);
|
||||||
|
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +45,20 @@ public class Room {
|
|||||||
/** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */
|
/** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */
|
||||||
private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>();
|
private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/** Optional condition-gates per exit direction. */
|
||||||
|
private final EnumMap<Direction, ExitLock> exitLocks = new EnumMap<>(Direction.class);
|
||||||
|
|
||||||
|
/** Optional condition-gated description variants, first match wins. */
|
||||||
|
private final List<DescriptionState> descriptionStates = new ArrayList<>();
|
||||||
|
|
||||||
|
/** Whether this room is dark (needs a light source to enter/see). */
|
||||||
|
@Setter
|
||||||
|
private boolean dark;
|
||||||
|
|
||||||
|
/** Optional GUI background-music track filename for this room; null = none. */
|
||||||
|
@Setter
|
||||||
|
private String music;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects this room to another in the given direction. Does not
|
* Connects this room to another in the given direction. Does not
|
||||||
* create the reverse connection — callers must set that up
|
* create the reverse connection — callers must set that up
|
||||||
@@ -54,6 +71,25 @@ public class Room {
|
|||||||
exits.put(direction, neighbour);
|
exits.put(direction, neighbour);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a condition-gate to the exit in the given direction.
|
||||||
|
*
|
||||||
|
* @param direction the gated direction
|
||||||
|
* @param lock the lock to apply
|
||||||
|
*/
|
||||||
|
public void addExitLock(Direction direction, ExitLock lock) {
|
||||||
|
exitLocks.put(direction, lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a condition-gated description variant (first match wins).
|
||||||
|
*
|
||||||
|
* @param state the description variant
|
||||||
|
*/
|
||||||
|
public void addDescriptionState(DescriptionState state) {
|
||||||
|
descriptionStates.add(state);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up the room reachable in the given direction.
|
* Looks up the room reachable in the given direction.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */
|
||||||
|
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
|
||||||
|
|
||||||
|
public Tutorial {
|
||||||
|
steps = steps == null ? List.of() : List.copyOf(steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return steps.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An absent tutorial (no steps). */
|
||||||
|
public static Tutorial none() {
|
||||||
|
return new Tutorial(null, List.of(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user