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