前言
当游戏规模开始大时,为了制作游戏后期的维护性,就可以考虑做资源管理和编辑器扩展了。一是可以集成一些制作流程,省去一些重复操作的步骤,二是更方便项目数据的规范和管理性。今天来分享一下如何在unity中做编辑器窗口的拓展,并实现一些简单的功能。例如根据模板自动创建脚本(System.IO)、创建预制体(AssetDatabase)、读取指定文件夹下的资源、根据鼠标选中的资源批量创建ScriptableObject等(Selection)。
实现效果如下图:
功能实现
因为本期所有内容均是在Unity编辑器内的内容,在游戏运行或者打包出来时并不起到作用,因此本期的脚本建议都放在项目Assets/Editor文件夹中,或者使用如下的编辑器宏定义,让打包时不再将这些内容添加到实际的包中。(有些代码只在编辑器模式下有效,打包时会报错)
#if UNITY_EDITOR//该部分代码只在编辑器模式下生效#endif
本期用到了一些Odin插件的比较方便的特性(Attritube),如果有更进一步的兴趣可以去看看其他教程或者官方文档。
编辑器窗口的拓展
在Unity内置的GUI中,我们可以使用新建一个脚本类,继承EditorWindow的方法,通过MenuItem的属性表示一个静态方法,实现打开编辑器窗口的效果。
using UnityEditor;using UnityEngine;public class FlowChartEdit : EditorWindow{//菜单栏顶部显示目录[MenuItem("FlowChart/FlowChart")]public static void OpenWindow(){FlowChartEdit wnd = GetWindow<FlowChartEdit>();wnd.titleContent = new GUIContent("FlowChart");}}
Unity GUI中,如果想要绘制各种属性需要比较繁琐的步骤,Odin插件为我们的编辑器窗口实现了更方便的属性,我们修改类继承自OdinWindow,下面展示一个简单的功能。
public class OdinWindowTest:OdinEditorWindow{[MenuItem("Tools/OdinWindowTest")]public static void ShowWindow(){var window = GetWindow<OdinWindowTest>();window.Show();}[LabelText("学生姓名")] public string StudentName;[LabelText("英文成绩")] public float EnglishScore;[LabelText("数学成绩")] public float MathScore;[LabelText("美术成绩")] public float ArtScore;[ReadOnly,LabelText("总成绩")] public float totalScore;[Button("计算总成绩",ButtonSizes.Large,Style =ButtonStyle.Box),]public void GetTotalScore(){totalScore = EnglishScore + MathScore + ArtScore;}}
如果想实现左右分栏,左边类似树状结构的窗口。我们也可以继承自OdinMenuEditorWindow,通过重载BuildMenuTree()函数去实现它。下面演示的脚本为,通过在窗口中添加某个文件夹下所有的ScriptableObject。
using UnityEditor;using Sirenix.OdinInspector.Editor;using Sirenix.Utilities.Editor;using UnityEngine;using Sirenix.Utilities;public class OdinConfigWindow : OdinMenuEditorWindow{[MenuItem("Sugarzo/项目配置设置")]private static void OpenWindow(){var window = GetWindow<OdinConfigWindow>();window.position = GUIHelper.GetEditorWindowRect().AlignCenter(720, 720);window.titleContent = new GUIContent("项目配置设置");}protected override OdinMenuTree BuildMenuTree(){var tree = new OdinMenuTree();//这里的第一个参数为窗口名字,第二个参数为指定目录,第三个参数为需要什么类型,第四个参数为是否在家该文件夹下的子文件夹tree.AddAllAssetsAtPath("项目配置设置", "Assets/SugarFrame/Configs", typeof(ScriptableObject), true);return tree;}}
指定文件下下的内容的ScriptableObject
打开编辑器窗口后,可以看到该文件夹下的内容已被显示在Odin窗口中。
根据模板文件生成脚本
当我们写好了基类的基本功能,后续扩展功能时只需要继承这个基类。如果我们每次想要新建一个类,都需要新建一个C#类,然后手动修改名字,修改继承关系,写出overrive需要拓展的功能的方法字段,就会比较麻烦。回想一下Unity给我们新建Monobehaviour脚本时,会默认写好一个基本模板,里面已经有了Start()方法和Update()可以直接写逻辑。这里我们也实现一个根据模板创建cs文件的方法。
首先我们已经先建立一个txt文件,里面写好我们需要的默认模板(里面的#TTT#是用来替换的,也可以换成其他标识符)
如何创建一个脚本文件呢,其实借助System.IO功能很简单,大体就是先知道路径,File.Create创建文件
,写入字节流就搞定了。以下是核心代码
//选择的文件路径,因为是脚本文件,这里需要后缀带有.cs;string filepath = sfd.file; Debug.Log("保存 " + filepath);var fStream = File.Create(filepath);//template为已经设计好的string对象,将里面的内容全部写入文件var bytes = System.Text.Encoding.UTF8.GetBytes(template);fStream.Write(bytes, 0, bytes.Length);fStream.Close();
我们可以用TextAsset保存文本文件,[FolderPath]特性指定需要的文件夹。修改一下内容就可以直接写入了。这里我们做的扩展一点,可以打开电脑的文件管理文件夹自定义把内容放在什么地方。拿下面的窗口来举例。
当我们按下【CreateScript】按钮后,打开资源管理文件夹:
点击保存后,就可以将Code窗口里的代码保存在选中的路径上了。
源码如下,注意当修改了项目资源后,最好使用AssetDatabase.Refresh()将项目刷新一遍
using Sirenix.OdinInspector;using UnityEditor;using UnityEngine;[CreateAssetMenu(fileName = "编辑器拓展/状态机设置")]public class StatusExtraTool : ScriptableObject{public enum CreateType{新建Trigger,新建Action,}public TextAsset actionScriptText;public TextAsset triggerScriptText;[Space][BoxGroup, EnumToggleButtons,HideLabel]public CreateType createType;[BoxGroup,LabelText("脚本名")]public string title;[Button,BoxGroup]public void CreateScript(){if(createType == CreateType.新建Trigger && !title.Contains("Trigger")){Debug.Log("脚本名需要以Trigger为后缀");return;}if (createType == CreateType.新建Action && !title.Contains("Action")){Debug.Log("脚本名需要以Action为后缀");return;}//将路径和需要新建的文本传入,打开资源管理文件夹FileManager.SaveScriptFile(title, Code);//重载资源AssetDatabase.Refresh();}[TextArea(20,30),ReadOnly]public string Code;private void OnValidate(){//替换上文提到的#TTT#if(triggerScriptText && createType == CreateType.新建Trigger){Code = triggerScriptText.ToString().Replace("#TTT#", title);}else if (actionScriptText && createType == CreateType.新建Action){Code = actionScriptText.ToString().Replace("#TTT#", title);}else{Code = "缺少脚本的模板文件";}}}
这里的打开文件管理窗口的代码,引入了系统目录的Comdlg32.dll(没读懂没事,复制粘贴能用就行
using UnityEngine;using System;using System.Runtime.InteropServices;using System.IO;using UnityEditor;using System.Collections.Generic;public static class FileManager{public static void OpenFile(){OpenFileDlg ofd = new OpenFileDlg();ofd.structSize = Marshal.SizeOf(ofd);ofd.filter = "txt files\0*.txt\0All Files\0*.*\0\0";ofd.file = new string(new char[256]);ofd.maxFile = ofd.file.Length;ofd.fileTitle = new string(new char[64]);ofd.maxFileTitle = ofd.fileTitle.Length;ofd.initialDir = Application.dataPath; //默认路径ofd.title = "打开文件";ofd.defExt = "txt";ofd.flags = 0x00080000 | 0x00001000 | 0x00000800 | 0x00000200 | 0x00000008;if (OpenFileDialog.GetOpenFileName(ofd)){string filepath = ofd.file; //选择的文件路径; Debug.Log("打开 " + filepath);}}public static void SaveFile(){SaveFileDlg sfd = new SaveFileDlg();sfd.structSize = Marshal.SizeOf(sfd);sfd.filter = "txt files\0*.txt\0All Files\0*.*\0\0";sfd.file = new string(new char[256]);sfd.maxFile = sfd.file.Length;sfd.fileTitle = new string(new char[64]);sfd.maxFileTitle = sfd.fileTitle.Length;sfd.initialDir = Application.dataPath; //默认路径sfd.title = "保存文件";sfd.defExt = "txt";sfd.flags = 0x00080000 | 0x00001000 | 0x00000800 | 0x00000200 | 0x00000008;if (SaveFileDialog.GetSaveFileName(sfd)){string filepath = sfd.file; //选择的文件路径;Debug.Log("保存 " + filepath);}}//保存脚本文件public static void SaveScriptFile(string fileTitle,string template,string defaultFolderPath = ""){SaveFileDlg sfd = new SaveFileDlg();sfd.structSize = Marshal.SizeOf(sfd);sfd.filter = "cs files\0*.cs\0All Files\0*.*\0\0";sfd.file = new string(new char[256]);sfd.maxFile = sfd.file.Length;sfd.fileTitle = new string(new char[64]);sfd.maxFileTitle = sfd.fileTitle.Length;sfd.initialDir = Application.dataPath; //默认路径sfd.title = "保存文件";sfd.defExt = "txt";sfd.flags = 0x00080000 | 0x00001000 | 0x00000800 | 0x00000200 | 0x00000008;sfd.file = new string(fileTitle);if (SaveFileDialog.GetSaveFileName(sfd)){string filepath = sfd.file; //选择的文件路径;Debug.Log("保存 " + filepath);var fStream = File.Create(filepath);var bytes = System.Text.Encoding.UTF8.GetBytes(template);fStream.Write(bytes, 0, bytes.Length);fStream.Close();}}}[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]public class FileDlog{public int structSize = 0;public IntPtr dlgOwner = IntPtr.Zero;public IntPtr instance = IntPtr.Zero;public String filter = null;public String customFilter = null;public int maxCustFilter = 0;public int filterIndex = 0;public String file = null;public int maxFile = 0;public String fileTitle = null;public int maxFileTitle = 0;public String initialDir = null;public String title = null;public int flags = 0;public short fileOffset = 0;public short fileExtension = 0;public String defExt = null;public IntPtr custData = IntPtr.Zero;public IntPtr hook = IntPtr.Zero;public String templateName = null;public IntPtr reservedPtr = IntPtr.Zero;public int reservedInt = 0;public int flagsEx = 0;}[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]public class OpenFileDlg : FileDlog{}public class OpenFileDialog{[DllImport("Comdlg32.dll", SetLastError = true, ThrowOnUnmappableChar = true, CharSet = CharSet.Auto)]public static extern bool GetOpenFileName([In, Out] OpenFileDlg ofn);}[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]public class SaveFileDlg : FileDlog{}public class SaveFileDialog{[DllImport("Comdlg32.dll", SetLastError = true, ThrowOnUnmappableChar = true, CharSet = CharSet.Auto)]public static extern bool GetSaveFileName([In, Out] SaveFileDlg ofn);}
创建预制体和ScriptableObject
接下来如何创建资源了,一般Unity最常用的资源就是预制体和ScriptableObject了,我们先创建一个基类
using Sirenix.OdinInspector;using UnityEngine;public interface IAssetCreator{public void Create();}public abstract class BaseAssetCreator : ScriptableObject, IAssetCreator{[FolderPath]public string createPath;[Space]public string createFileName;[Button]public abstract void Create();protected bool IsEmptyVariable(){return string.IsNullOrEmpty(createPath) || string.IsNullOrEmpty(createFileName);}}
首先是新建预制体,使用PrefabUtility类的API可以保存
using UnityEditor;using UnityEngine;[CreateAssetMenu(menuName = "编辑器拓展/PrefabCreator")]public class PrefabCreator : BaseAssetCreator{public GameObject prototype;public override void Create(){if (IsEmptyVariable() || prototype == null)return;var newGo = Instantiate(prototype);PrefabUtility.SaveAsPrefabAsset(newGo, createPath + "/"+ createFileName + ".prefab");DestroyImmediate(newGo);AssetDatabase.Refresh();}}
ScriptableObject类,可以使用AssetDataBase(),注意方法结尾需要AssetDatabase.Refresh()一下。
using UnityEditor;using UnityEngine;public class ScriptableObjectCreatorT<T> : BaseAssetCreator where T : ScriptableObject{public override void Create(){if (IsEmptyVariable())return;var go = ScriptableObject.CreateInstance<T>();AssetDatabase.CreateAsset(go, createPath + "/" + createFileName + ".asset");AssetDatabase.SaveAssets();AssetDatabase.Refresh();}}
读取指定目录/鼠标选中下的Assets资源
项目有时候会遇到需要读取某一目录下所有资源,用于加载一些内容。一般可以用AssetDatabase.LoadAllAssetsAtPath或者Resources.LoadAll可以实现类似功能,这里用一种System.IO遍历+LoadAssetAtPath的方式去返回指定泛型的列表List。
例如这里我新建了很多对话,但还没有和目标配置文件同步。
设置好路径,按下按钮,可以看到该文件下文件已被同步
这里的对话状态窗口代码如下:
#if UNITY_EDITOR[Header("同步配置")][FolderPath]public string pfbPath;[Button]public void LoadPfb(){datas.Clear();var dialoguePfbs = FileHelper.GetFiles<DialogueData>(pfbPath);foreach (var pfb in dialoguePfbs){datas.Add(new Data(pfb));}Debug.Log("加载" + datas.Count + "个对话");}#endif
FileHelper是我们自己写的方法,代码如下
public static List<T> GetFiles<T>(string dir) where T : UnityEngine.Object{string path = string.Format(dir);var list = new List<T>();//获取指定路径下面的所有资源文件 if (Directory.Exists(path)){DirectoryInfo direction = new DirectoryInfo(path);FileInfo[] files = direction.GetFiles("*");for (int i = 0; i < files.Length; i++){//忽略关联文件if (files[i].Name.EndsWith(".meta")){continue;}#if UNITY_EDITORvar so = AssetDatabase.LoadAssetAtPath<T>(dir + "/" + files[i].Name);if (so != null){Debug.Log("加载资源" + files[i].Name);list.Add(so as T);}#endif}}return list;}
除了指定文件夹下的资源外,有时候我们可能需要知道鼠标选中的资源。例如在我们的框架设计中,音效资源被我们封装成了一个ScriptableObject。
public class AudioSo : ScriptableObject{[TextArea, LabelText("注释")]public string text;public AudioClip audioData;[LabelText("音轨选择")]public AudioMixerGroup outputGroup;[LabelText("音频相对音量"), Range(0, 1)]public float volume = 0.5f;[LabelText("是否循环播放")]public bool loop;public override string ToString(){return name;}}
但是有时候,如果导入了一批新的音效(AudioClip,或者说是mp3格式)需要添加进项目中,一个个新建ScrpitableObject手动设置肯定是很麻烦的,使用文件夹配置好像也不太方便,这时候最好是可以鼠标选中一批clip,然后根据选中的资源来生成对于的文件。
Unity项目中,对于导入进Asset文件夹的文件,都会默认分配一个meta元文件和GUID信息去标记这个资产和存储对应的信息。GUID是该资源的唯一标识号,可以通过AssetDatabase.GUIDToAssetPath由GUID获取资产的文件路径(当然也可以反过来通过路径或者UnityEngine.Object获取GUID号)。
我们可以使用Selection.assetGUIDs,来获取当前我们鼠标选中资源的所有GUID号,再计算出文件在项目中的目录位置,代码如下:
[Button("选择音效资源然后创建")]void CreateAudioSo(){//验证路径if (string.IsNullOrEmpty(audioSoPath))return;//选择音效资源然后点击创建foreach (var guiD in Selection.assetGUIDs){var path = AssetDatabase.GUIDToAssetPath(guiD);var audioClip = AssetDatabase.LoadAssetAtPath<AudioClip>(path);if (audioClip != null){//同步文件并保存var so = ScriptableObject.CreateInstance<AudioSo>();so.name = "AudioSo-" + audioClip.name;so.audioData = audioClip;AssetDatabase.CreateAsset(so, audioSoPath + "/" + so.name + ".asset");AssetDatabase.SaveAssets();AssetDatabase.Refresh();}}}
后记
这个月的面试,面试官问我:你的项目有用到什么技术亮点嘛介绍一下。然后自己分享了半天写的轮子(x)
不过对于个人来说,实际上写项目印象最深的就是初期写各个系统的时候吧。一是如何思考如何组织各个系统。软件工程的一大目标:高内聚低耦合,中间就要用到各种各样的设计模式。二是造出各种各样的工具,遇到重复的操作时想办法把这段逻辑抽象出来,然后复用,也是规范程序格式。(关于工具,最近在学习UI Toolkit和Graphview,想自己造一个可视化节点的事件触发器)。其实框架设计思想,最终目的都是为了方便项目的进一步扩展,优化制作流程管线。但如果只是写技术demo或者只是几天的gamejam,就不会写那么复杂。
【Unity】框架设计(三) Odin编辑器窗口扩展 Asset资源的创建和管理(脚本文件创建 预制体 System.IO AssetDatabase Selection)