Compare commits

...

121 Commits

Author SHA1 Message Date
f8f7eb90f0 build: add Maven wrapper and prebuilt release jar; ignore docs/superpowers
- Add ./mvnw wrapper (script-only) so the project builds/runs without a
  system Maven install.
- Add maven-shade-plugin producing a self-contained release/HauntedManor.jar
  (merges audio SPI services; no dependency-reduced-pom).
- Document running via ./mvnw and via the jar directly in the README.
- Ignore docs/superpowers/ (planning specs kept locally, untracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:51:04 +02:00
675bf4c2ea Merge feature/content-expansion into develop
Haunted Manor content expansion (14 rooms, one world + solvability test),
bundled background music and GUI font, wider sidebar, and the main-menu
Quit fix.
2026-06-02 09:40:00 +02:00
d0966c3ce4 fix(menu): main-menu Quit now closes the GUI instead of revealing the game
App.run ended its menu loop on Quit and just returned. The console exits then,
but in the GUI run() is on a daemon worker while the Swing JFrame stays
displayable on the EDT, keeping the JVM alive — and the dismissed menu overlay
just revealed the running game underneath ("back into the game"). EXIT_ON_CLOSE
only fires for the native window X, not a programmatic quit.

Add a GameIO.shutdown() teardown hook (default no-op) and call it after the menu
loop ends. SwingIO overrides it to stop music, dispose the window, and exit the
JVM, mirroring EXIT_ON_CLOSE; console keeps the no-op. Extract an injectable
App.run overload and add AppQuitTest asserting the Quit path invokes shutdown().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:39:05 +02:00
1cbf606bd4 feat(io): widen GUI sidebar and drop its max-width cap
Give the map/quests sidebar more room: raise the width fraction (0.28 -> 0.34)
and minimum (180 -> 220), and remove the SIDE_MAX upper clamp so the panel
keeps scaling with the window instead of stopping at 460px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:32:45 +02:00
7ea11058a1 feat(io): bundle Cascadia Mono Nerd Font as the GUI font
Install the downloaded Cascadia Mono Nerd Font as src/main/resources/fonts/
game.ttf — the exact name SwingIO.loadFont() looks up on the classpath
(/fonts/game.ttf), so the GUI now uses a real monospaced Nerd Font (icon
glyphs included) instead of falling back to the logical monospaced font.
Include the font's SIL Open Font License as required for redistribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:27:25 +02:00
aa802582ec feat(io): bundle background music as classpath resources
Switch OggMusicBackend from streaming an external, git-ignored music/ folder
(relative to the run directory) to streaming bundled classpath resources under
/music/, so the four manor tracks ship inside the JAR and play out of the box —
consistent with how fonts are bundled. The play loop reopens the resource each
iteration via a BufferedInputStream (the vorbisspi SPI needs mark/reset to read
the OGG header). Missing tracks still fall back to silence.

Add the four converted OGG Vorbis tracks (manor-theme/cellar-drone/musicbox/
lament) under src/main/resources/music/, un-ignore that path, and re-anchor the
gitignore so only the top-level MP3 source folder stays ignored. Update
docs/music.md for the bundled approach and conversion recipes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:21:11 +02:00
d8e1a662bc feat(world): Haunted Manor content expansion (14 rooms, one world)
Replace the 5-room demo with a condensed 14-room manor on one spine
(restore power -> escape) that stacks all five mechanics, plus an optional
spirit thread feeding 3 prioritized endings (no hard-fail). Pure YAML on the
existing engines: 14 rooms across 3 levels, ~16 items, 5 combinations, the
old_man hint NPC, restore_power + lay_to_rest quests, and 3 endings.

Consolidate to one world: remove the toy "Test Manor" test fixture so the
loader tests exercise the real shipped world. Adapt WorldLoaderTest and
TutorialLoaderTest to form-invariants of the real world, and add
WorldSolvableTest, which walks the critical path (light/lock gating +
Combinations.tryUse + flags) to guarantee power_on, ghost_banished, and a
victory ending are reachable — an unsolvable world now fails the build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:06:28 +02:00
88cad77c83 Merge GUI background music into develop
Per-room OGG Vorbis background music in the Swing GUI: a testable MusicController
(idempotent track switch, null->silence, OFF disables) over an isolated
OggMusicBackend (vorbisspi/jorbis, streamed PCM, ~400ms gain fades, graceful on
missing files). Driven from GameIO.setMusic per turn (console no-op) and a new
persisted Music level (Off/Low/Med/High, default Medium) in Settings. Tracks come
from an external gitignored music/ folder via an optional rooms.yaml `music:`
field. Console stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:29:11 +02:00
702f7ac0d4 docs: clarify music transition is a sequential fade, not cross-fade 2026-06-01 23:29:11 +02:00
830a95c9f0 docs: mark GUI background music implemented 2026-06-01 23:25:11 +02:00
f1aedebfcb feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:20:14 +02:00
7380dcae41 refactor(settings): guard all Settings fields vs null; tidy import + test
Guard colorMode and glyphMode against null in the Settings compact
constructor (alongside the existing musicLevel guard), so a hand-edited
settings.json with a missing field no longer NPEs in SettingsMenu.
Remove the unused MusicLevel import from SettingsMenu. Assert that the
defaulted musicLevel survives a round-trip in SettingsStoreTest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:17:26 +02:00
c6ed199f7e feat(settings): persisted Music level; SettingsMenu applies via GameIO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:08:27 +02:00
bf36730524 feat(io): GameIO music seams; push room track per turn, silence at menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:02:44 +02:00
fa7851b6e0 feat(loader): per-room music field + demo rooms.yaml entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:58:13 +02:00
66e43830be fix(io): idempotent MusicController.level; assert gain forwarding + shutdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:55:27 +02:00
977872e77a feat(io): MusicController + MusicBackend (testable music logic)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:50:58 +02:00
3dd27a1b63 test(io): assert MEDIUM.isOn() too 2026-06-01 22:49:20 +02:00
e7db45f98d feat(io): MusicLevel enum (Off/Low/Med/High)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:47:40 +02:00
705e515a8e Merge: suppress endings during the tutorial 2026-06-01 22:09:06 +02:00
00d3f97c9b fix(game): suppress endings while the tutorial is active
A command during the walkthrough (e.g. use front_door -> fled) no longer ends
the game mid-tutorial; a pending ending fires once the tutorial finishes or is
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:09:06 +02:00
ebeda36b4a Merge interactive start-of-game tutorial into develop
A skippable interactive walkthrough shown on New Game: a TutorialGuide layered
onto the real game loop makes the player perform the core commands
(look→examine→take→inventory→use→go→go to) in the starting room, with verb-/
alias-aware completion (movement steps require an actual room change), gentle
hints on wrong-but-valid input, and closing tips for the rest. Data-driven
(tutorial.yaml + standalone TutorialLoader); 'skip' ends it; loaded games never
show it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:03:59 +02:00
1ff475c085 docs: note interactive start-of-game tutorial 2026-06-01 21:59:44 +02:00
236ed0a180 refactor: consistent isActive() guard on tutorial onCommand; tidy App imports 2026-06-01 21:59:07 +02:00
c315d3f4b7 feat: wire TutorialGuide into the game loop; show on New Game only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:53:27 +02:00
e27a12e4fe fix(game): null-safe moved() + idempotent begin(); cover multi-step + go_to_room hint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:49:47 +02:00
af319508f6 feat(game): TutorialGuide interactive walkthrough engine
Alias-aware, stateful engine with verb/minArgs matching, go_direction/go_to_room
room-change detection, skip, and closing-tips on completion. 7 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:42:30 +02:00
dbfdf86810 test(tutorial): cover malformed tutorial.yaml -> WorldLoadException 2026-06-01 21:40:06 +02:00
91f79b1537 feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:25:48 +02:00
0250eadd50 Merge go to <room> pathfinding into develop
BFS auto-walk over visited rooms (respecting locked exits + darkness), routed
through GoCommand (go to <room> vs go <direction>). Console: silent traversal +
a start/middle/destination summary line. GUI: the minimap animates the player
moving room-by-room via a new GameIO.travelStep hook (SwingIO override, ~300ms;
console no-op). New stateless Pathfinder BFS in the game package. Trie
autocomplete deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:27:30 +02:00
0af172ff64 docs: mark go to <room> pathfinding implemented 2026-06-01 20:24:26 +02:00
d20f3fd7d6 fix(command): combined unknown-target message; cover both go-to summary forms
- UX: unknown-target message now reads "don't know any direction or place
  called '...'" so direction typos (e.g. 'go nroth') make sense
- clarity: resolveVisited uses equalsIgnoreCase for id/name matching;
  add null-world guard comment pointing to CommandTestSupport
- Javadoc: note that 'to' filler word is stripped, making go-to forms equivalent
- tests: assert 'through the Hallway' in 3-node route; update unknown message
  assertion; add adjacentWalkUsesShortSummary covering the n==2 short-form path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:23:41 +02:00
97e2809719 feat(command): go to <room> auto-walk (BFS path, animated map, summary)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:13:25 +02:00
f1b9695c5e feat(game): Pathfinder BFS over visited rooms (locks + light aware)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:08:11 +02:00
62b7663e34 feat(io): travelStep hook for animated auto-walk (console no-op, Swing override)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:04:17 +02:00
86e138223a Merge use X on Y item combinations into develop
Data-driven combination recipes (combinations.yaml: requires/consume/produce/
effects/response/failText), operands resolved from inventory or current room,
order-independent canonical pair key. UseCommand routes 2+ args to a new
Combinations engine; no parser change (on/with/to already fillers). Loader
validates ids + rejects self-combination/duplicate pairs at load. Demo:
matches + torch -> lit_torch (a working light source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:48:20 +02:00
e0e8612982 docs: mark use X on Y item combination implemented 2026-06-01 17:45:16 +02:00
3711ef5a67 test(loader): assert exact item set incl. demo items (restore completeness check) 2026-06-01 17:44:32 +02:00
4ab5ad9d42 feat(loader): load combinations.yaml; demo matches+torch -> lit_torch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:38:17 +02:00
8257eb0c02 docs(command): update UseCommand javadoc for the combination form 2026-06-01 17:32:58 +02:00
9152aded4b feat(command): route use X on Y to the combination engine
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:31:07 +02:00
6eabebff00 fix(combinations): guard null response; reject self-combination at load
- Combinations.tryUse: only write response when non-null and non-blank,
  preventing NPE/IllegalArgument for effects-only recipes.
- CombinationFactory.fromDto: throw WorldLoadException when a == b,
  since item singletons make a self-combination impossible at runtime.
- Tests: +effectsOnlyRecipeAppliesEffectsWithoutProduceOrResponse
  (CombinationsTest 7→8), +rejectsSelfCombination
  (CombinationFactoryTest 4→5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:28:32 +02:00
ae7f9d6b05 feat(game): Combinations engine for use X on Y
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:22:16 +02:00
2747659800 test(loader): cover unknown consume id rejection 2026-06-01 17:19:41 +02:00
100b54c4d1 feat(loader): CombinationDto + CombinationFactory with id validation 2026-06-01 17:17:25 +02:00
c91bc3afea feat(model): World.combinations field + backward-compatible constructors 2026-06-01 17:13:05 +02:00
022973dc75 style(test): drop unused import in CombinationTest 2026-06-01 17:11:37 +02:00
cf592edc57 feat(model): Combination recipe record + order-independent key 2026-06-01 17:08:14 +02:00
b41ac80194 feat(io): themed full-window menu overlay in the GUI
Replace the plain Swing JDialog menu with a glass-pane overlay that fills
the whole window: dark background matching the game palette, bundled font,
lavender heading, flat hover-highlighted buttons, Escape = last option. The
overlay hides the game chrome (HUD/map/quest panels) while in the menu and
rebuilds at the current frameScale*zoom on window resize, so it scales
correctly from startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:21:46 +02:00
1d0cbc25e4 Merge main menu + save/load into develop
Adds a main-menu shell above the game loop (New Game / Load / Settings /
Quit) via a new GameIO.choose primitive (console numbered menu + Swing
buttons), JSON save/load as a delta over the YAML world (single active
slot; manual save, autosave on quest-completion + every 10 turns, and
quit-to-menu), and minimal runtime-togglable settings (colour + glyph).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:42:09 +02:00
182a6911a0 fix: EOF ends game loop (ConsoleIO returns null on EOF); settings.json excluded from save slots
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:39:08 +02:00
88e959adee docs: mark main menu + save/load implemented; note minimal settings scope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:30:51 +02:00
98b1561ef2 feat(io): SwingIO.choose renders the menu as buttons 2026-06-01 15:27:15 +02:00
361fd0939c refactor(app): drop dead newSession param, unused import, restore main javadoc
- Remove unused `saves` parameter from `newSession(GameIO, SaveService)`
  → signature is now `newSession(GameIO)`, call site updated accordingly
- Remove unused `import thb.jeanluc.adventure.menu.MenuAction` (switch
  uses unqualified constants; import was dead, compile confirmed clean)
- Restore `/** Standard JVM entry point. @param args ignored */` Javadoc
  on `main` and restore full log message "Fatal error during game startup"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:25:01 +02:00
4604952f63 feat(app): main-menu shell loop wiring; gitignore saves/
Replace App.run with a main-menu shell (New Game / Load / Settings /
Quit) above the game loop, wire SaveCommand + MenuCommand into the
registry with an autosave callback, and remove the now-unused
QuitCommand. Migrate GameTest from QuitCommand to MenuCommand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:17:46 +02:00
084b39e501 feat(game): autosave on quest completion 2026-06-01 15:12:18 +02:00
0f05680170 feat(command): save + menu commands (QuitCommand removal deferred to Task 11) 2026-06-01 15:10:10 +02:00
e4238e4553 test(menu): cover pickSlot selection and Back sentinel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:03:18 +02:00
687e88e20a feat(menu): MainMenu, SettingsMenu, MenuAction (frontend-agnostic) 2026-06-01 14:58:41 +02:00
d847928587 test(settings): cover corrupt settings file falls back to defaults
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:56:14 +02:00
206fc758a5 feat(settings): Settings + SettingsStore; runtime-togglable ConsoleIO 2026-06-01 14:52:28 +02:00
ed41418434 test(save): cover version mismatch + slug; clean up tmp on move failure
Add loadRejectsIncompatibleVersion test asserting SaveException on schema
version 999, assert derived slug in listReturnsSlotMetadata, and harden
save() to delete the orphaned .tmp file when the non-atomic fallback move
also fails before re-throwing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:50:10 +02:00
6a68d7e2f5 feat(save): SaveService disk persistence (atomic write, list, load) 2026-06-01 14:44:32 +02:00
3c7c9d2ad4 fix(save): restore visited set before setting current room; strengthen null-tolerance test
Reorder apply() so visitedRoomIds is restored before setCurrentRoom() is
called, ensuring the current room is never accidentally stripped from the
visited set by the subsequent clear(). Also adds a post-condition assertion
to applyToleratesMissingCollections that proves null roomItemIds leaves
room item tables empty after apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:42:15 +02:00
3be1036142 feat(save): SaveCodec capture/apply over a fresh world; tolerate null collections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:34:48 +02:00
c92c143058 feat(save): SaveData/SaveSlotInfo/SaveException model 2026-06-01 14:29:55 +02:00
310ee65285 feat(io): GameIO.choose numbered-menu primitive (console default) 2026-06-01 14:26:43 +02:00
c392f80ed0 feat: restore hooks for flags, quest progress, switch state 2026-06-01 14:21:56 +02:00
58352087c4 test(game): strengthen legacy-constructor delegation assertions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:19:54 +02:00
18336ecc44 feat(game): GameSession bundles savable state; turn + autosave hook 2026-06-01 14:15:44 +02:00
d34104f928 Merge light & darkness mechanic into develop
Room.dark + Item.light flags and a Light helper (isLit/carryingLight). Model B
gating: you can't enter a dark room without a lit light source; look/take/examine
are obscured in the dark; the HUD light field is wired. Entry-gate prevents
soft-locks. Demo: the dark dungeon needs the lit lamp to reach the generator.
127 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:09:15 +02:00
4ed1fa1010 feat(content): dark dungeon needs the lit lamp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:08:42 +02:00
569cef20fe feat: darkness gating for go/look/take/examine + HUD light
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:07:36 +02:00
c3d80f86d7 feat: Room.dark + Item.light flags and Light helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:04:42 +02:00
d7edc78882 Merge win-condition & endings (sub-project 3.3) into develop
Priority-ordered, condition-driven endings (endings.yaml) checked each turn
after the quest tick; the first match prints an ending banner + run summary
(turns, quests X/Y, rank) and ends the game. EndingEngine mirrors the quest
engine; World gains 5/6/7-arg back-compat constructors. Two reachable demo
endings: victory (manor_secured) and flee (front door -> fled). Completes the
quest system (#3). 120 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:51:15 +02:00
b23f706521 feat(content): victory & flee endings; front-door flee switch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:50:46 +02:00
2238aa6ff5 feat(game): EndingEngine + per-turn end check with summary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:49:00 +02:00
3ec540e41b feat: Ending model + endings.yaml loading (ordered, optional)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:47:38 +02:00
c652c33a9a Merge quests & quest-log (sub-project 3.2) into develop
Condition-driven multi-stage quests on the foundation: Quest/QuestStage data,
QuestLog + QuestEngine (per-turn auto-advance with ✓/★ announcements and
cascade), START_QUEST effect, QuestView pushed/rendered like the map. Console
'quests' command and a GUI quest-box under the map. Demo quest restore_power
auto-advances as you restore power and earn the key. 116 tests green.
Win-condition/endings (3.3) deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:11 +02:00
1b22c996e1 feat(content): demo quest 'restore_power'; document quests command
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:34:57 +02:00
e3fb0c9fa6 feat(io): GUI quest-box under the map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:33:57 +02:00
310d476264 feat(io,command): quests command, QuestText, per-turn quest tick
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:31:33 +02:00
a3600eadd7 feat(loader): quests.yaml loading + startQuest effect mapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:29:52 +02:00
513c7cb8d9 feat(game): QuestEngine (auto-advance + announcements) and QuestView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:27:18 +02:00
dd16d1326b feat(game): Quest model, QuestLog, START_QUEST effect
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:25:58 +02:00
eeee609c26 Merge quest foundation (world-state + conditions/effects) into develop
One typed condition/effect vocabulary (GameState + Conditions + Effects)
wired into five integration points: condition-locked exits, state-dependent
room descriptions, conditional NPC dialogue, reactions with requires/effects,
and switch effects. All new YAML fields optional (backward compatible). Demo:
a generator switch sets power_on, which unlocks and lights the cellar and
changes the old man's dialogue. 107 tests green. Light/dark, item-combination,
quest objects and endings deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:02:34 +02:00
a0b80f2a4b feat(content): demo the state engine (generator unlocks/lights the cellar)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:01:57 +02:00
00bad46661 feat: switchable items apply effects when turned on
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:59:46 +02:00
258ab4f63b feat: NPC reactions gated by conditions, applying effects
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:57:23 +02:00
268c24d4e3 feat: conditional NPC dialogue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:55:15 +02:00
c9c019cea1 feat: state-dependent room descriptions (LookCommand resolution)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:52:57 +02:00
df4a46ae5b feat: condition-locked exits (+ room/dialogue state plumbing)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:52:06 +02:00
228efaffcb feat(loader): ConditionDto/EffectDto with toModel mapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:47:11 +02:00
5546b63d47 feat(game): world-state engine (GameState, Condition/Effect, evaluator/applier)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:46:29 +02:00
9b28e2927e docs: note quest-log GUI box (under map) for quest sub-project
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:37:37 +02:00
8ab39cd535 fix(io): auto-scale GUI text with window size, consistent with the map
Body text/HUD font now derives from a window-relative auto-scale (starting
at uiScale, growing as the window grows) and is re-applied on resize, so the
text tracks the window like the map does. Ctrl +/- zoom still multiplies on
top of both. Removes the resize hiccup where only the map reacted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:26:09 +02:00
e80c6b65bb fix(io): fit the GUI map to the side panel; re-fit on resize
MapPanel now scales the map to fit the side panel (min over width/height),
with the Ctrl +/- zoom as a multiplier on top, so it no longer overflows
and scrolls at normal zoom. The map re-fits whenever the window/side panel
is resized; side panel grows a little more (28%, max 460px) before capping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:21:27 +02:00
f6f62b66d8 fix(io): scale the GUI map with HiDPI factor and live zoom
MapPanel had fixed pixel sizes and an 11pt font, so it ignored uiScale and
the Ctrl +/- zoom. It now multiplies all dimensions, strokes and the label
font by a scale set from uiScale x zoom in SwingIO.applyFonts().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:14:31 +02:00
f1ec321e50 Merge map / mini-map (sub-project 2) into develop
Fog-of-war map from the directional exit graph: MapLayout (BFS) builds a
frontend-agnostic MapView; SwingIO draws it (Graphics2D MapPanel, replacing
the exits text), ConsoleIO mirrors it as ASCII via the `map` command.
Visited tracking on Player; per-turn map push from Game. 90 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:09:45 +02:00
5ebbd2ce42 feat(io): adaptive ASCII map width; document map command, mark sub-project done
AsciiMap sizes room boxes to the longest visible name (capped) so names
like 'Dark Hallway' are no longer truncated; connector centering follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:09:08 +02:00
7948aa2088 feat(io): GUI MapPanel (Graphics2D) in the side panel; drop exits text
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:06:48 +02:00
a0560a6ec5 feat(game): push map to the IO each turn (GUI live update)
Game now derives the MapView from the world each turn; GameTest gains a
real World (the per-turn map push needs it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:04:14 +02:00
fcf8083b7c feat(io,command): map command + GameIO setMap/showMap, console ASCII
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:02:17 +02:00
9ce939167c feat(io): AsciiMap console renderer for MapView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:00:35 +02:00
c03123d5ae feat(map): MapLayout BFS grid layout with fog of war
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:59:52 +02:00
e10ff2cf99 feat(io): MapView model (CellState, RoomCell, Connection, MapView)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:58:58 +02:00
42dbcd9696 feat(model): track visited rooms on Player for the map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:58:21 +02:00
f1b83f1f81 Merge presentation/output layer (sub-project 1) into develop
Semantic styled-output model + console (ANSI/box-drawing) and Swing
(4-region, JTextPane, bundled font, HiDPI scaling + zoom, responsive
side panel) renderers, per-turn HUD, welcome banner. 79 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:38:19 +02:00
13f34e1694 fix: correct .gitignore for .superpowers/ (was glued to .DS_Store line)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:38:10 +02:00
b042d7e0ef fix(io): responsive side-panel width (ratio of window, clamped min/max)
Side panel (map slot) now sizes to ~25% of window width, clamped to
[160, 320] scaled px, recomputed on resize — instead of a fixed width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:29:51 +02:00
9316f67c31 docs: mark presentation layer (sub-project 1) as implemented
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:25:42 +02:00
d3a9bd06cc fix(io): scale GUI fonts for HiDPI + live zoom (Ctrl +/-/0)
Java Swing does not reliably pick up desktop display scaling on every
Linux session (tiny text on HiDPI laptops). Detect the display scale from
the default GraphicsConfiguration transform (falling back to screen DPI)
and multiply all font sizes by it, plus the window size. Add live zoom via
Ctrl +/-/0 as a guaranteed manual override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:25:04 +02:00
650bbbe58c feat(io): welcome banner for game start
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:22:59 +02:00
963ddfb89e feat(io): SwingIO 4-region layout, JTextPane styling, bundled font loader
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:21:23 +02:00
5f9d52ca41 feat(game): publish HUD (location, gold, turn) each loop iteration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:19:40 +02:00
9274eef4c0 refactor(command): LookCommand emits semantic RoomView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:18:50 +02:00
64cc654803 feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:18:18 +02:00
44fde78c64 feat(io): make print(StyledText) the GameIO primitive; keep suite green
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:16:55 +02:00
02e448d61a feat(io): RoomView/Hud views and default Renderings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:15:06 +02:00
d57a1ab9a4 feat(io): semantic styled-text model (Style, Span, StyledText)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:14:22 +02:00
230177dfb5 docs: spec for presentation/output layer (sub-project 1)
Semantic output model (Style/Span/StyledText) + RoomView/Hud views,
GameIO extended additively (write(String) stays as default), ConsoleIO
(ANSI + box-drawing, tiered glyph modes) and SwingIO (region layout,
JTextPane, bundled font) renderers, command + TestIO migration. Map,
menu, save, music explicitly deferred to later sub-projects.

Also: capture "don't overwhelm / distribute info across regions" and
"B baseline + ASCII for big moments" decisions in the backlog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:06:36 +02:00
189 changed files with 8347 additions and 333 deletions

View File

@@ -36,4 +36,18 @@ build/
.vscode/ .vscode/
### Mac OS ### ### Mac OS ###
.DS_Store.superpowers/ .DS_Store
### Brainstorming visual companion ###
.superpowers/
# Planning specs & implementation plans (kept locally, not tracked)
docs/superpowers/
# Local save games and settings
saves/
# Source audio (MP3 originals); only the converted .ogg under
# src/main/resources/music/ is tracked and bundled. Anchored so it
# ignores the top-level source folder but not the resources path.
/music/

View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip

View File

@@ -20,25 +20,43 @@ werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
## Voraussetzungen ## Voraussetzungen
- Java 25+ - Java 25+
- Maven 3.9+ - Maven 3.9+ **oder** der mitgelieferte Maven Wrapper (`./mvnw`) damit ist
keine separate Maven-Installation nötig; der Wrapper lädt die passende
Maven-Version beim ersten Aufruf selbst herunter.
## Schnellstart ohne Maven
Im Ordner [`release/`](release/) liegt eine fertig gebaute, eigenständige JAR
(`HauntedManor.jar`) mit allen Abhängigkeiten. Sie braucht nur Java 25:
```bash
java -jar release/HauntedManor.jar # Konsolen-Version
java -cp release/HauntedManor.jar thb.jeanluc.adventure.AppGui # Swing-GUI
```
## Bauen und Testen ## Bauen und Testen
Mit dem Wrapper (empfohlen, keine Maven-Installation nötig) unter Windows
`mvnw.cmd` statt `./mvnw`:
```bash ```bash
mvn clean test # alle Tests ausführen (67 Tests) ./mvnw clean test # alle Tests ausführen (233 Tests)
mvn clean package # JAR bauen ./mvnw clean package # baut die eigenständige JAR nach release/HauntedManor.jar
``` ```
Ein systemweit installiertes `mvn` funktioniert identisch (`mvn clean test` …).
## Spielen ## Spielen
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`: Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
```bash ```bash
mvn exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App) ./mvnw exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui) ./mvnw exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
``` ```
`mvn exec:java` ohne Profil startet ebenfalls die Konsolen-Version. `./mvnw exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
Alternativ direkt aus der gebauten JAR (siehe [Schnellstart](#schnellstart-ohne-maven)).
### Befehle im Spiel ### Befehle im Spiel
@@ -46,6 +64,8 @@ mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|---|---|---| |---|---|---|
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen | | `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
| `look` | `l` | Aktuellen Raum beschreiben | | `look` | `l` | Aktuellen Raum beschreiben |
| `map` | `m` | Karte der erkundeten Räume anzeigen |
| `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen |
| `inventory` | `inv`, `i` | Inventar anzeigen | | `inventory` | `inv`, `i` | Inventar anzeigen |
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen | | `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
| `drop <item>` | `put` | Gegenstand ablegen | | `drop <item>` | `put` | Gegenstand ablegen |

View File

@@ -17,7 +17,13 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
## Themenblöcke ## Themenblöcke
### 1. Reichere Präsentation / Ausgabe-Schicht ### 1. Reichere Präsentation / Ausgabe-Schicht ✅ umgesetzt (Branch `feature/presentation-layer`)
> Umgesetzt: semantisches Modell (`io.text`), `ConsoleIO` (ANSI + Box-Drawing,
> Color/Glyph-Modi), `SwingIO` (4 Regionen, `JTextPane`, gebündelter Font,
> HiDPI-Skalierung + Zoom `Strg +/-/0`), HUD pro Zug, Welcome-Banner. 79 Tests grün.
> Spec/Plan unter `docs/superpowers/`. Karte/Menü/Save/Musik weiterhin offen.
- Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner, - Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner,
Hervorhebungen für Items/NPCs/Richtungen). Hervorhebungen für Items/NPCs/Richtungen).
@@ -26,6 +32,19 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
- **GUI (Swing)**: mehr als ein `JTextArea` Farben, Stile, evtl. Panels, - **GUI (Swing)**: mehr als ein `JTextArea` Farben, Stile, evtl. Panels,
Statusanzeige, Bilder/Icons. Statusanzeige, Bilder/Icons.
**Festgelegt (Zielbild Konsole):** Stufe **B als Baseline** (Boxed Heading,
gelabelte Sektionen, persistente HUD-Zeile: Ort · Gold · Zug · Licht), **ASCII-Art
nur für große Momente** (Spielstart, Finale, Schlüsselräume). Semantische
Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
**Designprinzipien:**
- **Nicht überladen / Progressive Disclosure** Raumeintritt zeigt nur das
Wesentliche (Ort, kurze Beschreibung, Auffälliges); Details auf Abruf
(`examine`, `look`).
- **Info auf Regionen verteilen statt Text stapeln** die **Karte ersetzt die
„Exits:"-Zeile**. GUI: persistentes Map-Panel macht die Exit-Zeile überflüssig.
Konsole: kompakte Mini-Map (bei `map` bzw. knapp beim Raumeintritt).
### 2. Nerd Fonts / Glyphen das Installationsproblem ### 2. Nerd Fonts / Glyphen das Installationsproblem
- Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des - Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des
@@ -37,7 +56,14 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks - **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme. (reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
### 3. Karte / Map ### 3. Karte / Map ✅ umgesetzt (Branch `feature/map`)
> Umgesetzt: `MapLayout` (BFS-Gitter-Layout + Fog of War), `MapView`-Modell,
> GUI-`MapPanel` (Graphics2D, ersetzt die Exit-Liste), Konsolen-ASCII via
> `AsciiMap` + `map`-Befehl, Besuchte-Räume-Tracking auf `Player`, Map-Push pro Zug.
> Item/NPC-Marker und Türen-Styling offen (Quest-Teilprojekt). Spec/Plan unter
> `docs/superpowers/`.
- Übersichtskarte der Räume in **beiden** Modi. - Übersichtskarte der Räume in **beiden** Modi.
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph). - Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
@@ -52,10 +78,31 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
### 5. Quest-System (NPC-Ausbau) ### 5. Quest-System (NPC-Ausbau)
> ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags +
> Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen,
> bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Demo:
> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet.
>
> ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests
> (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt,
> Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest
> `restore_power`.
>
> ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven
> Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang).
> Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`).
> **Damit ist das Quest-System (#3) komplett.** Offen: Licht/Dunkelheit &
> Item-Kombination sowie #4 Content-Ausbau.
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item). - Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand), - Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
Quest-Log, evtl. Win-Condition daran gekoppelt. Quest-Log, evtl. Win-Condition daran gekoppelt.
**GUI-Layout (festgelegt):** Quest-Log als **dauerhaft sichtbare Box unterhalb der
Karte** im rechten Seitenbereich (Map oben, Quests darunter). Gehört zu Quest-
Teilprojekt #2 (Quest-Log); das Fundament #1 reserviert/plant den Slot.
### 5b. Spielkern: zentraler Bogen + Mechanik-Spine ### 5b. Spielkern: zentraler Bogen + Mechanik-Spine
**Leitidee (bestätigt):** Bewusst *kondensiert* wenige Mechaniken, die sich **Leitidee (bestätigt):** Bewusst *kondensiert* wenige Mechaniken, die sich
@@ -75,9 +122,19 @@ des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei.
Hebel/Ventile setzen Flags und öffnen Bereiche. Hebel/Ventile setzen Flags und öffnen Bereiche.
3. **Licht & Dunkelheit** dunkle Räume brauchen eine brennende Lichtquelle, 3. **Licht & Dunkelheit** dunkle Räume brauchen eine brennende Lichtquelle,
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen). sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
> `Light`-Helper, Gating in go/look/take/examine (Modell B: kein Eintritt ohne
> Licht), HUD-Licht verdrahtet. Demo: dunkler Dungeon braucht die brennende
> Lampe für den Generator.
4. **Item-Kombination** z.B. `match + candle → lit candle`. Achtung: v1.0 hat 4. **Item-Kombination** z.B. `match + candle → lit candle`. Achtung: v1.0 hat
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
werden (jetzt erlaubt, da über MVP hinaus). werden (jetzt erlaubt, da über MVP hinaus).
> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene
> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText),
> Operanden aus Inventar oder Raum, reihenfolge-unabhängiger Paar-Key. Kein
> Parser-Eingriff (`on`/`with`/`to` sind bereits Filler). Selbst-Kombination
> (a==b) wird beim Laden abgelehnt. Demo: `matches` + `torch` → `lit_torch`
> (dauerhafte Lichtquelle).
5. **Quest-Ketten + mehrere Enden** Teilziele referenzieren Flags; Kette 5. **Quest-Ketten + mehrere Enden** Teilziele referenzieren Flags; Kette
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende. abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
@@ -93,6 +150,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
### 6. Hauptmenü & Settings ### 6. Hauptmenü & Settings
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). Hauptmenü-Shell oberhalb
> des Game-Loops (Neues Spiel / Laden / Einstellungen / Beenden) über neue
> `GameIO.choose`-Primitive (Konsole: nummeriertes Menü; GUI: Buttons).
> **Minimale Settings**: nur Farb-Modus + Glyphen-Modus (ASCII/Unicode), live
> umschaltbar, in `saves/settings.json` persistiert. Musik-/Typewriter-Toggles
> weiter zurückgestellt (Features existieren noch nicht).
- **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*. - **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*.
- **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl, - **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl,
Zeitstempel) anzeigen und hineinladen. Zeitstempel) anzeigen und hineinladen.
@@ -103,12 +167,27 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
### 7. Speichern / Laden ### 7. Speichern / Laden
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). JSON-Spielstand als
> Delta über die frisch aus YAML geladene Welt (`SaveData`/`SaveCodec`/
> `SaveService`, atomare Writes). **Ein aktiver Slot**: manuelles `save`,
> Autosave (Quest-Completion + alle 10 Züge) und `quit`/`menu` (speichern +
> zurück ins Menü) schreiben denselben Slot; Laden nur über das Menü.
> `saves/` ist gitignored.
- Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume, - Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume,
Quest-Fortschritt) Format z.B. YAML/JSON. Quest-Fortschritt) Format z.B. YAML/JSON.
- Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü. - Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü.
### 8. Musik (GUI) ### 8. Musik (GUI)
> ✅ umgesetzt (Branch `feature/music`): OGG Vorbis (vorbisspi/jorbis), pro Raum
> via `music:`-Feld in `rooms.yaml`, externer gitignored `music/`-Ordner (nicht im
> JAR). Idempotenter Track-Wechsel mit ~400 ms Fades; Räume ohne `music` blenden zu
> Stille; gleicher Track läuft nahtlos weiter. Music-Stufe Off/Low/Med/High in den
> Settings (Default Medium), persistiert. Konsole stumm. Testbarer `MusicController`
> über isoliertes `OggMusicBackend`; fehlende Datei → still, kein Absturz. Siehe
> `docs/music.md`.
- Hintergrundmusik **nur in der GUI**. - Hintergrundmusik **nur in der GUI**.
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld - **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner. (z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
@@ -124,6 +203,19 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
## Festgelegte Erweiterungs-Entscheidungen ## Festgelegte Erweiterungs-Entscheidungen
> ✅ **Pathfinding** umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>`
> per BFS über besuchte Räume (Locks + Licht beachtet), via `GoCommand`
> (Richtung vs. Raumziel). Konsole still + Zusammenfassung (Start/Mitte/Ziel),
> GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms).
> **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt.
> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough
> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte
> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel),
> Abschluss-Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`,
> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem
> Spiel, nicht beim Laden.
| Entscheidung | Wert | | Entscheidung | Wert |
|---|---| |---|---|
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** Standard-Collections (vom Prof bestätigt) | | Eigene `uebung`-Datenstrukturen verwenden? | **Nein** Standard-Collections (vom Prof bestätigt) |

View File

@@ -0,0 +1,32 @@
# Background music (GUI)
Background music plays only in the Swing GUI. It is **data-driven and bundled**:
- Tracks live as `.ogg` (OGG Vorbis) files in `src/main/resources/music/`, so they
are on the classpath and ship inside the JAR — the game plays music out of the box,
with no external folder to set up.
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
`music: manor-theme.ogg`. Entering a room with a different track fades the old one
out and the new one in (sequential, not an overlapping cross-fade);
a room with no `music:` field fades to silence; the same track keeps playing
seamlessly across rooms.
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
persisted to `saves/settings.json`. Off disables playback.
- Missing tracks are ignored (silent) — the game never crashes over absent audio.
- The console version is always silent.
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI;
`OggMusicBackend` streams each track from the classpath (`/music/<track>`) on a
daemon thread.
## Adding or replacing a track
1. Convert your source audio to OGG Vorbis. With ffmpeg:
`ffmpeg -i in.mp3 -c:a libvorbis -q:a 5 out.ogg`
or with gstreamer (no ffmpeg needed):
`gst-launch-1.0 filesrc location=in.mp3 ! decodebin ! audioconvert ! audioresample ! vorbisenc quality=0.5 ! oggmux ! filesink location=out.ogg`
2. Drop the `.ogg` into `src/main/resources/music/`.
3. Reference it from a room's `music:` field in `rooms.yaml`.
The four tracks shipped today: `manor-theme.ogg` (ground floor),
`cellar-drone.ogg` (cellar), `musicbox.ogg` (upper floor), `lament.ogg` (chapel).

295
Semesterprojekt/mvnw vendored Executable file
View File

@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
Semesterprojekt/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

View File

@@ -49,6 +49,18 @@
<version>${logback.version}</version> <version>${logback.version}</version>
</dependency> </dependency>
<!-- OGG Vorbis decoding (GUI background music) -->
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>vorbisspi</artifactId>
<version>1.0.3.3</version>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jorbis</artifactId>
<version>0.0.17.4</version>
</dependency>
<!-- Tests --> <!-- Tests -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
@@ -97,6 +109,36 @@
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version> <version>3.5.2</version>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<!-- Self-contained, runnable JAR with all dependencies bundled. -->
<outputFile>${project.basedir}/release/HauntedManor.jar</outputFile>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<!-- Default entry point: console version. GUI via -cp ... AppGui. -->
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Main-Class>thb.jeanluc.adventure.App</Main-Class>
</manifestEntries>
</transformer>
<!-- Merge META-INF/services so the OGG Vorbis SPI keeps working. -->
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>org.codehaus.mojo</groupId> <groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId> <artifactId>exec-maven-plugin</artifactId>

Binary file not shown.

View File

@@ -10,21 +10,39 @@ import thb.jeanluc.adventure.command.impl.GoCommand;
import thb.jeanluc.adventure.command.impl.HelpCommand; import thb.jeanluc.adventure.command.impl.HelpCommand;
import thb.jeanluc.adventure.command.impl.InventoryCommand; import thb.jeanluc.adventure.command.impl.InventoryCommand;
import thb.jeanluc.adventure.command.impl.LookCommand; import thb.jeanluc.adventure.command.impl.LookCommand;
import thb.jeanluc.adventure.command.impl.QuitCommand; import thb.jeanluc.adventure.command.impl.MapCommand;
import thb.jeanluc.adventure.command.impl.MenuCommand;
import thb.jeanluc.adventure.command.impl.QuestsCommand;
import thb.jeanluc.adventure.command.impl.ReadCommand; import thb.jeanluc.adventure.command.impl.ReadCommand;
import thb.jeanluc.adventure.command.impl.SaveCommand;
import thb.jeanluc.adventure.command.impl.TakeCommand; import thb.jeanluc.adventure.command.impl.TakeCommand;
import thb.jeanluc.adventure.command.impl.TalkCommand; import thb.jeanluc.adventure.command.impl.TalkCommand;
import thb.jeanluc.adventure.command.impl.UseCommand; import thb.jeanluc.adventure.command.impl.UseCommand;
import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.game.TutorialGuide;
import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.io.text.Banner;
import thb.jeanluc.adventure.loader.TutorialLoader;
import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.loader.WorldLoader;
import thb.jeanluc.adventure.menu.MainMenu;
import thb.jeanluc.adventure.menu.SettingsMenu;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import thb.jeanluc.adventure.save.SaveSlotInfo;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;
import java.util.List;
/** /**
* Entry point for the console version of the game. Loads the world from * Console entry point. Loads settings, then runs the main-menu shell loop:
* YAML, wires up the command registry, and hands control to the * New Game / Load / Settings / Quit. Each game runs the shared {@link Game}
* {@link Game} loop. * loop and returns here when the player saves-and-exits, reaches an ending,
* or input ends. Shared by console and GUI via {@link #run(GameIO)}.
*/ */
@Slf4j @Slf4j
public final class App { public final class App {
@@ -32,11 +50,7 @@ public final class App {
private App() { private App() {
} }
/** /** Standard JVM entry point. @param args ignored */
* Standard JVM entry point.
*
* @param args ignored
*/
public static void main(String[] args) { public static void main(String[] args) {
GameIO io = new ConsoleIO(); GameIO io = new ConsoleIO();
try { try {
@@ -48,15 +62,55 @@ public final class App {
} }
} }
/** /** Runs the menu shell on the given IO until the player quits. */
* Boots the game on the given IO channel. Reusable by both
* {@link App} (console) and {@code AppGui} (Swing worker thread).
*
* @param io the IO channel to use
*/
public static void run(GameIO io) { public static void run(GameIO io) {
run(io, new SaveService(), new SettingsStore());
}
/** Runs the menu shell with injectable persistence (for testing). */
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
Settings settings = settingsStore.load();
SettingsMenu.apply(settings, io);
boolean running = true;
while (running) {
switch (MainMenu.show(io)) {
case NEW_GAME -> play(io, saves, newSession(io), true);
case LOAD -> {
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
if (slot != null) {
try {
play(io, saves, saves.load(slot.slug()), false);
} catch (SaveException e) {
io.write("Could not load: " + e.getUserMessage());
}
}
}
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);
case QUIT -> running = false;
}
}
io.write("Farewell.");
io.shutdown(); // GUI: close the window and exit; console: no-op
}
/** Loads a fresh world and binds it to a newly named session. */
private static GameSession newSession(GameIO io) {
WorldLoader.LoadResult loaded = new WorldLoader().load(); WorldLoader.LoadResult loaded = new WorldLoader().load();
GameContext ctx = new GameContext(loaded.world(), loaded.player(), io); String name = MainMenu.promptName(io, "Manor");
return new GameSession(loaded.world(), loaded.player(), name);
}
/** Builds the registry, runs one game to completion, autosaving to its slot. */
private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {
GameContext ctx = new GameContext(session, io);
ctx.setSaveCallback(() -> {
try {
saves.save(session);
} catch (SaveException e) {
log.warn("Autosave failed", e);
}
});
CommandRegistry registry = new CommandRegistry(); CommandRegistry registry = new CommandRegistry();
registry.register(new GoCommand(), "go", "move", "walk"); registry.register(new GoCommand(), "go", "move", "walk");
@@ -67,20 +121,31 @@ public final class App {
registry.register(new UseCommand(), "use"); registry.register(new UseCommand(), "use");
registry.register(new ReadCommand(), "read"); registry.register(new ReadCommand(), "read");
registry.register(new ExamineCommand(), "examine", "x", "inspect"); registry.register(new ExamineCommand(), "examine", "x", "inspect");
registry.register(new MapCommand(), "map", "m");
registry.register(new QuestsCommand(), "quests", "log", "journal");
registry.register(new TalkCommand(), "talk", "speak"); registry.register(new TalkCommand(), "talk", "speak");
registry.register(new GiveCommand(), "give"); registry.register(new GiveCommand(), "give");
registry.register(new HelpCommand(registry), "help", "?"); registry.register(new SaveCommand(saves), "save");
QuitCommand quit = new QuitCommand();
registry.register(quit, "quit", "exit");
Game game = new Game(ctx, registry, new CommandParser()); Game game = new Game(ctx, registry, new CommandParser());
quit.bind(game);
io.write(loaded.world().getTitle()); MenuCommand menu = new MenuCommand(saves);
io.write(loaded.world().getWelcomeMessage()); menu.bind(game);
new LookCommand().execute(ctx, java.util.List.of()); registry.register(menu, "menu", "quit", "exit");
registry.register(new HelpCommand(registry), "help", "?");
if (withTutorial) {
Tutorial tutorial = new TutorialLoader().load();
if (!tutorial.isEmpty()) {
game.setTutorialGuide(new TutorialGuide(tutorial, registry));
}
}
io.print(Banner.welcome(session.getWorld().getTitle()));
io.write(session.getWorld().getWelcomeMessage());
new LookCommand().execute(ctx, List.of());
game.run(); game.run();
io.setMusic(null); // fade music out when returning to the menu
} }
} }

View File

@@ -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()) {

View File

@@ -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());
} }

