2 Commits

Author SHA1 Message Date
Steven Wroblewski
6b0c41bbfd fix(core): resolve review findings in Sprint 3-4
- Fix SaveManager reset_game to use DirAccess.remove correctly
- Add null check for FileAccess.open in save_game
- Fix AudioManager crossfade tween callback chain
- Replace fragile absolute HUD path with relative onready
- Guard character position save against empty id
- Add GameState.has_character_position helper
- Emit room_changed signal after tween completes
- Add target_floor validation in ElevatorButton
- Persist audio settings via GameState
- Add class_name to RoomNavigator, AudioManager, HUD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:22:27 +02:00
Steven Wroblewski
13db45bb04 feat(core): implement room navigation, character states, save/load and settings menu
- RoomNavigator autoload: smooth camera pan between floors
- Floor1 and Floor2 placeholder rooms with elevator buttons
- CharacterData Resource with State enum (HEALTHY/SICK/SLEEPING/TIRED)
- Character visual state feedback via ColorRect color
- Main scene loads saved state on startup
- SettingsMenu with music/sfx sliders and game reset
- HUD settings button to open SettingsMenu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:17:56 +02:00
15 changed files with 431 additions and 16 deletions

View File

@@ -23,6 +23,7 @@ GameState="*res://scripts/autoload/GameState.gd"
SaveManager="*res://scripts/autoload/SaveManager.gd"
AudioManager="*res://scripts/autoload/AudioManager.gd"
InputManager="*res://scripts/autoload/InputManager.gd"
RoomNavigator="*res://scripts/systems/room_navigator.gd"
[display]

View File

