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:
Nicholas Butzke
2026-07-06 02:28:58 -04:00
co-authored by Claude Fable 5
parent 552338112e
commit d05477c135
52 changed files with 1933 additions and 466 deletions
+221
View File
@@ -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()