View File

@@ -1,43 +1,128 @@
package thb.jeanluc.adventure.command.impl; package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.game.Pathfinder;
import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Moves the player into the room reachable in a given direction. * Moves the player. {@code go <direction>} steps once to a connected room.
* Usage: {@code go <direction>}. * {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
* rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
* {@code go to <room>}. The parser strips the filler word "to", so "go to
* &lt;room&gt;" and "go &lt;room&gt;" are equivalent.
*/ */
public class GoCommand implements Command { public class GoCommand implements Command {
@Override @Override
public void execute(GameContext ctx, List<String> args) { public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) { if (args.isEmpty()) {
ctx.getIo().write("Go where? Try 'go north'."); ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
return; return;
} }
Direction dir;
try { try {
dir = Direction.fromString(args.getFirst()); Direction dir = Direction.fromString(args.getFirst());
} catch (IllegalArgumentException e) { moveDirection(ctx, dir);
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is."); } catch (IllegalArgumentException notADirection) {
return; goToRoom(ctx, args);
} }
Optional<Room> next = ctx.getPlayer().getCurrentRoom().getExit(dir); }
/** Single-step move in a compass direction (the original behaviour). */
private void moveDirection(GameContext ctx, Direction dir) {
Room here = ctx.getPlayer().getCurrentRoom();
Optional<Room> next = here.getExit(dir);
if (next.isEmpty()) { if (next.isEmpty()) {
ctx.getIo().write("You can't go " + dir.getLabel() + " from here."); ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
return; return;
} }
ExitLock lock = here.getExitLocks().get(dir);
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
ctx.getIo().write(lock.blocked());
return;
}
if (!Light.isLit(ctx, next.get())) {
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
+ "(try lighting your lamp with 'use lamp').");
return;
}
ctx.getPlayer().setCurrentRoom(next.get()); ctx.getPlayer().setCurrentRoom(next.get());
new LookCommand().execute(ctx, List.of()); new LookCommand().execute(ctx, List.of());
} }
/** Resolve a visited room by id/name and auto-walk the BFS path to it. */
private void goToRoom(GameContext ctx, List<String> args) {
String name = String.join(" ", args).toLowerCase();
Room target = resolveVisited(ctx, name);
if (target == null) {
ctx.getIo().write("You don't know any direction or place called '" + name + "'.");
return;
}
Room start = ctx.getPlayer().getCurrentRoom();
if (target == start) {
ctx.getIo().write("You're already in the " + target.getName() + ".");
return;
}
List<Room> path = Pathfinder.findPath(ctx, target);
if (path.isEmpty()) {
ctx.getIo().write("You can't find a way there right now.");
return;
}
List<Room> route = new ArrayList<>();
route.add(start);
route.addAll(path);
for (Room step : path) {
ctx.getPlayer().setCurrentRoom(step);
ctx.getIo().travelStep(MapLayout.compute(
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
}
ctx.getIo().write(summary(route));
new LookCommand().execute(ctx, List.of());
}
/** Finds a visited room whose id or display name matches {@code name}. */
private Room resolveVisited(GameContext ctx, String name) {
// the command test harness (CommandTestSupport) uses a null world; production always has one
if (ctx.getWorld() == null) {
return null;
}
for (String id : ctx.getPlayer().getVisitedRoomIds()) {
Room r = ctx.getWorld().getRooms().get(id);
if (r == null) {
continue;
}
if (id.equalsIgnoreCase(name) || r.getName().equalsIgnoreCase(name)) {
return r;
}
}
return null;
}
/** Names start, destination, and at most one middle waypoint. */
private String summary(List<Room> route) {
int n = route.size();
String start = route.get(0).getName();
String dest = route.get(n - 1).getName();
if (n >= 3) {
String middle = route.get(n / 2).getName();
return "You make your way from the " + start + ", through the " + middle
+ ", to the " + dest + ".";
}
return "You make your way from the " + start + " to the " + dest + ".";
}
@Override @Override
public String help() { public String help() {
return "go <direction> - move to the connected room (north/south/east/west)"; return "go <direction> - move one room (north/south/east/west); "
+ "or go to <room> to travel to a known room";
} }
} }

