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 } }