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,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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkhhg81ft70mm
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcwylgjtosys7
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpyu1lyn6evw7
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c52c38mrywjpi
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4adnnx5m3qss
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user