Initial commit: Unity 프로젝트 셋업 + 다이얼로그 시스템 이전
This commit is contained in:
321
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal file
321
Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
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 RegionGroup
|
||||
{
|
||||
public string Region; // 영역 이름 (NPC마다 자유롭게 지정 — 그룹 이름과 무관)
|
||||
public DialogGroup Group;
|
||||
}
|
||||
|
||||
// 마지막 선택지 코드(LastChoiceCode)를 인자로 넘기는 UnityEvent (인스펙터 노출용 구체 타입)
|
||||
[System.Serializable]
|
||||
public class ChoiceCodeEvent : UnityEvent<string> { }
|
||||
|
||||
// 노드의 EventKey ↔ 그 노드 재생 시 호출할 이벤트. 인자로 LastChoiceCode가 전달됨.
|
||||
[System.Serializable]
|
||||
public struct NodeEvent
|
||||
{
|
||||
public string Key;
|
||||
public ChoiceCodeEvent Event;
|
||||
}
|
||||
|
||||
[Tooltip("영역 이름 ↔ 그 영역에서 재생할 DialogGroup")]
|
||||
[SerializeField] private List<RegionGroup> _regionGroups;
|
||||
|
||||
[Header("Region")]
|
||||
[SerializeField] private string _currentRegion; // 현재 영역 이름. DialogRegion 트리거가 갱신
|
||||
|
||||
[Header("Dialog HUD Placement")] // 씬에서 캐릭터 위치/주변(벽 등)에 맞춰 조절
|
||||
[SerializeField] private float _hudChestHeight = 1.2f; // 화자 발 기준 가슴 높이
|
||||
[SerializeField] private float _hudForwardOffset = 0.5f; // 화자→플레이어 방향으로 띄울 거리
|
||||
[SerializeField] private float _hudLateralOffset = 0f; // 좌우 오프셋 (+ 플레이어 시점 오른쪽)
|
||||
|
||||
[Header("Dialog Events")]
|
||||
[Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")]
|
||||
[SerializeField] private List<NodeEvent> _nodeEvents = new();
|
||||
|
||||
private Dictionary<string, DialogGroup> _regionMap;
|
||||
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; }
|
||||
|
||||
// 마지막으로 고른 선택지 (인덱스/코드). DialogVariables에도 lastChoiceIndex / lastChoiceCode 로 저장됨
|
||||
public int LastChoiceIndex { get; private set; } = -1;
|
||||
public string LastChoiceCode { get; private set; }
|
||||
public event Action<int, string> OnChoiceSelected; // (index, code)
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_regionMap = new Dictionary<string, DialogGroup>();
|
||||
foreach (var e in _regionGroups)
|
||||
if (e.Group != null) _regionMap[e.Region] = e.Group;
|
||||
|
||||
_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()
|
||||
{
|
||||
var region = ResolveRegion();
|
||||
if (region != null)
|
||||
await Play(region);
|
||||
}
|
||||
|
||||
// 현재 영역. 영역이 없거나 매칭 그룹이 없으면 리스트 첫 항목으로 폴백.
|
||||
private string ResolveRegion()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_currentRegion) && _regionMap.ContainsKey(_currentRegion))
|
||||
return _currentRegion;
|
||||
return _regionGroups.Count > 0 ? _regionGroups[0].Region : null;
|
||||
}
|
||||
|
||||
// 영역 전환 (DialogRegion 트리거가 호출). 다음 Play()부터 해당 영역 대화가 재생됨.
|
||||
public void SetRegion(string region) => _currentRegion = region;
|
||||
|
||||
public string CurrentRegion => _currentRegion;
|
||||
|
||||
public async Awaitable Play(string region)
|
||||
{
|
||||
if (IsPlaying) return;
|
||||
if (!_regionMap.TryGetValue(region, out var group))
|
||||
{
|
||||
Debug.LogWarning($"[DialogPlayer] 영역 대화 없음: {region}");
|
||||
return;
|
||||
}
|
||||
|
||||
IsPlaying = true;
|
||||
try
|
||||
{
|
||||
var node = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsPlaying = false;
|
||||
if (DialogHud.Instance != null)
|
||||
DialogHud.Instance.Hide();
|
||||
RestoreDefaultAnimations();
|
||||
RestoreRotations();
|
||||
}
|
||||
|
||||
Debug.Log("[DialogPlayer] 대화 종료");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
RaiseNodeEvent(node.EventKey); // EventKey 있으면 매칭 이벤트 호출
|
||||
|
||||
// 보이스 재생
|
||||
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(LastChoiceCode); // 마지막 선택지 코드를 인자로 전달
|
||||
}
|
||||
|
||||
// 선택 결과 기록: 인덱스/코드를 프로퍼티 + DialogVariables에 저장하고 이벤트 발행
|
||||
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} 치환 → 동적으로 생성된 코드 반영
|
||||
|
||||
LastChoiceIndex = index;
|
||||
LastChoiceCode = code;
|
||||
|
||||
DialogVariables.Set("lastChoiceIndex", index.ToString());
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
DialogVariables.Set("lastChoiceCode", code);
|
||||
|
||||
OnChoiceSelected?.Invoke(index, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user