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_BUBBLE_OFFSET := Vector2(0.0, -640.0) enum RunnerState { IDLE, EXECUTING } enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING } 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 var action_queue: Array[Dictionary] = [] var _rig: StickmanRig = null var _locomotion: StickmanLocomotion = 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 func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void: _rig = rig _locomotion = locomotion func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void: _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() func speak(text: String, duration: float) -> void: _primary_speech = {"text": text, "time_left": maxf(duration, 0.0)} _show_visible_speech() func speak_overlay(text: String, duration: float) -> void: _overlay_speech = {"text": text, "time_left": maxf(duration, 0.0)} _show_visible_speech() 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.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE func queue_action(action: Dictionary) -> void: action_queue.append(action) queue_changed.emit() func clear_queue() -> void: action_queue.clear() 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 action_queue.remove_at(index) queue_changed.emit() func insert_action(index: int, action: Dictionary) -> void: action_queue.insert(clampi(index, 0, action_queue.size()), action) queue_changed.emit() func queue_size() -> int: return action_queue.size() func enqueue_reactive(actions: Array[Dictionary]) -> void: if actions.is_empty(): return 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 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(String(action.get("text", "")), float(action.get("duration", 2.0))) continue if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER: if not takeover_seen: takeover_seen = true _cancel_current_primary() _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 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}) _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 action_queue: if not bool(action.get("reactive", false)): kept.append(action) if kept.size() != action_queue.size(): action_queue = kept queue_changed.emit() func start_queue() -> void: if _runner_state == RunnerState.EXECUTING: return if action_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 _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 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 var action: Dictionary = _current_run_item.get("action", {}) _current_index = int(_current_run_item.get("queue_index", -1)) 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": _action_phase = ActionPhase.SPEAKING speak(String(action.get("text", "")), float(action.get("duration", 2.0))) "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() _: 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)) _action_phase = ActionPhase.NONE _current_run_item.clear() _current_index = -1 if not finished.is_empty(): action_finished.emit(finished, index) func _cancel_current_primary() -> void: if _locomotion != null: _locomotion.cancel_walk(not _is_blocked()) _primary_speech.clear() _phase_timer = 0.0 _action_phase = ActionPhase.NONE _current_run_item.clear() _current_index = -1 if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble): _speech_bubble.hide_bubble() func _is_blocked() -> bool: return _locomotion != null and _locomotion.is_action_blocked() func _update_speech(delta: float) -> void: var visible_owner_changed: bool = false if not _primary_speech.is_empty() and not _is_blocked(): _primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta if float(_primary_speech["time_left"]) <= 0.0: _primary_speech.clear() speech_finished.emit() if not _overlay_speech.is_empty(): _overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta if float(_overlay_speech["time_left"]) <= 0.0: _overlay_speech.clear() visible_owner_changed = true 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() elif visible_owner_changed and _overlay_speech.is_empty(): _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 Node2D _speech_bubble.name = "SpeechBubble" _speech_bubble.position = SPEECH_BUBBLE_OFFSET _rig.add_child(_speech_bubble) sync_facing(_rig.facing_profile) var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech if not visible_speech.is_empty(): _speech_bubble.show_text(String(visible_speech.get("text", "")))