diff --git a/CHANGELOG.md b/CHANGELOG.md index d19bbf2..fea3ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ ## [Unreleased] ### Fixed +- **Rig bodies are now `top_level` (world-space)** — a character root that writes its + transform every physics frame (`CharacterBody3D.move_and_slide()`, nav-driven NPCs) + re-teleported every rig body to `parent_xform * local` each frame, silently discarding + that frame's physics integration. The visible result: the rig froze solid while the + character moved (no animation showing through, no visible hit reactions) and the + springs pumped clamp-level velocities into the locked bodies — which released all at + once the moment the root stopped moving (e.g. on death), as an explosive ragdoll. + Position springs already carry the rig along with the character (their targets move + with the skeleton), so parent transform inheritance was never load-bearing. Applies + to both runtime-built and baked (RigBaker) rigs. - **Hits no longer knock a PERSISTENT ragdoll back to RAGDOLL** — `apply_hit()` only guarded `State.RAGDOLL`, so a hit on a held-down body (a corpse, a knockdown) fell through to the stagger/ragdoll logic; the resulting RAGDOLL state's settle→recovery diff --git a/addons/kickback/physics_rig_builder.gd b/addons/kickback/physics_rig_builder.gd index cf7497e..9b6db7d 100644 --- a/addons/kickback/physics_rig_builder.gd +++ b/addons/kickback/physics_rig_builder.gd @@ -96,6 +96,11 @@ func _adopt_baked_rig() -> bool: push_warning("PhysicsRigBuilder: Baked rig is missing %d bones (%s) — falling back to runtime generation" % [missing.size(), ", ".join(missing)]) return false + # Baked rigs predate (or may have been saved without) the world-space + # flag — enforce it on adopt, same rationale as _create_body. + for body: RigidBody3D in baked_bodies.values(): + body.top_level = true + _bodies = baked_bodies _rig_to_bone = baked_bones return true @@ -118,6 +123,16 @@ func _create_body(bone_def: BoneDefinition, bone_global: Transform3D) -> RigidBo body.gravity_scale = _tuning.gravity_scale body.angular_damp = _tuning.angular_damp body.linear_damp = _tuning.linear_damp + # World-space, immune to ancestor transform writes. Without this, a + # character root that moves every physics frame (CharacterBody3D + # move_and_slide, nav-driven NPCs) re-teleports each body to + # parent_xform * local every frame, silently discarding that frame's + # integration: the rig visibly freezes while springs pump clamp-level + # velocities into it — which then release all at once the moment the + # root stops (death), as an explosive ragdoll. The springs are what + # carry the rig along with the character (their targets already move + # with the skeleton); parent inheritance was never load-bearing. + body.top_level = true # Shape is offset locally along the bone direction (toward child bone) var col_shape := SkeletonDetector.create_collision_shape(bone_def) diff --git a/test/test_runtime_rig.gd b/test/test_runtime_rig.gd index d1f6361..4e64902 100644 --- a/test/test_runtime_rig.gd +++ b/test/test_runtime_rig.gd @@ -99,6 +99,38 @@ func test_springs_hold_pose_against_gravity(): "hips stay upright — springs track the animation orientation") +func test_rig_survives_moving_character_root(): + # Regression: a character root that writes its transform every physics + # frame (CharacterBody3D.move_and_slide, nav-driven NPCs) used to + # re-teleport every rig body to parent_xform * local each frame, + # silently discarding that frame's integration — the rig froze solid + # while springs pumped clamp-level velocities into it, which released + # as an explosive ragdoll the moment the root stopped (e.g. on death). + # Bodies are top_level now, so ancestor writes must not touch them. + var h = await _spawn() + var leg: RigidBody3D = h.get_body("UpperLeg_L") + assert_true(leg.top_level, "rig bodies are top_level (world-space)") + + # Drive the root like a nav NPC (one transform write per physics frame) + # while commanding a constant angular velocity, as the spring does. The + # body must actually integrate it: pre-fix this measures ~0.000 rad. + var travelled := 0.0 + var prev := leg.global_basis.get_rotation_quaternion() + for i in 30: + h.global_position.x += 0.002 + leg.angular_velocity = Vector3(0, 0, 4.0) + await wait_physics_frames(1) + var q := leg.global_basis.get_rotation_quaternion() + travelled += q.angle_to(prev) + prev = q + # The active spring immediately lerps the commanded velocity back toward + # its own (near-zero-error) target, so only a fraction of it integrates — + # ~0.17 rad measured. The regression pins ~0.000 rad exactly, so any + # clearly-nonzero travel discriminates. + assert_gt(travelled, 0.05, + "body orientation integrates while the root moves every frame") + + # ── Hit handling (apply_hit) ──────────────────────────────────────────────── func test_apply_hit_reduces_bone_strength():