diff --git a/Assets/02_Scripts/Communication/Dialog/DialogGestureZone.cs b/Assets/02_Scripts/Communication/Dialog/DialogGestureZone.cs new file mode 100644 index 00000000..db0f4e9b --- /dev/null +++ b/Assets/02_Scripts/Communication/Dialog/DialogGestureZone.cs @@ -0,0 +1,25 @@ +using UnityEngine; + +// 히든 분기를 물리 접촉으로 발동시키는 트리거 존 (KimchiHitZone과 같은 방식). +// +// 예) NPC 얼굴에 IsTrigger 콜라이더를 붙이고 GestureKey="slap", RequiredTag="PlayerHand". +// 대화 노드의 HiddenBranch가 설정되고 HiddenGestureKey="slap"인 순간에만 +// 이 존을 손으로 치면 히든 분기를 탄다. 그 외 시간엔 쳐도 아무 일도 안 일어난다. +// +// 콜라이더는 항상 켜 둬도 된다 — 발동 여부는 DialogPlayer의 무장 상태가 결정하므로, +// 노드마다 존을 껐다 켰다 할 필요가 없다. +public class DialogGestureZone : MonoBehaviour +{ + [Tooltip("이 존이 발동시킬 제스처 키. 대화 노드의 HiddenGestureKey와 일치해야 한다. " + + "노드 쪽 키가 비어 있으면 아무 존이나 발동 가능")] + [SerializeField] private string _gestureKey = "slap"; + + [Tooltip("이 태그를 가진 콜라이더가 들어와야 발동 (예: 손 컨트롤러). 비우면 태그 무시")] + [SerializeField] private string _requiredTag = "PlayerHand"; + + private void OnTriggerEnter(Collider other) + { + if (!string.IsNullOrEmpty(_requiredTag) && !other.CompareTag(_requiredTag)) return; + HiddenBranchResolver.Fire(_gestureKey); + } +} diff --git a/Assets/02_Scripts/Communication/Dialog/DialogGestureZone.cs.meta b/Assets/02_Scripts/Communication/Dialog/DialogGestureZone.cs.meta new file mode 100644 index 00000000..ed4b22ee --- /dev/null +++ b/Assets/02_Scripts/Communication/Dialog/DialogGestureZone.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 408261957835e514186f6a6f82e367e1 \ No newline at end of file diff --git a/Assets/02_Scripts/Communication/Dialog/DialogNode.cs b/Assets/02_Scripts/Communication/Dialog/DialogNode.cs index 490efe1a..b41fa39f 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogNode.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogNode.cs @@ -40,6 +40,19 @@ public class DialogNode : ScriptableObject public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드 public List Choices; // 있으면 플레이어 선택 대기 + [Header("Hidden Branch")] + [Tooltip("이 대사가 재생되는 동안 특정 행동(제스처)을 하면 Next/선택지 대신 이 노드로 몰래 분기. " + + "비우면 히든 분기 없음 — 평소엔 존재하지 않는 것처럼 보인다")] + public DialogNode HiddenBranch; + + [Tooltip("히든 분기를 여는 제스처 키 (DialogGestureZone의 Gesture Key와 일치). " + + "비우면 아무 제스처 존이나 발동 가능")] + public string HiddenGestureKey; + + [Tooltip("히든 분기를 탔을 때 기록할 선택지 Code (선택지의 Code와 동일한 용도). " + + "비우면 기록 안 함. 이후 대화 조건(RequiredChoiceCodes)에서 검사 가능")] + public string HiddenCode; + [Header("ChoiceQuestion")] [TextArea(2,5)] public string ChoiceQuestion; diff --git a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs index 7ed197b9..d3d5e49c 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs @@ -269,17 +269,41 @@ private async Awaitable PlayEntry(DialogEntry entry) var node = entry.Group.StartNode; while (node != null) { - await PlayNode(node); + // 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 제스처 감시를 무장한다. + // (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작) + bool armed = node.HiddenBranch != null; + if (armed) HiddenBranchResolver.Arm(node.HiddenGestureKey); + try + { + bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 제스처 감시 포함) - if (node.Choices != null && node.Choices.Count > 0) - { - int picked = await WaitForChoice(node); - RecordChoice(node, picked); - node = node.Choices[picked].DestinationNode; + 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; + } } - else + finally { - node = node.Next; + if (armed) HiddenBranchResolver.Disarm(); } } @@ -433,7 +457,8 @@ private void LateUpdate() } } - private async Awaitable PlayNode(DialogNode node) + // 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행 + private async Awaitable PlayNode(DialogNode node) { // 화자 옆 DialogHud에 대사 표시 // (배치는 화자의 DialogHudPlacement 담당, 없으면 DialogHud 기본값. 이름은 노드 오버라이드 우선) @@ -528,6 +553,11 @@ private async Awaitable PlayNode(DialogNode node) } // 진행 방식 결정 + // 히든 분기가 무장된 노드는 대기 중 제스처를 함께 감시(레이스)한다. + // 무장 안 된 노드는 아래 기존 대기 그대로 → 동작 100% 동일, 반환 false. + if (HiddenBranchResolver.Armed) + return await WaitAdvanceOrDivert(node); + if (node.WaitForInput) { await WaitForAdvanceInput(); // B버튼 입력이 있어야만 다음으로 @@ -543,6 +573,7 @@ private async Awaitable PlayNode(DialogNode node) else await WaitForAdvanceInput(); // 지정 시간이 없으면 입력으로 진행 } + return false; } // 노드의 EventKey와 같은 Key를 가진 이벤트들을 호출 @@ -564,17 +595,102 @@ private void RecordChoice(DialogNode node, int index) 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 WaitForChoice(DialogNode node) { - //선택을 기다리는 함수 수정해서 사용할것 - if (ChoiceHud.Instance == null) { Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택"); return 0; } - return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices); + // 무장 안 된 노드는 기존과 동일 — 그냥 메뉴 선택을 기다린다. + 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 WaitAdvanceOrDivert(DialogNode node) + { + // 기존 PlayNode 대기 규칙 그대로 계산 — 입력 대기냐, 시간 대기냐 + bool waitForInput; + float timeoutSeconds; + if (node.WaitForInput) + { + waitForInput = true; timeoutSeconds = -1f; + } + else + { + float wait = (node.Voice != null && node.Voice.Clip != null) + ? node.Voice.Clip.length + : node.LineDuration; + if (wait > 0f) { waitForInput = false; timeoutSeconds = wait; } + else { waitForInput = true; timeoutSeconds = -1f; } + } + + var im = InputManager.Instance; + bool advance = false; + void OnAdvance() => advance = true; + if (waitForInput && im != null) im.OnDialogNext_Event += OnAdvance; + + // 입력 대기인데 InputManager가 없으면 기존 WaitForAdvanceInput처럼 1초 후 진행 + float timeLeft = timeoutSeconds; + if (waitForInput && im == null) timeLeft = 1f; + + try + { + while (true) + { + if (HiddenBranchResolver.Fired) return true; // 제스처 발동 → 히든 분기 + if (waitForInput && advance) return false; // B버튼 → 평범하게 진행 + if (timeLeft >= 0f) + { + timeLeft -= Time.deltaTime; + if (timeLeft <= 0f) return false; // 시간 경과 → 평범하게 진행 + } + await Awaitable.NextFrameAsync(destroyCancellationToken); + } + } + catch (OperationCanceledException) + { + return false; // 대기 중 파괴/씬 전환 — 기존과 동일하게 조용히 종료 + } + finally + { + if (waitForInput && im != null) im.OnDialogNext_Event -= OnAdvance; + } } // 대화 진행 입력(OnDialogNext = VR B버튼) 한 번을 대기 diff --git a/Assets/02_Scripts/Communication/Dialog/HiddenBranchResolver.cs b/Assets/02_Scripts/Communication/Dialog/HiddenBranchResolver.cs new file mode 100644 index 00000000..b7e1f23f --- /dev/null +++ b/Assets/02_Scripts/Communication/Dialog/HiddenBranchResolver.cs @@ -0,0 +1,58 @@ +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회 발동. + public static void Fire(string key) + { + if (!Armed || Fired) return; + if (!string.IsNullOrEmpty(_armedKey) && _armedKey != key) return; + Fired = true; + FiredEvent?.Invoke(); + } +} diff --git a/Assets/02_Scripts/Communication/Dialog/HiddenBranchResolver.cs.meta b/Assets/02_Scripts/Communication/Dialog/HiddenBranchResolver.cs.meta new file mode 100644 index 00000000..ca59658d --- /dev/null +++ b/Assets/02_Scripts/Communication/Dialog/HiddenBranchResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1622ac8790b08674cbb2164b2a8dd1f7 \ No newline at end of file