Four separate third-person defects, one shared root: the arms were posed at art-directed ANGLES that merely approximated the gun, and a stuck one-shot flag kept handing them back to the animation clip. - The rifle hold was silently DEAD after the first reload or throw. The gate trusted AnimationNodeOneShot's `active` parameter, which never clears, so clip_owns_arms stayed true forever and the clip drove the arms while the gun hung off the hand. `_upper_lock` (a timer we own) is now the authority, and the one-shot is explicitly faded out when it expires. This alone is why the gun was never held correctly. - Arms are now solved with real two-bone IK (`_ik_arm`) onto points derived from the WEAPON: the stock is anchored in the shoulder pocket, the grip and foregrip fall out along the barrel, and the support hand is placed on the actual handguard (sliding inboard if the model is too long to reach). Verified numerically: the hand lands within 2mm of its target. - `_measure_weapon` measures the model's AABB along its barrel and re-seats it so the hand sits at a realistic pistol-grip point. Weapon models put their origin anywhere — the M4's was 10cm from the muzzle end, so parking its stock in the shoulder drove the hand INTO the shoulder and folded the arm up behind the head. - Recoil now shows for the OWNER in third person: server_play_fire_effects only ever fired for remote shooters, so the local player saw nothing. The ammo counter dropping now kicks the model. The kick rides in the gun's aim direction so the IK carries BOTH hands up with it, instead of rotating the arms and shoving the support hand off the gun. - Reload no longer flips the gun. The library's pistol-reload rotates the wrist the gun is parented to, which turned the rifle upside-down (mag to the sky) while the hand reached down for it. The hold now keeps the right arm through a reload and the support hand does the magazine work at the real mag well, under the receiver. anim_capture gains recoil shots and lets ADS settle before framing; debug/dump_bones.gd prints a skin's bone hierarchy. Co-Authored-By: Claude Opus 4.8 <[email protected]>
227 lines
7.2 KiB
GDScript
227 lines
7.2 KiB
GDScript
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,
|
|
# 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"):
|
|
_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 == 108:
|
|
_snap("anim_ads_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame >= 110:
|
|
_snap("anim_ads_s")
|
|
_debug_gun()
|
|
_phase_frame = 0
|
|
_mode = "recoil"
|
|
return false
|
|
# Sustained fire: the shot kick must visibly move the third-person model.
|
|
if _mode == "recoil":
|
|
_model.update_state("idle", 0.0, false)
|
|
if _model.has_method("set_locomotion"):
|
|
_model.set_locomotion(0.0, 0.0, 0.0)
|
|
_phase_frame += 1
|
|
if _phase_frame == 40:
|
|
_snap("anim_recoil_before_f")
|
|
elif _phase_frame == 41:
|
|
_model.add_gun_recoil() # a single shot
|
|
elif _phase_frame == 42:
|
|
_snap("anim_recoil_peak_f")
|
|
_cam_to(Vector3(2.4, 0.9, -0.4))
|
|
elif _phase_frame == 44:
|
|
_model.add_gun_recoil()
|
|
_snap("anim_recoil_peak_s")
|
|
_cam_to(Vector3(0.4, 0.9, -2.4))
|
|
_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
|
|
|
|
|
|
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)
|