实现 P0 音频服务
This commit is contained in:
8
My project/Assets/FlowScope/Runtime.meta
Normal file
8
My project/Assets/FlowScope/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4880ab4a40c44cf1b8e4b49ad5f64441
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
25
My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs
Normal file
25
My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Audio
|
||||||
|
{
|
||||||
|
public sealed class AudioHandle : IAudioHandle
|
||||||
|
{
|
||||||
|
private readonly Func<bool> _isPlaying;
|
||||||
|
private readonly Action<float> _stop;
|
||||||
|
|
||||||
|
internal AudioHandle(Func<bool> isPlaying, Action<float> 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, _ => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9e7da42c17284465ab9a68bec5387f19
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
324
My project/Assets/FlowScope/Runtime/Audio/AudioService.cs
Normal file
324
My project/Assets/FlowScope/Runtime/Audio/AudioService.cs
Normal file
@@ -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<SfxSlot> _sfxSlots = new();
|
||||||
|
private IResourceService _resources;
|
||||||
|
private AudioServiceConfig _config;
|
||||||
|
private AudioSource _bgmSource;
|
||||||
|
private IResourceHandle<AudioClip> _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<AudioSource>();
|
||||||
|
_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<AudioClip> LoadClip(string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handle = _resources.LoadAsync<AudioClip>(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<AudioSource>();
|
||||||
|
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<AudioClip> Resource { get; set; }
|
||||||
|
public int Sequence { get; set; }
|
||||||
|
|
||||||
|
public void Release()
|
||||||
|
{
|
||||||
|
Resource?.Dispose();
|
||||||
|
Resource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9e81e8f0ce1a42e6a3b09bf74893b6b6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FlowScope.Audio
|
||||||
|
{
|
||||||
|
public sealed class AudioServiceConfig
|
||||||
|
{
|
||||||
|
public int SfxPoolSize = 10;
|
||||||
|
public bool ReuseOldestWhenExhausted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 276b2d62e26a4edcaaeba1a96a3c2917
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Tests.meta
Normal file
8
My project/Assets/FlowScope/Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 96fccc03790046f68dd8c3e2ca9e8bea
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Tests/PlayMode.meta
Normal file
8
My project/Assets/FlowScope/Tests/PlayMode.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 225e7de85b4b4b3cbd3576fb19437c66
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Tests/PlayMode/Audio.meta
Normal file
8
My project/Assets/FlowScope/Tests/PlayMode/Audio.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2ab7b99842304631bef81f532867cc2f
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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<AudioService>())
|
||||||
|
{
|
||||||
|
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<AudioSource>().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<AudioSource>();
|
||||||
|
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<AudioSource>(), 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<InvalidOperationException>(() => 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<AudioService>().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<string, AudioClip> clips = new();
|
||||||
|
|
||||||
|
public void Add(string key, AudioClip clip)
|
||||||
|
{
|
||||||
|
clips[key] = clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IResourceHandle<T>> LoadAsync<T>(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<IResourceHandle<T>>(new FakeResourceHandle<T>(key, clip as T));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IResourceGroup CreateGroup()
|
||||||
|
{
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeResourceHandle<T> : IResourceHandle<T> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 313158b66c2da7e49b644db5e7a04c2e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user