chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

View 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 = "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;
}
}
}

View File

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

View File

@@ -0,0 +1,200 @@
using System.Collections;
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")]
[SerializeField] private AudioSource[] _sfxSources;
[Header("Event Channels - Subscribe")]
[SerializeField] private VoidEventChannelSO _onPlayerDied;
private AudioSource _activeBGMSource;
private AudioSource _inactiveBGMSource;
private Coroutine _crossfadeCoroutine;
private int _sfxRoundRobin;
// ── 遗留单例(已废弃;新代码请使用 ServiceLocator.Get<IAudioService>())────────────
[System.Obsolete("Use ServiceLocator.Get<IAudioService>() instead.")]
public static AudioManager Instance { get; private set; }
private void Awake()
{
#pragma warning disable CS0618
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
#pragma warning restore CS0618
_activeBGMSource = _bgmSourceA;
_inactiveBGMSource = _bgmSourceB;
// ServiceLocator 注册(覆盖 GameServiceRegistrar 的 NullAudioService 兜底)
ServiceLocator.Register<IAudioService>(this);
}
private void OnEnable()
{
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised += HandlePlayerDied;
}
private void OnDisable()
{
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised -= HandlePlayerDied;
}
// ── IAudioService string-key APIPhase 2 接入 AudioEventSO 后完整实现)─────────────
/// <summary>
/// 按 Addressable key 播放 BGM。Phase 2 接入 AudioEventSO 前为占位警告。
/// </summary>
public void PlayBGM(string key)
=> Debug.LogWarning($"[AudioManager] PlayBGM(key) 尚未接入 AudioEventSOPhase 2。key={key}");
/// <summary>
/// 按 Addressable key 播放 SFX。Phase 2 接入 AudioEventSO 前为占位警告。
/// </summary>
public void PlaySFX(string key)
=> Debug.LogWarning($"[AudioManager] PlaySFX(key) 尚未接入 AudioEventSOPhase 2。key={key}");
// ── 音量控制 ─────────────────────────────────────────────────────────────
/// <summary>
/// 将指定 Exposed Parameter 设置为 0-1 线性值(内部转换为 dB
/// 唯一音量写入入口(同时满足 IAudioService.SetVolume 接口)。
/// </summary>
public void SetVolume(string exposedParam, float linear)
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
/// <summary>读取 GlobalSettings 并应用所有音量初始值。</summary>
public void Initialize()
{
// TODO: 从 SettingsManager / PlayerPrefs 读取保存的音量值并应用
}
// ── 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();
src.volume = volumeScale;
src.PlayOneShot(clip);
}
/// <summary>2D 游戏中位置无衰减,统一委托多源池播放。</summary>
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f)
=> PlaySFX(clip, 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 AudioSource NextSFXSource()
{
if (_sfxSources == null || _sfxSources.Length == 0) return _bgmSourceA;
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;
}
}

View File

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

View 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";
}
}

View File

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

View 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);
}
}
}

View File

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

View File

@@ -0,0 +1,100 @@
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 void OnEnable()
{
if (_onBossFightToggled != null) _onBossFightToggled.OnEventRaised += OnBossFightToggled;
if (_onRegionEntered != null) _onRegionEntered.OnEventRaised += OnRegionEntered;
if (_onGameStateChanged != null) _onGameStateChanged.OnEventRaised += HandleStateChanged;
}
private void OnDisable()
{
if (_onBossFightToggled != null) _onBossFightToggled.OnEventRaised -= OnBossFightToggled;
if (_onRegionEntered != null) _onRegionEntered.OnEventRaised -= OnRegionEntered;
if (_onGameStateChanged != null) _onGameStateChanged.OnEventRaised -= HandleStateChanged;
}
private void OnBossFightToggled(bool started)
{
if (started)
{
_musicState = MusicState.Boss;
var clip = _config != null ? _config.GetBossBGM(_currentRegion) : null;
_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 != null ? _config.VictoryStingBGM : null,
fadeOutDur: 0.3f, fadeInDur: 0.1f);
float dur = _config != null ? _config.VictoryStingDuration : 4f;
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 != null ? _config.GetZoneBGM(regionId) : null;
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
}
}
private void HandleStateChanged(GameStateId state)
{
// ⚠️ GameStateId 是 struct不能用 switch使用 if/else + GameStates 常量
if (state == GameStates.MainMenu)
_audioManager.PlayBGM(_config != null ? _config.MainMenuBGM : null,
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);
}
}
}

View File

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

View 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": []
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bdbbbb51c06a54142b8bf1f9966fc408
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,85 @@
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.Combat;
namespace BaseGames.Audio
{
/// <summary>
/// 订阅战斗/死亡事件,通过 AudioManager 播放对应 SFX。
/// 挂载在 Persistent 场景的 [Systems] GameObject 上。
/// </summary>
public class CombatSFXController : MonoBehaviour
{
[Header("Event Channels")]
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[Header("Default Hit SFX")]
[SerializeField] private AudioClip _defaultHitSFX;
[Header("Per-Type Hit SFX (optional, overrides default)")]
[SerializeField] private AudioClip _sparkHitSFX;
[SerializeField] private AudioClip _slashHitSFX;
[SerializeField] private AudioClip _bloodHitSFX;
[SerializeField] private AudioClip _magicHitSFX;
[SerializeField] private AudioClip _heavyHitSFX;
[SerializeField] private AudioClip _critHitSFX;
[SerializeField] private AudioClip _parryHitSFX;
[SerializeField] private AudioClip _fireHitSFX;
[SerializeField] private AudioClip _iceHitSFX;
[Header("Death SFX")]
[SerializeField] private AudioClip _playerDeathSFX;
private void OnEnable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised += HandleHit;
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised += HandlePlayerDied;
}
private void OnDisable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised -= HandleHit;
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised -= HandlePlayerDied;
}
private void HandleHit(HitInfo info)
{
AudioClip clip = ResolveHitClip(info.DamageInfo.FxType);
if (clip == null) return;
AudioManager.Instance.PlaySFXAtPosition(clip, info.HitPoint);
}
private void HandlePlayerDied()
{
if (_playerDeathSFX == null) return;
AudioManager.Instance.PlaySFX(_playerDeathSFX);
}
private AudioClip ResolveHitClip(HitFxType fxType)
{
AudioClip 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;
}
}
}

View File

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

View File

@@ -0,0 +1,3 @@
// Placeholder to prevent asmdef-no-scripts warning.
namespace BaseGames.Audio { }

View File

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