Files
Cozypaw-Hospital/docs/superpowers/specs/2026-05-10-sprint-19-audio-manager.md
T
2026-05-10 00:39:40 +02:00

193 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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