Files
Dino_Love_Simulation/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
2026-07-09 12:28:11 +09:00

394 lines
14 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 = "무슨 이야기를 나눌까?";
[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; }
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;
}
}
}
public async Awaitable Play()
{
if (IsPlaying) return;
// 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다.
IsPlaying = true;
try
{
var playable = FindPlayableIndices();
if (playable.Count == 0)
{
Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}");
return;
}
int index = playable.Count == 1 ? playable[0] : await SelectDialog(playable);
if (index < 0) return; // 선택 대기 중 취소됨 (씬 전환 등)
await PlayEntry(_dialogs[index]);
}
finally
{
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를 띄운다.
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();
RestoreRotations();
}
}
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);
}
private async Awaitable RotateTowardPlayer(Transform target)
{
if (Camera.main == null) return;
var playerCam = Camera.main.transform;
float duration = 0.5f;
float elapsed = 0f;
while (elapsed < duration)
{
Vector3 dir = playerCam.position - target.position;
dir.y = 0f;
if (dir.sqrMagnitude > 0.0001f)
{
var targetRot = Quaternion.LookRotation(dir);
target.rotation = Quaternion.Slerp(target.rotation, targetRot, 10f * Time.deltaTime);
}
elapsed += Time.deltaTime;
await Awaitable.NextFrameAsync();
}
}
private void RestoreRotations()
{
foreach (var kvp in _originalRotations)
{
if (kvp.Key != null)
_ = RotateToRotation(kvp.Key, kvp.Value);
}
_originalRotations.Clear();
}
private async Awaitable RotateToRotation(Transform target, Quaternion targetRotation)
{
float duration = 0.5f;
float elapsed = 0f;
while (elapsed < duration)
{
if (target == null) return;
target.rotation = Quaternion.Slerp(target.rotation, targetRotation, 10f * Time.deltaTime);
elapsed += Time.deltaTime;
await Awaitable.NextFrameAsync();
}
}
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, voiceObj.transform.rotation);
_ = 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 불가)
}
}
}