Skip to content

Instantly share code, notes, and snippets.

@miguelSantirso
Created December 31, 2016 09:27
Show Gist options
  • Save miguelSantirso/d7051007d2c2465d163cf6421fc4b736 to your computer and use it in GitHub Desktop.
Save miguelSantirso/d7051007d2c2465d163cf6421fc4b736 to your computer and use it in GitHub Desktop.
Letter by letter reveal a paragraph of text in a smooth way
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
public class TextRevealer : MonoBehaviour
{
[UnityEngine.Header("Configuration")]
public int numCharactersFade = 3;
public float charsPerSecond = 30;
public float smoothSeconds = 0.75f;
[UnityEngine.Header("References")]
public Text text;
public UnityEvent allRevealed = new UnityEvent();
private string originalString;
private int nRevealedCharacters;
private bool isRevealing = false;
public bool IsRevealing { get { return isRevealing; } }
public void RestartWithText(string strText)
{
nRevealedCharacters = 0;
originalString = strText;
text.text = BuildPartiallyRevealedString(originalString, keyCharIndex: -1, minIndex: 0, maxIndex: 0, fadeLength: 1);
}
public void ShowEverythingWithoutAnimation()
{
StopAllCoroutines();
text.text = originalString;
nRevealedCharacters = originalString.Length;
isRevealing = false;
allRevealed.Invoke();
}
public void ShowNextParagraphWithoutAnimation()
{
if (IsAllRevealed()) return;
StopAllCoroutines();
var paragraphEnd = GetNextParagraphEnd(nRevealedCharacters);
text.text = BuildPartiallyRevealedString(original: originalString,
keyCharIndex: paragraphEnd,
minIndex: nRevealedCharacters,
maxIndex: paragraphEnd,
fadeLength: 0);
nRevealedCharacters = paragraphEnd + 1;
while (nRevealedCharacters < originalString.Length && originalString[nRevealedCharacters] == '\n')
nRevealedCharacters += 1;
if (IsAllRevealed())
allRevealed.Invoke();
isRevealing = false;
}
public void RevealNextParagraphAsync()
{
StartCoroutine(RevealNextParagraph());
}
public IEnumerator RevealNextParagraph()
{
if (IsAllRevealed() || isRevealing) yield break;
var paragraphEnd = GetNextParagraphEnd(nRevealedCharacters);
if (paragraphEnd < 0) yield break;
isRevealing = true;
var keyChar = (float)(nRevealedCharacters - numCharactersFade);
var keyCharEnd = paragraphEnd;
var speed = 0f;
var secondsElapsed = 0f;
while (keyChar < keyCharEnd)
{
secondsElapsed += Time.deltaTime;
if (secondsElapsed <= smoothSeconds)
speed = Mathf.Lerp(0f, charsPerSecond, secondsElapsed / smoothSeconds);
else
{
var secondsLeft = (keyCharEnd - keyChar) / charsPerSecond;
if (secondsLeft < smoothSeconds)
speed = Mathf.Lerp(charsPerSecond, 0.1f * charsPerSecond, 1f - secondsLeft / smoothSeconds);
}
keyChar = Mathf.MoveTowards(keyChar, keyCharEnd, speed * Time.deltaTime);
text.text = BuildPartiallyRevealedString(original: originalString,
keyCharIndex: keyChar,
minIndex: nRevealedCharacters,
maxIndex: paragraphEnd,
fadeLength: numCharactersFade);
yield return null;
}
nRevealedCharacters = paragraphEnd + 1;
while (nRevealedCharacters < originalString.Length && originalString[nRevealedCharacters] == '\n')
nRevealedCharacters += 1;
if (IsAllRevealed())
allRevealed.Invoke();
isRevealing = false;
}
public bool IsAllRevealed()
{
return nRevealedCharacters >= originalString.Length;
}
private int GetNextParagraphEnd(int startingFrom)
{
var paragraphEnd = originalString.IndexOf('\n', startingFrom);
if (paragraphEnd < 0 && startingFrom < originalString.Length) paragraphEnd = originalString.Length - 1;
return paragraphEnd;
}
private string BuildPartiallyRevealedString(string original, float keyCharIndex, int minIndex, int maxIndex, int fadeLength)
{
var lastFullyVisibleChar = Mathf.Max(Mathf.CeilToInt(keyCharIndex), minIndex - 1);
var firstFullyInvisibleChar = (int)Mathf.Min(keyCharIndex + fadeLength, maxIndex) + 1;
var revealed = original.Substring(0, lastFullyVisibleChar + 1);
var unrevealed = original.Substring(firstFullyInvisibleChar);
var sb = new StringBuilder();
sb.Append(revealed);
for (var i = lastFullyVisibleChar + 1; i < firstFullyInvisibleChar; ++i)
{
var c = original[i];
var originalColorRGB = ColorUtility.ToHtmlStringRGB(text.color);
var alpha = Mathf.RoundToInt(255 * (keyCharIndex - i) / (float)fadeLength);
sb.AppendFormat("<color=#{0}{1:X2}>{2}</color>", originalColorRGB, (byte)alpha, c);
}
sb.AppendFormat("<color=#00000000>{0}</color>", unrevealed);
return sb.ToString();
}
void Start()
{
if (string.IsNullOrEmpty(originalString))
RestartWithText(text.text);
}
}
@DigitalHazard
Copy link

Hi, good morning, I was looking for this effect exactly since the normal methods don't take into account if there is HTML code, I just need help with the implementation, I dont understand how to implement it correctly. can you help with that?

@eloxxpro
Copy link

For everyone wondering: I have adopted this piece of code into my project. Starting the reveal process in "Start" is maybe not what you need, so I created an additional method:

    public void RevealText()
    {
        if (string.IsNullOrEmpty(originalString))
            RestartWithText(text.text);

        this.RevealNextParagraphAsync();
    }

@SavedByZero
Copy link

SavedByZero commented Nov 8, 2022

This is cool, I adopted the core of this into my own text feeder class, but I noticed that the core mechanic does not mask html characters as they appear, unfortunately (my break tags are especially noticeable for a split second before they vanish and apply themselves to the text). Wasn't half the point of making something like this so designers could write visual novel scripts with tags in them that would get processed letter-by-letter without the html showing itself before it gets applied?

@SavedByZero
Copy link

SavedByZero commented Nov 9, 2022

What you could do is add something like this right before the BuildPartial() line:

 if (keyChar + numCharactersFade < keyCharEnd)
            {
                int intKeyChar = (int)keyChar;
                if (letters[intKeyChar + numCharactersFade] == '<')
                {
                    int count = 1;
                    for (int j = intKeyChar + 1; letters[j] != '>' && letters[j+1] != '<'; j++)
                        count++;
                    keyChar += count +  numCharactersFade;
                }
               
            }

That isn't perfect, as edge cases where someone decides to begin a paragraph with a tag will look wonky, but you could put an initial tag check/purge similar to that at the beginning of the paragraph iteration. (or you could just be like me and say "Well, tell the client not to put a *@#*ing tag at the start of their dialogue pieces.")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment