]> Untitled Git - wolf-seeking-sheep.git/blob - addons/dialogic/Core/DialogicUtil.gd
Squashed commit of the following:
[wolf-seeking-sheep.git] / addons / dialogic / Core / DialogicUtil.gd
1 @tool
2 class_name DialogicUtil
3
4 ## Script that container helper methods for both editor and game execution.
5 ## Used whenever the same thing is needed in different parts of the plugin.
6
7 #region EDITOR
8
9 ## This method should be used instead of EditorInterface.get_editor_scale(), because if you use that
10 ## it will run perfectly fine from the editor, but crash when the game is exported.
11 static func get_editor_scale() -> float:
12         return get_dialogic_plugin().get_editor_interface().get_editor_scale()
13
14
15 ## Although this does in fact always return a EditorPlugin node,
16 ##  that class is apparently not present in export and referencing it here creates a crash.
17 static func get_dialogic_plugin() -> Node:
18         for child in Engine.get_main_loop().get_root().get_children():
19                 if child.get_class() == "EditorNode":
20                         return child.get_node('DialogicPlugin')
21         return null
22
23 #endregion
24
25
26 ## Returns the autoload when in-game.
27 static func autoload() -> DialogicGameHandler:
28         if Engine.is_editor_hint():
29                 return null
30         if not Engine.get_main_loop().root.has_node("Dialogic"):
31                 return null
32         return Engine.get_main_loop().root.get_node("Dialogic")
33
34
35 #region FILE SYSTEM
36 ################################################################################
37 static func listdir(path: String, files_only:= true, _throw_error:= true, full_file_path:= false, include_imports := false) -> Array:
38         var files: Array = []
39         if path.is_empty(): path = "res://"
40         if DirAccess.dir_exists_absolute(path):
41                 var dir := DirAccess.open(path)
42                 dir.list_dir_begin()
43                 var file_name := dir.get_next()
44                 while file_name != "":
45                         if not file_name.begins_with("."):
46                                 if files_only:
47                                         if not dir.current_is_dir() and (not file_name.ends_with('.import') or include_imports):
48                                                 if full_file_path:
49                                                         files.append(path.path_join(file_name))
50                                                 else:
51                                                         files.append(file_name)
52                                 else:
53                                         if full_file_path:
54                                                 files.append(path.path_join(file_name))
55                                         else:
56                                                 files.append(file_name)
57                         file_name = dir.get_next()
58                 dir.list_dir_end()
59         return files
60
61
62 static func get_module_path(name:String, builtin:=true) -> String:
63         if builtin:
64                 return "res://addons/dialogic/Modules".path_join(name)
65         else:
66                 return ProjectSettings.get_setting('dialogic/extensions_folder', 'res://addons/dialogic_additions').path_join(name)
67
68
69 ## This is a private and editor-only function.
70 ##
71 ## Populates the [class DialogicGameHandler] with new custom subsystems by
72 ## directly manipulating the file's content and then importing the file.
73 static func _update_autoload_subsystem_access() -> void:
74         if not Engine.is_editor_hint():
75                 printerr("[Dialogic] This function is only available in the editor.")
76                 return
77
78         var script: Script = load("res://addons/dialogic/Core/DialogicGameHandler.gd")
79         var new_subsystem_access_list := "#region SUBSYSTEMS\n"
80         var subsystems_sorted := []
81
82         for indexer: DialogicIndexer in get_indexers(true, true):
83
84                 for subsystem: Dictionary in indexer._get_subsystems().duplicate(true):
85                         subsystems_sorted.append(subsystem)
86
87         subsystems_sorted.sort_custom(func (a: Dictionary, b: Dictionary) -> bool:
88                 return a.name < b.name
89         )
90
91         for subsystem: Dictionary in subsystems_sorted:
92                 new_subsystem_access_list += '\nvar {name} := preload("{script}").new():\n\tget: return get_subsystem("{name}")\n'.format(subsystem)
93
94         new_subsystem_access_list += "\n#endregion"
95         script.source_code = RegEx.create_from_string(r"#region SUBSYSTEMS\n#*\n((?!#endregion)(.*\n))*#endregion").sub(script.source_code, new_subsystem_access_list)
96         ResourceSaver.save(script)
97         Engine.get_singleton("EditorInterface").get_resource_filesystem().reimport_files(["res://addons/dialogic/Core/DialogicGameHandler.gd"])
98
99
100 static func get_indexers(include_custom := true, force_reload := false) -> Array[DialogicIndexer]:
101         if Engine.get_main_loop().has_meta('dialogic_indexers') and !force_reload:
102                 return Engine.get_main_loop().get_meta('dialogic_indexers')
103
104         var indexers: Array[DialogicIndexer] = []
105
106         for file in listdir(DialogicUtil.get_module_path(''), false):
107                 var possible_script: String = DialogicUtil.get_module_path(file).path_join("index.gd")
108                 if ResourceLoader.exists(possible_script):
109                         indexers.append(load(possible_script).new())
110
111         if include_custom:
112                 var extensions_folder: String = ProjectSettings.get_setting('dialogic/extensions_folder', "res://addons/dialogic_additions/")
113                 for file in listdir(extensions_folder, false, false):
114                         var possible_script: String = extensions_folder.path_join(file + "/index.gd")
115                         if ResourceLoader.exists(possible_script):
116                                 indexers.append(load(possible_script).new())
117
118         Engine.get_main_loop().set_meta('dialogic_indexers', indexers)
119         return indexers
120
121
122
123 ## Turns a [param file_path] from `some_file.png` to `Some File`.
124 static func pretty_name(file_path: String) -> String:
125         var _name := file_path.get_file().trim_suffix("." + file_path.get_extension())
126         _name = _name.replace('_', ' ')
127         _name = _name.capitalize()
128
129         return _name
130
131 #endregion
132
133
134 #region EDITOR SETTINGS & COLORS
135 ################################################################################
136
137 static func set_editor_setting(setting:String, value:Variant) -> void:
138         var cfg := ConfigFile.new()
139         if FileAccess.file_exists('user://dialogic/editor_settings.cfg'):
140                 cfg.load('user://dialogic/editor_settings.cfg')
141
142         cfg.set_value('DES', setting, value)
143
144         if !DirAccess.dir_exists_absolute('user://dialogic'):
145                 DirAccess.make_dir_absolute('user://dialogic')
146         cfg.save('user://dialogic/editor_settings.cfg')
147
148
149 static func get_editor_setting(setting:String, default:Variant=null) -> Variant:
150         var cfg := ConfigFile.new()
151         if !FileAccess.file_exists('user://dialogic/editor_settings.cfg'):
152                 return default
153
154         if !cfg.load('user://dialogic/editor_settings.cfg') == OK:
155                 return default
156
157         return cfg.get_value('DES', setting, default)
158
159
160 static func get_color_palette(default:bool = false) -> Dictionary:
161         var defaults := {
162                 'Color1': Color('#3b8bf2'), # Blue
163                 'Color2': Color('#00b15f'), # Green
164                 'Color3': Color('#e868e2'), # Pink
165                 'Color4': Color('#9468e8'), # Purple
166                 'Color5': Color('#574fb0'), # DarkPurple
167                 'Color6': Color('#1fa3a3'), # Aquamarine
168                 'Color7': Color('#fa952a'), # Orange
169                 'Color8': Color('#de5c5c'), # Red
170                 'Color9': Color('#7c7c7c'), # Gray
171         }
172         if default:
173                 return defaults
174         return get_editor_setting('color_palette', defaults)
175
176
177 static func get_color(value:String) -> Color:
178         var colors := get_color_palette()
179         return colors[value]
180
181 #endregion
182
183
184 #region TIMER PROCESS MODE
185 ################################################################################
186 static func is_physics_timer() -> bool:
187         return ProjectSettings.get_setting('dialogic/timer/process_in_physics', false)
188
189
190 static func update_timer_process_callback(timer:Timer) -> void:
191         timer.process_callback = Timer.TIMER_PROCESS_PHYSICS if is_physics_timer() else Timer.TIMER_PROCESS_IDLE
192
193 #endregion
194
195
196 #region MULTITWEEN
197 ################################################################################
198 static func multitween(tweened_value:Variant, item:Node, property:String, part:String) -> void:
199         var parts: Dictionary = item.get_meta(property+'_parts', {})
200         parts[part] = tweened_value
201
202         if not item.has_meta(property+'_base_value') and not 'base' in parts:
203                 item.set_meta(property+'_base_value', item.get(property))
204
205         var final_value: Variant = parts.get('base', item.get_meta(property+'_base_value', item.get(property)))
206
207         for key in parts:
208                 if key == 'base':
209                         continue
210                 else:
211                         final_value += parts[key]
212
213         item.set(property, final_value)
214         item.set_meta(property+'_parts', parts)
215
216 #endregion
217
218
219 #region TRANSLATIONS
220 ################################################################################
221
222 static func get_next_translation_id() -> String:
223         ProjectSettings.set_setting('dialogic/translation/id_counter', ProjectSettings.get_setting('dialogic/translation/id_counter', 16)+1)
224         return '%x' % ProjectSettings.get_setting('dialogic/translation/id_counter', 16)
225
226 #endregion
227
228
229 #region VARIABLES
230 ################################################################################
231
232 enum VarTypes {ANY, STRING, FLOAT, INT, BOOL}
233
234
235 static func get_default_variables() -> Dictionary:
236         return ProjectSettings.get_setting('dialogic/variables', {})
237
238
239 # helper that converts a nested variable dictionary into an array with paths
240 static func list_variables(dict:Dictionary, path := "", type:=VarTypes.ANY) -> Array:
241         var array := []
242         for key in dict.keys():
243                 if typeof(dict[key]) == TYPE_DICTIONARY:
244                         array.append_array(list_variables(dict[key], path+key+".", type))
245                 else:
246                         if type == VarTypes.ANY or get_variable_value_type(dict[key]) == type:
247                                 array.append(path+key)
248         return array
249
250
251 static func get_variable_value_type(value:Variant) -> VarTypes:
252         match typeof(value):
253                 TYPE_STRING:
254                         return VarTypes.STRING
255                 TYPE_FLOAT:
256                         return VarTypes.FLOAT
257                 TYPE_INT:
258                         return VarTypes.INT
259                 TYPE_BOOL:
260                         return VarTypes.BOOL
261         return VarTypes.ANY
262
263
264 static func get_variable_type(path:String, dict:Dictionary={}) -> VarTypes:
265         if dict.is_empty():
266                 dict = get_default_variables()
267         return get_variable_value_type(_get_value_in_dictionary(path, dict))
268
269
270 ## This will set a value in a dictionary (or a sub-dictionary based on the path)
271 ## e.g. it could set "Something.Something.Something" in {'Something':{'Something':{'Someting':"value"}}}
272 static func _set_value_in_dictionary(path:String, dictionary:Dictionary, value):
273         if '.' in path:
274                 var from := path.split('.')[0]
275                 if from in dictionary.keys():
276                         dictionary[from] = _set_value_in_dictionary(path.trim_prefix(from+"."), dictionary[from], value)
277         else:
278                 if path in dictionary.keys():
279                         dictionary[path] = value
280         return dictionary
281
282
283 ## This will get a value in a dictionary (or a sub-dictionary based on the path)
284 ## e.g. it could get "Something.Something.Something" in {'Something':{'Something':{'Someting':"value"}}}
285 static func _get_value_in_dictionary(path:String, dictionary:Dictionary, default= null) -> Variant:
286         if '.' in path:
287                 var from := path.split('.')[0]
288                 if from in dictionary.keys():
289                         return _get_value_in_dictionary(path.trim_prefix(from+"."), dictionary[from], default)
290         else:
291                 if path in dictionary.keys():
292                         return dictionary[path]
293         return default
294
295 #endregion
296
297
298
299 #region STYLES
300 ################################################################################
301
302 static func get_default_layout_base() -> PackedScene:
303         return load(DialogicUtil.get_module_path('DefaultLayoutParts').path_join("Base_Default/default_layout_base.tscn"))
304
305
306 static func get_fallback_style() -> DialogicStyle:
307         return load(DialogicUtil.get_module_path('DefaultLayoutParts').path_join("Style_VN_Default/default_vn_style.tres"))
308
309
310 static func get_default_style() -> DialogicStyle:
311         var default: String = ProjectSettings.get_setting('dialogic/layout/default_style', '')
312         if !ResourceLoader.exists(default):
313                 return get_fallback_style()
314         return load(default)
315
316
317 static func get_style_by_name(name:String) -> DialogicStyle:
318         if name.is_empty():
319                 return get_default_style()
320
321         var styles: Array = ProjectSettings.get_setting('dialogic/layout/style_list', [])
322         for style in styles:
323                 if not ResourceLoader.exists(style):
324                         continue
325                 if load(style).name == name:
326                         return load(style)
327
328         return get_default_style()
329 #endregion
330
331
332 #region SCENE EXPORT OVERRIDES
333 ################################################################################
334
335 static func apply_scene_export_overrides(node:Node, export_overrides:Dictionary, apply := true) -> void:
336         var default_info := get_scene_export_defaults(node)
337         if !node.script:
338                 return
339         var property_info: Array[Dictionary] = node.script.get_script_property_list()
340         for i in property_info:
341                 if i['usage'] & PROPERTY_USAGE_EDITOR:
342                         if i['name'] in export_overrides:
343                                 if str_to_var(export_overrides[i['name']]) == null and typeof(node.get(i['name'])) == TYPE_STRING:
344                                         node.set(i['name'], export_overrides[i['name']])
345                                 else:
346                                         node.set(i['name'], str_to_var(export_overrides[i['name']]))
347                         elif i['name'] in default_info:
348                                 node.set(i['name'], default_info.get(i['name']))
349         if apply:
350                 if node.has_method('apply_export_overrides'):
351                         node.apply_export_overrides()
352
353
354 static func get_scene_export_defaults(node:Node) -> Dictionary:
355         if !node.script:
356                 return {}
357
358         if Engine.get_main_loop().has_meta('dialogic_scene_export_defaults') and \
359                         node.script.resource_path in Engine.get_main_loop().get_meta('dialogic_scene_export_defaults'):
360                 return Engine.get_main_loop().get_meta('dialogic_scene_export_defaults')[node.script.resource_path]
361
362         if !Engine.get_main_loop().has_meta('dialogic_scene_export_defaults'):
363                 Engine.get_main_loop().set_meta('dialogic_scene_export_defaults', {})
364         var defaults := {}
365         var property_info: Array[Dictionary] = node.script.get_script_property_list()
366         for i in property_info:
367                 if i['usage'] & PROPERTY_USAGE_EDITOR:
368                         defaults[i['name']] = node.get(i['name'])
369         Engine.get_main_loop().get_meta('dialogic_scene_export_defaults')[node.script.resource_path] = defaults
370         return defaults
371
372 #endregion
373
374 #region MAKE CUSTOM
375
376 static func make_file_custom(original_file:String, target_folder:String, new_file_name := "", new_folder_name := "") -> String:
377         if not ResourceLoader.exists(original_file):
378                 push_error("[Dialogic] Unable to make file with invalid path custom!")
379                 return ""
380
381         if new_folder_name:
382                 target_folder = target_folder.path_join(new_folder_name)
383                 DirAccess.make_dir_absolute(target_folder)
384
385         if new_file_name.is_empty():
386                 new_file_name = "custom_" + original_file.get_file()
387
388         if not new_file_name.ends_with(original_file.get_extension()):
389                 new_file_name += "." + original_file.get_extension()
390
391         var target_file := target_folder.path_join(new_file_name)
392
393         customize_file(original_file, target_file)
394
395         get_dialogic_plugin().get_editor_interface().get_resource_filesystem().scan_sources()
396
397         return target_file
398
399
400 static func customize_file(original_file:String, target_file:String) -> String:
401         #print("\nCUSTOMIZE FILE")
402         #printt(original_file, "->", target_file)
403
404         DirAccess.copy_absolute(original_file, target_file)
405
406         var file := FileAccess.open(target_file, FileAccess.READ)
407         var file_text := file.get_as_text()
408         file.close()
409
410         # If we are customizing a scene, we check for any resources used in that scene that are in the same folder.
411         # Those will be copied as well and the scene will be modified to point to them.
412         if file_text.begins_with('[gd_'):
413                 var base_path: String = original_file.get_base_dir()
414
415                 var remove_uuid_regex := r'\[gd_.* (?<uid>uid="uid:[^"]*")'
416                 var result := RegEx.create_from_string(remove_uuid_regex).search(file_text)
417                 if result:
418                         file_text = file_text.replace(result.get_string("uid"), "")
419
420                 # This regex also removes the UID referencing the original resource
421                 var file_regex := r'(uid="[^"]*" )?\Qpath="'+base_path+r'\E(?<file>[^"]*)"'
422                 result = RegEx.create_from_string(file_regex).search(file_text)
423                 while result:
424                         var found_file_name := result.get_string('file')
425                         var found_file_path := base_path.path_join(found_file_name)
426                         var target_file_path := target_file.get_base_dir().path_join(found_file_name)
427
428                         # Files found in this file will ALSO be customized.
429                         customize_file(found_file_path, target_file_path)
430
431                         file_text = file_text.replace(found_file_path, target_file_path)
432
433                         result = RegEx.create_from_string(file_regex).search(file_text)
434
435         file = FileAccess.open(target_file, FileAccess.WRITE)
436         file.store_string(file_text)
437         file.close()
438
439         return target_file
440
441 #endregion
442
443 #region INSPECTOR FIELDS
444 ################################################################################
445
446 static func setup_script_property_edit_node(property_info: Dictionary, value:Variant, property_changed:Callable) -> Control:
447         var input: Control = null
448         match property_info['type']:
449                 TYPE_BOOL:
450                         input = CheckBox.new()
451                         if value != null:
452                                 input.button_pressed = value
453                         input.toggled.connect(DialogicUtil._on_export_bool_submitted.bind(property_info.name, property_changed))
454                 TYPE_COLOR:
455                         input = ColorPickerButton.new()
456                         if value != null:
457                                 input.color = value
458                         input.color_changed.connect(DialogicUtil._on_export_color_submitted.bind(property_info.name, property_changed))
459                         input.custom_minimum_size.x = get_editor_scale() * 50
460                 TYPE_INT:
461                         if property_info['hint'] & PROPERTY_HINT_ENUM:
462                                 input = OptionButton.new()
463                                 for x in property_info['hint_string'].split(','):
464                                         input.add_item(x.split(':')[0])
465                                 if value != null:
466                                         input.select(value)
467                                 input.item_selected.connect(DialogicUtil._on_export_int_enum_submitted.bind(property_info.name, property_changed))
468                         else:
469                                 input = SpinBox.new()
470                                 input.value_changed.connect(DialogicUtil._on_export_number_submitted.bind(property_info.name, property_changed))
471                                 if property_info.hint_string == 'int':
472                                         input.step = 1
473                                         input.allow_greater = true
474                                         input.allow_lesser = true
475                                 elif ',' in property_info.hint_string:
476                                         input.min_value = int(property_info.hint_string.get_slice(',', 0))
477                                         input.max_value = int(property_info.hint_string.get_slice(',', 1))
478                                         if property_info.hint_string.count(',') > 1:
479                                                 input.step = int(property_info.hint_string.get_slice(',', 2))
480                                 if value != null:
481                                         input.value = value
482                 TYPE_FLOAT:
483                         input = SpinBox.new()
484                         input.step = 0.01
485                         if ',' in property_info.hint_string:
486                                 input.min_value = float(property_info.hint_string.get_slice(',', 0))
487                                 input.max_value = float(property_info.hint_string.get_slice(',', 1))
488                                 if property_info.hint_string.count(',') > 1:
489                                         input.step = float(property_info.hint_string.get_slice(',', 2))
490                         input.value_changed.connect(DialogicUtil._on_export_number_submitted.bind(property_info.name, property_changed))
491                         if value != null:
492                                 input.value = value
493                 TYPE_VECTOR2, TYPE_VECTOR3, TYPE_VECTOR4:
494                         var vectorSize: String = type_string(typeof(value))[-1]
495                         input = load("res://addons/dialogic/Editor/Events/Fields/field_vector" + vectorSize + ".tscn").instantiate()
496                         input.property_name = property_info['name']
497                         input.set_value(value)
498                         input.value_changed.connect(DialogicUtil._on_export_vector_submitted.bind(property_changed))
499                 TYPE_STRING:
500                         if property_info['hint'] & PROPERTY_HINT_FILE or property_info['hint'] & PROPERTY_HINT_DIR:
501                                 input = load("res://addons/dialogic/Editor/Events/Fields/field_file.tscn").instantiate()
502                                 input.file_filter = property_info['hint_string']
503                                 input.file_mode = FileDialog.FILE_MODE_OPEN_FILE
504                                 if property_info['hint'] == PROPERTY_HINT_DIR:
505                                         input.file_mode = FileDialog.FILE_MODE_OPEN_DIR
506                                 input.property_name = property_info['name']
507                                 input.placeholder = "Default"
508                                 input.hide_reset = true
509                                 if value != null:
510                                         input.set_value(value)
511                                 input.value_changed.connect(DialogicUtil._on_export_file_submitted.bind(property_changed))
512                         elif property_info['hint'] & PROPERTY_HINT_ENUM:
513                                 input = OptionButton.new()
514                                 var options: PackedStringArray = []
515                                 for x in property_info['hint_string'].split(','):
516                                         options.append(x.split(':')[0].strip_edges())
517                                         input.add_item(options[-1])
518                                 if value != null:
519                                         input.select(options.find(value))
520                                 input.item_selected.connect(DialogicUtil._on_export_string_enum_submitted.bind(property_info.name, options, property_changed))
521                         else:
522                                 input = LineEdit.new()
523                                 if value != null:
524                                         input.text = value
525                                 input.text_submitted.connect(DialogicUtil._on_export_input_text_submitted.bind(property_info.name, property_changed))
526                 TYPE_DICTIONARY:
527                         input = load("res://addons/dialogic/Editor/Events/Fields/field_dictionary.tscn").instantiate()
528                         input.property_name = property_info["name"]
529                         input.value_changed.connect(_on_export_dict_submitted.bind(property_changed))
530                 TYPE_OBJECT:
531                         input = load("res://addons/dialogic/Editor/Common/hint_tooltip_icon.tscn").instantiate()
532                         input.hint_text = "Objects/Resources as settings are currently not supported. \nUse @export_file('*.extension') instead and load the resource once needed."
533
534                 _:
535                         input = LineEdit.new()
536                         if value != null:
537                                 input.text = value
538                         input.text_submitted.connect(_on_export_input_text_submitted.bind(property_info.name, property_changed))
539         return input
540
541
542 static func _on_export_input_text_submitted(text:String, property_name:String, callable: Callable) -> void:
543         callable.call(property_name, var_to_str(text))
544
545 static func _on_export_bool_submitted(value:bool, property_name:String, callable: Callable) -> void:
546         callable.call(property_name, var_to_str(value))
547
548 static func _on_export_color_submitted(color:Color, property_name:String, callable: Callable) -> void:
549         callable.call(property_name, var_to_str(color))
550
551 static func _on_export_int_enum_submitted(item:int, property_name:String, callable: Callable) -> void:
552         callable.call(property_name, var_to_str(item))
553
554 static func _on_export_number_submitted(value:float, property_name:String, callable: Callable) -> void:
555         callable.call(property_name, var_to_str(value))
556
557 static func _on_export_file_submitted(property_name:String, value:String, callable: Callable) -> void:
558         callable.call(property_name, var_to_str(value))
559
560 static func _on_export_string_enum_submitted(value:int, property_name:String, list:PackedStringArray, callable: Callable):
561         callable.call(property_name, var_to_str(list[value]))
562
563 static func _on_export_vector_submitted(property_name:String, value:Variant, callable: Callable) -> void:
564         callable.call(property_name, var_to_str(value))
565
566 static func _on_export_dict_submitted(property_name:String, value:Variant, callable: Callable) -> void:
567         callable.call(property_name, var_to_str(value))
568
569 #endregion
570
571
572 #region EVENT DEFAULTS
573 ################################################################################
574
575 static func get_custom_event_defaults(event_name:String) -> Dictionary:
576         if Engine.is_editor_hint():
577                 return ProjectSettings.get_setting('dialogic/event_default_overrides', {}).get(event_name, {})
578         else:
579                 if !Engine.get_main_loop().has_meta('dialogic_event_defaults'):
580                         Engine.get_main_loop().set_meta('dialogic_event_defaults', ProjectSettings.get_setting('dialogic/event_default_overrides', {}))
581                 return Engine.get_main_loop().get_meta('dialogic_event_defaults').get(event_name, {})
582
583 #endregion
584
585
586 #region CONVERSION
587 ################################################################################
588
589 static func str_to_bool(boolstring:String) -> bool:
590         return true if boolstring == "true" else false
591
592
593 static func logical_convert(value:Variant) -> Variant:
594         if typeof(value) == TYPE_STRING:
595                 if value.is_valid_int():
596                         return value.to_int()
597                 if value.is_valid_float():
598                         return value.to_float()
599                 if value == 'true':
600                         return true
601                 if value == 'false':
602                         return false
603         return value
604
605
606 ## Takes [param source] and builds a dictionary of keys only.
607 ## The values are `null`.
608 static func str_to_hash_set(source: String) -> Dictionary:
609         var dictionary := Dictionary()
610
611         for character in source:
612                 dictionary[character] = null
613
614         return dictionary
615
616 #endregion
617
618
619 static func get_character_suggestions(_search_text:String, current_value:DialogicCharacter = null, allow_none := true, allow_all:= false, editor_node:Node = null) -> Dictionary:
620         var suggestions := {}
621
622         var icon := load("res://addons/dialogic/Editor/Images/Resources/character.svg")
623
624         if allow_none and current_value:
625                 suggestions['(No one)'] = {'value':'', 'editor_icon':["GuiRadioUnchecked", "EditorIcons"]}
626
627         if allow_all:
628                 suggestions['ALL'] = {'value':'--All--', 'tooltip':'All currently joined characters leave', 'editor_icon':["GuiEllipsis", "EditorIcons"]}
629
630         # Get characters in the current timeline and place them at the top of suggestions.
631         if editor_node:
632                 var recent_characters := []
633                 var timeline_node := editor_node.get_parent().find_parent("Timeline") as DialogicEditor
634                 for event_node in timeline_node.find_child("Timeline").get_children():
635                         if event_node == editor_node:
636                                 break
637                         if event_node.resource is DialogicCharacterEvent or event_node.resource is DialogicTextEvent:
638                                 recent_characters.append(event_node.resource.character)
639
640                 recent_characters.reverse()
641                 for character in recent_characters:
642                         if character and not character.get_character_name() in suggestions:
643                                 suggestions[character.get_character_name()] = {'value': character.get_character_name(), 'tooltip': character.resource_path, 'icon': icon.duplicate()}
644
645         var character_directory := DialogicResourceUtil.get_character_directory()
646         for resource in character_directory.keys():
647                 suggestions[resource] = {'value': resource, 'tooltip': character_directory[resource], 'icon': icon}
648
649         return suggestions
650
651
652 static func get_portrait_suggestions(search_text:String, character:DialogicCharacter, allow_empty := false, empty_text := "Don't Change") -> Dictionary:
653         var icon := load("res://addons/dialogic/Editor/Images/Resources/portrait.svg")
654         var suggestions := {}
655
656         if allow_empty:
657                 suggestions[empty_text] = {'value':'', 'editor_icon':["GuiRadioUnchecked", "EditorIcons"]}
658
659         if "{" in search_text:
660                 suggestions[search_text] = {'value':search_text, 'editor_icon':["Variant", "EditorIcons"]}
661
662         if character != null:
663                 for portrait in character.portraits:
664                         suggestions[portrait] = {'value':portrait, 'icon':icon}
665
666         return suggestions
667
668
669 static func get_portrait_position_suggestions(search_text := "") -> Dictionary:
670         var icon := load(DialogicUtil.get_module_path("Character").path_join('portrait_position.svg'))
671
672         var setting: String = ProjectSettings.get_setting('dialogic/portraits/position_suggestion_names', 'leftmost, left, center, right, rightmost')
673
674         var suggestions := {}
675
676         if not search_text.is_empty():
677                 suggestions[search_text] = {'value':search_text.strip_edges(), 'editor_icon':["GuiScrollArrowRight", "EditorIcons"]}
678
679         for position_id in setting.split(','):
680                 suggestions[position_id.strip_edges()] = {'value':position_id.strip_edges(), 'icon':icon}
681                 if not search_text.is_empty() and position_id.strip_edges().begins_with(search_text):
682                         suggestions.erase(search_text)
683
684         return suggestions