feat(stage): complete phase 20b action plans

This commit is contained in:
2026-11-04 01:19:57 -05:00
parent 87a33b0abc
commit a9c0886348
70 changed files with 14234 additions and 469 deletions
@@ -5,11 +5,18 @@ extends Node
## locomotion and signals requests back to the facade.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STAGE_ACTION_PLAN := preload("res://scripts/stage/stage_action_plan.gd")
const STAGE_ACTION_IDENTITY := preload("res://scripts/stage/stage_action_identity.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
const PROP_INTERACTION_REACH: float = 110.0
const PROP_PURSUIT_MIN_SHIFT: float = 16.0
const PROP_PURSUIT_RETARGET_INTERVAL: float = 0.1
const PROP_PURSUIT_BUDGET: float = 12.0
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/speech/stickman_speech_bubble.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION, GROUP_BARRIER, PURSUIT_WAIT }
enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }
signal action_started(action: Dictionary, index: int)
@@ -20,8 +27,12 @@ signal speech_finished
signal ragdoll_requested(enabled: bool)
signal recovery_requested
signal active_action_cancelled(action: Dictionary)
signal planned_action_started(action_id: String, action: Dictionary, step_index: int)
signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)
signal plan_changed
var _authored_queue: Array[Dictionary] = []
var _authored_steps: Array[Dictionary] = []
var action_queue: Array[Dictionary]:
get:
return _authored_queue.duplicate(true)
@@ -43,7 +54,26 @@ var _speech_bubble: Node2D = null
var _speech_preview_suppressed: bool = false
var _action_ids: Array[int] = []
var _next_action_id: int = 1
var _stable_to_numeric_ids: Dictionary = {}
var _stable_status: Dictionary = {}
var _current_action_id: int = -1
var _current_stable_action_id: String = ""
var _current_step_index: int = -1
var _active_step_entry: Dictionary = {}
var _group_say_action_id: String = ""
var _group_say_numeric_id: int = -1
var _group_say_step_index: int = -1
var _group_say_action: Dictionary = {}
var _group_say_barrier: bool = false
var _group_primary_done: bool = false
var _parallel_active_numeric_ids: Dictionary = {}
var _pursuit_action_id: String = ""
var _pursuit_prop_id: String = ""
var _pursuit_offset: Vector2 = Vector2.ZERO
var _pursuit_last_prop_position: Vector2 = Vector2.ZERO
var _pursuit_approach: Vector2 = Vector2.INF
var _pursuit_elapsed: float = 0.0
var _pursuit_retarget_elapsed: float = 0.0
var _completed_ids: Dictionary = {}
var _interrupted_ids: Dictionary = {}
var _paused: bool = false
@@ -70,8 +100,14 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
_advance_to_next_action()
ActionPhase.WALKING:
if _locomotion != null:
if _locomotion.consume_walk_failed() or _locomotion.consume_walk_completed():
_finish_action()
if _locomotion.consume_walk_failed():
_end_prop_pursuit()
_finish_action(&"failed")
elif _locomotion.consume_walk_completed():
if _pursuit_action_id == _current_stable_action_id:
_check_prop_pursuit_arrival()
else:
_finish_action(&"succeeded")
ActionPhase.SPEAKING:
if _primary_speech.is_empty():
_finish_action()
@@ -79,6 +115,11 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_action()
ActionPhase.GROUP_BARRIER:
if not _group_say_barrier or _group_say_action_id.is_empty():
_complete_active_step()
ActionPhase.PURSUIT_WAIT:
pass
ActionPhase.RAGDOLLING:
if ragdoll_at_rest:
_finish_action()
@@ -91,6 +132,48 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
_finish_action()
func pre_movement(delta: float) -> void:
if _paused or _pursuit_action_id.is_empty() or _action_phase not in [ActionPhase.WALKING, ActionPhase.PURSUIT_WAIT]:
return
_pursuit_elapsed += delta
_pursuit_retarget_elapsed += delta
if _pursuit_elapsed >= PROP_PURSUIT_BUDGET:
if _locomotion != null:
_locomotion.cancel_walk(false)
_end_prop_pursuit()
_finish_action(&"failed")
return
var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id)
if prop == null:
if _locomotion != null:
_locomotion.cancel_walk(false)
_end_prop_pursuit()
_finish_action(&"failed")
return
var prop_position: Vector2 = prop.global_position
if prop_position.distance_to(_pursuit_last_prop_position) < PROP_PURSUIT_MIN_SHIFT or _pursuit_retarget_elapsed < PROP_PURSUIT_RETARGET_INTERVAL:
return
var projected: Vector2 = _locomotion.project_target_to_navigation(prop_position + _pursuit_offset) if _locomotion != null else Vector2.INF
var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK)))
var retargeted: bool = false
if projected.is_finite() and _locomotion != null:
retargeted = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style)
if not projected.is_finite() or not retargeted:
if _locomotion != null:
_locomotion.cancel_walk(false)
_end_prop_pursuit()
_finish_action(&"failed")
return
_pursuit_last_prop_position = prop_position
_pursuit_approach = projected
_pursuit_retarget_elapsed = 0.0
_action_phase = ActionPhase.WALKING
func is_prop_pursuit_active() -> bool:
return not _pursuit_action_id.is_empty()
func speak(text: String, duration: float) -> void:
speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
@@ -134,111 +217,286 @@ func sync_facing(profile: int) -> void:
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())
var steps: Array[Dictionary] = []
for source_action: Dictionary in actions:
var action: Dictionary = source_action.duplicate(true)
action["action_id"] = _allocate_stable_action_id()
if ACTION_REGISTRY.is_move_action(action) and action.get("target", null) is Vector2:
action["destination"] = {"kind": "place", "position": action["target"]}
action.erase("target")
steps.append({"actions": [action]})
_authored_steps = steps
_sync_legacy_queue()
queue_changed.emit()
plan_changed.emit()
return true
func get_action_plan() -> Array[Dictionary]:
return _authored_steps.duplicate(true)
func set_action_plan(steps: Array[Dictionary]) -> bool:
if _session_started or not STAGE_ACTION_PLAN.validate_steps(steps).is_empty():
return false
_authored_steps = steps.duplicate(true)
_sync_legacy_queue()
queue_changed.emit()
plan_changed.emit()
return true
func find_action(action_id: String) -> Dictionary:
return STAGE_ACTION_PLAN.find_action(_authored_steps, action_id)
func get_plan_view() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for step_index: int in _authored_steps.size():
var actions_value: Variant = _authored_steps[step_index].get("actions", [])
if not actions_value is Array:
continue
for raw_action: Variant in actions_value:
if not raw_action is Dictionary:
continue
var action: Dictionary = raw_action as Dictionary
var action_id: String = String(action.get("action_id", ""))
result.append({
"step_index": step_index,
"action_id": action_id,
"action": action.duplicate(true),
"status": _status_for_stable_id(action_id),
"numeric_id": _numeric_for_stable_id(action_id),
})
return result
func add_plan_step(action: Dictionary) -> String:
if _session_started and not _paused:
return ""
var created: Dictionary = _prepare_new_plan_action(action)
var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_step(_authored_steps, created)
return String(created.get("action_id", "")) if _commit_plan_edit(next) else ""
func add_plan_action_together(step_index: int, action: Dictionary) -> String:
if _session_started and not _paused:
return ""
if _session_started and _paused and not _plan_step_is_future(step_index):
return ""
var created: Dictionary = _prepare_new_plan_action(action)
var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_together(_authored_steps, step_index, created)
return String(created.get("action_id", "")) if _commit_plan_edit(next) else ""
func replace_plan_action(action_id: String, action: Dictionary) -> bool:
if _session_started and not _paused:
return false
var status: StringName = _status_for_stable_id(action_id)
if _session_started and _paused and status in [&"succeeded", &"failed", &"cancelled"]:
return false
var next: Array[Dictionary] = STAGE_ACTION_PLAN.replace_action(_authored_steps, action_id, action)
if next.is_empty() and not _authored_steps.is_empty():
return false
if _session_started and _paused and status == &"active":
_cancel_stable_action(action_id)
_stable_status[action_id] = &"pending"
return _commit_plan_edit(next)
func remove_plan_action(action_id: String) -> bool:
if _session_started and not _paused:
return false
var location: Dictionary = find_action(action_id)
if location.is_empty():
return false
if _status_for_stable_id(action_id) == &"active":
_cancel_stable_action(action_id)
var next: Array[Dictionary] = STAGE_ACTION_PLAN.remove_action(_authored_steps, action_id)
_stable_status.erase(action_id)
return _commit_plan_edit(next, true)
func move_plan_step(from_index: int, to_index: int) -> bool:
if _session_started and not _paused:
return false
if _session_started and _paused and (not _plan_step_is_future(from_index) or not _plan_step_is_future(to_index)):
return false
var next: Array[Dictionary] = STAGE_ACTION_PLAN.move_step(_authored_steps, from_index, to_index)
return _commit_plan_edit(next) if from_index != to_index else true
func can_add_plan_action_together(step_index: int, action_type: String) -> bool:
if _session_started and _paused and not _plan_step_is_future(step_index):
return false
if _session_started and not _paused:
return false
return step_index >= 0 and step_index < _authored_steps.size() and STAGE_ACTION_PLAN.can_add_together(_authored_steps[step_index], action_type)
func can_reorder_plan_step(step_index: int) -> bool:
if step_index < 0 or step_index >= _authored_steps.size():
return false
if _session_started and not _paused:
return false
return not _session_started or _plan_step_is_future(step_index)
func _prepare_new_plan_action(source_action: Dictionary) -> Dictionary:
var action: Dictionary = source_action.duplicate(true)
action["action_id"] = _allocate_stable_action_id()
if ACTION_REGISTRY.is_move_action(action) and not action.has("destination") and action.get("target", null) is Vector2:
action["destination"] = {"kind": "place", "position": action["target"]}
action.erase("target")
return action
func _commit_plan_edit(next: Array[Dictionary], allow_empty: bool = false) -> bool:
if next.is_empty() and not allow_empty:
return false
if not STAGE_ACTION_PLAN.validate_steps(next).is_empty():
return false
_authored_steps = next.duplicate(true)
_sync_legacy_queue()
if _session_started:
_rebuild_pending_runtime_queue()
queue_changed.emit()
plan_changed.emit()
return true
func _sync_legacy_queue() -> void:
_authored_queue = STAGE_ACTION_PLAN.flatten(_authored_steps)
_action_ids.clear()
for action: Dictionary in _authored_queue:
var stable_id: String = String(action.get("action_id", ""))
if not _stable_to_numeric_ids.has(stable_id):
_stable_to_numeric_ids[stable_id] = _allocate_action_id()
_action_ids.append(int(_stable_to_numeric_ids[stable_id]))
func _allocate_stable_action_id() -> String:
var identity := STAGE_ACTION_IDENTITY.new() as StageActionIdentity
var world: Node2D = _rig.get_parent() as Node2D if _rig != null else null
return identity.allocate(world)
func _numeric_for_stable_id(action_id: String) -> int:
if not _stable_to_numeric_ids.has(action_id):
_stable_to_numeric_ids[action_id] = _allocate_action_id()
return int(_stable_to_numeric_ids[action_id])
func _status_for_stable_id(action_id: String) -> StringName:
if _stable_status.has(action_id):
return StringName(String(_stable_status[action_id]))
return &"pending" if _session_started else &"not_started"
func _plan_step_is_future(step_index: int) -> bool:
if step_index < 0 or step_index >= _authored_steps.size():
return false
var actions: Array = _authored_steps[step_index].get("actions", [])
for raw_action: Variant in actions:
if not raw_action is Dictionary:
return false
if _status_for_stable_id(String((raw_action as Dictionary).get("action_id", ""))) != &"pending":
return false
return true
func _cancel_stable_action(action_id: String) -> void:
if action_id == _current_stable_action_id:
_cancel_current_primary(true)
_stable_status[action_id] = &"cancelled"
elif action_id == _group_say_action_id:
_overlay_speech.clear()
_parallel_active_numeric_ids.erase(_group_say_numeric_id)
_stable_status[action_id] = &"cancelled"
planned_action_ended.emit(action_id, &"cancelled", _group_say_step_index)
_group_say_action_id = ""
_group_say_numeric_id = -1
_group_say_action.clear()
if _group_say_barrier:
_group_say_barrier = false
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
_current_stable_action_id = ""
_group_say_action_id = ""
_group_say_action.clear()
_overlay_speech.clear()
_authored_steps.clear()
_sync_legacy_queue()
_run_queue.clear()
_run_cursor = 0
if _session_started:
_runner_state = RunnerState.IDLE
queue_changed.emit()
plan_changed.emit()
func get_queue() -> Array[Dictionary]:
return action_queue.duplicate(true)
return _authored_queue.duplicate(true)
func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
if index < 0 or index >= _action_ids.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()
var stable_id: String = _stable_for_numeric_id(_action_ids[index])
if not stable_id.is_empty():
remove_plan_action(stable_id)
func insert_action(index: int, action: Dictionary) -> void:
if _session_started and not _paused:
return
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()
var step_target: int = _step_boundary_for_flat_index(target)
if _session_started and _paused and not _plan_step_is_future(step_target):
return
var created: Dictionary = _prepare_new_plan_action(action)
var next: Array[Dictionary] = _authored_steps.duplicate(true)
next.insert(clampi(step_target, 0, next.size()), {"actions": [created]})
_commit_plan_edit(next)
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
var stable_id: String = String(_authored_queue[index].get("action_id", ""))
var replacement: Dictionary = action.duplicate(true)
if ACTION_REGISTRY.is_move_action(replacement) and replacement.get("target", null) is Vector2:
replacement["destination"] = {"kind": "place", "position": replacement["target"]}
replacement.erase("target")
return replace_plan_action(stable_id, replacement)
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
var source_id: String = String(_authored_queue[from_index].get("action_id", ""))
var target_id: String = String(_authored_queue[to_index].get("action_id", ""))
var source: Dictionary = find_action(source_id)
var target: Dictionary = find_action(target_id)
if source.is_empty() or target.is_empty():
return false
return move_plan_step(int(source["step_index"]), int(target["step_index"]))
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})
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id, "stable_action_id": String(_authored_queue[index].get("action_id", "")), "step_index": int(_authored_queue[index].get("step_index", -1))})
return result
@@ -266,7 +524,10 @@ func clear_pause_state() -> void:
_paused = false
_completed_ids.clear()
_interrupted_ids.clear()
_stable_status.clear()
_parallel_active_numeric_ids.clear()
_current_action_id = -1
_current_stable_action_id = ""
_session_started = false
@@ -360,10 +621,17 @@ func stop_queue() -> void:
_action_phase = ActionPhase.NONE
_current_index = -1
_current_action_id = -1
_current_stable_action_id = ""
_current_step_index = -1
_active_step_entry.clear()
_group_say_action_id = ""
_group_say_action.clear()
_parallel_active_numeric_ids.clear()
_paused = false
_session_started = false
_completed_ids.clear()
_interrupted_ids.clear()
_stable_status.clear()
func is_queue_running() -> bool:
@@ -381,8 +649,12 @@ func _advance_to_next_action() -> void:
_current_index = -1
queue_finished.emit()
return
_current_run_item = _run_queue[_run_cursor]
var next_item: Dictionary = _run_queue[_run_cursor]
_run_cursor += 1
if next_item.has("actions"):
_start_plan_step(next_item)
return
_current_run_item = next_item
_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
@@ -418,6 +690,121 @@ func _advance_to_next_action() -> void:
_begin_action(action, legacy_speed_override)
func _start_plan_step(step_entry: Dictionary) -> void:
_active_step_entry = step_entry.duplicate(true)
_current_step_index = int(step_entry.get("step_index", -1))
_group_primary_done = false
_group_say_barrier = false
_group_say_action_id = ""
_group_say_numeric_id = -1
_group_say_action.clear()
var raw_actions: Array = step_entry.get("actions", [])
var primary_action: Dictionary = {}
var primary_id: String = ""
var primary_numeric_id: int = -1
var speech_started: bool = false
for action_index: int in raw_actions.size():
var raw_action: Variant = raw_actions[action_index]
if not raw_action is Dictionary:
continue
var authored_action: Dictionary = raw_action as Dictionary
var action_id: String = String(authored_action.get("action_id", ""))
var numeric_id: int = _numeric_for_stable_id(action_id)
var runtime_action: Dictionary = _runtime_action_for_plan(authored_action)
_stable_status[action_id] = &"active"
_parallel_active_numeric_ids[numeric_id] = true
var flat_index: int = _flat_index_for_action(action_id)
planned_action_started.emit(action_id, runtime_action.duplicate(true), _current_step_index)
action_started.emit(runtime_action.duplicate(true), flat_index)
if String(authored_action.get("type", "")) == "speak" and raw_actions.size() == 2:
speech_started = true
speak_overlay_action(runtime_action)
if bool(authored_action.get("legacy_nonblocking", false)):
_stable_status[action_id] = &"succeeded"
_completed_ids[numeric_id] = true
_parallel_active_numeric_ids.erase(numeric_id)
planned_action_ended.emit(action_id, &"succeeded", _current_step_index)
action_finished.emit(runtime_action.duplicate(true), flat_index)
else:
_group_say_action_id = action_id
_group_say_numeric_id = numeric_id
_group_say_step_index = _current_step_index
_group_say_action = runtime_action.duplicate(true)
_group_say_barrier = true
elif primary_action.is_empty():
primary_action = runtime_action
primary_id = action_id
primary_numeric_id = numeric_id
if primary_action.is_empty():
_complete_active_step()
return
_current_run_item = {
"action": primary_action.duplicate(true),
"queue_index": _flat_index_for_action(primary_id),
"action_id": primary_numeric_id,
"stable_action_id": primary_id,
"step_index": _current_step_index,
"reactive": false,
}
_current_action_id = primary_numeric_id
_current_stable_action_id = primary_id
_current_index = int(_current_run_item.get("queue_index", -1))
var action: Dictionary = primary_action.duplicate(true)
if ACTION_REGISTRY.is_move_action(action):
action["type"] = ACTION_REGISTRY.MOVE_TO
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
action["style"] = style if not style.is_empty() else ACTION_REGISTRY.WALK
_begin_action(action)
if not speech_started and String(primary_action.get("type", "")) == "speak":
# Singleton Say is executed by the primary speech state, not an overlay.
_group_say_action_id = ""
func _runtime_action_for_plan(action: Dictionary) -> Dictionary:
var runtime_action: Dictionary = action.duplicate(true)
if ACTION_REGISTRY.is_move_action(runtime_action):
var destination_value: Variant = runtime_action.get("destination", {})
if destination_value is Dictionary:
var destination: Dictionary = destination_value
var point_value: Variant = destination.get("position", destination.get("last_known_approach", Vector2.ZERO))
runtime_action["target"] = point_value
return runtime_action
func _complete_active_step() -> void:
_active_step_entry.clear()
_current_step_index = -1
_group_primary_done = false
_group_say_barrier = false
_action_phase = ActionPhase.NONE
func _flat_index_for_action(action_id: String) -> int:
for index: int in _authored_queue.size():
if String(_authored_queue[index].get("action_id", "")) == action_id:
return index
return -1
func _stable_for_numeric_id(numeric_id: int) -> String:
for stable_id: Variant in _stable_to_numeric_ids:
if int(_stable_to_numeric_ids[stable_id]) == numeric_id:
return String(stable_id)
return ""
func _step_boundary_for_flat_index(flat_index: int) -> int:
var cursor: int = 0
for step_index: int in _authored_steps.size():
var actions: Array = _authored_steps[step_index].get("actions", [])
if flat_index <= cursor:
return step_index
if flat_index < cursor + actions.size():
return step_index + 1
cursor += actions.size()
return _authored_steps.size()
func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> void:
match String(action.get("type", "")):
"move_to":
@@ -425,8 +812,15 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty():
style = ACTION_REGISTRY.WALK
if _locomotion == null or not _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override):
_finish_action()
var destination_value: Variant = action.get("destination", {})
var move_started: bool = false
if destination_value is Dictionary and String((destination_value as Dictionary).get("kind", "")) == "prop":
move_started = _start_prop_pursuit(action, destination_value as Dictionary, style, legacy_speed_override)
elif _locomotion != null:
move_started = _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override)
if not move_started:
_end_prop_pursuit()
_finish_action(&"failed")
"speak":
if bool(action.get("keep_acting", false)):
speak_overlay_action(action)
@@ -445,34 +839,142 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
recovery_requested.emit()
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop", "use_prop":
if _rig == null or not _rig.start_prop_action(action):
_finish_action()
_finish_action(&"failed")
else:
_prop_action_finished = false
_action_phase = ActionPhase.PROP_INTERACTION
_:
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
_finish_action(&"failed")
func _finish_action() -> void:
func _start_prop_pursuit(action: Dictionary, destination: Dictionary, style: String, speed_override: float) -> bool:
var prop_id_value: Variant = destination.get("target_prop_id", null)
var offset_value: Variant = destination.get("approach_offset", null)
if not prop_id_value is String or not offset_value is Vector2 or not (offset_value as Vector2).is_finite():
return false
var prop: PropBlock = _resolve_pursuit_prop(String(prop_id_value))
if prop == null or _locomotion == null:
return false
var approach: Vector2 = prop.global_position + (offset_value as Vector2)
var projected: Vector2 = _locomotion.project_target_to_navigation(approach)
if not projected.is_finite():
return false
_pursuit_action_id = String(action.get("action_id", _current_stable_action_id))
_pursuit_prop_id = String(prop_id_value)
_pursuit_offset = offset_value as Vector2
_pursuit_last_prop_position = prop.global_position
_pursuit_approach = projected
_pursuit_elapsed = 0.0
_pursuit_retarget_elapsed = 0.0
return _locomotion.move_to(projected, style, speed_override)
func _resolve_pursuit_prop(prop_id: String) -> PropBlock:
if prop_id.is_empty() or _rig == null or not is_instance_valid(_rig):
return null
var world: Node = _rig.get_parent()
if world == null:
return null
for child: Node in world.get_children():
if child is PropBlock and not child.is_queued_for_deletion() and String((child as PropBlock).stage_object_id) == prop_id:
return child as PropBlock
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == prop_id:
return null
return null
func _check_prop_pursuit_arrival() -> void:
var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id)
if prop == null or _locomotion == null or _rig == null:
_end_prop_pursuit()
_finish_action(&"failed")
return
var projected: Vector2 = _locomotion.project_target_to_navigation(prop.global_position + _pursuit_offset)
if not projected.is_finite():
_end_prop_pursuit()
_finish_action(&"failed")
return
var actor_feet: Vector2 = _rig.global_position - _locomotion.get_foot_offset()
if actor_feet.distance_to(projected) <= 28.0 and actor_feet.distance_to(prop.global_position) <= PROP_INTERACTION_REACH:
_end_prop_pursuit()
_finish_action(&"succeeded")
return
if actor_feet.distance_to(projected) > 28.0:
var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK)))
var restarted: bool = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style)
if restarted:
_pursuit_approach = projected
_pursuit_last_prop_position = prop.global_position
_pursuit_retarget_elapsed = 0.0
_action_phase = ActionPhase.WALKING
return
_end_prop_pursuit()
_finish_action(&"failed")
return
_locomotion.cancel_walk(false)
_pursuit_approach = projected
_pursuit_last_prop_position = prop.global_position
_pursuit_retarget_elapsed = 0.0
_action_phase = ActionPhase.PURSUIT_WAIT
func _end_prop_pursuit() -> void:
_pursuit_action_id = ""
_pursuit_prop_id = ""
_pursuit_offset = Vector2.ZERO
_pursuit_last_prop_position = Vector2.ZERO
_pursuit_approach = Vector2.INF
_pursuit_elapsed = 0.0
_pursuit_retarget_elapsed = 0.0
func _finish_action(outcome: StringName = &"succeeded") -> 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))
var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id))
if stable_id == _pursuit_action_id:
_end_prop_pursuit()
if action_id >= 0:
_completed_ids[action_id] = true
index = _index_for_action_id(action_id) if action_id >= 0 else -1
if outcome == &"cancelled":
_interrupted_ids[action_id] = true
else:
_completed_ids[action_id] = true
if not stable_id.is_empty():
_stable_status[stable_id] = outcome
_parallel_active_numeric_ids.erase(action_id)
planned_action_ended.emit(stable_id, outcome, int(_current_run_item.get("step_index", _current_step_index)))
index = _flat_index_for_action(stable_id) if not stable_id.is_empty() else (_index_for_action_id(action_id) if action_id >= 0 else -1)
_action_phase = ActionPhase.NONE
if not finished.is_empty():
action_finished.emit(finished, index)
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
if not finished.is_empty():
action_finished.emit(finished, index)
_current_stable_action_id = ""
if not _active_step_entry.is_empty():
_group_primary_done = true
if _group_say_barrier and not _group_say_action_id.is_empty():
_action_phase = ActionPhase.GROUP_BARRIER
else:
_complete_active_step()
func _cancel_current_primary(notify: bool = false) -> void:
var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id))
var step_index: int = int(_current_run_item.get("step_index", _current_step_index))
var numeric_id: int = int(_current_run_item.get("action_id", _current_action_id))
if stable_id == _pursuit_action_id:
_end_prop_pursuit()
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 not stable_id.is_empty():
_stable_status[stable_id] = &"cancelled"
_parallel_active_numeric_ids.erase(numeric_id)
_interrupted_ids[numeric_id] = true
planned_action_ended.emit(stable_id, &"cancelled", step_index)
if _locomotion != null:
_locomotion.cancel_walk(not _is_blocked())
if _rig != null:
@@ -484,6 +986,7 @@ func _cancel_current_primary(notify: bool = false) -> void:
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
_current_stable_action_id = ""
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
@@ -502,13 +1005,25 @@ 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())
_stable_status.clear()
_parallel_active_numeric_ids.clear()
for action: Dictionary in _authored_queue:
_stable_status[String(action.get("action_id", ""))] = &"pending"
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 _runtime_step_item(step_index: int, step: Dictionary) -> Dictionary:
var actions: Array[Dictionary] = []
var stable_ids: Array[String] = []
var numeric_ids: Array[int] = []
for raw_action: Variant in step.get("actions", []):
if not raw_action is Dictionary:
continue
var action: Dictionary = raw_action as Dictionary
var stable_id: String = String(action.get("action_id", ""))
actions.append(action.duplicate(true))
stable_ids.append(stable_id)
numeric_ids.append(_numeric_for_stable_id(stable_id))
return {"step_index": step_index, "actions": actions, "stable_ids": stable_ids, "numeric_ids": numeric_ids, "reactive": false}
func _rebuild_pending_runtime_queue() -> void:
@@ -518,11 +1033,19 @@ func _rebuild_pending_runtime_queue() -> void:
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))
for step_index: int in _authored_steps.size():
var step: Dictionary = _authored_steps[step_index]
var all_pending: bool = true
for raw_action: Variant in step.get("actions", []):
if not raw_action is Dictionary:
all_pending = false
break
var status: StringName = _status_for_stable_id(String((raw_action as Dictionary).get("action_id", "")))
if status not in [&"pending", &"not_started"]:
all_pending = false
break
if all_pending:
rebuilt.append(_runtime_step_item(step_index, step))
_run_queue = retained_reactive + rebuilt
_run_cursor = 0
if not _run_queue.is_empty() and _session_started:
@@ -545,15 +1068,14 @@ func _index_for_action_id(action_id: int) -> int:
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
var stable_id: String = _stable_for_numeric_id(action_id)
var status: StringName = _status_for_stable_id(stable_id) if not stable_id.is_empty() else &"not_started"
match status:
&"succeeded", &"failed": return QueueStatus.COMPLETED
&"cancelled": return QueueStatus.INTERRUPTED
&"active": return QueueStatus.ACTIVE
&"pending": return QueueStatus.PENDING
_: return QueueStatus.NOT_STARTED
func _is_blocked() -> bool:
@@ -565,6 +1087,7 @@ func _update_speech(delta: float) -> void:
_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()
_finish_group_speech()
speech_finished.emit()
elif not _primary_speech.is_empty() and not _is_blocked():
_primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta
@@ -578,6 +1101,25 @@ func _update_speech(delta: float) -> void:
_show_visible_speech()
func _finish_group_speech() -> void:
if _group_say_action_id.is_empty():
return
var action_id: String = _group_say_action_id
var numeric_id: int = _group_say_numeric_id
var step_index: int = _group_say_step_index
var action: Dictionary = _group_say_action.duplicate(true)
_stable_status[action_id] = &"succeeded"
_completed_ids[numeric_id] = true
_parallel_active_numeric_ids.erase(numeric_id)
planned_action_ended.emit(action_id, &"succeeded", step_index)
action_finished.emit(action, _flat_index_for_action(action_id))
_group_say_action_id = ""
_group_say_numeric_id = -1
_group_say_step_index = -1
_group_say_action.clear()
_group_say_barrier = false
func _show_visible_speech() -> void:
if _rig == null:
return
@@ -161,6 +161,7 @@ var collision_lengths: Dictionary = {}
var collision_proxy_velocities: Dictionary = {}
var _collision_proxy_positions: Dictionary = {}
var _last_sweep_hit_tick: Dictionary = {}
var _owner_collision_grace_props: Dictionary = {}
var _actor_prop_debug_last_sample_frame: int = -1
var _actor_prop_debug_last_hit_tick: Dictionary = {}
var _collision_visuals: Dictionary = {}
@@ -196,6 +197,7 @@ func setup_collision_bodies() -> void:
collision_proxy_velocities.clear()
_collision_proxy_positions.clear()
_last_sweep_hit_tick.clear()
_owner_collision_grace_props.clear()
_collision_visuals.clear()
reaction_bones.clear()
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
@@ -470,6 +472,34 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
_walk_latch_probe_frames = 0
return true
func project_target_to_navigation(target: Vector2) -> Vector2:
if _nav_agent == null or not target.is_finite():
return Vector2.INF
var navigation_map: RID = _nav_agent.get_navigation_map()
if not navigation_map.is_valid() or NavigationServer2D.map_get_iteration_id(navigation_map) == 0:
return Vector2.INF
var projected: Vector2 = NavigationServer2D.map_get_closest_point(navigation_map, target)
return projected if projected.is_finite() else Vector2.INF
func retarget_walk(target: Vector2) -> bool:
if _rig == null or _nav_agent == null or not target.is_finite() or (not _walking and not _settling):
return false
_walk_target_feet = target
_nav_agent.target_position = target
if _settling:
_settling = false
_walk_done = false
_arrival_emitted = false
_walk_settle_elapsed = 0.0
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
_walking = true
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation(_active_animation_name):
_rig._anim_player.play(_active_animation_name)
return true
func cancel_walk(restore_idle_pose: bool = true) -> void:
if _rig == null:
return
@@ -624,6 +654,15 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
for own_proxy: AnimatableBody2D in collision_bodies.values():
if own_proxy != null and is_instance_valid(own_proxy):
excluded.append(own_proxy.get_rid())
for prop_id: Variant in _owner_collision_grace_props.keys():
var prop_ref: WeakRef = _owner_collision_grace_props.get(prop_id) as WeakRef
var grace_prop: PropBlock = prop_ref.get_ref() as PropBlock if prop_ref != null else null
if grace_prop == null or not is_instance_valid(grace_prop) or not grace_prop.is_owner_collision_grace_active_for(_rig):
_owner_collision_grace_props.erase(prop_id)
continue
var grace_prop_rid: RID = grace_prop.get_rid()
if grace_prop_rid.is_valid() and not excluded.has(grace_prop_rid):
excluded.append(grace_prop_rid)
var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null
if held != null and is_instance_valid(held):
excluded.append(held.get_rid())
@@ -779,6 +818,18 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
return {"fraction": safe_fraction}
func register_owner_collision_grace_prop(prop: PropBlock) -> void:
if prop == null or not is_instance_valid(prop):
return
_owner_collision_grace_props[prop.get_instance_id()] = weakref(prop)
func unregister_owner_collision_grace_prop(prop: PropBlock) -> void:
if prop == null:
return
_owner_collision_grace_props.erase(prop.get_instance_id())
func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock:
# PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than
# the `collider` object key used by ray/shape intersection queries.
@@ -955,6 +955,7 @@ func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary)
_hand = int(picked["hand"])
_held_prop = prop
_held_prop.interaction_reserved_by = _rig.get_instance_id()
_held_prop.begin_owner_collision_grace(_rig, true)
_active_action = action.duplicate(true)
_approach_replans = 0
var marker := _hand_marker()
@@ -1028,7 +1029,7 @@ func _begin_set_down(action: Dictionary) -> bool:
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
var target_feet := _set_down_approach_root - foot_offset
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.RUN)):
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.WALK)):
_active_action.clear()
prop_event.emit("set_down_failed", _held_prop, "Too far to reach")
return false
@@ -1930,12 +1931,18 @@ func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vec
release_velocity = Vector2.ZERO
if not is_finite(release_angular):
release_angular = 0.0
if reason == "scene_reset":
prop.clear_owner_collision_grace()
elif _rig != null and is_instance_valid(_rig) and prop.interaction_reserved_by == _rig.get_instance_id():
prop.release_owner_collision_grace(_rig)
prop.interaction_owner_id = 0
prop.interaction_reserved_by = 0
var target_role := PropBlock.RuntimeRole.RELEASED
if reason == "scene_reset":
target_role = PropBlock.RuntimeRole.START_PHYSICS if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_PHYSICS else PropBlock.RuntimeRole.START_STILL
prop.apply_runtime_role(target_role)
if reason == "set_down" and _rig != null and is_instance_valid(_rig):
prop.begin_owner_collision_grace(_rig)
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = release_angular
@@ -1983,6 +1990,7 @@ func _clear_reservation() -> void:
if _held_prop != null and is_instance_valid(_held_prop):
if _held_prop.interaction_reserved_by == _rig.get_instance_id():
_held_prop.interaction_reserved_by = 0
_held_prop.release_owner_collision_grace(_rig)
_held_prop = null
_held_anchor.clear()
_active_action.clear()
+68 -2
View File
@@ -182,6 +182,9 @@ signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
signal queue_changed # any queue mutation
signal action_plan_changed
signal planned_action_started(action_id: String, action: Dictionary, step_index: int)
signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)
signal speech_finished # bubble auto-hid after speak()
signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String)
signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)
@@ -215,6 +218,8 @@ var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lo
# ---------------------------------------------------------------------------
var _pending_action_queue: Array[Dictionary] = []
var _pending_action_plan: Array[Dictionary] = []
var _has_pending_action_plan: bool = false
var action_queue: Array[Dictionary]:
get:
return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue
@@ -262,7 +267,10 @@ func _ready() -> void:
push_warning("StickmanRig: missing RigComponents/PropInteractor; prop interactions unavailable.")
if _locomotion_component != null:
_locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
_locomotion_component.arrived.connect(func(target: Vector2) -> void:
if _action_runner_component == null or not _action_runner_component.is_prop_pursuit_active():
arrived.emit(target)
)
_locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit)
if _ragdoll_component != null:
_ragdoll_component.configure(self, _locomotion_component)
@@ -275,11 +283,17 @@ func _ready() -> void:
_prop_interactor_component.connect("use_failed", _on_prop_use_failed)
if _action_runner_component != null:
_action_runner_component.configure(self, _locomotion_component, _prop_interactor_component)
_action_runner_component.action_queue = _pending_action_queue
if _has_pending_action_plan:
_action_runner_component.set_action_plan(_pending_action_plan)
else:
_action_runner_component.action_queue = _pending_action_queue
_action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index))
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
_action_runner_component.queue_finished.connect(func() -> void: queue_finished.emit())
_action_runner_component.queue_changed.connect(func() -> void: queue_changed.emit())
_action_runner_component.plan_changed.connect(func() -> void: action_plan_changed.emit())
_action_runner_component.planned_action_started.connect(func(action_id: String, action: Dictionary, step_index: int) -> void: planned_action_started.emit(action_id, action, step_index))
_action_runner_component.planned_action_ended.connect(func(action_id: String, outcome: StringName, step_index: int) -> void: planned_action_ended.emit(action_id, outcome, step_index))
_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())
@@ -336,6 +350,8 @@ func _physics_process(delta: float) -> void:
_sample_ragdoll_velocity_baselines()
if _ragdoll_component != null:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _action_runner_component != null:
_action_runner_component.pre_movement(delta)
if _locomotion_component != null:
_locomotion_component.tick_movement(delta, int(state))
if _prop_interactor_component != null:
@@ -947,6 +963,56 @@ func get_queue() -> Array[Dictionary]:
return _action_runner_component.get_queue() if _action_runner_component != null else _pending_action_queue.duplicate(true)
func get_action_plan() -> Array[Dictionary]:
if _action_runner_component != null:
return _action_runner_component.get_action_plan()
return _pending_action_plan.duplicate(true)
func set_action_plan(steps: Array[Dictionary]) -> bool:
if _action_runner_component != null:
return _action_runner_component.set_action_plan(steps)
_pending_action_plan = steps.duplicate(true)
_has_pending_action_plan = true
return true
func find_action(action_id: String) -> Dictionary:
return _action_runner_component.find_action(action_id) if _action_runner_component != null else {}
func get_plan_view() -> Array[Dictionary]:
return _action_runner_component.get_plan_view() if _action_runner_component != null else []
func add_action_step(action: Dictionary) -> String:
return _action_runner_component.add_plan_step(action) if _action_runner_component != null else ""
func add_action_together(step_index: int, action: Dictionary) -> String:
return _action_runner_component.add_plan_action_together(step_index, action) if _action_runner_component != null else ""
func replace_planned_action(action_id: String, action: Dictionary) -> bool:
return _action_runner_component.replace_plan_action(action_id, action) if _action_runner_component != null else false
func remove_planned_action(action_id: String) -> bool:
return _action_runner_component.remove_plan_action(action_id) if _action_runner_component != null else false
func move_plan_step(from_index: int, to_index: int) -> bool:
return _action_runner_component.move_plan_step(from_index, to_index) if _action_runner_component != null else false
func can_add_action_together(step_index: int, action_type: String) -> bool:
return _action_runner_component.can_add_plan_action_together(step_index, action_type) if _action_runner_component != null else false
func can_reorder_plan_step(step_index: int) -> bool:
return _action_runner_component.can_reorder_plan_step(step_index) if _action_runner_component != null else false
func remove_action(index: int) -> void:
if _action_runner_component != null:
_action_runner_component.remove_action(index)