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
+81 -43
View File
@@ -1,62 +1,100 @@
extends Node
class_name SkinManager
## Manages all available player skins.
## Add new skins here or load from a config file.
## Autoload: manages all available player skins.
##
## Skins come from two places:
## 1. Built-in color tints for the procedural model (registered below).
## 2. assets/characters/skins/skins.json — written automatically by
## tools/pipeline.py for every imported character. No code changes needed
## to add a new model: run the pipeline, restart the game, it's there.
##
## The selected skin persists to user://skin_selection.cfg and is synced to
## other players via PlayerMovementController.synced_skin_id.
signal skin_changed(skin_id: String)
const SKINS_JSON := "res://assets/characters/skins/skins.json"
const SELECTION_CFG := "user://skin_selection.cfg"
var skins: Dictionary = {}
var active_skin_id: String = "default"
func _ready() -> void:
_register_default_skins()
_register_builtin_skins()
_load_skins_json()
_load_selection()
print("SkinManager: %d skins available" % skins.size())
func _register_default_skins() -> void:
# Default blue soldier
var default_skin = PlayerSkin.new()
default_skin.skin_name = "Default"
default_skin.description = "Standard issue"
default_skin.color_tint = Color(0.2, 0.4, 0.8)
default_skin.is_unlocked = true
skins["default"] = default_skin
# Red team
var red_skin = PlayerSkin.new()
red_skin.skin_name = "Red Team"
red_skin.description = "Red team colors"
red_skin.color_tint = Color(0.8, 0.2, 0.2)
red_skin.is_unlocked = true
skins["red_team"] = red_skin
func _register_builtin_skins() -> void:
_add_color_skin("default", "Default", "Standard issue", Color(0.2, 0.4, 0.8))
_add_color_skin("red_team", "Red Team", "Red team colors", Color(0.8, 0.2, 0.2))
_add_color_skin("forest", "Forest", "Forest camouflage", Color(0.2, 0.6, 0.2))
# Green soldier
var green_skin = PlayerSkin.new()
green_skin.skin_name = "Forest"
green_skin.description = "Forest camouflage"
green_skin.color_tint = Color(0.2, 0.6, 0.2)
green_skin.is_unlocked = true
skins["forest"] = green_skin
# Hatsune Miku
var miku_skin = PlayerSkin.new()
miku_skin.skin_name = "Miku"
miku_skin.description = "Hatsune Miku — Virtual Idol"
miku_skin.model_path = "res://assets/characters/skins/miku_proper_anim.glb"
miku_skin.color_tint = Color(0.0, 0.75, 0.75)
miku_skin.is_unlocked = true
skins["miku"] = miku_skin
func _add_color_skin(id: String, display_name: String, description: String, tint: Color) -> void:
var skin := PlayerSkin.new()
skin.skin_name = display_name
skin.description = description
skin.color_tint = tint
skin.is_unlocked = true
skins[id] = skin
print("SkinManager: registered %d skins" % skins.size())
for id in skins:
print(" - %s: %s" % [id, skins[id].skin_name])
func _load_skins_json() -> void:
if not FileAccess.file_exists(SKINS_JSON):
return
var file := FileAccess.open(SKINS_JSON, FileAccess.READ)
if not file:
return
var data = JSON.parse_string(file.get_as_text())
file.close()
if not data is Dictionary or not data.has("skins"):
push_warning("SkinManager: skins.json is malformed")
return
for entry in data["skins"]:
if not entry is Dictionary or not entry.has("id"):
continue
var skin := PlayerSkin.new()
skin.skin_name = entry.get("name", entry["id"])
skin.description = entry.get("description", "")
skin.model_path = entry.get("model", "")
skin.is_unlocked = entry.get("unlocked", true)
skins[entry["id"]] = skin
# ── Selection ─────────────────────────────────────────────────────────────────
func get_skin(skin_id: String) -> PlayerSkin:
if skins.has(skin_id):
return skins[skin_id]
return skins["default"]
return skins.get(skin_id, skins["default"])
func get_active_skin() -> PlayerSkin:
return get_skin(active_skin_id)
func set_active_skin(skin_id: String) -> void:
if skins.has(skin_id) and skins[skin_id].is_unlocked:
active_skin_id = skin_id
print("SkinManager: active skin set to '%s'" % skins[skin_id].skin_name)
if not skins.has(skin_id) or not skins[skin_id].is_unlocked:
return
active_skin_id = skin_id
_save_selection()
skin_changed.emit(skin_id)
func get_skin_ids() -> Array:
return skins.keys()
func _save_selection() -> void:
var cfg := ConfigFile.new()
cfg.set_value("skin", "active", active_skin_id)
cfg.save(SELECTION_CFG)
func _load_selection() -> void:
var cfg := ConfigFile.new()
if cfg.load(SELECTION_CFG) == OK:
var saved: String = cfg.get_value("skin", "active", "default")
if skins.has(saved):
active_skin_id = saved