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
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
Sketchfab model importer for Papaya-Shooter.
Searches and downloads models via the official Sketchfab Download API,
extracts the GLB/glTF, and stages it for the rigging pipeline.
Auth (any one of):
- env var SKETCHFAB_API_TOKEN
- --token <token>
- a file named .sketchfab_token in the project root (gitignored)
Usage:
python tools/sketchfab_import.py search "anime character" [--limit 10]
python tools/sketchfab_import.py download <model-uid> [--out assets/characters/incoming]
python tools/sketchfab_import.py info <model-uid>
Notes:
- Only models marked "downloadable" can be fetched (license permitting).
- License + attribution metadata is saved next to the model as <name>.license.json.
CC-BY requires crediting the author — keep those files!
"""
import argparse
import io
import json
import os
import re
import sys
import urllib.parse
import urllib.request
import zipfile
API_BASE = "https://api.sketchfab.com/v3"
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_OUT = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
def get_token(cli_token: str | None) -> str:
if cli_token:
return cli_token
tok = os.environ.get("SKETCHFAB_API_TOKEN", "").strip()
if tok:
return tok
token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token")
if os.path.exists(token_file):
with open(token_file, "r", encoding="utf-8") as f:
tok = f.read().strip()
if tok:
return tok
print("ERROR: no Sketchfab API token found.")
print("Set SKETCHFAB_API_TOKEN, pass --token, or create .sketchfab_token in the project root.")
print("Your token: https://sketchfab.com/settings/password (API token section)")
sys.exit(1)
def api_get(path: str, token: str, params: dict | None = None) -> dict:
url = API_BASE + path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"Authorization": f"Token {token}"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:500]
print(f"ERROR: API request failed ({e.code}) for {url}\n{body}")
sys.exit(1)
def slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
return slug or "model"
def cmd_search(args) -> None:
token = get_token(args.token)
params = {
"type": "models",
"q": args.query,
"downloadable": "true",
"count": str(args.limit),
"sort_by": "-likeCount",
}
if args.animated:
params["animated"] = "true"
if args.rigged:
params["rigged"] = "true"
data = api_get("/search", token, params)
results = data.get("results", [])
if not results:
print("No downloadable results.")
return
print(f"{'UID':<34} {'License':<18} {'Faces':>9} Name")
print("-" * 90)
for m in results:
lic = (m.get("license") or {}).get("label", "?")
faces = m.get("faceCount", 0)
print(f"{m['uid']:<34} {lic:<18} {faces:>9} {m['name']}")
print("\nDownload one with: python tools/sketchfab_import.py download <UID>")
def cmd_info(args) -> None:
token = get_token(args.token)
m = api_get(f"/models/{args.uid}", token)
lic = m.get("license") or {}
print(f"Name: {m.get('name')}")
print(f"Author: {(m.get('user') or {}).get('displayName')}")
print(f"License: {lic.get('label')} ({lic.get('slug')})")
print(f"Downloadable:{m.get('isDownloadable')}")
print(f"Faces: {m.get('faceCount')} Verts: {m.get('vertexCount')}")
print(f"Animations: {m.get('animationCount')}")
print(f"URL: {m.get('viewerUrl')}")
def cmd_download(args) -> None:
token = get_token(args.token)
meta = api_get(f"/models/{args.uid}", token)
name = args.name or slugify(meta.get("name", args.uid))
out_dir = os.path.abspath(args.out)
os.makedirs(out_dir, exist_ok=True)
dl = api_get(f"/models/{args.uid}/download", token)
# Prefer the single-file GLB; fall back to the glTF zip.
glb_path = os.path.join(out_dir, f"{name}.glb")
if "glb" in dl and dl["glb"].get("url"):
print(f"Downloading GLB ({dl['glb'].get('size', 0) / 1e6:.1f} MB)...")
urllib.request.urlretrieve(dl["glb"]["url"], glb_path)
elif "gltf" in dl and dl["gltf"].get("url"):
print(f"Downloading glTF zip ({dl['gltf'].get('size', 0) / 1e6:.1f} MB)...")
with urllib.request.urlopen(dl["gltf"]["url"], timeout=300) as resp:
zdata = resp.read()
extract_dir = os.path.join(out_dir, name)
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(zdata)) as zf:
zf.extractall(extract_dir)
gltfs = [f for f in os.listdir(extract_dir) if f.endswith((".gltf", ".glb"))]
if not gltfs:
print(f"ERROR: no .gltf/.glb found in archive at {extract_dir}")
sys.exit(1)
glb_path = os.path.join(extract_dir, gltfs[0])
else:
print("ERROR: model has no downloadable GLB/glTF archive.")
sys.exit(1)
# Save license/attribution metadata — required for CC-BY credit.
lic = meta.get("license") or {}
license_info = {
"name": meta.get("name"),
"uid": args.uid,
"author": (meta.get("user") or {}).get("displayName"),
"author_url": (meta.get("user") or {}).get("profileUrl"),
"license": lic.get("label"),
"license_slug": lic.get("slug"),
"source_url": meta.get("viewerUrl"),
}
license_path = os.path.splitext(glb_path)[0] + ".license.json"
with open(license_path, "w", encoding="utf-8") as f:
json.dump(license_info, f, indent=2)
print(f"Saved: {glb_path}")
print(f"License: {license_path} ({lic.get('label')})")
print(f"\nNext step (auto-rig + animations + register in game):")
print(f" python tools/pipeline.py --input \"{glb_path}\" --name {name}")
def main() -> None:
p = argparse.ArgumentParser(description="Sketchfab importer")
p.add_argument("--token", help="Sketchfab API token (else SKETCHFAB_API_TOKEN)")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("search", help="search downloadable models")
s.add_argument("query")
s.add_argument("--limit", type=int, default=10)
s.add_argument("--animated", action="store_true", help="only models with animations")
s.add_argument("--rigged", action="store_true", help="only rigged models")
s.set_defaults(func=cmd_search)
i = sub.add_parser("info", help="show model metadata")
i.add_argument("uid")
i.set_defaults(func=cmd_info)
d = sub.add_parser("download", help="download a model by UID")
d.add_argument("uid")
d.add_argument("--out", default=DEFAULT_OUT)
d.add_argument("--name", help="output base name (default: slugified model name)")
d.set_defaults(func=cmd_download)
args = p.parse_args()
# Propagate the global --token even when given after the subcommand.
args.func(args)
if __name__ == "__main__":
main()