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

2022-08-29 16:28:17 浏览数 (1)

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

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

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

代码语言:javascript复制
/// <summary>
    /// 音频库
    /// </summary>
    [CreateAssetMenu(fileName = "New Audio Database", order = 215)]
    public class AudioDatabase : ScriptableObject
    {
        /// <summary>
        /// 音频库名称
        /// </summary>
        public string databaseName;
        /// <summary>
        /// 输出混音器组
        /// </summary>
        public AudioMixerGroup outputAudioMixerGroup;
        /// <summary>
        /// 音频数据列表
        /// </summary>
        public List<AudioData> datasets = new List<AudioData>(0);
    }

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

代码语言:javascript复制
using System;
using UnityEngine;

namespace SK.Framework
{
    /// <summary>
    /// 音频数据
    /// </summary>
    [Serializable]
    public class AudioData
    {
        public string name;

        public AudioClip clip;
    }
}

该编辑器的布局结构:

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

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

代码语言:javascript复制
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(AudioDatabase))]
public class AudioDatabaseEditor : Editor
{
    private AudioDatabase database;

    private void OnEnable()
    {
        database = target as AudioDatabase;
    }

    public override void OnInspectorGUI()
    {
        //音频库名称
        var newDatabaseName = 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的类型即可:

代码语言:javascript复制
var newOutputAudioMixerGroup = EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) as AudioMixerGroup;
if (newOutputAudioMixerGroup != database.outputAudioMixerGroup)
{
    Undo.RecordObject(database, "Output");
    database.outputAudioMixerGroup = newOutputAudioMixerGroup;
    EditorUtility.SetDirty(database);
}

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

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

代码语言:javascript复制
using UnityEngine;
using UnityEditor;
using UnityEngine.Audio;
using UnityEditor.AnimatedValues;

[CustomEditor(typeof(AudioDatabase))]
public class AudioDatabaseEditor : Editor
{
    private AudioDatabase database;
    private AnimBool foldout;

    private void OnEnable()
    {
        database = target as AudioDatabase;
        foldout = new AnimBool(false, Repaint);
    }

    public override void OnInspectorGUI()
    {
        //音频库名称
        var newDatabaseName = EditorGUILayout.TextField("Database Name", database.databaseName);
        if (newDatabaseName != database.databaseName)
        {
            Undo.RecordObject(database, "Name");
            database.databaseName = newDatabaseName;
            EditorUtility.SetDirty(database);
        }

        //音频库输出混音器
        var newOutputAudioMixerGroup = EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) as AudioMixerGroup;
        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 (int i = 0; i < database.datasets.Count; i  )
            {
                var data = database.datasets[i];
                //水平布局
                GUILayout.BeginHorizontal();

                GUILayout.EndHorizontal();
            }
        }
        EditorGUILayout.EndFadeGroup();
    }
}

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

代码语言:javascript复制
//绘制音频图标
GUILayout.Label(EditorGUIUtility.IconContent("SceneViewAudio"), GUILayout.Width(20f));

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

代码语言:javascript复制
//音频数据名称
var newName = 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,用于表示当前选中项的索引值

代码语言:javascript复制
//使用音频名称绘制Button按钮 点击后使用PingObject方法定位该音频资源
Color colorCache = 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时间格式的方法,代码如下:

代码语言:javascript复制
//将秒数转换为00:00时间格式字符串
private string ToTimeFormat(float time)
{
    int seconds = (int)time;
    int minutes = seconds / 60;
    seconds %= 60;
    return string.Format("{0:D2}:{1:D2}", minutes, seconds);
}

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

代码语言:javascript复制
//播放按钮
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组件的物体并用其播放,将其添加到字典中,点击停止播放按钮时,将其从字典移除,并销毁物体,点击删除按钮时,也要判断该项如果正在播放,先要进行移除和销毁,再删除该音频数据项:

