From a0e21a9baa2bb38499a74c1782692d02f00c1047 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 22:21:10 +0200 Subject: [PATCH 01/13] docs: spec for GUI background music OGG Vorbis (vorbisspi+jorbis) per-room music via rooms.yaml, external gitignored music/ folder. Testable MusicController (idempotent track switch, null->silence, OFF disables) over an isolated OggMusicBackend; GameIO.setMusic/setMusicLevel no-op on console, played by SwingIO. Settings gains a Music level Off/Low/Med/High (default Medium). Console stays silent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-01-music-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/specs/2026-06-01-music-design.md diff --git a/Semesterprojekt/docs/superpowers/specs/2026-06-01-music-design.md b/Semesterprojekt/docs/superpowers/specs/2026-06-01-music-design.md new file mode 100644 index 0000000..8cada40 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-06-01-music-design.md @@ -0,0 +1,226 @@ +# Spec: Hintergrundmusik (GUI) + +Stand: 2026-06-01. Letzte der geplanten Mechaniken (nach `use X on Y`, +Pathfinding, Tutorial). Setzt Backlog #8 um: **Hintergrundmusik nur in der GUI**, +pro Raum via YAML, externer `music/`-Ordner, gestreamtes OGG. Baut auf der +`GameIO`-Abstraktion, dem Per-Zug-Push (`Game.publishHud`), dem datengetriebenen +Loader und der minimalen Settings auf. + +## 1. Kontext & Ziel + +Bisher gibt es keinen Ton. Ziel: **Hintergrundmusik in der GUI**, pro Raum über +ein optionales `music:`-Feld in `rooms.yaml`. Beim Raumwechsel sanfter Übergang; +Lautstärke/Aus über die Settings. Konsole bleibt stumm (No-Op). + +Bestätigte Entscheidungen (Brainstorming 2026-06-01): +- **Format/Lib**: **OGG Vorbis** via `vorbisspi` + `jorbis` (javax.sound-SPI), + gestreamt über eine `SourceDataLine`. +- **Wechsel**: gleicher Track → unverändert weiter (kein Neustart); anderer Track → + **kurzes Fade-out, dann Fade-in** (~400 ms, sequenziell, eine Line); + Raum **ohne** `music` → **Fade to silence**. +- **Settings**: eine zyklische **Music**-Stufe `OFF/LOW/MEDIUM/HIGH`, persistiert; + `OFF` deaktiviert Wiedergabe. Default **MEDIUM**. +- **Dateien**: externer `music/`-Ordner (Arbeitsverzeichnis), **gitignored**, nicht + im JAR; fehlende Datei → still + Log, kein Absturz. +- **Demo**: einige Räume mit Beispiel-`music:`-Feldern + Doku zum `music/`-Ordner. + +## 2. Scope + +**In Scope:** +- `MusicLevel`-Enum (OFF/LOW/MEDIUM/HIGH + `gainDb()`). +- `MusicController` (frontend-agnostisch, **testbar**): Track-/Level-Logik, + delegiert an ein `MusicBackend`. +- `MusicBackend`-Interface (Roh-Operationen) + `OggMusicBackend` (Audio, **manuell**). +- `GameIO.setMusic(String)` + `setMusicLevel(MusicLevel)` (Default No-Op); + `SwingIO`-Override mit `MusicController(new OggMusicBackend())`. +- `Game.publishHud` ruft `setMusic(currentRoom.getMusic())`; nach `game.run()` + zurück ins Menü → `setMusic(null)`. +- `Room.music` + `RoomDto.music` + `RoomFactory`; optionales `music:` in `rooms.yaml`. +- `Settings.musicLevel` + `SettingsStore` (fehlt → MEDIUM); `SettingsMenu`-Zeile + „Music"; Anwendung via `io.setMusicLevel(...)` (Konsole No-Op). +- `pom.xml`: `vorbisspi` + `jorbis`. +- `music/` in `.gitignore` + kurze Doku. +- Tests: `MusicController` (Fake-Backend), `MusicLevel`, Settings-Roundtrip, + Room-`music`-Laden. + +**Out of Scope:** +- Konsolen-Audio (bleibt stumm). +- True Cross-Fade (überlappende Lines) — nur sequenzielles Fade. +- Sound-Effekte / mehrere gleichzeitige Tracks. +- Mitliefern von Audiodateien im Repo. +- Automatisierte Tests der echten OGG-Dekodierung/-Wiedergabe (manuell, GUI-only). + +## 3. `MusicLevel` (model oder io) + +```java +public enum MusicLevel { + OFF, LOW, MEDIUM, HIGH; + + /** MASTER_GAIN in dB; für OFF irrelevant (Wiedergabe deaktiviert). */ + public float gainDb() { + return switch (this) { + case OFF -> Float.NEGATIVE_INFINITY; + case LOW -> -20f; + case MEDIUM -> -10f; + case HIGH -> 0f; + }; + } + public boolean isOn() { return this != OFF; } + + /** Nächste Stufe im Zyklus (für den Settings-Toggle). */ + public MusicLevel next() { + return values()[(ordinal() + 1) % values().length]; + } +} +``` +Platzierung: `io`-Paket (UI-nah, wie `ConsoleIO.ColorMode`). Settings referenziert es. + +## 4. `MusicBackend` + `MusicController` (io) + +```java +/** Raw audio operations; the only part that touches sound hardware. */ +public interface MusicBackend { + void fadeTo(String track, float gainDb); // fade current out, start track (loop), fade in + void fadeOut(); // fade current out + stop + void setGainDb(float gainDb); // immediate volume change + void shutdown(); // stop + release resources +} +``` + +```java +/** Frontend-agnostic music decision logic. Idempotent per track; honours level. */ +public final class MusicController { + private final MusicBackend backend; + private MusicLevel level = MusicLevel.MEDIUM; + private String current; // currently selected track id, or null = silence + + public MusicController(MusicBackend backend) { this.backend = backend; } + + /** Called per turn with the current room's music field (may be null). */ + public void room(String track) { + if (!level.isOn()) { return; } // OFF: stay silent, ignore + String t = (track == null || track.isBlank()) ? null : track; + if (java.util.Objects.equals(t, current)) { return; } // same → keep playing + current = t; + if (t == null) { backend.fadeOut(); } else { backend.fadeTo(t, level.gainDb()); } + } + + /** Settings change. */ + public void level(MusicLevel newLevel) { + this.level = newLevel; + if (!newLevel.isOn()) { backend.fadeOut(); current = null; } + else { backend.setGainDb(newLevel.gainDb()); } + // turning ON again starts on the next room() call (current was reset to null) + } + + public void shutdown() { backend.shutdown(); } +} +``` + +Hinweis: Bei Wechsel OFF→ON beginnt die Musik beim nächsten `room(...)` (pro Zug +ohnehin aufgerufen), da `current` auf null gesetzt wurde. Das vermeidet, dass der +Controller den aktuellen Raum kennen muss. + +## 5. `OggMusicBackend` (io, Audio, manuell) + +- Hält den aktuellen Wiedergabe-Thread (Daemon) + `SourceDataLine`. +- `fadeTo(track, gainDb)`: laufenden Track per `MASTER_GAIN`-Rampe (~400 ms) + ausblenden, Thread stoppen (`volatile boolean stopping`), `music/` öffnen + (`AudioSystem.getAudioInputStream` → vorbisspi liefert dekodiertes PCM), in einer + Schleife (Loop) in die Line schreiben, auf `gainDb` einblenden. +- `fadeOut()`: Rampe runter, Thread stoppen, Line schließen. +- `setGainDb(db)`: `MASTER_GAIN`-FloatControl sofort setzen. +- `shutdown()`: stoppen + Ressourcen freigeben. +- **Fehlende Datei / Decode-Fehler**: `log.warn`, still (kein Throw). `music/` fehlt + ganz → ebenso still. +- Threading: Wiedergabe auf Daemon-Thread; Fades blockierend auf dem aufrufenden + Thread (Worker), kurz (~400 ms) — analog zur bestehenden `travelStep`-Pause. + +`music/` = `Path.of("music", track)` relativ zum Arbeitsverzeichnis. + +## 6. `GameIO`-Anbindung + +```java +/** Per-turn current-room track; null = silence. Console no-op; GUI plays. */ +default void setMusic(String track) { /* no-op */ } + +/** Apply the music volume/level. Console no-op; GUI sets it. */ +default void setMusicLevel(MusicLevel level) { /* no-op */ } +``` + +- `SwingIO`: Feld `MusicController music = new MusicController(new OggMusicBackend());` + `setMusic(t) → music.room(t)`; `setMusicLevel(l) → music.level(l)`. +- `ConsoleIO`: erbt die No-Op-Defaults (kein Audio). +- `Game.publishHud()`: zusätzlich `ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());` + (idempotent → Wechsel nur bei echtem Raumwechsel). +- `App.play(...)`: nach `game.run()` → `io.setMusic(null);` (Musik beim Rückkehr ins + Menü ausblenden). + +## 7. Room-Feld + Loader + +- `Room`: optionales `private final String music;` (+ Getter). Konstruktor/Factory + erweitern; null = kein Track. +- `RoomDto`: optionales `music`-Feld (nullable). +- `RoomFactory.shellFromDto`: `music` durchreichen. +- `rooms.yaml`: optionales `music: .ogg` pro Raum. +- Demo: z.B. `kitchen: music: manor-theme.ogg`, `dungeon: music: dungeon-drone.ogg` + (Dateien liefert der Nutzer; fehlend → still). + +## 8. Settings-Erweiterung + +- `Settings` (record) bekommt `MusicLevel musicLevel`; `defaults()` → MEDIUM. +- `SettingsStore`: Jackson serialisiert `MusicLevel` als Namen; alter Spielstand + ohne Feld → `null` → auf MEDIUM normalisieren (im `load()` oder via Defaults). +- `SettingsMenu`: dritte Zeile „Music: "; Auswahl zyklt `level.next()`; + nach jeder Änderung speichern + `applySettings`. +- **Refactor**: `SettingsMenu.apply/show` nehmen statt `ConsoleIO` das `GameIO` + entgegen. `apply(Settings, GameIO io)`: + `io.setMusicLevel(s.musicLevel());` **und** `if (io instanceof ConsoleIO c) { + c.setColorMode(...); c.setGlyphMode(...); }`. Aufrufer (App, `MenuTest`) anpassen. +- `App.run`: beim Start `SettingsMenu.apply(settings, io)` (setzt auch Music-Level). + +## 9. Abhängigkeit + Dateien + +- `pom.xml` (``): + - `com.googlecode.soundlibs:vorbisspi:1.0.3.3` + - `com.googlecode.soundlibs:jorbis:0.0.17.4` + (vorbisspi zieht `tritonus-share` transitiv; bei Bedarf explizit ergänzen.) +- `.gitignore`: `music/`. +- Kurze Doku (z.B. in `docs/` oder README-Abschnitt): „Lege `.ogg`-Dateien in + `music/` ab; Räume referenzieren sie über `music:` in `rooms.yaml`. Ohne Dateien + bleibt die GUI stumm." + +## 10. Tests + +- **`MusicControllerTest`** (Fake-`MusicBackend`, das Aufrufe protokolliert): + - `room("a")` → `fadeTo("a", MEDIUM.gainDb())`; erneut `room("a")` → kein Aufruf. + - `room("b")` → `fadeTo("b", ...)`; `room(null)` → `fadeOut()`. + - `level(OFF)` → `fadeOut()` + danach ignoriert `room("a")` (still). + - `level(HIGH)` (von MEDIUM) → `setGainDb(HIGH)`; danach `room("a")` spielt. + - leerer/blank Track == null. +- **`MusicLevelTest`**: `gainDb()`-Mapping; `next()`-Zyklus; `isOn()`. +- **`SettingsStoreTest`** (erweitern): musicLevel round-trip; fehlendes Feld → MEDIUM. +- **`SettingsMenu`/`MenuTest`** (anpassen an `GameIO`-Signatur): Music-Zeile zyklt + und persistiert. +- **Room-`music`-Laden**: Loader-/Factory-Test, dass `music:` auf `Room.getMusic()` + landet (und Abwesenheit → null). +- `OggMusicBackend` + echte Wiedergabe: **manuell** (eine `.ogg` + Audio-Hardware), + keine Unit-Tests (Konvention: kein GUI-/Hardware-abhängiger Test). + +## 11. Architektur-Werte / Risiken + +- **Geteilter Loop bleibt**: nur zwei neue `GameIO`-No-Op-Defaults; Musik-Logik im + testbaren `MusicController`, Audio isoliert im `OggMusicBackend`. Konsole stumm. +- **Testbarkeit**: die wertvolle Entscheidungslogik (Idempotenz, null→Stille, OFF) + ist gegen ein Fake-Backend voll getestet; Hardware-Audio bleibt manuell. +- **Datengetrieben**: `music:` pro Raum in YAML; keine Hardcodierung. +- **Externe Dateien**: `music/` gitignored, nicht im JAR — Repo bleibt klein; fehlende + Dateien sind ein No-Op (graceful), kein Spielabbruch. +- **Risiko Lib**: vorbisspi/jorbis sind alt, funktionieren aber mit `javax.sound`. + Falls eine Datei nicht dekodierbar ist → graceful silence. Lizenz: LGPL/BSD-artig, + für eine Erweiterung erlaubt (Backlog: Bibliotheken erlaubt). +- **Threading**: Fades blockieren kurz den Worker-Thread (~400 ms, wie `travelStep`); + Wiedergabe auf Daemon-Thread. Beim Spielende `setMusic(null)` → Stille im Menü. +- **Settings-Refactor**: `SettingsMenu` von `ConsoleIO` auf `GameIO` umstellen ist die + einzige Änderung an bestehendem, bereits reviewtem Code — minimal und nötig, damit + der Music-Level beide Frontends erreicht (Konsole No-Op). From 36f60863ad20f86a57a73bcaad31312769f7b2e5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 22:46:15 +0200 Subject: [PATCH 02/13] docs: implementation plan for GUI background music 7 TDD tasks: MusicLevel, MusicController+MusicBackend, Room music field, GameIO seams + loop/app wiring, Settings music level + SettingsMenu->GameIO refactor, OggMusicBackend + deps + SwingIO wiring + docs, backlog note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../superpowers/plans/2026-06-01-music.md | 860 ++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md diff --git a/Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md b/Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md new file mode 100644 index 0000000..46369e4 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md @@ -0,0 +1,860 @@ +# GUI Background Music Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-room background music in the GUI — OGG tracks streamed from an external `music/` folder, fading on room change, with an Off/Low/Med/High Settings control. Console stays silent. + +**Architecture:** A testable `MusicController` holds the track/level decision logic (idempotent same-track, null→silence, OFF disables) and delegates raw operations to a `MusicBackend`. `OggMusicBackend` (the only audio/thread code, manually verified) decodes OGG via vorbisspi/jorbis and streams PCM through a `SourceDataLine` with gain fades. `GameIO.setMusic`/`setMusicLevel` are console no-ops; `SwingIO` owns the controller. `Game.publishHud` pushes the current room's track each turn; Settings carries a persisted `MusicLevel`. + +**Tech Stack:** Java 25, Maven, vorbisspi+jorbis (OGG), Jackson (YAML), JUnit 5 + AssertJ, Lombok. + +--- + +## File Structure + +**Create:** `io/MusicLevel.java`, `io/MusicBackend.java`, `io/MusicController.java`, `io/OggMusicBackend.java`; tests `MusicLevelTest`, `MusicControllerTest`; `docs/music.md`. +**Modify:** `io/GameIO.java` (seams), `io/SwingIO.java` (wire controller), `io/ConsoleIO.java` (none — inherits no-op), `game/Game.java` (publishHud), `App.java` (setMusic(null) + settings apply), `model/Room.java` + `loader/dto/RoomDto.java` + `loader/RoomFactory.java` (music field), `save/Settings.java` + `save/SettingsStore`-tests + `menu/SettingsMenu.java` + `test/.../menu/MenuTest.java`, `src/main/resources/world/rooms.yaml`, `pom.xml`, `.gitignore`, `docs/enhancement-ideas.md`. + +--- + +## Task 1: `MusicLevel` enum + +**Files:** Create `src/main/java/thb/jeanluc/adventure/io/MusicLevel.java`; Test `src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java`: +```java +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MusicLevelTest { + + @Test + void gainIncreasesWithLevel() { + assertThat(MusicLevel.LOW.gainDb()).isLessThan(MusicLevel.MEDIUM.gainDb()); + assertThat(MusicLevel.MEDIUM.gainDb()).isLessThan(MusicLevel.HIGH.gainDb()); + } + + @Test + void isOnOnlyWhenNotOff() { + assertThat(MusicLevel.OFF.isOn()).isFalse(); + assertThat(MusicLevel.LOW.isOn()).isTrue(); + assertThat(MusicLevel.HIGH.isOn()).isTrue(); + } + + @Test + void nextCyclesThroughAllAndWraps() { + assertThat(MusicLevel.OFF.next()).isEqualTo(MusicLevel.LOW); + assertThat(MusicLevel.LOW.next()).isEqualTo(MusicLevel.MEDIUM); + assertThat(MusicLevel.MEDIUM.next()).isEqualTo(MusicLevel.HIGH); + assertThat(MusicLevel.HIGH.next()).isEqualTo(MusicLevel.OFF); + } +} +``` + +- [ ] **Step 2: Run — expect FAIL** (`mvn -q test -Dtest=MusicLevelTest` → MusicLevel missing). + +- [ ] **Step 3: Create `MusicLevel`** + +`src/main/java/thb/jeanluc/adventure/io/MusicLevel.java`: +```java +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]; + } +} +``` + +- [ ] **Step 4: Run — PASS** (`mvn -q test -Dtest=MusicLevelTest`). +- [ ] **Step 5: Commit** — `git add` the two files; `git commit -m "feat(io): MusicLevel enum (Off/Low/Med/High)"`. + +--- + +## Task 2: `MusicBackend` + `MusicController` + +**Files:** Create `io/MusicBackend.java`, `io/MusicController.java`; Test `io/MusicControllerTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java`: +```java +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MusicControllerTest { + + /** Records backend calls for assertions. */ + private static final class FakeBackend implements MusicBackend { + final List calls = new ArrayList<>(); + public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); } + public void fadeOut() { calls.add("fadeOut"); } + public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); } + public void shutdown() { calls.add("shutdown"); } + } + + @Test + void firstRoomWithTrackPlaysAtDefaultLevel() { + FakeBackend b = new FakeBackend(); + new MusicController(b).room("a"); + assertThat(b.calls).containsExactly("fadeTo:a"); + } + + @Test + void sameTrackDoesNotRestart() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + b.calls.clear(); + c.room("a"); + assertThat(b.calls).isEmpty(); + } + + @Test + void differentTrackSwitches() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.room("b"); + assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b"); + } + + @Test + void nullOrBlankRoomFadesToSilence() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.room(null); + c.room(" "); + assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut"); + } + + @Test + void offStopsAndIgnoresRooms() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.level(MusicLevel.OFF); + b.calls.clear(); + c.room("b"); + assertThat(b.calls).isEmpty(); + } + + @Test + void levelChangeSetsGain() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); // default MEDIUM + c.level(MusicLevel.HIGH); + assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb()); + } + + @Test + void turningOffThenOnReplaysOnNextRoom() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.level(MusicLevel.OFF); // fadeOut + c.level(MusicLevel.MEDIUM); // setGain + b.calls.clear(); + c.room("a"); // current was reset → plays again + assertThat(b.calls).containsExactly("fadeTo:a"); + } +} +``` + +- [ ] **Step 2: Run — expect FAIL.** + +- [ ] **Step 3: Create `MusicBackend`** + +`src/main/java/thb/jeanluc/adventure/io/MusicBackend.java`: +```java +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(); +} +``` + +- [ ] **Step 4: Create `MusicController`** + +`src/main/java/thb/jeanluc/adventure/io/MusicController.java`: +```java +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) { + 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(); + } +} +``` + +- [ ] **Step 5: Run — PASS** (`mvn -q test -Dtest=MusicControllerTest`, 7 tests). +- [ ] **Step 6: Commit** — `git commit -m "feat(io): MusicController + MusicBackend (testable music logic)"`. + +--- + +## Task 3: `Room.music` field + loader + +**Files:** Modify `model/Room.java`, `loader/dto/RoomDto.java`, `loader/RoomFactory.java`, `src/main/resources/world/rooms.yaml`; Test `loader/RoomMusicTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java`: +```java +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoomMusicTest { + + @Test + void musicFieldIsCarriedOntoRoom() { + RoomDto dto = new RoomDto("r", "Room", "d", + Map.of(), List.of(), List.of(), null, null, null, "theme.ogg"); + Room room = RoomFactory.shellFromDto(dto); + assertThat(room.getMusic()).isEqualTo("theme.ogg"); + } + + @Test + void absentMusicIsNull() { + RoomDto dto = new RoomDto("r", "Room", "d", Map.of(), List.of(), List.of()); + Room room = RoomFactory.shellFromDto(dto); + assertThat(room.getMusic()).isNull(); + } +} +``` + +- [ ] **Step 2: Run — expect FAIL** (no `getMusic`, RoomDto has no music arg). + +- [ ] **Step 3: Add `music` to `Room`** + +In `src/main/java/thb/jeanluc/adventure/model/Room.java`, after the `dark` field (which uses `@Setter`), add: +```java + /** Optional GUI background-music track filename for this room; null = none. */ + @Setter + private String music; +``` +(`@Getter` on the class provides `getMusic()`; `@Setter` provides `setMusic(String)`, mirroring `dark`.) + +- [ ] **Step 4: Add `music` to `RoomDto`** + +In `src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java`, add `String music` as the final record component and update the backward-compatible constructor: +```java +public record RoomDto( + String id, + String name, + String description, + Map exits, + List items, + List npcs, + List exitLocks, + List descriptionStates, + Boolean dark, + String music +) { + /** Backward-compatible constructor without the optional state fields. */ + public RoomDto(String id, String name, String description, + Map exits, List items, List npcs) { + this(id, name, description, exits, items, npcs, null, null, null, null); + } +} +``` + +- [ ] **Step 5: Carry it in `RoomFactory`** + +In `src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java`, in `shellFromDto`, after `room.setDark(...)`: +```java + room.setMusic(dto.music()); +``` + +- [ ] **Step 6: Add demo `music:` fields** + +In `src/main/resources/world/rooms.yaml`, add a `music:` line to two rooms (files supplied externally; absent → silent). For the `kitchen` room add ` music: manor-theme.ogg` and for the `dungeon` room add ` music: dungeon-drone.ogg` (place the line among the room's other top-level keys, e.g. after its `description`). + +- [ ] **Step 7: Run — PASS** (`mvn -q test -Dtest=RoomMusicTest`), then `mvn -q clean test` (full suite green; WorldLoaderTest uses its own fixture base, unaffected by the production rooms.yaml `music:` additions). + +- [ ] **Step 8: Commit** — `git commit -m "feat(loader): per-room music field + demo rooms.yaml entries"`. + +--- + +## Task 4: `GameIO` music seams + game-loop / app wiring + +**Files:** Modify `io/GameIO.java`, `game/Game.java`, `App.java`; Test `io/MusicSeamDefaultTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java`: +```java +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MusicSeamDefaultTest { + + @Test + void defaultMusicSeamsAreSilentNoOps() { + TestIO io = new TestIO(); + io.setMusic("anything.ogg"); + io.setMusic(null); + io.setMusicLevel(MusicLevel.HIGH); + assertThat(io.outputs()).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run — expect FAIL** (no `setMusic`/`setMusicLevel`). + +- [ ] **Step 3: Add the default seams to `GameIO`** + +In `src/main/java/thb/jeanluc/adventure/io/GameIO.java`, add (MusicLevel is in the same package — no import needed): +```java + /** 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 + } +``` + +- [ ] **Step 4: Push the current track each turn in `Game`** + +In `src/main/java/thb/jeanluc/adventure/game/Game.java`, at the END of `publishHud()` (after `setQuests(...)`): +```java + ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic()); +``` + +- [ ] **Step 5: Silence music on return to menu in `App`** + +In `src/main/java/thb/jeanluc/adventure/App.java`, in `play(...)`, immediately after `game.run();`: +```java + io.setMusic(null); // fade music out when returning to the menu +``` + +- [ ] **Step 6: Run — PASS** (`mvn -q test -Dtest=MusicSeamDefaultTest`), then `mvn -q clean test` (full suite green; console/loop tests unaffected — seams are no-ops). + +- [ ] **Step 7: Commit** — `git commit -m "feat(io): GameIO music seams; push room track per turn, silence at menu"`. + +--- + +## Task 5: Settings `MusicLevel` + SettingsMenu refactor to `GameIO` + +**Files:** Modify `save/Settings.java`, `menu/SettingsMenu.java`, `App.java`, `test/.../menu/MenuTest.java`, `test/.../save/SettingsStoreTest.java` + +- [ ] **Step 1: Write the failing tests** + +Add to `src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java` (keep existing tests; add imports `thb.jeanluc.adventure.io.MusicLevel`): +```java + @Test + void musicLevelRoundTrips(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + store.save(new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.HIGH)); + assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.HIGH); + } + + @Test + void oldSettingsWithoutMusicDefaultToMedium(@TempDir Path dir) throws Exception { + Path file = dir.resolve("settings.json"); + Files.writeString(file, "{\"colorMode\":\"ON\",\"glyphMode\":\"ASCII\"}"); + assertThat(new SettingsStore(file).load().musicLevel()).isEqualTo(MusicLevel.MEDIUM); + } +``` +(Add `import java.nio.file.Files;` / `Path` / `@TempDir` if not already present — the existing tests already use `@TempDir`.) + +Update the music-toggle expectation in `src/test/java/thb/jeanluc/adventure/menu/MenuTest.java` — the existing `settingsMenuTogglesGlyphAndPersists` test calls `SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO())`. Change it to the new signature and account for the new "Music" row (options become Colour=1, Glyphs=2, Music=3, Back=4): +```java + @Test + void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + TestIO io = new TestIO().enqueue("2").enqueue("4"); // toggle Glyphs, then Back + Settings result = SettingsMenu.show(io, Settings.defaults(), store); + assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + } +``` +Add a music-cycle test: +```java + @Test + void settingsMenuCyclesMusicLevelAndPersists(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + TestIO io = new TestIO().enqueue("3").enqueue("4"); // cycle Music once, then Back + Settings result = SettingsMenu.show(io, Settings.defaults(), store); + assertThat(result.musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); // HIGH + assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); + } +``` +(Add `import thb.jeanluc.adventure.io.MusicLevel;` to MenuTest.) + +- [ ] **Step 2: Run — expect FAIL** (Settings has no musicLevel; show signature differs). + +- [ ] **Step 3: Extend `Settings`** + +Replace `src/main/java/thb/jeanluc/adventure/save/Settings.java`: +```java +package thb.jeanluc.adventure.save; + +import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.MusicLevel; + +/** Persisted user preferences (colour + glyph fidelity + music level). */ +public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) { + + public Settings { + if (musicLevel == null) { + musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field + } + } + + /** Backward-compatible constructor for callers that don't set music. */ + public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { + this(colorMode, glyphMode, MusicLevel.MEDIUM); + } + + public static Settings defaults() { + return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM); + } +} +``` + +- [ ] **Step 4: Refactor `SettingsMenu` to `GameIO` + add the Music row** + +Replace `src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java`: +```java +package thb.jeanluc.adventure.menu; + +import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.io.MusicLevel; +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; + }; + } +} +``` + +- [ ] **Step 5: Update `App` call sites** + +In `src/main/java/thb/jeanluc/adventure/App.java`, in `run()`: +- Remove the now-unused `ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;` line (if it becomes unused after the changes below). +- Change the startup apply to `SettingsMenu.apply(settings, io);`. +- Change the SETTINGS case to `case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);`. + +(If `ConsoleIO` import becomes unused in `App.java`, remove it. `io` is the `GameIO`.) + +- [ ] **Step 6: Run — PASS** (`mvn -q test -Dtest=SettingsStoreTest,MenuTest,ConsoleIORenderTest`), then `mvn -q clean test` (full suite green). + +- [ ] **Step 7: Commit** — `git commit -m "feat(settings): persisted Music level; SettingsMenu applies via GameIO"`. + +--- + +## Task 6: OGG dependency + `OggMusicBackend` + SwingIO wiring + +**Files:** Modify `pom.xml`, `io/SwingIO.java`, `.gitignore`; Create `io/OggMusicBackend.java`, `docs/music.md`. (No unit test — audio is manually verified per the no-GUI-test convention.) + +- [ ] **Step 1: Add the OGG dependencies to `pom.xml`** + +In the `` block: +```xml + + + com.googlecode.soundlibs + vorbisspi + 1.0.3.3 + + + com.googlecode.soundlibs + jorbis + 0.0.17.4 + +``` +Run `mvn -q -DskipTests compile` to confirm the dependencies resolve. + +- [ ] **Step 2: Create `OggMusicBackend`** + +`src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java`: +```java +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.nio.file.Files; +import java.nio.file.Path; + +/** + * Streams OGG Vorbis tracks from the external {@code music/} folder 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; + Path file = Path.of(MUSIC_DIR, track); + if (!Files.isReadable(file)) { + log.warn("Music file not found, staying silent: {}", file); + return; + } + stopping = false; + playThread = new Thread(() -> playLoop(file, 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(Path file, float gainDb) { + SourceDataLine l = null; + try { + while (!stopping) { + try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) { + 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 {}: {}", file, 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(); + } + } +} +``` +If any call doesn't compile against the installed vorbisspi/`javax.sound` API, adjust minimally to compile while preserving the behaviour (decode OGG→PCM, stream with looping, MASTER_GAIN fades, graceful on missing file). This class is manually verified. + +- [ ] **Step 3: Wire the controller into `SwingIO`** + +In `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`: +- Add a field (with the other final fields): `private final MusicController music = new MusicController(new OggMusicBackend());` +- Add the overrides (near `setMap`): +```java + @Override + public void setMusic(String track) { + music.room(track); + } + + @Override + public void setMusicLevel(MusicLevel level) { + music.level(level); + } +``` +(`MusicController`/`MusicLevel`/`OggMusicBackend` are in the same `io` package — no imports needed.) + +- [ ] **Step 4: Ignore the music folder** + +Append to `.gitignore`: +``` +# External background-music files (not bundled) +music/ +``` + +- [ ] **Step 5: Document the music folder** + +Create `docs/music.md`: +```markdown +# Background music (GUI) + +Background music plays only in the Swing GUI. It is **data-driven and external**: + +- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game. + This folder is git-ignored and never bundled in the JAR. +- 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 cross-fades to it; + 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 files 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. +``` + +- [ ] **Step 6: Build + full suite + manual check** + +Run: `mvn -q clean test` → BUILD SUCCESS (all prior + new tests; the audio backend has no unit test). +Manual (optional, needs audio): drop an `.ogg` as `music/manor-theme.ogg`, launch the GUI, New Game → the kitchen should play it; move to the dungeon → it cross-fades to `dungeon-drone.ogg` (or silence if absent); Settings → Music cycles volume/off. + +- [ ] **Step 7: Commit** — `git add` pom.xml, OggMusicBackend, SwingIO, .gitignore, docs/music.md; `git commit -m "feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs"`. + +--- + +## Task 7: Backlog doc + +**Files:** Modify `docs/enhancement-ideas.md` + +- [ ] **Step 1:** Under section **8. Musik (GUI)**, add a `> ✅ umgesetzt (Branch feature/music)` note mirroring the existing ✅ style: OGG Vorbis (vorbisspi/jorbis), per-room `music:` in rooms.yaml, external gitignored `music/` folder, idempotent track switch with ~400 ms fades, fade-to-silence in unscored rooms, Music level Off/Low/Med/High in Settings (default Medium), console silent; testable `MusicController` over an isolated `OggMusicBackend`. + +- [ ] **Step 2: Commit** — `git commit -m "docs: mark GUI background music implemented"`. + +--- + +## Final Verification + +- [ ] `mvn -q clean test` — all tests pass (211 prior + MusicLevel/MusicController/RoomMusic/MusicSeam + extended Settings/Menu suites). +- [ ] OGG dependencies resolve; project compiles. +- [ ] Manual GUI smoke (optional, with an `.ogg`): room music plays/fades; Settings Music Off silences it; console run is silent. +- [ ] Load a saved game from before this feature → defaults to Medium, no crash. +- [ ] `git status` clean (no `music/` tracked). +``` From d0e290c723c15da565d458485128b8aed86ece30 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 22:47:40 +0200 Subject: [PATCH 03/13] feat(io): MusicLevel enum (Off/Low/Med/High) Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/io/MusicLevel.java | 26 +++++++++++++++++ .../jeanluc/adventure/io/MusicLevelTest.java | 29 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicLevel.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicLevel.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicLevel.java new file mode 100644 index 0000000..79d2091 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicLevel.java @@ -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]; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java new file mode 100644 index 0000000..237bab8 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java @@ -0,0 +1,29 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MusicLevelTest { + + @Test + void gainIncreasesWithLevel() { + assertThat(MusicLevel.LOW.gainDb()).isLessThan(MusicLevel.MEDIUM.gainDb()); + assertThat(MusicLevel.MEDIUM.gainDb()).isLessThan(MusicLevel.HIGH.gainDb()); + } + + @Test + void isOnOnlyWhenNotOff() { + assertThat(MusicLevel.OFF.isOn()).isFalse(); + assertThat(MusicLevel.LOW.isOn()).isTrue(); + assertThat(MusicLevel.HIGH.isOn()).isTrue(); + } + + @Test + void nextCyclesThroughAllAndWraps() { + assertThat(MusicLevel.OFF.next()).isEqualTo(MusicLevel.LOW); + assertThat(MusicLevel.LOW.next()).isEqualTo(MusicLevel.MEDIUM); + assertThat(MusicLevel.MEDIUM.next()).isEqualTo(MusicLevel.HIGH); + assertThat(MusicLevel.HIGH.next()).isEqualTo(MusicLevel.OFF); + } +} From a1fd56b3891cc25d658daec2e6ac44ed1ff31127 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 22:49:20 +0200 Subject: [PATCH 04/13] test(io): assert MEDIUM.isOn() too --- .../src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java index 237bab8..e465584 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java @@ -16,6 +16,7 @@ class MusicLevelTest { void isOnOnlyWhenNotOff() { assertThat(MusicLevel.OFF.isOn()).isFalse(); assertThat(MusicLevel.LOW.isOn()).isTrue(); + assertThat(MusicLevel.MEDIUM.isOn()).isTrue(); assertThat(MusicLevel.HIGH.isOn()).isTrue(); } From 7f64c9f9e333bc4994f20b91058e59ec1dc9b403 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 22:50:58 +0200 Subject: [PATCH 05/13] feat(io): MusicController + MusicBackend (testable music logic) Co-Authored-By: Claude Sonnet 4.6 --- .../jeanluc/adventure/io/MusicBackend.java | 17 ++++ .../jeanluc/adventure/io/MusicController.java | 52 +++++++++++ .../adventure/io/MusicControllerTest.java | 87 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicBackend.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicBackend.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicBackend.java new file mode 100644 index 0000000..2958115 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicBackend.java @@ -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(); +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java new file mode 100644 index 0000000..ee572b0 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java @@ -0,0 +1,52 @@ +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) { + 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(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java new file mode 100644 index 0000000..0b931fc --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java @@ -0,0 +1,87 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MusicControllerTest { + + /** Records backend calls for assertions. */ + private static final class FakeBackend implements MusicBackend { + final List calls = new ArrayList<>(); + public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); } + public void fadeOut() { calls.add("fadeOut"); } + public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); } + public void shutdown() { calls.add("shutdown"); } + } + + @Test + void firstRoomWithTrackPlaysAtDefaultLevel() { + FakeBackend b = new FakeBackend(); + new MusicController(b).room("a"); + assertThat(b.calls).containsExactly("fadeTo:a"); + } + + @Test + void sameTrackDoesNotRestart() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + b.calls.clear(); + c.room("a"); + assertThat(b.calls).isEmpty(); + } + + @Test + void differentTrackSwitches() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.room("b"); + assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b"); + } + + @Test + void nullOrBlankRoomFadesToSilence() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.room(null); + c.room(" "); + assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut"); + } + + @Test + void offStopsAndIgnoresRooms() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.level(MusicLevel.OFF); + b.calls.clear(); + c.room("b"); + assertThat(b.calls).isEmpty(); + } + + @Test + void levelChangeSetsGain() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); // default MEDIUM + c.level(MusicLevel.HIGH); + assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb()); + } + + @Test + void turningOffThenOnReplaysOnNextRoom() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + c.level(MusicLevel.OFF); // fadeOut + c.level(MusicLevel.MEDIUM); // setGain + b.calls.clear(); + c.room("a"); // current was reset → plays again + assertThat(b.calls).containsExactly("fadeTo:a"); + } +} From a38bb6e4f3f4796db6e907905ba63543fd164cbb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 22:55:27 +0200 Subject: [PATCH 06/13] fix(io): idempotent MusicController.level; assert gain forwarding + shutdown Co-Authored-By: Claude Sonnet 4.6 --- .../jeanluc/adventure/io/MusicController.java | 3 +++ .../adventure/io/MusicControllerTest.java | 21 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java index ee572b0..3a4b1f4 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MusicController.java @@ -37,6 +37,9 @@ public final class MusicController { /** 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(); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java index 0b931fc..9238b31 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java @@ -12,7 +12,8 @@ class MusicControllerTest { /** Records backend calls for assertions. */ private static final class FakeBackend implements MusicBackend { final List calls = new ArrayList<>(); - public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); } + float lastGain = Float.NaN; + public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); lastGain = gainDb; } public void fadeOut() { calls.add("fadeOut"); } public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); } public void shutdown() { calls.add("shutdown"); } @@ -84,4 +85,22 @@ class MusicControllerTest { c.room("a"); // current was reset → plays again assertThat(b.calls).containsExactly("fadeTo:a"); } + + @Test + void forwardsTheCurrentLevelGain() { + FakeBackend b = new FakeBackend(); + MusicController c = new MusicController(b); + c.room("a"); + assertThat(b.lastGain).isEqualTo(MusicLevel.MEDIUM.gainDb()); + c.level(MusicLevel.HIGH); + c.room("b"); + assertThat(b.lastGain).isEqualTo(MusicLevel.HIGH.gainDb()); + } + + @Test + void shutdownDelegatesToBackend() { + FakeBackend b = new FakeBackend(); + new MusicController(b).shutdown(); + assertThat(b.calls).containsExactly("shutdown"); + } } From 35ad4e6f1a334d5efc99ce78cacecf8dc61610c4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 22:58:13 +0200 Subject: [PATCH 07/13] feat(loader): per-room music field + demo rooms.yaml entries Co-Authored-By: Claude Sonnet 4.6 --- .../jeanluc/adventure/loader/RoomFactory.java | 1 + .../jeanluc/adventure/loader/dto/RoomDto.java | 6 ++-- .../thb/jeanluc/adventure/model/Room.java | 4 +++ .../src/main/resources/world/rooms.yaml | 2 ++ .../adventure/loader/RoomMusicTest.java | 28 +++++++++++++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java index 5742eb9..38d3d24 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java @@ -21,6 +21,7 @@ public final class RoomFactory { public static Room shellFromDto(RoomDto dto) { Room room = new Room(dto.id(), dto.name(), dto.description()); room.setDark(Boolean.TRUE.equals(dto.dark())); + room.setMusic(dto.music()); return room; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java index e069351..53a351c 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java @@ -15,6 +15,7 @@ import java.util.Map; * @param exitLocks optional condition-gates per exit direction * @param descriptionStates optional condition-gated description variants * @param dark whether this room is dark; nullable + * @param music optional GUI background-music track filename; null = none */ public record RoomDto( String id, @@ -25,11 +26,12 @@ public record RoomDto( List npcs, List exitLocks, List descriptionStates, - Boolean dark + Boolean dark, + String music ) { /** Backward-compatible constructor without the optional state fields. */ public RoomDto(String id, String name, String description, Map exits, List items, List npcs) { - this(id, name, description, exits, items, npcs, null, null, null); + this(id, name, description, exits, items, npcs, null, null, null, null); } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java index dcd9e8c..4cc61fa 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java @@ -55,6 +55,10 @@ public class Room { @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 * create the reverse connection — callers must set that up diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml index c6d06e0..9eb5078 100644 --- a/Semesterprojekt/src/main/resources/world/rooms.yaml +++ b/Semesterprojekt/src/main/resources/world/rooms.yaml @@ -3,6 +3,7 @@ description: | A dusty kitchen. Cobwebs hang from the rafters and a letter lies on the table. + music: manor-theme.ogg exits: north: hallway east: cellar @@ -55,6 +56,7 @@ name: Dungeon description: | A cramped stone room, black as pitch. A rusty generator squats in the corner. + music: dungeon-drone.ogg dark: true exits: north: kitchen diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java new file mode 100644 index 0000000..7bd8977 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java @@ -0,0 +1,28 @@ +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoomMusicTest { + + @Test + void musicFieldIsCarriedOntoRoom() { + RoomDto dto = new RoomDto("r", "Room", "d", + Map.of(), List.of(), List.of(), null, null, null, "theme.ogg"); + Room room = RoomFactory.shellFromDto(dto); + assertThat(room.getMusic()).isEqualTo("theme.ogg"); + } + + @Test + void absentMusicIsNull() { + RoomDto dto = new RoomDto("r", "Room", "d", Map.of(), List.of(), List.of()); + Room room = RoomFactory.shellFromDto(dto); + assertThat(room.getMusic()).isNull(); + } +} From d0d8648bd018cb5477a27b62c820ae6469d8acc4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 23:02:44 +0200 Subject: [PATCH 08/13] feat(io): GameIO music seams; push room track per turn, silence at menu Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/thb/jeanluc/adventure/App.java | 1 + .../java/thb/jeanluc/adventure/game/Game.java | 1 + .../java/thb/jeanluc/adventure/io/GameIO.java | 10 ++++++++++ .../adventure/io/MusicSeamDefaultTest.java | 17 +++++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index 70daa3d..ae19bfc 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -143,5 +143,6 @@ public final class App { new LookCommand().execute(ctx, List.of()); game.run(); + io.setMusic(null); // fade music out when returning to the menu } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index ed191de..0205c91 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -109,6 +109,7 @@ public class Game { ctx.getPlayer().getCurrentRoom()); ctx.getIo().setMap(map); ctx.getIo().setQuests(QuestEngine.viewOf(ctx)); + ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic()); } /** diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java index ccbdf88..b1a22a1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -65,6 +65,16 @@ public interface GameIO { // 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)); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java new file mode 100644 index 0000000..0b4a402 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java @@ -0,0 +1,17 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MusicSeamDefaultTest { + + @Test + void defaultMusicSeamsAreSilentNoOps() { + TestIO io = new TestIO(); + io.setMusic("anything.ogg"); + io.setMusic(null); + io.setMusicLevel(MusicLevel.HIGH); + assertThat(io.outputs()).isEmpty(); + } +} From 2bad875df14873faa00e0d9df5073905586759f4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 23:08:27 +0200 Subject: [PATCH 09/13] feat(settings): persisted Music level; SettingsMenu applies via GameIO Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/thb/jeanluc/adventure/App.java | 5 ++- .../jeanluc/adventure/menu/SettingsMenu.java | 35 +++++++++---------- .../thb/jeanluc/adventure/save/Settings.java | 18 ++++++++-- .../thb/jeanluc/adventure/menu/MenuTest.java | 16 ++++++--- .../adventure/save/SettingsStoreTest.java | 15 ++++++++ 5 files changed, 60 insertions(+), 29 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index ae19bfc..da51382 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -67,8 +67,7 @@ public final class App { SaveService saves = new SaveService(); SettingsStore settingsStore = new SettingsStore(); Settings settings = settingsStore.load(); - ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null; - SettingsMenu.apply(settings, consoleIo); + SettingsMenu.apply(settings, io); boolean running = true; while (running) { @@ -84,7 +83,7 @@ public final class App { } } } - case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo); + case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore); case QUIT -> running = false; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java index 3661505..ecd6299 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java @@ -2,50 +2,47 @@ package thb.jeanluc.adventure.menu; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.io.MusicLevel; import thb.jeanluc.adventure.save.Settings; import thb.jeanluc.adventure.save.SettingsStore; import java.util.List; -/** Minimal settings screen: toggle colour and glyph mode; persists + applies. */ +/** Settings screen: cycle colour, glyph, and music level; persists + applies. */ public final class SettingsMenu { private SettingsMenu() { } - /** - * Loops the settings screen until the player picks "Back", persisting and - * applying each change. Returns the final settings. - * - * @param io active IO - * @param current starting settings - * @param store where to persist - * @param consoleIo the console IO to apply changes to live, or null if not console - */ - public static Settings show(GameIO io, Settings current, SettingsStore store, ConsoleIO consoleIo) { + /** 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 = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel()); } else if (i == 1) { - s = new Settings(s.colorMode(), nextGlyph(s.glyphMode())); + 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, consoleIo); + apply(s, io); } } - /** Applies colour/glyph settings to the console IO (no-op if null). */ - public static void apply(Settings s, ConsoleIO consoleIo) { - if (consoleIo != null) { - consoleIo.setColorMode(s.colorMode()); - consoleIo.setGlyphMode(s.glyphMode()); + /** 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()); } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java index cebac84..a46411b 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java @@ -1,11 +1,23 @@ package thb.jeanluc.adventure.save; import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.MusicLevel; -/** Persisted user preferences (minimal: colour + glyph fidelity). */ -public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { +/** Persisted user preferences (colour + glyph fidelity + music level). */ +public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) { + + public Settings { + if (musicLevel == null) { + musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field + } + } + + /** Backward-compatible constructor for callers that don't set music. */ + public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { + this(colorMode, glyphMode, MusicLevel.MEDIUM); + } public static Settings defaults() { - return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE); + return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM); } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java index 3088e3b..69af6c6 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java @@ -10,6 +10,7 @@ import thb.jeanluc.adventure.save.SaveSlotInfo; import thb.jeanluc.adventure.save.Settings; import thb.jeanluc.adventure.save.SettingsStore; import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.MusicLevel; import java.nio.file.Path; @@ -64,11 +65,18 @@ class MenuTest { @Test void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) { SettingsStore store = new SettingsStore(dir.resolve("settings.json")); - // choose "Glyph mode" (assume option 2) → its toggle, then "Back" (last) - TestIO io = new TestIO().enqueue("2").enqueue("3"); - Settings result = SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO()); - // UNICODE default toggles to ASCII; persisted + TestIO io = new TestIO().enqueue("2").enqueue("4"); // toggle Glyphs, then Back + Settings result = SettingsMenu.show(io, Settings.defaults(), store); assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); } + + @Test + void settingsMenuCyclesMusicLevelAndPersists(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + TestIO io = new TestIO().enqueue("3").enqueue("4"); // cycle Music once, then Back + Settings result = SettingsMenu.show(io, Settings.defaults(), store); + assertThat(result.musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); // HIGH + assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java index 9cca9fd..ccf26ab 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java @@ -3,6 +3,7 @@ package thb.jeanluc.adventure.save; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.MusicLevel; import java.io.IOException; import java.nio.file.Files; @@ -34,4 +35,18 @@ class SettingsStoreTest { Settings loaded = new SettingsStore(file).load(); assertThat(loaded).isEqualTo(Settings.defaults()); } + + @Test + void musicLevelRoundTrips(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + store.save(new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.HIGH)); + assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.HIGH); + } + + @Test + void oldSettingsWithoutMusicDefaultToMedium(@TempDir Path dir) throws Exception { + Path file = dir.resolve("settings.json"); + Files.writeString(file, "{\"colorMode\":\"ON\",\"glyphMode\":\"ASCII\"}"); + assertThat(new SettingsStore(file).load().musicLevel()).isEqualTo(MusicLevel.MEDIUM); + } } From bcd2ae9ddd3cf022c1b967c0846aeb80cbe03861 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 23:17:26 +0200 Subject: [PATCH 10/13] 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 --- .../main/java/thb/jeanluc/adventure/menu/SettingsMenu.java | 1 - .../src/main/java/thb/jeanluc/adventure/save/Settings.java | 6 ++++++ .../java/thb/jeanluc/adventure/save/SettingsStoreTest.java | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java index ecd6299..7acf8fc 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java @@ -2,7 +2,6 @@ package thb.jeanluc.adventure.menu; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; -import thb.jeanluc.adventure.io.MusicLevel; import thb.jeanluc.adventure.save.Settings; import thb.jeanluc.adventure.save.SettingsStore; diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java index a46411b..cc30359 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java @@ -7,6 +7,12 @@ import thb.jeanluc.adventure.io.MusicLevel; public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) { public Settings { + if (colorMode == null) { + colorMode = ConsoleIO.ColorMode.AUTO; + } + if (glyphMode == null) { + glyphMode = ConsoleIO.GlyphMode.UNICODE; + } if (musicLevel == null) { musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java index ccf26ab..5524db4 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java @@ -20,6 +20,7 @@ class SettingsStoreTest { Settings loaded = store.load(); assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON); assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + assertThat(loaded.musicLevel()).isEqualTo(MusicLevel.MEDIUM); } @Test From 28915536291e61a6c9a11824072a82a309e8291b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 23:20:14 +0200 Subject: [PATCH 11/13] feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs Co-Authored-By: Claude Sonnet 4.6 --- Semesterprojekt/.gitignore | 3 + Semesterprojekt/docs/music.md | 16 ++ Semesterprojekt/pom.xml | 12 ++ .../jeanluc/adventure/io/OggMusicBackend.java | 155 ++++++++++++++++++ .../thb/jeanluc/adventure/io/SwingIO.java | 11 ++ 5 files changed, 197 insertions(+) create mode 100644 Semesterprojekt/docs/music.md create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java diff --git a/Semesterprojekt/.gitignore b/Semesterprojekt/.gitignore index 90926aa..b39385b 100644 --- a/Semesterprojekt/.gitignore +++ b/Semesterprojekt/.gitignore @@ -43,3 +43,6 @@ build/ # Local save games and settings saves/ + +# External background-music files (not bundled) +music/ diff --git a/Semesterprojekt/docs/music.md b/Semesterprojekt/docs/music.md new file mode 100644 index 0000000..6d91ba1 --- /dev/null +++ b/Semesterprojekt/docs/music.md @@ -0,0 +1,16 @@ +# Background music (GUI) + +Background music plays only in the Swing GUI. It is **data-driven and external**: + +- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game. + This folder is git-ignored and never bundled in the JAR. +- 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 cross-fades to it; + 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 files 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. diff --git a/Semesterprojekt/pom.xml b/Semesterprojekt/pom.xml index 4e2dfd8..0fba00d 100644 --- a/Semesterprojekt/pom.xml +++ b/Semesterprojekt/pom.xml @@ -49,6 +49,18 @@ ${logback.version} + + + com.googlecode.soundlibs + vorbisspi + 1.0.3.3 + + + com.googlecode.soundlibs + jorbis + 0.0.17.4 + + org.junit.jupiter diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java new file mode 100644 index 0000000..72bee18 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java @@ -0,0 +1,155 @@ +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.nio.file.Files; +import java.nio.file.Path; + +/** + * Streams OGG Vorbis tracks from the external {@code music/} folder 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; + Path file = Path.of(MUSIC_DIR, track); + if (!Files.isReadable(file)) { + log.warn("Music file not found, staying silent: {}", file); + return; + } + stopping = false; + playThread = new Thread(() -> playLoop(file, 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(Path file, float gainDb) { + SourceDataLine l = null; + try { + while (!stopping) { + try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) { + 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 {}: {}", file, 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(); + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index 8501488..77c6004 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -74,6 +74,7 @@ public class SwingIO implements GameIO { private static final int SIDE_MIN = 180; private static final int SIDE_MAX = 460; + private final MusicController music = new MusicController(new OggMusicBackend()); private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); private final JFrame frame; private final JTextPane output; @@ -333,6 +334,16 @@ public class SwingIO implements GameIO { SwingUtilities.invokeLater(() -> hud.setText(txt)); } + @Override + public void setMusic(String track) { + music.room(track); + } + + @Override + public void setMusicLevel(MusicLevel level) { + music.level(level); + } + @Override public void setMap(MapView view) { map.show(view); From 08c92a57de876fe5a4facb746315122347394fdc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 23:25:11 +0200 Subject: [PATCH 12/13] docs: mark GUI background music implemented --- Semesterprojekt/docs/enhancement-ideas.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index 71a93e7..bdb1f85 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -180,6 +180,14 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ### 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**. - **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld (z.B. `music: `), Dateien in *einem* Ordner. From b5d603a7b1d76338c422022860bb08a0c7798f74 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 23:29:11 +0200 Subject: [PATCH 13/13] docs: clarify music transition is a sequential fade, not cross-fade --- Semesterprojekt/docs/music.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Semesterprojekt/docs/music.md b/Semesterprojekt/docs/music.md index 6d91ba1..e0d0686 100644 --- a/Semesterprojekt/docs/music.md +++ b/Semesterprojekt/docs/music.md @@ -5,7 +5,8 @@ Background music plays only in the Swing GUI. It is **data-driven and external** - Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game. This folder is git-ignored and never bundled in the JAR. - 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 cross-fades to it; + `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,