You won’t be able to use a RichTextEffect
for this. It’s possible but I would not recommend it.
I did answer a similar question here Strip BBCode from an array and detect characters - #3 by mrcdk
I’ve modified the code to add a [speed=<speed>]
“tag”. It’s not actually a bbcode tag, it gets preprocessed and stripped out before assigning the final text to RichTextLabel.text
extends Node
const TAG_REGEX = "\\[(?<tag>.*?)\\]"
## The speed at which the text animation will play
@export var playback_speed = 1.5
## The speead at which each character will appear
@export var char_speed = 0.08
## The long stop delay
@export var long_stop_delay = 0.4
## The long stop characters
@export var long_stop_chars = [".", "?", "!"]
## The short stop delay
@export var short_stop_delay = 0.2
## The short stop characters
@export var short_stop_chars = [",", ";", ":"]
@onready var rich_text_label: RichTextLabel = $RichTextLabel
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var audio_stream_player: AudioStreamPlayer = $AudioStreamPlayer
# The tag regex object
var tag_regex = RegEx.create_from_string(TAG_REGEX)
func _ready() -> void:
rich_text_label.bbcode_enabled = true
rich_text_label.visible_characters_behavior = TextServer.VC_CHARS_AFTER_SHAPING
display("[b][wave]Hello![/wave][/b] How are you? I'm... [speed=0.5]...fine[speed=1.0],[speed=4.0][font_size=20] I guess.[/font_size]\n[speed=1.0]I can play sounds!!! [rainbow]YAY![/rainbow]")
await animation_player.animation_finished
await get_tree().create_timer(2).timeout
display("You are a... [speed=0.2][b]What[/b][speed=1.0] are you???")
func display(text:String) -> void:
# Stop the current animation
animation_player.stop()
# The text with no bbcode tags, useful to get the char index where the speed need to change
var text_with_no_bbcode = ""
# The final text with the [speed] tag stripped
var final_text = ""
# A dictionary of char index and speed
var speeds = {
0: 1.0
}
# Get all the matches in the text
var matches = tag_regex.search_all(text)
var last = 0
var last_speed = 0
# For each match
for _match in matches:
# Get the start and end indices of the match
var start = _match.get_start(0)
var end = _match.get_end(0)
# Get the tag group and parse it with the built-in method
var tag = _match.get_string("tag")
var expr = rich_text_label.parse_expressions_for_values([tag])
var has_speed = expr.has("speed")
# Concat the left side of the tag in the text_with_no_bbcode
text_with_no_bbcode += text.substr(last, start - last)
# If it has speed
if has_speed:
# Concat the left side of the tag in the final_text
final_text += text.substr(last_speed, start - last_speed)
# Add a new char index => speed into the speeds dictionary
speeds[text_with_no_bbcode.length()] = expr.get("speed")
last_speed = end
last = end
# Concat the left over text into final_text
final_text += text.substr(last_speed, text.length() - last_speed)
# Reset the visible characters to 0 and set the text to be displayed
rich_text_label.visible_characters = 0
rich_text_label.text = final_text
# Generate the animation
var animation = _generate_animation(rich_text_label.get_parsed_text(), speeds)
# If the animation player does not have a global animation library, create one
if not animation_player.has_animation_library(""):
animation_player.add_animation_library("", AnimationLibrary.new())
var library = animation_player.get_animation_library("")
# remove the old animation if it exists
if library.has_animation("play_text"):
library.remove_animation("play_text")
# Add the new animation
library.add_animation("play_text", animation)
# Set the speed of the animation
animation_player.speed_scale = playback_speed
animation_player.play("play_text")
func play_sound(idx:int, char:String):
audio_stream_player.play()
func change_animation_speed(speed:float) -> void:
animation_player.speed_scale = playback_speed * speed
func _generate_animation(text:String, speeds:Dictionary) -> Animation:
# Create a new animation with 3 tracks, one for the visible_characters, one for the play_sound(),
# and one for the change_animation_speed()
var animation = Animation.new()
var track_index = animation.add_track(Animation.TYPE_VALUE)
var sound_index = animation.add_track(Animation.TYPE_METHOD)
var speed_index = animation.add_track(Animation.TYPE_METHOD)
animation.track_set_path(track_index, "%s:visible_characters" % get_path_to(rich_text_label))
animation.track_set_path(sound_index, get_path_to(self))
animation.track_set_path(speed_index, get_path_to(self))
# For each character check if it should skip the sound and add a delay
# if the current character is inside one of the short/long characters array and if the next is a space
var time = 0.0
for i in text.length():
var current_char = text[i]
var next_char = null
if i < text.length() - 1:
next_char = text[i+1]
var skip_sound = false
if current_char == " ":
skip_sound = true
var delay = char_speed
if next_char and next_char == " ":
if current_char in short_stop_chars:
delay = short_stop_delay
skip_sound = true
elif current_char in long_stop_chars:
delay = long_stop_delay
skip_sound = true
animation.track_insert_key(track_index, time, i+1)
if not skip_sound:
animation.track_insert_key(sound_index, time, {"method": "play_sound", "args": [i, current_char]})
if speeds.has(i):
animation.track_insert_key(speed_index, time, {"method": "change_animation_speed", "args": [speeds.get(i, 1.0)]})
time += delay
# Set the final time to the animation
animation.length = time
return animation
Result:
Edit: for some reason the video does not have any sound. I’ve uploaded it here with sound Watch video-2025-03-13_18.40.58 | Streamable