feat: real two-hand rifle hold — armed animations finally read as a shooter

The clips were playing all along, but every locomotion state used unarmed
jog arms while the gun floated at the hip, so nothing looked animated.

- ShooterPoseModifier now REPLACES the arm chain with a direction-based FK
  rifle hold (low-ready at hip, shouldered on ADS) instead of nudging the
  unarmed clip additively; the wrist is solved so the muzzle points exactly
  along the aim line, gun kept upright (no-roll constraint).
- Per-arm release logic: reload/throw/hit one-shots, dance, death, the
  slide ground-brace, wall-run reach and grapple free the authored clips.
- Third-person weapon: fixed re-hide on weapon swap while in third person,
  scaled to character proportions, gripped along the hand.
- Armed idle uses Idle (arms owned by the hold) instead of arms-crossed
  PistolIdle base.
- FP viewmodel arms: character-styled sleeve + teal cuff + skin hand
  instead of the blue slabs.
- debug/anim_capture.gd: front+side screenshots of every movement state,
  one-shot action and ADS for visual regression of the model pipeline.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-19 17:55:05 -04:00
co-authored by Claude Fable 5
parent 39af109893
commit 6c227b5538
3 changed files with 409 additions and 72 deletions
+168
View File
@@ -0,0 +1,168 @@
extends SceneTree
## Dev tool: screenshot every animation state on the active GLB skin.
## Run:
## godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> [skin_id]
## Saves anim_<state>.png per state plus mid-action shots for reload/throw.
var _frames := 0
var _out_dir := "."
var _skin := "miku"
var _player: Node = null
var _model: Node = null
var _cam: Camera3D = null
# [tag, state, speed, crouch, hold_frames]
var _states := [
["idle", "idle", 0.0, false, 50],
["walk", "ground", 2.2, false, 50],
["run", "ground", 6.5, false, 50],
["sprint", "ground", 12.0, false, 50],
["crouch", "ground", 0.0, true, 50],
["crouchwalk", "ground", 2.0, true, 50],
["fall", "air", 4.0, false, 50],
["slide", "slide", 10.0, true, 50],
["dash", "dash", 14.0, false, 30],
["wallrun", "wall_run", 9.0, false, 50],
["grapple", "grapple", 10.0, false, 50],
["dance", "idle", 0.0, false, 60],
]
var _phase := 0 # index into _states, then actions after
var _phase_frame := 0
var _actions := [["reload", 30], ["throw", 18]]
var _action_i := 0
var _mode := "states" # states -> actions -> ads -> done
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
if args.size() > 1:
_skin = args[1]
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 40:
var sm = root.get_node_or_null("SkinManager")
if sm:
sm.set_active_skin(_skin)
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 160:
return false
if _frames == 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
if p.has_method("get_visual_model") and p.is_multiplayer_authority():
_player = p
break
if not _player:
printerr("anim_capture: no player found")
return true
_model = _player.get_node_or_null("SkinnedModel")
if not _model:
printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin)
return true
# Freeze gameplay driving so we control the animation state directly.
_player.set_physics_process(false)
_player.set_process(false)
_model.set_owner_visible(true)
# Give the model a weapon so armed poses read.
if _model.has_method("set_weapon"):
_model.set_weapon("res://weapons/ak47.gd")
# Camera: repositioned per shot (front + side). Model faces -Z.
_cam = Camera3D.new()
_cam.cull_mask &= ~(1 << 19) # don't render the FP viewmodel layer
current_scene.add_child(_cam)
_cam_to(Vector3(0.4, 0.9, -2.4))
_cam.current = true
return false
# Drive the forced state every frame so blends settle. Front shot two
# frames before the hold ends, side shot on the last frame (the camera
# hop needs a real rendered frame in between).
if _mode == "states":
var s: Array = _states[_phase]
if s[0] == "dance" and _model.has_method("set_dancing"):
_model.set_dancing(true)
_model.update_state(s[1], s[2], s[3])
_phase_frame += 1
var hold: int = int(s[4])
if _phase_frame == hold - 2:
_snap("anim_" + String(s[0]) + "_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= hold:
_snap("anim_" + String(s[0]) + "_s")
_cam_to(Vector3(0.4, 0.9, -2.4))
if s[0] == "dance" and _model.has_method("set_dancing"):
_model.set_dancing(false)
if s[0] == "idle":
_debug_gun()
_phase_frame = 0
_phase += 1
if _phase >= _states.size():
_mode = "actions"
return false
if _mode == "actions":
var a: Array = _actions[_action_i]
if _phase_frame == 0:
_model.play_action(a[0])
_model.update_state("idle", 0.0, false)
_phase_frame += 1
var hold_a: int = int(a[1])
if _phase_frame == hold_a - 2:
_snap("anim_" + String(a[0]) + "_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= hold_a:
_snap("anim_" + String(a[0]) + "_s")
_cam_to(Vector3(0.4, 0.9, -2.4))
_phase_frame = 0
_action_i += 1
if _action_i >= _actions.size():
_mode = "ads"
return false
if _mode == "ads":
_model.update_state("idle", 0.0, false)
if _model.has_method("set_locomotion"):
_model.set_locomotion(0.0, 0.0, 1.0)
_phase_frame += 1
if _phase_frame == 48:
_snap("anim_ads_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 50:
_snap("anim_ads_s")
_debug_gun()
return true
return false
return true
func _debug_gun() -> void:
var pm = _model.get("_pose_mod")
if not pm:
return
var skel: Skeleton3D = _model.skeleton
var hand := skel.find_bone("DEF-hand.R")
var achieved := Vector3.ZERO
if hand >= 0 and pm.gun_fwd_hand.length_squared() > 0.01:
achieved = skel.get_bone_global_pose(hand).basis * pm.gun_fwd_hand
print("GUN DEBUG: fwd_hand=", pm.gun_fwd_hand, " ads=", pm.ads,
" hold_r=", pm._hold_r, " achieved_world_fwd=", achieved)
func _cam_to(offset: Vector3) -> void:
var base: Vector3 = _player.global_position
_cam.global_position = base + offset
_cam.look_at(base + Vector3(0, 0.25, 0), Vector3.UP)
func _snap(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("anim_capture: saved ", path)