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
+3 -1
View File
@@ -71,7 +71,9 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if player and not player.multiplayer.is_server(): return
# Client-authoritative movement: only the peer that OWNS this player
# simulates it. Everyone else interpolates the synced transform.
if player and not player.is_multiplayer_authority(): return
# Update coyote time
if on_ground:
+142 -71
View File
@@ -59,10 +59,24 @@ var grapple_rope: MeshInstance3D
# ── Multiplayer Sync Variables ─────────────────────────────────────────────
# These are written by the authority each frame and replicated to remote peers
# via the MultiplayerSynchronizer properties.
# Movement is CLIENT-AUTHORITATIVE: the owning peer simulates locally and
# broadcasts position/velocity; remote peers interpolate toward it (see
# _process). The server stays authoritative for health, shield, and kills.
var synced_movement_state: String = "idle"
var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false
var synced_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO
@export var synced_skin_id: String = ""
@export var synced_weapon_path: String = ""
# Remote interpolation tuning
const NET_LERP_RATE := 18.0 # exponential smoothing rate for remote players
const NET_SNAP_DISTANCE := 6.0 # teleport if desync exceeds this (respawns etc.)
const NET_EXTRAPOLATION := 0.05 # seconds of velocity extrapolation
var skinned_model: Node3D = null
var _applied_skin_id: String = "__none__"
@export var synced_grapple_point: Vector3 = Vector3.ZERO
@export var synced_is_grapple_shooting: bool = false
@@ -114,6 +128,54 @@ func _ready() -> void:
set_process(true)
if is_multiplayer_authority():
set_physics_process(true)
var skin_mgr = get_node_or_null("/root/SkinManager")
if skin_mgr:
synced_skin_id = skin_mgr.active_skin_id
synced_position = position
# Build the visual model for whatever skin is selected (remote peers get
# the id via the synchronizer and rebuild in _process when it arrives).
_apply_skin_model(synced_skin_id)
## Returns the node that visually represents this player (skinned GLB model
## if the active skin has one, otherwise the procedural HumanoidModel).
func get_visual_model() -> Node3D:
if is_instance_valid(skinned_model):
return skinned_model
return get_node_or_null("HumanoidModel")
## Swap the visual model to match a skin id. GLB skins replace the procedural
## model entirely; color skins tint the procedural model.
func _apply_skin_model(skin_id: String) -> void:
_applied_skin_id = skin_id
var humanoid = get_node_or_null("HumanoidModel")
var old = get_node_or_null("SkinnedModel")
if old:
remove_child(old)
old.queue_free()
skinned_model = null
var skin_mgr = get_node_or_null("/root/SkinManager")
var skin = skin_mgr.get_skin(skin_id) if skin_mgr else null
var has_model: bool = skin != null and skin.model_path != "" \
and (ResourceLoader.exists(skin.model_path) or FileAccess.file_exists(skin.model_path))
if has_model:
var model := SkinnedPlayerModel.new()
model.name = "SkinnedModel"
model.model_path = skin.model_path
# Owner gets the first-person body view (head hidden, fully animated);
# everyone else sees the full third-person model.
model.first_person_mode = is_multiplayer_authority()
model.position = Vector3(0, -0.9, 0) # capsule center -> feet
add_child(model)
skinned_model = model
if humanoid:
humanoid.visible = false
else:
if humanoid:
humanoid.visible = not is_dead
if skin and humanoid.has_method("_apply_color"):
humanoid._apply_color(skin.color_tint)
func _hide_remote_weapons() -> void:
if camera:
@@ -304,7 +366,19 @@ func server_apply_impulse(force: Vector3) -> void:
if not multiplayer.is_server(): return
var sender = multiplayer.get_remote_sender_id()
if sender != 1 and sender != str(name).to_int(): return
apply_impulse(force)
# Movement is client-authoritative, so the impulse must land on the peer
# that simulates this body.
if is_multiplayer_authority():
apply_impulse(force)
else:
authority_apply_impulse.rpc_id(get_multiplayer_authority(), force)
@rpc("any_peer", "call_local", "reliable")
func authority_apply_impulse(force: Vector3) -> void:
if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
if multiplayer.get_remote_sender_id() != 1: return
if is_multiplayer_authority():
apply_impulse(force)
func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impulse: Vector3 = Vector3.ZERO) -> void:
var attacker_id = 0
@@ -508,11 +582,10 @@ func rpc_play_explosion(pos: Vector3, radius: float) -> void:
@rpc("any_peer", "call_local", "reliable")
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
if not multiplayer.is_server(): return
if impulse.length_squared() > 0.01:
apply_impulse(impulse)
# Broadcast damage event to all peers so the victim dies on all screens
# Broadcast damage event to all peers so the victim dies on all screens.
# Knockback is applied inside rpc_take_damage on the victim's own peer,
# since that peer simulates this body.
rpc_take_damage.rpc(amount, hit_pos, attacker_id, weapon_name, impulse)
@rpc("any_peer", "call_local", "reliable")
@@ -538,6 +611,10 @@ func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_n
if attacker_id != 0:
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
# Knockback lands on the simulating peer (movement is client-authoritative)
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
apply_impulse(impulse)
time_since_last_damage = 0.0
if shield > 0.0:
@@ -647,32 +724,9 @@ func _ensure_machine() -> MovementStateMachine:
_machine = null
return null
var _client_input_dir: Vector2 = Vector2.ZERO
var _client_wish_dir_world: Vector3 = Vector3.ZERO
var _client_jump: bool = false
var _client_jump_just_pressed: bool = false
var _client_crouch: bool = false
var _client_dash: bool = false
var _client_grapple: bool = false
var _client_grapple_just_pressed: bool = false
@rpc("any_peer", "call_local", "unreliable")
func server_receive_inputs(input_dir: Vector2, wish_dir: Vector3, jump: bool, jump_just: bool, crouch: bool, dash: bool, grapple: bool, grapple_just: bool) -> void:
if not multiplayer.is_server(): return
var sender = multiplayer.get_remote_sender_id()
if sender != str(name).to_int() and sender != 1: return # Accept from owner or host self
_client_input_dir = input_dir
_client_wish_dir_world = wish_dir
_client_jump = jump
_client_jump_just_pressed = jump_just
_client_crouch = crouch
_client_dash = dash
_client_grapple = grapple
_client_grapple_just_pressed = grapple_just
func _physics_process(_delta: float) -> void:
# Local client captures input and sends it
# The owning peer reads input and feeds its own state machine directly.
# No server round-trip: movement responds on the same frame it's pressed.
if is_multiplayer_authority():
var raw_input := Vector2.ZERO
var input_jump := false
@@ -705,8 +759,17 @@ func _physics_process(_delta: float) -> void:
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
flashlight.visible = !flashlight.visible
server_receive_inputs.rpc_id(1, raw_input, world_dir, input_jump, input_jump_just, input_crouch, input_dash, input_grapple, input_grapple_just)
var machine := _ensure_machine()
if machine:
machine.input_dir = raw_input
machine.wish_dir_world = world_dir
machine.input_jump_pressed = input_jump
machine.input_jump_just_pressed = input_jump_just
machine.input_crouch = input_crouch
machine.input_dash = input_dash
machine.input_grapple = input_grapple
machine.input_grapple_just_pressed = input_grapple_just
var speed = velocity.length()
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
@@ -718,23 +781,8 @@ func _physics_process(_delta: float) -> void:
if not sm:
return
# Only the Server sets input values to the StateMachine for evaluation
if multiplayer.is_server():
sm.input_dir = _client_input_dir
sm.wish_dir_world = _client_wish_dir_world
sm.input_jump_pressed = _client_jump
sm.input_jump_just_pressed = _client_jump_just_pressed
sm.input_crouch = _client_crouch
sm.input_dash = _client_dash
sm.input_grapple = _client_grapple
sm.input_grapple_just_pressed = _client_grapple_just_pressed
# Reset one-frame actions
_client_jump_just_pressed = false
_client_dash = false
_client_grapple_just_pressed = false
# Update synced properties for the grapple
# The owning peer publishes its grapple state for everyone's rope visuals
if is_multiplayer_authority():
synced_grapple_point = sm.grapple_point
synced_is_grapple_shooting = sm.is_grapple_shooting
@@ -761,18 +809,18 @@ func _physics_process(_delta: float) -> void:
else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
# Update humanoid model animation state (runs everywhere)
# Update humanoid model animation state
var humanoid = get_node_or_null("HumanoidModel")
if humanoid:
# Drive the visual model (skinned GLB or procedural) from local state
var visual = get_visual_model()
if visual:
var h_speed = Vector2(velocity.x, velocity.z).length()
humanoid.update_state(sm.current_state, h_speed, sm.input_crouch)
# Write synced state for remote peers
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
# Publish state for remote peers
synced_movement_state = sm.current_state
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
synced_is_crouching = sm.input_crouch
synced_position = position
synced_velocity = velocity
func _on_movement_event(ev: String, data: Dictionary) -> void:
@@ -784,21 +832,37 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
grapple_latch_player.play()
func _process(delta: float) -> void:
# Remote players: apply synced movement state to their HumanoidModel
# Remote players: interpolate toward the owner's synced transform and
# apply synced movement state to their visual model
if not is_multiplayer_authority():
if synced_loadout_ready and not has_meta("remote_weapons_built"):
set_meta("remote_weapons_built", true)
var wman = camera.get_node_or_null("WeaponManager")
if wman and wman.has_method("_build_remote_loadout"):
wman._build_remote_loadout(synced_loadout_p1, synced_loadout_p2, synced_loadout_sp, synced_loadout_melee)
var humanoid = get_node_or_null("HumanoidModel")
if humanoid:
humanoid.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
# Skin can arrive/change after spawn — rebuild the model when it does
if synced_skin_id != _applied_skin_id:
_apply_skin_model(synced_skin_id)
# Snapshot interpolation: chase the owner's last known position with a
# little velocity extrapolation so fast targets stay accurate between
# packets. Snap on big desyncs (respawn/teleport).
if not is_dead and synced_position != Vector3.ZERO:
var target := synced_position + synced_velocity * NET_EXTRAPOLATION
if position.distance_to(target) > NET_SNAP_DISTANCE:
position = target
else:
position = position.lerp(target, 1.0 - exp(-NET_LERP_RATE * delta))
velocity = synced_velocity
var visual = get_visual_model()
if visual:
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
# Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != humanoid.get_meta("current_weapon_path", ""):
humanoid.set_weapon(synced_weapon_path)
humanoid.set_meta("current_weapon_path", synced_weapon_path)
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path)
visual.set_meta("current_weapon_path", synced_weapon_path)
return
# Update UI
@@ -1217,14 +1281,21 @@ func rpc_respawn(spawn_pos: Vector3) -> void:
if col:
col.set_deferred("disabled", false)
var visual_model = get_node_or_null("HumanoidModel")
var visual_model = get_visual_model()
if visual_model:
visual_model.visible = true
var humanoid_model = get_node_or_null("HumanoidModel")
if humanoid_model and humanoid_model != visual_model:
humanoid_model.visible = not is_instance_valid(skinned_model)
# Server sets the actual position to sync to everyone
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
position = spawn_pos
velocity = Vector3.ZERO
# Everyone applies the broadcast spawn position; the owning peer also
# resets its synced transform so remotes snap instead of lerping across
# the map.
position = spawn_pos
velocity = Vector3.ZERO
if is_multiplayer_authority():
synced_position = spawn_pos
synced_velocity = Vector3.ZERO
# Local client resets UI and rebuilds weapons for their view
if is_multiplayer_authority():