using System; using System.Collections; using System.Collections.Generic; using System.Linq; using OCES.Haptic; using UnityEngine; using UnityEngine.Audio; namespace OCES.Audio { /// /// 音效系统 /// public class SfxSystem : MonoBehaviour { public UnityEngine.UI.Text audioSystemTextBox; const int k_maxGlobalConcurrent = 128; //记录某个 AudioObject 最近一次触发播放的时刻,用于 MinInterval 节流判断,是"上次什么时候播过"。 readonly Dictionary m_lastPlayTime = new(); readonly Dictionary m_clipConcurrentCount = new(); readonly List m_activeSounds = new(); // 复用列表,避免 TryPlay 每帧分配临时 List readonly List m_tempSameObject = new(); readonly List m_tempSameGroup = new(); readonly List m_tempLowerPriority = new(); AudioGroupConfig m_groupConfig; AudioMixerGroup m_sfxGroup; AudioMixerGroup m_voiceGroup; AudioMixerGroup m_accentSfxGroup; AudioSourcePool m_pool; AudioContainerSelector m_containerSelector; PitchStepResolver m_pitchStepResolver; VolumeStepResolver m_volumeStepResolver; #if UNITY_EDITOR || DEVELOPMENT_BUILD void Update() { Editor.DebugInfoCollector.Instance.ActiveSounds = this.m_activeSounds; Editor.DebugInfoCollector.Instance.ClipConcurrentCount = this.m_clipConcurrentCount; } #endif int GetClipCount(uint id) { return this.m_clipConcurrentCount.GetValueOrDefault(id, 0); } void IncrementClipCount(uint id) { this.m_clipConcurrentCount[id] = GetClipCount(id) + 1; #if UNITY_EDITOR //Debug.Log($"{id} count added to {GetClipCount(id)}"); #endif } void DecrementClipCount(uint id) { int cur = GetClipCount(id); if (cur > 0) this.m_clipConcurrentCount[id] = cur - 1; //Debug.Log($"{id} clip count minus to {GetClipCount(id)}"); } // ───────────────────────────────────────────── // 初始化 // ───────────────────────────────────────────── public void Initialize(AudioGroupConfig groups, AudioMixer mixer, AudioSourcePool pool) { this.m_groupConfig = groups; this.m_pool = pool; this.m_containerSelector = new AudioContainerSelector(); this.m_pitchStepResolver = new PitchStepResolver(); this.m_volumeStepResolver = new VolumeStepResolver(); AudioMixerGroup[] sfx = Find("Master/Regular/SFX"); if (sfx.Length > 0) this.m_sfxGroup = sfx[0]; AudioMixerGroup[] voice = Find("Master/Regular/Voice"); if (voice.Length > 0) this.m_voiceGroup = voice[0]; AudioMixerGroup[] accentSfx = Find("Master/SFX_Accent"); if (accentSfx.Length > 0) this.m_accentSfxGroup = accentSfx[0]; return; AudioMixerGroup[] Find(string path) => mixer.FindMatchingGroups(path); } internal void Stop(uint audioId) { List stopTargets = this.m_activeSounds.FindAll(activeSound => activeSound.AudioObject.Id == audioId); foreach (ActiveSound stopTarget in stopTargets) { StopSound(stopTarget); } } internal void SetVolume(uint audioId, float targetVolume) { List targets = this.m_activeSounds.FindAll(activeSound => activeSound.AudioObject.Id == audioId); foreach (ActiveSound target in targets) { target.Volume = targetVolume; if (target.Source) { target.Source.volume = targetVolume; } } } internal void SetPitch(uint audioId, float targetPitch) { List targets = this.m_activeSounds.FindAll(activeSound => activeSound.AudioObject.Id == audioId); foreach (ActiveSound target in targets) { target.Pitch = targetPitch; if (target.Source) { target.Source.pitch = targetPitch; } } } internal void ResetVolume(uint audioId) { double now = Time.realtimeSinceStartupAsDouble * 1000.0; List targets = this.m_activeSounds.FindAll(activeSound => activeSound.AudioObject.Id == audioId); foreach (ActiveSound target in targets) { float defaultVolume = this.m_volumeStepResolver.ResolveVolume(target.AudioObject, now); target.Volume = defaultVolume; if (target.Source) { target.Source.volume = defaultVolume; } } } internal void ResetPitch(uint audioId) { double now = Time.realtimeSinceStartupAsDouble * 1000.0; List targets = this.m_activeSounds.FindAll(activeSound => activeSound.AudioObject.Id == audioId); foreach (ActiveSound target in targets) { float defaultPitch = this.m_pitchStepResolver.ResolvePitch(target.AudioObject, now); target.Pitch = defaultPitch; if (target.Source != null) { target.Source.pitch = defaultPitch; } } } // ───────────────────────────────────────────── // 节流 & 调度入口 // ───────────────────────────────────────────── internal void TryPlay(AudioObject audioObject, Action onPlay = null) { double now = Time.realtimeSinceStartupAsDouble * 1000.0; // 第一层:时间节流 单位毫秒 if (this.m_lastPlayTime.TryGetValue(audioObject.Id, out double lastTime) && now - lastTime < audioObject.MinInterval) { Debug.LogWarning($"[Throttle] {audioObject.Name[0]} 未达到最小间隔,取消播放。"); return; } // 第二层:单 AudioObject/Clip 并发控制 if (audioObject.ThrottleCount != 0) { this.m_tempSameObject.Clear(); foreach (ActiveSound activeSound in this.m_activeSounds) { if (activeSound.AudioObject.Id == audioObject.Id) { this.m_tempSameObject.Add(activeSound); } } if (this.m_tempSameObject.Count >= audioObject.ThrottleCount && !TryKill(this.m_tempSameObject, audioObject.KillMode, $"[Object] {audioObject.Name[0]}")) return; } // 第三层:Group 并发控制 AudioGroup groupConfig = this.m_groupConfig.QueryById(audioObject.Group); if (groupConfig == null) { groupConfig = this.m_groupConfig.QueryById(1); Debug.Log($"未找到{audioObject.Id}对应的组文件,已使用默认配置组1。"); } this.m_tempSameGroup.Clear(); foreach (ActiveSound activeSound in this.m_activeSounds) { if (activeSound.AudioObject.Group == audioObject.Group) { this.m_tempSameGroup.Add(activeSound); } } if (this.m_tempSameGroup.Count >= groupConfig.GroupThrottleCount) { this.m_tempLowerPriority.Clear(); foreach (ActiveSound activeSound in this.m_tempSameGroup) { if (activeSound.AudioObject.Priority > audioObject.Priority) { this.m_tempLowerPriority.Add(activeSound); } } List killCandidates = this.m_tempLowerPriority.Count > 0 ? this.m_tempLowerPriority : this.m_tempSameGroup; KillMode killMode = this.m_tempLowerPriority.Count > 0 ? groupConfig.KillMode : audioObject.KillMode; if (!TryKill(killCandidates, killMode, $"[Group] {audioObject.Name[0]}")) return; } // 第四层:全局并发控制 if (this.m_activeSounds.Count >= k_maxGlobalConcurrent) { this.m_tempLowerPriority.Clear(); foreach (ActiveSound activeSound in this.m_activeSounds) { if (activeSound.AudioObject.Priority > audioObject.Priority) { this.m_tempLowerPriority.Add(activeSound); } } if (this.m_tempLowerPriority.Count == 0 || !TryKill(this.m_tempLowerPriority, KillMode.Oldest, "[Global]")) return; } // 执行播放 float pitch = this.m_pitchStepResolver.ResolvePitch(audioObject, now); //算一下用不用变调 float volume = this.m_volumeStepResolver.ResolveVolume(audioObject, now); PlayNewSound(audioObject, pitch, volume); this.m_lastPlayTime[audioObject.Id] = now; onPlay?.Invoke(); } // ───────────────────────────────────────────── // 播放逻辑 // ───────────────────────────────────────────── void PlayNewSound(AudioObject audioObject, float pitch, float volume) { ActiveSound active = new() { AudioObject = audioObject, Pitch = pitch, Volume = volume, State = ActiveSoundState.Pending, StartTime = Time.realtimeSinceStartupAsDouble, }; ExecutePlay(active); } /// /// 连续容器播放协程(Random / Sequence 持续模式) /// IEnumerator PlayContainerContinuous(AudioSource source, AudioObject audioObject, ActiveSound chainActive, int startIndex) { bool isRandom = audioObject.ContainerType == ContainerType.Random; if (chainActive.AudioObject.InitialDelay > 0) { chainActive.State = ActiveSoundState.Pending; } // 初始化 Random 模式的辅助结构 List remainingPool = isRandom ? new List(Enumerable.Range(0, audioObject.Name.Count)) : null; Queue recentPlayed = isRandom ? new Queue() : null; int limitRepetition = isRandom ? Mathf.Clamp(audioObject.LimitRepetition, 0, audioObject.Name.Count - 1) : 0; int index = startIndex; while (true) { // 选择本轮播放的 index if (isRandom) index = this.m_containerSelector.PickNextRandomIndex( audioObject, remainingPool, recentPlayed, limitRepetition); // 配置并播放 if (!SetupSource(source, chainActive, index)) { Debug.LogError($"音频文件未找到:{audioObject.Name[index]}"); yield break; } if (chainActive.State == ActiveSoundState.Pending) { chainActive.State = ActiveSoundState.Playing; StartPlayback(source, audioObject.InitialDelay); } else { StartPlayback(source); } yield return new WaitWhile(() => source.isPlaying); // 判断本轮是否播完 bool roundFinished = isRandom ? remainingPool.Count == 0 || this.m_containerSelector.GetHistoryCount(audioObject.Id) == audioObject.Name.Count : ++index >= audioObject.Name.Count; if (roundFinished) { DecrementClipCount(audioObject.Id); this.m_activeSounds.Remove(chainActive); if (isRandom) this.m_containerSelector.ResetHistory(audioObject.Id); this.m_pool.ReturnToPool(source.gameObject); //Debug.Log($"[Container - Continuous] 协程正常结束: {audioObject.Name[0]}"); // TryStopHaptic(audioObject.Haptic); yield break; } } } static void StartPlayback(AudioSource source, float delay = 0f) { if (delay > 0f) source.PlayDelayed(delay); else source.Play(); } // ───────────────────────────────────────────── // 工具方法 // ───────────────────────────────────────────── /// /// 尝试打断一个候选声音。返回 false 表示应取消当前播放(Newest 模式)。 /// bool TryKill(List candidates, KillMode killMode, string logPrefix) { if (killMode == KillMode.Newest) { Debug.LogWarning($"{logPrefix} 被 KillNewest 取消播放。"); return false; } ActiveSound toKill = SelectOldest(candidates); if (toKill != null) { Debug.LogWarning($"{logPrefix} 打断了 {toKill.AudioObject.Name[0]}。"); StopSound(toKill); } return true; } /// /// 从候选列表中选出开始时间最早的声音 /// ActiveSound SelectOldest(List candidates) { ActiveSound oldest = null; foreach (ActiveSound s in candidates) { if (oldest == null || s.StartTime < oldest.StartTime) { oldest = s; } } return oldest; } /// /// 根据 MixingType 获取对应的 AudioMixerGroup /// AudioMixerGroup GetMixerGroup(MixingType type) => type switch { MixingType.Voice => this.m_voiceGroup, MixingType.Accented => this.m_accentSfxGroup, _ => GetDefaultGroup(type), }; AudioMixerGroup GetDefaultGroup(MixingType type) { if (type != MixingType.Sfx) { Debug.LogWarning($"[SfxSystem] Unrecognized MixingType: {type}, defaulting to SFX."); } return this.m_sfxGroup; } bool SetupSource(AudioSource source, ActiveSound activeSound, int clipIndex = 0) { AudioObject audioObject = activeSound.AudioObject; AudioClip clip = Resources.Load($"Audios/{audioObject.Name[clipIndex]}"); // TODO 抽象同一资源加载接口 if (!clip) { Debug.LogError($"音频文件未找到:{audioObject.Name[clipIndex]}"); return false; } source.clip = clip; source.loop = audioObject.LoopCount < 0; source.priority = audioObject.Priority; source.outputAudioMixerGroup = GetMixerGroup(audioObject.MixingType); source.pitch = activeSound.Pitch; source.volume = activeSound.Volume; return true; } void RegisterActiveSound(ActiveSound activeSound, AudioSource audioSource, bool isRegistered = false) { activeSound.Source = audioSource; activeSound.State = ActiveSoundState.Playing; activeSound.StartTime = Time.realtimeSinceStartupAsDouble; if (!isRegistered) { this.m_activeSounds.Add(activeSound); IncrementClipCount(activeSound.AudioObject.Id); } if (activeSound.AudioObject.ContainerType == ContainerType.Blend && activeSound.AudioObject.Haptic > 0) { Debug.LogWarning($"[Haptic System] Blend container {activeSound.AudioObject.Id} should not have haptic feedback!"); } else { TryStartHaptic(activeSound); } activeSound.Coroutine = StartCoroutine(RemoveWhenFinished(activeSound)); } void ExecutePlay(ActiveSound active, bool isRegistered = false) { AudioObject audioObject = active.AudioObject; float pitch = active.Pitch; float volume = active.Volume; // ======================= // Blend(每个clip一个ActiveSound) // ======================= if (audioObject.ContainerType == ContainerType.Blend) { for (int i = 0; i < audioObject.Name.Count; i++) { AudioSource source = this.m_pool.AcquireAudioSource(); ActiveSound child = new() { AudioObject = audioObject, Pitch = pitch, Volume = volume, State = ActiveSoundState.Playing, }; if (!SetupSource(source, child, i)) { this.m_pool.ReturnToPool(source.gameObject); continue; } StartPlayback(source, audioObject.InitialDelay); RegisterActiveSound(child, source); } // 删除 pending 占位 if (this.m_activeSounds.Contains(active)) { DecrementClipCount(audioObject.Id); this.m_activeSounds.Remove(active); } return; } AudioSource sourceSingle = this.m_pool.AcquireAudioSource(); // ======================= // Continuous // ======================= if (audioObject.Name.Count > 1 && audioObject.ContainerPlayMode) { active.Source = sourceSingle; active.State = ActiveSoundState.Playing; this.m_activeSounds.Add(active); IncrementClipCount(audioObject.Id); int start = audioObject.ContainerType == ContainerType.Random ? -1 : 0; active.Coroutine = StartCoroutine( PlayContainerContinuous(sourceSingle, audioObject, active, start) ); return; } // ======================= // 单次播放 // ======================= int index = audioObject.ContainerType switch { ContainerType.Random => m_containerSelector.PickShuffleIndex(audioObject), ContainerType.Sequence => m_containerSelector.GetNextSequenceIndex(audioObject), _ => 0, }; if (!SetupSource(sourceSingle, active, index)) { this.m_pool.ReturnToPool(sourceSingle.gameObject); return; } StartPlayback(sourceSingle, active.AudioObject.InitialDelay); RegisterActiveSound(active, sourceSingle, isRegistered); } IEnumerator RemoveWhenFinished(ActiveSound active) { if (active.AudioObject.LoopCount < 0) { //Debug.Log($"RemoveWhenFinished协程已结束,因为播放的是无限循环音效{active.Source.clip.name}"); yield break; } do { yield return new WaitWhile(() => active.Source.isPlaying); active.CurrentLoopCount++; } while (active.AudioObject.LoopCount > 0 && active.CurrentLoopCount < active.AudioObject.LoopCount && PlayAgain(active)); DecrementClipCount(active.AudioObject.Id); this.m_activeSounds.Remove(active); this.m_pool.ReturnToPool(active.Source.gameObject); } bool PlayAgain(ActiveSound active) { StartPlayback(active.Source); return true; } void StopSound(ActiveSound active) { if (active.Coroutine != null) { StopCoroutine(active.Coroutine); //Debug.Log($"[StopSound] 协程已终止: {active.AudioObject.Name[0]}"); } if(active.Source) active.Source.Stop(); DecrementClipCount(active.AudioObject.Id); this.m_activeSounds.Remove(active); this.m_pool.ReturnToPool(active.Source.gameObject); } static void TryStartHaptic(ActiveSound active) { uint hapticId = active.AudioObject.Haptic; if (hapticId == 0) return; HapticSystem.Instance.Play(hapticId, isDirectCall: false); } static void TryStopHaptic(uint hapticId) { HapticSystem.Instance.Stop(hapticId); } } /// /// 活跃的声音封装 /// public class ActiveSound { public AudioSource Source; public AudioObject AudioObject; // 记录某个具体播放实例的开始时间,用于 SelectOldest 在多个并发实例中挑出最老的那个来打断,是"这个实例是什么时候开始的"。 public double StartTime; public int CurrentLoopCount; public Coroutine Coroutine; public float Pitch; public float Volume; public ActiveSoundState State; } }