타이핑 연출
This commit is contained in:
BIN
Assets/01_Scenes/TestScene.unity
LFS
BIN
Assets/01_Scenes/TestScene.unity
LFS
Binary file not shown.
@@ -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; // 있으면 플레이어 선택 대기
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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 +")
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3aadea59345839a40b38e471686b4d1f
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
8
Assets/07_Data/TypewriterStyle.meta
Normal file
8
Assets/07_Data/TypewriterStyle.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 76f8b16779e5d3f4e9c077511375ba1d
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Assets/07_Data/TypewriterStyle/TWS_Default.asset
LFS
Normal file
BIN
Assets/07_Data/TypewriterStyle/TWS_Default.asset
LFS
Normal file
Binary file not shown.
8
Assets/07_Data/TypewriterStyle/TWS_Default.asset.meta
Normal file
8
Assets/07_Data/TypewriterStyle/TWS_Default.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c3ad6c9a10473554891e2229d433afe8
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user