@@ -1,23 +1,101 @@
[gd_scene load_steps=4 format=3 uid="uid://cozypaw_main"]
[gd_scene load_steps=8 format=3 uid="uid://cozypaw_main"]
[ext_resource type="PackedScene" path="res://scenes/rooms/floor0/Reception.tscn" id="1_reception"]
[ext_resource type="PackedScene" path="res://scenes/characters/Character.tscn" id="2_char"]
[ext_resource type="PackedScene" path="res://scenes/ui/HUD.tscn" id="3_hud"]
[ext_resource type="Script" path="res://scripts/main/main.gd" id="1_main"]
[ext_resource type="PackedScene" path="res://scenes/rooms/floor0/Reception.tscn" id="2_reception"]
[ext_resource type="PackedScene" path="res://scenes/characters/Character.tscn" id="3_char"]
[ext_resource type="PackedScene" path="res://scenes/ui/HUD.tscn" id="4_hud"]
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsMenu.tscn" id="5_settings"]
[ext_resource type="PackedScene" path="res://scenes/objects/ElevatorButton.tscn" id="6_elevbtn"]
[sub_resource type="CharacterData" id="CharacterData_bunny1"]
id = "bunny_01"
display_name = "Bunny"
species = 0
state = 0
current_floor = 0
position = Vector2(0, 0)
[node name="Main" type="Node2D"]
script = ExtResource("1_main")
[node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(640, 360)
zoom = Vector2(1, 1)
[node name="Hospital" type="Node2D" parent="."]
[node name="Floor0" type="Node2D" parent="Hospital"]
[node name="Reception" parent="Hospital/Floor0" instance=ExtResource("1_reception")]
position = Vector2(0, 0)
[node name="Reception" parent="Hospital/Floor0" instance=ExtResource("2_reception")]
position = Vector2(0, 0)
[node name="ElevatorUp0" parent="Hospital/Floor0" instance=ExtResource("6_elevbtn")]
position = Vector2(1200, 360)
target_floor = 1
[node name="Floor1" type="Node2D" parent="Hospital"]
position = Vector2(0, -720)
[node name="Background" type="ColorRect" parent="Hospital/Floor1"]
color = Color(0.78, 0.88, 0.96, 1)
size = Vector2(1280, 720)
position = Vector2(0, 0)
[node name="FloorLabel" type="Label" parent="Hospital/Floor1"]
offset_left = 560.0
offset_top = 300.0
offset_right = 720.0
offset_bottom = 360.0
text = "1. OG"
theme_override_font_sizes/font_size = 32
[node name="FloorRect" type="ColorRect" parent="Hospital/Floor1"]
color = Color(0.88, 0.80, 0.68, 1)
size = Vector2(1280, 100)
position = Vector2(0, 620)
[node name="ElevatorDown1" parent="Hospital/Floor1" instance=ExtResource("6_elevbtn")]
position = Vector2(1200, 540)
target_floor = 0
[node name="ElevatorUp1" parent="Hospital/Floor1" instance=ExtResource("6_elevbtn")]
position = Vector2(1200, 180)
target_floor = 2
[node name="Floor2" type="Node2D" parent="Hospital"]
position = Vector2(0, -1440)
[node name="Background" type="ColorRect" parent="Hospital/Floor2"]
color = Color(0.96, 0.88, 0.96, 1)
size = Vector2(1280, 720)
position = Vector2(0, 0)
[node name="FloorLabel" type="Label" parent="Hospital/Floor2"]
offset_left = 560.0
offset_top = 300.0
offset_right = 720.0
offset_bottom = 360.0
text = "2. OG"
theme_override_font_sizes/font_size = 32
[node name="FloorRect" type="ColorRect" parent="Hospital/Floor2"]
color = Color(0.88, 0.80, 0.68, 1)
size = Vector2(1280, 100)
position = Vector2(0, 620)
[node name="ElevatorDown2" parent="Hospital/Floor2" instance=ExtResource("6_elevbtn")]
position = Vector2(1200, 360)
target_floor = 1
[node name="Characters" type="Node2D" parent="."]
[node name="Bunny1" parent="Characters" instance=ExtResource("2_char")]
[node name="Bunny1" parent="Characters" instance=ExtResource("3_char")]
position = Vector2(300, 400)
data = SubResource("CharacterData_bunny1")
[node name="UI" type="CanvasLayer" parent="."]
[node name="HUD" parent="UI" instance=ExtResource("3_hud")]
[node name="HUD" parent="UI" instance=ExtResource("4_hud")]
[node name="SettingsMenu" parent="UI" instance=ExtResource("5_settings")]

View File

@@ -0,0 +1,29 @@
[gd_scene load_steps=3 format=3 uid="uid://cozypaw_elevator_btn"]
[ext_resource type="Script" path="res://scripts/objects/elevator_button.gd" id="1_elevbtn"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_elevbtn"]
size = Vector2(80, 80)
[node name="ElevatorButton" type="Node2D"]
script = ExtResource("1_elevbtn")
[node name="Visual" type="ColorRect" parent="."]
color = Color(0.95, 0.75, 0.3, 1)
size = Vector2(80, 80)
position = Vector2(-40, -40)
[node name="Label" type="Label" parent="Visual"]
offset_left = 0.0
offset_top = 0.0
offset_right = 80.0
offset_bottom = 80.0
horizontal_alignment = 1
vertical_alignment = 1
text = "▲"
[node name="CollisionArea" type="Area2D" parent="."]
input_pickable = true
[node name="CollisionShape" type="CollisionShape2D" parent="CollisionArea"]
shape = SubResource("RectangleShape2D_elevbtn")

View File

@@ -31,5 +31,19 @@ offset_bottom = 80.0
text = "♪"
flat = false
[node name="SettingsButton" type="Button" parent="."]
anchors_preset = 1
anchor_left = 1.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 0.0
offset_left = -160.0
offset_top = 16.0
offset_right = -96.0
offset_bottom = 80.0
text = "⚙"
flat = false
[connection signal="pressed" from="BackButton" to="." method="_on_back_button_pressed"]
[connection signal="pressed" from="MusicToggle" to="." method="_on_music_toggle_pressed"]
[connection signal="pressed" from="SettingsButton" to="." method="_on_settings_button_pressed"]

103
scenes/ui/SettingsMenu.tscn Normal file
View File

@@ -0,0 +1,103 @@
[gd_scene load_steps=2 format=3 uid="uid://cozypaw_settings"]
[ext_resource type="Script" path="res://scripts/systems/settings_menu.gd" id="1_settings"]
[node name="SettingsMenu" type="CanvasLayer"]
script = ExtResource("1_settings")
visible = false
[node name="Backdrop" type="ColorRect" parent="."]
color = Color(0, 0, 0, 0.7)
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 0.0
offset_top = 0.0
offset_right = 0.0
offset_bottom = 0.0
grow_horizontal = 2
grow_vertical = 2
[node name="Panel" type="Panel" parent="."]
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -200.0
offset_top = -150.0
offset_right = 200.0
offset_bottom = 150.0
grow_horizontal = 2
grow_vertical = 2
[node name="Title" type="Label" parent="Panel"]
offset_left = 0.0
offset_top = 16.0
offset_right = 400.0
offset_bottom = 64.0
horizontal_alignment = 1
text = "⚙"
theme_override_font_sizes/font_size = 32
[node name="MusicLabel" type="Label" parent="Panel"]
offset_left = 24.0
offset_top = 76.0
offset_right = 80.0
offset_bottom = 108.0
text = "♪"
theme_override_font_sizes/font_size = 24
[node name="MusicSlider" type="HSlider" parent="Panel"]
min_value = 0.0
max_value = 1.0
step = 0.05
value = 0.6
offset_left = 88.0
offset_top = 76.0
offset_right = 376.0
offset_bottom = 108.0
[node name="SfxLabel" type="Label" parent="Panel"]
offset_left = 24.0
offset_top = 120.0
offset_right = 80.0
offset_bottom = 152.0
text = "🔊"
theme_override_font_sizes/font_size = 24
[node name="SfxSlider" type="HSlider" parent="Panel"]
min_value = 0.0
max_value = 1.0
step = 0.05
value = 1.0
offset_left = 88.0
offset_top = 120.0
offset_right = 376.0
offset_bottom = 152.0
[node name="ResetButton" type="Button" parent="Panel"]
offset_left = 24.0
offset_top = 172.0
offset_right = 88.0
offset_bottom = 236.0
text = "↺"
theme_override_font_sizes/font_size = 28
[node name="ResetConfirmDialog" type="ConfirmationDialog" parent="Panel"]
title = "Reset?"
dialog_text = "Spielstand löschen?"
[node name="CloseButton" type="Button" parent="Panel"]
offset_left = 312.0
offset_top = 172.0
offset_right = 376.0
offset_bottom = 236.0
text = "✕"
theme_override_font_sizes/font_size = 28
[connection signal="value_changed" from="Panel/MusicSlider" to="." method="_on_music_slider_value_changed"]
[connection signal="value_changed" from="Panel/SfxSlider" to="." method="_on_sfx_slider_value_changed"]
[connection signal="pressed" from="Panel/ResetButton" to="." method="_on_reset_button_pressed"]
[connection signal="confirmed" from="Panel/ResetConfirmDialog" to="." method="_on_reset_confirmed"]
[connection signal="pressed" from="Panel/CloseButton" to="." method="_on_close_button_pressed"]

View File

@@ -1,5 +1,5 @@
## AudioManager — music playback with cross-fade, SFX playback, volume control.
extends Node
class_name AudioManager extends Node
const DEFAULT_MUSIC_VOLUME: float = 0.6
const CROSSFADE_DURATION: float = 1.0
@@ -31,13 +31,13 @@ func play_music(stream: AudioStream) -> void:
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(_active_player, "volume_db", linear_to_db(0.0), CROSSFADE_DURATION)
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)
var prev_player: AudioStreamPlayer = _active_player
tween.tween_callback(prev_player.stop).set_delay(CROSSFADE_DURATION)
_active_player = next_player
tween.chain().tween_callback(prev_player.stop)
func play_sfx(stream: AudioStream) -> void:

View File

@@ -7,6 +7,12 @@ signal character_moved(character_id: String, position: Vector2)
var _character_positions: 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:
@@ -33,6 +39,8 @@ func get_save_data() -> Dictionary:
"character_positions": _character_positions,
"object_states": _object_states,
"current_room": current_room,
"music_volume": music_volume,
"sfx_volume": sfx_volume,
}
@@ -43,3 +51,7 @@ func apply_save_data(data: Dictionary) -> void:
_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"]

