히든제스처

This commit is contained in:
2026-07-23 13:08:43 +09:00
parent e1c43c9b3d
commit de5fe5b85a
109 changed files with 10034 additions and 135 deletions

View File

@@ -0,0 +1,55 @@
using UnityEngine;
using UnityEngine.Events;
// 대화 중 "건네기/터치" 제스처를 수행하는 오브젝트(꽃다발·김치·손 등)에 붙인다.
// 이 오브젝트가 NPC의 받는 존(_targetTag 태그의 트리거)에 닿으면, 자신의 GestureKey로 히든 분기를 발동시킨다.
//
// 어떤 아이템인지는 GestureKey로 구분된다:
// - 노드가 HiddenGestureKey="kimchi"로 무장 → 김치(GestureKey="kimchi")만 발동, 꽃다발("bouquet")은 무시.
// 그래서 "김치를 줘야 하는데 꽃다발을 주면 이벤트가 안 진행"이 자동으로 보장된다.
//
// 콜라이더가 있는 오브젝트에 붙일 것 (잡는 아이템은 보통 XRGrabInteractable이 있는 루트).
// 존은 무장 여부와 무관하게 항상 닿을 수 있지만, 무장 안 된 순간에 닿으면 아무 일도 안 일어난다.
public class DialogGestureItem : MonoBehaviour
{
[Tooltip("이 오브젝트의 제스처 키. 대화 노드의 HiddenGestureKey와 일치할 때만 발동 (예: kimchi, bouquet, slap)")]
[SerializeField] private string _gestureKey;
[Tooltip("이 태그를 가진 트리거(NPC 받는 존)에 닿아야 발동. 비우면 태그 무시(아무 트리거나)")]
[SerializeField] private string _targetTag = "GiveZone";
[Header("Consume (건넨 뒤 사라지게)")]
[Tooltip("켜면 발동 시 이 오브젝트가 사라진다(건넨 연출). 손처럼 사라지면 안 되는 건 꺼둘 것")]
[SerializeField] private bool _consumeOnGiven = true;
[Tooltip("사라지는 방식: 켜면 완전 파괴(Destroy), 끄면 비활성화(SetActive false — 재사용 가능)")]
[SerializeField] private bool _destroy = true;
[Tooltip("사라지기 직전 호출 (파티클·사운드 연출용)")]
[SerializeField] private UnityEvent _onConsumed;
private void OnTriggerEnter(Collider other)
{
if (!string.IsNullOrEmpty(_targetTag) && !other.CompareTag(_targetTag)) return;
// 노드가 이 키로 무장 중일 때만 true — 그때만 소비한다(엉뚱한 타이밍/엉뚱한 아이템은 안 사라짐).
if (!HiddenBranchResolver.Fire(_gestureKey)) return;
if (_consumeOnGiven)
Consume();
}
private void Consume()
{
// 스크립트가 자식 콜라이더에 붙어 있어도 아이템 전체(Rigidbody 루트)를 처리
var body = GetComponentInParent<Rigidbody>();
var target = body != null ? body.gameObject : gameObject;
_onConsumed?.Invoke();
if (_destroy)
Destroy(target); // XRGrabInteractable도 파괴 시 손에서 자동 해제됨
else
target.SetActive(false); // 비활성화도 XRI가 선택을 취소해 손에서 놓게 됨
}
}

View File

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

View File

