using System;
using System.Collections;
using UnityEngine;
///
/// Reusable subtitle player that renders and plays subtitle lines loaded from JSON.
///
public class SubtitleSequencePlayer : MonoBehaviour
{
[Serializable]
private struct SubtitleLine
{
public string speaker;
public string text;
public float duration;
}
[Serializable]
private struct SubtitleFile
{
public SubtitleLine[] lines;
}
[Header("Optional Default Data")]
[Tooltip("Used only if trigger zone calls PlayDefault().")]
[SerializeField] private TextAsset defaultSubtitleJson;
[SerializeField] private float typewriterCharsPerSecond = 40f;
[SerializeField] private float fadeDuration = 0.2f;
[SerializeField] private float gapBetweenLines = 0.15f;
[Header("Visual")]
[SerializeField] private int fontSize = 28;
[SerializeField] private int speakerFontSize = 18;
[SerializeField] private float horizontalPadding = 28f;
[SerializeField] private float bottomOffset = 56f;
[SerializeField] private Color textColor = new Color(1f, 1f, 1f, 1f);
[SerializeField] private Color speakerColor = new Color(1f, 0.85f, 0.35f, 1f);
[SerializeField] private Color backgroundColor = new Color(0f, 0f, 0f, 0.62f);
private string m_currentSpeaker;
private string m_currentText;
private GUIStyle m_textStyle;
private GUIStyle m_speakerStyle;
private Texture2D m_background;
private bool m_isShowing;
private bool m_isPlaying;
private float m_alpha;
private SubtitleLine[] m_runtimeLines = Array.Empty();
public bool IsPlaying => m_isPlaying;
public bool TryPlay(TextAsset subtitleJson, float initialDelay = 0f)
{
if (m_isPlaying)
return false;
if (!TryReadLinesFromJson(subtitleJson, out SubtitleLine[] parsedLines))
return false;
m_runtimeLines = parsedLines;
StartCoroutine(PlaySequence(initialDelay));
return true;
}
public bool PlayDefault(float initialDelay = 0f)
{
return TryPlay(defaultSubtitleJson, initialDelay);
}
private IEnumerator PlaySequence(float initialDelay)
{
m_isPlaying = true;
if (m_runtimeLines == null || m_runtimeLines.Length == 0)
{
m_isPlaying = false;
yield break;
}
if (initialDelay > 0f)
yield return new WaitForSeconds(initialDelay);
for (int i = 0; i < m_runtimeLines.Length; i++)
{
if (string.IsNullOrWhiteSpace(m_runtimeLines[i].text) || m_runtimeLines[i].duration <= 0f)
continue;
yield return StartCoroutine(ShowLine(m_runtimeLines[i]));
if (gapBetweenLines > 0f)
yield return new WaitForSeconds(gapBetweenLines);
}
m_currentSpeaker = string.Empty;
m_currentText = string.Empty;
m_isPlaying = false;
}
private bool TryReadLinesFromJson(TextAsset subtitleJson, out SubtitleLine[] parsedLines)
{
parsedLines = Array.Empty();
if (subtitleJson == null || string.IsNullOrWhiteSpace(subtitleJson.text))
return false;
SubtitleFile file;
try
{
file = JsonUtility.FromJson(subtitleJson.text);
}
catch
{
return false;
}
if (file.lines == null || file.lines.Length == 0)
return false;
parsedLines = file.lines;
return true;
}
private IEnumerator ShowLine(SubtitleLine line)
{
m_currentSpeaker = line.speaker;
m_currentText = string.Empty;
m_isShowing = true;
if (fadeDuration > 0f)
{
float fadeIn = 0f;
while (fadeIn < fadeDuration)
{
fadeIn += Time.deltaTime;
m_alpha = Mathf.Clamp01(fadeIn / fadeDuration);
yield return null;
}
}
else
{
m_alpha = 1f;
}
float typeTime = 0f;
int totalChars = line.text.Length;
int visibleChars = 0;
if (typewriterCharsPerSecond > 0f)
{
while (m_currentText.Length < totalChars)
{
typeTime += Time.deltaTime;
visibleChars = Mathf.Clamp(Mathf.FloorToInt(typeTime * typewriterCharsPerSecond), 0, totalChars);
m_currentText = line.text.Substring(0, visibleChars);
yield return null;
}
}
else
{
m_currentText = line.text;
}
float holdDuration = Mathf.Max(0f, line.duration - (typewriterCharsPerSecond > 0f ? typeTime : 0f));
if (holdDuration > 0f)
yield return new WaitForSeconds(holdDuration);
if (fadeDuration > 0f)
{
float fadeOut = fadeDuration;
while (fadeOut > 0f)
{
fadeOut -= Time.deltaTime;
m_alpha = Mathf.Clamp01(fadeOut / fadeDuration);
yield return null;
}
}
m_alpha = 0f;
m_isShowing = false;
}
private void OnGUI()
{
if (!m_isShowing || string.IsNullOrEmpty(m_currentText))
return;
EnsureStyles();
float maxWidth = Mathf.Min(Screen.width - 24f, 940f);
float textWidth = maxWidth - horizontalPadding * 2f;
float speakerHeight = string.IsNullOrEmpty(m_currentSpeaker)
? 0f
: m_speakerStyle.CalcHeight(new GUIContent(m_currentSpeaker), textWidth);
float textHeight = m_textStyle.CalcHeight(new GUIContent(string.IsNullOrEmpty(m_currentText) ? " " : m_currentText), textWidth);
float boxWidth = maxWidth;
float boxHeight = speakerHeight + textHeight + 28f;
float boxX = (Screen.width - boxWidth) * 0.5f;
float boxY = Screen.height - bottomOffset - boxHeight;
Rect boxRect = new Rect(boxX, boxY, boxWidth, boxHeight);
Color previousColor = GUI.color;
GUI.color = new Color(1f, 1f, 1f, m_alpha);
GUI.DrawTexture(boxRect, m_background);
float yOffset = boxRect.y + 10f;
if (!string.IsNullOrEmpty(m_currentSpeaker))
{
Rect speakerRect = new Rect(
boxRect.x + horizontalPadding,
yOffset,
textWidth,
speakerHeight);
GUI.Label(speakerRect, m_currentSpeaker, m_speakerStyle);
yOffset += speakerHeight + 2f;
}
Rect textRect = new Rect(
boxRect.x + horizontalPadding,
yOffset,
textWidth,
textHeight);
GUI.Label(textRect, m_currentText, m_textStyle);
GUI.color = previousColor;
}
private void EnsureStyles()
{
if (m_textStyle != null)
return;
m_background = new Texture2D(1, 1);
m_background.SetPixel(0, 0, backgroundColor);
m_background.Apply();
m_textStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = fontSize,
wordWrap = true,
richText = false,
clipping = TextClipping.Clip,
};
m_textStyle.normal.textColor = textColor;
m_speakerStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = speakerFontSize,
fontStyle = FontStyle.Bold,
wordWrap = false,
clipping = TextClipping.Clip,
};
m_speakerStyle.normal.textColor = speakerColor;
}
private void OnDestroy()
{
if (m_background != null)
Destroy(m_background);
}
}