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,8 @@
fileFormatVersion: 2
guid: cb0dad36a50363647a4b9cd61d078b20
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bad231f70f838c84288d07f754f51ed7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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

View File

@@ -0,0 +1,47 @@
# Dialog Graph Tool
`Communication/Dialog` 시스템(`DialogGroup` / `DialogNode` / `DialogChoice`)을
**노드 그래프로 저작**하기 위한 에디터 전용 도구입니다.
Unity GraphToolkit(`com.unity.graphtoolkit`, experimental) 기반.
## 동작 개요
- `.wdg` 그래프 에셋을 노드로 편집 → 저장하면 `DialogGraphImporter`
기존 런타임 타입(`DialogGroup` + 여러 `DialogNode`)으로 자동 변환합니다.
- 변환 결과: **메인 에셋 = `DialogGroup`**, 서브에셋 = 각 `DialogNode`.
- `DialogPlayer`**수정 없이** 임포트된 `.wdg`(=DialogGroup)를 그대로 사용합니다.
## 사용법
1. Project 창에서 우클릭 → `Create > Communication > Dialog Graph``.wdg` 생성.
2. 더블클릭해 그래프 에디터를 엽니다.
3. 노드 추가:
- **Dialog Start Node** : 진입점. 출력 화살표를 첫 대사 노드에 연결. (그래프당 1개)
- **Dialog Line Node** : 대사 1줄. Speaker/TalkText/Gesture/Expression/Voice/
LineDuration/LookAtPlayer 입력.
- `Choice Count = 0` → 선형. `Out` 출력을 다음 노드로 연결(= `DialogNode.Next`).
- `Choice Count = N` → 가변 N지선다. `Choice Question` + 선택지마다
`Choice i Text`(텍스트) 와 `Choice i →`(분기 출력) 생성.
각 분기 출력을 목적지 노드에 연결(= `DialogNode.Choices[i].DestinationNode`).
4. 저장(임포트)되면 `.wdg` 에셋이 `DialogGroup`이 됩니다.
이를 `DialogPlayer``_dialogGroups` 리스트에 드래그하면 끝.
(그룹 이름 = 파일명. `DialogPlayer.Play(groupName)` 으로 호출)
## 구성 파일 (모두 Editor 전용)
- `DialogGraph.cs` — 그래프 타입/생성 메뉴/검증
- `DialogGraphNode.cs` — 공통 베이스(실행 포트 헬퍼)
- `DialogStartNode.cs` — 진입 노드
- `DialogLineNode.cs` — 대사 + 가변 N지선다 노드
- `DialogText.cs` — TalkText 멀티라인 입력용 래퍼 타입
- `DialogTextDrawer.cs` — DialogText를 여러 줄 TextField로 그리는 CustomPropertyDrawer
- `DialogGraphImporter.cs`— .wdg → DialogGroup/DialogNode 변환
## TalkText 멀티라인
- TalkText 포트는 `string`이 아니라 `DialogText` 타입을 쓴다.
- GraphToolkit은 `[CustomPropertyDrawer]`가 있는 타입을 Unity PropertyField로 렌더하므로,
`DialogTextDrawer`가 노드의 TalkText 칸을 여러 줄(멀티라인)로 만든다.
- 임포터는 `DialogText.Value`만 꺼내 `DialogNode.TalkText`(string)에 넣는다 — 런타임은 영향 없음.
- 높이를 더 키우려면 `DialogTextDrawer``minHeight` 값을 조정.
## 메모
- GraphToolkit은 experimental(0.4.0-exp.2)이라 API가 바뀔 수 있습니다.
- 분기 출력이 비어 있으면 해당 선택지의 `DestinationNode`는 null이 되어 대화가 종료됩니다.
- 여러 경로에서 같은 노드로 연결하면(루프 포함) 하나의 `DialogNode`로 합쳐집니다.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1fa58401314123a4b90fa0fda5240a18
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: