- 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.
497 lines
30 KiB
Markdown
497 lines
30 KiB
Markdown
# 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.
|