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([]) 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() 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