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
+205
View File
@@ -0,0 +1,205 @@
# Ragdoll ↔ Kinematic Blending & Recovery
## 1. Overview
Extend the existing kinematic-to-ragdoll system with two major features:
- **Blended Transition:** A smooth, visually appealing fade between the kinematic puppet and the ragdoll, eliminating the abrupt "pop" when switching modes.
- **Ragdoll Recovery:** The ability for the stickman to autonomously stand back up after falling, transitioning from ragdoll back to animated mode with a "get up" animation.
These features are essential for a director-driven sandbox where characters can fall, recover, and continue performing actions.
---
## 2. Feature 1: Ragdoll ↔ Kinematic Blending (Soft Transition)
### 2.1. Objective
Replace the instant mode switch with a gradual transition that blends the visual appearance and physical behavior over a configurable duration (e.g., 0.51.0 seconds). This avoids jarring pops and creates a more polished, filmlike effect.
### 2.2. Approach
#### 2.2.1. Visual Blending (Opacity Crossfade)
- During the transition, both the kinematic `Body/*` nodes and the ragdoll `RigidBody2D` bodies are visible.
- The kinematic nodes start at full opacity and fade out; the ragdoll bodies start at zero opacity and fade in.
- Use a `Tween` or `_process` lerp to drive the `modulate.a` of all relevant nodes over the transition duration.
#### 2.2.2. Physical Blending (Joint Stiffness Ramp)
- When entering ragdoll, start with the `PinJoint2D` stiffness (`softness`/`bias`) at a high value (nearrigid).
- Gradually reduce stiffness over the transition period so the limbs become floppy.
- Conversely, when exiting ragdoll, ramp stiffness from floppy to rigid before freezing the pose.
#### 2.2.3. Implementation Outline
- `StickmanRig` gains a `transition_duration` property (export, default 0.6s).
- `_enter_ragdoll()` spawns the ragdoll bodies with `modulate.a = 0.0` and joints at high stiffness.
- A `_transition_process(delta)` runs during the blend, updating opacities and joint properties.
- Upon completion, the kinematic nodes are hidden (or vice versa) and the system settles into the target state.
### 2.3. Acceptance Criteria
- No visible pop when switching modes.
- The transition duration is configurable (tunable per director preference).
- Both visual and physical blending are synchronized.
---
## 3. Feature 2: Ragdoll Recovery (Getting Back Up)
### 3.1. Objective
Allow the ragdoll to automatically stand up after it has come to rest. This involves detecting rest, capturing the ragdolls final pose, applying that pose to the kinematic skeleton, playing a "stand up" animation, and transitioning back to animated mode.
### 3.2. Core Components
#### 3.2.1. Rest Detection
- Monitor the ragdoll Torso bodys linear and angular velocities.
- When both remain below a small threshold (e.g., `0.1 m/s` and `0.1 rad/s`) for a continuous **timeout** (set by the director), trigger recovery.
- The timeout must be configurable per character (or globally).
#### 3.2.2. Pose Capture
- After rest is detected, read the global positions and rotations of all 10 `RigidBody2D` bodies.
- Convert these into local transforms relative to the `StickmanRig` root (or the `Skeleton2D` root).
- This captured pose becomes the target for the kinematic bones.
#### 3.2.3. Kinematic Snap
- Temporarily disable IK (`SkeletonModificationStack2D.enabled = false`).
- Set each `Bone2D` nodes global position and rotation to match the captured ragdoll pose.
- This ensures the skeleton matches the ragdolls final resting posture.
#### 3.2.4. Stand-Up Animation
- Play a "stand up" animation (e.g., `stand_up`) that transitions the skeleton from the captured pose to a neutral standing pose.
- The animation should be authored/generated to work from any reasonable rest pose.
- Once the animation finishes, reenable IK and set the rig back to `ANIMATED` mode.
### 3.3. Implementation Outline
- Add a new state `RECOVERING` to `RigState`.
- In `_physics_process`, when in `RAGDOLL` mode, track the Torsos velocity and a rest timer.
- When rest timer exceeds `rest_timeout`, call `_start_recovery()`.
- `_start_recovery()`:
1. Capture ragdoll pose.
2. Delete ragdoll bodies (or hide them).
3. Snap kinematic skeleton to captured pose.
4. Start the `AnimationPlayer` with the `stand_up` animation.
5. On animation end, reenable IK, show `Body/*`, transition to `ANIMATED`.
- The recovery process should be interruptible (e.g., if the director toggles back to ragdoll during recovery).
### 3.4. Acceptance Criteria
- Ragdoll automatically stands up after resting for the configured timeout.
- The standup motion is smooth and visually convincing.
- The character resumes animated behavior after recovery.
---
## 4. Animation Generation for Recovery
### 4.1. Current State
You have a `create_walk.gd` editor script that generates `walk_left` and `walk_right` animations by keyframing IK target positions.
### 4.2. Proposed Enhancement: `create_animations.gd`
Refactor the animation generation into a unified script that can generate:
- **Walk cycles** (already done)
- **Standup animation** (from a "down" pose to standing)
- **Idle / breathing** (optional)
#### 4.2.1. Architecture
- The script should define a set of **pose templates** (e.g., `POSE_DOWN`, `POSE_STANDING`).
- Each template maps IK target names to positions (relative to the rig root).
- The standup animation is a blend between the captured pose (first frame) and the standing pose (last frame), with intermediate frames interpolated using a curve.
#### 4.2.2. Integration
- The recovery logic will reference a pregenerated `stand_up` animation stored in the `AnimationPlayer`.
- The same animation library (`""`) holds all animations.
- The generator script can be run once at authoring time to create the default animations.
#### 4.2.3. FutureProofing
- The generator could also accept parameters (e.g., `animation_name`, `profile`, `duration`) to make it reusable.
### 4.3. Acceptance Criteria
- A single script (`create_animations.gd`) generates `walk_left`, `walk_right`, and `stand_up`.
- The `stand_up` animation works from any reasonable rest pose (i.e., it starts from the current skeleton pose, not a fixed start).
---
## 5. DirectorControlled Rest Timeout
### 5.1. Requirement
The director (user) should be able to adjust how long the ragdoll stays on the ground before attempting recovery. This is crucial for storytelling—some scenes need a quick recovery, others need a long pause.
### 5.2. Implementation
- `StickmanRig` gains an `@export var rest_timeout: float = 2.0` (seconds).
- The `PhysicsTestHarness` UI (and ultimately the director UI) will provide a slider or spinbox to modify this value on the selected rig.
- The value is read during `_physics_process` to determine when to start recovery.
### 5.3. Acceptance Criteria
- The rest timeout is editable in the inspector (or via a UI control).
- Changes take effect immediately (no need to reload the rig).
---
## 6. Integration Timeline (Suggested Order)
| Step | Task | Notes |
| ---- | ---------------------------------------------------------------------- | --------------------------------------------------- |
| 1 | Add `transition_duration` and `rest_timeout` exports to `StickmanRig`. | Low risk, sets foundation. |
| 2 | Implement rest detection timer in `_physics_process`. | Test by printing when rest is detected. |
| 3 | Implement pose capture and kinematic snap. | Manual trigger for testing. |
| 4 | Create `create_animations.gd` with a `stand_up` placeholder. | Even a simple interpolation is fine for first pass. |
| 5 | Integrate recovery flow (snap → play animation → reenable IK). | Endtoend test. |
| 6 | Implement visual blending (opacity crossfade). | Polish. |
| 7 | Implement physical blending (joint stiffness ramp). | Advanced polish. |
| 8 | Add UI control for `rest_timeout` in the harness. | Directorfacing. |
---
## 7. Risks & Mitigations
| Risk | Mitigation |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Recovery animation looks unnatural because the starting pose varies. | Use a generic "pushup" style animation that starts from a prone position; blend the first frame from the captured pose to the animations first keyframe. |
| Kinematic snap may cause jitter if the ragdoll pose is unstable. | Add a small stabilization delay (0.1s) after rest detection before capturing. |
| Blending physics (joint stiffness) may cause limbs to twitch. | Use a smooth interpolation curve (easeinout) rather than linear. |
| Multiple rigs in the scene may compete for recovery timers. | Each `StickmanRig` manages its own state independently. |
---
## 8. Summary of New/Modified Files
| File | Action |
| ---------------------------------- | --------------------------------------------------------------------------- |
| `scripts/stickman_rig.gd` | Add transition logic, rest detection, pose capture, recovery state machine. |
| `scripts/create_animations.gd` | New file: unified animation generator for walk and standup. |
| `scenes/physics_test_harness.tscn` | Add UI controls (slider/spinbox) for `rest_timeout`. |
| `scripts/physics_test_harness.gd` | Connect UI to the rigs `rest_timeout` property. |
---
## 9. Acceptance Criteria (Full Feature Set)
- Switching between animated and ragdoll modes is visually smooth (crossfade).
- The ragdoll automatically stands up after resting for the directordefined duration.
- The standup animation is generated procedurally and plays seamlessly.
- The director can adjust the rest timeout at runtime.
- The system is robust and does not produce orphaned nodes or crashes.
---
_End of Plan_
+72
View File
@@ -0,0 +1,72 @@
# Kinematic-to-Ragdoll Translation
## 1. Objective
We want dynamic state-switching system that transitions the `master_rig.tscn` stickman from a kinematic, IK-driven puppet (`ANIMATED` mode) into a fully physical, ragdoll-driven entity (`RAGDOLL` mode). This enables natural falling, tumbling, and collision responses with the physics environment (terrain and props) while preserving the stickmans current momentum.
## 2. Scope & Impact
- **Primary Target:** `scripts/stickman_rig.gd` (the runtime controller of `master_rig.tscn`).
- **Secondary Target:** `scripts/physics_test_harness.gd` (adds trigger method).
- **Scene Impact:** The `master_rig.tscn` scene file will **not** be modified. All ragdoll nodes (bodies and joints) will be spawned procedurally in code, keeping the scene file clean and maintaining the separation between visual authoring and runtime physics.
## 3. Core Architecture / Behavior
### 3.1. State Management
`StickmanRig` gains a new `state` property with two modes: `ANIMATED` and `RAGDOLL`.
- **`ANIMATED` (Default):** The skeleton and visual `Body/*` nodes are visible and driven by the `AnimationPlayer` and `SkeletonModificationStack2D` (IK). The script tracks the root nodes velocity every frame to cache momentum.
- **`RAGDOLL`:** The kinematic rig is frozen (IK disabled, `AnimationPlayer` stopped), the `Body/*` visual nodes are hidden, and a new network of `RigidBody2D` nodes (with matching collision shapes) is spawned and reparented to the world root. Physical `PinJoint2D` nodes connect these bodies, simulating the bone hierarchy.
### 3.2. Velocity Handoff (Momentum Preservation)
To avoid unnatural "freeze" effects when switching, the system captures the `linear_velocity` and `angular_velocity` of the rig root in `ANIMATED` mode (using `_process`). Upon entering `RAGDOLL` mode, these cached velocities are applied directly to the ragdolls **Torso** body, ensuring the stickman continues its current motion (falling, sliding, etc.) seamlessly.
## 4. Implementation Breakdown
### 4.1. Ragdoll Construction
A dedicated builder function iterates over a predefined set of `Bone2D` nodes from the `Skeleton2D` hierarchy. For each bone, it spawns a `RigidBody2D` with the following specifications:
- **Position:** The midpoint of the bone (calculated from `global_position` and `bone.length`).
- **Rotation:** Matches the `global_rotation` of the bone.
- **Collision Shapes:**
- **Head:** Uses a `CircleShape2D` with a radius of **100px** (matching the visual head graphic).
- **Other Bones (Limbs & Torso):** Uses a `CapsuleShape2D`. The height matches the bones `.length` property, and the radius matches the visual line width (approximately **14px**).
- **Mass Distribution:** The Torso is given a significantly higher mass (e.g., `8.0`) compared to limbs (e.g., `1.5`) to create realistic inertia and prevent limbs from dragging the body into orbit upon collision.
- **Damping:** `Linear` and `Angular` damping are applied to prevent excessively bouncy or "spaghetti-like" behavior.
### 4.2. Jointing (Parent-Child Constraints)
For every child bone that has a valid parent bone, the system spawns a `PinJoint2D`.
- **Positioning:** The joint is placed at the **bottom of the parent bone**, which aligns with the **top of the child bone**.
- **Rotation Limits (Critical for realism):**
- **Elbows and Knees:** Angular limits are enforced (strictly restricted to prevent backward bending/hyperextension).
- **Head/Neck:** Loose limits or free rotation to allow natural lolling.
- **Torso/Shoulders/Hips:** Moderate limits to maintain structural integrity while allowing dynamic twisting.
- **Stiffness:** The `softness` and `bias` properties are tuned to prevent limb separation (stretching) under stress.
### 4.3. Cleanup & Reversion
The `RAGDOLL` mode is designed to be reversible:
- When exiting `RAGDOLL` (or reloading the rig), all dynamically spawned `RigidBody2D` and `PinJoint2D` nodes are queued for deletion.
- The kinematic `Body/*` nodes are made visible again.
- The `SkeletonModificationStack2D` (IK) is re-enabled, and the `AnimationPlayer` is reset to a neutral state.
## 5. Integration with Test Harness
The existing `PhysicsTestHarness` scene will be updated to include a user trigger:
- **Key Binding:** Pressing the **R** key will call the `toggle_ragdoll()` method.
- **Collision Proxy Management:** The harness currently places a `RigCollisionProxy` (a large static box) around the rig to allow props to interact with it. When entering `RAGDOLL` mode, this proxy **must be destroyed** immediately; otherwise, the physical ragdoll will spawn inside the box and float unrealistically. The ragdoll will instead collide directly with the actual terrain geometry.
## 6. Acceptance Criteria (For QA/Testing)
- **Transition Seamlessness:** Switching to ragdoll mid-walk or mid-air applies the correct linear/angular momentum so the stickman continues the trajectory naturally.
- **Terrain Interaction:** The ragdoll must rest, slide, or tumble naturally on flat ground, ramps, and stairs (colliding with the `TerrainBlock` static bodies).
- **Structural Integrity:** Limbs should not stretch or detach under gravity or moderate impact. Elbows and knees must not bend backwards.
- **Visual Fidelity:** The physical collision shapes should align visually with the hidden `Line2D` bones (no floating collision boxes).
- **Cleanup:** Rapid switching between modes (spawning/deleting ragdolls) does not cause memory leaks or orphaned nodes.
+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.