Feat/visual audio overhaul #21

Merged
Dotts merged 31 commits from feat/visual-audio-overhaul into main 2026-07-21 14:09:18 -07:00
4 changed files with 233 additions and 22 deletions
Showing only changes of commit 1ad3563e5a - Show all commits
+187 -20
View File
@@ -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) \
+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"]:
+4
View File
@@ -924,6 +924,8 @@ func _physics_process(_delta: float) -> void:
visual.set_wall_side(sm.wall_side)
if visual.has_method("set_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
synced_movement_state = sm.current_state
@@ -1007,6 +1009,8 @@ func _process(delta: float) -> void:
visual.set_wall_side(synced_wall_side)
if visual.has_method("set_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
if visual.has_method("set_aim_pitch") and head_pivot:
visual.set_aim_pitch(head_pivot.rotation.x)