【a】可视化剧本编辑器 10.StoryEditor

This commit is contained in:
mzh
2026-01-06 14:24:23 +08:00
parent f055116d4d
commit 2e8accfed8
80 changed files with 3145 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 63c081c2eb3009043870d3700881058e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 52e52a16b8e597e48a3c645e65005b09
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 剧本数据
/// </summary>
[Serializable]
public class GraphData
{
/// <summary>
/// 剧本名称
/// </summary>
public string name;
/// <summary>
/// 节点
/// </summary>
public List<NodeData> nodes = new();
/// <summary>
/// 起始节点索引
/// </summary>
public int startNodeIndex;
/// <summary>
/// 资源加载类型
/// </summary>
public string resourceType;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c69fad22bb97abf42a50d1b2d0e74a42
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 77eaac9f599722345a3a3ae21a895fbd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace Stary.Evo.StoryEditor
{
[Serializable]
public class BeginNodeData : NodeData
{
public List<int> next = new();
public BeginNodeData()
{
}
public BeginNodeData(NodeData data)
{
name = data.name;
type = NodeType.Begin;
}
public new BeginNodePlayer GetPlayer(GraphPlayer graph)=>
new(graph, this);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fc62c71e8fb75c24eb096c74a651309d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace Stary.Evo.StoryEditor
{
[Serializable]
public class EndNodeData : NodeData
{
public List<int> pre = new();
public EndNodeData()
{
}
public EndNodeData(NodeData data)
{
name = data.name;
type = NodeType.End;
}
public new EndNodePlayer GetPlayer(GraphPlayer graph) =>
new(graph, this);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 407d564c2ca4c65499f8bf38949aec58
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 空节点(流程节点)数据
/// </summary>
[Serializable]
public class FlowNodeData : NodeData
{
public List<int> pre = new();
public List<int> next = new();
/// <summary>
/// 后续连接执行类型
/// </summary>
public NodeExecuteType executeType = NodeExecuteType.Async;
public FlowNodeData()
{
}
public FlowNodeData(NodeData data)
{
name = data.name;
type = NodeType.Empty;
}
public new FlowNodePlayer GetPlayer(GraphPlayer graph)=>
new(graph, this);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b0bafbcc38995f04c9189cc5a1491099
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
using System;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 节点数据
/// </summary>
[Serializable]
public class NodeData
{
/// <summary>
/// 节点名称
/// </summary>
public string name = "Unnamed Node";
/// <summary>
/// 节点类型
/// </summary>
public NodeType type = NodeType.Empty;
public NodePlayer GetPlayer(GraphPlayer graph)=>
new(graph, this);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d46e1dcd9310eed41a27d6e0c8c5066b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
using System;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 剧本段落节点数据
/// </summary>
[Serializable]
public class ScriptParagraphNodeData : FlowNodeData
{
/// <summary>
/// 字幕路径
/// </summary>
public ResourcePathData captionPath;
/// <summary>
/// 语音路径
/// </summary>
public ResourcePathData audioPath;
public ScriptParagraphNodeData()
{
}
public ScriptParagraphNodeData(FlowNodeData data) : base(data)
{
type = NodeType.Paragraph;
executeType = data.executeType;
}
public new ScriptParagraphNodePlayer GetPlayer(GraphPlayer graph) =>
new(graph,this);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c2ddf1032a2ad2541ab9ddae9772d0d0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+42
View File
@@ -0,0 +1,42 @@
using Sirenix.OdinInspector;
using UnityEngine;
namespace Stary.Evo.StoryEditor
{
public enum NodeType
{
/// <summary>
/// 空节点(流程节点)
/// </summary>
Empty,
/// <summary>
/// 起始节点
/// </summary>
Begin,
/// <summary>
/// 结束节点
/// </summary>
End,
/// <summary>
/// 剧本段落
/// </summary>
Paragraph
}
/// <summary>
/// 节点执行类型
/// </summary>
public enum NodeExecuteType
{
/// <summary>
/// 异步
/// </summary>
[LabelText("异步"), Tooltip("异步执行意味着Exit连接的多条支路将同时进行")]
Async,
/// <summary>
/// 同步
/// </summary>
[LabelText("同步"), Tooltip("同步执行意味着Exit连接的多条支路将依次进行")]
Sync
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4d4772e98c371834284ea14f4b641510
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: edf21b4cd79b9cf459596d41bb2a1da2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5a786bcde0f40a044b8e37ee058a125d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Stary.Evo.StoryEditor
{
public class GraphPlayer
{
/// <summary>
/// 剧本名称
/// </summary>
public string Name;
/// <summary>
/// 节点
/// </summary>
public List<NodePlayer> Nodes = new();
/// <summary>
/// 起始节点
/// </summary>
public BeginNodePlayer BeginNode;
/// <summary>
/// 结束节点
/// </summary>
public EndNodePlayer EndNode;
/// <summary>
/// 当前进行中的节点
/// </summary>
public List<NodePlayer> CurrentNodes = new();
/// <summary>
/// 播放完成
/// </summary>
public bool Finished;
/// <summary>
/// CTS
/// </summary>
public CancellationTokenSource Cts = new();
public GraphPlayer(GraphData data, IResource loader)
{
// 检查加载方式
if (loader.GetType().ToString() != data.resourceType)
{
Debug.LogError($"加载方式不匹配,剧本无法加载:{data.name}\n需要的加载方式: {data.resourceType}");
return;
}
Name = data.name;
data.nodes.ForEach(node =>
{
switch (node)
{
case BeginNodeData beginNode:
Nodes.Add(beginNode.GetPlayer(this));
break;
case EndNodeData endNode:
Nodes.Add(endNode.GetPlayer(this));
break;
case ScriptParagraphNodeData paraNode:
Nodes.Add(paraNode.GetPlayer(this));
break;
case FlowNodeData flowNode:
Nodes.Add(flowNode.GetPlayer(this));
break;
}
});
BeginNode = (BeginNodePlayer)Nodes[data.startNodeIndex];
BeginNode.Connect();
}
/// <summary>
/// 执行剧本
/// </summary>
public async UniTask Execute()
{
// 初始化结束标志
Finished = false;
// 执行剧本
_ = BeginNode.Execute();
// 等待剧本完成
await UniTask.WaitUntil(() => Finished);
}
/// <summary>
/// 停止剧本
/// </summary>
public void Stop()
{
// 取消并重置CancelToken
Cts.Cancel();
Cts = new();
// 标记剧本结束
Finished = true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ce92dbf7b238c4442b75ea500c804107
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: adab23e0caf805e45b47a9a0c88ec076
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Stary.Evo.StoryEditor
{
public class BeginNodePlayer : NodePlayer
{
public new BeginNodeData Data;
public List<NodePlayer> Next = new();
public BeginNodePlayer(GraphPlayer graph, BeginNodeData data) : base(graph, data)
{
Data = data;
}
public override bool Connect()
{
Debug.Log("BeginNodePlayer: Connect");
if (!base.Connect())
return false;
Data.next.ForEach(index => Next.Add(Graph.Nodes[index]));
Next.ForEach(node => node.Connect());
return true;
}
public override async UniTask Execute()
{
Init();
Debug.Log($"开始执行剧本: {Graph.Name}");
await base.Execute();
await MoveNext();
}
public override UniTask MoveNext()
{
Next.ForEach(node => node?.Execute());
return base.MoveNext();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a5fb5ecf3bf3ef64996bd3a7905884f1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Stary.Evo.StoryEditor
{
public class EndNodePlayer : NodePlayer
{
public new EndNodeData Data;
public List<NodePlayer> Pre = new();
public EndNodePlayer(GraphPlayer graph, EndNodeData data) : base(graph, data)
{
Data = data;
}
public override bool Connect()
{
if(!base.Connect())
return false;
Data.pre.ForEach(index => Pre.Add(Graph.Nodes[index]));
return true;
}
public override UniTask Execute()
{
Init();
Debug.Log($"剧本执行完成: {Graph.Name}");
// 标记剧本完成
Graph.Finished = true;
ScriptPlayer.ReleaseGraph();
return base.Execute();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6894cd8522d600447966dc11b2a13905
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
namespace Stary.Evo.StoryEditor
{
public class FlowNodePlayer : NodePlayer
{
public new FlowNodeData Data;
public List<NodePlayer> Pre = new();
public List<NodePlayer> Next = new();
/// <summary>
/// 后续连接执行类型
/// </summary>
public NodeExecuteType ExecuteType;
public FlowNodePlayer(GraphPlayer graph, FlowNodeData data) : base(graph, data)
{
Data = data;
ExecuteType = data.executeType;
}
public override bool Connect()
{
if(!base.Connect())
return false;
Data.pre.ForEach(index => Pre.Add(Graph.Nodes[index]));
Data.next.ForEach(index => Next.Add(Graph.Nodes[index]));
Next.ForEach(node => node.Connect());
return true;
}
/// <summary>
/// 开始执行
/// </summary>
public override async UniTask Execute()
{
await base.Execute();
await MoveNext();
}
/// <summary>
/// 向下继续执行
/// </summary>
public override async UniTask MoveNext()
{
// 异步执行(并行)
if (ExecuteType == NodeExecuteType.Async)
{
Next.ForEach(node => node?.Execute());
}
// 同步执行(串行)
else
{
foreach (var node in Next.Where(node => node != null))
{
await node.Execute();
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 58805d324b439d242a445138bc3a33a2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using Cysharp.Threading.Tasks;
namespace Stary.Evo.StoryEditor
{
public class NodePlayer
{
/// <summary>
/// 节点名称
/// </summary>
protected string Name;
/// <summary>
/// 图表
/// </summary>
protected GraphPlayer Graph;
/// <summary>
/// 数据
/// </summary>
protected NodeData Data;
/// <summary>
/// 初始化标志
/// </summary>
protected bool Initialized;
public NodePlayer(GraphPlayer graph, NodeData data)
{
Graph = graph;
Name = data.name;
Data = data;
}
/// <summary>
/// 连接节点
/// </summary>
public virtual bool Connect()
{
if(Initialized)
return false;
Initialized = true;
return true;
}
/// <summary>
/// 初始化
/// </summary>
public virtual void Init() => Graph.CurrentNodes.Add(this);
/// <summary>
/// 开始执行
/// </summary>
public virtual UniTask Execute()
{
Graph.CurrentNodes.Remove(this);
return UniTask.CompletedTask;
}
/// <summary>
/// 向下继续执行
/// </summary>
public virtual UniTask MoveNext() => UniTask.CompletedTask;
/// <summary>
/// 停止执行
/// </summary>
public virtual UniTask Stop() => UniTask.CompletedTask;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b76627cea0be464c864e744ae0f0b45
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,32 @@
using Cysharp.Threading.Tasks;
namespace Stary.Evo.StoryEditor
{
public class ScriptParagraphNodePlayer : FlowNodePlayer
{
public new ScriptParagraphNodeData Data;
/// <summary>
/// 字幕路径
/// </summary>
public ResourcePathData CaptionPath;
/// <summary>
/// 语音路径
/// </summary>
public ResourcePathData AudioPath;
public ScriptParagraphNodePlayer(GraphPlayer graph, ScriptParagraphNodeData data) : base(graph, data)
{
Data = data;
CaptionPath = data.captionPath;
AudioPath = data.audioPath;
}
public override async UniTask Execute()
{
Init();
await ScriptPlayer.PlayScriptPara(this, Graph.Cts.Token);
await base.Execute();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b263f1eba1f46094cab95e1c34ff2605
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,149 @@
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using DG.Tweening;
using Newtonsoft.Json;
using UnityEngine;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 剧本播放器
/// </summary>
public static class ScriptPlayer
{
/// <summary>
/// 资源加载工具
/// </summary>
private static IResource _loader;
/// <summary>
/// 动画单次时长
/// </summary>
private const float AnimationTime = 0.2f;
/// <summary>
/// 字幕组件
/// </summary>
private static SpriteRenderer _caption;
/// <summary>
/// 音频组件
/// </summary>
private static AudioSource _audio;
/// <summary>
/// 当前执行的剧本
/// </summary>
private static GraphPlayer _graph;
/// <summary>
/// 初始化剧本播放器
/// </summary>
/// <param name="loader">资源加载工具</param>
/// <param name="audio">音频组件</param>
/// <param name="caption">字幕组件</param>
public static void Init(IResource loader, AudioSource audio = null, SpriteRenderer caption = null)
{
_loader = loader;
_caption = caption;
_audio = audio;
}
/// <summary>
/// 释放资源
/// </summary>
public static void Release()
{
_loader = null;
_caption = null;
_audio = null;
Resources.UnloadUnusedAssets();
}
/// <summary>
/// 播放剧本
/// </summary>
/// <param name="packageID">包体ID</param>
/// <param name="scriptName">剧本名称(不用加_txt后缀)</param>
public static async UniTask Play(string packageID, string scriptName)
{
// 资源加载工具排空
if (_loader == null)
{
Debug.LogError("资源加载工具未准备好");
return;
}
// 加载剧本
ResourcePathData path = new(packageID,scriptName);
var json = await _loader.Load<TextAsset>(path);
var scriptData = JsonConvert.DeserializeObject<GraphData>(json.text, new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All });
if (scriptData == null)
{
Debug.LogError($"剧本加载失败:[{path.packageID}]{path.path}");
return;
}
// 解析并执行剧本
_graph = new(scriptData, _loader);
await _graph.Execute();
}
/// <summary>
/// 播放剧本段落
/// </summary>
/// <param name="para">段落数据</param>
/// <param name="ct">CT</param>
public static async UniTask PlayScriptPara(ScriptParagraphNodePlayer para, CancellationToken ct)
{
// 设置变量
if (_caption)
_caption.sprite = await _loader.Load<Sprite>(para.CaptionPath);
if (_audio)
_audio.clip = await _loader.Load<AudioClip>(para.AudioPath);
// 淡入
_caption?.DOColor(Color.white, AnimationTime);
_audio?.DOFade(1, AnimationTime);
await UniTask.Delay(TimeSpan.FromSeconds(AnimationTime), cancellationToken:ct);
// 等音频播放完
if (_audio)
{
_audio.Play();
await UniTask.Delay(TimeSpan.FromSeconds(_audio.clip.length), cancellationToken:ct);
}
// 淡出
_caption?.DOColor(Color.clear, AnimationTime);
_audio?.DOFade(0, AnimationTime);
await UniTask.Delay(TimeSpan.FromSeconds(AnimationTime), cancellationToken:ct);
}
/// <summary>
/// 停止剧本
/// </summary>
public static async UniTask Stop()
{
_graph.Stop();
var hasAnim = false;
if (_caption && _caption.color != Color.clear)
{
_caption?.DOColor(Color.clear, AnimationTime);
hasAnim = true;
}
if (_audio && _audio.volume > 0)
{
_audio?.DOFade(0, AnimationTime);
hasAnim = true;
}
if(hasAnim)
await UniTask.Delay(TimeSpan.FromSeconds(AnimationTime));
ReleaseGraph();
}
public static void ReleaseGraph() => _graph = null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b9bd336685230b746bc67a3bb9dc2102
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6e59434304b735b44b6f025c51615cfa
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,24 @@
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace Stary.Evo.StoryEditor
{
public interface IResource
{
/// <summary>
/// 加载资源
/// Json => 资源
/// </summary>
/// <param name="pathData">资源路径</param>
/// <typeparam name="T">资源类型</typeparam>
UniTask<T> Load<T>(ResourcePathData pathData) where T : Object;
/// <summary>
/// 保存资源
/// 资源 => Json
/// </summary>
/// <param name="asset">资源</param>
/// <param name="packageID">包体ID</param>
UniTask<ResourcePathData> Save<T>(T asset, string packageID = null) where T : Object;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 68c399cab4c86fc4b961516a2802b94d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using System;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 资源路径数据
/// </summary>
[Serializable]
public struct ResourcePathData
{
/// <summary>
/// 包体ID
/// </summary>
public string packageID;
/// <summary>
/// 资源路径
/// </summary>
public string path;
public ResourcePathData(string packageID, string path)
{
this.packageID = packageID;
this.path = path;
}
public void AddPath(params string[] tail)
{
if(tail == null || tail.Length == 0)
return;
foreach (var t in tail)
{
path = string.IsNullOrEmpty(path) ? t : System.IO.Path.Combine(path, t);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0fd990a65a7964740b2d98d2557afbb8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 48bedbe80b198ef4d8811fac3b41d204
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4f25ed558908de741b709bb8f3df2427
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json;
using Sirenix.OdinInspector;
using Sirenix.Utilities;
using UnityEngine;
using XNode;
namespace Stary.Evo.StoryEditor
{
[Serializable]
public class ScriptGraph : NodeGraph
{
/// <summary>
/// 起始节点
/// </summary>
[SerializeField, ReadOnly]
private BeginNode begin;
/// <summary>
/// 结束节点
/// </summary>
[SerializeField, ReadOnly]
private EndNode end;
/// <summary>
/// 初始化标识
/// </summary>
[SerializeField, ReadOnly]
private bool initialized;
private void OnEnable()
{
if (nodes == null)
{
Debug.LogError("Graph异常,无有效节点列表存在");
return;
}
// 初始化
if (!initialized)
{
_ = DelayInitialize();
#if UNITY_EDITOR
UnityEditor.EditorUtility.SetDirty(this);
#endif
}
}
protected override void OnDestroy()
{
// 清理所有节点
nodes.ForEach(DestroyImmediate);
nodes.Clear();
}
#region
/// <summary>
/// 延迟初始化
/// </summary>
private async UniTask DelayInitialize()
{
// 等待Graph资产落盘
#if UNITY_EDITOR
while (string.IsNullOrEmpty(UnityEditor.AssetDatabase.GetAssetPath(this)))
{
await UniTask.Delay(TimeSpan.FromSeconds(0.01));
}
#endif
await UniTask.Delay(TimeSpan.FromSeconds(0.01));
// 创建初始节点
InitBeginNode();
InitEndNode();
CreateSampleScriptParagraph();
initialized = true;
}
/// <summary>
/// 初始化起始节点
/// </summary>
private void InitBeginNode()
{
var existing = nodes.FirstOrDefault(n => n is BeginNode) as BeginNode;
if (existing != null)
{
begin = existing;
return;
}
begin = NodeBase.Create<BeginNode>(this, "Begin", Vector2.zero);
}
/// <summary>
/// 初始化结束节点
/// </summary>
private void InitEndNode()
{
var existing = nodes.FirstOrDefault(n => n is EndNode) as EndNode;
if (existing != null)
{
end = existing;
return;
}
end = NodeBase.Create<EndNode>(this, "End", new Vector2(NodeBase.DefaultNodeXInterval, NodeBase.DefaultNodeYInterval)* 2);
}
/// <summary>
/// 创建剧本段落样例
/// </summary>
private void CreateSampleScriptParagraph()
{
// 创建新的剧本段落
var paragraph = ScriptParagraphNode.Create(this, $"{name}_para_sample", new Vector2(NodeBase.DefaultNodeXInterval, NodeBase.DefaultNodeYInterval));
// 将剧本与起始节点和结束节点相连
begin.GetOutputPort("exit").Connect(paragraph.GetInputPort("enter"));
paragraph.GetOutputPort("exit").Connect(end.GetInputPort("enter"));
}
#endregion
#region
/// <summary>
/// 资源加载方式
/// </summary>
[BoxGroup("Export", centerLabel:true)]
[LabelText("资源加载方式"), ValueDropdown(nameof(_iResourceTypes)),SerializeField]
private string loaderType;
#if UNITY_EDITOR
/// <summary>
/// 获取继承 IResource 的所有类
/// </summary>
private HashSet<string> _iResourceTypes = AssemblyUtilities.GetTypes(AssemblyCategory.Scripts)
.Where(t => t.IsClass && typeof(IResource).IsAssignableFrom(t)).Select(t => t.ToString()).ToHashSet();
#else
private HashSet<string> _iResourceTypes = new();
#endif
private IResource _loader;
/// <summary>
/// 资源加载器
/// </summary>
public IResource Loader
{
get
{
if (_loader == null)
{
var type = Type.GetType(loaderType);
_loader = type == null ? null : (IResource)Activator.CreateInstance(type);
}
return _loader;
}
}
[BoxGroup("Export")]
[LabelText("包体ID")]
public string packageID;
[BoxGroup("Export")]
[LabelText("Graph目录地址")]
public string graphPath;
[BoxGroup("Export")]
[Button("导出")]
public async void Export()
{
// 资源加载方式排空
if (Loader == null)
{
Debug.LogError("导出失败,没有选择资源加载方式");
return;
}
Dictionary<Node, NodeData> dataMatch = new();
Dictionary<NodeData, NodeBase> nodeMatch = new();
// 创建图表数据
GraphData graph = new();
graph.name = name;
graph.resourceType = loaderType;
// 转录节点数据
foreach (var node in nodes)
{
if(node is not NodeBase bNode)
continue;
NodeData dNode = new();
switch (bNode)
{
case BeginNode beginNode:
dNode = await beginNode.Export();
break;
case EndNode endNode:
dNode = await endNode.Export();
break;
case ScriptParagraphNode paraNode:
dNode = await paraNode.Export();
break;
case FlowNode flowNode:
dNode = await flowNode.Export();
break;
default:
Debug.LogError($"节点{node.name}[type:{node.GetType()}]没有定义对应的数据转换逻辑,该节点将被跳过,此举将可能导致graph出现流程断裂或其他未知异常");
break;
}
dataMatch.Add(bNode, dNode);
nodeMatch.Add(dNode, bNode);
graph.nodes.Add(dNode);
}
// 转录节点连接
foreach (var data in nodeMatch.Keys)
{
switch (data)
{
// 起始节点
case BeginNodeData beginData:
// 记录图表首个节点的索引
graph.startNodeIndex = graph.nodes.IndexOf(beginData);
// 连接后部节点
nodeMatch[beginData].GetOutputPort("exit").GetConnections().ForEach(oNode => beginData.next.Add(graph.nodes.IndexOf(dataMatch[oNode.node])));
break;
// 结束节点
case EndNodeData endData:
// 连接前部节点
nodeMatch[endData].GetInputPort("enter").GetConnections().ForEach(oNode => endData.pre.Add(graph.nodes.IndexOf(dataMatch[oNode.node])));
break;
// 空节点(流程节点)
case FlowNodeData flowData:
// 连接前部节点
nodeMatch[flowData].GetInputPort("enter").GetConnections().ForEach(oNode => flowData.pre.Add(graph.nodes.IndexOf(dataMatch[oNode.node])));
// 连接后部节点
nodeMatch[flowData].GetOutputPort("exit").GetConnections().ForEach(oNode => flowData.next.Add(graph.nodes.IndexOf(dataMatch[oNode.node])));
break;
// 处理异常情况
default:
Debug.LogError($"节点{data.name}[type:{data.GetType()}]没有定义对应的数据转换逻辑,该节点将被跳过,此举将可能导致graph出现流程断裂或其他未知异常");
break;
}
}
Debug.Log("转录完成");
// 将转录完成的图表数据序列化为json
var json = JsonConvert.SerializeObject(graph, new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All });
// 文件目录排空
var graphFilePath = Path.Combine(Application.dataPath, graphPath.Replace(".asset", ".sg.json"));
if (!Directory.Exists(Path.GetDirectoryName(graphFilePath)))
Directory.CreateDirectory(Path.GetDirectoryName(graphFilePath) ?? string.Empty);
// 写入json
await File.WriteAllTextAsync(graphFilePath, json);
#if UNITY_EDITOR
UnityEditor.AssetDatabase.ImportAsset(graphFilePath.Replace(Application.dataPath, "Assets"));
#endif
Debug.Log("剧本导出完成");
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 96f13bed6a3bb274abdf828cac308231
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 90e4f51c40e7d9340a88df558072b353
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
using System;
using Cysharp.Threading.Tasks;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 起始节点
/// </summary>
[Serializable, CreateNodeMenu("")]
public class BeginNode : NodeBase
{
[Output]
public Exit exit;
/// <summary>
/// 导出
/// </summary>
public new async UniTask<BeginNodeData> Export() => new(await base.Export());
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f7bdd4ad134525043830b54f6a2391f6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
using System;
using Cysharp.Threading.Tasks;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 结束节点
/// </summary>
[Serializable, CreateNodeMenu("")]
public class EndNode : NodeBase
{
[Input]
public Enter enter;
/// <summary>
/// 导出
/// </summary>
public new async UniTask<EndNodeData> Export() => new(await base.Export());
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5e8d1b2bbe7b8154c8822cf7c0b6e064
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using Sirenix.OdinInspector;
using UnityEngine;
using XNode;
namespace Stary.Evo.StoryEditor
{
[Serializable,CreateNodeMenu("")]
public class FlowNode : NodeBase
{
[HorizontalGroup("Port", order:0)]
[Input]
public Enter enter;
[HorizontalGroup("Port")]
[Output]
public Exit exit;
/// <summary>
/// Exit端口连接的节点的执行类型
/// </summary>
[BoxGroup("Config", order:5, showLabel:false)]
[LabelText("后续执行")]
public NodeExecuteType exitNodeExecuteType = NodeExecuteType.Async;
/// <summary>
/// 创建新的流程节点
/// </summary>
/// <param name="graph">所在的块</param>
/// <param name="name">节点名称</param>
/// <param name="position">节点位置</param>
/// <param name="prePorts">节点前部连接</param>
/// <param name="nextPorts">节点后部连接</param>
public static T Create<T>(NodeGraph graph, string name = null, Vector2 position = default,
List<NodePort> prePorts = null, List<NodePort> nextPorts = null) where T:FlowNode
{
// 创建节点
var node = NodeBase.Create<T>(graph, name, position);
if (node == null)
return null;
// 将剧本与前部节点和后部节点相连
var enterPort = node.GetInputPort("enter");
if (enterPort != null && prePorts != null)
prePorts.ForEach(port => port.Connect(enterPort));
var exitPort = node.GetInputPort("exit");
if (exitPort != null && nextPorts != null)
nextPorts.ForEach(port => port.Connect(exitPort));
return node;
}
#region
/// <summary>
/// 将自身的入口连接传递给指定端口
/// </summary>
/// <param name="otherPort">指定端口</param>
/// <param name="deleteSelf">传递后删除自身连接</param>
public void GiveEnterPortToOtherPort(NodePort otherPort, bool deleteSelf)
=> GiveConnectionToOtherPort(GetInputPort("enter"), otherPort, deleteSelf);
/// <summary>
/// 将自身的出口连接传递给指定端口
/// </summary>
/// <param name="otherPort">指定端口</param>
/// <param name="deleteSelf">传递后删除自身连接</param>
public void GiveExitPortToOtherPort(NodePort otherPort, bool deleteSelf = false)
=> GiveConnectionToOtherPort(GetOutputPort("exit"), otherPort, deleteSelf);
#endregion
#region
/// <summary>
/// 向前插入节点
/// </summary>
[HorizontalGroup("Insert", order:1)]
[Button("(←)插入节点")]
public void InsertForward()
{
// 生成新的空节点
var newNode = SelectionNode.Create(graph);
// 将本节点的Enter连接给到新的节点的Enter
GiveEnterPortToOtherPort(newNode.GetInputPort("enter"), true);
// 将新的节点的Exit连接到本节点的Enter
GetInputPort("enter").Connect(newNode.GetOutputPort("exit"));
// 将新节点设定为本节点位置
newNode.position = position;
// 将包括自身在内的所有后续节点向后移动一个默认间隔
newNode.DoActionToAllNodesAfter((thisNode, otherNode) => otherNode.position = thisNode.position + new Vector2(DefaultNodeXInterval, DefaultNodeYInterval));
}
/// <summary>
/// 向后插入节点
/// </summary>
[HorizontalGroup("Insert")]
[Button("插入节点(→)")]
public void InsertBackward()
{
// 生成新的空节点
var newNode = SelectionNode.Create(graph);
// 将本节点的Exit连接给到新的节点的Exit
GiveExitPortToOtherPort(newNode.GetOutputPort("exit"), true);
// 将新的节点的Enter连接到本节点的Exit
GetOutputPort("exit").Connect(newNode.GetInputPort("enter"));
// 将新节点设置为本节点位置后移一格
newNode.position = position + new Vector2(DefaultNodeXInterval, DefaultNodeYInterval);
// 将所有后续节点向后移动一个默认间隔
newNode.DoActionToAllNodesAfter((thisNode, otherNode) => otherNode.position = thisNode.position + new Vector2(DefaultNodeXInterval, DefaultNodeYInterval));
}
#endregion
/// <summary>
/// 导出
/// </summary>
public new async UniTask<FlowNodeData> Export()
{
FlowNodeData node = new(await base.Export());
node.executeType = exitNodeExecuteType;
return node;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e85b8c018e8b68b418fff4c389089665
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,134 @@
using System;
using System.Linq;
using Cysharp.Threading.Tasks;
using UnityEngine;
using XNode;
namespace Stary.Evo.StoryEditor
{
[Serializable,CreateNodeMenu("")]
public class NodeBase : Node
{
[Serializable] public class Enter { }
[Serializable] public class Exit { }
/// <summary>
/// 默认节点位置间隔(x轴)
/// </summary>
public const float DefaultNodeXInterval = 250;
/// <summary>
/// 默认节点位置间隔(y轴)
/// </summary>
public const float DefaultNodeYInterval = 0;
/// <summary>
/// 创建新的节点
/// </summary>
/// <param name="graph">所在的块</param>
/// <param name="name">节点名称</param>
/// <param name="position">节点位置</param>
public static T Create<T>(NodeGraph graph, string name = null, Vector2 position = default) where T : NodeBase
{
// 创建节点
var node = graph.AddNode<T>();
if (node == null)
{
Debug.LogError("节点创建失败");
return null;
}
#if UNITY_EDITOR
// 将节点落盘
UnityEditor.AssetDatabase.AddObjectToAsset(node, graph);
UnityEditor.EditorUtility.SetDirty(graph);
#endif
// 设置节点变量
node.position = position;
node.name = name ?? $"{name}_para_sample";
// 初始化节点
node.Init();
return node;
}
/// <summary>
/// 移除自身
/// </summary>
public void DestroySelf()
{
// 清理连接
ClearConnections();
// 移除与Graph之间的联系
if (graph)
{
graph.nodes.Remove(this);
#if UNITY_EDITOR
UnityEditor.AssetDatabase.RemoveObjectFromAsset(this);
#endif
}
// 销毁自身
DestroyImmediate(this);
}
public UniTask<NodeData> Export()
{
NodeData nodeData = new();
nodeData.name = name;
return UniTask.FromResult(nodeData);
}
#region
/// <summary>
/// 将端口A的连接传递给端口B
/// </summary>
/// <param name="portA">端口A</param>
/// <param name="portB">端口B</param>
/// <param name="deleteA">移除端口A的连接</param>
public static void GiveConnectionToOtherPort(NodePort portA, NodePort portB, bool deleteA = false)
{
// 排除端口A与端口B重合的情况
if (portA == portB)
return;
// 获取当前Enter端口的所有连接
var enterPorts = portA.GetConnections();
enterPorts.ForEach(port =>
{
// 将Enter端口与该端口断开
if (deleteA)
port.Disconnect(portA);
// 将指定的端口与该端口连接
port.Connect(portB);
});
}
/// <summary>
/// 对所有之后的节点执行动作
/// </summary>
/// <param name="action">指定动作</param>
public void DoActionToAllNodesAfter(Action<NodeBase, NodeBase> action)
{
// 所有出口端口
Ports.Where(p => p.IsOutput).ToList().ForEach(port =>
{
// 所有出口端口连接的所有端口
port.GetConnections().ForEach(otherSide =>
{
// 所有出口端口连接的所有端口的是NodeBase的节点
if (otherSide.node is NodeBase baseNode)
{
action?.Invoke(this, baseNode);
baseNode.DoActionToAllNodesAfter(action);
}
});
});
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 128757ef2e3a23c48b67ade15852c36c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using Sirenix.OdinInspector;
using UnityEngine;
using XNode;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 剧本自然段
/// </summary>
[Serializable, CreateNodeMenu("创建剧本自然段", order = 5)]
public class ScriptParagraphNode : FlowNode
{
/// <summary>
/// 名称
/// (仅供辨识,不起实际作用)
/// </summary>
[BoxGroup("Info", order:10, showLabel:false)]
[LabelText("名称"),LabelWidth(30)]
public string displayName;
/// <summary>
/// 字幕
/// </summary>
[BoxGroup("Info")]
[LabelText("字幕"),LabelWidth(30)]
public Sprite caption;
/// <summary>
/// 语音
/// </summary>
[BoxGroup("Info")]
[LabelText("音频"),LabelWidth(30)]
public AudioClip audio;
/// <summary>
/// 创建新的剧本段落节点
/// </summary>
/// <param name="graph">所在的块</param>
/// <param name="name">节点名称</param>
/// <param name="position">节点位置</param>
/// <param name="prePorts">节点前部连接</param>
/// <param name="nextPorts">节点后部连接</param>
/// <param name="caption">字幕</param>
/// <param name="audio">音频</param>
public static ScriptParagraphNode Create(NodeGraph graph, string name = null, Vector2 position = default, List<NodePort> prePorts = null, List<NodePort> nextPorts = null, Sprite caption = null, AudioClip audio = null)
{
// 创建节点
var node = Create<ScriptParagraphNode>(graph,name,position, prePorts, nextPorts);
if (node == null)
return null;
// 设置变量
node.caption = caption;
node.audio = audio;
return node;
}
protected override void Init()
{
base.Init();
UpdateName();
}
private void OnValidate()
{
if (graph == null)
{
Debug.LogWarning($"项目中存在幽灵节点:{name}");
return;
}
UpdateName();
}
/// <summary>
/// 更新名称
/// </summary>
private void UpdateName()
{
if (string.IsNullOrEmpty(displayName))
{
displayName = graph.name + "_para";
}
name = displayName;
}
public new async UniTask<ScriptParagraphNodeData> Export()
{
var node = new ScriptParagraphNodeData(await base.Export());
var sGraph = (ScriptGraph)graph;
// 记录音频资源地址
if (audio)
node.audioPath = await sGraph.Loader.Save(audio,sGraph.packageID);
// 记录字幕资源地址
if (caption)
node.captionPath = await sGraph.Loader.Save(caption,sGraph.packageID);
return node;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7e0dc65de1614514883efb2a6848e5a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;
using XNode;
namespace Stary.Evo.StoryEditor
{
/// <summary>
/// 空节点
/// (可选择应用哪种节点)
/// </summary>
[Serializable, CreateNodeMenu("创建空节点", order = 0)]
public class SelectionNode : FlowNode
{
/// <summary>
/// 创建新的空节点
/// </summary>
/// <param name="graph">所在的块</param>
/// <param name="name">节点名称</param>
/// <param name="position">节点位置</param>
/// <param name="prePorts">节点前部连接</param>
/// <param name="nextPorts">节点后部连接</param>
public static SelectionNode Create(NodeGraph graph, string name = null, Vector2 position = default,
List<NodePort> prePorts = null, List<NodePort> nextPorts = null)
{
var node = Create<SelectionNode>(graph, name, position, prePorts, nextPorts);
return node;
}
/// <summary>
/// 创建剧本段落
/// </summary>
[BoxGroup("Option", order:20, showLabel:false)]
[Button("剧本段落")]
public void ChooseScriptParagraph()
{
// 记录动作缓存
#if UNITY_EDITOR
UnityEditor.Undo.RecordObject(graph, "Delete Node");
#endif
// 在本节点位置创建剧本段落节点
var node = ScriptParagraphNode.Create(graph, position: position);
GiveEnterPortToOtherPort(node.GetInputPort("enter"), true);
GiveExitPortToOtherPort(node.GetOutputPort("exit"), true);
// 移除本节点
DestroySelf();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79ef9a28ecfd1384a96569da56b2022b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: