using System; using System.Collections.Generic; using UnityEngine.Events; using UnityEngine.InputSystem; using UnityEngine; [RequireComponent(typeof(CharacterVoiceObject))] public class DialogPlayer : MonoBehaviour { // 대화 후보 하나. 리스트에서 위에 있을수록 우선순위가 높다. // (스토리 대화를 위에, 조건 없는 기본 잡담을 맨 아래에 두는 것을 권장) [System.Serializable] public struct DialogEntry { public DialogGroup Group; public DialogCondition Condition; [Tooltip("켜면 완료 후에도 반복 재생 가능(잡담용). 끄면 1회성(스토리 대화)")] public bool Repeatable; [Tooltip("이 대화를 처음 완료하면 메인 진행도 +N (필수 대화가 아니면 0)")] [Min(0)] public int ProgressOnComplete; [Tooltip("대화 선택 메뉴에 표시할 이름 (비우면 그룹 이름 사용)")] public string MenuLabel; [Tooltip("켜면 이 대화에선 말을 걸어도 플레이어 쪽으로 회전하지 않는다 (기본: 회전함). " + "정면을 고정한 연출 대화 등에 사용. 단, 선택 메뉴가 뜨는 다중 대화에선 메뉴를 위해 회전할 수 있음")] public bool DontRotateToPlayer; } // 노드의 EventKey ↔ 그 노드 재생 시 호출할 이벤트. [System.Serializable] public struct NodeEvent { public string Key; public UnityEvent Event; } [Tooltip("이 NPC의 대화 후보들. 조건을 만족하는 대화가 여럿이면 플레이어가 선택한다")] [SerializeField] private List _dialogs = new(); [Tooltip("대화가 여러 개일 때 선택 메뉴 상단에 표시할 안내 문구")] [SerializeField] private string _dialogSelectPrompt = "무슨 이야기를 나눌까?"; [Tooltip("말을 건 뒤 대화창(선택 메뉴·첫 대사)이 뜨기까지의 딜레이(초)")] [Min(0)] [SerializeField] private float _dialogStartDelay = 0.2f; // HUD 배치는 화자(NPC)의 DialogHudPlacement 컴포넌트가 담당한다 (없으면 DialogHud 기본값). [Header("Dialog Events")] [Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")] [SerializeField] private List _nodeEvents = new(); private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상) private Animator _animator; private readonly Dictionary _originalRotations = new(); // 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원. // (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다) private readonly Dictionary _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 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(); _animator = GetComponentInChildren(); // 화자를 못 찾을 때의 폴백 Animator } private void OnDestroy() { // 재생 도중 파괴(씬 전환 등)돼도 전역 잠금이 남지 않도록 if (_entryInProgress == this) _entryInProgress = null; } public async Awaitable Play() { if (IsPlaying) return; // 다른 NPC가 실제 대사를 재생 중이면 무시 — 대화 중 다른 NPC 상호작용 차단 if (_entryInProgress != null) return; // 이벤트존 타임라인(컷씬) 재생 중에도 무시 — 컷씬이 NPC 루트를 직접 움직이는 동안 // 대화를 시작하면 회전 캡처/복원이 타임라인과 충돌해서 방향이 꼬인다 if (EventZoneTimeline.IsAnyPlaying) return; // 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소한다. // (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다) if (ChoiceHud.Instance != null) ChoiceHud.Instance.CancelPending(); // 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다. IsPlaying = true; PushActive(); try { var playable = FindPlayableIndices(); if (playable.Count == 0) { Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}"); return; } // 말을 걸면 NPC가 플레이어 쪽으로 돌아본다 — 대화창(선택 메뉴·첫 대사)이 NPC 회전을 // 따라가므로 창도 플레이어를 향하게 된다. 원래 회전은 아래 finally에서 복원. // 단, 대화 항목이 하나이고 그 항목이 DontRotateToPlayer면 회전하지 않는다(정면 고정 연출 등). // 여러 개(선택 메뉴)일 땐 메뉴가 플레이어를 향하도록 우선 회전한다. bool singleEntry = playable.Count == 1; bool rotateToPlayer = !singleEntry || !_dialogs[playable[0]].DontRotateToPlayer; if (rotateToPlayer) { _originalRotations.TryAdd(transform, GetOriginalRotation(transform)); RotateTowardPlayer(transform); } // 대화창이 바로 튀어나오지 않도록 잠깐 뜸을 들인다 (선택 메뉴·첫 대사 공통) if (_dialogStartDelay > 0f) { await Awaitable.WaitForSecondsAsync(_dialogStartDelay, destroyCancellationToken); if (_entryInProgress != null) return; // 기다리는 사이 다른 NPC의 대화가 시작됨 } int index = singleEntry ? playable[0] : await SelectDialog(playable); if (index < 0) return; // 선택 대기 중 취소됨 (다른 NPC와 대화 시작, 씬 전환 등) _entryInProgress = this; // 여기서부터 실제 대사 재생 — 끝날 때까지 다른 NPC 상호작용 무시 await PlayEntry(_dialogs[index]); } catch (OperationCanceledException) { // 대화 도중 선택지 대기가 취소됨 (씬 전환으로 ChoiceHud 비활성화 등) — PlayEntry의 finally에서 정리됨 } finally { // 선택 메뉴 단계에서 돌린 회전 포함 — 대화가 어떤 경로로 끝나든(취소 포함) 원래 회전 복원 RestoreRotations(); if (_entryInProgress == this) _entryInProgress = null; IsPlaying = false; PopActive(); } } // 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (EventZoneTrigger 등 UnityEvent 연결용). // _dialogs에 등록된 그룹이면 그 항목의 설정(진행도 보상 등)을 그대로 쓰고, 없으면 임시 항목으로 재생한다. // 완료 여부는 검사하지 않으므로(강제) 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가 대사 재생 중 if (EventZoneTimeline.IsAnyPlaying) return; // 컷씬 중엔 시작하지 않음 (Play()와 동일한 이유) // 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소 if (ChoiceHud.Instance != null) ChoiceHud.Instance.CancelPending(); IsPlaying = true; PushActive(); try { // 등록된 항목이 있으면 Repeatable/ProgressOnComplete 설정을 그대로 사용 DialogEntry entry = _dialogs.Find(e => e.Group == group); if (entry.Group == null) entry = new DialogEntry { Group = group }; if (!entry.DontRotateToPlayer) { _originalRotations.TryAdd(transform, GetOriginalRotation(transform)); RotateTowardPlayer(transform); } if (_dialogStartDelay > 0f) { await Awaitable.WaitForSecondsAsync(_dialogStartDelay, destroyCancellationToken); if (_entryInProgress != null) return; // 기다리는 사이 다른 NPC의 대화가 시작됨 } _entryInProgress = this; await PlayEntry(entry); } catch (OperationCanceledException) { // 씬 전환 등으로 취소됨 — PlayEntry의 finally에서 정리됨 } finally { RestoreRotations(); if (_entryInProgress == this) _entryInProgress = null; IsPlaying = false; PopActive(); } } // 조건을 만족하고 (반복 가능하거나 아직 안 한) 대화들의 인덱스. 리스트 순서 유지. private List FindPlayableIndices() { var result = new List(); for (int i = 0; i < _dialogs.Count; i++) { var entry = _dialogs[i]; if (entry.Group == null) continue; if (!entry.Repeatable && StoryManager.Instance.IsDialogCompleted(entry.Group.name)) continue; if (entry.Condition != null && !entry.Condition.IsMet(_voice.Character)) continue; result.Add(i); } return result; } // 재생 가능한 대화가 여럿일 때 ChoiceHud로 플레이어에게 고르게 한다. 반환값은 _dialogs 인덱스. private async Awaitable SelectDialog(List playable) { if (ChoiceHud.Instance == null) return playable[0]; // 선택 UI가 없으면 기존처럼 최상단 우선 // ChoiceHud는 DialogHud를 따라 배치되므로, 먼저 화자 옆에 HUD를 띄운다. // (플레이어 쪽 회전은 Play() 시작 시 이미 걸려 있음) if (DialogHud.Instance != null) DialogHud.Instance.Show(_voice.Character, _dialogSelectPrompt); var options = new List(playable.Count); foreach (int i in playable) { var entry = _dialogs[i]; string label = !string.IsNullOrWhiteSpace(entry.MenuLabel) ? entry.MenuLabel : entry.Group.DialogGroupName; options.Add(new DialogChoice { ChoiceText = label }); // Code 없음 → 선택 이력에 기록 안 됨 } try { int picked = await ChoiceHud.Instance.Show(null, options); return playable[picked]; } catch (OperationCanceledException) { // 대기 중 ChoiceHud가 비활성화됨(씬 전환 등) — 재생하지 않음 if (DialogHud.Instance != null) DialogHud.Instance.Hide(); return -1; } } private async Awaitable PlayEntry(DialogEntry entry) { try { var node = entry.Group.StartNode; while (node != null) { // 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 제스처 감시를 무장한다. // (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작) 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(entry.Group.name); if (firstTime && entry.ProgressOnComplete > 0) story.MainProgress += entry.ProgressOnComplete; story.Save(); Debug.Log($"[DialogPlayer] 대화 종료: {entry.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); } // ── 대화 중 캐릭터 회전 ──────────────────────────────────────── // Animator가 루트 트랜스폼까지 애니메이션하는 캐릭터(에셋 팩 공룡 등)는 // Update 타이밍에 회전을 써도 직후 Animator 평가가 덮어써 버린다. // 그래서 회전을 잡(Job)으로 등록해 두고, Animator 평가가 끝난 LateUpdate에서 적용한다. private class RotationJob { public Transform Target; public Quaternion Goal; // 도달할 회전 (잡 등록 시점에 확정 — 이후 플레이어가 움직여도 안 바뀜) public Quaternion Current; // 우리가 관리하는 현재 회전 — Animator가 덮어써도 여기서 이어간다 public bool Hold; // true면 교체될 때까지 유지(대화 중), false면 0.5초 후 종료(복원) public float Remaining; } private readonly List _rotationJobs = new(); // 말을 건 시점의 플레이어 위치를 향해 한 번만 돈다. 이후 플레이어가 움직여도 따라가지 않지만, // Animator가 루트 회전을 덮어쓰는 캐릭터가 있어서 그 회전값 자체는 대화가 끝날 때까지 계속 유지해 준다. private void RotateTowardPlayer(Transform target) { if (Camera.main == null) return; Vector3 dir = Camera.main.transform.position - target.position; dir.y = 0f; if (dir.sqrMagnitude < 0.0001f) return; AddRotationJob(target, Quaternion.LookRotation(dir), hold: true); } private void RotateToRotation(Transform target, Quaternion rotation) => AddRotationJob(target, rotation, hold: false); // 플레이어가 지정 위치를 바라보도록 리그를 수평(yaw)으로만 돌린다. // VR에서는 HMD 카메라를 직접 못 돌리므로 "Player" 태그 루트(XR Origin)를 돌려서 // 카메라 정면이 목표를 향하게 한다. 대화가 끝나도 원상복구하지 않는다 (플레이어 시점이므로). private void RotatePlayerToward(Vector3 worldPos) { var cam = Camera.main; if (cam == null) return; var rigObj = GameObject.FindWithTag("Player"); Transform rig = rigObj != null ? rigObj.transform : cam.transform; // 리그 없는 테스트 씬은 카메라 직접 Vector3 toTarget = worldPos - cam.transform.position; toTarget.y = 0f; Vector3 camForward = cam.transform.forward; camForward.y = 0f; if (toTarget.sqrMagnitude < 0.0001f || camForward.sqrMagnitude < 0.0001f) return; // 카메라 기준 부족한 만큼만 리그를 돌린다 (리그가 돌면 카메라도 같이 돌므로 델타 방식) float yawDelta = Vector3.SignedAngle(camForward, toTarget, Vector3.up); RotateToRotation(rig, Quaternion.AngleAxis(yawDelta, Vector3.up) * rig.rotation); } private void AddRotationJob(Transform target, Quaternion goal, bool hold) { // 같은 타깃의 기존 잡이 있으면 진행 중이던 회전(Current)을 이어받아 교체 (바라보기 ↔ 복원 충돌 방지) Quaternion current = target.rotation; int existing = _rotationJobs.FindIndex(j => j.Target == target); if (existing >= 0) { current = _rotationJobs[existing].Current; _rotationJobs.RemoveAt(existing); } _rotationJobs.Add(new RotationJob { Target = target, Goal = goal, Current = current, Hold = hold, Remaining = 0.5f }); } // 대화 종료 시 복원할 '원래 회전' — 대화 시작 시점의 회전. // 단, 직전 대화의 복원 잡이 아직 도는 중이면(연달아 재대화) 중간 회전값이 아니라 // 그 잡의 목표(진짜 원래 회전)를 이어받아 누적 오차를 막는다. private Quaternion GetOriginalRotation(Transform target) { int existing = _rotationJobs.FindIndex(j => j.Target == target && !j.Hold); return existing >= 0 ? _rotationJobs[existing].Goal : target.rotation; } private void RestoreRotations() { foreach (var kvp in _originalRotations) { if (kvp.Key != null) RotateToRotation(kvp.Key, kvp.Value); } _originalRotations.Clear(); } private void LateUpdate() { for (int i = _rotationJobs.Count - 1; i >= 0; i--) { var job = _rotationJobs[i]; if (job.Target == null) { _rotationJobs.RemoveAt(i); continue; } // Animator가 이 프레임에 뭘 썼든 무시하고, 우리가 기억하는 회전에서 이어서 목표로 수렴시킨다 job.Current = Quaternion.Slerp(job.Current, job.Goal, 10f * Time.deltaTime); job.Target.rotation = job.Current; // 복원 잡만 수명이 있다 — 바라보기(Hold) 잡은 대화 종료 시 복원 잡이 교체한다 if (!job.Hold) { job.Remaining -= Time.deltaTime; if (job.Remaining <= 0f) { job.Target.rotation = job.Goal; // 마지막엔 목표값으로 정확히 스냅 (잔여 오차 방지) _rotationJobs.RemoveAt(i); } } } } // 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행 private async Awaitable PlayNode(DialogNode node) { // 화자 옆 DialogHud에 대사 표시 // (배치는 화자의 DialogHudPlacement 담당, 없으면 DialogHud 기본값. 이름은 노드 오버라이드 우선) // 연출 전용 노드(StagingOnly)는 대화창을 잠시 내리고 연출만 수행한다. if (DialogHud.Instance != null) { if (node.StagingOnly) DialogHud.Instance.Hide(); else DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride, node.HudAnchor); // HudAnchor 지정 시 창은 그 캐릭터 옆에 (비우면 화자 옆) } RaiseNodeEvent(node.EventKey); // EventKey 있으면 매칭 이벤트 호출 // 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 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(); } // 전용 VFX: 화자 위치에서 1회 재생 if (node.Vfx != null) { var speakerObj = node.Speaker != null ? CharacterVoiceObject.Find(node.Speaker) : null; var anchor = speakerObj != null ? speakerObj.transform : transform; var vfx = Instantiate(node.Vfx, anchor.position, anchor.rotation); // 파티클이면 재생 길이만큼, 아니면 5초 뒤 자동 제거 var ps = vfx.GetComponentInChildren(); float life = ps != null ? ps.main.duration + ps.main.startLifetime.constantMax : 5f; Destroy(vfx, life); } // 보이스 재생 if (node.Voice != null && node.Speaker != null) { var voiceObj = CharacterVoiceObject.Find(node.Speaker); if (voiceObj != null && node.Voice.Clip != null) voiceObj.Play(node.Voice.Clip); } // 플레이어 향해 회전 if (node.LookAtPlayer && node.Speaker != null) { var voiceObj = CharacterVoiceObject.Find(node.Speaker); if (voiceObj != null) { _originalRotations.TryAdd(voiceObj.transform, GetOriginalRotation(voiceObj.transform)); RotateTowardPlayer(voiceObj.transform); } } // 플레이어가 화자를 바라보도록 강제 회전 if (node.ForcePlayerLook && node.Speaker != null) { var voiceObj = CharacterVoiceObject.Find(node.Speaker); if (voiceObj != null) RotatePlayerToward(voiceObj.transform.position); } // 제스처/표정은 화자(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; 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); } } // 진행 방식 결정 // 히든 분기가 무장된 노드는 대기 중 제스처를 함께 감시(레이스)한다. // 무장 안 된 노드는 아래 기존 대기 그대로 → 동작 100% 동일, 반환 false. if (HiddenBranchResolver.Armed) return await WaitAdvanceOrDivert(node); if (node.WaitForInput) { await WaitForAdvanceInput(); // B버튼 입력이 있어야만 다음으로 } else { float wait = (node.Voice != null && node.Voice.Clip != null) ? node.Voice.Clip.length : node.LineDuration; if (wait > 0f) await Awaitable.WaitForSecondsAsync(wait); else await WaitForAdvanceInput(); // 지정 시간이 없으면 입력으로 진행 } return false; } // 노드의 EventKey와 같은 Key를 가진 이벤트들을 호출 private void RaiseNodeEvent(string key) { if (string.IsNullOrEmpty(key)) return; foreach (var e in _nodeEvents) if (e.Key == key) e.Event?.Invoke(); } // 선택 기록: 선택지 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 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 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버튼) 한 번을 대기 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 불가) } } }