diff --git a/docs/superpowers/plans/2026-05-11-sprint-14-garden-party.md b/docs/superpowers/plans/2026-05-11-sprint-14-garden-party.md new file mode 100644 index 0000000..763dede --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-sprint-14-garden-party.md @@ -0,0 +1,635 @@ +# Sprint 14 — Garden Party 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:** Complete the GardenParty scene with GiftBox auto-reset, Balloon pop/respawn, Cake cut/reset, and chair snap points for characters. + +**Architecture:** Three independent state-machine objects (`gift_box.gd` extended, `balloon.gd` new, `cake.gd` new) following the existing project pattern — each with a `State` enum, tween-driven animations, and `_on_X_complete()` callbacks testable without tweens. `GardenParty.tscn` is updated to wire balloon scripts and add the Cake and chair nodes. No new autoloads or base classes. + +**Tech Stack:** GDScript 4 (static types), GUT v9.6.0, Godot 4.6.2. + +--- + +### Task 1: GiftBox RESETTING state + +**Files:** +- Modify: `scripts/objects/gift_box.gd` +- Modify: `test/unit/test_gift_box.gd` + +The `OPEN` state is removed. `RESETTING` replaces it: gift fades in, waits 3 s, then the lid closes and the box resets. + +- [ ] **Step 1: Update test file** + +Replace the full contents of `test/unit/test_gift_box.gd` with: + +```gdscript +## Tests for GiftBox — CLOSED/OPENING/RESETTING state machine transitions. +extends GutTest + +var _box: GiftBox + + +func before_each() -> void: + _box = preload("res://scenes/objects/GiftBox.tscn").instantiate() as GiftBox + add_child_autofree(_box) + + +func test_initial_state_is_closed() -> void: + assert_eq(_box._state, GiftBox.State.CLOSED) + + +func test_ready_hides_gift_node() -> void: + var gift: Node2D = _box.get_node("Gift") as Node2D + assert_eq(gift.modulate.a, 0.0) + + +func test_start_opening_transitions_to_opening() -> void: + _box._start_opening() + assert_eq(_box._state, GiftBox.State.OPENING) + + +func test_on_lid_opened_transitions_to_resetting() -> void: + _box._start_opening() + _box._on_lid_opened() + assert_eq(_box._state, GiftBox.State.RESETTING) + + +func test_input_ignored_when_state_is_opening() -> void: + _box._state = GiftBox.State.OPENING + var event: InputEventScreenTouch = InputEventScreenTouch.new() + event.pressed = true + event.position = _box.global_position + _box._input(event) + assert_eq(_box._state, GiftBox.State.OPENING) + + +func test_input_ignored_when_state_is_resetting() -> void: + _box._state = GiftBox.State.RESETTING + var event: InputEventScreenTouch = InputEventScreenTouch.new() + event.pressed = true + event.position = _box.global_position + _box._input(event) + assert_eq(_box._state, GiftBox.State.RESETTING) + + +func test_tap_outside_hitbox_does_not_open() -> void: + var event: InputEventScreenTouch = InputEventScreenTouch.new() + event.pressed = true + event.position = _box.global_position + Vector2(200.0, 200.0) + _box._input(event) + assert_eq(_box._state, GiftBox.State.CLOSED) + + +func test_release_event_does_not_open() -> void: + var event: InputEventScreenTouch = InputEventScreenTouch.new() + event.pressed = false + event.position = _box.global_position + _box._input(event) + assert_eq(_box._state, GiftBox.State.CLOSED) + + +func test_on_reset_complete_transitions_to_closed() -> void: + _box._state = GiftBox.State.RESETTING + _box._on_reset_complete() + assert_eq(_box._state, GiftBox.State.CLOSED) +``` + +- [ ] **Step 2: Run → verify failures** + +```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-14-garden-party" +``` + +Expected: `test_on_lid_opened_transitions_to_resetting` FAIL (State.RESETTING not in enum), `test_input_ignored_when_state_is_resetting` FAIL, `test_on_reset_complete_transitions_to_closed` FAIL. + +- [ ] **Step 3: Replace `scripts/objects/gift_box.gd`** + +```gdscript +## GiftBox — tap while closed to open: lid rises and fades out, gift inside fades in. +## Auto-resets after RESET_DELAY seconds. +class_name GiftBox extends Node2D + +enum State { CLOSED, OPENING, RESETTING } + +const LID_OPEN_Y: float = -120.0 +const CLOSED_LID_Y: float = -60.0 +const OPEN_DURATION: float = 0.5 +const GIFT_FADE_DURATION: float = 0.4 +const RESET_DELAY: float = 3.0 +const BUTTON_HALF_WIDTH: float = 40.0 +const BUTTON_HALF_HEIGHT: float = 50.0 + +var _state: State = State.CLOSED + + +func _ready() -> void: + var gift: Node2D = get_node_or_null("Gift") as Node2D + if gift != null: + gift.modulate.a = 0.0 + + +func _input(event: InputEvent) -> void: + if _state != State.CLOSED: + return + if not event is InputEventScreenTouch: + return + var touch: InputEventScreenTouch = event as InputEventScreenTouch + if not touch.pressed: + return + var local: Vector2 = to_local(touch.position) + if abs(local.x) > BUTTON_HALF_WIDTH or abs(local.y) > BUTTON_HALF_HEIGHT: + return + _start_opening() + + +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: + _on_lid_opened() + 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) + + +func _on_lid_opened() -> void: + _state = State.RESETTING + var gift: Node2D = get_node_or_null("Gift") as Node2D + var tween: Tween = create_tween() + if gift != null: + tween.tween_property(gift, "modulate:a", 1.0, GIFT_FADE_DURATION) + tween.tween_interval(RESET_DELAY) + tween.tween_callback(_start_close_lid) + + +func _start_close_lid() -> void: + var lid: Node2D = get_node_or_null("Lid") as Node2D + var gift: Node2D = get_node_or_null("Gift") as Node2D + var tween: Tween = create_tween().set_parallel(true) + if lid != null: + tween.tween_property(lid, "position:y", CLOSED_LID_Y, OPEN_DURATION) + tween.tween_property(lid, "modulate:a", 1.0, OPEN_DURATION) + if gift != null: + tween.tween_property(gift, "modulate:a", 0.0, OPEN_DURATION) + tween.finished.connect(_on_reset_complete) + + +func _on_reset_complete() -> void: + _state = State.CLOSED +``` + +- [ ] **Step 4: Run → verify all 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-14-garden-party" +``` + +Expected: all tests pass, no regressions. + +- [ ] **Step 5: Commit** + +```bash +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" add scripts/objects/gift_box.gd test/unit/test_gift_box.gd +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" commit -m "feat(sprint-14): add GiftBox RESETTING auto-reset state" +``` + +--- + +### Task 2: Balloon pop/respawn + +**Files:** +- Create: `scripts/objects/balloon.gd` +- Create: `test/unit/test_balloon.gd` + +`balloon.gd` is a `Node2D` script (not attached to a scene file — attached directly to nodes in `GardenParty.tscn` in Task 4). Tests use `Balloon.new()`. + +- [ ] **Step 1: Write failing test** + +Create `test/unit/test_balloon.gd`: + +```gdscript +## Tests for Balloon — state machine transitions. +extends GutTest + +var _balloon: Balloon + + +func before_each() -> void: + _balloon = Balloon.new() + add_child_autofree(_balloon) + + +func test_initial_state_is_idle() -> void: + assert_eq(_balloon._state, Balloon.State.IDLE) + + +func test_start_pop_transitions_to_popping() -> void: + _balloon._start_pop() + assert_eq(_balloon._state, Balloon.State.POPPING) + + +func test_on_pop_complete_transitions_to_popped() -> void: + _balloon._on_pop_complete() + assert_eq(_balloon._state, Balloon.State.POPPED) + + +func test_on_respawn_complete_transitions_to_idle() -> void: + _balloon._state = Balloon.State.RESPAWNING + _balloon._on_respawn_complete() + assert_eq(_balloon._state, Balloon.State.IDLE) +``` + +- [ ] **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-14-garden-party" +``` + +Expected: all 4 balloon tests FAIL — `Balloon` class not found. + +- [ ] **Step 3: Create `scripts/objects/balloon.gd`** + +```gdscript +## Balloon — tap to pop, auto-respawns after a delay. +class_name Balloon extends Node2D + +enum State { IDLE, POPPING, POPPED, RESPAWNING } + +const POP_DURATION: float = 0.15 +const RESPAWN_DURATION: float = 0.30 +const RESPAWN_DELAY: float = 5.0 +const BUTTON_HALF_SIZE: float = 20.0 + +var _state: State = State.IDLE + + +func _input(event: InputEvent) -> void: + if _state != State.IDLE: + return + if not event is InputEventScreenTouch: + return + var touch: InputEventScreenTouch = event as InputEventScreenTouch + if not touch.pressed: + return + var local: Vector2 = to_local(touch.position) + if abs(local.x) > BUTTON_HALF_SIZE or abs(local.y) > BUTTON_HALF_SIZE: + return + _start_pop() + + +func _start_pop() -> void: + AudioManager.play_sfx("object_tap") + _state = State.POPPING + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_IN) + tween.set_trans(Tween.TRANS_BACK) + tween.tween_property(self, "scale", Vector2.ZERO, POP_DURATION) + tween.tween_callback(_on_pop_complete) + + +func _on_pop_complete() -> void: + _state = State.POPPED + var tween: Tween = create_tween() + tween.tween_interval(RESPAWN_DELAY) + tween.tween_callback(_start_respawn) + + +func _start_respawn() -> void: + _state = State.RESPAWNING + var tween: Tween = create_tween() + tween.set_ease(Tween.EASE_OUT) + tween.set_trans(Tween.TRANS_BACK) + tween.tween_property(self, "scale", Vector2.ONE, RESPAWN_DURATION) + tween.tween_callback(_on_respawn_complete) + + +func _on_respawn_complete() -> void: + _state = State.IDLE +``` + +- [ ] **Step 4: Run → verify all 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-14-garden-party" +``` + +Expected: all tests pass including 4 new balloon tests. + +- [ ] **Step 5: Commit** + +```bash +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" add scripts/objects/balloon.gd test/unit/test_balloon.gd +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" commit -m "feat(sprint-14): add Balloon pop/respawn state machine" +``` + +--- + +### Task 3: Cake cut/reset + +**Files:** +- Create: `scripts/objects/cake.gd` +- Create: `test/unit/test_cake.gd` + +`cake.gd` is attached directly to a `Node2D` in `GardenParty.tscn` (Task 4). It looks for a `"Slice"` child node. + +- [ ] **Step 1: Write failing test** + +Create `test/unit/test_cake.gd`: + +```gdscript +## Tests for Cake — state machine transitions. +extends GutTest + +var _cake: Cake + + +func before_each() -> void: + _cake = Cake.new() + add_child_autofree(_cake) + + +func test_initial_state_is_whole() -> void: + assert_eq(_cake._state, Cake.State.WHOLE) + + +func test_start_cutting_transitions_to_cutting() -> void: + _cake._start_cutting() + assert_eq(_cake._state, Cake.State.CUTTING) + + +func test_on_cut_complete_transitions_to_cut() -> void: + _cake._on_cut_complete() + assert_eq(_cake._state, Cake.State.CUT) + + +func test_on_reset_complete_transitions_to_whole() -> void: + _cake._state = Cake.State.RESETTING + _cake._on_reset_complete() + assert_eq(_cake._state, Cake.State.WHOLE) +``` + +- [ ] **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-14-garden-party" +``` + +Expected: all 4 cake tests FAIL — `Cake` class not found. + +- [ ] **Step 3: Create `scripts/objects/cake.gd`** + +```gdscript +## Cake — tap to cut a slice, slice auto-respawns after a delay. +class_name Cake extends Node2D + +enum State { WHOLE, CUTTING, CUT, RESETTING } + +const CUT_DURATION: float = 0.3 +const RESET_DURATION: float = 0.3 +const RESET_DELAY: float = 3.0 +const BUTTON_HALF_WIDTH: float = 40.0 +const BUTTON_HALF_HEIGHT: float = 20.0 + +var _state: State = State.WHOLE + + +func _input(event: InputEvent) -> void: + if _state != State.WHOLE: + return + if not event is InputEventScreenTouch: + return + var touch: InputEventScreenTouch = event as InputEventScreenTouch + if not touch.pressed: + return + var local: Vector2 = to_local(touch.position) + if abs(local.x) > BUTTON_HALF_WIDTH or abs(local.y) > BUTTON_HALF_HEIGHT: + return + _start_cutting() + + +func _start_cutting() -> void: + AudioManager.play_sfx("object_tap") + _state = State.CUTTING + var slice: Node2D = get_node_or_null("Slice") as Node2D + var tween: Tween = create_tween() + if slice != null: + tween.tween_property(slice, "modulate:a", 0.0, CUT_DURATION) + tween.tween_callback(_on_cut_complete) + + +func _on_cut_complete() -> void: + _state = State.CUT + var tween: Tween = create_tween() + tween.tween_interval(RESET_DELAY) + tween.tween_callback(_start_reset) + + +func _start_reset() -> void: + _state = State.RESETTING + var slice: Node2D = get_node_or_null("Slice") as Node2D + var tween: Tween = create_tween() + if slice != null: + tween.tween_property(slice, "modulate:a", 1.0, RESET_DURATION) + tween.tween_callback(_on_reset_complete) + + +func _on_reset_complete() -> void: + _state = State.WHOLE +``` + +- [ ] **Step 4: Run → verify all 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-14-garden-party" +``` + +Expected: all tests pass including 4 new cake tests. + +- [ ] **Step 5: Commit** + +```bash +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" add scripts/objects/cake.gd test/unit/test_cake.gd +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" commit -m "feat(sprint-14): add Cake cut/reset state machine" +``` + +--- + +### Task 4: GardenParty.tscn scene update + +**Files:** +- Modify: `scenes/rooms/home/GardenParty.tscn` + +Changes: add `balloon.gd` + `cake.gd` ext_resources, convert Balloon nodes from `ColorRect` to `Node2D` + Body child (required because `balloon.gd extends Node2D`, not `ColorRect`), replace `GiftBox3` with `Cake` node, add `ChairLeft`/`ChairRight` ColorRects as visual seat indicators. + +No tests for scene structure — verify visually on device. + +- [ ] **Step 1: Replace `scenes/rooms/home/GardenParty.tscn`** + +``` +[gd_scene load_steps=8 format=3 uid="uid://cozypaw_gardenparty"] + +[ext_resource type="PackedScene" path="res://scenes/objects/GiftBox.tscn" id="1_giftbox"] +[ext_resource type="PackedScene" path="res://scenes/objects/TeaPot.tscn" id="2_teapot"] +[ext_resource type="PackedScene" path="res://scenes/objects/HomeButton.tscn" id="3_homebtn"] +[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="4_snap"] +[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="5_chest"] +[ext_resource type="Script" path="res://scripts/objects/balloon.gd" id="6_balloon"] +[ext_resource type="Script" path="res://scripts/objects/cake.gd" id="7_cake"] + +[node name="GardenParty" type="Node2D"] + +[node name="Sky" type="ColorRect" parent="."] +offset_left = 0.0 +offset_top = 0.0 +offset_right = 1280.0 +offset_bottom = 400.0 +color = Color(0.53, 0.81, 0.98, 1) + +[node name="Grass" type="ColorRect" parent="."] +offset_left = 0.0 +offset_top = 400.0 +offset_right = 1280.0 +offset_bottom = 720.0 +color = Color(0.55, 0.76, 0.46, 1) + +[node name="GroundEdge" type="ColorRect" parent="."] +offset_left = 0.0 +offset_top = 392.0 +offset_right = 1280.0 +offset_bottom = 404.0 +color = Color(0.38, 0.62, 0.32, 1) + +[node name="TableTop" type="ColorRect" parent="."] +offset_left = 440.0 +offset_top = 464.0 +offset_right = 840.0 +offset_bottom = 480.0 +color = Color(0.82, 0.66, 0.46, 1) + +[node name="TableLeg1" type="ColorRect" parent="."] +offset_left = 456.0 +offset_top = 480.0 +offset_right = 472.0 +offset_bottom = 580.0 +color = Color(0.70, 0.52, 0.34, 1) + +[node name="TableLeg2" type="ColorRect" parent="."] +offset_left = 808.0 +offset_top = 480.0 +offset_right = 824.0 +offset_bottom = 580.0 +color = Color(0.70, 0.52, 0.34, 1) + +[node name="TeaPot" parent="." instance=ExtResource("2_teapot")] +position = Vector2(510, 464) + +[node name="GiftBox1" parent="." instance=ExtResource("1_giftbox")] +position = Vector2(640, 464) + +[node name="GiftBox2" parent="." instance=ExtResource("1_giftbox")] +position = Vector2(730, 464) + +[node name="Cake" type="Node2D" parent="."] +position = Vector2(820, 464) +script = ExtResource("7_cake") + +[node name="Base" type="ColorRect" parent="Cake"] +offset_left = -40.0 +offset_top = -20.0 +offset_right = 40.0 +offset_bottom = 20.0 +color = Color(0.72, 0.52, 0.32, 1) + +[node name="Slice" type="ColorRect" parent="Cake"] +offset_left = 5.0 +offset_top = -22.0 +offset_right = 33.0 +offset_bottom = 18.0 +color = Color(0.88, 0.74, 0.58, 1) +rotation = 0.15 + +[node name="TeaCup" type="ColorRect" parent="."] +offset_left = 558.0 +offset_top = 440.0 +offset_right = 590.0 +offset_bottom = 464.0 +color = Color(0.96, 0.92, 0.84, 1) + +[node name="Balloon1" type="Node2D" parent="."] +position = Vector2(200, 150) +script = ExtResource("6_balloon") + +[node name="Body" type="ColorRect" parent="Balloon1"] +offset_left = -20.0 +offset_top = -30.0 +offset_right = 20.0 +offset_bottom = 30.0 +color = Color(0.96, 0.44, 0.44, 1) + +[node name="Balloon2" type="Node2D" parent="."] +position = Vector2(1040, 130) +script = ExtResource("6_balloon") + +[node name="Body" type="ColorRect" parent="Balloon2"] +offset_left = -20.0 +offset_top = -30.0 +offset_right = 20.0 +offset_bottom = 30.0 +color = Color(0.56, 0.76, 0.96, 1) + +[node name="HomeButtonReturn" parent="." instance=ExtResource("3_homebtn")] +position = Vector2(100, 620) +go_to_garden = false + +[node name="ChairLeft" type="ColorRect" parent="."] +offset_left = 500.0 +offset_top = 476.0 +offset_right = 560.0 +offset_bottom = 496.0 +color = Color(0.60, 0.42, 0.26, 1) + +[node name="SnapTableLeft" type="Node2D" parent="."] +position = Vector2(530, 455) +script = ExtResource("4_snap") + +[node name="ChairRight" type="ColorRect" parent="."] +offset_left = 720.0 +offset_top = 476.0 +offset_right = 780.0 +offset_bottom = 496.0 +color = Color(0.60, 0.42, 0.26, 1) + +[node name="SnapTableRight" type="Node2D" parent="."] +position = Vector2(750, 455) +script = ExtResource("4_snap") + +[node name="GardenTable" type="Node2D" parent="."] +position = Vector2(200.0, 400.0) +script = ExtResource("5_chest") +chest_id = "garden_table" + +[node name="GardenStorage" type="Node2D" parent="."] +position = Vector2(900.0, 400.0) +script = ExtResource("5_chest") +chest_id = "garden_storage" +``` + +- [ ] **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-14-garden-party" +``` + +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" add scenes/rooms/home/GardenParty.tscn +git -C "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-14-garden-party" commit -m "feat(sprint-14): update GardenParty scene with cake, balloons, and chair snap points" +```