多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -0,0 +1,52 @@
using UnityEngine;
using UnityEngine.Audio;
namespace BaseGames.Audio
{
/// <summary>
/// 音效事件 ScriptableObject。随机从 Clips 中选取一条播放,
/// 并在 Volume / Pitch 区间内随机变化,增强音效多样性。
/// </summary>
[CreateAssetMenu(menuName = "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());
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: c7d6fe0521388084e83314560a394951
guid: 356d306cd9d1eaa46b9d283761bcba54
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -22,8 +22,22 @@ namespace BaseGames.Audio
[SerializeField] private AudioSource _bgmSourceB;
[Header("SFX Pool建议 6 个,均路由到 SFX MixerGroup")]
[Tooltip("轮转池大小:同帧触发数超过此值时最旧的音效会被打断。建议 6~8 个。")]
[SerializeField] private AudioSource[] _sfxSources;
[Header("Audio ConfigBGM 映射)")]
[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;
@@ -31,49 +45,61 @@ namespace BaseGames.Audio
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 Dictionary<string, AudioEventSO> _sfxLookup;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
#pragma warning disable CS0618
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
#pragma warning restore CS0618
if (ServiceLocator.GetOrDefault<IAudioService>() != null) { Destroy(gameObject); return; }
Debug.Assert(_audioConfig != null, "[AudioManager] _audioConfig 未赋值,请在 Inspector 中指定 AudioConfigSO。", this);
_activeBGMSource = _bgmSourceA;
_inactiveBGMSource = _bgmSourceB;
// ServiceLocator 注册(覆盖 GameServiceRegistrar 的 NullAudioService 兜底)
ServiceLocator.Register<IAudioService>(this);
BuildSFXLookup();
Initialize();
}
private void OnEnable()
{
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised += HandlePlayerDied;
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
}
private void OnDisable()
{
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised -= HandlePlayerDied;
_subs.Clear();
}
// ── 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}");
private void OnDestroy()
{
ServiceLocator.Unregister<IAudioService>(this);
}
/// <summary>
/// 按 Addressable key 播放 SFX。Phase 2 接入 AudioEventSO 前为占位警告。
/// </summary>
// ── 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)
=> Debug.LogWarning($"[AudioManager] PlaySFX(key) 尚未接入 AudioEventSOPhase 2。key={key}");
{
if (_sfxLookup != null && _sfxLookup.TryGetValue(key, out var evt))
{
evt?.PlayOneShot(NextSFXSource());
return;
}
Debug.LogWarning($"[AudioManager] SFX key '{key}' 未在注册表中找到。");
}
// ── 音量控制 ─────────────────────────────────────────────────────────────
/// <summary>
@@ -83,10 +109,15 @@ namespace BaseGames.Audio
public void SetVolume(string exposedParam, float linear)
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
/// <summary>读取 GlobalSettings 并应用所有音量初始值。</summary>
/// <summary>读取 SettingsManager 已加载的设置数据并应用四路音量到 AudioMixer。</summary>
public void Initialize()
{
// TODO: 从 SettingsManager / PlayerPrefs 读取保存的音量值并应用
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 ──────────────────────────────────────────────────────────────────
@@ -113,6 +144,7 @@ namespace BaseGames.Audio
{
if (clip == null) return;
var src = NextSFXSource();
if (src == null) return;
src.volume = volumeScale;
src.PlayOneShot(clip);
}
@@ -138,9 +170,22 @@ namespace BaseGames.Audio
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) return _bgmSourceA;
if (_sfxSources == null || _sfxSources.Length == 0)
{
Debug.LogError("[AudioManager] SFX Source 池为空,请在 Inspector 中为 _sfxSources 赋值。");
return null;
}
return _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
}

View File

@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
executionOrder: -500
icon: {instanceID: 0}
userData:
assetBundleName:

View File

@@ -30,19 +30,23 @@ namespace BaseGames.Audio
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()
{
if (_onBossFightToggled != null) _onBossFightToggled.OnEventRaised += OnBossFightToggled;
if (_onRegionEntered != null) _onRegionEntered.OnEventRaised += OnRegionEntered;
if (_onGameStateChanged != null) _onGameStateChanged.OnEventRaised += HandleStateChanged;
_onBossFightToggled?.Subscribe(OnBossFightToggled).AddTo(_subscriptions);
_onRegionEntered?.Subscribe(OnRegionEntered).AddTo(_subscriptions);
_onGameStateChanged?.Subscribe(HandleStateChanged).AddTo(_subscriptions);
}
private void OnDisable()
{
if (_onBossFightToggled != null) _onBossFightToggled.OnEventRaised -= OnBossFightToggled;
if (_onRegionEntered != null) _onRegionEntered.OnEventRaised -= OnRegionEntered;
if (_onGameStateChanged != null) _onGameStateChanged.OnEventRaised -= HandleStateChanged;
_subscriptions.Clear();
}
private void OnBossFightToggled(bool started)
@@ -50,7 +54,7 @@ namespace BaseGames.Audio
if (started)
{
_musicState = MusicState.Boss;
var clip = _config != null ? _config.GetBossBGM(_currentRegion) : null;
var clip = _config.GetBossBGM(_currentRegion);
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
}
@@ -63,9 +67,9 @@ namespace BaseGames.Audio
private IEnumerator PlayVictoryThenRestore()
{
_musicState = MusicState.Victory;
_audioManager.PlayBGM(_config != null ? _config.VictoryStingBGM : null,
_audioManager.PlayBGM(_config.VictoryStingBGM,
fadeOutDur: 0.3f, fadeInDur: 0.1f);
float dur = _config != null ? _config.VictoryStingDuration : 4f;
float dur = _config.VictoryStingDuration;
yield return new WaitForSecondsRealtime(dur);
_musicState = MusicState.Exploration;
OnRegionEntered(_currentRegion);
@@ -78,16 +82,15 @@ namespace BaseGames.Audio
_currentRegion = regionId;
if (_musicState == MusicState.Exploration)
{
var clip = _config != null ? _config.GetZoneBGM(regionId) : null;
var clip = _config.GetZoneBGM(regionId);
_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,
_audioManager.PlayBGM(_config.MainMenuBGM,
fadeOutDur: 0.5f, fadeInDur: 1.0f);
else if (state == GameStates.Paused)
_audioManager.TransitionToSnapshot("Paused", 0.2f);

View File

@@ -5,8 +5,9 @@ using BaseGames.Combat;
namespace BaseGames.Audio
{
/// <summary>
/// 订阅战斗/死亡事件,通过 AudioManager 播放对应 SFX
/// 订阅战斗/死亡事件,通过 GlobalSFXPlayer 播放对应 AudioEventSO 音效
/// 挂载在 Persistent 场景的 [Systems] GameObject 上。
/// 使用 AudioEventSO 替代裸 AudioClip支持随机音量 / 音调 / 多片段。
/// </summary>
public class CombatSFXController : MonoBehaviour
{
@@ -15,57 +16,52 @@ namespace BaseGames.Audio
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[Header("Default Hit SFX")]
[SerializeField] private AudioClip _defaultHitSFX;
[SerializeField] private AudioEventSO _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;
[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 AudioClip _playerDeathSFX;
[SerializeField] private AudioEventSO _playerDeathSFX;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised += HandleHit;
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised += HandlePlayerDied;
_onHitConfirmed?.Subscribe(HandleHit).AddTo(_subs);
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
}
private void OnDisable()
{
if (_onHitConfirmed != null)
_onHitConfirmed.OnEventRaised -= HandleHit;
if (_onPlayerDied != null)
_onPlayerDied.OnEventRaised -= HandlePlayerDied;
_subs.Clear();
}
private void HandleHit(HitInfo info)
{
AudioClip clip = ResolveHitClip(info.DamageInfo.FxType);
if (clip == null) return;
AudioEventSO sfx = ResolveHitSFX(info.DamageInfo.FxType);
if (sfx == null) return;
AudioManager.Instance.PlaySFXAtPosition(clip, info.HitPoint);
GlobalSFXPlayer.Play(sfx, info.HitPoint);
}
private void HandlePlayerDied()
{
if (_playerDeathSFX == null) return;
AudioManager.Instance.PlaySFX(_playerDeathSFX);
GlobalSFXPlayer.Play(_playerDeathSFX);
}
private AudioClip ResolveHitClip(HitFxType fxType)
private AudioEventSO ResolveHitSFX(HitFxType fxType)
{
AudioClip perType = fxType switch
AudioEventSO perType = fxType switch
{
HitFxType.Spark => _sparkHitSFX,
HitFxType.Slash => _slashHitSFX,

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

View File

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

View 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, // 洞穴(回响加强)
}
}

View File

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

View File

@@ -0,0 +1,16 @@
// Assets/Scripts/Audio/FootstepMaterialMarker.cs
// 挂载到地面碰撞体所在 GameObject标记该地面的脚步声材质
// Architecture 21_LiquidPuzzleModule §3.3
using UnityEngine;
namespace BaseGames.Audio
{
/// <summary>
/// 挂载到地面碰撞体所在 GameObjectTilemap 图层 or 单体地形 Prefab
/// 若玩家脚下 GameObject 无此组件,默认使用 FootstepMaterial.Stone。
/// </summary>
public class FootstepMaterialMarker : MonoBehaviour
{
public FootstepMaterial material = FootstepMaterial.Stone;
}
}

View File

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

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

View File

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

View File

@@ -0,0 +1,30 @@
// Assets/Scripts/Audio/UnderwaterAudioController.cs
// 进入 LiquidZone 时切换水下 DSP 处理Architecture 21_LiquidPuzzleModule §3.4
using UnityEngine;
using UnityEngine.Audio;
namespace BaseGames.Audio
{
/// <summary>
/// 挂载于 PlayerController 所在 GameObject。
/// 由 LiquidZone.OnTriggerEnter2D / OnTriggerExit2D 直接调用。
/// 切换 AudioMixer Snapshot 以应用/解除水下 DSP 处理。
/// </summary>
public class UnderwaterAudioController : MonoBehaviour
{
[SerializeField] private AudioMixer _mixer;
[SerializeField] private float _transitionDuration = 0.3f;
/// <summary>玩家进入 Water 类型液体时调用。</summary>
public void EnterWater()
{
_mixer?.FindSnapshot("Underwater")?.TransitionTo(_transitionDuration);
}
/// <summary>玩家离开液体时调用。</summary>
public void ExitWater()
{
_mixer?.FindSnapshot("Default")?.TransitionTo(_transitionDuration);
}
}
}

View File

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

View File

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