# 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"
```
