Merge pull request 'fix-bug/character-sound' (#344) from fix-bug/character-sound into develop

Reviewed-on: https://git.pthub.vn/Unity/perfect-world-unity/pulls/344
This commit is contained in:
hoangvd
2026-04-15 09:01:42 +00:00
11 changed files with 754 additions and 1150 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b013fb5e1a03c91240682019bd88c08a2e17ad520bb308d2ecec16cc03643250
size 302199
oid sha256:5cfdae6351b6fac92e853e5e66c95e40da85a9a9b8b45cfdd55f16f0c4e48992
size 303064
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:905756eec7a4d6595fe0f1902981c92a6c6c517aceb389eaa279759cefa07b76
size 112496
oid sha256:7854f585bbc6d2c574b614dbb36ba2600cc3d450ad835fed441f60cfe3363642
size 112932
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.AddressableAssets.ResourceLocators;
@@ -12,6 +13,7 @@ namespace BrewMonster.Scripts
public class AddressableManager : MonoSingleton<AddressableManager>
{
private bool _isInitialized = false;
private UniTaskCompletionSource _initializationTcs;
private Dictionary<string, AsyncOperationHandle<GameObject>> _loadedPrefabAssets = new();
private Dictionary<string, AsyncOperationHandle<TextAsset>> _loadedTextAssets = new();
@@ -31,10 +33,21 @@ namespace BrewMonster.Scripts
public bool IsInitialized() => _isInitialized;
/// <summary>
/// Returns immediately if already initialized; otherwise waits for the
/// Addressables initialization callback without per-frame polling.
/// </summary>
public UniTask WaitUntilInitializedAsync()
{
if (_isInitialized) return UniTask.CompletedTask;
return _initializationTcs.Task;
}
protected override void Initialize()
{
base.Initialize();
_isInitialized = false;
_initializationTcs = new UniTaskCompletionSource();
Addressables.InitializeAsync().Completed += OnInitializeComplete;
}
@@ -68,6 +81,7 @@ namespace BrewMonster.Scripts
if (handle.Status == AsyncOperationStatus.Succeeded)
{
_isInitialized = true;
_initializationTcs.TrySetResult();
BMLogger.Log($"AddressableManager: Initialized");
}
else
@@ -26,37 +26,6 @@ namespace BrewMonster
m_pNPCMan = EC_ManMessageMono.Instance?.CECNPCMan;
}
private const float SkillSfxVolume = 1f;
private void PlaySkillSfxLazy(string address, Vector3 worldPos)
{
if (string.IsNullOrEmpty(address)) return;
var mgr = AddressableManager.Instance;
if (mgr == null) return;
if (mgr.TryGetCachedAudioClip(address, out var clip) && clip != null)
{
AudioSource.PlayClipAtPoint(clip, worldPos, SkillSfxVolume);
return;
}
PlaySkillSfxLazyAsync(address, worldPos).Forget();
}
private async UniTaskVoid PlaySkillSfxLazyAsync(string address, Vector3 worldPos)
{
var mgr = AddressableManager.Instance;
if (mgr == null) return;
await UniTask.WaitUntil(() => mgr.IsInitialized());
var loaded = await mgr.LoadAudioClipAsync(address);
if (loaded != null)
{
// use worldPos for 3D spatial sound, or Vector3.zero for non-spatial sound
AudioSource.PlayClipAtPoint(loaded, Vector3.zero, SkillSfxVolume);
//AudioSource.PlayClipAtPoint(loaded, worldPos, SkillSfxVolume);
}
}
/// <summary>
/// Get the skill GFX composer
/// 获取技能特效组合器
@@ -384,7 +353,7 @@ namespace BrewMonster
m_flyGfxInstance = GameObject.Instantiate(prefab, pos, prefab.transform.rotation);
PlaySkillSfxLazy(m_pComposer.GetFlySfxPath(), pos);
SFXManager.Instance?.PlaySkillSfxAtPointAsync(m_pComposer.GetFlySfxPath(), pos).Forget();
// If m_bTraceTarget is true, add to tracking list when spawned
// 如果m_bTraceTarget为true,在生成时添加到跟踪列表
@@ -499,7 +468,7 @@ namespace BrewMonster
if (string.IsNullOrEmpty(hitSfxPath))
hitSfxPath = m_pComposer.GetHitSfxPath();
}
PlaySkillSfxLazy(hitSfxPath, vTarget);
SFXManager.Instance?.PlaySkillSfxAtPointAsync(hitSfxPath, vTarget).Forget();
// If m_bTraceTarget is true, add to tracking list (don't auto-destroy)
// 如果m_bTraceTarget为true,添加到跟踪列表(不自动销毁)
@@ -0,0 +1,214 @@
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.
/// </summary>
public async UniTaskVoid PlaySkillSfxAtPointAsync(string address, Vector3 worldPos)
{
if (string.IsNullOrEmpty(address)) return;
var mgr = AddressableManager.Instance;
if (mgr == null) return;
if (mgr.TryGetCachedAudioClip(address, out var clip) && clip != null)
{
AudioSource.PlayClipAtPoint(clip, worldPos, 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)
{
BMLogger.LogError($"HoangDev PlayMoveSoundAsync: id={id}, source={_moveSoundSource}");
if (_moveSoundSource == null) return;
_moveSoundSource.Stop();
if (id <= 0) return;
var path = GetSFXPath(id);
BMLogger.LogError($"HoangDev PlayMoveSoundAsync: resolved path='{path}'");
if (string.IsNullOrEmpty(path)) return;
if (AddressableManager.Instance.TryGetCachedAudioClip(path, out var clip) && clip != null)
{
BMLogger.LogError($"HoangDev PlayMoveSoundAsync: found cached clip='{clip.name}'");
_moveSoundSource.clip = clip;
_moveSoundSource.loop = true;
_moveSoundSource.Play();
return;
}
await AddressableManager.Instance.WaitUntilInitializedAsync();
var loaded = await AddressableManager.Instance.LoadAudioClipAsync(path);
if (loaded != null)
{
BMLogger.LogError($"HoangDev PlayMoveSoundAsync: loaded clip='{loaded.name}'");
_moveSoundSource.clip = loaded;
_moveSoundSource.loop = true;
_moveSoundSource.Play();
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4cfa292fff0815d40b82f32256b3f2cc
+79
View File
@@ -0,0 +1,79 @@
100 "SFX\Character\FootStep\LandWalkMaleA.wav"
101 "SFX\Character\FootStep\LandWalkFemaleA.wav"
102 "SFX\Character\FootStep\LandWalkMaleA.wav"
103 "SFX\Character\FootStep\LandWalkFemaleA.wav"
104 "SFX\Character\FootStep\LandWalkMaleA.wav"
105 "SFX\Character\FootStep\LandWalkFemaleA.wav"
106 ""
107 "SFX\Character\FootStep\LandWalkFemaleA.wav"
108 "SFX\Character\FootStep\LandWalkBeastA.wav"
109 ""
110 "SFX\Character\FootStep\LandWalkMaleA.wav"
111 "SFX\Character\FootStep\LandWalkFemaleA.wav"
112 "SFX\Character\FootStep\LandWalkMaleA.wav"
113 "SFX\Character\FootStep\LandWalkFemaleA.wav"
114 "SFX\Character\FootStep\LandWalkMaleA.wav"
115 "SFX\Character\FootStep\LandWalkFemaleA.wav"
116 "SFX\Character\FootStep\LandWalkMaleA.wav"
117 "SFX\Character\FootStep\LandWalkFemaleA.wav"
118 "SFX\Character\FootStep\LandWalkMaleA.wav"
119 "SFX\Character\FootStep\LandWalkFemaleA.wav"
120 "SFX\Character\FootStep\LandWalkMaleA.wav"
121 "SFX\Character\FootStep\LandWalkFemaleA.wav"
122 "SFX\Character\FootStep\LandWalkMaleA.wav"
123 "SFX\Character\FootStep\LandWalkFemaleA.wav"
130 "SFX\Character\FootStep\LandRunMaleA.wav"
131 "SFX\Character\FootStep\LandRunFemaleA.wav"
132 "SFX\Character\FootStep\LandRunMaleA.wav"
133 "SFX\Character\FootStep\LandRunFemaleA.wav"
134 "SFX\Character\FootStep\LandRunMaleA.wav"
135 "SFX\Character\FootStep\LandRunFemaleA.wav"
136 ""
137 "SFX\Character\FootStep\LandRunFemaleA.wav"
138 "SFX\Character\FootStep\LandRunBeastA.wav"
139 ""
140 "SFX\Character\FootStep\LandRunMaleA.wav"
141 "SFX\Character\FootStep\LandRunFemaleA.wav"
142 "SFX\Character\FootStep\LandRunMaleA.wav"
143 "SFX\Character\FootStep\LandRunFemaleA.wav"
144 "SFX\Character\FootStep\LandRunMaleA.wav"
145 "SFX\Character\FootStep\LandRunFemaleA.wav"
146 "SFX\Character\FootStep\LandRunMaleA.wav"
147 "SFX\Character\FootStep\LandRunFemaleA.wav"
148 "SFX\Character\FootStep\LandRunMaleA.wav"
149 "SFX\Character\FootStep\LandRunFemaleA.wav"
150 "SFX\Character\FootStep\LandRunMaleA.wav"
151 "SFX\Character\FootStep\LandRunFemaleA.wav"
152 "SFX\Character\FootStep\LandRunMaleA.wav"
153 "SFX\Character\FootStep\LandRunFemaleA.wav"
160 "SFX\Character\FootStep\Swim.wav"
161 "SFX\Character\FootStep\Float.wav"
162 ""
163 ""
164 ""
170 "SFX\Character\FootStep\LandWalkFoxA.wav"
171 "SFX\Character\FootStep\LandRunFoxA.wav"
172 "SFX\Character\FootStep\LandWalkTigerA.wav"
173 "SFX\Character\FootStep\LandRunTigerA.wav"
200 "SFX\Character\FootStep\LandWalkPetHorse.wav"
201 "SFX\Character\FootStep\LandRunPetHorse.wav"
202 "SFX\Character\FootStep\LandWalkPetBear.wav"
203 "SFX\Character\FootStep\LandRunPetBear.wav"
204 "SFX\Character\FootStep\LandWalkPetPuma.wav"
205 "SFX\Character\FootStep\LandRunPetPuma.wav"
206 "SFX\Character\FootStep\LandWalkPetDinasaur.wav"
207 "SFX\Character\FootStep\LandRunPetDinasaur.wav"
208 "SFX\Character\FootStep\LandWalkPetQilin.wav"
209 "SFX\Character\FootStep\LandRunPetQilin.wav"
210 "SFX\Character\FootStep\LandWalkPetAuto.wav"
211 "SFX\Character\FootStep\LandRunPetAuto.wav"
212 "SFX\Character\FootStep\LandRunFrog.wav"
213 "SFX\Character\FootStep\LandRunFrog.wav"
214 "SFX\Character\FootStep\LandRunPenguin.wav"
215 "SFX\Character\FootStep\LandRunPenguin.wav"
216 "SFX\Character\FootStep\LandRunRhinoceros.wav"
217 "SFX\Character\FootStep\LandRunRhinoceros.wav"
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3a171ad8b44ac09439bd7d2914dc7653
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+95 -47
View File
@@ -173,6 +173,10 @@ namespace BrewMonster
private BaseVfxObject m_pSelectedGFX;
private BaseVfxObject m_pHoverGFX;
// ── Movement sound (C++ equivalent: m_pCurMoveSnd) ──────────────────
private int _curMoveSndId = -1; // currently playing move-sound ID (-1 = none)
private int _moveSndUpdateCounter; // 3-tick throttle counter
// Cursor estimation optimization
private Vector2 m_lastMousePosition;
private float m_cursorUpdateTimer;
@@ -419,6 +423,9 @@ namespace BrewMonster
// Update GFXs
UpdateGFXs(Time.deltaTime);
// Update movement sound
UpdateMoveSound();
//m_dwMoveRelDir = 0;
m_fVertSpeed = 0.0f;
// Auto team / Automatic party grouping
@@ -428,52 +435,6 @@ namespace BrewMonster
// UpdatePosWing();
}
//public void HandleMovement()
//{
// // 1) Kiểm tra grounded bằng SphereCast ngắn dựa trên radius + skinWidth
// isGrounded = GroundCheck(out lastGroundHit);
// m_GndInfo.bOnGround = isGrounded;
// // 2) Input tạm thời: giữ nguyên như bạn
// //if (UnityEngine.Input.GetKeyDown(KeyCode.LeftShift)) SetStatusRun(true);
// //if (UnityEngine.Input.GetKeyUp(KeyCode.LeftShift)) SetStatusRun(false);
// //if (UnityEngine.Input.GetKeyDown(KeyCode.Space)) HandleJump();
// // 3) Trọng lực / sticky
// if (isGrounded && playerVelocity.y < 0f)
// {
// // Đè nhẹ để bám đất (tránh nhấp-nháy)
// playerVelocity.y = -2f;
// }
// else
// {
// playerVelocity.y += gravityValue * Time.deltaTime;
// }
// // 4) Chuyển động phẳng
// float x = joystick.Horizontal;
// float z = joystick.Vertical;
// Vector3 move = new Vector3(x, 0, z);
// move = Vector3.ClampMagnitude(move, 1f);
// if (move != Vector3.zero)
// {
// Vector3 finalMove = (move * playerSpeed) + (playerVelocity.y * Vector3.up);
// controller.Move(finalMove * Time.deltaTime);
// transform.forward = move;
// m_MoveCtrl.GroundMove(Time.deltaTime);
// m_MoveCtrl.SendMoveCmd(playerTransform.position, controller.velocity, (int)GPMoveMode.GP_MOVE_RUN);
// m_aabb.Center = EC_Utility.ToA3DVECTOR3(playerTransform.position) +
// new A3DVECTOR3(0.0f, m_aabb.Extents.y, 0.0f);
// m_aabb.CompleteMinsMaxs();
// m_aabbServer.Center = EC_Utility.ToA3DVECTOR3(playerTransform.position) +
// new A3DVECTOR3(0.0f, m_aabbServer.Extents.y, 0.0f);
// m_aabbServer.CompleteMinsMaxs();
// }
// else
// {
// }
//}
private void JoystickRelease(JoystickRealeaseEvent joystickRealeaseEvent)
{
// _playerStateMachine.ChangeState(_idleState);
@@ -4179,7 +4140,94 @@ namespace BrewMonster
//
// CECPlayer::Release();
}
public int GetCurServiceNPC() { return m_idSevNPC; }
public int GetCurServiceNPC() { return m_idSevNPC; }
// ────────────────────────────────────────────────────────────────────
// Move sound — C++ equivalent: CECHostPlayer::UpdateMoveSound / PlayMoveSound
// ────────────────────────────────────────────────────────────────────
/// <summary>
/// Selects and plays the correct looping movement SFX based on environment,
/// shape, and movement state. Throttled to run once every 3 frames, matching
/// the C++ dwUpdateCnt % 3 guard.
/// </summary>
private void UpdateMoveSound()
{
_moveSndUpdateCounter++;
if (_moveSndUpdateCounter % 3 != 0) return;
if (m_pWorkMan == null) return;
int newId;
if (m_pWorkMan.IsMoving())
{
if (m_iMoveEnv == (int)MoveEnvironment.MOVEENV_GROUND)
{
if (IsJumping())
{
newId = 162;
}
else if (!m_GndInfo.bOnGround)
{
newId = 163;
}
else
{
int iIndex = m_iProfession * GENDER.NUM_GENDER + m_iGender;
int iWalkRunOffset = m_bWalkRun ? 1 : 0;
if (IsShapeChanged() && (GetShapeID() == (int)ModelResourceType.RES_MOD_ORC_FOX
|| GetShapeID() == (int)ModelResourceType.RES_MOD_ORC_FOX2))
newId = 170 + iWalkRunOffset;
else if (IsShapeChanged() && GetShapeID() == (int)ModelResourceType.RES_MOD_ORC_TIGER)
newId = 172 + iWalkRunOffset;
else if (m_RidingPet.id != 0)
newId = 200 + GetRidingPetSndType(m_RidingPet.id) * 2 + iWalkRunOffset;
else
newId = (m_bWalkRun ? 130 : 100) + iIndex;
}
}
else if (m_iMoveEnv == (int)MoveEnvironment.MOVEENV_WATER)
{
float eyeY = GetPosVector3().y + 1.7f; // approximate eye height
newId = eyeY > m_GndInfo.fWaterHei ? 160 : 161;
}
else // MOVEENV_AIR
{
newId = 164;
}
}
else
{
newId = 164;
}
PlayMoveSound(newId);
}
/// <summary>
/// Switches the looping move sound to <paramref name="id"/>, no-op if already playing.
/// C++ equivalent: CECHostPlayer::PlayMoveSound
/// </summary>
private void PlayMoveSound(int id)
{
BMLogger.LogError($"HoangDev PlayMoveSound called with id: {id}"); // Debug log to trace sound ID changes
if (id == _curMoveSndId) return;
_curMoveSndId = id;
SFXManager.Instance?.PlayMoveSoundAsync(id).Forget();
}
/// <summary>
/// Returns the pet_snd_type for a riding pet, used to select the mount SFX series
/// (200 + type*2 + walkRunOffset).
/// TODO: look up PET_ESSENCE.pet_snd_type via ElementDataManager when available.
/// C++ equivalent: pData->pet_snd_type from PET_ESSENCE
/// </summary>
private static int GetRidingPetSndType(int petId)
{
return 0; // default type 0 = horse (walk 200, run 201)
}
}
public sealed class CECHPTraceSpellMatcher : CECHPWorkMatcher
{
File diff suppressed because one or more lines are too long
+327
View File
@@ -0,0 +1,327 @@
# SFX Manager — Unity C# Implementation
This document covers the Unity C# port of the Perfect World SFX ID-to-file lookup system,
implemented in [`Assets/PerfectWorld/Scripts/Sound/SFXManager.cs`](../Assets/PerfectWorld/Scripts/Sound/SFXManager.cs).
For the full C++ source analysis this is based on, see
[`perfect-world-source/docs/SFX_FLOW.md`](../../perfect-world-source/perfect-world-source/docs/SFX_FLOW.md).
---
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [Data Source: sound.txt](#2-data-source-soundtxt)
3. [Startup Flow](#3-startup-flow)
4. [Playing a Sound by ID](#4-playing-a-sound-by-id)
5. [Sound ID Reference Table](#5-sound-id-reference-table)
6. [Path Normalization](#6-path-normalization)
7. [Usage Examples](#7-usage-examples)
8. [C++ → C# Mapping](#8-c--c-mapping)
9. [Extension Points](#9-extension-points)
---
## 1. Architecture Overview
```
┌──────────────────────────────────────────────────────────┐
│ CALLER CODE │
│ (movement controller, skill handler, animation event) │
│ │
│ SFXManager.Instance.PlaySFXByIdAsync(id, audioSource) │
└────────────────────────┬─────────────────────────────────┘
│ int id
┌────────────────────────▼─────────────────────────────────┐
│ SFXManager │
│ │
│ _soundTable: Dictionary<int, string> │
│ key = numeric SFX ID (from sound.txt) │
│ value = normalized Addressable path │
│ │
│ GetSFXPath(id) ──► string path │
└────────────────────────┬─────────────────────────────────┘
│ string path (Addressable address)
┌────────────────────────▼─────────────────────────────────┐
│ AddressableManager │
│ │
│ TryGetCachedAudioClip(path) ──► cache hit │
│ LoadAudioClipAsync(path) ──► cache miss → load │
└────────────────────────┬─────────────────────────────────┘
│ AudioClip
AudioSource.PlayOneShot(clip)
```
---
## 2. Data Source: sound.txt
**Location:** [`Assets/Resources/sound.txt`](../Assets/Resources/sound.txt)
Loaded at startup via `Resources.Load<TextAsset>("sound")` — synchronous, no Addressables
overhead required.
### Format
Each non-blank line is a tab-separated pair:
```
<id>\t"<Windows path>"
```
Examples:
```
100 "SFX\Character\FootStep\LandWalkMaleA.wav"
160 "SFX\Character\FootStep\Swim.wav"
162 ""
164 ""
```
- **Empty path** (`""`) means silence is intended for that ID. These entries are skipped
during parsing and `GetSFXPath` returns `null` for them.
- **Blank lines** between ID groups are ignored.
---
## 3. Startup Flow
```
SFXManager.Awake()
└─ Initialize()
└─ LoadSoundTable()
├─ Resources.Load<TextAsset>("sound")
├─ Split by newline
└─ For each line:
├─ Split on first tab → idStr, rawPath
├─ int.TryParse(idStr) → id
├─ Strip surrounding quotes from rawPath
├─ Skip if empty (intentional silence IDs)
└─ _soundTable[id] = NormalizePath(rawPath)
```
On success, `BMLogger` prints the count of loaded entries:
```
SFXManager: Loaded 52 SFX entries from sound.txt
```
**C++ equivalent:** `CECGameRun::ImportSoundStringTable("Configs\\sound.txt")`
→ stores in `m_SoundStringTable` (`map<int, AString>`).
---
## 4. Playing a Sound by ID
```mermaid
flowchart TD
A[SFXManager.Initialize] --> B["Resources.Load<TextAsset>('sound')"]
B --> C[Parse each line into Dictionary int,string]
C --> D[_soundTable ready]
E[Caller: PlaySFXById(id, source)] --> F[_soundTable lookup by id]
F -->|"not found / empty"| G[return - no sound]
F -->|found path| H[TryGetCachedAudioClip]
H -->|cache hit| I[source.PlayOneShot(clip)]
H -->|cache miss| J["AddressableManager.LoadAudioClipAsync(path)"]
J --> I
```
Call chain detail:
```
PlaySFXByIdAsync(id, source)
├─ GetSFXPath(id)
│ ├─ not found / empty ──► return (no sound)
│ └─ found path
├─ AddressableManager.TryGetCachedAudioClip(path)
│ ├─ hit ──► source.PlayOneShot(clip) [sync, no await]
│ └─ miss
│ └─ await LoadAudioClipAsync(path).ToUniTask()
│ └─ source.PlayOneShot(clip)
```
Two variants are provided:
| Method | Return | When to use |
|--------|--------|-------------|
| `PlaySFXByIdAsync(id, source)` | `UniTaskVoid` | Fire-and-forget (movement, most SFX) |
| `PlaySFXByIdAsyncAwaitable(id, source)` | `UniTask` | When you need to `await` dispatch completion |
**C++ equivalent:** `CECHostPlayer::PlayMoveSound(id)`
`CECGameRun::GetOrLoadSoundFromTable(id)``AMSoundBuffer::Play(loop)`.
---
## 5. Sound ID Reference Table
Mirrors the C++ `UpdateMoveSound` ID selection logic:
| ID | Condition | Sound |
|----|-----------|-------|
| `100 + prof*2 + gender` | Ground, walking | Walk footsteps |
| `130 + prof*2 + gender` | Ground, running | Run footsteps |
| `160` | Water, above surface | Swimming |
| `161` | Water, submerged | Floating underwater |
| `162` | Ground, jumping | Jump (empty in current data) |
| `163` | Ground, falling | Fall (empty in current data) |
| `164` | Air / not moving | Silence |
| `170` | Fox shape, walking | Fox walk |
| `171` | Fox shape, running | Fox run |
| `172` | Tiger shape, walking | Tiger walk |
| `173` | Tiger shape, running | Tiger run |
| `200 + type*2 + 0` | Riding pet, walking | Mount walk |
| `200 + type*2 + 1` | Riding pet, running | Mount run |
Pet mount type offsets (200-series):
| type | Walk ID | Run ID | Mount |
|------|---------|--------|-------|
| 0 | 200 | 201 | Horse |
| 1 | 202 | 203 | Bear |
| 2 | 204 | 205 | Puma |
| 3 | 206 | 207 | Dinosaur |
| 4 | 208 | 209 | Qilin |
| 5 | 210 | 211 | Auto |
| 6 | 212 | 213 | Frog |
| 7 | 214 | 215 | Penguin |
| 8 | 216 | 217 | Rhinoceros |
---
## 6. Path Normalization
`NormalizePath` converts the Windows-style path from `sound.txt` into a Unity
Addressables address:
```
"SFX\Character\FootStep\LandWalkMaleA.wav"
↓ strip quotes
SFX\Character\FootStep\LandWalkMaleA.wav
↓ replace \ with /
SFX/Character/FootStep/LandWalkMaleA.wav
↓ ToLowerInvariant()
sfx/character/footstep/landwalkmalea.wav
↓ strip extension
sfx/character/footstep/landwalkmalea
↓ strip "sfx/" prefix
character/footstep/landwalkmalea
```
> **Important:** Your Addressables catalog addresses must match this format exactly.
> If your catalog registers assets with a different case, prefix, or extension, adjust
> `NormalizePath()` in `SFXManager.cs` accordingly.
---
## 7. Usage Examples
### Movement sound (2D, looping)
```csharp
// In your movement controller, called every frame (throttle as needed)
[SerializeField] private AudioSource _footstepSource; // 2D AudioSource on the player
private async void UpdateMoveSound(int profession, int gender, bool isRunning)
{
int id = isRunning
? 130 + profession * 2 + gender
: 100 + profession * 2 + gender;
SFXManager.Instance.PlaySFXByIdAsync(id, _footstepSource).Forget();
}
```
### Water / air sounds
```csharp
private void PlayEnvironmentSound(MoveEnv env, bool aboveSurface)
{
int id = env switch
{
MoveEnv.Water => aboveSurface ? 160 : 161,
MoveEnv.Air => 164, // silence
_ => 164
};
SFXManager.Instance.PlaySFXByIdAsync(id, _footstepSource).Forget();
}
```
### Mount riding
```csharp
private void PlayMountSound(int petSoundType, bool isRunning)
{
int id = 200 + petSoundType * 2 + (isRunning ? 1 : 0);
SFXManager.Instance.PlaySFXByIdAsync(id, _mountSource).Forget();
}
```
### Checking if a path exists before loading
```csharp
string path = SFXManager.Instance.GetSFXPath(id);
if (path != null)
{
// path is a valid Addressables address, proceed
}
```
---
## 8. C++ → C# Mapping
| C++ | C# |
|-----|----|
| `CECGameRun::m_SoundStringTable` | `SFXManager._soundTable` |
| `ImportSoundStringTable()` | `SFXManager.LoadSoundTable()` |
| `GetSoundStringFromTable(id)` | `SFXManager.GetSFXPath(id)` |
| `GetOrLoadSoundFromTable(id)` | `SFXManager.PlaySFXByIdAsync(id, source)` |
| `AMSoundBufferMan::Load2DSound()` | `AddressableManager.LoadAudioClipAsync()` |
| `AMSoundBuffer::Play(loop)` | `AudioSource.PlayOneShot(clip)` |
| `CECSoundCache` quota system | Not implemented — Addressables handles memory |
| `AMSoundBufferMan` LRU cache | `AddressableManager._loadedAudioAssets` dict |
---
## 9. Extension Points
### Adding 3D positional SFX (Path B equivalent)
The `PlaySFXByIdAsync` signature accepts any `AudioSource`. For 3D sounds, attach an
`AudioSource` to the world-space object (character bone, ornament, etc.) and pass it:
```csharp
// 3D source positioned on the character bone
await sfxManager.PlaySFXByIdAsyncAwaitable(sfxId, boneAudioSource);
```
### Looping movement sounds
The current implementation uses `PlayOneShot` (one-shot). For a looping movement sound
(matching `AMSoundBuffer::Play(bLoop=true)`), set `AudioSource.loop = true` and assign
`AudioSource.clip` directly instead of using `PlayOneShot`:
```csharp
var clip = await AddressableManager.Instance.LoadAudioClipAsync(path).ToUniTask();
if (clip != null)
{
source.clip = clip;
source.loop = true;
source.Play();
}
```
### Releasing audio from cache
When a scene unloads or a character is destroyed, release SFX clips via:
```csharp
AddressableManager.Instance.ReleaseAsset(sfxPath);
```
The `AddressableManager` deferred release system (`_releaseAssetTimeout`) handles the
timed cleanup automatically.