@@ -1,25 +0,0 @@
using UnityEngine;
// 히든 분기를 물리 접촉으로 발동시키는 트리거 존 (KimchiHitZone과 같은 방식).
//
// 예) NPC 얼굴에 IsTrigger 콜라이더를 붙이고 GestureKey="slap", RequiredTag="PlayerHand".
// 대화 노드의 HiddenBranch가 설정되고 HiddenGestureKey="slap"인 순간에만
// 이 존을 손으로 치면 히든 분기를 탄다. 그 외 시간엔 쳐도 아무 일도 안 일어난다.
//
// 콜라이더는 항상 켜 둬도 된다 — 발동 여부는 DialogPlayer의 무장 상태가 결정하므로,
// 노드마다 존을 껐다 켰다 할 필요가 없다.
public class DialogGestureZone : MonoBehaviour
{
[Tooltip("이 존이 발동시킬 제스처 키. 대화 노드의 HiddenGestureKey와 일치해야 한다. " +
"노드 쪽 키가 비어 있으면 아무 존이나 발동 가능")]
[SerializeField] private string _gestureKey = "slap";
[Tooltip("이 태그를 가진 콜라이더가 들어와야 발동 (예: 손 컨트롤러). 비우면 태그 무시")]
[SerializeField] private string _requiredTag = "PlayerHand";
private void OnTriggerEnter(Collider other)
{
if (!string.IsNullOrEmpty(_requiredTag) && !other.CompareTag(_requiredTag)) return;
HiddenBranchResolver.Fire(_gestureKey);
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 408261957835e514186f6a6f82e367e1

View File

@@ -45,8 +45,8 @@ public class DialogNode : ScriptableObject
"비우면 히든 분기 없음 — 평소엔 존재하지 않는 것처럼 보인다")]
public DialogNode HiddenBranch;
[Tooltip("히든 분기를 여는 제스처 키 (DialogGestureZone의 Gesture Key와 일치). " +
"비우면 아무 제스처 존이나 발동 가능")]
[Tooltip("히든 분기를 여는 제스처 키 (아이템의 DialogGestureItem의 Gesture Key와 일치). " +
"예: kimchi, bouquet, slap. 비우면 아무 제스처 오브젝트나 발동 가능")]
public string HiddenGestureKey;
[Tooltip("히든 분기를 탔을 때 기록할 선택지 Code (선택지의 Code와 동일한 용도). " +

View File

@@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다.
[ScriptedImporter(8, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
[ScriptedImporter(9, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
internal class DialogGraphImporter : ScriptedImporter
{
public override void OnImportAsset(AssetImportContext ctx)
@@ -128,6 +128,23 @@ public override void OnImportAsset(AssetImportContext ctx)
});
}
}
// 히든 분기 (켜져 있으면) — 목적지 노드 + 제스처 키 + 기록 코드
bool hasHidden = false;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
if (hasHidden)
{
var hiddenDest = GetConnectedNode(gn, DialogLineNode.PORT_HIDDEN_OUT);
dn.HiddenBranch = hiddenDest != null && map.TryGetValue(hiddenDest, out var hiddenDn) ? hiddenDn : null;
string gestureKey = null;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_GESTURE)?.TryGetValue(out gestureKey);
dn.HiddenGestureKey = gestureKey;
string hiddenCode = null;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_CODE)?.TryGetValue(out hiddenCode);
dn.HiddenCode = hiddenCode;
}
}
group.StartNode = map.TryGetValue(firstGraphNode, out var startDn) ? startDn : null;
@@ -178,6 +195,12 @@ static IEnumerable<INode> GetSuccessors(INode node)
for (int i = 0; i < choiceCount; i++)
yield return GetConnectedNode(node, DialogLineNode.ChoiceOutPort(i));
}
// 히든 분기 후속도 도달 가능해야 DialogNode로 생성된다
bool hasHidden = false;
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
if (hasHidden)
yield return GetConnectedNode(node, DialogLineNode.PORT_HIDDEN_OUT);
}
// 출력 실행 포트에 연결된 노드 (없으면 null)

View File

@@ -30,9 +30,13 @@ internal class DialogLineNode : DialogGraphNode
public const string PORT_AFFECTION = "Affection";
public const string PORT_PROGRESS = "Progress";
public const string PORT_QUESTION = "ChoiceQuestion";
public const string PORT_HIDDEN_OUT = "HiddenOut";
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
public const string OPTION_EVENT_KEY = "EventKey";
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
public const string OPTION_EVENT_KEY = "EventKey";
public const string OPTION_HIDDEN_BRANCH = "HasHiddenBranch";
public const string OPTION_HIDDEN_GESTURE = "HiddenGestureKey";
public const string OPTION_HIDDEN_CODE = "HiddenCode";
// 선택지별 포트 이름 규칙 (임포터와 공유)
public static string ChoiceTextPort(int i) => $"Choice{i}Text";
@@ -51,6 +55,22 @@ protected override void OnDefineOptions(IOptionDefinitionContext context)
.WithDisplayName("Event Key")
.WithTooltip("비우면 없음. 이 노드 재생 시 DialogPlayer의 같은 Key 이벤트 호출 (영문 키 권장)")
.Delayed();
context.AddOption<bool>(OPTION_HIDDEN_BRANCH)
.WithDisplayName("Hidden Branch")
.WithTooltip("켜면 맨 아래에 'Hidden →' 출력 포트가 생긴다. 이 대사 재생 중 특정 제스처(싸대기/터치)를 하면 " +
"Next/선택지 대신 그 포트로 연결된 노드로 몰래 분기한다")
.WithDefaultValue(false);
context.AddOption<string>(OPTION_HIDDEN_GESTURE)
.WithDisplayName("Hidden Gesture Key")
.WithTooltip("히든 분기를 여는 제스처 키 (DialogGestureZone의 Gesture Key와 일치). 비우면 아무 제스처 존이나 발동")
.Delayed();
context.AddOption<string>(OPTION_HIDDEN_CODE)
.WithDisplayName("Hidden Code")
.WithTooltip("히든 분기를 탔을 때 기록할 선택지 Code (이후 대화 조건 RequiredChoiceCodes에서 검사). 비우면 기록 안 함")
.Delayed();
}
protected override void OnDefinePorts(IPortDefinitionContext context)
@@ -88,23 +108,31 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
{
// 선형 진행
AddExecOutput(context, EXEC_OUT, string.Empty);
return;
}
// 가변 N지선다
// (string 포트는 GraphToolkit 기본 에디터의 IME 중복입력 버그가 있어 DialogText로 통일)
context.AddInputPort<DialogText>(PORT_QUESTION).WithDisplayName("Choice Question").Build();
for (int i = 0; i < choiceCount; i++)
else
{
context.AddInputPort<DialogText>(ChoiceTextPort(i))
.WithDisplayName($"Choice {i + 1} Text")
.Build();
context.AddInputPort<string>(ChoiceCodePort(i))
.WithDisplayName($"Choice {i + 1} Code")
.Build();
AddExecOutput(context, ChoiceOutPort(i), $"Choice {i + 1} →");
// 가변 N지선다
// (string 포트는 GraphToolkit 기본 에디터의 IME 중복입력 버그가 있어 DialogText로 통일)
context.AddInputPort<DialogText>(PORT_QUESTION).WithDisplayName("Choice Question").Build();
for (int i = 0; i < choiceCount; i++)
{
context.AddInputPort<DialogText>(ChoiceTextPort(i))
.WithDisplayName($"Choice {i + 1} Text")
.Build();
context.AddInputPort<string>(ChoiceCodePort(i))
.WithDisplayName($"Choice {i + 1} Code")
.Build();
AddExecOutput(context, ChoiceOutPort(i), $"Choice {i + 1} →");
}
}
// 히든 분기 출력 — 켜져 있으면 선형/선택지 관계없이 맨 아래에 붙는다.
// 이 포트에 연결된 노드가 DialogNode.HiddenBranch가 된다.
bool hasHidden = false;
GetNodeOptionByName(OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
if (hasHidden)
AddExecOutput(context, PORT_HIDDEN_OUT, "Hidden →");
}
}
}

