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

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using UnityEngine;
// 이 캐릭터가 화자일 때 대화창(DialogHud)을 어디에 띄울지 캐릭터별로 지정한다.
// 진행 중인 대화에 다른 NPC의 대사가 끼어들 때도 화자 본인의 이 값이 쓰인다.
// 이 컴포넌트가 없으면 DialogHud 자체의 기본값으로 배치된다 (후보 자동 회피 없음).
// CharacterVoiceObject와 같은 오브젝트(NPC 루트)에 붙일 것.
public class DialogHudPlacement : MonoBehaviour
{
[Tooltip("배치 후보들 — 위에서부터 검사해 벽에 안 겹치고 안 가려지는 첫 후보 사용. " +
"각 후보가 완전한 배치값(높이/앞/좌우/회전)이며 캐릭터 크기에 맞게 조절할 것. " +
"전부 실패하면 첫 후보(기본)로 표시")]
public List<DialogHud.PlacementCandidate> Candidates = new()
{
new DialogHud.PlacementCandidate { Name = "기본", Height = 1.2f, Forward = 0.7f, Lateral = 0f },
new DialogHud.PlacementCandidate { Name = "우", Height = 1.2f, Forward = 0.5f, Lateral = 0.8f },
new DialogHud.PlacementCandidate { Name = "좌", Height = 1.2f, Forward = 0.5f, Lateral = -0.8f },
new DialogHud.PlacementCandidate { Name = "우상단", Height = 1.8f, Forward = 0.5f, Lateral = 0.5f },
new DialogHud.PlacementCandidate { Name = "좌상단", Height = 1.8f, Forward = 0.5f, Lateral = -0.5f },
new DialogHud.PlacementCandidate { Name = "중앙상단", Height = 2.0f, Forward = 0.5f, Lateral = 0f },
};
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c42d3e6d9a0d3fc4788ddda76e366167

View File

@@ -8,6 +8,9 @@ public class DialogNode : ScriptableObject
[Header("Speaker")]
public CharacterData Speaker;
[Tooltip("비우면 Speaker의 이름 그대로, 채우면 이 이름으로 표시 (예: ???)")]
public string SpeakerNameOverride;
[Header("Content")]
[TextArea(2,5)] public string TalkText;
public GestureData Gesture;
@@ -25,7 +28,9 @@ public class DialogNode : ScriptableObject
[Header("Behavior")]
public bool LookAtPlayer;
public bool ForcePlayerLook; // true면 이 대사 시작 시 플레이어(카메라)가 화자를 바라보도록 리그를 회전
public bool WaitForInput; // true면 LineDuration 무시하고 B버튼(OnDialogNext) 입력까지 대기
public bool StagingOnly; // true면 대화창을 숨기고 연출(회전·제스처·VFX·이벤트·대기)만 수행
[Header("Flow")]
public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드

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)

View File

@@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다.
[ScriptedImporter(2, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
[ScriptedImporter(6, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
internal class DialogGraphImporter : ScriptedImporter
{
public override void OnImportAsset(AssetImportContext ctx)
@@ -47,7 +47,7 @@ public override void OnImportAsset(AssetImportContext ctx)
while (queue.Count > 0)
{
var gn = queue.Dequeue();
if (gn == null || map.ContainsKey(gn) || gn is not DialogLineNode)
if (gn == null || map.ContainsKey(gn) || (gn is not DialogLineNode && gn is not DialogStagingNode))
continue;
var dn = ScriptableObject.CreateInstance<DialogNode>();
@@ -71,10 +71,21 @@ public override void OnImportAsset(AssetImportContext ctx)
// 2패스: 데이터/링크 채우기
foreach (var gn in order)
{
var line = (DialogLineNode)gn;
var dn = map[gn];
// 연출 전용 노드 — 대사 없이 StagingOnly로 변환, 선형 진행만 지원
if (gn is DialogStagingNode stagingNode)
{
FillStagingNode(stagingNode, dn);
var stagingNext = GetConnectedNode(gn, DialogGraphNode.EXEC_OUT);
dn.Next = stagingNext != null && map.TryGetValue(stagingNext, out var stagingNextDn) ? stagingNextDn : null;
continue;
}
var line = (DialogLineNode)gn;
dn.Speaker = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogLineNode.PORT_SPEAKER));
dn.SpeakerNameOverride = GetInputPortValue<DialogShortText>(gn.GetInputPortByName(DialogLineNode.PORT_NAME_OVERRIDE)).Value;
dn.TalkText = GetInputPortValue<DialogText>(gn.GetInputPortByName(DialogLineNode.PORT_TALK)).Value;
dn.Gesture = GetInputPortValue<GestureData>(gn.GetInputPortByName(DialogLineNode.PORT_GESTURE));
dn.Expression = GetInputPortValue<ExpressionData>(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION));
@@ -83,6 +94,7 @@ public override void OnImportAsset(AssetImportContext ctx)
dn.Vfx = GetInputPortValue<GameObject>(gn.GetInputPortByName(DialogLineNode.PORT_VFX));
dn.LineDuration = GetInputPortValue<float>(gn.GetInputPortByName(DialogLineNode.PORT_DURATION));
dn.LookAtPlayer = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_LOOKAT));
dn.ForcePlayerLook = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_FORCELOOK));
dn.WaitForInput = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_WAITINPUT));
string eventKey = null;
@@ -118,9 +130,34 @@ public override void OnImportAsset(AssetImportContext ctx)
group.StartNode = map.TryGetValue(firstGraphNode, out var startDn) ? startDn : null;
}
// 연출 전용 노드의 데이터 채우기 (대사·선택지 없음)
static void FillStagingNode(DialogStagingNode gn, DialogNode dn)
{
dn.StagingOnly = true;
dn.Speaker = GetInputPortValue<CharacterData>(gn.GetInputPortByName(DialogStagingNode.PORT_SPEAKER));
dn.Gesture = GetInputPortValue<GestureData>(gn.GetInputPortByName(DialogStagingNode.PORT_GESTURE));
dn.Expression = GetInputPortValue<ExpressionData>(gn.GetInputPortByName(DialogStagingNode.PORT_EXPRESSION));
dn.Bgm = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogStagingNode.PORT_BGM));
dn.Vfx = GetInputPortValue<GameObject>(gn.GetInputPortByName(DialogStagingNode.PORT_VFX));
dn.LineDuration = GetInputPortValue<float>(gn.GetInputPortByName(DialogStagingNode.PORT_DURATION));
dn.LookAtPlayer = GetInputPortValue<bool>(gn.GetInputPortByName(DialogStagingNode.PORT_LOOKAT));
dn.ForcePlayerLook = GetInputPortValue<bool>(gn.GetInputPortByName(DialogStagingNode.PORT_FORCELOOK));
string eventKey = null;
gn.GetNodeOptionByName(DialogStagingNode.OPTION_EVENT_KEY)?.TryGetValue(out eventKey);
dn.EventKey = eventKey;
}
// 노드의 실행 흐름상 후속 노드들 (선형이면 1개, N지선다면 N개)
static IEnumerable<INode> GetSuccessors(INode node)
{
// 연출 전용 노드는 항상 선형
if (node is DialogStagingNode)
{
yield return GetConnectedNode(node, DialogGraphNode.EXEC_OUT);
yield break;
}
if (node is not DialogLineNode line)
yield break;

View File

@@ -15,6 +15,7 @@ namespace DinoLove.Dialog.GraphTool.Editor
internal class DialogLineNode : DialogGraphNode
{
public const string PORT_SPEAKER = "Speaker";
public const string PORT_NAME_OVERRIDE = "SpeakerNameOverride";
public const string PORT_TALK = "TalkText";
public const string PORT_GESTURE = "Gesture";
public const string PORT_EXPRESSION = "Expression";
@@ -23,6 +24,7 @@ internal class DialogLineNode : DialogGraphNode
public const string PORT_VFX = "Vfx";
public const string PORT_DURATION = "LineDuration";
public const string PORT_LOOKAT = "LookAtPlayer";
public const string PORT_FORCELOOK = "ForcePlayerLook";
public const string PORT_WAITINPUT = "WaitForInput";
public const string PORT_QUESTION = "ChoiceQuestion";
@@ -54,6 +56,8 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
// DialogNode의 라인 데이터 (모두 선택 입력, 비워두면 default)
context.AddInputPort<CharacterData>(PORT_SPEAKER).WithDisplayName("Speaker").Build();
context.AddInputPort<DialogShortText>(PORT_NAME_OVERRIDE).WithDisplayName("Name Override")
.WithTooltip("비우면 Speaker 이름 그대로, 채우면 이 이름으로 표시 (예: ???)").Build();
context.AddInputPort<DialogText>(PORT_TALK).WithDisplayName("Talk Text").Build();
context.AddInputPort<GestureData>(PORT_GESTURE).WithDisplayName("Gesture").Build();
context.AddInputPort<ExpressionData>(PORT_EXPRESSION).WithDisplayName("Expression").Build();
@@ -64,6 +68,8 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
.WithTooltip("이 대사 시작 시 화자 위치에서 1회 재생할 이펙트 프리팹").Build();
context.AddInputPort<float>(PORT_DURATION).WithDisplayName("Line Duration").Build();
context.AddInputPort<bool>(PORT_LOOKAT).WithDisplayName("Look At Player").Build();
context.AddInputPort<bool>(PORT_FORCELOOK).WithDisplayName("Force Player Look")
.WithTooltip("이 대사 시작 시 플레이어(카메라)가 화자를 바라보도록 강제 회전").Build();
context.AddInputPort<bool>(PORT_WAITINPUT).WithDisplayName("Wait For Input").Build();
int choiceCount = 0;

View File

@@ -0,0 +1,18 @@
using System;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 그래프 노드의 한 줄짜리 텍스트 포트용 래퍼 타입 (이름 오버라이드 등 짧은 텍스트).
// DialogText와 마찬가지로 string 포트의 IME 중복입력 버그를 피하기 위한 타입이며,
// 전용 DialogShortTextDrawer가 한 줄 TextField로 렌더한다.
// 임포트 시 Value 문자열만 전달된다(런타임은 이 타입을 모름).
//
// public인 이유: GraphToolkit이 포트 임베드 값을 편집할 때 이 타입을 감싸는
// 래퍼 ScriptableObject를 Reflection.Emit으로 다른 어셈블리에 생성하므로
// 접근 가능해야 한다.
[Serializable]
public struct DialogShortText
{
public string Value;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9432f3355b50d3d458cff5e56b6c9206

View File

@@ -0,0 +1,22 @@
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;
namespace DinoLove.Dialog.GraphTool.Editor
{
// DialogShortText를 한 줄 입력 필드로 그린다. (DialogTextDrawer의 싱글라인 버전)
[CustomPropertyDrawer(typeof(DialogShortText))]
internal class DialogShortTextDrawer : PropertyDrawer
{
public override VisualElement CreatePropertyGUI(SerializedProperty property)
{
var valueProp = property.FindPropertyRelative(nameof(DialogShortText.Value));
var field = new TextField();
if (valueProp != null)
field.BindProperty(valueProp);
return field;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 15b46c322dec78d438a144f875d67d42

View File

@@ -0,0 +1,55 @@
using System;
using Unity.GraphToolkit.Editor;
using UnityEngine;
namespace DinoLove.Dialog.GraphTool.Editor
{
// 연출 전용 노드. 대사/대화창 없이 몇 초간 연출만 수행한다.
// (플레이어 강제 회전, 대상 캐릭터 회전/제스처, VFX, BGM, 이벤트 호출 등)
// DialogNode(StagingOnly=true) 하나로 변환되며, 선형 진행만 지원한다.
// 예: "잠깐" 대사 → [연출 노드: Force Player Look + Duration 2초] → 다음 대사
[Serializable]
internal class DialogStagingNode : DialogGraphNode
{
public const string PORT_SPEAKER = "Speaker"; // 연출 대상 (바라볼 상대 / 제스처·VFX의 주인)
public const string PORT_GESTURE = "Gesture";
public const string PORT_EXPRESSION = "Expression";
public const string PORT_BGM = "Bgm";
public const string PORT_VFX = "Vfx";
public const string PORT_DURATION = "Duration";
public const string PORT_LOOKAT = "LookAtPlayer";
public const string PORT_FORCELOOK = "ForcePlayerLook";
public const string OPTION_EVENT_KEY = "EventKey";
protected override void OnDefineOptions(IOptionDefinitionContext context)
{
context.AddOption<string>(OPTION_EVENT_KEY)
.WithDisplayName("Event Key")
.WithTooltip("비우면 없음. 이 노드 재생 시 DialogPlayer의 같은 Key 이벤트 호출 (영문 키 권장)")
.Delayed();
}
protected override void OnDefinePorts(IPortDefinitionContext context)
{
AddExecInput(context);
context.AddInputPort<CharacterData>(PORT_SPEAKER).WithDisplayName("Target")
.WithTooltip("연출 대상 캐릭터 — 플레이어가 바라볼 상대이자 제스처·VFX의 주인").Build();
context.AddInputPort<GestureData>(PORT_GESTURE).WithDisplayName("Gesture").Build();
context.AddInputPort<ExpressionData>(PORT_EXPRESSION).WithDisplayName("Expression").Build();
context.AddInputPort<AudioClip>(PORT_BGM).WithDisplayName("BGM")
.WithTooltip("있으면 이 연출부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build();
context.AddInputPort<GameObject>(PORT_VFX).WithDisplayName("VFX Prefab")
.WithTooltip("연출 시작 시 대상 위치에서 1회 재생할 이펙트 프리팹").Build();
context.AddInputPort<float>(PORT_DURATION).WithDisplayName("Duration")
.WithTooltip("연출 시간(초). 0이면 B버튼 입력까지 대기").Build();
context.AddInputPort<bool>(PORT_LOOKAT).WithDisplayName("Look At Player")
.WithTooltip("대상 캐릭터가 플레이어를 바라봄").Build();
context.AddInputPort<bool>(PORT_FORCELOOK).WithDisplayName("Force Player Look")
.WithTooltip("플레이어가 대상 캐릭터를 바라봄").Build();
AddExecOutput(context, EXEC_OUT, string.Empty);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fafb2723eb8f0c54a8ca67baac92d8aa

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
@@ -25,6 +26,28 @@ public class DialogHud : MonoBehaviour
[SerializeField] private float _lateralOffset = 0f; // 좌우 오프셋 (+ 화자와 마주보는 시점 오른쪽)
[SerializeField] private Vector3 _rotationOffset = Vector3.zero; // 화자 회전 기준 추가 회전 (+α, 오일러 각)
// ── 후보 위치 자동 선택 ─────────────────────────────────────
// 대사창을 띄울 때 후보를 우선순위 순서대로 검사해서, 벽/지형에 겹치지 않고
// 플레이어에게 가려지지 않는 첫 후보 위치에 창을 띄운다.
// 후보 목록 자체는 NPC마다 크기가 달라 화자의 DialogHudPlacement에서 넘어온다.
// 후보 하나가 완전한 배치값을 갖는다 (기본값 + 보정 방식 아님 — 그대로 적용됨)
[System.Serializable]
public struct PlacementCandidate
{
public string Name; // 인스펙터 식별용
public float Height; // 화자 발 기준 높이
public float Forward; // 화자가 바라보는 방향으로 띄울 거리
public float Lateral; // 좌우 오프셋 (+ 화자와 마주보는 시점 오른쪽)
public Vector3 RotationOffset; // 추가 회전 (오일러 각)
}
[Header("Auto Placement (후보 위치 자동 선택)")]
[Tooltip("대사창이 피해야 할 레이어 (벽/지형 = Default). NPC·플레이어 레이어는 넣지 말 것")]
[SerializeField] private LayerMask _obstacleMask = 1; // Default 레이어
[Tooltip("충돌 검사에 쓰는 대사창 절반 크기 — 실제 캔버스 크기에 맞출 것")]
[SerializeField] private Vector3 _panelHalfExtents = new(0.5f, 0.3f, 0.05f);
private Transform _speakerTransform;
private float _activeChestHeight;
private float _activeForwardOffset;
@@ -43,21 +66,37 @@ private void OnDestroy()
if (Instance == this) Instance = null;
}
// DialogHud 자체 기본 오프셋 사용
public void Show(CharacterData speaker, string text)
=> Show(speaker, text, _chestHeight, _forwardOffset, _lateralOffset, _rotationOffset);
// 배치 오프셋을 직접 넘겨 사용 (DialogPlayer가 NPC/씬별 값 전달)
public void Show(CharacterData speaker, string text, float chestHeight, float forwardOffset, float lateralOffset, Vector3 rotationOffset)
// 화자 옆에 대사 표시.
// - 배치는 화자 오브젝트의 DialogHudPlacement 값 사용, 없으면 이 컴포넌트의 기본값 폴백
// - speakerNameOverride가 비어있지 않으면 CharacterData.Name 대신 그 이름을 표시한다 (예: "???")
// - 화자의 DialogHudPlacement에 후보 목록이 있으면 벽/가림 검사를 통과하는 첫 후보 위치로 자동 배치
public void Show(CharacterData speaker, string text, string speakerNameOverride = null)
{
_speakerTransform = speaker != null ? CharacterVoiceObject.Find(speaker)?.transform : null;
_activeChestHeight = chestHeight;
_activeForwardOffset = forwardOffset;
_activeLateralOffset = lateralOffset;
_activeRotationOffset = rotationOffset;
var voiceObj = speaker != null ? CharacterVoiceObject.Find(speaker) : null;
_speakerTransform = voiceObj != null ? voiceObj.transform : null;
DialogHudPlacement placement = null;
if (voiceObj != null) voiceObj.TryGetComponent(out placement);
if (placement != null && placement.Candidates != null && placement.Candidates.Count > 0)
{
ApplyCandidate(placement.Candidates[0]); // 일단 첫 후보(기본)로 배치하고
ApplyBestCandidate(placement.Candidates); // 검사를 통과하는 후보가 있으면 교체
}
else
{
_activeChestHeight = _chestHeight;
_activeForwardOffset = _forwardOffset;
_activeLateralOffset = _lateralOffset;
_activeRotationOffset = _rotationOffset;
}
if (_speakerName != null)
_speakerName.text = speaker != null ? DialogVariables.Format(speaker.Name) : string.Empty;
{
string speakerName = !string.IsNullOrEmpty(speakerNameOverride) ? speakerNameOverride
: speaker != null ? speaker.Name : string.Empty;
_speakerName.text = DialogVariables.Format(speakerName); // {key} 토큰 치환
}
if (_dialogueText != null)
_dialogueText.text = DialogVariables.Format(text); // {key} 토큰 치환
@@ -72,6 +111,51 @@ public void Hide()
_speakerTransform = null;
}
// 선택된 후보의 배치값을 그대로 적용한다
private void ApplyCandidate(PlacementCandidate cand)
{
_activeChestHeight = cand.Height;
_activeForwardOffset = cand.Forward;
_activeLateralOffset = cand.Lateral;
_activeRotationOffset = cand.RotationOffset;
}
// 후보 위치들을 우선순위대로 검사해 통과하는 첫 후보를 적용한다. 전부 실패하면 첫 후보 유지.
// 검사 기준은 호출 시점의 화자→플레이어(카메라) 방향 — 대화가 시작되면 NPC가
// 플레이어 쪽으로 돌아서므로, 돌아선 뒤의 실제 배치와 일치한다.
private void ApplyBestCandidate(List<PlacementCandidate> candidates)
{
if (candidates == null || candidates.Count == 0) return;
if (_speakerTransform == null || Camera.main == null) return;
Vector3 camPos = Camera.main.transform.position;
Vector3 dir = camPos - _speakerTransform.position;
dir.y = 0f;
if (dir.sqrMagnitude < 0.0001f) return;
dir.Normalize();
Vector3 right = Vector3.Cross(dir, Vector3.up);
foreach (var cand in candidates)
{
Vector3 pos = _speakerTransform.position
+ Vector3.up * cand.Height
+ dir * cand.Forward
+ right * cand.Lateral;
Quaternion rot = Quaternion.LookRotation(-dir) * Quaternion.Euler(cand.RotationOffset);
// 벽/지형에 겹치는가
if (Physics.CheckBox(pos, _panelHalfExtents, rot, _obstacleMask, QueryTriggerInteraction.Ignore))
continue;
// 플레이어 눈에서 벽에 가려지는가
if (Physics.Linecast(camPos, pos, _obstacleMask, QueryTriggerInteraction.Ignore))
continue;
ApplyCandidate(cand);
return;
}
// 전부 실패 — 미리 적용해 둔 첫 후보(기본) 그대로 사용
}
private void LateUpdate()
{
if (_speakerTransform == null) return;