【Unity】框架设计(三) Odin编辑器窗口扩展,Asset资源的创建和管理(脚本文件创建、预制体、System.IO、AssetDatabase、Selection)

前言

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

  • 5
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值