2026-07-10 다이얼로그 시스템 대대적 수정
This commit is contained in:
22
Assets/02_Scripts/Communication/Dialog/DialogHudPlacement.cs
Normal file
22
Assets/02_Scripts/Communication/Dialog/DialogHudPlacement.cs
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c42d3e6d9a0d3fc4788ddda76e366167
|
||||
@@ -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; // 선택지 없을 때 자동으로 갈 노드
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9432f3355b50d3d458cff5e56b6c9206
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15b46c322dec78d438a144f875d67d42
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fafb2723eb8f0c54a8ca67baac92d8aa
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user