# 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), 30–60 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" ```