feat: Implement Live Director System for interactive simulation control

- Added waypoint context updates to handle action queue statuses in `waypoint_context.gd`.
- Introduced new scene file `manwalk.scene` with terrain and stickman setup.
- Created architecture summary for the Live Director System detailing core state transitions, physics mechanics, UI elements, and action execution.
- Specified class contracts and interfaces for the Live Director implementation in `phase_12_live_director.md`.
- Defined state transitions, input handling, and queue editing mechanics for the Live Director feature.
This commit is contained in:
2026-09-24 15:25:10 -04:00
parent 0a7f97cec7
commit 730a9a966b
19 changed files with 1633 additions and 168 deletions
+452 -76
View File
@@ -46,6 +46,7 @@ const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholde
# ---------------------------------------------------------------------------
enum StageMode { EDIT, DIRECT, PLAY }
enum RunState { STOPPED, RUNNING, PAUSED }
## Phase 4 rule-builder state machine steps.
enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS }
@@ -63,6 +64,9 @@ signal object_placed(node: Node2D)
signal object_selected(nodes: Array[Node2D])
signal object_deselected()
signal object_deleted(nodes: Array[Node2D])
signal simulation_paused(elapsed_seconds: float)
signal simulation_resumed(elapsed_seconds: float)
signal simulation_reset
# ---------------------------------------------------------------------------
# Constants
@@ -150,6 +154,13 @@ func _stage_dbg(msg: String) -> void:
# ---------------------------------------------------------------------------
var current_mode: StageMode = StageMode.EDIT
var _run_state: RunState = RunState.STOPPED
var _elapsed_seconds: float = 0.0
var _event_dispatch_ready: bool = false
var _resume_baseline_frames: int = 0
var _prop_pause_states: Dictionary = {}
var _suppressed_contact_pairs: Dictionary = {}
var _paused_edit_autosave_remaining: float = -1.0
var _spawner: StageSpawner
var _selection: StageSelection
@@ -183,6 +194,12 @@ var _direct_hint_label: Label
var _mode_frame: Panel
var _mode_badge: PanelContainer
var _mode_badge_label: Label
var _timestamp_badge: PanelContainer = null
var _timestamp_label: Label = null
var _run_controls: HBoxContainer = null
var _pause_button: Button = null
var _resume_button: Button = null
var _reset_button: Button = null
var _tooltip: Label
var _grid_check: CheckBox
var _snap_check: CheckBox
@@ -212,6 +229,8 @@ var _cli_startup: bool = false
var _panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
var _space_key_down: bool = false
var _space_press_owned: bool = false
var _box_selecting: bool = false
var _box_start: Vector2 = Vector2.ZERO
@@ -366,6 +385,7 @@ var _panel_rig: StickmanRig = null
## Waypoint right-click context (which rig/queue index/position was clicked).
var _ctx_waypoint_rig: StickmanRig = null
var _ctx_waypoint_index: int = -1
var _ctx_waypoint_action_id: int = -1
var _ctx_waypoint_pos: Vector2 = Vector2.ZERO
## Rule-panel filter: source id (>= 0) OR waypoint (finite); never both.
@@ -383,12 +403,16 @@ var _capture_cancel: Callable = Callable()
## Walk-edit state (Phase 3c.3): re-placing an existing walk_to target.
var _walk_edit_rig: StickmanRig = null
var _walk_edit_index: int = -1
var _walk_edit_action_id: int = -1
var _walk_edit_from_panel: bool = false
## ActionEditor context for queue/rule add/edit flows.
var _action_editor_kind: String = ""
var _action_editor_index: int = -1
var _action_editor_rig: StickmanRig = null
var _action_editor_action_id: int = -1
var _action_editor_target_action_id: int = -1
var _action_editor_insert_after: bool = false
var _action_editor_actor_id: int = -1
var _action_editor_restore_queue: bool = false
var _pending_rule_action_flat: Dictionary = {}
@@ -433,6 +457,8 @@ func _ready() -> void:
_apply_mode_frame()
_sync_mode_buttons()
_refresh_mode_badge()
_set_timestamp(0.0)
_sync_run_controls()
_apply_cursor()
_refresh_status()
_update_scene_chrome()
@@ -457,14 +483,28 @@ func _process(delta: float) -> void:
if _toast_timer <= 0.0:
_toast_text = ""
_refresh_status()
if _paused_edit_autosave_remaining >= 0.0:
_paused_edit_autosave_remaining -= delta
if _paused_edit_autosave_remaining <= 0.0:
_paused_edit_autosave_remaining = -1.0
if not _save_scene_to_path(AUTOSAVE_PATH, true):
_dirty = true
func _physics_process(_delta: float) -> void:
func _physics_process(delta: float) -> void:
if _run_state == RunState.RUNNING:
_elapsed_seconds += delta
_set_timestamp(_elapsed_seconds)
_sync_pending_editable_physics_transforms()
if _restore_frames_left > 0:
_restore_frames_left -= 1
_restore_authored_state()
if current_mode == StageMode.PLAY:
if _resume_baseline_frames > 0:
_refresh_event_edge_baseline()
_resume_baseline_frames -= 1
if _resume_baseline_frames == 0:
call_deferred("_finish_resume_baseline")
if current_mode == StageMode.PLAY and _event_dispatch_ready and _run_state == RunState.RUNNING:
_update_area_entry()
# ---------------------------------------------------------------------------
@@ -472,6 +512,9 @@ func _physics_process(_delta: float) -> void:
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventKey and (event as InputEventKey).keycode == KEY_SPACE:
_handle_space_key_event(event as InputEventKey)
return
if not _palette_drag_payload.is_empty():
if event is InputEventMouseMotion:
_update_palette_drag((event as InputEventMouseMotion).position)
@@ -525,13 +568,62 @@ func _unhandled_key_input(event: InputEvent) -> void:
elif _selector_open:
_on_selector_cancelled()
elif current_mode == StageMode.DIRECT:
set_mode(StageMode.EDIT)
if _action_popup != null and _action_popup.visible:
_action_popup.hide()
elif _queue_panel != null and _queue_panel.visible:
_queue_panel.hide()
elif _rule_panel != null and _rule_panel.visible:
_rule_panel.hide()
elif _action_editor != null and _action_editor.visible:
_action_editor.hide()
elif _rule_editor != null and _rule_editor.visible:
_rule_editor.hide()
elif _placement_id != "":
set_placement_mode("")
elif not _selection.get_selected().is_empty():
_selection.clear_selection()
func _handle_space_key_event(event: InputEventKey) -> void:
if not event.pressed:
if _space_press_owned:
get_viewport().set_input_as_handled()
_space_key_down = false
_space_press_owned = false
return
if _space_key_down:
if _space_press_owned:
get_viewport().set_input_as_handled()
return
_space_key_down = true
if event.echo or (current_mode != StageMode.PLAY and current_mode != StageMode.DIRECT):
return
if _space_should_pass_to_gui():
return
_space_press_owned = true
if not _pause_toggle_blocked():
if current_mode == StageMode.PLAY:
set_mode(StageMode.DIRECT)
else:
set_mode(StageMode.PLAY)
get_viewport().set_input_as_handled()
func _space_should_pass_to_gui() -> bool:
var focus: Control = get_viewport().gui_get_focus_owner()
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
return true
for window: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]:
if window != null and window.visible:
return true
if _selector_open or (_queue_panel != null and _queue_panel.visible) or (_rule_panel != null and _rule_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible):
return true
for popup: PopupMenu in [_action_popup, _trigger_popup, _rule_action_popup, _rule_more_popup, _waypoint_context, _rig_context_popup]:
if popup != null and popup.visible:
return true
return false
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
match mb.button_index:
MOUSE_BUTTON_WHEEL_UP:
@@ -646,7 +738,7 @@ func set_mode(mode: StageMode) -> void:
if mode == current_mode:
return
var previous_mode: StageMode = current_mode
if mode == StageMode.PLAY and not _cli_startup:
if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode
@@ -663,52 +755,24 @@ func set_mode(mode: StageMode) -> void:
_apply_grid_settings()
_apply_mode_frame()
_refresh_mode_badge()
_sync_run_controls()
_apply_cursor()
mode_changed.emit(int(mode))
_refresh_status()
func _enter_edit_shared() -> void:
# Restore stickmen to their authored spawn/ghost pose (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.set_terrain_following_enabled(false)
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
if node is PropBlock:
(node as PropBlock).end_play_instance_scale()
_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()
_area_previous_bounds.clear()
_collision_pairs.clear()
_reset_run()
func _enter_edit_mode(previous_mode: StageMode) -> void:
# EDIT and DIRECT share the same frozen world. PLAY still needs the complete
# simulation cleanup and authored-pose restore before editing resumes.
if previous_mode != StageMode.DIRECT:
func _enter_edit_mode(_previous_mode: StageMode) -> void:
if _run_state != RunState.STOPPED:
_enter_edit_shared()
_clear_director_pending()
_set_edit_controls_visible(true)
_set_direct_controls_visible(false)
_gizmos.set_enabled(true)
_director_visuals.set_enabled(true)
## Cancels any in-progress director/rule-builder click flows when leaving a mode
@@ -741,16 +805,16 @@ func _clear_director_pending() -> void:
## but with the spawner/grid controls hidden, the director hint shown, and any
## placement/selection/pending-target cleared.
func _enter_direct_mode(previous_mode: StageMode) -> void:
# EDIT and DIRECT share the same non-simulating world. Preserve the exact
# placed stickman pose between them; a transition from PLAY still needs the
# full cleanup and authored-pose restore.
if previous_mode != StageMode.EDIT:
_enter_edit_shared()
if previous_mode == StageMode.PLAY and _run_state == RunState.RUNNING:
_pause_run()
set_placement_mode("")
_selection.clear_selection()
_clear_director_pending()
_set_edit_controls_visible(false)
_set_direct_controls_visible(true)
_gizmos.set_enabled(false)
_director_visuals.set_enabled(true)
_director_visuals.set_live_session(_run_state == RunState.PAUSED)
func _enter_play_mode() -> void:
@@ -763,24 +827,219 @@ func _enter_play_mode() -> void:
_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.
if _run_state == RunState.PAUSED:
_resume_run()
else:
_start_run()
_gizmos.set_enabled(false)
_director_visuals.set_enabled(false)
_director_visuals.set_live_session(false)
_set_direct_controls_visible(false)
_stage_dbg("PLAY run_state=%s" % RunState.keys()[int(_run_state)])
func _start_run() -> void:
_elapsed_seconds = 0.0
_set_timestamp(0.0)
_run_state = RunState.RUNNING
_event_dispatch_ready = true
_resume_baseline_frames = 0
_prop_pause_states.clear()
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
if node is PropBlock:
(node as PropBlock).begin_play_instance_scale()
(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
var rig := node as StickmanRig
rig.reset_simulation_pause()
rig.stop_queue()
rig.clear_reactive_actions()
rig.auto_recover = false
rig.begin_terrain_fall()
rig.start_queue()
rig_count += 1
_stage_dbg("PLAY rigs=%d" % rig_count)
_sync_run_controls()
func _pause_run() -> void:
if _run_state != RunState.RUNNING:
return
_event_dispatch_ready = false
_run_state = RunState.PAUSED
_resume_baseline_frames = 0
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
(node as StickmanRig).pause_simulation()
elif node is RigidBody2D:
_capture_prop_pause_state(node as RigidBody2D)
_refresh_event_edge_baseline()
_gizmos.set_enabled(false)
_selection.clear_selection()
_director_visuals.set_live_session(true)
_director_visuals.set_enabled(true)
_set_direct_controls_visible(true)
_sync_run_controls()
simulation_paused.emit(_elapsed_seconds)
func _resume_run() -> void:
if _run_state != RunState.PAUSED:
return
_clear_director_pending()
for body_id: int in _prop_pause_states.keys():
var snapshot: Dictionary = _prop_pause_states[body_id]
var body: RigidBody2D = snapshot.get("body") as RigidBody2D
if body == null or not is_instance_valid(body) or body.is_queued_for_deletion():
continue
body.freeze_mode = int(snapshot.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
body.freeze = bool(snapshot.get("freeze", false))
body.linear_velocity = snapshot.get("linear_velocity", Vector2.ZERO)
body.angular_velocity = float(snapshot.get("angular_velocity", 0.0))
body.sleeping = bool(snapshot.get("sleeping", false))
_prop_pause_states.clear()
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
(node as StickmanRig).resume_simulation()
_run_state = RunState.RUNNING
_event_dispatch_ready = false
_resume_baseline_frames = 2
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
_sync_run_controls()
simulation_resumed.emit(_elapsed_seconds)
func _reset_run() -> void:
_event_dispatch_ready = false
_run_state = RunState.STOPPED
_resume_baseline_frames = 0
_paused_edit_autosave_remaining = -1.0
_prop_pause_states.clear()
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG:
var rig := node as StickmanRig
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
rig.clear_reactive_actions()
rig.reset_simulation_pause()
elif node is RigidBody2D:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
if node is PropBlock:
(node as PropBlock).end_play_instance_scale()
_restore_authored_state()
_restore_frames_left = 3
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
_elapsed_seconds = 0.0
_set_timestamp(0.0)
_director_visuals.set_live_session(false)
_sync_run_controls()
simulation_reset.emit()
func _capture_prop_pause_state(body: RigidBody2D) -> Dictionary:
var snapshot: Dictionary = {"body": body, "transform": body.global_transform, "linear_velocity": body.linear_velocity, "angular_velocity": body.angular_velocity, "sleeping": body.sleeping, "freeze": body.freeze, "freeze_mode": body.freeze_mode}
_prop_pause_states[body.get_instance_id()] = snapshot
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
return snapshot
func _set_timestamp(seconds: float) -> void:
if _timestamp_label == null:
return
var safe_seconds: float = maxf(seconds, 0.0)
var minutes: int = int(floor(safe_seconds / 60.0))
var remainder: float = fmod(safe_seconds, 60.0)
var centiseconds: int = int(floor(remainder * 100.0))
_timestamp_label.text = "⏱️ %02d:%02d.%02d" % [minutes, int(centiseconds / 100), centiseconds % 100]
func _sync_run_controls() -> void:
var running: bool = _run_state == RunState.RUNNING
var paused: bool = _run_state == RunState.PAUSED
if _pause_button != null:
_pause_button.visible = running and current_mode == StageMode.PLAY
if _resume_button != null:
_resume_button.visible = paused and current_mode == StageMode.DIRECT
if _reset_button != null:
_reset_button.visible = running or paused
if _timestamp_badge != null:
_timestamp_badge.visible = true
func _finish_resume_baseline() -> void:
if _run_state == RunState.RUNNING and current_mode == StageMode.PLAY:
_event_dispatch_ready = true
func _pause_toggle_blocked() -> bool:
if _capture_kind != CaptureKind.NONE or _rule_step != RuleStep.IDLE or _pending_walk_target or _gizmos.is_dragging() or _selector_open:
return true
var focus: Control = get_viewport().gui_get_focus_owner()
if focus is LineEdit or focus is TextEdit or focus is SpinBox:
return true
for dialog: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]:
if dialog != null and dialog.visible:
return true
return (_queue_panel != null and _queue_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible)
func _refresh_event_edge_baseline() -> void:
var areas: Array[TriggerArea] = []
for child: Node in _world.get_children():
if child is TriggerArea:
areas.append(child as TriggerArea)
var movables: Array[Node2D] = []
for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG and (node as StickmanRig).state == StickmanRig.RigState.ANIMATED:
movables.append(node)
elif node is PropBlock:
movables.append(node)
_area_overlap.clear()
_area_previous_bounds.clear()
for node: Node2D in movables:
var node_id: int = node.get_instance_id()
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(node).abs()
_area_previous_bounds[node_id] = bounds
for area: TriggerArea in areas:
var key: String = "%d:%d" % [area.get_instance_id(), node_id]
var area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
_area_overlap[key] = bounds.intersects(area_rect, true)
_collision_pairs.clear()
_suppressed_contact_pairs.clear()
for child: Node2D in _world_children_selectable():
if not child is PropBlock:
continue
var prop := child as PropBlock
for other: Node in prop.get_colliding_bodies():
if other == null or not is_instance_valid(other):
continue
_suppressed_contact_pairs["%d:%d" % [prop.get_instance_id(), other.get_instance_id()]] = true
if not other.has_meta("stickman_rig"):
continue
var rig: StickmanRig = other.get_meta("stickman_rig") as StickmanRig
if rig == null or not is_instance_valid(rig):
continue
var key: String = "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var pair: Dictionary = _collision_pairs.get(key, {"rig_id": rig.get_instance_id(), "prop_id": prop.get_instance_id(), "bodies": {}})
var body_ids: Dictionary = pair.get("bodies", {})
body_ids[other.get_instance_id()] = true
pair["bodies"] = body_ids
_collision_pairs[key] = pair
func _save_object_state(node: Node2D) -> void:
@@ -1258,6 +1517,8 @@ func delete_selected() -> void:
func _mark_scene_dirty() -> void:
if not _suppress_dirty:
_set_scene_dirty(true)
if _run_state == RunState.PAUSED:
_paused_edit_autosave_remaining = 1.0
func _set_scene_dirty(value: bool) -> void:
@@ -1267,6 +1528,8 @@ func _set_scene_dirty(value: bool) -> void:
func _on_rig_queue_changed() -> void:
_director_visuals.mark_dirty()
if _queue_panel != null and _panel_rig != null and is_instance_valid(_panel_rig):
_queue_panel.refresh()
_mark_scene_dirty()
@@ -1327,6 +1590,7 @@ func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool:
if _pending_file_action != PendingFileAction.NONE: _pending_file_action = PendingFileAction.NONE
return false
if not is_autosave:
_paused_edit_autosave_remaining = -1.0
_current_scene_path = path
if _scene_created_unix <= 0: _scene_created_unix = Time.get_unix_time_from_system()
_set_scene_dirty(false); _add_recent_scene(path)
@@ -1561,6 +1825,9 @@ func _show_cli_error_dialog(message: String) -> void:
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT:
_space_key_down = false
_space_press_owned = false
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_pending_file_action(PendingFileAction.QUIT)
@@ -1989,6 +2256,55 @@ func _build_ui() -> void:
_mode_badge.add_child(_mode_badge_label)
ui.add_child(_mode_badge)
_run_controls = HBoxContainer.new()
_run_controls.name = "RunControls"
_run_controls.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_run_controls.offset_top = TOP_BAR_HEIGHT + 8.0
_run_controls.offset_right = -12.0
_run_controls.add_theme_constant_override("separation", 6)
ui.add_child(_run_controls)
_pause_button = Button.new()
_pause_button.text = "⏸️ Pause"
_pause_button.pressed.connect(func() -> void: set_mode(StageMode.DIRECT))
_run_controls.add_child(_pause_button)
_resume_button = Button.new()
_resume_button.text = "▶️ Resume"
_resume_button.pressed.connect(func() -> void: set_mode(StageMode.PLAY))
_run_controls.add_child(_resume_button)
_reset_button = Button.new()
_reset_button.text = "↺ Reset"
_reset_button.pressed.connect(func() -> void: set_mode(StageMode.EDIT))
_run_controls.add_child(_reset_button)
for button: Button in [_pause_button, _resume_button, _reset_button]:
button.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
_timestamp_badge = PanelContainer.new()
_timestamp_badge.name = "TimestampBadge"
_timestamp_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_timestamp_badge.offset_left = -220.0
_timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0
_timestamp_badge.offset_right = -12.0
_timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0
var timestamp_style := StyleBoxFlat.new()
timestamp_style.bg_color = _sandbox_theme.color("chrome.timestamp_badge.bg_color", Color("#171B24F2")) if _sandbox_theme != null else Color("#171B24F2")
timestamp_style.set_corner_radius_all(_sandbox_theme.integer("chrome.timestamp_badge.corner_radius", 10, 0, 48) if _sandbox_theme != null else 10)
var timestamp_padding: float = float(_sandbox_theme.integer("chrome.timestamp_badge.padding", 8, 0, 48)) if _sandbox_theme != null else 8.0
timestamp_style.content_margin_left = timestamp_padding
timestamp_style.content_margin_right = timestamp_padding
timestamp_style.content_margin_top = timestamp_padding * 0.5
timestamp_style.content_margin_bottom = timestamp_padding * 0.5
_timestamp_badge.add_theme_stylebox_override("panel", timestamp_style)
_timestamp_label = Label.new()
_timestamp_label.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.timestamp_badge", 22, 12, 48) if _sandbox_theme != null else 22)
_timestamp_label.add_theme_color_override("font_color", _sandbox_theme.color("global.text_color", Color("#F5F7FA")) if _sandbox_theme != null else Color("#F5F7FA"))
_timestamp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timestamp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_timestamp_label.custom_minimum_size = Vector2(192.0, 30.0)
_apply_ui_font(_timestamp_label)
_timestamp_badge.add_child(_timestamp_label)
ui.add_child(_timestamp_badge)
# Action tooltip (cursor-attached, hidden unless a click-awaiting step is active).
_tooltip = Label.new()
_tooltip.name = "ActionTooltip"
@@ -2008,6 +2324,8 @@ func _build_ui() -> void:
_tooltip.add_theme_color_override("font_color", Color.WHITE)
_apply_ui_font(_tooltip)
ui.add_child(_tooltip)
_sync_run_controls()
_set_timestamp(0.0)
_action_popup = PopupMenu.new()
_action_popup.add_item("🚶 Walk To", ACT_WALK)
@@ -3305,10 +3623,14 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr
# Physics contacts can be delivered one frame after PLAY is stopped. Never
# recreate a flinch or dispatch a reactive rule in EDIT/DIRECT after the
# authoritative snap_to_standing() cleanup has run.
if current_mode != StageMode.PLAY:
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if other == null or not is_instance_valid(other):
return
var contact_key: String = "%d:%d" % [prop.get_instance_id(), other.get_instance_id()]
if _suppressed_contact_pairs.has(contact_key):
return
_suppressed_contact_pairs[contact_key] = true
if bool(other.get_meta("stickman_ragdoll_body", false)):
return
var has_rig_metadata := other.has_meta("stickman_rig") or other.has_meta("part_name")
@@ -3341,8 +3663,11 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr
func _on_prop_separated(other: Node, prop: PropBlock) -> void:
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if other == null or not is_instance_valid(other):
return
_suppressed_contact_pairs.erase("%d:%d" % [prop.get_instance_id(), other.get_instance_id()])
if not other.has_meta("stickman_rig"):
return
var rig := other.get_meta("stickman_rig") as StickmanRig
@@ -3376,6 +3701,8 @@ func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
## Iterates every rule in order; every matching rule executes (no short-circuit).
func _handle_event(event: Dictionary) -> void:
if _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
for rule: Dictionary in _event_rules:
if _rule_matches(rule, event):
_execute_rule_actions(rule)
@@ -3552,10 +3879,11 @@ func _handle_right_click() -> void:
func _open_waypoint_context(world_pos: Vector2, wp_hit: Dictionary) -> void:
_ctx_waypoint_rig = wp_hit["rig"]
_ctx_waypoint_index = int(wp_hit["index"])
_ctx_waypoint_action_id = int(wp_hit.get("action_id", _ctx_waypoint_rig.action_id_at(_ctx_waypoint_index)))
_ctx_waypoint_pos = wp_hit["pos"]
var count := _count_rules_for_waypoint(_ctx_waypoint_pos)
var screen := _world_to_screen(world_pos)
_waypoint_context.popup_for(Rect2i(Vector2i(screen), Vector2i.ZERO), count)
_waypoint_context.popup_for(Rect2i(Vector2i(screen), Vector2i.ZERO), count, int(wp_hit.get("status", StickmanActionRunner.QueueStatus.NOT_STARTED)))
func _open_rig_context(world_pos: Vector2, rig: StickmanRig) -> void:
@@ -3566,19 +3894,28 @@ func _open_rig_context(world_pos: Vector2, rig: StickmanRig) -> void:
func _on_waypoint_context_id_pressed(id: int) -> void:
var rig := _ctx_waypoint_rig
var index := _ctx_waypoint_index
if rig == null or not is_instance_valid(rig):
return
var index: int = rig.index_for_action_id(_ctx_waypoint_action_id)
if index < 0:
_show_toast("That action is no longer in the queue")
return
var status: int = rig.get_action_status(_ctx_waypoint_action_id)
match id:
WaypointContext.EDIT_WALK:
if _queue_action_consumed(status): return
_begin_walk_edit(rig, index, false)
WaypointContext.DELETE_WALK:
rig.remove_action(index)
_show_toast("Walk deleted")
WaypointContext.INSERT_BEFORE:
_open_action_editor("queue_insert", {}, index, rig, -1, false)
if _queue_action_consumed(status) or status == StickmanActionRunner.QueueStatus.ACTIVE:
_show_toast("Insert after the active action")
return
_open_action_editor("queue_insert", {}, index, rig, -1, false, _ctx_waypoint_action_id, false)
WaypointContext.INSERT_AFTER:
_open_action_editor("queue_insert", {}, index + 1, rig, -1, false)
if _queue_action_consumed(status): return
_open_action_editor("queue_insert", {}, index + 1, rig, -1, false, _ctx_waypoint_action_id, true)
WaypointContext.EDIT_TRIGGER_RULES:
_open_trigger_rules_panel(_ctx_waypoint_pos)
@@ -3670,8 +4007,11 @@ func _resolve_capture(world_pos: Vector2) -> void:
# ---------------------------------------------------------------------------
func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
if index < 0 or index >= rig.queue_size():
return
_walk_edit_rig = rig
_walk_edit_index = index
_walk_edit_action_id = rig.action_id_at(index)
_walk_edit_from_panel = from_panel
var queue := rig.get_queue()
var pos: Vector2 = queue[index].get("target", rig.global_position - StickmanLocomotion.FOOT_OFFSET)
@@ -3681,21 +4021,25 @@ func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
func _on_walk_edit_captured(value: Vector2) -> void:
var rig := _walk_edit_rig
var index := _walk_edit_index
var action_id: int = _walk_edit_action_id
var from_panel := _walk_edit_from_panel
_walk_edit_rig = null
_walk_edit_index = -1
_walk_edit_action_id = -1
_walk_edit_from_panel = false
if _director_visuals != null:
_director_visuals.clear_edit_waypoint()
if rig != null and is_instance_valid(rig):
var queue := rig.get_queue()
if index >= 0 and index < queue.size():
var index: int = rig.index_for_action_id(action_id)
var status: int = rig.get_action_status(action_id)
var queue: Array[Dictionary] = rig.get_queue()
if index >= 0 and index < queue.size() and not _queue_action_consumed(status):
var action: Dictionary = queue[index]
action["target"] = value
rig.remove_action(index)
rig.insert_action(index, action)
_show_toast("Waypoint moved")
if rig.replace_action(index, action):
_show_toast("Waypoint moved")
else:
_show_toast("That action changed while the waypoint was open")
if from_panel and _queue_panel != null:
_queue_panel.refresh()
_queue_panel.popup_centered()
@@ -3705,6 +4049,7 @@ func _cb_walk_edit_cancel() -> void:
var from_panel := _walk_edit_from_panel
_walk_edit_rig = null
_walk_edit_index = -1
_walk_edit_action_id = -1
_walk_edit_from_panel = false
if _director_visuals != null:
_director_visuals.clear_edit_waypoint()
@@ -3728,6 +4073,9 @@ func _on_queue_panel_edit_requested(index: int) -> void:
var queue := _panel_rig.get_queue()
if index < 0 or index >= queue.size():
return
if _queue_action_consumed(_panel_rig.get_action_status(_panel_rig.action_id_at(index))):
_show_toast("Completed actions can only be deleted")
return
var action: Dictionary = queue[index]
if String(action.get("type", "")) == "walk_to":
_queue_panel.hide()
@@ -3739,12 +4087,15 @@ func _on_queue_panel_edit_requested(index: int) -> void:
func _on_queue_panel_delete_requested(index: int) -> void:
if _panel_rig == null or not is_instance_valid(_panel_rig):
return
var queue := _panel_rig.get_queue()
if index < 0 or index >= queue.size():
var target_rig: StickmanRig = _panel_rig
var action_id: int = target_rig.action_id_at(index)
if action_id < 0:
return
_ask_confirm("Delete Action", "Delete this action?", func() -> void:
if _panel_rig != null and is_instance_valid(_panel_rig):
_panel_rig.remove_action(index)
if target_rig != null and is_instance_valid(target_rig):
var current_index: int = target_rig.index_for_action_id(action_id)
if current_index >= 0:
target_rig.remove_action(current_index)
if _queue_panel != null:
_queue_panel.refresh()
)
@@ -3759,14 +4110,19 @@ func _on_queue_panel_add_requested() -> void:
func _on_queue_panel_clear_requested() -> void:
if _panel_rig == null or not is_instance_valid(_panel_rig):
return
_ask_confirm("Clear Queue", "Remove all actions for %s?" % _panel_rig.name, func() -> void:
if _panel_rig != null and is_instance_valid(_panel_rig):
_panel_rig.clear_queue()
var target_rig: StickmanRig = _panel_rig
_ask_confirm("Clear Queue", "Remove all actions for %s?" % target_rig.name, func() -> void:
if target_rig != null and is_instance_valid(target_rig):
target_rig.clear_queue()
if _queue_panel != null:
_queue_panel.refresh()
)
func _queue_action_consumed(status: int) -> bool:
return status == StickmanActionRunner.QueueStatus.COMPLETED or status == StickmanActionRunner.QueueStatus.INTERRUPTED
# ---------------------------------------------------------------------------
# Phase 3c.4 Rule Panel
# ---------------------------------------------------------------------------
@@ -3863,6 +4219,10 @@ func _on_rule_panel_clear_requested() -> void:
func _on_rule_panel_reorder_requested(ordered_ids: Array[int]) -> void:
if _run_state == RunState.PAUSED:
_show_toast("Event rules stay in order during a paused run")
_refresh_rule_panel()
return
_reorder_filtered_rules(ordered_ids)
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
@@ -4002,10 +4362,13 @@ func _cb_rule_actor_cancel() -> void:
# Phase 3c Action Editor
# ---------------------------------------------------------------------------
func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: StickmanRig, actor_id: int, hide_queue: bool) -> void:
func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: StickmanRig, actor_id: int, hide_queue: bool, target_action_id: int = -1, insert_after: bool = false) -> void:
_action_editor_kind = kind
_action_editor_index = index
_action_editor_rig = rig
_action_editor_action_id = rig.action_id_at(index) if kind == "queue_edit" and rig != null and is_instance_valid(rig) else -1
_action_editor_target_action_id = target_action_id
_action_editor_insert_after = insert_after
_action_editor_actor_id = actor_id
_action_editor_restore_queue = hide_queue
if hide_queue and _queue_panel != null:
@@ -4027,16 +4390,27 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
_show_toast("Action added")
"queue_edit":
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
var idx := _action_editor_index
var q := _action_editor_rig.get_queue()
if idx >= 0 and idx < q.size():
_action_editor_rig.remove_action(idx)
_action_editor_rig.insert_action(idx, flat)
var idx: int = _action_editor_rig.index_for_action_id(_action_editor_action_id)
var status: int = _action_editor_rig.get_action_status(_action_editor_action_id)
if idx >= 0 and not _queue_action_consumed(status) and _action_editor_rig.replace_action(idx, flat):
_show_toast("Action updated")
else:
_show_toast("That action is no longer editable")
"queue_insert":
if _action_editor_rig != null and is_instance_valid(_action_editor_rig):
_action_editor_rig.insert_action(_action_editor_index, flat)
_show_toast("Action inserted")
var insert_index: int = _action_editor_index
if _action_editor_target_action_id >= 0:
insert_index = _action_editor_rig.index_for_action_id(_action_editor_target_action_id)
var target_status: int = _action_editor_rig.get_action_status(_action_editor_target_action_id)
if insert_index < 0 or _queue_action_consumed(target_status) or (target_status == StickmanActionRunner.QueueStatus.ACTIVE and not _action_editor_insert_after):
insert_index = -1
else:
insert_index += 1 if _action_editor_insert_after else 0
if insert_index >= 0:
_action_editor_rig.insert_action(insert_index, flat)
_show_toast("Action inserted")
else:
_show_toast("That action can no longer be used as an insert point")
"rule_add":
_pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
@@ -4165,7 +4539,7 @@ func _world_children_selectable() -> Array[Node2D]:
var node := child as Node2D
if node == null:
continue
if node.name == RAGDOLL_CONTAINER_NAME:
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
continue
result.append(node)
return result
@@ -4311,6 +4685,8 @@ func _load_font(path: String) -> Font:
func _apply_theme_to_visuals() -> void:
if _director_visuals != null:
_director_visuals.set_style(_theme)
_director_visuals.completed_path_alpha = _sandbox_theme.number("overlays.completed_path_alpha", 0.20, 0.0, 1.0) if _sandbox_theme != null else 0.20
_director_visuals.completed_path_color = _sandbox_theme.color("overlays.completed_path_color", Color("#959EAD")) if _sandbox_theme != null else Color("#959EAD")
_director_visuals.emoji_font = _emoji_font
_director_visuals.ui_font = _ui_font
_director_visuals.bold_font = _ui_font_bold