using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;
namespace Unheimlich.UI
{
///
/// Splits a TextMeshProUGUI string into individual per-letter child objects,
/// then periodically reassigns a random font to each letter with a staggered delay.
/// Whitespace is preserved as sized spacers. Font size, color, and alignment are
/// inherited from the source TMP component by default.
/// Call to begin, or enable .
///
[RequireComponent(typeof(TextMeshProUGUI))]
public class PerLetterFontShifter : MonoBehaviour
{
#region Serialized Fields
[Header("Fonts")]
[SerializeField] private TMP_FontAsset[] _fonts;
[Header("Timing")]
[SerializeField] private bool _playOnAwake = false;
[SerializeField] private float _shiftInterval = 3f;
[Tooltip("Per-letter delay as a fraction of shift interval. 0 = all letters shift at once, 1 = stagger can span the full interval.")]
[SerializeField, Range(0f, 1f)] private float _shiftRandomness = 0.3f;
[Header("Style")]
[Tooltip("Leave at 0 to inherit font size from the source TMP component.")]
[SerializeField] private float _fontSize = 0f;
[Tooltip("When enabled, overrides the source TMP color with the value below.")]
[SerializeField] private bool _overrideColor = false;
[SerializeField] private Color _textColor = Color.white;
#endregion
#region Private Fields
private TextMeshProUGUI _sourceTmp;
private string _sourceText;
private float _resolvedFontSize;
private TextAlignmentOptions _resolvedAlignment;
private readonly List _letterObjects = new List();
private Coroutine _shiftLoopHandle;
#endregion
#region Unity Lifecycle
private void Awake()
{
_sourceTmp = GetComponent();
_sourceText = _sourceTmp.text;
_resolvedFontSize = (_fontSize > 0f) ? _fontSize : _sourceTmp.fontSize;
_resolvedAlignment = _sourceTmp.alignment;
// Hide the original component; child letter objects take over rendering.
_sourceTmp.enabled = false;
BuildLetterObjects();
}
private void Start()
{
if (_playOnAwake)
StartShifting();
}
private void OnDestroy()
{
DestroyLetterObjects();
}
#endregion
#region Public API
///
/// Replaces the displayed text and rebuilds all letter objects.
///
public void SetText(string newText)
{
_sourceText = newText;
BuildLetterObjects();
}
///
/// Begins the periodic font shuffle loop.
///
public void StartShifting()
{
if (_shiftLoopHandle != null)
return;
_shiftLoopHandle = StartCoroutine(ShiftLoopRoutine());
}
///
/// Stops the periodic font shuffle loop.
///
public void StopShifting()
{
if (_shiftLoopHandle == null)
return;
StopCoroutine(_shiftLoopHandle);
_shiftLoopHandle = null;
}
#endregion
#region Letter Object Management
///
/// Destroys existing letter objects, then creates one child GameObject per character.
/// Spaces become hardcoded-width spacers; newlines are skipped.
/// Assigns an initial random font to every letter instantly so the text
/// never appears in its default font before the first shuffle cycle.
///
private void BuildLetterObjects()
{
DestroyLetterObjects();
foreach (char c in _sourceText)
{
if (c == '\n')
continue;
GameObject go = new GameObject($"Letter_{c}");
go.transform.SetParent(transform, false);
TextMeshProUGUI tmp = go.AddComponent();
tmp.fontSize = _resolvedFontSize;
tmp.color = _overrideColor ? _textColor : _sourceTmp.color;
tmp.alignment = _resolvedAlignment;
if (c == ' ')
{
// Space glyphs are unreliable across custom fonts; use a LayoutElement
// with a hardcoded width derived from the resolved font size instead.
tmp.text = "\u00A0";
tmp.color = new Color(0f, 0f, 0f, 0f);
LayoutElement le = go.AddComponent();
le.preferredWidth = _resolvedFontSize * 0.5f;
le.preferredHeight = _resolvedFontSize;
}
else
{
tmp.text = c.ToString();
ContentSizeFitter fitter = go.AddComponent();
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
}
_letterObjects.Add(tmp);
}
ConfigureLayoutGroup();
AssignInitialFonts();
}
///
/// Assigns a random font to every letter instantly, with no stagger.
/// Called once after build so no brusque font switch occurs on the first cycle.
///
private void AssignInitialFonts()
{
if (_fonts == null || _fonts.Length == 0)
return;
foreach (TextMeshProUGUI letter in _letterObjects)
{
if (letter.text == "\u00A0")
continue;
letter.font = _fonts[Random.Range(0, _fonts.Length)];
}
}
///
/// Destroys all tracked letter child objects and clears the list.
///
private void DestroyLetterObjects()
{
foreach (TextMeshProUGUI letter in _letterObjects)
{
if (letter != null)
Destroy(letter.gameObject);
}
_letterObjects.Clear();
}
#endregion
#region Layout
///
/// Ensures a HorizontalLayoutGroup is present and configured to match
/// the alignment of the source TMP component.
///
private void ConfigureLayoutGroup()
{
HorizontalLayoutGroup layout = gameObject.GetComponent();
if (layout == null)
layout = gameObject.AddComponent();
layout.childAlignment = TMPAlignmentToTextAnchor(_resolvedAlignment);
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.spacing = 0f;
}
///
/// Maps a TMP TextAlignmentOptions value to the closest Unity TextAnchor equivalent.
///
private static TextAnchor TMPAlignmentToTextAnchor(TextAlignmentOptions alignment)
{
return alignment switch
{
TextAlignmentOptions.TopLeft
or TextAlignmentOptions.Left => TextAnchor.UpperLeft,
TextAlignmentOptions.Top => TextAnchor.UpperCenter,
TextAlignmentOptions.TopRight => TextAnchor.UpperRight,
TextAlignmentOptions.MidlineLeft
or TextAlignmentOptions.BaselineLeft => TextAnchor.MiddleLeft,
TextAlignmentOptions.Center
or TextAlignmentOptions.Midline
or TextAlignmentOptions.Baseline => TextAnchor.MiddleCenter,
TextAlignmentOptions.MidlineRight
or TextAlignmentOptions.BaselineRight => TextAnchor.MiddleRight,
TextAlignmentOptions.BottomLeft => TextAnchor.LowerLeft,
TextAlignmentOptions.Bottom => TextAnchor.LowerCenter,
TextAlignmentOptions.BottomRight => TextAnchor.LowerRight,
_ => TextAnchor.MiddleCenter,
};
}
#endregion
#region Font Shuffling
///
/// Kicks off a staggered coroutine that assigns a new random font to each letter
/// in a shuffled order to avoid a predictable left-to-right sweep.
///
private void ShuffleFonts()
{
if (_fonts == null || _fonts.Length == 0)
{
Debug.LogWarning($"[{nameof(PerLetterFontShifter)}] No fonts assigned.", this);
return;
}
StartCoroutine(ShuffleStaggeredRoutine());
}
///
/// Returns a Fisher-Yates shuffled list of indices into .
///
private List GetShuffledIndices()
{
List indices = new List(_letterObjects.Count);
for (int i = 0; i < _letterObjects.Count; i++)
indices.Add(i);
for (int i = indices.Count - 1; i > 0; i--)
{
int j = Random.Range(0, i + 1);
(indices[i], indices[j]) = (indices[j], indices[i]);
}
return indices;
}
#endregion
#region Coroutines
///
/// Waits for seconds, then triggers a new font shuffle.
/// Runs indefinitely until is called.
///
private IEnumerator ShiftLoopRoutine()
{
ShuffleFonts();
while (true)
{
yield return new WaitForSeconds(_shiftInterval);
ShuffleFonts();
}
}
///
/// Iterates over letter objects in shuffled order, assigning a random font to each
/// with a staggered delay of * .
/// Spacer objects (non-breaking spaces) are skipped.
///
private IEnumerator ShuffleStaggeredRoutine()
{
foreach (int index in GetShuffledIndices())
{
TextMeshProUGUI letter = _letterObjects[index];
if (letter.text == "\u00A0")
continue;
letter.font = _fonts[Random.Range(0, _fonts.Length)];
yield return new WaitForSeconds(_shiftInterval * Random.Range(0f, _shiftRandomness));
}
}
#endregion
#if UNITY_EDITOR
[ContextMenu("Test Shuffle Now")]
private void TestShuffle() => ShuffleFonts();
#endif
}
}