61 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
Steven Wroblewski 1d65bf21dc feat(sprint-19): AudioManager, floor music cross-fade, and SFX system 2026-05-10 20:19:55 +02:00
Steven Wroblewski 2e0cd18b6e feat(audio): wire SFX into HoldableItem and OutfitItem 2026-05-10 20:19:40 +02:00
Steven Wroblewski a220b641ca feat(audio): add tap handler and SFX to RoomChest
Adds _get_press_position helper and _unhandled_input tap detection to
RoomChest, wires AudioManager.play_sfx calls for chest_tap and
item_spawn events. Guards AudioManager audio load calls with Dummy
driver check so headless unit tests stay green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:18:16 +02:00
Steven Wroblewski bad2fbe65f feat(audio): add AudioManager with floor music cross-fade and SFX
Replaces placeholder AudioManager with full implementation: floor-based
music routing via _derive_floor_from_room, cross-fade tween between
AudioStreamPlayers, SFX event-key dispatch, and room-change guard to
prevent redundant load attempts. 11 new tests (207 total, 206 passing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:14:10 +02:00
Steven Wroblewski 4c60655e83 assets: add audio placeholder files and freesound recommendations for Sprint 19
Adds 11 empty .ogg placeholders (4 music tracks, 7 SFX) so the AudioManager
can reference them without errors during development. Includes docs/audio-assets-sprint19.md
with curated freesound.org download recommendations (CC0/CC-BY) for each file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:09:47 +02:00
Steven Wroblewski 2c0c8b3c42 docs: add Sprint 19 AudioManager implementation plan 2026-05-10 00:48:42 +02:00
Steven Wroblewski 5107790746 docs: add Sprint 19 AudioManager design spec 2026-05-10 00:39:40 +02:00
Steven Wroblewski df6df900c6 feat(sprint-18): room chests and item spawning system
- ChestItemData resource (ItemType enum, outfit_layer, spawn_offset)
- RoomChestConfig: static config for all 14 chests across 12 rooms
- RoomChest Node2D: tap-to-spawn with tween, receive-item return
- HoldableItem: chest-return priority (_try_return_to_chest, 80px radius)
- OutfitItem: chest-return before outfit-apply
- GameState v3: chest states persisted and restored on load
- All 12 rooms populated with placeholder item IDs
- 196 tests passing
2026-05-09 01:26:45 +02:00
Steven Wroblewski cd3ce7bf6e feat(rooms): add RoomChest nodes to Floor 2 and Home rooms
Add UltrasoundCart, DeliveryCabinet, NurseryShelf, GardenTable, GardenStorage
chest nodes to their respective scenes. All 10 new tests pass (196 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 01:24:18 +02:00
Steven Wroblewski 9aded82dbb feat(rooms): add RoomChest nodes to Floor 1 rooms
XRayCabinet (3 items), PharmacyMedicine + PharmacyTools (2 each),
LabBench (3 items), PatientCabinet (3 items). 10 new tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 01:20:36 +02:00
Steven Wroblewski a877d8f5fe feat(rooms): add RoomChest nodes to Floor 0 rooms
ReceptionDesk, GiftShopShelf, RestaurantCounter, EmergencyCabinet added
to their respective tscn files. Fixes spawn_items deferred call to avoid
add_child race during _ready tree setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 01:16:15 +02:00
Steven Wroblewski 87db92955a fix(items): safe cast in _try_return_to_chest, typed chest state param, object_states reset
- Replace unsafe direct cast in HoldableItem._try_return_to_chest() with guarded as-cast
- Type set_chest_state() parameter as Array[String] to match RoomChest._get_spawned_ids()
- Add else-branch in apply_save_data() to reset _object_states when key absent
- Rename test_save_data_has_version_two to test_save_data_has_version_three
2026-05-09 01:12:29 +02:00
Steven Wroblewski 96ec053331 feat(items): add chest-return priority to HoldableItem and GameState v3 chest states
- HoldableItem._try_return_to_chest() snaps item back if within CHEST_RETURN_RADIUS (80px)
- _on_drag_released checks chest return before hand-slot fallback
- OutfitItem._on_drag_released checks chest return before outfit/hand-slot logic
- GameState: _chest_states dict + get/set/clear_chest_state methods
- GameState.get_save_data() bumped to version 3, includes chest_states
- GameState.apply_save_data() restores chest_states from save data
2026-05-09 01:09:47 +02:00
Steven Wroblewski 4f1766834a refactor(items): strengthen RoomChest types, guard receive_item, expose get_spawned_item 2026-05-09 01:07:16 +02:00
Steven Wroblewski b9c73b80ea feat(items): add RoomChest with spawn and receive logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 01:04:34 +02:00
Steven Wroblewski 4e4743f14f refactor(items): use ItemType enum and offset constants in ChestItemData/RoomChestConfig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 01:01:50 +02:00
Steven Wroblewski b97b110876 feat(items): add ChestItemData resource and RoomChestConfig static config
TDD: 4 tests written first (FAIL), then implemented — all 151 tests pass.
2026-05-09 00:58:32 +02:00
Steven Wroblewski 9786cf5895 docs: add Sprint 18 Room Chests implementation plan 2026-05-09 00:54:19 +02:00
Steven Wroblewski 8aa9673154 docs: clarify item position persistence in Sprint 18 spec 2026-05-09 00:43:38 +02:00
Steven Wroblewski 2e0b961520 docs: add Sprint 18 Room Chests design spec 2026-05-09 00:43:23 +02:00
Steven Wroblewski fc801bdbd7 feat(sprint-17): hand slots, outfit items, and GameState v2
- HoldableItem: drag-and-drop base class, snaps to nearest free hand slot
- OutfitItem: extends HoldableItem, applies to character on drop, tap-to-undress
- Character: hand slot API (attach/detach/is_free), outfit layer API, tap detection
- GameState v2: outfit and held items persisted per character in save data
- 147 tests passing
2026-05-09 00:19:27 +02:00
Steven Wroblewski c1df40361a feat(items): add OutfitItem, tap-to-undress, and outfit refs on Character
- Add OutfitItem (extends HoldableItem): applies outfit on drop within 80px
  of character body, falls back to hand slot attach if no character in range
- Add apply_outfit_item / remove_outfit / _handle_outfit_tap to Character
- Track item node refs in _outfit_item_refs for restoring visibility
- Fix animation state: reset to idle before tap handling in _on_drag_released
- Extract _ITEM_DROP_OFFSET constant (replaces magic Vector2(0,60))
- Add 5 tests in test_outfit_item.gd, 14 new tests in test_character_v2.gd
2026-05-09 00:19:15 +02:00
Steven Wroblewski 07c3b996d7 feat(save): extend GameState to v2 — outfit and held items persisted per character
- Add held_left/held_right fields to CharacterData
- Add get/set_character_outfit and get/set_character_held_item to GameState
- get_save_data now returns version:2 with character_outfits and character_held_items
- apply_save_data resets both dicts when keys absent (empty-dict reset safe)
- 11 new tests in test_game_state.gd — 147/147 passing
2026-05-09 00:12:33 +02:00
Steven Wroblewski 09033b9401 fix(test): use get_node_or_null in detach position test per project convention 2026-05-09 00:09:53 +02:00
Steven Wroblewski ca1d20781e feat(items): add HoldableItem with hand slot detection, fix detach_item position
- Character registers in "characters" group on _ready for group scanning
- detach_item saves/restores global_position after reparenting
- New HoldableItem base class: scans "characters" group on drag_released,
  attaches to nearest free hand within 60px radius, detaches on pickup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:03:25 +02:00
Steven Wroblewski 628f97fff5 docs: add Sprint 17 implementation plan (Hand-Slots + Outfits, 147 tests) 2026-05-09 00:00:36 +02:00
Steven Wroblewski 835651a9cc feat(snap-points): merge Sprint 16 — 25 SnapPoints across 12 rooms, 115 tests 2026-05-08 22:42:54 +02:00
Steven Wroblewski 0d3788246a feat(snap-points): add SnapPoints to all 2.OG and Garten rooms (Ultrasound, DeliveryRoom, Nursery, GardenParty)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:40:45 +02:00
Steven Wroblewski 8cae50bc11 feat(snap-points): add SnapPoints to all 1.OG rooms (XRay, Pharmacy, Lab, PatientRoom) 2026-05-08 22:32:57 +02:00
Steven Wroblewski 7848b7a979 feat(snap-points): add SnapPoints to all EG rooms (Reception, GiftShop, Restaurant, EmergencyRoom) 2026-05-08 22:30:12 +02:00
Steven Wroblewski cb4e4951fe docs: add Sprint 16 implementation plan (Snap-Point System, 25 snap points across 12 rooms) 2026-05-08 22:27:56 +02:00
107 changed files with 9337 additions and 120 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.
+88
View File
@@ -0,0 +1,88 @@
# Audio Assets — Sprint 19
Download these files manually from freesound.org and replace the empty placeholders under `assets/audio/`.
All picks are CC0 (Public Domain) unless noted as CC-BY. CC-BY files require attribution in the game's credits / README.
---
## Music tracks
| Target file | Freesound ID | Title | Author | License | Duration | URL |
|---|---|---|---|---|---|---|
| `assets/audio/music/floor_0.ogg` | 725019 | Kids Background - Happy Children (loop ver.2) | AudioCoffee | CC0 | ~60s loop | https://freesound.org/people/AudioCoffee/sounds/725019/ |
| `assets/audio/music/floor_0.ogg` *(alt)* | 720612 | Kids - Children Background (loop) | AudioCoffee | CC0 | ~60s loop | https://freesound.org/people/AudioCoffee/sounds/720612/ |
| `assets/audio/music/floor_1.ogg` | 387588 | Piano Ambiance 4 (120bpm) — Ambient Piano Loop 37 | Erokia | CC0 | loop | https://freesound.org/people/Erokia/sounds/387588/ |
| `assets/audio/music/floor_1.ogg` *(alt)* | 384934 | Soft Piano Loop #1 | ispeakwaves | CC-BY 3.0 | loop | https://freesound.org/people/ispeakwaves/sounds/384934/ |
| `assets/audio/music/floor_2.ogg` | 684511 | Simple Game Music Loop | Seth_Makes_Sounds | CC0 | loop | https://freesound.org/people/Seth_Makes_Sounds/sounds/684511/ |
| `assets/audio/music/floor_2.ogg` *(alt)* | 387588 | Piano Ambiance 4 — use if floor_1 already taken | Erokia | CC0 | loop | https://freesound.org/people/Erokia/sounds/387588/ |
| `assets/audio/music/floor_3.ogg` | 723913 | Forest birds - ambient seamless loop | Magnesus | CC0 | seamless loop | https://freesound.org/people/Magnesus/sounds/723913/ |
| `assets/audio/music/floor_3.ogg` *(alt)* | 798842 | Morning Birds in a Quiet Urban Garden | WhisperingEarth | check page | field recording | https://freesound.org/people/WhisperingEarth/sounds/798842/ |
> **floor_2.ogg note:** The nursery/lullaby track is the hardest to find as a pure CC0 loop. `Seth_Makes_Sounds/684511` is a gentle, simple game loop that works for a calm nursery atmosphere. If you want a proper lullaby feel, `ispeakwaves/384934` (Soft Piano Loop, CC-BY 3.0) is a better fit — just add attribution.
---
## SFX
| Target file | Freesound ID | Title | Author | License | Duration | URL |
|---|---|---|---|---|---|---|
| `assets/audio/sfx/chest_tap.ogg` | 679772 | Knocking on Wood | ominouswhoosh | CC0 | ~0.3s | https://freesound.org/people/ominouswhoosh/sounds/679772/ |
| `assets/audio/sfx/chest_tap.ogg` *(alt)* | 617056 | Tapping Wood 1 | F.M.Audio | CC0 | short | https://freesound.org/s/617056/ |
| `assets/audio/sfx/item_spawn.ogg` | 683096 | Woosh | florianreichelt | CC0 | short | https://freesound.org/people/florianreichelt/sounds/683096/ |
| `assets/audio/sfx/item_spawn.ogg` *(alt)* | 460473 | VS_Short Whoosh 8 | Vilkas_Sound | CC0 | short | https://freesound.org/people/Vilkas_Sound/sounds/460473/ |
| `assets/audio/sfx/item_drag_start.ogg` | 411177 | Pick up Item 1 | SilverIllusionist | CC0 | short | https://freesound.org/people/SilverIllusionist/sounds/411177/ |
| `assets/audio/sfx/item_drag_start.ogg` *(alt)* | 133280 | game pick up object | Leszek_Szary | CC0 | short | https://freesound.org/people/Leszek_Szary/sounds/133280/ |
| `assets/audio/sfx/item_drop_hand.ogg` | 448086 | Normal click | Breviceps | CC0 | <0.2s | https://freesound.org/people/Breviceps/sounds/448086/ |
| `assets/audio/sfx/item_drop_hand.ogg` *(alt)* | 580780 | Flashlight clicking sound | StrikeWhistler | CC0 | <0.2s | https://freesound.org/people/StrikeWhistler/sounds/580780/ |
| `assets/audio/sfx/item_drop_outfit.ogg` | 161415 | cape-swoosh | CosmicEmbers | CC-BY 3.0 | short | https://freesound.org/people/CosmicEmbers/sounds/161415/ |
| `assets/audio/sfx/item_drop_outfit.ogg` *(alt)* | 683096 | Woosh (florianreichelt) — softer pick | florianreichelt | CC0 | short | https://freesound.org/people/florianreichelt/sounds/683096/ |
| `assets/audio/sfx/item_return_chest.ogg` | 740266 | soft button click 1 | FOSSarts | CC0 | <0.2s | https://freesound.org/people/FOSSarts/sounds/740266/ |
| `assets/audio/sfx/item_return_chest.ogg` *(alt)* | 677860 | UI Button Click Snap | el_boss | CC0 | <0.2s | https://freesound.org/people/el_boss/sounds/677860/ |
| `assets/audio/sfx/item_drop_floor.ogg` | 449955 | Wooden Thud (Mono) | Breviceps | CC0 | <0.3s | https://freesound.org/people/Breviceps/sounds/449955/ |
| `assets/audio/sfx/item_drop_floor.ogg` *(alt)* | 342530 | Light Thud 4 | sgrowe | CC0 | <0.3s | https://freesound.org/people/sgrowe/sounds/342530/ |
---
## Download instructions
1. Open each URL in a browser while logged in to freesound.org (free account required for download).
2. Click "Download" and choose OGG format where available (or download as WAV and convert with Audacity/ffmpeg).
3. Rename the file to match the "Target file" column and drop it into the repo, replacing the empty placeholder.
4. For CC-BY files, add an entry to `docs/credits-audio.md` (create if it does not exist yet) with: `Author — Title — CC-BY 3.0 — URL`.
## ffmpeg conversion (WAV to OGG)
```
ffmpeg -i input.wav -c:a libvorbis -q:a 4 output.ogg
```
## Notes
- All CC0 picks require no attribution, but you may credit anyway.
- 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,747 @@
# Sprint 16 — Snap-Point System 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 SnapPoint nodes to all furniture in all 12 rooms so characters can snap into sitting/lying poses when released near a piece of furniture.
**Architecture:** Each room `.tscn` gets one `ext_resource` entry for `snap_point.gd` and one `[node]` entry per snap position. SnapPoints are direct children of the room node (not children of ColorRect furniture, since ColorRects have no Node2D hierarchy). All SnapPoints auto-register in the `"snap_points"` group via `_ready()`. No new GDScript is written — this sprint is purely scene data.
**Tech Stack:** Godot 4.6.2, `.tscn` text format, GUT v9.6.0 (TDD), headless runner.
**GDD Reference:** `docs/game-design.md` — Kapitel 2 (Raum-Übersicht) + Kapitel 5.1 (Snap-Point System).
**Headless runner:**
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
**Existing tests must stay green:** 90 tests passing before this sprint starts.
---
## File Map
| Action | Path | SnapPoints added |
|---|---|---|
| Modify | `scenes/rooms/floor0/Reception.tscn` | 4 × sitting (2 benches × 2) |
| Modify | `scenes/rooms/floor0/GiftShop.tscn` | 1 × sitting (counter) |
| Modify | `scenes/rooms/floor0/Restaurant.tscn` | 6 × sitting (3 tables × 2) |
| Modify | `scenes/rooms/floor0/EmergencyRoom.tscn` | 1 × lying (medical table) |
| Modify | `scenes/rooms/floor1/XRay.tscn` | 1 × lying (exam table) |
| Modify | `scenes/rooms/floor1/Pharmacy.tscn` | 1 × sitting (counter) |
| Modify | `scenes/rooms/floor1/Lab.tscn` | 2 × sitting (lab bench) |
| Modify | `scenes/rooms/floor1/PatientRoom.tscn` | 2 × lying (2 beds) |
| Modify | `scenes/rooms/floor2/Ultrasound.tscn` | 1 × lying (exam table) |
| Modify | `scenes/rooms/floor2/DeliveryRoom.tscn` | 1 × lying (delivery bed) |
| Modify | `scenes/rooms/floor2/Nursery.tscn` | 3 × lying, baby_only (3 cradles) |
| Modify | `scenes/rooms/home/GardenParty.tscn` | 2 × sitting (garden table) |
| Create | `test/unit/test_snap_points_floor0.gd` | Tests for EG (12 snap points) |
| Create | `test/unit/test_snap_points_floor1.gd` | Tests for 1.OG (6 snap points) |
| Create | `test/unit/test_snap_points_floor2.gd` | Tests for 2.OG + Garten (7 snap points) |
**Total: 25 SnapPoints across 12 rooms.**
---
## How to add a SnapPoint to a .tscn
### 1. Increment `load_steps` in the header by 1
```
[gd_scene load_steps=3 format=3 ...] ← was 2
```
### 2. Add `ext_resource` for snap_point.gd after the last existing `ext_resource` line
For rooms with currently 1 ext_resource (InteractiveObject only), use id `"2_snap"`:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
```
For rooms with currently 2 ext_resources, use id `"3_snap"`:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
```
For GardenParty (3 ext_resources), use id `"4_snap"`:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="4_snap"]
```
### 3. Append node entries at the end of the file
Sitting (default pose, no need to set pose or baby_only):
```
[node name="SnapBenchLeft1" type="Node2D" parent="."]
position = Vector2(150, 555)
script = ExtResource("2_snap")
```
Lying (must set pose):
```
[node name="SnapExamTable" type="Node2D" parent="."]
position = Vector2(640, 480)
script = ExtResource("2_snap")
pose = "lying"
```
Lying + baby only:
```
[node name="SnapCradle1" type="Node2D" parent="."]
position = Vector2(340, 240)
script = ExtResource("3_snap")
pose = "lying"
baby_only = true
```
---
## Helper: how tests count SnapPoints in a room
```gdscript
func _count_snaps(room: Node2D) -> int:
var count: int = 0
for child: Node in room.get_children():
if child is SnapPoint:
count += 1
return count
func _count_snaps_with_pose(room: Node2D, pose: String) -> int:
var count: int = 0
for child: Node in room.get_children():
var snap: SnapPoint = child as SnapPoint
if snap != null and snap.pose == pose:
count += 1
return count
func _count_snaps_baby_only(room: Node2D) -> int:
var count: int = 0
for child: Node in room.get_children():
var snap: SnapPoint = child as SnapPoint
if snap != null and snap.baby_only:
count += 1
return count
```
---
## Task 1: Floor 0 — Reception, GiftShop, Restaurant, EmergencyRoom
**Files:**
- Modify: `scenes/rooms/floor0/Reception.tscn`
- Modify: `scenes/rooms/floor0/GiftShop.tscn`
- Modify: `scenes/rooms/floor0/Restaurant.tscn`
- Modify: `scenes/rooms/floor0/EmergencyRoom.tscn`
- Create: `test/unit/test_snap_points_floor0.gd`
### Step 1: Write the failing tests
Create `test/unit/test_snap_points_floor0.gd`:
```gdscript
## Tests verifying SnapPoints exist in all EG (Erdgeschoss) room scenes.
extends GutTest
func _count_snaps(room: Node2D) -> int:
var count: int = 0
for child: Node in room.get_children():
if child is SnapPoint:
count += 1
return count
func _count_snaps_with_pose(room: Node2D, pose: String) -> int:
var count: int = 0
for child: Node in room.get_children():
var snap: SnapPoint = child as SnapPoint
if snap != null and snap.pose == pose:
count += 1
return count
func test_reception_has_four_snap_points() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/Reception.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 4)
func test_reception_all_snaps_are_sitting() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/Reception.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "sitting"), 4)
func test_giftshop_has_one_snap_point() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/GiftShop.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 1)
func test_giftshop_snap_is_sitting() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/GiftShop.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "sitting"), 1)
func test_restaurant_has_six_snap_points() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/Restaurant.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 6)
func test_restaurant_all_snaps_are_sitting() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/Restaurant.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "sitting"), 6)
func test_emergency_room_has_one_snap_point() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/EmergencyRoom.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 1)
func test_emergency_room_snap_is_lying() -> void:
var room: Node2D = preload("res://scenes/rooms/floor0/EmergencyRoom.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "lying"), 1)
```
### Step 2: Run tests — verify they FAIL
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
Expected: 8 new failures. 90 existing pass.
### Step 3: Edit `scenes/rooms/floor0/Reception.tscn`
Current header: `[gd_scene load_steps=2 format=3 uid="uid://cozypaw_reception"]`
Change to: `[gd_scene load_steps=3 format=3 uid="uid://cozypaw_reception"]`
After `[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]`, add:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
```
Append at the end of the file:
```
[node name="SnapBenchLeft1" type="Node2D" parent="."]
position = Vector2(150, 555)
script = ExtResource("2_snap")
[node name="SnapBenchLeft2" type="Node2D" parent="."]
position = Vector2(240, 555)
script = ExtResource("2_snap")
[node name="SnapBenchRight1" type="Node2D" parent="."]
position = Vector2(990, 555)
script = ExtResource("2_snap")
[node name="SnapBenchRight2" type="Node2D" parent="."]
position = Vector2(1080, 555)
script = ExtResource("2_snap")
```
### Step 4: Edit `scenes/rooms/floor0/GiftShop.tscn`
Change header to `load_steps=3`.
Add after InteractiveObject ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
```
Append:
```
[node name="SnapCounter" type="Node2D" parent="."]
position = Vector2(640, 528)
script = ExtResource("2_snap")
```
### Step 5: Edit `scenes/rooms/floor0/Restaurant.tscn`
Change header to `load_steps=3`.
Add after InteractiveObject ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
```
Append:
```
[node name="SnapTable1Left" type="Node2D" parent="."]
position = Vector2(160, 510)
script = ExtResource("2_snap")
[node name="SnapTable1Right" type="Node2D" parent="."]
position = Vector2(280, 510)
script = ExtResource("2_snap")
[node name="SnapTable2Left" type="Node2D" parent="."]
position = Vector2(580, 510)
script = ExtResource("2_snap")
[node name="SnapTable2Right" type="Node2D" parent="."]
position = Vector2(700, 510)
script = ExtResource("2_snap")
[node name="SnapTable3Left" type="Node2D" parent="."]
position = Vector2(1000, 510)
script = ExtResource("2_snap")
[node name="SnapTable3Right" type="Node2D" parent="."]
position = Vector2(1120, 510)
script = ExtResource("2_snap")
```
### Step 6: Edit `scenes/rooms/floor0/EmergencyRoom.tscn`
Current header: `load_steps=3` (has Ambulance ext_resource). Change to `load_steps=4`.
Add after the last ext_resource line (Ambulance):
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
```
Append:
```
[node name="SnapMedicalTable" type="Node2D" parent="."]
position = Vector2(310, 480)
script = ExtResource("3_snap")
pose = "lying"
```
### Step 7: Run tests — verify 8 new tests PASS (98 total)
Run `--headless --import` first if needed, then the test runner.
Expected: 98/98 passed.
### Step 8: Commit
```bash
cd "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-16-snap-points"
git add scenes/rooms/floor0/Reception.tscn scenes/rooms/floor0/GiftShop.tscn
git add scenes/rooms/floor0/Restaurant.tscn scenes/rooms/floor0/EmergencyRoom.tscn
git add test/unit/test_snap_points_floor0.gd
git commit -m "feat(snap-points): add SnapPoints to all EG rooms (Reception, GiftShop, Restaurant, EmergencyRoom)"
```
---
## Task 2: Floor 1 — XRay, Pharmacy, Lab, PatientRoom
**Files:**
- Modify: `scenes/rooms/floor1/XRay.tscn`
- Modify: `scenes/rooms/floor1/Pharmacy.tscn`
- Modify: `scenes/rooms/floor1/Lab.tscn`
- Modify: `scenes/rooms/floor1/PatientRoom.tscn`
- Create: `test/unit/test_snap_points_floor1.gd`
### Step 1: Write the failing tests
Create `test/unit/test_snap_points_floor1.gd`:
```gdscript
## Tests verifying SnapPoints exist in all 1.OG room scenes.
extends GutTest
func _count_snaps(room: Node2D) -> int:
var count: int = 0
for child: Node in room.get_children():
if child is SnapPoint:
count += 1
return count
func _count_snaps_with_pose(room: Node2D, pose: String) -> int:
var count: int = 0
for child: Node in room.get_children():
var snap: SnapPoint = child as SnapPoint
if snap != null and snap.pose == pose:
count += 1
return count
func test_xray_has_one_snap_point() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/XRay.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 1)
func test_xray_snap_is_lying() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/XRay.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "lying"), 1)
func test_pharmacy_has_one_snap_point() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/Pharmacy.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 1)
func test_pharmacy_snap_is_sitting() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/Pharmacy.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "sitting"), 1)
func test_lab_has_two_snap_points() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/Lab.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 2)
func test_lab_all_snaps_are_sitting() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/Lab.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "sitting"), 2)
func test_patient_room_has_two_snap_points() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/PatientRoom.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 2)
func test_patient_room_all_snaps_are_lying() -> void:
var room: Node2D = preload("res://scenes/rooms/floor1/PatientRoom.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "lying"), 2)
```
### Step 2: Run tests — verify 8 new tests FAIL
Expected: 8 failures. 98 existing pass.
### Step 3: Edit `scenes/rooms/floor1/XRay.tscn`
Current header: `load_steps=3` (has XRayMachine). Change to `load_steps=4`.
Add after the last ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
```
Append:
```
[node name="SnapExamTable" type="Node2D" parent="."]
position = Vector2(640, 480)
script = ExtResource("3_snap")
pose = "lying"
```
### Step 4: Edit `scenes/rooms/floor1/Pharmacy.tscn`
Current header: `load_steps=2`. Change to `load_steps=3`.
Add after ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
```
Append:
```
[node name="SnapCounter" type="Node2D" parent="."]
position = Vector2(640, 520)
script = ExtResource("2_snap")
```
### Step 5: Edit `scenes/rooms/floor1/Lab.tscn`
Current header: `load_steps=2`. Change to `load_steps=3`.
Add after ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
```
Append:
```
[node name="SnapLabBench1" type="Node2D" parent="."]
position = Vector2(450, 470)
script = ExtResource("2_snap")
[node name="SnapLabBench2" type="Node2D" parent="."]
position = Vector2(750, 470)
script = ExtResource("2_snap")
```
### Step 6: Edit `scenes/rooms/floor1/PatientRoom.tscn`
Current header: `load_steps=2`. Change to `load_steps=3`.
Add after ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
```
Append:
```
[node name="SnapBed1" type="Node2D" parent="."]
position = Vector2(250, 465)
script = ExtResource("2_snap")
pose = "lying"
[node name="SnapBed2" type="Node2D" parent="."]
position = Vector2(810, 465)
script = ExtResource("2_snap")
pose = "lying"
```
### Step 7: Run tests — verify 8 new tests PASS (106 total)
Run `--headless --import` first if needed, then the test runner.
Expected: 106/106 passed.
### Step 8: Commit
```bash
cd "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-16-snap-points"
git add scenes/rooms/floor1/XRay.tscn scenes/rooms/floor1/Pharmacy.tscn
git add scenes/rooms/floor1/Lab.tscn scenes/rooms/floor1/PatientRoom.tscn
git add test/unit/test_snap_points_floor1.gd
git commit -m "feat(snap-points): add SnapPoints to all 1.OG rooms (XRay, Pharmacy, Lab, PatientRoom)"
```
---
## Task 3: Floor 2 + Garten — Ultrasound, DeliveryRoom, Nursery, GardenParty
**Files:**
- Modify: `scenes/rooms/floor2/Ultrasound.tscn`
- Modify: `scenes/rooms/floor2/DeliveryRoom.tscn`
- Modify: `scenes/rooms/floor2/Nursery.tscn`
- Modify: `scenes/rooms/home/GardenParty.tscn`
- Create: `test/unit/test_snap_points_floor2.gd`
### Step 1: Write the failing tests
Create `test/unit/test_snap_points_floor2.gd`:
```gdscript
## Tests verifying SnapPoints exist in all 2.OG and Garten room scenes.
extends GutTest
func _count_snaps(room: Node2D) -> int:
var count: int = 0
for child: Node in room.get_children():
if child is SnapPoint:
count += 1
return count
func _count_snaps_with_pose(room: Node2D, pose: String) -> int:
var count: int = 0
for child: Node in room.get_children():
var snap: SnapPoint = child as SnapPoint
if snap != null and snap.pose == pose:
count += 1
return count
func _count_snaps_baby_only(room: Node2D) -> int:
var count: int = 0
for child: Node in room.get_children():
var snap: SnapPoint = child as SnapPoint
if snap != null and snap.baby_only:
count += 1
return count
func test_ultrasound_has_one_snap_point() -> void:
var room: Node2D = preload("res://scenes/rooms/floor2/Ultrasound.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 1)
func test_ultrasound_snap_is_lying() -> void:
var room: Node2D = preload("res://scenes/rooms/floor2/Ultrasound.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "lying"), 1)
func test_delivery_room_has_one_snap_point() -> void:
var room: Node2D = preload("res://scenes/rooms/floor2/DeliveryRoom.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 1)
func test_delivery_room_snap_is_lying() -> void:
var room: Node2D = preload("res://scenes/rooms/floor2/DeliveryRoom.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "lying"), 1)
func test_nursery_has_three_snap_points() -> void:
var room: Node2D = preload("res://scenes/rooms/floor2/Nursery.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 3)
func test_nursery_all_snaps_are_baby_only() -> void:
var room: Node2D = preload("res://scenes/rooms/floor2/Nursery.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_baby_only(room), 3)
func test_nursery_all_snaps_are_lying() -> void:
var room: Node2D = preload("res://scenes/rooms/floor2/Nursery.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "lying"), 3)
func test_garden_party_has_two_snap_points() -> void:
var room: Node2D = preload("res://scenes/rooms/home/GardenParty.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps(room), 2)
func test_garden_party_all_snaps_are_sitting() -> void:
var room: Node2D = preload("res://scenes/rooms/home/GardenParty.tscn").instantiate() as Node2D
add_child_autofree(room)
assert_eq(_count_snaps_with_pose(room, "sitting"), 2)
```
### Step 2: Run tests — verify 9 new tests FAIL
Expected: 9 failures. 106 existing pass.
### Step 3: Edit `scenes/rooms/floor2/Ultrasound.tscn`
Current header: `load_steps=3` (has UltrasoundMachine). Change to `load_steps=4`.
Add after last ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
```
Append:
```
[node name="SnapExamTable" type="Node2D" parent="."]
position = Vector2(470, 480)
script = ExtResource("3_snap")
pose = "lying"
```
### Step 4: Edit `scenes/rooms/floor2/DeliveryRoom.tscn`
Current header: `load_steps=3` (has DeliveryBed). Change to `load_steps=4`.
Add after last ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
```
Append:
```
[node name="SnapDeliveryBed" type="Node2D" parent="."]
position = Vector2(540, 480)
script = ExtResource("3_snap")
pose = "lying"
```
### Step 5: Edit `scenes/rooms/floor2/Nursery.tscn`
Current header: `load_steps=3` (has Cradle). Change to `load_steps=4`.
Add after last ext_resource:
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
```
Append:
```
[node name="SnapCradle1" type="Node2D" parent="."]
position = Vector2(340, 240)
script = ExtResource("3_snap")
pose = "lying"
baby_only = true
[node name="SnapCradle2" type="Node2D" parent="."]
position = Vector2(600, 240)
script = ExtResource("3_snap")
pose = "lying"
baby_only = true
[node name="SnapCradle3" type="Node2D" parent="."]
position = Vector2(860, 240)
script = ExtResource("3_snap")
pose = "lying"
baby_only = true
```
### Step 6: Edit `scenes/rooms/home/GardenParty.tscn`
Current header: `load_steps=4` (has GiftBox, TeaPot, HomeButton). Change to `load_steps=5`.
Add after the last ext_resource (HomeButton):
```
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="4_snap"]
```
Append:
```
[node name="SnapTableLeft" type="Node2D" parent="."]
position = Vector2(530, 455)
script = ExtResource("4_snap")
[node name="SnapTableRight" type="Node2D" parent="."]
position = Vector2(750, 455)
script = ExtResource("4_snap")
```
### Step 7: Run tests — verify 9 new tests PASS (115 total)
Run `--headless --import` first, then the test runner.
Expected: 115/115 passed.
### Step 8: Commit
```bash
cd "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-16-snap-points"
git add scenes/rooms/floor2/Ultrasound.tscn scenes/rooms/floor2/DeliveryRoom.tscn
git add scenes/rooms/floor2/Nursery.tscn scenes/rooms/home/GardenParty.tscn
git add test/unit/test_snap_points_floor2.gd
git commit -m "feat(snap-points): add SnapPoints to all 2.OG and Garten rooms (Ultrasound, DeliveryRoom, Nursery, GardenParty)"
```
---
## Final Check
- [ ] **Run full test suite one last time**
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
Expected output:
```
Scripts 10
Tests 115
Passing Tests 115
---- All tests passed! ----
```
- [ ] **Verify git log shows 3 clean commits**
```bash
git log --oneline -4
```
Expected:
```
feat(snap-points): add SnapPoints to all 2.OG and Garten rooms...
feat(snap-points): add SnapPoints to all 1.OG rooms...
feat(snap-points): add SnapPoints to all EG rooms...
```
@@ -0,0 +1,852 @@
# Sprint 17 — Hand-Slots + Outfits 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:** Enable items to be held in Character hand slots and outfit items to be dragged onto characters to dress them, with tap-to-undress and save persistence.
**Architecture:** Three new concerns, each self-contained: (1) `HoldableItem` scans the "characters" group on release and attaches itself to the nearest free HandLeft/HandRight. (2) `OutfitItem` extends `HoldableItem` — on release near a character body it applies itself to an outfit layer and hides. (3) `GameState` extended to persist outfit + held-item state per character (save format v2). No new scene files required — all logic is GDScript.
**Tech Stack:** Godot 4.6.2, GDScript static typing, GUT v9.6.0, headless runner.
**GDD Reference:** `docs/game-design.md` — Kapitel 5.2 (Hand-Slot System) + 5.3 (Outfit Layer System) + Kapitel 8 (Save Format v2).
**Headless runner:**
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
**Existing tests must stay green:** 115 tests passing before this sprint starts.
---
## Context: what Sprint 15 already built
`Character.tscn` already has `HandLeft` (Node2D at (-32,-30)) and `HandRight` (Node2D at (32,-30)) as attachment points. `character.gd` already has `attach_item(hand, item)`, `detach_item(hand)`, `get_held_item(hand)`, `is_hand_free(hand)`, `set_outfit(layer, id, texture)`, `clear_outfit(layer)`, `get_outfit(layer)`. `CharacterData` already has `outfit: Array[String]`.
What is missing: (1) Characters are not registered in any group, so items cannot find them by scanning. (2) `detach_item` does not preserve `global_position` when reparenting — it will silently teleport items to (0,0). (3) No `HoldableItem` class. (4) No `OutfitItem` class. (5) GameState only saves character positions, not outfit or held items.
---
## File Map
| Action | Path | Purpose |
|---|---|---|
| Modify | `scripts/characters/character.gd` | group registration, detach fix, tap detection, apply/remove outfit |
| Create | `scripts/objects/holdable_item.gd` | base class: attach to hand slot on drag release |
| Create | `scripts/objects/outfit_item.gd` | extends HoldableItem: apply outfit on drop near character |
| Modify | `scripts/characters/character_data.gd` | add `held_left`, `held_right` String fields |
| Modify | `scripts/autoload/GameState.gd` | save/load outfit + held items per character (v2 format) |
| Modify | `test/unit/test_character_v2.gd` | add group, detach-position, tap, apply/remove outfit tests |
| Create | `test/unit/test_holdable_item.gd` | HoldableItem unit tests |
| Create | `test/unit/test_outfit_item.gd` | OutfitItem unit tests |
| Modify | `test/unit/test_game_state.gd` | extend with outfit + held-item save tests |
---
## Task 1: Character group + HoldableItem + detach fix
**Files:**
- Modify: `scripts/characters/character.gd`
- Create: `scripts/objects/holdable_item.gd`
- Modify: `test/unit/test_character_v2.gd`
- Create: `test/unit/test_holdable_item.gd`
### Step 1: Write the failing tests
Add to `test/unit/test_character_v2.gd` (after the last existing test):
```gdscript
func test_character_is_in_characters_group() -> void:
assert_true(_char.is_in_group("characters"))
func test_detach_item_preserves_global_position() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.global_position = Vector2(200.0, 300.0)
_char.attach_item("left", item)
# HandLeft is at offset (-32, -30) relative to character
var expected_global: Vector2 = _char.get_node("HandLeft").global_position
_char.detach_item("left")
assert_eq(item.global_position, expected_global)
```
Create `test/unit/test_holdable_item.gd`:
```gdscript
## Tests for HoldableItem — hand slot attachment on drag release.
extends GutTest
func test_holdable_item_id_default_empty() -> void:
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
assert_eq(item.item_id, "")
func test_holdable_item_is_not_in_hand_initially() -> void:
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
assert_false(item.is_in_hand_slot())
func test_holdable_item_attaches_to_nearest_free_hand() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
item.item_id = "test_item"
# Place item at HandLeft position
item.global_position = character.get_node("HandLeft").global_position
item._on_drag_released(item.global_position)
assert_true(item.is_in_hand_slot())
func test_holdable_item_does_not_attach_if_no_character_in_range() -> void:
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
item.global_position = Vector2(9999.0, 9999.0)
item._on_drag_released(item.global_position)
assert_false(item.is_in_hand_slot())
func test_holdable_item_does_not_attach_to_occupied_hand() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item1: HoldableItem = HoldableItem.new()
var item2: HoldableItem = HoldableItem.new()
add_child_autofree(item1)
add_child_autofree(item2)
# Fill left hand manually
character.attach_item("left", item1)
character.attach_item("right", item1) # fill both
# item2 tries to attach but both hands full
item2.global_position = character.global_position
item2._on_drag_released(item2.global_position)
assert_false(item2.is_in_hand_slot())
func test_holdable_item_detaches_on_pickup_when_in_slot() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
character.attach_item("left", item)
assert_true(item.is_in_hand_slot())
item._on_drag_picked_up(item.global_position)
assert_false(item.is_in_hand_slot())
func test_holdable_item_detach_preserves_global_position() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
character.attach_item("left", item)
var hand_pos: Vector2 = character.get_node("HandLeft").global_position
item._on_drag_picked_up(hand_pos)
assert_eq(item.global_position, hand_pos)
```
### Step 2: Run import + tests — verify new tests FAIL
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
Expected: 9 new failures. Existing 115 pass.
### Step 3: Modify `scripts/characters/character.gd`
**Change 1:** Add group registration in `_ready()` (after the drag connection block):
```gdscript
func _ready() -> void:
var drag: DragDropComponent = get_node_or_null("DragDropComponent") as DragDropComponent
if drag != null:
drag.drag_picked_up.connect(_on_drag_picked_up)
drag.drag_released.connect(_on_drag_released)
if data != null:
_update_visual_state()
_refresh_outfit_layers()
add_to_group("characters")
```
**Change 2:** Fix `detach_item` to preserve `global_position`:
```gdscript
func detach_item(hand: String) -> Node2D:
if hand != "left" and hand != "right":
return null
var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D
if slot == null or slot.get_child_count() == 0:
return null
var item: Node2D = slot.get_child(0) as Node2D
var saved_pos: Vector2 = item.global_position
slot.remove_child(item)
var scene_parent: Node = get_parent()
if scene_parent != null:
scene_parent.add_child(item)
item.global_position = saved_pos
return item
```
### Step 4: Create `scripts/objects/holdable_item.gd`
```gdscript
## HoldableItem — Node2D that can be held in a Character's HandLeft or HandRight slot.
## Attach DragDropComponent as a child. On drag_released scans "characters" group for
## the nearest free hand slot within HAND_SLOT_RADIUS.
class_name HoldableItem extends Node2D
signal item_picked_up(item: HoldableItem)
signal item_placed(item: HoldableItem)
const HAND_SLOT_RADIUS: float = 60.0
@export var item_id: String = ""
func _ready() -> void:
var drag: DragDropComponent = get_node_or_null("DragDropComponent") as DragDropComponent
if drag != null:
drag.drag_picked_up.connect(_on_drag_picked_up)
drag.drag_released.connect(_on_drag_released)
func _on_drag_picked_up(_pos: Vector2) -> void:
if is_in_hand_slot():
_detach_from_hand_slot()
item_picked_up.emit(self)
func _on_drag_released(_pos: Vector2) -> void:
var result: Array = _find_nearest_free_hand_slot()
if not result.is_empty():
var character: Character = result[0] as Character
var hand: String = result[1] as String
character.attach_item(hand, self)
item_placed.emit(self)
func is_in_hand_slot() -> bool:
var p: Node = get_parent()
if p == null:
return false
return p.name == "HandLeft" or p.name == "HandRight"
func _detach_from_hand_slot() -> void:
var hand_slot: Node = get_parent()
var character: Character = hand_slot.get_parent() as Character
if character == null:
return
var hand: String = "left" if hand_slot.name == "HandLeft" else "right"
character.detach_item(hand)
func _find_nearest_free_hand_slot() -> Array:
var best_dist: float = HAND_SLOT_RADIUS
var best_character: Character = null
var best_hand: String = ""
for node: Node in get_tree().get_nodes_in_group("characters"):
var character: Character = node as Character
if character == null:
continue
for hand: String in ["left", "right"]:
if not character.is_hand_free(hand):
continue
var slot: Node2D = character.get_node_or_null("Hand" + hand.capitalize()) as Node2D
if slot == null:
continue
var dist: float = global_position.distance_to(slot.global_position)
if dist < best_dist:
best_dist = dist
best_character = character
best_hand = hand
if best_character == null:
return []
return [best_character, best_hand]
```
### Step 5: Run import + tests — verify all pass (124 total)
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
Expected: 124/124 passed.
### Step 6: Commit
```bash
cd "F:/Development/_gameDev/Cozypaw-Hospital/.worktrees/sprint-17-hand-slots-outfits"
git add scripts/characters/character.gd
git add scripts/objects/holdable_item.gd
git add test/unit/test_character_v2.gd
git add test/unit/test_holdable_item.gd
git commit -m "feat(items): add HoldableItem with hand slot detection, fix detach_item position"
```
---
## Task 2: OutfitItem + apply-on-drop + tap-to-undress
**Files:**
- Modify: `scripts/characters/character.gd`
- Create: `scripts/objects/outfit_item.gd`
- Modify: `test/unit/test_character_v2.gd`
- Create: `test/unit/test_outfit_item.gd`
### Step 1: Write the failing tests
Add to `test/unit/test_character_v2.gd`:
```gdscript
func test_apply_outfit_item_hides_item() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(1, "white_coat", null, item)
assert_false(item.visible)
func test_apply_outfit_item_sets_outfit_data() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(1, "white_coat", null, item)
assert_eq(_char.get_outfit(1), "white_coat")
func test_remove_outfit_restores_item_visibility() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(2, "cast_arm", null, item)
_char.remove_outfit(2)
assert_true(item.visible)
func test_remove_outfit_clears_outfit_data() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(2, "cast_arm", null, item)
_char.remove_outfit(2)
assert_eq(_char.get_outfit(2), "")
func test_apply_outfit_item_replaces_existing() -> void:
var item1: Node2D = Node2D.new()
var item2: Node2D = Node2D.new()
add_child_autofree(item1)
add_child_autofree(item2)
_char.apply_outfit_item(1, "white_coat", null, item1)
_char.apply_outfit_item(1, "doctor_coat", null, item2)
assert_true(item1.visible)
assert_false(item2.visible)
assert_eq(_char.get_outfit(1), "doctor_coat")
func test_tap_removes_topmost_active_outfit_layer() -> void:
var item1: Node2D = Node2D.new()
var item3: Node2D = Node2D.new()
add_child_autofree(item1)
add_child_autofree(item3)
_char.apply_outfit_item(1, "white_coat", null, item1)
_char.apply_outfit_item(3, "stethoscope", null, item3)
_char._handle_outfit_tap()
# layer 3 is topmost active — it should be removed first
assert_eq(_char.get_outfit(3), "")
assert_eq(_char.get_outfit(1), "white_coat")
func test_tap_noop_when_no_outfit_active() -> void:
_char._handle_outfit_tap()
assert_eq(_char.get_outfit(1), "")
assert_eq(_char.get_outfit(2), "")
assert_eq(_char.get_outfit(3), "")
```
Create `test/unit/test_outfit_item.gd`:
```gdscript
## Tests for OutfitItem — applies outfit layer when dropped near a character.
extends GutTest
func test_outfit_item_default_layer_is_one() -> void:
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
assert_eq(item.outfit_layer, 1)
func test_outfit_item_applies_to_character_on_release_in_range() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var data: CharacterData = CharacterData.new()
character.data = data
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
item.outfit_sprite = null
# Place item near character
item.global_position = character.global_position
item._on_drag_released(item.global_position)
assert_eq(character.get_outfit(1), "white_coat")
func test_outfit_item_hides_after_applying() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var data: CharacterData = CharacterData.new()
character.data = data
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
item.global_position = character.global_position
item._on_drag_released(item.global_position)
assert_false(item.visible)
func test_outfit_item_falls_back_to_hand_slot_if_no_character_in_range() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
# Place item near hand slot but NOT near character body
item.global_position = character.get_node("HandLeft").global_position
item._on_drag_released(item.global_position)
# Should attach to hand slot, not apply as outfit
assert_true(item.is_in_hand_slot())
assert_eq(character.get_outfit(1), "")
func test_outfit_item_does_not_apply_if_far_from_character() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var data: CharacterData = CharacterData.new()
character.data = data
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
item.global_position = Vector2(9999.0, 9999.0)
item._on_drag_released(item.global_position)
assert_eq(character.get_outfit(1), "")
assert_true(item.visible)
```
### Step 2: Run tests — verify new tests FAIL
Expected: 12 new failures. Existing 124 pass.
### Step 3: Modify `scripts/characters/character.gd`
**Add** these new members and methods. Insert the `var` declarations after the existing `var _current_anim: String = "idle"` line:
```gdscript
var _current_anim: String = "idle"
var _drag_start_position: Vector2 = Vector2.ZERO
var _outfit_item_refs: Array = [null, null, null]
const _TAP_THRESHOLD: float = 10.0
```
**Replace** `_on_drag_picked_up`:
```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** `_on_drag_released`:
```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:
_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)
```
**Add** new public methods (before `_update_visual_state`):
```gdscript
func apply_outfit_item(layer: int, item_id: String, texture: Texture2D, item_node: Node2D) -> void:
if layer < 1 or layer > 3:
return
var i: int = layer - 1
var existing: Node2D = _outfit_item_refs[i] as Node2D
if existing != null:
existing.global_position = global_position + Vector2(0.0, 60.0)
existing.visible = true
_outfit_item_refs[i] = item_node
set_outfit(layer, item_id, texture)
if item_node != null:
item_node.visible = false
func remove_outfit(layer: int) -> void:
if layer < 1 or layer > 3:
return
var i: int = layer - 1
clear_outfit(layer)
var item_ref: Node2D = _outfit_item_refs[i] as Node2D
if item_ref != null:
_outfit_item_refs[i] = null
item_ref.global_position = global_position + Vector2(0.0, 60.0)
item_ref.visible = true
func _handle_outfit_tap() -> void:
for layer: int in range(3, 0, -1):
if not get_outfit(layer).is_empty():
remove_outfit(layer)
return
```
### Step 4: Create `scripts/objects/outfit_item.gd`
```gdscript
## OutfitItem — HoldableItem that applies an outfit layer to a Character when dropped
## within OUTFIT_APPLY_RADIUS of the character's center. If no character is in range,
## falls back to normal hand-slot attachment.
class_name OutfitItem extends HoldableItem
const OUTFIT_APPLY_RADIUS: float = 80.0
@export var outfit_layer: int = 1
@export var outfit_sprite: Texture2D
func _on_drag_released(_pos: Vector2) -> void:
var character: Character = _find_nearest_character()
if character != null:
character.apply_outfit_item(outfit_layer, item_id, outfit_sprite, self)
return
super._on_drag_released(_pos)
func _find_nearest_character() -> Character:
var best_dist: float = OUTFIT_APPLY_RADIUS
var best: Character = null
for node: Node in get_tree().get_nodes_in_group("characters"):
var character: Character = node as Character
if character == null:
continue
var dist: float = global_position.distance_to(character.global_position)
if dist < best_dist:
best_dist = dist
best = character
return best
```
### Step 5: Run import + tests — verify all pass (136 total)
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
Expected: 136/136 passed.
### Step 6: Commit
```bash
git add scripts/characters/character.gd
git add scripts/objects/outfit_item.gd
git add test/unit/test_character_v2.gd
git add test/unit/test_outfit_item.gd
git commit -m "feat(items): add OutfitItem with apply-on-drop and tap-to-undress on Character"
```
---
## Task 3: GameState v2 — outfit + held items persisted
**Files:**
- Modify: `scripts/characters/character_data.gd`
- Modify: `scripts/autoload/GameState.gd`
- Modify: `test/unit/test_game_state.gd`
### Step 1: Write the failing tests
Check if `test/unit/test_game_state.gd` exists. If not, create it. If it exists, append these tests:
```gdscript
## Tests for GameState — character outfit and held-item persistence.
extends GutTest
func before_each() -> void:
GameState.apply_save_data({})
func test_character_data_has_held_left_field() -> void:
var cd: CharacterData = CharacterData.new()
assert_eq(cd.held_left, "")
func test_character_data_has_held_right_field() -> void:
var cd: CharacterData = CharacterData.new()
assert_eq(cd.held_right, "")
func test_set_character_outfit_stores_value() -> void:
GameState.set_character_outfit("bunny_f", ["white_coat", "", "stethoscope"])
assert_eq(GameState.get_character_outfit("bunny_f"), ["white_coat", "", "stethoscope"])
func test_get_character_outfit_returns_empty_array_for_unknown() -> void:
var result: Array = GameState.get_character_outfit("unknown_id")
assert_eq(result, ["", "", ""])
func test_set_character_held_item_left() -> void:
GameState.set_character_held_item("bunny_f", "left", "medicine_blue")
assert_eq(GameState.get_character_held_item("bunny_f", "left"), "medicine_blue")
func test_get_character_held_item_returns_empty_for_unknown() -> void:
assert_eq(GameState.get_character_held_item("unknown", "left"), "")
func test_save_data_includes_outfit() -> void:
GameState.set_character_outfit("bunny_f", ["white_coat", "", ""])
var data: Dictionary = GameState.get_save_data()
assert_true(data.has("character_outfits"))
assert_eq(data["character_outfits"]["bunny_f"], ["white_coat", "", ""])
func test_save_data_includes_held_items() -> void:
GameState.set_character_held_item("bunny_f", "right", "medicine_blue")
var data: Dictionary = GameState.get_save_data()
assert_true(data.has("character_held_items"))
assert_eq(data["character_held_items"]["bunny_f"]["right"], "medicine_blue")
func test_apply_save_data_restores_outfit() -> void:
var save: Dictionary = {
"character_outfits": {
"bunny_f": ["doctor_coat", "", "stethoscope"]
}
}
GameState.apply_save_data(save)
assert_eq(GameState.get_character_outfit("bunny_f"), ["doctor_coat", "", "stethoscope"])
func test_apply_save_data_restores_held_items() -> void:
var save: Dictionary = {
"character_held_items": {
"kitten_f": {"left": "gel_tube", "right": ""}
}
}
GameState.apply_save_data(save)
assert_eq(GameState.get_character_held_item("kitten_f", "left"), "gel_tube")
```
### Step 2: Run tests — verify new tests FAIL
Expected: 11 new failures. Existing 136 pass.
### Step 3: Modify `scripts/characters/character_data.gd`
Add two new fields after `outfit`:
```gdscript
## CharacterData — Resource holding all persistent state for one character.
class_name CharacterData extends Resource
enum State { HEALTHY, SICK, SLEEPING, TIRED, PREGNANT, BABY }
enum Species { BUNNY, KITTEN }
@export var id: String = ""
@export var display_name: String = ""
@export var species: Species = Species.BUNNY
@export var state: State = State.HEALTHY
@export var current_floor: int = 0
@export var position: Vector2 = Vector2.ZERO
@export var outfit: Array[String] = ["", "", ""]
@export var held_left: String = ""
@export var held_right: String = ""
```
### Step 4: Modify `scripts/autoload/GameState.gd`
Full replacement of the file to add outfit + held-item tracking:
```gdscript
## GameState — global game state: character positions, outfit, held items, object states, current room.
extends Node
signal state_changed
signal character_moved(character_id: String, position: Vector2)
var _character_positions: Dictionary = {}
var _character_outfits: Dictionary = {}
var _character_held_items: Dictionary = {}
var _object_states: Dictionary = {}
var current_room: String = "reception"
var music_volume: float = 0.6
var sfx_volume: float = 1.0
func has_character_position(id: String) -> bool:
return _character_positions.has(id)
func get_character_position(id: String) -> Vector2:
return _character_positions.get(id, Vector2.ZERO)
func set_character_position(id: String, pos: Vector2) -> void:
_character_positions[id] = pos
character_moved.emit(id, pos)
state_changed.emit()
func get_character_outfit(id: String) -> Array:
return _character_outfits.get(id, ["", "", ""])
func set_character_outfit(id: String, outfit: Array) -> void:
_character_outfits[id] = outfit
state_changed.emit()
func get_character_held_item(id: String, hand: String) -> String:
if not _character_held_items.has(id):
return ""
return _character_held_items[id].get(hand, "")
func set_character_held_item(id: String, hand: String, item_id: String) -> void:
if not _character_held_items.has(id):
_character_held_items[id] = {"left": "", "right": ""}
_character_held_items[id][hand] = item_id
state_changed.emit()
func get_object_state(id: String) -> String:
return _object_states.get(id, "idle")
func set_object_state(id: String, state: String) -> void:
_object_states[id] = state
state_changed.emit()
func get_save_data() -> Dictionary:
var positions: Dictionary = {}
for key: String in _character_positions:
var pos: Vector2 = _character_positions[key]
positions[key] = [pos.x, pos.y]
return {
"version": 2,
"character_positions": positions,
"character_outfits": _character_outfits.duplicate(true),
"character_held_items": _character_held_items.duplicate(true),
"object_states": _object_states,
"current_room": current_room,
"music_volume": music_volume,
"sfx_volume": sfx_volume,
}
func apply_save_data(data: Dictionary) -> void:
if data.has("character_positions"):
_character_positions = {}
for key: String in data["character_positions"]:
var val: Variant = data["character_positions"][key]
if val is Array and val.size() >= 2:
_character_positions[key] = Vector2(val[0], val[1])
if data.has("character_outfits"):
_character_outfits = data["character_outfits"].duplicate(true)
else:
_character_outfits = {}
if data.has("character_held_items"):
_character_held_items = data["character_held_items"].duplicate(true)
else:
_character_held_items = {}
if data.has("object_states"):
_object_states = data["object_states"]
if data.has("current_room"):
current_room = data["current_room"]
if data.has("music_volume"):
music_volume = data["music_volume"]
if data.has("sfx_volume"):
sfx_volume = data["sfx_volume"]
```
### Step 5: Run import + tests — verify all pass (147 total)
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import 2>&1
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
Expected: 147/147 passed.
### Step 6: Commit
```bash
git add scripts/characters/character_data.gd
git add scripts/autoload/GameState.gd
git add test/unit/test_game_state.gd
git commit -m "feat(save): extend GameState to v2 — outfit and held items persisted per character"
```
---
## Final Check
- [ ] **Run full test suite one last time**
```
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit 2>&1
```
Expected output:
```
Scripts 13
Tests 147
Passing Tests 147
---- All tests passed! ----
```
- [ ] **Verify git log shows 3 clean commits**
```bash
git log --oneline -4
```
Expected:
```
feat(save): extend GameState to v2 — outfit and held items persisted per character
feat(items): add OutfitItem with apply-on-drop and tap-to-undress on Character
feat(items): add HoldableItem with hand slot detection, fix detach_item position
```
---
## Notes for implementer
- **`_outfit_item_refs` typed as `Array` not `Array[Node2D]`** — GDScript typed arrays with null defaults can cause issues in some Godot versions. Use untyped `Array` with explicit `as Node2D` casts.
- **`test_outfit_item_falls_back_to_hand_slot_if_no_character_in_range`** — This test places the item near a HandLeft but NOT near the character body. The OUTFIT_APPLY_RADIUS (80px) check on `character.global_position` will fail (HandLeft is only 32px offset), but since the HandLeft is within HAND_SLOT_RADIUS (60px) of HandLeft position, the HoldableItem fallback will attach it to the hand. Verify position arithmetic in your test setup.
- **`before_each` in test_game_state.gd calls `GameState.apply_save_data({})`** — this resets state between tests. The autoload singleton persists between GUT test functions, so the reset is mandatory.
- **`_handle_outfit_tap` is public** (no underscore would be cleaner, but the existing convention uses underscore for private methods). Keep the underscore — tests call it directly. This is acceptable for unit testing.
- **`_TAP_THRESHOLD`** needs to be a constant not a `const` with underscore (underscore prefix = private, which is fine here since it's internal to character.gd).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,574 @@
# Sprint 19 — AudioManager Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an AudioManager autoload that plays floor-based background music with cross-fade and fires SFX for every player interaction — chest taps, item spawning, drag, drop, outfit apply, and chest return.
**Architecture:** AudioManager extends Node (autoload, already registered in project.godot). Two AudioStreamPlayer children ping-pong for cross-fade (0.8 s). One SfxPlayer child handles all SFX. Floor is derived from `GameState.current_room` via a pure lookup function. All 7 SFX events are wired in via direct `AudioManager.play_sfx()` calls (autoload = globally accessible). RoomChest gains a tap handler (`_unhandled_input`) to trigger item spawning.
**Tech Stack:** GDScript 4 (static types), GUT v9.6.0, freesound.org CC0/CC-BY assets, ffmpeg for .ogg conversion if needed.
---
### Task 1: Audio Assets
**Files:**
- Create: `assets/audio/music/floor_0.ogg` through `floor_3.ogg`
- Create: `assets/audio/sfx/chest_tap.ogg`, `item_spawn.ogg`, `item_drag_start.ogg`, `item_drop_hand.ogg`, `item_drop_outfit.ogg`, `item_return_chest.ogg`, `item_drop_floor.ogg`
- [ ] **Step 1: Create directories**
```bash
mkdir -p "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/music"
mkdir -p "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/sfx"
```
- [ ] **Step 2: Download music tracks from freesound.org**
Search freesound.org for each track. Requirements: CC0 or CC-BY licence, loopable (end ≈ start), 3060 s, child-appropriate, no sudden loud sounds. Save as `floor_N.ogg` directly or download and rename.
| File | Search query on freesound.org | Character |
|---|---|---|
| `floor_0.ogg` | `children hospital cheerful loop` | Heiter, belebte Lobby |
| `floor_1.ogg` | `calm ambient medical loop` | Ruhig, klinisch |
| `floor_2.ogg` | `gentle nursery lullaby loop` | Sanft, Wiegenlied |
| `floor_3.ogg` | `garden birds outdoor ambient loop` | Draußen, Vogelgezwitscher |
If a result is `.mp3` or `.wav`, convert with ffmpeg:
```bash
ffmpeg -i input.mp3 -c:a libvorbis -q:a 4 floor_0.ogg
```
- [ ] **Step 3: Download SFX from freesound.org**
Requirements: CC0 or CC-BY, < 0.5 s each, no startling sounds.
| File | Search query | Target duration |
|---|---|---|
| `chest_tap.ogg` | `wood tap short` | < 0.3 s |
| `item_spawn.ogg` | `pop whoosh soft` | < 0.5 s |
| `item_drag_start.ogg` | `pickup soft short` | < 0.3 s |
| `item_drop_hand.ogg` | `light click short` | < 0.2 s |
| `item_drop_outfit.ogg` | `fabric swoosh short` | < 0.5 s |
| `item_return_chest.ogg` | `soft click snap` | < 0.2 s |
| `item_drop_floor.ogg` | `soft thud light` | < 0.3 s |
Convert to `.ogg` with ffmpeg if needed (same command as above, -q:a 6 for SFX).
- [ ] **Step 4: Verify all 11 files are present**
```bash
ls "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/music/"
ls "F:/Development/_gameDev/Cozypaw-Hospital/assets/audio/sfx/"
```
Expected: 4 music files, 7 SFX files.
- [ ] **Step 5: Commit**
```bash
git add assets/audio/music/ assets/audio/sfx/
git commit -m "assets: add floor music and SFX for Sprint 19"
```
---
### Task 2: AudioManager Script + Tests
**Files:**
- Create: `scripts/autoload/AudioManager.gd`
- Create: `test/unit/test_audio_manager.gd`
Note: `AudioManager` is already registered in `project.godot` as `"*res://scripts/autoload/AudioManager.gd"`. Do NOT add `class_name AudioManager` to the script (Godot 4 autoload + class_name conflict — see CLAUDE.md memory).
- [ ] **Step 1: Write the failing tests**
Create `test/unit/test_audio_manager.gd`:
```gdscript
## Tests for AudioManager — floor derivation, no-op guard, SFX key validation.
extends GutTest
func before_each() -> void:
AudioManager._current_floor = -1
AudioManager._is_crossfading = false
func test_derive_floor_floor0_reception() -> void:
assert_eq(AudioManager._derive_floor_from_room("reception"), 0)
func test_derive_floor_floor0_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("giftshop"), 0)
assert_eq(AudioManager._derive_floor_from_room("restaurant"), 0)
assert_eq(AudioManager._derive_floor_from_room("emergency"), 0)
func test_derive_floor_floor1_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("xray"), 1)
assert_eq(AudioManager._derive_floor_from_room("pharmacy"), 1)
assert_eq(AudioManager._derive_floor_from_room("lab"), 1)
assert_eq(AudioManager._derive_floor_from_room("patient_rooms"), 1)
func test_derive_floor_floor2_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("ultrasound"), 2)
assert_eq(AudioManager._derive_floor_from_room("delivery_room"), 2)
assert_eq(AudioManager._derive_floor_from_room("nursery"), 2)
func test_derive_floor_garden() -> void:
assert_eq(AudioManager._derive_floor_from_room("garden_party"), 3)
func test_derive_floor_unknown_returns_minus_one() -> void:
assert_eq(AudioManager._derive_floor_from_room("unknown_room"), -1)
assert_eq(AudioManager._derive_floor_from_room(""), -1)
func test_get_current_floor_starts_at_minus_one() -> void:
assert_eq(AudioManager.get_current_floor(), -1)
func test_play_floor_music_same_floor_is_noop() -> void:
AudioManager._current_floor = 0
AudioManager.play_floor_music(0)
assert_eq(AudioManager.get_current_floor(), 0)
func test_play_sfx_unknown_key_does_not_crash() -> void:
AudioManager.play_sfx("nonexistent_event_xyz")
pass
func test_sfx_map_has_all_seven_keys() -> void:
assert_true(AudioManager._SFX_MAP.has("chest_tap"))
assert_true(AudioManager._SFX_MAP.has("item_spawn"))
assert_true(AudioManager._SFX_MAP.has("item_drag_start"))
assert_true(AudioManager._SFX_MAP.has("item_drop_hand"))
assert_true(AudioManager._SFX_MAP.has("item_drop_outfit"))
assert_true(AudioManager._SFX_MAP.has("item_return_chest"))
assert_true(AudioManager._SFX_MAP.has("item_drop_floor"))
func test_music_map_has_all_four_floors() -> void:
assert_true(AudioManager._MUSIC_MAP.has(0))
assert_true(AudioManager._MUSIC_MAP.has(1))
assert_true(AudioManager._MUSIC_MAP.has(2))
assert_true(AudioManager._MUSIC_MAP.has(3))
```
- [ ] **Step 2: Run to verify FAIL**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
```
Expected: FAIL — `AudioManager` script missing.
- [ ] **Step 3: Create AudioManager.gd**
Create `scripts/autoload/AudioManager.gd`:
```gdscript
## AudioManager — floor music with cross-fade and SFX for player interactions.
## Autoload. Do NOT add class_name (Godot 4 autoload conflict).
extends Node
const CROSSFADE_DURATION: float = 0.8
const _MUSIC_MAP: Dictionary = {
0: "res://assets/audio/music/floor_0.ogg",
1: "res://assets/audio/music/floor_1.ogg",
2: "res://assets/audio/music/floor_2.ogg",
3: "res://assets/audio/music/floor_3.ogg",
}
const _SFX_MAP: Dictionary = {
"chest_tap": "res://assets/audio/sfx/chest_tap.ogg",
"item_spawn": "res://assets/audio/sfx/item_spawn.ogg",
"item_drag_start": "res://assets/audio/sfx/item_drag_start.ogg",
"item_drop_hand": "res://assets/audio/sfx/item_drop_hand.ogg",
"item_drop_outfit": "res://assets/audio/sfx/item_drop_outfit.ogg",
"item_return_chest": "res://assets/audio/sfx/item_return_chest.ogg",
"item_drop_floor": "res://assets/audio/sfx/item_drop_floor.ogg",
}
var _current_floor: int = -1
var _is_crossfading: bool = false
var _active_player: AudioStreamPlayer
var _music_a: AudioStreamPlayer
var _music_b: AudioStreamPlayer
var _sfx_player: AudioStreamPlayer
func _ready() -> void:
_music_a = AudioStreamPlayer.new()
_music_b = AudioStreamPlayer.new()
_sfx_player = AudioStreamPlayer.new()
add_child(_music_a)
add_child(_music_b)
add_child(_sfx_player)
_active_player = _music_a
_music_a.volume_db = linear_to_db(GameState.music_volume)
_music_b.volume_db = linear_to_db(0.0)
_sfx_player.volume_db = linear_to_db(GameState.sfx_volume)
GameState.state_changed.connect(_on_game_state_changed)
var initial_floor: int = _derive_floor_from_room(GameState.current_room)
if initial_floor != -1:
play_floor_music(initial_floor)
func play_floor_music(floor: int) -> void:
if floor == _current_floor:
return
if not _MUSIC_MAP.has(floor):
return
if _is_crossfading:
return
_is_crossfading = true
_current_floor = floor
var inactive: AudioStreamPlayer = _music_b if _active_player == _music_a else _music_a
var stream: AudioStream = load(_MUSIC_MAP[floor]) as AudioStream
if stream == null:
_is_crossfading = false
return
inactive.stream = stream
inactive.volume_db = linear_to_db(0.0)
inactive.play()
var tween: Tween = create_tween().set_parallel(true)
tween.tween_property(inactive, "volume_db", linear_to_db(GameState.music_volume), CROSSFADE_DURATION)
tween.tween_property(_active_player, "volume_db", linear_to_db(0.0), CROSSFADE_DURATION)
await tween.finished
_active_player.stop()
_active_player = inactive
_is_crossfading = false
func play_sfx(event: String) -> void:
if not _SFX_MAP.has(event):
return
var stream: AudioStream = load(_SFX_MAP[event]) as AudioStream
if stream == null:
return
_sfx_player.stream = stream
_sfx_player.play()
func set_music_volume(vol: float) -> void:
GameState.music_volume = vol
_active_player.volume_db = linear_to_db(vol)
func set_sfx_volume(vol: float) -> void:
GameState.sfx_volume = vol
_sfx_player.volume_db = linear_to_db(vol)
func get_current_floor() -> int:
return _current_floor
func _on_game_state_changed() -> void:
var floor: int = _derive_floor_from_room(GameState.current_room)
if floor != -1:
play_floor_music(floor)
func _derive_floor_from_room(room: String) -> int:
match room:
"reception", "giftshop", "restaurant", "emergency":
return 0
"xray", "pharmacy", "lab", "patient_rooms":
return 1
"ultrasound", "delivery_room", "nursery":
return 2
"garden_party":
return 3
return -1
```
- [ ] **Step 4: Run to verify PASS**
```bash
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless --import
"F:/Development/_tools/Godot_v4.6.2-stable_win64/Godot_v4.6.2-stable_win64.exe" --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/ -gexit
```
Expected: all previous tests pass + 12 new AudioManager tests pass. Total ≥ 208.
- [ ] **Step 5: Commit**
```bash
git add scripts/autoload/AudioManager.gd test/unit/test_audio_manager.gd
git commit -m "feat(audio): add AudioManager with floor music cross-fade and SFX"
```
---
### Task 3: RoomChest Tap Handler + SFX
**Files:**
- Modify: `scripts/objects/room_chest.gd`
- Modify: `test/unit/test_room_chest.gd` (append 2 tests)
Currently `RoomChest` has no input handler — tapping the chest does nothing. This task adds `_unhandled_input` with a helper `_get_press_position()` and wires the two chest SFX events.
- [ ] **Step 1: Append failing tests to test_room_chest.gd**
Add at the end of `test/unit/test_room_chest.gd`:
```gdscript
func test_get_press_position_returns_position_for_screen_touch_pressed() -> void:
var chest: RoomChest = RoomChest.new()
add_child_autofree(chest)
var event: InputEventScreenTouch = InputEventScreenTouch.new()
event.pressed = true
event.position = Vector2(100.0, 200.0)
assert_eq(chest._get_press_position(event), Vector2(100.0, 200.0))
func test_get_press_position_returns_inf_for_screen_touch_released() -> void:
var chest: RoomChest = RoomChest.new()
add_child_autofree(chest)
var event: InputEventScreenTouch = InputEventScreenTouch.new()
event.pressed = false
event.position = Vector2(100.0, 200.0)
assert_eq(chest._get_press_position(event), Vector2.INF)
```
- [ ] **Step 2: Run to verify FAIL**
```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
```
Expected: FAIL — `_get_press_position` not defined on RoomChest.
- [ ] **Step 3: Update room_chest.gd**
The full updated file (replace existing content):
```gdscript
## RoomChest — tappable storage node. Spawns HoldableItem/OutfitItem instances on demand.
## Items fly out with a tween. Receives items back via receive_item().
class_name RoomChest extends Node2D
signal items_spawned(chest: RoomChest)
signal item_received(chest: RoomChest, item_id: String)
const SPAWN_TWEEN_DURATION: float = 0.3
@export var chest_id: String = ""
@export var tap_radius: float = 50.0
var _spawned_items: Array[HoldableItem] = []
var _item_configs: Array[ChestItemData] = []
func _ready() -> void:
add_to_group("room_chests")
_item_configs = RoomChestConfig.get_items(chest_id)
if not chest_id.is_empty() and GameState.has_method("get_chest_state"):
if not GameState.get_chest_state(chest_id).is_empty():
call_deferred("spawn_items")
func _unhandled_input(event: InputEvent) -> void:
var press_pos: Vector2 = _get_press_position(event)
if press_pos == Vector2.INF:
return
var canvas_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * press_pos
if canvas_pos.distance_to(global_position) > tap_radius:
return
get_viewport().set_input_as_handled()
AudioManager.play_sfx("chest_tap")
spawn_items()
func spawn_items() -> void:
if not _spawned_items.is_empty():
return
AudioManager.play_sfx("item_spawn")
var parent: Node = get_parent()
for config: ChestItemData in _item_configs:
var item: HoldableItem = _create_item(config)
item.home_chest = self
if parent != null:
parent.add_child(item)
else:
add_child(item)
item.global_position = global_position
_spawned_items.append(item)
_tween_item_out(item, config.spawn_offset)
if GameState.has_method("set_chest_state"):
GameState.set_chest_state(chest_id, _get_spawned_ids())
items_spawned.emit(self)
func receive_item(item: HoldableItem) -> void:
if not _spawned_items.has(item):
return
_spawned_items.erase(item)
if GameState.has_method("set_chest_state"):
if _spawned_items.is_empty():
GameState.clear_chest_state(chest_id)
else:
GameState.set_chest_state(chest_id, _get_spawned_ids())
item_received.emit(self, item.item_id)
_tween_item_in(item)
func are_items_spawned() -> bool:
return not _spawned_items.is_empty()
func get_spawned_count() -> int:
return _spawned_items.size()
func get_item_config_count() -> int:
return _item_configs.size()
func get_spawned_item(index: int) -> HoldableItem:
if index < 0 or index >= _spawned_items.size():
return null
return _spawned_items[index]
func _get_press_position(event: InputEvent) -> Vector2:
if event is InputEventScreenTouch and event.pressed:
return event.position
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
return event.position
return Vector2.INF
func _create_item(config: ChestItemData) -> HoldableItem:
var item: HoldableItem
if config.item_type == ChestItemData.ItemType.OUTFIT:
var outfit: OutfitItem = OutfitItem.new()
outfit.outfit_layer = config.outfit_layer
item = outfit
else:
item = HoldableItem.new()
item.item_id = config.item_id
return item
func _get_spawned_ids() -> Array[String]:
var ids: Array[String] = []
for item: HoldableItem in _spawned_items:
ids.append(item.item_id)
return ids
func _tween_item_out(item: HoldableItem, offset: Vector2) -> void:
var tween: Tween = create_tween()
tween.tween_property(item, "global_position", global_position + offset, SPAWN_TWEEN_DURATION)
func _tween_item_in(item: HoldableItem) -> void:
var tween: Tween = create_tween()
tween.tween_property(item, "global_position", global_position, SPAWN_TWEEN_DURATION)
tween.tween_callback(item.queue_free)
```
- [ ] **Step 4: Run to verify PASS**
```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
```
Expected: all tests pass including 2 new `_get_press_position` tests. Total ≥ 210.
- [ ] **Step 5: Commit**
```bash
git add scripts/objects/room_chest.gd test/unit/test_room_chest.gd
git commit -m "feat(audio): add tap handler and SFX to RoomChest"
```
---
### Task 4: HoldableItem + OutfitItem SFX
**Files:**
- Modify: `scripts/objects/holdable_item.gd`
- Modify: `scripts/objects/outfit_item.gd`
Pure one-liner additions. No new tests — existing 196+ tests verify no regressions.
- [ ] **Step 1: Update holdable_item.gd**
Replace the three methods `_on_drag_picked_up`, `_on_drag_released`, and `_try_return_to_chest` with the versions below. Everything else in the file stays identical.
```gdscript
func _on_drag_picked_up(_pos: Vector2) -> void:
if is_in_hand_slot():
_detach_from_hand_slot()
AudioManager.play_sfx("item_drag_start")
item_picked_up.emit(self)
func _on_drag_released(_pos: Vector2) -> void:
if _try_return_to_chest():
return
var result: Array = _find_nearest_free_hand_slot()
if not result.is_empty():
var character: Character = result[0] as Character
var hand: String = result[1] as String
character.attach_item(hand, self)
AudioManager.play_sfx("item_drop_hand")
else:
AudioManager.play_sfx("item_drop_floor")
item_placed.emit(self)
func _try_return_to_chest() -> bool:
if home_chest == null:
return false
if global_position.distance_to(home_chest.global_position) >= CHEST_RETURN_RADIUS:
return false
var chest: RoomChest = home_chest as RoomChest
if chest == null:
return false
AudioManager.play_sfx("item_return_chest")
chest.receive_item(self)
return true
```
- [ ] **Step 2: Update outfit_item.gd**
Replace `_on_drag_released` with:
```gdscript
func _on_drag_released(_pos: Vector2) -> void:
if _try_return_to_chest():
return
var character: Character = _find_nearest_character()
if character != null:
character.apply_outfit_item(outfit_layer, item_id, outfit_sprite, self)
AudioManager.play_sfx("item_drop_outfit")
return
super._on_drag_released(_pos)
```
- [ ] **Step 3: Run full test suite**
```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
```
Expected: all tests pass. Total ≥ 210 (no new tests added in this task).
- [ ] **Step 4: Commit**
```bash
git add scripts/objects/holdable_item.gd scripts/objects/outfit_item.gd
git commit -m "feat(audio): wire SFX into HoldableItem and OutfitItem"
```
@@ -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,84 @@
# Sprint 18 — Room Chests & Item Spawning Design Spec
## Goal
Implement a `RoomChest` system: tappable storage nodes in every room that spawn `HoldableItem`/`OutfitItem` instances with a fly-out tween. Items persist in the world until manually dragged back to their chest. All 12 existing rooms get populated with placeholder items (string IDs, no textures yet).
## Architecture
### Data Layer
**`ChestItemData`** — `Resource` subclass, one entry per item slot in a chest.
| Field | Type | Purpose |
|---|---|---|
| `item_id` | `String` | Canonical item identifier, e.g. `"stethoscope"` |
| `item_type` | `String` | `"holdable"` or `"outfit"` |
| `outfit_layer` | `int` | 13, only relevant when `item_type == "outfit"` |
| `spawn_offset` | `Vector2` | Relative target position for fly-out tween |
**`RoomChest`** — `Node2D` subclass.
| Field | Type | Purpose |
|---|---|---|
| `chest_id` | `String` | Unique identifier, e.g. `"pharmacy_medicine"` |
| `items` | `Array[ChestItemData]` | Configured items for this chest |
| `_spawned_items` | `Array` | Currently live item nodes in the world |
### Spawn Behaviour
1. Player taps `RoomChest`.
2. If `_spawned_items` is not empty → no-op (already spawned).
3. Otherwise: for each `ChestItemData`, instantiate a `HoldableItem` or `OutfitItem` node. Set `item.home_chest = self`. Reparent to the room scene (not the chest). Tween `global_position` from `chest.global_position` to `chest.global_position + item_data.spawn_offset`.
### Return Behaviour
`HoldableItem._on_drag_released` checks (in priority order):
1. **Chest return** — if `home_chest != null` and `global_position.distance_to(home_chest.global_position) < CHEST_RETURN_RADIUS (80.0)`: tween back to chest → `home_chest.receive_item(self)``queue_free()`.
2. **Outfit apply** — existing OutfitItem logic (80 px body proximity).
3. **Hand slot attach** — existing HoldableItem logic (60 px hand slot proximity).
`RoomChest.receive_item(item: HoldableItem)` removes the item from `_spawned_items`.
### GameState v3
Extend `GameState` with `_chest_states: Dictionary` mapping `chest_id → Array[String]` of currently spawned `item_id`s. Persist in save data as `"version": 3`. Item positions within the room are **not** persisted (deliberate simplification). On load: if a chest has entries in `_chest_states`, re-spawn those items via the normal fly-out tween from the chest — they land at their configured `spawn_offset`, not their last dragged position.
## Room Population
All 12 existing room `.tscn` files get `RoomChest` nodes. No textures in this sprint — `item_id` strings only.
| Room | chest_id | item_ids |
|---|---|---|
| Reception | `reception_desk` | `clipboard`, `pen`, `bandage` |
| GiftShop | `giftshop_shelf` | `gift_box`, `ribbon`, `balloon` |
| Restaurant | `restaurant_counter` | `teacup`, `plate`, `spoon` |
| Emergency | `emergency_cabinet` | `bandage_roll`, `syringe`, `ice_pack` |
| XRay | `xray_cabinet` | `xray_sheet`, `lead_apron`*, `marker` |
| Pharmacy | `pharmacy_medicine` | `pill_bottle`, `syrup` |
| Pharmacy | `pharmacy_tools` | `mortar`, `spatula` |
| Lab | `lab_bench` | `test_tube`, `pipette`, `microscope_slide` |
| PatientRooms | `patient_cabinet` | `thermometer`, `stethoscope`*, `pillow` |
| Ultrasound | `ultrasound_cart` | `gel_tube`, `probe`, `towel` |
| DeliveryRoom | `delivery_cabinet` | `swaddle`*, `scissors`, `cord_clamp` |
| Nursery | `nursery_shelf` | `bottle`, `rattle`, `blanket`* |
| GardenParty | `garden_table` | `teapot`, `cake` |
| GardenParty | `garden_storage` | `confetti`, `party_hat` |
`*` = `item_type: "outfit"` (layer: `lead_apron`→1, `stethoscope`→2, `swaddle`→1, `blanket`→1)
## Testing
- `test/unit/test_room_chest.gd` — unit tests for `RoomChest` and `ChestItemData`
- `test/unit/test_game_state.gd` — appended tests for GameState v3 chest state
- `test/unit/test_holdable_item.gd` — appended tests for chest-return priority
Tests follow GUT v9.6.0, `extends GutTest`, `add_child_autofree`.
## Out of Scope
- Item textures / sprites (Sprint 18 uses null textures throughout)
- Chest open/close sprite animation (visual polish, later sprint)
- Per-item sounds (AudioManager sprint)
- Chest state per save-slot (single save file, existing SaveManager)
@@ -0,0 +1,192 @@
# Sprint 19 — AudioManager & SFX Design Spec
## Goal
Implement an `AudioManager` autoload that provides background music (per floor, with cross-fade) and SFX feedback for all player interactions. Real audio assets sourced from freesound.org (CC0/CC-BY). All 7 SFX events and 4 music tracks wired up by end of sprint.
## Architecture
### AudioManager (Autoload)
`scripts/autoload/AudioManager.gd` — Node with three child AudioStreamPlayers:
```
AudioManager (Node)
├── MusicA (AudioStreamPlayer) # Cross-fade ping-pong player A
├── MusicB (AudioStreamPlayer) # Cross-fade ping-pong player B
└── SfxPlayer (AudioStreamPlayer) # Single SFX player (no polyphony this sprint)
```
**Startup:** reads `GameState.music_volume` and `GameState.sfx_volume`, applies to players. Connects to `GameState.state_changed`.
**`_on_game_state_changed()`:** reads `GameState.current_room`, calls `_derive_floor_from_room()`, compares to `_current_floor`. If floor changed → `play_floor_music(new_floor)`.
**`play_floor_music(floor: int) -> void`:** public API. If `floor == _current_floor` → no-op. Otherwise: inactive player loads new stream, tweens volume from 0 → `GameState.music_volume` over 0.8 s; active player tweens volume → 0 over 0.8 s, then stops. Players swap active/inactive roles.
**`play_sfx(event: String) -> void`:** looks up event key in `_SFX_MAP: Dictionary`, loads stream, plays on `SfxPlayer`. Volume = `GameState.sfx_volume`.
**`set_music_volume(vol: float) -> void`** and **`set_sfx_volume(vol: float) -> void`:** update `GameState` and apply immediately to active players.
**`get_current_floor() -> int`:** returns `_current_floor` (-1 if none set).
### Internal State
```gdscript
var _current_floor: int = -1
var _active_player: AudioStreamPlayer # points to MusicA or MusicB
```
### Floor Derivation
`_derive_floor_from_room(room: String) -> int` — pure function, no side effects:
| Room strings | Floor |
|---|---|
| `"reception"`, `"giftshop"`, `"restaurant"`, `"emergency"` | `0` |
| `"xray"`, `"pharmacy"`, `"lab"`, `"patient_rooms"` | `1` |
| `"ultrasound"`, `"delivery_room"`, `"nursery"` | `2` |
| `"garden_party"` | `3` |
| anything else | `-1` |
## SFX Event Mapping
| Event key | Trigger location | When |
|---|---|---|
| `chest_tap` | `room_chest.gd` | Chest tapped, before spawn |
| `item_spawn` | `room_chest.gd` | Item tween starts (fly-out) |
| `item_drag_start` | `holdable_item.gd` | `_on_drag_picked_up` |
| `item_drop_hand` | `holdable_item.gd` | Item attached to hand slot |
| `item_drop_outfit` | `outfit_item.gd` | Outfit applied to character |
| `item_return_chest` | `holdable_item.gd` | `_try_return_to_chest` succeeds |
| `item_drop_floor` | `holdable_item.gd` | `_on_drag_released` — no target found |
All calls: `AudioManager.play_sfx("event_key")` (autoload, one line, no wiring).
## Asset Specification
### Directory Layout
```
assets/audio/music/
floor_0.ogg
floor_1.ogg
floor_2.ogg
floor_3.ogg
assets/audio/sfx/
chest_tap.ogg
item_spawn.ogg
item_drag_start.ogg
item_drop_hand.ogg
item_drop_outfit.ogg
item_return_chest.ogg
item_drop_floor.ogg
```
### Music — freesound.org Search Terms
| File | Search terms | Character |
|---|---|---|
| `floor_0.ogg` | `"children hospital happy loop"` | Heiter, belebte Lobby |
| `floor_1.ogg` | `"calm medical ambient loop"` | Ruhig, klinisch |
| `floor_2.ogg` | `"nursery lullaby gentle loop"` | Sanft, Wiegenlied |
| `floor_3.ogg` | `"garden birds outdoor loop"` | Draußen, Vogelgezwitscher |
Requirements: CC0 or CC-BY, loopable (seamless start/end), 3060 s duration, `.ogg` or convertible.
### SFX — freesound.org Search Terms
| File | Search terms | Target duration |
|---|---|---|
| `chest_tap.ogg` | `"wooden box tap"` | < 0.3 s |
| `item_spawn.ogg` | `"soft whoosh pop"` | < 0.5 s |
| `item_drag_start.ogg` | `"soft pickup"` | < 0.3 s |
| `item_drop_hand.ogg` | `"light click"` | < 0.2 s |
| `item_drop_outfit.ogg` | `"fabric swoosh"` | < 0.5 s |
| `item_return_chest.ogg` | `"soft click"` | < 0.2 s |
| `item_drop_floor.ogg` | `"light thud"` | < 0.3 s |
Requirements: CC0 or CC-BY, child-appropriate, no startling sounds.
### SFX Map in Code
```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",
}
```
### Music Map in Code
```gdscript
const _MUSIC_MAP: Dictionary = {
0: "res://assets/audio/music/floor_0.ogg",
1: "res://assets/audio/music/floor_1.ogg",
2: "res://assets/audio/music/floor_2.ogg",
3: "res://assets/audio/music/floor_3.ogg",
}
```
## Integration with Existing Scripts
### room_chest.gd
Add two `AudioManager.play_sfx()` calls:
1. In the chest tap handler (before spawn guard): `AudioManager.play_sfx("chest_tap")`
2. In `spawn_items()`, at the start of each item's tween: `AudioManager.play_sfx("item_spawn")`
### holdable_item.gd
- `_on_drag_picked_up`: add `AudioManager.play_sfx("item_drag_start")`
- `_try_return_to_chest` (on success, before tween): add `AudioManager.play_sfx("item_return_chest")`
- `_on_drag_released` (after hand slot attach): add `AudioManager.play_sfx("item_drop_hand")`
- `_on_drag_released` (no target branch): add `AudioManager.play_sfx("item_drop_floor")`
### outfit_item.gd
- `_on_drag_released` (after `apply_outfit_item` call): add `AudioManager.play_sfx("item_drop_outfit")`
### project.godot
Add `AudioManager` to the autoload list:
```
[autoload]
AudioManager="*res://scripts/autoload/AudioManager.gd"
```
## GameState Integration
`AudioManager` observes `GameState.state_changed`. No changes to `GameState` are required — `music_volume` and `sfx_volume` are already persisted in v3.
`set_music_volume` / `set_sfx_volume` write back to `GameState` so values persist on save.
## Testing
**File:** `test/unit/test_audio_manager.gd`
Testable (pure logic, no actual audio):
- `_derive_floor_from_room()` — all 12 room strings → correct floor int
- `_derive_floor_from_room("unknown")``-1`
- `play_floor_music()` with same floor → `_current_floor` unchanged (no-op)
- `get_current_floor()` → returns `-1` before any music played
- `play_sfx()` with unknown key → no crash (guard required)
Not testable (manual on device):
- Actual audio playback
- Cross-fade quality and timing
- Volume balance SFX vs. music
- Loop seamlessness
## Out of Scope
- Polyphonic SFX (multiple simultaneous sounds)
- Per-room music (12 tracks) — floor-level is sufficient for this sprint
- Character voice sounds (Häschen-Schnuffeln, Kätzchen-Miau) — separate sprint
- Settings UI for volume sliders — volume API exists, UI in a later sprint
- AudioBus setup (Master/Music/SFX buses) — single bus is sufficient for now
@@ -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).
+13 -1
View File
@@ -1,7 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://cozypaw_emergency"]
[gd_scene load_steps=5 format=3 uid="uid://cozypaw_emergency"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="PackedScene" path="res://scenes/objects/Ambulance.tscn" id="2_ambulance"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="4_chest"]
[node name="EmergencyRoom" type="Node2D"]
@@ -73,3 +75,13 @@ position = Vector2(550, 440)
position = Vector2(500, 570)
trigger_floor = 0
trigger_room = 3
[node name="SnapMedicalTable" type="Node2D" parent="."]
position = Vector2(310, 480)
script = ExtResource("3_snap")
pose = "lying"
[node name="EmergencyCabinet" type="Node2D" parent="."]
position = Vector2(150.0, 400.0)
script = ExtResource("4_chest")
chest_id = "emergency_cabinet"
+12 -1
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://cozypaw_giftshop"]
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_giftshop"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="3_chest"]
[node name="GiftShop" type="Node2D"]
@@ -63,3 +65,12 @@ position = Vector2(900, 330)
[node name="GiftBox" parent="." instance=ExtResource("1_iobj")]
position = Vector2(640, 510)
[node name="SnapCounter" type="Node2D" parent="."]
position = Vector2(640, 528)
script = ExtResource("2_snap")
[node name="GiftShopShelf" type="Node2D" parent="."]
position = Vector2(120.0, 300.0)
script = ExtResource("3_chest")
chest_id = "giftshop_shelf"
+24 -1
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://cozypaw_reception"]
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_reception"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="3_chest"]
[node name="Reception" type="Node2D"]
@@ -85,3 +87,24 @@ position = Vector2(530, 510)
[node name="PottedPlant" parent="." instance=ExtResource("1_iobj")]
position = Vector2(1180, 560)
[node name="SnapBenchLeft1" type="Node2D" parent="."]
position = Vector2(150, 555)
script = ExtResource("2_snap")
[node name="SnapBenchLeft2" type="Node2D" parent="."]
position = Vector2(240, 555)
script = ExtResource("2_snap")
[node name="SnapBenchRight1" type="Node2D" parent="."]
position = Vector2(990, 555)
script = ExtResource("2_snap")
[node name="SnapBenchRight2" type="Node2D" parent="."]
position = Vector2(1080, 555)
script = ExtResource("2_snap")
[node name="ReceptionDesk" type="Node2D" parent="."]
position = Vector2(120.0, 555.0)
script = ExtResource("3_chest")
chest_id = "reception_desk"
+32 -1
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://cozypaw_restaurant"]
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_restaurant"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="3_chest"]
[node name="Restaurant" type="Node2D"]
@@ -93,3 +95,32 @@ position = Vector2(1060, 490)
[node name="CashRegister" parent="." instance=ExtResource("1_iobj")]
position = Vector2(640, 210)
[node name="SnapTable1Left" type="Node2D" parent="."]
position = Vector2(160, 510)
script = ExtResource("2_snap")
[node name="SnapTable1Right" type="Node2D" parent="."]
position = Vector2(280, 510)
script = ExtResource("2_snap")
[node name="SnapTable2Left" type="Node2D" parent="."]
position = Vector2(580, 510)
script = ExtResource("2_snap")
[node name="SnapTable2Right" type="Node2D" parent="."]
position = Vector2(700, 510)
script = ExtResource("2_snap")
[node name="SnapTable3Left" type="Node2D" parent="."]
position = Vector2(1000, 510)
script = ExtResource("2_snap")
[node name="SnapTable3Right" type="Node2D" parent="."]
position = Vector2(1120, 510)
script = ExtResource("2_snap")
[node name="RestaurantCounter" type="Node2D" parent="."]
position = Vector2(120.0, 555.0)
script = ExtResource("3_chest")
chest_id = "restaurant_counter"
+17 -2
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://cozypaw_lab"]
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_lab"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="3_chest"]
[node name="Lab" type="Node2D"]
@@ -24,7 +26,7 @@ color = Color(0.88, 0.92, 0.94, 1)
size = Vector2(40, 620)
position = Vector2(1240, 0)
[node name="LabBench" type="ColorRect" parent="."]
[node name="LabBenchSurface" type="ColorRect" parent="."]
color = Color(0.88, 0.88, 0.92, 1)
size = Vector2(800, 40)
position = Vector2(240, 480)
@@ -68,3 +70,16 @@ position = Vector2(820, 450)
[node name="PetriDish" parent="." instance=ExtResource("1_iobj")]
position = Vector2(490, 450)
[node name="SnapLabBench1" type="Node2D" parent="."]
position = Vector2(450, 470)
script = ExtResource("2_snap")
[node name="SnapLabBench2" type="Node2D" parent="."]
position = Vector2(750, 470)
script = ExtResource("2_snap")
[node name="LabBench" type="Node2D" parent="."]
position = Vector2(150.0, 400.0)
script = ExtResource("3_chest")
chest_id = "lab_bench"
+18 -1
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://cozypaw_patientroom"]
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_patientroom"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="3_chest"]
[node name="PatientRoom" type="Node2D"]
@@ -82,3 +84,18 @@ position = Vector2(1100, 265)
[node name="BedsideTable" parent="." instance=ExtResource("1_iobj")]
position = Vector2(500, 540)
[node name="SnapBed1" type="Node2D" parent="."]
position = Vector2(250, 465)
script = ExtResource("2_snap")
pose = "lying"
[node name="SnapBed2" type="Node2D" parent="."]
position = Vector2(810, 465)
script = ExtResource("2_snap")
pose = "lying"
[node name="PatientCabinet" type="Node2D" parent="."]
position = Vector2(150.0, 400.0)
script = ExtResource("3_chest")
chest_id = "patient_cabinet"
+17 -1
View File
@@ -1,6 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://cozypaw_pharmacy"]
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_pharmacy"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="2_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="3_chest"]
[node name="Pharmacy" type="Node2D"]
@@ -68,3 +70,17 @@ position = Vector2(900, 270)
[node name="MedicineBox" parent="." instance=ExtResource("1_iobj")]
position = Vector2(640, 430)
[node name="SnapCounter" type="Node2D" parent="."]
position = Vector2(640, 520)
script = ExtResource("2_snap")
[node name="PharmacyMedicine" type="Node2D" parent="."]
position = Vector2(350.0, 320.0)
script = ExtResource("3_chest")
chest_id = "pharmacy_medicine"
[node name="PharmacyTools" type="Node2D" parent="."]
position = Vector2(900.0, 320.0)
script = ExtResource("3_chest")
chest_id = "pharmacy_tools"
+13 -1
View File
@@ -1,7 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://cozypaw_xray"]
[gd_scene load_steps=5 format=3 uid="uid://cozypaw_xray"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="PackedScene" path="res://scenes/objects/XRayMachine.tscn" id="2_xraymachine"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="4_chest"]
[node name="XRay" type="Node2D"]
@@ -53,3 +55,13 @@ position = Vector2(500, 510)
[node name="PlasterStation" parent="." instance=ExtResource("1_iobj")]
position = Vector2(900, 560)
[node name="SnapExamTable" type="Node2D" parent="."]
position = Vector2(640, 480)
script = ExtResource("3_snap")
pose = "lying"
[node name="XRayCabinet" type="Node2D" parent="."]
position = Vector2(150.0, 400.0)
script = ExtResource("4_chest")
chest_id = "xray_cabinet"
+13 -1
View File
@@ -1,7 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://cozypaw_deliveryroom"]
[gd_scene load_steps=5 format=3 uid="uid://cozypaw_deliveryroom"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="PackedScene" path="res://scenes/objects/DeliveryBed.tscn" id="2_deliverybed"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="4_chest"]
[node name="DeliveryRoom" type="Node2D"]
@@ -36,3 +38,13 @@ position = Vector2(1020, 540)
[node name="BabyBlanket" parent="." instance=ExtResource("1_iobj")]
position = Vector2(880, 540)
[node name="SnapDeliveryBed" type="Node2D" parent="."]
position = Vector2(540, 480)
script = ExtResource("3_snap")
pose = "lying"
[node name="DeliveryCabinet" type="Node2D" parent="."]
position = Vector2(150.0, 400.0)
script = ExtResource("4_chest")
chest_id = "delivery_cabinet"
+26 -1
View File
@@ -1,7 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://cozypaw_nursery"]
[gd_scene load_steps=5 format=3 uid="uid://cozypaw_nursery"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="PackedScene" path="res://scenes/objects/Cradle.tscn" id="2_cradle"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="4_chest"]
[node name="Nursery" type="Node2D"]
@@ -47,3 +49,26 @@ position = Vector2(1100, 540)
[node name="MilkBottle" parent="." instance=ExtResource("1_iobj")]
position = Vector2(960, 540)
[node name="SnapCradle1" type="Node2D" parent="."]
position = Vector2(340, 240)
script = ExtResource("3_snap")
pose = "lying"
baby_only = true
[node name="SnapCradle2" type="Node2D" parent="."]
position = Vector2(600, 240)
script = ExtResource("3_snap")
pose = "lying"
baby_only = true
[node name="SnapCradle3" type="Node2D" parent="."]
position = Vector2(860, 240)
script = ExtResource("3_snap")
pose = "lying"
baby_only = true
[node name="NurseryShelf" type="Node2D" parent="."]
position = Vector2(640.0, 260.0)
script = ExtResource("4_chest")
chest_id = "nursery_shelf"
+13 -1
View File
@@ -1,7 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://cozypaw_ultrasound"]
[gd_scene load_steps=5 format=3 uid="uid://cozypaw_ultrasound"]
[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"]
[ext_resource type="PackedScene" path="res://scenes/objects/UltrasoundMachine.tscn" id="2_ultrasound"]
[ext_resource type="Script" path="res://scripts/objects/snap_point.gd" id="3_snap"]
[ext_resource type="Script" path="res://scripts/objects/room_chest.gd" id="4_chest"]
[node name="Ultrasound" type="Node2D"]
@@ -51,3 +53,13 @@ position = Vector2(920, 450)
[node name="Blanket" parent="." instance=ExtResource("1_iobj")]
position = Vector2(480, 450)
[node name="SnapExamTable" type="Node2D" parent="."]
position = Vector2(470, 480)
script = ExtResource("3_snap")
pose = "lying"
[node name="UltrasoundCart" type="Node2D" parent="."]
position = Vector2(150.0, 400.0)
script = ExtResource("4_chest")
chest_id = "ultrasound_cart"
+72 -12
View File
@@ -1,8 +1,12 @@
[gd_scene load_steps=4 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"]
@@ -57,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
@@ -67,20 +87,60 @@ 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")
[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"
+117 -37
View File
@@ -1,63 +1,143 @@
## AudioManager — music playback with cross-fade, SFX playback, volume control.
## AudioManager — floor music with cross-fade and SFX for player interactions.
## Autoload registered in project.godot. No class_name (Godot 4 autoload conflict).
extends Node
const CROSSFADE_DURATION: float = 0.8
const DEFAULT_MUSIC_VOLUME: float = 0.6
const CROSSFADE_DURATION: float = 1.0
var _music_player_a: AudioStreamPlayer
var _music_player_b: AudioStreamPlayer
const _MUSIC_MAP: Dictionary = {
0: "res://assets/audio/music/floor_0.ogg",
1: "res://assets/audio/music/floor_1.ogg",
2: "res://assets/audio/music/floor_2.ogg",
3: "res://assets/audio/music/floor_3.ogg",
}
const _SFX_MAP: Dictionary = {
"chest_tap": "res://assets/audio/sfx/chest_tap.ogg",
"item_spawn": "res://assets/audio/sfx/item_spawn.ogg",
"item_drag_start": "res://assets/audio/sfx/item_drag_start.ogg",
"item_drop_hand": "res://assets/audio/sfx/item_drop_hand.ogg",
"item_drop_outfit": "res://assets/audio/sfx/item_drop_outfit.ogg",
"item_return_chest": "res://assets/audio/sfx/item_return_chest.ogg",
"item_drop_floor": "res://assets/audio/sfx/item_drop_floor.ogg",
"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
var _is_crossfading: bool = false
var _active_player: AudioStreamPlayer
var _last_room: String = ""
var _music_a: AudioStreamPlayer
var _music_b: AudioStreamPlayer
var _sfx_player: AudioStreamPlayer
var _music_volume: float = DEFAULT_MUSIC_VOLUME
var _sfx_volume: float = 1.0
var _is_fading: bool = false
func _ready() -> void:
_music_player_a = AudioStreamPlayer.new()
_music_player_b = AudioStreamPlayer.new()
_music_a = AudioStreamPlayer.new()
_music_b = AudioStreamPlayer.new()
_sfx_player = AudioStreamPlayer.new()
add_child(_music_player_a)
add_child(_music_player_b)
add_child(_music_a)
add_child(_music_b)
add_child(_sfx_player)
_active_player = _music_player_a
_apply_music_volume()
_active_player = _music_a
_music_a.volume_db = linear_to_db(GameState.music_volume)
_music_b.volume_db = linear_to_db(0.0)
_sfx_player.volume_db = linear_to_db(GameState.sfx_volume)
GameState.state_changed.connect(_on_game_state_changed)
_last_room = GameState.current_room
var initial_floor: int = _derive_floor_from_room(GameState.current_room)
if initial_floor != -1:
play_floor_music(initial_floor)
func play_music(stream: AudioStream) -> void:
if _active_player.stream == stream and _active_player.playing:
func play_floor_music(floor: int) -> void:
if AudioServer.get_driver_name() == "Dummy":
return
var next_player: AudioStreamPlayer = _music_player_b if _active_player == _music_player_a else _music_player_a
next_player.stream = stream
next_player.volume_db = linear_to_db(0.0)
next_player.play()
var prev_player: AudioStreamPlayer = _active_player
_active_player = next_player
var tween: Tween = create_tween()
tween.set_parallel(true)
tween.tween_property(prev_player, "volume_db", linear_to_db(0.0), CROSSFADE_DURATION)
tween.tween_property(next_player, "volume_db", linear_to_db(_music_volume), CROSSFADE_DURATION)
tween.chain().tween_callback(prev_player.stop)
if floor == _current_floor:
return
if not _MUSIC_MAP.has(floor):
return
if _is_crossfading:
return
_is_crossfading = true
_current_floor = floor
var inactive: AudioStreamPlayer = _music_b if _active_player == _music_a else _music_a
var path: String = _MUSIC_MAP[floor]
if not ResourceLoader.exists(path):
_is_crossfading = false
return
var stream: AudioStream = load(path) as AudioStream
if stream == null:
_is_crossfading = false
return
inactive.stream = stream
inactive.volume_db = linear_to_db(0.0)
inactive.play()
var tween: Tween = create_tween().set_parallel(true)
tween.tween_property(inactive, "volume_db", linear_to_db(GameState.music_volume), CROSSFADE_DURATION)
tween.tween_property(_active_player, "volume_db", linear_to_db(0.0), CROSSFADE_DURATION)
await tween.finished
_active_player.stop()
_active_player = inactive
_is_crossfading = false
func play_sfx(stream: AudioStream) -> void:
func play_sfx(event: String) -> void:
if AudioServer.get_driver_name() == "Dummy":
return
if not _SFX_MAP.has(event):
return
var path: String = _SFX_MAP[event]
if not ResourceLoader.exists(path):
return
var stream: AudioStream = load(path) as AudioStream
if stream == null:
return
_sfx_player.stream = stream
_sfx_player.volume_db = linear_to_db(_sfx_volume)
_sfx_player.play()
func set_music_volume(value: float) -> void:
_music_volume = clampf(value, 0.0, 1.0)
_apply_music_volume()
func set_music_volume(vol: float) -> void:
GameState.music_volume = vol
_active_player.volume_db = linear_to_db(vol)
func set_sfx_volume(value: float) -> void:
_sfx_volume = clampf(value, 0.0, 1.0)
func set_sfx_volume(vol: float) -> void:
GameState.sfx_volume = vol
_sfx_player.volume_db = linear_to_db(vol)
func get_music_volume() -> float:
return _music_volume
func get_current_floor() -> int:
return _current_floor
func _apply_music_volume() -> void:
_active_player.volume_db = linear_to_db(_music_volume)
func _on_game_state_changed() -> void:
if GameState.current_room == _last_room:
return
_last_room = GameState.current_room
var floor: int = _derive_floor_from_room(GameState.current_room)
if floor != -1:
play_floor_music(floor)
func _derive_floor_from_room(room: String) -> int:
match room:
"reception", "giftshop", "restaurant", "emergency":
return 0
"xray", "pharmacy", "lab", "patient_rooms":
return 1
"ultrasound", "delivery_room", "nursery":
return 2
"garden_party":
return 3
return -1
+63 -1
View File
@@ -1,11 +1,14 @@
## GameState — global game state: character positions, object states, current room.
## GameState — global game state: character positions, outfit, held items, object states, current room.
extends Node
signal state_changed
signal character_moved(character_id: String, position: Vector2)
var _character_positions: Dictionary = {}
var _character_outfits: Dictionary = {}
var _character_held_items: Dictionary = {}
var _object_states: Dictionary = {}
var _chest_states: Dictionary = {}
var current_room: String = "reception"
var music_volume: float = 0.6
var sfx_volume: float = 1.0
@@ -25,6 +28,28 @@ func set_character_position(id: String, pos: Vector2) -> void:
state_changed.emit()
func get_character_outfit(id: String) -> Array:
return _character_outfits.get(id, ["", "", ""])
func set_character_outfit(id: String, outfit: Array) -> void:
_character_outfits[id] = outfit
state_changed.emit()
func get_character_held_item(id: String, hand: String) -> String:
if not _character_held_items.has(id):
return ""
return _character_held_items[id].get(hand, "")
func set_character_held_item(id: String, hand: String, item_id: String) -> void:
if not _character_held_items.has(id):
_character_held_items[id] = {"left": "", "right": ""}
_character_held_items[id][hand] = item_id
state_changed.emit()
func get_object_state(id: String) -> String:
return _object_states.get(id, "idle")
@@ -34,14 +59,37 @@ func set_object_state(id: String, state: String) -> void:
state_changed.emit()
func get_chest_state(chest_id: String) -> Array:
return _chest_states.get(chest_id, [])
func set_chest_state(chest_id: String, spawned_item_ids: Array[String]) -> void:
_chest_states[chest_id] = spawned_item_ids
state_changed.emit()
func clear_chest_state(chest_id: String) -> void:
_chest_states.erase(chest_id)
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:
var pos: Vector2 = _character_positions[key]
positions[key] = [pos.x, pos.y]
return {
"version": 3,
"character_positions": positions,
"character_outfits": _character_outfits.duplicate(true),
"character_held_items": _character_held_items.duplicate(true),
"object_states": _object_states,
"chest_states": _chest_states.duplicate(true),
"current_room": current_room,
"music_volume": music_volume,
"sfx_volume": sfx_volume,
@@ -55,11 +103,25 @@ func apply_save_data(data: Dictionary) -> void:
var val: Variant = data["character_positions"][key]
if val is Array and val.size() >= 2:
_character_positions[key] = Vector2(val[0], val[1])
if data.has("character_outfits"):
_character_outfits = data["character_outfits"].duplicate(true)
else:
_character_outfits = {}
if data.has("character_held_items"):
_character_held_items = data["character_held_items"].duplicate(true)
else:
_character_held_items = {}
if data.has("object_states"):
_object_states = data["object_states"]
else:
_object_states = {}
if data.has("current_room"):
current_room = data["current_room"]
if data.has("music_volume"):
music_volume = data["music_volume"]
if data.has("sfx_volume"):
sfx_volume = data["sfx_volume"]
if data.has("chest_states"):
_chest_states = data["chest_states"].duplicate(true)
else:
_chest_states = {}
+50 -1
View File
@@ -13,7 +13,11 @@ signal state_changed(new_state: CharacterData.State)
var _is_held: bool = false
var _current_anim: String = "idle"
var _drag_start_position: Vector2 = Vector2.ZERO
var _outfit_item_refs: Array = [null, null, null]
const _TAP_THRESHOLD: float = 10.0
const _ITEM_DROP_OFFSET: Vector2 = Vector2(0.0, 60.0)
const _STATE_COLORS: Dictionary = {
CharacterData.State.HEALTHY: Color(0.6, 0.8, 1.0),
CharacterData.State.SICK: Color(0.7, 0.9, 0.7),
@@ -32,6 +36,7 @@ func _ready() -> void:
if data != null:
_update_visual_state()
_refresh_outfit_layers()
add_to_group("characters")
func set_state(new_state: CharacterData.State) -> void:
@@ -110,10 +115,12 @@ func detach_item(hand: String) -> Node2D:
if slot == null or slot.get_child_count() == 0:
return null
var item: Node2D = slot.get_child(0) as Node2D
var saved_pos: Vector2 = item.global_position
slot.remove_child(item)
var scene_parent: Node = get_parent()
if scene_parent != null:
scene_parent.add_child(item)
item.global_position = saved_pos
return item
@@ -146,14 +153,56 @@ func _update_visual_state() -> void:
ear_right.color = color
func _on_drag_picked_up(_pos: Vector2) -> void:
func apply_outfit_item(layer: int, item_id: String, texture: Texture2D, item_node: Node2D) -> void:
if layer < 1 or layer > 3:
return
var i: int = layer - 1
var existing: Node2D = _outfit_item_refs[i] as Node2D
if existing != null:
existing.global_position = global_position + _ITEM_DROP_OFFSET
existing.visible = true
_outfit_item_refs[i] = item_node
set_outfit(layer, item_id, texture)
if item_node != null:
item_node.visible = false
func remove_outfit(layer: int) -> void:
if layer < 1 or layer > 3:
return
var i: int = layer - 1
clear_outfit(layer)
var item_ref: Node2D = _outfit_item_refs[i] as Node2D
if item_ref != null:
_outfit_item_refs[i] = null
item_ref.global_position = global_position + _ITEM_DROP_OFFSET
item_ref.visible = true
func _handle_outfit_tap() -> void:
for layer: int in range(3, 0, -1):
if not get_outfit(layer).is_empty():
remove_outfit(layer)
return
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)
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
+2
View File
@@ -11,3 +11,5 @@ enum Species { BUNNY, KITTEN }
@export var current_floor: int = 0
@export var position: Vector2 = Vector2.ZERO
@export var outfit: Array[String] = ["", "", ""]
@export var held_left: String = ""
@export var held_right: String = ""
+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
+9
View File
@@ -0,0 +1,9 @@
## ChestItemData — configuration for a single item slot inside a RoomChest.
class_name ChestItemData extends Resource
enum ItemType { HOLDABLE, OUTFIT }
@export var item_id: String = ""
@export var item_type: ItemType = ItemType.HOLDABLE
@export_range(1, 3) var outfit_layer: int = 1
@export var spawn_offset: Vector2 = Vector2.ZERO
+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
+95
View File
@@ -0,0 +1,95 @@
## HoldableItem — Node2D that can be held in a Character's HandLeft or HandRight slot.
## Attach DragDropComponent as a child. On drag_released scans "characters" group for
## the nearest free hand slot within HAND_SLOT_RADIUS.
class_name HoldableItem extends Node2D
signal item_picked_up(item: HoldableItem)
signal item_placed(item: HoldableItem)
const HAND_SLOT_RADIUS: float = 60.0
const CHEST_RETURN_RADIUS: float = 80.0
@export var item_id: String = ""
var home_chest: Node2D = null
func _ready() -> void:
var drag: DragDropComponent = get_node_or_null("DragDropComponent") as DragDropComponent
if drag != null:
drag.drag_picked_up.connect(_on_drag_picked_up)
drag.drag_released.connect(_on_drag_released)
func _on_drag_picked_up(_pos: Vector2) -> void:
if is_in_hand_slot():
_detach_from_hand_slot()
AudioManager.play_sfx("item_drag_start")
item_picked_up.emit(self)
func _on_drag_released(_pos: Vector2) -> void:
if _try_return_to_chest():
return
var result: Array = _find_nearest_free_hand_slot()
if not result.is_empty():
var character: Character = result[0] as Character
var hand: String = result[1] as String
character.attach_item(hand, self)
AudioManager.play_sfx("item_drop_hand")
else:
AudioManager.play_sfx("item_drop_floor")
item_placed.emit(self)
func _try_return_to_chest() -> bool:
if home_chest == null:
return false
if global_position.distance_to(home_chest.global_position) >= CHEST_RETURN_RADIUS:
return false
var chest: RoomChest = home_chest as RoomChest
if chest == null:
return false
AudioManager.play_sfx("item_return_chest")
chest.receive_item(self)
return true
func is_in_hand_slot() -> bool:
var p: Node = get_parent()
if p == null:
return false
return p.name == "HandLeft" or p.name == "HandRight"
func _detach_from_hand_slot() -> void:
var hand_slot: Node = get_parent()
var character: Character = hand_slot.get_parent() as Character
if character == null:
return
var hand: String = "left" if hand_slot.name == "HandLeft" else "right"
character.detach_item(hand)
func _find_nearest_free_hand_slot() -> Array:
var best_dist: float = HAND_SLOT_RADIUS
var best_character: Character = null
var best_hand: String = ""
for node: Node in get_tree().get_nodes_in_group("characters"):
var character: Character = node as Character
if character == null:
continue
for hand: String in ["left", "right"]:
if not character.is_hand_free(hand):
continue
var slot: Node2D = character.get_node_or_null("Hand" + hand.capitalize()) as Node2D
if slot == null:
continue
var dist: float = global_position.distance_to(slot.global_position)
if dist < best_dist:
best_dist = dist
best_character = character
best_hand = hand
if best_character == null:
return []
return [best_character, best_hand]
+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")
+34
View File
@@ -0,0 +1,34 @@
## OutfitItem — HoldableItem that applies an outfit layer to a Character when dropped
## within OUTFIT_APPLY_RADIUS of the character's center. Falls back to hand slot
## attachment if no character body is in range.
class_name OutfitItem extends HoldableItem
const OUTFIT_APPLY_RADIUS: float = 80.0
@export var outfit_layer: int = 1
@export var outfit_sprite: Texture2D
func _on_drag_released(_pos: Vector2) -> void:
if _try_return_to_chest():
return
var character: Character = _find_nearest_character()
if character != null:
character.apply_outfit_item(outfit_layer, item_id, outfit_sprite, self)
AudioManager.play_sfx("item_drop_outfit")
return
super._on_drag_released(_pos)
func _find_nearest_character() -> Character:
var best_dist: float = OUTFIT_APPLY_RADIUS
var best: Character = null
for node: Node in get_tree().get_nodes_in_group("characters"):
var character: Character = node as Character
if character == null:
continue
var dist: float = global_position.distance_to(character.global_position)
if dist < best_dist:
best_dist = dist
best = character
return best
+123
View File
@@ -0,0 +1,123 @@
## RoomChest — tappable storage node. Spawns HoldableItem/OutfitItem instances on demand.
## Items fly out with a tween. Receives items back via receive_item().
class_name RoomChest extends Node2D
signal items_spawned(chest: RoomChest)
signal item_received(chest: RoomChest, item_id: String)
const SPAWN_TWEEN_DURATION: float = 0.3
@export var chest_id: String = ""
@export var tap_radius: float = 50.0
var _spawned_items: Array[HoldableItem] = []
var _item_configs: Array[ChestItemData] = []
func _ready() -> void:
add_to_group("room_chests")
_item_configs = RoomChestConfig.get_items(chest_id)
if not chest_id.is_empty() and GameState.has_method("get_chest_state"):
if not GameState.get_chest_state(chest_id).is_empty():
call_deferred("spawn_items")
func _unhandled_input(event: InputEvent) -> void:
var press_pos: Vector2 = _get_press_position(event)
if press_pos == Vector2.INF:
return
var canvas_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * press_pos
if canvas_pos.distance_to(global_position) > tap_radius:
return
get_viewport().set_input_as_handled()
AudioManager.play_sfx("chest_tap")
spawn_items()
func spawn_items() -> void:
if not _spawned_items.is_empty():
return
AudioManager.play_sfx("item_spawn")
var parent: Node = get_parent()
for config: ChestItemData in _item_configs:
var item: HoldableItem = _create_item(config)
item.home_chest = self
if parent != null:
parent.add_child(item)
else:
add_child(item)
item.global_position = global_position
_spawned_items.append(item)
_tween_item_out(item, config.spawn_offset)
if GameState.has_method("set_chest_state"):
GameState.set_chest_state(chest_id, _get_spawned_ids())
items_spawned.emit(self)
func receive_item(item: HoldableItem) -> void:
if not _spawned_items.has(item):
return
_spawned_items.erase(item)
if GameState.has_method("set_chest_state"):
if _spawned_items.is_empty():
GameState.clear_chest_state(chest_id)
else:
GameState.set_chest_state(chest_id, _get_spawned_ids())
item_received.emit(self, item.item_id)
_tween_item_in(item)
func are_items_spawned() -> bool:
return not _spawned_items.is_empty()
func get_spawned_count() -> int:
return _spawned_items.size()
func get_item_config_count() -> int:
return _item_configs.size()
func get_spawned_item(index: int) -> HoldableItem:
if index < 0 or index >= _spawned_items.size():
return null
return _spawned_items[index]
func _get_press_position(event: InputEvent) -> Vector2:
if event is InputEventScreenTouch and event.pressed:
return event.position
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
return event.position
return Vector2.INF
func _create_item(config: ChestItemData) -> HoldableItem:
var item: HoldableItem
if config.item_type == ChestItemData.ItemType.OUTFIT:
var outfit: OutfitItem = OutfitItem.new()
outfit.outfit_layer = config.outfit_layer
item = outfit
else:
item = HoldableItem.new()
item.item_id = config.item_id
return item
func _get_spawned_ids() -> Array[String]:
var ids: Array[String] = []
for item: HoldableItem in _spawned_items:
ids.append(item.item_id)
return ids
func _tween_item_out(item: HoldableItem, offset: Vector2) -> void:
var tween: Tween = create_tween()
tween.tween_property(item, "global_position", global_position + offset, SPAWN_TWEEN_DURATION)
func _tween_item_in(item: HoldableItem) -> void:
var tween: Tween = create_tween()
tween.tween_property(item, "global_position", global_position, SPAWN_TWEEN_DURATION)
tween.tween_callback(item.queue_free)
+107
View File
@@ -0,0 +1,107 @@
## RoomChestConfig — static item configuration for all room chests.
## Maps chest_id strings to ChestItemData arrays. No assets needed: item_id strings only.
class_name RoomChestConfig
const _OFFSET_LEFT: Vector2 = Vector2(-70.0, -60.0)
const _OFFSET_CENTER: Vector2 = Vector2(0.0, -80.0)
const _OFFSET_RIGHT: Vector2 = Vector2(70.0, -60.0)
const _OFFSET_LEFT_2: Vector2 = Vector2(-50.0, -60.0)
const _OFFSET_RIGHT_2: Vector2 = Vector2(50.0, -60.0)
static func get_items(chest_id: String) -> Array[ChestItemData]:
match chest_id:
"reception_desk":
return _make([
["clipboard", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["pen", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["bandage", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"giftshop_shelf":
return _make([
["gift_box", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["ribbon", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["balloon", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"restaurant_counter":
return _make([
["teacup", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["plate", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["spoon", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"emergency_cabinet":
return _make([
["bandage_roll", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["syringe", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["ice_pack", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"xray_cabinet":
return _make([
["xray_sheet", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["lead_apron", ChestItemData.ItemType.OUTFIT, 1, _OFFSET_CENTER],
["marker", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"pharmacy_medicine":
return _make([
["pill_bottle", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT_2],
["syrup", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT_2],
])
"pharmacy_tools":
return _make([
["mortar", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT_2],
["spatula", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT_2],
])
"lab_bench":
return _make([
["test_tube", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["pipette", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["microscope_slide", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"patient_cabinet":
return _make([
["thermometer", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["stethoscope", ChestItemData.ItemType.OUTFIT, 2, _OFFSET_CENTER],
["pillow", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"ultrasound_cart":
return _make([
["gel_tube", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["probe", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["towel", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"delivery_cabinet":
return _make([
["swaddle", ChestItemData.ItemType.OUTFIT, 1, _OFFSET_LEFT],
["scissors", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["cord_clamp", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT],
])
"nursery_shelf":
return _make([
["bottle", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT],
["rattle", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_CENTER],
["blanket", ChestItemData.ItemType.OUTFIT, 1, _OFFSET_RIGHT],
])
"garden_table":
return _make([
["teapot", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT_2],
["cake", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_RIGHT_2],
])
"garden_storage":
return _make([
["confetti", ChestItemData.ItemType.HOLDABLE, 1, _OFFSET_LEFT_2],
["party_hat", ChestItemData.ItemType.OUTFIT, 1, _OFFSET_RIGHT_2],
])
return []
static func _make(data: Array) -> Array[ChestItemData]:
var result: Array[ChestItemData] = []
for entry: Array in data:
assert(entry.size() == 4, "ChestItemData entry must have 4 elements: " + str(entry))
var d: ChestItemData = ChestItemData.new()
d.item_id = entry[0]
d.item_type = entry[1]
d.outfit_layer = entry[2]
d.spawn_offset = entry[3]
result.append(d)
return result
+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
+91
View File
@@ -0,0 +1,91 @@
## Tests for AudioManager — floor derivation, no-op guard, SFX key validation.
extends GutTest
func before_each() -> void:
AudioManager._current_floor = -1
AudioManager._is_crossfading = false
func test_derive_floor_floor0_reception() -> void:
assert_eq(AudioManager._derive_floor_from_room("reception"), 0)
func test_derive_floor_floor0_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("giftshop"), 0)
assert_eq(AudioManager._derive_floor_from_room("restaurant"), 0)
assert_eq(AudioManager._derive_floor_from_room("emergency"), 0)
func test_derive_floor_floor1_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("xray"), 1)
assert_eq(AudioManager._derive_floor_from_room("pharmacy"), 1)
assert_eq(AudioManager._derive_floor_from_room("lab"), 1)
assert_eq(AudioManager._derive_floor_from_room("patient_rooms"), 1)
func test_derive_floor_floor2_all_rooms() -> void:
assert_eq(AudioManager._derive_floor_from_room("ultrasound"), 2)
assert_eq(AudioManager._derive_floor_from_room("delivery_room"), 2)
assert_eq(AudioManager._derive_floor_from_room("nursery"), 2)
func test_derive_floor_garden() -> void:
assert_eq(AudioManager._derive_floor_from_room("garden_party"), 3)
func test_derive_floor_unknown_returns_minus_one() -> void:
assert_eq(AudioManager._derive_floor_from_room("unknown_room"), -1)
assert_eq(AudioManager._derive_floor_from_room(""), -1)
func test_get_current_floor_starts_at_minus_one() -> void:
assert_eq(AudioManager.get_current_floor(), -1)
func test_play_floor_music_same_floor_is_noop() -> void:
AudioManager._current_floor = 0
AudioManager.play_floor_music(0)
assert_eq(AudioManager.get_current_floor(), 0)
func test_play_sfx_unknown_key_does_not_crash() -> void:
AudioManager.play_sfx("nonexistent_event_xyz")
pass
func test_sfx_map_has_all_seven_keys() -> void:
assert_true(AudioManager._SFX_MAP.has("chest_tap"))
assert_true(AudioManager._SFX_MAP.has("item_spawn"))
assert_true(AudioManager._SFX_MAP.has("item_drag_start"))
assert_true(AudioManager._SFX_MAP.has("item_drop_hand"))
assert_true(AudioManager._SFX_MAP.has("item_drop_outfit"))
assert_true(AudioManager._SFX_MAP.has("item_return_chest"))
assert_true(AudioManager._SFX_MAP.has("item_drop_floor"))
func test_music_map_has_all_four_floors() -> void:
assert_true(AudioManager._MUSIC_MAP.has(0))
assert_true(AudioManager._MUSIC_MAP.has(1))
assert_true(AudioManager._MUSIC_MAP.has(2))
assert_true(AudioManager._MUSIC_MAP.has(3))
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)
+75
View File
@@ -169,3 +169,78 @@ func test_detach_returns_item() -> void:
func test_detach_from_empty_hand_returns_null() -> void:
var returned: Node2D = _char.detach_item("left")
assert_null(returned)
func test_character_is_in_characters_group() -> void:
assert_true(_char.is_in_group("characters"))
func test_detach_item_preserves_global_position() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.global_position = Vector2(200.0, 300.0)
_char.attach_item("left", item)
var expected_global: Vector2 = (_char.get_node_or_null("HandLeft") as Node2D).global_position
_char.detach_item("left")
assert_eq(item.global_position, expected_global)
func test_apply_outfit_item_hides_item() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(1, "white_coat", null, item)
assert_false(item.visible)
func test_apply_outfit_item_sets_outfit_data() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(1, "white_coat", null, item)
assert_eq(_char.get_outfit(1), "white_coat")
func test_remove_outfit_restores_item_visibility() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(2, "cast_arm", null, item)
_char.remove_outfit(2)
assert_true(item.visible)
func test_remove_outfit_clears_outfit_data() -> void:
var item: Node2D = Node2D.new()
add_child_autofree(item)
_char.apply_outfit_item(2, "cast_arm", null, item)
_char.remove_outfit(2)
assert_eq(_char.get_outfit(2), "")
func test_apply_outfit_item_replaces_existing() -> void:
var item1: Node2D = Node2D.new()
var item2: Node2D = Node2D.new()
add_child_autofree(item1)
add_child_autofree(item2)
_char.apply_outfit_item(1, "white_coat", null, item1)
_char.apply_outfit_item(1, "doctor_coat", null, item2)
assert_true(item1.visible)
assert_false(item2.visible)
assert_eq(_char.get_outfit(1), "doctor_coat")
func test_tap_removes_topmost_active_outfit_layer() -> void:
var item1: Node2D = Node2D.new()
var item3: Node2D = Node2D.new()
add_child_autofree(item1)
add_child_autofree(item3)
_char.apply_outfit_item(1, "white_coat", null, item1)
_char.apply_outfit_item(3, "stethoscope", null, item3)
_char._handle_outfit_tap()
assert_eq(_char.get_outfit(3), "")
assert_eq(_char.get_outfit(1), "white_coat")
func test_tap_noop_when_no_outfit_active() -> void:
_char._handle_outfit_tap()
assert_eq(_char.get_outfit(1), "")
assert_eq(_char.get_outfit(2), "")
assert_eq(_char.get_outfit(3), "")
+117
View File
@@ -77,3 +77,120 @@ func test_apply_save_data_with_empty_dict_does_not_crash() -> void:
_state.set_character_position("bunny_01", Vector2(10.0, 20.0))
_state.apply_save_data({})
assert_eq(_state.get_character_position("bunny_01"), Vector2(10.0, 20.0))
func test_character_data_has_held_left_field() -> void:
var cd: CharacterData = CharacterData.new()
assert_eq(cd.held_left, "")
func test_character_data_has_held_right_field() -> void:
var cd: CharacterData = CharacterData.new()
assert_eq(cd.held_right, "")
func test_set_character_outfit_stores_value() -> void:
_state.set_character_outfit("bunny_f", ["white_coat", "", "stethoscope"])
assert_eq(_state.get_character_outfit("bunny_f"), ["white_coat", "", "stethoscope"])
func test_get_character_outfit_returns_empty_array_for_unknown() -> void:
var result: Array = _state.get_character_outfit("unknown_id")
assert_eq(result, ["", "", ""])
func test_set_character_held_item_left() -> void:
_state.set_character_held_item("bunny_f", "left", "medicine_blue")
assert_eq(_state.get_character_held_item("bunny_f", "left"), "medicine_blue")
func test_get_character_held_item_returns_empty_for_unknown() -> void:
assert_eq(_state.get_character_held_item("unknown", "left"), "")
func test_save_data_includes_outfit() -> void:
_state.set_character_outfit("bunny_f", ["white_coat", "", ""])
var data: Dictionary = _state.get_save_data()
assert_true(data.has("character_outfits"))
assert_eq(data["character_outfits"]["bunny_f"], ["white_coat", "", ""])
func test_save_data_includes_held_items() -> void:
_state.set_character_held_item("bunny_f", "right", "medicine_blue")
var data: Dictionary = _state.get_save_data()
assert_true(data.has("character_held_items"))
assert_eq(data["character_held_items"]["bunny_f"]["right"], "medicine_blue")
func test_apply_save_data_restores_outfit() -> void:
var save: Dictionary = {
"character_outfits": {
"bunny_f": ["doctor_coat", "", "stethoscope"]
}
}
_state.apply_save_data(save)
assert_eq(_state.get_character_outfit("bunny_f"), ["doctor_coat", "", "stethoscope"])
func test_apply_save_data_restores_held_items() -> void:
var save: Dictionary = {
"character_held_items": {
"kitten_f": {"left": "gel_tube", "right": ""}
}
}
_state.apply_save_data(save)
assert_eq(_state.get_character_held_item("kitten_f", "left"), "gel_tube")
func test_save_data_has_version_three() -> void:
var data: Dictionary = _state.get_save_data()
assert_eq(data.get("version", 0), 3)
func test_get_chest_state_returns_empty_for_unknown_id() -> void:
assert_eq(GameState.get_chest_state("nonexistent_chest_xyz"), [])
func test_set_and_get_chest_state() -> void:
GameState.set_chest_state("pharmacy_medicine_test", ["pill_bottle", "syrup"])
assert_eq(GameState.get_chest_state("pharmacy_medicine_test"), ["pill_bottle", "syrup"])
func test_clear_chest_state_removes_entry() -> void:
GameState.set_chest_state("lab_bench_test", ["test_tube"])
GameState.clear_chest_state("lab_bench_test")
assert_eq(GameState.get_chest_state("lab_bench_test"), [])
func test_chest_state_included_in_save_data() -> void:
GameState.set_chest_state("xray_cabinet_test", ["xray_sheet"])
var data: Dictionary = GameState.get_save_data()
assert_true(data.has("chest_states"))
assert_eq(data["chest_states"]["xray_cabinet_test"], ["xray_sheet"])
func test_save_data_version_is_three() -> void:
var data: Dictionary = GameState.get_save_data()
assert_eq(data["version"], 3)
func test_apply_save_data_restores_chest_state() -> void:
var data: Dictionary = {
"version": 3,
"chest_states": {"reception_desk_test": ["clipboard", "pen"]},
}
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)
+101
View File
@@ -0,0 +1,101 @@
## Tests for HoldableItem — hand slot attachment on drag release.
extends GutTest
func test_holdable_item_id_default_empty() -> void:
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
assert_eq(item.item_id, "")
func test_holdable_item_is_not_in_hand_initially() -> void:
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
assert_false(item.is_in_hand_slot())
func test_holdable_item_attaches_to_nearest_free_hand() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
item.item_id = "test_item"
item.global_position = character.get_node("HandLeft").global_position
item._on_drag_released(item.global_position)
assert_true(item.is_in_hand_slot())
func test_holdable_item_does_not_attach_if_no_character_in_range() -> void:
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
item.global_position = Vector2(9999.0, 9999.0)
item._on_drag_released(item.global_position)
assert_false(item.is_in_hand_slot())
func test_holdable_item_does_not_attach_to_occupied_hand() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item1: Node2D = Node2D.new()
var item2: HoldableItem = HoldableItem.new()
add_child_autofree(item1)
add_child_autofree(item2)
character.attach_item("left", item1)
var item_filler: Node2D = Node2D.new()
add_child_autofree(item_filler)
character.attach_item("right", item_filler)
item2.global_position = character.global_position
item2._on_drag_released(item2.global_position)
assert_false(item2.is_in_hand_slot())
func test_holdable_item_detaches_on_pickup_when_in_slot() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
character.attach_item("left", item)
assert_true(item.is_in_hand_slot())
item._on_drag_picked_up(item.global_position)
assert_false(item.is_in_hand_slot())
func test_holdable_item_detach_preserves_global_position() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
character.attach_item("left", item)
var hand_pos: Vector2 = character.get_node("HandLeft").global_position
item._on_drag_picked_up(hand_pos)
assert_eq(item.global_position, hand_pos)
func test_try_return_to_chest_false_when_no_home_chest() -> void:
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
assert_false(item._try_return_to_chest())
func test_try_return_to_chest_false_when_beyond_radius() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
chest.global_position = Vector2.ZERO
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
item.home_chest = chest
item.global_position = Vector2(200.0, 0.0)
assert_false(item._try_return_to_chest())
func test_try_return_to_chest_true_when_within_radius() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
chest.global_position = Vector2.ZERO
var item: HoldableItem = HoldableItem.new()
add_child_autofree(item)
item.home_chest = chest
item.global_position = Vector2(40.0, 0.0)
assert_true(item._try_return_to_chest())
+63
View File
@@ -0,0 +1,63 @@
## Tests for OutfitItem — applies outfit layer when dropped near a character.
extends GutTest
func test_outfit_item_default_layer_is_one() -> void:
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
assert_eq(item.outfit_layer, 1)
func test_outfit_item_applies_to_character_on_release_in_range() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var data: CharacterData = CharacterData.new()
character.data = data
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
item.outfit_sprite = null
item.global_position = character.global_position
item._on_drag_released(item.global_position)
assert_eq(character.get_outfit(1), "white_coat")
func test_outfit_item_hides_after_applying() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var data: CharacterData = CharacterData.new()
character.data = data
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
item.global_position = character.global_position
item._on_drag_released(item.global_position)
assert_false(item.visible)
func test_outfit_item_stays_visible_if_no_character_in_range() -> void:
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
item.global_position = Vector2(9999.0, 9999.0)
item._on_drag_released(item.global_position)
assert_true(item.visible)
assert_false(item.is_in_hand_slot())
func test_outfit_item_does_not_apply_if_far_from_character() -> void:
var character: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character
add_child_autofree(character)
var data: CharacterData = CharacterData.new()
character.data = data
var item: OutfitItem = OutfitItem.new()
add_child_autofree(item)
item.item_id = "white_coat"
item.outfit_layer = 1
item.global_position = Vector2(9999.0, 9999.0)
item._on_drag_released(item.global_position)
assert_eq(character.get_outfit(1), "")
assert_true(item.visible)
+103
View File
@@ -0,0 +1,103 @@
## Tests for ChestItemData resource and RoomChestConfig static config.
extends GutTest
func test_chest_item_data_default_item_type_is_holdable() -> void:
var d: ChestItemData = ChestItemData.new()
assert_eq(d.item_type, ChestItemData.ItemType.HOLDABLE)
func test_chest_item_data_default_outfit_layer_is_one() -> void:
var d: ChestItemData = ChestItemData.new()
assert_eq(d.outfit_layer, 1)
func test_room_chest_config_reception_desk_has_three_items() -> void:
var items: Array[ChestItemData] = RoomChestConfig.get_items("reception_desk")
assert_eq(items.size(), 3)
func test_room_chest_config_unknown_id_returns_empty() -> void:
var items: Array[ChestItemData] = RoomChestConfig.get_items("does_not_exist")
assert_eq(items.size(), 0)
func test_room_chest_config_reception_desk_first_item_fields() -> void:
var items: Array[ChestItemData] = RoomChestConfig.get_items("reception_desk")
assert_eq(items[0].item_id, "clipboard")
assert_eq(items[0].item_type, ChestItemData.ItemType.HOLDABLE)
assert_eq(items[0].spawn_offset, Vector2(-70.0, -60.0))
func test_room_chest_config_patient_cabinet_stethoscope_outfit_layer_two() -> void:
var items: Array[ChestItemData] = RoomChestConfig.get_items("patient_cabinet")
var stethoscope: ChestItemData = items[1]
assert_eq(stethoscope.item_id, "stethoscope")
assert_eq(stethoscope.item_type, ChestItemData.ItemType.OUTFIT)
assert_eq(stethoscope.outfit_layer, 2)
func test_are_items_spawned_false_initially() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
assert_false(chest.are_items_spawned())
func test_get_spawned_count_zero_initially() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
assert_eq(chest.get_spawned_count(), 0)
func test_get_item_config_count_matches_config() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
assert_eq(chest.get_item_config_count(), 3)
func test_spawn_items_creates_correct_count() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
chest.spawn_items()
assert_eq(chest.get_spawned_count(), 3)
func test_double_spawn_is_noop() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
chest.spawn_items()
chest.spawn_items()
assert_eq(chest.get_spawned_count(), 3)
func test_receive_item_decrements_spawned_count() -> void:
var chest: RoomChest = RoomChest.new()
chest.chest_id = "reception_desk"
add_child_autofree(chest)
chest.spawn_items()
var item: HoldableItem = chest.get_spawned_item(0)
chest.receive_item(item)
assert_eq(chest.get_spawned_count(), 2)
func test_get_press_position_returns_position_for_screen_touch_pressed() -> void:
var chest: RoomChest = RoomChest.new()
add_child_autofree(chest)
var event: InputEventScreenTouch = InputEventScreenTouch.new()
event.pressed = true
event.position = Vector2(100.0, 200.0)
assert_eq(chest._get_press_position(event), Vector2(100.0, 200.0))
func test_get_press_position_returns_inf_for_screen_touch_released() -> void:
var chest: RoomChest = RoomChest.new()
add_child_autofree(chest)
var event: InputEventScreenTouch = InputEventScreenTouch.new()
event.pressed = false
event.position = Vector2(100.0, 200.0)
assert_eq(chest._get_press_position(event), Vector2.INF)

Some files were not shown because too many files have changed in this diff Show More