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

18 KiB
Raw Blame History

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

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:

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
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
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:

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

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

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
"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):

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

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:

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
"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
git add scripts/objects/holdable_item.gd scripts/objects/outfit_item.gd
git commit -m "feat(audio): wire SFX into HoldableItem and OutfitItem"