UNITY/Script

[UNITY C#] Localization을 이용한 대화 시스템 구현

HYEOKJUN 2024. 9. 26. 15:00
반응형


대화 시스템 (Dialogue System)

 대화 시스템은 캐릭터 간의 상호작용을 통해 게임의 서사를 더 생동감 있게 전달할 수 있습니다.

 간단한 게임의 경우, Unity에서 이 시스템을 만드는 것은 어렵지 않습니다. 하지만 게임의 사이즈가 커지면서 대화의 양이 많아지고 여기에 현지화 기능까지 추가한다면, 이 기능들을 모두 담은 시스템을 구현하기는 까다로울 수 있습니다. 

 Unity에는 강력한 현지화 기능을 제공하는 Localization이라는 Package가 존재합니다. 이 현지화 기능을 대화 시스템에 적용시키기 위해서 다음과 같은 작업을 진행합니다.


 

Package 설치 및 기본 설정

https://hyeokjunjjang.tistory.com/entry/UNITY-Package-Localization-간단-사용법

 

[UNITY] Localization 간단 사용법

https://docs.unity3d.com/Packages/com.unity.localization@1.1 Home Page. | Localization | 1.1.1Home Page. This is the home page for this package.docs.unity3d.comPackage 설치하기[Window > Package Manager]를 선택합니다.좌측 상단 [+]을 클릭

hyeokjunjjang.tistory.com

 위 링크를 참고하여 Localization Package를 설치하고 게임에서 표시할 언어를 설정합니다.

 

https://hyeokjunjjang.tistory.com/entry/UNITY-TextMesh-PUNITY-TextMesh-Pro-간단-사용법

 

[UNITY] TextMesh Pro 간단 사용법

https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.0 TextMesh Pro User Guide | TextMeshPro | 3.0.6 TextMesh Pro User Guide Overview This User Guide was designed to provide first time users of TextMesh Pro with a basic overview of the features and fu

hyeokjunjjang.tistory.com

 추가적으로 이 대화 시스템은 TextMeshPro를 사용합니다. 위 링크를 참고하여 TextMeshPro Package를 설치합니다.


기본 대화 데이터 설계하기

 기본 데이터를 구현하기 위해 다음과 같은 스크립트를 작성합니다.

DialogueData.cs

더보기
using System;
using UnityEngine;
using UnityEngine.Localization;

#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Dialogue
{
    /// <summary>
    /// 대화의 문장 데이터를 나타냅니다.
    /// </summary>
    [Serializable]
    public class DialogueData
    {
        /// <summary>
        /// 현재 문장을 참조할 현지화 문자열 테이블 엔트리를 나타냅니다. 
        /// </summary>
        public LocalizedString ref_cur;
        /// <summary>
        /// 현재 문장에 대해 선택할 수 있는 옵션 현지화 문자열 테이블 엔트리 리스트를 나타냅니다.
        /// </summary>
        public LocalizedString[] refs_option = null;
        /// <summary>
        /// 현재 문장의 다음으로 표시할 현지화 문자열 테이블 엔트리를 나타냅니다.
        /// </summary>
        public LocalizedString ref_next = null;
        /// <summary>
        /// 대화 애니메이터를 한 단계 작동시키는지 여부를 나타냅니다.
        /// </summary>
        public bool continueNextAnim = false;
        /// <summary>
        /// 이 문장 데이터가 대화의 끝 부분인지 여부를 나타냅니다.
        /// </summary>
        public bool isEndPoint = false;
        /// <summary>
        /// 이 문장 데이터가 옵션 분기점인지 여부를 나타냅니다.
        /// (true일 경우 refs_option을 사용합니다.
        /// false일 경우 ref_next를 사용합니다.)
        /// </summary>
        public bool isOptionPoint = false;

        public DialogueData(DialogueData dialogueData)
        {
            ref_cur = dialogueData.ref_cur;
            refs_option = new LocalizedString[dialogueData.refs_option.Length];
            for(int i = 0; i < refs_option.Length; i++) 
            {
                refs_option[i] = dialogueData.refs_option[i];
            }
            ref_next = dialogueData.ref_next;
            continueNextAnim = dialogueData.continueNextAnim;
            isEndPoint = dialogueData.isEndPoint;
            isOptionPoint = dialogueData.isOptionPoint;
        }
#if UNITY_EDITOR
        [CustomPropertyDrawer(typeof(DialogueData))]
        public class DialogueDataDrawer : PropertyDrawer
        {
            public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
            {
                EditorGUI.BeginProperty(position, label, property);

                property.isExpanded = EditorGUI.Foldout(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight), property.isExpanded, label);
                if (property.isExpanded)
                {
                    float lineHeight = EditorGUIUtility.singleLineHeight;
                    float spacing = 2f;
                    position.y += lineHeight + spacing;

                    EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, lineHeight), property.FindPropertyRelative("ref_cur"));

                    position.y += lineHeight + spacing;
                    EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, lineHeight), property.FindPropertyRelative("continueNextAnim"));

                    position.y += lineHeight + spacing;
                    SerializedProperty isEndPointProp = property.FindPropertyRelative("isEndPoint");
                    EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, lineHeight), isEndPointProp);

                    if (!isEndPointProp.boolValue)
                    {
                        position.y += lineHeight + spacing;
                        SerializedProperty isOptionPointProp = property.FindPropertyRelative("isOptionPoint");
                        EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, lineHeight), isOptionPointProp);

                        position.y += lineHeight + spacing;
                        if (isOptionPointProp.boolValue)
                        {
                            SerializedProperty refsOptionProp = property.FindPropertyRelative("refs_option");
                            EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, lineHeight), refsOptionProp, true);
                        }
                        else
                        {
                            EditorGUI.PropertyField(new Rect(position.x, position.y, position.width, lineHeight), property.FindPropertyRelative("ref_next"));
                        }
                    }
                }

                EditorGUI.EndProperty();
            }

            public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
            {
                if (!property.isExpanded)
                {
                    return EditorGUIUtility.singleLineHeight;
                }

                float height = EditorGUIUtility.singleLineHeight; // 폴드아웃 높이
                float lineHeight = EditorGUIUtility.singleLineHeight;
                float spacing = 2f;

                height += (lineHeight + spacing) * 3; // ref_cur, continueNextAnim, isEndPoint

                SerializedProperty isEndPointProp = property.FindPropertyRelative("isEndPoint");
                if (!isEndPointProp.boolValue)
                {
                    height += lineHeight + spacing; // isOptionPoint

                    SerializedProperty isOptionPointProp = property.FindPropertyRelative("isOptionPoint");
                    if (isOptionPointProp.boolValue)
                    {
                        SerializedProperty refsOptionProp = property.FindPropertyRelative("refs_option");
                        height += EditorGUI.GetPropertyHeight(refsOptionProp, true) + spacing;
                    }
                    else
                    {
                        height += lineHeight + spacing; // ref_next
                    }
                }

                return height;
            }
        }
#endif
    }

}

 대화 시스템의 가장 작은 기본 데이터인 DialogueData.cs를 작성합니다.

 Localized String Table을 참조하여 문자열 데이터를 불러오기 위해 LocalizedString 형식으로 Entry 값을 필드에 할당했습니다.

 해당 대화 데이터 다음으로 불러올 LocalizedString을 isOptionPoint를 이용해서 Option 방식으로 설정하거나 단순한 대화 데이터로 설정할 수 있습니다. 

 이 대화 데이터는 미리 설정된 애니메이션을 실행할 수도 있습니다. continueNextAnim단계별로 애니메이션을 설정한 애니메이터를 한 단계 작동하도록 기능합니다.

 DialogueDataDrawer 내부 클래스는 Unity Inspector에서 표시하는 방식을 변경하여 더 편집하기 쉽게 만들어 줍니다. 만약 isOptionPoint가 true라면 ref_next는 사용하지 않으므로 Inspector에서 사라지게 하고, false라면 refs_option을 사용하지 않으므로 refs_option을 사라지게 만듭니다.


DialogueBook.cs

더보기
using UnityEngine;
using UnityEngine.Localization.Tables;

namespace Dialogue
{

    /// <summary>
    /// 대화집의 기본 데이터를 나타냅니다.
    /// </summary>
    [CreateAssetMenu(fileName = "DialogueBook", menuName = "ScriptableObjects/DialogueBook")]
    public class DialogueBook : ScriptableObject 
    {
        /// <summary>
        /// 대화집을 대표 및 구별하는 테이블 참조값을 나타냅니다.
        /// </summary>
        public TableReference tableRef = string.Empty;

