docs: add Sprint 19 AudioManager design spec

This commit is contained in:
Steven Wroblewski
2026-05-10 00:39:40 +02:00
parent df6df900c6
commit 5107790746
@@ -0,0 +1,192 @@
# Sprint 19 — AudioManager & SFX Design Spec
## Goal
Implement an `AudioManager` autoload that provides background music (per floor, with cross-fade) and SFX feedback for all player interactions. Real audio assets sourced from freesound.org (CC0/CC-BY). All 7 SFX events and 4 music tracks wired up by end of sprint.
## Architecture
### AudioManager (Autoload)
`scripts/autoload/AudioManager.gd` — Node with three child AudioStreamPlayers:
```
AudioManager (Node)
├── MusicA (AudioStreamPlayer) # Cross-fade ping-pong player A
├── MusicB (AudioStreamPlayer) # Cross-fade ping-pong player B
└── SfxPlayer (AudioStreamPlayer) # Single SFX player (no polyphony this sprint)
```
**Startup:** reads `GameState.music_volume` and `GameState.sfx_volume`, applies to players. Connects to `GameState.state_changed`.
**`_on_game_state_changed()`:** reads `GameState.current_room`, calls `_derive_floor_from_room()`, compares to `_current_floor`. If floor changed → `play_floor_music(new_floor)`.
**`play_floor_music(floor: int) -> void`:** public API. If `floor == _current_floor` → no-op. Otherwise: inactive player loads new stream, tweens volume from 0 → `GameState.music_volume` over 0.8 s; active player tweens volume → 0 over 0.8 s, then stops. Players swap active/inactive roles.
**`play_sfx(event: String) -> void`:** looks up event key in `_SFX_MAP: Dictionary`, loads stream, plays on `SfxPlayer`. Volume = `GameState.sfx_volume`.
**`set_music_volume(vol: float) -> void`** and **`set_sfx_volume(vol: float) -> void`:** update `GameState` and apply immediately to active players.
**`get_current_floor() -> int`:** returns `_current_floor` (-1 if none set).
### Internal State
```gdscript
var _current_floor: int = -1
var _active_player: AudioStreamPlayer # points to MusicA or MusicB
```
### Floor Derivation
`_derive_floor_from_room(room: String) -> int` — pure function, no side effects:
| Room strings | Floor |
|---|---|
| `"reception"`, `"giftshop"`, `"restaurant"`, `"emergency"` | `0` |
| `"xray"`, `"pharmacy"`, `"lab"`, `"patient_rooms"` | `1` |
| `"ultrasound"`, `"delivery_room"`, `"nursery"` | `2` |
| `"garden_party"` | `3` |
| anything else | `-1` |
## SFX Event Mapping
| Event key | Trigger location | When |
|---|---|---|
| `chest_tap` | `room_chest.gd` | Chest tapped, before spawn |
| `item_spawn` | `room_chest.gd` | Item tween starts (fly-out) |
| `item_drag_start` | `holdable_item.gd` | `_on_drag_picked_up` |
| `item_drop_hand` | `holdable_item.gd` | Item attached to hand slot |
| `item_drop_outfit` | `outfit_item.gd` | Outfit applied to character |
| `item_return_chest` | `holdable_item.gd` | `_try_return_to_chest` succeeds |
| `item_drop_floor` | `holdable_item.gd` | `_on_drag_released` — no target found |
All calls: `AudioManager.play_sfx("event_key")` (autoload, one line, no wiring).
## Asset Specification
### Directory Layout
```
assets/audio/music/
floor_0.ogg
floor_1.ogg
floor_2.ogg
floor_3.ogg
assets/audio/sfx/
chest_tap.ogg
item_spawn.ogg
item_drag_start.ogg
item_drop_hand.ogg
item_drop_outfit.ogg
item_return_chest.ogg
item_drop_floor.ogg
```
### Music — freesound.org Search Terms
| File | Search terms | Character |
|---|---|---|
| `floor_0.ogg` | `"children hospital happy loop"` | Heiter, belebte Lobby |
| `floor_1.ogg` | `"calm medical ambient loop"` | Ruhig, klinisch |
| `floor_2.ogg` | `"nursery lullaby gentle loop"` | Sanft, Wiegenlied |
| `floor_3.ogg` | `"garden birds outdoor loop"` | Draußen, Vogelgezwitscher |
Requirements: CC0 or CC-BY, loopable (seamless start/end), 3060 s duration, `.ogg` or convertible.
### SFX — freesound.org Search Terms
| File | Search terms | Target duration |
|---|---|---|
| `chest_tap.ogg` | `"wooden box tap"` | < 0.3 s |
| `item_spawn.ogg` | `"soft whoosh pop"` | < 0.5 s |
| `item_drag_start.ogg` | `"soft pickup"` | < 0.3 s |
| `item_drop_hand.ogg` | `"light click"` | < 0.2 s |
| `item_drop_outfit.ogg` | `"fabric swoosh"` | < 0.5 s |
| `item_return_chest.ogg` | `"soft click"` | < 0.2 s |
| `item_drop_floor.ogg` | `"light thud"` | < 0.3 s |
Requirements: CC0 or CC-BY, child-appropriate, no startling sounds.
### SFX Map in Code
```gdscript
const _SFX_MAP: Dictionary = {
"chest_tap": "res://assets/audio/sfx/chest_tap.ogg",
"item_spawn": "res://assets/audio/sfx/item_spawn.ogg",
"item_drag_start": "res://assets/audio/sfx/item_drag_start.ogg",
"item_drop_hand": "res://assets/audio/sfx/item_drop_hand.ogg",
"item_drop_outfit": "res://assets/audio/sfx/item_drop_outfit.ogg",
"item_return_chest":"res://assets/audio/sfx/item_return_chest.ogg",
"item_drop_floor": "res://assets/audio/sfx/item_drop_floor.ogg",
}
```
### Music Map in Code
```gdscript
const _MUSIC_MAP: Dictionary = {
0: "res://assets/audio/music/floor_0.ogg",
1: "res://assets/audio/music/floor_1.ogg",
2: "res://assets/audio/music/floor_2.ogg",
3: "res://assets/audio/music/floor_3.ogg",
}
```
## Integration with Existing Scripts
### room_chest.gd
Add two `AudioManager.play_sfx()` calls:
1. In the chest tap handler (before spawn guard): `AudioManager.play_sfx("chest_tap")`
2. In `spawn_items()`, at the start of each item's tween: `AudioManager.play_sfx("item_spawn")`
### holdable_item.gd
- `_on_drag_picked_up`: add `AudioManager.play_sfx("item_drag_start")`
- `_try_return_to_chest` (on success, before tween): add `AudioManager.play_sfx("item_return_chest")`
- `_on_drag_released` (after hand slot attach): add `AudioManager.play_sfx("item_drop_hand")`
- `_on_drag_released` (no target branch): add `AudioManager.play_sfx("item_drop_floor")`
### outfit_item.gd
- `_on_drag_released` (after `apply_outfit_item` call): add `AudioManager.play_sfx("item_drop_outfit")`
### project.godot
Add `AudioManager` to the autoload list:
```
[autoload]
AudioManager="*res://scripts/autoload/AudioManager.gd"
```
## GameState Integration
`AudioManager` observes `GameState.state_changed`. No changes to `GameState` are required — `music_volume` and `sfx_volume` are already persisted in v3.
`set_music_volume` / `set_sfx_volume` write back to `GameState` so values persist on save.
## Testing
**File:** `test/unit/test_audio_manager.gd`
Testable (pure logic, no actual audio):
- `_derive_floor_from_room()` — all 12 room strings → correct floor int
- `_derive_floor_from_room("unknown")``-1`
- `play_floor_music()` with same floor → `_current_floor` unchanged (no-op)
- `get_current_floor()` → returns `-1` before any music played
- `play_sfx()` with unknown key → no crash (guard required)
Not testable (manual on device):
- Actual audio playback
- Cross-fade quality and timing
- Volume balance SFX vs. music
- Loop seamlessness
## Out of Scope
- Polyphonic SFX (multiple simultaneous sounds)
- Per-room music (12 tracks) — floor-level is sufficient for this sprint
- Character voice sounds (Häschen-Schnuffeln, Kätzchen-Miau) — separate sprint
- Settings UI for volume sliders — volume API exists, UI in a later sprint
- AudioBus setup (Master/Music/SFX buses) — single bus is sufficient for now