using UnityEngine; namespace ProjectM.Client { /// /// Pooled 3D one-shot SFX voices — the allocation-free replacement for /// AudioSource.PlayClipAtPoint that sits behind . /// /// PlayClipAtPoint allocates a fresh GameObject + AudioSource per call and /// schedules a delayed Destroy. Every one-shot in the project funnels through /// (footsteps, hits, kills, telegraphs, growls, strike beeps, /// swings, connects, socket fire, dash), measured at ~20-33 calls/s in light combat — i.e. hundreds /// of create/destroy pairs per ten seconds of play. This ring replaces that with a fixed set of /// long-lived voices, so a one-shot costs zero managed allocation. /// /// /// PARITY with PlayClipAtPoint matters — 20 call sites are re-levelled at once by any deviation. /// PlayClipAtPoint sets spatialBlend = 1 explicitly (a fresh AudioSource defaults to 0 = /// fully 2D) and leaves everything else at the stock defaults, so those are all re-stated in /// . Two DELIBERATE divergences, both forced by the voices being long-lived /// rather than per-event: playOnAwake = false (a pooled source would otherwise self-start on /// any enable, replaying whatever clip is still assigned) and dopplerLevel = 0 (a pooled /// voice TELEPORTS between events — at the stock dopplerLevel of 1 a 20 m jump pitch-bends the clip, /// a bug that cannot exist when the source is created at the position and never moves). /// /// /// Lifetime: the root is DontDestroyOnLoad because WorldLauncher does /// LoadScene(..., Single) while the client world is alive — a scene-parented pool would be /// destroyed mid-session and every SFX would go silently dead. Statics survive fast-enter-playmode /// domain reloads, so nulls them on play-enter (the house rule; without /// it the second play session holds an array of destroyed objects and the first cue throws /// MissingReferenceException). Both the root and each voice are additionally rebuilt on /// fake-null, so anything that does destroy them heals instead of going quiet. /// /// Main-thread only (every caller is a PresentationSystemGroup SystemBase) — no locking. /// Asset-free: built from new GameObject + AddComponent, never a prefab or Resources.Load. /// public static class OneShotAudioPool { /// /// Voice count. Observed peak is ~33 starts/s; the live combat cues top out at 0.45 s (the barrel boom), /// which is ~15 concurrent voices worst case, so 32 leaves headroom and stealing never bites in practice. /// The longest clip that can reach this pool at all is AmbientLifeSystem's 0.9 s thunder, and that sits /// behind a currently-unreachable biome branch. Do not drop below 24: PlayClipAtPoint is effectively /// unbounded in voice count, and bounded stealing is the one intentional behaviour change here. /// /// const int RingSize = 32; static GameObject s_root; static AudioSource[] s_voices; static float[] s_freeAt; // unscaled time each voice is expected to finish; 0 = idle /// /// Play-enter reset (the static-presentation-bridge rule). Statics survive a fast-enter-playmode /// domain reload but the UnityEngine.Objects they point at do NOT survive exiting play mode — so /// the array must be dropped, not reused, or session two rents destroyed voices. /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void ResetStatics() { s_root = null; s_voices = null; s_freeAt = null; } /// /// Fire a positional one-shot. is the FINAL level — the caller /// () has already applied the GameVolume.Sfx bus trim, /// exactly as the PlayClipAtPoint call did. Baked in at play time and never re-applied to a live /// voice, so moving the SFX slider cannot retroactively re-level a cue already in flight. /// public static void Play(AudioClip clip, Vector3 pos, float volume) { if (clip == null || !Application.isPlaying) return; var voice = Rent(clip.length); if (voice == null) return; // Order matters: position BEFORE Play so the voice is spatialised at the event, not at // wherever the previous rent left it (a pooled voice parked at the origin loses 3D panning). voice.transform.position = pos; voice.clip = clip; voice.pitch = 1f; // defensive: a future per-cue pitch jitter must never leak across a reuse voice.volume = volume; voice.Play(); } /// /// Stop every voice. Called on client-world teardown so in-flight combat SFX do not bleed into /// the main menu (the pool outlives the world by design — it is DontDestroyOnLoad). /// public static void SilenceAll() { if (s_voices == null) return; for (int i = 0; i < s_voices.Length; i++) { if (s_voices[i] != null) s_voices[i].Stop(); s_freeAt[i] = 0f; } } /// /// First idle voice, else steal the one nearest to finishing (smallest deadline). Deadlines come /// from the actual clip.length — never a fixed constant: the project's SFX span 0.05 s /// (strike beep) to 0.45 s (barrel boom), and a fixed recycle window would audibly truncate the /// long ones. Time.unscaledTime because this project never touches Time.timeScale /// (hit-stop is a camera punch by house rule). /// static AudioSource Rent(float clipLength) { Build(); if (s_voices == null) return null; float now = Time.unscaledTime; int steal = -1; float oldest = float.MaxValue; for (int i = 0; i < s_voices.Length; i++) { if (s_voices[i] == null) continue; // destroyed out from under us; Build() already retried it if (s_freeAt[i] <= now) { s_freeAt[i] = now + Mathf.Max(0.01f, clipLength); return s_voices[i]; } if (s_freeAt[i] < oldest) { oldest = s_freeAt[i]; steal = i; } } if (steal < 0) return null; s_freeAt[steal] = now + Mathf.Max(0.01f, clipLength); return s_voices[steal]; } /// Idempotent: builds the root and any missing voice, and heals fake-null entries. static void Build() { if (s_root == null) { s_root = new GameObject("~OneShotAudioPool") { hideFlags = HideFlags.HideAndDontSave }; Object.DontDestroyOnLoad(s_root); } if (s_voices == null || s_voices.Length != RingSize) { s_voices = new AudioSource[RingSize]; s_freeAt = new float[RingSize]; } for (int i = 0; i < RingSize; i++) if (s_voices[i] == null) { s_voices[i] = MakeVoice(i); s_freeAt[i] = 0f; } } /// /// One voice, configured for PlayClipAtPoint parity. Every stock default is re-stated explicitly /// because a pooled source is long-lived and would otherwise drift with any future edit. /// static AudioSource MakeVoice(int index) { var go = new GameObject("Voice" + index); go.transform.SetParent(s_root.transform, false); var src = go.AddComponent(); src.playOnAwake = false; // divergence #1: a long-lived source must never self-start src.dopplerLevel = 0f; // divergence #2: pooled voices teleport; Doppler would pitch-bend them src.spatialBlend = 1f; // the one thing PlayClipAtPoint sets explicitly (default is 2D) src.loop = false; src.mute = false; src.rolloffMode = AudioRolloffMode.Logarithmic; src.minDistance = 1f; src.maxDistance = 500f; src.spread = 0f; src.priority = 128; src.panStereo = 0f; src.reverbZoneMix = 1f; src.bypassEffects = false; src.bypassListenerEffects = false; src.bypassReverbZones = false; src.pitch = 1f; src.volume = 1f; src.outputAudioMixerGroup = null; // no AudioMixer in this project: straight to the listener return src; } } }