View File

@@ -12,6 +12,7 @@ func save_game() -> void:
var data: Dictionary = GameState.get_save_data()
var file: FileAccess = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("SaveManager: cannot open save file for writing")
return
file.store_string(JSON.stringify(data))
file.close()
@@ -32,7 +33,9 @@ func load_game() -> void:
func reset_game() -> void:
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
var dir: DirAccess = DirAccess.open("user://")
if dir != null:
dir.remove("savegame.json")
GameState.apply_save_data({})

View File

@@ -3,18 +3,55 @@ class_name Character extends Node2D
signal character_picked_up(character: Character)
signal character_placed(character: Character, position: Vector2)
signal state_changed(new_state: CharacterData.State)
@export var character_id: String = ""
@export var display_name: String = ""
@export var data: CharacterData
var _is_held: bool = false
const _STATE_COLORS: Dictionary = {
CharacterData.State.HEALTHY: Color(0.6, 0.8, 1.0),
CharacterData.State.SICK: Color(0.7, 0.9, 0.7),
CharacterData.State.SLEEPING: Color(0.8, 0.7, 0.95),
CharacterData.State.TIRED: Color(1.0, 0.95, 0.6),
CharacterData.State.PREGNANT: Color(1.0, 0.85, 0.9),
CharacterData.State.BABY: Color(0.9, 0.95, 1.0),
}
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()
func set_state(new_state: CharacterData.State) -> void:
if data == null:
return
data.state = new_state
_update_visual_state()
state_changed.emit(new_state)
func _update_visual_state() -> void:
if data == null:
return
var visual: ColorRect = get_node_or_null("Visual") as ColorRect
if visual == null:
return
var color: Color = _STATE_COLORS.get(data.state, Color(0.6, 0.8, 1.0))
visual.color = color
var ear_left: ColorRect = get_node_or_null("Ears/EarLeft") as ColorRect
var ear_right: ColorRect = get_node_or_null("Ears/EarRight") as ColorRect
if ear_left != null:
ear_left.color = color
if ear_right != null:
ear_right.color = color
func _on_drag_picked_up(_pos: Vector2) -> void:
@@ -24,5 +61,7 @@ func _on_drag_picked_up(_pos: Vector2) -> void:
func _on_drag_released(pos: Vector2) -> void:
_is_held = false
if data == null or data.id.is_empty():
return
GameState.set_character_position(character_id, global_position)
character_placed.emit(self, global_position)

