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") # --------------------------------------------------------------------------- # 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 } # --------------------------------------------------------------------------- # 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 ## 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 _tooltip_font_size: int = 18 var _status_pill_font_size: int = 16 var _ui_font: Font = null var _emoji_font: Font = null 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 ## 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 ":" ## and ":". 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 # --------------------------------------------------------------------------- # 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 _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 # RMB ends draw/placement mode in EDIT (Phase 4b build cancelling). LMB keeps # the Phase 2 repeated-placement behavior. if mb.button_index == MOUSE_BUTTON_RIGHT: if mb.pressed and current_mode == StageMode.EDIT and _placement_id != "": _cancel_terrain_drag() set_placement_mode("") 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 # 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 _rule_step != RuleStep.IDLE: _cancel_rule_build() if _action_popup != null: _action_popup.hide() ## 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) if _emoji_font != null: popup.add_theme_font_override("font", _emoji_font) 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.id_pressed.connect(_on_action_popup_id_pressed) 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) 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) 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: _rule_builder = { "trigger": { "source": _context_rig.get_instance_id(), "target": -1, "params": {} }, "actions": [], } _rule_editing_id = -1 _rule_context_rig = _context_rig _rule_step = RuleStep.SELECT_TRIGGER _trigger_popup.popup(_popup_anchor_rect()) # --------------------------------------------------------------------------- # Director UX (Phase 4b): cursor-attached tooltip, trajectory, ghost marker # --------------------------------------------------------------------------- func _is_awaiting_click() -> bool: if _pending_walk_target: 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" 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 _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 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() _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() _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() ## Re-enters the builder at SELECT_ACTION pre-populated from the stored rule ## (trigger edits are delete + rebuild, per the approved design). func _begin_edit_rule(id: int) -> void: for rule: Dictionary in _event_rules: if int(rule.get("id", -1)) != id: continue var trigger: Dictionary = (rule.get("trigger", {}) as Dictionary).duplicate(true) var actions: Array = [] for a: Dictionary in rule.get("actions", []): actions.append(a.duplicate(true)) _rule_builder = { "trigger": trigger, "actions": actions } _rule_editing_id = id var source_id := int(trigger.get("source", -1)) if source_id >= 0: var src := instance_from_id(source_id) if src is StickmanRig: _rule_context_rig = src _rule_step = RuleStep.SELECT_ACTION _set_popup_anchor(_mouse_popup_rect()) _open_rule_action_popup() 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 # --------------------------------------------------------------------------- # 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 = {} 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)) _tooltip_font_size = int(fonts.get("tooltip_font_size", 18)) _status_pill_font_size = int(fonts.get("status_pill_font_size", 16)) _ui_font = _load_font(String(fonts.get("ui_font", ""))) _emoji_font = _load_font(String(fonts.get("emoji_font", ""))) 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) 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 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()