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") # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- enum StageMode { EDIT, 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 SETTINGS_PATH := "user://sandbox_settings.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_id: String = "" var _mode_button: Button var _palette_buttons: Dictionary = {} var _status_label: 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 ## 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 _direct_mode: bool = false var _direct_button: Button = null 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 # --------------------------------------------------------------------------- # 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 ## Rule-builder popups (built in _build_ui). var _trigger_popup: PopupMenu = null var _rule_action_popup: PopupMenu = null var _rule_more_popup: PopupMenu = null ## Edge-trigger bookkeeping for the geometric engine, keyed ":" ## and ":". var _area_overlap: Dictionary = {} var _collision_pairs: Dictionary = {} ## Lightweight toast text + countdown (cleared in _process on expiry). var _toast_text: String = "" var _toast_timer: float = 0.0 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _camera.make_current() _load_settings() _spawner = STAGE_SPAWNER.new(_world) _selection = STAGE_SELECTION.new(_world, _camera) _selection.selection_changed.connect(_on_selection_changed) _selection.hover_changed.connect(_on_hover_changed) _build_grid_layer() _build_gizmo_layer() _build_director_visuals() _build_navigation() _build_ghost_holder() _build_ui() _apply_grid_settings() _refresh_status() func _process(delta: float) -> void: if _nav_dirty: _nav_dirty = false _rebake_navigation() if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT: _update_ghost_position() 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 _refresh_status() elif _direct_mode: _direct_mode = false _direct_button.set_pressed_no_signal(false) 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 mb.button_index != MOUSE_BUTTON_LEFT: return if current_mode != StageMode.EDIT: 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 _direct_mode: if mb.pressed: _handle_direct_click(world_pos) return if mb.pressed: if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE: _gizmos.begin_drag(world_pos) elif _placement_id != "": _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 _panning: _camera.position -= mm.relative / _camera.zoom.x return if current_mode != StageMode.EDIT: return var world_pos := _camera.get_global_mouse_position() if _gizmos.is_dragging(): _gizmos.drag_to(world_pos) elif _box_selecting: _box_rect = Rect2(_box_start, world_pos - _box_start) _gizmos.set_box_rect(_box_rect) elif not _is_mouse_over_ui(): _selection.update_hover(world_pos) # --------------------------------------------------------------------------- # Mode management # --------------------------------------------------------------------------- func set_mode(mode: StageMode) -> void: if mode == current_mode: return current_mode = mode if mode == StageMode.EDIT: _enter_edit_mode() else: _enter_play_mode() mode_changed.emit(int(mode)) _refresh_status() func _enter_edit_mode() -> 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() # 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) _apply_grid_settings() _set_build_controls_visible(true) # Clear edge-trigger state so PLAY overlaps do not leak stale results. _area_overlap.clear() _collision_pairs.clear() func _enter_play_mode() -> void: _gizmos.set_enabled(false) _selection.clear_selection() set_placement_mode("") if _action_popup != null: _action_popup.hide() _pending_walk_target = false _context_rig = null _direct_mode = false if _direct_button != null: _direct_button.set_pressed_no_signal(false) _director_visuals.set_enabled(false) _apply_grid_settings() _set_build_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() 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 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)) if node is TerrainBlock: _nav_dirty = true object_placed.emit(node) _refresh_status() _spawn_ghost() 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) 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: # Freeze the ghost rig into a static standing figure: disable IK solving # and animation so its limbs don't flex/follow anything while the ghost # tracks the cursor. 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() _ghost = ghost _update_ghost_position() 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 _snap_enabled: pos = _snap_to_grid(pos) _ghost.position = pos # --------------------------------------------------------------------------- # 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() _cleanup_rules_for_nodes(selected) _selection.clear_selection() 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 mode_text := "EDIT" if current_mode == StageMode.EDIT else "PLAY" 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 := "Mode: %s | Objects: %d | Selected: %s" % [mode_text, 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 if _mode_button != null: _mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY) _mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit" func _build_ui() -> void: var ui := CanvasLayer.new() ui.name = "UI" add_child(ui) 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) _mode_button = Button.new() _mode_button.toggle_mode = true _mode_button.toggled.connect(_on_mode_toggled) hbox.add_child(_mode_button) 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_button = Button.new() _direct_button.text = "Direct" _direct_button.toggle_mode = true _direct_button.toggled.connect(_on_direct_toggled) hbox.add_child(_direct_button) _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) _status_label = Label.new() _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT hbox.add_child(_status_label) _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) 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_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_toggled(pressed: bool) -> void: set_mode(StageMode.PLAY if pressed else StageMode.EDIT) func _on_palette_toggled(pressed: bool, id: String) -> void: if pressed: _direct_mode = false if _direct_button != null: _direct_button.set_pressed_no_signal(false) set_placement_mode(id) elif _placement_id == id: set_placement_mode("") 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 # --------------------------------------------------------------------------- # Director tool (Phase 3a) # --------------------------------------------------------------------------- func _on_direct_toggled(pressed: bool) -> void: _direct_mode = pressed if pressed: set_placement_mode("") _selection.clear_selection() _pending_walk_target = false _context_rig = null _refresh_status() 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 _refresh_status() return var hit := _selection.hit_test(world_pos) if hit is STICKMAN_RIG: _context_rig = hit as StickmanRig var mouse := get_viewport().get_mouse_position() _action_popup.popup(Rect2i(Vector2i(mouse), Vector2i.ZERO)) 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 _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(_mouse_popup_rect()) 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 (used by every rule popup). func _mouse_popup_rect() -> Rect2i: var mouse := get_viewport().get_mouse_position() return Rect2i(Vector2i(mouse), Vector2i.ZERO) func _open_rule_action_popup() -> void: _sync_rule_action_done_item() _rule_action_popup.popup(_mouse_popup_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(_mouse_popup_rect()) func _on_trigger_popup_id_pressed(id: int) -> void: if id == TRIG_BACK: _cancel_rule_build() 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: 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: _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 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 _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 # --------------------------------------------------------------------------- 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_build_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 if _direct_button != null: _direct_button.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 # --------------------------------------------------------------------------- # 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()