2026-06-19 다이얼로그 그래프툴

This commit is contained in:
2026-06-19 15:30:42 +09:00
parent b1e85a5b89
commit 2af5ba1357
26 changed files with 753 additions and 2 deletions

View File

@@ -0,0 +1,43 @@
using System.Linq;
using Unity.GraphToolkit.Editor;
using UnityEditor;
namespace WhaleAdventure.Dialog.GraphTool.Editor
{
// 고래 대화 그래프.
// 기존 Communication/Dialog 시스템(DialogGroup / DialogNode / DialogChoice)을
// 노드 그래프로 저작하기 위한 에디터 전용 그래프 타입이다.
// 임포트 시 DialogGraphImporter가 이 그래프를 DialogGroup 에셋으로 변환한다.
[Graph(AssetExtension)]
internal class DialogGraph : Graph
{
// ScriptedImporter가 사용하는 확장자. 프로젝트 내에서 유일해야 한다.
public const string AssetExtension = "wdg"; // Whale Dialog Graph
const string k_DefaultName = "New Dialog Graph";
[MenuItem("Assets/Create/Communication/Dialog Graph")]
static void CreateAssetFile()
{
GraphDatabase.PromptInProjectBrowserToCreateNewAsset<DialogGraph>(k_DefaultName);
}
// 그래프가 바뀔 때마다 호출되어 에러/경고를 보고한다.
public override void OnGraphChanged(GraphLogger infos)
{
base.OnGraphChanged(infos);
var startNodes = GetNodes().OfType<DialogStartNode>().ToList();
switch (startNodes.Count)
{
case 0:
infos.LogError("Start 노드가 필요합니다. (Dialog Start Node를 추가하세요)", this);
break;
case >= 2:
foreach (var extra in startNodes.Skip(1))
infos.LogWarning("Start 노드는 하나만 사용됩니다. 가장 먼저 생성된 노드만 적용됩니다.", extra);
break;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f25a0745082bd7c439679495337d3598

View File

@@ -0,0 +1,166 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Unity.GraphToolkit.Editor;
using UnityEditor.AssetImporters;
using UnityEngine;
namespace WhaleAdventure.Dialog.GraphTool.Editor
{
// .wdg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
// 따라서 DialogPlayer는 수정 없이 임포트된 .wdg 에셋(= DialogGroup)을 그대로 사용한다.
[ScriptedImporter(1, DialogGraph.AssetExtension)]
internal class DialogGraphImporter : ScriptedImporter
{
public override void OnImportAsset(AssetImportContext ctx)
{
var graph = GraphDatabase.LoadGraphForImporter<DialogGraph>(ctx.assetPath);
if (graph == null)
{
Debug.LogError($"[DialogGraphImporter] 그래프 로드 실패: {ctx.assetPath}");
return;
}
// 메인 에셋: DialogGroup (이름은 파일명 기준 — DialogPlayer가 이름으로 조회)
var groupName = Path.GetFileNameWithoutExtension(ctx.assetPath);
var group = ScriptableObject.CreateInstance<DialogGroup>();
group.name = groupName;
group.DialogGroupName = groupName;
ctx.AddObjectToAsset("Group", group);
ctx.SetMainObject(group);
var startNode = graph.GetNodes().OfType<DialogStartNode>().FirstOrDefault();
if (startNode == null)
return; // OnGraphChanged에서 에러 로깅됨
var firstGraphNode = GetConnectedNode(startNode, DialogGraphNode.EXEC_OUT);
if (firstGraphNode == null)
return; // Start만 있고 연결 없음
// 1패스: 도달 가능한 모든 라인 노드 → DialogNode 인스턴스 생성 (중복 제거)
var map = new Dictionary<INode, DialogNode>();
var order = new List<INode>();
var queue = new Queue<INode>();
queue.Enqueue(firstGraphNode);
while (queue.Count > 0)
{
var gn = queue.Dequeue();
if (gn == null || map.ContainsKey(gn) || gn is not DialogLineNode)
continue;
var dn = ScriptableObject.CreateInstance<DialogNode>();
dn.Choices = new List<DialogChoice>();
map[gn] = dn;
order.Add(gn);
foreach (var next in GetSuccessors(gn))
if (next != null && !map.ContainsKey(next))
queue.Enqueue(next);
}
// 서브에셋 등록 + 이름 지정
for (int i = 0; i < order.Count; i++)
{
var dn = map[order[i]];
dn.name = $"Node_{i:00}";
ctx.AddObjectToAsset(dn.name, dn);
}
// 2패스: 데이터/링크 채우기
foreach (var gn in order)
{
var line = (DialogLineNode)gn;
var dn = map[gn];
dn.Speaker = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogLineNode.PORT_SPEAKER));
dn.TalkText = GetInputPortValue<DialogText>(gn.GetInputPortByName(DialogLineNode.PORT_TALK)).Value;
dn.Gesture = GetInputPortValue<GestureData>(gn.GetInputPortByName(DialogLineNode.PORT_GESTURE));
dn.Expression = GetInputPortValue<ExpressionData>(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION));
dn.Voice = GetInputPortValue<VoiceClip>(gn.GetInputPortByName(DialogLineNode.PORT_VOICE));
dn.LineDuration = GetInputPortValue<float>(gn.GetInputPortByName(DialogLineNode.PORT_DURATION));
dn.LookAtPlayer = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_LOOKAT));
int choiceCount = 0;
line.GetNodeOptionByName(DialogLineNode.OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
if (choiceCount <= 0)
{
var next = GetConnectedNode(gn, DialogGraphNode.EXEC_OUT);
dn.Next = next != null && map.TryGetValue(next, out var nextDn) ? nextDn : null;
}
else
{
dn.ChoiceQuestion = GetInputPortValue<string>(gn.GetInputPortByName(DialogLineNode.PORT_QUESTION));
for (int i = 0; i < choiceCount; i++)
{
var choiceText = GetInputPortValue<string>(gn.GetInputPortByName(DialogLineNode.ChoiceTextPort(i)));
var dest = GetConnectedNode(gn, DialogLineNode.ChoiceOutPort(i));
dn.Choices.Add(new DialogChoice
{
ChoiceText = choiceText,
DestinationNode = dest != null && map.TryGetValue(dest, out var destDn) ? destDn : null
});
}
}
}
group.StartNode = map.TryGetValue(firstGraphNode, out var startDn) ? startDn : null;
}
// 노드의 실행 흐름상 후속 노드들 (선형이면 1개, N지선다면 N개)
static IEnumerable<INode> GetSuccessors(INode node)
{
if (node is not DialogLineNode line)
yield break;
int choiceCount = 0;
line.GetNodeOptionByName(DialogLineNode.OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
if (choiceCount <= 0)
{
yield return GetConnectedNode(node, DialogGraphNode.EXEC_OUT);
}
else
{
for (int i = 0; i < choiceCount; i++)
yield return GetConnectedNode(node, DialogLineNode.ChoiceOutPort(i));
}
}
// 출력 실행 포트에 연결된 노드 (없으면 null)
static INode GetConnectedNode(INode node, string outputPortName)
{
var port = node.GetOutputPortByName(outputPortName);
return port?.firstConnectedPort?.GetNode();
}
// 입력 포트 값 읽기. (연결된 변수/상수 노드 → 임베드 값 → 기본값 순)
static T GetInputPortValue<T>(IPort port)
{
T value = default;
if (port == null)
return value;
if (port.isConnected)
{
switch (port.firstConnectedPort.GetNode())
{
case IVariableNode variableNode:
variableNode.variable.TryGetDefaultValue<T>(out value);
return value;
case IConstantNode constantNode:
constantNode.TryGetValue<T>(out value);
return value;
}
}
else
{
port.TryGetValue(out value);
}
return value;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2ae5ca89bbed445479d9023586f0c041

View File

@@ -0,0 +1,33 @@
using System;
using Unity.GraphToolkit.Editor;
namespace WhaleAdventure.Dialog.GraphTool.Editor
{
// 대화 그래프 노드들의 공통 베이스.
// 실행 흐름(Execution) 포트를 추가하는 헬퍼를 제공한다.
// 실행 포트는 화살촉(Arrowhead) 커넥터를 쓰고, 데이터 포트(원형)와 구분된다.
[Serializable]
internal abstract class DialogGraphNode : Node
{
public const string EXEC_IN = "In";
public const string EXEC_OUT = "Out";
// 입력 실행 포트 (이 노드로 들어오는 흐름)
protected void AddExecInput(IPortDefinitionContext context)
{
context.AddInputPort(EXEC_IN)
.WithDisplayName(string.Empty)
.WithConnectorUI(PortConnectorUI.Arrowhead)
.Build();
}
// 출력 실행 포트 (이 노드에서 나가는 흐름)
protected void AddExecOutput(IPortDefinitionContext context, string portName, string displayName)
{
context.AddOutputPort(portName)
.WithDisplayName(displayName)
.WithConnectorUI(PortConnectorUI.Arrowhead)
.Build();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be386f8d9f138f8449c9e84034f5e1ef

View File

@@ -0,0 +1,75 @@
using System;
using Unity.GraphToolkit.Editor;
namespace WhaleAdventure.Dialog.GraphTool.Editor
{
// 대사 한 노드. DialogNode 한 개로 변환된다.
//
// ChoiceCount 옵션으로 분기 방식을 정한다:
// - 0 : 선형 진행. 출력 실행 포트 "Out" 하나(→ DialogNode.Next)
// - 1 이상 : 가변 N지선다. ChoiceQuestion + 각 선택지마다
// [Choice{i} Text 입력 포트] + [Choice{i} 출력 실행 포트] 생성
// (→ DialogNode.Choices / ChoiceQuestion)
[Serializable]
internal class DialogLineNode : DialogGraphNode
{
public const string PORT_SPEAKER = "Speaker";
public const string PORT_TALK = "TalkText";
public const string PORT_GESTURE = "Gesture";
public const string PORT_EXPRESSION = "Expression";
public const string PORT_VOICE = "Voice";
public const string PORT_DURATION = "LineDuration";
public const string PORT_LOOKAT = "LookAtPlayer";
public const string PORT_QUESTION = "ChoiceQuestion";
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
// 선택지별 포트 이름 규칙 (임포터와 공유)
public static string ChoiceTextPort(int i) => $"Choice{i}Text";
public static string ChoiceOutPort(int i) => $"Choice{i}Out";
protected override void OnDefineOptions(IOptionDefinitionContext context)
{
context.AddOption<int>(OPTION_CHOICE_COUNT)
.WithDisplayName("Choice Count")
.WithTooltip("0이면 선형 진행(Next), 1 이상이면 가변 N지선다 분기")
.WithDefaultValue(0)
.Delayed();
}
protected override void OnDefinePorts(IPortDefinitionContext context)
{
AddExecInput(context);
// DialogNode의 라인 데이터 (모두 선택 입력, 비워두면 default)
context.AddInputPort<CharacterData>(PORT_SPEAKER).WithDisplayName("Speaker").Build();
context.AddInputPort<DialogText>(PORT_TALK).WithDisplayName("Talk Text").Build();
context.AddInputPort<GestureData>(PORT_GESTURE).WithDisplayName("Gesture").Build();
context.AddInputPort<ExpressionData>(PORT_EXPRESSION).WithDisplayName("Expression").Build();
context.AddInputPort<VoiceClip>(PORT_VOICE).WithDisplayName("Voice").Build();
context.AddInputPort<float>(PORT_DURATION).WithDisplayName("Line Duration").Build();
context.AddInputPort<bool>(PORT_LOOKAT).WithDisplayName("Look At Player").Build();
int choiceCount = 0;
GetNodeOptionByName(OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
if (choiceCount <= 0)
{
// 선형 진행
AddExecOutput(context, EXEC_OUT, string.Empty);
return;
}
// 가변 N지선다
context.AddInputPort<string>(PORT_QUESTION).WithDisplayName("Choice Question").Build();
for (int i = 0; i < choiceCount; i++)
{
context.AddInputPort<string>(ChoiceTextPort(i))
.WithDisplayName($"Choice {i + 1} Text")
.Build();
AddExecOutput(context, ChoiceOutPort(i), $"Choice {i + 1} →");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be6c5a15779e30f40910985e5d7cfbd9

View File

@@ -0,0 +1,16 @@
using System;
using Unity.GraphToolkit.Editor;
namespace WhaleAdventure.Dialog.GraphTool.Editor
{
// 대화의 진입점. 출력 실행 포트 하나만 가진다.
// 임포터는 이 노드에 연결된 첫 노드를 DialogGroup.StartNode로 설정한다.
[Serializable]
internal class DialogStartNode : DialogGraphNode
{
protected override void OnDefinePorts(IPortDefinitionContext context)
{
AddExecOutput(context, EXEC_OUT, string.Empty);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f76763c19a33214ca518048c8a89799

View File

@@ -0,0 +1,17 @@
using System;
namespace WhaleAdventure.Dialog.GraphTool.Editor
{
// 그래프 노드의 TalkText 포트를 여러 줄(멀티라인)로 편집하기 위한 래퍼 타입.
// 전용 DialogTextDrawer가 multiline TextField로 렌더한다.
// 임포트 시 Value 문자열만 DialogNode.TalkText로 전달된다(런타임은 이 타입을 모름).
//
// public인 이유: GraphToolkit이 포트 임베드 값을 편집할 때 이 타입을 감싸는
// 래퍼 ScriptableObject를 Reflection.Emit으로 다른 어셈블리에 생성하므로
// 접근 가능해야 한다.
[Serializable]
public struct DialogText
{
public string Value;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ad54cf039f672845a54666166b5021c

View File

@@ -0,0 +1,30 @@
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;
namespace WhaleAdventure.Dialog.GraphTool.Editor
{
// DialogText를 여러 줄 입력 필드로 그린다.
// GraphToolkit의 포트 값 에디터(ConstantField)는 CustomPropertyDrawer가 있는 타입을
// Unity PropertyField로 렌더하므로, 이 드로어가 노드/인스펙터의 TalkText 칸을 멀티라인으로 만든다.
[CustomPropertyDrawer(typeof(DialogText))]
internal class DialogTextDrawer : PropertyDrawer
{
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
var valueProp = property.FindPropertyRelative(nameof(DialogText.Value));
var field = new TextField
{
multiline = true
};
field.style.minHeight = 72; // 약 4~5줄 높이
field.style.whiteSpace = WhiteSpace.Normal; // 줄바꿈(wrap) 허용
if (valueProp != null)
field.BindProperty(valueProp);
return field;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d31004aee38951a4faaff37d886d3a65