View File

@@ -48,11 +48,13 @@ public static void Disarm()
}
// 제스처 존이 호출. 무장 중이고 key가 맞을 때만 1회 발동.
public static void Fire(string key)
// 반환: 이번 호출로 실제 발동했으면 true (호출 측이 "그때만" 아이템 소비 등에 쓸 수 있게).
public static bool Fire(string key)
{
if (!Armed || Fired) return;
if (!string.IsNullOrEmpty(_armedKey) && _armedKey != key) return;
if (!Armed || Fired) return false;
if (!string.IsNullOrEmpty(_armedKey) && _armedKey != key) return false;
Fired = true;
FiredEvent?.Invoke();
return true;
}
}

View File

@@ -5,14 +5,24 @@
// 대화 중(선택 메뉴 포함) 월드 상호작용을 차단한다 — UI 상호작용은 유지.
// XR Origin(리그 루트)에 붙이면 자식의 모든 인터랙터를 찾아서, 대화가 시작될 때
// Interaction Layer를 None으로 바꿔 3D 인터랙터블과의 호버/선택/활성화를 막는다.
// Interaction Layer를 (기본) None으로 바꿔 3D 인터랙터블과의 호버/선택/활성화를 막는다.
// 캔버스 UI(선택지 버튼 등)는 인터랙션 레이어를 거치지 않고 XRUIInputModule로
// 처리되므로 영향받지 않는다. 대화가 끝나면 원래 레이어로 복원.
//
// _keepActiveDuringDialog에 레이어를 넣으면, 대화 중에도 그 레이어의 인터랙터블만 상호작용된다.
// 예: 대화 중에 꽃다발을 잡아서 NPC에게 건네야 하는 상황 — 꽃다발(XRGrabInteractable)을 그 레이어에
// 올려두면, NPC 대화는 계속 막힌 채 꽃다발만 잡고/들고 있을 수 있다. (대화 시작 시 이미 들고 있던
// 오브젝트가 레이어 차단으로 손에서 떨어지는 것도 막아준다.) 건넸는지 판정은 아이템의 DialogGestureItem이
// NPC의 받는 존(태그) 트리거에 닿는 순수 물리 트리거로 처리하므로 이 블로커와 무관하게 동작한다.
public class DialogInteractionBlocker : MonoBehaviour
{
[Tooltip("차단에서 제외할 인터랙터 (예: 대화 중에도 텔레포트를 허용하고 싶을 때 텔레포트 레이 인터랙터)")]
[SerializeField] private List<XRBaseInteractor> _exclude = new();
[Tooltip("대화 중에도 살려둘 인터랙션 레이어. 여기 든 레이어의 인터랙터블(예: 대화 중 건네야 하는 꽃다발)은 " +
"대화 중에도 잡기/들기가 된다. 비우면(None) 기존처럼 전부 차단")]
[SerializeField] private InteractionLayerMask _keepActiveDuringDialog;
// 차단 직전의 원래 레이어 — 복원용. 비어있지 않으면 차단 중이라는 뜻.
private readonly Dictionary<XRBaseInteractor, InteractionLayerMask> _saved = new();
@@ -43,7 +53,7 @@ private void Block()
{
if (_exclude.Contains(interactor)) continue;
_saved[interactor] = interactor.interactionLayers;
interactor.interactionLayers = 0; // None — 3D 인터랙터블 전부 차단
interactor.interactionLayers = _keepActiveDuringDialog; // 지정 레이어만 유지(비우면 None=전부 차단)
}
}