Unity StoryLine基于Node节点的剧情编辑器

介绍

随着《完蛋我被美女包围了》等互动影视游戏的大火,短视频平台那些剧情节奏快一集一个反转的视频快速批量的产出,那么在游戏开发制作方面就必须跟上视频产出的速度,所以有一套剧情编辑器就可以加速和规范开发流程。

CleanShot 2024-08-31 at 11.25.49@2x CleanShot 2024-08-31 at 11.25.49@2x

设计思路

需求分析

需求如下:

  1. 支持显示视频和图片,可在图片或视频上叠加固定区域选项自定义位置选项QTE自定义位置按钮
  2. 视频播放完或者播放到某一时间会弹出选项,根据选项继续播放下一个视频或者图片,如果没有选项则自动播放
  3. 在播放下一个视频或图片之前,可以穿插小游戏玩法,比如视频播放完后进入一段打飞机游戏,小游戏失败可成功可进入不同的视频
  4. 叠加在视频或图片上的选项可配置 条件。条件用于判断是否显示选项,点击选项是否可触发。
  5. 在播放某个视频或者点击某个选项可触发自定义游戏内容,比如获取道具、获取成就等游戏业务逻辑。

具体分析:

  • 作为剧情编辑器,一目了然的看到剧情走向和各种剧情分支是极其重要的,所以首先就摒弃了纯参数填写的编辑器方式,把各种参数交给产品策划填写,太复杂填写容易出错不运行游戏根本发现不了,而且可读性极差。所以我选择了基于Unity中GraphView的Node节点的形式制作视频节点编辑器,官方的ShaderGraph就是节点编辑。

  • 决定好了在GraphView下用Node节点连线方式来做编辑器,那么首先根据需求拆分为 章节编辑器单元剧编辑器

    编辑器 说明
    章节编辑器 用于编辑故事分支走向
    单元剧编辑器 用于编辑每个故事节点的具体内容:播放什么视频图片,穿插小游戏等随意组合
  • 章节编辑器中则把节点分为:开始节点,结束节点,单元剧节点

    节点名称 说明
    开始节点 一个章节的开头,可能有具体的业务逻辑
    结束节点 一个章节的结束,用于链接下一章节或者有具体的业务逻辑
    单元剧节点 用于关联单元剧的
  • 单元剧编辑器咋把节点分为:开始节点、结束节点、视频节点、图片节点、事件节点、小游戏节点、跳转Jump节点

    节点名称 说明
    开始节点 用于单元剧的开头
    结束节点 单元剧结束,可有多个End节点,用于在章节编辑器中显示出口
    视频节点 游戏播放视频时的数据编辑,用于配置显示选项、视频资源等
    图片节点 游戏显示图片时的数据编辑,用于配置显示选项、视频资源等
    事件节点 用于连接StoryLine编辑器和正常开发的业务逻辑。比如一些获取道具成就啥的,不应该内置在编辑器中,而是由主程序根据实际情况拓展
    小游戏节点 主程序中实现小游戏接口即可在编辑器中选择小游戏。小游戏也是根据情况自行拓展的
    跳转Jump节点 在单元剧编辑器中的每个节点都有一个唯一id,那么Jump跳转节点既可以直接跳转到某个id的节点播放。目的是可能有一些特殊用途。
  • 在视频节点和图片节点中的选项里面可以添加条件,用于判断是否显示这个选项或者是否可以点击触发这个选项。那么这个条件的具体代码逻辑就不能内置在StoryLine包中,而是给Attribute特效,在主程序中给一个方法添加此特性,那么就可以在编辑器中选择这个条件方法。这样就可以连接编辑器和实际开发的业务需求了。

代码设计

有了大概的需求,那么就可以着手设计代码了。

CleanShot 2024-08-31 at 16.54.15

事件系统

为了让StoryLine编辑器相对独立且有一定的拓展性,需要让游戏开发中部分业务逻辑可以在编辑器中编辑,那么我们就可以通过事件分发来实现。在业务逻辑中写一个事件分发所需要的数据结构,在编辑器中编辑对应的数据结构,然后StoryLine的事件系统把这个数据分发出去让业务层监听执行即可。

