From 073567c652c4502e984552ba0cbdf05278aabdd7 Mon Sep 17 00:00:00 2001 From: nakjun Date: Thu, 30 Jul 2026 11:28:35 +0900 Subject: [PATCH] =?UTF-8?q?=ED=83=80=EC=9D=B4=ED=95=91=20=EC=97=B0?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/01_Scenes/TestScene.unity | 4 +- .../Communication/Dialog/DialogNode.cs | 4 + .../Communication/Dialog/DialogPlayer.cs | 33 +++- .../GraphTool/Editor/DialogGraphImporter.cs | 3 +- .../Dialog/GraphTool/Editor/DialogLineNode.cs | 4 + Assets/02_Scripts/Managers/SoundManager.cs | 28 +++ .../Communication/Dialog/TypewriterStyle.cs | 33 ++++ .../Dialog/TypewriterStyle.cs.meta | 2 + .../02_Scripts/_UI/Communication/DialogHud.cs | 49 +++-- Assets/02_Scripts/_UI/Typewriter.cs | 181 ++++++++++++++++++ Assets/02_Scripts/_UI/Typewriter.cs.meta | 2 + Assets/07_Data/TypewriterStyle.meta | 8 + .../07_Data/TypewriterStyle/TWS_Default.asset | 3 + .../TypewriterStyle/TWS_Default.asset.meta | 8 + 14 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs create mode 100644 Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs.meta create mode 100644 Assets/02_Scripts/_UI/Typewriter.cs create mode 100644 Assets/02_Scripts/_UI/Typewriter.cs.meta create mode 100644 Assets/07_Data/TypewriterStyle.meta create mode 100644 Assets/07_Data/TypewriterStyle/TWS_Default.asset create mode 100644 Assets/07_Data/TypewriterStyle/TWS_Default.asset.meta diff --git a/Assets/01_Scenes/TestScene.unity b/Assets/01_Scenes/TestScene.unity index 61d6bcb..f6b5fb5 100644 --- a/Assets/01_Scenes/TestScene.unity +++ b/Assets/01_Scenes/TestScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86d391b69a080b393e583dc40c20fb41a4bf94dc05dec47d5305480c6b9c78e2 -size 19502 +oid sha256:277cc60cb67a35ecf3445ce1561e0b815adb10fec99c401854373883070da098 +size 19587 diff --git a/Assets/02_Scripts/Communication/Dialog/DialogNode.cs b/Assets/02_Scripts/Communication/Dialog/DialogNode.cs index 9f2387b..6020681 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogNode.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogNode.cs @@ -20,6 +20,10 @@ public class DialogNode : ScriptableObject [Header("Presentation")] public AudioClip Bgm; // 있으면 이 대사부터 전용 BGM 재생, 비어있으면 기본 BGM으로 복귀 + [Tooltip("이 대사만의 타이핑 연출(속도·글자색·타이핑 사운드). " + + "비우면 DialogHud의 기본 스타일을 쓴다")] + public TypewriterStyle Typewriter; + [Header("Flow")] public DialogNode Next; // 선택지 없을 때 자동으로 갈 노드 public List Choices; // 있으면 플레이어 선택 대기 diff --git a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs index ab14948..f0c746e 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs @@ -288,9 +288,9 @@ private void CaptureInitialAnimState(Animator anim) // 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행 private async Awaitable PlayNode(DialogNode node) { - // DialogHud에 대사 표시 (이름은 노드 오버라이드 우선) + // DialogHud에 대사 표시 (이름은 노드 오버라이드 우선, 타이핑 연출도 노드 지정이 우선) 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 if (node.Affection != 0) @@ -467,7 +467,12 @@ private async Awaitable WaitAdvanceOrDivert() while (true) { if (HiddenBranchResolver.Fired) return true; // 히든 발동 → 히든 분기 - if (advance) return false; // 진행 입력 → 평범하게 진행 + if (advance) + { + advance = false; + // 타이핑 중이었으면 이 입력은 "전체 표시"에 쓰고 계속 기다린다 + if (!TrySkipReveal()) return false; // 이미 다 나옴 → 평범하게 진행 + } if (timeLeft >= 0f) { timeLeft -= Time.deltaTime; @@ -486,7 +491,17 @@ private async Awaitable 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() { var im = InputManager.Instance; @@ -502,8 +517,16 @@ private async Awaitable WaitForAdvanceInput() im.OnDialogNext_Event += Handler; try { - while (!pressed) + while (true) + { + if (pressed) + { + pressed = false; + // 타이핑 중이었으면 이 입력은 "전체 표시"에 쓰고 계속 기다린다 + if (!TrySkipReveal()) break; // 이미 다 나옴 → 다음 노드로 + } await Awaitable.NextFrameAsync(destroyCancellationToken); + } } catch (OperationCanceledException) { diff --git a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs index 54790c2..e96bdfe 100644 --- a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs +++ b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs @@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor // .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다. // 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다. // 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다. - [ScriptedImporter(16, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨 + [ScriptedImporter(17, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨 internal class DialogGraphImporter : ScriptedImporter { public override void OnImportAsset(AssetImportContext ctx) @@ -117,6 +117,7 @@ public override void OnImportAsset(AssetImportContext ctx) dn.Expression = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION)); dn.Voice = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_VOICE)); dn.Bgm = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_BGM)); + dn.Typewriter = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_TYPEWRITER)); dn.Affection = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_AFFECTION)); dn.Progress = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_PROGRESS)); diff --git a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs index 2a2716c..82a6362 100644 --- a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs +++ b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs @@ -21,6 +21,7 @@ internal class DialogLineNode : DialogGraphNode public const string PORT_EXPRESSION = "Expression"; public const string PORT_VOICE = "Voice"; public const string PORT_BGM = "Bgm"; + public const string PORT_TYPEWRITER = "Typewriter"; public const string PORT_AFFECTION = "Affection"; public const string PORT_PROGRESS = "Progress"; public const string PORT_QUESTION = "ChoiceQuestion"; @@ -69,6 +70,9 @@ protected override void OnDefinePorts(IPortDefinitionContext context) context.AddInputPort(PORT_VOICE).WithDisplayName("Voice").Build(); context.AddInputPort(PORT_BGM).WithDisplayName("BGM") .WithTooltip("있으면 이 대사부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build(); + context.AddInputPort(PORT_TYPEWRITER).WithDisplayName("Typewriter") + .WithTooltip("이 대사만의 타이핑 연출(속도·글자색·타이핑 사운드). " + + "비우면 DialogHud의 기본 스타일").Build(); context.AddInputPort(PORT_AFFECTION).WithDisplayName("Affection ±") .WithTooltip("0이 아니면 이 대사 재생 시 화자(비우면 대화 주인 NPC)의 호감도를 이만큼 증감").Build(); context.AddInputPort(PORT_PROGRESS).WithDisplayName("Progress +") diff --git a/Assets/02_Scripts/Managers/SoundManager.cs b/Assets/02_Scripts/Managers/SoundManager.cs index 64653ae..0c4ecf1 100644 --- a/Assets/02_Scripts/Managers/SoundManager.cs +++ b/Assets/02_Scripts/Managers/SoundManager.cs @@ -174,6 +174,34 @@ private AudioSource GetSfxSource() 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) { diff --git a/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs b/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs new file mode 100644 index 0000000..640aac6 --- /dev/null +++ b/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs @@ -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; +} diff --git a/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs.meta b/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs.meta new file mode 100644 index 0000000..2654708 --- /dev/null +++ b/Assets/02_Scripts/_Data/Communication/Dialog/TypewriterStyle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3aadea59345839a40b38e471686b4d1f \ No newline at end of file diff --git a/Assets/02_Scripts/_UI/Communication/DialogHud.cs b/Assets/02_Scripts/_UI/Communication/DialogHud.cs index 46249d1..e518fb1 100644 --- a/Assets/02_Scripts/_UI/Communication/DialogHud.cs +++ b/Assets/02_Scripts/_UI/Communication/DialogHud.cs @@ -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(); // 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; } diff --git a/Assets/02_Scripts/_UI/Typewriter.cs b/Assets/02_Scripts/_UI/Typewriter.cs new file mode 100644 index 0000000..a9e256d --- /dev/null +++ b/Assets/02_Scripts/_UI/Typewriter.cs @@ -0,0 +1,181 @@ +using System; +using System.Threading; +using UnityEngine; + +// 텍스트를 한 글자씩 드러내는 연출 엔진. MonoBehaviour가 아니라 상태 + 타이밍만 담당한다. +// +// 표시는 소유자가 한다 — 진행될 때마다 onChanged가 불리고, 소유자는 Composed를 자기 Label에 넣는다. +// 덕분에 UI 리로드로 Label 인스턴스가 새로 만들어져도, 소유자가 Composed를 다시 반영하면 +// 타이핑 중이던 지점부터 그대로 이어진다. +// +// 드러내는 방식은 부분 문자열이 아니라 태그다: +// 부분 문자열로 하면 글자가 늘어나며 줄바꿈이 재계산돼 마지막 단어가 다음 줄로 튀지만, +// 이 방식은 전체 텍스트가 항상 레이아웃되므로 줄바꿈이 고정된다. +public sealed class Typewriter +{ + private const string HIDE_TAG = ""; + + 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(); + } + + // 커서를 보이는 글자 하나만큼 전진시키고 그 글자를 반환한다. + // 리치 텍스트 태그(, 등)는 보이는 글자가 아니므로 통째로 건너뛴다 — + // 태그 중간에 이 끼어들어 태그가 깨지는 것을 막는다. + 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; + } +} diff --git a/Assets/02_Scripts/_UI/Typewriter.cs.meta b/Assets/02_Scripts/_UI/Typewriter.cs.meta new file mode 100644 index 0000000..18587b2 --- /dev/null +++ b/Assets/02_Scripts/_UI/Typewriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 052bb7e2c07256d42bcc7c66a9a3c44e \ No newline at end of file diff --git a/Assets/07_Data/TypewriterStyle.meta b/Assets/07_Data/TypewriterStyle.meta new file mode 100644 index 0000000..b3f6904 --- /dev/null +++ b/Assets/07_Data/TypewriterStyle.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76f8b16779e5d3f4e9c077511375ba1d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/07_Data/TypewriterStyle/TWS_Default.asset b/Assets/07_Data/TypewriterStyle/TWS_Default.asset new file mode 100644 index 0000000..acaaaad --- /dev/null +++ b/Assets/07_Data/TypewriterStyle/TWS_Default.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55f00b6de5a4faf61e0d80797f73a4b9741a027051cc15d1585ff8cdb424250a +size 595 diff --git a/Assets/07_Data/TypewriterStyle/TWS_Default.asset.meta b/Assets/07_Data/TypewriterStyle/TWS_Default.asset.meta new file mode 100644 index 0000000..0699597 --- /dev/null +++ b/Assets/07_Data/TypewriterStyle/TWS_Default.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c3ad6c9a10473554891e2229d433afe8 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: