diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..23335ff --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + } +} \ No newline at end of file diff --git a/docs/credits-audio.md b/docs/credits-audio.md new file mode 100644 index 0000000..37bae12 --- /dev/null +++ b/docs/credits-audio.md @@ -0,0 +1,7 @@ +# Audio Credits + +CC-BY files require attribution. + +| File | Title | Author | License | URL | +|---|---|---|---|---| +| `assets/audio/sfx/item_drop_outfit.ogg` | cape-swoosh | CosmicEmbers | CC-BY 3.0 | https://freesound.org/s/161415/ | diff --git a/docs/download_audio.py b/docs/download_audio.py index 4eea06b..2641778 100644 --- a/docs/download_audio.py +++ b/docs/download_audio.py @@ -28,7 +28,7 @@ import requests from pathlib import Path # ── Fill in your API key here ────────────────────────────────────────────────── -API_KEY = "XLXzH6xQJbt5HQjLx7kQwfDSB9MTFawMTsAFhRFG" # e.g. "aB3dEfGhIjKlMnOpQrStUvWx" +API_KEY = "" # get your free key at freesound.org → API credentials # ────────────────────────────────────────────────────────────────────────────── REPO_ROOT = Path(__file__).parent.parent diff --git a/docs/superpowers/plans/2026-05-07-sprint-15-character-v2.md b/docs/superpowers/plans/2026-05-07-sprint-15-character-v2.md new file mode 100644 index 0000000..f974f75 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-sprint-15-character-v2.md @@ -0,0 +1,1038 @@ +# Sprint 15 — Character System v2 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:** Extend the Character node with AnimatedSprite2D (6 states), SnapReceiver (furniture attachment), HandLeft/HandRight slots (item holding), and three OutfitLayers (clothing overlay). + +**Architecture:** Four independent APIs are added to the existing `Character` class. `SnapPoint` and `SnapReceiver` are standalone nodes following the project's component pattern (`DragDropComponent`). All new nodes are added to `Character.tscn` so scene instantiation gives a complete character. ColorRect placeholder stays visible until real art replaces it. + +**Tech Stack:** Godot 4.6.2, GDScript (static typing), GUT v9.6.0 (TDD), headless runner. + +**GDD Reference:** `docs/game-design.md` — Kapitel 3 (Figurensystem) + Kapitel 5.1–5.2. + +**Headless runner:** +``` +"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 2>&1 +``` + +**Existing tests must stay green:** 42 tests passing before this sprint starts. + +--- + +## File Map + +| Action | Path | Responsibility | +|---|---|---| +| Create | `scripts/objects/snap_point.gd` | Attachment node on furniture | +| Create | `scripts/characters/snap_receiver.gd` | Snap detection on Character | +| Modify | `scripts/characters/character_data.gd` | Add `outfit: Array[String]` field | +| Modify | `scripts/characters/character.gd` | Anim state API, outfit API, hand slot API | +| Modify | `scenes/characters/Character.tscn` | Add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver | +| Create | `test/unit/test_snap_point.gd` | SnapPoint unit tests | +| Create | `test/unit/test_character_v2.gd` | Character API tests | +| Create | `test/unit/test_snap_receiver.gd` | SnapReceiver integration tests | + +--- + +## Task 1: SnapPoint + +**Files:** +- Create: `scripts/objects/snap_point.gd` +- Create: `test/unit/test_snap_point.gd` + +- [ ] **Step 1: Write the failing tests** + +Create `test/unit/test_snap_point.gd`: + +```gdscript +## Tests for SnapPoint — attachment node on furniture. +extends GutTest + +const SnapPointScript: GDScript = preload("res://scripts/objects/snap_point.gd") + +var _snap: SnapPoint +var _char: Character + + +func before_each() -> void: + _snap = SnapPointScript.new() + _snap.pose = "sitting" + add_child_autofree(_snap) + _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(_char) + + +func test_is_free_when_no_occupant() -> void: + assert_true(_snap.is_free()) + + +func test_is_not_free_when_occupied() -> void: + _snap.snap(_char) + assert_false(_snap.is_free()) + + +func test_snap_sets_occupant() -> void: + _snap.snap(_char) + assert_eq(_snap.occupant, _char) + + +func test_unsnap_clears_occupant() -> void: + _snap.snap(_char) + _snap.unsnap() + assert_null(_snap.occupant) + + +func test_accepts_any_character_when_baby_only_false() -> void: + assert_true(_snap.accepts(_char)) + + +func test_does_not_accept_when_occupied() -> void: + _snap.snap(_char) + var other: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(other) + assert_false(_snap.accepts(other)) + + +func test_baby_only_rejects_healthy_character() -> void: + _snap.baby_only = true + var cd: CharacterData = CharacterData.new() + cd.state = CharacterData.State.HEALTHY + _char.data = cd + assert_false(_snap.accepts(_char)) + + +func test_baby_only_accepts_baby_state_character() -> void: + _snap.baby_only = true + var cd: CharacterData = CharacterData.new() + cd.state = CharacterData.State.BABY + _char.data = cd + assert_true(_snap.accepts(_char)) + + +func test_snap_emits_character_snapped() -> void: + watch_signals(_snap) + _snap.snap(_char) + assert_signal_emitted(_snap, "character_snapped") + + +func test_unsnap_emits_character_unsnapped() -> void: + _snap.snap(_char) + watch_signals(_snap) + _snap.unsnap() + assert_signal_emitted(_snap, "character_unsnapped") +``` + +- [ ] **Step 2: Run tests — verify they 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 2>&1 +``` + +Expected: errors about `SnapPoint` class not found. Existing 42 tests still pass. + +- [ ] **Step 3: Implement `scripts/objects/snap_point.gd`** + +```gdscript +## SnapPoint — attachment position on furniture where a Character can snap into a pose. +## Add to any furniture node. The node auto-registers in the "snap_points" group on _ready. +class_name SnapPoint extends Node2D + +signal character_snapped(character: Character) +signal character_unsnapped(character: Character) + +@export var pose: String = "sitting" +@export var baby_only: bool = false + +var occupant: Character = null + + +func _ready() -> void: + add_to_group("snap_points") + + +func is_free() -> bool: + return occupant == null + + +func accepts(character: Character) -> bool: + if not is_free(): + return false + if baby_only: + if character.data == null: + return false + return character.data.state == CharacterData.State.BABY + return true + + +func snap(character: Character) -> void: + occupant = character + character_snapped.emit(character) + + +func unsnap() -> void: + var prev: Character = occupant + occupant = null + if prev != null: + character_unsnapped.emit(prev) +``` + +- [ ] **Step 4: Run tests — verify 10 new tests 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 2>&1 +``` + +Expected: 52/52 passed (42 existing + 10 SnapPoint). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/objects/snap_point.gd test/unit/test_snap_point.gd +git commit -m "feat(snap-point): add SnapPoint node with pose, baby_only filter, and occupant tracking" +``` + +--- + +## Task 2: SnapReceiver stub + +**Files:** +- Create: `scripts/characters/snap_receiver.gd` + +The SnapReceiver script must exist before `Character.tscn` can reference it. Full implementation comes in Task 7. + +- [ ] **Step 1: Create the stub** + +Create `scripts/characters/snap_receiver.gd`: + +```gdscript +## SnapReceiver — scans for nearby SnapPoints when the parent Character is released. +## Attach as child of Character. Full implementation connects to DragDropComponent signals. +class_name SnapReceiver extends Node + +const SCAN_RADIUS: float = 80.0 + +var _current_snap: SnapPoint = null +var _character: Character + + +func _ready() -> void: + _character = get_parent() as Character + + +func get_current_snap() -> SnapPoint: + return _current_snap + + +func force_unsnap() -> void: + if _current_snap != null: + _current_snap.unsnap() + _current_snap = null +``` + +- [ ] **Step 2: Run tests — verify 52 still 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 2>&1 +``` + +Expected: 52/52 passed. No new failures. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/characters/snap_receiver.gd +git commit -m "feat(snap-receiver): add SnapReceiver stub (full implementation in sprint-15 task 7)" +``` + +--- + +## Task 3: Character.tscn — add new child nodes + +**Files:** +- Modify: `scenes/characters/Character.tscn` +- Create: `test/unit/test_character_v2.gd` (partial — node existence tests only for now) + +- [ ] **Step 1: Write node-existence tests** + +Create `test/unit/test_character_v2.gd` with only the node-existence tests for now: + +```gdscript +## Tests for Character System v2 — animation state, outfit layers, hand slots. +extends GutTest + +var _char: Character + + +func before_each() -> void: + _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(_char) + var cd: CharacterData = CharacterData.new() + _char.data = cd + + +func test_animated_sprite_node_exists() -> void: + assert_not_null(_char.get_node_or_null("AnimatedSprite2D")) + + +func test_outfit_layer_1_node_exists() -> void: + assert_not_null(_char.get_node_or_null("OutfitLayer1")) + + +func test_outfit_layer_2_node_exists() -> void: + assert_not_null(_char.get_node_or_null("OutfitLayer2")) + + +func test_outfit_layer_3_node_exists() -> void: + assert_not_null(_char.get_node_or_null("OutfitLayer3")) + + +func test_hand_left_node_exists() -> void: + assert_not_null(_char.get_node_or_null("HandLeft")) + + +func test_hand_right_node_exists() -> void: + assert_not_null(_char.get_node_or_null("HandRight")) + + +func test_snap_receiver_node_exists() -> void: + assert_not_null(_char.get_node_or_null("SnapReceiver")) +``` + +- [ ] **Step 2: Run tests — verify they 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 2>&1 +``` + +Expected: 7 new failures (nodes not in scene yet). 52 existing pass. + +- [ ] **Step 3: Update `scenes/characters/Character.tscn`** + +Read the file first (`F:/Development/_gameDev/Cozypaw-Hospital/scenes/characters/Character.tscn`), then make these changes: + +Change the header line: +``` +[gd_scene load_steps=4 format=3 uid="uid://cozypaw_char"] +``` +to: +``` +[gd_scene load_steps=5 format=3 uid="uid://cozypaw_char"] +``` + +After the line `[ext_resource type="Script" path="res://scripts/systems/drag_drop_component.gd" id="2_drag"]`, add: +``` +[ext_resource type="Script" path="res://scripts/characters/snap_receiver.gd" id="3_snap_recv"] +``` + +Append at the end of the file (after the last `[node ...]` block): +``` +[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."] +visible = false + +[node name="OutfitLayer1" type="Sprite2D" parent="."] +position = Vector2(0, -40) +visible = false + +[node name="OutfitLayer2" type="Sprite2D" parent="."] +position = Vector2(0, -40) +visible = false + +[node name="OutfitLayer3" type="Sprite2D" parent="."] +position = Vector2(0, -40) +visible = false + +[node name="HandLeft" type="Node2D" parent="."] +position = Vector2(-32, -30) + +[node name="HandRight" type="Node2D" parent="."] +position = Vector2(32, -30) + +[node name="SnapReceiver" type="Node" parent="."] +script = ExtResource("3_snap_recv") +``` + +- [ ] **Step 4: Run tests — verify 7 node-existence tests now 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 2>&1 +``` + +Expected: 59/59 passed (52 + 7 node-existence). + +- [ ] **Step 5: Commit** + +```bash +git add scenes/characters/Character.tscn test/unit/test_character_v2.gd +git commit -m "feat(character): add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver to Character scene" +``` + +--- + +## Task 4: CharacterData outfit field + +**Files:** +- Modify: `scripts/characters/character_data.gd` + +- [ ] **Step 1: Add outfit test to `test/unit/test_character_v2.gd`** + +Append this function to the file (inside the class, before the final empty line): + +```gdscript +func test_character_data_outfit_has_three_empty_slots() -> void: + assert_eq(_char.data.outfit.size(), 3) + assert_eq(_char.data.outfit[0], "") + assert_eq(_char.data.outfit[1], "") + assert_eq(_char.data.outfit[2], "") +``` + +- [ ] **Step 2: Run test — verify it FAILS** + +``` +"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 2>&1 +``` + +Expected: 1 failure (outfit field does not exist on CharacterData). + +- [ ] **Step 3: Update `scripts/characters/character_data.gd`** + +Replace the entire file with: + +```gdscript +## CharacterData — Resource holding all persistent state for one character. +class_name CharacterData extends Resource + +enum State { HEALTHY, SICK, SLEEPING, TIRED, PREGNANT, BABY } +enum Species { BUNNY, KITTEN } + +@export var id: String = "" +@export var display_name: String = "" +@export var species: Species = Species.BUNNY +@export var state: State = State.HEALTHY +@export var current_floor: int = 0 +@export var position: Vector2 = Vector2.ZERO +@export var outfit: Array[String] = ["", "", ""] +``` + +- [ ] **Step 4: Run tests — verify new test PASSES, all 60 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 2>&1 +``` + +Expected: 60/60 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/characters/character_data.gd test/unit/test_character_v2.gd +git commit -m "feat(character-data): add outfit array field for three outfit layer slots" +``` + +--- + +## Task 5: Character animation state API + +**Files:** +- Modify: `scripts/characters/character.gd` +- Modify: `test/unit/test_character_v2.gd` + +- [ ] **Step 1: Add animation state tests to `test/unit/test_character_v2.gd`** + +Append these functions: + +```gdscript +func test_default_animation_state_is_idle() -> void: + assert_eq(_char.get_animation_state(), "idle") + + +func test_set_animation_state_sitting() -> void: + _char.set_animation_state("sitting") + assert_eq(_char.get_animation_state(), "sitting") + + +func test_set_animation_state_lying() -> void: + _char.set_animation_state("lying") + assert_eq(_char.get_animation_state(), "lying") + + +func test_set_animation_state_held() -> void: + _char.set_animation_state("held") + assert_eq(_char.get_animation_state(), "held") + + +func test_set_animation_state_happy() -> void: + _char.set_animation_state("happy") + assert_eq(_char.get_animation_state(), "happy") + + +func test_set_animation_state_sleeping() -> void: + _char.set_animation_state("sleeping") + assert_eq(_char.get_animation_state(), "sleeping") +``` + +- [ ] **Step 2: Run tests — verify 6 new tests 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 2>&1 +``` + +Expected: 6 failures (methods don't exist yet). 60 existing pass. + +- [ ] **Step 3: Add animation state API to `scripts/characters/character.gd`** + +Add `var _current_anim: String = "idle"` after the existing `var _is_held: bool = false` line. + +Add these two methods after `set_state()`: + +```gdscript +func set_animation_state(anim: String) -> void: + _current_anim = anim + var sprite: AnimatedSprite2D = get_node_or_null("AnimatedSprite2D") as AnimatedSprite2D + if sprite == null or sprite.sprite_frames == null: + return + if sprite.sprite_frames.has_animation(anim): + sprite.play(anim) + + +func get_animation_state() -> String: + return _current_anim +``` + +Also update `_on_drag_picked_up` to set animation: + +```gdscript +func _on_drag_picked_up(_pos: Vector2) -> void: + _is_held = true + set_animation_state("held") + character_picked_up.emit(self) +``` + +And update `_on_drag_released`: + +```gdscript +func _on_drag_released(pos: Vector2) -> void: + _is_held = false + 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 4: Run tests — verify 6 new tests PASS, 66 total** + +``` +"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 2>&1 +``` + +Expected: 66/66 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/characters/character.gd test/unit/test_character_v2.gd +git commit -m "feat(character): add animation state API (set/get_animation_state)" +``` + +--- + +## Task 6: Character outfit layer API + +**Files:** +- Modify: `scripts/characters/character.gd` +- Modify: `test/unit/test_character_v2.gd` + +- [ ] **Step 1: Add outfit layer tests to `test/unit/test_character_v2.gd`** + +Append these functions: + +```gdscript +func test_get_outfit_returns_empty_for_all_layers_initially() -> void: + assert_eq(_char.get_outfit(1), "") + assert_eq(_char.get_outfit(2), "") + assert_eq(_char.get_outfit(3), "") + + +func test_set_outfit_stores_item_id() -> void: + _char.set_outfit(1, "white_coat", null) + assert_eq(_char.get_outfit(1), "white_coat") + + +func test_set_outfit_does_not_affect_other_layers() -> void: + _char.set_outfit(1, "white_coat", null) + assert_eq(_char.get_outfit(2), "") + assert_eq(_char.get_outfit(3), "") + + +func test_clear_outfit_returns_item_id() -> void: + _char.set_outfit(2, "cast_arm", null) + var returned: String = _char.clear_outfit(2) + assert_eq(returned, "cast_arm") + + +func test_clear_outfit_empties_layer() -> void: + _char.set_outfit(2, "cast_arm", null) + _char.clear_outfit(2) + assert_eq(_char.get_outfit(2), "") + + +func test_set_outfit_invalid_layer_zero_is_noop() -> void: + _char.set_outfit(0, "white_coat", null) + assert_eq(_char.get_outfit(1), "") + + +func test_set_outfit_invalid_layer_four_is_noop() -> void: + _char.set_outfit(4, "white_coat", null) + assert_eq(_char.get_outfit(3), "") +``` + +- [ ] **Step 2: Run tests — verify 7 new tests 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 2>&1 +``` + +Expected: 7 failures. 66 existing pass. + +- [ ] **Step 3: Add outfit API to `scripts/characters/character.gd`** + +Add these three methods after `get_animation_state()`: + +```gdscript +func set_outfit(layer: int, item_id: String, texture: Texture2D) -> void: + if layer < 1 or layer > 3: + return + if data != null: + data.outfit[layer - 1] = item_id + var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D + if layer_node == null: + return + layer_node.texture = texture + layer_node.visible = not item_id.is_empty() + + +func clear_outfit(layer: int) -> String: + if layer < 1 or layer > 3: + return "" + var old_id: String = "" + if data != null: + old_id = data.outfit[layer - 1] + data.outfit[layer - 1] = "" + var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D + if layer_node != null: + layer_node.texture = null + layer_node.visible = false + return old_id + + +func get_outfit(layer: int) -> String: + if data == null or layer < 1 or layer > 3: + return "" + return data.outfit[layer - 1] +``` + +Also add `_refresh_outfit_layers()` call in `_ready()`, just after `_update_visual_state()`: + +```gdscript +func _ready() -> void: + var drag: DragDropComponent = get_node_or_null("DragDropComponent") as DragDropComponent + if drag != null: + drag.drag_picked_up.connect(_on_drag_picked_up) + drag.drag_released.connect(_on_drag_released) + if data != null: + _update_visual_state() + _refresh_outfit_layers() +``` + +Add `_refresh_outfit_layers()` helper at the bottom of the file: + +```gdscript +func _refresh_outfit_layers() -> void: + if data == null: + return + for i: int in range(3): + var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % (i + 1)) as Sprite2D + if layer_node != null: + layer_node.visible = not data.outfit[i].is_empty() +``` + +- [ ] **Step 4: Run tests — verify 7 new tests PASS, 73 total** + +``` +"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 2>&1 +``` + +Expected: 73/73 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/characters/character.gd test/unit/test_character_v2.gd +git commit -m "feat(character): add outfit layer API (set/clear/get_outfit per layer 1-3)" +``` + +--- + +## Task 7: Character hand slot API + +**Files:** +- Modify: `scripts/characters/character.gd` +- Modify: `test/unit/test_character_v2.gd` + +- [ ] **Step 1: Add hand slot tests to `test/unit/test_character_v2.gd`** + +Append these functions: + +```gdscript +func test_both_hands_free_initially() -> void: + assert_true(_char.is_hand_free("left")) + assert_true(_char.is_hand_free("right")) + + +func test_attach_item_to_left_hand() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("left", item) + assert_false(_char.is_hand_free("left")) + + +func test_get_held_item_returns_attached_item() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("right", item) + assert_eq(_char.get_held_item("right"), item) + + +func test_attach_to_occupied_hand_returns_false() -> void: + var item1: Node2D = Node2D.new() + var item2: Node2D = Node2D.new() + add_child_autofree(item1) + add_child_autofree(item2) + _char.attach_item("left", item1) + var result: bool = _char.attach_item("left", item2) + assert_false(result) + + +func test_attach_returns_true_on_success() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + var result: bool = _char.attach_item("right", item) + assert_true(result) + + +func test_detach_item_frees_hand() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("left", item) + _char.detach_item("left") + assert_true(_char.is_hand_free("left")) + + +func test_detach_returns_item() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("right", item) + var returned: Node2D = _char.detach_item("right") + assert_eq(returned, item) + + +func test_detach_from_empty_hand_returns_null() -> void: + var returned: Node2D = _char.detach_item("left") + assert_null(returned) +``` + +- [ ] **Step 2: Run tests — verify 8 new tests 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 2>&1 +``` + +Expected: 8 failures. 73 existing pass. + +- [ ] **Step 3: Add hand slot API to `scripts/characters/character.gd`** + +Add these four methods after `get_outfit()`: + +```gdscript +func attach_item(hand: String, item: Node2D) -> bool: + if hand != "left" and hand != "right": + return false + var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D + if slot == null: + return false + if slot.get_child_count() > 0: + return false + var old_parent: Node = item.get_parent() + if old_parent != null: + old_parent.remove_child(item) + slot.add_child(item) + item.position = Vector2.ZERO + return true + + +func detach_item(hand: String) -> Node2D: + if hand != "left" and hand != "right": + return null + var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D + if slot == null or slot.get_child_count() == 0: + return null + var item: Node2D = slot.get_child(0) as Node2D + slot.remove_child(item) + var scene_parent: Node = get_parent() + if scene_parent != null: + scene_parent.add_child(item) + return item + + +func get_held_item(hand: String) -> Node2D: + if hand != "left" and hand != "right": + return null + var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D + if slot == null or slot.get_child_count() == 0: + return null + return slot.get_child(0) as Node2D + + +func is_hand_free(hand: String) -> bool: + return get_held_item(hand) == null +``` + +- [ ] **Step 4: Run tests — verify 8 new tests PASS, 81 total** + +``` +"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 2>&1 +``` + +Expected: 81/81 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/characters/character.gd test/unit/test_character_v2.gd +git commit -m "feat(character): add hand slot API (attach/detach/get_held_item/is_hand_free)" +``` + +--- + +## Task 8: SnapReceiver full implementation + tests + +**Files:** +- Modify: `scripts/characters/snap_receiver.gd` +- Create: `test/unit/test_snap_receiver.gd` + +- [ ] **Step 1: Write the SnapReceiver tests** + +Create `test/unit/test_snap_receiver.gd`: + +```gdscript +## Tests for SnapReceiver — snap detection when Character is released near a SnapPoint. +extends GutTest + +var _char: Character +var _snap: SnapPoint + + +func before_each() -> void: + _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(_char) + _snap = SnapPoint.new() + _snap.pose = "sitting" + add_child_autofree(_snap) + + +func _get_receiver() -> SnapReceiver: + return _char.get_node("SnapReceiver") as SnapReceiver + + +func test_snap_receiver_exists_on_character() -> void: + assert_not_null(_get_receiver()) + + +func test_no_current_snap_initially() -> void: + assert_null(_get_receiver().get_current_snap()) + + +func test_snap_detected_when_character_released_within_radius() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + assert_eq(_get_receiver().get_current_snap(), _snap) + + +func test_no_snap_when_released_outside_radius() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(200.0, 0.0) + _get_receiver()._on_drag_released(Vector2(200.0, 0.0)) + assert_null(_get_receiver().get_current_snap()) + + +func test_character_position_set_to_snap_point_on_snap() -> void: + _snap.global_position = Vector2(100.0, 100.0) + _char.global_position = Vector2(120.0, 100.0) + _get_receiver()._on_drag_released(Vector2(120.0, 100.0)) + assert_eq(_char.global_position, Vector2(100.0, 100.0)) + + +func test_character_animation_set_to_snap_pose_on_snap() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _snap.pose = "lying" + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + assert_eq(_char.get_animation_state(), "lying") + + +func test_pickup_clears_current_snap() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + _get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0)) + assert_null(_get_receiver().get_current_snap()) + + +func test_pickup_frees_snap_point() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + _get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0)) + assert_true(_snap.is_free()) + + +func test_second_character_cannot_snap_to_occupied_point() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + var char2: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(char2) + char2.global_position = Vector2(60.0, 0.0) + var recv2: SnapReceiver = char2.get_node("SnapReceiver") as SnapReceiver + recv2._on_drag_released(Vector2(60.0, 0.0)) + assert_null(recv2.get_current_snap()) +``` + +- [ ] **Step 2: Run tests — verify 8 new tests FAIL (most of them)** + +``` +"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 2>&1 +``` + +Expected: several failures for snap detection tests. `test_snap_receiver_exists_on_character` may pass already. 81 existing tests pass. + +- [ ] **Step 3: Implement full `scripts/characters/snap_receiver.gd`** + +Replace the entire file: + +```gdscript +## SnapReceiver — scans for nearby SnapPoints when the parent Character is released. +## Attach as child of Character. Connects automatically to DragDropComponent signals. +class_name SnapReceiver extends Node + +const SCAN_RADIUS: float = 80.0 + +var _current_snap: SnapPoint = null +var _character: Character + + +func _ready() -> void: + _character = get_parent() as Character + var drag: DragDropComponent = _character.get_node_or_null("DragDropComponent") as DragDropComponent + if drag != null: + drag.drag_picked_up.connect(_on_drag_picked_up) + drag.drag_released.connect(_on_drag_released) + + +func _on_drag_picked_up(_pos: Vector2) -> void: + if _current_snap != null: + _current_snap.unsnap() + _current_snap = null + _character.set_animation_state("held") + + +func _on_drag_released(_pos: Vector2) -> void: + var nearest: SnapPoint = _find_nearest_accepting_snap() + if nearest != null: + _current_snap = nearest + nearest.snap(_character) + _character.global_position = nearest.global_position + _character.set_animation_state(nearest.pose) + else: + _character.set_animation_state("idle") + + +func _find_nearest_accepting_snap() -> SnapPoint: + var best: SnapPoint = null + var best_dist: float = SCAN_RADIUS + for node: Node in get_tree().get_nodes_in_group("snap_points"): + var snap_point: SnapPoint = node as SnapPoint + if snap_point == null: + continue + if not snap_point.accepts(_character): + continue + var dist: float = _character.global_position.distance_to(snap_point.global_position) + if dist < best_dist: + best_dist = dist + best = snap_point + return best + + +func get_current_snap() -> SnapPoint: + return _current_snap + + +func force_unsnap() -> void: + if _current_snap != null: + _current_snap.unsnap() + _current_snap = null + _character.set_animation_state("idle") +``` + +- [ ] **Step 4: Run all tests — verify 8 new tests PASS, 89 total** + +``` +"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 2>&1 +``` + +Expected: 89/89 passed. 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/characters/snap_receiver.gd test/unit/test_snap_receiver.gd +git commit -m "feat(snap-receiver): implement snap detection, position snapping, and pose animation trigger" +``` + +--- + +## Final Check + +- [ ] **Run full test suite one last time** + +``` +"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 2>&1 +``` + +Expected output: +``` +Scripts 8 +Tests 89 +Passing Tests 89 +Asserts (varies) +---- All tests passed! ---- +``` + +- [ ] **Verify git log shows 7 clean commits** + +```bash +git log --oneline -8 +``` + +Expected (most recent first): +``` +feat(snap-receiver): implement snap detection, position snapping, and pose animation trigger +feat(character): add hand slot API (attach/detach/get_held_item/is_hand_free) +feat(character): add outfit layer API (set/clear/get_outfit per layer 1-3) +feat(character): add animation state API (set/get_animation_state) +feat(character-data): add outfit array field for three outfit layer slots +feat(character): add AnimatedSprite2D, OutfitLayers, HandSlots, SnapReceiver to Character scene +feat(snap-receiver): add SnapReceiver stub (full implementation in sprint-15 task 7) +feat(snap-point): add SnapPoint node with pose, baby_only filter, and occupant tracking +``` diff --git a/docs/superpowers/plans/2026-05-10-sprint-21-object-sfx.md b/docs/superpowers/plans/2026-05-10-sprint-21-object-sfx.md new file mode 100644 index 0000000..1c34f23 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-sprint-21-object-sfx.md @@ -0,0 +1,291 @@ +# Sprint 21 — Interactive Object 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 `AudioManager.play_sfx()` calls to all 6 tappable interactive objects, add 7 new SFX keys to `AudioManager._SFX_MAP`, and create placeholder audio files for each. + +**Architecture:** Single `AudioManager.play_sfx("key")` call per trigger method, before the tween. New keys added to the existing `_SFX_MAP` constant. Placeholder 0-byte `.ogg` files committed so `ResourceLoader.exists()` returns true; `AudioServer.get_driver_name() == "Dummy"` guard in `play_sfx()` ensures headless tests don't crash on empty files. + +**Tech Stack:** GDScript 4 (static types), GUT v9.6.0. + +--- + +### Task 1: Create 7 placeholder SFX files + update audio asset docs + +**Files:** +- Create: `assets/audio/sfx/xray_scan.ogg` +- Create: `assets/audio/sfx/tea_pour.ogg` +- Create: `assets/audio/sfx/cradle_rock.ogg` +- Create: `assets/audio/sfx/gift_open.ogg` +- Create: `assets/audio/sfx/ambulance_siren.ogg` +- Create: `assets/audio/sfx/delivery_cheer.ogg` +- Create: `assets/audio/sfx/object_tap.ogg` +- Modify: `docs/audio-assets-sprint19.md` + +- [ ] **Step 1: Create 7 empty placeholder files** + +```bash +New-Item -ItemType File "assets/audio/sfx/xray_scan.ogg" -Force +New-Item -ItemType File "assets/audio/sfx/tea_pour.ogg" -Force +New-Item -ItemType File "assets/audio/sfx/cradle_rock.ogg" -Force +New-Item -ItemType File "assets/audio/sfx/gift_open.ogg" -Force +New-Item -ItemType File "assets/audio/sfx/ambulance_siren.ogg" -Force +New-Item -ItemType File "assets/audio/sfx/delivery_cheer.ogg" -Force +New-Item -ItemType File "assets/audio/sfx/object_tap.ogg" -Force +``` + +All 0-byte. `ResourceLoader.exists()` returns true for these; `load()` is never called in headless mode due to the AudioServer Dummy guard already present in `play_sfx()`. + +- [ ] **Step 2: Append Sprint 21 entries to `docs/audio-assets-sprint19.md`** + +Append to the end of the existing file: + +```markdown + +## Sprint 21 — Interactive Object 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/xray_scan.ogg` | electrical hum / machine beep | search "xray machine beep" or "electrical hum short" | +| `assets/audio/sfx/tea_pour.ogg` | liquid pouring | search "liquid pour short" or "tea pouring" | +| `assets/audio/sfx/cradle_rock.ogg` | gentle creak / lullaby chime | search "gentle creak wood" or "lullaby chime" | +| `assets/audio/sfx/gift_open.ogg` | unwrapping / pop | search "gift unwrap" or "pop sound soft" | +| `assets/audio/sfx/ambulance_siren.ogg` | short siren sting <1.5s child-friendly | search "toy siren short" or "ambulance beep" | +| `assets/audio/sfx/delivery_cheer.ogg` | happy chime / fanfare | search "happy chime short" or "fanfare child" | +| `assets/audio/sfx/object_tap.ogg` | soft tap / click | search "soft tap" or "gentle click" | + +All files must be <1.5 s, child-friendly (no harsh/loud sounds), mono or stereo, 44100 Hz, OGG Vorbis. +``` + +- [ ] **Step 3: Commit** + +```bash +git add assets/audio/sfx/xray_scan.ogg assets/audio/sfx/tea_pour.ogg assets/audio/sfx/cradle_rock.ogg assets/audio/sfx/gift_open.ogg assets/audio/sfx/ambulance_siren.ogg assets/audio/sfx/delivery_cheer.ogg assets/audio/sfx/object_tap.ogg docs/audio-assets-sprint19.md +git commit -m "assets(sfx): add sprint-21 interactive object SFX placeholders" +``` + +--- + +### Task 2: Add 7 new 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_interactive_object_keys() -> void: + assert_true(AudioManager._SFX_MAP.has("xray_scan")) + assert_true(AudioManager._SFX_MAP.has("tea_pour")) + assert_true(AudioManager._SFX_MAP.has("cradle_rock")) + assert_true(AudioManager._SFX_MAP.has("gift_open")) + assert_true(AudioManager._SFX_MAP.has("ambulance_siren")) + assert_true(AudioManager._SFX_MAP.has("delivery_cheer")) + assert_true(AudioManager._SFX_MAP.has("object_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-21-object-sfx" +``` + +Expected: `test_sfx_map_has_all_interactive_object_keys` fails — keys not in `_SFX_MAP`. + +- [ ] **Step 3: Add 7 new keys to AudioManager._SFX_MAP** + +In `scripts/autoload/AudioManager.gd`, replace the `_SFX_MAP` constant: + +```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", +} +``` + +- [ ] **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-21-object-sfx" +``` + +Expected: all previous tests plus `test_sfx_map_has_all_interactive_object_keys` pass. Total ≥ 221. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/autoload/AudioManager.gd test/unit/test_audio_manager.gd +git commit -m "feat(sfx): add interactive object SFX keys to AudioManager._SFX_MAP" +``` + +--- + +### Task 3: Wire play_sfx() into 6 interactive object scripts + base class + +**Files:** +- Modify: `scripts/objects/interactive_object.gd` +- Modify: `scripts/objects/xray_machine.gd` +- Modify: `scripts/objects/tea_pot.gd` +- Modify: `scripts/objects/cradle.gd` +- Modify: `scripts/objects/gift_box.gd` +- Modify: `scripts/objects/ambulance.gd` +- Modify: `scripts/objects/delivery_bed.gd` + +No new tests — these are single-line wiring calls. The AudioManager Dummy-driver guard makes headless tests safe (no crash on empty .ogg files). The existing test suite verifies nothing regresses. + +- [ ] **Step 1: Add object_tap to interactive_object.gd** + +In `scripts/objects/interactive_object.gd`, in `_trigger_interaction()`, add `AudioManager.play_sfx("object_tap")` as the first line: + +```gdscript +func _trigger_interaction() -> void: + AudioManager.play_sfx("object_tap") + _set_state(State.ACTIVE) + object_interacted.emit(self) + GameState.set_object_state(object_id, "active") + _play_bounce_animation() +``` + +- [ ] **Step 2: Add xray_scan to xray_machine.gd** + +In `scripts/objects/xray_machine.gd`, in `_start_scan()`, add `AudioManager.play_sfx("xray_scan")` as the first line: + +```gdscript +func _start_scan() -> void: + AudioManager.play_sfx("xray_scan") + _state = State.SLIDING_IN + var plate: Node2D = get_node_or_null("Plate") as Node2D + if plate == null: + _state = State.IDLE + return + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_IN_OUT) + tween.set_trans(Tween.TRANS_QUAD) + tween.tween_property(plate, "position:x", PLATE_ACTIVE_X, SLIDE_DURATION) + tween.finished.connect(_on_plate_in) +``` + +- [ ] **Step 3: Add tea_pour to tea_pot.gd** + +In `scripts/objects/tea_pot.gd`, in `_start_pouring()`, add `AudioManager.play_sfx("tea_pour")` as the first line: + +```gdscript +func _start_pouring() -> void: + AudioManager.play_sfx("tea_pour") + _state = State.POURING + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_IN_OUT) + tween.set_trans(Tween.TRANS_SINE) + tween.tween_property(self, "rotation_degrees", TILT_ANGLE, TILT_DURATION) + tween.tween_interval(POUR_HOLD) + tween.tween_property(self, "rotation_degrees", 0.0, RETURN_DURATION) + tween.finished.connect(func() -> void: _state = State.IDLE) +``` + +- [ ] **Step 4: Add cradle_rock to cradle.gd** + +In `scripts/objects/cradle.gd`, in `_start_rocking()`, add `AudioManager.play_sfx("cradle_rock")` as the first line: + +```gdscript +func _start_rocking() -> void: + AudioManager.play_sfx("cradle_rock") + _state = State.ROCKING + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_IN_OUT) + tween.set_trans(Tween.TRANS_SINE) + tween.tween_property(self, "rotation_degrees", ROCK_ANGLE, ROCK_DURATION) + tween.tween_property(self, "rotation_degrees", -ROCK_ANGLE, ROCK_DURATION) + tween.tween_property(self, "rotation_degrees", 0.0, ROCK_DURATION * 0.5) + tween.finished.connect(func() -> void: _state = State.IDLE) +``` + +- [ ] **Step 5: Add gift_open to gift_box.gd** + +In `scripts/objects/gift_box.gd`, in `_start_opening()`, add `AudioManager.play_sfx("gift_open")` as the first line: + +```gdscript +func _start_opening() -> void: + AudioManager.play_sfx("gift_open") + _state = State.OPENING + var lid: Node2D = get_node_or_null("Lid") as Node2D + if lid == null: + _state = State.OPEN + return + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_OUT) + tween.set_trans(Tween.TRANS_BACK) + tween.tween_property(lid, "position:y", LID_OPEN_Y, OPEN_DURATION) + tween.parallel().tween_property(lid, "modulate:a", 0.0, OPEN_DURATION) + tween.finished.connect(_on_lid_opened) +``` + +- [ ] **Step 6: Add ambulance_siren to ambulance.gd** + +In `scripts/objects/ambulance.gd`, in `_drive_in()`, add `AudioManager.play_sfx("ambulance_siren")` as the first line: + +```gdscript +func _drive_in() -> void: + AudioManager.play_sfx("ambulance_siren") + _is_animating = true + _is_parked = false + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_OUT) + tween.set_trans(Tween.TRANS_QUAD) + tween.tween_property(self, "position:x", _parked_x, DRIVE_DURATION) + tween.finished.connect(func() -> void: + _is_parked = true + _is_animating = false + _play_stop_bounce() + ) +``` + +- [ ] **Step 7: Add delivery_cheer to delivery_bed.gd** + +In `scripts/objects/delivery_bed.gd`, in `_start_arrival()`, add `AudioManager.play_sfx("delivery_cheer")` as the first line: + +```gdscript +func _start_arrival() -> void: + AudioManager.play_sfx("delivery_cheer") + _state = State.MAMA_ARRIVING + var mama: Node2D = get_node_or_null("Mama") as Node2D + if mama == null: + _state = State.IDLE + return + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_OUT) + tween.set_trans(Tween.TRANS_QUAD) + tween.tween_property(mama, "position:x", MAMA_PARKED_X, ARRIVE_DURATION) + tween.finished.connect(_on_mama_arrived) +``` + +- [ ] **Step 8: 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-21-object-sfx" +``` + +Expected: all tests pass. Total ≥ 221. + +- [ ] **Step 9: Commit** + +```bash +git add scripts/objects/interactive_object.gd scripts/objects/xray_machine.gd scripts/objects/tea_pot.gd scripts/objects/cradle.gd scripts/objects/gift_box.gd scripts/objects/ambulance.gd scripts/objects/delivery_bed.gd +git commit -m "feat(sfx): wire interactive object SFX to AudioManager.play_sfx" +``` diff --git a/docs/superpowers/plans/2026-05-10-sprint-22-character-ambient-sfx.md b/docs/superpowers/plans/2026-05-10-sprint-22-character-ambient-sfx.md new file mode 100644 index 0000000..5d518b0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-sprint-22-character-ambient-sfx.md @@ -0,0 +1,299 @@ +# 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" +``` diff --git a/docs/superpowers/specs/2026-05-10-sprint-21-object-sfx.md b/docs/superpowers/specs/2026-05-10-sprint-21-object-sfx.md new file mode 100644 index 0000000..fb9ffb2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-sprint-21-object-sfx.md @@ -0,0 +1,61 @@ +# Sprint 21 — Interactive Object SFX Design Spec + +## Goal + +Add SFX to all 6 tappable interactive objects. Each object makes a sound when its animation starts — XRay scan, TeaPot pour, Cradle rock, GiftBox open, Ambulance drive-in, DeliveryBed mama arrival. UltrasoundMachine is excluded (continuous auto-loop, not tap-triggered). + +## New SFX Events + +7 new events added to `AudioManager._SFX_MAP`: + +| Event key | Object | Trigger | +|---|---|---| +| `xray_scan` | XRayMachine | `_start_scan()` | +| `tea_pour` | TeaPot | `_start_pouring()` | +| `cradle_rock` | Cradle | `_start_rocking()` | +| `gift_open` | GiftBox | `_start_opening()` | +| `ambulance_siren` | Ambulance | `_drive_in()` | +| `delivery_cheer` | DeliveryBed | mama arrives (`_start_mama_arriving()`) | +| `object_tap` | Generic fallback | any tap on InteractiveObject base | + +## Asset Specification + +New files: + +``` +assets/audio/sfx/xray_scan.ogg — electrical hum / machine beep +assets/audio/sfx/tea_pour.ogg — liquid pouring +assets/audio/sfx/cradle_rock.ogg — gentle creak / lullaby chime +assets/audio/sfx/gift_open.ogg — unwrapping / pop +assets/audio/sfx/ambulance_siren.ogg — short siren sting (<1.5s, child-friendly) +assets/audio/sfx/delivery_cheer.ogg — happy chime / fanfare +assets/audio/sfx/object_tap.ogg — soft tap / click +``` + +All CC0 or CC-BY from freesound.org. Placeholder 0-byte files committed; real downloads documented in `docs/audio-assets-sprint19.md` (extend existing file). + +## Integration Points + +Each is a single `AudioManager.play_sfx("key")` call at the start of the action: + +- `xray_machine.gd` → in `_start_scan()`, before tween +- `tea_pot.gd` → in `_start_pouring()`, before tween +- `cradle.gd` → in `_start_rocking()`, before tween +- `gift_box.gd` → in `_start_opening()` (the method that starts the animation) +- `ambulance.gd` → in the drive-in animation method, before tween +- `delivery_bed.gd` → when mama starts arriving, before tween +- `interactive_object.gd` → in the base tap handler if one exists (object_tap) + +## Testing + +Append to `test/unit/test_audio_manager.gd`: + +- `test_sfx_map_has_all_interactive_object_keys` — verifies all 7 new keys exist in `_SFX_MAP` + +No per-object unit tests for SFX wiring — the calls are single-line, and the AudioManager Dummy-driver guard makes headless tests safe. + +## Out of Scope + +- UltrasoundMachine heartbeat sound (continuous loop, separate sprint) +- Character reaction sounds (Häschen/Kätzchen — separate sprint) +- Per-state-transition sounds (e.g., XRay completion sound) diff --git a/docs/superpowers/specs/2026-05-10-sprint-22-character-ambient-sfx.md b/docs/superpowers/specs/2026-05-10-sprint-22-character-ambient-sfx.md new file mode 100644 index 0000000..866e72b --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-sprint-22-character-ambient-sfx.md @@ -0,0 +1,70 @@ +# Sprint 22 — Character & Ambient SFX Design Spec + +## Goal + +Two deferred SFX items from Sprint 21: + +1. **UltrasoundMachine ambient heartbeat** — continuous looping audio that starts when the player enters the ultrasound room and stops when they leave. +2. **Character SFX** — pickup, place, and tap sounds wired into `character.gd`. + +## New SFX Events + +### AudioManager._SFX_MAP additions (3 new keys) + +| Event key | Trigger | +|---|---| +| `character_pickup` | `Character._on_drag_picked_up()` | +| `character_place` | `Character._on_drag_released()` — drag distance ≥ tap threshold | +| `character_tap` | `Character._on_drag_released()` — drag distance < tap threshold | + +### UltrasoundMachine (self-managed, not via _SFX_MAP) + +The heartbeat is a looping ambient sound owned by the `UltrasoundMachine` node itself. It does not go through `AudioManager.play_sfx()` — that path is for one-shot SFX. Instead, `UltrasoundMachine` creates its own `AudioStreamPlayer` child, sets `stream.loop = true` at runtime, and starts/stops it in response to `RoomNavigator.room_changed`. + +## Asset Specification + +New files: + +``` +assets/audio/sfx/ultrasound_heartbeat.ogg — soft beep/blip, ~1s, loops seamlessly +assets/audio/sfx/character_pickup.ogg — happy soft squeak / whoosh +assets/audio/sfx/character_place.ogg — gentle thud / landing +assets/audio/sfx/character_tap.ogg — short happy chime / pop +``` + +All CC0 or CC-BY from freesound.org. Placeholder 0-byte files committed; real downloads documented in `docs/audio-assets-sprint19.md` (extend existing file). + +## Integration Points + +### UltrasoundMachine (`scripts/objects/ultrasound_machine.gd`) + +Full replacement. New behaviour: + +- `@export var trigger_floor: int = 2` and `@export var trigger_room: int = 0` (matches ultrasound room position in `_ROOM_NAMES`) +- In `_ready()`: create `AudioStreamPlayer` child, load stream with `loop = true`, connect to `RoomNavigator.room_changed` +- In `_on_room_changed(floor_index, room_index)`: if matches trigger position → `_audio.play()`, else → `_audio.stop()` +- `AudioServer.get_driver_name() == "Dummy"` guard wraps all audio operations +- `_exit_tree()`: disconnect signal (mirrors Ambulance pattern) +- Volume is inherited from the bus (no explicit volume set — ambient heartbeat is soft by design) + +### Character (`scripts/characters/character.gd`) + +Three one-liner additions: + +- `_on_drag_picked_up()` → `AudioManager.play_sfx("character_pickup")` as first line +- `_on_drag_released()` tap branch → `AudioManager.play_sfx("character_tap")` before `_handle_outfit_tap()` +- `_on_drag_released()` place branch → `AudioManager.play_sfx("character_place")` before `character_placed.emit()` + +## Testing + +Append to `test/unit/test_audio_manager.gd`: + +- `test_sfx_map_has_all_character_keys` — verifies `character_pickup`, `character_place`, `character_tap` exist in `_SFX_MAP` + +No unit test for UltrasoundMachine audio start/stop — the trigger is room-navigation-driven and mirrors the Ambulance pattern (which also has no per-SFX unit test). + +## Out of Scope + +- Per-state-transition character sounds (e.g., happy sound when healed — separate sprint) +- Room-specific ambient audio for other rooms +- UltrasoundMachine volume linked to `GameState.sfx_volume` (ambient bus handles this via AudioServer)