first
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:
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// 호감도 비교 연산자. (호감도 [연산자] Value)
|
||||
public enum AffectionCompare
|
||||
{
|
||||
[InspectorName("≥")] AtLeast,
|
||||
[InspectorName(">")] GreaterThan,
|
||||
[InspectorName("≤")] AtMost,
|
||||
[InspectorName("<")] LessThan,
|
||||
[InspectorName("=")] Equal,
|
||||
[InspectorName("≠")] NotEqual,
|
||||
}
|
||||
|
||||
// 다음 조건과 묶는 방식. AND가 OR보다 우선순위가 높다 (A and B or C → (A and B) or C).
|
||||
public enum AffectionJoin
|
||||
{
|
||||
[InspectorName("AND")] And,
|
||||
[InspectorName("OR")] Or,
|
||||
}
|
||||
|
||||
// 호감도 분기 노드가 검사하는 조건 하나. (캐릭터, 연산자, 값) + 다음 조건과의 연결자.
|
||||
// 여러 개를 이어 붙여 조건식을 만든다 — DialogNode.AffectionRequirements 참고.
|
||||
[Serializable]
|
||||
public class AffectionRequirement
|
||||
{
|
||||
// 검사 대상 캐릭터. 비우면 대화 주인 NPC의 호감도를 본다.
|
||||
public CharacterData Character;
|
||||
|
||||
// 호감도를 Value와 어떻게 비교할지
|
||||
public AffectionCompare Compare = AffectionCompare.AtLeast;
|
||||
|
||||
// 비교 기준값
|
||||
public int Value;
|
||||
|
||||
// 다음 조건과 묶는 방식 (마지막 조건에서는 무시된다)
|
||||
public AffectionJoin JoinWithNext = AffectionJoin.And;
|
||||
|
||||
// 이 조건 하나의 성립 여부
|
||||
public bool IsMet(int affection) => Compare switch
|
||||
{
|
||||
AffectionCompare.AtLeast => affection >= Value,
|
||||
AffectionCompare.GreaterThan => affection > Value,
|
||||
AffectionCompare.AtMost => affection <= Value,
|
||||
AffectionCompare.LessThan => affection < Value,
|
||||
AffectionCompare.Equal => affection == Value,
|
||||
AffectionCompare.NotEqual => affection != Value,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f38182435621e88449adc22d629f6e6b
|
||||
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
|
||||
62
Assets/02_Scripts/Communication/Dialog/DialogCondition.cs
Normal file
62
Assets/02_Scripts/Communication/Dialog/DialogCondition.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// DialogGroup 하나가 활성화되기 위한 조건 묶음. 모든 항목을 만족해야 한다(AND).
|
||||
// 진행도/호감도는 범위(양끝 포함)로 지정한다. 기본 범위 = 조건 없음. 트리거 목록은 비워두면 조건 없음.
|
||||
[Serializable]
|
||||
public class DialogCondition : ISerializationCallbackReceiver
|
||||
{
|
||||
const int MaxProgressDefault = 9999;
|
||||
const int MaxAffectionDefault = 100;
|
||||
|
||||
[Tooltip("메인 진행도가 이 범위 안이어야 함 (양끝 포함). 기본 0~9999 = 조건 없음")]
|
||||
public IntRange MainProgress = new(0, MaxProgressDefault);
|
||||
|
||||
[Tooltip("이 NPC의 호감도가 이 범위 안이어야 함 (양끝 포함). 기본 0~100 = 조건 없음. " +
|
||||
"예: 0~10 = 호감도가 낮을 때만, 50~100 = 호감도가 쌓인 뒤에만")]
|
||||
public IntRange Affection = new(0, MaxAffectionDefault);
|
||||
|
||||
[Tooltip("켜져 있어야 하는 트리거 Id들 (전부 필요). StoryManager.SetTrigger로 켠다")]
|
||||
public List<string> RequiredTriggerIds = new();
|
||||
|
||||
[Tooltip("켜지면 이 대화가 비활성화되는 트리거 Id들 (하나라도 켜졌으면 비활성화). " +
|
||||
"예: 특정 사건 이후엔 더 못 보는 대사")]
|
||||
public List<string> ExcludedTriggerIds = new();
|
||||
|
||||
// 인스펙터에서 리스트에 새로 추가된 항목은 필드 초기화식이 무시되고 전부 0으로 생성된다.
|
||||
// 그대로면 범위가 0~0(사실상 잠긴 대화)이 되므로, 처음 만들어진 인스턴스에만 기본 범위를 넣어준다.
|
||||
[SerializeField, HideInInspector] private bool _initialized = true;
|
||||
|
||||
public void OnBeforeSerialize() { }
|
||||
|
||||
public void OnAfterDeserialize()
|
||||
{
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
MainProgress = new IntRange(0, MaxProgressDefault);
|
||||
Affection = new IntRange(0, MaxAffectionDefault);
|
||||
}
|
||||
|
||||
// affectionTarget: 호감도 조건을 검사할 캐릭터 (보통 대화를 거는 NPC 자신)
|
||||
public bool IsMet(CharacterData affectionTarget)
|
||||
{
|
||||
var story = StoryManager.Instance;
|
||||
|
||||
if (!MainProgress.Contains(story.MainProgress))
|
||||
return false;
|
||||
|
||||
if (!Affection.Contains(story.GetAffection(affectionTarget)))
|
||||
return false;
|
||||
|
||||
foreach (var id in RequiredTriggerIds)
|
||||
if (!string.IsNullOrEmpty(id) && !story.HasTrigger(id))
|
||||
return false;
|
||||
|
||||
foreach (var id in ExcludedTriggerIds)
|
||||
if (!string.IsNullOrEmpty(id) && story.HasTrigger(id))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f307619d54e9a74990a68cda19bff5b
|
||||
55
Assets/02_Scripts/Communication/Dialog/DialogGestureItem.cs
Normal file
55
Assets/02_Scripts/Communication/Dialog/DialogGestureItem.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
// 대화 중 "건네기/터치" 제스처를 수행하는 오브젝트(꽃다발·김치·손 등)에 붙인다.
|
||||
// 이 오브젝트가 NPC의 받는 존(_targetTag 태그의 트리거)에 닿으면, 자신의 GestureKey로 히든 분기를 발동시킨다.
|
||||
//
|
||||
// 어떤 아이템인지는 GestureKey로 구분된다:
|
||||
// - 노드가 HiddenGestureKey="kimchi"로 무장 → 김치(GestureKey="kimchi")만 발동, 꽃다발("bouquet")은 무시.
|
||||
// 그래서 "김치를 줘야 하는데 꽃다발을 주면 이벤트가 안 진행"이 자동으로 보장된다.
|
||||
//
|
||||
// 콜라이더가 있는 오브젝트에 붙일 것 (잡는 아이템은 보통 XRGrabInteractable이 있는 루트).
|
||||
// 존은 무장 여부와 무관하게 항상 닿을 수 있지만, 무장 안 된 순간에 닿으면 아무 일도 안 일어난다.
|
||||
public class DialogGestureItem : MonoBehaviour
|
||||
{
|
||||
[Tooltip("이 오브젝트의 제스처 키. 대화 노드의 HiddenGestureKey와 일치할 때만 발동 (예: kimchi, bouquet, slap)")]
|
||||
[SerializeField] private string _gestureKey;
|
||||
|
||||
[Tooltip("이 태그를 가진 트리거(NPC 받는 존)에 닿아야 발동. 비우면 태그 무시(아무 트리거나)")]
|
||||
[SerializeField] private string _targetTag = "GiveZone";
|
||||
|
||||
[Header("Consume (건넨 뒤 사라지게)")]
|
||||
[Tooltip("켜면 발동 시 이 오브젝트가 사라진다(건넨 연출). 손처럼 사라지면 안 되는 건 꺼둘 것")]
|
||||
[SerializeField] private bool _consumeOnGiven = true;
|
||||
|
||||
[Tooltip("사라지는 방식: 켜면 완전 파괴(Destroy), 끄면 비활성화(SetActive false — 재사용 가능)")]
|
||||
[SerializeField] private bool _destroy = true;
|
||||
|
||||
[Tooltip("사라지기 직전 호출 (파티클·사운드 연출용)")]
|
||||
[SerializeField] private UnityEvent _onConsumed;
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_targetTag) && !other.CompareTag(_targetTag)) return;
|
||||
|
||||
// 노드가 이 키로 무장 중일 때만 true — 그때만 소비한다(엉뚱한 타이밍/엉뚱한 아이템은 안 사라짐).
|
||||
if (!HiddenBranchResolver.Fire(_gestureKey)) return;
|
||||
|
||||
if (_consumeOnGiven)
|
||||
Consume();
|
||||
}
|
||||
|
||||
private void Consume()
|
||||
{
|
||||
// 스크립트가 자식 콜라이더에 붙어 있어도 아이템 전체(Rigidbody 루트)를 처리
|
||||
var body = GetComponentInParent<Rigidbody>();
|
||||
var target = body != null ? body.gameObject : gameObject;
|
||||
|
||||
_onConsumed?.Invoke();
|
||||
|
||||
if (_destroy)
|
||||
Destroy(target); // XRGrabInteractable도 파괴 시 손에서 자동 해제됨
|
||||
else
|
||||
target.SetActive(false); // 비활성화도 XRI가 선택을 취소해 손에서 놓게 됨
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c11477ec44fef124bb7279924beb66af
|
||||
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
|
||||
62
Assets/02_Scripts/Communication/Dialog/DialogNode.cs
Normal file
62
Assets/02_Scripts/Communication/Dialog/DialogNode.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
[CreateAssetMenu(menuName = "Communication/Dialog Node")]
|
||||
public class DialogNode : ScriptableObject
|
||||
{
|
||||
[Header("Speaker")]
|
||||
public CharacterData Speaker;
|
||||
|
||||
[Tooltip("비우면 Speaker의 이름 그대로, 채우면 이 이름으로 표시 (예: ???)")]
|
||||
public string SpeakerNameOverride;
|
||||
|
||||
[Header("Content")]
|
||||
[TextArea(2,5)] public string TalkText;
|
||||
public GestureData Gesture;
|
||||
public ExpressionData Expression;
|
||||
public VoiceClip Voice;
|
||||
// 진행은 항상 플레이어 입력(OnDialogNext)으로 넘어간다.
|
||||
|
||||
[Header("Presentation")]
|
||||
public AudioClip Bgm; // 있으면 이 대사부터 전용 BGM 재생, 비어있으면 기본 BGM으로 복귀
|
||||
|
||||
[Header("Flow")]
|
||||
public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드
|
||||
public List<DialogChoice> Choices; // 있으면 플레이어 선택 대기
|
||||
|
||||
[Header("Hidden Branch")]
|
||||
[Tooltip("이 대사가 재생되는 동안 특정 행동(제스처)을 하면 Next/선택지 대신 이 노드로 몰래 분기. " +
|
||||
"비우면 히든 분기 없음 — 평소엔 존재하지 않는 것처럼 보인다")]
|
||||
public DialogNode HiddenBranch;
|
||||
|
||||
[Tooltip("히든 분기를 여는 제스처 키 (아이템의 DialogGestureItem의 Gesture Key와 일치). " +
|
||||
"예: kimchi, bouquet, slap. 비우면 아무 제스처 오브젝트나 발동 가능")]
|
||||
public string HiddenGestureKey;
|
||||
|
||||
[Tooltip("히든 분기를 탔을 때 기록할 선택지 Code (선택지의 Code와 동일한 용도). " +
|
||||
"비우면 기록 안 함")]
|
||||
public string HiddenCode;
|
||||
|
||||
[Header("Affection Branch")]
|
||||
[Tooltip("켜면 이 노드는 대사 없이 호감도 조건만 검사해 즉시 라우팅한다: " +
|
||||
"조건을 만족하면 AffectionPassBranch로, 아니면 Next로")]
|
||||
public bool AffectionCheck;
|
||||
|
||||
[Tooltip("검사할 호감도 조건들 (캐릭터·연산자·값 + 다음 조건과의 and/or). " +
|
||||
"캐릭터를 비우면 대화 주인 NPC. AND가 OR보다 우선. 비어 있으면 무조건 통과")]
|
||||
public List<AffectionRequirement> AffectionRequirements = new();
|
||||
|
||||
[Tooltip("조건을 만족했을 때 갈 노드 (실패하면 Next로)")]
|
||||
public DialogNode AffectionPassBranch;
|
||||
|
||||
[Header("ChoiceQuestion")]
|
||||
[TextArea(2,5)] public string ChoiceQuestion;
|
||||
|
||||
[Header("Story")]
|
||||
[Tooltip("0이 아니면 이 노드 재생 시 화자(비우면 대화 주인 NPC)의 호감도를 이만큼 증감")]
|
||||
public int Affection;
|
||||
|
||||
[Tooltip("0이 아니면 이 노드 재생 시 메인 진행도를 이만큼 증가. " +
|
||||
"분기 끝 노드마다 다른 값을 주면 선택지에 따라 진행도를 다르게 줄 수 있다")]
|
||||
public int Progress;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb30abdb4b5671b458096d48d11c7f27
|
||||
520
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal file
520
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal file
@@ -0,0 +1,520 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine;
|
||||
|
||||
[RequireComponent(typeof(CharacterVoiceObject))]
|
||||
public class DialogPlayer : MonoBehaviour
|
||||
{
|
||||
// 대화 후보는 인스펙터가 아니라 StoryDatabase에서 온다 —
|
||||
// 현재 장소(LocationManager.Current) + 이 캐릭터의 비트 중 조건을 만족하는 것들.
|
||||
// 여럿이면 선택 메뉴가 뜨고, DB 목록에서 위에 있을수록 우선순위가 높다.
|
||||
|
||||
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
|
||||
private Animator _animator;
|
||||
|
||||
// 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원.
|
||||
// (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다)
|
||||
private readonly Dictionary<Animator, (int gestureHash, int expressionHash, bool hasExpression)> _touchedAnimators = new();
|
||||
public bool IsPlaying { get; private set; }
|
||||
|
||||
// 지금 실제 대사를 재생 중인 DialogPlayer (전역 1개) — 이때 다른 NPC의 Play()는 무시된다.
|
||||
// 선택 메뉴만 떠 있는 단계는 여기 안 잡힌다 (그 경우는 새 NPC가 메뉴를 취소하고 시작).
|
||||
private static DialogPlayer _entryInProgress;
|
||||
|
||||
// ── 전역 대화 진행 신호 (대화 중 월드 상호작용 차단용) ────────
|
||||
// 선택 메뉴 단계부터 대사 종료까지, 어느 NPC든 대화가 진행 중이면 true.
|
||||
// DialogInteractionBlocker가 구독해서 인터랙터를 잠근다.
|
||||
public static bool IsAnyActive => _activeCount > 0;
|
||||
public static event Action<bool> AnyActiveChanged;
|
||||
private static int _activeCount;
|
||||
|
||||
private static void PushActive()
|
||||
{
|
||||
if (++_activeCount == 1) AnyActiveChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
private static void PopActive()
|
||||
{
|
||||
if (_activeCount <= 0) return;
|
||||
if (--_activeCount == 0) AnyActiveChanged?.Invoke(false);
|
||||
}
|
||||
|
||||
// Enter Play Mode에서 도메인 리로드를 꺼도 이전 상태가 안 남게
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetStatics()
|
||||
{
|
||||
_entryInProgress = null;
|
||||
_activeCount = 0;
|
||||
AnyActiveChanged = null;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_voice = GetComponent<CharacterVoiceObject>();
|
||||
_animator = GetComponentInChildren<Animator>(); // 화자를 못 찾을 때의 폴백 Animator
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// 재생 도중 파괴(씬 전환 등)돼도 전역 잠금이 남지 않도록
|
||||
if (_entryInProgress == this) _entryInProgress = null;
|
||||
}
|
||||
|
||||
public async Awaitable Play()
|
||||
{
|
||||
if (IsPlaying) return;
|
||||
|
||||
// 다른 NPC가 실제 대사를 재생 중이면 무시 — 대화 중 다른 NPC 상호작용 차단
|
||||
if (_entryInProgress != null) return;
|
||||
|
||||
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소한다.
|
||||
// (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다)
|
||||
if (ChoiceHud.Instance != null)
|
||||
ChoiceHud.Instance.CancelPending();
|
||||
|
||||
// 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다.
|
||||
IsPlaying = true;
|
||||
PushActive();
|
||||
try
|
||||
{
|
||||
var playable = FindPlayableBeats();
|
||||
if (playable.Count == 0)
|
||||
{
|
||||
Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}");
|
||||
return;
|
||||
}
|
||||
|
||||
StoryBeat beat = playable.Count == 1 ? playable[0] : await SelectBeat(playable);
|
||||
if (beat == null) return; // 선택 대기 중 취소됨 (다른 NPC와 대화 시작, 씬 전환 등)
|
||||
|
||||
_entryInProgress = this; // 여기서부터 실제 대사 재생 — 끝날 때까지 다른 NPC 상호작용 무시
|
||||
await PlayBeat(beat);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 대화 도중 선택지 대기가 취소됨 (씬 전환으로 ChoiceHud 비활성화 등) — PlayBeat의 finally에서 정리됨
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_entryInProgress == this) _entryInProgress = null;
|
||||
IsPlaying = false;
|
||||
PopActive();
|
||||
}
|
||||
}
|
||||
|
||||
// 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (EventZoneTrigger 등 UnityEvent 연결용).
|
||||
// DB에 등록된 비트면 그 설정(진행도 보상 등)을 그대로 쓰고, 없으면 임시 비트로 재생한다.
|
||||
// 완료 여부는 검사하지 않으므로(강제) 1회성이 필요하면 호출하는 쪽(존의 Trigger Once)에서 보장할 것.
|
||||
public void PlayGroup(DialogGroup group)
|
||||
{
|
||||
_ = PlayGroupForced(group);
|
||||
}
|
||||
|
||||
private async Awaitable PlayGroupForced(DialogGroup group)
|
||||
{
|
||||
if (group == null || IsPlaying) return;
|
||||
if (_entryInProgress != null) return; // 다른 NPC가 대사 재생 중
|
||||
|
||||
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소
|
||||
if (ChoiceHud.Instance != null)
|
||||
ChoiceHud.Instance.CancelPending();
|
||||
|
||||
IsPlaying = true;
|
||||
PushActive();
|
||||
try
|
||||
{
|
||||
// DB에 등록된 비트가 있으면 그 설정(진행도 보상 등)을 그대로 사용, 없으면 임시 비트로 재생
|
||||
var lm = LocationManager.Instance;
|
||||
StoryBeat beat = lm != null && lm.Database != null ? lm.Database.FindBeat(group) : null;
|
||||
beat ??= new StoryBeat { Group = group };
|
||||
|
||||
_entryInProgress = this;
|
||||
await PlayBeat(beat);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 씬 전환 등으로 취소됨 — PlayBeat의 finally에서 정리됨
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_entryInProgress == this) _entryInProgress = null;
|
||||
IsPlaying = false;
|
||||
PopActive();
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 장소에서 이 캐릭터가 걸 수 있는 대화 비트들 (DB 목록 순서 = 우선순위)
|
||||
private List<StoryBeat> FindPlayableBeats()
|
||||
{
|
||||
var lm = LocationManager.Instance;
|
||||
if (lm == null || lm.Database == null)
|
||||
{
|
||||
Debug.LogWarning($"[DialogPlayer] LocationManager 또는 StoryDatabase가 없음: {name}");
|
||||
return new List<StoryBeat>();
|
||||
}
|
||||
return lm.Database.GetPlayableBeats(lm.Current, _voice.Character);
|
||||
}
|
||||
|
||||
// 재생 가능한 대화가 여럿일 때 ChoiceHud로 플레이어에게 고르게 한다. 취소되면 null.
|
||||
private async Awaitable<StoryBeat> SelectBeat(List<StoryBeat> playable)
|
||||
{
|
||||
if (ChoiceHud.Instance == null)
|
||||
return playable[0]; // 선택 UI가 없으면 기존처럼 최상단 우선
|
||||
|
||||
var options = new List<DialogChoice>(playable.Count);
|
||||
foreach (var beat in playable)
|
||||
{
|
||||
string label = !string.IsNullOrWhiteSpace(beat.MenuLabel)
|
||||
? beat.MenuLabel
|
||||
: beat.Group.DialogGroupName;
|
||||
options.Add(new DialogChoice { ChoiceText = label }); // Code 없음 → 선택 이력에 기록 안 됨
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int picked = await ChoiceHud.Instance.Show(null, options);
|
||||
return playable[picked];
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 대기 중 ChoiceHud가 비활성화됨(씬 전환 등) — 재생하지 않음
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Awaitable PlayBeat(StoryBeat beat)
|
||||
{
|
||||
try
|
||||
{
|
||||
var node = beat.Group.StartNode;
|
||||
int routingHops = 0; // 연속 라우팅 횟수 — 라우팅 노드끼리 순환하면 대기 없는 무한 루프가 되므로 차단
|
||||
while (node != null)
|
||||
{
|
||||
// 호감도 라우팅 노드 — 대사 없이 즉시 분기 (플레이어에겐 분기 자체가 보이지 않는다)
|
||||
if (node.AffectionCheck)
|
||||
{
|
||||
if (++routingHops > 100)
|
||||
{
|
||||
Debug.LogError($"[DialogPlayer] 라우팅 노드가 순환합니다 — 대화 중단: {beat.Group.name}");
|
||||
break;
|
||||
}
|
||||
node = IsAffectionMet(node) ? node.AffectionPassBranch : node.Next;
|
||||
continue;
|
||||
}
|
||||
routingHops = 0; // 실제 대사 노드에 도달 — 카운터 리셋
|
||||
|
||||
// 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 제스처 감시를 무장한다.
|
||||
// (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작)
|
||||
bool armed = node.HiddenBranch != null;
|
||||
if (armed) HiddenBranchResolver.Arm(node.HiddenGestureKey);
|
||||
try
|
||||
{
|
||||
bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 제스처 감시 포함)
|
||||
|
||||
if (diverted)
|
||||
{
|
||||
RecordHiddenChoice(node);
|
||||
node = node.HiddenBranch; // 대사 도중 제스처 발동 → 몰래 분기
|
||||
}
|
||||
else if (node.Choices != null && node.Choices.Count > 0)
|
||||
{
|
||||
int picked = await WaitForChoice(node); // 메뉴(무장 시 제스처 감시 포함)
|
||||
if (picked == DivertIndex)
|
||||
{
|
||||
RecordHiddenChoice(node);
|
||||
node = node.HiddenBranch; // 메뉴 도중 제스처 발동 → 메뉴엔 없던 히든 분기
|
||||
}
|
||||
else
|
||||
{
|
||||
RecordChoice(node, picked);
|
||||
node = node.Choices[picked].DestinationNode;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
node = node.Next;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (armed) HiddenBranchResolver.Disarm();
|
||||
}
|
||||
}
|
||||
|
||||
// 여기까지 왔으면 자연 종료(끝까지 재생) — 이때만 완료로 기록한다.
|
||||
// (중간에 오브젝트 파괴 등으로 끊기면 예외로 빠져나가 기록되지 않음)
|
||||
var story = StoryManager.Instance;
|
||||
bool firstTime = story.MarkDialogCompleted(beat.Group.name);
|
||||
if (firstTime && beat.ProgressOnComplete > 0)
|
||||
story.MainProgress += beat.ProgressOnComplete;
|
||||
story.Save();
|
||||
|
||||
Debug.Log($"[DialogPlayer] 대화 종료: {beat.Group.name}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (DialogHud.Instance != null)
|
||||
DialogHud.Instance.Hide();
|
||||
if (SoundManager.Instance != null)
|
||||
SoundManager.Instance.ClearOverrideBGM(); // 대화가 끝나면 기본 BGM으로 복귀
|
||||
RestoreDefaultAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
// 대화 중 제스처/표정을 재생했던 모든 Animator(끼어든 NPC 포함)를 원래 상태로 복원
|
||||
private void RestoreDefaultAnimations()
|
||||
{
|
||||
foreach (var kvp in _touchedAnimators)
|
||||
{
|
||||
var anim = kvp.Key;
|
||||
if (anim == null) continue;
|
||||
anim.CrossFade(kvp.Value.gestureHash, 0.3f, 0, normalizedTimeOffset: 0f);
|
||||
if (kvp.Value.hasExpression)
|
||||
anim.CrossFade(kvp.Value.expressionHash, 0.3f, 1, normalizedTimeOffset: 0f);
|
||||
}
|
||||
_touchedAnimators.Clear();
|
||||
}
|
||||
|
||||
// 처음 건드리는 Animator면 현재 상태를 기억해 둔다 (대화 종료 시 복원 기준)
|
||||
private void CaptureInitialAnimState(Animator anim)
|
||||
{
|
||||
if (_touchedAnimators.ContainsKey(anim)) return;
|
||||
bool hasExpression = anim.layerCount > 1;
|
||||
_touchedAnimators[anim] = (
|
||||
anim.GetCurrentAnimatorStateInfo(0).fullPathHash,
|
||||
hasExpression ? anim.GetCurrentAnimatorStateInfo(1).fullPathHash : 0,
|
||||
hasExpression);
|
||||
}
|
||||
|
||||
// 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행
|
||||
private async Awaitable<bool> PlayNode(DialogNode node)
|
||||
{
|
||||
// DialogHud에 대사 표시 (이름은 노드 오버라이드 우선)
|
||||
if (DialogHud.Instance != null)
|
||||
DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride);
|
||||
|
||||
// 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC
|
||||
if (node.Affection != 0)
|
||||
{
|
||||
var affectionTarget = node.Speaker != null ? node.Speaker : _voice.Character;
|
||||
StoryManager.Instance.AddAffection(affectionTarget, node.Affection);
|
||||
}
|
||||
|
||||
// 메인 진행도 증가 — 분기 끝 노드마다 다른 값을 주면 선택지에 따라 진행도가 달라진다
|
||||
if (node.Progress != 0)
|
||||
StoryManager.Instance.MainProgress += node.Progress;
|
||||
|
||||
// 전용 BGM: 설정돼 있으면 교체, 비어 있으면 기본 BGM으로 복귀
|
||||
if (SoundManager.Instance != null)
|
||||
{
|
||||
if (node.Bgm != null)
|
||||
SoundManager.Instance.PlayOverrideBGM(node.Bgm);
|
||||
else
|
||||
SoundManager.Instance.ClearOverrideBGM();
|
||||
}
|
||||
|
||||
// 보이스 재생
|
||||
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);
|
||||
}
|
||||
|
||||
// 제스처/표정은 화자(Target)의 Animator에 적용 — 끼어든 NPC의 대사/연출 노드도 그 캐릭터가 움직인다.
|
||||
// 화자를 못 찾으면 대화 주인 NPC의 Animator로 폴백.
|
||||
if (node.Gesture != null || node.Expression != null)
|
||||
{
|
||||
var gestureObj = node.Speaker != null ? CharacterVoiceObject.Find(node.Speaker) : null;
|
||||
var anim = gestureObj != null ? gestureObj.GetComponentInChildren<Animator>() : _animator;
|
||||
if (anim == null) anim = _animator;
|
||||
|
||||
if (anim != null)
|
||||
{
|
||||
CaptureInitialAnimState(anim); // 대화 종료 시 복원할 원래 상태 기억
|
||||
if (node.Gesture != null)
|
||||
anim.CrossFade(node.Gesture.StateName, node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
|
||||
if (node.Expression != null)
|
||||
anim.CrossFade(node.Expression.StateName, node.Expression.CrossFadeDuration, node.Expression.AnimationLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// 진행은 항상 플레이어 입력 대기.
|
||||
// 히든 분기가 무장된 노드는 대기 중 제스처를 함께 감시(레이스)한다.
|
||||
if (HiddenBranchResolver.Armed)
|
||||
return await WaitAdvanceOrDivert();
|
||||
|
||||
await WaitForAdvanceInput();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 호감도 분기 노드의 조건식 평가.
|
||||
// 각 조건의 JoinWithNext로 이어 붙이며, AND가 OR보다 우선순위가 높다:
|
||||
// A and B or C → (A and B) or C
|
||||
// 즉 "AND 그룹들을 OR로 합치는" 형태로 계산한다. 조건이 없으면 통과로 본다.
|
||||
private bool IsAffectionMet(DialogNode node)
|
||||
{
|
||||
var requirements = node.AffectionRequirements;
|
||||
if (requirements == null || requirements.Count == 0) return true;
|
||||
|
||||
var story = StoryManager.Instance;
|
||||
bool result = false; // 지금까지 닫힌 AND 그룹들을 OR로 합친 값
|
||||
bool group = true; // 현재 진행 중인 AND 그룹
|
||||
|
||||
for (int i = 0; i < requirements.Count; i++)
|
||||
{
|
||||
var req = requirements[i];
|
||||
if (req == null) continue;
|
||||
|
||||
var target = req.Character != null ? req.Character : _voice.Character;
|
||||
group &= req.IsMet(story.GetAffection(target));
|
||||
|
||||
// 다음 연결자가 OR이거나 마지막이면 AND 그룹을 닫고 OR로 합친다
|
||||
bool isLast = i == requirements.Count - 1;
|
||||
if (isLast || req.JoinWithNext == AffectionJoin.Or)
|
||||
{
|
||||
result |= group;
|
||||
group = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 선택 기록: 선택지 Code를 StoryState에 영구 기록 (대화 활성화 조건 판정에 사용)
|
||||
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} 치환 → 동적으로 생성된 코드 반영
|
||||
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
StoryManager.Instance.RecordChoice(code);
|
||||
}
|
||||
|
||||
// 히든 분기 기록: 노드의 HiddenCode를 선택지 Code와 동일하게 StoryState에 영구 기록
|
||||
private void RecordHiddenChoice(DialogNode node)
|
||||
{
|
||||
string code = DialogVariables.Format(node.HiddenCode); // {token} 치환
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
StoryManager.Instance.RecordChoice(code);
|
||||
}
|
||||
|
||||
// 히든 분기 신호값 — 선택지 인덱스(0..n-1)와 절대 겹치지 않는 값.
|
||||
private const int DivertIndex = -1;
|
||||
|
||||
private async Awaitable<int> WaitForChoice(DialogNode node)
|
||||
{
|
||||
if (ChoiceHud.Instance == null)
|
||||
{
|
||||
Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 무장 안 된 노드는 기존과 동일 — 그냥 메뉴 선택을 기다린다.
|
||||
if (!HiddenBranchResolver.Armed)
|
||||
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
|
||||
|
||||
// 무장된 노드: 메뉴가 떠 있는 동안 제스처가 들어오면 메뉴를 취소하고 히든으로 분기.
|
||||
if (HiddenBranchResolver.Fired)
|
||||
return DivertIndex; // 메뉴가 뜨기 전에 이미 발동한 경우
|
||||
|
||||
void OnFired() => ChoiceHud.Instance.CancelPending(); // 제스처 → 메뉴 즉시 취소
|
||||
HiddenBranchResolver.FiredEvent += OnFired;
|
||||
try
|
||||
{
|
||||
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 우리가 제스처로 취소한 것이면 히든 분기, 아니면(씬 전환 등) 위로 전파
|
||||
if (HiddenBranchResolver.Fired) return DivertIndex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
HiddenBranchResolver.FiredEvent -= OnFired;
|
||||
}
|
||||
}
|
||||
|
||||
// 노드 대기 + 히든 제스처 감시(레이스). 무장된 노드에서만 호출된다.
|
||||
// 반환: true = 대기 도중 제스처 발동(→히든 분기), false = 평범하게 진행(입력)
|
||||
private async Awaitable<bool> WaitAdvanceOrDivert()
|
||||
{
|
||||
var im = InputManager.Instance;
|
||||
bool advance = false;
|
||||
void OnAdvance() => advance = true;
|
||||
if (im != null) im.OnDialogNext_Event += OnAdvance;
|
||||
|
||||
// InputManager가 없으면 기존 WaitForAdvanceInput처럼 1초 후 진행
|
||||
float timeLeft = im == null ? 1f : -1f;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (HiddenBranchResolver.Fired) return true; // 제스처 발동 → 히든 분기
|
||||
if (advance) return false; // 진행 입력 → 평범하게 진행
|
||||
if (timeLeft >= 0f)
|
||||
{
|
||||
timeLeft -= Time.deltaTime;
|
||||
if (timeLeft <= 0f) return false;
|
||||
}
|
||||
await Awaitable.NextFrameAsync(destroyCancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false; // 대기 중 파괴/씬 전환 — 기존과 동일하게 조용히 종료
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (im != null) im.OnDialogNext_Event -= OnAdvance;
|
||||
}
|
||||
}
|
||||
|
||||
// 대화 진행 입력(OnDialogNext) 한 번을 대기
|
||||
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(); // 테스트용 fire-and-forget (Update는 await 불가)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9838e0e0a3edefa4e92ddbb98aaa3ce5
|
||||
@@ -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,83 @@
|
||||
using System;
|
||||
using Unity.GraphToolkit.Editor;
|
||||
|
||||
namespace DinoLove.Dialog.GraphTool.Editor
|
||||
{
|
||||
// 호감도 분기 노드. 대사 없이 호감도 조건식만 검사해 두 경로 중 하나로 즉시 라우팅한다.
|
||||
// DialogNode(AffectionCheck=true) 하나로 변환된다.
|
||||
//
|
||||
// Condition Count로 조건 줄을 늘린다. 조건 하나는 [Target / 연산자 / 값] 세 줄이고,
|
||||
// 조건 사이마다 [and · or] 연결자 줄이 하나씩 생긴다:
|
||||
//
|
||||
// Target 1 윤지후
|
||||
// Compare 1 ≥
|
||||
// Affection 1 30
|
||||
// Join 1 AND
|
||||
// Target 2 잔디
|
||||
// Compare 2 <
|
||||
// Affection 2 20
|
||||
// ├─ True →
|
||||
// └─ False →
|
||||
//
|
||||
// AND가 OR보다 우선순위가 높다 (A and B or C → (A and B) or C).
|
||||
[Serializable]
|
||||
internal class DialogAffectionNode : DialogGraphNode
|
||||
{
|
||||
public const string PORT_PASS_OUT = "PassOut";
|
||||
public const string PORT_FAIL_OUT = "FailOut";
|
||||
|
||||
public const string OPTION_CONDITION_COUNT = "ConditionCount";
|
||||
|
||||
// 조건별 포트 이름 규칙 (임포터와 공유)
|
||||
public static string TargetPort(int i) => $"Target{i}";
|
||||
public static string ComparePort(int i) => $"Compare{i}";
|
||||
public static string ValuePort(int i) => $"Value{i}";
|
||||
public static string JoinPort(int i) => $"Join{i}";
|
||||
|
||||
protected override void OnDefineOptions(IOptionDefinitionContext context)
|
||||
{
|
||||
context.AddOption<int>(OPTION_CONDITION_COUNT)
|
||||
.WithDisplayName("Condition Count")
|
||||
.WithTooltip("검사할 호감도 조건 개수 (캐릭터마다 하나씩)")
|
||||
.WithDefaultValue(1)
|
||||
.Delayed();
|
||||
}
|
||||
|
||||
protected override void OnDefinePorts(IPortDefinitionContext context)
|
||||
{
|
||||
AddExecInput(context);
|
||||
|
||||
int conditionCount = 1;
|
||||
GetNodeOptionByName(OPTION_CONDITION_COUNT)?.TryGetValue(out conditionCount);
|
||||
if (conditionCount < 1) conditionCount = 1;
|
||||
|
||||
for (int i = 0; i < conditionCount; i++)
|
||||
{
|
||||
context.AddInputPort<CharacterData>(TargetPort(i))
|
||||
.WithDisplayName($"Target {i + 1}")
|
||||
.WithTooltip("누구의 호감도를 검사할지. 비우면 대화 주인 NPC")
|
||||
.Build();
|
||||
context.AddInputPort<AffectionCompare>(ComparePort(i))
|
||||
.WithDisplayName($"Compare {i + 1}")
|
||||
.WithTooltip("호감도를 아래 값과 어떻게 비교할지")
|
||||
.Build();
|
||||
context.AddInputPort<int>(ValuePort(i))
|
||||
.WithDisplayName($"Affection {i + 1}")
|
||||
.WithTooltip("비교 기준값")
|
||||
.Build();
|
||||
|
||||
// 조건 사이에만 연결자를 둔다 (마지막 조건 뒤에는 이어질 게 없으므로 생략)
|
||||
if (i < conditionCount - 1)
|
||||
{
|
||||
context.AddInputPort<AffectionJoin>(JoinPort(i))
|
||||
.WithDisplayName($"Join {i + 1}")
|
||||
.WithTooltip("다음 조건과 묶는 방식. AND가 OR보다 우선")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
AddExecOutput(context, PORT_PASS_OUT, "True →");
|
||||
AddExecOutput(context, PORT_FAIL_OUT, "False →");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25bc0691603f93e4196ae428c028e855
|
||||
@@ -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,236 @@
|
||||
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(15, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
|
||||
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 (이름은 파일명 기준 — StoryState 대화 이력의 키로 쓰임)
|
||||
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 && gn is not DialogAffectionNode))
|
||||
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 dn = map[gn];
|
||||
|
||||
// 호감도 분기 노드 — 대사 없이 라우팅만: 조건 통과 → AffectionPassBranch, 실패 → Next
|
||||
if (gn is DialogAffectionNode affectionNode)
|
||||
{
|
||||
dn.AffectionCheck = true;
|
||||
|
||||
int conditionCount = 1;
|
||||
var countOption = affectionNode.GetNodeOptionByName(DialogAffectionNode.OPTION_CONDITION_COUNT);
|
||||
if (countOption != null && countOption.TryGetValue(out int storedCount) && storedCount > 0)
|
||||
conditionCount = storedCount;
|
||||
|
||||
dn.AffectionRequirements = new List<AffectionRequirement>(conditionCount);
|
||||
for (int i = 0; i < conditionCount; i++)
|
||||
{
|
||||
dn.AffectionRequirements.Add(new AffectionRequirement
|
||||
{
|
||||
Character = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogAffectionNode.TargetPort(i))),
|
||||
Compare = GetInputPortValue<AffectionCompare>(gn.GetInputPortByName(DialogAffectionNode.ComparePort(i))),
|
||||
Value = GetInputPortValue<int>(gn.GetInputPortByName(DialogAffectionNode.ValuePort(i))),
|
||||
// 마지막 조건에는 Join 포트가 없다 → 기본 And (평가 시 무시됨)
|
||||
JoinWithNext = i < conditionCount - 1
|
||||
? GetInputPortValue<AffectionJoin>(gn.GetInputPortByName(DialogAffectionNode.JoinPort(i)))
|
||||
: AffectionJoin.And
|
||||
});
|
||||
}
|
||||
|
||||
var passDest = GetConnectedNode(gn, DialogAffectionNode.PORT_PASS_OUT);
|
||||
dn.AffectionPassBranch = passDest != null && map.TryGetValue(passDest, out var passDn) ? passDn : null;
|
||||
|
||||
var failDest = GetConnectedNode(gn, DialogAffectionNode.PORT_FAIL_OUT);
|
||||
dn.Next = failDest != null && map.TryGetValue(failDest, out var failDn) ? failDn : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
var line = (DialogLineNode)gn;
|
||||
|
||||
dn.Speaker = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogLineNode.PORT_SPEAKER));
|
||||
dn.SpeakerNameOverride = GetInputPortValue<DialogShortText>(gn.GetInputPortByName(DialogLineNode.PORT_NAME_OVERRIDE)).Value;
|
||||
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.Bgm = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogLineNode.PORT_BGM));
|
||||
dn.Affection = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_AFFECTION));
|
||||
dn.Progress = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_PROGRESS));
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 히든 분기 (켜져 있으면) — 목적지 노드 + 제스처 키 + 기록 코드
|
||||
bool hasHidden = false;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
|
||||
if (hasHidden)
|
||||
{
|
||||
var hiddenDest = GetConnectedNode(gn, DialogLineNode.PORT_HIDDEN_OUT);
|
||||
dn.HiddenBranch = hiddenDest != null && map.TryGetValue(hiddenDest, out var hiddenDn) ? hiddenDn : null;
|
||||
|
||||
string gestureKey = null;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_GESTURE)?.TryGetValue(out gestureKey);
|
||||
dn.HiddenGestureKey = gestureKey;
|
||||
|
||||
string hiddenCode = null;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_CODE)?.TryGetValue(out hiddenCode);
|
||||
dn.HiddenCode = hiddenCode;
|
||||
}
|
||||
}
|
||||
|
||||
group.StartNode = map.TryGetValue(firstGraphNode, out var startDn) ? startDn : null;
|
||||
}
|
||||
|
||||
// 노드의 실행 흐름상 후속 노드들 (선형이면 1개, N지선다면 N개)
|
||||
static IEnumerable<INode> GetSuccessors(INode node)
|
||||
{
|
||||
// 호감도 분기 노드는 두 출력 모두 후속
|
||||
if (node is DialogAffectionNode)
|
||||
{
|
||||
yield return GetConnectedNode(node, DialogAffectionNode.PORT_PASS_OUT);
|
||||
yield return GetConnectedNode(node, DialogAffectionNode.PORT_FAIL_OUT);
|
||||
yield break;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// 히든 분기 후속도 도달 가능해야 DialogNode로 생성된다
|
||||
bool hasHidden = false;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
|
||||
if (hasHidden)
|
||||
yield return GetConnectedNode(node, DialogLineNode.PORT_HIDDEN_OUT);
|
||||
}
|
||||
|
||||
// 출력 실행 포트에 연결된 노드 (없으면 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,117 @@
|
||||
using System;
|
||||
using Unity.GraphToolkit.Editor;
|
||||
using UnityEngine;
|
||||
|
||||
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_NAME_OVERRIDE = "SpeakerNameOverride";
|
||||
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_BGM = "Bgm";
|
||||
public const string PORT_AFFECTION = "Affection";
|
||||
public const string PORT_PROGRESS = "Progress";
|
||||
public const string PORT_QUESTION = "ChoiceQuestion";
|
||||
public const string PORT_HIDDEN_OUT = "HiddenOut";
|
||||
|
||||
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
|
||||
public const string OPTION_HIDDEN_BRANCH = "HasHiddenBranch";
|
||||
public const string OPTION_HIDDEN_GESTURE = "HiddenGestureKey";
|
||||
public const string OPTION_HIDDEN_CODE = "HiddenCode";
|
||||
|
||||
// 선택지별 포트 이름 규칙 (임포터와 공유)
|
||||
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<bool>(OPTION_HIDDEN_BRANCH)
|
||||
.WithDisplayName("Hidden Branch")
|
||||
.WithTooltip("켜면 맨 아래에 'Hidden →' 출력 포트가 생긴다. 이 대사 재생 중 특정 제스처(싸대기/터치)를 하면 " +
|
||||
"Next/선택지 대신 그 포트로 연결된 노드로 몰래 분기한다")
|
||||
.WithDefaultValue(false);
|
||||
|
||||
context.AddOption<string>(OPTION_HIDDEN_GESTURE)
|
||||
.WithDisplayName("Hidden Gesture Key")
|
||||
.WithTooltip("히든 분기를 여는 제스처 키 (DialogGestureZone의 Gesture Key와 일치). 비우면 아무 제스처 존이나 발동")
|
||||
.Delayed();
|
||||
|
||||
context.AddOption<string>(OPTION_HIDDEN_CODE)
|
||||
.WithDisplayName("Hidden Code")
|
||||
.WithTooltip("히든 분기를 탔을 때 기록할 선택지 Code. 비우면 기록 안 함")
|
||||
.Delayed();
|
||||
}
|
||||
|
||||
protected override void OnDefinePorts(IPortDefinitionContext context)
|
||||
{
|
||||
AddExecInput(context);
|
||||
|
||||
// DialogNode의 라인 데이터 (모두 선택 입력, 비워두면 default)
|
||||
context.AddInputPort<CharacterData>(PORT_SPEAKER).WithDisplayName("Speaker").Build();
|
||||
context.AddInputPort<DialogShortText>(PORT_NAME_OVERRIDE).WithDisplayName("Name Override")
|
||||
.WithTooltip("비우면 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<AudioClip>(PORT_BGM).WithDisplayName("BGM")
|
||||
.WithTooltip("있으면 이 대사부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build();
|
||||
context.AddInputPort<int>(PORT_AFFECTION).WithDisplayName("Affection ±")
|
||||
.WithTooltip("0이 아니면 이 대사 재생 시 화자(비우면 대화 주인 NPC)의 호감도를 이만큼 증감").Build();
|
||||
context.AddInputPort<int>(PORT_PROGRESS).WithDisplayName("Progress +")
|
||||
.WithTooltip("0이 아니면 이 대사 재생 시 메인 진행도를 이만큼 증가 (분기 끝 노드에 달아 선택지별로 다르게)").Build();
|
||||
|
||||
int choiceCount = 0;
|
||||
GetNodeOptionByName(OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
|
||||
|
||||
if (choiceCount <= 0)
|
||||
{
|
||||
// 선형 진행
|
||||
AddExecOutput(context, EXEC_OUT, string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 가변 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} →");
|
||||
}
|
||||
}
|
||||
|
||||
// 히든 분기 출력 — 켜져 있으면 선형/선택지 관계없이 맨 아래에 붙는다.
|
||||
// 이 포트에 연결된 노드가 DialogNode.HiddenBranch가 된다.
|
||||
bool hasHidden = false;
|
||||
GetNodeOptionByName(OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
|
||||
if (hasHidden)
|
||||
AddExecOutput(context, PORT_HIDDEN_OUT, "Hidden →");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be6c5a15779e30f40910985e5d7cfbd9
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace DinoLove.Dialog.GraphTool.Editor
|
||||
{
|
||||
// 그래프 노드의 한 줄짜리 텍스트 포트용 래퍼 타입 (이름 오버라이드 등 짧은 텍스트).
|
||||
// DialogText와 마찬가지로 string 포트의 IME 중복입력 버그를 피하기 위한 타입이며,
|
||||
// 전용 DialogShortTextDrawer가 한 줄 TextField로 렌더한다.
|
||||
// 임포트 시 Value 문자열만 전달된다(런타임은 이 타입을 모름).
|
||||
//
|
||||
// public인 이유: GraphToolkit이 포트 임베드 값을 편집할 때 이 타입을 감싸는
|
||||
// 래퍼 ScriptableObject를 Reflection.Emit으로 다른 어셈블리에 생성하므로
|
||||
// 접근 가능해야 한다.
|
||||
[Serializable]
|
||||
public struct DialogShortText
|
||||
{
|
||||
public string Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9432f3355b50d3d458cff5e56b6c9206
|
||||
@@ -0,0 +1,22 @@
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace DinoLove.Dialog.GraphTool.Editor
|
||||
{
|
||||
// DialogShortText를 한 줄 입력 필드로 그린다. (DialogTextDrawer의 싱글라인 버전)
|
||||
[CustomPropertyDrawer(typeof(DialogShortText))]
|
||||
internal class DialogShortTextDrawer : PropertyDrawer
|
||||
{
|
||||
public override VisualElement CreatePropertyGUI(SerializedProperty property)
|
||||
{
|
||||
var valueProp = property.FindPropertyRelative(nameof(DialogShortText.Value));
|
||||
|
||||
var field = new TextField();
|
||||
if (valueProp != null)
|
||||
field.BindProperty(valueProp);
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15b46c322dec78d438a144f875d67d42
|
||||
@@ -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) 기반.
|
||||
|
||||
## 동작 개요
|
||||
- `.dlg` 그래프 에셋을 노드로 편집 → 저장하면 `DialogGraphImporter`가
|
||||
기존 런타임 타입(`DialogGroup` + 여러 `DialogNode`)으로 자동 변환합니다.
|
||||
- 변환 결과: **메인 에셋 = `DialogGroup`**, 서브에셋 = 각 `DialogNode`.
|
||||
- `DialogPlayer`는 **수정 없이** 임포트된 `.dlg`(=DialogGroup)를 그대로 사용합니다.
|
||||
|
||||
## 사용법
|
||||
1. Project 창에서 우클릭 → `Create > Communication > Dialog Graph` 로 `.dlg` 생성.
|
||||
2. 더블클릭해 그래프 에디터를 엽니다.
|
||||
3. 노드 추가:
|
||||
- **Dialog Start Node** : 진입점. 출력 화살표를 첫 대사 노드에 연결. (그래프당 1개)
|
||||
- **Dialog Line Node** : 대사 1줄. Speaker/TalkText/Gesture/Expression/Voice/BGM 입력.
|
||||
진행은 항상 플레이어 입력으로 넘어갑니다.
|
||||
- `Choice Count = 0` → 선형. `Out` 출력을 다음 노드로 연결(= `DialogNode.Next`).
|
||||
- `Choice Count = N` → 가변 N지선다. `Choice Question` + 선택지마다
|
||||
`Choice i Text`(텍스트) 와 `Choice i →`(분기 출력) 생성.
|
||||
각 분기 출력을 목적지 노드에 연결(= `DialogNode.Choices[i].DestinationNode`).
|
||||
4. 저장(임포트)되면 `.dlg` 에셋이 `DialogGroup`이 됩니다.
|
||||
이를 `StoryDatabase`의 비트(장소·캐릭터·조건)에 등록하면 끝 —
|
||||
해당 장소에서 그 캐릭터에게 말을 걸면 조건에 따라 재생됩니다.
|
||||
|
||||
## 구성 파일 (모두 Editor 전용)
|
||||
- `DialogGraph.cs` — 그래프 타입/생성 메뉴/검증
|
||||
- `DialogGraphNode.cs` — 공통 베이스(실행 포트 헬퍼)
|
||||
- `DialogStartNode.cs` — 진입 노드
|
||||
- `DialogLineNode.cs` — 대사 + 가변 N지선다 노드
|
||||
- `DialogText.cs` — TalkText 멀티라인 입력용 래퍼 타입
|
||||
- `DialogTextDrawer.cs` — DialogText를 여러 줄 TextField로 그리는 CustomPropertyDrawer
|
||||
- `DialogGraphImporter.cs`— .dlg → 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:
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// 히든 분기 감시 허브 (씬 오브젝트 불필요, 순수 static).
|
||||
//
|
||||
// 대화 노드가 재생되는 동안 DialogPlayer가 Arm(key)로 무장하고, 끝나면 Disarm()한다.
|
||||
// 그 창(무장 구간) 안에서 DialogGestureZone이 Fire(key)를 호출하면 Fired가 켜지고,
|
||||
// DialogPlayer가 이를 감지해 Next(또는 선택지) 대신 노드의 HiddenBranch로 몰래 분기한다.
|
||||
//
|
||||
// 무장돼 있지 않으면 어떤 Fire도 무시된다 → 평소엔 존재하지 않는 것처럼 동작(=히든).
|
||||
public static class HiddenBranchResolver
|
||||
{
|
||||
private static string _armedKey;
|
||||
|
||||
// 지금 히든 분기를 받을 수 있는 상태인가 (노드 재생 중)
|
||||
public static bool Armed { get; private set; }
|
||||
|
||||
// 이번 무장 구간에서 제스처가 발동했는가
|
||||
public static bool Fired { get; private set; }
|
||||
|
||||
// 발동 즉시 알림 — 떠 있는 선택지 메뉴를 그 자리에서 취소시키는 용도
|
||||
public static event Action FiredEvent;
|
||||
|
||||
// Enter Play Mode(도메인 리로드 off)에서 이전 상태가 안 남게
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetStatics()
|
||||
{
|
||||
_armedKey = null;
|
||||
Armed = false;
|
||||
Fired = false;
|
||||
FiredEvent = null;
|
||||
}
|
||||
|
||||
// 노드 진입 시 무장. key가 비면 아무 제스처 존이나 발동시킬 수 있다.
|
||||
public static void Arm(string key)
|
||||
{
|
||||
_armedKey = key;
|
||||
Armed = true;
|
||||
Fired = false;
|
||||
}
|
||||
|
||||
// 노드 종료 시 해제.
|
||||
public static void Disarm()
|
||||
{
|
||||
Armed = false;
|
||||
_armedKey = null;
|
||||
Fired = false;
|
||||
}
|
||||
|
||||
// 제스처 존이 호출. 무장 중이고 key가 맞을 때만 1회 발동.
|
||||
// 반환: 이번 호출로 실제 발동했으면 true (호출 측이 "그때만" 아이템 소비 등에 쓸 수 있게).
|
||||
public static bool Fire(string key)
|
||||
{
|
||||
if (!Armed || Fired) return false;
|
||||
if (!string.IsNullOrEmpty(_armedKey) && _armedKey != key) return false;
|
||||
Fired = true;
|
||||
FiredEvent?.Invoke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1622ac8790b08674cbb2164b2a8dd1f7
|
||||
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