Initial commit: Unity 프로젝트 셋업 + 다이얼로그 시스템 이전
This commit is contained in:
8
Assets/02_Scripts/Communication/Dialog.meta
Normal file
8
Assets/02_Scripts/Communication/Dialog.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5dd56c374fcdca41927418e4717e370
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
9
Assets/02_Scripts/Communication/Dialog/DialogChoice.cs
Normal file
9
Assets/02_Scripts/Communication/Dialog/DialogChoice.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
[Serializable]
|
||||
public class DialogChoice
|
||||
{
|
||||
public DialogNode DestinationNode;
|
||||
public string ChoiceText;
|
||||
public string Code; // 선택 시 기록/식별용 코드 (선택 입력, 영문 권장)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd5ea8e7c904dd24e967d91b86004efb
|
||||
8
Assets/02_Scripts/Communication/Dialog/DialogGroup.cs
Normal file
8
Assets/02_Scripts/Communication/Dialog/DialogGroup.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(menuName = "Communication/Dialog Group")]
|
||||
public class DialogGroup : ScriptableObject
|
||||
{
|
||||
public string DialogGroupName;
|
||||
public DialogNode StartNode;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 72ef984dbd5eb29498ece3d2dae297ea
|
||||
19
Assets/02_Scripts/Communication/Dialog/DialogInteractable.cs
Normal file
19
Assets/02_Scripts/Communication/Dialog/DialogInteractable.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b05ae7fb07619844f8a7e36f20f64ba8
|
||||
35
Assets/02_Scripts/Communication/Dialog/DialogNode.cs
Normal file
35
Assets/02_Scripts/Communication/Dialog/DialogNode.cs
Normal 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의 이벤트를 호출
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb30abdb4b5671b458096d48d11c7f27
|
||||
321
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal file
321
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9838e0e0a3edefa4e92ddbb98aaa3ce5
|
||||
27
Assets/02_Scripts/Communication/Dialog/DialogRegion.cs
Normal file
27
Assets/02_Scripts/Communication/Dialog/DialogRegion.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1fb6dce8c4da85e478d7da770f057cf3
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f182cc352a11ed48b27e690bdb10520
|
||||
51
Assets/02_Scripts/Communication/Dialog/DialogVariables.cs
Normal file
51
Assets/02_Scripts/Communication/Dialog/DialogVariables.cs
Normal 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();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6bb90f809bd62e409383e02949f32c3
|
||||
8
Assets/02_Scripts/Communication/Dialog/GraphTool.meta
Normal file
8
Assets/02_Scripts/Communication/Dialog/GraphTool.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb0dad36a50363647a4b9cd61d078b20
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bad231f70f838c84288d07f754f51ed7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f25a0745082bd7c439679495337d3598
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ae5ca89bbed445479d9023586f0c041
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be386f8d9f138f8449c9e84034f5e1ef
|
||||
@@ -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} →");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be6c5a15779e30f40910985e5d7cfbd9
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f76763c19a33214ca518048c8a89799
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ad54cf039f672845a54666166b5021c
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d31004aee38951a4faaff37d886d3a65
|
||||
47
Assets/02_Scripts/Communication/Dialog/GraphTool/README.md
Normal file
47
Assets/02_Scripts/Communication/Dialog/GraphTool/README.md
Normal 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`로 합쳐집니다.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1fa58401314123a4b90fa0fda5240a18
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/02_Scripts/Communication/Voice.meta
Normal file
8
Assets/02_Scripts/Communication/Voice.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98959040e708aa04891672a6c21a9479
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa8a1a66f46e7ef4f99770437e10ae34
|
||||
109
Assets/02_Scripts/Communication/Voice/LipSync.cs
Normal file
109
Assets/02_Scripts/Communication/Voice/LipSync.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Communication/Voice/LipSync.cs.meta
Normal file
2
Assets/02_Scripts/Communication/Voice/LipSync.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae0d9abbe32fe5b4e8e4886143e1e5e2
|
||||
Reference in New Issue
Block a user