# 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 │ │ 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("sound")` — synchronous, no Addressables overhead required. ### Format Each non-blank line is a tab-separated pair: ``` \t"" ``` 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("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`). --- ## 4. Playing a Sound by ID ```mermaid flowchart TD A[SFXManager.Initialize] --> B["Resources.Load('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.