比如业务层需要在播放完一个视频后获取一个道具,作为独立package 的StoryLine肯定不知道有这个业务,所以业务层只需编写一个获取道具所需的数据结构(道具id,道具数量),然后StoryLine的事件系统分发这个数据结构,让业务层执行获取道具的任务即可。这样就可以任意拓展了。

对于事件系统代码设计,我参考了知名框架ET中的EventSystem。

新增了两个Attribute,用于收集所有的EventStruct和Event要执行的代码逻辑。

  • EventAttribute:用于标记业务代码逻辑
  • StoryLineEventAttribute:用于标记数据结构
[AttributeUsage(AttributeTargets.Class)]
public class BaseAttribute: Attribute
{
}

public class EventAttribute: BaseAttribute
{
  public EventAttribute()
  {
  }
}

[AttributeUsage(AttributeTargets.Struct,AllowMultiple = true)]
public class StoryLineEventAttribute : Attribute
    {
        private long _eventId;
        public long eventId => _eventId;

        private string _eventName;
        public string eventName => _eventName;
        public StoryLineEventAttribute(long eventId,string eventName)
        {
            _eventId = eventId;
            _eventName = eventName;
        }
}

在CodeTypes.cs脚本中,收集上述的两个Attribute

public void Init(Assembly[] assemblies)
        {
            Dictionary<string, Type> addTypes = AssemblyHelper.GetAssemblyTypes(assemblies);
            foreach ((string fullName, Type type) in addTypes)
            {
                this.allTypes[fullName] = type;
                
                if (type.IsAbstract)
                {
                    continue;
                }
                
                // 记录所有的有BaseAttribute标记的的类型
                object[] objects = type.GetCustomAttributes(typeof(BaseAttribute), true);

                foreach (object o in objects)
                {
                    this.types.Add(o.GetType(), type);
                }
                
                object[] objectsStoryLineEventAttribute = type.GetCustomAttributes(typeof(StoryLineEventAttribute), true);
                foreach (object o in objectsStoryLineEventAttribute)
                {
                    this.types.Add(o.GetType(), type);
                }
            }
        }

最后在EventSystem.cs中分发即可。遍历数据结构T对应的所有业务逻辑片段,然后挨个执行Handle即可

 public void Publish<T>(T a) where T : struct
        {
            List<EventInfo> iEvents;
            if (!this.allEvents.TryGetValue(typeof (T), out iEvents))
            {
                return;
            }
            
            foreach (EventInfo eventInfo in iEvents)
            {
                
                if (!(eventInfo.IEvent is AEvent<T> aEvent))
                {
                    Debug.LogError($"event error: {eventInfo.IEvent.GetType().FullName}");
                    continue;
                }
                aEvent.Handle(a);
            }
        }

具体实现可以查看框架ET中的EventSystem。

数据结构

数据结构这边不多赘述,在章节编辑器中需要章节开始节点,章节结束节点,单元剧节点的数据。在单元剧编辑器中需要开始节点、结束节点、视频节点、图片节点、事件节点、小游戏节点、跳转Jump节点。

单元剧编辑器创建编辑的DialogueContainer的ScriptableObject对应了章节编辑器中单元剧节点的数据,章节编辑器本质就是创建编辑ChapterContainer的ScriptableObject。

编辑器

编辑器的开发中,我导入了Odin插件加速开发。

剧情编辑器

关于Node剧情编辑器,我从总体的构想上进行,实现一个可用的节点编辑器,而不是顺序性的实现一个简单的图。

下面以章节编辑器为示例。单元剧编辑大同小异,原理都是一样的。

GraphView

GraphView类是一个UIElements,继承于VisualElement,继承GraphView类以 创建自己的 UI 控件 ,GraphView提供的功能仅仅是提供图的一些基本功能。可以注册 添加/移除 节点/边 的事件,能获取到图的各种信息。