        /// <summary>
        /// 대화집에 사용할 애니메이터를 나타냅니다.
        /// </summary>
        public RuntimeAnimatorController animatorController = null;
        
        /// <summary>
        /// 대화집을 구성하는 문장 데이터 리스트를 나타냅니다.
        /// </summary>
        public DialogueData[] dialogueDatas;
    }

}

 대화 데이터들의 집합인 DialogueBook.cs를 작성합니다.

 tableRef는DialogueBook 간의 식별을 더 쉽게 하기 위해 둔 필드 변수입니다. 책의 이름이라고 볼 수 있습니다.

 animatorController는 대화 시스템에서 사용할 사전에 만들어진 Animator Controller를 나타냅니다.


Scirpt 작성 완료

 다음과 같이 대화 시스템의 기본 데이터 스크립트를 작성했습니다. 다음으로는 ScriptableObject를 이용해 DialogueBook Asset을 만들어야 합니다.

 [Project 창]에서 [Create > ScriptableObjects > DialogueBook]을 선택하여 DialogueBook Asset을 생성합니다.

Asset 생성 완료

 다음과 같이 DialogueBook Asset은 여러 개 존재할 수 있습니다. 더 많은 Asset이 필요하다면 DialogueBook Asset을 관리하는 BookManager와 같은 관리 클래스를 만드는 것이 좋습니다.

대화 시스템 UI는 다음과 같이 구성됩니다.


대화 시스템 UI 구성

DialogueController는 대화 시스템의 모든 요소를 포함합니다.

ObjectContainer는 대화 시스템의 애니메이션에 나타나는 배경과 캐릭터 이미지를 포함합니다. 만약 한 화면에 여러 캐릭터가 필요하다면 이미지를 추가하고, 그렇지 않으면 애니메이션에서 이미지의 스프라이트를 변경하여 캐릭터 이미지를 조정합니다.

DialogueContinueArea는 패널 형태로, 클릭 시 다음 대화로 진행되는 클릭 트리거 기능을 수행합니다.

DialoguePrinter는 대화 내용을 나타내는 모든 UI 요소를 포함하며, 화자의 이름과 대화 내용을 표시합니다. OptionBox_Template는 대화가 진행될 때 옵션의 수에 따라 인스턴스화되어 여러 선택지를 제공합니다.

다음 단계로 각 UI 요소에 해당하는 아래의 스크립트를 장착해야 합니다.


DialogueController.cs

더보기
using System;
using System.Linq;
using System.Collections;
using UnityEngine;
using UnityEngine.Localization;

namespace Dialogue
{
    /// <summary>
    /// 현지화된 대화집을 나타냅니다.
    /// </summary>
    public class LocalizedDialogueBook : DialogueBook 
    {
        public new LocalizedDialogueData[] dialogueDatas;

        public LocalizedDialogueBook(DialogueBook dialogueBook) : base(dialogueBook) {}
    }

    /// <summary>
    /// 현지화된 대화 데이터를 나타냅니다.
    /// </summary>
    public class LocalizedDialogueData : DialogueData
    {
        public string localizedString;

        public LocalizedDialogueData(DialogueData dialogueData) : base(dialogueData) {}
    }

    /// <summary>
    /// 대화 시스템를 관리 및 제어하는 클래스입니다.
    /// </summary>
    public class DialogueController : MonoBehaviour
    {
        // 테스트용 코드 //
        [Header("Test")]
        [SerializeField] internal DialogueBook dialogueBook_test;
        protected void Start()
        {
            if(dialogueBook_test != null)
            {
                Play(dialogueBook_test);
            }
        }
        ///////////////////
        
