Text Speed Control with BBCode

Godot Version

4.3

Question

I’m trying to control the speed of my text build up.
For that, i created a custom effect.
Here the Script:

extends RichTextEffect

class_name Labelspeed

var bbcode = "speed"

var default_speed = 0.05
var current_speed = default_speed

func _process_custom_fx(char_fx):
	if char_fx.visible_characters >= char_fx.absolute_index:
		if "speed" in char_fx.env:
			current_speed = float(char_fx.env["speed"])
		else:
			current_speed = default_speed
	return true

func get_current_speed():
	return current_speed

In my script for Label control is a variable named text_speed i want to change.
Here the script:

extends RichTextLabel

@onready var audio_player = $"../../../../LetterAudio"
@onready var timer = $"../../../../Timer"
@onready var active_label = self

var speed_effect: Labelspeed = null
var text_speed = 0.05
var text_speed_boost = 1.0

func _ready() -> void:
	set_process(false)

	for effect in get("custom_effects"):
		if effect is Labelspeed:
			speed_effect = effect # Here i'm getting the custom effect

func _process(_delta):
	animation_speed_control()

func animation_speed_control():
	if Input.is_action_pressed("ActionButton"):
		text_speed_boost = clamp((text_speed_boost - 0.05), 0.1, 1.0)
	else:
		text_speed_boost = 1.0
	timer.wait_time = text_speed * text_speed_boost

func start_text_build_up(Text: String):
	active_label.visible_characters = 0
	active_label.text = Text
	timer.start()
	set_process(true)

func _on_timer_timeout() -> void:
	var text_length = active_label.text.length() 
	var text_visible_characters = active_label.visible_characters
	if text_length > text_visible_characters:
		
		if speed_effect:
			text_speed = speed_effect.get_current_speed()
			print("speed: ", text_speed) # Here is where i try to change the text_speed variable
		
		active_label.visible_characters = active_label.visible_characters + 1
		audio_player.pitch_scale = randf_range(0.9, 1.1)
		audio_player.play()
	else:
		timer.stop()
		finish_text_build_up()

The variable text_speed doesnt change and the tags([speed=0.2], [/speed]) are visible.

I’m very new to BBCode and don’t know what i’m doing wrong.
:expressionless:

1 Like

Make sure you checked “BBCode enabled” on your RichTextLabel. That should fix the bbcode showing on your game.

1 Like

I’m very interested to a solution using a custom BBCode effect, since I have only seen this solved with Tweens before. It seems like using a custom effect is more complicated since by tagging it’s inherently confined to a substring. Do you plan to use multiple speed effects within one RichTextLabel or do you plan to wrap the whole label in the tag?

1 Like

I’m not sure if you can set a ‘default’ parameter in the form [bbcode=param] for custom bbcodes. The documentation doesn’t mention it. What you can do is to set ‘named’ parameters. E.g. [speed s=0.2] and then get it via char_fx.env:

char_fx.env.get("s", default_speed)

CharFXTransform does not define absolute_index and visible_characters. The former you can get with range.x, the latter you probably have to pass to the effect from the label.

As far as I can tell, your condition won’t work. The problem is that you are always setting the current_speed to default_speed as long as any character in speed is not visible. If they are all visible you always set it to speed, even when the currently last visible character is not within [speed]. This somewhat depend in which order _process_custom_fx is called. I assume it’s in character order, but I don’t know - it will lead to issues in any case.

What you should probably do instead is to check if the current character is the last visible one and do nothing when not:

if visible_characters == char_fx.range.x + 1:
  current_speed = char_fx.env.get("s", default_speed)

return true

Finally, a problem you might encounter is resetting the speed after the bbcode. The effect is only executed for the characters in the bbcode. You could (automatically) add additional bbcodes with the default speed:

[speed s=0.2]slow text[/speed] [speed]normal text[/speed]

That being said, bbcodes might not be the best solution for this. As an alternative you can e.g. make your own simple ‘codes’. All you have to do when parsing them is to store the indices and the specified speed. You can then use this to change the speed when visible_characters reaches a specific index.

2 Likes

BBCode was enabled :grin:

1 Like

I want to use it multiple times for some text’s.

After trying things out for a bit, I think I will drop the BBCode idea.
Maybe it’s easier to solve this via simple ‘codes,’ like you said, using
var search_pattern = RegEx.create_from_string(“%speed”).

Finding the %speed tags, getting their value and position (text length), and then sorting them out.

I will let you know if I get something to work.

1 Like

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

Okay, this brought me closer.

