diff --git a/docs/superpowers/plans/2026-05-10-sprint-19-audio-manager.md b/docs/superpowers/plans/2026-05-10-sprint-19-audio-manager.md new file mode 100644 index 0000000..4d070db --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-sprint-19-audio-manager.md @@ -0,0 +1,574 @@ +# 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" +```