Unity中的声音播放工具
Unity中提供了基础的音频播放以及处理工具,其最重要的组件便是AudioSource和AudioListener两个,通常而言Camera上会自动附加一个AudioListener,如果新建了Camera却忘记删除AudioListener组件的话就会得到一条有些烦人的警告,告诉开发者场景中仅允许存在一个AudioListener。
从名称含义上看,AudioListener是用于“监听”场景中的音频信号的,而AudioSource自然就是“播放”音频信号的组件,Unity将所有可识别且使用的音频资源统一认定为AudioClip资源类型,AudioSource便可以和某个AudioClip资源绑定在一起,游戏过程中播放音频内容。
如果以最简单粗暴的方式来思考游戏中的音频管理这个话题,那么解决方案或许是在场景内预先添加好各种声音播放器,也就是挂载了AudioSource组件的物体,将它们与AudioClip绑定后再通过脚本代码激活播放。
但这种方案在音频资源较多且播放逻辑复杂的需求场景下几乎无法使用,各种动态音频播放需求会导致控制代码一再膨胀甚至完全无法处理,而面对这种困境就必须另寻出路。
配置文件与ScriptableObject
从音频资源的角度出发,一个游戏在完成开发之后,其中的音频资源大多数情况下是不会改变的,即便是通过MOD添加新的音频,也不应该影响到其原有的音频资源。
对于这种具有相当稳定性的资源,一种通用的管理方案就是本地配置文件,它能在真实的资源和代码之间提供一个映射层,将资源的调用过程抽象出来编入统一的处理方法中,极大地改善混乱的局面。
以音频配置为例,一个典型而简单的配置文件可以是字符串标识符映射资源的加载路径,也可以是整型的唯一标识映射完整的资源描述数据结构,根据具体场景选择方案即可。
// 字符串映射路径 public class AudioResource { public string resDesc; public string resPath; } // 使用字典来储存映射并将其序列化为本地文件即可 Dictionary<string, AudioResource> audioResTable = new Dictionary<string, AudioResource>(); // 整型映射描述符 public class AudioResInfo { public int guid; public string name; public string path; public string file; } // 使用字典来储存映射并将其序列化为本地文件即可 Dictionary<int, AudioResInfo> audioResTable = new Dictionary<int, AudioResInfo>();
但配置文件的方案存在一个较为繁琐的地方,那便是资源的加载,对于大部分动态资源而言运行中加载并无不妥,反而异步加载能增加游戏的流畅性。
然而音频资源略有不同,诚如前文所言,音频资源对游戏而言很多时候都是不变的静态资源,动态加载的意义并不大,将其额外打包的成本和收益也不一定合适,除非有音频资源热更新的需求,否则将其视作静态资源进行配置会更加恰当。
静态资源的配置主要优点就在于调用方便,就以音频资源为例,如果采用普通的文本或者二进制配置文件的话,读取到的音频资源描述符本质上还是加载路径和方式的说明,游戏主线程依然要耗费时间进行二次加载调用。
若是将其视作静态资源进行配置和调用,那么实际使用的体验将和拖放Inspector面板一样方便快捷。
Unity为实现静态资源配置提供了一个相当不错的工具,那便是ScriptableObject基类,它可以被继承实现,和MonoBehavior有一定相似度,但ScriptableObject无法被挂载到场景物体上,它只能存在于资源文件里。
下面是一个简单的范例用于说明ScriptableObject的作用。
[CreateAssetMenu(fileName = "NewConfig", menuName = "Custom/Config")] public class TestConfigs : ScriptableObject { public int integerValue = 0; public float floatValue = 0f; public string stringValue = "Str"; }
编写好如上脚本代码后,它并不能被挂载到任何场景对象上,但由于CreateAssetMenu这项特性描述的存在,可以通过在项目资源管理面板中右键,弹出菜单选择Custom,然后点击二级菜单里的Config来创建一个新的资源文件。
重命名后选中新建的资源文件即可在Inspector里看到脚本中定义的三个配置数据项了,也可以直接修改它们。
该资源文件的使用方法和其它类型的资源一样,通过Resources文件夹加载或者拖放到场景物体的公共字段上,只要类型匹配上TestConfigs即可。
很明显可以看出,ScriptableObject利用了Unity的序列化机制,能够将数据序列化到资源文件中,并且可以通过编辑器Inspector或者窗口进行修改。
而当游戏开始运行时,由于它被视作一种被包含在游戏内的资源,因此往往以只读的形式被加载出来,非常适合用于储存一些需要预先配置的信息。
音频数据库
有了ScriptableObject这个工具,我们就可以尝试利用它来设计一套音频数据库,方便游戏中的各个模块调用相关音频资源。
而这个数据库的设计理念是三层结构的,顶层的根数据表示一套完整的音频数据,通常来说游戏只需要一个根数据文件,但如果有不同主题或者风格的音乐以及音效需求,但又不想改变调用逻辑,那么可以创建多个版本的根数据文件并在游戏中进行对应的加载。
[CreateAssetMenu(fileName = "NewRoot", menuName = "Audio/Root")] public class AudioDataRootObject : ScriptableObject { public List<string> databaseNames = new List<string>(); public List<AudioDatabaseObject> audioDatabases = new List<AudioDatabaseObject>(); public bool AddAudioDatabase(string name, AudioDatabaseObject database) { if (database != null) { if (!databaseNames.Contains(name)) { audioDatabases.Add(database); databaseNames.Add(name); return true; } else { Debug.LogWarning($"AUDIO: Cannot Add Audio Database due to the same name [{name}]."); } } return false; } public bool CheckDatabase(string name) { return databaseNames.Contains(name); } public void RemoveDatabase(string name) { if (CheckDatabase(name)) { int index = databaseNames.IndexOf(name); databaseNames.RemoveAt(index); audioDatabases.RemoveAt(index); } } public AudioDatabaseObject GetAudioDatabase(string name) { AudioDatabaseObject result = null; if (CheckDatabase(name)) { int index = databaseNames.IndexOf(name); result = audioDatabases[index]; } return result; } }
第二层是具体的数据库,这一层通常是用于区分不同类型的音频资源,比如背景音乐,游戏音效,界面音效,人物语音等等;同种类型的音频即可划入相同的数据库,也可以再进行细分,让每个数据库的描述更加具体。
[CreateAssetMenu(fileName = "NewDatabase", menuName = "Audio/Database")] public class AudioDatabaseObject : ScriptableObject { public string databaseName; public AudioMixerGroup databaseMixer; public List<string> groupNames = new List<string>(); public List<AudioGroupData> audioGroups = new List<AudioGroupData>(); // Editor编辑界面所需数据 public bool isExpanded = false; public bool AddAudioGroup(string name, AudioGroupData groupData) { if (groupData != null) { if (!groupNames.Contains(name)) { audioGroups.Add(groupData); groupNames.Add(name); return true; } else { Debug.LogWarning($"AUDIO: Cannot Add Audio Group Data due to the same name [{name}]."); } } return false; } public bool CheckGroupData(string name) { return groupNames.Contains(name); } public void RemoveGroupData(string name) { if (CheckGroupData(name)) { int index = groupNames.IndexOf(name); groupNames.RemoveAt(index); audioGroups.RemoveAt(index); } } public AudioGroupData GetAudioGroupData(string name) { AudioGroupData result = null; if (CheckGroupData(name)) { int index = groupNames.IndexOf(name); result = audioGroups[index]; } return result; } }
第三层是音频组,这一层是用于描述单个音频的,内部提供了音频播放所需的参数,包括音量和音调等,但它可以包含复数个音频资源并选择一个特定的播放方式。
[System.Serializable] public class AudioGroupData { public string groupName; public float groupVolume; public float groupPitch; public bool groupLoop; public float groupSpatialBlend; public int groupPlayMode; public List<AudioData> groupData; // Editor编辑界面所需数据 public bool isExpanded = false; public int dataCount { get { return groupData.Count; } } // -- 非储存字段 public int lastPlayIndex = -1; public AudioGroupData(string name) { groupName = name; groupVolume = 1f; groupPitch = 1f; groupLoop = false; groupSpatialBlend = 0f; groupPlayMode = AudioConfigs.ENUM_GROUP_PLAY_MODE_RANDOM; groupData = new List<AudioData>(); } public void ResetPlayIndex() { lastPlayIndex = -1; } public int MoveNextIndex() { if (groupPlayMode == AudioConfigs.ENUM_GROUP_PLAY_MODE_RANDOM) { lastPlayIndex = Random.Range(0, groupData.Count); } else { lastPlayIndex++; if (lastPlayIndex >= dataCount) { lastPlayIndex -= dataCount; } } return lastPlayIndex; } public void PutAudioData(string dataName, string clipName, AudioClip clip) { AudioData data = new AudioData(dataName, clipName, clip); groupData.Add(data); } public AudioData GetNextAudioData() { if (dataCount > 0) { int index = MoveNextIndex(); return groupData[index]; } return null; } }
这样的设定是针对某些随机音效需求的,比如人物行走的步伐声音或者子弹击打墙面的声音,在实际开发中这些音效可能不止一个音频资源,而是很多个有细微区别的音频资源,在游戏运行时随机播放。
这种情况下便可以将这些音频资源都归纳到同一个音频组下,并设定其为随机播放,此后便不必费心去管理这部分功能了。
音频组内包含的音频资源并不直接使用AudioClip,而是将其封装成AudioData来使用,使其具备在特殊需求下从AssetBundle中甚至从网络上直接获取音频文件的能力。
[System.Serializable] public class AudioData { public string dataName; public string clipName; public AudioClip audioClip; public AudioData(string dn, string cn, AudioClip clip) { dataName = dn; clipName = cn; audioClip = clip; } }
有了以上的基础数据结构之后,还可以为其专门编写编辑器插件来修改内容,下方的示例代码将仅创建和编辑一个根数据文件,如果有多个根数据文件需求要进行修改。
public class AudioDatabaseWindow : EditorWindow { [MenuItem("Window/Audio Data Setup")] private static void NewWindow() { Rect windowRect = new Rect(400, 300, 800, 570); AudioDatabaseWindow window = (AudioDatabaseWindow)GetWindowWithRect(typeof(AudioDatabaseWindow), windowRect, true, "音频资源映射表"); window.Show(); } private AudioDataRootObject rootObject; private SerializedObject windowObject; private Vector2 audioDatabaseListPos; private Vector2 audioGroupListPos; private AudioDatabaseObject editDatabaseObject; private AudioGroupData editAudioGroup; private string createAudioDatabaseName = string.Empty; private string createAudioGroupName = string.Empty; private string[] groupMode = { "Random", "Sequence", "Combined" }; private string selectGroupMode = "Random"; private Color expandItemColor; private Color shrinkItemColor; private void OnEnable() { windowObject = new SerializedObject(this); LoadAudioDataFromResource(); expandItemColor = Color.green; expandItemColor.a = 0.25f; shrinkItemColor = Color.magenta; shrinkItemColor.a = 0.25f; } // 检查相关资源文件的情况并进行必要的创建工作 private void LoadAudioDataFromResource() { if (!FileUtils.CheckDirectory(AudioConfigs.PATH_ASSET_RESOURCE_FOLDER)) { FileUtils.CreateDirectory(AudioConfigs.PATH_ASSET_RESOURCE_FOLDER); } string audioDataFolder = $"{AudioConfigs.PATH_ASSET_RESOURCE_FOLDER}/{AudioConfigs.FOLDER_AUDIO_DATA}/"; if (!FileUtils.CheckDirectory(audioDataFolder)) { FileUtils.CreateDirectory(audioDataFolder); } string audioDatabaseFolder = $"{AudioConfigs.PATH_ASSET_RESOURCE_FOLDER}/{AudioConfigs.FOLDER_AUDIO_DATA}/{AudioConfigs.FOLDER_AUDIO_DATABASE}/"; if (!FileUtils.CheckDirectory(audioDatabaseFolder)) { FileUtils.CreateDirectory(audioDatabaseFolder); } string audioDataRoot = $"{AudioConfigs.PATH_ASSET_RESOURCE_FOLDER}/{AudioConfigs.URI_AUDIO_DATA_ROOT}.asset"; if (!FileUtils.CheckFile(audioDataRoot)) { rootObject = AssetUtils.CreateAsset<AudioDataRootObject>(audioDataFolder, AudioConfigs.FILE_AUDIO_DATA_ROOT); } else { rootObject = ResourceUtils.LoadResource<AudioDataRootObject>(AudioConfigs.URI_AUDIO_DATA_ROOT); } } private void OnGUI() { windowObject.Update(); EditorGUILayout.BeginVertical(); EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("Save Audio Data")) { SaveDataToFile(); } EditorGUILayout.EndHorizontal(); EditorGUILayout.LabelField("Audio Database List"); EditorGUILayout.BeginVertical(GUI.skin.box, GUILayout.Height(240f)); audioDatabaseListPos = EditorGUILayout.BeginScrollView(audioDatabaseListPos, GUILayout.Height(240f)); DrawAudioDatabaseList(); EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Create Database", GUILayout.Width(80f)); createAudioDatabaseName = EditorGUILayout.TextField(createAudioDatabaseName); if (GUILayout.Button("Create", GUILayout.Width(80f))) { if (!string.IsNullOrEmpty(createAudioDatabaseName)) { if (!rootObject.CheckDatabase(createAudioDatabaseName)) { string audioDatabaseFolder = $"{AudioConfigs.PATH_ASSET_RESOURCE_FOLDER}/{AudioConfigs.FOLDER_AUDIO_DATA}/{AudioConfigs.FOLDER_AUDIO_DATABASE}/"; string dbName = $"{AudioConfigs.PREFIX_AUDIO_DATABASE}{createAudioDatabaseName.Trim()}"; AudioDatabaseObject databaseObject = AssetUtils.CreateAsset<AudioDatabaseObject>(audioDatabaseFolder, dbName); databaseObject.databaseName = createAudioDatabaseName; rootObject.AddAudioDatabase(createAudioDatabaseName, databaseObject); createAudioDatabaseName = string.Empty; Repaint(); } } } EditorGUILayout.EndHorizontal(); if (string.IsNullOrEmpty(createAudioDatabaseName)) { EditorGUILayout.HelpBox("Please Input Database Name", MessageType.Warning); } else { if (rootObject.CheckDatabase(createAudioDatabaseName)) { EditorGUILayout.HelpBox("Database Already Exists", MessageType.Warning); } else { EditorGUILayout.HelpBox("Database Name Available", MessageType.Info); } } EditorGUILayout.LabelField("Edit Database Groups"); EditorGUILayout.BeginVertical(GUI.skin.box, GUILayout.Height(160f)); audioGroupListPos = EditorGUILayout.BeginScrollView(audioGroupListPos, GUILayout.Height(160f)); DrawAudioDataList(); EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); if (editDatabaseObject != null && editAudioGroup != null) { DrawAudioDropArea(); } EditorGUILayout.EndVertical(); windowObject.ApplyModifiedProperties(); } private void DrawAudioDatabaseList() { EditorGUILayout.BeginVertical(); for (int i = 0; i < rootObject.databaseNames.Count; i++) { DrawSingleAudioDatabase(i, rootObject.databaseNames[i]); } EditorGUILayout.EndVertical(); } private void DrawSingleAudioDatabase(int index, string name) { if (rootObject != null) { AudioDatabaseObject databaseObject = rootObject.audioDatabases[index]; Color bgColor = databaseObject.isExpanded ? expandItemColor : shrinkItemColor; GUI.backgroundColor = bgColor; EditorGUILayout.BeginVertical(GUI.skin.box); GUI.backgroundColor = Color.white; EditorGUILayout.BeginHorizontal(); databaseObject.isExpanded = EditorGUILayout.Toggle(databaseObject.isExpanded, GUILayout.Width(16f)); EditorGUILayout.LabelField(name, GUILayout.Width(150f)); EditorGUILayout.LabelField(string.Empty); EditorGUILayout.LabelField("Output Mixer", GUILayout.Width(80f)); databaseObject.databaseMixer = EditorGUILayout.ObjectField(GUIContent.none, databaseObject.databaseMixer, typeof(AudioMixerGroup), false, GUILayout.Width(180f)) as AudioMixerGroup; if (GUILayout.Button("Delete", GUILayout.Width(100f))) { if (EditorUtility.DisplayDialog("删除确认", "请问是否要删除指定数据库?", "确认", "取消")) { rootObject.RemoveDatabase(name); AssetDatabase.MoveAssetToTrash(AssetDatabase.GetAssetPath(databaseObject)); Repaint(); } } EditorGUILayout.EndHorizontal(); if (databaseObject.isExpanded) { DrawAudioGroupList(databaseObject); } EditorGUILayout.EndVertical(); } } private void DrawAudioGroupList(AudioDatabaseObject database) { GUI.backgroundColor = expandItemColor; EditorGUILayout.BeginVertical(GUI.skin.box); GUI.backgroundColor = Color.white; EditorGUI.indentLevel++; for (int i = 0; i < database.groupNames.Count; i++) { AudioGroupData group = database.audioGroups[i]; DrawSingleAudioGroup(database, group); } EditorGUILayout.BeginHorizontal(); EditorGUILayout.Space(); EditorGUILayout.LabelField("Create Audio Group", GUILayout.Width(128f)); createAudioGroupName = EditorGUILayout.TextField(createAudioGroupName, GUILayout.Width(280f)); if (GUILayout.Button("Create", GUILayout.Width(100f))) { if (!string.IsNullOrEmpty(createAudioGroupName)) { if (!database.CheckGroupData(createAudioGroupName)) { AudioGroupData grp = new AudioGroupData(createAudioGroupName); database.AddAudioGroup(createAudioGroupName, grp); createAudioGroupName = string.Empty; Repaint(); } } } EditorGUILayout.EndHorizontal(); EditorGUI.indentLevel--; EditorGUILayout.EndVertical(); } private void DrawSingleAudioGroup(AudioDatabaseObject database, AudioGroupData groupData) { if (groupData != null) { string indexTag = groupData.dataCount > 0 ? "+" : "-"; EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(indexTag, GUILayout.Width(24f)); EditorGUILayout.LabelField(groupData.groupName); string grpmode = groupMode[groupData.groupPlayMode]; GUIContent content = new GUIContent(grpmode); if (EditorGUILayout.DropdownButton(content, FocusType.Keyboard, GUILayout.Width(120f))) { editDatabaseObject = database; editAudioGroup = groupData; GenericMenu menu = new GenericMenu(); foreach (string mode in groupMode) { GUIContent modeContent = new GUIContent(mode); menu.AddItem(modeContent, grpmode.Equals(mode), OnGroupModeSelected, mode); } menu.ShowAsContext(); } if (GUILayout.Button("Edit Audio Group", GUILayout.Width(120f))) { SaveDataToFile(); editDatabaseObject = database; editAudioGroup = groupData; Repaint(); } if (GUILayout.Button("Delete Audio Group", GUILayout.Width(120f))) { if (EditorUtility.DisplayDialog("删除确认", "请问是否要删除指定音频组?", "确认", "取消")) { database.RemoveGroupData(groupData.groupName); Repaint(); } } EditorGUILayout.EndHorizontal(); } } private void OnGroupModeSelected(object value) { selectGroupMode = value.ToString(); for (int i = 0; i < groupMode.Length; i++) { if (selectGroupMode == groupMode[i]) { editAudioGroup.groupPlayMode = i; break; } } } private void DrawAudioDataList() { EditorGUILayout.BeginVertical(); if (editDatabaseObject != null && editAudioGroup != null) { EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField($"Current DB:[{editDatabaseObject.databaseName}][{editAudioGroup.groupName}]", GUILayout.Width(200f)); EditorGUILayout.LabelField("Volume", GUILayout.Width(46f)); editAudioGroup.groupVolume = EditorGUILayout.Slider(editAudioGroup.groupVolume, 0f, 1f); EditorGUILayout.LabelField("Pitch", GUILayout.Width(36f)); editAudioGroup.groupPitch = EditorGUILayout.Slider(editAudioGroup.groupPitch, 0f, 1f); EditorGUILayout.LabelField("Spatial", GUILayout.Width(40f)); editAudioGroup.groupSpatialBlend = EditorGUILayout.Slider(editAudioGroup.groupSpatialBlend, 0f, 1f); editAudioGroup.groupLoop = EditorGUILayout.ToggleLeft("循环设置", editAudioGroup.groupLoop, GUILayout.Width(70f)); EditorGUILayout.EndHorizontal(); if (editAudioGroup != null) { EditorGUI.indentLevel++; for (int i = 0; i < editAudioGroup.dataCount; i++) { AudioData data = editAudioGroup.groupData[i]; DrawSingleAudioData(i, data); } EditorGUI.indentLevel--; } } EditorGUILayout.EndVertical(); } private void DrawSingleAudioData(int index, AudioData data) { GUI.color = expandItemColor; EditorGUILayout.BeginVertical(GUI.skin.box); GUI.color = Color.white; EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField($"Clip Name: {data.clipName}"); EditorGUILayout.LabelField("Audio Name:", GUILayout.Width(90f)); data.dataName = EditorGUILayout.TextField(data.dataName, GUILayout.Width(160f)); if (GUILayout.Button("Delete", GUILayout.Width(120f))) { if (EditorUtility.DisplayDialog("删除确认", "请问是否要删除指定音频?", "确认", "取消")) { editAudioGroup.groupData.RemoveAt(index); Repaint(); } } EditorGUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } // 保存修改到资源文件 private void SaveDataToFile() { if (rootObject != null) { EditorUtility.SetDirty(rootObject); } if (editDatabaseObject != null) { EditorUtility.SetDirty(editDatabaseObject); } AssetDatabase.SaveAssets(); } // 拖放方式添加音频资源 private void DrawAudioDropArea() { GUILayout.Label(GUIContent.none, GUILayout.ExpandWidth(true), GUILayout.Height(0)); Rect lastRect = GUILayoutUtility.GetLastRect(); EditorGUILayout.BeginHorizontal(); var dropRect = new Rect(lastRect.width - 200f, lastRect.y - 2f, 200f, 20f); bool containsMouse = dropRect.Contains(Event.current.mousePosition); if (containsMouse) { switch (Event.current.type) { case EventType.DragUpdated: bool containsAudioClip = DragAndDrop.objectReferences.OfType<AudioClip>().Any(); DragAndDrop.visualMode = containsAudioClip ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected; Event.current.Use(); Repaint(); break; case EventType.DragPerform: if (editDatabaseObject != null && editAudioGroup != null) { int count = DragAndDrop.objectReferences.Length; for (int i = 0; i < count; i++) { Object obj = DragAndDrop.objectReferences[i]; if (obj is AudioClip) { AudioClip clip = obj as AudioClip; string dataName = $"Audio Clip {editAudioGroup.dataCount}"; editAudioGroup.PutAudioData(dataName, clip.name, clip); } } } Event.current.Use(); Repaint(); break; } } Color color = GUI.color; GUI.color = new Color(color.r, color.g, color.b, containsMouse ? 0.8f : 0.4f); GUI.Box(dropRect, "+将音频资源拖放至此"); GUI.color = color; EditorGUILayout.EndHorizontal(); } }
至此,音频数据库的相关工具制作就完成了,之后便可以运用这些工具来实现音频管理器。
音频管理器
音频管理器的主要功能其实很简单,就是根据请求内容查找音频资源并自动生成播放音频的游戏对象,与此同时为了避免生成过多的对象还要维护那些已经完成播放的对象将其回收或者删除。
查找资源的问题已经被前面的音频数据库解决了,那么现在重点在于生成以及维护播放器对象,这方面有个现成的例子可以参考,那就是游戏中常见的“对象池”。
音频管理器可以维护一个音频播放器对象池,每次有音频播放请求到来则尝试从中获取一个可用的播放器,如果获取不到则创建新对象。
架构方面可以自底向上进行设计,首先搞定播放器自身的脚本代码。
public class AudioController : MonoBehaviour { #region Static Properties public const string NAME_AUDIO_CONTROLLER = "AudioController"; public const float IDLE_TIMEOUT_LENGTH = 30f; private static List<AudioController> controllers = new List<AudioController>(); #endregion public AudioSource source { get; private set; } private Transform audioTransform; private Transform followTarget; public bool inUse { get; private set; } public float playProgress { get; private set; } public bool isPause { get; private set; } public bool isMute { get; private set; } public float lastPlayTime { get; private set; } public bool isPlaying { get; private set; } public bool autoPause { get; private set; } public bool muted { get; private set; } public bool paused { get; private set; } public float idleTime { get { return Time.realtimeSinceStartup - lastPlayTime; } } private void Reset() { ResetController(); } private void Awake() { controllers.Add(this); audioTransform = transform; source = gameObject.GetComponent<AudioSource>() ?? gameObject.AddComponent<AudioSource>(); ResetController(); } private void Update() { if (isMute || isPause || source.isPlaying) { lastPlayTime = Time.realtimeSinceStartup; } if (isMute != muted) { source.mute = isMute; muted = isMute; } if (isPause != paused) { if (isPause) { if (source.isPlaying) { source.Pause(); } } else { source.UnPause(); } paused = isPause; } UpdatePlayProgress(); if (playProgress >= 1f) { Stop(); playProgress = 0f; } else { autoPause = inUse && isPlaying && !source.isPlaying && playProgress > 0f; if (inUse && !autoPause && !source.isPlaying && !isPause && isMute) { Stop(); } else { FollowTarget(); } } if (idleTime > IDLE_TIMEOUT_LENGTH) { Kill(); } } private void OnDestroy() { controllers.Remove(this); } // -- Public Methods public void Kill() { source.Stop(); if (AudioManager.Instance.debugFlag) { Debug.Log($"Kill AudioController[{name}]"); } Destroy(gameObject); } public void Mute() { isMute = true; if (AudioManager.Instance.debugFlag) { Debug.Log($"Mute AudioController[{name}]"); } } public void Pause() { isPause = true; if (AudioManager.Instance.debugFlag) { Debug.Log($"Pause AudioController[{name}]"); } } public void Play() { inUse = true; isPause = false; isPlaying = true; source.Play(); if (AudioManager.Instance.debugFlag) { Debug.Log($"Play AudioController[{name}]"); } } public void SetupFollowTarget(Transform target) { followTarget = target; } public void SetupAudioMixerGroup(AudioMixerGroup group) { if (group != null) { source.outputAudioMixerGroup = group; } } public void SetPosition(Vector3 pos) { audioTransform.position = pos; } public void SetSourceProperties(AudioClip clip, float volume, float pitch, bool loop, float spatialBlend) { if (clip != null) { source.clip = clip; source.volume = volume; source.pitch = pitch; source.loop = loop; source.spatialBlend = spatialBlend; } else { Stop(); } } public void Stop() { Unpause(); Unmute(); source.Stop(); if (AudioManager.Instance.debugFlag) { Debug.Log($"Stop AudioController[{name}]"); } AudioPool.Instance.PutController(this); ResetController(); } public void Unmute() { isMute = false; if (AudioManager.Instance.debugFlag) { Debug.Log($"Unmute AudioController[{name}]"); } } public void Unpause() { isPause = false; if (AudioManager.Instance.debugFlag) { Debug.Log($"Unpause AudioController[{name}]"); } } // -- Private Methods private void FollowTarget() { if (followTarget != null) { audioTransform.position = followTarget.position; if (AudioManager.Instance.debugFlag) { Debug.Log($"{name} is following the GameObject[{followTarget.name}]"); } } } private void UpdatePlayProgress() { if (source != null && source.clip != null) { playProgress = Mathf.Clamp01(source.time / source.clip.length); } } private void ResetController() { inUse = false; isPause = false; followTarget = null; lastPlayTime = Time.realtimeSinceStartup; } // -- Static Methods public static AudioController GetController() { AudioController controller = new GameObject(NAME_AUDIO_CONTROLLER, typeof(AudioSource), typeof(AudioController)).GetComponent<AudioController>(); return controller; } public static void KillAll() { if (AudioManager.Instance.debugFlag) { Debug.Log("Kill All AudioControllers"); } RemoveNullsFromList(); for (int i = 0; i < controllers.Count; i++) { controllers[i].Kill(); } } public static void MuteAll() { if (AudioManager.Instance.debugFlag) { Debug.Log("Mute All AudioControllers"); } RemoveNullsFromList(); for (int i = 0; i < controllers.Count; i++) { controllers[i].Mute(); } } public static void PauseAll() { if (AudioManager.Instance.debugFlag) { Debug.Log("Pause All AudioControllers"); } RemoveNullsFromList(); for (int i = 0; i < controllers.Count; i++) { controllers[i].Pause(); } } public static void StopAll() { if (AudioManager.Instance.debugFlag) { Debug.Log("Stop All AudioControllers"); } RemoveNullsFromList(); for (int i = 0; i < controllers.Count; i++) { controllers[i].Stop(); } } public static void UnmuteAll() { if (AudioManager.Instance.debugFlag) { Debug.Log("Unmute All AudioControllers"); } RemoveNullsFromList(); for (int i = 0; i < controllers.Count; i++) { controllers[i].Unmute(); } } public static void UnpauseAll() { if (AudioManager.Instance.debugFlag) { Debug.Log("Unpause All AudioControllers"); } RemoveNullsFromList(); for (int i = 0; i < controllers.Count; i++) { controllers[i].Unpause(); } } public static void RemoveNullsFromList() { IEnumerable<AudioController> tempSet = controllers; controllers = new List<AudioController>(); foreach (AudioController ctrl in tempSet) { if (ctrl != null) { controllers.Add(ctrl); } } } }
然后是播放控制器的对象池,采用单例模式因此最好和管理器一起挂载到对象上且保证全局只有一个即可,负责维护一定数量的播放控制器,在没有播放请求时根据设定删除多余的对象。
public class AudioPool : MonoBehaviour { #region Singleton public static AudioPool Instance { get; private set; } #endregion #region Public Fields public bool AutoKillIdleControllers = true; // 是否自动删除多余的播放控制器 public float ControllerIdleKillDuration = 5f; // 删除播放控制器的时间要求 public float IdleCheckInterval = 1f; // 检查周期 public int MinimumNumberOfControllers = 3; // 最小保有的播放控制器数量 #endregion #region Private Fields private List<AudioController> audioPool; private Coroutine idleCheckCoroutine; private WaitForSecondsRealtime idleCheckIntervalWaitTime; private AudioController tempController; #endregion private void OnEnable() { if (AutoKillIdleControllers) { StartIdleCheckInterval(); } } private void Awake() { audioPool = new List<AudioController>(); Instance = this; } private void OnDisable() { StopIdleCheckInterval(); } // -- Static Methods public void ClearPool(bool keepMinCount = false) { if (keepMinCount) { RemoveNullsFromThePool(); if (audioPool.Count <= MinimumNumberOfControllers) { if (AudioManager.Instance.debugFlag) { Debug.Log($"Clear Pool, {audioPool.Count} Controllers Available"); } } else { int killCount = 0; for (int i = audioPool.Count - 1; i >= MinimumNumberOfControllers; i--) { AudioController ctrl = audioPool[i]; audioPool.Remove(ctrl); ctrl.Kill(); killCount++; } if (AudioManager.Instance.debugFlag) { Debug.Log($"Clear Pool, Killed {killCount} Controllers, {audioPool.Count} Controllers Available"); } } } else { AudioController.KillAll(); audioPool.Clear(); if (AudioManager.Instance.debugFlag) { Debug.Log($"Clear Pool, Killed All Controllers, {audioPool.Count} Controllers Available"); } } } public AudioController GetController() { RemoveNullsFromThePool(); AudioController ctrl; if (audioPool.Count > 0) { ctrl = audioPool[0]; audioPool.Remove(ctrl); ctrl.gameObject.SetActive(true); } else { ctrl = AudioController.GetController(); ctrl.transform.SetParent(Instance.transform); } return ctrl; } public void PopulatePool(int count) { RemoveNullsFromThePool(); if (count >= 1) { for (int i = 0; i < count; i++) { PutController(AudioController.GetController()); } if (AudioManager.Instance.debugFlag) { Debug.Log($"Populate Pool, Add {count} Controllers to the Pool, {audioPool.Count} Controllers Available"); } } } public void PutController(AudioController ctrl) { if (ctrl != null) { ctrl.gameObject.SetActive(false); ctrl.transform.SetParent(Instance.transform); if (!audioPool.Contains(ctrl)) { audioPool.Add(ctrl); } if (AudioManager.Instance.debugFlag) { Debug.Log($"Put AudioController[{ctrl.name}] in the Pool, {audioPool.Count} Controllers Available"); } } } private void RemoveNullsFromThePool() { IEnumerable<AudioController> tempSet = audioPool; audioPool = new List<AudioController>(); foreach (AudioController ctrl in tempSet) { if (ctrl != null) { audioPool.Add(ctrl); } } } // -- Private Methods private void StartIdleCheckInterval() { if (AudioManager.Instance.debugFlag) { Debug.Log("Start Idle Check"); } idleCheckIntervalWaitTime = new WaitForSecondsRealtime(IdleCheckInterval < 0f ? 0f : IdleCheckInterval); idleCheckCoroutine = StartCoroutine(KillIdleControllers()); } private void StopIdleCheckInterval() { if (AudioManager.Instance.debugFlag) { Debug.Log("Stop Idle Check"); } if (idleCheckCoroutine != null) { StopCoroutine(idleCheckCoroutine); idleCheckCoroutine = null; } } // -- Coroutine private IEnumerator KillIdleControllers() { // 定时检查播放控制器进行适当的删除 while (AutoKillIdleControllers) { yield return idleCheckIntervalWaitTime; RemoveNullsFromThePool(); int minControllerCount = MinimumNumberOfControllers > 0 ? MinimumNumberOfControllers : 0; float controllerKillDuration = ControllerIdleKillDuration > 0f ? ControllerIdleKillDuration : 0f; if (audioPool.Count > minControllerCount) { for (int i = audioPool.Count - 1; i >= minControllerCount; i--) { tempController = audioPool[i]; if (tempController.idleTime >= controllerKillDuration) { audioPool.Remove(tempController); tempController.Kill(); } } } } idleCheckCoroutine = null; } }
最后就是管理器本身,它会和对象池协作,根据请求获取播放控制器并且将音频资源放入其中进行播放,可以将控制器的引用返回方便调用者控制播放。
[RequireComponent(typeof(AudioPool))] public class AudioManager : MonoBehaviour { #region Singleton public static AudioManager Instance { get; private set; } #endregion #region Overall Configs public bool debugFlag = false; #endregion #region Static Properties private static AudioPool audioPool; public static AudioPool Pool { get { if (audioPool != null) { return audioPool; } else { audioPool = Instance.gameObject.GetComponent<AudioPool>(); if (audioPool == null) { audioPool = Instance.gameObject.AddComponent<AudioPool>(); } return audioPool; } } } #endregion #region Private Fields private bool isAudioDataReady = false; private string uriAudioDataRoot; private AudioDataRootObject rootObject; #endregion private void Awake() { DontDestroyOnLoad(gameObject); Instance = this; uriAudioDataRoot = AudioConfigs.URI_AUDIO_DATA_ROOT; } private void Start() { StartCoroutine(UpdateAudioData()); } public AudioController Play(string dbName, string aName, Vector3 pos) { if (isAudioDataReady && !string.IsNullOrEmpty(dbName) && !string.IsNullOrEmpty(aName)) { AudioDatabaseObject db = rootObject.GetAudioDatabase(dbName); AudioGroupData grp = db?.GetAudioGroupData(aName); if (grp != null) { if (Instance.debugFlag) { Debug.Log($"Play [{dbName}].[{aName}] Audio Group at {pos}"); } AudioData data = grp.GetNextAudioData(); ; AudioController ctrl = AudioPool.Instance.GetController(); AudioMixerGroup mixer = db.databaseMixer; AudioClip clip = data.audioClip; ctrl.SetupAudioMixerGroup(mixer); if (clip != null) { ctrl.SetPosition(pos); ctrl.SetSourceProperties(clip, grp.groupVolume, grp.groupPitch, grp.groupLoop, grp.groupSpatialBlend); ctrl.Play(); return ctrl; } } } return null; } public AudioController Play(string dbName, string aName, Transform target) { if (isAudioDataReady && !string.IsNullOrEmpty(dbName) && !string.IsNullOrEmpty(aName)) { AudioDatabaseObject db = rootObject.GetAudioDatabase(dbName); AudioGroupData grp = db?.GetAudioGroupData(aName); if (grp != null) { if (Instance.debugFlag) { Debug.Log($"Play [{dbName}].[{aName}] Audio Group following target [{target.name}]"); } AudioData data = grp.GetNextAudioData(); AudioController ctrl = AudioPool.Instance.GetController(); AudioMixerGroup mixer = db.databaseMixer; AudioClip clip = data.audioClip; ctrl.SetupAudioMixerGroup(mixer); if (clip != null) { ctrl.SetPosition(target.position); ctrl.SetupFollowTarget(target); ctrl.SetSourceProperties(clip, grp.groupVolume, grp.groupPitch, grp.groupLoop, grp.groupSpatialBlend); ctrl.Play(); return ctrl; } } } return null; } public AudioController Play(AudioClip clip, Vector3 pos, float volume = 1f, float pitch = 1f, bool loop = false, float spatialBlend = 1f, AudioMixerGroup mixer = null) { if (clip != null) { if (Instance.debugFlag) { Debug.Log($"Play Clip[{clip.name}] Audio Group at {pos}"); } AudioController ctrl = AudioPool.Instance.GetController(); ctrl.SetSourceProperties(clip, volume, pitch, loop, spatialBlend); ctrl.SetupAudioMixerGroup(mixer); ctrl.SetPosition(pos); ctrl.Play(); return ctrl; } return null; } public AudioController Play(AudioClip clip, Transform target, float volume = 1f, float pitch = 1f, bool loop = false, float spatialBlend = 1f, AudioMixerGroup mixer = null) { if (clip != null) { if (Instance.debugFlag) { Debug.Log($"Play Clip[{clip.name}] Audio Group following target [{target.name}]"); } AudioController ctrl = AudioPool.Instance.GetController(); ctrl.SetSourceProperties(clip, volume, pitch, loop, spatialBlend); ctrl.SetupAudioMixerGroup(mixer); ctrl.SetPosition(target.position); ctrl.SetupFollowTarget(target); ctrl.Play(); return ctrl; } return null; } public void LoadAudioRootData(string uri) { uriAudioDataRoot = uri; StartCoroutine(UpdateAudioData()); } public void SetupAudioRootData(AudioDataRootObject root) { rootObject = root; isAudioDataReady = rootObject != null; } // 更新音频数据库 protected IEnumerator UpdateAudioData() { ResourceRequest request = Resources.LoadAsync<AudioDataRootObject>(uriAudioDataRoot); yield return request; rootObject = request.asset as AudioDataRootObject; isAudioDataReady = rootObject != null; } }
总结
音频管理对游戏而言是个相当有用的功能,而且其设计理念和实现方法都不算复杂,即便在大型游戏中有更为复杂的音频管理需求,也可以通过对基础管理模块的拓展和重构来实现。
当然了,实战中的最佳选择依然是成熟的第三方管理插件,找到合心意的一款即可。
暂无关于此日志的评论。