Files
stickman/scripts/sandbox_stage.gd
T
ryan 6a1359f244 Add prop interaction system and throw mechanics specification
- Introduced `stickman_prop_interactor.gd` to manage prop interactions.
- Created `phase_11_prop_interactions.md` detailing the design and implementation of prop pickup, drop, and throw mechanics.
- Defined interaction states, action lifecycles, and event triggers for prop handling.
- Specified constraints for throw actions, including target positioning and velocity calculations.
- Established acceptance criteria for prop interactions and lifecycle management.
2026-09-25 23:42:26 -04:00

5062 lines
187 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")
const STAGE_PLACEMENT_OVERLAY := preload("res://scripts/stage_placement_overlay.gd")
const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd")
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd")
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
# Phase 3c editor tools.
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
const QUEUE_PANEL := preload("res://scenes/queue_panel.tscn")
const RULE_PANEL := preload("res://scenes/rule_panel.tscn")
const ACTION_EDITOR := preload("res://scenes/action_editor.tscn")
const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum StageMode { EDIT, DIRECT, PLAY }
enum RunState { STOPPED, RUNNING, PAUSED }
## Phase 4 rule-builder state machine steps.
enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
## Phase 3c target-capture kinds (what the next stage click resolves to).
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION }
enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT }
# ---------------------------------------------------------------------------
# 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])
signal simulation_paused(elapsed_seconds: float)
signal simulation_resumed(elapsed_seconds: float)
signal simulation_reset
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const ZOOM_STEP: float = 1.10
const MIN_BOX_AREA: float = 16.0
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const SELECTOR_DIM_ALPHA := 0.5
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
const SETTINGS_PATH := "user://sandbox_settings.json"
const THEME_PATH := "res://sandbox_theme.json"
const DEFAULT_GRID_SIZE := 15.0
const MIN_GRID_SIZE := 1.0
const MAX_GRID_SIZE := 100.0
const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene"
const RECENT_SCENE_LIMIT: int = 5
const DEFAULT_SCENE_NAME: String = "Untitled"
const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0)
const DEFAULT_CAMERA_ZOOM: float = 0.5
## 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
## Phase 4 rule-builder item ids. ACT_WHEN appends to the action popup; the
## TRIG_* ids drive the trigger sub-menu.
const ACT_WHEN := 5
const TRIG_ARRIVED := 0
const TRIG_ACTION_FINISHED := 1
const TRIG_SPEECH_FINISHED := 2
const TRIG_ENTERED_AREA := 3
const TRIG_COLLIDED := 4
const TRIG_BACK := 5
const TRIG_PROP_PICKED_UP := 6
const TRIG_PROP_DROPPED := 7
const TRIG_PROP_THROWN := 8
## Phase 4 "add another / done" popup item ids.
const RULE_MORE_ADD := 0
const RULE_MORE_DONE := 1
## Phase 4 "done" item id for the rule-action popup (edit mode only). Distinct
## from ACT_WALK..ACT_RECOVER (0..4) so it never collides with an action id.
const RULE_ACTION_DONE := 6
## Phase 3c action-popup item ids ("Edit Queue…" / "Edit Rules…").
const ACT_EDIT_QUEUE := 7
const ACT_EDIT_RULES := 8
const ACT_PICK_UP_PROP := 9
const ACT_DROP_PROP := 10
const ACT_THROW_PROP := 11
## Phase 3c stickman right-click context-menu item ids.
const RIG_CTX_EDIT_QUEUE := 0
const RIG_CTX_EDIT_RULES := 1
## Max distance (px) between an arrival event position and a rule's waypoint for
## the arrived_at_waypoint trigger to match.
const WAYPOINT_MATCH_EPSILON := 24.0
## 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 _run_state: RunState = RunState.STOPPED
var _elapsed_seconds: float = 0.0
var _event_dispatch_ready: bool = false
var _resume_baseline_frames: int = 0
var _prop_pause_states: Dictionary = {}
var _suppressed_contact_pairs: Dictionary = {}
var _paused_edit_autosave_remaining: float = -1.0
var _spawner: StageSpawner
var _selection: StageSelection
var _gizmos: StageGizmos
var _grid: StageGrid
var _placement_overlay = null # StagePlacementOverlay (preloaded)
var _placement_id: String = ""
var _stickman_library: StickmanLibrary
var _thumbnail_cache: ThumbnailCache
var _stickman_thumb: StickmanThumbnail
var _prop_thumb: PropThumbnail
var _selector: AssetSelector = null
var _selector_open: bool = false
var _selector_kind: String = ""
var _selector_dim: ColorRect = null
var _browse_dialog: FileDialog = null
var _thumbnail_queue: Array[Dictionary] = []
var _thumbnail_busy: bool = false
var _thumbnail_generation: int = 0
var _mode_buttons: Dictionary = {} # int (StageMode) -> Button
var _palette_buttons: Dictionary = {}
var _palette_dock: PanelContainer = null
var _palette_drag_payload: Dictionary = {}
var _palette_drag_previous_id: String = ""
var _status_label: Label
var _status_cursor_coords: Label
var _direct_hint_label: Label
var _mode_frame: Panel
var _mode_badge: PanelContainer
var _mode_badge_label: Label
var _timestamp_badge: PanelContainer = null
var _timestamp_label: Label = null
var _run_controls: HBoxContainer = null
var _pause_button: Button = null
var _resume_button: Button = null
var _reset_button: Button = null
var _tooltip: 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 _current_scene_path: String = ""
var _scene_name: String = DEFAULT_SCENE_NAME
var _scene_created_unix: int = 0
var _dirty: bool = false
var _suppress_dirty: bool = false
var _recent_scenes: Array[String] = []
var _pending_file_action: PendingFileAction = PendingFileAction.NONE
var _pending_open_path: String = ""
var _file_menu: MenuButton = null
var _scene_open_dialog: FileDialog = null
var _scene_save_dialog: FileDialog = null
var _unsaved_changes_dialog: ConfirmationDialog = null
var _rename_scene_dialog: ConfirmationDialog = null
var _scene_name_edit: LineEdit = null
var _missing_assets_dialog: AcceptDialog = null
var _cli_error_dialog: AcceptDialog = null
var _cli_startup: bool = false
var _panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
var _space_key_down: bool = false
var _space_press_owned: bool = false
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
## Phase 4b terrain drag-painting state.
var _terrain_dragging: bool = false
const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0
var _terrain_press_world: Vector2 = Vector2.ZERO
var _terrain_painting: bool = false
var _terrain_anchor_cell: Vector2i = Vector2i.ZERO
var _terrain_target_cell: Vector2i = Vector2i.ZERO
var _drag_cells: Array[Vector2i] = []
var _last_drag_cells: Array[Vector2i] = []
var _ghost_array: Array[Node2D] = []
## Block cells already stamped during the CURRENT drag (in-drag self-overlap
## skip, 3-state Case B).
var _drag_painted: Dictionary = {}
## Grid spatial dictionary: cell (Vector2i, TERRAIN_GRID_SIZE) -> Array[Node2D]
## whose world AABB overlaps that cell. Advisory only (3-state tint + skip);
## never authoritative for physics.
var _grid_cells: Dictionary = {}
## 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
var _edit_physics_targets: Dictionary = {} # { RigidBody2D: Transform2D }
var _edit_physics_settle_frames: int = 0
# ---------------------------------------------------------------------------
# Director state (Phase 3a)
# ---------------------------------------------------------------------------
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _next_stage_prop_serial: int = 0
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _speak_stop_current_check: CheckBox = 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
# ---------------------------------------------------------------------------
# Theme / cursor state (Phase 4b)
# ---------------------------------------------------------------------------
## Parsed sandbox_theme.json (or {} if missing/malformed).
var _theme: Dictionary = {}
var _sandbox_theme = null
var _theme_grid_default: float = DEFAULT_GRID_SIZE
var _accent_edit: Color = Color("#22c6ff")
var _accent_direct: Color = Color("#ffb300")
var _accent_play: Color = Color("#33dd77")
var _guide_line_color: Color = Color("#22c6ff")
var _action_popup_font_size: int = 24
var _action_popup_emoji_size: int = 22
var _tooltip_font_size: int = 18
var _status_pill_font_size: int = 16
var _ui_font: Font = null
var _emoji_font: Font = null
## Phase 3c style fonts/sizes/flags (sandbox_theme.json §fonts). Bold/italic are
## resolved from ui_font_bold/ui_font_italic (fallback to a FontVariation / ui_font).
var _ui_font_bold: Font = null
var _ui_font_italic: Font = null
var _queue_panel_font_size: int = 18
var _rule_panel_font_size: int = 18
var _action_editor_font_size: int = 18
var _rule_editor_font_size: int = 18
var _panel_row_font_size: int = 16
var _panel_title_font_size: int = 18
var _panel_title_bold: bool = true
var _rule_label_bold: bool = false
var _badge_bold: bool = true
var _action_popup_bold: bool = false
var _action_popup_italic: bool = false
var _font_sizes: Dictionary = {}
var _action_cursor: ImageTexture = null
var _last_awaiting_click: bool = false
# ---------------------------------------------------------------------------
# Rule / event system state (Phase 4)
# ---------------------------------------------------------------------------
## Stored "When X -> do Y" rules. Persist across mode toggles; NOT saved to disk.
var _event_rules: Array[Dictionary] = []
var _next_rule_id: int = 0
## Rule-builder state machine.
var _rule_step: RuleStep = RuleStep.IDLE
var _rule_builder: Dictionary = {}
var _rule_context_rig: StickmanRig = null
var _rule_hint: String = ""
var _rule_editing_id: int = -1
## True when the rule builder was launched from the Rule Panel's "Add Rule"
## (restore the panel on finalize/cancel).
var _rule_build_from_panel: bool = false
## Session popup anchor: the first context menu of a rule session records its
## screen rect; all child popups in that session reuse it. Cleared on confirm
## or cancel so the next session re-records from a fresh click.
var _popup_anchor: Rect2i = Rect2i()
var _popup_anchor_set: bool = false
## Rule-builder popups (built in _build_ui).
var _trigger_popup: PopupMenu = null
var _rule_action_popup: PopupMenu = null
var _rule_more_popup: PopupMenu = null
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
## and "<rig_id>:<prop_id>".
var _area_overlap: Dictionary = {}
var _area_previous_bounds: Dictionary = {}
var _collision_pairs: Dictionary = {}
## Lightweight toast text + countdown (cleared in _process on expiry).
var _toast_text: String = ""
var _toast_timer: float = 0.0
# ---------------------------------------------------------------------------
# Phase 3c editor-tool state
# ---------------------------------------------------------------------------
## Editor popups / panels (instantiated from their scenes in _build_ui).
var _queue_panel: QueuePanel = null
var _rule_panel: RulePanel = null
var _action_editor: ActionEditor = null
var _rule_editor: RuleEditor = null
var _waypoint_context: WaypointContext = null
var _rig_context_popup: PopupMenu = null
var _confirm_dialog: ConfirmationDialog = null
var _confirm_callback: Callable = Callable()
## The stickman the panels/editors currently operate on.
var _panel_rig: StickmanRig = null
## Waypoint right-click context (which rig/queue index/position was clicked).
var _ctx_waypoint_rig: StickmanRig = null
var _ctx_waypoint_index: int = -1
var _ctx_waypoint_action_id: int = -1
var _ctx_waypoint_pos: Vector2 = Vector2.ZERO
## Rule-panel filter: source id (>= 0) OR waypoint (finite); never both.
var _rule_panel_source_id: int = -1
var _rule_panel_waypoint: Vector2 = Vector2.INF
var _rule_panel_title: String = ""
var _rule_panel_filter_ids: Array[int] = []
## Unified target capture for editor popups.
var _capture_kind: CaptureKind = CaptureKind.NONE
var _capture_hint: String = ""
var _capture_callback: Callable = Callable()
var _capture_cancel: Callable = Callable()
var _capture_throw_preview_rig: StickmanRig = null
var _capture_throw_preview_prefix: Array[Dictionary] = []
var _throw_preview_cache: Dictionary = {}
## Walk-edit state (Phase 3c.3): re-placing an existing walk_to target.
var _walk_edit_rig: StickmanRig = null
var _walk_edit_index: int = -1
var _walk_edit_action_id: int = -1
var _walk_edit_from_panel: bool = false
## ActionEditor context for queue/rule add/edit flows.
var _action_editor_kind: String = ""
var _action_editor_index: int = -1
var _action_editor_rig: StickmanRig = null
var _action_editor_action_id: int = -1
var _action_editor_target_action_id: int = -1
var _action_editor_insert_after: bool = false
var _action_editor_actor_id: int = -1
var _action_editor_restore_queue: bool = false
var _pending_rule_action_flat: Dictionary = {}
## Rule editor came from the rule panel (restore it on close).
var _rule_editor_from_panel: bool = false
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
get_tree().auto_accept_quit = false
_camera.make_current()
_load_theme()
_grid_size = _theme_grid_default
_load_settings()
_spawner = STAGE_SPAWNER.new(_world)
_stickman_library = STICKMAN_LIBRARY.new()
_thumbnail_cache = THUMBNAIL_CACHE.new()
_stickman_thumb = STICKMAN_THUMBNAIL.new()
_prop_thumb = PROP_THUMBNAIL.new()
add_child(_stickman_thumb)
add_child(_prop_thumb)
_selection = STAGE_SELECTION.new(_world, _camera)
_selection.selection_changed.connect(_on_selection_changed)
_selection.hover_changed.connect(_on_hover_changed)
_build_action_cursor()
_build_grid_layer()
_build_gizmo_layer()
_build_director_visuals()
_build_navigation()
_build_ghost_holder()
_build_placement_overlay()
_build_ui()
_apply_theme_to_visuals()
_rebuild_grid_cells()
_apply_grid_settings()
_update_stage_chrome_layout()
_apply_mode_frame()
_sync_mode_buttons()
_refresh_mode_badge()
_set_timestamp(0.0)
_sync_run_controls()
_apply_cursor()
_refresh_status()
_update_scene_chrome()
_handle_cli_startup(OS.get_cmdline_user_args())
func _process(delta: float) -> void:
_drain_thumbnail_queue()
if _nav_dirty:
_nav_dirty = false
_rebake_navigation()
if current_mode == StageMode.EDIT:
if _terrain_dragging:
_update_terrain_drag(_camera.get_global_mouse_position())
elif _ghost != null and is_instance_valid(_ghost):
_update_ghost_position()
_update_cursor_coords()
_update_action_overlay()
_sync_awaiting_cursor()
if _toast_timer > 0.0:
_toast_timer -= delta
if _toast_timer <= 0.0:
_toast_text = ""
_refresh_status()
if _paused_edit_autosave_remaining >= 0.0:
_paused_edit_autosave_remaining -= delta
if _paused_edit_autosave_remaining <= 0.0:
_paused_edit_autosave_remaining = -1.0
if not _save_scene_to_path(AUTOSAVE_PATH, true):
_dirty = true
func _physics_process(delta: float) -> void:
_update_throw_preview_cache()
if _run_state == RunState.RUNNING:
_elapsed_seconds += delta
_set_timestamp(_elapsed_seconds)
_sync_pending_editable_physics_transforms()
if _restore_frames_left > 0:
_restore_frames_left -= 1
_restore_authored_state()
if _resume_baseline_frames > 0:
_refresh_event_edge_baseline()
_resume_baseline_frames -= 1
if _resume_baseline_frames == 0:
call_deferred("_finish_resume_baseline")
if current_mode == StageMode.PLAY and _event_dispatch_ready and _run_state == RunState.RUNNING:
_update_area_entry()
func _update_throw_preview_cache() -> void:
if _capture_kind != CaptureKind.THROW_POSITION or _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
_throw_preview_cache.clear()
return
if _camera == null:
_throw_preview_cache.clear()
return
var target := _camera.get_global_mouse_position()
if _snap_enabled:
target = _snap_to_grid(target)
_throw_preview_cache = _capture_throw_preview_rig.preview_prop_throw(target, _capture_throw_preview_prefix)
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventKey and (event as InputEventKey).keycode == KEY_SPACE:
_handle_space_key_event(event as InputEventKey)
return
if not _palette_drag_payload.is_empty():
if event is InputEventMouseMotion:
_update_palette_drag((event as InputEventMouseMotion).position)
return
if event is InputEventMouseButton:
var drag_button := event as InputEventMouseButton
if drag_button.button_index == MOUSE_BUTTON_LEFT and not drag_button.pressed:
_finish_palette_drag(drag_button.position)
return
if drag_button.button_index == MOUSE_BUTTON_RIGHT and drag_button.pressed:
_cancel_palette_drag()
return
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 not _palette_drag_payload.is_empty():
_cancel_palette_drag()
elif _capture_kind != CaptureKind.NONE:
_cancel_capture()
elif _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
elif _terrain_dragging:
_cancel_terrain_drag()
elif _selector_open:
_on_selector_cancelled()
elif current_mode == StageMode.DIRECT:
if _action_popup != null and _action_popup.visible:
_action_popup.hide()
elif _queue_panel != null and _queue_panel.visible:
_queue_panel.hide()
elif _rule_panel != null and _rule_panel.visible:
_rule_panel.hide()
elif _action_editor != null and _action_editor.visible:
_action_editor.hide()
elif _rule_editor != null and _rule_editor.visible:
_rule_editor.hide()
elif _placement_id != "":
set_placement_mode("")
elif not _selection.get_selected().is_empty():
_selection.clear_selection()
func _handle_space_key_event(event: InputEventKey) -> void:
if not event.pressed:
if _space_press_owned:
get_viewport().set_input_as_handled()
_space_key_down = false
_space_press_owned = false
return
if _space_key_down:
if _space_press_owned:
get_viewport().set_input_as_handled()
return
_space_key_down = true
if event.echo or (current_mode != StageMode.PLAY and current_mode != StageMode.DIRECT):
return
if _space_should_pass_to_gui():
return
_space_press_owned = true
if not _pause_toggle_blocked():
if current_mode == StageMode.PLAY:
set_mode(StageMode.DIRECT)
else:
set_mode(StageMode.PLAY)
get_viewport().set_input_as_handled()
func _space_should_pass_to_gui() -> bool:
var focus: Control = get_viewport().gui_get_focus_owner()
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
return true
for window: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]:
if window != null and window.visible:
return true
if _selector_open or (_queue_panel != null and _queue_panel.visible) or (_rule_panel != null and _rule_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible):
return true
for popup: PopupMenu in [_action_popup, _trigger_popup, _rule_action_popup, _rule_more_popup, _waypoint_context, _rig_context_popup]:
if popup != null and popup.visible:
return true
return false
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 _selector_open:
return
if current_mode == StageMode.PLAY:
return
if mb.button_index == MOUSE_BUTTON_RIGHT:
if mb.pressed:
if _capture_kind != CaptureKind.NONE:
_cancel_capture()
elif _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
else:
_handle_right_click()
return
if mb.button_index != MOUSE_BUTTON_LEFT:
return
# Use the position carried by this exact event. Polling the global cursor
# here can lag behind an unhandled press/release, leaving the gizmo with an
# unchanged drag origin even though the pointer has moved.
var world_pos := _viewport_to_world(mb.position)
# Rule-builder click routing is highest priority (Phase 4).
if _rule_step != RuleStep.IDLE:
if mb.pressed:
_handle_rule_click(world_pos)
return
# Phase 3c editor target capture.
if _capture_kind != CaptureKind.NONE:
if mb.pressed:
_resolve_capture(world_pos)
return
# Rule label / delete icon hit-testing, before gizmo/placement/selection.
var rule_hit: Dictionary = _director_visuals.hit_test_rule(world_pos)
if not rule_hit.is_empty() and mb.pressed:
if rule_hit["part"] == "delete":
_delete_rule(int(rule_hit["id"]))
else:
_begin_edit_rule(int(rule_hit["id"]))
return
if current_mode == StageMode.DIRECT:
if mb.pressed:
_handle_direct_click(world_pos)
return
# EDIT mode: a terrain drag commits on release.
if _terrain_dragging:
if not mb.pressed:
_commit_terrain_drag()
return
if mb.pressed:
if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE:
_gizmos.begin_drag(world_pos)
elif _placement_id != "":
if _spawner.is_terrain_id(_placement_id):
_begin_terrain_drag(world_pos)
else:
_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 _selector_open:
return
if _panning:
_camera.position -= mm.relative / _camera.zoom.x
return
if current_mode == StageMode.PLAY:
return
var world_pos := _viewport_to_world(mm.position)
if _gizmos.is_dragging():
_gizmos.drag_to(world_pos)
_director_visuals.mark_dirty()
elif _terrain_dragging:
_update_terrain_drag(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() and current_mode == StageMode.EDIT:
_selection.update_hover(world_pos)
func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
return get_viewport().get_canvas_transform().affine_inverse() * viewport_pos
# ---------------------------------------------------------------------------
# Mode management
# ---------------------------------------------------------------------------
func set_mode(mode: StageMode) -> void:
if mode == current_mode:
return
var previous_mode: StageMode = current_mode
if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode
if not _palette_drag_payload.is_empty():
_cancel_palette_drag()
match mode:
StageMode.EDIT:
_enter_edit_mode(previous_mode)
StageMode.DIRECT:
_enter_direct_mode(previous_mode)
StageMode.PLAY:
_enter_play_mode()
_sync_mode_buttons()
_apply_grid_settings()
_apply_mode_frame()
_refresh_mode_badge()
_sync_run_controls()
_apply_cursor()
mode_changed.emit(int(mode))
_refresh_status()
func _enter_edit_shared() -> void:
_reset_run()
func _enter_edit_mode(_previous_mode: StageMode) -> void:
if _run_state != RunState.STOPPED:
_enter_edit_shared()
_clear_director_pending()
_set_edit_controls_visible(true)
_set_direct_controls_visible(false)
_gizmos.set_enabled(true)
_director_visuals.set_enabled(true)
## Cancels any in-progress director/rule-builder click flows when leaving a mode
## (a pending walk target or a half-built When->Then rule must not survive into
## another mode where its click routing would mis-fire).
func _clear_director_pending() -> void:
_clear_popup_anchor()
_pending_walk_target = false
_context_rig = null
if _capture_kind != CaptureKind.NONE:
_cancel_capture()
if _rule_step != RuleStep.IDLE:
_cancel_rule_build()
if _action_popup != null:
_action_popup.hide()
# Close any Phase 3c editor popups.
if _queue_panel != null:
_queue_panel.hide()
if _rule_panel != null:
_rule_panel.hide()
if _action_editor != null:
_action_editor.hide()
if _rule_editor != null:
_rule_editor.hide()
if _director_visuals != null:
_director_visuals.clear_edit_waypoint()
## Direct is internally an "edit-with-direct" state: the same frozen world,
## but with the spawner/grid controls hidden, the director hint shown, and any
## placement/selection/pending-target cleared.
func _enter_direct_mode(previous_mode: StageMode) -> void:
if previous_mode == StageMode.PLAY and _run_state == RunState.RUNNING:
_pause_run()
set_placement_mode("")
_selection.clear_selection()
_clear_director_pending()
_set_edit_controls_visible(false)
_set_direct_controls_visible(true)
_gizmos.set_enabled(false)
_director_visuals.set_enabled(true)
_director_visuals.set_live_session(_run_state == RunState.PAUSED)
func _enter_play_mode() -> void:
_edit_physics_targets.clear()
_edit_physics_settle_frames = 0
_gizmos.set_enabled(false)
_selection.clear_selection()
set_placement_mode("")
_clear_director_pending()
_director_visuals.set_enabled(false)
_set_edit_controls_visible(false)
_set_direct_controls_visible(false)
if _run_state == RunState.PAUSED:
_resume_run()
else:
_start_run()
_gizmos.set_enabled(false)
_director_visuals.set_enabled(false)
_director_visuals.set_live_session(false)
_set_direct_controls_visible(false)
_stage_dbg("PLAY run_state=%s" % RunState.keys()[int(_run_state)])
func _start_run() -> void:
_elapsed_seconds = 0.0
_set_timestamp(0.0)
_run_state = RunState.RUNNING
_event_dispatch_ready = true
_resume_baseline_frames = 0
_prop_pause_states.clear()
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
if node is PropBlock:
(node as PropBlock).begin_play_instance_scale()
(node as RigidBody2D).freeze = false
if node is STICKMAN_RIG:
var rig := node as StickmanRig
rig.reset_simulation_pause()
rig.stop_queue()
rig.clear_reactive_actions()
rig.auto_recover = false
rig.begin_terrain_fall()
rig.start_queue()
_sync_run_controls()
func _pause_run() -> void:
if _run_state != RunState.RUNNING:
return
_event_dispatch_ready = false
_run_state = RunState.PAUSED
_resume_baseline_frames = 0
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
(node as StickmanRig).pause_simulation()
elif node is RigidBody2D:
_capture_prop_pause_state(node as RigidBody2D)
_refresh_event_edge_baseline()
_gizmos.set_enabled(false)
_selection.clear_selection()
_director_visuals.set_live_session(true)
_director_visuals.set_enabled(true)
_set_direct_controls_visible(true)
_sync_run_controls()
simulation_paused.emit(_elapsed_seconds)
func _resume_run() -> void:
if _run_state != RunState.PAUSED:
return
_clear_director_pending()
for body_id: int in _prop_pause_states.keys():
var snapshot: Dictionary = _prop_pause_states[body_id]
var body: RigidBody2D = snapshot.get("body") as RigidBody2D
if body == null or not is_instance_valid(body) or body.is_queued_for_deletion():
continue
body.freeze_mode = int(snapshot.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
body.freeze = bool(snapshot.get("freeze", false))
body.linear_velocity = snapshot.get("linear_velocity", Vector2.ZERO)
body.angular_velocity = float(snapshot.get("angular_velocity", 0.0))
body.sleeping = bool(snapshot.get("sleeping", false))
_prop_pause_states.clear()
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
(node as StickmanRig).resume_simulation()
_run_state = RunState.RUNNING
_event_dispatch_ready = false
_resume_baseline_frames = 2
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
_sync_run_controls()
simulation_resumed.emit(_elapsed_seconds)
func _reset_run() -> void:
_event_dispatch_ready = false
_run_state = RunState.STOPPED
_resume_baseline_frames = 0
_paused_edit_autosave_remaining = -1.0
_prop_pause_states.clear()
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as StickmanRig
rig.reset_prop_interactions_for_scene_exit()
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
rig.clear_reactive_actions()
rig.reset_simulation_pause()
elif node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
if node is PropBlock:
(node as PropBlock).end_play_instance_scale()
_restore_authored_state()
_restore_frames_left = 3
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
_elapsed_seconds = 0.0
_set_timestamp(0.0)
_director_visuals.set_live_session(false)
_sync_run_controls()
simulation_reset.emit()
func _capture_prop_pause_state(body: RigidBody2D) -> Dictionary:
var snapshot: Dictionary = {"body": body, "transform": body.global_transform, "linear_velocity": body.linear_velocity, "angular_velocity": body.angular_velocity, "sleeping": body.sleeping, "freeze": body.freeze, "freeze_mode": body.freeze_mode}
_prop_pause_states[body.get_instance_id()] = snapshot
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
return snapshot
func _set_timestamp(seconds: float) -> void:
if _timestamp_label == null:
return
var safe_seconds: float = maxf(seconds, 0.0)
var minutes: int = int(floor(safe_seconds / 60.0))
var remainder: float = fmod(safe_seconds, 60.0)
var centiseconds: int = int(floor(remainder * 100.0))
_timestamp_label.text = "⏱️ %02d:%02d.%02d" % [minutes, int(centiseconds / 100), centiseconds % 100]
func _sync_run_controls() -> void:
var running: bool = _run_state == RunState.RUNNING
var paused: bool = _run_state == RunState.PAUSED
if _pause_button != null:
_pause_button.visible = running and current_mode == StageMode.PLAY
if _resume_button != null:
_resume_button.visible = paused and current_mode == StageMode.DIRECT
if _reset_button != null:
_reset_button.visible = running or paused
if _timestamp_badge != null:
_timestamp_badge.visible = true
func _finish_resume_baseline() -> void:
if _run_state == RunState.RUNNING and current_mode == StageMode.PLAY:
_event_dispatch_ready = true
func _pause_toggle_blocked() -> bool:
if _capture_kind != CaptureKind.NONE or _rule_step != RuleStep.IDLE or _pending_walk_target or _gizmos.is_dragging() or _selector_open:
return true
var focus: Control = get_viewport().gui_get_focus_owner()
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
return true
for dialog: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]:
if dialog != null and dialog.visible:
return true
return (_queue_panel != null and _queue_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible)
func _refresh_event_edge_baseline() -> void:
var areas: Array[TriggerArea] = []
for child: Node in _world.get_children():
if child is TriggerArea:
areas.append(child as TriggerArea)
var movables: Array[Node2D] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG and (node as StickmanRig).state == StickmanRig.RigState.ANIMATED:
movables.append(node)
elif node is PropBlock:
movables.append(node)
_area_overlap.clear()
_area_previous_bounds.clear()
for node: Node2D in movables:
var node_id: int = node.get_instance_id()
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
_area_previous_bounds[node_id] = bounds
for area: TriggerArea in areas:
var key: String = "%d:%d" % [area.get_instance_id(), node_id]
var area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
_area_overlap[key] = bounds.intersects(area_rect, true)
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
for child: Node2D in _world_children_selectable():
if not child is PropBlock:
continue
var prop := child as PropBlock
for other: Node in prop.get_colliding_bodies():
if other == null or not is_instance_valid(other):
continue
_suppressed_contact_pairs["%d:%d" % [prop.get_instance_id(), other.get_instance_id()]] = true
if not other.has_meta("stickman_rig"):
continue
var rig: StickmanRig = other.get_meta("stickman_rig") as StickmanRig
if rig == null or not is_instance_valid(rig):
continue
var key: String = "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {"rig_id": rig.get_instance_id(), "prop_id": prop.get_instance_id(), "bodies": {}})
var body_ids: Dictionary = pair.get("bodies", {})
body_ids[other.get_instance_id()] = true
pair["bodies"] = body_ids
_collision_pairs[key] = pair
func _save_object_state(node: Node2D) -> void:
var authored_rotation := node.global_rotation
if node is STICKMAN_RIG and (node as StickmanRig).get_facing_profile() == StickmanRig.FacingProfile.LEFT:
# LEFT is a derived root reflection. Remove it before persisting the
# director-authored rotation so a load never applies the mirror twice.
authored_rotation = (node.global_transform * Transform2D.FLIP_X).get_rotation()
var entry := {
"node": node,
"position": node.global_position,
"rotation": authored_rotation,
}
if node is STICKMAN_RIG:
entry["facing_profile"] = (node as STICKMAN_RIG).get_facing_profile()
if not node is STICKMAN_RIG:
entry["scale"] = node.scale
_authored[node.get_instance_id()] = entry
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 entry.has("scale"):
node.scale = entry["scale"]
if node is STICKMAN_RIG and entry.has("facing_profile"):
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
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()
_free_terrain_ghosts()
_end_terrain_drag()
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
_finalize_placed_node(node)
if node is TerrainBlock:
_nav_dirty = true
_rebuild_grid_cells()
object_placed.emit(node)
_mark_scene_dirty()
_refresh_status()
_spawn_ghost()
## Common post-spawn wiring: freeze kinematic props in EDIT, save authored state,
## and connect the Phase 3a/4 signals for stickmen/props.
func _finalize_placed_node(node: Node2D) -> void:
if node is PropBlock:
var placed_prop := node as PropBlock
if placed_prop.stage_object_id.is_empty():
placed_prop.stage_object_id = _new_stage_prop_id()
placed_prop.ensure_default_grip()
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.set_prop_resolver(Callable(self, "_resolve_prop_by_stage_id"))
rig.queue_changed.connect(_on_rig_queue_changed)
rig.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
rig.prop_interaction_event.connect(_on_rig_prop_interaction_event.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
prop.collided.connect(_on_prop_collided.bind(prop))
prop.separated.connect(_on_prop_separated.bind(prop))
func _new_stage_prop_id() -> String:
while true:
_next_stage_prop_serial += 1
var candidate := "prop_%d_%d_%d" % [Time.get_ticks_usec(), randi(), _next_stage_prop_serial]
if not _stage_prop_id_in_use(candidate):
return candidate
return ""
func _stage_prop_id_in_use(stage_id: String) -> bool:
for child: Node in _world.get_children():
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
return true
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == stage_id:
return true
return false
func _resolve_prop_by_stage_id(stage_id: String) -> PropBlock:
if stage_id.is_empty():
return null
for child: Node in _world.get_children():
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
return child as PropBlock
return null
func _on_rig_prop_interaction_event(event_type: String, prop: PropBlock, reason: String, rig: StickmanRig) -> void:
if prop == null or not is_instance_valid(prop) or rig == null or not is_instance_valid(rig):
return
_handle_event({ "type": event_type, "source": rig, "target": prop, "position": prop.global_position, "reason": reason, "action": {} })
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)
_configure_ghost_collision(ghost)
_ghost = ghost
_update_ghost_position()
## Disables collision / freezes a ghost so it never participates in physics or
## selection, and stops a ghost stickman's IK + animation.
func _configure_ghost_collision(ghost: Node2D) -> void:
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:
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()
rig.set_collision_bodies_enabled(false)
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 _is_terrain_placement():
pos = _snap_to_grid(pos) if _snap_enabled else pos
elif _snap_enabled:
pos = _snap_to_grid(pos)
_ghost.position = pos
# ---------------------------------------------------------------------------
# Terrain drag-painting (Phase 4b)
# ---------------------------------------------------------------------------
func _is_terrain_placement() -> bool:
return _spawner.is_terrain_id(_placement_id)
## Paint stride = the active terrain template's AABB extent per axis (decision
## D1). Falls back to TERRAIN_GRID_SIZE for a non-terrain / empty placement so
## the quantizers never divide by zero.
func _terrain_stride() -> Vector2:
var size: Vector2 = _spawner.get_template_aabb(_placement_id).size
if size.x <= 0.0001 or size.y <= 0.0001:
return Vector2(STAGE_SPAWNER.TERRAIN_GRID_SIZE, STAGE_SPAWNER.TERRAIN_GRID_SIZE)
return size
func _world_to_terrain_cell(pos: Vector2) -> Vector2i:
var s := _terrain_stride()
return Vector2i(int(roundf(pos.x / s.x)), int(roundf(pos.y / s.y)))
func _terrain_cell_center(cell: Vector2i) -> Vector2:
var s := _terrain_stride()
return Vector2(cell.x * s.x, cell.y * s.y)
## 16-px grid cell for the advisory _grid_cells dictionary lookups (NOT the
## block-unit paint cell). Used by _is_target_valid.
func _world_to_grid_cell(pos: Vector2) -> Vector2i:
var gs := STAGE_SPAWNER.TERRAIN_GRID_SIZE
return Vector2i(int(roundf(pos.x / gs)), int(roundf(pos.y / gs)))
## World AABB of the block that would be stamped at `cell` (block center +
## the template's local AABB).
func _block_aabb(cell: Vector2i) -> Rect2:
var local: Rect2 = _spawner.get_template_aabb(_placement_id)
return Rect2(_terrain_cell_center(cell) + local.position, local.size)
func _begin_terrain_drag(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
_free_ghost()
_drag_painted.clear()
_terrain_dragging = true
_terrain_painting = false
_terrain_press_world = world_pos
_terrain_anchor_cell = _world_to_terrain_cell(world_pos)
_terrain_target_cell = _terrain_anchor_cell
func _update_terrain_drag(world_pos: Vector2) -> void:
if not _terrain_dragging:
return
if not _terrain_painting:
var zoom: float = _camera.zoom.x if _camera != null else 1.0
if (world_pos - _terrain_press_world).length() * zoom < TERRAIN_DRAG_THRESHOLD_PX:
return
_terrain_painting = true
var target := _world_to_terrain_cell(world_pos)
if Input.is_key_pressed(KEY_SHIFT):
var d := target - _terrain_anchor_cell
if absi(d.x) >= absi(d.y):
target = Vector2i(_terrain_anchor_cell.x + d.x, _terrain_anchor_cell.y)
else:
target = Vector2i(_terrain_anchor_cell.x, _terrain_anchor_cell.y + d.y)
_terrain_target_cell = target
_drag_cells = _bresenham_cells(_terrain_anchor_cell, target)
_refresh_terrain_ghosts()
if _placement_overlay != null:
if target != _terrain_anchor_cell:
_placement_overlay.set_terrain_guide(_terrain_cell_center(_terrain_anchor_cell), _terrain_cell_center(target))
else:
_placement_overlay.clear_terrain_guide()
func _bresenham_cells(a: Vector2i, b: Vector2i) -> Array[Vector2i]:
var cells: Array[Vector2i] = []
var x0: int = a.x
var y0: int = a.y
var x1: int = b.x
var y1: int = b.y
var dx: int = absi(x1 - x0)
var dy: int = -absi(y1 - y0)
var sx: int = 1 if x0 < x1 else -1
var sy: int = 1 if y0 < y1 else -1
var err: int = dx + dy
while true:
cells.append(Vector2i(x0, y0))
if x0 == x1 and y0 == y1:
break
var e2: int = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
return cells
## Three-state classification per block cell: 1 empty, 2 same-type skip, 3
## conflicting. Uses the 16-px advisory dictionary as a broadphase and then does
## a precise interior-overlap test so blocks that tile edge-to-edge (Case A
## seamless extension) still classify empty while genuinely overlapping blocks
## skip. Cells stamped earlier in the current drag (in-drag self-overlap) read
## as same-type skip.
func _classify_cell(cell: Vector2i) -> int:
if _drag_painted.has(cell):
return 2
var aabb := _block_aabb(cell)
var seen: Dictionary = {}
for c: Vector2i in _rasterize_aabb_to_cells(aabb):
for n in _grid_cells.get(c, []):
if n is Node2D:
seen[(n as Node2D).get_instance_id()] = n
var has_same := false
for n in seen.values():
var node := n as Node2D
if node == null:
continue
if not _aabb_overlaps(aabb, STAGE_SELECTION.get_world_aabb(node)):
continue
if node is TerrainBlock and (node as TerrainBlock).spawn_id == _placement_id:
has_same = true
continue
return 3
return 2 if has_same else 1
## Strict interior overlap (excludes edge/corner contact) so blocks that tile
## edge-to-edge do not register as overlapping.
func _aabb_overlaps(a: Rect2, b: Rect2) -> bool:
return a.position.x < b.end.x and b.position.x < a.end.x \
and a.position.y < b.end.y and b.position.y < a.end.y
func _refresh_terrain_ghosts() -> void:
if _drag_cells == _last_drag_cells:
return
_last_drag_cells = _drag_cells.duplicate()
_free_terrain_ghosts()
if _placement_id == "" or not _is_terrain_placement():
return
for cell: Vector2i in _drag_cells:
var state := _classify_cell(cell)
var ghost := _spawner.spawn(_placement_id, _terrain_cell_center(cell))
if ghost == null:
continue
if ghost.get_parent() == _world:
_world.remove_child(ghost)
_ghost_holder.add_child(ghost)
_configure_ghost_collision(ghost)
match state:
1:
ghost.modulate = Color(0.3, 1.0, 0.4, 0.5)
2:
ghost.modulate = Color(1.0, 1.0, 1.0, 0.15)
3:
ghost.modulate = Color(1.0, 0.3, 0.3, 0.5)
_ghost_array.append(ghost)
func _free_terrain_ghosts() -> void:
for g: Node2D in _ghost_array:
if g != null and is_instance_valid(g):
g.queue_free()
_ghost_array.clear()
func _commit_terrain_drag() -> void:
var placed: Array[Node2D] = []
if not _terrain_painting:
var click_pos: Vector2 = _snap_to_grid(_terrain_press_world) if _snap_enabled else _terrain_press_world
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
if click_node != null:
_finalize_placed_node(click_node)
object_placed.emit(click_node)
placed.append(click_node)
for cell: Vector2i in _drag_cells:
if _classify_cell(cell) != 1:
continue
var node := _spawner.spawn(_placement_id, _terrain_cell_center(cell))
if node == null:
continue
_finalize_placed_node(node)
object_placed.emit(node)
placed.append(node)
_drag_painted[cell] = true
if not placed.is_empty():
_nav_dirty = true
_rebuild_grid_cells()
_mark_scene_dirty()
_end_terrain_drag()
_refresh_status()
func _cancel_terrain_drag() -> void:
_end_terrain_drag()
func _end_terrain_drag() -> void:
_terrain_dragging = false
_terrain_painting = false
_terrain_press_world = Vector2.ZERO
_drag_cells.clear()
_last_drag_cells.clear()
_drag_painted.clear()
_free_terrain_ghosts()
if _placement_overlay != null:
_placement_overlay.clear_terrain_guide()
# Re-arm the single cursor-following ghost (LMB repeated placement) unless
# the tool was just put away (placement_id cleared).
if _placement_id != "":
_spawn_ghost()
# ---------------------------------------------------------------------------
# Grid spatial dictionary (Phase 4b) — advisory only, never authoritative.
# ---------------------------------------------------------------------------
func _rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]:
var cells: Array[Vector2i] = []
if aabb.size.x <= 0.0 or aabb.size.y <= 0.0:
return cells
var gs := STAGE_SPAWNER.TERRAIN_GRID_SIZE
var min_x := int(floorf(aabb.position.x / gs))
var max_x := int(floorf((aabb.end.x - 0.0001) / gs))
var min_y := int(floorf(aabb.position.y / gs))
var max_y := int(floorf((aabb.end.y - 0.0001) / gs))
for y: int in range(min_y, max_y + 1):
for x: int in range(min_x, max_x + 1):
cells.append(Vector2i(x, y))
return cells
func _rebuild_grid_cells() -> void:
_grid_cells.clear()
for node: Node2D in _world_children_selectable():
_add_node_to_grid_cells(node)
func _add_node_to_grid_cells(node: Node2D) -> void:
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
return
var aabb := STAGE_SELECTION.get_world_aabb(node)
for cell: Vector2i in _rasterize_aabb_to_cells(aabb):
var list: Array = _grid_cells.get(cell, [])
if not list.has(node):
list.append(node)
_grid_cells[cell] = list
# ---------------------------------------------------------------------------
# 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)
var deleted_ids: Array[int] = [node.get_instance_id()]
_purge_collision_pairs_for_ids(deleted_ids)
node.queue_free()
_rebuild_grid_cells()
_cleanup_rules_for_nodes(selected)
_selection.clear_selection()
_selection.clear_hover()
if nav_changed:
_nav_dirty = true
_director_visuals.mark_dirty()
object_deleted.emit(selected)
_mark_scene_dirty()
_refresh_status()
# ---------------------------------------------------------------------------
# Status / UI
# ---------------------------------------------------------------------------
func _mark_scene_dirty() -> void:
if not _suppress_dirty:
_set_scene_dirty(true)
if _run_state == RunState.PAUSED:
_paused_edit_autosave_remaining = 1.0
func _set_scene_dirty(value: bool) -> void:
_dirty = value
_update_scene_chrome()
func _on_rig_queue_changed() -> void:
_director_visuals.mark_dirty()
if _queue_panel != null and _panel_rig != null and is_instance_valid(_panel_rig):
_queue_panel.refresh()
_mark_scene_dirty()
func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for node: Node in _world.get_children():
if not node is Node2D: continue
var n := node as Node2D
var type := ""
if n is TerrainBlock: type = "terrain"
elif n is PropBlock or n.get_script() == MISSING_PROP_PLACEHOLDER: type = "prop"
elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman"
elif n is TriggerArea: type = "area"
else: continue
counters[type] = int(counters[type]) + 1
var file_id := "%s_%d" % [type, int(counters[type])]
runtime_to_file[n.get_instance_id()] = file_id
var authored: Dictionary = _authored.get(n.get_instance_id(), {})
var pos: Vector2 = authored.get("position", n.global_position)
var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation)))
var entry: Dictionary = {"file_id": file_id, "type": type, "position": STAGE_SCENE_IO.vector_to_data(pos), "rotation": rot}
if type == "terrain":
entry["asset_id"] = (n as TerrainBlock).spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
entry["stage_object_id"] = String(n.get("stage_object_id"))
elif type == "prop":
var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id
entry["stage_object_id"] = prop.stage_object_id
var prop_scale: Vector2 = authored.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
elif type == "area":
var area := n as TriggerArea
entry["scale"] = STAGE_SCENE_IO.vector_to_data(area.scale)
entry["size"] = STAGE_SCENE_IO.vector_to_data(area.size)
elif n.get_script() == MISSING_STICKMAN_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "stickman"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot
else:
var rig := n as StickmanRig
entry["stk_path"] = rig.source_stk_path
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_facing_profile()))]
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
entry["action_queue"] = queue_result["actions"]
objects.append(entry)
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
return {"version": STAGE_SCENE_IO.FILE_VERSION, "scene_name": _scene_name, "camera": {"position": STAGE_SCENE_IO.vector_to_data(_camera.global_position), "zoom": _camera.zoom.x}, "objects": objects, "event_rules": rules_result["rules"], "metadata": {"created_unix": _scene_created_unix if _scene_created_unix > 0 else Time.get_unix_time_from_system(), "modified_unix": Time.get_unix_time_from_system(), "autosave": is_autosave, "source_path": _current_scene_path if is_autosave else ""}}
func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool:
var result: Dictionary = STAGE_SCENE_IO.write_file_safe(path, _collect_scene_data(is_autosave))
if not bool(result.get("ok", false)):
_show_toast(String(result.get("error", "Could not save scene.")))
if _pending_file_action != PendingFileAction.NONE: _pending_file_action = PendingFileAction.NONE
return false
if not is_autosave:
_paused_edit_autosave_remaining = -1.0
_current_scene_path = path
if _scene_created_unix <= 0: _scene_created_unix = Time.get_unix_time_from_system()
_set_scene_dirty(false); _add_recent_scene(path)
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
return true
func _reset_scene_content() -> void:
set_mode(StageMode.EDIT)
_free_ghost(); _free_terrain_ghosts(); _end_terrain_drag()
_selection.clear_selection(); _selection.clear_hover(); _authored.clear(); _edit_physics_targets.clear(); _event_rules.clear(); _next_rule_id = 0
_clear_director_pending(); _reset_rule_builder(); _nav_dirty = false
for child: Node in _world.get_children():
_world.remove_child(child)
child.queue_free()
_area_overlap.clear(); _collision_pairs.clear(); _grid_cells.clear(); _director_visuals.set_rules(_event_rules)
func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool:
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
if not bool(read.get("ok", false)):
_show_toast(String(read.get("error", "Could not load scene."))); return false
return _apply_scene_data(read["data"], path, is_recovery)
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
var was_suppressed := _suppress_dirty; _suppress_dirty = true
_reset_scene_content()
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []
for raw: Variant in data.get("objects", []):
if not raw is Dictionary: warnings.append("Malformed object"); continue
var entry: Dictionary = raw; var file_id := String(entry.get("file_id", "")); var type := String(entry.get("type", ""))
if file_id.is_empty() or file_to_runtime.has(file_id) or unavailable.has(file_id): warnings.append("Duplicate or empty file id"); continue
if not STAGE_SCENE_IO.is_vector_data(entry.get("position")) or not STAGE_SCENE_IO._finite_number(entry.get("rotation", 0.0)):
warnings.append("Malformed object transform"); continue
var pos := STAGE_SCENE_IO.data_to_vector(entry.get("position"), Vector2.ZERO)
var node: Node2D = null
match type:
"terrain": node = _spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), pos)
"prop":
node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
var prop_placeholder: Node2D = MISSING_PROP_PLACEHOLDER.new() as Node2D
prop_placeholder.configure(entry); prop_placeholder.position = pos; _world.add_child(prop_placeholder); node = prop_placeholder
"area": node = _spawner.spawn_area(pos)
"stickman":
node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos)
if node == null:
var placeholder: Node2D = MISSING_STICKMAN_PLACEHOLDER.new() as Node2D
placeholder.configure(entry); placeholder.position = pos; _world.add_child(placeholder); node = placeholder; unavailable[file_id] = true
missing_stickmen += 1
var missing_path := String(entry.get("stk_path", ""))
if not missing_paths.has(missing_path): missing_paths.append(missing_path)
if node == null: warnings.append("Unknown object asset"); continue
node.global_rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
if type != "stickman":
if entry.has("scale") and not STAGE_SCENE_IO.is_vector_data(entry["scale"]):
warnings.append("Malformed object scale")
_world.remove_child(node)
node.queue_free()
continue
node.scale = STAGE_SCENE_IO.data_to_vector(entry.get("scale"), Vector2.ONE)
if node is TriggerArea:
var area_size := STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2.ZERO)
if not STAGE_SCENE_IO.is_vector_data(entry.get("size")) or area_size.x <= 0.0 or area_size.y <= 0.0:
warnings.append("Malformed trigger area")
_world.remove_child(node)
node.queue_free()
continue
(node as TriggerArea).size = area_size
if node is PropBlock:
(node as PropBlock).stage_object_id = String(entry.get("stage_object_id", ""))
var preset_names: Array = PropBlock.MaterialPreset.keys()
var raw_preset: Variant = entry.get("material_preset", PropBlock.MaterialPreset.NONE)
var index := -1
if raw_preset is int or raw_preset is float:
var numeric_preset := float(raw_preset)
if is_finite(numeric_preset) and is_equal_approx(numeric_preset, roundf(numeric_preset)):
index = int(numeric_preset)
else:
index = preset_names.find(String(raw_preset))
if index >= 0 and index < preset_names.size():
(node as PropBlock).material_preset = index
else:
warnings.append("Invalid prop material preset")
if node is StickmanRig:
var facing_names: Array = StickmanRig.FacingProfile.keys(); var facing := facing_names.find(String(entry.get("facing_profile", "FORWARD")))
if facing >= 0: (node as StickmanRig).set_facing_profile(facing)
var raw_queue: Variant = entry.get("action_queue", [])
if raw_queue is Array:
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
for warning: String in queue.get("warnings", []):
warnings.append(warning)
for action: Dictionary in queue["actions"]:
(node as StickmanRig).queue_action(action)
else:
warnings.append("Invalid stickman action queue")
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
_director_visuals.set_rules(_event_rules)
var raw_camera: Variant = data.get("camera", {})
var camera: Dictionary = raw_camera as Dictionary if raw_camera is Dictionary else {}
_camera.global_position = STAGE_SCENE_IO.data_to_vector(camera.get("position"), DEFAULT_CAMERA_POSITION)
_camera.zoom = Vector2.ONE * clampf(float(camera.get("zoom", DEFAULT_CAMERA_ZOOM)), min_zoom, max_zoom)
_scene_name = String(data.get("scene_name", DEFAULT_SCENE_NAME)).strip_edges(); if _scene_name.is_empty(): _scene_name = DEFAULT_SCENE_NAME
var raw_metadata: Variant = data.get("metadata", {})
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
_scene_created_unix = int(metadata.get("created_unix", Time.get_unix_time_from_system()))
_current_scene_path = String(metadata.get("source_path", source_path)) if is_recovery else source_path
_rebuild_grid_cells(); _nav_dirty = true; _suppress_dirty = was_suppressed; _set_scene_dirty(is_recovery)
if not warnings.is_empty() or int(decoded_rules.get("skipped", 0)) > 0 or missing_stickmen > 0:
_show_toast("Loaded with %d warning(s), %d missing stickman, %d skipped rule(s)" % [warnings.size(), missing_stickmen, int(decoded_rules.get("skipped", 0))])
if not missing_paths.is_empty():
_show_missing_assets_dialog(missing_paths)
if not is_recovery:
_add_recent_scene(source_path)
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
return true
func _show_missing_assets_dialog(paths: Array[String]) -> void:
if _missing_assets_dialog == null:
return
var asset_lines: Array[String] = []
for path: String in paths:
var asset_name := path.get_file()
if asset_name.is_empty(): asset_name = "Unnamed .stk asset"
asset_lines.append("• %s\n %s" % [asset_name, path if not path.is_empty() else "(no asset path was saved)"])
_missing_assets_dialog.dialog_text = "%d stickman asset(s) could not be loaded:\n\n%s\n\nA red marker was placed where each missing asset belongs." % [paths.size(), "\n\n".join(asset_lines)]
_missing_assets_dialog.popup_centered()
func _add_recent_scene(path: String) -> void:
if path.is_empty() or path == AUTOSAVE_PATH: return
_recent_scenes.erase(path); _recent_scenes.push_front(path)
while _recent_scenes.size() > RECENT_SCENE_LIMIT: _recent_scenes.pop_back()
_save_settings()
_update_recent_menu()
func _update_recent_menu() -> void:
if _file_menu == null: return
var recent := _file_menu.get_popup().get_node_or_null(NodePath("OpenRecent")) as PopupMenu
if recent == null: return
recent.clear()
var existing: Array[String] = []
for path: String in _recent_scenes:
if FileAccess.file_exists(path): existing.append(path)
var pruned := existing.size() != _recent_scenes.size()
_recent_scenes = existing
if pruned: _save_settings()
if _recent_scenes.is_empty():
recent.add_item("(No recent scenes)", -1); recent.set_item_disabled(0, true)
else:
for index: int in _recent_scenes.size(): recent.add_item(_recent_scenes[index], index)
func _on_recent_scene_selected(index: int) -> void:
if index < 0 or index >= _recent_scenes.size(): return
var path := _recent_scenes[index]
if not FileAccess.file_exists(path):
_recent_scenes.remove_at(index); _save_settings(); _show_toast("File not found: %s" % path); return
_request_pending_file_action(PendingFileAction.OPEN_PATH, path)
func _update_scene_chrome() -> void:
if is_inside_tree():
var scene_file := _current_scene_path.get_file()
if scene_file.is_empty():
scene_file = "Unsaved"
get_window().title = "%s%s — %s — Stickman Studio" % [_scene_name, " *" if _dirty else "", scene_file]
_refresh_status()
func _check_autosave_recovery() -> void:
if not FileAccess.file_exists(AUTOSAVE_PATH): return
var read: Dictionary = STAGE_SCENE_IO.read_file(AUTOSAVE_PATH)
if not bool(read.get("ok", false)):
DirAccess.remove_absolute(AUTOSAVE_PATH); return
var autosave_data: Dictionary = read["data"] as Dictionary
var raw_metadata: Variant = autosave_data.get("metadata", {})
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
if not bool(metadata.get("autosave", false)):
DirAccess.remove_absolute(AUTOSAVE_PATH); return
var source_path := String(metadata.get("source_path", ""))
if not source_path.is_empty() and FileAccess.file_exists(source_path) and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
DirAccess.remove_absolute(AUTOSAVE_PATH); return
var dialog := ConfirmationDialog.new()
dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?"
dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free())
dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free())
get_node(NodePath("UI")).add_child(dialog); _apply_dialog_theme(dialog); dialog.popup_centered()
func _handle_cli_startup(args: Array) -> void:
var result: Dictionary = CliSceneLauncher.parse(args)
if not String(result.get("error", "")).is_empty():
_show_cli_error_dialog(String(result["error"]))
_check_autosave_recovery()
return
var scene_path := String(result.get("scene_path", ""))
if scene_path.is_empty():
_check_autosave_recovery()
return
if FileAccess.file_exists(AUTOSAVE_PATH):
DirAccess.remove_absolute(AUTOSAVE_PATH)
var load: Dictionary = _load_cli_scene(scene_path)
if not bool(load.get("ok", false)):
_show_cli_error_dialog(String(load.get("error", "Could not load scene.")))
return
if bool(result.get("play", false)):
_cli_startup = true
set_mode(StageMode.PLAY)
_cli_startup = false
func _load_cli_scene(path: String) -> Dictionary:
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
if not bool(read.get("ok", false)):
return {"ok": false, "error": String(read.get("error", "Could not load scene."))}
if not _apply_scene_data(read["data"], path, false):
return {"ok": false, "error": "Could not apply scene data."}
return {"ok": true, "error": ""}
func _show_cli_error_dialog(message: String) -> void:
if _cli_error_dialog == null:
return
_cli_error_dialog.dialog_text = message
_cli_error_dialog.popup_centered()
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT:
_space_key_down = false
_space_press_owned = false
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_pending_file_action(PendingFileAction.QUIT)
func _refresh_status() -> void:
if _status_label == null:
return
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()
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
if _pending_walk_target:
text += " | Click stage for walk target (Esc or RMB to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
text += " | " + _rule_hint
if not _toast_text.is_empty():
text = _toast_text + " | " + text
_status_label.text = text
func _accent_for_mode(mode: StageMode) -> Color:
match mode:
StageMode.DIRECT:
return _accent_direct
StageMode.PLAY:
return _accent_play
_:
return _accent_edit
func _sync_mode_buttons() -> void:
var accent := _accent_for_mode(current_mode)
for id: int in _mode_buttons:
var btn: Button = _mode_buttons[id]
var active := int(current_mode) == id
btn.set_pressed_no_signal(active)
if active:
btn.add_theme_color_override("font_color", accent)
btn.add_theme_color_override("font_pressed_color", accent)
btn.add_theme_color_override("font_hover_color", accent)
else:
btn.remove_theme_color_override("font_color")
btn.remove_theme_color_override("font_pressed_color")
btn.remove_theme_color_override("font_hover_color")
func _apply_mode_frame() -> void:
if _mode_frame == null:
return
_update_stage_chrome_layout()
_mode_frame.visible = true
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
if sb != null: sb.border_color = _accent_for_mode(current_mode)
func _refresh_mode_badge() -> void:
if _mode_badge == null or _mode_badge_label == null:
return
var accent := _accent_for_mode(current_mode)
var text := "✏️ EDIT"
match current_mode:
StageMode.EDIT:
text = "✏️ EDIT"
StageMode.DIRECT:
text = "🎬 DIRECTING"
StageMode.PLAY:
text = "▶️ SIMULATING"
_mode_badge_label.text = text
var sb: StyleBoxFlat = _mode_badge.get_theme_stylebox("panel")
sb.bg_color = accent
var text_color := Color.BLACK if accent.get_luminance() > 0.5 else Color.WHITE
_mode_badge_label.add_theme_color_override("font_color", text_color)
_mode_badge.size = _mode_badge.get_combined_minimum_size()
func _apply_cursor() -> void:
_last_awaiting_click = _is_awaiting_click()
if _last_awaiting_click:
_apply_action_cursor()
return
Input.set_custom_mouse_cursor(null)
match current_mode:
StageMode.PLAY:
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
_:
Input.set_default_cursor_shape(Input.CURSOR_CROSS)
func _apply_action_cursor() -> void:
if _action_cursor == null:
return
var hotspot := Vector2(_action_cursor.get_width() * 0.5, _action_cursor.get_height() * 0.5)
Input.set_custom_mouse_cursor(_action_cursor, Input.CURSOR_ARROW, hotspot)
func _build_action_cursor() -> void:
var size := 24
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
img.fill(Color(0.0, 0.0, 0.0, 0.0))
var color := Color(1.0, 0.7, 0.1, 1.0)
var center := Vector2(size, size) * 0.5
var radius := float(size) * 0.4
for y: int in size:
for x: int in size:
var p := Vector2(x, y) + Vector2(0.5, 0.5)
var d := p.distance_to(center)
if absf(d - radius) <= 1.5:
img.set_pixel(x, y, color)
if absf(p.x - center.x) <= 1.0 and p.y >= center.y - radius and p.y <= center.y + radius:
img.set_pixel(x, y, color)
if absf(p.y - center.y) <= 1.0 and p.x >= center.x - radius and p.x <= center.x + radius:
img.set_pixel(x, y, color)
_action_cursor = ImageTexture.create_from_image(img)
func _apply_popup_theme(popup: PopupMenu) -> void:
popup.add_theme_font_size_override("font_size", _action_popup_font_size)
# Style-variant font selection: bold/italic object-form flags take precedence,
# then the emoji font. Falls back to the engine default when none are set.
var font: Font = _emoji_font
if _action_popup_bold and _ui_font_bold != null:
font = _ui_font_bold
elif _action_popup_italic and _ui_font_italic != null:
font = _ui_font_italic
if font != null:
popup.add_theme_font_override("font", font)
# Consume the previously-dead action_popup_emoji_size: PopupMenu has no
# per-item font-size API in Godot 4.x, so when an emoji/style font is in
# use the configured emoji-glyph size becomes the effective menu font size
# (the menu draws every glyph with its one active font).
if _action_popup_emoji_size > 0:
popup.add_theme_font_size_override("font_size", _action_popup_emoji_size)
func _apply_ui_font(control: Control) -> void:
if control != null and _ui_font != null:
control.add_theme_font_override("font", _ui_font)
func _theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value("chrome.dialogs." + key, [])
if raw is Array and (raw as Array).size() == 2:
return Vector2i(maxi(320, int(raw[0])), maxi(180, int(raw[1])))
return fallback
## Dialogs are separate native/embedded windows, so they do not reliably
## inherit the CanvasLayer's visual defaults. Apply only local overrides.
func _apply_dialog_theme(dialog: Window) -> void:
if dialog == null:
return
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
dialog.add_theme_font_size_override("font_size", size)
if _ui_font != null:
dialog.add_theme_font_override("font", _ui_font)
if dialog is AcceptDialog:
var message: Label = (dialog as AcceptDialog).get_label()
if message != null:
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
message.add_theme_font_size_override("font_size", message_size)
if _ui_font != null: message.add_theme_font_override("font", _ui_font)
var ok: Button = (dialog as AcceptDialog).get_ok_button()
if ok != null:
ok.add_theme_font_size_override("font_size", size)
if _ui_font != null: ok.add_theme_font_override("font", _ui_font)
if dialog is ConfirmationDialog:
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
if cancel != null:
cancel.add_theme_font_size_override("font_size", size)
if _ui_font != null: cancel.add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(dialog, size)
func _apply_dialog_font_to_children(node: Node, size: int) -> void:
for child: Node in node.get_children():
if child is Control:
(child as Control).add_theme_font_size_override("font_size", size)
if _ui_font != null: (child as Control).add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(child, size)
func _update_cursor_coords() -> void:
if _status_cursor_coords == null:
return
if _camera == null or not is_instance_valid(_camera):
return
var viewport_pos: Vector2 = get_viewport().get_mouse_position()
if not _is_stage_frame_position(viewport_pos):
_status_cursor_coords.text = "X: — Y: —"
return
var world_pos := _camera.get_global_mouse_position()
_status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]
func _is_stage_frame_position(viewport_pos: Vector2) -> bool:
if _mode_frame == null:
return false
var viewport_size: Vector2 = get_window().size
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
viewport_size = Vector2(1280.0, 720.0)
var left: float = _mode_frame.offset_left
var top: float = _mode_frame.offset_top
var right: float = maxf(left + 1.0, viewport_size.x)
var bottom: float = maxf(top + 1.0, viewport_size.y + _mode_frame.offset_bottom)
return is_point_in_stage_frame(viewport_pos, Rect2(Vector2(left, top), Vector2(right - left, bottom - top)))
static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool:
return frame_rect.abs().has_point(viewport_pos)
func _update_stage_chrome_layout() -> void:
if _mode_frame == null:
return
var palette_width: float = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
var left_inset: float = palette_width if current_mode == StageMode.EDIT else 0.0
_mode_frame.offset_left = left_inset
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_right = 0.0
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
if _mode_badge != null:
_mode_badge.offset_left = left_inset + 12.0
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
func _sync_awaiting_cursor() -> void:
if _is_awaiting_click() != _last_awaiting_click:
_apply_cursor()
func _build_ui() -> void:
var ui := CanvasLayer.new()
ui.name = "UI"
add_child(ui)
# Mode frame (over the viewport, behind the top bar; amber border in DIRECT).
_mode_frame = Panel.new()
_mode_frame.name = "ModeFrame"
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_mode_frame.offset_left = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
var frame_style := StyleBoxFlat.new()
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
frame_style.border_color = _accent_direct
frame_style.set_border_width_all(3)
_mode_frame.add_theme_stylebox_override("panel", frame_style)
ui.add_child(_mode_frame)
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = TOP_BAR_HEIGHT
ui.add_child(top_bar)
var chrome := Control.new()
chrome.name = "Content"
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
top_bar.add_child(chrome)
var left_group := HBoxContainer.new()
left_group.name = "LeftControls"
left_group.mouse_filter = Control.MOUSE_FILTER_PASS
left_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
left_group.offset_left = 8.0
left_group.offset_top = 8.0
chrome.add_child(left_group)
var right_group := HBoxContainer.new()
right_group.name = "RightControls"
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
right_group.offset_right = -8.0
right_group.offset_top = 8.0
right_group.add_theme_constant_override("separation", 8)
chrome.add_child(right_group)
_file_menu = MenuButton.new()
_file_menu.name = "FileMenu"
_file_menu.text = "File"
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
_file_menu.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.file_menu", 22, 12, 48) if _sandbox_theme != null else 22)
var file_popup := _file_menu.get_popup()
var file_popup_font_size: int = _sandbox_theme.integer("fonts.sizes.file_menu_popup", 26, 12, 64) if _sandbox_theme != null else 26
file_popup.add_theme_font_size_override("font_size", file_popup_font_size)
file_popup.add_item("New", 0)
file_popup.add_item("Open…", 1)
var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent"
recent_popup.add_theme_font_size_override("font_size", file_popup_font_size)
recent_popup.id_pressed.connect(_on_recent_scene_selected)
file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent")
file_popup.add_item("Save", 2)
file_popup.add_item("Save As…", 3)
file_popup.add_item("Rename Scene…", 4)
file_popup.id_pressed.connect(_on_file_menu_id_pressed)
file_popup.about_to_popup.connect(_update_recent_menu)
left_group.add_child(_file_menu)
# This centered group is independent of left/right controls, preserving its
# window-center position at every supported stage width.
var mode_center := CenterContainer.new()
mode_center.name = "ModeCenter"
# This full-width layout wrapper must not shield File or utility controls.
mode_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
mode_center.offset_bottom = TOP_BAR_HEIGHT
chrome.add_child(mode_center)
var mode_switcher := HBoxContainer.new()
mode_switcher.name = "ModeSwitcher"
mode_switcher.mouse_filter = Control.MOUSE_FILTER_PASS
mode_switcher.add_theme_constant_override("separation", 4)
mode_center.add_child(mode_switcher)
var mode_defs: Array = [
[StageMode.EDIT, "✏️ Edit"],
[StageMode.DIRECT, "🎬 Direct"],
[StageMode.PLAY, "▶️ Play"],
]
for def: Array in mode_defs:
var mid: StageMode = def[0]
var btn := Button.new()
btn.text = String(def[1])
btn.toggle_mode = true
btn.custom_minimum_size = Vector2(_sandbox_theme.number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0) if _sandbox_theme != null else 124.0, 48.0)
btn.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
btn.toggled.connect(_on_mode_segment_toggled.bind(mid))
mode_switcher.add_child(btn)
_mode_buttons[int(mid)] = btn
_direct_hint_label = Label.new()
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
_direct_hint_label.visible = false
_apply_ui_font(_direct_hint_label)
right_group.add_child(_direct_hint_label)
_grid_check = CheckBox.new()
_grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid
_grid_check.toggled.connect(_on_grid_toggled)
right_group.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)
right_group.add_child(_snap_check)
_grid_size_label = Label.new()
_grid_size_label.text = "Size"
right_group.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)
right_group.add_child(_grid_size_spin)
# Graphical asset palette lives below chrome so stage tools keep their row.
_palette_dock = PanelContainer.new()
_palette_dock.name = "PaletteDock"
_palette_dock.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
_palette_dock.offset_top = TOP_BAR_HEIGHT
_palette_dock.offset_bottom = -STATUS_BAR_HEIGHT
_palette_dock.offset_right = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
if _sandbox_theme != null: _palette_dock.add_theme_stylebox_override("panel", _sandbox_theme.stylebox("palette"))
var scroll := ScrollContainer.new(); scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED; _palette_dock.add_child(scroll)
var cards := VBoxContainer.new(); cards.name = "PaletteCards"; cards.size_flags_horizontal = Control.SIZE_EXPAND_FILL; scroll.add_child(cards)
for entry: Dictionary in _spawner.get_palette_entries():
var card: Variant = ASSET_CARD.new()
card.configure(entry, null, _sandbox_theme)
card.toggle_mode = true
card.activated.connect(_on_palette_card_activated)
card.drag_started.connect(_begin_palette_drag)
cards.add_child(card)
_palette_buttons[String(entry["placement_id"])] = card
ui.add_child(_palette_dock)
# Bottom status bar (left = status, right = cursor world coords).
var bottom_bar := PanelContainer.new()
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
bottom_bar.offset_top = -STATUS_BAR_HEIGHT
ui.add_child(bottom_bar)
var status_hbox := HBoxContainer.new()
status_hbox.add_theme_constant_override("separation", 12)
bottom_bar.add_child(status_hbox)
_status_label = Label.new()
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_ui_font(_status_label)
status_hbox.add_child(_status_label)
_status_cursor_coords = Label.new()
_apply_ui_font(_status_cursor_coords)
status_hbox.add_child(_status_cursor_coords)
# Mode badge pill (top-left, below the top bar).
_mode_badge = PanelContainer.new()
_mode_badge.name = "ModeBadge"
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
_mode_badge.offset_left = (_sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0) + 12.0
var badge_style := StyleBoxFlat.new()
badge_style.bg_color = _accent_edit
badge_style.set_corner_radius_all(12)
badge_style.content_margin_left = 12.0
badge_style.content_margin_right = 12.0
badge_style.content_margin_top = 4.0
badge_style.content_margin_bottom = 4.0
_mode_badge.add_theme_stylebox_override("panel", badge_style)
_mode_badge_label = Label.new()
_mode_badge_label.add_theme_font_size_override("font_size", _status_pill_font_size)
_apply_ui_font(_mode_badge_label)
_mode_badge.add_child(_mode_badge_label)
ui.add_child(_mode_badge)
_run_controls = HBoxContainer.new()
_run_controls.name = "RunControls"
_run_controls.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_run_controls.offset_top = TOP_BAR_HEIGHT + 8.0
_run_controls.offset_right = -12.0
_run_controls.add_theme_constant_override("separation", 6)
ui.add_child(_run_controls)
_pause_button = Button.new()
_pause_button.text = "⏸️ Pause"
_pause_button.pressed.connect(func() -> void: set_mode(StageMode.DIRECT))
_run_controls.add_child(_pause_button)
_resume_button = Button.new()
_resume_button.text = "▶️ Resume"
_resume_button.pressed.connect(func() -> void: set_mode(StageMode.PLAY))
_run_controls.add_child(_resume_button)
_reset_button = Button.new()
_reset_button.text = "↺ Reset"
_reset_button.pressed.connect(func() -> void: set_mode(StageMode.EDIT))
_run_controls.add_child(_reset_button)
for button: Button in [_pause_button, _resume_button, _reset_button]:
button.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
_timestamp_badge = PanelContainer.new()
_timestamp_badge.name = "TimestampBadge"
_timestamp_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_timestamp_badge.offset_left = -220.0
_timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0
_timestamp_badge.offset_right = -12.0
_timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0
var timestamp_style := StyleBoxFlat.new()
timestamp_style.bg_color = _sandbox_theme.color("chrome.timestamp_badge.bg_color", Color("#171B24F2")) if _sandbox_theme != null else Color("#171B24F2")
timestamp_style.set_corner_radius_all(_sandbox_theme.integer("chrome.timestamp_badge.corner_radius", 10, 0, 48) if _sandbox_theme != null else 10)
var timestamp_padding: float = float(_sandbox_theme.integer("chrome.timestamp_badge.padding", 8, 0, 48)) if _sandbox_theme != null else 8.0
timestamp_style.content_margin_left = timestamp_padding
timestamp_style.content_margin_right = timestamp_padding
timestamp_style.content_margin_top = timestamp_padding * 0.5
timestamp_style.content_margin_bottom = timestamp_padding * 0.5
_timestamp_badge.add_theme_stylebox_override("panel", timestamp_style)
_timestamp_label = Label.new()
_timestamp_label.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.timestamp_badge", 22, 12, 48) if _sandbox_theme != null else 22)
_timestamp_label.add_theme_color_override("font_color", _sandbox_theme.color("global.text_color", Color("#F5F7FA")) if _sandbox_theme != null else Color("#F5F7FA"))
_timestamp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timestamp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_timestamp_label.custom_minimum_size = Vector2(192.0, 30.0)
_apply_ui_font(_timestamp_label)
_timestamp_badge.add_child(_timestamp_label)
ui.add_child(_timestamp_badge)
# Action tooltip (cursor-attached, hidden unless a click-awaiting step is active).
_tooltip = Label.new()
_tooltip.name = "ActionTooltip"
_tooltip.visible = false
_tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
var tooltip_style := StyleBoxFlat.new()
tooltip_style.bg_color = Color(0.1, 0.1, 0.1, 0.9)
tooltip_style.border_color = _accent_direct
tooltip_style.set_border_width_all(1)
tooltip_style.set_corner_radius_all(8)
tooltip_style.content_margin_left = 10.0
tooltip_style.content_margin_right = 10.0
tooltip_style.content_margin_top = 4.0
tooltip_style.content_margin_bottom = 4.0
_tooltip.add_theme_stylebox_override("normal", tooltip_style)
_tooltip.add_theme_font_size_override("font_size", _tooltip_font_size)
_tooltip.add_theme_color_override("font_color", Color.WHITE)
_apply_ui_font(_tooltip)
ui.add_child(_tooltip)
_sync_run_controls()
_set_timestamp(0.0)
_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.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP)
_action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP)
_action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP)
_action_popup.add_separator()
_action_popup.add_item("⚡ When...", ACT_WHEN)
_action_popup.add_separator()
_action_popup.add_item("📋 Edit Queue…", ACT_EDIT_QUEUE)
_action_popup.add_item("⚡ Edit Rules…", ACT_EDIT_RULES)
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
_action_popup.about_to_popup.connect(_refresh_action_popup_items)
ui.add_child(_action_popup)
_trigger_popup = PopupMenu.new()
_trigger_popup.add_item("📍 Arrives at a waypoint", TRIG_ARRIVED)
_trigger_popup.add_item("✅ Completes any action", TRIG_ACTION_FINISHED)
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
_trigger_popup.add_item("🫴 Picks up a prop", TRIG_PROP_PICKED_UP)
_trigger_popup.add_item("⬇️ Drops a prop", TRIG_PROP_DROPPED)
_trigger_popup.add_item("🏹 Throws a prop", TRIG_PROP_THROWN)
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
ui.add_child(_trigger_popup)
_rule_action_popup = PopupMenu.new()
_rule_action_popup.add_item("🚶 Walk To", ACT_WALK)
_rule_action_popup.add_item("💬 Speak", ACT_SPEAK)
_rule_action_popup.add_item("⏳ Wait", ACT_WAIT)
_rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_rule_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_rule_action_popup.add_item("🫴 Pick Up Prop…", ACT_PICK_UP_PROP)
_rule_action_popup.add_item("⬇️ Drop Prop", ACT_DROP_PROP)
_rule_action_popup.add_item("🏹 Throw Prop…", ACT_THROW_PROP)
_rule_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
ui.add_child(_rule_action_popup)
_rule_more_popup = PopupMenu.new()
_rule_more_popup.add_item("➕ Add another action", RULE_MORE_ADD)
_rule_more_popup.add_item("✅ Done", RULE_MORE_DONE)
_rule_more_popup.id_pressed.connect(_on_rule_more_id_pressed)
ui.add_child(_rule_more_popup)
_speak_dialog = AcceptDialog.new()
_speak_dialog.title = "Speak"
_speak_dialog.confirmed.connect(_on_speak_confirmed)
var speak_box := VBoxContainer.new()
_speak_dialog.add_child(speak_box)
_speak_edit = LineEdit.new()
_speak_edit.placeholder_text = "Say something…"
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
speak_box.add_child(_speak_edit)
_speak_stop_current_check = CheckBox.new()
_speak_stop_current_check.text = "Stop what he's doing?"
_speak_stop_current_check.visible = false
speak_box.add_child(_speak_stop_current_check)
_speak_dialog.register_text_enter(_speak_edit)
ui.add_child(_speak_dialog)
_apply_dialog_theme(_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)
_apply_dialog_theme(_wait_dialog)
_apply_popup_theme(_action_popup)
_apply_popup_theme(_trigger_popup)
_apply_popup_theme(_rule_action_popup)
_apply_popup_theme(_rule_more_popup)
_selector_dim = ColorRect.new()
_selector_dim.name = "SelectorDim"
_selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
_selector_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_selector_dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_selector_dim.visible = false
ui.add_child(_selector_dim)
_selector = ASSET_SELECTOR.instantiate() as AssetSelector
_selector.item_selected.connect(_on_asset_selected)
_selector.cancelled.connect(_on_selector_cancelled)
_selector.popup_hide.connect(_on_selector_cancelled)
_selector.browse_requested.connect(_on_browse_requested)
_selector.refresh_requested.connect(_on_refresh_requested)
ui.add_child(_selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
_selector.apply_font(_ui_font, _emoji_font)
_selector.apply_theme(_sandbox_theme)
_browse_dialog = FileDialog.new()
_browse_dialog.title = "Open .stk"
_browse_dialog.access = FileDialog.ACCESS_FILESYSTEM
_browse_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_browse_dialog.file_selected.connect(_on_browse_file_selected)
ui.add_child(_browse_dialog)
_scene_open_dialog = FileDialog.new()
_scene_open_dialog.name = "SceneOpenDialog"
_scene_open_dialog.access = FileDialog.ACCESS_FILESYSTEM
_scene_open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_scene_open_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
_scene_open_dialog.file_selected.connect(func(path: String) -> void: _load_scene_from_path(path))
ui.add_child(_scene_open_dialog)
_scene_save_dialog = FileDialog.new()
_scene_save_dialog.name = "SceneSaveDialog"
_scene_save_dialog.access = FileDialog.ACCESS_FILESYSTEM
_scene_save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_scene_save_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
_scene_save_dialog.file_selected.connect(_on_scene_save_path_selected)
_scene_save_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
ui.add_child(_scene_save_dialog)
_rename_scene_dialog = ConfirmationDialog.new()
_rename_scene_dialog.name = "RenameSceneDialog"
_rename_scene_dialog.title = "Rename Scene"
_rename_scene_dialog.cancel_button_text = "Cancel"
_rename_scene_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_scene_name_edit = LineEdit.new()
_scene_name_edit.name = "SceneNameEdit"
_scene_name_edit.placeholder_text = "Scene name"
_scene_name_edit.custom_minimum_size = Vector2(460.0, 0.0)
_rename_scene_dialog.add_child(_scene_name_edit)
_rename_scene_dialog.confirmed.connect(func() -> void:
var scene_title := _scene_name_edit.text.strip_edges()
if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty())
ui.add_child(_rename_scene_dialog)
_apply_dialog_theme(_rename_scene_dialog)
_missing_assets_dialog = AcceptDialog.new()
_missing_assets_dialog.name = "MissingAssetsDialog"
_missing_assets_dialog.title = "Missing Scene Assets"
_missing_assets_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
ui.add_child(_missing_assets_dialog)
_apply_dialog_theme(_missing_assets_dialog)
_cli_error_dialog = AcceptDialog.new()
_cli_error_dialog.name = "CliErrorDialog"
_cli_error_dialog.title = "Could Not Load Scene"
_cli_error_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
ui.add_child(_cli_error_dialog)
_apply_dialog_theme(_cli_error_dialog)
_unsaved_changes_dialog = ConfirmationDialog.new()
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "⚠️ Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"; _unsaved_changes_dialog.ok_button_text = "💾 Save"
_unsaved_changes_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
_unsaved_changes_dialog.confirmed.connect(_on_unsaved_save)
_unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard())
_unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
ui.add_child(_unsaved_changes_dialog)
_apply_dialog_theme(_unsaved_changes_dialog)
# ---- Phase 3c editor tools ----
_rig_context_popup = PopupMenu.new()
_rig_context_popup.add_item("📋 Edit Queue…", RIG_CTX_EDIT_QUEUE)
_rig_context_popup.add_item("⚡ Edit Rules…", RIG_CTX_EDIT_RULES)
_rig_context_popup.id_pressed.connect(_on_rig_context_id_pressed)
_rig_context_popup.about_to_popup.connect(_refresh_rig_context_items)
ui.add_child(_rig_context_popup)
_apply_popup_theme(_rig_context_popup)
_waypoint_context = WAYPOINT_CONTEXT.new()
_waypoint_context.id_pressed.connect(_on_waypoint_context_id_pressed)
ui.add_child(_waypoint_context)
_apply_popup_theme(_waypoint_context)
_confirm_dialog = ConfirmationDialog.new()
_confirm_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_confirm_dialog.confirmed.connect(_on_confirm_confirmed)
ui.add_child(_confirm_dialog)
_apply_dialog_theme(_confirm_dialog)
_queue_panel = QUEUE_PANEL.instantiate() as QueuePanel
_queue_panel.edit_requested.connect(_on_queue_panel_edit_requested)
_queue_panel.delete_requested.connect(_on_queue_panel_delete_requested)
_queue_panel.add_requested.connect(_on_queue_panel_add_requested)
_queue_panel.clear_requested.connect(_on_queue_panel_clear_requested)
ui.add_child(_queue_panel)
_queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
_queue_panel.min_size = _theme_dialog_size("queue_size", Vector2i(680, 520))
_rule_panel = RULE_PANEL.instantiate() as RulePanel
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
_rule_panel.delete_requested.connect(_on_rule_panel_delete_requested)
_rule_panel.add_requested.connect(_on_rule_panel_add_requested)
_rule_panel.clear_requested.connect(_on_rule_panel_clear_requested)
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
ui.add_child(_rule_panel)
_rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
_rule_panel.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
_action_editor = ACTION_EDITOR.instantiate() as ActionEditor
_action_editor.committed.connect(_on_action_editor_committed)
_action_editor.cancelled.connect(_on_action_editor_cancelled)
_action_editor.target_requested.connect(_on_action_editor_target_requested)
ui.add_child(_action_editor)
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor
_rule_editor.committed.connect(_on_rule_editor_committed)
_rule_editor.cancelled.connect(_on_rule_editor_cancelled)
_rule_editor.trigger_target_requested.connect(_on_rule_editor_trigger_target_requested)
_rule_editor.action_add_requested.connect(_on_rule_editor_action_add_requested)
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
ui.add_child(_rule_editor)
_rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_rule_editor.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
func _build_gizmo_layer() -> void:
_gizmos = STAGE_GIZMOS.new()
_gizmos.name = "GizmoLayer"
_gizmos.camera = _camera
_gizmos.transform_committed.connect(_on_transform_committed)
_gizmos.transform_started.connect(_on_gizmo_transform_started)
_gizmos.transform_updated.connect(_on_gizmo_transform_updated)
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_placement_overlay() -> void:
_placement_overlay = STAGE_PLACEMENT_OVERLAY.new()
_placement_overlay.name = "PlacementOverlay"
_placement_overlay.camera = _camera
add_child(_placement_overlay)
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 navigation from exposed top edges only. Each qualifying terrain edge
## becomes a shallow ribbon just above its collision surface, so agents route
## over floors/ramps rather than through solid terrain polygons.
func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region):
return
var verts := PackedVector2Array()
var triangles: Array[PackedInt32Array] = []
var surfaces: Array[Dictionary] = []
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)
for i: int in pts.size():
var a := pts[i]
var b := pts[(i + 1) % pts.size()]
var edge := b - a
if edge.length_squared() <= 0.001:
continue
# TerrainUtils stores clockwise polygons; this right normal is
# outward, so only its upward-facing edges are walkable.
var outward := Vector2(edge.y, -edge.x).normalized()
if outward.y > -0.15:
continue
surfaces.append({"a": a, "b": b, "normal": outward})
for surface: Dictionary in surfaces:
_append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"])
# Join only short, climbable gaps between exposed surface ends. This connects
# authored stair treads without making cliffs or terrain interiors walkable.
for i: int in surfaces.size():
for j: int in range(i + 1, surfaces.size()):
for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]:
for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]:
if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
_append_navigation_ribbon(verts, triangles, a, b, Vector2.UP)
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()])
func _append_navigation_ribbon(
verts: PackedVector2Array,
triangles: Array[PackedInt32Array],
a: Vector2,
b: Vector2,
normal: Vector2
) -> void:
if a.distance_squared_to(b) <= 0.001:
return
var base: int = verts.size()
verts.append_array(PackedVector2Array([
a + normal * 4.0, b + normal * 4.0,
b - normal * 12.0, a - normal * 12.0,
]))
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
triangles.append(PackedInt32Array([base, base + 2, base + 3]))
# ---------------------------------------------------------------------------
# Signal handlers
# ---------------------------------------------------------------------------
func _on_mode_segment_toggled(pressed: bool, mode: StageMode) -> void:
if pressed:
set_mode(mode)
else:
if current_mode == mode:
(_mode_buttons[int(mode)] as Button).set_pressed_no_signal(true)
func _on_palette_toggled(pressed: bool, id: String) -> void:
if pressed:
if id == "stickman" or id == "prop":
_open_selector(id)
return
set_placement_mode(id)
else:
if _selector_open and _selector_kind == id:
_close_selector()
elif _placement_id == id:
set_placement_mode("")
func _on_palette_card_activated(payload: Dictionary) -> void:
var id: String = String(payload.get("placement_id", ""))
if id.is_empty(): return
if bool(payload.get("opens_selector", false)):
_open_selector(id)
else:
set_placement_mode(id)
func _begin_palette_drag(payload: Dictionary) -> void:
if current_mode != StageMode.EDIT or _selector_open: return
var id: String = String(payload.get("placement_id", ""))
if id.is_empty(): return
_palette_drag_payload = payload.duplicate(true)
_palette_drag_previous_id = _placement_id
_placement_id = id
_spawn_ghost()
func _update_palette_drag(viewport_pos: Vector2) -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.global_position = _viewport_to_world(viewport_pos) + _spawner.get_spawn_offset(_placement_id)
func _finish_palette_drag(viewport_pos: Vector2) -> void:
var id: String = _placement_id
if _is_stage_drop_position(viewport_pos):
_place_at(_viewport_to_world(viewport_pos))
_palette_drag_payload.clear()
_placement_id = _palette_drag_previous_id
_palette_drag_previous_id = ""
if _placement_id.is_empty(): _free_ghost()
else: _spawn_ghost()
for pid: String in _palette_buttons: (_palette_buttons[pid] as Button).set_pressed_no_signal(pid == _placement_id)
func _cancel_palette_drag() -> void:
_palette_drag_payload.clear()
_placement_id = _palette_drag_previous_id
_palette_drag_previous_id = ""
if _placement_id.is_empty(): _free_ghost()
else: _spawn_ghost()
func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
var rect: Rect2 = get_viewport().get_visible_rect()
if not rect.has_point(viewport_pos): return false
var hovered: Control = get_viewport().gui_get_hovered_control()
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
func _open_selector(kind: String) -> void:
for pid: String in _palette_buttons:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
_selector_kind = kind
var entries: Array[Dictionary]
if kind == "stickman":
entries = _stickman_library.scan()
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
else:
entries = PROP_LIBRARY.scan()
if kind == "stickman" and entries.size() == 1:
_on_asset_selected(entries[0])
return
_selector_open = true
if _selector_dim != null:
_selector_dim.visible = true
_selector.open(kind, entries)
_load_or_enqueue_thumbnails(kind, entries)
func _on_asset_selected(entry: Dictionary) -> void:
var kind := _selector_kind
if kind == "stickman":
_spawner.selected_stickman_path = String(entry.get("path", ""))
else:
_spawner.selected_prop_id = String(entry.get("id", ""))
_close_selector()
set_placement_mode(kind)
func _close_selector() -> void:
_selector_open = false
_selector_kind = ""
_thumbnail_generation += 1
_thumbnail_queue.clear()
if _selector_dim != null:
_selector_dim.visible = false
if _selector != null:
_selector.hide()
func _on_selector_cancelled() -> void:
if not _selector_open:
return
_close_selector()
set_placement_mode("")
func _on_browse_requested() -> void:
if _browse_dialog != null:
if _selector_kind == "prop":
_browse_dialog.title = "Open .prop"
_browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
else:
_browse_dialog.title = "Open .stk"
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_browse_dialog.popup_centered()
func _on_browse_file_selected(path: String) -> void:
var entry: Dictionary = PROP_LIBRARY.make_custom_entry(path) if _selector_kind == "prop" else _stickman_library.make_entry(path)
if entry.is_empty():
_show_toast("Could not load %s: %s" % [_selector_kind, path])
return
_on_asset_selected(entry)
func _on_refresh_requested() -> void:
if _selector_kind == "prop":
var prop_entries: Array[Dictionary] = PROP_LIBRARY.scan()
_selector.set_entries(prop_entries)
_load_or_enqueue_thumbnails("prop", prop_entries)
return
var entries: Array[Dictionary] = _stickman_library.scan()
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
_selector.set_entries(entries)
_load_or_enqueue_thumbnails("stickman", entries)
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
_thumbnail_queue.clear()
_thumbnail_generation += 1
var generation: int = _thumbnail_generation
for entry: Dictionary in entries:
if kind == "stickman":
var path := String(entry.get("path", ""))
var key := _thumbnail_cache.stickman_key(path)
var png := _thumbnail_cache.stickman_png(key)
var tex := _thumbnail_cache.get_texture(png)
if tex != null:
if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex)
else:
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
else:
var id := String(entry.get("id", ""))
var png := _thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(id)
var tex := _thumbnail_cache.get_texture(png)
if tex != null:
if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex)
else:
_thumbnail_queue.append({ "kind": kind, "entry": entry, "cache_path": png, "generation": generation })
func _drain_thumbnail_queue() -> void:
if _thumbnail_busy or _thumbnail_queue.is_empty():
return
_thumbnail_busy = true
var job: Dictionary = _thumbnail_queue.pop_front()
var kind := String(job["kind"])
var entry: Dictionary = job["entry"]
var tex: Texture2D = null
if kind == "stickman":
var key := String(job["key"])
var data: Dictionary = entry["data"]
tex = await _stickman_thumb.render(data)
if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
else:
if bool(entry.get("is_custom", false)):
tex = await _prop_thumb.render_shapes(entry.get("shapes", []) as Array[Dictionary])
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if tex != null:
var fallback := _thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(String(entry["id"]))
_thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), tex)
_thumbnail_busy = false
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
_selector.set_thumbnail(entry, tex)
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:
if node is RigidBody2D:
_edit_physics_targets[node] = node.global_transform
_sync_editable_physics_transform(node)
_save_object_state(node)
if node is TerrainBlock:
_nav_dirty = true
_rebuild_grid_cells()
_director_visuals.mark_dirty()
_edit_physics_settle_frames = 3
_mark_scene_dirty()
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0:
_request_pending_file_action(PendingFileAction.NEW_SCENE)
1:
_request_pending_file_action(PendingFileAction.OPEN_DIALOG)
2:
if _current_scene_path.is_empty(): _scene_save_dialog.popup_centered_ratio(0.7)
else: _save_scene_to_path(_current_scene_path)
3: _scene_save_dialog.popup_centered_ratio(0.7)
4: _rename_scene_prompt()
func _on_scene_save_path_selected(path: String) -> void:
var final_path := path
if final_path.get_extension().to_lower() != "scene": final_path = final_path.get_basename() + ".scene"
if _save_scene_to_path(final_path) and _pending_file_action != PendingFileAction.NONE:
_continue_pending_file_action()
func _rename_scene_prompt() -> void:
_scene_name_edit.text = _scene_name
_rename_scene_dialog.popup_centered()
call_deferred("_focus_scene_name_edit")
func _focus_scene_name_edit() -> void:
if _scene_name_edit == null or not _scene_name_edit.visible:
return
_scene_name_edit.grab_focus()
_scene_name_edit.select_all()
func _request_pending_file_action(action: PendingFileAction, path: String = "") -> void:
_pending_file_action = action; _pending_open_path = path
if _dirty:
_unsaved_changes_dialog.popup_centered()
else:
_continue_pending_file_action()
func _continue_pending_file_action() -> void:
if _unsaved_changes_dialog != null:
_unsaved_changes_dialog.hide()
var action := _pending_file_action; var path := _pending_open_path
_pending_file_action = PendingFileAction.NONE; _pending_open_path = ""
match action:
PendingFileAction.NEW_SCENE:
_reset_scene_content(); _current_scene_path = ""; _scene_name = DEFAULT_SCENE_NAME; _scene_created_unix = Time.get_unix_time_from_system(); _camera.global_position = DEFAULT_CAMERA_POSITION; _camera.zoom = Vector2.ONE * DEFAULT_CAMERA_ZOOM; _set_scene_dirty(false)
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
PendingFileAction.OPEN_DIALOG: _scene_open_dialog.popup_centered_ratio(0.7)
PendingFileAction.OPEN_PATH: _load_scene_from_path(path)
PendingFileAction.QUIT:
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
get_tree().quit()
func _on_unsaved_save() -> void:
_unsaved_changes_dialog.hide()
if _current_scene_path.is_empty():
_scene_save_dialog.popup_centered_ratio(0.7)
return
if _save_scene_to_path(_current_scene_path): _continue_pending_file_action()
func _on_unsaved_discard() -> void:
_unsaved_changes_dialog.hide()
_continue_pending_file_action()
func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void:
_restore_frames_left = 0
_edit_physics_targets.clear()
_edit_physics_settle_frames = 0
func _on_gizmo_transform_updated(nodes: Array[Node2D]) -> void:
for node: Node2D in nodes:
if node is RigidBody2D:
_edit_physics_targets[node] = node.global_transform
_sync_editable_physics_transform(node)
func _sync_pending_editable_physics_transforms() -> void:
if _edit_physics_targets.is_empty() or current_mode != StageMode.EDIT:
return
for node: RigidBody2D in _edit_physics_targets.keys().duplicate():
if node == null or not is_instance_valid(node):
_edit_physics_targets.erase(node)
continue
node.global_transform = _edit_physics_targets[node]
_sync_editable_physics_transform(node)
if not _gizmos.is_dragging() and _edit_physics_settle_frames > 0:
_edit_physics_settle_frames -= 1
if _edit_physics_settle_frames == 0:
_edit_physics_targets.clear()
func _sync_editable_physics_transform(node: Node2D) -> void:
if not (node is RigidBody2D):
return
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
var target_transform: Transform2D = body.global_transform
var rid := body.get_rid()
if rid.is_valid():
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, target_transform)
body.global_transform = target_transform
body.reset_physics_interpolation()
# ---------------------------------------------------------------------------
# Director tool (Phase 3a)
# ---------------------------------------------------------------------------
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
_apply_cursor()
_refresh_status()
return
var hit := _selection.hit_test(world_pos)
if hit is STICKMAN_RIG:
_context_rig = hit as StickmanRig
var screen := _world_to_screen(hit.global_position)
var rect := Rect2i(Vector2i(screen) + Vector2i(24, 0), Vector2i.ZERO)
_set_popup_anchor(rect)
_action_popup.popup(rect)
## Re-enables/disables the "📋 Edit Queue…" / "⚡ Edit Rules…" items in the Direct
## action popup based on the context rig's queue/rules (entry-point gating, spec §2
## decision 8). Runs on about_to_popup.
func _refresh_action_popup_items() -> void:
if _action_popup == null:
return
var rig := _context_rig
var valid := rig != null and is_instance_valid(rig)
_disable_popup_item(_action_popup, ACT_EDIT_QUEUE, not valid or rig.get_queue().is_empty())
_disable_popup_item(_action_popup, ACT_EDIT_RULES, not valid or not _rig_has_rules(rig))
## Same gating for the stickman right-click context menu.
func _refresh_rig_context_items() -> void:
if _rig_context_popup == null:
return
var rig := _panel_rig
var valid := rig != null and is_instance_valid(rig)
_disable_popup_item(_rig_context_popup, RIG_CTX_EDIT_QUEUE, not valid or rig.get_queue().is_empty())
_disable_popup_item(_rig_context_popup, RIG_CTX_EDIT_RULES, not valid or not _rig_has_rules(rig))
## Disables/enables a PopupMenu item by id (set_item_disabled is index-based and
## the edit items are not at id==index positions once separators are present).
func _disable_popup_item(popup: PopupMenu, id: int, disabled: bool) -> void:
var idx := popup.get_item_index(id)
if idx >= 0:
popup.set_item_disabled(idx, disabled)
## True when the given rig is the source of at least one rule in _event_rules.
func _rig_has_rules(rig: StickmanRig) -> bool:
if rig == null or not is_instance_valid(rig):
return false
var id := rig.get_instance_id()
for rule: Dictionary in _event_rules:
var trigger: Dictionary = rule.get("trigger", {})
if int(trigger.get("source", -1)) == id:
return true
return false
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
_apply_cursor()
_refresh_status()
ACT_SPEAK:
_speak_edit.text = ""
_speak_stop_current_check.visible = false
_speak_stop_current_check.button_pressed = false
_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" })
ACT_PICK_UP_PROP:
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_direct_pickup_prop, _cb_direct_capture_cancel)
ACT_DROP_PROP:
_context_rig.queue_action({ "type": "drop_prop" })
ACT_THROW_PROP:
_begin_throw_position_capture(
_context_rig,
"🏹 Click where to throw (Esc or RMB to cancel)",
_cb_direct_throw_target,
_cb_direct_capture_cancel,
_throw_preview_prefix_for(_context_rig)
)
ACT_WHEN:
_begin_rule_build(_context_rig.get_instance_id())
ACT_EDIT_QUEUE:
_open_queue_panel(_context_rig)
ACT_EDIT_RULES:
_open_rules_panel_for_rig(_context_rig)
func _cb_direct_pickup_prop(value: Node2D) -> void:
if _context_rig != null and is_instance_valid(_context_rig) and value is PropBlock:
var prop := value as PropBlock
if not prop.stage_object_id.is_empty():
_context_rig.queue_action({ "type": "pick_up_prop", "target_prop_id": prop.stage_object_id, "hand": "auto" })
else:
_show_toast("That prop is missing its scene ID")
_context_rig = null
func _cb_direct_throw_target(value: Vector2) -> void:
if _context_rig != null and is_instance_valid(_context_rig):
_context_rig.queue_action({ "type": "throw_prop", "target_position": value })
_context_rig = null
func _cb_direct_capture_cancel() -> void:
_context_rig = null
# ---------------------------------------------------------------------------
# Director UX (Phase 4b): cursor-attached tooltip, trajectory, ghost marker
# ---------------------------------------------------------------------------
func _is_awaiting_click() -> bool:
if _pending_walk_target:
return true
if _capture_kind != CaptureKind.NONE:
return true
return _rule_step == RuleStep.TRIGGER_TARGET \
or _rule_step == RuleStep.ACTION_TARGET \
or _rule_step == RuleStep.ACTION_POSITION
func _action_hint_text() -> String:
if _pending_walk_target:
return "🚩 Click to set walk target"
if _capture_kind != CaptureKind.NONE:
if _capture_kind == CaptureKind.THROW_POSITION and not _throw_preview_cache.is_empty():
if not bool(_throw_preview_cache.get("preview_available", true)):
return "ℹ %s Click to set the throw target." % String(_throw_preview_cache.get("preview_reason", "No throw preview is available."))
var preview_hint := String(_throw_preview_cache.get("preview_hint", ""))
if not preview_hint.is_empty():
return "%s %s" % [_capture_hint, preview_hint]
return _capture_hint
match _rule_step:
RuleStep.TRIGGER_TARGET:
var trigger: Dictionary = _rule_builder.get("trigger", {})
match String(trigger.get("type", "")):
"arrived_at_waypoint":
return "📍 Click the waypoint"
"entered_area":
return "🎯 Click the trigger area"
"collided":
return "💥 Click the prop"
_:
return "🎯 Click the target"
RuleStep.ACTION_TARGET:
return "🎯 Click the stickman"
RuleStep.ACTION_POSITION:
return "🚩 Click where to walk"
return ""
func _action_origin() -> Vector2:
if _capture_kind == CaptureKind.THROW_POSITION and _capture_throw_preview_rig != null and is_instance_valid(_capture_throw_preview_rig):
if not _throw_preview_cache.is_empty():
return _throw_preview_cache.get("start_position", _capture_throw_preview_rig.global_position)
return _capture_throw_preview_rig.global_position
if _pending_walk_target:
if _context_rig != null and is_instance_valid(_context_rig):
return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
return Vector2.ZERO
if _capture_kind != CaptureKind.NONE:
if _walk_edit_rig != null and is_instance_valid(_walk_edit_rig):
return _walk_edit_rig.global_position - StickmanLocomotion.FOOT_OFFSET
if _panel_rig != null and is_instance_valid(_panel_rig):
return _panel_rig.global_position - StickmanLocomotion.FOOT_OFFSET
return Vector2.ZERO
if _rule_step != RuleStep.IDLE:
if _rule_context_rig != null and is_instance_valid(_rule_context_rig):
return _rule_context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
var trigger: Dictionary = _rule_builder.get("trigger", {})
var src := instance_from_id(int(trigger.get("source", -1)))
if src is Node2D and is_instance_valid(src):
return (src as Node2D).global_position
return Vector2.ZERO
func _is_target_valid(pos: Vector2) -> bool:
var cell := _world_to_grid_cell(pos)
for n in _grid_cells.get(cell, []):
if n is TerrainBlock and STAGE_SELECTION.get_world_aabb(n).has_point(pos):
return false
return true
func _update_action_overlay() -> void:
var awaiting := _is_awaiting_click()
if _placement_overlay != null:
if awaiting:
var origin := _action_origin()
var target := _camera.get_global_mouse_position()
if _snap_enabled and (_rule_step == RuleStep.ACTION_POSITION or _capture_kind == CaptureKind.THROW_POSITION):
target = _snap_to_grid(target)
if _capture_kind == CaptureKind.THROW_POSITION:
if _capture_throw_preview_rig == null or not is_instance_valid(_capture_throw_preview_rig):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var throw_preview := _throw_preview_cache
if throw_preview.is_empty():
_placement_overlay.set_throw_preview_unavailable(target)
elif not bool(throw_preview.get("preview_available", true)):
_placement_overlay.set_throw_preview_unavailable(target)
else:
var trajectory_points: PackedVector2Array = throw_preview.get("trajectory_points", PackedVector2Array())
var effective_target: Vector2 = throw_preview.get("effective_target", target)
var collision_position: Vector2 = throw_preview.get("collision_position", Vector2.INF)
var target_reachable := bool(throw_preview.get("target_reachable", true))
var preview_planned := bool(throw_preview.get("preview_planned", false))
var preview_uncertain := bool(throw_preview.get("preview_uncertain", false))
_placement_overlay.set_throw_trajectory(
throw_preview["start_position"],
target,
throw_preview["landing_position"],
target_reachable,
trajectory_points,
effective_target,
collision_position,
preview_planned,
preview_uncertain
)
else:
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
else:
_placement_overlay.clear_action()
if _tooltip != null:
_tooltip.visible = awaiting
if awaiting:
_tooltip.text = _action_hint_text()
var sb: StyleBox = _tooltip.get_theme_stylebox("normal")
var extra := sb.get_minimum_size() if sb != null else Vector2.ZERO
_tooltip.size = _tooltip.get_minimum_size() + extra
_update_tooltip_position()
func _update_tooltip_position() -> void:
if _tooltip == null:
return
var mouse := get_viewport().get_mouse_position()
var size := _tooltip.size
var pos := mouse + Vector2(20.0, -20.0)
var vp := get_viewport().get_visible_rect().size
if pos.x + size.x > vp.x:
pos.x = mouse.x - size.x - 20.0
if pos.y + size.y > vp.y:
pos.y = mouse.y - size.y - 20.0
if pos.y < 0.0:
pos.y = mouse.y + 20.0
_tooltip.position = pos
func _on_speak_confirmed() -> void:
if _rule_step == RuleStep.PARAMS:
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0, "stop_current": _speak_stop_current_check.button_pressed })
_open_rule_more_popup()
return
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 _rule_step == RuleStep.PARAMS:
_set_rule_action_params({ "duration": _wait_spin.value })
_open_rule_more_popup()
return
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
# ---------------------------------------------------------------------------
# Rule / event system (Phase 4)
# ---------------------------------------------------------------------------
## Popup rect at the current mouse position.
func _mouse_popup_rect() -> Rect2i:
var mouse := get_viewport().get_mouse_position()
return Rect2i(Vector2i(mouse), Vector2i.ZERO)
## Records the session popup anchor (the first context menu's screen rect).
func _set_popup_anchor(rect: Rect2i) -> void:
_popup_anchor = rect
_popup_anchor_set = true
## Clears the session popup anchor (on rule confirm or cancel).
func _clear_popup_anchor() -> void:
_popup_anchor = Rect2i()
_popup_anchor_set = false
## Returns the session anchor, recording it from the mouse on first use so a
## rule-builder flow that never passed through a first menu still has one.
func _popup_anchor_rect() -> Rect2i:
if not _popup_anchor_set:
_set_popup_anchor(_mouse_popup_rect())
return _popup_anchor
## Starts a fresh rule build with the given trigger source (instance id; -1 for
## "any stickman"). Used by both the Phase 4 "When…" action and the Phase 3c
## Rule Panel's "Add Rule" button.
func _begin_rule_build(source_id: int, from_panel: bool = false) -> void:
_rule_build_from_panel = from_panel
_rule_builder = {
"trigger": { "source": source_id, "target": -1, "params": {} },
"actions": [],
}
_rule_editing_id = -1
var src := instance_from_id(source_id)
_rule_context_rig = src if src is StickmanRig else null
_rule_step = RuleStep.SELECT_TRIGGER
_trigger_popup.popup(_popup_anchor_rect())
func _open_rule_action_popup() -> void:
_sync_rule_action_done_item()
_rule_action_popup.popup(_popup_anchor_rect())
## Shows the "✅ Done" item in the rule-action popup only while editing an
## existing rule (otherwise there is no way to save without adding an action).
func _sync_rule_action_done_item() -> void:
var idx: int = _rule_action_popup.get_item_index(RULE_ACTION_DONE)
var editing: bool = _rule_editing_id >= 0
if editing and idx < 0:
_rule_action_popup.add_item("✅ Done", RULE_ACTION_DONE)
elif not editing and idx >= 0:
_rule_action_popup.remove_item(idx)
func _open_rule_more_popup() -> void:
_rule_more_popup.popup(_popup_anchor_rect())
func _on_trigger_popup_id_pressed(id: int) -> void:
if id == TRIG_BACK:
_reset_rule_builder()
if _context_rig != null and is_instance_valid(_context_rig):
_action_popup.popup(_popup_anchor_rect())
_refresh_status()
return
var trigger: Dictionary = _rule_builder.get("trigger", {})
trigger["type"] = _trigger_type_string(id)
_rule_builder["trigger"] = trigger
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED or id == TRIG_PROP_PICKED_UP or id == TRIG_PROP_DROPPED or id == TRIG_PROP_THROWN:
_rule_step = RuleStep.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
_rule_hint = "Click the waypoint to trigger on (Esc or RMB to cancel)"
TRIG_ENTERED_AREA:
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN:
_rule_hint = "Click the prop to watch (Esc or RMB to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
func _on_rule_action_popup_id_pressed(id: int) -> void:
if id == RULE_ACTION_DONE:
_finalize_rule()
return
var action := { "type": _action_type_string(id), "target": -1, "params": {} }
var actions: Array = _rule_builder.get("actions", [])
actions.append(action)
_rule_builder["actions"] = actions
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will %s (Esc or RMB to cancel)" % _action_verb(id)
_refresh_status()
func _on_rule_more_id_pressed(id: int) -> void:
match id:
RULE_MORE_ADD:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
RULE_MORE_DONE:
_finalize_rule()
func _trigger_type_string(id: int) -> String:
match id:
TRIG_ARRIVED:
return "arrived_at_waypoint"
TRIG_ACTION_FINISHED:
return "action_finished"
TRIG_SPEECH_FINISHED:
return "speech_finished"
TRIG_ENTERED_AREA:
return "entered_area"
TRIG_COLLIDED:
return "collided"
TRIG_PROP_PICKED_UP:
return "prop_picked_up"
TRIG_PROP_DROPPED:
return "prop_dropped"
TRIG_PROP_THROWN:
return "prop_thrown"
_:
return ""
func _action_type_string(id: int) -> String:
match id:
ACT_WALK:
return "walk_to"
ACT_SPEAK:
return "speak"
ACT_WAIT:
return "wait"
ACT_RAGDOLL:
return "ragdoll"
ACT_RECOVER:
return "recover"
ACT_PICK_UP_PROP:
return "pick_up_prop"
ACT_DROP_PROP:
return "drop_prop"
ACT_THROW_PROP:
return "throw_prop"
_:
return ""
func _action_verb(id: int) -> String:
match id:
ACT_WALK:
return "walk"
ACT_SPEAK:
return "speak"
ACT_WAIT:
return "wait"
ACT_RAGDOLL:
return "ragdoll"
ACT_RECOVER:
return "recover"
ACT_PICK_UP_PROP:
return "pick up a prop"
ACT_DROP_PROP:
return "drop a prop"
ACT_THROW_PROP:
return "throw a prop"
_:
return "act"
func _handle_rule_click(world_pos: Vector2) -> void:
match _rule_step:
RuleStep.TRIGGER_TARGET:
_handle_trigger_target_click(world_pos)
RuleStep.ACTION_TARGET:
_handle_action_target_click(world_pos)
RuleStep.ACTION_POSITION:
_handle_action_position_click(world_pos)
func _handle_trigger_target_click(world_pos: Vector2) -> void:
var trigger: Dictionary = _rule_builder.get("trigger", {})
match String(trigger.get("type", "")):
"arrived_at_waypoint":
var wp: Vector2 = _director_visuals.hit_test_waypoint(world_pos)
if wp.is_finite():
trigger["params"] = { "waypoint_pos": wp }
trigger["target"] = -1
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a waypoint dot (Esc or RMB to cancel)"
_refresh_status()
"entered_area":
var hit := _selection.hit_test(world_pos)
if hit is TriggerArea:
trigger["target"] = hit.get_instance_id()
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
_refresh_status()
"collided", "prop_picked_up", "prop_dropped", "prop_thrown":
var hit2 := _selection.hit_test(world_pos)
if hit2 is PropBlock:
trigger["target"] = hit2.get_instance_id()
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
_refresh_status()
func _handle_action_target_click(world_pos: Vector2) -> void:
var hit := _selection.hit_test(world_pos)
if not (hit is STICKMAN_RIG):
_refresh_status()
return
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
action["target"] = (hit as StickmanRig).get_instance_id()
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
match String(action.get("type", "")):
"walk_to":
_rule_step = RuleStep.ACTION_POSITION
_rule_hint = "Click where %s should walk (Esc or RMB to cancel)" % String(hit.name)
_refresh_status()
"speak":
_rule_step = RuleStep.PARAMS
_speak_edit.text = ""
_speak_stop_current_check.visible = true
_speak_stop_current_check.button_pressed = false
_speak_dialog.popup_centered()
_speak_edit.grab_focus()
"wait":
_rule_step = RuleStep.PARAMS
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
"pick_up_prop":
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_rule_action_prop_target, _cb_rule_action_capture_cancel)
"throw_prop":
_begin_throw_position_capture(hit as StickmanRig, "🏹 Click where to throw (Esc or RMB to cancel)", _cb_rule_action_throw_target, _cb_rule_action_capture_cancel)
_:
# Ragdoll, recover, and drop need no extra parameters.
_open_rule_more_popup()
func _handle_action_position_click(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
var params: Dictionary = action.get("params", {})
params["target"] = world_pos
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
func _cb_rule_action_prop_target(value: Node2D) -> void:
if not value is PropBlock:
return
var prop := value as PropBlock
if prop.stage_object_id.is_empty():
_show_toast("That prop is missing its scene ID")
return
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params["target_prop_id"] = prop.stage_object_id
params["hand"] = String(params.get("hand", "auto"))
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
func _cb_rule_action_throw_target(value: Vector2) -> void:
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions.back()
var params: Dictionary = action.get("params", {})
params["target_position"] = value
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
func _cb_rule_action_capture_cancel() -> void:
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will act (Esc or RMB to cancel)"
_refresh_status()
## Writes dialog-confirmed params onto the last action in the builder.
func _set_rule_action_params(params: Dictionary) -> void:
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
func _finalize_rule() -> void:
_clear_popup_anchor()
var rule: Dictionary = _rule_builder.duplicate(true)
if _rule_editing_id >= 0:
# Replace the existing rule in place (same index).
var idx := -1
for i: int in _event_rules.size():
if int(_event_rules[i].get("id", -1)) == _rule_editing_id:
idx = i
break
if idx >= 0:
rule["id"] = _rule_editing_id
_event_rules[idx] = rule
_show_toast("Rule updated!")
else:
rule["id"] = _next_rule_id
_next_rule_id += 1
_event_rules.append(rule)
_show_toast("Rule created!")
else:
rule["id"] = _next_rule_id
_next_rule_id += 1
_event_rules.append(rule)
_show_toast("Rule created!")
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_reset_rule_builder()
_restore_rule_build_panel()
_refresh_status()
func _cancel_rule_build() -> void:
_clear_popup_anchor()
_reset_rule_builder()
if _trigger_popup != null:
_trigger_popup.hide()
if _rule_action_popup != null:
_rule_action_popup.hide()
if _rule_more_popup != null:
_rule_more_popup.hide()
_restore_rule_build_panel()
_refresh_status()
func _reset_rule_builder() -> void:
if _speak_stop_current_check != null:
_speak_stop_current_check.visible = false
_speak_stop_current_check.button_pressed = false
_rule_step = RuleStep.IDLE
_rule_builder = {}
_rule_context_rig = null
_rule_hint = ""
_rule_editing_id = -1
_apply_cursor()
func _delete_rule(id: int) -> void:
var before := _event_rules.size()
_event_rules = _event_rules.filter(func(r): return int(r.get("id", -1)) != id)
if _event_rules.size() != before:
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_refresh_status()
## Opens the consequence-only rule editor for a rule (Phase 3c.5). Trigger is
## read-only; only the action list is editable.
func _begin_edit_rule(id: int) -> void:
for rule: Dictionary in _event_rules:
if int(rule.get("id", -1)) != id:
continue
_open_rule_editor_consequence(rule)
return
## True when any of trigger.source / trigger.target / an action.target is in ids.
func _rule_references_any(rule: Dictionary, ids: Array[int]) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
if ids.has(int(trigger.get("source", -1))):
return true
if ids.has(int(trigger.get("target", -1))):
return true
for a: Dictionary in rule.get("actions", []):
if ids.has(int(a.get("target", -1))):
return true
return false
func _cleanup_rules_for_nodes(nodes: Array[Node2D]) -> void:
var ids: Array[int] = []
for n: Node2D in nodes:
ids.append(n.get_instance_id())
_event_rules = _event_rules.filter(func(r): return not _rule_references_any(r, ids))
_director_visuals.set_rules(_event_rules)
# ---------------------------------------------------------------------------
# Event engine (Phase 4)
# ---------------------------------------------------------------------------
func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
_handle_event({ "type": "arrived_at_waypoint", "source": rig, "target": null, "position": target, "action": {} })
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
func _on_rig_speech_finished(rig: StickmanRig) -> void:
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void:
# Physics contacts can be delivered one frame after PLAY is stopped. Never
# recreate a flinch or dispatch a reactive rule in EDIT/DIRECT after the
# authoritative snap_to_standing() cleanup has run.
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if other == null or not is_instance_valid(other):
return
var contact_key: String = "%d:%d" % [prop.get_instance_id(), other.get_instance_id()]
if _suppressed_contact_pairs.has(contact_key):
return
_suppressed_contact_pairs[contact_key] = true
if bool(other.get_meta("stickman_ragdoll_body", false)):
return
var has_rig_metadata := other.has_meta("stickman_rig") or other.has_meta("part_name")
var rig := other.get_meta("stickman_rig") as StickmanRig if other.has_meta("stickman_rig") else null
var part := String(other.get_meta("part_name")) if other.has_meta("part_name") else ""
if rig == null or not is_instance_valid(rig) or part.is_empty():
if has_rig_metadata:
return
_handle_event({ "type": "collided", "source": prop, "target": other, "position": position, "action": {} })
return
if rig.state == StickmanRig.RigState.RAGDOLL:
return
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {
"rig_id": rig.get_instance_id(),
"prop_id": prop.get_instance_id(),
"bodies": {},
})
var bodies: Dictionary = pair.get("bodies", {})
var body_id := other.get_instance_id()
if bodies.has(body_id):
return
var first_contact := bodies.is_empty()
bodies[body_id] = true
pair["bodies"] = bodies
_collision_pairs[key] = pair
rig.apply_collision_reaction(part, normal, prop.linear_velocity.length(), position)
if first_contact:
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": position, "action": {} })
func _on_prop_separated(other: Node, prop: PropBlock) -> void:
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if other == null or not is_instance_valid(other):
return
_suppressed_contact_pairs.erase("%d:%d" % [prop.get_instance_id(), other.get_instance_id()])
if not other.has_meta("stickman_rig"):
return
var rig := other.get_meta("stickman_rig") as StickmanRig
if rig == null or not is_instance_valid(rig):
return
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {})
if pair.is_empty():
return
var bodies: Dictionary = pair.get("bodies", {})
bodies.erase(other.get_instance_id())
if bodies.is_empty():
_collision_pairs.erase(key)
else:
pair["bodies"] = bodies
_collision_pairs[key] = pair
func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void:
if new_state == StickmanRig.RigState.RAGDOLL:
var rig_ids: Array[int] = [rig.get_instance_id()]
_purge_collision_pairs_for_ids(rig_ids)
func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
for key: String in _collision_pairs.keys().duplicate():
var pair: Dictionary = _collision_pairs[key]
if ids.has(int(pair.get("rig_id", -1))) or ids.has(int(pair.get("prop_id", -1))):
_collision_pairs.erase(key)
## Iterates every rule in order; every matching rule executes (no short-circuit).
func _handle_event(event: Dictionary) -> void:
if _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
for rule: Dictionary in _event_rules:
if _rule_matches(rule, event):
_execute_rule_actions(rule)
func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
var rule_type := String(trigger.get("type", ""))
if rule_type != String(event.get("type", "")):
return false
var source := event.get("source") as Node2D
var target := event.get("target") as Node2D
var source_id := int(trigger.get("source", -1))
var target_id := int(trigger.get("target", -1))
match rule_type:
"arrived_at_waypoint":
if not _ids_match(source_id, source):
return false
var params: Dictionary = trigger.get("params", {})
var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF)
return (event.get("position", Vector2.INF) as Vector2).distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON
"action_finished":
if not _ids_match(source_id, source):
return false
var trig_params: Dictionary = trigger.get("params", {})
var want := String(trig_params.get("action_type", ""))
var action: Dictionary = event.get("action", {})
return want.is_empty() or want == String(action.get("type", ""))
"speech_finished":
return _ids_match(source_id, source)
"entered_area":
return _ids_match(source_id, source) and _ids_match(target_id, target)
"collided":
return _ids_match(source_id, source) and _ids_match(target_id, target)
"prop_picked_up", "prop_dropped", "prop_thrown":
return _ids_match(source_id, source) and _ids_match(target_id, target)
_:
return false
func _ids_match(a: int, b: Node2D) -> bool:
return a == -1 or (b != null and is_instance_valid(b) and b.get_instance_id() == a)
func _execute_rule_actions(rule: Dictionary) -> void:
var batches: Dictionary = {}
for a: Dictionary in rule.get("actions", []):
var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig
if rig == null or not is_instance_valid(rig):
continue
var id: int = rig.get_instance_id()
if not batches.has(id):
batches[id] = { "rig": rig, "actions": [] }
(batches[id]["actions"] as Array).append(_action_for_rig(a))
for batch: Dictionary in batches.values():
var target_rig := batch.get("rig") as StickmanRig
if target_rig != null and is_instance_valid(target_rig):
var batch_actions: Array[Dictionary] = []
batch_actions.assign(batch.get("actions", []))
target_rig.enqueue_reactive(batch_actions)
## Converts a rule action into the queue-action shape the runner understands.
func _action_for_rig(a: Dictionary) -> Dictionary:
var params: Dictionary = a.get("params", {})
var out: Dictionary = { "type": String(a.get("type", "")) }
match String(a.get("type", "")):
"walk_to":
out["target"] = params.get("target", Vector2.ZERO)
"speak":
out["text"] = String(params.get("text", ""))
out["duration"] = float(params.get("duration", 2.0))
out["stop_current"] = bool(params.get("stop_current", false))
"wait":
out["duration"] = float(params.get("duration", 0.0))
"pick_up_prop":
out["target_prop_id"] = String(params.get("target_prop_id", ""))
out["hand"] = String(params.get("hand", "auto"))
"throw_prop":
out["target_position"] = params.get("target_position", Vector2.ZERO)
return out
## Geometric overlap: TriggerArea children vs ANIMATED stickmen + unfrozen props.
## Edge-triggered on entry; emits an entered_area event per new overlap.
func _update_area_entry() -> void:
var areas: Array[TriggerArea] = []
for child: Node in _world.get_children():
if child is TriggerArea:
areas.append(child as TriggerArea)
if areas.is_empty():
return
var movables: Array[Node2D] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
if rig.state == StickmanRig.RigState.ANIMATED:
movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
movables.append(node)
# Build one swept bound per movable before iterating areas. The stage parent
# polls before child rigs apply this frame's free-fall displacement, so the
# previous->current union prevents fast airborne motion from tunneling
# through a trigger between two observed poses.
var swept_bounds: Dictionary = {}
var live_ids: Dictionary = {}
for node: Node2D in movables:
var node_id := node.get_instance_id()
var current_bounds := STAGE_SELECTION.get_world_aabb(node).abs()
var swept := current_bounds
if _area_previous_bounds.has(node_id):
swept = swept.merge((_area_previous_bounds[node_id] as Rect2).abs())
swept_bounds[node_id] = swept
_area_previous_bounds[node_id] = current_bounds
live_ids[node_id] = true
for old_id: int in _area_previous_bounds.keys().duplicate():
if not live_ids.has(old_id):
_area_previous_bounds.erase(old_id)
for area: TriggerArea in areas:
var area_rect := (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables:
var point := _movable_point(node)
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var inside: bool = (swept_bounds.get(
node.get_instance_id(), Rect2()) as Rect2).intersects(area_rect, true)
var was: bool = bool(_area_overlap.get(key, false))
if inside and not was:
_area_overlap[key] = true
_handle_event({ "type": "entered_area", "source": node, "target": area, "position": point, "action": {} })
elif not inside and was:
_area_overlap[key] = false
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
func _movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG:
return node.global_position - StickmanLocomotion.FOOT_OFFSET
return node.global_position
## Trigger entry follows the visible object, not only its origin/feet anchor.
## StageSelection already owns the mounted-geometry world AABB contract used by
## selection and gizmos, so reuse it here to keep what the user sees and what
## the event engine detects in agreement. Point-like fallbacks retain the old
## behavior for any future movable without visual bounds.
func _movable_overlaps_area(node: Node2D, area_rect: Rect2) -> bool:
var bounds := STAGE_SELECTION.get_world_aabb(node).abs()
if bounds.size.is_zero_approx():
return area_rect.has_point(_movable_point(node))
return area_rect.intersects(bounds, true)
func _show_toast(msg: String) -> void:
_toast_text = msg
_toast_timer = 2.0
# ---------------------------------------------------------------------------
# Phase 3c editor tools (Action & Rule editing)
# ---------------------------------------------------------------------------
## Right-click routing: waypoint context menu > stickman context menu. In EDIT
## with an active placement/drag, RMB keeps its Phase 4b "cancel build" role.
func _handle_right_click() -> void:
if current_mode == StageMode.PLAY:
return
var world_pos := _camera.get_global_mouse_position()
if current_mode == StageMode.EDIT and _placement_id != "":
_cancel_terrain_drag()
set_placement_mode("")
return
var wp_hit: Dictionary = _director_visuals.hit_test_waypoint_action(world_pos)
if not wp_hit.is_empty():
_open_waypoint_context(world_pos, wp_hit)
return
var hit := _selection.hit_test(world_pos)
if hit is STICKMAN_RIG:
_open_rig_context(world_pos, hit as StickmanRig)
func _open_waypoint_context(world_pos: Vector2, wp_hit: Dictionary) -> void:
_ctx_waypoint_rig = wp_hit["rig"]
_ctx_waypoint_index = int(wp_hit["index"])
_ctx_waypoint_action_id = int(wp_hit.get("action_id", _ctx_waypoint_rig.action_id_at(_ctx_waypoint_index)))
_ctx_waypoint_pos = wp_hit["pos"]
var count := _count_rules_for_waypoint(_ctx_waypoint_pos)
var screen := _world_to_screen(world_pos)
_waypoint_context.popup_for(Rect2i(Vector2i(screen), Vector2i.ZERO), count, int(wp_hit.get("status", StickmanActionRunner.QueueStatus.NOT_STARTED)))
func _open_rig_context(world_pos: Vector2, rig: StickmanRig) -> void:
_panel_rig = rig
var screen := _world_to_screen(world_pos)
_rig_context_popup.popup(Rect2i(Vector2i(screen), Vector2i.ZERO))
func _on_waypoint_context_id_pressed(id: int) -> void:
var rig := _ctx_waypoint_rig
if rig == null or not is_instance_valid(rig):
return
var index: int = rig.index_for_action_id(_ctx_waypoint_action_id)
if index < 0:
_show_toast("That action is no longer in the queue")
return
var status: int = rig.get_action_status(_ctx_waypoint_action_id)
match id:
WaypointContext.EDIT_WALK:
if _queue_action_consumed(status): return
_begin_walk_edit(rig, index, false)
WaypointContext.DELETE_WALK:
rig.remove_action(index)
_show_toast("Walk deleted")
WaypointContext.INSERT_BEFORE:
if _queue_action_consumed(status) or status == StickmanActionRunner.QueueStatus.ACTIVE:
_show_toast("Insert after the active action")
return
_open_action_editor("queue_insert", {}, index, rig, -1, false, _ctx_waypoint_action_id, false)
WaypointContext.INSERT_AFTER:
if _queue_action_consumed(status): return
_open_action_editor("queue_insert", {}, index + 1, rig, -1, false, _ctx_waypoint_action_id, true)
WaypointContext.EDIT_TRIGGER_RULES:
_open_trigger_rules_panel(_ctx_waypoint_pos)
func _on_rig_context_id_pressed(id: int) -> void:
if _panel_rig == null or not is_instance_valid(_panel_rig):
return
match id:
RIG_CTX_EDIT_QUEUE:
_open_queue_panel(_panel_rig)
RIG_CTX_EDIT_RULES:
_open_rules_panel_for_rig(_panel_rig)
func _count_rules_for_waypoint(pos: Vector2) -> int:
var n := 0
for rule: Dictionary in _event_rules:
var trigger: Dictionary = rule.get("trigger", {})
if String(trigger.get("type", "")) != "arrived_at_waypoint":
continue
var params: Dictionary = trigger.get("params", {})
var wp: Vector2 = params.get("waypoint_pos", Vector2.INF)
if wp.is_finite() and wp.distance_to(pos) <= WAYPOINT_MATCH_EPSILON:
n += 1
return n
# ---------------------------------------------------------------------------
# Phase 3c unified target capture
# ---------------------------------------------------------------------------
func _begin_capture(kind: CaptureKind, hint: String, on_resolve: Callable, on_cancel: Callable = Callable()) -> void:
_capture_kind = kind
_capture_hint = hint
_capture_callback = on_resolve
_capture_cancel = on_cancel
_capture_throw_preview_rig = null
_capture_throw_preview_prefix.clear()
_throw_preview_cache.clear()
_apply_cursor()
_refresh_status()
func _begin_throw_position_capture(rig: StickmanRig, hint: String, on_resolve: Callable, on_cancel: Callable = Callable(), queue_prefix: Array[Dictionary] = []) -> void:
_begin_capture(CaptureKind.THROW_POSITION, hint, on_resolve, on_cancel)
_capture_throw_preview_rig = rig if rig != null and is_instance_valid(rig) else null
_capture_throw_preview_prefix = queue_prefix.duplicate(true)
_throw_preview_cache.clear()
func _end_capture() -> void:
_capture_kind = CaptureKind.NONE
_capture_hint = ""
_capture_callback = Callable()
_capture_cancel = Callable()
_capture_throw_preview_rig = null
_capture_throw_preview_prefix.clear()
_throw_preview_cache.clear()
_apply_cursor()
_refresh_status()
func _throw_preview_prefix_for(rig: StickmanRig, before_action_id: int = -1) -> Array[Dictionary]:
var result: Array[Dictionary] = []
if rig == null or not is_instance_valid(rig):
return result
var queue_view: Array[Dictionary] = rig.get_queue_view()
var limit := queue_view.size()
if before_action_id >= 0:
limit = rig.index_for_action_id(before_action_id)
if limit < 0:
return result
for index: int in mini(limit, queue_view.size()):
var entry: Dictionary = queue_view[index]
var status := int(entry.get("status", StickmanActionRunner.QueueStatus.NOT_STARTED))
if status == StickmanActionRunner.QueueStatus.COMPLETED or status == StickmanActionRunner.QueueStatus.INTERRUPTED:
continue
var action: Dictionary = entry.get("action", {})
if action.is_empty():
continue
result.append({
"action": action.duplicate(true),
"step": index + 1,
"active": status == StickmanActionRunner.QueueStatus.ACTIVE,
})
return result
func _cancel_capture() -> void:
var cb := _capture_cancel
_end_capture()
if cb.is_valid():
cb.call()
func _resolve_capture(world_pos: Vector2) -> void:
var value: Variant = null
match _capture_kind:
CaptureKind.WAYPOINT:
var wp: Vector2 = _director_visuals.hit_test_waypoint(world_pos)
if wp.is_finite():
value = wp
CaptureKind.AREA:
var hit := _selection.hit_test(world_pos)
if hit is TriggerArea:
value = hit
CaptureKind.PROP:
var hit2 := _selection.hit_test(world_pos)
if hit2 is PropBlock:
value = hit2
CaptureKind.STICKMAN:
var hit3 := _selection.hit_test(world_pos)
if hit3 is STICKMAN_RIG:
value = hit3
CaptureKind.POSITION:
value = _snap_to_grid(world_pos) if _snap_enabled else world_pos
CaptureKind.THROW_POSITION:
value = _snap_to_grid(world_pos) if _snap_enabled else world_pos
if value == null:
_refresh_status()
return
var cb := _capture_callback
_end_capture()
if cb.is_valid():
cb.call(value)
# ---------------------------------------------------------------------------
# Phase 3c.3 walk-edit (visual waypoint re-placement)
# ---------------------------------------------------------------------------
func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
if index < 0 or index >= rig.queue_size():
return
_walk_edit_rig = rig
_walk_edit_index = index
_walk_edit_action_id = rig.action_id_at(index)
_walk_edit_from_panel = from_panel
var queue := rig.get_queue()
var pos: Vector2 = queue[index].get("target", rig.global_position - StickmanLocomotion.FOOT_OFFSET)
_director_visuals.set_edit_waypoint(pos)
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc or RMB to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
func _on_walk_edit_captured(value: Vector2) -> void:
var rig := _walk_edit_rig
var action_id: int = _walk_edit_action_id
var from_panel := _walk_edit_from_panel
_walk_edit_rig = null
_walk_edit_index = -1
_walk_edit_action_id = -1
_walk_edit_from_panel = false
if _director_visuals != null:
_director_visuals.clear_edit_waypoint()
if rig != null and is_instance_valid(rig):
var index: int = rig.index_for_action_id(action_id)
var status: int = rig.get_action_status(action_id)
var queue: Array[Dictionary] = rig.get_queue()
if index >= 0 and index < queue.size() and not _queue_action_consumed(status):
var action: Dictionary = queue[index]
action["target"] = value
if rig.replace_action(index, action):
_show_toast("Waypoint moved")
else:
_show_toast("That action changed while the waypoint was open")
if from_panel and _queue_panel != null:
_queue_panel.refresh()
_queue_panel.popup_centered()
func _cb_walk_edit_cancel() -> void:
var from_panel := _walk_edit_from_panel
_walk_edit_rig = null
_walk_edit_index = -1
_walk_edit_action_id = -1
_walk_edit_from_panel = false
if _director_visuals != null:
_director_visuals.clear_edit_waypoint()
if from_panel and _queue_panel != null:
_queue_panel.popup_centered()
# ---------------------------------------------------------------------------
# Phase 3c.2 Action Queue Panel
# ---------------------------------------------------------------------------
func _open_queue_panel(rig: StickmanRig) -> void:
_panel_rig = rig
_queue_panel.setup(rig)
_queue_panel.popup_centered()
func _on_queue_panel_edit_requested(index: int) -> void:
if _panel_rig == null or not is_instance_valid(_panel_rig):
return
var queue := _panel_rig.get_queue()
if index < 0 or index >= queue.size():
return
if _queue_action_consumed(_panel_rig.get_action_status(_panel_rig.action_id_at(index))):
_show_toast("Completed actions can only be deleted")
return
var action: Dictionary = queue[index]
if String(action.get("type", "")) == "walk_to":
_queue_panel.hide()
_begin_walk_edit(_panel_rig, index, true)
else:
_open_action_editor("queue_edit", action.duplicate(true), index, _panel_rig, -1, true)
func _on_queue_panel_delete_requested(index: int) -> void:
if _panel_rig == null or not is_instance_valid(_panel_rig):
return
var target_rig: StickmanRig = _panel_rig
var action_id: int = target_rig.action_id_at(index)
if action_id < 0:
return
_ask_confirm("Delete Action", "Delete this action?", func() -> void:
if target_rig != null and is_instance_valid(target_rig):
var current_index: int = target_rig.index_for_action_id(action_id)
if current_index >= 0:
target_rig.remove_action(current_index)
if _queue_panel != null:
_queue_panel.refresh()
)
func _on_queue_panel_add_requested() -> void:
if _panel_rig == null or not is_instance_valid(_panel_rig):
return
_open_action_editor("queue_add", {}, -1, _panel_rig, -1, true)
func _on_queue_panel_clear_requested() -> void:
if _panel_rig == null or not is_instance_valid(_panel_rig):
return
var target_rig: StickmanRig = _panel_rig
_ask_confirm("Clear Queue", "Remove all actions for %s?" % target_rig.name, func() -> void:
if target_rig != null and is_instance_valid(target_rig):
target_rig.clear_queue()
if _queue_panel != null:
_queue_panel.refresh()
)
func _queue_action_consumed(status: int) -> bool:
return status == StickmanActionRunner.QueueStatus.COMPLETED or status == StickmanActionRunner.QueueStatus.INTERRUPTED
# ---------------------------------------------------------------------------
# Phase 3c.4 Rule Panel
# ---------------------------------------------------------------------------
func _open_rules_panel_for_rig(rig: StickmanRig) -> void:
_panel_rig = rig
_rule_panel_source_id = rig.get_instance_id()
_rule_panel_waypoint = Vector2.INF
_rule_panel_title = "Source: %s" % rig.name
_show_rule_panel()
func _open_trigger_rules_panel(pos: Vector2) -> void:
_rule_panel_source_id = -1
_rule_panel_waypoint = pos
_rule_panel_title = "Waypoint (%d, %d)" % [int(roundf(pos.x)), int(roundf(pos.y))]
_show_rule_panel()
func _rule_panel_rules() -> Array[Dictionary]:
var out: Array[Dictionary] = []
for rule: Dictionary in _event_rules:
if _rule_matches_panel_filter(rule):
out.append(rule)
return out
func _rule_matches_panel_filter(rule: Dictionary) -> bool:
if _rule_panel_source_id >= 0:
var trigger: Dictionary = rule.get("trigger", {})
return int(trigger.get("source", -1)) == _rule_panel_source_id
if _rule_panel_waypoint.is_finite():
var trig: Dictionary = rule.get("trigger", {})
if String(trig.get("type", "")) != "arrived_at_waypoint":
return false
var params: Dictionary = trig.get("params", {})
var wp: Vector2 = params.get("waypoint_pos", Vector2.INF)
return wp.is_finite() and wp.distance_to(_rule_panel_waypoint) <= WAYPOINT_MATCH_EPSILON
return true
## Builds the filtered rule list + filter-id set and pops the panel.
func _show_rule_panel() -> void:
_rule_panel_filter_ids.clear()
var rules := _rule_panel_rules()
for r: Dictionary in rules:
_rule_panel_filter_ids.append(int(r.get("id", -1)))
_rule_panel.show_rules(rules, _rule_panel_title)
## Refreshes the rule panel's list in place (no re-centering). Safe to call even
## when the panel is hidden (restore flows re-pop it afterwards, so it must not
## skip the rebuild when `visible == false`).
func _refresh_rule_panel() -> void:
if _rule_panel == null:
return
_rule_panel_filter_ids.clear()
var rules := _rule_panel_rules()
for r: Dictionary in rules:
_rule_panel_filter_ids.append(int(r.get("id", -1)))
_rule_panel.set_rules(rules, _rule_panel_title)
func _on_rule_panel_edit_requested(rule_id: int) -> void:
for rule: Dictionary in _event_rules:
if int(rule.get("id", -1)) == rule_id:
_open_rule_editor_full(rule, true)
return
func _on_rule_panel_delete_requested(rule_id: int) -> void:
_ask_confirm("Delete Rule", "Delete this rule?", func() -> void:
_delete_rule(rule_id)
_refresh_rule_panel()
)
func _on_rule_panel_add_requested() -> void:
if _rule_panel_source_id < 0:
_show_toast("Select a stickman first")
return
_rule_panel.hide()
_begin_rule_build(_rule_panel_source_id, true)
func _on_rule_panel_clear_requested() -> void:
var ids := _rule_panel_filter_ids.duplicate()
_ask_confirm("Clear Rules", "Remove all %d rule(s)?" % ids.size(), func() -> void:
_event_rules = _event_rules.filter(func(r): return not ids.has(int(r.get("id", -1))))
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_refresh_rule_panel()
)
func _on_rule_panel_reorder_requested(ordered_ids: Array[int]) -> void:
if _run_state == RunState.PAUSED:
_show_toast("Event rules stay in order during a paused run")
_refresh_rule_panel()
return
_reorder_filtered_rules(ordered_ids)
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
## Rewrites the filtered rules (whose ids are in ordered_ids) in the new order
## into their existing slots, preserving every un-filtered rule's position.
func _reorder_filtered_rules(ordered_ids: Array[int]) -> void:
var by_id: Dictionary = {}
for r: Dictionary in _event_rules:
by_id[int(r.get("id", -1))] = r
var idx := 0
for i: int in _event_rules.size():
var rid := int(_event_rules[i].get("id", -1))
if ordered_ids.has(rid):
if idx < ordered_ids.size() and by_id.has(ordered_ids[idx]):
_event_rules[i] = by_id[ordered_ids[idx]]
idx += 1
## Re-shows the rule panel after a rule build launched from it finishes.
func _restore_rule_build_panel() -> void:
if _rule_build_from_panel:
_rule_build_from_panel = false
_refresh_rule_panel()
if _rule_panel != null:
_rule_panel.popup_centered()
# ---------------------------------------------------------------------------
# Phase 3c.5 Rule Editor
# ---------------------------------------------------------------------------
func _open_rule_editor_full(rule: Dictionary, from_panel: bool) -> void:
_rule_editor_from_panel = from_panel
if from_panel and _rule_panel != null:
_rule_panel.hide()
_rule_editor.open_full(rule)
func _open_rule_editor_consequence(rule: Dictionary) -> void:
_rule_editor_from_panel = false
_rule_editor.open_consequence(rule)
func _restore_rule_panel() -> void:
if _rule_editor_from_panel and _rule_panel != null:
_rule_editor_from_panel = false
_refresh_rule_panel()
_rule_panel.popup_centered()
func _on_rule_editor_committed(rule: Dictionary) -> void:
var id := int(rule.get("id", -1))
var idx := -1
for i: int in _event_rules.size():
if int(_event_rules[i].get("id", -1)) == id:
idx = i
break
if idx >= 0:
_event_rules[idx] = rule
_show_toast("Rule updated!")
else:
rule["id"] = _next_rule_id
_next_rule_id += 1
_event_rules.append(rule)
_show_toast("Rule created!")
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_restore_rule_panel()
func _on_rule_editor_cancelled() -> void:
_restore_rule_panel()
func _on_rule_editor_trigger_target_requested(type: String) -> void:
_rule_editor.hide()
match TRIGGER_REGISTRY.target_type(type):
"waypoint":
_begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc or RMB to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
"area":
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc or RMB to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
"prop":
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc or RMB to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel)
func _on_rule_editor_action_add_requested() -> void:
_open_action_editor("rule_add", {}, -1, null, -1, false)
func _on_rule_editor_action_edit_requested(index: int) -> void:
var rule_action: Dictionary = _rule_editor.get_action(index)
if rule_action.is_empty():
return
var flat := ACTION_REGISTRY.from_rule_action(rule_action)
var actor_id := int(rule_action.get("target", -1))
_open_action_editor("rule_edit", flat, index, null, actor_id, false)
# --- Rule editor target-capture callbacks -----------------------------------
func _cb_rule_trigger_waypoint(value: Vector2) -> void:
if _rule_editor != null:
_rule_editor.set_trigger_target(-1, { "waypoint_pos": value })
func _cb_rule_trigger_area(value: Node2D) -> void:
if _rule_editor != null:
_rule_editor.set_trigger_target(int(value.get_instance_id()), {})
func _cb_rule_trigger_prop(value: Node2D) -> void:
if _rule_editor != null:
_rule_editor.set_trigger_target(int(value.get_instance_id()), {})
func _cb_rule_trigger_cancel() -> void:
if _rule_editor != null:
_rule_editor.popup_centered()
func _cb_rule_actor(value: Node2D) -> void:
var actor_id := int(value.get_instance_id())
var flat: Dictionary = _pending_rule_action_flat
_pending_rule_action_flat = {}
if _rule_editor != null:
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, actor_id))
func _cb_rule_actor_cancel() -> void:
if _rule_editor != null:
_rule_editor.popup_centered()
# ---------------------------------------------------------------------------
# Phase 3c Action Editor
# ---------------------------------------------------------------------------
func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: StickmanRig, actor_id: int, hide_queue: bool, target_action_id: int = -1, insert_after: bool = false) -> void:
_action_editor_kind = kind
_action_editor_index = index
_action_editor_rig = rig
_action_editor_action_id = rig.action_id_at(index) if kind == "queue_edit" and rig != null and is_instance_valid(rig) else -1
_action_editor_target_action_id = target_action_id
_action_editor_insert_after = insert_after
_action_editor_actor_id = actor_id
_action_editor_restore_queue = hide_queue
if hide_queue and _queue_panel != null:
_queue_panel.hide()
elif (kind == "rule_add" or kind == "rule_edit") and _rule_editor != null:
_rule_editor.hide()
if flat.is_empty():
_action_editor.open_new(kind == "rule_add" or kind == "rule_edit")
else:
_action_editor.open_edit(flat, kind == "rule_add" or kind == "rule_edit")
func _on_action_editor_committed(flat: Dictionary) -> void:
_action_editor.hide()
match _action_editor_kind:
"queue_add":
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
_action_editor_rig.queue_action(flat)
_show_toast("Action added")
"queue_edit":
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
var idx: int = _action_editor_rig.index_for_action_id(_action_editor_action_id)
var status: int = _action_editor_rig.get_action_status(_action_editor_action_id)
if idx >= 0 and not _queue_action_consumed(status) and _action_editor_rig.replace_action(idx, flat):
_show_toast("Action updated")
else:
_show_toast("That action is no longer editable")
"queue_insert":
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
var insert_index: int = _action_editor_index
if _action_editor_target_action_id >= 0:
insert_index = _action_editor_rig.index_for_action_id(_action_editor_target_action_id)
var target_status: int = _action_editor_rig.get_action_status(_action_editor_target_action_id)
if insert_index < 0 or _queue_action_consumed(target_status) or (target_status == StickmanActionRunner.QueueStatus.ACTIVE and not _action_editor_insert_after):
insert_index = -1
else:
insert_index += 1 if _action_editor_insert_after else 0
if insert_index >= 0:
_action_editor_rig.insert_action(insert_index, flat)
_show_toast("Action inserted")
else:
_show_toast("That action can no longer be used as an insert point")
"rule_add":
if _action_editor_actor_id >= 0:
if _rule_editor != null:
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, _action_editor_actor_id))
else:
_pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
return
"rule_edit":
if _rule_editor != null:
_rule_editor.set_action(_action_editor_index, ACTION_REGISTRY.to_rule_action(flat, _action_editor_actor_id))
_restore_queue_panel()
func _on_action_editor_cancelled() -> void:
_action_editor.hide()
match _action_editor_kind:
"queue_add", "queue_edit":
_restore_queue_panel()
"rule_add", "rule_edit":
if _rule_editor != null:
_rule_editor.popup_centered()
_:
pass
func _on_action_editor_target_requested() -> void:
_action_editor.hide()
var action_type := _action_editor.current_action_type()
if _action_editor_kind == "rule_add" and _action_editor_actor_id < 0 and (action_type == "pick_up_prop" or action_type == "throw_prop"):
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_editor_actor_for_target, _cb_editor_actor_capture_cancel)
return
_begin_editor_action_target_capture()
func _begin_editor_action_target_capture() -> void:
match _action_editor.current_action_type():
"pick_up_prop":
_begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
"throw_prop":
var preview_rig := _action_editor_preview_rig()
var queue_prefix: Array[Dictionary] = []
if _action_editor_kind == "queue_edit":
queue_prefix = _throw_preview_prefix_for(preview_rig, _action_editor_action_id)
elif _action_editor_kind == "queue_add":
queue_prefix = _throw_preview_prefix_for(preview_rig)
_begin_throw_position_capture(
preview_rig,
"🏹 Click where to throw (Esc or RMB to cancel)",
_cb_editor_throw_target,
_cb_editor_walk_target_cancel,
queue_prefix
)
_:
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_walk_target(value)
func _cb_editor_prop_target(value: Node2D) -> void:
if _action_editor != null and value is PropBlock:
var prop := value as PropBlock
if not prop.stage_object_id.is_empty():
_action_editor.set_prop_target(prop.stage_object_id)
else:
_action_editor.popup_centered()
func _cb_editor_throw_target(value: Vector2) -> void:
if _action_editor != null:
_action_editor.set_throw_target(value)
func _cb_editor_actor_for_target(value: Node2D) -> void:
if not value is StickmanRig:
if _action_editor != null:
_action_editor.popup_centered()
return
_action_editor_actor_id = int(value.get_instance_id())
_begin_editor_action_target_capture()
func _cb_editor_actor_capture_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
func _action_editor_preview_rig() -> StickmanRig:
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
return _action_editor_rig
var actor := instance_from_id(_action_editor_actor_id) as StickmanRig
return actor if actor != null and is_instance_valid(actor) else null
func _cb_editor_walk_target_cancel() -> void:
if _action_editor != null:
_action_editor.popup_centered()
func _restore_queue_panel() -> void:
if _action_editor_restore_queue and _queue_panel != null:
_queue_panel.refresh()
_queue_panel.popup_centered()
# ---------------------------------------------------------------------------
# Phase 3c confirmation dialog
# ---------------------------------------------------------------------------
func _ask_confirm(title: String, message: String, callback: Callable) -> void:
_confirm_callback = callback
_confirm_dialog.title = title
_confirm_dialog.dialog_text = message
_confirm_dialog.popup_centered()
func _on_confirm_confirmed() -> void:
var cb := _confirm_callback
_confirm_callback = Callable()
if cb.is_valid():
cb.call()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
## Converts a world position to screen coordinates using the stage camera (the
## exact inverse of Camera2D.get_global_mouse_position()).
func _world_to_screen(world_pos: Vector2) -> Vector2:
var vp_size := get_viewport().get_visible_rect().size
return (world_pos - _camera.position) * _camera.zoom + vp_size * 0.5
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.set_snap_size(_grid_size if _snap_enabled else 0.0)
func _set_edit_controls_visible(visible: bool) -> void:
for btn: Button in _palette_buttons.values():
btn.visible = visible
if _palette_dock != null:
_palette_dock.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
func _set_direct_controls_visible(visible: bool) -> void:
if _direct_hint_label != null:
_direct_hint_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.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
continue
result.append(node)
return result
# ---------------------------------------------------------------------------
# Theme loading (Phase 4b)
# ---------------------------------------------------------------------------
## Loads the theme JSON. `source_path` is overridable for tests (missing /
## malformed fallback); defaults to the committed res:// theme.
func _load_theme(source_path: String = THEME_PATH) -> void:
_sandbox_theme = SANDBOX_THEME.new()
_sandbox_theme.load_from(source_path)
_theme = {}
_build_font_sizes()
if not FileAccess.file_exists(source_path):
push_warning("SandboxStage: theme file '%s' not found; using defaults." % source_path)
return
var file := FileAccess.open(source_path, FileAccess.READ)
if file == null:
return
var json: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not json is Dictionary:
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
return
_theme = _sandbox_theme.raw_config()
var fonts: Dictionary = _theme.get("fonts", {})
_action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
_action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22))
_tooltip_font_size = int(fonts.get("tooltip_font_size", 18))
_status_pill_font_size = int(fonts.get("status_pill_font_size", 16))
_queue_panel_font_size = int(fonts.get("queue_panel_font_size", 18))
_rule_panel_font_size = int(fonts.get("rule_panel_font_size", 18))
_action_editor_font_size = int(fonts.get("action_editor_font_size", 18))
_rule_editor_font_size = int(fonts.get("rule_editor_font_size", 18))
_panel_row_font_size = int(fonts.get("panel_row_font_size", 16))
_panel_title_font_size = int(fonts.get("panel_title_font_size", 18))
_panel_title_bold = bool(fonts.get("panel_title_bold", true))
_rule_label_bold = bool(fonts.get("rule_label_bold", false))
_badge_bold = bool(fonts.get("badge_bold", true))
_ui_font = _load_font(String(fonts.get("ui_font", "")))
_emoji_font = _load_font(String(fonts.get("emoji_font", "")))
_ui_font_bold = _resolve_style_font(String(fonts.get("ui_font_bold", "")), _ui_font, true)
_ui_font_italic = _resolve_style_font(String(fonts.get("ui_font_italic", "")), _ui_font, false)
# Optional per-widget object form (e.g. "action_popup": { size, bold, italic });
# its `size` overrides the flat action_popup_font_size key. Bold/italic only
# exist in the object form, so they default back to false on every load (a
# later theme that omits the object must not inherit a previous theme's flags).
_action_popup_bold = false
_action_popup_italic = false
var ap: Variant = fonts.get("action_popup", null)
if ap is Dictionary:
var apd := ap as Dictionary
if apd.has("size"):
_action_popup_font_size = int(apd["size"])
_action_popup_bold = bool(apd.get("bold", false))
_action_popup_italic = bool(apd.get("italic", false))
var grid: Dictionary = _theme.get("grid", {})
_theme_grid_default = clampf(float(grid.get("snap_size", DEFAULT_GRID_SIZE)), MIN_GRID_SIZE, MAX_GRID_SIZE)
var colors: Dictionary = _theme.get("mode_colors", {})
_accent_edit = _parse_color(String(colors.get("edit_accent", "")), _accent_edit)
_accent_direct = _parse_color(String(colors.get("direct_accent", "")), _accent_direct)
_accent_play = _parse_color(String(colors.get("play_accent", "")), _accent_play)
_guide_line_color = _parse_color(String(colors.get("guide_line", "")), _guide_line_color)
# Schema v2 wins only when the source itself opts into v2. Legacy ad-hoc
# theme files retain their Phase 3 flat-key precedence for compatibility.
if String((json as Dictionary).get("version", "")) == SANDBOX_THEME.SCHEMA_VERSION:
_action_popup_font_size = _sandbox_theme.integer("fonts.sizes.action_popup", _action_popup_font_size, 8, 96)
_action_popup_emoji_size = _sandbox_theme.integer("fonts.sizes.action_popup_emoji", _action_popup_emoji_size, 8, 96)
_tooltip_font_size = _sandbox_theme.integer("fonts.sizes.tooltip", _tooltip_font_size, 8, 96)
_status_pill_font_size = _sandbox_theme.integer("fonts.sizes.status_pill", _status_pill_font_size, 8, 96)
_queue_panel_font_size = _sandbox_theme.integer("fonts.sizes.queue_panel", _queue_panel_font_size, 8, 96)
_rule_panel_font_size = _sandbox_theme.integer("fonts.sizes.rule_panel", _rule_panel_font_size, 8, 96)
_action_editor_font_size = _sandbox_theme.integer("fonts.sizes.action_editor", _action_editor_font_size, 8, 96)
_rule_editor_font_size = _sandbox_theme.integer("fonts.sizes.rule_editor", _rule_editor_font_size, 8, 96)
_panel_row_font_size = _sandbox_theme.integer("fonts.sizes.panel_row", _panel_row_font_size, 8, 96)
_panel_title_font_size = _sandbox_theme.integer("fonts.sizes.panel_title", _panel_title_font_size, 8, 96)
_accent_edit = _sandbox_theme.color("mode_colors.edit", _accent_edit)
_accent_direct = _sandbox_theme.color("mode_colors.direct", _accent_direct)
_accent_play = _sandbox_theme.color("mode_colors.play", _accent_play)
_guide_line_color = _sandbox_theme.color("mode_colors.guide", _guide_line_color)
_theme_grid_default = _sandbox_theme.number("grid.snap_size", _theme_grid_default, MIN_GRID_SIZE, MAX_GRID_SIZE)
_build_font_sizes()
## Builds the `sizes` dict passed to each Phase 3c widget's apply_font(): the
## per-widget font sizes, the panel title/row sizes, the panel-title bold flag,
## and the resolved bold/italic Font variants.
func _build_font_sizes() -> void:
_font_sizes = {
"queue_panel": _queue_panel_font_size,
"rule_panel": _rule_panel_font_size,
"action_editor": _action_editor_font_size,
"rule_editor": _rule_editor_font_size,
"panel_row": _panel_row_font_size,
"panel_title": _panel_title_font_size,
"panel_title_bold": _panel_title_bold,
"bold_font": _ui_font_bold,
"italic_font": _ui_font_italic,
}
## Resolves a style-variant font: a dedicated path is honoured first, then a
## FontVariation embolden over the base font (bold only), then the base font.
func _resolve_style_font(path: String, base: Font, bold: bool) -> Font:
if not path.is_empty():
var loaded := _load_font(path)
if loaded != null:
return loaded
if base == null:
return null
if not bold:
return base
var variation := FontVariation.new()
variation.base_font = base
variation.variation_embolden = 0.9
return variation
static func _parse_color(html: String, fallback: Color) -> Color:
var s := html.strip_edges()
if s.begins_with("#"):
s = s.substr(1)
if s.is_empty():
return fallback
return Color.from_string(s, fallback)
func _load_font(path: String) -> Font:
if path.is_empty():
return null
if not ResourceLoader.exists(path):
push_warning("SandboxStage: theme font '%s' not found; using fallback." % path)
return null
var res := ResourceLoader.load(path)
if res is Font:
return res
push_warning("SandboxStage: theme font '%s' is not a Font; using fallback." % path)
return null
func _apply_theme_to_visuals() -> void:
if _director_visuals != null:
_director_visuals.set_style(_theme)
_director_visuals.completed_path_alpha = _sandbox_theme.number("overlays.completed_path_alpha", 0.20, 0.0, 1.0) if _sandbox_theme != null else 0.20
_director_visuals.completed_path_color = _sandbox_theme.color("overlays.completed_path_color", Color("#959EAD")) if _sandbox_theme != null else Color("#959EAD")
_director_visuals.emoji_font = _emoji_font
_director_visuals.ui_font = _ui_font
_director_visuals.bold_font = _ui_font_bold
_director_visuals.italic_font = _ui_font_italic
if _placement_overlay != null:
_placement_overlay.apply_style(_sandbox_theme.value("overlays", {}) if _sandbox_theme != null else {})
# ---------------------------------------------------------------------------
# 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))
_recent_scenes.clear()
var raw_recents: Variant = d.get("recent_scenes", [])
if raw_recents is Array:
for raw: Variant in raw_recents:
var path := String(raw)
if not path.is_empty() and FileAccess.file_exists(path) and not _recent_scenes.has(path):
_recent_scenes.append(path)
if _recent_scenes.size() >= RECENT_SCENE_LIMIT:
break
func _save_settings() -> void:
var data := {
"version": "1.0",
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
"show_grid": _show_grid,
"recent_scenes": _recent_scenes,
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()