게임클리어 이벤트

This commit is contained in:
2026-06-23 17:03:38 +09:00
parent fecc7556d6
commit b85855d4d6
25 changed files with 1317 additions and 28 deletions

View File

@@ -5,4 +5,5 @@ public class DialogChoice
{
public DialogNode DestinationNode;
public string ChoiceText;
public string Code; // 선택 시 기록/식별용 코드 (선택 입력, 영문 권장)
}

View File

@@ -21,6 +21,7 @@ public class DialogNode : ScriptableObject
[Header("Behavior")]
public bool LookAtPlayer;
public bool WaitForInput; // true면 LineDuration 무시하고 B버튼(OnDialogNext) 입력까지 대기
[Header("Flow")]
public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드
@@ -28,4 +29,7 @@ public class DialogNode : ScriptableObject
[Header("ChoiceQuestion")]
[TextArea(2,5)] public string ChoiceQuestion;
[Header("Event")]
public string EventKey; // 비어있지 않으면 이 노드가 재생될 때 DialogPlayer가 같은 Key의 이벤트를 호출
}

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using UnityEngine.Events;
using UnityEngine.InputSystem;
using UnityEngine;
@@ -12,6 +14,18 @@ public struct RegionGroup
public DialogGroup Group;
}
// 마지막 선택지 코드(LastChoiceCode)를 인자로 넘기는 UnityEvent (인스펙터 노출용 구체 타입)
[System.Serializable]
public class ChoiceCodeEvent : UnityEvent<string> { }
// 노드의 EventKey ↔ 그 노드 재생 시 호출할 이벤트. 인자로 LastChoiceCode가 전달됨.
[System.Serializable]
public struct NodeEvent
{
public string Key;
public ChoiceCodeEvent Event;
}
[Tooltip("영역 이름 ↔ 그 영역에서 재생할 DialogGroup")]
[SerializeField] private List<RegionGroup> _regionGroups;
@@ -23,6 +37,10 @@ public struct RegionGroup
[SerializeField] private float _hudForwardOffset = 0.5f; // 화자→플레이어 방향으로 띄울 거리
[SerializeField] private float _hudLateralOffset = 0f; // 좌우 오프셋 (+ 플레이어 시점 오른쪽)
[Header("Dialog Events")]
[Tooltip("노드의 Event Key와 같은 Key가 그 노드 재생 시 호출됨")]
[SerializeField] private List<NodeEvent> _nodeEvents = new();
private Dictionary<string, DialogGroup> _regionMap;
private Animator _animator;
private int _initialGestureHash;
@@ -31,6 +49,11 @@ public struct RegionGroup
private readonly Dictionary<Transform, Quaternion> _originalRotations = new();
public bool IsPlaying { get; private set; }
// 마지막으로 고른 선택지 (인덱스/코드). DialogVariables에도 lastChoiceIndex / lastChoiceCode 로 저장됨
public int LastChoiceIndex { get; private set; } = -1;
public string LastChoiceCode { get; private set; }
public event Action<int, string> OnChoiceSelected; // (index, code)
private void Awake()
{
_regionMap = new Dictionary<string, DialogGroup>();
@@ -90,6 +113,7 @@ public async Awaitable Play(string region)
if (node.Choices != null && node.Choices.Count > 0)
{
int picked = await WaitForChoice(node);
RecordChoice(node, picked);
node = node.Choices[picked].DestinationNode;
}
else
@@ -169,6 +193,8 @@ private async Awaitable PlayNode(DialogNode node)
if (DialogHud.Instance != null)
DialogHud.Instance.Show(node.Speaker, node.TalkText, _hudChestHeight, _hudForwardOffset, _hudLateralOffset);
RaiseNodeEvent(node.EventKey); // EventKey 있으면 매칭 이벤트 호출
// 보이스 재생
if (node.Voice != null && node.Speaker != null)
{
@@ -193,17 +219,47 @@ private async Awaitable PlayNode(DialogNode node)
if (node.Expression != null)
_animator.CrossFade(node.Expression.StateName, node.Expression.CrossFadeDuration, node.Expression.AnimationLayer);
// 대기 시간 결정
float wait = 0f;
if (node.Voice != null && node.Voice.Clip != null)
wait = node.Voice.Clip.length;
// 진행 방식 결정
if (node.WaitForInput)
{
await WaitForAdvanceInput(); // B버튼 입력이 있어야만 다음으로
}
else
wait = node.LineDuration;
{
float wait = (node.Voice != null && node.Voice.Clip != null)
? node.Voice.Clip.length
: node.LineDuration;
if (wait > 0f)
await Awaitable.WaitForSecondsAsync(wait);
else
await WaitForAdvanceInput(); // 수동 진행
if (wait > 0f)
await Awaitable.WaitForSecondsAsync(wait);
else
await WaitForAdvanceInput(); // 지정 시간이 없으면 입력으로 진행
}
}
// 노드의 EventKey와 같은 Key를 가진 이벤트들을 호출
private void RaiseNodeEvent(string key)
{
if (string.IsNullOrEmpty(key)) return;
foreach (var e in _nodeEvents)
if (e.Key == key) e.Event?.Invoke(LastChoiceCode); // 마지막 선택지 코드를 인자로 전달
}
// 선택 결과 기록: 인덱스/코드를 프로퍼티 + DialogVariables에 저장하고 이벤트 발행
private void RecordChoice(DialogNode node, int index)
{
string code = (node.Choices != null && index >= 0 && index < node.Choices.Count)
? node.Choices[index].Code : null;
code = DialogVariables.Format(code); // {token} 치환 → 동적으로 생성된 코드 반영
LastChoiceIndex = index;
LastChoiceCode = code;
DialogVariables.Set("lastChoiceIndex", index.ToString());
if (!string.IsNullOrEmpty(code))
DialogVariables.Set("lastChoiceCode", code);
OnChoiceSelected?.Invoke(index, code);
}
private async Awaitable<int> WaitForChoice(DialogNode node)
@@ -219,10 +275,33 @@ private async Awaitable<int> WaitForChoice(DialogNode node)
}
// 대화 진행 입력(OnDialogNext = VR B버튼) 한 번을 대기
private async Awaitable WaitForAdvanceInput()
{
// TODO: VR 컨트롤러 버튼 입력 대기. 일단은 1초 대기
await Awaitable.WaitForSecondsAsync(1f);
var im = InputManager.Instance;
if (im == null)
{
// 입력 매니저 없으면 안전하게 잠깐 대기 후 진행
await Awaitable.WaitForSecondsAsync(1f);
return;
}
bool pressed = false;
void Handler() => pressed = true;
im.OnDialogNext_Event += Handler;
try
{
while (!pressed)
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
catch (OperationCanceledException)
{
// 대기 중 오브젝트 파괴 시 조용히 종료
}
finally
{
im.OnDialogNext_Event -= Handler;
}
}
//테스트용

View File

@@ -0,0 +1,12 @@
using UnityEngine;
// 인스펙터에 지정한 key로 DialogVariables에 값을 넣는 헬퍼.
// 예: TMP_InputField의 On End Edit(string) → 이 컴포넌트의 Set(string) 에 연결하면
// 플레이어가 입력한 글자가 {key} 토큰으로 대화에 들어간다.
public class DialogVariableSetter : MonoBehaviour
{
[SerializeField] private string _key;
public void Set(string value) => DialogVariables.Set(_key, value); // UnityEvent<string> 연결용
public void SetKey(string key) => _key = key;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f182cc352a11ed48b27e690bdb10520

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
// 대화 텍스트의 {key} 토큰을 런타임 값으로 치환하는 전역 저장소.
// 예) DialogVariables.Set("playerName", "철수");
// 대사 "안녕 {playerName}!" → "안녕 철수!"
//
// 표시 직전(DialogHud / ChoiceHud)에서 Format()을 거치므로, 그래프엔 그냥 {key}만 써두면 된다.
public static class DialogVariables
{
private static readonly Dictionary<string, string> _values = new();
public static void Set(string key, string value) => _values[key] = value ?? string.Empty;
public static void Remove(string key) => _values.Remove(key);
public static void Clear() => _values.Clear();
public static bool TryGet(string key, out string value) => _values.TryGetValue(key, out value);
// "{key}" 토큰을 등록된 값으로 치환. 등록 안 된 키는 그대로 둔다(빠진 값 디버깅용).
public static string Format(string text)
{
if (string.IsNullOrEmpty(text) || text.IndexOf('{') < 0) return text;
var sb = new StringBuilder(text.Length);
int i = 0;
while (i < text.Length)
{
if (text[i] == '{')
{
int close = text.IndexOf('}', i + 1);
if (close > i)
{
string key = text.Substring(i + 1, close - i - 1);
if (_values.TryGetValue(key, out var val))
{
sb.Append(val);
i = close + 1;
continue;
}
}
}
sb.Append(text[i]);
i++;
}
return sb.ToString();
}
// 플레이 시작마다 초기화 (Enter Play Mode에서 도메인 리로드를 꺼도 이전 값이 안 남게)
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetOnPlay() => _values.Clear();
}

View File

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

View File

@@ -81,6 +81,11 @@ public override void OnImportAsset(AssetImportContext ctx)
dn.Voice = GetInputPortValue<VoiceClip>(gn.GetInputPortByName(DialogLineNode.PORT_VOICE));
dn.LineDuration = GetInputPortValue<float>(gn.GetInputPortByName(DialogLineNode.PORT_DURATION));
dn.LookAtPlayer = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_LOOKAT));
dn.WaitForInput = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_WAITINPUT));
string eventKey = null;
line.GetNodeOptionByName(DialogLineNode.OPTION_EVENT_KEY)?.TryGetValue(out eventKey);
dn.EventKey = eventKey;
int choiceCount = 0;
line.GetNodeOptionByName(DialogLineNode.OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
@@ -96,10 +101,12 @@ public override void OnImportAsset(AssetImportContext ctx)
for (int i = 0; i < choiceCount; i++)
{
var choiceText = GetInputPortValue<DialogText>(gn.GetInputPortByName(DialogLineNode.ChoiceTextPort(i))).Value;
var choiceCode = GetInputPortValue<string>(gn.GetInputPortByName(DialogLineNode.ChoiceCodePort(i)));
var dest = GetConnectedNode(gn, DialogLineNode.ChoiceOutPort(i));
dn.Choices.Add(new DialogChoice
{
ChoiceText = choiceText,
Code = choiceCode,
DestinationNode = dest != null && map.TryGetValue(dest, out var destDn) ? destDn : null
});
}

View File

@@ -20,12 +20,15 @@ internal class DialogLineNode : DialogGraphNode
public const string PORT_VOICE = "Voice";
public const string PORT_DURATION = "LineDuration";
public const string PORT_LOOKAT = "LookAtPlayer";
public const string PORT_WAITINPUT = "WaitForInput";
public const string PORT_QUESTION = "ChoiceQuestion";
public const string OPTION_CHOICE_COUNT = "ChoiceCount";
public const string OPTION_EVENT_KEY = "EventKey";
// 선택지별 포트 이름 규칙 (임포터와 공유)
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";
protected override void OnDefineOptions(IOptionDefinitionContext context)
@@ -35,6 +38,11 @@ protected override void OnDefineOptions(IOptionDefinitionContext context)
.WithTooltip("0이면 선형 진행(Next), 1 이상이면 가변 N지선다 분기")
.WithDefaultValue(0)
.Delayed();
context.AddOption<string>(OPTION_EVENT_KEY)
.WithDisplayName("Event Key")
.WithTooltip("비우면 없음. 이 노드 재생 시 DialogPlayer의 같은 Key 이벤트 호출 (영문 키 권장)")
.Delayed();
}
protected override void OnDefinePorts(IPortDefinitionContext context)
@@ -49,6 +57,7 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
context.AddInputPort<VoiceClip>(PORT_VOICE).WithDisplayName("Voice").Build();
context.AddInputPort<float>(PORT_DURATION).WithDisplayName("Line Duration").Build();
context.AddInputPort<bool>(PORT_LOOKAT).WithDisplayName("Look At Player").Build();
context.AddInputPort<bool>(PORT_WAITINPUT).WithDisplayName("Wait For Input").Build();
int choiceCount = 0;
GetNodeOptionByName(OPTION_CHOICE_COUNT)?.TryGetValue(out choiceCount);
@@ -69,6 +78,9 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
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} →");
}
}