942 lines
42 KiB
C#
942 lines
42 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;
|
|
|
|
[Tooltip("켜면 이 대화에선 말을 걸어도 플레이어 쪽으로 회전하지 않는다 (기본: 회전함). " +
|
|
"정면을 고정한 연출 대화 등에 사용. 단, 선택 메뉴가 뜨는 다중 대화에선 메뉴를 위해 회전할 수 있음")]
|
|
public bool DontRotateToPlayer;
|
|
}
|
|
|
|
// 노드의 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 = "무슨 이야기를 나눌까?";
|
|
|
|
[Tooltip("말을 건 뒤 대화창(선택 메뉴·첫 대사)이 뜨기까지의 딜레이(초)")]
|
|
[Min(0)] [SerializeField] private float _dialogStartDelay = 0.2f;
|
|
|
|
[Tooltip("보이스가 있는 노드는 클립이 끝난 뒤 이만큼(초) 더 기다렸다 다음으로 넘어간다 (바로 넘어가면 어색해서)")]
|
|
[Min(0)] [SerializeField] private float _voiceTrailingDelay = 0.5f;
|
|
|
|
// HUD 배치는 화자(NPC)의 DialogHudPlacement 컴포넌트가 담당한다 (없으면 DialogHud 기본값).
|
|
|
|
[Header("Dialog Events")]
|
|
[Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")]
|
|
[SerializeField] private List<NodeEvent> _nodeEvents = new();
|
|
|
|
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
|
|
private Animator _animator;
|
|
private readonly Dictionary<Transform, Quaternion> _originalRotations = new();
|
|
|
|
// 대화 중 제스처/표정으로 건드린 (Animator, 레이어)의 원래 state — 대화 종료 시 그 레이어만 복원.
|
|
// (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다)
|
|
private readonly Dictionary<(Animator anim, int layer), int> _restoreStates = new();
|
|
|
|
// 클립 교체 재생용 — Animator별 오버라이드 컨트롤러(최초 1회만 래핑), 레이어별 슬롯 유무 캐시, 핑퐁 커서
|
|
private readonly Dictionary<Animator, AnimatorOverrideController> _overrides = new();
|
|
private readonly Dictionary<(Animator anim, int layer), (bool hasA, bool hasB)> _slotCache = new();
|
|
private readonly Dictionary<(Animator anim, int layer), int> _slotCursor = new(); // 0 = 직전에 A 슬롯 사용
|
|
|
|
// 표정으로 커스텀 연출을 켠 캐릭터들 — 대화 종료 시 전부 원복
|
|
private readonly HashSet<CharacterExpressionCustom> _touchedCustoms = new();
|
|
public bool IsPlaying { get; private set; }
|
|
|
|
// 지금 실제 대사를 재생 중인 DialogPlayer (전역 1개) — 이때 다른 NPC의 Play()는 무시된다.
|
|
// 선택 메뉴만 떠 있는 단계는 여기 안 잡힌다 (그 경우는 새 NPC가 메뉴를 취소하고 시작).
|
|
private static DialogPlayer _entryInProgress;
|
|
|
|
// ── 전역 대화 진행 신호 (대화 중 월드 상호작용 차단용) ────────
|
|
// 선택 메뉴 단계부터 대사 종료까지, 어느 NPC든 대화가 진행 중이면 true.
|
|
// DialogInteractionBlocker가 구독해서 인터랙터를 잠근다.
|
|
public static bool IsAnyActive => _activeCount > 0;
|
|
public static event Action<bool> AnyActiveChanged;
|
|
private static int _activeCount;
|
|
|
|
private static void PushActive()
|
|
{
|
|
if (++_activeCount == 1) AnyActiveChanged?.Invoke(true);
|
|
}
|
|
|
|
private static void PopActive()
|
|
{
|
|
if (_activeCount <= 0) return;
|
|
if (--_activeCount == 0) AnyActiveChanged?.Invoke(false);
|
|
}
|
|
|
|
// Enter Play Mode에서 도메인 리로드를 꺼도 이전 상태가 안 남게
|
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
|
private static void ResetStatics()
|
|
{
|
|
_entryInProgress = null;
|
|
_activeCount = 0;
|
|
AnyActiveChanged = null;
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
_voice = GetComponent<CharacterVoiceObject>();
|
|
_animator = GetComponentInChildren<Animator>(); // 화자를 못 찾을 때의 폴백 Animator
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
// 재생 도중 파괴(씬 전환 등)돼도 전역 잠금이 남지 않도록
|
|
if (_entryInProgress == this) _entryInProgress = null;
|
|
}
|
|
|
|
public async Awaitable Play()
|
|
{
|
|
if (IsPlaying) return;
|
|
|
|
// 다른 NPC가 실제 대사를 재생 중이면 무시 — 대화 중 다른 NPC 상호작용 차단
|
|
if (_entryInProgress != null) return;
|
|
|
|
// 이벤트존 타임라인(컷씬) 재생 중에도 무시 — 컷씬이 NPC 루트를 직접 움직이는 동안
|
|
// 대화를 시작하면 회전 캡처/복원이 타임라인과 충돌해서 방향이 꼬인다
|
|
if (EventZoneTimeline.IsAnyPlaying) return;
|
|
|
|
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소한다.
|
|
// (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다)
|
|
if (ChoiceHud.Instance != null)
|
|
ChoiceHud.Instance.CancelPending();
|
|
|
|
// 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다.
|
|
IsPlaying = true;
|
|
PushActive();
|
|
try
|
|
{
|
|
var playable = FindPlayableIndices();
|
|
if (playable.Count == 0)
|
|
{
|
|
Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}");
|
|
return;
|
|
}
|
|
|
|
// 말을 걸면 NPC가 플레이어 쪽으로 돌아본다 — 대화창(선택 메뉴·첫 대사)이 NPC 회전을
|
|
// 따라가므로 창도 플레이어를 향하게 된다. 원래 회전은 아래 finally에서 복원.
|
|
// 단, 대화 항목이 하나이고 그 항목이 DontRotateToPlayer면 회전하지 않는다(정면 고정 연출 등).
|
|
// 여러 개(선택 메뉴)일 땐 메뉴가 플레이어를 향하도록 우선 회전한다.
|
|
bool singleEntry = playable.Count == 1;
|
|
bool rotateToPlayer = !singleEntry || !_dialogs[playable[0]].DontRotateToPlayer;
|
|
if (rotateToPlayer)
|
|
{
|
|
_originalRotations.TryAdd(transform, GetOriginalRotation(transform));
|
|
RotateTowardPlayer(transform);
|
|
}
|
|
|
|
// 대화창이 바로 튀어나오지 않도록 잠깐 뜸을 들인다 (선택 메뉴·첫 대사 공통)
|
|
if (_dialogStartDelay > 0f)
|
|
{
|
|
await Awaitable.WaitForSecondsAsync(_dialogStartDelay, destroyCancellationToken);
|
|
if (_entryInProgress != null) return; // 기다리는 사이 다른 NPC의 대화가 시작됨
|
|
}
|
|
|
|
int index = singleEntry ? 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;
|
|
PopActive();
|
|
}
|
|
}
|
|
|
|
// 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (EventZoneTrigger 등 UnityEvent 연결용).
|
|
// _dialogs에 등록된 그룹이면 그 항목의 설정(진행도 보상 등)을 그대로 쓰고, 없으면 임시 항목으로 재생한다.
|
|
// 완료 여부는 검사하지 않으므로(강제) 1회성이 필요하면 호출하는 쪽(존의 Trigger Once)에서 보장할 것.
|
|
public void PlayGroup(DialogGroup group)
|
|
{
|
|
_ = PlayGroupForced(group);
|
|
}
|
|
|
|
private async Awaitable PlayGroupForced(DialogGroup group)
|
|
{
|
|
if (group == null || IsPlaying) return;
|
|
if (_entryInProgress != null) return; // 다른 NPC가 대사 재생 중
|
|
if (EventZoneTimeline.IsAnyPlaying) return; // 컷씬 중엔 시작하지 않음 (Play()와 동일한 이유)
|
|
|
|
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소
|
|
if (ChoiceHud.Instance != null)
|
|
ChoiceHud.Instance.CancelPending();
|
|
|
|
IsPlaying = true;
|
|
PushActive();
|
|
try
|
|
{
|
|
// 등록된 항목이 있으면 Repeatable/ProgressOnComplete 설정을 그대로 사용
|
|
DialogEntry entry = _dialogs.Find(e => e.Group == group);
|
|
if (entry.Group == null)
|
|
entry = new DialogEntry { Group = group };
|
|
|
|
if (!entry.DontRotateToPlayer)
|
|
{
|
|
_originalRotations.TryAdd(transform, GetOriginalRotation(transform));
|
|
RotateTowardPlayer(transform);
|
|
}
|
|
|
|
if (_dialogStartDelay > 0f)
|
|
{
|
|
await Awaitable.WaitForSecondsAsync(_dialogStartDelay, destroyCancellationToken);
|
|
if (_entryInProgress != null) return; // 기다리는 사이 다른 NPC의 대화가 시작됨
|
|
}
|
|
|
|
_entryInProgress = this;
|
|
await PlayEntry(entry);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 씬 전환 등으로 취소됨 — PlayEntry의 finally에서 정리됨
|
|
}
|
|
finally
|
|
{
|
|
RestoreRotations();
|
|
if (_entryInProgress == this) _entryInProgress = null;
|
|
IsPlaying = false;
|
|
PopActive();
|
|
}
|
|
}
|
|
|
|
// 조건을 만족하고 (반복 가능하거나 아직 안 한) 대화들의 인덱스. 리스트 순서 유지.
|
|
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를 띄운다.
|
|
// (플레이어 쪽 회전은 Play() 시작 시 이미 걸려 있음)
|
|
if (DialogHud.Instance != null)
|
|
DialogHud.Instance.Show(_voice.Character, _dialogSelectPrompt);
|
|
|
|
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;
|
|
int routingHops = 0; // 연속 라우팅 횟수 — 라우팅 노드끼리 순환하면 대기 없는 무한 루프가 되므로 차단
|
|
while (node != null)
|
|
{
|
|
// 호감도 라우팅 노드 — 대사 없이 즉시 분기 (플레이어에겐 분기 자체가 보이지 않는다)
|
|
if (node.AffectionCheck)
|
|
{
|
|
if (++routingHops > 100)
|
|
{
|
|
Debug.LogError($"[DialogPlayer] 라우팅 노드가 순환합니다 — 대화 중단: {entry.Group.name}");
|
|
break;
|
|
}
|
|
node = IsAffectionMet(node) ? node.AffectionPassBranch : node.Next;
|
|
continue;
|
|
}
|
|
routingHops = 0; // 실제 대사 노드에 도달 — 카운터 리셋
|
|
|
|
// 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 제스처 감시를 무장한다.
|
|
// (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작)
|
|
bool armed = node.HiddenBranch != null;
|
|
if (armed) HiddenBranchResolver.Arm(node.HiddenGestureKey);
|
|
try
|
|
{
|
|
bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 제스처 감시 포함)
|
|
|
|
if (diverted)
|
|
{
|
|
RecordHiddenChoice(node);
|
|
node = node.HiddenBranch; // 대사 도중 제스처 발동 → 몰래 분기
|
|
}
|
|
else if (node.Choices != null && node.Choices.Count > 0)
|
|
{
|
|
int picked = await WaitForChoice(node); // 메뉴(무장 시 제스처 감시 포함)
|
|
if (picked == DivertIndex)
|
|
{
|
|
RecordHiddenChoice(node);
|
|
node = node.HiddenBranch; // 메뉴 도중 제스처 발동 → 메뉴엔 없던 히든 분기
|
|
}
|
|
else
|
|
{
|
|
RecordChoice(node, picked);
|
|
node = node.Choices[picked].DestinationNode;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
node = node.Next;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (armed) HiddenBranchResolver.Disarm();
|
|
}
|
|
}
|
|
|
|
// 여기까지 왔으면 자연 종료(끝까지 재생) — 이때만 완료로 기록한다.
|
|
// (중간에 오브젝트 파괴 등으로 끊기면 예외로 빠져나가 기록되지 않음)
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 대화 중 제스처/표정으로 건드린 (Animator, 레이어)를 원래 state로 복원 (끼어든 NPC 포함)
|
|
private void RestoreDefaultAnimations()
|
|
{
|
|
foreach (var kvp in _restoreStates)
|
|
{
|
|
var (anim, layer) = kvp.Key;
|
|
if (anim == null || layer >= anim.layerCount) continue;
|
|
anim.CrossFade(kvp.Value, 0.3f, layer, normalizedTimeOffset: 0f);
|
|
}
|
|
_restoreStates.Clear();
|
|
|
|
// 표정으로 켰던 커스텀 연출 전부 원복
|
|
foreach (var c in _touchedCustoms)
|
|
if (c != null) c.ResetAll();
|
|
_touchedCustoms.Clear();
|
|
}
|
|
|
|
// 이 레이어를 처음 건드리면 현재 state를 기억해 둔다 (대화 종료 시 이 레이어만 복원)
|
|
private void CaptureLayerState(Animator anim, int layer)
|
|
{
|
|
if (layer < 0 || layer >= anim.layerCount) return;
|
|
var key = (anim, layer);
|
|
if (_restoreStates.ContainsKey(key)) return;
|
|
_restoreStates[key] = anim.GetCurrentAnimatorStateInfo(layer).fullPathHash;
|
|
}
|
|
|
|
// ── 표정/제스처 재생 (슬롯 클립 교체 우선, 없으면 StateName 폴백) ──────────────
|
|
// AnimClip이 있으면: 그 레이어의 Dlg 슬롯 state(플레이스홀더 클립)를 실제 클립으로 교체해 재생.
|
|
// 슬롯 2개(A·B) → 번갈아 CrossFade(부드러운 블렌드), 1개 → Play(즉시 전환), 0개 → 아래 StateName 폴백.
|
|
// AnimClip이 없으면(구식 자산): 컨트롤러에 미리 만들어 둔 StateName state로 CrossFade.
|
|
private const string SlotStatePrefix = "DlgSlot"; // 슬롯 state 이름: DlgSlotA / DlgSlotB
|
|
private static string SlotStateName(char slot) => SlotStatePrefix + slot;
|
|
private static string SlotClipName(int layer, char slot) => $"__DlgSlot_{layer}_{slot}"; // 슬롯이 무는 플레이스홀더 클립 이름
|
|
|
|
private void PlayAnimData(Animator anim, AnimationClip clip, string stateName, float crossFade, int layer)
|
|
{
|
|
if (layer < 0 || layer >= anim.layerCount) layer = 0;
|
|
CaptureLayerState(anim, layer);
|
|
|
|
// 슬롯 방식 (클립 교체) — 이 레이어에 Dlg 슬롯이 있을 때만
|
|
if (clip != null && TryGetSlots(anim, layer, out bool hasA, out bool hasB))
|
|
{
|
|
bool pingpong = hasA && hasB;
|
|
char slot;
|
|
if (pingpong)
|
|
{
|
|
var key = (anim, layer);
|
|
_slotCursor.TryGetValue(key, out int last); // last == 0 → 직전에 A 사용
|
|
slot = last == 0 ? 'B' : 'A';
|
|
_slotCursor[key] = slot == 'A' ? 0 : 1;
|
|
}
|
|
else
|
|
{
|
|
slot = hasA ? 'A' : 'B';
|
|
}
|
|
|
|
var ovr = GetOverride(anim);
|
|
ovr[SlotClipName(layer, slot)] = clip; // 슬롯의 플레이스홀더를 실제 클립으로 교체
|
|
if (pingpong)
|
|
anim.CrossFade(SlotStateName(slot), crossFade, layer, normalizedTimeOffset: 0f);
|
|
else
|
|
anim.Play(SlotStateName(slot), layer, 0f); // 슬롯 1개 → 스냅
|
|
return;
|
|
}
|
|
|
|
// 폴백 — 컨트롤러에 이름으로 미리 만들어 둔 state
|
|
if (!string.IsNullOrEmpty(stateName))
|
|
anim.CrossFade(stateName, crossFade, layer);
|
|
else if (clip != null)
|
|
Debug.LogWarning($"[DialogPlayer] '{anim.name}' 레이어 {layer}에 Dlg 슬롯도 없고 StateName도 비어 클립을 재생할 수 없습니다: {clip.name}");
|
|
}
|
|
|
|
// 이 Animator를 (아직 아니면) 오버라이드 컨트롤러로 래핑해 캐시 — 최초 1회만 rebind 발생
|
|
private AnimatorOverrideController GetOverride(Animator anim)
|
|
{
|
|
if (_overrides.TryGetValue(anim, out var o) && o != null) return o;
|
|
if (anim.runtimeAnimatorController is AnimatorOverrideController existing)
|
|
o = existing;
|
|
else
|
|
{
|
|
o = new AnimatorOverrideController(anim.runtimeAnimatorController)
|
|
{ name = anim.runtimeAnimatorController.name + " (Dlg)" };
|
|
anim.runtimeAnimatorController = o;
|
|
}
|
|
_overrides[anim] = o;
|
|
return o;
|
|
}
|
|
|
|
// 이 레이어에 Dlg 슬롯 플레이스홀더 클립(__DlgSlot_{layer}_A/B)이 존재하는지. 결과는 캐시.
|
|
private bool TryGetSlots(Animator anim, int layer, out bool hasA, out bool hasB)
|
|
{
|
|
var key = (anim, layer);
|
|
if (_slotCache.TryGetValue(key, out var c)) { hasA = c.hasA; hasB = c.hasB; return hasA || hasB; }
|
|
|
|
string an = SlotClipName(layer, 'A'), bn = SlotClipName(layer, 'B');
|
|
hasA = false; hasB = false;
|
|
var rac = anim.runtimeAnimatorController;
|
|
if (rac is AnimatorOverrideController ao)
|
|
{
|
|
// 이미 래핑돼 슬롯이 실제 클립으로 교체됐어도, 원본(Key) 클립 이름은 플레이스홀더 그대로다
|
|
var list = new List<KeyValuePair<AnimationClip, AnimationClip>>();
|
|
ao.GetOverrides(list);
|
|
foreach (var p in list)
|
|
{
|
|
if (p.Key == null) continue;
|
|
if (p.Key.name == an) hasA = true;
|
|
else if (p.Key.name == bn) hasB = true;
|
|
}
|
|
}
|
|
else if (rac != null)
|
|
{
|
|
foreach (var cl in rac.animationClips)
|
|
{
|
|
if (cl == null) continue;
|
|
if (cl.name == an) hasA = true;
|
|
else if (cl.name == bn) hasB = true;
|
|
}
|
|
}
|
|
_slotCache[key] = (hasA, hasB);
|
|
return hasA || hasB;
|
|
}
|
|
|
|
// ── 대화 중 캐릭터 회전 ────────────────────────────────────────
|
|
// 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;
|
|
Vector3 dir = Camera.main.transform.position - target.position;
|
|
dir.y = 0f;
|
|
if (dir.sqrMagnitude < 0.0001f) return;
|
|
AddRotationJob(target, Quaternion.LookRotation(dir), hold: true);
|
|
}
|
|
|
|
private void RotateToRotation(Transform target, Quaternion rotation) => AddRotationJob(target, rotation, hold: false);
|
|
|
|
// 화자를 특정 월드 Y각(yaw)으로 고정 회전한다. hold=true라 대화 동안 Animator가 루트를 덮어써도 유지되고,
|
|
// 대화 종료 시 _originalRotations 복원으로 원래 각도로 돌아간다. (특정 각도 전용 애니메이션용)
|
|
private void RotateToFixedYaw(Transform target, float yawDegrees)
|
|
=> AddRotationJob(target, Quaternion.Euler(0f, yawDegrees, 0f), hold: true);
|
|
|
|
// 플레이어가 지정 위치를 바라보도록 리그를 수평(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)을 이어받아 교체 (바라보기 ↔ 복원 충돌 방지)
|
|
Quaternion current = target.rotation;
|
|
int existing = _rotationJobs.FindIndex(j => j.Target == target);
|
|
if (existing >= 0)
|
|
{
|
|
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()
|
|
{
|
|
foreach (var kvp in _originalRotations)
|
|
{
|
|
if (kvp.Key != null)
|
|
RotateToRotation(kvp.Key, kvp.Value);
|
|
}
|
|
_originalRotations.Clear();
|
|
}
|
|
|
|
private void LateUpdate()
|
|
{
|
|
for (int i = _rotationJobs.Count - 1; i >= 0; i--)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행
|
|
private async Awaitable<bool> PlayNode(DialogNode node)
|
|
{
|
|
// 화자 옆 DialogHud에 대사 표시
|
|
// (배치는 화자의 DialogHudPlacement 담당, 없으면 DialogHud 기본값. 이름은 노드 오버라이드 우선)
|
|
// 연출 전용 노드(StagingOnly)는 대화창을 잠시 내리고 연출만 수행한다.
|
|
if (DialogHud.Instance != null)
|
|
{
|
|
if (node.StagingOnly)
|
|
DialogHud.Instance.Hide();
|
|
else
|
|
DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride,
|
|
node.HudAnchor); // HudAnchor 지정 시 창은 그 캐릭터 옆에 (비우면 화자 옆)
|
|
}
|
|
|
|
RaiseNodeEvent(node.EventKey); // EventKey 있으면 매칭 이벤트 호출
|
|
|
|
// 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC
|
|
if (node.Affection != 0)
|
|
{
|
|
var affectionTarget = node.Speaker != null ? node.Speaker : _voice.Character;
|
|
StoryManager.Instance.AddAffection(affectionTarget, node.Affection);
|
|
}
|
|
|
|
// 메인 진행도 증가 — 분기 끝 노드마다 다른 값을 주면 선택지에 따라 진행도가 달라진다
|
|
if (node.Progress != 0)
|
|
StoryManager.Instance.MainProgress += node.Progress;
|
|
|
|
// 전용 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)
|
|
voiceObj.Play(node.Voice);
|
|
}
|
|
|
|
// 플레이어 향해 회전
|
|
if (node.LookAtPlayer && node.Speaker != null)
|
|
{
|
|
var voiceObj = CharacterVoiceObject.Find(node.Speaker);
|
|
if (voiceObj != null)
|
|
{
|
|
_originalRotations.TryAdd(voiceObj.transform, GetOriginalRotation(voiceObj.transform));
|
|
RotateTowardPlayer(voiceObj.transform);
|
|
}
|
|
}
|
|
|
|
// 플레이어가 화자를 바라보도록 강제 회전
|
|
if (node.ForcePlayerLook && node.Speaker != null)
|
|
{
|
|
var voiceObj = CharacterVoiceObject.Find(node.Speaker);
|
|
if (voiceObj != null)
|
|
RotatePlayerToward(voiceObj.transform.position);
|
|
}
|
|
|
|
// 고정 각도 회전 — 특정 각도에서만 자연스러운 애니메이션/제스처용 (화자, 못 찾으면 대화 주인 NPC)
|
|
if (node.UseFixedAngle)
|
|
{
|
|
var speakerObj = node.Speaker != null ? CharacterVoiceObject.Find(node.Speaker) : null;
|
|
var fixedTarget = speakerObj != null ? speakerObj.transform : transform;
|
|
_originalRotations.TryAdd(fixedTarget, GetOriginalRotation(fixedTarget));
|
|
RotateToFixedYaw(fixedTarget, node.FixedAngleY);
|
|
}
|
|
|
|
// 제스처/표정은 화자(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)
|
|
{
|
|
// 클립 교체가 최초 1회 rebind를 유발할 수 있으니, 건드릴 레이어의 원래 state를 먼저 기억
|
|
if (node.Gesture != null) CaptureLayerState(anim, node.Gesture.AnimationLayer);
|
|
if (node.Expression != null) CaptureLayerState(anim, node.Expression.AnimationLayer);
|
|
|
|
if (node.Gesture != null)
|
|
PlayAnimData(anim, node.Gesture.AnimClip, node.Gesture.StateName,
|
|
node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
|
|
if (node.Expression != null)
|
|
PlayAnimData(anim, node.Expression.AnimClip, null, // 표정은 StateName 폴백 없음 — 슬롯 전용
|
|
node.Expression.CrossFadeDuration, node.Expression.AnimationLayer);
|
|
}
|
|
|
|
// 표정에 딸린 커스텀 연출(눈 하이라이트·홍조 등) 적용 — 화자(못 찾으면 대화 주인 NPC)의 CharacterExpressionCustom에.
|
|
// CustomKeys에 있는 Key만 활성, 나머지는 비활성 → 다른 표정으로 바뀌면 자동으로 이전 연출이 꺼진다.
|
|
if (node.Expression != null)
|
|
{
|
|
var custom = gestureObj != null
|
|
? gestureObj.GetComponentInChildren<CharacterExpressionCustom>()
|
|
: GetComponentInChildren<CharacterExpressionCustom>();
|
|
if (custom != null)
|
|
{
|
|
_touchedCustoms.Add(custom);
|
|
custom.SetActiveKeys(node.Expression.CustomKeys);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 진행 방식 결정
|
|
// 히든 분기가 무장된 노드는 대기 중 제스처를 함께 감시(레이스)한다.
|
|
// 무장 안 된 노드는 아래 기존 대기 그대로 → 동작 100% 동일, 반환 false.
|
|
if (HiddenBranchResolver.Armed)
|
|
return await WaitAdvanceOrDivert(node);
|
|
|
|
if (node.WaitForInput)
|
|
{
|
|
await WaitForAdvanceInput(); // B버튼 입력이 있어야만 다음으로
|
|
}
|
|
else
|
|
{
|
|
float wait = node.Voice != null
|
|
? node.Voice.length + _voiceTrailingDelay // 클립이 끝난 뒤 잠깐 여운
|
|
: node.LineDuration;
|
|
|
|
if (wait > 0f)
|
|
await Awaitable.WaitForSecondsAsync(wait);
|
|
else
|
|
await WaitForAdvanceInput(); // 지정 시간이 없으면 입력으로 진행
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 호감도 분기 노드의 조건식 평가.
|
|
// 각 조건의 JoinWithNext로 이어 붙이며, AND가 OR보다 우선순위가 높다:
|
|
// A and B or C → (A and B) or C
|
|
// 즉 "AND 그룹들을 OR로 합치는" 형태로 계산한다. 조건이 없으면 통과로 본다.
|
|
private bool IsAffectionMet(DialogNode node)
|
|
{
|
|
var requirements = node.AffectionRequirements;
|
|
if (requirements == null || requirements.Count == 0) return true;
|
|
|
|
var story = StoryManager.Instance;
|
|
bool result = false; // 지금까지 닫힌 AND 그룹들을 OR로 합친 값
|
|
bool group = true; // 현재 진행 중인 AND 그룹
|
|
|
|
for (int i = 0; i < requirements.Count; i++)
|
|
{
|
|
var req = requirements[i];
|
|
if (req == null) continue;
|
|
|
|
var target = req.Character != null ? req.Character : _voice.Character;
|
|
group &= req.IsMet(story.GetAffection(target));
|
|
|
|
// 다음 연결자가 OR이거나 마지막이면 AND 그룹을 닫고 OR로 합친다
|
|
bool isLast = i == requirements.Count - 1;
|
|
if (isLast || req.JoinWithNext == AffectionJoin.Or)
|
|
{
|
|
result |= group;
|
|
group = true;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// 노드의 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);
|
|
}
|
|
|
|
// 히든 분기 기록: 노드의 HiddenCode를 선택지 Code와 동일하게 StoryState에 영구 기록
|
|
private void RecordHiddenChoice(DialogNode node)
|
|
{
|
|
string code = DialogVariables.Format(node.HiddenCode); // {token} 치환
|
|
if (!string.IsNullOrEmpty(code))
|
|
StoryManager.Instance.RecordChoice(code);
|
|
}
|
|
|
|
// 히든 분기 신호값 — 선택지 인덱스(0..n-1)와 절대 겹치지 않는 값.
|
|
private const int DivertIndex = -1;
|
|
|
|
private async Awaitable<int> WaitForChoice(DialogNode node)
|
|
{
|
|
if (ChoiceHud.Instance == null)
|
|
{
|
|
Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택");
|
|
return 0;
|
|
}
|
|
|
|
// 무장 안 된 노드는 기존과 동일 — 그냥 메뉴 선택을 기다린다.
|
|
if (!HiddenBranchResolver.Armed)
|
|
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
|
|
|
|
// 무장된 노드: 메뉴가 떠 있는 동안 제스처가 들어오면 메뉴를 취소하고 히든으로 분기.
|
|
if (HiddenBranchResolver.Fired)
|
|
return DivertIndex; // 메뉴가 뜨기 전에 이미 발동한 경우
|
|
|
|
void OnFired() => ChoiceHud.Instance.CancelPending(); // 제스처 → 메뉴 즉시 취소
|
|
HiddenBranchResolver.FiredEvent += OnFired;
|
|
try
|
|
{
|
|
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 우리가 제스처로 취소한 것이면 히든 분기, 아니면(씬 전환 등) 위로 전파
|
|
if (HiddenBranchResolver.Fired) return DivertIndex;
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
HiddenBranchResolver.FiredEvent -= OnFired;
|
|
}
|
|
}
|
|
|
|
// 노드 대기 + 히든 제스처 감시(레이스). 무장된 노드에서만 호출된다.
|
|
// 반환: true = 대기 도중 제스처 발동(→히든 분기), false = 평범하게 진행(입력/시간)
|
|
private async Awaitable<bool> WaitAdvanceOrDivert(DialogNode node)
|
|
{
|
|
// 기존 PlayNode 대기 규칙 그대로 계산 — 입력 대기냐, 시간 대기냐
|
|
bool waitForInput;
|
|
float timeoutSeconds;
|
|
if (node.WaitForInput)
|
|
{
|
|
waitForInput = true; timeoutSeconds = -1f;
|
|
}
|
|
else
|
|
{
|
|
float wait = node.Voice != null
|
|
? node.Voice.length + _voiceTrailingDelay // 클립이 끝난 뒤 잠깐 여운
|
|
: node.LineDuration;
|
|
if (wait > 0f) { waitForInput = false; timeoutSeconds = wait; }
|
|
else { waitForInput = true; timeoutSeconds = -1f; }
|
|
}
|
|
|
|
var im = InputManager.Instance;
|
|
bool advance = false;
|
|
void OnAdvance() => advance = true;
|
|
if (waitForInput && im != null) im.OnDialogNext_Event += OnAdvance;
|
|
|
|
// 입력 대기인데 InputManager가 없으면 기존 WaitForAdvanceInput처럼 1초 후 진행
|
|
float timeLeft = timeoutSeconds;
|
|
if (waitForInput && im == null) timeLeft = 1f;
|
|
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
if (HiddenBranchResolver.Fired) return true; // 제스처 발동 → 히든 분기
|
|
if (waitForInput && advance) return false; // B버튼 → 평범하게 진행
|
|
if (timeLeft >= 0f)
|
|
{
|
|
timeLeft -= Time.deltaTime;
|
|
if (timeLeft <= 0f) return false; // 시간 경과 → 평범하게 진행
|
|
}
|
|
await Awaitable.NextFrameAsync(destroyCancellationToken);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return false; // 대기 중 파괴/씬 전환 — 기존과 동일하게 조용히 종료
|
|
}
|
|
finally
|
|
{
|
|
if (waitForInput && im != null) im.OnDialogNext_Event -= OnAdvance;
|
|
}
|
|
}
|
|
|
|
// 대화 진행 입력(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 불가)
|
|
}
|
|
}
|
|
}
|