Compare commits
65 Commits
83643a192f
...
6dcfbd62ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dcfbd62ef | |||
| 9ea07470a9 | |||
| 3cac6cb796 | |||
| e65e3b600a | |||
| acd797bf65 | |||
| 5d11d7730c | |||
| 122ff37a09 | |||
| 7295463805 | |||
| 3189b057a0 | |||
| f9bd2d9a67 | |||
| 3ac9540a6a | |||
| 0f40ee637d | |||
| eb78c7840e | |||
| 55944a0343 | |||
| e360a754a6 | |||
| c2e5789118 | |||
| fe9ac65f25 | |||
| 423cf85c39 | |||
| 4e0e951a8d | |||
| 84ab176b47 | |||
| 20d4f1470a | |||
| 59781e5cbd | |||
| 93a5da3af7 | |||
| acaae3c4f7 | |||
| ab008ee562 | |||
| 9d23cb01b7 | |||
| 8cb2b360e6 | |||
| e3b80736dd | |||
| c339d13244 | |||
| bf10f11438 | |||
| c4542165ab | |||
| f79382c43a | |||
| fdc4915431 | |||
| 8b1232b2fc | |||
| 5fb2446373 | |||
| aff1768808 | |||
| e0f5821fbb | |||
| b182b09070 | |||
| 8243d5dfc5 | |||
| dd0b13fbd8 | |||
| 7605b724b6 | |||
| a846297abe | |||
| cda18a75cd | |||
| 820c17aea9 | |||
| da2e90faec | |||
| 7298427eaf | |||
| 025372b9b1 | |||
| 0e3f898927 | |||
| 2616c9bea5 | |||
| f91d525fd0 | |||
| 5181622f7d | |||
| 94ce5ab2f7 | |||
| 42330d9376 | |||
| d25f9f4504 | |||
| c27d719452 | |||
| d033a878db | |||
| d31b583871 | |||
| 95ed54eb25 | |||
| f2b243afb5 | |||
| 32ece84a85 | |||
| 9e7961e903 | |||
| 705d5f9db1 | |||
| 0b9f55d0b1 | |||
| a130cf329b | |||
| 3225599412 |
3
Semesterprojekt/.gitignore
vendored
3
Semesterprojekt/.gitignore
vendored
@@ -37,3 +37,6 @@ build/
|
|||||||
|
|
||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
### Brainstorming visual companion ###
|
||||||
|
.superpowers/
|
||||||
|
|||||||
95
Semesterprojekt/README.md
Normal file
95
Semesterprojekt/README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Haunted Manor – Textadventure
|
||||||
|
|
||||||
|
Semesterprojekt im Modul Programmierung 2 (Algorithmen und Datenstrukturen,
|
||||||
|
2. Semester). Ein textbasiertes Adventure in Java: Der Spieler navigiert durch
|
||||||
|
Räume eines Spukhauses, sammelt Gegenstände, interagiert mit NPCs und löst so
|
||||||
|
das Spiel.
|
||||||
|
|
||||||
|
Die Spielwelt ist vollständig datengetrieben – Räume, Gegenstände und NPCs
|
||||||
|
werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Navigation** durch beliebig viele Räume in den Himmelsrichtungen
|
||||||
|
- **Inventar** mit Aufnehmen (`take`) und Ablegen (`drop`) von Gegenständen
|
||||||
|
- **Item-Hierarchie**: einfache, lesbare (`read`) und schaltbare (`use`) Gegenstände
|
||||||
|
- **NPCs** mit Gesprächen (`talk`) und Geschenk-Reaktionen (`give`)
|
||||||
|
- **Zwei Frontends**: klassische Konsole und Swing-GUI (Bonus)
|
||||||
|
- **Datengetriebene Welt** aus YAML inkl. Referenzauflösung und Validierung beim Laden
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Java 25+
|
||||||
|
- Maven 3.9+
|
||||||
|
|
||||||
|
## Bauen und Testen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn clean test # alle Tests ausführen (67 Tests)
|
||||||
|
mvn clean package # JAR bauen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spielen
|
||||||
|
|
||||||
|
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
|
||||||
|
mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
||||||
|
```
|
||||||
|
|
||||||
|
`mvn exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
|
||||||
|
|
||||||
|
### Befehle im Spiel
|
||||||
|
|
||||||
|
| Befehl | Aliase | Wirkung |
|
||||||
|
|---|---|---|
|
||||||
|
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
|
||||||
|
| `look` | `l` | Aktuellen Raum beschreiben |
|
||||||
|
| `map` | `m` | Karte der erkundeten Räume anzeigen |
|
||||||
|
| `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen |
|
||||||
|
| `inventory` | `inv`, `i` | Inventar anzeigen |
|
||||||
|
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
|
||||||
|
| `drop <item>` | `put` | Gegenstand ablegen |
|
||||||
|
| `use <item>` | | Gegenstand benutzen / schalten |
|
||||||
|
| `read <item>` | | Lesbaren Gegenstand lesen |
|
||||||
|
| `examine <item>` | `x`, `inspect` | Gegenstand genauer ansehen |
|
||||||
|
| `talk <npc>` | `speak` | Mit einem NPC sprechen |
|
||||||
|
| `give <item> <npc>` | | NPC einen Gegenstand geben |
|
||||||
|
| `help` | `?` | Befehlsübersicht anzeigen |
|
||||||
|
| `quit` | `exit` | Spiel beenden |
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
Semesterprojekt/
|
||||||
|
├── src/main/java/thb/jeanluc/adventure/
|
||||||
|
│ ├── App.java - Einstiegspunkt Konsole
|
||||||
|
│ ├── AppGui.java - Einstiegspunkt Swing-GUI
|
||||||
|
│ ├── command/ - Befehlsparser, Registry + Command-Implementierungen
|
||||||
|
│ ├── game/ - Spielschleife (Game) und Kontext (GameContext)
|
||||||
|
│ ├── io/ - GameIO-Abstraktion: ConsoleIO, SwingIO
|
||||||
|
│ ├── loader/ - YAML-Laden: DTOs, Factories, Resolver, Validator
|
||||||
|
│ └── model/ - Domänenmodell: Room, World, Player, Npc, Item-Hierarchie
|
||||||
|
├── src/main/resources/world/
|
||||||
|
│ ├── game.yaml - Metadaten (Titel, Startraum, Begrüßung)
|
||||||
|
│ ├── rooms.yaml - Räume mit Ausgängen, Items, NPCs
|
||||||
|
│ ├── items.yaml - Gegenstände
|
||||||
|
│ └── npcs.yaml - NPCs und ihre Reaktionen
|
||||||
|
└── docs/ - Design- und Architekturdokumentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technologie-Stack
|
||||||
|
|
||||||
|
- **Java 25**
|
||||||
|
- **Jackson** (YAML) für das Laden der Spielwelt
|
||||||
|
- **Lombok** zur Reduktion von Boilerplate
|
||||||
|
- **JUnit 5 + AssertJ + Mockito** für Tests
|
||||||
|
- **SLF4J + Logback** für Logging
|
||||||
|
- **Swing** für die GUI (Bonus)
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
Ausführliche Design- und Architekturdokumentation liegt unter
|
||||||
|
[`docs/`](docs/README.md): Package-Struktur, Datenstrukturen, Item-Modell,
|
||||||
|
YAML-Schemas, Lade-Ablauf, Befehle und NPC-Modell.
|
||||||
198
Semesterprojekt/docs/enhancement-ideas.md
Normal file
198
Semesterprojekt/docs/enhancement-ideas.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Enhancement-Ideen (Backlog)
|
||||||
|
|
||||||
|
> Roh-Sammlung von Ausbau-Ideen über die Abgabe hinaus. Lebendes Dokument –
|
||||||
|
> noch kein Design, noch keine Festlegung. Wird in Teilprojekte zerlegt und
|
||||||
|
> nacheinander über den Brainstorming → Spec → Plan → Implementierung-Zyklus
|
||||||
|
> verfeinert.
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Ausgangslage: Pflichtumfang ("bare minimum") erfüllt,
|
||||||
|
alle Phasen 1–7 grün. Ziel: deutlich mehr Tiefe und Politur.
|
||||||
|
|
||||||
|
## Leitziele
|
||||||
|
|
||||||
|
- **Reichere Ausgabe** statt nacktem Konsolentext – sowohl Konsole als auch GUI.
|
||||||
|
- **GUI soll noch mehr bieten** als die Konsole (mehr Raum für visuelle Mittel).
|
||||||
|
- **~60 Minuten Spielzeit** anpeilen. Viel mehr Inhalt, viel mehr zu tun.
|
||||||
|
- Bestehendes **NPC-System zu einem tieferen Quest-System** ausbauen.
|
||||||
|
|
||||||
|
## Themenblöcke
|
||||||
|
|
||||||
|
### 1. Reichere Präsentation / Ausgabe-Schicht ✅ umgesetzt (Branch `feature/presentation-layer`)
|
||||||
|
|
||||||
|
> Umgesetzt: semantisches Modell (`io.text`), `ConsoleIO` (ANSI + Box-Drawing,
|
||||||
|
> Color/Glyph-Modi), `SwingIO` (4 Regionen, `JTextPane`, gebündelter Font,
|
||||||
|
> HiDPI-Skalierung + Zoom `Strg +/-/0`), HUD pro Zug, Welcome-Banner. 79 Tests grün.
|
||||||
|
> Spec/Plan unter `docs/superpowers/`. Karte/Menü/Save/Musik weiterhin offen.
|
||||||
|
|
||||||
|
|
||||||
|
- Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner,
|
||||||
|
Hervorhebungen für Items/NPCs/Richtungen).
|
||||||
|
- **Konsole**: ANSI-Farben, Box-Drawing-Zeichen für Rahmen, evtl. ASCII-Art /
|
||||||
|
Banner für Räume oder Spielstart.
|
||||||
|
- **GUI (Swing)**: mehr als ein `JTextArea` – Farben, Stile, evtl. Panels,
|
||||||
|
Statusanzeige, Bilder/Icons.
|
||||||
|
|
||||||
|
**Festgelegt (Zielbild Konsole):** Stufe **B als Baseline** (Boxed Heading,
|
||||||
|
gelabelte Sektionen, persistente HUD-Zeile: Ort · Gold · Zug · Licht), **ASCII-Art
|
||||||
|
nur für große Momente** (Spielstart, Finale, Schlüsselräume). Semantische
|
||||||
|
Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
|
||||||
|
|
||||||
|
**Designprinzipien:**
|
||||||
|
- **Nicht überladen / Progressive Disclosure** – Raumeintritt zeigt nur das
|
||||||
|
Wesentliche (Ort, kurze Beschreibung, Auffälliges); Details auf Abruf
|
||||||
|
(`examine`, `look`).
|
||||||
|
- **Info auf Regionen verteilen statt Text stapeln** – die **Karte ersetzt die
|
||||||
|
„Exits:"-Zeile**. GUI: persistentes Map-Panel macht die Exit-Zeile überflüssig.
|
||||||
|
Konsole: kompakte Mini-Map (bei `map` bzw. knapp beim Raumeintritt).
|
||||||
|
|
||||||
|
### 2. Nerd Fonts / Glyphen – das Installationsproblem
|
||||||
|
|
||||||
|
- Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des
|
||||||
|
Spielers installiert sein, sonst Tofu-Kästchen.
|
||||||
|
- Offene Frage: gute Lösung finden, damit es überall funktioniert.
|
||||||
|
- Erste Gedanken:
|
||||||
|
- **GUI**: Font ließe sich *mitliefern* (`.ttf` in Resources, via
|
||||||
|
`Font.createFont` laden) → Installationsproblem entfällt für die GUI.
|
||||||
|
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
|
||||||
|
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
|
||||||
|
|
||||||
|
### 3. Karte / Map ✅ umgesetzt (Branch `feature/map`)
|
||||||
|
|
||||||
|
> Umgesetzt: `MapLayout` (BFS-Gitter-Layout + Fog of War), `MapView`-Modell,
|
||||||
|
> GUI-`MapPanel` (Graphics2D, ersetzt die Exit-Liste), Konsolen-ASCII via
|
||||||
|
> `AsciiMap` + `map`-Befehl, Besuchte-Räume-Tracking auf `Player`, Map-Push pro Zug.
|
||||||
|
> Item/NPC-Marker und Türen-Styling offen (Quest-Teilprojekt). Spec/Plan unter
|
||||||
|
> `docs/superpowers/`.
|
||||||
|
|
||||||
|
|
||||||
|
- Übersichtskarte der Räume – in **beiden** Modi.
|
||||||
|
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
|
||||||
|
- Offene Fragen: nur bereits besuchte Räume zeigen? aktuelle Position markieren?
|
||||||
|
fester Grundriss oder dynamisch aus den Exits berechnet?
|
||||||
|
|
||||||
|
### 4. Gameplay-Ausbau (Inhalt)
|
||||||
|
|
||||||
|
- Deutlich mehr Räume, Items, NPCs.
|
||||||
|
- Mehr zu *tun*: Rätsel, Mechaniken, Abhängigkeiten zwischen Objekten.
|
||||||
|
- Zielgröße: ~60 Min. Spielzeit (laut Annahme machbar, da überwiegend Text).
|
||||||
|
|
||||||
|
### 5. Quest-System (NPC-Ausbau)
|
||||||
|
|
||||||
|
> ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags +
|
||||||
|
> Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen,
|
||||||
|
> bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Demo:
|
||||||
|
> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet.
|
||||||
|
>
|
||||||
|
> ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests
|
||||||
|
> (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt,
|
||||||
|
> Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest
|
||||||
|
> `restore_power`.
|
||||||
|
>
|
||||||
|
> ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven
|
||||||
|
> Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang).
|
||||||
|
> Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`).
|
||||||
|
> **Damit ist das Quest-System (#3) komplett.** Offen: Licht/Dunkelheit &
|
||||||
|
> Item-Kombination sowie #4 Content-Ausbau.
|
||||||
|
|
||||||
|
|
||||||
|
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
|
||||||
|
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
|
||||||
|
Quest-Log, evtl. Win-Condition daran gekoppelt.
|
||||||
|
|
||||||
|
**GUI-Layout (festgelegt):** Quest-Log als **dauerhaft sichtbare Box unterhalb der
|
||||||
|
Karte** im rechten Seitenbereich (Map oben, Quests darunter). Gehört zu Quest-
|
||||||
|
Teilprojekt #2 (Quest-Log); das Fundament #1 reserviert/plant den Slot.
|
||||||
|
|
||||||
|
### 5b. Spielkern: zentraler Bogen + Mechanik-Spine
|
||||||
|
|
||||||
|
**Leitidee (bestätigt):** Bewusst *kondensiert* – wenige Mechaniken, die sich
|
||||||
|
vielfach kombinieren, statt vieler Einzel-Gimmicks. Ein 60-Min-Spiel soll
|
||||||
|
*designt* wirken, nicht gestreckt.
|
||||||
|
|
||||||
|
**Hauptziel:** *Den Strom wieder anschalten* (`power_on`). Der Weg dahin ist eine
|
||||||
|
**Quest-Kette, die den Spieler Raum für Raum führt** (Raum X freischalten → führt
|
||||||
|
zu Raum Y → … → Generator/Sicherungskasten). Das Hauptziel selbst ist das Ende
|
||||||
|
des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei.
|
||||||
|
|
||||||
|
**Mechanik-Spine (5 Bausteine, die sich kombinieren):**
|
||||||
|
|
||||||
|
1. **World-Flags / Zustand** – Rückgrat (`power_on`, `cellar_drained`,
|
||||||
|
`ghost_banished`). Beschreibungen, Exits und NPC-Reaktionen lesen/schreiben sie.
|
||||||
|
2. **Verschlossene Exits & Schalter** – Türen brauchen Schlüssel *oder* Flag;
|
||||||
|
Hebel/Ventile setzen Flags und öffnen Bereiche.
|
||||||
|
3. **Licht & Dunkelheit** – dunkle Räume brauchen eine brennende Lichtquelle,
|
||||||
|
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
|
||||||
|
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
|
||||||
|
> `Light`-Helper, Gating in go/look/take/examine (Modell B: kein Eintritt ohne
|
||||||
|
> Licht), HUD-Licht verdrahtet. Demo: dunkler Dungeon braucht die brennende
|
||||||
|
> Lampe für den Generator.
|
||||||
|
4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat
|
||||||
|
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
||||||
|
werden (jetzt erlaubt, da über MVP hinaus).
|
||||||
|
5. **Quest-Ketten + mehrere Enden** – Teilziele referenzieren Flags; Kette
|
||||||
|
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
|
||||||
|
|
||||||
|
**Beispiel-Puzzle (zeigt das Stapeln):** *Strom wiederherstellen* – Sicherung im
|
||||||
|
gefluteten Keller; erst Ventil → `cellar_drained`; Sicherung einsetzen → Schalter
|
||||||
|
→ `power_on` → oberes Stockwerk/Tür öffnet sich. Ein Puzzle, drei Mechaniken,
|
||||||
|
schaltet einen ganzen Flügel frei.
|
||||||
|
|
||||||
|
*Weitere Puzzle-Ideen im Backlog (optional, nach Bedarf):* Zahlenschloss mit
|
||||||
|
Code aus Lore, Caesar-Chiffre-Brief (`decode`), Geist bannen (Reliquien-Fetch),
|
||||||
|
Melodie-/Sequenz-Rätsel, dunkler Gang. Resource-Meter (Sanity/Öl) **offen** –
|
||||||
|
vorerst puzzle-fokussiert ohne Survival-Druck.
|
||||||
|
|
||||||
|
### 6. Hauptmenü & Settings
|
||||||
|
|
||||||
|
- **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*.
|
||||||
|
- **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl,
|
||||||
|
Zeitstempel) anzeigen und hineinladen.
|
||||||
|
- **Settings-Untermenü**: z.B. Farben an/aus, Typewriter-Tempo, Musik-Lautstärke
|
||||||
|
an/aus, ASCII vs. Unicode.
|
||||||
|
- Sollte in beiden Modi funktionieren: Konsole = nummeriertes Textmenü, GUI =
|
||||||
|
Buttons/Liste. Architektur: Menü-Schicht *oberhalb* des Game-Loops.
|
||||||
|
|
||||||
|
### 7. Speichern / Laden
|
||||||
|
|
||||||
|
- Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume,
|
||||||
|
Quest-Fortschritt) – Format z.B. YAML/JSON.
|
||||||
|
- Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü.
|
||||||
|
|
||||||
|
### 8. Musik (GUI)
|
||||||
|
|
||||||
|
- Hintergrundmusik **nur in der GUI**.
|
||||||
|
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
|
||||||
|
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
|
||||||
|
- **Dateigröße** ist ein Thema → Lösungsrichtung:
|
||||||
|
- Audiodateien **nicht** in JAR/Repo bündeln, sondern externer `music/`-Ordner;
|
||||||
|
Audio ggf. via `.gitignore` ausschließen + dokumentieren, wo Dateien hinkommen.
|
||||||
|
- **Komprimierte Formate** (OGG/MP3) statt WAV, **gestreamt** statt komplett geladen.
|
||||||
|
- Bibliothek erlaubt → kleinen Audio-Decoder einbinden (Java `javax.sound` kann
|
||||||
|
OGG/MP3 nicht nativ).
|
||||||
|
- Loop pro Track, sanfter Wechsel beim Raumwechsel, graceful fallback bei
|
||||||
|
fehlender Datei, Lautstärke aus den Settings.
|
||||||
|
- Architektur: `MusicPlayer`-Komponente hinter Interface; Konsole = No-Op.
|
||||||
|
|
||||||
|
## Festgelegte Erweiterungs-Entscheidungen
|
||||||
|
|
||||||
|
| Entscheidung | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |
|
||||||
|
| Algorithmen-Showcase | weiterhin willkommen, aber mit Standard-Datenstrukturen (z.B. BFS `go to <raum>`, Trie-Autocomplete) |
|
||||||
|
| Bibliotheken erlaubt? | **Ja** (Pflichtteil war bewusst ohne; Erweiterung darf welche nutzen) |
|
||||||
|
| Hauptmenü | ja: Neues Spiel / Laden / Settings / Beenden |
|
||||||
|
| Speichern/Laden | ja, mehrere benannte Spielstände |
|
||||||
|
| Musik | ja, **nur GUI**, pro Raum via YAML, externer Ordner, komprimiert + gestreamt |
|
||||||
|
|
||||||
|
## Offene Designfragen (zu klären)
|
||||||
|
|
||||||
|
- Wie weit darf die `GameIO`-Abstraktion aufgebohrt werden, ohne dass Konsole
|
||||||
|
und GUI auseinanderlaufen? (Gemeinsamer Loop ist ein Kernwert der Architektur.)
|
||||||
|
- Bleibt alles datengetrieben (YAML), auch Quests und Map-Layout?
|
||||||
|
- Reihenfolge / Abhängigkeiten der Teilprojekte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Notiz: Datengetriebenheit (YAML), getrennte DTO/Domain-Schicht und ein für
|
||||||
|
beide Frontends identischer Game-Loop sind etablierte Architekturwerte – neue
|
||||||
|
Features sollten sie respektieren, nicht umgehen.*
|
||||||
458
Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md
Normal file
458
Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
# Win-Condition & Endings Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Priority-ordered, condition-driven endings with an end-of-game summary; the game ends when an ending fires.
|
||||||
|
|
||||||
|
**Architecture:** `Ending` is data (condition + text), loaded from optional `endings.yaml` into an ordered `World.endings`. `EndingEngine.triggered(ctx)` returns the first matching ending; `Game` checks it each turn after the quest tick, prints the ending + summary, and stops. Reuses the existing condition engine and quest log.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok, Jackson/SnakeYAML.
|
||||||
|
|
||||||
|
Spec: [docs/superpowers/specs/2026-05-31-endings-design.md](../specs/2026-05-31-endings-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Ending model, DTO/factory, World.endings, loading
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `model/Ending.java`, `loader/dto/EndingDto.java`, `loader/EndingFactory.java`
|
||||||
|
- Modify: `model/World.java`, `loader/WorldLoader.java`
|
||||||
|
- Test: `loader/EndingLoadingTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `model/Ending.java`**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A game ending: shown (and ends the game) when its conditions hold. */
|
||||||
|
public record Ending(String id, String title, boolean victory, List<Condition> when, String text) {
|
||||||
|
public Ending {
|
||||||
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `endings` to `World.java` with back-compat constructors**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
```java
|
||||||
|
import java.util.List;
|
||||||
|
```
|
||||||
|
Add the field after `quests`:
|
||||||
|
```java
|
||||||
|
/** Ordered list of endings (first matching one wins). */
|
||||||
|
private final List<Ending> endings;
|
||||||
|
```
|
||||||
|
Replace the existing back-compat constructor with two that both delegate to the full constructor:
|
||||||
|
```java
|
||||||
|
/** Backward-compatible constructor for worlds without quests or endings. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds with quests but no endings. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage, Map<String, Quest> quests) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, quests, List.of());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(The 7-arg constructor is the Lombok-generated `@RequiredArgsConstructor` over all final fields in declaration order: rooms, items, npcs, title, welcomeMessage, quests, endings.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `loader/dto/EndingDto.java`**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of a game ending. */
|
||||||
|
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `loader/EndingFactory.java`**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
|
/** Builds {@link Ending} objects from {@link EndingDto}s. */
|
||||||
|
public final class EndingFactory {
|
||||||
|
|
||||||
|
private EndingFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Ending fromDto(EndingDto dto) {
|
||||||
|
return new Ending(
|
||||||
|
dto.id(),
|
||||||
|
dto.title(),
|
||||||
|
Boolean.TRUE.equals(dto.victory()),
|
||||||
|
ConditionDto.toModelList(dto.when()),
|
||||||
|
dto.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Load `endings.yaml` in `WorldLoader.java`**
|
||||||
|
|
||||||
|
Add imports:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
```
|
||||||
|
After the `questDtos` read, add:
|
||||||
|
```java
|
||||||
|
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
||||||
|
```
|
||||||
|
After the quest `requireUniqueIds`, add:
|
||||||
|
```java
|
||||||
|
requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());
|
||||||
|
```
|
||||||
|
Build an **ordered** ending list (preserve YAML order) next to the quest map build:
|
||||||
|
```java
|
||||||
|
List<Ending> endings = new ArrayList<>();
|
||||||
|
for (EndingDto dto : endingDtos) {
|
||||||
|
endings.add(EndingFactory.fromDto(dto));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add the import for `ArrayList` if missing:
|
||||||
|
```java
|
||||||
|
import java.util.ArrayList;
|
||||||
|
```
|
||||||
|
Change the `World` construction to the 7-arg form:
|
||||||
|
```java
|
||||||
|
World world = new World(rooms, items, npcs,
|
||||||
|
gameDto.title(), gameDto.welcomeMessage(), quests, endings);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write the loading test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class EndingLoadingTest {
|
||||||
|
@Test
|
||||||
|
void factoryMapsFields() {
|
||||||
|
Ending e = EndingFactory.fromDto(new EndingDto("victory", "Won", true,
|
||||||
|
List.of(new ConditionDto("manor_secured", null, null)), "You win."));
|
||||||
|
assertThat(e.id()).isEqualTo("victory");
|
||||||
|
assertThat(e.victory()).isTrue();
|
||||||
|
assertThat(e.when()).hasSize(1);
|
||||||
|
assertThat(e.text()).isEqualTo("You win.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=EndingLoadingTest`
|
||||||
|
Expected: PASS.
|
||||||
|
Run: `mvn -q test`
|
||||||
|
Expected: PASS — the real `endings.yaml` does not exist yet, so `readListOptional` returns empty; existing `new World(...)` callers compile via the back-compat constructors.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/model/Ending.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/dto/EndingDto.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/EndingFactory.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/model/World.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java
|
||||||
|
git commit -m "feat: Ending model + endings.yaml loading (ordered, optional)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: EndingEngine + Game wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `game/EndingEngine.java`
|
||||||
|
- Modify: `game/Game.java`
|
||||||
|
- Test: `game/EndingEngineTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class EndingEngineTest {
|
||||||
|
private GameContext ctx(List<Ending> endings) {
|
||||||
|
Player p = new Player(new Room("k", "K", "d"), 0);
|
||||||
|
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), endings);
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Ending ending(String id, boolean victory, String flag) {
|
||||||
|
return new Ending(id, id + "-title", victory,
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, flag)), id + " text");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstMatchingEndingWinsByOrder() {
|
||||||
|
GameContext ctx = ctx(List.of(ending("victory", true, "won"), ending("fled", false, "fled")));
|
||||||
|
ctx.getState().set("won");
|
||||||
|
ctx.getState().set("fled");
|
||||||
|
assertThat(EndingEngine.triggered(ctx).id()).isEqualTo("victory");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noEndingWhenNoConditionHolds() {
|
||||||
|
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
|
||||||
|
assertThat(EndingEngine.triggered(ctx)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renderIncludesTitleTextAndSummary() {
|
||||||
|
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
|
||||||
|
ctx.getState().set("won");
|
||||||
|
String out = EndingEngine.render(EndingEngine.triggered(ctx), ctx, 7).plainText();
|
||||||
|
assertThat(out).contains("victory-title").contains("victory text");
|
||||||
|
assertThat(out).contains("Turns: 7").contains("Quests completed: 0 / 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=EndingEngineTest`
|
||||||
|
Expected: FAIL — `EndingEngine` missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `game/EndingEngine.java`**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.StyledText;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
|
/** Detects the first matching ending and renders the end-of-game screen. */
|
||||||
|
public final class EndingEngine {
|
||||||
|
|
||||||
|
private EndingEngine() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First ending whose conditions hold, in list order; null if none. */
|
||||||
|
public static Ending triggered(GameContext ctx) {
|
||||||
|
for (Ending e : ctx.getWorld().getEndings()) {
|
||||||
|
if (Conditions.all(e.when(), ctx)) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the ending screen: title banner, text, and a run summary. */
|
||||||
|
public static StyledText render(Ending e, GameContext ctx, int turns) {
|
||||||
|
int total = ctx.getWorld().getQuests().size();
|
||||||
|
int done = ctx.getQuestLog().completed().size();
|
||||||
|
String bar = "═".repeat(Math.max(12, e.title().length() + 6));
|
||||||
|
|
||||||
|
StyledText.Builder b = StyledText.builder();
|
||||||
|
b.heading(bar + "\n").heading(" " + e.title() + "\n").heading(bar + "\n");
|
||||||
|
b.plain(e.text().stripTrailing()).plain("\n\n");
|
||||||
|
b.dim("Turns: " + turns + "\n");
|
||||||
|
b.dim("Quests completed: " + done + " / " + total + "\n");
|
||||||
|
b.heading("Rank: " + rank(e, total, done));
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rank(Ending e, int total, int done) {
|
||||||
|
if (e.victory() && total > 0 && done == total) {
|
||||||
|
return "Master of the Manor";
|
||||||
|
}
|
||||||
|
if (e.victory()) {
|
||||||
|
return "Manor Reclaimed";
|
||||||
|
}
|
||||||
|
return "Escaped — the manor keeps its secrets";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire endings into `Game.java`**
|
||||||
|
|
||||||
|
In `Game.java` add a `maybeEnd()` method and call it after each `publishHud()`. Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
```
|
||||||
|
(`EndingEngine` is in the same `game` package — no import.) Update `run()` so it calls `maybeEnd()` after the initial publish and after the per-iteration publish:
|
||||||
|
```java
|
||||||
|
public void run() {
|
||||||
|
publishHud();
|
||||||
|
maybeEnd();
|
||||||
|
while (running) {
|
||||||
|
String input = ctx.getIo().readLine();
|
||||||
|
if (input == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ParsedCommand parsed = parser.parse(input);
|
||||||
|
if (parsed.verb().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Optional<Command> cmd = registry.find(parsed.verb());
|
||||||
|
if (cmd.isEmpty()) {
|
||||||
|
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||||||
|
} else {
|
||||||
|
cmd.get().execute(ctx, parsed.args());
|
||||||
|
turn++;
|
||||||
|
}
|
||||||
|
publishHud();
|
||||||
|
maybeEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void maybeEnd() {
|
||||||
|
Ending e = EndingEngine.triggered(ctx);
|
||||||
|
if (e != null) {
|
||||||
|
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=EndingEngineTest`
|
||||||
|
Expected: PASS.
|
||||||
|
Run: `mvn -q test`
|
||||||
|
Expected: PASS — `GameTest`'s worlds have no endings (empty list), so `maybeEnd()` is a no-op there.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/game/EndingEngine.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/game/Game.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java
|
||||||
|
git commit -m "feat(game): EndingEngine + per-turn end check with summary"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Demo endings + flee door + docs + verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/world/endings.yaml`
|
||||||
|
- Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `docs/enhancement-ideas.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `src/main/resources/world/endings.yaml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: victory
|
||||||
|
title: "The Manor Reclaimed"
|
||||||
|
victory: true
|
||||||
|
when: [{ flag: manor_secured }]
|
||||||
|
text: |
|
||||||
|
The lights hold steady and the whispers fade to nothing. Whatever held
|
||||||
|
this place has loosened its grip. The manor is yours now.
|
||||||
|
- id: fled
|
||||||
|
title: "Into the Night"
|
||||||
|
victory: false
|
||||||
|
when: [{ flag: fled }]
|
||||||
|
text: |
|
||||||
|
You wrench the front door open and bolt into the dark. Safe — but the
|
||||||
|
manor keeps its secrets, and they will keep you awake for years.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a "front door" switch to `items.yaml`**
|
||||||
|
|
||||||
|
Append:
|
||||||
|
```yaml
|
||||||
|
- id: front_door
|
||||||
|
type: switchable
|
||||||
|
name: Front Door
|
||||||
|
description: The heavy front door. It would let you leave the manor for good.
|
||||||
|
state: false
|
||||||
|
onText: |
|
||||||
|
You haul the front door open and step out into the cold night.
|
||||||
|
offText: |
|
||||||
|
You ease the door shut again.
|
||||||
|
effects:
|
||||||
|
- { setFlag: fled }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Place the door in the kitchen (`rooms.yaml`)**
|
||||||
|
|
||||||
|
Change the kitchen's items line from:
|
||||||
|
```yaml
|
||||||
|
items: [letter, lamp]
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```yaml
|
||||||
|
items: [letter, lamp, front_door]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mark 3.3 done in `docs/enhancement-ideas.md`**
|
||||||
|
|
||||||
|
Under the `### 5. Quest-System (NPC-Ausbau)` status block, append:
|
||||||
|
```markdown
|
||||||
|
> ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven
|
||||||
|
> Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang).
|
||||||
|
> Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`).
|
||||||
|
> Damit ist das Quest-System (#3) komplett.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full suite + end-to-end console**
|
||||||
|
|
||||||
|
Run: `mvn -q test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
Victory path:
|
||||||
|
```bash
|
||||||
|
printf 'go south\nuse generator\ngo north\ntake lamp\ngive lamp old_man\n' | mvn -q -DskipTests exec:java@run
|
||||||
|
```
|
||||||
|
Expected: completing the `restore_power` quest sets `manor_secured`; on the next turn the game prints the **"The Manor Reclaimed"** ending banner, the summary (`Turns: …`, `Quests completed: 1 / 1`, `Rank: Master of the Manor`), and exits — no `quit` needed.
|
||||||
|
|
||||||
|
Flee path:
|
||||||
|
```bash
|
||||||
|
printf 'use front_door\n' | mvn -q -DskipTests exec:java@run
|
||||||
|
```
|
||||||
|
Expected: the **"Into the Night"** ending + summary (`Rank: Escaped …`), then exit.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/world/endings.yaml \
|
||||||
|
src/main/resources/world/items.yaml \
|
||||||
|
src/main/resources/world/rooms.yaml \
|
||||||
|
docs/enhancement-ideas.md
|
||||||
|
git commit -m "feat(content): victory & flee endings; front-door flee switch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- **Spec coverage:** model+loader (T1), engine+Game wiring+summary (T2), demo+docs (T3). Scoreboards/turn-conditions deferred.
|
||||||
|
- **Backward compatibility:** `World` gains 5- and 6-arg back-compat constructors delegating to the 7-arg; existing `new World(...)` callers (tests, prior loader pattern) compile unchanged. `endings.yaml` optional. `GameTest` worlds have empty endings → `maybeEnd()` no-op.
|
||||||
|
- **Type consistency:** `EndingEngine.triggered(ctx)`/`render(Ending, ctx, int)`, `Ending.when/victory/title/text`, `World.getEndings`, `EndingFactory.fromDto`, `EndingDto` used consistently.
|
||||||
|
- **Layering:** `Ending` (model) carries no `game` dependency; `EndingEngine` (game) depends on model + io.text.
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# Light & Darkness Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Dark rooms you can't enter, see, or interact with unless you carry a lit light source (the lamp).
|
||||||
|
|
||||||
|
**Architecture:** A `Room.dark` flag and an `Item.light` flag, plus one `Light` helper (`isLit`/`carryingLight`). Four commands gate on it (`go` entry, `look`, `take`, `examine`); the HUD's `light` field is wired to it. Reuses the switchable lamp and is fully backward-compatible (optional flags).
|
||||||
|
|
||||||
|
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok.
|
||||||
|
|
||||||
|
Spec: [docs/superpowers/specs/2026-05-31-light-darkness-design.md](../specs/2026-05-31-light-darkness-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data flags + `Light` helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `model/item/Item.java`, `loader/dto/ItemDto.java`, `loader/ItemFactory.java`, `model/Room.java`, `loader/dto/RoomDto.java`, `loader/RoomFactory.java`
|
||||||
|
- Create: `game/Light.java`
|
||||||
|
- Test: `game/LightTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/game/LightTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LightTest {
|
||||||
|
private GameContext ctx(Player p) {
|
||||||
|
return new GameContext(null, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private SwitchableItem lamp(boolean on) {
|
||||||
|
return SwitchableItem.builder().id("lamp").name("Lamp").description("d")
|
||||||
|
.state(on).onText("on").offText("off").light(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void litRoomIsAlwaysLit() {
|
||||||
|
Room bright = new Room("r", "R", "d"); // dark defaults to false
|
||||||
|
assertThat(Light.isLit(ctx(new Player(bright, 0)), bright)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void darkRoomNeedsActiveLight() {
|
||||||
|
Room dark = new Room("r", "R", "d");
|
||||||
|
dark.setDark(true);
|
||||||
|
Player p = new Player(dark, 0);
|
||||||
|
GameContext ctx = ctx(p);
|
||||||
|
assertThat(Light.isLit(ctx, dark)).isFalse();
|
||||||
|
|
||||||
|
p.addItem(lamp(false));
|
||||||
|
assertThat(Light.isLit(ctx, dark)).isFalse(); // lamp off
|
||||||
|
|
||||||
|
p.removeItem("lamp");
|
||||||
|
p.addItem(lamp(true));
|
||||||
|
assertThat(Light.isLit(ctx, dark)).isTrue(); // lamp on, carried
|
||||||
|
assertThat(Light.carryingLight(ctx)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void litLampLyingInRoomLightsIt() {
|
||||||
|
Room dark = new Room("r", "R", "d");
|
||||||
|
dark.setDark(true);
|
||||||
|
dark.addItem(lamp(true));
|
||||||
|
Player p = new Player(dark, 0);
|
||||||
|
assertThat(Light.isLit(ctx(p), dark)).isTrue();
|
||||||
|
assertThat(Light.carryingLight(ctx(p))).isFalse(); // not carried
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonLightItemDoesNotLight() {
|
||||||
|
Room dark = new Room("r", "R", "d");
|
||||||
|
dark.setDark(true);
|
||||||
|
Player p = new Player(dark, 0);
|
||||||
|
p.addItem(PlainItem.builder().id("rock").name("Rock").description("d").build());
|
||||||
|
assertThat(Light.isLit(ctx(p), dark)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=LightTest`
|
||||||
|
Expected: FAIL — `Item.light` / `Room.setDark` / `Light` missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `light` to `Item.java`**
|
||||||
|
|
||||||
|
Add the field after `description`:
|
||||||
|
```java
|
||||||
|
/** Whether this item can serve as a light source (when on, for switchables). */
|
||||||
|
protected final boolean light;
|
||||||
|
```
|
||||||
|
(`@SuperBuilder` exposes `.light(...)` on every subclass builder; `@Getter` generates `isLight()`. Unset builders default to `false`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `light` to `ItemDto.java` and wire `ItemFactory.java`**
|
||||||
|
|
||||||
|
In `ItemDto.java` append a component (after `effects`) and update the back-compat constructor:
|
||||||
|
```java
|
||||||
|
List<EffectDto> effects,
|
||||||
|
Boolean light
|
||||||
|
```
|
||||||
|
```java
|
||||||
|
/** Backward-compatible constructor without effects/light. */
|
||||||
|
public ItemDto(String type, String id, String name, String description,
|
||||||
|
String readText, Boolean initialState, String onText, String offText) {
|
||||||
|
this(type, id, name, description, readText, initialState, onText, offText, null, null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `ItemFactory.java`, add `.light(Boolean.TRUE.equals(dto.light()))` to **each** of the three builder cases (plain, readable, switchable), e.g.:
|
||||||
|
```java
|
||||||
|
case "plain" -> PlainItem.builder()
|
||||||
|
.id(dto.id())
|
||||||
|
.name(dto.name())
|
||||||
|
.description(dto.description())
|
||||||
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
(and the same `.light(...)` line before `.build()` in the `readable` and `switchable` cases.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `dark` to `Room.java`**
|
||||||
|
|
||||||
|
Add the import if missing:
|
||||||
|
```java
|
||||||
|
import lombok.Setter;
|
||||||
|
```
|
||||||
|
Add the field after `descriptionStates`:
|
||||||
|
```java
|
||||||
|
/** Whether this room is dark (needs a light source to enter/see). */
|
||||||
|
@Setter
|
||||||
|
private boolean dark;
|
||||||
|
```
|
||||||
|
(`@Getter` generates `isDark()`; not a constructor arg, so existing `new Room(id,name,desc)` calls are unaffected.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add `dark` to `RoomDto.java` and wire `RoomFactory.java`**
|
||||||
|
|
||||||
|
In `RoomDto.java` append a component (after `descriptionStates`) and update the back-compat constructor:
|
||||||
|
```java
|
||||||
|
List<DescriptionStateDto> descriptionStates,
|
||||||
|
Boolean dark
|
||||||
|
```
|
||||||
|
```java
|
||||||
|
/** Backward-compatible constructor without the optional state fields. */
|
||||||
|
public RoomDto(String id, String name, String description,
|
||||||
|
Map<String, String> exits, List<String> items, List<String> npcs) {
|
||||||
|
this(id, name, description, exits, items, npcs, null, null, null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `RoomFactory.java`, set `dark` after building the shell:
|
||||||
|
```java
|
||||||
|
public static Room shellFromDto(RoomDto dto) {
|
||||||
|
Room room = new Room(dto.id(), dto.name(), dto.description());
|
||||||
|
room.setDark(Boolean.TRUE.equals(dto.dark()));
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Create `game/Light.java`**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
/** Decides whether a room is lit, given dark rooms and active light sources. */
|
||||||
|
public final class Light {
|
||||||
|
|
||||||
|
private Light() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room is lit if it is not dark, or an active light source is carried or present. */
|
||||||
|
public static boolean isLit(GameContext ctx, Room room) {
|
||||||
|
if (!room.isDark()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasActiveLight(ctx.getPlayer().getInventory().values())
|
||||||
|
|| hasActiveLight(room.getItems().values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the player carries an active light source (for the HUD). */
|
||||||
|
public static boolean carryingLight(GameContext ctx) {
|
||||||
|
return hasActiveLight(ctx.getPlayer().getInventory().values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasActiveLight(Collection<Item> items) {
|
||||||
|
for (Item it : items) {
|
||||||
|
if (isActiveLight(it)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isActiveLight(Item it) {
|
||||||
|
if (!it.isLight()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(it instanceof SwitchableItem s) || s.isOn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=LightTest`
|
||||||
|
Expected: PASS.
|
||||||
|
Run: `mvn -q test`
|
||||||
|
Expected: PASS — flags default off; existing item/room construction unchanged via back-compat constructors.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/model/item/Item.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/model/Room.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/game/Light.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/game/LightTest.java
|
||||||
|
git commit -m "feat: Room.dark + Item.light flags and Light helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Command gating + HUD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `command/impl/GoCommand.java`, `command/impl/LookCommand.java`, `command/impl/TakeCommand.java`, `command/impl/ExamineCommand.java`, `game/Game.java`
|
||||||
|
- Test: `command/impl/DarknessTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DarknessTest {
|
||||||
|
private SwitchableItem lamp(boolean on) {
|
||||||
|
return SwitchableItem.builder().id("lamp").name("Lamp").description("d")
|
||||||
|
.state(on).onText("on").offText("off").light(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cannotEnterDarkRoomWithoutLight() {
|
||||||
|
Room hall = new Room("hall", "Hall", "d");
|
||||||
|
Room cave = new Room("cave", "Cave", "d");
|
||||||
|
cave.setDark(true);
|
||||||
|
hall.addExit(Direction.NORTH, cave);
|
||||||
|
Player p = new Player(hall, 0);
|
||||||
|
GameContext ctx = new GameContext(null, p, new TestIO());
|
||||||
|
|
||||||
|
new GoCommand().execute(ctx, List.of("north"));
|
||||||
|
assertThat(p.getCurrentRoom()).isEqualTo(hall);
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).containsIgnoringCase("pitch black");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canEnterDarkRoomWithLitLamp() {
|
||||||
|
Room hall = new Room("hall", "Hall", "d");
|
||||||
|
Room cave = new Room("cave", "Cave", "d");
|
||||||
|
cave.setDark(true);
|
||||||
|
hall.addExit(Direction.NORTH, cave);
|
||||||
|
Player p = new Player(hall, 0);
|
||||||
|
p.addItem(lamp(true));
|
||||||
|
GameContext ctx = new GameContext(null, p, new TestIO());
|
||||||
|
|
||||||
|
new GoCommand().execute(ctx, List.of("north"));
|
||||||
|
assertThat(p.getCurrentRoom()).isEqualTo(cave);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lookAndTakeAreBlockedInTheDark() {
|
||||||
|
Room cave = new Room("cave", "Cave", "d");
|
||||||
|
cave.setDark(true);
|
||||||
|
Player p = new Player(cave, 0);
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, p, io);
|
||||||
|
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
assertThat(io.allOutput()).containsIgnoringCase("pitch black");
|
||||||
|
|
||||||
|
io.outputs().clear();
|
||||||
|
new TakeCommand().execute(ctx, List.of("anything"));
|
||||||
|
assertThat(io.allOutput()).containsIgnoringCase("too dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=DarknessTest`
|
||||||
|
Expected: FAIL — no darkness gating yet (entering succeeds, look shows room).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Gate `GoCommand`**
|
||||||
|
|
||||||
|
Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
```
|
||||||
|
After the exit-lock check and before `ctx.getPlayer().setCurrentRoom(next.get())`, insert:
|
||||||
|
```java
|
||||||
|
if (!Light.isLit(ctx, next.get())) {
|
||||||
|
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
|
||||||
|
+ "(try lighting your lamp with 'use lamp').");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate `LookCommand`**
|
||||||
|
|
||||||
|
Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
```
|
||||||
|
At the very start of `execute`, after fetching the room:
|
||||||
|
```java
|
||||||
|
Room room = ctx.getPlayer().getCurrentRoom();
|
||||||
|
if (!Light.isLit(ctx, room)) {
|
||||||
|
ctx.getIo().write("It's pitch black; you can't make anything out. You need a light source.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(The existing `Room room = ...` line is already there — replace it with this guarded version, keeping the rest below unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Gate `TakeCommand`**
|
||||||
|
|
||||||
|
Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
```
|
||||||
|
After the `args.isEmpty()` check:
|
||||||
|
```java
|
||||||
|
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||||
|
ctx.getIo().write("It's too dark to see that.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Gate `ExamineCommand`**
|
||||||
|
|
||||||
|
Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
```
|
||||||
|
After the `args.isEmpty()` check:
|
||||||
|
```java
|
||||||
|
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||||
|
ctx.getIo().write("It's too dark to see that.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Wire the HUD `light` field in `Game.java`**
|
||||||
|
|
||||||
|
Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.Light; // same package 'game' — drop if redundant
|
||||||
|
```
|
||||||
|
(`Light` is in package `game`, same as `Game` — no import needed.) In `publishHud()`, change the `Hud` construction's last argument from `false` to `Light.carryingLight(ctx)`:
|
||||||
|
```java
|
||||||
|
ctx.getIo().setHud(new Hud(
|
||||||
|
ctx.getPlayer().getCurrentRoom().getName(),
|
||||||
|
ctx.getPlayer().getGold(),
|
||||||
|
turn,
|
||||||
|
Light.carryingLight(ctx)));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=DarknessTest`
|
||||||
|
Expected: PASS.
|
||||||
|
Run: `mvn -q test`
|
||||||
|
Expected: PASS (existing command tests use non-dark rooms → `isLit` true → no behaviour change).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/game/Game.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java
|
||||||
|
git commit -m "feat: darkness gating for go/look/take/examine + HUD light"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Demo (dark dungeon) + docs + verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `docs/enhancement-ideas.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Make the lamp a light source in `items.yaml`**
|
||||||
|
|
||||||
|
On the `lamp` item, add `light: true`:
|
||||||
|
```yaml
|
||||||
|
- type: switchable
|
||||||
|
id: lamp
|
||||||
|
name: Oil Lamp
|
||||||
|
description: An old oil lamp, heavy with fuel.
|
||||||
|
initialState: false
|
||||||
|
light: true
|
||||||
|
onText: The lamp flares to life, casting a warm glow.
|
||||||
|
offText: You snuff out the lamp.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Make the dungeon dark in `rooms.yaml`**
|
||||||
|
|
||||||
|
On the `dungeon` room, add `dark: true` and tweak the description:
|
||||||
|
```yaml
|
||||||
|
- id: dungeon
|
||||||
|
name: Dungeon
|
||||||
|
description: |
|
||||||
|
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
||||||
|
dark: true
|
||||||
|
exits:
|
||||||
|
north: kitchen
|
||||||
|
items: [generator]
|
||||||
|
npcs: []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Mark the mechanic done in `docs/enhancement-ideas.md`**
|
||||||
|
|
||||||
|
Under the mechanic-spine section (point 3, "Licht & Dunkelheit"), append a status note:
|
||||||
|
```markdown
|
||||||
|
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
|
||||||
|
> `Light`-Helper, Gating in go/look/take/examine, HUD-Licht. Demo: dunkler
|
||||||
|
> Dungeon braucht die brennende Lampe für den Generator.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Full suite + end-to-end console**
|
||||||
|
|
||||||
|
Run: `mvn -q test`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
printf 'go south\ntake lamp\nuse lamp\ngo south\nuse generator\nquit\n' | mvn -q -DskipTests exec:java@run
|
||||||
|
```
|
||||||
|
Expected: the first `go south` is blocked ("pitch black beyond the doorway"); after `take lamp` and `use lamp` (HUD flips to `light: on`), `go south` enters the dungeon and `use generator` restores the power. No exceptions.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/world/items.yaml \
|
||||||
|
src/main/resources/world/rooms.yaml \
|
||||||
|
docs/enhancement-ideas.md
|
||||||
|
git commit -m "feat(content): dark dungeon needs the lit lamp"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- **Spec coverage:** flags + `Light` (T1), command gating + HUD (T2), demo + docs (T3). Fuel/timed light, cross-room radius deferred.
|
||||||
|
- **Backward compatibility:** `Item.light` and `Room.dark` default off; `ItemDto`/`RoomDto` keep back-compat constructors; `Room.dark` is not a constructor arg. Existing tests and YAML unaffected (non-dark rooms are always `isLit`).
|
||||||
|
- **Type consistency:** `Light.isLit(ctx, Room)` / `carryingLight(ctx)`, `Item.isLight()`, `Room.isDark()/setDark()`, `ItemDto.light`, `RoomDto.dark` used consistently.
|
||||||
|
- **Soft-lock safety:** entry-gate model means dark rooms are entered only with light; lit rooms always re-enterable.
|
||||||
1139
Semesterprojekt/docs/superpowers/plans/2026-05-31-map.md
Normal file
1139
Semesterprojekt/docs/superpowers/plans/2026-05-31-map.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1065
Semesterprojekt/docs/superpowers/plans/2026-05-31-quests.md
Normal file
1065
Semesterprojekt/docs/superpowers/plans/2026-05-31-quests.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
# Spec: Win-Condition & Enden (Teilprojekt 3.3)
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Capstone des Quest-Systems. Baut auf Fundament (3.1) + Quests
|
||||||
|
(3.2) auf: Conditions/Effects/Flags + QuestLog sind vorhanden.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Das Spiel endet bisher nur per `quit`. Ziel: **mehrere, priorisierte Enden**,
|
||||||
|
condition-driven (Ansatz wie Quests), plus ein **End-Screen mit Zusammenfassung**
|
||||||
|
(Züge, abgeschlossene Quests, Rang). Bestätigt: **A mit Summary**.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `Ending`-Datenmodell + optionale `endings.yaml` (Reihenfolge = Priorität).
|
||||||
|
- `EndingEngine` (erstes passendes Ende ermitteln + End-Screen rendern).
|
||||||
|
- `Game`-Anbindung: pro Zug nach dem Quest-Tick prüfen; bei Treffer Ende +
|
||||||
|
Summary ausgeben und `stop()`.
|
||||||
|
- End-Summary: Züge, Quests X/Y, Rang.
|
||||||
|
- Loader: `EndingDto`, `EndingFactory`, `World.endings`.
|
||||||
|
- Demo: zwei spielbar erreichbare Enden ohne neue Mechanik.
|
||||||
|
|
||||||
|
**Out of Scope:** Scoreboard/Persistenz; zugbasierte Conditions; Spielstand-Reset.
|
||||||
|
|
||||||
|
## 3. Datenmodell (model)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record Ending(String id, String title, boolean victory,
|
||||||
|
List<Condition> when, String text) { /* when: List.copyOf */ }
|
||||||
|
```
|
||||||
|
`endings.yaml` (geordnete Liste, erstes passendes gewinnt):
|
||||||
|
```yaml
|
||||||
|
- id: victory
|
||||||
|
title: "The Manor Reclaimed"
|
||||||
|
victory: true
|
||||||
|
when: [{ flag: manor_secured }]
|
||||||
|
text: |
|
||||||
|
The lights hold steady, the whispers fade. You've made the manor yours.
|
||||||
|
- id: fled
|
||||||
|
title: "Into the Night"
|
||||||
|
victory: false
|
||||||
|
when: [{ flag: fled }]
|
||||||
|
text: |
|
||||||
|
You bolt through the front door. Safe — but the manor keeps its secrets.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Engine (game)
|
||||||
|
|
||||||
|
- `EndingEngine.triggered(GameContext ctx)` → erstes `Ending`, dessen `when` via
|
||||||
|
`Conditions.all` gilt, sonst `null`. Rein, testbar (Priorität/erster Treffer).
|
||||||
|
- `EndingEngine.render(Ending e, GameContext ctx, int turns)` → `StyledText`:
|
||||||
|
Titel-Banner + `text` + Summary.
|
||||||
|
- **Summary**: `Turns: N`, `Quests completed: X / Y`
|
||||||
|
(`X = questLog.completed().size()`, `Y = world.getQuests().size()`), plus
|
||||||
|
**Rang**:
|
||||||
|
- victory & alle Quests → „Master of the Manor"
|
||||||
|
- victory → „Manor Reclaimed"
|
||||||
|
- sonst → „Escaped — the manor keeps its secrets"
|
||||||
|
|
||||||
|
## 5. Game-Anbindung
|
||||||
|
|
||||||
|
In `Game.run` nach `publishHud()` (Initial **und** pro Schleifendurchlauf) ein
|
||||||
|
`maybeEnd()`:
|
||||||
|
```java
|
||||||
|
private void maybeEnd() {
|
||||||
|
Ending e = EndingEngine.triggered(ctx);
|
||||||
|
if (e != null) {
|
||||||
|
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`stop()` setzt `running=false`; die Schleife endet sauber, kein zusätzlicher Prompt.
|
||||||
|
Konsole: Programm endet. GUI: Worker-Thread endet, Fenster bleibt mit End-Screen.
|
||||||
|
|
||||||
|
## 6. Loading
|
||||||
|
|
||||||
|
- `EndingDto(id, title, Boolean victory, List<ConditionDto> when, String text)`.
|
||||||
|
- `EndingFactory.fromDto` baut `Ending` (`when` via `ConditionDto.toModelList`).
|
||||||
|
- `WorldLoader` liest `endings.yaml` **optional** (`readListOptional`), baut eine
|
||||||
|
**geordnete** `List<Ending>` (Reihenfolge der YAML-Liste), `requireUniqueIds("ending", …)`.
|
||||||
|
- `World` bekommt Feld `endings` (List) + **rückwärtskompatible 5- und
|
||||||
|
6-Arg-Konstruktoren** (leere Quests/Endings), damit bestehende `new World(...)`-
|
||||||
|
Aufrufe (Tests, bisheriger Loader-Pfad) unverändert kompilieren. Der Loader nutzt
|
||||||
|
den vollen 7-Arg-Konstruktor.
|
||||||
|
|
||||||
|
## 7. Fehlerbehandlung
|
||||||
|
|
||||||
|
| Fall | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `endings.yaml` fehlt | leere Liste → Spiel endet nur per `quit` (wie bisher) |
|
||||||
|
| mehrere Conditions treffen | erstes Ende in Listenreihenfolge gewinnt |
|
||||||
|
| Ending ohne `when` | trifft sofort (leere Condition-Liste = true) – Autoren sollten Reihenfolge beachten |
|
||||||
|
| keine Quests definiert | Summary zeigt `0 / 0`, Rang nach victory-Flag |
|
||||||
|
|
||||||
|
## 8. Demonstration (zwei erreichbare Enden, keine neue Mechanik)
|
||||||
|
|
||||||
|
- **victory**: `manor_secured` wird gesetzt, wenn die `restore_power`-Quest endet
|
||||||
|
(bereits vorhanden) → Sieg-Ende.
|
||||||
|
- **fled**: neuer schaltbarer Gegenstand **Front Door** (`effects: setFlag fled`)
|
||||||
|
in der Küche → Flucht-Ende. `victory` steht vor `fled`, also schlägt das
|
||||||
|
Sichern der Villa die Flucht.
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
- `EndingEngine.triggered`: Priorität/erster Treffer; kein Treffer → `null`.
|
||||||
|
- `EndingEngine.render`: enthält Titel, Text, Züge, Quests-Zähler, Rang.
|
||||||
|
- `EndingFactory.fromDto`: Mapping inkl. victory/when/text.
|
||||||
|
- End-to-End (Konsole): Villa sichern → Sieg-Ende + Summary, Spiel endet;
|
||||||
|
alternativ Front Door benutzen → Flucht-Ende.
|
||||||
|
- `Game`-Integration: nicht separat unit-getestet (Loop/IO); über Konsolen-Smoke.
|
||||||
|
|
||||||
|
## 10. Offene Detailfragen (in Implementierung)
|
||||||
|
|
||||||
|
- Genaue Banner-Optik des End-Screens (HEADING-Rahmen).
|
||||||
|
- Rang-Schwellen (zunächst nur victory + alle-Quests-Abfrage).
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Spec: Licht & Dunkelheit (Mechanik-Erweiterung)
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Kleine Mechanik aus dem Spine (#3 der Mechanik-Liste), die das
|
||||||
|
Fundament (Flags/Items) nutzt. Strenge: **Modell B** (Dunkelheit blockiert).
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Dunkle Räume sollen den schaltbaren `lamp` sinnvoll machen: Ohne aktive
|
||||||
|
Lichtquelle kann man einen dunklen Raum **nicht betreten**, nichts sehen
|
||||||
|
(`look`), und nichts aufnehmen/untersuchen. Die HUD-Zeile `light: on/off` wird
|
||||||
|
endlich verdrahtet.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `Room.dark` (Bool, optional), `Item.light` (Bool, optional).
|
||||||
|
- `Light`-Helper: `isLit`, `carryingLight`.
|
||||||
|
- Command-Gating: `go` (Eintritt), `look`, `take`, `examine`; HUD-`lightOn`.
|
||||||
|
- Demo: Dungeon dunkel → Lampe nötig.
|
||||||
|
|
||||||
|
**Out of Scope:** Lampen-Brennstoff/zeitlich begrenztes Licht; Lichtradius über
|
||||||
|
Räume hinweg; Zufall.
|
||||||
|
|
||||||
|
## 3. Daten (rückwärtskompatibel)
|
||||||
|
|
||||||
|
- **`Room.dark`**: `@Setter`-Feld (default `false`), **kein** Konstruktor-Argument →
|
||||||
|
keine Test-Brüche. Vom `RoomFactory` aus `RoomDto.dark` gesetzt. YAML: `dark: true`.
|
||||||
|
- **`Item.light`**: `boolean` am abstrakten `Item` (via `@SuperBuilder`), default
|
||||||
|
`false`. Vom `ItemFactory` aus `ItemDto.light` gesetzt. YAML: `light: true`.
|
||||||
|
- **Aktive Lichtquelle** = `item.light` **und** (kein Switchable **oder**
|
||||||
|
`SwitchableItem.isOn()`). Die `lamp` leuchtet also nur eingeschaltet.
|
||||||
|
|
||||||
|
## 4. Helper `Light` (game)
|
||||||
|
|
||||||
|
```java
|
||||||
|
static boolean isLit(GameContext ctx, Room room) {
|
||||||
|
if (!room.isDark()) return true;
|
||||||
|
return hasActiveLight(player-inventory) || hasActiveLight(room-items);
|
||||||
|
}
|
||||||
|
static boolean carryingLight(GameContext ctx); // aktive Lichtquelle im Inventar
|
||||||
|
```
|
||||||
|
`hasActiveLight(items)` iteriert; `isActiveLight(item)` = `item.isLight()` &&
|
||||||
|
(`!(item instanceof SwitchableItem s)` || `s.isOn()`).
|
||||||
|
|
||||||
|
Räume gelten als beleuchtet, wenn sie nicht dunkel sind **oder** eine aktive
|
||||||
|
Lichtquelle getragen wird **oder** im Raum liegt.
|
||||||
|
|
||||||
|
## 5. Command-Gating (Modell B)
|
||||||
|
|
||||||
|
| Command | Verhalten bei dunklem, unbeleuchtetem Zielraum/aktuellem Raum |
|
||||||
|
|---|---|
|
||||||
|
| `GoCommand` | **Eintritt blockiert**, wenn `!isLit(ctx, target)`: Hinweis-Text, kein Wechsel. (Lichträume bleiben immer betretbar → **kein Soft-Lock**, Rückzug stets möglich.) |
|
||||||
|
| `LookCommand` | `!isLit(ctx, current)` → „It's pitch black; you can't make anything out." statt `RoomView` (Fall: Lampe ging im Raum aus). |
|
||||||
|
| `TakeCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
|
||||||
|
| `ExamineCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
|
||||||
|
| HUD | `lightOn` = `Light.carryingLight(ctx)` (in `Game.publishHud`). |
|
||||||
|
|
||||||
|
Erlaubt im Dunkeln bleiben: `inventory`, `drop`, **`use`** (z. B. Lampe anzünden),
|
||||||
|
`quests`, `map`, `help`. So kann man eine getragene Lampe im Dunkeln anzünden.
|
||||||
|
|
||||||
|
## 6. Fehlerbehandlung / Soft-Lock
|
||||||
|
|
||||||
|
Eintrittssperre statt Innen-Sperre: Man betritt einen dunklen Raum nur **mit**
|
||||||
|
Licht. Geht das Licht drinnen aus, sind `look`/`take`/`examine` blockiert, aber der
|
||||||
|
Rückweg in einen hellen Raum bleibt (Zielraum hell → `isLit` true). Kein Soft-Lock.
|
||||||
|
|
||||||
|
## 7. Demo
|
||||||
|
|
||||||
|
- `lamp`: `light: true` (bereits schaltbar; `use lamp` → an).
|
||||||
|
- `dungeon`: `dark: true`; der Generator steht dort. Ablauf:
|
||||||
|
`take lamp` → `use lamp` (an) → `go south` (jetzt betretbar) → `use generator`.
|
||||||
|
HUD zeigt `light: on`, sobald die Lampe brennt. Gibt man die Lampe später dem
|
||||||
|
alten Mann, verliert man das Licht (thematisch).
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
- `Light.isLit`: nicht dunkel → hell; dunkel ohne Licht → dunkel; dunkel + getragene
|
||||||
|
brennende Lampe → hell; dunkel + Lampe **aus** → dunkel; dunkel + brennende Lampe
|
||||||
|
**im Raum** → hell. `carryingLight`.
|
||||||
|
- Command-Gates: `go` in dunklen Raum ohne Licht blockt, mit brennender Lampe ok;
|
||||||
|
`look`/`take` im dunklen Raum blockt.
|
||||||
|
- End-to-End (Konsole): `go south` ohne Licht blockt; `take lamp`/`use lamp`/`go south`
|
||||||
|
betritt; HUD `light: on`.
|
||||||
|
|
||||||
|
## 9. Offene Detailfragen (in Implementierung)
|
||||||
|
|
||||||
|
- Genauer Hinweis-Text der Eintrittssperre (nennt `use lamp`).
|
||||||
151
Semesterprojekt/docs/superpowers/specs/2026-05-31-map-design.md
Normal file
151
Semesterprojekt/docs/superpowers/specs/2026-05-31-map-design.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Spec: Karte / Mini-Map (Teilprojekt 2)
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Zweites Teilprojekt aus [enhancement-ideas.md](../../enhancement-ideas.md).
|
||||||
|
Baut auf der Präsentations-Schicht (Teilprojekt 1) auf, insbesondere dem
|
||||||
|
reservierten GUI-Seitenpanel und dem semantischen Modell-+-Renderer-Muster.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Räume bilden bereits einen gerichteten N/S/O/W-Graphen über `Room.exits`
|
||||||
|
(`EnumMap<Direction, Room>`). Ziel: eine **Karte** in beiden Frontends, die diesen
|
||||||
|
Graphen sichtbar macht – **Fog of War** (nur Entdecktes), Stil **„umrandete Räume +
|
||||||
|
Korridore"** (im Brainstorming bestätigt). Die GUI zeichnet grafisch (Graphics2D),
|
||||||
|
die Konsole spiegelt es als ASCII.
|
||||||
|
|
||||||
|
Bestätigte Entscheidungen:
|
||||||
|
- **Fog of War**: nur besuchte Räume + deren direkte Nachbarn als `?`-Stub.
|
||||||
|
- **Stil**: umrandete Räume, durch Korridore verbunden; aktueller Raum hervorgehoben.
|
||||||
|
- **Layout aus dem Graphen abgeleitet** (keine Koordinaten in YAML).
|
||||||
|
- GUI: persistentes Panel ersetzt die Exit-Zeile. Konsole: `map`-Befehl on demand,
|
||||||
|
Exit-Zeile im Raumtext bleibt.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `MapView`-Modell (platzierte Zellen + Verbindungen, Zustände).
|
||||||
|
- `MapLayout`-Service: BFS-Layout aus dem Exit-Graphen + Fog-of-War-Filter.
|
||||||
|
- **Besuchte-Räume-Tracking** auf `Player`.
|
||||||
|
- `GameIO`: `setMap` (Push pro Zug, GUI) + `showMap` (on demand, Konsole).
|
||||||
|
- `MapCommand` (`map`, `m`).
|
||||||
|
- `Game`-Loop ruft `setMap` pro Zug (neben `setHud`).
|
||||||
|
- GUI: `MapPanel` (Graphics2D) im Seitenpanel, scrollbar, auf aktuellen Raum zentriert.
|
||||||
|
- Konsole: ASCII-Karte (`AsciiMap`), respektiert `GlyphMode`.
|
||||||
|
|
||||||
|
**Out of Scope (spätere Teilprojekte):**
|
||||||
|
- Marker für Items/NPCs auf der Karte.
|
||||||
|
- Styling für verschlossene/blockierte Exits (Quest-Teilprojekt; Karte bekommt
|
||||||
|
später einen Hook dafür).
|
||||||
|
- Zoom über das Scrollen hinaus.
|
||||||
|
|
||||||
|
## 3. Architektur (Modell + Renderer pro Frontend)
|
||||||
|
|
||||||
|
`MapLayout` erzeugt aus `World` (Exit-Graph), der Menge besuchter Räume und dem
|
||||||
|
aktuellen Raum eine frontend-agnostische `MapView`. Layout und Fog-of-War-Logik
|
||||||
|
liegen ausschließlich hier; die Renderer kennen nur das fertige `MapView`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W["World (Exit-Graph)"] --> L["MapLayout.compute()"]
|
||||||
|
V["Player.visitedRoomIds"] --> L
|
||||||
|
C["aktueller Raum"] --> L
|
||||||
|
L --> MV["MapView (Zellen + Verbindungen)"]
|
||||||
|
MV -- "setMap()" --> GUI["SwingIO → MapPanel (Graphics2D)"]
|
||||||
|
MV -- "showMap()" --> CON["ConsoleIO → AsciiMap (StyledText)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Komponenten & Datenmodell
|
||||||
|
|
||||||
|
Views in `io.text`, Layout-Logik in neuem Package `map`, GUI-Panel in `io`.
|
||||||
|
|
||||||
|
```java
|
||||||
|
// io.text
|
||||||
|
public enum CellState { CURRENT, VISITED, KNOWN }
|
||||||
|
|
||||||
|
public record RoomCell(String id, String name, int x, int y, CellState state) {}
|
||||||
|
|
||||||
|
public record Connection(RoomCell from, RoomCell to) {} // Stil via Zellzustand
|
||||||
|
|
||||||
|
public record MapView(java.util.List<RoomCell> cells,
|
||||||
|
java.util.List<Connection> connections) {
|
||||||
|
public MapView { cells = List.copyOf(cells); connections = List.copyOf(connections); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `map.MapLayout` – `static MapView compute(World world, String startRoomId, Set<String> visited, Room current)`.
|
||||||
|
- `io.text.AsciiMap` – `static StyledText render(MapView, GlyphMode)`.
|
||||||
|
- `io.MapPanel` – `JComponent`, `void show(MapView)`; `paintComponent` zeichnet.
|
||||||
|
- `command.impl.MapCommand` – baut `MapView`, ruft `io.showMap(view)`.
|
||||||
|
- `model.Player` – `Set<String> visitedRoomIds` (LinkedHashSet, Reihenfolge stabil).
|
||||||
|
|
||||||
|
### GameIO-Erweiterung (additiv)
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Push pro Zug: GUI aktualisiert ihr Panel; Konsole ignoriert (kein Spam). */
|
||||||
|
default void setMap(MapView view) { /* no-op */ }
|
||||||
|
|
||||||
|
/** On demand (map-Befehl): rendert die Karte. Default = ASCII in UNICODE. */
|
||||||
|
default void showMap(MapView view) { print(AsciiMap.render(view, GlyphMode.UNICODE)); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`ConsoleIO`** überschreibt `showMap` → `print(AsciiMap.render(view, this.glyphs))`
|
||||||
|
(nutzt also seinen konfigurierten Glyph-Modus). `setMap` bleibt Default (no-op) →
|
||||||
|
kein Karten-Spam pro Zug in der Konsole.
|
||||||
|
- **`SwingIO`** überschreibt `setMap` → `MapPanel` neu zeichnen; `showMap` → no-op
|
||||||
|
(das Panel ist ohnehin sichtbar).
|
||||||
|
- **`TestIO`** nutzt beide Defaults: `setMap` = no-op, `showMap` druckt UNICODE-ASCII
|
||||||
|
in `outputs` → `MapCommand`-Test kann darauf assertieren. Kein Override nötig.
|
||||||
|
|
||||||
|
So liegt der Glyph-Modus dort, wo er hingehört (am Konsolen-Renderer), ohne die
|
||||||
|
`GameIO`-Schnittstelle um eine Glyph-Methode aufzublähen.
|
||||||
|
|
||||||
|
## 5. Fog-of-War-Regeln
|
||||||
|
|
||||||
|
| Zustand | Bedeutung | Darstellung |
|
||||||
|
|---|---|---|
|
||||||
|
| `CURRENT` | besuchter Raum, in dem der Spieler steht | Goldener Rahmen |
|
||||||
|
| `VISITED` | bereits betretener Raum | heller Rahmen + Name |
|
||||||
|
| `KNOWN` | direkter Exit-Nachbar eines besuchten Raums, noch nicht betreten | gestrichelter `?`-Stub |
|
||||||
|
| (hidden) | alles andere | nicht platziert |
|
||||||
|
|
||||||
|
Beim Betreten eines `?`-Raums wird er `VISITED`; seine Nachbarn werden zu neuen
|
||||||
|
`KNOWN`-Stubs. Die Karte wächst eine Ringschicht voraus.
|
||||||
|
|
||||||
|
## 6. Layout-Algorithmus & Konfliktbehandlung
|
||||||
|
|
||||||
|
BFS vom Startraum über den **vollständigen** Exit-Graphen; jede Richtung verschiebt
|
||||||
|
die Gitterkoordinate (N→y−1, S→y+1, O→x+1, W→x−1). Hand-gebaute Welten sind
|
||||||
|
geometrisch konsistent; falls BFS zwei Räume auf dieselbe Zelle legt (nicht-planarer
|
||||||
|
Graph), **gewinnt der erste**, die Kollision wird geloggt, das Rendern läuft weiter
|
||||||
|
(kein Crash). Die Fog-of-War-Filterung läuft nach dem Layout; danach werden die
|
||||||
|
Koordinaten normalisiert (Verschiebung, sodass min = 0,0).
|
||||||
|
|
||||||
|
## 7. Konsolen-Verhalten
|
||||||
|
|
||||||
|
`map` / `m` druckt die ASCII-Karte on demand (keine persistente Region in der
|
||||||
|
Konsole). Die Box-Raumansicht behält ihre Inline-`Exits:`-Zeile – Konsolenspieler
|
||||||
|
müssen also nicht `map` tippen, um zu navigieren; die Karte ist Überblick-Bonus. In
|
||||||
|
der GUI ersetzt das Dauerpanel die Exit-Zeile.
|
||||||
|
|
||||||
|
## 8. Fehlerbehandlung
|
||||||
|
|
||||||
|
| Fall | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Koordinaten-Kollision (nicht-planar) | erste Zuordnung gewinnt, Warnung geloggt, Rendern fährt fort |
|
||||||
|
| Startraum hat keine Exits | Karte zeigt nur die aktuelle Zelle |
|
||||||
|
| Sehr große Karte (viele Räume) | GUI-Panel scrollbar, auf aktuelle Zelle zentriert; ASCII so groß wie nötig |
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
- `MapLayout` (reine Logik): Koordinaten aus bekanntem Graphen; Fog-Zustände für eine
|
||||||
|
gegebene Visited-Menge; Kollisionsbehandlung; Normalisierung.
|
||||||
|
- `MapView`/Records: Immutabilität.
|
||||||
|
- `AsciiMap`: strukturelle Assertions auf bekanntem `MapView` (Namen vorhanden,
|
||||||
|
aktueller markiert, `?` für `KNOWN`), Farbe aus.
|
||||||
|
- `MapCommand`: über `TestIO` – erzeugt Karten-Ausgabe.
|
||||||
|
- `Player`: Besuchte-Räume-Tracking (Start + nach Bewegung).
|
||||||
|
- `SwingIO`/`MapPanel`: nicht unit-getestet (Graphics2D/EDT); manueller Smoke-Test.
|
||||||
|
|
||||||
|
## 10. Offene Detailfragen (in Implementierung entscheidbar)
|
||||||
|
|
||||||
|
- ASCII-Zellbreite/-höhe und Verbindungslänge (Lesbarkeit vs. Kompaktheit).
|
||||||
|
- Genaue Farben/Strichstärken im `MapPanel` (an Präsentations-Palette angelehnt).
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Spec: Präsentations-/Ausgabe-Schicht (Teilprojekt 1)
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Erstes Teilprojekt aus [enhancement-ideas.md](../../enhancement-ideas.md).
|
||||||
|
Baut auf der v1.0-Baseline (Tag `v1.0`) auf.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
`GameIO` bietet aktuell nur `readLine()` + `write(String)`; beide Frontends
|
||||||
|
(`ConsoleIO`, `SwingIO`) schieben rohen Text durch. Ziel: **reichere, strukturierte
|
||||||
|
Ausgabe** in beiden Modi, ohne den für beide Frontends **identischen Game-Loop**
|
||||||
|
aufzugeben (Kernwert der Architektur).
|
||||||
|
|
||||||
|
Zielbild (bestätigt im Brainstorming):
|
||||||
|
- Konsole: **Stufe B** – Boxed Heading, gelabelte Sektionen, persistente HUD-Zeile;
|
||||||
|
ASCII-Art nur für große Momente.
|
||||||
|
- GUI ist der **Fokus** (mehr Freiheit), Konsole wird weiter unterstützt.
|
||||||
|
- Semantische Farb-Rollen für Items / NPCs / Exits / Headings / Gefahr.
|
||||||
|
- **Progressive Disclosure** (nicht überladen) und **Info auf Regionen verteilen**
|
||||||
|
(Karte ersetzt später die Exit-Zeile).
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- Semantisches Ausgabe-Modell (`Style`, `Span`, `StyledText` + Builder).
|
||||||
|
- High-Level-Views: `RoomView`, `Hud`.
|
||||||
|
- Erweiterung der `GameIO`-Schnittstelle (additiv, `write(String)` bleibt als Default).
|
||||||
|
- `ConsoleIO` als Renderer: ANSI-Farben + Box-Drawing (Stufe B), HUD-Zeile,
|
||||||
|
ASCII-Art-Banner-Mechanismus, gestufte Glyph-Fähigkeit.
|
||||||
|
- `SwingIO` als Renderer: Region-Layout (HUD oben, Output mittig, Seitenpanel
|
||||||
|
rechts reserviert, Input unten), `JTextPane`/`StyledDocument`, **gebündelter Font**.
|
||||||
|
- Migration der bestehenden Commands auf die neue API; `TestIO` angepasst.
|
||||||
|
|
||||||
|
**Out of Scope (jeweils eigene spätere Specs):**
|
||||||
|
- Tatsächliches Zeichnen der **Karte** (Teilprojekt 2) – Seitenpanel ist nur Slot,
|
||||||
|
zeigt bis dahin die Exit-Liste.
|
||||||
|
- **Hauptmenü/Settings** (6), **Speichern/Laden** (7), **Musik** (8).
|
||||||
|
- Neue **Inhalte/Mechaniken** (Quests, Licht, Item-Kombination).
|
||||||
|
|
||||||
|
Die Schnittstellen lassen bewusst Slots (Seitenpanel, HUD-Felder) für diese
|
||||||
|
späteren Teile.
|
||||||
|
|
||||||
|
## 3. Architektur (Approach 2)
|
||||||
|
|
||||||
|
**Commands erzeugen Bedeutung, kein Format.** Ein Command baut ein semantisches
|
||||||
|
Objekt (`StyledText` oder eine View) und übergibt es an `GameIO`. Jedes Frontend
|
||||||
|
**rendert** es selbst: `ConsoleIO` → ANSI/Box-Drawing, `SwingIO` → Swing-Komponenten
|
||||||
|
und **eigenes Layout**. Der Game-Loop bleibt unverändert.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
CMD["Command<br/>(z.B. LookCommand)"] -- "baut" --> VIEW["RoomView / StyledText / Hud<br/>(semantisch, kein Format)"]
|
||||||
|
VIEW -- "GameIO.showRoom() / print() / setHud()" --> IO{{"GameIO"}}
|
||||||
|
IO --> CON["ConsoleIO<br/>ANSI + Box-Drawing"]
|
||||||
|
IO --> GUI["SwingIO<br/>JTextPane + Panels"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Komponenten & API
|
||||||
|
|
||||||
|
Neues Subpackage `io.text` für das Modell **und** die Views (`Style`, `Span`,
|
||||||
|
`StyledText`, `RoomView`, `Hud`); die Renderer (`ConsoleIO`, `SwingIO`) bleiben in `io`.
|
||||||
|
|
||||||
|
### 4.1 Style (enum)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }
|
||||||
|
```
|
||||||
|
|
||||||
|
Kleines, bewusst begrenztes Rollen-Set. Mapping auf konkrete Farben/Attribute
|
||||||
|
liegt im jeweiligen Renderer, nicht am Style.
|
||||||
|
|
||||||
|
### 4.2 Span & StyledText
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record Span(String text, Style style) {}
|
||||||
|
|
||||||
|
public final class StyledText {
|
||||||
|
private final List<Span> spans; // immutable
|
||||||
|
public List<Span> spans() { ... }
|
||||||
|
public String plainText() { ... } // Spans konkateniert, ohne Format
|
||||||
|
|
||||||
|
public static StyledText of(String s) { ... } // ein PLAIN-Span
|
||||||
|
public static Builder builder() { ... }
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
Builder plain(String s); Builder heading(String s);
|
||||||
|
Builder item(String s); Builder npc(String s);
|
||||||
|
Builder exit(String s); Builder danger(String s);
|
||||||
|
Builder dim(String s); StyledText build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
```java
|
||||||
|
io.print(StyledText.builder()
|
||||||
|
.plain("You pick up ").item("the brass lamp").plain(".").build());
|
||||||
|
```
|
||||||
|
Zeilenumbrüche stehen als `\n` im Text der Spans.
|
||||||
|
|
||||||
|
### 4.3 Views
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record RoomView(String name, String description,
|
||||||
|
List<String> items, List<String> npcs,
|
||||||
|
List<String> exits) {}
|
||||||
|
|
||||||
|
public record Hud(String location, int gold, int turn, boolean lightOn) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Views tragen bereits **anzeigefertige Strings** (Namen aufgelöst), aber **kein
|
||||||
|
Layout**. `RoomView.exits` versorgt vorerst das GUI-Seitenpanel; sobald Teilprojekt 2
|
||||||
|
die Karte zeichnet, blendet der GUI-Renderer die Exit-Sektion aus.
|
||||||
|
|
||||||
|
### 4.4 GameIO (erweitert, additiv)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface GameIO {
|
||||||
|
String readLine();
|
||||||
|
void print(StyledText text); // gestylter Ausgabe-Block
|
||||||
|
void showRoom(RoomView room); // High-Level-Raumansicht
|
||||||
|
void setHud(Hud hud); // HUD-Region aktualisieren
|
||||||
|
|
||||||
|
default void write(String s) { // Komfort + Rückwärtskompatibilität
|
||||||
|
print(StyledText.of(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`write(String)` bleibt als Default → bestehende Aufrufstellen kompilieren weiter;
|
||||||
|
Migration kann schrittweise erfolgen.
|
||||||
|
|
||||||
|
### 4.5 ConsoleIO (Renderer)
|
||||||
|
|
||||||
|
- `print`: Spans → ANSI-Sequenzen (Rollen→Farbe), dann `println`.
|
||||||
|
- `showRoom`: Stufe-B-Layout mit Box-Drawing-Rahmen (Heading + Sektionen
|
||||||
|
„You see / Here is / Exits"), Rollen-Farben.
|
||||||
|
- `setHud`: dim gefärbte Statuszeile (`[ Kitchen · 0 gold · turn 7 · light: off ]`).
|
||||||
|
- **ASCII-Art-Banner**: Methode/Helfer für große Momente (Start, Finale); Kunst aus
|
||||||
|
Ressourcen geladen. Mechanismus in Scope, konkrete Kunstwerke folgen mit Inhalten.
|
||||||
|
- **Konfiguration** (Defaults sicher, Verdrahtung mit Settings-Menü später):
|
||||||
|
- `ColorMode`: an/aus; Auto-Erkennung (`System.console() == null` → keine ANSI).
|
||||||
|
- `GlyphMode`: `ASCII | UNICODE | GLYPH`, Default `UNICODE` (Box-Drawing breit
|
||||||
|
unterstützt; Nerd-Glyphen nur bei explizitem Opt-in). Bei `ASCII` degradieren
|
||||||
|
die Rahmen auf `+`, `-`, `|` statt der Unicode-Box-Drawing-Zeichen.
|
||||||
|
|
||||||
|
### 4.6 SwingIO (Renderer) + Layout
|
||||||
|
|
||||||
|
Region-Layout (bestätigt, Variante A):
|
||||||
|
- **NORTH**: HUD-Leiste (`JPanel` mit `JLabel`s) – Ort · Gold · Zug · Licht.
|
||||||
|
- **CENTER**: `JTextPane` mit `StyledDocument` (Farben/Stile via `AttributeSet` pro
|
||||||
|
`Style`), scrollbar.
|
||||||
|
- **EAST**: Seitenpanel, reserviert für Karte; zeigt bis dahin `RoomView.exits` als Liste.
|
||||||
|
- **SOUTH**: Eingabefeld (bestehende `LinkedBlockingQueue`-Brücke bleibt).
|
||||||
|
|
||||||
|
**Gebündelter Font:** `.ttf` unter `src/main/resources/fonts/`, via
|
||||||
|
`Font.createFont(Font.TRUETYPE_FONT, stream)` geladen und registriert → Farben und
|
||||||
|
**Glyphen funktionieren ohne Installation auf dem Spieler-System**. Fallback auf
|
||||||
|
`Font.MONOSPACED` bei Ladefehler (geloggt).
|
||||||
|
|
||||||
|
`print`/`showRoom` schreiben gestylte Spans in das `JTextPane`; `setHud` aktualisiert
|
||||||
|
die Labels. Alle Swing-Mutationen via `SwingUtilities.invokeLater` (wie bisher).
|
||||||
|
|
||||||
|
## 5. Datenfluss
|
||||||
|
|
||||||
|
```
|
||||||
|
Command → baut RoomView/StyledText/Hud → GameIO.{showRoom|print|setHud}
|
||||||
|
→ Frontend-Renderer → Konsole (ANSI) bzw. GUI (Swing)
|
||||||
|
```
|
||||||
|
Game-Loop, Parser, Registry, Domain: **unverändert**.
|
||||||
|
|
||||||
|
## 6. Nerd-Font / Glyph-Strategie (zusammengefasst)
|
||||||
|
|
||||||
|
- **GUI**: Font mitliefern (bundled `.ttf`) → Problem gelöst, keine Installation nötig.
|
||||||
|
- **Konsole**: Font nicht kontrollierbar → **gestufte Fallbacks** (`ASCII` →
|
||||||
|
`UNICODE`-Box-Drawing → `GLYPH`). Default `UNICODE`. Keine harte Abhängigkeit von
|
||||||
|
installierten Nerd Fonts.
|
||||||
|
|
||||||
|
## 7. Migration der Commands & Tests
|
||||||
|
|
||||||
|
- `LookCommand`: statt `StringBuilder` eine `RoomView` bauen → `io.showRoom(view)`.
|
||||||
|
- Übrige Commands (`Take`, `Drop`, `Use`, `Read`, `Examine`, `Talk`, `Give`,
|
||||||
|
`Inventory`, `Help`): `io.print(StyledText…)` mit Rollen, wo es Mehrwert bringt;
|
||||||
|
sonst genügt das Default-`write(String)`. Schrittweise, nicht Big-Bang.
|
||||||
|
- `TestIO`: implementiert `print`/`showRoom`/`setHud` durch **Flattening auf
|
||||||
|
Klartext** (`StyledText.plainText()` bzw. RoomView→Textblock), abgelegt in
|
||||||
|
`outputs`. `lastOutput()`/`allOutput()` liefern weiterhin Klartext → die
|
||||||
|
bestehenden `.contains(...)`-Assertions der 67 Tests bleiben gültig.
|
||||||
|
|
||||||
|
## 8. Fehlerbehandlung
|
||||||
|
|
||||||
|
| Fall | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Gebündelter Font nicht ladbar | Log-Warnung, Fallback `Font.MONOSPACED` |
|
||||||
|
| Terminal ohne ANSI / Umleitung | Auto-Erkennung → Farben aus, reiner Text |
|
||||||
|
| `GlyphMode=GLYPH` ohne passende Terminal-Font | Nutzer-Opt-in; kein Auto-Fallback nötig, Default bleibt `UNICODE` |
|
||||||
|
| `null`-Text an `StyledText.of` | `IllegalArgumentException` (wie bestehende Konventionen) |
|
||||||
|
|
||||||
|
## 9. Testing-Strategie
|
||||||
|
|
||||||
|
- **Unit**: `StyledText`-Builder & `plainText()`, `Span`, Immutabilität von
|
||||||
|
`RoomView`/`Hud`.
|
||||||
|
- **ConsoleIO**: Rendern einer bekannten `RoomView`/`Hud` mit Farbe AUS gegen einen
|
||||||
|
`StringWriter`/`PrintStream` (Test-Konstruktor existiert bereits) → strukturelle
|
||||||
|
Assertions (Heading, Labels, Exit-Namen).
|
||||||
|
- **Command-Tests**: laufen unverändert über das angepasste `TestIO` (Klartext).
|
||||||
|
- **SwingIO**: nicht unit-getestet (EDT/Swing); Logik dünn halten, manueller
|
||||||
|
Smoke-Test. Verifikation per `mvn exec:java@gui`.
|
||||||
|
|
||||||
|
## 10. Handoffs an spätere Teilprojekte
|
||||||
|
|
||||||
|
- **Karte (2)**: GUI-Seitenpanel + künftige `showMap(MapView)`-Methode;
|
||||||
|
`RoomView.exits` ist bis dahin Platzhalter-Quelle.
|
||||||
|
- **Menü/Settings (6)**: verdrahtet später `ColorMode`/`GlyphMode`/Lautstärke.
|
||||||
|
- **Save (7)**, **Musik (8)**: nutzen die HUD/Region-Slots, hier nicht implementiert.
|
||||||
|
|
||||||
|
## 11. Offene Detailfragen (in Implementierung entscheidbar)
|
||||||
|
|
||||||
|
- Konkrete Wahl des gebündelten Fonts (freier Monospace bzw. Nerd-Font-Variante,
|
||||||
|
Lizenz prüfen).
|
||||||
|
- Exakte ANSI-Farbpalette pro Rolle.
|
||||||
|
- Ob `Hud` schon ein `lightOn` führt, obwohl die Licht-Mechanik erst mit den Inhalten
|
||||||
|
kommt → Feld vorsehen, vorerst konstant `false`/ausgeblendet.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Spec: Quest-Fundament – World-State & Bedingungen/Effekte (Teilprojekt 3.1)
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Erstes Stück des Quest-Systems (Teilprojekt 3) aus
|
||||||
|
[enhancement-ideas.md](../../enhancement-ideas.md). Liefert die Zustands- und
|
||||||
|
Bedingungs-/Effekt-Engine, auf der Quests (#3.2) und Enden (#3.3) aufbauen.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Das NPC-System ist heute statisch: fester `greeting`, Reaktionen
|
||||||
|
(`consumes`/`gives`/`response`) ohne Bedingungen, kein Weltzustand. Ziel:
|
||||||
|
**gated progression** datengetrieben ermöglichen – verschlossene Türen,
|
||||||
|
zustandsabhängige Beschreibungen, reaktive NPCs, Schalter, die Weltzustand setzen –
|
||||||
|
über **eine** kleine, wiederverwendbare Bedingungs-/Effekt-Sprache (Ansatz A).
|
||||||
|
|
||||||
|
Scope **core-only** (bestätigt): Licht/Dunkelheit und Item-Kombination werden
|
||||||
|
bewusst auf spätere kleine Folge-Specs verschoben.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- **`GameState`** – Flag-Speicher (benannte Booleans).
|
||||||
|
- **Condition/Effect-Modell** + Auswerter (`Conditions`) + Anwender (`Effects`).
|
||||||
|
- Fünf Integrationspunkte: verschlossene Exits, zustandsabhängige
|
||||||
|
Raumbeschreibungen, bedingte NPC-Dialoge, Reaktionen mit `requires`/`effects`,
|
||||||
|
Schalter mit `effects`.
|
||||||
|
- Loader/DTO/Factory/Resolver-Erweiterungen, alle neuen YAML-Felder **optional**
|
||||||
|
(Rückwärtskompatibilität).
|
||||||
|
|
||||||
|
**Out of Scope (eigene spätere Specs):**
|
||||||
|
- Licht & Dunkelheit; Item-Kombination (`use X on Y`).
|
||||||
|
- Quest-Objekte, Quest-Log, GUI-Quest-Box (Teilprojekt #3.2).
|
||||||
|
- Win-Condition / mehrere Enden (#3.3).
|
||||||
|
- `startFlags`-Seeding (YAGNI – Flags starten leer = alle false, was der
|
||||||
|
Ausgangslage „Strom aus, Keller geflutet" entspricht).
|
||||||
|
|
||||||
|
## 3. Kern-Engine (Ansatz A)
|
||||||
|
|
||||||
|
### 3.1 GameState
|
||||||
|
|
||||||
|
`GameState` (Package `game`) hält die gesetzten Flags als `Set<String>`
|
||||||
|
(vorhanden = true). API: `isSet(name)`, `set(name)`, `clear(name)`, `all()`.
|
||||||
|
|
||||||
|
`GameContext` bekommt ein **selbst-initialisiertes** Feld
|
||||||
|
`private final GameState state = new GameState();` (per `@Getter` lesbar). Da das
|
||||||
|
Feld initialisiert ist, bleibt der `@RequiredArgsConstructor` (world, player, io)
|
||||||
|
**unverändert** – keine Anpassung der bestehenden Konstruktionsstellen nötig.
|
||||||
|
|
||||||
|
### 3.2 Condition (Daten in `model`, Auswerter in `game`)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record Condition(Type type, String arg) {
|
||||||
|
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`Conditions.all(List<Condition>, GameContext)` → true, wenn alle gelten (leere
|
||||||
|
Liste = true):
|
||||||
|
- `FLAG` → `state.isSet(arg)`
|
||||||
|
- `NOT_FLAG` → `!state.isSet(arg)`
|
||||||
|
- `HAS_ITEM` → `player.hasItem(arg)`
|
||||||
|
|
||||||
|
### 3.3 Effect (Daten in `model`, Anwender in `game`)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record Effect(Type type, String arg) {
|
||||||
|
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`Effects.applyAll(List<Effect>, GameContext)` wendet jeden an:
|
||||||
|
- `SET_FLAG`/`CLEAR_FLAG` → `state.set/clear(arg)`
|
||||||
|
- `GIVE_ITEM` → Item aus `world.getItems().get(arg)` ins Inventar (unbekannt →
|
||||||
|
Warnung geloggt, übersprungen)
|
||||||
|
- `REMOVE_ITEM` → `player.removeItem(arg)`
|
||||||
|
- `SAY` → `io.write(arg)`
|
||||||
|
|
||||||
|
## 4. Integrationspunkte
|
||||||
|
|
||||||
|
Jeder trägt nur `requires` (Conditions) und/oder `effects` und ruft die Helfer.
|
||||||
|
|
||||||
|
| # | Träger (model) | YAML-Feld | Konsument | Verhalten |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `Room.exitLocks: EnumMap<Direction, ExitLock>` | `exitLocks` | `GoCommand` | Vor Bewegung: gilt `requires` nicht → `blocked` ausgeben, nicht bewegen |
|
||||||
|
| 2 | `Room.descriptionStates: List<DescriptionState>` | `descriptionStates` | `LookCommand` | Erste passende Variante als Beschreibung, sonst `description` |
|
||||||
|
| 3 | `Npc.dialogue: List<DialogueLine>` | `dialogue` | `TalkCommand` | Erste passende Zeile, sonst `greeting` |
|
||||||
|
| 4 | `NpcReaction.requires/effects` | `requires`, `effects` | `GiveCommand` | `requires` prüfen; bei Erfolg consume/give wie bisher + `effects` |
|
||||||
|
| 5 | `SwitchableItem.effects: List<Effect>` | `effects` | `use` | Beim Schalten **auf „on"**: `effects` anwenden |
|
||||||
|
|
||||||
|
Hilfstypen (records in `model`): `ExitLock(List<Condition> requires, String blocked)`,
|
||||||
|
`DescriptionState(List<Condition> when, String text)`,
|
||||||
|
`DialogueLine(List<Condition> when, String text)`.
|
||||||
|
|
||||||
|
### YAML-Beispiele
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# rooms.yaml
|
||||||
|
exits: { east: cellar }
|
||||||
|
exitLocks:
|
||||||
|
- direction: east
|
||||||
|
requires: [{ flag: power_on }]
|
||||||
|
blocked: "The cellar door won't budge without power."
|
||||||
|
descriptionStates:
|
||||||
|
- when: [{ flag: power_on }]
|
||||||
|
text: "The cellar, now lit, reveals a workbench."
|
||||||
|
|
||||||
|
# npcs.yaml
|
||||||
|
dialogue:
|
||||||
|
- when: [{ flag: power_on }]
|
||||||
|
text: '"You did it — the lights are back!"'
|
||||||
|
reactions:
|
||||||
|
- onReceive: fuse
|
||||||
|
requires: [{ flag: cellar_drained }]
|
||||||
|
response: "That'll hold now."
|
||||||
|
effects:
|
||||||
|
- { removeItem: fuse }
|
||||||
|
- { setFlag: fuse_installed }
|
||||||
|
|
||||||
|
# items.yaml
|
||||||
|
- id: breaker
|
||||||
|
type: switchable
|
||||||
|
name: Breaker
|
||||||
|
onText: "You flip the breaker. The manor hums to life."
|
||||||
|
offText: "You switch the breaker off."
|
||||||
|
state: false
|
||||||
|
effects:
|
||||||
|
- { setFlag: power_on }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Loading / DTO / Mapping
|
||||||
|
|
||||||
|
- Neue DTOs (`loader.dto`): `ConditionDto(flag, notFlag, hasItem)`,
|
||||||
|
`EffectDto(setFlag, clearFlag, giveItem, removeItem, say)`, `ExitLockDto`,
|
||||||
|
`DescriptionStateDto`, `DialogueLineDto`. Jeweils `toModel()`.
|
||||||
|
- Erweiterte DTOs: `RoomDto` (+`exitLocks`, +`descriptionStates`), `NpcDto`
|
||||||
|
(+`dialogue`), `ReactionDto` (+`requires`, +`effects`), `ItemDto` (+`effects`).
|
||||||
|
- `ConditionDto.toModel()`: genau ein Feld gesetzt → entsprechende `Condition`,
|
||||||
|
sonst `WorldLoadException`. Analog `EffectDto`.
|
||||||
|
- Verdrahtung: `ReferenceResolver.resolveRooms` baut `exitLocks` (Direction via
|
||||||
|
`Direction.fromString`) und `descriptionStates`; `resolveNpcs` baut `dialogue`
|
||||||
|
und erweitert die `NpcReaction` um `requires`/`effects`; `ItemFactory` setzt
|
||||||
|
`SwitchableItem.effects`.
|
||||||
|
- **Item-Referenzen in Conditions/Effects** (`hasItem`/`giveItem`/`removeItem`)
|
||||||
|
werden **nicht** beim Laden aufgelöst, sondern zur Laufzeit (lazy) – kein
|
||||||
|
Eingriff in die strikte Resolver-Validierung; `GIVE_ITEM` unbekannt ist
|
||||||
|
laufzeit-tolerant (Warnung).
|
||||||
|
|
||||||
|
## 6. Fehlerbehandlung
|
||||||
|
|
||||||
|
| Fall | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Condition/Effect-DTO ohne gesetztes Feld | `WorldLoadException` beim Laden |
|
||||||
|
| `exitLocks` mit unbekannter Richtung | `WorldLoadException` (`Direction.fromString`) |
|
||||||
|
| `GIVE_ITEM` mit unbekannter Item-ID | Warnung geloggt, Effekt übersprungen |
|
||||||
|
| Neue Felder fehlen (alte YAMLs) | null → als leere Liste behandelt, lädt normal |
|
||||||
|
| Schalter `off`→`on` mehrfach | `effects` werden bei jedem Übergang auf „on" angewendet (idempotent bei `setFlag`) |
|
||||||
|
|
||||||
|
## 7. Rückwärtskompatibilität
|
||||||
|
|
||||||
|
Alle neuen Felder sind optional (nullable). Bestehende `rooms.yaml`, `npcs.yaml`,
|
||||||
|
`items.yaml` und alle Test-Fixtures laden unverändert; die 90 bestehenden Tests
|
||||||
|
bleiben grün. `NpcReaction` behält `consumes`/`gives`/`response`.
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
- **Unit:** `GameState` (set/clear/isSet); `Conditions.all` (FLAG/NOT_FLAG/HAS_ITEM,
|
||||||
|
AND, leer=true); `Effects.applyAll` (alle fünf Typen, unbekanntes Item tolerant);
|
||||||
|
`ConditionDto.toModel`/`EffectDto.toModel` (inkl. Fehlerfall).
|
||||||
|
- **Integration (in-Code-Welt wie bestehende Command-Tests):**
|
||||||
|
- `GoCommand`: gesperrter Exit blockt ohne Flag, passiert mit Flag.
|
||||||
|
- `LookCommand`: Beschreibung wechselt mit Flag.
|
||||||
|
- `TalkCommand`: Dialogzeile wechselt mit Flag, sonst `greeting`.
|
||||||
|
- `GiveCommand`: Reaktion mit `requires` blockt/erlaubt; `effects` setzen Flag.
|
||||||
|
- `SwitchableItem.use`: setzt Flag beim Einschalten.
|
||||||
|
- **Loader:** ein Round-Trip-Test, der ein YAML mit allen neuen Feldern lädt;
|
||||||
|
ein Test, dass alte YAMLs ohne neue Felder weiterhin laden.
|
||||||
|
|
||||||
|
## 9. Demonstration (am Ende, minimal)
|
||||||
|
|
||||||
|
Eine kleine, sichere Erweiterung der echten Welt-YAMLs, um die Kette
|
||||||
|
end-to-end spielbar zu zeigen (z. B. Schalter setzt ein Flag, das eine Tür
|
||||||
|
öffnet und eine Beschreibung ändert) – ohne bestehende Tests zu berühren.
|
||||||
|
|
||||||
|
## 10. Offene Detailfragen (in Implementierung entscheidbar)
|
||||||
|
|
||||||
|
- `SAY`-Effekt: schlicht `io.write` oder gestylt (`StyledText`) – zunächst `write`.
|
||||||
|
- Ob `ExitLock`/`DescriptionState`/`DialogueLine` als records in `model` oder
|
||||||
|
`model.state` liegen – im Plan festgelegt (`model`).
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Spec: Quests & Quest-Log (Teilprojekt 3.2)
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Baut auf dem Quest-Fundament (3.1) auf: Flags, Conditions,
|
||||||
|
Effects sind vorhanden. Liefert echte Quest-Objekte, eine Fortschritts-Engine und
|
||||||
|
die Anzeige (Konsolen-Befehl + GUI-Quest-Box unter der Karte).
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Das Fundament erlaubt gated progression über Flags. Jetzt: **strukturierte Quests**
|
||||||
|
mit mehreren Stufen, deren Fortschritt **automatisch aus dem Weltzustand** erkannt
|
||||||
|
wird (bestätigt: Ansatz A, condition-driven), plus sichtbares **Quest-Log**.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- Quest-Datenmodell (`Quest`, `QuestStage`) + `quests.yaml`.
|
||||||
|
- Runtime: `QuestLog` (Fortschritt), `QuestEngine` (Tick pro Zug, Auto-Advance,
|
||||||
|
Ansagen), `START_QUEST`-Effekt.
|
||||||
|
- Anzeige: `QuestView`-Modell, `GameIO.setQuests/showQuests`, Konsolen-Befehl
|
||||||
|
`quests`, GUI-Quest-Box **unter der Karte**.
|
||||||
|
- Loader: `QuestDto`/`QuestStageDto`, `QuestFactory`, `quests.yaml` (optional),
|
||||||
|
`World.quests`.
|
||||||
|
- Demo-Quest in der echten Welt.
|
||||||
|
|
||||||
|
**Out of Scope (3.3 / später):**
|
||||||
|
- Win-Condition / mehrere Enden (3.3) – eine Quest kann aber per `onComplete`-Effekt
|
||||||
|
ein Flag setzen, das 3.3 nutzt.
|
||||||
|
- Quest-Verzweigung, Fehlschlag-Zustände, Voraussetzungen (per `startQuest`-Effekt
|
||||||
|
ausdrückbar).
|
||||||
|
|
||||||
|
## 3. Datenmodell (model)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record Quest(String id, String title, boolean autoStart,
|
||||||
|
List<QuestStage> stages, List<Effect> onComplete) { /* List.copyOf */ }
|
||||||
|
|
||||||
|
public record QuestStage(String objective, List<Condition> completion,
|
||||||
|
List<Effect> onComplete) { /* List.copyOf */ }
|
||||||
|
```
|
||||||
|
`quests.yaml`:
|
||||||
|
```yaml
|
||||||
|
- id: restore_power
|
||||||
|
title: "Bring the Manor to Life"
|
||||||
|
autoStart: true
|
||||||
|
stages:
|
||||||
|
- objective: "Get the power running."
|
||||||
|
completion: [{ flag: power_on }]
|
||||||
|
- objective: "Earn the Old Man's brass key."
|
||||||
|
completion: [{ hasItem: key }]
|
||||||
|
onComplete:
|
||||||
|
- { setFlag: manor_secured }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Runtime (game)
|
||||||
|
|
||||||
|
### 4.1 QuestLog (in GameContext)
|
||||||
|
|
||||||
|
`QuestLog` (selbst-initialisiertes Feld in `GameContext`, wie `GameState` → kein
|
||||||
|
Konstruktor-Eingriff). Hält nur Laufzeit-Fortschritt:
|
||||||
|
- `Map<String, Integer> stageIndex` – aktuelle Stufe je *aktiver* Quest.
|
||||||
|
- `Set<String> completed` – abgeschlossene Quest-IDs.
|
||||||
|
- API: `start(id)`, `isActive(id)`, `isCompleted(id)`, `stageIndex(id)`,
|
||||||
|
`advance(id)`, `complete(id)`.
|
||||||
|
|
||||||
|
### 4.2 START_QUEST-Effekt
|
||||||
|
|
||||||
|
`Effect.Type.START_QUEST` (arg = Quest-ID). `Effects.apply` ruft
|
||||||
|
`ctx.getQuestLog().start(arg)`. `EffectDto` bekommt Feld `startQuest`. Damit können
|
||||||
|
Dialoge/Schalter/Reaktionen Quests starten.
|
||||||
|
|
||||||
|
### 4.3 QuestEngine.tick(ctx)
|
||||||
|
|
||||||
|
Pro Zug aus der `Game`-Schleife (neben `setHud`/`setMap`):
|
||||||
|
1. Auto-Start: jede `autoStart`-Quest, die weder aktiv noch abgeschlossen ist, starten.
|
||||||
|
2. Für jede **aktive** Quest die `completion` der **aktuellen** Stufe via
|
||||||
|
`Conditions.all` prüfen. Erfüllt →
|
||||||
|
- `onComplete`-Effekte der Stufe anwenden,
|
||||||
|
- Ansage (`io.print`, z. B. „✓ Objective complete: …"),
|
||||||
|
- `advance`; war es die letzte Stufe → Quest-`onComplete` anwenden, `complete`,
|
||||||
|
Ansage „Quest complete: …".
|
||||||
|
3. **Kaskade**: Schleife wiederholen, solange sich etwas geändert hat (begrenzt durch
|
||||||
|
Gesamt-Stufenzahl), damit ein Zug mehrere Stufen auslösen kann.
|
||||||
|
|
||||||
|
`tick` ist idempotent (abgeschlossene Stufen feuern nicht erneut). `QuestEngine.viewOf(ctx)`
|
||||||
|
baut die `QuestView` aus `World.getQuests()` + `QuestLog`.
|
||||||
|
|
||||||
|
## 5. Anzeige
|
||||||
|
|
||||||
|
### 5.1 Modell (io.text)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record QuestEntry(String title, String objective) {}
|
||||||
|
public record QuestView(List<QuestEntry> active, List<String> completed) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 GameIO (additiv, wie die Karte)
|
||||||
|
|
||||||
|
```java
|
||||||
|
default void setQuests(QuestView view) { /* no-op */ } // Push pro Zug (GUI)
|
||||||
|
default void showQuests(QuestView view) { print(QuestText.render(view)); } // on demand (Konsole)
|
||||||
|
```
|
||||||
|
- **Konsole**: nutzt beide Defaults. `setQuests` = no-op; `showQuests` druckt via
|
||||||
|
`QuestText.render` (io.text), ausgelöst durch `QuestsCommand`.
|
||||||
|
- **GUI**: überschreibt `setQuests` (Quest-Box neu zeichnen) und `showQuests` (no-op,
|
||||||
|
Box ist immer sichtbar).
|
||||||
|
- `TestIO`: Defaults; `showQuests` druckt in `outputs` → testbar.
|
||||||
|
|
||||||
|
### 5.3 Konsolen-Befehl
|
||||||
|
|
||||||
|
`QuestsCommand` (`quests`, `log`, `journal`) → `io.showQuests(QuestEngine.viewOf(ctx))`.
|
||||||
|
Ausgabe: aktive Quests (Titel + aktuelles Ziel), darunter abgeschlossene (dim, mit ✓).
|
||||||
|
|
||||||
|
### 5.4 GUI-Quest-Box
|
||||||
|
|
||||||
|
Rechter Seitenbereich wird zu einem Panel: **Karte oben, Quest-Box unten**
|
||||||
|
(`BorderLayout`: Map-Scroll CENTER, Quest-Scroll SOUTH mit fester/anteiliger Höhe).
|
||||||
|
Die Quest-Box ist ein gestyltes, schreibgeschütztes Textfeld (`JTextPane`): aktive
|
||||||
|
Quests fett (Titel) + Ziel; abgeschlossene dim mit ✓. Font folgt der Auto-Skalierung
|
||||||
|
(`applyFonts`). `setQuests` aktualisiert sie pro Zug.
|
||||||
|
|
||||||
|
## 6. Loading
|
||||||
|
|
||||||
|
- `QuestDto(id, title, Boolean autoStart, List<QuestStageDto> stages, List<EffectDto> onComplete)`,
|
||||||
|
`QuestStageDto(objective, List<ConditionDto> completion, List<EffectDto> onComplete)`.
|
||||||
|
- `QuestFactory.fromDto` baut `Quest` (Conditions/Effects via vorhandene
|
||||||
|
`toModelList`); keine Resolver-Phase nötig (Flags/Items lazy, Quest-IDs sind Strings).
|
||||||
|
- `WorldLoader` liest `quests.yaml` **optional** (fehlt die Datei → leere Liste),
|
||||||
|
baut `Map<String, Quest>` und übergibt sie an `World`.
|
||||||
|
- `World` bekommt Feld `quests` + **rückwärtskompatiblen 5-Arg-Konstruktor**
|
||||||
|
(delegiert mit leerer Quest-Map), damit bestehende `new World(...)`-Aufrufe in Tests
|
||||||
|
unverändert kompilieren.
|
||||||
|
|
||||||
|
## 7. Fehlerbehandlung
|
||||||
|
|
||||||
|
| Fall | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `quests.yaml` fehlt | leere Quest-Map, Spiel läuft ohne Quests |
|
||||||
|
| `START_QUEST` mit unbekannter Quest-ID | `QuestLog.start` legt Eintrag an; `viewOf` ignoriert IDs ohne Definition (Warnung geloggt) |
|
||||||
|
| Quest ohne Stufen | gilt sofort als abgeschlossen beim ersten Tick |
|
||||||
|
| Doppelte Quest-IDs | `WorldLoader.requireUniqueIds` erweitert um Quests |
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
- **QuestLog**: start/advance/complete, isActive/isCompleted.
|
||||||
|
- **QuestEngine**: Auto-Start; Stufe rückt vor, wenn Condition gilt; Ansage; Quest
|
||||||
|
schließt nach letzter Stufe ab; Kaskade (eine Stufe löst die nächste); `viewOf`
|
||||||
|
liefert aktive + abgeschlossene korrekt.
|
||||||
|
- **Effects**: `START_QUEST` startet Quest.
|
||||||
|
- **EffectDto/QuestDto**: `toModel`/`fromDto`-Mapping.
|
||||||
|
- **QuestsCommand**: über `TestIO` – Ausgabe enthält aktives Ziel.
|
||||||
|
- **WorldLoader**: lädt `quests.yaml`; fehlende Datei → leer.
|
||||||
|
- **GUI**: nicht unit-getestet; manueller Smoke (`exec:java@gui`).
|
||||||
|
|
||||||
|
## 9. Demonstration
|
||||||
|
|
||||||
|
`quests.yaml` mit `restore_power` (autoStart, 2 Stufen): Strom anschalten →
|
||||||
|
Schlüssel vom alten Mann holen. Sichtbar im Konsolen-`quests`-Befehl und in der
|
||||||
|
GUI-Box; Ansagen beim Erfüllen.
|
||||||
|
|
||||||
|
## 10. Offene Detailfragen (in Implementierung)
|
||||||
|
|
||||||
|
- Höhe/Anteil der GUI-Quest-Box (ca. 30 % der Seitenleiste, scrollbar).
|
||||||
|
- Stil der Ansagen (zunächst `HEADING`).
|
||||||
@@ -97,6 +97,28 @@
|
|||||||
<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.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>run</id>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>thb.jeanluc.adventure.App</mainClass>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>gui</id>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>thb.jeanluc.adventure.AppGui</mainClass>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>thb.jeanluc.adventure.App</mainClass>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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.MapCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.QuestsCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.ReadCommand;
|
import thb.jeanluc.adventure.command.impl.ReadCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||||
@@ -19,6 +21,7 @@ import thb.jeanluc.adventure.game.Game;
|
|||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
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.WorldLoader;
|
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +70,8 @@ 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 HelpCommand(registry), "help", "?");
|
||||||
@@ -77,7 +82,7 @@ public final class App {
|
|||||||
Game game = new Game(ctx, registry, new CommandParser());
|
Game game = new Game(ctx, registry, new CommandParser());
|
||||||
quit.bind(game);
|
quit.bind(game);
|
||||||
|
|
||||||
io.write(loaded.world().getTitle());
|
io.print(Banner.welcome(loaded.world().getTitle()));
|
||||||
io.write(loaded.world().getWelcomeMessage());
|
io.write(loaded.world().getWelcomeMessage());
|
||||||
new LookCommand().execute(ctx, java.util.List.of());
|
new LookCommand().execute(ctx, java.util.List.of());
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +1,11 @@
|
|||||||
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.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.List;
|
import java.util.List;
|
||||||
@@ -32,6 +35,16 @@ public class GoCommand implements Command {
|
|||||||
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir);
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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,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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,15 @@ 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;
|
||||||
|
|
||||||
|
/** Number of commands processed this session; shown in the HUD. */
|
||||||
|
private int turn = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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();
|
||||||
while (running) {
|
while (running) {
|
||||||
String input = ctx.getIo().readLine();
|
String input = ctx.getIo().readLine();
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -46,10 +55,37 @@ public class Game {
|
|||||||
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());
|
||||||
|
turn++;
|
||||||
}
|
}
|
||||||
|
publishHud();
|
||||||
|
maybeEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ends the game if any ending's conditions are met, printing its screen. */
|
||||||
|
private void maybeEnd() {
|
||||||
|
Ending e = EndingEngine.triggered(ctx);
|
||||||
|
if (e != null) {
|
||||||
|
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishHud() {
|
||||||
|
QuestEngine.tick(ctx);
|
||||||
|
ctx.getIo().setHud(new Hud(
|
||||||
|
ctx.getPlayer().getCurrentRoom().getName(),
|
||||||
|
ctx.getPlayer().getGold(),
|
||||||
|
turn,
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signals the loop to terminate after the current iteration.
|
* Signals the loop to terminate after the current iteration.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,4 +23,10 @@ public class GameContext {
|
|||||||
|
|
||||||
/** IO channel for reading input and writing output. */
|
/** IO channel for reading input and writing output. */
|
||||||
private final GameIO io;
|
private final GameIO io;
|
||||||
|
|
||||||
|
/** Mutable world flags. Created fresh per context; not a constructor arg. */
|
||||||
|
private final GameState state = new GameState();
|
||||||
|
|
||||||
|
/** Runtime quest progress. Created fresh per context; not a constructor arg. */
|
||||||
|
private final QuestLog questLog = new QuestLog();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,94 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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,49 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,59 @@
|
|||||||
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 final boolean useColor;
|
||||||
|
private final 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;
|
||||||
|
this.useColor = switch (colorMode) {
|
||||||
|
case ON -> true;
|
||||||
|
case OFF -> false;
|
||||||
|
case AUTO -> System.console() != null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -51,7 +69,91 @@ public class ConsoleIO implements GameIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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,59 @@
|
|||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-demand quest log (the 'quests' command). Default renders styled text. */
|
||||||
|
default void showQuests(QuestView view) {
|
||||||
|
print(QuestText.render(view));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,285 @@
|
|||||||
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.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.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.Dimension;
|
||||||
import java.awt.Font;
|
import java.awt.Font;
|
||||||
|
import java.awt.GraphicsConfiguration;
|
||||||
|
import java.awt.GraphicsEnvironment;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Side-panel width as a fraction of the window width, clamped by the bounds below. */
|
||||||
|
private static final double SIDE_RATIO = 0.28;
|
||||||
|
private static final int SIDE_MIN = 180;
|
||||||
|
private static final int SIDE_MAX = 460;
|
||||||
|
|
||||||
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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, clamped to [min, max] (scaled). */
|
||||||
|
private void updateSideWidth() {
|
||||||
|
int min = (int) (SIDE_MIN * uiScale);
|
||||||
|
int max = (int) (SIDE_MAX * uiScale);
|
||||||
|
int target = Math.max(min, Math.min(max, (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 +291,63 @@ 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 setMap(MapView view) {
|
||||||
|
map.show(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.
|
||||||
|
}
|
||||||
|
|
||||||
|
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,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,8 @@ 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()));
|
||||||
|
return room;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,23 @@ 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.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.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 +73,15 @@ 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);
|
||||||
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 +95,14 @@ 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));
|
||||||
|
}
|
||||||
|
|
||||||
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||||
resolver.resolveRooms(roomDtos);
|
resolver.resolveRooms(roomDtos);
|
||||||
@@ -98,7 +115,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);
|
||||||
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 +131,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,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,9 @@ 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
|
||||||
*/
|
*/
|
||||||
public record RoomDto(
|
public record RoomDto(
|
||||||
String id,
|
String id,
|
||||||
@@ -19,6 +22,14 @@ 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
|
||||||
) {
|
) {
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,16 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 +67,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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import lombok.Getter;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,4 +30,22 @@ public class World {
|
|||||||
|
|
||||||
/** Welcome message printed once when the game starts. */
|
/** Welcome message printed once when the game starts. */
|
||||||
private final String welcomeMessage;
|
private final String welcomeMessage;
|
||||||
|
|
||||||
|
/** Global lookup of quests by id. */
|
||||||
|
private final Map<String, Quest> quests;
|
||||||
|
|
||||||
|
/** Ordered list of endings (first matching one wins). */
|
||||||
|
private final List<Ending> endings;
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds without quests or endings. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds with quests but no endings. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage, Map<String, Quest> quests) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, quests, List.of());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public abstract class Item {
|
|||||||
/** Long description shown by the {@code examine} command. */
|
/** Long description shown by the {@code examine} command. */
|
||||||
protected final String description;
|
protected final String description;
|
||||||
|
|
||||||
|
/** Whether this item can serve as a light source (when on, for switchables). */
|
||||||
|
protected final boolean light;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the item's primary action. Side effects (text output,
|
* Executes the item's primary action. Side effects (text output,
|
||||||
* mutation of player state) flow through the given context.
|
* mutation of player state) flow through the given context.
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package thb.jeanluc.adventure.model.item;
|
|||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.experimental.SuperBuilder;
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import thb.jeanluc.adventure.game.Effects;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Item with two boolean states (on / off). Each {@code use} toggles the
|
* Item with two boolean states (on / off). Each {@code use} toggles the
|
||||||
@@ -24,6 +28,9 @@ public class SwitchableItem extends Item {
|
|||||||
*/
|
*/
|
||||||
private boolean state;
|
private boolean state;
|
||||||
|
|
||||||
|
/** Effects applied when this item transitions to on; null = none. */
|
||||||
|
private final List<Effect> effects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the state and writes the corresponding transition text.
|
* Toggles the state and writes the corresponding transition text.
|
||||||
*
|
*
|
||||||
@@ -33,6 +40,9 @@ public class SwitchableItem extends Item {
|
|||||||
public void use(GameContext ctx) {
|
public void use(GameContext ctx) {
|
||||||
state = !state;
|
state = !state;
|
||||||
ctx.getIo().write(state ? onText : offText);
|
ctx.getIo().write(state ? onText : offText);
|
||||||
|
if (state) {
|
||||||
|
Effects.applyAll(effects, ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
12
Semesterprojekt/src/main/resources/fonts/README.md
Normal file
12
Semesterprojekt/src/main/resources/fonts/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Bundled GUI font
|
||||||
|
|
||||||
|
Drop a TrueType font here named `game.ttf` to use it in the Swing GUI
|
||||||
|
(`SwingIO`). It is loaded via `Font.createFont` and registered at runtime —
|
||||||
|
no installation on the player's machine required.
|
||||||
|
|
||||||
|
- A monospaced font is recommended (the UI assumes fixed width for frames).
|
||||||
|
- A Nerd Font variant additionally provides icon glyphs.
|
||||||
|
- Check the font's licence before committing the `.ttf`.
|
||||||
|
|
||||||
|
If no `game.ttf` is present, `SwingIO` falls back to the logical
|
||||||
|
`Font.MONOSPACED`, so the game still runs.
|
||||||
14
Semesterprojekt/src/main/resources/world/endings.yaml
Normal file
14
Semesterprojekt/src/main/resources/world/endings.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
- id: victory
|
||||||
|
title: "The Manor Reclaimed"
|
||||||
|
victory: true
|
||||||
|
when: [{ flag: manor_secured }]
|
||||||
|
text: |
|
||||||
|
The lights hold steady and the whispers fade to nothing. Whatever held
|
||||||
|
this place has loosened its grip. The manor is yours now.
|
||||||
|
- id: fled
|
||||||
|
title: "Into the Night"
|
||||||
|
victory: false
|
||||||
|
when: [{ flag: fled }]
|
||||||
|
text: |
|
||||||
|
You wrench the front door open and bolt into the dark. Safe — but the
|
||||||
|
manor keeps its secrets, and they will keep you awake for years.
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
name: Oil Lamp
|
name: Oil Lamp
|
||||||
description: An old oil lamp, heavy with fuel.
|
description: An old oil lamp, heavy with fuel.
|
||||||
initialState: false
|
initialState: false
|
||||||
|
light: true
|
||||||
onText: The lamp flares to life, casting a warm glow.
|
onText: The lamp flares to life, casting a warm glow.
|
||||||
offText: You snuff out the lamp.
|
offText: You snuff out the lamp.
|
||||||
|
|
||||||
@@ -22,3 +23,27 @@
|
|||||||
id: key
|
id: key
|
||||||
name: Brass Key
|
name: Brass Key
|
||||||
description: A small brass key, polished from use.
|
description: A small brass key, polished from use.
|
||||||
|
|
||||||
|
- type: switchable
|
||||||
|
id: generator
|
||||||
|
name: Generator
|
||||||
|
description: A rusty generator with a heavy lever.
|
||||||
|
initialState: false
|
||||||
|
onText: |
|
||||||
|
You heave the lever. Somewhere deep in the manor, the lights flicker on.
|
||||||
|
offText: |
|
||||||
|
You pull the lever back; the manor falls dark again.
|
||||||
|
effects:
|
||||||
|
- { setFlag: power_on }
|
||||||
|
|
||||||
|
- type: switchable
|
||||||
|
id: front_door
|
||||||
|
name: Front Door
|
||||||
|
description: The heavy front door. It would let you leave the manor for good.
|
||||||
|
initialState: false
|
||||||
|
onText: |
|
||||||
|
You haul the front door open and step out into the cold night.
|
||||||
|
offText: |
|
||||||
|
You ease the door shut again.
|
||||||
|
effects:
|
||||||
|
- { setFlag: fled }
|
||||||
|
|||||||
@@ -10,3 +10,8 @@
|
|||||||
"Thank you! Here, take this key."
|
"Thank you! Here, take this key."
|
||||||
gives: key
|
gives: key
|
||||||
consumes: lamp
|
consumes: lamp
|
||||||
|
dialogue:
|
||||||
|
- when: [{ flag: power_on }]
|
||||||
|
text: |
|
||||||
|
"You got the power running! The whole house feels less...
|
||||||
|
watchful now."
|
||||||
|
|||||||
10
Semesterprojekt/src/main/resources/world/quests.yaml
Normal file
10
Semesterprojekt/src/main/resources/world/quests.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
- id: restore_power
|
||||||
|
title: "Bring the Manor to Life"
|
||||||
|
autoStart: true
|
||||||
|
stages:
|
||||||
|
- objective: "Get the power running."
|
||||||
|
completion: [{ flag: power_on }]
|
||||||
|
- objective: "Earn the Old Man's brass key."
|
||||||
|
completion: [{ hasItem: key }]
|
||||||
|
onComplete:
|
||||||
|
- { setFlag: manor_secured }
|
||||||
@@ -6,8 +6,14 @@
|
|||||||
exits:
|
exits:
|
||||||
north: hallway
|
north: hallway
|
||||||
east: cellar
|
east: cellar
|
||||||
items: [letter, lamp]
|
south: dungeon
|
||||||
|
items: [letter, lamp, front_door]
|
||||||
npcs: [old_man]
|
npcs: [old_man]
|
||||||
|
exitLocks:
|
||||||
|
- direction: east
|
||||||
|
requires: [{ flag: power_on }]
|
||||||
|
blocked: |
|
||||||
|
The cellar door is held shut by a magnetic lock — dead without power.
|
||||||
|
|
||||||
- id: hallway
|
- id: hallway
|
||||||
name: Dark Hallway
|
name: Dark Hallway
|
||||||
@@ -39,3 +45,18 @@
|
|||||||
west: kitchen
|
west: kitchen
|
||||||
items: []
|
items: []
|
||||||
npcs: []
|
npcs: []
|
||||||
|
descriptionStates:
|
||||||
|
- when: [{ flag: power_on }]
|
||||||
|
text: |
|
||||||
|
With the power back, a bare bulb buzzes overhead, revealing
|
||||||
|
shelves of dusty jars along the far wall.
|
||||||
|
|
||||||
|
- id: dungeon
|
||||||
|
name: Dungeon
|
||||||
|
description: |
|
||||||
|
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
||||||
|
dark: true
|
||||||
|
exits:
|
||||||
|
north: kitchen
|
||||||
|
items: [generator]
|
||||||
|
npcs: []
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DarknessTest {
|
||||||
|
private SwitchableItem lamp(boolean on) {
|
||||||
|
return SwitchableItem.builder().id("lamp").name("Lamp").description("d")
|
||||||
|
.state(on).onText("on").offText("off").light(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cannotEnterDarkRoomWithoutLight() {
|
||||||
|
Room hall = new Room("hall", "Hall", "d");
|
||||||
|
Room cave = new Room("cave", "Cave", "d");
|
||||||
|
cave.setDark(true);
|
||||||
|
hall.addExit(Direction.NORTH, cave);
|
||||||
|
Player p = new Player(hall, 0);
|
||||||
|
GameContext ctx = new GameContext(null, p, new TestIO());
|
||||||
|
|
||||||
|
new GoCommand().execute(ctx, List.of("north"));
|
||||||
|
assertThat(p.getCurrentRoom()).isEqualTo(hall);
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).containsIgnoringCase("pitch black");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canEnterDarkRoomWithLitLamp() {
|
||||||
|
Room hall = new Room("hall", "Hall", "d");
|
||||||
|
Room cave = new Room("cave", "Cave", "d");
|
||||||
|
cave.setDark(true);
|
||||||
|
hall.addExit(Direction.NORTH, cave);
|
||||||
|
Player p = new Player(hall, 0);
|
||||||
|
p.addItem(lamp(true));
|
||||||
|
GameContext ctx = new GameContext(null, p, new TestIO());
|
||||||
|
|
||||||
|
new GoCommand().execute(ctx, List.of("north"));
|
||||||
|
assertThat(p.getCurrentRoom()).isEqualTo(cave);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lookAndTakeAreBlockedInTheDark() {
|
||||||
|
Room cave = new Room("cave", "Cave", "d");
|
||||||
|
cave.setDark(true);
|
||||||
|
Player p = new Player(cave, 0);
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, p, io);
|
||||||
|
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
assertThat(io.allOutput()).containsIgnoringCase("pitch black");
|
||||||
|
|
||||||
|
io.outputs().clear();
|
||||||
|
new TakeCommand().execute(ctx, List.of("anything"));
|
||||||
|
assertThat(io.allOutput()).containsIgnoringCase("too dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.DescriptionState;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DescriptionStateTest {
|
||||||
|
@Test
|
||||||
|
void descriptionSwitchesWithFlag() {
|
||||||
|
Room cellar = new Room("cellar", "Cellar", "Pitch black down here.");
|
||||||
|
cellar.addDescriptionState(new DescriptionState(
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "power_on")),
|
||||||
|
"Lit now, a workbench stands against the wall."));
|
||||||
|
Player player = new Player(cellar, 0);
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, player, io);
|
||||||
|
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
assertThat(io.allOutput()).contains("Pitch black");
|
||||||
|
|
||||||
|
io.outputs().clear();
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
assertThat(io.allOutput()).contains("workbench").doesNotContain("Pitch black");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.DialogueLine;
|
||||||
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DialogueTest {
|
||||||
|
@Test
|
||||||
|
void dialogueSwitchesWithFlagElseGreeting() {
|
||||||
|
Room room = new Room("k", "K", "d");
|
||||||
|
Npc man = Npc.shell("man", "Old Man", "stooped", "Hello there.");
|
||||||
|
man.addDialogue(new DialogueLine(
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "power_on")),
|
||||||
|
"The lights are back, bless you!"));
|
||||||
|
room.addNpc(man);
|
||||||
|
Player player = new Player(room, 0);
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, player, io);
|
||||||
|
|
||||||
|
new TalkCommand().execute(ctx, List.of("man"));
|
||||||
|
assertThat(io.allOutput()).contains("Hello there");
|
||||||
|
|
||||||
|
io.outputs().clear();
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
new TalkCommand().execute(ctx, List.of("man"));
|
||||||
|
assertThat(io.allOutput()).contains("lights are back").doesNotContain("Hello there");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LockedExitTest {
|
||||||
|
@Test
|
||||||
|
void lockedExitBlocksUntilFlagSet() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room cellar = new Room("cellar", "Cellar", "d");
|
||||||
|
kitchen.addExit(Direction.EAST, cellar);
|
||||||
|
kitchen.addExitLock(Direction.EAST, new ExitLock(
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "power_on")),
|
||||||
|
"The cellar door is sealed."));
|
||||||
|
Player player = new Player(kitchen, 0);
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, player, io);
|
||||||
|
|
||||||
|
new GoCommand().execute(ctx, List.of("east"));
|
||||||
|
assertThat(player.getCurrentRoom()).isEqualTo(kitchen);
|
||||||
|
assertThat(io.allOutput()).contains("sealed");
|
||||||
|
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
new GoCommand().execute(ctx, List.of("east"));
|
||||||
|
assertThat(player.getCurrentRoom()).isEqualTo(cellar);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MapCommandTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void map_showsVisitedRoomAndKnownNeighbour() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room hallway = new Room("hallway", "Hallway", "d");
|
||||||
|
kitchen.addExit(Direction.NORTH, hallway);
|
||||||
|
hallway.addExit(Direction.SOUTH, kitchen);
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
rooms.put("kitchen", kitchen);
|
||||||
|
rooms.put("hallway", hallway);
|
||||||
|
World world = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
|
||||||
|
Player player = new Player(kitchen, 0); // visited = {kitchen}; hallway is a KNOWN stub
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(world, player, io);
|
||||||
|
|
||||||
|
new MapCommand().execute(ctx, List.of());
|
||||||
|
|
||||||
|
String out = io.allOutput();
|
||||||
|
assertThat(out).contains("Kitchen"); // current room name appears on the map
|
||||||
|
assertThat(out).contains("?"); // unexplored neighbour stub
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.QuestEngine;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Quest;
|
||||||
|
import thb.jeanluc.adventure.model.QuestStage;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class QuestsCommandTest {
|
||||||
|
@Test
|
||||||
|
void showsActiveObjective() {
|
||||||
|
Quest q = new Quest("q", "My Quest", true,
|
||||||
|
List.of(new QuestStage("Do the thing",
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "x")), List.of())), List.of());
|
||||||
|
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of("q", q));
|
||||||
|
GameContext ctx = new GameContext(w, new Player(new Room("k", "K", "d"), 0), new TestIO());
|
||||||
|
QuestEngine.tick(ctx); // auto-start
|
||||||
|
|
||||||
|
new QuestsCommand().execute(ctx, List.of());
|
||||||
|
|
||||||
|
String out = ((TestIO) ctx.getIo()).allOutput();
|
||||||
|
assertThat(out).contains("My Quest").contains("Do the thing");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user