Files
test/Assets/PerfectWorld/Scripts/Sound/SFXManager.cs
T
2026-04-24 17:49:20 +07:00

229 lines
9.0 KiB
C#

using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
using BrewMonster.Scripts;
namespace BrewMonster.Scripts
{
/// <summary>
/// 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()
/// </summary>
public class SFXManager : MonoSingleton<SFXManager>
{
private readonly Dictionary<int, string> _soundTable = new();
private const string SoundTableResourcePath = "sound"; // Assets/Resources/sound.txt
/// <summary>
/// 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.
/// </summary>
[SerializeField] private AudioSource _moveSoundSource;
protected override void Initialize()
{
base.Initialize();
LoadSoundTable();
}
// ────────────────────────────────────────────────────────────────────
// Startup: parse sound.txt
// ────────────────────────────────────────────────────────────────────
private void LoadSoundTable()
{
var textAsset = Resources.Load<TextAsset>(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");
}
/// <summary>
/// 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"
/// </summary>
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;
}
// ────────────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────────────
private const float SkillSfxVolume = 1f;
/// <summary>
/// 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.
/// <para>
/// Pass <paramref name="delay"/> (in seconds) to postpone playback. The wait
/// can be cancelled early via <paramref name="cancellationToken"/>; if cancelled
/// the method exits silently without playing anything.
/// </para>
/// </summary>
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)
{
AudioSource.PlayClipAtPoint(clip, Vector3.zero, SkillSfxVolume);
return;
}
await mgr.WaitUntilInitializedAsync();
var loaded = await mgr.LoadAudioClipAsync(address);
if (loaded != null)
AudioSource.PlayClipAtPoint(loaded, Vector3.zero, SkillSfxVolume);
}
/// <summary>
/// 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.
/// </summary>
public string GetSFXPath(int id)
{
return _soundTable.TryGetValue(id, out var path) ? path : null;
}
/// <summary>
/// Load (if not already cached) and play a sound by its numeric ID.
/// Maps directly to CECHostPlayer::PlayMoveSound / SFX_INFO::Start logic.
///
/// <paramref name="source"/> 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.
/// </summary>
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);
}
/// <summary>
/// Awaitable variant — use when you need to know when playback has been
/// dispatched (e.g. chaining movement sound updates).
/// </summary>
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);
}
/// <summary>
/// Plays a looping movement sound by numeric ID on the manager-owned
/// <see cref="_moveSoundSource"/>, stopping the current clip when the sound changes.
/// Pass id = 0 to stop without starting a new clip.
/// C++ equivalent: CECHostPlayer::PlayMoveSound (looping variant)
/// </summary>
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();
}
}
}