From 2f5e9d99a69efc5883ffbd9888cacb2efb7dae59 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Fri, 8 May 2026 22:12:18 +0200 Subject: [PATCH] feat(snap-receiver): implement snap detection, position snapping, and pose animation trigger Co-Authored-By: Claude Sonnet 4.6 --- scripts/characters/snap_receiver.gd | 48 +++++++++++++++-- test/unit/test_snap_receiver.gd | 82 +++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 test/unit/test_snap_receiver.gd diff --git a/scripts/characters/snap_receiver.gd b/scripts/characters/snap_receiver.gd index b5d7ca8..46dfde6 100644 --- a/scripts/characters/snap_receiver.gd +++ b/scripts/characters/snap_receiver.gd @@ -1,5 +1,5 @@ ## SnapReceiver — scans for nearby SnapPoints when the parent Character is released. -## Attach as child of Character. Full implementation connects to DragDropComponent signals. +## Attach as child of Character. Connects automatically to DragDropComponent signals. class_name SnapReceiver extends Node const SCAN_RADIUS: float = 80.0 @@ -10,6 +10,44 @@ 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: @@ -17,6 +55,8 @@ func get_current_snap() -> SnapPoint: func force_unsnap() -> void: - if _current_snap != null: - _current_snap.unsnap() - _current_snap = null + if _current_snap == null: + return + _current_snap.unsnap() + _current_snap = null + _character.set_animation_state("idle") 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())