This commit is contained in:
2026-07-24 18:20:02 +09:00
commit 0b02bacb3d
177 changed files with 5172 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,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,
};
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View 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가 선택을 취소해 손에서 놓게 됨
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

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

View 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 불가)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 →");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 25bc0691603f93e4196ae428c028e855

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 →");
}
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9432f3355b50d3d458cff5e56b6c9206

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 15b46c322dec78d438a144f875d67d42

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
# Dialog Graph Tool
`Communication/Dialog` 시스템(`DialogGroup` / `DialogNode` / `DialogChoice`)을
**노드 그래프로 저작**하기 위한 에디터 전용 도구입니다.
Unity GraphToolkit(`com.unity.graphtoolkit`, experimental) 기반.
## 동작 개요
- `.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`로 합쳐집니다.

View File

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1622ac8790b08674cbb2164b2a8dd1f7

View File

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

View File

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

View File

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

View File

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

View File

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