Files
test/Documentation/SFX_MANAGER.md
T
2026-04-15 16:00:31 +07:00

11 KiB

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.

For the full C++ source analysis this is based on, see perfect-world-source/docs/SFX_FLOW.md.


Table of Contents

  1. Architecture Overview
  2. Data Source: sound.txt
  3. Startup Flow
  4. Playing a Sound by ID
  5. Sound ID Reference Table
  6. Path Normalization
  7. Usage Examples
  8. C++ → C# Mapping
  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

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

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)

// 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

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

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

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:

// 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:

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:

AddressableManager.Instance.ReleaseAsset(sfxPath);

The AddressableManager deferred release system (_releaseAssetTimeout) handles the timed cleanup automatically.