타이핑 연출

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

Binary file not shown.

View File

@@ -20,6 +20,10 @@ public class DialogNode : ScriptableObject
[Header("Presentation")] [Header("Presentation")]
public AudioClip Bgm; // 있으면 이 대사부터 전용 BGM 재생, 비어있으면 기본 BGM으로 복귀 public AudioClip Bgm; // 있으면 이 대사부터 전용 BGM 재생, 비어있으면 기본 BGM으로 복귀
[Tooltip("이 대사만의 타이핑 연출(속도·글자색·타이핑 사운드). " +
"비우면 DialogHud의 기본 스타일을 쓴다")]
public TypewriterStyle Typewriter;
[Header("Flow")] [Header("Flow")]
public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드 public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드
public List<DialogChoice> Choices; // 있으면 플레이어 선택 대기 public List<DialogChoice> Choices; // 있으면 플레이어 선택 대기

View File

@@ -288,9 +288,9 @@ private void CaptureInitialAnimState(Animator anim)
// 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행 // 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행
private async Awaitable<bool> PlayNode(DialogNode node) private async Awaitable<bool> PlayNode(DialogNode node)
{ {
// DialogHud에 대사 표시 (이름은 노드 오버라이드 우선) // DialogHud에 대사 표시 (이름은 노드 오버라이드 우선, 타이핑 연출도 노드 지정이 우선)
if (DialogHud.Instance != null) if (DialogHud.Instance != null)
DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride); DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride, node.Typewriter);
// 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC // 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC
if (node.Affection != 0) if (node.Affection != 0)
@@ -467,7 +467,12 @@ private async Awaitable<bool> WaitAdvanceOrDivert()
while (true) while (true)
{ {
if (HiddenBranchResolver.Fired) return true; // 히든 발동 → 히든 분기 if (HiddenBranchResolver.Fired) return true; // 히든 발동 → 히든 분기
if (advance) return false; // 진행 입력 → 평범하게 진행 if (advance)
{
advance = false;
// 타이핑 중이었으면 이 입력은 "전체 표시"에 쓰고 계속 기다린다
if (!TrySkipReveal()) return false; // 이미 다 나옴 → 평범하게 진행
}
if (timeLeft >= 0f) if (timeLeft >= 0f)
{ {
timeLeft -= Time.deltaTime; timeLeft -= Time.deltaTime;
@@ -486,7 +491,17 @@ private async Awaitable<bool> WaitAdvanceOrDivert()
} }
} }
// 대화 진행 입력(OnDialogNext) 한 번을 대기 // 대사가 아직 드러나는 중이면 전체 표시로 건너뛰고 true.
// 이미 다 나와 있으면 false — 그 입력은 다음 노드로 넘어가는 데 쓰인다.
private static bool TrySkipReveal()
{
var hud = DialogHud.Instance;
if (hud == null || !hud.IsRevealing) return false;
hud.SkipReveal();
return true;
}
// 대화 진행 입력(OnDialogNext) 한 번을 대기 (타이핑 중이면 첫 입력은 전체 표시에 쓰인다)
private async Awaitable WaitForAdvanceInput() private async Awaitable WaitForAdvanceInput()
{ {
var im = InputManager.Instance; var im = InputManager.Instance;
@@ -502,8 +517,16 @@ private async Awaitable WaitForAdvanceInput()
im.OnDialogNext_Event += Handler; im.OnDialogNext_Event += Handler;
try try
{ {
while (!pressed) while (true)
{
if (pressed)
{
pressed = false;
// 타이핑 중이었으면 이 입력은 "전체 표시"에 쓰고 계속 기다린다
if (!TrySkipReveal()) break; // 이미 다 나옴 → 다음 노드로
}
await Awaitable.NextFrameAsync(destroyCancellationToken); await Awaitable.NextFrameAsync(destroyCancellationToken);
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor
// .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다. // .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다.
// 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다. // 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다.
// 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다. // 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다.
[ScriptedImporter(16, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨 [ScriptedImporter(17, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨
internal class DialogGraphImporter : ScriptedImporter internal class DialogGraphImporter : ScriptedImporter
{ {
public override void OnImportAsset(AssetImportContext ctx) public override void OnImportAsset(AssetImportContext ctx)
@@ -117,6 +117,7 @@ public override void OnImportAsset(AssetImportContext ctx)
dn.Expression = GetInputPortValue<ExpressionData>(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION)); dn.Expression = GetInputPortValue<ExpressionData>(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION));
dn.Voice = GetInputPortValue<VoiceClip>(gn.GetInputPortByName(DialogLineNode.PORT_VOICE)); dn.Voice = GetInputPortValue<VoiceClip>(gn.GetInputPortByName(DialogLineNode.PORT_VOICE));
dn.Bgm = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogLineNode.PORT_BGM)); dn.Bgm = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogLineNode.PORT_BGM));
dn.Typewriter = GetInputPortValue<TypewriterStyle>(gn.GetInputPortByName(DialogLineNode.PORT_TYPEWRITER));
dn.Affection = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_AFFECTION)); dn.Affection = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_AFFECTION));
dn.Progress = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_PROGRESS)); dn.Progress = GetInputPortValue<int>(gn.GetInputPortByName(DialogLineNode.PORT_PROGRESS));

View File