ISearchWindowProvider 接口

这个接口用于提供一个右键菜单,指明右键有哪些节点能被创建。

CleanShot 2024-08-31 at 18.23.19@2x
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;

namespace StoryLine.Editor
{
    public class ChapterNodeSearchWindow : ScriptableObject,ISearchWindowProvider
    {
        private EditorWindow _window;
        private ChapterGraphView _graphView;

        private Texture2D _indentationIcon;
        
        public void Configure(EditorWindow window,ChapterGraphView graphView)
        {
            _window = window;
            _graphView = graphView;
            
            //Transparent 1px indentation icon as a hack
            _indentationIcon = new Texture2D(1,1);
            _indentationIcon.SetPixel(0,0,new Color(0,0,0,0));
            _indentationIcon.Apply();
        }
        
        public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
        {
            var tree = new List<SearchTreeEntry>
            {
                //TODO:新建Node节点
                new SearchTreeGroupEntry(new GUIContent("Create Node"), 0),
                //new SearchTreeGroupEntry(new GUIContent("Dialogue"), 1),
                new SearchTreeEntry(new GUIContent("Chapter Start", _indentationIcon))
                {
                    level = 1, userData = new ChapterStartNode()
                },
                new SearchTreeEntry(new GUIContent("Chapter Node", _indentationIcon))
                {
                    level = 1, userData = new DialogueContainerNode()
                },
                new SearchTreeEntry(new GUIContent("Chapter End", _indentationIcon))
                {
                    level = 1, userData = new ChapterEndNode()
                },
            };

            return tree;
        }

        public bool OnSelectEntry(SearchTreeEntry SearchTreeEntry, SearchWindowContext context)
        {
            //Editor window-based mouse position
            var mousePosition = _window.rootVisualElement.ChangeCoordinatesTo(_window.rootVisualElement.parent,
                context.screenMousePosition - _window.position.position);
            var graphMousePosition = _graphView.contentViewContainer.WorldToLocal(mousePosition);
            string nodeFolder = $"{StoryLineSetting.Instance.UnitDramaPath}/{_graphView.ChapterContainerName}";
            switch (SearchTreeEntry.userData)
            {
                //TODO:新建Node节点
                case DialogueContainerNode dialogueContainerNode:
                    _graphView.CreateNewDialogueContainerNode(nodeFolder,graphMousePosition,null);
                    return true;
                case ChapterEndNode chapterEndNode:
                    _graphView.CreateNewChapterEndNode(nodeFolder,graphMousePosition,null);
                    return true;
                case ChapterStartNode chapterStartNode:
                    _graphView.CreateNewChapterStartNode(nodeFolder,graphMousePosition,null);
                    return true;
            }
            return false;
        }
    }
}

Node 类

我这里创建了一个基类继承了Node,后面每个节点需要实现我的StoryNodeBase类

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;

namespace StoryLine.Editor
{
    public abstract class StoryNodeBase : Node
    {
        public int GUID;
        public bool EntyPoint = false;
        public string NodePath;
        private StoryGraphView _graphView;
        public override void OnSelected()
        {    
            base.OnSelected();
        }

        public override void OnUnselected()
        {
            base.OnUnselected();
        }

        public override Port InstantiatePort(Orientation orientation, Direction direction, Port.Capacity capacity, Type type)
        {
            var port = StoryLineNodePort.Create(orientation, direction, capacity, type);
            if (direction.Equals(Direction.Input))
            {
                port.portColor = Color.green;
            }
            else
            {
                port.portColor = Color.blue;
            }

            return port;
        }
        
        public void RemoveConnectedEdges()
        {
            // Get all output ports from the output container
            var outputPorts = outputContainer.Children().OfType<Port>();

            // Create a list to store edges to remove
            List<Edge> edgesToRemove = new List<Edge>();

            // Loop through each output port
            foreach (var port in outputPorts)
            {
                // Add all connected edges to the list
                edgesToRemove.AddRange(port.connections);
            }

            // Remove each edge from the graph view
            foreach (var edge in edgesToRemove)
            {
                // Disconnect the ports
                edge.input.Disconnect(edge);
                edge.output.Disconnect(edge);

                // Remove the edge from its parent container
                edge.RemoveFromHierarchy();
            }
        }
    }
}

