新增 FlowScope Git 包雏形

This commit is contained in:
JSD\13999
2026-06-04 14:54:49 +08:00
parent 37e6d4ca57
commit f5887da9fb
127 changed files with 5330 additions and 0 deletions

View 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, _ => { });
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e7da42c17284465ab9a68bec5387f19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,392 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
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 = LoadClipIfCompleted(key);
return PlayBgm(clipHandle, loop, fadeIn);
}
public async Task<IAudioHandle> PlayBgmAsync(
string key,
bool loop = true,
float fadeIn = 0f,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
var clipHandle = await LoadClipAsync(key, cancellationToken);
return PlayBgm(clipHandle, loop, fadeIn);
}
public IAudioHandle PlaySfx(string key)
{
EnsureInitialized();
var clipHandle = LoadClipIfCompleted(key);
var slot = GetSfxSlot(key);
if (slot == null)
{
clipHandle.Dispose();
return AudioHandle.Stopped;
}
return PlaySfx(slot, clipHandle);
}
public async Task<IAudioHandle> PlaySfxAsync(
string key,
CancellationToken cancellationToken = default)
{
EnsureInitialized();
var clipHandle = await LoadClipAsync(key, cancellationToken);
var slot = GetSfxSlot(key);
if (slot == null)
{
clipHandle.Dispose();
return AudioHandle.Stopped;
}
return PlaySfx(slot, clipHandle);
}
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 IAudioHandle PlayBgm(IResourceHandle<AudioClip> clipHandle, bool loop, float fadeIn)
{
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));
}
private IAudioHandle PlaySfx(SfxSlot slot, IResourceHandle<AudioClip> clipHandle)
{
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));
}
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> LoadClipIfCompleted(string key)
{
try
{
if (!(_resources is ICompletedResourceService completedResources) ||
!completedResources.TryLoadCompleted(key, out IResourceHandle<AudioClip> handle))
{
throw new InvalidOperationException(
$"Audio clip is not loaded synchronously: {key}. Use PlayBgmAsync or PlaySfxAsync.");
}
return ValidateLoadedClip(key, handle);
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception);
}
}
private async Task<IResourceHandle<AudioClip>> LoadClipAsync(
string key,
CancellationToken cancellationToken)
{
try
{
var handle = await _resources.LoadAsync<AudioClip>(key, cancellationToken);
return ValidateLoadedClip(key, handle);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception);
}
}
private static IResourceHandle<AudioClip> ValidateLoadedClip(
string key,
IResourceHandle<AudioClip> handle)
{
if (handle == null || handle.Asset == null)
{
handle?.Dispose();
throw new InvalidOperationException($"Audio clip is missing: {key}");
}
return handle;
}
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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e81e8f0ce1a42e6a3b09bf74893b6b6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
namespace FlowScope.Audio
{
public sealed class AudioServiceConfig
{
public int SfxPoolSize = 10;
public bool ReuseOldestWhenExhausted = true;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 276b2d62e26a4edcaaeba1a96a3c2917
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
namespace FlowScope.Audio
{
public interface IAudioHandle
{
void Stop(float fadeOut = 0f);
bool IsPlaying { get; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 780afa085183438eaa9b372219113d62
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.Audio
{
public interface IAudioService
{
IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f);
Task<IAudioHandle> PlayBgmAsync(
string key,
bool loop = true,
float fadeIn = 0f,
CancellationToken cancellationToken = default);
IAudioHandle PlaySfx(string key);
Task<IAudioHandle> PlaySfxAsync(
string key,
CancellationToken cancellationToken = default);
void StopBgm(float fadeOut = 0f);
void SetBgmVolume(float volume);
void SetSfxVolume(float volume);
void SetMute(bool mute);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2763bff6b4ca4ac2ae5b9e5f764ad984
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: