대화중 표정 수정

This commit is contained in:
2026-07-27 14:38:25 +09:00
parent 45b5d46019
commit c191bea41d
187 changed files with 62945 additions and 70 deletions

View File

@@ -56,9 +56,17 @@ public struct NodeEvent
private Animator _animator;
private readonly Dictionary<Transform, Quaternion> _originalRotations = new();
// 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원.
// 대화 중 제스처/표정으로 건드린 (Animator, 레이어)의 원래 state — 대화 종료 시 그 레이어만 복원.
// (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다)
private readonly Dictionary<Animator, (int gestureHash, int expressionHash, bool hasExpression)> _touchedAnimators = new();
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()는 무시된다.
@@ -355,29 +363,126 @@ private async Awaitable PlayEntry(DialogEntry entry)
}
}
// 대화 중 제스처/표정을 재생했던 모든 Animator(끼어든 NPC 포함)를 원래 상태로 복원
// 대화 중 제스처/표정으로 건드린 (Animator, 레이어)를 원래 state로 복원 (끼어든 NPC 포함)
private void RestoreDefaultAnimations()
{
foreach (var kvp in _touchedAnimators)
foreach (var kvp in _restoreStates)
{
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);
var (anim, layer) = kvp.Key;
if (anim == null || layer >= anim.layerCount) continue;
anim.CrossFade(kvp.Value, 0.3f, layer, normalizedTimeOffset: 0f);
}
_touchedAnimators.Clear();
_restoreStates.Clear();
// 표정으로 켰던 커스텀 연출 전부 원복
foreach (var c in _touchedCustoms)
if (c != null) c.ResetAll();
_touchedCustoms.Clear();
}
// 처음 건드리는 Animator면 현재 상태를 기억해 둔다 (대화 종료 시 복원 기준)
private void CaptureInitialAnimState(Animator anim)
// 이 레이어를 처음 건드리면 현재 state를 기억해 둔다 (대화 종료 시 이 레이어만 복원)
private void CaptureLayerState(Animator anim, int layer)
{
if (_touchedAnimators.ContainsKey(anim)) return;
bool hasExpression = anim.layerCount > 1;
_touchedAnimators[anim] = (
anim.GetCurrentAnimatorStateInfo(0).fullPathHash,
hasExpression ? anim.GetCurrentAnimatorStateInfo(1).fullPathHash : 0,
hasExpression);
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;
}
// ── 대화 중 캐릭터 회전 ────────────────────────────────────────
@@ -586,11 +691,30 @@ private async Awaitable<bool> PlayNode(DialogNode node)
if (anim != null)
{
CaptureInitialAnimState(anim); // 대화 종료 시 복원할 원래 상태 기억
// 클립 교체가 최초 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)
anim.CrossFade(node.Gesture.StateName, node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
PlayAnimData(anim, node.Gesture.AnimClip, node.Gesture.StateName,
node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
if (node.Expression != null)
anim.CrossFade(node.Expression.StateName, node.Expression.CrossFadeDuration, node.Expression.AnimationLayer);
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);
}
}
}