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 _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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user