class_name StickmanActionRunner extends Node ## Owns authored and reactive action execution plus the shared speech bubble. ## StickmanRig remains the public facade; this node only calls down into ## locomotion and signals requests back to the facade. const ACTION_REGISTRY := preload("res://scripts/action_registry.gd") const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd") const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd") enum RunnerState { IDLE, EXECUTING } enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION } enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING } signal action_started(action: Dictionary, index: int) signal action_finished(action: Dictionary, index: int) signal queue_finished signal queue_changed signal speech_finished signal ragdoll_requested(enabled: bool) signal recovery_requested signal active_action_cancelled(action: 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 _prop_interactor: Node = null var _runner_state: RunnerState = RunnerState.IDLE var _action_phase: ActionPhase = ActionPhase.NONE var _current_index: int = -1 var _run_queue: Array[Dictionary] = [] var _current_run_item: Dictionary = {} var _run_cursor: int = 0 var _phase_timer: float = 0.0 var _primary_speech: Dictionary = {} var _overlay_speech: Dictionary = {} var _speech_bubble: Node2D = null var _speech_preview_suppressed: bool = false 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 var _prop_action_finished: bool = false func configure(rig: StickmanRig, locomotion: StickmanLocomotion, prop_interactor: Node = null) -> void: _rig = rig _locomotion = locomotion _prop_interactor = prop_interactor if _prop_interactor != null and not _prop_interactor.is_connected("action_completed", _on_prop_action_completed): _prop_interactor.connect("action_completed", _on_prop_action_completed) 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 match _action_phase: ActionPhase.NONE: _advance_to_next_action() ActionPhase.WALKING: if _locomotion != null and _locomotion.consume_walk_completed(): _finish_action() ActionPhase.SPEAKING: if _primary_speech.is_empty(): _finish_action() ActionPhase.WAITING: _phase_timer -= delta if _phase_timer <= 0.0: _finish_action() ActionPhase.RAGDOLLING: if ragdoll_at_rest: _finish_action() ActionPhase.RECOVERING: if rig_state == StickmanRig.RigState.ANIMATED: _finish_action() ActionPhase.PROP_INTERACTION: if _prop_action_finished: _prop_action_finished = false _finish_action() func speak(text: String, duration: float) -> void: speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)}) func speak_action(action: Dictionary) -> void: var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action) var duration: float = SPEECH_PAYLOAD.duration_for(normalized) _primary_speech = {"action": normalized, "elapsed": 0.0, "duration": duration} _show_visible_speech() func speak_overlay(text: String, duration: float) -> void: speak_overlay_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)}) func speak_overlay_action(action: Dictionary) -> void: var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action) var duration: float = SPEECH_PAYLOAD.duration_for(normalized) _overlay_speech = {"action": normalized, "elapsed": 0.0, "duration": duration} _show_visible_speech() func set_speech_preview_suppressed(value: bool) -> void: _speech_preview_suppressed = value if _speech_bubble != null and is_instance_valid(_speech_bubble): (_speech_bubble as SpeechBubble).set_suppressed(value) func hide_speech() -> void: _primary_speech.clear() _overlay_speech.clear() if _speech_bubble != null and is_instance_valid(_speech_bubble): _speech_bubble.hide_bubble() func sync_facing(profile: int) -> void: if _speech_bubble != null and is_instance_valid(_speech_bubble): (_speech_bubble as SpeechBubble).set_mirrored(profile == StickmanRig.FacingProfile.LEFT) 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: _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() func get_queue() -> Array[Dictionary]: return action_queue.duplicate(true) 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 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: 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() 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: _rebuild_pending_runtime_queue() var primary: Array[Dictionary] = [] var takeover_seen: bool = false for action: Dictionary in actions: var role: int = ACTION_REGISTRY.reactive_role(action) if role == ACTION_REGISTRY.ReactiveRole.OVERLAY: speak_overlay_action(action) continue if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER: if not takeover_seen: takeover_seen = true 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] = [] for item: Dictionary in _run_queue.slice(_run_cursor): if not bool(item.get("reactive", false)): kept.append(item) _run_queue = kept _run_cursor = 0 primary.clear() primary.append(action.duplicate(true)) if primary.is_empty(): return var injected: Array[Dictionary] = [] for action: Dictionary in primary: injected.append({"action": action, "queue_index": -1, "reactive": true}) _run_queue = injected + _run_queue.slice(_run_cursor) _run_cursor = 0 _runner_state = RunnerState.EXECUTING _action_phase = ActionPhase.NONE func clear_reactive_actions() -> void: var runtime_kept: Array[Dictionary] = [] for item: Dictionary in _run_queue: if not bool(item.get("reactive", false)): runtime_kept.append(item) _run_queue = runtime_kept _run_cursor = mini(_run_cursor, _run_queue.size()) var kept: Array[Dictionary] = [] for action: Dictionary in _authored_queue: if not bool(action.get("reactive", false)): kept.append(action) 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 and _session_started: return 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 _rebuild_pending_runtime_queue() _current_run_item.clear() func stop_queue() -> void: _cancel_current_primary() hide_speech() _run_queue.clear() _current_run_item.clear() _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: return _runner_state == RunnerState.EXECUTING func get_current_index() -> int: return _current_index func _advance_to_next_action() -> void: if _run_cursor >= _run_queue.size(): _runner_state = RunnerState.IDLE _action_phase = ActionPhase.NONE _current_index = -1 queue_finished.emit() return _current_run_item = _run_queue[_run_cursor] _run_cursor += 1 _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", {}) var action_type := String(action.get("type", "")) var requires_animated := action_type == "walk_to" or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop" if requires_animated 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) func _begin_action(action: Dictionary) -> void: match String(action.get("type", "")): "walk_to": _action_phase = ActionPhase.WALKING if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))): _finish_action() "speak": if bool(action.get("keep_acting", false)): speak_overlay_action(action) _finish_action() else: _action_phase = ActionPhase.SPEAKING speak_action(action) "wait": _phase_timer = float(action.get("duration", 0.0)) _action_phase = ActionPhase.WAITING "ragdoll": _action_phase = ActionPhase.RAGDOLLING ragdoll_requested.emit(true) "recover": _action_phase = ActionPhase.RECOVERING recovery_requested.emit() "pick_up_prop", "drop_prop", "throw_prop": if _rig == null or not _rig.start_prop_action(action): _finish_action() else: _prop_action_finished = false _action_phase = ActionPhase.PROP_INTERACTION _: push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", ""))) _finish_action() 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(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()) if _rig != null: _rig.cancel_prop_action() _primary_speech.clear() _phase_timer = 0.0 _action_phase = ActionPhase.NONE _prop_action_finished = false _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 _on_prop_action_completed() -> void: _prop_action_finished = true 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() func _update_speech(delta: float) -> void: if not _overlay_speech.is_empty(): _overlay_speech["elapsed"] = float(_overlay_speech.get("elapsed", 0.0)) + delta if float(_overlay_speech.get("elapsed", 0.0)) >= float(_overlay_speech.get("duration", 0.0)): _overlay_speech.clear() speech_finished.emit() elif not _primary_speech.is_empty() and not _is_blocked(): _primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta if float(_primary_speech.get("elapsed", 0.0)) >= float(_primary_speech.get("duration", 0.0)): _primary_speech.clear() speech_finished.emit() if _overlay_speech.is_empty() and _primary_speech.is_empty(): if _speech_bubble != null and is_instance_valid(_speech_bubble): _speech_bubble.hide_bubble() else: _show_visible_speech() func _show_visible_speech() -> void: if _rig == null: return if _speech_bubble == null or not is_instance_valid(_speech_bubble): _speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as SpeechBubble _speech_bubble.name = "SpeechBubble" _rig.add_child(_speech_bubble) sync_facing(_rig.facing_profile) (_speech_bubble as SpeechBubble).set_suppressed(_speech_preview_suppressed) var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech if not visible_speech.is_empty(): var action: Dictionary = visible_speech.get("action", {}) (_speech_bubble as SpeechBubble).render_action(action, float(visible_speech.get("elapsed", 0.0)), false)