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;
}
}
}