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,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