Unity 编辑器开发实战【Custom Editor】- AudioDatabase Editor 音频库编辑器

举报
CoderZ1010 发表于 2022/09/25 04:33:12 2022/09/25
【摘要】 本文实现一个音频库的自定义编辑器,效果如图:         开始实现之前,首先简单介绍该音频库模块,音频库类Audio Database继承自Scriptable Object类,是一个可配置的资源文件:    &...

本文实现一个音频库的自定义编辑器,效果如图:

        开始实现之前,首先简单介绍该音频库模块,音频库类Audio Database继承自Scriptable Object类,是一个可配置的资源文件:

        包含的内容如下,databaseName表示该音频库的名称,outputAudioMixerGroup表示音频播放时的输出混音器组,datasets则是表示所有音频数据的列表:


  
  1. /// <summary>
  2. /// 音频库
  3. /// </summary>
  4. [CreateAssetMenu(fileName = "New Audio Database", order = 215)]
  5. public class AudioDatabase : ScriptableObject
  6. {
  7. /// <summary>
  8. /// 音频库名称
  9. /// </summary>
  10. public string databaseName;
  11. /// <summary>
  12. /// 输出混音器组
  13. /// </summary>
  14. public AudioMixerGroup outputAudioMixerGroup;
  15. /// <summary>
  16. /// 音频数据列表
  17. /// </summary>
  18. public List<AudioData> datasets = new List<AudioData>(0);
  19. }

        AudioData音频数据类包含两个字段:name 表示该音频数据的名称,clip 表示该音频资源:


  
  1. using System;
  2. using UnityEngine;
  3. namespace SK.Framework
  4. {
  5. /// <summary>
  6. /// 音频数据
  7. /// </summary>
  8. [Serializable]
  9. public class AudioData
  10. {
  11. public string name;
  12. public AudioClip clip;
  13. }
  14. }