@@ -21,6 +21,7 @@ internal class DialogLineNode : DialogGraphNode
public const string PORT_EXPRESSION = "Expression"; public const string PORT_EXPRESSION = "Expression";
public const string PORT_VOICE = "Voice"; public const string PORT_VOICE = "Voice";
public const string PORT_BGM = "Bgm"; public const string PORT_BGM = "Bgm";
public const string PORT_TYPEWRITER = "Typewriter";
public const string PORT_AFFECTION = "Affection"; public const string PORT_AFFECTION = "Affection";
public const string PORT_PROGRESS = "Progress"; public const string PORT_PROGRESS = "Progress";
public const string PORT_QUESTION = "ChoiceQuestion"; public const string PORT_QUESTION = "ChoiceQuestion";
@@ -69,6 +70,9 @@ protected override void OnDefinePorts(IPortDefinitionContext context)
context.AddInputPort<VoiceClip>(PORT_VOICE).WithDisplayName("Voice").Build(); context.AddInputPort<VoiceClip>(PORT_VOICE).WithDisplayName("Voice").Build();
context.AddInputPort<AudioClip>(PORT_BGM).WithDisplayName("BGM") context.AddInputPort<AudioClip>(PORT_BGM).WithDisplayName("BGM")
.WithTooltip("있으면 이 대사부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build(); .WithTooltip("있으면 이 대사부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build();
context.AddInputPort<TypewriterStyle>(PORT_TYPEWRITER).WithDisplayName("Typewriter")
.WithTooltip("이 대사만의 타이핑 연출(속도·글자색·타이핑 사운드). " +
"비우면 DialogHud의 기본 스타일").Build();
context.AddInputPort<int>(PORT_AFFECTION).WithDisplayName("Affection ±") context.AddInputPort<int>(PORT_AFFECTION).WithDisplayName("Affection ±")
.WithTooltip("0이 아니면 이 대사 재생 시 화자(비우면 대화 주인 NPC)의 호감도를 이만큼 증감").Build(); .WithTooltip("0이 아니면 이 대사 재생 시 화자(비우면 대화 주인 NPC)의 호감도를 이만큼 증감").Build();
context.AddInputPort<int>(PORT_PROGRESS).WithDisplayName("Progress +") context.AddInputPort<int>(PORT_PROGRESS).WithDisplayName("Progress +")

View File

@@ -174,6 +174,34 @@ private AudioSource GetSfxSource()
return source; return source;
} }
//=========================== Looping SFX ===========================
//타이핑 사운드처럼 "시작 → 임의 시점에 정지"가 필요한 루프 재생.
//반환값은 정지에 쓰는 핸들이다. PlaySFX와 달리 자동 반납되지 않으니 반드시 StopLoop를 부를 것.
public AudioSource PlayLoop(AudioClip clip, float volume = 1f)
{
if (clip == null) return null;
AudioSource source = GetSfxSource();
source.loop = true;
source.clip = clip;
source.volume = volume;
source.Play();
return source;
}
//루프 재생을 멈추고 소스를 풀에 반납한다. 이미 반납된 소스(중복 호출)는 무시한다.
public void StopLoop(AudioSource source)
{
if (source == null || !source.gameObject.activeSelf) return;
source.Stop();
source.loop = false; //풀에 돌아가는 소스는 항상 loop=false 상태여야 한다
source.clip = null;
source.gameObject.SetActive(false);
_sfxPool.Enqueue(source);
}
//재생 길이만큼 대기 후 소스를 풀로 반납 //재생 길이만큼 대기 후 소스를 풀로 반납
private async Awaitable ReturnAfterPlay(AudioSource source, float duration) private async Awaitable ReturnAfterPlay(AudioSource source, float duration)
{ {

View File

@@ -0,0 +1,33 @@
using UnityEngine;
// 타이핑 연출 프리셋 — 속도 / 문장부호 대기 / 글자 색 / 타이핑되는 동안 깔리는 사운드.
//
// 대사 노드(DialogNode.Typewriter)에 물리면 그 대사만 이 연출로 나온다. 비우면 DialogHud의 기본 스타일.
// 톤이 다른 연출이 필요하면 에셋을 하나 더 만들면 된다 — 예를 들어 장소 이름 표시는
// "느린 속도 + 다른 색 + 다른 사운드"인데, 그게 곧 이 에셋 하나다.
[CreateAssetMenu(menuName = "Communication/Typewriter Style")]
public class TypewriterStyle : ScriptableObject
{
[Header("Speed")]
[Tooltip("초당 드러나는 글자 수")]
[Min(1f)] public float CharsPerSecond = 30f;
[Tooltip("쉼표류(, 、 ·) 뒤에 추가로 쉬는 시간(초)")]
[Min(0f)] public float CommaPause = 0.12f;
[Tooltip("문장 끝(. ! ? … 。) 뒤에 추가로 쉬는 시간(초)")]
[Min(0f)] public float SentencePause = 0.3f;
[Header("Color")]
[Tooltip("켜면 글자 색을 아래 색으로 덮는다. 끄면 USS에 지정된 색 그대로")]
public bool OverrideColor;
public Color TextColor = Color.white;
[Header("Sound")]
[Tooltip("타이핑되는 동안 루프로 재생할 사운드. 비우면 무음. " +
"글자마다 개별 재생이 아니라, 타이핑이 끝나거나 스킵되면 멈춘다")]
public AudioClip TypingSound;
[Range(0f, 1f)] public float TypingVolume = 1f;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3aadea59345839a40b38e471686b4d1f

View File

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

View File

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

Binary file not shown.

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c3ad6c9a10473554891e2229d433afe8
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant: