wip : subtitles & flash hallu

This commit is contained in:
Ferlat Thibaud
2026-05-29 16:01:03 +02:00
parent 816ccb843f
commit 4e6a8b96e4
26 changed files with 18075 additions and 791 deletions
@@ -6,6 +6,13 @@ 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
{
@@ -15,44 +22,53 @@ namespace Unheimlich.UI
[SerializeField] private TMP_FontAsset[] _fonts;
[Header("Timing")]
[SerializeField] private bool _playOnAwake = false;
[SerializeField] private float _shiftInterval = 3f;
[SerializeField] private float _minLetterDelay = 0.05f;
[SerializeField] private float _maxLetterDelay = 0.25f;
[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")]
[SerializeField] private float _fontSize = 36f;
[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 _originalTmp;
private string _originalText;
private TextMeshProUGUI _sourceTmp;
private string _sourceText;
private float _resolvedFontSize;
private TextAlignmentOptions _resolvedAlignment;
private readonly List<TextMeshProUGUI> _letterObjects = new List<TextMeshProUGUI>();
private Coroutine _shiftLoopRoutineHandle;
private Coroutine _shiftLoopHandle;
#endregion
#region Unity Lifecycle Methods
#region Unity Lifecycle
private void Awake()
{
_originalTmp = GetComponent<TextMeshProUGUI>();
_originalText = _originalTmp.text;
_originalTmp.enabled = false;
}
_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;
private void OnEnable()
{
BuildLetterObjects();
_shiftLoopRoutineHandle = StartCoroutine(ShiftLoopRoutine());
}
private void OnDisable()
private void Start()
{
if (_shiftLoopRoutineHandle != null)
StopCoroutine(_shiftLoopRoutineHandle);
if (_playOnAwake)
StartShifting();
}
private void OnDestroy()
@@ -62,40 +78,115 @@ namespace Unheimlich.UI
#endregion
#region Public Methods
#region Public API
/// <summary>
/// Replaces the displayed text and rebuilds all letter objects.
/// </summary>
public void SetText(string newText)
{
_originalText = 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 Private Methods
#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()
{
DestroyLetterObjects();
foreach (char character in _originalText)
foreach (char c in _sourceText)
{
GameObject letterGo = new GameObject($"Letter_{character}");
letterGo.transform.SetParent(transform, false);
if (c == '\n')
continue;
TextMeshProUGUI letterTmp = letterGo.AddComponent<TextMeshProUGUI>();
letterTmp.text = character.ToString();
letterTmp.fontSize = _fontSize;
letterTmp.color = _textColor;
letterTmp.alignment = TextAlignmentOptions.Center;
GameObject go = new GameObject($"Letter_{c}");
go.transform.SetParent(transform, false);
_letterObjects.Add(letterTmp);
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);
}
ArrangeLetters();
ShuffleFonts();
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)
@@ -107,30 +198,61 @@ namespace Unheimlich.UI
_letterObjects.Clear();
}
private void ArrangeLetters()
#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 = TextAnchor.MiddleCenter;
layout.childAlignment = TMPAlignmentToTextAnchor(_resolvedAlignment);
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.spacing = 0f;
foreach (TextMeshProUGUI letter in _letterObjects)
{
ContentSizeFitter fitter = letter.gameObject.GetComponent<ContentSizeFitter>();
if (fitter == null)
fitter = letter.gameObject.AddComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
}
}
/// <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)
@@ -142,10 +264,12 @@ namespace Unheimlich.UI
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>();
List<int> indices = new List<int>(_letterObjects.Count);
for (int i = 0; i < _letterObjects.Count; i++)
indices.Add(i);
@@ -162,8 +286,14 @@ namespace Unheimlich.UI
#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);
@@ -171,20 +301,22 @@ namespace Unheimlich.UI
}
}
/// <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()
{
List<int> shuffledIndices = GetShuffledIndices();
foreach (int index in shuffledIndices)
foreach (int index in GetShuffledIndices())
{
TextMeshProUGUI letter = _letterObjects[index];
if (letter.text == " " || letter.text == "\n")
if (letter.text == "\u00A0")
continue;
letter.font = _fonts[Random.Range(0, _fonts.Length)];
yield return new WaitForSeconds(Random.Range(_minLetterDelay, _maxLetterDelay));
yield return new WaitForSeconds(_shiftInterval * Random.Range(0f, _shiftRandomness));
}
}