View File

@@ -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

View File

@@ -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";
}
}

View File

@@ -0,0 +1,44 @@
package thb.jeanluc.adventure.command.impl;
import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import java.util.List;
/**
* Saves the active slot and returns to the main menu by stopping the loop.
* Usage: {@code menu} (aliases {@code quit}, {@code exit}).
*/
@RequiredArgsConstructor
public class MenuCommand implements Command {
private final SaveService saves;
private Game game;
/** Binds the running game (two-phase wiring, as the registry is built first). */
public void bind(Game game) {
this.game = game;
}
@Override
public void execute(GameContext ctx, List<String> args) {
try {
saves.save(ctx.getSession());
ctx.getIo().write("Game saved. Returning to the main menu.");
} catch (SaveException e) {
ctx.getIo().write("Could not save: " + e.getUserMessage());
}
if (game != null) {
game.stop();
}
}
@Override
public String help() {
return "menu - save and return to the main menu (aliases: quit, exit)";
}
}

View File

@@ -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)";
}
}

View File

@@ -1,41 +0,0 @@
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext;
import java.util.List;
/**
* Ends the game loop. Holds a reference to the {@link Game} so it can
* flip the loop flag. Usage: {@code quit}.
*/
public class QuitCommand implements Command {
/** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */
private Game game;
/**
* Binds the game instance after construction. Two-phase wiring is
* necessary because the registry is typically built before the game
* starts running.
*
* @param game the running game; must not be null
*/
public void bind(Game game) {
this.game = game;
}
@Override
public void execute(GameContext ctx, List<String> args) {
ctx.getIo().write("Goodbye.");
if (game != null) {
game.stop();
}
}
@Override
public String help() {
return "quit - exit the game (alias: exit)";
}
}

