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.
This commit is contained in:
2026-09-10 01:09:30 -04:00
parent 699b623be3
commit 63c64bd529
5 changed files with 276 additions and 164 deletions
+87 -48
View File
@@ -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), NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports: `rest_timeout` (2.0 s),
`auto_recover` (true), plus recovery constants `auto_recover` (true), plus recovery constants
`STAND_POSE`/`IK_TARGET_PATHS`/`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD`/ `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)` / Signals `facing_profile_changed(profile)` /
`bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_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 `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` RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D`
container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback
`get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10** `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 `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 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 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 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 build). Each body clones the rig's own mounted visual geometry into the ragdoll body frame
`-5°..+150°`, `elbow_knee_ccw` `-150°..+5°`, `shoulder_hip` ±160°, default (neck) free. Cached via `_clone_part_visuals()` (reads the entry's `visual_path` `Body/*` node, copies each
momentum is applied to the torso body. `apply_ragdoll_velocity_boost(velocity)` applies the `Line2D`/`Polygon2D` child with points baked into the body's local space), falling back to a
same velocity delta (mass-scaled `apply_central_impulse`) to every ragdoll body — used by the gray capsule / white circle when the part node is missing or has no mounted shapes. Angular
harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is limits via `_apply_ragdoll_joint_limits()`: elbow/knee bands come from the editable
**not** modified. `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 == - **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state ==
RAGDOLL`) RAGDOLL`)
reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below
`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` it accumulates `_rest_timer`; after `REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` (or it is sleeping) it accumulates
`rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` on it calls `_rest_timer`; after `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover`
`_start_recovery()`. `_start_recovery()` (also `request_recovery()`, no-op unless in on it calls `_start_recovery()`.
RAGDOLL) captures the 10 bodies' rig-local `{pos, rot, half}` into `_captured_pose` (`half` = - **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`,
each capsule's half-length from build-time metadata) plus a landing anchor which snapshots, into `_pre_ragdoll_state`, the 6 IK marker values, the 8 limb bone
(`_captured_landing_center` = the ragdoll torso's world center, `_captured_ground_y` = rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head
`torso.center.y + RAGDOLL_TORSO_RADIUS`), `_reanchor_root_to_landing()`s (translates the rig LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry
root so the standing figure's **feet** sit on the ground at the landing X — `feet = offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs
(_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + FOOT_OFFSET` — and **no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation`
re-bases the captured rig-local positions by the root shift; the figure stands up **in place, decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a
on the ground**, where the ragdoll landed, since the ragdoll bodies live under a world sibling mid-walk pose; the animation is not resumed).
and the root never moved while it fell), `_destroy_ragdoll()`s, - **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
sets `state = RECOVERING` + emits, then `_snap_skeleton_to_pose()` — a **marker-driven** snap unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local
writing `IK_Targets/Torso.position`/`.rotation`, `IK_Targets/Head.position`, and the 4 limb `{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and
markers (never the slaved `Torso` Bone2D), re-showing probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()`
`Body/*`, then calling `_rearm_ik_stack()` — a defensive re-setup (re-sets up the (a **7-ray fan** around the landing X, `GROUND_PROBE_START_OFFSET`/`END_OFFSET`,
`SkeletonModificationStack2D` when it reports `!get_is_setup()`, re-asserts `enabled = true` `GROUND_PROBE_TOLERANCE` band, ragdoll RIDs excluded via `query.exclude`); the probe
and `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's context is retained in `_probe_top_y`/`_probe_bottom_y`/`_probe_exclude` for the ankle
`RemoteTransform2D` update-position/rotation/scale flags) so the stack reliably resumes solving clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()`
toward the end-effectors after a disable→enable toggle. Runtime diagnosis of the re-arm is (translates the rig root so the standing figure's **feet** sit on the ground at the
gated behind `const DEBUG_RECOVERY := false` (off by default; `_recovery_dbg()` traces stack landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet +
enabled/setup/internal + bone-following state). Snap FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local
geometry: the ragdoll capsules span joint origin→tip along their +X, so the **hip** is derived shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives
as `torso.pos − spine_dir·half` and the wrist/ankle targets as `lower_body.pos + dir·half`; the LEFT X-mirror), sets `state = RECOVERING` + emits.
the Torso marker rotation subtracts the Torso Bone2D's `bone_angle` (bone world angle = - **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90° `to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`;
and lay it flat). the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and
`_play_stand_up()` then tweens the 6 markers **directly** from their captured values to `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via
`STAND_POSE` over `STAND_UP_DURATION` (2.0 s, sine ease-in-out, `_tween_markers_to()`); the baked `chain(head) − head_chain_offset − θ_t`. All snapped rotations are normalized into
`"stand_up"` animation is **not** played (a fixed first keyframe can never match an arbitrary `(−π, π]` because Godot's `Skeleton2D` re-wraps bone rotations internally (which would
ragdoll rest pose, so recovery starts from wherever the snap left the markers). On tween otherwise corrupt the tween's captured start). The ankle markers are clamped up onto the
finish `_on_stand_up_finished()` re-enables IK, re-shows `Body/*`, sets `state = ANIMATED`, terrain surface via a per-ankle `_probe_ground_y()` at each ankle's X. The snap also
and emits. Interruptible: `set_ragdoll(true)` computes and stores the per-segment tween targets in `_recovery_bones`. It re-shows
during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `Body/*`, calls `_rearm_ik_stack()` (a defensive re-setup — re-runs `setup()` when the
`set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated stack reports `!get_is_setup()`, re-asserts `enabled = true` and
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's
`state == RigState.RAGDOLL` (so `RECOVERING` reads as "Stickman"). `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 - **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE, queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)` WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
+15 -4
View File
@@ -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). 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). **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. **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). **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/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/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_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/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://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). | | `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 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`.
+1
View File
@@ -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. 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. **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 ### 2.2. The Fix
+88 -112
View File
@@ -198,9 +198,8 @@ const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF. ## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false const DEBUG_WALK := false
## Debug gate for the ragdoll-recovery re-solve trace. TEMPORARILY true for ## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
## the Round 4 transition debugging — flip back to false once verified. const DEBUG_RECOVERY := false
const DEBUG_RECOVERY := true
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on. ## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void: 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" }, { "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 # Exported controls
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -355,7 +412,6 @@ var _stand_up_tween: Tween = null
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } } var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up 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 _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_top_y: float = 0.0 # ground-probe context retained for the ankle clamp
var _probe_bottom_y: float = 0.0 var _probe_bottom_y: float = 0.0
var _probe_exclude: Array[RID] = [] var _probe_exclude: Array[RID] = []
@@ -471,7 +527,6 @@ func _physics_process(delta: float) -> void:
_update_speech(delta) _update_speech(delta)
_update_runner(delta) _update_runner(delta)
_pin_mirrored_head_rotation() _pin_mirrored_head_rotation()
_debug_recovery_visuals(delta)
@@ -922,10 +977,6 @@ func _capture_ragdoll_pose() -> void:
"rot": body.global_rotation - global_rotation, "rot": body.global_rotation - global_rotation,
"half": half, "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 # 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 fallen figure so recovery can re-anchor the root's feet onto wherever
# the ragdoll actually landed (flat ground, slopes and ramp edges included). # 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): if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position _captured_landing_center = torso.global_position
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids) _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() # 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 # can re-probe the terrain at each ankle's X using the same extents and
# exclusion set (without re-running the capture loop). # 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 ## 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 ## 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), ## 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 = _wrap_toward(raw_rot, pre_torso_rot)
theta_t = wrapf(theta_t, -PI, PI) theta_t = wrapf(theta_t, -PI, PI)
torso_marker.rotation = theta_t 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") var head_marker := _get_ik_marker("Head")
if head_marker != null: if head_marker != null:
@@ -1285,12 +1300,6 @@ func _snap_skeleton_to_pose() -> void:
lower.rotation = _wrap_toward( lower.rotation = _wrap_toward(
lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle), lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle),
float(pre_pair.get("lower", lower.rotation))) 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 # Normalize the snapped rotations into (-PI, PI]: Godot's Skeleton2D
# re-wraps bone rotations on its next internal process, which would # re-wraps bone rotations on its next internal process, which would
# otherwise change the tween's captured START and send it the long # 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 - # The joint's rendered bend is offset by bone_angle_lower -
# bone_angle_upper (e.g. -90deg for the legs): evaluate the natural # bone_angle_upper (e.g. -90deg for the legs): evaluate the natural
# rule in that rendered space so the knee unfolds through straight # rule in that rendered space so the knee unfolds through straight
# instead of being sent the long way around. # instead of being sent the long way around. A "shortest" entry in
var target_l := _natural_joint_target(lower.rotation, pre_l, deg_to_rad(lower.bone_angle - upper.bone_angle)) # 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": upper, "target": target_u })
_recovery_bones.append({ "bone": lower, "target": target_l }) _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 # 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 # 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 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 var raw_head := _chain_angle_from_captured(head_rel) - head_chain_offset - theta_t
_head_bone.rotation = _wrap_toward(raw_head, pre_head_rotation) _head_bone.rotation = _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) _head_bone.rotation = wrapf(_head_bone.rotation, -PI, PI)
var target_h := _head_bone.rotation + wrapf(pre_head_rotation - _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 }) _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 # 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 # pose, then re-arm the stack (torso driver) with the limb TwoBoneIK mods
@@ -1360,7 +1363,11 @@ func _snap_skeleton_to_pose() -> void:
continue continue
var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {}) var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {})
var entry: Dictionary = leg_sides.get(leg_joint, {}) 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 # Derive the captured knee side from the RAGDOLL capture in world
# space. The bone nodes are NOT reliable here: the torso marker's # space. The bone nodes are NOT reliable here: the torso marker's
# driver has not pushed the re-anchored hip yet, so bone globals # 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_side := float(entry.get("side", captured_side))
var entry_flag := bool(entry.get("flag", false)) var entry_flag := bool(entry.get("flag", false))
mod.flip_bend_direction = entry_flag if captured_side == entry_side else not entry_flag 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 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: 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. ## Stand-up tween complete: settle into ANIMATED.
func _on_stand_up_finished() -> void: 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 # Restore the pre-ragdoll facing profile + per-joint bend flags so the
# re-enabled IK holds the exact pre-ragdoll pose. # re-enabled IK holds the exact pre-ragdoll pose.
set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile)))) 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_a = pin.get_path_to(parent_body)
pin.node_b = pin.get_path_to(child_body) pin.node_b = pin.get_path_to(child_body)
pin.softness = RAGDOLL_TARGET_SOFTNESS pin.softness = RAGDOLL_TARGET_SOFTNESS
_apply_ragdoll_joint_limits(pin, entry["limit"]) _apply_ragdoll_joint_limits(pin, entry["limit"], str(entry["child"]))
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)])
## Applies angular limits to a ragdoll PinJoint2D. The natural elbow/knee fold ## Applies angular limits to a ragdoll PinJoint2D. The elbow/knee band comes
## follows the LIMB (encoded in the static `limit` strings: left arm/right leg ## from the editable RAGDOLL_JOINT_BAND_LIMITS table (per profile, per joint,
## fold +CW, right arm/left leg fold -CCW) and is profile-independent — the IK ## in degrees); the static `limit` string is only a fallback for joints
## bend flags and the LEFT whole-rig mirror do NOT change it (the ragdoll's ## missing from the table. `shoulder_hip` is ±160°; other limits leave the
## world fold direction stays with the limb). `shoulder_hip` is ±160°; any ## joint free.
## other `limit` leaves the joint free. func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String, child_key: String = "") -> void:
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void:
match limit: match limit:
"elbow_knee", "elbow_knee_ccw": "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 pin.angular_limit_enabled = true
if ccw: pin.angular_limit_lower = deg_to_rad(float(band.get("lower", -5.0)))
pin.angular_limit_lower = -deg_to_rad(150.0) pin.angular_limit_upper = deg_to_rad(float(band.get("upper", 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)
"shoulder_hip": "shoulder_hip":
pin.angular_limit_enabled = true pin.angular_limit_enabled = true
pin.angular_limit_lower = -deg_to_rad(160.0) pin.angular_limit_lower = -deg_to_rad(160.0)
+85
View File
@@ -409,3 +409,88 @@ Verified against the user's RIGHT data: RightLeg knee −159.7° (world −97.5
minimal-world-sweep choice). minimal-world-sweep choice).
- [ ] `[stand-up]` lines show every segment sweep ≤ 180°; end pose exactly pre-ragdoll. - [ ] `[stand-up]` lines show every segment sweep ≤ 180°; end pose exactly pre-ragdoll.
- [ ] No console errors. Flip `DEBUG_RECOVERY` to `false` once verified. - [ ] 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.