Feat/14 movement overhaul #20

Merged
Dotts merged 86 commits from feat/14-movement-overhaul into main 2026-07-17 10:44:23 -07:00
Showing only changes of commit 5d360d256e - Show all commits
+233 -227
View File
@@ -23,6 +23,16 @@ var _anim_debug_timer: float = 0.0
var _mesh_instance: MeshInstance3D
var _bone_cache: Dictionary = {} # bone_name -> index
## Code-driven animation system — applies bone rotations directly each frame.
## Godot 4.2.1 AnimationPlayer cannot rotate runtime-loaded GLB bones via tracks,
## so we interpolate and apply poses manually using set_bone_pose_rotation().
var _current_anim_name: String = ""
var _anim_time: float = 0.0
var _procedural_anims: Dictionary = {} # name -> {length, loop, tracks}
## Bone indices for procedural animation (cached at load time)
var _bone_indices: Dictionary = {} # bone_name -> index
func _ready() -> void:
if model_path != "":
load_model(model_path)
@@ -31,6 +41,10 @@ func load_model(path: String) -> void:
for child in get_children():
child.queue_free()
_bone_cache.clear()
_bone_indices.clear()
_procedural_anims.clear()
_current_anim_name = ""
_anim_time = 0.0
print("SkinnedPlayerModel: loading %s via GLTFDocument" % path)
@@ -77,6 +91,7 @@ func load_model(path: String) -> void:
# Cache bone indices for fast lookups in procedural animations
for i in range(skeleton.get_bone_count()):
_bone_cache[skeleton.get_bone_name(i)] = i
_bone_indices[skeleton.get_bone_name(i)] = i
else:
print("SkinnedPlayerModel: WARNING - no skeleton found")
@@ -142,29 +157,7 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
## Creates procedural Walk, Run, Jump, and Idle animations.
## Replaces GLB animations that were stripped by Blender's GLTF exporter
## (tracks exist but contain near-zero motion — model appears to T-pose).
## These procedural animations use rotation tracks for visible skeletal motion.
# Bone names match the GLB skeleton exactly
var hips: String = _get_bone_name("Hips")
var spine: String = _get_bone_name("Spine")
var chest: String = _get_bone_name("Chest")
var head: String = _get_bone_name("Head")
var left_upper_arm: String = _get_bone_name("LeftUpperArm")
var left_lower_arm: String = _get_bone_name("LeftLowerArm")
var _left_hand: String = _get_bone_name("LeftHand")
var right_upper_arm: String = _get_bone_name("RightUpperArm")
var right_lower_arm: String = _get_bone_name("RightLowerArm")
var _right_hand: String = _get_bone_name("RightHand")
var left_upper_leg: String = _get_bone_name("LeftUpperLeg")
var left_lower_leg: String = _get_bone_name("LeftLowerLeg")
var left_foot: String = _get_bone_name("LeftFoot")
var right_upper_leg: String = _get_bone_name("RightUpperLeg")
var right_lower_leg: String = _get_bone_name("RightLowerLeg")
var right_foot: String = _get_bone_name("RightFoot")
# Use "MikuRig/Skeleton3D:" prefix — AnimationPlayer is sibling of MikuRig,
# and Skeleton3D is a child of MikuRig. The GLB's own animations use this path format.
var prefix: String = "MikuRig/Skeleton3D:"
## Uses code-driven bone rotation applied directly via set_bone_pose_rotation().
# Always replace animations that have near-zero motion (Blender GLTF track stripping)
var broken_walk := _is_animation_broken(ap, "Walk")
@@ -176,103 +169,23 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
if broken_walk:
print("SkinnedPlayerModel: Walk animation has near-zero motion — replacing with procedural")
var anim = _create_locomotion_anim(
prefix, true,
left_upper_leg, left_lower_leg, left_foot,
right_upper_leg, right_lower_leg, right_foot,
left_upper_arm, left_lower_arm,
right_upper_arm, right_lower_arm,
spine, hips
)
_add_animation_to_player(ap, "Walk", anim)
_procedural_anims["Walk"] = _create_locomotion_data(true)
_add_animation_to_player(ap, "Walk", Animation.new()) # placeholder for AP list
if broken_run:
print("SkinnedPlayerModel: Run animation has near-zero motion — replacing with procedural")
var anim = _create_locomotion_anim(
prefix, true,
left_upper_leg, left_lower_leg, left_foot,
right_upper_leg, right_lower_leg, right_foot,
left_upper_arm, left_lower_arm,
right_upper_arm, right_lower_arm,
spine, hips, 1.8 # faster cycle for run
)
_add_animation_to_player(ap, "Run", anim)
_procedural_anims["Run"] = _create_locomotion_data(true, 1.8)
_add_animation_to_player(ap, "Run", Animation.new())
if broken_jump:
print("SkinnedPlayerModel: Jump animation has near-zero motion — replacing with procedural")
var anim = Animation.new()
anim.length = 1.0
anim.loop_mode = Animation.LOOP_LINEAR
# Arms raised, knees slightly bent, brief hop
_add_rotation_track(anim, prefix + left_upper_arm, [
{"time": 0.0, "value": Vector3(-0.8, 0, 0.3)},
{"time": 0.5, "value": Vector3(-1.2, 0, 0.5)},
{"time": 1.0, "value": Vector3(-0.8, 0, 0.3)}
])
_add_rotation_track(anim, prefix + right_upper_arm, [
{"time": 0.0, "value": Vector3(-0.8, 0, -0.3)},
{"time": 0.5, "value": Vector3(-1.2, 0, -0.5)},
{"time": 1.0, "value": Vector3(-0.8, 0, -0.3)}
])
_add_rotation_track(anim, prefix + left_lower_arm, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.4, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + right_lower_arm, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.4, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + left_upper_leg, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.3, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + right_upper_leg, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(-0.3, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + spine, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 0.5, "value": Vector3(0.1, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0, 0)}
])
_add_animation_to_player(ap, "Jump", anim)
_procedural_anims["Jump"] = _create_jump_data()
_add_animation_to_player(ap, "Jump", Animation.new())
if broken_idle:
print("SkinnedPlayerModel: Idle animation has near-zero motion — replacing with procedural")
var anim = Animation.new()
anim.length = 2.0
anim.loop_mode = Animation.LOOP_LINEAR
# Gentle breathing/sway — use rotation tracks for visible motion
_add_rotation_track(anim, prefix + spine, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 1.0, "value": Vector3(0.02, 0, 0)},
{"time": 2.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + head, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 1.0, "value": Vector3(0.03, 0.02, 0)},
{"time": 2.0, "value": Vector3(0, 0, 0)}
])
_add_rotation_track(anim, prefix + left_upper_arm, [
{"time": 0.0, "value": Vector3(0, 0, 0.05)},
{"time": 1.0, "value": Vector3(0.02, 0, 0.08)},
{"time": 2.0, "value": Vector3(0, 0, 0.05)}
])
_add_rotation_track(anim, prefix + right_upper_arm, [
{"time": 0.0, "value": Vector3(0, 0, -0.05)},
{"time": 1.0, "value": Vector3(0.02, 0, -0.08)},
{"time": 2.0, "value": Vector3(0, 0, -0.05)}
])
_add_position_track(anim, prefix + hips, [
{"time": 0.0, "value": Vector3(0, 0, 0)},
{"time": 1.0, "value": Vector3(0, 0.005, 0)},
{"time": 2.0, "value": Vector3(0, 0, 0)}
])
_add_animation_to_player(ap, "Idle", anim)
_procedural_anims["Idle"] = _create_idle_data()
_add_animation_to_player(ap, "Idle", Animation.new())
func _is_animation_broken(ap: AnimationPlayer, anim_name: String) -> bool:
## Returns true if the animation has near-zero motion (Blender GLTF track stripping).
@@ -310,107 +223,159 @@ func _get_bone_name(bone_name: String) -> String:
return bone_name
return bone_name
func _create_locomotion_anim(
prefix: String,
loop: bool,
l_leg_upper: String, l_leg_lower: String, _l_foot: String,
r_leg_upper: String, r_leg_lower: String, _r_foot: String,
l_arm_upper: String, l_arm_lower: String,
r_arm_upper: String, r_arm_lower: String,
spine: String, hips: String,
speed_mult: float = 1.0
) -> Animation:
var anim = Animation.new()
var cycle: float = 1.0 / locomotion_cycle_speed * speed_mult
anim.length = cycle
anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
# ═══════════════════════════════════════════════════════════════════════════════
# CODE-DRIVEN ANIMATION SYSTEM
# Godot 4.2.1 AnimationPlayer cannot rotate bones via ":rotation" track paths
# on runtime-loaded GLB skeletons. We apply rotations directly each frame.
# ═══════════════════════════════════════════════════════════════════════════════
func _create_locomotion_data(loop: bool, speed_mult: float = 1.0) -> Dictionary:
## Creates a walk/run animation data structure.
## Returns: {length, loop, tracks: [{bone, keyframes: [{t, rot: Vector3}]}]}
var cycle: float = 1.0 / locomotion_cycle_speed * speed_mult
var t1: float = 0.0
var t2: float = cycle * 0.25
var t3: float = cycle * 0.5
var t4: float = cycle * 0.75
var t5: float = cycle
var tracks: Array = []
# Legs: opposite phase walk cycle
_add_bone_track_catmull(anim, prefix + l_leg_upper, [t1, t2, t3, t4, t5], [
-0.6, -0.1, 0.6, -0.1, -0.6 # swing forward/back
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_bone_track_catmull(anim, prefix + l_leg_lower, [t1, t2, t3, t4, t5], [
0.1, 0.5, 0.8, 0.3, 0.1 # bend on back swing
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_bone_track_catmull(anim, prefix + r_leg_upper, [t1, t2, t3, t4, t5], [
0.6, -0.1, -0.6, -0.1, 0.6 # opposite phase
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_bone_track_catmull(anim, prefix + r_leg_lower, [t1, t2, t3, t4, t5], [
0.3, 0.1, 0.1, 0.5, 0.3 # bend on back swing
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_catmull_rot_track(tracks, "LeftUpperLeg", [t1, t2, t3, t4, t5],
[-0.6, -0.1, 0.6, -0.1, -0.6], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
_add_catmull_rot_track(tracks, "LeftLowerLeg", [t1, t2, t3, t4, t5],
[0.1, 0.5, 0.8, 0.3, 0.1], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
_add_catmull_rot_track(tracks, "RightUpperLeg", [t1, t2, t3, t4, t5],
[0.6, -0.1, -0.6, -0.1, 0.6], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
_add_catmull_rot_track(tracks, "RightLowerLeg", [t1, t2, t3, t4, t5],
[0.3, 0.1, 0.1, 0.5, 0.3], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
# Arms: opposite to legs
_add_bone_track_catmull(anim, prefix + l_arm_upper, [t1, t2, t3, t4, t5], [
0.5, 0.0, -0.5, 0.0, 0.5
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_bone_track_catmull(anim, prefix + l_arm_lower, [t1, t2, t3, t4, t5], [
-0.8, -0.3, -0.2, -0.5, -0.8
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_bone_track_catmull(anim, prefix + r_arm_upper, [t1, t2, t3, t4, t5], [
-0.5, 0.0, 0.5, 0.0, -0.5
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_bone_track_catmull(anim, prefix + r_arm_lower, [t1, t2, t3, t4, t5], [
-0.2, -0.5, -0.8, -0.3, -0.2
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_catmull_rot_track(tracks, "LeftUpperArm", [t1, t2, t3, t4, t5],
[0.5, 0.0, -0.5, 0.0, 0.5], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
_add_catmull_rot_track(tracks, "LeftLowerArm", [t1, t2, t3, t4, t5],
[-0.8, -0.3, -0.2, -0.5, -0.8], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
_add_catmull_rot_track(tracks, "RightUpperArm", [t1, t2, t3, t4, t5],
[-0.5, 0.0, 0.5, 0.0, -0.5], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
_add_catmull_rot_track(tracks, "RightLowerArm", [t1, t2, t3, t4, t5],
[-0.2, -0.5, -0.8, -0.3, -0.2], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
# Spine twist
_add_bone_track_catmull(anim, prefix + spine, [t1, t2, t3, t4, t5], [
0.0, 0.05, 0.0, -0.05, 0.0
], [0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
_add_catmull_rot_track(tracks, "Spine", [t1, t2, t3, t4, t5],
[0.0, 0.05, 0.0, -0.05, 0.0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0])
# Hips bounce position offset (not rotation)
_add_position_track_catmull(anim, prefix + hips, [t1, t2, t3, t4, t5], [
0.0, 0.0, 0.0, 0.0, 0.0
], [0.0, 0.02, 0.0, 0.02, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0])
# Hips bounce (position offset)
_add_catmull_pos_track(tracks, "Hips", [t1, t2, t3, t4, t5],
[0, 0, 0, 0, 0], [0, 0.02, 0, 0.02, 0], [0, 0, 0, 0, 0])
return anim
return {"length": cycle, "loop": loop, "tracks": tracks}
func _add_bone_track_catmull(anim: Animation, bone_path: String, times: Array, x_vals: Array, y_vals: Array, z_vals: Array) -> void:
## Adds a rotation track with Catmull-Rom-style keyframes from separate axis arrays.
## Uses ":rotation" suffix on the path to target bone rotation.
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path + ":rotation")
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)
func _create_jump_data() -> Dictionary:
## Creates jump animation data.
var tracks: Array = []
var t1: float = 0.0
var t2: float = 0.5
var t3: float = 1.0
_add_keyframe_rot_track(tracks, "LeftUpperArm", [
{"t": t1, "rot": Vector3(-0.8, 0, 0.3)},
{"t": t2, "rot": Vector3(-1.2, 0, 0.5)},
{"t": t3, "rot": Vector3(-0.8, 0, 0.3)}
])
_add_keyframe_rot_track(tracks, "RightUpperArm", [
{"t": t1, "rot": Vector3(-0.8, 0, -0.3)},
{"t": t2, "rot": Vector3(-1.2, 0, -0.5)},
{"t": t3, "rot": Vector3(-0.8, 0, -0.3)}
])
_add_keyframe_rot_track(tracks, "LeftLowerArm", [
{"t": t1, "rot": Vector3(0, 0, 0)},
{"t": t2, "rot": Vector3(-0.4, 0, 0)},
{"t": t3, "rot": Vector3(0, 0, 0)}
])
_add_keyframe_rot_track(tracks, "RightLowerArm", [
{"t": t1, "rot": Vector3(0, 0, 0)},
{"t": t2, "rot": Vector3(-0.4, 0, 0)},
{"t": t3, "rot": Vector3(0, 0, 0)}
])
_add_keyframe_rot_track(tracks, "LeftUpperLeg", [
{"t": t1, "rot": Vector3(0, 0, 0)},
{"t": t2, "rot": Vector3(-0.3, 0, 0)},
{"t": t3, "rot": Vector3(0, 0, 0)}
])
_add_keyframe_rot_track(tracks, "RightUpperLeg", [
{"t": t1, "rot": Vector3(0, 0, 0)},
{"t": t2, "rot": Vector3(-0.3, 0, 0)},
{"t": t3, "rot": Vector3(0, 0, 0)}
])
_add_keyframe_rot_track(tracks, "Spine", [
{"t": t1, "rot": Vector3(0, 0, 0)},
{"t": t2, "rot": Vector3(0.1, 0, 0)},
{"t": t3, "rot": Vector3(0, 0, 0)}
])
return {"length": 1.0, "loop": true, "tracks": tracks}
func _create_idle_data() -> Dictionary:
## Creates idle animation data.
var tracks: Array = []
var t1: float = 0.0
var t2: float = 1.0
var t3: float = 2.0
_add_keyframe_rot_track(tracks, "Spine", [
{"t": t1, "rot": Vector3(0, 0, 0)},
{"t": t2, "rot": Vector3(0.02, 0, 0)},
{"t": t3, "rot": Vector3(0, 0, 0)}
])
_add_keyframe_rot_track(tracks, "Head", [
{"t": t1, "rot": Vector3(0, 0, 0)},
{"t": t2, "rot": Vector3(0.03, 0.02, 0)},
{"t": t3, "rot": Vector3(0, 0, 0)}
])
_add_keyframe_rot_track(tracks, "LeftUpperArm", [
{"t": t1, "rot": Vector3(0, 0, 0.05)},
{"t": t2, "rot": Vector3(0.02, 0, 0.08)},
{"t": t3, "rot": Vector3(0, 0, 0.05)}
])
_add_keyframe_rot_track(tracks, "RightUpperArm", [
{"t": t1, "rot": Vector3(0, 0, -0.05)},
{"t": t2, "rot": Vector3(0.02, 0, -0.08)},
{"t": t3, "rot": Vector3(0, 0, -0.05)}
])
_add_keyframe_pos_track(tracks, "Hips", [
{"t": t1, "pos": Vector3(0, 0, 0)},
{"t": t2, "pos": Vector3(0, 0.005, 0)},
{"t": t3, "pos": Vector3(0, 0, 0)}
])
return {"length": 2.0, "loop": true, "tracks": tracks}
func _add_catmull_rot_track(tracks: Array, bone_name: String, times: Array, x: Array, y: Array, z: Array) -> void:
var kf: Array = []
for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], Vector3(x_vals[i], y_vals[i], z_vals[i]))
kf.append({"t": times[i], "rot": Vector3(x[i], y[i], z[i])})
tracks.append({"bone": bone_name, "keyframes": kf})
func _add_rotation_track(anim: Animation, bone_path: String, keyframes: Array) -> void:
## Adds a rotation track from an array of {time, value} dictionaries.
## Uses ":rotation" suffix on the path to target bone rotation.
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path + ":rotation")
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)
for kf in keyframes:
anim.track_insert_key(track_idx, kf["time"], kf["value"])
func _add_position_track_catmull(anim: Animation, bone_path: String, times: Array, x_vals: Array, y_vals: Array, z_vals: Array) -> void:
## Adds a position/value track with Catmull-Rom-style keyframes from separate axis arrays.
## No ":rotation" suffix — targets bone position directly.
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)
func _add_catmull_pos_track(tracks: Array, bone_name: String, times: Array, x: Array, y: Array, z: Array) -> void:
var kf: Array = []
for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], Vector3(x_vals[i], y_vals[i], z_vals[i]))
kf.append({"t": times[i], "pos": Vector3(x[i], y[i], z[i])})
tracks.append({"bone": bone_name, "keyframes": kf})
func _add_keyframe_rot_track(tracks: Array, bone_name: String, keyframes: Array) -> void:
var kf: Array = []
for k in keyframes:
kf.append({"t": k["t"], "rot": k["rot"]})
tracks.append({"bone": bone_name, "keyframes": kf})
func _add_keyframe_pos_track(tracks: Array, bone_name: String, keyframes: Array) -> void:
var kf: Array = []
for k in keyframes:
kf.append({"t": k["t"], "pos": k["pos"]})
tracks.append({"bone": bone_name, "keyframes": kf})
func _add_position_track(anim: Animation, bone_path: String, keyframes: Array) -> void:
## Adds a position/value track from an array of {time, value} dictionaries.
var track_idx = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, bone_path)
anim.track_set_interpolation_type(track_idx, Animation.INTERPOLATION_LINEAR)
for kf in keyframes:
anim.track_insert_key(track_idx, kf["time"], kf["value"])
func _add_animation_to_player(ap: AnimationPlayer, anim_name: String, anim: Animation) -> void:
## Adds an animation to the AnimationPlayer via its default library.
@@ -489,27 +454,9 @@ func _warn_missing(anim_name: String) -> void:
print("SkinnedPlayerModel: WARNING - '%s' not available (using fallback)" % anim_name)
func _process(delta: float) -> void:
if not animation_player:
if not skeleton:
return
# Debug output every 2 seconds (rate-limited to avoid log spam)
_anim_debug_timer += delta
if _anim_debug_timer > 2.0:
_anim_debug_timer = 0.0
if animation_player.is_playing():
print("SkinnedPlayerModel: playing '%s' (pos: %.2f)" % [animation_player.current_animation, animation_player.current_animation_position])
elif _last_played == "":
print("SkinnedPlayerModel: NOT playing")
# Force skeleton skin update EVERY frame, deferred to ensure it runs
# AFTER the AnimationPlayer has advanced its poses this frame.
# GLTF runtime loaded models need this to emit bone_pose_changed,
# which triggers the MeshInstance to re-compute its skin vertex buffers.
# Without deferral, this runs before AnimationPlayer._process() (due to
# tree order), so the MeshInstance would render with stale poses.
if skeleton:
call_deferred("_update_skin")
# Get movement state from parent PlayerMovementController
var parent = get_parent()
if not parent:
@@ -550,19 +497,78 @@ func _process(delta: float) -> void:
_:
target_anim = "Idle"
if target_anim != _last_played:
if animation_player.has_animation(target_anim):
play_animation(target_anim)
_last_played = target_anim
# Switch animation if needed
if target_anim != _current_anim_name:
_current_anim_name = target_anim
_anim_time = 0.0
# Also play on AP for debug output
if animation_player and animation_player.has_animation(target_anim):
animation_player.play(target_anim)
# Advance animation time
if _procedural_anims.has(_current_anim_name):
var data = _procedural_anims[_current_anim_name]
var length: float = data["length"]
var loop: bool = data["loop"]
_anim_time += delta
if loop:
_anim_time = fmod(_anim_time, length)
else:
_warn_missing(target_anim)
# Try fallback to Idle — should always exist
if animation_player.has_animation(target_anim.replace(target_anim, "Idle")):
play_animation("Idle")
_last_played = "Idle"
elif _last_played == "" or not animation_player.is_playing():
# Last resort: play first available animation
var anim_list = animation_player.get_animation_list()
if anim_list.size() > 0:
play_animation(anim_list[0])
_last_played = anim_list[0]
_anim_time = min(_anim_time, length)
# Apply procedural animation to skeleton
_apply_procedural_animation(data, _anim_time)
elif animation_player:
# For non-procedural anims (Death, Crouch from GLB), just update skin
call_deferred("_update_skin")
# Debug output
_anim_debug_timer += delta
if _anim_debug_timer > 2.0:
_anim_debug_timer = 0.0
if animation_player.is_playing():
print("SkinnedPlayerModel: playing '%s' (pos: %.2f)" % [animation_player.current_animation, animation_player.current_animation_position])
# Force skeleton skin update every frame (deferred to run after pose updates)
if skeleton:
call_deferred("_update_skin")
func _apply_procedural_animation(data: Dictionary, time: float) -> void:
## Applies procedural animation data to the skeleton at the given time.
## Uses linear interpolation between keyframe values.
if not skeleton:
return
var tracks = data["tracks"]
for track in tracks:
var bone_name = track["bone"]
var bone_idx = _bone_indices.get(bone_name, -1)
if bone_idx < 0:
continue
var keyframes = track["keyframes"]
if keyframes.size() == 0:
continue
# Find the two keyframes surrounding the current time
var prev_kf = keyframes[0]
var next_kf = keyframes[keyframes.size() - 1]
for i in range(keyframes.size()):
if keyframes[i]["t"] <= time:
prev_kf = keyframes[i]
if keyframes[i]["t"] >= time:
next_kf = keyframes[i]
break
# Interpolate between keyframes
var segment_length: float = next_kf["t"] - prev_kf["t"]
var t: float = 0.0
if segment_length > 0.001:
t = (time - prev_kf["t"]) / segment_length
t = clamp(t, 0.0, 1.0)
if prev_kf.has("rot") and next_kf.has("rot"):
var rot: Vector3 = lerp(prev_kf["rot"], next_kf["rot"], t)
# Convert euler angles to Quaternion for set_bone_pose_rotation
var quat = Quaternion.from_euler(rot)
skeleton.set_bone_pose_rotation(bone_idx, quat)
elif prev_kf.has("pos") and next_kf.has("pos"):
var pos: Vector3 = lerp(prev_kf["pos"], next_kf["pos"], t)
skeleton.set_bone_pose_position(bone_idx, pos)