        [Header("UI")]
        /// <summary>
        /// 대화 출력 UI를 나타냅니다.
        /// </summary>
        [SerializeField] protected DialoguePrinter dialoguePrinter;
        /// <summary>
        /// 현재 재생중인 대화집을 나타냅니다.
        /// </summary>
        protected LocalizedDialogueBook dialogueBook_playing;
        /// <summary>
        /// 현재 재생중인 대화집의 문장 데이터 인덱스를 나타냅니다.
        /// </summary>
        internal int index_selector = -1;
        /// <summary>
        /// 대화가 재생중인지 여부를 나타냅니다.
        /// </summary>
        protected bool isPlayingDialogue;
        /// <summary>
        /// 대화를 스킵중인지 여부를 나타냅니다.
        /// </summary>
        protected bool isSkipping;
        /// <summary>
        /// 대화가 끝났을 때 호출될 콜백 함수를 나타냅니다.
        /// </summary>
        internal Action callback_end;

        protected void Update()
        {
            if(isPlayingDialogue && !dialoguePrinter.IsOptionSelectPhase && Input.GetKey(KeyCode.Space))
            {
                Continue();
            }
        }

        /// <summary>
        /// 대화를 빠른 속도로 출력하여 스킵합니다.
        /// </summary>
        public void Skip()
        {
            StartCoroutine(SkipCoroutine());
        }

        /// <summary>
        /// 대화를 빠른 속도로 출력하여 스킵하는 코루틴을 나타냅니다.
        /// </summary>
        internal IEnumerator SkipCoroutine()
        {
            isSkipping = true;

            while (isPlayingDialogue)
            {
                yield return null;
                yield return null;
                Continue();
            }

            isSkipping = false;
        }

        /// <summary>
        /// 대화집을 재생합니다.
        /// </summary>
        /// <param name="dialogueBook">재생할 대화집을 나타냅니다.</param>
        /// <param name="callback_end">대화가 끝났을 때 호출될 콜백 함수를 나타냅니다.</param>
        public void Play(DialogueBook dialogueBook, Action callback_end = null)
        {
            if (dialogueBook == null)
            {
                throw new ArgumentNullException(nameof(dialogueBook), "null 값이 될 수 없습니다.");
            }

            isPlayingDialogue = true;
            this.callback_end = callback_end;
            dialoguePrinter.SetAnimationController(dialogueBook.animatorController);
            dialogueBook_playing = new(dialogueBook) 
            {
                dialogueDatas = new LocalizedDialogueData[dialogueBook.dialogueDatas.Length]
            };
            for (int i = 0; i < dialogueBook_playing.dialogueDatas.Length; i++)
            {
                dialogueBook_playing.dialogueDatas[i] = new(dialogueBook.dialogueDatas[i])
                {
                    localizedString = dialogueBook.dialogueDatas[i].ref_cur.GetLocalizedString(),
                };
            }
            index_selector = 0;
            Continue();
        }

        /// <summary>
        /// 다음 대화를 계속 재생합니다.
        /// (대화가 끝났으면 콜백 함수를 호출합니다.)
        /// </summary>  
        public void Continue()
        {
            if(!isPlayingDialogue 
            || dialoguePrinter.SkipTying() 
            || dialoguePrinter.IsOptionSelectPhase)
            {
                return;
            }

            LocalizedDialogueData ref_cur = dialogueBook_playing.dialogueDatas[index_selector];
            dialoguePrinter.Print(ref_cur);

            if(ref_cur.isEndPoint)
            {
                EndDialogue();
                return;
            }

            if (ref_cur.isOptionPoint)
            {
                HandleOptionPoint(ref_cur);
            }
            else
            {
                MoveSelectorToNextDialogue(ref_cur.ref_next);
            }
        }

        /// <summary>
        /// 대화 종료를 알립니다.
        /// </summary>
        internal void EndDialogue()
        {
            isPlayingDialogue = false;
            isSkipping = false;
            index_selector = -1;
            callback_end?.Invoke();
        }

        /// <summary>
        /// 대화 옵션 선택 단계에 진입합니다.
        /// </summary>
        /// <param name="ref_cur">현재 대화 데이터를 나타냅니다.</param>
        internal void HandleOptionPoint(LocalizedDialogueData ref_cur)
        {
            LocalizedDialogueData[] refs_option = ref_cur.refs_option.Select(FindDialogueData).ToArray();
            dialoguePrinter.SetOptions(refs_option, index => 
                {
                    MoveSelectorToNextDialogue(ref_cur.refs_option[index]);
                    Continue();
                }
            );
        }

