- Introduced a new architecture specification for Phase 4 final bugs, detailing scope, decisions, runtime data contracts, action roles, execution lanes, speech ownership, and signal flow. - Implemented changes in `scripts/action_registry.gd`, `scripts/action_editor.gd`, `scripts/sandbox_stage.gd`, `scripts/stage_gizmos.gd`, `scripts/stickman_factory.gd`, and `scripts/stickman_rig.gd` to address identified bugs. - Created a new test suite `tests/test_phase4_final_bugs.gd` to validate the implementation, covering action roles, grid snapping, prop manipulation, reactive runtime lane, spawn pose preservation, and more. - Added a unique identifier for the test file to ensure proper tracking and management.
2595 lines
111 KiB
GDScript
2595 lines
111 KiB
GDScript
class_name StickmanRig
|
||
extends Node2D
|
||
## StickmanRig - Runtime owner of facing direction and per-joint bone bend for
|
||
## master_rig.tscn (Phase 9 / Task 4).
|
||
##
|
||
## Attached to the `Master` root node of master_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).
|
||
|
||
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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",
|
||
],
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ragdoll constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
|
||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||
|
||
const RAGDOLL_LIMB_RADIUS := 8.0
|
||
const RAGDOLL_TORSO_RADIUS := 12.0
|
||
const RAGDOLL_HEAD_RADIUS := 100.0
|
||
const HEAD_COLLISION_RADIUS := 90.0
|
||
const TORSO_COLLISION_RADIUS := 20.0
|
||
const ARM_COLLISION_RADIUS := 12.0
|
||
const LEG_COLLISION_RADIUS := 14.0
|
||
const HEAD_COLLISION_FALLBACK_CENTER := Vector2(0.0, -72.0)
|
||
const COLLISION_LAYER := 1
|
||
const COLLISION_PARTS: Dictionary = {
|
||
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
|
||
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
|
||
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
|
||
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
|
||
}
|
||
const REACTION_BASE_ANGLE := 0.35
|
||
const REACTION_REFERENCE_SPEED := 400.0
|
||
const REACTION_MAX_ANGLE := 0.6
|
||
const REACTION_LOCAL_X_EPSILON := 0.05
|
||
const REACTION_TARGETS: Dictionary = {
|
||
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
|
||
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
|
||
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
|
||
"left_lower_arm": [
|
||
{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35},
|
||
{"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25},
|
||
],
|
||
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
|
||
"right_lower_arm": [
|
||
{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35},
|
||
{"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25},
|
||
],
|
||
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||
"left_lower_leg": [
|
||
{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35},
|
||
{"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25},
|
||
],
|
||
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
|
||
"right_lower_leg": [
|
||
{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35},
|
||
{"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25},
|
||
],
|
||
}
|
||
|
||
## Visual mesh colors: the rig's authored part color (gray Line2D limbs) and the
|
||
## head's filled white circle. Collision shapes are invisible in-game, so each
|
||
## ragdoll body gets a matching visible mesh (Line2D capsule / Polygon2D circle).
|
||
const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
|
||
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
|
||
const RAGDOLL_CIRCLE_SEGMENTS := 32
|
||
|
||
## Ground probe (recovery): the ray fans 7 X-offsets around the landing X,
|
||
## starts this far above the ragdoll's topmost point and ends this far below
|
||
## its bottommost point.
|
||
const GROUND_PROBE_START_OFFSET := 50.0
|
||
const GROUND_PROBE_END_OFFSET := 1500.0
|
||
## A hit is accepted only inside this band around the ragdoll's top/bottom
|
||
## extent (rejects overhang hits above and gap hits far below).
|
||
const GROUND_PROBE_TOLERANCE := 200.0
|
||
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
|
||
|
||
## Upper + lower Bone2D paths (Skeleton2D-relative) per bend joint. Used to
|
||
## drive the limb bones directly during recovery while the TwoBoneIK mods are
|
||
## disabled (the IK re-solve would otherwise snap limbs to the profile's
|
||
## preferred bend solution instead of the captured ragdoll pose).
|
||
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"],
|
||
}
|
||
|
||
## Ragdoll body keys (upper, lower) per bend joint.
|
||
const LIMB_BODY_KEYS: Dictionary = {
|
||
"LeftArm": ["left_upper_arm", "left_lower_arm"],
|
||
"RightArm": ["right_upper_arm", "right_lower_arm"],
|
||
"LeftLeg": ["left_upper_leg", "left_lower_leg"],
|
||
"RightLeg": ["right_upper_leg", "right_lower_leg"],
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Blend / recovery constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Rest detection thresholds (linear px/s, angular rad/s) for the ragdoll
|
||
## torso. 5.0 px/s (not the plan's 0.1) — a soft-pinned ragdoll micro-jitters
|
||
## around ~0.5 px/s even when fully settled, so 0.1 is never reached. A body
|
||
## that the physics engine has put to sleep also counts as at rest.
|
||
const REST_LINEAR_THRESHOLD := 5.0
|
||
const REST_ANGULAR_THRESHOLD := 0.1
|
||
|
||
## Duration of the stand-up tween (captured pose -> STAND_POSE).
|
||
const STAND_UP_DURATION := 2.0
|
||
|
||
## Extra hold after rest is detected before recovery captures the pose.
|
||
const STABILIZATION_DELAY := 0.1
|
||
|
||
## Pin softness applied to every ragdoll joint at build time.
|
||
const RAGDOLL_TARGET_SOFTNESS := 0.2
|
||
|
||
## IK-target standing positions (rig-local), matching master_rig.tscn defaults.
|
||
const STAND_POSE: Dictionary = {
|
||
"Torso": { "pos": Vector2(0, 10), "rot": 0.0 },
|
||
"Head": { "pos": Vector2(100, -614), "rot": 0.0 },
|
||
"Left_Hand": { "pos": Vector2(90, 110), "rot": 0.0 },
|
||
"Right_Hand": { "pos": Vector2(-90, 110), "rot": 0.0 },
|
||
"Left_Leg": { "pos": Vector2(-110, 380), "rot": 0.0 },
|
||
"Right_Leg": { "pos": Vector2(110, 390), "rot": 0.0 },
|
||
}
|
||
|
||
## IK-target marker node paths (rig-root relative), keyed by marker name.
|
||
const IK_TARGET_PATHS: Dictionary = {
|
||
"Torso": "IK_Targets/Torso",
|
||
"Head": "IK_Targets/Head",
|
||
"Left_Hand": "IK_Targets/Left_Hand",
|
||
"Right_Hand": "IK_Targets/Right_Hand",
|
||
"Left_Leg": "IK_Targets/Left_Leg",
|
||
"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
|
||
## How many post-sync physics frames to wait for the nav agent's reachability
|
||
## flag before deciding the walk is genuinely off-mesh (latch "direct").
|
||
const LATCH_PROBE_MAX_FRAMES := 6
|
||
## 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
|
||
|
||
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
|
||
const DEBUG_RECOVERY := 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).
|
||
const RAGDOLL_BODIES: Array[Dictionary] = [
|
||
{ "key": "torso", "kind": "bone", "node_path": "Torso", "visual_path": "Body/Body", "parent": "", "shape": "capsule", "radius": RAGDOLL_TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
|
||
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "visual_path": "Body/Head", "parent": "torso", "shape": "circle", "radius": RAGDOLL_HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
|
||
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "visual_path": "Body/LeftUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
{ "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "visual_path": "Body/LeftLowerArm", "parent": "left_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "visual_path": "Body/RightUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
{ "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "visual_path": "Body/RightLowerArm", "parent": "right_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "visual_path": "Body/LeftUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
{ "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "visual_path": "Body/LeftLowerLeg", "parent": "left_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "visual_path": "Body/RightUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
{ "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "visual_path": "Body/RightLowerLeg", "parent": "right_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
|
||
]
|
||
|
||
## Ragdoll joint definitions: one PinJoint2D per non-root body, pinned at the
|
||
## child bone's origin. `limit` selects the angular-limit band (neck free,
|
||
## shoulder/hip ±160°, elbow/knee −5°..+150°, or its mirrored CCW variant).
|
||
const RAGDOLL_JOINTS: Array[Dictionary] = [
|
||
{ "child": "head", "parent": "torso", "pin_node_path": "Torso/Head", "limit": "neck" },
|
||
{ "child": "left_upper_arm", "parent": "torso", "pin_node_path": "Torso/LeftUpperArm", "limit": "shoulder_hip" },
|
||
{ "child": "left_lower_arm", "parent": "left_upper_arm", "pin_node_path": "Torso/LeftUpperArm/LeftLowerArm", "limit": "elbow_knee" },
|
||
{ "child": "right_upper_arm", "parent": "torso", "pin_node_path": "Torso/RightUpperArm", "limit": "shoulder_hip" },
|
||
{ "child": "right_lower_arm", "parent": "right_upper_arm", "pin_node_path": "Torso/RightUpperArm/RightLowerArm", "limit": "elbow_knee_ccw" },
|
||
{ "child": "left_upper_leg", "parent": "torso", "pin_node_path": "Torso/LeftUpperLeg", "limit": "shoulder_hip" },
|
||
{ "child": "left_lower_leg", "parent": "left_upper_leg", "pin_node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "elbow_knee_ccw" },
|
||
{ "child": "right_upper_leg", "parent": "torso", "pin_node_path": "Torso/RightUpperLeg", "limit": "shoulder_hip" },
|
||
{ "child": "right_lower_leg", "parent": "right_upper_leg", "pin_node_path": "Torso/RightUpperLeg/RightLowerLeg", "limit": "elbow_knee" },
|
||
]
|
||
|
||
## ---------------------------------------------------------------------------
|
||
## Joint bend configuration (EDIT ME)
|
||
## ---------------------------------------------------------------------------
|
||
## Every per-joint bend decision in the ragdoll cycle lives in these tables,
|
||
## keyed by facing profile, so each joint can be tuned individually without
|
||
## touching the pipeline code.
|
||
##
|
||
## RAGDOLL_JOINT_BAND_LIMITS[profile][joint] — the fall's knee/elbow PinJoint2D
|
||
## angular band, in DEGREES, as { "lower": ..., "upper": ... }:
|
||
## - The band is the allowed range of the joint's relative angle
|
||
## (shin vs thigh / forearm vs upper arm), measured in the pin's frame.
|
||
## - 0 = straight. Negative = folds one way; positive = folds the other.
|
||
## - Natural shape: a SMALL side (~5) that resists hyperextension the wrong
|
||
## way, and a LARGE side (~150) on the fold direction.
|
||
## - To reverse a joint's bend: swap the magnitudes, e.g.
|
||
## { "lower": -5, "upper": 150 } <-> { "lower": -150, "upper": 5 }.
|
||
## - lower must be <= upper (the joint angle must stay inside the band).
|
||
## Missing joint entries fall back to the RAGDOLL_JOINTS `limit` string.
|
||
const RAGDOLL_JOINT_BAND_LIMITS: Dictionary = {
|
||
FacingProfile.FORWARD: {
|
||
"left_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||
"left_lower_leg": { "lower": -5.0, "upper": 150.0 },
|
||
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
|
||
},
|
||
FacingProfile.LEFT: {
|
||
"left_lower_arm": { "lower": 60.0, "upper": 100.0 },
|
||
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||
"left_lower_leg": { "lower": -100.0, "upper": -90.0 },
|
||
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
|
||
},
|
||
FacingProfile.RIGHT: {
|
||
"left_lower_arm": { "lower": -100.0, "upper": -60.0 },
|
||
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||
"left_lower_leg": { "lower": 90.0, "upper": 100.0 },
|
||
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
|
||
},
|
||
}
|
||
|
||
## RECOVERY_LEG_SIDE_FORCE[profile][leg] — optional manual override of the leg
|
||
## IK `flip_bend_direction` during the stand-up (true = inverted). A missing
|
||
## key means automatic: match the captured fall pose's knee side.
|
||
const RECOVERY_LEG_SIDE_FORCE: Dictionary = {
|
||
FacingProfile.FORWARD: {},
|
||
FacingProfile.LEFT: {},
|
||
FacingProfile.RIGHT: {},
|
||
}
|
||
|
||
## RECOVERY_JOINT_PATH[profile][joint] — optional override of the elbow/knee
|
||
## stand-up tween path: "shortest" = plain shortest rotation. A missing key
|
||
## means automatic: pass through straight when the shortest path would cross
|
||
## the +/-180 (fully-backward) boundary.
|
||
const RECOVERY_JOINT_PATH: Dictionary = {
|
||
FacingProfile.FORWARD: {},
|
||
FacingProfile.LEFT: {},
|
||
FacingProfile.RIGHT: {},
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 _ragdoll_root: Node2D = null
|
||
var _ragdoll_bodies: Dictionary = {} # { String : RigidBody2D }
|
||
var _anim_player: AnimationPlayer = null
|
||
var _prev_global_pos: Vector2 = Vector2.ZERO
|
||
var _prev_global_rot: float = 0.0
|
||
var _cached_linear_velocity: Vector2 = Vector2.ZERO
|
||
var _cached_angular_velocity: float = 0.0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recovery state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
var _rest_timer: float = 0.0
|
||
var _stabilize_timer: float = 0.0
|
||
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
|
||
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
|
||
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
|
||
var _stand_up_tween: Tween = null
|
||
var _stand_up_generation: int = 0
|
||
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
|
||
var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up
|
||
var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target
|
||
var _probe_top_y: float = 0.0 # ground-probe context retained for the ankle clamp
|
||
var _probe_bottom_y: float = 0.0
|
||
var _probe_exclude: Array[RID] = []
|
||
var _collision_bodies: Dictionary = {}
|
||
var _collision_lengths: Dictionary = {}
|
||
var _collision_visuals: Dictionary = {}
|
||
var _reaction_bones: Dictionary = {}
|
||
var _impact_reactions: Dictionary = {}
|
||
var _spawn_pose: Dictionary = {}
|
||
var _spawn_limb_rotations: Dictionary = {}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 _run_queue: Array[Dictionary] = []
|
||
var _current_run_item: Dictionary = {}
|
||
var _run_cursor: int = 0
|
||
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_mode_latched: bool = false
|
||
var _walk_latch_probe_frames: int = 0
|
||
var _walk_settle_frames: int = 0
|
||
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 _primary_speech: Dictionary = {}
|
||
var _overlay_speech: Dictionary = {}
|
||
|
||
var _phase_timer: float = 0.0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lifecycle
|
||
# ---------------------------------------------------------------------------
|
||
|
||
func _ready() -> void:
|
||
# 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()
|
||
_resolve_reaction_bones()
|
||
_build_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()
|
||
|
||
# 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
|
||
|
||
|
||
func _physics_process(delta: float) -> void:
|
||
_track_momentum(delta)
|
||
_update_rest_detection(delta)
|
||
_update_walking(delta)
|
||
_settle_walk_markers()
|
||
_update_speech(delta)
|
||
_update_runner(delta)
|
||
_pin_mirrored_head_rotation()
|
||
_apply_impact_reactions(delta)
|
||
_sync_collision_bodies()
|
||
|
||
|
||
|
||
|
||
## 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
|
||
|
||
|
||
func _track_momentum(delta: float) -> void:
|
||
if delta > 0.0:
|
||
_cached_linear_velocity = (global_position - _prev_global_pos) / delta
|
||
_cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta
|
||
_prev_global_pos = global_position
|
||
_prev_global_rot = global_rotation
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-frame rest detection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## RAGDOLL rest detection: when the torso sits still long enough (and
|
||
## auto_recover is on), trigger recovery after a short stabilization delay.
|
||
func _update_rest_detection(delta: float) -> void:
|
||
if state != RigState.RAGDOLL:
|
||
return
|
||
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||
if torso == null or not is_instance_valid(torso):
|
||
return
|
||
var at_rest := torso.sleeping \
|
||
or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD \
|
||
and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
|
||
if not at_rest:
|
||
_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
|
||
_rest_timer += delta
|
||
if _rest_timer < rest_timeout:
|
||
return
|
||
_stabilize_timer += delta
|
||
if _stabilize_timer >= STABILIZATION_DELAY:
|
||
_start_recovery()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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_ragdoll(enabled: bool) -> void:
|
||
if enabled:
|
||
match state:
|
||
RigState.RAGDOLL:
|
||
return
|
||
RigState.RECOVERING:
|
||
_cancel_recovery()
|
||
_enter_ragdoll()
|
||
_:
|
||
_enter_ragdoll()
|
||
else:
|
||
if state == RigState.RAGDOLL:
|
||
_start_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:
|
||
_start_recovery()
|
||
|
||
|
||
## Instantly snaps the rig back to its authored standing pose — no stand-up
|
||
## tween. Used by the sandbox stage so a stickman "reappears" at its starting
|
||
## position/state on return to EDIT (instead of animating the recovery glide).
|
||
func snap_to_standing() -> void:
|
||
var previous_state := state
|
||
if state == RigState.RAGDOLL:
|
||
_destroy_ragdoll()
|
||
elif state == RigState.RECOVERING:
|
||
_cancel_recovery()
|
||
_clear_impact_reactions(true)
|
||
_hide_speech()
|
||
_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
|
||
_set_limb_mods_enabled(true)
|
||
if previous_state == RigState.ANIMATED:
|
||
_apply_head_lookat_mirror_mode()
|
||
else:
|
||
_restore_head_lookat_state()
|
||
_recovery_bones.clear()
|
||
_rest_timer = 0.0
|
||
_stabilize_timer = 0.0
|
||
_ragdoll_at_rest = false
|
||
_captured_pose.clear()
|
||
_captured_landing_center = Vector2.ZERO
|
||
_captured_ground_y = 0.0
|
||
_pre_ragdoll_state.clear()
|
||
_probe_top_y = 0.0
|
||
_probe_bottom_y = 0.0
|
||
_probe_exclude.clear()
|
||
_set_collision_bodies_enabled(true)
|
||
|
||
if _body_container != null and is_instance_valid(_body_container):
|
||
_body_container.visible = true
|
||
_body_container.modulate.a = 1.0
|
||
state = RigState.ANIMATED
|
||
_apply_idle_limb_solver_mode(_spawn_limb_rotations)
|
||
if previous_state != RigState.ANIMATED:
|
||
state_changed.emit(int(state))
|
||
|
||
|
||
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
|
||
func _restore_standing_markers() -> void:
|
||
var pose: Dictionary = _spawn_pose if not _spawn_pose.is_empty() else STAND_POSE
|
||
for marker_name: String in pose:
|
||
var marker := _get_ik_marker(marker_name)
|
||
if marker == null:
|
||
continue
|
||
var target: Dictionary = pose[marker_name]
|
||
marker.position = target.get("pos", marker.position)
|
||
if marker_name == "Torso":
|
||
marker.rotation = target.get("rot", marker.rotation)
|
||
|
||
|
||
## 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 skeleton := get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
||
if skeleton == null:
|
||
push_warning("StickmanRig: cannot initialize spawn pose; missing Skeleton2D.")
|
||
return
|
||
var pose: Dictionary = {}
|
||
var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||
if torso != null:
|
||
pose["Torso"] = { "pos": to_local(torso.global_position), "rot": torso.rotation }
|
||
var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||
if head != null:
|
||
var head_tip: Vector2 = head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
|
||
pose["Head"] = { "pos": to_local(head_tip), "rot": 0.0 }
|
||
var paths: Dictionary = {
|
||
"Left_Hand": "Torso/LeftUpperArm/LeftLowerArm",
|
||
"Right_Hand": "Torso/RightUpperArm/RightLowerArm",
|
||
"Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg",
|
||
"Right_Leg": "Torso/RightUpperLeg/RightLowerLeg",
|
||
}
|
||
for marker_name: String in paths:
|
||
var bone := skeleton.get_node_or_null(NodePath(String(paths[marker_name]))) as Bone2D
|
||
if bone == null:
|
||
push_warning("StickmanRig: missing spawn-pose bone '%s'." % String(paths[marker_name]))
|
||
continue
|
||
var endpoint: Vector2 = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||
pose[marker_name] = { "pos": to_local(endpoint), "rot": 0.0 }
|
||
if pose.size() == IK_TARGET_PATHS.size():
|
||
# The targets must be written before the rig enters the tree; merely
|
||
# caching them would let the modification stack solve once against the
|
||
# adapter's generic targets and change the placed silhouette.
|
||
for marker_name: String in pose:
|
||
var marker := get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
|
||
if marker == null:
|
||
push_warning("StickmanRig: missing spawn-pose marker '%s'." % marker_name)
|
||
return
|
||
var target: Dictionary = pose[marker_name]
|
||
marker.position = target.get("pos", marker.position)
|
||
marker.rotation = target.get("rot", marker.rotation)
|
||
_spawn_pose = pose.duplicate(true)
|
||
_spawn_limb_rotations.clear()
|
||
for joint: String in LIMB_BONE_PATHS:
|
||
var pair: Array = LIMB_BONE_PATHS[joint]
|
||
var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D
|
||
var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D
|
||
if upper != null and lower != null:
|
||
_spawn_limb_rotations[joint] = {
|
||
"upper": upper.rotation,
|
||
"lower": lower.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.
|
||
func apply_ragdoll_velocity_boost(velocity: Vector2) -> void:
|
||
if not is_in_ragdoll():
|
||
return
|
||
for key: String in _ragdoll_bodies:
|
||
var body := _ragdoll_bodies[key] as RigidBody2D
|
||
if body != null and is_instance_valid(body):
|
||
body.apply_central_impulse(velocity * body.mass)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 _resolve_reaction_bones() -> void:
|
||
_reaction_bones.clear()
|
||
if _skeleton == null:
|
||
return
|
||
for key: String in COLLISION_PARTS:
|
||
var definition: Dictionary = COLLISION_PARTS[key]
|
||
var bone := _skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
|
||
if bone == null or not is_instance_valid(bone):
|
||
push_warning("StickmanRig: missing reaction bone '%s'." % str(definition["bone"]))
|
||
continue
|
||
var joint_name := "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"])
|
||
_reaction_bones[joint_name] = bone
|
||
|
||
|
||
func _build_collision_bodies() -> void:
|
||
_collision_bodies.clear()
|
||
_collision_lengths.clear()
|
||
_collision_visuals.clear()
|
||
if _body_container == null or _skeleton == null:
|
||
return
|
||
for key: String in COLLISION_PARTS:
|
||
var definition: Dictionary = COLLISION_PARTS[key]
|
||
var visual := _body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D
|
||
var bone := _skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
|
||
if visual == null or bone == null:
|
||
push_warning("StickmanRig: missing collision visual or bone for '%s'." % key)
|
||
continue
|
||
var proxy := AnimatableBody2D.new()
|
||
proxy.name = "CollisionBody"
|
||
proxy.collision_layer = COLLISION_LAYER
|
||
proxy.collision_mask = COLLISION_LAYER
|
||
# RemoteTransform2D moves the Body/* visual after the physics body's own
|
||
# synchronization step. Letting a child AnimatableBody2D synchronize
|
||
# itself makes Godot counter-transform it, leaving capsules upright at
|
||
# stale world positions. Keep the proxy top-level and copy the rendered
|
||
# visual transform explicitly once per physics frame instead.
|
||
proxy.sync_to_physics = false
|
||
proxy.set_meta("stickman_rig", self)
|
||
proxy.set_meta("part_name", key)
|
||
var shape_node := CollisionShape2D.new()
|
||
shape_node.name = "CollisionShape2D"
|
||
if str(definition["shape"]) == "circle":
|
||
var circle := CircleShape2D.new()
|
||
var bounds := _mounted_visual_bounds_local(visual)
|
||
if bool(bounds["valid"]):
|
||
var rect := bounds["rect"] as Rect2
|
||
shape_node.position = rect.get_center()
|
||
circle.radius = maxf(float(definition["radius"]), maxf(rect.size.x, rect.size.y) * 0.5)
|
||
else:
|
||
shape_node.position = HEAD_COLLISION_FALLBACK_CENTER
|
||
circle.radius = float(definition["radius"])
|
||
shape_node.shape = circle
|
||
else:
|
||
var length: float = absf(_head_bone.position.y) if key == "torso" and _head_bone != null else bone.length
|
||
_collision_lengths[key] = length
|
||
var capsule := CapsuleShape2D.new()
|
||
capsule.height = length
|
||
capsule.radius = float(definition["radius"])
|
||
shape_node.shape = capsule
|
||
shape_node.position = Vector2(0.0, length * 0.5)
|
||
proxy.add_child(shape_node)
|
||
visual.add_child(proxy)
|
||
proxy.top_level = true
|
||
proxy.global_transform = visual.global_transform
|
||
_collision_bodies[key] = proxy
|
||
_collision_visuals[key] = visual
|
||
|
||
|
||
func _sync_collision_bodies() -> void:
|
||
for key: String in _collision_bodies:
|
||
var proxy := _collision_bodies.get(key) as AnimatableBody2D
|
||
var visual := _collision_visuals.get(key) as Node2D
|
||
if proxy == null or visual == null or not is_instance_valid(proxy) or not is_instance_valid(visual):
|
||
continue
|
||
proxy.global_transform = visual.global_transform
|
||
|
||
|
||
func _mounted_visual_bounds_local(part: Node2D) -> Dictionary:
|
||
var points: Array[Vector2] = []
|
||
var nodes: Array[Node] = [part]
|
||
while not nodes.is_empty():
|
||
var child: Node = nodes.pop_back()
|
||
if child is Line2D:
|
||
for point: Vector2 in (child as Line2D).points:
|
||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||
elif child is Polygon2D:
|
||
for point: Vector2 in (child as Polygon2D).polygon:
|
||
points.append(part.to_local((child as Node2D).to_global(point)))
|
||
for grandchild: Node in child.get_children():
|
||
nodes.append(grandchild)
|
||
if points.is_empty():
|
||
return {"valid": false, "rect": Rect2()}
|
||
var rect := Rect2(points[0], Vector2.ZERO)
|
||
for point: Vector2 in points:
|
||
rect = rect.expand(point)
|
||
return {"valid": true, "rect": rect}
|
||
|
||
|
||
func set_collision_bodies_enabled(enabled: bool) -> void:
|
||
_set_collision_bodies_enabled(enabled)
|
||
|
||
|
||
func _set_collision_bodies_enabled(enabled: bool) -> void:
|
||
var layer := COLLISION_LAYER if enabled else 0
|
||
for proxy: AnimatableBody2D in _collision_bodies.values():
|
||
if proxy != null and is_instance_valid(proxy):
|
||
proxy.collision_layer = layer
|
||
proxy.collision_mask = layer
|
||
|
||
|
||
func apply_collision_reaction(
|
||
part_name: String,
|
||
impact_direction: Vector2,
|
||
impact_speed: float,
|
||
impact_position: Vector2 = Vector2.INF
|
||
) -> void:
|
||
if state != RigState.ANIMATED or impact_direction.is_zero_approx() or impact_speed <= 0.0:
|
||
return
|
||
for target: Dictionary in REACTION_TARGETS.get(part_name, []):
|
||
var joint := str(target["joint"])
|
||
var bone := _reaction_bones.get(joint) as Bone2D
|
||
if bone == null or not is_instance_valid(bone):
|
||
continue
|
||
var direction := _collision_contact_direction(part_name, impact_position, impact_direction)
|
||
if is_zero_approx(direction):
|
||
var local_dir := bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||
direction = signf(local_dir.x) if absf(local_dir.x) > REACTION_LOCAL_X_EPSILON else 0.0
|
||
if is_zero_approx(direction):
|
||
direction = -1.0 if joint.begins_with("Left") else 1.0
|
||
var angle := direction * minf(
|
||
REACTION_MAX_ANGLE,
|
||
REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED
|
||
) * float(target["weight"])
|
||
var old: Dictionary = _impact_reactions.get(joint, {})
|
||
if old.is_empty():
|
||
_impact_reactions[joint] = {
|
||
"angle": angle,
|
||
"strength": 1.0,
|
||
"decay": float(target["decay"]),
|
||
"base_rotation": bone.rotation,
|
||
}
|
||
else:
|
||
if absf(angle) >= absf(float(old.get("angle", 0.0))):
|
||
old["angle"] = angle
|
||
old["decay"] = float(target["decay"])
|
||
old["strength"] = 1.0
|
||
_impact_reactions[joint] = old
|
||
|
||
|
||
func _collision_contact_direction(
|
||
part_name: String,
|
||
impact_position: Vector2,
|
||
impact_direction: Vector2
|
||
) -> float:
|
||
if not impact_position.is_finite():
|
||
return 0.0
|
||
var proxy := _collision_bodies.get(part_name) as AnimatableBody2D
|
||
if proxy == null or not is_instance_valid(proxy):
|
||
return 0.0
|
||
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||
if shape_node == null:
|
||
return 0.0
|
||
# Proxies use the hanging frame, so local X identifies which side was hit.
|
||
# A downward hit on the upper-left must rotate opposite to an upward hit on
|
||
# the lower-left. Combining the side with the force's local Y component
|
||
# supplies that top/bottom inversion and remains mirror-correct.
|
||
var local_hit := proxy.to_local(impact_position)
|
||
var side := local_hit.x - shape_node.position.x
|
||
if absf(side) <= REACTION_LOCAL_X_EPSILON:
|
||
return 0.0
|
||
var local_force := proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
|
||
if absf(local_force.y) > REACTION_LOCAL_X_EPSILON:
|
||
return signf(side * local_force.y)
|
||
# A nearly horizontal impact has no useful top/bottom component; retain the
|
||
# established side-only response for that case.
|
||
return signf(side)
|
||
|
||
|
||
func _clear_impact_reactions(restore_bones: bool = false) -> void:
|
||
if restore_bones:
|
||
for joint: String in _impact_reactions:
|
||
var reaction: Dictionary = _impact_reactions[joint]
|
||
var bone := _reaction_bones.get(joint) as Bone2D
|
||
if bone != null and is_instance_valid(bone) and reaction.has("base_rotation"):
|
||
bone.rotation = float(reaction["base_rotation"])
|
||
_impact_reactions.clear()
|
||
|
||
|
||
func _apply_impact_reactions(delta: float) -> void:
|
||
if state != RigState.ANIMATED:
|
||
_clear_impact_reactions()
|
||
return
|
||
for joint: String in _impact_reactions.keys().duplicate():
|
||
var reaction: Dictionary = _impact_reactions[joint]
|
||
var bone := _reaction_bones.get(joint) as Bone2D
|
||
if bone == null or not is_instance_valid(bone):
|
||
_impact_reactions.erase(joint)
|
||
continue
|
||
# Always pose relative to the rotation captured at impact. Adding the
|
||
# whole offset every frame accumulates several complete revolutions during
|
||
# a 0.5 s head flinch and bypasses the intended angular cap.
|
||
var base_rotation := float(reaction.get("base_rotation", bone.rotation))
|
||
bone.rotation = base_rotation + float(reaction["angle"]) * float(reaction["strength"])
|
||
reaction["strength"] = float(reaction["strength"]) - delta / maxf(float(reaction["decay"]), 0.001)
|
||
if float(reaction["strength"]) <= 0.0:
|
||
bone.rotation = base_rotation
|
||
_impact_reactions.erase(joint)
|
||
else:
|
||
_impact_reactions[joint] = reaction
|
||
|
||
|
||
## 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 _walking
|
||
var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations()
|
||
_set_limb_mods_enabled(not pin_mirrored_idle)
|
||
if not pin_mirrored_idle:
|
||
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
|
||
## FORWARD canonical aim rotation (PI), 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 live (ANIMATED/RECOVERING)
|
||
## because re-enabling the IK stack during recovery would otherwise let the
|
||
## LookAt flip the bone again.
|
||
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 = false#not mirrored
|
||
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
|
||
_head_bone.rotation = PI
|
||
elif not mirrored:
|
||
_head_bone.rotation = 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)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ragdoll translation (kinematic -> physics)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Captures the kinematic puppet's current state at the moment ragdoll is
|
||
## entered: the 6 IK-target marker values, the 8 limb bone rotations, the head
|
||
## bone rotation, the facing profile, the 4 live bend flags, and the head
|
||
## LookAt mod's enabled state. This snapshot is the recovery tween's END target
|
||
## — the figure returns to the exact pose it had just before the fall (which
|
||
## may be a mid-walk pose); the animation is not resumed.
|
||
func _capture_pre_ragdoll_state() -> void:
|
||
_pre_ragdoll_state.clear()
|
||
var markers := {}
|
||
for marker_name: String in STAND_POSE:
|
||
var marker := _get_ik_marker(marker_name)
|
||
if marker != null:
|
||
markers[marker_name] = { "pos": marker.position, "rot": marker.rotation }
|
||
_pre_ragdoll_state["markers"] = markers
|
||
var limb_rotations := {}
|
||
for joint: String in _limb_bones:
|
||
var pair: Dictionary = _limb_bones[joint]
|
||
var upper: Bone2D = pair.get("upper")
|
||
var lower: Bone2D = pair.get("lower")
|
||
limb_rotations[joint] = {
|
||
"upper": upper.rotation if upper != null and is_instance_valid(upper) else 0.0,
|
||
"lower": lower.rotation if lower != null and is_instance_valid(lower) else 0.0,
|
||
}
|
||
_pre_ragdoll_state["limb_rotations"] = limb_rotations
|
||
_pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null and is_instance_valid(_head_bone) else 0.0
|
||
_pre_ragdoll_state["facing_profile"] = int(facing_profile)
|
||
var bends := {}
|
||
for joint: String in BEND_JOINTS:
|
||
bends[joint] = get_joint_bend_flipped(joint)
|
||
_pre_ragdoll_state["bends"] = bends
|
||
_pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null and is_instance_valid(_head_look_at) else false
|
||
# Per-leg knee side (sign of the hip->ankle vs hip->knee cross) at entry,
|
||
# used at the snap to set the leg IK bend flags so the re-solve keeps the
|
||
# captured knee side instead of flipping to the mirrored solution.
|
||
var leg_sides := {}
|
||
for leg_joint: String in ["LeftLeg", "RightLeg"]:
|
||
var pair: Dictionary = _limb_bones.get(leg_joint, {})
|
||
var upper: Bone2D = pair.get("upper")
|
||
var lower: Bone2D = pair.get("lower")
|
||
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
|
||
continue
|
||
var marker_name: String = "Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg"
|
||
var ankle_marker := _get_ik_marker(marker_name)
|
||
if ankle_marker == null:
|
||
continue
|
||
var hip := upper.global_position
|
||
var knee := lower.global_position
|
||
var ankle := ankle_marker.global_position
|
||
var side := (ankle - hip).cross(knee - hip)
|
||
leg_sides[leg_joint] = { "side": 1.0 if side >= 0.0 else -1.0, "flag": get_joint_bend_flipped(leg_joint) }
|
||
_pre_ragdoll_state["leg_sides"] = leg_sides
|
||
# Chain-space offsets that map captured ragdoll angles onto the node chains:
|
||
# the torso marker's entry rotation relative to the entry spine direction,
|
||
# and the head bone's entry chain relative to the head body's entry angle
|
||
# (the head ragdoll body spawns with rotation 0). Captured here so the
|
||
# recovery snap needs no authored geometry constants, and the mirrored
|
||
# root's ambiguous global_rotation decomposition (+/-PI) cancels out.
|
||
var torso_entry_rot := 0.0
|
||
var torso_marker := _get_ik_marker("Torso")
|
||
if torso_marker != null:
|
||
torso_entry_rot = torso_marker.rotation
|
||
var spine_entry_rel := 0.0
|
||
if _head_bone != null and _torso_bone != null and is_instance_valid(_head_bone) and is_instance_valid(_torso_bone):
|
||
spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - global_rotation
|
||
_pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot
|
||
var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0))
|
||
_pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-global_rotation) - head_entry_chain
|
||
|
||
|
||
func _enter_ragdoll() -> void:
|
||
if _skeleton == null or _body_container == null:
|
||
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
|
||
return
|
||
_capture_pre_ragdoll_state()
|
||
_impact_reactions.clear()
|
||
_set_collision_bodies_enabled(false)
|
||
# Preserve the live solved pose for the instant ragdoll handoff. A normal
|
||
# queue cancellation restores the idle markers/limbs, which would pop here.
|
||
_cancel_walking(false)
|
||
_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
|
||
# authored rest pose). Body/* is then hidden immediately and IK disabled —
|
||
# no crossfade, because the ragdoll is spawned at exactly the same pose, so
|
||
# a fade would only read as ghosting.
|
||
if _anim_player != null and is_instance_valid(_anim_player):
|
||
_anim_player.stop(true)
|
||
_build_ragdoll()
|
||
if is_instance_valid(_body_container):
|
||
_body_container.visible = false
|
||
_body_container.modulate.a = 1.0
|
||
if _skeleton.modification_stack != null:
|
||
_skeleton.modification_stack.enabled = false
|
||
state = RigState.RAGDOLL
|
||
state_changed.emit(int(state))
|
||
_rest_timer = 0.0
|
||
_stabilize_timer = 0.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recovery (ragdoll -> kinematic stand-up)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Captures every ragdoll body's global transform into rig-local space, plus
|
||
## each capsule's half-length (from build-time metadata) so the snap can derive
|
||
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
|
||
func _capture_ragdoll_pose() -> void:
|
||
_captured_pose.clear()
|
||
_captured_landing_center = Vector2.ZERO
|
||
_captured_ground_y = 0.0
|
||
# While capturing, also compute the ragdoll's full top/bottom extent and
|
||
# collect every body's RID so the ground probe can raycast the terrain under
|
||
# the fallen figure while excluding the ragdoll's own collision shapes.
|
||
var top_y := INF
|
||
var bottom_y := -INF
|
||
var exclude_rids: Array[RID] = []
|
||
for key: String in _ragdoll_bodies:
|
||
var body := _ragdoll_bodies[key] as RigidBody2D
|
||
if body == null or not is_instance_valid(body):
|
||
continue
|
||
var half := float(body.get_meta("half_height", 0.0))
|
||
var radius := float(body.get_meta("radius", 0.0))
|
||
var extent := half + radius
|
||
top_y = minf(top_y, body.global_position.y - extent)
|
||
bottom_y = maxf(bottom_y, body.global_position.y + extent)
|
||
exclude_rids.append(body.get_rid())
|
||
_captured_pose[key] = {
|
||
"pos": to_local(body.global_position),
|
||
"rot": body.global_rotation - global_rotation,
|
||
"half": half,
|
||
}
|
||
# Record the ragdoll torso's world center + probe the terrain surface under
|
||
# the fallen figure so recovery can re-anchor the root's feet onto wherever
|
||
# the ragdoll actually landed (flat ground, slopes and ramp edges included).
|
||
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||
if torso != null and is_instance_valid(torso):
|
||
_captured_landing_center = torso.global_position
|
||
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
|
||
# Retain the probe context so the ankle clamp in _snap_skeleton_to_pose()
|
||
# can re-probe the terrain at each ankle's X using the same extents and
|
||
# exclusion set (without re-running the capture loop).
|
||
_probe_top_y = top_y
|
||
_probe_bottom_y = bottom_y
|
||
_probe_exclude = exclude_rids
|
||
|
||
|
||
## Fans downward raycasts around `world_x` to find the terrain surface under the
|
||
## landed ragdoll. Returns the accepted hit whose y is closest to `bottom_y`, or
|
||
## `bottom_y` when no acceptable hit is found. `exclude` carries the ragdoll
|
||
## bodies' RIDs so the probe ignores its own collision shapes.
|
||
func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
|
||
var best_y := bottom_y
|
||
var best_dist := INF
|
||
var space_state := get_world_2d().direct_space_state
|
||
if space_state == null:
|
||
return best_y
|
||
for offset: float in GROUND_PROBE_X_OFFSETS:
|
||
var query := PhysicsRayQueryParameters2D.create(
|
||
Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET),
|
||
Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET))
|
||
query.collision_mask = 1
|
||
query.collide_with_areas = false
|
||
query.collide_with_bodies = true
|
||
query.exclude = exclude
|
||
var hit := space_state.intersect_ray(query)
|
||
if hit.is_empty():
|
||
continue
|
||
var hit_position: Vector2 = hit.get("position", Vector2.ZERO)
|
||
if hit_position.y < top_y - GROUND_PROBE_TOLERANCE or hit_position.y > bottom_y + GROUND_PROBE_TOLERANCE:
|
||
continue
|
||
var dist := absf(hit_position.y - bottom_y)
|
||
if dist < best_dist:
|
||
best_dist = dist
|
||
best_y = hit_position.y
|
||
return best_y
|
||
|
||
|
||
func _start_recovery() -> void:
|
||
if state != RigState.RAGDOLL:
|
||
return
|
||
_capture_ragdoll_pose()
|
||
_destroy_ragdoll()
|
||
_reanchor_root_to_landing()
|
||
state = RigState.RECOVERING
|
||
state_changed.emit(int(state))
|
||
_snap_skeleton_to_pose()
|
||
_set_collision_bodies_enabled(true)
|
||
_play_stand_up()
|
||
|
||
|
||
## Re-anchors the rig root so the standing figure's feet sit on the ground at
|
||
## the ragdoll's landing X. The ragdoll bodies live under a world sibling (not
|
||
## the rig), so the rig root never moved while it fell; without this the
|
||
## stand-up tween would drag the figure back to the root's pre-ragdoll world
|
||
## position.
|
||
func _reanchor_root_to_landing() -> void:
|
||
if not _captured_pose.has("torso"):
|
||
return
|
||
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
|
||
var old_root := global_position
|
||
var new_root := feet_world + FOOT_OFFSET
|
||
global_position = new_root
|
||
# Re-base the captured (rig-local) pose onto the re-anchored root so the
|
||
# snap reproduces the ragdoll's world pose, not the pre-ragdoll one. The
|
||
# re-base must be mirror-aware: a world-space delta maps into rig-local
|
||
# space through the root basis's inverse (the LEFT X-mirror negates X;
|
||
# adding the raw shift would offset every position by twice the distance).
|
||
var shift := old_root - new_root
|
||
var local_shift := to_local(shift) - to_local(Vector2.ZERO)
|
||
for key: String in _captured_pose:
|
||
var entry: Dictionary = _captured_pose[key]
|
||
entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift
|
||
|
||
|
||
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
|
||
## clean slate.
|
||
func _cancel_recovery() -> void:
|
||
_stand_up_generation += 1
|
||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||
_stand_up_tween.kill()
|
||
_stand_up_tween = null
|
||
|
||
|
||
## Re-enables the Skeleton2D modification stack after RAGDOLL and forces it to
|
||
## resume solving. A disabled->enabled toggle alone can leave the stack's
|
||
## internal solve state stale (the classic Godot disable->enable quirk), so we
|
||
## explicitly re-setup the stack when it reports !is_setup, ensure Skeleton2D
|
||
## processes internally (required for the stack to execute), and re-assert the
|
||
## Torso marker's RemoteTransform2D update flags so the hip bone keeps
|
||
## following IK_Targets/Torso through the stand-up tween.
|
||
func _rearm_ik_stack() -> void:
|
||
if _skeleton == null or not is_instance_valid(_skeleton):
|
||
return
|
||
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||
if stack != null:
|
||
if not stack.get_is_setup():
|
||
stack.setup()
|
||
stack.enabled = true
|
||
_skeleton.set_process_internal(true)
|
||
# Torso marker -> Torso bone driver: ensure it forwards position/rotation.
|
||
var torso_marker := _get_ik_marker("Torso")
|
||
if torso_marker != null:
|
||
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
|
||
if driver != null:
|
||
driver.update_position = true
|
||
driver.update_rotation = true
|
||
driver.update_scale = true
|
||
_recovery_dbg()
|
||
|
||
|
||
## Logs the IK stack + bone-following state during recovery (off by default).
|
||
func _recovery_dbg() -> void:
|
||
if not DEBUG_RECOVERY:
|
||
return
|
||
var stack: SkeletonModificationStack2D = null
|
||
var stack_enabled := false
|
||
var stack_setup := false
|
||
var internal := false
|
||
if _skeleton != null and is_instance_valid(_skeleton):
|
||
stack = _skeleton.modification_stack
|
||
stack_enabled = stack != null and stack.enabled
|
||
stack_setup = stack != null and stack.get_is_setup()
|
||
internal = _skeleton.is_processing_internal()
|
||
var torso_bone_pos := Vector2.ZERO
|
||
if _torso_bone != null and is_instance_valid(_torso_bone):
|
||
torso_bone_pos = _torso_bone.global_position
|
||
var torso_marker := _get_ik_marker("Torso")
|
||
var torso_marker_pos := torso_marker.global_position if torso_marker != null else Vector2.ZERO
|
||
var limb := _bend_joint_bones.get("LeftLeg") as Bone2D
|
||
var limb_pos := limb.global_position if limb != null and is_instance_valid(limb) else Vector2.ZERO
|
||
print("[recovery] stack.enabled=%s is_setup=%s internal=%s torso_bone=%s torso_marker=%s left_lower_leg=%s" % [
|
||
str(stack_enabled), str(stack_setup), str(internal),
|
||
str(torso_bone_pos), str(torso_marker_pos), str(limb_pos),
|
||
])
|
||
|
||
|
||
## Converts a captured relative angle (ragdoll body world rotation minus the
|
||
## rig root's global_rotation) into the skeleton's chain-space angle. Under the
|
||
## LEFT whole-rig X-mirror the root's global_rotation decomposes as (PI - phi),
|
||
## so captured relative angles come out negated: chain = -captured.
|
||
func _chain_angle_from_captured(captured_rel: float) -> float:
|
||
if facing_profile == FacingProfile.LEFT:
|
||
return -captured_rel
|
||
return captured_rel
|
||
|
||
|
||
## Wraps `value` to within +/-PI of `target` (shortest-rotation path) so a bone
|
||
## rotation tween never spins the long way around. The wrapped value renders
|
||
## identically to the raw one (2PI-periodic).
|
||
func _wrap_toward(value: float, target: float) -> float:
|
||
return target + wrapf(value - target, -PI, PI)
|
||
|
||
|
||
## Returns the tween target for a HINGE joint (elbows/knees only) so it bends
|
||
## the natural way, evaluated in the joint's RENDERED space (the value space is
|
||
## offset by `bone_angle_lower - bone_angle_upper` — e.g. -90deg for the legs).
|
||
## The shortest rendered path is used unless it would cross the +/-PI
|
||
## (fully-backward) boundary (opposite signs with |a| + |b| > PI) — a joint
|
||
## that fell bent backwards must straighten through 0 instead of folding
|
||
## further backwards. The returned target differs from `target` only by whole
|
||
## turns.
|
||
func _natural_joint_target(current: float, target: float, offset: float) -> float:
|
||
var a := wrapf(current + offset, -PI, PI)
|
||
var b := wrapf(target + offset, -PI, PI)
|
||
var d := wrapf(b - a, -PI, PI)
|
||
if a * b < 0.0 and absf(a) + absf(b) > PI:
|
||
d += TAU if d < 0.0 else -TAU
|
||
return current + d
|
||
|
||
|
||
## 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
|
||
|
||
|
||
## Restores the head LookAt mod's enabled state to what it was captured as
|
||
## before ragdoll (fallback: disabled).
|
||
func _restore_head_lookat_state() -> void:
|
||
if _head_look_at == null or not is_instance_valid(_head_look_at):
|
||
return
|
||
_head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false))
|
||
|
||
|
||
## Kinematic snap: writes the captured ragdoll pose onto the 6 IK-target
|
||
## markers (NOT the Torso Bone2D, which is slaved to its marker via
|
||
## RemoteTransform2D), drives the ARMS' limb Bone2D rotations directly from the
|
||
## captured body angles, and sets the head bone from the head body. The two arm
|
||
## TwoBoneIK mods and the head LookAt are disabled for the snap so the re-solve
|
||
## cannot overwrite the directly-driven bones. The two LEG TwoBoneIK mods are
|
||
## instead ENABLED so the legs solve toward their ankle markers — the ankles
|
||
## are clamped up onto the terrain surface (via a ground probe at each ankle's
|
||
## X) and then tween along the ground to the pre-ragdoll ankle positions while
|
||
## the torso marker rises, so the feet never sink below the ground. Each leg
|
||
## mod's bend side is matched to the captured knee side so the re-solve cannot
|
||
## flip a knee. The stand-up tween then rotates the markers + arms + head to
|
||
## the PRE-ragdoll captured state and re-enables the arm mods.
|
||
##
|
||
## Geometry notes: the ragdoll capsules span joint origin -> tip along their
|
||
## +X (body.rotation IS the segment direction), so the real joints are at
|
||
## center ± direction * half_height. All derived joint points go through
|
||
## to_local(to_global(pos) + world_dir * half) so the LEFT whole-rig X-mirror
|
||
## and any rig root rotation are handled by the transform math itself. Rotations
|
||
## use entry-captured offsets instead of authored constants: the torso marker
|
||
## rotation θ_t is the captured spine chain minus `torso_chain_offset` (which
|
||
## absorbs the bone_angle / driver conventions), the limbs parent on θ_t, and
|
||
## the head bone is the captured head chain minus `head_chain_offset` (the head
|
||
## ragdoll body's baked entry chain) minus θ_t. Every snapped rotation is
|
||
## normalized into (-PI, PI] because Godot's Skeleton2D re-wraps bone rotations
|
||
## internally, which would otherwise corrupt the tween's captured START.
|
||
##
|
||
## This function ALSO computes and stores the per-bone tween targets in
|
||
## `_recovery_bones` (the torso marker, the 4 arm limb bones, and the head
|
||
## bone). Shoulders (upper arm bones) and the head take the shortest value
|
||
## path; elbows (lower arm bones) use `_natural_joint_target` so a joint that
|
||
## fell bent backwards straightens through 0 instead of folding further
|
||
## backwards through +/-PI. The legs are excluded from the bone tween — they
|
||
## are IK-driven toward their tweening ankle markers. `_play_stand_up()` then
|
||
## only starts the tween from those stored targets.
|
||
func _snap_skeleton_to_pose() -> void:
|
||
_recovery_bones.clear()
|
||
var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {})
|
||
var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
|
||
var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0))
|
||
var pre_torso_rot := 0.0
|
||
var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0))
|
||
var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0))
|
||
var theta_t := 0.0
|
||
var torso_chain := 0.0
|
||
var torso_marker := _get_ik_marker("Torso")
|
||
if torso_marker != null:
|
||
var torso_pose: Dictionary = _captured_pose.get("torso", {})
|
||
if not torso_pose.is_empty():
|
||
var torso_rel := float(torso_pose.get("rot", 0.0))
|
||
# World-space spine direction: the captured angle is relative to the
|
||
# root's global_rotation, so add it back to get the true world angle.
|
||
var spine_dir := Vector2.from_angle(torso_rel + global_rotation)
|
||
var half := float(torso_pose.get("half", 0.0))
|
||
# Hip = spine bottom end of the torso capsule, converted through
|
||
# to_local(to_global(...)) so the LEFT X-mirror and any rig root
|
||
# rotation are handled by the transform math itself.
|
||
var center_world := to_global(torso_pose.get("pos", Vector2.ZERO))
|
||
torso_marker.position = to_local(center_world - spine_dir * half)
|
||
torso_chain = _chain_angle_from_captured(torso_rel)
|
||
# The torso bone's node rotation = the captured spine chain minus
|
||
# the entry-captured offset (the bone_angle / driver conventions
|
||
# are baked into the offset, so no authored constants are needed).
|
||
var raw_rot := torso_chain - torso_chain_offset
|
||
pre_torso_rot = float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rot))
|
||
theta_t = _wrap_toward(raw_rot, pre_torso_rot)
|
||
theta_t = wrapf(theta_t, -PI, PI)
|
||
torso_marker.rotation = theta_t
|
||
|
||
var head_marker := _get_ik_marker("Head")
|
||
if head_marker != null:
|
||
var head_pose: Dictionary = _captured_pose.get("head", {})
|
||
if not head_pose.is_empty():
|
||
head_marker.position = head_pose.get("pos", head_marker.position)
|
||
|
||
_set_marker_from_body("Left_Hand", "left_lower_arm")
|
||
_set_marker_from_body("Right_Hand", "right_lower_arm")
|
||
_set_marker_from_body("Left_Leg", "left_lower_leg")
|
||
_set_marker_from_body("Right_Leg", "right_lower_leg")
|
||
|
||
# Clamp the ankle markers up onto the terrain surface so the feet never
|
||
# sink below the ground during the stand-up (the leg IK follows them).
|
||
for leg_key: String in ["Left_Leg", "Right_Leg"]:
|
||
var leg_marker := _get_ik_marker(leg_key)
|
||
if leg_marker == null:
|
||
continue
|
||
var body_key: String = "left_lower_leg" if leg_key == "Left_Leg" else "right_lower_leg"
|
||
if _captured_pose.get(body_key, {}).is_empty():
|
||
continue
|
||
var ankle_world := to_global(leg_marker.position)
|
||
var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude)
|
||
if ankle_world.y < ground_y:
|
||
ankle_world.y = ground_y
|
||
leg_marker.position = to_local(ankle_world)
|
||
|
||
# Drive the limb bones directly from the captured body angles: the ragdoll
|
||
# body angle IS the bone DIRECTION, while the Body/* visuals follow the bone
|
||
# NODE rotation, so the bone's own bone_angle is subtracted. The parent
|
||
# chain is the torso marker's snapped node rotation θ_t (the captured spine
|
||
# chain minus the entry-captured torso_chain_offset). The snapped rotations
|
||
# are normalized into (-PI, PI] (Godot re-wraps bone rotations internally);
|
||
# upper bones take the shortest path and lower bones use
|
||
# `_natural_joint_target` (through straight when bent backwards).
|
||
if not _captured_pose.get("torso", {}).is_empty():
|
||
for joint: String in LIMB_BONE_PATHS:
|
||
var pair: Dictionary = _limb_bones.get(joint, {})
|
||
var upper: Bone2D = pair.get("upper")
|
||
var lower: Bone2D = pair.get("lower")
|
||
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
|
||
continue
|
||
var body_keys: Array = LIMB_BODY_KEYS[joint]
|
||
var upper_pose: Dictionary = _captured_pose.get(body_keys[0], {})
|
||
var lower_pose: Dictionary = _captured_pose.get(body_keys[1], {})
|
||
if upper_pose.is_empty() or lower_pose.is_empty():
|
||
continue
|
||
var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0)))
|
||
var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0)))
|
||
var pre_pair: Dictionary = pre_limb_rotations.get(joint, {})
|
||
var parent_chain := theta_t
|
||
upper.rotation = _wrap_toward(
|
||
upper_chain - parent_chain - deg_to_rad(upper.bone_angle),
|
||
float(pre_pair.get("upper", upper.rotation)))
|
||
# The lower bone's node parent chain does NOT include the upper
|
||
# bone's bone_angle (bone_angle only appears in the ragdoll body
|
||
# direction), so it is added back before subtracting the lower's.
|
||
lower.rotation = _wrap_toward(
|
||
lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle),
|
||
float(pre_pair.get("lower", lower.rotation)))
|
||
# Normalize the snapped rotations into (-PI, PI]: Godot's Skeleton2D
|
||
# re-wraps bone rotations on its next internal process, which would
|
||
# otherwise change the tween's captured START and send it the long
|
||
# way around. With the start normalized, the wrap is a no-op.
|
||
upper.rotation = wrapf(upper.rotation, -PI, PI)
|
||
lower.rotation = wrapf(lower.rotation, -PI, PI)
|
||
if joint == "LeftLeg" or joint == "RightLeg":
|
||
# Legs are IK-driven during the stand-up (the ankles follow the
|
||
# ground-clamped markers then tween to the pre positions), so
|
||
# they get NO direct bone tween — but they ARE snapped to the
|
||
# captured pose above so the first recovery frame matches the
|
||
# ragdoll and the knee-side matching sees the true fallen pose.
|
||
continue
|
||
# Shoulders/hips (upper bones) take the shortest path; elbows/knees
|
||
# (lower bones) must pass through straight when the ragdoll bent
|
||
# them backwards.
|
||
var pre_u := float(pre_pair.get("upper", upper.rotation))
|
||
var pre_l := float(pre_pair.get("lower", lower.rotation))
|
||
var target_u := upper.rotation + wrapf(pre_u - upper.rotation, -PI, PI)
|
||
# The joint's rendered bend is offset by bone_angle_lower -
|
||
# bone_angle_upper (e.g. -90deg for the legs): evaluate the natural
|
||
# rule in that rendered space so the knee unfolds through straight
|
||
# instead of being sent the long way around. A "shortest" entry in
|
||
# RECOVERY_JOINT_PATH skips the natural rule entirely.
|
||
var target_l: float
|
||
var path_override := String(RECOVERY_JOINT_PATH.get(facing_profile, {}).get(joint, ""))
|
||
if path_override == "shortest":
|
||
target_l = lower.rotation + wrapf(pre_l - lower.rotation, -PI, PI)
|
||
else:
|
||
target_l = _natural_joint_target(lower.rotation, pre_l, deg_to_rad(lower.bone_angle - upper.bone_angle))
|
||
_recovery_bones.append({ "bone": upper, "target": target_u })
|
||
_recovery_bones.append({ "bone": lower, "target": target_l })
|
||
|
||
# The head ragdoll body is a "visual" body spawned with rotation 0, so its
|
||
# cloned visual carries the entry head chain as a baked offset — captured
|
||
# at entry and subtracted back here.
|
||
if not _captured_pose.get("head", {}).is_empty() and _head_bone != null and is_instance_valid(_head_bone):
|
||
var head_rel := float((_captured_pose["head"] as Dictionary).get("rot", 0.0))
|
||
var raw_head := _chain_angle_from_captured(head_rel) - head_chain_offset - theta_t
|
||
_head_bone.rotation = _wrap_toward(raw_head, pre_head_rotation)
|
||
_head_bone.rotation = wrapf(_head_bone.rotation, -PI, PI)
|
||
var target_h := _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)
|
||
_recovery_bones.append({ "bone": _head_bone, "target": target_h })
|
||
|
||
# Show the kinematic puppet first so it appears already in the captured
|
||
# pose, then re-arm the stack (torso driver) with the limb TwoBoneIK mods
|
||
# and the head LookAt disabled so nothing re-solves the snapped bones.
|
||
if _body_container != null and is_instance_valid(_body_container):
|
||
_body_container.visible = true
|
||
_body_container.modulate.a = 1.0
|
||
_rearm_ik_stack()
|
||
_set_limb_mods_enabled(false)
|
||
if _head_look_at != null and is_instance_valid(_head_look_at):
|
||
_head_look_at.enabled = false
|
||
# Enable the two leg TwoBoneIK mods so the legs solve toward the ankle
|
||
# markers (clamped on the ground) while the torso marker rises. Match each
|
||
# leg's bend side to the captured pose so the re-solve cannot flip a knee.
|
||
for leg_joint: String in ["LeftLeg", "RightLeg"]:
|
||
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
|
||
if mod == null or not is_instance_valid(mod):
|
||
continue
|
||
var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {})
|
||
var entry: Dictionary = leg_sides.get(leg_joint, {})
|
||
var force_profile: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(facing_profile, {})
|
||
if force_profile.has(leg_joint):
|
||
# Manual override from the editable RECOVERY_LEG_SIDE_FORCE table.
|
||
mod.flip_bend_direction = bool(force_profile[leg_joint])
|
||
elif not entry.is_empty():
|
||
# Derive the captured knee side from the RAGDOLL capture in world
|
||
# space. The bone nodes are NOT reliable here: the torso marker's
|
||
# driver has not pushed the re-anchored hip yet, so bone globals
|
||
# would mix the stale entry hip with the snapped limb rotations
|
||
# and produce a garbage side (both knees flipping at the first
|
||
# solve).
|
||
var up_key: String = "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg"
|
||
var low_key: String = "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg"
|
||
var up_pose: Dictionary = _captured_pose.get(up_key, {})
|
||
var low_pose: Dictionary = _captured_pose.get(low_key, {})
|
||
if not up_pose.is_empty() and not low_pose.is_empty():
|
||
var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + global_rotation)
|
||
var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + global_rotation)
|
||
var half_u := float(up_pose.get("half", 0.0))
|
||
var half_l := float(low_pose.get("half", 0.0))
|
||
var center_u := to_global(up_pose.get("pos", Vector2.ZERO))
|
||
var hip := center_u - dir_u * half_u
|
||
var knee := center_u + dir_u * half_u
|
||
var ankle := to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * half_l
|
||
var side := (ankle - hip).cross(knee - hip)
|
||
var captured_side := 1.0 if side >= 0.0 else -1.0
|
||
var entry_side := float(entry.get("side", captured_side))
|
||
var entry_flag := bool(entry.get("flag", false))
|
||
mod.flip_bend_direction = entry_flag if captured_side == entry_side else not entry_flag
|
||
mod.enabled = true
|
||
|
||
|
||
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
|
||
var marker := _get_ik_marker(marker_name)
|
||
if marker == null:
|
||
return
|
||
var pose: Dictionary = _captured_pose.get(body_key, {})
|
||
if pose.is_empty():
|
||
return
|
||
# Far end (wrist / ankle) = body center + segment direction * half, in WORLD
|
||
# space (the captured angle is root-relative), converted back to local via
|
||
# to_local so the LEFT X-mirror and rig root rotation are handled exactly.
|
||
var dir := Vector2.from_angle(float(pose.get("rot", 0.0)) + global_rotation)
|
||
var half := float(pose.get("half", 0.0))
|
||
var center_world := to_global(pose.get("pos", Vector2.ZERO))
|
||
marker.position = to_local(center_world + dir * half)
|
||
|
||
|
||
func _get_ik_marker(name: String) -> Marker2D:
|
||
var path: String = IK_TARGET_PATHS.get(name, "")
|
||
if path.is_empty():
|
||
return null
|
||
return get_node_or_null(NodePath(path)) as Marker2D
|
||
|
||
|
||
## Stand-up: starts the tween from the captured ragdoll pose to the PRE-ragdoll
|
||
## captured state (sine ease-in-out, same duration). No baked animation — a fixed
|
||
## first keyframe can never match an arbitrary ragdoll rest pose, so the tween
|
||
## starts from wherever the snap left the markers/bones.
|
||
##
|
||
## The per-bone tween targets (minimal world-direction sweeps) are computed and
|
||
## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only
|
||
## starts the marker tween and appends one bone-rotation tween per entry.
|
||
func _play_stand_up() -> void:
|
||
_stand_up_generation += 1
|
||
var generation: int = _stand_up_generation
|
||
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
|
||
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
|
||
if _stand_up_tween != null:
|
||
for entry: Dictionary in _recovery_bones:
|
||
var bone: Bone2D = entry.get("bone")
|
||
if bone == null or not is_instance_valid(bone):
|
||
continue
|
||
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
|
||
_stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation))
|
||
|
||
|
||
## Tweens the 6 IK markers from their current (captured) values to the target
|
||
## pose over `duration` (sine ease-in-out), all in parallel.
|
||
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
|
||
var tween := create_tween()
|
||
tween.set_parallel(true)
|
||
tween.set_trans(Tween.TRANS_SINE)
|
||
tween.set_ease(Tween.EASE_IN_OUT)
|
||
for marker_name: String in target_pose:
|
||
var marker := _get_ik_marker(marker_name)
|
||
if marker == null:
|
||
continue
|
||
var target: Dictionary = target_pose[marker_name]
|
||
tween.tween_property(marker, "position", target.get("pos", marker.position), duration)
|
||
if marker_name == "Torso":
|
||
tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration)
|
||
return tween
|
||
|
||
|
||
## Stand-up tween complete: settle into ANIMATED.
|
||
func _on_stand_up_finished(generation: int) -> void:
|
||
if generation != _stand_up_generation or state != RigState.RECOVERING:
|
||
return
|
||
var recovered_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
|
||
# Restore the pre-ragdoll facing profile + per-joint bend flags so the
|
||
# re-enabled IK holds the exact pre-ragdoll pose.
|
||
set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile))))
|
||
var bends: Dictionary = _pre_ragdoll_state.get("bends", {})
|
||
for joint: String in BEND_JOINTS:
|
||
if bends.has(joint):
|
||
set_joint_bend_flipped(joint, bool(bends[joint]))
|
||
_set_limb_mods_enabled(true)
|
||
_restore_head_lookat_state()
|
||
_recovery_bones.clear()
|
||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||
_skeleton.modification_stack.enabled = true
|
||
if _body_container != null and is_instance_valid(_body_container):
|
||
_body_container.visible = true
|
||
_body_container.modulate.a = 1.0
|
||
_set_collision_bodies_enabled(true)
|
||
state = RigState.ANIMATED
|
||
_apply_idle_limb_solver_mode(recovered_limb_rotations)
|
||
state_changed.emit(int(state))
|
||
|
||
|
||
func _build_ragdoll() -> void:
|
||
var parent: Node = get_parent()
|
||
if parent == null:
|
||
parent = get_tree().current_scene
|
||
if parent == null:
|
||
push_warning("StickmanRig: cannot reparent ragdoll container; no parent or current scene.")
|
||
return
|
||
|
||
_ragdoll_root = Node2D.new()
|
||
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME
|
||
parent.add_child(_ragdoll_root)
|
||
|
||
_ragdoll_bodies.clear()
|
||
for entry: Dictionary in RAGDOLL_BODIES:
|
||
_build_ragdoll_body(entry)
|
||
for entry: Dictionary in RAGDOLL_JOINTS:
|
||
_build_ragdoll_joint(entry)
|
||
|
||
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||
if torso != null:
|
||
torso.linear_velocity = _cached_linear_velocity
|
||
torso.angular_velocity = _cached_angular_velocity
|
||
|
||
|
||
func _build_ragdoll_body(entry: Dictionary) -> void:
|
||
var key: String = entry["key"]
|
||
var body := RigidBody2D.new()
|
||
body.name = "Ragdoll_" + key
|
||
body.mass = float(entry["mass"])
|
||
body.linear_damp = float(entry["linear_damp"])
|
||
body.angular_damp = float(entry["angular_damp"])
|
||
body.gravity_scale = 1.0
|
||
body.lock_rotation = false
|
||
body.freeze = false
|
||
body.collision_layer = 1
|
||
body.collision_mask = 1
|
||
body.set_meta("stickman_rig", self)
|
||
body.set_meta("part_name", key)
|
||
body.set_meta("stickman_ragdoll_body", true)
|
||
|
||
var shape := CollisionShape2D.new()
|
||
shape.name = "CollisionShape2D"
|
||
|
||
if entry["kind"] == "visual":
|
||
var visual := get_node_or_null(NodePath(entry["node_path"])) as Node2D
|
||
if visual == null or not is_instance_valid(visual):
|
||
push_warning("StickmanRig: missing ragdoll visual node '%s'." % entry["node_path"])
|
||
body.queue_free()
|
||
return
|
||
var circle := CircleShape2D.new()
|
||
circle.radius = float(entry["radius"])
|
||
shape.shape = circle
|
||
body.add_child(shape)
|
||
body.position = visual.global_position
|
||
body.rotation = 0.0
|
||
body.set_meta("half_height", 0.0)
|
||
body.set_meta("radius", float(entry["radius"]))
|
||
if not _clone_part_visuals(body, str(entry.get("visual_path", ""))):
|
||
_add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR)
|
||
else:
|
||
var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D
|
||
if bone == null or not is_instance_valid(bone):
|
||
push_warning("StickmanRig: missing ragdoll bone '%s'." % entry["node_path"])
|
||
body.queue_free()
|
||
return
|
||
var origin: Vector2 = bone.global_position
|
||
var tip: Vector2
|
||
if key == "torso":
|
||
var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
|
||
if head_bone == null or not is_instance_valid(head_bone):
|
||
push_warning("StickmanRig: missing Head bone for torso ragdoll body.")
|
||
body.queue_free()
|
||
return
|
||
tip = head_bone.global_position
|
||
else:
|
||
# A Bone2D's length runs along its local +X rotated by `bone_angle`
|
||
# (stored in degrees). to_global(Vector2(length, 0)) alone ignores
|
||
# bone_angle, so rotate the tip vector by it to reach the real
|
||
# far-end joint (which coincides with the child bone's origin).
|
||
tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
|
||
var length: float = origin.distance_to(tip)
|
||
var midpoint: Vector2 = (origin + tip) * 0.5
|
||
var capsule := CapsuleShape2D.new()
|
||
capsule.height = length
|
||
capsule.radius = float(entry["radius"])
|
||
shape.shape = capsule
|
||
# CapsuleShape2D spans local +Y, but a Bone2D's length runs along local
|
||
# +X: rotate the shape -90° so the capsule aligns with the body's +X,
|
||
# which we point along the bone's origin->tip direction below.
|
||
shape.rotation = -PI / 2.0
|
||
body.add_child(shape)
|
||
body.position = midpoint
|
||
body.rotation = (tip - origin).angle()
|
||
# Half the capsule's length along the body's +X — lets recovery derive
|
||
# the joint ends (hip/wrist/ankle) from the body center at capture time.
|
||
body.set_meta("half_height", length * 0.5)
|
||
body.set_meta("radius", float(entry["radius"]))
|
||
if not _clone_part_visuals(body, str(entry.get("visual_path", ""))):
|
||
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
|
||
|
||
_ragdoll_root.add_child(body)
|
||
_ragdoll_bodies[key] = body
|
||
|
||
|
||
## Clones the part node's mounted shape children (Line2D open outlines /
|
||
## Polygon2D closed fills, added by StkRigAdapter as children of the Body/*
|
||
## part node) into the ragdoll body's local frame, so the ragdoll shows the
|
||
## stickman's own geometry instead of the gray fallback capsule/circle. Returns
|
||
## true if at least one shape was cloned.
|
||
func _clone_part_visuals(body: RigidBody2D, visual_path: String) -> bool:
|
||
if visual_path.is_empty():
|
||
return false
|
||
var source := get_node_or_null(NodePath(visual_path)) as Node2D
|
||
if source == null or not is_instance_valid(source):
|
||
return false
|
||
var cloned := 0
|
||
for child: Node in source.get_children():
|
||
if child is Line2D:
|
||
var src := child as Line2D
|
||
var copy := Line2D.new()
|
||
var pts := PackedVector2Array()
|
||
for p: Vector2 in src.points:
|
||
pts.append(body.to_local(src.to_global(p)))
|
||
copy.points = pts
|
||
copy.width = src.width
|
||
copy.default_color = src.default_color
|
||
if src.gradient != null:
|
||
copy.gradient = src.gradient
|
||
copy.begin_cap_mode = src.begin_cap_mode
|
||
copy.end_cap_mode = src.end_cap_mode
|
||
copy.joint_mode = src.joint_mode
|
||
copy.closed = src.closed
|
||
copy.antialiased = src.antialiased
|
||
copy.position = Vector2.ZERO
|
||
copy.name = "Visual" + str(body.get_child_count())
|
||
body.add_child(copy)
|
||
cloned += 1
|
||
elif child is Polygon2D:
|
||
var src := child as Polygon2D
|
||
var copy := Polygon2D.new()
|
||
var pts := PackedVector2Array()
|
||
for p: Vector2 in src.polygon:
|
||
pts.append(body.to_local(src.to_global(p)))
|
||
copy.polygon = pts
|
||
copy.color = src.color
|
||
copy.antialiased = src.antialiased
|
||
copy.position = Vector2.ZERO
|
||
copy.name = "Visual" + str(body.get_child_count())
|
||
body.add_child(copy)
|
||
cloned += 1
|
||
return cloned > 0
|
||
|
||
|
||
## Visible capsule mesh (Line2D with round caps) spanning the body's local +X,
|
||
## which `_build_ragdoll_body` already aligns with the bone's origin->tip
|
||
## direction. Collision shapes never render in-game, so this is what the player
|
||
## actually sees in RAGDOLL mode.
|
||
func _add_ragdoll_visual_capsule(body: RigidBody2D, length: float, radius: float, color: Color) -> void:
|
||
var line := Line2D.new()
|
||
line.name = "VisualCapsule"
|
||
line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)])
|
||
line.width = radius * 2.0
|
||
line.default_color = color
|
||
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
|
||
line.end_cap_mode = Line2D.LINE_CAP_ROUND
|
||
line.joint_mode = Line2D.LINE_JOINT_ROUND
|
||
body.add_child(line)
|
||
|
||
|
||
## Visible filled circle for the head body, matching the authored head circle.
|
||
func _add_ragdoll_visual_circle(body: RigidBody2D, radius: float, color: Color) -> void:
|
||
var poly := Polygon2D.new()
|
||
poly.name = "VisualCircle"
|
||
var points := PackedVector2Array()
|
||
for i: int in RAGDOLL_CIRCLE_SEGMENTS:
|
||
var angle: float = TAU * float(i) / float(RAGDOLL_CIRCLE_SEGMENTS)
|
||
points.append(Vector2(cos(angle), sin(angle)) * radius)
|
||
poly.polygon = points
|
||
poly.color = color
|
||
body.add_child(poly)
|
||
|
||
|
||
func _build_ragdoll_joint(entry: Dictionary) -> void:
|
||
var child_key: String = entry["child"]
|
||
var parent_key: String = entry["parent"]
|
||
var child_body := _ragdoll_bodies.get(child_key) as RigidBody2D
|
||
var parent_body := _ragdoll_bodies.get(parent_key) as RigidBody2D
|
||
if child_body == null or parent_body == null:
|
||
return
|
||
var pin_bone := _skeleton.get_node_or_null(NodePath(entry["pin_node_path"])) as Bone2D
|
||
if pin_bone == null or not is_instance_valid(pin_bone):
|
||
push_warning("StickmanRig: missing pin bone '%s' for ragdoll joint '%s'." % [entry["pin_node_path"], child_key])
|
||
return
|
||
|
||
var pin := PinJoint2D.new()
|
||
pin.name = "RagdollPin_" + child_key
|
||
pin.position = pin_bone.global_position
|
||
_ragdoll_root.add_child(pin)
|
||
pin.node_a = pin.get_path_to(parent_body)
|
||
pin.node_b = pin.get_path_to(child_body)
|
||
pin.softness = RAGDOLL_TARGET_SOFTNESS
|
||
_apply_ragdoll_joint_limits(pin, entry["limit"], str(entry["child"]))
|
||
|
||
|
||
## Applies angular limits to a ragdoll PinJoint2D. The elbow/knee band comes
|
||
## from the editable RAGDOLL_JOINT_BAND_LIMITS table (per profile, per joint,
|
||
## in degrees); the static `limit` string is only a fallback for joints
|
||
## missing from the table. `shoulder_hip` is ±160°; other limits leave the
|
||
## joint free.
|
||
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String, child_key: String = "") -> void:
|
||
match limit:
|
||
"elbow_knee", "elbow_knee_ccw":
|
||
var band: Dictionary = RAGDOLL_JOINT_BAND_LIMITS.get(facing_profile, {}).get(child_key, {})
|
||
if band.is_empty():
|
||
if limit == "elbow_knee":
|
||
band = { "lower": -5.0, "upper": 150.0 }
|
||
else:
|
||
band = { "lower": -150.0, "upper": 5.0 }
|
||
pin.angular_limit_enabled = true
|
||
pin.angular_limit_lower = deg_to_rad(float(band.get("lower", -5.0)))
|
||
pin.angular_limit_upper = deg_to_rad(float(band.get("upper", 150.0)))
|
||
"shoulder_hip":
|
||
pin.angular_limit_enabled = true
|
||
pin.angular_limit_lower = -deg_to_rad(160.0)
|
||
pin.angular_limit_upper = deg_to_rad(160.0)
|
||
_:
|
||
pin.angular_limit_enabled = false
|
||
|
||
|
||
func _destroy_ragdoll() -> void:
|
||
if _ragdoll_root != null and is_instance_valid(_ragdoll_root):
|
||
# Retire the name immediately so a same-frame _build_ragdoll (e.g.
|
||
# set_ragdoll(true) during RECOVERING) does not get its fresh container
|
||
# auto-renamed by Godot's sibling-name de-duplication while the old one
|
||
# is still awaiting its deferred queue_free().
|
||
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME + "_retired"
|
||
_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
|
||
if dx < -0.5:
|
||
set_facing_profile(FacingProfile.LEFT)
|
||
elif dx > 0.5:
|
||
set_facing_profile(FacingProfile.RIGHT)
|
||
# LEFT idle pins the captured limb pose because mirrored TwoBoneIK is not
|
||
# stable. Re-enable the solvers before the walk animation starts driving.
|
||
_set_limb_mods_enabled(true)
|
||
# Always play the canonical walk_right clip. For LEFT the rig root is
|
||
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
|
||
# walk_left is no longer used at runtime.
|
||
var 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
|
||
# The nav/direct steering mode is latched once per walk (on the first
|
||
# post-sync frame) so it cannot flip between frames and oscillate the rig.
|
||
_walk_mode = "nav"
|
||
_walk_mode_latched = false
|
||
_walk_latch_probe_frames = 0
|
||
_walk_settle_frames = 0
|
||
|
||
|
||
func is_walking() -> bool:
|
||
return _walking
|
||
|
||
|
||
func _update_walking(delta: float) -> void:
|
||
if not _walking:
|
||
return
|
||
if state != RigState.ANIMATED:
|
||
_cancel_walking(false)
|
||
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
|
||
# Latch the steering mode once the map is synced, so a waypoint that sits
|
||
# near the mesh boundary can't flip nav<->direct between frames (that flip
|
||
# swaps between two vertically-offset targets and reads as up/down jitter).
|
||
# Reachability only becomes meaningful a frame or two AFTER the map syncs and
|
||
# a forced path query round-trips, so probe for up to LATCH_PROBE_MAX_FRAMES:
|
||
# latch "nav" as soon as the agent reports the target reachable; if it never
|
||
# does within the bound (genuinely off-mesh), latch "direct".
|
||
if not _walk_mode_latched:
|
||
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
|
||
_walk_latch_probe_frames += 1
|
||
_nav_agent.get_next_path_position()
|
||
if _nav_agent.is_target_reachable():
|
||
_walk_mode_latched = true
|
||
_walk_mode = "nav"
|
||
_walk_dbg("latch mode=nav (probe %d)" % _walk_latch_probe_frames)
|
||
else:
|
||
_walk_dbg("latch probe %d (not reachable yet)" % _walk_latch_probe_frames)
|
||
return
|
||
else:
|
||
_walk_mode_latched = true
|
||
_walk_mode = "direct"
|
||
_walk_dbg("latch mode=direct (probe bound reached)")
|
||
# 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 final_root := _walk_target_feet + FOOT_OFFSET
|
||
var dist_to_final := global_position.distance_to(final_root)
|
||
var root_target: Vector2
|
||
if _walk_mode == "nav":
|
||
# Terminate once the nav agent reports its path complete AND the rig is
|
||
# close enough: the agent finishes at target_desired_distance (12 px,
|
||
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
|
||
# last stale next-waypoint would oscillate the rig around the 16-px band.
|
||
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||
global_position = final_root
|
||
_finish_walk("arrive")
|
||
return
|
||
# Near the destination, ignore the (possibly behind-path) next waypoint
|
||
# and steer straight at the final target so the rig cannot reverse.
|
||
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||
root_target = final_root
|
||
else:
|
||
root_target = next_feet + FOOT_OFFSET
|
||
else:
|
||
# DIRECT branch: an off-mesh waypoint is a supported case — steer
|
||
# straight at the clicked point, ignoring the nav mesh.
|
||
root_target = final_root
|
||
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
|
||
# Unified arrival radius against the FINAL target, in both branches. Snap
|
||
# the residual offset away so the rig lands exactly on the waypoint.
|
||
if dist_to_final <= ARRIVE_DISTANCE:
|
||
global_position = final_root
|
||
_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()),
|
||
])
|
||
|
||
|
||
## Legacy deferred marker reset hook. Current walk completion deliberately
|
||
## leaves this at zero because the final animation frame must remain visible.
|
||
func _settle_walk_markers() -> void:
|
||
if _walk_settle_frames > 0:
|
||
_walk_settle_frames -= 1
|
||
_restore_standing_markers()
|
||
|
||
|
||
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,
|
||
])
|
||
# Freeze on the exact solved animation frame at arrival. LEFT cannot leave
|
||
# TwoBoneIK running under the root mirror, so capture the bone rotations
|
||
# before stopping and pin that same frame after disabling the solvers.
|
||
var final_limb_rotations: Dictionary = _capture_limb_rotations()
|
||
if _anim_player != null and is_instance_valid(_anim_player):
|
||
_anim_player.stop(true)
|
||
_walking = false
|
||
_apply_idle_limb_solver_mode(final_limb_rotations)
|
||
_walk_settle_frames = 0
|
||
_walk_done = true
|
||
arrived.emit(_walk_target_feet)
|
||
|
||
|
||
func _cancel_walking(restore_idle_pose: bool = true) -> void:
|
||
_walk_dbg("cancel (state=%s)" % RigState.keys()[state])
|
||
var current_limb_rotations: Dictionary = _capture_limb_rotations()
|
||
if _anim_player != null and is_instance_valid(_anim_player):
|
||
_anim_player.stop(true)
|
||
if _nav_agent != null and _nav_anchor != null:
|
||
_nav_agent.target_position = _nav_anchor.global_position
|
||
_walking = false
|
||
_walk_done = false
|
||
_walk_mode_latched = false
|
||
_walk_latch_probe_frames = 0
|
||
_walk_settle_frames = 0
|
||
if restore_idle_pose:
|
||
_apply_idle_limb_solver_mode(current_limb_rotations)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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:
|
||
_primary_speech = { "text": text, "time_left": maxf(duration, 0.0) }
|
||
_speech_active = true
|
||
_speech_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 _show_visible_speech() -> 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)
|
||
_sync_speech_bubble_mirror()
|
||
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", "")))
|
||
|
||
|
||
func _update_speech(delta: float) -> void:
|
||
var visible_owner_changed := false
|
||
if not _primary_speech.is_empty():
|
||
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
|
||
_speech_time_left = float(_primary_speech["time_left"])
|
||
if _speech_time_left <= 0.0:
|
||
_primary_speech.clear()
|
||
_speech_active = false
|
||
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 _hide_speech() -> void:
|
||
_speech_active = false
|
||
_speech_time_left = 0.0
|
||
_primary_speech.clear()
|
||
_overlay_speech.clear()
|
||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||
_speech_bubble.hide_bubble()
|
||
|
||
|
||
func _sync_speech_bubble_mirror() -> void:
|
||
if _speech_bubble != null and is_instance_valid(_speech_bubble):
|
||
_speech_bubble.scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2.ONE
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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()
|
||
|
||
|
||
## 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 actions.is_empty():
|
||
return
|
||
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
|
||
for i: int in action_queue.size():
|
||
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
|
||
_run_cursor = 0
|
||
var primary: Array[Dictionary] = []
|
||
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:
|
||
_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))
|
||
else:
|
||
primary.append(action.duplicate(true))
|
||
if primary.is_empty():
|
||
return
|
||
_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
|
||
|
||
|
||
## 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:
|
||
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()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
_run_queue.clear()
|
||
for i: int in action_queue.size():
|
||
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
|
||
_run_cursor = 0
|
||
_current_run_item.clear()
|
||
_stop_requested = false
|
||
|
||
|
||
## Abort the current action and return to IDLE. Does not emit queue_finished.
|
||
func stop_queue() -> void:
|
||
_stop_requested = true
|
||
_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 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 _primary_speech.is_empty():
|
||
_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:
|
||
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))
|
||
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:
|
||
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:
|
||
_cancel_walking()
|
||
_primary_speech.clear()
|
||
_speech_active = false
|
||
_speech_time_left = 0.0
|
||
_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()
|