Refactor animation generation: replace create_walk.gd with create_animations.gd
- Removed the old create_walk.gd script, which generated walk animations. - Introduced create_animations.gd to unify the generation of walk_left, walk_right, and stand_up animations. - Added a new SpinBox for rest timeout configuration in physics_test_harness.gd. - Enhanced StickmanRig to support automatic recovery from ragdoll state with configurable timeout. - Implemented recovery logic in StickmanRig, allowing for smooth transitions from ragdoll to animated state. - Updated animation generation logic to use new pose templates for standing and lying down positions.
This commit is contained in:
+274
-18
@@ -20,8 +20,10 @@ enum FacingProfile { LEFT, RIGHT, FORWARD }
|
||||
enum BendDirection { NORMAL, INVERTED }
|
||||
|
||||
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
|
||||
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll).
|
||||
enum RigState { ANIMATED, RAGDOLL }
|
||||
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll);
|
||||
## RECOVERING snaps the skeleton back to the captured rest pose and tweens the
|
||||
## IK targets to the standing pose before returning to ANIMATED.
|
||||
enum RigState { ANIMATED, RAGDOLL, RECOVERING }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -100,6 +102,46 @@ const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
|
||||
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
|
||||
const RAGDOLL_CIRCLE_SEGMENTS := 32
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blend / recovery constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Rest detection thresholds (linear px/s, angular rad/s) for the ragdoll
|
||||
## torso. 5.0 px/s (not the plan's 0.1) — a soft-pinned ragdoll micro-jitters
|
||||
## around ~0.5 px/s even when fully settled, so 0.1 is never reached. A body
|
||||
## that the physics engine has put to sleep also counts as at rest.
|
||||
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
|
||||
|
||||
## Extra hold after rest is detected before recovery captures the pose.
|
||||
const STABILIZATION_DELAY := 0.1
|
||||
|
||||
## Pin softness applied to every ragdoll joint at build time.
|
||||
const RAGDOLL_TARGET_SOFTNESS := 0.2
|
||||
|
||||
## IK-target standing positions (rig-local), matching master_rig.tscn defaults.
|
||||
const STAND_POSE: Dictionary = {
|
||||
"Torso": { "pos": Vector2(0, 10), "rot": 0.0 },
|
||||
"Head": { "pos": Vector2(100, -614), "rot": 0.0 },
|
||||
"Left_Hand": { "pos": Vector2(90, 110), "rot": 0.0 },
|
||||
"Right_Hand": { "pos": Vector2(-90, 110), "rot": 0.0 },
|
||||
"Left_Leg": { "pos": Vector2(-110, 380), "rot": 0.0 },
|
||||
"Right_Leg": { "pos": Vector2(110, 390), "rot": 0.0 },
|
||||
}
|
||||
|
||||
## IK-target marker node paths (rig-root relative), keyed by marker name.
|
||||
const IK_TARGET_PATHS: Dictionary = {
|
||||
"Torso": "IK_Targets/Torso",
|
||||
"Head": "IK_Targets/Head",
|
||||
"Left_Hand": "IK_Targets/Left_Hand",
|
||||
"Right_Hand": "IK_Targets/Right_Hand",
|
||||
"Left_Leg": "IK_Targets/Left_Leg",
|
||||
"Right_Leg": "IK_Targets/Right_Leg",
|
||||
}
|
||||
|
||||
## Ragdoll body definitions, ordered parent-before-child. `node_path` is
|
||||
## Skeleton2D-relative for bones and rig-root-relative for the head visual.
|
||||
## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
|
||||
@@ -166,6 +208,14 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [
|
||||
right_leg_bend = value
|
||||
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
|
||||
|
||||
@export_group("Ragdoll Transition")
|
||||
## How long the ragdoll torso must be at rest before auto-recovery (seconds).
|
||||
@export var rest_timeout: float = 2.0
|
||||
|
||||
## When true, a rested ragdoll automatically stands back up. When false, the
|
||||
## ragdoll stays down until request_recovery() is called manually.
|
||||
@export var auto_recover: bool = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -188,6 +238,7 @@ signal state_changed(new_state: int)
|
||||
var _nodes_ready: bool = false
|
||||
var _skeleton: Skeleton2D = null
|
||||
var _body_container: Node2D = null
|
||||
var _torso_bone: Bone2D = null
|
||||
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
|
||||
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
|
||||
|
||||
@@ -205,6 +256,15 @@ var _prev_global_rot: float = 0.0
|
||||
var _cached_linear_velocity: Vector2 = Vector2.ZERO
|
||||
var _cached_angular_velocity: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _rest_timer: float = 0.0
|
||||
var _stabilize_timer: float = 0.0
|
||||
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
|
||||
var _stand_up_tween: Tween = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -219,6 +279,11 @@ func _ready() -> void:
|
||||
if _body_container == null:
|
||||
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
|
||||
|
||||
if _skeleton != null:
|
||||
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||||
if _torso_bone == null:
|
||||
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
|
||||
|
||||
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
||||
if _anim_player == null:
|
||||
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
|
||||
@@ -246,6 +311,7 @@ func _ready() -> void:
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_track_momentum(delta)
|
||||
_update_rest_detection(delta)
|
||||
|
||||
|
||||
func _track_momentum(delta: float) -> void:
|
||||
@@ -255,6 +321,35 @@ func _track_momentum(delta: float) -> void:
|
||||
_prev_global_pos = global_position
|
||||
_prev_global_rot = global_rotation
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-frame rest detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## RAGDOLL rest detection: when the torso sits still long enough (and
|
||||
## auto_recover is on), trigger recovery after a short stabilization delay.
|
||||
func _update_rest_detection(delta: float) -> void:
|
||||
if state != RigState.RAGDOLL:
|
||||
return
|
||||
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||||
if torso == null or not is_instance_valid(torso):
|
||||
return
|
||||
var at_rest := torso.sleeping \
|
||||
or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD \
|
||||
and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
|
||||
if not at_rest:
|
||||
_rest_timer = 0.0
|
||||
_stabilize_timer = 0.0
|
||||
return
|
||||
if not auto_recover:
|
||||
_rest_timer = 0.0
|
||||
return
|
||||
_rest_timer += delta
|
||||
if _rest_timer < rest_timeout:
|
||||
return
|
||||
_stabilize_timer += delta
|
||||
if _stabilize_timer >= STABILIZATION_DELAY:
|
||||
_start_recovery()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -316,16 +411,31 @@ func is_in_ragdoll() -> bool:
|
||||
|
||||
|
||||
func set_ragdoll(enabled: bool) -> void:
|
||||
if enabled and not is_in_ragdoll():
|
||||
_enter_ragdoll()
|
||||
elif not enabled and is_in_ragdoll():
|
||||
_exit_ragdoll()
|
||||
if enabled:
|
||||
match state:
|
||||
RigState.RAGDOLL:
|
||||
return
|
||||
RigState.RECOVERING:
|
||||
_cancel_recovery()
|
||||
_enter_ragdoll()
|
||||
_:
|
||||
_enter_ragdoll()
|
||||
else:
|
||||
if state == RigState.RAGDOLL:
|
||||
_start_recovery()
|
||||
# else ANIMATED / RECOVERING: no-op
|
||||
|
||||
|
||||
func toggle_ragdoll() -> void:
|
||||
set_ragdoll(not is_in_ragdoll())
|
||||
|
||||
|
||||
## Public stand-up request. No-op unless the rig is in RAGDOLL.
|
||||
func request_recovery() -> void:
|
||||
if state == RigState.RAGDOLL:
|
||||
_start_recovery()
|
||||
|
||||
|
||||
## Applies the same velocity delta to every ragdoll body via a mass-scaled
|
||||
## central impulse, preserving the ragdoll's internal structure. No-op outside
|
||||
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
|
||||
@@ -431,26 +541,163 @@ func _enter_ragdoll() -> void:
|
||||
if _skeleton == null or _body_container == null:
|
||||
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
|
||||
return
|
||||
# Freeze the kinematic puppet: disable IK, stop animation, hide visuals.
|
||||
# Instant handoff: stop the player without resetting it (keep_state) and
|
||||
# build the ragdoll from the CURRENT solved bone positions while the IK
|
||||
# stack is still enabled (disabling it first would revert the bones to the
|
||||
# authored rest pose). Body/* is then hidden immediately and IK disabled —
|
||||
# no crossfade, because the ragdoll is spawned at exactly the same pose, so
|
||||
# a fade would only read as ghosting.
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop(true)
|
||||
_build_ragdoll()
|
||||
if is_instance_valid(_body_container):
|
||||
_body_container.visible = false
|
||||
_body_container.modulate.a = 1.0
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = false
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
_body_container.visible = false
|
||||
_build_ragdoll()
|
||||
state = RigState.RAGDOLL
|
||||
state_changed.emit(int(state))
|
||||
_rest_timer = 0.0
|
||||
_stabilize_timer = 0.0
|
||||
|
||||
|
||||
func _exit_ragdoll() -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recovery (ragdoll -> kinematic stand-up)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Captures every ragdoll body's global transform into rig-local space, plus
|
||||
## each capsule's half-length (from build-time metadata) so the snap can derive
|
||||
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
|
||||
func _capture_ragdoll_pose() -> void:
|
||||
_captured_pose.clear()
|
||||
for key: String in _ragdoll_bodies:
|
||||
var body := _ragdoll_bodies[key] as RigidBody2D
|
||||
if body == null or not is_instance_valid(body):
|
||||
continue
|
||||
_captured_pose[key] = {
|
||||
"pos": to_local(body.global_position),
|
||||
"rot": body.global_rotation - global_rotation,
|
||||
"half": float(body.get_meta("half_height", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
func _start_recovery() -> void:
|
||||
_capture_ragdoll_pose()
|
||||
_destroy_ragdoll()
|
||||
state = RigState.RECOVERING
|
||||
state_changed.emit(int(state))
|
||||
_snap_skeleton_to_pose()
|
||||
_play_stand_up()
|
||||
|
||||
|
||||
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
|
||||
## clean slate.
|
||||
func _cancel_recovery() -> void:
|
||||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||||
_stand_up_tween.kill()
|
||||
_stand_up_tween = null
|
||||
|
||||
|
||||
## 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
|
||||
## the captured end-effectors.
|
||||
##
|
||||
## Geometry notes: the ragdoll capsules span joint origin -> tip along their
|
||||
## +X (body.rotation IS the segment direction), so the real joints are at
|
||||
## center ± direction * half_height. The Torso marker rotation must also
|
||||
## subtract the Torso Bone2D's `bone_angle` (bone world angle = marker rotation
|
||||
## + bone_angle); using the body rotation directly would slam the whole
|
||||
## skeleton -90° and lay the figure flat.
|
||||
func _snap_skeleton_to_pose() -> void:
|
||||
var torso_marker := _get_ik_marker("Torso")
|
||||
if torso_marker != null:
|
||||
var torso_pose: Dictionary = _captured_pose.get("torso", {})
|
||||
if not torso_pose.is_empty():
|
||||
var spine_dir := Vector2.from_angle(torso_pose.get("rot", 0.0))
|
||||
var half := float(torso_pose.get("half", 0.0))
|
||||
var bone_angle_rad := 0.0
|
||||
if _torso_bone != null:
|
||||
bone_angle_rad = deg_to_rad(_torso_bone.bone_angle)
|
||||
# Hip = spine bottom end of the torso capsule.
|
||||
torso_marker.position = torso_pose.get("pos", torso_marker.position) - spine_dir * half
|
||||
torso_marker.rotation = torso_pose.get("rot", 0.0) - bone_angle_rad
|
||||
|
||||
var head_marker := _get_ik_marker("Head")
|
||||
if head_marker != null:
|
||||
var head_pose: Dictionary = _captured_pose.get("head", {})
|
||||
if not head_pose.is_empty():
|
||||
head_marker.position = head_pose.get("pos", head_marker.position)
|
||||
|
||||
_set_marker_from_body("Left_Hand", "left_lower_arm")
|
||||
_set_marker_from_body("Right_Hand", "right_lower_arm")
|
||||
_set_marker_from_body("Left_Leg", "left_lower_leg")
|
||||
_set_marker_from_body("Right_Leg", "right_lower_leg")
|
||||
|
||||
# Show the kinematic puppet first so it appears already in the captured
|
||||
# pose, then re-enable IK to solve toward the end-effector markers.
|
||||
if _body_container != null and is_instance_valid(_body_container):
|
||||
_body_container.visible = true
|
||||
if _skeleton != null and is_instance_valid(_skeleton):
|
||||
if _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = true
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
_body_container.modulate.a = 1.0
|
||||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||||
_skeleton.modification_stack.enabled = true
|
||||
|
||||
|
||||
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
return
|
||||
var pose: Dictionary = _captured_pose.get(body_key, {})
|
||||
if pose.is_empty():
|
||||
return
|
||||
# Far end (wrist / ankle) = body center + segment direction * half.
|
||||
var dir := Vector2.from_angle(pose.get("rot", 0.0))
|
||||
var half := float(pose.get("half", 0.0))
|
||||
marker.position = pose.get("pos", marker.position) + dir * half
|
||||
|
||||
|
||||
func _get_ik_marker(name: String) -> Marker2D:
|
||||
var path: String = IK_TARGET_PATHS.get(name, "")
|
||||
if path.is_empty():
|
||||
return null
|
||||
return get_node_or_null(NodePath(path)) as Marker2D
|
||||
|
||||
|
||||
## Stand-up: tweens the 6 IK markers from the captured pose to STAND_POSE
|
||||
## (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:
|
||||
_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)
|
||||
|
||||
|
||||
## Tweens the 6 IK markers from their current (captured) values to the target
|
||||
## pose over `duration` (sine ease-in-out), all in parallel.
|
||||
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
|
||||
var tween := create_tween()
|
||||
tween.set_parallel(true)
|
||||
tween.set_trans(Tween.TRANS_SINE)
|
||||
tween.set_ease(Tween.EASE_IN_OUT)
|
||||
for marker_name: String in target_pose:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
continue
|
||||
var target: Dictionary = target_pose[marker_name]
|
||||
tween.tween_property(marker, "position", target.get("pos", marker.position), duration)
|
||||
if marker_name == "Torso":
|
||||
tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration)
|
||||
return tween
|
||||
|
||||
|
||||
## Stand-up tween complete: settle into ANIMATED.
|
||||
func _on_stand_up_finished() -> void:
|
||||
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
|
||||
state = RigState.ANIMATED
|
||||
state_changed.emit(int(state))
|
||||
|
||||
@@ -507,6 +754,7 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
|
||||
body.add_child(shape)
|
||||
body.position = visual.global_position
|
||||
body.rotation = 0.0
|
||||
body.set_meta("half_height", 0.0)
|
||||
_add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR)
|
||||
else:
|
||||
var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D
|
||||
@@ -542,6 +790,9 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
|
||||
body.add_child(shape)
|
||||
body.position = midpoint
|
||||
body.rotation = (tip - origin).angle()
|
||||
# Half the capsule's length along the body's +X — lets recovery derive
|
||||
# the joint ends (hip/wrist/ankle) from the body center at capture time.
|
||||
body.set_meta("half_height", length * 0.5)
|
||||
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
|
||||
|
||||
_ragdoll_root.add_child(body)
|
||||
@@ -595,7 +846,7 @@ func _build_ragdoll_joint(entry: Dictionary) -> void:
|
||||
_ragdoll_root.add_child(pin)
|
||||
pin.node_a = pin.get_path_to(parent_body)
|
||||
pin.node_b = pin.get_path_to(child_body)
|
||||
pin.softness = 0.0
|
||||
pin.softness = RAGDOLL_TARGET_SOFTNESS
|
||||
_apply_ragdoll_joint_limits(pin, entry["limit"])
|
||||
|
||||
|
||||
@@ -623,6 +874,11 @@ func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void:
|
||||
|
||||
func _destroy_ragdoll() -> void:
|
||||
if _ragdoll_root != null and is_instance_valid(_ragdoll_root):
|
||||
# Retire the name immediately so a same-frame _build_ragdoll (e.g.
|
||||
# set_ragdoll(true) during RECOVERING) does not get its fresh container
|
||||
# auto-renamed by Godot's sibling-name de-duplication while the old one
|
||||
# is still awaiting its deferred queue_free().
|
||||
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME + "_retired"
|
||||
_ragdoll_root.queue_free()
|
||||
_ragdoll_root = null
|
||||
_ragdoll_bodies.clear()
|
||||
|
||||
Reference in New Issue
Block a user