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
+39 -1
View File
@@ -68,9 +68,12 @@ func _process(_delta: float) -> bool:
if not _model:
printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin)
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_process(false)
_player.global_position = Vector3(0, 1.2, 14)
_player.rotation = Vector3.ZERO
_model.set_owner_visible(true)
# Give the model a weapon so armed poses read.
if _model.has_method("set_weapon"):
@@ -137,6 +140,41 @@ func _process(_delta: float) -> bool:
elif _phase_frame >= 50:
_snap("anim_ads_s")
_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 false
return true
+3 -1
View File
@@ -88,7 +88,9 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
bound = true
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
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)
# Drive some movement states through the same API the game uses.
for state in ["ground", "air", "slide", "wall_run", "dash"]: