@tool extends EditorScript # Editor tool: convert each dasher_*.glb into an AnimationLibrary .tres file # with tracks retargeted to the player rig (GeneralSkeleton + Mixamo bone names). # # Run once in the Godot editor: # File > Run (Ctrl+Shift+X) on this script, OR # from a @tool script: EditorInterface.get_resource_filesystem().scan() # # Output: # assets/characters/animations/dasher_.tres (one per glb) # Plus a combined library: # assets/characters/animations/dasher-pack.tres # # Run build_dasher_pack.gd after this to consolidate. const DASHER_DIR := "res://assets/characters/dashers" const ANIM_DIR := "res://assets/characters/animations" # Map dasher glb bone names (Blender/standard) -> player rig bone names (Mixamo). # Tracks whose node name is not in this map are dropped (e.g. helper "head_end" empties). const BONE_REMAP := { "bob-hold": "Hips", # root of the dasher glb "spine": "Hips", "spine.001": "Spine", "head": "Head", "hand.L": "LeftHand", "hand.R": "RightHand", "forearm.L": "LeftLowerArm", "forearm.R": "RightLowerArm", "upper_arm.L": "LeftUpperArm", "upper_arm.R": "RightUpperArm", "shoulder.L": "LeftShoulder", "shoulder.R": "RightShoulder", "leg.L": "LeftLowerLeg", "leg.R": "RightLowerLeg", "thigh.L": "LeftUpperLeg", "thigh.R": "RightUpperLeg", "foot.L": "LeftFoot", "foot.R": "RightFoot", "toe.L": "LeftToeBase", "toe.R": "RightToeBase", } const TRACK_TYPES := [ "position", # 3D position tracks "rotation", # 3D rotation tracks (quaternion) "scale", # 3D scale tracks ] func _run() -> void: var dasher_dir := DirAccess.open(DASHER_DIR) if not dasher_dir: push_error("[Convert] Cannot open %s" % DASHER_DIR) return var converted: Array[String] = [] for fname in dasher_dir.get_files(): if not fname.ends_with(".glb"): continue if not fname.begins_with("dasher_"): continue var stem := fname.get_basename() # e.g. "dasher_hold" var out_path := "%s/%s.res" % [ANIM_DIR, stem] var ok := _convert_one("%s/%s" % [DASHER_DIR, fname], stem, out_path) if ok: converted.append(out_path) else: push_warning("[Convert] Skipped %s" % fname) print("[Convert] %d dasher glb(s) converted: %s" % [converted.size(), converted]) func _convert_one(glb_path: String, anim_name: String, out_path: String) -> bool: print("[Convert] %s -> %s" % [glb_path, out_path]) var doc := GLTFDocument.new() var state := GLTFState.new() var err := doc.append_from_file(glb_path, state) if err != OK: push_error("[Convert] append_from_file failed: %d" % err) return false var scene := doc.generate_scene(state) if not scene: push_error("[Convert] generate_scene returned null") return false var src_player: AnimationPlayer = scene.find_child("AnimationPlayer", true, false) if not src_player: push_error("[Convert] No AnimationPlayer in %s" % glb_path) scene.queue_free() return false var out_lib := AnimationLibrary.new() for lib_name in src_player.get_animation_library_list(): var src_lib := src_player.get_animation_library(lib_name) for src_anim_name in src_lib.get_animation_list(): var src_anim: Animation = src_lib.get_animation(src_anim_name) var retargeted := _retarget(src_anim) if retargeted and retargeted.get_track_count() > 0: out_lib.add_animation(anim_name, retargeted) print(" + %s (%d tracks)" % [anim_name, retargeted.get_track_count()]) else: push_warning(" - %s produced 0 tracks after retarget" % src_anim_name) scene.queue_free() if out_lib.get_animation_list().is_empty(): push_error("[Convert] Output library is empty for %s" % glb_path) return false var save_err := ResourceSaver.save(out_lib, out_path) if save_err != OK: push_error("[Convert] ResourceSaver.save failed: %d" % save_err) return false return true func _retarget(src: Animation) -> Animation: var dst := Animation.new() dst.length = src.length dst.loop_mode = src.loop_mode dst.step = src.step # Map: original_track_idx -> new track path or -1 to drop for i in src.get_track_count(): var orig_path: String = src.track_get_path(i) var bone := _extract_bone_name(orig_path) if not BONE_REMAP.has(bone): # drop helper nodes like head_end, hand.L_end, etc. continue var new_bone: String = BONE_REMAP[bone] var new_path := "%%GeneralSkeleton:%s" % new_bone # Match the suffix type (:position/:rotation/:scale) var type_suffix := "" if orig_path.ends_with(":position"): type_suffix = "position" elif orig_path.ends_with(":rotation"): type_suffix = "rotation" elif orig_path.ends_with(":scale"): type_suffix = "scale" else: push_warning(" Unrecognized track path: %s" % orig_path) continue match type_suffix: "position": if new_bone != "Hips": continue dst.add_track(Animation.TYPE_POSITION_3D) "rotation": dst.add_track(Animation.TYPE_ROTATION_3D) "scale": if new_bone != "Hips": continue dst.add_track(Animation.TYPE_SCALE_3D) var new_idx := dst.get_track_count() - 1 dst.track_set_path(new_idx, NodePath(new_path)) dst.track_set_interpolation_type(new_idx, src.track_get_interpolation_type(i)) dst.track_set_imported(new_idx, src.track_is_imported(i)) dst.track_set_enabled(new_idx, src.track_is_enabled(i)) # Copy keyframes var key_count := src.track_get_key_count(i) for k in key_count: var t: float = src.track_get_key_time(i, k) var v: Variant = src.track_get_key_value(i, k) var trans := src.track_get_key_transition(i, k) match type_suffix: "position": dst.track_insert_key(new_idx, t, v) "rotation": dst.track_insert_key(new_idx, t, v) "scale": dst.track_insert_key(new_idx, t, v) dst.track_set_key_transition(new_idx, k, trans) return dst func _extract_bone_name(path: String) -> String: # Tracks look like: "bob-hold/Skeleton3D:hand.L:rotation" # We want the bone name "hand.L". # Find the last ":" that precedes a type suffix; everything between the previous # ":" (or start) and that ":" is the bone name. Easier: split on ":" and take # the second-to-last segment. var parts := path.split(":") if parts.size() < 2: return "" # parts[-1] is the type ("rotation" etc), parts[-2] is the bone name # but bone names can contain "." (e.g. "hand.L"), so just take parts[-2] as-is. return parts[-2]