3340 lines
110 KiB
GDScript
3340 lines
110 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 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")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 }
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 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
|
||
|
||
## Director action-popup item ids (Phase 3a).
|
||
const ACT_WALK := 0
|
||
const ACT_SPEAK := 1
|
||
const ACT_WAIT := 2
|
||
const ACT_RAGDOLL := 3
|
||
const ACT_RECOVER := 4
|
||
|
||
## Phase 4 rule-builder item ids. ACT_WHEN appends to the action popup; the
|
||
## TRIG_* ids drive the trigger sub-menu.
|
||
const ACT_WHEN := 5
|
||
const TRIG_ARRIVED := 0
|
||
const TRIG_ACTION_FINISHED := 1
|
||
const TRIG_SPEECH_FINISHED := 2
|
||
const TRIG_ENTERED_AREA := 3
|
||
const TRIG_COLLIDED := 4
|
||
const TRIG_BACK := 5
|
||
|
||
## 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 _mode_buttons: Dictionary = {} # int (StageMode) -> Button
|
||
var _palette_buttons: Dictionary = {}
|
||
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 _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
|
||
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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 _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 _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 _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 = {}
|
||
|
||
## Rule editor came from the rule panel (restore it on close).
|
||
var _rule_editor_from_panel: bool = false
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lifecycle
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _ready() -> void:
|
||
_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()
|
||
_apply_mode_frame()
|
||
_sync_mode_buttons()
|
||
_refresh_mode_badge()
|
||
_apply_cursor()
|
||
_refresh_status()
|
||
|
||
|
||
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:
|
||
if _restore_frames_left > 0:
|
||
_restore_frames_left -= 1
|
||
_restore_authored_state()
|
||
if current_mode == StageMode.PLAY:
|
||
_update_area_entry()
|
||
_update_stickman_prop_collision()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Input
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _input(event: InputEvent) -> void:
|
||
if event is InputEventMagnifyGesture:
|
||
# Touchpad pinch zoom.
|
||
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
|
||
elif event is InputEventPanGesture:
|
||
# Touchpad two-finger pan (delta is screen px; ×3 for speed parity).
|
||
_camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x
|
||
elif event is InputEventMouseButton:
|
||
_handle_mouse_button(event)
|
||
elif event is InputEventMouseMotion:
|
||
_handle_mouse_motion(event)
|
||
|
||
|
||
func _unhandled_input(event: InputEvent) -> void:
|
||
if event is InputEventMouseButton:
|
||
_handle_world_click(event)
|
||
|
||
|
||
func _unhandled_key_input(event: InputEvent) -> void:
|
||
if not (event is InputEventKey) or not event.pressed or event.echo:
|
||
return
|
||
match event.keycode:
|
||
KEY_DELETE, KEY_BACKSPACE:
|
||
if current_mode == StageMode.EDIT:
|
||
delete_selected()
|
||
KEY_ESCAPE:
|
||
if _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:
|
||
_handle_right_click()
|
||
return
|
||
if mb.button_index != MOUSE_BUTTON_LEFT:
|
||
return
|
||
var world_pos := _camera.get_global_mouse_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 := _camera.get_global_mouse_position()
|
||
if _gizmos.is_dragging():
|
||
_gizmos.drag_to(world_pos)
|
||
_director_visuals.mark_dirty()
|
||
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)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Mode management
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func set_mode(mode: StageMode) -> void:
|
||
if mode == current_mode:
|
||
return
|
||
current_mode = mode
|
||
match mode:
|
||
StageMode.EDIT:
|
||
_enter_edit_mode()
|
||
StageMode.DIRECT:
|
||
_enter_direct_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:
|
||
# Snap stickmen straight back to their standing pose/position (no stand-up
|
||
# tween glide), stopping any director queue first.
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
rig.stop_queue()
|
||
rig.snap_to_standing()
|
||
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
|
||
_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()
|
||
_collision_pairs.clear()
|
||
|
||
|
||
func _enter_edit_mode() -> void:
|
||
_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: same freeze/stand/gizmo
|
||
## side effects, but with the spawner/grid controls hidden, the director hint
|
||
## shown, and any placement/selection/pending-target cleared.
|
||
func _enter_direct_mode() -> void:
|
||
_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:
|
||
_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()
|
||
_collision_pairs.clear()
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is RigidBody2D:
|
||
(node as RigidBody2D).freeze = false
|
||
var rig_count := 0
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
rig.auto_recover = false
|
||
rig.start_queue()
|
||
rig_count += 1
|
||
_stage_dbg("PLAY rigs=%d" % rig_count)
|
||
|
||
|
||
func _save_object_state(node: Node2D) -> void:
|
||
_authored[node.get_instance_id()] = {
|
||
"node": node,
|
||
"position": node.global_position,
|
||
"rotation": node.global_rotation,
|
||
}
|
||
|
||
|
||
func _clear_object_state(node: Node2D) -> void:
|
||
_authored.erase(node.get_instance_id())
|
||
|
||
|
||
func _restore_authored_state() -> void:
|
||
for id: int in _authored.keys():
|
||
var entry: Dictionary = _authored[id]
|
||
var node := entry.get("node") as Node2D
|
||
if node == null or not is_instance_valid(node):
|
||
_authored.erase(id)
|
||
continue
|
||
node.global_position = entry.get("position", node.global_position)
|
||
node.global_rotation = entry.get("rotation", node.global_rotation)
|
||
if node is RigidBody2D:
|
||
var body := node as RigidBody2D
|
||
body.linear_velocity = Vector2.ZERO
|
||
body.angular_velocity = 0.0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Placement
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func set_placement_mode(id: String) -> void:
|
||
_placement_id = id
|
||
for pid: String in _palette_buttons:
|
||
var btn: Button = _palette_buttons[pid]
|
||
btn.set_pressed_no_signal(pid == id)
|
||
if id == "":
|
||
_free_ghost()
|
||
_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)
|
||
_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(_director_visuals.mark_dirty)
|
||
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))
|
||
_director_visuals.mark_dirty()
|
||
if node is PropBlock:
|
||
var prop := node as PropBlock
|
||
prop.collided.connect(_on_prop_collided.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()
|
||
|
||
|
||
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 = _terrain_cell_center(_world_to_terrain_cell(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:
|
||
_free_ghost()
|
||
_drag_painted.clear()
|
||
_terrain_dragging = true
|
||
_terrain_anchor_cell = _world_to_terrain_cell(world_pos)
|
||
_terrain_target_cell = _terrain_anchor_cell
|
||
_update_terrain_drag(world_pos)
|
||
|
||
|
||
func _update_terrain_drag(world_pos: Vector2) -> void:
|
||
if not _terrain_dragging:
|
||
return
|
||
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] = []
|
||
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()
|
||
_end_terrain_drag()
|
||
_refresh_status()
|
||
|
||
|
||
func _cancel_terrain_drag() -> void:
|
||
_end_terrain_drag()
|
||
|
||
|
||
func _end_terrain_drag() -> void:
|
||
_terrain_dragging = false
|
||
_drag_cells.clear()
|
||
_last_drag_cells.clear()
|
||
_drag_painted.clear()
|
||
_free_terrain_ghosts()
|
||
if _placement_overlay != null:
|
||
_placement_overlay.clear_terrain_guide()
|
||
# Re-arm the single cursor-following ghost (LMB repeated placement) unless
|
||
# the tool was just put away (placement_id cleared).
|
||
if _placement_id != "":
|
||
_spawn_ghost()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Grid spatial dictionary (Phase 4b) — advisory only, never authoritative.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]:
|
||
var cells: Array[Vector2i] = []
|
||
if aabb.size.x <= 0.0 or aabb.size.y <= 0.0:
|
||
return cells
|
||
var gs := STAGE_SPAWNER.TERRAIN_GRID_SIZE
|
||
var min_x := int(floorf(aabb.position.x / gs))
|
||
var max_x := int(floorf((aabb.end.x - 0.0001) / gs))
|
||
var min_y := int(floorf(aabb.position.y / gs))
|
||
var max_y := int(floorf((aabb.end.y - 0.0001) / gs))
|
||
for y: int in range(min_y, max_y + 1):
|
||
for x: int in range(min_x, max_x + 1):
|
||
cells.append(Vector2i(x, y))
|
||
return cells
|
||
|
||
|
||
func _rebuild_grid_cells() -> void:
|
||
_grid_cells.clear()
|
||
for node: Node2D in _world_children_selectable():
|
||
_add_node_to_grid_cells(node)
|
||
|
||
|
||
func _add_node_to_grid_cells(node: Node2D) -> void:
|
||
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
|
||
return
|
||
var aabb := STAGE_SELECTION.get_world_aabb(node)
|
||
for cell: Vector2i in _rasterize_aabb_to_cells(aabb):
|
||
var list: Array = _grid_cells.get(cell, [])
|
||
if not list.has(node):
|
||
list.append(node)
|
||
_grid_cells[cell] = list
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Selection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
|
||
var hit := _selection.hit_test(world_pos)
|
||
if hit != null:
|
||
if shift:
|
||
_selection.toggle_selection(hit)
|
||
if _selection.is_selected(hit):
|
||
_gizmos.begin_translate_drag(hit, world_pos)
|
||
elif _selection.is_selected(hit):
|
||
# Already selected: keep the whole selection and drag it together.
|
||
_gizmos.begin_translate_drag(hit, world_pos)
|
||
else:
|
||
_selection.select_only(hit)
|
||
_gizmos.begin_translate_drag(hit, world_pos)
|
||
else:
|
||
_box_selecting = true
|
||
_box_start = world_pos
|
||
_box_rect = Rect2(world_pos, Vector2.ZERO)
|
||
_gizmos.set_box_rect(_box_rect)
|
||
|
||
|
||
func _finish_box_select(shift: bool) -> void:
|
||
_box_selecting = false
|
||
_gizmos.set_box_rect(Rect2())
|
||
var rect := _box_rect.abs()
|
||
if rect.get_area() < MIN_BOX_AREA:
|
||
if not shift:
|
||
_selection.clear_selection()
|
||
else:
|
||
_selection.box_select(rect, shift)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deletion
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func delete_selected() -> void:
|
||
var selected := _selection.get_selected().duplicate()
|
||
if selected.is_empty():
|
||
return
|
||
var nav_changed := false
|
||
for node: Node2D in selected:
|
||
if is_instance_valid(node):
|
||
if node is TerrainBlock:
|
||
nav_changed = true
|
||
_clear_object_state(node)
|
||
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)
|
||
_refresh_status()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Status / UI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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 := "Objects: %d | Selected: %s" % [count, sel_text]
|
||
if _pending_walk_target:
|
||
text += " | Click stage for walk target (Esc 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
|
||
_mode_frame.visible = current_mode == StageMode.DIRECT
|
||
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
|
||
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 _update_cursor_coords() -> void:
|
||
if _status_cursor_coords == null:
|
||
return
|
||
if _camera == null or not is_instance_valid(_camera):
|
||
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 _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)
|
||
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 = 40.0
|
||
ui.add_child(top_bar)
|
||
|
||
var hbox := HBoxContainer.new()
|
||
hbox.add_theme_constant_override("separation", 8)
|
||
top_bar.add_child(hbox)
|
||
|
||
# 3-segment 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.toggled.connect(_on_mode_segment_toggled.bind(mid))
|
||
hbox.add_child(btn)
|
||
_mode_buttons[int(mid)] = btn
|
||
|
||
for id: String in _spawner.get_spawnable_ids():
|
||
var btn := Button.new()
|
||
btn.text = _spawner.get_label(id)
|
||
btn.toggle_mode = true
|
||
btn.toggled.connect(_on_palette_toggled.bind(id))
|
||
hbox.add_child(btn)
|
||
_palette_buttons[id] = btn
|
||
|
||
_direct_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)
|
||
hbox.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)
|
||
hbox.add_child(_grid_check)
|
||
|
||
_snap_check = CheckBox.new()
|
||
_snap_check.text = "Snap"
|
||
_snap_check.button_pressed = _snap_enabled
|
||
_snap_check.toggled.connect(_on_snap_toggled)
|
||
hbox.add_child(_snap_check)
|
||
|
||
_grid_size_label = Label.new()
|
||
_grid_size_label.text = "Size"
|
||
hbox.add_child(_grid_size_label)
|
||
|
||
_grid_size_spin = SpinBox.new()
|
||
_grid_size_spin.min_value = MIN_GRID_SIZE
|
||
_grid_size_spin.max_value = MAX_GRID_SIZE
|
||
_grid_size_spin.step = 1.0
|
||
_grid_size_spin.value = _grid_size
|
||
_grid_size_spin.rounded = true
|
||
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
|
||
hbox.add_child(_grid_size_spin)
|
||
|
||
# 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 = -28.0
|
||
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 = 48.0
|
||
_mode_badge.offset_left = 8.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_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)
|
||
_speak_edit = LineEdit.new()
|
||
_speak_edit.placeholder_text = "Say something…"
|
||
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
|
||
_speak_dialog.add_child(_speak_edit)
|
||
_speak_dialog.register_text_enter(_speak_edit)
|
||
ui.add_child(_speak_dialog)
|
||
|
||
_wait_dialog = AcceptDialog.new()
|
||
_wait_dialog.title = "Wait"
|
||
_wait_dialog.confirmed.connect(_on_wait_confirmed)
|
||
_wait_spin = SpinBox.new()
|
||
_wait_spin.min_value = 0.1
|
||
_wait_spin.max_value = 10.0
|
||
_wait_spin.step = 0.1
|
||
_wait_spin.value = 1.0
|
||
_wait_dialog.add_child(_wait_spin)
|
||
ui.add_child(_wait_dialog)
|
||
|
||
_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)
|
||
|
||
_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)
|
||
|
||
# ---- 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.confirmed.connect(_on_confirm_confirmed)
|
||
ui.add_child(_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)
|
||
|
||
_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)
|
||
|
||
_action_editor = ACTION_EDITOR.instantiate() as ActionEditor
|
||
_action_editor.committed.connect(_on_action_editor_committed)
|
||
_action_editor.cancelled.connect(_on_action_editor_cancelled)
|
||
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
||
ui.add_child(_action_editor)
|
||
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
|
||
|
||
_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)
|
||
|
||
|
||
func _build_gizmo_layer() -> void:
|
||
_gizmos = STAGE_GIZMOS.new()
|
||
_gizmos.name = "GizmoLayer"
|
||
_gizmos.camera = _camera
|
||
_gizmos.transform_committed.connect(_on_transform_committed)
|
||
add_child(_gizmos)
|
||
|
||
|
||
func _build_grid_layer() -> void:
|
||
_grid = STAGE_GRID.new()
|
||
_grid.name = "GridLayer"
|
||
_grid.camera = _camera
|
||
add_child(_grid)
|
||
move_child(_grid, 0)
|
||
|
||
|
||
func _build_ghost_holder() -> void:
|
||
_ghost_holder = Node2D.new()
|
||
_ghost_holder.name = "PlacementGhost"
|
||
add_child(_ghost_holder)
|
||
|
||
|
||
func _build_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 the navigation mesh from every TerrainBlock child of World: each
|
||
## block's world-space polygon is convex-decomposed and fan-triangulated into
|
||
## one manual NavigationPolygon.
|
||
func _rebake_navigation() -> void:
|
||
if _nav_region == null or not is_instance_valid(_nav_region):
|
||
return
|
||
var verts := PackedVector2Array()
|
||
var triangles: Array[PackedInt32Array] = []
|
||
for child: Node in _world.get_children():
|
||
var block := child as TerrainBlock
|
||
if block == null:
|
||
continue
|
||
var pts := PackedVector2Array()
|
||
for p: Vector2 in block.polygon_points:
|
||
pts.append(block.transform * p)
|
||
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
|
||
if pieces.is_empty():
|
||
pieces = [pts]
|
||
for piece: PackedVector2Array in pieces:
|
||
var base: int = verts.size()
|
||
verts.append_array(piece)
|
||
for i: int in range(1, piece.size() - 1):
|
||
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
|
||
var poly := NavigationPolygon.new()
|
||
poly.set_vertices(verts)
|
||
for tri: PackedInt32Array in triangles:
|
||
poly.add_polygon(tri)
|
||
_nav_region.navigation_polygon = poly
|
||
if DEBUG_STAGE:
|
||
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Signal handlers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _on_mode_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 _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.get_entries()
|
||
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_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:
|
||
_browse_dialog.popup_centered()
|
||
|
||
|
||
func _on_browse_file_selected(path: String) -> void:
|
||
var entry := _stickman_library.make_entry(path)
|
||
if entry.is_empty():
|
||
_show_toast("Could not load stickman: " + path)
|
||
return
|
||
_on_asset_selected(entry)
|
||
|
||
|
||
func _on_refresh_requested() -> void:
|
||
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()
|
||
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)
|
||
if FileAccess.file_exists(png):
|
||
var tex := _thumbnail_cache.load_png(png)
|
||
if tex != null and _selector != null:
|
||
_selector.set_thumbnail(entry, tex)
|
||
else:
|
||
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key })
|
||
else:
|
||
var id := String(entry.get("id", ""))
|
||
var png := _thumbnail_cache.prop_png(id)
|
||
if FileAccess.file_exists(png):
|
||
var tex := _thumbnail_cache.load_png(png)
|
||
if tex != null and _selector != null:
|
||
_selector.set_thumbnail(entry, tex)
|
||
else:
|
||
_thumbnail_queue.append({ "kind": kind, "entry": entry })
|
||
|
||
|
||
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.save_png(tex, _thumbnail_cache.stickman_png(key))
|
||
else:
|
||
var payload: Dictionary = entry["payload"]
|
||
var preset: int = int(entry["material_preset"])
|
||
tex = await _prop_thumb.render(payload, preset)
|
||
if tex != null:
|
||
_thumbnail_cache.save_png(tex, _thumbnail_cache.prop_png(String(entry["id"])))
|
||
_thumbnail_busy = false
|
||
if _selector != null and _selector_open 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:
|
||
_save_object_state(node)
|
||
if node is TerrainBlock:
|
||
_nav_dirty = true
|
||
_rebuild_grid_cells()
|
||
_director_visuals.mark_dirty()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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_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 - STICKMAN_RIG.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 - STICKMAN_RIG.FOOT_OFFSET
|
||
if _panel_rig != null and is_instance_valid(_panel_rig):
|
||
return _panel_rig.global_position - STICKMAN_RIG.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 - STICKMAN_RIG.FOOT_OFFSET
|
||
var trigger: Dictionary = _rule_builder.get("trigger", {})
|
||
var src := instance_from_id(int(trigger.get("source", -1)))
|
||
if src is Node2D and is_instance_valid(src):
|
||
return (src as Node2D).global_position
|
||
return Vector2.ZERO
|
||
|
||
|
||
func _is_target_valid(pos: Vector2) -> bool:
|
||
var cell := _world_to_grid_cell(pos)
|
||
for n in _grid_cells.get(cell, []):
|
||
if n is TerrainBlock and STAGE_SELECTION.get_world_aabb(n).has_point(pos):
|
||
return false
|
||
return true
|
||
|
||
|
||
func _update_action_overlay() -> void:
|
||
var awaiting := _is_awaiting_click()
|
||
if _placement_overlay != null:
|
||
if awaiting:
|
||
var origin := _action_origin()
|
||
var target := _camera.get_global_mouse_position()
|
||
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
|
||
target = _snap_to_grid(target)
|
||
_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 })
|
||
_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:
|
||
_rule_step = RuleStep.TRIGGER_TARGET
|
||
match id:
|
||
TRIG_ARRIVED:
|
||
_rule_hint = "Click the waypoint to trigger on (Esc to cancel)"
|
||
TRIG_ENTERED_AREA:
|
||
_rule_hint = "Click the trigger area to watch (Esc to cancel)"
|
||
TRIG_COLLIDED:
|
||
_rule_hint = "Click the prop that will be collided with (Esc 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 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"
|
||
_:
|
||
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 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 to cancel)"
|
||
_refresh_status()
|
||
"collided":
|
||
var hit2 := _selection.hit_test(world_pos)
|
||
if hit2 is PropBlock:
|
||
trigger["target"] = hit2.get_instance_id()
|
||
_rule_builder["trigger"] = trigger
|
||
_rule_step = RuleStep.SELECT_ACTION
|
||
_open_rule_action_popup()
|
||
else:
|
||
_rule_hint = "Click the prop that will be collided with (Esc 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 to cancel)" % String(hit.name)
|
||
_refresh_status()
|
||
"speak":
|
||
_rule_step = RuleStep.PARAMS
|
||
_speak_edit.text = ""
|
||
_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)
|
||
_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:
|
||
_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)
|
||
_refresh_status()
|
||
|
||
|
||
## Opens the consequence-only rule editor for a rule (Phase 3c.5). Trigger is
|
||
## read-only; only the action list is editable.
|
||
func _begin_edit_rule(id: int) -> void:
|
||
for rule: Dictionary in _event_rules:
|
||
if int(rule.get("id", -1)) != id:
|
||
continue
|
||
_open_rule_editor_consequence(rule)
|
||
return
|
||
|
||
|
||
## True when any of trigger.source / trigger.target / an action.target is in ids.
|
||
func _rule_references_any(rule: Dictionary, ids: Array[int]) -> bool:
|
||
var trigger: Dictionary = rule.get("trigger", {})
|
||
if ids.has(int(trigger.get("source", -1))):
|
||
return true
|
||
if ids.has(int(trigger.get("target", -1))):
|
||
return true
|
||
for a: Dictionary in rule.get("actions", []):
|
||
if ids.has(int(a.get("target", -1))):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _cleanup_rules_for_nodes(nodes: Array[Node2D]) -> void:
|
||
var ids: Array[int] = []
|
||
for n: Node2D in nodes:
|
||
ids.append(n.get_instance_id())
|
||
_event_rules = _event_rules.filter(func(r): return not _rule_references_any(r, ids))
|
||
_director_visuals.set_rules(_event_rules)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Event engine (Phase 4)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
|
||
_handle_event({ "type": "arrived_at_waypoint", "source": rig, "target": null, "position": target, "action": {} })
|
||
|
||
|
||
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
|
||
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
|
||
|
||
|
||
func _on_rig_speech_finished(rig: StickmanRig) -> void:
|
||
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
|
||
|
||
|
||
func _on_prop_collided(other: Node, prop: PropBlock) -> void:
|
||
_handle_event({ "type": "collided", "source": prop, "target": other, "position": prop.global_position, "action": {} })
|
||
|
||
|
||
## 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(target_id, target)
|
||
"collided":
|
||
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:
|
||
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
|
||
rig.enqueue_reactive([_action_for_rig(a)])
|
||
|
||
|
||
## 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))
|
||
"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)
|
||
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 = area_rect.has_point(point)
|
||
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
|
||
|
||
|
||
## Geometric stickman-vs-prop collision (ANIMATED rigs x unfrozen props),
|
||
## edge-triggered via the prop's world AABB containing the rig's feet/root point.
|
||
func _update_stickman_prop_collision() -> void:
|
||
var rigs: Array[StickmanRig] = []
|
||
var props: Array[PropBlock] = []
|
||
for node: Node2D in _world_children_selectable():
|
||
if node is STICKMAN_RIG:
|
||
var rig := node as STICKMAN_RIG
|
||
if rig.state == StickmanRig.RigState.ANIMATED:
|
||
rigs.append(rig)
|
||
elif node is PropBlock and not (node as PropBlock).freeze:
|
||
props.append(node as PropBlock)
|
||
for rig: StickmanRig in rigs:
|
||
for prop: PropBlock in props:
|
||
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
|
||
var feet: Vector2 = rig.global_position - StickmanRig.FOOT_OFFSET
|
||
var overlap: bool = STAGE_SELECTION.get_world_aabb(prop).has_point(feet)
|
||
var was: bool = bool(_collision_pairs.get(key, false))
|
||
if overlap and not was:
|
||
_collision_pairs[key] = true
|
||
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": rig.global_position, "action": {} })
|
||
elif not overlap and was:
|
||
_collision_pairs[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 - StickmanRig.FOOT_OFFSET
|
||
return node.global_position
|
||
|
||
|
||
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()
|
||
_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
|
||
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 - STICKMAN_RIG.FOOT_OFFSET)
|
||
_director_visuals.set_edit_waypoint(pos)
|
||
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc 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)
|
||
_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)
|
||
|
||
|
||
## 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)
|
||
_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 to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
|
||
"area":
|
||
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
|
||
"prop":
|
||
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc 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()
|
||
else:
|
||
_action_editor.open_edit(flat)
|
||
|
||
|
||
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 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()
|
||
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
|
||
|
||
|
||
func _cb_editor_walk_target(value: Vector2) -> void:
|
||
if _action_editor != null:
|
||
_action_editor.set_walk_target(value)
|
||
|
||
|
||
func _cb_editor_walk_target_cancel() -> void:
|
||
if _action_editor != null:
|
||
_action_editor.popup_centered()
|
||
|
||
|
||
func _restore_queue_panel() -> void:
|
||
if _action_editor_restore_queue and _queue_panel != null:
|
||
_queue_panel.refresh()
|
||
_queue_panel.popup_centered()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 3c confirmation dialog
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _ask_confirm(title: String, message: String, callback: Callable) -> void:
|
||
_confirm_callback = callback
|
||
_confirm_dialog.title = title
|
||
_confirm_dialog.dialog_text = message
|
||
_confirm_dialog.popup_centered()
|
||
|
||
|
||
func _on_confirm_confirmed() -> void:
|
||
var cb := _confirm_callback
|
||
_confirm_callback = Callable()
|
||
if cb.is_valid():
|
||
cb.call()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Converts a world position to screen coordinates using the stage camera (the
|
||
## exact inverse of Camera2D.get_global_mouse_position()).
|
||
func _world_to_screen(world_pos: Vector2) -> Vector2:
|
||
var vp_size := get_viewport().get_visible_rect().size
|
||
return (world_pos - _camera.position) * _camera.zoom + vp_size * 0.5
|
||
|
||
|
||
func _set_zoom(value: float) -> void:
|
||
var z := clampf(value, min_zoom, max_zoom)
|
||
var old_zoom := _camera.zoom.x
|
||
if is_equal_approx(z, old_zoom):
|
||
return
|
||
var mouse_world := _camera.get_global_mouse_position()
|
||
_camera.zoom = Vector2(z, z)
|
||
_camera.position = mouse_world - (mouse_world - _camera.position) * (old_zoom / z)
|
||
|
||
|
||
func _is_mouse_over_ui() -> bool:
|
||
return get_viewport().gui_get_hovered_control() != null
|
||
|
||
|
||
func _snap_to_grid(v: Vector2) -> Vector2:
|
||
if _grid_size <= 0.0:
|
||
return v
|
||
return Vector2(roundf(v.x / _grid_size) * _grid_size, roundf(v.y / _grid_size) * _grid_size)
|
||
|
||
|
||
func _apply_grid_settings() -> void:
|
||
if _grid != null:
|
||
_grid.grid_size = _grid_size
|
||
_grid.enabled = _show_grid
|
||
_grid.visible = _show_grid and current_mode == StageMode.EDIT
|
||
if _gizmos != null:
|
||
_gizmos.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 _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:
|
||
_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 = json as Dictionary
|
||
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)
|
||
_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.guide_line_color = _guide_line_color
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Settings persistence
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _load_settings() -> void:
|
||
if not FileAccess.file_exists(SETTINGS_PATH):
|
||
return
|
||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
|
||
if file == null:
|
||
return
|
||
var json: Variant = JSON.parse_string(file.get_as_text())
|
||
file.close()
|
||
if not json is Dictionary:
|
||
return
|
||
var d := json as Dictionary
|
||
_grid_size = float(d.get("grid_size", DEFAULT_GRID_SIZE))
|
||
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||
_snap_enabled = bool(d.get("snap_to_grid", false))
|
||
_show_grid = bool(d.get("show_grid", true))
|
||
|
||
|
||
func _save_settings() -> void:
|
||
var data := {
|
||
"version": "1.0",
|
||
"grid_size": _grid_size,
|
||
"snap_to_grid": _snap_enabled,
|
||
"show_grid": _show_grid,
|
||
}
|
||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
|
||
if file:
|
||
file.store_string(JSON.stringify(data, "\t", false))
|
||
file.close()
|