Files
stickman/plans/KINEMATIC_TO_RAGDOLL_SPEC.md
T
ryan e3df1cc5c0 feat: Implement kinematic-to-ragdoll transition system
- Added KINEMATIC_BLENDING_AND_RECOVERY.md to outline features for smooth transitions between kinematic and ragdoll states, including visual and physical blending, and ragdoll recovery.
- Introduced KINEMATIC_TO_RAGDOLL.md detailing the objectives, scope, and core architecture for transitioning the stickman from kinematic to ragdoll mode.
- Created KINEMATIC_TO_RAGDOLL_SPEC.md as an implementation specification, verifying codebase facts and correcting the initial plan based on Godot 4.4 source.
- Enhanced StickmanRig with state management for animated and ragdoll modes, including momentum preservation and ragdoll construction.
- Updated physics_test_harness to support toggling between kinematic and ragdoll states with user input.
2026-08-27 00:05:17 -04:00

430 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kinematic-to-Ragdoll Translation — Implementation Spec
> Status: implementation-ready. This spec replaces the plan's open questions with
> **verified** facts from the codebase and Godot 4.4 source. Everything below is
> checked against the actual files; corrections to the plan are called out in §3.
---
## 1. Objective
Add a reversible `ANIMATED ⇄ RAGDOLL` state switch to the runtime rig
(`master_rig.tscn` + `scripts/stickman_rig.gd`). In `RAGDOLL` mode the kinematic
`skeleton + IK` puppet is frozen and hidden, and a procedurally-built network of
`RigidBody2D` + `PinJoint2D` nodes (spawned in code, never in the scene file)
takes over, so the figure tumbles/falls against the terrain and props. The
`physics_test_harness` scene gets an **R** key trigger.
---
## 2. Verified codebase facts (read these as ground truth)
### 2.1 Scene tree of `master_rig.tscn` (root = `Master`, script `StickmanRig`)
```
Master (Node2D, script = stickman_rig.gd, class StickmanRig)
├── Body (Node2D — sibling of Skeleton2D)
│ ├── Body (Line2D 0→400, width 16 — TORSO visual)
│ ├── LeftUpperLeg (Line2D 0→200 w16)
│ ├── RightUpperLeg (Line2D 0→200 w16)
│ ├── LeftLowerLeg (Line2D 0→200 w16)
│ ├── RightLowerLeg (Line2D 0→200 w16)
│ ├── LeftUpperArm (Line2D 0→175 w16)
│ ├── RightUpperArm (Line2D 0→175 w16)
│ ├── LeftLowerArm (Line2D 0→200 w16)
│ ├── RightLowerArm (Line2D 0→200 w16)
│ └── Head (Node2D + inline @tool circle script, radius 100)
├── Skeleton2D (modification_stack assigned)
│ ├── Torso (Bone2D, root — NO length)
│ │ ├── Head (Bone2D, pos (0,-391.5) rel Torso, length 90, bone_angle -90)
│ │ │ ├── RayCast_Aim
│ │ │ └── Pivot → RemoteTransform2D (→ Body/Head)
│ │ ├── LeftUpperArm (Bone2D pos (0,-248), length 168)
│ │ │ ├── LeftLowerArm (Bone2D pos (-168,0), length 200)
│ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerArm)
│ │ │ └── RemoteTransform2D (→ Body/LeftUpperArm)
│ │ ├── RightUpperArm (Bone2D pos (0,-248), length 168)
│ │ │ ├── RightLowerArm (Bone2D pos (168,0), length 200)
│ │ │ │ └── RemoteTransform2D (→ Body/RightLowerArm)
│ │ │ └── RemoteTransform2D (→ Body/RightUpperArm)
│ │ ├── LeftUpperLeg (Bone2D pos (0,0), length 200)
│ │ │ ├── LeftLowerLeg (Bone2D pos (0,200), length 200)
│ │ │ │ └── RemoteTransform2D (→ Body/LeftLowerLeg)
│ │ │ └── RemoteTransform2D (→ Body/LeftUpperLeg)
│ │ ├── RightUpperLeg (Bone2D, length 200) ← NOTE: current scene is 200,
│ │ │ ├── RightLowerLeg (Bone2D pos (0,200), length 200) not the stale "90"
│ │ │ │ └── RemoteTransform2D (→ Body/RightLowerLeg) mentioned in old docs
│ │ │ └── RemoteTransform2D (→ Body/RightUpperLeg)
│ │ └── RemoteTransform2D (→ Body/Body, torso driver, rotation π)
├── RayCast_Ground
├── IK_Targets (Right_Hand, Left_Hand, Right_Leg, Left_Leg, Head, Torso→RT2D, RayCasts)
├── AnimationPlayer (libraries: RESET / walk_left / walk_right)
└── AnimationTree (active=false, placeholder — out of scope)
```
**Key answers to "where things live":**
- `Body/*` visual nodes are **siblings of `Skeleton2D`**, both children of `Master`.
They are **not** children of the bones. Each `Body/*` node is driven by a
`RemoteTransform2D` that is a **child of the matching bone** (via `remote_path`).
- The `RemoteTransform2D` drivers live under the bones, not on the `Body/*` nodes.
- The **torso visual** is named `Body/Body` (not `Body/Torso`). The **torso bone**
(`Skeleton2D/Torso`) has **no `length`** — it is the hip joint root. The actual
spine segment runs from the Torso bone origin (hips) to the Head bone origin
(neck), distance ≈ 391.5 px.
### 2.2 Bone semantics (for ragdoll geometry)
`Bone2D` extends along its **local +X** axis by `length`. So for any bone:
- **origin** = `bone.global_position`
- **tip** (far end / child joint) = `bone.to_global(Vector2(bone.length, 0.0))`
- **midpoint** = `(origin + tip) / 2`
- **rotation** = `bone.global_rotation` (the bone points along +X locally)
The `Body/*` `Line2D`s are authored along local +Y and their `RemoteTransform2D`
drivers add `±π/2` rotation to align them with the bone's +X. This is why
`Body/LeftUpperArm` is 175 px but its bone `length` is 168 — **use the Bone2D
`length` for collision, not the Line2D points length**.
### 2.3 Harness integration points (`physics_test_harness.gd`)
- Scene root `PhysicsTestHarness (Node2D)` at world origin, children `Camera2D`,
`Environment` (empty Node2D). Rig is spawned in **code**, not the scene file.
- `_spawn_rig()` (line ~146): `RIG_SCENE.instantiate()` → `rig.position =
RIG_SPAWN_POSITION (0,-385)` → `add_child(rig)` → `_add_rig_collision_proxy()`.
**It does not store the rig reference** — a `_rig` member must be added.
- `_add_rig_collision_proxy()` (line ~191): creates `StaticBody2D` named
**`"RigCollisionProxy"`** as a direct child of the harness root, `position =
RIG_PROXY_CENTER`, with a `RectangleShape2D` child (240×1000). No removal
function exists yet — add `_remove_rig_collision_proxy()`.
- Key handling: `_input()` → `_handle_key(InputEventKey)` (line ~76) already
matches `KEY_1/KEY_2/KEY_3` (guarding `key.pressed` and `key.echo`). **R goes
here** as a new `match` arm. `R` is currently unused — no conflict.
- `PhysicsTestHarness` does **not** play any animation; the rig stands in rest
pose with the modification stack enabled. Momentum at toggle will therefore be
≈ 0 in this harness (the momentum path is generic and still specified).
### 2.4 `StickmanRig` (`scripts/stickman_rig.gd`) internals to reuse
Existing fields/methods (do not rename): `_nodes_ready`, `_skeleton: Skeleton2D`,
`_body_container: Node2D`, `_bend_joint_bones`, `_bend_modifications`;
`_ready()` enables `_skeleton.modification_stack.enabled = true`; `_apply_profile()`
rewrites IK flags + `Body/*` z-order; `get_bend_joint_global_position()`.
The ragdoll system adds **new** members and does not change these.
### 2.5 AnimationPlayer
Owned by the rig root (`Master`), direct child, named **`"AnimationPlayer"`.
`test_harness.gd` confirms `const ANIMATION_PLAYER_PATH := "AnimationPlayer"`.
Ragdoll code resolves it as `get_node_or_null(NodePath("AnimationPlayer"))`.
### 2.6 Collision layers
No script sets `collision_layer`/`collision_mask`. `TerrainBlock` (StaticBody2D),
`PropBlock` (RigidBody2D), and `RigCollisionProxy` (StaticBody2D) all use Godot
defaults: **layer 1, mask 1**. Ragdoll bodies must therefore also use **layer 1,
mask 1** so they collide with terrain and props (and props still bounce off them).
---
## 3. Plan corrections (verified against Godot 4.4 source)
1. **`PinJoint2D` DOES have angle limits in Godot 4.4** — the plan's "no limits,
must build a custom joint" assumption is wrong. Verified properties (4.4 docs
+ `scene/2d/physics/joints/pin_joint_2d.{h,cpp}`):
- `softness: float` (default 0)
- `angular_limit_enabled: bool` (default false)
- `angular_limit_lower: float` (radians, default 0, hint range 180°..180°)
- `angular_limit_upper: float` (radians, default 0, hint range 180°..180°)
- `motor_enabled: bool`, `motor_target_velocity: float` (rad/s)
- Inherited from `Joint2D`: `node_a`, `node_b`, `bias`, `disable_collision`
(default **true** — connected bodies won't self-collide, which is what we want).
- The pin point = the joint node's **global position**
(`joint_make_pin(joint, get_global_position(), …)`).
**Angle-limit semantics (critical, read carefully)** — from
`modules/godot_physics_2d/godot_joints_2d.cpp`:
- On joint construction the solver stores
`initial_angle = angle_from(parent_body_origin → child_body_origin)` (world
space, captured **once**).
- Each step it computes
`dist = angle( (child_origin parent_origin).rotated(initial_angle) )` and
clamps `dist` to `[angular_limit_lower, angular_limit_upper]`.
- **Therefore the limits are measured in WORLD space**, relative to the
spawn-time direction of the parent→child center vector, and **do not follow
the parent body's own rotation**.
- Practical consequences (documented, accepted for v1):
* The limit is an *approximation* of the child's swing angle (it uses body
centers, so there is a ~parent_length/2 parallax — monotonic and fine).
* Because it is world-frame, the "no backward bend" guarantee holds at the
spawn orientation but degrades as the whole figure tumbles. This is the
standard ragdoll trade-off; a local-frame custom joint is listed as a
**deferred enhancement**, not v1.
* The per-limb fold sign (+/) depends on limb side and facing — the
implementer must do a one-time visual check and swap/normalize the
`lower`/`upper` pair per joint (the data table in §5 makes this a one-line
edit).
2. **`CapsuleShape2D` semantics** (verified in `modules/godot_physics_2d/godot_shape_2d.cpp`):
`height` = **total** capsule height (tip to tip), `radius` = cap radius; the
straight section length = `height 2·radius`; AABB spans local Y
`[height/2, +height/2]`. **Recommendation: `height = bone.length`, `radius =
8.0`** so the capsule spans the bone exactly tip-to-tip (rounded caps at the
joints), matching the Line2D's round caps. (`width 16 → radius 8`. The plan's
"≈14" is a chunkier stability alternative; expose as a named constant.)
3. **Engine version discrepancy**: `AGENTS.md` says "Godot 4.4" but
`project.godot` has `config/features=PackedStringArray("4.7", "Forward Plus")`
(project last saved with 4.7). Disk has `Godot_v4.4-stable_win64{,_console}.exe`
and `Godot_v4.7.1-stable_win64{,_console}.exe`. The angle-limit API verified
here exists in 4.4 and later. Recommend the implementer confirm which binary is
canonical (default to **4.4** per AGENTS/task, flag the 4.7 features string).
4. **`RightUpperLeg.length` is already 200** in the current `master_rig.tscn`
(the "90" value in old docs/AGENTS is stale). The ragdoll builder reads
`bone.length` live, so this is moot, but do not assume 90 anywhere.
5. **Momentum**: `StickmanRig` is a `Node2D` — it has no built-in velocity and
no angular velocity. Track both from per-frame deltas (see §6.3). In the
current harness the rig never moves, so values are ≈ 0; the mechanism is
generic for future use.
---
## 4. Public API on `StickmanRig`
```gdscript
enum RigState { ANIMATED, RAGDOLL }
signal state_changed(new_state: int) # emits RigState value
var state: RigState = RigState.ANIMATED # read-only outside; only setters change it
func is_in_ragdoll() -> bool # state == RigState.RAGDOLL
func set_ragdoll(enabled: bool) -> void # enter if enabled & !in_ragdoll; exit if !enabled & in_ragdoll
func toggle_ragdoll() -> void # set_ragdoll(not is_in_ragdoll())
```
`set_ragdoll()` / `toggle_ragdoll()` are the only external entry points. The
harness calls `rig.toggle_ragdoll()`.
---
## 5. Ragdoll data tables
### 5.1 Body definitions (10 bodies)
Ordered so parents are built before children. `bone_path` is relative to the rig
root. The torso and head are special-cased in the builder (see §6.2).
| key | bone_path (Skeleton2D-relative) | parent | shape | length source | radius | mass | lin damp | ang damp |
|---|---|---|---|---|---|---|---|---|
| `torso` | `Skeleton2D/Torso` | — | capsule | dist(Torso, Head) | 12 | 8.0 | 1.0 | 4.0 |
| `head` | `Body/Head` (visual center) | `torso` | circle | radius 100 | 100| 2.0 | 0.5 | 2.0 |
| `left_upper_arm` | `Skeleton2D/Torso/LeftUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 |
| `left_lower_arm` | `Skeleton2D/Torso/LeftUpperArm/LeftLowerArm` | `left_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 |
| `right_upper_arm`| `Skeleton2D/Torso/RightUpperArm` | `torso` | capsule | `bone.length` (168)| 8 | 1.5 | 0.5 | 3.0 |
| `right_lower_arm`| `Skeleton2D/Torso/RightUpperArm/RightLowerArm` | `right_upper_arm` | capsule | `bone.length` (200) | 8 | 1.0 | 0.5 | 3.0 |
| `left_upper_leg` | `Skeleton2D/Torso/LeftUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 |
| `left_lower_leg` | `Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg` | `left_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 |
| `right_upper_leg`| `Skeleton2D/Torso/RightUpperLeg` | `torso` | capsule | `bone.length` (200)| 8 | 2.0 | 0.5 | 3.0 |
| `right_lower_leg`| `Skeleton2D/Torso/RightUpperLeg/RightLowerLeg` | `right_upper_leg` | capsule | `bone.length` (200) | 8 | 1.5 | 0.5 | 3.0 |
Named constants: `RAGDOLL_LIMB_RADIUS := 8.0`, `RAGDOLL_TORSO_RADIUS := 12.0`,
`RAGDOLL_HEAD_RADIUS := 100.0`. (Tune `RAGDOLL_LIMB_RADIUS` up to ~14 if limbs
tunnel at high speed.)
### 5.2 Joint definitions (9 joints)
One `PinJoint2D` per non-root body. `pin_point` = the **child bone's origin**
(`child_bone.global_position`), which coincides with the parent bone's tip.
`node_a` = parent body, `node_b` = child body.
| joint | child | parent | pin_point (world) | limits |
|---|---|---|---|---|
| neck | `head` | `torso` | `Head.global_position` | **free** (`angular_limit_enabled=false`) |
| left_shoulder | `left_upper_arm` | `torso` | `LeftUpperArm.global_position` | shoulder/hip band |
| left_elbow | `left_lower_arm` | `left_upper_arm` | `LeftLowerArm.global_position` | elbow/knee band |
| right_shoulder | `right_upper_arm`| `torso` | `RightUpperArm.global_position` | shoulder/hip band |
| right_elbow | `right_lower_arm`| `right_upper_arm`| `RightLowerArm.global_position` | elbow/knee band |
| left_hip | `left_upper_leg` | `torso` | `LeftUpperLeg.global_position` | shoulder/hip band |
| left_knee | `left_lower_leg` | `left_upper_leg` | `LeftLowerLeg.global_position` | elbow/knee band |
| right_hip | `right_upper_leg`| `torso` | `RightUpperLeg.global_position` | shoulder/hip band |
| right_knee | `right_lower_leg`| `right_upper_leg`| `RightLowerLeg.global_position` | elbow/knee band |
Limit bands (radians, relative to spawn angle; **sign to be eyeballed per limb**):
| band | angular_limit_enabled | lower | upper |
|---|---|---|---|
| elbow/knee | true | `-deg_to_rad(5)` | `+deg_to_rad(150)` |
| shoulder/hip| true | `-deg_to_rad(160)` | `+deg_to_rad(160)` |
| neck | false | (n/a) | (n/a) |
If a joint folds the wrong way in testing, swap its `lower`/`upper` (or negate
both) in the table. For a stricter "no hyperextension" at rest, lower
elbow/knee to `-deg_to_rad(2)`.
All joints: `softness = 0.0` (stiff; raise to 0.050.2 only if jitter/stretch),
`bias` left at default 0 (uses project `default_constraint_bias`),
`disable_collision` left `true` (default).
---
## 6. Implementation design
### 6.1 State & lifecycle
- New members: `state: RigState`, `_ragdoll_root: Node2D` (container),
`_ragdoll_bodies: Dictionary` (key → `RigidBody2D`), `_prev_global_pos:
Vector2`, `_prev_global_rot: float`, `_cached_linear_velocity: Vector2`,
`_cached_angular_velocity: float`, `_anim_player: AnimationPlayer`.
- `_physics_process(delta)`: `_track_momentum(delta)` (only meaningful while
`ANIMATED`; harmless otherwise).
### 6.2 `_enter_ragdoll()`
1. Freeze kinematic rig: `_skeleton.modification_stack.enabled = false`;
`_anim_player.stop()` (null-guarded); `_body_container.visible = false`.
(Leave `RemoteTransform2D` drivers as-is — hidden visuals, zero visual cost.)
2. `_build_ragdoll()`:
- Create `_ragdoll_root = Node2D.new()`, name `"RagdollBodyContainer"`.
- **Reparent to world space**: add as child of `get_parent()` (the harness
root, at world origin). Guard: if `get_parent() == null`, fall back to
`get_tree().current_scene`. Bodies/joints are placed in **world** coords
(their `position == global_position` under an origin parent). Do NOT parent
the container under the rig — the rig carries `RIG_SPAWN_POSITION` offset.
- For each entry in the body table (parent-before-child order):
* Resolve the `Bone2D` via `_skeleton.get_node_or_null` (torso/limbs) or the
`Body/Head` visual via `get_node_or_null`. Null → `push_warning` + skip
that body (and any joints that reference it).
* Compute origin/tip/midpoint/rotation:
- limbs: origin=`bone.global_position`, tip=`bone.to_global(Vector2(bone.length,0))`.
- torso: origin=`Torso.global_position`, tip=`Head.global_position`
(length = that distance).
- head: center=`Body/Head.global_position` (the circle center).
* Build `RigidBody2D` (name `"Ragdoll_" + key`):
- `mass`, `linear_damp`, `angular_damp` from table.
- `gravity_scale = 1.0`, `lock_rotation = false`, `freeze = false`.
- `collision_layer = 1`, `collision_mask = 1` (defaults; explicit for clarity).
- `CollisionShape2D` child: `CapsuleShape2D(height=length, radius=r)` for
capsules, or `CircleShape2D(radius=100)` for head.
- Position body at midpoint, rotation = bone `global_rotation`
(capsules); head rotation irrelevant (circle).
- `_ragdoll_root.add_child(body)`; record in `_ragdoll_bodies[key]`.
- For each joint entry (skip if either body missing):
* `var pin := PinJoint2D.new()`, name `"RagdollPin_" + child_key`.
* `pin.position = pin_point` (world). Add to `_ragdoll_root`.
* `pin.node_a = pin.get_path_to(parent_body)`; `pin.node_b =
pin.get_path_to(child_body)`.
* Apply limit band + `softness`. (Set `node_a`/`node_b` **after** adding to
the tree so `initial_angle` captures the rest pose — bodies are already
positioned, so the reference is correct.)
- Momentum handoff: `_ragdoll_bodies["torso"].linear_velocity =
_cached_linear_velocity`; `.angular_velocity = _cached_angular_velocity`
(set **after** `add_child`).
3. `state = RigState.RAGDOLL`; `state_changed.emit(int(state))`.
### 6.3 `_track_momentum(delta)`
```gdscript
if delta > 0.0:
_cached_linear_velocity = (global_position - _prev_global_pos) / delta
_cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta
_prev_global_pos = global_position
_prev_global_rot = global_rotation
```
Initialize `_prev_*` in `_ready()`.
### 6.4 `_exit_ragdoll()`
1. `_destroy_ragdoll()`: if `_ragdoll_root != null && is_instance_valid`,
`_ragdoll_root.queue_free()`; clear `_ragdoll_bodies`, `_ragdoll_root = null`.
2. Re-enable kinematic rig: `_body_container.visible = true`;
`_skeleton.modification_stack.enabled = true`; `_anim_player.stop()` (leave
pose as-is; do not attempt to restore a specific frame — the skeleton keeps
its last pose, which for the harness is the rest/IK pose).
3. `state = RigState.ANIMATED`; `state_changed.emit(int(state))`.
### 6.5 `set_ragdoll(enabled)`
Idempotent guards: entering while already `RAGDOLL` (or exiting while `ANIMATED`)
is a no-op. Null-guard `_skeleton`/`_body_container` before use (consistent with
the file's `push_warning` style).
---
## 7. `physics_test_harness.gd` changes (exact)
1. Add member: `var _rig: StickmanRig = null`.
2. In `_spawn_rig()`, change `as Node2D` → `as StickmanRig`, store `_rig = rig`.
3. Add a `match` arm in `_handle_key`:
```gdscript
KEY_R:
if _rig != null:
_rig.toggle_ragdoll()
if _rig.is_in_ragdoll():
_remove_rig_collision_proxy()
else:
_add_rig_collision_proxy()
```
4. Add `_remove_rig_collision_proxy()`: find the direct child named
`"RigCollisionProxy"` (iterate `get_children()` matching `name`), `queue_free()`
if present. Make `_add_rig_collision_proxy()` idempotent: skip if a child named
`"RigCollisionProxy"` already exists (prevents duplicates on rapid toggling).
---
## 8. Acceptance criteria
- **Toggle**: pressing **R** hides the stick figure, removes `RigCollisionProxy`,
and spawns a physics ragdoll at the same world pose; pressing **R** again frees
the ragdoll, restores the figure, and re-adds the proxy.
- **Terrain**: ragdoll rests/slides/tumbles on flat ground, ramp, and stairs
(collides with `TerrainBlock` bodies); props still bounce off it.
- **Integrity**: connected limbs never separate/detach (pin joints hold under
gravity); elbows/knees resist hyperextension and full 360° rotation at the
spawn orientation (native world-frame limits — see caveat in §3.1).
- **Fidelity**: collision capsules/circle line up with the hidden bones (no
floating/offset shapes).
- **Cleanup**: rapid R toggling leaves no orphaned nodes (proxy + ragdoll
container are freed each cycle); no leaked `RigidBody2D`/`PinJoint2D`.
- **No scene edits**: `master_rig.tscn` and `physics_test_harness.tscn` are
unchanged; everything is code-driven.
---
## 9. Verification commands (for the tester phase)
Godot binaries live in `C:\Godot4\` (`Godot_v4.4-stable_win64_console.exe`,
`Godot_v4.7.1-stable_win64_console.exe`). No shell tool is available to the
Architect; the Tester runs these:
```bat
:: 1. Whole-project parse/compile check (reports script errors, then quits)
C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --editor --path C:\Godot4\stickman --quit
:: 2. Headless runtime smoke test of the harness scene (runs N frames, then quits)
C:\Godot4\Godot_v4.4-stable_win64_console.exe --headless --path C:\Godot4\stickman res://scenes/physics_test_harness.tscn --quit-after 120
```
- Command 1 catches GDScript syntax/type errors across all scripts.
- Command 2 exercises `_ready()` + `_spawn_rig()` + `_add_rig_collision_proxy()`.
Headless cannot send the **R** keypress, so the toggle itself must be verified
**interactively** via F6 on `physics_test_harness.tscn` (project convention):
load scene, press R, observe ragdoll fall; press R again; confirm figure
restored and props still collide. Rapid-tap R to check for orphans/leaks
(use the editor's remote scene tree).
---
## 10. Deferred / out of scope
- **Local-frame angular limits** (limits that follow the parent's rotation) —
would require a custom `Joint2D` or per-frame correction; only pursue if QA
deems the world-frame limits insufficient for tumbling realism.
- **Disabling `RemoteTransform2D` drivers** while in RAGDOLL — skipped (hidden
visuals, negligible cost).
- **`AnimationTree`** — untouched placeholder (per existing convention).
- **Ragdoll self-collision tuning** — v1 relies on `disable_collision=true` for
joint-connected pairs + thin capsules; a dedicated collision layer for
intra-ragdoll exclusions is a follow-up if jitter appears.