229 lines
9.0 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|