Files
Cozypaw-Hospital/docs/superpowers/plans/2026-05-10-sprint-19-audio-manager.md
T
2026-05-10 00:48:42 +02:00

575 lines
18 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 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an AudioManager autoload that plays floor-based background music with cross-fade and fires SFX for every player interaction — chest taps, item spawning, drag, drop, outfit apply, and chest return.
**Architecture:** AudioManager extends Node (autoload, already registered in project.godot). Two AudioStreamPlayer children ping-pong for cross-fade (0.8 s). One SfxPlayer child handles all SFX. Floor is derived from `GameState.current_room` via a pure lookup function. All 7 SFX events are wired in via direct `AudioManager.play_sfx()` calls (autoload = globally accessible). RoomChest gains a tap handler (`_unhandled_input`) to trigger item spawning.
**Tech Stack:** GDScript 4 (static types), GUT v9.6.0, freesound.org CC0/CC-BY assets, ffmpeg for .ogg conversion if needed.
---
### Task 1: Audio Assets
**Files:**
- Create: `assets/audio/music/floor_0.ogg` through `floor_3.ogg`
- Create: `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`
- [ ] **Step 1: Create directories**
```bash
mkdir -p "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/music"
mkdir -p "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/sfx"
```
- [ ] **Step 2: Download music tracks from freesound.org**
Search freesound.org for each track. Requirements: CC0 or CC-BY licence, loopable (end ≈ start), 3060 s, child-appropriate, no sudden loud sounds. Save as `floor_N.ogg` directly or download and rename.
| File | Search query on freesound.org | Character |
|---|---|---|
| `floor_0.ogg` | `children hospital cheerful loop` | Heiter, belebte Lobby |
| `floor_1.ogg` | `calm ambient medical loop` | Ruhig, klinisch |
| `floor_2.ogg` | `gentle nursery lullaby loop` | Sanft, Wiegenlied |
| `floor_3.ogg` | `garden birds outdoor ambient loop` | Draußen, Vogelgezwitscher |
If a result is `.mp3` or `.wav`, convert with ffmpeg:
```bash
ffmpeg -i input.mp3 -c:a libvorbis -q:a 4 floor_0.ogg
```
- [ ] **Step 3: Download SFX from freesound.org**
Requirements: CC0 or CC-BY, < 0.5 s each, no startling sounds.
| File | Search query | Target duration |
|---|---|---|
| `chest_tap.ogg` | `wood tap short` | < 0.3 s |
| `item_spawn.ogg` | `pop whoosh soft` | < 0.5 s |
| `item_drag_start.ogg` | `pickup soft short` | < 0.3 s |
| `item_drop_hand.ogg` | `light click short` | < 0.2 s |
| `item_drop_outfit.ogg` | `fabric swoosh short` | < 0.5 s |
| `item_return_chest.ogg` | `soft click snap` | < 0.2 s |
| `item_drop_floor.ogg` | `soft thud light` | < 0.3 s |
Convert to `.ogg` with ffmpeg if needed (same command as above, -q:a 6 for SFX).
- [ ] **Step 4: Verify all 11 files are present**
```bash
ls "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/music/"
ls "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/sfx/"
```
Expected: 4 music files, 7 SFX files.
- [ ] **Step 5: Commit**
```bash
git add assets/audio/music/ assets/audio/sfx/
git commit -m "assets: add floor music and SFX for Sprint 19"
```
---
### Task 2: AudioManager Script + Tests
**Files:**
- Create: `scripts/autoload/AudioManager.gd`
- Create: `test/unit/test_audio_manager.gd`
Note: `AudioManager` is already registered in `project.godot` as `"*res://scripts/autoload/AudioManager.gd"`. Do NOT add `class_name AudioManager` to the script (Godot 4 autoload + class_name conflict — see CLAUDE.md memory).
- [ ] **Step 1: Write the failing tests**
Create `test/unit/test_audio_manager.gd`:
```gdscript
## Tests for AudioManager — floor derivation, no-op guard, SFX key validation.
extends GutTest
func before_each() -> void:
AudioManager._current_floor = -1
AudioManager._is_crossfading = false
func test_derive_floor_floor0_reception() -> void:
assert_eq(AudioManager._derive_floor_from_room("reception"), 0)
func test_derive_floor_floor0_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("giftshop"), 0)
assert_eq(AudioManager._derive_floor_from_room("restaurant"), 0)
assert_eq(AudioManager._derive_floor_from_room("emergency"), 0)
func test_derive_floor_floor1_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("xray"), 1)
assert_eq(AudioManager._derive_floor_from_room("pharmacy"), 1)
assert_eq(AudioManager._derive_floor_from_room("lab"), 1)
assert_eq(AudioManager._derive_floor_from_room("patient_rooms"), 1)
func test_derive_floor_floor2_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("ultrasound"), 2)
assert_eq(AudioManager._derive_floor_from_room("delivery_room"), 2)
assert_eq(AudioManager._derive_floor_from_room("nursery"), 2)
func test_derive_floor_garden() -> void:
assert_eq(AudioManager._derive_floor_from_room("garden_party"), 3)
func test_derive_floor_unknown_returns_minus_one() -> void:
assert_eq(AudioManager._derive_floor_from_room("unknown_room"), -1)
assert_eq(AudioManager._derive_floor_from_room(""), -1)
func test_get_current_floor_starts_at_minus_one() -> void:
assert_eq(AudioManager.get_current_floor(), -1)
func test_play_floor_music_same_floor_is_noop() -> void:
AudioManager._current_floor = 0
AudioManager.play_floor_music(0)
assert_eq(AudioManager.get_current_floor(), 0)
func test_play_sfx_unknown_key_does_not_crash() -> void:
AudioManager.play_sfx("nonexistent_event_xyz")
pass
func test_sfx_map_has_all_seven_keys() -> void:
assert_true(AudioManager._SFX_MAP.has("chest_tap"))
assert_true(AudioManager._SFX_MAP.has("item_spawn"))
assert_true(AudioManager._SFX_MAP.has("item_drag_start"))
assert_true(AudioManager._SFX_MAP.has("item_drop_hand"))
assert_true(AudioManager._SFX_MAP.has("item_drop_outfit"))
assert_true(AudioManager._SFX_MAP.has("item_return_chest"))
assert_true(AudioManager._SFX_MAP.has("item_drop_floor"))
func test_music_map_has_all_four_floors() -> void:
assert_true(AudioManager._MUSIC_MAP.has(0))
assert_true(AudioManager._MUSIC_MAP.has(1))
assert_true(AudioManager._MUSIC_MAP.has(2))
assert_true(AudioManager._MUSIC_MAP.has(3))
```
- [ ] **Step 2: Run to verify FAIL**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
```
Expected: FAIL — `AudioManager` script missing.
- [ ] **Step 3: Create AudioManager.gd**
Create `scripts/autoload/AudioManager.gd`:
```gdscript
## AudioManager — floor music with cross-fade and SFX for player interactions.
## Autoload. Do NOT add class_name (Godot 4 autoload conflict).
extends Node
const CROSSFADE_DURATION: float = 0.8
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",
}
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",
}
var _current_floor: int = -1
var _is_crossfading: bool = false
var _active_player: AudioStreamPlayer
var _music_a: AudioStreamPlayer
var _music_b: AudioStreamPlayer
var _sfx_player: AudioStreamPlayer
func _ready() -> void:
_music_a = AudioStreamPlayer.new()
_music_b = AudioStreamPlayer.new()
_sfx_player = AudioStreamPlayer.new()
add_child(_music_a)
add_child(_music_b)
add_child(_sfx_player)
_active_player = _music_a
_music_a.volume_db = linear_to_db(GameState.music_volume)
_music_b.volume_db = linear_to_db(0.0)
_sfx_player.volume_db = linear_to_db(GameState.sfx_volume)
GameState.state_changed.connect(_on_game_state_changed)
var initial_floor: int = _derive_floor_from_room(GameState.current_room)
if initial_floor != -1:
play_floor_music(initial_floor)
func play_floor_music(floor: int) -> void:
if floor == _current_floor:
return
if not _MUSIC_MAP.has(floor):
return
if _is_crossfading:
return
_is_crossfading = true
_current_floor = floor
var inactive: AudioStreamPlayer = _music_b if _active_player == _music_a else _music_a
var stream: AudioStream = load(_MUSIC_MAP[floor]) as AudioStream
if stream == null:
_is_crossfading = false
return
inactive.stream = stream
inactive.volume_db = linear_to_db(0.0)
inactive.play()
var tween: Tween = create_tween().set_parallel(true)
tween.tween_property(inactive, "volume_db", linear_to_db(GameState.music_volume), CROSSFADE_DURATION)
tween.tween_property(_active_player, "volume_db", linear_to_db(0.0), CROSSFADE_DURATION)
await tween.finished
_active_player.stop()
_active_player = inactive
_is_crossfading = false
func play_sfx(event: String) -> void:
if not _SFX_MAP.has(event):
return
var stream: AudioStream = load(_SFX_MAP[event]) as AudioStream
if stream == null:
return
_sfx_player.stream = stream
_sfx_player.play()
func set_music_volume(vol: float) -> void:
GameState.music_volume = vol
_active_player.volume_db = linear_to_db(vol)
func set_sfx_volume(vol: float) -> void:
GameState.sfx_volume = vol
_sfx_player.volume_db = linear_to_db(vol)
func get_current_floor() -> int:
return _current_floor
func _on_game_state_changed() -> void:
var floor: int = _derive_floor_from_room(GameState.current_room)
if floor != -1:
play_floor_music(floor)
func _derive_floor_from_room(room: String) -> int:
match room:
"reception", "giftshop", "restaurant", "emergency":
return 0
"xray", "pharmacy", "lab", "patient_rooms":
return 1
"ultrasound", "delivery_room", "nursery":
return 2
"garden_party":
return 3
return -1
```
- [ ] **Step 4: Run to verify PASS**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
```
Expected: all previous tests pass + 12 new AudioManager tests pass. Total ≥ 208.
- [ ] **Step 5: Commit**
```bash
git add scripts/autoload/AudioManager.gd test/unit/test_audio_manager.gd
git commit -m "feat(audio): add AudioManager with floor music cross-fade and SFX"
```
---
### Task 3: RoomChest Tap Handler + SFX
**Files:**
- Modify: `scripts/objects/room_chest.gd`
- Modify: `test/unit/test_room_chest.gd` (append 2 tests)
Currently `RoomChest` has no input handler — tapping the chest does nothing. This task adds `_unhandled_input` with a helper `_get_press_position()` and wires the two chest SFX events.
- [ ] **Step 1: Append failing tests to test_room_chest.gd**
Add at the end of `test/unit/test_room_chest.gd`:
```gdscript
func test_get_press_position_returns_position_for_screen_touch_pressed() -> void:
var chest: RoomChest = RoomChest.new()
add_child_autofree(chest)
var event: InputEventScreenTouch = InputEventScreenTouch.new()
event.pressed = true
event.position = Vector2(100.0, 200.0)
assert_eq(chest._get_press_position(event), Vector2(100.0, 200.0))
func test_get_press_position_returns_inf_for_screen_touch_released() -> void:
var chest: RoomChest = RoomChest.new()
add_child_autofree(chest)
var event: InputEventScreenTouch = InputEventScreenTouch.new()
event.pressed = false
event.position = Vector2(100.0, 200.0)
assert_eq(chest._get_press_position(event), Vector2.INF)
```
- [ ] **Step 2: Run to verify FAIL**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
```
Expected: FAIL — `_get_press_position` not defined on RoomChest.
- [ ] **Step 3: Update room_chest.gd**
The full updated file (replace existing content):
```gdscript
## RoomChest — tappable storage node. Spawns HoldableItem/OutfitItem instances on demand.
## Items fly out with a tween. Receives items back via receive_item().
class_name RoomChest extends Node2D
signal items_spawned(chest: RoomChest)
signal item_received(chest: RoomChest, item_id: String)
const SPAWN_TWEEN_DURATION: float = 0.3
@export var chest_id: String = ""
@export var tap_radius: float = 50.0
var _spawned_items: Array[HoldableItem] = []
var _item_configs: Array[ChestItemData] = []
func _ready() -> void:
add_to_group("room_chests")
_item_configs = RoomChestConfig.get_items(chest_id)
if not chest_id.is_empty() and GameState.has_method("get_chest_state"):
if not GameState.get_chest_state(chest_id).is_empty():
call_deferred("spawn_items")
func _unhandled_input(event: InputEvent) -> void:
var press_pos: Vector2 = _get_press_position(event)
if press_pos == Vector2.INF:
return
var canvas_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * press_pos
if canvas_pos.distance_to(global_position) > tap_radius:
return
get_viewport().set_input_as_handled()
AudioManager.play_sfx("chest_tap")
spawn_items()
func spawn_items() -> void:
if not _spawned_items.is_empty():
return
AudioManager.play_sfx("item_spawn")
var parent: Node = get_parent()
for config: ChestItemData in _item_configs:
var item: HoldableItem = _create_item(config)
item.home_chest = self
if parent != null:
parent.add_child(item)
else:
add_child(item)
item.global_position = global_position
_spawned_items.append(item)
_tween_item_out(item, config.spawn_offset)
if GameState.has_method("set_chest_state"):
GameState.set_chest_state(chest_id, _get_spawned_ids())
items_spawned.emit(self)
func receive_item(item: HoldableItem) -> void:
if not _spawned_items.has(item):
return
_spawned_items.erase(item)
if GameState.has_method("set_chest_state"):
if _spawned_items.is_empty():
GameState.clear_chest_state(chest_id)
else:
GameState.set_chest_state(chest_id, _get_spawned_ids())
item_received.emit(self, item.item_id)
_tween_item_in(item)
func are_items_spawned() -> bool:
return not _spawned_items.is_empty()
func get_spawned_count() -> int:
return _spawned_items.size()
func get_item_config_count() -> int:
return _item_configs.size()
func get_spawned_item(index: int) -> HoldableItem:
if index < 0 or index >= _spawned_items.size():
return null
return _spawned_items[index]
func _get_press_position(event: InputEvent) -> Vector2:
if event is InputEventScreenTouch and event.pressed:
return event.position
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
return event.position
return Vector2.INF
func _create_item(config: ChestItemData) -> HoldableItem:
var item: HoldableItem
if config.item_type == ChestItemData.ItemType.OUTFIT:
var outfit: OutfitItem = OutfitItem.new()
outfit.outfit_layer = config.outfit_layer
item = outfit
else:
item = HoldableItem.new()
item.item_id = config.item_id
return item
func _get_spawned_ids() -> Array[String]:
var ids: Array[String] = []
for item: HoldableItem in _spawned_items:
ids.append(item.item_id)
return ids
func _tween_item_out(item: HoldableItem, offset: Vector2) -> void:
var tween: Tween = create_tween()
tween.tween_property(item, "global_position", global_position + offset, SPAWN_TWEEN_DURATION)
func _tween_item_in(item: HoldableItem) -> void:
var tween: Tween = create_tween()
tween.tween_property(item, "global_position", global_position, SPAWN_TWEEN_DURATION)
tween.tween_callback(item.queue_free)
```
- [ ] **Step 4: Run to verify PASS**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
```
Expected: all tests pass including 2 new `_get_press_position` tests. Total ≥ 210.
- [ ] **Step 5: Commit**
```bash
git add scripts/objects/room_chest.gd test/unit/test_room_chest.gd
git commit -m "feat(audio): add tap handler and SFX to RoomChest"
```
---
### Task 4: HoldableItem + OutfitItem SFX
**Files:**
- Modify: `scripts/objects/holdable_item.gd`
- Modify: `scripts/objects/outfit_item.gd`
Pure one-liner additions. No new tests — existing 196+ tests verify no regressions.
- [ ] **Step 1: Update holdable_item.gd**
Replace the three methods `_on_drag_picked_up`, `_on_drag_released`, and `_try_return_to_chest` with the versions below. Everything else in the file stays identical.
```gdscript
func _on_drag_picked_up(_pos: Vector2) -> void:
if is_in_hand_slot():
_detach_from_hand_slot()
AudioManager.play_sfx("item_drag_start")
item_picked_up.emit(self)
func _on_drag_released(_pos: Vector2) -> void:
if _try_return_to_chest():
return
var result: Array = _find_nearest_free_hand_slot()
if not result.is_empty():
var character: Character = result[0] as Character
var hand: String = result[1] as String
character.attach_item(hand, self)
AudioManager.play_sfx("item_drop_hand")
else:
AudioManager.play_sfx("item_drop_floor")
item_placed.emit(self)
func _try_return_to_chest() -> bool:
if home_chest == null:
return false
if global_position.distance_to(home_chest.global_position) >= CHEST_RETURN_RADIUS:
return false
var chest: RoomChest = home_chest as RoomChest
if chest == null:
return false
AudioManager.play_sfx("item_return_chest")
chest.receive_item(self)
return true
```
- [ ] **Step 2: Update outfit_item.gd**
Replace `_on_drag_released` with:
```gdscript
func _on_drag_released(_pos: Vector2) -> void:
if _try_return_to_chest():
return
var character: Character = _find_nearest_character()
if character != null:
character.apply_outfit_item(outfit_layer, item_id, outfit_sprite, self)
AudioManager.play_sfx("item_drop_outfit")
return
super._on_drag_released(_pos)
```
- [ ] **Step 3: Run full test suite**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
```
Expected: all tests pass. Total ≥ 210 (no new tests added in this task).
- [ ] **Step 4: Commit**
```bash
git add scripts/objects/holdable_item.gd scripts/objects/outfit_item.gd
git commit -m "feat(audio): wire SFX into HoldableItem and OutfitItem"
```