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