Files
stickman/scripts/stickman_action_runner.gd
T
ryan 6a1359f244 Add prop interaction system and throw mechanics specification
- Introduced `stickman_prop_interactor.gd` to manage prop interactions.
- Created `phase_11_prop_interactions.md` detailing the design and implementation of prop pickup, drop, and throw mechanics.
- Defined interaction states, action lifecycles, and event triggers for prop handling.
- Specified constraints for throw actions, including target positioning and velocity calculations.
- Established acceptance criteria for prop interactions and lifecycle management.
2026-09-25 23:42:26 -04:00

557 lines
18 KiB
GDScript

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, 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 _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:
_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 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(String(action.get("text", "")), float(action.get("duration", 2.0)))
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":
_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()
"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:
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", "")))