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:
+29
-12
@@ -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
@@ -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
|
||||
|
||||
@@ -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": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user