357 lines
12 KiB
C#
357 lines
12 KiB
C#
using Godot;
|
|
using System.Globalization;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace DialogueManagerRuntime
|
|
{
|
|
/// <summary>
|
|
/// A RichTextLabel specifically for use with Dialogue Manager dialogue.
|
|
/// </summary>
|
|
[Tool, Icon("uid://s0s1348tpm6d")]
|
|
public partial class DialogueLabel : RichTextLabel
|
|
{
|
|
/// <summary>
|
|
/// Emitted for each letter typed out.
|
|
/// </summary>
|
|
[Signal] public delegate void SpokeEventHandler(string letter, int letterIndex, float speed);
|
|
|
|
/// <summary>
|
|
/// Emitted when the player skips the typing of dialogue.
|
|
/// </summary>
|
|
[Signal] public delegate void SkippedTypingEventHandler();
|
|
|
|
/// <summary>
|
|
/// Emitted when typing starts.
|
|
/// </summary>
|
|
[Signal] public delegate void StartedTypingEventHandler();
|
|
|
|
/// <summary>
|
|
/// Emitted when typing finishes.
|
|
/// </summary>
|
|
[Signal] public delegate void FinishedTypingEventHandler();
|
|
|
|
|
|
/// <summary>
|
|
/// The action to press to skip typing.
|
|
/// </summary>
|
|
[Export] public StringName SkipAction = "ui_cancel";
|
|
|
|
/// <summary>
|
|
/// The speed with which the text types out.
|
|
/// </summary>
|
|
[Export] public float SecondsPerStep = 0.02f;
|
|
|
|
/// <summary>
|
|
/// Automatically have a brief pause when these characters are encountered.
|
|
/// </summary>
|
|
[Export] public string PauseAtCharacters = ".?!";
|
|
|
|
/// <summary>
|
|
/// Don't auto pause if the character after the pause is one of these.
|
|
/// </summary>
|
|
[Export] public string SkipPauseAtCharacterIfFollowedBy = ")\"";
|
|
|
|
/// <summary>
|
|
/// Don't auto pause after these abbreviations (only if "." is in <c>PauseAtCharacters</c>).
|
|
/// Abbreviations are limited to 5 characters in length.
|
|
/// Does not support multi-period abbreviations (ex. "p.m.")
|
|
/// </summary>
|
|
[Export] public string[] SkipPauseAtAbbreviations = { "Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex" };
|
|
|
|
/// <summary>
|
|
/// The amount of time to pause when exposing a character present in <c>PauseAtCharacters</c>.
|
|
/// </summary>
|
|
[Export] public float SecondsPerPauseStep = 0.3f;
|
|
|
|
private readonly System.Collections.Generic.List<int> alreadyMutatedIndices = new();
|
|
|
|
|
|
private DialogueLine dialogueLine = null;
|
|
|
|
/// <summary>
|
|
/// The current line of dialogue.
|
|
/// </summary>
|
|
public DialogueLine DialogueLine
|
|
{
|
|
get => dialogueLine;
|
|
set
|
|
{
|
|
if (value != dialogueLine)
|
|
{
|
|
dialogueLine = value;
|
|
UpdateText();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the label is currently typing itself out.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Sets the label's text from the current dialogue line. Override if you want
|
|
/// to do something more interesting in your subclass.
|
|
/// </summary>
|
|
protected virtual void UpdateText()
|
|
{
|
|
if (IsInstanceValid(dialogueLine))
|
|
{
|
|
Text = dialogueLine.Text;
|
|
}
|
|
else
|
|
{
|
|
Text = "";
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Start typing out the text.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Stop typing out the text and jump right to the end.
|
|
/// </summary>
|
|
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]);
|
|
}
|
|
}
|
|
}
|