chore: initial commit
This commit is contained in:
526
Docs/Architecture/11_AudioModule.md
Normal file
526
Docs/Architecture/11_AudioModule.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# 11 · 音频模块
|
||||
|
||||
> **命名空间** `BaseGames.Audio`
|
||||
> **程序集** `BaseGames.Audio`
|
||||
> **路径** `Assets/Scripts/Audio/`
|
||||
> **依赖** `BaseGames.Core.Events`、`Unity AudioMixer`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [AudioMixer 架构](#1-audiomixer-架构)
|
||||
2. [AudioManager](#2-audiomanager)
|
||||
3. [BGMController](#3-bgmcontroller)
|
||||
4. [AudioZone](#4-audiozone)
|
||||
5. [AudioEventSO(SFX 集成)](#5-audioeventsso)
|
||||
6. [GlobalSFXPlayer](#6-globalsfxplayer)
|
||||
7. [AudioConfigSO](#7-audioconfigso)
|
||||
8. [音频事件频道清单](#8-音频事件频道清单)
|
||||
|
||||
---
|
||||
|
||||
## 1. AudioMixer 架构
|
||||
|
||||
**资产路径**:`Assets/Audio/MainMixer.mixer`
|
||||
|
||||
### 混音组层级
|
||||
|
||||
```
|
||||
Master
|
||||
├── BGM (背景音乐)
|
||||
├── SFX (所有音效)
|
||||
│ ├── SFX_Player
|
||||
│ ├── SFX_Enemy
|
||||
│ └── SFX_World
|
||||
└── Ambient (环境音)
|
||||
```
|
||||
|
||||
### Exposed Parameters(代码用字符串常量)
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioMixerKeys.cs
|
||||
public static class AudioMixerKeys
|
||||
{
|
||||
public const string Master = "MasterVolume";
|
||||
public const string BGM = "BGMVolume";
|
||||
public const string SFX = "SFXVolume";
|
||||
public const string Ambient = "AmbientVolume";
|
||||
}
|
||||
```
|
||||
|
||||
所有参数范围:`-80 ~ 0 dB`
|
||||
|
||||
### AudioMixer 快照
|
||||
|
||||
| 快照名 | 触发条件 | 主要差异 |
|
||||
|--------|---------|---------|
|
||||
| `Default` | 正常 Gameplay | 全组正常 |
|
||||
| `Paused` | `GameState.Paused` | BGM/SFX -12 dB + 低通滤波 |
|
||||
| `Dead` | 玩家死亡 | BGM 渐出 -80 dB(1.5s)|
|
||||
| `BossFight` | Boss 战开始 | Ambient -20 dB |
|
||||
|
||||
切换方式:
|
||||
```csharp
|
||||
_mixer.TransitionToSnapshots(new[] { snapshot }, new[] { 1f }, transitionTime);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. AudioManager
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioManager.cs
|
||||
[DefaultExecutionOrder(-500)]
|
||||
public class AudioManager : MonoBehaviour
|
||||
{
|
||||
[Header("AudioMixer")]
|
||||
[SerializeField] private AudioMixer _mixer;
|
||||
|
||||
[Header("BGM Sources(双 Source 交叉淡入淡出)")]
|
||||
[SerializeField] private AudioSource _bgmSourceA;
|
||||
[SerializeField] private AudioSource _bgmSourceB;
|
||||
|
||||
[Header("SFX Pool(4~8 源轮转,防高密度战斗音效戳断)")]
|
||||
[SerializeField] private AudioSource[] _sfxSources; // Inspector 预挂,建议 6 个,均路由到 SFX MixerGroup
|
||||
private int _sfxRoundRobin; // 轮转指针(无锁)
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ bossId: string(与 02_EventSystem §4 / §3 BGMController 一致)
|
||||
|
||||
// ── Singleton(已废弃:新代码请使用 ServiceLocator.Get<IAudioService>())─────────────
|
||||
/// <summary>
|
||||
/// 已废弃。请改用 ServiceLocator.Get<IAudioService>() 访问音频服务。
|
||||
/// 保留此属性仅为历史层兴范过渡期兼容,将在下一个大版本移除。
|
||||
/// </summary>
|
||||
[System.Obsolete("Use ServiceLocator.Get<IAudioService>() instead. AudioManager.Instance will be removed in a future version.")]
|
||||
public static AudioManager Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
_activeBGMSource = _bgmSourceA;
|
||||
_inactiveBGMSource = _bgmSourceB;
|
||||
}
|
||||
|
||||
private AudioSource _activeBGMSource;
|
||||
private AudioSource _inactiveBGMSource;
|
||||
private Coroutine _crossfadeCoroutine;
|
||||
|
||||
// ── 音量(统一入口;SettingsManager / 设置面板调用)────────────
|
||||
public void Initialize(); // 读取 GlobalSettings 并应用所有音量
|
||||
|
||||
/// <summary>
|
||||
/// 将指定混音器参数设置为 0-1 线性值(内部转换为 dB)。
|
||||
/// 唯一音量写入入口——所有调用方均使用此方法。
|
||||
/// </summary>
|
||||
/// <param name="exposedParam">AudioMixerKeys.* 常量(Master / BGM / SFX / Ambient)</param>
|
||||
public void SetVolume(string exposedParam, float linear)
|
||||
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
|
||||
|
||||
// BGM 播放(带两段淡变时长)
|
||||
public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f);
|
||||
public void StopBGM(float fadeDuration = 1f);
|
||||
|
||||
// SFX 一次性播放(轮转多源,避免高密度战斗时音效相互戳断)
|
||||
public void PlaySFX(AudioClip clip, float volumeScale = 1f)
|
||||
{
|
||||
var src = NextSFXSource();
|
||||
src.volume = volumeScale;
|
||||
src.PlayOneShot(clip); // PlayOneShot 不打断当前正在播放的其他音效
|
||||
}
|
||||
|
||||
// 2D 游戏中位置参数不做 3D 衰减,统一个多源池路由
|
||||
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f)
|
||||
=> PlaySFX(clip, volumeScale);
|
||||
|
||||
// 轮转返回下一个 AudioSource;PlayOneShot 下无需检查 isPlaying
|
||||
private AudioSource NextSFXSource()
|
||||
=> _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
|
||||
|
||||
// 快照切换
|
||||
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f);
|
||||
|
||||
private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur);
|
||||
private static float LinearToDecibel(float linear)
|
||||
=> linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. BGMController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/BGMController.cs
|
||||
// 订阅世界 / Boss 事件,指挥 AudioManager 切换 BGM
|
||||
public class BGMController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private AudioManager _audioManager;
|
||||
[SerializeField] private AudioConfigSO _config;
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // true=开始, false=结束
|
||||
[SerializeField] private StringEventChannelSO _onRegionEntered; // region id
|
||||
|
||||
private MusicState _musicState = MusicState.Exploration;
|
||||
|
||||
private string _currentRegion = "Forest";
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
|
||||
_onRegionEntered.OnEventRaised += OnRegionEntered;
|
||||
_onGameStateChanged.OnEventRaised += HandleStateChanged;
|
||||
}
|
||||
private void OnDisable() { /* -= */ }
|
||||
|
||||
private void OnBossFightToggled(bool started)
|
||||
{
|
||||
if (started)
|
||||
{
|
||||
_musicState = MusicState.Boss;
|
||||
var clip = _config.GetBossBGM(_currentRegion);
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
|
||||
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(PlayVictoryThenRestore());
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PlayVictoryThenRestore()
|
||||
{
|
||||
_musicState = MusicState.Victory;
|
||||
_audioManager.PlayBGM(_config.VictoryStingBGM, fadeOutDur: 0.3f, fadeInDur: 0.1f);
|
||||
yield return new WaitForSecondsRealtime(_config.VictoryStingDuration);
|
||||
_musicState = MusicState.Exploration;
|
||||
OnRegionEntered(_currentRegion);
|
||||
_audioManager.TransitionToSnapshot("Default", 1.0f);
|
||||
}
|
||||
|
||||
private void OnRegionEntered(string regionId)
|
||||
{
|
||||
if (regionId == _currentRegion) return;
|
||||
_currentRegion = regionId;
|
||||
if (_musicState == MusicState.Exploration)
|
||||
{
|
||||
var clip = _config.GetZoneBGM(regionId);
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStateChanged(GameStateId state)
|
||||
{
|
||||
// ⚠️ GameStateId 是 struct,不能用 switch;使用 if/else + GameStates 常量(架构 03_CoreModule §2)
|
||||
if (state == GameStates.MainMenu)
|
||||
_audioManager.PlayBGM(_config.MainMenuBGM, fadeOutDur: 0.5f, fadeInDur: 1.0f);
|
||||
else if (state == GameStates.Paused)
|
||||
_audioManager.TransitionToSnapshot("Paused", 0.2f);
|
||||
else if (state == GameStates.Dead)
|
||||
_audioManager.TransitionToSnapshot("Dead", 1.5f);
|
||||
else if (state == GameStates.Gameplay)
|
||||
_audioManager.TransitionToSnapshot("Default", 0.3f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3.5 MusicState 音乐状态机
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/BGMController.cs
|
||||
// 由 BGMController 内部维护,控制 BGM 切换逻辑
|
||||
public enum MusicState
|
||||
{
|
||||
Exploration, // 默认:区域探索 BGM
|
||||
Boss, // Boss 战:Boss 主题 BGM
|
||||
Victory, // Boss 击败后短暂胜利音乐
|
||||
None, // 过场/死亡/主菜单时由 BGMController 直接切换
|
||||
}
|
||||
```
|
||||
|
||||
**状态转换**:
|
||||
|
||||
```
|
||||
Exploration ──[开始 Boss 战]─────────────► Boss
|
||||
◄─[结束 Boss 战]──────────────
|
||||
Boss ──[Boss 击败]────────────────► Victory
|
||||
Victory ──[VictorySting 播放完毕]─────► Exploration
|
||||
Exploration ──[OnRegionEntered]─────────► Exploration(切换同状态内不同曲目)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. AudioZone
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioZone.cs
|
||||
// 触发器:进入区域时切换 BGM
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class AudioZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _zoneId; // 与 AudioConfigSO 中的 key 对应
|
||||
|
||||
[Header("Event Channel")]
|
||||
[SerializeField] private StringEventChannelSO _onRegionEntered;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (other.CompareTag("Player"))
|
||||
_onRegionEntered.Raise(_zoneId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AudioEventSO
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioEventSO.cs
|
||||
// 可在 Inspector 配置的 SFX 数据:支持随机音量/音调、随机片段
|
||||
[CreateAssetMenu(menuName = "Audio/AudioEvent")]
|
||||
public class AudioEventSO : ScriptableObject
|
||||
{
|
||||
public AudioClip[] Clips; // 随机挑选一个播放
|
||||
[Range(0f, 1f)]
|
||||
public float VolumeMin = 0.9f;
|
||||
[Range(0f, 1f)]
|
||||
public float VolumeMax = 1.0f;
|
||||
[Range(0.5f, 2f)]
|
||||
public float PitchMin = 0.95f;
|
||||
[Range(0.5f, 2f)]
|
||||
public float PitchMax = 1.05f;
|
||||
public AudioMixerGroup MixerGroup; // 指定路由到哪个子混音组(如 SFX_Player)
|
||||
|
||||
public void Play(AudioSource source)
|
||||
{
|
||||
if (Clips == null || Clips.Length == 0) return;
|
||||
source.outputAudioMixerGroup = MixerGroup;
|
||||
source.clip = Clips[Random.Range(0, Clips.Length)];
|
||||
source.volume = Random.Range(VolumeMin, VolumeMax);
|
||||
source.pitch = Random.Range(PitchMin, PitchMax);
|
||||
source.Play();
|
||||
}
|
||||
|
||||
public void PlayOneShot(AudioSource source)
|
||||
{
|
||||
if (Clips == null || Clips.Length == 0) return;
|
||||
var clip = Clips[Random.Range(0, Clips.Length)];
|
||||
source.outputAudioMixerGroup = MixerGroup;
|
||||
source.PlayOneShot(clip, Random.Range(VolumeMin, VolumeMax));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/Audio/`
|
||||
**命名规范**:`AUD_{Category}_{Name}.asset`(例 `AUD_Player_SwordSlash.asset`)
|
||||
|
||||
---
|
||||
|
||||
## 6. GlobalSFXPlayer
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/GlobalSFXPlayer.cs
|
||||
// 提供静态方法入口,配合 AudioEventSO 在任何地方播放 SFX
|
||||
// Feel(MMF_Player)的 MMSoundManagerSoundSO 也通过此路由
|
||||
public class GlobalSFXPlayer : MonoBehaviour
|
||||
{
|
||||
private static GlobalSFXPlayer _instance;
|
||||
|
||||
[SerializeField] private AudioMixerGroup _sfxGroup;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null) { Destroy(gameObject); return; }
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
// 路由到 AudioManager.PlaySFX(多源轮转池,见 §2)
|
||||
// AudioEventSO.GetClip() 返回对应音频片段(含随机变体支持)
|
||||
public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
|
||||
{
|
||||
// 2D 游戏不需要 3D 空间音效衰减;统一委托 AudioManager 多源池播放
|
||||
var clip = audioEvent.GetClip();
|
||||
if (clip != null)
|
||||
AudioManager.Instance.PlaySFX(clip);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. AudioConfigSO
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioConfigSO.cs
|
||||
[CreateAssetMenu(menuName = "Audio/AudioConfig")]
|
||||
public class AudioConfigSO : ScriptableObject
|
||||
{
|
||||
[System.Serializable]
|
||||
public struct ZoneBGMEntry
|
||||
{
|
||||
public string ZoneId;
|
||||
public AudioClip BGMClip;
|
||||
public float FadeDuration;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public struct BossBGMEntry
|
||||
{
|
||||
public string BossId;
|
||||
public AudioClip BGMClip;
|
||||
}
|
||||
|
||||
public ZoneBGMEntry[] ZoneBGMs;
|
||||
public BossBGMEntry[] BossBGMs;
|
||||
public AudioClip MainMenuBGM;
|
||||
public AudioClip GameOverSting; // 死亡时短音乐片段
|
||||
public AudioClip VictoryStingBGM; // Boss 击败后胜利音乐片段
|
||||
public float VictoryStingDuration = 4f; // 胜利音乐播放时长(秒)
|
||||
|
||||
public AudioClip GetZoneBGM(string zoneId)
|
||||
{
|
||||
foreach (var e in ZoneBGMs)
|
||||
if (e.ZoneId == zoneId) return e.BGMClip;
|
||||
return null;
|
||||
}
|
||||
|
||||
public AudioClip GetBossBGM(string bossId)
|
||||
{
|
||||
foreach (var e in BossBGMs)
|
||||
if (e.BossId == bossId) return e.BGMClip;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/Audio/AUD_Config.asset`
|
||||
|
||||
---
|
||||
|
||||
## 8. 音频事件频道清单
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_RegionEntered` | `StringEventChannelSO` | `AudioZone` | `BGMController` |
|
||||
| `EVT_BossFightStarted` | `StringEventChannelSO` | `BossOrchestrator` | `BGMController`、`BossHPBar` |
|
||||
| `EVT_BossFightEnded` | `BoolEventChannelSO` | `BossBase` | `BGMController` |
|
||||
| `EVT_GameStateChanged` | `GameStateEventChannelSO` | `GameManager` | `BGMController`(暂停/恢复)、`AudioManager`(快照) |
|
||||
| `EVT_PlayerDied` | `VoidEventChannelSO` | `PlayerStats` | `AudioManager`(播放 GameOverSting、切 Dead 快照) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 脚步声材质分层(Footstep Material System)
|
||||
|
||||
> **Design 来源**:[12_AudioSystem](../Design/12_AudioSystem.md) §14
|
||||
|
||||
脚步声根据脚下地面材质动态切换,不使用单一 SFX 以增强环境真实感。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/FootstepMaterial.cs
|
||||
public enum FootstepMaterial
|
||||
{
|
||||
Stone, // 石板地(默认)
|
||||
Dirt, // 泥土/草地
|
||||
Wood, // 木板
|
||||
Metal, // 金属格栅
|
||||
Water, // 浅水区(溅水声)
|
||||
Sand, // 沙地
|
||||
Grass, // 草丛
|
||||
Cave, // 洞穴(回响加强)
|
||||
}
|
||||
|
||||
// 路径: Assets/Scripts/Audio/FootstepAudioConfigSO.cs
|
||||
[CreateAssetMenu(menuName = "BaseGames/Audio/FootstepAudioConfig")]
|
||||
public class FootstepAudioConfigSO : ScriptableObject
|
||||
{
|
||||
[System.Serializable]
|
||||
public struct MaterialEntry
|
||||
{
|
||||
public FootstepMaterial material;
|
||||
public AudioClip[] clips; // 随机选一个,防止重复感
|
||||
[Range(0f, 1f)] public float volume;
|
||||
[Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围
|
||||
}
|
||||
|
||||
public MaterialEntry[] entries;
|
||||
|
||||
public MaterialEntry? GetEntry(FootstepMaterial mat)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
if (e.material == mat) return e;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 路径: Assets/Scripts/Audio/FootstepMaterialMarker.cs
|
||||
// 挂载到地面碰撞体所在 GameObject(Tilemap 图层 or 单体地形 Prefab)
|
||||
public class FootstepMaterialMarker : MonoBehaviour
|
||||
{
|
||||
public FootstepMaterial material;
|
||||
}
|
||||
```
|
||||
|
||||
**播放时机**:
|
||||
- **落地**:`PlayerController.OnLanded()` 触发(同 MaterialEntry,音量 ×1.5)
|
||||
- **行走**:Animancer 动画事件 `FootstepL` / `FootstepR`(见 `24_AnimEventModule`)触发
|
||||
- **冲刺起步**:Dash 动画第 2 帧触发专属 `DashSFX`(不走 Footstep 通道)
|
||||
|
||||
玩家若脚下 GameObject 无 `FootstepMaterialMarker`,默认使用 `Stone`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 水下音效处理(Underwater Audio)
|
||||
|
||||
> **Design 来源**:[12_AudioSystem](../Design/12_AudioSystem.md) §15
|
||||
|
||||
进入 `LiquidZone`(见 `21_LiquidPuzzleModule`)时,全局音效自动应用水下 DSP 处理。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/UnderwaterAudioController.cs
|
||||
// 挂载于 PlayerController 所在 GameObject;LiquidZone 调用 EnterWater/ExitWater
|
||||
public class UnderwaterAudioController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] AudioMixer _mixer;
|
||||
[SerializeField] float _transitionDuration = 0.3f;
|
||||
|
||||
/// <summary>LiquidZone.OnTriggerEnter2D 时调用</summary>
|
||||
public void EnterWater()
|
||||
{
|
||||
_mixer.FindSnapshot("Underwater")
|
||||
.TransitionTo(_transitionDuration);
|
||||
}
|
||||
|
||||
/// <summary>LiquidZone.OnTriggerExit2D 时调用</summary>
|
||||
public void ExitWater()
|
||||
{
|
||||
_mixer.FindSnapshot("Default")
|
||||
.TransitionTo(_transitionDuration);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Underwater Snapshot DSP 配置**(AudioMixer 中预设):
|
||||
|
||||
| Bus | 处理 |
|
||||
|-----|------|
|
||||
| BGM | Low-Pass 800 Hz(水下声音沉闷)|
|
||||
| SFX | Low-Pass 1200 Hz + Volume ×0.7 |
|
||||
| Ambient | Volume ×0,替换为水下环境音(气泡声)|
|
||||
| PlayerSFX | Low-Pass 1000 Hz |
|
||||
|
||||
**水下专属 SFX 对照**:
|
||||
|
||||
| 动作 | 水上 SFX | 水下 SFX |
|
||||
|------|---------|---------|
|
||||
| 攻击 | `sfx_player_slash` | `sfx_player_slash_underwater` |
|
||||
| 浮出水面 | — | `sfx_splash_exit` |
|
||||
| 入水 | — | `sfx_splash_enter` |
|
||||
| 游泳移动 | — | `sfx_swim_loop`(循环,pitch 随速度变化)|
|
||||
Reference in New Issue
Block a user