数据结构

我们存储每个图的全部节点信息,全部连接信息,视图,就能恢复到上一处的状态。

下面代码是节点的结构,节点需要有GUID作为关联,有在编辑器里的位置,以及唯一识别字,字段信息。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Serialization;

namespace StoryLine
{
    public class ChapterContainer  : ScriptableObject
    {
        public int ChapterId;
        public List<NodeLinkData> NodeLinks = new List<NodeLinkData>();
        public List<DialogueContainerNodeData> DialogueContainerNodeData = new List<DialogueContainerNodeData>();

        public List<ChapterEndNodeData> ChapterEndNodeData = new List<ChapterEndNodeData>();
        public ChapterStartNodeData ChapterStartNodeData;
        public Vector2 GetDataPosition(int targetNodeGUID)
        {
            var data = DialogueContainerNodeData.Find(x => x.NodeGUID == targetNodeGUID);
            if (data != null)
            {
                return data.Position;
            }

            var endData = ChapterEndNodeData.Find(x => x.NodeGUID == targetNodeGUID);
            if (endData != null)
            {
                return endData.Position;
            }
            return Vector2.zero;
        }
    }
}

下面是节点的基类,节点需要有GUID作为关联,有在编辑器里的位置,以及唯一识别字。其他字段信息则写在继承的子类中。

using Sirenix.OdinInspector;
using UnityEngine;

namespace StoryLine
{
    public abstract class ChapterNodeBaseData : ScriptableObject
    {
        [ReadOnly]
        public int NodeGUID;
        [ReadOnly]
        public Vector2 Position;

        public int NodeId;
    }
}

连线的LinkData

连接结构,这里需要存储从哪个节点来BaseNodeGUID,到哪个节点去TargetNodeGUID,从哪个端口来OutputIndex

using System;
using Sirenix.OdinInspector;
using UnityEngine.Serialization;

namespace StoryLine
{
    [Serializable]
    public class NodeLinkData
    {
        public int BaseNodeGUID;
        public int OutputIndex;
        public int TargetNodeGUID;
        public string PortName;

        /// <summary>
        /// 用于DialogueNode节点的 选项
        /// </summary>
        public int OutputId;
        public NodeLinkData Clone()
        {
            return (NodeLinkData)MemberwiseClone();
        }
    }
}

View

下面提供了创建节点、连接节点、节点增删事件、序列化行为等基本的功能。

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using StoryLine;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.UIElements;
using Button = UnityEngine.UIElements.Button;

namespace StoryLine.Editor
{
    public partial class ChapterGraphView : GraphView
    {
        public readonly Vector2 DefaultNodeSize = new Vector2(200, 150);
        public readonly Vector2 DefaultCommentBlockSize = new Vector2(300, 200);
        private ChapterNodeSearchWindow _searchWindow;
        
        public string ChapterContainerName
        {
            get;
            set;
        } 
        public ChapterGraphView(StoryGraph editorWindow)
        {
            // _codesTool = new CodesTool();
            // _codesTool.InitCodes();
            styleSheets.Add(Resources.Load<StyleSheet>("NarrativeGraph2"));
            SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);

            this.AddManipulator(new ContentDragger());//ContentDragger,允许鼠标拖动一个或多个元素的操控器
            this.AddManipulator(new SelectionDragger());//SelectionDragger,选项拖动程序操控器
            this.AddManipulator(new RectangleSelector());//RectangleSelector,矩形选择框操控器
            this.AddManipulator(new FreehandSelector());//FreehandSelector,自由选择工具
            
            //
            var grid = new GridBackground();
            Insert(0, grid);
            grid.StretchToParentSize();

            //AddElement(GetEntryPointNodeInstance());

