feat: animations follow the mechanics — upper/lower split + real grapple pose

Two fidelity gaps closed:

- Reload/throw/shoot/hit used to REPLACE the whole-body clip, so reloading
  mid-slide snapped the character upright on standing legs. The model now
  runs a runtime AnimationTree (clips -> Transition -> TimeScale -> OneShot)
  whose one-shot layer is FILTERED to upper-body bones: the arms play the
  action while the legs keep sliding/running/falling. Land stays full-body.
  The rifle hold releases exactly while the one-shot node reports active.
- Grapple no longer plays the library's horizontal-swim clip. The zip pose
  is procedural: Fall as the airborne base, the spine pivots to fly along
  the line to the actual anchor point (capped so overhead shots don't fold
  the body), the head sights the anchor, legs trail behind, the FREE left
  hand reaches up the rope — and the right hand keeps holding the rifle.
  The anchor point feeds in from the controller for local and remote
  players (synced_grapple_point).
- anim_capture: deterministic open-ground teleport + two new regression
  shots (reload-while-sliding, grapple zip toward a real anchor).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-20 22:51:04 -04:00
co-authored by Claude Fable 5
parent 87b8ae70df
commit 1ad3563e5a
4 changed files with 233 additions and 22 deletions
+187 -20
View File
@@ -81,6 +81,24 @@ var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D
var is_holding_weapon: bool = false
# Animation blending: locomotion plays full-body through a Transition node;
# gameplay one-shots (reload/throw/shoot/hit) play through an
# AnimationNodeOneShot FILTERED to upper-body bones, so the legs keep
# sliding/running underneath instead of popping to the one-shot's standing legs.
var _anim_tree: AnimationTree
var _loco_trans: AnimationNodeTransition
var _upper_anim: AnimationNodeAnimation
var _upper_lock: float = 0.0 # seconds the one-shot owns the ARMS
## Bone-name fragments that belong to the upper-body one-shot layer.
const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb",
"f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003",
"neck", "head"]
# Grapple: world-space anchor the hook is attached to (drives the procedural
# zip pose — body aligned to the line, free arm reaching for the point).
var _grapple_point_world: Vector3 = Vector3.ZERO
var _cur_grapple: float = 0.0
# Procedural shooter pose layer (lean / slide / weapon hold), applied on top of
# the base clip by a SkeletonModifier3D so it composes with the animation.
var _pose_mod: ShooterPoseModifier
@@ -135,6 +153,7 @@ func load_model(path: String) -> void:
LevelMaterials.apply_toon_recursive(scene)
if animation_player:
_index_animations()
_setup_anim_tree(scene)
else:
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
@@ -178,6 +197,67 @@ func _index_animations() -> void:
anim.loop_mode = Animation.LOOP_LINEAR
## Runtime blend tree:
## clips -> loco Transition -> TimeScale -> OneShot(upper filter) -> output
## The OneShot's filter holds every upper-body track, so reload/throw/hit
## replace arms+chest only while the locomotion clip keeps owning the legs.
func _setup_anim_tree(scene: Node) -> void:
var bt := AnimationNodeBlendTree.new()
_loco_trans = AnimationNodeTransition.new()
_loco_trans.xfade_time = BLEND_TIME
_loco_trans.allow_transition_to_self = true # lets Land restart itself
bt.add_node("loco", _loco_trans, Vector2(-200, 0))
# One input per unique clip, named by the clip so transition_request works.
var seen := {}
var idx := 0
for canonical in _resolved_clips:
var clip_name: String = _resolved_clips[canonical]
if seen.has(clip_name):
continue
seen[clip_name] = true
var an := AnimationNodeAnimation.new()
an.animation = clip_name
var node_id := "clip_%d" % idx
bt.add_node(node_id, an, Vector2(-500, idx * 60))
_loco_trans.add_input(clip_name)
bt.connect_node("loco", idx, node_id)
idx += 1
var ts := AnimationNodeTimeScale.new()
bt.add_node("loco_scale", ts, Vector2(0, 0))
bt.connect_node("loco_scale", 0, "loco")
var upper := AnimationNodeOneShot.new()
upper.fadein_time = 0.08
upper.fadeout_time = 0.15
upper.filter_enabled = true
_upper_anim = AnimationNodeAnimation.new()
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240))
bt.add_node("upper", upper, Vector2(220, 0))
bt.connect_node("upper", 0, "loco_scale")
bt.connect_node("upper", 1, "upper_clip")
bt.connect_node("output", 0, "upper")
# Filter = every track whose bone is upper-body. Track paths are identical
# across the library's clips, so sample any one of them.
var sample: Animation = animation_player.get_animation(_resolved_clips.values()[0])
for t in sample.get_track_count():
var p := sample.track_get_path(t)
var bone := String(p.get_concatenated_subnames())
for hint in UPPER_BONE_HINTS:
if bone.findn(hint) != -1:
upper.set_filter_path(p, true)
break
_anim_tree = AnimationTree.new()
_anim_tree.name = "AnimTree"
_anim_tree.tree_root = bt
scene.add_child(_anim_tree)
_anim_tree.anim_player = _anim_tree.get_path_to(animation_player)
_anim_tree.active = true
func _find_clip(available: PackedStringArray, wanted: String) -> String:
for name in available:
if name == wanted:
@@ -218,13 +298,23 @@ var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
var _dancing: bool = false
## Play a one-shot clip (Hit reaction, Land, ...) over locomotion for
## `lock_time` seconds; locomotion resumes afterwards.
## Play a one-shot clip over locomotion for `lock_time` seconds.
## Whole-body moments (Land) briefly own the full skeleton; everything else
## (reload/throw/shoot/hit) plays on the UPPER BODY ONLY through the filtered
## OneShot node, so the legs keep doing whatever the movement state says —
## you can reload mid-slide and stay sliding.
const FULL_BODY_ONESHOTS := ["Land"]
func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
if not loaded or not _resolved_clips.has(canonical):
return
_oneshot_lock = lock_time
_play_clip(canonical, true)
if canonical in FULL_BODY_ONESHOTS or not _anim_tree:
_oneshot_lock = lock_time
_play_clip(canonical, true)
return
_upper_lock = lock_time
_upper_anim.animation = _resolved_clips[canonical]
_anim_tree.set("parameters/upper/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
## Emote toggle (Dance). Shown while grounded and near-idle; any real
@@ -297,7 +387,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
"wall_cling", "wall_climb":
clip = "WallCling"
"grapple":
clip = "Grapple"
# No canned clip — the zip pose is procedural (body aligned to the
# line, free arm reaching). Fall gives the legs a natural airborne
# base instead of the library's horizontal swim.
clip = "Fall"
"dash":
clip = "Dash"
"death":
@@ -310,13 +403,14 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_pose_mod.weapon_held = is_holding_weapon
# Scale locomotion playback so feet keep up with actual movement speed.
match clip:
"Walk", "CrouchWalk":
animation_player.speed_scale = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
"Run", "Sprint", "WallRun":
animation_player.speed_scale = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
_:
animation_player.speed_scale = 1.0
if _anim_tree:
var s := 1.0
match clip:
"Walk", "CrouchWalk":
s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
"Run", "Sprint", "WallRun":
s = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
_anim_tree.set("parameters/loco_scale/scale", s)
## Drives the procedural pose layer. Called by the controller each frame.
@@ -335,6 +429,13 @@ func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0)
## World-space point the grapple hook is anchored to. While the movement state
## is "grapple" the pose layer aligns the body along the line to this point
## and reaches the free hand toward it.
func set_grapple_target(point_world: Vector3) -> void:
_grapple_point_world = point_world
func _process(delta: float) -> void:
if not _pose_mod:
return
@@ -352,11 +453,29 @@ func _process(delta: float) -> void:
_pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall
# Grapple zip pose: blend in while grappling; feed the modifier the
# direction to the anchor in skeleton space.
if _upper_lock > 0.0:
_upper_lock -= delta
var grapple_target := 1.0 if _pose_mod.state == "grapple" else 0.0
_cur_grapple = lerpf(_cur_grapple, grapple_target, t)
_pose_mod.grapple = _cur_grapple
if _cur_grapple > 0.01 and skeleton and _grapple_point_world != Vector3.ZERO:
var chest := skeleton.global_transform.origin + Vector3.UP * 1.2
var dir_world := _grapple_point_world - chest
if dir_world.length_squared() > 0.01:
_pose_mod.grapple_dir = \
(skeleton.global_transform.basis.inverse() * dir_world).normalized()
# Two-hand rifle hold: owns the arms whenever a weapon is held, EXCEPT when
# a one-shot clip (reload/throw/hit), an emote, or a state whose arms matter
# (death, dance) needs the authored animation to read through.
# a one-shot clip (reload/throw/hit) or a full-body moment (Land lock,
# dance, death) needs the authored animation to read through. The upper
# one-shot's own `active` flag is the truth for how long it owns the arms.
var st: String = _pose_mod.state
var clip_owns_arms := _oneshot_lock > 0.0 or _dancing or st == "death"
var upper_active: bool = _anim_tree != null \
and bool(_anim_tree.get("parameters/upper/active"))
var clip_owns_arms := _oneshot_lock > 0.0 or _upper_lock > 0.0 \
or upper_active or _dancing or st == "death"
var hold_r := 0.0
var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms:
@@ -378,14 +497,13 @@ func _process(delta: float) -> void:
func _play_clip(canonical: String, restart: bool = false) -> void:
if not animation_player or not _resolved_clips.has(canonical):
if not _anim_tree or not _resolved_clips.has(canonical):
return
var clip_name: String = _resolved_clips[canonical]
if not restart and _current_clip == clip_name and animation_player.is_playing():
if not restart and _current_clip == clip_name:
return
if restart:
animation_player.stop()
animation_player.play(clip_name, BLEND_TIMES.get(canonical, BLEND_TIME))
_loco_trans.xfade_time = BLEND_TIMES.get(canonical, BLEND_TIME)
_anim_tree.set("parameters/loco/transition_request", clip_name)
_current_clip = clip_name
@@ -501,6 +619,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var recoil: float = 0.0 # decaying shot kick
var state: String = "idle"
var weapon_held: bool = false
# Grapple zip: 0..1 blend + skeleton-space direction to the anchor point.
var grapple: float = 0.0
var grapple_dir: Vector3 = Vector3.ZERO
# Per-arm rifle-hold weights (0 = clip owns the arm, 1 = hold pose owns it).
var hold_r_target: float = 0.0
var hold_l_target: float = 0.0
@@ -557,6 +678,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_slide(skel)
if absf(wall) > 0.01:
_apply_wall_lean(skel)
if grapple > 0.01 and grapple_dir != Vector3.ZERO:
_apply_grapple(skel)
if _hold_r > 0.01 or _hold_l > 0.01:
_apply_rifle_hold(skel)
if recoil > 0.01:
@@ -599,6 +722,50 @@ class ShooterPoseModifier extends SkeletonModifier3D:
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
# Grapple zip: the whole body pivots to fly along the line to the anchor,
# legs trail behind, and the FREE (left) hand reaches up the rope — the
# right hand keeps the rifle (the hold layer runs after this and owns it).
func _apply_grapple(skel: Skeleton3D) -> void:
var d := grapple_dir.normalized()
var fwd := Vector3(0, 0, 1)
if d.dot(fwd) > 0.999:
d = (d + Vector3(0.001, 0.001, 0)).normalized()
var arc := Quaternion(fwd, d)
# Align a good chunk of the body with the line, capped so a grapple
# point straight overhead doesn't fold the character in half.
var frac := 0.65
var ang := arc.get_angle()
if ang * frac > 1.15:
frac = 1.15 / ang
var per := Quaternion.IDENTITY.slerp(arc, grapple * frac / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
# Head keeps sighting the anchor point.
var head_q := Quaternion.IDENTITY.slerp(arc, grapple * 0.18)
_add_space(skel, _idx.get("DEF-neck", -1), head_q)
_add_space(skel, _idx.get("DEF-head", -1), head_q)
# Legs trail behind the flight line (Superman zip, not a swim).
var trail := Quaternion(Vector3(1, 0, 0), 0.45 * grapple)
_add_space(skel, _idx.get("DEF-thigh.R", -1), trail)
_add_space(skel, _idx.get("DEF-thigh.L", -1),
Quaternion(Vector3(1, 0, 0), 0.32 * grapple))
var straighten := Quaternion(Vector3(1, 0, 0), -0.25 * grapple)
_add_space(skel, _idx.get("DEF-shin.R", -1), straighten)
_add_space(skel, _idx.get("DEF-shin.L", -1), straighten)
# Free arm reaches along the rope toward the anchor.
var ua_dir := (d + Vector3(0.28, 0.1, 0.0)).normalized()
var g_fa := _aim_chain(skel, "DEF-upper_arm.L", "DEF-forearm.L",
ua_dir, d, -0.2, grapple)
var hand_l: int = _idx.get("DEF-hand.L", -1)
var fa_l: int = _idx.get("DEF-forearm.L", -1)
if hand_l >= 0 and fa_l >= 0 and g_fa != Quaternion.IDENTITY:
var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
var fa_o := skel.get_bone_global_rest(fa_l).origin
var hand_o := skel.get_bone_global_rest(hand_l).origin
var fa_rest_dir := (hand_o - fa_o).normalized()
var g_hand := Quaternion(d, 0.4) * Quaternion(fa_rest_dir, d) * hand_rest_q
_blend_local(skel, hand_l, g_fa.inverse() * g_hand, grapple)
# Wall run: roll into the wall, drive forward, inner arm reaches the wall.
func _apply_wall_lean(skel: Skeleton3D) -> void:
var q := Quaternion(Vector3(0, 0, 1), wall * 0.35) \