- Added PropTrajectory class for deterministic ballistic calculations. - Introduced StickmanPropInteraction component to manage prop interactions (pickup, drop, throw). - Implemented grip resolution and attachment logic for props. - Added signals for interaction events (prop_picked_up, prop_dropped, prop_thrown). - Defined specifications for prop interaction lifecycle and event rules. - Ensured compatibility with existing prop systems and added new capabilities for prop pickup.
4498 lines
165 KiB
GDScript
4498 lines
165 KiB
GDScript
class_name SandboxStage
|
||
extends Node2D
|
||
## SandboxStage - Kid-friendly director sandbox stage (Phase 2).
|
||
##
|
||
## A self-contained visual stage for placing terrain, props and stickmen from a
|
||
## palette, with EDIT (build) and PLAY (simulate) modes. Selection uses geometric
|
||
## hit-testing; objects are dragged directly (no move handle) and rotated via a
|
||
## ring handle. A placement ghost previews the object under the cursor, and an
|
||
## optional snap-to-grid + grid overlay ease placement. NOT wired into the editor
|
||
## - run standalone via F6 on res://scenes/sandbox_stage.tscn.
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Preloaded helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
|
||
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
||
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
|
||
const STAGE_GRID := preload("res://scripts/stage_grid.gd")
|
||
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
|
||
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd")
|
||
const STAGE_PLACEMENT_OVERLAY := preload("res://scripts/stage_placement_overlay.gd")
|
||
const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
|
||
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
|
||
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
|
||
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
|
||
const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd")
|
||
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
|
||
const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd")
|
||
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
||
|
||
# Phase 3c editor tools.
|
||
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
|
||
const QUEUE_PANEL := preload("res://scenes/queue_panel.tscn")
|
||
const RULE_PANEL := preload("res://scenes/rule_panel.tscn")
|
||
const ACTION_EDITOR := preload("res://scenes/action_editor.tscn")
|
||
const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
|
||
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
|
||
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
|
||
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
|
||
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
|
||
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Enums
|
||
# ---------------------------------------------------------------------------
|
||
|
||
enum StageMode { EDIT, DIRECT, PLAY }
|
||
|
||
## 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 }
|
||
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])
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
const ZOOM_STEP: float = 1.10
|
||
const MIN_BOX_AREA: float = 16.0
|
||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||
const SELECTOR_DIM_ALPHA := 0.5
|
||
const TOP_BAR_HEIGHT: float = 64.0
|
||
const STATUS_BAR_HEIGHT: float = 38.0
|
||
|
||
const SETTINGS_PATH := "user://sandbox_settings.json"
|
||
const THEME_PATH := "res://sandbox_theme.json"
|
||
const DEFAULT_GRID_SIZE := 15.0
|
||
const MIN_GRID_SIZE := 1.0
|
||
const MAX_GRID_SIZE := 100.0
|
||
const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene"
|
||
const RECENT_SCENE_LIMIT: int = 5
|
||
const DEFAULT_SCENE_NAME: String = "Untitled"
|
||
const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0)
|
||
const DEFAULT_CAMERA_ZOOM: float = 0.5
|
||
|
||
## Director action-popup item ids (Phase 3a).
|
||
const ACT_WALK := 0
|
||
const ACT_SPEAK := 1
|
||
const ACT_WAIT := 2
|
||
const ACT_RAGDOLL := 3
|
||
const ACT_RECOVER := 4
|
||
|
||
## Phase 4 rule-builder item ids. ACT_WHEN appends to the action popup; the
|
||
## TRIG_* ids drive the trigger sub-menu.
|
||
const ACT_WHEN := 5
|
||
const TRIG_ARRIVED := 0
|
||
const TRIG_ACTION_FINISHED := 1
|
||
const TRIG_SPEECH_FINISHED := 2
|
||
const TRIG_ENTERED_AREA := 3
|
||
const TRIG_COLLIDED := 4
|
||
const TRIG_PROP_PICKED_UP := 5
|
||
const TRIG_PROP_DROPPED := 6
|
||
const TRIG_PROP_THROWN := 7
|
||
const TRIG_BACK := 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
|
||
|
||
## 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 _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 _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 _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 _speak_dialog: AcceptDialog = null
|
||
var _speak_edit: LineEdit = null
|
||
var _speak_stop_current_check: CheckBox = null
|
||
var _wait_dialog: AcceptDialog = null
|
||
var _wait_spin: SpinBox = null
|
||
var _director_visuals = null # StageDirectorVisuals (preloaded)
|
||
|
||
var _nav_region: NavigationRegion2D = null
|
||
var _nav_dirty: bool = true
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Theme / cursor state (Phase 4b)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Parsed sandbox_theme.json (or {} if missing/malformed).
|
||
var _theme: Dictionary = {}
|
||
var _sandbox_theme = null
|
||
var _theme_grid_default: float = DEFAULT_GRID_SIZE
|
||
var _accent_edit: Color = Color("#22c6ff")
|
||
var _accent_direct: Color = Color("#ffb300")
|
||
var _accent_play: Color = Color("#33dd77")
|
||
var _guide_line_color: Color = Color("#22c6ff")
|
||
var _action_popup_font_size: int = 24
|
||
var _action_popup_emoji_size: int = 22
|
||
var _tooltip_font_size: int = 18
|
||
var _status_pill_font_size: int = 16
|
||
var _ui_font: Font = null
|
||
var _emoji_font: Font = null
|
||
|
||
## Phase 3c style fonts/sizes/flags (sandbox_theme.json §fonts). Bold/italic are
|
||
## resolved from ui_font_bold/ui_font_italic (fallback to a FontVariation / ui_font).
|
||
var _ui_font_bold: Font = null
|
||
var _ui_font_italic: Font = null
|
||
var _queue_panel_font_size: int = 18
|
||
var _rule_panel_font_size: int = 18
|
||
var _action_editor_font_size: int = 18
|
||
var _rule_editor_font_size: int = 18
|
||
var _panel_row_font_size: int = 16
|
||
var _panel_title_font_size: int = 18
|
||
var _panel_title_bold: bool = true
|
||
var _rule_label_bold: bool = false
|
||
var _badge_bold: bool = true
|
||
var _action_popup_bold: bool = false
|
||
var _action_popup_italic: bool = false
|
||
var _font_sizes: Dictionary = {}
|
||
|
||
var _action_cursor: ImageTexture = null
|
||
var _last_awaiting_click: bool = false
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Rule / event system state (Phase 4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Stored "When X -> do Y" rules. Persist across mode toggles; NOT saved to disk.
|
||
var _event_rules: Array[Dictionary] = []
|
||
var _next_rule_id: int = 0
|
||
|
||
## Rule-builder state machine.
|
||
var _rule_step: RuleStep = RuleStep.IDLE
|
||
var _rule_builder: Dictionary = {}
|
||
var _rule_context_rig: StickmanRig = null
|
||
var _rule_hint: String = ""
|
||
var _rule_editing_id: int = -1
|
||
|
||
## True when the rule builder was launched from the Rule Panel's "Add Rule"
|
||
## (restore the panel on finalize/cancel).
|
||
var _rule_build_from_panel: bool = false
|
||
|
||
## Session popup anchor: the first context menu of a rule session records its
|
||
## screen rect; all child popups in that session reuse it. Cleared on confirm
|
||
## or cancel so the next session re-records from a fresh click.
|
||
var _popup_anchor: Rect2i = Rect2i()
|
||
var _popup_anchor_set: bool = false
|
||
|
||
## Rule-builder popups (built in _build_ui).
|
||
var _trigger_popup: PopupMenu = null
|
||
var _rule_action_popup: PopupMenu = null
|
||
var _rule_more_popup: PopupMenu = null
|
||
|
||
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
|
||
## and "<rig_id>:<prop_id>".
|
||
var _area_overlap: Dictionary = {}
|
||
var _area_previous_bounds: Dictionary = {}
|
||
var _collision_pairs: Dictionary = {}
|
||
|
||
## Lightweight toast text + countdown (cleared in _process on expiry).
|
||
var _toast_text: String = ""
|
||
var _toast_timer: float = 0.0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 3c editor-tool state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Editor popups / panels (instantiated from their scenes in _build_ui).
|
||
var _queue_panel: QueuePanel = null
|
||
var _rule_panel: RulePanel = null
|
||
var _action_editor: ActionEditor = null
|
||
var _rule_editor: RuleEditor = null
|
||
var _waypoint_context: WaypointContext = null
|
||
var _rig_context_popup: PopupMenu = null
|
||
var _confirm_dialog: ConfirmationDialog = null
|
||
var _confirm_callback: Callable = Callable()
|
||
|
||
## The stickman the panels/editors currently operate on.
|
||
var _panel_rig: StickmanRig = null
|
||
|
||
## Waypoint right-click context (which rig/queue index/position was clicked).
|
||
var _ctx_waypoint_rig: StickmanRig = null
|
||
var _ctx_waypoint_index: int = -1
|
||
var _ctx_waypoint_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()
|
||
|
||
## 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_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_actor_id: int = -1
|
||
var _action_editor_restore_queue: bool = false
|
||
var _pending_rule_action_flat: Dictionary = {}
|
||
## Active only while the Action Editor is capturing a throw landing point.
|
||
## Kept separately from generic position capture so walk/rule capture remains
|
||
## unchanged and preview/commit share one explicit contract.
|
||
var _throw_capture_prop_id: int = -1
|
||
var _throw_capture_flight_time: float = 0.9
|
||
|
||
## 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()
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
_update_scene_chrome()
|
||
_handle_cli_startup(OS.get_cmdline_user_args())
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
_drain_thumbnail_queue()
|
||
if _nav_dirty:
|
||
_nav_dirty = false
|
||
_rebake_navigation()
|
||
if current_mode == StageMode.EDIT:
|
||
if _terrain_dragging:
|
||
_update_terrain_drag(_camera.get_global_mouse_position())
|
||
elif _ghost != null and is_instance_valid(_ghost):
|
||
_update_ghost_position()
|
||
_update_cursor_coords()
|
||
_update_action_overlay()
|
||
_sync_awaiting_cursor()
|
||
if _toast_timer > 0.0:
|
||
_toast_timer -= delta
|
||
if _toast_timer <= 0.0:
|
||
_toast_text = ""
|
||
_refresh_status()
|
||
|
||
|
||
func _physics_process(_delta: float) -> void:
|
||
_sync_pending_editable_physics_transforms()
|
||
if _restore_frames_left > 0:
|
||
_restore_frames_left -= 1
|
||
_restore_authored_state()
|
||
if current_mode == StageMode.PLAY:
|
||
_update_area_entry()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Input
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _input(event: InputEvent) -> void:
|
||
if not _palette_drag_payload.is_empty():
|
||
if event is InputEventMouseMotion:
|
||
_update_palette_drag((event as InputEventMouseMotion).position)
|
||
return
|
||
if event is InputEventMouseButton:
|
||
var drag_button := event as InputEventMouseButton
|
||
if drag_button.button_index == MOUSE_BUTTON_LEFT and not drag_button.pressed:
|
||
_finish_palette_drag(drag_button.position)
|
||
return
|
||
if drag_button.button_index == MOUSE_BUTTON_RIGHT and drag_button.pressed:
|
||
_cancel_palette_drag()
|
||
return
|
||
if event is InputEventMagnifyGesture:
|
||
# Touchpad pinch zoom.
|
||
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
|
||
elif event is InputEventPanGesture:
|
||
# Touchpad two-finger pan (delta is screen px; ×3 for speed parity).
|
||
_camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x
|
||
elif event is InputEventMouseButton:
|
||
_handle_mouse_button(event)
|
||
elif event is InputEventMouseMotion:
|
||
_handle_mouse_motion(event)
|
||
|
||
|
||
func _unhandled_input(event: InputEvent) -> void:
|
||
if event is InputEventMouseButton:
|
||
_handle_world_click(event)
|
||
|
||
|
||
func _unhandled_key_input(event: InputEvent) -> void:
|
||
if not (event is InputEventKey) or not event.pressed or event.echo:
|
||
return
|
||
match event.keycode:
|
||
KEY_DELETE, KEY_BACKSPACE:
|
||
if current_mode == StageMode.EDIT:
|
||
delete_selected()
|
||
KEY_ESCAPE:
|
||
if not _palette_drag_payload.is_empty():
|
||
_cancel_palette_drag()
|
||
elif _capture_kind != CaptureKind.NONE:
|
||
_cancel_capture()
|
||
elif _rule_step != RuleStep.IDLE:
|
||
_cancel_rule_build()
|
||
elif _pending_walk_target:
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
elif _terrain_dragging:
|
||
_cancel_terrain_drag()
|
||
elif _selector_open:
|
||
_on_selector_cancelled()
|
||
elif current_mode == StageMode.DIRECT:
|
||
set_mode(StageMode.EDIT)
|
||
elif _placement_id != "":
|
||
set_placement_mode("")
|
||
elif not _selection.get_selected().is_empty():
|
||
_selection.clear_selection()
|
||
|
||
|
||
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
||
match mb.button_index:
|
||
MOUSE_BUTTON_WHEEL_UP:
|
||
if mb.pressed:
|
||
_set_zoom(_camera.zoom.x * ZOOM_STEP)
|
||
MOUSE_BUTTON_WHEEL_DOWN:
|
||
if mb.pressed:
|
||
_set_zoom(_camera.zoom.x / ZOOM_STEP)
|
||
MOUSE_BUTTON_MIDDLE:
|
||
_panning = mb.pressed
|
||
_pan_last = mb.position
|
||
|
||
|
||
func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||
if _selector_open:
|
||
return
|
||
if current_mode == StageMode.PLAY:
|
||
return
|
||
if mb.button_index == MOUSE_BUTTON_RIGHT:
|
||
if mb.pressed:
|
||
if _capture_kind != CaptureKind.NONE:
|
||
_cancel_capture()
|
||
elif _rule_step != RuleStep.IDLE:
|
||
_cancel_rule_build()
|
||
elif _pending_walk_target:
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
else:
|
||
_handle_right_click()
|
||
return
|
||
if mb.button_index != MOUSE_BUTTON_LEFT:
|
||
return
|
||
# Use the position carried by this exact event. Polling the global cursor
|
||
# here can lag behind an unhandled press/release, leaving the gizmo with an
|
||
# unchanged drag origin even though the pointer has moved.
|
||
var world_pos := _viewport_to_world(mb.position)
|
||
# Rule-builder click routing is highest priority (Phase 4).
|
||
if _rule_step != RuleStep.IDLE:
|
||
if mb.pressed:
|
||
_handle_rule_click(world_pos)
|
||
return
|
||
# Phase 3c editor target capture.
|
||
if _capture_kind != CaptureKind.NONE:
|
||
if mb.pressed:
|
||
_resolve_capture(world_pos)
|
||
return
|
||
# Rule label / delete icon hit-testing, before gizmo/placement/selection.
|
||
var rule_hit: Dictionary = _director_visuals.hit_test_rule(world_pos)
|
||
if not rule_hit.is_empty() and mb.pressed:
|
||
if rule_hit["part"] == "delete":
|
||
_delete_rule(int(rule_hit["id"]))
|
||
else:
|
||
_begin_edit_rule(int(rule_hit["id"]))
|
||
return
|
||
if current_mode == StageMode.DIRECT:
|
||
if mb.pressed:
|
||
_handle_direct_click(world_pos)
|
||
return
|
||
# EDIT mode: a terrain drag commits on release.
|
||
if _terrain_dragging:
|
||
if not mb.pressed:
|
||
_commit_terrain_drag()
|
||
return
|
||
if mb.pressed:
|
||
if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE:
|
||
_gizmos.begin_drag(world_pos)
|
||
elif _placement_id != "":
|
||
if _spawner.is_terrain_id(_placement_id):
|
||
_begin_terrain_drag(world_pos)
|
||
else:
|
||
_place_at(world_pos)
|
||
else:
|
||
_begin_click_select(world_pos, mb.shift_pressed)
|
||
else:
|
||
if _gizmos.is_dragging():
|
||
_gizmos.end_drag()
|
||
elif _box_selecting:
|
||
_finish_box_select(mb.shift_pressed)
|
||
|
||
|
||
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
||
if _selector_open:
|
||
return
|
||
if _panning:
|
||
_camera.position -= mm.relative / _camera.zoom.x
|
||
return
|
||
if current_mode == StageMode.PLAY:
|
||
return
|
||
var world_pos := _viewport_to_world(mm.position)
|
||
if _gizmos.is_dragging():
|
||
_gizmos.drag_to(world_pos)
|
||
_director_visuals.mark_dirty()
|
||
elif _terrain_dragging:
|
||
_update_terrain_drag(world_pos)
|
||
elif _box_selecting:
|
||
_box_rect = Rect2(_box_start, world_pos - _box_start)
|
||
_gizmos.set_box_rect(_box_rect)
|
||
elif not _is_mouse_over_ui() and current_mode == StageMode.EDIT:
|
||
_selection.update_hover(world_pos)
|
||
|
||
|
||
func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
|
||
return get_viewport().get_canvas_transform().affine_inverse() * viewport_pos
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Mode management
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func set_mode(mode: StageMode) -> void:
|
||
if mode == current_mode:
|
||
return
|
||
var previous_mode: StageMode = current_mode
|
||
if mode == StageMode.PLAY and 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()
|
||
_apply_cursor()
|
||
mode_changed.emit(int(mode))
|
||
_refresh_status()
|
||
|
||
|
||
func _enter_edit_shared() -> void:
|
||
# Restore stickmen to their authored spawn/ghost pose (no stand-up tween
|
||
# glide), stopping any director queue first.
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
rig.release_held_prop_for_lifecycle(&"play_exit")
|
||
rig.stop_queue()
|
||
rig.set_terrain_following_enabled(false)
|
||
rig.snap_to_standing()
|
||
rig.clear_reactive_actions()
|
||
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
|
||
# any object that may not be in the authored map. freeze_mode is set BEFORE
|
||
# freeze so the body freezes directly as kinematic, never via the static
|
||
# layer (whose transform sync can drop a subsequent position/rotation set).
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is RigidBody2D:
|
||
var body := node as RigidBody2D
|
||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||
body.freeze = true
|
||
if node is PropBlock:
|
||
(node as PropBlock).end_play_instance_scale()
|
||
_restore_authored_state()
|
||
# The freeze/teleport can take a physics frame or two to settle in the engine,
|
||
# so re-assert the authored transform on the next few physics frames.
|
||
_restore_frames_left = 3
|
||
_gizmos.set_enabled(true)
|
||
_director_visuals.set_enabled(true)
|
||
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
|
||
_area_overlap.clear()
|
||
_area_previous_bounds.clear()
|
||
_collision_pairs.clear()
|
||
|
||
|
||
func _enter_edit_mode(previous_mode: StageMode) -> void:
|
||
# EDIT and DIRECT share the same frozen world. PLAY still needs the complete
|
||
# simulation cleanup and authored-pose restore before editing resumes.
|
||
if previous_mode != StageMode.DIRECT:
|
||
_enter_edit_shared()
|
||
_clear_director_pending()
|
||
_set_edit_controls_visible(true)
|
||
_set_direct_controls_visible(false)
|
||
|
||
|
||
## Cancels any in-progress director/rule-builder click flows when leaving a mode
|
||
## (a pending walk target or a half-built When->Then rule must not survive into
|
||
## another mode where its click routing would mis-fire).
|
||
func _clear_director_pending() -> void:
|
||
_clear_popup_anchor()
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
if _capture_kind != CaptureKind.NONE:
|
||
_cancel_capture()
|
||
if _rule_step != RuleStep.IDLE:
|
||
_cancel_rule_build()
|
||
if _action_popup != null:
|
||
_action_popup.hide()
|
||
# Close any Phase 3c editor popups.
|
||
if _queue_panel != null:
|
||
_queue_panel.hide()
|
||
if _rule_panel != null:
|
||
_rule_panel.hide()
|
||
if _action_editor != null:
|
||
_action_editor.hide()
|
||
if _rule_editor != null:
|
||
_rule_editor.hide()
|
||
if _director_visuals != null:
|
||
_director_visuals.clear_edit_waypoint()
|
||
|
||
|
||
## Direct is internally an "edit-with-direct" state: the same frozen world,
|
||
## but with the spawner/grid controls hidden, the director hint shown, and any
|
||
## placement/selection/pending-target cleared.
|
||
func _enter_direct_mode(previous_mode: StageMode) -> void:
|
||
# EDIT and DIRECT share the same non-simulating world. Preserve the exact
|
||
# placed stickman pose between them; a transition from PLAY still needs the
|
||
# full cleanup and authored-pose restore.
|
||
if previous_mode != StageMode.EDIT:
|
||
_enter_edit_shared()
|
||
set_placement_mode("")
|
||
_selection.clear_selection()
|
||
_clear_director_pending()
|
||
_set_edit_controls_visible(false)
|
||
_set_direct_controls_visible(true)
|
||
|
||
|
||
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)
|
||
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
|
||
_area_overlap.clear()
|
||
_area_previous_bounds.clear()
|
||
_collision_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
|
||
var rig_count := 0
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
rig.auto_recover = false
|
||
rig.begin_terrain_fall()
|
||
rig.start_queue()
|
||
rig_count += 1
|
||
_stage_dbg("PLAY rigs=%d" % rig_count)
|
||
|
||
|
||
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 current_mode == StageMode.EDIT and node is RigidBody2D:
|
||
var body := node as RigidBody2D
|
||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||
body.freeze = true
|
||
_save_object_state(node)
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
rig.queue_changed.connect(_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))
|
||
var interaction: StickmanPropInteraction = rig.get_prop_interaction()
|
||
if interaction != null:
|
||
interaction.prop_transition.connect(_on_rig_prop_transition)
|
||
_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 _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):
|
||
# Disarm before either endpoint leaves World so a held body is never
|
||
# left frozen or referenced by a surviving IK marker.
|
||
if node is StickmanRig:
|
||
(node as StickmanRig).release_held_prop_for_lifecycle(&"holder_deleted")
|
||
elif node is PropBlock:
|
||
for child: Node in _world.get_children():
|
||
if child is StickmanRig:
|
||
var interaction: StickmanPropInteraction = (child as StickmanRig).get_prop_interaction()
|
||
if interaction != null and interaction.get_held_prop() == node:
|
||
interaction.release_for_lifecycle(&"prop_deleted")
|
||
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)
|
||
|
||
|
||
func _set_scene_dirty(value: bool) -> void:
|
||
_dirty = value
|
||
_update_scene_chrome()
|
||
|
||
|
||
func _on_rig_queue_changed() -> void:
|
||
_director_visuals.mark_dirty()
|
||
_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}
|
||
# Queue actions may point at a prop that appears later in World. Build the
|
||
# complete runtime→file map before encoding any individual rig queue.
|
||
for node: Node in _world.get_children():
|
||
if not node is Node2D: continue
|
||
var map_node: Node2D = node as Node2D
|
||
var map_type: String = ""
|
||
if map_node is TerrainBlock: map_type = "terrain"
|
||
elif map_node is PropBlock or map_node.get_script() == MISSING_PROP_PLACEHOLDER: map_type = "prop"
|
||
elif map_node is STICKMAN_RIG or map_node.get_script() == MISSING_STICKMAN_PLACEHOLDER: map_type = "stickman"
|
||
elif map_node is TriggerArea: map_type = "area"
|
||
if map_type.is_empty(): continue
|
||
counters[map_type] = int(counters[map_type]) + 1
|
||
runtime_to_file[map_node.get_instance_id()] = "%s_%d" % [map_type, int(counters[map_type])]
|
||
counters = {"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])]
|
||
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)
|
||
elif type == "prop":
|
||
var prop := n as PropBlock
|
||
entry["asset_id"] = prop.spawn_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(), runtime_to_file)
|
||
entry["action_queue"] = queue_result["actions"]
|
||
objects.append(entry)
|
||
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
|
||
return {"version": STAGE_SCENE_IO.FILE_VERSION, "scene_name": _scene_name, "camera": {"position": STAGE_SCENE_IO.vector_to_data(_camera.global_position), "zoom": _camera.zoom.x}, "objects": objects, "event_rules": rules_result["rules"], "metadata": {"created_unix": _scene_created_unix if _scene_created_unix > 0 else Time.get_unix_time_from_system(), "modified_unix": Time.get_unix_time_from_system(), "autosave": is_autosave, "source_path": _current_scene_path if is_autosave else ""}}
|
||
|
||
|
||
func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool:
|
||
var result: Dictionary = STAGE_SCENE_IO.write_file_safe(path, _collect_scene_data(is_autosave))
|
||
if not bool(result.get("ok", false)):
|
||
_show_toast(String(result.get("error", "Could not save scene.")))
|
||
if _pending_file_action != PendingFileAction.NONE: _pending_file_action = PendingFileAction.NONE
|
||
return false
|
||
if not is_autosave:
|
||
_current_scene_path = path
|
||
if _scene_created_unix <= 0: _scene_created_unix = Time.get_unix_time_from_system()
|
||
_set_scene_dirty(false); _add_recent_scene(path)
|
||
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||
return true
|
||
|
||
|
||
func _reset_scene_content() -> void:
|
||
set_mode(StageMode.EDIT)
|
||
_free_ghost(); _free_terrain_ghosts(); _end_terrain_drag()
|
||
_selection.clear_selection(); _selection.clear_hover(); _authored.clear(); _edit_physics_targets.clear(); _event_rules.clear(); _next_rule_id = 0
|
||
_clear_director_pending(); _reset_rule_builder(); _nav_dirty = false
|
||
for child: Node in _world.get_children():
|
||
_world.remove_child(child)
|
||
child.queue_free()
|
||
_area_overlap.clear(); _collision_pairs.clear(); _grid_cells.clear(); _director_visuals.set_rules(_event_rules)
|
||
|
||
|
||
func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool:
|
||
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
|
||
if not bool(read.get("ok", false)):
|
||
_show_toast(String(read.get("error", "Could not load scene."))); return false
|
||
return _apply_scene_data(read["data"], path, is_recovery)
|
||
|
||
|
||
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
|
||
var was_suppressed := _suppress_dirty; _suppress_dirty = true
|
||
_reset_scene_content()
|
||
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []; var pending_queues: Array[Dictionary] = []
|
||
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:
|
||
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:
|
||
pending_queues.append({"rig": node, "entries": raw_queue})
|
||
else:
|
||
warnings.append("Invalid stickman action queue")
|
||
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
|
||
for pending: Dictionary in pending_queues:
|
||
var queued_rig: StickmanRig = pending.get("rig") as StickmanRig
|
||
if queued_rig == null or not is_instance_valid(queued_rig): continue
|
||
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(pending.get("entries", []) as Array, file_to_runtime, unavailable)
|
||
for warning: String in queue.get("warnings", []): warnings.append(warning)
|
||
for action: Dictionary in queue.get("actions", []): queued_rig.queue_action(action)
|
||
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
|
||
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
|
||
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
|
||
_director_visuals.set_rules(_event_rules)
|
||
var raw_camera: Variant = data.get("camera", {})
|
||
var camera: Dictionary = raw_camera as Dictionary if raw_camera is Dictionary else {}
|
||
_camera.global_position = STAGE_SCENE_IO.data_to_vector(camera.get("position"), DEFAULT_CAMERA_POSITION)
|
||
_camera.zoom = Vector2.ONE * clampf(float(camera.get("zoom", DEFAULT_CAMERA_ZOOM)), min_zoom, max_zoom)
|
||
_scene_name = String(data.get("scene_name", DEFAULT_SCENE_NAME)).strip_edges(); if _scene_name.is_empty(): _scene_name = DEFAULT_SCENE_NAME
|
||
var raw_metadata: Variant = data.get("metadata", {})
|
||
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
|
||
_scene_created_unix = int(metadata.get("created_unix", Time.get_unix_time_from_system()))
|
||
_current_scene_path = String(metadata.get("source_path", source_path)) if is_recovery else source_path
|
||
_rebuild_grid_cells(); _nav_dirty = true; _suppress_dirty = was_suppressed; _set_scene_dirty(is_recovery)
|
||
if not warnings.is_empty() or int(decoded_rules.get("skipped", 0)) > 0 or missing_stickmen > 0:
|
||
_show_toast("Loaded with %d warning(s), %d missing stickman, %d skipped rule(s)" % [warnings.size(), missing_stickmen, int(decoded_rules.get("skipped", 0))])
|
||
if not missing_paths.is_empty():
|
||
_show_missing_assets_dialog(missing_paths)
|
||
if not is_recovery:
|
||
_add_recent_scene(source_path)
|
||
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||
return true
|
||
|
||
|
||
func _show_missing_assets_dialog(paths: Array[String]) -> void:
|
||
if _missing_assets_dialog == null:
|
||
return
|
||
var asset_lines: Array[String] = []
|
||
for path: String in paths:
|
||
var asset_name := path.get_file()
|
||
if asset_name.is_empty(): asset_name = "Unnamed .stk asset"
|
||
asset_lines.append("• %s\n %s" % [asset_name, path if not path.is_empty() else "(no asset path was saved)"])
|
||
_missing_assets_dialog.dialog_text = "%d stickman asset(s) could not be loaded:\n\n%s\n\nA red marker was placed where each missing asset belongs." % [paths.size(), "\n\n".join(asset_lines)]
|
||
_missing_assets_dialog.popup_centered()
|
||
|
||
|
||
func _add_recent_scene(path: String) -> void:
|
||
if path.is_empty() or path == AUTOSAVE_PATH: return
|
||
_recent_scenes.erase(path); _recent_scenes.push_front(path)
|
||
while _recent_scenes.size() > RECENT_SCENE_LIMIT: _recent_scenes.pop_back()
|
||
_save_settings()
|
||
_update_recent_menu()
|
||
|
||
|
||
func _update_recent_menu() -> void:
|
||
if _file_menu == null: return
|
||
var recent := _file_menu.get_popup().get_node_or_null(NodePath("OpenRecent")) as PopupMenu
|
||
if recent == null: return
|
||
recent.clear()
|
||
var existing: Array[String] = []
|
||
for path: String in _recent_scenes:
|
||
if FileAccess.file_exists(path): existing.append(path)
|
||
var pruned := existing.size() != _recent_scenes.size()
|
||
_recent_scenes = existing
|
||
if pruned: _save_settings()
|
||
if _recent_scenes.is_empty():
|
||
recent.add_item("(No recent scenes)", -1); recent.set_item_disabled(0, true)
|
||
else:
|
||
for index: int in _recent_scenes.size(): recent.add_item(_recent_scenes[index], index)
|
||
|
||
|
||
func _on_recent_scene_selected(index: int) -> void:
|
||
if index < 0 or index >= _recent_scenes.size(): return
|
||
var path := _recent_scenes[index]
|
||
if not FileAccess.file_exists(path):
|
||
_recent_scenes.remove_at(index); _save_settings(); _show_toast("File not found: %s" % path); return
|
||
_request_pending_file_action(PendingFileAction.OPEN_PATH, path)
|
||
|
||
|
||
func _update_scene_chrome() -> void:
|
||
if is_inside_tree():
|
||
var scene_file := _current_scene_path.get_file()
|
||
if scene_file.is_empty():
|
||
scene_file = "Unsaved"
|
||
get_window().title = "%s%s — %s — Stickman Studio" % [_scene_name, " *" if _dirty else "", scene_file]
|
||
_refresh_status()
|
||
|
||
|
||
func _check_autosave_recovery() -> void:
|
||
if not FileAccess.file_exists(AUTOSAVE_PATH): return
|
||
var read: Dictionary = STAGE_SCENE_IO.read_file(AUTOSAVE_PATH)
|
||
if not bool(read.get("ok", false)):
|
||
DirAccess.remove_absolute(AUTOSAVE_PATH); return
|
||
var autosave_data: Dictionary = read["data"] as Dictionary
|
||
var raw_metadata: Variant = autosave_data.get("metadata", {})
|
||
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
|
||
if not bool(metadata.get("autosave", false)):
|
||
DirAccess.remove_absolute(AUTOSAVE_PATH); return
|
||
var source_path := String(metadata.get("source_path", ""))
|
||
if not source_path.is_empty() and FileAccess.file_exists(source_path) and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
|
||
DirAccess.remove_absolute(AUTOSAVE_PATH); return
|
||
var dialog := ConfirmationDialog.new()
|
||
dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?"
|
||
dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free())
|
||
dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free())
|
||
get_node(NodePath("UI")).add_child(dialog); _apply_dialog_theme(dialog); dialog.popup_centered()
|
||
|
||
|
||
func _handle_cli_startup(args: Array) -> void:
|
||
var result: Dictionary = CliSceneLauncher.parse(args)
|
||
if not String(result.get("error", "")).is_empty():
|
||
_show_cli_error_dialog(String(result["error"]))
|
||
_check_autosave_recovery()
|
||
return
|
||
var scene_path := String(result.get("scene_path", ""))
|
||
if scene_path.is_empty():
|
||
_check_autosave_recovery()
|
||
return
|
||
if FileAccess.file_exists(AUTOSAVE_PATH):
|
||
DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||
var load: Dictionary = _load_cli_scene(scene_path)
|
||
if not bool(load.get("ok", false)):
|
||
_show_cli_error_dialog(String(load.get("error", "Could not load scene.")))
|
||
return
|
||
if bool(result.get("play", false)):
|
||
_cli_startup = true
|
||
set_mode(StageMode.PLAY)
|
||
_cli_startup = false
|
||
|
||
|
||
func _load_cli_scene(path: String) -> Dictionary:
|
||
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
|
||
if not bool(read.get("ok", false)):
|
||
return {"ok": false, "error": String(read.get("error", "Could not load scene."))}
|
||
if not _apply_scene_data(read["data"], path, false):
|
||
return {"ok": false, "error": "Could not apply scene data."}
|
||
return {"ok": true, "error": ""}
|
||
|
||
|
||
func _show_cli_error_dialog(message: String) -> void:
|
||
if _cli_error_dialog == null:
|
||
return
|
||
_cli_error_dialog.dialog_text = message
|
||
_cli_error_dialog.popup_centered()
|
||
|
||
|
||
func _notification(what: int) -> void:
|
||
if what == NOTIFICATION_WM_CLOSE_REQUEST:
|
||
_request_pending_file_action(PendingFileAction.QUIT)
|
||
|
||
func _refresh_status() -> void:
|
||
if _status_label == null:
|
||
return
|
||
var count := _world_children_selectable().size()
|
||
var sel := _selection.get_selected()
|
||
var sel_text: String
|
||
if sel.is_empty():
|
||
sel_text = "none"
|
||
elif sel.size() == 1:
|
||
sel_text = String(sel[0].name)
|
||
else:
|
||
sel_text = "%d objects" % sel.size()
|
||
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
|
||
if _pending_walk_target:
|
||
text += " | Click stage for walk target (Esc or RMB to cancel)"
|
||
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
|
||
text += " | " + _rule_hint
|
||
if not _toast_text.is_empty():
|
||
text = _toast_text + " | " + text
|
||
_status_label.text = text
|
||
|
||
|
||
func _accent_for_mode(mode: StageMode) -> Color:
|
||
match mode:
|
||
StageMode.DIRECT:
|
||
return _accent_direct
|
||
StageMode.PLAY:
|
||
return _accent_play
|
||
_:
|
||
return _accent_edit
|
||
|
||
|
||
func _sync_mode_buttons() -> void:
|
||
var accent := _accent_for_mode(current_mode)
|
||
for id: int in _mode_buttons:
|
||
var btn: Button = _mode_buttons[id]
|
||
var active := int(current_mode) == id
|
||
btn.set_pressed_no_signal(active)
|
||
if active:
|
||
btn.add_theme_color_override("font_color", accent)
|
||
btn.add_theme_color_override("font_pressed_color", accent)
|
||
btn.add_theme_color_override("font_hover_color", accent)
|
||
else:
|
||
btn.remove_theme_color_override("font_color")
|
||
btn.remove_theme_color_override("font_pressed_color")
|
||
btn.remove_theme_color_override("font_hover_color")
|
||
|
||
|
||
func _apply_mode_frame() -> void:
|
||
if _mode_frame == null:
|
||
return
|
||
_update_stage_chrome_layout()
|
||
_mode_frame.visible = true
|
||
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
|
||
if sb != null: sb.border_color = _accent_for_mode(current_mode)
|
||
|
||
|
||
func _refresh_mode_badge() -> void:
|
||
if _mode_badge == null or _mode_badge_label == null:
|
||
return
|
||
var accent := _accent_for_mode(current_mode)
|
||
var text := "✏️ EDIT"
|
||
match current_mode:
|
||
StageMode.EDIT:
|
||
text = "✏️ EDIT"
|
||
StageMode.DIRECT:
|
||
text = "🎬 DIRECTING"
|
||
StageMode.PLAY:
|
||
text = "▶️ SIMULATING"
|
||
_mode_badge_label.text = text
|
||
var sb: StyleBoxFlat = _mode_badge.get_theme_stylebox("panel")
|
||
sb.bg_color = accent
|
||
var text_color := Color.BLACK if accent.get_luminance() > 0.5 else Color.WHITE
|
||
_mode_badge_label.add_theme_color_override("font_color", text_color)
|
||
_mode_badge.size = _mode_badge.get_combined_minimum_size()
|
||
|
||
|
||
func _apply_cursor() -> void:
|
||
_last_awaiting_click = _is_awaiting_click()
|
||
if _last_awaiting_click:
|
||
_apply_action_cursor()
|
||
return
|
||
Input.set_custom_mouse_cursor(null)
|
||
match current_mode:
|
||
StageMode.PLAY:
|
||
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
|
||
_:
|
||
Input.set_default_cursor_shape(Input.CURSOR_CROSS)
|
||
|
||
|
||
func _apply_action_cursor() -> void:
|
||
if _action_cursor == null:
|
||
return
|
||
var hotspot := Vector2(_action_cursor.get_width() * 0.5, _action_cursor.get_height() * 0.5)
|
||
Input.set_custom_mouse_cursor(_action_cursor, Input.CURSOR_ARROW, hotspot)
|
||
|
||
|
||
func _build_action_cursor() -> void:
|
||
var size := 24
|
||
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
|
||
img.fill(Color(0.0, 0.0, 0.0, 0.0))
|
||
var color := Color(1.0, 0.7, 0.1, 1.0)
|
||
var center := Vector2(size, size) * 0.5
|
||
var radius := float(size) * 0.4
|
||
for y: int in size:
|
||
for x: int in size:
|
||
var p := Vector2(x, y) + Vector2(0.5, 0.5)
|
||
var d := p.distance_to(center)
|
||
if absf(d - radius) <= 1.5:
|
||
img.set_pixel(x, y, color)
|
||
if absf(p.x - center.x) <= 1.0 and p.y >= center.y - radius and p.y <= center.y + radius:
|
||
img.set_pixel(x, y, color)
|
||
if absf(p.y - center.y) <= 1.0 and p.x >= center.x - radius and p.x <= center.x + radius:
|
||
img.set_pixel(x, y, color)
|
||
_action_cursor = ImageTexture.create_from_image(img)
|
||
|
||
|
||
func _apply_popup_theme(popup: PopupMenu) -> void:
|
||
popup.add_theme_font_size_override("font_size", _action_popup_font_size)
|
||
# Style-variant font selection: bold/italic object-form flags take precedence,
|
||
# then the emoji font. Falls back to the engine default when none are set.
|
||
var font: Font = _emoji_font
|
||
if _action_popup_bold and _ui_font_bold != null:
|
||
font = _ui_font_bold
|
||
elif _action_popup_italic and _ui_font_italic != null:
|
||
font = _ui_font_italic
|
||
if font != null:
|
||
popup.add_theme_font_override("font", font)
|
||
# Consume the previously-dead action_popup_emoji_size: PopupMenu has no
|
||
# per-item font-size API in Godot 4.x, so when an emoji/style font is in
|
||
# use the configured emoji-glyph size becomes the effective menu font size
|
||
# (the menu draws every glyph with its one active font).
|
||
if _action_popup_emoji_size > 0:
|
||
popup.add_theme_font_size_override("font_size", _action_popup_emoji_size)
|
||
|
||
|
||
func _apply_ui_font(control: Control) -> void:
|
||
if control != null and _ui_font != null:
|
||
control.add_theme_font_override("font", _ui_font)
|
||
|
||
|
||
func _theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
|
||
if _sandbox_theme == null:
|
||
return fallback
|
||
var raw: Variant = _sandbox_theme.value("chrome.dialogs." + key, [])
|
||
if raw is Array and (raw as Array).size() == 2:
|
||
return Vector2i(maxi(320, int(raw[0])), maxi(180, int(raw[1])))
|
||
return fallback
|
||
|
||
|
||
## Dialogs are separate native/embedded windows, so they do not reliably
|
||
## inherit the CanvasLayer's visual defaults. Apply only local overrides.
|
||
func _apply_dialog_theme(dialog: Window) -> void:
|
||
if dialog == null:
|
||
return
|
||
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
|
||
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
|
||
dialog.add_theme_font_size_override("font_size", size)
|
||
if _ui_font != null:
|
||
dialog.add_theme_font_override("font", _ui_font)
|
||
if dialog is AcceptDialog:
|
||
var message: Label = (dialog as AcceptDialog).get_label()
|
||
if message != null:
|
||
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
message.add_theme_font_size_override("font_size", message_size)
|
||
if _ui_font != null: message.add_theme_font_override("font", _ui_font)
|
||
var ok: Button = (dialog as AcceptDialog).get_ok_button()
|
||
if ok != null:
|
||
ok.add_theme_font_size_override("font_size", size)
|
||
if _ui_font != null: ok.add_theme_font_override("font", _ui_font)
|
||
if dialog is ConfirmationDialog:
|
||
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
|
||
if cancel != null:
|
||
cancel.add_theme_font_size_override("font_size", size)
|
||
if _ui_font != null: cancel.add_theme_font_override("font", _ui_font)
|
||
_apply_dialog_font_to_children(dialog, size)
|
||
|
||
|
||
func _apply_dialog_font_to_children(node: Node, size: int) -> void:
|
||
for child: Node in node.get_children():
|
||
if child is Control:
|
||
(child as Control).add_theme_font_size_override("font_size", size)
|
||
if _ui_font != null: (child as Control).add_theme_font_override("font", _ui_font)
|
||
_apply_dialog_font_to_children(child, size)
|
||
|
||
|
||
func _update_cursor_coords() -> void:
|
||
if _status_cursor_coords == null:
|
||
return
|
||
if _camera == null or not is_instance_valid(_camera):
|
||
return
|
||
var viewport_pos: Vector2 = get_viewport().get_mouse_position()
|
||
if not _is_stage_frame_position(viewport_pos):
|
||
_status_cursor_coords.text = "X: — Y: —"
|
||
return
|
||
var world_pos := _camera.get_global_mouse_position()
|
||
_status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]
|
||
|
||
|
||
func _is_stage_frame_position(viewport_pos: Vector2) -> bool:
|
||
if _mode_frame == null:
|
||
return false
|
||
var viewport_size: Vector2 = get_window().size
|
||
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
|
||
viewport_size = Vector2(1280.0, 720.0)
|
||
var left: float = _mode_frame.offset_left
|
||
var top: float = _mode_frame.offset_top
|
||
var right: float = maxf(left + 1.0, viewport_size.x)
|
||
var bottom: float = maxf(top + 1.0, viewport_size.y + _mode_frame.offset_bottom)
|
||
return is_point_in_stage_frame(viewport_pos, Rect2(Vector2(left, top), Vector2(right - left, bottom - top)))
|
||
|
||
|
||
static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool:
|
||
return frame_rect.abs().has_point(viewport_pos)
|
||
|
||
|
||
func _update_stage_chrome_layout() -> void:
|
||
if _mode_frame == null:
|
||
return
|
||
var palette_width: float = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
|
||
var left_inset: float = palette_width if current_mode == StageMode.EDIT else 0.0
|
||
_mode_frame.offset_left = left_inset
|
||
_mode_frame.offset_top = TOP_BAR_HEIGHT
|
||
_mode_frame.offset_right = 0.0
|
||
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
|
||
if _mode_badge != null:
|
||
_mode_badge.offset_left = left_inset + 12.0
|
||
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
|
||
|
||
|
||
func _sync_awaiting_cursor() -> void:
|
||
if _is_awaiting_click() != _last_awaiting_click:
|
||
_apply_cursor()
|
||
|
||
|
||
func _build_ui() -> void:
|
||
var ui := CanvasLayer.new()
|
||
ui.name = "UI"
|
||
add_child(ui)
|
||
|
||
# Mode frame (over the viewport, behind the top bar; amber border in DIRECT).
|
||
_mode_frame = Panel.new()
|
||
_mode_frame.name = "ModeFrame"
|
||
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
_mode_frame.offset_left = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
|
||
_mode_frame.offset_top = TOP_BAR_HEIGHT
|
||
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
|
||
var frame_style := StyleBoxFlat.new()
|
||
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
||
frame_style.border_color = _accent_direct
|
||
frame_style.set_border_width_all(3)
|
||
_mode_frame.add_theme_stylebox_override("panel", frame_style)
|
||
ui.add_child(_mode_frame)
|
||
|
||
var top_bar := PanelContainer.new()
|
||
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||
top_bar.offset_bottom = TOP_BAR_HEIGHT
|
||
ui.add_child(top_bar)
|
||
|
||
var chrome := Control.new()
|
||
chrome.name = "Content"
|
||
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
top_bar.add_child(chrome)
|
||
var left_group := HBoxContainer.new()
|
||
left_group.name = "LeftControls"
|
||
left_group.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
left_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||
left_group.offset_left = 8.0
|
||
left_group.offset_top = 8.0
|
||
chrome.add_child(left_group)
|
||
var right_group := HBoxContainer.new()
|
||
right_group.name = "RightControls"
|
||
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||
right_group.offset_right = -8.0
|
||
right_group.offset_top = 8.0
|
||
right_group.add_theme_constant_override("separation", 8)
|
||
chrome.add_child(right_group)
|
||
|
||
_file_menu = MenuButton.new()
|
||
_file_menu.name = "FileMenu"
|
||
_file_menu.text = "File"
|
||
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
|
||
_file_menu.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.file_menu", 22, 12, 48) if _sandbox_theme != null else 22)
|
||
var file_popup := _file_menu.get_popup()
|
||
var file_popup_font_size: int = _sandbox_theme.integer("fonts.sizes.file_menu_popup", 26, 12, 64) if _sandbox_theme != null else 26
|
||
file_popup.add_theme_font_size_override("font_size", file_popup_font_size)
|
||
file_popup.add_item("New", 0)
|
||
file_popup.add_item("Open…", 1)
|
||
var recent_popup := PopupMenu.new()
|
||
recent_popup.name = "OpenRecent"
|
||
recent_popup.add_theme_font_size_override("font_size", file_popup_font_size)
|
||
recent_popup.id_pressed.connect(_on_recent_scene_selected)
|
||
file_popup.add_child(recent_popup)
|
||
file_popup.add_submenu_item("Open Recent", "OpenRecent")
|
||
file_popup.add_item("Save", 2)
|
||
file_popup.add_item("Save As…", 3)
|
||
file_popup.add_item("Rename Scene…", 4)
|
||
file_popup.id_pressed.connect(_on_file_menu_id_pressed)
|
||
file_popup.about_to_popup.connect(_update_recent_menu)
|
||
left_group.add_child(_file_menu)
|
||
|
||
# This centered group is independent of left/right controls, preserving its
|
||
# window-center position at every supported stage width.
|
||
var mode_center := CenterContainer.new()
|
||
mode_center.name = "ModeCenter"
|
||
# This full-width layout wrapper must not shield File or utility controls.
|
||
mode_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||
mode_center.offset_bottom = TOP_BAR_HEIGHT
|
||
chrome.add_child(mode_center)
|
||
var mode_switcher := HBoxContainer.new()
|
||
mode_switcher.name = "ModeSwitcher"
|
||
mode_switcher.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
mode_switcher.add_theme_constant_override("separation", 4)
|
||
mode_center.add_child(mode_switcher)
|
||
var mode_defs: Array = [
|
||
[StageMode.EDIT, "✏️ Edit"],
|
||
[StageMode.DIRECT, "🎬 Direct"],
|
||
[StageMode.PLAY, "▶️ Play"],
|
||
]
|
||
for def: Array in mode_defs:
|
||
var mid: StageMode = def[0]
|
||
var btn := Button.new()
|
||
btn.text = String(def[1])
|
||
btn.toggle_mode = true
|
||
btn.custom_minimum_size = Vector2(_sandbox_theme.number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0) if _sandbox_theme != null else 124.0, 48.0)
|
||
btn.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
|
||
btn.toggled.connect(_on_mode_segment_toggled.bind(mid))
|
||
mode_switcher.add_child(btn)
|
||
_mode_buttons[int(mid)] = btn
|
||
_direct_hint_label = Label.new()
|
||
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
|
||
_direct_hint_label.visible = false
|
||
_apply_ui_font(_direct_hint_label)
|
||
right_group.add_child(_direct_hint_label)
|
||
|
||
_grid_check = CheckBox.new()
|
||
_grid_check.text = "Grid"
|
||
_grid_check.button_pressed = _show_grid
|
||
_grid_check.toggled.connect(_on_grid_toggled)
|
||
right_group.add_child(_grid_check)
|
||
|
||
_snap_check = CheckBox.new()
|
||
_snap_check.text = "Snap"
|
||
_snap_check.button_pressed = _snap_enabled
|
||
_snap_check.toggled.connect(_on_snap_toggled)
|
||
right_group.add_child(_snap_check)
|
||
|
||
_grid_size_label = Label.new()
|
||
_grid_size_label.text = "Size"
|
||
right_group.add_child(_grid_size_label)
|
||
|
||
_grid_size_spin = SpinBox.new()
|
||
_grid_size_spin.min_value = MIN_GRID_SIZE
|
||
_grid_size_spin.max_value = MAX_GRID_SIZE
|
||
_grid_size_spin.step = 1.0
|
||
_grid_size_spin.value = _grid_size
|
||
_grid_size_spin.rounded = true
|
||
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
|
||
right_group.add_child(_grid_size_spin)
|
||
|
||
# Graphical asset palette lives below chrome so stage tools keep their row.
|
||
_palette_dock = PanelContainer.new()
|
||
_palette_dock.name = "PaletteDock"
|
||
_palette_dock.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
|
||
_palette_dock.offset_top = TOP_BAR_HEIGHT
|
||
_palette_dock.offset_bottom = -STATUS_BAR_HEIGHT
|
||
_palette_dock.offset_right = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
|
||
if _sandbox_theme != null: _palette_dock.add_theme_stylebox_override("panel", _sandbox_theme.stylebox("palette"))
|
||
var scroll := ScrollContainer.new(); scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED; _palette_dock.add_child(scroll)
|
||
var cards := VBoxContainer.new(); cards.name = "PaletteCards"; cards.size_flags_horizontal = Control.SIZE_EXPAND_FILL; scroll.add_child(cards)
|
||
for entry: Dictionary in _spawner.get_palette_entries():
|
||
var card: Variant = ASSET_CARD.new()
|
||
card.configure(entry, null, _sandbox_theme)
|
||
card.toggle_mode = true
|
||
card.activated.connect(_on_palette_card_activated)
|
||
card.drag_started.connect(_begin_palette_drag)
|
||
cards.add_child(card)
|
||
_palette_buttons[String(entry["placement_id"])] = card
|
||
ui.add_child(_palette_dock)
|
||
|
||
# Bottom status bar (left = status, right = cursor world coords).
|
||
var bottom_bar := PanelContainer.new()
|
||
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
|
||
bottom_bar.offset_top = -STATUS_BAR_HEIGHT
|
||
ui.add_child(bottom_bar)
|
||
|
||
var status_hbox := HBoxContainer.new()
|
||
status_hbox.add_theme_constant_override("separation", 12)
|
||
bottom_bar.add_child(status_hbox)
|
||
|
||
_status_label = Label.new()
|
||
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_ui_font(_status_label)
|
||
status_hbox.add_child(_status_label)
|
||
|
||
_status_cursor_coords = Label.new()
|
||
_apply_ui_font(_status_cursor_coords)
|
||
status_hbox.add_child(_status_cursor_coords)
|
||
|
||
# Mode badge pill (top-left, below the top bar).
|
||
_mode_badge = PanelContainer.new()
|
||
_mode_badge.name = "ModeBadge"
|
||
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
|
||
_mode_badge.offset_left = (_sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0) + 12.0
|
||
var badge_style := StyleBoxFlat.new()
|
||
badge_style.bg_color = _accent_edit
|
||
badge_style.set_corner_radius_all(12)
|
||
badge_style.content_margin_left = 12.0
|
||
badge_style.content_margin_right = 12.0
|
||
badge_style.content_margin_top = 4.0
|
||
badge_style.content_margin_bottom = 4.0
|
||
_mode_badge.add_theme_stylebox_override("panel", badge_style)
|
||
_mode_badge_label = Label.new()
|
||
_mode_badge_label.add_theme_font_size_override("font_size", _status_pill_font_size)
|
||
_apply_ui_font(_mode_badge_label)
|
||
_mode_badge.add_child(_mode_badge_label)
|
||
ui.add_child(_mode_badge)
|
||
|
||
# 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)
|
||
|
||
_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_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.id_pressed.connect(_on_rule_action_popup_id_pressed)
|
||
ui.add_child(_rule_action_popup)
|
||
|
||
_rule_more_popup = PopupMenu.new()
|
||
_rule_more_popup.add_item("➕ Add another action", RULE_MORE_ADD)
|
||
_rule_more_popup.add_item("✅ Done", RULE_MORE_DONE)
|
||
_rule_more_popup.id_pressed.connect(_on_rule_more_id_pressed)
|
||
ui.add_child(_rule_more_popup)
|
||
|
||
_speak_dialog = AcceptDialog.new()
|
||
_speak_dialog.title = "Speak"
|
||
_speak_dialog.confirmed.connect(_on_speak_confirmed)
|
||
var speak_box := VBoxContainer.new()
|
||
_speak_dialog.add_child(speak_box)
|
||
_speak_edit = LineEdit.new()
|
||
_speak_edit.placeholder_text = "Say something…"
|
||
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
|
||
speak_box.add_child(_speak_edit)
|
||
_speak_stop_current_check = CheckBox.new()
|
||
_speak_stop_current_check.text = "Stop what he's doing?"
|
||
_speak_stop_current_check.visible = false
|
||
speak_box.add_child(_speak_stop_current_check)
|
||
_speak_dialog.register_text_enter(_speak_edit)
|
||
ui.add_child(_speak_dialog)
|
||
_apply_dialog_theme(_speak_dialog)
|
||
|
||
_wait_dialog = AcceptDialog.new()
|
||
_wait_dialog.title = "Wait"
|
||
_wait_dialog.confirmed.connect(_on_wait_confirmed)
|
||
_wait_spin = SpinBox.new()
|
||
_wait_spin.min_value = 0.1
|
||
_wait_spin.max_value = 10.0
|
||
_wait_spin.step = 0.1
|
||
_wait_spin.value = 1.0
|
||
_wait_dialog.add_child(_wait_spin)
|
||
ui.add_child(_wait_dialog)
|
||
_apply_dialog_theme(_wait_dialog)
|
||
|
||
_apply_popup_theme(_action_popup)
|
||
_apply_popup_theme(_trigger_popup)
|
||
_apply_popup_theme(_rule_action_popup)
|
||
_apply_popup_theme(_rule_more_popup)
|
||
|
||
_selector_dim = ColorRect.new()
|
||
_selector_dim.name = "SelectorDim"
|
||
_selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
|
||
_selector_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_selector_dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
_selector_dim.visible = false
|
||
ui.add_child(_selector_dim)
|
||
|
||
_selector = ASSET_SELECTOR.instantiate() as AssetSelector
|
||
_selector.item_selected.connect(_on_asset_selected)
|
||
_selector.cancelled.connect(_on_selector_cancelled)
|
||
_selector.popup_hide.connect(_on_selector_cancelled)
|
||
_selector.browse_requested.connect(_on_browse_requested)
|
||
_selector.refresh_requested.connect(_on_refresh_requested)
|
||
ui.add_child(_selector)
|
||
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
|
||
_selector.apply_font(_ui_font, _emoji_font)
|
||
_selector.apply_theme(_sandbox_theme)
|
||
|
||
_browse_dialog = FileDialog.new()
|
||
_browse_dialog.title = "Open .stk"
|
||
_browse_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||
_browse_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
|
||
_browse_dialog.file_selected.connect(_on_browse_file_selected)
|
||
ui.add_child(_browse_dialog)
|
||
|
||
_scene_open_dialog = FileDialog.new()
|
||
_scene_open_dialog.name = "SceneOpenDialog"
|
||
_scene_open_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||
_scene_open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||
_scene_open_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
|
||
_scene_open_dialog.file_selected.connect(func(path: String) -> void: _load_scene_from_path(path))
|
||
ui.add_child(_scene_open_dialog)
|
||
_scene_save_dialog = FileDialog.new()
|
||
_scene_save_dialog.name = "SceneSaveDialog"
|
||
_scene_save_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||
_scene_save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
|
||
_scene_save_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
|
||
_scene_save_dialog.file_selected.connect(_on_scene_save_path_selected)
|
||
_scene_save_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
|
||
ui.add_child(_scene_save_dialog)
|
||
|
||
_rename_scene_dialog = ConfirmationDialog.new()
|
||
_rename_scene_dialog.name = "RenameSceneDialog"
|
||
_rename_scene_dialog.title = "Rename Scene"
|
||
_rename_scene_dialog.cancel_button_text = "Cancel"
|
||
_rename_scene_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||
_scene_name_edit = LineEdit.new()
|
||
_scene_name_edit.name = "SceneNameEdit"
|
||
_scene_name_edit.placeholder_text = "Scene name"
|
||
_scene_name_edit.custom_minimum_size = Vector2(460.0, 0.0)
|
||
_rename_scene_dialog.add_child(_scene_name_edit)
|
||
_rename_scene_dialog.confirmed.connect(func() -> void:
|
||
var scene_title := _scene_name_edit.text.strip_edges()
|
||
if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty())
|
||
ui.add_child(_rename_scene_dialog)
|
||
_apply_dialog_theme(_rename_scene_dialog)
|
||
|
||
_missing_assets_dialog = AcceptDialog.new()
|
||
_missing_assets_dialog.name = "MissingAssetsDialog"
|
||
_missing_assets_dialog.title = "Missing Scene Assets"
|
||
_missing_assets_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||
ui.add_child(_missing_assets_dialog)
|
||
_apply_dialog_theme(_missing_assets_dialog)
|
||
_cli_error_dialog = AcceptDialog.new()
|
||
_cli_error_dialog.name = "CliErrorDialog"
|
||
_cli_error_dialog.title = "Could Not Load Scene"
|
||
_cli_error_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||
ui.add_child(_cli_error_dialog)
|
||
_apply_dialog_theme(_cli_error_dialog)
|
||
_unsaved_changes_dialog = ConfirmationDialog.new()
|
||
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "⚠️ Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"; _unsaved_changes_dialog.ok_button_text = "💾 Save"
|
||
_unsaved_changes_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||
_unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
|
||
_unsaved_changes_dialog.confirmed.connect(_on_unsaved_save)
|
||
_unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard())
|
||
_unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
|
||
ui.add_child(_unsaved_changes_dialog)
|
||
_apply_dialog_theme(_unsaved_changes_dialog)
|
||
|
||
# ---- Phase 3c editor tools ----
|
||
|
||
_rig_context_popup = PopupMenu.new()
|
||
_rig_context_popup.add_item("📋 Edit Queue…", RIG_CTX_EDIT_QUEUE)
|
||
_rig_context_popup.add_item("⚡ Edit Rules…", RIG_CTX_EDIT_RULES)
|
||
_rig_context_popup.id_pressed.connect(_on_rig_context_id_pressed)
|
||
_rig_context_popup.about_to_popup.connect(_refresh_rig_context_items)
|
||
ui.add_child(_rig_context_popup)
|
||
_apply_popup_theme(_rig_context_popup)
|
||
|
||
_waypoint_context = WAYPOINT_CONTEXT.new()
|
||
_waypoint_context.id_pressed.connect(_on_waypoint_context_id_pressed)
|
||
ui.add_child(_waypoint_context)
|
||
_apply_popup_theme(_waypoint_context)
|
||
|
||
_confirm_dialog = ConfirmationDialog.new()
|
||
_confirm_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||
_confirm_dialog.confirmed.connect(_on_confirm_confirmed)
|
||
ui.add_child(_confirm_dialog)
|
||
_apply_dialog_theme(_confirm_dialog)
|
||
|
||
_queue_panel = QUEUE_PANEL.instantiate() as QueuePanel
|
||
_queue_panel.edit_requested.connect(_on_queue_panel_edit_requested)
|
||
_queue_panel.delete_requested.connect(_on_queue_panel_delete_requested)
|
||
_queue_panel.add_requested.connect(_on_queue_panel_add_requested)
|
||
_queue_panel.clear_requested.connect(_on_queue_panel_clear_requested)
|
||
ui.add_child(_queue_panel)
|
||
_queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||
_queue_panel.min_size = _theme_dialog_size("queue_size", Vector2i(680, 520))
|
||
|
||
_rule_panel = RULE_PANEL.instantiate() as RulePanel
|
||
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
|
||
_rule_panel.delete_requested.connect(_on_rule_panel_delete_requested)
|
||
_rule_panel.add_requested.connect(_on_rule_panel_add_requested)
|
||
_rule_panel.clear_requested.connect(_on_rule_panel_clear_requested)
|
||
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
|
||
ui.add_child(_rule_panel)
|
||
_rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||
_rule_panel.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
|
||
|
||
_action_editor = ACTION_EDITOR.instantiate() as ActionEditor
|
||
_action_editor.committed.connect(_on_action_editor_committed)
|
||
_action_editor.cancelled.connect(_on_action_editor_cancelled)
|
||
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
||
_action_editor.prop_requested.connect(_on_action_editor_prop_requested)
|
||
ui.add_child(_action_editor)
|
||
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
|
||
|
||
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor
|
||
_rule_editor.committed.connect(_on_rule_editor_committed)
|
||
_rule_editor.cancelled.connect(_on_rule_editor_cancelled)
|
||
_rule_editor.trigger_target_requested.connect(_on_rule_editor_trigger_target_requested)
|
||
_rule_editor.action_add_requested.connect(_on_rule_editor_action_add_requested)
|
||
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
|
||
ui.add_child(_rule_editor)
|
||
_rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||
_rule_editor.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
|
||
|
||
|
||
func _build_gizmo_layer() -> void:
|
||
_gizmos = STAGE_GIZMOS.new()
|
||
_gizmos.name = "GizmoLayer"
|
||
_gizmos.camera = _camera
|
||
_gizmos.transform_committed.connect(_on_transform_committed)
|
||
_gizmos.transform_started.connect(_on_gizmo_transform_started)
|
||
_gizmos.transform_updated.connect(_on_gizmo_transform_updated)
|
||
add_child(_gizmos)
|
||
|
||
|
||
func _build_grid_layer() -> void:
|
||
_grid = STAGE_GRID.new()
|
||
_grid.name = "GridLayer"
|
||
_grid.camera = _camera
|
||
add_child(_grid)
|
||
move_child(_grid, 0)
|
||
|
||
|
||
func _build_ghost_holder() -> void:
|
||
_ghost_holder = Node2D.new()
|
||
_ghost_holder.name = "PlacementGhost"
|
||
add_child(_ghost_holder)
|
||
|
||
|
||
func _build_placement_overlay() -> void:
|
||
_placement_overlay = STAGE_PLACEMENT_OVERLAY.new()
|
||
_placement_overlay.name = "PlacementOverlay"
|
||
_placement_overlay.camera = _camera
|
||
add_child(_placement_overlay)
|
||
|
||
|
||
func _build_director_visuals() -> void:
|
||
_director_visuals = STAGE_DIRECTOR_VISUALS.new()
|
||
_director_visuals.name = "DirectorVisualsLayer"
|
||
_director_visuals.camera = _camera
|
||
_director_visuals.world = _world
|
||
add_child(_director_visuals)
|
||
|
||
|
||
## Code-built NavigationRegion2D child of the stage (NOT World, so it is never
|
||
## selected/hit-tested). Sits at the stage origin; World is at identity so world
|
||
## coordinates equal region-local coordinates.
|
||
func _build_navigation() -> void:
|
||
_nav_region = NavigationRegion2D.new()
|
||
_nav_region.name = "NavigationRegion2D"
|
||
add_child(_nav_region)
|
||
_rebake_navigation()
|
||
|
||
|
||
## Rebuild navigation from exposed top edges only. Each qualifying terrain edge
|
||
## becomes a shallow ribbon just above its collision surface, so agents route
|
||
## over floors/ramps rather than through solid terrain polygons.
|
||
func _rebake_navigation() -> void:
|
||
if _nav_region == null or not is_instance_valid(_nav_region):
|
||
return
|
||
var verts := PackedVector2Array()
|
||
var triangles: Array[PackedInt32Array] = []
|
||
var surfaces: Array[Dictionary] = []
|
||
for child: Node in _world.get_children():
|
||
var block := child as TerrainBlock
|
||
if block == null:
|
||
continue
|
||
var pts := PackedVector2Array()
|
||
for p: Vector2 in block.polygon_points:
|
||
pts.append(block.transform * p)
|
||
for i: int in pts.size():
|
||
var a := pts[i]
|
||
var b := pts[(i + 1) % pts.size()]
|
||
var edge := b - a
|
||
if edge.length_squared() <= 0.001:
|
||
continue
|
||
# TerrainUtils stores clockwise polygons; this right normal is
|
||
# outward, so only its upward-facing edges are walkable.
|
||
var outward := Vector2(edge.y, -edge.x).normalized()
|
||
if outward.y > -0.15:
|
||
continue
|
||
surfaces.append({"a": a, "b": b, "normal": outward})
|
||
for surface: Dictionary in surfaces:
|
||
_append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"])
|
||
# Join only short, climbable gaps between exposed surface ends. This connects
|
||
# authored stair treads without making cliffs or terrain interiors walkable.
|
||
for i: int in surfaces.size():
|
||
for j: int in range(i + 1, surfaces.size()):
|
||
for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]:
|
||
for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]:
|
||
if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
|
||
_append_navigation_ribbon(verts, triangles, a, b, Vector2.UP)
|
||
var poly := NavigationPolygon.new()
|
||
poly.set_vertices(verts)
|
||
for tri: PackedInt32Array in triangles:
|
||
poly.add_polygon(tri)
|
||
_nav_region.navigation_polygon = poly
|
||
if DEBUG_STAGE:
|
||
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
|
||
|
||
|
||
func _append_navigation_ribbon(
|
||
verts: PackedVector2Array,
|
||
triangles: Array[PackedInt32Array],
|
||
a: Vector2,
|
||
b: Vector2,
|
||
normal: Vector2
|
||
) -> void:
|
||
if a.distance_squared_to(b) <= 0.001:
|
||
return
|
||
var base: int = verts.size()
|
||
verts.append_array(PackedVector2Array([
|
||
a + normal * 4.0, b + normal * 4.0,
|
||
b - normal * 12.0, a - normal * 12.0,
|
||
]))
|
||
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
|
||
triangles.append(PackedInt32Array([base, base + 2, base + 3]))
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Signal handlers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _on_mode_segment_toggled(pressed: bool, mode: StageMode) -> void:
|
||
if pressed:
|
||
set_mode(mode)
|
||
else:
|
||
if current_mode == mode:
|
||
(_mode_buttons[int(mode)] as Button).set_pressed_no_signal(true)
|
||
|
||
|
||
func _on_palette_toggled(pressed: bool, id: String) -> void:
|
||
if pressed:
|
||
if id == "stickman" or id == "prop":
|
||
_open_selector(id)
|
||
return
|
||
set_placement_mode(id)
|
||
else:
|
||
if _selector_open and _selector_kind == id:
|
||
_close_selector()
|
||
elif _placement_id == id:
|
||
set_placement_mode("")
|
||
|
||
|
||
func _on_palette_card_activated(payload: Dictionary) -> void:
|
||
var id: String = String(payload.get("placement_id", ""))
|
||
if id.is_empty(): return
|
||
if bool(payload.get("opens_selector", false)):
|
||
_open_selector(id)
|
||
else:
|
||
set_placement_mode(id)
|
||
|
||
|
||
func _begin_palette_drag(payload: Dictionary) -> void:
|
||
if current_mode != StageMode.EDIT or _selector_open: return
|
||
var id: String = String(payload.get("placement_id", ""))
|
||
if id.is_empty(): return
|
||
_palette_drag_payload = payload.duplicate(true)
|
||
_palette_drag_previous_id = _placement_id
|
||
_placement_id = id
|
||
_spawn_ghost()
|
||
|
||
|
||
func _update_palette_drag(viewport_pos: Vector2) -> void:
|
||
if _ghost != null and is_instance_valid(_ghost):
|
||
_ghost.global_position = _viewport_to_world(viewport_pos) + _spawner.get_spawn_offset(_placement_id)
|
||
|
||
|
||
func _finish_palette_drag(viewport_pos: Vector2) -> void:
|
||
var id: String = _placement_id
|
||
if _is_stage_drop_position(viewport_pos):
|
||
_place_at(_viewport_to_world(viewport_pos))
|
||
_palette_drag_payload.clear()
|
||
_placement_id = _palette_drag_previous_id
|
||
_palette_drag_previous_id = ""
|
||
if _placement_id.is_empty(): _free_ghost()
|
||
else: _spawn_ghost()
|
||
for pid: String in _palette_buttons: (_palette_buttons[pid] as Button).set_pressed_no_signal(pid == _placement_id)
|
||
|
||
|
||
func _cancel_palette_drag() -> void:
|
||
_palette_drag_payload.clear()
|
||
_placement_id = _palette_drag_previous_id
|
||
_palette_drag_previous_id = ""
|
||
if _placement_id.is_empty(): _free_ghost()
|
||
else: _spawn_ghost()
|
||
|
||
|
||
func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
|
||
var rect: Rect2 = get_viewport().get_visible_rect()
|
||
if not rect.has_point(viewport_pos): return false
|
||
var hovered: Control = get_viewport().gui_get_hovered_control()
|
||
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
|
||
|
||
|
||
func _open_selector(kind: String) -> void:
|
||
for pid: String in _palette_buttons:
|
||
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
|
||
_selector_kind = kind
|
||
var entries: Array[Dictionary]
|
||
if kind == "stickman":
|
||
entries = _stickman_library.scan()
|
||
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
|
||
_spawner.selected_stickman_path = String(entries[0]["path"])
|
||
else:
|
||
entries = PROP_LIBRARY.scan()
|
||
if kind == "stickman" and entries.size() == 1:
|
||
_on_asset_selected(entries[0])
|
||
return
|
||
_selector_open = true
|
||
if _selector_dim != null:
|
||
_selector_dim.visible = true
|
||
_selector.open(kind, entries)
|
||
_load_or_enqueue_thumbnails(kind, entries)
|
||
|
||
|
||
func _on_asset_selected(entry: Dictionary) -> void:
|
||
var kind := _selector_kind
|
||
if kind == "stickman":
|
||
_spawner.selected_stickman_path = String(entry.get("path", ""))
|
||
else:
|
||
_spawner.selected_prop_id = String(entry.get("id", ""))
|
||
_close_selector()
|
||
set_placement_mode(kind)
|
||
|
||
|
||
func _close_selector() -> void:
|
||
_selector_open = false
|
||
_selector_kind = ""
|
||
_thumbnail_generation += 1
|
||
_thumbnail_queue.clear()
|
||
if _selector_dim != null:
|
||
_selector_dim.visible = false
|
||
if _selector != null:
|
||
_selector.hide()
|
||
|
||
|
||
func _on_selector_cancelled() -> void:
|
||
if not _selector_open:
|
||
return
|
||
_close_selector()
|
||
set_placement_mode("")
|
||
|
||
|
||
func _on_browse_requested() -> void:
|
||
if _browse_dialog != null:
|
||
if _selector_kind == "prop":
|
||
_browse_dialog.title = "Open .prop"
|
||
_browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
|
||
else:
|
||
_browse_dialog.title = "Open .stk"
|
||
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
|
||
_browse_dialog.popup_centered()
|
||
|
||
|
||
func _on_browse_file_selected(path: String) -> void:
|
||
var entry: Dictionary = PROP_LIBRARY.make_custom_entry(path) if _selector_kind == "prop" else _stickman_library.make_entry(path)
|
||
if entry.is_empty():
|
||
_show_toast("Could not load %s: %s" % [_selector_kind, path])
|
||
return
|
||
_on_asset_selected(entry)
|
||
|
||
|
||
func _on_refresh_requested() -> void:
|
||
if _selector_kind == "prop":
|
||
var prop_entries: Array[Dictionary] = PROP_LIBRARY.scan()
|
||
_selector.set_entries(prop_entries)
|
||
_load_or_enqueue_thumbnails("prop", prop_entries)
|
||
return
|
||
var entries: Array[Dictionary] = _stickman_library.scan()
|
||
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
|
||
_spawner.selected_stickman_path = String(entries[0]["path"])
|
||
_selector.set_entries(entries)
|
||
_load_or_enqueue_thumbnails("stickman", entries)
|
||
|
||
|
||
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
|
||
_thumbnail_queue.clear()
|
||
_thumbnail_generation += 1
|
||
var generation: int = _thumbnail_generation
|
||
for entry: Dictionary in entries:
|
||
if kind == "stickman":
|
||
var path := String(entry.get("path", ""))
|
||
var key := _thumbnail_cache.stickman_key(path)
|
||
var png := _thumbnail_cache.stickman_png(key)
|
||
var tex := _thumbnail_cache.get_texture(png)
|
||
if tex != null:
|
||
if tex != null and _selector != null:
|
||
_selector.set_thumbnail(entry, tex)
|
||
else:
|
||
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
|
||
else:
|
||
var id := String(entry.get("id", ""))
|
||
var png := _thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(id)
|
||
var tex := _thumbnail_cache.get_texture(png)
|
||
if tex != null:
|
||
if tex != null and _selector != null:
|
||
_selector.set_thumbnail(entry, tex)
|
||
else:
|
||
_thumbnail_queue.append({ "kind": kind, "entry": entry, "cache_path": png, "generation": generation })
|
||
|
||
|
||
func _drain_thumbnail_queue() -> void:
|
||
if _thumbnail_busy or _thumbnail_queue.is_empty():
|
||
return
|
||
_thumbnail_busy = true
|
||
var job: Dictionary = _thumbnail_queue.pop_front()
|
||
var kind := String(job["kind"])
|
||
var entry: Dictionary = job["entry"]
|
||
var tex: Texture2D = null
|
||
if kind == "stickman":
|
||
var key := String(job["key"])
|
||
var data: Dictionary = entry["data"]
|
||
tex = await _stickman_thumb.render(data)
|
||
if tex != null:
|
||
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
|
||
else:
|
||
if bool(entry.get("is_custom", false)):
|
||
tex = await _prop_thumb.render_shapes(entry.get("shapes", []) as Array[Dictionary])
|
||
else:
|
||
var payload: Dictionary = entry["payload"]
|
||
var preset: int = int(entry["material_preset"])
|
||
tex = await _prop_thumb.render(payload, preset)
|
||
if tex != null:
|
||
var fallback := _thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(String(entry["id"]))
|
||
_thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), tex)
|
||
_thumbnail_busy = false
|
||
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
|
||
_selector.set_thumbnail(entry, tex)
|
||
|
||
|
||
func _on_grid_toggled(pressed: bool) -> void:
|
||
_show_grid = pressed
|
||
_apply_grid_settings()
|
||
_save_settings()
|
||
|
||
|
||
func _on_snap_toggled(pressed: bool) -> void:
|
||
_snap_enabled = pressed
|
||
_apply_grid_settings()
|
||
_save_settings()
|
||
|
||
|
||
func _on_grid_size_changed(value: float) -> void:
|
||
_grid_size = clampf(value, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||
_apply_grid_settings()
|
||
_save_settings()
|
||
|
||
|
||
func _on_selection_changed(nodes: Array[Node2D]) -> void:
|
||
_gizmos.set_targets(nodes)
|
||
if nodes.is_empty():
|
||
object_deselected.emit()
|
||
else:
|
||
object_selected.emit(nodes)
|
||
_refresh_status()
|
||
|
||
|
||
func _on_hover_changed(node: Node2D) -> void:
|
||
_gizmos.set_hover(node)
|
||
|
||
|
||
func _on_transform_committed(nodes: Array[Node2D]) -> void:
|
||
for node: Node2D in nodes:
|
||
if node is RigidBody2D:
|
||
_edit_physics_targets[node] = node.global_transform
|
||
_sync_editable_physics_transform(node)
|
||
_save_object_state(node)
|
||
if node is TerrainBlock:
|
||
_nav_dirty = true
|
||
_rebuild_grid_cells()
|
||
_director_visuals.mark_dirty()
|
||
_edit_physics_settle_frames = 3
|
||
_mark_scene_dirty()
|
||
|
||
|
||
func _on_file_menu_id_pressed(id: int) -> void:
|
||
match id:
|
||
0:
|
||
_request_pending_file_action(PendingFileAction.NEW_SCENE)
|
||
1:
|
||
_request_pending_file_action(PendingFileAction.OPEN_DIALOG)
|
||
2:
|
||
if _current_scene_path.is_empty(): _scene_save_dialog.popup_centered_ratio(0.7)
|
||
else: _save_scene_to_path(_current_scene_path)
|
||
3: _scene_save_dialog.popup_centered_ratio(0.7)
|
||
4: _rename_scene_prompt()
|
||
|
||
|
||
func _on_scene_save_path_selected(path: String) -> void:
|
||
var final_path := path
|
||
if final_path.get_extension().to_lower() != "scene": final_path = final_path.get_basename() + ".scene"
|
||
if _save_scene_to_path(final_path) and _pending_file_action != PendingFileAction.NONE:
|
||
_continue_pending_file_action()
|
||
|
||
|
||
func _rename_scene_prompt() -> void:
|
||
_scene_name_edit.text = _scene_name
|
||
_rename_scene_dialog.popup_centered()
|
||
call_deferred("_focus_scene_name_edit")
|
||
|
||
|
||
func _focus_scene_name_edit() -> void:
|
||
if _scene_name_edit == null or not _scene_name_edit.visible:
|
||
return
|
||
_scene_name_edit.grab_focus()
|
||
_scene_name_edit.select_all()
|
||
|
||
|
||
func _request_pending_file_action(action: PendingFileAction, path: String = "") -> void:
|
||
_pending_file_action = action; _pending_open_path = path
|
||
if _dirty:
|
||
_unsaved_changes_dialog.popup_centered()
|
||
else:
|
||
_continue_pending_file_action()
|
||
|
||
|
||
func _continue_pending_file_action() -> void:
|
||
if _unsaved_changes_dialog != null:
|
||
_unsaved_changes_dialog.hide()
|
||
var action := _pending_file_action; var path := _pending_open_path
|
||
_pending_file_action = PendingFileAction.NONE; _pending_open_path = ""
|
||
match action:
|
||
PendingFileAction.NEW_SCENE:
|
||
_reset_scene_content(); _current_scene_path = ""; _scene_name = DEFAULT_SCENE_NAME; _scene_created_unix = Time.get_unix_time_from_system(); _camera.global_position = DEFAULT_CAMERA_POSITION; _camera.zoom = Vector2.ONE * DEFAULT_CAMERA_ZOOM; _set_scene_dirty(false)
|
||
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||
PendingFileAction.OPEN_DIALOG: _scene_open_dialog.popup_centered_ratio(0.7)
|
||
PendingFileAction.OPEN_PATH: _load_scene_from_path(path)
|
||
PendingFileAction.QUIT:
|
||
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||
get_tree().quit()
|
||
|
||
|
||
func _on_unsaved_save() -> void:
|
||
_unsaved_changes_dialog.hide()
|
||
if _current_scene_path.is_empty():
|
||
_scene_save_dialog.popup_centered_ratio(0.7)
|
||
return
|
||
if _save_scene_to_path(_current_scene_path): _continue_pending_file_action()
|
||
|
||
|
||
func _on_unsaved_discard() -> void:
|
||
_unsaved_changes_dialog.hide()
|
||
_continue_pending_file_action()
|
||
|
||
|
||
func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void:
|
||
_restore_frames_left = 0
|
||
_edit_physics_targets.clear()
|
||
_edit_physics_settle_frames = 0
|
||
|
||
|
||
func _on_gizmo_transform_updated(nodes: Array[Node2D]) -> void:
|
||
for node: Node2D in nodes:
|
||
if node is RigidBody2D:
|
||
_edit_physics_targets[node] = node.global_transform
|
||
_sync_editable_physics_transform(node)
|
||
|
||
|
||
func _sync_pending_editable_physics_transforms() -> void:
|
||
if _edit_physics_targets.is_empty() or current_mode != StageMode.EDIT:
|
||
return
|
||
for node: RigidBody2D in _edit_physics_targets.keys().duplicate():
|
||
if node == null or not is_instance_valid(node):
|
||
_edit_physics_targets.erase(node)
|
||
continue
|
||
node.global_transform = _edit_physics_targets[node]
|
||
_sync_editable_physics_transform(node)
|
||
if not _gizmos.is_dragging() and _edit_physics_settle_frames > 0:
|
||
_edit_physics_settle_frames -= 1
|
||
if _edit_physics_settle_frames == 0:
|
||
_edit_physics_targets.clear()
|
||
|
||
|
||
func _sync_editable_physics_transform(node: Node2D) -> void:
|
||
if not (node is RigidBody2D):
|
||
return
|
||
var body := node as RigidBody2D
|
||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||
body.freeze = true
|
||
body.linear_velocity = Vector2.ZERO
|
||
body.angular_velocity = 0.0
|
||
var target_transform: Transform2D = body.global_transform
|
||
var rid := body.get_rid()
|
||
if rid.is_valid():
|
||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, target_transform)
|
||
body.global_transform = target_transform
|
||
body.reset_physics_interpolation()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Director tool (Phase 3a)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _handle_direct_click(world_pos: Vector2) -> void:
|
||
if _pending_walk_target and _context_rig != null and is_instance_valid(_context_rig):
|
||
_stage_dbg("walk_to captured target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
|
||
_context_rig.queue_action({ "type": "walk_to", "target": world_pos })
|
||
_pending_walk_target = false
|
||
_context_rig = null
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
return
|
||
var hit := _selection.hit_test(world_pos)
|
||
if hit is STICKMAN_RIG:
|
||
_context_rig = hit as StickmanRig
|
||
var screen := _world_to_screen(hit.global_position)
|
||
var rect := Rect2i(Vector2i(screen) + Vector2i(24, 0), Vector2i.ZERO)
|
||
_set_popup_anchor(rect)
|
||
_action_popup.popup(rect)
|
||
|
||
|
||
## Re-enables/disables the "📋 Edit Queue…" / "⚡ Edit Rules…" items in the Direct
|
||
## action popup based on the context rig's queue/rules (entry-point gating, spec §2
|
||
## decision 8). Runs on about_to_popup.
|
||
func _refresh_action_popup_items() -> void:
|
||
if _action_popup == null:
|
||
return
|
||
var rig := _context_rig
|
||
var valid := rig != null and is_instance_valid(rig)
|
||
_disable_popup_item(_action_popup, ACT_EDIT_QUEUE, not valid or rig.get_queue().is_empty())
|
||
_disable_popup_item(_action_popup, ACT_EDIT_RULES, not valid or not _rig_has_rules(rig))
|
||
|
||
|
||
## Same gating for the stickman right-click context menu.
|
||
func _refresh_rig_context_items() -> void:
|
||
if _rig_context_popup == null:
|
||
return
|
||
var rig := _panel_rig
|
||
var valid := rig != null and is_instance_valid(rig)
|
||
_disable_popup_item(_rig_context_popup, RIG_CTX_EDIT_QUEUE, not valid or rig.get_queue().is_empty())
|
||
_disable_popup_item(_rig_context_popup, RIG_CTX_EDIT_RULES, not valid or not _rig_has_rules(rig))
|
||
|
||
|
||
## Disables/enables a PopupMenu item by id (set_item_disabled is index-based and
|
||
## the edit items are not at id==index positions once separators are present).
|
||
func _disable_popup_item(popup: PopupMenu, id: int, disabled: bool) -> void:
|
||
var idx := popup.get_item_index(id)
|
||
if idx >= 0:
|
||
popup.set_item_disabled(idx, disabled)
|
||
|
||
|
||
## True when the given rig is the source of at least one rule in _event_rules.
|
||
func _rig_has_rules(rig: StickmanRig) -> bool:
|
||
if rig == null or not is_instance_valid(rig):
|
||
return false
|
||
var id := rig.get_instance_id()
|
||
for rule: Dictionary in _event_rules:
|
||
var trigger: Dictionary = rule.get("trigger", {})
|
||
if int(trigger.get("source", -1)) == id:
|
||
return true
|
||
return false
|
||
|
||
|
||
func _on_action_popup_id_pressed(id: int) -> void:
|
||
if _context_rig == null or not is_instance_valid(_context_rig):
|
||
return
|
||
match id:
|
||
ACT_WALK:
|
||
_pending_walk_target = true
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
ACT_SPEAK:
|
||
_speak_edit.text = ""
|
||
_speak_stop_current_check.visible = false
|
||
_speak_stop_current_check.button_pressed = false
|
||
_speak_dialog.popup_centered()
|
||
_speak_edit.grab_focus()
|
||
ACT_WAIT:
|
||
_wait_spin.value = 1.0
|
||
_wait_dialog.popup_centered()
|
||
ACT_RAGDOLL:
|
||
_context_rig.queue_action({ "type": "ragdoll" })
|
||
ACT_RECOVER:
|
||
_context_rig.queue_action({ "type": "recover" })
|
||
ACT_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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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:
|
||
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 _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 throw_capture: bool = _is_throw_capture_active()
|
||
var origin: Vector2 = _throw_release_point() if throw_capture else _action_origin()
|
||
var target := _camera.get_global_mouse_position()
|
||
if _snap_enabled and (_capture_kind == CaptureKind.POSITION or _rule_step == RuleStep.ACTION_POSITION):
|
||
target = _snap_to_grid(target)
|
||
if throw_capture:
|
||
var solution: Dictionary = _solve_throw_capture(target)
|
||
var arc: PackedVector2Array = _sample_throw_arc(origin, solution) if bool(solution.get("ok", false)) else PackedVector2Array([origin, target])
|
||
_placement_overlay.set_throw_trajectory(origin, target, arc, bool(solution.get("ok", false)))
|
||
else:
|
||
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
|
||
else:
|
||
_placement_overlay.clear_action()
|
||
if _tooltip != null:
|
||
_tooltip.visible = awaiting
|
||
if awaiting:
|
||
_tooltip.text = _action_hint_text()
|
||
var sb: StyleBox = _tooltip.get_theme_stylebox("normal")
|
||
var extra := sb.get_minimum_size() if sb != null else Vector2.ZERO
|
||
_tooltip.size = _tooltip.get_minimum_size() + extra
|
||
_update_tooltip_position()
|
||
|
||
|
||
func _update_tooltip_position() -> void:
|
||
if _tooltip == null:
|
||
return
|
||
var mouse := get_viewport().get_mouse_position()
|
||
var size := _tooltip.size
|
||
var pos := mouse + Vector2(20.0, -20.0)
|
||
var vp := get_viewport().get_visible_rect().size
|
||
if pos.x + size.x > vp.x:
|
||
pos.x = mouse.x - size.x - 20.0
|
||
if pos.y + size.y > vp.y:
|
||
pos.y = mouse.y - size.y - 20.0
|
||
if pos.y < 0.0:
|
||
pos.y = mouse.y + 20.0
|
||
_tooltip.position = pos
|
||
|
||
|
||
func _on_speak_confirmed() -> void:
|
||
if _rule_step == RuleStep.PARAMS:
|
||
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0, "stop_current": _speak_stop_current_check.button_pressed })
|
||
_open_rule_more_popup()
|
||
return
|
||
if _context_rig == null or not is_instance_valid(_context_rig):
|
||
return
|
||
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
|
||
|
||
|
||
func _on_wait_confirmed() -> void:
|
||
if _rule_step == RuleStep.PARAMS:
|
||
_set_rule_action_params({ "duration": _wait_spin.value })
|
||
_open_rule_more_popup()
|
||
return
|
||
if _context_rig == null or not is_instance_valid(_context_rig):
|
||
return
|
||
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Rule / event system (Phase 4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Popup rect at the current mouse position.
|
||
func _mouse_popup_rect() -> Rect2i:
|
||
var mouse := get_viewport().get_mouse_position()
|
||
return Rect2i(Vector2i(mouse), Vector2i.ZERO)
|
||
|
||
|
||
## Records the session popup anchor (the first context menu's screen rect).
|
||
func _set_popup_anchor(rect: Rect2i) -> void:
|
||
_popup_anchor = rect
|
||
_popup_anchor_set = true
|
||
|
||
|
||
## Clears the session popup anchor (on rule confirm or cancel).
|
||
func _clear_popup_anchor() -> void:
|
||
_popup_anchor = Rect2i()
|
||
_popup_anchor_set = false
|
||
|
||
|
||
## Returns the session anchor, recording it from the mouse on first use so a
|
||
## rule-builder flow that never passed through a first menu still has one.
|
||
func _popup_anchor_rect() -> Rect2i:
|
||
if not _popup_anchor_set:
|
||
_set_popup_anchor(_mouse_popup_rect())
|
||
return _popup_anchor
|
||
|
||
|
||
## Starts a fresh rule build with the given trigger source (instance id; -1 for
|
||
## "any stickman"). Used by both the Phase 4 "When…" action and the Phase 3c
|
||
## Rule Panel's "Add Rule" button.
|
||
func _begin_rule_build(source_id: int, from_panel: bool = false) -> void:
|
||
_rule_build_from_panel = from_panel
|
||
_rule_builder = {
|
||
"trigger": { "source": source_id, "target": -1, "params": {} },
|
||
"actions": [],
|
||
}
|
||
_rule_editing_id = -1
|
||
var src := instance_from_id(source_id)
|
||
_rule_context_rig = src if src is StickmanRig else null
|
||
_rule_step = RuleStep.SELECT_TRIGGER
|
||
_trigger_popup.popup(_popup_anchor_rect())
|
||
|
||
|
||
func _open_rule_action_popup() -> void:
|
||
_sync_rule_action_done_item()
|
||
_rule_action_popup.popup(_popup_anchor_rect())
|
||
|
||
|
||
## Shows the "✅ Done" item in the rule-action popup only while editing an
|
||
## existing rule (otherwise there is no way to save without adding an action).
|
||
func _sync_rule_action_done_item() -> void:
|
||
var idx: int = _rule_action_popup.get_item_index(RULE_ACTION_DONE)
|
||
var editing: bool = _rule_editing_id >= 0
|
||
if editing and idx < 0:
|
||
_rule_action_popup.add_item("✅ Done", RULE_ACTION_DONE)
|
||
elif not editing and idx >= 0:
|
||
_rule_action_popup.remove_item(idx)
|
||
|
||
|
||
func _open_rule_more_popup() -> void:
|
||
_rule_more_popup.popup(_popup_anchor_rect())
|
||
|
||
|
||
func _on_trigger_popup_id_pressed(id: int) -> void:
|
||
if id == TRIG_BACK:
|
||
_reset_rule_builder()
|
||
if _context_rig != null and is_instance_valid(_context_rig):
|
||
_action_popup.popup(_popup_anchor_rect())
|
||
_refresh_status()
|
||
return
|
||
var trigger: Dictionary = _rule_builder.get("trigger", {})
|
||
trigger["type"] = _trigger_type_string(id)
|
||
_rule_builder["trigger"] = trigger
|
||
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED or id == TRIG_PROP_PICKED_UP or id == TRIG_PROP_DROPPED or id == TRIG_PROP_THROWN:
|
||
_rule_step = RuleStep.TRIGGER_TARGET
|
||
match id:
|
||
TRIG_ARRIVED:
|
||
_rule_hint = "Click the waypoint to trigger on (Esc or RMB to cancel)"
|
||
TRIG_ENTERED_AREA:
|
||
_rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
|
||
TRIG_COLLIDED:
|
||
_rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||
TRIG_PROP_PICKED_UP:
|
||
_rule_hint = "Click the prop to watch being picked up (Esc or RMB to cancel)"
|
||
TRIG_PROP_DROPPED:
|
||
_rule_hint = "Click the prop to watch being dropped (Esc or RMB to cancel)"
|
||
TRIG_PROP_THROWN:
|
||
_rule_hint = "Click the prop to watch being thrown (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"
|
||
_:
|
||
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"
|
||
_:
|
||
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 to use for this trigger (Esc or RMB to cancel)"
|
||
_refresh_status()
|
||
|
||
|
||
func _handle_action_target_click(world_pos: Vector2) -> void:
|
||
var hit := _selection.hit_test(world_pos)
|
||
if not (hit is STICKMAN_RIG):
|
||
_refresh_status()
|
||
return
|
||
var actions: Array = _rule_builder.get("actions", [])
|
||
if actions.is_empty():
|
||
return
|
||
var action: Dictionary = actions[actions.size() - 1]
|
||
action["target"] = (hit as StickmanRig).get_instance_id()
|
||
actions[actions.size() - 1] = action
|
||
_rule_builder["actions"] = actions
|
||
match String(action.get("type", "")):
|
||
"walk_to":
|
||
_rule_step = RuleStep.ACTION_POSITION
|
||
_rule_hint = "Click where %s should walk (Esc or RMB to cancel)" % String(hit.name)
|
||
_refresh_status()
|
||
"speak":
|
||
_rule_step = RuleStep.PARAMS
|
||
_speak_edit.text = ""
|
||
_speak_stop_current_check.visible = true
|
||
_speak_stop_current_check.button_pressed = false
|
||
_speak_dialog.popup_centered()
|
||
_speak_edit.grab_focus()
|
||
"wait":
|
||
_rule_step = RuleStep.PARAMS
|
||
_wait_spin.value = 1.0
|
||
_wait_dialog.popup_centered()
|
||
_:
|
||
# ragdoll / recover need no params.
|
||
_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()
|
||
|
||
|
||
## Writes dialog-confirmed params onto the last action in the builder.
|
||
func _set_rule_action_params(params: Dictionary) -> void:
|
||
var actions: Array = _rule_builder.get("actions", [])
|
||
if actions.is_empty():
|
||
return
|
||
var action: Dictionary = actions[actions.size() - 1]
|
||
action["params"] = params
|
||
actions[actions.size() - 1] = action
|
||
_rule_builder["actions"] = actions
|
||
|
||
|
||
func _finalize_rule() -> void:
|
||
_clear_popup_anchor()
|
||
var rule: Dictionary = _rule_builder.duplicate(true)
|
||
if _rule_editing_id >= 0:
|
||
# Replace the existing rule in place (same index).
|
||
var idx := -1
|
||
for i: int in _event_rules.size():
|
||
if int(_event_rules[i].get("id", -1)) == _rule_editing_id:
|
||
idx = i
|
||
break
|
||
if idx >= 0:
|
||
rule["id"] = _rule_editing_id
|
||
_event_rules[idx] = rule
|
||
_show_toast("Rule updated!")
|
||
else:
|
||
rule["id"] = _next_rule_id
|
||
_next_rule_id += 1
|
||
_event_rules.append(rule)
|
||
_show_toast("Rule created!")
|
||
else:
|
||
rule["id"] = _next_rule_id
|
||
_next_rule_id += 1
|
||
_event_rules.append(rule)
|
||
_show_toast("Rule created!")
|
||
_director_visuals.set_rules(_event_rules)
|
||
_mark_scene_dirty()
|
||
_reset_rule_builder()
|
||
_restore_rule_build_panel()
|
||
_refresh_status()
|
||
|
||
|
||
func _cancel_rule_build() -> void:
|
||
_clear_popup_anchor()
|
||
_reset_rule_builder()
|
||
if _trigger_popup != null:
|
||
_trigger_popup.hide()
|
||
if _rule_action_popup != null:
|
||
_rule_action_popup.hide()
|
||
if _rule_more_popup != null:
|
||
_rule_more_popup.hide()
|
||
_restore_rule_build_panel()
|
||
_refresh_status()
|
||
|
||
|
||
func _reset_rule_builder() -> void:
|
||
if _speak_stop_current_check != null:
|
||
_speak_stop_current_check.visible = false
|
||
_speak_stop_current_check.button_pressed = false
|
||
_rule_step = RuleStep.IDLE
|
||
_rule_builder = {}
|
||
_rule_context_rig = null
|
||
_rule_hint = ""
|
||
_rule_editing_id = -1
|
||
_apply_cursor()
|
||
|
||
|
||
func _delete_rule(id: int) -> void:
|
||
var before := _event_rules.size()
|
||
_event_rules = _event_rules.filter(func(r): return int(r.get("id", -1)) != id)
|
||
if _event_rules.size() != before:
|
||
_director_visuals.set_rules(_event_rules)
|
||
_mark_scene_dirty()
|
||
_refresh_status()
|
||
|
||
|
||
## Opens the consequence-only rule editor for a rule (Phase 3c.5). Trigger is
|
||
## read-only; only the action list is editable.
|
||
func _begin_edit_rule(id: int) -> void:
|
||
for rule: Dictionary in _event_rules:
|
||
if int(rule.get("id", -1)) != id:
|
||
continue
|
||
_open_rule_editor_consequence(rule)
|
||
return
|
||
|
||
|
||
## True when any of trigger.source / trigger.target / an action.target is in ids.
|
||
func _rule_references_any(rule: Dictionary, ids: Array[int]) -> bool:
|
||
var trigger: Dictionary = rule.get("trigger", {})
|
||
if ids.has(int(trigger.get("source", -1))):
|
||
return true
|
||
if ids.has(int(trigger.get("target", -1))):
|
||
return true
|
||
for a: Dictionary in rule.get("actions", []):
|
||
if ids.has(int(a.get("target", -1))):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _cleanup_rules_for_nodes(nodes: Array[Node2D]) -> void:
|
||
var ids: Array[int] = []
|
||
for n: Node2D in nodes:
|
||
ids.append(n.get_instance_id())
|
||
_event_rules = _event_rules.filter(func(r): return not _rule_references_any(r, ids))
|
||
_director_visuals.set_rules(_event_rules)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Event engine (Phase 4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
|
||
_handle_event({ "type": "arrived_at_waypoint", "source": rig, "target": null, "position": target, "action": {} })
|
||
|
||
|
||
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
|
||
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
|
||
|
||
func _on_rig_prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName) -> void:
|
||
if rig == null or prop == null or not is_instance_valid(rig) or not is_instance_valid(prop):
|
||
return
|
||
_handle_event({"type": String(action_type), "source": rig, "target": prop, "position": prop.global_position, "action": {"type": String(action_type), "reason": String(reason)}})
|
||
|
||
|
||
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:
|
||
return
|
||
if other == null or not is_instance_valid(other):
|
||
return
|
||
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 other == null or not is_instance_valid(other):
|
||
return
|
||
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:
|
||
for rule: Dictionary in _event_rules:
|
||
if _rule_matches(rule, event):
|
||
_execute_rule_actions(rule)
|
||
|
||
|
||
func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
|
||
var trigger: Dictionary = rule.get("trigger", {})
|
||
var rule_type := String(trigger.get("type", ""))
|
||
if rule_type != String(event.get("type", "")):
|
||
return false
|
||
var source := event.get("source") as Node2D
|
||
var target := event.get("target") as Node2D
|
||
var source_id := int(trigger.get("source", -1))
|
||
var target_id := int(trigger.get("target", -1))
|
||
match rule_type:
|
||
"arrived_at_waypoint":
|
||
if not _ids_match(source_id, source):
|
||
return false
|
||
var params: Dictionary = trigger.get("params", {})
|
||
var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF)
|
||
return (event.get("position", Vector2.INF) as Vector2).distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON
|
||
"action_finished":
|
||
if not _ids_match(source_id, source):
|
||
return false
|
||
var trig_params: Dictionary = trigger.get("params", {})
|
||
var want := String(trig_params.get("action_type", ""))
|
||
var action: Dictionary = event.get("action", {})
|
||
return want.is_empty() or want == String(action.get("type", ""))
|
||
"speech_finished":
|
||
return _ids_match(source_id, source)
|
||
"entered_area":
|
||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
||
"collided":
|
||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
||
"prop_picked_up", "prop_dropped", "prop_thrown":
|
||
return _ids_match(source_id, source) and _ids_match(target_id, target)
|
||
_:
|
||
return false
|
||
|
||
|
||
func _ids_match(a: int, b: Node2D) -> bool:
|
||
return a == -1 or (b != null and is_instance_valid(b) and b.get_instance_id() == a)
|
||
|
||
|
||
func _execute_rule_actions(rule: Dictionary) -> void:
|
||
var batches: Dictionary = {}
|
||
for a: Dictionary in rule.get("actions", []):
|
||
var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig
|
||
if rig == null or not is_instance_valid(rig):
|
||
continue
|
||
var id: int = rig.get_instance_id()
|
||
if not batches.has(id):
|
||
batches[id] = { "rig": rig, "actions": [] }
|
||
(batches[id]["actions"] as Array).append(_action_for_rig(a))
|
||
for batch: Dictionary in batches.values():
|
||
var target_rig := batch.get("rig") as StickmanRig
|
||
if target_rig != null and is_instance_valid(target_rig):
|
||
var batch_actions: Array[Dictionary] = []
|
||
batch_actions.assign(batch.get("actions", []))
|
||
target_rig.enqueue_reactive(batch_actions)
|
||
|
||
|
||
## Converts a rule action into the queue-action shape the runner understands.
|
||
func _action_for_rig(a: Dictionary) -> Dictionary:
|
||
var params: Dictionary = a.get("params", {})
|
||
var out: Dictionary = { "type": String(a.get("type", "")) }
|
||
match String(a.get("type", "")):
|
||
"walk_to":
|
||
out["target"] = params.get("target", Vector2.ZERO)
|
||
"speak":
|
||
out["text"] = String(params.get("text", ""))
|
||
out["duration"] = float(params.get("duration", 2.0))
|
||
out["stop_current"] = bool(params.get("stop_current", false))
|
||
"wait":
|
||
out["duration"] = float(params.get("duration", 0.0))
|
||
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_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)
|
||
|
||
|
||
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
|
||
var index := _ctx_waypoint_index
|
||
if rig == null or not is_instance_valid(rig):
|
||
return
|
||
match id:
|
||
WaypointContext.EDIT_WALK:
|
||
_begin_walk_edit(rig, index, false)
|
||
WaypointContext.DELETE_WALK:
|
||
rig.remove_action(index)
|
||
_show_toast("Walk deleted")
|
||
WaypointContext.INSERT_BEFORE:
|
||
_open_action_editor("queue_insert", {}, index, rig, -1, false)
|
||
WaypointContext.INSERT_AFTER:
|
||
_open_action_editor("queue_insert", {}, index + 1, rig, -1, false)
|
||
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
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
|
||
|
||
func _end_capture() -> void:
|
||
_capture_kind = CaptureKind.NONE
|
||
_capture_hint = ""
|
||
_capture_callback = Callable()
|
||
_capture_cancel = Callable()
|
||
_throw_capture_prop_id = -1
|
||
_throw_capture_flight_time = 0.9
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
|
||
|
||
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
|
||
if value == null:
|
||
_refresh_status()
|
||
return
|
||
if _capture_kind == CaptureKind.POSITION and _is_throw_capture_active() and not bool(_solve_throw_capture(value as Vector2).get("ok", false)):
|
||
_show_toast("That throw is too far for this flight time.")
|
||
_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:
|
||
_walk_edit_rig = rig
|
||
_walk_edit_index = 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 index := _walk_edit_index
|
||
var from_panel := _walk_edit_from_panel
|
||
_walk_edit_rig = null
|
||
_walk_edit_index = -1
|
||
_walk_edit_from_panel = false
|
||
if _director_visuals != null:
|
||
_director_visuals.clear_edit_waypoint()
|
||
if rig != null and is_instance_valid(rig):
|
||
var queue := rig.get_queue()
|
||
if index >= 0 and index < queue.size():
|
||
var action: Dictionary = queue[index]
|
||
action["target"] = value
|
||
rig.remove_action(index)
|
||
rig.insert_action(index, action)
|
||
_show_toast("Waypoint moved")
|
||
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_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
|
||
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 queue := _panel_rig.get_queue()
|
||
if index < 0 or index >= queue.size():
|
||
return
|
||
_ask_confirm("Delete Action", "Delete this action?", func() -> void:
|
||
if _panel_rig != null and is_instance_valid(_panel_rig):
|
||
_panel_rig.remove_action(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
|
||
_ask_confirm("Clear Queue", "Remove all actions for %s?" % _panel_rig.name, func() -> void:
|
||
if _panel_rig != null and is_instance_valid(_panel_rig):
|
||
_panel_rig.clear_queue()
|
||
if _queue_panel != null:
|
||
_queue_panel.refresh()
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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:
|
||
_reorder_filtered_rules(ordered_ids)
|
||
_director_visuals.set_rules(_event_rules)
|
||
_mark_scene_dirty()
|
||
|
||
|
||
## Rewrites the filtered rules (whose ids are in ordered_ids) in the new order
|
||
## into their existing slots, preserving every un-filtered rule's position.
|
||
func _reorder_filtered_rules(ordered_ids: Array[int]) -> void:
|
||
var by_id: Dictionary = {}
|
||
for r: Dictionary in _event_rules:
|
||
by_id[int(r.get("id", -1))] = r
|
||
var idx := 0
|
||
for i: int in _event_rules.size():
|
||
var rid := int(_event_rules[i].get("id", -1))
|
||
if ordered_ids.has(rid):
|
||
if idx < ordered_ids.size() and by_id.has(ordered_ids[idx]):
|
||
_event_rules[i] = by_id[ordered_ids[idx]]
|
||
idx += 1
|
||
|
||
|
||
## Re-shows the rule panel after a rule build launched from it finishes.
|
||
func _restore_rule_build_panel() -> void:
|
||
if _rule_build_from_panel:
|
||
_rule_build_from_panel = false
|
||
_refresh_rule_panel()
|
||
if _rule_panel != null:
|
||
_rule_panel.popup_centered()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 3c.5 Rule Editor
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _open_rule_editor_full(rule: Dictionary, from_panel: bool) -> void:
|
||
_rule_editor_from_panel = from_panel
|
||
if from_panel and _rule_panel != null:
|
||
_rule_panel.hide()
|
||
_rule_editor.open_full(rule)
|
||
|
||
|
||
func _open_rule_editor_consequence(rule: Dictionary) -> void:
|
||
_rule_editor_from_panel = false
|
||
_rule_editor.open_consequence(rule)
|
||
|
||
|
||
func _restore_rule_panel() -> void:
|
||
if _rule_editor_from_panel and _rule_panel != null:
|
||
_rule_editor_from_panel = false
|
||
_refresh_rule_panel()
|
||
_rule_panel.popup_centered()
|
||
|
||
|
||
func _on_rule_editor_committed(rule: Dictionary) -> void:
|
||
var id := int(rule.get("id", -1))
|
||
var idx := -1
|
||
for i: int in _event_rules.size():
|
||
if int(_event_rules[i].get("id", -1)) == id:
|
||
idx = i
|
||
break
|
||
if idx >= 0:
|
||
_event_rules[idx] = rule
|
||
_show_toast("Rule updated!")
|
||
else:
|
||
rule["id"] = _next_rule_id
|
||
_next_rule_id += 1
|
||
_event_rules.append(rule)
|
||
_show_toast("Rule created!")
|
||
_director_visuals.set_rules(_event_rules)
|
||
_mark_scene_dirty()
|
||
_restore_rule_panel()
|
||
|
||
|
||
func _on_rule_editor_cancelled() -> void:
|
||
_restore_rule_panel()
|
||
|
||
|
||
func _on_rule_editor_trigger_target_requested(type: String) -> void:
|
||
_rule_editor.hide()
|
||
match TRIGGER_REGISTRY.target_type(type):
|
||
"waypoint":
|
||
_begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc or RMB to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
|
||
"area":
|
||
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc or RMB to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
|
||
"prop":
|
||
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc or RMB to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel)
|
||
|
||
|
||
func _on_rule_editor_action_add_requested() -> void:
|
||
_open_action_editor("rule_add", {}, -1, null, -1, false)
|
||
|
||
|
||
func _on_rule_editor_action_edit_requested(index: int) -> void:
|
||
var rule_action: Dictionary = _rule_editor.get_action(index)
|
||
if rule_action.is_empty():
|
||
return
|
||
var flat := ACTION_REGISTRY.from_rule_action(rule_action)
|
||
var actor_id := int(rule_action.get("target", -1))
|
||
_open_action_editor("rule_edit", flat, index, null, actor_id, false)
|
||
|
||
|
||
# --- Rule editor target-capture callbacks -----------------------------------
|
||
|
||
func _cb_rule_trigger_waypoint(value: Vector2) -> void:
|
||
if _rule_editor != null:
|
||
_rule_editor.set_trigger_target(-1, { "waypoint_pos": value })
|
||
|
||
|
||
func _cb_rule_trigger_area(value: Node2D) -> void:
|
||
if _rule_editor != null:
|
||
_rule_editor.set_trigger_target(int(value.get_instance_id()), {})
|
||
|
||
|
||
func _cb_rule_trigger_prop(value: Node2D) -> void:
|
||
if _rule_editor != null:
|
||
_rule_editor.set_trigger_target(int(value.get_instance_id()), {})
|
||
|
||
|
||
func _cb_rule_trigger_cancel() -> void:
|
||
if _rule_editor != null:
|
||
_rule_editor.popup_centered()
|
||
|
||
|
||
func _cb_rule_actor(value: Node2D) -> void:
|
||
var actor_id := int(value.get_instance_id())
|
||
var flat: Dictionary = _pending_rule_action_flat
|
||
_pending_rule_action_flat = {}
|
||
if _rule_editor != null:
|
||
_rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, actor_id))
|
||
|
||
|
||
func _cb_rule_actor_cancel() -> void:
|
||
if _rule_editor != null:
|
||
_rule_editor.popup_centered()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 3c Action Editor
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: StickmanRig, actor_id: int, hide_queue: bool) -> void:
|
||
_action_editor_kind = kind
|
||
_action_editor_index = index
|
||
_action_editor_rig = rig
|
||
_action_editor_actor_id = actor_id
|
||
_action_editor_restore_queue = hide_queue
|
||
if hide_queue and _queue_panel != null:
|
||
_queue_panel.hide()
|
||
elif (kind == "rule_add" or kind == "rule_edit") and _rule_editor != null:
|
||
_rule_editor.hide()
|
||
if flat.is_empty():
|
||
_action_editor.open_new(kind == "rule_add" or kind == "rule_edit")
|
||
else:
|
||
_action_editor.open_edit(flat, kind == "rule_add" or kind == "rule_edit")
|
||
|
||
|
||
func _on_action_editor_committed(flat: Dictionary) -> void:
|
||
_action_editor.hide()
|
||
match _action_editor_kind:
|
||
"queue_add":
|
||
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||
_action_editor_rig.queue_action(flat)
|
||
_show_toast("Action added")
|
||
"queue_edit":
|
||
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||
var idx := _action_editor_index
|
||
var q := _action_editor_rig.get_queue()
|
||
if idx >= 0 and idx < q.size():
|
||
_action_editor_rig.remove_action(idx)
|
||
_action_editor_rig.insert_action(idx, flat)
|
||
_show_toast("Action updated")
|
||
"queue_insert":
|
||
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||
_action_editor_rig.insert_action(_action_editor_index, flat)
|
||
_show_toast("Action inserted")
|
||
"rule_add":
|
||
_pending_rule_action_flat = flat
|
||
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
|
||
return
|
||
"rule_edit":
|
||
if _rule_editor != null:
|
||
_rule_editor.set_action(_action_editor_index, ACTION_REGISTRY.to_rule_action(flat, _action_editor_actor_id))
|
||
_restore_queue_panel()
|
||
|
||
|
||
func _on_action_editor_cancelled() -> void:
|
||
_action_editor.hide()
|
||
match _action_editor_kind:
|
||
"queue_add", "queue_edit":
|
||
_restore_queue_panel()
|
||
"rule_add", "rule_edit":
|
||
if _rule_editor != null:
|
||
_rule_editor.popup_centered()
|
||
_:
|
||
pass
|
||
|
||
|
||
func _on_action_editor_target_requested() -> void:
|
||
_action_editor.hide()
|
||
var is_throw: bool = _action_editor != null and _action_editor.get_action_type() == "throw_prop"
|
||
if is_throw:
|
||
_throw_capture_prop_id = _action_editor.get_target_prop_id()
|
||
_throw_capture_flight_time = _action_editor.get_flight_time()
|
||
_begin_capture(CaptureKind.POSITION, "🎯 Click where the prop should land (Esc or RMB to cancel)" if is_throw else "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_throw_target if is_throw else _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_throw_target(value: Vector2) -> void:
|
||
if _action_editor != null:
|
||
_action_editor.set_throw_target(value)
|
||
|
||
func _on_action_editor_prop_requested() -> void:
|
||
_action_editor.hide()
|
||
_begin_capture(CaptureKind.PROP, "🧱 Click the prop to use (Esc or RMB to cancel)", _cb_editor_prop_target, _cb_editor_walk_target_cancel)
|
||
|
||
func _cb_editor_prop_target(value: PropBlock) -> void:
|
||
if _action_editor != null:
|
||
_action_editor.set_prop_target(value, _prop_action_target_label(value))
|
||
|
||
func _prop_action_target_label(prop: PropBlock) -> String:
|
||
if prop == null or not is_instance_valid(prop):
|
||
return ""
|
||
var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id)
|
||
var label: String = String(entry.get("name", ""))
|
||
if label.is_empty():
|
||
label = prop.spawn_id.get_file().get_basename() if prop.spawn_id.contains(".prop") else prop.spawn_id.capitalize()
|
||
if label.is_empty():
|
||
label = "Prop"
|
||
var ordinal: int = 0
|
||
for node: Node in _world.get_children():
|
||
if node is PropBlock and (node as PropBlock).spawn_id == prop.spawn_id:
|
||
ordinal += 1
|
||
if node == prop:
|
||
break
|
||
return "%s #%d" % [label, maxi(1, ordinal)]
|
||
|
||
|
||
func _cb_editor_walk_target_cancel() -> void:
|
||
if _action_editor != null:
|
||
_action_editor.popup_centered()
|
||
|
||
func _is_throw_capture_active() -> bool:
|
||
return _capture_kind == CaptureKind.POSITION and _throw_capture_prop_id >= 0
|
||
|
||
func _throw_release_point() -> Vector2:
|
||
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
|
||
var marker: Marker2D = _action_editor_rig._get_ik_marker("Right_Hand")
|
||
if marker != null and is_instance_valid(marker): return marker.global_position
|
||
return _action_editor_rig.global_position
|
||
return Vector2.ZERO
|
||
|
||
func _solve_throw_capture(target: Vector2) -> Dictionary:
|
||
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
|
||
if prop == null or not is_instance_valid(prop): return {"ok": false, "reason": "missing_prop"}
|
||
return PROP_TRAJECTORY.solve_throw_velocity(_throw_release_point(), target, _throw_capture_flight_time, StickmanPropInteraction.gravity_for(prop))
|
||
|
||
func _sample_throw_arc(origin: Vector2, solution: Dictionary) -> PackedVector2Array:
|
||
var points := PackedVector2Array()
|
||
if not bool(solution.get("ok", false)): return points
|
||
var prop: PropBlock = instance_from_id(_throw_capture_prop_id) as PropBlock
|
||
if prop == null or not is_instance_valid(prop): return points
|
||
var velocity: Vector2 = solution.get("velocity", Vector2.ZERO)
|
||
var duration: float = float(solution.get("flight_time", 0.0))
|
||
var gravity: Vector2 = StickmanPropInteraction.gravity_for(prop)
|
||
const SEGMENTS: int = 24
|
||
for index: int in range(SEGMENTS + 1):
|
||
var time: float = duration * float(index) / float(SEGMENTS)
|
||
points.append(origin + velocity * time + gravity * 0.5 * time * time)
|
||
return points
|
||
|
||
|
||
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.name == RAGDOLL_CONTAINER_NAME:
|
||
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.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()
|