            AddSearchWindow(editorWindow);
        }
      //添加右键节点菜单
      private void AddSearchWindow(StoryGraph editorWindow)
        {
            _searchWindow = ScriptableObject.CreateInstance<ChapterNodeSearchWindow>();
            _searchWindow.Configure(editorWindow, this);
            nodeCreationRequest = context =>
                SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), _searchWindow);
        }
      //加载视图
      public void LoadGraph(string path,string name)
        {
            ChapterContainerName = name;
            ChapterContainer chapterContainer = AssetDatabase.LoadAssetAtPath<ChapterContainer>(path);
            m_chapterContainer = chapterContainer;
            ClearGraph();
            if (m_chapterContainer.DialogueContainerNodeData.Count == 0)
            {
                return;
            }
            GenerateDialogueNodes();
            ConnectDialogueNodes();
        }
       private void GenerateDialogueNodes(){}
       private void ConnectDialogueNodes(){}
      	//。。。。。。。。
    }
}

下面是View的背景网格

GridBackground {
    --grid-background-color: #1c1c1c;
    --line-color: rgba(154, 42, 42, 0);
    --thick-line-color: rgba(29, 183, 18, 0.08);
    --spacing: 25;
}

视频图片编辑器

视频图片编辑不是说要修改视频,而是在视频之上叠加选项设置。比如设置一些选项位置等信息,直接拖拽即可,免得手动填写坐标信息不准确。

CleanShot 2024-09-02 at 09.48.11@2x CleanShot 2024-09-02 at 09.49.54@2x

视频编辑器这边没啥好说的,就是UITookit创建一个EditorWindow,然后读取一个VideoClip显示在#VideoDrawer的VisualElement上,主要代码就是如何显示在VisualElement上。

核心思路就是在OnEnable()中new一个GameObject和RenderTexture,然后Add一下VideoPlayer设置视频和_renderTexture,在OnDisable()中删除。

如果你把new的GameObject的hideFlags设置为HideFlags.HideAndDontSave,那么就不是显示在场景中且不是存储。

private void OnEnable()
        {
            _isVideoFrameSliderMouseEnter = false;
            // 初始化VideoPlayer
            _tempGO = new GameObject();
            //_tempGO.hideFlags = HideFlags.HideAndDontSave;
            videoPlayer = _tempGO.AddComponent<VideoPlayer>();
            videoPlayer.source = VideoSource.VideoClip;
            // 创建RenderTexture
            _renderTexture = new RenderTexture(RenderTextureWidth, RenderTextureHeight, 24);
            videoPlayer.targetTexture = _renderTexture;
            RegisterUnityEvents();
            
        }

private void OnDisable()
        {
            if (_renderTexture != null)
            {
                DestroyImmediate(_renderTexture);
            }

            if (_tempGO != null)
            {
                DestroyImmediate(_tempGO);
            }

            UnregisterUnityEvents();
        }

然后在OnUpdate()中刷新VisualElement即可

private void RegisterUnityEvents()
        {
            EditorApplication.update += OnUpdate;
            AssemblyReloadEvents.beforeAssemblyReload += BeforeAssemblyReload;
            AssemblyReloadEvents.afterAssemblyReload += AfterAssemblyReload;
        }

        private void UnregisterUnityEvents()
        {
            EditorApplication.update -= OnUpdate;
            AssemblyReloadEvents.beforeAssemblyReload -= BeforeAssemblyReload;
            AssemblyReloadEvents.afterAssemblyReload -= AfterAssemblyReload;
        }
        private void OnUpdate()
        {
            if (videoPlayer.isPlaying)
            {
                RefreshVideoImage();
                Repaint();
            }
        }
				private void RefreshVideoImage()
        {
            if (videoObject.value != null && videoPlayer.isPlaying)
            {
                // var texture = videoPlayer.texture;
                var texture = _renderTexture;
                int width = texture.width;
                int height = texture.height;
                //var size =ReSize(height,width,1280);
                videoDrawer.style.height = height;
                videoDrawer.style.width = width;
            
                var value = videoDrawer.style.backgroundImage.value;
                value.renderTexture = texture;
                var image = videoDrawer.style.backgroundImage;
                image.value = value;
                videoDrawer.style.backgroundImage = image;
                if (!_isVideoFrameSliderMouseEnter)
                {
                    videoFrameSlider.SetValueWithoutNotify((int)(videoPlayer.time * VideoFPS));
                }
            }
        }

