Implement Phase 8: Rig Relocation and Component Modularization
- Moved the rig scene from `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn`, preserving its UID. - Split the `StickmanRig` controller into three components: `StickmanLocomotion`, `StickmanRagdoll`, and `StickmanActionRunner`. - Updated the architecture to ensure `StickmanRig` acts as the public facade for the new components. - Created a new test suite `test_phase8_rig_modularization.gd` to verify the integrity of the relocated rig and its components. - Added checks for scene loading, component resolution, action queue delegation, and state authority.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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", "")))
|
||||
Reference in New Issue
Block a user