394 lines
14 KiB
C#
394 lines
14 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 = "무슨 이야기를 나눌까?";
|
||
|
||
[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 불가)
|
||
}
|
||
}
|
||
}
|