Add unique identifier for test_phase3c_walk_recovery.gd

This commit is contained in:
2026-09-06 13:09:37 -04:00
parent 1f91f3d2e5
commit 80595a0273
53 changed files with 6806 additions and 471 deletions
+158 -22
View File
@@ -120,7 +120,7 @@ const REST_LINEAR_THRESHOLD := 5.0
const REST_ANGULAR_THRESHOLD := 0.1
## Duration of the stand-up tween (captured pose -> STAND_POSE).
const STAND_UP_DURATION := 0.8
const STAND_UP_DURATION := 2.0
## Extra hold after rest is detected before recovery captures the pose.
const STABILIZATION_DELAY := 0.1
@@ -169,6 +169,9 @@ const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := false
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
if DEBUG_WALK:
@@ -290,6 +293,8 @@ var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _torso_bone: Bone2D = null
var _head_bone: Bone2D = null
var _head_look_at: SkeletonModification2DLookAt = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
@@ -314,6 +319,8 @@ var _cached_angular_velocity: float = 0.0
var _rest_timer: float = 0.0
var _stabilize_timer: float = 0.0
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
var _stand_up_tween: Tween = null
# ---------------------------------------------------------------------------
@@ -369,6 +376,9 @@ func _ready() -> void:
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
if _torso_bone == null:
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if _head_bone == null:
push_warning("StickmanRig: missing 'Torso/Head' bone in Skeleton2D.")
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
if _anim_player == null:
@@ -381,6 +391,7 @@ func _ready() -> void:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
pass
else:
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
@@ -421,6 +432,25 @@ func _physics_process(delta: float) -> void:
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
_pin_mirrored_head_rotation()
## Re-asserts the head bone's FORWARD canonical aim rotation each frame while
## facing LEFT with the kinematic puppet live (ANIMATED or RECOVERING). The
## LookAt modification is disabled in that state (_apply_head_lookat_mirror_mode)
## so nothing else rewrites the bone; the per-frame pin covers recovery, where
## the IK stack is re-enabled mid-tween.
func _pin_mirrored_head_rotation() -> void:
if facing_profile != FacingProfile.LEFT or state == RigState.RAGDOLL:
return
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
if _head_look_at.enabled:
_head_look_at.enabled = false
if _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = 0
func _track_momentum(delta: float) -> void:
@@ -562,6 +592,7 @@ func snap_to_standing() -> void:
# Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
@@ -613,8 +644,12 @@ func _resolve_bend_modifications() -> void:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack == null:
return
_head_look_at = null
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if mod is SkeletonModification2DLookAt:
_head_look_at = mod as SkeletonModification2DLookAt
continue
if not (mod is SkeletonModification2DTwoBoneIK):
continue
var ik := mod as SkeletonModification2DTwoBoneIK
@@ -638,21 +673,39 @@ func _apply_profile() -> void:
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
_apply_body_z_order()
_apply_head_flip()
# Whole-rig Y-axis mirror (spec §9a): facing LEFT mirrors the entire figure
# -- head + body + IK targets + mounted Body/* geometry -- so the head AND
# body face the correct direction together. RIGHT/FORWARD keep an identity
# scale. An X-mirror does not affect depth (draw order), so
# Z_ORDER_BY_PROFILE is unchanged.
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
_apply_head_lookat_mirror_mode()
facing_profile_changed.emit(int(facing_profile))
func _apply_head_flip() -> void:
var head := get_node_or_null("Skeleton2D/Torso/Head") as Node2D
var pivot := get_node_or_null("Skeleton2D/Torso/Head/Pivot") as Node2D
if pivot != null:
var is_left := (facing_profile == FacingProfile.LEFT)
if is_left:
# Mirror local X and invert double the bone's rotation to mirror in world space
pivot.scale = Vector2(-1.0, 1.0)
pivot.rotation = -1.0 * head.rotation
else:
pivot.scale = Vector2(1.0, 1.0)
pivot.rotation = 0.0
## The SkeletonModification2DLookAt that aims the Head bone is NOT
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
## root's negative X scale reflects the head-bone frame, and the LookAt writes a
## bone rotation 180 degrees off the FORWARD aim (PI -> 0 headless). Because the
## mounted head geometry is offset from Body/Head's local origin (the chin sits
## at the neck and the head extends away from it), that 180-degree error flips
## the head to hang BELOW the neck instead of sitting above it -- the whole-rig
## mirror then displaces the head ~2x its mount offset relative to the torso.
## Fix: when facing LEFT, disable the LookAt and pin the head bone to the
## FORWARD canonical aim rotation (PI), so the head is a rigid mirror of the
## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted
## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING)
## because re-enabling the IK stack during recovery would otherwise let the
## LookAt flip the bone again.
func _apply_head_lookat_mirror_mode() -> void:
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
var mirrored := facing_profile == FacingProfile.LEFT
_head_look_at.enabled = false#not mirrored
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = PI
elif not mirrored:
_head_bone.rotation = 0
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
@@ -717,6 +770,8 @@ func _enter_ragdoll() -> void:
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
func _capture_ragdoll_pose() -> void:
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
for key: String in _ragdoll_bodies:
var body := _ragdoll_bodies[key] as RigidBody2D
if body == null or not is_instance_valid(body):
@@ -726,17 +781,46 @@ func _capture_ragdoll_pose() -> void:
"rot": body.global_rotation - global_rotation,
"half": float(body.get_meta("half_height", 0.0)),
}
# Record the ragdoll torso's world center + its ground-contact line so
# recovery can re-anchor the root's feet onto wherever the ragdoll landed.
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position
_captured_ground_y = torso.global_position.y + RAGDOLL_TORSO_RADIUS
func _start_recovery() -> void:
if state != RigState.RAGDOLL:
return
_capture_ragdoll_pose()
_destroy_ragdoll()
_reanchor_root_to_landing()
state = RigState.RECOVERING
state_changed.emit(int(state))
_snap_skeleton_to_pose()
_play_stand_up()
## Re-anchors the rig root so the standing figure's feet sit on the ground at
## the ragdoll's landing X. The ragdoll bodies live under a world sibling (not
## the rig), so the rig root never moved while it fell; without this the
## stand-up tween would drag the figure back to the root's pre-ragdoll world
## position.
func _reanchor_root_to_landing() -> void:
if not _captured_pose.has("torso"):
return
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
var old_root := global_position
var new_root := feet_world + FOOT_OFFSET
global_position = new_root
# Re-base the captured (rig-local) pose onto the re-anchored root so the
# snap reproduces the ragdoll's world pose, not the pre-ragdoll one.
var shift := old_root - new_root
for key: String in _captured_pose:
var entry: Dictionary = _captured_pose[key]
entry["pos"] = entry.get("pos", Vector2.ZERO) + shift
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
## clean slate.
func _cancel_recovery() -> void:
@@ -745,6 +829,59 @@ func _cancel_recovery() -> void:
_stand_up_tween = null
## Re-enables the Skeleton2D modification stack after RAGDOLL and forces it to
## resume solving. A disabled->enabled toggle alone can leave the stack's
## internal solve state stale (the classic Godot disable->enable quirk), so we
## explicitly re-setup the stack when it reports !is_setup, ensure Skeleton2D
## processes internally (required for the stack to execute), and re-assert the
## Torso marker's RemoteTransform2D update flags so the hip bone keeps
## following IK_Targets/Torso through the stand-up tween.
func _rearm_ik_stack() -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
if not stack.get_is_setup():
stack.setup()
stack.enabled = true
_skeleton.set_process_internal(true)
# Torso marker -> Torso bone driver: ensure it forwards position/rotation.
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
if driver != null:
driver.update_position = true
driver.update_rotation = true
driver.update_scale = true
_recovery_dbg()
## Logs the IK stack + bone-following state during recovery (off by default).
func _recovery_dbg() -> void:
if not DEBUG_RECOVERY:
return
var stack: SkeletonModificationStack2D = null
var stack_enabled := false
var stack_setup := false
var internal := false
if _skeleton != null and is_instance_valid(_skeleton):
stack = _skeleton.modification_stack
stack_enabled = stack != null and stack.enabled
stack_setup = stack != null and stack.get_is_setup()
internal = _skeleton.is_processing_internal()
var torso_bone_pos := Vector2.ZERO
if _torso_bone != null and is_instance_valid(_torso_bone):
torso_bone_pos = _torso_bone.global_position
var torso_marker := _get_ik_marker("Torso")
var torso_marker_pos := torso_marker.global_position if torso_marker != null else Vector2.ZERO
var limb := _bend_joint_bones.get("LeftLeg") as Bone2D
var limb_pos := limb.global_position if limb != null and is_instance_valid(limb) else Vector2.ZERO
print("[recovery] stack.enabled=%s is_setup=%s internal=%s torso_bone=%s torso_marker=%s left_lower_leg=%s" % [
str(stack_enabled), str(stack_setup), str(internal),
str(torso_bone_pos), str(torso_marker_pos), str(limb_pos),
])
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
## markers (NOT the Torso Bone2D, which is slaved to its marker via
## RemoteTransform2D), then re-enables IK so TwoBoneIK solves the limbs toward
@@ -786,8 +923,7 @@ func _snap_skeleton_to_pose() -> void:
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
_rearm_ik_stack()
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
@@ -814,7 +950,7 @@ func _get_ik_marker(name: String) -> Marker2D:
## (sine ease-in-out). No baked animation — a fixed first keyframe can never
## match an arbitrary ragdoll rest pose, so the tween starts from wherever the
## snap left the markers.
func _play_stand_up() -> void:
func _play_stand_up() -> void:
_stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION)
if _stand_up_tween != null:
_stand_up_tween.finished.connect(_on_stand_up_finished)
@@ -842,6 +978,7 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
func _on_stand_up_finished() -> void:
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
pass
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
@@ -1052,15 +1189,14 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
])
var dx := target.x - global_position.x
var anim_name: String
if dx < -0.5:
set_facing_profile(FacingProfile.LEFT)
anim_name = "walk_left"
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
anim_name = "walk_right"
else:
anim_name = "walk_right"
# Always play the canonical walk_right clip. For LEFT the rig root is
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
# walk_left is no longer used at runtime.
var anim_name := "walk_right"
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
_anim_player.play(anim_name)
_walking = true