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.
This commit is contained in:
2026-08-27 00:05:17 -04:00
parent 6edf3e53e3
commit e3df1cc5c0
12 changed files with 1325 additions and 57 deletions
+429
View File
@@ -0,0 +1,429 @@
# 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.