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
+182 -15
View File
@@ -81,6 +81,24 @@ var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D var _weapon_attachment: BoneAttachment3D
var is_holding_weapon: bool = false 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 # Procedural shooter pose layer (lean / slide / weapon hold), applied on top of
# the base clip by a SkeletonModifier3D so it composes with the animation. # the base clip by a SkeletonModifier3D so it composes with the animation.
var _pose_mod: ShooterPoseModifier var _pose_mod: ShooterPoseModifier
@@ -135,6 +153,7 @@ func load_model(path: String) -> void:
LevelMaterials.apply_toon_recursive(scene) LevelMaterials.apply_toon_recursive(scene)
if animation_player: if animation_player:
_index_animations() _index_animations()
_setup_anim_tree(scene)
else: else:
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path) 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 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: func _find_clip(available: PackedStringArray, wanted: String) -> String:
for name in available: for name in available:
if name == wanted: 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 var _dancing: bool = false
## Play a one-shot clip (Hit reaction, Land, ...) over locomotion for ## Play a one-shot clip over locomotion for `lock_time` seconds.
## `lock_time` seconds; locomotion resumes afterwards. ## 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: func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
if not loaded or not _resolved_clips.has(canonical): if not loaded or not _resolved_clips.has(canonical):
return return
if canonical in FULL_BODY_ONESHOTS or not _anim_tree:
_oneshot_lock = lock_time _oneshot_lock = lock_time
_play_clip(canonical, true) _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 ## 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": "wall_cling", "wall_climb":
clip = "WallCling" clip = "WallCling"
"grapple": "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": "dash":
clip = "Dash" clip = "Dash"
"death": "death":
@@ -310,13 +403,14 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_pose_mod.weapon_held = is_holding_weapon _pose_mod.weapon_held = is_holding_weapon
# Scale locomotion playback so feet keep up with actual movement speed. # Scale locomotion playback so feet keep up with actual movement speed.
if _anim_tree:
var s := 1.0
match clip: match clip:
"Walk", "CrouchWalk": "Walk", "CrouchWalk":
animation_player.speed_scale = clampf(speed / walk_anim_reference_speed, 0.7, 1.6) s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
"Run", "Sprint", "WallRun": "Run", "Sprint", "WallRun":
animation_player.speed_scale = clampf(speed / run_anim_reference_speed, 0.7, 1.8) s = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
_: _anim_tree.set("parameters/loco_scale/scale", s)
animation_player.speed_scale = 1.0
## Drives the procedural pose layer. Called by the controller each frame. ## 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) _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: func _process(delta: float) -> void:
if not _pose_mod: if not _pose_mod:
return return
@@ -352,11 +453,29 @@ func _process(delta: float) -> void:
_pose_mod.slide = _cur_slide _pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall _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 # 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 # a one-shot clip (reload/throw/hit) or a full-body moment (Land lock,
# (death, dance) needs the authored animation to read through. # 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 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_r := 0.0
var hold_l := 0.0 var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms: 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: 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 return
var clip_name: String = _resolved_clips[canonical] 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 return
if restart: _loco_trans.xfade_time = BLEND_TIMES.get(canonical, BLEND_TIME)
animation_player.stop() _anim_tree.set("parameters/loco/transition_request", clip_name)
animation_player.play(clip_name, BLEND_TIMES.get(canonical, BLEND_TIME))
_current_clip = clip_name _current_clip = clip_name
@@ -501,6 +619,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var recoil: float = 0.0 # decaying shot kick var recoil: float = 0.0 # decaying shot kick
var state: String = "idle" var state: String = "idle"
var weapon_held: bool = false 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). # Per-arm rifle-hold weights (0 = clip owns the arm, 1 = hold pose owns it).
var hold_r_target: float = 0.0 var hold_r_target: float = 0.0
var hold_l_target: float = 0.0 var hold_l_target: float = 0.0
@@ -557,6 +678,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_slide(skel) _apply_slide(skel)
if absf(wall) > 0.01: if absf(wall) > 0.01:
_apply_wall_lean(skel) _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: if _hold_r > 0.01 or _hold_l > 0.01:
_apply_rifle_hold(skel) _apply_rifle_hold(skel)
if recoil > 0.01: if recoil > 0.01:
@@ -599,6 +722,50 @@ class ShooterPoseModifier extends SkeletonModifier3D:
for n in SPINE: for n in SPINE:
_add_space(skel, _idx.get(n, -1), per) _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. # Wall run: roll into the wall, drive forward, inner arm reaches the wall.
func _apply_wall_lean(skel: Skeleton3D) -> void: func _apply_wall_lean(skel: Skeleton3D) -> void:
var q := Quaternion(Vector3(0, 0, 1), wall * 0.35) \ var q := Quaternion(Vector3(0, 0, 1), wall * 0.35) \
+39 -1
View File
@@ -68,9 +68,12 @@ func _process(_delta: float) -> bool:
if not _model: if not _model:
printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin) printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin)
return true return true
# Freeze gameplay driving so we control the animation state directly. # Freeze gameplay driving so we control the animation state directly,
# and teleport to open ground so walls never block the shot.
_player.set_physics_process(false) _player.set_physics_process(false)
_player.set_process(false) _player.set_process(false)
_player.global_position = Vector3(0, 1.2, 14)
_player.rotation = Vector3.ZERO
_model.set_owner_visible(true) _model.set_owner_visible(true)
# Give the model a weapon so armed poses read. # Give the model a weapon so armed poses read.
if _model.has_method("set_weapon"): if _model.has_method("set_weapon"):
@@ -137,6 +140,41 @@ func _process(_delta: float) -> bool:
elif _phase_frame >= 50: elif _phase_frame >= 50:
_snap("anim_ads_s") _snap("anim_ads_s")
_debug_gun() _debug_gun()
_phase_frame = 0
_mode = "combo"
return false
# Mechanic-fidelity combos: reload WHILE sliding (legs must keep sliding),
# then the grapple zip pose toward an actual anchor point.
if _mode == "combo":
if _model.has_method("set_locomotion"):
_model.set_locomotion(0.0, 0.0, 0.0)
_model.update_state("slide", 10.0, true)
if _phase_frame == 10:
_model.play_action("reload")
_phase_frame += 1
if _phase_frame == 40:
_snap("anim_slide_reload_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 42:
_snap("anim_slide_reload_s")
_phase_frame = 0
_mode = "grapple_zip"
return false
if _mode == "grapple_zip":
if _phase_frame == 0 and _model._anim_tree:
# End the reload one-shot so the zip pose is unpolluted.
_model._anim_tree.set("parameters/upper/request",
AnimationNodeOneShot.ONE_SHOT_REQUEST_ABORT)
_model.update_state("grapple", 12.0, false)
if _model.has_method("set_grapple_target"):
# Anchor up and ahead of the player (player faces -Z world).
_model.set_grapple_target(_player.global_position + Vector3(1.5, 7.0, -7.0))
_phase_frame += 1
if _phase_frame == 48:
_snap("anim_grapple_zip_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 50:
_snap("anim_grapple_zip_s")
return true return true
return false return false
return true return true
+3 -1
View File
@@ -88,7 +88,9 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
bound = true bound = true
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)") _check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
if skinned.animation_player: if skinned.animation_player:
_check(skinned.animation_player.is_playing(), "animation playing") # The AnimationTree drives playback now; the raw player is idle.
_check(skinned._anim_tree != null and skinned._anim_tree.active,
"animation tree active")
print(" clips resolved: ", skinned._resolved_clips) print(" clips resolved: ", skinned._resolved_clips)
# Drive some movement states through the same API the game uses. # Drive some movement states through the same API the game uses.
for state in ["ground", "air", "slide", "wall_run", "dash"]: for state in ["ground", "air", "slide", "wall_run", "dash"]:
+4
View File
@@ -924,6 +924,8 @@ func _physics_process(_delta: float) -> void:
visual.set_wall_side(sm.wall_side) visual.set_wall_side(sm.wall_side)
if visual.has_method("set_dancing"): if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing) visual.set_dancing(synced_is_dancing)
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
# Publish state for remote peers # Publish state for remote peers
synced_movement_state = sm.current_state synced_movement_state = sm.current_state
@@ -1007,6 +1009,8 @@ func _process(delta: float) -> void:
visual.set_wall_side(synced_wall_side) visual.set_wall_side(synced_wall_side)
if visual.has_method("set_dancing"): if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing) visual.set_dancing(synced_is_dancing)
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
# Upper body follows the owner's synced camera pitch # Upper body follows the owner's synced camera pitch
if visual.has_method("set_aim_pitch") and head_pivot: if visual.has_method("set_aim_pitch") and head_pivot:
visual.set_aim_pitch(head_pivot.rotation.x) visual.set_aim_pitch(head_pivot.rotation.x)