
인물들이 대사를 할 때 적용할 세팅을 담당하는 클래스를 static으로 선언하여, 전역적으로 호출이 가능하도록 한다. 아래 세팅의 경우 대사가 한 글자씩 출력되도록하는 기능을 가지고 있다.
// 스크립트 출력시 전역적으로 호출이 가능한 static 클래스
public static class ScriptSetting
{
// 한 글자씩 출력되는 기능을 코루틴으로 구현
public static IEnumerator WriteWords
(TextMeshProUGUI text, string str, WaitForSeconds delay, Func<bool> skipRequested)
{
// 최적화를 위한 stringbuilder 활용
StringBuilder strText = new StringBuilder();
for (int i = 0; i < str.Length; i++)
{
strText.Append(str[i]);
text.text = strText.ToString();
// 외부 입력을 통해 skiprequest 업데이트 시 한 번에 출력
if (skipRequested())
{
text.text = str;
break;
}
yield return delay;
}
}
}
기본적으로 입력한 문자열이 스크립트 세팅을 통해 한 글자씩 출력되도록 하되, 인게임 화면을 누르는 경우 _skipRequested가 true가 되어 모든 대사가 한 번에 출력되도록 구현했다.
책임 분리를 위해 해당 기능은 스크립트 세팅에서 작업했다.
public class script : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI text;
private WaitForSeconds _delay;
private bool _skipRequested;
private void OnEnable()
{
Init();
StartCoroutine(ScriptSetting.WriteWords
(text, "안녕하세요 저는 원숭이라고 합니다.", _delay, () => _skipRequested));
}
private void Init()
{
// 글자 출력 딜레이 캐싱
_delay = new WaitForSeconds(0.05f);
}
public void OnClickSkipButton()
{
_skipRequested = true;
}
}
단일 스크립트, 대사가 하나 밖에 없는 경우 단순 문자열을 사용하면 되지만, 대사가 많은 경우 출력 타이밍을 분리해야하므로 string 배열을 활용하여 출력하고자 하는 스크립트를 구분하여 배열에 담았다.
public class SpeechController : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI text;
private WaitForSeconds _delay;
private bool _skipRequested;
private void OnEnable()
{
Init();
StartCoroutine(TypeScripts());
}
private void Init()
{
_delay = new WaitForSeconds(0.05f);
}
private IEnumerator TypeScripts()
{
string[] scripts = Scripts(3);
for (int i = 0; i < scripts.Length; i++)
{
string str = scripts[i];
yield return StartCoroutine
(ScriptSetting.WriteWords(text, str, _delay, () => _skipRequested));
_skipRequested = false;
yield return new WaitForSeconds(1f);
}
}
private string[] Scripts(int num)
{
string[] scripts = new string[num];
scripts[0] = "원숭이 손오공";
scripts[1] = "본인 등장";
scripts[2] = "나를 깨운 자 누구인가?";
return scripts;
}
public void OnClickSkipButton()
{
_skipRequested = true;
}
}
만약 스크립트가 인게임 화면에 무질서하게 출력된다면 게임의 몰입도를 해칠 수 있기에, 스크립트를 담을 말풍선이나 UI를 구현하는 것은 굉장히 중요하다.
인게임 하단에 UI를 구현하고 그 안에 스크립트를 출력하거나, 인물의 상단에 말풍선이 생성되어 내부에 스크립트를 출력하는 등 여러 방식으로 스크립트를 담을 수 있지만, 이 중 2D 말풍선을 구현해보았다.
Asprite 프로그램을 활용하여 직접 제작하거나 이미지를 다운 받아 유니티에 넣어주면 해당 png파일을 말풍선으로 사용이 가능해진다.
여기서 출력하고자 하는 글자수에 비례하여 말풍선의 크기가 달라지는 기능까지 구현하기 위해서는 추가적인 설정이 필요하다.
위의 이미지는 말풍선의 뼈대가 되는 이미지를 Asprite를 활용하여 만든 것이다.
하지만 해당 이미지를 아무런 세팅없이 sprite로 지정하게 될 경우 양 옆 부분이 텍스트와 함께 늘어나 모양이 깨지게되는데, 이를 방지하기 위해 가운데 빈 부분만 늘어나도록 설정할 필요가 있다.

