Files
stickman/scripts/sandbox_stage.gd
T
ryan 1f91f3d2e5 Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
2026-09-04 15:08:08 -04:00

2475 lines
80 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name SandboxStage
extends Node2D
## SandboxStage - Kid-friendly director sandbox stage (Phase 2).
##
## A self-contained visual stage for placing terrain, props and stickmen from a
## palette, with EDIT (build) and PLAY (simulate) modes. Selection uses geometric
## hit-testing; objects are dragged directly (no move handle) and rotated via a
## ring handle. A placement ghost previews the object under the cursor, and an
## optional snap-to-grid + grid overlay ease placement. NOT wired into the editor
## - run standalone via F6 on res://scenes/sandbox_stage.tscn.
# ---------------------------------------------------------------------------
# Preloaded helpers
# ---------------------------------------------------------------------------
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
const STAGE_GRID := preload("res://scripts/stage_grid.gd")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage_director_visuals.gd")
const STAGE_PLACEMENT_OVERLAY := preload("res://scripts/stage_placement_overlay.gd")
const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd")
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum StageMode { EDIT, DIRECT, PLAY }
## Phase 4 rule-builder state machine steps.
enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal mode_changed(mode: int)
signal object_placed(node: Node2D)
signal object_selected(nodes: Array[Node2D])
signal object_deselected()
signal object_deleted(nodes: Array[Node2D])
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const ZOOM_STEP: float = 1.10
const MIN_BOX_AREA: float = 16.0
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const SELECTOR_DIM_ALPHA := 0.5
const SETTINGS_PATH := "user://sandbox_settings.json"
const THEME_PATH := "res://sandbox_theme.json"
const DEFAULT_GRID_SIZE := 15.0
const MIN_GRID_SIZE := 1.0
const MAX_GRID_SIZE := 100.0
## Director action-popup item ids (Phase 3a).
const ACT_WALK := 0
const ACT_SPEAK := 1
const ACT_WAIT := 2
const ACT_RAGDOLL := 3
const ACT_RECOVER := 4
## Phase 4 rule-builder item ids. ACT_WHEN appends to the action popup; the
## TRIG_* ids drive the trigger sub-menu.
const ACT_WHEN := 5
const TRIG_ARRIVED := 0
const TRIG_ACTION_FINISHED := 1
const TRIG_SPEECH_FINISHED := 2
const TRIG_ENTERED_AREA := 3
const TRIG_COLLIDED := 4
const TRIG_BACK := 5
## Phase 4 "add another / done" popup item ids.
const RULE_MORE_ADD := 0
const RULE_MORE_DONE := 1
## Phase 4 "done" item id for the rule-action popup (edit mode only). Distinct
## from ACT_WALK..ACT_RECOVER (0..4) so it never collides with an action id.
const RULE_ACTION_DONE := 6
## Max distance (px) between an arrival event position and a rule's waypoint for
## the arrived_at_waypoint trigger to match.
const WAYPOINT_MATCH_EPSILON := 24.0
## Debug gate for the Phase 3a stage trace. Ship OFF.
const DEBUG_STAGE := false
## Prints a `[stage] `-prefixed message only when DEBUG_STAGE is on.
func _stage_dbg(msg: String) -> void:
if DEBUG_STAGE:
print("[stage] ", msg)
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
@export var min_zoom: float = 0.1
@export var max_zoom: float = 6.0
# ---------------------------------------------------------------------------
# Node references
# ---------------------------------------------------------------------------
@onready var _camera: Camera2D = $Camera2D
@onready var _world: Node2D = $World
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var current_mode: StageMode = StageMode.EDIT
var _spawner: StageSpawner
var _selection: StageSelection
var _gizmos: StageGizmos
var _grid: StageGrid
var _placement_overlay = null # StagePlacementOverlay (preloaded)
var _placement_id: String = ""
var _stickman_library: StickmanLibrary
var _thumbnail_cache: ThumbnailCache
var _stickman_thumb: StickmanThumbnail
var _prop_thumb: PropThumbnail
var _selector: AssetSelector = null
var _selector_open: bool = false
var _selector_kind: String = ""
var _selector_dim: ColorRect = null
var _browse_dialog: FileDialog = null
var _thumbnail_queue: Array[Dictionary] = []
var _thumbnail_busy: bool = false
var _mode_buttons: Dictionary = {} # int (StageMode) -> Button
var _palette_buttons: Dictionary = {}
var _status_label: Label
var _status_cursor_coords: Label
var _direct_hint_label: Label
var _mode_frame: Panel
var _mode_badge: PanelContainer
var _mode_badge_label: Label
var _tooltip: Label
var _grid_check: CheckBox
var _snap_check: CheckBox
var _grid_size_spin: SpinBox
var _grid_size_label: Label
var _grid_size: float = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false
var _show_grid: bool = true
var _panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
var _box_selecting: bool = false
var _box_start: Vector2 = Vector2.ZERO
var _box_rect: Rect2 = Rect2()
var _ghost: Node2D = null
var _ghost_holder: Node2D = null
## Phase 4b terrain drag-painting state.
var _terrain_dragging: bool = false
var _terrain_anchor_cell: Vector2i = Vector2i.ZERO
var _terrain_target_cell: Vector2i = Vector2i.ZERO
var _drag_cells: Array[Vector2i] = []
var _last_drag_cells: Array[Vector2i] = []
var _ghost_array: Array[Node2D] = []
## Block cells already stamped during the CURRENT drag (in-drag self-overlap
## skip, 3-state Case B).
var _drag_painted: Dictionary = {}
## Grid spatial dictionary: cell (Vector2i, TERRAIN_GRID_SIZE) -> Array[Node2D]
## whose world AABB overlaps that cell. Advisory only (3-state tint + skip);
## never authoritative for physics.
var _grid_cells: Dictionary = {}
## Authored object state (instance id -> {node, position, rotation}), updated on
## every place/move/rotate and restored when returning to EDIT.
var _authored: Dictionary = {}
## Physics frames remaining before re-asserting the authored restore (a safety
## net for engines where a same-frame freeze + teleport does not stick).
var _restore_frames_left: int = 0
# ---------------------------------------------------------------------------
# Director state (Phase 3a)
# ---------------------------------------------------------------------------
var _action_popup: PopupMenu = null
var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _wait_dialog: AcceptDialog = null
var _wait_spin: SpinBox = null
var _director_visuals = null # StageDirectorVisuals (preloaded)
var _nav_region: NavigationRegion2D = null
var _nav_dirty: bool = true
# ---------------------------------------------------------------------------
# Theme / cursor state (Phase 4b)
# ---------------------------------------------------------------------------
## Parsed sandbox_theme.json (or {} if missing/malformed).
var _theme: Dictionary = {}
var _theme_grid_default: float = DEFAULT_GRID_SIZE
var _accent_edit: Color = Color("#22c6ff")
var _accent_direct: Color = Color("#ffb300")
var _accent_play: Color = Color("#33dd77")
var _guide_line_color: Color = Color("#22c6ff")
var _action_popup_font_size: int = 24
var _tooltip_font_size: int = 18
var _status_pill_font_size: int = 16
var _ui_font: Font = null
var _emoji_font: Font = null
var _action_cursor: ImageTexture = null
var _last_awaiting_click: bool = false
# ---------------------------------------------------------------------------
# Rule / event system state (Phase 4)
# ---------------------------------------------------------------------------
## Stored "When X -> do Y" rules. Persist across mode toggles; NOT saved to disk.
var _event_rules: Array[Dictionary] = []
var _next_rule_id: int = 0
## Rule-builder state machine.
var _rule_step: RuleStep = RuleStep.IDLE
var _rule_builder: Dictionary = {}
var _rule_context_rig: StickmanRig = null
var _rule_hint: String = ""
var _rule_editing_id: int = -1
## Session popup anchor: the first context menu of a rule session records its
## screen rect; all child popups in that session reuse it. Cleared on confirm
## or cancel so the next session re-records from a fresh click.
var _popup_anchor: Rect2i = Rect2i()
var _popup_anchor_set: bool = false
## Rule-builder popups (built in _build_ui).
var _trigger_popup: PopupMenu = null
var _rule_action_popup: PopupMenu = null
var _rule_more_popup: PopupMenu = null
## Edge-trigger bookkeeping for the geometric engine, keyed "<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
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_camera.make_current()
_load_theme()
_grid_size = _theme_grid_default
_load_settings()
_spawner = STAGE_SPAWNER.new(_world)
_stickman_library = STICKMAN_LIBRARY.new()
_thumbnail_cache = THUMBNAIL_CACHE.new()
_stickman_thumb = STICKMAN_THUMBNAIL.new()
_prop_thumb = PROP_THUMBNAIL.new()
add_child(_stickman_thumb)
add_child(_prop_thumb)
_selection = STAGE_SELECTION.new(_world, _camera)
_selection.selection_changed.connect(_on_selection_changed)
_selection.hover_changed.connect(_on_hover_changed)
_build_action_cursor()
_build_grid_layer()
_build_gizmo_layer()
_build_director_visuals()
_build_navigation()
_build_ghost_holder()
_build_placement_overlay()
_build_ui()
_apply_theme_to_visuals()
_rebuild_grid_cells()
_apply_grid_settings()
_apply_mode_frame()
_sync_mode_buttons()
_refresh_mode_badge()
_apply_cursor()
_refresh_status()
func _process(delta: float) -> void:
_drain_thumbnail_queue()
if _nav_dirty:
_nav_dirty = false
_rebake_navigation()
if current_mode == StageMode.EDIT:
if _terrain_dragging:
_update_terrain_drag(_camera.get_global_mouse_position())
elif _ghost != null and is_instance_valid(_ghost):
_update_ghost_position()
_update_cursor_coords()
_update_action_overlay()
_sync_awaiting_cursor()
if _toast_timer > 0.0:
_toast_timer -= delta
if _toast_timer <= 0.0:
_toast_text = ""
_refresh_status()
func _physics_process(_delta: float) -> void:
if _restore_frames_left > 0:
_restore_frames_left -= 1
_restore_authored_state()
if current_mode == StageMode.PLAY:
_update_area_entry()
_update_stickman_prop_collision()
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventMagnifyGesture:
# Touchpad pinch zoom.
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
elif event is InputEventPanGesture:
# Touchpad two-finger pan (delta is screen px; ×3 for speed parity).
_camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x
elif event is InputEventMouseButton:
_handle_mouse_button(event)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
_handle_world_click(event)
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.pressed or event.echo:
return
match event.keycode:
KEY_DELETE, KEY_BACKSPACE:
if current_mode == StageMode.EDIT:
delete_selected()
KEY_ESCAPE:
if _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
elif _terrain_dragging:
_cancel_terrain_drag()
elif _selector_open:
_on_selector_cancelled()
elif current_mode == StageMode.DIRECT:
set_mode(StageMode.EDIT)
elif _placement_id != "":
set_placement_mode("")
elif not _selection.get_selected().is_empty():
_selection.clear_selection()
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
match mb.button_index:
MOUSE_BUTTON_WHEEL_UP:
if mb.pressed:
_set_zoom(_camera.zoom.x * ZOOM_STEP)
MOUSE_BUTTON_WHEEL_DOWN:
if mb.pressed:
_set_zoom(_camera.zoom.x / ZOOM_STEP)
MOUSE_BUTTON_MIDDLE:
_panning = mb.pressed
_pan_last = mb.position
func _handle_world_click(mb: InputEventMouseButton) -> void:
if _selector_open:
return
if current_mode == StageMode.PLAY:
return
# RMB ends draw/placement mode in EDIT (Phase 4b build cancelling). LMB keeps
# the Phase 2 repeated-placement behavior.
if mb.button_index == MOUSE_BUTTON_RIGHT:
if mb.pressed and current_mode == StageMode.EDIT and _placement_id != "":
_cancel_terrain_drag()
set_placement_mode("")
return
if mb.button_index != MOUSE_BUTTON_LEFT:
return
var world_pos := _camera.get_global_mouse_position()
# Rule-builder click routing is highest priority (Phase 4).
if _rule_step != RuleStep.IDLE:
if mb.pressed:
_handle_rule_click(world_pos)
return
# Rule label / delete icon hit-testing, before gizmo/placement/selection.
var rule_hit: Dictionary = _director_visuals.hit_test_rule(world_pos)
if not rule_hit.is_empty() and mb.pressed:
if rule_hit["part"] == "delete":
_delete_rule(int(rule_hit["id"]))
else:
_begin_edit_rule(int(rule_hit["id"]))
return
if current_mode == StageMode.DIRECT:
if mb.pressed:
_handle_direct_click(world_pos)
return
# EDIT mode: a terrain drag commits on release.
if _terrain_dragging:
if not mb.pressed:
_commit_terrain_drag()
return
if mb.pressed:
if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE:
_gizmos.begin_drag(world_pos)
elif _placement_id != "":
if _spawner.is_terrain_id(_placement_id):
_begin_terrain_drag(world_pos)
else:
_place_at(world_pos)
else:
_begin_click_select(world_pos, mb.shift_pressed)
else:
if _gizmos.is_dragging():
_gizmos.end_drag()
elif _box_selecting:
_finish_box_select(mb.shift_pressed)
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
if _selector_open:
return
if _panning:
_camera.position -= mm.relative / _camera.zoom.x
return
if current_mode == StageMode.PLAY:
return
var world_pos := _camera.get_global_mouse_position()
if _gizmos.is_dragging():
_gizmos.drag_to(world_pos)
_director_visuals.mark_dirty()
elif _box_selecting:
_box_rect = Rect2(_box_start, world_pos - _box_start)
_gizmos.set_box_rect(_box_rect)
elif not _is_mouse_over_ui() and current_mode == StageMode.EDIT:
_selection.update_hover(world_pos)
# ---------------------------------------------------------------------------
# Mode management
# ---------------------------------------------------------------------------
func set_mode(mode: StageMode) -> void:
if mode == current_mode:
return
current_mode = mode
match mode:
StageMode.EDIT:
_enter_edit_mode()
StageMode.DIRECT:
_enter_direct_mode()
StageMode.PLAY:
_enter_play_mode()
_sync_mode_buttons()
_apply_grid_settings()
_apply_mode_frame()
_refresh_mode_badge()
_apply_cursor()
mode_changed.emit(int(mode))
_refresh_status()
func _enter_edit_shared() -> void:
# Snap stickmen straight back to their standing pose/position (no stand-up
# tween glide), stopping any director queue first.
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.stop_queue()
rig.snap_to_standing()
rig.clear_reactive_actions()
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
# any object that may not be in the authored map. freeze_mode is set BEFORE
# freeze so the body freezes directly as kinematic, never via the static
# layer (whose transform sync can drop a subsequent position/rotation set).
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
_restore_authored_state()
# The freeze/teleport can take a physics frame or two to settle in the engine,
# so re-assert the authored transform on the next few physics frames.
_restore_frames_left = 3
_gizmos.set_enabled(true)
_director_visuals.set_enabled(true)
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
_area_overlap.clear()
_collision_pairs.clear()
func _enter_edit_mode() -> void:
_enter_edit_shared()
_clear_director_pending()
_set_edit_controls_visible(true)
_set_direct_controls_visible(false)
## Cancels any in-progress director/rule-builder click flows when leaving a mode
## (a pending walk target or a half-built When->Then rule must not survive into
## another mode where its click routing would mis-fire).
func _clear_director_pending() -> void:
_clear_popup_anchor()
_pending_walk_target = false
_context_rig = null
if _rule_step != RuleStep.IDLE:
_cancel_rule_build()
if _action_popup != null:
_action_popup.hide()
## Direct is internally an "edit-with-direct" state: same freeze/stand/gizmo
## side effects, but with the spawner/grid controls hidden, the director hint
## shown, and any placement/selection/pending-target cleared.
func _enter_direct_mode() -> void:
_enter_edit_shared()
set_placement_mode("")
_selection.clear_selection()
_clear_director_pending()
_set_edit_controls_visible(false)
_set_direct_controls_visible(true)
func _enter_play_mode() -> void:
_gizmos.set_enabled(false)
_selection.clear_selection()
set_placement_mode("")
_clear_director_pending()
_director_visuals.set_enabled(false)
_set_edit_controls_visible(false)
_set_direct_controls_visible(false)
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
_area_overlap.clear()
_collision_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
(node as RigidBody2D).freeze = false
var rig_count := 0
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.auto_recover = false
rig.start_queue()
rig_count += 1
_stage_dbg("PLAY rigs=%d" % rig_count)
func _save_object_state(node: Node2D) -> void:
_authored[node.get_instance_id()] = {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
}
func _clear_object_state(node: Node2D) -> void:
_authored.erase(node.get_instance_id())
func _restore_authored_state() -> void:
for id: int in _authored.keys():
var entry: Dictionary = _authored[id]
var node := entry.get("node") as Node2D
if node == null or not is_instance_valid(node):
_authored.erase(id)
continue
node.global_position = entry.get("position", node.global_position)
node.global_rotation = entry.get("rotation", node.global_rotation)
if node is RigidBody2D:
var body := node as RigidBody2D
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
# ---------------------------------------------------------------------------
# Placement
# ---------------------------------------------------------------------------
func set_placement_mode(id: String) -> void:
_placement_id = id
for pid: String in _palette_buttons:
var btn: Button = _palette_buttons[pid]
btn.set_pressed_no_signal(pid == id)
if id == "":
_free_ghost()
_free_terrain_ghosts()
_end_terrain_drag()
else:
_spawn_ghost()
func _place_at(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
var node := _spawner.spawn(_placement_id, world_pos)
if node == null:
return
_finalize_placed_node(node)
if node is TerrainBlock:
_nav_dirty = true
_rebuild_grid_cells()
object_placed.emit(node)
_refresh_status()
_spawn_ghost()
## Common post-spawn wiring: freeze kinematic props in EDIT, save authored state,
## and connect the Phase 3a/4 signals for stickmen/props.
func _finalize_placed_node(node: Node2D) -> void:
if current_mode == StageMode.EDIT and node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
_save_object_state(node)
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.queue_changed.connect(_director_visuals.mark_dirty)
rig.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
prop.collided.connect(_on_prop_collided.bind(prop))
func _spawn_ghost() -> void:
_free_ghost()
if _placement_id == "":
return
var ghost := _spawner.spawn(_placement_id, Vector2.ZERO)
if ghost == null:
return
# Reparent out of World so the ghost is not selectable/counted.
if ghost.get_parent() == _world:
_world.remove_child(ghost)
_ghost_holder.add_child(ghost)
ghost.modulate = Color(1.0, 1.0, 1.0, 0.5)
_configure_ghost_collision(ghost)
_ghost = ghost
_update_ghost_position()
## Disables collision / freezes a ghost so it never participates in physics or
## selection, and stops a ghost stickman's IK + animation.
func _configure_ghost_collision(ghost: Node2D) -> void:
if ghost is RigidBody2D:
var body := ghost as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.collision_layer = 0
body.collision_mask = 0
elif ghost is StaticBody2D:
var sb := ghost as StaticBody2D
sb.collision_layer = 0
sb.collision_mask = 0
elif ghost is STICKMAN_RIG:
var rig := ghost as STICKMAN_RIG
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
if skeleton != null and skeleton.modification_stack != null:
skeleton.modification_stack.enabled = false
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
if anim != null:
anim.stop()
func _free_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.queue_free()
_ghost = null
func _update_ghost_position() -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
if _is_terrain_placement():
pos = _terrain_cell_center(_world_to_terrain_cell(pos))
elif _snap_enabled:
pos = _snap_to_grid(pos)
_ghost.position = pos
# ---------------------------------------------------------------------------
# Terrain drag-painting (Phase 4b)
# ---------------------------------------------------------------------------
func _is_terrain_placement() -> bool:
return _spawner.is_terrain_id(_placement_id)
## Paint stride = the active terrain template's AABB extent per axis (decision
## D1). Falls back to TERRAIN_GRID_SIZE for a non-terrain / empty placement so
## the quantizers never divide by zero.
func _terrain_stride() -> Vector2:
var size: Vector2 = _spawner.get_template_aabb(_placement_id).size
if size.x <= 0.0001 or size.y <= 0.0001:
return Vector2(STAGE_SPAWNER.TERRAIN_GRID_SIZE, STAGE_SPAWNER.TERRAIN_GRID_SIZE)
return size
func _world_to_terrain_cell(pos: Vector2) -> Vector2i:
var s := _terrain_stride()
return Vector2i(int(roundf(pos.x / s.x)), int(roundf(pos.y / s.y)))
func _terrain_cell_center(cell: Vector2i) -> Vector2:
var s := _terrain_stride()
return Vector2(cell.x * s.x, cell.y * s.y)
## 16-px grid cell for the advisory _grid_cells dictionary lookups (NOT the
## block-unit paint cell). Used by _is_target_valid.
func _world_to_grid_cell(pos: Vector2) -> Vector2i:
var gs := STAGE_SPAWNER.TERRAIN_GRID_SIZE
return Vector2i(int(roundf(pos.x / gs)), int(roundf(pos.y / gs)))
## World AABB of the block that would be stamped at `cell` (block center +
## the template's local AABB).
func _block_aabb(cell: Vector2i) -> Rect2:
var local: Rect2 = _spawner.get_template_aabb(_placement_id)
return Rect2(_terrain_cell_center(cell) + local.position, local.size)
func _begin_terrain_drag(world_pos: Vector2) -> void:
_free_ghost()
_drag_painted.clear()
_terrain_dragging = true
_terrain_anchor_cell = _world_to_terrain_cell(world_pos)
_terrain_target_cell = _terrain_anchor_cell
_update_terrain_drag(world_pos)
func _update_terrain_drag(world_pos: Vector2) -> void:
if not _terrain_dragging:
return
var target := _world_to_terrain_cell(world_pos)
if Input.is_key_pressed(KEY_SHIFT):
var d := target - _terrain_anchor_cell
if absi(d.x) >= absi(d.y):
target = Vector2i(_terrain_anchor_cell.x + d.x, _terrain_anchor_cell.y)
else:
target = Vector2i(_terrain_anchor_cell.x, _terrain_anchor_cell.y + d.y)
_terrain_target_cell = target
_drag_cells = _bresenham_cells(_terrain_anchor_cell, target)
_refresh_terrain_ghosts()
if _placement_overlay != null:
if target != _terrain_anchor_cell:
_placement_overlay.set_terrain_guide(_terrain_cell_center(_terrain_anchor_cell), _terrain_cell_center(target))
else:
_placement_overlay.clear_terrain_guide()
func _bresenham_cells(a: Vector2i, b: Vector2i) -> Array[Vector2i]:
var cells: Array[Vector2i] = []
var x0: int = a.x
var y0: int = a.y
var x1: int = b.x
var y1: int = b.y
var dx: int = absi(x1 - x0)
var dy: int = -absi(y1 - y0)
var sx: int = 1 if x0 < x1 else -1
var sy: int = 1 if y0 < y1 else -1
var err: int = dx + dy
while true:
cells.append(Vector2i(x0, y0))
if x0 == x1 and y0 == y1:
break
var e2: int = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
return cells
## Three-state classification per block cell: 1 empty, 2 same-type skip, 3
## conflicting. Uses the 16-px advisory dictionary as a broadphase and then does
## a precise interior-overlap test so blocks that tile edge-to-edge (Case A
## seamless extension) still classify empty while genuinely overlapping blocks
## skip. Cells stamped earlier in the current drag (in-drag self-overlap) read
## as same-type skip.
func _classify_cell(cell: Vector2i) -> int:
if _drag_painted.has(cell):
return 2
var aabb := _block_aabb(cell)
var seen: Dictionary = {}
for c: Vector2i in _rasterize_aabb_to_cells(aabb):
for n in _grid_cells.get(c, []):
if n is Node2D:
seen[(n as Node2D).get_instance_id()] = n
var has_same := false
for n in seen.values():
var node := n as Node2D
if node == null:
continue
if not _aabb_overlaps(aabb, STAGE_SELECTION.get_world_aabb(node)):
continue
if node is TerrainBlock and (node as TerrainBlock).spawn_id == _placement_id:
has_same = true
continue
return 3
return 2 if has_same else 1
## Strict interior overlap (excludes edge/corner contact) so blocks that tile
## edge-to-edge do not register as overlapping.
func _aabb_overlaps(a: Rect2, b: Rect2) -> bool:
return a.position.x < b.end.x and b.position.x < a.end.x \
and a.position.y < b.end.y and b.position.y < a.end.y
func _refresh_terrain_ghosts() -> void:
if _drag_cells == _last_drag_cells:
return
_last_drag_cells = _drag_cells.duplicate()
_free_terrain_ghosts()
if _placement_id == "" or not _is_terrain_placement():
return
for cell: Vector2i in _drag_cells:
var state := _classify_cell(cell)
var ghost := _spawner.spawn(_placement_id, _terrain_cell_center(cell))
if ghost == null:
continue
if ghost.get_parent() == _world:
_world.remove_child(ghost)
_ghost_holder.add_child(ghost)
_configure_ghost_collision(ghost)
match state:
1:
ghost.modulate = Color(0.3, 1.0, 0.4, 0.5)
2:
ghost.modulate = Color(1.0, 1.0, 1.0, 0.15)
3:
ghost.modulate = Color(1.0, 0.3, 0.3, 0.5)
_ghost_array.append(ghost)
func _free_terrain_ghosts() -> void:
for g: Node2D in _ghost_array:
if g != null and is_instance_valid(g):
g.queue_free()
_ghost_array.clear()
func _commit_terrain_drag() -> void:
var placed: Array[Node2D] = []
for cell: Vector2i in _drag_cells:
if _classify_cell(cell) != 1:
continue
var node := _spawner.spawn(_placement_id, _terrain_cell_center(cell))
if node == null:
continue
_finalize_placed_node(node)
object_placed.emit(node)
placed.append(node)
_drag_painted[cell] = true
if not placed.is_empty():
_nav_dirty = true
_rebuild_grid_cells()
_end_terrain_drag()
_refresh_status()
func _cancel_terrain_drag() -> void:
_end_terrain_drag()
func _end_terrain_drag() -> void:
_terrain_dragging = false
_drag_cells.clear()
_last_drag_cells.clear()
_drag_painted.clear()
_free_terrain_ghosts()
if _placement_overlay != null:
_placement_overlay.clear_terrain_guide()
# Re-arm the single cursor-following ghost (LMB repeated placement) unless
# the tool was just put away (placement_id cleared).
if _placement_id != "":
_spawn_ghost()
# ---------------------------------------------------------------------------
# Grid spatial dictionary (Phase 4b) — advisory only, never authoritative.
# ---------------------------------------------------------------------------
func _rasterize_aabb_to_cells(aabb: Rect2) -> Array[Vector2i]:
var cells: Array[Vector2i] = []
if aabb.size.x <= 0.0 or aabb.size.y <= 0.0:
return cells
var gs := STAGE_SPAWNER.TERRAIN_GRID_SIZE
var min_x := int(floorf(aabb.position.x / gs))
var max_x := int(floorf((aabb.end.x - 0.0001) / gs))
var min_y := int(floorf(aabb.position.y / gs))
var max_y := int(floorf((aabb.end.y - 0.0001) / gs))
for y: int in range(min_y, max_y + 1):
for x: int in range(min_x, max_x + 1):
cells.append(Vector2i(x, y))
return cells
func _rebuild_grid_cells() -> void:
_grid_cells.clear()
for node: Node2D in _world_children_selectable():
_add_node_to_grid_cells(node)
func _add_node_to_grid_cells(node: Node2D) -> void:
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
return
var aabb := STAGE_SELECTION.get_world_aabb(node)
for cell: Vector2i in _rasterize_aabb_to_cells(aabb):
var list: Array = _grid_cells.get(cell, [])
if not list.has(node):
list.append(node)
_grid_cells[cell] = list
# ---------------------------------------------------------------------------
# Selection
# ---------------------------------------------------------------------------
func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
var hit := _selection.hit_test(world_pos)
if hit != null:
if shift:
_selection.toggle_selection(hit)
if _selection.is_selected(hit):
_gizmos.begin_translate_drag(hit, world_pos)
elif _selection.is_selected(hit):
# Already selected: keep the whole selection and drag it together.
_gizmos.begin_translate_drag(hit, world_pos)
else:
_selection.select_only(hit)
_gizmos.begin_translate_drag(hit, world_pos)
else:
_box_selecting = true
_box_start = world_pos
_box_rect = Rect2(world_pos, Vector2.ZERO)
_gizmos.set_box_rect(_box_rect)
func _finish_box_select(shift: bool) -> void:
_box_selecting = false
_gizmos.set_box_rect(Rect2())
var rect := _box_rect.abs()
if rect.get_area() < MIN_BOX_AREA:
if not shift:
_selection.clear_selection()
else:
_selection.box_select(rect, shift)
# ---------------------------------------------------------------------------
# Deletion
# ---------------------------------------------------------------------------
func delete_selected() -> void:
var selected := _selection.get_selected().duplicate()
if selected.is_empty():
return
var nav_changed := false
for node: Node2D in selected:
if is_instance_valid(node):
if node is TerrainBlock:
nav_changed = true
_clear_object_state(node)
node.queue_free()
_rebuild_grid_cells()
_cleanup_rules_for_nodes(selected)
_selection.clear_selection()
_selection.clear_hover()
if nav_changed:
_nav_dirty = true
_director_visuals.mark_dirty()
object_deleted.emit(selected)
_refresh_status()
# ---------------------------------------------------------------------------
# Status / UI
# ---------------------------------------------------------------------------
func _refresh_status() -> void:
if _status_label == null:
return
var count := _world_children_selectable().size()
var sel := _selection.get_selected()
var sel_text: String
if sel.is_empty():
sel_text = "none"
elif sel.size() == 1:
sel_text = String(sel[0].name)
else:
sel_text = "%d objects" % sel.size()
var text := "Objects: %d | Selected: %s" % [count, sel_text]
if _pending_walk_target:
text += " | Click stage for walk target (Esc to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
text += " | " + _rule_hint
if not _toast_text.is_empty():
text = _toast_text + " | " + text
_status_label.text = text
func _accent_for_mode(mode: StageMode) -> Color:
match mode:
StageMode.DIRECT:
return _accent_direct
StageMode.PLAY:
return _accent_play
_:
return _accent_edit
func _sync_mode_buttons() -> void:
var accent := _accent_for_mode(current_mode)
for id: int in _mode_buttons:
var btn: Button = _mode_buttons[id]
var active := int(current_mode) == id
btn.set_pressed_no_signal(active)
if active:
btn.add_theme_color_override("font_color", accent)
btn.add_theme_color_override("font_pressed_color", accent)
btn.add_theme_color_override("font_hover_color", accent)
else:
btn.remove_theme_color_override("font_color")
btn.remove_theme_color_override("font_pressed_color")
btn.remove_theme_color_override("font_hover_color")
func _apply_mode_frame() -> void:
if _mode_frame == null:
return
_mode_frame.visible = current_mode == StageMode.DIRECT
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
sb.border_color = _accent_for_mode(current_mode)
func _refresh_mode_badge() -> void:
if _mode_badge == null or _mode_badge_label == null:
return
var accent := _accent_for_mode(current_mode)
var text := "✏️ EDIT"
match current_mode:
StageMode.EDIT:
text = "✏️ EDIT"
StageMode.DIRECT:
text = "🎬 DIRECTING"
StageMode.PLAY:
text = "▶️ SIMULATING"
_mode_badge_label.text = text
var sb: StyleBoxFlat = _mode_badge.get_theme_stylebox("panel")
sb.bg_color = accent
var text_color := Color.BLACK if accent.get_luminance() > 0.5 else Color.WHITE
_mode_badge_label.add_theme_color_override("font_color", text_color)
_mode_badge.size = _mode_badge.get_combined_minimum_size()
func _apply_cursor() -> void:
_last_awaiting_click = _is_awaiting_click()
if _last_awaiting_click:
_apply_action_cursor()
return
Input.set_custom_mouse_cursor(null)
match current_mode:
StageMode.PLAY:
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
_:
Input.set_default_cursor_shape(Input.CURSOR_CROSS)
func _apply_action_cursor() -> void:
if _action_cursor == null:
return
var hotspot := Vector2(_action_cursor.get_width() * 0.5, _action_cursor.get_height() * 0.5)
Input.set_custom_mouse_cursor(_action_cursor, Input.CURSOR_ARROW, hotspot)
func _build_action_cursor() -> void:
var size := 24
var img := Image.create(size, size, false, Image.FORMAT_RGBA8)
img.fill(Color(0.0, 0.0, 0.0, 0.0))
var color := Color(1.0, 0.7, 0.1, 1.0)
var center := Vector2(size, size) * 0.5
var radius := float(size) * 0.4
for y: int in size:
for x: int in size:
var p := Vector2(x, y) + Vector2(0.5, 0.5)
var d := p.distance_to(center)
if absf(d - radius) <= 1.5:
img.set_pixel(x, y, color)
if absf(p.x - center.x) <= 1.0 and p.y >= center.y - radius and p.y <= center.y + radius:
img.set_pixel(x, y, color)
if absf(p.y - center.y) <= 1.0 and p.x >= center.x - radius and p.x <= center.x + radius:
img.set_pixel(x, y, color)
_action_cursor = ImageTexture.create_from_image(img)
func _apply_popup_theme(popup: PopupMenu) -> void:
popup.add_theme_font_size_override("font_size", _action_popup_font_size)
if _emoji_font != null:
popup.add_theme_font_override("font", _emoji_font)
func _apply_ui_font(control: Control) -> void:
if control != null and _ui_font != null:
control.add_theme_font_override("font", _ui_font)
func _update_cursor_coords() -> void:
if _status_cursor_coords == null:
return
if _camera == null or not is_instance_valid(_camera):
return
var world_pos := _camera.get_global_mouse_position()
_status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]
func _sync_awaiting_cursor() -> void:
if _is_awaiting_click() != _last_awaiting_click:
_apply_cursor()
func _build_ui() -> void:
var ui := CanvasLayer.new()
ui.name = "UI"
add_child(ui)
# Mode frame (over the viewport, behind the top bar; amber border in DIRECT).
_mode_frame = Panel.new()
_mode_frame.name = "ModeFrame"
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var frame_style := StyleBoxFlat.new()
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
frame_style.border_color = _accent_direct
frame_style.set_border_width_all(3)
_mode_frame.add_theme_stylebox_override("panel", frame_style)
ui.add_child(_mode_frame)
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = 40.0
ui.add_child(top_bar)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox)
# 3-segment mode switcher.
var mode_defs: Array = [
[StageMode.EDIT, "✏️ Edit"],
[StageMode.DIRECT, "🎬 Direct"],
[StageMode.PLAY, "▶️ Play"],
]
for def: Array in mode_defs:
var mid: StageMode = def[0]
var btn := Button.new()
btn.text = String(def[1])
btn.toggle_mode = true
btn.toggled.connect(_on_mode_segment_toggled.bind(mid))
hbox.add_child(btn)
_mode_buttons[int(mid)] = btn
for id: String in _spawner.get_spawnable_ids():
var btn := Button.new()
btn.text = _spawner.get_label(id)
btn.toggle_mode = true
btn.toggled.connect(_on_palette_toggled.bind(id))
hbox.add_child(btn)
_palette_buttons[id] = btn
_direct_hint_label = Label.new()
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
_direct_hint_label.visible = false
_apply_ui_font(_direct_hint_label)
hbox.add_child(_direct_hint_label)
_grid_check = CheckBox.new()
_grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid
_grid_check.toggled.connect(_on_grid_toggled)
hbox.add_child(_grid_check)
_snap_check = CheckBox.new()
_snap_check.text = "Snap"
_snap_check.button_pressed = _snap_enabled
_snap_check.toggled.connect(_on_snap_toggled)
hbox.add_child(_snap_check)
_grid_size_label = Label.new()
_grid_size_label.text = "Size"
hbox.add_child(_grid_size_label)
_grid_size_spin = SpinBox.new()
_grid_size_spin.min_value = MIN_GRID_SIZE
_grid_size_spin.max_value = MAX_GRID_SIZE
_grid_size_spin.step = 1.0
_grid_size_spin.value = _grid_size
_grid_size_spin.rounded = true
_grid_size_spin.value_changed.connect(_on_grid_size_changed)
hbox.add_child(_grid_size_spin)
# Bottom status bar (left = status, right = cursor world coords).
var bottom_bar := PanelContainer.new()
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
bottom_bar.offset_top = -28.0
ui.add_child(bottom_bar)
var status_hbox := HBoxContainer.new()
status_hbox.add_theme_constant_override("separation", 12)
bottom_bar.add_child(status_hbox)
_status_label = Label.new()
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_ui_font(_status_label)
status_hbox.add_child(_status_label)
_status_cursor_coords = Label.new()
_apply_ui_font(_status_cursor_coords)
status_hbox.add_child(_status_cursor_coords)
# Mode badge pill (top-left, below the top bar).
_mode_badge = PanelContainer.new()
_mode_badge.name = "ModeBadge"
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_mode_badge.offset_top = 48.0
_mode_badge.offset_left = 8.0
var badge_style := StyleBoxFlat.new()
badge_style.bg_color = _accent_edit
badge_style.set_corner_radius_all(12)
badge_style.content_margin_left = 12.0
badge_style.content_margin_right = 12.0
badge_style.content_margin_top = 4.0
badge_style.content_margin_bottom = 4.0
_mode_badge.add_theme_stylebox_override("panel", badge_style)
_mode_badge_label = Label.new()
_mode_badge_label.add_theme_font_size_override("font_size", _status_pill_font_size)
_apply_ui_font(_mode_badge_label)
_mode_badge.add_child(_mode_badge_label)
ui.add_child(_mode_badge)
# Action tooltip (cursor-attached, hidden unless a click-awaiting step is active).
_tooltip = Label.new()
_tooltip.name = "ActionTooltip"
_tooltip.visible = false
_tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
var tooltip_style := StyleBoxFlat.new()
tooltip_style.bg_color = Color(0.1, 0.1, 0.1, 0.9)
tooltip_style.border_color = _accent_direct
tooltip_style.set_border_width_all(1)
tooltip_style.set_corner_radius_all(8)
tooltip_style.content_margin_left = 10.0
tooltip_style.content_margin_right = 10.0
tooltip_style.content_margin_top = 4.0
tooltip_style.content_margin_bottom = 4.0
_tooltip.add_theme_stylebox_override("normal", tooltip_style)
_tooltip.add_theme_font_size_override("font_size", _tooltip_font_size)
_tooltip.add_theme_color_override("font_color", Color.WHITE)
_apply_ui_font(_tooltip)
ui.add_child(_tooltip)
_action_popup = PopupMenu.new()
_action_popup.add_item("🚶 Walk To", ACT_WALK)
_action_popup.add_item("💬 Speak", ACT_SPEAK)
_action_popup.add_item("⏳ Wait", ACT_WAIT)
_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_action_popup.add_separator()
_action_popup.add_item("⚡ When...", ACT_WHEN)
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
ui.add_child(_action_popup)
_trigger_popup = PopupMenu.new()
_trigger_popup.add_item("📍 Arrives at a waypoint", TRIG_ARRIVED)
_trigger_popup.add_item("✅ Completes any action", TRIG_ACTION_FINISHED)
_trigger_popup.add_item("💬 Finishes speaking", TRIG_SPEECH_FINISHED)
_trigger_popup.add_item("🎯 Enters trigger area", TRIG_ENTERED_AREA)
_trigger_popup.add_item("💥 Collides with something", TRIG_COLLIDED)
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", TRIG_BACK)
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
ui.add_child(_trigger_popup)
_rule_action_popup = PopupMenu.new()
_rule_action_popup.add_item("🚶 Walk To", ACT_WALK)
_rule_action_popup.add_item("💬 Speak", ACT_SPEAK)
_rule_action_popup.add_item("⏳ Wait", ACT_WAIT)
_rule_action_popup.add_item("💥 Ragdoll", ACT_RAGDOLL)
_rule_action_popup.add_item("🔄 Recover", ACT_RECOVER)
_rule_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
ui.add_child(_rule_action_popup)
_rule_more_popup = PopupMenu.new()
_rule_more_popup.add_item(" Add another action", RULE_MORE_ADD)
_rule_more_popup.add_item("✅ Done", RULE_MORE_DONE)
_rule_more_popup.id_pressed.connect(_on_rule_more_id_pressed)
ui.add_child(_rule_more_popup)
_speak_dialog = AcceptDialog.new()
_speak_dialog.title = "Speak"
_speak_dialog.confirmed.connect(_on_speak_confirmed)
_speak_edit = LineEdit.new()
_speak_edit.placeholder_text = "Say something…"
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
_speak_dialog.add_child(_speak_edit)
_speak_dialog.register_text_enter(_speak_edit)
ui.add_child(_speak_dialog)
_wait_dialog = AcceptDialog.new()
_wait_dialog.title = "Wait"
_wait_dialog.confirmed.connect(_on_wait_confirmed)
_wait_spin = SpinBox.new()
_wait_spin.min_value = 0.1
_wait_spin.max_value = 10.0
_wait_spin.step = 0.1
_wait_spin.value = 1.0
_wait_dialog.add_child(_wait_spin)
ui.add_child(_wait_dialog)
_apply_popup_theme(_action_popup)
_apply_popup_theme(_trigger_popup)
_apply_popup_theme(_rule_action_popup)
_apply_popup_theme(_rule_more_popup)
_selector_dim = ColorRect.new()
_selector_dim.name = "SelectorDim"
_selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
_selector_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_selector_dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_selector_dim.visible = false
ui.add_child(_selector_dim)
_selector = ASSET_SELECTOR.instantiate() as AssetSelector
_selector.item_selected.connect(_on_asset_selected)
_selector.cancelled.connect(_on_selector_cancelled)
_selector.popup_hide.connect(_on_selector_cancelled)
_selector.browse_requested.connect(_on_browse_requested)
_selector.refresh_requested.connect(_on_refresh_requested)
ui.add_child(_selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
_selector.apply_font(_ui_font, _emoji_font)
_browse_dialog = FileDialog.new()
_browse_dialog.title = "Open .stk"
_browse_dialog.access = FileDialog.ACCESS_FILESYSTEM
_browse_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_browse_dialog.file_selected.connect(_on_browse_file_selected)
ui.add_child(_browse_dialog)
func _build_gizmo_layer() -> void:
_gizmos = STAGE_GIZMOS.new()
_gizmos.name = "GizmoLayer"
_gizmos.camera = _camera
_gizmos.transform_committed.connect(_on_transform_committed)
add_child(_gizmos)
func _build_grid_layer() -> void:
_grid = STAGE_GRID.new()
_grid.name = "GridLayer"
_grid.camera = _camera
add_child(_grid)
move_child(_grid, 0)
func _build_ghost_holder() -> void:
_ghost_holder = Node2D.new()
_ghost_holder.name = "PlacementGhost"
add_child(_ghost_holder)
func _build_placement_overlay() -> void:
_placement_overlay = STAGE_PLACEMENT_OVERLAY.new()
_placement_overlay.name = "PlacementOverlay"
_placement_overlay.camera = _camera
add_child(_placement_overlay)
func _build_director_visuals() -> void:
_director_visuals = STAGE_DIRECTOR_VISUALS.new()
_director_visuals.name = "DirectorVisualsLayer"
_director_visuals.camera = _camera
_director_visuals.world = _world
add_child(_director_visuals)
## Code-built NavigationRegion2D child of the stage (NOT World, so it is never
## selected/hit-tested). Sits at the stage origin; World is at identity so world
## coordinates equal region-local coordinates.
func _build_navigation() -> void:
_nav_region = NavigationRegion2D.new()
_nav_region.name = "NavigationRegion2D"
add_child(_nav_region)
_rebake_navigation()
## Rebuild the navigation mesh from every TerrainBlock child of World: each
## block's world-space polygon is convex-decomposed and fan-triangulated into
## one manual NavigationPolygon.
func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region):
return
var verts := PackedVector2Array()
var triangles: Array[PackedInt32Array] = []
for child: Node in _world.get_children():
var block := child as TerrainBlock
if block == null:
continue
var pts := PackedVector2Array()
for p: Vector2 in block.polygon_points:
pts.append(block.transform * p)
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
if pieces.is_empty():
pieces = [pts]
for piece: PackedVector2Array in pieces:
var base: int = verts.size()
verts.append_array(piece)
for i: int in range(1, piece.size() - 1):
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
var poly := NavigationPolygon.new()
poly.set_vertices(verts)
for tri: PackedInt32Array in triangles:
poly.add_polygon(tri)
_nav_region.navigation_polygon = poly
if DEBUG_STAGE:
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
# ---------------------------------------------------------------------------
# Signal handlers
# ---------------------------------------------------------------------------
func _on_mode_segment_toggled(pressed: bool, mode: StageMode) -> void:
if pressed:
set_mode(mode)
else:
if current_mode == mode:
(_mode_buttons[int(mode)] as Button).set_pressed_no_signal(true)
func _on_palette_toggled(pressed: bool, id: String) -> void:
if pressed:
if id == "stickman" or id == "prop":
_open_selector(id)
return
set_placement_mode(id)
else:
if _selector_open and _selector_kind == id:
_close_selector()
elif _placement_id == id:
set_placement_mode("")
func _open_selector(kind: String) -> void:
for pid: String in _palette_buttons:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
_selector_kind = kind
var entries: Array[Dictionary]
if kind == "stickman":
entries = _stickman_library.scan()
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
else:
entries = PROP_LIBRARY.get_entries()
if kind == "stickman" and entries.size() == 1:
_on_asset_selected(entries[0])
return
_selector_open = true
if _selector_dim != null:
_selector_dim.visible = true
_selector.open(kind, entries)
_load_or_enqueue_thumbnails(kind, entries)
func _on_asset_selected(entry: Dictionary) -> void:
var kind := _selector_kind
if kind == "stickman":
_spawner.selected_stickman_path = String(entry.get("path", ""))
else:
_spawner.selected_prop_id = String(entry.get("id", ""))
_close_selector()
set_placement_mode(kind)
func _close_selector() -> void:
_selector_open = false
_selector_kind = ""
_thumbnail_queue.clear()
if _selector_dim != null:
_selector_dim.visible = false
if _selector != null:
_selector.hide()
func _on_selector_cancelled() -> void:
if not _selector_open:
return
_close_selector()
set_placement_mode("")
func _on_browse_requested() -> void:
if _browse_dialog != null:
_browse_dialog.popup_centered()
func _on_browse_file_selected(path: String) -> void:
var entry := _stickman_library.make_entry(path)
if entry.is_empty():
_show_toast("Could not load stickman: " + path)
return
_on_asset_selected(entry)
func _on_refresh_requested() -> void:
var entries: Array[Dictionary] = _stickman_library.scan()
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
_selector.set_entries(entries)
_load_or_enqueue_thumbnails("stickman", entries)
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
_thumbnail_queue.clear()
for entry: Dictionary in entries:
if kind == "stickman":
var path := String(entry.get("path", ""))
var key := _thumbnail_cache.stickman_key(path)
var png := _thumbnail_cache.stickman_png(key)
if FileAccess.file_exists(png):
var tex := _thumbnail_cache.load_png(png)
if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex)
else:
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key })
else:
var id := String(entry.get("id", ""))
var png := _thumbnail_cache.prop_png(id)
if FileAccess.file_exists(png):
var tex := _thumbnail_cache.load_png(png)
if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex)
else:
_thumbnail_queue.append({ "kind": kind, "entry": entry })
func _drain_thumbnail_queue() -> void:
if _thumbnail_busy or _thumbnail_queue.is_empty():
return
_thumbnail_busy = true
var job: Dictionary = _thumbnail_queue.pop_front()
var kind := String(job["kind"])
var entry: Dictionary = job["entry"]
var tex: Texture2D = null
if kind == "stickman":
var key := String(job["key"])
var data: Dictionary = entry["data"]
tex = await _stickman_thumb.render(data)
if tex != null:
_thumbnail_cache.save_png(tex, _thumbnail_cache.stickman_png(key))
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if tex != null:
_thumbnail_cache.save_png(tex, _thumbnail_cache.prop_png(String(entry["id"])))
_thumbnail_busy = false
if _selector != null and _selector_open and tex != null:
_selector.set_thumbnail(entry, tex)
func _on_grid_toggled(pressed: bool) -> void:
_show_grid = pressed
_apply_grid_settings()
_save_settings()
func _on_snap_toggled(pressed: bool) -> void:
_snap_enabled = pressed
_apply_grid_settings()
_save_settings()
func _on_grid_size_changed(value: float) -> void:
_grid_size = clampf(value, MIN_GRID_SIZE, MAX_GRID_SIZE)
_apply_grid_settings()
_save_settings()
func _on_selection_changed(nodes: Array[Node2D]) -> void:
_gizmos.set_targets(nodes)
if nodes.is_empty():
object_deselected.emit()
else:
object_selected.emit(nodes)
_refresh_status()
func _on_hover_changed(node: Node2D) -> void:
_gizmos.set_hover(node)
func _on_transform_committed(nodes: Array[Node2D]) -> void:
for node: Node2D in nodes:
_save_object_state(node)
if node is TerrainBlock:
_nav_dirty = true
_rebuild_grid_cells()
_director_visuals.mark_dirty()
# ---------------------------------------------------------------------------
# Director tool (Phase 3a)
# ---------------------------------------------------------------------------
func _handle_direct_click(world_pos: Vector2) -> void:
if _pending_walk_target and _context_rig != null and is_instance_valid(_context_rig):
_stage_dbg("walk_to captured target=(%.1f, %.1f)" % [world_pos.x, world_pos.y])
_context_rig.queue_action({ "type": "walk_to", "target": world_pos })
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
return
var hit := _selection.hit_test(world_pos)
if hit is STICKMAN_RIG:
_context_rig = hit as StickmanRig
var screen := _world_to_screen(hit.global_position)
var rect := Rect2i(Vector2i(screen) + Vector2i(24, 0), Vector2i.ZERO)
_set_popup_anchor(rect)
_action_popup.popup(rect)
func _on_action_popup_id_pressed(id: int) -> void:
if _context_rig == null or not is_instance_valid(_context_rig):
return
match id:
ACT_WALK:
_pending_walk_target = true
_apply_cursor()
_refresh_status()
ACT_SPEAK:
_speak_edit.text = ""
_speak_dialog.popup_centered()
_speak_edit.grab_focus()
ACT_WAIT:
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
ACT_RAGDOLL:
_context_rig.queue_action({ "type": "ragdoll" })
ACT_RECOVER:
_context_rig.queue_action({ "type": "recover" })
ACT_WHEN:
_rule_builder = {
"trigger": { "source": _context_rig.get_instance_id(), "target": -1, "params": {} },
"actions": [],
}
_rule_editing_id = -1
_rule_context_rig = _context_rig
_rule_step = RuleStep.SELECT_TRIGGER
_trigger_popup.popup(_popup_anchor_rect())
# ---------------------------------------------------------------------------
# Director UX (Phase 4b): cursor-attached tooltip, trajectory, ghost marker
# ---------------------------------------------------------------------------
func _is_awaiting_click() -> bool:
if _pending_walk_target:
return true
return _rule_step == RuleStep.TRIGGER_TARGET \
or _rule_step == RuleStep.ACTION_TARGET \
or _rule_step == RuleStep.ACTION_POSITION
func _action_hint_text() -> String:
if _pending_walk_target:
return "🚩 Click to set walk target"
match _rule_step:
RuleStep.TRIGGER_TARGET:
var trigger: Dictionary = _rule_builder.get("trigger", {})
match String(trigger.get("type", "")):
"arrived_at_waypoint":
return "📍 Click the waypoint"
"entered_area":
return "🎯 Click the trigger area"
"collided":
return "💥 Click the prop"
_:
return "🎯 Click the target"
RuleStep.ACTION_TARGET:
return "🎯 Click the stickman"
RuleStep.ACTION_POSITION:
return "🚩 Click where to walk"
return ""
func _action_origin() -> Vector2:
if _pending_walk_target:
if _context_rig != null and is_instance_valid(_context_rig):
return _context_rig.global_position - STICKMAN_RIG.FOOT_OFFSET
return Vector2.ZERO
if _rule_step != RuleStep.IDLE:
if _rule_context_rig != null and is_instance_valid(_rule_context_rig):
return _rule_context_rig.global_position - STICKMAN_RIG.FOOT_OFFSET
var trigger: Dictionary = _rule_builder.get("trigger", {})
var src := instance_from_id(int(trigger.get("source", -1)))
if src is Node2D and is_instance_valid(src):
return (src as Node2D).global_position
return Vector2.ZERO
func _is_target_valid(pos: Vector2) -> bool:
var cell := _world_to_grid_cell(pos)
for n in _grid_cells.get(cell, []):
if n is TerrainBlock and STAGE_SELECTION.get_world_aabb(n).has_point(pos):
return false
return true
func _update_action_overlay() -> void:
var awaiting := _is_awaiting_click()
if _placement_overlay != null:
if awaiting:
var origin := _action_origin()
var target := _camera.get_global_mouse_position()
if _snap_enabled and _rule_step == RuleStep.ACTION_POSITION:
target = _snap_to_grid(target)
_placement_overlay.set_action_trajectory(origin, target, _is_target_valid(target))
else:
_placement_overlay.clear_action()
if _tooltip != null:
_tooltip.visible = awaiting
if awaiting:
_tooltip.text = _action_hint_text()
var sb: StyleBox = _tooltip.get_theme_stylebox("normal")
var extra := sb.get_minimum_size() if sb != null else Vector2.ZERO
_tooltip.size = _tooltip.get_minimum_size() + extra
_update_tooltip_position()
func _update_tooltip_position() -> void:
if _tooltip == null:
return
var mouse := get_viewport().get_mouse_position()
var size := _tooltip.size
var pos := mouse + Vector2(20.0, -20.0)
var vp := get_viewport().get_visible_rect().size
if pos.x + size.x > vp.x:
pos.x = mouse.x - size.x - 20.0
if pos.y + size.y > vp.y:
pos.y = mouse.y - size.y - 20.0
if pos.y < 0.0:
pos.y = mouse.y + 20.0
_tooltip.position = pos
func _on_speak_confirmed() -> void:
if _rule_step == RuleStep.PARAMS:
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0 })
_open_rule_more_popup()
return
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "speak", "text": _speak_edit.text, "duration": 2.0 })
func _on_wait_confirmed() -> void:
if _rule_step == RuleStep.PARAMS:
_set_rule_action_params({ "duration": _wait_spin.value })
_open_rule_more_popup()
return
if _context_rig == null or not is_instance_valid(_context_rig):
return
_context_rig.queue_action({ "type": "wait", "duration": _wait_spin.value })
# ---------------------------------------------------------------------------
# Rule / event system (Phase 4)
# ---------------------------------------------------------------------------
## Popup rect at the current mouse position.
func _mouse_popup_rect() -> Rect2i:
var mouse := get_viewport().get_mouse_position()
return Rect2i(Vector2i(mouse), Vector2i.ZERO)
## Records the session popup anchor (the first context menu's screen rect).
func _set_popup_anchor(rect: Rect2i) -> void:
_popup_anchor = rect
_popup_anchor_set = true
## Clears the session popup anchor (on rule confirm or cancel).
func _clear_popup_anchor() -> void:
_popup_anchor = Rect2i()
_popup_anchor_set = false
## Returns the session anchor, recording it from the mouse on first use so a
## rule-builder flow that never passed through a first menu still has one.
func _popup_anchor_rect() -> Rect2i:
if not _popup_anchor_set:
_set_popup_anchor(_mouse_popup_rect())
return _popup_anchor
func _open_rule_action_popup() -> void:
_sync_rule_action_done_item()
_rule_action_popup.popup(_popup_anchor_rect())
## Shows the "✅ Done" item in the rule-action popup only while editing an
## existing rule (otherwise there is no way to save without adding an action).
func _sync_rule_action_done_item() -> void:
var idx: int = _rule_action_popup.get_item_index(RULE_ACTION_DONE)
var editing: bool = _rule_editing_id >= 0
if editing and idx < 0:
_rule_action_popup.add_item("✅ Done", RULE_ACTION_DONE)
elif not editing and idx >= 0:
_rule_action_popup.remove_item(idx)
func _open_rule_more_popup() -> void:
_rule_more_popup.popup(_popup_anchor_rect())
func _on_trigger_popup_id_pressed(id: int) -> void:
if id == TRIG_BACK:
_reset_rule_builder()
if _context_rig != null and is_instance_valid(_context_rig):
_action_popup.popup(_popup_anchor_rect())
_refresh_status()
return
var trigger: Dictionary = _rule_builder.get("trigger", {})
trigger["type"] = _trigger_type_string(id)
_rule_builder["trigger"] = trigger
if id == TRIG_ARRIVED or id == TRIG_ENTERED_AREA or id == TRIG_COLLIDED:
_rule_step = RuleStep.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
_rule_hint = "Click the waypoint to trigger on (Esc to cancel)"
TRIG_ENTERED_AREA:
_rule_hint = "Click the trigger area to watch (Esc to cancel)"
TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
_refresh_status()
else:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
func _on_rule_action_popup_id_pressed(id: int) -> void:
if id == RULE_ACTION_DONE:
_finalize_rule()
return
var action := { "type": _action_type_string(id), "target": -1, "params": {} }
var actions: Array = _rule_builder.get("actions", [])
actions.append(action)
_rule_builder["actions"] = actions
_rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will %s (Esc to cancel)" % _action_verb(id)
_refresh_status()
func _on_rule_more_id_pressed(id: int) -> void:
match id:
RULE_MORE_ADD:
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
RULE_MORE_DONE:
_finalize_rule()
func _trigger_type_string(id: int) -> String:
match id:
TRIG_ARRIVED:
return "arrived_at_waypoint"
TRIG_ACTION_FINISHED:
return "action_finished"
TRIG_SPEECH_FINISHED:
return "speech_finished"
TRIG_ENTERED_AREA:
return "entered_area"
TRIG_COLLIDED:
return "collided"
_:
return ""
func _action_type_string(id: int) -> String:
match id:
ACT_WALK:
return "walk_to"
ACT_SPEAK:
return "speak"
ACT_WAIT:
return "wait"
ACT_RAGDOLL:
return "ragdoll"
ACT_RECOVER:
return "recover"
_:
return ""
func _action_verb(id: int) -> String:
match id:
ACT_WALK:
return "walk"
ACT_SPEAK:
return "speak"
ACT_WAIT:
return "wait"
ACT_RAGDOLL:
return "ragdoll"
ACT_RECOVER:
return "recover"
_:
return "act"
func _handle_rule_click(world_pos: Vector2) -> void:
match _rule_step:
RuleStep.TRIGGER_TARGET:
_handle_trigger_target_click(world_pos)
RuleStep.ACTION_TARGET:
_handle_action_target_click(world_pos)
RuleStep.ACTION_POSITION:
_handle_action_position_click(world_pos)
func _handle_trigger_target_click(world_pos: Vector2) -> void:
var trigger: Dictionary = _rule_builder.get("trigger", {})
match String(trigger.get("type", "")):
"arrived_at_waypoint":
var wp: Vector2 = _director_visuals.hit_test_waypoint(world_pos)
if wp.is_finite():
trigger["params"] = { "waypoint_pos": wp }
trigger["target"] = -1
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a waypoint dot (Esc to cancel)"
_refresh_status()
"entered_area":
var hit := _selection.hit_test(world_pos)
if hit is TriggerArea:
trigger["target"] = hit.get_instance_id()
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click a trigger area (Esc to cancel)"
_refresh_status()
"collided":
var hit2 := _selection.hit_test(world_pos)
if hit2 is PropBlock:
trigger["target"] = hit2.get_instance_id()
_rule_builder["trigger"] = trigger
_rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup()
else:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)"
_refresh_status()
func _handle_action_target_click(world_pos: Vector2) -> void:
var hit := _selection.hit_test(world_pos)
if not (hit is STICKMAN_RIG):
_refresh_status()
return
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
action["target"] = (hit as StickmanRig).get_instance_id()
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
match String(action.get("type", "")):
"walk_to":
_rule_step = RuleStep.ACTION_POSITION
_rule_hint = "Click where %s should walk (Esc to cancel)" % String(hit.name)
_refresh_status()
"speak":
_rule_step = RuleStep.PARAMS
_speak_edit.text = ""
_speak_dialog.popup_centered()
_speak_edit.grab_focus()
"wait":
_rule_step = RuleStep.PARAMS
_wait_spin.value = 1.0
_wait_dialog.popup_centered()
_:
# ragdoll / recover need no params.
_open_rule_more_popup()
func _handle_action_position_click(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
var params: Dictionary = action.get("params", {})
params["target"] = world_pos
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
_open_rule_more_popup()
## Writes dialog-confirmed params onto the last action in the builder.
func _set_rule_action_params(params: Dictionary) -> void:
var actions: Array = _rule_builder.get("actions", [])
if actions.is_empty():
return
var action: Dictionary = actions[actions.size() - 1]
action["params"] = params
actions[actions.size() - 1] = action
_rule_builder["actions"] = actions
func _finalize_rule() -> void:
_clear_popup_anchor()
var rule: Dictionary = _rule_builder.duplicate(true)
if _rule_editing_id >= 0:
# Replace the existing rule in place (same index).
var idx := -1
for i: int in _event_rules.size():
if int(_event_rules[i].get("id", -1)) == _rule_editing_id:
idx = i
break
if idx >= 0:
rule["id"] = _rule_editing_id
_event_rules[idx] = rule
_show_toast("Rule updated!")
else:
rule["id"] = _next_rule_id
_next_rule_id += 1
_event_rules.append(rule)
_show_toast("Rule created!")
else:
rule["id"] = _next_rule_id
_next_rule_id += 1
_event_rules.append(rule)
_show_toast("Rule created!")
_director_visuals.set_rules(_event_rules)
_reset_rule_builder()
_refresh_status()
func _cancel_rule_build() -> void:
_clear_popup_anchor()
_reset_rule_builder()
if _trigger_popup != null:
_trigger_popup.hide()
if _rule_action_popup != null:
_rule_action_popup.hide()
if _rule_more_popup != null:
_rule_more_popup.hide()
_refresh_status()
func _reset_rule_builder() -> void:
_rule_step = RuleStep.IDLE
_rule_builder = {}
_rule_context_rig = null
_rule_hint = ""
_rule_editing_id = -1
_apply_cursor()
func _delete_rule(id: int) -> void:
var before := _event_rules.size()
_event_rules = _event_rules.filter(func(r): return int(r.get("id", -1)) != id)
if _event_rules.size() != before:
_director_visuals.set_rules(_event_rules)
_refresh_status()
## Re-enters the builder at SELECT_ACTION pre-populated from the stored rule
## (trigger edits are delete + rebuild, per the approved design).
func _begin_edit_rule(id: int) -> void:
for rule: Dictionary in _event_rules:
if int(rule.get("id", -1)) != id:
continue
var trigger: Dictionary = (rule.get("trigger", {}) as Dictionary).duplicate(true)
var actions: Array = []
for a: Dictionary in rule.get("actions", []):
actions.append(a.duplicate(true))
_rule_builder = { "trigger": trigger, "actions": actions }
_rule_editing_id = id
var source_id := int(trigger.get("source", -1))
if source_id >= 0:
var src := instance_from_id(source_id)
if src is StickmanRig:
_rule_context_rig = src
_rule_step = RuleStep.SELECT_ACTION
_set_popup_anchor(_mouse_popup_rect())
_open_rule_action_popup()
return
## True when any of trigger.source / trigger.target / an action.target is in ids.
func _rule_references_any(rule: Dictionary, ids: Array[int]) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
if ids.has(int(trigger.get("source", -1))):
return true
if ids.has(int(trigger.get("target", -1))):
return true
for a: Dictionary in rule.get("actions", []):
if ids.has(int(a.get("target", -1))):
return true
return false
func _cleanup_rules_for_nodes(nodes: Array[Node2D]) -> void:
var ids: Array[int] = []
for n: Node2D in nodes:
ids.append(n.get_instance_id())
_event_rules = _event_rules.filter(func(r): return not _rule_references_any(r, ids))
_director_visuals.set_rules(_event_rules)
# ---------------------------------------------------------------------------
# Event engine (Phase 4)
# ---------------------------------------------------------------------------
func _on_rig_arrived(target: Vector2, rig: StickmanRig) -> void:
_handle_event({ "type": "arrived_at_waypoint", "source": rig, "target": null, "position": target, "action": {} })
func _on_rig_action_finished(action: Dictionary, _index: int, rig: StickmanRig) -> void:
_handle_event({ "type": "action_finished", "source": rig, "target": null, "position": rig.global_position, "action": action })
func _on_rig_speech_finished(rig: StickmanRig) -> void:
_handle_event({ "type": "speech_finished", "source": rig, "target": null, "position": rig.global_position, "action": {} })
func _on_prop_collided(other: Node, prop: PropBlock) -> void:
_handle_event({ "type": "collided", "source": prop, "target": other, "position": prop.global_position, "action": {} })
## Iterates every rule in order; every matching rule executes (no short-circuit).
func _handle_event(event: Dictionary) -> void:
for rule: Dictionary in _event_rules:
if _rule_matches(rule, event):
_execute_rule_actions(rule)
func _rule_matches(rule: Dictionary, event: Dictionary) -> bool:
var trigger: Dictionary = rule.get("trigger", {})
var rule_type := String(trigger.get("type", ""))
if rule_type != String(event.get("type", "")):
return false
var source := event.get("source") as Node2D
var target := event.get("target") as Node2D
var source_id := int(trigger.get("source", -1))
var target_id := int(trigger.get("target", -1))
match rule_type:
"arrived_at_waypoint":
if not _ids_match(source_id, source):
return false
var params: Dictionary = trigger.get("params", {})
var waypoint: Vector2 = params.get("waypoint_pos", Vector2.INF)
return (event.get("position", Vector2.INF) as Vector2).distance_to(waypoint) <= WAYPOINT_MATCH_EPSILON
"action_finished":
if not _ids_match(source_id, source):
return false
var trig_params: Dictionary = trigger.get("params", {})
var want := String(trig_params.get("action_type", ""))
var action: Dictionary = event.get("action", {})
return want.is_empty() or want == String(action.get("type", ""))
"speech_finished":
return _ids_match(source_id, source)
"entered_area":
return _ids_match(target_id, target)
"collided":
return _ids_match(source_id, source) and _ids_match(target_id, target)
_:
return false
func _ids_match(a: int, b: Node2D) -> bool:
return a == -1 or (b != null and is_instance_valid(b) and b.get_instance_id() == a)
func _execute_rule_actions(rule: Dictionary) -> void:
for a: Dictionary in rule.get("actions", []):
var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig
if rig == null or not is_instance_valid(rig):
continue
rig.enqueue_reactive([_action_for_rig(a)])
## Converts a rule action into the queue-action shape the runner understands.
func _action_for_rig(a: Dictionary) -> Dictionary:
var params: Dictionary = a.get("params", {})
var out: Dictionary = { "type": String(a.get("type", "")) }
match String(a.get("type", "")):
"walk_to":
out["target"] = params.get("target", Vector2.ZERO)
"speak":
out["text"] = String(params.get("text", ""))
out["duration"] = float(params.get("duration", 2.0))
"wait":
out["duration"] = float(params.get("duration", 0.0))
return out
## Geometric overlap: TriggerArea children vs ANIMATED stickmen + unfrozen props.
## Edge-triggered on entry; emits an entered_area event per new overlap.
func _update_area_entry() -> void:
var areas: Array[TriggerArea] = []
for child: Node in _world.get_children():
if child is TriggerArea:
areas.append(child as TriggerArea)
if areas.is_empty():
return
var movables: Array[Node2D] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
if rig.state == StickmanRig.RigState.ANIMATED:
movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
movables.append(node)
for area: TriggerArea in areas:
var area_rect := (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables:
var point := _movable_point(node)
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var inside: bool = area_rect.has_point(point)
var was: bool = bool(_area_overlap.get(key, false))
if inside and not was:
_area_overlap[key] = true
_handle_event({ "type": "entered_area", "source": node, "target": area, "position": point, "action": {} })
elif not inside and was:
_area_overlap[key] = false
## Geometric stickman-vs-prop collision (ANIMATED rigs x unfrozen props),
## edge-triggered via the prop's world AABB containing the rig's feet/root point.
func _update_stickman_prop_collision() -> void:
var rigs: Array[StickmanRig] = []
var props: Array[PropBlock] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
if rig.state == StickmanRig.RigState.ANIMATED:
rigs.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
props.append(node as PropBlock)
for rig: StickmanRig in rigs:
for prop: PropBlock in props:
var key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var feet: Vector2 = rig.global_position - StickmanRig.FOOT_OFFSET
var overlap: bool = STAGE_SELECTION.get_world_aabb(prop).has_point(feet)
var was: bool = bool(_collision_pairs.get(key, false))
if overlap and not was:
_collision_pairs[key] = true
_handle_event({ "type": "collided", "source": rig, "target": prop, "position": rig.global_position, "action": {} })
elif not overlap and was:
_collision_pairs[key] = false
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
func _movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG:
return node.global_position - StickmanRig.FOOT_OFFSET
return node.global_position
func _show_toast(msg: String) -> void:
_toast_text = msg
_toast_timer = 2.0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
## Converts a world position to screen coordinates using the stage camera (the
## exact inverse of Camera2D.get_global_mouse_position()).
func _world_to_screen(world_pos: Vector2) -> Vector2:
var vp_size := get_viewport().get_visible_rect().size
return (world_pos - _camera.position) * _camera.zoom + vp_size * 0.5
func _set_zoom(value: float) -> void:
var z := clampf(value, min_zoom, max_zoom)
var old_zoom := _camera.zoom.x
if is_equal_approx(z, old_zoom):
return
var mouse_world := _camera.get_global_mouse_position()
_camera.zoom = Vector2(z, z)
_camera.position = mouse_world - (mouse_world - _camera.position) * (old_zoom / z)
func _is_mouse_over_ui() -> bool:
return get_viewport().gui_get_hovered_control() != null
func _snap_to_grid(v: Vector2) -> Vector2:
if _grid_size <= 0.0:
return v
return Vector2(roundf(v.x / _grid_size) * _grid_size, roundf(v.y / _grid_size) * _grid_size)
func _apply_grid_settings() -> void:
if _grid != null:
_grid.grid_size = _grid_size
_grid.enabled = _show_grid
_grid.visible = _show_grid and current_mode == StageMode.EDIT
if _gizmos != null:
_gizmos.snap_size = _grid_size if _snap_enabled else 0.0
func _set_edit_controls_visible(visible: bool) -> void:
for btn: Button in _palette_buttons.values():
btn.visible = visible
if _grid_check != null:
_grid_check.visible = visible
if _snap_check != null:
_snap_check.visible = visible
if _grid_size_spin != null:
_grid_size_spin.visible = visible
if _grid_size_label != null:
_grid_size_label.visible = visible
func _set_direct_controls_visible(visible: bool) -> void:
if _direct_hint_label != null:
_direct_hint_label.visible = visible
## Direct Node2D children of World, excluding the ragdoll body container.
func _world_children_selectable() -> Array[Node2D]:
var result: Array[Node2D] = []
for child: Node in _world.get_children():
var node := child as Node2D
if node == null:
continue
if node.name == RAGDOLL_CONTAINER_NAME:
continue
result.append(node)
return result
# ---------------------------------------------------------------------------
# Theme loading (Phase 4b)
# ---------------------------------------------------------------------------
## Loads the theme JSON. `source_path` is overridable for tests (missing /
## malformed fallback); defaults to the committed res:// theme.
func _load_theme(source_path: String = THEME_PATH) -> void:
_theme = {}
if not FileAccess.file_exists(source_path):
push_warning("SandboxStage: theme file '%s' not found; using defaults." % source_path)
return
var file := FileAccess.open(source_path, FileAccess.READ)
if file == null:
return
var json: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not json is Dictionary:
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
return
_theme = json as Dictionary
var fonts: Dictionary = _theme.get("fonts", {})
_action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
_tooltip_font_size = int(fonts.get("tooltip_font_size", 18))
_status_pill_font_size = int(fonts.get("status_pill_font_size", 16))
_ui_font = _load_font(String(fonts.get("ui_font", "")))
_emoji_font = _load_font(String(fonts.get("emoji_font", "")))
var grid: Dictionary = _theme.get("grid", {})
_theme_grid_default = clampf(float(grid.get("snap_size", DEFAULT_GRID_SIZE)), MIN_GRID_SIZE, MAX_GRID_SIZE)
var colors: Dictionary = _theme.get("mode_colors", {})
_accent_edit = _parse_color(String(colors.get("edit_accent", "")), _accent_edit)
_accent_direct = _parse_color(String(colors.get("direct_accent", "")), _accent_direct)
_accent_play = _parse_color(String(colors.get("play_accent", "")), _accent_play)
_guide_line_color = _parse_color(String(colors.get("guide_line", "")), _guide_line_color)
static func _parse_color(html: String, fallback: Color) -> Color:
var s := html.strip_edges()
if s.begins_with("#"):
s = s.substr(1)
if s.is_empty():
return fallback
return Color.from_string(s, fallback)
func _load_font(path: String) -> Font:
if path.is_empty():
return null
if not ResourceLoader.exists(path):
push_warning("SandboxStage: theme font '%s' not found; using fallback." % path)
return null
var res := ResourceLoader.load(path)
if res is Font:
return res
push_warning("SandboxStage: theme font '%s' is not a Font; using fallback." % path)
return null
func _apply_theme_to_visuals() -> void:
if _director_visuals != null:
_director_visuals.set_style(_theme)
_director_visuals.emoji_font = _emoji_font
if _placement_overlay != null:
_placement_overlay.guide_line_color = _guide_line_color
# ---------------------------------------------------------------------------
# Settings persistence
# ---------------------------------------------------------------------------
func _load_settings() -> void:
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
if file == null:
return
var json: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not json is Dictionary:
return
var d := json as Dictionary
_grid_size = float(d.get("grid_size", DEFAULT_GRID_SIZE))
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_snap_enabled = bool(d.get("snap_to_grid", false))
_show_grid = bool(d.get("show_grid", true))
func _save_settings() -> void:
var data := {
"version": "1.0",
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
"show_grid": _show_grid,
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()