2026-07-10 다이얼로그 시스템 대대적 수정
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user