193 lines
7.0 KiB
Markdown
193 lines
7.0 KiB
Markdown
# 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), 30–60 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
|