3D 잔재 제거
This commit is contained in:
@@ -1,55 +0,0 @@
|
||||
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가 선택을 취소해 손에서 놓게 됨
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c11477ec44fef124bb7279924beb66af
|
||||
@@ -25,17 +25,11 @@ public class DialogNode : ScriptableObject
|
||||
public List<DialogChoice> Choices; // 있으면 플레이어 선택 대기
|
||||
|
||||
[Header("Hidden Branch")]
|
||||
[Tooltip("이 대사가 재생되는 동안 특정 행동(제스처)을 하면 Next/선택지 대신 이 노드로 몰래 분기. " +
|
||||
[Tooltip("이 대사가 재생되는 동안 특정 행동(부위 터치·증거품 제출 등)을 하면 " +
|
||||
"Next/선택지 대신 몰래 분기한다. 키마다 다른 목적지를 줄 수 있고, " +
|
||||
"Key가 빈 항목은 나머지 전부를 받는 catch-all이 된다. " +
|
||||
"비우면 히든 분기 없음 — 평소엔 존재하지 않는 것처럼 보인다")]
|
||||
public DialogNode HiddenBranch;
|
||||
|
||||
[Tooltip("히든 분기를 여는 제스처 키 (아이템의 DialogGestureItem의 Gesture Key와 일치). " +
|
||||
"예: kimchi, bouquet, slap. 비우면 아무 제스처 오브젝트나 발동 가능")]
|
||||
public string HiddenGestureKey;
|
||||
|
||||
[Tooltip("히든 분기를 탔을 때 기록할 선택지 Code (선택지의 Code와 동일한 용도). " +
|
||||
"비우면 기록 안 함")]
|
||||
public string HiddenCode;
|
||||
public List<HiddenBranch> HiddenBranches = new();
|
||||
|
||||
[Header("Affection Branch")]
|
||||
[Tooltip("켜면 이 노드는 대사 없이 호감도 조건만 검사해 즉시 라우팅한다: " +
|
||||
|
||||
@@ -24,7 +24,7 @@ public class DialogPlayer : MonoBehaviour
|
||||
|
||||
// ── 전역 대화 진행 신호 (대화 중 월드 상호작용 차단용) ────────
|
||||
// 선택 메뉴 단계부터 대사 종료까지, 어느 NPC든 대화가 진행 중이면 true.
|
||||
// DialogInteractionBlocker가 구독해서 인터랙터를 잠근다.
|
||||
// 이동 버튼·클릭 인터랙션 등이 AnyActiveChanged를 구독해 대화 중 입력을 잠그면 된다.
|
||||
public static bool IsAnyActive => _activeCount > 0;
|
||||
public static event Action<bool> AnyActiveChanged;
|
||||
private static int _activeCount;
|
||||
@@ -103,7 +103,7 @@ public async Awaitable Play()
|
||||
}
|
||||
}
|
||||
|
||||
// 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (EventZoneTrigger 등 UnityEvent 연결용).
|
||||
// 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (버튼·컷신 등 UnityEvent 연결용).
|
||||
// DB에 등록된 비트면 그 설정(진행도 보상 등)을 그대로 쓰고, 없으면 임시 비트로 재생한다.
|
||||
// 완료 여부는 검사하지 않으므로(강제) 1회성이 필요하면 호출하는 쪽(존의 Trigger Once)에서 보장할 것.
|
||||
public void PlayGroup(DialogGroup group)
|
||||
@@ -204,26 +204,24 @@ private async Awaitable PlayBeat(StoryBeat beat)
|
||||
}
|
||||
routingHops = 0; // 실제 대사 노드에 도달 — 카운터 리셋
|
||||
|
||||
// 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 제스처 감시를 무장한다.
|
||||
// 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 발동 감시를 무장한다.
|
||||
// (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작)
|
||||
bool armed = node.HiddenBranch != null;
|
||||
if (armed) HiddenBranchResolver.Arm(node.HiddenGestureKey);
|
||||
bool armed = node.HiddenBranches != null && node.HiddenBranches.Count > 0;
|
||||
if (armed) HiddenBranchResolver.Arm(node.HiddenBranches);
|
||||
try
|
||||
{
|
||||
bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 제스처 감시 포함)
|
||||
bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 발동 감시 포함)
|
||||
|
||||
if (diverted)
|
||||
{
|
||||
RecordHiddenChoice(node);
|
||||
node = node.HiddenBranch; // 대사 도중 제스처 발동 → 몰래 분기
|
||||
node = TakeHiddenBranch(node); // 대사 도중 발동 → 몰래 분기
|
||||
}
|
||||
else if (node.Choices != null && node.Choices.Count > 0)
|
||||
{
|
||||
int picked = await WaitForChoice(node); // 메뉴(무장 시 제스처 감시 포함)
|
||||
int picked = await WaitForChoice(node); // 메뉴(무장 시 발동 감시 포함)
|
||||
if (picked == DivertIndex)
|
||||
{
|
||||
RecordHiddenChoice(node);
|
||||
node = node.HiddenBranch; // 메뉴 도중 제스처 발동 → 메뉴엔 없던 히든 분기
|
||||
node = TakeHiddenBranch(node); // 메뉴 도중 발동 → 메뉴엔 없던 히든 분기
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -341,7 +339,7 @@ private async Awaitable<bool> PlayNode(DialogNode node)
|
||||
}
|
||||
|
||||
// 진행은 항상 플레이어 입력 대기.
|
||||
// 히든 분기가 무장된 노드는 대기 중 제스처를 함께 감시(레이스)한다.
|
||||
// 히든 분기가 무장된 노드는 대기 중 히든 발동을 함께 감시(레이스)한다.
|
||||
if (HiddenBranchResolver.Armed)
|
||||
return await WaitAdvanceOrDivert();
|
||||
|
||||
@@ -393,12 +391,26 @@ private void RecordChoice(DialogNode node, int index)
|
||||
StoryManager.Instance.RecordChoice(code);
|
||||
}
|
||||
|
||||
// 히든 분기 기록: 노드의 HiddenCode를 선택지 Code와 동일하게 StoryState에 영구 기록
|
||||
private void RecordHiddenChoice(DialogNode node)
|
||||
// 발동한 히든 분기로 이동한다. 어느 분기가 발동했는지는 Resolver가 키를 보고 이미 골라 뒀다.
|
||||
// (Disarm보다 먼저 불려야 하므로 반드시 무장 구간 안에서 호출할 것)
|
||||
private DialogNode TakeHiddenBranch(DialogNode node)
|
||||
{
|
||||
string code = DialogVariables.Format(node.HiddenCode); // {token} 치환
|
||||
int index = HiddenBranchResolver.FiredIndex;
|
||||
if (node.HiddenBranches == null || index < 0 || index >= node.HiddenBranches.Count)
|
||||
{
|
||||
// 정상 흐름에선 올 수 없다(발동 = 매칭 성공). 와도 대화가 끊기지 않게 원래 흐름으로.
|
||||
Debug.LogWarning($"[DialogPlayer] 히든 분기 인덱스가 범위를 벗어남: {index}");
|
||||
return node.Next;
|
||||
}
|
||||
|
||||
var branch = node.HiddenBranches[index];
|
||||
|
||||
// 선택지 Code와 동일하게 StoryState에 영구 기록
|
||||
string code = DialogVariables.Format(branch.Code); // {token} 치환
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
StoryManager.Instance.RecordChoice(code);
|
||||
|
||||
return branch.Destination;
|
||||
}
|
||||
|
||||
// 히든 분기 신호값 — 선택지 인덱스(0..n-1)와 절대 겹치지 않는 값.
|
||||
@@ -416,11 +428,11 @@ private async Awaitable<int> WaitForChoice(DialogNode node)
|
||||
if (!HiddenBranchResolver.Armed)
|
||||
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
|
||||
|
||||
// 무장된 노드: 메뉴가 떠 있는 동안 제스처가 들어오면 메뉴를 취소하고 히든으로 분기.
|
||||
// 무장된 노드: 메뉴가 떠 있는 동안 발동이 들어오면 메뉴를 취소하고 히든으로 분기.
|
||||
if (HiddenBranchResolver.Fired)
|
||||
return DivertIndex; // 메뉴가 뜨기 전에 이미 발동한 경우
|
||||
|
||||
void OnFired() => ChoiceHud.Instance.CancelPending(); // 제스처 → 메뉴 즉시 취소
|
||||
void OnFired() => ChoiceHud.Instance.CancelPending(); // 발동 → 메뉴 즉시 취소
|
||||
HiddenBranchResolver.FiredEvent += OnFired;
|
||||
try
|
||||
{
|
||||
@@ -428,7 +440,7 @@ private async Awaitable<int> WaitForChoice(DialogNode node)
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 우리가 제스처로 취소한 것이면 히든 분기, 아니면(씬 전환 등) 위로 전파
|
||||
// 우리가 히든 발동으로 취소한 것이면 히든 분기, 아니면(씬 전환 등) 위로 전파
|
||||
if (HiddenBranchResolver.Fired) return DivertIndex;
|
||||
throw;
|
||||
}
|
||||
@@ -438,8 +450,8 @@ private async Awaitable<int> WaitForChoice(DialogNode node)
|
||||
}
|
||||
}
|
||||
|
||||
// 노드 대기 + 히든 제스처 감시(레이스). 무장된 노드에서만 호출된다.
|
||||
// 반환: true = 대기 도중 제스처 발동(→히든 분기), false = 평범하게 진행(입력)
|
||||
// 노드 대기 + 히든 발동 감시(레이스). 무장된 노드에서만 호출된다.
|
||||
// 반환: true = 대기 도중 히든 발동(→히든 분기), false = 평범하게 진행(입력)
|
||||
private async Awaitable<bool> WaitAdvanceOrDivert()
|
||||
{
|
||||
var im = InputManager.Instance;
|
||||
@@ -454,7 +466,7 @@ private async Awaitable<bool> WaitAdvanceOrDivert()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (HiddenBranchResolver.Fired) return true; // 제스처 발동 → 히든 분기
|
||||
if (HiddenBranchResolver.Fired) return true; // 히든 발동 → 히든 분기
|
||||
if (advance) return false; // 진행 입력 → 평범하게 진행
|
||||
if (timeLeft >= 0f)
|
||||
{
|
||||
@@ -503,15 +515,16 @@ private async Awaitable WaitForAdvanceInput()
|
||||
}
|
||||
}
|
||||
|
||||
//테스트용
|
||||
//테스트용 — 캐릭터를 클릭하면 대화 시작 (Collider2D가 있어야 잡힌다)
|
||||
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))
|
||||
var world = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
|
||||
var hit = Physics2D.OverlapPoint(world);
|
||||
if (hit != null && hit.transform.IsChildOf(transform))
|
||||
{
|
||||
Debug.Log("캐릭터 클릭");
|
||||
_ = Play(); // 테스트용 fire-and-forget (Update는 await 불가)
|
||||
|
||||
46
Assets/02_Scripts/Communication/Dialog/DialogTouchZone.cs
Normal file
46
Assets/02_Scripts/Communication/Dialog/DialogTouchZone.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
// 캐릭터의 특정 부위(손·머리·어깨 등)에 붙이는 클릭 판정 영역.
|
||||
// 대화 노드가 히든 분기로 무장한 동안 이 영역을 마우스로 누르면 그 분기가 열린다.
|
||||
//
|
||||
// 어느 부위인지는 Key로 구분된다:
|
||||
// - 노드에 Key="head" 분기가 있으면 머리 존(Key="head")이 그 분기를 연다.
|
||||
// - 어디에도 안 걸리면 노드의 catch-all 분기(Key 빈 항목)로 가고, 그것도 없으면 아무 일도 안 일어난다.
|
||||
//
|
||||
// 무장 중이 아니면 클릭 판정 자체를 하지 않는다 → 평소엔 존재하지 않는 것처럼 동작(=히든).
|
||||
//
|
||||
// 배치: Collider2D가 있는 오브젝트에 붙일 것 (보통 캐릭터의 자식).
|
||||
// isTrigger 여부는 상관없다 — 충돌이 아니라 점 판정만 쓴다.
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class DialogTouchZone : MonoBehaviour
|
||||
{
|
||||
[Tooltip("이 부위의 키. 대화 노드의 히든 분기 Key와 일치할 때 그 분기를 연다 (예: head, hand, cheek)")]
|
||||
[SerializeField] private string _key;
|
||||
|
||||
[Tooltip("실제로 분기가 열렸을 때 호출 (연출·사운드용). 발동하지 않은 클릭에는 불리지 않는다")]
|
||||
[SerializeField] private UnityEvent _onTouched;
|
||||
|
||||
private Collider2D _collider;
|
||||
|
||||
private void Awake() => _collider = GetComponent<Collider2D>();
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 무장 안 된 순간에는 클릭을 아예 보지 않는다
|
||||
if (!HiddenBranchResolver.Armed) return;
|
||||
|
||||
if (Mouse.current == null || !Mouse.current.leftButton.wasPressedThisFrame) return;
|
||||
|
||||
var cam = Camera.main;
|
||||
if (cam == null || _collider == null) return;
|
||||
|
||||
Vector2 world = cam.ScreenToWorldPoint(Mouse.current.position.ReadValue());
|
||||
if (!_collider.OverlapPoint(world)) return;
|
||||
|
||||
// 이 노드에 맞는 분기가 있을 때만 true (무장 구간당 1회)
|
||||
if (HiddenBranchResolver.Fire(_key))
|
||||
_onTouched?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4eb9e02881f5bc04c955f656e949db78
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Unity.GraphToolkit.Editor;
|
||||
using UnityEditor;
|
||||
@@ -8,6 +9,9 @@ namespace DinoLove.Dialog.GraphTool.Editor
|
||||
// 기존 Communication/Dialog 시스템(DialogGroup / DialogNode / DialogChoice)을
|
||||
// 노드 그래프로 저작하기 위한 에디터 전용 그래프 타입이다.
|
||||
// 임포트 시 DialogGraphImporter가 이 그래프를 DialogGroup 에셋으로 변환한다.
|
||||
// [Serializable]: GraphToolkit이 이 그래프 인스턴스를 .dlg 안에 [SerializeReference]로 저장한다.
|
||||
// 없으면 "missing the [Serializable] attribute" 경고가 뜨고, 필드를 추가해도 직렬화되지 않는다.
|
||||
[Serializable]
|
||||
[Graph(AssetExtension)]
|
||||
internal class DialogGraph : Graph
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor
|
||||
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
|
||||
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
|
||||
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다.
|
||||
[ScriptedImporter(15, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
|
||||
[ScriptedImporter(16, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
|
||||
internal class DialogGraphImporter : ScriptedImporter
|
||||
{
|
||||
public override void OnImportAsset(AssetImportContext ctx)
|
||||
@@ -53,6 +53,7 @@ public override void OnImportAsset(AssetImportContext ctx)
|
||||
|
||||
var dn = ScriptableObject.CreateInstance<DialogNode>();
|
||||
dn.Choices = new List<DialogChoice>();
|
||||
dn.HiddenBranches = new List<HiddenBranch>();
|
||||
map[gn] = dn;
|
||||
order.Add(gn);
|
||||
|
||||
@@ -144,21 +145,19 @@ public override void OnImportAsset(AssetImportContext ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// 히든 분기 (켜져 있으면) — 목적지 노드 + 제스처 키 + 기록 코드
|
||||
bool hasHidden = false;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
|
||||
if (hasHidden)
|
||||
// 히든 분기 — 분기마다 [여는 키 + 목적지 노드 + 기록 코드]
|
||||
int hiddenCount = 0;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_COUNT)?.TryGetValue(out hiddenCount);
|
||||
|
||||
for (int i = 0; i < hiddenCount; i++)
|
||||
{
|
||||
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;
|
||||
var hiddenDest = GetConnectedNode(gn, DialogLineNode.HiddenOutPort(i));
|
||||
dn.HiddenBranches.Add(new HiddenBranch
|
||||
{
|
||||
Key = GetInputPortValue<string>(gn.GetInputPortByName(DialogLineNode.HiddenKeyPort(i))),
|
||||
Code = GetInputPortValue<string>(gn.GetInputPortByName(DialogLineNode.HiddenCodePort(i))),
|
||||
Destination = hiddenDest != null && map.TryGetValue(hiddenDest, out var hiddenDn) ? hiddenDn : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,10 +192,11 @@ static IEnumerable<INode> GetSuccessors(INode node)
|
||||
}
|
||||
|
||||
// 히든 분기 후속도 도달 가능해야 DialogNode로 생성된다
|
||||
bool hasHidden = false;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
|
||||
if (hasHidden)
|
||||
yield return GetConnectedNode(node, DialogLineNode.PORT_HIDDEN_OUT);
|
||||
int hiddenCount = 0;
|
||||
line.GetNodeOptionByName(DialogLineNode.OPTION_HIDDEN_COUNT)?.TryGetValue(out hiddenCount);
|
||||
|
||||
for (int i = 0; i < hiddenCount; i++)
|
||||
yield return GetConnectedNode(node, DialogLineNode.HiddenOutPort(i));
|
||||
}
|
||||
|
||||
// 출력 실행 포트에 연결된 노드 (없으면 null)
|
||||
|
||||
@@ -24,18 +24,20 @@ 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_HIDDEN_BRANCH = "HasHiddenBranch";
|
||||
public const string OPTION_HIDDEN_GESTURE = "HiddenGestureKey";
|
||||
public const string OPTION_HIDDEN_CODE = "HiddenCode";
|
||||
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
|
||||
public const string OPTION_HIDDEN_COUNT = "HiddenBranchCount";
|
||||
|
||||
// 선택지별 포트 이름 규칙 (임포터와 공유)
|
||||
public static string ChoiceTextPort(int i) => $"Choice{i}Text";
|
||||
public static string ChoiceCodePort(int i) => $"Choice{i}Code";
|
||||
public static string ChoiceOutPort(int i) => $"Choice{i}Out";
|
||||
|
||||
// 히든 분기별 포트 이름 규칙 (임포터와 공유)
|
||||
public static string HiddenKeyPort(int i) => $"Hidden{i}Key";
|
||||
public static string HiddenCodePort(int i) => $"Hidden{i}Code";
|
||||
public static string HiddenOutPort(int i) => $"Hidden{i}Out";
|
||||
|
||||
protected override void OnDefineOptions(IOptionDefinitionContext context)
|
||||
{
|
||||
context.AddOption<int>(OPTION_CHOICE_COUNT)
|
||||
@@ -44,20 +46,12 @@ protected override void OnDefineOptions(IOptionDefinitionContext context)
|
||||
.WithDefaultValue(0)
|
||||
.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. 비우면 기록 안 함")
|
||||
context.AddOption<int>(OPTION_HIDDEN_COUNT)
|
||||
.WithDisplayName("Hidden Branch Count")
|
||||
.WithTooltip("이 대사에 달 히든 분기 개수. 0이면 히든 분기 없음. " +
|
||||
"1 이상이면 분기마다 [Key · Code 입력 포트]와 'Hidden N →' 출력 포트가 생긴다. " +
|
||||
"재생 중 그 Key로 발동하면 Next/선택지 대신 해당 포트로 몰래 분기한다")
|
||||
.WithDefaultValue(0)
|
||||
.Delayed();
|
||||
}
|
||||
|
||||
@@ -106,12 +100,24 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
|
||||
}
|
||||
}
|
||||
|
||||
// 히든 분기 출력 — 켜져 있으면 선형/선택지 관계없이 맨 아래에 붙는다.
|
||||
// 이 포트에 연결된 노드가 DialogNode.HiddenBranch가 된다.
|
||||
bool hasHidden = false;
|
||||
GetNodeOptionByName(OPTION_HIDDEN_BRANCH)?.TryGetValue(out hasHidden);
|
||||
if (hasHidden)
|
||||
AddExecOutput(context, PORT_HIDDEN_OUT, "Hidden →");
|
||||
// 히든 분기 — 선형/선택지 관계없이 맨 아래에 붙는다.
|
||||
// 각 포트에 연결된 노드가 DialogNode.HiddenBranches[i].Destination이 된다.
|
||||
int hiddenCount = 0;
|
||||
GetNodeOptionByName(OPTION_HIDDEN_COUNT)?.TryGetValue(out hiddenCount);
|
||||
|
||||
for (int i = 0; i < hiddenCount; i++)
|
||||
{
|
||||
context.AddInputPort<string>(HiddenKeyPort(i))
|
||||
.WithDisplayName($"Hidden {i + 1} Key")
|
||||
.WithTooltip("이 분기를 여는 키 (터치 존·증거품 등 발동시키는 쪽의 키와 일치). " +
|
||||
"비우면 catch-all — 다른 키에 안 걸린 발동을 전부 받는다(오답 처리용)")
|
||||
.Build();
|
||||
context.AddInputPort<string>(HiddenCodePort(i))
|
||||
.WithDisplayName($"Hidden {i + 1} Code")
|
||||
.WithTooltip("이 분기를 탔을 때 기록할 코드. 비우면 기록 안 함")
|
||||
.Build();
|
||||
AddExecOutput(context, HiddenOutPort(i), $"Hidden {i + 1} →");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
Assets/02_Scripts/Communication/Dialog/HiddenBranch.cs
Normal file
23
Assets/02_Scripts/Communication/Dialog/HiddenBranch.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// 히든 분기 하나 — "무엇을 하면(Key), 어디로 가고(Destination), 뭘 기록하는가(Code)".
|
||||
// DialogChoice의 히든 버전이라고 보면 된다. 차이는 플레이어에게 목록이 보이지 않는다는 것뿐.
|
||||
//
|
||||
// 한 노드에 여러 개를 달 수 있다:
|
||||
// Key="knife" → 진짜 증거를 들이댔을 때의 분기
|
||||
// Key="photo" → 다른 증거를 들이댔을 때의 다른 반응
|
||||
// Key="" → 위 어디에도 안 걸린 나머지 전부 (오답 페널티 등)
|
||||
[Serializable]
|
||||
public class HiddenBranch
|
||||
{
|
||||
[Tooltip("이 분기를 여는 키 (터치 존·증거품 등 발동시키는 쪽의 키와 일치). 예: head, hand, knife. " +
|
||||
"비우면 catch-all — 다른 키에 안 걸린 모든 발동을 받는다")]
|
||||
public string Key;
|
||||
|
||||
[Tooltip("이 분기로 들어왔을 때 갈 노드. 비어 있으면 이 분기는 발동하지 않는다")]
|
||||
public DialogNode Destination;
|
||||
|
||||
[Tooltip("이 분기를 탔을 때 기록할 코드 (선택지 Code와 동일한 용도). 비우면 기록 안 함")]
|
||||
public string Code;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 409a7546d9339b745aced834ccd9f252
|
||||
@@ -1,22 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// 히든 분기 감시 허브 (씬 오브젝트 불필요, 순수 static).
|
||||
//
|
||||
// 대화 노드가 재생되는 동안 DialogPlayer가 Arm(key)로 무장하고, 끝나면 Disarm()한다.
|
||||
// 그 창(무장 구간) 안에서 DialogGestureZone이 Fire(key)를 호출하면 Fired가 켜지고,
|
||||
// DialogPlayer가 이를 감지해 Next(또는 선택지) 대신 노드의 HiddenBranch로 몰래 분기한다.
|
||||
// 대화 노드가 재생되는 동안 DialogPlayer가 Arm(분기 목록)으로 무장하고, 끝나면 Disarm()한다.
|
||||
// 그 창(무장 구간) 안에서 누군가 Fire(key)를 호출하면 키에 맞는 분기가 골라지고,
|
||||
// DialogPlayer가 이를 감지해 Next(또는 선택지) 대신 그 분기로 몰래 이동한다.
|
||||
// Fire를 호출하는 쪽은 캐릭터 부위 터치 존, 증거품 제출 UI 등 무엇이든 될 수 있다.
|
||||
//
|
||||
// 무장돼 있지 않으면 어떤 Fire도 무시된다 → 평소엔 존재하지 않는 것처럼 동작(=히든).
|
||||
public static class HiddenBranchResolver
|
||||
{
|
||||
private static string _armedKey;
|
||||
// 지금 무장된 노드의 히든 분기 목록 (null이면 무장 해제 상태)
|
||||
private static IReadOnlyList<HiddenBranch> _branches;
|
||||
|
||||
// 지금 히든 분기를 받을 수 있는 상태인가 (노드 재생 중)
|
||||
public static bool Armed { get; private set; }
|
||||
public static bool Armed => _branches != null;
|
||||
|
||||
// 이번 무장 구간에서 제스처가 발동했는가
|
||||
public static bool Fired { get; private set; }
|
||||
// 이번 무장 구간에서 발동한 분기의 인덱스 (-1이면 아직 발동 안 함)
|
||||
public static int FiredIndex { get; private set; } = -1;
|
||||
|
||||
// 이번 무장 구간에서 발동했는가
|
||||
public static bool Fired => FiredIndex >= 0;
|
||||
|
||||
// 발동 즉시 알림 — 떠 있는 선택지 메뉴를 그 자리에서 취소시키는 용도
|
||||
public static event Action FiredEvent;
|
||||
@@ -25,35 +31,59 @@ public static class HiddenBranchResolver
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetStatics()
|
||||
{
|
||||
_armedKey = null;
|
||||
Armed = false;
|
||||
Fired = false;
|
||||
_branches = null;
|
||||
FiredIndex = -1;
|
||||
FiredEvent = null;
|
||||
}
|
||||
|
||||
// 노드 진입 시 무장. key가 비면 아무 제스처 존이나 발동시킬 수 있다.
|
||||
public static void Arm(string key)
|
||||
// 노드 진입 시 무장.
|
||||
public static void Arm(IReadOnlyList<HiddenBranch> branches)
|
||||
{
|
||||
_armedKey = key;
|
||||
Armed = true;
|
||||
Fired = false;
|
||||
_branches = branches;
|
||||
FiredIndex = -1;
|
||||
}
|
||||
|
||||
// 노드 종료 시 해제.
|
||||
public static void Disarm()
|
||||
{
|
||||
Armed = false;
|
||||
_armedKey = null;
|
||||
Fired = false;
|
||||
_branches = null;
|
||||
FiredIndex = -1;
|
||||
}
|
||||
|
||||
// 제스처 존이 호출. 무장 중이고 key가 맞을 때만 1회 발동.
|
||||
// 반환: 이번 호출로 실제 발동했으면 true (호출 측이 "그때만" 아이템 소비 등에 쓸 수 있게).
|
||||
// 발동시키는 쪽(터치 존·증거품 UI 등)이 호출. 무장 중이고 키가 맞을 때만 1회 발동.
|
||||
//
|
||||
// 매칭 규칙: 키가 정확히 일치하는 분기가 최우선, 없으면 Key가 빈 분기(catch-all).
|
||||
// 덕분에 "정답 키는 각자 분기로, 나머지는 전부 오답 분기로"가 자연스럽게 나온다.
|
||||
// Destination이 비어 있는 분기는 후보에서 제외한다(아무 데도 못 가는 분기로 새는 것 방지).
|
||||
//
|
||||
// 반환: 이번 호출로 실제 발동했으면 true (호출 측이 "그때만" 연출·아이템 소비 등에 쓸 수 있게).
|
||||
public static bool Fire(string key)
|
||||
{
|
||||
if (!Armed || Fired) return false;
|
||||
if (!string.IsNullOrEmpty(_armedKey) && _armedKey != key) return false;
|
||||
Fired = true;
|
||||
if (_branches == null || FiredIndex >= 0) return false;
|
||||
|
||||
int exact = -1;
|
||||
int catchAll = -1;
|
||||
|
||||
for (int i = 0; i < _branches.Count; i++)
|
||||
{
|
||||
var branch = _branches[i];
|
||||
if (branch == null || branch.Destination == null) continue;
|
||||
|
||||
if (string.IsNullOrEmpty(branch.Key))
|
||||
{
|
||||
if (catchAll < 0) catchAll = i; // 첫 catch-all만 사용
|
||||
}
|
||||
else if (branch.Key == key)
|
||||
{
|
||||
exact = i;
|
||||
break; // 정확 일치는 더 볼 것 없음
|
||||
}
|
||||
}
|
||||
|
||||
int picked = exact >= 0 ? exact : catchAll;
|
||||
if (picked < 0) return false; // 맞는 분기 없음 — 아무 일도 안 일어남
|
||||
|
||||
FiredIndex = picked;
|
||||
FiredEvent?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// 보이스 진폭에 따라 입 관련 블렌드셰이프 그룹의 weight를 직접 제어
|
||||
// LateUpdate에서 갱신해 Animator가 같은 프레임에 0으로 세팅한 값을 덮어씀
|
||||
[RequireComponent(typeof(CharacterVoiceObject))]
|
||||
public class LipSync : MonoBehaviour
|
||||
{
|
||||
[Serializable]
|
||||
private struct LipShape
|
||||
{
|
||||
public string Name;
|
||||
[Range(0f, 100f)] public float MaxWeight; // amplitude=1일 때 도달할 weight
|
||||
}
|
||||
|
||||
[Header("Refs")]
|
||||
[SerializeField] private SkinnedMeshRenderer _meshRenderer;
|
||||
|
||||
// BMAC_OpenMouse_Big 클립의 입 관련 셰이프 프리셋
|
||||
[Header("Mouth Preset (입 최대 시 weight)")]
|
||||
[SerializeField] private LipShape[] _shapes =
|
||||
{
|
||||
new() { Name = "Expression_SurpriesedMouth", MaxWeight = 50f },
|
||||
new() { Name = "Expression_MouthSad_L", MaxWeight = 10f },
|
||||
new() { Name = "Expression_MouthSad_R", MaxWeight = 10f },
|
||||
new() { Name = "Expression_MouthWide_L", MaxWeight = 30f },
|
||||
new() { Name = "Expression_MouthWide_R", MaxWeight = 30f },
|
||||
new() { Name = "Expression_LipsOh", MaxWeight = 100f },
|
||||
new() { Name = "Expression_LipsO", MaxWeight = 5f },
|
||||
};
|
||||
|
||||
[Header("Tuning")]
|
||||
[SerializeField, Range(0f, 20f)] private float _amplitudeScale = 6f; // RMS → 0~1 매핑 배수
|
||||
[SerializeField, Range(0f, 0.05f)] private float _noiseFloor = 0.005f;
|
||||
[SerializeField, Range(0f, 30f)] private float _smoothingSpeed = 15f;
|
||||
[SerializeField] private int _sampleSize = 256;
|
||||
|
||||
private AudioSource _audioSource;
|
||||
private int[] _indices;
|
||||
private float[] _sampleBuffer;
|
||||
private float _currentAmplitude;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
var voiceObj = GetComponent<CharacterVoiceObject>();
|
||||
_audioSource = voiceObj != null ? voiceObj.VoiceSource : null;
|
||||
|
||||
// 메시 자동 탐색 — 첫 번째 셰이프 이름을 가진 SkinnedMeshRenderer 사용
|
||||
if (_meshRenderer == null && _shapes.Length > 0)
|
||||
{
|
||||
string probe = _shapes[0].Name;
|
||||
foreach (var smr in GetComponentsInChildren<SkinnedMeshRenderer>(true))
|
||||
{
|
||||
if (smr.sharedMesh != null && smr.sharedMesh.GetBlendShapeIndex(probe) >= 0)
|
||||
{
|
||||
_meshRenderer = smr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 인덱스 캐시
|
||||
_indices = new int[_shapes.Length];
|
||||
if (_meshRenderer != null && _meshRenderer.sharedMesh != null)
|
||||
{
|
||||
var mesh = _meshRenderer.sharedMesh;
|
||||
for (int i = 0; i < _shapes.Length; i++)
|
||||
{
|
||||
_indices[i] = mesh.GetBlendShapeIndex(_shapes[i].Name);
|
||||
if (_indices[i] < 0)
|
||||
Debug.LogWarning($"[LipSync] 블렌드셰이프 없음: {_shapes[i].Name}", this);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < _indices.Length; i++) _indices[i] = -1;
|
||||
}
|
||||
|
||||
if (_audioSource == null)
|
||||
Debug.LogWarning("[LipSync] CharacterVoiceObject.VoiceSource 미할당", this);
|
||||
|
||||
_sampleBuffer = new float[_sampleSize];
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_audioSource == null || _meshRenderer == null) return;
|
||||
|
||||
// PlayOneShot도 잡히도록 항상 샘플링 — 무음은 노이즈 플로어로 컷
|
||||
_audioSource.GetOutputData(_sampleBuffer, 0);
|
||||
|
||||
float sumSq = 0f;
|
||||
for (int i = 0; i < _sampleBuffer.Length; i++)
|
||||
sumSq += _sampleBuffer[i] * _sampleBuffer[i];
|
||||
|
||||
float rms = Mathf.Sqrt(sumSq / _sampleBuffer.Length);
|
||||
rms = Mathf.Max(0f, rms - _noiseFloor);
|
||||
float target = Mathf.Clamp01(rms * _amplitudeScale);
|
||||
|
||||
_currentAmplitude = Mathf.Lerp(_currentAmplitude, target, Time.deltaTime * _smoothingSpeed);
|
||||
|
||||
// Animator가 같은 프레임에 0으로 덮은 값을 LateUpdate에서 다시 씌움
|
||||
for (int i = 0; i < _shapes.Length; i++)
|
||||
{
|
||||
if (_indices[i] < 0) continue;
|
||||
_meshRenderer.SetBlendShapeWeight(_indices[i], _currentAmplitude * _shapes[i].MaxWeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae0d9abbe32fe5b4e8e4886143e1e5e2
|
||||
@@ -149,32 +149,12 @@ private async Awaitable BGMFade(float from, float to, CancellationToken token)
|
||||
|
||||
//=========================== SFX ===========================
|
||||
|
||||
//2D SFX 재생 (UI 사운드 등 위치 무관)
|
||||
//SFX 재생. 2D 게임이라 위치 개념 없이 항상 화면 전체에 동일하게 들린다.
|
||||
public void PlaySFX(AudioClip clip, float volume = 1f)
|
||||
{
|
||||
if (clip == null) return;
|
||||
|
||||
AudioSource source = GetSfxSource();
|
||||
source.transform.localPosition = Vector3.zero;
|
||||
source.spatialBlend = 0f; //2D
|
||||
Play(source, clip, volume);
|
||||
_ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납
|
||||
}
|
||||
|
||||
//3D SFX 재생 (VR 공간음향 - 특정 위치에서 들림)
|
||||
//minDistance: 이 거리 안에선 풀 볼륨으로 들림. 하늘 높이 터지는 폭죽처럼 멀리서 나는 큰 소리는
|
||||
//크게(예: 30~50) 잡아야 거리 감쇠로 사라지지 않는다. maxDistance: 감쇠 계산 상한.
|
||||
public void PlaySFXAt(AudioClip clip, Vector3 position, float volume = 1f,
|
||||
float minDistance = 1f, float maxDistance = 500f)
|
||||
{
|
||||
if (clip == null) return;
|
||||
|
||||
AudioSource source = GetSfxSource();
|
||||
source.transform.position = position;
|
||||
source.spatialBlend = 1f; //3D
|
||||
source.rolloffMode = AudioRolloffMode.Logarithmic;
|
||||
source.minDistance = minDistance;
|
||||
source.maxDistance = maxDistance;
|
||||
Play(source, clip, volume);
|
||||
_ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납
|
||||
}
|
||||
@@ -222,6 +202,7 @@ private AudioSource CreateSfxSource()
|
||||
AudioSource source = go.AddComponent<AudioSource>();
|
||||
source.outputAudioMixerGroup = _sfxGroup;
|
||||
source.playOnAwake = false;
|
||||
source.spatialBlend = 0f; //2D — 거리 감쇠 없이 항상 동일하게
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user