Text Speed Control with BBCode

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

1 Like