using System; using System.Collections; using System.Collections.Generic; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using UnityEngine; namespace BumpCombat.Audio { /// /// Small scene-local audio router for the presentation pass. It keeps /// combat sounds bounded and leaves gameplay systems unaware of clips. /// [DisallowMultipleComponent] public sealed class BumpCombatAudioService : MonoBehaviour { private const string ResourceRoot = "Presentation/Audio-v1/"; private const string MasterKey = "BumpCombat.MasterVolume"; private const string MusicKey = "BumpCombat.MusicVolume"; private const string SfxKey = "BumpCombat.SfxVolume"; private const float DefaultMaster = 0.8f; private const float DefaultMusic = 0.55f; private const float DefaultSfx = 0.8f; private const float SfxBusGain = 0.65f; private const int DefaultVoiceCount = 8; private const string MusicSourcePrefix = "Music"; private sealed class Voice { public AudioSource Source; public string ClipId; public float StartedAt; public float Gain = 1f; } private readonly Dictionary clips = new(); private readonly Dictionary lastPlayedAt = new(); private readonly List voices = new(); private readonly HashSet activeBossEvents = new(); private AudioSource musicSource; private AudioSource activeMusic; private AudioClip activeMusicClip; private float musicWeight; private Coroutine musicFadeRoutine; private RunManager runManager; private PlayerHealth playerHealth; private ExperienceSystem experienceSystem; private ActiveArtifactController artifacts; private SpawnDirector spawnDirector; private bool nextPitchUp; private bool gaugeWasReady; private bool runEndSoundPlayed; private int lastExperience; private int lastLevel; private float musicDuck = 1f; private float masterVolume; private float musicVolume; private float sfxVolume; public static BumpCombatAudioService Instance { get; private set; } public float MasterVolume => masterVolume; public float MusicVolume => musicVolume; public float SfxVolume => sfxVolume; public int VoiceCount => voices.Count; public string ActiveMusicClipId { get; private set; } public string LastPlayedClipId { get; private set; } public float CurrentMusicOutputGain => musicSource != null ? musicSource.volume : 0f; public bool AreMusicSourcesStopped => musicSource == null || !musicSource.isPlaying; private void Awake() { if (Instance != null && Instance != this) { StopAndRemoveGeneratedMusicSources(); Destroy(this); return; } Instance = this; StopAndRemoveGeneratedMusicSources(); masterVolume = LoadVolume(MasterKey, DefaultMaster); musicVolume = LoadVolume(MusicKey, DefaultMusic); sfxVolume = LoadVolume(SfxKey, DefaultSfx); LoadClips(); CreateSources(); } private void Start() { BindRuntimeObjects(); PlayMusic("bgm_courtyard", true); } private void OnDestroy() { bool ownsInstance = Instance == this; UnbindRuntimeObjects(); if (musicFadeRoutine != null) { StopCoroutine(musicFadeRoutine); musicFadeRoutine = null; } if (ownsInstance) { StopAndRemoveGeneratedMusicSources(); Instance = null; } } private void StopAndRemoveGeneratedMusicSources() { for (int i = transform.childCount - 1; i >= 0; i--) { Transform child = transform.GetChild(i); if (!IsGeneratedMusicSourceName(child.name)) { continue; } AudioSource[] sources = child.GetComponentsInChildren(true); for (int j = 0; j < sources.Length; j++) { sources[j].Stop(); sources[j].volume = 0f; } Destroy(child.gameObject); } } private static bool IsGeneratedMusicSourceName(string objectName) { return string.Equals(objectName, MusicSourcePrefix, StringComparison.Ordinal) || string.Equals(objectName, "Music A", StringComparison.Ordinal) || string.Equals(objectName, "Music B", StringComparison.Ordinal); } private void LoadClips() { string[] ids = { "ui_move", "ui_confirm", "hit_light", "hit_heavy", "player_hurt", "enemy_defeat", "xp_pickup", "level_up", "gauge_ready", "run_end", "charge_start", "artifact_dash", "artifact_pulse", "artifact_ray", "artifact_cyclone", "artifact_thunder", "artifact_arc", "bgm_courtyard", "bgm_boss", }; for (int i = 0; i < ids.Length; i++) { AudioClip clip = Resources.Load(ResourceRoot + ids[i]); if (clip != null) { clips[ids[i]] = clip; } } } private void CreateSources() { for (int i = 0; i < DefaultVoiceCount; i++) { GameObject sourceObject = new($"SFX Voice {i + 1}"); sourceObject.transform.SetParent(transform, false); AudioSource source = sourceObject.AddComponent(); source.playOnAwake = false; source.ignoreListenerPause = true; source.spatialBlend = 0f; voices.Add(new Voice { Source = source }); } musicSource = CreateMusicSource("Music"); } private AudioSource CreateMusicSource(string name) { GameObject sourceObject = new(name); sourceObject.transform.SetParent(transform, false); AudioSource source = sourceObject.AddComponent(); source.playOnAwake = false; source.loop = true; source.ignoreListenerPause = true; source.spatialBlend = 0f; return source; } private void BindRuntimeObjects() { runManager = RunManager.Instance; playerHealth = FindAnyObjectByType(); experienceSystem = FindAnyObjectByType(); artifacts = FindAnyObjectByType(); spawnDirector = FindAnyObjectByType(); CombatEvents.OnValidHit += HandleValidHit; CombatEvents.OnEnemyDied += HandleEnemyDied; if (runManager != null) { runManager.OnSelectionChanged += HandleSelectionChanged; runManager.OnPauseChanged += HandlePauseChanged; runManager.OnTitleChanged += HandleTitleChanged; runManager.OnGameOver += HandleGameOver; } if (playerHealth != null) { playerHealth.OnDamaged += HandlePlayerDamaged; playerHealth.OnDied += HandlePlayerDied; } if (experienceSystem != null) { lastExperience = experienceSystem.CurrentExperience; lastLevel = experienceSystem.Level; experienceSystem.OnExperienceChanged += HandleExperienceChanged; experienceSystem.OnLevelGained += HandleLevelGained; } if (artifacts != null) { gaugeWasReady = artifacts.CurrentGauge >= artifacts.MaxGauge; artifacts.OnStateChanged += HandleArtifactStateChanged; artifacts.OnArtifactChargeStarted += HandleArtifactChargeStarted; artifacts.OnArtifactUseSucceeded += HandleArtifactUseSucceeded; } if (spawnDirector != null) { spawnDirector.OnEventEnemySpawned += HandleEventEnemySpawned; spawnDirector.OnEventEnemyDefeated += HandleEventEnemyDefeated; } UpdateMusicDuck(runManager?.IsTitleScreen == true || runManager?.IsSelectionOpen == true || runManager?.IsPaused == true); } private void UnbindRuntimeObjects() { CombatEvents.OnValidHit -= HandleValidHit; CombatEvents.OnEnemyDied -= HandleEnemyDied; if (runManager != null) { runManager.OnSelectionChanged -= HandleSelectionChanged; runManager.OnPauseChanged -= HandlePauseChanged; runManager.OnTitleChanged -= HandleTitleChanged; runManager.OnGameOver -= HandleGameOver; } if (playerHealth != null) { playerHealth.OnDamaged -= HandlePlayerDamaged; playerHealth.OnDied -= HandlePlayerDied; } if (experienceSystem != null) { experienceSystem.OnExperienceChanged -= HandleExperienceChanged; experienceSystem.OnLevelGained -= HandleLevelGained; } if (artifacts != null) { artifacts.OnStateChanged -= HandleArtifactStateChanged; artifacts.OnArtifactChargeStarted -= HandleArtifactChargeStarted; artifacts.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded; } if (spawnDirector != null) { spawnDirector.OnEventEnemySpawned -= HandleEventEnemySpawned; spawnDirector.OnEventEnemyDefeated -= HandleEventEnemyDefeated; } } private void HandleValidHit(CombatHitResult result) { // Artifact hits already have one sound at cast start. Keep this // event for successful ordinary bumps only, avoiding AoE chatter. if (!result.IsOrdinaryBump || !result.ShowsHitFeedback) { return; } PlaySfx( result.IsStrongDash || result.HasBackBonus ? "hit_heavy" : "hit_light", 1f, false, 0.05f); } private void HandleEnemyDied(GameObject target) { PlaySfx("enemy_defeat", 0.85f, false, 0.08f); } private void HandlePlayerDamaged(Vector2 direction, float force) { PlaySfx("player_hurt", 1f, false, 0.08f); } private void HandlePlayerDied() { if (runEndSoundPlayed) { return; } runEndSoundPlayed = true; PlaySfx("run_end", 1f, false, 0f); FadeMusicOut(0.5f); } private void HandleGameOver() { if (runEndSoundPlayed) { return; } runEndSoundPlayed = true; PlaySfx("run_end", 1f, false, 0f); FadeMusicOut(0.5f); } private void HandleExperienceChanged(int current, int required, int level) { if (current > lastExperience || level > lastLevel) { PlaySfx("xp_pickup", 0.6f, false, 0.07f); } lastExperience = current; lastLevel = level; } private void HandleLevelGained(int level) { PlaySfx("level_up", 1f, false, 0.1f); } private void HandleArtifactStateChanged() { if (artifacts == null) { return; } bool ready = artifacts.CurrentGauge >= artifacts.MaxGauge - 0.0001f; if (ready && !gaugeWasReady) { PlaySfx("gauge_ready", 0.9f, false, 0.1f); } gaugeWasReady = ready; } private void HandleArtifactChargeStarted(ActiveArtifactDefinition definition) { PlaySfx("charge_start", 0.8f, false, 0.1f); } private void HandleArtifactUseSucceeded( ActiveArtifactDefinition definition, bool charged) { if (definition == null) { return; } string clipId = definition.Effect switch { ActiveArtifactEffect.Dash => "artifact_dash", ActiveArtifactEffect.Pulse => "artifact_pulse", ActiveArtifactEffect.Phoenix => "artifact_ray", ActiveArtifactEffect.Cyclone => "artifact_cyclone", ActiveArtifactEffect.ThunderCrash => "artifact_thunder", ActiveArtifactEffect.ChainLightning => "artifact_arc", _ => null, }; if (clipId != null) { PlaySfx(clipId, charged ? 1f : 0.85f, false, 0.04f); } } private void HandleEventEnemySpawned( RunTimedEvent timedEvent, EnemyController enemy) { if (timedEvent == RunTimedEvent.MidBoss || timedEvent == RunTimedEvent.FinalBoss) { activeBossEvents.Add(timedEvent); PlayMusic("bgm_boss", false); } } private void HandleEventEnemyDefeated(RunTimedEvent timedEvent) { if (timedEvent == RunTimedEvent.MidBoss || timedEvent == RunTimedEvent.FinalBoss) { activeBossEvents.Remove(timedEvent); if (activeBossEvents.Count == 0) { PlayMusic("bgm_courtyard", false); } } } private void HandleSelectionChanged(bool isOpen) { UpdateMusicDuck(isOpen || runManager?.IsPaused == true || runManager?.IsTitleScreen == true); } private void HandlePauseChanged(bool isOpen) { UpdateMusicDuck(isOpen || runManager?.IsSelectionOpen == true || runManager?.IsTitleScreen == true); } private void HandleTitleChanged(bool isOpen) { UpdateMusicDuck(isOpen || runManager?.IsSelectionOpen == true || runManager?.IsPaused == true); } private void UpdateMusicDuck(bool duck) { musicDuck = duck ? 0.35f : 1f; ApplyMusicVolume(); } public bool PlayUi(string clipId, float volume = 1f) { return PlaySfx(clipId, volume, true, 0.025f); } public bool PlaySfx( string clipId, float volume = 1f, bool isUi = false, float minimumInterval = 0f) { if (!clips.TryGetValue(clipId, out AudioClip clip) || clip == null || voices.Count == 0) { return false; } float now = Time.unscaledTime; if (minimumInterval > 0f && lastPlayedAt.TryGetValue(clipId, out float previous) && now - previous < minimumInterval) { return false; } Voice voice = FindVoice(isUi); if (voice == null) { return false; } lastPlayedAt[clipId] = now; LastPlayedClipId = clipId; voice.ClipId = clipId; voice.StartedAt = now; voice.Gain = Mathf.Clamp01(volume); AudioSource source = voice.Source; source.Stop(); source.clip = clip; source.loop = false; source.volume = Mathf.Clamp01(masterVolume * sfxVolume * SfxBusGain * voice.Gain); source.pitch = isUi ? 1f : nextPitchUp ? 1.02f : 0.98f; nextPitchUp = !nextPitchUp; source.Play(); return true; } private Voice FindVoice(bool isUi) { int first = isUi ? 0 : 1; int last = isUi ? 1 : voices.Count; for (int i = first; i < last; i++) { if (!voices[i].Source.isPlaying) { return voices[i]; } } Voice oldest = null; for (int i = first; i < last; i++) { if (oldest == null || voices[i].StartedAt < oldest.StartedAt) { oldest = voices[i]; } } return oldest; } public void SetMasterVolume(float value) { masterVolume = SaveVolume(MasterKey, value); ApplySfxVolume(); ApplyMusicVolume(); } public void SetMusicVolume(float value) { musicVolume = SaveVolume(MusicKey, value); ApplyMusicVolume(); } public void SetSfxVolume(float value) { sfxVolume = SaveVolume(SfxKey, value); ApplySfxVolume(); } private void ApplySfxVolume() { for (int i = 0; i < voices.Count; i++) { if (voices[i].Source.isPlaying) { voices[i].Source.volume = masterVolume * sfxVolume * SfxBusGain * voices[i].Gain; } } } private void PlayMusic(string clipId, bool immediate) { if (!clips.TryGetValue(clipId, out AudioClip clip) || clip == null) { return; } if (activeMusicClip == clip && activeMusic != null && activeMusic.isPlaying) { return; } if (musicFadeRoutine != null) { StopCoroutine(musicFadeRoutine); musicFadeRoutine = null; } // Music owns one loop source. Stop the previous clip before // assigning the new one so a boss transition can never overlap // ambient music, even during the transition fade. SetMusicWeight(immediate ? 1f : 0f); musicSource.Stop(); musicSource.clip = clip; musicSource.loop = true; musicSource.volume = musicWeight * GetMusicGain(); musicSource.Play(); activeMusic = musicSource; activeMusicClip = clip; ActiveMusicClipId = clipId; ApplyMusicVolume(); if (immediate) { return; } musicFadeRoutine = StartCoroutine(FadeMusicIn(0.5f)); } private IEnumerator FadeMusicIn(float duration) { float elapsed = 0f; while (elapsed < duration) { elapsed += Time.unscaledDeltaTime; float t = Mathf.Clamp01(elapsed / duration); SetMusicWeight(Mathf.Lerp(0f, 1f, t)); ApplyMusicVolume(); yield return null; } SetMusicWeight(1f); ApplyMusicVolume(); musicFadeRoutine = null; } private void FadeMusicOut(float duration) { if (musicSource == null || !musicSource.isPlaying) { return; } if (musicFadeRoutine != null) { StopCoroutine(musicFadeRoutine); } musicFadeRoutine = StartCoroutine(FadeMusicOutRoutine(duration)); } private IEnumerator FadeMusicOutRoutine(float duration) { float musicStart = musicWeight; float elapsed = 0f; while (elapsed < duration) { elapsed += Time.unscaledDeltaTime; float t = Mathf.Clamp01(elapsed / duration); SetMusicWeight(Mathf.Lerp(musicStart, 0f, t)); ApplyMusicVolume(); yield return null; } if (musicSource != null) { musicSource.Stop(); } SetMusicWeight(0f); ApplyMusicVolume(); activeMusic = null; activeMusicClip = null; musicFadeRoutine = null; } private float GetMusicGain() { return Mathf.Clamp01(masterVolume * musicVolume * musicDuck); } private void ApplyMusicVolume() { float gain = GetMusicGain(); if (musicSource != null) { musicSource.volume = musicSource.isPlaying ? musicWeight * gain : 0f; } } private void SetMusicWeight(float weight) { musicWeight = Mathf.Clamp01(weight); } private static float LoadVolume(string key, float fallback) { return Mathf.Clamp01(PlayerPrefs.GetFloat(key, fallback)); } private static float SaveVolume(string key, float value) { float clamped = Mathf.Clamp01(value); PlayerPrefs.SetFloat(key, clamped); PlayerPrefs.Save(); return clamped; } #if UNITY_EDITOR public bool DebugHasClip(string clipId) { return clips.ContainsKey(clipId); } #endif } }