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:
@@ -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.5–1.0 seconds). This avoids jarring pops and creates a more polished, film‑like 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 (near‑rigid).
|
||||
- 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 ragdoll’s 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 body’s 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` node’s global position and rotation to match the captured ragdoll pose.
|
||||
- This ensures the skeleton matches the ragdoll’s 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, re‑enable 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 Torso’s 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, re‑enable 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 stand‑up 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)
|
||||
- **Stand‑up 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 stand‑up 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 pre‑generated `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. Future‑Proofing
|
||||
|
||||
- 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. Director‑Controlled 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 → re‑enable IK). | End‑to‑end 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. | Director‑facing. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Recovery animation looks unnatural because the starting pose varies. | Use a generic "push‑up" style animation that starts from a prone position; blend the first frame from the captured pose to the animation’s 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 (ease‑in‑out) 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 stand‑up. |
|
||||
| `scenes/physics_test_harness.tscn` | Add UI controls (slider/spinbox) for `rest_timeout`. |
|
||||
| `scripts/physics_test_harness.gd` | Connect UI to the rig’s `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 director‑defined duration.
|
||||
- The stand‑up 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_
|
||||
@@ -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 stickman’s 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 node’s 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 ragdoll’s **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 bone’s `.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.
|
||||
@@ -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.05–0.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.
|
||||
Reference in New Issue
Block a user