        /// <summary>
        /// 인덱서를 다음 대화 데이터로 이동시킵니다.
        /// </summary>
        /// <param name="nextRef">다음 문자열 테이블 엔트리를 나타냅니다.</param>
        internal void MoveSelectorToNextDialogue(LocalizedString ref_next)
        {
            if(dialogueBook_playing == null)
            {
                throw new InvalidOperationException("dialogueBook_playing이 설정되어 있지 않습니다.");
            }

            index_selector = Array.FindIndex(dialogueBook_playing.dialogueDatas, d => CompareLocalizedString(d.ref_cur, ref_next));
            if (index_selector == -1)
            {
                throw new InvalidOperationException("해당하는 LocalizedDialogueData를 찾을 수 없습니다.");
            }
        }

        /// <summary>
        /// 대화 데이터를 찾습니다.
        /// </summary>
        /// <param name="reference">찾을 문자열 테이블 엔트리를 나타냅니다.</param>
        /// <returns>찾은 대화 데이터를 나타냅니다.</returns>
        internal LocalizedDialogueData FindDialogueData(LocalizedString reference)
        {
            if(dialogueBook_playing == null)
            {
                throw new InvalidOperationException("dialogueBook_playing이 설정되어 있지 않습니다.");
            }

            return dialogueBook_playing.dialogueDatas.FirstOrDefault(d => CompareLocalizedString(d.ref_cur, reference))
                ?? throw new InvalidOperationException("해당하는 옵션의 LocalizedDialogueData가 존재하지 않습니다.");
        }

        /// <summary>
        /// 두 문자열 테이블 엔트리의 참조가 같은지 비교합니다.
        /// </summary>
        /// <param name="ref_lhs">비교할 첫 번째 문자열 테이블 엔트리를 나타냅니다.</param>
        /// <param name="ref_rhs">비교할 두 번째 문자열 테이블 엔트리를 나타냅니다.</param>
        /// <returns>두 문자열 테이블 엔트리의 참조가 같으면 true, 다르면 false를 반환합니다.</returns>
        internal static bool CompareLocalizedString(LocalizedString ref_lhs, LocalizedString ref_rhs)
        {
            return ref_lhs.TableEntryReference.Equals(ref_rhs.TableEntryReference);
        }
    }

}

* 테스트 코드가 파일의 앞부분에 위치합니다. 테스트가 완료되면, 해당 코드 영역을 제거하세요.

LocalizedDialogueBookLocalizedDialogueData는 DialogueBook과 DialogueData의 현지화된 문자열을 가지는 데이터입니다. LocalizedString의 GetLocalizedString 메서드를 사용하여 문자열을 미리 받아 캐싱하는 string 변수를 가지고 있습니다. DialogueController와 DialoguePrinter에서 사용됩니다.

DialogueController는 대화 시스템의 모든 요소를 종합적으로 관리하는 클래스입니다. Play 메서드를 이용하여 재생할 DialogueBook을 설정할 수 있고 대화가 종료되었을 때 실행할 콜백 함수도 등록할 수 있습니다. Continue 메서드를 이용하여 다음 대화 내용을 불러오거나, 대화 옵션 선택 단계라면 DialogueOptionBox를 띄워 선택지를 제공합니다.

DialogueController.cs는 Scene UI에 생성된 DialogueController GameObject에 장착합니다. 또한 DialogueController GameObject에는 [Animator Component]도 추가해야 합니다.

완성된 DialogueController GameObject


DialoguePrinter.cs

더보기
using System.Collections;
using UnityEngine;
using TMPro;
using System;
using System.Collections.Generic;

namespace Dialogue
{

    /// <summary>
    /// 대화 출력 UI를 관리하는 클래스입니다.
    /// </summary>
    public class DialoguePrinter : MonoBehaviour
    {
        [Header("Animator")]
        [SerializeField] protected Animator animator = null;
        [Header("Contents")]
        [SerializeField] protected TextMeshProUGUI name_text = null;
        [SerializeField] protected TextMeshProUGUI content_text = null;
        [Header("Option")]
        [SerializeField] protected GameObject dialogueOptionBoxesContainer_object = null;
        [SerializeField] internal GameObject dialogueOptionBox_template = null;

        /// <summary>
        /// 현재 생성된 대화 옵션 박스를 나타냅니다.
        /// </summary>
        internal readonly List<DialogueOptionBox> dialogueOptionBoxes = new();