View File

@@ -0,0 +1,31 @@
package thb.jeanluc.adventure.command.impl;
import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import java.util.List;
/** Manually saves the active slot. Usage: {@code save}. */
@RequiredArgsConstructor
public class SaveCommand implements Command {
private final SaveService saves;
@Override
public void execute(GameContext ctx, List<String> args) {
try {
saves.save(ctx.getSession());
ctx.getIo().write("Game saved.");
} catch (SaveException e) {
ctx.getIo().write(e.getUserMessage());
}
}
@Override
public String help() {
return "save - save your game to the current slot";
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -1,6 +1,7 @@
package thb.jeanluc.adventure.command.impl; package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Combinations;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
@@ -8,9 +9,10 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Invokes the item-specific {@link Item#use(GameContext)} action. The * With one argument, invokes the item-specific {@link Item#use(GameContext)}
* item must either be in the player's inventory or in the current room. * action; the item must be in the player's inventory or the current room.
* Usage: {@code use <item-id>}. * With two arguments ({@code use <item> on <item>}), delegates to the
* combination engine. Usage: {@code use <item>} or {@code use <item> on <item>}.
*/ */
public class UseCommand implements Command { public class UseCommand implements Command {
@@ -20,6 +22,10 @@ public class UseCommand implements Command {
ctx.getIo().write("Use what?"); ctx.getIo().write("Use what?");
return; return;
} }
if (args.size() >= 2) {
Combinations.tryUse(ctx, args.get(0), args.get(1));
return;
}
String itemId = args.getFirst(); String itemId = args.getFirst();
Optional<Item> item = ctx.getPlayer().findItem(itemId); Optional<Item> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) { if (item.isEmpty()) {
@@ -34,6 +40,6 @@ public class UseCommand implements Command {
@Override @Override
public String help() { public String help() {
return "use <item> - use an item (read it, switch it on/off, ...)"; return "use <item> - use an item; or use <item> on <item> to combine them";
} }
} }

View File

@@ -0,0 +1,69 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.item.Item;
/**
* Applies {@code use X on Y} item combinations. Stateless utility, mirroring
* {@link Conditions}/{@link Effects}/{@link Light}. Operands resolve from the
* player's inventory or the current room.
*/
public final class Combinations {
private Combinations() {
}
/**
* Attempts to combine the two named items. Prints a resolution error if an
* operand is absent, the recipe's {@code failText} (or a generic line) if a
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
* or applies consume/produce/effects + the response on success.
*/
public static void tryUse(GameContext ctx, String idX, String idY) {
if (!present(ctx, idX)) {
ctx.getIo().write(noSuch(idX));
return;
}
if (!present(ctx, idY)) {
ctx.getIo().write(noSuch(idY));
return;
}
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
if (c == null) {
ctx.getIo().write("Nothing happens.");
return;
}
if (!Conditions.all(c.requires(), ctx)) {
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
return;
}
for (String id : c.consume()) {
removeFromAnywhere(ctx, id);
}
if (c.produce() != null) {
Item produced = ctx.getWorld().getItems().get(c.produce());
if (produced != null) {
ctx.getPlayer().addItem(produced);
}
}
Effects.applyAll(c.effects(), ctx);
if (c.response() != null && !c.response().isBlank()) {
ctx.getIo().write(c.response());
}
}
private static boolean present(GameContext ctx, String id) {
return ctx.getPlayer().hasItem(id)
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent();
}
private static void removeFromAnywhere(GameContext ctx, String id) {
if (ctx.getPlayer().removeItem(id).isEmpty()) {
ctx.getPlayer().getCurrentRoom().removeItem(id);
}
}
private static String noSuch(String id) {
return "There is no '" + id + "' here or in your inventory.";
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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";
}
}

View File

@@ -5,6 +5,10 @@ import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.command.CommandParser; import thb.jeanluc.adventure.command.CommandParser;
import thb.jeanluc.adventure.command.CommandRegistry; import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand; import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Ending;
import java.util.Optional; import java.util.Optional;
@@ -28,10 +32,23 @@ public class Game {
/** Loop flag. Flipped to false by {@link #stop()}. */ /** Loop flag. Flipped to false by {@link #stop()}. */
private boolean running = true; private boolean running = true;
/** Optional onboarding guide; null in normal/loaded play. */
private TutorialGuide tutorialGuide;
/** Wires the interactive tutorial (New Game only). */
public void setTutorialGuide(TutorialGuide tutorialGuide) {
this.tutorialGuide = tutorialGuide;
}
/** /**
* Runs the loop until {@link #stop()} is called or input is exhausted. * Runs the loop until {@link #stop()} is called or input is exhausted.
*/ */
public void run() { public void run() {
publishHud();
maybeEnd();
if (tutorialGuide != null) {
tutorialGuide.begin(ctx);
}
while (running) { while (running) {
String input = ctx.getIo().readLine(); String input = ctx.getIo().readLine();
if (input == null) { if (input == null) {
@@ -41,15 +58,60 @@ public class Game {
if (parsed.verb().isEmpty()) { if (parsed.verb().isEmpty()) {
continue; continue;
} }
if (tutorialGuide != null && tutorialGuide.isActive()
&& parsed.verb().equals("skip")) {
tutorialGuide.skip(ctx);
publishHud();
continue;
}
Optional<Command> cmd = registry.find(parsed.verb()); Optional<Command> cmd = registry.find(parsed.verb());
if (cmd.isEmpty()) { if (cmd.isEmpty()) {
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
} else { } else {
cmd.get().execute(ctx, parsed.args()); cmd.get().execute(ctx, parsed.args());
ctx.getSession().incrementTurn();
if (ctx.getSession().getTurn() % 10 == 0) {
ctx.save();
}
if (tutorialGuide != null && tutorialGuide.isActive()) {
tutorialGuide.onCommand(ctx, parsed);
}
} }
publishHud();
maybeEnd();
} }
} }
/** Ends the game if any ending's conditions are met, printing its screen. */
private void maybeEnd() {
// Don't end the game (win or lose) while the player is still in the tutorial —
// a pending ending fires once the walkthrough finishes or is skipped.
if (tutorialGuide != null && tutorialGuide.isActive()) {
return;
}
Ending e = EndingEngine.triggered(ctx);
if (e != null) {
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
stop();
}
}
private void publishHud() {
QuestEngine.tick(ctx);
ctx.getIo().setHud(new Hud(
ctx.getPlayer().getCurrentRoom().getName(),
ctx.getPlayer().getGold(),
ctx.getSession().getTurn(),
Light.carryingLight(ctx)));
MapView map = MapLayout.compute(
ctx.getWorld(),
ctx.getPlayer().getVisitedRoomIds(),
ctx.getPlayer().getCurrentRoom());
ctx.getIo().setMap(map);
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
}
/** /**
* Signals the loop to terminate after the current iteration. * Signals the loop to terminate after the current iteration.
*/ */

View File

@@ -1,26 +1,63 @@
package thb.jeanluc.adventure.game; package thb.jeanluc.adventure.game;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.World; import thb.jeanluc.adventure.model.World;
/** /**
* Bundle of everything a command needs to do its work: the world, the * Bundle of everything a command needs: the active {@link GameSession}
* player, and the IO channel. Passing this single object keeps command * (world, player, flags, quests, turn) and the IO channel. Getters delegate
* signatures small and consistent. * to the session so existing command code keeps working unchanged.
*/ */
@Getter
@RequiredArgsConstructor
public class GameContext { public class GameContext {
/** Loaded world (rooms, items, npcs). */ private final GameSession session;
private final World world;
/** The player avatar. */
private final Player player;
/** IO channel for reading input and writing output. */
private final GameIO io; private final GameIO io;
/** Silent autosave hook, wired by the app; no-op by default (tests). */
private Runnable saveCallback = () -> { };
public GameContext(GameSession session, GameIO io) {
this.session = session;
this.io = io;
}
/** Convenience for tests/legacy callers: wraps a fresh single-use session. */
public GameContext(World world, Player player, GameIO io) {
this(new GameSession(world, player, "session"), io);
}
public GameSession getSession() {
return session;
}
public World getWorld() {
return session.getWorld();
}
public Player getPlayer() {
return session.getPlayer();
}
public GameState getState() {
return session.getState();
}
public QuestLog getQuestLog() {
return session.getQuestLog();
}
public GameIO getIo() {
return io;
}
/** Sets the silent autosave hook (called by interval/quest autosave). */
public void setSaveCallback(Runnable saveCallback) {
this.saveCallback = saveCallback;
}
/** Runs the silent autosave hook. */
public void save() {
saveCallback.run();
}
} }

View File

@@ -0,0 +1,36 @@
package thb.jeanluc.adventure.game;
import lombok.Getter;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.World;
/**
* Bundle of the mutable, savable game state for one playthrough: the
* loaded {@link World}, the {@link Player}, world {@link GameState} flags,
* {@link QuestLog} progress, the turn counter, and the bound save-slot name.
* Save/load read and write exactly this object.
*/
@Getter
public class GameSession {
private final World world;
private final Player player;
private final GameState state = new GameState();
private final QuestLog questLog = new QuestLog();
private final String slotName;
private int turn;
public GameSession(World world, Player player, String slotName) {
this.world = world;
this.player = player;
this.slotName = slotName;
}
public void setTurn(int turn) {
this.turn = turn;
}
public void incrementTurn() {
this.turn++;
}
}

View File

@@ -0,0 +1,34 @@
package thb.jeanluc.adventure.game;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/** Named world flags. A flag is "set" (true) when present in the set. */
public class GameState {
private final Set<String> flags = new LinkedHashSet<>();
public boolean isSet(String flag) {
return flags.contains(flag);
}
public void set(String flag) {
flags.add(flag);
}
public void clear(String flag) {
flags.remove(flag);
}
public Set<String> all() {
return Collections.unmodifiableSet(flags);
}
/** Replaces all flags with the given collection (used by load). */
public void restore(Collection<String> newFlags) {
flags.clear();
flags.addAll(newFlags);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,76 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
/**
* Breadth-first shortest-path search over the rooms the player has already
* visited, honouring the same gates as a manual move: locked exits and
* darkness. Stateless utility, alongside {@link Conditions}/{@link Effects}/
* {@link Light}.
*/
public final class Pathfinder {
private Pathfinder() {
}
/**
* Finds the shortest path from the player's current room to {@code target}.
*
* @param ctx active game context
* @param target destination room (expected to be a visited room)
* @return the rooms to step through, excluding the start and ending at
* {@code target}; empty if there is no currently-passable path
* (or {@code target} is the current room)
*/
public static List<Room> findPath(GameContext ctx, Room target) {
Room start = ctx.getPlayer().getCurrentRoom();
Set<String> visited = ctx.getPlayer().getVisitedRoomIds();
Queue<Room> queue = new ArrayDeque<>();
Map<Room, Room> cameFrom = new HashMap<>();
queue.add(start);
cameFrom.put(start, null);
while (!queue.isEmpty()) {
Room current = queue.poll();
if (current == target) {
return reconstruct(cameFrom, target);
}
for (Map.Entry<Direction, Room> exit : current.getExits().entrySet()) {
Room next = exit.getValue();
if (cameFrom.containsKey(next) || !visited.contains(next.getId())) {
continue;
}
ExitLock lock = current.getExitLocks().get(exit.getKey());
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
continue;
}
if (!Light.isLit(ctx, next)) {
continue;
}
cameFrom.put(next, current);
queue.add(next);
}
}
return List.of();
}
/** Walks predecessors back from target to start; returns steps after start. */
private static List<Room> reconstruct(Map<Room, Room> cameFrom, Room target) {
LinkedList<Room> path = new LinkedList<>();
for (Room r = target; r != null && cameFrom.get(r) != null; r = cameFrom.get(r)) {
path.addFirst(r);
}
return path;
}
}

View File

@@ -0,0 +1,95 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.io.text.QuestEntry;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.StyledText;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.QuestStage;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Per-turn quest progression and view building, driven by world conditions. */
public final class QuestEngine {
private QuestEngine() {
}
public static void tick(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests();
for (Quest q : quests.values()) {
if (q.autoStart() && !ctx.getQuestLog().isActive(q.id()) && !ctx.getQuestLog().isCompleted(q.id())) {
ctx.getQuestLog().start(q.id());
}
}
int guard = 0;
int max = totalStages(quests) + quests.size() + 1;
boolean changed = true;
while (changed && guard++ < max) {
changed = false;
for (String id : new ArrayList<>(ctx.getQuestLog().active())) {
Quest q = quests.get(id);
if (q == null) {
continue;
}
int idx = ctx.getQuestLog().stageIndex(id);
if (idx >= q.stages().size()) {
finish(ctx, q);
changed = true;
continue;
}
QuestStage stage = q.stages().get(idx);
if (Conditions.all(stage.completion(), ctx)) {
Effects.applyAll(stage.onComplete(), ctx);
ctx.getIo().print(StyledText.builder()
.heading("✓ Objective complete: ").plain(stage.objective()).build());
ctx.getQuestLog().advance(id);
if (ctx.getQuestLog().stageIndex(id) >= q.stages().size()) {
finish(ctx, q);
}
changed = true;
}
}
}
}
private static void finish(GameContext ctx, Quest q) {
if (ctx.getQuestLog().isCompleted(q.id())) {
return;
}
Effects.applyAll(q.onComplete(), ctx);
ctx.getQuestLog().complete(q.id());
ctx.getIo().print(StyledText.builder()
.heading("★ Quest complete: ").plain(q.title()).build());
ctx.save();
}
public static QuestView viewOf(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests();
List<QuestEntry> active = new ArrayList<>();
for (String id : ctx.getQuestLog().active()) {
Quest q = quests.get(id);
if (q == null) {
continue;
}
int idx = ctx.getQuestLog().stageIndex(id);
String objective = idx < q.stages().size() ? q.stages().get(idx).objective() : "";
active.add(new QuestEntry(q.title(), objective));
}
List<String> completed = new ArrayList<>();
for (String id : ctx.getQuestLog().completed()) {
Quest q = quests.get(id);
completed.add(q == null ? id : q.title());
}
return new QuestView(active, completed);
}
private static int totalStages(Map<String, Quest> quests) {
int total = 0;
for (Quest q : quests.values()) {
total += q.stages().size();
}
return total;
}
}

View File

@@ -0,0 +1,58 @@
package thb.jeanluc.adventure.game;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/** Runtime quest progress: current stage per active quest, plus completed ids. */
public class QuestLog {
private final Map<String, Integer> stageIndex = new LinkedHashMap<>();
private final Set<String> completed = new LinkedHashSet<>();
public void start(String id) {
if (!completed.contains(id)) {
stageIndex.putIfAbsent(id, 0);
}
}
public boolean isActive(String id) {
return stageIndex.containsKey(id);
}
public boolean isCompleted(String id) {
return completed.contains(id);
}
public int stageIndex(String id) {
return stageIndex.getOrDefault(id, 0);
}
public void advance(String id) {
stageIndex.merge(id, 1, Integer::sum);
}
public void complete(String id) {
stageIndex.remove(id);
completed.add(id);
}
public Set<String> active() {
return Collections.unmodifiableSet(stageIndex.keySet());
}
public Set<String> completed() {
return Collections.unmodifiableSet(completed);
}
/** Replaces all progress with the given stage map and completed set (load). */
public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
stageIndex.clear();
stageIndex.putAll(stages);
completed.clear();
completed.addAll(completedIds);
}
}

View File

@@ -0,0 +1,123 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import java.util.Optional;
/**
* Drives the interactive start-of-game walkthrough, layered onto the real game
* loop. Prints an instruction, then on each executed command decides whether the
* current step is satisfied (confirm + advance) or not (hint). Movement steps
* require an actual room change. Alias-aware via the {@link CommandRegistry}.
*/
public final class TutorialGuide {
private final Tutorial tutorial;
private final CommandRegistry registry;
private int index;
private boolean active;
private boolean begun;
private Room roomAtStepStart;
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
this.tutorial = tutorial;
this.registry = registry;
this.active = !tutorial.isEmpty();
}
/** @return true while the walkthrough is still running. */
public boolean isActive() {
return active;
}
/** Prints the intro and the first instruction (call once at loop start). */
public void begin(GameContext ctx) {
if (!active || begun) {
return;
}
begun = true;
if (tutorial.intro() != null && !tutorial.intro().isBlank()) {
ctx.getIo().write(tutorial.intro());
}
enterStep(ctx);
}
/** Evaluates the current step against the command that was just executed. */
public void onCommand(GameContext ctx, ParsedCommand parsed) {
if (!active) {
return;
}
TutorialStep step = tutorial.steps().get(index);
if (satisfies(step, ctx, parsed)) {
ctx.getIo().write(step.confirm());
index++;
if (index >= tutorial.steps().size()) {
if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) {
ctx.getIo().write(tutorial.closingTips());
}
active = false;
} else {
enterStep(ctx);
}
} else {
ctx.getIo().write(step.hint());
}
}
/** Ends the tutorial early (the 'skip' command). */
public void skip(GameContext ctx) {
if (active) {
ctx.getIo().write("Tutorial skipped.");
active = false;
}
}
private void enterStep(GameContext ctx) {
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
ctx.getIo().write(tutorial.steps().get(index).instruction());
}
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
String expect = step.expect();
if ("go_direction".equals(expect)) {
return isGo(parsed) && firstIsDirection(parsed) && moved(ctx);
}
if ("go_to_room".equals(expect)) {
return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx);
}
return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs();
}
private boolean isGo(ParsedCommand p) {
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
}
private boolean firstIsDirection(ParsedCommand p) {
if (p.args().isEmpty()) {
return false;
}
try {
Direction.fromString(p.args().get(0));
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private boolean moved(GameContext ctx) {
return roomAtStepStart != null
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
}
private boolean sameCommand(String a, String b) {
Optional<Command> ca = registry.find(a);
Optional<Command> cb = registry.find(b);
return ca.isPresent() && cb.isPresent() && ca.get() == cb.get();
}
}

View File

@@ -1,41 +1,69 @@
package thb.jeanluc.adventure.io; package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.PrintStream; import java.io.PrintStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List;
/** /**
* {@link GameIO} backed by {@code System.in} and {@code System.out}. * {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
* Each {@code write} appends a newline so output blocks are visually * (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
* separated. {@code readLine} prints a {@code &gt; } prompt before * and degrade safely (no colours when output is piped; ASCII frames on request).
* blocking.
*/ */
public class ConsoleIO implements GameIO { public class ConsoleIO implements GameIO {
/** Input source. */ /** Whether ANSI colour is emitted. */
public enum ColorMode { ON, OFF, AUTO }
/** Frame/glyph fidelity tier. */
public enum GlyphMode { ASCII, UNICODE, GLYPH }
private static final int FRAME_WIDTH = 44;
private final BufferedReader in; private final BufferedReader in;
/** Output sink. */
private final PrintStream out; private final PrintStream out;
private boolean useColor;
private GlyphMode glyphs;
/**
* Creates a console IO using standard input and output.
*/
public ConsoleIO() { public ConsoleIO() {
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out); this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
} }
/** /** Backward-compatible stream constructor (colour off, unicode frames). */
* Test-friendly constructor allowing custom streams.
*
* @param in reader to consume player input
* @param out stream to write player output
*/
public ConsoleIO(BufferedReader in, PrintStream out) { public ConsoleIO(BufferedReader in, PrintStream out) {
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
}
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
this.in = in; this.in = in;
this.out = out; this.out = out;
this.glyphs = glyphs;
setColorMode(colorMode);
}
/** Re-resolves colour emission from the given mode (AUTO checks the console). */
public void setColorMode(ColorMode mode) {
this.useColor = switch (mode) {
case ON -> true;
case OFF -> false;
case AUTO -> System.console() != null;
};
}
/** Switches the frame/glyph fidelity tier live. */
public void setGlyphMode(GlyphMode mode) {
this.glyphs = mode;
} }
@Override @Override
@@ -43,15 +71,100 @@ public class ConsoleIO implements GameIO {
out.print("> "); out.print("> ");
out.flush(); out.flush();
try { try {
String line = in.readLine(); // null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts).
return line == null ? "" : line; return in.readLine();
} catch (IOException e) { } catch (IOException e) {
return ""; // Treat a stream failure as EOF so input ends cleanly rather than spinning.
return null;
} }
} }
@Override @Override
public void write(String s) { public void print(StyledText text) {
out.println(s); StringBuilder sb = new StringBuilder();
for (Span s : text.spans()) {
sb.append(paint(s.style(), s.text()));
}
out.println(sb);
}
@Override
public void showRoom(RoomView room) {
out.println(paint(Style.HEADING, frameTop(room.name())));
out.println(room.description().stripTrailing());
if (!room.items().isEmpty()) {
out.println(section("Items", room.items(), Style.ITEM));
}
if (!room.npcs().isEmpty()) {
out.println(section("People", room.npcs(), Style.NPC));
}
if (!room.exits().isEmpty()) {
out.println(section("Exits", room.exits(), Style.EXIT));
} else {
out.println(" " + paint(Style.DIM, "no obvious exits"));
}
out.println(paint(Style.HEADING, frameBottom()));
}
@Override
public void setHud(Hud h) {
String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn()
+ " · light: " + (h.lightOn() ? "on" : "off") + " ]";
out.println(paint(Style.DIM, line));
}
@Override
public void showMap(MapView view) {
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
}
private String section(String label, List<String> xs, Style st) {
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {
sb.append(" · ");
}
sb.append(paint(st, xs.get(i)));
}
return sb.toString();
}
private String frameTop(String title) {
String tl = glyphs == GlyphMode.ASCII ? "+" : "";
String tr = glyphs == GlyphMode.ASCII ? "+" : "";
String h = glyphs == GlyphMode.ASCII ? "-" : "";
StringBuilder sb = new StringBuilder(tl).append(h).append(' ')
.append(title.toUpperCase()).append(' ');
while (sb.length() < FRAME_WIDTH - 1) {
sb.append(h);
}
return sb.append(tr).toString();
}
private String frameBottom() {
String bl = glyphs == GlyphMode.ASCII ? "+" : "";
String br = glyphs == GlyphMode.ASCII ? "+" : "";
String h = glyphs == GlyphMode.ASCII ? "-" : "";
StringBuilder sb = new StringBuilder(bl);
while (sb.length() < FRAME_WIDTH - 1) {
sb.append(h);
}
return sb.append(br).toString();
}
private String paint(Style st, String s) {
if (!useColor) {
return s;
}
String code = switch (st) {
case HEADING -> "";
case ITEM -> "";
case NPC -> "";
case EXIT -> "";
case DANGER -> "";
case DIM -> "";
case PLAIN -> "";
};
return code.isEmpty() ? s : code + s + "";
} }
} }

View File

@@ -1,24 +1,126 @@
package thb.jeanluc.adventure.io; package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.QuestText;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.Renderings;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.StyledText;
import java.util.List;
/** /**
* Bidirectional text channel between the engine and the player. Implemented * Bidirectional channel between the engine and the player. Commands emit
* by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic * semantic, role-styled output via {@link #print(StyledText)} (the primitive)
* to the actual interface. * and the {@link #showRoom}/{@link #setHud} views; each frontend renders them.
*/ */
public interface GameIO { public interface GameIO {
/** /** Blocks until a line of input is available; empty string at end of input. */
* Blocks until a line of input is available.
*
* @return the next user input line, or an empty string at end of input
*/
String readLine(); String readLine();
/** Renders one styled output block. The only method implementors must provide. */
void print(StyledText text);
/** Convenience: a plain, unstyled line. */
default void write(String s) {
print(StyledText.of(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
}
/** /**
* Writes a message to the player. Each call corresponds to one logical * One frame of an auto-walk (the {@code go to} command), as the player moves
* output block; the implementation handles line breaks at the boundary. * through a room en route. Default is a no-op: text mode travels silently.
* The GUI overrides this to repaint the map and pace the animation.
* *
* @param s message text; must not be null * @param view the map snapshot at this step
*/ */
void write(String s); default void travelStep(MapView view) {
// no animation in text mode
}
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
default void showMap(MapView view) {
print(AsciiMap.render(view, true));
}
/** Per-turn push of the quest log. GUI repaints its box; console ignores. */
default void setQuests(QuestView view) {
// no-op
}
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
default void setMusic(String track) {
// no audio in text mode
}
/** Applies the music volume/level. Console no-op; GUI sets it. */
default void setMusicLevel(MusicLevel level) {
// no audio in text mode
}
/** On-demand quest log (the 'quests' command). Default renders styled text. */
default void showQuests(QuestView view) {
print(QuestText.render(view));
}
/**
* Presents a titled list of options and returns the 0-based index of the
* player's choice. Default (console) prints a numbered list and reads a
* number, re-prompting on invalid input. EOF / empty input returns the
* LAST option (menus place the safe/cancel option last). Frontends with
* native widgets (e.g. buttons) may override.
*
* @param title heading shown above the options
* @param options non-empty list of option labels
* @return 0-based index into {@code options}
*/
default int choose(String title, List<String> options) {
StyledText.Builder b = StyledText.builder().heading(title).plain("\n");
for (int i = 0; i < options.size(); i++) {
b.plain(" " + (i + 1) + ") " + options.get(i) + "\n");
}
print(b.build());
while (true) {
String line = readLine();
if (line == null || line.isBlank()) {
return options.size() - 1;
}
try {
int n = Integer.parseInt(line.trim());
if (n >= 1 && n <= options.size()) {
return n - 1;
}
} catch (NumberFormatException ignored) {
// fall through to re-prompt
}
write("Please enter a number between 1 and " + options.size() + ".");
}
}
/**
* Tears the frontend down after the main-menu loop ends (the player chose
* Quit). Console mode has nothing to release and exits naturally when
* {@code main} returns; the GUI overrides this to close its window and
* terminate the JVM, which would otherwise stay alive on the Swing EDT.
*/
default void shutdown() {
// no-op in text mode
}
} }

View File

@@ -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 &gt; 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();
}
}

View File

@@ -0,0 +1,17 @@
package thb.jeanluc.adventure.io;
/** Raw audio operations — the only part that touches sound hardware. */
public interface MusicBackend {
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
void fadeTo(String track, float gainDb);
/** Fade the current track out and stop. */
void fadeOut();
/** Set the playback volume immediately. */
void setGainDb(float gainDb);
/** Stop playback and release resources. */
void shutdown();
}

View File

@@ -0,0 +1,55 @@
package thb.jeanluc.adventure.io;
import java.util.Objects;
/**
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
* the same track), fades to silence on a null/blank track, and honours the
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
* {@link MusicBackend}.
*/
public final class MusicController {
private final MusicBackend backend;
private MusicLevel level = MusicLevel.MEDIUM;
private String current; // currently selected track, or null = silence
public MusicController(MusicBackend backend) {
this.backend = backend;
}
/** Called per turn with the current room's music field (may be null/blank). */
public void room(String track) {
if (!level.isOn()) {
return;
}
String next = (track == null || track.isBlank()) ? null : track;
if (Objects.equals(next, current)) {
return;
}
current = next;
if (next == null) {
backend.fadeOut();
} else {
backend.fadeTo(next, level.gainDb());
}
}
/** Applies a new music level (volume / off). */
public void level(MusicLevel newLevel) {
if (newLevel == this.level) {
return;
}
this.level = newLevel;
if (!newLevel.isOn()) {
backend.fadeOut();
current = null; // turning back on replays on the next room() call
} else {
backend.setGainDb(newLevel.gainDb());
}
}
public void shutdown() {
backend.shutdown();
}
}

View File

@@ -0,0 +1,26 @@
package thb.jeanluc.adventure.io;
/** Background-music volume level, cycled in the Settings menu. */
public enum MusicLevel {
OFF, LOW, MEDIUM, HIGH;
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
public float gainDb() {
return switch (this) {
case OFF -> Float.NEGATIVE_INFINITY;
case LOW -> -20f;
case MEDIUM -> -10f;
case HIGH -> 0f;
};
}
/** @return true when music should play. */
public boolean isOn() {
return this != OFF;
}
/** Next level in the cycle (wraps HIGH → OFF). */
public MusicLevel next() {
return values()[(ordinal() + 1) % values().length];
}
}

View File

@@ -0,0 +1,158 @@
package thb.jeanluc.adventure.io;
import lombok.extern.slf4j.Slf4j;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.SourceDataLine;
import java.io.BufferedInputStream;
import java.io.InputStream;
/**
* Streams OGG Vorbis tracks bundled on the classpath under {@code /music/}
* through a {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding
* uses the vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a
* daemon thread; missing/undecodable files are logged and stay silent. GUI-only
* — verified manually.
*/
@Slf4j
public final class OggMusicBackend implements MusicBackend {
private static final String MUSIC_DIR = "/music/";
private static final int FADE_MS = 400;
private static final int FADE_STEPS = 20;
private static final int BUFFER = 4096;
private static final float FLOOR_DB = -40f; // near-silence for fades
private volatile Thread playThread;
private volatile boolean stopping;
private volatile SourceDataLine line;
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
@Override
public synchronized void fadeTo(String track, float gainDb) {
stopCurrent();
targetGainDb = gainDb;
String resource = MUSIC_DIR + track;
if (getClass().getResource(resource) == null) {
log.warn("Music resource not found, staying silent: {}", resource);
return;
}
stopping = false;
playThread = new Thread(() -> playLoop(resource, gainDb), "music");
playThread.setDaemon(true);
playThread.start();
}
@Override
public synchronized void fadeOut() {
stopCurrent();
}
@Override
public synchronized void setGainDb(float gainDb) {
targetGainDb = gainDb;
SourceDataLine l = line;
if (l != null) {
applyGain(l, gainDb);
}
}
@Override
public synchronized void shutdown() {
stopCurrent();
}
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
private void stopCurrent() {
Thread t = playThread;
if (t == null) {
return;
}
stopping = true;
try {
t.join(FADE_MS + 600L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
playThread = null;
}
private void playLoop(String resource, float gainDb) {
SourceDataLine l = null;
try {
while (!stopping) {
// Reopen the bundled resource each loop; BufferedInputStream gives
// the SPI the mark/reset support it needs to read the OGG header.
try (InputStream res = getClass().getResourceAsStream(resource);
AudioInputStream in = AudioSystem.getAudioInputStream(new BufferedInputStream(res))) {
AudioFormat base = in.getFormat();
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
base.getSampleRate(), 16, base.getChannels(),
base.getChannels() * 2, base.getSampleRate(), false);
try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
if (l == null) {
l = AudioSystem.getSourceDataLine(pcm);
l.open(pcm);
l.start();
line = l;
ramp(l, FLOOR_DB, gainDb); // fade in once
}
byte[] buf = new byte[BUFFER];
int n;
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
l.write(buf, 0, n);
}
}
}
}
} catch (Exception e) {
log.warn("Music playback failed for {}: {}", resource, e.getMessage());
} finally {
if (l != null) {
try {
ramp(l, currentGain(l), FLOOR_DB); // fade out
} catch (RuntimeException ignored) {
// best-effort fade
}
l.stop();
l.close();
}
line = null;
}
}
private void ramp(SourceDataLine l, float fromDb, float toDb) {
for (int i = 0; i <= FADE_STEPS; i++) {
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
sleep(FADE_MS / FADE_STEPS);
}
}
private float currentGain(SourceDataLine l) {
try {
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
} catch (IllegalArgumentException e) {
return targetGainDb;
}
}
private void applyGain(SourceDataLine l, float db) {
try {
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
} catch (IllegalArgumentException ignored) {
// line has no master-gain control on this platform
}
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View File

@@ -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);
};
}
}

