feat(chapter): 添加章节图可视化编辑器
基于 UIToolkit 实现 ChapterGraph 编辑器窗口,可视化查看 和编辑 TalkSceneSO 之间的多出口连接关系。
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 09d2581fcb827614bba6bf5c3279358e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AibisDream;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class ChapterGraphEditorWindow : EditorWindow
|
||||
{
|
||||
private ChapterGraphView _graphView;
|
||||
private ChapterGraphInspector _inspector;
|
||||
private IMGUIContainer _inspectorContainer;
|
||||
|
||||
private const string RootFolderPrefKey = "AibisDream.ChapterGraph.RootFolder";
|
||||
private const string CurrentFolderPrefKey = "AibisDream.ChapterGraph.CurrentFolder";
|
||||
private string _rootFolder;
|
||||
private string _currentFolder;
|
||||
private DropdownField _folderDropdown;
|
||||
private readonly Dictionary<string, string> _displayToPath = new();
|
||||
|
||||
[MenuItem("Window/Chapter Graph Editor")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ChapterGraphEditorWindow>("Chapter Graph Editor");
|
||||
window.minSize = new Vector2(800, 600);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_graphView == null) return;
|
||||
_graphView.LoadGraph(_currentFolder);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_graphView != null)
|
||||
{
|
||||
_graphView.OnNodeDeleted -= OnNodeDeleted;
|
||||
}
|
||||
if (_inspector != null)
|
||||
{
|
||||
_inspector.OnColorChanged -= OnNodeColorChanged;
|
||||
_inspector.Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateGUI()
|
||||
{
|
||||
// 根容器:纵向排列
|
||||
var root = rootVisualElement;
|
||||
root.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
// 顶部工具栏
|
||||
var toolbar = new Toolbar();
|
||||
toolbar.Add(CreateToolbarButton("Save", OnSaveClicked));
|
||||
toolbar.Add(CreateToolbarButton("Auto Layout", OnAutoLayoutClicked));
|
||||
toolbar.Add(CreateToolbarButton("Validate", OnValidateClicked));
|
||||
|
||||
_rootFolder = EditorPrefs.GetString(RootFolderPrefKey, "Assets/ScriptableObjects/SceneSO");
|
||||
|
||||
_folderDropdown = new DropdownField("路径");
|
||||
_folderDropdown.style.width = 160;
|
||||
_folderDropdown.style.marginLeft = 8;
|
||||
_folderDropdown.style.marginRight = 4;
|
||||
RefreshFolderChoices();
|
||||
_folderDropdown.RegisterValueChangedCallback(OnFolderChanged);
|
||||
toolbar.Add(_folderDropdown);
|
||||
|
||||
var setRootBtn = new Button(OnSetRootFolderClicked) { text = "..." };
|
||||
setRootBtn.style.width = 28;
|
||||
toolbar.Add(setRootBtn);
|
||||
|
||||
toolbar.Add(new ToolbarSpacer() { style = { flexGrow = 1 } });
|
||||
toolbar.Add(CreateToolbarButton("Create Chapter", OnCreateChapterClicked));
|
||||
root.Add(toolbar);
|
||||
|
||||
// 主内容区:横向分栏
|
||||
var mainContainer = new VisualElement();
|
||||
mainContainer.style.flexGrow = 1;
|
||||
mainContainer.style.flexDirection = FlexDirection.Row;
|
||||
mainContainer.style.overflow = Overflow.Hidden;
|
||||
|
||||
// 左侧 Inspector 面板
|
||||
var leftPanel = new VisualElement();
|
||||
leftPanel.style.width = 320;
|
||||
leftPanel.style.minWidth = 200;
|
||||
leftPanel.style.maxWidth = 500;
|
||||
leftPanel.style.flexShrink = 0;
|
||||
leftPanel.style.flexDirection = FlexDirection.Column;
|
||||
leftPanel.style.borderRightWidth = 1;
|
||||
leftPanel.style.borderRightColor = new StyleColor(new Color(0.15f, 0.15f, 0.15f));
|
||||
|
||||
// Inspector 标题
|
||||
var header = new Label("章节属性")
|
||||
{
|
||||
style =
|
||||
{
|
||||
unityFontStyleAndWeight = FontStyle.Bold,
|
||||
fontSize = 14,
|
||||
paddingTop = 8,
|
||||
paddingBottom = 8,
|
||||
paddingLeft = 12,
|
||||
paddingRight = 12,
|
||||
borderBottomWidth = 1,
|
||||
borderBottomColor = new StyleColor(new Color(0.15f, 0.15f, 0.15f))
|
||||
}
|
||||
};
|
||||
leftPanel.Add(header);
|
||||
|
||||
// IMGUI Inspector
|
||||
_inspector = new ChapterGraphInspector();
|
||||
_inspectorContainer = new IMGUIContainer(() => _inspector.OnGUI());
|
||||
_inspectorContainer.style.flexGrow = 1;
|
||||
leftPanel.Add(_inspectorContainer);
|
||||
|
||||
mainContainer.Add(leftPanel);
|
||||
|
||||
// 拖拽条
|
||||
var resizer = new VisualElement();
|
||||
resizer.style.width = 5;
|
||||
resizer.style.backgroundColor = new StyleColor(new Color(0.15f, 0.15f, 0.15f));
|
||||
mainContainer.Add(resizer);
|
||||
|
||||
float startWidth = 0;
|
||||
float startMouseX = 0;
|
||||
resizer.RegisterCallback<MouseDownEvent>(evt =>
|
||||
{
|
||||
startWidth = leftPanel.resolvedStyle.width;
|
||||
startMouseX = evt.mousePosition.x;
|
||||
resizer.CaptureMouse();
|
||||
evt.StopPropagation();
|
||||
});
|
||||
resizer.RegisterCallback<MouseMoveEvent>(evt =>
|
||||
{
|
||||
if (!resizer.HasMouseCapture()) return;
|
||||
var delta = evt.mousePosition.x - startMouseX;
|
||||
var newWidth = Mathf.Clamp(startWidth + delta, 200, 600);
|
||||
leftPanel.style.width = newWidth;
|
||||
});
|
||||
resizer.RegisterCallback<MouseUpEvent>(evt =>
|
||||
{
|
||||
if (resizer.HasMouseCapture())
|
||||
resizer.ReleaseMouse();
|
||||
});
|
||||
|
||||
// 右侧 GraphView
|
||||
_graphView = new ChapterGraphView();
|
||||
_graphView.style.flexGrow = 1;
|
||||
mainContainer.Add(_graphView);
|
||||
|
||||
root.Add(mainContainer);
|
||||
|
||||
// 加载图数据
|
||||
_graphView.LoadGraph(_currentFolder);
|
||||
|
||||
// 选中节点同步到 Inspector
|
||||
_graphView.OnNodeSelected += OnNodeSelected;
|
||||
_graphView.OnNodeDeleted += OnNodeDeleted;
|
||||
_inspector.OnColorChanged += OnNodeColorChanged;
|
||||
}
|
||||
|
||||
private void OnNodeDeleted(TalkSceneSO so)
|
||||
{
|
||||
_inspector?.SetTarget(null);
|
||||
_inspectorContainer?.MarkDirtyRepaint();
|
||||
}
|
||||
|
||||
private void OnNodeColorChanged(TalkSceneSO so)
|
||||
{
|
||||
_graphView?.UpdateNodeColor(so);
|
||||
}
|
||||
|
||||
private Button CreateToolbarButton(string text, System.Action onClick)
|
||||
{
|
||||
var btn = new Button(onClick) { text = text };
|
||||
return btn;
|
||||
}
|
||||
|
||||
private void OnSaveClicked()
|
||||
{
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.DisplayDialog("保存", "所有资源已保存。", "确定");
|
||||
}
|
||||
|
||||
private void OnAutoLayoutClicked()
|
||||
{
|
||||
_graphView?.AutoLayout();
|
||||
}
|
||||
|
||||
private void OnValidateClicked()
|
||||
{
|
||||
_graphView?.ValidateGraph();
|
||||
}
|
||||
|
||||
private void OnCreateChapterClicked()
|
||||
{
|
||||
var center = new Vector2(position.width / 2f, position.height / 2f);
|
||||
var localPos = _graphView.contentViewContainer.WorldToLocal(center);
|
||||
var defaultFolder = _currentFolder ?? EditorPrefs.GetString(ChapterGraphView.DefaultFolderPrefKey, _rootFolder);
|
||||
_graphView?.CreateChapterNodeAt(localPos, defaultFolder);
|
||||
}
|
||||
|
||||
private void OnNodeSelected(TalkSceneSO so)
|
||||
{
|
||||
_inspector?.SetTarget(so);
|
||||
_inspectorContainer?.MarkDirtyRepaint();
|
||||
}
|
||||
|
||||
private void RefreshFolderChoices()
|
||||
{
|
||||
_displayToPath.Clear();
|
||||
_displayToPath["All"] = null;
|
||||
|
||||
var subFolders = AssetDatabase.GetSubFolders(_rootFolder);
|
||||
foreach (var folder in subFolders)
|
||||
{
|
||||
var name = Path.GetFileName(folder);
|
||||
_displayToPath[name] = folder;
|
||||
}
|
||||
|
||||
var rootHasSO = AssetDatabase.FindAssets("t:TalkSceneSO", new[] { _rootFolder })
|
||||
.Select(g => AssetDatabase.GUIDToAssetPath(g))
|
||||
.Any(path => Path.GetDirectoryName(path)?.Replace('\\', '/') == _rootFolder);
|
||||
|
||||
if (rootHasSO)
|
||||
{
|
||||
_displayToPath["(Root)"] = _rootFolder;
|
||||
}
|
||||
|
||||
_folderDropdown.choices = _displayToPath.Keys.ToList();
|
||||
|
||||
var saved = EditorPrefs.GetString(CurrentFolderPrefKey, "All");
|
||||
_folderDropdown.value = _displayToPath.ContainsKey(saved) ? saved : "All";
|
||||
_currentFolder = _displayToPath.TryGetValue(_folderDropdown.value, out var path) ? path : null;
|
||||
}
|
||||
|
||||
private void OnFolderChanged(ChangeEvent<string> evt)
|
||||
{
|
||||
_currentFolder = _displayToPath.TryGetValue(evt.newValue, out var path) ? path : null;
|
||||
EditorPrefs.SetString(CurrentFolderPrefKey, evt.newValue);
|
||||
_graphView?.LoadGraph(_currentFolder);
|
||||
}
|
||||
|
||||
private void OnSetRootFolderClicked()
|
||||
{
|
||||
var path = EditorUtility.OpenFolderPanel("选择章节根目录", _rootFolder, "");
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
var absoluteRoot = Application.dataPath.Replace('\\', '/');
|
||||
var selected = path.Replace('\\', '/');
|
||||
if (!selected.StartsWith(absoluteRoot))
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "请选择项目 Assets 目录内的文件夹。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
_rootFolder = "Assets" + selected.Substring(absoluteRoot.Length);
|
||||
EditorPrefs.SetString(RootFolderPrefKey, _rootFolder);
|
||||
RefreshFolderChoices();
|
||||
_graphView?.LoadGraph(_currentFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c30bb2152169f544cb672c29ac8cb5d1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,70 @@
|
||||
using AibisDream;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class ChapterGraphInspector
|
||||
{
|
||||
private Editor _editor;
|
||||
private TalkSceneSO _target;
|
||||
private Vector2 _scrollPosition;
|
||||
|
||||
public event System.Action<TalkSceneSO> OnColorChanged;
|
||||
|
||||
public void SetTarget(TalkSceneSO so)
|
||||
{
|
||||
// 不能用 ==,Unity 重载了 ==,已销毁的对象 == null 为 true
|
||||
if (ReferenceEquals(_target, so)) return;
|
||||
|
||||
_target = so;
|
||||
if (_editor != null)
|
||||
{
|
||||
Object.DestroyImmediate(_editor);
|
||||
_editor = null;
|
||||
}
|
||||
|
||||
if (so != null)
|
||||
{
|
||||
_editor = Editor.CreateEditor(so);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnGUI()
|
||||
{
|
||||
if (_editor == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("请在图中选中一个章节节点以编辑属性", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
var newColor = EditorGUILayout.ColorField("节点颜色", _target.nodeColor);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
Undo.RecordObject(_target, "Change Node Color");
|
||||
_target.nodeColor = newColor;
|
||||
EditorUtility.SetDirty(_target);
|
||||
OnColorChanged?.Invoke(_target);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
_editor.OnInspectorGUI();
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
public void Destroy()
|
||||
{
|
||||
if (_editor != null)
|
||||
{
|
||||
Object.DestroyImmediate(_editor);
|
||||
_editor = null;
|
||||
}
|
||||
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b122b6f926732f46b067d9b26eb191b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,503 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AibisDream;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Experimental.GraphView;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class ChapterGraphView : GraphView
|
||||
{
|
||||
public event Action<TalkSceneSO> OnNodeSelected;
|
||||
public event Action<TalkSceneSO> OnNodeDeleted;
|
||||
|
||||
private const float HorizontalSpacing = 420f;
|
||||
private const float VerticalSpacing = 220f;
|
||||
public const string DefaultFolderPrefKey = "AibisDream.ChapterGraph.DefaultFolder";
|
||||
public const string DefaultFolder = "Assets/ScriptableObjects/SceneSO";
|
||||
|
||||
public ChapterGraphView()
|
||||
{
|
||||
style.flexGrow = 1;
|
||||
|
||||
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
|
||||
this.AddManipulator(new ContentDragger());
|
||||
this.AddManipulator(new SelectionDragger());
|
||||
this.AddManipulator(new RectangleSelector());
|
||||
|
||||
var grid = new GridBackground();
|
||||
Insert(0, grid);
|
||||
grid.StretchToParentSize();
|
||||
|
||||
graphViewChanged += OnGraphViewChanged;
|
||||
}
|
||||
|
||||
public void UpdateNodeColor(TalkSceneSO so)
|
||||
{
|
||||
foreach (var node in graphElements.OfType<ChapterNode>())
|
||||
{
|
||||
if (node.SceneSo == so)
|
||||
{
|
||||
node.UpdateColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadGraph(string folderPath = null)
|
||||
{
|
||||
ClearGraph();
|
||||
|
||||
var guids = AssetDatabase.FindAssets("t:TalkSceneSO", folderPath != null ? new[] { folderPath } : null);
|
||||
var allSos = guids.Select(g => AssetDatabase.LoadAssetAtPath<TalkSceneSO>(AssetDatabase.GUIDToAssetPath(g))).Where(so => so != null).ToList();
|
||||
var nodeMap = new Dictionary<TalkSceneSO, ChapterNode>();
|
||||
|
||||
// 创建节点
|
||||
foreach (var so in allSos)
|
||||
{
|
||||
var node = new ChapterNode(so);
|
||||
AddElement(node);
|
||||
nodeMap[so] = node;
|
||||
}
|
||||
|
||||
// 创建连线
|
||||
foreach (var so in allSos)
|
||||
{
|
||||
if (so.exits == null) continue;
|
||||
if (!nodeMap.TryGetValue(so, out var sourceNode)) continue;
|
||||
|
||||
for (int i = 0; i < so.exits.Count; i++)
|
||||
{
|
||||
var exit = so.exits[i];
|
||||
if (exit == null || exit.targetScene == null) continue;
|
||||
if (!nodeMap.TryGetValue(exit.targetScene, out var targetNode)) continue;
|
||||
|
||||
var outputPort = sourceNode.GetOutputPort(i);
|
||||
var inputPort = targetNode.InputPort;
|
||||
if (outputPort == null || inputPort == null) continue;
|
||||
|
||||
var edge = new Edge
|
||||
{
|
||||
output = outputPort,
|
||||
input = inputPort
|
||||
};
|
||||
edge.output.Connect(edge);
|
||||
edge.input.Connect(edge);
|
||||
AddElement(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearGraph()
|
||||
{
|
||||
// 清除所有元素
|
||||
var elementsToRemove = graphElements.ToList();
|
||||
foreach (var element in elementsToRemove)
|
||||
{
|
||||
RemoveElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
|
||||
{
|
||||
base.BuildContextualMenu(evt);
|
||||
|
||||
if (evt.target is GraphView)
|
||||
{
|
||||
var mousePosition = contentViewContainer.WorldToLocal(evt.mousePosition);
|
||||
evt.menu.AppendAction("添加章节节点", (_) => CreateChapterNodeAt(mousePosition));
|
||||
}
|
||||
}
|
||||
|
||||
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
|
||||
{
|
||||
var compatiblePorts = new List<Port>();
|
||||
var startNode = startPort.node as ChapterNode;
|
||||
if (startNode == null) return compatiblePorts;
|
||||
|
||||
ports.ForEach(port =>
|
||||
{
|
||||
var targetNode = port.node as ChapterNode;
|
||||
if (targetNode == null) return;
|
||||
if (targetNode == startNode) return;
|
||||
if (startPort.direction == port.direction) return;
|
||||
if (startPort.node == port.node) return;
|
||||
|
||||
compatiblePorts.Add(port);
|
||||
});
|
||||
|
||||
return compatiblePorts;
|
||||
}
|
||||
|
||||
private GraphViewChange OnGraphViewChanged(GraphViewChange change)
|
||||
{
|
||||
// 先处理节点删除确认:用户取消则从移除列表中剔除
|
||||
if (change.elementsToRemove != null)
|
||||
{
|
||||
var nodesToRemove = change.elementsToRemove.OfType<ChapterNode>().ToList();
|
||||
foreach (var node in nodesToRemove)
|
||||
{
|
||||
if (!ConfirmRemoveNode(node))
|
||||
{
|
||||
change.elementsToRemove.Remove(node);
|
||||
// 同时保留与该节点相连的边,避免边被单独删掉
|
||||
var edgesToKeep = change.elementsToRemove.OfType<Edge>()
|
||||
.Where(e => e.output?.node == node || e.input?.node == node)
|
||||
.ToList();
|
||||
foreach (var edge in edgesToKeep)
|
||||
{
|
||||
change.elementsToRemove.Remove(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Edge 删除
|
||||
if (change.elementsToRemove != null)
|
||||
{
|
||||
foreach (var edge in change.elementsToRemove.OfType<Edge>())
|
||||
{
|
||||
HandleEdgeRemoved(edge);
|
||||
}
|
||||
|
||||
foreach (var node in change.elementsToRemove.OfType<ChapterNode>())
|
||||
{
|
||||
HandleNodeRemoved(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Edge 创建
|
||||
if (change.edgesToCreate != null)
|
||||
{
|
||||
foreach (var edge in change.edgesToCreate)
|
||||
{
|
||||
HandleEdgeCreated(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理节点移动
|
||||
if (change.movedElements != null)
|
||||
{
|
||||
foreach (var element in change.movedElements)
|
||||
{
|
||||
if (element is ChapterNode node)
|
||||
{
|
||||
node.SyncPositionToSo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return change;
|
||||
}
|
||||
|
||||
private void HandleEdgeCreated(Edge edge)
|
||||
{
|
||||
if (!(edge.output?.node is ChapterNode sourceNode)) return;
|
||||
if (!(edge.input?.node is ChapterNode targetNode)) return;
|
||||
|
||||
var sourceSo = sourceNode.SceneSo;
|
||||
var targetSo = targetNode.SceneSo;
|
||||
if (sourceSo == null || targetSo == null) return;
|
||||
|
||||
int exitIndex = edge.output.userData is int idx ? idx : -1;
|
||||
if (exitIndex < 0 || exitIndex >= sourceSo.exits.Count) return;
|
||||
|
||||
Undo.RecordObject(sourceSo, "Connect Chapter");
|
||||
sourceSo.exits[exitIndex].targetScene = targetSo;
|
||||
EditorUtility.SetDirty(sourceSo);
|
||||
}
|
||||
|
||||
private void HandleEdgeRemoved(Edge edge)
|
||||
{
|
||||
if (!(edge.output?.node is ChapterNode sourceNode)) return;
|
||||
|
||||
var sourceSo = sourceNode.SceneSo;
|
||||
if (sourceSo == null) return;
|
||||
|
||||
int exitIndex = edge.output.userData is int idx ? idx : -1;
|
||||
if (exitIndex < 0 || exitIndex >= sourceSo.exits.Count) return;
|
||||
|
||||
Undo.RecordObject(sourceSo, "Disconnect Chapter");
|
||||
sourceSo.exits[exitIndex].targetScene = null;
|
||||
EditorUtility.SetDirty(sourceSo);
|
||||
}
|
||||
|
||||
private bool ConfirmRemoveNode(ChapterNode node)
|
||||
{
|
||||
var so = node.SceneSo;
|
||||
if (so == null) return true;
|
||||
|
||||
var referrers = new List<string>();
|
||||
foreach (var other in graphElements.OfType<ChapterNode>())
|
||||
{
|
||||
if (other == node) continue;
|
||||
if (other.SceneSo == null || other.SceneSo.exits == null) continue;
|
||||
foreach (var exit in other.SceneSo.exits)
|
||||
{
|
||||
if (exit.targetScene == so)
|
||||
{
|
||||
referrers.Add(string.IsNullOrEmpty(other.SceneSo.title) ? other.SceneSo.name : other.SceneSo.title);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var name = string.IsNullOrEmpty(so.title) ? so.name : so.title;
|
||||
var msg = $"确定要删除章节节点 \"{name}\" 吗?";
|
||||
if (referrers.Count > 0)
|
||||
{
|
||||
msg += $"\n\n以下节点仍引用该节点:\n• {string.Join("\n• ", referrers)}";
|
||||
}
|
||||
|
||||
return EditorUtility.DisplayDialog("删除确认", msg, "删除", "取消");
|
||||
}
|
||||
|
||||
private void HandleNodeRemoved(ChapterNode node)
|
||||
{
|
||||
var so = node.SceneSo;
|
||||
if (so == null) return;
|
||||
|
||||
var path = AssetDatabase.GetAssetPath(so);
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
AssetDatabase.DeleteAsset(path);
|
||||
}
|
||||
|
||||
OnNodeDeleted?.Invoke(so);
|
||||
}
|
||||
|
||||
public override void AddToSelection(ISelectable selectable)
|
||||
{
|
||||
base.AddToSelection(selectable);
|
||||
NotifySelectionChanged();
|
||||
}
|
||||
|
||||
public override void RemoveFromSelection(ISelectable selectable)
|
||||
{
|
||||
base.RemoveFromSelection(selectable);
|
||||
NotifySelectionChanged();
|
||||
}
|
||||
|
||||
public override void ClearSelection()
|
||||
{
|
||||
base.ClearSelection();
|
||||
NotifySelectionChanged();
|
||||
}
|
||||
|
||||
private void NotifySelectionChanged()
|
||||
{
|
||||
var selectedNode = selection.OfType<ChapterNode>().FirstOrDefault();
|
||||
OnNodeSelected?.Invoke(selectedNode?.SceneSo);
|
||||
}
|
||||
|
||||
public void CreateChapterNodeAt(Vector2 position, string defaultFolder = null)
|
||||
{
|
||||
var folder = defaultFolder ?? EditorPrefs.GetString(DefaultFolderPrefKey, DefaultFolder);
|
||||
var path = EditorUtility.SaveFilePanelInProject("新建章节", "NewChapter", "asset", "选择新章节的保存位置", folder);
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
var so = ScriptableObject.CreateInstance<TalkSceneSO>();
|
||||
so.name = System.IO.Path.GetFileNameWithoutExtension(path);
|
||||
so.title = "新章节";
|
||||
so.nodeColor = new Color(0.85f, 0.45f, 0.45f);
|
||||
so.graphPosition = position;
|
||||
so.exits = new System.Collections.Generic.List<SceneExit>();
|
||||
|
||||
AssetDatabase.CreateAsset(so, path);
|
||||
EditorUtility.SetDirty(so);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
// 记住文件夹
|
||||
var savedFolder = System.IO.Path.GetDirectoryName(path)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(savedFolder))
|
||||
{
|
||||
EditorPrefs.SetString(DefaultFolderPrefKey, savedFolder);
|
||||
}
|
||||
|
||||
var node = new ChapterNode(so);
|
||||
AddElement(node);
|
||||
}
|
||||
|
||||
public void AutoLayout()
|
||||
{
|
||||
var nodes = graphElements.OfType<ChapterNode>().ToList();
|
||||
if (nodes.Count == 0) return;
|
||||
|
||||
// 计算入度
|
||||
var inDegree = nodes.ToDictionary(n => n, _ => 0);
|
||||
var edges = graphElements.OfType<Edge>().ToList();
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (edge.input?.node is ChapterNode target && inDegree.ContainsKey(target))
|
||||
{
|
||||
inDegree[target]++;
|
||||
}
|
||||
}
|
||||
|
||||
// 分层 BFS
|
||||
var layer = new Dictionary<ChapterNode, int>();
|
||||
var queue = new Queue<ChapterNode>();
|
||||
|
||||
// 入度为 0 的节点作为起点
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (inDegree[node] == 0)
|
||||
{
|
||||
layer[node] = 0;
|
||||
queue.Enqueue(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有入度为 0 的节点(纯循环),任选一个作为起点
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
layer[nodes[0]] = 0;
|
||||
queue.Enqueue(nodes[0]);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
var currentLayer = layer[current];
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (edge.output?.node == current && edge.input?.node is ChapterNode next)
|
||||
{
|
||||
if (!layer.ContainsKey(next))
|
||||
{
|
||||
layer[next] = currentLayer + 1;
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
else
|
||||
{
|
||||
layer[next] = Math.Max(layer[next], currentLayer + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 为未分配层级的节点(孤立或不连通部分)分配层级
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (!layer.ContainsKey(node))
|
||||
{
|
||||
layer[node] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 按层级分组并计算位置
|
||||
var groups = layer.GroupBy(kvp => kvp.Value).OrderBy(g => g.Key).ToList();
|
||||
Undo.RecordObjects(nodes.Select(n => n.SceneSo).ToArray(), "Auto Layout");
|
||||
|
||||
for (int i = 0; i < groups.Count; i++)
|
||||
{
|
||||
var group = groups[i];
|
||||
var groupNodes = group.ToList();
|
||||
float x = group.Key * HorizontalSpacing;
|
||||
float startY = -(groupNodes.Count - 1) * VerticalSpacing / 2f;
|
||||
|
||||
for (int j = 0; j < groupNodes.Count; j++)
|
||||
{
|
||||
var node = groupNodes[j].Key;
|
||||
var y = startY + j * VerticalSpacing;
|
||||
var newPos = new Vector2(x, y);
|
||||
node.SetPosition(new Rect(newPos, Vector2.zero));
|
||||
node.SceneSo.graphPosition = newPos;
|
||||
EditorUtility.SetDirty(node.SceneSo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ValidateGraph()
|
||||
{
|
||||
var nodes = graphElements.OfType<ChapterNode>().ToList();
|
||||
var issues = new List<string>();
|
||||
var soSet = new HashSet<TalkSceneSO>(nodes.Select(n => n.SceneSo));
|
||||
|
||||
// 孤立节点
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var so = node.SceneSo;
|
||||
bool hasIncoming = graphElements.OfType<Edge>().Any(e => e.input?.node == node);
|
||||
bool hasOutgoing = so.exits != null && so.exits.Any(e => e.targetScene != null);
|
||||
|
||||
if (!hasIncoming && !hasOutgoing)
|
||||
{
|
||||
issues.Add($"孤立节点: {so.name} (没有入边也没有出边)");
|
||||
}
|
||||
}
|
||||
|
||||
// 循环引用 (DFS)
|
||||
var visited = new HashSet<TalkSceneSO>();
|
||||
var visiting = new HashSet<TalkSceneSO>();
|
||||
|
||||
foreach (var so in soSet)
|
||||
{
|
||||
if (!visited.Contains(so))
|
||||
{
|
||||
CheckCycle(so, visited, visiting, soSet, issues);
|
||||
}
|
||||
}
|
||||
|
||||
// 空 exitName
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var so = node.SceneSo;
|
||||
if (so.exits == null) continue;
|
||||
for (int i = 0; i < so.exits.Count; i++)
|
||||
{
|
||||
if (string.IsNullOrEmpty(so.exits[i].exitName))
|
||||
{
|
||||
issues.Add($"空 exitName: {so.name} 的第 {i + 1} 个出口");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("验证结果", "图表验证通过,未发现问题。", "确定");
|
||||
}
|
||||
else
|
||||
{
|
||||
var msg = string.Join("\n", issues.Take(20));
|
||||
if (issues.Count > 20)
|
||||
{
|
||||
msg += $"\n... 还有 {issues.Count - 20} 个问题";
|
||||
}
|
||||
EditorUtility.DisplayDialog($"验证发现 {issues.Count} 个问题", msg, "确定");
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckCycle(TalkSceneSO current, HashSet<TalkSceneSO> visited, HashSet<TalkSceneSO> visiting, HashSet<TalkSceneSO> soSet, List<string> issues)
|
||||
{
|
||||
visited.Add(current);
|
||||
visiting.Add(current);
|
||||
|
||||
if (current.exits != null)
|
||||
{
|
||||
foreach (var exit in current.exits)
|
||||
{
|
||||
var next = exit.targetScene;
|
||||
if (next == null || !soSet.Contains(next)) continue;
|
||||
|
||||
if (visiting.Contains(next))
|
||||
{
|
||||
issues.Add($"循环引用: {current.name} -> {next.name}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!visited.Contains(next))
|
||||
{
|
||||
CheckCycle(next, visited, visiting, soSet, issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.Remove(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 913b090485a3d844fab3e436e7bf4f57
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AibisDream;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Experimental.GraphView;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class ChapterNode : Node
|
||||
{
|
||||
public TalkSceneSO SceneSo { get; }
|
||||
public Port InputPort { get; private set; }
|
||||
|
||||
private const float PortLabelMaxWidth = 120f;
|
||||
|
||||
public ChapterNode(TalkSceneSO so)
|
||||
{
|
||||
SceneSo = so ?? throw new ArgumentNullException(nameof(so));
|
||||
|
||||
// 节点整体放大
|
||||
style.minWidth = 240;
|
||||
style.paddingTop = 8;
|
||||
style.paddingBottom = 8;
|
||||
style.paddingLeft = 10;
|
||||
style.paddingRight = 10;
|
||||
|
||||
UpdateTitle();
|
||||
UpdateColor();
|
||||
BuildInputPort();
|
||||
BuildOutputPorts();
|
||||
BuildSubtitle();
|
||||
BuildAddExitButton();
|
||||
SetPosition(new Rect(so.graphPosition, Vector2.zero));
|
||||
|
||||
// 放大标题
|
||||
var titleLabel = this.Q<Label>("title-label");
|
||||
if (titleLabel != null)
|
||||
{
|
||||
titleLabel.style.fontSize = 16;
|
||||
titleLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateTitle()
|
||||
{
|
||||
title = string.IsNullOrEmpty(SceneSo.title) ? SceneSo.name : SceneSo.title;
|
||||
}
|
||||
|
||||
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
|
||||
{
|
||||
base.BuildContextualMenu(evt);
|
||||
|
||||
if (SceneSo.exits != null && SceneSo.exits.Count > 0)
|
||||
{
|
||||
for (int i = 0; i < SceneSo.exits.Count; i++)
|
||||
{
|
||||
var exit = SceneSo.exits[i];
|
||||
var name = string.IsNullOrEmpty(exit.exitName) ? $"出口{i}" : exit.exitName;
|
||||
var idx = i;
|
||||
evt.menu.AppendAction($"删除出口/{name}", _ => RemoveExitAt(idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateColor()
|
||||
{
|
||||
style.backgroundColor = new StyleColor(SceneSo.nodeColor);
|
||||
}
|
||||
|
||||
private void BuildInputPort()
|
||||
{
|
||||
InputPort = Port.Create<Edge>(
|
||||
Orientation.Horizontal,
|
||||
Direction.Input,
|
||||
Port.Capacity.Single,
|
||||
typeof(TalkSceneSO));
|
||||
InputPort.portName = "▶";
|
||||
InputPort.style.fontSize = 14;
|
||||
InputPort.AddManipulator(new EdgeConnector<Edge>(new EmptyEdgeListener()));
|
||||
inputContainer.Add(InputPort);
|
||||
}
|
||||
|
||||
private void BuildOutputPorts()
|
||||
{
|
||||
outputContainer.Clear();
|
||||
if (SceneSo.exits == null) return;
|
||||
|
||||
for (int i = 0; i < SceneSo.exits.Count; i++)
|
||||
{
|
||||
var exit = SceneSo.exits[i];
|
||||
var port = CreateOutputPort(exit, i);
|
||||
outputContainer.Add(port);
|
||||
}
|
||||
}
|
||||
|
||||
private Port CreateOutputPort(SceneExit exit, int index)
|
||||
{
|
||||
var port = Port.Create<Edge>(
|
||||
Orientation.Horizontal,
|
||||
Direction.Output,
|
||||
Port.Capacity.Single,
|
||||
typeof(TalkSceneSO));
|
||||
|
||||
port.portName = string.IsNullOrEmpty(exit.exitName) ? $"出口{index}" : exit.exitName;
|
||||
port.userData = index;
|
||||
|
||||
// 限制端口标签宽度并放大字体
|
||||
var label = port.Q<Label>("type");
|
||||
if (label != null)
|
||||
{
|
||||
label.style.maxWidth = PortLabelMaxWidth;
|
||||
label.style.fontSize = 12;
|
||||
}
|
||||
|
||||
port.AddManipulator(new EdgeConnector<Edge>(new EmptyEdgeListener()));
|
||||
|
||||
port.RegisterCallback<ContextualMenuPopulateEvent>((evt) =>
|
||||
{
|
||||
var currentIndex = port.userData is int idx ? idx : -1;
|
||||
evt.menu.AppendAction("删除出口", (_) => RemoveExitAt(currentIndex));
|
||||
}, TrickleDown.TrickleDown);
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
private void BuildSubtitle()
|
||||
{
|
||||
var subtitleLabel = new Label($"{SceneSo.name}")
|
||||
{
|
||||
style =
|
||||
{
|
||||
fontSize = 12,
|
||||
unityTextAlign = TextAnchor.MiddleCenter,
|
||||
color = new Color(0.8f, 0.8f, 0.8f),
|
||||
paddingTop = 4,
|
||||
paddingBottom = 2
|
||||
}
|
||||
};
|
||||
extensionContainer.Add(subtitleLabel);
|
||||
RefreshExpandedState();
|
||||
}
|
||||
|
||||
private void BuildAddExitButton()
|
||||
{
|
||||
var removeButton = new Button(() => RemoveLastExit())
|
||||
{
|
||||
text = "-",
|
||||
style =
|
||||
{
|
||||
width = 26,
|
||||
height = 22,
|
||||
fontSize = 16,
|
||||
unityFontStyleAndWeight = FontStyle.Bold,
|
||||
marginRight = 4
|
||||
}
|
||||
};
|
||||
titleButtonContainer.Add(removeButton);
|
||||
|
||||
var addButton = new Button(() => AddExit())
|
||||
{
|
||||
text = "+",
|
||||
style = { width = 26, height = 22, fontSize = 16, unityFontStyleAndWeight = FontStyle.Bold }
|
||||
};
|
||||
titleButtonContainer.Add(addButton);
|
||||
}
|
||||
|
||||
private void RemoveLastExit()
|
||||
{
|
||||
if (SceneSo.exits == null || SceneSo.exits.Count == 0) return;
|
||||
RemoveExitAt(SceneSo.exits.Count - 1);
|
||||
}
|
||||
|
||||
public void AddExit()
|
||||
{
|
||||
Undo.RecordObject(SceneSo, "Add Exit");
|
||||
if (SceneSo.exits == null)
|
||||
SceneSo.exits = new System.Collections.Generic.List<SceneExit>();
|
||||
|
||||
var newExit = new SceneExit
|
||||
{
|
||||
exitName = $"exit_{SceneSo.exits.Count}"
|
||||
};
|
||||
SceneSo.exits.Add(newExit);
|
||||
EditorUtility.SetDirty(SceneSo);
|
||||
|
||||
var newPort = CreateOutputPort(newExit, SceneSo.exits.Count - 1);
|
||||
outputContainer.Add(newPort);
|
||||
RefreshPorts();
|
||||
}
|
||||
|
||||
public void RemoveExitAt(int index)
|
||||
{
|
||||
if (SceneSo.exits == null || index < 0 || index >= SceneSo.exits.Count) return;
|
||||
|
||||
// 断开并移除该端口的所有边
|
||||
var portToRemove = GetOutputPort(index);
|
||||
if (portToRemove != null)
|
||||
{
|
||||
var edges = portToRemove.connections.ToList();
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
edge.input?.Disconnect(edge);
|
||||
edge.output?.Disconnect(edge);
|
||||
edge.RemoveFromHierarchy();
|
||||
}
|
||||
portToRemove.RemoveFromHierarchy();
|
||||
}
|
||||
|
||||
Undo.RecordObject(SceneSo, "Remove Exit");
|
||||
SceneSo.exits.RemoveAt(index);
|
||||
EditorUtility.SetDirty(SceneSo);
|
||||
|
||||
// 更新后续端口的 userData 和 portName
|
||||
for (int i = index; i < SceneSo.exits.Count; i++)
|
||||
{
|
||||
var port = GetOutputPort(i);
|
||||
if (port != null)
|
||||
{
|
||||
port.userData = i;
|
||||
var exit = SceneSo.exits[i];
|
||||
port.portName = string.IsNullOrEmpty(exit.exitName) ? $"出口{i}" : exit.exitName;
|
||||
}
|
||||
}
|
||||
|
||||
RefreshPorts();
|
||||
}
|
||||
|
||||
public void RefreshOutputPorts()
|
||||
{
|
||||
// 先断开所有输出端口的边
|
||||
var edgesToRemove = outputContainer.Children()
|
||||
.OfType<Port>()
|
||||
.SelectMany(p => p.connections.ToList())
|
||||
.ToList();
|
||||
|
||||
foreach (var edge in edgesToRemove)
|
||||
{
|
||||
edge.input?.Disconnect(edge);
|
||||
edge.output?.Disconnect(edge);
|
||||
edge.RemoveFromHierarchy();
|
||||
}
|
||||
|
||||
BuildOutputPorts();
|
||||
RefreshPorts();
|
||||
}
|
||||
|
||||
public Port GetOutputPort(int index)
|
||||
{
|
||||
return outputContainer.Children().OfType<Port>().ElementAtOrDefault(index);
|
||||
}
|
||||
|
||||
public override void SetPosition(Rect newPos)
|
||||
{
|
||||
base.SetPosition(newPos);
|
||||
if (SceneSo != null)
|
||||
{
|
||||
SceneSo.graphPosition = newPos.position;
|
||||
EditorUtility.SetDirty(SceneSo);
|
||||
}
|
||||
}
|
||||
|
||||
public void SyncPositionToSo()
|
||||
{
|
||||
if (SceneSo == null) return;
|
||||
var pos = GetPosition();
|
||||
if (SceneSo.graphPosition != pos.position)
|
||||
{
|
||||
SceneSo.graphPosition = pos.position;
|
||||
EditorUtility.SetDirty(SceneSo);
|
||||
}
|
||||
}
|
||||
|
||||
private class EmptyEdgeListener : IEdgeConnectorListener
|
||||
{
|
||||
public void OnDropOutsidePort(Edge edge, Vector2 position) { }
|
||||
public void OnDrop(GraphView graphView, Edge edge) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3a4f1beaaa3a5740b1904f8b9b926b7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,32 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
[CustomEditor(typeof(TalkSceneSO))]
|
||||
public class TalkSceneSOEditor : Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("exits"), true);
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("displayOrder"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("yarnProject"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("firstSceneName"));
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("显示信息", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("title"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("description"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("coverPic"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("chapter"));
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("编辑器设置", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("nodeColor"), new GUIContent("节点颜色"));
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4988e2ee30c565e40989210408a234e3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user