타이핑 연출
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
181
Assets/02_Scripts/_UI/Typewriter.cs
Normal file
181
Assets/02_Scripts/_UI/Typewriter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/_UI/Typewriter.cs.meta
Normal file
2
Assets/02_Scripts/_UI/Typewriter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 052bb7e2c07256d42bcc7c66a9a3c44e
|
||||
Reference in New Issue
Block a user