多语言编辑器

此处的多语言主要是对话选项中使用。当然也可以不用,自行拓展也可以。

由于需要做一个package,所以此处就不读Excel表了,直接用ScriptableObject实现。

using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEditor;
using UnityEngine;

namespace StoryLine
{
    public class Language : ScriptableObject
    {
        public LanguageType languageType;
        
        [ListDrawerSettings(IsReadOnly = true)]
        public List<LanguageData> languageData=new List<LanguageData>();

#if UNITY_EDITOR
        
        [Button("添加")]
        public static void AddNewText()
        {
            var guide = Guid.NewGuid().GetHashCode();
            var data = new LanguageData();
            data.id = guide;
            data.text = string.Empty;
            data.tag = string.Empty;
            //获取所有Language的ScriptableObject,统一赋值
            
            foreach (var language in GetAllLanguage())
            {
                language.languageData.Add(data);
            }
        }

        public static int AddNewText(string text,string tag)
        {
            var guide = Guid.NewGuid().GetHashCode();
            var data = new LanguageData();
            data.id = guide;
            data.text = text;
            data.tag = tag;
            //获取所有Language的ScriptableObject,统一赋值
            foreach (var language in GetAllLanguage())
            {
                language.languageData.Add(data);
                UnityEditor.EditorUtility.SetDirty(language);
            }
            UnityEditor.AssetDatabase.SaveAssets();
            UnityEditor.AssetDatabase.Refresh();
            return guide;
        }
        /// <summary>
        /// 获取路径中所有的Language ScriptableObject
        /// </summary>
        /// <returns></returns>
        public static List<Language> GetAllLanguage()
        {
            List<Language> languages = new List<Language>();
            // 获取目录下所有文件
            string[] filePaths = System.IO.Directory.GetFiles(StoryLineSetting.Instance.LanguagePath+"/");
            // 遍历所有文件
            foreach (string filePath in filePaths)
            {
                // 检查文件是否为ScriptableObject文件
                if (filePath.EndsWith(".asset"))
                {
                    // 加载ScriptableObject
                    Language scriptableObject = UnityEditor.AssetDatabase.LoadAssetAtPath(filePath, typeof(Language)) as Language;

                    if (scriptableObject != null)
                    {
                        languages.Add(scriptableObject);
                    }
                    else
                    {
                        Debug.LogWarning("Failed to load ScriptableObject at path: " + filePath);
                    }
                }
            }
            return languages;
        }

        public static void RefreshLanguageText(LanguageType languageType,int languageId,string newValue)
        {
            var languages = GetAllLanguage();
            foreach (var language in languages)
            {
                if (language.languageType.Equals(languageType))
                {
                    for (int i = 0; i < language.languageData.Count; i++)
                    {
                        if (language.languageData[i].id == languageId)
                        {
                            language.languageData[i]=new LanguageData()
                            {
                                id = languageId,
                                text = newValue,
                                tag = language.languageData[i].tag
                            };
                        }
                    }
                    break;
                }
            }
        }
        public static void RefreshLanguageTag(int languageId,string newTag)
        {
            var languages = GetAllLanguage();
            foreach (var language in languages)
            {
                for (int i = 0; i < language.languageData.Count; i++)
                {
                    if (language.languageData[i].id == languageId)
                    {
                        language.languageData[i]=new LanguageData()
                        {
                            id = languageId,
                            text = language.languageData[i].text,
                            tag = newTag
                        };
                    }
                }
            }
        }
#endif
    }

