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
+29 -12
View File
@@ -10,6 +10,7 @@ extends PopupPanel
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const ACTION_RUNNER := preload("res://scripts/stickman_action_runner.gd")
signal edit_requested(index: int)
signal delete_requested(index: int)
@@ -65,14 +66,15 @@ func refresh() -> void:
_drag_index = -1
_drag_target = -1
var queue: Array[Dictionary] = []
var queue_view: Array[Dictionary] = []
if rig != null and is_instance_valid(rig):
queue = rig.get_queue()
queue_view = rig.get_queue_view()
_title_label.text = "Stickman: %s" % (String(rig.name) if rig != null and is_instance_valid(rig) else "?")
_empty_label.visible = queue.is_empty()
_empty_label.visible = queue_view.is_empty()
for i: int in queue.size():
_rows.append(_make_row(i, queue[i]))
for i: int in queue_view.size():
var entry: Dictionary = queue_view[i]
_rows.append(_make_row(i, entry.get("action", {}), int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED))))
func _build_ui() -> void:
@@ -129,7 +131,7 @@ func _build_ui() -> void:
min_size = Vector2i(460, 360)
func _make_row(index: int, action: Dictionary) -> PanelContainer:
func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0)))
@@ -145,10 +147,15 @@ func _make_row(index: int, action: Dictionary) -> PanelContainer:
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)]
row.add_child(summary)
var status_label := Label.new()
status_label.text = _status_label(status)
status_label.modulate = Color(1.0, 1.0, 1.0, 0.65) if status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED else Color.WHITE
row.add_child(status_label)
var edit := Button.new()
edit.text = "✎"
edit.tooltip_text = "Edit"
edit.disabled = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
edit.pressed.connect(func() -> void: edit_requested.emit(index))
row.add_child(edit)
@@ -161,6 +168,7 @@ func _make_row(index: int, action: Dictionary) -> PanelContainer:
var drag := Button.new()
drag.text = "≡"
drag.tooltip_text = "Drag to reorder"
drag.disabled = status == ACTION_RUNNER.QueueStatus.ACTIVE or status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
drag.mouse_default_cursor_shape = Control.CURSOR_MOVE
drag.gui_input.connect(_on_drag_handle_gui_input.bind(index))
row.add_child(drag)
@@ -226,6 +234,20 @@ func _row_style(bg: Color) -> StyleBoxFlat:
return sb
func _status_label(status: int) -> String:
match status:
ACTION_RUNNER.QueueStatus.COMPLETED:
return "✓ Done"
ACTION_RUNNER.QueueStatus.INTERRUPTED:
return "↯ Interrupted"
ACTION_RUNNER.QueueStatus.ACTIVE:
return "▶ Active"
ACTION_RUNNER.QueueStatus.PENDING:
return "• Pending"
_:
return ""
func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void:
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
if (event as InputEventMouseButton).pressed:
@@ -281,9 +303,4 @@ func _move_action(from: int, to: int) -> void:
var queue := rig.get_queue()
if from < 0 or from >= queue.size() or to < 0 or to >= queue.size():
return
var action: Dictionary = queue[from]
rig.remove_action(from)
var target := to
if to > from:
target -= 1
rig.insert_action(target, action)
rig.move_action(from, to)
+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
+3 -3
View File
@@ -8,13 +8,13 @@ const DEFAULT_PATH: String = "res://sandbox_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
}
+82 -37
View File
@@ -7,6 +7,7 @@ extends Node2D
## numbers. Pure drawing; no hit-testing. Visible in EDIT, hidden in PLAY.
const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd")
const ACTION_RUNNER := preload("res://scripts/stickman_action_runner.gd")
const WAYPOINT_RADIUS_PX := 6.0
const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0)
@@ -42,6 +43,8 @@ var _dirty: bool = true
var badge_icon_size: float = ICON_SIZE_PX
var badge_number_size: float = NUMBER_FONT_SIZE_PX
var badge_radius: float = RULE_BADGE_RADIUS_PX
var completed_path_alpha: float = 0.20
var completed_path_color: Color = Color(0.58, 0.62, 0.68)
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
var emoji_font: Font = null
@@ -61,6 +64,7 @@ var _rule_hit_regions: Array[Dictionary] = []
## Phase 3c: the walk_to waypoint currently being visually edited (blinking
## highlight). Vector2.INF when none.
var _edit_waypoint: Vector2 = Vector2.INF
var _live_session: bool = false
## Highlights a waypoint while its walk target is being re-placed on the stage.
func set_edit_waypoint(pos: Vector2) -> void:
@@ -77,6 +81,11 @@ func set_enabled(value: bool) -> void:
visible = value
queue_redraw()
func set_live_session(paused: bool) -> void:
_live_session = paused
mark_dirty()
func mark_dirty() -> void:
_dirty = true
@@ -141,18 +150,20 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
var best: Dictionary = {}
var best_dist := radius
for rig: StickmanRig in _collect_rigs():
var queue := rig.get_queue()
if queue.is_empty():
var queue_view: Array[Dictionary] = rig.get_queue_view()
if queue_view.is_empty():
continue
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
for i: int in queue.size():
var action: Dictionary = queue[i]
var anchor: Dictionary = _queue_anchor(rig)
var current: Vector2 = anchor.get("ground", anchor.get("feet", rig.global_position - StickmanLocomotion.FOOT_OFFSET))
for i: int in queue_view.size():
var entry: Dictionary = queue_view[i]
var action: Dictionary = entry.get("action", {})
if String(action.get("type", "")) == "walk_to":
var target: Vector2 = action.get("target", current)
var d := world_pos.distance_to(target)
if d <= best_dist:
best_dist = d
best = { "rig": rig, "index": i, "pos": target }
best = { "rig": rig, "index": int(entry.get("authored_index", i)), "action_id": int(entry.get("action_id", -1)), "status": int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED)), "pos": target }
current = target
return best
@@ -188,26 +199,56 @@ func _draw() -> void:
_draw_rules(zoom)
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
var queue := rig.get_queue()
if queue.is_empty():
var queue_view: Array[Dictionary] = rig.get_queue_view()
if queue_view.is_empty():
return
# Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root).
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
var anchor: Dictionary = _queue_anchor(rig)
var authored_current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
var has_authored_cursor: bool = not _live_session
var live_current: Vector2 = anchor.get("feet", authored_current)
if _live_session:
live_current = anchor.get("ground", live_current) if bool(anchor.get("has_ground", false)) else anchor.get("feet", live_current)
if bool(anchor.get("airborne", false)) and bool(anchor.get("has_ground", false)):
draw_line(anchor.get("feet", live_current), anchor.get("ground", live_current), Color(0.8, 0.8, 0.8, 0.35), 1.5 / zoom, true)
var stack := 0
for i: int in queue.size():
var action: Dictionary = queue[i]
for i: int in queue_view.size():
var entry: Dictionary = queue_view[i]
var action: Dictionary = entry.get("action", {})
var status: int = int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED))
var tint: Color = _status_color(status)
var consumed: bool = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
var current: Vector2 = authored_current if _live_session and consumed else live_current if _live_session else authored_current
if String(action.get("type", "")) == "walk_to":
var target: Vector2 = action.get("target", current)
_draw_dashed(current, target, zoom)
_draw_waypoint(target, zoom, str(i + 1))
current = target
if not (_live_session and consumed and not has_authored_cursor):
_draw_dashed(current, target, zoom, tint)
_draw_waypoint(target, zoom, str(i + 1), tint)
if _live_session and consumed:
authored_current = target
has_authored_cursor = true
else:
live_current = target
authored_current = target
stack = 0
else:
var anchor := current + BADGE_STACK_STEP * float(stack) / zoom
_draw_badge(anchor, String(action.get("type", "")), zoom, str(i + 1))
var badge_anchor: Vector2 = current + BADGE_STACK_STEP * float(stack) / zoom
_draw_badge(badge_anchor, String(action.get("type", "")), zoom, str(i + 1), tint)
stack += 1
func _draw_dashed(from: Vector2, to: Vector2, zoom: float) -> void:
func _queue_anchor(rig: StickmanRig) -> Dictionary:
if _live_session:
return rig.get_director_anchor()
var feet: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
return {"feet": feet, "ground": feet, "has_ground": false, "airborne": false}
func _status_color(status: int) -> Color:
if _live_session and (status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED):
return Color(completed_path_color.r, completed_path_color.g, completed_path_color.b, completed_path_alpha)
return Color(1.0, 1.0, 1.0, 1.0)
func _draw_dashed(from: Vector2, to: Vector2, zoom: float, tint: Color = DASH_COLOR) -> void:
var dash := DASH_LENGTH_PX / zoom
var gap := DASH_GAP_PX / zoom
var dir := from.direction_to(to)
@@ -216,48 +257,52 @@ func _draw_dashed(from: Vector2, to: Vector2, zoom: float) -> void:
while dist < total:
var start := from + dir * dist
var len := minf(dash, total - dist)
draw_line(start, start + dir * len, DASH_COLOR, DASH_WIDTH_PX / zoom, true)
var line_color: Color = DASH_COLOR if tint == Color.WHITE else tint
line_color.a *= DASH_COLOR.a
draw_line(start, start + dir * len, line_color, DASH_WIDTH_PX / zoom, true)
dist += dash + gap
func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
func _draw_waypoint(pos: Vector2, zoom: float, number: String, tint: Color = WAYPOINT_COLOR) -> void:
var radius := WAYPOINT_RADIUS_PX / zoom
draw_circle(pos, radius, WAYPOINT_COLOR)
draw_arc(pos, radius, 0.0, TAU, 32, WAYPOINT_OUTLINE, 2.0 / zoom, true)
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
draw_circle(pos, radius, tint)
var outline: Color = Color.WHITE
outline.a = tint.a
draw_arc(pos, radius, 0.0, TAU, 32, outline, 2.0 / zoom, true)
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom, outline)
# Phase 3c: pulsing highlight ring while this waypoint is being edited.
if _edit_waypoint.is_finite() and pos.distance_to(_edit_waypoint) < 0.5:
var pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() / 150.0)
draw_arc(pos, radius + 6.0 / zoom, 0.0, TAU, 32, Color(1.0, 0.8, 0.0, pulse), 3.0 / zoom, true)
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), NUMBER_COLOR)
func _draw_number(pos: Vector2, number: String, zoom: float, tint: Color = NUMBER_COLOR) -> void:
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), tint)
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) -> void:
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String, tint: Color = ICON_COLOR) -> void:
var s := badge_icon_size / zoom
match type:
"speak":
var bw := s * 1.6
var bh := s * 1.1
draw_rect(Rect2(anchor - Vector2(bw, bh) * 0.5, Vector2(bw, bh)), ICON_COLOR, true)
draw_rect(Rect2(anchor - Vector2(bw, bh) * 0.5, Vector2(bw, bh)), tint, true)
draw_colored_polygon(PackedVector2Array([
anchor + Vector2(-s * 0.25, bh * 0.5),
anchor + Vector2(s * 0.25, bh * 0.5),
anchor + Vector2(0.0, bh * 0.5 + s * 0.5),
]), ICON_COLOR)
]), tint)
"wait":
draw_arc(anchor, s, 0.0, TAU, 32, ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(0.0, -s * 0.7), ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(s * 0.5, 0.0), ICON_COLOR, 2.0 / zoom, true)
draw_arc(anchor, s, 0.0, TAU, 32, tint, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(0.0, -s * 0.7), tint, 2.0 / zoom, true)
draw_line(anchor, anchor + Vector2(s * 0.5, 0.0), tint, 2.0 / zoom, true)
"ragdoll":
draw_line(anchor + Vector2(-s, -s) * 0.5, anchor + Vector2(s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor + Vector2(s, -s) * 0.5, anchor + Vector2(-s, s) * 0.5, ICON_COLOR, 2.0 / zoom, true)
draw_line(anchor + Vector2(-s, -s) * 0.5, anchor + Vector2(s, s) * 0.5, tint, 2.0 / zoom, true)
draw_line(anchor + Vector2(s, -s) * 0.5, anchor + Vector2(-s, s) * 0.5, tint, 2.0 / zoom, true)
"recover":
var base := anchor + Vector2(0.0, s * 0.6)
var tip := anchor + Vector2(0.0, -s * 0.6)
draw_line(base, tip, ICON_COLOR, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), ICON_COLOR, 2.0 / zoom, true)
_draw_number(anchor + Vector2(s, -s), number, zoom)
draw_line(base, tip, tint, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), tint, 2.0 / zoom, true)
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), tint, 2.0 / zoom, true)
_draw_number(anchor + Vector2(s, -s), number, zoom, tint)
# ---------------------------------------------------------------------------
# Rule rendering (Phase 4)
+25 -1
View File
@@ -194,14 +194,38 @@ func _is_selectable(node: Node) -> bool:
return false
if node.get_parent() != _world:
return false
if node.name == RAGDOLL_CONTAINER_NAME:
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
return false
return true
func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
if _world == null or not is_instance_valid(_world):
return null
var state: PhysicsDirectSpaceState2D = _world.get_world_2d().direct_space_state
var query := PhysicsPointQueryParameters2D.new()
query.position = world_pos
query.collision_mask = 0x7FFFFFFF
query.collide_with_areas = false
query.collide_with_bodies = true
var hits: Array[Dictionary] = state.intersect_point(query, 32)
for hit: Dictionary in hits:
var body: Object = hit.get("collider") as Object
if body == null or not body is Node or not (body as Node).has_meta("stickman_ragdoll_body"):
continue
var rig: Node2D = (body as Node).get_meta("stickman_rig") as Node2D
if rig == null or not is_instance_valid(rig) or rig.is_queued_for_deletion() or rig.get_parent() != _world:
continue
return rig
return null
## Highest World child index wins; on equal area the frontmost (first hit in
## reverse iteration) stays.
func _frontmost_at(world_pos: Vector2) -> Node2D:
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
if ragdoll_hit != null:
return ragdoll_hit
var children := _world.get_children()
var best: Node2D = null
var best_area := INF
+249 -23
View File
@@ -10,6 +10,7 @@ const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
@@ -18,8 +19,14 @@ signal queue_changed
signal speech_finished
signal ragdoll_requested(enabled: bool)
signal recovery_requested
signal active_action_cancelled(action: Dictionary)
var action_queue: Array[Dictionary] = []
var _authored_queue: Array[Dictionary] = []
var action_queue: Array[Dictionary]:
get:
return _authored_queue.duplicate(true)
set(value):
set_authored_queue(value)
var _rig: StickmanRig = null
var _locomotion: StickmanLocomotion = null
var _runner_state: RunnerState = RunnerState.IDLE
@@ -32,6 +39,13 @@ var _phase_timer: float = 0.0
var _primary_speech: Dictionary = {}
var _overlay_speech: Dictionary = {}
var _speech_bubble: Node2D = null
var _action_ids: Array[int] = []
var _next_action_id: int = 1
var _current_action_id: int = -1
var _completed_ids: Dictionary = {}
var _interrupted_ids: Dictionary = {}
var _paused: bool = false
var _session_started: bool = false
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
@@ -40,6 +54,8 @@ func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
if _paused:
return
_update_speech(delta)
if _runner_state != RunnerState.EXECUTING or _is_blocked():
return
@@ -86,13 +102,31 @@ func sync_facing(profile: int) -> void:
_speech_bubble.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE
func queue_action(action: Dictionary) -> void:
action_queue.append(action)
func set_authored_queue(actions: Array[Dictionary]) -> bool:
if _session_started:
return false
_authored_queue = actions.duplicate(true)
_action_ids.clear()
for _action: Dictionary in _authored_queue:
_action_ids.append(_allocate_action_id())
queue_changed.emit()
return true
func queue_action(action: Dictionary) -> void:
insert_action(action_queue.size(), action)
func clear_queue() -> void:
action_queue.clear()
_authored_queue.clear()
_action_ids.clear()
if not _current_run_item.is_empty():
_cancel_current_primary(true)
_current_action_id = -1
_run_queue.clear()
_run_cursor = 0
if _session_started:
_runner_state = RunnerState.IDLE
queue_changed.emit()
@@ -104,15 +138,109 @@ func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
push_warning("StickmanActionRunner: remove_action index %d out of range." % index)
return
action_queue.remove_at(index)
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
var status: int = _status_for_action_id(action_id)
if _session_started and _paused and status == QueueStatus.ACTIVE:
_cancel_current_primary(true)
_current_action_id = -1
_runner_state = RunnerState.IDLE
_authored_queue.remove_at(index)
if index < _action_ids.size():
_action_ids.remove_at(index)
_rebuild_pending_runtime_queue()
queue_changed.emit()
func insert_action(index: int, action: Dictionary) -> void:
action_queue.insert(clampi(index, 0, action_queue.size()), action)
var target: int = clampi(index, 0, _authored_queue.size())
if _session_started and _paused:
if target <= _paused_history_floor():
return
var action_id: int = _allocate_action_id()
_authored_queue.insert(target, action.duplicate(true))
_action_ids.insert(target, action_id)
if _session_started and _paused:
_rebuild_pending_runtime_queue()
queue_changed.emit()
func replace_action(index: int, action: Dictionary) -> bool:
if index < 0 or index >= _authored_queue.size():
return false
var action_id: int = _action_ids[index]
var status: int = _status_for_action_id(action_id)
if _session_started and _paused and (status == QueueStatus.COMPLETED or status == QueueStatus.INTERRUPTED):
return false
if _session_started and _paused and status == QueueStatus.ACTIVE:
_cancel_current_primary(true)
_current_action_id = -1
_runner_state = RunnerState.IDLE
_authored_queue[index] = action.duplicate(true)
if _session_started and _paused:
_completed_ids.erase(action_id)
_interrupted_ids.erase(action_id)
_rebuild_pending_runtime_queue()
queue_changed.emit()
return true
func move_action(from_index: int, to_index: int) -> bool:
if from_index < 0 or from_index >= _authored_queue.size() or to_index < 0 or to_index >= _authored_queue.size():
return false
if _session_started and _paused:
var id: int = _action_ids[from_index]
if _status_for_action_id(id) != QueueStatus.PENDING:
return false
if to_index <= _paused_history_floor():
return false
if from_index == to_index:
return true
var action: Dictionary = _authored_queue.pop_at(from_index)
var action_id: int = _action_ids.pop_at(from_index)
_authored_queue.insert(to_index, action)
_action_ids.insert(to_index, action_id)
if _session_started and _paused:
_rebuild_pending_runtime_queue()
queue_changed.emit()
return true
func get_queue_view() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for index: int in _authored_queue.size():
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id})
return result
func action_id_at(index: int) -> int:
return _action_ids[index] if index >= 0 and index < _action_ids.size() else -1
func index_for_action_id(action_id: int) -> int:
return _index_for_action_id(action_id)
func get_action_status(action_id: int) -> int:
return _status_for_action_id(action_id)
func pause_simulation() -> void:
_paused = true
func resume_simulation() -> void:
_paused = false
func clear_pause_state() -> void:
_paused = false
_completed_ids.clear()
_interrupted_ids.clear()
_current_action_id = -1
_session_started = false
func queue_size() -> int:
return action_queue.size()
@@ -120,10 +248,10 @@ func queue_size() -> int:
func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty():
return
if not _session_started:
_start_session_runtime()
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
for index: int in action_queue.size():
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
_run_cursor = 0
_rebuild_pending_runtime_queue()
var primary: Array[Dictionary] = []
var takeover_seen: bool = false
for action: Dictionary in actions:
@@ -134,7 +262,10 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
if not takeover_seen:
takeover_seen = true
_cancel_current_primary()
if _current_action_id >= 0:
_interrupted_ids[_current_action_id] = true
_cancel_current_primary(true)
_current_action_id = -1
_overlay_speech.clear()
hide_speech()
var kept: Array[Dictionary] = []
@@ -147,8 +278,6 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
primary.append(action.duplicate(true))
if primary.is_empty():
return
if not takeover_seen:
_cancel_current_primary()
var injected: Array[Dictionary] = []
for action: Dictionary in primary:
injected.append({"action": action, "queue_index": -1, "reactive": true})
@@ -166,27 +295,30 @@ func clear_reactive_actions() -> void:
_run_queue = runtime_kept
_run_cursor = mini(_run_cursor, _run_queue.size())
var kept: Array[Dictionary] = []
for action: Dictionary in action_queue:
for action: Dictionary in _authored_queue:
if not bool(action.get("reactive", false)):
kept.append(action)
if kept.size() != action_queue.size():
action_queue = kept
if kept.size() != _authored_queue.size():
_authored_queue = kept
_action_ids.clear()
for _action: Dictionary in _authored_queue:
_action_ids.append(_allocate_action_id())
queue_changed.emit()
func start_queue() -> void:
if _runner_state == RunnerState.EXECUTING:
if _runner_state == RunnerState.EXECUTING and _session_started:
return
if action_queue.is_empty():
if not _session_started:
_start_session_runtime()
_paused = false
if _authored_queue.is_empty():
queue_finished.emit()
return
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_current_index = -1
_run_queue.clear()
for index: int in action_queue.size():
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
_run_cursor = 0
_rebuild_pending_runtime_queue()
_current_run_item.clear()
@@ -198,6 +330,11 @@ func stop_queue() -> void:
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
_current_action_id = -1
_paused = false
_session_started = false
_completed_ids.clear()
_interrupted_ids.clear()
func is_queue_running() -> bool:
@@ -217,8 +354,24 @@ func _advance_to_next_action() -> void:
return
_current_run_item = _run_queue[_run_cursor]
_run_cursor += 1
var action: Dictionary = _current_run_item.get("action", {})
_current_index = int(_current_run_item.get("queue_index", -1))
_current_action_id = int(_current_run_item.get("action_id", -1))
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
var action: Dictionary = _current_run_item.get("action", {})
if String(action.get("type", "")) == "walk_to" and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
_current_run_item.clear()
_current_action_id = -1
_current_index = -1
return
if _rig.is_ragdolled():
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
_run_queue.insert(_run_cursor, {"action": {"type": "recover"}, "queue_index": -1, "action_id": -1, "reactive": true})
_current_run_item.clear()
_current_action_id = -1
_current_index = -1
return
action_started.emit(action, _current_index)
_begin_action(action)
@@ -249,14 +402,22 @@ func _begin_action(action: Dictionary) -> void:
func _finish_action() -> void:
var finished: Dictionary = _current_run_item.get("action", {})
var index: int = int(_current_run_item.get("queue_index", -1))
var action_id: int = int(_current_run_item.get("action_id", -1))
if action_id >= 0:
_completed_ids[action_id] = true
index = _index_for_action_id(action_id) if action_id >= 0 else -1
_action_phase = ActionPhase.NONE
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
if not finished.is_empty():
action_finished.emit(finished, index)
func _cancel_current_primary() -> void:
func _cancel_current_primary(notify: bool = false) -> void:
if notify and not _current_run_item.is_empty():
var action: Dictionary = _current_run_item.get("action", {})
active_action_cancelled.emit(action.duplicate(true))
if _locomotion != null:
_locomotion.cancel_walk(not _is_blocked())
_primary_speech.clear()
@@ -264,10 +425,75 @@ func _cancel_current_primary() -> void:
_action_phase = ActionPhase.NONE
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
func _allocate_action_id() -> int:
var result: int = _next_action_id
_next_action_id += 1
return result
func _start_session_runtime() -> void:
_session_started = true
_completed_ids.clear()
_interrupted_ids.clear()
_action_ids.clear()
for _action: Dictionary in _authored_queue:
_action_ids.append(_allocate_action_id())
func _runtime_item(action_id: int, action: Dictionary, index: int) -> Dictionary:
return {"action": action.duplicate(true), "queue_index": index, "action_id": action_id, "reactive": false}
func _rebuild_pending_runtime_queue() -> void:
var rebuilt: Array[Dictionary] = []
var retained_reactive: Array[Dictionary] = []
for runtime_index: int in range(_run_cursor, _run_queue.size()):
var runtime_item: Dictionary = _run_queue[runtime_index]
if bool(runtime_item.get("reactive", false)):
retained_reactive.append(runtime_item.duplicate(true))
for index: int in _authored_queue.size():
var action_id: int = _action_ids[index]
var status: int = _status_for_action_id(action_id)
if status == QueueStatus.PENDING or status == QueueStatus.NOT_STARTED:
rebuilt.append(_runtime_item(action_id, _authored_queue[index], index))
_run_queue = retained_reactive + rebuilt
_run_cursor = 0
if not _run_queue.is_empty() and _session_started:
_runner_state = RunnerState.EXECUTING
func _paused_history_floor() -> int:
var floor_index: int = -1
for index: int in _authored_queue.size():
var status: int = _status_for_action_id(_action_ids[index])
if status != QueueStatus.PENDING and status != QueueStatus.NOT_STARTED:
floor_index = index
return floor_index
func _index_for_action_id(action_id: int) -> int:
return _action_ids.find(action_id)
func _status_for_action_id(action_id: int) -> int:
if action_id < 0:
return QueueStatus.NOT_STARTED
if _completed_ids.has(action_id):
return QueueStatus.COMPLETED
if _interrupted_ids.has(action_id):
return QueueStatus.INTERRUPTED
if action_id == _current_action_id:
return QueueStatus.ACTIVE
if _session_started:
return QueueStatus.PENDING
return QueueStatus.NOT_STARTED
func _is_blocked() -> bool:
return _locomotion != null and _locomotion.is_action_blocked()
+17
View File
@@ -366,6 +366,22 @@ func cancel_walk(restore_idle_pose: bool = true) -> void:
func is_walking() -> bool:
return _walking
func ensure_grounded_walk_animation() -> void:
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or not _walking:
return
if terrain_motion != TerrainMotion.GROUNDED or blocks_walking():
return
if _rig._anim_player == null or not is_instance_valid(_rig._anim_player) or not _rig._anim_player.has_animation("walk_right"):
return
if _terrain_paused_walk_animation:
if _rig._anim_player.is_playing():
_terrain_paused_walk_animation = false
else:
_resume_walk_animation()
elif not _rig._anim_player.is_playing():
_rig._anim_player.play("walk_right")
func get_foot_offset() -> Vector2:
return FOOT_OFFSET
@@ -479,6 +495,7 @@ func reset_for_ragdoll_handoff() -> void:
air_fall_distance = 0.0
terrain_motion = TerrainMotion.DISABLED
terrain_air_velocity = Vector2.ZERO
_reset_foot_contacts()
func is_action_blocked() -> bool:
return terrain_motion == TerrainMotion.LANDING_IMPACT
+93 -2
View File
@@ -117,6 +117,9 @@ var _probe_bottom_y := 0.0
var _probe_exclude: Array[RID] = []
var _spawn_pose: Dictionary = {}
var _spawn_limb_rotations: Dictionary = {}
var _simulation_paused: bool = false
var _paused_body_states: Dictionary = {}
var _was_recovery_tween_running: bool = false
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
_rig = rig
@@ -150,9 +153,97 @@ func initialize(skeleton: Skeleton2D) -> void:
## straight knee cross its angular limit during the step; correcting here keeps
## that invalid intermediate pose from ever reaching the rendered frame.
func _process(_delta: float) -> void:
if _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
if not _simulation_paused and _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
_enforce_side_profile_leg_bends()
func pause_simulation() -> void:
if _simulation_paused:
return
_simulation_paused = true
_paused_body_states.clear()
_was_recovery_tween_running = _stand_up_tween != null and _stand_up_tween.is_valid() and _stand_up_tween.is_running()
if _was_recovery_tween_running:
_stand_up_tween.pause()
for body: RigidBody2D in _bodies.values():
if body == null or not is_instance_valid(body):
continue
var body_id: int = body.get_instance_id()
_paused_body_states[body_id] = {"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}
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
func resume_simulation() -> void:
if not _simulation_paused:
return
for body_id: int in _paused_body_states.keys():
var snapshot: Dictionary = _paused_body_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))
_paused_body_states.clear()
_simulation_paused = false
if _was_recovery_tween_running and _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.play()
_was_recovery_tween_running = false
func clear_pause_snapshot() -> void:
_paused_body_states.clear()
_simulation_paused = false
_was_recovery_tween_running = false
func get_body_nodes() -> Array[RigidBody2D]:
var result: Array[RigidBody2D] = []
for body: RigidBody2D in _bodies.values():
if body != null and is_instance_valid(body):
result.append(body)
return result
func get_body_rids() -> Array[RID]:
var result: Array[RID] = []
for body: RigidBody2D in get_body_nodes():
var rid: RID = body.get_rid()
if rid.is_valid():
result.append(rid)
return result
func get_world_body_bounds() -> Rect2:
var bounds := Rect2()
var has_bounds: bool = false
for body: RigidBody2D in get_body_nodes():
var extent: float = float(body.get_meta("radius", LIMB_RADIUS)) + float(body.get_meta("half_height", 0.0))
var body_rect := Rect2(body.global_position - Vector2(extent, extent), Vector2(extent * 2.0, extent * 2.0))
if not has_bounds:
bounds = body_rect
has_bounds = true
else:
bounds = bounds.merge(body_rect)
return bounds if has_bounds else Rect2()
func interrupt_recovery_to_ragdoll() -> void:
if _rig == null or _rig.state != StickmanRig.RigState.RECOVERING:
return
if _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.kill()
_stand_up_tween = null
_stand_up_generation += 1
if enter_ragdoll():
state_transition_requested.emit(StickmanRig.RigState.RAGDOLL)
if _simulation_paused:
_simulation_paused = false
pause_simulation()
func _resolve_limb_bones() -> void:
_limb_bones.clear()
for joint: String in LIMB_BONE_PATHS:
@@ -186,7 +277,7 @@ func build() -> bool:
var parent: Node = _rig.get_parent()
if parent == null: parent = _rig.get_tree().current_scene
if parent == null: return false
_root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear()
_root = Node2D.new(); _root.name = CONTAINER_NAME; _root.set_meta("stickman_ragdoll_container", true); _root.add_to_group("stickman_ragdoll_container"); _root.set_meta("stickman_rig", _rig); parent.add_child(_root); _bodies.clear()
for entry: Dictionary in BODIES: _build_body(entry)
_stabilize_side_profile_leg_bends()
for entry: Dictionary in JOINTS: _build_joint(entry)
+172 -6
View File
@@ -213,9 +213,9 @@ var action_queue: Array[Dictionary]:
return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue
set(value):
if _action_runner_component != null:
_action_runner_component.action_queue = value
_action_runner_component.set_authored_queue(value)
else:
_pending_action_queue = value
_pending_action_queue = value.duplicate(true)
## Original .stk asset path, assigned by StageSpawner for scene persistence.
var source_stk_path: String = ""
@@ -225,6 +225,10 @@ var source_stk_path: String = ""
var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null
var _action_runner_component: StickmanActionRunner = null
var _simulation_paused: bool = false
var _phase12_paused_animation: bool = false
var _phase12_paused_animation_name: StringName = &""
var _phase12_paused_animation_position: float = 0.0
# ---------------------------------------------------------------------------
# Lifecycle
@@ -257,6 +261,7 @@ func _ready() -> void:
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
_action_runner_component.active_action_cancelled.connect(_on_active_action_cancelled)
# Resolve all runtime node references.
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
@@ -304,6 +309,8 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if _simulation_paused:
return
if _ragdoll_component != null:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _locomotion_component != null:
@@ -485,6 +492,12 @@ func request_recovery() -> void:
func snap_to_standing() -> void:
if _ragdoll_component != null:
_ragdoll_component.snap_to_standing(int(state))
_simulation_paused = false
_phase12_paused_animation = false
if _action_runner_component != null:
_action_runner_component.clear_pause_state()
if _ragdoll_component != null:
_ragdoll_component.clear_pause_snapshot()
## Restores the spawn-marker pose through the ragdoll component. Kept on the
@@ -751,11 +764,17 @@ func _sync_speech_bubble_mirror() -> void:
# ---------------------------------------------------------------------------
func queue_action(action: Dictionary) -> void:
if _action_runner_component != null: _action_runner_component.queue_action(action)
if _action_runner_component != null:
insert_action(queue_size(), action)
else:
_pending_action_queue.append(action.duplicate(true))
func clear_queue() -> void:
if _action_runner_component != null: _action_runner_component.clear_queue()
if _action_runner_component != null:
_action_runner_component.clear_queue()
else:
_pending_action_queue.clear()
func get_queue() -> Array[Dictionary]:
@@ -763,11 +782,158 @@ func get_queue() -> Array[Dictionary]:
func remove_action(index: int) -> void:
if _action_runner_component != null: _action_runner_component.remove_action(index)
if _action_runner_component != null:
_action_runner_component.remove_action(index)
elif index >= 0 and index < _pending_action_queue.size():
_pending_action_queue.remove_at(index)
func insert_action(index: int, action: Dictionary) -> void:
if _action_runner_component != null: _action_runner_component.insert_action(index, action)
if _action_runner_component != null:
var normalized: Dictionary = _with_recover_barrier(action)
if is_ragdolled() and String(normalized.get("type", "")) == "walk_to":
var queue: Array[Dictionary] = get_queue()
var at: int = clampi(index, 0, queue.size())
var has_recover_barrier: bool = false
if at > 0 and String(queue[at - 1].get("type", "")) == "recover":
var previous_status: int = get_action_status(action_id_at(at - 1))
has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE
if not has_recover_barrier:
_action_runner_component.insert_action(at, {"type": "recover"})
at += 1
_action_runner_component.insert_action(at, normalized)
else:
_action_runner_component.insert_action(index, normalized)
else:
_pending_action_queue.insert(clampi(index, 0, _pending_action_queue.size()), action.duplicate(true))
func replace_action(index: int, action: Dictionary) -> bool:
if _action_runner_component == null:
return false
if is_ragdolled() and String(action.get("type", "")) == "walk_to":
var queue: Array[Dictionary] = get_queue()
var has_recover_barrier: bool = false
if index > 0 and index < queue.size() and String(queue[index - 1].get("type", "")) == "recover":
var previous_status: int = get_action_status(action_id_at(index - 1))
has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE
if has_recover_barrier:
return _action_runner_component.replace_action(index, action.duplicate(true))
var replaced: bool = _action_runner_component.replace_action(index, {"type": "recover"})
if not replaced:
return false
_action_runner_component.insert_action(index + 1, action.duplicate(true))
return true
return _action_runner_component.replace_action(index, _with_recover_barrier(action))
func move_action(from_index: int, to_index: int) -> bool:
return _action_runner_component.move_action(from_index, to_index) if _action_runner_component != null else false
func get_queue_view() -> Array[Dictionary]:
return _action_runner_component.get_queue_view() if _action_runner_component != null else []
func action_id_at(index: int) -> int:
return _action_runner_component.action_id_at(index) if _action_runner_component != null else -1
func index_for_action_id(action_id: int) -> int:
return _action_runner_component.index_for_action_id(action_id) if _action_runner_component != null else -1
func get_action_status(action_id: int) -> int:
return _action_runner_component.get_action_status(action_id) if _action_runner_component != null else StickmanActionRunner.QueueStatus.NOT_STARTED
func pause_simulation() -> void:
if _simulation_paused:
return
_simulation_paused = true
if _action_runner_component != null:
_action_runner_component.pause_simulation()
if _ragdoll_component != null:
_ragdoll_component.pause_simulation()
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.is_playing():
_phase12_paused_animation_name = _anim_player.current_animation
_phase12_paused_animation_position = _anim_player.get_current_animation_position()
_phase12_paused_animation = true
_anim_player.pause()
func resume_simulation() -> void:
if not _simulation_paused:
return
if _ragdoll_component != null:
_ragdoll_component.resume_simulation()
if _action_runner_component != null:
_action_runner_component.resume_simulation()
_simulation_paused = false
if _phase12_paused_animation and _anim_player != null and is_instance_valid(_anim_player):
if _anim_player.current_animation == _phase12_paused_animation_name:
_anim_player.play(_phase12_paused_animation_name)
_anim_player.seek(_phase12_paused_animation_position, true)
_phase12_paused_animation = false
_phase12_paused_animation_name = &""
_phase12_paused_animation_position = 0.0
if _locomotion_component != null:
_locomotion_component.ensure_grounded_walk_animation()
func reset_simulation_pause() -> void:
if _ragdoll_component != null:
_ragdoll_component.clear_pause_snapshot()
if _action_runner_component != null:
_action_runner_component.clear_pause_state()
_simulation_paused = false
_phase12_paused_animation = false
_phase12_paused_animation_name = &""
_phase12_paused_animation_position = 0.0
func get_director_anchor() -> Dictionary:
var bounds: Rect2 = _ragdoll_component.get_world_body_bounds() if _ragdoll_component != null and state == RigState.RAGDOLL else Rect2()
var feet: Vector2 = global_position - StickmanLocomotion.FOOT_OFFSET
var airborne: bool = _locomotion_component != null and _locomotion_component.terrain_motion == StickmanLocomotion.TerrainMotion.AIRBORNE
if state == RigState.RAGDOLL and not bounds.size.is_zero_approx():
feet = Vector2(bounds.get_center().x, bounds.end.y)
var probe_origin: Vector2 = Vector2(bounds.get_center().x, feet.y) if state == RigState.RAGDOLL and not bounds.size.is_zero_approx() else feet
var query := PhysicsRayQueryParameters2D.create(probe_origin + Vector2.UP * 40.0, probe_origin + Vector2.DOWN * 1800.0)
query.collision_mask = 0x7FFFFFFF
query.collide_with_areas = false
var excluded: Array[RID] = []
_collect_nonterrain_rids(get_parent(), excluded)
query.exclude = excluded
var hit: Dictionary = get_world_2d().direct_space_state.intersect_ray(query)
var has_ground: bool = not hit.is_empty()
return {"feet": feet, "ground": hit.get("position", feet), "has_ground": has_ground, "airborne": airborne or state == RigState.RAGDOLL}
func is_ragdolled() -> bool:
return state == RigState.RAGDOLL
func _on_active_action_cancelled(action: Dictionary) -> void:
if String(action.get("type", "")) == "recover" and state == RigState.RECOVERING and _ragdoll_component != null:
_ragdoll_component.interrupt_recovery_to_ragdoll()
func _with_recover_barrier(action: Dictionary) -> Dictionary:
return action.duplicate(true)
func _collect_nonterrain_rids(node: Node, out: Array[RID]) -> void:
if node == null:
return
if node is TerrainBlock:
return
if node is CollisionObject2D:
var rid: RID = (node as CollisionObject2D).get_rid()
if rid.is_valid() and not out.has(rid):
out.append(rid)
for child: Node in node.get_children():
_collect_nonterrain_rids(child, out)
func queue_size() -> int:
+6 -1
View File
@@ -10,6 +10,7 @@ const DELETE_WALK := 1
const INSERT_BEFORE := 2
const INSERT_AFTER := 3
const EDIT_TRIGGER_RULES := 4
const ACTION_RUNNER := preload("res://scripts/stickman_action_runner.gd")
const _EDIT_TRIGGER_IDX := 5 # item position of the trigger-rules entry (after a separator at 4)
@@ -25,7 +26,11 @@ func _init() -> void:
## Updates the trigger-rules entry (count / enabled) and pops up at `rect`.
func popup_for(rect: Rect2i, trigger_rule_count: int) -> void:
func popup_for(rect: Rect2i, trigger_rule_count: int, queue_status: int = ACTION_RUNNER.QueueStatus.NOT_STARTED) -> void:
var consumed: bool = queue_status == ACTION_RUNNER.QueueStatus.COMPLETED or queue_status == ACTION_RUNNER.QueueStatus.INTERRUPTED
set_item_disabled(get_item_index(EDIT_WALK), consumed)
set_item_disabled(get_item_index(INSERT_BEFORE), consumed or queue_status == ACTION_RUNNER.QueueStatus.ACTIVE)
set_item_disabled(get_item_index(INSERT_AFTER), consumed)
if trigger_rule_count > 0:
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules (%d rule%s)" % [trigger_rule_count, "" if trigger_rule_count == 1 else "s"])
set_item_disabled(_EDIT_TRIGGER_IDX, false)