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:
co-authored by
Claude Fable 5
parent
9939e7e524
commit
29fdca3565
@@ -59,6 +59,18 @@ var _current_clip: String = ""
|
||||
var _weapon_attachment: BoneAttachment3D
|
||||
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:
|
||||
if model_path != "":
|
||||
@@ -90,6 +102,9 @@ func load_model(path: String) -> void:
|
||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||
else:
|
||||
_ensure_meshes_bound(scene)
|
||||
_pose_mod = ShooterPoseModifier.new()
|
||||
_pose_mod.name = "ShooterPose"
|
||||
skeleton.add_child(_pose_mod)
|
||||
if animation_player:
|
||||
_index_animations()
|
||||
else:
|
||||
@@ -187,7 +202,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
elif speed > 0.5:
|
||||
clip = "Walk"
|
||||
"air":
|
||||
clip = "Fall"
|
||||
# Composed, weapon-ready airborne — not a flailing fall.
|
||||
clip = "Jump"
|
||||
"slide":
|
||||
clip = "Slide"
|
||||
"wall_run":
|
||||
@@ -203,6 +219,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
|
||||
_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.
|
||||
match clip:
|
||||
"Walk":
|
||||
@@ -213,6 +233,31 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
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:
|
||||
if not animation_player or not _resolved_clips.has(canonical):
|
||||
return
|
||||
@@ -296,3 +341,104 @@ func _set_shadows_recursive(node: Node) -> void:
|
||||
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||
for child in node.get_children():
|
||||
_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)
|
||||
|
||||
@@ -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_speed")
|
||||
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_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_speed")
|
||||
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_is_grapple_shooting")
|
||||
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_speed")
|
||||
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_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
Reference in New Issue
Block a user