feat: shooter animation feel — directional lean, slide, composed airborne, weapon hold

Layer a procedural SkeletonModifier3D (ShooterPoseModifier) on top of the base
clip so the character reads like a movement-shooter avatar:

- Directional lean: banks into the movement direction (right/left/back) and
  blends smoothly for diagonals, driven by velocity relative to facing.
- Slide: leans the torso back and pitches the head up to look forward, instead
  of the base clip's forward-torso/legs-out "spine break".
- Airborne: plays a composed Jump pose (weapon ready) rather than a flailing
  fall.
- Weapon hold: the base clip already keeps the arms down (weapon at the hip);
  on ADS both arms lift and swing in toward centre-front to aim. ADS is read
  from the active weapon and synced (synced_is_ads) so remote players raise
  their weapons too.

The controller feeds movement direction + ADS to SkinnedPlayerModel.set_locomotion()
each frame (works for local and remote via synced velocity/rotation). All
rotations are authored in skeleton space (fwd=+Z, up=+Y, right=-X) and converted
per-bone; tuning constants are at the top of ShooterPoseModifier.

Verified by rendering the poses (idle/strafe/back/slide/ads) in a real Godot
viewport. Smoke test 30/30.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-06 23:23:51 -04:00
co-authored by Claude Fable 5
parent 9939e7e524
commit 29fdca3565
5 changed files with 182 additions and 1 deletions
+32
View File
@@ -73,6 +73,7 @@ var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false
var synced_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false
@export var synced_skin_id: String = ""
@export var synced_weapon_path: String = ""
@@ -864,11 +865,17 @@ func _physics_process(_delta: float) -> void:
else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
# Local player's aim-down-sights state (drives the model's weapon raise).
synced_is_ads = _read_ads()
# Drive the visual model (skinned GLB or procedural) from local state
var visual = get_visual_model()
if visual:
var h_speed = Vector2(velocity.x, velocity.z).length()
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
if visual.has_method("set_locomotion"):
var d := _local_move_dir()
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
# Publish state for remote peers
synced_movement_state = sm.current_state
@@ -878,6 +885,28 @@ func _physics_process(_delta: float) -> void:
synced_velocity = velocity
## Movement direction relative to facing: x = strafe (+right), y = forward
## (+forward). Derived from velocity so it works for local and remote players.
func _local_move_dir() -> Vector2:
var hspeed := Vector2(velocity.x, velocity.z).length()
if hspeed < 0.5:
return Vector2.ZERO
var local_vel := global_transform.basis.inverse() * velocity
return Vector2(local_vel.x / hspeed, -local_vel.z / hspeed)
## Whether the local player's active weapon is aiming down sights.
func _read_ads() -> bool:
if not is_instance_valid(camera):
return false
var wman = camera.get_node_or_null("WeaponManager")
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
var w = wman.weapons[wman.active_slot]
if w and "is_ads" in w:
return w.is_ads
return false
func _on_movement_event(ev: String, data: Dictionary) -> void:
if ev == "chain_updated":
chain_updated.emit(data.count, data.bonus)
@@ -914,6 +943,9 @@ func _process(delta: float) -> void:
var visual = get_visual_model()
if visual:
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
if visual.has_method("set_locomotion"):
var d := _local_move_dir()
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
# Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path)