2026-07-09 예시

This commit is contained in:
2026-07-09 15:27:34 +09:00
parent 1036e2fbc7
commit dcd15573bb
15 changed files with 1468 additions and 52 deletions

View File

@@ -39,6 +39,9 @@ public struct NodeEvent
[Tooltip("대화가 여러 개일 때 선택 메뉴 상단에 표시할 안내 문구")]
[SerializeField] private string _dialogSelectPrompt = "무슨 이야기를 나눌까?";
[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; // 화자가 바라보는 방향으로 띄울 거리
@@ -57,6 +60,10 @@ public struct NodeEvent
private readonly Dictionary<Transform, Quaternion> _originalRotations = new();
public bool IsPlaying { get; private set; }
// 지금 실제 대사를 재생 중인 DialogPlayer (전역 1개) — 이때 다른 NPC의 Play()는 무시된다.
// 선택 메뉴만 떠 있는 단계는 여기 안 잡힌다 (그 경우는 새 NPC가 메뉴를 취소하고 시작).
private static DialogPlayer _entryInProgress;
private void Awake()
{
_voice = GetComponent<CharacterVoiceObject>();
@@ -73,10 +80,28 @@ private void Awake()
}
}
private void OnDestroy()
{
// 재생 도중 파괴(씬 전환 등)돼도 전역 잠금이 남지 않도록
if (_entryInProgress == this) _entryInProgress = null;
}
public async Awaitable Play()
{
if (IsPlaying) return;
// 다른 NPC가 실제 대사를 재생 중이면 무시 — 대화 중 다른 NPC 상호작용 차단
if (_entryInProgress != null) return;
// 이벤트존 타임라인(컷씬) 재생 중에도 무시 — 컷씬이 NPC 루트를 직접 움직이는 동안
// 대화를 시작하면 회전 캡처/복원이 타임라인과 충돌해서 방향이 꼬인다
if (EventZone.IsAnyPlaying) return;
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소한다.
// (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다)
if (ChoiceHud.Instance != null)
ChoiceHud.Instance.CancelPending();
// 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다.
IsPlaying = true;
try
@@ -88,13 +113,33 @@ public async Awaitable Play()
return;
}
int index = playable.Count == 1 ? playable[0] : await SelectDialog(playable);
if (index < 0) return; // 선택 대기 중 취소됨 (씬 전환 등)
// 말을 걸면 NPC가 플레이어 쪽으로 돌아본다 — 대화창(선택 메뉴·첫 대사)이 NPC 회전을
// 따라가므로 창도 플레이어를 향하게 된다. 원래 회전은 아래 finally에서 복원.
_originalRotations.TryAdd(transform, GetOriginalRotation(transform));
RotateTowardPlayer(transform);
// 대화창이 바로 튀어나오지 않도록 잠깐 뜸을 들인다 (선택 메뉴·첫 대사 공통)
if (_dialogStartDelay > 0f)
{
await Awaitable.WaitForSecondsAsync(_dialogStartDelay, destroyCancellationToken);
if (_entryInProgress != null) return; // 기다리는 사이 다른 NPC의 대화가 시작됨
}
int index = playable.Count == 1 ? playable[0] : await SelectDialog(playable);
if (index < 0) return; // 선택 대기 중 취소됨 (다른 NPC와 대화 시작, 씬 전환 등)
_entryInProgress = this; // 여기서부터 실제 대사 재생 — 끝날 때까지 다른 NPC 상호작용 무시
await PlayEntry(_dialogs[index]);
}
catch (OperationCanceledException)
{
// 대화 도중 선택지 대기가 취소됨 (씬 전환으로 ChoiceHud 비활성화 등) — PlayEntry의 finally에서 정리됨
}
finally
{
// 선택 메뉴 단계에서 돌린 회전 포함 — 대화가 어떤 경로로 끝나든(취소 포함) 원래 회전 복원
RestoreRotations();
if (_entryInProgress == this) _entryInProgress = null;
IsPlaying = false;
}
}
@@ -121,6 +166,7 @@ private async Awaitable<int> SelectDialog(List<int> playable)
return playable[0]; // 선택 UI가 없으면 기존처럼 최상단 우선
// ChoiceHud는 DialogHud를 따라 배치되므로, 먼저 화자 옆에 HUD를 띄운다.
// (플레이어 쪽 회전은 Play() 시작 시 이미 걸려 있음)
if (DialogHud.Instance != null)
DialogHud.Instance.Show(_voice.Character, _dialogSelectPrompt, _hudChestHeight, _hudForwardOffset, _hudLateralOffset, _hudRotationOffset);
@@ -186,7 +232,6 @@ private async Awaitable PlayEntry(DialogEntry entry)
if (SoundManager.Instance != null)
SoundManager.Instance.ClearOverrideBGM(); // 대화가 끝나면 기본 BGM으로 복귀
RestoreDefaultAnimations();
RestoreRotations();
}
}
@@ -198,26 +243,53 @@ private void RestoreDefaultAnimations()
_animator.CrossFade(_initialExpressionHash, 0.3f, 1, normalizedTimeOffset: 0f);
}
private async Awaitable RotateTowardPlayer(Transform target)
// ── 대화 중 캐릭터 회전 ────────────────────────────────────────
// Animator가 루트 트랜스폼까지 애니메이션하는 캐릭터(에셋 팩 공룡 등)는
// Update 타이밍에 회전을 써도 직후 Animator 평가가 덮어써 버린다.
// 그래서 회전을 잡(Job)으로 등록해 두고, Animator 평가가 끝난 LateUpdate에서 적용한다.
private class RotationJob
{
public Transform Target;
public Quaternion Goal; // 도달할 회전 (잡 등록 시점에 확정 — 이후 플레이어가 움직여도 안 바뀜)
public Quaternion Current; // 우리가 관리하는 현재 회전 — Animator가 덮어써도 여기서 이어간다
public bool Hold; // true면 교체될 때까지 유지(대화 중), false면 0.5초 후 종료(복원)
public float Remaining;
}
private readonly List<RotationJob> _rotationJobs = new();
// 말을 건 시점의 플레이어 위치를 향해 한 번만 돈다. 이후 플레이어가 움직여도 따라가지 않지만,
// Animator가 루트 회전을 덮어쓰는 캐릭터가 있어서 그 회전값 자체는 대화가 끝날 때까지 계속 유지해 준다.
private void RotateTowardPlayer(Transform target)
{
if (Camera.main == null) return;
var playerCam = Camera.main.transform;
Vector3 dir = Camera.main.transform.position - target.position;
dir.y = 0f;
if (dir.sqrMagnitude < 0.0001f) return;
AddRotationJob(target, Quaternion.LookRotation(dir), hold: true);
}
float duration = 0.5f;
float elapsed = 0f;
private void RotateToRotation(Transform target, Quaternion rotation) => AddRotationJob(target, rotation, hold: false);
while (elapsed < duration)
private void AddRotationJob(Transform target, Quaternion goal, bool hold)
{
// 같은 타깃의 기존 잡이 있으면 진행 중이던 회전(Current)을 이어받아 교체 (바라보기 ↔ 복원 충돌 방지)
Quaternion current = target.rotation;
int existing = _rotationJobs.FindIndex(j => j.Target == target);
if (existing >= 0)
{
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();
current = _rotationJobs[existing].Current;
_rotationJobs.RemoveAt(existing);
}
_rotationJobs.Add(new RotationJob { Target = target, Goal = goal, Current = current, Hold = hold, Remaining = 0.5f });
}
// 대화 종료 시 복원할 '원래 회전' — 대화 시작 시점의 회전.
// 단, 직전 대화의 복원 잡이 아직 도는 중이면(연달아 재대화) 중간 회전값이 아니라
// 그 잡의 목표(진짜 원래 회전)를 이어받아 누적 오차를 막는다.
private Quaternion GetOriginalRotation(Transform target)
{
int existing = _rotationJobs.FindIndex(j => j.Target == target && !j.Hold);
return existing >= 0 ? _rotationJobs[existing].Goal : target.rotation;
}
private void RestoreRotations()
@@ -225,21 +297,32 @@ private void RestoreRotations()
foreach (var kvp in _originalRotations)
{
if (kvp.Key != null)
_ = RotateToRotation(kvp.Key, kvp.Value);
RotateToRotation(kvp.Key, kvp.Value);
}
_originalRotations.Clear();
}
private async Awaitable RotateToRotation(Transform target, Quaternion targetRotation)
private void LateUpdate()
{
float duration = 0.5f;
float elapsed = 0f;
while (elapsed < duration)
for (int i = _rotationJobs.Count - 1; i >= 0; i--)
{
if (target == null) return;
target.rotation = Quaternion.Slerp(target.rotation, targetRotation, 10f * Time.deltaTime);
elapsed += Time.deltaTime;
await Awaitable.NextFrameAsync();
var job = _rotationJobs[i];
if (job.Target == null) { _rotationJobs.RemoveAt(i); continue; }
// Animator가 이 프레임에 뭘 썼든 무시하고, 우리가 기억하는 회전에서 이어서 목표로 수렴시킨다
job.Current = Quaternion.Slerp(job.Current, job.Goal, 10f * Time.deltaTime);
job.Target.rotation = job.Current;
// 복원 잡만 수명이 있다 — 바라보기(Hold) 잡은 대화 종료 시 복원 잡이 교체한다
if (!job.Hold)
{
job.Remaining -= Time.deltaTime;
if (job.Remaining <= 0f)
{
job.Target.rotation = job.Goal; // 마지막엔 목표값으로 정확히 스냅 (잔여 오차 방지)
_rotationJobs.RemoveAt(i);
}
}
}
}
@@ -287,8 +370,8 @@ private async Awaitable PlayNode(DialogNode node)
var voiceObj = CharacterVoiceObject.Find(node.Speaker);
if (voiceObj != null)
{
_originalRotations.TryAdd(voiceObj.transform, voiceObj.transform.rotation);
_ = RotateTowardPlayer(voiceObj.transform);
_originalRotations.TryAdd(voiceObj.transform, GetOriginalRotation(voiceObj.transform));
RotateTowardPlayer(voiceObj.transform);
}
}