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


设计思路
需求分析
需求如下:
- 支持显示视频和图片,可在图片或视频上叠加
固定区域选项
、自定义位置选项
、QTE
、自定义位置按钮
- 视频播放完或者播放到某一时间会弹出选项,根据选项继续播放下一个视频或者图片,如果没有选项则自动播放
- 在播放下一个视频或图片之前,可以穿插小游戏玩法,比如视频播放完后进入一段打飞机游戏,小游戏失败可成功可进入不同的视频
- 叠加在视频或图片上的选项可配置 条件。条件用于判断是否显示选项,点击选项是否可触发。
- 在播放某个视频或者点击某个选项可触发自定义游戏内容,比如获取道具、获取成就等游戏业务逻辑。
具体分析:
-
作为剧情编辑器,一目了然的看到剧情走向和各种剧情分支是极其重要的,所以首先就摒弃了纯参数填写的编辑器方式,把各种参数交给产品策划填写,太复杂填写容易出错不运行游戏根本发现不了,而且可读性极差。所以我选择了基于Unity中GraphView的Node节点的形式制作视频节点编辑器,官方的ShaderGraph就是节点编辑。
-
决定好了在GraphView下用Node节点连线方式来做编辑器,那么首先根据需求拆分为
章节编辑器
和单元剧编辑器
编辑器 说明 章节编辑器 用于编辑故事分支走向 单元剧编辑器 用于编辑每个故事节点的具体内容:播放什么视频图片,穿插小游戏等随意组合 -
章节编辑器中则把节点分为:开始节点,结束节点,单元剧节点
节点名称 说明 开始节点 一个章节的开头,可能有具体的业务逻辑 结束节点 一个章节的结束,用于链接下一章节或者有具体的业务逻辑 单元剧节点 用于关联单元剧的 -
单元剧编辑器咋把节点分为:开始节点、结束节点、视频节点、图片节点、事件节点、小游戏节点、跳转Jump节点
节点名称 说明 开始节点 用于单元剧的开头 结束节点 单元剧结束,可有多个End节点,用于在章节编辑器中显示出口 视频节点 游戏播放视频时的数据编辑,用于配置显示选项、视频资源等 图片节点 游戏显示图片时的数据编辑,用于配置显示选项、视频资源等 事件节点 用于连接StoryLine编辑器和正常开发的业务逻辑。比如一些获取道具成就啥的,不应该内置在编辑器中,而是由主程序根据实际情况拓展 小游戏节点 主程序中实现小游戏接口即可在编辑器中选择小游戏。小游戏也是根据情况自行拓展的 跳转Jump节点 在单元剧编辑器中的每个节点都有一个唯一id,那么Jump跳转节点既可以直接跳转到某个id的节点播放。目的是可能有一些特殊用途。 -
在视频节点和图片节点中的选项里面可以添加条件,用于判断是否显示这个选项或者是否可以点击触发这个选项。那么这个条件的具体代码逻辑就不能内置在StoryLine包中,而是给Attribute特效,在主程序中给一个方法添加此特性,那么就可以在编辑器中选择这个条件方法。这样就可以连接编辑器和实际开发的业务需求了。
代码设计
有了大概的需求,那么就可以着手设计代码了。
事件系统
为了让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 接口
这个接口用于提供一个右键菜单,指明右键有哪些节点能被创建。

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


视频编辑器这边没啥好说的,就是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,统一管理

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;
}
}
}
}
最终效果如下

