]> Untitled Git - wolf-seeking-sheep.git/blob - addons/dialogic/Modules/Text/event_text.gd
Squashed commit of the following:
[wolf-seeking-sheep.git] / addons / dialogic / Modules / Text / event_text.gd
1 @tool
2 class_name DialogicTextEvent
3 extends DialogicEvent
4
5 ## Event that stores text. Can be said by a character.
6 ## Should be shown by a DialogicNode_DialogText.
7
8
9 ### Settings
10
11 ## This is the content of the text event.
12 ## It is supposed to be displayed by a DialogicNode_DialogText node.
13 ## That means you can use bbcode, but also some custom commands.
14 var text := ""
15 ## If this is not null, the given character (as a resource) will be associated with this event.
16 ## The DialogicNode_NameLabel will show the characters display_name. If a typing sound is setup,
17 ## it will play.
18 var character: DialogicCharacter = null
19 ## If a character is set, this setting can change the portrait of that character.
20 var portrait := ""
21
22 ### Helpers
23
24 ## Used to set the character resource from the unique name identifier and vice versa
25 var character_identifier: String:
26         get:
27                 if character:
28                         var identifier := DialogicResourceUtil.get_unique_identifier(character.resource_path)
29                         if not identifier.is_empty():
30                                 return identifier
31                 return character_identifier
32         set(value):
33                 character_identifier = value
34                 character = DialogicResourceUtil.get_character_resource(value)
35                 if not character.portraits.has(portrait):
36                         portrait = ""
37                         ui_update_needed.emit()
38
39 var regex := RegEx.create_from_string(r'\s*((")?(?<name>(?(2)[^"\n]*|[^(: \n]*))(?(2)"|)(\W*(?<portrait>\(.*\)))?\s*(?<!\\):)?(?<text>(.|\n)*)')
40 var split_regex := RegEx.create_from_string(r"((\[n\]|\[n\+\])?((?!(\[n\]|\[n\+\]))(.|\n))+)")
41
42 enum States {REVEALING, IDLE, DONE}
43 var state := States.IDLE
44 signal advance
45
46
47 #region EXECUTION
48 ################################################################################
49
50 func _clear_state() -> void:
51         dialogic.current_state_info.erase('text_sub_idx')
52         _disconnect_signals()
53
54 func _execute() -> void:
55         if text.is_empty():
56                 finish()
57                 return
58
59         if (not character or character.custom_info.get('style', '').is_empty()) and dialogic.has_subsystem('Styles'):
60                 # if previous characters had a custom style change back to base style
61                 if dialogic.current_state_info.get('base_style') != dialogic.current_state_info.get('style'):
62                         dialogic.Styles.change_style(dialogic.current_state_info.get('base_style', 'Default'))
63                         await dialogic.get_tree().process_frame
64
65         var character_name_text := dialogic.Text.get_character_name_parsed(character)
66         if character:
67                 dialogic.current_state_info['speaker'] = character.resource_path
68                 if dialogic.has_subsystem('Styles') and character.custom_info.get('style', null):
69                         dialogic.Styles.change_style(character.custom_info.style, false)
70                         await dialogic.get_tree().process_frame
71
72
73                 if portrait and dialogic.has_subsystem('Portraits') and dialogic.Portraits.is_character_joined(character):
74                         dialogic.Portraits.change_character_portrait(character, portrait)
75                 dialogic.Portraits.change_speaker(character, portrait)
76                 var check_portrait: String = portrait if !portrait.is_empty() else dialogic.current_state_info['portraits'].get(character.resource_path, {}).get('portrait', '')
77
78                 if check_portrait and character.portraits.get(check_portrait, {}).get('sound_mood', '') in character.custom_info.get('sound_moods', {}):
79                         dialogic.Text.update_typing_sound_mood(character.custom_info.get('sound_moods', {}).get(character.portraits[check_portrait].get('sound_mood', {}), {}))
80                 elif !character.custom_info.get('sound_mood_default', '').is_empty():
81                         dialogic.Text.update_typing_sound_mood(character.custom_info.get('sound_moods', {}).get(character.custom_info.get('sound_mood_default'), {}))
82                 else:
83                         dialogic.Text.update_typing_sound_mood()
84
85                 dialogic.Text.update_name_label(character)
86         else:
87                 dialogic.Portraits.change_speaker(null)
88                 dialogic.Text.update_name_label(null)
89                 dialogic.Text.update_typing_sound_mood()
90
91         _connect_signals()
92
93         var final_text: String = get_property_translated('text')
94         if ProjectSettings.get_setting('dialogic/text/split_at_new_lines', false):
95                 match ProjectSettings.get_setting('dialogic/text/split_at_new_lines_as', 0):
96                         0:
97                                 final_text = final_text.replace('\n', '[n]')
98                         1:
99                                 final_text = final_text.replace('\n', '[n+][br]')
100
101         var split_text := []
102         for i in split_regex.search_all(final_text):
103                 split_text.append([i.get_string().trim_prefix('[n]').trim_prefix('[n+]')])
104                 split_text[-1].append(i.get_string().begins_with('[n+]'))
105
106         dialogic.current_state_info['text_sub_idx'] = dialogic.current_state_info.get('text_sub_idx', -1)
107
108         var reveal_next_segment: bool = dialogic.current_state_info['text_sub_idx'] == -1
109
110         for section_idx in range(min(max(0, dialogic.current_state_info['text_sub_idx']), len(split_text)-1), len(split_text)):
111                 dialogic.Inputs.block_input(ProjectSettings.get_setting('dialogic/text/text_reveal_skip_delay', 0.1))
112
113                 if reveal_next_segment:
114                         dialogic.Text.hide_next_indicators()
115
116                         dialogic.current_state_info['text_sub_idx'] = section_idx
117
118                         var segment: String = dialogic.Text.parse_text(split_text[section_idx][0])
119                         var is_append: bool = split_text[section_idx][1]
120
121                         final_text = segment
122                         dialogic.Text.about_to_show_text.emit({'text':final_text, 'character':character, 'portrait':portrait, 'append': is_append})
123
124                         await dialogic.Text.update_textbox(final_text, false)
125
126                         state = States.REVEALING
127                         _try_play_current_line_voice()
128                         final_text = dialogic.Text.update_dialog_text(final_text, false, is_append)
129
130                         _mark_as_read(character_name_text, final_text)
131
132                         # We must skip text animation before we potentially return when there
133                         # is a Choice event.
134                         if dialogic.Inputs.auto_skip.enabled:
135                                 dialogic.Text.skip_text_reveal()
136                         else:
137                                 await dialogic.Text.text_finished
138
139                         state = States.IDLE
140                 else:
141                         reveal_next_segment = true
142
143                 # Handling potential Choice Events.
144                 if section_idx == len(split_text)-1 and dialogic.has_subsystem('Choices') and dialogic.Choices.is_question(dialogic.current_event_idx):
145                         dialogic.Text.show_next_indicators(true)
146
147                         finish()
148                         return
149
150                 elif dialogic.Inputs.auto_advance.is_enabled():
151                         dialogic.Text.show_next_indicators(false, true)
152                         dialogic.Inputs.auto_advance.start()
153                 else:
154                         dialogic.Text.show_next_indicators()
155
156                 if section_idx == len(split_text)-1:
157                         state = States.DONE
158
159                 # If Auto-Skip is enabled and there are multiple parts of this text
160                 # we need to skip the text after the defined time per event.
161                 if dialogic.Inputs.auto_skip.enabled:
162                         await dialogic.Inputs.start_autoskip_timer()
163
164                         # Check if Auto-Skip is still enabled.
165                         if not dialogic.Inputs.auto_skip.enabled:
166                                 await advance
167
168                 else:
169                         await advance
170
171
172         finish()
173
174
175 func _mark_as_read(character_name_text: String, final_text: String) -> void:
176         if dialogic.has_subsystem('History'):
177                 if character:
178                         dialogic.History.store_simple_history_entry(final_text, event_name, {'character':character_name_text, 'character_color':character.color})
179                 else:
180                         dialogic.History.store_simple_history_entry(final_text, event_name)
181                 dialogic.History.mark_event_as_visited()
182
183
184 func _connect_signals() -> void:
185         if not dialogic.Inputs.dialogic_action.is_connected(_on_dialogic_input_action):
186                 dialogic.Inputs.dialogic_action.connect(_on_dialogic_input_action)
187
188                 dialogic.Inputs.auto_skip.toggled.connect(_on_auto_skip_enable)
189
190         if not dialogic.Inputs.auto_advance.autoadvance.is_connected(_on_dialogic_input_autoadvance):
191                 dialogic.Inputs.auto_advance.autoadvance.connect(_on_dialogic_input_autoadvance)
192
193
194 ## If the event is done, this method can clean-up signal connections.
195 func _disconnect_signals() -> void:
196         if dialogic.Inputs.dialogic_action.is_connected(_on_dialogic_input_action):
197                 dialogic.Inputs.dialogic_action.disconnect(_on_dialogic_input_action)
198         if dialogic.Inputs.auto_advance.autoadvance.is_connected(_on_dialogic_input_autoadvance):
199                 dialogic.Inputs.auto_advance.autoadvance.disconnect(_on_dialogic_input_autoadvance)
200         if dialogic.Inputs.auto_skip.toggled.is_connected(_on_auto_skip_enable):
201                 dialogic.Inputs.auto_skip.toggled.disconnect(_on_auto_skip_enable)
202
203
204 ## Tries to play the voice clip for the current line.
205 func _try_play_current_line_voice() -> void:
206         # If Auto-Skip is enabled and we skip voice clips, we don't want to play.
207         if (dialogic.Inputs.auto_skip.enabled
208         and dialogic.Inputs.auto_skip.skip_voice):
209                 return
210
211         # Plays the audio region for the current line.
212         if (dialogic.has_subsystem('Voice')
213         and dialogic.Voice.is_voiced(dialogic.current_event_idx)):
214                 dialogic.Voice.play_voice()
215
216
217 func _on_dialogic_input_action() -> void:
218         match state:
219                 States.REVEALING:
220                         if dialogic.Text.is_text_reveal_skippable():
221                                 dialogic.Text.skip_text_reveal()
222                                 dialogic.Inputs.stop_timers()
223                 _:
224                         if dialogic.Inputs.manual_advance.is_enabled():
225                                 advance.emit()
226                                 dialogic.Inputs.stop_timers()
227
228
229 func _on_dialogic_input_autoadvance() -> void:
230         if state == States.IDLE or state == States.DONE:
231                 advance.emit()
232
233
234 func _on_auto_skip_enable(enabled: bool) -> void:
235         if not enabled:
236                 return
237
238         match state:
239                 States.DONE:
240                         await dialogic.Inputs.start_autoskip_timer()
241
242                         # If Auto-Skip is still enabled, advance the text.
243                         if dialogic.Inputs.auto_skip.enabled:
244                                 advance.emit()
245
246                 States.REVEALING:
247                         dialogic.Text.skip_text_reveal()
248
249 #endregion
250
251
252 #region INITIALIZE
253 ################################################################################
254
255 func _init() -> void:
256         event_name = "Text"
257         set_default_color('Color1')
258         event_category = "Main"
259         event_sorting_index = 0
260         expand_by_default = true
261         help_page_path = "https://docs.dialogic.pro/writing-text-events.html"
262
263
264
265 ################################################################################
266 ##                                              SAVING/LOADING
267 ################################################################################
268
269 func to_text() -> String:
270         var result := text.replace('\n', '\\\n')
271         result = result.replace(':', '\\:')
272         if result.is_empty():
273                 result = "<Empty Text Event>"
274
275         if character:
276                 var name := DialogicResourceUtil.get_unique_identifier(character.resource_path)
277                 if name.count(" ") > 0:
278                         name = '"' + name + '"'
279                 if not portrait.is_empty():
280                         result =  name+" ("+portrait+"): "+result
281                 else:
282                         result = name+": "+result
283         for event in DialogicResourceUtil.get_event_cache():
284                 if not event is DialogicTextEvent and event.is_valid_event(result):
285                         result = '\\'+result
286                         break
287
288         return result
289
290
291 func from_text(string:String) -> void:
292         # Load default character
293         # This is only of relevance if the default has been overriden (usually not)
294         character = DialogicResourceUtil.get_character_resource(character_identifier)
295
296         var result := regex.search(string.trim_prefix('\\'))
297         if result and not result.get_string('name').is_empty():
298                 var name := result.get_string('name').strip_edges()
299
300                 if name == '_':
301                         character = null
302                 else:
303                         character = DialogicResourceUtil.get_character_resource(name)
304
305                         if character == null and Engine.is_editor_hint() == false:
306                                 character = DialogicCharacter.new()
307                                 character.display_name = name
308                                 character.resource_path = "user://"+name+".dch"
309                                 DialogicResourceUtil.add_resource_to_directory(character.resource_path, DialogicResourceUtil.get_character_directory())
310
311         if !result.get_string('portrait').is_empty():
312                 portrait = result.get_string('portrait').strip_edges().trim_prefix('(').trim_suffix(')')
313
314         if result:
315                 text = result.get_string('text').replace("\\\n", "\n").replace('\\:', ':').strip_edges().trim_prefix('\\')
316                 if text == '<Empty Text Event>':
317                         text = ""
318
319
320 func is_valid_event(_string:String) -> bool:
321         return true
322
323
324 func is_string_full_event(string:String) -> bool:
325         return !string.ends_with('\\')
326
327
328 # this is only here to provide a list of default values
329 # this way the module manager can add custom default overrides to this event.
330 func get_shortcode_parameters() -> Dictionary:
331         return {
332                 #param_name     : property_info
333                 "character"             : {"property": "character_identifier", "default": ""},
334                 "portrait"              : {"property": "portrait",                                      "default": ""},
335         }
336 #endregion
337
338
339 #region TRANSLATIONS
340 ################################################################################
341
342 func _get_translatable_properties() -> Array:
343         return ['text']
344
345
346 func _get_property_original_translation(property:String) -> String:
347         match property:
348                 'text':
349                         return text
350         return ''
351
352
353 #endregion
354
355
356 #region EVENT EDITOR
357 ################################################################################
358
359 func _enter_visual_editor(editor:DialogicEditor):
360         editor.opened.connect(func(): ui_update_needed.emit())
361
362
363 func build_event_editor() -> void:
364         add_header_edit('character_identifier', ValueType.DYNAMIC_OPTIONS,
365                         {'file_extension'       : '.dch',
366                         'mode'                          : 2,
367                         'suggestions_func'      : get_character_suggestions,
368                         'empty_text'            : '(No one)',
369                         'icon'                          : load("res://addons/dialogic/Editor/Images/Resources/character.svg")}, 'do_any_characters_exist()')
370         add_header_edit('portrait', ValueType.DYNAMIC_OPTIONS,
371                         {'suggestions_func' : get_portrait_suggestions,
372                         'placeholder'           : "(Don't change)",
373                         'icon'                          : load("res://addons/dialogic/Editor/Images/Resources/portrait.svg"),
374                         'collapse_when_empty': true,},
375                         'should_show_portrait_selector()')
376         add_body_edit('text', ValueType.MULTILINE_TEXT, {'autofocus':true})
377
378
379 func should_show_portrait_selector() -> bool:
380         return character and not character.portraits.is_empty() and not character.portraits.size() == 1
381
382
383 func do_any_characters_exist() -> bool:
384         return not DialogicResourceUtil.get_character_directory().is_empty()
385
386
387 func get_character_suggestions(search_text:String) -> Dictionary:
388         return DialogicUtil.get_character_suggestions(search_text, character, true, false, editor_node)
389
390
391 func get_portrait_suggestions(search_text:String) -> Dictionary:
392         return DialogicUtil.get_portrait_suggestions(search_text, character, true, "Don't change")
393
394 #endregion
395
396
397 #region CODE COMPLETION
398 ################################################################################
399
400 var completion_text_character_getter_regex := RegEx.new()
401 var completion_text_effects := {}
402 func _get_code_completion(CodeCompletionHelper:Node, TextNode:TextEdit, line:String, _word:String, symbol:String) -> void:
403         if completion_text_character_getter_regex.get_pattern().is_empty():
404                 completion_text_character_getter_regex.compile("(\"[^\"]*\"|[^\\s:]*)")
405
406         if completion_text_effects.is_empty():
407                 for idx in DialogicUtil.get_indexers():
408                         for effect in idx._get_text_effects():
409                                 completion_text_effects[effect['command']] = effect
410
411         if not ':' in line.substr(0, TextNode.get_caret_column()) and symbol == '(':
412                 var completion_character := completion_text_character_getter_regex.search(line).get_string().trim_prefix('"').trim_suffix('"')
413                 CodeCompletionHelper.suggest_portraits(TextNode, completion_character)
414
415         if symbol == '[':
416                 suggest_bbcode(TextNode)
417                 for effect in completion_text_effects.values():
418                         if effect.get('arg', false):
419                                 TextNode.add_code_completion_option(CodeEdit.KIND_MEMBER, effect.command, effect.command+'=', TextNode.syntax_highlighter.normal_color, TextNode.get_theme_icon("RichTextEffect", "EditorIcons"))
420                         else:
421                                 TextNode.add_code_completion_option(CodeEdit.KIND_MEMBER, effect.command, effect.command, TextNode.syntax_highlighter.normal_color, TextNode.get_theme_icon("RichTextEffect", "EditorIcons"), ']')
422
423         if symbol == '{':
424                 CodeCompletionHelper.suggest_variables(TextNode)
425
426         if symbol == '=':
427                 if CodeCompletionHelper.get_line_untill_caret(line).ends_with('[portrait='):
428                         var completion_character := completion_text_character_getter_regex.search(line).get_string('name')
429                         CodeCompletionHelper.suggest_portraits(TextNode, completion_character, ']')
430
431
432 func _get_start_code_completion(CodeCompletionHelper:Node, TextNode:TextEdit) -> void:
433         CodeCompletionHelper.suggest_characters(TextNode, CodeEdit.KIND_CLASS, true)
434
435
436 func suggest_bbcode(TextNode:CodeEdit):
437         for i in [['b (bold)', 'b'], ['i (italics)', 'i'], ['color', 'color='], ['font size','font_size=']]:
438                 TextNode.add_code_completion_option(CodeEdit.KIND_MEMBER, i[0], i[1],  TextNode.syntax_highlighter.normal_color, TextNode.get_theme_icon("RichTextEffect", "EditorIcons"),)
439                 TextNode.add_code_completion_option(CodeEdit.KIND_CLASS, 'end '+i[0], '/'+i[1],  TextNode.syntax_highlighter.normal_color, TextNode.get_theme_icon("RichTextEffect", "EditorIcons"), ']')
440         for i in [['new event', 'n'],['new event (same box)', 'n+']]:
441                 TextNode.add_code_completion_option(CodeEdit.KIND_MEMBER, i[0], i[1],  TextNode.syntax_highlighter.normal_color, TextNode.get_theme_icon("ArrowRight", "EditorIcons"),)
442
443 #endregion
444
445
446 #region SYNTAX HIGHLIGHTING
447 ################################################################################
448
449 var text_effects := ""
450 var text_effects_regex := RegEx.new()
451 func load_text_effects() -> void:
452         if text_effects.is_empty():
453                 for idx in DialogicUtil.get_indexers():
454                         for effect in idx._get_text_effects():
455                                 text_effects+= effect['command']+'|'
456                 text_effects += "b|i|u|s|code|p|center|left|right|fill|n\\+|n|indent|url|img|font|font_size|opentype_features|color|bg_color|fg_color|outline_size|outline_color|table|cell|ul|ol|lb|rb|br"
457         if text_effects_regex.get_pattern().is_empty():
458                 text_effects_regex.compile("(?<!\\\\)\\[\\s*/?(?<command>"+text_effects+")\\s*(=\\s*(?<value>.+?)\\s*)?\\]")
459
460
461 var text_random_word_regex := RegEx.new()
462 var text_effect_color := Color('#898276')
463 func _get_syntax_highlighting(Highlighter:SyntaxHighlighter, dict:Dictionary, line:String) -> Dictionary:
464         load_text_effects()
465         if text_random_word_regex.get_pattern().is_empty():
466                 text_random_word_regex.compile("(?<!\\\\)\\<[^\\[\\>]+(\\/[^\\>]*)\\>")
467
468         var result := regex.search(line)
469         if !result:
470                 return dict
471         if Highlighter.mode == Highlighter.Modes.FULL_HIGHLIGHTING:
472                 if result.get_string('name'):
473                         dict[result.get_start('name')] = {"color":Highlighter.character_name_color}
474                         dict[result.get_end('name')] = {"color":Highlighter.normal_color}
475                 if result.get_string('portrait'):
476                         dict[result.get_start('portrait')] = {"color":Highlighter.character_portrait_color}
477                         dict[result.get_end('portrait')] = {"color":Highlighter.normal_color}
478         if result.get_string('text'):
479                 var effects_result := text_effects_regex.search_all(line)
480                 for eff in effects_result:
481                         dict[eff.get_start()] = {"color":text_effect_color}
482                         dict[eff.get_end()] = {"color":Highlighter.normal_color}
483                 dict = Highlighter.color_region(dict, Highlighter.variable_color, line, '{', '}', result.get_start('text'))
484
485                 for replace_mod_match in text_random_word_regex.search_all(result.get_string('text')):
486                         var color: Color = Highlighter.string_color
487                         color = color.lerp(Highlighter.normal_color, 0.4)
488                         dict[replace_mod_match.get_start()+result.get_start('text')] = {'color':Highlighter.string_color}
489                         var offset := 1
490                         for b in replace_mod_match.get_string().trim_suffix('>').trim_prefix('<').split('/'):
491                                 color.h = wrap(color.h+0.2, 0, 1)
492                                 dict[replace_mod_match.get_start()+result.get_start('text')+offset] = {'color':color}
493                                 offset += len(b)
494                                 dict[replace_mod_match.get_start()+result.get_start('text')+offset] = {'color':Highlighter.string_color}
495                                 offset += 1
496                         dict[replace_mod_match.get_end()+result.get_start('text')] = {'color':Highlighter.normal_color}
497         return dict
498
499 #endregion