Initial commit: Unity 프로젝트 셋업 + 다이얼로그 시스템 이전

This commit is contained in:
2026-07-04 15:38:52 +09:00
commit 4d94878dbe
1310 changed files with 141631 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,9 @@
using System;
[Serializable]
public class DialogChoice
{
public DialogNode DestinationNode;
public string ChoiceText;
public string Code; // 선택 시 기록/식별용 코드 (선택 입력, 영문 권장)
}

View File

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

View File

@@ -0,0 +1,8 @@
using UnityEngine;
[CreateAssetMenu(menuName = "Communication/Dialog Group")]
public class DialogGroup : ScriptableObject
{
public string DialogGroupName;
public DialogNode StartNode;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72ef984dbd5eb29498ece3d2dae297ea

View File

@@ -0,0 +1,19 @@
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
[RequireComponent(typeof(DialogPlayer))]
public class DialogInteractable : MonoBehaviour
{
private DialogPlayer _player;
private void Awake()
{
_player = GetComponent<DialogPlayer>();
}
public void HandleActivated(ActivateEventArgs args)
{
if (_player == null || _player.IsPlaying) return;
_ = _player.Play();
}
}

View File

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

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
[CreateAssetMenu(menuName = "Communication/Dialog Node")]
public class DialogNode : ScriptableObject
{
[Header("Speaker")]
public CharacterData Speaker;
[Header("Content")]
[TextArea(2,5)] public string TalkText;
public GestureData Gesture;
public ExpressionData Expression;
public VoiceClip Voice;
public float LineDuration; //자동 넘김 시간
//LineDuration=0 → 플레이어 입력 대기 (수동)
//Voice 있음 → 클립 길이만큼 대기
//Voice 없음 → LineDuration 대기
[Header("Behavior")]
public bool LookAtPlayer;
public bool WaitForInput; // true면 LineDuration 무시하고 B버튼(OnDialogNext) 입력까지 대기
[Header("Flow")]
public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드
public List<DialogChoice> Choices; // 있으면 플레이어 선택 대기
[Header("ChoiceQuestion")]
[TextArea(2,5)] public string ChoiceQuestion;
[Header("Event")]
public string EventKey; // 비어있지 않으면 이 노드가 재생될 때 DialogPlayer가 같은 Key의 이벤트를 호출
}

View File

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

View File

@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using UnityEngine.Events;
using UnityEngine.InputSystem;
using UnityEngine;
[RequireComponent(typeof(CharacterVoiceObject))]
public class DialogPlayer : MonoBehaviour
{
[System.Serializable]
public struct RegionGroup
{
public string Region; // 영역 이름 (NPC마다 자유롭게 지정 — 그룹 이름과 무관)
public DialogGroup Group;
}
// 마지막 선택지 코드(LastChoiceCode)를 인자로 넘기는 UnityEvent (인스펙터 노출용 구체 타입)
[System.Serializable]
public class ChoiceCodeEvent : UnityEvent<string> { }
// 노드의 EventKey ↔ 그 노드 재생 시 호출할 이벤트. 인자로 LastChoiceCode가 전달됨.
[System.Serializable]
public struct NodeEvent
{
public string Key;
public ChoiceCodeEvent Event;
}
[Tooltip("영역 이름 ↔ 그 영역에서 재생할 DialogGroup")]
[SerializeField] private List<RegionGroup> _regionGroups;
[Header("Region")]
[SerializeField] private string _currentRegion; // 현재 영역 이름. DialogRegion 트리거가 갱신
[Header("Dialog HUD Placement")] // 씬에서 캐릭터 위치/주변(벽 등)에 맞춰 조절
[SerializeField] private float _hudChestHeight = 1.2f; // 화자 발 기준 가슴 높이
[SerializeField] private float _hudForwardOffset = 0.5f; // 화자→플레이어 방향으로 띄울 거리
[SerializeField] private float _hudLateralOffset = 0f; // 좌우 오프셋 (+ 플레이어 시점 오른쪽)
[Header("Dialog Events")]
[Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")]
[SerializeField] private List<NodeEvent> _nodeEvents = new();
private Dictionary<string, DialogGroup> _regionMap;
private Animator _animator;
private int _initialGestureHash;
private int _initialExpressionHash;
private bool _hasInitialExpression;
private readonly Dictionary<Transform, Quaternion> _originalRotations = new();
public bool IsPlaying { get; private set; }
// 마지막으로 고른 선택지 (인덱스/코드). DialogVariables에도 lastChoiceIndex / lastChoiceCode 로 저장됨
public int LastChoiceIndex { get; private set; } = -1;
public string LastChoiceCode { get; private set; }
public event Action<int, string> OnChoiceSelected; // (index, code)
private void Awake()
{
_regionMap = new Dictionary<string, DialogGroup>();
foreach (var e in _regionGroups)
if (e.Group != null) _regionMap[e.Region] = e.Group;
_animator = GetComponentInChildren<Animator>();
if (_animator != null)
{
_initialGestureHash = _animator.GetCurrentAnimatorStateInfo(0).fullPathHash;
if (_animator.layerCount > 1)
{
_initialExpressionHash = _animator.GetCurrentAnimatorStateInfo(1).fullPathHash;
_hasInitialExpression = true;
}
}
}
public async Awaitable Play()
{
var region = ResolveRegion();
if (region != null)
await Play(region);
}
// 현재 영역. 영역이 없거나 매칭 그룹이 없으면 리스트 첫 항목으로 폴백.
private string ResolveRegion()
{
if (!string.IsNullOrEmpty(_currentRegion) && _regionMap.ContainsKey(_currentRegion))
return _currentRegion;
return _regionGroups.Count > 0 ? _regionGroups[0].Region : null;
}
// 영역 전환 (DialogRegion 트리거가 호출). 다음 Play()부터 해당 영역 대화가 재생됨.
public void SetRegion(string region) => _currentRegion = region;
public string CurrentRegion => _currentRegion;
public async Awaitable Play(string region)
{
if (IsPlaying) return;
if (!_regionMap.TryGetValue(region, out var group))
{
Debug.LogWarning($"[DialogPlayer] 영역 대화 없음: {region}");
return;
}
IsPlaying = true;
try
{
var node = group.StartNode;
while (node != null)
{
await PlayNode(node);
if (node.Choices != null && node.Choices.Count > 0)
{
int picked = await WaitForChoice(node);
RecordChoice(node, picked);
node = node.Choices[picked].DestinationNode;
}
else
{
node = node.Next;
}
}
}
finally
{
IsPlaying = false;
if (DialogHud.Instance != null)
DialogHud.Instance.Hide();
RestoreDefaultAnimations();
RestoreRotations();
}
Debug.Log("[DialogPlayer] 대화 종료");
}
private void RestoreDefaultAnimations()
{
if (_animator == null) return;
_animator.CrossFade(_initialGestureHash, 0.3f, 0, normalizedTimeOffset: 0f);
if (_hasInitialExpression)
_animator.CrossFade(_initialExpressionHash, 0.3f, 1, normalizedTimeOffset: 0f);
}
private async Awaitable RotateTowardPlayer(Transform target)
{
if (Camera.main == null) return;
var playerCam = Camera.main.transform;
float duration = 0.5f;
float elapsed = 0f;
while (elapsed < duration)
{
Vector3 dir = playerCam.position - target.position;
dir.y = 0f;
if (dir.sqrMagnitude > 0.0001f)
{
var targetRot = Quaternion.LookRotation(dir);
target.rotation = Quaternion.Slerp(target.rotation, targetRot, 10f * Time.deltaTime);
}
elapsed += Time.deltaTime;
await Awaitable.NextFrameAsync();
}
}
private void RestoreRotations()
{
foreach (var kvp in _originalRotations)
{
if (kvp.Key != null)
_ = RotateToRotation(kvp.Key, kvp.Value);
}
_originalRotations.Clear();
}
private async Awaitable RotateToRotation(Transform target, Quaternion targetRotation)
{
float duration = 0.5f;
float elapsed = 0f;
while (elapsed < duration)
{
if (target == null) return;
target.rotation = Quaternion.Slerp(target.rotation, targetRotation, 10f * Time.deltaTime);
elapsed += Time.deltaTime;
await Awaitable.NextFrameAsync();
}
}
private async Awaitable PlayNode(DialogNode node)
{
// 화자 옆 DialogHud에 대사 표시 (배치 오프셋은 이 NPC의 설정값 사용)
if (DialogHud.Instance != null)
DialogHud.Instance.Show(node.Speaker, node.TalkText, _hudChestHeight, _hudForwardOffset, _hudLateralOffset);
RaiseNodeEvent(node.EventKey); // EventKey 있으면 매칭 이벤트 호출
// 보이스 재생
if (node.Voice != null && node.Speaker != null)
{
var voiceObj = CharacterVoiceObject.Find(node.Speaker);
if (voiceObj != null && node.Voice.Clip != null)
voiceObj.Play(node.Voice.Clip);
}
// 플레이어 향해 회전
if (node.LookAtPlayer && node.Speaker != null)
{
var voiceObj = CharacterVoiceObject.Find(node.Speaker);
if (voiceObj != null)
{
_originalRotations.TryAdd(voiceObj.transform, voiceObj.transform.rotation);
_ = RotateTowardPlayer(voiceObj.transform);
}
}
if (node.Gesture != null)
_animator.CrossFade(node.Gesture.StateName, node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
if (node.Expression != null)
_animator.CrossFade(node.Expression.StateName, node.Expression.CrossFadeDuration, node.Expression.AnimationLayer);
// 진행 방식 결정
if (node.WaitForInput)
{
await WaitForAdvanceInput(); // B버튼 입력이 있어야만 다음으로
}
else
{
float wait = (node.Voice != null && node.Voice.Clip != null)
? node.Voice.Clip.length
: node.LineDuration;
if (wait > 0f)
await Awaitable.WaitForSecondsAsync(wait);
else
await WaitForAdvanceInput(); // 지정 시간이 없으면 입력으로 진행
}
}
// 노드의 EventKey와 같은 Key를 가진 이벤트들을 호출
private void RaiseNodeEvent(string key)
{
if (string.IsNullOrEmpty(key)) return;
foreach (var e in _nodeEvents)
if (e.Key == key) e.Event?.Invoke(LastChoiceCode); // 마지막 선택지 코드를 인자로 전달
}
// 선택 결과 기록: 인덱스/코드를 프로퍼티 + DialogVariables에 저장하고 이벤트 발행
private void RecordChoice(DialogNode node, int index)
{
string code = (node.Choices != null && index >= 0 && index < node.Choices.Count)
? node.Choices[index].Code : null;
code = DialogVariables.Format(code); // {token} 치환 → 동적으로 생성된 코드 반영
LastChoiceIndex = index;
LastChoiceCode = code;
DialogVariables.Set("lastChoiceIndex", index.ToString());
if (!string.IsNullOrEmpty(code))
DialogVariables.Set("lastChoiceCode", code);
OnChoiceSelected?.Invoke(index, code);
}
private async Awaitable<int> WaitForChoice(DialogNode node)
{
//선택을 기다리는 함수 수정해서 사용할것
if (ChoiceHud.Instance == null)
{
Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택");
return 0;
}
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
}
// 대화 진행 입력(OnDialogNext = VR B버튼) 한 번을 대기
private async Awaitable WaitForAdvanceInput()
{
var im = InputManager.Instance;
if (im == null)
{
// 입력 매니저 없으면 안전하게 잠깐 대기 후 진행
await Awaitable.WaitForSecondsAsync(1f);
return;
}
bool pressed = false;
void Handler() => pressed = true;
im.OnDialogNext_Event += Handler;
try
{
while (!pressed)
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
catch (OperationCanceledException)
{
// 대기 중 오브젝트 파괴 시 조용히 종료
}
finally
{
im.OnDialogNext_Event -= Handler;
}
}
//테스트용
private void Update()
{
if (Mouse.current == null) return;
if (!Mouse.current.leftButton.wasPressedThisFrame) return;
if (Camera.main == null) return;
var ray = Camera.main.ScreenPointToRay(Mouse.current.position.ReadValue());
if (Physics.Raycast(ray, out var hit) && hit.transform.IsChildOf(transform))
{
Debug.Log("캐릭터 클릭");
Play();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9838e0e0a3edefa4e92ddbb98aaa3ce5

View File

@@ -0,0 +1,27 @@
using UnityEngine;
// 영역 트리거. 이 콜라이더(isTrigger) 안으로 NPC(DialogPlayer 보유)가 들어오면
// 그 NPC의 현재 영역을 _regionKey로 전환한다.
// _regionKey는 해당 영역에서 재생할 DialogGroup의 이름과 일치해야 한다 (예: "Coast", "Hill").
//
// 주의: OnTriggerEnter가 동작하려면 들어오는 쪽(또는 트리거 쪽)에 Rigidbody가 있어야 하고,
// 이 오브젝트의 Collider는 Is Trigger여야 한다.
[RequireComponent(typeof(Collider))]
public class DialogRegion : MonoBehaviour
{
[SerializeField] private string _regionKey; // DialogGroup 이름과 일치
private void Reset()
{
// 컴포넌트 추가 시 편의상 트리거로 설정
var col = GetComponent<Collider>();
if (col != null) col.isTrigger = true;
}
private void OnTriggerEnter(Collider other)
{
var player = other.GetComponentInParent<DialogPlayer>();
if (player != null)
player.SetRegion(_regionKey);
}
}

View File

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

View File

@@ -0,0 +1,12 @@
using UnityEngine;
// 인스펙터에 지정한 key로 DialogVariables에 값을 넣는 헬퍼.
// 예: TMP_InputField의 On End Edit(string) → 이 컴포넌트의 Set(string) 에 연결하면
// 플레이어가 입력한 글자가 {key} 토큰으로 대화에 들어간다.
public class DialogVariableSetter : MonoBehaviour
{
[SerializeField] private string _key;
public void Set(string value) => DialogVariables.Set(_key, value); // UnityEvent<string> 연결용
public void SetKey(string key) => _key = key;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f182cc352a11ed48b27e690bdb10520

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
// 대화 텍스트의 {key} 토큰을 런타임 값으로 치환하는 전역 저장소.
// 예) DialogVariables.Set("playerName", "철수");
// 대사 "안녕 {playerName}!" → "안녕 철수!"
//
// 표시 직전(DialogHud / ChoiceHud)에서 Format()을 거치므로, 그래프엔 그냥 {key}만 써두면 된다.
public static class DialogVariables
{
private static readonly Dictionary<string, string> _values = new();
public static void Set(string key, string value) => _values[key] = value ?? string.Empty;
public static void Remove(string key) => _values.Remove(key);
public static void Clear() => _values.Clear();
public static bool TryGet(string key, out string value) => _values.TryGetValue(key, out value);
// "{key}" 토큰을 등록된 값으로 치환. 등록 안 된 키는 그대로 둔다(빠진 값 디버깅용).
public static string Format(string text)
{
if (string.IsNullOrEmpty(text) || text.IndexOf('{') < 0) return text;
var sb = new StringBuilder(text.Length);
int i = 0;
while (i < text.Length)
{
if (text[i] == '{')
{
int close = text.IndexOf('}', i + 1);
if (close > i)
{
string key = text.Substring(i + 1, close - i - 1);
if (_values.TryGetValue(key, out var val))
{
sb.Append(val);
i = close + 1;
continue;
}
}
}
sb.Append(text[i]);
i++;
}
return sb.ToString();
}
// 플레이 시작마다 초기화 (Enter Play Mode에서 도메인 리로드를 꺼도 이전 값이 안 남게)
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetOnPlay() => _values.Clear();
}

View File

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

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 DinoLove.Dialog.GraphTool.Editor
{
// 대화 그래프.
// 기존 Communication/Dialog 시스템(DialogGroup / DialogNode / DialogChoice)을
// 노드 그래프로 저작하기 위한 에디터 전용 그래프 타입이다.
// 임포트 시 DialogGraphImporter가 이 그래프를 DialogGroup 에셋으로 변환한다.
[Graph(AssetExtension)]
internal class DialogGraph : Graph
{
// ScriptedImporter가 사용하는 확장자. 프로젝트 내에서 유일해야 한다.
public const string AssetExtension = "dlg"; // DiaLoG
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,173 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Unity.GraphToolkit.Editor;
using UnityEditor.AssetImporters;
using UnityEngine;
namespace DinoLove.Dialog.GraphTool.Editor
{
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= 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));
dn.WaitForInput = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_WAITINPUT));
string eventKey = null;
line.GetNodeOptionByName(DialogLineNode.OPTION_EVENT_KEY)?.TryGetValue(out eventKey);
dn.EventKey = eventKey;
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<DialogText>(gn.GetInputPortByName(DialogLineNode.PORT_QUESTION)).Value;
for (int i = 0; i < choiceCount; i++)
{
var choiceText = GetInputPortValue<DialogText>(gn.GetInputPortByName(DialogLineNode.ChoiceTextPort(i))).Value;
var choiceCode = GetInputPortValue<string>(gn.GetInputPortByName(DialogLineNode.ChoiceCodePort(i)));
var dest = GetConnectedNode(gn, DialogLineNode.ChoiceOutPort(i));
dn.Choices.Add(new DialogChoice
{
ChoiceText = choiceText,
Code = choiceCode,
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 DinoLove.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,88 @@
using System;
using Unity.GraphToolkit.Editor;
namespace DinoLove.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_WAITINPUT = "WaitForInput";
public const string PORT_QUESTION = "ChoiceQuestion";
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
public const string OPTION_EVENT_KEY = "EventKey";
// 선택지별 포트 이름 규칙 (임포터와 공유)
public static string ChoiceTextPort(int i) => $"Choice{i}Text";
public static string ChoiceCodePort(int i) => $"Choice{i}Code";
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();
context.AddOption<string>(OPTION_EVENT_KEY)
.WithDisplayName("Event Key")
.WithTooltip("비우면 없음. 이 노드 재생 시 DialogPlayer의 같은 Key 이벤트 호출 (영문 키 권장)")
.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();
context.AddInputPort<bool>(PORT_WAITINPUT).WithDisplayName("Wait For Input").Build();
int choiceCount = 0;
GetNodeOptionByName(OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
if (choiceCount <= 0)
{
// 선형 진행
AddExecOutput(context, EXEC_OUT, string.Empty);
return;
}
// 가변 N지선다
// (string 포트는 GraphToolkit 기본 에디터의 IME 중복입력 버그가 있어 DialogText로 통일)
context.AddInputPort<DialogText>(PORT_QUESTION).WithDisplayName("Choice Question").Build();
for (int i = 0; i < choiceCount; i++)
{
context.AddInputPort<DialogText>(ChoiceTextPort(i))
.WithDisplayName($"Choice {i + 1} Text")
.Build();
context.AddInputPort<string>(ChoiceCodePort(i))
.WithDisplayName($"Choice {i + 1} Code")
.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 DinoLove.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 DinoLove.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 DinoLove.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:

View File

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

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using UnityEngine;
public class CharacterVoiceObject : MonoBehaviour
{
public CharacterData Character;
public AudioSource VoiceSource;
private static readonly Dictionary<CharacterData, CharacterVoiceObject> _registry = new();
private void OnEnable() => _registry[Character] = this;
private void OnDisable() => _registry.Remove(Character);
public static CharacterVoiceObject Find(CharacterData data)
=> _registry.TryGetValue(data, out var obj) ? obj : null;
public void Play(AudioClip clip) => VoiceSource.PlayOneShot(clip);
}

View File

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

View File

@@ -0,0 +1,109 @@
using System;
using UnityEngine;
// 보이스 진폭에 따라 입 관련 블렌드셰이프 그룹의 weight를 직접 제어
// LateUpdate에서 갱신해 Animator가 같은 프레임에 0으로 세팅한 값을 덮어씀
[RequireComponent(typeof(CharacterVoiceObject))]
public class LipSync : MonoBehaviour
{
[Serializable]
private struct LipShape
{
public string Name;
[Range(0f, 100f)] public float MaxWeight; // amplitude=1일 때 도달할 weight
}
[Header("Refs")]
[SerializeField] private SkinnedMeshRenderer _meshRenderer;
// BMAC_OpenMouse_Big 클립의 입 관련 셰이프 프리셋
[Header("Mouth Preset (입 최대 시 weight)")]
[SerializeField] private LipShape[] _shapes =
{
new() { Name = "Expression_SurpriesedMouth", MaxWeight = 50f },
new() { Name = "Expression_MouthSad_L", MaxWeight = 10f },
new() { Name = "Expression_MouthSad_R", MaxWeight = 10f },
new() { Name = "Expression_MouthWide_L", MaxWeight = 30f },
new() { Name = "Expression_MouthWide_R", MaxWeight = 30f },
new() { Name = "Expression_LipsOh", MaxWeight = 100f },
new() { Name = "Expression_LipsO", MaxWeight = 5f },
};
[Header("Tuning")]
[SerializeField, Range(0f, 20f)] private float _amplitudeScale = 6f; // RMS → 0~1 매핑 배수
[SerializeField, Range(0f, 0.05f)] private float _noiseFloor = 0.005f;
[SerializeField, Range(0f, 30f)] private float _smoothingSpeed = 15f;
[SerializeField] private int _sampleSize = 256;
private AudioSource _audioSource;
private int[] _indices;
private float[] _sampleBuffer;
private float _currentAmplitude;
private void Awake()
{
var voiceObj = GetComponent<CharacterVoiceObject>();
_audioSource = voiceObj != null ? voiceObj.VoiceSource : null;
// 메시 자동 탐색 — 첫 번째 셰이프 이름을 가진 SkinnedMeshRenderer 사용
if (_meshRenderer == null && _shapes.Length > 0)
{
string probe = _shapes[0].Name;
foreach (var smr in GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (smr.sharedMesh != null && smr.sharedMesh.GetBlendShapeIndex(probe) >= 0)
{
_meshRenderer = smr;
break;
}
}
}
// 인덱스 캐시
_indices = new int[_shapes.Length];
if (_meshRenderer != null && _meshRenderer.sharedMesh != null)
{
var mesh = _meshRenderer.sharedMesh;
for (int i = 0; i < _shapes.Length; i++)
{
_indices[i] = mesh.GetBlendShapeIndex(_shapes[i].Name);
if (_indices[i] < 0)
Debug.LogWarning($"[LipSync] 블렌드셰이프 없음: {_shapes[i].Name}", this);
}
}
else
{
for (int i = 0; i < _indices.Length; i++) _indices[i] = -1;
}
if (_audioSource == null)
Debug.LogWarning("[LipSync] CharacterVoiceObject.VoiceSource 미할당", this);
_sampleBuffer = new float[_sampleSize];
}
private void LateUpdate()
{
if (_audioSource == null || _meshRenderer == null) return;
// PlayOneShot도 잡히도록 항상 샘플링 — 무음은 노이즈 플로어로 컷
_audioSource.GetOutputData(_sampleBuffer, 0);
float sumSq = 0f;
for (int i = 0; i < _sampleBuffer.Length; i++)
sumSq += _sampleBuffer[i] * _sampleBuffer[i];
float rms = Mathf.Sqrt(sumSq / _sampleBuffer.Length);
rms = Mathf.Max(0f, rms - _noiseFloor);
float target = Mathf.Clamp01(rms * _amplitudeScale);
_currentAmplitude = Mathf.Lerp(_currentAmplitude, target, Time.deltaTime * _smoothingSpeed);
// Animator가 같은 프레임에 0으로 덮은 값을 LateUpdate에서 다시 씌움
for (int i = 0; i < _shapes.Length; i++)
{
if (_indices[i] < 0) continue;
_meshRenderer.SetBlendShapeWeight(_indices[i], _currentAmplitude * _shapes[i].MaxWeight);
}
}
}

View File

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