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>
11 KiB
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 eineSourceDataLine. - 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;OFFdeaktiviert 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 zummusic/-Ordner.
2. Scope
In Scope:
MusicLevel-Enum (OFF/LOW/MEDIUM/HIGH +gainDb()).MusicController(frontend-agnostisch, testbar): Track-/Level-Logik, delegiert an einMusicBackend.MusicBackend-Interface (Roh-Operationen) +OggMusicBackend(Audio, manuell).GameIO.setMusic(String)+setMusicLevel(MusicLevel)(Default No-Op);SwingIO-Override mitMusicController(new OggMusicBackend()).Game.publishHudruftsetMusic(currentRoom.getMusic()); nachgame.run()zurück ins Menü →setMusic(null).Room.music+RoomDto.music+RoomFactory; optionalesmusic:inrooms.yaml.Settings.musicLevel+SettingsStore(fehlt → MEDIUM);SettingsMenu-Zeile „Music"; Anwendung viaio.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)
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)
/** 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
}
/** 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 perMASTER_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, aufgainDbeinblenden.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
/** 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: FeldMusicController 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ätzlichctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());(idempotent → Wechsel nur bei echtem Raumwechsel).App.play(...): nachgame.run()→io.setMusic(null);(Musik beim Rückkehr ins Menü ausblenden).
7. Room-Feld + Loader
Room: optionalesprivate final String music;(+ Getter). Konstruktor/Factory erweitern; null = kein Track.RoomDto: optionalesmusic-Feld (nullable).RoomFactory.shellFromDto:musicdurchreichen.rooms.yaml: optionalesmusic: <datei>.oggpro 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) bekommtMusicLevel musicLevel;defaults()→ MEDIUM.SettingsStore: Jackson serialisiertMusicLevelals Namen; alter Spielstand ohne Feld →null→ auf MEDIUM normalisieren (imload()oder via Defaults).SettingsMenu: dritte Zeile „Music: "; Auswahl zykltlevel.next(); nach jeder Änderung speichern +applySettings.- Refactor:
SettingsMenu.apply/shownehmen stattConsoleIOdasGameIOentgegen.apply(Settings, GameIO io):io.setMusicLevel(s.musicLevel());undif (io instanceof ConsoleIO c) { c.setColorMode(...); c.setGlyphMode(...); }. Aufrufer (App,MenuTest) anpassen. App.run: beim StartSettingsMenu.apply(settings, io)(setzt auch Music-Level).
9. Abhängigkeit + Dateien
pom.xml(<dependencies>):com.googlecode.soundlibs:vorbisspi:1.0.3.3com.googlecode.soundlibs:jorbis:0.0.17.4(vorbisspi ziehttritonus-sharetransitiv; bei Bedarf explizit ergänzen.)
.gitignore:music/.- Kurze Doku (z.B. in
docs/oder README-Abschnitt): „Lege.ogg-Dateien inmusic/ab; Räume referenzieren sie übermusic:inrooms.yaml. Ohne Dateien bleibt die GUI stumm."
10. Tests
MusicControllerTest(Fake-MusicBackend, das Aufrufe protokolliert):room("a")→fadeTo("a", MEDIUM.gainDb()); erneutroom("a")→ kein Aufruf.room("b")→fadeTo("b", ...);room(null)→fadeOut().level(OFF)→fadeOut()+ danach ignoriertroom("a")(still).level(HIGH)(von MEDIUM) →setGainDb(HIGH); danachroom("a")spielt.- leerer/blank Track == null.
MusicLevelTest:gainDb()-Mapping;next()-Zyklus;isOn().SettingsStoreTest(erweitern): musicLevel round-trip; fehlendes Feld → MEDIUM.SettingsMenu/MenuTest(anpassen anGameIO-Signatur): Music-Zeile zyklt und persistiert.- Room-
music-Laden: Loader-/Factory-Test, dassmusic:aufRoom.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 testbarenMusicController, Audio isoliert imOggMusicBackend. 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 SpielendesetMusic(null)→ Stille im Menü. - Settings-Refactor:
SettingsMenuvonConsoleIOaufGameIOumstellen ist die einzige Änderung an bestehendem, bereits reviewtem Code — minimal und nötig, damit der Music-Level beide Frontends erreicht (Konsole No-Op).