- 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.
430 lines
23 KiB
Markdown
430 lines
23 KiB
Markdown
# 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.
|