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).