Files
stickman/specs/ragdoll-cleanup.md
T
ryan 63c64bd529 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.
2026-09-10 01:09:30 -04:00

497 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ragdoll Cleanup — Architecture Spec
Source plan: `plans/RAGDOLL_CLEANUP.md`
Target file: `scripts/stickman_rig.gd` (only file changed)
## Verified code facts
- `_capture_ragdoll_pose()` (line 771) stores `_captured_ground_y = torso.global_position.y +
RAGDOLL_TORSO_RADIUS` — the floating bug. `_reanchor_root_to_landing()` (line 809) consumes it.
- Ragdoll bodies live on `collision_layer/mask = 1` (same as terrain) → a ground raycast MUST
exclude them.
- At runtime each `Body/*` part node's own geometry is cleared by `StkRigAdapter` and the
`.stk` shapes are mounted as **children** (`Line2D` open / `Polygon2D` closed) — so visual
cloning must walk the part node's *children*, not the node itself.
- Per-joint bend direction is stored in the 4 exported vars and queryable via
`get_joint_bend_flipped(joint)`; `facing_profile` applies via `PROFILE_FLAGS` + a whole-rig
X-mirror for LEFT (`Master.scale.x = -1`).
- The plan's §4.2 flag table is **outdated**. Actual `PROFILE_FLAGS` in code:
`LEFT == RIGHT == {LeftArm: inv, RightArm: inv, LeftLeg: norm, RightLeg: norm}`,
`FORWARD == {norm, inv, inv, norm}`. This spec uses the code's flags.
---
## Fix 1 — Recovery ground probe (raycast)
Replace the torso-radius ground guess with a downward raycast performed **inside
`_capture_ragdoll_pose()`** (while the ragdoll still exists — RIDs are needed for exclusion).
New constants:
```gdscript
const GROUND_PROBE_START_OFFSET := 50.0 # ray starts this far above the ragdoll top
const GROUND_PROBE_END_OFFSET := 1500.0 # ray ends this far below the ragdoll bottom
const GROUND_PROBE_TOLERANCE := 200.0 # accepted-hit band around the ragdoll extent
const GROUND_PROBE_X_OFFSETS := [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
```
New helper `_probe_ground_y(world_x: float, top_y: float, bottom_y: float,
exclude: Array[RID]) -> float`:
- Fans 7 rays at `world_x +` each offset, from `(x, top_y − START_OFFSET)` down to
`(x, bottom_y + END_OFFSET)`.
- `PhysicsRayQueryParameters2D`, `collision_mask = 1`, `collide_with_bodies = true`,
`collide_with_areas = false`, `query.exclude = exclude` (all ragdoll body RIDs).
- Accept only hits inside the band `top_y − TOLERANCE .. bottom_y + TOLERANCE` (rejects
overhang hits above and gap hits far below); among accepted hits keep the one whose `y`
is closest to `bottom_y`.
- Return the best hit `y`, or `bottom_y` as fallback (the ragdoll rests on *something*
there even if no ray found it).
`_capture_ragdoll_pose()` changes:
- Store `body.set_meta("radius", …)` at build time (`_build_ragdoll_body`).
- Compute `top_y` / `bottom_y` from each body's `pos.y ± (half + radius)` (head circle:
`half = 0`, `radius = 100`).
- `_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)`.
`_reanchor_root_to_landing()` — **unchanged**.
## Fix 2 — Ragdoll visual identity
- Add `"visual_path"` to each `RAGDOLL_BODIES` entry (`Body/Body`, `Body/Head`,
`Body/LeftUpperArm`, … — the `Body/*` part node).
- New `_clone_part_visuals(body: RigidBody2D, visual_path: String) -> bool`:
- Look up the part node; for each child that is `Line2D` or `Polygon2D`, create a same-type
node, copy visual props (points/polygon, `width`, `default_color`, cap/joint modes,
`closed`, `antialiased`), and **bake each point into the ragdoll body's local frame**:
`body.to_local(src.to_global(p))`; copy `position = Vector2.ZERO`.
- `add_child` the copies; return `true` if at least one shape was cloned.
- `_build_ragdoll_body()` calls the cloner; on `false` (missing part node / no mounted
shapes / empty bbox part) fall back to the existing gray capsule / white circle helpers
(`_add_ragdoll_visual_capsule` / `_add_ragdoll_visual_circle` become fallback-only).
- Physics is untouched — collision stays capsule/circle; visuals are cosmetic overlays
parented to the rigidbody (rotate/translate with it automatically).
## Fix 3 — Profile-aware ragdoll joint limits
- New const map from ragdoll child key → bend joint name:
```gdscript
const RAGDOLL_JOINT_BEND_JOINTS: Dictionary = {
"left_lower_arm": "LeftArm", "right_lower_arm": "RightArm",
"left_lower_leg": "LeftLeg", "right_lower_leg": "RightLeg",
}
```
- `_build_ragdoll_joint()` passes the child key; `_apply_ragdoll_joint_limits(pin, limit,
child_key)`:
- `elbow_knee`/`elbow_knee_ccw` entries: `flipped := get_joint_bend_flipped(
RAGDOLL_JOINT_BEND_JOINTS[child_key])`, then **mirror-corrected**:
`flipped_world := flipped != (facing_profile == FacingProfile.LEFT)` — the LEFT whole-rig
X-mirror negates world-space relative angles.
- `flipped_world == true` → band `[-150°, +5°]`; else band `[-5°, +150°]`.
- `shoulder_hip` ±160° and `neck` free: unchanged.
- Uses the **live** per-joint flags, so it covers the facing profile *and* harness
right-click overrides. FORWARD reproduces today's static config exactly.
## Implementation order
1. Fix 1 (probe constants + `_probe_ground_y` + capture changes + radius meta).
2. Fix 2 (`visual_path` entries + `_clone_part_visuals` + fallback wiring).
3. Fix 3 (joint map + mirror-corrected limits).
4. Headless `--check-only` validation of `scripts/stickman_rig.gd`.
## Acceptance criteria (from plan §7, unchanged)
- Recovery: feet on terrain on flat/slopes/ramp edges; no floating/clipping.
- Visuals: ragdoll shows the stickman's own colors/shapes; no gray bones; smooth handoff.
- Profiles: LEFT/RIGHT/FORWARD bend correctly in ragdoll and after recovery; no hyperextension.
- No new console errors; ANIMATED behavior untouched.
---
# Round 2 — Recovery snap & tween (post-test bugs)
User-reported symptoms:
- Walking RIGHT → ragdoll → recover: **arms/legs jump** to a new pose at tween start.
- Walking LEFT → ragdoll → recover: **whole figure (incl. torso) jumps**; body lands left of
the ragdoll pile; "feet end up where the torso should be".
## Root causes
1. **Mirror-unaware snap math (LEFT).** `_snap_skeleton_to_pose()` / `_set_marker_from_body()`
add *world-space* direction vectors to *rig-local* positions, and use the captured relative
angle directly for the Torso marker rotation. Under the LEFT whole-rig X-mirror
(`Master.scale.x = -1`) the root's `global_rotation` decomposes as `π − φ`, so the captured
relative angle equals `−A` (the negated unmirrored chain angle), not `A`. Result: the Torso
renders ~180° off and every joint offset is X-mirrored — the whole-body jump.
2. **IK re-solve at snap (RIGHT/FORWARD).** After the snap re-arms the modification stack,
TwoBoneIK re-solves the limbs toward the captured end-effector markers using the profile's
`flip_bend_direction` flags. If the ragdoll fell with a knee/elbow on the other side of the
solution line, the limb snaps to the mirrored IK solution at tween start.
## Fix design (approved)
- **Mirror-safe snap math:** convert every derived joint point through
`to_local(to_global(captured_pos) + world_dir * half)` — the transform math itself handles the
mirror and any root rotation. Captured relative angles are converted to chain angles via
`_chain_angle_from_captured(x) = −x` for LEFT, else `x`.
- **Bone-driven limb recovery:** during RECOVERING the 4 TwoBoneIK mods are **disabled** and the
8 limb `Bone2D` rotations are driven directly:
- `_ready()` resolves the 8 limb bones (`LIMB_BONE_PATHS`) and captures their authored rest
rotations (`_rest_bone_rotations`) before the first IK solve.
- `_snap_skeleton_to_pose()` sets each limb bone's local rotation from the captured body
angles: `rot_u = A_u − A_torso − bone_angle_u`, `rot_l = A_l − A_u − bone_angle_l`
(mirror-corrected chain angles), then disables the 4 TwoBoneIK mods
(`_set_limb_mods_enabled(false)`).
- `_play_stand_up()` tweens the snapped bones **in parallel** with the marker tween (same
duration/ease) to their rest rotations.
- `_on_stand_up_finished()` and `snap_to_standing()` re-enable the 4 mods — at that point
markers are at STAND_POSE and bones at rest, so the resumed IK solve reproduces the authored
standing pose exactly (no end snap).
## Round 2 acceptance criteria
- [ ] RIGHT: ragdoll → recover transitions with no limb jump at tween start or end.
- [ ] LEFT: ragdoll → recover snaps back to the exact ragdoll pose (no whole-body jump); figure
stands up at the landing spot with feet on the terrain.
- [ ] FORWARD: unchanged smoothness; existing recovery behavior intact.
- [ ] Interrupted recovery (ragdoll re-entry mid-stand-up) and `snap_to_standing()` still work;
no console errors.
---
# Round 3 — Pre-ragdoll state capture + spine-offset fix
User-reported (after Round 2): the first recovery frame still shows arms/legs at wrong
positions ("the first frame of recover changes where the arms and legs are before the tween").
User-prescribed design:
1. Capture the stickman's state on the last frame **before** ragdoll (limbs, head, torso,
facing profile, bend flags) — this is the **recovery END target** (possibly a mid-walk
pose — the animation does not resume, but the transforms are retained).
2. Capture the state on the last frame **of** ragdoll — the **tween START**.
3. Tween every bone/marker from the ragdoll state to the pre-ragdoll state.
## Root cause of the Round 2 jump (verified against master_rig.tscn)
The Torso **bone's** direction is not the spine. The Head bone's authored local position is
`(0, −391.5)` — the spine runs along the Torso bone's **−Y**, not its +X. Therefore:
- `torso_marker.rotation = chain_angle(spine_rel) + π/2 − bone_angle_torso` (new const
`TORSO_SPINE_OFFSET := PI / 2.0`); at rest `spine_rel = −π/2` → marker rotation `0` = STAND_POSE ✓.
- The limbs' parent chain angle is the **bone** angle `θ_t = marker.rotation + bone_angle_t
= chain(spine_rel) + π/2` — Round 2 used `chain(spine_rel)` directly, so all 8 limb rotations
were ~90° off at snap. The limb formula itself (`rot = chain(dir) − parent_chain − bone_angle`,
where the ragdoll body angle IS the bone *direction* and the Body/* visuals follow the bone
*node* rotation) is otherwise correct.
- The head bone drives `Body/Head` via its node rotation (bone_angle excluded from node
transforms): `head.rotation = chain(head_body_rel) − θ_t` (no `bone_angle` term). Round 2
never set the head bone — the re-armed LookAt rewrote it on the first frame (head jump).
## Fix design (approved)
- **Pre-ragdoll capture** `_capture_pre_ragdoll_state()` at the top of `_enter_ragdoll()`:
the 6 marker local positions + rotations, the 8 limb bone rotations, the head bone rotation,
`facing_profile`, the 4 live bend flags, and the head LookAt's enabled state.
- **Corrected snap** `_snap_skeleton_to_pose()`:
- torso marker rotation `+ TORSO_SPINE_OFFSET`;
- limb rotations with parent chain `torso_chain + TORSO_SPINE_OFFSET`;
- NEW: head bone rotation from the head body (no bone_angle term);
- all snapped rotations wrapped to the shortest path toward their pre-ragdoll targets
(`_wrap_toward(v, target) = target + wrapf(v − target, −PI, PI)`) so the tween never takes
the long way around;
- disable the 4 TwoBoneIK mods AND the head LookAt.
- **Tween** `_play_stand_up()`: markers → pre-ragdoll marker values; 8 limb bones + head bone →
pre-ragdoll rotations (same parallel sine ease, 2.0 s).
- **Finish** `_on_stand_up_finished()`: restore the captured facing profile + per-joint bend
flags + head LookAt enabled state, re-enable the 4 limb mods → the resumed IK solve reproduces
the pre-ragdoll pose exactly (no end snap). `snap_to_standing()` restores the LookAt state too.
- `_pin_mirrored_head_rotation()` gated to ANIMATED only (its per-frame pin would fight the head
tween during RECOVERING).
- `_rest_bone_rotations` removed (rest pose is no longer a tween target).
## Round 3 acceptance criteria
- [ ] First recovery frame is pixel-identical to the last ragdoll frame (limbs, head, torso).
- [ ] Recovery ends in the exact pre-ragdoll pose (mid-walk poses retained; no animation resume),
profile + bend flags restored; no end-of-tween snap.
- [ ] LEFT/RIGHT/FORWARD all recover; interrupted recovery and `snap_to_standing()` still work;
no console errors.
---
# Round 4 — Snap angle corrections + runtime debug prints
User-reported (after Round 3): end of recovery matches the pre-ragdoll frame ✓, but the last
ragdoll frame and the first recovery frame are "completely different" — body/limbs/head all in
the wrong positions, head orientation wrong, arms rotated unrealistically, whole body ~90° off.
User requested runtime debug prints of each part/IK at the end of ragdoll and start of recovery.
## Root causes (verified against master_rig.tscn + `_build_ragdoll_body` math)
Chain-angle framework (validated): `chain(captured_rel) = A + bone_angle` for every bone,
where `A` is the unmirrored node-chain angle (bone_angle is NOT part of node transforms — only
the ragdoll body direction includes it; the Body/* visuals follow the node rotation).
1. **Lower-limb parent chain.** The lower bone's node parent chain is `A_upper` (no bone_angle),
but the captured upper body angle is `A_upper + bone_angle_upper`. Round 3 computed
`rot_l = chain(l) − chain(u) − bone_angle_l`, which is off by `−bone_angle_upper` —
LeftLowerArm flipped ~180° (bone_angle_upper = −π) and both lower legs off ~90°
(bone_angle_upper = +π/2). Correct:
`rot_l = chain(l) − chain(u) + bone_angle_upper − bone_angle_lower`
(validated: reproduces every authored rest rotation exactly).
2. **Head entry offset.** The head ragdoll body is a "visual" body spawned with rotation 0 (a
circle, not a direction bone), so its cloned visual carries the ENTRY head chain as a baked
world offset `V_entry = rendered(θ_t_entry + rot_h_entry)`. Round 3 used
`rot_h = chain(h_rel) − θ_t_snap`, dropping `V_entry` and the LEFT `−π` mirror term. Correct:
`rot_h = chain(h_rel) + head_entry_offset + (θ_t_entry + rot_h_entry) − θ_t_snap`
where `head_entry_offset = +φ` (FORWARD/RIGHT) or `−(π − φ)` (LEFT), `φ = global_rotation`,
and `θ_t_entry + rot_h_entry` = pre-ragdoll Torso marker rotation + pre-ragdoll head rotation.
3. **Debug prints** (user-requested, gated by `DEBUG_RECOVERY`, set **true** for this round):
- `_capture_ragdoll_pose()`: per body — key, world pos/rot, local pos, relative rot.
- `_snap_skeleton_to_pose()`: torso chain/raw/wrapped marker rotation + pre target; per joint
the upper/lower chain angles, computed rotations, pre-ragdoll targets; head raw/wrapped/
target; final TwoBoneIK mod + LookAt enabled states.
## Round 4 acceptance criteria
- [ ] First recovery frame is pixel-identical to the last ragdoll frame for ALL profiles.
- [ ] No 90°/180° limb flips; head orientation continuous across the transition.
- [ ] Console debug output shows matching angles between the ragdoll-end and recovery-start
frames (flip `DEBUG_RECOVERY` back to `false` once verified).
- [ ] End of recovery still matches the pre-ragdoll frame; no console errors.
---
# Round 5 — Entry-offset snap + mirror-aware re-basing (from user debug data)
User-provided LEFT-profile debug output revealed three bugs (the end-of-recovery match and the
limb formulas were already correct; the limb debug values reconcile exactly once the torso
parent is right).
## Findings from the debug data
1. **Torso marker rotation double-count (~90° whole-body rotation).** The Torso bone's
`bone_angle` is AUTO-CALCULATED (no `auto_calculate_length_and_angle = false` in the tscn)
from its first child (the Head bone at `(0, −391.5)`) → `bone_angle_t = −90°`. The original
`chain − bone_angle_t` therefore already encodes the spine offset; Round 3/4's additional
`+TORSO_SPINE_OFFSET` double-counted it: `raw = chain + 180` instead of `chain + 90`
(verified: printed `raw=147.64`, correct `57.62`).
2. **Mirror-unaware re-basing (~552 px X shift).** `_reanchor_root_to_landing()` re-bases the
captured rig-local positions by `+ shift` (the world-space root delta). Under the LEFT
X-mirror a local +X moves world −X, so every captured position lands off by `2·shift.x`
(verified: `hip_local.x = 386.38` vs expected `−165.25`, difference = `2·275.85`).
3. **Head entry offset doubled (head 180° off).** Round 4's `entry_offset` used
`global_rotation`, which Godot decomposes as `−π` for the mirrored root (not `π − φ`),
producing `−360°` instead of `−180°`.
## Fix design (approved — derived from the user's own debug output)
- **Entry-offset capture** in `_capture_pre_ragdoll_state()`:
- `torso_chain_offset = chain(spine_entry_rel) − θ_t_entry`, where `spine_entry_rel` is the
entry spine direction (head bone − torso bone world positions, minus `global_rotation`) and
`θ_t_entry` the entry Torso marker rotation.
- `head_chain_offset = chain(−global_rotation) − (θ_t_entry + head_rotation_entry)` — the
head ragdoll body spawns with rotation 0, so this is its baked entry offset.
- The `chain()` form is branch-proof: the mirrored root's `global_rotation` decomposition
(±π) cancels in the difference of two captured angles.
- **Snap** `_snap_skeleton_to_pose()`:
- `θ_t = wrap_toward(torso_chain − torso_chain_offset, pre_torso_marker_rot)`; the torso
marker rotation AND the limb parent chain both use this single `θ_t` value.
- `rot_h = wrap_toward(chain(head_rel) − head_chain_offset − θ_t, pre_head_rotation)`.
- `TORSO_SPINE_OFFSET`, the `− bone_angle_t` subtraction, and the Round 4 head
`entry_offset`/`entry_chain` reconstruction are all REMOVED — the captured offsets absorb
every authored convention.
- **Mirror-aware re-basing** in `_reanchor_root_to_landing()`:
`local_shift = to_local(shift) − to_local(Vector2.ZERO)` (the root basis's inverse applied to
the world delta — negates X under LEFT, handles root rotation too).
## Round 5 acceptance criteria
- [ ] Debug output for LEFT: `[recover-start] torso ... raw≈57.6 final≈57.6`, hip_local ≈
(−165.2, 343.3), head final ≈ −57.6.
- [ ] First recovery frame pixel-identical to the last ragdoll frame (all profiles).
- [ ] Recovery ends at the pre-ragdoll pose at the landing spot; no console errors.
- [ ] Flip `DEBUG_RECOVERY` back to `false` once verified.
---
# Round 6 — Natural joint-bend tween paths (no unwrapping)
User-reported (after Round 5): positions now match, but limbs **unwrap unrealistically** — a
knee sweeps the long/backwards way (≈315°) instead of a natural short bend (≈45°). "The body
should recover just as smoothly as it ragdolls."
## Root cause (verified against the user's FORWARD debug data)
Each bone rotation tweens along the **shortest value path** toward its pre-ragdoll value. For a
joint that fell bent backwards, the shortest path sweeps **through the ±180° (fully-backward)
side** — the knee folds further backwards through the back of the joint — instead of through
straight (0), the only natural humanoid path. Example from the data: left lower leg value
`286.34` (≡ −73.7°, bent backwards) → pre `120.40`: Round 5 tweens `286.34 → 120.40` (−165.9°,
through −180°, backwards) instead of `286.34 → 480.40` (+194.1°, through 0/straight).
## Fix design (approved)
New helper `_natural_joint_target(current, target) -> float`:
- `a = wrapf(current, −π, π)`, `b = wrapf(target, −π, π)`, `d = wrapf(b − a, −π, π)`.
- If `|a| + |b| > π` the shortest path crosses ±π (the joint would fold backwards) — take the
long way around through straight: `d += 2π` if `d < 0` else `d −= 2π`.
- Return `current + d` (the pre value shifted by whole turns, so the tween END still renders the
pre rotation).
- Applied to the 8 limb bones' tween targets in `_play_stand_up()` (NOT the torso marker or the
head — single segments, shortest path stays natural).
## Round 6 acceptance criteria
- [ ] No joint sweeps through the ±180° backward side during the stand-up; elbows/knees pass
through straight when the ragdoll bent them backwards.
- [ ] Every limb segment's world-direction sweep stays ≤ 180°; recovery reads as smooth as the
ragdoll fall.
- [ ] End pose still exactly the pre-ragdoll pose (targets differ by whole turns only).
- [ ] All profiles; no console errors. Flip `DEBUG_RECOVERY` to `false` once verified.
---
# Round 7 — Natural-bend rule restricted to elbows/knees
User-reported (after Round 6): "made it worse, now limbs are going fully past 360 degrees."
Verified against the RIGHT-profile debug data: Round 6 applied the avoid-±π rule to ALL 8
limb bones, so the LeftArm SHOULDER took the long way (`−185.40 → −450.98`, a −265.6° sweep;
world sweep −326.2°) instead of the natural shortest +94.4°.
## Correction
- **Shoulders/hips** (the 4 upper bones) are ball joints — keep the plain shortest path
(the snap's existing wrap; Round 5 behavior). Revert Round 6 for the upper bones.
- **Elbows/knees** (the 4 lower bones) keep `_natural_joint_target` — a hinge joint bent
backwards must pass through straight (0) instead of folding further through ±π. This was
Round 6's correct half and fixes Round 5's −307° lower-leg world spin (now +52.9°).
- Debug: print each bone's stand-up tween (name, from → to) so the sweeps are visible.
## Round 7 acceptance criteria
- [ ] No shoulder/hip sweep exceeds 180°; no elbow/knee folds backwards through ±π.
- [ ] Every limb segment's world-direction sweep ≤ 180° for all profiles.
- [ ] Console shows `[stand-up] <bone> <from> -> <to>` lines; spot-check the sweeps.
- [ ] End pose still exactly pre-ragdoll; no errors. Flip `DEBUG_RECOVERY` to `false` when done.
---
# Round 8 — Minimal world-sweep tween targets (per-segment branch selection)
User-reported (after Round 7): limbs still flip ~360°. Verified against the RIGHT debug data:
the per-bone rules compose badly — RightLeg upper swept +62.3° and the Round-7 knee rule chose
+200.3° (through straight), so the lower leg's WORLD direction swept +262.6° — a near-full spin.
The flaw: choosing each bone's branch in isolation ignores the parent chain's simultaneous
sweep.
## Fix design (approved)
For each bone in chain order, choose the whole-turn branch of its value sweep
(`pre − start`, `±360°`) that **minimizes the segment's world-direction sweep**
(`Δθ_torso + ΣΔrot` up to and including that bone). Since the candidates are 360° apart,
exactly one lands within ±180° of the accumulated parent sweep — every segment's world sweep is
guaranteed ≤ 180°:
- torso marker: existing wrap (Δθ ≤ 180°).
- upper bone: pick `d_u ∈ {pre_u − start_u, ±360}` minimizing `|Δθ + d_u|`.
- lower bone: pick `d_l ∈ {pre_l − start_l, ±360}` minimizing `|Δθ + d_u + d_l|` (d_u already
chosen) — the knee/elbow branch follows the minimal world sweep, which is exactly the
"through straight when bent backwards" behavior, derived instead of heuristic.
- head: pick `d_h ∈ {pre_h − start_h, ±360}` minimizing `|Δθ + d_h|`.
- Targets are computed in `_snap_skeleton_to_pose()` (where the chains are known) and stored in
`_recovery_bones`; `_play_stand_up()` just tweens them. `_natural_joint_target()` removed.
Verified against the user's RIGHT data: RightLeg knee −159.7° (world −97.5°), LeftLeg knee
+183.6° (world +31.2°), LeftArm elbow −170.6° (world −128.2°); every segment sweep ≤ 180°.
## Round 8 acceptance criteria
- [ ] No limb segment's world direction sweeps > 180° for any profile; no visible spins.
- [ ] Knees/elbows pass through straight when the ragdoll bent them backwards (follows from the
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.