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