|
|
|
@@ -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) \
|
|
|
|
|