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) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||||
Reference in New Issue
Block a user