30 Commits

Author SHA1 Message Date
Steven Wroblewski 6e9432fa82 chore(ci): replace wget with curl in Godot download step
Cozypaw Hospital/pipeline/head This commit looks good
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:11:54 +02:00
Steven Wroblewski fb4434a537 chore(ci): add Jenkins pipeline with headless GUT test runner
Cozypaw Hospital/pipeline/head There was a failure building this commit
Downloads Godot 4.6.2 on first run, caches in /tmp, runs all 231 unit
tests headless on every push via Gitea webhook.

Also updates development-plan.md: mark sprints 0-22 complete, document
scope extensions, remove iOS references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:09:26 +02:00
Steven Wroblewski cda31fcac9 assets(audio): add floor music tracks (floor 0-3) 2026-05-11 21:05:03 +02:00
Steven Wroblewski c697b996d8 fix(sprint-14): use preload pattern in test_balloon.gd to fix class_name parse error 2026-05-11 21:04:57 +02:00
Steven Wroblewski ec473dc4e3 feat(sprint-14): update GardenParty scene with cake, balloons, and chair snap points 2026-05-11 20:17:20 +02:00
Steven Wroblewski 2cb265c922 feat(sprint-14): add Cake cut/reset state machine 2026-05-11 20:15:44 +02:00
Steven Wroblewski 666648c154 feat(sprint-14): add Balloon pop/respawn state machine 2026-05-11 20:13:39 +02:00
Steven Wroblewski 6a5a18ca42 fix(sprint-14): guarantee _start_close_lid tween callback when lid and gift are null 2026-05-11 20:11:21 +02:00
Steven Wroblewski 14a50364f3 feat(sprint-14): add GiftBox RESETTING auto-reset state 2026-05-11 20:05:21 +02:00
Steven Wroblewski adefc59bea docs(sprint-14): add garden party implementation plan
4 tasks: GiftBox RESETTING state, Balloon, Cake, GardenParty scene update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:31:34 +02:00
Steven Wroblewski 8f0569766c docs(sprint-14): add garden party spec
GiftBox auto-reset, Balloon pop/respawn, Cake cut/reset, chair snap points.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:26:14 +02:00
Steven Wroblewski 52ebb78862 chore(audio): add download script, audio credits, and sprint 21/22 docs
- docs/download_audio.py: freesound batch downloader with all 22 confirmed IDs
  (API key removed — fill in locally from freesound.org)
- docs/credits-audio.md: generated CC-BY attribution table
- docs/superpowers/plans+specs: sprint 15, 21, 22 implementation plan/spec docs
- .claude/settings.json: enable experimental agent teams env var

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:57:27 +02:00
Steven Wroblewski ad9a406775 assets(audio): replace all placeholder SFX and music with real CC0 audio
All 22 placeholder 0-byte OGG files replaced with freesound.org previews
(128 kbps HQ OGG). All tracks are CC0. Includes Godot import sidecar files
(.ogg.import) for 11 files that had none previously.

