수정 사항

This commit is contained in:
2026-08-09 13:58:32 +09:00
parent 8a4a001fe0
commit 120233faac
28 changed files with 3471 additions and 73 deletions

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;
@@ -18,6 +20,65 @@ public static class DialogMarkup
public const string OPEN = "[[";
public const string CLOSE = "]]";
// 저작자가 원하는 자리에 직접 뜸을 넣는 표기: 그날 밤<<delay:0.4>> 저는 봤습니다
//
// 문장부호 자동 쉼(TypewriterStyle.CommaPause/SentencePause)은 "어디에나 일정하게" 걸리는
// 바닥값이고, 이건 "여기서 이만큼" 을 콕 집는 수단이다. 둘은 대체 관계가 아니라 층이 다르다.
public const string DELAY_OPEN = "<<delay:";
public const string DELAY_CLOSE = ">>";
// <<delay:x>> 표기를 텍스트에서 걷어내고, 걷어낸 자리(결과 문자열 기준 인덱스)와 초를 모아 준다.
//
// 왜 텍스트에서 반드시 제거해야 하는가: Typewriter는 받은 문자열을 그대로 Label에 넣으므로,
// 표기가 남아 있으면 화면에 "<<delay:0.4>>"가 그대로 찍힌다.
// 인덱스를 결과 문자열 기준으로 잡는 것도 같은 이유다 — Typewriter의 커서와 같은 좌표계여야 한다.
//
// delays는 인덱스 오름차순으로 채워진다 (앞에서부터 훑으므로 자연히 정렬된다).
public static string ExtractDelays(string text, List<(int Index, float Seconds)> delays)
{
delays?.Clear();
if (string.IsNullOrEmpty(text) || text.IndexOf(DELAY_OPEN, StringComparison.Ordinal) < 0)
return text;
var sb = new StringBuilder(text.Length);
int i = 0;
while (i < text.Length)
{
int open = text.IndexOf(DELAY_OPEN, i, StringComparison.Ordinal);
if (open < 0)
{
sb.Append(text, i, text.Length - i);
break;
}
int close = text.IndexOf(DELAY_CLOSE, open + DELAY_OPEN.Length, StringComparison.Ordinal);
if (close < 0)
{
sb.Append(text, i, text.Length - i); // 닫히지 않은 표기 — 손대지 않고 그대로 둔다
break;
}
string raw = text.Substring(open + DELAY_OPEN.Length, close - open - DELAY_OPEN.Length);
// 숫자가 아니면 저작 실수다. 조용히 지우면 "왜 뜸이 없지"를 한참 찾게 되므로,
// 경고를 남기고 표기를 화면에 그대로 남겨 눈에 띄게 한다.
if (!float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float seconds))
{
Debug.LogWarning($"[DialogMarkup] 지연 표기의 숫자를 읽지 못함: {DELAY_OPEN}{raw}{DELAY_CLOSE}");
sb.Append(text, i, close + DELAY_CLOSE.Length - i);
i = close + DELAY_CLOSE.Length;
continue;
}
sb.Append(text, i, open - i);
delays?.Add((sb.Length, Mathf.Max(0f, seconds)));
i = close + DELAY_CLOSE.Length;
}
return sb.ToString();
}
public static string Expand(string text, TypewriterStyle style)
{
if (string.IsNullOrEmpty(text) || text.IndexOf(OPEN, StringComparison.Ordinal) < 0)

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using UnityEngine;
@@ -24,6 +25,11 @@ public sealed class Typewriter
private bool _running;
private AudioSource _loopSource;
// 저작자가 <<delay:x>>로 찍어 둔 뜸 — (커서 위치, 초). 인덱스 오름차순.
// 표기 자체는 _full에서 제거돼 있으므로 Composed는 이걸 전혀 신경 쓰지 않아도 된다.
private readonly List<(int Index, float Seconds)> _delays = new();
private int _delayCursor; // 다음에 먹을 지연 (한 번 먹은 것은 다시 먹지 않는다)
// Begin/Clear마다 증가. 실행 중인 RunAsync는 자기 세대가 아니면 즉시 물러난다 —
// 대사를 빠르게 넘기면 이전 루프가 NextFrameAsync에서 깨어나 새 타이핑의 커서를
// 같이 전진시켜(속도 2배) 버리는 것을 막는다.
@@ -77,7 +83,11 @@ public void Begin(string fullText, TypewriterStyle style, CancellationToken toke
Finish(); // 이전 타이핑 정리 (사운드 포함)
int generation = ++_generation;
_full = fullText ?? string.Empty;
// 지연 표기는 여기서 걷어낸다 — 남으면 화면에 그대로 찍힌다.
// 연출 없이(style == null) 즉시 표시하는 경로에서도 걷어내야 하므로 분기보다 앞에 둔다.
_full = DialogMarkup.ExtractDelays(fullText ?? string.Empty, _delays);
_delayCursor = 0;
_style = style;
_cursor = 0;
@@ -100,6 +110,7 @@ public void SkipToEnd()
{
if (!_running) return;
_cursor = _full.Length;
_delayCursor = _delays.Count; // 건너뛴 구간의 뜸도 같이 소진 처리
Finish();
_onChanged?.Invoke();
}
@@ -111,6 +122,8 @@ public void Clear()
_generation++; // 돌고 있는 루프를 물러나게 한다
_full = string.Empty;
_cursor = 0;
_delays.Clear();
_delayCursor = 0;
_style = null;
_onChanged?.Invoke();
}
@@ -128,6 +141,16 @@ private async Awaitable RunAsync(int generation, CancellationToken token)
bool changed = false;
while (timer <= 0f && _cursor < _full.Length)
{
// 글자를 내기 **전에** 이 자리의 <<delay:x>>를 먼저 먹는다.
// 그래야 "<<delay:1>>안녕"이 1초 쉰 뒤에 타이핑을 시작한다 —
// 나중에 먹으면 '안'이 먼저 나와 버린다.
float authored = ConsumeDelaysAt(_cursor);
if (authored > 0f)
{
timer += authored;
break;
}
char revealed = AdvanceOneVisibleChar();
timer += StepSeconds(revealed);
changed = true;
@@ -181,6 +204,19 @@ private void SkipTags()
}
}
// 커서 위치까지 밀린 저작 지연을 모아 소비한다.
// 같은 자리에 여러 개가 겹쳐 있으면 합산된다.
private float ConsumeDelaysAt(int cursor)
{
float total = 0f;
while (_delayCursor < _delays.Count && _delays[_delayCursor].Index <= cursor)
{
total += _delays[_delayCursor].Seconds;
_delayCursor++;
}
return total;
}
// 이 글자를 드러낸 뒤 다음 글자까지 기다릴 시간
private float StepSeconds(char revealed)
=> 1f / Mathf.Max(1f, _style.CharsPerSecond) + PauseAfter(revealed);