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
+1 -1
View File
@@ -1 +1 @@
uid://dn5ndscghwovm
uid://5ayptqm4yjih
+116
View File
@@ -0,0 +1,116 @@
extends SceneTree
## Headless smoke test for the player spawn + skin + animation pipeline.
## Boots the test level as a singleplayer match, spawns the local player,
## ticks frames, and verifies the visual model came up for both the
## procedural skin and a GLB skin.
##
## Run: godot --headless --path . -s res://debug/spawn_smoke_test.gd
var _failures: Array = []
func _init() -> void:
call_deferred("_run")
func _run() -> void:
await process_frame
await process_frame
var skin_mgr = root.get_node_or_null("/root/SkinManager")
_check(skin_mgr != null, "SkinManager autoload exists")
var audio_mgr = root.get_node_or_null("/root/AudioManager")
_check(audio_mgr != null, "AudioManager autoload exists")
if audio_mgr:
_check(audio_mgr.has_sound("ak47_fire"), "AudioManager auto-registered ak47_fire")
# Pick the GLB skin if its model file is present, else default.
var glb_skin_id := ""
if skin_mgr:
for id in skin_mgr.get_skin_ids():
var s = skin_mgr.get_skin(id)
if s.model_path != "" and ResourceLoader.exists(s.model_path):
glb_skin_id = id
break
await _test_spawn_with_skin("default", false)
if glb_skin_id != "":
await _test_spawn_with_skin(glb_skin_id, true)
else:
print("NOTE: no GLB skin available, skipped skinned model test")
print("\n=== SPAWN SMOKE SUMMARY ===")
print("Failures: %d" % _failures.size())
for f in _failures:
print("FAIL: ", f)
quit(0 if _failures.is_empty() else 1)
func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
print("\n--- Spawn test with skin '%s' ---" % skin_id)
var skin_mgr = root.get_node_or_null("/root/SkinManager")
if skin_mgr:
skin_mgr.set_active_skin(skin_id)
var nm = root.get_node_or_null("/root/NetworkManager")
if not _check(nm != null, "NetworkManager autoload exists"):
return
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
# Let the level build and the player spawn + settle.
for i in 30:
await process_frame
var level = current_scene
if not _check(level != null, "level scene loaded"):
return
var player = _find_player(level)
if not _check(player != null, "player '1' spawned"):
return
_check(player.get_node_or_null("MovementStateMachine") != null, "state machine present")
var visual = player.get_visual_model() if player.has_method("get_visual_model") else null
_check(visual != null, "visual model present")
if expect_skinned:
var skinned = player.get_node_or_null("SkinnedModel")
if _check(skinned != null, "SkinnedModel created for GLB skin"):
_check(skinned.loaded, "GLB model loaded")
_check(skinned.skeleton != null, "skeleton found in GLB")
if skinned.animation_player:
_check(skinned.animation_player.is_playing(), "animation playing")
print(" clips resolved: ", skinned._resolved_clips)
# Drive some movement states through the same API the game uses.
for state in ["ground", "air", "slide", "wall_run", "dash"]:
skinned.update_state(state, 9.0, false)
await process_frame
_check(true, "state cycling did not crash")
else:
var humanoid = player.get_node_or_null("HumanoidModel")
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
# Simulate a few physics frames of idle play.
for i in 30:
await physics_frame
_check(is_instance_valid(player), "player survived 30 physics frames")
func _find_player(node: Node) -> Node:
if node is CharacterBody3D and node.name == "1" and node.has_method("get_visual_model"):
return node
for child in node.get_children():
var found := _find_player(child)
if found:
return found
return null
func _check(ok: bool, msg: String) -> bool:
if ok:
print(" OK: ", msg)
else:
print(" FAIL: ", msg)
_failures.append(msg)
return ok
+8 -6
View File
@@ -299,15 +299,9 @@ func _spawn_player(pid: int) -> CharacterBody3D:
server_sync.name = "ServerSynchronizer"
server_sync.set_multiplayer_authority(1) # Host always controls these
var server_rep_config = SceneReplicationConfig.new()
server_rep_config.add_property(":position")
server_rep_config.add_property(":synced_movement_state")
server_rep_config.add_property(":synced_movement_speed")
server_rep_config.add_property(":synced_is_crouching")
server_rep_config.add_property(":health")
server_rep_config.add_property(":shield")
server_rep_config.add_property(":is_dead")
server_rep_config.add_property(":synced_grapple_point")
server_rep_config.add_property(":synced_is_grapple_shooting")
server_sync.replication_config = server_rep_config
player.add_child(server_sync)
@@ -316,8 +310,16 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_sync.name = "MultiplayerSynchronizer" # Keep original name for compatibility if needed elsewhere
client_sync.set_multiplayer_authority(pid)
var client_rep_config = SceneReplicationConfig.new()
client_rep_config.add_property(":synced_position")
client_rep_config.add_property(":synced_velocity")
client_rep_config.add_property(":rotation")
client_rep_config.add_property("HeadPivot:rotation")
client_rep_config.add_property(":synced_movement_state")
client_rep_config.add_property(":synced_movement_speed")
client_rep_config.add_property(":synced_is_crouching")
client_rep_config.add_property(":synced_grapple_point")
client_rep_config.add_property(":synced_is_grapple_shooting")
client_rep_config.add_property(":synced_skin_id")
client_rep_config.add_property(":synced_weapon_path")
client_rep_config.add_property(":synced_loadout_p1")
client_rep_config.add_property(":synced_loadout_p2")
+1 -1
View File
@@ -1 +1 @@
uid://c7ltcn37gfd71
uid://btkp2l168jj45