I don’t use RichTextEffect anymore, instead i made a sorting function.
This is the whole Script:

extends RichTextLabel

signal action_pressed

@onready var audio_player = $"../../../../LetterAudio"
@onready var animation_player = $"../../../../Textbox_AnimationPlayer" # only needed for textbox animations
@onready var timer = $"../../../../Timer"
@onready var active_label = self
var is_question = false

var speed_tag_dictionary = {} # A Dicnionary withe &Speed Tag positions and values
var sorted_speed_positions = [] # A sorted list of positions (key from the dictionary)
var next_speed_index = 0 # Index of the next entry we want to query

var text_speed = 0.05
var text_speed_boost = 1.0

func _ready() -> void:
	set_process(false)

func _process(_delta):
	animation_speed_control()

func animation_speed_control():
	if Input.is_action_pressed("ActionButton"):
		text_speed_boost = clamp((text_speed_boost - 0.05), 0.1, 1.0)
	else:
		text_speed_boost = 1.0
	timer.wait_time = text_speed * text_speed_boost

func set_speed_tags(Text: String) -> String:
	print("set_speed_tags called with text:", Text)
	var search_speed_tag = RegEx.create_from_string("%speed=(-?\\d+(\\.\\d+)?) ")
	var result = search_speed_tag.search_all(Text)
	var updated_text = Text
	speed_tag_dictionary.clear()  # clear Dictionary
	var position_shift = 0  # Correction for removed characters
	if result:
		for match in result:
			var match_text = match.get_string()  # Entire found string, e.g. "%speed=0.15"
			var speed_value = match.get_string(1).to_float()  # Extracted float value
			var tag_position = match.get_start() - position_shift  # Adjusted position in the cleaned text
			speed_tag_dictionary[tag_position] = speed_value  
			print("found: %speed=", speed_value, " at position: ", tag_position)
			var index = updated_text.find(match_text) # Remove only the first occurrence of match_text
			if index != -1:
				updated_text = updated_text.substr(0, index) + updated_text.substr(index + match_text.length())
			position_shift += match_text.length() # Update the offset to account for the shift in the text
	else:
		print("No matches found")
	print("Speed-Tag-Dictionary:", speed_tag_dictionary)
	return updated_text  # send cleared text

func set_up_question(Question_text: String):
	is_question = true
	active_label = $"../../../QandA_VBoxContainer/Question_Label"
	start_text_build_up(Question_text)

func start_text_build_up(Text: String):
	Text = set_speed_tags(Text)  # get cleared text
	sorted_speed_positions = speed_tag_dictionary.keys() # Create a sorted list of positions where %speed tags were located
	sorted_speed_positions.sort()  # Sort ascending
	next_speed_index = 0
	active_label.visible_characters = 0
	active_label.text = Text  # label gets cleared text
	animation_player.play("LED_Off")
	timer.start()
	set_process(true)

func _on_timer_timeout() -> void:
	var text_length = active_label.text.length() 
	active_label.visible_characters += 1 # add visibility
	var current_visible = active_label.visible_characters
	if next_speed_index < sorted_speed_positions.size() and current_visible >= sorted_speed_positions[next_speed_index] - 1: # Check for new %speed-value
		text_speed = speed_tag_dictionary[ sorted_speed_positions[next_speed_index] ]
		print("Text speed updated to: ", text_speed, " at position ", sorted_speed_positions[next_speed_index])
		next_speed_index += 1
	var current_char = active_label.text[current_visible - 1] # Soundeffekt
	if current_char != " ":
		audio_player.pitch_scale = randf_range(0.9, 1.1)
		audio_player.play()
	if current_visible >= text_length: # check if visibility is complete
		timer.stop()
		finish_text_build_up()

func finish_text_build_up():
	set_process(false)
	text_speed_boost = 1.0
	text_speed = 0.05
	speed_tag_dictionary.clear()
	sorted_speed_positions.clear()
	next_speed_index = 0
	
	if is_question == true:
		is_question = false
		animation_player.play("LED_Text_done")
		active_label = self
		$"../../..".emit_signal("question_text_signal")
	else:
		animation_player.play("LED_Text_done")
		await action_pressed
		animation_player.stop()
		$"../../Textbox_LED_Texture".texture.region = Rect2(0,0,18,8)
		active_label.visible_characters = 0
		$"../../..".Active_Event.emit_signal("TextBoxSignal")

At this point, it works… mostly.
-Can’t use regular tags like [wave] because they shifting the positions of my selfmade %speed tags.
-The speed boost for button pressing is messi too.
-Putting a speed% tag on the beginning of a text works delayed.

