Add Phase 3a Core Director Functionality

- Introduced `PHASE_3a_CORE_DIRECTOR.md` detailing the core functionality for directors, including navigation, action queue, UI, waypoint visualization, and action execution.
- Implemented `StageDirectorVisuals` for drawing stickman action queues in edit mode, including waypoints and action badges.
- Created `SpeechBubble` class for displaying speech bubbles above stickmen, with customizable text and styling.
This commit is contained in:
2026-08-30 00:29:30 -04:00
parent badff571f0
commit bf11a5fab5
14 changed files with 2030 additions and 23 deletions
+422 -9
View File
@@ -25,6 +25,12 @@ enum BendDirection { NORMAL, INVERTED }
## IK targets to the standing pose before returning to ANIMATED.
enum RigState { ANIMATED, RAGDOLL, RECOVERING }
## Director action-runner execution state (Phase 3a).
enum RunnerState { IDLE, EXECUTING }
## Which kind of queued action is currently executing (Phase 3a).
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@@ -142,6 +148,33 @@ const IK_TARGET_PATHS: Dictionary = {
"Right_Leg": "IK_Targets/Right_Leg",
}
# ---------------------------------------------------------------------------
# Director / navigation constants (Phase 3a)
# ---------------------------------------------------------------------------
## Feet -> root translation (a ground point -> the hip/root position).
const FOOT_OFFSET := Vector2(0.0, -385.0)
## The navigation agent sits at the feet, on the nav mesh.
const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0)
## Fallback arrival distance (px) to the root destination.
const ARRIVE_DISTANCE := 8.0
const NAV_PATH_DESIRED_DISTANCE := 8.0
const NAV_TARGET_DESIRED_DISTANCE := 12.0
## Rig-local anchor for the speech bubble, above the head.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
if DEBUG_WALK:
print("[walk] ", msg)
## Preloaded (not a class_name type) so this script compiles even when the
## editor's global class cache is stale.
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
## Ragdoll body definitions, ordered parent-before-child. `node_path` is
## Skeleton2D-relative for bones and rig-root-relative for the head visual.
## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
@@ -216,6 +249,10 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [
## ragdoll stays down until request_recovery() is called manually.
@export var auto_recover: bool = true
@export_group("Director")
## Walk speed (px/s) used by walk_to when no per-action speed is given.
@export var walk_speed: float = 300.0
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
@@ -231,6 +268,17 @@ signal bend_flag_changed(joint: String, flipped: bool)
## the RigState enum value.
signal state_changed(new_state: int)
# ---------------------------------------------------------------------------
# Director signals (Phase 3a)
# ---------------------------------------------------------------------------
signal arrived # walk_to reached its destination
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 speech_finished # bubble auto-hid after speak()
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
@@ -265,6 +313,38 @@ var _stabilize_timer: float = 0.0
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
var _stand_up_tween: Tween = null
# ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a)
# ---------------------------------------------------------------------------
var action_queue: Array[Dictionary] = []
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1
var _stop_requested: bool = false
var _nav_agent: NavigationAgent2D = null
## NavigationAgent2D is a plain Node (not Node2D): it has no `position`; it
## derives its agent position from its parent Node2D's global position. This
## anchor sits at the feet (NAV_AGENT_LOCAL_POS) so the agent paths from
## ground level.
var _nav_anchor: Node2D = null
var _walking: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line)
var _walk_done: bool = false
var _ragdoll_at_rest: bool = false
var _speech_bubble = null # SpeechBubble (preloaded script)
var _speech_active: bool = false
var _speech_time_left: float = 0.0
var _phase_timer: float = 0.0
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -305,6 +385,25 @@ func _ready() -> void:
# setters only stored values).
_nodes_ready = true
_apply_profile()
# Build the navigation agent at the feet (Phase 3a). It shares the default
# navigation map/layer with the stage's NavigationRegion2D.
# NavigationAgent2D is a plain Node: it has no `position`; it derives its
# agent position from its parent Node2D's global position, so we anchor it
# under a Node2D placed at the feet.
_nav_anchor = Node2D.new()
_nav_anchor.name = "NavigationAgentAnchor"
_nav_anchor.position = NAV_AGENT_LOCAL_POS
add_child(_nav_anchor)
_nav_agent = NavigationAgent2D.new()
_nav_agent.name = "NavigationAgent2D"
_nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE
_nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE
_nav_agent.path_max_distance = 100.0
_nav_agent.max_speed = walk_speed
_nav_agent.avoidance_enabled = false
_nav_anchor.add_child(_nav_agent)
_prev_global_pos = global_position
_prev_global_rot = global_rotation
@@ -312,6 +411,9 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_update_walking(delta)
_update_speech(delta)
_update_runner(delta)
func _track_momentum(delta: float) -> void:
@@ -340,6 +442,9 @@ func _update_rest_detection(delta: float) -> void:
_rest_timer = 0.0
_stabilize_timer = 0.0
return
# Publish rest regardless of auto_recover (the director runner polls it).
if not _ragdoll_at_rest:
_ragdoll_at_rest = true
if not auto_recover:
_rest_timer = 0.0
return
@@ -446,15 +551,7 @@ func snap_to_standing() -> void:
_destroy_ragdoll()
else:
_cancel_recovery()
# Set the IK targets directly to the standing pose (no tween).
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
_restore_standing_markers()
# Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
@@ -465,6 +562,18 @@ func snap_to_standing() -> void:
state_changed.emit(int(state))
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
func _restore_standing_markers() -> void:
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
## Applies the same velocity delta to every ragdoll body via a mass-scaled
## central impulse, preserving the ragdoll's internal structure. No-op outside
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
@@ -570,6 +679,8 @@ func _enter_ragdoll() -> void:
if _skeleton == null or _body_container == null:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return
_cancel_walking()
_ragdoll_at_rest = false
# Instant handoff: stop the player without resetting it (keep_state) and
# build the ragdoll from the CURRENT solved bone positions while the IK
# stack is still enabled (disabling it first would revert the bones to the
@@ -911,3 +1022,305 @@ func _destroy_ragdoll() -> void:
_ragdoll_root.queue_free()
_ragdoll_root = null
_ragdoll_bodies.clear()
# ---------------------------------------------------------------------------
# Navigation / walking (Phase 3a)
# ---------------------------------------------------------------------------
## Start walking so the feet land at `target` (world/ground space). `speed <= 0`
## uses walk_speed. No-op (push_warning) unless state == ANIMATED.
func walk_to(target: Vector2, speed: float = -1.0) -> void:
if state != RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
return
_walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else walk_speed
_nav_agent.max_speed = _walk_speed_current
_nav_agent.target_position = target
_walk_dbg("walk_to target=(%.1f, %.1f) speed=%.1f root=(%.1f, %.1f) feet=(%.1f, %.1f)" % [
target.x, target.y, _walk_speed_current,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
])
var dx := target.x - global_position.x
var anim_name: String
if dx < -0.5:
set_facing_profile(FacingProfile.LEFT)
anim_name = "walk_left"
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
anim_name = "walk_right"
else:
anim_name = "walk_right"
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
_anim_player.play(anim_name)
_walking = true
_walk_done = false
func is_walking() -> bool:
return _walking
func _update_walking(delta: float) -> void:
if not _walking:
return
if state != RigState.ANIMATED:
_cancel_walking()
return
# Defer all nav reads until the map has actually synchronized. An unsynced
# agent reports an empty, finished path (map iteration id == 0), which would
# otherwise end the walk after a single move_toward step (~5 px).
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
_walk_dbg("sync pending")
return
# Ask the agent for its next waypoint FIRST. This forces the agent's internal
# path update (_update_navigation), which re-queries the map whenever the
# stored path is empty (set_target_position resets it via _request_repath).
# The read-only get_current_navigation_path() accessor alone never triggers a
# repath, so checking it directly would leave the path empty forever.
var next_feet := _nav_agent.get_next_path_position()
var root_target: Vector2
if _nav_agent.is_target_reachable():
# NAV branch: the target lies on the mesh — follow the path to it.
_walk_mode = "nav"
if _nav_agent.is_navigation_finished():
_finish_walk("finished")
return
root_target = next_feet + FOOT_OFFSET
else:
# DIRECT branch: an off-mesh waypoint is now a normal, supported case —
# steer straight at the clicked point, ignoring the nav mesh.
if _walk_mode != "direct":
_walk_dbg("off-mesh waypoint: switching to direct steering")
_walk_mode = "direct"
root_target = _walk_target_feet + FOOT_OFFSET
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE:
_finish_walk("arrive")
return
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
Engine.get_physics_frames(),
_current_index,
_walk_mode,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
_walk_target_feet.x, _walk_target_feet.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
str(_nav_agent.is_navigation_finished()),
str(_nav_agent.is_target_reachable()),
_nav_agent.get_final_position().x, _nav_agent.get_final_position().y,
_nav_agent.get_current_navigation_path().size(),
next_feet.x, next_feet.y,
NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()),
])
func _finish_walk(reason: String = "") -> void:
if DEBUG_WALK:
var map_iter := -1
if _nav_agent != null:
map_iter = NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map())
_walk_dbg("finish reason=%s root=(%.1f, %.1f) dist_to_target=%.1f map_iter=%d" % [
reason,
global_position.x, global_position.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
map_iter,
])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_restore_standing_markers()
_walk_done = true
_walking = false
arrived.emit()
func _cancel_walking() -> void:
_walk_dbg("cancel (state=%s)" % RigState.keys()[state])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_walk_done = false
# ---------------------------------------------------------------------------
# Speech (Phase 3a)
# ---------------------------------------------------------------------------
## Show the speech bubble with `text` for `duration` seconds; auto-hides and
## emits speech_finished. Lazily creates the SpeechBubble child on first use.
func speak(text: String, duration: float) -> void:
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new()
_speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
add_child(_speech_bubble)
_speech_bubble.show_text(text)
_speech_active = true
_speech_time_left = maxf(duration, 0.0)
func _update_speech(delta: float) -> void:
if not _speech_active:
return
_speech_time_left -= delta
if _speech_time_left <= 0.0:
_hide_speech()
speech_finished.emit()
func _hide_speech() -> void:
_speech_active = false
_speech_time_left = 0.0
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
# ---------------------------------------------------------------------------
# Action queue (Phase 3a)
# ---------------------------------------------------------------------------
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()
func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
push_warning("StickmanRig: 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()
# ---------------------------------------------------------------------------
# Runner state machine (Phase 3a)
# ---------------------------------------------------------------------------
## Start running the queue. Empty queue emits queue_finished immediately.
func start_queue() -> void:
if _runner_state == RunnerState.EXECUTING:
return
if DEBUG_WALK:
print("[runner] start_queue size=%d" % action_queue.size())
if action_queue.is_empty():
queue_finished.emit()
return
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_current_index = -1
_stop_requested = false
## Abort the current action and return to IDLE. Does not emit queue_finished.
func stop_queue() -> void:
_stop_requested = true
_cancel_walking()
_hide_speech()
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
func is_queue_running() -> bool:
return _runner_state == RunnerState.EXECUTING
func is_ragdoll_at_rest() -> bool:
return _ragdoll_at_rest
func _update_runner(delta: float) -> void:
if _runner_state != RunnerState.EXECUTING:
return
match _action_phase:
ActionPhase.NONE:
_advance_to_next_action()
ActionPhase.WALKING:
if _walk_done:
_finish_action()
ActionPhase.SPEAKING:
if not _speech_active:
_finish_action()
ActionPhase.WAITING:
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_action()
ActionPhase.RAGDOLLING:
if is_ragdoll_at_rest():
_finish_action()
ActionPhase.RECOVERING:
if state == RigState.ANIMATED:
_finish_action()
func _advance_to_next_action() -> void:
_current_index += 1
if _current_index >= action_queue.size():
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
queue_finished.emit()
return
var action := action_queue[_current_index]
if DEBUG_WALK:
print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))])
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
_walk_done = false
walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0)))
if not _walking:
# walk_to self-guarded (e.g. not ANIMATED): complete immediately
# so the runner never hangs.
_walk_done = true
"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
set_ragdoll(true)
"recover":
_action_phase = ActionPhase.RECOVERING
request_recovery()
_:
push_warning("StickmanRig: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
func _finish_action() -> void:
if DEBUG_WALK and _current_index >= 0 and _current_index < action_queue.size():
print("[runner] finish idx=%d type=%s" % [_current_index, String(action_queue[_current_index].get("type", ""))])
if _current_index >= 0 and _current_index < action_queue.size():
action_finished.emit(action_queue[_current_index], _current_index)
_action_phase = ActionPhase.NONE