using Godot; using System.Globalization; using System.Threading.Tasks; namespace DialogueManagerRuntime { /// /// A RichTextLabel specifically for use with Dialogue Manager dialogue. /// [Tool, Icon("uid://s0s1348tpm6d")] public partial class DialogueLabel : RichTextLabel { /// /// Emitted for each letter typed out. /// [Signal] public delegate void SpokeEventHandler(string letter, int letterIndex, float speed); /// /// Emitted when the player skips the typing of dialogue. /// [Signal] public delegate void SkippedTypingEventHandler(); /// /// Emitted when typing starts. /// [Signal] public delegate void StartedTypingEventHandler(); /// /// Emitted when typing finishes. /// [Signal] public delegate void FinishedTypingEventHandler(); /// /// The action to press to skip typing. /// [Export] public StringName SkipAction = "ui_cancel"; /// /// The speed with which the text types out. /// [Export] public float SecondsPerStep = 0.02f; /// /// Automatically have a brief pause when these characters are encountered. /// [Export] public string PauseAtCharacters = ".?!"; /// /// Don't auto pause if the character after the pause is one of these. /// [Export] public string SkipPauseAtCharacterIfFollowedBy = ")\""; /// /// Don't auto pause after these abbreviations (only if "." is in PauseAtCharacters). /// Abbreviations are limited to 5 characters in length. /// Does not support multi-period abbreviations (ex. "p.m.") /// [Export] public string[] SkipPauseAtAbbreviations = { "Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex" }; /// /// The amount of time to pause when exposing a character present in PauseAtCharacters. /// [Export] public float SecondsPerPauseStep = 0.3f; private readonly System.Collections.Generic.List alreadyMutatedIndices = new(); private DialogueLine dialogueLine = null; /// /// The current line of dialogue. /// public DialogueLine DialogueLine { get => dialogueLine; set { if (value != dialogueLine) { dialogueLine = value; UpdateText(); } } } /// /// Whether the label is currently typing itself out. /// public bool IsTyping { get => isTyping && !isAwaitingMutation; set { bool didJustFinish = isTyping != value && value == false && VisibleCharacters == GetTotalCharacterCount(); isTyping = value; if (didJustFinish) { EmitSignal(SignalName.FinishedTyping); } } } private bool isTyping = false; private int lastWaitIndex = -1; private int lastMutationIndex = -1; private float waitingSeconds = 0; private bool isAwaitingMutation = false; private bool isSkippingMutations = false; public override void _Process(double delta) { if (isTyping) { // Type out text if (VisibleRatio < 1) { // See if we are waiting if (waitingSeconds > 0) { waitingSeconds -= (float)delta; } // If we are no longer waiting then keep typing if (waitingSeconds <= 0) { TypeNext((float)delta, waitingSeconds); } } else { // Make sure any mutations at the end of the line get run _ = MutateInlineMutations(GetTotalCharacterCount()); IsTyping = false; } } } /// /// Sets the label's text from the current dialogue line. Override if you want /// to do something more interesting in your subclass. /// protected virtual void UpdateText() { if (IsInstanceValid(dialogueLine)) { Text = dialogueLine.Text; } else { Text = ""; } } /// /// Start typing out the text. /// public async void TypeOut() { UpdateText(); VisibleCharacters = 0; VisibleRatio = 0; waitingSeconds = 0; lastWaitIndex = -1; lastMutationIndex = -1; alreadyMutatedIndices.Clear(); IsTyping = true; EmitSignal(SignalName.StartedTyping); // Allow typing listeners a chance to connect await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); if (GetTotalCharacterCount() == 0) { IsTyping = false; } else if (SecondsPerStep == 0) { MutateRemainingMutations(); VisibleCharacters = GetTotalCharacterCount(); IsTyping = false; } } /// /// Stop typing out the text and jump right to the end. /// public void SkipTyping() { MutateRemainingMutations(); VisibleCharacters = GetTotalCharacterCount(); IsTyping = false; EmitSignal(SignalName.SkippedTyping); } // Type out the next character(s) private void TypeNext(float delta, float secondsNeeded) { if (isAwaitingMutation) return; if (VisibleCharacters == GetTotalCharacterCount()) { return; } if (lastMutationIndex != VisibleCharacters) { lastMutationIndex = VisibleCharacters; _ = MutateInlineMutations(VisibleCharacters); if (isAwaitingMutation) return; } // Pause on characters like "." float pauseWaitingSeconds = ShouldAutoPause() ? SecondsPerPauseStep : 0f; if (lastWaitIndex != VisibleCharacters && pauseWaitingSeconds > 0) { lastWaitIndex = VisibleCharacters; waitingSeconds += pauseWaitingSeconds; } else { VisibleCharacters += 1; if (VisibleCharacters <= GetTotalCharacterCount()) { EmitSignal(SignalName.Spoke, GetParsedText()[VisibleCharacters - 1].ToString(), VisibleCharacters - 1, GetSpeed(VisibleCharacters)); } // See if there's time to type out some more in this frame secondsNeeded += SecondsPerStep * (1.0f / GetSpeed(VisibleCharacters)); if (secondsNeeded > delta) { waitingSeconds += secondsNeeded; } else { TypeNext(delta, secondsNeeded); } } } // Get the speed for the current typing position private float GetSpeed(int atIndex) { float speed = 1; foreach (var speedEntry in dialogueLine.Speeds) { if ((int)speedEntry.Key > atIndex) { return speed; } speed = (float)speedEntry.Value; } return speed; } // Run any inline mutations that haven't been run yet private void MutateRemainingMutations() { isSkippingMutations = true; for (int i = VisibleCharacters; i < GetTotalCharacterCount() + 1; i++) { _ = MutateInlineMutations(i); } isSkippingMutations = false; } // Run any mutations at the current typing position private async Task MutateInlineMutations(int index) { if (!IsInstanceValid(dialogueLine)) return; foreach (Godot.Collections.Array inlineMutation in dialogueLine.InlineMutations) { // Inline mutations are an array of arrays in the form of [character index, resolvable function] if ((int)inlineMutation[0] > index) { return; } if ((int)inlineMutation[0] == index && !alreadyMutatedIndices.Contains(index)) { if (isSkippingMutations) { _ = DialogueManager.Mutate(inlineMutation[1].AsGodotDictionary(), dialogueLine.ExtraGameStates, true); } else { isAwaitingMutation = true; await DialogueManager.Mutate(inlineMutation[1].AsGodotDictionary(), dialogueLine.ExtraGameStates, true); isAwaitingMutation = false; } } } alreadyMutatedIndices.Add(index); } // Determine if the current autopause character at the cursor should qualify to pause typing. private bool ShouldAutoPause() { if (VisibleCharacters == 0) return false; string parsedText = GetParsedText(); // Avoid out-of-bounds when the label auto-translates and the text changes to one shorter while typing out // Note: visible characters can be larger than parsed_text after a translation event if (VisibleCharacters >= parsedText.Length) return false; // Ignore pause characters if they are next to a non-pause character if (SkipPauseAtCharacterIfFollowedBy.Contains(parsedText[VisibleCharacters])) { return false; } // Ignore "." if it's between two numbers if (VisibleCharacters > 3 && parsedText[VisibleCharacters - 1] == '.') { string possibleNumber = parsedText.Substring(VisibleCharacters - 2, 3); if (float.TryParse(possibleNumber, NumberStyles.Float, CultureInfo.InvariantCulture, out float number) && number.ToString("0.0", CultureInfo.InvariantCulture) == possibleNumber) { return false; } } // Ignore "." if it's used in an abbreviation // Note: does NOT support multi-period abbreviations (ex. p.m.) if (PauseAtCharacters.Contains('.') && parsedText[VisibleCharacters - 1] == '.') { foreach (string abbreviation in SkipPauseAtAbbreviations) { int start = VisibleCharacters - abbreviation.Length - 1; if (VisibleCharacters >= abbreviation.Length && start >= 0 && parsedText.Substring(start, abbreviation.Length) == abbreviation) { return false; } } } // Ignore two non-"." characters next to each other string otherPauseCharacters = PauseAtCharacters.Replace(".", ""); if (VisibleCharacters > 1 && otherPauseCharacters.Contains(parsedText[VisibleCharacters - 1]) && otherPauseCharacters.Contains(parsedText[VisibleCharacters])) { return false; } return PauseAtCharacters.Contains(parsedText[VisibleCharacters - 1]); } } }