        /// <summary>
        /// 타이핑 코루틴을 나타냅니다.
        /// </summary>
        internal Coroutine coroutine_typing;

        /// <summary>
        /// 타이핑 중 여부를 나타냅니다.
        /// </summary>
        protected bool isTyping = false;
        /// <summary>
        /// 옵션 선택 단계 여부를 나타냅니다.
        /// </summary>
        protected bool isOptionSelectPhase = false;
        /// <summary>
        /// 옵션 선택 단계 여부를 나타냅니다.
        /// </summary>
        public bool IsOptionSelectPhase => isOptionSelectPhase;

        /// <summary>
        /// 캐싱된 대화 데이터를 나타냅니다.
        /// (타이핑 스킵 효과에 사용됩니다.) 
        /// </summary>
        internal (string name, string content) line_cached;

        /// <summary>
        /// 문자 출력 사이의 시간을 멈추는 WaitForSeconds 객체를 나타냅니다.
        /// </summary>
        static readonly WaitForSeconds wfs = new(0.01f);

        protected void Awake()
        {
            name_text.text = string.Empty;
            content_text.text = string.Empty;

            dialogueOptionBoxesContainer_object.SetActive(false);
            dialogueOptionBox_template.SetActive(false);
        }

        /// <summary>
        /// UI를 활성화 또는 비활성화합니다.
        /// </summary>
        /// <param name="isActive">UI를 활성화할지 여부를 나타냅니다.</param>
        public void SwitchPrinter(bool isActive)
        {
            gameObject.SetActive(isActive);
        }

        /// <summary>
        /// 애니메이터 컨트롤러를 설정합니다.
        /// </summary>
        /// <param name="animatorController">설정할 애니메이터 컨트롤러를 나타냅니다.</param>
        public void SetAnimationController(RuntimeAnimatorController animatorController)
        {
            animator.runtimeAnimatorController = animatorController;
        }

        /// <summary>
        /// 대화를 출력합니다.
        /// </summary>
        /// <param name="lddata">출력할 대화 데이터를 나타냅니다.</param> 
        public void Print(LocalizedDialogueData lddata)
        {
            dialogueOptionBoxesContainer_object.SetActive(false);

            line_cached = DematerializeDialogueData(lddata);

            name_text.text = line_cached.name;
            content_text.text = string.Empty;
            coroutine_typing = StartCoroutine(Typing());

            if(lddata.continueNextAnim)
            {
                PlayNextInAnimator();
            }

            IEnumerator Typing() {
                isTyping = true;
                char[] content_char = line_cached.content.ToCharArray();
                int index_cur = 0;
                while(index_cur < content_char.Length) {
                    content_text.text += content_char[index_cur++];
                    yield return wfs;
                }

                yield return wfs;

                if(isOptionSelectPhase)
                {
                    dialogueOptionBoxesContainer_object.SetActive(true);
                }
                isTyping = false;
            }
        }

        /// <summary>
        /// 타이핑을 건너뛰는 메서드입니다.
        /// </summary>
        /// <returns>타이핑이 완료되었는지 여부를 반환합니다.</returns>
        public bool SkipTying() 
        {
            if(isTyping)
            {
                StopCoroutine(coroutine_typing);

                content_text.text = line_cached.content;

                if(isOptionSelectPhase)
                {
                    dialogueOptionBoxesContainer_object.SetActive(true);
                }
                isTyping = false;

                return true;
            }
            else
            {
                return false;
            }
        }

