本文实现一个音频库的自定义编辑器,效果如图:
开始实现之前,首先简单介绍该音频库模块,音频库类Audio Database继承自Scriptable Object类,是一个可配置的资源文件:
包含的内容如下,databaseName表示该音频库的名称,outputAudioMixerGroup表示音频播放时的输出混音器组,datasets则是表示所有音频数据的列表:
/// <summary>/// 音频库/// </summary> [CreateAssetMenu(fileName="New Audio Database", order=215)] publicclassAudioDatabase : ScriptableObject { /// <summary>/// 音频库名称/// </summary>publicstringdatabaseName; /// <summary>/// 输出混音器组/// </summary>publicAudioMixerGroupoutputAudioMixerGroup; /// <summary>/// 音频数据列表/// </summary>publicList<AudioData>datasets=newList<AudioData>(0); }
AudioData音频数据类包含两个字段:name 表示该音频数据的名称,clip 表示该音频资源:
usingSystem; usingUnityEngine; namespaceSK.Framework{ /// <summary>/// 音频数据/// </summary> [Serializable] publicclassAudioData { publicstringname; publicAudioClipclip; } }
该编辑器的布局结构:
首先继承自Editor类,使用CustomEditorAttribute,并重写OnInspectorGUI方法以实现自定义编辑器。
音频库名称是一个string类型字段,因此使用EditorGUILayout中的TextField函数来添加一个文本编辑框:
usingUnityEditor; usingUnityEngine; [CustomEditor(typeof(AudioDatabase))] publicclassAudioDatabaseEditor : Editor{ privateAudioDatabasedatabase; privatevoidOnEnable() { database=targetasAudioDatabase; } publicoverridevoidOnInspectorGUI() { //音频库名称varnewDatabaseName=EditorGUILayout.TextField("Database Name", database.databaseName); if (newDatabaseName!=database.databaseName) { Undo.RecordObject(database, "Name"); database.databaseName=newDatabaseName; EditorUtility.SetDirty(database); } } }
其中Undo.RecordObject方法用于实现撤销、恢复操作。即当我们修改音频库名称后,使用Ctrl+Z可以撤销修改的操作,撤销后使用Ctrl+Y可以恢复撤销的内容。EditorUtility类中的SetDirty方法则用于标识该物体已经被修改,以实现资产更新保存。上述这两个方法将会大量用到。
outputAudioMixerGroup使用ObjectField方法来实现赋值和更改,objType参数传入AudioMixerGroup的类型即可:
varnewOutputAudioMixerGroup=EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) asAudioMixerGroup; if (newOutputAudioMixerGroup!=database.outputAudioMixerGroup) { Undo.RecordObject(database, "Output"); database.outputAudioMixerGroup=newOutputAudioMixerGroup; EditorUtility.SetDirty(database); }
折叠栏使用EditorGUILayout类中的BeginFadeGroup和EndFadeGroup方法来实现,可以使用一个bool类型字段来实现简单的折叠,不过我们这里用的是AnimBool,它可以实现折叠时的动画效果,效果如下
在折叠栏为打开状态时,遍历音频数据列表,每一项数据添加一个水平布局,从左到右依次添加音频图标、音频名称、一个Button按钮、时长信息、播放、停止、删除按钮。
usingUnityEngine; usingUnityEditor; usingUnityEngine.Audio; usingUnityEditor.AnimatedValues; [CustomEditor(typeof(AudioDatabase))] publicclassAudioDatabaseEditor : Editor{ privateAudioDatabasedatabase; privateAnimBoolfoldout; privatevoidOnEnable() { database=targetasAudioDatabase; foldout=newAnimBool(false, Repaint); } publicoverridevoidOnInspectorGUI() { //音频库名称varnewDatabaseName=EditorGUILayout.TextField("Database Name", database.databaseName); if (newDatabaseName!=database.databaseName) { Undo.RecordObject(database, "Name"); database.databaseName=newDatabaseName; EditorUtility.SetDirty(database); } //音频库输出混音器varnewOutputAudioMixerGroup=EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) asAudioMixerGroup; if (newOutputAudioMixerGroup!=database.outputAudioMixerGroup) { Undo.RecordObject(database, "Output"); database.outputAudioMixerGroup=newOutputAudioMixerGroup; EditorUtility.SetDirty(database); } //音频数据折叠栏 使用AnimBool实现动画效果foldout.target=EditorGUILayout.Foldout(foldout.target, "Datasets"); if (EditorGUILayout.BeginFadeGroup(foldout.faded)) { for (inti=0; i<database.datasets.Count; i++) { vardata=database.datasets[i]; //水平布局GUILayout.BeginHorizontal(); GUILayout.EndHorizontal(); } } EditorGUILayout.EndFadeGroup(); } }
音频图标使用的是Unity中内置的图标,如何查看Unity中的内置图标在如下链接的博客中有介绍:Unity编辑器开发之GUIIcon 有了图标的名称后,通过EditorGUIUtility类中的IconContent方法进行实现:
//绘制音频图标GUILayout.Label(EditorGUIUtility.IconContent("SceneViewAudio"), GUILayout.Width(20f));
音频数据的名称为string类型字段,也通过TextField进行实现:
//音频数据名称varnewName=EditorGUILayout.TextField(data.name, GUILayout.Width(120f)); if (newName!=data.name) { Undo.RecordObject(database, "Data Name"); data.name=newName; EditorUtility.SetDirty(database); }
添加Button按钮,点击该按钮后,使用EditorGUIUtility类中的PingObject方法定位该项数据中的音频资源,绘制按钮时使用不同颜色来区分当前项是否为选中的音频数据项,声明一个int类型字段currentIndex,用于表示当前选中项的索引值
//使用音频名称绘制Button按钮 点击后使用PingObject方法定位该音频资源ColorcolorCache=GUI.color; GUI.color=currentIndex==i?Color.cyan : colorCache; if (GUILayout.Button(data.clip!=null?data.clip.name : "Null")) { currentIndex=i; EditorGUIUtility.PingObject(data.clip); } GUI.color=colorCache;
播放进度和音频时长均为float类型,我们需要一个将时长转化为00:00时间格式的方法,代码如下:
//将秒数转换为00:00时间格式字符串privatestringToTimeFormat(floattime) { intseconds= (int)time; intminutes=seconds/60; seconds%=60; returnstring.Format("{0:D2}:{1:D2}", minutes, seconds); }
播放、停止播放及删除按钮的图标用的也均是Unity中的内置图标,分别为PlayButton、PauseButton和Toolbar Minus:
//播放按钮if (GUILayout.Button(EditorGUIUtility.IconContent("PlayButton"), GUILayout.Width(20f))) { } //停止播放按钮if (GUILayout.Button(EditorGUIUtility.IconContent("PauseButton"), GUILayout.Width(20f))) { } //删除按钮 点击后删除该项音频数据if (GUILayout.Button(EditorGUIUtility.IconContent("Toolbar Minus"), GUILayout.Width(20f))) { }
我们声明一个字典来存储当前正在播放的音频项,点击播放按钮时,创建一个带有Audio Source组件的物体并用其播放,将其添加到字典中,点击停止播放按钮时,将其从字典移除,并销毁物体,点击删除按钮时,也要判断该项如果正在播放,先要进行移除和销毁,再删除该音频数据项:
privateDictionary<AudioData, AudioSource>players;
//播放按钮if (GUILayout.Button(EditorGUIUtility.IconContent("PlayButton"), GUILayout.Width(20f))) { if (!players.ContainsKey(data)) { //创建一个物体并添加AudioSource组件 varsource=EditorUtility.CreateGameObjectWithHideFlags("Audio Player", HideFlags.HideAndDontSave).AddComponent<AudioSource>(); source.clip=data.clip; source.outputAudioMixerGroup=database.outputAudioMixerGroup; source.Play(); players.Add(data, source); } } //停止播放按钮if (GUILayout.Button(EditorGUIUtility.IconContent("PauseButton"), GUILayout.Width(20f))) { if (players.ContainsKey(data)) { DestroyImmediate(players[data].gameObject); players.Remove(data); } } //删除按钮 点击后删除该项音频数据if (GUILayout.Button(EditorGUIUtility.IconContent("Toolbar Minus"), GUILayout.Width(20f))) { Undo.RecordObject(database, "Delete"); database.datasets.Remove(data); if (players.ContainsKey(data)) { DestroyImmediate(players[data].gameObject); players.Remove(data); } EditorUtility.SetDirty(database); Repaint(); }
最后绘制一个矩形区域,当拖拽AudioClip资源到该区域时,添加音频数据项,使用DragAndDrop类来实现:
//以下代码块中绘制了一个矩形区域,将AudioClip资产拖到该区域则添加一项音频数据GUILayout.BeginHorizontal(); { GUILayout.Label(GUIContent.none, GUILayout.ExpandWidth(true)); RectlastRect=GUILayoutUtility.GetLastRect(); vardropRect=newRect(lastRect.x+2f, lastRect.y-2f, 120f, 20f); boolcontainsMouse=dropRect.Contains(Event.current.mousePosition); if (containsMouse) { switch (Event.current.type) { caseEventType.DragUpdated: boolcontainsAudioClip=DragAndDrop.objectReferences.OfType<AudioClip>().Any(); DragAndDrop.visualMode=containsAudioClip?DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected; Event.current.Use(); Repaint(); break; caseEventType.DragPerform: IEnumerable<AudioClip>audioClips=DragAndDrop.objectReferences.OfType<AudioClip>(); foreach (varaudioClipinaudioClips) { if (database.datasets.Find(m=>m.clip==audioClip) ==null) { Undo.RecordObject(database, "Add"); database.datasets.Add(newAudioData() { name=audioClip.name, clip=audioClip }); EditorUtility.SetDirty(database); } } Event.current.Use(); Repaint(); break; } } Colorcolor=GUI.color; GUI.color=newColor(GUI.color.r, GUI.color.g, GUI.color.b, containsMouse? .9f : .5f); GUI.Box(dropRect, "Drop AudioClips Here", newGUIStyle(GUI.skin.box) { fontSize=10 }); GUI.color=color; } GUILayout.EndHorizontal();
最终代码:
usingUnityEngine; usingUnityEditor; usingSystem.Linq; usingUnityEngine.Audio; usingSystem.Collections.Generic; usingUnityEditor.AnimatedValues; [CustomEditor(typeof(AudioDatabase))] publicclassAudioDatabaseEditor : Editor{ privateAudioDatabasedatabase; privateAnimBoolfoldout; privateintcurrentIndex=-1; privateDictionary<AudioData, AudioSource>players; privatevoidOnEnable() { database=targetasAudioDatabase; foldout=newAnimBool(false, Repaint); players=newDictionary<AudioData, AudioSource>(); EditorApplication.update+=Update; } privatevoidOnDestroy() { EditorApplication.update-=Update; foreach (varplayerinplayers) { DestroyImmediate(player.Value.gameObject); } players.Clear(); } privatevoidUpdate() { Repaint(); foreach (varplayerinplayers) { if (!player.Value.isPlaying) { DestroyImmediate(player.Value.gameObject); players.Remove(player.Key); break; } } } publicoverridevoidOnInspectorGUI() { //音频库名称varnewDatabaseName=EditorGUILayout.TextField("Database Name", database.databaseName); if (newDatabaseName!=database.databaseName) { Undo.RecordObject(database, "Name"); database.databaseName=newDatabaseName; EditorUtility.SetDirty(database); } //音频库输出混音器varnewOutputAudioMixerGroup=EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) asAudioMixerGroup; if (newOutputAudioMixerGroup!=database.outputAudioMixerGroup) { Undo.RecordObject(database, "Output"); database.outputAudioMixerGroup=newOutputAudioMixerGroup; EditorUtility.SetDirty(database); } //音频数据折叠栏 使用AnimBool实现动画效果foldout.target=EditorGUILayout.Foldout(foldout.target, "Datasets"); if (EditorGUILayout.BeginFadeGroup(foldout.faded)) { for (inti=0; i<database.datasets.Count; i++) { vardata=database.datasets[i]; GUILayout.BeginHorizontal(); //绘制音频图标GUILayout.Label(EditorGUIUtility.IconContent("SceneViewAudio"), GUILayout.Width(20f)); //音频数据名称varnewName=EditorGUILayout.TextField(data.name, GUILayout.Width(120f)); if (newName!=data.name) { Undo.RecordObject(database, "Data Name"); data.name=newName; EditorUtility.SetDirty(database); } //使用音频名称绘制Button按钮 点击后使用PingObject方法定位该音频资源ColorcolorCache=GUI.color; GUI.color=currentIndex==i?Color.cyan : colorCache; if (GUILayout.Button(data.clip!=null?data.clip.name : "Null")) { currentIndex=i; EditorGUIUtility.PingObject(data.clip); } GUI.color=colorCache; //若该音频正在播放 计算其播放进度 stringprogress=players.ContainsKey(data) ?ToTimeFormat(players[data].time) : "00:00"; GUI.color=newColor(GUI.color.r, GUI.color.g, GUI.color.b, players.ContainsKey(data) ? .9f : .5f); //显示信息:播放进度 / 音频时长 (00:00 / 00:00)GUILayout.Label($"({progress} / {(data.clip != null ? ToTimeFormat(data.clip.length) : "00:00")})", newGUIStyle(GUI.skin.label) { alignment=TextAnchor.LowerRight, fontSize=8, fontStyle=FontStyle.Italic }, GUILayout.Width(60f)); GUI.color=colorCache; //播放按钮if (GUILayout.Button(EditorGUIUtility.IconContent("PlayButton"), GUILayout.Width(20f))) { if (!players.ContainsKey(data)) { //创建一个物体并添加AudioSource组件 varsource=EditorUtility.CreateGameObjectWithHideFlags("Audio Player", HideFlags.HideAndDontSave).AddComponent<AudioSource>(); source.clip=data.clip; source.outputAudioMixerGroup=database.outputAudioMixerGroup; source.Play(); players.Add(data, source); } } //停止播放按钮if (GUILayout.Button(EditorGUIUtility.IconContent("PauseButton"), GUILayout.Width(20f))) { if (players.ContainsKey(data)) { DestroyImmediate(players[data].gameObject); players.Remove(data); } } //删除按钮 点击后删除该项音频数据if (GUILayout.Button(EditorGUIUtility.IconContent("Toolbar Minus"), GUILayout.Width(20f))) { Undo.RecordObject(database, "Delete"); database.datasets.Remove(data); if (players.ContainsKey(data)) { DestroyImmediate(players[data].gameObject); players.Remove(data); } EditorUtility.SetDirty(database); Repaint(); } GUILayout.EndHorizontal(); } EditorGUILayout.Space(); //以下代码块中绘制了一个矩形区域,将AudioClip资产拖到该区域则添加一项音频数据GUILayout.BeginHorizontal(); { GUILayout.Label(GUIContent.none, GUILayout.ExpandWidth(true)); RectlastRect=GUILayoutUtility.GetLastRect(); vardropRect=newRect(lastRect.x+2f, lastRect.y-2f, 120f, 20f); boolcontainsMouse=dropRect.Contains(Event.current.mousePosition); if (containsMouse) { switch (Event.current.type) { caseEventType.DragUpdated: boolcontainsAudioClip=DragAndDrop.objectReferences.OfType<AudioClip>().Any(); DragAndDrop.visualMode=containsAudioClip?DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected; Event.current.Use(); Repaint(); break; caseEventType.DragPerform: IEnumerable<AudioClip>audioClips=DragAndDrop.objectReferences.OfType<AudioClip>(); foreach (varaudioClipinaudioClips) { if (database.datasets.Find(m=>m.clip==audioClip) ==null) { Undo.RecordObject(database, "Add"); database.datasets.Add(newAudioData() { name=audioClip.name, clip=audioClip }); EditorUtility.SetDirty(database); } } Event.current.Use(); Repaint(); break; } } Colorcolor=GUI.color; GUI.color=newColor(GUI.color.r, GUI.color.g, GUI.color.b, containsMouse? .9f : .5f); GUI.Box(dropRect, "Drop AudioClips Here", newGUIStyle(GUI.skin.box) { fontSize=10 }); GUI.color=color; } GUILayout.EndHorizontal(); } EditorGUILayout.EndFadeGroup(); serializedObject.ApplyModifiedProperties(); } //将秒数转换为00:00时间格式字符串privatestringToTimeFormat(floattime) { intseconds= (int)time; intminutes=seconds/60; seconds%=60; returnstring.Format("{0:D2}:{1:D2}", minutes, seconds); } }