Files
stickman/scripts/sandbox_stage.gd
T
ryan bf11a5fab5 Add Phase 3a Core Director Functionality
- Introduced `PHASE_3a_CORE_DIRECTOR.md` detailing the core functionality for directors, including navigation, action queue, UI, waypoint visualization, and action execution.
- Implemented `StageDirectorVisuals` for drawing stickman action queues in edit mode, including waypoints and action badges.
- Created `SpeechBubble` class for displaying speech bubbles above stickmen, with customizable text and styling.
2026-08-30 00:29:30 -04:00

910 lines
28 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.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
## Director action-popup item ids (Phase 3a).
const ACT_WALK := 0
const ACT_SPEAK := 1
const ACT_WAIT := 2
const ACT_RAGDOLL := 3
const ACT_RECOVER := 4
## Debug gate for the Phase 3a stage trace. Ship OFF.
const DEBUG_STAGE := false
## Prints a `[stage] `-prefixed message only when DEBUG_STAGE is on.
func _stage_dbg(msg: String) -> void:
if DEBUG_STAGE:
print("[stage] ", msg)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Director state (Phase 3a)
# ---------------------------------------------------------------------------
var _direct_mode: bool = false
var _direct_button: Button = null
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _wait_dialog: AcceptDialog = null
var _wait_spin: SpinBox = null
var _director_visuals = null # StageDirectorVisuals (preloaded)
var _nav_region: NavigationRegion2D = null
var _nav_dirty: bool = true
# ---------------------------------------------------------------------------
# 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_director_visuals()
_build_navigation()
_build_ghost_holder()
_build_ui()
_apply_grid_settings()
_refresh_status()
func _process(_delta: float) -> void:
if _nav_dirty:
_nav_dirty = false
_rebake_navigation()
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 _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_refresh_status()
elif _direct_mode:
_direct_mode = false
_direct_button.set_pressed_no_signal(false)
elif _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 _direct_mode:
if mb.pressed:
_handle_direct_click(world_pos)
return
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), stopping any director queue first.
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.stop_queue()
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)
_director_visuals.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("")
if _action_popup != null:
_action_popup.hide()
_pending_walk_target = false
_context_rig = null
_direct_mode = false
if _direct_button != null:
_direct_button.set_pressed_no_signal(false)
_director_visuals.set_enabled(false)
_apply_grid_settings()
_set_build_controls_visible(false)
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
(node as RigidBody2D).freeze = false
var rig_count := 0
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.auto_recover = false
rig.start_queue()
rig_count += 1
_stage_dbg("PLAY rigs=%d" % rig_count)
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)
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.queue_changed.connect(_director_visuals.mark_dirty)
_director_visuals.mark_dirty()
if node is TerrainBlock:
_nav_dirty = true
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
var nav_changed := false
for node: Node2D in selected:
if is_instance_valid(node):
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node)
node.queue_free()
_selection.clear_selection()
if nav_changed:
_nav_dirty = true
_director_visuals.mark_dirty()
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 _pending_walk_target:
_status_label.text += " | Click stage for walk target (Esc to cancel)"
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
_direct_button = Button.new()
_direct_button.text = "Direct"
_direct_button.toggle_mode = true
_direct_button.toggled.connect(_on_direct_toggled)
hbox.add_child(_direct_button)
_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)
_action_popup = PopupMenu.new()
_action_popup.add_item("🚶 Walk To", ACT_WALK)
_action_popup.add_item("💬 Speak", ACT_SPEAK)
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
ui.add_child(_action_popup)
_speak_dialog = AcceptDialog.new()
_speak_dialog.title = "Speak"
_speak_dialog.confirmed.connect(_on_speak_confirmed)
_speak_edit = LineEdit.new()
_speak_edit.placeholder_text = "Say something…"
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
_speak_dialog.add_child(_speak_edit)
_speak_dialog.register_text_enter(_speak_edit)
ui.add_child(_speak_dialog)
_wait_dialog = AcceptDialog.new()
_wait_dialog.title = "Wait"
_wait_dialog.confirmed.connect(_on_wait_confirmed)
_wait_spin = SpinBox.new()
_wait_spin.min_value = 0.1
_wait_spin.max_value = 10.0
_wait_spin.step = 0.1
_wait_spin.value = 1.0
_wait_dialog.add_child(_wait_spin)
ui.add_child(_wait_dialog)
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)
func _build_director_visuals() -> void:
_director_visuals = STAGE_DIRECTOR_VISUALS.new()
_director_visuals.name = "DirectorVisualsLayer"
_director_visuals.camera = _camera
_director_visuals.world = _world
add_child(_director_visuals)
## Code-built NavigationRegion2D child of the stage (NOT World, so it is never
## selected/hit-tested). Sits at the stage origin; World is at identity so world
## coordinates equal region-local coordinates.
func _build_navigation() -> void:
_nav_region = NavigationRegion2D.new()
_nav_region.name = "NavigationRegion2D"
add_child(_nav_region)
_rebake_navigation()
## Rebuild the navigation mesh from every TerrainBlock child of World: each
## block's world-space polygon is convex-decomposed and fan-triangulated into
## one manual NavigationPolygon.
func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region):
return
var verts := PackedVector2Array()
var triangles: Array[PackedInt32Array] = []
for child: Node in _world.get_children():
var block := child as TerrainBlock
if block == null:
continue
var pts := PackedVector2Array()
for p: Vector2 in block.polygon_points:
pts.append(block.transform * p)
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
if pieces.is_empty():
pieces = [pts]
for piece: PackedVector2Array in pieces:
var base: int = verts.size()
verts.append_array(piece)
for i: int in range(1, piece.size() - 1):
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
var poly := NavigationPolygon.new()
poly.set_vertices(verts)
for tri: PackedInt32Array in triangles:
poly.add_polygon(tri)
_nav_region.navigation_polygon = poly
if DEBUG_STAGE:
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
# ---------------------------------------------------------------------------
# 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:
_direct_mode = false
if _direct_button != null:
_direct_button.set_pressed_no_signal(false)
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)
if node is TerrainBlock:
_nav_dirty = true
# ---------------------------------------------------------------------------
# Director tool (Phase 3a)
# ---------------------------------------------------------------------------
func _on_direct_toggled(pressed: bool) -> void:
_direct_mode = pressed
if pressed:
set_placement_mode("")
_selection.clear_selection()
_pending_walk_target = false
_context_rig = null
_refresh_status()
func _handle_direct_click(world_pos: Vector2) -> void:
if _pending_walk_target and _context_rig != null and is_instance_valid(_context_rig):
_stage_dbg("walk_to captured target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
_context_rig.queue_action({ "type": "walk_to", "target": world_pos })
_pending_walk_target = false
_context_rig = null
_refresh_status()
return
var hit := _selection.hit_test(world_pos)
if hit is STICKMAN_RIG:
_context_rig = hit as StickmanRig
var mouse := get_viewport().get_mouse_position()
_action_popup.popup(Rect2i(Vector2i(mouse), Vector2i.ZERO))
func _on_action_popup_id_pressed(id: int) -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
match id:
ACT_WALK:
_pending_walk_target = true
_refresh_status()
ACT_SPEAK:
_speak_edit.text = ""
_speak_dialog.popup_centered()
_speak_edit.grab_focus()
ACT_WAIT:
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
ACT_RAGDOLL:
_context_rig.queue_action({ "type": "ragdoll" })
ACT_RECOVER:
_context_rig.queue_action({ "type": "recover" })
func _on_speak_confirmed() -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
func _on_wait_confirmed() -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
# ---------------------------------------------------------------------------
# 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
if _direct_button != null:
_direct_button.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()