feat: add rigged+animated Miku player model with auto-rig pipeline

- Add miku_rigged_animated.glb (3.27MB) with 18-bone skeleton and 6 animations
  (Idle, Walk, Run, Jump, Crouch, Death) via Blender auto-rig script
- Add tools/rig_and_animate.py: reusable Blender script for rigging any humanoid
  mesh with Mixamo-compatible bone naming
- Update SkinnedPlayerModel: scale_factor, first-person mode, animation state
  matching (Idle/Walk/Run/Jump/Crouch/Death)
- Update level_runtime.gd: use skinned model for local player, procedural
  humanoid for remote players
- Update skin_manager.gd and test_level_builder.gd to use animated model
- Fix Godot 4.2.1 'is not Type' syntax in 5 weapon files
- Add editor/import_miku_to_tscn.gd for editor-based GLB import
This commit is contained in:
2026-06-23 00:19:52 -04:00
parent f7cd3571fd
commit 0672bebe7b
13 changed files with 546 additions and 52 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ func _register_default_skins() -> void:
var miku_skin = PlayerSkin.new()
miku_skin.skin_name = "Miku"
miku_skin.description = "Hatsune Miku — Virtual Idol"
miku_skin.model_path = "res://assets/characters/skins/miku.glb"
miku_skin.model_path = "res://assets/characters/skins/miku_rigged_animated.glb"
miku_skin.color_tint = Color(0.0, 0.75, 0.75)
miku_skin.is_unlocked = true
skins["miku"] = miku_skin
+76 -36
View File
@@ -5,10 +5,13 @@ class_name SkinnedPlayerModel
## Replaces the procedural HumanoidModel for characters with custom skins.
@export var model_path: String = ""
@export var scale_factor: float = 1.0
@export var first_person_mode: bool = false # Hide head/torso for FPS view
var skeleton: Skeleton3D
var animation_player: AnimationPlayer
var _anim_debug_timer: float = 0.0
var _mesh_instance: MeshInstance3D
func _ready() -> void:
if model_path != "":
@@ -17,58 +20,67 @@ func _ready() -> void:
func load_model(path: String) -> void:
for child in get_children():
child.queue_free()
var loaded = load(path)
if not loaded:
print("SkinnedPlayerModel: failed to load %s" % path)
return
var scene = loaded.instantiate()
if not scene:
print("SkinnedPlayerModel: failed to instantiate %s" % path)
return
add_child(scene)
# Debug: print the full scene tree
if scale_factor != 1.0:
scene.scale = Vector3(scale_factor, scale_factor, scale_factor)
print("SkinnedPlayerModel: applied scale %.2f" % scale_factor)
# Find mesh instance for first-person mode
_mesh_instance = scene.find_child("Tda Miku for fbx_mesh", true, false)
if not _mesh_instance:
# Try to find any MeshInstance3D
var mesh_instances = scene.find_children("*", "MeshInstance3D", true, false)
if mesh_instances.size() > 0:
_mesh_instance = mesh_instances[0]
if first_person_mode and _mesh_instance:
_setup_first_person()
print("SkinnedPlayerModel: scene tree:")
_print_tree(scene, 0)
# Find skeleton
skeleton = find_skeleton(self)
if not skeleton:
skeleton = find_skeleton(scene)
# Find existing AnimationPlayer in the GLB scene
if skeleton:
print("SkinnedPlayerModel: found skeleton '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()])
else:
print("SkinnedPlayerModel: WARNING - no skeleton found")
animation_player = find_animation_player_recursive(scene)
# If no AnimationPlayer found, create one and add it to the armature
if not animation_player:
print("SkinnedPlayerModel: no AnimationPlayer in GLB, creating one...")
animation_player = AnimationPlayer.new()
animation_player.name = "AnimationPlayer"
# Add to the skeleton/armature node if found, otherwise to self
if skeleton:
skeleton.add_child(animation_player)
print("SkinnedPlayerModel: added AnimationPlayer to skeleton")
else:
add_child(animation_player)
print("SkinnedPlayerModel: added AnimationPlayer to self")
# Try to copy animations from the armature's animation_data
if skeleton and skeleton.animation_data:
var src_data = skeleton.animation_data
# Create animation library
var anim_lib = AnimationLibrary.new()
# Copy the action from the armature
if src_data.action:
var anim = src_data.action.copy()
anim_lib.add_animation(anim.name, anim)
print("SkinnedPlayerModel: copied animation '%s' (%.2fs)" % [anim.name, anim.length])
# Also check NLA tracks
if src_data.nla_tracks:
for track in src_data.nla_tracks:
for strip in track.strips:
@@ -76,21 +88,22 @@ func load_model(path: String) -> void:
var anim = strip.action.copy()
anim_lib.add_animation(anim.name, anim)
print("SkinnedPlayerModel: copied NLA animation '%s'" % anim.name)
animation_player.add_animation_library("", anim_lib)
else:
print("SkinnedPlayerModel: WARNING - skeleton has no animation_data")
# List available animations
print("SkinnedPlayerModel: WARNING - no animation_data found")
if animation_player:
var anim_list = animation_player.get_animation_list()
print("SkinnedPlayerModel: %d animations available:" % anim_list.size())
for anim in anim_list:
var a = animation_player.get_animation(anim)
print(" - %s (%.2fs, loop=%s)" % [anim, a.length, a.loop_mode])
# Auto-play idle
if animation_player.has_animation("Idle"):
if animation_player.has_animation("idle"):
animation_player.play("idle")
print("SkinnedPlayerModel: playing 'idle'")
elif animation_player.has_animation("Idle"):
animation_player.play("Idle")
print("SkinnedPlayerModel: playing 'Idle'")
elif anim_list.size() > 0:
@@ -99,6 +112,19 @@ func load_model(path: String) -> void:
else:
print("SkinnedPlayerModel: ERROR - no AnimationPlayer available")
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
print("SkinnedPlayerModel: setting up first-person mode")
# 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)
var extra = ""
@@ -158,15 +184,29 @@ func _process(delta: float) -> void:
var vel = sm.player.velocity
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 target_anim = "Idle"
match state:
"ground", "idle":
if speed > 1.0:
if is_dead:
target_anim = "Death"
elif is_crouching:
target_anim = "Crouch"
else:
var walk_speed = 10.0
if sm and sm.params:
walk_speed = sm.params.walk_speed
match state:
"ground", "idle":
if speed > walk_speed * 1.2:
target_anim = "Run"
elif speed > 0.5:
target_anim = "Walk"
"slide", "wall_run", "grapple", "dash":
target_anim = "Run"
"slide", "wall_run", "grapple", "dash":
target_anim = "Run"
_:
target_anim = "Idle"
"air":
target_anim = "Jump"
_:
target_anim = "Idle"
if animation_player.current_animation != target_anim or not animation_player.is_playing():
play_animation(target_anim)