201 lines
6.6 KiB
GDScript
201 lines
6.6 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())
|
|
|
|
|
|
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
|