Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions addons/kickback/physics_rig_builder.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions test/test_runtime_rig.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down