Files
Dino_Love_Simulation/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
2026-07-09 15:27:34 +09:00

477 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
[Header("Dialog HUD Placement")] // 씬에서 캐릭터 위치/주변(벽 등)에 맞춰 조절
[SerializeField] private float _hudChestHeight = 1.2f; // 화자 발 기준 가슴 높이
[SerializeField] private float _hudForwardOffset = 0.5f; // 화자가 바라보는 방향으로 띄울 거리
[SerializeField] private float _hudLateralOffset = 0f; // 좌우 오프셋 (+ 화자와 마주보는 시점 오른쪽)
[SerializeField] private Vector3 _hudRotationOffset = Vector3.zero; // 화자 회전 기준 추가 회전 (+α, 오일러 각)
[Header("Dialog Events")]
[Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")]
[SerializeField] private List<NodeEvent> _nodeEvents = new();
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
private Animator _animator;
private int _initialGestureHash;
private int _initialExpressionHash;
private bool _hasInitialExpression;
private readonly Dictionary<Transform, Quaternion> _originalRotations = 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>();
if (_animator != null)
{
_initialGestureHash = _animator.GetCurrentAnimatorStateInfo(0).fullPathHash;
if (_animator.layerCount > 1)
{
_initialExpressionHash = _animator.GetCurrentAnimatorStateInfo(1).fullPathHash;
_hasInitialExpression = true;
}
}
}
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, _hudChestHeight, _hudForwardOffset, _hudLateralOffset, _hudRotationOffset);
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();
}
}
private void RestoreDefaultAnimations()
{
if (_animator == null) return;
_animator.CrossFade(_initialGestureHash, 0.3f, 0, normalizedTimeOffset: 0f);
if (_hasInitialExpression)
_animator.CrossFade(_initialExpressionHash, 0.3f, 1, normalizedTimeOffset: 0f);
}
// ── 대화 중 캐릭터 회전 ────────────────────────────────────────
// 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);
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에 대사 표시 (배치 오프셋은 이 NPC의 설정값 사용)
if (DialogHud.Instance != null)
DialogHud.Instance.Show(node.Speaker, node.TalkText, _hudChestHeight, _hudForwardOffset, _hudLateralOffset, _hudRotationOffset);
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.Gesture != null)
_animator.CrossFade(node.Gesture.StateName, node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
if (node.Expression != null)
_animator.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 불가)
}
}
}