diff --git a/scenes/characters/Character.tscn b/scenes/characters/Character.tscn index 29d0c53..f6ce084 100644 --- a/scenes/characters/Character.tscn +++ b/scenes/characters/Character.tscn @@ -1,7 +1,8 @@ -[gd_scene load_steps=4 format=3 uid="uid://cozypaw_char"] +[gd_scene load_steps=5 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"] +[ext_resource type="Script" path="res://scripts/characters/snap_receiver.gd" id="3_snap_recv"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_char"] size = Vector2(64, 80) @@ -49,3 +50,27 @@ input_pickable = true [node name="CollisionShape" type="CollisionShape2D" parent="CollisionArea"] shape = SubResource("RectangleShape2D_char") position = Vector2(0, -40) + +[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."] +visible = false + +[node name="OutfitLayer1" type="Sprite2D" parent="."] +position = Vector2(0, -40) +visible = false + +[node name="OutfitLayer2" type="Sprite2D" parent="."] +position = Vector2(0, -40) +visible = false + +[node name="OutfitLayer3" type="Sprite2D" parent="."] +position = Vector2(0, -40) +visible = false + +[node name="HandLeft" type="Node2D" parent="."] +position = Vector2(-32, -30) + +[node name="HandRight" type="Node2D" parent="."] +position = Vector2(32, -30) + +[node name="SnapReceiver" type="Node" parent="."] +script = ExtResource("3_snap_recv") diff --git a/scripts/characters/character.gd b/scripts/characters/character.gd index 7a95646..30cf39e 100644 --- a/scripts/characters/character.gd +++ b/scripts/characters/character.gd @@ -12,6 +12,7 @@ signal state_changed(new_state: CharacterData.State) @export var data: CharacterData var _is_held: bool = false +var _current_anim: String = "idle" const _STATE_COLORS: Dictionary = { CharacterData.State.HEALTHY: Color(0.6, 0.8, 1.0), @@ -30,6 +31,7 @@ func _ready() -> void: drag.drag_released.connect(_on_drag_released) if data != null: _update_visual_state() + _refresh_outfit_layers() func set_state(new_state: CharacterData.State) -> void: @@ -40,6 +42,94 @@ func set_state(new_state: CharacterData.State) -> void: state_changed.emit(new_state) +func set_animation_state(anim: String) -> void: + _current_anim = anim + var sprite: AnimatedSprite2D = get_node_or_null("AnimatedSprite2D") as AnimatedSprite2D + if sprite == null or sprite.sprite_frames == null: + return + if sprite.sprite_frames.has_animation(anim): + sprite.play(anim) + + +func get_animation_state() -> String: + return _current_anim + + +func set_outfit(layer: int, item_id: String, texture: Texture2D) -> void: + if layer < 1 or layer > 3: + return + if data != null: + data.outfit[layer - 1] = item_id + var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D + if layer_node == null: + return + layer_node.texture = texture + layer_node.visible = not item_id.is_empty() + + +func clear_outfit(layer: int) -> String: + if layer < 1 or layer > 3: + return "" + var old_id: String = "" + if data != null: + old_id = data.outfit[layer - 1] + data.outfit[layer - 1] = "" + var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % layer) as Sprite2D + if layer_node != null: + layer_node.texture = null + layer_node.visible = false + return old_id + + +func get_outfit(layer: int) -> String: + if data == null or layer < 1 or layer > 3: + return "" + return data.outfit[layer - 1] + + +func attach_item(hand: String, item: Node2D) -> bool: + if hand != "left" and hand != "right": + return false + var slot: Node2D = get_node_or_null("Hand" + hand.capitalize()) as Node2D + if slot == null: + return false + if slot.get_child_count() > 0: + return false + var old_parent: Node = item.get_parent() + if old_parent != null: + old_parent.remove_child(item) + slot.add_child(item) + item.position = Vector2.ZERO + return true + + +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 + slot.remove_child(item) + var scene_parent: Node = get_parent() + if scene_parent != null: + scene_parent.add_child(item) + return item + + +func get_held_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 + return slot.get_child(0) as Node2D + + +func is_hand_free(hand: String) -> bool: + return get_held_item(hand) == null + + func _update_visual_state() -> void: if data == null: return @@ -58,12 +148,23 @@ func _update_visual_state() -> void: func _on_drag_picked_up(_pos: Vector2) -> void: _is_held = true + set_animation_state("held") character_picked_up.emit(self) func _on_drag_released(pos: Vector2) -> void: _is_held = false + 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) + + +func _refresh_outfit_layers() -> void: + if data == null: + return + for i: int in range(3): + var layer_node: Sprite2D = get_node_or_null("OutfitLayer%d" % (i + 1)) as Sprite2D + if layer_node != null: + layer_node.visible = not data.outfit[i].is_empty() diff --git a/scripts/characters/character_data.gd b/scripts/characters/character_data.gd index 6b5dba4..ac9f820 100644 --- a/scripts/characters/character_data.gd +++ b/scripts/characters/character_data.gd @@ -10,3 +10,4 @@ enum Species { BUNNY, KITTEN } @export var state: State = State.HEALTHY @export var current_floor: int = 0 @export var position: Vector2 = Vector2.ZERO +@export var outfit: Array[String] = ["", "", ""] diff --git a/scripts/characters/snap_receiver.gd b/scripts/characters/snap_receiver.gd new file mode 100644 index 0000000..46dfde6 --- /dev/null +++ b/scripts/characters/snap_receiver.gd @@ -0,0 +1,62 @@ +## SnapReceiver — scans for nearby SnapPoints when the parent Character is released. +## Attach as child of Character. Connects automatically to DragDropComponent signals. +class_name SnapReceiver extends Node + +const SCAN_RADIUS: float = 80.0 + +var _current_snap: SnapPoint = null +var _character: Character + + +func _ready() -> void: + _character = get_parent() as Character + var drag: DragDropComponent = _character.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 _current_snap != null: + _current_snap.unsnap() + _current_snap = null + _character.set_animation_state("held") + + +func _on_drag_released(_pos: Vector2) -> void: + var nearest: SnapPoint = _find_nearest_accepting_snap() + if nearest != null: + _current_snap = nearest + nearest.snap(_character) + _character.global_position = nearest.global_position + _character.set_animation_state(nearest.pose) + else: + _character.set_animation_state("idle") + + +func _find_nearest_accepting_snap() -> SnapPoint: + var best: SnapPoint = null + var best_dist: float = SCAN_RADIUS + for node: Node in get_tree().get_nodes_in_group("snap_points"): + var snap_point: SnapPoint = node as SnapPoint + if snap_point == null: + continue + if not snap_point.accepts(_character): + continue + var dist: float = _character.global_position.distance_to(snap_point.global_position) + if dist < best_dist: + best_dist = dist + best = snap_point + return best + + +func get_current_snap() -> SnapPoint: + return _current_snap + + +func force_unsnap() -> void: + if _current_snap == null: + return + _current_snap.unsnap() + _current_snap = null + _character.set_animation_state("idle") diff --git a/scripts/objects/snap_point.gd b/scripts/objects/snap_point.gd new file mode 100644 index 0000000..c8e7e41 --- /dev/null +++ b/scripts/objects/snap_point.gd @@ -0,0 +1,42 @@ +## SnapPoint — attachment position on furniture where a Character can snap into a pose. +## Add to any furniture node. The node auto-registers in the "snap_points" group on _ready. +class_name SnapPoint extends Node2D + +signal character_snapped(character: Character) +signal character_unsnapped(character: Character) + +@export var pose: String = "sitting" +@export var baby_only: bool = false + +var occupant: Character = null + + +func _ready() -> void: + add_to_group("snap_points") + + +func is_free() -> bool: + return occupant == null + + +func accepts(character: Character) -> bool: + if not is_free(): + return false + if not baby_only: + return true + if character.data == null: + return false + return character.data.state == CharacterData.State.BABY + + +func snap(character: Character) -> void: + occupant = character + character_snapped.emit(character) + + +func unsnap() -> void: + if occupant == null: + return + var prev: Character = occupant + occupant = null + character_unsnapped.emit(prev) diff --git a/test/unit/test_character_v2.gd b/test/unit/test_character_v2.gd new file mode 100644 index 0000000..af043df --- /dev/null +++ b/test/unit/test_character_v2.gd @@ -0,0 +1,171 @@ +## Tests for Character System v2 — animation state, outfit layers, hand slots. +extends GutTest + +var _char: Character + + +func before_each() -> void: + _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(_char) + var cd: CharacterData = CharacterData.new() + _char.data = cd + + +func test_animated_sprite_node_exists() -> void: + assert_not_null(_char.get_node_or_null("AnimatedSprite2D")) + + +func test_outfit_layer_1_node_exists() -> void: + assert_not_null(_char.get_node_or_null("OutfitLayer1")) + + +func test_outfit_layer_2_node_exists() -> void: + assert_not_null(_char.get_node_or_null("OutfitLayer2")) + + +func test_outfit_layer_3_node_exists() -> void: + assert_not_null(_char.get_node_or_null("OutfitLayer3")) + + +func test_hand_left_node_exists() -> void: + assert_not_null(_char.get_node_or_null("HandLeft")) + + +func test_hand_right_node_exists() -> void: + assert_not_null(_char.get_node_or_null("HandRight")) + + +func test_snap_receiver_node_exists() -> void: + assert_not_null(_char.get_node_or_null("SnapReceiver")) + + +func test_character_data_outfit_has_three_empty_slots() -> void: + assert_eq(_char.data.outfit.size(), 3) + assert_eq(_char.data.outfit[0], "") + assert_eq(_char.data.outfit[1], "") + assert_eq(_char.data.outfit[2], "") + + +func test_default_animation_state_is_idle() -> void: + assert_eq(_char.get_animation_state(), "idle") + + +func test_set_animation_state_sitting() -> void: + _char.set_animation_state("sitting") + assert_eq(_char.get_animation_state(), "sitting") + + +func test_set_animation_state_lying() -> void: + _char.set_animation_state("lying") + assert_eq(_char.get_animation_state(), "lying") + + +func test_set_animation_state_held() -> void: + _char.set_animation_state("held") + assert_eq(_char.get_animation_state(), "held") + + +func test_set_animation_state_happy() -> void: + _char.set_animation_state("happy") + assert_eq(_char.get_animation_state(), "happy") + + +func test_set_animation_state_sleeping() -> void: + _char.set_animation_state("sleeping") + assert_eq(_char.get_animation_state(), "sleeping") + + +func test_get_outfit_returns_empty_for_all_layers_initially() -> void: + assert_eq(_char.get_outfit(1), "") + assert_eq(_char.get_outfit(2), "") + assert_eq(_char.get_outfit(3), "") + + +func test_set_outfit_stores_item_id() -> void: + _char.set_outfit(1, "white_coat", null) + assert_eq(_char.get_outfit(1), "white_coat") + + +func test_set_outfit_does_not_affect_other_layers() -> void: + _char.set_outfit(1, "white_coat", null) + assert_eq(_char.get_outfit(2), "") + assert_eq(_char.get_outfit(3), "") + + +func test_clear_outfit_returns_item_id() -> void: + _char.set_outfit(2, "cast_arm", null) + var returned: String = _char.clear_outfit(2) + assert_eq(returned, "cast_arm") + + +func test_clear_outfit_empties_layer() -> void: + _char.set_outfit(2, "cast_arm", null) + _char.clear_outfit(2) + assert_eq(_char.get_outfit(2), "") + + +func test_set_outfit_invalid_layer_zero_is_noop() -> void: + _char.set_outfit(0, "white_coat", null) + assert_eq(_char.get_outfit(1), "") + + +func test_set_outfit_invalid_layer_four_is_noop() -> void: + _char.set_outfit(4, "white_coat", null) + assert_eq(_char.get_outfit(3), "") + + +func test_both_hands_free_initially() -> void: + assert_true(_char.is_hand_free("left")) + assert_true(_char.is_hand_free("right")) + + +func test_attach_item_to_left_hand() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("left", item) + assert_false(_char.is_hand_free("left")) + + +func test_get_held_item_returns_attached_item() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("right", item) + assert_eq(_char.get_held_item("right"), item) + + +func test_attach_to_occupied_hand_returns_false() -> void: + var item1: Node2D = Node2D.new() + var item2: Node2D = Node2D.new() + add_child_autofree(item1) + add_child_autofree(item2) + _char.attach_item("left", item1) + var result: bool = _char.attach_item("left", item2) + assert_false(result) + + +func test_attach_returns_true_on_success() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + var result: bool = _char.attach_item("right", item) + assert_true(result) + + +func test_detach_item_frees_hand() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("left", item) + _char.detach_item("left") + assert_true(_char.is_hand_free("left")) + + +func test_detach_returns_item() -> void: + var item: Node2D = Node2D.new() + add_child_autofree(item) + _char.attach_item("right", item) + var returned: Node2D = _char.detach_item("right") + assert_eq(returned, item) + + +func test_detach_from_empty_hand_returns_null() -> void: + var returned: Node2D = _char.detach_item("left") + assert_null(returned) diff --git a/test/unit/test_snap_point.gd b/test/unit/test_snap_point.gd new file mode 100644 index 0000000..3f61aee --- /dev/null +++ b/test/unit/test_snap_point.gd @@ -0,0 +1,75 @@ +## Tests for SnapPoint — attachment node on furniture. +extends GutTest + +const SnapPointScript: GDScript = preload("res://scripts/objects/snap_point.gd") + +var _snap: SnapPoint +var _char: Character + + +func before_each() -> void: + _snap = SnapPointScript.new() + _snap.pose = "sitting" + add_child_autofree(_snap) + _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(_char) + + +func test_is_free_when_no_occupant() -> void: + assert_true(_snap.is_free()) + + +func test_is_not_free_when_occupied() -> void: + _snap.snap(_char) + assert_false(_snap.is_free()) + + +func test_snap_sets_occupant() -> void: + _snap.snap(_char) + assert_eq(_snap.occupant, _char) + + +func test_unsnap_clears_occupant() -> void: + _snap.snap(_char) + _snap.unsnap() + assert_null(_snap.occupant) + + +func test_accepts_any_character_when_baby_only_false() -> void: + assert_true(_snap.accepts(_char)) + + +func test_does_not_accept_when_occupied() -> void: + _snap.snap(_char) + var other: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(other) + assert_false(_snap.accepts(other)) + + +func test_baby_only_rejects_healthy_character() -> void: + _snap.baby_only = true + var cd: CharacterData = CharacterData.new() + cd.state = CharacterData.State.HEALTHY + _char.data = cd + assert_false(_snap.accepts(_char)) + + +func test_baby_only_accepts_baby_state_character() -> void: + _snap.baby_only = true + var cd: CharacterData = CharacterData.new() + cd.state = CharacterData.State.BABY + _char.data = cd + assert_true(_snap.accepts(_char)) + + +func test_snap_emits_character_snapped() -> void: + watch_signals(_snap) + _snap.snap(_char) + assert_signal_emitted(_snap, "character_snapped") + + +func test_unsnap_emits_character_unsnapped() -> void: + _snap.snap(_char) + watch_signals(_snap) + _snap.unsnap() + assert_signal_emitted(_snap, "character_unsnapped") diff --git a/test/unit/test_snap_receiver.gd b/test/unit/test_snap_receiver.gd new file mode 100644 index 0000000..a287810 --- /dev/null +++ b/test/unit/test_snap_receiver.gd @@ -0,0 +1,82 @@ +## Tests for SnapReceiver — snap detection when Character is released near a SnapPoint. +extends GutTest + +var _char: Character +var _snap: SnapPoint + + +func before_each() -> void: + _char = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(_char) + _snap = SnapPoint.new() + _snap.pose = "sitting" + add_child_autofree(_snap) + + +func _get_receiver() -> SnapReceiver: + return _char.get_node("SnapReceiver") as SnapReceiver + + +func test_snap_receiver_exists_on_character() -> void: + assert_not_null(_get_receiver()) + + +func test_no_current_snap_initially() -> void: + assert_null(_get_receiver().get_current_snap()) + + +func test_snap_detected_when_character_released_within_radius() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + assert_eq(_get_receiver().get_current_snap(), _snap) + + +func test_no_snap_when_released_outside_radius() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(200.0, 0.0) + _get_receiver()._on_drag_released(Vector2(200.0, 0.0)) + assert_null(_get_receiver().get_current_snap()) + + +func test_character_position_set_to_snap_point_on_snap() -> void: + _snap.global_position = Vector2(100.0, 100.0) + _char.global_position = Vector2(120.0, 100.0) + _get_receiver()._on_drag_released(Vector2(120.0, 100.0)) + assert_eq(_char.global_position, Vector2(100.0, 100.0)) + + +func test_character_animation_set_to_snap_pose_on_snap() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _snap.pose = "lying" + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + assert_eq(_char.get_animation_state(), "lying") + + +func test_pickup_clears_current_snap() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + _get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0)) + assert_null(_get_receiver().get_current_snap()) + + +func test_pickup_frees_snap_point() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + _get_receiver()._on_drag_picked_up(Vector2(50.0, 0.0)) + assert_true(_snap.is_free()) + + +func test_second_character_cannot_snap_to_occupied_point() -> void: + _snap.global_position = Vector2(0.0, 0.0) + _char.global_position = Vector2(50.0, 0.0) + _get_receiver()._on_drag_released(Vector2(50.0, 0.0)) + var char2: Character = preload("res://scenes/characters/Character.tscn").instantiate() as Character + add_child_autofree(char2) + char2.global_position = Vector2(60.0, 0.0) + var recv2: SnapReceiver = char2.get_node("SnapReceiver") as SnapReceiver + recv2._on_drag_released(Vector2(60.0, 0.0)) + assert_null(recv2.get_current_snap())