From 43a7e6bde4f539653fc488e398756b98020df922 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Sun, 10 May 2026 20:50:13 +0200 Subject: [PATCH] docs: add Sprint 20 navigation integration spec and plan --- ...-05-10-sprint-20-navigation-integration.md | 347 ++++++++++++++++++ ...-05-10-sprint-20-navigation-integration.md | 99 +++++ 2 files changed, 446 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-sprint-20-navigation-integration.md create mode 100644 docs/superpowers/specs/2026-05-10-sprint-20-navigation-integration.md diff --git a/docs/superpowers/plans/2026-05-10-sprint-20-navigation-integration.md b/docs/superpowers/plans/2026-05-10-sprint-20-navigation-integration.md new file mode 100644 index 0000000..c6892f1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-sprint-20-navigation-integration.md @@ -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" +``` diff --git a/docs/superpowers/specs/2026-05-10-sprint-20-navigation-integration.md b/docs/superpowers/specs/2026-05-10-sprint-20-navigation-integration.md new file mode 100644 index 0000000..1511b84 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-sprint-20-navigation-integration.md @@ -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