662 lines
22 KiB
C#
662 lines
22 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Small scene-local audio router for the presentation pass. It keeps
|
|
/// combat sounds bounded and leaves gameplay systems unaware of clips.
|
|
/// </summary>
|
|
[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<string, AudioClip> clips = new();
|
|
private readonly Dictionary<string, float> lastPlayedAt = new();
|
|
private readonly List<Voice> voices = new();
|
|
private readonly HashSet<RunTimedEvent> 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<AudioSource>(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<AudioClip>(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<AudioSource>();
|
|
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<AudioSource>();
|
|
source.playOnAwake = false;
|
|
source.loop = true;
|
|
source.ignoreListenerPause = true;
|
|
source.spatialBlend = 0f;
|
|
return source;
|
|
}
|
|
|
|
private void BindRuntimeObjects()
|
|
{
|
|
runManager = RunManager.Instance;
|
|
playerHealth = FindAnyObjectByType<PlayerHealth>();
|
|
experienceSystem = FindAnyObjectByType<ExperienceSystem>();
|
|
artifacts = FindAnyObjectByType<ActiveArtifactController>();
|
|
spawnDirector = FindAnyObjectByType<SpawnDirector>();
|
|
|
|
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
|
|
}
|
|
}
|