Files

3340 lines
110 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name SandboxStage
extends Node2D
## SandboxStage - Kid-friendly director sandbox stage (Phase 2).
##
## A self-contained visual stage for placing terrain, props and stickmen from a
## palette, with EDIT (build) and PLAY (simulate) modes. Selection uses geometric
## hit-testing; objects are dragged directly (no move handle) and rotated via a
## ring handle. A placement ghost previews the object under the cursor, and an
## optional snap-to-grid + grid overlay ease placement. NOT wired into the editor
## - run standalone via F6 on res://scenes/sandbox_stage.tscn.
# ---------------------------------------------------------------------------
# Preloaded helpers
# ---------------------------------------------------------------------------
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
const STAGE_GRID := preload("res://scripts/stage_grid.gd")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd")
const STAGE_PLACEMENT_OVERLAY := preload("res://scripts/stage_placement_overlay.gd")
const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const 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()