2026-07-10 다이얼로그 시스템 대대적 수정

This commit is contained in:
2026-07-10 12:17:10 +09:00
parent 5edc62d6c4
commit 51056f0994
28 changed files with 2173 additions and 64 deletions

View File

@@ -42,11 +42,7 @@ public struct NodeEvent
[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; // 화자 회전 기준 추가 회전 (+α, 오일러 각)
// HUD 배치는 화자(NPC)의 DialogHudPlacement 컴포넌트가 담당한다 (없으면 DialogHud 기본값).
[Header("Dialog Events")]
[Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")]
@@ -54,10 +50,11 @@ public struct NodeEvent
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
private Animator _animator;
private int _initialGestureHash;
private int _initialExpressionHash;
private bool _hasInitialExpression;
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()는 무시된다.
@@ -67,17 +64,7 @@ public struct NodeEvent
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;
}
}
_animator = GetComponentInChildren<Animator>(); // 화자를 못 찾을 때의 폴백 Animator
}
private void OnDestroy()
@@ -168,7 +155,7 @@ private async Awaitable<int> SelectDialog(List<int> playable)
// ChoiceHud는 DialogHud를 따라 배치되므로, 먼저 화자 옆에 HUD를 띄운다.
// (플레이어 쪽 회전은 Play() 시작 시 이미 걸려 있음)
if (DialogHud.Instance != null)
DialogHud.Instance.Show(_voice.Character, _dialogSelectPrompt, _hudChestHeight, _hudForwardOffset, _hudLateralOffset, _hudRotationOffset);
DialogHud.Instance.Show(_voice.Character, _dialogSelectPrompt);
var options = new List<DialogChoice>(playable.Count);
foreach (int i in playable)
@@ -235,12 +222,29 @@ private async Awaitable PlayEntry(DialogEntry entry)
}
}
// 대화 중 제스처/표정을 재생했던 모든 Animator(끼어든 NPC 포함)를 원래 상태로 복원
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);
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);
}
// ── 대화 중 캐릭터 회전 ────────────────────────────────────────
@@ -270,6 +274,28 @@ private void RotateTowardPlayer(Transform target)
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)을 이어받아 교체 (바라보기 ↔ 복원 충돌 방지)
@@ -328,9 +354,16 @@ private void LateUpdate()
private async Awaitable PlayNode(DialogNode node)
{
// 화자 옆 DialogHud에 대사 표시 (배치 오프셋은 이 NPC의 설정값 사용)
// 화자 옆 DialogHud에 대사 표시
// (배치는 화자의 DialogHudPlacement 담당, 없으면 DialogHud 기본값. 이름은 노드 오버라이드 우선)
// 연출 전용 노드(StagingOnly)는 대화창을 잠시 내리고 연출만 수행한다.
if (DialogHud.Instance != null)
DialogHud.Instance.Show(node.Speaker, node.TalkText, _hudChestHeight, _hudForwardOffset, _hudLateralOffset, _hudRotationOffset);
{
if (node.StagingOnly)
DialogHud.Instance.Hide();
else
DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride);
}
RaiseNodeEvent(node.EventKey); // EventKey 있으면 매칭 이벤트 호출
@@ -375,10 +408,31 @@ private async Awaitable PlayNode(DialogNode node)
}
}
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.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)