331 lines
11 KiB
C#
331 lines
11 KiB
C#
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using TMPro;
|
|
using UnityEngine.UI;
|
|
|
|
namespace Unheimlich.UI
|
|
{
|
|
/// <summary>
|
|
/// 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 <see cref="StartShifting"/> to begin, or enable <see cref="_playOnAwake"/>.
|
|
/// </summary>
|
|
[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<TextMeshProUGUI> _letterObjects = new List<TextMeshProUGUI>();
|
|
private Coroutine _shiftLoopHandle;
|
|
|
|
#endregion
|
|
|
|
#region Unity Lifecycle
|
|
|
|
private void Awake()
|
|
{
|
|
_sourceTmp = GetComponent<TextMeshProUGUI>();
|
|
_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
|
|
|
|
/// <summary>
|
|
/// Replaces the displayed text and rebuilds all letter objects.
|
|
/// </summary>
|
|
public void SetText(string newText)
|
|
{
|
|
_sourceText = newText;
|
|
BuildLetterObjects();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Begins the periodic font shuffle loop.
|
|
/// </summary>
|
|
public void StartShifting()
|
|
{
|
|
if (_shiftLoopHandle != null)
|
|
return;
|
|
|
|
_shiftLoopHandle = StartCoroutine(ShiftLoopRoutine());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the periodic font shuffle loop.
|
|
/// </summary>
|
|
public void StopShifting()
|
|
{
|
|
if (_shiftLoopHandle == null)
|
|
return;
|
|
|
|
StopCoroutine(_shiftLoopHandle);
|
|
_shiftLoopHandle = null;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Letter Object Management
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void BuildLetterObjects()
|
|
{
|
|
if (_sourceTmp == null) return;
|
|
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<TextMeshProUGUI>();
|
|
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<LayoutElement>();
|
|
le.preferredWidth = _resolvedFontSize * 0.5f;
|
|
le.preferredHeight = _resolvedFontSize;
|
|
}
|
|
else
|
|
{
|
|
tmp.text = c.ToString();
|
|
|
|
ContentSizeFitter fitter = go.AddComponent<ContentSizeFitter>();
|
|
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
|
|
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
|
}
|
|
|
|
_letterObjects.Add(tmp);
|
|
}
|
|
|
|
ConfigureLayoutGroup();
|
|
AssignInitialFonts();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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)];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Destroys all tracked letter child objects and clears the list.
|
|
/// </summary>
|
|
private void DestroyLetterObjects()
|
|
{
|
|
foreach (TextMeshProUGUI letter in _letterObjects)
|
|
{
|
|
if (letter != null)
|
|
Destroy(letter.gameObject);
|
|
}
|
|
|
|
_letterObjects.Clear();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Layout
|
|
|
|
/// <summary>
|
|
/// Ensures a HorizontalLayoutGroup is present and configured to match
|
|
/// the alignment of the source TMP component.
|
|
/// </summary>
|
|
private void ConfigureLayoutGroup()
|
|
{
|
|
HorizontalLayoutGroup layout = gameObject.GetComponent<HorizontalLayoutGroup>();
|
|
if (layout == null)
|
|
layout = gameObject.AddComponent<HorizontalLayoutGroup>();
|
|
|
|
layout.childAlignment = TMPAlignmentToTextAnchor(_resolvedAlignment);
|
|
layout.childForceExpandWidth = false;
|
|
layout.childForceExpandHeight = false;
|
|
layout.childControlWidth = true;
|
|
layout.childControlHeight = true;
|
|
layout.spacing = 0f;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a TMP TextAlignmentOptions value to the closest Unity TextAnchor equivalent.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void ShuffleFonts()
|
|
{
|
|
if (_fonts == null || _fonts.Length == 0)
|
|
{
|
|
Debug.LogWarning($"[{nameof(PerLetterFontShifter)}] No fonts assigned.", this);
|
|
return;
|
|
}
|
|
|
|
StartCoroutine(ShuffleStaggeredRoutine());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a Fisher-Yates shuffled list of indices into <see cref="_letterObjects"/>.
|
|
/// </summary>
|
|
private List<int> GetShuffledIndices()
|
|
{
|
|
List<int> indices = new List<int>(_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
|
|
|
|
/// <summary>
|
|
/// Waits for <see cref="_shiftInterval"/> seconds, then triggers a new font shuffle.
|
|
/// Runs indefinitely until <see cref="StopShifting"/> is called.
|
|
/// </summary>
|
|
private IEnumerator ShiftLoopRoutine()
|
|
{
|
|
ShuffleFonts();
|
|
|
|
while (true)
|
|
{
|
|
yield return new WaitForSeconds(_shiftInterval);
|
|
ShuffleFonts();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Iterates over letter objects in shuffled order, assigning a random font to each
|
|
/// with a staggered delay of <see cref="_shiftRandomness"/> * <see cref="_shiftInterval"/>.
|
|
/// Spacer objects (non-breaking spaces) are skipped.
|
|
/// </summary>
|
|
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
|
|
}
|
|
} |