        /// <summary>
        /// 옵션 선택 콜백을 나타냅니다.
        /// </summary>
        Action<int> callback_option = null;
        /// <summary>
        /// 옵션 선택 콜백을 설정합니다.
        /// </summary>
        /// <param name="lddatas_option">옵션 대화 데이터를 나타냅니다.</param>
        /// <param name="callback_option">옵션 선택 콜백을 나타냅니다.</param>
        public void SetOptions(LocalizedDialogueData[] lddatas_option, Action<int> callback_option)
        {
            int count_option_less = lddatas_option.Length - dialogueOptionBoxes.Count;
            if(count_option_less > 0)
            {
                for(int r = 0; r < count_option_less; r++)
                {
                    GameObject optionBox_object = Instantiate(dialogueOptionBox_template, dialogueOptionBoxesContainer_object.transform);
                    DialogueOptionBox optionBox = optionBox_object.GetComponent<DialogueOptionBox>();
                    dialogueOptionBoxes.Add(optionBox);
                }
            }

            this.callback_option = callback_option;
            for(int i = 0; i < dialogueOptionBoxes.Count; i++)
            {
                if(i < lddatas_option.Length)
                {
                    dialogueOptionBoxes[i].SetOption(DematerializeDialogueData(lddatas_option[i]).content, index: i, callback_click: OnClickOptionBox);
                }
                else
                {
                    dialogueOptionBoxes[i].Hide();
                }
            }
            isOptionSelectPhase = true;
        }

        /// <summary>
        /// 옵션을 선택합니다.
        /// (클릭 이벤트에서 호출됩니다.)
        /// </summary>
        /// <param name="index">선택된 옵션의 인덱스를 나타냅니다.</param>
        public void OnClickOptionBox(int index)
        {
            if(index < 0 || index >= dialogueOptionBoxes.Count)
            {
                throw new ArgumentOutOfRangeException(nameof(index), "옵션 선택 인덱스가 범위를 벗어났습니다.");
            }

            isOptionSelectPhase = false;
            SetOptionInAnimator(index);
            callback_option.Invoke(index);
        }
        
        /// <summary>
        /// Animator를 한 단계 진행합니다.
        /// </summary>
        public void PlayNextInAnimator()
        {
            animator.SetTrigger("Next");
        }

        /// <summary>
        /// Animator의 옵션 분기점 변수를 설정합니다.
        /// </summary>
        /// <param name="index">선택할 옵션의 인덱스를 나타냅니다.</param>
        public void SetOptionInAnimator(int index)
        {
            animator.SetInteger("Option", index);
        }

        /// <summary>
        /// 대화 데이터를 특정 튜플로 변환합니다.
        /// </summary>
        /// <param name="lddata">변환할 대화 데이터를 나타냅니다.</param>
        /// <returns>대화 데이터 변환하여 (이름, 내용) 튜플 형식으로 반환합니다.</returns>
        internal static (string name, string content) DematerializeDialogueData(LocalizedDialogueData lddata)
        {
            string[] source_splited = lddata.localizedString.Split(":");

            return (source_splited.Length > 1) ? (source_splited[0], source_splited[1]) : (string.Empty, source_splited[0]);
        }
    }
}

DialoguePrinter는 대화 데이터를 UI에 표시하는 역할을 합니다. 문자열을 표시할 뿐만 아니라, Animator를 이용하여 대화 애니메이션을 표현할 수 있습니다.

DialogueOptionBox GameObject의 Template을 만들어 dialogueOptionBox_template에 할당해야 합니다. 이때 해당 GameObject에는 DialogueOptionBox.cs 스크립트가 장착된 상태여야 합니다. 또한 Animator를 사용하기 위해서는 SetAnimationController 메서드를 이용하여 애니메이터 컨트롤러를 할당해야 합니다. 

Print 메서드를 이용하여 LocalizedDialogueData의 현지화 문자열 데이터를 UI에 표시합니다. SetOptions 메서드를 이용하여 대화 옵션 데이터를 표시하고 또한 각 대화 옵션 선택 시 OnClickOptionBox 메서드를 실행하도록 합니다.

완성된 DialoguePrinter


OptionContainer

완성된 OptionsConatiner

OptionContainer는 다음과 같이 세팅합니다. [Vertical Layout Group Component]와 [Content Size Fitter]를 사용하여 자식 OptionBox GameObject를 자동으로 정렬합니다.


DialogueOptionBox.cs

더보기
using System;
using UnityEngine;
using TMPro;

namespace Dialogue
{

    /// <summary>
    /// 대화 옵션 박스를 나타냅니다.
    /// </summary>
    public class DialogueOptionBox : MonoBehaviour
    {
        [SerializeField] internal TextMeshProUGUI text = null;

        /// <summary>
        /// 옵션 박스의 인덱스를 나타냅니다.
        /// </summary>
        internal int index = -1;
        /// <summary>
        /// 옵션 박스를 클릭했을 때 호출될 콜백 함수입니다.
        /// </summary>
        internal Action<int> callback_click = null;