    [Serializable]
    public struct LanguageData
    {
        [ReadOnly]
        public int id;
        public string text;
        [Delayed]
        [OnValueChanged("OnValueChanged")]
        public string tag;

#if UNITY_EDITOR
        [GUIColor(0, 1, 0)]
        [Button("删除")]
        private void Delete()
        {
            foreach (var language in Language.GetAllLanguage())
            {
                foreach (var data in language.languageData)
                {
                    if (data.id == id)
                    {
                        language.languageData.Remove(data);
                        break;
                    }
                }
            }
        }

        private void OnValueChanged()
        {

            var languages = Language.GetAllLanguage();
            for (int i = 0; i < languages.Count; i++)
            {
                for (int j = 0; j < languages[i].languageData.Count; j++)
                {
                    if (languages[i].languageData[j].id == id)
                    {
                        var dataText = languages[i].languageData[j].text;
                        languages[i].languageData[j] = new LanguageData()
                        {
                            id = id,
                            text = dataText,
                            tag = tag
                        };
                    }
                }
            }
        }
#endif
    }
}

在配合上EditorWindow,统一管理

CleanShot 2024-09-02 at 10.23.25@2x
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Unity.Plastic.Newtonsoft.Json.Serialization;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

namespace StoryLine.Editor
{

    public class LanguageEditorWindow : EditorWindow
    {
        [SerializeField] private VisualTreeAsset m_VisualTreeAsset = default;

        [MenuItem("Tools/StoryLine/多语言编辑界面")]
        public static void ShowExample()
        {
            LanguageEditorWindow wnd = GetWindow<LanguageEditorWindow>("LanguageEditorWindow");
        }

        private void OnFocus()
        {
            RefreshScrollView();
        }

        public void CreateGUI()
        {
            // Each editor window contains a root VisualElement object
            VisualElement root = rootVisualElement;


            // Instantiate UXML
            VisualElement labelFromUXML = m_VisualTreeAsset.Instantiate();
            root.Add(labelFromUXML);
            var languageTypeEnum = root.Q<EnumField>("LanguageTypeEnum");
            var button = root.Q<Button>("Btn_AddNewLanguage");
            button.clicked += () =>
            {
                BuildProjectConfig(languageTypeEnum.value is LanguageType ? (LanguageType)languageTypeEnum.value : LanguageType.Chinese);
            };
            var button_newData = root.Q<Button>("Btn_AddNewLanguageData");
            button_newData.clicked += () =>
            {
                Language.AddNewText();
                RefreshScrollView();
            };
            var search = root.Q<ToolbarSearchField>("Toolbar_Search");
            search.RegisterValueChangedCallback((key) =>
            {
                if (string.IsNullOrEmpty(key.newValue))
                {
                    RefreshScrollView();
                }
                else
                {
                    //TODO:根据关键词,查询相关的key
                    RefreshScrollView(key.newValue);
                }
            });
            RefreshScrollView();
        }
        private void RefreshScrollView(string key="")
        {
            var scrollView = rootVisualElement.Q<ScrollView>("ScrollView_Middle");
            scrollView.Clear();
            var languages = Language.GetAllLanguage();
            List<int> showIds = new List<int>();
            if (!string.IsNullOrEmpty(key))
            {
                foreach (var language in languages)
                {
                    foreach (var data in language.languageData)
                    {
                        if (data.text.Contains(key))
                        {
                            showIds.Add(data.id);
                            break;
                        }
                        if (data.tag.Contains(key))
                        {
                            showIds.Add(data.id);
                            break;
                        }
                        if (data.id.ToString().Contains(key))
                        {
                            showIds.Add(data.id);
                            break;
                        }
                    }
                }
            }
            else
            {
                foreach (var data in languages[0].languageData)
                {
                    showIds.Add(data.id);
                }
            }
            if (languages.Count > 0)
            {
                
                var itemTop = CreateLanguageItem(0,20);
                itemTop.style.backgroundColor = Color.gray;
                int index = 0;
                itemTop.Add(CreateLabel_HeightAndWight("ID",20,100));
                itemTop.Add(CreateLabel_HeightAndWight("Tag",20,95));
                foreach (var data in languages)
                {
                    itemTop.Add(CreateLabel_HeightAndWight(data.languageType.ToString(),20,95));
                }
                scrollView.Add(itemTop);
                foreach (var language in languages[0].languageData)
                {
                    if(!showIds.Contains(language.id))
                        continue;
                    var item = CreateLanguageItem(index);
                    item.Add(CreateLabel(language.id.ToString()));
                    item.Add(CreateTextField(language.tag, (newTag) =>
                    {
                        Language.RefreshLanguageTag(language.id,newTag);
                    }));
                    foreach (var data in languages)
                    {
                        var index1 = index;
                        item.Add(CreateTextField(data.languageData[index].text, (newText) =>
                        {
                            Language.RefreshLanguageText(data.languageType,data.languageData[index1].id,newText);
                        }));
                    }
                    scrollView.Add(item);
                    index++;
                }
            }
        }
        
