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
+147 -1
View File
@@ -59,6 +59,18 @@ var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D var _weapon_attachment: BoneAttachment3D
var is_holding_weapon: bool = false var is_holding_weapon: bool = false
# 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
var _target_strafe: float = 0.0
var _target_fwd: float = 0.0
var _target_ads: float = 0.0
var _cur_strafe: float = 0.0
var _cur_fwd: float = 0.0
var _cur_ads: float = 0.0
var _cur_slide: float = 0.0
const POSE_SMOOTH := 10.0
func _ready() -> void: func _ready() -> void:
if model_path != "": if model_path != "":
@@ -90,6 +102,9 @@ func load_model(path: String) -> void:
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path) push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
else: else:
_ensure_meshes_bound(scene) _ensure_meshes_bound(scene)
_pose_mod = ShooterPoseModifier.new()
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
if animation_player: if animation_player:
_index_animations() _index_animations()
else: else:
@@ -187,7 +202,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
elif speed > 0.5: elif speed > 0.5:
clip = "Walk" clip = "Walk"
"air": "air":
clip = "Fall" # Composed, weapon-ready airborne — not a flailing fall.
clip = "Jump"
"slide": "slide":
clip = "Slide" clip = "Slide"
"wall_run": "wall_run":
@@ -203,6 +219,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_play_clip(clip) _play_clip(clip)
if _pose_mod:
_pose_mod.state = state
_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: match clip:
"Walk": "Walk":
@@ -213,6 +233,31 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
animation_player.speed_scale = 1.0 animation_player.speed_scale = 1.0
## Drives the procedural pose layer. Called by the controller each frame.
## strafe: -1 (moving left) .. +1 (moving right), relative to facing
## fwd: -1 (moving back) .. +1 (moving forward), relative to facing
## ads: 0 (hip) .. 1 (aiming down sights)
func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
_target_strafe = clampf(strafe, -1.0, 1.0)
_target_fwd = clampf(fwd, -1.0, 1.0)
_target_ads = clampf(ads, 0.0, 1.0)
func _process(delta: float) -> void:
if not _pose_mod:
return
var t := 1.0 - exp(-POSE_SMOOTH * delta)
_cur_strafe = lerpf(_cur_strafe, _target_strafe, t)
_cur_fwd = lerpf(_cur_fwd, _target_fwd, t)
_cur_ads = lerpf(_cur_ads, _target_ads, t)
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
_cur_slide = lerpf(_cur_slide, slide_target, t)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
_pose_mod.ads = _cur_ads
_pose_mod.slide = _cur_slide
func _play_clip(canonical: String) -> void: func _play_clip(canonical: String) -> void:
if not animation_player or not _resolved_clips.has(canonical): if not animation_player or not _resolved_clips.has(canonical):
return return
@@ -296,3 +341,104 @@ func _set_shadows_recursive(node: Node) -> void:
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
for child in node.get_children(): for child in node.get_children():
_set_shadows_recursive(child) _set_shadows_recursive(child)
# ── Procedural shooter pose layer ─────────────────────────────────────────────
#
# Runs after the AnimationPlayer each frame and layers shooter-feel poses on top
# of the base clip: lean into the movement direction, a slide that leans back and
# looks forward, and an always-held weapon that raises to ADS. All rotations are
# authored in the skeleton's own space (forward = +Z, up = +Y, character-right =
# -X) and converted into each bone's local pose, so they read intuitively.
class ShooterPoseModifier extends SkeletonModifier3D:
# Inputs, written by the owning SkinnedPlayerModel each frame.
var strafe: float = 0.0 # -1 left .. +1 right
var fwd: float = 0.0 # -1 back .. +1 forward
var ads: float = 0.0 # 0 hip .. 1 aiming
var slide: float = 0.0 # 0 .. 1 slide blend
var state: String = "idle"
var weapon_held: bool = false
# Tuning (radians). Positive pitch leans forward; positive roll leans right.
const LEAN_ROLL := 0.30
const LEAN_PITCH := 0.18
const SLIDE_BACK := 0.75 # torso lean-back during slide
const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward
const ADS_LIFT := 1.05 # upper-arm raise toward aim at full ADS
const ADS_SWING := 0.6 # swing arms in toward centre-front on ADS
const ADS_FOREARM := 0.55 # forearm bend to bring the weapon up on ADS
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
var _idx: Dictionary = {}
var _resolved := false
func _resolve() -> void:
var skel := get_skeleton()
var names := SPINE + ["DEF-neck", "DEF-head",
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L"]
for n in names:
_idx[n] = skel.find_bone(n)
_resolved = true
func _process_modification() -> void:
var skel := get_skeleton()
if not skel:
return
if not _resolved:
_resolve()
_apply_lean(skel)
if slide > 0.01:
_apply_slide(skel)
if weapon_held:
_apply_weapon(skel)
# Distribute a skeleton-space lean across the spine bones.
func _apply_lean(skel: Skeleton3D) -> void:
var pitch := fwd * LEAN_PITCH * (1.0 - slide)
var roll := strafe * LEAN_ROLL * (1.0 - slide)
if absf(pitch) < 0.001 and absf(roll) < 0.001:
return
var q := Quaternion(Vector3(1, 0, 0), pitch) * Quaternion(Vector3(0, 0, 1), roll)
var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
# Slide: lean the whole torso back, then pitch the head up to look forward.
func _apply_slide(skel: Skeleton3D) -> void:
var back := Quaternion(Vector3(1, 0, 0), -SLIDE_BACK * slide)
var per := Quaternion.IDENTITY.slerp(back, 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
var up := Quaternion(Vector3(1, 0, 0), SLIDE_HEAD_UP * slide)
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(up, 0.5))
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(up, 0.5))
# Weapon hold. At the hip the base clip already keeps the arms down with the
# weapon (attached to the hand) at the side, so we leave it alone. On ADS we
# additively lift both arms forward-up toward an aiming pose. Rotations are
# about the skeleton's X axis (the shoulder line), so the down arms swing
# forward to eye level.
func _apply_weapon(skel: Skeleton3D) -> void:
if ads < 0.01:
return
# Raise about X (down arm -> forward) and swing about Y so each arm comes
# IN toward centre-front instead of splaying out to the side. Right arm
# is on -X so it swings +Y; the left mirrors it.
var lift := Quaternion(Vector3(1, 0, 0), -ADS_LIFT * ads)
var swing := ADS_SWING * ads
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), Quaternion(Vector3(0, 1, 0), swing) * lift)
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), Quaternion(Vector3(0, 1, 0), -swing) * lift)
var bend := Quaternion(Vector3(1, 0, 0), -ADS_FOREARM * ads)
_add_space(skel, _idx.get("DEF-forearm.R", -1), bend)
_add_space(skel, _idx.get("DEF-forearm.L", -1), bend)
# Compose a skeleton-space rotation onto a bone's animated local pose.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
if idx < 0:
return
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
var local := b.inverse() * q_space * b
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)
+1
View File
@@ -317,6 +317,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_movement_state") client_rep_config.add_property(":synced_movement_state")
client_rep_config.add_property(":synced_movement_speed") client_rep_config.add_property(":synced_movement_speed")
client_rep_config.add_property(":synced_is_crouching") client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_grapple_point")
client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_is_grapple_shooting")
client_rep_config.add_property(":synced_skin_id") client_rep_config.add_property(":synced_skin_id")
+32
View File
@@ -73,6 +73,7 @@ var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false var synced_is_crouching: bool = false
var synced_position: Vector3 = Vector3.ZERO var synced_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false
@export var synced_skin_id: String = "" @export var synced_skin_id: String = ""
@export var synced_weapon_path: String = "" @export var synced_weapon_path: String = ""
@@ -864,11 +865,17 @@ func _physics_process(_delta: float) -> void:
else: else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0) 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 # Drive the visual model (skinned GLB or procedural) from local state
var visual = get_visual_model() var visual = get_visual_model()
if visual: if visual:
var h_speed = Vector2(velocity.x, velocity.z).length() var h_speed = Vector2(velocity.x, velocity.z).length()
visual.update_state(sm.current_state, h_speed, sm.input_crouch) 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 # Publish state for remote peers
synced_movement_state = sm.current_state synced_movement_state = sm.current_state
@@ -878,6 +885,28 @@ func _physics_process(_delta: float) -> void:
synced_velocity = velocity 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: func _on_movement_event(ev: String, data: Dictionary) -> void:
if ev == "chain_updated": if ev == "chain_updated":
chain_updated.emit(data.count, data.bonus) chain_updated.emit(data.count, data.bonus)
@@ -914,6 +943,9 @@ func _process(delta: float) -> void:
var visual = get_visual_model() var visual = get_visual_model()
if visual: if visual:
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching) 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 # Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""): if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path) visual.set_weapon(synced_weapon_path)
+1
View File
@@ -117,6 +117,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_movement_state") client_rep_config.add_property(":synced_movement_state")
client_rep_config.add_property(":synced_movement_speed") client_rep_config.add_property(":synced_movement_speed")
client_rep_config.add_property(":synced_is_crouching") client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_grapple_point")
client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_is_grapple_shooting")
client_rep_config.add_property(":synced_skin_id") client_rep_config.add_property(":synced_skin_id")
@@ -81,6 +81,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_movement_state") client_rep_config.add_property(":synced_movement_state")
client_rep_config.add_property(":synced_movement_speed") client_rep_config.add_property(":synced_movement_speed")
client_rep_config.add_property(":synced_is_crouching") client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_grapple_point")
client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_is_grapple_shooting")
client_rep_config.add_property(":synced_skin_id") client_rep_config.add_property(":synced_skin_id")