代码语言:javascript复制
private Dictionary<AudioData, AudioSource> players;
代码语言:javascript复制
//播放按钮
if (GUILayout.Button(EditorGUIUtility.IconContent("PlayButton"), GUILayout.Width(20f)))
{
    if (!players.ContainsKey(data))
    {
        //创建一个物体并添加AudioSource组件 
        var source = 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类来实现:

代码语言:javascript复制
//以下代码块中绘制了一个矩形区域,将AudioClip资产拖到该区域则添加一项音频数据
GUILayout.BeginHorizontal();
{
    GUILayout.Label(GUIContent.none, GUILayout.ExpandWidth(true));
    Rect lastRect = GUILayoutUtility.GetLastRect();
    var dropRect = new Rect(lastRect.x   2f, lastRect.y - 2f, 120f, 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:
                IEnumerable<AudioClip> audioClips = DragAndDrop.objectReferences.OfType<AudioClip>();
                foreach (var audioClip in audioClips)
                {
                    if (database.datasets.Find(m => m.clip == audioClip) == null)
                    {
                        Undo.RecordObject(database, "Add");
                        database.datasets.Add(new AudioData() { name = audioClip.name, clip = audioClip });
                        EditorUtility.SetDirty(database);
                    }
                }
                Event.current.Use();
                Repaint();
                break;
        }
    }
    Color color = GUI.color;
    GUI.color = new Color(GUI.color.r, GUI.color.g, GUI.color.b, containsMouse ? .9f : .5f);
    GUI.Box(dropRect, "Drop AudioClips Here", new GUIStyle(GUI.skin.box) { fontSize = 10 });
    GUI.color = color;
}
GUILayout.EndHorizontal();

最终代码:

代码语言:javascript复制
using UnityEngine;
using UnityEditor;
using System.Linq;
using UnityEngine.Audio;
using System.Collections.Generic;
using UnityEditor.AnimatedValues;

[CustomEditor(typeof(AudioDatabase))]
public class AudioDatabaseEditor : Editor
{
    private AudioDatabase database;
    private AnimBool foldout;
    private int currentIndex = -1;
    private Dictionary<AudioData, AudioSource> players;

    private void OnEnable()
    {
        database = target as AudioDatabase;
        foldout = new AnimBool(false, Repaint);
        players = new Dictionary<AudioData, AudioSource>();
        EditorApplication.update  = Update;
    }
    private void OnDestroy()
    {
        EditorApplication.update -= Update;
        foreach (var player in players)
        {
            DestroyImmediate(player.Value.gameObject);
        }
        players.Clear();
    }
    private void Update()
    {
        Repaint();
        foreach (var player in players)
        {
            if (!player.Value.isPlaying)
            {
                DestroyImmediate(player.Value.gameObject);
                players.Remove(player.Key);
                break;
            }
        }
    }
    public override void OnInspectorGUI()
    {
        //音频库名称
        var newDatabaseName = EditorGUILayout.TextField("Database Name", database.databaseName);
        if (newDatabaseName != database.databaseName)
        {
            Undo.RecordObject(database, "Name");
            database.databaseName = newDatabaseName;
            EditorUtility.SetDirty(database);
        }

        //音频库输出混音器
        var newOutputAudioMixerGroup = EditorGUILayout.ObjectField("Output Audio Mixer Group", database.outputAudioMixerGroup, typeof(AudioMixerGroup), false) as AudioMixerGroup;
        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 (int i = 0; i < database.datasets.Count; i  )
            {
                var data = database.datasets[i];
                GUILayout.BeginHorizontal();
                //绘制音频图标
                GUILayout.Label(EditorGUIUtility.IconContent("SceneViewAudio"), GUILayout.Width(20f));

                //音频数据名称
                var newName = EditorGUILayout.TextField(data.name, GUILayout.Width(120f));
                if (newName != data.name)
                {
                    Undo.RecordObject(database, "Data Name");
                    data.name = newName;
                    EditorUtility.SetDirty(database);
                }
                //使用音频名称绘制Button按钮 点击后使用PingObject方法定位该音频资源
                Color colorCache = 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;

                //若该音频正在播放 计算其播放进度 
                string progress = players.ContainsKey(data) ? ToTimeFormat(players[data].time) : "00:00";
                GUI.color = new Color(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")})",
                    new GUIStyle(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组件 
                        var source = 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));
                Rect lastRect = GUILayoutUtility.GetLastRect();
                var dropRect = new Rect(lastRect.x   2f, lastRect.y - 2f, 120f, 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:
                            IEnumerable<AudioClip> audioClips = DragAndDrop.objectReferences.OfType<AudioClip>();
                            foreach (var audioClip in audioClips)
                            {
                                if (database.datasets.Find(m => m.clip == audioClip) == null)
                                {
                                    Undo.RecordObject(database, "Add");
                                    database.datasets.Add(new AudioData() { name = audioClip.name, clip = audioClip });
                                    EditorUtility.SetDirty(database);
                                }
                            }
                            Event.current.Use();
                            Repaint();
                            break;
                    }
                }
                Color color = GUI.color;
                GUI.color = new Color(GUI.color.r, GUI.color.g, GUI.color.b, containsMouse ? .9f : .5f);
                GUI.Box(dropRect, "Drop AudioClips Here", new GUIStyle(GUI.skin.box) { fontSize = 10 });
                GUI.color = color;
            }
            GUILayout.EndHorizontal();
        }
        EditorGUILayout.EndFadeGroup();
        serializedObject.ApplyModifiedProperties();
    }

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

0 人点赞