From 9a1e30d808d90e0fb8719635b890d65d5d0440e6 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Fri, 17 Apr 2026 10:38:29 +0200 Subject: [PATCH] feat(poc): implement Sprint 1 proof of concept - project.godot with autoload configuration - Reception room with placeholder visuals - Draggable Character with DragDropComponent - Interactive flower object with bounce animation - GameState, SaveManager, AudioManager, InputManager autoloads - HUD with back button and music toggle Co-Authored-By: Claude Sonnet 4.6 --- project.godot | 15 +++-- scenes/characters/Character.tscn | 46 +++++++++++++++ scenes/main/Main.tscn | 23 ++++++++ scenes/objects/InteractiveObject.tscn | 28 +++++++++ scenes/rooms/floor0/Reception.tscn | 41 +++++++++++++ scenes/ui/HUD.tscn | 35 +++++++++++ scripts/autoload/AudioManager.gd | 63 ++++++++++++++++++++ scripts/autoload/GameState.gd | 45 +++++++++++++++ scripts/autoload/InputManager.gd | 30 ++++++++++ scripts/autoload/SaveManager.gd | 40 +++++++++++++ scripts/characters/character.gd | 28 +++++++++ scripts/objects/interactive_object.gd | 57 ++++++++++++++++++ scripts/systems/drag_drop_component.gd | 80 ++++++++++++++++++++++++++ scripts/systems/hud.gd | 21 +++++++ 14 files changed, 547 insertions(+), 5 deletions(-) create mode 100644 scenes/characters/Character.tscn create mode 100644 scenes/main/Main.tscn create mode 100644 scenes/objects/InteractiveObject.tscn create mode 100644 scenes/rooms/floor0/Reception.tscn create mode 100644 scenes/ui/HUD.tscn create mode 100644 scripts/autoload/AudioManager.gd create mode 100644 scripts/autoload/GameState.gd create mode 100644 scripts/autoload/InputManager.gd create mode 100644 scripts/autoload/SaveManager.gd create mode 100644 scripts/characters/character.gd create mode 100644 scripts/objects/interactive_object.gd create mode 100644 scripts/systems/drag_drop_component.gd create mode 100644 scripts/systems/hud.gd diff --git a/project.godot b/project.godot index f38c946..373bb56 100644 --- a/project.godot +++ b/project.godot @@ -1,10 +1,6 @@ ; Engine configuration file. ; It's best edited using the editor UI and not directly, -; since the parameters that go here are not all obvious. -; -; Format: -; [section] ; section goes here -; param=value ; parameter goes here +; but this is the source of truth for engine configuration. config_version=5 @@ -13,7 +9,16 @@ config_version=5 config/name="Cozypaw Hospital" config/description="Werbefrei. Offline. Für Kinder." config/version="0.1.0" +run/main_scene="res://scenes/main/Main.tscn" config/features=PackedStringArray("4.6", "Mobile") +boot_splash/show_image=false + +[autoload] + +GameState="*res://scripts/autoload/GameState.gd" +SaveManager="*res://scripts/autoload/SaveManager.gd" +AudioManager="*res://scripts/autoload/AudioManager.gd" +InputManager="*res://scripts/autoload/InputManager.gd" [display] diff --git a/scenes/characters/Character.tscn b/scenes/characters/Character.tscn new file mode 100644 index 0000000..42510ec --- /dev/null +++ b/scenes/characters/Character.tscn @@ -0,0 +1,46 @@ +[gd_scene load_steps=3 format=3 uid="uid://cozypaw_char"] + +[ext_resource type="Script" path="res://scripts/characters/character.gd" id="1_char"] +[ext_resource type="Script" path="res://scripts/systems/drag_drop_component.gd" id="2_drag"] + +[node name="Character" type="Node2D"] +script = ExtResource("1_char") +character_id = "bunny_01" +display_name = "Bunny" + +[node name="Visual" type="ColorRect" parent="."] +color = Color(0.95, 0.85, 0.9, 1) +size = Vector2(64, 80) +position = Vector2(-32, -80) + +[node name="Eyes" type="Node2D" parent="Visual"] + +[node name="EyeLeft" type="ColorRect" parent="Visual/Eyes"] +color = Color(0.1, 0.1, 0.1, 1) +size = Vector2(8, 8) +position = Vector2(14, 20) + +[node name="EyeRight" type="ColorRect" parent="Visual/Eyes"] +color = Color(0.1, 0.1, 0.1, 1) +size = Vector2(8, 8) +position = Vector2(42, 20) + +[node name="Ears" type="Node2D" parent="."] + +[node name="EarLeft" type="ColorRect" parent="Ears"] +color = Color(0.95, 0.85, 0.9, 1) +size = Vector2(18, 36) +position = Vector2(-22, -114) + +[node name="EarRight" type="ColorRect" parent="Ears"] +color = Color(0.95, 0.85, 0.9, 1) +size = Vector2(18, 36) +position = Vector2(4, -114) + +[node name="DragDropComponent" type="Node" parent="."] +script = ExtResource("2_drag") + +[node name="CollisionArea" type="Area2D" parent="."] +input_pickable = true + +[node name="CollisionShape" type="CollisionShape2D" parent="CollisionArea"] diff --git a/scenes/main/Main.tscn b/scenes/main/Main.tscn new file mode 100644 index 0000000..cfd0027 --- /dev/null +++ b/scenes/main/Main.tscn @@ -0,0 +1,23 @@ +[gd_scene load_steps=4 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"] + +[node name="Main" type="Node2D"] + +[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="Characters" type="Node2D" parent="."] + +[node name="Bunny1" parent="Characters" instance=ExtResource("2_char")] +position = Vector2(300, 400) + +[node name="UI" type="CanvasLayer" parent="."] + +[node name="HUD" parent="UI" instance=ExtResource("3_hud")] diff --git a/scenes/objects/InteractiveObject.tscn b/scenes/objects/InteractiveObject.tscn new file mode 100644 index 0000000..45b6c52 --- /dev/null +++ b/scenes/objects/InteractiveObject.tscn @@ -0,0 +1,28 @@ +[gd_scene load_steps=3 format=3 uid="uid://cozypaw_iobj"] + +[ext_resource type="Script" path="res://scripts/objects/interactive_object.gd" id="1_iobj"] +[ext_resource type="Script" path="res://scripts/systems/drag_drop_component.gd" id="2_drag"] + +[node name="InteractiveObject" type="Node2D"] +script = ExtResource("1_iobj") +object_id = "flower_01" + +[node name="Visual" type="Node2D" parent="."] + +[node name="Stem" type="ColorRect" parent="Visual"] +color = Color(0.2, 0.7, 0.2, 1) +size = Vector2(8, 40) +position = Vector2(-4, -40) + +[node name="Bloom" type="ColorRect" parent="Visual"] +color = Color(1.0, 0.4, 0.6, 1) +size = Vector2(32, 32) +position = Vector2(-16, -72) + +[node name="DragDropComponent" type="Node" parent="."] +script = ExtResource("2_drag") + +[node name="CollisionArea" type="Area2D" parent="."] +input_pickable = true + +[node name="CollisionShape" type="CollisionShape2D" parent="CollisionArea"] diff --git a/scenes/rooms/floor0/Reception.tscn b/scenes/rooms/floor0/Reception.tscn new file mode 100644 index 0000000..fad2787 --- /dev/null +++ b/scenes/rooms/floor0/Reception.tscn @@ -0,0 +1,41 @@ +[gd_scene load_steps=2 format=3 uid="uid://cozypaw_reception"] + +[ext_resource type="PackedScene" path="res://scenes/objects/InteractiveObject.tscn" id="1_iobj"] + +[node name="Reception" type="Node2D"] + +[node name="Background" type="ColorRect" parent="."] +color = Color(0.78, 0.94, 0.80, 1) +size = Vector2(1280, 720) +position = Vector2(0, 0) + +[node name="Counter" type="ColorRect" parent="."] +color = Color(0.55, 0.35, 0.18, 1) +size = Vector2(300, 80) +position = Vector2(490, 610) + +[node name="CounterTop" type="ColorRect" parent="."] +color = Color(0.70, 0.50, 0.28, 1) +size = Vector2(300, 12) +position = Vector2(490, 598) + +[node name="Floor" type="ColorRect" parent="."] +color = Color(0.88, 0.80, 0.68, 1) +size = Vector2(1280, 100) +position = Vector2(0, 620) + +[node name="WallLeft" type="ColorRect" parent="."] +color = Color(0.92, 0.88, 0.82, 1) +size = Vector2(40, 620) +position = Vector2(0, 0) + +[node name="WallRight" type="ColorRect" parent="."] +color = Color(0.92, 0.88, 0.82, 1) +size = Vector2(40, 620) +position = Vector2(1240, 0) + +[node name="CharacterSpawn" type="Marker2D" parent="."] +position = Vector2(300, 520) + +[node name="Flower" parent="." instance=ExtResource("1_iobj")] +position = Vector2(200, 560) diff --git a/scenes/ui/HUD.tscn b/scenes/ui/HUD.tscn new file mode 100644 index 0000000..f301e41 --- /dev/null +++ b/scenes/ui/HUD.tscn @@ -0,0 +1,35 @@ +[gd_scene load_steps=2 format=3 uid="uid://cozypaw_hud"] + +[ext_resource type="Script" path="res://scripts/systems/hud.gd" id="1_hud"] + +[node name="HUD" type="CanvasLayer"] +script = ExtResource("1_hud") + +[node name="BackButton" type="Button" parent="."] +anchors_preset = 0 +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +offset_left = 16.0 +offset_top = 16.0 +offset_right = 80.0 +offset_bottom = 80.0 +text = "←" +flat = false + +[node name="MusicToggle" type="Button" parent="."] +anchors_preset = 1 +anchor_left = 1.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 0.0 +offset_left = -80.0 +offset_top = 16.0 +offset_right = -16.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"] diff --git a/scripts/autoload/AudioManager.gd b/scripts/autoload/AudioManager.gd new file mode 100644 index 0000000..da09072 --- /dev/null +++ b/scripts/autoload/AudioManager.gd @@ -0,0 +1,63 @@ +## AudioManager — music playback with cross-fade, SFX playback, volume control. +extends Node + +const DEFAULT_MUSIC_VOLUME: float = 0.6 +const CROSSFADE_DURATION: float = 1.0 + +var _music_player_a: AudioStreamPlayer +var _music_player_b: AudioStreamPlayer +var _active_player: 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() + _sfx_player = AudioStreamPlayer.new() + add_child(_music_player_a) + add_child(_music_player_b) + add_child(_sfx_player) + _active_player = _music_player_a + _apply_music_volume() + + +func play_music(stream: AudioStream) -> void: + if _active_player.stream == stream and _active_player.playing: + 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 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(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 + + +func play_sfx(stream: AudioStream) -> void: + _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_sfx_volume(value: float) -> void: + _sfx_volume = clampf(value, 0.0, 1.0) + + +func get_music_volume() -> float: + return _music_volume + + +func _apply_music_volume() -> void: + _active_player.volume_db = linear_to_db(_music_volume) diff --git a/scripts/autoload/GameState.gd b/scripts/autoload/GameState.gd new file mode 100644 index 0000000..ba86893 --- /dev/null +++ b/scripts/autoload/GameState.gd @@ -0,0 +1,45 @@ +## GameState — global game state: character positions, object states, current room. +extends Node + +signal state_changed +signal character_moved(character_id: String, position: Vector2) + +var _character_positions: Dictionary = {} +var _object_states: Dictionary = {} +var current_room: String = "reception" + + +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_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: + return { + "character_positions": _character_positions, + "object_states": _object_states, + "current_room": current_room, + } + + +func apply_save_data(data: Dictionary) -> void: + if data.has("character_positions"): + _character_positions = data["character_positions"] + if data.has("object_states"): + _object_states = data["object_states"] + if data.has("current_room"): + current_room = data["current_room"] diff --git a/scripts/autoload/InputManager.gd b/scripts/autoload/InputManager.gd new file mode 100644 index 0000000..c50bf67 --- /dev/null +++ b/scripts/autoload/InputManager.gd @@ -0,0 +1,30 @@ +## InputManager — abstracts touch and mouse input into unified drag signals. +extends Node + +signal drag_started(position: Vector2) +signal drag_moved(position: Vector2) +signal drag_ended(position: Vector2) + +var _is_dragging: bool = false + + +func _input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + if event.pressed: + _is_dragging = true + drag_started.emit(event.position) + else: + _is_dragging = false + drag_ended.emit(event.position) + elif event is InputEventScreenDrag: + if _is_dragging: + drag_moved.emit(event.position) + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _is_dragging = true + drag_started.emit(event.position) + else: + _is_dragging = false + drag_ended.emit(event.position) + elif event is InputEventMouseMotion and _is_dragging: + drag_moved.emit(event.position) diff --git a/scripts/autoload/SaveManager.gd b/scripts/autoload/SaveManager.gd new file mode 100644 index 0000000..21ffac4 --- /dev/null +++ b/scripts/autoload/SaveManager.gd @@ -0,0 +1,40 @@ +## SaveManager — persists game state as JSON to user://savegame.json, auto-saves on state_changed. +extends Node + +const SAVE_PATH: String = "user://savegame.json" + + +func _ready() -> void: + GameState.state_changed.connect(_on_state_changed) + + +func save_game() -> void: + var data: Dictionary = GameState.get_save_data() + var file: FileAccess = FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if file == null: + return + file.store_string(JSON.stringify(data)) + file.close() + + +func load_game() -> void: + if not FileAccess.file_exists(SAVE_PATH): + return + var file: FileAccess = FileAccess.open(SAVE_PATH, FileAccess.READ) + if file == null: + return + var raw: String = file.get_as_text() + file.close() + var parsed = JSON.parse_string(raw) + if parsed is Dictionary: + GameState.apply_save_data(parsed) + + +func reset_game() -> void: + if FileAccess.file_exists(SAVE_PATH): + DirAccess.remove_absolute(SAVE_PATH) + GameState.apply_save_data({}) + + +func _on_state_changed() -> void: + save_game() diff --git a/scripts/characters/character.gd b/scripts/characters/character.gd new file mode 100644 index 0000000..a40e327 --- /dev/null +++ b/scripts/characters/character.gd @@ -0,0 +1,28 @@ +## Character — base class for all playable figures (bunny, cat, etc.). +class_name Character extends Node2D + +signal character_picked_up(character: Character) +signal character_placed(character: Character, position: Vector2) + +@export var character_id: String = "" +@export var display_name: String = "" + +var _is_held: bool = false + + +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: + _is_held = true + character_picked_up.emit(self) + + +func _on_drag_released(pos: Vector2) -> void: + _is_held = false + GameState.set_character_position(character_id, global_position) + character_placed.emit(self, pos) diff --git a/scripts/objects/interactive_object.gd b/scripts/objects/interactive_object.gd new file mode 100644 index 0000000..8abfada --- /dev/null +++ b/scripts/objects/interactive_object.gd @@ -0,0 +1,57 @@ +## InteractiveObject — base class for all interactive room objects (flowers, equipment, etc.). +class_name InteractiveObject extends Node2D + +signal object_interacted(object: InteractiveObject) + +enum State { IDLE, ACTIVE, RETURNING } + +@export var object_id: String = "" + +var _current_state: State = State.IDLE + + +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) + var area: Area2D = get_node_or_null("CollisionArea") as Area2D + if area != null: + area.input_event.connect(_on_area_input_event) + + +func _on_drag_picked_up(_pos: Vector2) -> void: + _set_state(State.ACTIVE) + object_interacted.emit(self) + + +func _on_drag_released(_pos: Vector2) -> void: + _set_state(State.RETURNING) + GameState.set_object_state(object_id, "idle") + _play_bounce_animation() + + +func _on_area_input_event(_viewport: Node, event: InputEvent, _shape_idx: int) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + _trigger_interaction() + elif event is InputEventScreenTouch and event.pressed: + _trigger_interaction() + + +func _trigger_interaction() -> void: + _set_state(State.ACTIVE) + object_interacted.emit(self) + GameState.set_object_state(object_id, "active") + _play_bounce_animation() + + +func _set_state(new_state: State) -> void: + _current_state = new_state + + +func _play_bounce_animation() -> void: + var tween: Tween = create_tween() + tween.tween_property(self, "scale", Vector2(1.2, 1.2), 0.1) + tween.tween_property(self, "scale", Vector2(0.9, 0.9), 0.1) + tween.tween_property(self, "scale", Vector2(1.0, 1.0), 0.1) + tween.tween_callback(func() -> void: _set_state(State.IDLE)) diff --git a/scripts/systems/drag_drop_component.gd b/scripts/systems/drag_drop_component.gd new file mode 100644 index 0000000..9c7d995 --- /dev/null +++ b/scripts/systems/drag_drop_component.gd @@ -0,0 +1,80 @@ +## DragDropComponent — reusable drag-and-drop node; attach to any Character or InteractiveObject. +class_name DragDropComponent extends Node + +signal drag_picked_up(global_position: Vector2) +signal drag_released(global_position: Vector2) + +const DRAG_Z_INDEX: int = 10 +const DRAG_SCALE: float = 1.1 + +@export var drag_target: Node2D + +var _is_dragging: bool = false +var _drag_offset: Vector2 = Vector2.ZERO +var _original_z_index: int = 0 +var _original_scale: Vector2 = Vector2.ONE + + +func _ready() -> void: + if drag_target == null: + drag_target = get_parent() as Node2D + + +func _input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + _handle_press(event.pressed, event.position) + elif event is InputEventScreenDrag and _is_dragging: + _move_to(event.position) + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + _handle_press(event.pressed, event.position) + elif event is InputEventMouseMotion and _is_dragging: + _move_to(event.position) + + +func _handle_press(pressed: bool, screen_pos: Vector2) -> void: + if pressed and _is_position_over_target(screen_pos): + _start_drag(screen_pos) + elif not pressed and _is_dragging: + _end_drag(screen_pos) + + +func _start_drag(screen_pos: Vector2) -> void: + _is_dragging = true + var world_pos: Vector2 = _screen_to_world(screen_pos) + _drag_offset = drag_target.global_position - world_pos + _original_z_index = drag_target.z_index + _original_scale = drag_target.scale + drag_target.z_index = DRAG_Z_INDEX + drag_target.scale = _original_scale * DRAG_SCALE + drag_picked_up.emit(drag_target.global_position) + + +func _end_drag(_screen_pos: Vector2) -> void: + _is_dragging = false + drag_target.z_index = _original_z_index + drag_target.scale = _original_scale + drag_released.emit(drag_target.global_position) + + +func _move_to(screen_pos: Vector2) -> void: + var world_pos: Vector2 = _screen_to_world(screen_pos) + drag_target.global_position = world_pos + _drag_offset + + +func _screen_to_world(screen_pos: Vector2) -> Vector2: + var canvas_transform: Transform2D = drag_target.get_viewport().get_canvas_transform() + return canvas_transform.affine_inverse() * screen_pos + + +func _is_position_over_target(screen_pos: Vector2) -> bool: + if drag_target == null: + return false + var world_pos: Vector2 = _screen_to_world(screen_pos) + var local_pos: Vector2 = drag_target.to_local(world_pos) + var area: Area2D = drag_target.get_node_or_null("CollisionArea") as Area2D + if area == null: + return local_pos.length() < 64.0 + for child in area.get_children(): + if child is CollisionShape2D and child.shape != null: + return child.shape.get_rect().has_point(local_pos) + return local_pos.length() < 64.0 diff --git a/scripts/systems/hud.gd b/scripts/systems/hud.gd new file mode 100644 index 0000000..01be0f1 --- /dev/null +++ b/scripts/systems/hud.gd @@ -0,0 +1,21 @@ +## HUD — heads-up display with back button and music toggle. +extends CanvasLayer + +var _music_enabled: bool = true + + +func _ready() -> void: + pass + + +func _on_back_button_pressed() -> void: + get_tree().quit() + + +func _on_music_toggle_pressed() -> void: + _music_enabled = not _music_enabled + var volume: float = AudioManager.DEFAULT_MUSIC_VOLUME if _music_enabled else 0.0 + AudioManager.set_music_volume(volume) + var btn: Button = get_node_or_null("MusicToggle") as Button + if btn != null: + btn.text = "♪" if _music_enabled else "✕"