using System; using System.Collections.Generic; using UnityEngine.InputSystem; using UnityEngine; public class DialogPlayer : MonoBehaviour { // 대화 후보는 인스펙터가 아니라 StoryDatabase에서 온다 — // 현재 장소(LocationManager.Current) + 이 캐릭터의 비트 중 조건을 만족하는 것들. // 여럿이면 선택 메뉴가 뜨고, DB 목록에서 위에 있을수록 우선순위가 높다. // // CharacterVoiceObject는 선택이다: // 있으면 → NPC 플레이어. 그 캐릭터의 비트(말을 걸어야 시작되는 대화)를 재생한다. // 없으면 → 씬 플레이어. Character가 빈 "장소 비트"를 재생한다 (서술·진입 연출 등). // LocationManager가 장소 입장 직후 자동으로 호출한다. private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (없으면 씬 플레이어) private Animator _animator; // 이 플레이어가 담당하는 캐릭터. 씬 플레이어는 null이고, 그게 곧 "장소 비트" 조회 키가 된다. private CharacterData OwnerCharacter => _voice != null ? _voice.Character : null; // 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원. // (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다) private readonly Dictionary _touchedAnimators = new(); // 대화 중 포즈를 바꾼 캐릭터들의 원래 포즈 — 대화 종료 시 전부 복원. // (끼어든 다른 NPC의 포즈도 포함되므로 Animator와 같은 방식으로 딕셔너리에 추적한다) // 키가 인터페이스라 스프라이트 인물과 Live2D 인물이 한 딕셔너리에 섞여 들어간다. private readonly Dictionary _touchedPoses = new(); public bool IsPlaying { get; private set; } // 지금 실제 대사를 재생 중인 DialogPlayer (전역 1개) — 이때 다른 NPC의 Play()는 무시된다. // 선택 메뉴만 떠 있는 단계는 여기 안 잡힌다 (그 경우는 새 NPC가 메뉴를 취소하고 시작). private static DialogPlayer _entryInProgress; // ── 전역 대화 진행 신호 (대화 중 월드 상호작용 차단용) ──────── // 선택 메뉴 단계부터 대사 종료까지, 어느 NPC든 대화가 진행 중이면 true. // 이동 버튼·클릭 인터랙션 등이 AnyActiveChanged를 구독해 대화 중 입력을 잠그면 된다. 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; } // 장소 비트를 자동으로 시작한다 (LocationManager가 장소 입장 직후 호출). // 선택 메뉴를 띄우지 않는다 — 메뉴는 플레이어가 "말을 건" 경우의 UI다. public void PlayAuto() => _ = Play(autoSelect: true); // 「이야기한다」 버튼용 — 선택 메뉴를 띄우고 대화를 시작한다. // PlayAuto와 달리 후보가 여럿이면 DialogEnterHud로 고르게 한다. (UnityEvent 연결용 void) public void Talk() => _ = Play(); // autoSelect가 true면 후보가 여럿이어도 메뉴 없이 최상단(우선순위 1위) 비트를 바로 재생한다. public async Awaitable Play(bool autoSelect = false) { if (IsPlaying) return; // 다른 NPC가 실제 대사를 재생 중이면 무시 — 대화 중 다른 NPC 상호작용 차단 if (_entryInProgress != null) return; // 다른 NPC의 대화 선택 메뉴가 떠 있으면 먼저 취소한다. // (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다) if (DialogEnterHud.Instance != null) DialogEnterHud.Instance.CancelPending(); // 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다. IsPlaying = true; PushActive(); try { var playable = FindPlayableBeats(); if (playable.Count == 0) { Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}"); return; } StoryBeat beat = playable.Count == 1 || autoSelect ? 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(); } } // 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (버튼·컷신 등 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 (DialogEnterHud.Instance != null) DialogEnterHud.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 FindPlayableBeats() { var lm = LocationManager.Instance; if (lm == null || lm.Database == null) { Debug.LogWarning($"[DialogPlayer] LocationManager 또는 StoryDatabase가 없음: {name}"); return new List(); } return lm.Database.GetPlayableBeats(lm.Current, OwnerCharacter); } // 재생 가능한 대화가 여럿일 때 DialogEnterHud로 플레이어에게 고르게 한다. 취소되면 null. private async Awaitable SelectBeat(List playable) { if (DialogEnterHud.Instance == null) return playable[0]; // 선택 UI가 없으면 기존처럼 최상단 우선 var options = new List(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 DialogEnterHud.Instance.Show(options); return playable[picked]; } catch (OperationCanceledException) { // 대기 중 DialogEnterHud가 비활성화됨(씬 전환 등) — 재생하지 않음 return null; } } private async Awaitable PlayBeat(StoryBeat beat) { // 도달한 종료 노드가 정한다. 중간에 끊기면(씬 전환 등) false로 남아 장소 BGM으로 복귀한다. bool keepBgmOnEnd = false; try { var node = beat.Group.StartNode; int routingHops = 0; // 연속 라우팅 횟수 — 라우팅 노드끼리 순환하면 대기 없는 무한 루프가 되므로 차단 while (node != null) { // 종료 노드 — 대화를 끝낸다. 분기마다 다른 종료 노드를 둘 수 있어서 // "이 루트는 BGM 유지, 저 루트는 복귀"가 각각 정해진다. if (node.Kind == DialogNodeKind.End) { keepBgmOnEnd = node.KeepBgmOnEnd; break; } // 호감도 라우팅 노드 — 대사 없이 즉시 분기 (플레이어에겐 분기 자체가 보이지 않는다) if (node.Kind == DialogNodeKind.AffectionCheck) { if (++routingHops > 100) { Debug.LogError($"[DialogPlayer] 라우팅 노드가 순환합니다 — 대화 중단: {beat.Group.name}"); break; } node = IsAffectionMet(node) ? node.AffectionPassBranch : node.Next; continue; } routingHops = 0; // 실제 대사 노드에 도달 — 카운터 리셋 // 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 발동 감시를 무장한다. // (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작) bool armed = node.HiddenBranches != null && node.HiddenBranches.Count > 0; if (armed) HiddenBranchResolver.Arm(node.HiddenBranches); try { bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 발동 감시 포함) if (diverted) { node = TakeHiddenBranch(node); // 대사 도중 발동 → 몰래 분기 } else if (node.Choices != null && node.Choices.Count > 0) { int picked = await WaitForChoice(node); // 메뉴(무장 시 발동 감시 포함) if (picked == DivertIndex) { node = TakeHiddenBranch(node); // 메뉴 도중 발동 → 메뉴엔 없던 히든 분기 } 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(); // 대화 BGM 처리 — 도달한 종료 노드가 Keep BGM이면 마지막 곡을 그대로 유지하고, // 아니면 장소 BGM으로 복귀한다. if (SoundManager.Instance != null && !keepBgmOnEnd) SoundManager.Instance.ClearOverrideBGM(); // 장면 그림과 앞/옆 그림은 대화 전용이라 대화가 끝나면 무조건 걷는다 if (SceneScreen.Instance != null) SceneScreen.Instance.Hide(); if (FrontSprite.Instance != null) FrontSprite.Instance.Hide(); if (SideSprite.Instance != null) SideSprite.Instance.Hide(); RestoreDefaultPoses(); 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 PlayNode(DialogNode node) { // DialogHud에 대사 표시 (이름은 노드 오버라이드 우선, 타이핑 연출도 노드 지정이 우선) if (DialogHud.Instance != null) DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride, node.Typewriter); // 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC if (node.Affection != 0) { var affectionTarget = node.Speaker != null ? node.Speaker : OwnerCharacter; StoryManager.Instance.AddAffection(affectionTarget, node.Affection); } // 메인 진행도 증가 — 분기 끝 노드마다 다른 값을 주면 선택지에 따라 진행도가 달라진다 if (node.Progress != 0) StoryManager.Instance.MainProgress += node.Progress; // BGM: 지정된 노드에서만 갈아타고, 다시 바꾸기 전까지 계속 이어진다. // 비어 있으면 "변경 없음" — 모든 노드에 같은 곡을 넣어줄 필요가 없다. if (node.Bgm != null && SoundManager.Instance != null) SoundManager.Instance.PlayOverrideBGM(node.Bgm); // 1회성 효과음 — 이 대사가 뜨는 순간 한 번 재생된다 if (node.Sfx != null && SoundManager.Instance != null) SoundManager.Instance.PlaySFX(node.Sfx); // 포즈 + 화면 그림 ApplyStageVisuals(node); // 보이스 재생 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; 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; } // 노드가 지정한 무대 연출을 반영한다. 넷의 수명 규칙이 각각 다르다: // Pose — 지속. '변경 없음'이 기본이라 인물이 서 있는 동안 다시 꽂을 필요가 없다 // SceneScreen — 지정한 대사에서만. 비우면 사라진다 (배경 앞·캐릭터 뒤 장면 그림) // FrontSprite — 1회성. 지정 안 한 노드에선 사라진다 ("이의있음!" 같은 번쩍 연출) // SideSprite — 지정한 대사에서만. 비우면 사라진다 // Pose 말고 셋은 Show(null)이 곧 Hide라, 노드 값을 그대로 넘기면 두 경우가 한 줄로 처리된다. private void ApplyStageVisuals(DialogNode node) { if (SceneScreen.Instance != null) SceneScreen.Instance.Show(node.SceneScreen); if (FrontSprite.Instance != null) FrontSprite.Instance.Show(node.FrontSprite); if (SideSprite.Instance != null) SideSprite.Instance.Show(node.SideSprite); if (node.Pose == Pose.Keep) return; // 변경 없음 — 지금 포즈를 그대로 둔다 // 대상을 안 정했으면 아무 일도 하지 않는다. // 화자나 장소 인물로 자동 유추할 수도 있지만, 그러면 "누가 바뀌는지"가 노드만 봐서는 // 보이지 않는다. 말하는 사람과 화면에 비치는 사람이 다를 수 있는 이상 명시를 요구하는 편이 낫다. if (node.PoseTarget == null) { Debug.LogWarning($"[DialogPlayer] Pose({node.Pose})를 지정했으나 Pose Target이 비어 있음 — 무시"); return; } var poses = FindPoses(node.PoseTarget); if (poses == null) { Debug.LogWarning($"[DialogPlayer] {node.PoseTarget.name}의 CharacterPoses를 찾지 못함 — 포즈 무시"); return; } // 대화 종료 시 복원할 원래 포즈를 처음 건드릴 때만 기억한다 if (!_touchedPoses.ContainsKey(poses)) _touchedPoses[poses] = poses.Current; poses.Apply(node.Pose); } // 포즈를 적용할 대상의 포즈 구현. 그 캐릭터가 지금 씬에 없으면 null. // 제스처/표정과 달리 화자를 전혀 보지 않는다 — 대상은 노드가 명시한 것 하나뿐이다. // 스프라이트(CharacterPoses)든 Live2D(CubismCharacterPoses)든 인터페이스로 받으므로 // 대화 그래프는 그 인물이 어느 쪽인지 알 필요가 없다. private static ICharacterPoses FindPoses(CharacterData target) { var obj = CharacterVoiceObject.Find(target); return obj != null ? obj.GetComponentInChildren(includeInactive: true) : null; } // 대화 중 포즈를 바꾼 모든 캐릭터(끼어든 NPC 포함)를 원래 포즈로 복원 private void RestoreDefaultPoses() { foreach (var kvp in _touchedPoses) { // 인터페이스 참조로는 Unity의 "파괴됨" 검사가 안 걸린다(그냥 C# 널 비교가 된다). // 씬 전환으로 오브젝트가 사라졌을 수 있으므로 Object로 보고 확인한다. if (kvp.Key as UnityEngine.Object != null) kvp.Key.Apply(kvp.Value); } _touchedPoses.Clear(); } // 호감도 분기 노드의 조건식 평가. // 각 조건의 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 : OwnerCharacter; 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); } // 발동한 히든 분기로 이동한다. 어느 분기가 발동했는지는 Resolver가 키를 보고 이미 골라 뒀다. // (Disarm보다 먼저 불려야 하므로 반드시 무장 구간 안에서 호출할 것) private DialogNode TakeHiddenBranch(DialogNode node) { int index = HiddenBranchResolver.FiredIndex; if (node.HiddenBranches == null || index < 0 || index >= node.HiddenBranches.Count) { // 정상 흐름에선 올 수 없다(발동 = 매칭 성공). 와도 대화가 끊기지 않게 원래 흐름으로. Debug.LogWarning($"[DialogPlayer] 히든 분기 인덱스가 범위를 벗어남: {index}"); return node.Next; } var branch = node.HiddenBranches[index]; // 선택지 Code와 동일하게 StoryState에 영구 기록 string code = DialogVariables.Format(branch.Code); // {token} 치환 if (!string.IsNullOrEmpty(code)) StoryManager.Instance.RecordChoice(code); return branch.Destination; } // 히든 분기 신호값 — 선택지 인덱스(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() { 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) { advance = false; // 타이핑 중이었으면 이 입력은 "전체 표시"에 쓰고 계속 기다린다 if (!TrySkipReveal()) 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; } } // 대사가 아직 드러나는 중이면 전체 표시로 건너뛰고 true. // 이미 다 나와 있으면 false — 그 입력은 다음 노드로 넘어가는 데 쓰인다. private static bool TrySkipReveal() { var hud = DialogHud.Instance; if (hud == null || !hud.IsRevealing) return false; hud.SkipReveal(); return true; } // 대화 진행 입력(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 (true) { if (pressed) { pressed = false; // 타이핑 중이었으면 이 입력은 "전체 표시"에 쓰고 계속 기다린다 if (!TrySkipReveal()) break; // 이미 다 나옴 → 다음 노드로 } await Awaitable.NextFrameAsync(destroyCancellationToken); } } catch (OperationCanceledException) { // 대기 중 오브젝트 파괴 시 조용히 종료 } finally { im.OnDialogNext_Event -= Handler; } } //테스트용 — 캐릭터를 클릭하면 대화 시작 (Collider2D가 있어야 잡힌다) private void Update() { if (_voice == null) return; //씬 플레이어(장소 비트 담당)는 클릭 대상이 아니다 if (Mouse.current == null) return; if (!Mouse.current.leftButton.wasPressedThisFrame) return; if (Camera.main == null) return; var world = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue()); var hit = Physics2D.OverlapPoint(world); if (hit != null && hit.transform.IsChildOf(transform)) { Debug.Log("캐릭터 클릭"); _ = Play(); // 테스트용 fire-and-forget (Update는 await 불가) } } }