2026-06-19 UI, UI로직

This commit is contained in:
skrwns304@gmail.com
2026-06-19 14:27:40 +09:00
parent b751a9ed66
commit b1e85a5b89
549 changed files with 18058 additions and 20 deletions

View File

@@ -0,0 +1,29 @@
using UnityEngine;
using UnityEngine.Events;
[System.Serializable]
public class ChoiceData
{
[Header("Choice Text")]
[TextArea(1, 2)]
public string choiceText;
[Header("Branch To Another Dialogue")]
[Tooltip("다른 DialogueData로 이동합니다. 이 값이 있으면 nextNodeId, nextNodeIndex보다 우선됩니다.")]
public DialogueData nextDialogue;
[Header("Branch Inside Current Dialogue")]
[Tooltip("현재 DialogueData 안에서 이동할 노드 ID입니다.")]
public string nextNodeId;
[Tooltip("-1이면 사용하지 않습니다. 현재 DialogueData 안에서 특정 노드 번호로 이동합니다.")]
public int nextNodeIndex = -1;
[Header("End")]
[Tooltip("이 선택지를 누르면 대화를 종료합니다.")]
public bool endDialogue;
[Header("Optional Event")]
[Tooltip("선택지를 눌렀을 때 실행할 이벤트입니다. 퀘스트 시작, 아이템 지급 등에 사용할 수 있습니다.")]
public UnityEvent onSelected;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 00c80393d9221c543af1f245de1c40fc

View File

@@ -0,0 +1,65 @@
using UnityEngine;
public class DialogueBillboard : MonoBehaviour
{
[Header("Target")]
[SerializeField] private Transform target;
[SerializeField] private Camera targetCamera;
[Header("Position")]
[SerializeField] private Vector3 offset = new Vector3(0f, 2f, 0f);
[Header("Look At Camera")]
[SerializeField] private bool faceCamera = true;
[SerializeField] private bool lockYAxis = false;
[SerializeField] private bool rotate180AfterLookAt = true;
public Transform Target => target;
private void LateUpdate()
{
if (target == null)
return;
transform.position = target.position + offset;
if (!faceCamera)
return;
if (targetCamera == null)
targetCamera = Camera.main;
if (targetCamera == null)
return;
Vector3 lookPosition = targetCamera.transform.position;
if (lockYAxis)
lookPosition.y = transform.position.y;
transform.LookAt(lookPosition);
if (rotate180AfterLookAt)
transform.Rotate(0f, 180f, 0f);
}
public void SetTarget(Transform newTarget)
{
target = newTarget;
}
public void ClearTarget()
{
target = null;
}
public void SetCamera(Camera newCamera)
{
targetCamera = newCamera;
}
public void SetOffset(Vector3 newOffset)
{
offset = newOffset;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9d898806f1a151c44933dc01f69acfc0

View File

@@ -0,0 +1,69 @@
using UnityEngine;
[CreateAssetMenu(fileName = "New Dialogue",
menuName = "Dialogue/Dialogue Data")]
public class DialogueData : ScriptableObject
{
public DialogueNode[] nodes;
public bool HasNodes
{
get
{
return nodes != null && nodes.Length > 0;
}
}
public bool IsValidIndex(int index)
{
return nodes != null && index >= 0 && index < nodes.Length;
}
public bool TryGetNode(int index, out DialogueNode node)
{
node = null;
if (!IsValidIndex(index))
return false;
node = nodes[index];
return node != null;
}
public int GetNodeIndexById(string nodeId)
{
if (string.IsNullOrWhiteSpace(nodeId))
return -1;
if (nodes == null)
return -1;
for (int i = 0; i < nodes.Length; i++)
{
if (nodes[i] == null)
continue;
if (nodes[i].nodeId == nodeId)
return i;
}
return -1;
}
#if UNITY_EDITOR
private void OnValidate()
{
if (nodes == null)
return;
for (int i = 0; i < nodes.Length; i++)
{
if (nodes[i] == null)
continue;
if (string.IsNullOrWhiteSpace(nodes[i].nodeId))
nodes[i].nodeId = $"Node_{i:00}";
}
}
#endif
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 13d3b4b8c4cf18d40865a5497e1e1f29

View File

@@ -0,0 +1,272 @@
using UnityEngine;
public class DialogueManager : MonoBehaviour
{
[Header("References")]
[SerializeField] private DialogueUI ui;
[SerializeField] private DialogueBillboard billboard;
[Header("Starting Dialogue")]
[SerializeField] private DialogueData startingDialogue;
[SerializeField] private Transform startingTarget;
[SerializeField] private bool startOnAwake = false;
[Header("Settings")]
[SerializeField] private bool allowRestartWhileOpen = true;
[SerializeField] private bool showDebugLog = true;
private DialogueData currentDialogue;
private int currentNodeIndex = -1;
private Transform currentNpcTarget;
public bool IsDialogueActive { get; private set; }
private void Awake()
{
AutoFindReferences();
ValidateReferences();
}
private void Start()
{
if (ui != null)
{
ui.SetVisible(false);
ui.ClearChoices();
}
if (startOnAwake && startingDialogue != null)
StartDialogue(startingDialogue, startingTarget);
}
private void AutoFindReferences()
{
if (ui == null)
ui = FindFirstObjectByType<DialogueUI>();
if (billboard == null)
billboard = FindFirstObjectByType<DialogueBillboard>();
}
private void ValidateReferences()
{
if (ui == null)
Debug.LogWarning("[DialogueManager] DialogueUI가 연결되지 않았습니다.");
if (billboard == null)
Debug.LogWarning("[DialogueManager] DialogueBillboard가 연결되지 않았습니다. VR 위치 추적 기능은 동작하지 않습니다.");
}
public void StartDialogue(DialogueData dialogue)
{
StartDialogue(dialogue, currentNpcTarget);
}
public void StartDialogue(DialogueData dialogue, Transform npc)
{
if (dialogue == null)
{
Debug.LogWarning("[DialogueManager] 시작할 DialogueData가 null입니다.");
return;
}
if (!allowRestartWhileOpen && IsDialogueActive)
return;
if (!dialogue.HasNodes)
{
Debug.LogWarning($"[DialogueManager] DialogueData '{dialogue.name}'에 노드가 없습니다.");
EndDialogue();
return;
}
currentDialogue = dialogue;
currentNodeIndex = 0;
currentNpcTarget = npc;
IsDialogueActive = true;
if (ui != null)
ui.SetVisible(true);
if (billboard != null)
billboard.SetTarget(npc);
ShowCurrentNode();
if (showDebugLog)
Debug.Log($"[DialogueManager] Dialogue Start: {dialogue.name}");
}
public void StartDialogueFromNPC(DialogueData dialogue, Transform npc)
{
StartDialogue(dialogue, npc);
}
public void NextDialogue()
{
if (!IsDialogueActive)
return;
GoToNodeIndex(currentNodeIndex + 1);
}
public void GoToNodeIndex(int index)
{
if (currentDialogue == null)
{
EndDialogue();
return;
}
if (!currentDialogue.IsValidIndex(index))
{
EndDialogue();
return;
}
currentNodeIndex = index;
ShowCurrentNode();
}
public void GoToNodeId(string nodeId)
{
if (currentDialogue == null)
{
EndDialogue();
return;
}
int index = currentDialogue.GetNodeIndexById(nodeId);
if (index < 0)
{
Debug.LogWarning($"[DialogueManager] nodeId '{nodeId}'를 찾을 수 없습니다.");
EndDialogue();
return;
}
GoToNodeIndex(index);
}
private void ShowCurrentNode()
{
if (currentDialogue == null)
{
EndDialogue();
return;
}
if (!currentDialogue.TryGetNode(currentNodeIndex, out DialogueNode node))
{
Debug.LogWarning("[DialogueManager] 현재 노드를 가져올 수 없습니다.");
EndDialogue();
return;
}
if (ui == null)
return;
ui.SetSpeaker(node.speaker);
ui.SetDialogueText(node.dialogue);
if (HasChoices(node))
{
ui.SetNextButtonVisible(false);
ui.CreateChoices(node.choices, OnChoiceClicked);
}
else
{
ui.ClearChoices();
ui.SetNextButtonVisible(true);
ui.SetupNextButton(NextDialogue);
}
}
private bool HasChoices(DialogueNode node)
{
return node != null &&
node.choices != null &&
node.choices.Length > 0;
}
private void OnChoiceClicked(int choiceIndex)
{
if (!IsDialogueActive)
return;
if (currentDialogue == null)
return;
if (!currentDialogue.TryGetNode(currentNodeIndex, out DialogueNode node))
return;
if (node.choices == null ||
choiceIndex < 0 ||
choiceIndex >= node.choices.Length)
return;
ChoiceData choice = node.choices[choiceIndex];
if (choice == null)
return;
HandleChoice(choice);
}
private void HandleChoice(ChoiceData choice)
{
choice.onSelected?.Invoke();
if (choice.endDialogue)
{
EndDialogue();
return;
}
if (choice.nextDialogue != null)
{
StartDialogue(choice.nextDialogue, currentNpcTarget);
return;
}
if (!string.IsNullOrWhiteSpace(choice.nextNodeId))
{
GoToNodeId(choice.nextNodeId);
return;
}
if (choice.nextNodeIndex >= 0)
{
GoToNodeIndex(choice.nextNodeIndex);
return;
}
NextDialogue();
}
public void EndDialogue()
{
if (ui != null)
{
ui.ClearChoices();
ui.SetNextButtonVisible(false);
ui.SetVisible(false);
}
if (billboard != null)
billboard.ClearTarget();
currentDialogue = null;
currentNodeIndex = -1;
currentNpcTarget = null;
IsDialogueActive = false;
if (showDebugLog)
Debug.Log("[DialogueManager] Dialogue End");
}
public void CloseDialogue()
{
EndDialogue();
}
}

View File

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

View File

@@ -0,0 +1,19 @@
using UnityEngine;
[System.Serializable]
public class DialogueNode
{
[Header("Node ID")]
[Tooltip("선택지에서 nextNodeId로 이동할 때 사용하는 ID입니다.")]
public string nodeId;
[Header("Speaker")]
public NPCProfile speaker;
[Header("Dialogue")]
[TextArea(3, 5)]
public string dialogue;
[Header("Choices")]
public ChoiceData[] choices;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96966b084e2dff74dbe4265e7c3dece5

View File

@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
public class DialogueUI : MonoBehaviour
{
[Header("Panel")]
[SerializeField] private GameObject dialoguePanel;
[Header("Speaker UI")]
[SerializeField] private Image portraitImage;
[SerializeField] private TMP_Text nameText;
[Header("Dialogue UI")]
[SerializeField] private TMP_Text dialogueText;
[SerializeField] private Button nextButton;
[Header("Choice UI")]
[SerializeField] private Transform choiceRoot;
[SerializeField] private Button choiceButtonPrefab;
[Header("Settings")]
[SerializeField] private bool hidePortraitWhenNoSpeaker = true;
private readonly List<Button> spawnedChoiceButtons = new List<Button>();
public GameObject DialoguePanel => dialoguePanel;
public bool IsVisible
{
get
{
return dialoguePanel != null && dialoguePanel.activeSelf;
}
}
private void Awake()
{
SetVisible(false);
ClearChoices();
}
public void SetVisible(bool visible)
{
if (dialoguePanel != null)
dialoguePanel.SetActive(visible);
}
public void SetSpeaker(NPCProfile speaker)
{
if (speaker == null)
{
if (nameText != null)
nameText.text = string.Empty;
if (portraitImage != null)
{
portraitImage.sprite = null;
portraitImage.enabled = !hidePortraitWhenNoSpeaker;
}
return;
}
if (nameText != null)
nameText.text = speaker.displayName;
if (portraitImage != null)
{
portraitImage.sprite = speaker.portrait;
portraitImage.enabled = speaker.portrait != null || !hidePortraitWhenNoSpeaker;
}
}
public void SetDialogueText(string text)
{
if (dialogueText != null)
dialogueText.text = text;
}
public void SetupNextButton(UnityAction onClick)
{
if (nextButton == null)
return;
nextButton.onClick.RemoveAllListeners();
if (onClick != null)
nextButton.onClick.AddListener(onClick);
}
public void SetNextButtonVisible(bool visible)
{
if (nextButton != null)
nextButton.gameObject.SetActive(visible);
}
public void CreateChoices(ChoiceData[] choices, Action<int> onChoiceClicked)
{
ClearChoices();
if (choices == null || choices.Length == 0)
return;
if (choiceRoot == null)
{
Debug.LogWarning("[DialogueUI] choiceRoot가 연결되지 않았습니다.");
return;
}
if (choiceButtonPrefab == null)
{
Debug.LogWarning("[DialogueUI] choiceButtonPrefab이 연결되지 않았습니다.");
return;
}
for (int i = 0; i < choices.Length; i++)
{
ChoiceData choice = choices[i];
if (choice == null)
continue;
Button button = Instantiate(choiceButtonPrefab, choiceRoot);
button.gameObject.SetActive(true);
TMP_Text label = button.GetComponentInChildren<TMP_Text>(true);
if (label != null)
{
if (string.IsNullOrWhiteSpace(choice.choiceText))
label.text = "(빈 선택지)";
else
label.text = choice.choiceText;
}
int capturedIndex = i;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() =>
{
onChoiceClicked?.Invoke(capturedIndex);
});
spawnedChoiceButtons.Add(button);
}
}
public void ClearChoices()
{
for (int i = spawnedChoiceButtons.Count - 1; i >= 0; i--)
{
if (spawnedChoiceButtons[i] == null)
continue;
if (Application.isPlaying)
Destroy(spawnedChoiceButtons[i].gameObject);
else
DestroyImmediate(spawnedChoiceButtons[i].gameObject);
}
spawnedChoiceButtons.Clear();
}
}

View File

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

View File

@@ -0,0 +1,47 @@
using UnityEngine;
[DisallowMultipleComponent]
public class NPCInteract : MonoBehaviour
{
[Header("Dialogue Data")]
[SerializeField] private DialogueData dialogue;
[Header("Reference")]
[SerializeField] private DialogueManager manager;
[Header("Settings")]
[SerializeField] private bool autoFindManager = true;
private void Awake()
{
if (manager == null && autoFindManager)
manager = FindFirstObjectByType<DialogueManager>();
}
public void Interact()
{
if (dialogue == null)
{
Debug.LogWarning($"[NPCInteract] {name}에 DialogueData가 연결되지 않았습니다.");
return;
}
if (manager == null)
{
Debug.LogWarning($"[NPCInteract] {name}에 DialogueManager가 연결되지 않았습니다.");
return;
}
manager.StartDialogueFromNPC(dialogue, transform);
}
public void SetDialogue(DialogueData newDialogue)
{
dialogue = newDialogue;
}
public void SetManager(DialogueManager newManager)
{
manager = newManager;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 031ff22c2a7ccda46b524922b5acaefe

View File

@@ -0,0 +1,11 @@
using UnityEngine;
[CreateAssetMenu(fileName = "New NPC Profile",
menuName = "Dialogue/NPC Profile")]
public class NPCProfile : ScriptableObject
{
[Header("NPC Info")]
public string displayName;
public Sprite portrait;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 746ac9c211712334e8701c49211cf302