2 class_name DialogicUtil
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.
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()
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')
26 ## Returns the autoload when in-game.
27 static func autoload() -> DialogicGameHandler:
28 if Engine.is_editor_hint():
30 if not Engine.get_main_loop().root.has_node("Dialogic"):
32 return Engine.get_main_loop().root.get_node("Dialogic")
36 ################################################################################
37 static func listdir(path: String, files_only:= true, _throw_error:= true, full_file_path:= false, include_imports := false) -> Array:
39 if path.is_empty(): path = "res://"
40 if DirAccess.dir_exists_absolute(path):
41 var dir := DirAccess.open(path)
43 var file_name := dir.get_next()
44 while file_name != "":
45 if not file_name.begins_with("."):
47 if not dir.current_is_dir() and (not file_name.ends_with('.import') or include_imports):
49 files.append(path.path_join(file_name))
51 files.append(file_name)
54 files.append(path.path_join(file_name))
56 files.append(file_name)
57 file_name = dir.get_next()
62 static func get_module_path(name:String, builtin:=true) -> String:
64 return "res://addons/dialogic/Modules".path_join(name)
66 return ProjectSettings.get_setting('dialogic/extensions_folder', 'res://addons/dialogic_additions').path_join(name)
69 ## This is a private and editor-only function.
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.")
78 var script: Script = load("res://addons/dialogic/Core/DialogicGameHandler.gd")
79 var new_subsystem_access_list := "#region SUBSYSTEMS\n"
80 var subsystems_sorted := []
82 for indexer: DialogicIndexer in get_indexers(true, true):
84 for subsystem: Dictionary in indexer._get_subsystems().duplicate(true):
85 subsystems_sorted.append(subsystem)
87 subsystems_sorted.sort_custom(func (a: Dictionary, b: Dictionary) -> bool:
88 return a.name < b.name
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)
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"])
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')
104 var indexers: Array[DialogicIndexer] = []
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())
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())
118 Engine.get_main_loop().set_meta('dialogic_indexers', indexers)
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()
134 #region EDITOR SETTINGS & COLORS
135 ################################################################################
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')
142 cfg.set_value('DES', setting, value)
144 if !DirAccess.dir_exists_absolute('user://dialogic'):
145 DirAccess.make_dir_absolute('user://dialogic')
146 cfg.save('user://dialogic/editor_settings.cfg')
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'):
154 if !cfg.load('user://dialogic/editor_settings.cfg') == OK:
157 return cfg.get_value('DES', setting, default)
160 static func get_color_palette(default:bool = false) -> Dictionary:
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
174 return get_editor_setting('color_palette', defaults)
177 static func get_color(value:String) -> Color:
178 var colors := get_color_palette()
184 #region TIMER PROCESS MODE
185 ################################################################################
186 static func is_physics_timer() -> bool:
187 return ProjectSettings.get_setting('dialogic/timer/process_in_physics', false)
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
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
202 if not item.has_meta(property+'_base_value') and not 'base' in parts:
203 item.set_meta(property+'_base_value', item.get(property))
205 var final_value: Variant = parts.get('base', item.get_meta(property+'_base_value', item.get(property)))
211 final_value += parts[key]
213 item.set(property, final_value)
214 item.set_meta(property+'_parts', parts)
220 ################################################################################
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)
230 ################################################################################
232 enum VarTypes {ANY, STRING, FLOAT, INT, BOOL}
235 static func get_default_variables() -> Dictionary:
236 return ProjectSettings.get_setting('dialogic/variables', {})
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:
242 for key in dict.keys():
243 if typeof(dict[key]) == TYPE_DICTIONARY:
244 array.append_array(list_variables(dict[key], path+key+".", type))
246 if type == VarTypes.ANY or get_variable_value_type(dict[key]) == type:
247 array.append(path+key)
251 static func get_variable_value_type(value:Variant) -> VarTypes:
254 return VarTypes.STRING
256 return VarTypes.FLOAT
264 static func get_variable_type(path:String, dict:Dictionary={}) -> VarTypes:
266 dict = get_default_variables()
267 return get_variable_value_type(_get_value_in_dictionary(path, dict))
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):
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)
278 if path in dictionary.keys():
279 dictionary[path] = value
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:
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)
291 if path in dictionary.keys():
292 return dictionary[path]
300 ################################################################################
302 static func get_default_layout_base() -> PackedScene:
303 return load(DialogicUtil.get_module_path('DefaultLayoutParts').path_join("Base_Default/default_layout_base.tscn"))
306 static func get_fallback_style() -> DialogicStyle:
307 return load(DialogicUtil.get_module_path('DefaultLayoutParts').path_join("Style_VN_Default/default_vn_style.tres"))
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()
317 static func get_style_by_name(name:String) -> DialogicStyle:
319 return get_default_style()
321 var styles: Array = ProjectSettings.get_setting('dialogic/layout/style_list', [])
323 if not ResourceLoader.exists(style):
325 if load(style).name == name:
328 return get_default_style()
332 #region SCENE EXPORT OVERRIDES
333 ################################################################################
335 static func apply_scene_export_overrides(node:Node, export_overrides:Dictionary, apply := true) -> void:
336 var default_info := get_scene_export_defaults(node)
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']])
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']))
350 if node.has_method('apply_export_overrides'):
351 node.apply_export_overrides()
354 static func get_scene_export_defaults(node:Node) -> Dictionary:
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]
362 if !Engine.get_main_loop().has_meta('dialogic_scene_export_defaults'):
363 Engine.get_main_loop().set_meta('dialogic_scene_export_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
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!")
382 target_folder = target_folder.path_join(new_folder_name)
383 DirAccess.make_dir_absolute(target_folder)
385 if new_file_name.is_empty():
386 new_file_name = "custom_" + original_file.get_file()
388 if not new_file_name.ends_with(original_file.get_extension()):
389 new_file_name += "." + original_file.get_extension()
391 var target_file := target_folder.path_join(new_file_name)
393 customize_file(original_file, target_file)
395 get_dialogic_plugin().get_editor_interface().get_resource_filesystem().scan_sources()
400 static func customize_file(original_file:String, target_file:String) -> String:
401 #print("\nCUSTOMIZE FILE")
402 #printt(original_file, "->", target_file)
404 DirAccess.copy_absolute(original_file, target_file)
406 var file := FileAccess.open(target_file, FileAccess.READ)
407 var file_text := file.get_as_text()
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()
415 var remove_uuid_regex := r'\[gd_.* (?<uid>uid="uid:[^"]*")'
416 var result := RegEx.create_from_string(remove_uuid_regex).search(file_text)
418 file_text = file_text.replace(result.get_string("uid"), "")
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)
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)
428 # Files found in this file will ALSO be customized.
429 customize_file(found_file_path, target_file_path)
431 file_text = file_text.replace(found_file_path, target_file_path)
433 result = RegEx.create_from_string(file_regex).search(file_text)
435 file = FileAccess.open(target_file, FileAccess.WRITE)
436 file.store_string(file_text)
443 #region INSPECTOR FIELDS
444 ################################################################################
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']:
450 input = CheckBox.new()
452 input.button_pressed = value
453 input.toggled.connect(DialogicUtil._on_export_bool_submitted.bind(property_info.name, property_changed))
455 input = ColorPickerButton.new()
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
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])
467 input.item_selected.connect(DialogicUtil._on_export_int_enum_submitted.bind(property_info.name, property_changed))
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':
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))
483 input = SpinBox.new()
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))
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))
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
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])
519 input.select(options.find(value))
520 input.item_selected.connect(DialogicUtil._on_export_string_enum_submitted.bind(property_info.name, options, property_changed))
522 input = LineEdit.new()
525 input.text_submitted.connect(DialogicUtil._on_export_input_text_submitted.bind(property_info.name, property_changed))
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))
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."
535 input = LineEdit.new()
538 input.text_submitted.connect(_on_export_input_text_submitted.bind(property_info.name, property_changed))
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))
545 static func _on_export_bool_submitted(value:bool, property_name:String, callable: Callable) -> void:
546 callable.call(property_name, var_to_str(value))
548 static func _on_export_color_submitted(color:Color, property_name:String, callable: Callable) -> void:
549 callable.call(property_name, var_to_str(color))
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))
554 static func _on_export_number_submitted(value:float, property_name:String, callable: Callable) -> void:
555 callable.call(property_name, var_to_str(value))
557 static func _on_export_file_submitted(property_name:String, value:String, callable: Callable) -> void:
558 callable.call(property_name, var_to_str(value))
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]))
563 static func _on_export_vector_submitted(property_name:String, value:Variant, callable: Callable) -> void:
564 callable.call(property_name, var_to_str(value))
566 static func _on_export_dict_submitted(property_name:String, value:Variant, callable: Callable) -> void:
567 callable.call(property_name, var_to_str(value))
572 #region EVENT DEFAULTS
573 ################################################################################
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, {})
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, {})
587 ################################################################################
589 static func str_to_bool(boolstring:String) -> bool:
590 return true if boolstring == "true" else false
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()
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()
611 for character in source:
612 dictionary[character] = null
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 := {}
622 var icon := load("res://addons/dialogic/Editor/Images/Resources/character.svg")
624 if allow_none and current_value:
625 suggestions['(No one)'] = {'value':'', 'editor_icon':["GuiRadioUnchecked", "EditorIcons"]}
628 suggestions['ALL'] = {'value':'--All--', 'tooltip':'All currently joined characters leave', 'editor_icon':["GuiEllipsis", "EditorIcons"]}
630 # Get characters in the current timeline and place them at the top of suggestions.
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:
637 if event_node.resource is DialogicCharacterEvent or event_node.resource is DialogicTextEvent:
638 recent_characters.append(event_node.resource.character)
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()}
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}
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 := {}
657 suggestions[empty_text] = {'value':'', 'editor_icon':["GuiRadioUnchecked", "EditorIcons"]}
659 if "{" in search_text:
660 suggestions[search_text] = {'value':search_text, 'editor_icon':["Variant", "EditorIcons"]}
662 if character != null:
663 for portrait in character.portraits:
664 suggestions[portrait] = {'value':portrait, 'icon':icon}
669 static func get_portrait_position_suggestions(search_text := "") -> Dictionary:
670 var icon := load(DialogicUtil.get_module_path("Character").path_join('portrait_position.svg'))
672 var setting: String = ProjectSettings.get_setting('dialogic/portraits/position_suggestion_names', 'leftmost, left, center, right, rightmost')
674 var suggestions := {}
676 if not search_text.is_empty():
677 suggestions[search_text] = {'value':search_text.strip_edges(), 'editor_icon':["GuiScrollArrowRight", "EditorIcons"]}
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)