Files
Dino_Love_Simulation/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs

532 lines
23 KiB
C#

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;
}
// 노드의 EventKey ↔ 그 노드 재생 시 호출할 이벤트.
[System.Serializable]
public struct NodeEvent
{
public string Key;
public UnityEvent Event;
}
[Tooltip("이 NPC의 대화 후보들. 조건을 만족하는 대화가 여럿이면 플레이어가 선택한다")]
[SerializeField] private List<DialogEntry> _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<NodeEvent> _nodeEvents = new();
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
private Animator _animator;
private readonly Dictionary<Transform, Quaternion> _originalRotations = new();
// 대화 중 제스처/표정을 재생한 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;
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 루트를 직접 움직이는 동안
// 대화를 시작하면 회전 캡처/복원이 타임라인과 충돌해서 방향이 꼬인다
if (EventZone.IsAnyPlaying) return;
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소한다.
// (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다)
if (ChoiceHud.Instance != null)
ChoiceHud.Instance.CancelPending();
// 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다.
IsPlaying = true;
try
{
var playable = FindPlayableIndices();
if (playable.Count == 0)
{
Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}");
return;
}
// 말을 걸면 NPC가 플레이어 쪽으로 돌아본다 — 대화창(선택 메뉴·첫 대사)이 NPC 회전을
// 따라가므로 창도 플레이어를 향하게 된다. 원래 회전은 아래 finally에서 복원.
_originalRotations.TryAdd(transform, GetOriginalRotation(transform));
RotateTowardPlayer(transform);
// 대화창이 바로 튀어나오지 않도록 잠깐 뜸을 들인다 (선택 메뉴·첫 대사 공통)
if (_dialogStartDelay > 0f)
{
await Awaitable.WaitForSecondsAsync(_dialogStartDelay, destroyCancellationToken);
if (_entryInProgress != null) return; // 기다리는 사이 다른 NPC의 대화가 시작됨
}
int index = playable.Count == 1 ? 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;
}
}
// 조건을 만족하고 (반복 가능하거나 아직 안 한) 대화들의 인덱스. 리스트 순서 유지.
private List<int> FindPlayableIndices()
{
var result = new List<int>();
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<int> SelectDialog(List<int> 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<DialogChoice>(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)
{
await PlayNode(node);
if (node.Choices != null && node.Choices.Count > 0)
{
int picked = await WaitForChoice(node);
RecordChoice(node, picked);
node = node.Choices[picked].DestinationNode;
}
else
{
node = node.Next;
}
}
// 여기까지 왔으면 자연 종료(끝까지 재생) — 이때만 완료로 기록한다.
// (중간에 오브젝트 파괴 등으로 끊기면 예외로 빠져나가 기록되지 않음)
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<RotationJob> _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);
}
}
}
}
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 있으면 매칭 이벤트 호출
// 전용 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<ParticleSystem>();
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>() : _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 (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(); // 지정 시간이 없으면 입력으로 진행
}
}
// 노드의 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);
}
private async Awaitable<int> WaitForChoice(DialogNode node)
{
//선택을 기다리는 함수 수정해서 사용할것
if (ChoiceHud.Instance == null)
{
Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택");
return 0;
}
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
}
// 대화 진행 입력(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 불가)
}
}
}