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_label: Label 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 InputEventMagnifyGesture: # Touchpad pinch zoom. _set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor) elif event is InputEventPanGesture: # Touchpad two-finger pan (delta is screen px; ×3 for speed parity). _camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x elif 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) _apply_grid_settings() _set_build_controls_visible(true) func _enter_play_mode() -> void: _gizmos.set_enabled(false) _selection.clear_selection() set_placement_mode("") _apply_grid_settings() _set_build_controls_visible(false) 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) elif _selection.is_selected(hit): # Already selected: keep the whole selection and drag it together. _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) _grid_size_label = Label.new() _grid_size_label.text = "Size" hbox.add_child(_grid_size_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[Node2D]) -> void: _gizmos.set_targets(nodes) 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(nodes: Array[Node2D]) -> void: for node: Node2D in nodes: _save_object_state(node) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- func _set_zoom(value: float) -> void: var z := clampf(value, min_zoom, max_zoom) var old_zoom := _camera.zoom.x if is_equal_approx(z, old_zoom): return var mouse_world := _camera.get_global_mouse_position() _camera.zoom = Vector2(z, z) _camera.position = mouse_world - (mouse_world - _camera.position) * (old_zoom / 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 _grid.visible = _show_grid and current_mode == StageMode.EDIT if _gizmos != null: _gizmos.snap_size = _grid_size if _snap_enabled else 0.0 func _set_build_controls_visible(visible: bool) -> void: for btn: Button in _palette_buttons.values(): btn.visible = visible if _grid_check != null: _grid_check.visible = visible if _snap_check != null: _snap_check.visible = visible if _grid_size_spin != null: _grid_size_spin.visible = visible if _grid_size_label != null: _grid_size_label.visible = visible ## 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()