DEG-118 [Feat] 대화 관리 시스템 및 npc와의 대화문 추가

This commit is contained in:
Sehyeon 2025-04-29 16:09:35 +09:00
parent 23e411b6e1
commit f8e7f539d7
10 changed files with 695 additions and 115 deletions

BIN
Assets/KSH/DungeonTestScene.unity (Stored with Git LFS)

Binary file not shown.

View File

@ -1,113 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class ChatWindowController : MonoBehaviour, IPointerClickHandler
{
[SerializeField] private TMP_Text chatText;
[SerializeField] private Image clickIndicator;
private Coroutine _typingCoroutine;
private Coroutine _clickCoroutine;
private string _inputText;
private Queue<string> _inputQueue;
public delegate void OnComplete();
public OnComplete onComplete;
private void Start()
{
Init("마무리", () =>
{
Debug.Log("대화 끝.");
});
ShowText(_inputQueue.Dequeue());
}
public void Init(string text, OnComplete onComplete)
{
_inputQueue = new Queue<string>();
_inputQueue.Enqueue("아 망했어 오늘도 지각이다!!!! 이러면 진짜 해고당할 수도 있어!!!\n어떡하지 큰일이다!!!");
_inputQueue.Enqueue("톼사하셈 ㅋ");
_inputQueue.Enqueue("톼사하셈 ㅋ");
_inputQueue.Enqueue("스킵도 가능 톼사하셈 ㅋ톼사하셈 ㅋ톼사하셈 ㅋ톼사하셈 ㅋ톼사하셈 ㅋ");
_inputQueue.Enqueue(text);
this.onComplete = onComplete;
}
//화면에 표시할 텍스트 삽입 함수
private void ShowText(string text)
{
var clickIndicatorColor = clickIndicator.color;
clickIndicatorColor.a = 1;
clickIndicator.color = clickIndicatorColor;
_inputText = text;
if (_typingCoroutine != null)
{
StopCoroutine(_typingCoroutine);
}
_typingCoroutine = StartCoroutine(TypingEffectCoroutine(_inputText));
}
//텍스트 타이핑효과 코루틴
private IEnumerator TypingEffectCoroutine(string text)
{
StringBuilder strText = new StringBuilder();
for (int i = 0; i < text.Length; i++)
{
strText.Append(text[i]);
chatText.text = strText.ToString();
yield return new WaitForSeconds(0.1f);
}
_clickCoroutine = StartCoroutine(ClickIndicatorCoroutine());
_typingCoroutine = null;
}
private IEnumerator ClickIndicatorCoroutine()
{
bool flag = true;
var clickIndicatorColor = clickIndicator.color;
while (true)
{
clickIndicatorColor.a = flag? 0:1;
flag = !flag;
clickIndicator.color = clickIndicatorColor;
yield return new WaitForSeconds(0.5f);
}
}
//대화창 클릭 시 호출 함수
public void OnPointerClick(PointerEventData eventData)
{
if (_typingCoroutine != null)
{
StopCoroutine(_typingCoroutine);
_typingCoroutine = null;
chatText.text = _inputText;
_clickCoroutine = StartCoroutine(ClickIndicatorCoroutine());
}
else
{
if (_clickCoroutine != null)
{
StopCoroutine(_clickCoroutine);
_clickCoroutine = null;
}
if (_inputQueue.Count > 0)
{
ShowText(_inputQueue.Dequeue());
}
else
{
onComplete?.Invoke();
}
}
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: feed7c503b7a69549acfe2836b78976e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,158 @@
{
"dialogues": [
{
"id": "fairy_intro_1",
"name": "???",
"text": "일어나라 소년, 아니 청년, ...아니 34세의 회사원... 그대의 도움이 필요하다.",
"nextId": "fairy_intro_2",
"phase": "intro"
},
{
"id": "fairy_intro_2",
"name": "냉장고 요정",
"text": "나는 냉장고의 요정. \n이 냉장고는 세상을 멸망시킬 던전이 잠들어 있다, 그대가 부디 클리어 해주길 바란다. \n...일주일 안에!",
"nextId": "player_intro_1",
"phase": "intro"
},
{
"id": "player_intro_1",
"name": "주인공",
"text": "...(어쩐지 감자 마켓 온도가 -99도더니...) 내가 왜",
"nextId": "fairy_intro_3",
"phase": "intro"
},
{
"id": "fairy_intro_3",
"name": "냉장고 요정",
"text": "안 하면 냉장고 터진다.",
"nextId": "player_intro_2",
"phase": "intro"
},
{
"id": "player_intro_2",
"name": "주인공",
"text": "안돼! 3만원에 사온 내 비X코프 냉장고가! \n(실제 냉장고 상호명과는 아무 관계 없습니다.)",
"nextId": "fairy_intro_4",
"phase": "intro"
},
{
"id": "fairy_intro_4",
"name": "냉장고 요정",
"text": "또한 냉장고 폭파로 인한 화재가 발생할 수 있으며, 이는 부주의로 인하였기에 보험 적용에 제한이 있을 수 있습니다.",
"nextId": "player_intro_3",
"phase": "intro"
},
{
"id": "player_intro_3",
"name": "주인공",
"text": "할 수 밖에 없잖아...!!",
"nextId": "",
"phase": "intro"
},
{
"id": "fairy_gameplay_1",
"name": "냉장고 요정",
"text": "일주일 후 평판이 3이면 좋은 일이 발생할 지도 몰라",
"nextId": "",
"phase": "gameplay"
},
{
"id": "fairy_gameplay_2",
"name": "냉장고 요정",
"text": "출근 한 번 안했다고 평판 2씩이나 깎는 회사가 있으려나...",
"nextId": "player_gameplay_2",
"phase": "gameplay"
},
{
"id": "player_gameplay_2",
"name": "주인공",
"text": "(여기 있다.)",
"nextId": "",
"phase": "gameplay"
},
{
"id": "fairy_gameplay_3",
"name": "냉장고 요정",
"text": "던전은 총 2스테이지까지 있으며 모두 클리어한다면...",
"nextId": "player_gameplay_3",
"phase": "gameplay"
},
{
"id": "player_gameplay_3",
"name": "주인공",
"text": "한다면... 설마 부와 명예를?",
"nextId": "fairy_gameplay_4",
"phase": "gameplay"
},
{
"id": "fairy_gameplay_4",
"name": "냉장고 요정",
"text": "축하의 박수를 쳐줄게~",
"nextId": "",
"phase": "gameplay"
},
{
"id": "fairy_end_1",
"name": "냉장고 요정",
"text": "일주일이 지났네! 수고했어! 이제 이 비스X프 냉장고는 너의 것이야!",
"nextId": "player_end_1",
"phase": "end"
},
{
"id": "player_end_1",
"name": "주인공",
"text": "(네 도움이 없었다면 불가능했을 거야. 정말 고마워.) \n아자!!!!!!!!!",
"nextId": "fairy_end_2",
"phase": "end"
},
{
"id": "fairy_end_2",
"name": "냉장고 요정",
"text": "네 덕분에 이 세계는 다시 평화를 되찾았어.",
"nextId": "player_end_2",
"phase": "end"
},
{
"id": "player_end_2",
"name": "주인공",
"text": "그래... 이제 넌 어떻게 되는 거지?",
"nextId": "fairy_end_3",
"phase": "end"
},
{
"id": "fairy_end_3",
"name": "냉장고 요정",
"text": "여기 있지...?",
"nextId": "player_end_3",
"phase": "end"
},
{
"id": "player_end_3",
"name": "주인공",
"text": "?",
"nextId": "fairy_end_4",
"phase": "end"
},
{
"id": "fairy_end_4",
"name": "냉장고 요정",
"text": "?",
"nextId": "player_end_4",
"phase": "end"
},
{
"id": "player_end_4",
"name": "주인공",
"text": "... ?",
"nextId": "fairy_end_5",
"phase": "end"
},
{
"id": "fairy_end_5",
"name": "냉장고 요정",
"text": "... 엔딩 크레딧 출력!",
"nextId": "",
"phase": "end"
}
]
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: cac8b86426d9414c912671dbc2fa7959
timeCreated: 1745890651

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cfc4230a3c9c2b24b80574293a72e411
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,250 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using Random = UnityEngine.Random;
// 대화 데이터 클래스
[Serializable]
public class DialogueStruct
{
public string id; // 대화 고유 ID, 세이브용
public string name;
public string text;
public string nextId;
public string phase; // 단계 (intro, gameplay, end)
}
[Serializable]
public class DialogueData // 전체 데이터 클래스
{
public DialogueData()
{
dialogues = new List<DialogueStruct>();
}
public List<DialogueStruct> dialogues;
}
public enum GamePhase // 단계별로 출력되는 대화가 달라짐
{
Intro, // 인트로 설명문
Gameplay, // 게임 진행 팁? 등
End // 엔딩 대화
}
public class ChatWindowController : MonoBehaviour, IPointerClickHandler
{
[SerializeField] private TMP_Text nameText;
[SerializeField] private TMP_Text chatText;
[SerializeField] private Image clickIndicator;
[SerializeField] private GameObject chatWindowObject; // 대화 종료용
private Coroutine _typingCoroutine;
private Coroutine _clickCoroutine;
private string _inputText;
private Queue<DialogueStruct> _inputQueue;
public delegate void OnComplete();
public OnComplete onComplete;
private bool _dialogueEnded = false; // 대화 종료 여부
private FairyDialogueManager _dialogueManager;
private void Awake()
{
_inputQueue = new Queue<DialogueStruct>();
chatWindowObject.SetActive(false); // 일단 비활성화로 시작
}
private void Start()
{
// FairyDialogueManager 초기화
_dialogueManager = new FairyDialogueManager(this);
// 완료 콜백 설정
onComplete = () => {
Debug.Log("대화가 완료되었습니다.");
};
// 테스트 코드: 인트로 대화 시작
Debug.Log("인트로 대화 시작");
_dialogueManager.SetGamePhase(GamePhase.Intro);
// 테스트 코드: 게임플레이 및 엔딩 대화 테스트를 위한 코루틴 시작
StartCoroutine(TestDialogueSequence());
}
// 테스트 코드
private IEnumerator TestDialogueSequence()
{
// 인트로 대화가 끝날 시간을 대략적으로 기다림
yield return new WaitForSeconds(5f);
// 게임플레이 단계로 전환
Debug.Log("게임플레이 대화 시작");
_dialogueManager.SetGamePhase(GamePhase.Gameplay);
_dialogueManager.TalkToFairy();
// 3초 후 다른 게임플레이 대화 테스트
yield return new WaitForSeconds(3f);
_dialogueManager.StartDialogueById("fairy_gameplay_2");
// 3초 후 엔딩 대화 테스트
yield return new WaitForSeconds(3f);
Debug.Log("엔딩 대화 시작");
_dialogueManager.SetGamePhase(GamePhase.End);
}
// 대화창 표시
public void ShowWindow()
{
chatWindowObject.SetActive(true);
_dialogueEnded = false;
if (_inputQueue.Count > 0)
{
ShowNextDialogue();
}
}
// 대화창 숨기기
public void HideWindow()
{
chatWindowObject.SetActive(false);
onComplete?.Invoke(); // 대화창 중지하며 콜백 호출
// 진행 중인 모든 코루틴 중지
if (_typingCoroutine != null)
{
StopCoroutine(_typingCoroutine);
_typingCoroutine = null;
}
if (_clickCoroutine != null)
{
StopCoroutine(_clickCoroutine);
_clickCoroutine = null;
}
}
// 대화 시퀀스 설정
public void SetDialogueSequence(List<DialogueStruct> sequence)
{
// 기존 큐 초기화
_inputQueue.Clear();
_dialogueEnded = false;
// 새 대화 시퀀스를 큐에 추가
foreach (DialogueStruct dialog in sequence)
{
_inputQueue.Enqueue(dialog);
}
}
// 다음 대화 표시
private void ShowNextDialogue()
{
if (_inputQueue.Count == 0)
{
_dialogueEnded = true;
return;
}
DialogueStruct dialog = _inputQueue.Dequeue();
// 이름 텍스트 업데이트
if (nameText != null)
{
nameText.text = dialog.name;
}
// 클릭 인디케이터 활성화
var clickIndicatorColor = clickIndicator.color;
clickIndicatorColor.a = 1;
clickIndicator.color = clickIndicatorColor;
_inputText = dialog.text;
// 이전 타이핑 코루틴 중단
if (_typingCoroutine != null)
{
StopCoroutine(_typingCoroutine);
}
// 새 타이핑 코루틴 시작
_typingCoroutine = StartCoroutine(TypingEffectCoroutine(_inputText));
}
//텍스트 타이핑효과 코루틴
private IEnumerator TypingEffectCoroutine(string text)
{
StringBuilder strText = new StringBuilder();
for (int i = 0; i < text.Length; i++)
{
strText.Append(text[i]);
chatText.text = strText.ToString();
yield return new WaitForSeconds(0.05f);
}
_clickCoroutine = StartCoroutine(ClickIndicatorCoroutine());
_typingCoroutine = null;
}
private IEnumerator ClickIndicatorCoroutine()
{
bool flag = true;
var clickIndicatorColor = clickIndicator.color;
while (true)
{
clickIndicatorColor.a = flag? 0:1;
flag = !flag;
clickIndicator.color = clickIndicatorColor;
yield return new WaitForSeconds(0.5f);
}
}
//대화창 클릭 시 호출 함수
public void OnPointerClick(PointerEventData eventData)
{
if (_typingCoroutine != null)
{
StopCoroutine(_typingCoroutine);
_typingCoroutine = null;
chatText.text = _inputText;
_clickCoroutine = StartCoroutine(ClickIndicatorCoroutine());
}
else
{
if (_clickCoroutine != null)
{
StopCoroutine(_clickCoroutine);
_clickCoroutine = null;
}
// 대화가 끝났으면 창 닫기
if (_dialogueEnded)
{
HideWindow();
onComplete?.Invoke();
return;
}
if (_inputQueue.Count > 0) // 대화가 남은 경우
{
ShowNextDialogue();
}
else
{
_dialogueEnded = true; // 일단 대화창 닫고 이후 콜백 호출
}
}
}
}

View File

@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Random = UnityEngine.Random;
// 일반 클래스로 변경된 FairyDialogueManager
public class FairyDialogueManager
{
private ChatWindowController _chatWindow;
private string _dialogueFileName;
// 현재 게임 단계
private GamePhase _currentGamePhase = GamePhase.Intro;
private DialogueData _database;
private Dictionary<string, DialogueStruct> _dialogueDict;
// 단계별 대화 모음
private Dictionary<string, List<DialogueStruct>> _phaseDialogues;
// 이미 보여준 게임플레이 대화 추적
private HashSet<string> _shownGameplayDialogueIds;
// 생성자
public FairyDialogueManager(ChatWindowController chatWindow, string dialogueFileName = "dialogue.json")
{
_chatWindow = chatWindow;
_dialogueFileName = dialogueFileName;
_shownGameplayDialogueIds = new HashSet<string>();
LoadDialogueData();
OrganizeDialogues();
}
// 대화 데이터베이스 로드
private void LoadDialogueData()
{
string filePath = "Dialogues/" + _dialogueFileName.Replace(".json", "");
TextAsset jsonFile = Resources.Load<TextAsset>(filePath);
if (jsonFile == null)
{
Debug.LogError($"Failed to load dialogue database: {filePath}");
_database = new DialogueData { dialogues = new List<DialogueStruct>() };
return;
}
Debug.Log($"JSON 파일 내용: {jsonFile.text.Substring(0, Mathf.Min(200, jsonFile.text.Length))}..."); // 파일 내용 확인
try {
_database = JsonUtility.FromJson<DialogueData>(jsonFile.text);
Debug.Log($"대화 항목 수: {_database.dialogues.Count}");
// 검증: 각 단계별 대화 항목 수 확인
Dictionary<string, int> phaseCount = new Dictionary<string, int>();
foreach (var dialogue in _database.dialogues)
{
if (!phaseCount.ContainsKey(dialogue.phase))
phaseCount[dialogue.phase] = 0;
phaseCount[dialogue.phase]++;
}
foreach (var phase in phaseCount)
{
Debug.Log($"단계 '{phase.Key}': {phase.Value}개 항목");
}
// 대화 사전 초기화
_dialogueDict = new Dictionary<string, DialogueStruct>();
foreach (DialogueStruct entry in _database.dialogues)
{
_dialogueDict[entry.id] = entry;
}
}
catch (Exception e) {
Debug.LogError($"JSON 파싱 오류: {e.Message}");
_database = new DialogueData { dialogues = new List<DialogueStruct>() };
}
}
// 대화를 단계별로 분류
private void OrganizeDialogues()
{
_phaseDialogues = new Dictionary<string, List<DialogueStruct>>();
foreach (DialogueStruct entry in _database.dialogues)
{
// 단계별 분류
if (!_phaseDialogues.ContainsKey(entry.phase))
{
_phaseDialogues[entry.phase] = new List<DialogueStruct>();
}
_phaseDialogues[entry.phase].Add(entry);
}
}
// 게임 단계 설정
public void SetGamePhase(GamePhase phase)
{
_currentGamePhase = phase;
// Intro 또는 End 단계로 진입 시 자동으로 해당 대화 시작
if (phase == GamePhase.Intro)
{
StartPhaseDialogue("intro");
}
else if (phase == GamePhase.End)
{
StartPhaseDialogue("end");
}
}
// 단계별 시작 대화 찾기 및 시작
private void StartPhaseDialogue(string phaseName)
{
if (!_phaseDialogues.ContainsKey(phaseName) || _phaseDialogues[phaseName].Count == 0)
{
Debug.LogWarning($"No dialogues found for phase: {phaseName}");
return;
}
// 해당 단계의 첫 대화 항목 찾기 (요정이 먼저 말하는 대화)
DialogueStruct startEntry = _phaseDialogues[phaseName]
.FirstOrDefault(d => d.name == "냉장고 요정" && !string.IsNullOrEmpty(d.nextId));
if (startEntry == null)
{
// 적합한 시작 대화가 없으면 첫 번째 대화 사용
startEntry = _phaseDialogues[phaseName][0];
}
// 대화 시퀀스 시작
StartDialogueSequence(startEntry.id);
}
// 랜덤 게임플레이 대화 보여주기
public void ShowRandomGameplayDialogue()
{
if (_currentGamePhase != GamePhase.Gameplay)
{
Debug.LogWarning("Random dialogue can only be shown during gameplay phase");
return;
}
// 게임플레이 단계의 요정 대화 중 아직 보여주지 않은 것 찾기
var availableDialogues = _phaseDialogues["gameplay"]
.Where(d => d.name == "냉장고 요정" && !_shownGameplayDialogueIds.Contains(d.id))
.ToList();
// 모든 대화를 다 보여줬다면 다시 초기화
if (availableDialogues.Count == 0)
{
_shownGameplayDialogueIds.Clear();
availableDialogues = _phaseDialogues["gameplay"]
.Where(d => d.name == "냉장고 요정")
.ToList();
}
// 랜덤 대화 선택
if (availableDialogues.Count > 0)
{
int randomIndex = Random.Range(0, availableDialogues.Count);
DialogueStruct randomDialogue = availableDialogues[randomIndex];
// 보여준 대화 기록
_shownGameplayDialogueIds.Add(randomDialogue.id);
// 대화 시퀀스 시작
StartDialogueSequence(randomDialogue.id);
}
}
// 대화 ID로 대화 시작
public void StartDialogueSequence(string startDialogueId)
{
if (!_dialogueDict.ContainsKey(startDialogueId))
{
Debug.LogError($"Dialogue ID not found: {startDialogueId}");
return;
}
// 시작 대화 항목 가져오기
DialogueStruct startEntry = _dialogueDict[startDialogueId];
// 대화 시퀀스 구성
List<DialogueStruct> sequence = BuildDialogueSequence(startEntry);
// 대화창에 대화 시퀀스 전달
_chatWindow.SetDialogueSequence(sequence);
_chatWindow.ShowWindow();
}
// 연결된 대화 시퀀스 구성
private List<DialogueStruct> BuildDialogueSequence(DialogueStruct startEntry)
{
List<DialogueStruct> sequence = new List<DialogueStruct>();
HashSet<string> visitedIds = new HashSet<string>(); // 순환 참조 방지
DialogueStruct currentEntry = startEntry;
while (currentEntry != null && !visitedIds.Contains(currentEntry.id))
{
sequence.Add(currentEntry);
visitedIds.Add(currentEntry.id);
// 다음 대화가 없으면 종료
if (string.IsNullOrEmpty(currentEntry.nextId) ||
!_dialogueDict.ContainsKey(currentEntry.nextId))
{
break;
}
// 다음 대화로 이동
currentEntry = _dialogueDict[currentEntry.nextId];
}
return sequence;
}
// 요정에게 말 걸기 (게임 중 언제든 사용 가능)
public void TalkToFairy()
{
switch (_currentGamePhase)
{
case GamePhase.Intro:
StartPhaseDialogue("intro");
break;
case GamePhase.Gameplay:
ShowRandomGameplayDialogue();
break;
case GamePhase.End:
StartPhaseDialogue("end");
break;
}
}
// 특정 대화 ID로 대화 시작 (외부에서 호출 가능)
public void StartDialogueById(string dialogueId)
{
if (_dialogueDict.ContainsKey(dialogueId))
{
StartDialogueSequence(dialogueId);
}
else
{
Debug.LogError($"Dialogue ID not found: {dialogueId}");
}
}
// 완료 콜백 설정
public void SetOnCompleteCallback(ChatWindowController.OnComplete callback)
{
_chatWindow.onComplete = callback;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5c08c5a9419d39e40b838ea04b98e85e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: