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
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
One-command character pipeline for Papaya-Shooter:
Sketchfab model -> auto-rig -> merge animation library -> in game.
Examples:
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN):
python tools/pipeline.py --uid a1b2c3... --name space_marine
# From a local unrigged mesh:
python tools/pipeline.py --input downloads/robot.glb --name robot
# From an ALREADY-RIGGED model (Mixamo/AccuRig/Tripo output) — skips autorig:
python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged
The result is assets/characters/skins/<name>.glb with the full canonical
animation set, plus a registry entry in assets/characters/skins/skins.json
that SkinManager picks up automatically — no code changes needed.
Requires Blender 3.6+ on PATH or BLENDER_PATH set.
Animation library: assets/characters/animations/ (see docs/ASSET_SOURCES.md).
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOOLS = os.path.join(PROJECT_ROOT, "tools")
SKINS_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "skins")
ANIM_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "animations")
STAGING = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
SKINS_JSON = os.path.join(SKINS_DIR, "skins.json")
def find_blender() -> str:
env = os.environ.get("BLENDER_PATH")
if env and os.path.exists(env):
return env
on_path = shutil.which("blender")
if on_path:
return on_path
candidates = []
for pf in (r"C:\Program Files\Blender Foundation", r"C:\Program Files (x86)\Blender Foundation"):
if os.path.isdir(pf):
for d in sorted(os.listdir(pf), reverse=True):
exe = os.path.join(pf, d, "blender.exe")
if os.path.exists(exe):
candidates.append(exe)
if candidates:
return candidates[0]
print("ERROR: Blender not found. Install Blender or set BLENDER_PATH to blender.exe")
sys.exit(1)
def run(cmd: list[str], step: str) -> None:
print(f"\n=== {step} ===")
print(" ".join(f'"{c}"' if " " in c else c for c in cmd))
result = subprocess.run(cmd)
if result.returncode != 0:
print(f"ERROR: step '{step}' failed (exit {result.returncode})")
sys.exit(result.returncode)
def register_skin(name: str, display_name: str, model_res_path: str, description: str) -> None:
registry = {"skins": []}
if os.path.exists(SKINS_JSON):
with open(SKINS_JSON, "r", encoding="utf-8") as f:
registry = json.load(f)
skins = registry.setdefault("skins", [])
entry = {
"id": name,
"name": display_name,
"description": description,
"model": model_res_path,
"unlocked": True,
}
for i, s in enumerate(skins):
if s.get("id") == name:
skins[i] = entry
break
else:
skins.append(entry)
os.makedirs(SKINS_DIR, exist_ok=True)
with open(SKINS_JSON, "w", encoding="utf-8") as f:
json.dump(registry, f, indent=2)
print(f"Registered skin '{name}' in {os.path.relpath(SKINS_JSON, PROJECT_ROOT)}")
def main() -> None:
p = argparse.ArgumentParser(description="Sketchfab -> rigged, animated, in-game character")
src = p.add_mutually_exclusive_group(required=True)
src.add_argument("--uid", help="Sketchfab model UID to download")
src.add_argument("--input", help="local model file (glb/gltf/fbx/obj)")
p.add_argument("--name", required=True, help="skin id (snake_case)")
p.add_argument("--display-name", help="name shown in menus (default: from --name)")
p.add_argument("--description", default="", help="skin description")
p.add_argument("--rigged", action="store_true", help="input is already rigged — skip autorig")
p.add_argument("--height", type=float, default=1.75, help="target character height in meters")
p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips")
p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory")
args = p.parse_args()
name = args.name
display_name = args.display_name or name.replace("_", " ").title()
os.makedirs(STAGING, exist_ok=True)
# 1. Acquire the model.
if args.uid:
run([sys.executable, os.path.join(TOOLS, "sketchfab_import.py"),
"download", args.uid, "--name", name, "--out", STAGING],
"Download from Sketchfab")
input_path = os.path.join(STAGING, f"{name}.glb")
if not os.path.exists(input_path):
# glTF-zip fallback path used by the importer
alt = os.path.join(STAGING, name)
gltfs = [f for f in os.listdir(alt) if f.endswith((".gltf", ".glb"))] if os.path.isdir(alt) else []
if not gltfs:
print("ERROR: downloaded model not found in staging dir")
sys.exit(1)
input_path = os.path.join(alt, gltfs[0])
else:
input_path = os.path.abspath(args.input)
if not os.path.exists(input_path):
print(f"ERROR: input not found: {input_path}")
sys.exit(1)
blender = find_blender()
print(f"Using Blender: {blender}")
# 2. Auto-rig (or pass through if already rigged).
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
if args.rigged:
rigged_path = input_path
print("Skipping autorig (--rigged)")
else:
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
"--", input_path, rigged_path, str(args.height)],
"Auto-rig (Blender)")
# 3. Merge the shared animation library.
if not os.path.isdir(args.anim_dir) or not any(
f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)):
print(f"ERROR: animation library is empty: {args.anim_dir}")
print("Fill it with one clip per file (idle.fbx, run.fbx, ...) — see docs/ASSET_SOURCES.md")
sys.exit(1)
final_path = os.path.join(SKINS_DIR, f"{name}.glb")
merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"),
"--", rigged_path, args.anim_dir, final_path]
if args.keep_root_motion:
merge_cmd.append("--keep-root-motion")
run(merge_cmd, "Merge animation library (Blender)")
# 4. Carry the license file along if the model came from Sketchfab.
lic_src = os.path.splitext(input_path)[0] + ".license.json"
if os.path.exists(lic_src):
shutil.copyfile(lic_src, os.path.join(SKINS_DIR, f"{name}.license.json"))
# 5. Register so the game sees it.
register_skin(name, display_name, f"res://assets/characters/skins/{name}.glb", args.description)
print(f"\nDONE: {os.path.relpath(final_path, PROJECT_ROOT)}")
print(f"'{display_name}' is now selectable in-game (SkinManager reads skins.json).")
if __name__ == "__main__":
main()