diff --git a/My project/Assets/FlowScope/Runtime.meta b/My project/Assets/FlowScope/Runtime.meta new file mode 100644 index 0000000..5a3ecb5 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4880ab4a40c44cf1b8e4b49ad5f64441 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs b/My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs new file mode 100644 index 0000000..1d8c61d --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs @@ -0,0 +1,25 @@ +using System; + +namespace FlowScope.Audio +{ + public sealed class AudioHandle : IAudioHandle + { + private readonly Func _isPlaying; + private readonly Action _stop; + + internal AudioHandle(Func isPlaying, Action stop) + { + _isPlaying = isPlaying ?? throw new ArgumentNullException(nameof(isPlaying)); + _stop = stop ?? throw new ArgumentNullException(nameof(stop)); + } + + public bool IsPlaying => _isPlaying(); + + public void Stop(float fadeOut = 0f) + { + _stop(fadeOut); + } + + internal static AudioHandle Stopped { get; } = new AudioHandle(() => false, _ => { }); + } +} diff --git a/My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs.meta b/My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs.meta new file mode 100644 index 0000000..270aa05 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e7da42c17284465ab9a68bec5387f19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs b/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs new file mode 100644 index 0000000..85d5f62 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; +using FlowScope.Resources; +using UnityEngine; + +namespace FlowScope.Audio +{ + public sealed class AudioService : MonoBehaviour, IAudioService + { + private readonly List _sfxSlots = new(); + private IResourceService _resources; + private AudioServiceConfig _config; + private AudioSource _bgmSource; + private IResourceHandle _bgmResource; + private Coroutine _bgmFade; + private float _bgmVolume = 1f; + private float _sfxVolume = 1f; + private bool _mute; + private int _playSequence; + + public AudioService Initialize(IResourceService resources, AudioServiceConfig config = null) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _config = config ?? new AudioServiceConfig(); + if (_config.SfxPoolSize < 0) + { + _config.SfxPoolSize = 0; + } + + return this; + } + + public IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f) + { + EnsureInitialized(); + var clipHandle = LoadClip(key); + + StopBgm(); + _bgmSource = gameObject.AddComponent(); + _bgmResource = clipHandle; + _bgmSource.clip = clipHandle.Asset; + _bgmSource.loop = loop; + _bgmSource.volume = fadeIn > 0f ? 0f : EffectiveBgmVolume; + _bgmSource.Play(); + + if (fadeIn > 0f) + { + _bgmFade = StartCoroutine(FadeVolume(_bgmSource, 0f, EffectiveBgmVolume, fadeIn, null)); + } + + var source = _bgmSource; + return new AudioHandle( + () => source != null && source.isPlaying, + fadeOut => StopBgmSource(source, fadeOut)); + } + + public IAudioHandle PlaySfx(string key) + { + EnsureInitialized(); + var slot = GetSfxSlot(key); + if (slot == null) + { + return AudioHandle.Stopped; + } + + var clipHandle = LoadClip(key); + slot.Release(); + slot.Resource = clipHandle; + slot.Source.clip = clipHandle.Asset; + slot.Source.loop = false; + slot.Source.volume = EffectiveSfxVolume; + slot.Sequence = ++_playSequence; + slot.Source.Play(); + + return new AudioHandle( + () => slot.Source != null && slot.Source.isPlaying, + fadeOut => StopSfxSlot(slot, fadeOut)); + } + + public void StopBgm(float fadeOut = 0f) + { + StopBgmSource(_bgmSource, fadeOut); + } + + public void SetBgmVolume(float volume) + { + _bgmVolume = Mathf.Clamp01(volume); + if (_bgmSource != null) + { + _bgmSource.volume = EffectiveBgmVolume; + } + } + + public void SetSfxVolume(float volume) + { + _sfxVolume = Mathf.Clamp01(volume); + foreach (var slot in _sfxSlots) + { + if (slot.Source != null) + { + slot.Source.volume = EffectiveSfxVolume; + } + } + } + + public void SetMute(bool mute) + { + _mute = mute; + if (_bgmSource != null) + { + _bgmSource.volume = EffectiveBgmVolume; + } + + foreach (var slot in _sfxSlots) + { + if (slot.Source != null) + { + slot.Source.volume = EffectiveSfxVolume; + } + } + } + + private float EffectiveBgmVolume => _mute ? 0f : _bgmVolume; + private float EffectiveSfxVolume => _mute ? 0f : _sfxVolume; + + private void Update() + { + foreach (var slot in _sfxSlots) + { + if (slot.Source != null && !slot.Source.isPlaying) + { + slot.Release(); + } + } + } + + private void OnDestroy() + { + StopBgm(); + foreach (var slot in _sfxSlots) + { + slot.Release(); + } + } + + private IResourceHandle LoadClip(string key) + { + try + { + var handle = _resources.LoadAsync(key, CancellationToken.None) + .GetAwaiter() + .GetResult(); + if (handle == null || handle.Asset == null) + { + handle?.Dispose(); + throw new InvalidOperationException($"Audio clip is missing: {key}"); + } + + return handle; + } + catch (Exception exception) + { + throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception); + } + } + + private SfxSlot GetSfxSlot(string key) + { + foreach (var slot in _sfxSlots) + { + if (slot.Source != null && !slot.Source.isPlaying) + { + return slot; + } + } + + if (_sfxSlots.Count < _config.SfxPoolSize) + { + var source = gameObject.AddComponent(); + var slot = new SfxSlot(source); + _sfxSlots.Add(slot); + return slot; + } + + if (_config.ReuseOldestWhenExhausted && _sfxSlots.Count > 0) + { + var oldest = _sfxSlots[0]; + foreach (var slot in _sfxSlots) + { + if (slot.Sequence < oldest.Sequence) + { + oldest = slot; + } + } + + oldest.Source.Stop(); + return oldest; + } + + Debug.LogWarning($"SFX pool exhausted, skipped key: {key}"); + return null; + } + + private void StopBgmSource(AudioSource source, float fadeOut) + { + if (source == null || source != _bgmSource) + { + return; + } + + if (_bgmFade != null) + { + StopCoroutine(_bgmFade); + _bgmFade = null; + } + + if (fadeOut > 0f && source.isPlaying) + { + _bgmFade = StartCoroutine(FadeVolume( + source, + source.volume, + 0f, + fadeOut, + () => CompleteBgmStop(source))); + return; + } + + CompleteBgmStop(source); + } + + private void CompleteBgmStop(AudioSource source) + { + if (source == null || source != _bgmSource) + { + return; + } + + source.Stop(); + source.volume = 0f; + Destroy(source); + _bgmSource = null; + _bgmFade = null; + _bgmResource?.Dispose(); + _bgmResource = null; + } + + private void StopSfxSlot(SfxSlot slot, float fadeOut) + { + if (slot?.Source == null || !slot.Source.isPlaying) + { + return; + } + + if (fadeOut > 0f) + { + StartCoroutine(FadeVolume( + slot.Source, + slot.Source.volume, + 0f, + fadeOut, + () => + { + slot.Source.Stop(); + slot.Release(); + })); + return; + } + + slot.Source.Stop(); + slot.Release(); + } + + private IEnumerator FadeVolume( + AudioSource source, + float from, + float to, + float duration, + Action complete) + { + var elapsed = 0f; + while (elapsed < duration && source != null) + { + elapsed += Time.deltaTime; + source.volume = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / duration)); + yield return null; + } + + if (source != null) + { + source.volume = to; + } + + complete?.Invoke(); + } + + private void EnsureInitialized() + { + if (_resources == null) + { + throw new InvalidOperationException("AudioService must be initialized before use."); + } + } + + private sealed class SfxSlot + { + public SfxSlot(AudioSource source) + { + Source = source; + } + + public AudioSource Source { get; } + public IResourceHandle Resource { get; set; } + public int Sequence { get; set; } + + public void Release() + { + Resource?.Dispose(); + Resource = null; + } + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs.meta b/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs.meta new file mode 100644 index 0000000..05968b6 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Audio/AudioService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e81e8f0ce1a42e6a3b09bf74893b6b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/Audio/AudioServiceConfig.cs b/My project/Assets/FlowScope/Runtime/Audio/AudioServiceConfig.cs new file mode 100644 index 0000000..d5430ca --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Audio/AudioServiceConfig.cs @@ -0,0 +1,8 @@ +namespace FlowScope.Audio +{ + public sealed class AudioServiceConfig + { + public int SfxPoolSize = 10; + public bool ReuseOldestWhenExhausted = true; + } +} diff --git a/My project/Assets/FlowScope/Runtime/Audio/AudioServiceConfig.cs.meta b/My project/Assets/FlowScope/Runtime/Audio/AudioServiceConfig.cs.meta new file mode 100644 index 0000000..886ea73 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/Audio/AudioServiceConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 276b2d62e26a4edcaaeba1a96a3c2917 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests.meta b/My project/Assets/FlowScope/Tests.meta new file mode 100644 index 0000000..c5a01e5 --- /dev/null +++ b/My project/Assets/FlowScope/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 96fccc03790046f68dd8c3e2ca9e8bea +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode.meta b/My project/Assets/FlowScope/Tests/PlayMode.meta new file mode 100644 index 0000000..0ca9035 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 225e7de85b4b4b3cbd3576fb19437c66 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Audio.meta b/My project/Assets/FlowScope/Tests/PlayMode/Audio.meta new file mode 100644 index 0000000..576d3ba --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/Audio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2ab7b99842304631bef81f532867cc2f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs new file mode 100644 index 0000000..184d329 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Audio; +using FlowScope.Resources; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace FlowScope.Tests.PlayMode.Audio +{ + public sealed class AudioServiceTests + { + private FakeResourceService resources; + private AudioClip bgmA; + private AudioClip bgmB; + private AudioClip sfx; + + [SetUp] + public void SetUp() + { + resources = new FakeResourceService(); + bgmA = CreateClip("bgm-a", 0.25f); + bgmB = CreateClip("bgm-b", 0.25f); + sfx = CreateClip("sfx", 0.1f); + + resources.Add("bgm-a", bgmA); + resources.Add("bgm-b", bgmB); + resources.Add("sfx", sfx); + } + + [TearDown] + public void TearDown() + { + foreach (var service in Object.FindObjectsOfType()) + { + Object.Destroy(service.gameObject); + } + } + + [UnityTest] + public System.Collections.IEnumerator PlayBgm_ReturnsHandleAndStopBgmStopsIt() + { + var service = CreateService(); + + var handle = service.PlayBgm("bgm-a"); + + yield return null; + Assert.That(handle.IsPlaying, Is.True); + + service.StopBgm(); + + yield return null; + Assert.That(handle.IsPlaying, Is.False); + } + + [UnityTest] + public System.Collections.IEnumerator PlayBgm_WhenNewBgmStarts_StopsPreviousBgm() + { + var service = CreateService(); + var first = service.PlayBgm("bgm-a"); + + yield return null; + var second = service.PlayBgm("bgm-b"); + + yield return null; + Assert.That(first.IsPlaying, Is.False); + Assert.That(second.IsPlaying, Is.True); + } + + [UnityTest] + public System.Collections.IEnumerator PlaySfx_UsesConfiguredPoolLimitAndSkipsWhenExhausted() + { + var service = CreateService(new AudioServiceConfig + { + SfxPoolSize = 2, + ReuseOldestWhenExhausted = false + }); + + var first = service.PlaySfx("sfx"); + var second = service.PlaySfx("sfx"); + + LogAssert.Expect(LogType.Warning, "SFX pool exhausted, skipped key: sfx"); + var skipped = service.PlaySfx("sfx"); + + yield return null; + Assert.That(first.IsPlaying, Is.True); + Assert.That(second.IsPlaying, Is.True); + Assert.That(skipped.IsPlaying, Is.False); + Assert.That(Object.FindObjectsOfType().Length, Is.EqualTo(2)); + } + + [UnityTest] + public System.Collections.IEnumerator SetVolumes_ClampsAndMuteUpdatesExistingSources() + { + var service = CreateService(); + var bgm = service.PlayBgm("bgm-a"); + var sfxHandle = service.PlaySfx("sfx"); + + yield return null; + service.SetBgmVolume(2f); + service.SetSfxVolume(-1f); + + var sources = Object.FindObjectsOfType(); + Assert.That(Array.Find(sources, source => source.clip == bgmA).volume, Is.EqualTo(1f)); + Assert.That(Array.Find(sources, source => source.clip == sfx).volume, Is.EqualTo(0f)); + + service.SetMute(true); + + Assert.That(Array.Find(sources, source => source.clip == bgmA).volume, Is.EqualTo(0f)); + Assert.That(Array.Find(sources, source => source.clip == sfx).volume, Is.EqualTo(0f)); + Assert.That(bgm.IsPlaying, Is.True); + Assert.That(sfxHandle.IsPlaying, Is.True); + } + + [UnityTest] + public System.Collections.IEnumerator PlayBgm_FadeInAndFadeOutUseLinearProgress() + { + var service = CreateService(); + + var handle = service.PlayBgm("bgm-a", fadeIn: 0.1f); + var bgmSource = Array.Find(Object.FindObjectsOfType(), source => source.clip == bgmA); + Assert.That(bgmSource.volume, Is.EqualTo(0f)); + + yield return new WaitForSeconds(0.15f); + Assert.That(bgmSource.volume, Is.EqualTo(1f).Within(0.01f)); + + handle.Stop(0.1f); + + yield return new WaitForSeconds(0.15f); + Assert.That(handle.IsPlaying, Is.False); + Assert.That(bgmSource.isPlaying, Is.False); + Assert.That(bgmSource.volume, Is.EqualTo(0f).Within(0.01f)); + } + + [Test] + public void PlayBgm_WhenResourceMissing_ThrowsWithKey() + { + var service = CreateService(); + + var exception = Assert.Throws(() => service.PlayBgm("missing-bgm")); + + Assert.That(exception.Message, Does.Contain("missing-bgm")); + } + + private AudioService CreateService(AudioServiceConfig config = null) + { + var gameObject = new GameObject("AudioServiceTest"); + return gameObject.AddComponent().Initialize(resources, config); + } + + private static AudioClip CreateClip(string name, float lengthSeconds) + { + var sampleCount = Mathf.CeilToInt(44100 * lengthSeconds); + var clip = AudioClip.Create(name, sampleCount, 1, 44100, false); + var data = new float[sampleCount]; + for (var i = 0; i < data.Length; i++) + { + data[i] = 0.1f; + } + + clip.SetData(data, 0); + return clip; + } + + private sealed class FakeResourceService : IResourceService + { + private readonly Dictionary clips = new(); + + public void Add(string key, AudioClip clip) + { + clips[key] = clip; + } + + public Task> LoadAsync(string key, CancellationToken cancellationToken) + where T : class + { + if (typeof(T) != typeof(AudioClip) || !clips.TryGetValue(key, out var clip)) + { + throw new InvalidOperationException($"missing resource: {key}"); + } + + return Task.FromResult>(new FakeResourceHandle(key, clip as T)); + } + + public IResourceGroup CreateGroup() + { + throw new NotSupportedException(); + } + } + + private sealed class FakeResourceHandle : IResourceHandle where T : class + { + public FakeResourceHandle(string key, T asset) + { + Key = key; + Asset = asset; + } + + public string Key { get; } + public T Asset { get; private set; } + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + Asset = null; + } + } + } +} diff --git a/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs.meta b/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs.meta new file mode 100644 index 0000000..9a563d3 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/Audio/AudioServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 313158b66c2da7e49b644db5e7a04c2e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: