328 lines
11 KiB
Markdown
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.
|