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
+173
View File
@@ -0,0 +1,173 @@
extends Node
## Autoload: central sound playback with variation + pitch randomization.
##
## Why: identical samples played back-to-back sound robotic. Real games ship
## 3-5 variations per event and randomize pitch a few percent per shot. This
## manager makes that the default for every sound in the game.
##
## Usage:
## AudioManager.play_3d("ak47_fire", muzzle_global_pos)
## AudioManager.play_ui("hit_confirm")
## AudioManager.register_sound("footstep_metal", [
## "res://assets/sounds/footstep_metal_01.wav",
## "res://assets/sounds/footstep_metal_02.wav",
## ], "Footsteps")
##
## Sounds are auto-registered from assets/sounds/*.wav on startup (the file
## stem is the sound id). Drop variations next to the original with _01/_02/…
## suffixes and they are picked up as one id with random selection:
## footstep.wav, footstep_01.wav, footstep_02.wav -> id "footstep" (3 vars)
##
## Buses: Master <- SFX <- {Weapons, Footsteps, UI}, Master <- Music.
## See docs/SOUND_DESIGN.md for where to get good source audio.
const SOUNDS_DIR := "res://assets/sounds"
const POOL_SIZE_3D := 32
const POOL_SIZE_2D := 8
# id -> { "streams": Array[AudioStream], "bus": String, "pitch_var": float, "volume_db": float }
var _library: Dictionary = {}
var _pool_3d: Array[AudioStreamPlayer3D] = []
var _pool_2d: Array[AudioStreamPlayer] = []
var _next_3d: int = 0
var _next_2d: int = 0
# Default routing/tuning per id prefix (applied during auto-registration).
const BUS_HINTS := {
"footstep": "Footsteps",
"fire": "Weapons",
"reload": "Weapons",
}
func _ready() -> void:
_setup_buses()
_build_pools()
_auto_register_sounds()
print("AudioManager: %d sounds registered" % _library.size())
# ── Bus layout ────────────────────────────────────────────────────────────────
func _setup_buses() -> void:
_ensure_bus("SFX", "Master")
_ensure_bus("Weapons", "SFX")
_ensure_bus("Footsteps", "SFX")
_ensure_bus("UI", "SFX")
_ensure_bus("Music", "Master")
func _ensure_bus(bus_name: String, send_to: String) -> void:
if AudioServer.get_bus_index(bus_name) != -1:
return
var idx := AudioServer.bus_count
AudioServer.add_bus(idx)
AudioServer.set_bus_name(idx, bus_name)
AudioServer.set_bus_send(idx, send_to)
func set_bus_volume_linear(bus_name: String, linear: float) -> void:
var idx := AudioServer.get_bus_index(bus_name)
if idx != -1:
AudioServer.set_bus_volume_db(idx, linear_to_db(clampf(linear, 0.0001, 1.0)))
# ── Registration ──────────────────────────────────────────────────────────────
func register_sound(id: String, paths: Array, bus: String = "SFX",
pitch_var: float = 0.06, volume_db: float = 0.0) -> void:
var streams: Array = []
for p in paths:
if ResourceLoader.exists(p):
var s = load(p)
if s:
streams.append(s)
if streams.is_empty():
return
_library[id] = {
"streams": streams,
"bus": bus,
"pitch_var": pitch_var,
"volume_db": volume_db,
}
func _auto_register_sounds() -> void:
var dir := DirAccess.open(SOUNDS_DIR)
if not dir:
return
# Group files: "footstep_01.wav" and "footstep.wav" both map to "footstep".
var groups: Dictionary = {}
for fname in dir.get_files():
if not (fname.ends_with(".wav") or fname.ends_with(".ogg") or fname.ends_with(".mp3")):
continue
var stem := fname.get_basename()
var base := stem
var parts := stem.rsplit("_", true, 1)
if parts.size() == 2 and parts[1].is_valid_int():
base = parts[0]
if not groups.has(base):
groups[base] = []
groups[base].append(SOUNDS_DIR + "/" + fname)
for id in groups:
var bus := "SFX"
for hint in BUS_HINTS:
if id.findn(hint) != -1:
bus = BUS_HINTS[hint]
break
register_sound(id, groups[id], bus)
func has_sound(id: String) -> bool:
return _library.has(id)
# ── Playback ──────────────────────────────────────────────────────────────────
func _build_pools() -> void:
for i in POOL_SIZE_3D:
var p := AudioStreamPlayer3D.new()
p.name = "Pool3D_%d" % i
p.max_distance = 80.0
p.unit_size = 8.0
add_child(p)
_pool_3d.append(p)
for i in POOL_SIZE_2D:
var p := AudioStreamPlayer.new()
p.name = "Pool2D_%d" % i
add_child(p)
_pool_2d.append(p)
## Play a positional sound. Safe to call every frame — players are pooled.
func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
pitch_scale: float = 1.0) -> AudioStreamPlayer3D:
var entry = _library.get(id)
if entry == null:
return null
var player := _pool_3d[_next_3d]
_next_3d = (_next_3d + 1) % POOL_SIZE_3D
player.stream = entry["streams"].pick_random()
player.bus = entry["bus"]
player.volume_db = entry["volume_db"] + volume_db_offset
player.pitch_scale = pitch_scale * randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
player.global_position = global_pos
player.play()
return player
## Play a non-positional sound (UI clicks, local confirms).
func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer:
var entry = _library.get(id)
if entry == null:
return null
var player := _pool_2d[_next_2d]
_next_2d = (_next_2d + 1) % POOL_SIZE_2D
player.stream = entry["streams"].pick_random()
player.bus = "UI"
player.volume_db = entry["volume_db"] + volume_db_offset
player.pitch_scale = randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
player.play()
return player