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>
This commit is contained in:
3
Semesterprojekt/.gitignore
vendored
3
Semesterprojekt/.gitignore
vendored
@@ -43,3 +43,6 @@ build/
|
||||
|
||||
# Local save games and settings
|
||||
saves/
|
||||
|
||||
# External background-music files (not bundled)
|
||||
music/
|
||||
|
||||
@@ -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: <dateiname>`), Dateien in *einem* Ordner.
|
||||
|
||||
17
Semesterprojekt/docs/music.md
Normal file
17
Semesterprojekt/docs/music.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 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 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 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.
|
||||
860
Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md
Normal file
860
Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md
Normal file
@@ -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<String> 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<String, String> exits,
|
||||
List<String> items,
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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 `<dependencies>` block:
|
||||
```xml
|
||||
<!-- 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>
|
||||
```
|
||||
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).
|
||||
```
|
||||
@@ -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/<track>` ö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/<track>` = `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: <datei>.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: <level>"; 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` (`<dependencies>`):
|
||||
- `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).
|
||||
@@ -49,6 +49,18 @@
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OGG Vorbis decoding (GUI background music) -->
|
||||
<dependency>
|
||||
<groupId>com.googlecode.soundlibs</groupId>
|
||||
<artifactId>vorbisspi</artifactId>
|
||||
<version>1.0.3.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.googlecode.soundlibs</groupId>
|
||||
<artifactId>jorbis</artifactId>
|
||||
<version>0.0.17.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Tests -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -143,5 +142,6 @@ public final class App {
|
||||
new LookCommand().execute(ctx, List.of());
|
||||
|
||||
game.run();
|
||||
io.setMusic(null); // fade music out when returning to the menu
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
/** Raw audio operations — the only part that touches sound hardware. */
|
||||
public interface MusicBackend {
|
||||
|
||||
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
|
||||
void fadeTo(String track, float gainDb);
|
||||
|
||||
/** Fade the current track out and stop. */
|
||||
void fadeOut();
|
||||
|
||||
/** Set the playback volume immediately. */
|
||||
void setGainDb(float gainDb);
|
||||
|
||||
/** Stop playback and release resources. */
|
||||
void shutdown();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
|
||||
* the same track), fades to silence on a null/blank track, and honours the
|
||||
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
|
||||
* {@link MusicBackend}.
|
||||
*/
|
||||
public final class MusicController {
|
||||
|
||||
private final MusicBackend backend;
|
||||
private MusicLevel level = MusicLevel.MEDIUM;
|
||||
private String current; // currently selected track, or null = silence
|
||||
|
||||
public MusicController(MusicBackend backend) {
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
/** Called per turn with the current room's music field (may be null/blank). */
|
||||
public void room(String track) {
|
||||
if (!level.isOn()) {
|
||||
return;
|
||||
}
|
||||
String next = (track == null || track.isBlank()) ? null : track;
|
||||
if (Objects.equals(next, current)) {
|
||||
return;
|
||||
}
|
||||
current = next;
|
||||
if (next == null) {
|
||||
backend.fadeOut();
|
||||
} else {
|
||||
backend.fadeTo(next, level.gainDb());
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a new music level (volume / off). */
|
||||
public void level(MusicLevel newLevel) {
|
||||
if (newLevel == this.level) {
|
||||
return;
|
||||
}
|
||||
this.level = newLevel;
|
||||
if (!newLevel.isOn()) {
|
||||
backend.fadeOut();
|
||||
current = null; // turning back on replays on the next room() call
|
||||
} else {
|
||||
backend.setGainDb(newLevel.gainDb());
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
backend.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
/** Background-music volume level, cycled in the Settings menu. */
|
||||
public enum MusicLevel {
|
||||
OFF, LOW, MEDIUM, HIGH;
|
||||
|
||||
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
|
||||
public float gainDb() {
|
||||
return switch (this) {
|
||||
case OFF -> Float.NEGATIVE_INFINITY;
|
||||
case LOW -> -20f;
|
||||
case MEDIUM -> -10f;
|
||||
case HIGH -> 0f;
|
||||
};
|
||||
}
|
||||
|
||||
/** @return true when music should play. */
|
||||
public boolean isOn() {
|
||||
return this != OFF;
|
||||
}
|
||||
|
||||
/** Next level in the cycle (wraps HIGH → OFF). */
|
||||
public MusicLevel next() {
|
||||
return values()[(ordinal() + 1) % values().length];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> npcs,
|
||||
List<ExitLockDto> exitLocks,
|
||||
List<DescriptionStateDto> descriptionStates,
|
||||
Boolean dark
|
||||
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);
|
||||
this(id, name, description, exits, items, npcs, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,45 +7,41 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
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 (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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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<String> calls = new ArrayList<>();
|
||||
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"); }
|
||||
}
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
@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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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.MEDIUM.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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -19,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
|
||||
@@ -34,4 +36,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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user