        private VisualElement CreateLanguageItem(int index,int height=100)
        {
            var ve = new VisualElement();
            var style = ve.style;
            if (index % 2 == 0)
            {
                style.backgroundColor = new Color(0.19f, 0.19f, 0.19f, 0.8f);
            }
            else
            {
                style.backgroundColor = new Color(0.32f, 0.32f, 0.32f, 0.8f);
            }
            style.flexDirection=FlexDirection.Row;
            style.alignItems = Align.Center;
            style.justifyContent = Justify.FlexStart;
            style.height = height;
            style.maxHeight = height;
            return ve;
        }

        private Label CreateLabel(string id,int width=100)
        {
            var label= new Label(id);
            var style = label.style;
            style.width = width;
            style.maxWidth = width;
            return label;
        }
        
        private Label CreateLabel_HeightAndWight(string id,int height=100,int width=100)
        {
            var label= new Label(id);
            var style = label.style;
            style.height = height;
            style.maxHeight = height;
            style.width = width;
            style.maxWidth = width;
            return label;
        }
        private TextField CreateTextField(string text,Action<string> callback=null)
        {
            var tf = new TextField("");
            tf.multiline = true;
            var style = tf.style;
            style.height = 90;
            style.width = 90;
            style.maxHeight = 90;
            style.maxWidth = 90;
            style.whiteSpace = WhiteSpace.Normal;
            
            tf.value = text;
            if (callback != null)
            {
                tf.RegisterValueChangedCallback((e) => { callback?.Invoke(e.newValue); });
            }
            return tf;
        }
        public static void BuildProjectConfig(LanguageType languageType)
        {
            string LanguageFilePath = StoryLineSetting.Instance.LanguagePath;
            Language data = null;
            if (!Directory.Exists(LanguageFilePath))
            {
                Directory.CreateDirectory(LanguageFilePath);
            }

            //string dataPath = LanguageFilePath + "/Language.asset";
            string dataPath = $"{LanguageFilePath}/Language_{languageType.ToString()}.asset";
            data = AssetDatabase.LoadAssetAtPath<Language>(dataPath);
            if (data == null)
            {
                data = ScriptableObject.CreateInstance<Language>();
                data.languageType = languageType;
                var languages = Language.GetAllLanguage();
                if (languages.Count > 0)
                {
                    var list = new List<LanguageData>();
                    foreach (var languageData in languages[0].languageData)
                    {
                        list.Add(new LanguageData()
                        {
                            id = languageData.id,
                            tag = languageData.tag,
                            text = string.Empty
                        });
                    }
                    data.languageData = list;
                }
                else
                {
                    data.languageData = new List<LanguageData>();
                }
                AssetDatabase.CreateAsset(data, dataPath);
            }

            EditorUtility.SetDirty(data);
            AssetDatabase.SaveAssets();
            {
                // 选中
                Selection.activeObject = data;
                EditorGUIUtility.PingObject(data);
                GUIUtility.keyboardControl = 0;
                GUIUtility.hotControl = 0;
            }
        }
        
    }
}

最终效果如下

CleanShot 2024-09-02 at 10.31.18@2x CleanShot 2024-09-02 at 10.32.08@2x