Files
stickman/plans/KINEMATIC_TO_RAGDOLL_SPEC.md
ryan e3df1cc5c0 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.
2026-08-27 00:05:17 -04:00

23 KiB
Raw Permalink Blame History

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/* Line2Ds 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

enum RigState { ANIMATED, RAGDOLL }

signal state_changed(new_state: int)          # emits RigState value

var state: RigState = RigState.ANIMATED        # read-only outside; only setters change it

func is_in_ragdoll() -> bool                   # state == RigState.RAGDOLL
func set_ragdoll(enabled: bool) -> void        # enter if enabled & !in_ragdoll; exit if !enabled & in_ragdoll
func toggle_ragdoll() -> void                  # set_ragdoll(not is_in_ragdoll())

set_ragdoll() / toggle_ragdoll() are the only external entry points. The harness calls rig.toggle_ragdoll().


5. Ragdoll data tables

5.1 Body definitions (10 bodies)

Ordered so parents are built before children. bone_path is relative to the rig root. The torso and head are special-cased in the builder (see §6.2).

key bone_path (Skeleton2D-relative) parent shape length source radius mass lin damp ang damp
torso Skeleton2D/Torso capsule dist(Torso, Head) 12 8.0 1.0 4.0
head Body/Head (visual center) torso circle radius 100 100 2.0 0.5 2.0
left_upper_arm Skeleton2D/Torso/LeftUpperArm torso capsule bone.length (168) 8 1.5 0.5 3.0
left_lower_arm Skeleton2D/Torso/LeftUpperArm/LeftLowerArm left_upper_arm capsule bone.length (200) 8 1.0 0.5 3.0
right_upper_arm Skeleton2D/Torso/RightUpperArm torso capsule bone.length (168) 8 1.5 0.5 3.0
right_lower_arm Skeleton2D/Torso/RightUpperArm/RightLowerArm right_upper_arm capsule bone.length (200) 8 1.0 0.5 3.0
left_upper_leg Skeleton2D/Torso/LeftUpperLeg torso capsule bone.length (200) 8 2.0 0.5 3.0
left_lower_leg Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg left_upper_leg capsule bone.length (200) 8 1.5 0.5 3.0
right_upper_leg Skeleton2D/Torso/RightUpperLeg torso capsule bone.length (200) 8 2.0 0.5 3.0
right_lower_leg Skeleton2D/Torso/RightUpperLeg/RightLowerLeg right_upper_leg capsule bone.length (200) 8 1.5 0.5 3.0

Named constants: RAGDOLL_LIMB_RADIUS := 8.0, RAGDOLL_TORSO_RADIUS := 12.0, RAGDOLL_HEAD_RADIUS := 100.0. (Tune RAGDOLL_LIMB_RADIUS up to ~14 if limbs tunnel at high speed.)

5.2 Joint definitions (9 joints)

One PinJoint2D per non-root body. pin_point = the child bone's origin (child_bone.global_position), which coincides with the parent bone's tip. node_a = parent body, node_b = child body.

joint child parent pin_point (world) limits
neck head torso Head.global_position free (angular_limit_enabled=false)
left_shoulder left_upper_arm torso LeftUpperArm.global_position shoulder/hip band
left_elbow left_lower_arm left_upper_arm LeftLowerArm.global_position elbow/knee band
right_shoulder right_upper_arm torso RightUpperArm.global_position shoulder/hip band
right_elbow right_lower_arm right_upper_arm RightLowerArm.global_position elbow/knee band
left_hip left_upper_leg torso LeftUpperLeg.global_position shoulder/hip band
left_knee left_lower_leg left_upper_leg LeftLowerLeg.global_position elbow/knee band
right_hip right_upper_leg torso RightUpperLeg.global_position shoulder/hip band
right_knee right_lower_leg right_upper_leg RightLowerLeg.global_position elbow/knee band

Limit bands (radians, relative to spawn angle; sign to be eyeballed per limb):

band angular_limit_enabled lower upper
elbow/knee true -deg_to_rad(5) +deg_to_rad(150)
shoulder/hip true -deg_to_rad(160) +deg_to_rad(160)
neck false (n/a) (n/a)

If a joint folds the wrong way in testing, swap its lower/upper (or negate both) in the table. For a stricter "no hyperextension" at rest, lower elbow/knee to -deg_to_rad(2).

All joints: softness = 0.0 (stiff; raise to 0.050.2 only if jitter/stretch), bias left at default 0 (uses project default_constraint_bias), disable_collision left true (default).


6. Implementation design

6.1 State & lifecycle

  • New members: state: RigState, _ragdoll_root: Node2D (container), _ragdoll_bodies: Dictionary (key → RigidBody2D), _prev_global_pos: Vector2, _prev_global_rot: float, _cached_linear_velocity: Vector2, _cached_angular_velocity: float, _anim_player: AnimationPlayer.
  • _physics_process(delta): _track_momentum(delta) (only meaningful while ANIMATED; harmless otherwise).

6.2 _enter_ragdoll()

  1. Freeze kinematic rig: _skeleton.modification_stack.enabled = false; _anim_player.stop() (null-guarded); _body_container.visible = false. (Leave RemoteTransform2D drivers as-is — hidden visuals, zero visual cost.)
  2. _build_ragdoll():
    • Create _ragdoll_root = Node2D.new(), name "RagdollBodyContainer".
    • Reparent to world space: add as child of get_parent() (the harness root, at world origin). Guard: if get_parent() == null, fall back to get_tree().current_scene. Bodies/joints are placed in world coords (their position == global_position under an origin parent). Do NOT parent the container under the rig — the rig carries RIG_SPAWN_POSITION offset.
    • For each entry in the body table (parent-before-child order):
      • Resolve the Bone2D via _skeleton.get_node_or_null (torso/limbs) or the Body/Head visual via get_node_or_null. Null → push_warning + skip that body (and any joints that reference it).
      • Compute origin/tip/midpoint/rotation:
        • limbs: origin=bone.global_position, tip=bone.to_global(Vector2(bone.length,0)).
        • torso: origin=Torso.global_position, tip=Head.global_position (length = that distance).
        • head: center=Body/Head.global_position (the circle center).
      • Build RigidBody2D (name "Ragdoll_" + key):
        • mass, linear_damp, angular_damp from table.
        • gravity_scale = 1.0, lock_rotation = false, freeze = false.
        • collision_layer = 1, collision_mask = 1 (defaults; explicit for clarity).
        • CollisionShape2D child: CapsuleShape2D(height=length, radius=r) for capsules, or CircleShape2D(radius=100) for head.
        • Position body at midpoint, rotation = bone global_rotation (capsules); head rotation irrelevant (circle).
        • _ragdoll_root.add_child(body); record in _ragdoll_bodies[key].
    • For each joint entry (skip if either body missing):
      • var pin := PinJoint2D.new(), name "RagdollPin_" + child_key.
      • pin.position = pin_point (world). Add to _ragdoll_root.
      • pin.node_a = pin.get_path_to(parent_body); pin.node_b = pin.get_path_to(child_body).
      • Apply limit band + softness. (Set node_a/node_b after adding to the tree so initial_angle captures the rest pose — bodies are already positioned, so the reference is correct.)
    • Momentum handoff: _ragdoll_bodies["torso"].linear_velocity = _cached_linear_velocity; .angular_velocity = _cached_angular_velocity (set after add_child).
  3. state = RigState.RAGDOLL; state_changed.emit(int(state)).

6.3 _track_momentum(delta)

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 Node2Das StickmanRig, store _rig = rig.
  3. Add a match arm in _handle_key:
    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:

:: 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.