I’m trying to work that out and the putting a clean(mostly) script online.

Finaly it works :grin:

Here’s the Script:

extends RichTextLabel

@onready var audio_player = $"../../../../LetterAudio"
@onready var timer = $"../../../../Timer"
@onready var active_label = self
var is_question = false

var speed_tag_dictionary = {} # A Dicnionary with Speed Tag positions and values
var sorted_speed_positions = [] # A sorted list of positions (key from the dictionary)
var next_speed_index = 0 # Index of the next entry we want to query
var text_speed = 0.05 # controlled by %speed-tags
var text_speed_boost = 1.0 # controlled by input
var text_length # needed for "func _on_timer_timeout()" loops

func _ready() -> void:
	pass

func strip_bbcode(Text: String) -> String:
	var search_bbcode = RegEx.create_from_string("\\[.*?\\]")  # Find BBCode-Tags
	return search_bbcode.sub(Text, "", true)  # switch BBCode with clear String

func set_speed_tags(Text: String) -> String:
	print("set_speed_tags called with text:", Text)
	var text_without_bbcode = strip_bbcode(Text)  # remove BBCode
	var search_speed_tag = RegEx.create_from_string("%speed=(-?\\d+(\\.\\d+)?) ") # search for %speed tags
	var result = search_speed_tag.search_all(text_without_bbcode)
	var updated_text = text_without_bbcode
	speed_tag_dictionary.clear()
	var position_shift = 0
	
	if result: # get speed-tag position & value. then sort them out
		for match in result:
			var match_text = match.get_string()
			var speed_value = match.get_string(1).to_float()
			var tag_position = match.get_start() - position_shift
			if tag_position <= 1:
				text_speed = speed_value
				print("Applied initial speed directly:", text_speed)
				timer.wait_time = text_speed
			else:
				speed_tag_dictionary[tag_position] = speed_value  
				print("found: %speed=", speed_value, " at position: ", tag_position)
			var index = updated_text.find(match_text)
			if index != -1:
				updated_text = updated_text.substr(0, index) + updated_text.substr(index + match_text.length())
			position_shift += match_text.length()
	else:
		print("No matches found")
	print("Speed-tag-dictionary:", speed_tag_dictionary)
	text_length = updated_text.length() # text length without BBCode and %speed
	var final_text = Text
	for match in result:
		var match_text = match.get_string()
		final_text = final_text.replace(match_text, "") # remove %speed-tags only
	return final_text  # Text with BBCode, but without %speed

func start_text_build_up(Text: String):
	var cleaned_text = set_speed_tags(Text)  # gets text without %speed-Tags, but with BBCode
	sorted_speed_positions = speed_tag_dictionary.keys()
	sorted_speed_positions.sort()
	next_speed_index = 0
	active_label.visible_characters = 0
	active_label.text = cleaned_text
	timer.start()

func _on_timer_timeout() -> void:
	var current_visible = active_label.visible_characters
	if next_speed_index < sorted_speed_positions.size(): # checking for new speed
		var adjusted_position = clamp(sorted_speed_positions[next_speed_index] - 2, 0, INF)
		if current_visible == adjusted_position:
			text_speed = speed_tag_dictionary[sorted_speed_positions[next_speed_index]]
			print("Text speed updated to: ", text_speed, " at position ", sorted_speed_positions[next_speed_index])
			next_speed_index += 1
	if Input.is_action_pressed("ActionButton"): # checking for speed-up input
		text_speed_boost = 0.2
	else:
		text_speed_boost = 1
	timer.wait_time = text_speed * text_speed_boost
	active_label.visible_characters += 1
	var current_char = active_label.text[current_visible] #no soundeffect for empty space
	if current_char != " ":
		audio_player.pitch_scale = randf_range(0.9, 1.1)
		audio_player.play()
	if active_label.visible_characters >= text_length: # checking if text is complete
		timer.stop()
		finish_text_build_up()

func finish_text_build_up():
	text_speed_boost = 1.0
	text_speed = 0.05
	speed_tag_dictionary.clear()
	sorted_speed_positions.clear()
	next_speed_index = 0
	timer.wait_time = text_speed * text_speed_boost
	active_label.visible_characters = 0
	# end of the road :) 

You can change the text speed by setting a %speed=0.05 tag
like this:

“text with normal speed, %speed=0.20 text with slow speed %speed=0.50 text with very slow speed”

Just send a String like this to the “start_text_build_up(Text: String)” function.
It works with regular BBCode and you can fasten it by pressing an input.

The result:

Hope it helped someone :grinning:

1 Like