#if UNITY_EDITOR #define CHECK_ERRORS //used to check text errors #endif #if TA_Naninovel #define INTEGRATE_NANINOVEL #endif using Febucci.UI.Core; using System; using System.Collections.Generic; using System.Linq; using TMPro; using UnityEngine; using UnityEngine.Assertions; namespace Febucci.UI { /// /// The main TextAnimator component. Add this near to a TextMeshPro component in order to enable effects. It can also be used in combination with a TextAnimatorPlayer in order to display letters dynamically (like a typewriter).
/// - See also:
/// - Manual: How to add effects to your texts
///
[HelpURL("https://www.febucci.com/text-animator-unity/docs/1.X/how-to-add-effects-to-your-texts/")] [AddComponentMenu("Febucci/TextAnimator/TextAnimator")] [RequireComponent(typeof(TMP_Text)), DisallowMultipleComponent] public class TextAnimator : MonoBehaviour { #region Types (Structs + Enums) public enum UpdateMode { /// /// Update Loop /// Auto = 0, /// /// Via Script /// Manual = 1 } /// /// Contains TextAnimator's current time values. /// [System.Serializable] public struct TimeData { /// /// Time passed since the textAnimator started showing the very first letter /// public float timeSinceStart { get; private set; } /// /// TextAnimator's Component delta time, could be Scaled or Unscaled /// public float deltaTime { get; private set; } internal void ResetData() { timeSinceStart = 0; } internal void IncreaseTime() { timeSinceStart += deltaTime; } internal void UpdateDeltaTime(TimeScale timeScale) { deltaTime = timeScale == TimeScale.Unscaled ? Time.unscaledDeltaTime : Time.deltaTime; //To avoid possible desync errors etc., effects can't be played backwards. if (deltaTime < 0) deltaTime = 0; } } [System.Serializable] class AppearancesContainer { [SerializeField, UnityEngine.Serialization.FormerlySerializedAs("tags")] public string[] tagsFallback_Appearances = new string[] { TAnimTags.ap_Size }; //starts with a size effect by default public string[] tagsFallback_Disappearances = new string[] { TAnimTags.ap_Size }; //starts with a size effect by default public AppearanceDefaultValues values = new AppearanceDefaultValues(); } internal struct InternalAction { public TypewriterAction action; public int charIndex; public bool triggered; public int internalOrder; } enum ShowTextMode : byte { Hidden = 0, Shown = 1, UserTyping = 2 } /// /// TextAnimator's effects time scale, which could match unity's Time.deltaTime or Time.unscaledDeltaTime /// public enum TimeScale { Scaled, Unscaled, } #endregion private void Awake() { Canvas[] canvases = gameObject.GetComponentsInParent(true); //----- //TMPro UI references a canvas, but if it's null [in its case, the object is inactive] it doesn't generate the mesh and it throws error(s). //These variables manages a canvas also if its' disabled. //----- if (canvases.Length > 0) { parentCanvas = canvases[0]; hasParentCanvas = parentCanvas != null; } #if UNITY_2019_2_OR_NEWER gameObject.TryGetComponent(out attachedInputField); #else attachedInputField = gameObject.GetComponentInParent(); #endif #if INTEGRATE_NANINOVEL reveablelText = GetComponent(); isNaninovelPresent = reveablelText != null; #endif //If we're checking text from TMPro, prevents its very first set text to appear for one frame and then disappear if (triggerAnimPlayerOnChange) { tmproText.renderMode = TextRenderFlags.DontRender; } m_time.UpdateDeltaTime(timeScale); } #region Variables TAnimPlayerBase _tAnimPlayer; /// /// Linked TAnimPlayer to this component /// TAnimPlayerBase tAnimPlayer { get { if (_tAnimPlayer != null) return _tAnimPlayer; #if UNITY_2019_2_OR_NEWER if(!TryGetComponent(out _tAnimPlayer)) { Debug.LogError($"Text Animator component is null on GameObject {gameObject.name}"); } #else _tAnimPlayer = GetComponent(); Assert.IsNotNull(_tAnimPlayer, $"Text Animator Player component is null on GameObject {gameObject.name}"); #endif return _tAnimPlayer; } } #region Inspector /// /// Controls how Text Animator should update its effects. Set to Manual in order to update effects manually from script, invoking . /// [Tooltip("Controls how Text Animator should update its effects. Set to Manual in order to update effects manually from script, otherwise leave it to Auto.")] public UpdateMode updateMode = UpdateMode.Auto; [SerializeField, Tooltip("If true, the typewriter is triggered automatically once the TMPro text changes (requires a TextAnimatorPlayer component). Otherwise, it shows the entire text instantly.")] bool triggerAnimPlayerOnChange = false; /// /// Multiplies the intensity for all the effects that behave differently with fonts and sizes. /// - Documentation. /// [SerializeField] public float effectIntensityMultiplier = 50; [UnityEngine.Serialization.FormerlySerializedAs("defaultAppearance"), SerializeField, Header("Text Appearance")] AppearancesContainer appearancesContainer = new AppearancesContainer(); [SerializeField] string[] tags_fallbackBehaviors = new string[0]; [SerializeField] BehaviorDefaultValues behaviorValues = new BehaviorDefaultValues(); //Global effect values #pragma warning disable 0649 [SerializeField] BuiltinBehaviorsDataScriptable scriptable_globalBehaviorsValues; [SerializeField] BuiltinAppearancesDataScriptable scriptable_globalAppearancesValues; #pragma warning restore 0649 /// /// True if you want effects to have the same intensities even if text is larger/smaller than default (example: when TMPro's AutoSize changes the size based on screen size) /// [SerializeField, Tooltip("True if you want effects to have the same intensities even if text is larger/smaller than default (example: when TMPro's AutoSize changes the size based on screen size)")] public bool useDynamicScaling = false; /// /// Used for scaling, represents the text's size where/when effects intensity behave like intended. /// [SerializeField, Tooltip("Used for scaling, represents the text's size where/when effects intensity behave like intended.")] public float referenceFontSize = -1; /// /// True if you want effects time to be reset when a new text is set (default option), false otherwise. /// [SerializeField, Tooltip("True if you want effects time to be reset when a new text is set (default option), false otherwise.")] public bool isResettingEffectsOnNewText = true; #endregion #region Public Variables TMP_Text _tmproText; /// /// The TextMeshPro component linked to this TextAnimator /// public TMP_Text tmproText { get { if (_tmproText != null) return _tmproText; #if UNITY_2019_2_OR_NEWER if(!TryGetComponent(out _tmproText)) { Debug.LogError("TextAnimator: TMproText component is null."); } #else _tmproText = GetComponent(); Assert.IsNotNull(tmproText, $"TextMeshPro component is null on Object {gameObject.name}"); #endif return _tmproText; } private set { _tmproText = value; } } #region Time /// /// Effects timescale, you can set it to scaled or unscaled. /// It also affects the TextAnimatorPlayer, if there is one linked to this TextAnimator. /// public TimeScale timeScale = TimeScale.Scaled; #endregion #region Events /// /// Delegate used for TextAnimator's events. Listeners can subscribe to: .
/// - Manual: Triggering Events while typing ///
/// public delegate void MessageEvent(string message); /// /// Invoked by the typewriter once it reaches a message tag while showing letters.
/// - Manual: Triggering Events while typing ///
public event MessageEvent onEvent; #endregion string latestText; /// /// The text stored in the TextAnimator component, without TextAnimator's tags. /// public string text { get => latestText; private set => latestText = value; } /// /// true if the text is entirely visible. /// /// /// You can use this to check if all the letters have been shown. /// public bool allLettersShown => _maxVisibleCharacters >= tmproText.textInfo.characterCount; /// /// true if any letter is still visible in the text /// /// /// You can use this to check if the disappearance effects are still running. /// public bool anyLetterVisible { get { if (characters.Length == 0) return true; bool IsCharacterVisible(int index) { return characters[index].data.passedTime > 0; } //searches for the first character or the last one first, since they're most probably the first ones to be shown (based on orientation) if (IsCharacterVisible(0) || IsCharacterVisible(tmproText.textInfo.characterCount-1)) return true; //searches for the other, which might still be running their appearance/disappearance for(int i=1;i /// The latest TextMeshPro character shown by the typewriter. /// public TMP_CharacterInfo latestCharacterShown { get; private set; } //TODO rename in "latestCharacterVisible" for better clarity, since users can now "decrease" the max visible character visible as well #endregion #region Managament variables /// /// Contains TextAnimator's current time values. /// public TimeData time => m_time; TimeData m_time; #if INTEGRATE_NANINOVEL //Naninovel integration bool isNaninovelPresent; Naninovel.UI.IRevealableText reveablelText; #endif bool forceMeshRefresh; bool skipAppearanceEffects; //----- TMPro workaround ----- bool hasParentCanvas; Canvas parentCanvas; TMP_InputField attachedInputField; //----- //----- TMPro values cache ----- bool autoSize; Rect sourceRect; Color sourceColor; //----- int _maxVisibleCharacters = 0; public int maxVisibleCharacters { get => _maxVisibleCharacters; set { if (_maxVisibleCharacters == value) return; _maxVisibleCharacters = value; //clamps value if (_maxVisibleCharacters < 0) _maxVisibleCharacters = 0; //stores the latest character visible if (hasText && _maxVisibleCharacters <= textInfo.characterCount && _maxVisibleCharacters > 0) { latestCharacterShown = textInfo.characterInfo[_maxVisibleCharacters - 1]; } AssertCharacterTimes(); } } void AssertCharacterTimes() { bool IsCharacterShown(int i) { return i <= textInfo.characterCount && i >= _firstVisibleCharacter && i < _maxVisibleCharacters; } for (int i = 0; i < characters.Length; i++) { //P.S. Do not change characters' passed time here, since this method might be called by the user while the Update is already applying effects, and it could cause some glitches for that frame characters[i].wantsToDisappear = !IsCharacterShown(i); } } int _firstVisibleCharacter; public int firstVisibleCharacter { get => _firstVisibleCharacter; set { if (_firstVisibleCharacter == value) return; _firstVisibleCharacter = value; AssertCharacterTimes(); } } bool hasText = false; internal bool hasActions { get; private set; } int latestTriggeredEvent = 0; int latestTriggeredAction = 0; #endregion #region Text Elements TMP_TextInfo textInfo; Character[] characters = new Character[0]; List behaviorEffects = new List(); List appearanceEffects = new List(); List disappearanceEffects = new List(); AppearanceBase[] fallbackAppearanceEffects; AppearanceBase[] fallbackDisappearanceEffects; BehaviorBase[] fallbackBehaviorEffects; List typewriterActions = new List(); List eventMarkers = new List(); #endregion #endregion #region Public Component Methods #region For setting the Text /// /// Method to set the TextAnimator's text and apply its tags (effects/actions/tmpro/...). /// /// Source text, including rich text tags /// true = sets the text but hides it (visible characters = 0). Mostly used to let the typewriter show letters after setting the text public void SetText(string text, bool hideText) { _SetText(text, hideText ? ShowTextMode.Hidden : ShowTextMode.Shown); } /// /// Appends the given text to the already existing TMPro's one, applying its tags etc. /// /// Text to append, including rich text tags /// true = appends the text but hides it. Mostly used to let the typewriter show the remaining letters. /// /// If you're using the typewriter, you must manually start it from the code (after appending the text). See: /// public void AppendText(string text, bool hideText) { //Prevents appending an empty text if (string.IsNullOrEmpty(text)) return; //The user is appending to an empty text //so we set it instead if (!hasText) { SetText(text, hideText); return; } _ApplyTextToCharacters(this.text + _FormatText(text, this.text.Length)); #if TA_DEBUG DebugText(); #endif } #endregion #region For the typewriter /// /// Tries to return the next character in the text. /// /// /// /// if (textAnimatorComponent.TryGetNextCharacter(out TMP_CharacterInfo nextChar)) /// { /// ///[...] /// } /// /// /// /// public bool TryGetNextCharacter(out TMP_CharacterInfo result) { if (_maxVisibleCharacters < textInfo.characterCount) { result = textInfo.characterInfo[_maxVisibleCharacters]; return true; } result = default; return false; } [System.Obsolete("Please use 'maxVisibleCharacter++' instead.")] public char IncreaseVisibleChars() { maxVisibleCharacters++; return latestCharacterShown.character; } /// /// Turns all characters visible at the end of the frame (i.e. "a typewriter skip") /// /// Set this to true if you want all letters to appear instantly (without any appearance effect) public void ShowAllCharacters(bool skipAppearanceEffects) { maxVisibleCharacters = textInfo.characterCount; this.skipAppearanceEffects = skipAppearanceEffects; } /// /// Triggers all the remaining TextAnimator's events. /// public void TriggerRemainingEvents() { if (eventMarkers.Count <= 0) return; for (int i = latestTriggeredEvent; i < eventMarkers.Count; i++) { if (!eventMarkers[i].triggered) { var _event = eventMarkers[i]; _event.triggered = true; onEvent?.Invoke(eventMarkers[i].eventMessage); } } latestTriggeredEvent = eventMarkers.Count - 1; } #endregion /// /// Forces refreshing the mesh at the end of the frame /// public void ForceMeshRefresh() { forceMeshRefresh = true; } /// /// Triggers events that are currently visible /// public void TriggerVisibleEvents() { TryTriggeringEvent(int.MaxValue); //Invokes all events that are after the current letter (but on the same TMPro index) } /// /// Resets the effects time, making them start from the start. /// /// P.S. If you want to restart the typewriter, see /// true if you want the characters to reset mostly their behavior effects, staying on screen. false if you want all characters to disappear and play back from their appearance (all together). public void ResetEffectsTime(bool skipAppearances) { if (skipAppearances) { for (int i = firstVisibleCharacter; i < maxVisibleCharacters; i++) { characters[i].isDisappearing = false; characters[i].data.passedTime = characters[i].appearancesMaxDuration; } } else { for (int i = firstVisibleCharacter; i < maxVisibleCharacters; i++) { characters[i].isDisappearing = false; characters[i].data.passedTime = 0; } } //resets text animator's internal time m_time.ResetData(); } #endregion #region Public Static Methods /// /// true if behavior effects are enabled globally (in all TextAnimators). /// /// /// To modify this value, invoke: /// public static bool effectsBehaviorsEnabled => enabled_globalBehaviors; /// /// true if appearance effects are enabled globally (in all TextAnimators). /// /// /// To modify this value, invoke: /// public static bool effectsAppearancesEnabled => enabled_globalAppearances; static bool enabled_globalAppearances = true; static bool enabled_globalBehaviors = true; /// /// Enables/Disables all effects for all TextAnimators. /// public static void EnableAllEffects(bool enabled) { EnableAppearances(enabled); EnableBehaviors(enabled); } /// /// Enables/Disables Appearances effects globally (for all TextAnimators) /// /// /// /// To check if behaviors are enabled, refer to public static void EnableAppearances(bool enabled) { enabled_globalAppearances = enabled; } /// /// Enables/Disables Behavior effects globally (for all TextAnimators) /// /// /// To check if behaviors are enabled, refer to public static void EnableBehaviors(bool enabled) { enabled_globalBehaviors = enabled; } bool enabled_localBehaviors = true; bool enabled_localAppearances = true; /// /// Enables/disables Behavior effects for this specific TextAnimator component. /// /// /// To disable effects on all TextAnimators, please see /// /// public void EnableBehaviorsLocally(bool value) { enabled_localBehaviors = value; } /// /// Enables/disables Appearance effects for this specific TextAnimator component. /// /// /// To disable effects on all TextAnimators, please see /// /// public void EnableAppearancesLocally(bool value) { enabled_localAppearances = value; } #endregion #region Effects Database bool databaseBuilt = false; Dictionary localBehaviors = new Dictionary(); Dictionary localAppearances = new Dictionary(); public void AssignSharedAppearancesData(BuiltinAppearancesDataScriptable scriptable) { scriptable_globalAppearancesValues = scriptable; appearancesContainer.values.defaults = scriptable.effectValues; } public void AssignSharedBehaviorsData(BuiltinBehaviorsDataScriptable scriptable) { scriptable_globalBehaviorsValues = scriptable; behaviorValues.defaults = scriptable.effectValues; } void BuildTagsDatabase() { if (databaseBuilt) return; TAnimBuilder.InitializeGlobalDatabase(); databaseBuilt = true; #region Global built-in effects values //replaces local appearances data with global scriptable data if (scriptable_globalAppearancesValues) { appearancesContainer.values.defaults = scriptable_globalAppearancesValues.effectValues; } //replaces local behavior data with global scriptable data if (scriptable_globalBehaviorsValues) { behaviorValues.defaults = scriptable_globalBehaviorsValues.effectValues; } #endregion //adds local behavior presets for (int i = 0; i < behaviorValues.presets.Length; i++) { TAnimBuilder.TryAddingPresetToDictionary(ref localBehaviors, behaviorValues.presets[i].effectTag, typeof(PresetBehavior)); } //Adds local appearance presets for (int i = 0; i < appearancesContainer.values.presets.Length; i++) { TAnimBuilder.TryAddingPresetToDictionary(ref localAppearances, appearancesContainer.values.presets[i].effectTag, typeof(PresetAppearance)); } #region Fallback appearing effects //TODO make a generic method for both AppearanceBase[] GetFallbackAppearancesFromTag(string[] tagsToConvert) { var temp_fallbackEffects = new List(); //Default effects for (int i = 0; i < tagsToConvert.Length; i++) { if (tagsToConvert[i].Length <= 0) continue; //tag is empty var tags = tagsToConvert[i].Split(' '); string actualEffect = tags[0]; //removes probable modifiers foreach(var effect in temp_fallbackEffects) { if (effect.regionManager.entireRichTextTag.Equals(tagsToConvert[i])) continue; //same effect has already been added } if (TryGetAppearingClassFromTag(actualEffect, tagsToConvert[i], 0, out AppearanceBase effectBase)) { effectBase.SetDefaultValues(appearancesContainer.values); TryProcessingModifier(tags, ref effectBase); effectBase.regionManager.AddRegion(0); temp_fallbackEffects.Add(effectBase); } else { Debug.LogError($"TextAnimator: Effect Tag '{tagsToConvert[i]}' is not recognized.", this.gameObject); } } return temp_fallbackEffects.ToArray(); } this.fallbackAppearanceEffects = GetFallbackAppearancesFromTag(appearancesContainer.tagsFallback_Appearances); this.fallbackDisappearanceEffects = GetFallbackAppearancesFromTag(appearancesContainer.tagsFallback_Disappearances); var temp_fallbackBehaviorEffects = new List(); //Default behavior effects for (int i = 0; i < tags_fallbackBehaviors.Length; i++) { if (tags_fallbackBehaviors[i].Length <= 0) { continue; } var tags = tags_fallbackBehaviors[i].Split(' '); string actualEffect = tags[0]; //removes probable modifiers foreach (var effect in temp_fallbackBehaviorEffects) { if (effect.regionManager.entireRichTextTag.Equals(tags_fallbackBehaviors[i])) continue; //same effect has already been added } if (TryGetBehaviorClassFromTag(actualEffect, tags_fallbackBehaviors[i], 0, out BehaviorBase effectBase)) { effectBase.regionManager.AddRegion(0); TryProcessingModifier(tags, ref effectBase); temp_fallbackBehaviorEffects.Add(effectBase); } else { Debug.LogError($"TextAnimator: Behavior Tag '{tags_fallbackBehaviors[i]}' is not recognized.", this.gameObject); } } this.fallbackBehaviorEffects = temp_fallbackBehaviorEffects.ToArray(); #endregion } #endregion #region Effects Creation/Instancing bool TryGetBehaviorClassFromTag(string tag, string entireRichTextTag, int regionStartIndex, out BehaviorBase effectBase) { //Global Tags if (TAnimBuilder.TryGetGlobalBehaviorFromTag(tag, entireRichTextTag, out effectBase)) { effectBase.SetDefaultValues(behaviorValues); //<-- add this effectBase.regionManager.AddRegion(regionStartIndex); return true; } //Local tags if (TAnimBuilder.TryGetEffectClassFromTag(localBehaviors, tag, entireRichTextTag, out effectBase)) { effectBase.SetDefaultValues(behaviorValues); //<-- add this effectBase.regionManager.AddRegion(regionStartIndex); return true; } effectBase = default; return false; } bool TryGetAppearingClassFromTag(string tag, string entireRichTextTag, int startIndex, out AppearanceBase effectBase) { //Global Tags if (TAnimBuilder.TryGetGlobalAppearanceFromTag(tag, entireRichTextTag, out effectBase)) { effectBase.regionManager.AddRegion(startIndex); return true; } //Local tags if (TAnimBuilder.TryGetEffectClassFromTag(localAppearances, tag, entireRichTextTag, out effectBase)) { effectBase.regionManager.AddRegion(startIndex); return true; } effectBase = default; return false; } #endregion #region Management Methods #region Tags Processing const char m_closureSymbol = '/'; const char m_eventSymbol = '?'; const char m_disappearanceSymbol = '#'; bool TryProcessingAppearanceTag(string richTextTag, int realTextIndex) { //Closure tag, eg. '/' if (richTextTag[0] == m_closureSymbol) { //Tries closing effect if (richTextTag.Length > 1 && richTextTag[1] == m_disappearanceSymbol) { //disappearances, tag starts later (accounts for / and #) return disappearanceEffects.CloseSingleOrAllEffects(richTextTag.Substring(2, richTextTag.Length - 2), realTextIndex); } else { //appearances return appearanceEffects.CloseSingleOrAllEffects(richTextTag.Substring(1, richTextTag.Length - 1), realTextIndex); } } else { bool ProcessOpeningTag(List effectsList) { //Avoids creating a new class if the same effect has already been instanced for (int i = 0; i < effectsList.Count; i++) { if (effectsList[i].regionManager.TryReutilizingWithTag(richTextTag, realTextIndex)) { return true; } } //All the tags inside the { } region (without the opening and ending chars, '{' and '}') separated by a space string[] tags = richTextTag.Split(' '); #region Tries adding effect if (TryGetAppearingClassFromTag(tags[0], richTextTag, realTextIndex, out AppearanceBase effectBase)) { effectBase.SetDefaultValues(appearancesContainer.values); TryProcessingModifier(tags, ref effectBase); effectsList.TryAddingNewRegion(effectBase); return true; } #endregion return false; } if (richTextTag[0] == m_disappearanceSymbol) //disappearances { richTextTag = richTextTag.Substring(1, richTextTag.Length - 1); //removes the disappearance symbol, e.g. #fade becomes fade return ProcessOpeningTag(disappearanceEffects); } else //appearances { return ProcessOpeningTag(appearanceEffects); } } } void TryProcessingModifier(string [] tags, ref T effect) where T : EffectsBase { int equalsIndex; //Searches for modifiers inside the effect region (after the first tag, which we used to check the type of effect to add) for (int tagIndex = 1; tagIndex < tags.Length; tagIndex++) { equalsIndex = tags[tagIndex].IndexOf('='); //we've found an "=" symbol, so we're setting the modifier if (equalsIndex >= 0) { //modifier name, from start to the equals symbol string modifierName = tags[tagIndex].Substring(0, equalsIndex); //Numeric value of the modifier (the part after the equal symbol) string modifierValueName = tags[tagIndex].Substring(equalsIndex + 1); //modifierValueName = modifierValueName.Replace('.', ','); //replaces dots with commas effect.SetModifier(modifierName, modifierValueName); #if UNITY_EDITOR effect.EDITOR_RecordModifier(modifierName, modifierValueName); #endif } } } bool TryProcessingBehaviorTag(string richTextTag, string loweredRichTextTag, int realTextIndex, ref int internalEventActionIndex) { if (loweredRichTextTag[0] == m_eventSymbol) { richTextTag = richTextTag.Substring(1, richTextTag.Length - 1); #region Tries firing event if (richTextTag.Length == 0) //prevents from adding an empty callback return false; eventMarkers.Add(new EventMarker { charIndex = realTextIndex, eventMessage = richTextTag, internalOrder = internalEventActionIndex, }); internalEventActionIndex++; //increases internal events and features order return true; #endregion } else if (loweredRichTextTag[0] == m_closureSymbol) { loweredRichTextTag = loweredRichTextTag.Substring(1, loweredRichTextTag.Length - 1); #region Tries closing effect bool closedRegion = false; //Closes all the regions if (loweredRichTextTag.Length <= 0) //tag is { //Closes ALL the region opened until now for (int k = 0; k < behaviorEffects.Count; k++) { closedRegion = behaviorEffects.CloseElement(k, realTextIndex); } } //Closes the current region else { closedRegion = behaviorEffects.CloseRegionNamed(loweredRichTextTag, realTextIndex); } return closedRegion; #endregion } else { #region Tries adding effect //Avoids creating a new effect if the same one has already been instanced for (int i = 0; i < behaviorEffects.Count; i++) { if (behaviorEffects[i].regionManager.TryReutilizingWithTag(loweredRichTextTag, realTextIndex)) return true; } //All the tags inside the "< >" region (without the opening and ending chars, '<' and '>') separated by a space string[] tags = loweredRichTextTag.Split(' '); //Creates a behavior effect if (TryGetBehaviorClassFromTag(tags[0], loweredRichTextTag, realTextIndex, out BehaviorBase behaviorEffect)) { behaviorEffect.SetDefaultValues(behaviorValues); TryProcessingModifier(tags, ref behaviorEffect); behaviorEffects.TryAddingNewRegion(behaviorEffect); return true; } //No effect found return false; #endregion } } bool TryProcessingActionTag(string entireTag, int realTextIndex, ref int internalEventActionIndex) { //First part of the tag, "" becomes "ciao" string firstPartTag = entireTag.Substring(1, entireTag.Length - 2); //Trims from the equal symbol. If it's "" it becomes "ciao" int trimmeredIndex = entireTag.IndexOf('='); if (trimmeredIndex >= 0) { firstPartTag = firstPartTag.Substring(0, trimmeredIndex - 1); } //Checks if the tag is a recognized action if (TAnimBuilder.IsDefaultAction(firstPartTag) || TAnimBuilder.IsCustomAction(firstPartTag)) { hasActions = true; InternalAction m_action = default; m_action.action = new TypewriterAction(); m_action.action.actionID = firstPartTag; m_action.charIndex = realTextIndex; m_action.action.parameters = new List(); //the tag has also a part after the equal if (trimmeredIndex >= 0) { //creates its parameters string finalPartTag = entireTag.Substring(firstPartTag.Length + 2); finalPartTag = finalPartTag .Substring(0, finalPartTag.Length - 1); //Splits parameters m_action.action.parameters = finalPartTag.Split(',').ToList(); } m_action.internalOrder = internalEventActionIndex; typewriterActions.Add(m_action); internalEventActionIndex++; return true; } return false; } #endregion bool noparseEnabled = false; int internalEventActionIndex = 0; List temp_effectsToApply = new List(); //temporary void _SetText(string text, ShowTextMode showTextMode) { //Prevents to calculate everything for an empty text if (text.Length <= 0) { hasText = false; text = string.Empty; tmproText.text = string.Empty; tmproText.ClearMesh(); return; } BuildTagsDatabase(); #region Resets text variables skipAppearanceEffects = false; hasActions = false; noparseEnabled = false; if(isResettingEffectsOnNewText) m_time.ResetData(); //resets time behaviorEffects.Clear(); appearanceEffects.Clear(); disappearanceEffects.Clear(); eventMarkers.Clear(); typewriterActions.Clear(); latestTriggeredEvent = 0; latestTriggeredAction = 0; internalEventActionIndex = 0; #endregion #region Adds Fallback Effects //fallback effects are added at the end of the list for (int i = 0; i < fallbackAppearanceEffects.Length; i++) { appearanceEffects.Add(fallbackAppearanceEffects[i]); } for (int i = 0; i < fallbackDisappearanceEffects.Length; i++) { disappearanceEffects.Add(fallbackDisappearanceEffects[i]); } for (int i = 0; i < fallbackBehaviorEffects.Length; i++) { behaviorEffects.Add(fallbackBehaviorEffects[i]); } #endregion _ApplyTextToCharacters(_FormatText(text, 0)); //-------- //Decides how many characters to show //-------- void HideCharacter(int i) { characters[i].data.passedTime = 0; characters[i].isDisappearing = true; characters[i].wantsToDisappear = true; characters[i].Hide(); } void HideAllCharacters() { _maxVisibleCharacters = 0; for (int i = 0; i < textInfo.characterCount; i++) { HideCharacter(i); } if (_maxVisibleCharacters <= 0 && characters.Length > 0) { HideCharacter(0); } } void ShowAllCharacters() { _maxVisibleCharacters = textInfo.characterCount; //resets letters time for (int i = 0; i < textInfo.characterCount; i++) { characters[i].data.passedTime = 0; characters[i].isDisappearing = false; characters[i].wantsToDisappear = false; } } switch (showTextMode) { case ShowTextMode.Hidden: HideAllCharacters(); break; case ShowTextMode.Shown: ShowAllCharacters(); break; case ShowTextMode.UserTyping: maxVisibleCharacters = textInfo.characterCount + 1; #if INTEGRATE_NANINOVEL //Hides characters based on naninovel's progress for (int i = 0; i < characters.Length; i++) { if (i >= Mathf.CeilToInt(Mathf.Clamp01(reveablelText.RevealProgress) * textInfo.characterCount)) { HideCharacter(i); } } #endif if (_maxVisibleCharacters - 1 < characters.Length && _maxVisibleCharacters - 1 >= 0) HideCharacter(_maxVisibleCharacters - 1); //user is typing, the latest letter has time reset break; } #if TA_DEBUG DebugText(); #endif } private string _FormatText(string text, int startCharacterIndex) { System.Text.StringBuilder temp_realText = new System.Text.StringBuilder(); #if CHECK_ERRORS EDITOR_CompatibilityCheck(text); #endif temp_realText.Clear(); //Temporary variables string entireTag; string loweredRichTextTag; string richTextTag; int indexOfClosing; int indexOfNextOpening; for (int i = 0, realTextIndex = startCharacterIndex; i < text.Length; i++) { #region Local Methods void AppendCurrentCharacterToText() { temp_realText.Append(text[i]); realTextIndex++; } bool TryGetClosingCharacter(out char _closingCharacter) { if (text[i] == TAnimBuilder.tag_behaviors.charOpeningTag) { _closingCharacter = TAnimBuilder.tag_behaviors.charClosingTag; return true; } else if (text[i] == TAnimBuilder.tag_appearances.charOpeningTag) { _closingCharacter = TAnimBuilder.tag_appearances.charClosingTag; return true; } _closingCharacter = default; return false; } //Pastes the entire tag (eg. ) to the text void AppendCurrentTagToText() { temp_realText.Append(entireTag); realTextIndex += entireTag.Length; } #endregion if (TryGetClosingCharacter(out char closingCharacter)) { indexOfNextOpening = text.IndexOf(text[i], i + 1); indexOfClosing = text.IndexOf(closingCharacter, i + 1); //Checks if the tag is closed correctly and valid if ( indexOfClosing >= 0 //the tag ends somewhere && ( indexOfNextOpening > indexOfClosing || //next opening char is further from the closing (example, at first pos " <" is ok, "<" is wrong) indexOfNextOpening < 0 //there isn't a next opening char ) ) { //entire tag found, including < and > entireTag = (text.Substring(i, indexOfClosing - i + 1)); richTextTag = entireTag.Substring(1, entireTag.Length - 2); loweredRichTextTag = richTextTag.ToLower(); #region Processes Tags if (loweredRichTextTag.Length < 1) //avoids an empty tag { AppendCurrentTagToText(); } else { if (closingCharacter == TAnimBuilder.tag_appearances.charClosingTag) { if (noparseEnabled || !TryProcessingAppearanceTag(loweredRichTextTag, realTextIndex)) { AppendCurrentTagToText(); } } else //behavior effects { switch (loweredRichTextTag) { // case "noparse": noparseEnabled = true; AppendCurrentTagToText(); break; case "/noparse": noparseEnabled = false; AppendCurrentTagToText(); break; default: if (noparseEnabled) { AppendCurrentTagToText(); } else { if (!TryProcessingBehaviorTag(richTextTag, loweredRichTextTag, realTextIndex, ref internalEventActionIndex)) { if (!TryProcessingActionTag(entireTag, realTextIndex, ref internalEventActionIndex)) { AppendCurrentTagToText(); } } } break; } } } #endregion //"skips" all the characters inside the tag, so we'll go back adding letters again i = indexOfClosing; } else //tag is not closed correctly - pastes the tag opening/closing character (eg. '<') { AppendCurrentCharacterToText(); } } else { AppendCurrentCharacterToText(); } } return temp_realText.ToString(); } void _ApplyTextToCharacters(string text) { //Applies the formatted to the component in order to get the proper TextInfo { //Avoids rendering the text for half a frame tmproText.renderMode = TextRenderFlags.DontRender; //--generates mesh and text info-- if (attachedInputField) attachedInputField.text = text; //renders input field else tmproText.text = text; //<-- sets the text tmproText.ForceMeshUpdate(true); textInfo = tmproText.GetTextInfo(tmproText.text); } //Resizes characters array if (characters.Length < textInfo.characterCount) Array.Resize(ref characters, textInfo.characterCount); #region Effects and Features Initialization foreach (var effect in this.appearanceEffects) { effect.Initialize(characters.Length); } foreach (var effect in this.disappearanceEffects) { effect.Initialize(characters.Length); } foreach (var effect in this.behaviorEffects) { effect.Initialize(characters.Length); } #endregion #region Characters Setup for (int i = 0; i < textInfo.characterCount; i++) { characters[i].data.tmp_CharInfo = textInfo.characterInfo[i]; //Calculates which effects are applied to this character #region Sources and data //Creates sources and data arrays only the first time if (!characters[i].initialized) { characters[i].sources.vertices = new Vector3[TextUtilities.verticesPerChar]; characters[i].sources.colors = new Color32[TextUtilities.verticesPerChar]; characters[i].data.vertices = new Vector3[TextUtilities.verticesPerChar]; characters[i].data.colors = new Color32[TextUtilities.verticesPerChar]; } #endregion void SetEffectsDependency(ref int[] indexes, List effects, int fallbackEffectsCount) where T : EffectsBase { temp_effectsToApply.Clear(); //Checks if the character is inside a region of any effect, if yes we add a pointer to it for (int l = fallbackEffectsCount; l < effects.Count; l++) { if (effects[l].regionManager.IsCharInsideRegion(textInfo.characterInfo[i].index)) { temp_effectsToApply.Add(l); } } indexes = new int[temp_effectsToApply.Count]; for (int x = 0; x < temp_effectsToApply.Count; x++) { indexes[x] = temp_effectsToApply[x]; } } //Assigns effects SetEffectsDependency(ref characters[i].indexBehaviorEffects, behaviorEffects, fallbackBehaviorEffects.Length); SetEffectsDependency(ref characters[i].indexAppearanceEffects, appearanceEffects, fallbackAppearanceEffects.Length); SetEffectsDependency(ref characters[i].indexDisappearanceEffects, disappearanceEffects, fallbackDisappearanceEffects.Length); #region Fallback Effects void AssignFallbackEffect(T[] effect, ref int[] indexes) where T : EffectsBase { //Assigns fallbacks appearances if there are no effects on the current characters if (effect.Length > 0 && indexes.Length <= 0) { indexes = new int[effect.Length]; for (int x = 0; x < effect.Length; x++) { indexes[x] = x; //fallback effects are added at the start of the array } } } AssignFallbackEffect(fallbackAppearanceEffects, ref characters[i].indexAppearanceEffects); AssignFallbackEffect(fallbackBehaviorEffects, ref characters[i].indexBehaviorEffects); AssignFallbackEffect(fallbackDisappearanceEffects, ref characters[i].indexDisappearanceEffects); #endregion //Assigns duration float CalculateAppearanceDuration(int[] effectsIndex, List effects) { float duration = 0; //calculates disappearance duration foreach (var index in effectsIndex) { if (effects[index].effectDuration > duration) duration = effects[index].effectDuration; } return duration; } characters[i].disappearancesMaxDuration = CalculateAppearanceDuration(characters[i].indexDisappearanceEffects, disappearanceEffects); characters[i].appearancesMaxDuration = CalculateAppearanceDuration(characters[i].indexAppearanceEffects, appearanceEffects); //Copies source data from the mesh info only if the character is valid, otherwise its vertices array will be null and tAnim will start throw errors if (textInfo.characterInfo[i].isVisible) { for (byte k = 0; k < TextUtilities.verticesPerChar; k++) { //vertices characters[i].sources.vertices[k] = textInfo.meshInfo[textInfo.characterInfo[i].materialReferenceIndex].vertices[textInfo.characterInfo[i].vertexIndex + k]; //colors characters[i].sources.colors[k] = textInfo.meshInfo[textInfo.characterInfo[i].materialReferenceIndex].colors32[textInfo.characterInfo[i].vertexIndex + k]; } } } //makes sure the new characters start invisible, and do not have a disappearance effect applied for (int i = maxVisibleCharacters; i < characters.Length; i++) { characters[i].isDisappearing = true; characters[i].data.passedTime = 0; } #endregion #region Updates variables hasText = text.Length > 0; autoSize = tmproText.enableAutoSizing; this.text = tmproText.text; #endregion AssertCharacterTimes(); //Avoids the next text to be rendered for half a frame tmproText.renderMode = TextRenderFlags.DontRender; CopyMeshSources(); } void TryTriggeringEvent(int maxInternalOrder) { //Calls all events markers until the current shown visible character for (int i = latestTriggeredEvent; i < eventMarkers.Count; i++) { if (!eventMarkers[i].triggered && //current event must not be triggered already eventMarkers[i].charIndex <= textInfo.characterInfo[_maxVisibleCharacters].index && //triggers any event until the current character eventMarkers[i].internalOrder < maxInternalOrder ) { var _event = eventMarkers[i]; _event.triggered = true; eventMarkers[i] = _event; latestTriggeredEvent = i; onEvent?.Invoke(eventMarkers[i].eventMessage); } } } /// /// Tries to get an action in the current position of the text /// /// Initialized feature /// True if we have found one action in the current text position internal bool TryGetAction(out TypewriterAction action) { if (_maxVisibleCharacters >= textInfo.characterCount) //avoids searching if text has ended { action = default; return false; } for (int i = latestTriggeredAction; i < typewriterActions.Count; i++) { if (typewriterActions[i].charIndex == textInfo.characterInfo[_maxVisibleCharacters].index && !typewriterActions[i].triggered) { //tries triggering event, if it's written before function TryTriggeringEvent(typewriterActions[i].internalOrder); var typAction = typewriterActions[i]; typAction.triggered = true; typewriterActions[i] = typAction; action = typAction.action; latestTriggeredAction = i; return true; } } action = default; return false; } /// /// Updates effect intensity based on the text size. /// /// void UpdateEffectIntensityWithSize(float charSize) { float intensity = effectIntensityMultiplier; if (useDynamicScaling) { // multiplies by current character size, which could be modified by "size" tags and so // be different than the basic tmp font size value intensity *= charSize / referenceFontSize; } void SetEffectsIntensity(List effects) where T: EffectsBase { foreach (T effect in effects) { effect.uniformIntensity = intensity; } } SetEffectsIntensity(behaviorEffects); SetEffectsIntensity(appearanceEffects); SetEffectsIntensity(disappearanceEffects); } #endregion #region Mesh int tmpFirstVisibleCharacter; int tmpMaxVisibleCharacters; void CopyMeshSources() { forceMeshRefresh = false; autoSize = tmproText.enableAutoSizing; sourceRect = tmproText.rectTransform.rect; sourceColor = tmproText.color; tmpFirstVisibleCharacter = tmproText.firstVisibleCharacter; tmpMaxVisibleCharacters = tmproText.maxVisibleCharacters; //Updates the characters sources for (int i = 0; i < textInfo.characterCount && i < characters.Length; i++) { //Updates TMP char info characters[i].data.tmp_CharInfo = textInfo.characterInfo[i]; if (!textInfo.characterInfo[i].isVisible) continue; //Updates vertices for (byte k = 0; k < TextUtilities.verticesPerChar; k++) { characters[i].sources.vertices[k] = textInfo.meshInfo[textInfo.characterInfo[i].materialReferenceIndex].vertices[textInfo.characterInfo[i].vertexIndex + k]; } //Updates colors for (byte k = 0; k < TextUtilities.verticesPerChar; k++) { characters[i].sources.colors[k] = textInfo.meshInfo[textInfo.characterInfo[i].materialReferenceIndex].colors32[textInfo.characterInfo[i].vertexIndex + k]; } } } /// /// Applies the changes to the text component /// void UpdateMesh() { //Updates the mesh for (int i = 0; i < textInfo.characterCount && i < characters.Length; i++) { //Avoids updating if we're on an invisible character, like a spacebar //Do not switch this with "i 0) { debugBuilder.Append($"appearances: "); for (int j = 0; j < character.indexAppearanceEffects.Length; j++) { debugBuilder.Append($"[{character.indexAppearanceEffects[j]}]"); } //adds a '-' in case there are other effects too if (character.indexDisappearanceEffects.Length > 0 || character.indexBehaviorEffects.Length > 0) { debugBuilder.Append($" - "); } } if (character.indexDisappearanceEffects.Length > 0) { debugBuilder.Append($"disappearances: "); for (int j = 0; j < character.indexDisappearanceEffects.Length; j++) { debugBuilder.Append($"[{character.indexDisappearanceEffects[j]}]"); } debugBuilder.Append($", {character.disappearancesMaxDuration}s"); //adds a '-' in case there are other effects too if (character.indexBehaviorEffects.Length > 0) { debugBuilder.Append($" - "); } } if (character.indexBehaviorEffects.Length > 0) { debugBuilder.Append($"behaviors: "); for (int j = 0; j < character.indexBehaviorEffects.Length; j++) { debugBuilder.Append($"[{character.indexBehaviorEffects[j]}]"); } } debugBuilder.Append($"\n"); } debugBuilder.Append("\n--TMP CHARACTERS--\n"); //Characters for (int i = 0; i < textInfo.characterCount; i++) { var character = textInfo.characterInfo[i]; debugBuilder.Append($"[C #{i}] '{character.character}' - visible: {character.isVisible} - index: {character.index}\n"); } Debug.Log(debugBuilder.ToString(), this.gameObject); } #endif private void Update() { //TMPRO's text changed, setting the text again if (!tmproText.text.Equals(text)) { if (hasParentCanvas && !parentCanvas.isActiveAndEnabled) return; //trigers anim player if (triggerAnimPlayerOnChange && tAnimPlayer != null) { #if TA_NoTempFix tAnimPlayer.ShowText(tmproText.text); #else //temp fix, opening and closing this TMPro tag (which won't be showed in the text, acting like they aren't there) because otherwise //there isn't any way to trigger that the text has changed, if it's actually the same as the previous one. if (tmproText.text.Length <= 0) //forces clearing the mesh during the tempFix, without the tags tAnimPlayer.ShowText(""); else tAnimPlayer.ShowText($"{tmproText.text}"); #endif } else //user is typing from TMPro { _SetText(tmproText.text, ShowTextMode.UserTyping); } return; } //applies effects if(updateMode == UpdateMode.Auto) UpdateEffects(); } /// /// Invoke this to manually update the effects, when is set to . /// public void UpdateEffects() { if (!hasText) return; UpdateEffectsToMesh(); } void UpdateEffectsToMesh() { m_time.UpdateDeltaTime(timeScale); m_time.IncreaseTime(); #region Effects Calculation for (int i = 0; i < behaviorEffects.Count; i++) { behaviorEffects[i].SetAnimatorData(m_time); behaviorEffects[i].Calculate(); } for (int i = 0; i < appearanceEffects.Count; i++) { appearanceEffects[i].Calculate(); } for (int i = 0; i < disappearanceEffects.Count; i++) { disappearanceEffects[i].Calculate(); } #endregion for (int i = 0; i < textInfo.characterCount && i < characters.Length; i++) { #if INTEGRATE_NANINOVEL //If we're integrating naninovels, shows characters based on its reveal component if (isNaninovelPresent) { if (reveablelText.RevealProgress < (float)i / textInfo.characterCount) continue; } #endif //applies effects only if the character is visible in TMPro //otherwise the UVs etc. are all distorted and we don't want that if (!textInfo.characterInfo[i].isVisible) { characters[i].data.passedTime = 0; characters[i].Hide(); continue; } void TryApplyingBehaviors() { if (enabled_globalBehaviors && enabled_localBehaviors) { foreach (int behaviorIndex in characters[i].indexBehaviorEffects) { behaviorEffects[behaviorIndex].ApplyEffect(ref characters[i].data, i); } } } //Makes the disappearance or appearance effect start instantly, in the correct order (to prevent graphic glitches when the user changes maxVisibleCharacters etc. in the middle of a frame or similar) if (characters[i].isDisappearing != characters[i].wantsToDisappear) { characters[i].isDisappearing = characters[i].wantsToDisappear; characters[i].data.passedTime = characters[i].isDisappearing ? characters[i].disappearancesMaxDuration : 0; } characters[i].ResetColors(); characters[i].ResetVertices(); //Updates again the effects intensity, since this character might have a different font size //compared to the others (e.g. modified by TMPRO's size tag) UpdateEffectIntensityWithSize(textInfo.characterInfo[i].pointSize); //character is appearing if (!characters[i].isDisappearing) { //behaviors TryApplyingBehaviors(); //appearances if (enabled_globalAppearances && enabled_localAppearances && !skipAppearanceEffects) { foreach (int appearanceIndex in characters[i].indexAppearanceEffects) { if (appearanceEffects[appearanceIndex].CanShowAppearanceOn(characters[i].data.passedTime)) { appearanceEffects[appearanceIndex].ApplyEffect(ref characters[i].data, i); } } } characters[i].data.passedTime += m_time.deltaTime; } else //tries to apply disappearance effects { //hides the character entirely if (characters[i].data.passedTime <= 0) { characters[i].data.passedTime = 0; characters[i].Hide(); continue; } //still applies behavior effects (if present) in order to not cut them while a letter disappears TryApplyingBehaviors(); //disappearances if (enabled_globalAppearances && enabled_localAppearances) { foreach (int disappearanceIndex in characters[i].indexDisappearanceEffects) { if (disappearanceEffects[disappearanceIndex].CanShowAppearanceOn(characters[i].data.passedTime)) { disappearanceEffects[disappearanceIndex].ApplyEffect(ref characters[i].data, i); } } } characters[i].data.passedTime -= m_time.deltaTime; } } UpdateMesh(); //TMPro's component changed, recalculating mesh //P.S. Must be placed after everything else. if (tmproText.havePropertiesChanged || forceMeshRefresh //changing the properties below doesn't seem to trigger 'havePropertiesChanged', so we're checking them manually || tmproText.enableAutoSizing != autoSize || tmproText.rectTransform.rect != sourceRect || tmproText.color != sourceColor || tmproText.firstVisibleCharacter != tmpFirstVisibleCharacter || tmproText.maxVisibleCharacters != tmpMaxVisibleCharacters ) { tmproText.ForceMeshUpdate(); CopyMeshSources(); } } private void OnEnable() { //The mesh might have changed when the gameObject was disabled (eg. change of "autoSize") forceMeshRefresh = true; textInfo = tmproText.textInfo; UpdateEffectsToMesh(); #if UNITY_EDITOR TAnim_EditorHelper.onChangesApplied += EDITORONLY_ResetEffects; #endif } #if UNITY_EDITOR #region Editor #if CHECK_ERRORS void EDITOR_CompatibilityCheck(string text) { #region Text string textLower = text.ToLower(); string errorsLog = ""; //page if ((textLower.Contains(" 0) { Debug.LogError($"TextAnimator: Given text not accepted [expand for more details]\n\nText:'{text}'\n\nErrors:\n{errorsLog}", this.gameObject); } #endregion } #endif [ContextMenu("Toggle Appearances (all scripts)")] void EDITORONLY_ToggleAppearances() { if (!Application.isPlaying) return; EnableAppearances(!enabled_globalAppearances); } [ContextMenu("Toggle Behaviors (all scripts)")] void EDITORONLY_ToggleBehaviors() { if (!Application.isPlaying) return; EnableBehaviors(!enabled_globalBehaviors); } private void OnDisable() { TAnim_EditorHelper.onChangesApplied -= EDITORONLY_ResetEffects; } void EDITORONLY_ResetEffects() { if (!Application.isPlaying) return; if (behaviorEffects != null && appearanceEffects != null && disappearanceEffects != null) { //---sets intensity--- for (int i = 0; i < behaviorEffects.Count; i++) { behaviorEffects[i].SetDefaultValues(behaviorValues); } for (int i = 0; i < appearanceEffects.Count; i++) { appearanceEffects[i].SetDefaultValues(appearancesContainer.values); } for (int i = 0; i < disappearanceEffects.Count; i++) { disappearanceEffects[i].SetDefaultValues(appearancesContainer.values); } for (int i = 0; i < behaviorEffects.Count; i++) { behaviorEffects[i].EDITOR_ApplyModifiers(); } for (int i = 0; i < appearanceEffects.Count; i++) { appearanceEffects[i].EDITOR_ApplyModifiers(); } for (int i = 0; i < disappearanceEffects.Count; i++) { disappearanceEffects[i].EDITOR_ApplyModifiers(); } } } #endregion #endif } }