- 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.
981 lines
40 KiB
GDScript
981 lines
40 KiB
GDScript
class_name StickmanRig
|
|
extends Node2D
|
|
## StickmanRig - Runtime owner of facing direction and per-joint bone bend for
|
|
## scenes/stickman_rig.tscn (Phase 9 / Task 4).
|
|
##
|
|
## Attached to the `Master` root node of scenes/stickman_rig.tscn. Owns the facing
|
|
## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the
|
|
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
|
|
## reordering run only at runtime (_ready + setters on a live instance).
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Facing profiles. Values are used directly as facing-menu item ids in the
|
|
## harness (0/1/2), so they must stay stable.
|
|
enum FacingProfile { LEFT, RIGHT, FORWARD }
|
|
|
|
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
|
|
enum BendDirection { NORMAL, INVERTED }
|
|
|
|
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
|
|
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll);
|
|
## RECOVERING snaps the skeleton back to the captured rest pose and tweens the
|
|
## 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const SKELETON_PATH := "Skeleton2D"
|
|
const BODY_CONTAINER_PATH := "Body"
|
|
|
|
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
|
|
## Direction" flag is user-controllable.
|
|
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
|
|
|
|
## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to
|
|
## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report
|
|
## each joint's world position for hit-testing.
|
|
const BEND_JOINT_BONE_PATHS: Dictionary = {
|
|
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
|
|
"RightArm": "Torso/RightUpperArm/RightLowerArm",
|
|
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
|
|
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
|
|
}
|
|
|
|
## flip_bend_direction value per facing profile, keyed by bend-joint name.
|
|
const PROFILE_FLAGS: Dictionary = {
|
|
FacingProfile.LEFT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
|
|
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
|
|
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
|
|
}
|
|
|
|
## Body/* visual part node names in draw order (back-to-front) per profile.
|
|
## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the
|
|
## far (behind-torso) side the arm pair draws behind the leg pair, on the near
|
|
## side the arm pair draws in front; head always frontmost.
|
|
const Z_ORDER_BY_PROFILE: Dictionary = {
|
|
FacingProfile.FORWARD: [
|
|
"Body",
|
|
"LeftUpperLeg", "RightUpperLeg",
|
|
"LeftLowerLeg", "RightLowerLeg",
|
|
"LeftUpperArm", "RightUpperArm",
|
|
"LeftLowerArm", "RightLowerArm",
|
|
"Head",
|
|
],
|
|
FacingProfile.LEFT: [
|
|
"LeftUpperArm", "LeftLowerArm",
|
|
"LeftUpperLeg", "LeftLowerLeg",
|
|
"Body",
|
|
"RightUpperLeg", "RightLowerLeg",
|
|
"RightUpperArm", "RightLowerArm",
|
|
"Head",
|
|
],
|
|
FacingProfile.RIGHT: [
|
|
"RightUpperArm", "RightLowerArm",
|
|
"RightUpperLeg", "RightLowerLeg",
|
|
"Body",
|
|
"LeftUpperLeg", "LeftLowerLeg",
|
|
"LeftUpperArm", "LeftLowerArm",
|
|
"Head",
|
|
],
|
|
}
|
|
|
|
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
|
|
|
|
## Upper + lower Bone2D paths (Skeleton2D-relative), retained for mirrored
|
|
## idle-pose preservation and legacy test access.
|
|
const LIMB_BONE_PATHS: Dictionary = {
|
|
"LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"],
|
|
"RightArm": ["Torso/RightUpperArm", "Torso/RightUpperArm/RightLowerArm"],
|
|
"LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"],
|
|
"RightLeg": ["Torso/RightUpperLeg", "Torso/RightUpperLeg/RightLowerLeg"],
|
|
}
|
|
|
|
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
|
|
const DEBUG_WALK := false
|
|
const DEBUG_TERRAIN := false
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exported controls
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Facing preset. Setting it overwrites the four per-joint bend values from
|
|
## PROFILE_FLAGS and reorders Body/* children. Default FORWARD.
|
|
@export var facing_profile: FacingProfile = FacingProfile.FORWARD:
|
|
set(value):
|
|
if facing_profile == value:
|
|
return
|
|
facing_profile = value
|
|
if _nodes_ready:
|
|
_apply_profile()
|
|
|
|
## Per-joint bend direction (the actual flip_bend_direction source of truth).
|
|
## Defaults match the FORWARD profile. Individually overridable after a profile
|
|
## is applied (drifting away from the preset, matching the harness right-click).
|
|
@export_group("Bend Direction")
|
|
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
|
|
set(value):
|
|
left_arm_bend = value
|
|
_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
|
|
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
|
|
set(value):
|
|
right_arm_bend = value
|
|
_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
|
|
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
|
|
set(value):
|
|
left_leg_bend = value
|
|
_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
|
|
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
|
|
set(value):
|
|
right_leg_bend = value
|
|
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
|
|
|
|
@export_group("Ragdoll Transition")
|
|
## How long the ragdoll torso must be at rest before auto-recovery (seconds).
|
|
@export var rest_timeout: float = 2.0
|
|
|
|
## When true, a rested ragdoll automatically stands back up. When false, the
|
|
## 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Emitted when the facing preset changes (after flags + z-order are applied).
|
|
signal facing_profile_changed(profile: int)
|
|
|
|
## Emitted when a single joint's bend direction changes. `flipped` is the new
|
|
## flip_bend_direction value (true == inverted).
|
|
signal bend_flag_changed(joint: String, flipped: bool)
|
|
|
|
## Emitted when the rig's ANIMATED/RAGDOLL state changes. `new_state` carries
|
|
## the RigState enum value.
|
|
signal state_changed(new_state: int)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Director signals (Phase 3a)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
signal arrived(target: Vector2) # 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _nodes_ready: bool = false
|
|
var _skeleton: Skeleton2D = null
|
|
var _body_container: Node2D = null
|
|
var _torso_bone: Bone2D = null
|
|
var _head_bone: Bone2D = null
|
|
var _head_look_at: SkeletonModification2DLookAt = null
|
|
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
|
|
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rig state (ANIMATED / RAGDOLL)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var state: RigState = RigState.ANIMATED
|
|
|
|
var _anim_player: AnimationPlayer = null
|
|
|
|
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Director / navigation state (Phase 3a)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _pending_action_queue: Array[Dictionary] = []
|
|
var action_queue: Array[Dictionary]:
|
|
get:
|
|
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.set_authored_queue(value)
|
|
else:
|
|
_pending_action_queue = value.duplicate(true)
|
|
|
|
## Original .stk asset path, assigned by StageSpawner for scene persistence.
|
|
var source_stk_path: String = ""
|
|
|
|
## Authored nonvisual subsystem children. They establish explicit ownership
|
|
## boundaries while this facade preserves the long-standing public API.
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
|
|
_ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
|
|
_action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner
|
|
if _locomotion_component == null:
|
|
push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.")
|
|
if _ragdoll_component == null:
|
|
push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.")
|
|
if _action_runner_component == null:
|
|
push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.")
|
|
if _locomotion_component != null:
|
|
_locomotion_component.configure(self)
|
|
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
|
|
if _ragdoll_component != null:
|
|
_ragdoll_component.configure(self, _locomotion_component)
|
|
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
|
|
_ragdoll_component.state_transition_requested.connect(_set_state)
|
|
if _action_runner_component != null:
|
|
_action_runner_component.configure(self, _locomotion_component)
|
|
_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.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:
|
|
push_warning("StickmanRig: missing '%s' node in rig." % SKELETON_PATH)
|
|
|
|
_body_container = get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
|
|
if _body_container == null:
|
|
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
|
|
|
|
if _skeleton != null:
|
|
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
|
if _torso_bone == null:
|
|
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
|
|
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
|
if _head_bone == null:
|
|
push_warning("StickmanRig: missing 'Torso/Head' bone in Skeleton2D.")
|
|
|
|
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
|
if _anim_player == null:
|
|
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
|
|
_resolve_bend_modifications()
|
|
_resolve_limb_bones()
|
|
if _locomotion_component != null:
|
|
_locomotion_component.setup_collision_bodies()
|
|
|
|
# Enable the modification stack (IK solves only at runtime).
|
|
if _skeleton != null:
|
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
|
if stack != null:
|
|
stack.enabled = true
|
|
pass
|
|
else:
|
|
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
|
|
|
|
# Apply the authored/default profile once: writes the four mod flags from the
|
|
# current var values, reorders Body/* children, and emits the profile signal.
|
|
# _nodes_ready is set first so the per-joint setters route their mod updates
|
|
# + signal emissions through the live apply path (during instantiation the
|
|
# setters only stored values).
|
|
_nodes_ready = true
|
|
_apply_profile()
|
|
|
|
if _ragdoll_component != null:
|
|
_ragdoll_component.initialize(_skeleton)
|
|
|
|
|
|
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:
|
|
_locomotion_component.tick_movement(delta, int(state))
|
|
if _action_runner_component != null:
|
|
_action_runner_component.tick(delta, int(state), is_ragdoll_at_rest())
|
|
_pin_mirrored_head_rotation()
|
|
if _locomotion_component != null:
|
|
_locomotion_component.tick_post_skeleton(delta, int(state))
|
|
|
|
|
|
func get_locomotion() -> StickmanLocomotion:
|
|
return _locomotion_component
|
|
|
|
|
|
func get_collision_bodies() -> Dictionary:
|
|
return _locomotion_component.collision_bodies if _locomotion_component != null else {}
|
|
|
|
|
|
func get_collision_body_rids() -> Array[RID]:
|
|
var result: Array[RID] = []
|
|
for proxy: AnimatableBody2D in get_collision_bodies().values():
|
|
if proxy != null and is_instance_valid(proxy):
|
|
var rid: RID = proxy.get_rid()
|
|
if rid.is_valid():
|
|
result.append(rid)
|
|
return result
|
|
|
|
|
|
## Narrow shared skeleton service used by locomotion and ragdoll components.
|
|
func _get_ik_marker(marker_name: String) -> Marker2D:
|
|
var path := String(StickmanRagdoll.IK_TARGET_PATHS.get(marker_name, ""))
|
|
if path.is_empty():
|
|
return null
|
|
return get_node_or_null(NodePath(path)) as Marker2D
|
|
|
|
|
|
|
|
|
|
## Re-asserts the head bone's FORWARD canonical aim rotation each frame while
|
|
## facing LEFT and ANIMATED. The LookAt modification is disabled in that state
|
|
## (_apply_head_lookat_mirror_mode) so nothing else rewrites the bone. During
|
|
## RECOVERING the pin is skipped: the stand-up tween drives the head bone
|
|
## itself, and the LookAt is disabled, so a per-frame pin would fight the tween.
|
|
func _pin_mirrored_head_rotation() -> void:
|
|
if facing_profile != FacingProfile.LEFT or state != RigState.ANIMATED:
|
|
return
|
|
if _head_look_at == null or not is_instance_valid(_head_look_at):
|
|
return
|
|
if _head_look_at.enabled:
|
|
_head_look_at.enabled = false
|
|
if _head_bone != null and is_instance_valid(_head_bone):
|
|
_head_bone.rotation = 0.0
|
|
|
|
|
|
## Public terrain facade. Runtime terrain state and implementation live in RigComponents/Locomotion.
|
|
func begin_terrain_fall() -> void:
|
|
if _locomotion_component != null:
|
|
_locomotion_component.begin_terrain_fall()
|
|
|
|
|
|
func snap_to_terrain() -> void:
|
|
begin_terrain_fall()
|
|
|
|
|
|
func set_terrain_following_enabled(enabled: bool) -> void:
|
|
if _locomotion_component != null:
|
|
_locomotion_component.set_terrain_following_enabled(enabled)
|
|
|
|
|
|
func _is_action_queue_blocked() -> bool:
|
|
return _locomotion_component != null and _locomotion_component.is_action_blocked()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func set_facing_profile(profile: int) -> void:
|
|
if not PROFILE_FLAGS.has(profile):
|
|
push_warning("StickmanRig: unknown facing profile %d; ignored." % profile)
|
|
return
|
|
facing_profile = profile
|
|
|
|
|
|
func get_facing_profile() -> int:
|
|
return int(facing_profile)
|
|
|
|
|
|
func set_joint_bend_flipped(joint: String, flipped: bool) -> void:
|
|
match joint:
|
|
"LeftArm":
|
|
left_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
"RightArm":
|
|
right_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
"LeftLeg":
|
|
left_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
"RightLeg":
|
|
right_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
|
|
_:
|
|
push_warning("StickmanRig: unknown bend joint '%s'; ignored." % joint)
|
|
|
|
|
|
func get_joint_bend_flipped(joint: String) -> bool:
|
|
match joint:
|
|
"LeftArm":
|
|
return left_arm_bend == BendDirection.INVERTED
|
|
"RightArm":
|
|
return right_arm_bend == BendDirection.INVERTED
|
|
"LeftLeg":
|
|
return left_leg_bend == BendDirection.INVERTED
|
|
"RightLeg":
|
|
return right_leg_bend == BendDirection.INVERTED
|
|
_:
|
|
push_warning("StickmanRig: unknown bend joint '%s'." % joint)
|
|
return false
|
|
|
|
|
|
func get_bend_joints() -> Array[String]:
|
|
return BEND_JOINTS
|
|
|
|
|
|
func get_bend_joint_global_position(joint: String) -> Vector2:
|
|
var bone := _bend_joint_bones.get(joint) as Bone2D
|
|
if bone == null or not is_instance_valid(bone):
|
|
push_warning("StickmanRig: unknown or missing bend joint '%s'." % joint)
|
|
return Vector2.ZERO
|
|
return bone.global_position
|
|
|
|
|
|
func is_in_ragdoll() -> bool:
|
|
return state == RigState.RAGDOLL
|
|
|
|
|
|
func _set_state(next_state: int) -> void:
|
|
if next_state < RigState.ANIMATED or next_state > RigState.RECOVERING:
|
|
push_warning("StickmanRig: invalid state transition target %d." % next_state)
|
|
return
|
|
if int(state) == next_state:
|
|
return
|
|
state = next_state
|
|
state_changed.emit(int(state))
|
|
|
|
|
|
func set_ragdoll(enabled: bool) -> void:
|
|
if enabled:
|
|
if _locomotion_component != null:
|
|
_locomotion_component.reset_for_ragdoll_handoff()
|
|
match state:
|
|
RigState.RAGDOLL:
|
|
return
|
|
RigState.RECOVERING:
|
|
if _ragdoll_component != null:
|
|
_ragdoll_component.cancel_recovery()
|
|
if _ragdoll_component.enter_ragdoll():
|
|
_set_state(RigState.RAGDOLL)
|
|
_:
|
|
if _ragdoll_component != null and _ragdoll_component.enter_ragdoll():
|
|
_set_state(RigState.RAGDOLL)
|
|
else:
|
|
if state == RigState.RAGDOLL:
|
|
request_recovery()
|
|
# else ANIMATED / RECOVERING: no-op
|
|
|
|
|
|
func toggle_ragdoll() -> void:
|
|
set_ragdoll(not is_in_ragdoll())
|
|
|
|
|
|
## Public stand-up request. No-op unless the rig is in RAGDOLL.
|
|
func request_recovery() -> void:
|
|
if state == RigState.RAGDOLL and _ragdoll_component != null:
|
|
_ragdoll_component.request_recovery()
|
|
|
|
|
|
## Instantly restores the rig's factory-captured spawn/ghost pose — no stand-up
|
|
## tween. Used by the sandbox stage so a stickman reappears in its authored
|
|
## starting pose when leaving PLAY (instead of using the generic fallback pose).
|
|
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
|
|
## facade for locomotion and legacy callers.
|
|
func _restore_standing_markers(use_spawn_pose: bool = true) -> void:
|
|
if _ragdoll_component != null:
|
|
_ragdoll_component._restore_standing_markers(use_spawn_pose)
|
|
|
|
|
|
## Captures adapted skeleton endpoints as the initial IK marker pose. Called by
|
|
## StickmanFactory before the instance enters the tree; safe for factory bypasses.
|
|
func initialize_spawn_pose_from_skeleton() -> void:
|
|
var component := get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll
|
|
if component == null:
|
|
push_warning("StickmanRig: missing RigComponents/Ragdoll for spawn-pose initialization.")
|
|
return
|
|
if _ragdoll_component == null:
|
|
_ragdoll_component = component
|
|
if _locomotion_component == null:
|
|
_locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion
|
|
component.configure(self, _locomotion_component)
|
|
component.initialize_spawn_pose_from_skeleton()
|
|
|
|
|
|
## 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.
|
|
func apply_ragdoll_velocity_boost(velocity: Vector2) -> void:
|
|
if not is_in_ragdoll():
|
|
return
|
|
if _ragdoll_component != null:
|
|
_ragdoll_component.apply_velocity_boost(velocity)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal resolution / apply
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _resolve_bend_modifications() -> void:
|
|
_bend_joint_bones.clear()
|
|
_bend_modifications.clear()
|
|
if _skeleton == null:
|
|
return
|
|
|
|
for joint: String in BEND_JOINTS:
|
|
var path: String = BEND_JOINT_BONE_PATHS[joint]
|
|
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
|
|
if bone != null and is_instance_valid(bone):
|
|
_bend_joint_bones[joint] = bone
|
|
else:
|
|
push_warning("StickmanRig: missing bend-joint bone '%s'." % path)
|
|
|
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
|
if stack == null:
|
|
return
|
|
_head_look_at = null
|
|
for i: int in stack.modification_count:
|
|
var mod := stack.get_modification(i)
|
|
if mod is SkeletonModification2DLookAt:
|
|
_head_look_at = mod as SkeletonModification2DLookAt
|
|
continue
|
|
if not (mod is SkeletonModification2DTwoBoneIK):
|
|
continue
|
|
var ik := mod as SkeletonModification2DTwoBoneIK
|
|
for joint: String in BEND_JOINTS:
|
|
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
|
|
_bend_modifications[joint] = ik
|
|
break
|
|
|
|
for joint: String in BEND_JOINTS:
|
|
if not _bend_modifications.has(joint):
|
|
push_warning("StickmanRig: missing TwoBoneIK modification for bend joint '%s'." % joint)
|
|
|
|
|
|
## Resolves the 8 limb Bone2D nodes (upper + lower per bend joint). Called from
|
|
## _ready() so recovery can drive the bones directly while the TwoBoneIK mods
|
|
## are disabled.
|
|
func _resolve_limb_bones() -> void:
|
|
_limb_bones.clear()
|
|
if _skeleton == null:
|
|
return
|
|
for joint: String in LIMB_BONE_PATHS:
|
|
var paths: Array = LIMB_BONE_PATHS[joint]
|
|
var upper := _skeleton.get_node_or_null(NodePath(paths[0])) as Bone2D
|
|
var lower := _skeleton.get_node_or_null(NodePath(paths[1])) as Bone2D
|
|
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
|
|
push_warning("StickmanRig: missing limb bones for bend joint '%s'." % joint)
|
|
continue
|
|
_limb_bones[joint] = { "upper": upper, "lower": lower }
|
|
|
|
func set_collision_bodies_enabled(enabled: bool) -> void:
|
|
if _locomotion_component != null:
|
|
_locomotion_component.set_collision_bodies_enabled(enabled)
|
|
|
|
|
|
func apply_collision_reaction(
|
|
part_name: String,
|
|
impact_direction: Vector2,
|
|
impact_speed: float,
|
|
impact_position: Vector2 = Vector2.INF
|
|
) -> void:
|
|
if _locomotion_component != null:
|
|
_locomotion_component.apply_collision_reaction(part_name, impact_direction, impact_speed, impact_position)
|
|
|
|
|
|
## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters,
|
|
## so the live mods stay in sync), reorders Body/* children, then emits
|
|
## facing_profile_changed.
|
|
func _apply_profile() -> void:
|
|
var flags: Dictionary = PROFILE_FLAGS.get(facing_profile, PROFILE_FLAGS[FacingProfile.FORWARD])
|
|
left_arm_bend = BendDirection.INVERTED if bool(flags.get("LeftArm", false)) else BendDirection.NORMAL
|
|
right_arm_bend = BendDirection.INVERTED if bool(flags.get("RightArm", false)) else BendDirection.NORMAL
|
|
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
|
|
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
|
|
_apply_body_z_order()
|
|
# Whole-rig Y-axis mirror (spec §9a): facing LEFT mirrors the entire figure
|
|
# -- head + body + IK targets + mounted Body/* geometry -- so the head AND
|
|
# body face the correct direction together. RIGHT/FORWARD keep an identity
|
|
# scale. An X-mirror does not affect depth (draw order), so
|
|
# Z_ORDER_BY_PROFILE is unchanged.
|
|
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
|
|
_sync_speech_bubble_mirror()
|
|
_apply_head_lookat_mirror_mode()
|
|
_apply_idle_limb_solver_mode()
|
|
facing_profile_changed.emit(int(facing_profile))
|
|
|
|
|
|
## Godot's TwoBoneIK solver is not mirror-invariant under a negative X scale.
|
|
## While a LEFT-facing walk is active the animation must keep driving IK, but
|
|
## once the walk stops an enabled solver can settle into a pose that never
|
|
## existed in the clip. Pin the limbs to the exact frame captured at stop.
|
|
func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void:
|
|
var pin_mirrored_idle := facing_profile == FacingProfile.LEFT \
|
|
and state == RigState.ANIMATED and not is_walking()
|
|
var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations()
|
|
_set_limb_mods_enabled(not pin_mirrored_idle)
|
|
# Recovery supplies the exact pre-ragdoll rotations. Write them immediately
|
|
# even for profiles that keep IK enabled so the completion frame cannot show
|
|
# the recovery-side knee solution while the solver waits for its next tick.
|
|
if not pin_mirrored_idle and rotations.is_empty():
|
|
return
|
|
for joint: String in source:
|
|
var bones: Dictionary = _limb_bones.get(joint, {})
|
|
var saved: Dictionary = source[joint]
|
|
var upper := bones.get("upper") as Bone2D
|
|
var lower := bones.get("lower") as Bone2D
|
|
if upper != null and is_instance_valid(upper):
|
|
upper.rotation = float(saved.get("upper", upper.rotation))
|
|
if lower != null and is_instance_valid(lower):
|
|
lower.rotation = float(saved.get("lower", lower.rotation))
|
|
|
|
|
|
func _capture_limb_rotations() -> Dictionary:
|
|
var rotations: Dictionary = {}
|
|
for joint: String in _limb_bones:
|
|
var bones: Dictionary = _limb_bones[joint]
|
|
var upper := bones.get("upper") as Bone2D
|
|
var lower := bones.get("lower") as Bone2D
|
|
if upper != null and lower != null and is_instance_valid(upper) and is_instance_valid(lower):
|
|
rotations[joint] = {
|
|
"upper": upper.rotation,
|
|
"lower": lower.rotation,
|
|
}
|
|
return rotations
|
|
|
|
|
|
## The SkeletonModification2DLookAt that aims the Head bone is NOT
|
|
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
|
|
## root's negative X scale reflects the head-bone frame, and the LookAt writes a
|
|
## bone rotation 180 degrees off the FORWARD aim (PI -> 0 headless). Because the
|
|
## mounted head geometry is offset from Body/Head's local origin (the chin sits
|
|
## at the neck and the head extends away from it), that 180-degree error flips
|
|
## the head to hang BELOW the neck instead of sitting above it -- the whole-rig
|
|
## mirror then displaces the head ~2x its mount offset relative to the torso.
|
|
## Fix: when facing LEFT, disable the LookAt and pin the head bone to the
|
|
## authored canonical local rotation (0), so the head is a rigid mirror of the
|
|
## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted
|
|
## every physics frame while the kinematic puppet is ANIMATED; recovery disables
|
|
## LookAt separately while its stand-up tween drives the head bone.
|
|
func _apply_head_lookat_mirror_mode() -> void:
|
|
if _head_look_at == null or not is_instance_valid(_head_look_at):
|
|
return
|
|
var mirrored := facing_profile == FacingProfile.LEFT
|
|
_head_look_at.enabled = not mirrored
|
|
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
|
|
_head_bone.rotation = 0.0
|
|
|
|
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
|
|
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
|
|
## setter only stored the backing value during instantiation).
|
|
func _set_joint_bend_inverted(joint: String, inverted: bool) -> void:
|
|
if not _nodes_ready:
|
|
return
|
|
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
|
|
if mod != null:
|
|
mod.flip_bend_direction = inverted
|
|
bend_flag_changed.emit(joint, inverted)
|
|
|
|
|
|
## Task 2 algorithm: walk the profile's ordered part names back-to-front and
|
|
## move_child(part, count - 1) each existing part; unknown/extra children stay
|
|
## at the back; missing parts skipped silently.
|
|
func _apply_body_z_order() -> void:
|
|
if _body_container == null or not is_instance_valid(_body_container):
|
|
return
|
|
var order: Array = Z_ORDER_BY_PROFILE.get(facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
|
|
for part_name: String in order:
|
|
var part := _body_container.get_node_or_null(NodePath(part_name))
|
|
if part != null:
|
|
_body_container.move_child(part, _body_container.get_child_count() - 1)
|
|
|
|
## Enables/disables the 4 TwoBoneIK mods. Disabled during RECOVERING so the IK
|
|
## re-solve cannot overwrite the directly-driven limb bones; re-enabled when
|
|
## the stand-up completes (or on snap_to_standing).
|
|
func _set_limb_mods_enabled(enabled: bool) -> void:
|
|
for joint: String in BEND_JOINTS:
|
|
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
|
|
if mod != null and is_instance_valid(mod):
|
|
mod.enabled = enabled
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 _locomotion_component != null:
|
|
_locomotion_component.walk_to(target, speed)
|
|
|
|
|
|
func is_walking() -> bool:
|
|
return _locomotion_component.is_walking() if _locomotion_component != null else 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 _action_runner_component != null:
|
|
_action_runner_component.speak(text, duration)
|
|
|
|
|
|
func _speak_overlay(text: String, duration: float) -> void:
|
|
if _action_runner_component != null:
|
|
_action_runner_component.speak_overlay(text, duration)
|
|
|
|
|
|
func _hide_speech() -> void:
|
|
if _action_runner_component != null:
|
|
_action_runner_component.hide_speech()
|
|
|
|
|
|
func _sync_speech_bubble_mirror() -> void:
|
|
if _action_runner_component != null:
|
|
_action_runner_component.sync_facing(facing_profile)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action queue (Phase 3a)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func queue_action(action: Dictionary) -> void:
|
|
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()
|
|
else:
|
|
_pending_action_queue.clear()
|
|
|
|
|
|
func get_queue() -> Array[Dictionary]:
|
|
return _action_runner_component.get_queue() if _action_runner_component != null else _pending_action_queue.duplicate(true)
|
|
|
|
|
|
func remove_action(index: int) -> void:
|
|
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:
|
|
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:
|
|
return _action_runner_component.queue_size() if _action_runner_component != null else _pending_action_queue.size()
|
|
|
|
|
|
## Append `actions` to the queue and, when the runner is idle, resume execution
|
|
## at the first newly-appended action WITHOUT replaying the existing queue.
|
|
## Used by the Phase 4 event engine to inject reactive actions onto a stickman.
|
|
func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
|
if _action_runner_component != null:
|
|
_action_runner_component.enqueue_reactive(actions)
|
|
|
|
|
|
## Drops rule-injected ("reactive") actions from the queue, restoring the
|
|
## authored sequential queue after a Play session. No-op while the runner is
|
|
## executing (callers invoke it on mode exit, after stop_queue()).
|
|
func clear_reactive_actions() -> void:
|
|
if _action_runner_component != null:
|
|
_action_runner_component.clear_reactive_actions()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runner state machine (Phase 3a)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Start running the queue. Empty queue emits queue_finished immediately.
|
|
func start_queue() -> void:
|
|
if _action_runner_component != null:
|
|
_action_runner_component.start_queue()
|
|
|
|
|
|
## Abort the current action and return to IDLE. Does not emit queue_finished.
|
|
func stop_queue() -> void:
|
|
if _action_runner_component != null:
|
|
_action_runner_component.stop_queue()
|
|
|
|
|
|
func is_queue_running() -> bool:
|
|
return _action_runner_component != null and _action_runner_component.is_queue_running()
|
|
|
|
|
|
func is_ragdoll_at_rest() -> bool:
|
|
return _ragdoll_component.is_at_rest() if _ragdoll_component != null else false
|