Files
2026-04-15 16:00:31 +07:00

328 lines
11 KiB
Markdown

# 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.