Texture Type을 Sprite(2D and UI)로 설정한 뒤, 도트 표현을 살리기 위해 Filter Mode를 Point(no filter)로 바꿔준다.
Pixels Per Unit(PPU)는 유닛에 포함되는 픽셀 수로 보통 설정한 픽셀 수와 동일하게 가져가며, 크기가 작을수록 더 커지기 때문에 이를 참고하여 설정해준다.
변경 사항을 Apply를 눌러 적용시키고 Sprite Editor에 들어가 Border를 설정해준다.

위 사진을 보면 초록색 경계를 직접 움직여 Border를 설정할 수 있고, 옆의 Sprite 창에서 Border에 값을 입력하여 설정하는 것이 가능하다. 초록색 경계에서 벗어난 영역은 크기가 변함에 따라 변동이 없기 때문에, 초록색으로 둘러쌓인 영역만 크기가 늘어남에 따라 함께 늘어나게 된다.
따라서 가운데 부분만 초록색 영역으로 두고 Apply를 눌러 적용시킨다.
이미지 파일에 대한 Sprite 설정이 끝났으면 하이어라키 창에서 우클릭 - UI - Image를 생성하여 해당 이미지 인스펙터의 Image 컴포넌트 - Source Image에 이미지 파일을 드래그하여 넣어준다.

이미지를 넣고 2개의 컴포넌트를 추가하여 텍스트 길이에 따라 달라지도록 설정을 해야한다.
Horizontal Layout Group : 본인과 하위 객체의 레이아웃 설정을 그룹화해주는 역할. Padding은 여백이며 Child Alignment과 Control Child Size로 하위 객체의 정렬 방식과 크기를 정할 수 있다.Content Size Fitter : 이미지의 크기를 고정된 값이 아닌 하위 텍스트에 크기에 맞춰 설정해주는 역할. 세로 길이는 글자 폰트 크기가 달라지지 않는 이상 고정으로 두고, 가로 길이만 Preferred Size로 설정하여 텍스트 길이에 따라 달라지도록 설정한다.이미지 설정까지 마쳤다면 하위 객체로 UI - TMP를 추가하여 텍스트 설정을 해준다.
Font Asset은 사용하는 폰트가 따로 있다면 넣어주고, 원하는 Font 크기를 설정한뒤 우측 상단에 정렬이 되도록 한다. 컴포넌트로 작성한 Script를 추가한 뒤 인스펙터에 각각 연결해준다.

Extra Settings의 Margins에서 4방향의 여백을 개별적으로 설정하는 것 또한 가능하다.

