From ca1d20781e71787374ede5d538b7ee51cdcd888a Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Sat, 9 May 2026 00:03:25 +0200 Subject: [PATCH] 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 --- scripts/characters/character.gd | 3 ++ scripts/objects/holdable_item.gd | 73 ++++++++++++++++++++++++++++++++ test/unit/test_character_v2.gd | 14 ++++++ test/unit/test_holdable_item.gd | 71 +++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 scripts/objects/holdable_item.gd create mode 100644 test/unit/test_holdable_item.gd diff --git a/scripts/characters/character.gd b/scripts/characters/character.gd index 30cf39e..97a8fe6 100644 --- a/scripts/characters/character.gd +++ b/scripts/characters/character.gd @@ -32,6 +32,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 +111,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 diff --git a/scripts/objects/holdable_item.gd b/scripts/objects/holdable_item.gd new file mode 100644 index 0000000..59d7bf6 --- /dev/null +++ b/scripts/objects/holdable_item.gd @@ -0,0 +1,73 @@ +## 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] diff --git a/test/unit/test_character_v2.gd b/test/unit/test_character_v2.gd index af043df..4825a99 100644 --- a/test/unit/test_character_v2.gd +++ b/test/unit/test_character_v2.gd @@ -169,3 +169,17 @@ 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("HandLeft").global_position + _char.detach_item("left") + assert_eq(item.global_position, expected_global) diff --git a/test/unit/test_holdable_item.gd b/test/unit/test_holdable_item.gd new file mode 100644 index 0000000..5511cdc --- /dev/null +++ b/test/unit/test_holdable_item.gd @@ -0,0 +1,71 @@ +## 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)