View File

@@ -0,0 +1,12 @@
## 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

17
scripts/main/main.gd Normal file
View File

@@ -0,0 +1,17 @@
## 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()
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)

View File

@@ -0,0 +1,32 @@
## ElevatorButton — tappable button that navigates the camera to a target floor.
class_name ElevatorButton extends Node2D
@export var target_floor: int = 0
var _area: Area2D
func _ready() -> void:
_area = get_node_or_null("CollisionArea") as Area2D
if _area != null:
_area.input_event.connect(_on_input_event)
func _on_input_event(_viewport: Node, event: InputEvent, _shape_idx: int) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_pressed()
elif event is InputEventScreenTouch and event.pressed:
_on_pressed()
func _on_pressed() -> void:
if target_floor < 0:
return
RoomNavigator.go_to_floor(target_floor)
_play_bounce()
func _play_bounce() -> void:
var tween: Tween = create_tween()
tween.tween_property(self, "scale", Vector2(1.15, 1.15), 0.08)
tween.tween_property(self, "scale", Vector2(1.0, 1.0), 0.08)

View File

@@ -1,10 +1,11 @@
## HUD — heads-up display with back button and music toggle.
extends CanvasLayer
## HUD — heads-up display with back button, music toggle, and settings access.
class_name HUD extends CanvasLayer
const MUSIC_ON_SYMBOL: String = ""
const MUSIC_OFF_SYMBOL: String = ""
var _music_enabled: bool = true
@onready var _settings_menu: SettingsMenu = get_node_or_null("../SettingsMenu") as SettingsMenu
func _on_back_button_pressed() -> void:
@@ -18,3 +19,8 @@ func _on_music_toggle_pressed() -> void:
var btn: Button = get_node_or_null("MusicToggle") as Button
if btn != null:
btn.text = MUSIC_ON_SYMBOL if _music_enabled else MUSIC_OFF_SYMBOL
func _on_settings_button_pressed() -> void:
if _settings_menu != null:
_settings_menu.show_menu()

View File

@@ -0,0 +1,30 @@
## RoomNavigator — autoload that moves the Camera2D smoothly between hospital floors.
class_name RoomNavigator extends Node
signal room_changed(floor_index: int)
const FLOOR_HEIGHT: float = 720.0
const CAMERA_TWEEN_DURATION: float = 0.6
var _current_floor: int = 0
var _camera: Camera2D
func initialize(camera: Camera2D) -> void:
_camera = camera
func go_to_floor(floor_index: int) -> void:
if _camera == null or floor_index == _current_floor:
return
_current_floor = floor_index
var target_y: float = floor_index * -FLOOR_HEIGHT
var tween: Tween = create_tween()
tween.set_ease(Tween.EASE_IN_OUT)
tween.set_trans(Tween.TRANS_SINE)
tween.tween_property(_camera, "position:y", target_y, CAMERA_TWEEN_DURATION)
tween.finished.connect(func() -> void: room_changed.emit(floor_index))
func get_current_floor() -> int:
return _current_floor

View File

@@ -0,0 +1,39 @@
## SettingsMenu — overlay panel for audio volume and game reset.
class_name SettingsMenu extends CanvasLayer
func _ready() -> void:
visible = false
func show_menu() -> void:
visible = true
func hide_menu() -> void:
visible = false
func _on_music_slider_value_changed(value: float) -> void:
AudioManager.set_music_volume(value)
GameState.music_volume = value
func _on_sfx_slider_value_changed(value: float) -> void:
AudioManager.set_sfx_volume(value)
GameState.sfx_volume = value
func _on_reset_button_pressed() -> void:
var dialog: ConfirmationDialog = get_node_or_null("Panel/ResetConfirmDialog") as ConfirmationDialog
if dialog != null:
dialog.popup_centered()
func _on_reset_confirmed() -> void:
SaveManager.reset_game()
get_tree().reload_current_scene()
func _on_close_button_pressed() -> void:
hide_menu()