Added dialogue manager plugin

This commit is contained in:
Matthew Barbour
2026-08-30 01:15:04 -05:00
parent e90ea3a0a4
commit f983ab64ce
186 changed files with 20125 additions and 0 deletions
@@ -0,0 +1,356 @@
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]);
}
}
}
@@ -0,0 +1 @@
uid://b8beyexrpj6n7
@@ -0,0 +1,19 @@
[gd_scene format=3 uid="uid://c3qmxhslp0dl7"]
[ext_resource type="Script" uid="uid://b8beyexrpj6n7" path="res://addons/dialogue_manager/nodes/dialogue_label/DialogueLabel.cs" id="1_cital"]
[node name="DialogueLabel" type="RichTextLabel"]
anchors_preset = 10
anchor_right = 1.0
grow_horizontal = 2
mouse_filter = 1
bbcode_enabled = true
fit_content = true
scroll_active = false
shortcut_keys_enabled = false
meta_underlined = false
hint_underlined = false
deselect_on_focus_loss_enabled = false
visible_characters_behavior = 1
script = ExtResource("1_cital")
SkipPauseAtAbbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex")
@@ -0,0 +1,228 @@
@icon("uid://s0s1348tpm6d")
@tool
## A RichTextLabel specifically for use with [b]Dialogue Manager[/b] dialogue.
class_name DialogueLabel extends RichTextLabel
## Emitted for each letter typed out.
signal spoke(letter: String, letter_index: int, speed: float)
## Emitted when the player skips the typing of dialogue.
signal skipped_typing()
## Emitted when typing starts
signal started_typing()
## Emitted when typing finishes.
signal finished_typing()
## The action to press to skip typing.
@export var skip_action: StringName = &"ui_cancel"
## The speed with which the text types out.
@export var seconds_per_step: float = 0.02
## Automatically have a brief pause when these characters are encountered.
@export var pause_at_characters: String = ".?!"
## Don't auto pause if the character after the pause is one of these.
@export var skip_pause_at_character_if_followed_by: String = ")\""
## Don't auto pause after these abbreviations (only if "." is in `pause_at_characters`).[br]
## Abbreviations are limitted to 5 characters in length [br]
## Does not support multi-period abbreviations (ex. "p.m.")
@export var skip_pause_at_abbreviations: PackedStringArray = ["Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex"]
## The amount of time to pause when exposing a character present in `pause_at_characters`.
@export var seconds_per_pause_step: float = 0.3
var _already_mutated_indices: PackedInt32Array = []
## The current line of dialogue.
var dialogue_line: Variant:
set(value):
if value != dialogue_line:
dialogue_line = value
_update_text()
get:
return dialogue_line
## Whether the label is currently typing itself out.
var is_typing: bool = false:
set(value):
var did_just_finish: bool = _is_typing != value and value == false and visible_characters == get_total_character_count()
_is_typing = value
if did_just_finish:
finished_typing.emit()
get:
return _is_typing and not _is_awaiting_mutation
var _is_typing: bool = false
var _last_wait_index: int = -1
var _last_mutation_index: int = -1
var _waiting_seconds: float = 0
var _is_awaiting_mutation: bool = false
var _is_skipping_mutations: bool = false
func _process(delta: float) -> void:
if _is_typing:
# Type out text
if visible_ratio < 1:
# See if we are waiting
if _waiting_seconds > 0:
_waiting_seconds = _waiting_seconds - delta
# If we are no longer waiting then keep typing
if _waiting_seconds <= 0:
_type_next(delta, _waiting_seconds)
else:
# Make sure any mutations at the end of the line get run
_mutate_inline_mutations(get_total_character_count())
is_typing = false
## Sets the label's text from the current dialogue line. Override if you want
## to do something more interesting in your subclass.
func _update_text() -> void:
if is_instance_valid(dialogue_line):
text = dialogue_line.text
else:
text = ""
## Start typing out the text
func type_out() -> void:
_update_text()
visible_characters = 0
visible_ratio = 0
_waiting_seconds = 0
_last_wait_index = -1
_last_mutation_index = -1
_already_mutated_indices.clear()
is_typing = true
started_typing.emit()
# Allow typing listeners a chance to connect
await get_tree().process_frame
if get_total_character_count() == 0:
is_typing = false
elif seconds_per_step == 0:
_mutate_remaining_mutations()
visible_characters = get_total_character_count()
is_typing = false
## Stop typing out the text and jump right to the end
func skip_typing() -> void:
_mutate_remaining_mutations()
visible_characters = get_total_character_count()
is_typing = false
skipped_typing.emit()
# Type out the next character(s)
func _type_next(delta: float, seconds_needed: float) -> void:
if _is_awaiting_mutation: return
if visible_characters == get_total_character_count():
return
if _last_mutation_index != visible_characters:
_last_mutation_index = visible_characters
_mutate_inline_mutations(visible_characters)
if _is_awaiting_mutation: return
# Pause on characters like "."
var waiting_seconds: float = seconds_per_pause_step if _should_auto_pause() else 0.0
if _last_wait_index != visible_characters and waiting_seconds > 0:
_last_wait_index = visible_characters
_waiting_seconds += waiting_seconds
else:
visible_characters += 1
if visible_characters <= get_total_character_count():
spoke.emit(get_parsed_text()[visible_characters - 1], visible_characters - 1, _get_speed(visible_characters))
# See if there's time to type out some more in this frame
seconds_needed += seconds_per_step * (1.0 / _get_speed(visible_characters))
if seconds_needed > delta:
_waiting_seconds += seconds_needed
else:
_type_next(delta, seconds_needed)
# Get the speed for the current typing position
func _get_speed(at_index: int) -> float:
var speed: float = 1
for index: int in dialogue_line.speeds:
if index > at_index:
return speed
speed = dialogue_line.speeds[index]
return speed
# Run any inline mutations that haven't been run yet
func _mutate_remaining_mutations() -> void:
_is_skipping_mutations = true
for i: int in range(visible_characters, get_total_character_count() + 1):
_mutate_inline_mutations(i)
_is_skipping_mutations = false
# Run any mutations at the current typing position
func _mutate_inline_mutations(index: int) -> void:
for inline_mutation: Array in dialogue_line.inline_mutations:
# inline mutations are an array of arrays in the form of [character index, resolvable function]
if inline_mutation[0] > index:
return
if inline_mutation[0] == index and not _already_mutated_indices.has(index):
if _is_skipping_mutations:
Engine.get_singleton("DialogueManager")._mutate(inline_mutation[1], dialogue_line.extra_game_states, true)
else:
_is_awaiting_mutation = true
# The DialogueManager can't be referenced directly here so we need to get it by its path
await Engine.get_singleton("DialogueManager")._mutate(inline_mutation[1], dialogue_line.extra_game_states, true)
_is_awaiting_mutation = false
_already_mutated_indices.append(index)
# Determine if the current autopause character at the cursor should qualify to pause typing.
func _should_auto_pause() -> bool:
if visible_characters == 0: return false
var parsed_text: String = get_parsed_text()
# Avoid outofbounds 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 visible_characters >= parsed_text.length(): return false
# Ignore pause characters if they are next to a non-pause character
if parsed_text[visible_characters] in skip_pause_at_character_if_followed_by.split():
return false
# Ignore "." if it's between two numbers
if visible_characters > 3 and parsed_text[visible_characters - 1] == ".":
var possible_number: String = parsed_text.substr(visible_characters - 2, 3)
if str(float(possible_number)).pad_decimals(1) == possible_number:
return false
# Ignore "." if it's used in an abbreviation
# Note: does NOT support multi-period abbreviations (ex. p.m.)
if "." in pause_at_characters and parsed_text[visible_characters - 1] == ".":
for abbreviation: String in skip_pause_at_abbreviations:
if visible_characters >= abbreviation.length():
var previous_characters: String = parsed_text.substr(visible_characters - abbreviation.length() - 1, abbreviation.length())
if previous_characters == abbreviation:
return false
# Ignore two non-"." characters next to each other
var other_pause_characters: PackedStringArray = pause_at_characters.replace(".", "").split()
if visible_characters > 1 and parsed_text[visible_characters - 1] in other_pause_characters and parsed_text[visible_characters] in other_pause_characters:
return false
return parsed_text[visible_characters - 1] in pause_at_characters.split()
@@ -0,0 +1 @@
uid://g32um0mltv5d
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://s0s1348tpm6d"
path="res://.godot/imported/dialogue_label.svg-d4dcce28fb3dfdb414d1a2c4579fb6a5.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/nodes/dialogue_label/dialogue_label.svg"
dest_files=["res://.godot/imported/dialogue_label.svg-d4dcce28fb3dfdb414d1a2c4579fb6a5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,19 @@
[gd_scene format=3 uid="uid://ckvgyvclnwggo"]
[ext_resource type="Script" uid="uid://g32um0mltv5d" path="res://addons/dialogue_manager/nodes/dialogue_label/dialogue_label.gd" id="1_cital"]
[node name="DialogueLabel" type="RichTextLabel" unique_id=1840273907]
anchors_preset = 10
anchor_right = 1.0
grow_horizontal = 2
mouse_filter = 1
bbcode_enabled = true
fit_content = true
scroll_active = false
shortcut_keys_enabled = false
meta_underlined = false
hint_underlined = false
deselect_on_focus_loss_enabled = false
visible_characters_behavior = 1
script = ExtResource("1_cital")
skip_pause_at_abbreviations = PackedStringArray("Mr", "Mrs", "Ms", "Dr", "etc", "eg", "ex")