You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

580 lines
20 KiB

6 months ago
extends DialogicSubsystem
## Subsystem that handles showing of dialog text (+text effects & modifiers), name label, and next indicator
#region SIGNALS
signal about_to_show_text(info:Dictionary)
signal text_finished(info:Dictionary)
signal speaker_updated(character:DialogicCharacter)
signal textbox_visibility_changed(visible:bool)
signal animation_textbox_new_text
signal animation_textbox_show
signal animation_textbox_hide
# forwards of the dialog_text signals of all present dialog_text nodes
signal meta_hover_ended(meta:Variant)
signal meta_hover_started(meta:Variant)
signal meta_clicked(meta:Variant)
#endregion
# used to color names without searching for all characters each time
var character_colors := {}
var color_regex := RegEx.new()
var text_already_read := false
var text_effects := {}
var parsed_text_effect_info: Array[Dictionary] = []
var text_effects_regex := RegEx.new()
enum TextModifierModes {ALL=-1, TEXT_ONLY=0, CHOICES_ONLY=1}
enum TextTypes {DIALOG_TEXT, CHOICE_TEXT}
var text_modifiers := []
## set by the [speed] effect, multies the letter speed and [pause] effects
var _speed_multiplier := 1.0
## stores the pure letter speed (unmultiplied)
var _pure_letter_speed := 0.1
var _letter_speed_absolute := false
var _voice_synced_text := false
var _autopauses := {}
#region STATE
####################################################################################################
func clear_game_state(clear_flag:=DialogicGameHandler.ClearFlags.FULL_CLEAR) -> void:
update_dialog_text('', true)
update_name_label(null)
dialogic.current_state_info['speaker'] = ""
dialogic.current_state_info['text'] = ''
set_text_reveal_skippable(ProjectSettings.get_setting('dialogic/text/initial_text_reveal_skippable', true))
# TODO check whether this can happen on the node directly
for text_node in get_tree().get_nodes_in_group('dialogic_dialog_text'):
if text_node.start_hidden:
text_node.textbox_root.hide()
func load_game_state(load_flag:=LoadFlags.FULL_LOAD) -> void:
update_textbox(dialogic.current_state_info.get('text', ''), true)
update_dialog_text(dialogic.current_state_info.get('text', ''), true)
var character: DialogicCharacter = null
if dialogic.current_state_info.get('speaker', ""):
character = load(dialogic.current_state_info.get('speaker', ""))
if character:
update_name_label(character)
func post_install():
dialogic.Settings.connect_to_change('text_speed', _update_user_speed)
collect_character_names()
collect_text_effects()
collect_text_modifiers()
#endregion
#region MAIN METHODS
####################################################################################################
## Applies modifiers, effects and coloring to the text
func parse_text(text:String, type:int=TextTypes.DIALOG_TEXT, variables := true, glossary := true, modifiers:= true, effects:= true, color_names:= true) -> String:
if variables and dialogic.has_subsystem('VAR'):
text = dialogic.VAR.parse_variables(text)
if modifiers:
text = parse_text_modifiers(text, type)
if effects:
text = parse_text_effects(text)
if color_names:
text = color_names(text)
if glossary and dialogic.has_subsystem('Glossary'):
text = dialogic.Glossary.parse_glossary(text)
return text
## When an event updates the text spoken, this can adjust the state of
## the dialog text box.
## This method is async.
func update_textbox(text: String, instant := false) -> void:
if text.is_empty():
await hide_textbox(instant)
else:
await show_textbox(instant)
if !dialogic.current_state_info['text'].is_empty():
animation_textbox_new_text.emit()
if dialogic.Animations.is_animating():
await dialogic.Animations.finished
## Shows the given text on all visible DialogText nodes.
## Instant can be used to skip all revieling.
## If additional is true, the previous text will be kept.
func update_dialog_text(text: String, instant := false, additional := false) -> String:
update_text_speed()
if !instant: dialogic.current_state = dialogic.States.REVEALING_TEXT
if additional:
dialogic.current_state_info['text'] += text
else:
dialogic.current_state_info['text'] = text
for text_node in get_tree().get_nodes_in_group('dialogic_dialog_text'):
connect_meta_signals(text_node)
if text_node.enabled and (text_node == text_node.textbox_root or text_node.textbox_root.is_visible_in_tree()):
if instant:
text_node.text = text
else:
text_node.reveal_text(text, additional)
if !text_node.finished_revealing_text.is_connected(_on_dialog_text_finished):
text_node.finished_revealing_text.connect(_on_dialog_text_finished)
dialogic.current_state_info['text_parsed'] = (text_node as RichTextLabel).get_parsed_text()
# Reset speed multiplier
update_text_speed(-1, false, 1)
# Reset Auto-Advance temporarily and the No-Skip setting:
dialogic.Inputs.auto_advance.enabled_until_next_event = false
dialogic.Inputs.auto_advance.override_delay_for_current_event = -1
dialogic.Inputs.manual_advance.disabled_until_next_event = false
set_text_reveal_skippable(true, true)
return text
func _on_dialog_text_finished() -> void:
text_finished.emit({'text':dialogic.current_state_info['text'], 'character':dialogic.current_state_info['speaker']})
## Updates the visible name on all name labels nodes.
## If a name changes, the [signal speaker_updated] signal is emitted.
func update_name_label(character:DialogicCharacter):
var character_path := character.resource_path if character else ""
var current_character_path: String = dialogic.current_state_info.get("speaker", "")
if character_path != current_character_path:
dialogic.current_state_info['speaker'] = character_path
speaker_updated.emit(character)
var name_label_text := get_character_name_parsed(character)
for name_label in get_tree().get_nodes_in_group('dialogic_name_label'):
name_label.text = name_label_text
if character:
if !'use_character_color' in name_label or name_label.use_character_color:
name_label.self_modulate = character.color
else:
name_label.self_modulate = Color(1,1,1,1)
func update_typing_sound_mood(mood:Dictionary = {}) -> void:
for typing_sound in get_tree().get_nodes_in_group('dialogic_type_sounds'):
typing_sound.load_overwrite(mood)
## instant skips the signal and thus possible animations
func show_textbox(instant:=false) -> void:
var emitted := instant
for text_node in get_tree().get_nodes_in_group('dialogic_dialog_text'):
if not text_node.enabled:
continue
if !text_node.textbox_root.visible and !emitted:
animation_textbox_show.emit()
text_node.textbox_root.show()
if dialogic.Animations.is_animating():
await dialogic.Animations.finished
textbox_visibility_changed.emit(true)
emitted = true
else:
text_node.textbox_root.show()
## Instant skips the signal and thus possible animations
func hide_textbox(instant:=false) -> void:
dialogic.current_state_info['text'] = ''
var emitted := instant
for name_label in get_tree().get_nodes_in_group('dialogic_name_label'):
name_label.text = ""
if !emitted and !get_tree().get_nodes_in_group('dialogic_dialog_text').is_empty() and get_tree().get_nodes_in_group('dialogic_dialog_text')[0].textbox_root.visible:
animation_textbox_hide.emit()
if dialogic.Animations.is_animating():
await dialogic.Animations.finished
for text_node in get_tree().get_nodes_in_group('dialogic_dialog_text'):
if text_node.textbox_root.visible and !emitted:
textbox_visibility_changed.emit(false)
emitted = true
text_node.textbox_root.hide()
func is_textbox_visible() -> bool:
return get_tree().get_nodes_in_group('dialogic_dialog_text').any(func(x): return x.textbox_root.visible)
func show_next_indicators(question:=false, autoadvance:=false) -> void:
for next_indicator in get_tree().get_nodes_in_group('dialogic_next_indicator'):
if next_indicator.enabled:
if (question and 'show_on_questions' in next_indicator and next_indicator.show_on_questions) or \
(autoadvance and 'show_on_autoadvance' in next_indicator and next_indicator.show_on_autoadvance) or (!question and !autoadvance):
next_indicator.show()
else:
next_indicator.hide()
func hide_next_indicators(_fake_arg :Variant= null) -> void:
for next_indicator in get_tree().get_nodes_in_group('dialogic_next_indicator'):
next_indicator.hide()
## This method will sync the text speed to the voice audio clip length, if a
## voice is playing.
## For instance, if the voice is playing for four seconds, the text will finish
## revealing after this time.
## This feature ignores Auto-Pauses on letters. Pauses via BBCode will desync
## the reveal.
func set_text_voice_synced(enabled: bool = true) -> void:
_voice_synced_text = enabled
update_text_speed()
## Returns whether voice-synced text is enabled.
func is_text_voice_synced() -> bool:
return _voice_synced_text
## Sets how fast text will be revealed.
##
## [param absolute] will force test to display at the given speed, regardless
## of the user's text speed setting.
##
## [param _speed_multiplier] adjusts the speed of the text, if set to -1,
## the value won't be updated and the current value will persist.
##
## [param _user_speed] adjusts the speed of the text, if set to -1, the
## project setting 'text_speed' will be used.operator
func update_text_speed(letter_speed: float = -1,
absolute := false,
speed_multiplier := _speed_multiplier,
user_speed: float = dialogic.Settings.get_setting('text_speed', 1)) -> void:
if letter_speed == -1:
letter_speed = ProjectSettings.get_setting('dialogic/text/letter_speed', 0.01)
_pure_letter_speed = letter_speed
_letter_speed_absolute = absolute
_speed_multiplier = speed_multiplier
for text_node in get_tree().get_nodes_in_group('dialogic_dialog_text'):
if absolute:
text_node.set_speed(letter_speed)
else:
text_node.set_speed(letter_speed * _speed_multiplier * user_speed)
func set_text_reveal_skippable(skippable:= true, temp:=false) -> void:
if !dialogic.current_state_info.has('text_reveal_skippable'):
dialogic.current_state_info['text_reveal_skippable'] = {'enabled':false, 'temp_enabled':false}
if temp:
dialogic.current_state_info['text_reveal_skippable']['temp_enabled'] = skippable
else:
dialogic.current_state_info['text_reveal_skippable']['enabled'] = skippable
func is_text_reveal_skippable() -> bool:
return dialogic.current_state_info['text_reveal_skippable']['enabled'] and dialogic.current_state_info['text_reveal_skippable'].get('temp_enabled', true)
func skip_text_reveal() -> void:
for text_node in get_tree().get_nodes_in_group('dialogic_dialog_text'):
if text_node.is_visible_in_tree():
text_node.finish_text()
if dialogic.has_subsystem('Voice'):
dialogic.Voice.stop_audio()
#endregion
#region TEXT EFFECTS & MODIFIERS
####################################################################################################
func collect_text_effects() -> void:
var text_effect_names := ""
text_effects.clear()
for indexer in DialogicUtil.get_indexers(true):
for effect in indexer._get_text_effects():
text_effects[effect.command] = {}
if effect.has('subsystem') and effect.has('method'):
text_effects[effect.command]['callable'] = Callable(dialogic.get_subsystem(effect.subsystem), effect.method)
elif effect.has('node_path') and effect.has('method'):
text_effects[effect.command]['callable'] = Callable(get_node(effect.node_path), effect.method)
else:
continue
text_effect_names += effect.command +"|"
text_effects_regex.compile("(?<!\\\\)\\[\\s*(?<command>"+text_effect_names.trim_suffix("|")+")\\s*(=\\s*(?<value>.+?)\\s*)?\\]")
## Returns the string with all text effects removed
## Use get_parsed_text_effects() after calling this to get all effect information
func parse_text_effects(text:String) -> String:
parsed_text_effect_info.clear()
var rtl := RichTextLabel.new()
rtl.bbcode_enabled = true
var position_correction := 0
var bbcode_correction := 0
for effect_match in text_effects_regex.search_all(text):
rtl.text = text.substr(0, effect_match.get_start()-position_correction)
bbcode_correction = effect_match.get_start()-position_correction-len(rtl.get_parsed_text())
# append [index] = [command, value] to effects dict
parsed_text_effect_info.append({'index':effect_match.get_start()-position_correction-bbcode_correction, 'execution_info':text_effects[effect_match.get_string('command')], 'value': effect_match.get_string('value').strip_edges()})
text = text.substr(0,effect_match.get_start()-position_correction)+text.substr(effect_match.get_start()-position_correction+len(effect_match.get_string()))
position_correction += len(effect_match.get_string())
text = text.replace('\\[', '[')
rtl.queue_free()
return text
func execute_effects(current_index:int, text_node:Control, skipping := false) -> void:
# might have to execute multiple effects
while true:
if parsed_text_effect_info.is_empty():
return
if current_index != -1 and current_index < parsed_text_effect_info[0]['index']:
return
var effect: Dictionary = parsed_text_effect_info.pop_front()
await (effect['execution_info']['callable'] as Callable).call(text_node, skipping, effect['value'])
func collect_text_modifiers() -> void:
text_modifiers.clear()
for indexer in DialogicUtil.get_indexers(true):
for modifier in indexer._get_text_modifiers():
if modifier.has('subsystem') and modifier.has('method'):
text_modifiers.append({'method':Callable(dialogic.get_subsystem(modifier.subsystem), modifier.method)})
elif modifier.has('node_path') and modifier.has('method'):
text_modifiers.append({'method':Callable(get_node(modifier.node_path), modifier.method)})
text_modifiers[-1]['mode'] = modifier.get('mode', TextModifierModes.TEXT_ONLY)
func parse_text_modifiers(text:String, type:int=TextTypes.DIALOG_TEXT) -> String:
for mod in text_modifiers:
if mod.mode != TextModifierModes.ALL and type != -1 and type != mod.mode:
continue
text = mod.method.call(text)
return text
#endregion
#region HELPERS & OTHER STUFF
####################################################################################################
func _ready():
dialogic.event_handled.connect(hide_next_indicators)
_autopauses = {}
var autopause_data: Dictionary = ProjectSettings.get_setting('dialogic/text/autopauses', {})
for i in autopause_data.keys():
_autopauses[RegEx.create_from_string('(?<!(\\[|\\{))['+i+'](?!([\\w\\s]*!?[\\]\\}]|$))')] = autopause_data[i]
## Parses the character's display_name and returns the text that
## should be rendered. Note that characters may have variables in their
## name, therefore this function should be called to evaluate
## any potential variables in a character's name.
func get_character_name_parsed(character:DialogicCharacter) -> String:
if character:
var translated_display_name := character.get_display_name_translated()
if dialogic.has_subsystem('VAR'):
return dialogic.VAR.parse_variables(translated_display_name)
else:
return translated_display_name
return ""
## Returns the [class DialogicCharacter] of the current speaker.
## If there is no current speaker or the speaker is not found, returns null.
func get_current_speaker() -> DialogicCharacter:
var speaker_path: String = dialogic.current_state_info.get("speaker", "")
if speaker_path.is_empty():
return null
var speaker_resource := load(speaker_path)
if speaker_resource == null:
return null
var speaker_character := speaker_resource as DialogicCharacter
return speaker_character
func _update_user_speed(user_speed:float) -> void:
update_text_speed(_pure_letter_speed, _letter_speed_absolute)
func connect_meta_signals(text_node: Node) -> void:
if not text_node.meta_clicked.is_connected(emit_meta_signal):
text_node.meta_clicked.connect(emit_meta_signal.bind("meta_clicked"))
if not text_node.meta_hover_started.is_connected(emit_meta_signal):
text_node.meta_hover_started.connect(emit_meta_signal.bind("meta_hover_started"))
if not text_node.meta_hover_ended.is_connected(emit_meta_signal):
text_node.meta_hover_ended.connect(emit_meta_signal.bind("meta_hover_ended"))
func emit_meta_signal(meta:Variant, sig:String) -> void:
emit_signal(sig, meta)
#endregion
#region AUTOCOLOR NAMES
################################################################################
func color_names(text:String) -> String:
if !ProjectSettings.get_setting('dialogic/text/autocolor_names', false):
return text
var counter := 0
for result in color_regex.search_all(text):
text = text.insert(result.get_start("name")+((9+8+8)*counter), '[color=#' + character_colors[result.get_string('name')].to_html() + ']')
text = text.insert(result.get_end("name")+9+8+((9+8+8)*counter), '[/color]')
counter += 1
return text
func collect_character_names() -> void:
#don't do this at all if we're not using autocolor names to begin with
if !ProjectSettings.get_setting('dialogic/text/autocolor_names', false):
return
character_colors = {}
for dch_path in DialogicResourceUtil.list_resources_of_type('.dch'):
var character := (load(dch_path) as DialogicCharacter)
if character.display_name:
character_colors[character.display_name] = character.color
for nickname in character.get_nicknames_translated():
if nickname.strip_edges():
character_colors[nickname.strip_edges()] = character.color
if dialogic.has_subsystem('Glossary'):
dialogic.Glossary.color_overrides.merge(character_colors, true)
color_regex.compile('(?<=\\W|^)(?<name>'+str(character_colors.keys()).trim_prefix('["').trim_suffix('"]').replace('", "', '|')+')(?=\\W|$)')
#endregion
#region DEFAULT TEXT EFFECTS & MODIFIERS
################################################################################
func effect_pause(text_node:Control, skipped:bool, argument:String) -> void:
if skipped:
return
# We want to ignore pauses if we're skipping.
if dialogic.Inputs.auto_skip.enabled:
return
var text_speed: float = dialogic.Settings.get_setting('text_speed', 1)
if argument:
if argument.ends_with('!'):
await get_tree().create_timer(float(argument.trim_suffix('!'))).timeout
elif _speed_multiplier != 0 and dialogic.Settings.get_setting('text_speed', 1) != 0:
await get_tree().create_timer(float(argument) * _speed_multiplier * dialogic.Settings.get_setting('text_speed', 1)).timeout
elif _speed_multiplier != 0 and dialogic.Settings.get_setting('text_speed', 1) != 0:
await get_tree().create_timer(0.5 * _speed_multiplier*dialogic.Settings.get_setting('text_speed', 1)).timeout
func effect_speed(text_node:Control, skipped:bool, argument:String) -> void:
if skipped:
return
if argument:
update_text_speed(-1, false, float(argument))
else:
update_text_speed(-1, false, 1)
func effect_lspeed(text_node:Control, skipped:bool, argument:String) -> void:
if skipped:
return
if argument:
if argument.ends_with('!'):
update_text_speed(float(argument.trim_suffix('!')), true)
else:
update_text_speed(float(argument), false)
else:
update_text_speed()
func effect_signal(text_node:Control, skipped:bool, argument:String) -> void:
dialogic.text_signal.emit(argument)
func effect_mood(text_node:Control, skipped:bool, argument:String) -> void:
if argument.is_empty(): return
if dialogic.current_state_info.get('speaker', ""):
update_typing_sound_mood(
load(dialogic.current_state_info.speaker).custom_info.get('sound_moods', {}).get(argument, {}))
var modifier_words_select_regex := RegEx.create_from_string("(?<!\\\\)\\<[^\\[\\>]+(\\/[^\\>]*)\\>")
func modifier_random_selection(text:String) -> String:
for replace_mod_match in modifier_words_select_regex.search_all(text):
var string: String= replace_mod_match.get_string().trim_prefix("<").trim_suffix(">")
string = string.replace('//', '<slash>')
var list: PackedStringArray= string.split('/')
var item: String= list[randi()%len(list)]
item = item.replace('<slash>', '/')
text = text.replace(replace_mod_match.get_string(), item.strip_edges())
return text
func modifier_break(text:String) -> String:
return text.replace('[br]', '\n')
func modifier_autopauses(text:String) -> String:
var absolute: bool = ProjectSettings.get_setting('dialogic/text/absolute_autopauses', false)
for i in _autopauses.keys():
var offset := 0
for result in i.search_all(text):
if absolute:
text = text.insert(result.get_end()+offset, '[pause='+str(_autopauses[i])+'!]')
offset += len('[pause='+str(_autopauses[i])+'!]')
else:
text = text.insert(result.get_end()+offset, '[pause='+str(_autopauses[i])+']')
offset += len('[pause='+str(_autopauses[i])+']')
return text
#endregion