From 63c64bd52922b22d00cdcebd1a86961f020386b1 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Thu, 10 Sep 2026 01:09:30 -0400 Subject: [PATCH] Refactor ragdoll recovery system: implement joint bend limits and recovery path overrides - Updated `DEBUG_RECOVERY` to false for production. - Introduced `RAGDOLL_JOINT_BAND_LIMITS` for customizable joint bend limits based on facing profile. - Added `RECOVERY_LEG_SIDE_FORCE` for manual control of leg IK bend direction during recovery. - Implemented `RECOVERY_JOINT_PATH` to allow optional tween path overrides for elbow/knee recovery. - Removed debug print statements related to recovery visuals to clean up output. - Adjusted `_apply_ragdoll_joint_limits` to utilize new bend limits configuration. - Enhanced documentation in `ragdoll-cleanup.md` to reflect changes in the recovery pipeline and tuning tables. --- AGENTS.md | 135 ++++++++++++++++---------- README.md | 19 +++- plans/RAGDOLL_CLEANUP.md | 1 + scripts/stickman_rig.gd | 200 +++++++++++++++++---------------------- specs/ragdoll-cleanup.md | 85 +++++++++++++++++ 5 files changed, 276 insertions(+), 164 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ab197a..7080e2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,7 +235,16 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports: `rest_timeout` (2.0 s), `auto_recover` (true), plus recovery constants `STAND_POSE`/`IK_TARGET_PATHS`/`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD`/ - `STAND_UP_DURATION`/`STABILIZATION_DELAY`/`RAGDOLL_TARGET_SOFTNESS`. + `STAND_UP_DURATION`/`STABILIZATION_DELAY`/`RAGDOLL_TARGET_SOFTNESS`. The + **Joint bend configuration (EDIT ME)** block holds every per-joint bend decision of the + ragdoll cycle, keyed by facing profile, so joints can be tuned without touching pipeline + code: `RAGDOLL_JOINT_BAND_LIMITS[profile][joint] = {"lower": deg, "upper": deg}` (the + fall's knee/elbow `PinJoint2D` band — 0 = straight, small side ~5 resists hyperextension, + large side ~150 is the fold; missing entries fall back to the `RAGDOLL_JOINTS` `limit` + string), `RECOVERY_LEG_SIDE_FORCE[profile][leg]` (optional leg IK `flip_bend_direction` + override; missing = match the captured fall pose's knee side), and + `RECOVERY_JOINT_PATH[profile][joint]` (optional `"shortest"` elbow/knee tween-path + override; missing = pass through straight when the shortest path would cross ±180°). Signals `facing_profile_changed(profile)` / `bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, and @@ -283,56 +292,86 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D` container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback `get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10** - `RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass - 2.0, limb capsules radius 8 masses 1.0–2.0; `collision_layer`/`collision_mask` = 1, bodies - spawn fully visible) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, one per - non-root body pinned at the child bone's origin, `softness = RAGDOLL_TARGET_SOFTNESS` at - build). Angular limits via `_apply_ragdoll_joint_limits()`: `elbow_knee` folds +CW - `-5°..+150°`, `elbow_knee_ccw` `-150°..+5°`, `shoulder_hip` ±160°, default (neck) free. Cached - momentum is applied to the torso body. `apply_ragdoll_velocity_boost(velocity)` applies the - same velocity delta (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the - harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is - **not** modified. + `RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass + 2.0, limb capsules radius 8 masses 1.0–2.0; `collision_layer`/`collision_mask` = 1, bodies + spawn fully visible) and the `RAGDOLL_JOINTS` table (**9** `PinJoint2D`, one per + non-root body pinned at the child bone's origin, `softness = RAGDOLL_TARGET_SOFTNESS` at + build). Each body clones the rig's own mounted visual geometry into the ragdoll body frame + via `_clone_part_visuals()` (reads the entry's `visual_path` `Body/*` node, copies each + `Line2D`/`Polygon2D` child with points baked into the body's local space), falling back to a + gray capsule / white circle when the part node is missing or has no mounted shapes. Angular + limits via `_apply_ragdoll_joint_limits()`: elbow/knee bands come from the editable + `RAGDOLL_JOINT_BAND_LIMITS` table (per profile, per joint) — FORWARD arms `{-150°, +5°}`, + legs `{-5°, +150°}`; LEFT/RIGHT override both sides (e.g. locked `{60°, 100°}`); the + `elbow_knee`/`elbow_knee_ccw` `limit` strings are only the fallback for joints missing from + the table. Shoulder/hip ±160°, neck free. Cached + momentum is applied to the torso body. `apply_ragdoll_velocity_boost(velocity)` applies the + same velocity delta (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the + harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is + **not** modified. - **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state == RAGDOLL`) reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below - `REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` it accumulates `_rest_timer`; after - `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` on it calls - `_start_recovery()`. `_start_recovery()` (also `request_recovery()`, no-op unless in - RAGDOLL) captures the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` = - each capsule's half-length from build-time metadata) plus a landing anchor - (`_captured_landing_center` = the ragdoll torso's world center, `_captured_ground_y` = - `torso.center.y + RAGDOLL_TORSO_RADIUS`), `_reanchor_root_to_landing()`s (translates the rig - root so the standing figure's **feet** sit on the ground at the landing X — `feet = - (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + FOOT_OFFSET` — and - re-bases the captured rig-local positions by the root shift; the figure stands up **in place, - on the ground**, where the ragdoll landed, since the ragdoll bodies live under a world sibling - and the root never moved while it fell), `_destroy_ragdoll()`s, - sets `state = RECOVERING` + emits, then `_snap_skeleton_to_pose()` — a **marker-driven** snap - writing `IK_Targets/Torso.position`/`.rotation`, `IK_Targets/Head.position`, and the 4 limb - markers (never the slaved `Torso` Bone2D), re-showing - `Body/*`, then calling `_rearm_ik_stack()` — a defensive re-setup (re-sets up the - `SkeletonModificationStack2D` when it reports `!get_is_setup()`, re-asserts `enabled = true` - and `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's - `RemoteTransform2D` update-position/rotation/scale flags) so the stack reliably resumes solving - toward the end-effectors after a disable→enable toggle. Runtime diagnosis of the re-arm is - gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces stack - enabled/setup/internal + bone-following state). Snap - geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived - as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`; - the Torso marker rotation subtracts the Torso Bone2D's `bone_angle` (bone world angle = - marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90° - and lay it flat). - `_play_stand_up()` then tweens the 6 markers **directly** from their captured values to - `STAND_POSE` over `STAND_UP_DURATION` (2.0 s, sine ease-in-out, `_tween_markers_to()`); the baked - `"stand_up"` animation is **not** played (a fixed first keyframe can never match an arbitrary - ragdoll rest pose, so recovery starts from wherever the snap left the markers). On tween - finish `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`, - and emits. Interruptible: `set_ragdoll(true)` - during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; - `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated - `set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays - `state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). + `REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` (or it is sleeping) it accumulates + `_rest_timer`; after `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` + on it calls `_start_recovery()`. + - **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`, + which snapshots, into `_pre_ragdoll_state`, the 6 IK marker values, the 8 limb bone + rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head + LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry + offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs + **no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation` + decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a + mid-walk pose; the animation is not resumed). + - **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op + unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local + `{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and + probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()` + (a **7-ray fan** around the landing X, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`, + `GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded via `query.exclude`); the probe + context is retained in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle + clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()` + (translates the rig root so the standing figure's **feet** sit on the ground at the + landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + + FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local + shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives + the LEFT X-mirror), sets `state = RECOVERING` + emits. + - **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via + `to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`; + the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and + `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via + `chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into + `(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would + otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the + terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. The snap also + computes and stores the per-segment tween targets in `_recovery_bones`. It re-shows + `Body/*`, calls `_rearm_ik_stack()` (a defensive re-setup — re-runs `setup()` when the + stack reports `!get_is_setup()`, re-asserts `enabled = true` and + `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's + `RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head + LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee + side vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). + - **Stand-up:** `_play_stand_up()` starts a 2.0 s sine-in-out tween (`STAND_UP_DURATION`) + of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the + `_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use + `_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower − + bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space + and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven** + toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay + disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed + first keyframe can never match an arbitrary ragdoll rest pose). + - **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint + bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods, + re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the + pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk + poses). + - `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible: + `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; + `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated + `set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays + `state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). Runtime diagnosis is + gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces the + stack + bone-following state — the temporary per-step debug prints have been stripped). - **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)` diff --git a/README.md b/README.md index 86a34ad..c0d611d 100644 --- a/README.md +++ b/README.md @@ -381,13 +381,23 @@ A **unified live-update setter** drives geometry to all children live — a chan Ragdoll bodies spawn fully visible — the entry handoff is instant (the ragdoll is built at the current solved pose, so there is nothing to fade). -**Angular limits** (`_apply_ragdoll_joint_limits()`): elbows/knees fold **only** — the natural bend goes toward +CW (`-5°..+150°`) or its mirrored CCW variant (`-150°..+5°`) depending on limb side/facing, so limbs never hyperextend; shoulders/hips allow ±160°; the neck is free (`angular_limit_enabled = false`). +**Angular limits** (`_apply_ragdoll_joint_limits()`): elbows/knees get their band from the editable per-profile, per-joint `RAGDOLL_JOINT_BAND_LIMITS` table — a `{ "lower": deg, "upper": deg }` range for the joint's allowed relative angle (0 = straight; a small side ~5 resists hyperextension, a large side ~150 is the fold, and a narrow/locked band such as `{60, 100}` forces a permanently-bent limb). FORWARD arms `{-150°, +5°}`, legs `{-5°, +150°}`; LEFT/RIGHT override both sides. Joints missing from the table fall back to the `RAGDOLL_JOINTS` `limit` string (`elbow_knee` `-5°..+150°`, `elbow_knee_ccw` `-150°..+5°`). Shoulders/hips allow ±160°; the neck is free (`angular_limit_enabled = false`). + +**Joint bend configuration (EDIT ME):** every per-joint bend *decision* in the ragdoll cycle lives in three `const` tables at the top of `scripts/stickman_rig.gd`, keyed by facing profile, so joints can be tuned without touching pipeline code: + +| Const | Shape | Purpose | +|---|---|---| +| `RAGDOLL_JOINT_BAND_LIMITS` | `[profile][joint] = { "lower": deg, "upper": deg }` | The fall's knee/elbow `PinJoint2D` angular band (in degrees). Missing entries fall back to the `RAGDOLL_JOINTS` `limit` string. | +| `RECOVERY_LEG_SIDE_FORCE` | `[profile][leg] = bool` | Optional 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. | +| `RECOVERY_JOINT_PATH` | `[profile][joint] = "shortest"` | Optional override of the elbow/knee stand-up tween path. A missing key means automatic: pass through straight (0) when the shortest path would cross ±180° (fully-backward). | **Instant handoff (Phase 11):** `_enter_ragdoll()` stops the player with `keep_state` (no pose reset), builds the ragdoll from the **current solved bone positions** while the IK stack is still enabled, then hides `Body/*` and disables the IK stack in the same call. There is deliberately **no crossfade** — the ragdoll spawns at exactly the same pose, so a fade would only read as ghosting (an earlier blended transition was removed on director feedback). **Rest detection (auto-recovery):** while `state == RAGDOLL`, `_update_rest_detection()` reads the **Torso** `RigidBody2D`. When it is sleeping **or** its linear velocity ≤ `REST_LINEAR_THRESHOLD` (**5.0 px/s** — tuned up from the original 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s even when settled) and angular velocity ≤ `REST_ANGULAR_THRESHOLD` (0.1 rad/s), a `_rest_timer` accumulates; after `rest_timeout` (exported, default **2.0 s**) plus a `STABILIZATION_DELAY` (0.1 s) hold, and with `auto_recover` (exported, default **true**) enabled, recovery is triggered. `rest_timeout` and `auto_recover` are runtime-adjustable exports. -**Recovery (`_start_recovery()`, also `request_recovery()`):** `_capture_ragdoll_pose()` records the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` = each capsule's half-length, stored as build-time metadata) **plus** a landing anchor — `_captured_landing_center` (the ragdoll torso's world center) and `_captured_ground_y` (the torso's ground-contact line, `center.y + RAGDOLL_TORSO_RADIUS`). Because the ragdoll bodies spawn under a world sibling, the rig root never moves while the figure falls, so before destroying the ragdoll `_start_recovery()` calls `_reanchor_root_to_landing()`, which translates the rig root so the standing figure's **feet** sit on the ground at the landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + FOOT_OFFSET` — and re-bases the captured rig-local positions by that root shift. The figure stands up **in place, on the ground**, where the ragdoll landed — not anchoring its hip to the lying hip (a spine-direction hip sits at ground level when the torso lies flat, which would bury the standing feet ~363 px into the ground), and not back at its pre-ragdoll position. The ragdoll is then destroyed and `state = RECOVERING` is set + emitted. `_snap_skeleton_to_pose()` writes the `IK_Targets/Torso` position + rotation, `IK_Targets/Head`, and the 4 limb markers (never the slaved Torso `Bone2D`), re-shows `Body/*`, then calls `_rearm_ik_stack()` — a defensive re-setup (re-runs `setup()` when the stack reports `!get_is_setup()`, re-asserts `enabled = true` and `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker `RemoteTransform2D`'s update-position/rotation/scale flags) so TwoBoneIK reliably resumes solving toward the end-effectors after a disable→enable toggle; its runtime diagnosis is gated behind `const DEBUG_RECOVERY := false` (`_recovery_dbg()`, off by default). Snap geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`; the Torso marker rotation subtracts the Torso `Bone2D`'s `bone_angle` (bone world angle = marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90° and lay it flat). The snap therefore reproduces the ragdoll's exact final pose (a "sitting" rest stays sitting). `_play_stand_up()` then tweens the 6 markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (**2.0 s**, sine ease-in-out, `_tween_markers_to()`). The baked `stand_up` animation is **not** played — a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump from the captured pose to the animation's first frame), so the tween starts from wherever the snap left the markers. On tween finish, `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`, and emits. +**Recovery (`_start_recovery()`, also `request_recovery()`):** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`, which snapshots the 6 IK marker values, the 8 limb bone rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs **no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation` decomposition (±π) cancels out; this snapshot is the recovery **END target** (possibly a mid-walk pose; the animation is not resumed). `_capture_ragdoll_pose()` records the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` = each capsule's half-length, stored as build-time metadata) **and** probes the terrain under the fallen figure via `_probe_ground_y()` (a **7-ray fan** around the landing X — `GROUND_PROBE_X_OFFSETS`, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`, `GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded), keeping the probe context in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle clamp at snap time. Because the ragdoll bodies spawn under a world sibling, the rig root never moves while the figure falls, so before destroying the ragdoll `_start_recovery()` calls `_reanchor_root_to_landing()`, which translates the rig root so the standing figure's **feet** sit on the ground at the landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)` (a raw `+ shift` would land every position off by `2·shift.x` under the LEFT X-mirror). The figure stands up **in place, on the ground**, where the ragdoll landed — not anchoring its hip to the lying hip (a spine-direction hip sits at ground level when the torso lies flat, which would bury the standing feet ~363 px into the ground), and not back at its pre-ragdoll position. The ragdoll is then destroyed and `state = RECOVERING` is set + emitted. `_snap_skeleton_to_pose()` writes the captured pose onto the 6 IK-target markers (never the slaved Torso `Bone2D`): the torso marker position via `to_local(center_world − spine_dir·half)` and rotation `chain(spine) − torso_chain_offset`; the limb bones `rot_u = chain(u) − θ_t − bone_angle_u`, `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via `chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into `(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. It re-shows `Body/*`, calls `_rearm_ik_stack()` — a defensive re-setup (re-runs `setup()` when the stack reports `!get_is_setup()`, re-asserts `enabled = true` and `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker `RemoteTransform2D`'s update-position/rotation/scale flags) so TwoBoneIK reliably resumes solving after a disable→enable toggle — then **disables** the 4 arm TwoBoneIK mods + the head LookAt and **enables** the 2 leg mods, each leg's `flip_bend_direction` matched to the captured fall pose's knee side (vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). The snap therefore reproduces the ragdoll's exact final pose (a "sitting" rest stays sitting). Runtime diagnosis is gated behind `const DEBUG_RECOVERY := false` (`_recovery_dbg()`, off by default). + +`_play_stand_up()` then starts a **2.0 s** sine-in-out tween (`STAND_UP_DURATION`) of the 6 markers → their **pre-ragdoll** captured values and of the arm + head bones to the `_recovery_bones` targets computed by the snap — upper arm bones take the shortest path, lower arm bones use `_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower − bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven** toward their tweening, ground-clamped ankle markers (the 2 leg mods stay enabled). The arm mods + head LookAt stay disabled during the tween. The baked `stand_up` animation is **not** played — a fixed first keyframe can never match an arbitrary ragdoll rest pose. On tween finish, `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint bend flags + head LookAt state, re-enables the arm mods, re-shows `Body/*`, sets `state = ANIMATED`, and emits — the resumed IK solve reproduces the pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk poses). **Interruptibility:** `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll` calls are idempotent. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is **not** modified (the `stand_up` / `walk_left` / `walk_right` animations are baked into the scene's `AnimationLibrary` by the `create_animations.gd` editor script, which runs manually in the editor; the baked `stand_up` is an authored reference and the recovery path does not play it). The ragdoll bodies spawn under a **world sibling** (not the rig root), so the whole-rig Y-axis mirror does not affect them; `walk_left` remains in the library but is **not used at runtime** — `walk_to()` plays the canonical `walk_right` clip for every direction (root-mirrored for LEFT). @@ -931,7 +941,7 @@ Behavior: | `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. | | `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. | | `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** | -| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** | +| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. The recovery cycle is **pre-capture → ragdoll pose capture (7-ray ground probe) → mirror-aware re-anchor → snap (entry-captured chain offsets, `(−π, π]`-normalized bones, ankle clamp) → 2.0 s tween to the pre-ragdoll pose (arms/head bone-driven with `_natural_joint_target` for elbows; legs IK-driven) → finish (profile + bend flags + LookAt restored)**, with every per-joint bend decision editable in the `RAGDOLL_JOINT_BAND_LIMITS` / `RECOVERY_LEG_SIDE_FORCE` / `RECOVERY_JOINT_PATH` tables (Joint bend configuration (EDIT ME)). Diagnostics are gated behind `DEBUG_RECOVERY := false`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. Null-guarded (`push_warning` + skip). **Not used by the editor.** | | `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). | | `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). | | `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). | @@ -1063,4 +1073,5 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor > **Phase 10 (Kinematic-to-Ragdoll):** adds a reversible **kinematic-to-ragdoll** state switch to the runtime rig. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL }`, `var state: RigState`, `signal state_changed(new_state)`, and the `set_ragdoll(enabled)` / `toggle_ragdoll()` / `is_in_ragdoll()` API. In `RAGDOLL` mode the IK modification stack is disabled, the `AnimationPlayer` stopped, and the `Body/*` visuals hidden; a procedural network of **10** `RigidBody2D` (torso `CapsuleShape2D` mass 8.0, head `CircleShape2D` radius 100, limb capsules radius 8) + **9** `PinJoint2D` (elbow/knee fold-only ±bands, shoulder/hip ±160°, neck free) is built in code and reparented into a `"RagdollBodyContainer"` under the rig's **parent** (world root), layer 1/mask 1 so it collides with terrain and props. The rig root's momentum (tracked in `_physics_process`) is applied to the ragdoll Torso body for a seamless handoff. Exiting frees the ragdoll, re-shows `Body/*`, re-enables IK, and stops the animation. The physics harness toggles via its **Stickman ↔ Ragdoll** button, removing the `RigCollisionProxy` on entry and re-adding it (idempotently) on exit. `master_rig.tscn` is **not** modified. -> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery captures the 10 bodies' rig-local pose **plus a landing anchor** (`_captured_landing_center` = the ragdoll torso's world center, `_captured_ground_y` = torso `center.y + RAGDOLL_TORSO_RADIUS`), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`) so the figure stands up **in place, on the ground**, where the ragdoll landed rather than sliding back to its pre-ragdoll root position (the earlier spine-direction-hip anchor sat at ground level for a lying torso and buried the standing feet), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves the skeleton via the **6** IK targets (`IK_Targets/Torso` pos+rot, `IK_Targets/Head`, 4 limb markers — never the slaved Torso `Bone2D`), deriving the **hip** from the torso capsule's bottom end (`pos − dir·half`) and the wrist/ankle targets from the lower-limb capsules' far ends (`pos + dir·half`), with the Torso marker rotation subtracting the Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat), then `_play_stand_up()` tweens the markers **directly** from their captured values to `STAND_POSE` over `STAND_UP_DURATION` (2.0 s, sine ease-in-out) — the baked `stand_up` animation is **not** played, because a fixed first keyframe can never match an arbitrary ragdoll rest pose (the earlier bridge-into-the-animation approach caused a visible jump); `_on_stand_up_finished()` then returns the rig to `ANIMATED`. `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. +> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery first calls `_capture_pre_ragdoll_state()` (6 IK markers, 8 limb + head bone rotations, facing profile, 4 bend flags, head LookAt state, per-leg `leg_sides`, and entry chain offsets `torso_chain_offset`/`head_chain_offset`) as the END target, then `_capture_ragdoll_pose()` records the 10 bodies' rig-local pose **and** probes the terrain via `_probe_ground_y()` (a 7-ray fan with a tolerance band, excluding the ragdoll RIDs), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`, with a mirror-aware local shift `local_shift = to_local(shift) − to_local(Vector2.ZERO)`), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves via `_snap_skeleton_to_pose()` — the 6 IK targets plus direct arm/head bone rotations (`rot_u = chain(u) − θ_t − bone_angle_u`, `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`, head via `head_chain_offset`), all normalized into `(−π, π]`, with the ankles clamped onto the terrain and the two leg IK mods enabled with the captured knee side — and finally `_play_stand_up()` runs a **2.0 s** sine-in-out tween of the markers → their pre-ragdoll values and the arm/head bones to the snap-computed `_recovery_bones` targets (elbows via `_natural_joint_target`), legs IK-driven. `_on_stand_up_finished()` restores the facing profile + bend flags + LookAt state, re-enables the arm mods, and returns to `ANIMATED`. The per-joint bend decisions live in three editable tables — `RAGDOLL_JOINT_BAND_LIMITS`, `RECOVERY_LEG_SIDE_FORCE`, `RECOVERY_JOINT_PATH` (Joint bend configuration (EDIT ME)); diagnostics are gated behind `DEBUG_RECOVERY := false`. + Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat). `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. diff --git a/plans/RAGDOLL_CLEANUP.md b/plans/RAGDOLL_CLEANUP.md index f812996..3b17f11 100644 --- a/plans/RAGDOLL_CLEANUP.md +++ b/plans/RAGDOLL_CLEANUP.md @@ -21,6 +21,7 @@ Each issue has a clear fix and verification steps. When the stickman recovers from ragdoll, it appears floating above the terrain at the landing position, rather than standing with feet on the ground. **Root Cause:** `_reanchor_root_to_landing()` calculates the ground contact point using `_captured_ground_y = torso.center.y + RAGDOLL_TORSO_RADIUS`. This assumes the torso is lying flat on its side. If the ragdoll's torso is rotated (as it often is after a fall), the calculated ground point is incorrect. +I ### 2.2. The Fix diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index 266e1b4..7691c54 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -198,9 +198,8 @@ 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. TEMPORARILY true for -## the Round 4 transition debugging — flip back to false once verified. -const DEBUG_RECOVERY := true +## 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: @@ -242,6 +241,64 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [ { "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 # --------------------------------------------------------------------------- @@ -355,7 +412,6 @@ var _stand_up_tween: Tween = null 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 _debug_recover_time: float = -1.0 # throttle accumulator for the recovery visual prints 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] = [] @@ -471,7 +527,6 @@ func _physics_process(delta: float) -> void: _update_speech(delta) _update_runner(delta) _pin_mirrored_head_rotation() - _debug_recovery_visuals(delta) @@ -922,10 +977,6 @@ func _capture_ragdoll_pose() -> void: "rot": body.global_rotation - global_rotation, "half": half, } - if DEBUG_RECOVERY: - print("[ragdoll-end] %s world_pos=%s world_rot=%.2fdeg local_pos=%s rel_rot=%.2fdeg" % [ - key, str(body.global_position), rad_to_deg(body.global_rotation), - str(_captured_pose[key]["pos"]), rad_to_deg(float(_captured_pose[key]["rot"]))]) # 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). @@ -933,8 +984,6 @@ func _capture_ragdoll_pose() -> void: 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) - if DEBUG_RECOVERY: - print("[ragdoll-end] landing_center=%s ground_y=%.1f" % [str(_captured_landing_center), _captured_ground_y]) # 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). @@ -1071,36 +1120,6 @@ func _recovery_dbg() -> void: ]) -## Prints the rendered world rotations of the 10 Body/* visual part nodes plus -## the 4 limb bone world rotations, throttled to every 0.25 s while RECOVERING -## (DEBUG_RECOVERY only). Used to pinpoint where a limb appears to spin. -func _debug_recovery_visuals(delta: float) -> void: - if not DEBUG_RECOVERY: - return - if state != RigState.RECOVERING: - _debug_recover_time = -1.0 - return - if _debug_recover_time < 0.0: - _debug_recover_time = 0.0 - _debug_recover_time += delta - if _debug_recover_time < 0.25: - return - _debug_recover_time = 0.0 - if _body_container == null or not is_instance_valid(_body_container): - return - var parts: Array[String] = ["Body", "Head", "LeftUpperArm", "LeftLowerArm", "RightUpperArm", "RightLowerArm", "LeftUpperLeg", "LeftLowerLeg", "RightUpperLeg", "RightLowerLeg"] - for p: String in parts: - var node := _body_container.get_node_or_null(NodePath(p)) as Node2D - if node != null and is_instance_valid(node): - print("[recover-vis] %s rot=%.2fdeg" % [p, rad_to_deg(node.global_rotation)]) - for joint: String in LIMB_BONE_PATHS: - var pair: Dictionary = _limb_bones.get(joint, {}) - for part: String in ["upper", "lower"]: - var bone: Bone2D = pair.get(part) - if bone != null and is_instance_valid(bone): - print("[recover-bone] %s %s local=%.2fdeg global=%.2fdeg" % [joint, part, rad_to_deg(bone.rotation), rad_to_deg(bone.global_rotation)]) - - ## 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), @@ -1221,10 +1240,6 @@ func _snap_skeleton_to_pose() -> void: theta_t = _wrap_toward(raw_rot, pre_torso_rot) theta_t = wrapf(theta_t, -PI, PI) torso_marker.rotation = theta_t - if DEBUG_RECOVERY: - print("[recover-start] torso rel=%.2fdeg chain=%.2fdeg raw=%.2fdeg pre=%.2fdeg final=%.2fdeg hip_local=%s" % [ - rad_to_deg(torso_rel), rad_to_deg(torso_chain), rad_to_deg(raw_rot), - rad_to_deg(pre_torso_rot), rad_to_deg(torso_marker.rotation), str(torso_marker.position)]) var head_marker := _get_ik_marker("Head") if head_marker != null: @@ -1285,12 +1300,6 @@ func _snap_skeleton_to_pose() -> void: 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))) - if DEBUG_RECOVERY: - print("[recover-start] %s upper_chain=%.2fdeg lower_chain=%.2fdeg rot_u=%.2fdeg rot_l=%.2fdeg pre_u=%.2fdeg pre_l=%.2fdeg" % [ - joint, rad_to_deg(upper_chain), rad_to_deg(lower_chain), - rad_to_deg(upper.rotation), rad_to_deg(lower.rotation), - rad_to_deg(float(pre_pair.get("upper", upper.rotation))), - rad_to_deg(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 @@ -1313,15 +1322,16 @@ func _snap_skeleton_to_pose() -> void: # 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. - var target_l := _natural_joint_target(lower.rotation, pre_l, deg_to_rad(lower.bone_angle - upper.bone_angle)) + # 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 }) - if DEBUG_RECOVERY: - print("[stand-up] %s %.2fdeg -> %.2fdeg (sweep %.2fdeg)" % [ - upper.name, rad_to_deg(upper.rotation), rad_to_deg(target_u), rad_to_deg(target_u - upper.rotation)]) - print("[stand-up] %s %.2fdeg -> %.2fdeg (sweep %.2fdeg)" % [ - lower.name, rad_to_deg(lower.rotation), rad_to_deg(target_l), rad_to_deg(target_l - lower.rotation)]) # 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 @@ -1330,16 +1340,9 @@ func _snap_skeleton_to_pose() -> void: 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) - if DEBUG_RECOVERY: - print("[recover-start] head rel=%.2fdeg head_chain_offset=%.2fdeg raw=%.2fdeg pre=%.2fdeg final=%.2fdeg" % [ - rad_to_deg(head_rel), rad_to_deg(head_chain_offset), - rad_to_deg(raw_head), rad_to_deg(pre_head_rotation), rad_to_deg(_head_bone.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 }) - if DEBUG_RECOVERY: - print("[stand-up] %s %.2fdeg -> %.2fdeg (sweep %.2fdeg)" % [ - _head_bone.name, rad_to_deg(_head_bone.rotation), rad_to_deg(target_h), rad_to_deg(target_h - _head_bone.rotation)]) # 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 @@ -1360,7 +1363,11 @@ func _snap_skeleton_to_pose() -> void: continue var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {}) var entry: Dictionary = leg_sides.get(leg_joint, {}) - if not entry.is_empty(): + 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 @@ -1385,23 +1392,7 @@ func _snap_skeleton_to_pose() -> void: 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 - if DEBUG_RECOVERY: - print("[leg-side] %s captured_side=%s entry_side=%s flag=%s" % [ - leg_joint, str(captured_side), str(entry_side), str(mod.flip_bend_direction)]) mod.enabled = true - if DEBUG_RECOVERY: - for joint: String in BEND_JOINTS: - var dbg_mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK - print("[recover-start] ik %s enabled=%s" % [joint, str(dbg_mod != null and dbg_mod.enabled)]) - print("[recover-start] lookat enabled=%s stack_enabled=%s" % [ - str(_head_look_at != null and _head_look_at.enabled), - str(_skeleton != null and _skeleton.modification_stack != null and _skeleton.modification_stack.enabled)]) - if DEBUG_RECOVERY and _body_container != null and is_instance_valid(_body_container): - var snap_parts: Array[String] = ["Body", "Head", "LeftUpperArm", "LeftLowerArm", "RightUpperArm", "RightLowerArm", "LeftUpperLeg", "LeftLowerLeg", "RightUpperLeg", "RightLowerLeg"] - for p: String in snap_parts: - var node := _body_container.get_node_or_null(NodePath(p)) as Node2D - if node != null and is_instance_valid(node): - print("[recover-snap-vis] %s rot=%.2fdeg" % [p, rad_to_deg(node.global_rotation)]) func _set_marker_from_body(marker_name: String, body_key: String) -> void: @@ -1467,12 +1458,6 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween: ## Stand-up tween complete: settle into ANIMATED. func _on_stand_up_finished() -> void: - if DEBUG_RECOVERY and _body_container != null and is_instance_valid(_body_container): - var end_parts: Array[String] = ["Body", "Head", "LeftUpperArm", "LeftLowerArm", "RightUpperArm", "RightLowerArm", "LeftUpperLeg", "LeftLowerLeg", "RightUpperLeg", "RightLowerLeg"] - for p: String in end_parts: - var node := _body_container.get_node_or_null(NodePath(p)) as Node2D - if node != null and is_instance_valid(node): - print("[recover-end-vis] %s rot=%.2fdeg" % [p, rad_to_deg(node.global_rotation)]) # 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)))) @@ -1690,35 +1675,26 @@ func _build_ragdoll_joint(entry: Dictionary) -> void: 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"]) - if DEBUG_RECOVERY: - # Diagnostic: profile + the applied band + the joint's INITIAL relative - # angle (child vs parent body) — if the initial angle sits outside the - # band, the pin re-folds the limb at ragdoll entry. - var rel := wrapf(child_body.rotation - parent_body.rotation, -PI, PI) - print("[ragdoll-joint] %s limit=%s profile=%s band=[%.1f, %.1f]deg initial_rel=%.1fdeg" % [ - child_key, entry["limit"], FacingProfile.keys()[facing_profile], - rad_to_deg(pin.angular_limit_lower), rad_to_deg(pin.angular_limit_upper), - rad_to_deg(rel)]) + _apply_ragdoll_joint_limits(pin, entry["limit"], str(entry["child"])) -## Applies angular limits to a ragdoll PinJoint2D. The natural elbow/knee fold -## follows the LIMB (encoded in the static `limit` strings: left arm/right leg -## fold +CW, right arm/left leg fold -CCW) and is profile-independent — the IK -## bend flags and the LEFT whole-rig mirror do NOT change it (the ragdoll's -## world fold direction stays with the limb). `shoulder_hip` is ±160°; any -## other `limit` leaves the joint free. -func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void: +## 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 ccw := limit == "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 - if ccw: - pin.angular_limit_lower = -deg_to_rad(150.0) - pin.angular_limit_upper = deg_to_rad(5.0) - else: - pin.angular_limit_lower = -deg_to_rad(5.0) - pin.angular_limit_upper = deg_to_rad(150.0) + 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) diff --git a/specs/ragdoll-cleanup.md b/specs/ragdoll-cleanup.md index f604659..47a3dc1 100644 --- a/specs/ragdoll-cleanup.md +++ b/specs/ragdoll-cleanup.md @@ -409,3 +409,88 @@ Verified against the user's RIGHT data: RightLeg knee −159.7° (world −97.5 minimal-world-sweep choice). - [ ] `[stand-up]` lines show every segment sweep ≤ 180°; end pose exactly pre-ragdoll. - [ ] No console errors. Flip `DEBUG_RECOVERY` to `false` once verified. + +--- + +# Verified final state + +Shipped and verified working. The round-by-round history above is retained for context; the +design below is what is actually in `scripts/stickman_rig.gd` and documented in `AGENTS.md` / +`README.md`. + +## Ragdoll fall + +- **Bodies / joints:** 10 `RigidBody2D` (torso capsule r12 mass 8.0, head circle r100 mass 2.0, + 8 limb capsules r8 masses 1.0–2.0) + 9 `PinJoint2D` (`softness = RAGDOLL_TARGET_SOFTNESS`), + built in code and reparented into `"RagdollBodyContainer"` under the rig's parent; layer/mask 1. +- **Visual cloning:** `_clone_part_visuals()` reads each body entry's `visual_path` `Body/*` node + and clones every `Line2D`/`Polygon2D` child with points baked into the ragdoll body's local + frame — no gray bones unless the part is missing/empty (fallback only). +- **Ground probe:** `_probe_ground_y()` fans **7** rays (`GROUND_PROBE_X_OFFSETS`) around the + landing X, from `top_y − GROUND_PROBE_START_OFFSET` to `bottom_y + GROUND_PROBE_END_OFFSET`, + accepting only hits inside `GROUND_PROBE_TOLERANCE` of the ragdoll extent and excluding the + ragdoll body RIDs (`query.exclude`). Called in `_capture_ragdoll_pose()` (landing ground) and + again per-ankle in `_snap_skeleton_to_pose()` (ankle clamp). +- **Joint angular limits:** `_apply_ragdoll_joint_limits()` reads the editable + `RAGDOLL_JOINT_BAND_LIMITS[profile][joint] = { "lower": deg, "upper": deg }` table (the joint's + allowed relative angle; 0 = straight; a small side ~5 resists hyperextension and a large side + ~150 is the fold; narrow/locked bands such as `{60, 100}` force a permanently-bent limb). + FORWARD arms `{-150°, +5°}`, legs `{-5°, +150°}`; LEFT/RIGHT override both sides. Joints + missing from the table fall back to the `RAGDOLL_JOINTS` `limit` string + (`elbow_knee`/`elbow_knee_ccw`). Shoulder/hip ±160°, neck free. + +## Recovery pipeline (final) + +1. **Pre-ragdoll capture** — `_enter_ragdoll()` → `_capture_pre_ragdoll_state()` snapshots into + `_pre_ragdoll_state`: the 6 IK marker values, the 8 limb bone rotations, the head bone + rotation, the facing profile, the 4 live bend flags, the head LookAt enabled state, per-leg + knee side + flag (`leg_sides`), and the chain-space entry offsets `torso_chain_offset` / + `head_chain_offset`. Captured at entry so the snap needs **no authored geometry constants**, + and the LEFT mirror's ambiguous `global_rotation` decomposition (±π) cancels out. This is the + recovery **END target** (possibly a mid-walk pose; the animation is not resumed). +2. **Ragdoll pose capture** — `_capture_ragdoll_pose()` records each body's rig-local + `{pos, rot, half}` and probes the terrain; the probe context is stored in + `_probe_top_y` / `_probe_bottom_y` / `_probe_exclude`. +3. **Re-anchor** — `_reanchor_root_to_landing()` moves the root so the standing feet sit on the + ground at the landing X (`new_root = (_captured_landing_center.x, _captured_ground_y) + + FOOT_OFFSET`) and re-bases the captured positions with a **mirror-aware local shift** + `local_shift = to_local(shift) − to_local(Vector2.ZERO)`. +4. **Snap** — `_snap_skeleton_to_pose()` writes the captured pose: torso marker + `position = to_local(center_world − spine_dir·half)`, `rotation = chain(spine) − + torso_chain_offset`; limb bones `rot_u = chain(u) − θ_t − bone_angle_u`, + `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; head `chain(head) − + head_chain_offset − θ_t`. All snapped rotations normalized into `(−π, π]` because Godot's + `Skeleton2D` re-wraps bone rotations internally (otherwise the tween's captured START is + corrupted). Ankles clamped onto the terrain. Re-shows `Body/*`, `_rearm_ik_stack()`, then + disables the 4 arm mods + head LookAt and enables the 2 leg IK mods with + `flip_bend_direction` matched to the captured knee side (`leg_sides`) or forced by + `RECOVERY_LEG_SIDE_FORCE`. +5. **Stand-up** — `_play_stand_up()` runs a **2.0 s** sine-in-out tween of the 6 markers → their + **pre-ragdoll** values and the arm + head bones → the `_recovery_bones` targets computed by + the snap. Upper arm bones use the shortest path; lower arm bones use + `_natural_joint_target(current, target, offset)` (`offset = bone_angle_lower − + bone_angle_upper`; through-straight rule evaluated in the joint's rendered space; a + `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven** toward + their tweening, ground-clamped ankle markers; the arm mods + head LookAt stay disabled. +6. **Finish** — `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint + bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods, + re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the + pre-ragdoll pose exactly (including mid-walk poses). + +`_pin_mirrored_head_rotation()` pins the head only while `ANIMATED` (during `RECOVERING` the +stand-up tween drives the head bone itself). `set_ragdoll(true)` during `RECOVERING` kills the +tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through recovery; +calls are idempotent. `is_in_ragdoll()` stays `state == RAGDOLL`. + +## Tuning tables (EDIT ME) + +Every per-joint bend decision of the cycle is editable without touching pipeline code: + +| Const | Shape | Purpose | +|---|---|---| +| `RAGDOLL_JOINT_BAND_LIMITS` | `[profile][joint] = { "lower": deg, "upper": deg }` | Fall knee/elbow `PinJoint2D` band. Missing entries fall back to the `RAGDOLL_JOINTS` `limit` string. | +| `RECOVERY_LEG_SIDE_FORCE` | `[profile][leg] = bool` | Optional leg IK `flip_bend_direction` override (`true` = inverted). Missing = match the captured fall pose's knee side. | +| `RECOVERY_JOINT_PATH` | `[profile][joint] = "shortest"` | Optional elbow/knee tween-path override. Missing = pass through straight when the shortest path would cross ±180°. | + +`const DEBUG_RECOVERY := false` (shipped OFF); `_recovery_dbg()` remains as the gated trace and +the temporary per-step debug prints have been stripped.