该编辑器的布局结构:

        首先继承自Editor类,使用CustomEditorAttribute,并重写OnInspectorGUI方法以实现自定义编辑器。

        音频库名称是一个string类型字段,因此使用EditorGUILayout中的TextField函数来添加一个文本编辑框:


  
  1. using UnityEditor;
  2. using UnityEngine;
  3. [CustomEditor(typeof(AudioDatabase))]
  4. public class AudioDatabaseEditor : Editor
  5. {
  6. private AudioDatabase database;
  7. private void OnEnable()
  8. {
  9. database = target as AudioDatabase;
  10. }
  11. public override void OnInspectorGUI()
  12. {
  13. //音频库名称
  14. var newDatabaseName = EditorGUILayout.TextField("Database Name", database.databaseName);
  15. if (newDatabaseName != database.databaseName)
  16. {
  17. Undo.RecordObject(database, "Name");
  18. database.databaseName = newDatabaseName;
  19. EditorUtility.SetDirty(database);
  20. }
  21. }
  22. }

        其中Undo.RecordObject方法用于实现撤销、恢复操作。即当我们修改音频库名称后,使用Ctrl+Z可以撤销修改的操作,撤销后使用Ctrl+Y可以恢复撤销的内容。EditorUtility类中的SetDirty方法则用于标识该物体已经被修改,以实现资产更新保存。上述这两个方法将会大量用到。

        outputAudioMixerGroup使用ObjectField方法来实现赋值和更改,objType参数传入AudioMixerGroup的类型即可:


  
  1. var newOutputAudioMixerGroup = EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) as AudioMixerGroup;
  2. if (newOutputAudioMixerGroup != database.outputAudioMixerGroup)
  3. {
  4. Undo.RecordObject(database, "Output");
  5. database.outputAudioMixerGroup = newOutputAudioMixerGroup;
  6. EditorUtility.SetDirty(database);
  7. }

        折叠栏使用EditorGUILayout类中的BeginFadeGroup和EndFadeGroup方法来实现,可以使用一个bool类型字段来实现简单的折叠,不过我们这里用的是AnimBool,它可以实现折叠时的动画效果,效果如下:(AnimBool的使用在以往的博客中有介绍:Unity编辑器开发之AnimBool

        在折叠栏为打开状态时,遍历音频数据列表,每一项数据添加一个水平布局,从左到右依次添加音频图标、音频名称、一个Button按钮、时长信息、播放、停止、删除按钮。 


  
  1. using UnityEngine;
  2. using UnityEditor;
  3. using UnityEngine.Audio;
  4. using UnityEditor.AnimatedValues;
  5. [CustomEditor(typeof(AudioDatabase))]
  6. public class AudioDatabaseEditor : Editor
  7. {
  8. private AudioDatabase database;
  9. private AnimBool foldout;
  10. private void OnEnable()
  11. {
  12. database = target as AudioDatabase;
  13. foldout = new AnimBool(false, Repaint);
  14. }
  15. public override void OnInspectorGUI()
  16. {
  17. //音频库名称
  18. var newDatabaseName = EditorGUILayout.TextField("Database Name", database.databaseName);
  19. if (newDatabaseName != database.databaseName)
  20. {
  21. Undo.RecordObject(database, "Name");
  22. database.databaseName = newDatabaseName;
  23. EditorUtility.SetDirty(database);
  24. }
  25. //音频库输出混音器
  26. var newOutputAudioMixerGroup = EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) as AudioMixerGroup;
  27. if (newOutputAudioMixerGroup != database.outputAudioMixerGroup)
  28. {
  29. Undo.RecordObject(database, "Output");
  30. database.outputAudioMixerGroup = newOutputAudioMixerGroup;
  31. EditorUtility.SetDirty(database);
  32. }
  33. //音频数据折叠栏 使用AnimBool实现动画效果
  34. foldout.target = EditorGUILayout.Foldout(foldout.target, "Datasets");
  35. if (EditorGUILayout.BeginFadeGroup(foldout.faded))
  36. {
  37. for (int i = 0; i < database.datasets.Count; i++)
  38. {
  39. var data = database.datasets[i];
  40. //水平布局
  41. GUILayout.BeginHorizontal();
  42. GUILayout.EndHorizontal();
  43. }
  44. }
  45. EditorGUILayout.EndFadeGroup();
  46. }
  47. }

        音频图标使用的是Unity中内置的图标,如何查看Unity中的内置图标在如下链接的博客中有介绍:Unity编辑器开发之GUIIcon 有了图标的名称后,通过EditorGUIUtility类中的IconContent方法进行实现:


  
  1. //绘制音频图标
  2. GUILayout.Label(EditorGUIUtility.IconContent("SceneViewAudio"), GUILayout.Width(20f));

        音频数据的名称为string类型字段,也通过TextField进行实现:


  
  1. //音频数据名称
  2. var newName = EditorGUILayout.TextField(data.name, GUILayout.Width(120f));
  3. if (newName != data.name)
  4. {
  5. Undo.RecordObject(database, "Data Name");
  6. data.name = newName;
  7. EditorUtility.SetDirty(database);
  8. }

        添加Button按钮,点击该按钮后,使用EditorGUIUtility类中的PingObject方法定位该项数据中的音频资源,绘制按钮时使用不同颜色来区分当前项是否为选中的音频数据项,声明一个int类型字段currentIndex,用于表示当前选中项的索引值


  
  1. //使用音频名称绘制Button按钮 点击后使用PingObject方法定位该音频资源
  2. Color colorCache = GUI.color;
  3. GUI.color = currentIndex == i ? Color.cyan : colorCache;
  4. if (GUILayout.Button(data.clip != null ? data.clip.name : "Null"))
  5. {
  6. currentIndex = i;
  7. EditorGUIUtility.PingObject(data.clip);
  8. }
  9. GUI.color = colorCache;

        播放进度和音频时长均为float类型,我们需要一个将时长转化为00:00时间格式的方法,代码如下:


  
  1. //将秒数转换为00:00时间格式字符串
  2. private string ToTimeFormat(float time)
  3. {
  4. int seconds = (int)time;
  5. int minutes = seconds / 60;
  6. seconds %= 60;
  7. return string.Format("{0:D2}:{1:D2}", minutes, seconds);
  8. }

        播放、停止播放及删除按钮的图标用的也均是Unity中的内置图标,分别为PlayButton、PauseButton和Toolbar Minus:


  
  1. //播放按钮
  2. if (GUILayout.Button(EditorGUIUtility.IconContent("PlayButton"), GUILayout.Width(20f)))
  3. {
  4. }
  5. //停止播放按钮
  6. if (GUILayout.Button(EditorGUIUtility.IconContent("PauseButton"), GUILayout.Width(20f)))
  7. {
  8. }
  9. //删除按钮 点击后删除该项音频数据
  10. if (GUILayout.Button(EditorGUIUtility.IconContent("Toolbar Minus"), GUILayout.Width(20f)))
  11. {
  12. }

        我们声明一个字典来存储当前正在播放的音频项,点击播放按钮时,创建一个带有Audio Source组件的物体并用其播放,将其添加到字典中,点击停止播放按钮时,将其从字典移除,并销毁物体,点击删除按钮时,也要判断该项如果正在播放,先要进行移除和销毁,再删除该音频数据项:

