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
@@ -1 +1 @@
|
||||
uid://dn5ndscghwovm
|
||||
uid://5ayptqm4yjih
|
||||
|
||||
@@ -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
|
||||
@@ -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 @@
|
||||
uid://c7ltcn37gfd71
|
||||
uid://btkp2l168jj45
|
||||
|
||||
Reference in New Issue
Block a user