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 d5a8267cd2 - Show all commits
+83 -215
View File
@@ -5,26 +5,22 @@ class_name SkinnedPlayerModel
## Replaces the procedural HumanoidModel for characters with custom skins.
@export var model_path: String = ""
## Scale factor applied to the loaded scene. The current Miku GLB is ~1.2m tall;
## set to 1.25 to reach ~1.5m (fits inside the 1.8m player capsule).
## For models exported in centimeters (Mixamo/Blender default), set to 0.01.
@export var scale_factor: float = 1.25
## Vertical offset to align the model's visual feet with the player origin.
## The Miku GLB has feet at Y=0.2 in rest pose; negative offset lowers them.
@export var position_y_offset: float = -0.2
@export var first_person_mode: bool = false # Hide head/torso for FPS view
@export var first_person_mode: bool = false
## 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
## Bone indices for procedural animation (cached at load time)
var _bone_indices: Dictionary = {} # bone_name -> index
var _bone_cache: Dictionary = {}
var _bone_indices: Dictionary = {}
var _code_driven_mode: bool = false
var _current_anim: String = ""
var _anim_time: float = 0.0
var _bone_idxs: Dictionary = {}
func _ready() -> void:
if model_path != "":
@@ -35,9 +31,12 @@ func load_model(path: String) -> void:
child.queue_free()
_bone_cache.clear()
_bone_indices.clear()
_code_driven_mode = false
_anim_time = 0.0
_current_anim = ""
_bone_idxs.clear()
print("SkinnedPlayerModel: loading %s via GLTFDocument" % path)
# Read GLB file as bytes
if not FileAccess.file_exists(path):
print("SkinnedPlayerModel: file not found: %s" % path)
return
@@ -46,7 +45,6 @@ func load_model(path: String) -> void:
var bytes = file.get_buffer(file.get_length())
file.close()
# Parse with GLTFDocument (works at runtime without .import files)
var gltf = GLTFDocument.new()
var state = GLTFState.new()
var err = gltf.append_from_buffer(bytes, "", state)
@@ -63,30 +61,25 @@ func load_model(path: String) -> void:
add_child(scene)
print("SkinnedPlayerModel: scene added: %s" % scene.name)
# Apply model scale (the Miku GLB is ~1.2m; scale_factor adjusts to target height)
scene.scale = Vector3(scale_factor, scale_factor, scale_factor)
scene.position = Vector3(0, position_y_offset * scale_factor, 0)
print("SkinnedPlayerModel: applied scale %.4f, y_offset %.4f" % [scale_factor, position_y_offset])
# Find skeleton FIRST (needed for mesh binding fix below)
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
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")
# Find mesh instance for first-person mode
_mesh_instance = scene.find_child("Tda Miku for fbx_mesh", true, false)
if not _mesh_instance:
var mesh_instances = scene.find_children("*", "MeshInstance3D", true, false)
if mesh_instances.size() > 0:
_mesh_instance = mesh_instances[0]
# Fix skeleton binding
if _mesh_instance and skeleton:
_mesh_instance.skeleton = NodePath("..")
print("SkinnedPlayerModel: fixed mesh skeleton binding -> '%s'" % _mesh_instance.skeleton)
@@ -94,41 +87,29 @@ func load_model(path: String) -> void:
if first_person_mode and _mesh_instance:
_setup_first_person()
# Print tree for debugging
print("SkinnedPlayerModel: scene tree:")
_print_tree(scene, 0)
# Find AnimationPlayer
animation_player = _find_animation_player(scene)
if animation_player:
animation_player.process_mode = Node.PROCESS_MODE_ALWAYS
animation_player.active = true
print("SkinnedPlayerModel: AP process_mode=%d active=%s" % [animation_player.process_mode, animation_player.active])
var anim_list = animation_player.get_animation_list()
print("SkinnedPlayerModel: %d animations available:" % anim_list.size())
print("SkinnedPlayerModel: %d animations:" % anim_list.size())
for a_name in anim_list:
var a = animation_player.get_animation(a_name)
print(" - %s (%.2fs, %d tracks)" % [a_name, a.length, a.get_track_count()])
# Auto-play idle
if animation_player.has_animation("idle"):
animation_player.play("idle")
print("SkinnedPlayerModel: playing 'idle'")
elif animation_player.has_animation("Idle"):
if animation_player.has_animation("Idle"):
animation_player.play("Idle")
print("SkinnedPlayerModel: playing 'Idle'")
elif anim_list.size() > 0:
animation_player.play(anim_list[0])
print("SkinnedPlayerModel: playing '%s'" % anim_list[0])
# Skip the animation creation - the GLB already has proper animations
# Just verify they have real motion
_verify_animations(animation_player)
else:
print("SkinnedPlayerModel: WARNING - no AnimationPlayer found")
func _verify_animations(ap: AnimationPlayer) -> void:
## Verifies that animations have real motion (not stripped by exporter).
## If all animations are stripped, enables code-driven animation.
var has_real_anim := false
for a_name in ap.get_animation_list():
var anim = ap.get_animation(a_name)
@@ -146,71 +127,20 @@ func _verify_animations(ap: AnimationPlayer) -> void:
max_delta = max(max_delta, v.angle_to(prev))
if max_delta > 0.05:
has_real_anim = true
print(" Animation '%s' has real motion (delta=%.3f)" % [a_name, max_delta])
print(" OK: '%s' (delta=%.3f)" % [a_name, max_delta])
else:
print(" STRIPPED: '%s' (delta=%.4f)" % [a_name, max_delta])
if not has_real_anim:
print("SkinnedPlayerModel: All GLB animations are stripped, using code-driven mode")
print("SkinnedPlayerModel: All GLB anims stripped, using code-driven mode")
_enable_code_driven_anim()
func _enable_code_driven_anim() -> void:
## Enables code-driven animation when GLB animations are stripped.
_code_driven_mode = true
## GLB skeletons. Animation resources are kept for reference but the
## actual animation is done code-driven in _process().
pass
func _create_walk_anim() -> Animation:
return Animation.new()
func _create_run_anim() -> Animation:
return Animation.new()
func _create_jump_anim() -> Animation:
return Animation.new()
func _create_idle_anim() -> Animation:
return 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).
## 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
for i in range(1, key_count):
var v = anim.track_get_key_value(t, i)
var prev = anim.track_get_key_value(t, i - 1)
if v is Vector3 and prev is Vector3:
max_delta = max(max_delta, v.distance_to(prev))
elif v is Quaternion and prev is Quaternion:
max_delta = max(max_delta, v.angle_to(prev))
# Threshold: < 0.005 units of motion
return max_delta < 0.005
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):
return bone_name
return bone_name
func _setup_first_person() -> void:
# In first person, hide the head and upper body so only arms/hands/legs show
# This creates the classic FPS arms-only view
if not _mesh_instance:
return
# The mesh has multiple sub-meshes (body parts). We need to hide the ones
# that are above the chest (head, torso, skirt, etc.)
# For now, we hide the entire mesh and rely on the arms being separate
# A more sophisticated approach would hide specific bones
_mesh_instance.visible = false
print("SkinnedPlayerModel: mesh hidden for first-person (arms-only view needs separate arms model)")
func _print_tree(node: Node, depth: int) -> void:
var indent = " ".repeat(depth)
@@ -241,28 +171,10 @@ func _find_animation_player(node: Node) -> AnimationPlayer:
return result
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)
return
# Animation missing — already covered by _ensure_locomotion_animations()
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 skeleton:
return
# Get movement state from parent PlayerMovementController
var parent = get_parent()
if not parent:
return
@@ -281,7 +193,6 @@ func _process(delta: float) -> void:
if parent and "is_dead" in parent:
is_dead = parent.is_dead
# Determine target animation
var anim_name = "Idle"
if is_dead:
anim_name = "Death"
@@ -304,152 +215,109 @@ func _process(delta: float) -> void:
_:
anim_name = "Idle"
# Switch animation if needed (AP for reference timing, code for actual poses)
if animation_player and (animation_player.current_animation != anim_name or not animation_player.is_playing()):
if animation_player.has_animation(anim_name):
animation_player.play(anim_name)
# Update animation time
_anim_time += delta
_current_anim = anim_name
# Apply code-driven animation poses directly to skeleton
_apply_animation_by_code(anim_name, _anim_time)
if _code_driven_mode:
_apply_code_animation(anim_name, _anim_time)
else:
# GLB animations are applied by AP, just force skeleton update
skeleton.force_update_all_bone_transforms()
# Debug
_anim_debug_timer += delta
if _anim_debug_timer > 2.0:
_anim_debug_timer = 0.0
if animation_player and animation_player.is_playing():
print("SkinnedPlayerModel: '%s' (pos: %.2f)" % [anim_name, _anim_time])
print("SkinnedPlayerModel: '%s' t=%.1f mode=%s" % [anim_name, _anim_time, "code" if _code_driven_mode else "AP"])
# Code-driven animation: applies bone rotations directly each frame.
var _current_anim: String = ""
var _anim_time: float = 0.0
## Bone indices for animation (cached at load time)
var _bone_idxs: Dictionary = {} # bone_name -> index
## Per-bone animation rot_x values for each bone (radians offset from rest)
func _apply_animation_by_code(anim_name: String, time: float) -> void:
if not skeleton:
return
# Ensure bone indices are cached
func _apply_code_animation(anim: String, time: float) -> void:
if _bone_idxs.is_empty():
_cache_bone_indices()
# Only override bones that we have animation data for
for bone_name in _bone_idxs:
var bone_idx: int = _bone_idxs[bone_name]
var offset: Vector3 = _get_bone_anim_offset(anim_name, bone_idx, time)
var idx: int = _bone_idxs[bone_name]
var offset := _get_offset(anim, idx, time)
if offset == Vector3.ZERO:
# Reset this bone to rest (clear any previous override)
skeleton.set_bone_global_pose_override(idx, Transform3D(), 0.0, true)
continue
var rest: Transform3D = skeleton.get_bone_rest(bone_idx)
var offset_basis: Basis = Basis(Quaternion.from_euler(offset))
var new_basis: Basis = offset_basis * rest.basis
skeleton.set_bone_global_pose_override(bone_idx, Transform3D(new_basis, rest.origin), 1.0, true)
var rest: Transform3D = skeleton.get_bone_rest(idx)
var q := Quaternion.from_euler(offset)
var new_basis: Basis = Basis(q) * rest.basis
skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, rest.origin), 1.0, true)
func _cache_bone_indices() -> void:
if not skeleton:
return
_bone_idxs.clear()
for i in range(skeleton.get_bone_count()):
_bone_idxs[skeleton.get_bone_name(i)] = i
## Returns the bone rotation offset (euler radians) for a given bone at a given time.
func _get_bone_anim_offset(anim_name: String, bone_idx: int, time: float) -> Vector3:
func _get_offset(anim: String, bone_idx: int, time: float) -> Vector3:
if not skeleton:
return Vector3.ZERO
var bone_name: String = ""
for k in _bone_idxs:
if _bone_idxs[k] == bone_idx:
bone_name = k
break
if anim_name == "Walk":
return _walk_offset(bone_name, time)
elif anim_name == "Run":
return _run_offset(bone_name, time)
elif anim_name == "Jump":
return _jump_offset(bone_name, time)
elif anim_name == "Idle":
return _idle_offset(bone_name, time)
match anim:
"Walk": return _walk_offset(bone_name, time)
"Run": return _run_offset(bone_name, time)
"Jump": return _jump_offset(bone_name, time)
"Idle": return _idle_offset(bone_name, time)
"Crouch": return _crouch_offset(bone_name, time)
return Vector3.ZERO
func _walk_offset(bone_name: String, time: float) -> Vector3:
var cycle: float = 1.0 / locomotion_cycle_speed
var t: float = fmod(time, cycle) / cycle # Normalize to 0-1
var phase: float = t * 6.28318 # 2*pi
match bone_name:
"LeftUpperLeg":
return Vector3(sin(phase) * 0.6, 0, 0)
"RightUpperLeg":
return Vector3(sin(phase + 3.14159) * 0.6, 0, 0)
"LeftUpperArm":
return Vector3(sin(phase + 3.14159) * 0.4, 0, 0)
"RightUpperArm":
return Vector3(sin(phase) * 0.4, 0, 0)
"Spine":
return Vector3(0, sin(phase) * 0.1, 0)
_:
func _walk_offset(bone: String, time: float) -> Vector3:
var c: float = 1.0 / locomotion_cycle_speed
var t: float = fmod(time, c) / c * 6.28318
match bone:
"LeftUpperLeg": return Vector3(sin(t) * 0.8, 0, 0)
"RightUpperLeg": return Vector3(sin(t + 3.14159) * 0.8, 0, 0)
"LeftUpperArm": return Vector3(sin(t + 3.14159) * 0.6, 0, 0)
"RightUpperArm": return Vector3(sin(t) * 0.6, 0, 0)
"Spine": return Vector3(0, 0, sin(t) * 0.15)
"Chest": return Vector3(0, 0, sin(t) * 0.1)
"Hips": return Vector3(0, sin(t) * 0.05, 0)
return Vector3.ZERO
func _run_offset(bone_name: String, time: float) -> Vector3:
var cycle: float = 1.0 / locomotion_cycle_speed * 1.8
var t: float = fmod(time, cycle) / cycle
var phase: float = t * 6.28318
match bone_name:
"LeftUpperLeg":
return Vector3(sin(phase) * 0.6, 0, 0)
"RightUpperLeg":
return Vector3(sin(phase + 3.14159) * 0.6, 0, 0)
"LeftUpperArm":
return Vector3(sin(phase + 3.14159) * 0.4, 0, 0)
"RightUpperArm":
return Vector3(sin(phase) * 0.4, 0, 0)
_:
func _run_offset(bone: String, time: float) -> Vector3:
var c: float = 1.0 / locomotion_cycle_speed * 1.8
var t: float = fmod(time, c) / c * 6.28318
match bone:
"LeftUpperLeg": return Vector3(sin(t) * 1.0, 0, 0)
"RightUpperLeg": return Vector3(sin(t + 3.14159) * 1.0, 0, 0)
"LeftUpperArm": return Vector3(sin(t + 3.14159) * 0.8, 0, 0)
"RightUpperArm": return Vector3(sin(t) * 0.8, 0, 0)
"Spine": return Vector3(0, 0, sin(t) * 0.2)
"Hips": return Vector3(0, sin(t) * 0.1, 0)
return Vector3.ZERO
func _jump_offset(bone_name: String, time: float) -> Vector3:
var cycle: float = 0.83
var t: float = fmod(time, cycle) / cycle
var phase: float = t * 6.28318
# Jump pose: arms raised, knees bent, brief hop
var peak: float = 1.0 - abs(t - 0.5) * 2.0
func _jump_offset(bone: String, time: float) -> Vector3:
var c: float = 0.83
var t: float = fmod(time, c) / c
var peak: float = max(0.0, 1.0 - abs(t - 0.5) * 3.0)
peak = clamp(peak, 0.0, 1.0)
match bone_name:
"LeftUpperArm":
return Vector3(-1.2 * peak, 0, -0.3 * peak)
"RightUpperArm":
return Vector3(-1.2 * peak, 0, 0.3 * peak)
"LeftUpperLeg":
return Vector3(-0.3 * peak, 0, 0)
"RightUpperLeg":
return Vector3(-0.3 * peak, 0, 0)
"Spine":
return Vector3(0.1 * peak, 0, 0)
_:
match bone:
"LeftUpperArm": return Vector3(-2.5 * peak, 0, -0.3 * peak)
"RightUpperArm": return Vector3(-2.5 * peak, 0, 0.3 * peak)
"LeftUpperLeg": return Vector3(-0.5 * peak, 0, 0)
"RightUpperLeg": return Vector3(-0.5 * peak, 0, 0)
"Spine": return Vector3(0.2 * peak, 0, 0)
"Hips": return Vector3(0, 0.4 * peak, 0)
return Vector3.ZERO
func _idle_offset(bone_name: String, time: float) -> Vector3:
var t = time * 0.5 # Slow breathing
match bone_name:
"Spine":
return Vector3(0, sin(t) * 0.005, 0)
"Head":
return Vector3(sin(t * 0.7) * 0.02, sin(t * 0.5) * 0.02, 0)
"LeftUpperArm":
return Vector3(sin(t) * 0.02, 0, sin(t * 0.5) * 0.05)
"RightUpperArm":
return Vector3(sin(t) * 0.02, 0, -sin(t * 0.5) * 0.05)
"Hips":
return Vector3(0, sin(t) * 0.005, 0)
_:
func _idle_offset(bone: String, time: float) -> Vector3:
var t: float = time * 0.5
match bone:
"Spine": return Vector3(0, sin(t) * 0.05, 0)
"Head": return Vector3(sin(t * 0.7) * 0.05, sin(t * 0.5) * 0.05, 0)
"LeftUpperArm": return Vector3(sin(t) * 0.05, 0, sin(t * 0.5) * 0.15)
"RightUpperArm": return Vector3(sin(t) * 0.05, 0, -sin(t * 0.5) * 0.15)
"Hips": return Vector3(0, sin(t) * 0.03, 0)
return Vector3.ZERO
func _crouch_offset(bone: String, time: float) -> Vector3:
# Static crouch pose if not animated by AP
return Vector3.ZERO