feat: Sketchfab-to-game character pipeline, FP/TP animated views, client-auth netcode, audio system
Character pipeline (tools/): - sketchfab_import.py: search/download via Sketchfab Download API with license/attribution tracking (SKETCHFAB_API_TOKEN) - autorig.py: headless Blender auto-rig — fits a Mixamo-named skeleton to unrigged humanoids, binds automatic weights - merge_animations.py: merges the shared Mixamo-skeleton animation library onto any rigged character, strips root motion, canonical names - pipeline.py: one command chains download -> rig -> animate -> register In-game skin system: - SkinManager autoload reads skins.json (auto-written by the pipeline) - SkinnedPlayerModel rewritten: canonical clips with fallback chains, blend times, speed-scaled locomotion, weapon bone attachment - First-person: full animated body for the owner, head hidden via SkeletonModifier3D; third-person: full model for other players - Skin selector in main menu; skin id synced in multiplayer - Fixed GLBLoader crash (GLTFDocument.get_animation_count doesn't exist) Multiplayer sync overhaul: - Movement is now client-authoritative: the owning peer simulates locally (no input round-trip), server keeps health/kills/death - Remote players interpolate synced_position/velocity with extrapolation and snap-on-teleport - Knockback/impulses routed to the simulating peer Audio: - AudioManager autoload: SFX/Weapons/Footsteps/UI/Music buses, pooled 3D players, variation + pitch randomization, auto-registration from assets/sounds Docs: 3D_ASSET_PIPELINE.md rewritten end-to-end, new ASSET_SOURCES.md (non-procedural animation/map/sound sources) and SOUND_DESIGN.md. Verified with debug/spawn_smoke_test.gd (headless: 24/24 checks pass). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
552338112e
commit
d05477c135
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless Blender animation merger for Papaya-Shooter.
|
||||
|
||||
Merges every animation clip from the shared animation library onto a rigged
|
||||
character (Mixamo-compatible bone names) and exports one game-ready GLB whose
|
||||
animations use the game's canonical clip names (Idle, Walk, Run, Jump, ...).
|
||||
|
||||
Animation library: a directory of FBX/GLB files, ONE clip per file, all using
|
||||
the Mixamo skeleton. Download once from Mixamo ("without skin") or use a CC0
|
||||
pack — see docs/ASSET_SOURCES.md. Filenames map to canonical clip names:
|
||||
idle.fbx -> Idle
|
||||
run.fbx -> Run
|
||||
crouch_walk.fbx -> CrouchWalk
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/merge_animations.py -- \
|
||||
<rigged_character.glb> <animations_dir> <output.glb> [--keep-root-motion]
|
||||
|
||||
Locomotion clips are exported in place (hips horizontal motion stripped)
|
||||
unless --keep-root-motion is given, since the gameplay code moves the body.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 3:
|
||||
print("Usage: blender --background --python tools/merge_animations.py -- "
|
||||
"<character.glb> <animations_dir> <output.glb> [--keep-root-motion]")
|
||||
sys.exit(1)
|
||||
|
||||
CHARACTER = argv[0]
|
||||
ANIM_DIR = argv[1]
|
||||
OUTPUT = argv[2]
|
||||
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
|
||||
MIX = "mixamorig:"
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
|
||||
|
||||
def to_pascal(stem):
|
||||
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_"))
|
||||
|
||||
|
||||
def find_armature(objects):
|
||||
for o in objects:
|
||||
if o.type == "ARMATURE":
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def normalize_prefix(name):
|
||||
"""mixamorig1:Hips / mixamorig_Hips / Hips -> mixamorig:Hips"""
|
||||
base = name
|
||||
for i in range(10):
|
||||
base = base.replace(f"mixamorig{i}:", MIX)
|
||||
base = base.replace("mixamorig_", MIX)
|
||||
if ":" not in base and base in CORE_BONES:
|
||||
base = MIX + base
|
||||
return base
|
||||
|
||||
|
||||
CORE_BONES = {
|
||||
"Hips", "Spine", "Spine1", "Spine2", "Neck", "Head",
|
||||
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
|
||||
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
|
||||
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase",
|
||||
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase",
|
||||
}
|
||||
|
||||
|
||||
def normalize_action_paths(action):
|
||||
for fc in action.fcurves:
|
||||
if 'pose.bones["' in fc.data_path:
|
||||
start = fc.data_path.index('"') + 1
|
||||
end = fc.data_path.index('"', start)
|
||||
bone = fc.data_path[start:end]
|
||||
fixed = normalize_prefix(bone)
|
||||
if fixed != bone:
|
||||
fc.data_path = fc.data_path[:start] + fixed + fc.data_path[end:]
|
||||
|
||||
|
||||
def hips_height(arm):
|
||||
for b in arm.data.bones:
|
||||
if b.name.endswith("Hips"):
|
||||
return (arm.matrix_world @ b.head_local).z
|
||||
return 1.0
|
||||
|
||||
|
||||
def scale_location_curves(action, ratio):
|
||||
if abs(ratio - 1.0) < 0.01:
|
||||
return
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path.endswith(".location"):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y *= ratio
|
||||
kp.handle_left.y *= ratio
|
||||
kp.handle_right.y *= ratio
|
||||
|
||||
|
||||
def strip_hips_horizontal(action):
|
||||
"""Zero hips X (side) and Y (forward) location so clips play in place.
|
||||
|
||||
Blender bone-local axes for a Y-up-exported Mixamo hips: X = side,
|
||||
Y = up (bone axis), Z = forward. We keep Y (vertical bob) and zero X/Z.
|
||||
"""
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path.endswith('.location') and "Hips" in fc.data_path and fc.array_index in (0, 2):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y = 0.0
|
||||
kp.handle_left.y = 0.0
|
||||
kp.handle_right.y = 0.0
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing character {CHARACTER}...")
|
||||
bpy.ops.import_scene.gltf(filepath=CHARACTER)
|
||||
target_arm = find_armature(bpy.data.objects)
|
||||
if not target_arm:
|
||||
print("ERROR: character has no armature — run tools/autorig.py first")
|
||||
sys.exit(1)
|
||||
|
||||
# Normalize character bone names too.
|
||||
for bone in target_arm.data.bones:
|
||||
fixed = normalize_prefix(bone.name)
|
||||
if fixed != bone.name:
|
||||
bone.name = fixed
|
||||
|
||||
target_hips = hips_height(target_arm)
|
||||
print(f"Character hips height: {target_hips:.3f}")
|
||||
|
||||
if not target_arm.animation_data:
|
||||
target_arm.animation_data_create()
|
||||
|
||||
# Drop any pre-existing animation so only canonical clips ship.
|
||||
for track in list(target_arm.animation_data.nla_tracks):
|
||||
target_arm.animation_data.nla_tracks.remove(track)
|
||||
target_arm.animation_data.action = None
|
||||
|
||||
anim_files = sorted(
|
||||
f for f in os.listdir(ANIM_DIR)
|
||||
if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith(".")
|
||||
)
|
||||
if not anim_files:
|
||||
print(f"ERROR: no animation files in {ANIM_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
merged = 0
|
||||
for fname in anim_files:
|
||||
clip_name = to_pascal(os.path.splitext(fname)[0])
|
||||
path = os.path.join(ANIM_DIR, fname)
|
||||
print(f"--- {fname} -> '{clip_name}'")
|
||||
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
try:
|
||||
if fname.lower().endswith(".fbx"):
|
||||
bpy.ops.import_scene.fbx(filepath=path, ignore_leaf_bones=True)
|
||||
else:
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
except Exception as e:
|
||||
print(f" SKIP: import failed: {e}")
|
||||
continue
|
||||
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
new_actions = [a for a in bpy.data.actions if a not in before_actions]
|
||||
src_arm = find_armature(new_objects)
|
||||
|
||||
if not new_actions:
|
||||
print(" SKIP: no action found in file")
|
||||
else:
|
||||
action = max(new_actions, key=lambda a: len(a.fcurves))
|
||||
action.name = clip_name
|
||||
normalize_action_paths(action)
|
||||
if src_arm:
|
||||
ratio = target_hips / max(hips_height(src_arm), 1e-6)
|
||||
scale_location_curves(action, ratio)
|
||||
if STRIP_ROOT_MOTION:
|
||||
strip_hips_horizontal(action)
|
||||
|
||||
track = target_arm.animation_data.nla_tracks.new()
|
||||
track.name = clip_name
|
||||
strip = track.strips.new(clip_name, 0, action)
|
||||
strip.name = clip_name
|
||||
track.mute = True
|
||||
action.use_fake_user = True
|
||||
merged += 1
|
||||
|
||||
# Remove the imported helper objects (keep the action).
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for a in new_actions:
|
||||
if a.name != clip_name:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
if merged == 0:
|
||||
print("ERROR: no animations merged")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Merged {merged} clips. Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="NLA_TRACKS",
|
||||
export_skins=True,
|
||||
export_bake_animation=True,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user