        /// <summary>
        /// 옵션 박스를 표시하는 메서드입니다.
        /// </summary>
        /// <param name="str">옵션 박스에 표시할 문자열입니다.</param>
        /// <param name="index">옵션 박스에 설정할 인덱스입니다.</param>
        /// <param name="callback_click">옵션 박스를 클릭했을 때 호출될 콜백 함수입니다.</param>
        public void SetOption(string str, int index, Action<int> callback_click)
        {
            text.text = str;
            this.index = index;
            this.callback_click = callback_click;
            gameObject.SetActive(true);
        }

        /// <summary>
        /// 옵션 박스를 숨기는 메서드입니다.
        /// </summary>
        public void Hide()
        {
            text.text = string.Empty;
            index = -1;
            callback_click = null;
            gameObject.SetActive(false);
        }

        /// <summary>
        /// 옵션 박스를 클릭했을 때 호출될 콜백 함수입니다.
        /// (OnClick 이벤트에서 호출됩니다.)
        /// </summary>
        public void OnClick()
        {
            callback_click?.Invoke(index);
        }
    }
    
}

DialogueOptionBox는 단일 대화 옵션 데이터를 UI에 표시하는 역할을 합니다. 이때 할당된 DialogueOptionBox GameObject에는 [Button Component]를 장착하여 OnClick 이벤트에  DialogueOptionBox의 OnClick 메서드를 할당한 상태여야 합니다.

SetOption 메서드를 이용하여 DialogueOptionBox를 초기화합니다.

완성된 DialogueOptionBox


Localied String Table 작성하기

 DialogueBook Asset을 작성하기 전에 Localization의 String Table을 만들어야 합니다.

 다음과 같이 새로운 String Table을 생성합니다. Table의 이름은 해당 DialogueBook Asset과 같거나 비슷하게 하는 것이 좋습니다. 그러고 나서 String Table을 작성합니다.

 대화의 흐름에 따라 대화 내용을 위처럼 작성합니다. Key 이름에 제약은 없지만 대화의 흐름을 알 수 있도록 작성하는 것이 좋습니다. 

name:content
content

 대화 내용 형식은 다음과 같으며, 콜론(:)으로 화자의 이름내용을 구분합니다. name없이 content만으로도 구성할 수 있으며 이때 Name UI에는 빈 문자열 값으로 표시됩니다.


Dialogue Animator 구성

Animation Clip 생성

 다음과 같이 ObjectContainer의 요소들을 이용하여 Animation Clip을 생성합니다. 

 대부분 Animation Clip의 Loop Time 체크 옵션을 해제하는 것이 더 좋습니다.  

완성된 Animation Clips


Animator Controller 생성

  Animator Controller를 생성하여, Parameters에 'Next'라는 Trigger 타입과 'Option'이라는 Int 타입의 파라미터를 추가합니다. 'Next'는 애니메이션을 다음 단계로 진행시키는 역할을 하고, 'Option'은 대화 옵션의 인덱스를 나타내며, 이는 0부터 시작합니다.

 생성된 모든 Animation Clip을 추가하고, Init이라는 빈 State를 생성하여 Layer Default State로 설정합니다. Init부터 대화의 흐름에 따라 Transition을 설정합니다. Transition 조건에는 'Next' Trigger를 항상 포함시키고, 옵션 선택 단계에서는 'Option' Int 값도 함께 설정합니다.

완성된 Animator Controller


대화 데이터 구성

 다시 DialogueBook으로 돌아와서 다음과 같이 TableRef, AnimatorController, DialogueDatas를 할당합니다.


작업 결과

 다음과 같이 대화 시스템이 정상적으로 작동하는 것을 확인할 수 있습니다.


[Reference]

https://hyeokjunjjang.tistory.com/entry/UNITY-Package-Localization-간단-사용법

 

[UNITY] Localization 간단 사용법

https://docs.unity3d.com/Packages/com.unity.localization@1.1 Home Page. | Localization | 1.1.1Home Page. This is the home page for this package.docs.unity3d.comPackage 설치하기[Window > Package Manager]를 선택합니다.좌측 상단 [+]을 클릭

hyeokjunjjang.tistory.com

 

반응형