View File

@@ -1,73 +1,295 @@
package thb.jeanluc.adventure.io; package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame; import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Color; import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font; import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsEnvironment;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
/** /**
* {@link GameIO} backed by a Swing window. A read-only output area shows * {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
* game text; a bottom text field collects player input. The input from * a styled output pane (centre), a side panel for exits / future map (right),
* the EDT is handed to the (worker-thread) game loop through a * and an input field (bottom). A bundled TrueType font is loaded if present so
* {@link LinkedBlockingQueue}. * colours and glyphs work without the player installing anything.
*
* <p>Font sizes are multiplied by a detected display scale (HiDPI) and by a
* user-adjustable zoom ({@code Ctrl +} / {@code Ctrl -} / {@code Ctrl 0}), so
* text stays readable on high-DPI laptops even when the JVM does not pick up
* the desktop scaling factor.</p>
*/ */
public class SwingIO implements GameIO { public class SwingIO implements GameIO {
/** Bridge between the EDT and the game loop. One element = one line. */ /** Unscaled base size for the main output/input font, in points. */
private static final float BASE_SIZE = 15f;
/** Unscaled size for the HUD and side-panel font, in points. */
private static final float SMALL_SIZE = 12.5f;
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
private static final long TRAVEL_STEP_MS = 300L;
/** Side-panel width as a fraction of the window width, with a lower bound only. */
private static final double SIDE_RATIO = 0.34;
private static final int SIDE_MIN = 220;
private final MusicController music = new MusicController(new OggMusicBackend());
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>(); private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
/** Main window. */
private final JFrame frame; private final JFrame frame;
private final JTextPane output;
/** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */ private final StyledDocument doc;
private final JTextArea output;
/** Input text field; ENTER pushes the line into {@link #inputs}. */
private final JTextField input; private final JTextField input;
private final JLabel hud;
private final MapPanel map;
private final JScrollPane sideScroll;
private final QuestPanel quests;
private final JScrollPane questScroll;
private final JPanel sidePanel;
private final Font baseFont;
/** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
private final float uiScale;
/** User zoom multiplier, adjusted live via keyboard. */
private float zoom = 1f;
/**
* Creates and shows the window with the given title. Must be called on
* the EDT (typical pattern: from {@code SwingUtilities.invokeLater}).
*
* @param title window title
*/
public SwingIO(String title) { public SwingIO(String title) {
frame = new JFrame(title); baseFont = loadFont();
output = new JTextArea(); uiScale = detectScale();
input = new JTextField();
output = new JTextPane();
output.setEditable(false); output.setEditable(false);
output.setLineWrap(true); output.setBackground(new Color(0x0b, 0x0e, 0x13));
output.setWrapStyleWord(true); output.setForeground(new Color(0xcf, 0xd6, 0xe0));
output.setBackground(Color.BLACK); output.setMargin(new Insets(8, 8, 8, 8));
output.setForeground(Color.LIGHT_GRAY); doc = output.getStyledDocument();
output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
input.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); hud = new JLabel(" ");
hud.setOpaque(true);
hud.setBackground(new Color(0x11, 0x15, 0x1c));
hud.setForeground(new Color(0x8b, 0x94, 0xa3));
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
map = new MapPanel(baseFont);
sideScroll = new JScrollPane(map);
sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18));
quests = new QuestPanel(baseFont);
questScroll = new JScrollPane(quests);
questScroll.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(0x2a, 0x2f, 0x3a)));
sidePanel = new JPanel(new BorderLayout());
sidePanel.add(sideScroll, BorderLayout.CENTER);
sidePanel.add(questScroll, BorderLayout.SOUTH);
input = new JTextField();
input.addActionListener(e -> { input.addActionListener(e -> {
String line = input.getText(); String line = input.getText();
input.setText(""); input.setText("");
output.append("> " + line + "\n"); appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
inputs.offer(line); inputs.offer(line);
}); });
frame = new JFrame(title);
frame.setLayout(new BorderLayout()); frame.setLayout(new BorderLayout());
frame.add(hud, BorderLayout.NORTH);
frame.add(new JScrollPane(output), BorderLayout.CENTER); frame.add(new JScrollPane(output), BorderLayout.CENTER);
frame.add(sidePanel, BorderLayout.EAST);
frame.add(input, BorderLayout.SOUTH); frame.add(input, BorderLayout.SOUTH);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(800, 600); frame.setSize((int) (900 * uiScale), (int) (600 * uiScale));
frame.setLocationRelativeTo(null); frame.setLocationRelativeTo(null);
frame.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
updateSideWidth();
applyFonts();
rescaleMenuOverlayIfVisible();
}
});
installZoomKeys();
applyFonts();
updateSideWidth();
frame.setVisible(true); frame.setVisible(true);
input.requestFocusInWindow(); input.requestFocusInWindow();
} }
/** Loads the bundled font if present, else falls back to a logical monospaced font. */
private Font loadFont() {
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
if (in != null) {
Font f = Font.createFont(Font.TRUETYPE_FONT, in);
GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
return f;
}
} catch (Exception e) {
// fall through to the logical monospaced fallback
}
return new Font(Font.MONOSPACED, Font.PLAIN, 12);
}
/** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */
private float detectScale() {
try {
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice().getDefaultConfiguration();
double transform = gc.getDefaultTransform().getScaleX();
if (transform > 1.0) {
return (float) transform;
}
} catch (Exception ignored) {
// fall through to DPI heuristic
}
try {
int dpi = Toolkit.getDefaultToolkit().getScreenResolution();
return Math.max(1f, dpi / 96f);
} catch (Exception ignored) {
return 1f;
}
}
/** Sizes the side panel to a fraction of the window width, with a scaled lower bound. */
private void updateSideWidth() {
int min = (int) (SIDE_MIN * uiScale);
int target = Math.max(min, (int) (frame.getWidth() * SIDE_RATIO));
sidePanel.setPreferredSize(new Dimension(target, 0));
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
sidePanel.revalidate();
frame.validate();
// The side panel changed size: let the map re-fit into it.
map.revalidate();
map.repaint();
}
/**
* Auto-scale derived from the window size relative to the design size
* (900x600). At the initial size this equals {@link #uiScale}; growing the
* window grows the text, mirroring how the map fits its panel.
*/
private float frameScale() {
double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0);
return (float) Math.max(0.6, Math.min(2.6, s));
}
/** Re-derives and applies all component fonts from the window auto-scale and zoom. */
private void applyFonts() {
float auto = frameScale();
float main = BASE_SIZE * auto * zoom;
float small = SMALL_SIZE * auto * zoom;
output.setFont(baseFont.deriveFont(main));
input.setFont(baseFont.deriveFont(main));
hud.setFont(baseFont.deriveFont(small));
quests.setFont(baseFont.deriveFont(small));
map.setZoom(zoom);
frame.revalidate();
frame.repaint();
}
/** Binds Ctrl +, Ctrl - and Ctrl 0 to live font zoom. */
private void installZoomKeys() {
JComponent root = frame.getRootPane();
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_0, KeyEvent.CTRL_DOWN_MASK), "zoomReset");
root.getActionMap().put("zoomIn", action(e -> setZoom(zoom + 0.1f)));
root.getActionMap().put("zoomOut", action(e -> setZoom(zoom - 0.1f)));
root.getActionMap().put("zoomReset", action(e -> setZoom(1f)));
}
private void bind(JComponent c, KeyStroke ks, String name) {
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
}
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
return new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
body.accept(e);
}
};
}
private void setZoom(float z) {
zoom = Math.max(0.6f, Math.min(3.0f, z));
applyFonts();
}
private Color colorFor(Style s) {
return switch (s) {
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
case ITEM -> new Color(0x46, 0xc8, 0xd8);
case NPC -> new Color(0xe6, 0xc3, 0x4a);
case EXIT -> new Color(0x6f, 0xcf, 0x73);
case DANGER -> new Color(0xe0, 0x6c, 0x6c);
case DIM -> new Color(0x6b, 0x72, 0x80);
case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
};
}
private void appendSpans(List<Span> spans) {
SwingUtilities.invokeLater(() -> {
try {
for (Span sp : spans) {
SimpleAttributeSet as = new SimpleAttributeSet();
StyleConstants.setForeground(as, colorFor(sp.style()));
if (sp.style() == Style.HEADING) {
StyleConstants.setBold(as, true);
}
doc.insertString(doc.getLength(), sp.text(), as);
}
output.setCaretPosition(doc.getLength());
} catch (BadLocationException ignored) {
// append-only; cannot occur with getLength()
}
});
}
@Override @Override
public String readLine() { public String readLine() {
try { try {
@@ -79,7 +301,231 @@ public class SwingIO implements GameIO {
} }
@Override @Override
public void write(String s) { public void print(StyledText text) {
SwingUtilities.invokeLater(() -> output.append(s + "\n")); List<Span> spans = new ArrayList<>(text.spans());
spans.add(new Span("\n", Style.PLAIN));
appendSpans(spans);
}
@Override
public void showRoom(RoomView room) {
List<Span> spans = new ArrayList<>();
spans.add(new Span(room.name() + "\n", Style.HEADING));
spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN));
if (!room.items().isEmpty()) {
spans.add(new Span("You see ", Style.PLAIN));
addList(spans, room.items(), Style.ITEM);
spans.add(new Span(".\n", Style.PLAIN));
}
if (!room.npcs().isEmpty()) {
addList(spans, room.npcs(), Style.NPC);
spans.add(new Span(" is here.\n", Style.PLAIN));
}
appendSpans(spans);
// Exits are shown by the map panel, not as text.
}
@Override
public void setHud(Hud h) {
String txt = " " + h.location() + " gold " + h.gold() + " turn " + h.turn()
+ " light " + (h.lightOn() ? "on" : "off");
SwingUtilities.invokeLater(() -> hud.setText(txt));
}
@Override
public void setMusic(String track) {
music.room(track);
}
@Override
public void setMusicLevel(MusicLevel level) {
music.level(level);
}
/**
* Closes the GUI when the player quits from the main menu. Stops music,
* disposes the window, and terminates the JVM — mirroring the native
* window-close (EXIT_ON_CLOSE); without this the EDT keeps the process
* alive and the dismissed menu overlay just reveals the game underneath.
*/
@Override
public void shutdown() {
music.shutdown();
SwingUtilities.invokeLater(() -> {
frame.dispose();
System.exit(0);
});
}
@Override
public void setMap(MapView view) {
map.show(view);
}
@Override
public void travelStep(MapView view) {
setMap(view);
try {
Thread.sleep(TRAVEL_STEP_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public void setQuests(QuestView view) {
quests.show(view);
}
@Override
public void showQuests(QuestView view) {
// The GUI quest box is always visible; nothing extra to do.
}
@Override
public void showMap(MapView view) {
// The GUI map panel is always visible; nothing extra to do on the 'map' command.
}
@Override
public int choose(String title, List<String> options) {
// Full-window overlay (glass pane), not a floating dialog: it covers the game
// chrome so no panels/outlines show behind the menu, and it fills + scales with
// the window. EDT↔worker handoff as in readLine: the worker blocks on take(),
// a button click / Escape offers the chosen index.
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
try {
return picked.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return options.size() - 1;
}
}
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
private String menuTitle;
private List<String> menuOptions;
private LinkedBlockingQueue<Integer> menuPicked;
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
private void rescaleMenuOverlayIfVisible() {
if (menuOptions != null && frame.getGlassPane().isVisible()) {
showMenuOverlay(menuTitle, menuOptions, menuPicked);
}
}
/** Installs the menu as the frame's glass pane (covers everything) and shows it. */
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
menuTitle = title;
menuOptions = options;
menuPicked = picked;
float scale = frameScale() * zoom; // same scaling the rest of the UI uses
JPanel glass = new JPanel(new GridBagLayout());
glass.setOpaque(true);
glass.setBackground(MENU_BG);
glass.addMouseListener(new java.awt.event.MouseAdapter() { }); // swallow backdrop clicks
JPanel card = new JPanel();
card.setOpaque(false);
card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS));
JLabel heading = new JLabel(title.toUpperCase());
heading.setForeground(MENU_TITLE);
heading.setFont(baseFont.deriveFont(Font.BOLD, BASE_SIZE * scale * 1.6f));
heading.setAlignmentX(Component.CENTER_ALIGNMENT);
heading.setBorder(BorderFactory.createEmptyBorder(0, 0, (int) (22 * scale), 0));
card.add(heading);
Runnable cancel = () -> {
hideMenuOverlay();
picked.offer(options.size() - 1);
};
for (int i = 0; i < options.size(); i++) {
int idx = i;
JButton b = menuButton(options.get(i), scale);
b.addActionListener(e -> {
hideMenuOverlay();
picked.offer(idx);
});
card.add(b);
if (i < options.size() - 1) {
card.add(Box.createVerticalStrut((int) (10 * scale)));
}
}
// Escape selects the last (safe/cancel) option — mirrors the console EOF convention.
glass.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "menuCancel");
glass.getActionMap().put("menuCancel", action(e -> cancel.run()));
glass.add(card); // single child → centered by GridBagLayout
frame.setGlassPane(glass);
glass.setVisible(true);
glass.requestFocusInWindow();
}
private void hideMenuOverlay() {
menuOptions = null; // stop resize rebuilds once the menu is dismissed
frame.getGlassPane().setVisible(false);
}
/** Palette for the themed menu overlay, matching the game window's dark look. */
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);
/** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */
private JButton menuButton(String text, float scale) {
int padV = (int) (11 * scale);
int padH = (int) (18 * scale);
JButton b = new JButton(text);
b.setFont(baseFont.deriveFont(BASE_SIZE * scale));
b.setForeground(BTN_FG);
b.setBackground(BTN_BG);
b.setFocusPainted(false);
b.setOpaque(true);
b.setHorizontalAlignment(SwingConstants.LEFT);
b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
b.setAlignmentX(Component.CENTER_ALIGNMENT);
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
Dimension size = new Dimension((int) (340 * scale), b.getPreferredSize().height);
b.setPreferredSize(size);
b.setMinimumSize(size);
b.setMaximumSize(size);
b.addMouseListener(new java.awt.event.MouseAdapter() {
@Override
public void mouseEntered(java.awt.event.MouseEvent e) {
b.setBackground(BTN_BG_HOVER);
b.setBorder(menuButtonBorder(BTN_LINE_HOVER, padV, padH));
}
@Override
public void mouseExited(java.awt.event.MouseEvent e) {
b.setBackground(BTN_BG);
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
}
});
return b;
}
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
return BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(line, 1),
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
}
private void addList(List<Span> spans, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {
spans.add(new Span(", ", Style.PLAIN));
}
spans.add(new Span(xs.get(i), st));
}
} }
} }

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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 }

