Files
stickman/scripts/sandbox_stage.gd
T
ryan 6666173c4f Implement speech style toolbar live-edit refinements and whole speech show controls
- Added a new specification for the speech style toolbar live-edit, detailing immediate style application, scoped effect demonstration, and hover feedback.
- Introduced refinements to the speech style toolbar, including improved chooser sizing, exclusive stage editing during speech composition, and removal of redundant controls.
- Documented new stage controls for managing how the whole speech shows, including options for bubble arrangement, word appearance, and bubble motion.
- Created a new log file for speech padding syntax to track engine versioning.
2026-10-03 14:49:22 -04:00

6765 lines
265 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 SPEECH_COMPOSER := preload("res://scenes/speech_composer.tscn")
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
const SPEECH_STYLE_TOOLBAR := preload("res://scripts/speech_style_toolbar.gd")
const SPEECH_SHOW_TOOLBAR := preload("res://scripts/speech_show_toolbar.gd")
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 SPEECH_DRAFT_PATH: String = "user://speech_composer_draft.json"
const SPEECH_DRAFT_DEBOUNCE: float = 0.75
const SPEECH_DRAFT_SCHEMA: int = 1
const SPEECH_BUBBLE_DRAG_THRESHOLD: float = 8.0
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 _speech_composer: Control = null
var _speech_style_toolbar: Control = null
var _speech_show_toolbar: Control = null
var _speech_preview: SpeechBubble = null
var _speech_actor: StickmanRig = null
var _speech_session_kind: String = ""
var _speech_placing: bool = false
var _speech_placement_bubble_index: int = 0
var _speech_bubble_dragging: bool = false
var _speech_bubble_pointer_pending: bool = false
var _speech_bubble_pointer_moved: bool = false
var _speech_bubble_pointer_on_badge: bool = false
var _speech_bubble_pointer_start_screen: Vector2 = Vector2.ZERO
var _speech_bubble_pointer_start_world: Vector2 = Vector2.ZERO
var _speech_bubble_pointer_hits: Array[int] = []
var _speech_bubble_pointer_index: int = -1
var _speech_bubble_pointer_sequence: int = 0
var _speech_bubble_word_clicked: bool = false
var _speech_badge_cursor_active: bool = false
var _speech_bubble_picker: PopupPanel = null
var _speech_token_picker: PopupPanel = null
var _pending_speech_initial: Dictionary = {}
var _speech_origin: Dictionary = {}
var _speech_draft_dirty: bool = false
var _speech_draft_timer: float = -1.0
var _speech_recovery_dialog: ConfirmationDialog = null
var _speech_destination_dialog: ConfirmationDialog = null
var _speech_save_gate_dialog: ConfirmationDialog = null
var _speech_pending_scene_save_path: String = ""
var _speech_pending_scene_save_as: bool = false
var _speech_scene_save_pending: bool = false
var _rule_edit_uncommitted: bool = false
var _speech_recovery_in_progress: bool = false
var _speech_pending_open: Dictionary = {}
var _speech_restored_unbound: bool = false
var _speech_restored_payload: Dictionary = {}
var _speech_restored_origin: Dictionary = {}
var _speech_restored_selected_bubble: int = 0
var _speech_pending_commit_action: Dictionary = {}
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())
_check_speech_recovery()
func _process(delta: float) -> void:
_update_speech_composition()
if _speech_draft_timer >= 0.0:
_speech_draft_timer -= delta
if _speech_draft_timer <= 0.0:
_speech_draft_timer = -1.0
_persist_speech_draft_now()
_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()
_update_speech_badge_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 _speech_composer_is_open() and bool(_speech_composer.call("is_message_dialog_open")):
if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
_speech_composer.call("close_message_dialog")
get_viewport().set_input_as_handled()
return
if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE and _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")):
_speech_style_toolbar.call("close_chooser")
get_viewport().set_input_as_handled()
return
if event is InputEventKey and (event as InputEventKey).keycode == KEY_SPACE:
_handle_space_key_event(event as InputEventKey)
return
if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")) and _is_speech_pointer_press(event):
var chooser_point: Vector2 = _speech_event_position(event)
if not bool(_speech_style_toolbar.call("chooser_contains_viewport_point", chooser_point)):
_speech_style_toolbar.call("close_chooser")
if _speech_composer_owns_event(event):
_panning = false
if _speech_bubble_pointer_pending:
_cancel_speech_bubble_pointer()
return
if _route_speech_composer_stage_input(event):
return
if _speech_bubble_pointer_pending:
if event is InputEventMouseMotion:
_update_speech_bubble_pointer((event as InputEventMouseMotion).position)
return
if event is InputEventScreenDrag:
_update_speech_bubble_pointer((event as InputEventScreenDrag).position)
return
var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed
var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed
if mouse_release or touch_release:
if _speech_bubble_dragging:
if _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.end_bubble_drag()
_speech_bubble_dragging = false
call_deferred("_clear_speech_bubble_pointer", _speech_bubble_pointer_sequence)
else:
var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position
call_deferred("_resolve_speech_bubble_pointer_click", _speech_bubble_pointer_sequence, release_position)
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 _speech_composer_owns_event(event: InputEvent) -> bool:
if _speech_composer == null or not _speech_composer.visible or not _speech_composer.has_method("contains_viewport_point"):
return false
var point: Vector2 = Vector2(-1.0, -1.0)
if event is InputEventMouse:
point = (event as InputEventMouse).position
elif event is InputEventGesture:
point = (event as InputEventGesture).position
elif event is InputEventScreenTouch:
point = (event as InputEventScreenTouch).position
elif event is InputEventScreenDrag:
point = (event as InputEventScreenDrag).position
if point.x < 0.0 or point.y < 0.0:
return false
if bool(_speech_composer.call("contains_viewport_point", point)):
return true
if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("contains_viewport_point", point)):
return true
if _speech_show_toolbar != null and bool(_speech_show_toolbar.call("contains_viewport_point", point)):
return true
if _speech_token_picker != null and is_instance_valid(_speech_token_picker) and _speech_token_picker.visible:
return Rect2(_speech_token_picker.position, Vector2(_speech_token_picker.size)).has_point(point)
return false
func _is_speech_pointer_press(event: InputEvent) -> bool:
return event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and (event as InputEventMouseButton).pressed or event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed
func _speech_event_position(event: InputEvent) -> Vector2:
if event is InputEventMouse:
return (event as InputEventMouse).position
if event is InputEventScreenTouch:
return (event as InputEventScreenTouch).position
if event is InputEventGesture:
return (event as InputEventGesture).position
return Vector2(-1.0, -1.0)
func _speech_composer_is_open() -> bool:
return _speech_composer != null and is_instance_valid(_speech_composer) and _speech_composer.visible
func _speech_stage_viewport_rect() -> Rect2:
var viewport_size: Vector2 = get_viewport_rect().size
return Rect2(Vector2(12.0, 72.0), Vector2(maxf(1.0, viewport_size.x - 24.0), maxf(1.0, viewport_size.y - 120.0)))
func _route_speech_composer_stage_input(event: InputEvent) -> bool:
if not _speech_composer_is_open():
return false
if _speech_bubble_pointer_pending:
if event is InputEventMouseMotion:
_update_speech_bubble_pointer((event as InputEventMouseMotion).position)
return true
if event is InputEventScreenDrag:
_update_speech_bubble_pointer((event as InputEventScreenDrag).position)
return true
var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed
var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed
if mouse_release or touch_release:
if _speech_bubble_dragging:
if _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.end_bubble_drag()
_speech_bubble_dragging = false
call_deferred("_clear_speech_bubble_pointer", _speech_bubble_pointer_sequence)
else:
var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position
call_deferred("_resolve_speech_bubble_pointer_click", _speech_bubble_pointer_sequence, release_position)
return true
var point: Vector2 = _speech_event_position(event)
if point.x < 0.0 or point.y < 0.0 or not _speech_stage_viewport_rect().has_point(point):
return false
if event is InputEventMagnifyGesture:
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
get_viewport().set_input_as_handled()
return true
if event is InputEventPanGesture:
_camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x
get_viewport().set_input_as_handled()
return true
if event is InputEventMouseButton:
var mouse_button: InputEventMouseButton = event as InputEventMouseButton
if mouse_button.button_index == MOUSE_BUTTON_MIDDLE or mouse_button.button_index == MOUSE_BUTTON_WHEEL_UP or mouse_button.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_handle_mouse_button(mouse_button)
get_viewport().set_input_as_handled()
return true
if mouse_button.button_index == MOUSE_BUTTON_LEFT:
if mouse_button.pressed:
if _speech_placing:
return false
_begin_speech_bubble_pointer(mouse_button.position)
if not _speech_bubble_pointer_pending:
get_viewport().set_input_as_handled()
return true
get_viewport().set_input_as_handled()
return true
if mouse_button.button_index == MOUSE_BUTTON_RIGHT and mouse_button.pressed and _speech_placing:
return false
get_viewport().set_input_as_handled()
return true
if event is InputEventMouseMotion:
if _panning:
_handle_mouse_motion(event as InputEventMouseMotion)
get_viewport().set_input_as_handled()
return true
if event is InputEventScreenTouch:
var touch: InputEventScreenTouch = event as InputEventScreenTouch
if touch.pressed:
if _speech_placing:
return false
_begin_speech_bubble_pointer(touch.position)
if not _speech_bubble_pointer_pending:
get_viewport().set_input_as_handled()
return true
get_viewport().set_input_as_handled()
return true
if event is InputEventScreenDrag:
get_viewport().set_input_as_handled()
return true
return false
func _begin_speech_bubble_pointer(screen_position: Vector2) -> void:
if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null or not _speech_composer.visible:
return
if not _speech_stage_viewport_rect().has_point(screen_position):
return
var pointer_world: Vector2 = _viewport_to_world(screen_position)
var badge_index: int = _speech_preview.hit_test_order_badge(pointer_world)
var hits: Array[int] = []
if badge_index >= 0:
hits.append(badge_index)
else:
hits = _speech_preview.hit_test_bubbles(pointer_world)
_speech_bubble_pointer_sequence += 1
_speech_bubble_pointer_pending = true
_speech_bubble_pointer_moved = false
_speech_bubble_pointer_on_badge = badge_index >= 0
_speech_bubble_pointer_start_screen = screen_position
_speech_bubble_pointer_start_world = _viewport_to_world(screen_position)
_speech_bubble_pointer_hits = hits
_speech_bubble_pointer_index = hits[0] if not hits.is_empty() else -1
_speech_bubble_word_clicked = false
_speech_bubble_dragging = false
func _update_speech_badge_cursor() -> void:
var should_point: bool = false
if _speech_composer_is_open() and not _speech_placing and not _is_awaiting_click() and not _is_mouse_over_ui():
var mouse_position: Vector2 = get_viewport().get_mouse_position()
if _speech_stage_viewport_rect().has_point(mouse_position) and _speech_preview != null and is_instance_valid(_speech_preview):
should_point = _speech_preview.hit_test_order_badge(_viewport_to_world(mouse_position)) >= 0
if should_point == _speech_badge_cursor_active:
return
_speech_badge_cursor_active = should_point
if _speech_badge_cursor_active:
Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND)
else:
_apply_cursor()
func _update_speech_bubble_pointer(screen_position: Vector2) -> void:
if _speech_preview == null or not is_instance_valid(_speech_preview):
_cancel_speech_bubble_pointer()
return
if not _speech_bubble_dragging:
if screen_position.distance_to(_speech_bubble_pointer_start_screen) < SPEECH_BUBBLE_DRAG_THRESHOLD:
return
_speech_bubble_pointer_moved = true
if _speech_bubble_pointer_on_badge:
return
if _speech_bubble_pointer_hits.is_empty():
return
var drag_bubble_index: int = _speech_preview.hit_test_bubble_for_drag(_speech_bubble_pointer_start_world)
if drag_bubble_index < 0:
drag_bubble_index = _speech_bubble_pointer_index
_select_speech_bubble_from_stage(drag_bubble_index)
_speech_bubble_dragging = _speech_preview.begin_bubble_drag(_speech_bubble_pointer_start_world, drag_bubble_index)
if not _speech_bubble_dragging:
return
_speech_preview.update_bubble_drag(_viewport_to_world(screen_position))
func _resolve_speech_bubble_pointer_click(sequence: int, screen_position: Vector2) -> void:
if not _speech_bubble_pointer_pending or sequence != _speech_bubble_pointer_sequence:
return
if not _speech_bubble_pointer_moved and not _speech_bubble_word_clicked:
if _speech_bubble_pointer_hits.is_empty():
if _speech_composer != null and _speech_composer.visible:
_speech_composer.call("clear_stage_focus")
_clear_speech_bubble_pointer(sequence)
return
_select_speech_bubble_from_stage(_speech_bubble_pointer_index)
if _speech_bubble_pointer_hits.size() > 1:
_show_speech_bubble_picker(_speech_bubble_pointer_hits, screen_position)
_clear_speech_bubble_pointer(sequence)
func _clear_speech_bubble_pointer(sequence: int) -> void:
if sequence != _speech_bubble_pointer_sequence:
return
_speech_bubble_pointer_pending = false
_speech_bubble_pointer_moved = false
_speech_bubble_pointer_on_badge = false
_speech_bubble_pointer_hits.clear()
_speech_bubble_pointer_index = -1
_speech_bubble_word_clicked = false
func _cancel_speech_bubble_pointer() -> void:
if _speech_bubble_dragging and _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.end_bubble_drag()
_speech_bubble_dragging = false
_speech_bubble_pointer_pending = false
_speech_bubble_pointer_moved = false
_speech_bubble_pointer_on_badge = false
_speech_bubble_pointer_hits.clear()
_speech_bubble_pointer_index = -1
_speech_bubble_word_clicked = false
_speech_bubble_pointer_sequence += 1
func _select_speech_bubble_from_stage(index: int) -> void:
if _speech_composer == null or not _speech_composer.visible:
return
if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")):
_speech_style_toolbar.call("close_chooser")
_speech_composer.call("select_bubble_from_stage", index)
if _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.set_selected_bubble_index(index)
_speech_preview.set_selected_token(-1, -1, -1)
_show_speech_style_toolbar()
func _on_speech_preview_word_selected(bubble_index: int, start: int, end: int) -> void:
_on_speech_preview_token_selected(bubble_index, start, end)
func _on_speech_preview_token_selected(bubble_index: int, start: int, end: int) -> void:
if _speech_bubble_pointer_pending:
_speech_bubble_word_clicked = true
if _speech_composer == null or not _speech_composer.visible:
return
if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")):
_speech_style_toolbar.call("close_chooser")
_speech_composer.call("select_bubble_token_from_stage", bubble_index, start, end)
if _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.set_selected_bubble_index(bubble_index)
_speech_preview.set_selected_token(bubble_index, start, end)
_show_speech_style_toolbar()
func _show_speech_bubble_picker(hits: Array[int], screen_position: Vector2) -> void:
if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker):
_speech_bubble_picker.hide()
var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer
if ui_layer == null or _speech_composer == null:
return
var picker := PopupPanel.new()
picker.name = "SpeechBubblePicker"
picker.exclusive = false
picker.transient = true
picker.unfocusable = false
ui_layer.add_child(picker)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 8)
margin.add_theme_constant_override("margin_bottom", 8)
picker.add_child(margin)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 6)
margin.add_child(column)
var title := Label.new()
title.text = "Choose a bubble"
title.add_theme_font_size_override("font_size", 18)
column.add_child(title)
for index: int in hits:
var bubble_label: String = _speech_bubble_picker_label(index)
var choice := Button.new()
choice.text = bubble_label
choice.custom_minimum_size = Vector2(240.0, 44.0)
choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL
choice.pressed.connect(_on_speech_bubble_picker_selected.bind(index))
column.add_child(choice)
var viewport_size: Vector2 = get_viewport_rect().size
var popup_width: int = mini(280, maxi(220, int(viewport_size.x) - 16))
var popup_height: int = mini(80 + hits.size() * 50, maxi(100, int(viewport_size.y) - 16))
picker.size = Vector2i(popup_width, popup_height)
var popup_position := Vector2i(screen_position + Vector2(12.0, 12.0))
popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8))
popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8))
_speech_bubble_picker = picker
picker.popup_hide.connect(_on_speech_bubble_picker_closed.bind(picker))
picker.position = popup_position
picker.popup()
func _speech_bubble_picker_label(index: int) -> String:
var bubbles: Array = []
if _speech_composer != null and _speech_composer.has_method("get_draft"):
var draft: Dictionary = _speech_composer.call("get_draft")
bubbles = draft.get("bubbles", [])
if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary:
return "Bubble %d · Empty" % (index + 1)
var text: String = String((bubbles[index] as Dictionary).get("text", "")).replace("\n", " ").strip_edges()
if text.is_empty():
text = "Empty"
elif text.length() > 20:
text = text.substr(0, 19) + "…"
return "Bubble %d · %s" % [index + 1, text]
func _on_speech_bubble_picker_selected(index: int) -> void:
_select_speech_bubble_from_stage(index)
if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker):
_speech_bubble_picker.hide()
func _on_speech_bubble_picker_closed(picker: PopupPanel) -> void:
if _speech_bubble_picker == picker:
_speech_bubble_picker = null
if is_instance_valid(picker):
picker.queue_free()
func _on_speech_preview_line_tokens_requested(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void:
if _speech_bubble_pointer_pending:
_speech_bubble_word_clicked = true
_show_speech_token_picker(bubble_index, spans, viewport_position)
func _on_speech_preview_text_tap_detected(_bubble_index: int) -> void:
if _speech_bubble_pointer_pending:
_speech_bubble_word_clicked = true
func _show_speech_token_picker(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void:
if spans.is_empty() or _speech_composer == null:
return
if _speech_token_picker != null and is_instance_valid(_speech_token_picker):
_speech_token_picker.hide()
var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer
if ui_layer == null:
return
var picker: PopupPanel = PopupPanel.new()
picker.name = "SpeechTokenPicker"
picker.exclusive = false
picker.transient = true
picker.unfocusable = false
ui_layer.add_child(picker)
var margin: MarginContainer = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 8)
margin.add_theme_constant_override("margin_bottom", 8)
picker.add_child(margin)
var column: VBoxContainer = VBoxContainer.new()
column.add_theme_constant_override("separation", 6)
margin.add_child(column)
var title: Label = Label.new()
title.text = "Choose a word or mark"
title.add_theme_font_size_override("font_size", 18)
column.add_child(title)
var scroll: ScrollContainer = ScrollContainer.new()
scroll.custom_minimum_size = Vector2(300.0, minf(320.0, float(spans.size()) * 54.0))
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
column.add_child(scroll)
var choices: HFlowContainer = HFlowContainer.new()
choices.add_theme_constant_override("h_separation", 5)
choices.add_theme_constant_override("v_separation", 5)
scroll.add_child(choices)
var draft: Dictionary = _speech_composer.call("get_draft")
var bubbles: Array = draft.get("bubbles", [])
var text: String = String((bubbles[bubble_index] as Dictionary).get("text", "")) if bubble_index >= 0 and bubble_index < bubbles.size() and bubbles[bubble_index] is Dictionary else ""
for span: Vector2i in spans:
if span.x < 0 or span.y <= span.x or span.y > text.length():
continue
var token: String = text.substr(span.x, span.y - span.x)
var choice: Button = Button.new()
choice.text = token
choice.custom_minimum_size = Vector2(maxf(52.0, minf(160.0, 22.0 + float(token.length()) * 14.0)), 48.0)
choice.pressed.connect(_on_speech_token_picker_selected.bind(bubble_index, span.x, span.y))
choices.add_child(choice)
var viewport_size: Vector2 = get_viewport_rect().size
var popup_width: int = mini(360, maxi(220, int(viewport_size.x) - 16))
var popup_height: int = mini(420, maxi(130, int(viewport_size.y) - 16))
picker.size = Vector2i(popup_width, popup_height)
var popup_position: Vector2i = Vector2i(viewport_position + Vector2(12.0, 12.0))
popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8))
popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8))
_speech_token_picker = picker
picker.popup_hide.connect(_on_speech_token_picker_closed.bind(picker))
picker.position = popup_position
picker.popup()
func _on_speech_token_picker_selected(bubble_index: int, start: int, end: int) -> void:
_on_speech_preview_token_selected(bubble_index, start, end)
if _speech_token_picker != null and is_instance_valid(_speech_token_picker):
_speech_token_picker.hide()
func _on_speech_token_picker_closed(picker: PopupPanel) -> void:
if _speech_token_picker == picker:
_speech_token_picker = null
if is_instance_valid(picker):
picker.queue_free()
func _on_speech_style_chooser_open_requested(kind: StringName) -> void:
if _speech_composer == null or _speech_style_toolbar == null:
return
if bool(_speech_composer.call("is_message_dialog_open")):
_speech_composer.call("close_message_dialog")
_speech_composer.call("set_style_category", kind)
_speech_style_toolbar.call("set_context", _speech_composer.call("style_context"))
_speech_style_toolbar.call("present_chooser", kind)
func _on_speech_style_choice_selected(kind: StringName, value: String) -> void:
if _speech_composer == null:
return
_speech_composer.call("apply_style_choice", kind, value)
_speech_style_toolbar.call("set_context", _speech_composer.call("style_context"))
func _on_speech_style_sequence_action_requested(action: StringName) -> void:
if _speech_composer == null or not _speech_composer.visible:
return
match action:
&"earlier": _speech_composer.call("move_selected_bubble", -1)
&"later": _speech_composer.call("move_selected_bubble", 1)
&"add":
_speech_composer.call("add_bubble_after_selected")
call_deferred("_resolve_added_speech_bubble_collision")
&"remove": _speech_composer.call("remove_selected_bubble")
&"undo_remove": _speech_composer.call("undo_remove_bubble")
_refresh_speech_style_toolbar()
func _resolve_added_speech_bubble_collision() -> void:
if not _speech_composer_is_open() or _speech_preview == null or not is_instance_valid(_speech_preview):
return
var bubble_index: int = int(_speech_composer.call("selected_bubble_index"))
var suggestion: Dictionary = _speech_preview.suggest_added_bubble_center(bubble_index)
if not bool(suggestion.get("collided", false)):
return
var center: Variant = suggestion.get("center", null)
if center is Vector2:
_speech_composer.call("set_bubble_placement_preview", bubble_index, center)
func _on_speech_done_requested() -> void:
if _speech_composer != null and _speech_composer.visible:
_speech_composer.call("request_done_speech")
func _on_speech_cancel_requested() -> void:
if _speech_composer != null and _speech_composer.visible:
_speech_composer.call("request_cancel_speech")
func _on_speech_validation_failed(message: String, bubble_index: int) -> void:
_show_toast(message)
if bubble_index >= 0 and _speech_composer != null and _speech_composer.visible:
_speech_composer.call("open_message_dialog")
_refresh_speech_style_toolbar()
func _on_speech_show_choice_selected(key: StringName, value: Variant) -> void:
if _speech_composer == null or not _speech_composer.visible:
return
var action: Dictionary = _speech_composer.call("get_preview_action")
if key == &"connect_bubbles" and bool(value) and String(action.get("bubble_layout", "stack")) == "same_spot":
if _speech_preview == null or not is_instance_valid(_speech_preview):
return
var bubbles: Array = action.get("bubbles", [])
var centers: Array = _speech_preview.bubble_layout_centers(&"stack")
if bubbles.size() < 2 or centers.size() != bubbles.size():
return
_speech_composer.call("apply_bubble_layout", centers, "stack")
_speech_composer.call("choose_speech_show", key, value)
_speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled"))
_refresh_speech_style_toolbar()
func _on_speech_layout_preset_requested(preset: StringName) -> void:
if _speech_composer == null or not _speech_composer.visible or _speech_preview == null or not is_instance_valid(_speech_preview):
return
var centers: Array = _speech_preview.bubble_layout_centers(preset)
var action: Dictionary = _speech_composer.call("get_preview_action")
var bubbles: Array = action.get("bubbles", [])
if centers.is_empty() or centers.size() != bubbles.size():
return
_speech_composer.call("apply_bubble_layout", centers, String(preset))
_speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled"))
_refresh_speech_style_toolbar()
func _on_speech_stage_focus_changed(bubble_index: int) -> void:
if _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.set_selected_bubble_index(bubble_index)
if bubble_index < 0:
_speech_preview.set_selected_token(-1, -1, -1)
_refresh_speech_style_toolbar()
func _on_speech_style_message_requested(opened: bool) -> void:
if _speech_composer == null or _speech_style_toolbar == null:
return
if opened:
_speech_style_toolbar.call("close_chooser")
_speech_composer.call("open_message_dialog")
else:
_speech_composer.call("close_message_dialog")
_refresh_speech_style_toolbar()
func _on_speech_message_dialog_closed() -> void:
if _speech_style_toolbar != null:
_speech_style_toolbar.call("set_message_dialog_open", false)
_show_speech_style_toolbar()
func _on_speech_style_done_requested() -> void:
if _speech_composer != null and bool(_speech_composer.call("is_message_dialog_open")):
_speech_composer.call("close_message_dialog")
if _speech_composer != null:
_speech_composer.call("clear_stage_focus")
func _show_speech_style_toolbar() -> void:
_refresh_speech_style_toolbar()
func _on_speech_style_context_changed(context: Dictionary) -> void:
if _speech_style_toolbar != null:
_speech_style_toolbar.call("set_context", context)
if _speech_preview != null and is_instance_valid(_speech_preview):
var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {}
_speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context"))
var focus_index: int = int(_speech_composer.call("stage_focus_bubble_index"))
_speech_preview.set_selected_bubble_index(focus_index)
_speech_preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1)))
_refresh_speech_style_toolbar()
func _refresh_speech_style_toolbar() -> void:
if _speech_style_toolbar == null or _speech_show_toolbar == null or _speech_composer == null or _speech_preview == null or not is_instance_valid(_speech_preview) or not _speech_composer.visible:
if _speech_style_toolbar != null:
_speech_style_toolbar.hide()
if _speech_show_toolbar != null:
_speech_show_toolbar.hide()
_sync_speech_badge_avoidance()
return
var preview_state: String = String(_speech_composer.call("preview_state_name"))
var unavailable: bool = _speech_placing or _speech_bubble_dragging or preview_state != "static" or not _speech_preview.visible
var stage_focus_index: int = int(_speech_composer.call("stage_focus_bubble_index"))
if stage_focus_index < 0:
_speech_style_toolbar.hide()
_speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled"))
_speech_show_toolbar.call("set_busy", unavailable)
if unavailable:
_speech_show_toolbar.hide()
_sync_speech_badge_avoidance()
return
var group_rect: Rect2 = _speech_preview.group_frames_viewport_rect()
_sync_speech_badge_avoidance()
_speech_show_toolbar.call("set_anchor_rect", group_rect, _speech_stage_viewport_rect())
return
_speech_show_toolbar.hide()
_speech_style_toolbar.call("set_busy", unavailable)
if unavailable:
_speech_style_toolbar.hide()
_sync_speech_badge_avoidance()
return
var context: Dictionary = _speech_composer.call("style_context")
_speech_style_toolbar.call("set_context", context)
var bubble_index: int = int(context.get("bubble_index", -1))
var frame_rect: Rect2 = _speech_preview.selected_frame_viewport_rect(bubble_index)
_sync_speech_badge_avoidance()
_speech_style_toolbar.call("set_anchor_rect", frame_rect, _speech_stage_viewport_rect())
_speech_composer.call("set_message_dialog_anchor", frame_rect, _speech_stage_viewport_rect(), _speech_style_toolbar.call("toolbar_viewport_rect"))
func _sync_speech_badge_avoidance() -> void:
var badge_rects: Array[Rect2] = []
if _speech_preview != null and is_instance_valid(_speech_preview):
badge_rects = _speech_preview.order_badge_viewport_rects()
if _speech_show_toolbar != null and is_instance_valid(_speech_show_toolbar):
_speech_show_toolbar.call("set_badge_avoidance_rects", badge_rects)
if _speech_style_toolbar != null and is_instance_valid(_speech_style_toolbar):
_speech_style_toolbar.call("set_badge_avoidance_rects", badge_rects)
func _unhandled_input(event: InputEvent) -> void:
if _speech_composer_is_open() and bool(_speech_composer.call("is_message_dialog_open")):
get_viewport().set_input_as_handled()
return
if _speech_bubble_pointer_pending:
var bubble_mouse_event: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT
var bubble_touch_event: bool = event is InputEventScreenTouch
if bubble_mouse_event or bubble_touch_event:
get_viewport().set_input_as_handled()
return
if _speech_composer_is_open():
var speech_point: Vector2 = _speech_event_position(event)
var in_speech_stage: bool = speech_point.x >= 0.0 and speech_point.y >= 0.0 and _speech_stage_viewport_rect().has_point(speech_point)
var placement_press: bool = false
if _speech_placing and event is InputEventMouseButton:
var mouse_button: InputEventMouseButton = event as InputEventMouseButton
placement_press = mouse_button.pressed and (mouse_button.button_index == MOUSE_BUTTON_LEFT or mouse_button.button_index == MOUSE_BUTTON_RIGHT)
elif _speech_placing and event is InputEventScreenTouch:
placement_press = (event as InputEventScreenTouch).pressed
if in_speech_stage and not placement_press and (event is InputEventMouseButton or event is InputEventMouseMotion or event is InputEventScreenTouch or event is InputEventScreenDrag or event is InputEventGesture):
get_viewport().set_input_as_handled()
return
if event is InputEventMouseButton:
_handle_world_click(event)
elif event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed and _speech_placing:
_place_speech_bubble_at((event as InputEventScreenTouch).position)
func _place_speech_bubble_at(viewport_position: Vector2) -> void:
if not _speech_placing or _speech_actor == null or not is_instance_valid(_speech_actor) or _speech_composer == null:
return
if _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.set_placement_ghost(false)
var local_center: Vector2 = _speech_actor.to_local(_viewport_to_world(viewport_position))
if _speech_placement_bubble_index < 0:
_speech_composer.call("set_placement_preview", local_center)
else:
_speech_composer.call("set_bubble_placement_preview", _speech_placement_bubble_index, local_center)
_speech_placing = false
_speech_composer.call("set_placement_pending", false)
_speech_placement_bubble_index = 0
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.pressed or event.echo:
return
if _speech_composer_is_open():
if event.keycode == KEY_ESCAPE:
if bool(_speech_composer.call("is_message_dialog_open")):
_speech_composer.call("close_message_dialog")
elif _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")):
_speech_style_toolbar.call("close_chooser")
get_viewport().set_input_as_handled()
return
match event.keycode:
KEY_DELETE, KEY_BACKSPACE:
if current_mode == StageMode.EDIT:
delete_selected()
KEY_ESCAPE:
if _speech_placing:
_speech_placing = false
if _speech_composer != null:
_speech_composer.call("set_placement_pending", false)
_refresh_status()
elif 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:
if _speech_composer_is_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 window: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _speech_save_gate_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 _speech_placing:
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_speech_placing = false
if _speech_composer != null:
_speech_composer.call("set_placement_pending", false)
return
if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
if _speech_actor != null and is_instance_valid(_speech_actor) and _speech_composer != null:
_place_speech_bubble_at(mb.position)
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
if _rule_edit_uncommitted:
_show_toast("Finish or cancel the rule before switching modes.")
_sync_mode_buttons()
return
if _speech_composer_is_open():
_show_toast("Finish or cancel speech editing first.")
_sync_mode_buttons()
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:
if _speech_composer != null and _speech_composer.visible:
_finish_speech_composition(false)
_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, _speech_save_gate_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:
if _speech_composer_is_open():
if is_autosave:
return false
_speech_pending_scene_save_path = path
_speech_pending_scene_save_as = false
_speech_scene_save_pending = true
if _speech_save_gate_dialog != null and not _speech_save_gate_dialog.visible:
_speech_save_gate_dialog.popup_centered()
return false
if _rule_edit_uncommitted:
if not is_autosave:
_show_toast("Finish the rule before saving the scene.")
return false
var queue_error: String = _validate_queues_for_save()
if not queue_error.is_empty():
_show_toast(queue_error)
if _pending_file_action != PendingFileAction.NONE:
_pending_file_action = PendingFileAction.NONE
return false
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)
if (_speech_composer == null or not _speech_composer.visible) and not _current_scene_path.is_empty():
_speech_recovery_matches_loaded_scene()
return true
func _validate_queues_for_save() -> String:
for child: Node in _world.get_children():
if not child is StickmanRig:
continue
var rig: StickmanRig = child as StickmanRig
var actions: Array[Dictionary] = rig.get_queue()
var encoded: Dictionary = STAGE_SCENE_IO.encode_queue(actions)
if (encoded.get("actions", []) as Array).size() == actions.size():
continue
for action_index: int in actions.size():
var one_action: Array[Dictionary] = [actions[action_index]]
var one_encoded: Dictionary = STAGE_SCENE_IO.encode_queue(one_action)
if (one_encoded.get("actions", []) as Array).size() == 1:
continue
var character_name: String = rig.source_stk_path.get_file().trim_suffix(".stk")
if character_name.is_empty():
character_name = String(rig.name)
return "Can't save: %s action %d (%s) can't be saved. Edit or remove it from the queue." % [character_name, action_index + 1, String(actions[action_index].get("type", "unknown"))]
return ""
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", []):
var character_name: String = String(entry.get("stk_path", "")).get_file()
if character_name.is_empty():
character_name = file_id
warnings.append("%s: %s Open this character's queue to review the skipped action." % [character_name, warning])
for action: Dictionary in queue["actions"]:
(node as StickmanRig).queue_action(action)
else:
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
_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 warnings.is_empty():
_show_scene_load_warnings(warnings)
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_scene_load_warnings(warnings: Array[String]) -> void:
var dialog: AcceptDialog = AcceptDialog.new()
dialog.title = "Some saved actions need a look"
var lines: PackedStringArray = PackedStringArray()
for index: int in mini(warnings.size(), 12):
lines.append("• " + warnings[index])
if warnings.size() > 12:
lines.append("• And %d more warning(s)." % (warnings.size() - 12))
dialog.dialog_text = "These items could not be restored:\n\n%s" % "\n".join(lines)
add_child(dialog)
dialog.confirmed.connect(dialog.queue_free)
dialog.close_requested.connect(dialog.queue_free)
dialog.popup_centered(Vector2i(620, 420))
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)
if _speech_badge_cursor_active:
Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND)
return
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)
_speech_save_gate_dialog = ConfirmationDialog.new()
_speech_save_gate_dialog.name = "FinishSpeechBeforeSaveDialog"
_speech_save_gate_dialog.title = "Finish speech before saving"
_speech_save_gate_dialog.dialog_text = "Choose Done speech to finish editing before the scene is saved."
_speech_save_gate_dialog.ok_button_text = "Done speech"
_speech_save_gate_dialog.get_cancel_button().text = "Keep editing"
_speech_save_gate_dialog.confirmed.connect(_on_speech_save_gate_confirmed)
_speech_save_gate_dialog.canceled.connect(_on_speech_save_gate_cancelled)
ui.add_child(_speech_save_gate_dialog)
_apply_dialog_theme(_speech_save_gate_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)
_action_editor.speech_requested.connect(_on_action_editor_speech_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))
_speech_composer = SPEECH_COMPOSER.instantiate() as Control
_speech_composer.connect("draft_changed", _on_speech_draft_changed)
_speech_composer.connect("placement_requested", _on_speech_placement_requested)
_speech_composer.connect("placement_reset_requested", _on_speech_placement_reset)
_speech_composer.connect("bubble_placement_requested", _on_speech_bubble_placement_requested)
_speech_composer.connect("bubble_placement_reset_requested", _on_speech_bubble_placement_reset)
_speech_composer.connect("replay_requested", _on_speech_replay_requested)
_speech_composer.connect("preview_state_changed", _on_speech_preview_state_changed)
_speech_composer.connect("style_context_changed", _on_speech_style_context_changed)
_speech_composer.connect("stage_focus_changed", _on_speech_stage_focus_changed)
_speech_composer.connect("message_dialog_closed", _on_speech_message_dialog_closed)
_speech_composer.connect("committed", _on_speech_committed)
_speech_composer.connect("cancelled", _on_speech_cancelled)
_speech_composer.connect("validation_failed", _on_speech_validation_failed)
ui.add_child(_speech_composer)
_speech_style_toolbar = SPEECH_STYLE_TOOLBAR.new() as Control
_speech_style_toolbar.name = "SpeechStyleToolbar"
_speech_style_toolbar.call("configure", _sandbox_theme)
_speech_style_toolbar.connect("chooser_open_requested", _on_speech_style_chooser_open_requested)
_speech_style_toolbar.connect("choice_selected", _on_speech_style_choice_selected)
_speech_style_toolbar.connect("done_styling_requested", _on_speech_style_done_requested)
_speech_style_toolbar.connect("message_edit_requested", _on_speech_style_message_requested)
_speech_style_toolbar.connect("sequence_action_requested", _on_speech_style_sequence_action_requested)
ui.add_child(_speech_style_toolbar)
_speech_show_toolbar = SPEECH_SHOW_TOOLBAR.new() as Control
_speech_show_toolbar.name = "SpeechShowToolbar"
_speech_show_toolbar.call("configure", _sandbox_theme)
_speech_show_toolbar.connect("choice_selected", _on_speech_show_choice_selected)
_speech_show_toolbar.connect("layout_preset_requested", _on_speech_layout_preset_requested)
_speech_show_toolbar.connect("preview_speech_requested", _on_speech_toolbar_preview_requested)
_speech_show_toolbar.connect("done_speech_requested", _on_speech_done_requested)
_speech_show_toolbar.connect("cancel_speech_requested", _on_speech_cancel_requested)
ui.add_child(_speech_show_toolbar)
_speech_recovery_dialog = ConfirmationDialog.new()
_speech_recovery_dialog.title = "Speech draft found"
_speech_recovery_dialog.dialog_text = "Restore your unsaved speech draft?"
_speech_recovery_dialog.get_ok_button().text = "Restore"
_speech_recovery_dialog.get_cancel_button().text = "Discard"
_speech_recovery_dialog.confirmed.connect(_on_speech_recovery_restore)
_speech_recovery_dialog.canceled.connect(_on_speech_recovery_discard)
ui.add_child(_speech_recovery_dialog)
_speech_destination_dialog = ConfirmationDialog.new()
_speech_destination_dialog.title = "Save recovered speech"
_speech_destination_dialog.dialog_text = "Add this speech to the chosen character’s action queue?"
_speech_destination_dialog.get_ok_button().text = "Add to queue"
_speech_destination_dialog.get_cancel_button().text = "Keep editing"
_speech_destination_dialog.confirmed.connect(_commit_unbound_recovered_speech)
ui.add_child(_speech_destination_dialog)
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:
if id in [0, 1, 4] and _speech_composer_is_open():
_show_toast("Finish or cancel speech editing first.")
return
if id in [0, 1, 4] and _rule_edit_uncommitted:
_show_toast("Finish or cancel the rule before changing scenes.")
return
match id:
0:
_request_pending_file_action(PendingFileAction.NEW_SCENE)
1:
_request_pending_file_action(PendingFileAction.OPEN_DIALOG)
2: _request_scene_save()
3: _request_scene_save("", true)
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"
_request_scene_save(final_path)
func _request_scene_save(path: String = "", force_save_as: bool = false) -> void:
if _speech_composer_is_open():
_speech_pending_scene_save_path = path
_speech_pending_scene_save_as = force_save_as
_speech_scene_save_pending = true
if _speech_save_gate_dialog != null and not _speech_save_gate_dialog.visible:
_speech_save_gate_dialog.popup_centered()
return
if _rule_edit_uncommitted:
_show_toast("Finish the rule before saving the scene.")
return
if force_save_as or (path.is_empty() and _current_scene_path.is_empty()):
_scene_save_dialog.popup_centered_ratio(0.7)
return
var target_path: String = path if not path.is_empty() else _current_scene_path
if not target_path.is_empty() and _save_scene_to_path(target_path) and _pending_file_action != PendingFileAction.NONE:
_continue_pending_file_action()
func _on_speech_save_gate_confirmed() -> void:
if _speech_composer != null and _speech_composer.visible:
_speech_composer.call("request_done_speech")
func _on_speech_save_gate_cancelled() -> void:
_speech_pending_scene_save_path = ""
_speech_pending_scene_save_as = false
_speech_scene_save_pending = false
func _continue_deferred_speech_scene_save() -> void:
if not _speech_scene_save_pending or _rule_edit_uncommitted:
return
var requested_path: String = _speech_pending_scene_save_path
var force_save_as: bool = _speech_pending_scene_save_as
_speech_pending_scene_save_path = ""
_speech_pending_scene_save_as = false
_speech_scene_save_pending = false
if force_save_as or (requested_path.is_empty() and _current_scene_path.is_empty()):
_scene_save_dialog.popup_centered_ratio(0.7)
return
var target_path: String = requested_path if not requested_path.is_empty() else _current_scene_path
if not target_path.is_empty() and _save_scene_to_path(target_path) and _pending_file_action != PendingFileAction.NONE:
_continue_pending_file_action()
func _finish_rule_commit_deferred_speech_save() -> void:
_rule_edit_uncommitted = false
_continue_deferred_speech_scene_save()
func _cancel_deferred_speech_scene_save(reason: String) -> void:
if not _speech_scene_save_pending and not _rule_edit_uncommitted:
return
var had_pending_save: bool = _speech_scene_save_pending
_speech_pending_scene_save_path = ""
_speech_pending_scene_save_as = false
_speech_scene_save_pending = false
_rule_edit_uncommitted = false
if had_pending_save:
_show_toast(reason)
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:
if _rule_edit_uncommitted:
_show_toast("Finish or cancel the rule before changing scenes.")
return
if _speech_composer_is_open():
_show_toast("Finish or cancel speech editing first.")
return
_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()
_request_scene_save()
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:
_open_speech_composer(_context_rig, {"type": "speak", "text": "", "duration": 2.0}, false, "direct")
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_edit_uncommitted = true
_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:
_cancel_deferred_speech_scene_save("Scene save canceled because the rule was not committed. Finish the rule and save again.")
_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
_open_speech_composer(hit as StickmanRig, {"type": "speak", "text": "", "duration": 2.0}, true, "rule_builder")
"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()
_finish_rule_commit_deferred_speech_save()
func _cancel_rule_build() -> void:
_cancel_deferred_speech_scene_save("Scene save canceled because the rule was not committed. Finish the rule and save again.")
_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":
return ACTION_REGISTRY.from_rule_action(a)
"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_edit_uncommitted = true
_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_edit_uncommitted = true
_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()
_finish_rule_commit_deferred_speech_save()
func _on_rule_editor_cancelled() -> void:
_cancel_deferred_speech_scene_save("Scene save canceled because the rule was not committed. Finish the rule and save again.")
_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_speech_requested(initial: Dictionary) -> void:
if _action_editor != null:
_action_editor.hide()
var actor: StickmanRig = _action_editor_preview_rig()
if (actor == null or not is_instance_valid(actor)) and _action_editor_kind == "rule_add":
_pending_speech_initial = initial.duplicate(true)
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the character who will speak (Esc or right-click to cancel)", _cb_speech_actor_capture, _cb_speech_actor_capture_cancel)
return
if actor == null or not is_instance_valid(actor):
_show_toast("Choose a character before composing speech")
_on_action_editor_cancelled()
return
_open_speech_composer(actor, initial, _action_editor_kind == "rule_add" or _action_editor_kind == "rule_edit", "action_editor")
func _cb_speech_actor_capture(value: Node2D) -> void:
if not value is StickmanRig:
_cb_speech_actor_capture_cancel()
return
_action_editor_actor_id = value.get_instance_id()
_open_speech_composer(value as StickmanRig, _pending_speech_initial, true, "action_editor")
_pending_speech_initial.clear()
func _cb_speech_actor_capture_cancel() -> void:
_pending_speech_initial.clear()
if _rule_editor != null:
_rule_editor.popup_centered()
func _open_speech_composer(actor: StickmanRig, initial: Dictionary, reactive_options: bool, context: String) -> void:
if actor == null or not is_instance_valid(actor) or _speech_composer == null:
return
if _speech_composer.visible:
_finish_speech_composition(false)
var speech_initial: Dictionary = initial.duplicate(true)
if not _speech_recovery_in_progress and context == "action_editor" and _action_editor_kind == "queue_edit" and _action_editor_rig == actor and _action_editor_action_id >= 0:
var queue_index: int = actor.index_for_action_id(_action_editor_action_id)
var queue: Array[Dictionary] = actor.get_queue()
if queue_index >= 0 and queue_index < queue.size() and String(queue[queue_index].get("type", "")) == "speak":
speech_initial = queue[queue_index].duplicate(true)
if _speech_recovery_matches_loaded_scene() and not _speech_recovery_dialog.visible and not _speech_recovery_in_progress:
_speech_pending_open = {"actor_id": actor.get_instance_id(), "initial": speech_initial, "reactive": reactive_options, "context": context}
_speech_recovery_dialog.popup_centered()
return
_speech_actor = actor
_speech_session_kind = context
_speech_origin = _capture_speech_origin(actor, context)
_speech_restored_unbound = context == "recovered_unbound"
_speech_placing = false
_speech_placement_bubble_index = 0
_cancel_speech_bubble_pointer()
_speech_actor.set_speech_preview_suppressed(true)
_speech_preview = SpeechBubble.new()
_speech_preview.name = "SpeechPreview"
_speech_preview.bubble_position_changed.connect(_on_speech_bubble_position_changed)
_speech_preview.stage_token_selected.connect(_on_speech_preview_token_selected)
_speech_preview.stage_line_tokens_requested.connect(_on_speech_preview_line_tokens_requested)
_speech_preview.stage_text_tap_detected.connect(_on_speech_preview_text_tap_detected)
_speech_actor.add_child(_speech_preview)
_speech_preview.set_mirrored(_speech_actor.facing_profile == StickmanRig.FacingProfile.LEFT)
_speech_composer.call("open_for", actor, speech_initial, reactive_options)
_speech_draft_dirty = true
_speech_draft_timer = SPEECH_DRAFT_DEBOUNCE
func _on_speech_draft_changed(action: Dictionary) -> void:
_speech_draft_dirty = true
_speech_draft_timer = SPEECH_DRAFT_DEBOUNCE
if _speech_preview != null and is_instance_valid(_speech_preview) and _speech_composer != null:
var preview_state: String = String(_speech_composer.call("preview_state_name"))
var preview_action: Dictionary = _speech_composer.call("get_preview_action")
_speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context"))
_speech_preview.render_action(preview_action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing")
var context: Dictionary = _speech_composer.call("style_context")
var focus_index: int = int(_speech_composer.call("stage_focus_bubble_index"))
_speech_preview.set_selected_bubble_index(focus_index)
var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {}
_speech_preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1)))
_sync_speech_preview_overflow(action)
_speech_style_toolbar.call("set_context", context)
_refresh_speech_style_toolbar()
func _update_speech_composition() -> void:
if _speech_composer == null or not _speech_composer.visible:
return
if _speech_actor == null or not is_instance_valid(_speech_actor):
_show_toast("The character is gone. Your speech draft was saved for recovery.")
_finish_speech_composition(false)
return
if _speech_preview == null or not is_instance_valid(_speech_preview):
return
_speech_preview.set_mirrored(_speech_actor.facing_profile == StickmanRig.FacingProfile.LEFT)
_speech_preview.set_selected_bubble_index(int(_speech_composer.call("stage_focus_bubble_index")))
if not bool(_speech_composer.call("preview_visible")):
_speech_preview.visible = false
return
_speech_preview.visible = true
var action: Dictionary = _speech_composer.call("get_preview_action")
var preview_state: String = String(_speech_composer.call("preview_state_name"))
_speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context"))
if _speech_placing:
var world_pos: Vector2 = _camera.get_global_mouse_position()
var hover_action: Dictionary = action.duplicate(true)
var local_center: Vector2 = _speech_actor.to_local(world_pos)
if _speech_placement_bubble_index < 0:
hover_action["placement"] = {"mode": "manual", "center": local_center}
else:
var bubbles: Array = hover_action.get("bubbles", [])
if _speech_placement_bubble_index < bubbles.size() and bubbles[_speech_placement_bubble_index] is Dictionary:
var bubble: Dictionary = bubbles[_speech_placement_bubble_index]
bubble["placement"] = {"mode": "manual", "center": local_center}
bubbles[_speech_placement_bubble_index] = bubble
hover_action["bubbles"] = bubbles
_speech_preview.set_placement_ghost(false)
_speech_preview.render_action(hover_action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing")
else:
_speech_preview.set_placement_ghost(false)
_speech_preview.render_action(action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing")
_sync_speech_preview_overflow(action)
var context: Dictionary = _speech_composer.call("style_context")
_speech_preview.set_selected_token(int(context.get("bubble_index", -1)), int((context.get("target", {}) as Dictionary).get("start", -1)), int((context.get("target", {}) as Dictionary).get("end", -1)))
_refresh_speech_style_toolbar()
func _on_speech_placement_requested() -> void:
_speech_placement_bubble_index = -1
_speech_placing = true
_speech_composer.call("set_placement_pending", true, -1)
_show_toast("Click the stage to place the bubble. Esc cancels.")
func _on_speech_placement_reset() -> void:
_speech_placing = false
if _speech_composer != null:
_speech_composer.call("set_placement_pending", false)
func _on_speech_bubble_placement_requested(bubble_index: int) -> void:
_speech_placement_bubble_index = bubble_index
_speech_placing = true
_speech_composer.call("set_placement_pending", true, bubble_index)
_show_toast("Move this bubble: click the stage. Esc cancels.")
func _on_speech_bubble_placement_reset(_bubble_index: int) -> void:
_speech_placing = false
if _speech_composer != null:
_speech_composer.call("set_placement_pending", false)
func _on_speech_bubble_position_changed(bubble_index: int, center_rig: Vector2) -> void:
if _speech_composer == null or not _speech_composer.visible:
return
_speech_composer.call("set_bubble_placement_preview", bubble_index, center_rig)
func _on_speech_replay_requested() -> void:
if _speech_composer != null and _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.visible = true
var action: Dictionary = _speech_composer.call("get_preview_action")
var preview_state: String = String(_speech_composer.call("preview_state_name"))
_speech_preview.render_action(action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing")
_sync_speech_preview_overflow(action)
func _on_speech_toolbar_preview_requested() -> void:
if _speech_composer != null and _speech_composer.visible:
_speech_composer.call("replay_preview_once")
func _on_speech_preview_state_changed(_state: String, elapsed: float, preview_visible: bool) -> void:
if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null:
return
_speech_preview.visible = preview_visible
if preview_visible:
var action: Dictionary = _speech_composer.call("get_preview_action")
_speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context"))
_speech_preview.render_action(action, elapsed, true, _state != "playing")
_sync_speech_preview_overflow(action)
_refresh_speech_style_toolbar()
func _sync_speech_preview_overflow(action: Dictionary) -> void:
if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null:
return
var overflow: bool = _speech_preview.has_display_overflow()
var arrangement: String = String(action.get("arrangement", "together"))
var bubble_layout: String = String(action.get("bubble_layout", "stack"))
_speech_composer.call("set_preview_overflow", overflow, arrangement, bubble_layout)
func _on_speech_committed(action: Dictionary) -> void:
if _speech_session_kind == "recovered_unbound":
_speech_pending_commit_action = action.duplicate(true)
_speech_destination_dialog.popup_centered()
return
var speech_updates_uncommitted_rule: bool = _speech_session_kind == "rule_builder" or (
_speech_session_kind == "action_editor" and _action_editor_kind in ["rule_add", "rule_edit"]
)
if not _commit_speech_action(action):
_show_toast("Could not save speech here. Your draft is still open.")
return
_clear_speech_draft_file()
_finish_speech_composition(true)
if speech_updates_uncommitted_rule:
_rule_edit_uncommitted = true
if _speech_scene_save_pending:
_show_toast("Speech added to the rule. Finish the rule before saving the scene.")
else:
_continue_deferred_speech_scene_save()
func _on_speech_cancelled() -> void:
var context: String = _speech_session_kind
_speech_pending_commit_action.clear()
_speech_pending_scene_save_path = ""
_speech_pending_scene_save_as = false
_speech_scene_save_pending = false
_clear_speech_draft_file()
_finish_speech_composition(true)
if context == "action_editor":
_on_action_editor_cancelled()
elif context == "rule_builder":
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the character who will speak, or choose another action"
_refresh_status()
func _finish_speech_composition(clear_recovery: bool = false) -> void:
if _speech_composer != null and _speech_composer.visible and not clear_recovery:
var matching_queue_edit: bool = false
var origin_check: Dictionary = _validate_speech_origin(_speech_origin)
var draft_value: Variant = _speech_composer.call("get_draft")
if not origin_check.is_empty() and String(origin_check.get("context", "")) == "action_editor" and String(origin_check.get("kind", "")) == "queue_edit" and draft_value is Dictionary:
matching_queue_edit = _speech_action_matches_queue_edit(draft_value as Dictionary, origin_check["actor"] as StickmanRig, int(origin_check["queue_index"]))
if matching_queue_edit:
_clear_speech_draft_file()
else:
_persist_speech_draft_now()
if clear_recovery:
_clear_speech_draft_file()
_speech_placing = false
_cancel_speech_bubble_pointer()
_speech_placement_bubble_index = 0
if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker):
_speech_bubble_picker.hide()
if _speech_token_picker != null and is_instance_valid(_speech_token_picker):
_speech_token_picker.hide()
if _speech_style_toolbar != null:
_speech_style_toolbar.call("close_chooser")
_speech_style_toolbar.call("set_message_dialog_open", false)
_speech_style_toolbar.hide()
if _speech_show_toolbar != null:
_speech_show_toolbar.hide()
if _speech_destination_dialog != null:
_speech_destination_dialog.hide()
if _speech_save_gate_dialog != null:
_speech_save_gate_dialog.hide()
if _speech_composer != null:
_speech_composer.call("set_placement_pending", false)
if _speech_preview != null and is_instance_valid(_speech_preview):
_speech_preview.queue_free()
_speech_preview = null
if _speech_actor != null and is_instance_valid(_speech_actor):
_speech_actor.set_speech_preview_suppressed(false)
_speech_actor = null
_speech_session_kind = ""
if _speech_composer != null:
_speech_composer.call("close_without_commit")
_speech_draft_timer = -1.0
_speech_draft_dirty = false
_speech_origin.clear()
_speech_restored_unbound = false
func _capture_speech_origin(actor: StickmanRig, context: String) -> Dictionary:
var origin: Dictionary = {
"context": context,
"scene_path": _current_scene_path,
"scene_fingerprint": _speech_scene_fingerprint(),
"actor_file_id": _speech_actor_file_id(actor),
"actor_stk_path": actor.source_stk_path if actor != null and is_instance_valid(actor) else "",
"action_editor_kind": _action_editor_kind,
"action_editor_index": _action_editor_index,
"restore_queue": _action_editor_restore_queue,
"queue_index": -1,
"queue_action": {},
"insert_after": _action_editor_insert_after,
}
if context != "action_editor" or actor == null or not is_instance_valid(actor):
return origin
if _action_editor_kind == "queue_edit" and _action_editor_rig == actor and _action_editor_action_id >= 0:
var queue_index: int = actor.index_for_action_id(_action_editor_action_id)
origin["queue_index"] = queue_index
origin["queue_action"] = _queue_action_wire_at(actor, queue_index)
elif _action_editor_kind == "queue_insert" and _action_editor_rig == actor:
var insert_index: int = _action_editor_index
if _action_editor_target_action_id >= 0:
insert_index = actor.index_for_action_id(_action_editor_target_action_id)
origin["queue_index"] = insert_index
origin["queue_action"] = _queue_action_wire_at(actor, insert_index)
return origin
func _speech_actor_file_id(actor: StickmanRig) -> String:
if actor == null or not is_instance_valid(actor):
return ""
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for child: Node in _world.get_children():
if not child is Node2D:
continue
var node: Node2D = child as Node2D
var object_type: String = _speech_object_type(node)
if object_type.is_empty():
continue
counters[object_type] = int(counters[object_type]) + 1
if node == actor:
return "%s_%d" % [object_type, int(counters[object_type])]
return ""
func _speech_object_type(node: Node2D) -> String:
if node is TerrainBlock:
return "terrain"
if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER:
return "prop"
if node is STICKMAN_RIG or node.get_script() == MISSING_STICKMAN_PLACEHOLDER:
return "stickman"
if node is TriggerArea:
return "area"
return ""
func _find_speech_actor(file_id: String, source_path: String) -> StickmanRig:
if file_id.is_empty():
return null
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for child: Node in _world.get_children():
if not child is Node2D:
continue
var node: Node2D = child as Node2D
var object_type: String = _speech_object_type(node)
if object_type.is_empty():
continue
counters[object_type] = int(counters[object_type]) + 1
if "%s_%d" % [object_type, int(counters[object_type])] != file_id:
continue
if not node is StickmanRig:
return null
var actor: StickmanRig = node as StickmanRig
return actor if actor.source_stk_path == source_path else null
return null
func _speech_scene_fingerprint() -> String:
if _world == null or _camera == null:
return ""
var data: Dictionary = _collect_scene_data(false)
var stable: Dictionary = {"scene_name": _scene_name, "objects": data.get("objects", []), "event_rules": data.get("event_rules", [])}
var digest: HashingContext = HashingContext.new()
if digest.start(HashingContext.HASH_SHA256) != OK:
return ""
digest.update(JSON.stringify(stable).to_utf8_buffer())
return digest.finish().hex_encode()
func _queue_action_wire_at(actor: StickmanRig, index: int) -> Dictionary:
if actor == null or not is_instance_valid(actor) or index < 0:
return {}
var queue: Array[Dictionary] = actor.get_queue()
if index >= queue.size():
return {}
var one_action: Array[Dictionary] = [queue[index]]
var encoded: Dictionary = STAGE_SCENE_IO.encode_queue(one_action)
var actions: Array = encoded.get("actions", [])
return actions[0] if not actions.is_empty() and actions[0] is Dictionary else {}
func _validate_speech_origin(origin: Dictionary) -> Dictionary:
if _current_scene_path.is_empty() or String(origin.get("scene_path", "")) != _current_scene_path:
return {}
var scene_fingerprint_matches: bool = String(origin.get("scene_fingerprint", "")) == _speech_scene_fingerprint()
var actor: StickmanRig = _find_speech_actor(String(origin.get("actor_file_id", "")), String(origin.get("actor_stk_path", "")))
if actor == null:
return {}
var context: String = String(origin.get("context", ""))
if context == "direct":
return {"actor": actor, "context": "direct"} if scene_fingerprint_matches else {}
if context != "action_editor":
return {}
var editor_kind: String = String(origin.get("action_editor_kind", ""))
if editor_kind == "queue_add":
return {"actor": actor, "context": "action_editor", "kind": editor_kind} if scene_fingerprint_matches else {}
if editor_kind != "queue_edit" and editor_kind != "queue_insert":
return {}
var queue_index: int = int(origin.get("queue_index", -1))
var expected: Dictionary = origin.get("queue_action", {})
if queue_index < 0 or expected.is_empty() or _queue_action_wire_at(actor, queue_index) != expected:
return {}
if editor_kind == "queue_edit":
var action_id: int = actor.action_id_at(queue_index)
if action_id < 0 or _queue_action_consumed(actor.get_action_status(action_id)):
return {}
return {"actor": actor, "context": "action_editor", "kind": editor_kind, "queue_index": queue_index}
func _check_speech_recovery() -> void:
if _speech_recovery_dialog != null and _speech_recovery_matches_loaded_scene():
_speech_recovery_dialog.popup_centered()
func _speech_recovery_matches_loaded_scene() -> bool:
if _current_scene_path.is_empty():
return false
if not FileAccess.file_exists(SPEECH_DRAFT_PATH) and not FileAccess.file_exists(SPEECH_DRAFT_PATH + ".bak"):
return false
var document: Dictionary = _read_speech_draft_document()
var origin_value: Variant = document.get("origin", null)
if not origin_value is Dictionary:
return false
var validated: Dictionary = _validate_speech_origin(origin_value as Dictionary)
if validated.is_empty():
return false
var draft_value: Variant = document.get("draft", null)
if draft_value is Dictionary and String(validated.get("context", "")) == "action_editor" and String(validated.get("kind", "")) == "queue_edit":
if _speech_action_matches_queue_edit(draft_value as Dictionary, validated["actor"] as StickmanRig, int(validated["queue_index"])):
_clear_speech_draft_file()
return false
return true
func _speech_action_matches_queue_edit(draft: Dictionary, actor: StickmanRig, queue_index: int) -> bool:
if actor == null or not is_instance_valid(actor):
return false
var queue: Array[Dictionary] = actor.get_queue()
if queue_index < 0 or queue_index >= queue.size() or String(queue[queue_index].get("type", "")) != "speak":
return false
var draft_payload: Dictionary = SPEECH_PAYLOAD.normalize(draft)
var saved_payload: Dictionary = SPEECH_PAYLOAD.normalize(queue[queue_index])
draft_payload.erase("legacy_fixed")
saved_payload.erase("legacy_fixed")
return draft_payload == saved_payload
func _read_speech_draft_document() -> Dictionary:
if not FileAccess.file_exists(SPEECH_DRAFT_PATH) and FileAccess.file_exists(SPEECH_DRAFT_PATH + ".bak"):
DirAccess.rename_absolute(ProjectSettings.globalize_path(SPEECH_DRAFT_PATH + ".bak"), ProjectSettings.globalize_path(SPEECH_DRAFT_PATH))
if not FileAccess.file_exists(SPEECH_DRAFT_PATH):
return {}
var file: FileAccess = FileAccess.open(SPEECH_DRAFT_PATH, FileAccess.READ)
if file == null:
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
return parsed as Dictionary if parsed is Dictionary and int((parsed as Dictionary).get("schema", -1)) == SPEECH_DRAFT_SCHEMA else {}
func _on_speech_recovery_restore() -> void:
var document: Dictionary = _read_speech_draft_document()
var payload_value: Variant = document.get("draft", null)
var origin_value: Variant = document.get("origin", {})
if payload_value == null or not payload_value is Dictionary:
_show_toast("That speech draft could not be restored.")
_clear_speech_draft_file()
_open_pending_speech_request()
return
_speech_restored_origin = origin_value as Dictionary if origin_value is Dictionary else {}
var validated: Dictionary = _validate_speech_origin(_speech_restored_origin)
var recovered_payload: Dictionary = (payload_value as Dictionary).duplicate(true)
var origin_action_value: Variant = _speech_restored_origin.get("queue_action", null)
if String(_speech_restored_origin.get("context", "")) == "action_editor" and String(_speech_restored_origin.get("action_editor_kind", "")) == "queue_edit" and origin_action_value is Dictionary and String((origin_action_value as Dictionary).get("type", "")) == "speak":
recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, origin_action_value as Dictionary)
var restored_from_pending_edit: bool = false
var pending_actor: StickmanRig = instance_from_id(int(_speech_pending_open.get("actor_id", 0))) as StickmanRig
var pending_initial_value: Variant = _speech_pending_open.get("initial", {})
if pending_actor != null and is_instance_valid(pending_actor) and String(_speech_pending_open.get("context", "")) == "action_editor" and String(_speech_restored_origin.get("action_editor_kind", "")) == "queue_edit" and String(_speech_restored_origin.get("actor_file_id", "")) == _speech_actor_file_id(pending_actor) and String(_speech_restored_origin.get("actor_stk_path", "")) == pending_actor.source_stk_path and pending_initial_value is Dictionary:
var pending_initial: Dictionary = pending_initial_value as Dictionary
var pending_encoded: Dictionary = STAGE_SCENE_IO.encode_queue([pending_initial])
var pending_actions: Array = pending_encoded.get("actions", [])
var saved_origin_action: Variant = _speech_restored_origin.get("queue_action", null)
if not pending_actions.is_empty() and pending_actions[0] == saved_origin_action:
recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, pending_initial)
if validated.is_empty():
validated = {
"actor": pending_actor,
"context": "action_editor",
"kind": "queue_edit",
"queue_index": int(_speech_restored_origin.get("queue_index", -1)),
}
restored_from_pending_edit = true
if not validated.is_empty() and String(validated.get("context", "")) == "action_editor" and String(validated.get("kind", "")) == "queue_edit":
var saved_actor: StickmanRig = validated["actor"]
var saved_index: int = int(validated.get("queue_index", -1))
var saved_queue: Array[Dictionary] = saved_actor.get_queue()
if saved_index >= 0 and saved_index < saved_queue.size():
recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, saved_queue[saved_index])
_speech_restored_payload = SPEECH_PAYLOAD.normalize(recovered_payload)
var selected_bubble: int = clampi(int(document.get("selected_bubble", 0)), 0, maxi(0, (_speech_restored_payload.get("bubbles", []) as Array).size() - 1))
_speech_restored_selected_bubble = selected_bubble
if not validated.is_empty():
var actor: StickmanRig = validated["actor"]
var context: String = String(validated["context"])
if context == "action_editor":
var editor_kind: String = String(validated["kind"])
_action_editor_kind = editor_kind
_action_editor_rig = actor
_action_editor_actor_id = int(actor.get_instance_id())
_action_editor_restore_queue = bool(_speech_restored_origin.get("restore_queue", editor_kind == "queue_edit" or editor_kind == "queue_insert"))
_action_editor_insert_after = bool(_speech_restored_origin.get("insert_after", false))
_action_editor_index = int(_speech_restored_origin.get("action_editor_index", -1))
_action_editor_action_id = -1
_action_editor_target_action_id = -1
if editor_kind == "queue_edit":
_action_editor_index = int(validated["queue_index"])
_action_editor_action_id = actor.action_id_at(_action_editor_index)
elif editor_kind == "queue_insert":
_action_editor_index = int(validated["queue_index"])
_action_editor_target_action_id = actor.action_id_at(_action_editor_index)
_speech_recovery_in_progress = true
_open_speech_composer(actor, _speech_restored_payload, context == "action_editor", context)
_speech_recovery_in_progress = false
if not restored_from_pending_edit:
_speech_origin = _speech_restored_origin.duplicate(true)
_speech_composer.call("set_draft", _speech_restored_payload)
_speech_composer.call("set_selected_bubble_index", selected_bubble)
else:
_speech_restored_unbound = true
_speech_recovery_in_progress = true
_begin_capture(CaptureKind.STICKMAN, "Choose a character for this recovered speech", _on_speech_unbound_actor_capture, _on_speech_unbound_actor_cancel)
_speech_recovery_in_progress = false
_clear_pending_speech_request()
func _restore_empty_recovered_bubble_text(recovered: Dictionary, saved_action: Dictionary) -> Dictionary:
var result: Dictionary = recovered.duplicate(true)
var saved: Dictionary = SPEECH_PAYLOAD.normalize(saved_action)
var saved_bubbles: Array = saved.get("bubbles", [])
var recovered_bubbles_value: Variant = result.get("bubbles", null)
if recovered_bubbles_value is Array:
var recovered_bubbles: Array = recovered_bubbles_value
for index: int in mini(recovered_bubbles.size(), saved_bubbles.size()):
if not recovered_bubbles[index] is Dictionary or not saved_bubbles[index] is Dictionary:
continue
var bubble: Dictionary = recovered_bubbles[index]
if not String(bubble.get("text", "")).strip_edges().is_empty():
continue
var saved_text: String = String((saved_bubbles[index] as Dictionary).get("text", ""))
if saved_text.strip_edges().is_empty():
continue
bubble["text"] = saved_text
recovered_bubbles[index] = bubble
result["bubbles"] = recovered_bubbles
if not recovered_bubbles.is_empty() and recovered_bubbles[0] is Dictionary:
result["text"] = String((recovered_bubbles[0] as Dictionary).get("text", ""))
elif String(result.get("text", "")).strip_edges().is_empty():
result["text"] = String(saved.get("text", ""))
elif String(result.get("text", "")).strip_edges().is_empty():
result["text"] = String(saved.get("text", ""))
return result
func _on_speech_recovery_discard() -> void:
_clear_speech_draft_file()
_clear_pending_speech_request()
_open_pending_speech_request()
func _on_speech_unbound_actor_capture(value: Node2D) -> void:
if not value is StickmanRig:
_on_speech_unbound_actor_cancel()
return
_speech_recovery_in_progress = true
_open_speech_composer(value as StickmanRig, _speech_restored_payload, false, "recovered_unbound")
_speech_recovery_in_progress = false
_speech_restored_unbound = true
_speech_composer.call("set_selected_bubble_index", _speech_restored_selected_bubble)
func _on_speech_unbound_actor_cancel() -> void:
_speech_restored_unbound = false
_show_toast("Speech draft kept. Choose a character to continue.")
func _open_pending_speech_request() -> void:
if _speech_pending_open.is_empty():
return
var actor: StickmanRig = instance_from_id(int(_speech_pending_open.get("actor_id", 0))) as StickmanRig
var initial: Dictionary = _speech_pending_open.get("initial", {})
var reactive: bool = bool(_speech_pending_open.get("reactive", false))
var context: String = String(_speech_pending_open.get("context", ""))
_clear_pending_speech_request()
if actor != null and is_instance_valid(actor):
_open_speech_composer(actor, initial, reactive, context)
func _clear_pending_speech_request() -> void:
_speech_pending_open.clear()
func _speech_payload_for_json(action: Dictionary) -> Dictionary:
var payload: Dictionary = action.duplicate(true)
var placement: Dictionary = payload.get("placement", {}) if payload.get("placement", {}) is Dictionary else {}
if String(placement.get("mode", "auto")) == "manual" and placement.get("center", null) is Vector2:
placement["center"] = STAGE_SCENE_IO.vector_to_data(placement["center"])
payload["placement"] = placement
var bubbles: Array = payload.get("bubbles", [])
for index: int in bubbles.size():
if not bubbles[index] is Dictionary:
continue
var bubble: Dictionary = bubbles[index]
var bubble_placement: Variant = bubble.get("placement", null)
if bubble_placement is Dictionary:
var bubble_position: Variant = (bubble_placement as Dictionary).get("center", null)
if String((bubble_placement as Dictionary).get("mode", "auto")) == "manual" and bubble_position is Vector2:
(bubble_placement as Dictionary)["center"] = STAGE_SCENE_IO.vector_to_data(bubble_position)
bubbles[index] = bubble
if not bubbles.is_empty():
payload["bubbles"] = bubbles
return payload
func _persist_speech_draft_now() -> void:
if _speech_composer == null or not _speech_composer.visible or not _speech_draft_dirty:
return
var document: Dictionary = {
"schema": SPEECH_DRAFT_SCHEMA,
"modified_unix": Time.get_unix_time_from_system(),
"draft": _speech_payload_for_json(_speech_composer.call("get_draft")),
"selected_bubble": int(_speech_composer.call("selected_bubble_index")),
"origin": _speech_origin.duplicate(true),
}
var tmp_path: String = SPEECH_DRAFT_PATH + ".tmp"
var bak_path: String = SPEECH_DRAFT_PATH + ".bak"
var file: FileAccess = FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
push_warning("Speech composer draft could not be written.")
return
file.store_string(JSON.stringify(document, "\t", false))
file.flush()
file.close()
var tmp_abs: String = ProjectSettings.globalize_path(tmp_path)
var path_abs: String = ProjectSettings.globalize_path(SPEECH_DRAFT_PATH)
var bak_abs: String = ProjectSettings.globalize_path(bak_path)
if FileAccess.file_exists(bak_path):
DirAccess.remove_absolute(bak_abs)
if FileAccess.file_exists(SPEECH_DRAFT_PATH) and DirAccess.rename_absolute(path_abs, bak_abs) != OK:
DirAccess.remove_absolute(tmp_abs)
push_warning("Speech composer draft backup failed; keeping the previous draft.")
return
if DirAccess.rename_absolute(tmp_abs, path_abs) != OK:
if FileAccess.file_exists(bak_path):
DirAccess.rename_absolute(bak_abs, path_abs)
push_warning("Speech composer draft replacement failed.")
return
if FileAccess.file_exists(bak_path):
DirAccess.remove_absolute(bak_abs)
_speech_draft_dirty = false
func _clear_speech_draft_file() -> void:
_speech_draft_timer = -1.0
_speech_draft_dirty = false
_speech_pending_commit_action.clear()
_speech_restored_payload.clear()
_speech_restored_origin.clear()
_speech_restored_selected_bubble = 0
for path: String in [SPEECH_DRAFT_PATH, SPEECH_DRAFT_PATH + ".tmp", SPEECH_DRAFT_PATH + ".bak"]:
var absolute: String = ProjectSettings.globalize_path(path)
if FileAccess.file_exists(path):
DirAccess.remove_absolute(absolute)
func _commit_speech_action(action: Dictionary) -> bool:
var actor: StickmanRig = _speech_actor
match _speech_session_kind:
"direct":
if actor == null or not is_instance_valid(actor):
return false
if not _queue_action_and_confirm(actor, action):
_show_toast("Speech was not added to the character's queue. Keep editing and try again.")
return false
_show_toast("Speech added")
return true
"rule_builder":
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return false
var rule_action: Dictionary = actions.back()
var params: Dictionary = action.duplicate(true)
params.erase("type")
rule_action["params"] = params
actions[actions.size() - 1] = rule_action
_rule_builder["actions"] = actions
_open_rule_more_popup()
return true
"action_editor":
return _on_action_editor_committed(action)
return false
func _commit_unbound_recovered_speech() -> void:
if _speech_actor == null or not is_instance_valid(_speech_actor) or _speech_pending_commit_action.is_empty():
return
if not _queue_action_and_confirm(_speech_actor, _speech_pending_commit_action):
_show_toast("Recovered speech was not added to the queue. Your draft is still open.")
return
_speech_pending_commit_action.clear()
_clear_speech_draft_file()
_finish_speech_composition(true)
_continue_deferred_speech_scene_save()
_show_toast("Recovered speech added to the action queue")
func _on_action_editor_committed(flat: Dictionary) -> bool:
if _action_editor == null:
return false
var succeeded: bool = false
match _action_editor_kind:
"queue_add":
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
if not _queue_action_and_confirm(_action_editor_rig, flat):
_show_toast("Action was not added to the queue. Keep editing and try again.")
return false
_show_toast("Action added")
succeeded = true
"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")
succeeded = true
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:
if not _queue_action_and_confirm(_action_editor_rig, flat, insert_index):
_show_toast("Action was not inserted into the queue. Keep editing and try again.")
return false
_show_toast("Action inserted")
succeeded = true
else:
_show_toast("That action can no longer be used as an insert point")
"rule_add":
var actor_id: int = _action_editor_actor_id
if actor_id < 0 and _speech_actor != null and is_instance_valid(_speech_actor):
actor_id = int(_speech_actor.get_instance_id())
if actor_id >= 0 and _rule_editor != null:
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, actor_id))
succeeded = true
"rule_edit":
var actor_id: int = _action_editor_actor_id
if actor_id < 0 and _speech_actor != null and is_instance_valid(_speech_actor):
actor_id = int(_speech_actor.get_instance_id())
if _rule_editor != null and actor_id >= 0:
_rule_editor.set_action(_action_editor_index, ACTION_REGISTRY.to_rule_action(flat, actor_id))
succeeded = true
if not succeeded:
return false
_action_editor.hide()
_restore_queue_panel()
return true
func _queue_action_and_confirm(rig: StickmanRig, action: Dictionary, index: int = -1) -> bool:
if rig == null or not is_instance_valid(rig):
return false
var before: Array[Dictionary] = rig.get_queue()
var expected_index: int = before.size() if index < 0 else clampi(index, 0, before.size())
if index < 0:
rig.queue_action(action)
else:
rig.insert_action(index, action)
var after: Array[Dictionary] = rig.get_queue()
if after.size() != before.size() + 1 or expected_index >= after.size() or after[expected_index] != action:
return false
_mark_scene_dirty()
return true
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()