private Dictionary<AudioData, AudioSource> players;
 

  
  1. //播放按钮
  2. if (GUILayout.Button(EditorGUIUtility.IconContent("PlayButton"), GUILayout.Width(20f)))
  3. {
  4. if (!players.ContainsKey(data))
  5. {
  6. //创建一个物体并添加AudioSource组件
  7. var source = EditorUtility.CreateGameObjectWithHideFlags("Audio Player", HideFlags.HideAndDontSave).AddComponent<AudioSource>();
  8. source.clip = data.clip;
  9. source.outputAudioMixerGroup = database.outputAudioMixerGroup;
  10. source.Play();
  11. players.Add(data, source);
  12. }
  13. }
  14. //停止播放按钮
  15. if (GUILayout.Button(EditorGUIUtility.IconContent("PauseButton"), GUILayout.Width(20f)))
  16. {
  17. if (players.ContainsKey(data))
  18. {
  19. DestroyImmediate(players[data].gameObject);
  20. players.Remove(data);
  21. }
  22. }
  23. //删除按钮 点击后删除该项音频数据
  24. if (GUILayout.Button(EditorGUIUtility.IconContent("Toolbar Minus"), GUILayout.Width(20f)))
  25. {
  26. Undo.RecordObject(database, "Delete");
  27. database.datasets.Remove(data);
  28. if (players.ContainsKey(data))
  29. {
  30. DestroyImmediate(players[data].gameObject);
  31. players.Remove(data);
  32. }
  33. EditorUtility.SetDirty(database);
  34. Repaint();
  35. }

        最后绘制一个矩形区域,当拖拽AudioClip资源到该区域时,添加音频数据项,使用DragAndDrop类来实现:


  
  1. //以下代码块中绘制了一个矩形区域,将AudioClip资产拖到该区域则添加一项音频数据
  2. GUILayout.BeginHorizontal();
  3. {
  4. GUILayout.Label(GUIContent.none, GUILayout.ExpandWidth(true));
  5. Rect lastRect = GUILayoutUtility.GetLastRect();
  6. var dropRect = new Rect(lastRect.x + 2f, lastRect.y - 2f, 120f, 20f);
  7. bool containsMouse = dropRect.Contains(Event.current.mousePosition);
  8. if (containsMouse)
  9. {
  10. switch (Event.current.type)
  11. {
  12. case EventType.DragUpdated:
  13. bool containsAudioClip = DragAndDrop.objectReferences.OfType<AudioClip>().Any();
  14. DragAndDrop.visualMode = containsAudioClip ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected;
  15. Event.current.Use();
  16. Repaint();
  17. break;
  18. case EventType.DragPerform:
  19. IEnumerable<AudioClip> audioClips = DragAndDrop.objectReferences.OfType<AudioClip>();
  20. foreach (var audioClip in audioClips)
  21. {
  22. if (database.datasets.Find(m => m.clip == audioClip) == null)
  23. {
  24. Undo.RecordObject(database, "Add");
  25. database.datasets.Add(new AudioData() { name = audioClip.name, clip = audioClip });
  26. EditorUtility.SetDirty(database);
  27. }
  28. }
  29. Event.current.Use();
  30. Repaint();
  31. break;
  32. }
  33. }
  34. Color color = GUI.color;
  35. GUI.color = new Color(GUI.color.r, GUI.color.g, GUI.color.b, containsMouse ? .9f : .5f);
  36. GUI.Box(dropRect, "Drop AudioClips Here", new GUIStyle(GUI.skin.box) { fontSize = 10 });
  37. GUI.color = color;
  38. }
  39. GUILayout.EndHorizontal();

