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:
2026-08-28 21:53:55 -04:00
parent 0e971d99b1
commit ef30931b20
17 changed files with 1800 additions and 6 deletions
+644
View File
@@ -0,0 +1,644 @@
class_name SandboxStage
extends Node2D
## SandboxStage - Kid-friendly director sandbox stage (Phase 2).
##
## A self-contained visual stage for placing terrain, props and stickmen from a
## palette, with EDIT (build) and PLAY (simulate) modes. Selection uses geometric
## hit-testing; objects are dragged directly (no move handle) and rotated via a
## ring handle. A placement ghost previews the object under the cursor, and an
## optional snap-to-grid + grid overlay ease placement. NOT wired into the editor
## - run standalone via F6 on res://scenes/sandbox_stage.tscn.
# ---------------------------------------------------------------------------
# Preloaded helpers
# ---------------------------------------------------------------------------
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
const STAGE_GRID := preload("res://scripts/stage_grid.gd")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum StageMode { EDIT, PLAY }
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal mode_changed(mode: int)
signal object_placed(node: Node2D)
signal object_selected(nodes: Array[Node2D])
signal object_deselected()
signal object_deleted(nodes: Array[Node2D])
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const ZOOM_STEP: float = 1.10
const MIN_BOX_AREA: float = 16.0
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const SETTINGS_PATH := "user://sandbox_settings.json"
const DEFAULT_GRID_SIZE := 15.0
const MIN_GRID_SIZE := 1.0
const MAX_GRID_SIZE := 100.0
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
@export var min_zoom: float = 0.1
@export var max_zoom: float = 6.0
# ---------------------------------------------------------------------------
# Node references
# ---------------------------------------------------------------------------
@onready var _camera: Camera2D = $Camera2D
@onready var _world: Node2D = $World
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var current_mode: StageMode = StageMode.EDIT
var _spawner: StageSpawner
var _selection: StageSelection
var _gizmos: StageGizmos
var _grid: StageGrid
var _placement_id: String = ""
var _mode_button: Button
var _palette_buttons: Dictionary = {}
var _status_label: Label
var _grid_check: CheckBox
var _snap_check: CheckBox
var _grid_size_spin: SpinBox
var _grid_size: float = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false
var _show_grid: bool = true
var _panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
var _box_selecting: bool = false
var _box_start: Vector2 = Vector2.ZERO
var _box_rect: Rect2 = Rect2()
var _ghost: Node2D = null
var _ghost_holder: Node2D = null
## Authored object state (instance id -> {node, position, rotation}), updated on
## every place/move/rotate and restored when returning to EDIT.
var _authored: Dictionary = {}
## Physics frames remaining before re-asserting the authored restore (a safety
## net for engines where a same-frame freeze + teleport does not stick).
var _restore_frames_left: int = 0
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_camera.make_current()
_load_settings()
_spawner = STAGE_SPAWNER.new(_world)
_selection = STAGE_SELECTION.new(_world, _camera)
_selection.selection_changed.connect(_on_selection_changed)
_selection.hover_changed.connect(_on_hover_changed)
_build_grid_layer()
_build_gizmo_layer()
_build_ghost_holder()
_build_ui()
_apply_grid_settings()
_refresh_status()
func _process(_delta: float) -> void:
if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT:
_update_ghost_position()
func _physics_process(_delta: float) -> void:
if _restore_frames_left > 0:
_restore_frames_left -= 1
_restore_authored_state()
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
_handle_mouse_button(event)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
_handle_world_click(event)
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.pressed or event.echo:
return
match event.keycode:
KEY_DELETE, KEY_BACKSPACE:
if current_mode == StageMode.EDIT:
delete_selected()
KEY_ESCAPE:
if _placement_id != "":
set_placement_mode("")
elif not _selection.get_selected().is_empty():
_selection.clear_selection()
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
match mb.button_index:
MOUSE_BUTTON_WHEEL_UP:
if mb.pressed:
_set_zoom(_camera.zoom.x * ZOOM_STEP)
MOUSE_BUTTON_WHEEL_DOWN:
if mb.pressed:
_set_zoom(_camera.zoom.x / ZOOM_STEP)
MOUSE_BUTTON_MIDDLE:
_panning = mb.pressed
_pan_last = mb.position
func _handle_world_click(mb: InputEventMouseButton) -> void:
if mb.button_index != MOUSE_BUTTON_LEFT:
return
if current_mode != StageMode.EDIT:
return
var world_pos := _camera.get_global_mouse_position()
if mb.pressed:
if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE:
_gizmos.begin_drag(world_pos)
elif _placement_id != "":
_place_at(world_pos)
else:
_begin_click_select(world_pos, mb.shift_pressed)
else:
if _gizmos.is_dragging():
_gizmos.end_drag()
elif _box_selecting:
_finish_box_select(mb.shift_pressed)
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
if _panning:
_camera.position -= mm.relative / _camera.zoom.x
return
if current_mode != StageMode.EDIT:
return
var world_pos := _camera.get_global_mouse_position()
if _gizmos.is_dragging():
_gizmos.drag_to(world_pos)
elif _box_selecting:
_box_rect = Rect2(_box_start, world_pos - _box_start)
_gizmos.set_box_rect(_box_rect)
elif not _is_mouse_over_ui():
_selection.update_hover(world_pos)
# ---------------------------------------------------------------------------
# Mode management
# ---------------------------------------------------------------------------
func set_mode(mode: StageMode) -> void:
if mode == current_mode:
return
current_mode = mode
if mode == StageMode.EDIT:
_enter_edit_mode()
else:
_enter_play_mode()
mode_changed.emit(int(mode))
_refresh_status()
func _enter_edit_mode() -> void:
# Snap stickmen straight back to their standing pose/position (no stand-up
# tween glide).
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
(node as STICKMAN_RIG).snap_to_standing()
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
# any object that may not be in the authored map. freeze_mode is set BEFORE
# freeze so the body freezes directly as kinematic, never via the static
# layer (whose transform sync can drop a subsequent position/rotation set).
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
_restore_authored_state()
# The freeze/teleport can take a physics frame or two to settle in the engine,
# so re-assert the authored transform on the next few physics frames.
_restore_frames_left = 3
_gizmos.set_enabled(true)
func _enter_play_mode() -> void:
_gizmos.set_enabled(false)
_selection.clear_selection()
set_placement_mode("")
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
(node as RigidBody2D).freeze = false
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.auto_recover = false
rig.set_ragdoll(true)
func _save_object_state(node: Node2D) -> void:
_authored[node.get_instance_id()] = {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
}
func _clear_object_state(node: Node2D) -> void:
_authored.erase(node.get_instance_id())
func _restore_authored_state() -> void:
for id: int in _authored.keys():
var entry: Dictionary = _authored[id]
var node := entry.get("node") as Node2D
if node == null or not is_instance_valid(node):
_authored.erase(id)
continue
node.global_position = entry.get("position", node.global_position)
node.global_rotation = entry.get("rotation", node.global_rotation)
if node is RigidBody2D:
var body := node as RigidBody2D
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
# ---------------------------------------------------------------------------
# Placement
# ---------------------------------------------------------------------------
func set_placement_mode(id: String) -> void:
_placement_id = id
for pid: String in _palette_buttons:
var btn: Button = _palette_buttons[pid]
btn.set_pressed_no_signal(pid == id)
if id == "":
_free_ghost()
else:
_spawn_ghost()
func _place_at(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
var node := _spawner.spawn(_placement_id, world_pos)
if node == null:
return
if current_mode == StageMode.EDIT and node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
_save_object_state(node)
object_placed.emit(node)
_refresh_status()
_spawn_ghost()
func _spawn_ghost() -> void:
_free_ghost()
if _placement_id == "":
return
var ghost := _spawner.spawn(_placement_id, Vector2.ZERO)
if ghost == null:
return
# Reparent out of World so the ghost is not selectable/counted.
if ghost.get_parent() == _world:
_world.remove_child(ghost)
_ghost_holder.add_child(ghost)
ghost.modulate = Color(1.0, 1.0, 1.0, 0.5)
if ghost is RigidBody2D:
var body := ghost as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.collision_layer = 0
body.collision_mask = 0
elif ghost is StaticBody2D:
var sb := ghost as StaticBody2D
sb.collision_layer = 0
sb.collision_mask = 0
elif ghost is STICKMAN_RIG:
# Freeze the ghost rig into a static standing figure: disable IK solving
# and animation so its limbs don't flex/follow anything while the ghost
# tracks the cursor.
var rig := ghost as STICKMAN_RIG
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
if skeleton != null and skeleton.modification_stack != null:
skeleton.modification_stack.enabled = false
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
if anim != null:
anim.stop()
_ghost = ghost
_update_ghost_position()
func _free_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.queue_free()
_ghost = null
func _update_ghost_position() -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
if _snap_enabled:
pos = _snap_to_grid(pos)
_ghost.position = pos
# ---------------------------------------------------------------------------
# Selection
# ---------------------------------------------------------------------------
func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
var hit := _selection.hit_test(world_pos)
if hit != null:
if shift:
_selection.toggle_selection(hit)
if _selection.is_selected(hit):
_gizmos.begin_translate_drag(hit, world_pos)
else:
_selection.select_only(hit)
_gizmos.begin_translate_drag(hit, world_pos)
else:
_box_selecting = true
_box_start = world_pos
_box_rect = Rect2(world_pos, Vector2.ZERO)
_gizmos.set_box_rect(_box_rect)
func _finish_box_select(shift: bool) -> void:
_box_selecting = false
_gizmos.set_box_rect(Rect2())
var rect := _box_rect.abs()
if rect.get_area() < MIN_BOX_AREA:
if not shift:
_selection.clear_selection()
else:
_selection.box_select(rect, shift)
# ---------------------------------------------------------------------------
# Deletion
# ---------------------------------------------------------------------------
func delete_selected() -> void:
var selected := _selection.get_selected().duplicate()
if selected.is_empty():
return
for node: Node2D in selected:
if is_instance_valid(node):
_clear_object_state(node)
node.queue_free()
_selection.clear_selection()
object_deleted.emit(selected)
_refresh_status()
# ---------------------------------------------------------------------------
# Status / UI
# ---------------------------------------------------------------------------
func _refresh_status() -> void:
if _status_label == null:
return
var mode_text := "EDIT" if current_mode == StageMode.EDIT else "PLAY"
var count := _world_children_selectable().size()
var sel := _selection.get_selected()
var sel_text: String
if sel.is_empty():
sel_text = "none"
elif sel.size() == 1:
sel_text = String(sel[0].name)
else:
sel_text = "%d objects" % sel.size()
_status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text]
if _mode_button != null:
_mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY)
_mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit"
func _build_ui() -> void:
var ui := CanvasLayer.new()
ui.name = "UI"
add_child(ui)
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = 40.0
ui.add_child(top_bar)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox)
_mode_button = Button.new()
_mode_button.toggle_mode = true
_mode_button.toggled.connect(_on_mode_toggled)
hbox.add_child(_mode_button)
for id: String in _spawner.get_spawnable_ids():
var btn := Button.new()
btn.text = _spawner.get_label(id)
btn.toggle_mode = true
btn.toggled.connect(_on_palette_toggled.bind(id))
hbox.add_child(btn)
_palette_buttons[id] = btn
_grid_check = CheckBox.new()
_grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid
_grid_check.toggled.connect(_on_grid_toggled)
hbox.add_child(_grid_check)
_snap_check = CheckBox.new()
_snap_check.text = "Snap"
_snap_check.button_pressed = _snap_enabled
_snap_check.toggled.connect(_on_snap_toggled)
hbox.add_child(_snap_check)
var grid_label := Label.new()
grid_label.text = "Size"
hbox.add_child(grid_label)
_grid_size_spin = SpinBox.new()
_grid_size_spin.min_value = MIN_GRID_SIZE
_grid_size_spin.max_value = MAX_GRID_SIZE
_grid_size_spin.step = 1.0
_grid_size_spin.value = _grid_size
_grid_size_spin.rounded = true
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
hbox.add_child(_grid_size_spin)
_status_label = Label.new()
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
hbox.add_child(_status_label)
func _build_gizmo_layer() -> void:
_gizmos = STAGE_GIZMOS.new()
_gizmos.name = "GizmoLayer"
_gizmos.camera = _camera
_gizmos.transform_committed.connect(_on_transform_committed)
add_child(_gizmos)
func _build_grid_layer() -> void:
_grid = STAGE_GRID.new()
_grid.name = "GridLayer"
_grid.camera = _camera
add_child(_grid)
move_child(_grid, 0)
func _build_ghost_holder() -> void:
_ghost_holder = Node2D.new()
_ghost_holder.name = "PlacementGhost"
add_child(_ghost_holder)
# ---------------------------------------------------------------------------
# Signal handlers
# ---------------------------------------------------------------------------
func _on_mode_toggled(pressed: bool) -> void:
set_mode(StageMode.PLAY if pressed else StageMode.EDIT)
func _on_palette_toggled(pressed: bool, id: String) -> void:
if pressed:
set_placement_mode(id)
elif _placement_id == id:
set_placement_mode("")
func _on_grid_toggled(pressed: bool) -> void:
_show_grid = pressed
_apply_grid_settings()
_save_settings()
func _on_snap_toggled(pressed: bool) -> void:
_snap_enabled = pressed
_apply_grid_settings()
_save_settings()
func _on_grid_size_changed(value: float) -> void:
_grid_size = clampf(value, MIN_GRID_SIZE, MAX_GRID_SIZE)
_apply_grid_settings()
_save_settings()
func _on_selection_changed(nodes: Array) -> void:
_gizmos.set_target(_selection.get_primary())
if nodes.is_empty():
object_deselected.emit()
else:
object_selected.emit(nodes)
_refresh_status()
func _on_hover_changed(node: Node2D) -> void:
_gizmos.set_hover(node)
func _on_transform_committed(node: Node2D) -> void:
_save_object_state(node)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _set_zoom(value: float) -> void:
var z := clampf(value, min_zoom, max_zoom)
_camera.zoom = Vector2(z, z)
func _is_mouse_over_ui() -> bool:
return get_viewport().gui_get_hovered_control() != null
func _snap_to_grid(v: Vector2) -> Vector2:
if _grid_size <= 0.0:
return v
return Vector2(roundf(v.x / _grid_size) * _grid_size, roundf(v.y / _grid_size) * _grid_size)
func _apply_grid_settings() -> void:
if _grid != null:
_grid.grid_size = _grid_size
_grid.enabled = _show_grid
if _gizmos != null:
_gizmos.snap_size = _grid_size if _snap_enabled else 0.0
## Direct Node2D children of World, excluding the ragdoll body container.
func _world_children_selectable() -> Array[Node2D]:
var result: Array[Node2D] = []
for child: Node in _world.get_children():
var node := child as Node2D
if node == null:
continue
if node.name == RAGDOLL_CONTAINER_NAME:
continue
result.append(node)
return result
# ---------------------------------------------------------------------------
# Settings persistence
# ---------------------------------------------------------------------------
func _load_settings() -> void:
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if file == null:
return
var json: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not json is Dictionary:
return
var d := json as Dictionary
_grid_size = float(d.get("grid_size", DEFAULT_GRID_SIZE))
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_snap_enabled = bool(d.get("snap_to_grid", false))
_show_grid = bool(d.get("show_grid", true))
func _save_settings() -> void:
var data := {
"version": "1.0",
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
"show_grid": _show_grid,
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()
+1
View File
@@ -0,0 +1 @@
uid://dkhhg81ft70mm
+172
View File
@@ -0,0 +1,172 @@
class_name StageGizmos
extends Node2D
## StageGizmos - Hover highlight, selection outline and a rotate gizmo for the
## Sandbox Stage Builder (Phase 2).
##
## Translation is done by dragging the object directly (no translate handle);
## only the rotate ring is a dedicated handle. Pure drawing + distance-based
## handle hit-testing (no Area2D). Dragging drives global_position /
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum Handle { NONE, TRANSLATE, ROTATE }
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal transform_committed(node: Node2D)
# ---------------------------------------------------------------------------
# Style constants (screen-space px; divided by zoom for world-space drawing)
# ---------------------------------------------------------------------------
const SELECTION_COLOR := Color(1.0, 1.0, 1.0, 0.9)
const HOVER_COLOR := Color(1.0, 0.9, 0.2, 0.5)
const ROTATE_COLOR := Color(0.3, 0.6, 1.0, 0.9)
const SELECTION_WIDTH := 2.0
const HOVER_WIDTH := 1.5
const ROTATE_WIDTH := 2.0
const ROTATE_HANDLE_MARGIN_PX := 48.0
const ROTATE_RING_TOLERANCE_PX := 12.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var camera: Camera2D = null
## When > 0, translation drags snap to this grid size (0 = off).
var snap_size: float = 0.0
var _enabled: bool = true
var _target: Node2D = null
var _hovered: Node2D = null
var _dragging: Handle = Handle.NONE
var _drag_last: Vector2 = Vector2.ZERO
var _box_rect: Rect2 = Rect2()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func set_target(node: Node2D) -> void:
_target = node
queue_redraw()
func set_hover(node: Node2D) -> void:
_hovered = node
queue_redraw()
func set_enabled(enabled: bool) -> void:
_enabled = enabled
visible = enabled
queue_redraw()
func get_target() -> Node2D:
return _target
func set_box_rect(rect: Rect2) -> void:
_box_rect = rect
queue_redraw()
## Returns ROTATE if the cursor is on the rotate ring of the primary selection.
func hit_test(world_pos: Vector2) -> Handle:
if _target == null or not is_instance_valid(_target) or not _enabled:
return Handle.NONE
var rect := STAGE_SELECTION.get_world_aabb(_target)
var ring := _rotate_ring_radius(rect)
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
return Handle.ROTATE
return Handle.NONE
## Begins a rotate drag (used when the ring is clicked).
func begin_drag(world_pos: Vector2) -> void:
_dragging = hit_test(world_pos)
_drag_last = world_pos
## Begins a translate drag on `node` (used when an object body is clicked).
func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void:
_target = node
_dragging = Handle.TRANSLATE
_drag_last = world_pos
func drag_to(world_pos: Vector2) -> void:
if _target == null or not is_instance_valid(_target) or _dragging == Handle.NONE:
return
var delta := world_pos - _drag_last
if _dragging == Handle.TRANSLATE:
_target.global_position += delta
if snap_size > 0.0:
_target.global_position = _snap(_target.global_position)
elif _dragging == Handle.ROTATE:
var center := STAGE_SELECTION.get_world_aabb(_target).get_center()
var before := (_drag_last - center).angle()
var after := (world_pos - center).angle()
_target.global_rotation += after - before
_drag_last = world_pos
queue_redraw()
func end_drag() -> void:
if _dragging != Handle.NONE and _target != null and is_instance_valid(_target):
transform_committed.emit(_target)
_dragging = Handle.NONE
func is_dragging() -> bool:
return _dragging != Handle.NONE
# ---------------------------------------------------------------------------
# Frame / drawing
# ---------------------------------------------------------------------------
func _process(_delta: float) -> void:
if _enabled and (_target != null or _hovered != null or _box_rect.has_area()):
queue_redraw()
func _draw() -> void:
if not _enabled:
return
var zoom := _zoom()
if _hovered != null and is_instance_valid(_hovered) and _hovered != _target:
draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
if _target != null and is_instance_valid(_target):
var rect := STAGE_SELECTION.get_world_aabb(_target)
draw_rect(rect, SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
if _box_rect.has_area():
draw_rect(_box_rect, HOVER_COLOR, false, HOVER_WIDTH / zoom)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
func _zoom() -> float:
if camera != null and is_instance_valid(camera):
return maxf(camera.zoom.x, 0.0001)
return 1.0
func _rotate_ring_radius(rect: Rect2) -> float:
return rect.size.length() * 0.5 + ROTATE_HANDLE_MARGIN_PX / _zoom()
func _snap(v: Vector2) -> Vector2:
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
+1
View File
@@ -0,0 +1 @@
uid://dcwylgjtosys7
+46
View File
@@ -0,0 +1,46 @@
class_name StageGrid
extends Node2D
## StageGrid - Optional world-space grid overlay for the Sandbox Stage Builder.
##
## Draws grid lines that pan/zoom with the camera, with a heavier major line
## every few cells. Pure drawing; no hit-testing. Rendered behind the World by
## keeping this node as the first child of the stage root.
var camera: Camera2D = null
var grid_size: float = 15.0
var enabled: bool = true
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.08)
const MAJOR_COLOR := Color(1.0, 1.0, 1.0, 0.16)
const MAJOR_EVERY := 5
func _process(_delta: float) -> void:
if enabled:
queue_redraw()
func _draw() -> void:
if not enabled or grid_size <= 0.0:
return
if camera == null or not is_instance_valid(camera):
return
var viewport_size := get_viewport_rect().size
var zoom := maxf(camera.zoom.x, 0.0001)
var half := viewport_size * 0.5 / zoom
var center := camera.get_screen_center_position()
var rect := Rect2(center - half, half * 2.0)
var line_width := 1.0 / zoom
var x := floorf(rect.position.x / grid_size) * grid_size
while x <= rect.end.x:
var grid_index := int(round(x / grid_size))
var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR
draw_line(Vector2(x, rect.position.y), Vector2(x, rect.end.y), color, line_width)
x += grid_size
var y := floorf(rect.position.y / grid_size) * grid_size
while y <= rect.end.y:
var grid_index := int(round(y / grid_size))
var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR
draw_line(Vector2(rect.position.x, y), Vector2(rect.end.x, y), color, line_width)
y += grid_size
+1
View File
@@ -0,0 +1 @@
uid://dpyu1lyn6evw7
+200
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c52c38mrywjpi
+235
View File
@@ -0,0 +1,235 @@
class_name StageSpawner
extends RefCounted
## StageSpawner - Registry-driven factory for the Sandbox Stage Builder (Phase 2).
##
## A spawn registry maps an id to a terrain/prop/stickman template. Adding a new
## spawnable type only requires appending a registry entry - no hard-coded match
## statements on ids. Reuses TerrainUtils, PropUtils and StickmanFactory.
# ---------------------------------------------------------------------------
# Preloaded dependencies (resolved directly, independent of the global class
# registry, so this script compiles even when the editor's class cache is stale)
# ---------------------------------------------------------------------------
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Default stickman asset (the only complete, upright figure).
const DEFAULT_STICKMAN_PATH := "res://stickmen/test.stk"
## The rig's feet rest ~385 px below its root (hips), so placing the root 385 px
## above the cursor puts the feet on it.
const STICKMAN_FOOT_OFFSET := Vector2(0.0, -385.0)
const TERRAIN_GRID_SIZE: float = 16.0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _world: Node2D
var _registry: Array[Dictionary] = []
var _stickman_data: Dictionary = {}
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _init(world: Node2D) -> void:
_world = world
_stickman_data = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
if _stickman_data.is_empty():
push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH)
_build_registry()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func get_spawnable_ids() -> Array[String]:
var ids: Array[String] = []
for entry: Dictionary in _registry:
ids.append(String(entry["id"]))
return ids
func get_label(id: String) -> String:
var entry := _find_entry(id)
return String(entry.get("label", id)) if not entry.is_empty() else id
func get_spawn_offset(id: String) -> Vector2:
var entry := _find_entry(id)
if entry.is_empty():
return Vector2.ZERO
return entry.get("spawn_offset", Vector2.ZERO)
## Spawn the registry type at `world_position`; returns null + push_warning on
## an unknown id.
func spawn(id: String, world_position: Vector2) -> Node2D:
var entry := _find_entry(id)
if entry.is_empty():
push_warning("StageSpawner: unknown spawn id '%s'." % id)
return null
var offset: Vector2 = entry.get("spawn_offset", Vector2.ZERO)
var pos := world_position + offset
match String(entry.get("kind", "")):
"terrain":
return _spawn_terrain(entry, pos)
"prop":
return _spawn_prop(entry, pos)
"stickman":
return _spawn_stickman(pos)
_:
push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", ""))
return null
## World-space AABB for a spawnable node: union of the Polygon2D child's world
## points; for a stickman rig, union of its mounted `Body/*` shape geometry; a
## point rect otherwise.
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 * Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
return Rect2(node.global_position, Vector2.ZERO)
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)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
func _build_registry() -> void:
_registry = [
{
"id": "ground", "label": "Ground", "kind": "terrain",
"points": PackedVector2Array([Vector2(-100, -16), Vector2(100, -16), Vector2(100, 16), Vector2(-100, 16)]),
"fill": Color(0.25, 0.55, 0.25), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
"spawn_offset": Vector2.ZERO,
},
{
"id": "ramp", "label": "Ramp", "kind": "terrain",
"points": PackedVector2Array([Vector2(-96, 32), Vector2(96, -96), Vector2(96, -32), Vector2(-96, 96)]),
"fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
"spawn_offset": Vector2.ZERO,
},
{
"id": "step", "label": "Step", "kind": "terrain",
"points": PackedVector2Array([
Vector2(-128, 128), Vector2(128, 128), Vector2(128, -128), Vector2(64, -128),
Vector2(64, -64), Vector2(0, -64), Vector2(0, 0), Vector2(-64, 0),
Vector2(-64, 64), Vector2(-128, 64),
]),
"fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
"spawn_offset": Vector2.ZERO,
},
{
"id": "crate", "label": "Crate", "kind": "prop",
"payload": PROP_UTILS.create_box(), "preset": PROP_BLOCK.MaterialPreset.WOOD,
"spawn_offset": Vector2.ZERO,
},
{
"id": "ball", "label": "Ball", "kind": "prop",
"payload": PROP_UTILS.create_ball(), "preset": PROP_BLOCK.MaterialPreset.RUBBER,
"spawn_offset": Vector2.ZERO,
},
{
"id": "stickman", "label": "Stickman", "kind": "stickman",
"spawn_offset": STICKMAN_FOOT_OFFSET,
},
]
func _find_entry(id: String) -> Dictionary:
for entry: Dictionary in _registry:
if String(entry.get("id", "")) == id:
return entry
return {}
# ---------------------------------------------------------------------------
# Spawn helpers
# ---------------------------------------------------------------------------
## Center the terrain template on its local origin so the block rotates about
## its own center, then place the block at the cursor.
func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
var template: PackedVector2Array = entry["points"]
var center := _points_center(template)
var centered := PackedVector2Array()
for p: Vector2 in template:
centered.append(p - center)
var block: TerrainBlock = TERRAIN_UTILS.spawn_block(
_world,
centered,
TERRAIN_GRID_SIZE,
entry.get("fill", TERRAIN_UTILS.DEFAULT_FILL_COLOR),
entry.get("outline", TERRAIN_UTILS.DEFAULT_OUTLINE_COLOR),
float(entry.get("width", 2.0))
)
block.position = world_position
return block
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry.get("preset", PROP_BLOCK.MaterialPreset.WOOD))
return PROP_UTILS.spawn_prop(_world, world_position, payload, preset, Vector2.ZERO)
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
if _stickman_data.is_empty():
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
return null
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(_stickman_data)
if rig == null:
push_warning("StageSpawner: failed to spawn stickman.")
return null
rig.position = world_position
_world.add_child(rig)
return rig
static func _points_center(pts: PackedVector2Array) -> Vector2:
if pts.is_empty():
return Vector2.ZERO
var sum := Vector2.ZERO
for p: Vector2 in pts:
sum += p
return sum / float(pts.size())
+1
View File
@@ -0,0 +1 @@
uid://c4adnnx5m3qss
+29
View File
@@ -436,6 +436,35 @@ func request_recovery() -> void:
_start_recovery()
## Instantly snaps the rig back to its authored standing pose — no stand-up
## tween. Used by the sandbox stage so a stickman "reappears" at its starting
## position/state on return to EDIT (instead of animating the recovery glide).
func snap_to_standing() -> void:
if state == RigState.ANIMATED:
return
if state == RigState.RAGDOLL:
_destroy_ragdoll()
else:
_cancel_recovery()
# Set the IK targets directly to the standing pose (no tween).
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
# Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
state = RigState.ANIMATED
state_changed.emit(int(state))
## Applies the same velocity delta to every ragdoll body via a mass-scaled
## central impulse, preserving the ragdoll's internal structure. No-op outside
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
+8 -3
View File
@@ -233,9 +233,14 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
var part_data: Variant = body_parts.get(part_name, {})
if part_data is Dictionary:
var pd := part_data as Dictionary
var shapes_var: Variant = pd.get("shapes", [])
if shapes_var is Array:
shapes = shapes_var as Array
if pd.has("shapes"):
var shapes_var: Variant = pd.get("shapes")
if shapes_var is Array:
shapes = shapes_var as Array
elif pd.has("points"):
# v1.0/v1.1 single-shape format: the part dict is itself one
# shape (wrapped like the editor's load path).
shapes = [pd]
# Phase 9 Round 3: the preview's per-part rotation (degrees) and
# scale about the bbox center are applied to the mounted geometry.
rotation_deg = float(pd.get("rotation", 0.0))