View File

@@ -0,0 +1,4 @@
package thb.jeanluc.adventure.io.text;
/** A corridor between two placed rooms. */
public record Connection(RoomCell from, RoomCell to) {}

View File

@@ -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) {}

View File

@@ -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);
}
}

View File

@@ -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) {}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}
}

View File

@@ -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) {}

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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 }

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,53 @@
package thb.jeanluc.adventure.loader;
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.item.Item;
import java.util.List;
import java.util.Map;
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
public final class CombinationFactory {
private CombinationFactory() {
}
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
requireItem(items, dto.a(), "a");
requireItem(items, dto.b(), "b");
if (dto.a().equals(dto.b())) {
throw new WorldLoadException(
"Combination cannot combine an item with itself: '" + dto.a() + "'");
}
if (dto.consume() != null) {
for (String id : dto.consume()) {
requireItem(items, id, "consume");
}
}
if (dto.produce() != null) {
requireItem(items, dto.produce(), "produce");
}
return new Combination(
dto.a(),
dto.b(),
ConditionDto.toModelList(dto.requires()),
dto.consume() == null ? List.of() : List.copyOf(dto.consume()),
dto.produce(),
EffectDto.toModelList(dto.effects()),
dto.response(),
dto.failText());
}
private static void requireItem(Map<String, Item> items, String id, String field) {
if (id == null) {
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
}
if (!items.containsKey(id)) {
throw new WorldLoadException(
"Combination references unknown item '" + id + "' (field '" + field + "')");
}
}
}

