docs: add Sprint 19 AudioManager design spec
This commit is contained in:
@@ -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), 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
|
||||||
Reference in New Issue
Block a user