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