最终代码:


  
  1. using UnityEngine;
  2. using UnityEditor;
  3. using System.Linq;
  4. using UnityEngine.Audio;
  5. using System.Collections.Generic;
  6. using UnityEditor.AnimatedValues;
  7. [CustomEditor(typeof(AudioDatabase))]
  8. public class AudioDatabaseEditor : Editor
  9. {
  10. private AudioDatabase database;
  11. private AnimBool foldout;
  12. private int currentIndex = -1;
  13. private Dictionary<AudioData, AudioSource> players;
  14. private void OnEnable()
  15. {
  16. database = target as AudioDatabase;
  17. foldout = new AnimBool(false, Repaint);
  18. players = new Dictionary<AudioData, AudioSource>();
  19. EditorApplication.update += Update;
  20. }
  21. private void OnDestroy()
  22. {
  23. EditorApplication.update -= Update;
  24. foreach (var player in players)
  25. {
  26. DestroyImmediate(player.Value.gameObject);
  27. }
  28. players.Clear();
  29. }
  30. private void Update()
  31. {
  32. Repaint();
  33. foreach (var player in players)
  34. {
  35. if (!player.Value.isPlaying)
  36. {
  37. DestroyImmediate(player.Value.gameObject);
  38. players.Remove(player.Key);
  39. break;
  40. }
  41. }
  42. }
  43. public override void OnInspectorGUI()
  44. {
  45. //音频库名称
  46. var newDatabaseName = EditorGUILayout.TextField("Database Name", database.databaseName);
  47. if (newDatabaseName != database.databaseName)
  48. {
  49. Undo.RecordObject(database, "Name");
  50. database.databaseName = newDatabaseName;
  51. EditorUtility.SetDirty(database);
  52. }
  53. //音频库输出混音器
  54. var newOutputAudioMixerGroup = EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) as AudioMixerGroup;
  55. if (newOutputAudioMixerGroup != database.outputAudioMixerGroup)
  56. {
  57. Undo.RecordObject(database, "Output");
  58. database.outputAudioMixerGroup = newOutputAudioMixerGroup;
  59. EditorUtility.SetDirty(database);
  60. }
  61. //音频数据折叠栏 使用AnimBool实现动画效果
  62. foldout.target = EditorGUILayout.Foldout(foldout.target, "Datasets");
  63. if (EditorGUILayout.BeginFadeGroup(foldout.faded))
  64. {
  65. for (int i = 0; i < database.datasets.Count; i++)
  66. {
  67. var data = database.datasets[i];
  68. GUILayout.BeginHorizontal();
  69. //绘制音频图标
  70. GUILayout.Label(EditorGUIUtility.IconContent("SceneViewAudio"), GUILayout.Width(20f));
  71. //音频数据名称
  72. var newName = EditorGUILayout.TextField(data.name, GUILayout.Width(120f));
  73. if (newName != data.name)
  74. {
  75. Undo.RecordObject(database, "Data Name");
  76. data.name = newName;
  77. EditorUtility.SetDirty(database);
  78. }
  79. //使用音频名称绘制Button按钮 点击后使用PingObject方法定位该音频资源
  80. Color colorCache = GUI.color;
  81. GUI.color = currentIndex == i ? Color.cyan : colorCache;
  82. if (GUILayout.Button(data.clip != null ? data.clip.name : "Null"))
  83. {
  84. currentIndex = i;
  85. EditorGUIUtility.PingObject(data.clip);
  86. }
  87. GUI.color = colorCache;
  88. //若该音频正在播放 计算其播放进度
  89. string progress = players.ContainsKey(data) ? ToTimeFormat(players[data].time) : "00:00";
  90. GUI.color = new Color(GUI.color.r, GUI.color.g, GUI.color.b, players.ContainsKey(data) ? .9f : .5f);
  91. //显示信息:播放进度 / 音频时长 (00:00 / 00:00)
  92. GUILayout.Label($"({progress} / {(data.clip != null ? ToTimeFormat(data.clip.length) : "00:00")})",
  93. new GUIStyle(GUI.skin.label) { alignment = TextAnchor.LowerRight, fontSize = 8, fontStyle = FontStyle.Italic }, GUILayout.Width(60f));
  94. GUI.color = colorCache;
  95. //播放按钮
  96. if (GUILayout.Button(EditorGUIUtility.IconContent("PlayButton"), GUILayout.Width(20f)))
  97. {
  98. if (!players.ContainsKey(data))
  99. {
  100. //创建一个物体并添加AudioSource组件
  101. var source = EditorUtility.CreateGameObjectWithHideFlags("Audio Player", HideFlags.HideAndDontSave).AddComponent<AudioSource>();
  102. source.clip = data.clip;
  103. source.outputAudioMixerGroup = database.outputAudioMixerGroup;
  104. source.Play();
  105. players.Add(data, source);
  106. }
  107. }
  108. //停止播放按钮
  109. if (GUILayout.Button(EditorGUIUtility.IconContent("PauseButton"), GUILayout.Width(20f)))
  110. {
  111. if (players.ContainsKey(data))
  112. {
  113. DestroyImmediate(players[data].gameObject);
  114. players.Remove(data);
  115. }
  116. }
  117. //删除按钮 点击后删除该项音频数据
  118. if (GUILayout.Button(EditorGUIUtility.IconContent("Toolbar Minus"), GUILayout.Width(20f)))
  119. {
  120. Undo.RecordObject(database, "Delete");
  121. database.datasets.Remove(data);
  122. if (players.ContainsKey(data))
  123. {
  124. DestroyImmediate(players[data].gameObject);
  125. players.Remove(data);
  126. }
  127. EditorUtility.SetDirty(database);
  128. Repaint();
  129. }
  130. GUILayout.EndHorizontal();
  131. }
  132. EditorGUILayout.Space();
  133. //以下代码块中绘制了一个矩形区域,将AudioClip资产拖到该区域则添加一项音频数据
  134. GUILayout.BeginHorizontal();
  135. {
  136. GUILayout.Label(GUIContent.none, GUILayout.ExpandWidth(true));
  137. Rect lastRect = GUILayoutUtility.GetLastRect();
  138. var dropRect = new Rect(lastRect.x + 2f, lastRect.y - 2f, 120f, 20f);
  139. bool containsMouse = dropRect.Contains(Event.current.mousePosition);
  140. if (containsMouse)
  141. {
  142. switch (Event.current.type)
  143. {
  144. case EventType.DragUpdated:
  145. bool containsAudioClip = DragAndDrop.objectReferences.OfType<AudioClip>().Any();
  146. DragAndDrop.visualMode = containsAudioClip ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected;
  147. Event.current.Use();
  148. Repaint();
  149. break;
  150. case EventType.DragPerform:
  151. IEnumerable<AudioClip> audioClips = DragAndDrop.objectReferences.OfType<AudioClip>();
  152. foreach (var audioClip in audioClips)
  153. {
  154. if (database.datasets.Find(m => m.clip == audioClip) == null)
  155. {
  156. Undo.RecordObject(database, "Add");
  157. database.datasets.Add(new AudioData() { name = audioClip.name, clip = audioClip });
  158. EditorUtility.SetDirty(database);
  159. }
  160. }
  161. Event.current.Use();
  162. Repaint();
  163. break;
  164. }
  165. }
  166. Color color = GUI.color;
  167. GUI.color = new Color(GUI.color.r, GUI.color.g, GUI.color.b, containsMouse ? .9f : .5f);
  168. GUI.Box(dropRect, "Drop AudioClips Here", new GUIStyle(GUI.skin.box) { fontSize = 10 });
  169. GUI.color = color;
  170. }
  171. GUILayout.EndHorizontal();
  172. }
  173. EditorGUILayout.EndFadeGroup();
  174. serializedObject.ApplyModifiedProperties();
  175. }
  176. //将秒数转换为00:00时间格式字符串
  177. private string ToTimeFormat(float time)
  178. {
  179. int seconds = (int)time;
  180. int minutes = seconds / 60;
  181. seconds %= 60;
  182. return string.Format("{0:D2}:{1:D2}", minutes, seconds);
  183. }
  184. }

文章来源: coderz.blog.csdn.net,作者:CoderZ1010,版权归原作者所有,如需转载,请联系作者。

原文链接:coderz.blog.csdn.net/article/details/122511478

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。