feat: Implement Live Director System for interactive simulation control

- Added waypoint context updates to handle action queue statuses in `waypoint_context.gd`.
- Introduced new scene file `manwalk.scene` with terrain and stickman setup.
- Created architecture summary for the Live Director System detailing core state transitions, physics mechanics, UI elements, and action execution.
- Specified class contracts and interfaces for the Live Director implementation in `phase_12_live_director.md`.
- Defined state transitions, input handling, and queue editing mechanics for the Live Director feature.
This commit is contained in:
2026-09-24 15:25:10 -04:00
parent 0a7f97cec7
commit 730a9a966b
19 changed files with 1633 additions and 168 deletions
+172 -6
View File
@@ -213,9 +213,9 @@ var action_queue: Array[Dictionary]:
return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue
set(value):
if _action_runner_component != null:
_action_runner_component.action_queue = value
_action_runner_component.set_authored_queue(value)
else:
_pending_action_queue = value
_pending_action_queue = value.duplicate(true)
## Original .stk asset path, assigned by StageSpawner for scene persistence.
var source_stk_path: String = ""
@@ -225,6 +225,10 @@ var source_stk_path: String = ""
var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null
var _action_runner_component: StickmanActionRunner = null
var _simulation_paused: bool = false
var _phase12_paused_animation: bool = false
var _phase12_paused_animation_name: StringName = &""
var _phase12_paused_animation_position: float = 0.0
# ---------------------------------------------------------------------------
# Lifecycle
@@ -257,6 +261,7 @@ func _ready() -> void:
_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())
_action_runner_component.active_action_cancelled.connect(_on_active_action_cancelled)
# Resolve all runtime node references.
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
@@ -304,6 +309,8 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if _simulation_paused:
return
if _ragdoll_component != null:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _locomotion_component != null:
@@ -485,6 +492,12 @@ func request_recovery() -> void:
func snap_to_standing() -> void:
if _ragdoll_component != null:
_ragdoll_component.snap_to_standing(int(state))
_simulation_paused = false
_phase12_paused_animation = false
if _action_runner_component != null:
_action_runner_component.clear_pause_state()
if _ragdoll_component != null:
_ragdoll_component.clear_pause_snapshot()
## Restores the spawn-marker pose through the ragdoll component. Kept on the
@@ -751,11 +764,17 @@ func _sync_speech_bubble_mirror() -> void:
# ---------------------------------------------------------------------------
func queue_action(action: Dictionary) -> void:
if _action_runner_component != null: _action_runner_component.queue_action(action)
if _action_runner_component != null:
insert_action(queue_size(), action)
else:
_pending_action_queue.append(action.duplicate(true))
func clear_queue() -> void:
if _action_runner_component != null: _action_runner_component.clear_queue()
if _action_runner_component != null:
_action_runner_component.clear_queue()
else:
_pending_action_queue.clear()
func get_queue() -> Array[Dictionary]:
@@ -763,11 +782,158 @@ func get_queue() -> Array[Dictionary]:
func remove_action(index: int) -> void:
if _action_runner_component != null: _action_runner_component.remove_action(index)
if _action_runner_component != null:
_action_runner_component.remove_action(index)
elif index >= 0 and index < _pending_action_queue.size():
_pending_action_queue.remove_at(index)
func insert_action(index: int, action: Dictionary) -> void:
if _action_runner_component != null: _action_runner_component.insert_action(index, action)
if _action_runner_component != null:
var normalized: Dictionary = _with_recover_barrier(action)
if is_ragdolled() and String(normalized.get("type", "")) == "walk_to":
var queue: Array[Dictionary] = get_queue()
var at: int = clampi(index, 0, queue.size())
var has_recover_barrier: bool = false
if at > 0 and String(queue[at - 1].get("type", "")) == "recover":
var previous_status: int = get_action_status(action_id_at(at - 1))
has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE
if not has_recover_barrier:
_action_runner_component.insert_action(at, {"type": "recover"})
at += 1
_action_runner_component.insert_action(at, normalized)
else:
_action_runner_component.insert_action(index, normalized)
else:
_pending_action_queue.insert(clampi(index, 0, _pending_action_queue.size()), action.duplicate(true))
func replace_action(index: int, action: Dictionary) -> bool:
if _action_runner_component == null:
return false
if is_ragdolled() and String(action.get("type", "")) == "walk_to":
var queue: Array[Dictionary] = get_queue()
var has_recover_barrier: bool = false
if index > 0 and index < queue.size() and String(queue[index - 1].get("type", "")) == "recover":
var previous_status: int = get_action_status(action_id_at(index - 1))
has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE
if has_recover_barrier:
return _action_runner_component.replace_action(index, action.duplicate(true))
var replaced: bool = _action_runner_component.replace_action(index, {"type": "recover"})
if not replaced:
return false
_action_runner_component.insert_action(index + 1, action.duplicate(true))
return true
return _action_runner_component.replace_action(index, _with_recover_barrier(action))
func move_action(from_index: int, to_index: int) -> bool:
return _action_runner_component.move_action(from_index, to_index) if _action_runner_component != null else false
func get_queue_view() -> Array[Dictionary]:
return _action_runner_component.get_queue_view() if _action_runner_component != null else []
func action_id_at(index: int) -> int:
return _action_runner_component.action_id_at(index) if _action_runner_component != null else -1
func index_for_action_id(action_id: int) -> int:
return _action_runner_component.index_for_action_id(action_id) if _action_runner_component != null else -1
func get_action_status(action_id: int) -> int:
return _action_runner_component.get_action_status(action_id) if _action_runner_component != null else StickmanActionRunner.QueueStatus.NOT_STARTED
func pause_simulation() -> void:
if _simulation_paused:
return
_simulation_paused = true
if _action_runner_component != null:
_action_runner_component.pause_simulation()
if _ragdoll_component != null:
_ragdoll_component.pause_simulation()
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.is_playing():
_phase12_paused_animation_name = _anim_player.current_animation
_phase12_paused_animation_position = _anim_player.get_current_animation_position()
_phase12_paused_animation = true
_anim_player.pause()
func resume_simulation() -> void:
if not _simulation_paused:
return
if _ragdoll_component != null:
_ragdoll_component.resume_simulation()
if _action_runner_component != null:
_action_runner_component.resume_simulation()
_simulation_paused = false
if _phase12_paused_animation and _anim_player != null and is_instance_valid(_anim_player):
if _anim_player.current_animation == _phase12_paused_animation_name:
_anim_player.play(_phase12_paused_animation_name)
_anim_player.seek(_phase12_paused_animation_position, true)
_phase12_paused_animation = false
_phase12_paused_animation_name = &""
_phase12_paused_animation_position = 0.0
if _locomotion_component != null:
_locomotion_component.ensure_grounded_walk_animation()
func reset_simulation_pause() -> void:
if _ragdoll_component != null:
_ragdoll_component.clear_pause_snapshot()
if _action_runner_component != null:
_action_runner_component.clear_pause_state()
_simulation_paused = false
_phase12_paused_animation = false
_phase12_paused_animation_name = &""
_phase12_paused_animation_position = 0.0
func get_director_anchor() -> Dictionary:
var bounds: Rect2 = _ragdoll_component.get_world_body_bounds() if _ragdoll_component != null and state == RigState.RAGDOLL else Rect2()
var feet: Vector2 = global_position - StickmanLocomotion.FOOT_OFFSET
var airborne: bool = _locomotion_component != null and _locomotion_component.terrain_motion == StickmanLocomotion.TerrainMotion.AIRBORNE
if state == RigState.RAGDOLL and not bounds.size.is_zero_approx():
feet = Vector2(bounds.get_center().x, bounds.end.y)
var probe_origin: Vector2 = Vector2(bounds.get_center().x, feet.y) if state == RigState.RAGDOLL and not bounds.size.is_zero_approx() else feet
var query := PhysicsRayQueryParameters2D.create(probe_origin + Vector2.UP * 40.0, probe_origin + Vector2.DOWN * 1800.0)
query.collision_mask = 0x7FFFFFFF
query.collide_with_areas = false
var excluded: Array[RID] = []
_collect_nonterrain_rids(get_parent(), excluded)
query.exclude = excluded
var hit: Dictionary = get_world_2d().direct_space_state.intersect_ray(query)
var has_ground: bool = not hit.is_empty()
return {"feet": feet, "ground": hit.get("position", feet), "has_ground": has_ground, "airborne": airborne or state == RigState.RAGDOLL}
func is_ragdolled() -> bool:
return state == RigState.RAGDOLL
func _on_active_action_cancelled(action: Dictionary) -> void:
if String(action.get("type", "")) == "recover" and state == RigState.RECOVERING and _ragdoll_component != null:
_ragdoll_component.interrupt_recovery_to_ragdoll()
func _with_recover_barrier(action: Dictionary) -> Dictionary:
return action.duplicate(true)
func _collect_nonterrain_rids(node: Node, out: Array[RID]) -> void:
if node == null:
return
if node is TerrainBlock:
return
if node is CollisionObject2D:
var rid: RID = (node as CollisionObject2D).get_rid()
if rid.is_valid() and not out.has(rid):
out.append(rid)
for child: Node in node.get_children():
_collect_nonterrain_rids(child, out)
func queue_size() -> int: