# Sprint 22 — Character & Ambient SFX 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 looping ambient heartbeat SFX to UltrasoundMachine (room-driven start/stop) and wire three character interaction sounds (pickup, tap, place) into `character.gd`. **Architecture:** UltrasoundMachine owns its own `AudioStreamPlayer` with `stream.loop = true`, connected to `RoomNavigator.room_changed` — mirrors the Ambulance pattern. Character sounds use the existing `AudioManager.play_sfx()` one-shot path with 3 new `_SFX_MAP` keys. **Tech Stack:** GDScript 4 (static types), GUT v9.6.0. --- ### Task 1: Create 4 placeholder SFX files + update audio asset docs **Files:** - Create: `assets/audio/sfx/ultrasound_heartbeat.ogg` - Create: `assets/audio/sfx/character_pickup.ogg` - Create: `assets/audio/sfx/character_place.ogg` - Create: `assets/audio/sfx/character_tap.ogg` - Modify: `docs/audio-assets-sprint19.md` - [ ] **Step 1: Create 4 empty placeholder files** ```powershell New-Item -ItemType File -Force "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx/assets/audio/sfx/ultrasound_heartbeat.ogg" New-Item -ItemType File -Force "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx/assets/audio/sfx/character_pickup.ogg" New-Item -ItemType File -Force "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx/assets/audio/sfx/character_place.ogg" New-Item -ItemType File -Force "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx/assets/audio/sfx/character_tap.ogg" ``` - [ ] **Step 2: Append Sprint 22 entries to `docs/audio-assets-sprint19.md`** Append to the end of the file: ```markdown ## Sprint 22 — Character & Ambient SFX All CC0 or CC-BY from freesound.org. Replace placeholder 0-byte files with the downloads below. | File | Description | Freesound suggestion | |---|---|---| | `assets/audio/sfx/ultrasound_heartbeat.ogg` | soft beep/blip ~1s, loops seamlessly | search "heartbeat beep soft" or "medical monitor beep" | | `assets/audio/sfx/character_pickup.ogg` | happy soft squeak / whoosh | search "cartoon pickup soft" or "whoosh gentle" | | `assets/audio/sfx/character_place.ogg` | gentle thud / landing | search "soft thud" or "gentle landing" | | `assets/audio/sfx/character_tap.ogg` | short happy chime / pop | search "happy chime short" or "cartoon pop soft" | All files must be child-friendly (no harsh/loud sounds), mono or stereo, 44100 Hz, OGG Vorbis. `ultrasound_heartbeat.ogg` must loop seamlessly (start and end points match). ``` - [ ] **Step 3: Commit** ```bash git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" add assets/audio/sfx/ultrasound_heartbeat.ogg assets/audio/sfx/character_pickup.ogg assets/audio/sfx/character_place.ogg assets/audio/sfx/character_tap.ogg docs/audio-assets-sprint19.md git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" commit -m "assets(sfx): add sprint-22 character and ambient SFX placeholders" ``` --- ### Task 2: Add 3 character SFX keys to AudioManager._SFX_MAP + test **Files:** - Modify: `scripts/autoload/AudioManager.gd` - Modify: `test/unit/test_audio_manager.gd` - [ ] **Step 1: Write failing test** Append to `test/unit/test_audio_manager.gd`: ```gdscript func test_sfx_map_has_all_character_keys() -> void: assert_true(AudioManager._SFX_MAP.has("character_pickup")) assert_true(AudioManager._SFX_MAP.has("character_place")) assert_true(AudioManager._SFX_MAP.has("character_tap")) ``` - [ ] **Step 2: Run → 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 --path "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" ``` Expected: `test_sfx_map_has_all_character_keys` fails — 3 keys missing from `_SFX_MAP`. - [ ] **Step 3: Add 3 keys to AudioManager._SFX_MAP** In `scripts/autoload/AudioManager.gd`, replace the `_SFX_MAP` constant with: ```gdscript 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", "xray_scan": "res://assets/audio/sfx/xray_scan.ogg", "tea_pour": "res://assets/audio/sfx/tea_pour.ogg", "cradle_rock": "res://assets/audio/sfx/cradle_rock.ogg", "gift_open": "res://assets/audio/sfx/gift_open.ogg", "ambulance_siren": "res://assets/audio/sfx/ambulance_siren.ogg", "delivery_cheer": "res://assets/audio/sfx/delivery_cheer.ogg", "object_tap": "res://assets/audio/sfx/object_tap.ogg", "character_pickup": "res://assets/audio/sfx/character_pickup.ogg", "character_place": "res://assets/audio/sfx/character_place.ogg", "character_tap": "res://assets/audio/sfx/character_tap.ogg", } ``` - [ ] **Step 4: Run → 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 --path "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" ``` Expected: all tests pass. Total Passing Tests ≥ 221. - [ ] **Step 5: Commit** ```bash git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" add scripts/autoload/AudioManager.gd test/unit/test_audio_manager.gd git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" commit -m "feat(sfx): add character SFX keys to AudioManager._SFX_MAP" ``` --- ### Task 3: Wire character SFX into character.gd **Files:** - Modify: `scripts/characters/character.gd` No new tests — single-line additions, AudioManager Dummy guard covers headless safety. - [ ] **Step 1: Edit `_on_drag_picked_up()`** Current: ```gdscript func _on_drag_picked_up(pos: Vector2) -> void: _is_held = true _drag_start_position = pos set_animation_state("held") character_picked_up.emit(self) ``` Replace with: ```gdscript func _on_drag_picked_up(pos: Vector2) -> void: AudioManager.play_sfx("character_pickup") _is_held = true _drag_start_position = pos set_animation_state("held") character_picked_up.emit(self) ``` - [ ] **Step 2: Edit `_on_drag_released()` — tap branch and place branch** Current: ```gdscript func _on_drag_released(pos: Vector2) -> void: _is_held = false var drag_distance: float = pos.distance_to(_drag_start_position) if drag_distance < _TAP_THRESHOLD: set_animation_state("idle") _handle_outfit_tap() return set_animation_state("idle") if data == null or data.id.is_empty(): return GameState.set_character_position(character_id, global_position) character_placed.emit(self, global_position) ``` Replace with: ```gdscript func _on_drag_released(pos: Vector2) -> void: _is_held = false var drag_distance: float = pos.distance_to(_drag_start_position) if drag_distance < _TAP_THRESHOLD: AudioManager.play_sfx("character_tap") set_animation_state("idle") _handle_outfit_tap() return AudioManager.play_sfx("character_place") set_animation_state("idle") if data == null or data.id.is_empty(): return GameState.set_character_position(character_id, global_position) character_placed.emit(self, global_position) ``` - [ ] **Step 3: Run full test suite → verify no regressions** ```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 --path "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" ``` Expected: all tests pass. Total ≥ 221. - [ ] **Step 4: Commit** ```bash git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" add scripts/characters/character.gd git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" commit -m "feat(sfx): wire character pickup/tap/place SFX to AudioManager" ``` --- ### Task 4: UltrasoundMachine ambient heartbeat audio **Files:** - Modify: `scripts/objects/ultrasound_machine.gd` No new tests — room-event-driven audio, mirrors Ambulance pattern (no per-SFX unit test for Ambulance either). - [ ] **Step 1: Replace `ultrasound_machine.gd`** Full replacement: ```gdscript ## UltrasoundMachine — displays a continuous heartbeat pulse on the screen. ## Plays looping ambient heartbeat audio when the ultrasound room is active. class_name UltrasoundMachine extends Node2D const BEAT_RISE_DURATION: float = 0.12 const BEAT_FALL_DURATION: float = 0.12 const BEAT_INTERVAL: float = 0.60 const BEAT_SCALE_PEAK: Vector2 = Vector2(1.5, 1.5) const BEAT_SCALE_REST: Vector2 = Vector2(1.0, 1.0) const _HEARTBEAT_PATH: String = "res://assets/audio/sfx/ultrasound_heartbeat.ogg" @export var trigger_floor: int = 2 @export var trigger_room: int = 0 var _audio: AudioStreamPlayer func _ready() -> void: _start_heartbeat_loop() _setup_audio() RoomNavigator.room_changed.connect(_on_room_changed) func _exit_tree() -> void: if RoomNavigator.room_changed.is_connected(_on_room_changed): RoomNavigator.room_changed.disconnect(_on_room_changed) func _setup_audio() -> void: _audio = AudioStreamPlayer.new() add_child(_audio) if AudioServer.get_driver_name() == "Dummy": return if not ResourceLoader.exists(_HEARTBEAT_PATH): return var stream: AudioStreamOggVorbis = load(_HEARTBEAT_PATH) as AudioStreamOggVorbis if stream == null: return stream.loop = true _audio.stream = stream func _on_room_changed(floor_index: int, room_index: int) -> void: if _audio == null or _audio.stream == null: return if floor_index == trigger_floor and room_index == trigger_room: _audio.play() else: _audio.stop() func _start_heartbeat_loop() -> void: var dot: Node2D = get_node_or_null("HeartbeatDot") as Node2D if dot == null: return var tween: Tween = create_tween() tween.set_loops() tween.tween_property(dot, "scale", BEAT_SCALE_PEAK, BEAT_RISE_DURATION) tween.tween_property(dot, "scale", BEAT_SCALE_REST, BEAT_FALL_DURATION) tween.tween_interval(BEAT_INTERVAL) ``` - [ ] **Step 2: Run full test suite → verify no regressions** ```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 --path "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" ``` Expected: all tests pass. Total ≥ 221. - [ ] **Step 3: Commit** ```bash git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" add scripts/objects/ultrasound_machine.gd git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-22-character-ambient-sfx" commit -m "feat(sfx): add looping ambient heartbeat to UltrasoundMachine" ```