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