Add sandbox stage builder scripts and functionality
- Introduced StageGizmos for hover highlighting, selection outlines, and rotation handles in the sandbox stage builder. - Added StageGrid for an optional world-space grid overlay that adjusts with camera panning and zooming. - Implemented StageSelection for geometric hit-testing and selection management of nodes in the sandbox. - Created StageSpawner as a registry-driven factory for spawning terrain, props, and stickmen, allowing for dynamic template management. - Each script includes necessary constants, state management, and public API methods for interaction.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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
|
||||
Reference in New Issue
Block a user