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
3 changed files with 227 additions and 71 deletions
Showing only changes of commit 50de6df450 - Show all commits
+215 -59
View File
@@ -5,13 +5,19 @@ class_name SkinnedPlayerModel
## Replaces the procedural HumanoidModel for characters with custom skins.
@export var model_path: String = ""
## Scale factor applied to the loaded scene. For the Miku GLB, 1.0 is correct (~1.6m).
## For models exported in centimeters (Mixamo/Blender default), set to 0.01.
@export var scale_factor: float = 1.0
@export var first_person_mode: bool = false # Hide head/torso for FPS view
## Bones per second for locomotion animations created at runtime.
@export var locomotion_cycle_speed: float = 4.0
var skeleton: Skeleton3D
var animation_player: AnimationPlayer
var _anim_debug_timer: float = 0.0
var _mesh_instance: MeshInstance3D
var _bone_cache: Dictionary = {} # bone_name -> index
func _ready() -> void:
if model_path != "":
@@ -20,6 +26,7 @@ func _ready() -> void:
func load_model(path: String) -> void:
for child in get_children():
child.queue_free()
_bone_cache.clear()
print("SkinnedPlayerModel: loading %s via GLTFDocument" % path)
@@ -49,9 +56,10 @@ func load_model(path: String) -> void:
add_child(scene)
print("SkinnedPlayerModel: scene added: %s" % scene.name)
if scale_factor != 1.0:
# Model scale — the Miku GLB is already exported at meter scale (~1.6m tall).
# For models exported in centimeters (common from Mixamo/Blender), set scale_factor to 0.01.
scene.scale = Vector3(scale_factor, scale_factor, scale_factor)
print("SkinnedPlayerModel: applied scale %.2f" % scale_factor)
print("SkinnedPlayerModel: applied scale %.4f" % scale_factor)
# Find mesh instance for first-person mode
_mesh_instance = scene.find_child("Tda Miku for fbx_mesh", true, false)
@@ -71,6 +79,9 @@ func load_model(path: String) -> void:
skeleton = _find_skeleton(scene)
if skeleton:
print("SkinnedPlayerModel: found skeleton '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()])
# 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
else:
print("SkinnedPlayerModel: WARNING - no skeleton found")
@@ -93,73 +104,187 @@ func load_model(path: String) -> void:
animation_player.play(anim_list[0])
print("SkinnedPlayerModel: playing '%s'" % anim_list[0])
# If the loaded animations are too subtle (only 1 track), create better ones
if animation_player.has_animation("Idle"):
var idle_anim = animation_player.get_animation("Idle")
if idle_anim.get_track_count() <= 1:
print("SkinnedPlayerModel: Idle has too few tracks, creating a proper one")
_create_idle_animation(animation_player)
animation_player.play("Idle")
print("SkinnedPlayerModel: playing newly created Idle")
# Ensure fallback animations exist for states the movement system needs
_ensure_locomotion_animations(animation_player)
else:
print("SkinnedPlayerModel: WARNING - no AnimationPlayer found")
func _create_idle_animation(ap: AnimationPlayer) -> void:
# Create a proper idle animation with visible movement
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).
# 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:"
if not ap.has_animation("Walk"):
print("SkinnedPlayerModel: creating Walk animation")
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)
if not ap.has_animation("Run"):
print("SkinnedPlayerModel: creating Run animation")
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)
if not ap.has_animation("Jump"):
print("SkinnedPlayerModel: creating Jump animation")
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_animation_to_player(ap, "Jump", anim)
if not ap.has_animation("Idle"):
print("SkinnedPlayerModel: creating Idle animation")
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)
_add_animation_to_player(ap, "Idle", anim)
# Sway the spine (position track)
_create_bone_track(anim, "MikuRig/Skeleton3D:Spine",
[
{ "time": 0.0, "value": Vector3(0, 0.1, 0) },
{ "time": 0.5, "value": Vector3(0, -0.1, 0) },
{ "time": 1.0, "value": Vector3(0, 0.1, 0) },
{ "time": 1.5, "value": Vector3(0, -0.1, 0) },
{ "time": 2.0, "value": Vector3(0, 0.1, 0) },
])
func _get_bone_name(name: String) -> String:
## Returns the bone name as stored in the skeleton (uses cache).
if _bone_cache.has(name):
return name
return name
# Sway left arm
_create_bone_track(anim, "MikuRig/Skeleton3D:LeftUpperArm",
[
{ "time": 0.0, "value": Vector3(0, 0, 0.2) },
{ "time": 1.0, "value": Vector3(0, 0, 0.4) },
{ "time": 2.0, "value": Vector3(0, 0, 0.2) },
])
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
# Sway right arm
_create_bone_track(anim, "MikuRig/Skeleton3D:RightUpperArm",
[
{ "time": 0.0, "value": Vector3(0, 0, -0.2) },
{ "time": 1.0, "value": Vector3(0, 0, -0.4) },
{ "time": 2.0, "value": Vector3(0, 0, -0.2) },
])
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
# 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])
# 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])
# 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])
# Hips bounce
_create_bone_track(anim, "MikuRig/Skeleton3D:Hips",
[
{ "time": 0.0, "value": Vector3(0, 0, 0) },
{ "time": 0.5, "value": Vector3(0, 0.03, 0) },
{ "time": 1.0, "value": Vector3(0, 0, 0) },
{ "time": 1.5, "value": Vector3(0, 0.03, 0) },
{ "time": 2.0, "value": Vector3(0, 0, 0) },
])
_add_bone_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])
# Add animation to the player via library
var lib = ap.get_animation_library("")
lib.add_animation("Idle", anim)
print("SkinnedPlayerModel: created Idle animation with %d tracks" % anim.get_track_count())
return anim
func _create_bone_track(anim: Animation, bone_path: String, keyframes: Array) -> void:
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.
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.
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:
var time = kf["time"]
var value = kf["value"]
anim.track_insert_key(track_idx, time, value)
anim.track_insert_key(track_idx, kf["time"], kf["value"])
func _add_animation_to_player(ap: AnimationPlayer, name: String, anim: Animation) -> void:
## Adds an animation to the AnimationPlayer via its default library.
var lib_name: String = ""
if not ap.has_animation_library(lib_name):
ap.add_animation_library(lib_name, AnimationLibrary.new())
var lib = ap.get_animation_library(lib_name)
lib.add_animation(name, anim)
print("SkinnedPlayerModel: added '%s' (%d tracks)" % [name, anim.get_track_count()])
func _setup_first_person() -> void:
# In first person, hide the head and upper body so only arms/hands/legs show
@@ -204,26 +329,41 @@ func _find_animation_player(node: Node) -> AnimationPlayer:
return null
func play_animation(anim_name: String) -> void:
"""Play an animation by name. Silently skips if the animation doesn't exist."""
if animation_player and animation_player.has_animation(anim_name):
if animation_player.current_animation != anim_name or not animation_player.is_playing():
animation_player.play(anim_name)
else:
print("SkinnedPlayerModel: animation '%s' not found" % anim_name)
return
# Animation missing — already covered by _ensure_locomotion_animations()
var _last_played: String = ""
var _missing_anim_warned: Array = []
func _warn_missing(anim_name: String) -> void:
## Warn once per missing animation, not every frame.
if _missing_anim_warned.has(anim_name):
return
_missing_anim_warned.append(anim_name)
print("SkinnedPlayerModel: WARNING - '%s' not available (using fallback)" % anim_name)
func _process(delta: float) -> void:
if not animation_player:
return
# Debug output every 2 seconds
# 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])
else:
elif _last_played == "":
print("SkinnedPlayerModel: NOT playing")
# Get movement state
var sm = get_parent().get_node_or_null("MovementStateMachine")
# Get movement state from parent PlayerMovementController
var parent = get_parent()
if not parent:
return
var sm = parent.get_node_or_null("MovementStateMachine")
if not sm:
return
@@ -234,7 +374,9 @@ func _process(delta: float) -> void:
speed = Vector2(vel.x, vel.z).length()
var is_crouching = sm.input_crouch if sm else false
var is_dead = get_parent().is_dead if get_parent() and get_parent().has_method("get") else false
var is_dead: bool = false
if parent and "is_dead" in parent:
is_dead = parent.is_dead
var target_anim = "Idle"
if is_dead:
target_anim = "Death"
@@ -257,5 +399,19 @@ func _process(delta: float) -> void:
_:
target_anim = "Idle"
if animation_player.current_animation != target_anim or not animation_player.is_playing():
if target_anim != _last_played:
if animation_player.has_animation(target_anim):
play_animation(target_anim)
_last_played = target_anim
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]
+1 -1
View File
@@ -432,7 +432,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
# Miku: use the rigged GLB model with its own armature
var skinned = load("res://characters/skinned_player_model.gd").new()
skinned.name = "SkinnedModel"
skinned.model_path = "res://assets/characters/skins/miku_rigged_final.glb"
skinned.model_path = "res://assets/characters/skins/miku_rigged_animated.glb"
skinned.scale_factor = 1.0 # Already scaled to 1.8m in Blender
skinned.position = Vector3.ZERO # Model origin = player origin
player.add_child(skinned)
+1 -1
View File
@@ -147,7 +147,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
# Local player: use skinned Miku model with animations
var skinned = load("res://characters/skinned_player_model.gd").new()
skinned.name = "SkinnedModel"
skinned.model_path = "res://assets/characters/skins/miku_rigged_final.glb"
skinned.model_path = "res://assets/characters/skins/miku_rigged_animated.glb"
skinned.position = Vector3(0, 0.0, 0)
skinned.first_person_mode = true
player.add_child(skinned)