View File

@@ -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());
}
}

View File

@@ -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() + "'");

View File

@@ -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()));
}
}

View File

@@ -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);
} }

View File

@@ -19,6 +19,9 @@ public final class RoomFactory {
* @return the freshly built room shell * @return the freshly built room shell
*/ */
public static Room shellFromDto(RoomDto dto) { public static Room shellFromDto(RoomDto dto) {
return new Room(dto.id(), dto.name(), dto.description()); Room room = new Room(dto.id(), dto.name(), dto.description());
room.setDark(Boolean.TRUE.equals(dto.dark()));
room.setMusic(dto.music());
return room;
} }
} }

View File

@@ -0,0 +1,63 @@
package thb.jeanluc.adventure.loader;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import thb.jeanluc.adventure.loader.dto.TutorialDto;
import thb.jeanluc.adventure.loader.dto.TutorialStepDto;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
/**
* Loads the optional onboarding tutorial from {@code <base>/tutorial.yaml}.
* Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world
* content. Returns {@link Tutorial#none()} when the file is absent.
*/
public class TutorialLoader {
/** Default classpath base directory. */
public static final String DEFAULT_BASE = "/world";
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
private final String basePath;
public TutorialLoader() {
this(DEFAULT_BASE);
}
public TutorialLoader(String basePath) {
this.basePath = basePath;
}
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */
public Tutorial load() {
String resource = basePath + "/tutorial.yaml";
try (InputStream in = getClass().getResourceAsStream(resource)) {
if (in == null) {
return Tutorial.none();
}
TutorialDto dto = yaml.readValue(in, TutorialDto.class);
if (dto == null) {
return Tutorial.none();
}
List<TutorialStep> steps = new ArrayList<>();
if (dto.steps() != null) {
for (TutorialStepDto s : dto.steps()) {
steps.add(new TutorialStep(
s.instruction(),
s.expect(),
s.minArgs() == null ? 0 : s.minArgs(),
s.confirm(),
s.hint()));
}
}
return new Tutorial(dto.intro(), steps, dto.closingTips());
} catch (IOException e) {
throw new WorldLoadException("Failed to parse " + resource, e);
}
}
}

