赞
踩
当游戏规模开始大时,为了制作游戏后期的维护性,就可以考虑做资源管理和编辑器扩展了。一是可以集成一些制作流程,省去一些重复操作的步骤,二是更方便项目数据的规范和管理性。今天来分享一下如何在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); }
接下来如何创建资源了,一般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(); } }
项目有时候会遇到需要读取某一目录下所有资源,用于加载一些内容。一般可以用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_EDITOR var 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,就不会写那么复杂。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。