Files
stickman/scripts/stage_selection.gd
T
ryan 1f91f3d2e5 Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
2026-09-04 15:08:08 -04:00

213 lines
7.0 KiB
GDScript

class_name StageSelection
extends RefCounted
## StageSelection - Hover, click and box selection for the Sandbox Stage Builder.
##
## Hit-testing is geometric (world-space AABBs), so it works uniformly for
## TerrainBlock, PropBlock and StickmanRig (which has no physics collision).
## Frontmost World child wins; smallest area breaks ties. The ragdoll body
## container and its subtree are never selectable.
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal hover_changed(node: Node2D)
signal selection_changed(nodes: Array[Node2D])
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds).
const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _world: Node2D
var _camera: Camera2D
var _selected: Array[Node2D] = []
var _hovered: Node2D = null
var _primary: Node2D = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _init(world: Node2D, camera: Camera2D) -> void:
_world = world
_camera = camera
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func get_selected() -> Array[Node2D]:
return _selected
func get_primary() -> Node2D:
return _primary
func clear_selection() -> void:
_selected.clear()
_primary = null
selection_changed.emit(_selected.duplicate())
## Clears the hovered node (e.g. when it is about to be deleted), emitting
## hover_changed(null) so the gizmo layer drops its stale highlight.
func clear_hover() -> void:
if _hovered == null:
return
_hovered = null
hover_changed.emit(null)
func select_only(node: Node2D) -> void:
_selected = [node]
_primary = node
selection_changed.emit(_selected.duplicate())
func add_to_selection(node: Node2D) -> void:
if not _selected.has(node):
_selected.append(node)
_primary = node
selection_changed.emit(_selected.duplicate())
func toggle_selection(node: Node2D) -> void:
if _selected.has(node):
_selected.erase(node)
_primary = _selected[_selected.size() - 1] if not _selected.is_empty() else null
else:
_selected.append(node)
_primary = node
selection_changed.emit(_selected.duplicate())
func is_selected(node: Node2D) -> bool:
return _selected.has(node)
## Returns the frontmost selectable node under `world_pos`, or null.
func hit_test(world_pos: Vector2) -> Node2D:
return _frontmost_at(world_pos)
## Refreshes the hovered node under `world_pos`, emitting hover_changed when it
## changes. Returns the new hover target (or null).
func update_hover(world_pos: Vector2) -> Node2D:
var hit := _frontmost_at(world_pos)
if hit != _hovered:
_hovered = hit
hover_changed.emit(hit)
return hit
## Selects all selectable nodes whose AABB intersects `rect`. When `additive`,
## appends to the current selection instead of replacing it.
func box_select(rect: Rect2, additive: bool) -> void:
var hits: Array[Node2D] = []
for child: Node in _world.get_children():
var node := child as Node2D
if node == null or not _is_selectable(node):
continue
if get_world_aabb(node).intersects(rect):
hits.append(node)
if additive:
for node: Node2D in hits:
if not _selected.has(node):
_selected.append(node)
if not hits.is_empty():
_primary = hits[hits.size() - 1]
else:
_selected = hits
_primary = hits[hits.size() - 1] if not hits.is_empty() else null
selection_changed.emit(_selected.duplicate())
## World-space AABB for a node. Terrain/props use their Polygon2D child; a
## stickman rig unions its mounted `Body/*` shape geometry (so the box is
## centered on the actual figure, head to feet).
static func get_world_aabb(node: Node2D) -> Rect2:
if node == null or not is_instance_valid(node):
return Rect2()
# Duck-typed TriggerArea: expose its centered local rect through the transform.
if node.has_method("get_area_rect"):
return node.global_transform * (node.call("get_area_rect") as Rect2)
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if poly != null and not poly.polygon.is_empty():
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
for p: Vector2 in poly.polygon:
rect = rect.expand(node.to_global(p))
return rect
var body := node.get_node_or_null(NodePath("Body")) as Node2D
if body != null:
var acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
_collect_visual_points(body, acc)
if acc["min_x"] <= acc["max_x"]:
return Rect2(Vector2(acc["min_x"], acc["min_y"]), Vector2(acc["max_x"] - acc["min_x"], acc["max_y"] - acc["min_y"]))
if node.get_node_or_null(NodePath("Skeleton2D")) != null:
return node.global_transform * RIG_LOCAL_RECT
return Rect2(node.global_position, Vector2.ZERO)
## Recursively unions the world-space points of every Line2D / Polygon2D under
## `node` into `acc` (keys min_x/min_y/max_x/max_y).
static func _collect_visual_points(node: Node, acc: Dictionary) -> void:
if node is Line2D:
for p: Vector2 in (node as Line2D).points:
_accumulate((node as Line2D).to_global(p), acc)
elif node is Polygon2D:
for p: Vector2 in (node as Polygon2D).polygon:
_accumulate((node as Polygon2D).to_global(p), acc)
for child: Node in node.get_children():
_collect_visual_points(child, acc)
static func _accumulate(p: Vector2, acc: Dictionary) -> void:
acc["min_x"] = minf(acc["min_x"], p.x)
acc["min_y"] = minf(acc["min_y"], p.y)
acc["max_x"] = maxf(acc["max_x"], p.x)
acc["max_y"] = maxf(acc["max_y"], p.y)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
## A selectable node is a direct Node2D child of World that is not the ragdoll
## body container.
func _is_selectable(node: Node) -> bool:
if node == null or not (node is Node2D):
return false
if node.get_parent() != _world:
return false
if node.name == RAGDOLL_CONTAINER_NAME:
return false
return true
## Highest World child index wins; on equal area the frontmost (first hit in
## reverse iteration) stays.
func _frontmost_at(world_pos: Vector2) -> Node2D:
var children := _world.get_children()
var best: Node2D = null
var best_area := INF
for i: int in range(children.size() - 1, -1, -1):
var node := children[i] as Node2D
if node == null or not _is_selectable(node):
continue
var aabb := get_world_aabb(node)
if aabb.has_point(world_pos):
var area := aabb.get_area()
if best == null or area < best_area:
best = node
best_area = area
return best