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 361 additions and 81 deletions
Showing only changes of commit 0d125dc03f - Show all commits
+284 -79
View File
@@ -90,6 +90,8 @@ var _anim_tree: AnimationTree
var _loco_trans: AnimationNodeTransition var _loco_trans: AnimationNodeTransition
var _upper_anim: AnimationNodeAnimation var _upper_anim: AnimationNodeAnimation
var _upper_lock: float = 0.0 # seconds the one-shot owns the ARMS var _upper_lock: float = 0.0 # seconds the one-shot owns the ARMS
var _upper_total: float = 0.0 # its full duration, for progress 0..1
var _upper_action: String = "" # which ACTIONS entry is playing
## Bone-name fragments that belong to the upper-body one-shot layer. ## Bone-name fragments that belong to the upper-body one-shot layer.
const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb", const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb",
"f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003", "f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003",
@@ -314,6 +316,7 @@ func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
_play_clip(canonical, true) _play_clip(canonical, true)
return return
_upper_lock = lock_time _upper_lock = lock_time
_upper_total = lock_time
_upper_anim.animation = _resolved_clips[canonical] _upper_anim.animation = _resolved_clips[canonical]
_anim_tree.set("parameters/upper/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE) _anim_tree.set("parameters/upper/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
@@ -327,6 +330,7 @@ func set_dancing(on: bool) -> void:
## Play a named gameplay action (reload / throw / shoot) as a one-shot. ## Play a named gameplay action (reload / throw / shoot) as a one-shot.
func play_action(action: String) -> void: func play_action(action: String) -> void:
if ACTIONS.has(action): if ACTIONS.has(action):
_upper_action = action
play_oneshot(ACTIONS[action][0], ACTIONS[action][1]) play_oneshot(ACTIONS[action][0], ACTIONS[action][1])
@@ -458,6 +462,15 @@ func _process(delta: float) -> void:
# direction to the anchor in skeleton space. # direction to the anchor in skeleton space.
if _upper_lock > 0.0: if _upper_lock > 0.0:
_upper_lock -= delta _upper_lock -= delta
if _upper_lock <= 0.0:
# Explicitly retire the one-shot. Its `active` parameter does NOT
# reliably clear on its own, and anything still treating the shot
# as live permanently disables the rifle hold — after one reload
# the character would hold the gun with clip arms forever.
_upper_action = ""
if _anim_tree:
_anim_tree.set("parameters/upper/request",
AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT)
var grapple_target := 1.0 if _pose_mod.state == "grapple" else 0.0 var grapple_target := 1.0 if _pose_mod.state == "grapple" else 0.0
_cur_grapple = lerpf(_cur_grapple, grapple_target, t) _cur_grapple = lerpf(_cur_grapple, grapple_target, t)
_pose_mod.grapple = _cur_grapple _pose_mod.grapple = _cur_grapple
@@ -473,10 +486,17 @@ func _process(delta: float) -> void:
# dance, death) needs the authored animation to read through. The upper # 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. # 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 upper_active: bool = _anim_tree != null \ # `_upper_lock` (a timer we own) is the authority on how long the one-shot
and bool(_anim_tree.get("parameters/upper/active")) # owns the arms — NOT the OneShot node's `active` flag, which can stay
var clip_owns_arms := _oneshot_lock > 0.0 or _upper_lock > 0.0 \ # true indefinitely and would strand the arms on the clip forever.
or upper_active or _dancing or st == "death" var reloading := _upper_action == "reload" and _upper_lock > 0.0
# A RELOAD must never hand the right arm to the clip: the gun is parented
# to that hand, and the library's pistol-reload rotates the wrist — which
# flipped the rifle upside-down (mag pointing at the sky) while the hand
# reached "down" for it. During a reload the hold keeps the gun steady and
# the support hand does the magazine work at the real mag well instead.
var clip_owns_arms := (_oneshot_lock > 0.0 or _upper_lock > 0.0 \
or _dancing or st == "death") and not reloading
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:
@@ -496,6 +516,12 @@ func _process(delta: float) -> void:
_pose_mod.hold_r_target = hold_r _pose_mod.hold_r_target = hold_r
_pose_mod.hold_l_target = hold_l _pose_mod.hold_l_target = hold_l
# Reload progress drives the support hand's trip to the mag well.
var rl_target := 0.0
if reloading and _upper_total > 0.0:
rl_target = clampf(1.0 - (_upper_lock / _upper_total), 0.0, 1.0)
_pose_mod.reload_phase = rl_target
func _play_clip(canonical: String, restart: bool = false) -> void: func _play_clip(canonical: String, restart: bool = false) -> void:
if not _anim_tree or not _resolved_clips.has(canonical): if not _anim_tree or not _resolved_clips.has(canonical):
@@ -547,13 +573,14 @@ func set_weapon(script_path: String) -> void:
# lie along the hand's grip, scaled down to character proportions. # lie along the hand's grip, scaled down to character proportions.
w.position = Vector3(-0.02, 0.07, 0.0) w.position = Vector3(-0.02, 0.07, 0.0)
w.rotation_degrees = Vector3(0, 90, -90) w.rotation_degrees = Vector3(0, 90, -90)
w.scale = Vector3(0.75, 0.75, 0.75) w.scale = Vector3(1.0, 1.0, 1.0)
# Tell the pose layer the gun's axes in hand-bone space so it can # Tell the pose layer the gun's axes in hand-bone space so it can
# aim the wrist to point the muzzle exactly where the player looks. # aim the wrist to point the muzzle exactly where the player looks.
if _pose_mod: if _pose_mod:
var b: Basis = w.transform.basis.orthonormalized() var b: Basis = w.transform.basis.orthonormalized()
_pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1) _pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1)
_pose_mod.gun_up_hand = b * Vector3(0, 1, 0) _pose_mod.gun_up_hand = b * Vector3(0, 1, 0)
_measure_weapon(w)
) )
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"]) var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
@@ -572,6 +599,45 @@ func set_weapon(script_path: String) -> void:
add_child(w) add_child(w)
## Measure the held weapon along its own barrel axis so the pose layer knows
## where the real foregrip and stock butt are, instead of guessing. Distances
## are from the GRIP (the weapon node's origin, which sits in the hand), in
## metres of character space.
func _measure_weapon(w: Node3D) -> void:
var local_fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis
var min_t := INF # most negative = stock end
var max_t := -INF # most positive = muzzle end
for mi in w.find_children("*", "MeshInstance3D", true, false):
if not mi.mesh:
continue
var xf: Transform3D = w.global_transform.affine_inverse() * mi.global_transform
var aabb: AABB = mi.mesh.get_aabb()
for i in 8:
var t: float = (xf * aabb.get_endpoint(i)).dot(local_fwd)
min_t = minf(min_t, t)
max_t = maxf(max_t, t)
if min_t > max_t:
return
var s: float = absf(w.scale.z)
var total := max_t - min_t
if total < 0.0001:
return
# Weapon models put their origin wherever the artist left it — for the M4
# that is barely 10 cm behind the muzzle end, so hanging the hand there
# and then parking the stock in the shoulder shoved the hand INTO the
# shoulder and the arm folded up behind the head. Re-seat the weapon so
# the hand sits at a realistic pistol-grip point (~a third back from the
# muzzle), which puts real length of gun behind the hand to reach the
# shoulder with.
var grip_at := min_t + total * 0.32
w.position -= _pose_mod.gun_fwd_hand * (grip_at * s)
var back := (grip_at - min_t) * s # butt of the stock, behind the grip
var front := (max_t - grip_at) * s # muzzle, ahead of the grip
_pose_mod.gun_stock = clampf(back, 0.10, 0.40)
# Support hand rides partway out the handguard, never past the muzzle.
_pose_mod.gun_fore = clampf(front * 0.55, 0.14, 0.45)
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
func _find_bone(name_parts: Array) -> int: func _find_bone(name_parts: Array) -> int:
@@ -632,6 +698,12 @@ class ShooterPoseModifier extends SkeletonModifier3D:
# The attached gun's forward/up axes in hand-bone space (set on set_weapon). # The attached gun's forward/up axes in hand-bone space (set on set_weapon).
var gun_fwd_hand: Vector3 = Vector3.ZERO var gun_fwd_hand: Vector3 = Vector3.ZERO
var gun_up_hand: Vector3 = Vector3.UP var gun_up_hand: Vector3 = Vector3.UP
# Measured gun geometry (metres from the grip): how far out the support
# hand rides, and how far back the stock butt reaches. See _measure_weapon.
var gun_fore: float = 0.26
var gun_stock: float = 0.20
# 0..1 through a reload — drives the support hand to the mag well and back.
var reload_phase: float = 0.0
# Tuning (radians). Positive pitch leans forward; positive roll leans right. # Tuning (radians). Positive pitch leans forward; positive roll leans right.
const LEAN_ROLL := 0.30 const LEAN_ROLL := 0.30
@@ -701,16 +773,15 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(head_q, 0.5)) _add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(head_q, 0.5))
# Shot kick: shoulders snap back and up, forearms rise; decays fast. # Shot kick: the torso absorbs it. The MUZZLE rise is not applied here —
# it rides in the hold's aim direction (see `kick` in _apply_rifle_hold),
# so the IK carries BOTH hands up with the gun. Rotating the arms here
# instead would shove the support hand straight off the handguard.
func _apply_recoil(skel: Skeleton3D) -> void: func _apply_recoil(skel: Skeleton3D) -> void:
var k := recoil var k := recoil
var back := Quaternion(Vector3(1, 0, 0), -0.12 * k) var back := Quaternion(Vector3(1, 0, 0), -0.05 * k)
for n in ["DEF-spine.002", "DEF-spine.003"]: for n in ["DEF-spine.002", "DEF-spine.003"]:
_add_space(skel, _idx.get(n, -1), back) _add_space(skel, _idx.get(n, -1), back)
var arm_up := Quaternion(Vector3(1, 0, 0), -0.3 * k)
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), arm_up)
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), arm_up)
_add_space(skel, _idx.get("DEF-forearm.R", -1), Quaternion(Vector3(1, 0, 0), -0.22 * k))
# Distribute a skeleton-space lean across the spine bones. # Distribute a skeleton-space lean across the spine bones.
func _apply_lean(skel: Skeleton3D) -> void: func _apply_lean(skel: Skeleton3D) -> void:
@@ -765,7 +836,7 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var hand_o := skel.get_bone_global_rest(hand_l).origin var hand_o := skel.get_bone_global_rest(hand_l).origin
var fa_rest_dir := (hand_o - fa_o).normalized() var fa_rest_dir := (hand_o - fa_o).normalized()
var g_hand := Quaternion(d, 0.4) * Quaternion(fa_rest_dir, d) * hand_rest_q 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) _set_global_rot(skel, hand_l, g_fa, 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:
@@ -807,88 +878,207 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_add_space(skel, _idx.get("DEF-forearm.L", -1), Quaternion(Vector3(1, 0, 0), 0.35 * slide)) _add_space(skel, _idx.get("DEF-forearm.L", -1), Quaternion(Vector3(1, 0, 0), 0.35 * slide))
# ── Two-hand rifle hold ────────────────────────────────────────────────── # ── Two-hand rifle hold ──────────────────────────────────────────────────
# REPLACES the arm-chain rotations from the base clip with a deterministic # The gun is parented to the RIGHT HAND bone, so where the hands go decides
# FK pose: each arm bone is aimed along an art-directed skeleton-space # where the gun goes. We therefore place the WEAPON first — stock in the
# DIRECTION (low-ready at the hip, shouldered on ADS), so the hold looks # shoulder pocket, barrel down the aim line — then solve both arms with
# identical in every locomotion state — no unarmed jog arms flailing # two-bone IK to the resulting grip and foregrip points. That is what makes
# around a floating gun. Directions are in skeleton space: character faces # the stock actually meet the shoulder and the support hand actually touch
# +Z, up +Y, character-right -X. # the handguard, instead of both arms waving at art-directed angles near it.
# Skeleton space: character faces +Z, up +Y, character-right -X.
# Right arm: gun hand — elbow at the ribs, hand ahead of the right hip,
# rifle line pointing forward-down.
const R_UA_HIP := Vector3(-0.30, -0.90, 0.28)
const R_FA_HIP := Vector3(0.25, 0.15, 0.95)
# Left arm: support hand crosses to the foregrip ahead of the belly.
const L_UA_HIP := Vector3(0.32, -0.80, 0.42)
const L_FA_HIP := Vector3(-0.25, 0.10, 0.96)
# ADS: both arms rise with bent elbows, hands stack along the eye line.
const R_UA_ADS := Vector3(-0.30, -0.60, 0.70)
const R_FA_ADS := Vector3(0.35, 0.45, 0.85)
const L_UA_ADS := Vector3(0.28, -0.50, 0.80)
const L_FA_ADS := Vector3(-0.25, 0.45, 0.88)
# Twist about the bone line (radians) to keep elbows/palms natural.
const R_UA_TWIST := 0.3; const L_UA_TWIST := -0.3
const L_HAND_TWIST := 0.5
const GUN_PITCH_HIP := 0.38 # muzzle tilts down this much at low-ready const GUN_PITCH_HIP := 0.38 # muzzle tilts down this much at low-ready
# Where the butt of the stock sits, relative to the right shoulder joint.
# The pocket is on the FRONT of the shoulder, slightly inboard of the joint.
const POCKET_ADS := Vector3(0.05, 0.01, 0.07) # in the shoulder pocket
const POCKET_HIP := Vector3(0.02, -0.20, 0.05) # tucked down at the ribs
# Elbow bend hints (skeleton space). At the hip the firing elbow rides
# down by the ribs; shouldered it flares OUT and level (the classic
# "chicken wing"), which is what keeps the tight fold from folding the
# arm up behind the head. The support elbow always tucks under the gun.
const POLE_R_HIP := Vector3(-0.55, -0.85, -0.20)
const POLE_R_ADS := Vector3(-1.0, -0.25, -0.10)
const POLE_L_HIP := Vector3(0.45, -0.90, -0.10)
const POLE_L_ADS := Vector3(0.30, -0.95, -0.05)
const R_HAND_TWIST := 0.0
const L_HAND_TWIST := 0.5
func _apply_rifle_hold(skel: Skeleton3D) -> void: func _apply_rifle_hold(skel: Skeleton3D) -> void:
var breathe := sin(_time * 2.2) * 0.015 + fwd * 0.03 var ua_r: int = _idx.get("DEF-upper_arm.R", -1)
var kick := recoil * 0.2 var ua_l: int = _idx.get("DEF-upper_arm.L", -1)
# Aim pitch tilts the whole hold on ADS; recoil kicks the muzzle up. if ua_r < 0:
var pitch := (-aim_pitch * ads) - kick - breathe return
var q_pitch := Quaternion(Vector3(1, 0, 0), pitch) var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02
# ~7 degrees of muzzle rise per shot, stacking a little on full auto.
var kick := recoil * 0.12
var ua_r: Vector3 = q_pitch * R_UA_HIP.lerp(R_UA_ADS, ads).normalized() # 1. The gun's line: pitched down at low-ready, on the camera line at
var fa_r: Vector3 = q_pitch * R_FA_HIP.lerp(R_FA_ADS, ads).normalized() # ADS, kicked up by recoil.
var ua_l: Vector3 = q_pitch * L_UA_HIP.lerp(L_UA_ADS, ads).normalized() var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe
var fa_l: Vector3 = q_pitch * L_FA_HIP.lerp(L_FA_ADS, ads).normalized() var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \
* Vector3(0, 0, 1)).normalized()
# Gun's "up" — perpendicular to the barrel in the vertical plane, so the
# magazine always hangs DOWN.
var side := aim_dir.cross(Vector3.UP)
if side.length_squared() < 0.0001:
side = Vector3(-1, 0, 0)
side = side.normalized()
var gun_up := side.cross(aim_dir).normalized()
var g_fa_r := _aim_chain(skel, "DEF-upper_arm.R", "DEF-forearm.R", # 2. Anchor the stock at the shoulder, then walk out along the barrel.
ua_r, fa_r, R_UA_TWIST, _hold_r) var shoulder := skel.get_bone_global_pose(ua_r).origin
var g_fa_l := _aim_chain(skel, "DEF-upper_arm.L", "DEF-forearm.L", var pocket: Vector3 = POCKET_HIP.lerp(POCKET_ADS, ads)
ua_l, fa_l, L_UA_TWIST, _hold_l) var stock_pos := shoulder + pocket
var grip_pos := stock_pos + aim_dir * gun_stock
# The support hand rides as far out the handguard as it can actually
# REACH. Without this a long rifle puts the foregrip past the left
# arm's limit and the IK yanks the whole arm out straight.
var fore_dist := gun_fore
if ua_l >= 0:
var l_sh := skel.get_bone_global_pose(ua_l).origin
var reach_l := _arm_reach(skel, "DEF-upper_arm.L", "DEF-forearm.L",
"DEF-hand.L") * 0.94
for _i in 5:
if grip_pos.distance_to(l_sh) > reach_l:
break # even the grip is out of reach; nothing to slide to
if (grip_pos + aim_dir * fore_dist).distance_to(l_sh) <= reach_l:
break
fore_dist *= 0.75
var fore_pos := grip_pos + aim_dir * fore_dist
# Gun hand: orient the wrist so the MUZZLE points exactly along the aim # 3. Support hand goes to the mag well during a reload (under the
# line (forward-down at low-ready, camera pitch on ADS), gun kept # receiver — the correct side), otherwise to the handguard.
# upright. This is what makes the weapon read "aimed" instead of var l_target := fore_pos
# dangling at whatever angle the wrist twist happens to produce. if reload_phase > 0.001:
var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10
var drop := mag_well - gun_up * 0.22 - aim_dir * 0.05
var p := reload_phase
if p < 0.30: # rip the mag straight down out of the well
l_target = mag_well.lerp(drop, p / 0.30)
elif p < 0.55: # reach down for a fresh one
l_target = drop
elif p < 0.80: # bring it back up and seat it
l_target = drop.lerp(mag_well, (p - 0.55) / 0.25)
else: # hand returns to the handguard
l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20)
# 4. Solve both arms onto those points.
var pole_r: Vector3 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized()
var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, ads).normalized()
var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R",
"DEF-hand.R", grip_pos, pole_r, _hold_r)
var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L",
"DEF-hand.L", l_target, pole_l, _hold_l)
if OS.has_environment("GUN_POSE_DEBUG"):
var hr: int = _idx.get("DEF-hand.R", -1)
var hl: int = _idx.get("DEF-hand.L", -1)
print("HOLD ads=%.2f holdR=%.2f holdL=%.2f rl=%.2f fore=%.3f stock=%.3f foredist=%.3f" % [
ads, _hold_r, _hold_l, reload_phase, gun_fore, gun_stock, fore_dist],
" sh=", shoulder, " grip=", grip_pos, " fore=", fore_pos,
" handR=", skel.get_bone_global_pose(hr).origin if hr >= 0 else "-",
" handL=", skel.get_bone_global_pose(hl).origin if hl >= 0 else "-",
" reachL=%.3f" % _arm_reach(skel, "DEF-upper_arm.L",
"DEF-forearm.L", "DEF-hand.L"))
# 5. Roll the gun hand so the BARREL lies on the aim line and the
# magazine points down — the gun's orientation comes entirely from
# this wrist, so it can never end up inverted.
if _hold_r > 0.001 and g_fa_r != Quaternion.IDENTITY \ if _hold_r > 0.001 and g_fa_r != Quaternion.IDENTITY \
and gun_fwd_hand.length_squared() > 0.5: and gun_fwd_hand.length_squared() > 0.5:
var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick * 2.0
var d := Quaternion(Vector3(1, 0, 0), gun_pitch) * Vector3(0, 0, 1)
var hand: int = _idx.get("DEF-hand.R", -1) var hand: int = _idx.get("DEF-hand.R", -1)
if hand >= 0: if hand >= 0:
var arc := Quaternion(gun_fwd_hand.normalized(), d) var arc := Quaternion(gun_fwd_hand.normalized(), aim_dir)
# Kill the roll: rotate about the aim line so the gun's up
# vector lands in the vertical plane of the aim direction.
var up_now := arc * gun_up_hand.normalized() var up_now := arc * gun_up_hand.normalized()
var side := d.cross(Vector3.UP) var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
if side.length_squared() > 0.001: if up_flat.length_squared() > 0.0001:
var up_ideal := side.normalized().cross(d).normalized() var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
var up_flat := (up_now - d * up_now.dot(d)).normalized() arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc
var roll := up_flat.signed_angle_to(up_ideal, d) _set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
arc = Quaternion(d, roll) * arc
_blend_local(skel, hand, g_fa_r.inverse() * arc, _hold_r)
if OS.has_environment("GUN_POSE_DEBUG"):
var fa_actual := skel.get_bone_global_pose(
_idx.get("DEF-forearm.R", -1)).basis.get_rotation_quaternion()
var hand_actual := skel.get_bone_global_pose(hand).basis.get_rotation_quaternion()
print("MOD DEBUG d=", d,
" fa_target=", g_fa_r, " fa_actual=", fa_actual,
" hand_global*v=", hand_actual * gun_fwd_hand)
# Support hand: follow the forearm line with a fixed palm twist. # 6. Support hand: palm wraps the handguard, following its forearm.
if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY: if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY and ua_l >= 0:
var hand_l: int = _idx.get("DEF-hand.L", -1) var hand_l: int = _idx.get("DEF-hand.L", -1)
var fa_l_idx: int = _idx.get("DEF-forearm.L", -1) var fa_l_idx: int = _idx.get("DEF-forearm.L", -1)
if hand_l >= 0 and fa_l_idx >= 0: if hand_l >= 0 and fa_l_idx >= 0:
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_idx).origin var fa_o := skel.get_bone_global_rest(fa_l_idx).origin
var hand_o := skel.get_bone_global_rest(hand_l).origin var hand_o := skel.get_bone_global_rest(hand_l).origin
var fa_rest_dir := (hand_o - fa_o).normalized() var fa_rest_dir := (hand_o - fa_o).normalized()
var g_hand := Quaternion(fa_l, L_HAND_TWIST) \ var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
* Quaternion(fa_rest_dir, fa_l) * hand_rest_q # Point the palm along the barrel so the fingers close over it.
_blend_local(skel, hand_l, g_fa_l.inverse() * g_hand, _hold_l) var g_hand := Quaternion(aim_dir, L_HAND_TWIST) \
* Quaternion(fa_rest_dir, aim_dir) * hand_rest_q
_set_global_rot(skel, hand_l, g_fa_l, g_hand, _hold_l)
## Straight-arm length of an arm chain, from the rest pose.
func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String,
hand_name: String) -> float:
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
var hand: int = _idx.get(hand_name, -1)
if ua < 0 or fa < 0 or hand < 0:
return 0.5
var a := skel.get_bone_global_rest(ua).origin
var b := skel.get_bone_global_rest(fa).origin
var c := skel.get_bone_global_rest(hand).origin
return a.distance_to(b) + b.distance_to(c)
## Two-bone IK: rotate the upper arm + forearm so the HAND JOINT lands on
## `target` (skeleton space). `pole` biases which way the elbow breaks.
## Returns the forearm's achieved global rotation (IDENTITY when skipped).
func _ik_arm(skel: Skeleton3D, ua_name: String, fa_name: String,
hand_name: String, target: Vector3, pole: Vector3,
w: float) -> Quaternion:
if w <= 0.001:
return Quaternion.IDENTITY
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
var hand: int = _idx.get(hand_name, -1)
if ua < 0 or fa < 0 or hand < 0:
return Quaternion.IDENTITY
# Segment lengths come from the REST pose so they never drift.
var ua_rest := skel.get_bone_global_rest(ua).origin
var fa_rest := skel.get_bone_global_rest(fa).origin
var hand_rest := skel.get_bone_global_rest(hand).origin
var l1 := ua_rest.distance_to(fa_rest)
var l2 := fa_rest.distance_to(hand_rest)
if l1 < 0.0001 or l2 < 0.0001:
return Quaternion.IDENTITY
# The shoulder's CURRENT position (spine lean/aim pitch already moved it).
var root := skel.get_bone_global_pose(ua).origin
var to_target := target - root
var d := to_target.length()
if d < 0.0001:
return Quaternion.IDENTITY
var reach := to_target / d
d = clampf(d, absf(l1 - l2) + 0.002, l1 + l2 - 0.002)
# Law of cosines for the shoulder angle, then break the elbow toward
# the pole to pick one of the infinitely many solutions.
var cos_a := clampf((l1 * l1 + d * d - l2 * l2) / (2.0 * l1 * d), -1.0, 1.0)
var axis := reach.cross(pole)
if axis.length_squared() < 0.000001:
axis = reach.cross(Vector3.UP)
if axis.length_squared() < 0.000001:
axis = reach.cross(Vector3(1, 0, 0))
axis = axis.normalized()
var ua_dir := (Quaternion(axis, acos(cos_a)) * reach).normalized()
var elbow := root + ua_dir * l1
var fa_dir := target - elbow
fa_dir = fa_dir.normalized() if fa_dir.length_squared() > 0.00000001 else reach
var g_fa := _aim_chain(skel, ua_name, fa_name, ua_dir, fa_dir, 0.0, w)
if OS.has_environment("IK_DEBUG") and ua_name.ends_with(".R"):
var got_ua := skel.get_bone_global_pose(ua)
var got_fa := skel.get_bone_global_pose(fa)
var got_hand := skel.get_bone_global_pose(hand)
print("IK l1=%.3f l2=%.3f d=%.3f" % [l1, l2, d],
"\n root_want=", root, " ua_origin_got=", got_ua.origin,
"\n elbow_want=", elbow, " fa_origin_got=", got_fa.origin,
"\n hand_want=", target, " hand_got=", got_hand.origin,
"\n ua_dir=", ua_dir, " ua_dir_got=",
(got_fa.origin - got_ua.origin).normalized())
return g_fa
# Aim an upper-arm/forearm chain along the given directions with exact FK: # Aim an upper-arm/forearm chain along the given directions with exact FK:
# desired global orientation = (shortest arc from the bone's rest line to # desired global orientation = (shortest arc from the bone's rest line to
@@ -921,13 +1111,21 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var g_ua := Quaternion(ua_dir, ua_twist) * Quaternion(ua_rest_dir, ua_dir) * ua_rest_q var g_ua := Quaternion(ua_dir, ua_twist) * Quaternion(ua_rest_dir, ua_dir) * ua_rest_q
var g_fa := Quaternion(fa_rest_dir, fa_dir) * fa_rest_q var g_fa := Quaternion(fa_rest_dir, fa_dir) * fa_rest_q
# Local poses against the actual (clip-posed) parent for the shoulder # Convert both to LOCAL pose rotations. Godot composes a bone as
# link, then against our own target down the chain. # global = parent_global * rest_local * pose_local
# so pose_local = rest_local⁻¹ * parent_global⁻¹ * global_target.
# (Dropping the rest_local⁻¹ term silently biases every bone by its
# rest orientation — which is why hand-tuned angles used to be needed.)
var parent := skel.get_bone_parent(ua) var parent := skel.get_bone_parent(ua)
var g_parent := skel.get_bone_global_pose(parent).basis.get_rotation_quaternion() \ var g_parent := skel.get_bone_global_pose(parent).basis.get_rotation_quaternion() \
if parent >= 0 else Quaternion.IDENTITY if parent >= 0 else Quaternion.IDENTITY
_blend_local(skel, ua, g_parent.inverse() * g_ua, w) _set_global_rot(skel, ua, g_parent, g_ua, w)
_blend_local(skel, fa, g_ua.inverse() * g_fa, w) # The forearm hangs off the upper arm we just aimed, so its parent's
# global IS g_ua (using the cached pose here would lag a frame).
var fa_parent := skel.get_bone_parent(fa)
var g_fa_parent := g_ua if fa_parent == ua \
else skel.get_bone_global_pose(fa_parent).basis.get_rotation_quaternion()
_set_global_rot(skel, fa, g_fa_parent, g_fa, w)
if OS.has_environment("GUN_POSE_DEBUG") and ua_name.ends_with(".R"): if OS.has_environment("GUN_POSE_DEBUG") and ua_name.ends_with(".R"):
var ua_actual := skel.get_bone_global_pose(ua).basis.get_rotation_quaternion() var ua_actual := skel.get_bone_global_pose(ua).basis.get_rotation_quaternion()
var fa_actual := skel.get_bone_global_pose(fa).basis.get_rotation_quaternion() var fa_actual := skel.get_bone_global_pose(fa).basis.get_rotation_quaternion()
@@ -941,6 +1139,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
skel.set_bone_pose_rotation(idx, skel.set_bone_pose_rotation(idx,
skel.get_bone_pose_rotation(idx).slerp(target.normalized(), w)) skel.get_bone_pose_rotation(idx).slerp(target.normalized(), w))
## Give a bone the requested GLOBAL rotation, given its parent's global
## rotation. In Godot 4 a bone's POSE *is* its local transform (rest is
## merely the default), so this is a plain parent-relative conversion.
func _set_global_rot(skel: Skeleton3D, idx: int, g_parent: Quaternion,
g_target: Quaternion, w: float) -> void:
_blend_local(skel, idx, g_parent.inverse() * g_target, w)
# Compose a skeleton-space rotation onto a bone's animated local pose. # Compose a skeleton-space rotation onto a bone's animated local pose.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void: func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
if idx < 0: if idx < 0:
+22 -2
View File
@@ -134,13 +134,33 @@ func _process(_delta: float) -> bool:
if _model.has_method("set_locomotion"): if _model.has_method("set_locomotion"):
_model.set_locomotion(0.0, 0.0, 1.0) _model.set_locomotion(0.0, 0.0, 1.0)
_phase_frame += 1 _phase_frame += 1
if _phase_frame == 48: if _phase_frame == 108:
_snap("anim_ads_f") _snap("anim_ads_f")
_cam_to(Vector3(2.4, 0.9, -0.4)) _cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 50: elif _phase_frame >= 110:
_snap("anim_ads_s") _snap("anim_ads_s")
_debug_gun() _debug_gun()
_phase_frame = 0 _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" _mode = "combo"
return false return false
# Mechanic-fidelity combos: reload WHILE sliding (legs must keep sliding), # Mechanic-fidelity combos: reload WHILE sliding (legs must keep sliding),
+39
View File
@@ -0,0 +1,39 @@
extends SceneTree
## Dev tool: print the skeleton's bone hierarchy for a skin's GLB.
## godot --headless --path . -s res://debug/dump_bones.gd -- <res-path-to-glb>
func _init() -> void:
call_deferred("_run")
func _run() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := GLBLoader.load(path)
if not scene:
printerr("could not load ", path)
quit(1)
return
var skel: Skeleton3D = _find(scene) as Skeleton3D
if not skel:
printerr("no skeleton")
quit(1)
return
print("bones: ", skel.get_bone_count())
for i in skel.get_bone_count():
var p := skel.get_bone_parent(i)
var pname := skel.get_bone_name(p) if p >= 0 else "-"
var n := skel.get_bone_name(i)
if n.findn("arm") != -1 or n.findn("hand") != -1 or n.findn("shoulder") != -1:
print(" %d %s parent=%s" % [i, n, pname])
quit(0)
func _find(node: Node) -> Node:
if node is Skeleton3D:
return node
for c in node.get_children():
var f := _find(c)
if f:
return f
return null
+16
View File
@@ -46,6 +46,9 @@ var _remote_footstep_timer: float = 0.0
@export var synced_action_seq: int = 0 @export var synced_action_seq: int = 0
var _last_action_seq: int = 0 var _last_action_seq: int = 0
var _was_reloading: bool = false var _was_reloading: bool = false
## Ammo count last frame — a drop means we fired, which kicks the owner's
## own third-person model (remote shooters go through server_play_fire_effects).
var _last_local_ammo: int = -1
var dash_player: AudioStreamPlayer var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer var hit_player: AudioStreamPlayer
@@ -1048,6 +1051,19 @@ func _process(delta: float) -> void:
if now_reloading and not _was_reloading: if now_reloading and not _was_reloading:
_trigger_action("reload") _trigger_action("reload")
_was_reloading = now_reloading _was_reloading = now_reloading
# Shot kick on our OWN third-person model. server_play_fire_effects
# only fires for REMOTE shooters, so without this the owner sees no
# recoil at all in third person. Detect it from the ammo counter.
var ammo_now: int = -1
if lw:
if "current_ammo" in lw:
ammo_now = lw.current_ammo
elif "shells" in lw:
ammo_now = lw.shells
if ammo_now >= 0 and _last_local_ammo >= 0 and ammo_now < _last_local_ammo \
and lvisual.has_method("add_gun_recoil"):
lvisual.add_gun_recoil()
_last_local_ammo = ammo_now
# Update UI # Update UI
if is_instance_valid(health_bar): if is_instance_valid(health_bar):