using System.Collections.Generic; using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.Audio; using BrewMonster.Scripts; namespace BrewMonster.Scripts { /// /// Translates numeric SFX IDs (from the original C++ sound.txt table) into /// Addressable audio paths, then loads and plays them via AddressableManager. /// /// C++ equivalent: CECGameRun::m_SoundStringTable + GetOrLoadSoundFromTable() /// public class SFXManager : MonoSingleton { private readonly Dictionary _soundTable = new(); private const string SoundTableResourcePath = "sound"; // Assets/Resources/sound.txt /// /// 2D looping AudioSource used exclusively for host-player movement sounds /// (footsteps, swim, air). Assign in the Inspector on the SFXManager GameObject. /// C++ equivalent: CECHostPlayer::m_pCurMoveSnd ownership moved here. /// [SerializeField] private AudioSource _moveSoundSource; /// /// Mixer group that all skill SFX are routed through. Assign in the Inspector. /// [SerializeField] private AudioMixerGroup _sfxMixerGroup; /// /// Number of pooled AudioSources available for concurrent skill SFX playback. /// [SerializeField] private int _sfxPoolSize = 8; private readonly List _sfxPool = new(); protected override void Initialize() { base.Initialize(); LoadSoundTable(); BuildSfxPool(); } // ──────────────────────────────────────────────────────────────────── // Startup: parse sound.txt // ──────────────────────────────────────────────────────────────────── private void LoadSoundTable() { var textAsset = Resources.Load(SoundTableResourcePath); if (textAsset == null) { BMLogger.LogError("SFXManager: sound.txt not found in Resources/"); return; } var lines = textAsset.text.Split('\n'); foreach (var line in lines) { var trimmed = line.Trim(); if (string.IsNullOrEmpty(trimmed)) continue; var tabIndex = trimmed.IndexOf('\t'); if (tabIndex < 0) continue; var idStr = trimmed.Substring(0, tabIndex).Trim(); var rawPath = trimmed.Substring(tabIndex + 1).Trim().Trim('"'); if (!int.TryParse(idStr, out int id)) continue; if (string.IsNullOrEmpty(rawPath)) continue; // empty entry — intentional silence _soundTable[id] = NormalizePath(rawPath); } BMLogger.Log($"SFXManager: Loaded {_soundTable.Count} SFX entries from sound.txt"); } /// /// Convert Windows backslash path from sound.txt into a forward-slash /// lowercase Addressables address without file extension. /// e.g. "SFX\Character\FootStep\LandWalkMaleA.wav" /// → "sfx/character/footstep/landwalkmalea" /// private static string NormalizePath(string raw) { var path = raw.Replace('\\', '/').ToLowerInvariant(); var dotIndex = path.LastIndexOf('.'); if (dotIndex > 0) path = path.Substring(0, dotIndex); if (path.StartsWith("sfx/")) path = path.Substring(4); return path; } // ──────────────────────────────────────────────────────────────────── // Skill SFX pool // ──────────────────────────────────────────────────────────────────── private void BuildSfxPool() { for (int i = 0; i < _sfxPoolSize; i++) { var child = new GameObject($"SFXPool_{i}"); child.transform.SetParent(transform); var src = child.AddComponent(); src.playOnAwake = false; src.outputAudioMixerGroup = _sfxMixerGroup; _sfxPool.Add(src); } } /// World position the source is moved to before playback. /// 0 = fully 2D, 1 = fully 3D positional. private AudioSource GetPooledSource(Vector3 worldPos, float spatialBlend = 0f) { AudioSource chosen = null; foreach (var src in _sfxPool) if (!src.isPlaying) { chosen = src; break; } if (chosen == null) chosen = _sfxPool[0]; // fallback: steal oldest chosen.transform.position = worldPos; chosen.spatialBlend = spatialBlend; return chosen; } // ──────────────────────────────────────────────────────────────────── // Public API // ──────────────────────────────────────────────────────────────────── private const float SkillSfxVolume = 1f; /// /// Play a skill SFX by Addressable address at a world position. /// Uses the cache fast-path when the clip is already loaded; otherwise waits /// for AddressableManager initialization (no per-frame lambda polling) before /// loading and playing the clip. /// /// Pass (in seconds) to postpone playback. The wait /// can be cancelled early via ; if cancelled /// the method exits silently without playing anything. /// /// public async UniTaskVoid PlaySkillSfxAtPointAsync( string address, Vector3 worldPos, float delay = 0f, System.Threading.CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(address)) return; if (delay > 0f) await UniTask.Delay(System.TimeSpan.FromSeconds(delay), cancellationToken: cancellationToken); var mgr = AddressableManager.Instance; if (mgr == null) return; if (mgr.TryGetCachedAudioClip(address, out var clip) && clip != null) { GetPooledSource(worldPos).PlayOneShot(clip, SkillSfxVolume); return; } await mgr.WaitUntilInitializedAsync(); var loaded = await mgr.LoadAudioClipAsync(address); if (loaded != null) GetPooledSource(worldPos).PlayOneShot(loaded, SkillSfxVolume); } /// /// Resolve a numeric SFX ID to its normalized Addressable path. /// Returns null if the ID is not in the table or maps to an empty entry. /// public string GetSFXPath(int id) { return _soundTable.TryGetValue(id, out var path) ? path : null; } /// /// Load (if not already cached) and play a sound by its numeric ID. /// Maps directly to CECHostPlayer::PlayMoveSound / SFX_INFO::Start logic. /// /// controls spatialization — pass a 2D source for /// movement sounds or a 3D world-space source for positional SFX. /// /// No-op if the ID is unmapped, maps to an empty entry, or the clip fails /// to load. /// public async UniTaskVoid PlaySFXByIdAsync(int id, AudioSource source) { if (source == null) return; var path = GetSFXPath(id); if (string.IsNullOrEmpty(path)) return; // Fast path: clip already in Addressables cache if (AddressableManager.Instance.TryGetCachedAudioClip(path, out var cached)) { source.PlayOneShot(cached); return; } // Slow path: load from Addressables bundle var clip = await AddressableManager.Instance.LoadAudioClipAsync(path); if (clip != null) source.PlayOneShot(clip); } /// /// Awaitable variant — use when you need to know when playback has been /// dispatched (e.g. chaining movement sound updates). /// public async UniTask PlaySFXByIdAsyncAwaitable(int id, AudioSource source) { if (source == null) return; var path = GetSFXPath(id); if (string.IsNullOrEmpty(path)) return; if (AddressableManager.Instance.TryGetCachedAudioClip(path, out var cached)) { source.PlayOneShot(cached); return; } var clip = await AddressableManager.Instance.LoadAudioClipAsync(path); if (clip != null) source.PlayOneShot(clip); } /// /// Plays a looping movement sound by numeric ID on the manager-owned /// , stopping the current clip when the sound changes. /// Pass id = 0 to stop without starting a new clip. /// C++ equivalent: CECHostPlayer::PlayMoveSound (looping variant) /// public async UniTaskVoid PlayMoveSoundAsync(int id) { if (_moveSoundSource == null) return; _moveSoundSource.Stop(); if (id <= 0) return; var path = GetSFXPath(id); if (string.IsNullOrEmpty(path)) return; if (AddressableManager.Instance.TryGetCachedAudioClip(path, out var clip) && clip != null) { _moveSoundSource.clip = clip; _moveSoundSource.loop = true; _moveSoundSource.Play(); return; } await AddressableManager.Instance.WaitUntilInitializedAsync(); var loaded = await AddressableManager.Instance.LoadAudioClipAsync(path); if (loaded != null) { _moveSoundSource.clip = loaded; _moveSoundSource.loop = true; _moveSoundSource.Play(); } } public async UniTaskVoid StopMoveSoundAsync() { if (_moveSoundSource == null) return; _moveSoundSource.Stop(); } } }