Replace dasher-pack with unified animation-pack using original Blender bone names

This commit is contained in:
2026-06-15 14:28:26 +08:00
parent 9dd3c59edf
commit 844ec194cb
297 changed files with 28680 additions and 1884 deletions
+33 -46
View File
@@ -1,63 +1,50 @@
# Dasher Animation Tools
# Tools
The dasher character (`assets/characters/dashers/dasher_*.glb`) provides six
animation clips. They need to be retargeted to the player rig (which uses
`GeneralSkeleton` + Mixamo bone names) and packed into a single
`AnimationLibrary` that `player.tscn` can reference.
## rename_rig_in_glb.py
## One-time generation (headless, no editor needed)
Renames the rig root node in each character GLB to a consistent name
(`Character`). This makes all characters share the same skeleton path
`Character/Skeleton3D:bone_name`, enabling a shared animation library.
```bash
godot --headless --path /home/beng/Godot/Projects/tekton-enet \
--script res://tools/convert_dasher_animations_headless.gd
godot --headless --path /home/beng/Godot/Projects/tekton-enet \
--script res://tools/build_dasher_pack_headless.gd
python3 tools/rename_rig_in_glb.py
```
Output:
- `assets/characters/animations/dasher_<name>.res` (one per glb)
- `assets/characters/animations/dasher-pack.res` (combined library)
Modifies: `Bob.glb`, `Oldpop.glb`, `Masbro.glb`, `Gatot.glb`
- bob-rig → Character
- oldpop-rig → Character
- masbro-tpose → Character
- gatot-tpose → Character
`scenes/player.tscn` already references `dasher-pack.res` as a second
AnimationLibrary on the AnimationPlayer node, alongside the original
`animation-pack.res`.
Creates `.glb.bak` backups on first run. Re-runnable; restores from backup
each time to ensure idempotency.
## Or run inside the editor
After running, you must reimport in the Godot editor (or via
`godot --headless --import`).
Use the EditorScript variants:
- `tools/convert_dasher_animations.gd` (`@tool extends EditorScript`)
- `tools/build_dasher_pack.gd` (`@tool extends EditorScript`)
## build_animation_pack.gd
Open each in the editor and `File > Run` (`Ctrl+Shift+X`).
Headless script that extracts all animations from `animation-0.glb`,
applies rest-pose correction against `Bob.glb` (the reference rig), and
saves as `assets/characters/animations/animation-pack.res`.
## Usage
```gdscript
anim_player.play("dasher-pack/dasher_hit")
anim_player.play("dasher-pack/dasher_stun")
# etc.
```bash
godot --headless --path . --script tools/build_animation_pack.gd
```
## How it works
Run `rename_rig_in_glb.py` first so the reference skeleton has the
expected path.
Each `dasher_*.glb` contains three source animations in a single
`AnimationLibrary` (e.g. `dasher_hold.glb` has `bob-rig|Hold|Anima_Layer`,
`bob-rig|Put|Anima_Layer`, `bob-rig|bob ani|Anima_Layer`). The converter
picks the most relevant one based on the glb filename
(`ANIM_PICK` constant in `convert_dasher_animations_headless.gd`) and
retargets the bone names. The resulting `.res` has one animation per glb
named after the glb (`dasher_hold`, `dasher_hit`, etc.), retargeted to
`GeneralSkeleton:<MixamoBone>` paths so they apply against the player's
shared skeleton.
The animation-pack contains 15 animations used by the player:
- idle, walk_forward, backflip_1
- take_tile_1, take_tile_2, drop_tile_1, drop_tile_2, spawn_tile_1
- pickup_1, put_1, holding_1
- hit_1, hitted_1, getting_hit_1, stun_1
The `BONE_REMAP` table in the converter defines Blender → Mixamo bone
name translations (e.g. `head``Head`, `hand.L``LeftHand`).
Helper nodes (`head_end`, `hand.L_end`, etc.) that don't exist in the
player rig are silently dropped.
All tracks use original Blender bone names with path
`Character/Skeleton3D:bone_name` so they resolve regardless of which
character model is active.
## Editing the bone remap
## generate_version_json.py
If a dasher animation doesn't apply correctly, the most likely cause is a
bone name in the glb that's not in `BONE_REMAP`. Add the entry in
`convert_dasher_animations_headless.gd` and re-run both tools.
Generates version.json for game update checks.
+196
View File
@@ -0,0 +1,196 @@
extends SceneTree
## Headless script: loads animation-0.glb, extracts animations with
## original Blender bone names, applies rest-pose correction per bone,
## and saves animation-pack.res.
##
## Usage: godot --headless --path <project> --script tools/build_animation_pack.gd
const SOURCE_GLB := "res://assets/characters/animation-0.glb"
const TARGET_SKELETON_GLB := "res://assets/characters/Bob.glb"
const OUTPUT_PATH := "res://assets/characters/animations/animation-pack.res"
var _corrections: Dictionary = {} # bone_name -> Quaternion correction
var _correction_src_rest: Dictionary = {} # "Skeleton3D:bone" -> Vector3
var _correction_tgt_rest: Dictionary = {} # "Skeleton3D:bone" -> Vector3
func _init() -> void:
print("[build_animation_pack] Starting...")
# Step 1: Load source skeleton (animation-0.glb)
var gltf_doc := GLTFDocument.new()
var gltf_state := GLTFState.new()
var err := gltf_doc.append_from_file(
ProjectSettings.globalize_path(SOURCE_GLB), gltf_state
)
if err != OK:
push_error("[build_animation_pack] Failed to load source GLB: error %d" % err)
quit(1)
return
var src_scene := gltf_doc.generate_scene(gltf_state)
if not src_scene:
push_error("[build_animation_pack] source generate_scene returned null")
quit(1)
return
var src_skel: Skeleton3D = src_scene.find_child("Skeleton3D", true, false)
if not src_skel:
push_error("[build_animation_pack] No Skeleton3D in source")
src_scene.queue_free()
quit(1)
return
# Step 2: Load target skeleton (Bob, raw)
var tgt_gltf_doc := GLTFDocument.new()
var tgt_gltf_state := GLTFState.new()
err = tgt_gltf_doc.append_from_file(
ProjectSettings.globalize_path(TARGET_SKELETON_GLB), tgt_gltf_state
)
if err != OK:
push_error("[build_animation_pack] Failed to load target GLB: error %d" % err)
src_scene.queue_free()
quit(1)
return
var tgt_scene: Node = tgt_gltf_doc.generate_scene(tgt_gltf_state)
if not tgt_scene:
push_error("[build_animation_pack] target generate_scene returned null")
src_scene.queue_free()
quit(1)
return
var tgt_skel: Skeleton3D = tgt_scene.find_child("Skeleton3D", true, false)
if not tgt_skel:
push_error("[build_animation_pack] No Skeleton3D in target")
src_scene.queue_free()
tgt_scene.queue_free()
quit(1)
return
# Step 3: Build per-bone correction quaternions (original bone names, 1:1)
var src_bone_index: Dictionary = {}
for i in src_skel.get_bone_count():
src_bone_index[src_skel.get_bone_name(i)] = i
for i in tgt_skel.get_bone_count():
var bone_name: String = tgt_skel.get_bone_name(i)
if not src_bone_index.has(bone_name):
continue
var src_idx: int = src_bone_index[bone_name]
var src_rest: Transform3D = src_skel.get_bone_rest(src_idx)
var tgt_rest: Transform3D = tgt_skel.get_bone_rest(i)
# correction = tgt_rest.rot * src_rest.rot.inverse()
var correction: Quaternion = Quaternion(tgt_rest.basis) * Quaternion(src_rest.basis.inverse())
_corrections[bone_name] = correction
_correction_src_rest["Character/Skeleton3D:%s" % bone_name] = src_rest.origin
_correction_tgt_rest["Character/Skeleton3D:%s" % bone_name] = tgt_rest.origin
print("[build_animation_pack] Built %d rest-pose corrections" % _corrections.size())
# Step 4: Extract animations with original bone names
# We use the generated scene's AnimationPlayer, which contains the
# already-stripped tracks (immutable ones removed by GLTF importer).
var anim_player: AnimationPlayer = src_scene.find_child("AnimationPlayer", true, false)
if not anim_player:
push_error("[build_animation_pack] No AnimationPlayer in source")
src_scene.queue_free()
tgt_scene.queue_free()
quit(1)
return
var out_lib := AnimationLibrary.new()
var count := 0
var total_tracks := 0
var corrected_keys := 0
for lib_name in anim_player.get_animation_library_list():
var src_lib := anim_player.get_animation_library(lib_name)
for anim_name in src_lib.get_animation_list():
var src_anim: Animation = src_lib.get_animation(anim_name)
if not src_anim:
continue
var new_anim := Animation.new()
new_anim.length = src_anim.length
new_anim.loop_mode = src_anim.loop_mode
for i in src_anim.get_track_count():
var path_str: String = str(src_anim.track_get_path(i))
var track_type: int = src_anim.track_get_type(i)
# Parse "retarget/Skeleton3D:bone_name" -> bone_name
var bone_name := ""
if ":" in path_str:
bone_name = path_str.split(":")[1]
else:
# Non-bone track: skip GLB root "retarget"
if "retarget" in path_str:
continue
_copy_track(src_anim, i, new_anim, path_str, Quaternion.IDENTITY, track_type)
continue
# Skip the GLB scene root "retarget" — not a real bone.
if bone_name == "retarget":
continue
# Use original bone name with character-rig-relative path.
# All characters have rig root renamed to "Character" via GLB modification,
# so the path "Character/Skeleton3D:bone" is consistent across all of them.
var new_path := "Character/Skeleton3D:%s" % bone_name
var correction: Quaternion = _corrections.get(bone_name, Quaternion())
_copy_track(src_anim, i, new_anim, new_path, correction, track_type)
total_tracks += 1
corrected_keys += _last_key_count
out_lib.add_animation(anim_name, new_anim)
count += 1
print(" + %s (%d tracks)" % [anim_name, new_anim.get_track_count()])
print("[build_animation_pack] Extracted %d animations, %d tracks, corrected %d rotation keys" % [count, total_tracks, corrected_keys])
err = ResourceSaver.save(out_lib, OUTPUT_PATH)
if err != OK:
push_error("[build_animation_pack] Failed to save: error %d" % err)
src_scene.queue_free()
tgt_scene.queue_free()
quit(1)
return
print("[build_animation_pack] Saved to %s" % OUTPUT_PATH)
src_scene.queue_free()
tgt_scene.queue_free()
quit(0)
var _last_key_count: int = 0
func _copy_track(src_anim: Animation, src_idx: int, dst_anim: Animation, new_path: String, correction: Quaternion, track_type: int = -1) -> void:
if track_type < 0:
track_type = src_anim.track_get_type(src_idx)
var dst_idx := dst_anim.add_track(track_type)
dst_anim.track_set_path(dst_idx, NodePath(new_path))
dst_anim.track_set_interpolation_type(dst_idx, src_anim.track_get_interpolation_type(src_idx))
var key_count := src_anim.track_get_key_count(src_idx)
_last_key_count = 0
for k in key_count:
var time := src_anim.track_get_key_time(src_idx, k)
var value = src_anim.track_get_key_value(src_idx, k)
var transition := src_anim.track_get_key_transition(src_idx, k)
match track_type:
Animation.TYPE_POSITION_3D:
var src_pos: Vector3 = value
var src_rest_pos: Vector3 = _correction_src_rest.get(new_path, Vector3.ZERO)
var tgt_rest_pos: Vector3 = _correction_tgt_rest.get(new_path, Vector3.ZERO)
var corrected_pos: Vector3 = correction * (src_pos - src_rest_pos) + tgt_rest_pos
dst_anim.position_track_insert_key(dst_idx, time, corrected_pos)
Animation.TYPE_ROTATION_3D:
var src_q: Quaternion = value
var corrected_q: Quaternion = correction * src_q
dst_anim.rotation_track_insert_key(dst_idx, time, corrected_q)
_last_key_count += 1
Animation.TYPE_SCALE_3D:
dst_anim.scale_track_insert_key(dst_idx, time, value)
Animation.TYPE_BLEND_SHAPE:
dst_anim.blend_shape_track_insert_key(dst_idx, time, value)
_:
dst_anim.track_insert_key(dst_idx, time, value, transition)
+1
View File
@@ -0,0 +1 @@
uid://dkltni8e2osfr
-52
View File
@@ -1,52 +0,0 @@
@tool
extends EditorScript
# Editor tool: combine all dasher_<name>.tres AnimationLibrary files into a
# single dasher-pack.tres for player.tscn to reference.
#
# Run AFTER convert_dasher_animations.gd.
# File > Run (Ctrl+Shift+X) on this script.
#
# Output: res://assets/characters/animations/dasher-pack.tres
#
# Then add an ext_resource to scenes/player.tscn:
# [ext_resource type="AnimationLibrary" path="res://assets/characters/animations/dasher-pack.tres" id="..."]
# And on the AnimationPlayer node:
# libraries/dasher-pack = ExtResource("...")
const ANIM_DIR := "res://assets/characters/animations"
const OUTPUT := "res://assets/characters/animations/dasher-pack.res"
func _run() -> void:
var combined := AnimationLibrary.new()
var dir := DirAccess.open(ANIM_DIR)
if not dir:
push_error("[Build] Cannot open %s" % ANIM_DIR)
return
var loaded: Array[String] = []
for fname in dir.get_files():
if not fname.begins_with("dasher_"): continue
if not fname.ends_with(".res"): continue
if fname == "dasher-pack.res": continue
var path := "%s/%s" % [ANIM_DIR, fname]
var res: Resource = load(path)
if not res is AnimationLibrary:
push_warning("[Build] %s is not an AnimationLibrary, skipping" % fname)
continue
var lib: AnimationLibrary = res
for anim_name in lib.get_animation_list():
if combined.has_animation(anim_name):
push_warning("[Build] Duplicate anim name '%s' in %s, skipping" % [anim_name, fname])
continue
combined.add_animation(anim_name, lib.get_animation(anim_name))
loaded.append("%s/%s" % [fname, anim_name])
var err := ResourceSaver.save(combined, OUTPUT)
if err != OK:
push_error("[Build] ResourceSaver.save failed: %d" % err)
return
print("[Build] %d animation(s) written to %s" % [loaded.size(), OUTPUT])
for l in loaded:
print(" - %s" % l)
-48
View File
@@ -1,48 +0,0 @@
extends SceneTree
# Headless equivalent of build_dasher_pack.gd.
# Run from CLI AFTER convert_dasher_animations_headless.gd:
# godot --headless --path /home/beng/Godot/Projects/tekton-enet --script res://tools/build_dasher_pack_headless.gd
#
# Produces: res://assets/characters/animations/dasher-pack.tres
const ANIM_DIR := "res://assets/characters/animations"
const OUTPUT := "res://assets/characters/animations/dasher-pack.res"
func _init() -> void:
print("[Build] Headless run starting...")
var combined := AnimationLibrary.new()
var dir := DirAccess.open(ANIM_DIR)
if not dir:
push_error("[Build] Cannot open %s" % ANIM_DIR)
quit(1)
return
var loaded: Array[String] = []
for fname in dir.get_files():
if not fname.begins_with("dasher_"): continue
if not fname.ends_with(".res"): continue
if fname == "dasher-pack.res": continue
var path := "%s/%s" % [ANIM_DIR, fname]
var res: Resource = load(path)
if not res is AnimationLibrary:
push_warning("[Build] %s is not an AnimationLibrary, skipping" % fname)
continue
var lib: AnimationLibrary = res
for anim_name in lib.get_animation_list():
if combined.has_animation(anim_name):
push_warning("[Build] Duplicate anim name '%s' in %s, skipping" % [anim_name, fname])
continue
combined.add_animation(anim_name, lib.get_animation(anim_name))
loaded.append("%s/%s" % [fname, anim_name])
var err := ResourceSaver.save(combined, OUTPUT)
if err != OK:
push_error("[Build] ResourceSaver.save failed: %d" % err)
quit(1)
return
print("[Build] Done. %d animation(s) written to %s" % [loaded.size(), OUTPUT])
for l in loaded:
print(" - %s" % l)
quit(0)
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env -S godot --headless -s
extends MainLoop
func _initialize():
print("--- Starting Automated Patch Build ---")
var output_file = "patch.pck"
var changed_files_txt = "changed_files.txt"
if not FileAccess.file_exists(changed_files_txt):
print("ERROR: missing changed_files.txt. Cannot build patch.")
return
var pck_packer = PCKPacker.new()
var err = pck_packer.pck_start(output_file)
if err != OK:
print("ERROR: Could not start PCK file: ", output_file)
return
var file = FileAccess.open(changed_files_txt, FileAccess.READ)
var count = 0
while not file.eof_reached():
var line = file.get_line().strip_edges()
if line.is_empty(): continue
var res_path = "res://" + line
# Include automatically compiled scripts for GDScript
if line.ends_with(".gd"):
var remap_path = res_path.replace(".gd", ".gdc")
if FileAccess.file_exists(remap_path):
pck_packer.add_file(res_path, remap_path)
else:
pck_packer.add_file(res_path, res_path)
count += 1
print("Adding (Script): ", res_path)
elif FileAccess.file_exists(res_path):
print("Adding to patch: ", res_path)
pck_packer.add_file(res_path, res_path)
count += 1
else:
print("Warning: Changed file not found or Is Directory, skipping: ", res_path)
# Always package our version/changelog list so clients see the new changelog
var version_manifest = "res://assets/data/version.json"
pck_packer.add_file(version_manifest, version_manifest)
print("Adding Version Manifest: ", version_manifest)
pck_packer.flush(true)
print("--- Patch Build Complete! Packed %d files into %s ---" % [count + 1, output_file])
func _process(_delta: float) -> bool:
return true # True tells Godot to gracefully shut down the engine now!
-185
View File
@@ -1,185 +0,0 @@
@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_<name>.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]
-256
View File
@@ -1,256 +0,0 @@
extends SceneTree
# Headless: convert each dasher_*.glb into a dasher_<name>.res with proper retargeting.
# Reads rest poses from both source GLB skeleton and target GeneralSkeleton (player.tscn)
# to compute per-bone correction quaternions.
const DASHER_DIR := "res://assets/characters/dashers"
const ANIM_DIR := "res://assets/characters/animations"
const BONE_REMAP := {
"bob-hold": "Hips",
"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 ANIM_PICK := {
"dasher_getting_hit": "Getting Hit",
"dasher_hit": "Hit",
"dasher_hold": "Hold",
"dasher_put": "Put",
"dasher_stun": "Stun",
"dasher_take": "bob ani",
}
# Source bone rest transforms (from GLB)
var src_rest: Dictionary = {} # bone_name -> Transform3D
# Target bone rest transforms (from GeneralSkeleton)
var tgt_rest: Dictionary = {} # bone_name -> Transform3D
# Precomputed correction quaternions
var corrections: Dictionary = {} # bone_name -> Quaternion
func _init() -> void:
print("[Convert] Headless run starting...")
# Step 1: Load target skeleton rest poses from player scene
_load_target_rest()
# Step 2: Convert each GLB
var dasher_dir := DirAccess.open(DASHER_DIR)
if not dasher_dir:
push_error("[Convert] Cannot open %s" % DASHER_DIR)
quit(1); 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()
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] Done. %d dasher glb(s) converted:" % converted.size())
for p in converted:
print(" - %s" % p)
quit(0)
func _load_target_rest() -> void:
# Load Masbro.glb directly to extract GeneralSkeleton rest poses
# (player.tscn may fail to load if dasher-pack.res doesn't exist yet)
var doc := GLTFDocument.new()
var state := GLTFState.new()
var err := doc.append_from_file("res://assets/characters/Masbro.glb", state)
if err != OK:
push_error("[Convert] Cannot load Masbro.glb: %d" % err)
return
var instance = doc.generate_scene(state)
if not instance:
push_error("[Convert] generate_scene returned null for Masbro.glb")
return
# Find the Skeleton3D (named GeneralSkeleton in the scene tree)
var skeleton: Skeleton3D = instance.find_child("GeneralSkeleton", true, false)
if not skeleton:
# Try finding any Skeleton3D
skeleton = instance.find_child("Skeleton3D", true, false)
if not skeleton:
push_error("[Convert] No Skeleton3D in Masbro.glb")
instance.free()
return
print("[Convert] Target skeleton: '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()])
for bone_idx in skeleton.get_bone_count():
var bone_name := skeleton.get_bone_name(bone_idx)
tgt_rest[bone_name] = skeleton.get_bone_rest(bone_idx)
instance.free()
print("[Convert] Loaded %d target bone rest poses" % tgt_rest.size())
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: Node = doc.generate_scene(state)
if not scene:
push_error("[Convert] generate_scene returned null")
return false
# Extract source skeleton rest poses
src_rest.clear()
var src_skeleton: Skeleton3D = scene.find_child("Skeleton3D", true, false)
if src_skeleton:
for bone_idx in src_skeleton.get_bone_count():
var bone_name := src_skeleton.get_bone_name(bone_idx)
src_rest[bone_name] = src_skeleton.get_bone_rest(bone_idx)
print(" Source skeleton: %d bones" % src_rest.size())
else:
push_error("[Convert] No Skeleton3D in %s" % glb_path)
scene.queue_free()
return false
# Compute per-bone correction quaternions
corrections.clear()
for src_bone in BONE_REMAP:
var tgt_bone: String = BONE_REMAP[src_bone]
if src_rest.has(src_bone) and tgt_rest.has(tgt_bone):
var src_basis: Basis = src_rest[src_bone].basis
var tgt_basis: Basis = tgt_rest[tgt_bone].basis
# correction = tgt_rest.inverse() * src_rest
# Applied as: final_q = correction * keyframe_q
corrections[src_bone] = tgt_basis.inverse() * src_basis
# print(" Correction for %s -> %s: %s" % [src_bone, tgt_bone, str(corrections[src_bone])])
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
# Pick the animation whose middle segment matches the keyword
var pick_keyword: String = ANIM_PICK.get(anim_name, anim_name.replace("dasher_", ""))
var picked: Animation = null
for lib_name in src_player.get_animation_library_list():
var src_lib: AnimationLibrary = src_player.get_animation_library(lib_name)
for src_anim_name in src_lib.get_animation_list():
var segments := src_anim_name.split("|")
var mid_segment: String = segments[1] if segments.size() >= 3 else src_anim_name
if pick_keyword.to_lower() == mid_segment.to_lower():
picked = src_lib.get_animation(src_anim_name)
break
if picked: break
if not picked:
for lib_name in src_player.get_animation_library_list():
var src_lib: AnimationLibrary = src_player.get_animation_library(lib_name)
if src_lib.get_animation_list().size() > 0:
picked = src_lib.get_animation(src_lib.get_animation_list()[0])
break
if not picked:
push_error("[Convert] No animations in %s" % glb_path)
scene.queue_free()
return false
var retargeted := _retarget(picked)
scene.queue_free()
if retargeted.get_track_count() == 0:
push_error("[Convert] Retarget produced 0 tracks for %s" % glb_path)
return false
var out_lib := AnimationLibrary.new()
out_lib.add_animation(anim_name, retargeted)
print(" + %s (%d tracks from %s)" % [anim_name, retargeted.get_track_count(), picked.resource_name if picked.resource_name else "<anon>"])
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
var usable: Array[int] = []
for i in src.get_track_count():
var orig_path: String = src.track_get_path(i)
var bone := _extract_bone_name(orig_path)
if BONE_REMAP.has(bone):
usable.append(i)
for i in usable:
var orig_path: String = src.track_get_path(i)
var bone: String = _extract_bone_name(orig_path)
var new_bone: String = BONE_REMAP[bone]
var new_path := "%%GeneralSkeleton:%s" % new_bone
var track_type := src.track_get_type(i)
match track_type:
Animation.TYPE_POSITION_3D:
if new_bone != "Hips": continue
dst.add_track(Animation.TYPE_POSITION_3D)
Animation.TYPE_ROTATION_3D:
dst.add_track(Animation.TYPE_ROTATION_3D)
Animation.TYPE_SCALE_3D:
if new_bone != "Hips": continue
dst.add_track(Animation.TYPE_SCALE_3D)
Animation.TYPE_BLEND_SHAPE: continue
_: continue
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))
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)
# Apply rest-pose correction to rotation tracks
if track_type == Animation.TYPE_ROTATION_3D and corrections.has(bone):
var orig_q: Quaternion = v
var corr_q: Quaternion = corrections[bone]
v = corr_q * orig_q
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:
var parts := path.split(":")
if parts.size() < 2:
return ""
return parts[-1]
@@ -1 +0,0 @@
uid://dgwmo0tdt8dwl
-20
View File
@@ -1,20 +0,0 @@
extends SceneTree
const DASHER_DIR := "res://assets/characters/dashers"
func _init() -> void:
var dir := DirAccess.open(DASHER_DIR)
for fname in dir.get_files():
if not fname.ends_with(".glb"): continue
var doc = GLTFDocument.new()
var state = GLTFState.new()
doc.append_from_file("%s/%s" % [DASHER_DIR, fname], state)
var scene = doc.generate_scene(state)
var ap: AnimationPlayer = scene.find_child("AnimationPlayer", true, false)
if ap:
print("=== %s ===" % fname)
for lib_name in ap.get_animation_library_list():
var lib = ap.get_animation_library(lib_name)
for anim_name in lib.get_animation_list():
print(" ", anim_name)
quit()
-1
View File
@@ -1 +0,0 @@
uid://cau1rt1kfa32y
-32
View File
@@ -1,32 +0,0 @@
extends SceneTree
func _init() -> void:
var scene = preload("res://scenes/player.tscn").instantiate()
var skel: Skeleton3D = null
# Find GeneralSkeleton anywhere in the player scene
var queue = [scene]
while queue.size() > 0:
var n = queue.pop_front()
if n is Skeleton3D and n.name == "GeneralSkeleton":
skel = n
break
for c in n.get_children():
queue.append(c)
if skel:
print("Found GeneralSkeleton! Bones:")
for i in skel.get_bone_count():
print(" - ", skel.get_bone_name(i))
else:
print("GeneralSkeleton not found in player.tscn!")
print("Tree:")
_print_tree(scene, 0)
quit()
func _print_tree(n: Node, depth: int) -> void:
var indent = " ".repeat(depth)
print("%s- %s (%s)" % [indent, n.name, n.get_class()])
for c in n.get_children():
_print_tree(c, depth + 1)
-1
View File
@@ -1 +0,0 @@
uid://dxxtbdm3usgdp
-15
View File
@@ -1,15 +0,0 @@
extends SceneTree
func _init() -> void:
var scene = preload("res://scenes/player.tscn").instantiate()
var masbro = scene.get_node("Masbro")
if masbro:
print("Masbro found! Children:")
_print_tree(masbro, 0)
else:
print("No Masbro")
quit()
func _print_tree(n: Node, depth: int) -> void:
var indent = " ".repeat(depth)
print("%s- %s (%s)" % [indent, n.name, n.get_class()])
for c in n.get_children():
_print_tree(c, depth + 1)
-1
View File
@@ -1 +0,0 @@
uid://dt8bew0d4r6bp
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Rename rig root node in character GLBs to 'Character' for consistent paths.
This must run before Godot imports the GLBs. It modifies the .glb JSON to rename
the rig root node (oldpop-rig, bob-rig, etc.) to 'Character' so all characters
share the same skeleton path: Character/Skeleton3D:bone_name
Backups are created as .glb.bak on first run.
Re-runnable: if backup exists, restore from backup first to ensure idempotency.
"""
import json
import os
import struct
import shutil
import sys
CHARACTERS = {
'Bob.glb': 'bob-rig',
'Oldpop.glb': 'oldpop-rig',
'Masbro.glb': 'masbro-tpose',
'Gatot.glb': 'gatot-tpose',
}
NEW_NAME = 'Character'
CHARS_DIR = os.path.join(os.path.dirname(__file__), '..', 'assets', 'characters')
def find_rig_node(nodes, old_name):
for i, n in enumerate(nodes):
if n.get('name') == old_name:
return i
return -1
def process_glb(path, old_name):
backup = path + '.bak'
if os.path.exists(backup):
shutil.copy(backup, path)
else:
shutil.copy(path, backup)
with open(path, 'rb') as f:
f.read(12) # magic + version + length
chunk_len = struct.unpack('<I', f.read(4))[0]
f.read(4) # chunk type
json_data = f.read(chunk_len)
rest = f.read()
data = json.loads(json_data)
nodes = data.get('nodes', [])
idx = find_rig_node(nodes, old_name)
if idx < 0:
print(f' {os.path.basename(path)}: rig root "{old_name}" NOT FOUND, skipping')
return False
nodes[idx]['name'] = NEW_NAME
new_json = json.dumps(data, separators=(',', ':')).encode('utf-8')
new_chunk_len = len(new_json)
new_length = 12 + 8 + new_chunk_len + len(rest)
with open(path, 'wb') as f:
f.write(b'glTF')
f.write(struct.pack('<I', 2))
f.write(struct.pack('<I', new_length))
f.write(struct.pack('<I', new_chunk_len))
f.write(b'JSON')
f.write(new_json)
f.write(rest)
print(f' {os.path.basename(path)}: renamed {old_name} -> {NEW_NAME}')
return True
def main():
os.chdir(CHARS_DIR)
print(f'Processing GLBs in {CHARS_DIR}')
for glb, old_name in CHARACTERS.items():
path = os.path.join(CHARS_DIR, glb)
if not os.path.exists(path):
print(f' {glb}: not found, skipping')
continue
process_glb(path, old_name)
print('Done.')
if __name__ == '__main__':
main()
-8
View File
@@ -1,8 +0,0 @@
extends SceneTree
func _init() -> void:
print("TYPE_VALUE: ", Animation.TYPE_VALUE)
print("TYPE_POSITION_3D: ", Animation.TYPE_POSITION_3D)
print("TYPE_ROTATION_3D: ", Animation.TYPE_ROTATION_3D)
print("TYPE_SCALE_3D: ", Animation.TYPE_SCALE_3D)
quit()
-1
View File
@@ -1 +0,0 @@
uid://dl8svqqgt6m3n
-33
View File
@@ -1,33 +0,0 @@
extends SceneTree
func _init() -> void:
var lib = load("res://assets/characters/animations/dasher-pack.res") as AnimationLibrary
if not lib:
quit(1)
return
var anim_name = "dasher_hit"
if lib.has_animation(anim_name):
var anim = lib.get_animation(anim_name)
print("Animation: ", anim_name)
var has_pos = 0
var has_rot = 0
var has_scale = 0
var pos_bones = []
for i in anim.get_track_count():
var type = anim.track_get_type(i)
var path = anim.track_get_path(i)
if type == Animation.TYPE_POSITION_3D:
has_pos += 1
pos_bones.append(str(path))
elif type == Animation.TYPE_ROTATION_3D:
has_rot += 1
elif type == Animation.TYPE_SCALE_3D:
has_scale += 1
print(" Positions: ", has_pos, " (", pos_bones, ")")
print(" Rotations: ", has_rot)
print(" Scales: ", has_scale)
quit(0)
-1
View File
@@ -1 +0,0 @@
uid://bleaj3miugqrm
-13
View File
@@ -1,13 +0,0 @@
extends SceneTree
func _init() -> void:
var lib = load("res://assets/characters/animations/dasher-pack.res") as AnimationLibrary
for anim_name in lib.get_animation_list():
var anim = lib.get_animation(anim_name)
print("=== %s (%d tracks) ===" % [anim_name, anim.get_track_count()])
for i in anim.get_track_count():
var type = anim.track_get_type(i)
var path = anim.track_get_path(i)
var type_str = ["VALUE","POSITION","ROTATION","SCALE"][type]
print(" [%d] type=%s path=%s" % [i, type_str, path])
quit()
-1
View File
@@ -1 +0,0 @@
uid://yyhb801dk5ve
-16
View File
@@ -1,16 +0,0 @@
extends SceneTree
func _init() -> void:
var lib = load("res://assets/characters/animations/dasher-pack.res") as AnimationLibrary
if not lib:
print("Failed to load dasher-pack.res")
quit(1)
return
var anim_name = lib.get_animation_list()[0]
var anim = lib.get_animation(anim_name)
print("First animation: ", anim_name)
for i in min(anim.get_track_count(), 5):
print(" Track ", i, " path: ", anim.track_get_path(i))
quit(0)
-1
View File
@@ -1 +0,0 @@
uid://ce58ow1re1l24
-19
View File
@@ -1,19 +0,0 @@
extends SceneTree
func _init() -> void:
var doc = GLTFDocument.new()
var state = GLTFState.new()
var err = doc.append_from_file("res://assets/characters/dashers/dasher_hit.glb", state)
var scene = doc.generate_scene(state)
var ap: AnimationPlayer = scene.find_child("AnimationPlayer", true, false)
var lib = ap.get_animation_library("")
var anim_name = "dasher_take|Hit|Anima_Layer"
if lib.has_animation(anim_name):
var anim = lib.get_animation(anim_name)
print("=== RAW TRACKS ===")
for i in anim.get_track_count():
var type = anim.track_get_type(i)
var path = anim.track_get_path(i)
print(" Track ", i, " type: ", type, " path: ", path)
quit()
-1
View File
@@ -1 +0,0 @@
uid://dcoduco766d6w
-34
View File
@@ -1,34 +0,0 @@
extends SceneTree
func _init() -> void:
var lib = load("res://assets/characters/animations/animation-pack.res") as AnimationLibrary
if not lib:
print("Failed to load animation-pack.res")
quit(1)
return
var anim_name = "walk_forward"
if lib.has_animation(anim_name):
var anim = lib.get_animation(anim_name)
print("Animation: ", anim_name)
var has_pos = 0
var has_rot = 0
var has_scale = 0
var pos_bones = []
for i in anim.get_track_count():
var type = anim.track_get_type(i)
var path = anim.track_get_path(i)
if type == Animation.TYPE_POSITION_3D:
has_pos += 1
pos_bones.append(str(path))
elif type == Animation.TYPE_ROTATION_3D:
has_rot += 1
elif type == Animation.TYPE_SCALE_3D:
has_scale += 1
print(" Positions: ", has_pos, " (", pos_bones, ")")
print(" Rotations: ", has_rot)
print(" Scales: ", has_scale)
quit(0)
-1
View File
@@ -1 +0,0 @@
uid://c1boh2egfnnfu
-12
View File
@@ -1,12 +0,0 @@
extends SceneTree
func _init() -> void:
var lib = load("res://assets/characters/animations/animation-pack.res") as AnimationLibrary
var anim = lib.get_animation("walk_forward")
print("=== walk_forward tracks ===")
for i in anim.get_track_count():
var type = anim.track_get_type(i)
var path = anim.track_get_path(i)
var type_str = ["VALUE","POSITION","ROTATION","SCALE"][type]
print(" [%d] type=%s path=%s" % [i, type_str, path])
quit()
-1
View File
@@ -1 +0,0 @@
uid://dajp15327baah