타이핑 연출

This commit is contained in:
2026-07-30 11:28:35 +09:00
parent 28f014152c
commit 073567c652
14 changed files with 343 additions and 19 deletions

View File

@@ -2,17 +2,21 @@
using UnityEngine.UIElements;
// UI Toolkit 버전 대사 HUD. DialogUI.uxml의 요소(#SpeakerName / #DialogText / #DialogField)를 잡아
// 화자 이름 + 대사를 표시한다.
// 공개 API(Instance / Show / Hide)는 기존과 동일 — DialogPlayer는 수정 없이 그대로 쓴다.
// 화자 이름 + 대사를 표시한다. 대사는 Typewriter로 한 글자씩 드러난다.
//
// UIDocument의 후속인 PanelRenderer를 쓴다. 요소 참조는 UI 리로드 콜백으로 받는다:
// - rootVisualElement가 준비됐는지 매번 확인하던 지연 초기화(EnsureRefs)가 사라진다.
// - 플레이 중 UXML을 수정해 UI가 리로드돼도 참조가 자동으로 다시 연결된다.
// - 플레이 중 UXML을 수정해 UI가 리로드돼도 참조가 자동으로 다시 연결되고,
// 타이핑 중이던 대사도 그 지점부터 이어진다 (표시가 ApplyState 한 곳으로 모여 있어서).
[RequireComponent(typeof(PanelRenderer))]
public class DialogHud : MonoBehaviour
{
public static DialogHud Instance { get; private set; }
[Tooltip("대사의 기본 타이핑 연출. 노드에 Typewriter가 지정돼 있으면 그것이 우선한다. " +
"여기까지 비우면 연출 없이 대사가 한 번에 표시된다")]
[SerializeField] private TypewriterStyle _defaultStyle;
private PanelRenderer _panelRenderer;
private VisualElement _panel; // 대사 패널(#DialogField) — 토글 대상
@@ -22,16 +26,24 @@ public class DialogHud : MonoBehaviour
// 현재 표시 상태. 리로드로 요소가 새로 만들어졌을 때 이 값으로 복원한다.
private bool _visible;
private string _speakerText = string.Empty;
private string _bodyText = string.Empty;
private Typewriter _typewriter;
// 같은 버전으로 콜백이 중복 호출될 때 헛일을 막는다 (Unity 권장 패턴)
private int _uiVersion = -1;
// 대사가 아직 드러나는 중인가 (DialogPlayer가 진행 입력을 스킵으로 쓸지 판단)
public bool IsRevealing => _typewriter != null && _typewriter.IsRevealing;
// 남은 글자를 즉시 전부 드러낸다 (진행 입력 1번째)
public void SkipReveal() => _typewriter?.SkipToEnd();
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
_typewriter = new Typewriter(ApplyState); // 진행될 때마다 화면에 반영
_panelRenderer = GetComponent<PanelRenderer>();
// root가 이미 준비돼 있으면 즉시 호출되고, 이후 UI가 리로드될 때마다 다시 호출된다
_panelRenderer.RegisterUIReloadCallback(OnUIReload);
@@ -57,25 +69,28 @@ private void OnUIReload(PanelRenderer panelRenderer, VisualElement root, int ver
ApplyState(); // 첫 호출에선 _visible=false라 숨김 상태로 시작한다
}
// 화자 이름 + 대사 표시.
// 화자 이름 + 대사 표시. 대사는 style(없으면 기본 스타일)로 한 글자씩 드러난다.
// - speakerNameOverride가 비어있지 않으면 CharacterData.Name 대신 그 이름을 표시한다 (예: "???")
public void Show(CharacterData speaker, string text, string speakerNameOverride = null)
// - style은 이 대사만의 연출 (DialogNode.Typewriter). 둘 다 비면 한 번에 표시된다.
public void Show(CharacterData speaker, string text, string speakerNameOverride = null,
TypewriterStyle style = null)
{
string speakerName = !string.IsNullOrEmpty(speakerNameOverride) ? speakerNameOverride
: speaker != null ? speaker.Name : string.Empty;
_speakerText = DialogVariables.Format(speakerName); // {key} 토큰 치환
_bodyText = DialogVariables.Format(text);
_visible = true;
ApplyState();
// Begin이 진행 콜백으로 ApplyState를 부르므로 _visible을 먼저 세워 둔다
_typewriter.Begin(DialogVariables.Format(text), style != null ? style : _defaultStyle,
destroyCancellationToken);
}
public void Hide()
{
_speakerText = string.Empty;
_bodyText = string.Empty;
_visible = false;
ApplyState();
_typewriter.Clear(); // 타이핑 사운드 정지 + ApplyState 호출
}
// 캐시된 상태를 실제 요소에 반영한다.
@@ -83,7 +98,19 @@ public void Hide()
private void ApplyState()
{
if (_speakerName != null) _speakerName.text = _speakerText;
if (_dialogText != null) _dialogText.text = _bodyText;
if (_dialogText != null)
{
_dialogText.text = _typewriter.Composed;
// 스타일이 색을 덮으면 인라인으로 지정하고, 아니면 인라인을 걷어 USS 색으로 되돌린다
var style = _typewriter.Style;
if (style != null && style.OverrideColor)
_dialogText.style.color = style.TextColor;
else
_dialogText.style.color = StyleKeyword.Null;
}
if (_panel != null)
_panel.style.display = _visible ? DisplayStyle.Flex : DisplayStyle.None;
}

View File

@@ -0,0 +1,181 @@
using System;
using System.Threading;
using UnityEngine;
// 텍스트를 한 글자씩 드러내는 연출 엔진. MonoBehaviour가 아니라 상태 + 타이밍만 담당한다.
//
// 표시는 소유자가 한다 — 진행될 때마다 onChanged가 불리고, 소유자는 Composed를 자기 Label에 넣는다.
// 덕분에 UI 리로드로 Label 인스턴스가 새로 만들어져도, 소유자가 Composed를 다시 반영하면
// 타이핑 중이던 지점부터 그대로 이어진다.
//
// 드러내는 방식은 부분 문자열이 아니라 <alpha=#00> 태그다:
// 부분 문자열로 하면 글자가 늘어나며 줄바꿈이 재계산돼 마지막 단어가 다음 줄로 튀지만,
// 이 방식은 전체 텍스트가 항상 레이아웃되므로 줄바꿈이 고정된다.
public sealed class Typewriter
{
private const string HIDE_TAG = "<alpha=#00>";
private readonly Action _onChanged;
private string _full = string.Empty;
private int _cursor; // _full 안에서 다음에 드러낼 위치 (태그 포함 raw 인덱스)
private TypewriterStyle _style;
private bool _running;
private AudioSource _loopSource;
// Begin/Clear마다 증가. 실행 중인 RunAsync는 자기 세대가 아니면 즉시 물러난다 —
// 대사를 빠르게 넘기면 이전 루프가 NextFrameAsync에서 깨어나 새 타이핑의 커서를
// 같이 전진시켜(속도 2배) 버리는 것을 막는다.
private int _generation;
public Typewriter(Action onChanged) => _onChanged = onChanged;
// 타이핑이 진행 중인가. 완료되거나 스킵되면 false
public bool IsRevealing => _running;
// 지금 적용할 스타일 (글자 색 오버라이드 판단용). 연출 없이 표시했으면 null
public TypewriterStyle Style => _style;
// Label.text에 그대로 넣을 문자열. 아직 안 드러난 뒷부분은 투명 처리돼 있다
public string Composed => _cursor >= _full.Length
? _full
: _full.Substring(0, _cursor) + HIDE_TAG + _full.Substring(_cursor);
// 새 텍스트로 타이핑을 시작한다. style이 null이면 연출 없이 즉시 전체 표시.
// token은 소유자(MonoBehaviour)의 destroyCancellationToken을 넘길 것.
public void Begin(string fullText, TypewriterStyle style, CancellationToken token)
{
Finish(); // 이전 타이핑 정리 (사운드 포함)
int generation = ++_generation;
_full = fullText ?? string.Empty;
_style = style;
_cursor = 0;
if (style == null || _full.Length == 0)
{
_cursor = _full.Length;
_onChanged?.Invoke();
return;
}
_running = true;
StartTypingSound();
_onChanged?.Invoke();
_ = RunAsync(generation, token); // 표시는 onChanged로 밀어내므로 완료를 기다릴 필요가 없다
}
// 남은 글자를 즉시 전부 드러낸다 (진행 입력 1번째의 동작).
// 호출 직후 IsRevealing이 false가 되므로, 다음 입력은 그대로 진행 입력으로 쓰인다.
public void SkipToEnd()
{
if (!_running) return;
_cursor = _full.Length;
Finish();
_onChanged?.Invoke();
}
// 텍스트를 비운다 (HUD를 숨길 때).
public void Clear()
{
Finish();
_generation++; // 돌고 있는 루프를 물러나게 한다
_full = string.Empty;
_cursor = 0;
_style = null;
_onChanged?.Invoke();
}
private async Awaitable RunAsync(int generation, CancellationToken token)
{
try
{
float timer = 0f;
while (_generation == generation && _running && _cursor < _full.Length)
{
timer -= Time.deltaTime;
// 한 프레임에 여러 글자가 나올 수도 있다 (속도가 프레임레이트보다 빠를 때)
bool changed = false;
while (timer <= 0f && _cursor < _full.Length)
{
char revealed = AdvanceOneVisibleChar();
timer += StepSeconds(revealed);
changed = true;
}
if (changed) _onChanged?.Invoke();
if (_cursor >= _full.Length) break;
await Awaitable.NextFrameAsync(token);
}
}
catch (OperationCanceledException)
{
// 씬 전환/오브젝트 파괴로 취소됨 — finally에서 정리된다
}
finally
{
// 내 세대일 때만 정리 — 이미 새 타이핑이 시작됐다면 그쪽 사운드를 끄면 안 된다
if (_generation == generation) Finish();
}
}
// 타이핑 종료 — 사운드를 멈추고 진행 중 표시를 내린다. 여러 번 불려도 안전하다.
private void Finish()
{
_running = false;
StopTypingSound();
}
// 커서를 보이는 글자 하나만큼 전진시키고 그 글자를 반환한다.
// 리치 텍스트 태그(<b>, <color=...> 등)는 보이는 글자가 아니므로 통째로 건너뛴다 —
// 태그 중간에 <alpha=#00>이 끼어들어 태그가 깨지는 것을 막는다.
private char AdvanceOneVisibleChar()
{
SkipTags();
if (_cursor >= _full.Length) return '\0';
char c = _full[_cursor];
// 서로게이트 페어(이모지 등)는 두 char가 한 글자다
_cursor += char.IsHighSurrogate(c) && _cursor + 1 < _full.Length ? 2 : 1;
SkipTags(); // 방금 글자 뒤에 닫는 태그가 붙어 있으면 같이 넘긴다
return c;
}
private void SkipTags()
{
while (_cursor < _full.Length && _full[_cursor] == '<')
{
int close = _full.IndexOf('>', _cursor + 1);
if (close < 0) return; // 닫히지 않은 '<' — 평범한 글자로 취급
_cursor = close + 1;
}
}
// 이 글자를 드러낸 뒤 다음 글자까지 기다릴 시간
private float StepSeconds(char revealed)
=> 1f / Mathf.Max(1f, _style.CharsPerSecond) + PauseAfter(revealed);
private float PauseAfter(char c) => c switch
{
'.' or '!' or '?' or '…' or '。' or '' or '' => _style.SentencePause,
',' or '、' or '·' or '' => _style.CommaPause,
_ => 0f,
};
private void StartTypingSound()
{
if (_style == null || _style.TypingSound == null) return;
var sound = SoundManager.Instance;
if (sound == null) return;
_loopSource = sound.PlayLoop(_style.TypingSound, _style.TypingVolume);
}
private void StopTypingSound()
{
if (_loopSource == null) return;
if (SoundManager.Instance != null)
SoundManager.Instance.StopLoop(_loopSource);
_loopSource = null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 052bb7e2c07256d42bcc7c66a9a3c44e