摄像机区域的架构改动
This commit is contained in:
57
Assets/_Game/Scripts/Audio/AudioConfigSO.cs
Normal file
57
Assets/_Game/Scripts/Audio/AudioConfigSO.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 音频全局配置 SO:区域 BGM 映射、Boss BGM 映射、特殊曲目。
|
||||
/// 资产路径:Assets/ScriptableObjects/Audio/AUD_Config.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Audio/AudioConfig")]
|
||||
public class AudioConfigSO : ScriptableObject
|
||||
{
|
||||
[System.Serializable]
|
||||
public struct ZoneBGMEntry
|
||||
{
|
||||
public string ZoneId;
|
||||
public AudioClip BGMClip;
|
||||
public float FadeDuration;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public struct BossBGMEntry
|
||||
{
|
||||
public string BossId;
|
||||
public AudioClip BGMClip;
|
||||
}
|
||||
|
||||
[Header("区域 BGM 映射")]
|
||||
public ZoneBGMEntry[] ZoneBGMs;
|
||||
|
||||
[Header("Boss BGM 映射")]
|
||||
public BossBGMEntry[] BossBGMs;
|
||||
|
||||
[Header("特殊曲目")]
|
||||
public AudioClip MainMenuBGM;
|
||||
public AudioClip GameOverSting;
|
||||
public AudioClip VictoryStingBGM;
|
||||
[Min(0.1f)]
|
||||
public float VictoryStingDuration = 4f;
|
||||
|
||||
public AudioClip GetZoneBGM(string zoneId)
|
||||
{
|
||||
if (ZoneBGMs == null) return null;
|
||||
foreach (var e in ZoneBGMs)
|
||||
if (e.ZoneId == zoneId) return e.BGMClip;
|
||||
return null;
|
||||
}
|
||||
|
||||
public AudioClip GetBossBGM(string bossId)
|
||||
{
|
||||
if (BossBGMs == null) return null;
|
||||
foreach (var e in BossBGMs)
|
||||
if (e.BossId == bossId) return e.BGMClip;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/AudioConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/AudioConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af379c0184a345441914e3167376d39c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
52
Assets/_Game/Scripts/Audio/AudioEventSO.cs
Normal file
52
Assets/_Game/Scripts/Audio/AudioEventSO.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 音效事件 ScriptableObject。随机从 Clips 中选取一条播放,
|
||||
/// 并在 Volume / Pitch 区间内随机变化,增强音效多样性。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Audio/AudioEvent")]
|
||||
public class AudioEventSO : ScriptableObject
|
||||
{
|
||||
[SerializeField] private AudioClip[] _clips;
|
||||
[SerializeField] private float _volumeMin = 0.9f;
|
||||
[SerializeField] private float _volumeMax = 1.0f;
|
||||
[SerializeField] private float _pitchMin = 0.95f;
|
||||
[SerializeField] private float _pitchMax = 1.05f;
|
||||
[SerializeField] private AudioMixerGroup _mixerGroup;
|
||||
|
||||
public AudioClip PickClip()
|
||||
{
|
||||
if (_clips == null || _clips.Length == 0) return null;
|
||||
return _clips[Random.Range(0, _clips.Length)];
|
||||
}
|
||||
|
||||
public float PickVolume() => Random.Range(_volumeMin, _volumeMax);
|
||||
|
||||
/// <summary>通过已有的 AudioSource 播放(适用于场景内固定位置音效)。</summary>
|
||||
public void Play(AudioSource source)
|
||||
{
|
||||
var clip = PickClip();
|
||||
if (clip == null || source == null) return;
|
||||
|
||||
source.clip = clip;
|
||||
source.volume = PickVolume();
|
||||
source.pitch = Random.Range(_pitchMin, _pitchMax);
|
||||
source.outputAudioMixerGroup = _mixerGroup;
|
||||
source.Play();
|
||||
}
|
||||
|
||||
/// <summary>通过 AudioSource.PlayOneShot 播放(不打断当前音效)。</summary>
|
||||
public void PlayOneShot(AudioSource source)
|
||||
{
|
||||
var clip = PickClip();
|
||||
if (clip == null || source == null) return;
|
||||
|
||||
source.outputAudioMixerGroup = _mixerGroup;
|
||||
source.pitch = Random.Range(_pitchMin, _pitchMax);
|
||||
source.PlayOneShot(clip, PickVolume());
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/AudioEventSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/AudioEventSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 356d306cd9d1eaa46b9d283761bcba54
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
249
Assets/_Game/Scripts/Audio/AudioManager.cs
Normal file
249
Assets/_Game/Scripts/Audio/AudioManager.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 音频管理器。
|
||||
/// 职责:BGM 双 Source 交叉淡入淡出、SFX 多源轮转池、AudioMixer 快照切换、音量控制。
|
||||
/// 挂在 Persistent 场景 [AudioManager] GameObject 上。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-500)]
|
||||
public class AudioManager : MonoBehaviour, IAudioService
|
||||
{
|
||||
[Header("AudioMixer")]
|
||||
[SerializeField] private AudioMixer _mixer;
|
||||
|
||||
[Header("BGM Sources(双 Source 交叉淡入淡出)")]
|
||||
[SerializeField] private AudioSource _bgmSourceA;
|
||||
[SerializeField] private AudioSource _bgmSourceB;
|
||||
|
||||
[Header("SFX Pool(建议 6 个,均路由到 SFX MixerGroup)")]
|
||||
[Tooltip("轮转池大小:同帧触发数超过此值时最旧的音效会被打断。建议 6~8 个。")]
|
||||
[SerializeField] private AudioSource[] _sfxSources;
|
||||
|
||||
[Header("Audio Config(BGM 映射)")]
|
||||
[SerializeField] private AudioConfigSO _audioConfig;
|
||||
|
||||
[Header("SFX 注册表(key → AudioEventSO)")]
|
||||
[SerializeField] private AudioEventEntry[] _sfxRegistry;
|
||||
|
||||
[System.Serializable]
|
||||
public struct AudioEventEntry
|
||||
{
|
||||
public string Key;
|
||||
public AudioEventSO Event;
|
||||
}
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
|
||||
private AudioSource _activeBGMSource;
|
||||
private AudioSource _inactiveBGMSource;
|
||||
private Coroutine _crossfadeCoroutine;
|
||||
private int _sfxRoundRobin;
|
||||
private Dictionary<string, AudioEventSO> _sfxLookup;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IAudioService>() != null) { Destroy(gameObject); return; }
|
||||
|
||||
Debug.Assert(_audioConfig != null, "[AudioManager] _audioConfig 未赋值,请在 Inspector 中指定 AudioConfigSO。", this);
|
||||
|
||||
_activeBGMSource = _bgmSourceA;
|
||||
_inactiveBGMSource = _bgmSourceB;
|
||||
|
||||
ServiceLocator.Register<IAudioService>(this);
|
||||
BuildSFXLookup();
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IAudioService>(this);
|
||||
}
|
||||
|
||||
// ── IAudioService string-key API ────────────────────────────────────────────
|
||||
/// <summary>按 Zone/Boss key 查 AudioConfigSO 播放 BGM。</summary>
|
||||
public void PlayBGM(string key)
|
||||
{
|
||||
var clip = _audioConfig.GetZoneBGM(key) ?? _audioConfig.GetBossBGM(key);
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogWarning($"[AudioManager] BGM key '{key}' 在 AudioConfigSO 中未找到。");
|
||||
return;
|
||||
}
|
||||
PlayBGM(clip);
|
||||
}
|
||||
|
||||
/// <summary>按 key 查 SFX 注册表播放 AudioEventSO。</summary>
|
||||
public void PlaySFX(string key)
|
||||
{
|
||||
if (_sfxLookup != null && _sfxLookup.TryGetValue(key, out var evt))
|
||||
{
|
||||
evt?.PlayOneShot(NextSFXSource());
|
||||
return;
|
||||
}
|
||||
Debug.LogWarning($"[AudioManager] SFX key '{key}' 未在注册表中找到。");
|
||||
}
|
||||
|
||||
// ── 音量控制 ─────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 将指定 Exposed Parameter 设置为 0-1 线性值(内部转换为 dB)。
|
||||
/// 唯一音量写入入口(同时满足 IAudioService.SetVolume 接口)。
|
||||
/// </summary>
|
||||
public void SetVolume(string exposedParam, float linear)
|
||||
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
|
||||
|
||||
/// <summary>读取 SettingsManager 已加载的设置数据并应用四路音量到 AudioMixer。</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
var settings = ServiceLocator.GetOrDefault<ISettingsService>();
|
||||
GlobalSettingsData data = settings?.Current ?? new GlobalSettingsData();
|
||||
SetVolume(AudioMixerKeys.Master, data.MasterVolume);
|
||||
SetVolume(AudioMixerKeys.BGM, data.BGMVolume);
|
||||
SetVolume(AudioMixerKeys.SFX, data.SFXVolume);
|
||||
SetVolume(AudioMixerKeys.Ambient, data.AmbientVolume);
|
||||
}
|
||||
|
||||
// ── BGM ──────────────────────────────────────────────────────────────────
|
||||
/// <summary>播放 BGM,使用双 AudioSource 交叉淡入淡出。</summary>
|
||||
public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f)
|
||||
{
|
||||
if (clip == null) return;
|
||||
if (_crossfadeCoroutine != null) StopCoroutine(_crossfadeCoroutine);
|
||||
_crossfadeCoroutine = StartCoroutine(CrossfadeCoroutine(clip, fadeOutDur, fadeInDur));
|
||||
}
|
||||
|
||||
/// <summary>停止 BGM(带淡出)。</summary>
|
||||
public void StopBGM(float fadeDuration = 1f)
|
||||
{
|
||||
if (_crossfadeCoroutine != null) StopCoroutine(_crossfadeCoroutine);
|
||||
_crossfadeCoroutine = StartCoroutine(FadeOutCoroutine(_activeBGMSource, fadeDuration));
|
||||
}
|
||||
|
||||
// ── SFX ──────────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 一次性播放 SFX,使用轮转多源池避免高密度战斗时音效相互戳断。
|
||||
/// </summary>
|
||||
public void PlaySFX(AudioClip clip, float volumeScale = 1f)
|
||||
{
|
||||
if (clip == null) return;
|
||||
var src = NextSFXSource();
|
||||
if (src == null) return;
|
||||
src.volume = volumeScale;
|
||||
src.PlayOneShot(clip);
|
||||
}
|
||||
|
||||
/// <summary>2D 游戏中位置无衰减,统一委托多源池播放。</summary>
|
||||
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f)
|
||||
{
|
||||
if (clip == null) return;
|
||||
AudioSource.PlayClipAtPoint(clip, pos, volumeScale);
|
||||
}
|
||||
|
||||
// ── 快照切换 ─────────────────────────────────────────────────────────────
|
||||
/// <summary>切换 AudioMixer 快照(如 Default / Paused / Dead / BossFight)。</summary>
|
||||
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f)
|
||||
{
|
||||
var snapshot = _mixer.FindSnapshot(snapshotName);
|
||||
if (snapshot != null)
|
||||
snapshot.TransitionTo(transitionTime);
|
||||
else
|
||||
Debug.LogWarning($"[AudioManager] Snapshot '{snapshotName}' not found in mixer.");
|
||||
}
|
||||
|
||||
// ── 内部实现 ─────────────────────────────────────────────────────────────
|
||||
private void HandlePlayerDied()
|
||||
{
|
||||
TransitionToSnapshot("Dead", 1.5f);
|
||||
}
|
||||
|
||||
private void BuildSFXLookup()
|
||||
{
|
||||
_sfxLookup = new Dictionary<string, AudioEventSO>(_sfxRegistry?.Length ?? 0);
|
||||
if (_sfxRegistry == null) return;
|
||||
foreach (var entry in _sfxRegistry)
|
||||
if (!string.IsNullOrEmpty(entry.Key) && entry.Event != null)
|
||||
_sfxLookup[entry.Key] = entry.Event;
|
||||
}
|
||||
|
||||
private AudioSource NextSFXSource()
|
||||
{
|
||||
if (_sfxSources == null || _sfxSources.Length == 0)
|
||||
{
|
||||
Debug.LogError("[AudioManager] SFX Source 池为空,请在 Inspector 中为 _sfxSources 赋值。");
|
||||
return null;
|
||||
}
|
||||
return _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
|
||||
}
|
||||
|
||||
private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur)
|
||||
{
|
||||
// 淡出当前活跃 Source
|
||||
float startVolume = _activeBGMSource.volume;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < fadeOutDur)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
_activeBGMSource.volume = Mathf.Lerp(startVolume, 0f, elapsed / fadeOutDur);
|
||||
yield return null;
|
||||
}
|
||||
_activeBGMSource.Stop();
|
||||
_activeBGMSource.volume = 0f;
|
||||
|
||||
// 切换到非活跃 Source 播放新曲目
|
||||
var temp = _activeBGMSource;
|
||||
_activeBGMSource = _inactiveBGMSource;
|
||||
_inactiveBGMSource = temp;
|
||||
|
||||
_activeBGMSource.clip = newClip;
|
||||
_activeBGMSource.volume = 0f;
|
||||
_activeBGMSource.Play();
|
||||
|
||||
// 淡入新 Source
|
||||
elapsed = 0f;
|
||||
while (elapsed < fadeInDur)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
_activeBGMSource.volume = Mathf.Lerp(0f, 1f, elapsed / fadeInDur);
|
||||
yield return null;
|
||||
}
|
||||
_activeBGMSource.volume = 1f;
|
||||
_crossfadeCoroutine = null;
|
||||
}
|
||||
|
||||
private IEnumerator FadeOutCoroutine(AudioSource source, float duration)
|
||||
{
|
||||
float startVolume = source.volume;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
source.volume = Mathf.Lerp(startVolume, 0f, elapsed / duration);
|
||||
yield return null;
|
||||
}
|
||||
source.Stop();
|
||||
source.volume = 0f;
|
||||
_crossfadeCoroutine = null;
|
||||
}
|
||||
|
||||
private static float LinearToDecibel(float linear)
|
||||
=> linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/AudioManager.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/AudioManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1230831ab62bdd84fbeb7df03e20c254
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: -500
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
15
Assets/_Game/Scripts/Audio/AudioMixerKeys.cs
Normal file
15
Assets/_Game/Scripts/Audio/AudioMixerKeys.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// AudioMixer Exposed Parameter 字符串常量。
|
||||
/// 与 Assets/Audio/MainMixer.mixer 中的 Exposed Parameters 保持同步。
|
||||
/// 参数范围:-80 ~ 0 dB(代码通过 LinearToDecibel 转换后写入)。
|
||||
/// </summary>
|
||||
public static class AudioMixerKeys
|
||||
{
|
||||
public const string Master = "MasterVolume";
|
||||
public const string BGM = "BGMVolume";
|
||||
public const string SFX = "SFXVolume";
|
||||
public const string Ambient = "AmbientVolume";
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/AudioMixerKeys.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/AudioMixerKeys.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8df8e995946cb24bb61b96c362bf58b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
24
Assets/_Game/Scripts/Audio/AudioZone.cs
Normal file
24
Assets/_Game/Scripts/Audio/AudioZone.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 区域音效触发器:玩家进入 Collider2D 时广播 _onRegionEntered 事件频道。
|
||||
/// 挂在区域边界的 GameObject 上,Collider2D 设置为 Is Trigger。
|
||||
/// _zoneId 须与 AudioConfigSO.ZoneBGMs 中的 ZoneId 一致。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class AudioZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _zoneId;
|
||||
[SerializeField] private StringEventChannelSO _onRegionEntered;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
if (_onRegionEntered != null)
|
||||
_onRegionEntered.Raise(_zoneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/AudioZone.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/AudioZone.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 11367ad39af3b9649af572aaa55891ec
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
115
Assets/_Game/Scripts/Audio/BGMController.cs
Normal file
115
Assets/_Game/Scripts/Audio/BGMController.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>BGM 状态机内部状态。</summary>
|
||||
public enum MusicState
|
||||
{
|
||||
Exploration, // 默认:区域探索 BGM
|
||||
Boss, // Boss 战:Boss 主题 BGM
|
||||
Victory, // Boss 击败后短暂胜利音乐
|
||||
None, // 过场/死亡/主菜单时由 BGMController 直接切换
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BGM 控制器:订阅世界/Boss/游戏状态事件,指挥 AudioManager 切换 BGM 和快照。
|
||||
/// 挂在 Persistent 场景 [AudioManager] 子对象上。
|
||||
/// </summary>
|
||||
public class BGMController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private AudioManager _audioManager;
|
||||
[SerializeField] private AudioConfigSO _config;
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // true=开始, false=结束
|
||||
[SerializeField] private StringEventChannelSO _onRegionEntered;
|
||||
|
||||
private MusicState _musicState = MusicState.Exploration;
|
||||
private string _currentRegion = string.Empty;
|
||||
private readonly CompositeDisposable _subscriptions = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Debug.Assert(_config != null, "[BGMController] _config 未赋值,请在 Inspector 中指定 AudioConfigSO。", this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onBossFightToggled?.Subscribe(OnBossFightToggled).AddTo(_subscriptions);
|
||||
_onRegionEntered?.Subscribe(OnRegionEntered).AddTo(_subscriptions);
|
||||
_onGameStateChanged?.Subscribe(HandleStateChanged).AddTo(_subscriptions);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subscriptions.Clear();
|
||||
}
|
||||
|
||||
private void OnBossFightToggled(bool started)
|
||||
{
|
||||
if (started)
|
||||
{
|
||||
_musicState = MusicState.Boss;
|
||||
var clip = _config.GetBossBGM(_currentRegion);
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogWarning($"[BGMController] 区域 '{_currentRegion}' 未配置 Boss BGM,将保持当前音乐。", this);
|
||||
}
|
||||
else
|
||||
{
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
|
||||
}
|
||||
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(PlayVictoryThenRestore());
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PlayVictoryThenRestore()
|
||||
{
|
||||
_musicState = MusicState.Victory;
|
||||
_audioManager.PlayBGM(_config.VictoryStingBGM,
|
||||
fadeOutDur: 0.3f, fadeInDur: 0.1f);
|
||||
float dur = _config.VictoryStingDuration;
|
||||
yield return new WaitForSecondsRealtime(dur);
|
||||
_musicState = MusicState.Exploration;
|
||||
OnRegionEntered(_currentRegion);
|
||||
_audioManager.TransitionToSnapshot("Default", 1.0f);
|
||||
}
|
||||
|
||||
private void OnRegionEntered(string regionId)
|
||||
{
|
||||
if (regionId == _currentRegion) return;
|
||||
_currentRegion = regionId;
|
||||
if (_musicState == MusicState.Exploration)
|
||||
{
|
||||
var clip = _config.GetZoneBGM(regionId);
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogWarning($"[BGMController] 区域 '{regionId}' 未配置 BGM,将保持当前音乐。", this);
|
||||
return;
|
||||
}
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStateChanged(GameStateId state)
|
||||
{
|
||||
if (state == GameStates.MainMenu)
|
||||
_audioManager.PlayBGM(_config.MainMenuBGM,
|
||||
fadeOutDur: 0.5f, fadeInDur: 1.0f);
|
||||
else if (state == GameStates.Paused)
|
||||
_audioManager.TransitionToSnapshot("Paused", 0.2f);
|
||||
else if (state == GameStates.Dead)
|
||||
_audioManager.TransitionToSnapshot("Dead", 1.5f);
|
||||
else if (state == GameStates.Gameplay)
|
||||
_audioManager.TransitionToSnapshot("Default", 0.3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/BGMController.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/BGMController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eed9bb7d17336ae4c8e2636fbeeef6bd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Assets/_Game/Scripts/Audio/BaseGames.Audio.asmdef
Normal file
18
Assets/_Game/Scripts/Audio/BaseGames.Audio.asmdef
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"precompiledReferences": [],
|
||||
"name": "BaseGames.Audio",
|
||||
"defineConstraints": [],
|
||||
"noEngineReferences": false,
|
||||
"versionDefines": [],
|
||||
"rootNamespace": "BaseGames.Audio",
|
||||
"references": [
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Combat"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
"includePlatforms": []
|
||||
}
|
||||
7
Assets/_Game/Scripts/Audio/BaseGames.Audio.asmdef.meta
Normal file
7
Assets/_Game/Scripts/Audio/BaseGames.Audio.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bdbbbb51c06a54142b8bf1f9966fc408
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
81
Assets/_Game/Scripts/Audio/CombatSFXController.cs
Normal file
81
Assets/_Game/Scripts/Audio/CombatSFXController.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅战斗/死亡事件,通过 GlobalSFXPlayer 播放对应 AudioEventSO 音效。
|
||||
/// 挂载在 Persistent 场景的 [Systems] GameObject 上。
|
||||
/// 使用 AudioEventSO 替代裸 AudioClip,支持随机音量 / 音调 / 多片段。
|
||||
/// </summary>
|
||||
public class CombatSFXController : MonoBehaviour
|
||||
{
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
|
||||
[Header("Default Hit SFX")]
|
||||
[SerializeField] private AudioEventSO _defaultHitSFX;
|
||||
|
||||
[Header("Per-Type Hit SFX (optional, overrides default)")]
|
||||
[SerializeField] private AudioEventSO _sparkHitSFX;
|
||||
[SerializeField] private AudioEventSO _slashHitSFX;
|
||||
[SerializeField] private AudioEventSO _bloodHitSFX;
|
||||
[SerializeField] private AudioEventSO _magicHitSFX;
|
||||
[SerializeField] private AudioEventSO _heavyHitSFX;
|
||||
[SerializeField] private AudioEventSO _critHitSFX;
|
||||
[SerializeField] private AudioEventSO _parryHitSFX;
|
||||
[SerializeField] private AudioEventSO _fireHitSFX;
|
||||
[SerializeField] private AudioEventSO _iceHitSFX;
|
||||
|
||||
[Header("Death SFX")]
|
||||
[SerializeField] private AudioEventSO _playerDeathSFX;
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onHitConfirmed?.Subscribe(HandleHit).AddTo(_subs);
|
||||
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void HandleHit(HitInfo info)
|
||||
{
|
||||
AudioEventSO sfx = ResolveHitSFX(info.DamageInfo.FxType);
|
||||
if (sfx == null) return;
|
||||
|
||||
GlobalSFXPlayer.Play(sfx, info.HitPoint);
|
||||
}
|
||||
|
||||
private void HandlePlayerDied()
|
||||
{
|
||||
if (_playerDeathSFX == null) return;
|
||||
GlobalSFXPlayer.Play(_playerDeathSFX);
|
||||
}
|
||||
|
||||
private AudioEventSO ResolveHitSFX(HitFxType fxType)
|
||||
{
|
||||
AudioEventSO perType = fxType switch
|
||||
{
|
||||
HitFxType.Spark => _sparkHitSFX,
|
||||
HitFxType.Slash => _slashHitSFX,
|
||||
HitFxType.Blood => _bloodHitSFX,
|
||||
HitFxType.Magic => _magicHitSFX,
|
||||
HitFxType.Heavy => _heavyHitSFX,
|
||||
HitFxType.Crit => _critHitSFX,
|
||||
HitFxType.Parry => _parryHitSFX,
|
||||
HitFxType.Fire => _fireHitSFX,
|
||||
HitFxType.Ice => _iceHitSFX,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return perType != null ? perType : _defaultHitSFX;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/CombatSFXController.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/CombatSFXController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9ab0a72bbabda44888a0bec8186bc27
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
30
Assets/_Game/Scripts/Audio/FootstepAudioConfigSO.cs
Normal file
30
Assets/_Game/Scripts/Audio/FootstepAudioConfigSO.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
// Assets/Scripts/Audio/FootstepAudioConfigSO.cs
|
||||
// 脚步声音效配置(Architecture 21_LiquidPuzzleModule §3.3)
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/BaseGames/Audio/FootstepAudioConfig")]
|
||||
public class FootstepAudioConfigSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct MaterialEntry
|
||||
{
|
||||
public FootstepMaterial material;
|
||||
public AudioClip[] clips; // 随机选一个,防止重复感
|
||||
[Range(0f, 1f)] public float volume;
|
||||
[Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围
|
||||
}
|
||||
|
||||
public MaterialEntry[] entries;
|
||||
|
||||
public MaterialEntry? GetEntry(FootstepMaterial mat)
|
||||
{
|
||||
if (entries == null) return null;
|
||||
foreach (var e in entries)
|
||||
if (e.material == mat) return e;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/FootstepAudioConfigSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/FootstepAudioConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 902dc34e76d63a041a7e9a564b89a5e0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/_Game/Scripts/Audio/FootstepMaterial.cs
Normal file
16
Assets/_Game/Scripts/Audio/FootstepMaterial.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
// Assets/Scripts/Audio/FootstepMaterial.cs
|
||||
// 脚步声材质枚举(Architecture 21_LiquidPuzzleModule §3.3)
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
public enum FootstepMaterial
|
||||
{
|
||||
Stone, // 石板地(默认)
|
||||
Dirt, // 泥土/草地
|
||||
Wood, // 木板
|
||||
Metal, // 金属格栅
|
||||
Water, // 浅水区(溅水声)
|
||||
Sand, // 沙地
|
||||
Grass, // 草丛
|
||||
Cave, // 洞穴(回响加强)
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/FootstepMaterial.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/FootstepMaterial.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dc331b476a760a49b238b6645aae052
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/_Game/Scripts/Audio/FootstepMaterialMarker.cs
Normal file
16
Assets/_Game/Scripts/Audio/FootstepMaterialMarker.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
// Assets/Scripts/Audio/FootstepMaterialMarker.cs
|
||||
// 挂载到地面碰撞体所在 GameObject,标记该地面的脚步声材质
|
||||
// (Architecture 21_LiquidPuzzleModule §3.3)
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂载到地面碰撞体所在 GameObject(Tilemap 图层 or 单体地形 Prefab)。
|
||||
/// 若玩家脚下 GameObject 无此组件,默认使用 FootstepMaterial.Stone。
|
||||
/// </summary>
|
||||
public class FootstepMaterialMarker : MonoBehaviour
|
||||
{
|
||||
public FootstepMaterial material = FootstepMaterial.Stone;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/FootstepMaterialMarker.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/FootstepMaterialMarker.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9bed4b6cdf4d7064793fe22fefccce43
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Assets/_Game/Scripts/Audio/GlobalSFXPlayer.cs
Normal file
44
Assets/_Game/Scripts/Audio/GlobalSFXPlayer.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局 SFX 播放入口(单例 MonoBehaviour)。
|
||||
/// 通过 <see cref="Play"/> 静态方法播放 <see cref="AudioEventSO"/>,
|
||||
/// 可选传入世界坐标以在指定位置 3D 播放。
|
||||
/// </summary>
|
||||
public class GlobalSFXPlayer : MonoBehaviour
|
||||
{
|
||||
private static GlobalSFXPlayer _instance;
|
||||
|
||||
[SerializeField] private AudioSource _globalSFXSource;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null) { Destroy(gameObject); return; }
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放一个音效事件。
|
||||
/// <para>若传入 <paramref name="worldPos"/>,则在该位置 3D 播放;否则使用全局 AudioSource 2D 播放。</para>
|
||||
/// </summary>
|
||||
public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
|
||||
{
|
||||
if (audioEvent == null || _instance == null) return;
|
||||
|
||||
if (worldPos.HasValue)
|
||||
{
|
||||
var clip = audioEvent.PickClip();
|
||||
if (clip != null)
|
||||
ServiceLocator.GetOrDefault<IAudioService>()
|
||||
?.PlaySFXAtPosition(clip, worldPos.Value, audioEvent.PickVolume());
|
||||
}
|
||||
else
|
||||
{
|
||||
audioEvent.Play(_instance._globalSFXSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/GlobalSFXPlayer.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/GlobalSFXPlayer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8275d76dd985c4c419f4c477b9275de3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
58
Assets/_Game/Scripts/Audio/UnderwaterAudioController.cs
Normal file
58
Assets/_Game/Scripts/Audio/UnderwaterAudioController.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
// Assets/Scripts/Audio/UnderwaterAudioController.cs
|
||||
// 进入 LiquidZone 时切换水下 DSP 处理(Architecture 21_LiquidPuzzleModule §3.4)
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂载于 PlayerController 所在 GameObject。
|
||||
/// 订阅 EVT_LiquidEntered / EVT_LiquidExited 事件频道(与 WaterDangerState、UnderwaterPostProcessingController 一致)。
|
||||
/// 切换 AudioMixer Snapshot 以应用/解除水下 DSP 处理。
|
||||
/// 仅响应 Water 类型液体;Acid / Lava 不切换水下音频。
|
||||
/// </summary>
|
||||
public class UnderwaterAudioController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private AudioMixer _mixer;
|
||||
[SerializeField] private float _transitionDuration = 0.3f;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered
|
||||
[SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onLiquidEntered?.Subscribe(OnLiquidEntered).AddTo(_subs);
|
||||
_onLiquidExited?.Subscribe(OnLiquidExited).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void OnLiquidEntered(LiquidEvent evt)
|
||||
{
|
||||
if (evt.LiquidType == LiquidType.Water)
|
||||
EnterWater();
|
||||
}
|
||||
|
||||
private void OnLiquidExited(LiquidEvent evt)
|
||||
{
|
||||
if (evt.LiquidType == LiquidType.Water)
|
||||
ExitWater();
|
||||
}
|
||||
|
||||
/// <summary>切换至水下 AudioMixer Snapshot。</summary>
|
||||
public void EnterWater()
|
||||
{
|
||||
_mixer?.FindSnapshot("Underwater")?.TransitionTo(_transitionDuration);
|
||||
}
|
||||
|
||||
/// <summary>切换回默认 AudioMixer Snapshot。</summary>
|
||||
public void ExitWater()
|
||||
{
|
||||
_mixer?.FindSnapshot("Default")?.TransitionTo(_transitionDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Audio/UnderwaterAudioController.cs.meta
Normal file
11
Assets/_Game/Scripts/Audio/UnderwaterAudioController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d32189c7a2ecbe4caf2bf3d8aa174f2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user