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:
co-authored by
Claude Fable 5
parent
87b8ae70df
commit
1ad3563e5a
@@ -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
|
||||||
_oneshot_lock = lock_time
|
if canonical in FULL_BODY_ONESHOTS or not _anim_tree:
|
||||||
_play_clip(canonical, true)
|
_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
|
## 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.
|
||||||
match clip:
|
if _anim_tree:
|
||||||
"Walk", "CrouchWalk":
|
var s := 1.0
|
||||||
animation_player.speed_scale = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
|
match clip:
|
||||||
"Run", "Sprint", "WallRun":
|
"Walk", "CrouchWalk":
|
||||||
animation_player.speed_scale = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
|
s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
|
||||||
_:
|
"Run", "Sprint", "WallRun":
|
||||||
animation_player.speed_scale = 1.0
|
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.
|
## 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
@@ -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
|
||||||
|
|||||||
@@ -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"]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user