Sprints 19, 21, 22 audio coverage complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:57:21 +02:00
Steven Wroblewski faed0951d3 chore: add confirmed freesound IDs for all sprint-21 and sprint-22 SFX 2026-05-11 11:42:32 +02:00
Steven Wroblewski 162ebd158f chore: add freesound batch download script 2026-05-11 11:20:33 +02:00
Steven Wroblewski fefa947783 feat(sprint-22): character SFX + UltrasoundMachine ambient heartbeat 2026-05-10 21:53:57 +02:00
Steven Wroblewski b7757a5548 fix(sfx): duplicate OGG stream before setting loop to avoid shared resource mutation 2026-05-10 21:53:51 +02:00
Steven Wroblewski 80274b0294 feat(sfx): add looping ambient heartbeat to UltrasoundMachine 2026-05-10 21:50:52 +02:00
Steven Wroblewski 18c982f770 feat(sfx): wire character pickup/tap/place SFX to AudioManager 2026-05-10 21:49:40 +02:00
Steven Wroblewski aefd8349f6 feat(sfx): add character SFX keys to AudioManager._SFX_MAP 2026-05-10 21:48:38 +02:00
Steven Wroblewski 24fad7baf7 assets(sfx): add sprint-22 character and ambient SFX placeholders 2026-05-10 21:47:14 +02:00
Steven Wroblewski 1ef6a4ee9e feat(sprint-21): interactive object SFX — 7 new play_sfx wiring calls + AudioManager keys 2026-05-10 21:15:25 +02:00
Steven Wroblewski 9e1058ab6c feat(sfx): wire interactive object SFX to AudioManager.play_sfx 2026-05-10 21:13:04 +02:00
Steven Wroblewski 21628c21fd feat(sfx): add interactive object SFX keys to AudioManager._SFX_MAP 2026-05-10 21:11:34 +02:00
Steven Wroblewski c68fb668d8 assets(sfx): add sprint-21 interactive object SFX placeholders 2026-05-10 21:10:16 +02:00
Steven Wroblewski 8f5d7ed592 feat(sprint-20): RoomNavigator-GameState-AudioManager integration 2026-05-10 20:55:34 +02:00
Steven Wroblewski 48c7e96b38 feat(nav): restore camera to saved room on game load 2026-05-10 20:55:14 +02:00
Steven Wroblewski 3189703d24 feat(nav): wire RoomNavigator to GameState.set_current_room and add room name lookup 2026-05-10 20:54:53 +02:00
Steven Wroblewski c2edaf2761 feat(nav): add GameState.set_current_room and AudioManager.DEFAULT_MUSIC_VOLUME 2026-05-10 20:53:25 +02:00
Steven Wroblewski 43a7e6bde4 docs: add Sprint 20 navigation integration spec and plan 2026-05-10 20:50:13 +02:00
74 changed files with 4017 additions and 69 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}
Vendored
+46
View File
@@ -0,0 +1,46 @@
pipeline {
agent any
environment {
GODOT_VERSION = '4.6.2-stable'
GODOT_BIN = '/tmp/godot_ci/Godot_v4.6.2-stable_linux.x86_64'
}
stages {
stage('Godot Setup') {
steps {
sh '''
if [ ! -x "$GODOT_BIN" ]; then
mkdir -p /tmp/godot_ci
curl -fsSL -o /tmp/godot_ci/godot.zip \
"https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_linux.x86_64.zip"
unzip -o /tmp/godot_ci/godot.zip -d /tmp/godot_ci/
chmod +x "$GODOT_BIN"
fi
'''
}
}
stage('Import Assets') {
steps {
// Godot must import assets before tests can run
sh '$GODOT_BIN --headless --import || true'
}
}
stage('Unit Tests') {
steps {
sh '$GODOT_BIN --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit'
}
}
}
post {
success {
echo '✅ All tests passed'
}
failure {
echo '❌ Tests failed'
}
}
}
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dgcrrb572igsv"
valid=false
[deps]
source_file="res://assets/audio/music/floor_0.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://5e2h06ahgper"
valid=false
[deps]
source_file="res://assets/audio/music/floor_1.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://ddia1ses0471i"
valid=false
[deps]
source_file="res://assets/audio/music/floor_2.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://hswbjuc6exdq"
valid=false
[deps]
source_file="res://assets/audio/music/floor_3.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://clhj71pir50qn"
valid=false
[deps]
source_file="res://assets/audio/sfx/chest_tap.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c8ejoka50o3yr"
valid=false
[deps]
source_file="res://assets/audio/sfx/item_drag_start.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://cv8mj3nk04dov"
valid=false
[deps]
source_file="res://assets/audio/sfx/item_drop_floor.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c3hooek70n7dq"
valid=false
[deps]
source_file="res://assets/audio/sfx/item_drop_hand.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://db5cgjn6svke4"
valid=false
[deps]
source_file="res://assets/audio/sfx/item_drop_outfit.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://c7t2tdceav7ms"
valid=false
[deps]
source_file="res://assets/audio/sfx/item_return_chest.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://bu26y0klq2pn5"
valid=false
[deps]
source_file="res://assets/audio/sfx/item_spawn.ogg"
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -63,3 +63,26 @@ ffmpeg -i input.wav -c:a libvorbis -q:a 4 output.ogg
- Prefer the primary pick; use the alt only if the primary is unavailable or unsuitable on preview.
- Keep music loops between 3060 s to minimize file size on mobile.
- SFX should be trimmed with a short (~5 ms) fade-out to avoid clicks.
## Sprint 21 — Interactive Object SFX
| Target file | Freesound ID | Title | Author | License | URL |
|---|---|---|---|---|---|
| `assets/audio/sfx/xray_scan.ogg` | 614030 | Machine beep.wav | INHIVE.NEWERA | CC0 | https://freesound.org/s/614030/ |
| `assets/audio/sfx/tea_pour.ogg` | 116396 | liquid-pour.mp3 | shakala1 | CC0 | https://freesound.org/s/116396/ |
| `assets/audio/sfx/cradle_rock.ogg` | 216877 | Slow gentle close of squeaky wooden door.wav | CastIronCarousel | CC0 | https://freesound.org/s/216877/ |
| `assets/audio/sfx/gift_open.ogg` | 676625 | Rip 8 - Long | NearTheAtmoshphere | CC0 | https://freesound.org/s/676625/ |
| `assets/audio/sfx/ambulance_siren.ogg` | 536773 | Siren.ogg | egomassive | CC0 | https://freesound.org/s/536773/ |
| `assets/audio/sfx/delivery_cheer.ogg` | 717771 | victory chime | 1bob | CC0 | https://freesound.org/s/717771/ |
| `assets/audio/sfx/object_tap.ogg` | 817506 | Soft Interface 01 Tap | tonymadethatt | CC0 | https://freesound.org/s/817506/ |
## Sprint 22 — Character & Ambient SFX
| Target file | Freesound ID | Title | Author | License | URL |
|---|---|---|---|---|---|
| `assets/audio/sfx/ultrasound_heartbeat.ogg` | 463202 | one_beep.wav | Kenneth_Cooney | CC0 | https://freesound.org/s/463202/ |
| `assets/audio/sfx/character_pickup.ogg` | 789840 | Whoosh Short | FartCTO | CC0 | https://freesound.org/s/789840/ |
| `assets/audio/sfx/character_place.ogg` | 653910 | soft-hit.wav | Krokulator | CC0 | https://freesound.org/s/653910/ |
| `assets/audio/sfx/character_tap.ogg` | 776443 | pop out, bubble, soft bursting | chaferwitt | CC0 | https://freesound.org/s/776443/ |
> `ultrasound_heartbeat.ogg` (one_beep.wav, 1.85s) is a single heartbeat pulse — loops cleanly at 1-2s intervals.
+7
View File
@@ -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/ |
+77 -36
View File
@@ -65,7 +65,6 @@ Hospital (Node2D)
- **Entwicklungs-Rechner:** Dein Arbeitsplatz reicht
- **Android-Export:** Android Studio SDK + JDK (einmalig einrichten)
- **iOS-Export:** Mac + Xcode + Apple Developer Account (€99/Jahr) — **oder:** Android zuerst, iOS später nachziehen
- **Version Control:** Git (Godot-Projekte sind git-freundlich, `.import/` und `.godot/` in `.gitignore`)
### Empfohlene VS Code Setup (alternativ zum Godot-Editor)
@@ -234,25 +233,24 @@ Hier scheitern die meisten Hobby-Gamedev-Projekte. Drei realistische Wege:
Realistisch für einen Vollzeit-Entwickler mit Familie und Side-Projekten. Kürzer geht, wenn du die Abende länger nutzt.
### Sprint 0: Setup (Woche 1)
- [ ] Godot 4 installieren, Android-Export einrichten
- [ ] Git-Repo anlegen
- [ ] GDScript-Grundlagen durchgehen (Godot-Docs, 3-5h)
- [ ] Apple Developer Account falls iOS geplant
- [ ] Projektname + Logo-Idee
### Sprint 0: Setup (Woche 1)
- [x] Godot 4 installieren, Android-Export einrichten
- [x] Git-Repo anlegen
- [x] GDScript-Grundlagen durchgehen (Godot-Docs, 3-5h)
- [x] Projektname + Logo-Idee → "Cozypaw Hospital"
### Sprint 1-2: Proof of Concept (Woche 2-3)
- [ ] Ein Raum (z.B. Empfang) mit Hintergrund
- [ ] Eine Figur (Platzhalter-Häschen) per Drag bewegen
- [ ] Ein interaktives Objekt (z.B. Blume pflücken)
- [ ] Auf echtem Tablet testen
- **Gate:** Funktioniert der Kern-Loop? Finden die Kinder es gut?
### Sprint 1-2: Proof of Concept (Woche 2-3)
- [x] Ein Raum (z.B. Empfang) mit Hintergrund
- [x] Eine Figur (Platzhalter-Häschen) per Drag bewegen
- [x] Ein interaktives Objekt (z.B. Blume pflücken)
- [x] Auf echtem Tablet testen
- **Gate:** ✅ Kern-Loop funktioniert
### Sprint 3-4: Core Systems (Woche 4-5)
- [ ] Raum-Navigationssystem (Etagen-Wechsel per Aufzug)
- [ ] Save/Load-System
- [ ] Settings-Menü (Lautstärke, Reset)
- [ ] Character-State-System (gesund, krank, schläft)
### Sprint 3-4: Core Systems (Woche 4-5)
- [x] Raum-Navigationssystem (Etagen-Wechsel per Aufzug)
- [x] Save/Load-System
- [x] Settings-Menü (Lautstärke, Reset)
- [x] Character-State-System (gesund, krank, schläft)
### Sprint 5-7: Erdgeschoss (Woche 6-8) ✅
- [x] Empfang komplett
@@ -271,22 +269,71 @@ Realistisch für einen Vollzeit-Entwickler mit Familie und Side-Projekten. Kürz
- [x] Kreißsaal (kindgerecht: Mama kommt rein, Baby ist da)
- [x] Säuglingsstation mit Wiegen
### Sprint 14: Zuhause & Garten (Woche 15)
- [ ] Garten-Szene
- [ ] Party-Mechanik (Geschenke auspacken, Tee)
---
### Sprint 15: Polish & Sound (Woche 16)
- [ ] Alle Sounds einbauen
- [ ] Hintergrundmusik mit Cross-Fade
- [ ] Animations-Feinschliff
- [ ] Tutorial / erster Start
> **Scope-Erweiterung:** Die folgenden Sprints gingen über den ursprünglichen 16-Wochen-Plan hinaus und bauten das Spielsystem signifikant aus.
### Sprint 16: Release-Vorbereitung (Woche 17+)
### Sprint 15 (git): Character v2 ✅
- [x] SnapPoint-System (Figuren rasten an Möbeln/Objekten ein)
- [x] SnapReceiver-Komponente
- [x] AnimState-System (idle, picked_up, placed)
- [x] OutfitLayers (visuelle Outfit-Schichten pro Figur)
- [x] HandSlots (Figur kann Objekte halten)
### Sprint 16 (git): Snap-Points in allen Räumen ✅
- [x] 25 SnapPoints quer über alle 12 Räume
- [x] 115 Unit-Tests
### Sprint 17 (git): Hand-Slots & Outfit-Items ✅
- [x] HoldableItem mit Hand-Slot-Erkennung
- [x] OutfitItem mit Tap-to-Undress
- [x] GameState v2 — Outfit und gehaltene Items werden gespeichert
### Sprint 18 (git): Room Chests & Item-Spawning ✅
- [x] RoomChest mit Spawn- und Rücknahme-Logik
- [x] ChestItemData Resource
- [x] RoomChestConfig (alle Räume konfiguriert)
- [x] Chest-Nodes in allen 12 Räumen
### Sprint 19 (git): AudioManager & Cross-Fade ✅
- [x] AudioManager Autoload mit `_SFX_MAP`
- [x] Etagen-Musik mit Cross-Fade zwischen Räumen
- [x] Basis-SFX: item_drag, item_drop, item_spawn, chest_tap
### Sprint 20 (git): Navigation-Integration ✅
- [x] RoomNavigator → GameState.set_current_room
- [x] AudioManager wird beim Raumwechsel getriggert
- [x] Kamera wird beim Laden auf gespeicherten Raum restored
### Sprint 21 (git): Interaktive Objekte SFX ✅
- [x] 7 neue `play_sfx`-Aufrufe in interaktiven Objekten
- [x] SFX: xray_scan, gift_open, tea_pour, cradle_rock, ambulance_siren, delivery_cheer, object_tap
### Sprint 22 (git): Character SFX & Ambient ✅
- [x] character_pickup, character_tap, character_place SFX
- [x] UltrasoundMachine: loopender Herzschlag-Ambient
---
### Sprint 14: Zuhause & Garten ✅
- [x] Garten-Szene (GardenParty)
- [x] Party-Mechanik: Geschenke auspacken (GiftBox), Tee einschenken (TeaPot)
- [x] Kuchen schneiden (Cake) mit Reset-Statemachine
- [x] Luftballons (Balloon) mit Pop/Respawn-Statemachine
- [x] Stuhl-SnapPoints in der Gartenparty
- [x] Floor-Music-Tracks 03 (echte CC0-Audiodateien)
### Sprint 15 (Plan): Polish & Sound ✅
- [x] Alle Sounds einbauen → erledigt in Sprint 21 + 22
- [x] Hintergrundmusik mit Cross-Fade → erledigt in Sprint 19
- [x] Animations-Feinschliff → erledigt in Sprint 15 (git) Character v2
- [ ] Tutorial / erster Start ← **offen**
### Sprint 16 (Plan): Release-Vorbereitung
- [ ] Icon, Splash Screen
- [ ] Play Console Setup, Screenshots, Beschreibung
- [ ] Internal Testing mit Kindern
- [ ] Release auf Play Store (Android zuerst)
- [ ] iOS-Port falls gewünscht
- [ ] Internal Testing mit Kindern (UAT)
- [ ] Release auf Play Store (Android)
---
@@ -298,11 +345,6 @@ Realistisch für einen Vollzeit-Entwickler mit Familie und Side-Projekten. Kürz
3. Oder: Direkte APK-Distribution in der Familie (kein Store nötig)
4. Ggf. später: Öffentlicher Release
### iOS (später)
- Apple Developer Account (€99/Jahr)
- TestFlight für Familie
- App Store Review deutlich strenger als Google
### **WICHTIG — COPPA/Kids-Compliance**
Da Zielgruppe 3+ Jahre:
- Keine Analytics (Google Analytics, Firebase, etc.)
@@ -324,7 +366,6 @@ Da Zielgruppe 3+ Jahre:
| Risiko | Mitigation |
|---|---|
| **Asset-Produktion zieht sich** | Mit Platzhaltern entwickeln, Assets parallelisieren |
| **iOS-Deployment kompliziert** | Erst Android, iOS später |
| **Feature-Creep** | Strikt am MVP-Plan halten, später iterieren |
| **Motivation ebbt nach 2 Monaten ab** | Kinder regelmäßig Build zeigen → Feedback = Motor |
| **Komplexe Animationen** | Mit einfachen 2-Frame-Animationen starten |
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
Cozypaw Hospital — freesound.org batch audio downloader
Downloads all placeholder audio files (0-byte .ogg) and replaces them with
128 kbps HQ OGG previews from freesound.org.
Prerequisites:
pip install requests
API key setup (free, ~2 min):
1. freesound.org → login → click your username → "API credentials"
2. Click "Apply for an API key"
3. App name: "Cozypaw Download Script", Description: "Personal game project"
4. Copy the "Api key" value (the long string) into API_KEY below.
Usage:
python docs/download_audio.py
Quality note:
This script downloads the "preview-hq-ogg" (128 kbps OGG Vorbis).
For a mobile children's game this is indistinguishable from lossless.
Original-quality downloads require full OAuth2 — not worth the hassle.
"""
import os
import sys
import requests
from pathlib import Path
# ── Fill in your API key here ──────────────────────────────────────────────────
API_KEY = "" # get your free key at freesound.org → API credentials
# ──────────────────────────────────────────────────────────────────────────────
REPO_ROOT = Path(__file__).parent.parent
BASE_URL = "https://freesound.org/apiv2"
# Files with confirmed freesound IDs — downloaded by ID, no searching needed.
# CC-BY entries are marked; they need an attribution line in docs/credits-audio.md
KNOWN_IDS: dict[str, tuple[int, str]] = {
# path id license
# ── Sprint 19 — music ──────────────────────────────────────────────────────
"assets/audio/music/floor_0.ogg": (725019, "CC0"),
"assets/audio/music/floor_1.ogg": (387588, "CC0"),
"assets/audio/music/floor_2.ogg": (684511, "CC0"),
"assets/audio/music/floor_3.ogg": (723913, "CC0"),
# ── Sprint 19 — SFX ───────────────────────────────────────────────────────
"assets/audio/sfx/chest_tap.ogg": (679772, "CC0"),
"assets/audio/sfx/item_spawn.ogg": (683096, "CC0"),
"assets/audio/sfx/item_drag_start.ogg": (411177, "CC0"),
"assets/audio/sfx/item_drop_hand.ogg": (448086, "CC0"),
"assets/audio/sfx/item_drop_outfit.ogg": (161415, "CC-BY 3.0"), # needs attribution
"assets/audio/sfx/item_return_chest.ogg": (740266, "CC0"),
"assets/audio/sfx/item_drop_floor.ogg": (449955, "CC0"),
# ── Sprint 21 — interactive object SFX ────────────────────────────────────
"assets/audio/sfx/xray_scan.ogg": (614030, "CC0"), # Machine beep.wav — INHIVE.NEWERA
"assets/audio/sfx/tea_pour.ogg": (116396, "CC0"), # liquid-pour.mp3 — shakala1
"assets/audio/sfx/cradle_rock.ogg": (216877, "CC0"), # Slow gentle squeaky wooden door — CastIronCarousel
"assets/audio/sfx/gift_open.ogg": (676625, "CC0"), # Rip 8 - Long — NearTheAtmoshphere
"assets/audio/sfx/ambulance_siren.ogg": (536773, "CC0"), # Siren.ogg — egomassive
"assets/audio/sfx/delivery_cheer.ogg": (717771, "CC0"), # victory chime — 1bob
"assets/audio/sfx/object_tap.ogg": (817506, "CC0"), # Soft Interface 01 Tap — tonymadethatt
# ── Sprint 22 — character & ambient SFX ───────────────────────────────────
"assets/audio/sfx/ultrasound_heartbeat.ogg":(463202, "CC0"), # one_beep.wav — Kenneth_Cooney
"assets/audio/sfx/character_pickup.ogg": (789840, "CC0"), # Whoosh Short — FartCTO
"assets/audio/sfx/character_place.ogg": (653910, "CC0"), # soft-hit.wav — Krokulator
"assets/audio/sfx/character_tap.ogg": (776443, "CC0"), # pop out, bubble — chaferwitt
}
# All files now have confirmed IDs in KNOWN_IDS above.
SEARCH_QUERIES: dict[str, tuple[str, float]] = {}
# ── Helpers ────────────────────────────────────────────────────────────────────
def _is_placeholder(path: Path) -> bool:
"""Returns True if the file is missing or 0-byte (i.e. still a placeholder)."""
return not path.exists() or path.stat().st_size == 0
def _get_sound_info(sound_id: int) -> dict | None:
url = f"{BASE_URL}/sounds/{sound_id}/"
r = requests.get(url, params={
"fields": "id,name,previews,license,username,duration",
"token": API_KEY,
}, timeout=15)
if r.status_code != 200:
print(f" ✗ API error {r.status_code} for ID {sound_id}")
return None
return r.json()
def _search_sound(query: str, max_duration: float) -> dict | None:
r = requests.get(f"{BASE_URL}/search/text/", params={
"query": query,
"filter": f'license:"Creative Commons 0" duration:[0 TO {max_duration}]',
"fields": "id,name,previews,license,username,duration",
"sort": "score",
"page_size": 5,
"token": API_KEY,
}, timeout=15)
if r.status_code != 200:
print(f" ✗ Search API error {r.status_code} for query '{query}'")
return None
results = r.json().get("results", [])
if not results:
print(f" ✗ No results for '{query}' under {max_duration}s")
return None
return results[0]
def _download_preview(info: dict, dest: Path) -> bool:
ogg_url = info.get("previews", {}).get("preview-hq-ogg")
if not ogg_url:
print(f" ✗ No HQ OGG preview URL in response")
return False
r = requests.get(ogg_url, timeout=30)
if r.status_code != 200:
print(f" ✗ CDN download failed ({r.status_code}): {ogg_url}")
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(r.content)
return True
def _record_attribution(path: str, info: dict, license_str: str) -> None:
credits_file = REPO_ROOT / "docs" / "credits-audio.md"
line = (
f"| `{path}` | {info['name']} | {info['username']} "
f"| {license_str} | https://freesound.org/s/{info['id']}/ |\n"
)
if not credits_file.exists():
credits_file.write_text(
"# Audio Credits\n\nCC-BY files require attribution.\n\n"
"| File | Title | Author | License | URL |\n"
"|---|---|---|---|---|\n"
)
content = credits_file.read_text()
if f"/{info['id']}/" not in content:
with credits_file.open("a") as f:
f.write(line)
print(f" → Attribution recorded in docs/credits-audio.md")
# ── Main ───────────────────────────────────────────────────────────────────────
def main() -> None:
if not API_KEY:
print("ERROR: API_KEY is empty.")
print("Get your free key at: https://freesound.org/apiv2/apply/")
print("Then fill in API_KEY at the top of this script.")
sys.exit(1)
skipped = []
succeeded = []
failed = []
# ── Known IDs ──────────────────────────────────────────────────────────────
print(f"\n{''*60}")
print("Downloading files with known freesound IDs …")
print(f"{''*60}")
for rel_path, (sound_id, license_str) in KNOWN_IDS.items():
dest = REPO_ROOT / rel_path
if not _is_placeholder(dest):
print(f" ✓ skip {rel_path} (already downloaded)")
skipped.append(rel_path)
continue
print(f"{rel_path} (ID {sound_id})")
info = _get_sound_info(sound_id)
if info is None:
failed.append(rel_path)
continue
if _download_preview(info, dest):
size_kb = dest.stat().st_size // 1024
print(f"{info['name']} by {info['username']}"
f" [{size_kb} KB, {info['duration']:.1f}s, {license_str}]")
succeeded.append(rel_path)
if "CC-BY" in license_str:
_record_attribution(rel_path, info, license_str)
else:
failed.append(rel_path)
# ── Search queries ─────────────────────────────────────────────────────────
print(f"\n{''*60}")
print("Searching and downloading remaining SFX …")
print(f"{''*60}")
for rel_path, (query, max_dur) in SEARCH_QUERIES.items():
dest = REPO_ROOT / rel_path
if not _is_placeholder(dest):
print(f" ✓ skip {rel_path} (already downloaded)")
skipped.append(rel_path)
continue
print(f"{rel_path} (search: '{query}', max {max_dur}s)")
info = _search_sound(query, max_dur)
if info is None:
failed.append(rel_path)
continue
if _download_preview(info, dest):
size_kb = dest.stat().st_size // 1024
print(f"{info['name']} by {info['username']}"
f" [{size_kb} KB, {info['duration']:.1f}s, {info['license']}]")
succeeded.append(rel_path)
else:
failed.append(rel_path)
# ── Summary ────────────────────────────────────────────────────────────────
print(f"\n{''*60}")
print(f"Done. ✓ {len(succeeded)} downloaded"
f" · ↷ {len(skipped)} skipped"
f" · ✗ {len(failed)} failed")
if failed:
print("\nFailed files (fix manually):")
for f in failed:
print(f" {f}")
print(f"{''*60}\n")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,347 @@
# Sprint 20 — Navigation Integration 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:** Wire RoomNavigator → GameState → AudioManager so floor music switches automatically when the player navigates rooms. Fix the AudioManager.DEFAULT_MUSIC_VOLUME crash. Restore camera to saved room on game load.
**Architecture:** `GameState.set_current_room()` emits `state_changed`, which AudioManager already observes. `RoomNavigator` calls `set_current_room()` after every navigation. `main.gd` calls `RoomNavigator.go_to_room_by_name()` on start to restore camera. `AudioManager` gets the missing `DEFAULT_MUSIC_VOLUME` constant.
**Tech Stack:** GDScript 4 (static types), GUT v9.6.0.
---
### Task 1: GameState.set_current_room + AudioManager constant + tests
**Files:**
- Modify: `scripts/autoload/GameState.gd`
- Modify: `scripts/autoload/AudioManager.gd`
- Modify: `test/unit/test_game_state.gd` (append 2 tests)
- Modify: `test/unit/test_audio_manager.gd` (append 1 test)
- [ ] **Step 1: Write failing tests**
Append to `test/unit/test_game_state.gd`:
```gdscript
func test_set_current_room_updates_value() -> void:
GameState.set_current_room("xray")
assert_eq(GameState.current_room, "xray")
GameState.set_current_room("reception")
func test_set_current_room_emits_state_changed() -> void:
var signal_count: int = 0
GameState.state_changed.connect(func() -> void: signal_count += 1, CONNECT_ONE_SHOT)
GameState.set_current_room("pharmacy")
assert_eq(signal_count, 1)
GameState.set_current_room("reception")
```
Append to `test/unit/test_audio_manager.gd`:
```gdscript
func test_default_music_volume_constant_is_0_6() -> void:
assert_eq(AudioManager.DEFAULT_MUSIC_VOLUME, 0.6)
```
- [ ] **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-20-navigation-integration"
```
Expected: 3 new tests fail — `set_current_room` not defined, `DEFAULT_MUSIC_VOLUME` not defined.
- [ ] **Step 3: Add set_current_room to GameState.gd**
Add after `clear_chest_state`:
```gdscript
func set_current_room(room: String) -> void:
current_room = room
state_changed.emit()
```
- [ ] **Step 4: Add DEFAULT_MUSIC_VOLUME to AudioManager.gd**
Add after the `CROSSFADE_DURATION` constant:
```gdscript
const DEFAULT_MUSIC_VOLUME: float = 0.6
```
- [ ] **Step 5: 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-20-navigation-integration"
```
Expected: all previous tests plus 3 new tests pass. Total ≥ 212.
- [ ] **Step 6: Commit**
```bash
git add scripts/autoload/GameState.gd scripts/autoload/AudioManager.gd test/unit/test_game_state.gd test/unit/test_audio_manager.gd
git commit -m "feat(nav): add GameState.set_current_room and AudioManager.DEFAULT_MUSIC_VOLUME"
```
---
### Task 2: RoomNavigator room names + navigation wiring + tests
**Files:**
- Modify: `scripts/systems/room_navigator.gd`
- Create: `test/unit/test_room_navigator.gd`
- [ ] **Step 1: Write failing tests**
Create `test/unit/test_room_navigator.gd`:
```gdscript
## Tests for RoomNavigator room name lookup and go_to_room_by_name.
extends GutTest
func test_room_names_dict_has_eleven_entries() -> void:
assert_eq(RoomNavigator._ROOM_NAMES.size(), 11)
func test_get_room_name_floor0_room0_is_reception() -> void:
assert_eq(RoomNavigator.get_room_name(0, 0), "reception")
func test_get_room_name_floor0_room3_is_emergency() -> void:
assert_eq(RoomNavigator.get_room_name(0, 3), "emergency")
func test_get_room_name_floor1_room2_is_lab() -> void:
assert_eq(RoomNavigator.get_room_name(1, 2), "lab")
func test_get_room_name_floor2_room1_is_delivery_room() -> void:
assert_eq(RoomNavigator.get_room_name(2, 1), "delivery_room")
func test_get_room_name_unknown_returns_empty_string() -> void:
assert_eq(RoomNavigator.get_room_name(99, 0), "")
assert_eq(RoomNavigator.get_room_name(0, 99), "")
func test_go_to_room_by_name_garden_party_sets_is_at_home() -> void:
RoomNavigator.go_to_room_by_name("garden_party")
assert_true(RoomNavigator.is_at_home())
RoomNavigator._is_at_home = false
func test_go_to_room_by_name_unknown_is_noop() -> void:
var floor_before: int = RoomNavigator.get_current_floor()
RoomNavigator.go_to_room_by_name("nonexistent_room_xyz")
assert_eq(RoomNavigator.get_current_floor(), floor_before)
```
- [ ] **Step 2: Run → verify FAIL**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import --path "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-20-navigation-integration"
"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-20-navigation-integration"
```
Expected: 8 new tests fail — `_ROOM_NAMES`, `get_room_name`, `go_to_room_by_name` not defined.
- [ ] **Step 3: Update room_navigator.gd**
Full replacement:
```gdscript
## RoomNavigator — autoload that moves the Camera2D smoothly between hospital floors, rooms, and the home/garden area.
extends Node
signal room_changed(floor_index: int, room_index: int)
signal home_entered()
signal hospital_entered()
const FLOOR_HEIGHT: float = 720.0
const ROOM_WIDTH: float = 1280.0
const HOME_CAMERA_X: float = 640.0
const HOME_CAMERA_Y: float = 1080.0
const CAMERA_TWEEN_DURATION: float = 0.6
const HOME_ROOM_NAME: String = "garden_party"
const _ROOM_NAMES: Dictionary = {
Vector2i(0, 0): "reception",
Vector2i(0, 1): "giftshop",
Vector2i(0, 2): "restaurant",
Vector2i(0, 3): "emergency",
Vector2i(1, 0): "xray",
Vector2i(1, 1): "pharmacy",
Vector2i(1, 2): "lab",
Vector2i(1, 3): "patient_rooms",
Vector2i(2, 0): "ultrasound",
Vector2i(2, 1): "delivery_room",
Vector2i(2, 2): "nursery",
}
var _current_floor: int = 0
var _current_room: int = 0
var _is_at_home: bool = false
var _camera: Camera2D
var _active_tween: Tween
func initialize(camera: Camera2D) -> void:
_camera = camera
func go_to_floor(floor_index: int) -> void:
go_to_room(floor_index, 0)
func go_to_room(floor_index: int, room_index: int) -> void:
if _camera == null:
return
if not _is_at_home and floor_index == _current_floor and room_index == _current_room:
return
_is_at_home = false
_current_floor = floor_index
_current_room = room_index
var room_name: String = _ROOM_NAMES.get(Vector2i(floor_index, room_index), "")
if not room_name.is_empty():
GameState.set_current_room(room_name)
var target_x: float = room_index * ROOM_WIDTH + ROOM_WIDTH * 0.5
var target_y: float = floor_index * -FLOOR_HEIGHT + FLOOR_HEIGHT * 0.5
if _active_tween != null:
_active_tween.kill()
_active_tween = create_tween()
_active_tween.set_ease(Tween.EASE_IN_OUT)
_active_tween.set_trans(Tween.TRANS_SINE)
_active_tween.tween_property(_camera, "position", Vector2(target_x, target_y), CAMERA_TWEEN_DURATION)
_active_tween.finished.connect(func() -> void: room_changed.emit(floor_index, room_index))
func go_to_home() -> void:
if _camera == null:
return
if _is_at_home:
return
_is_at_home = true
GameState.set_current_room(HOME_ROOM_NAME)
if _active_tween != null:
_active_tween.kill()
_active_tween = create_tween()
_active_tween.set_ease(Tween.EASE_IN_OUT)
_active_tween.set_trans(Tween.TRANS_SINE)
_active_tween.tween_property(_camera, "position", Vector2(HOME_CAMERA_X, HOME_CAMERA_Y), CAMERA_TWEEN_DURATION)
_active_tween.finished.connect(func() -> void: home_entered.emit())
func go_to_hospital() -> void:
if _camera == null:
return
if not _is_at_home:
return
_is_at_home = false
var room_name: String = _ROOM_NAMES.get(Vector2i(_current_floor, _current_room), "")
if not room_name.is_empty():
GameState.set_current_room(room_name)
var target_x: float = _current_room * ROOM_WIDTH + ROOM_WIDTH * 0.5
var target_y: float = _current_floor * -FLOOR_HEIGHT + FLOOR_HEIGHT * 0.5
if _active_tween != null:
_active_tween.kill()
_active_tween = create_tween()
_active_tween.set_ease(Tween.EASE_IN_OUT)
_active_tween.set_trans(Tween.TRANS_SINE)
_active_tween.tween_property(_camera, "position", Vector2(target_x, target_y), CAMERA_TWEEN_DURATION)
_active_tween.finished.connect(func() -> void: hospital_entered.emit())
func go_to_room_by_name(room_name: String) -> void:
if room_name == HOME_ROOM_NAME:
go_to_home()
return
for key: Vector2i in _ROOM_NAMES:
if _ROOM_NAMES[key] == room_name:
go_to_room(key.x, key.y)
return
func get_room_name(floor_index: int, room_index: int) -> String:
return _ROOM_NAMES.get(Vector2i(floor_index, room_index), "")
func get_current_floor() -> int:
return _current_floor
func get_current_room() -> int:
return _current_room
func is_at_home() -> bool:
return _is_at_home
```
- [ ] **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-20-navigation-integration"
```
Expected: all tests pass including 8 new RoomNavigator tests. Total ≥ 220.
- [ ] **Step 5: Commit**
```bash
git add scripts/systems/room_navigator.gd test/unit/test_room_navigator.gd
git commit -m "feat(nav): wire RoomNavigator to GameState.set_current_room and add room name lookup"
```
---
### Task 3: main.gd camera restore on load
**Files:**
- Modify: `scripts/main/main.gd`
No new tests — camera tween is untestable (visual). Verified manually.
- [ ] **Step 1: Update main.gd**
Replace the existing file:
```gdscript
## Main — scene root: wires up RoomNavigator and restores saved character positions.
extends Node2D
func _ready() -> void:
RoomNavigator.initialize($Camera2D)
SaveManager.load_game()
AudioManager.set_music_volume(GameState.music_volume)
AudioManager.set_sfx_volume(GameState.sfx_volume)
_apply_saved_state()
RoomNavigator.go_to_room_by_name(GameState.current_room)
func _apply_saved_state() -> void:
for character in $Characters.get_children():
if character is Character and character.data != null:
if GameState.has_character_position(character.data.id):
character.global_position = GameState.get_character_position(character.data.id)
```
The only change: `RoomNavigator.go_to_room_by_name(GameState.current_room)` added at the end of `_ready()`. On a fresh game, `GameState.current_room = "reception"` so the camera starts at Floor 0, Reception — the correct default. On subsequent loads, it restores to wherever the player was.
- [ ] **Step 2: Run full test suite**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit --path "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-20-navigation-integration"
```
Expected: all tests pass. No regressions.
- [ ] **Step 3: Commit**
```bash
git add scripts/main/main.gd
git commit -m "feat(nav): restore camera to saved room on game load"
```
@@ -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"
```
@@ -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"
```
@@ -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"
```
@@ -0,0 +1,99 @@
# Sprint 20 — Navigation-Integration Design Spec
## Goal
Close the loop between RoomNavigator, GameState, and AudioManager. Three concrete bugs:
1. `AudioManager.DEFAULT_MUSIC_VOLUME` missing → HUD crashes on music toggle
2. `GameState.current_room` never updated during gameplay → floor music never switches
3. Camera not restored to saved room on game load
## Changes
### GameState
Add `set_current_room(room: String) -> void`:
```gdscript
func set_current_room(room: String) -> void:
current_room = room
state_changed.emit()
```
GameState already persists `current_room` in `get_save_data()` / `apply_save_data()`. No version bump needed — structure unchanged.
### RoomNavigator
Add room name lookup and call `GameState.set_current_room()` on every navigation:
```gdscript
const _ROOM_NAMES: Dictionary = {
Vector2i(0, 0): "reception",
Vector2i(0, 1): "giftshop",
Vector2i(0, 2): "restaurant",
Vector2i(0, 3): "emergency",
Vector2i(1, 0): "xray",
Vector2i(1, 1): "pharmacy",
Vector2i(1, 2): "lab",
Vector2i(1, 3): "patient_rooms",
Vector2i(2, 0): "ultrasound",
Vector2i(2, 1): "delivery_room",
Vector2i(2, 2): "nursery",
}
const HOME_ROOM_NAME: String = "garden_party"
```
`go_to_room(floor_index, room_index)` → after setting internal state, call `GameState.set_current_room(_ROOM_NAMES.get(Vector2i(floor_index, room_index), ""))`.
`go_to_home()``GameState.set_current_room(HOME_ROOM_NAME)`.
`go_to_hospital()``GameState.set_current_room(_ROOM_NAMES.get(Vector2i(_current_floor, _current_room), ""))`.
Add public `get_room_name(floor_index: int, room_index: int) -> String` — returns the room name or `""`.
Add `go_to_room_by_name(room_name: String) -> void` — reverse lookup: if `room_name == HOME_ROOM_NAME`, call `go_to_home()`; otherwise search `_ROOM_NAMES` for matching value and call `go_to_room()`.
### AudioManager
Add missing constant:
```gdscript
const DEFAULT_MUSIC_VOLUME: float = 0.6
```
No other changes — the existing `_on_game_state_changed()` observer already handles room-based music switching correctly once `GameState.set_current_room()` is wired up.
### main.gd
After `SaveManager.load_game()`, restore camera to the saved room:
```gdscript
RoomNavigator.go_to_room_by_name(GameState.current_room)
```
(Called without tween wait — camera teleports to saved position on startup, no animation.)
To suppress the cross-fade animation on startup (no music cross-fade before the initial room is set), `go_to_room_by_name` passes a flag or AudioManager's `_current_floor` is -1 so the first `play_floor_music` call is treated as initial — this already works correctly since `_current_floor` starts at -1.
## Testing
**`test/unit/test_game_state.gd`** — append:
- `test_set_current_room_updates_value` — calls `set_current_room("xray")`, asserts `current_room == "xray"`
- `test_set_current_room_emits_state_changed` — connects to `state_changed`, calls `set_current_room`, verifies signal fired
**`test/unit/test_room_navigator.gd`** — new file:
- `test_room_names_dict_has_eleven_entries`
- `test_get_room_name_floor0_room0_is_reception`
- `test_get_room_name_floor1_room2_is_lab`
- `test_get_room_name_floor2_room1_is_delivery_room`
- `test_get_room_name_unknown_returns_empty`
- `test_go_to_room_by_name_sets_home_for_garden_party` — verifies `is_at_home()` after call
- `test_go_to_room_by_name_unknown_is_noop` — no crash, no state change
**`test/unit/test_audio_manager.gd`** — append:
- `test_default_music_volume_constant_is_0_6` — asserts `AudioManager.DEFAULT_MUSIC_VOLUME == 0.6`
## Out of Scope
- Camera pan animation on game restore (instant teleport is sufficient)
- Floor 2 home button wiring (navigation_arrow for garden already exists in Main.tscn)
- Room-within-floor persistence beyond the existing `current_room` string
@@ -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)
@@ -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)
@@ -0,0 +1,152 @@
# Sprint 14 — Garden Party Spec
## Goal
Make the GardenParty scene a fully playable sandbox room with three interactive objects
(GiftBox auto-reset, Balloon pop/respawn, Cake cut/reset) and character snap-to-chair
at the party table.
---
## Scope
**In scope:**
- `gift_box.gd`: add `RESETTING` state (auto-reset 3 s after opening)
- `balloon.gd`: new script — tap to pop, auto-respawn after 5 s
- `cake.gd`: new script — tap to cut slice, auto-reset after 3 s
- `GardenParty.tscn`: replace GiftBox3 with Cake, add Cake node structure, add chair
ColorRects under snap points, attach balloon.gd to existing Balloon nodes
- Tests: extend `test_gift_box.gd`, add `test_balloon.gd`, add `test_cake.gd`
**Out of scope:**
- New SFX keys (reuse `object_tap` for all three objects)
- Navigation changes (HomeButtonToGarden and HomeButtonReturn already wired)
- Additional characters beyond Bunny1 already in Main.tscn
---
## Object Mechanics
### GiftBox — RESETTING state
New state added to the existing `State` enum:
```
CLOSED → OPENING → RESETTING → CLOSED
```
- `_on_lid_opened()`: state → `RESETTING`, gift fades in (existing tween), inline 3 s
tween interval runs in parallel → `_start_close_lid()`
- `_start_close_lid()`: lid tweens back (fade-in alpha 0→1, slide y back to 0) →
`_on_reset_complete()`
- `_on_reset_complete()`: state → `CLOSED`
- `OPEN` state removed — `RESETTING` covers "gift visible + waiting"
- New internal methods: `_start_close_lid()`, `_on_reset_complete()`
- Constant: `RESET_DELAY: float = 3.0`
### Balloon
New file: `scripts/objects/balloon.gd`
New scene attachment: `balloon.gd` attached to existing `Balloon1` / `Balloon2` nodes in
`GardenParty.tscn`.
```
IDLE → POPPING → POPPED → RESPAWNING → IDLE
```
State transitions:
- Touch input on balloon while `IDLE``_start_pop()`
- `_start_pop()`: `AudioManager.play_sfx("object_tap")`, state → `POPPING`, scale tween to
`Vector2.ZERO` (0.15 s, EASE_IN, TRANS_BACK) → `_on_pop_complete()`
- `_on_pop_complete()`: state → `POPPED`, starts 5 s tween interval → `_start_respawn()`
- `_start_respawn()`: state → `RESPAWNING`, scale tween to `Vector2.ONE` (0.3 s, EASE_OUT,
TRANS_BACK) → `_on_respawn_complete()`
- `_on_respawn_complete()`: state → `IDLE`
Input: `_input(event)` — same touch-rect pattern as GiftBox, half-size 20 px (balloon is
small).
Constants: `POP_DURATION`, `RESPAWN_DURATION`, `RESPAWN_DELAY`.
### Cake
New file: `scripts/objects/cake.gd`
New node structure in `GardenParty.tscn` (replaces GiftBox3 at x=820, y=464):
```
Cake (Node2D, cake.gd)
├── Base (ColorRect, ~80×40 px, warm brown)
└── Slice (ColorRect, ~30×40 px, slightly offset + rotated, lighter brown)
```
```
WHOLE → CUTTING → CUT → RESETTING → WHOLE
```
State transitions:
- Touch input on Cake while `WHOLE``_start_cutting()`
- `_start_cutting()`: `AudioManager.play_sfx("object_tap")`, state → `CUTTING`,
tween `Slice.modulate.a` 1→0 (0.3 s, EASE_OUT) → `_on_cut_complete()`
- `_on_cut_complete()`: state → `CUT`, 3 s tween interval → `_start_reset()`
- `_start_reset()`: state → `RESETTING`, tween `Slice.modulate.a` 0→1 (0.3 s, EASE_IN)
`_on_reset_complete()`
- `_on_reset_complete()`: state → `WHOLE`
Input: touch rect `BUTTON_HALF_WIDTH = 40`, `BUTTON_HALF_HEIGHT = 20`.
Constants: `CUT_DURATION`, `RESET_DELAY = 3.0`, `RESET_DURATION`.
---
## Scene Changes (GardenParty.tscn)
| Change | Detail |
|---|---|
| Remove `GiftBox3` | Was at position Vector2(820, 464) |
| Add `Cake` | Node2D at Vector2(820, 464), `cake.gd`, child nodes Base + Slice |
| Attach `balloon.gd` | To existing `Balloon1` and `Balloon2` ColorRect nodes |
| Add `ChairLeft` | ColorRect ~60×20 px, brown, under `SnapTableLeft` (x=530, y=480) |
| Add `ChairRight` | ColorRect ~60×20 px, brown, under `SnapTableRight` (x=750, y=480) |
Navigation and snap-point logic unchanged.
---
## Tests
### `test_gift_box.gd` (extend existing)
| Test | Assertion |
|---|---|
| `test_state_becomes_resetting_after_lid_opened` | Call `_on_lid_opened()` → state == `State.RESETTING` |
| `test_state_becomes_closed_after_reset_complete` | Call `_on_reset_complete()` → state == `State.CLOSED` |
### `test_balloon.gd` (new, extends GutTest)
| Test | Assertion |
|---|---|
| `test_initial_state_is_idle` | `state == State.IDLE` |
| `test_start_pop_sets_popping` | Call `_start_pop()``state == State.POPPING` |
| `test_on_pop_complete_sets_popped` | Call `_on_pop_complete()``state == State.POPPED` |
| `test_on_respawn_complete_sets_idle` | Call `_on_respawn_complete()``state == State.IDLE` |
### `test_cake.gd` (new, extends GutTest)
| Test | Assertion |
|---|---|
| `test_initial_state_is_whole` | `state == State.WHOLE` |
| `test_start_cutting_sets_cutting` | Call `_start_cutting()``state == State.CUTTING` |
| `test_on_cut_complete_sets_cut` | Call `_on_cut_complete()``state == State.CUT` |
| `test_on_reset_complete_sets_whole` | Call `_on_reset_complete()``state == State.WHOLE` |
---
## Architecture Notes
- All three objects follow the same state-machine pattern as existing objects (`cradle.gd`,
`gift_box.gd`, etc.) — no new abstractions.
- Tween-based animations are not unit-tested (visual only). State transitions triggered by
`tween.finished` callbacks are tested by calling the callbacks directly.
- `AudioServer.get_driver_name() == "Dummy"` guard is already in `AudioManager.play_sfx()`
— no additional guard needed in new scripts.
- `balloon.gd` scales the Node2D itself (no child sprite node needed at placeholder stage).
+52 -12
View File
@@ -1,10 +1,12 @@
[gd_scene load_steps=6 format=3 uid="uid://cozypaw_gardenparty"]
[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"]
@@ -59,8 +61,24 @@ position = Vector2(640, 464)
[node name="GiftBox2" parent="." instance=ExtResource("1_giftbox")]
position = Vector2(730, 464)
[node name="GiftBox3" parent="." instance=ExtResource("1_giftbox")]
[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
@@ -69,28 +87,50 @@ offset_right = 590.0
offset_bottom = 464.0
color = Color(0.96, 0.92, 0.84, 1)
[node name="Balloon1" type="ColorRect" parent="."]
offset_left = 180.0
offset_top = 120.0
offset_right = 220.0
offset_bottom = 180.0
[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="ColorRect" parent="."]
offset_left = 1020.0
offset_top = 100.0
offset_right = 1060.0
offset_bottom = 160.0
[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")
+11
View File
@@ -3,6 +3,7 @@
extends Node
const CROSSFADE_DURATION: float = 0.8
const DEFAULT_MUSIC_VOLUME: float = 0.6
const _MUSIC_MAP: Dictionary = {
0: "res://assets/audio/music/floor_0.ogg",
@@ -19,6 +20,16 @@ const _SFX_MAP: Dictionary = {
"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",
}
var _current_floor: int = -1
+5
View File
@@ -73,6 +73,11 @@ func clear_chest_state(chest_id: String) -> void:
state_changed.emit()
func set_current_room(room: String) -> void:
current_room = room
state_changed.emit()
func get_save_data() -> Dictionary:
var positions: Dictionary = {}
for key: String in _character_positions:
+3
View File
@@ -187,6 +187,7 @@ func _handle_outfit_tap() -> void:
func _on_drag_picked_up(pos: Vector2) -> void:
AudioManager.play_sfx("character_pickup")
_is_held = true
_drag_start_position = pos
set_animation_state("held")
@@ -197,9 +198,11 @@ 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
+1
View File
@@ -8,6 +8,7 @@ func _ready() -> void:
AudioManager.set_music_volume(GameState.music_volume)
AudioManager.set_sfx_volume(GameState.sfx_volume)
_apply_saved_state()
RoomNavigator.go_to_room_by_name(GameState.current_room)
func _apply_saved_state() -> void:
+1
View File
@@ -52,6 +52,7 @@ func _input(event: InputEvent) -> void:
func _drive_in() -> void:
AudioManager.play_sfx("ambulance_siren")
_is_animating = true
_is_parked = false
var tween: Tween = create_tween()
+55
View File
@@ -0,0 +1,55 @@
## 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
+56
View File
@@ -0,0 +1,56 @@
## 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
+1
View File
@@ -28,6 +28,7 @@ func _input(event: InputEvent) -> void:
func _start_rocking() -> void:
AudioManager.play_sfx("cradle_rock")
_state = State.ROCKING
var tween: Tween = create_tween()
tween.set_ease(Tween.EASE_IN_OUT)
+1
View File
@@ -44,6 +44,7 @@ func _input(event: InputEvent) -> void:
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:
+26 -5
View File
@@ -1,11 +1,14 @@
## 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, OPEN }
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
@@ -33,10 +36,11 @@ func _input(event: InputEvent) -> void:
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
_on_lid_opened()
return
var tween: Tween = create_tween()
tween.set_ease(Tween.EASE_OUT)
@@ -47,9 +51,26 @@ func _start_opening() -> void:
func _on_lid_opened() -> void:
_state = State.OPEN
_state = State.RESETTING
var gift: Node2D = get_node_or_null("Gift") as Node2D
if gift == null:
return
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()
if lid != null:
tween.parallel().tween_property(lid, "position:y", CLOSED_LID_Y, OPEN_DURATION)
tween.parallel().tween_property(lid, "modulate:a", 1.0, OPEN_DURATION)
if gift != null:
tween.parallel().tween_property(gift, "modulate:a", 0.0, OPEN_DURATION)
tween.tween_callback(_on_reset_complete)
func _on_reset_complete() -> void:
_state = State.CLOSED
+1
View File
@@ -39,6 +39,7 @@ func _on_area_input_event(_viewport: Node, event: InputEvent, _shape_idx: int) -
func _trigger_interaction() -> void:
AudioManager.play_sfx("object_tap")
_set_state(State.ACTIVE)
object_interacted.emit(self)
GameState.set_object_state(object_id, "active")
+1
View File
@@ -27,6 +27,7 @@ func _input(event: InputEvent) -> void:
func _start_pouring() -> void:
AudioManager.play_sfx("tea_pour")
_state = State.POURING
var tween: Tween = create_tween()
tween.set_ease(Tween.EASE_IN_OUT)
+41
View File
@@ -1,4 +1,5 @@
## 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
@@ -6,10 +7,50 @@ 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 base: AudioStream = load(_HEARTBEAT_PATH) as AudioStream
if base == null:
return
var ogg: AudioStreamOggVorbis = base as AudioStreamOggVorbis
if ogg == null:
return
ogg = ogg.duplicate() as AudioStreamOggVorbis
ogg.loop = true
_audio.stream = ogg
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:
+1
View File
@@ -39,6 +39,7 @@ func _input(event: InputEvent) -> void:
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:
+42 -6
View File
@@ -10,6 +10,21 @@ const ROOM_WIDTH: float = 1280.0
const HOME_CAMERA_X: float = 640.0
const HOME_CAMERA_Y: float = 1080.0
const CAMERA_TWEEN_DURATION: float = 0.6
const HOME_ROOM_NAME: String = "garden_party"
const _ROOM_NAMES: Dictionary = {
Vector2i(0, 0): "reception",
Vector2i(0, 1): "giftshop",
Vector2i(0, 2): "restaurant",
Vector2i(0, 3): "emergency",
Vector2i(1, 0): "xray",
Vector2i(1, 1): "pharmacy",
Vector2i(1, 2): "lab",
Vector2i(1, 3): "patient_rooms",
Vector2i(2, 0): "ultrasound",
Vector2i(2, 1): "delivery_room",
Vector2i(2, 2): "nursery",
}
var _current_floor: int = 0
var _current_room: int = 0
@@ -27,13 +42,16 @@ func go_to_floor(floor_index: int) -> void:
func go_to_room(floor_index: int, room_index: int) -> void:
if _camera == null:
return
if not _is_at_home and floor_index == _current_floor and room_index == _current_room:
return
_is_at_home = false
_current_floor = floor_index
_current_room = room_index
var room_name: String = _ROOM_NAMES.get(Vector2i(floor_index, room_index), "")
if not room_name.is_empty():
GameState.set_current_room(room_name)
if _camera == null:
return
var target_x: float = room_index * ROOM_WIDTH + ROOM_WIDTH * 0.5
var target_y: float = floor_index * -FLOOR_HEIGHT + FLOOR_HEIGHT * 0.5
if _active_tween != null:
@@ -46,11 +64,12 @@ func go_to_room(floor_index: int, room_index: int) -> void:
func go_to_home() -> void:
if _camera == null:
return
if _is_at_home:
return
_is_at_home = true
GameState.set_current_room(HOME_ROOM_NAME)
if _camera == null:
return
if _active_tween != null:
_active_tween.kill()
_active_tween = create_tween()
@@ -61,11 +80,14 @@ func go_to_home() -> void:
func go_to_hospital() -> void:
if _camera == null:
return
if not _is_at_home:
return
_is_at_home = false
var room_name: String = _ROOM_NAMES.get(Vector2i(_current_floor, _current_room), "")
if not room_name.is_empty():
GameState.set_current_room(room_name)
if _camera == null:
return
var target_x: float = _current_room * ROOM_WIDTH + ROOM_WIDTH * 0.5
var target_y: float = _current_floor * -FLOOR_HEIGHT + FLOOR_HEIGHT * 0.5
if _active_tween != null:
@@ -77,6 +99,20 @@ func go_to_hospital() -> void:
_active_tween.finished.connect(func() -> void: hospital_entered.emit())
func go_to_room_by_name(room_name: String) -> void:
if room_name == HOME_ROOM_NAME:
go_to_home()
return
for key: Vector2i in _ROOM_NAMES:
if _ROOM_NAMES[key] == room_name:
go_to_room(key.x, key.y)
return
func get_room_name(floor_index: int, room_index: int) -> String:
return _ROOM_NAMES.get(Vector2i(floor_index, room_index), "")
func get_current_floor() -> int:
return _current_floor
+20
View File
@@ -69,3 +69,23 @@ func test_music_map_has_all_four_floors() -> void:
assert_true(AudioManager._MUSIC_MAP.has(1))
assert_true(AudioManager._MUSIC_MAP.has(2))
assert_true(AudioManager._MUSIC_MAP.has(3))
func test_default_music_volume_constant_is_0_6() -> void:
assert_eq(AudioManager.DEFAULT_MUSIC_VOLUME, 0.6)
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"))
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"))
+31
View File
@@ -0,0 +1,31 @@
## Tests for Balloon — state machine transitions.
extends GutTest
const BalloonScript = preload("res://scripts/objects/balloon.gd")
var _balloon: Node2D
func before_each() -> void:
_balloon = BalloonScript.new()
add_child_autofree(_balloon)
func test_initial_state_is_idle() -> void:
assert_eq(_balloon._state, BalloonScript.State.IDLE)
func test_start_pop_transitions_to_popping() -> void:
_balloon._start_pop()
assert_eq(_balloon._state, BalloonScript.State.POPPING)
func test_on_pop_complete_transitions_to_popped() -> void:
_balloon._on_pop_complete()
assert_eq(_balloon._state, BalloonScript.State.POPPED)
func test_on_respawn_complete_transitions_to_idle() -> void:
_balloon._state = BalloonScript.State.RESPAWNING
_balloon._on_respawn_complete()
assert_eq(_balloon._state, BalloonScript.State.IDLE)
+31
View File
@@ -0,0 +1,31 @@
## Tests for Cake — state machine transitions.
extends GutTest
const CakeScript = preload("res://scripts/objects/cake.gd")
var _cake: Node2D
func before_each() -> void:
_cake = CakeScript.new()
add_child_autofree(_cake)
func test_initial_state_is_whole() -> void:
assert_eq(_cake._state, CakeScript.State.WHOLE)
func test_start_cutting_transitions_to_cutting() -> void:
_cake._start_cutting()
assert_eq(_cake._state, CakeScript.State.CUTTING)
func test_on_cut_complete_transitions_to_cut() -> void:
_cake._on_cut_complete()
assert_eq(_cake._state, CakeScript.State.CUT)
func test_on_reset_complete_transitions_to_whole() -> void:
_cake._state = CakeScript.State.RESETTING
_cake._on_reset_complete()
assert_eq(_cake._state, CakeScript.State.WHOLE)
+13
View File
@@ -181,3 +181,16 @@ func test_apply_save_data_restores_chest_state() -> void:
}
GameState.apply_save_data(data)
assert_eq(GameState.get_chest_state("reception_desk_test"), ["clipboard", "pen"])
func test_set_current_room_updates_value() -> void:
GameState.set_current_room("xray")
assert_eq(GameState.current_room, "xray")
GameState.set_current_room("reception")
func test_set_current_room_emits_state_changed() -> void:
watch_signals(GameState)
GameState.set_current_room("pharmacy")
assert_signal_emitted(GameState, "state_changed")
GameState.set_current_room("reception")
+12 -6
View File
@@ -1,4 +1,4 @@
## Tests for GiftBox — CLOSED/OPENING/OPEN state machine transitions.
## Tests for GiftBox — CLOSED/OPENING/RESETTING state machine transitions.
extends GutTest
var _box: GiftBox
@@ -23,10 +23,10 @@ func test_start_opening_transitions_to_opening() -> void:
assert_eq(_box._state, GiftBox.State.OPENING)
func test_on_lid_opened_transitions_to_open() -> void:
func test_on_lid_opened_transitions_to_resetting() -> void:
_box._start_opening()
_box._on_lid_opened()
assert_eq(_box._state, GiftBox.State.OPEN)
assert_eq(_box._state, GiftBox.State.RESETTING)
func test_input_ignored_when_state_is_opening() -> void:
@@ -38,13 +38,13 @@ func test_input_ignored_when_state_is_opening() -> void:
assert_eq(_box._state, GiftBox.State.OPENING)
func test_input_ignored_when_state_is_open() -> void:
_box._state = GiftBox.State.OPEN
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.OPEN)
assert_eq(_box._state, GiftBox.State.RESETTING)
func test_tap_outside_hitbox_does_not_open() -> void:
@@ -61,3 +61,9 @@ func test_release_event_does_not_open() -> void:
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)
+40 -3
View File
@@ -74,13 +74,13 @@ func test_go_to_floor_does_not_emit_if_already_on_floor_room_zero() -> void:
assert_signal_not_emitted(_nav, "room_changed")
func test_initialize_with_null_camera_prevents_state_update() -> void:
func test_initialize_with_null_camera_still_updates_state() -> void:
var nav: Node = RoomNavigatorScript.new()
add_child_autofree(nav)
nav.initialize(null)
nav.go_to_room(1, 2)
assert_eq(nav.get_current_floor(), 0)
assert_eq(nav.get_current_room(), 0)
assert_eq(nav.get_current_floor(), 1)
assert_eq(nav.get_current_room(), 2)
func test_multiple_room_changes_update_state() -> void:
@@ -138,3 +138,40 @@ func test_go_to_room_after_home_clears_is_at_home() -> void:
_nav.go_to_home()
_nav.go_to_room(0, 0)
assert_false(_nav.is_at_home())
func test_room_names_dict_has_eleven_entries() -> void:
assert_eq(RoomNavigator._ROOM_NAMES.size(), 11)
func test_get_room_name_floor0_room0_is_reception() -> void:
assert_eq(RoomNavigator.get_room_name(0, 0), "reception")
func test_get_room_name_floor0_room3_is_emergency() -> void:
assert_eq(RoomNavigator.get_room_name(0, 3), "emergency")
func test_get_room_name_floor1_room2_is_lab() -> void:
assert_eq(RoomNavigator.get_room_name(1, 2), "lab")
func test_get_room_name_floor2_room1_is_delivery_room() -> void:
assert_eq(RoomNavigator.get_room_name(2, 1), "delivery_room")
func test_get_room_name_unknown_returns_empty_string() -> void:
assert_eq(RoomNavigator.get_room_name(99, 0), "")
assert_eq(RoomNavigator.get_room_name(0, 99), "")
func test_go_to_room_by_name_garden_party_sets_is_at_home() -> void:
RoomNavigator.go_to_room_by_name("garden_party")
assert_true(RoomNavigator.is_at_home())
RoomNavigator._is_at_home = false
func test_go_to_room_by_name_unknown_is_noop() -> void:
var floor_before: int = RoomNavigator.get_current_floor()
RoomNavigator.go_to_room_by_name("nonexistent_room_xyz")
assert_eq(RoomNavigator.get_current_floor(), floor_before)