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,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()
|
||||
Reference in New Issue
Block a user