View File

@@ -5,18 +5,25 @@ import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.loader.dto.GameDto; import thb.jeanluc.adventure.loader.dto.GameDto;
import thb.jeanluc.adventure.loader.dto.ItemDto; import thb.jeanluc.adventure.loader.dto.ItemDto;
import thb.jeanluc.adventure.loader.dto.NpcDto; import thb.jeanluc.adventure.loader.dto.NpcDto;
import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.loader.dto.RoomDto; import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.Ending;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World; import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -68,11 +75,17 @@ public class WorldLoader {
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class); List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class); List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class); List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
List<CombinationDto> combinationDtos =
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class); GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList()); requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList()); requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList()); requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList());
requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());
Map<String, Item> items = new HashMap<>(); Map<String, Item> items = new HashMap<>();
for (ItemDto dto : itemDtos) { for (ItemDto dto : itemDtos) {
@@ -86,6 +99,24 @@ public class WorldLoader {
for (RoomDto dto : roomDtos) { for (RoomDto dto : roomDtos) {
rooms.put(dto.id(), RoomFactory.shellFromDto(dto)); rooms.put(dto.id(), RoomFactory.shellFromDto(dto));
} }
Map<String, Quest> quests = new HashMap<>();
for (QuestDto dto : questDtos) {
quests.put(dto.id(), QuestFactory.fromDto(dto));
}
List<Ending> endings = new ArrayList<>();
for (EndingDto dto : endingDtos) {
endings.add(EndingFactory.fromDto(dto));
}
Map<String, Combination> combinations = new HashMap<>();
for (CombinationDto dto : combinationDtos) {
Combination c = CombinationFactory.fromDto(dto, items);
String key = c.key();
if (combinations.containsKey(key)) {
throw new WorldLoadException("Duplicate combination for pair '" + key + "'");
}
combinations.put(key, c);
}
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms); ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
resolver.resolveRooms(roomDtos); resolver.resolveRooms(roomDtos);
@@ -98,7 +129,7 @@ public class WorldLoader {
Player player = new Player(start, gold); Player player = new Player(start, gold);
World world = new World(rooms, items, npcs, World world = new World(rooms, items, npcs,
gameDto.title(), gameDto.welcomeMessage()); gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
log.info("World '{}' loaded: {} rooms, {} items, {} npcs", log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
gameDto.title(), rooms.size(), items.size(), npcs.size()); gameDto.title(), rooms.size(), items.size(), npcs.size());
return new LoadResult(world, player); return new LoadResult(world, player);
@@ -114,6 +145,19 @@ public class WorldLoader {
} }
} }
private <T> List<T> readListOptional(String resource, Class<T> elementType) {
try (InputStream in = getClass().getResourceAsStream(resource)) {
if (in == null) {
return List.of();
}
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
List<T> result = yaml.readValue(in, type);
return result == null ? List.of() : result;
} catch (IOException e) {
throw new WorldLoadException("Failed to parse " + resource, e);
}
}
private <T> T readSingle(String resource, Class<T> type) { private <T> T readSingle(String resource, Class<T> type) {
try (InputStream in = openResource(resource)) { try (InputStream in = openResource(resource)) {
return yaml.readValue(in, type); return yaml.readValue(in, type);

View File

@@ -0,0 +1,16 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of an item-combination recipe. */
public record CombinationDto(
String a,
String b,
List<ConditionDto> requires,
List<String> consume,
String produce,
List<EffectDto> effects,
String response,
String failText
) {
}

View File

@@ -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;
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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;
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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);
}
} }

View File

@@ -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);
}
} }

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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);
}
} }

View File

@@ -12,6 +12,10 @@ import java.util.Map;
* @param exits direction-name to target-room-id map * @param exits direction-name to target-room-id map
* @param items ids of items initially in this room * @param items ids of items initially in this room
* @param npcs ids of NPCs initially in this room * @param npcs ids of NPCs initially in this room
* @param exitLocks optional condition-gates per exit direction
* @param descriptionStates optional condition-gated description variants
* @param dark whether this room is dark; nullable
* @param music optional GUI background-music track filename; null = none
*/ */
public record RoomDto( public record RoomDto(
String id, String id,
@@ -19,6 +23,15 @@ public record RoomDto(
String description, String description,
Map<String, String> exits, Map<String, String> exits,
List<String> items, List<String> items,
List<String> npcs List<String> npcs,
List<ExitLockDto> exitLocks,
List<DescriptionStateDto> descriptionStates,
Boolean dark,
String music
) { ) {
/** Backward-compatible constructor without the optional state fields. */
public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null, null, null);
}
} }

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of the tutorial document. */
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
}

View File

@@ -0,0 +1,11 @@
package thb.jeanluc.adventure.loader.dto;
/** YAML representation of a tutorial step. */
public record TutorialStepDto(
String instruction,
String expect,
Integer minArgs,
String confirm,
String hint
) {
}

View File

@@ -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};
};
}
}

View File

@@ -0,0 +1,49 @@
package thb.jeanluc.adventure.menu;
import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.save.SaveService;
import thb.jeanluc.adventure.save.SaveSlotInfo;
import java.util.ArrayList;
import java.util.List;
/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */
public final class MainMenu {
private MainMenu() {
}
/** Shows the main menu and returns the chosen action. */
public static MenuAction show(GameIO io) {
int i = io.choose("HAUNTED MANOR",
List.of("New Game", "Load Game", "Settings", "Quit"));
return MenuAction.values()[i];
}
/** Prompts for a new save name; blank input yields {@code defaultName}. */
public static String promptName(GameIO io, String defaultName) {
io.write("Name your save (Enter for \"" + defaultName + "\"):");
String line = io.readLine();
return (line == null || line.isBlank()) ? defaultName : line.trim();
}
/**
* Lets the player pick a save slot to load. Returns the chosen
* {@link SaveSlotInfo}, or {@code null} if there are none or the player
* picks "Back".
*/
public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) {
List<SaveSlotInfo> slots = saves.list();
if (slots.isEmpty()) {
io.write("No saved games.");
return null;
}
List<String> labels = new ArrayList<>();
for (SaveSlotInfo s : slots) {
labels.add(s.slotName() + " (" + s.roomId() + ", turn " + s.turn() + ")");
}
labels.add("Back");
int i = io.choose("LOAD GAME", labels);
return i < slots.size() ? slots.get(i) : null;
}
}

View File

@@ -0,0 +1,6 @@
package thb.jeanluc.adventure.menu;
/** Top-level main-menu choices, in display order. */
public enum MenuAction {
NEW_GAME, LOAD, SETTINGS, QUIT
}

View File

@@ -0,0 +1,63 @@
package thb.jeanluc.adventure.menu;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;
import java.util.List;
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
public final class SettingsMenu {
private SettingsMenu() {
}
/** Loops the settings screen until "Back", persisting + applying each change. */
public static Settings show(GameIO io, Settings current, SettingsStore store) {
Settings s = current;
while (true) {
int i = io.choose("SETTINGS", List.of(
"Colour: " + s.colorMode(),
"Glyphs: " + s.glyphMode(),
"Music: " + s.musicLevel(),
"Back"));
if (i == 0) {
s = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
} else if (i == 1) {
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()), s.musicLevel());
} else if (i == 2) {
s = new Settings(s.colorMode(), s.glyphMode(), s.musicLevel().next());
} else {
return s;
}
store.save(s);
apply(s, io);
}
}
/** Applies settings to the IO: music level always; colour/glyph only on the console. */
public static void apply(Settings s, GameIO io) {
io.setMusicLevel(s.musicLevel());
if (io instanceof ConsoleIO c) {
c.setColorMode(s.colorMode());
c.setGlyphMode(s.glyphMode());
}
}
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
return switch (m) {
case AUTO -> ConsoleIO.ColorMode.ON;
case ON -> ConsoleIO.ColorMode.OFF;
case OFF -> ConsoleIO.ColorMode.AUTO;
};
}
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
return switch (m) {
case UNICODE -> ConsoleIO.GlyphMode.ASCII;
case ASCII -> ConsoleIO.GlyphMode.GLYPH;
case GLYPH -> ConsoleIO.GlyphMode.UNICODE;
};
}
}

View File

@@ -0,0 +1,35 @@
package thb.jeanluc.adventure.model;
import java.util.List;
/**
* A data-driven item-combination recipe. Identified by an unordered pair of
* item ids ({@code a}, {@code b}). When applied it may require conditions,
* consume items, produce an item, apply effects, and print a response.
*/
public record Combination(
String a,
String b,
List<Condition> requires,
List<String> consume,
String produce,
List<Effect> effects,
String response,
String failText
) {
public Combination {
requires = requires == null ? List.of() : List.copyOf(requires);
consume = consume == null ? List.of() : List.copyOf(consume);
effects = effects == null ? List.of() : List.copyOf(effects);
}
/** Order-independent key for an item pair (sorted, '|'-joined). */
public static String key(String x, String y) {
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
}
/** This recipe's canonical pair key. */
public String key() {
return key(a, b);
}
}

View File

@@ -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 }
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 }
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.
* *

View File

@@ -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;
} }

View File

@@ -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());
} }
/** /**

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -2,10 +2,13 @@ package thb.jeanluc.adventure.model;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.Setter;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
import java.util.ArrayList;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -42,6 +45,20 @@ public class Room {
/** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */ /** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */
private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>(); private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>();
/** Optional condition-gates per exit direction. */
private final EnumMap<Direction, ExitLock> exitLocks = new EnumMap<>(Direction.class);
/** Optional condition-gated description variants, first match wins. */
private final List<DescriptionState> descriptionStates = new ArrayList<>();
/** Whether this room is dark (needs a light source to enter/see). */
@Setter
private boolean dark;
/** Optional GUI background-music track filename for this room; null = none. */
@Setter
private String music;
/** /**
* Connects this room to another in the given direction. Does not * Connects this room to another in the given direction. Does not
* create the reverse connection — callers must set that up * create the reverse connection — callers must set that up
@@ -54,6 +71,25 @@ public class Room {
exits.put(direction, neighbour); exits.put(direction, neighbour);
} }
/**
* Adds a condition-gate to the exit in the given direction.
*
* @param direction the gated direction
* @param lock the lock to apply
*/
public void addExitLock(Direction direction, ExitLock lock) {
exitLocks.put(direction, lock);
}
/**
* Adds a condition-gated description variant (first match wins).
*
* @param state the description variant
*/
public void addDescriptionState(DescriptionState state) {
descriptionStates.add(state);
}
/** /**
* Looks up the room reachable in the given direction. * Looks up the room reachable in the given direction.
* *

View File

@@ -0,0 +1,20 @@
package thb.jeanluc.adventure.model;
import java.util.List;
/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
public Tutorial {
steps = steps == null ? List.of() : List.copyOf(steps);
}
public boolean isEmpty() {
return steps.isEmpty();
}
/** An absent tutorial (no steps). */
public static Tutorial none() {
return new Tutorial(null, List.of(), null);
}
}

Some files were not shown because too many files have changed in this diff Show More