위에서 언급한 대로 말풍선의 크기를 최대로 정해놓고 왼쪽에서 글자가 하나씩 출력되도록 하고자 한다면 먼저 이미지에 추가한 컴포넌트(Content Size Fitter)를 비활성화할 필요가 있다. 말풍선의 크기를 출력될 모든 글자 수로 정해질 것이기 때문에 출력된 글자수에 맞춰서 변동되면 안되기 때문이다. 이후 스크립트 세팅에 사이즈를 결정하는 SetSize() 메서드를 추가한다.
public static class ScriptSetting
{
public static IEnumerator WriteWords
(TextMeshProUGUI text, string str, WaitForSeconds delay, Func<bool> skipRequested)
{
StringBuilder strText = new StringBuilder();
for (int i = 0; i < str.Length; i++)
{
strText.Append(str[i]);
text.text = strText.ToString();
if (skipRequested())
{
text.text = str;
break;
}
yield return delay;
}
}
public static Vector2 SetSize(TextMeshProUGUI text, string str)
{
text.text = str;
LayoutRebuilder.ForceRebuildLayoutImmediate(text.rectTransform);
Vector2 preferredSize = text.GetPreferredValues(str);
float paddingX = 0f;
float paddingY = 30f;
text.rectTransform.SetSizeWithCurrentAnchors
(RectTransform.Axis.Horizontal,preferredSize.x);
text.rectTransform.SetSizeWithCurrentAnchors
(RectTransform.Axis.Vertical,preferredSize.y);
return new Vector2(preferredSize.x + paddingX, preferredSize.y + paddingY);
}
}
그리고 출력하고자 하는 스크립트에 img.sizeDelta = ScriptSetting.SetSize(text, str);를 추가하여 이미지와 내부 텍스트의 크기를 설정한다.
public class SpeechController : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI text;
[SerializeField] private RectTransform img;
[SerializeField] private GameObject speechBubble;
private WaitForSeconds _delay;
private bool _skipRequested;
private void OnEnable()
{
Init();
StartCoroutine(TypeScripts());
}
private void Init()
{
_delay = new WaitForSeconds(0.05f);
}
private IEnumerator TypeScripts()
{
string[] scripts = Scripts(3);
for (int i = 0; i < scripts.Length; i++)
{
string str = scripts[i];
img.sizeDelta = ScriptSetting.SetSize(text, str);
yield return StartCoroutine
(ScriptSetting.WriteWords(text, str, _delay, () => _skipRequested));
_skipRequested = false;
yield return new WaitForSeconds(1f);
}
HideSpeechBubble();
}
private string[] Scripts(int num)
{
string[] scripts = new string[num];
scripts[0] = "원숭이 손오공";
scripts[1] = "본인 등장";
scripts[2] = "나를 막을 자 누구인가";
return scripts;
}
private void HideSpeechBubble()
{
speechBubble.SetActive(false);
}
public void OnClickSkipButton()
{
_skipRequested = true;
}
}
이렇게 설정을 마무리하면 말풍선은 아래와 같이 출력된다.

말풍선이 엉뚱한 곳에 나타나면 안되기 때문에 말하고 있는 인물 위에 나와야할 필요가 있다. 따라서Canvas의 Render Mode를 Screen Space- Camera로 설정한 뒤 소스코드를 추가해준다.
public class SpeechFollow : MonoBehaviour
{
[SerializeField] private Transform target;
// 캐릭터 살짝 위에 말풍선이 위치하기 위한 조정
private Vector3 _offset = new Vector3(0f, 1f, 0f);
private RectTransform _rectTransform;
private Canvas _canvas;
private void Awake()
{
Init();
}
말풍선의 위치가 확실하게 결정되도록 LateUpdate에서 처리
private void LateUpdate()
{
if (target == null || _canvas == null) return;
Vector3 worldPos = target.position + _offset;
Vector3 screenPos = Camera.main.WorldToScreenPoint(worldPos);
// Canvas RenderMode가 다른 경우의 예외 처리
if (_canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
_rectTransform.position = screenPos;
}
else
{
// 말풍선의 위치를 지정하는 유니티 메서드
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_canvas.transform as RectTransform,
screenPos,
_canvas.worldCamera,
out Vector2 localPoint);
_rectTransform.localPosition = localPoint;
}
}
private void Init()
{
_rectTransform = GetComponent<RectTransform>();
_canvas = GetComponentInParent<Canvas>();
if (target == null)
{
Debug.LogWarning("SpeechFollow: target이 지정되지 않았습니다.");
return;
}
}
}
콘솔창에서 대사를 출력하는 것은 굉장히 쉬운 일이었지만, Unity에서 대사를 출력하는 것은 꽤나 손이 가는 작업이었다. script setting의 코드 자체는 비슷했지만 말풍선의 위치를 지정하는 것과 말풍선, 텍스트의 크기를 설정하는 것이 생각보다 까다로웠다.
말풍선은 보통 2D에서 많이 사용되는 형태이기 때문에 2D 뿐만이 아닌 3D에서도 보편적으로 사용되는 하단 UI 스크립트 출력 방식도 한 번 다루어봐야할 것 같다.
혼자 이렇게 하면 되는건가... 하고 구글링을 통해 만들었기에 최적화가 잘 되어있는지, 현업에서 활용될 수 있을지 여부를 잘 모르겠다. 일단 작업한 내용을 까먹기 전에 정리하고 싶어서 일단 흐름대로 작성했다.
UI 연출 방식, 카메라 렌더 모드 등 아직 헷갈리는 개념이 많아 전체적으로 확인하고 체크할 필요성이 있다.