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 86a13bc782 - Show all commits
+135 -29
View File
@@ -139,14 +139,15 @@ func load_model(path: String) -> void:
print("SkinnedPlayerModel: WARNING - no AnimationPlayer found")
func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
## Creates procedural Walk, Run, Jump, and Idle animations if missing from the GLB.
## These use the bone names discovered in the imported skeleton.
## Track paths are relative to the AnimationPlayer's parent (MikuRig node).
## 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 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")
@@ -165,8 +166,16 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
# and Skeleton3D is a child of MikuRig. The GLB's own animations use this path format.
var prefix: String = "MikuRig/Skeleton3D:"
if not ap.has_animation("Walk"):
print("SkinnedPlayerModel: creating Walk animation")
# Always replace animations that have near-zero motion (Blender GLTF track stripping)
var broken_walk := _is_animation_broken(ap, "Walk")
var broken_run := _is_animation_broken(ap, "Run")
var broken_jump := _is_animation_broken(ap, "Jump")
var broken_idle := _is_animation_broken(ap, "Idle")
if not broken_idle:
broken_idle = _is_animation_broken(ap, "idle")
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,
@@ -177,8 +186,8 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
)
_add_animation_to_player(ap, "Walk", anim)
if not ap.has_animation("Run"):
print("SkinnedPlayerModel: creating Run animation")
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,
@@ -189,34 +198,112 @@ func _ensure_locomotion_animations(ap: AnimationPlayer) -> void:
)
_add_animation_to_player(ap, "Run", anim)
if not ap.has_animation("Jump"):
print("SkinnedPlayerModel: creating Jump animation")
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_bone_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) }], prefix)
_add_bone_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) }], prefix)
_add_bone_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) }], prefix)
_add_bone_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) }], prefix)
_add_bone_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) }], prefix)
_add_bone_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) }], prefix)
_add_bone_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) }], prefix)
_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)
if not ap.has_animation("Idle"):
print("SkinnedPlayerModel: creating Idle animation")
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
_add_bone_track(anim, prefix + spine, [{ "time": 0.0, "value": Vector3(0, 0.005, 0) }, { "time": 1.0, "value": Vector3(0, -0.005, 0) }, { "time": 2.0, "value": Vector3(0, 0.005, 0) }], prefix)
_add_bone_track(anim, prefix + head, [{ "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) }], prefix)
_add_bone_track(anim, prefix + left_upper_arm, [{ "time": 0.0, "value": Vector3(0, 0, 0.1) }, { "time": 1.0, "value": Vector3(0, 0, 0.12) }, { "time": 2.0, "value": Vector3(0, 0, 0.1) }], prefix)
_add_bone_track(anim, prefix + right_upper_arm, [{ "time": 0.0, "value": Vector3(0, 0, -0.1) }, { "time": 1.0, "value": Vector3(0, 0, -0.12) }, { "time": 2.0, "value": Vector3(0, 0, -0.1) }], prefix)
_add_bone_track(anim, prefix + hips, [{ "time": 0.0, "value": Vector3(0, 0, 0) }, { "time": 1.0, "value": Vector3(0, 0.003, 0) }, { "time": 2.0, "value": Vector3(0, 0, 0) }], prefix)
# 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)
func _is_animation_broken(ap: AnimationPlayer, anim_name: String) -> bool:
## Returns true if the animation has near-zero motion (Blender GLTF track stripping).
## Checks the maximum delta between any two keyframe values on any track.
if not ap.has_animation(anim_name):
return true
var anim = ap.get_animation(anim_name)
if anim.get_track_count() == 0:
return true
# Check max delta across all tracks
var max_delta: float = 0.0
for t in range(anim.get_track_count()):
var key_count = anim.track_get_key_count(t)
if key_count < 2:
continue
var path = String(anim.track_get_path(t))
var is_rotation_track = path.ends_with(":rotation")
for i in range(1, key_count):
var v = anim.track_get_key_value(t, i)
if v is Vector3:
var prev_v = anim.track_get_key_value(t, i - 1)
var delta: float
if is_rotation_track:
# For rotation tracks, sum absolute euler angle differences
delta = abs(v.x - prev_v.x) + abs(v.y - prev_v.y) + abs(v.z - prev_v.z)
else:
delta = v.distance_to(prev_v)
max_delta = max(max_delta, delta)
# Threshold: rotation < 0.02 rad total (~1.1 deg) or position < 0.005 units
return max_delta < 0.02
func _get_bone_name(bone_name: String) -> String:
## Returns the bone name as stored in the skeleton (uses cache).
if _bone_cache.has(bone_name):
@@ -283,8 +370,8 @@ func _create_locomotion_anim(
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])
# Hips bounce
_add_bone_track_catmull(anim, prefix + hips, [t1, t2, t3, t4, t5], [
# 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])
@@ -292,14 +379,33 @@ func _create_locomotion_anim(
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)
for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], Vector3(x_vals[i], y_vals[i], z_vals[i]))
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)
for i in range(times.size()):
anim.track_insert_key(track_idx, times[i], Vector3(x_vals[i], y_vals[i], z_vals[i]))
func _add_bone_track(anim: Animation, bone_path: String, keyframes: Array, _prefix: String = "") -> void:
## Adds a value track from an array of {time, value} dictionaries.
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)