前言
当游戏规模开始大时,为了制作游戏后期的维护性,就可以考虑做资源管理和编辑器扩展了。一是可以集成一些制作流程,省去一些重复操作的步骤,二是更方便项目数据的规范和管理性。今天来分享一下如何在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_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,就不会写那么复杂。