From 3c2bd781fc9f9581f2c83dac809fb693aef0d184 Mon Sep 17 00:00:00 2001 From: Salia Nifo Date: Thu, 7 Nov 2024 21:47:21 -0500 Subject: [PATCH] Combine Music/Sound event into single Audio event --- addons/dialogic/Core/DialogicResourceUtil.gd | 41 +++ addons/dialogic/Core/DialogicUtil.gd | 87 +++++ .../Events/Fields/field_options_dynamic.gd | 84 ++++- .../Events/Fields/field_options_dynamic.tscn | 10 +- .../TextEditor/timeline_editor_text.gd | 6 + .../VisualEditor/timeline_editor_visual.gd | 8 + .../Editor/TimelineEditor/timeline_editor.gd | 14 + addons/dialogic/Modules/Audio/event_audio.gd | 298 ++++++++++++++++++ addons/dialogic/Modules/Audio/event_music.gd | 105 ------ addons/dialogic/Modules/Audio/event_sound.gd | 86 ----- addons/dialogic/Modules/Audio/index.gd | 2 +- .../dialogic/Modules/Audio/settings_audio.gd | 253 ++++++++++++++- .../Modules/Audio/settings_audio.tscn | 45 +-- .../dialogic/Modules/Audio/subsystem_audio.gd | 267 +++++++++------- addons/dialogic/Modules/Clear/event_clear.gd | 7 +- 15 files changed, 967 insertions(+), 346 deletions(-) create mode 100644 addons/dialogic/Modules/Audio/event_audio.gd delete mode 100644 addons/dialogic/Modules/Audio/event_music.gd delete mode 100644 addons/dialogic/Modules/Audio/event_sound.gd diff --git a/addons/dialogic/Core/DialogicResourceUtil.gd b/addons/dialogic/Core/DialogicResourceUtil.gd index a86356cbf..afe1db648 100644 --- a/addons/dialogic/Core/DialogicResourceUtil.gd +++ b/addons/dialogic/Core/DialogicResourceUtil.gd @@ -1,6 +1,7 @@ @tool class_name DialogicResourceUtil +static var channel_cache := {} static var label_cache := {} static var event_cache: Array[DialogicEvent] = [] @@ -11,6 +12,7 @@ static func update() -> void: update_directory('.dch') update_directory('.dtl') update_label_cache() + update_channel_cache() #region RESOURCE DIRECTORIES @@ -139,6 +141,45 @@ static func update_label_cache() -> void: #endregion +#region CHANNEL CACHE +################################################################################ +# The channel cache is only for the editor so we don't have to scan all timelines +# whenever we want to suggest channels. This has no use in game and is not always perfect. + +static func get_channel_cache() -> Dictionary: + if not channel_cache.is_empty(): + return channel_cache + + channel_cache = DialogicUtil.get_editor_setting('channel_ref', {}) + return channel_cache + + +static func get_channel_list() -> Array: + if channel_cache.is_empty(): + return [] + + var cached_names := [] + for timeline in channel_cache: + for name in channel_cache[timeline]: + if not cached_names.has(name): + cached_names.append(name) + return cached_names + + +static func set_channel_cache(cache:Dictionary) -> void: + channel_cache = cache + + +static func update_channel_cache() -> void: + var cache := get_channel_cache() + var timelines := get_timeline_directory().values() + for timeline in cache: + if !timeline in timelines: + cache.erase(timeline) + set_channel_cache(cache) + +#endregion + #region EVENT CACHE ################################################################################ diff --git a/addons/dialogic/Core/DialogicUtil.gd b/addons/dialogic/Core/DialogicUtil.gd index 8abc1c636..fcb9cc5bc 100644 --- a/addons/dialogic/Core/DialogicUtil.gd +++ b/addons/dialogic/Core/DialogicUtil.gd @@ -682,3 +682,90 @@ static func get_portrait_position_suggestions(search_text := "") -> Dictionary: suggestions.erase(search_text) return suggestions + + +static func get_channel_suggestions(search_text:String, is_sync := false, event: DialogicAudioEvent = null) -> Dictionary: + if is_sync and event and event.channel_name.is_empty(): + return {} + + var suggestions := {} + var channel_defaults := DialogicUtil.get_channel_defaults() + var cached_names := DialogicResourceUtil.get_channel_list() + + for i in channel_defaults.keys(): + if not cached_names.has(i): + cached_names.append(i) + + cached_names.sort() + + for i in cached_names: + if i.is_empty(): + if is_sync: + suggestions['(No Sync)'] = { + 'value': i, + 'editor_icon': ["GuiRadioUnchecked", "EditorIcons"], + } + else: + suggestions['(One-Shot SFX)'] = { + 'value': i, + 'editor_icon': ["GuiRadioUnchecked", "EditorIcons"], + 'tooltip': "Used for one shot sounds effects. Plays each sound in its own AudioStreamPlayer." + } + + elif is_sync and event and event.channel_name == i: + continue + + elif i in channel_defaults.keys(): + suggestions[i] = { + 'value': i, + 'editor_icon': ["Favorites", "EditorIcons"], + } + + if not is_sync: + suggestions[i]['tooltip'] = 'This channel has default settings.' + + else: + suggestions[i] = { + 'value': i, + 'editor_icon': ["AudioStream", "EditorIcons"], + } + + return suggestions + + +static func get_channel_defaults() -> Dictionary: + return ProjectSettings.get_setting('dialogic/audio/channel_defaults', { + "": { + 'volume': 0.0, + 'audio_bus': '', + 'fade_length': 0.0, + 'loop': false, + }, + "music": { + 'volume': 0.0, + 'audio_bus': '', + 'fade_length': 0.0, + 'loop': true, + }}) + + +static var channel_name_regex := RegEx.create_from_string(r'(?^-$)|(?[^\w-]{1})') +static func validate_channel_name(text: String) -> Dictionary: + var result := {} + var matches := channel_name_regex.search_all(text) + var invalid_chars := [] + + for regex_match in matches: + if regex_match.get_string('dash_only'): + result['error_tooltip'] = "Channel name cannot be '-'." + result['valid_text'] = '' + else: + var invalid_char = regex_match.get_string('invalid') + if not invalid_char in invalid_chars: + invalid_chars.append(invalid_char) + + if invalid_chars: + result['valid_text'] = channel_name_regex.sub(text, '', true) + result['error_tooltip'] = "Channel names cannot contain the following characters: " + "".join(invalid_chars) + + return result diff --git a/addons/dialogic/Editor/Events/Fields/field_options_dynamic.gd b/addons/dialogic/Editor/Events/Fields/field_options_dynamic.gd index 16a315f14..7197b3d06 100644 --- a/addons/dialogic/Editor/Events/Fields/field_options_dynamic.gd +++ b/addons/dialogic/Editor/Events/Fields/field_options_dynamic.gd @@ -6,12 +6,13 @@ extends DialogicVisualEditorField ## SETTINGS @export var placeholder_text := "Select Resource" @export var empty_text := "" -enum Modes {PURE_STRING, PRETTY_PATH, IDENTIFIER} +enum Modes {PURE_STRING, PRETTY_PATH, IDENTIFIER, ANY_VALID_STRING} @export var mode := Modes.PURE_STRING @export var fit_text_length := true var collapse_when_empty := false var valid_file_drop_extension := "" var get_suggestions_func: Callable +var validation_func: Callable var resource_icon: Texture = null: get: @@ -21,8 +22,13 @@ var resource_icon: Texture = null: %Icon.texture = new_icon ## STATE -var current_value: String +var current_value: String: + set(value): + if current_value != value: + current_value = value + current_value_updated = true var current_selected := 0 +var current_value_updated := false ## SUGGESTIONS ITEM LIST var _v_separation := 0 @@ -38,12 +44,15 @@ var _max_height := 200 * DialogicUtil.get_editor_scale() func _set_value(value:Variant) -> void: if value == null or value.is_empty(): %Search.text = empty_text + update_error_tooltip('') else: match mode: Modes.PRETTY_PATH: %Search.text = DialogicUtil.pretty_name(value) Modes.IDENTIFIER when value.begins_with("res://"): %Search.text = DialogicResourceUtil.get_unique_identifier(value) + Modes.ANY_VALID_STRING when validation_func: + %Search.text = validation_func.call(value).get('valid_text', value) _: %Search.text = str(value) @@ -51,11 +60,11 @@ func _set_value(value:Variant) -> void: current_value = str(value) - func _load_display_info(info:Dictionary) -> void: valid_file_drop_extension = info.get('file_extension', '') collapse_when_empty = info.get('collapse_when_empty', false) get_suggestions_func = info.get('suggestions_func', get_suggestions_func) + validation_func = info.get('validation_func', validation_func) empty_text = info.get('empty_text', '') placeholder_text = info.get('placeholder', 'Select Resource') mode = info.get("mode", 0) @@ -101,16 +110,60 @@ func _ready() -> void: if resource_icon == null: self.resource_icon = null + var error_label_style := StyleBoxFlat.new() + error_label_style.bg_color = get_theme_color('background', 'Editor') + error_label_style.border_color = get_theme_color('error_color', 'Editor') + error_label_style.set_border_width_all(1) + error_label_style.set_corner_radius_all(4) + error_label_style.set_content_margin_all(6) + + %ErrorTooltip.add_theme_stylebox_override('normal', error_label_style) + func change_to_empty() -> void: + update_error_tooltip('') value_changed.emit(property_name, "") + +func validate() -> void: + if mode == Modes.ANY_VALID_STRING: + if validation_func: + var validation_result := validation_func.call(current_value) + current_value = validation_result.get('valid_text', current_value) + update_error_tooltip(validation_result.get('error_tooltip', '')) + + +func update_error_tooltip(text: String) -> void: + %ErrorTooltip.text = text + if text.is_empty(): + %ErrorTooltip.hide() + %Search.remove_theme_color_override("font_color") + else: + %ErrorTooltip.reset_size() + %ErrorTooltip.global_position = global_position - Vector2(0, %ErrorTooltip.size.y + 4) + %ErrorTooltip.show() + %Search.add_theme_color_override("font_color", get_theme_color('error_color', 'Editor')) + #endregion #region SEARCH & SUGGESTION POPUP ################################################################################ + func _on_Search_text_entered(new_text:String) -> void: + if mode == Modes.ANY_VALID_STRING: + if validation_func: + var validation_result := validation_func.call(new_text) + new_text = validation_result.get('valid_text', new_text) + update_error_tooltip(validation_result.get('error_tooltip', '')) + + set_value(new_text) + + value_changed.emit(property_name, current_value) + current_value_updated = false + hide_suggestions() + return + if %Suggestions.get_item_count(): if %Suggestions.is_anything_selected(): suggestion_selected(%Suggestions.get_selected_items()[0]) @@ -128,10 +181,27 @@ func _on_Search_text_changed(new_text:String, just_update:bool = false) -> void: else: %Search.show() + if mode == Modes.ANY_VALID_STRING and !just_update: + if validation_func: + var validation_result := validation_func.call(new_text) + new_text = validation_result.get('valid_text', new_text) + update_error_tooltip(validation_result.get('error_tooltip', '')) + + current_value = new_text + var suggestions: Dictionary = get_suggestions_func.call(new_text) var line_length := 0 var idx := 0 + + if new_text and mode == Modes.ANY_VALID_STRING and not new_text in suggestions.keys(): + %Suggestions.add_item(new_text, get_theme_icon('GuiScrollArrowRight', 'EditorIcons')) + %Suggestions.set_item_metadata(idx, new_text) + line_length = get_theme_font('font', 'Label').get_string_size( + new_text, HORIZONTAL_ALIGNMENT_LEFT, -1, get_theme_font_size("font_size", 'Label') + ).x + %Suggestions.fixed_icon_size.x * %Suggestions.get_icon_scale() + _icon_margin * 2 + _h_separation + idx += 1 + for element in suggestions: if new_text.is_empty() or new_text.to_lower() in element.to_lower() or new_text.to_lower() in str(suggestions[element].value).to_lower() or new_text.to_lower() in suggestions[element].get('tooltip', '').to_lower(): var curr_line_length: int = 0 @@ -196,10 +266,12 @@ func suggestion_selected(index: int, position := Vector2(), button_index := MOUS else: current_value = %Suggestions.get_item_metadata(index) + update_error_tooltip('') hide_suggestions() grab_focus() value_changed.emit(property_name, current_value) + current_value_updated = false func _input(event:InputEvent) -> void: @@ -256,12 +328,17 @@ func _on_search_focus_entered() -> void: _on_Search_text_changed("") %Search.call_deferred('select_all') %Focus.show() + validate() func _on_search_focus_exited() -> void: %Focus.hide() if !%Suggestions.get_global_rect().has_point(get_global_mouse_position()): hide_suggestions() + validate() + if current_value_updated: + value_changed.emit(property_name, current_value) + current_value_updated = false #endregion @@ -285,5 +362,6 @@ func _drop_data(position:Vector2, data:Variant) -> void: path = DialogicResourceUtil.get_unique_identifier(path) _set_value(path) value_changed.emit(property_name, path) + current_value_updated = false #endregion diff --git a/addons/dialogic/Editor/Events/Fields/field_options_dynamic.tscn b/addons/dialogic/Editor/Events/Fields/field_options_dynamic.tscn index 1f4e2200e..3a9bbc969 100644 --- a/addons/dialogic/Editor/Events/Fields/field_options_dynamic.tscn +++ b/addons/dialogic/Editor/Events/Fields/field_options_dynamic.tscn @@ -127,9 +127,17 @@ mouse_filter = 2 theme_override_styles/panel = SubResource("StyleBoxFlat_g74jb") metadata/_edit_use_anchors_ = true +[node name="ErrorTooltip" type="Label" parent="PanelContainer/Focus"] +unique_name_in_owner = true +visible = false +top_level = true +layout_mode = 0 +offset_left = -2.0 +offset_top = -44.5 +offset_right = 11.0 +offset_bottom = -9.5 [connection signal="focus_entered" from="." to="." method="_on_focus_entered"] [connection signal="focus_entered" from="PanelContainer/MarginContainer/HBoxContainer/Search" to="." method="_on_search_focus_entered"] [connection signal="focus_exited" from="PanelContainer/MarginContainer/HBoxContainer/Search" to="." method="_on_search_focus_exited"] [connection signal="gui_input" from="PanelContainer/MarginContainer/HBoxContainer/Search" to="." method="_on_search_gui_input"] -[connection signal="gui_input" from="PanelContainer/MarginContainer/HBoxContainer/Search/Suggestions" to="." method="_on_suggestions_gui_input"] [connection signal="toggled" from="PanelContainer/MarginContainer/HBoxContainer/SelectButton" to="." method="_on_SelectButton_toggled"] diff --git a/addons/dialogic/Editor/TimelineEditor/TextEditor/timeline_editor_text.gd b/addons/dialogic/Editor/TimelineEditor/TextEditor/timeline_editor_text.gd index 64ae34cd4..b483bf5f5 100644 --- a/addons/dialogic/Editor/TimelineEditor/TextEditor/timeline_editor_text.gd +++ b/addons/dialogic/Editor/TimelineEditor/TextEditor/timeline_editor_text.gd @@ -7,6 +7,7 @@ extends CodeEdit @onready var code_completion_helper: Node= find_parent('EditorsManager').get_node('CodeCompletionHelper') var label_regex := RegEx.create_from_string('label +(?[^\n]+)') +var channel_regex := RegEx.create_from_string(r'audio +(?[\w-]{2,}|[\w]+)') func _ready() -> void: await find_parent('EditorView').ready @@ -211,6 +212,11 @@ func update_content_list() -> void: labels.append(i.get_string('name')) timeline_editor.editors_manager.sidebar.update_content_list(labels) + var channels: PackedStringArray = [] + for i in channel_regex.search_all(text): + channels.append(i.get_string('channel')) + timeline_editor.update_channel_cache(channels) + func _on_content_item_clicked(label:String) -> void: if label == "~ Top": diff --git a/addons/dialogic/Editor/TimelineEditor/VisualEditor/timeline_editor_visual.gd b/addons/dialogic/Editor/TimelineEditor/VisualEditor/timeline_editor_visual.gd index 71ea1850f..6bcc3d4c0 100644 --- a/addons/dialogic/Editor/TimelineEditor/VisualEditor/timeline_editor_visual.gd +++ b/addons/dialogic/Editor/TimelineEditor/VisualEditor/timeline_editor_visual.gd @@ -283,6 +283,7 @@ func update_content_list() -> void: if not is_inside_tree(): return + var channels: PackedStringArray = [] var labels: PackedStringArray = [] for event in %Timeline.get_children(): @@ -290,7 +291,12 @@ func update_content_list() -> void: if 'event_name' in event.resource and event.resource is DialogicLabelEvent: labels.append(event.resource.name) + if 'event_name' in event.resource and event.resource is DialogicAudioEvent: + if not event.resource.channel_name in channels: + channels.append(event.resource.channel_name) + timeline_editor.editors_manager.sidebar.update_content_list(labels) + timeline_editor.update_channel_cache(channels) #endregion @@ -362,6 +368,8 @@ func add_event_node(event_resource:DialogicEvent, at_index:int = -1, auto_select if event_resource.event_name == "Label": block.content_changed.connect(update_content_list) + if event_resource.event_name == "Audio": + block.content_changed.connect(update_content_list) if at_index == -1: if len(selected_items) != 0: selected_items[0].add_sibling(block) diff --git a/addons/dialogic/Editor/TimelineEditor/timeline_editor.gd b/addons/dialogic/Editor/TimelineEditor/timeline_editor.gd index ab6538b55..dcdf98b01 100644 --- a/addons/dialogic/Editor/TimelineEditor/timeline_editor.gd +++ b/addons/dialogic/Editor/TimelineEditor/timeline_editor.gd @@ -155,6 +155,20 @@ func new_timeline(path:String) -> void: editors_manager.edit_resource(new_timeline) +func update_channel_cache(list:PackedStringArray) -> void: + var timeline_directory := DialogicResourceUtil.get_timeline_directory() + var channel_directory := DialogicResourceUtil.get_channel_cache() + if current_resource != null: + for i in timeline_directory: + if timeline_directory[i] == current_resource.resource_path: + channel_directory[i] = list + + # also always store the current timelines channels for easy access + channel_directory[""] = list + + DialogicResourceUtil.set_channel_cache(channel_directory) + + func _ready() -> void: $NoTimelineScreen.add_theme_stylebox_override("panel", get_theme_stylebox("Background", "EditorStyles")) diff --git a/addons/dialogic/Modules/Audio/event_audio.gd b/addons/dialogic/Modules/Audio/event_audio.gd new file mode 100644 index 000000000..35e93fb92 --- /dev/null +++ b/addons/dialogic/Modules/Audio/event_audio.gd @@ -0,0 +1,298 @@ +@tool +## Event that can change the currently playing background music. +## This event won't play new music if it's already playing. +class_name DialogicAudioEvent +extends DialogicEvent + +### Settings + +## The file to play. If empty, the previous music will be faded out. +var file_path := "": + set(value): + if file_path != value: + file_path = value + ui_update_needed.emit() +## The channel name to use. +var channel_name := "": + set(value): + if channel_name != channel_name_regex.sub(value, '', true): + channel_name = channel_name_regex.sub(value, '', true) + var defaults := DialogicUtil.get_channel_defaults().get(channel_name, {}) + if defaults: + fade_length = defaults.fade_length + volume = defaults.volume + audio_bus = defaults.audio_bus + loop = defaults.loop + ui_update_needed.emit() +## Sync starting time with different channel (if playing audio on that channel) +var sync_channel := "" +## The length of the fade. If 0 (by default) it's an instant change. +var fade_length: float = 0.0 +## The volume the music will be played at. +var volume: float = 0.0 +## The audio bus the music will be played at. +var audio_bus := "" +## If true, the audio will loop, otherwise only play once. +var loop := true + + +var regex := RegEx.create_from_string(r'(?:audio)\s*(?[\w-]{2,}|[\w]*)?\s*(")?(?(?(2)[^"\n]*|[^(: \n]*))(?(2)"|)(?:\s*\[(?.*)\])?') +var channel_name_regex := RegEx.create_from_string(r'(?^-$)|(?[^\w-]{1})') + +################################################################################ +## EXECUTE +################################################################################ + +func _execute() -> void: + if channel_name.is_empty(): + if file_path.is_empty(): + dialogic.Audio.stop_all_sounds() + else: + dialogic.Audio.play_sound(file_path, volume, audio_bus) + elif not dialogic.Audio.is_audio_playing_resource(file_path, channel_name): + dialogic.Audio.update_audio(channel_name, file_path, volume, audio_bus, fade_length, loop, sync_channel) + + + finish() + +################################################################################ +## INITIALIZE +################################################################################ + +func _init() -> void: + event_name = "Audio" + set_default_color('Color7') + event_category = "Audio" + event_sorting_index = 2 + + +func _get_icon() -> Resource: + return load(this_folder.path_join('icon_music.png')) + +################################################################################ +## SAVING/LOADING +################################################################################ + +func to_text () -> String: + var result_string := "audio " + + if not channel_name.is_empty(): + result_string += channel_name + " " + else: + loop = false + + if not file_path.is_empty(): + result_string += "\"" + file_path + "\"" + else: + result_string += "-" + + var shortcode := store_to_shortcode_parameters() + if not shortcode.is_empty(): + result_string += " [" + shortcode + "]" + + return result_string + + +func from_text(string:String) -> void: + # Pre Alpha 17 Conversion + if string.begins_with('[music'): + _music_from_text(string) + return + elif string.begins_with('[sound'): + _sound_from_text(string) + return + + var result := regex.search(string) + + channel_name = result.get_string('channel') + + if result.get_string('file_path') == '-': + file_path = "" + else: + file_path = result.get_string('file_path') + + if not result.get_string('shortcode'): + return + + load_from_shortcode_parameters(result.get_string('shortcode')) + + +func get_shortcode_parameters() -> Dictionary: + return { + #param_name : property_info + "path" : {"property": "file_path", "default": "", "custom_stored":true}, + "channel" : {"property": "channel_name", "default": "", "custom_stored":true}, + "sync" : {"property": "sync_channel", "default": ""}, + "fade" : {"property": "fade_length", "default": 0.0}, + "volume" : {"property": "volume", "default": 0.0}, + "bus" : {"property": "audio_bus", "default": "", + "suggestions": get_bus_suggestions}, + "loop" : {"property": "loop", "default": true}, + } + + +## Returns a string with all the shortcode parameters. +func store_to_shortcode_parameters(params:Dictionary = {}) -> String: + if params.is_empty(): + params = get_shortcode_parameters() + var custom_defaults: Dictionary = DialogicUtil.get_custom_event_defaults(event_name) + var channel_defaults := DialogicUtil.get_channel_defaults() + var result_string := "" + for parameter in params.keys(): + var parameter_info: Dictionary = params[parameter] + var value: Variant = get(parameter_info.property) + var default_value: Variant = custom_defaults.get(parameter_info.property, parameter_info.default) + + if parameter_info.get('custom_stored', false): + continue + + if "set_" + parameter_info.property in self and not get("set_" + parameter_info.property): + continue + + if channel_name in channel_defaults.keys(): + default_value = channel_defaults[channel_name].get(parameter_info.property, default_value) + + if typeof(value) == typeof(default_value) and value == default_value: + if not "set_" + parameter_info.property in self or not get("set_" + parameter_info.property): + continue + + result_string += " " + parameter + '="' + value_to_string(value, parameter_info.get("suggestions", Callable())) + '"' + + return result_string.strip_edges() + + +func is_valid_event(string:String) -> bool: + if string.begins_with("audio"): + return true + # Pre Alpha 17 Converter + if string.strip_edges().begins_with('[music ') or string.strip_edges().begins_with('[music]'): + return true + if string.strip_edges().begins_with('[sound ') or string.strip_edges().begins_with('[sound]'): + return true + return false + +#region PreAlpha17 Conversion + +func _music_from_text(string:String) -> void: + var data := parse_shortcode_parameters(string) + + if data.has('channel') and data['channel'].to_int() > 0: + channel_name = 'music' + str(data['channel'].to_int() + 1) + else: + channel_name = 'music' + + # Reapply original defaults as setting channel name may have overridden them + fade_length = 0.0 + volume = 0.0 + audio_bus = '' + loop = true + + # Apply any custom event defaults + for default_prop in DialogicUtil.get_custom_event_defaults('music'): + if default_prop in self: + set(default_prop, DialogicUtil.get_custom_event_defaults('music')[default_prop]) + + # Apply shortcodes that exist + if data.has('path'): + file_path = data['path'] + if data.has('fade'): + fade_length = data['fade'].to_float() + if data.has('volume'): + volume = data['volume'].to_float() + if data.has('bus'): + audio_bus = data['bus'] + if data.has('loop'): + loop = str_to_var(data['loop']) + + +func _sound_from_text(string:String) -> void: + var data := parse_shortcode_parameters(string) + + channel_name = '' + + # Reapply original defaults as setting channel name may have overridden them + fade_length = 0.0 + volume = 0.0 + audio_bus = '' + loop = false + + # Apply any custom event defaults + for default_prop in DialogicUtil.get_custom_event_defaults('sound'): + if default_prop in self: + set(default_prop, DialogicUtil.get_custom_event_defaults('sound')[default_prop]) + + # Apply shortcodes that exist + if data.has('path'): + file_path = data['path'] + if data.has('volume'): + volume = data['volume'].to_float() + if data.has('bus'): + audio_bus = data['bus'] + if data.has('loop'): + loop = str_to_var(data['loop']) + +#endregion + +################################################################################ +## EDITOR REPRESENTATION +################################################################################ + +func build_event_editor() -> void: + add_header_edit('file_path', ValueType.FILE, { + 'left_text' : 'Play', + 'file_filter' : "*.mp3, *.ogg, *.wav; Supported Audio Files", + 'placeholder' : "Silence", + 'editor_icon' : ["AudioStreamPlayer", "EditorIcons"]}) + add_header_edit('file_path', ValueType.AUDIO_PREVIEW) + + add_header_edit('channel_name', ValueType.DYNAMIC_OPTIONS, { + 'left_text' :'on:', + 'placeholder' : '(One-Shot SFX)', + 'mode' : 3, + 'suggestions_func' : DialogicUtil.get_channel_suggestions.bind(false, self), + 'validation_func' : DialogicUtil.validate_channel_name, + 'tooltip' : 'Use an existing channel or type the name for a new channel.', + }) + add_header_button('', _update_defaults_for_channel, 'Add/Update defaults for this channel', + editor_node.get_theme_icon('Favorites', 'EditorIcons'), '!file_path.is_empty()') + + add_header_edit('sync_channel', ValueType.DYNAMIC_OPTIONS, { + 'left_text' :'sync with:', + 'placeholder' : '(No Sync)', + 'mode' : 3, + 'suggestions_func' : DialogicUtil.get_channel_suggestions.bind(true, self), + 'validation_func' : DialogicUtil.validate_channel_name, + 'tooltip' : "Use an existing channel or type the name for a new channel. If channel doesn't exist, this setting will be ignored.", + }, '!channel_name.is_empty() and !file_path.is_empty()') + + add_body_edit('fade_length', ValueType.NUMBER, {'left_text':'Fade Time:'}, '!channel_name.is_empty()') + add_body_edit('volume', ValueType.NUMBER, {'left_text':'Volume:', 'mode':2}, '!file_path.is_empty()') + add_body_edit('audio_bus', ValueType.DYNAMIC_OPTIONS, { + 'left_text':'Audio Bus:', + 'placeholder' : 'Master', + 'mode' : 2, + 'suggestions_func' : get_bus_suggestions, + }, '!file_path.is_empty()') + add_body_edit('loop', ValueType.BOOL, {'left_text':'Loop:'}, '!channel_name.is_empty() and !file_path.is_empty()') + + +func _update_defaults_for_channel() -> void: + var defaults := DialogicUtil.get_channel_defaults() + defaults[channel_name] = { + 'volume': volume, + 'audio_bus': audio_bus, + 'fade_length': fade_length, + 'loop': loop, + } + ProjectSettings.set_setting('dialogic/audio/channel_defaults', defaults) + ProjectSettings.save() + + +func get_bus_suggestions(search_text:String) -> Dictionary: + var bus_name_list := {} + for i in range(AudioServer.bus_count): + if i == 0: + bus_name_list[AudioServer.get_bus_name(i)] = {'value':''} + else: + bus_name_list[AudioServer.get_bus_name(i)] = {'value':AudioServer.get_bus_name(i)} + return bus_name_list diff --git a/addons/dialogic/Modules/Audio/event_music.gd b/addons/dialogic/Modules/Audio/event_music.gd deleted file mode 100644 index b7b096e12..000000000 --- a/addons/dialogic/Modules/Audio/event_music.gd +++ /dev/null @@ -1,105 +0,0 @@ -@tool -## Event that can change the currently playing background music. -## This event won't play new music if it's already playing. -class_name DialogicMusicEvent -extends DialogicEvent - - -### Settings - -## The file to play. If empty, the previous music will be faded out. -var file_path := "": - set(value): - if file_path != value: - file_path = value - ui_update_needed.emit() -## The channel to use. -var channel_id: int = 0 -## The length of the fade. If 0 (by default) it's an instant change. -var fade_length: float = 0 -## The volume the music will be played at. -var volume: float = 0 -## The audio bus the music will be played at. -var audio_bus := "" -## If true, the audio will loop, otherwise only play once. -var loop := true - - -################################################################################ -## EXECUTE -################################################################################ - -func _execute() -> void: - if not dialogic.Audio.is_music_playing_resource(file_path, channel_id): - dialogic.Audio.update_music(file_path, volume, audio_bus, fade_length, loop, channel_id) - - finish() - -################################################################################ -## INITIALIZE -################################################################################ - -func _init() -> void: - event_name = "Music" - set_default_color('Color7') - event_category = "Audio" - event_sorting_index = 2 - - -func _get_icon() -> Resource: - return load(self.get_script().get_path().get_base_dir().path_join('icon_music.png')) - -################################################################################ -## SAVING/LOADING -################################################################################ - -func get_shortcode() -> String: - return "music" - - -func get_shortcode_parameters() -> Dictionary: - return { - #param_name : property_info - "path" : {"property": "file_path", "default": ""}, - "channel" : {"property": "channel_id", "default": 0}, - "fade" : {"property": "fade_length", "default": 0}, - "volume" : {"property": "volume", "default": 0}, - "bus" : {"property": "audio_bus", "default": "", - "suggestions": get_bus_suggestions}, - "loop" : {"property": "loop", "default": true}, - } - - -################################################################################ -## EDITOR REPRESENTATION -################################################################################ - -func build_event_editor() -> void: - add_header_edit('file_path', ValueType.FILE, { - 'left_text' : 'Play', - 'file_filter' : "*.mp3, *.ogg, *.wav; Supported Audio Files", - 'placeholder' : "No music", - 'editor_icon' : ["AudioStreamPlayer", "EditorIcons"]}) - add_header_edit('channel_id', ValueType.FIXED_OPTIONS, {'left_text':'on:', 'options': get_channel_list()}) - add_header_edit('file_path', ValueType.AUDIO_PREVIEW) - add_body_edit('fade_length', ValueType.NUMBER, {'left_text':'Fade Time:'}) - add_body_edit('volume', ValueType.NUMBER, {'left_text':'Volume:', 'mode':2}, '!file_path.is_empty()') - add_body_edit('audio_bus', ValueType.SINGLELINE_TEXT, {'left_text':'Audio Bus:'}, '!file_path.is_empty()') - add_body_edit('loop', ValueType.BOOL, {'left_text':'Loop:'}, '!file_path.is_empty() and not file_path.to_lower().ends_with(".wav")') - - -func get_bus_suggestions() -> Dictionary: - var bus_name_list := {} - for i in range(AudioServer.bus_count): - bus_name_list[AudioServer.get_bus_name(i)] = {'value':AudioServer.get_bus_name(i)} - return bus_name_list - - -func get_channel_list() -> Array: - var channel_name_list := [] - for i in ProjectSettings.get_setting('dialogic/audio/max_channels', 4): - channel_name_list.append({ - 'label': 'Channel %s' % (i + 1), - 'value': i, - }) - return channel_name_list diff --git a/addons/dialogic/Modules/Audio/event_sound.gd b/addons/dialogic/Modules/Audio/event_sound.gd deleted file mode 100644 index 0784bbf21..000000000 --- a/addons/dialogic/Modules/Audio/event_sound.gd +++ /dev/null @@ -1,86 +0,0 @@ -@tool -class_name DialogicSoundEvent -extends DialogicEvent - -## Event that allows to play a sound effect. Requires the Audio subsystem! - - -### Settings - -## The path to the file to play. -var file_path := "": - set(value): - if file_path != value: - file_path = value - ui_update_needed.emit() -## The volume to play the sound at. -var volume: float = 0 -## The bus to play the sound on. -var audio_bus := "" -## If true, the sound will loop infinitely. Not recommended (as there is no way to stop it). -var loop := false - - -################################################################################ -## EXECUTE -################################################################################ - -func _execute() -> void: - dialogic.Audio.play_sound(file_path, volume, audio_bus, loop) - finish() - - -################################################################################ -## INITIALIZE -################################################################################ - -func _init() -> void: - event_name = "Sound" - set_default_color('Color7') - event_category = "Audio" - event_sorting_index = 3 - help_page_path = "https://dialogic.coppolaemilio.com" - - -func _get_icon() -> Resource: - return load(self.get_script().get_path().get_base_dir().path_join('icon_sound.png')) - -################################################################################ -## SAVING/LOADING -################################################################################ - -func get_shortcode() -> String: - return "sound" - - -func get_shortcode_parameters() -> Dictionary: - return { - #param_name : property_name - "path" : {"property": "file_path", "default": "",}, - "volume" : {"property": "volume", "default": 0}, - "bus" : {"property": "audio_bus", "default": "", - "suggestions": get_bus_suggestions}, - "loop" : {"property": "loop", "default": false}, - } - - -################################################################################ -## EDITOR REPRESENTATION -################################################################################ - -func build_event_editor() -> void: - add_header_edit('file_path', ValueType.FILE, - {'left_text' : 'Play', - 'file_filter' : '*.mp3, *.ogg, *.wav; Supported Audio Files', - 'placeholder' : "Select file", - 'editor_icon' : ["AudioStreamPlayer", "EditorIcons"]}) - add_header_edit('file_path', ValueType.AUDIO_PREVIEW) - add_body_edit('volume', ValueType.NUMBER, {'left_text':'Volume:', 'mode':2}, '!file_path.is_empty()') - add_body_edit('audio_bus', ValueType.SINGLELINE_TEXT, {'left_text':'Audio Bus:'}, '!file_path.is_empty()') - - -func get_bus_suggestions() -> Dictionary: - var bus_name_list := {} - for i in range(AudioServer.bus_count): - bus_name_list[AudioServer.get_bus_name(i)] = {'value':AudioServer.get_bus_name(i)} - return bus_name_list diff --git a/addons/dialogic/Modules/Audio/index.gd b/addons/dialogic/Modules/Audio/index.gd index f2ff1270a..61ca7fa7e 100644 --- a/addons/dialogic/Modules/Audio/index.gd +++ b/addons/dialogic/Modules/Audio/index.gd @@ -3,7 +3,7 @@ extends DialogicIndexer func _get_events() -> Array: - return [this_folder.path_join('event_music.gd'), this_folder.path_join('event_sound.gd')] + return [this_folder.path_join('event_audio.gd')] func _get_subsystems() -> Array: diff --git a/addons/dialogic/Modules/Audio/settings_audio.gd b/addons/dialogic/Modules/Audio/settings_audio.gd index d7afd3c3d..69e5feea2 100644 --- a/addons/dialogic/Modules/Audio/settings_audio.gd +++ b/addons/dialogic/Modules/Audio/settings_audio.gd @@ -3,16 +3,18 @@ extends DialogicSettingsPage ## Settings page that contains settings for the audio subsystem -const MUSIC_MAX_CHANNELS := "dialogic/audio/max_channels" const TYPE_SOUND_AUDIO_BUS := "dialogic/audio/type_sound_bus" +const CHANNEL_DEFAULTS := "dialogic/audio/channel_defaults" + +var channel_defaults := {} +var _revalidate_channel_names := false + func _ready() -> void: - %MusicChannelCount.value_changed.connect(_on_music_channel_count_value_changed) %TypeSoundBus.item_selected.connect(_on_type_sound_bus_item_selected) func _refresh() -> void: - %MusicChannelCount.value = ProjectSettings.get_setting(MUSIC_MAX_CHANNELS, 4) %TypeSoundBus.clear() var idx := 0 for i in range(AudioServer.bus_count): @@ -21,12 +23,251 @@ func _refresh() -> void: idx = i %TypeSoundBus.select(idx) + load_channel_defaults(DialogicUtil.get_channel_defaults()) -func _on_music_channel_count_value_changed(value:float) -> void: - ProjectSettings.set_setting(MUSIC_MAX_CHANNELS, value) - ProjectSettings.save() + +func _about_to_close() -> void: + save_channel_defaults() func _on_type_sound_bus_item_selected(index:int) -> void: ProjectSettings.set_setting(TYPE_SOUND_AUDIO_BUS, %TypeSoundBus.get_item_text(index)) ProjectSettings.save() + + +## CHANNEL DEFAULTS +################################################################################ + +func load_channel_defaults(dictionary:Dictionary) -> void: + channel_defaults.clear() + for i in %AudioChannelDefaults.get_children(): + i.queue_free() + + var column_names := [ + "Channel Name", + "Volume", + "Audio Bus", + "Fade", + "Loop", + "" + ] + + for column in column_names: + var label := Label.new() + label.text = column + label.theme_type_variation = 'DialogicHintText2' + %AudioChannelDefaults.add_child(label) + + var channel_names := dictionary.keys() + channel_names.sort() + + for channel_name in channel_names: + add_channel_defaults( + channel_name, + dictionary[channel_name].volume, + dictionary[channel_name].audio_bus, + dictionary[channel_name].fade_length, + dictionary[channel_name].loop) + + await get_tree().process_frame + + _revalidate_channel_names = true + revalidate_channel_names.call_deferred() + + +func save_channel_defaults() -> void: + var dictionary := {} + + for i in channel_defaults: + if is_instance_valid(channel_defaults[i].channel_name): + var channel_name := "" + if not channel_defaults[i].channel_name is Label: + if channel_defaults[i].channel_name.current_value.is_empty(): + continue + + channel_name = channel_defaults[i].channel_name.current_value + channel_name = DialogicUtil.channel_name_regex.sub(channel_name, '', true) + + if channel_name.is_empty(): + dictionary[channel_name] = { + 'volume': channel_defaults[i].volume.get_value(), + 'audio_bus': channel_defaults[i].audio_bus.current_value, + 'fade_length': 0.0, + 'loop': false, + } + else: + dictionary[channel_name] = { + 'volume': channel_defaults[i].volume.get_value(), + 'audio_bus': channel_defaults[i].audio_bus.current_value, + 'fade_length': channel_defaults[i].fade_length.get_value(), + 'loop': channel_defaults[i].loop.button_pressed, + } + + ProjectSettings.set_setting(CHANNEL_DEFAULTS, dictionary) + ProjectSettings.save() + + +func _on_add_channel_defaults_pressed() -> void: + var added_node := add_channel_defaults('new_channel_name', 0.0, '', 0.0, true) + if added_node: + added_node.take_autofocus() + _revalidate_channel_names = true + revalidate_channel_names.call_deferred() + + +func add_channel_defaults(channel_name: String, volume: float, audio_bus: String, fade: float, loop: bool) -> Control: + var info := {} + + if channel_name.is_empty(): + var channel_label = Label.new() + channel_label.text = 'One-Shot SFX' + channel_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + info['channel_name'] = channel_label + %AudioChannelDefaults.add_child(channel_label) + else: + var channel_options := preload("res://addons/dialogic/Editor/Events/Fields/field_options_dynamic.tscn").instantiate() + channel_options._load_display_info({ + 'placeholder' : 'Enter channel name', + 'mode' : 3, + 'suggestions_func' : get_channel_suggestions, + 'validation_func' : validate_channel_names.bind(channel_options) + }) + channel_options.set_value(channel_name) + channel_options.size_flags_horizontal = Control.SIZE_EXPAND_FILL + info['channel_name'] = channel_options + %AudioChannelDefaults.add_child(channel_options) + + var volume_field := preload("res://addons/dialogic/Editor/Events/Fields/field_number.tscn").instantiate() + volume_field.use_decibel_mode(0.1) + volume_field.set_value(volume) + info['volume'] = volume_field + %AudioChannelDefaults.add_child(volume_field) + + var bus_options := preload("res://addons/dialogic/Editor/Events/Fields/field_options_dynamic.tscn").instantiate() + bus_options._load_display_info({ + 'placeholder' : 'Master', + 'mode' : 2, + 'suggestions_func' : get_bus_suggestions + }) + bus_options.set_value(audio_bus) + info['audio_bus'] = bus_options + %AudioChannelDefaults.add_child(bus_options) + + if channel_name.is_empty(): + var fade_disabled := TextureRect.new() + fade_disabled.texture = get_theme_icon('NodeInfo', 'EditorIcons') + fade_disabled.stretch_mode = TextureRect.STRETCH_KEEP_CENTERED + fade_disabled.set_anchors_preset(Control.PRESET_FULL_RECT) + fade_disabled.tooltip_text = "Fading is disbaled for this channel." + info['fade_length'] = fade_disabled + %AudioChannelDefaults.add_child(fade_disabled) + + info['loop'] = fade_disabled.duplicate() + info['loop'].tooltip_text = "Looping is disabled for this channel." + %AudioChannelDefaults.add_child(info['loop']) + else: + var fade_field := preload("res://addons/dialogic/Editor/Events/Fields/field_number.tscn").instantiate() + fade_field.use_float_mode(0.1) + fade_field.set_value(fade) + fade_field.min = 0.0 + info['fade_length'] = fade_field + %AudioChannelDefaults.add_child(fade_field) + + var loop_button := CheckButton.new() + loop_button.set_pressed_no_signal(loop) + info['loop'] = loop_button + %AudioChannelDefaults.add_child(loop_button) + + var remove_btn := Button.new() + remove_btn.icon = get_theme_icon(&'Remove', &'EditorIcons') + remove_btn.pressed.connect(_on_remove_channel_defaults_pressed.bind(len(channel_defaults))) + remove_btn.disabled = channel_name.is_empty() + info['delete'] = remove_btn + %AudioChannelDefaults.add_child(remove_btn) + channel_defaults[len(channel_defaults)] = info + + return info['channel_name'] + + +func _on_remove_channel_defaults_pressed(index: int) -> void: + for key in channel_defaults[index]: + channel_defaults[index][key].queue_free() + channel_defaults.erase(index) + + +func get_bus_suggestions(search_text:String) -> Dictionary: + var bus_name_list := {} + for i in range(AudioServer.bus_count): + bus_name_list[AudioServer.get_bus_name(i)] = {'value':AudioServer.get_bus_name(i)} + return bus_name_list + + +func get_channel_suggestions(search_text:String) -> Dictionary: + var suggestions := DialogicUtil.get_channel_suggestions(search_text) + + var suggestion_values := [] + for key in suggestions.keys(): + if suggestions[key].value: + suggestion_values.append(suggestions[key].value) + else: + suggestions.erase(key) + + for i in channel_defaults: + if (is_instance_valid(channel_defaults[i].channel_name) + and not channel_defaults[i].channel_name is Label + and channel_defaults[i].channel_name.current_value in suggestion_values): + suggestions.erase(channel_defaults[i].channel_name.current_value) + + for key in suggestions.keys(): + suggestions[key].erase('tooltip') + suggestions[key]['editor_icon'] = ["AudioStream", "EditorIcons"] + + return suggestions + +func revalidate_channel_names() -> void: + _revalidate_channel_names = false + for i in channel_defaults: + if (is_instance_valid(channel_defaults[i].channel_name) + and not channel_defaults[i].channel_name is Label): + channel_defaults[i].channel_name.validate() + + +func validate_channel_names(search_text: String, field_node: Control) -> Dictionary: + var channel_cache = {} + var result := {} + var tooltips := [] + + if search_text.is_empty(): + result['error_tooltip'] = 'Must not be empty.' + return result + + if field_node: + channel_cache[search_text] = [field_node] + if field_node.current_value != search_text: + _revalidate_channel_names = true + revalidate_channel_names.call_deferred() + + # Collect all channel names entered + for i in channel_defaults: + if (is_instance_valid(channel_defaults[i].channel_name) + and not channel_defaults[i].channel_name is Label + and channel_defaults[i].channel_name != field_node): + var text := channel_defaults[i].channel_name.current_value as String + if not channel_cache.has(text): + channel_cache[text] = [] + + channel_cache[text].append(channel_defaults[i].channel_name) + + # Check for duplicate names + if channel_cache.has(search_text) and channel_cache[search_text].size() > 1: + tooltips.append("Duplicate channel name.") + + # Check for invalid characters + result = DialogicUtil.validate_channel_name(search_text) + if result: + tooltips.append(result.error_tooltip) + result.error_tooltip = "\n".join(tooltips) + elif not tooltips.is_empty(): + result['error_tooltip'] = "\n".join(tooltips) + + return result diff --git a/addons/dialogic/Modules/Audio/settings_audio.tscn b/addons/dialogic/Modules/Audio/settings_audio.tscn index c8eb8bad0..a2ebce8d3 100644 --- a/addons/dialogic/Modules/Audio/settings_audio.tscn +++ b/addons/dialogic/Modules/Audio/settings_audio.tscn @@ -8,47 +8,50 @@ offset_right = 121.0 offset_bottom = 58.0 script = ExtResource("1_2iyyr") -[node name="Label" type="Label" parent="."] +[node name="TypingSoundsTitle" type="Label" parent="."] layout_mode = 2 theme_type_variation = &"DialogicSettingsSection" -text = "Music Channels" +text = "Typing Sounds" -[node name="HBoxContainer" type="HBoxContainer" parent="."] +[node name="HBoxContainer2" type="HBoxContainer" parent="."] layout_mode = 2 -[node name="Label" type="Label" parent="HBoxContainer"] +[node name="Label" type="Label" parent="HBoxContainer2"] layout_mode = 2 -text = "Max music channels" +text = "Audio Bus" -[node name="HintTooltip" parent="HBoxContainer" instance=ExtResource("2_o1ban")] +[node name="HintTooltip" parent="HBoxContainer2" instance=ExtResource("2_o1ban")] layout_mode = 2 texture = null -hint_text = "Lowering this value may invalidate existing music events!" +hint_text = "The default audio bus used by TypeSound nodes." -[node name="MusicChannelCount" type="SpinBox" parent="HBoxContainer"] +[node name="TypeSoundBus" type="OptionButton" parent="HBoxContainer2"] unique_name_in_owner = true layout_mode = 2 -min_value = 1.0 -value = 1.0 -[node name="TypingSoundsTitle" type="Label" parent="."] +[node name="HBoxContainer3" type="HBoxContainer" parent="."] layout_mode = 2 -theme_type_variation = &"DialogicSettingsSection" -text = "Typing Sounds" -[node name="HBoxContainer2" type="HBoxContainer" parent="."] +[node name="Label" type="Label" parent="HBoxContainer3"] layout_mode = 2 +theme_type_variation = &"DialogicSettingsSection" +text = "Audio Channel Defaults" -[node name="Label" type="Label" parent="HBoxContainer2"] +[node name="HintTooltip" parent="HBoxContainer3" instance=ExtResource("2_o1ban")] layout_mode = 2 -text = "Audio Bus" +texture = null +hint_text = "Default settings for named audio channels." -[node name="HintTooltip" parent="HBoxContainer2" instance=ExtResource("2_o1ban")] +[node name="Add" type="Button" parent="HBoxContainer3"] layout_mode = 2 -tooltip_text = "Lowering this value may invalidate existing music events!" -texture = null -hint_text = "The default audio bus used by TypeSound nodes." +size_flags_horizontal = 8 +size_flags_vertical = 4 +text = "Add channel" -[node name="TypeSoundBus" type="OptionButton" parent="HBoxContainer2"] +[node name="AudioChannelDefaults" type="GridContainer" parent="."] unique_name_in_owner = true layout_mode = 2 +size_flags_horizontal = 3 +columns = 6 + +[connection signal="pressed" from="HBoxContainer3/Add" to="." method="_on_add_channel_defaults_pressed"] diff --git a/addons/dialogic/Modules/Audio/subsystem_audio.gd b/addons/dialogic/Modules/Audio/subsystem_audio.gd index 18db7282d..dccf6644d 100644 --- a/addons/dialogic/Modules/Audio/subsystem_audio.gd +++ b/addons/dialogic/Modules/Audio/subsystem_audio.gd @@ -1,57 +1,30 @@ extends DialogicSubsystem -## Subsystem for managing background music and one-shot sound effects. +## Subsystem for managing background audio and one-shot sound effects. ## ## This subsystem has many different helper methods for managing audio ## in your timeline. -## For instance, you can listen to music changes via [signal music_started]. +## For instance, you can listen to audio changes via [signal audio_started]. -## Whenever a new background music is started, this signal is emitted and +## Whenever a new audio event is started, this signal is emitted and ## contains a dictionary with the following keys: [br] ## [br] ## Key | Value Type | Value [br] ## ----------- | ------------- | ----- [br] ## `path` | [type String] | The path to the audio resource file. [br] -## `volume` | [type float] | The volume of the audio resource that will be set to the [member base_music_player]. [br] -## `audio_bus` | [type String] | The audio bus name that the [member base_music_player] will use. [br] +## `volume` | [type float] | The volume in `db` of the audio resource that will be set to the [AudioStreamPlayer]. [br] +## `audio_bus` | [type String] | The audio bus name that the [AudioStreamPlayer] will use. [br] ## `loop` | [type bool] | Whether the audio resource will loop or not once it finishes playing. [br] -## `channel` | [type int] | The channel ID to play the audio on. [br] -signal music_started(info: Dictionary) +## `channel` | [type String] | The channel name to play the audio on. [br] +signal audio_started(info: Dictionary) -## Whenever a new sound effect is set, this signal is emitted and contains a -## dictionary with the following keys: [br] -## [br] -## Key | Value Type | Value [br] -## ----------- | ------------- | ----- [br] -## `path` | [type String] | The path to the audio resource file. [br] -## `volume` | [type float] | The volume of the audio resource that will be set to [member base_sound_player]. [br] -## `audio_bus` | [type String] | The audio bus name that the [member base_sound_player] will use. [br] -## `loop` | [type bool] | Whether the audio resource will loop or not once it finishes playing. [br] -signal sound_started(info: Dictionary) - - -var max_channels: int: - set(value): - if max_channels != value: - max_channels = value - ProjectSettings.set_setting('dialogic/audio/max_channels', value) - ProjectSettings.save() - current_music_player.resize(value) - get: - return ProjectSettings.get_setting('dialogic/audio/max_channels', 4) - -## Audio player base duplicated to play background music. -## -## Background music is long audio. -var base_music_player := AudioStreamPlayer.new() +## Audio node for holding audio players +var audio_node := Node.new() +## Sound node for holding sound players +var sound_node := Node.new() ## Reference to the last used music player. -var current_music_player: Array[AudioStreamPlayer] = [] -## Audio player base, that will be duplicated to play sound effects. -## -## Sound effects are short audio. -var base_sound_player := AudioStreamPlayer.new() - +var current_audio_player: Dictionary = {} #region STATE #################################################################################################### @@ -60,8 +33,9 @@ var base_sound_player := AudioStreamPlayer.new() ## ## If you want to stop sounds only, use [method stop_all_sounds]. func clear_game_state(_clear_flag := DialogicGameHandler.ClearFlags.FULL_CLEAR) -> void: - for idx in max_channels: - update_music('', 0.0, '', 0.0, true, idx) + var info: Dictionary = dialogic.current_state_info.get("audio", {}) + for channel_name in current_audio_player.keys(): + update_audio(channel_name) stop_all_sounds() @@ -69,15 +43,17 @@ func clear_game_state(_clear_flag := DialogicGameHandler.ClearFlags.FULL_CLEAR) func load_game_state(load_flag:=LoadFlags.FULL_LOAD) -> void: if load_flag == LoadFlags.ONLY_DNODES: return - var info: Dictionary = dialogic.current_state_info.get("music", {}) - if not info.is_empty() and info.has('path'): - update_music(info.path, info.volume, info.audio_bus, 0, info.loop, 0) - else: - for channel_id in info.keys(): - if info[channel_id].is_empty() or info[channel_id].path.is_empty(): - update_music('', 0.0, '', 0.0, true, channel_id) - else: - update_music(info[channel_id].path, info[channel_id].volume, info[channel_id].audio_bus, 0, info[channel_id].loop, channel_id) + + # Pre Alpha 17 Converter + _convert_state_info() + + var info: Dictionary = dialogic.current_state_info.get("audio", {}) + + for channel_name in info.keys(): + if info[channel_name].path.is_empty(): + update_audio(channel_name) + else: + update_audio(channel_name, info[channel_name].path, info[channel_name].volume, info[channel_name].audio_bus, 0, info[channel_name].loop) ## Pauses playing audio. @@ -95,7 +71,7 @@ func resume() -> void: func _on_dialogic_timeline_ended() -> void: if not dialogic.Styles.get_layout_node(): clear_game_state() - pass + #endregion @@ -105,99 +81,117 @@ func _on_dialogic_timeline_ended() -> void: func _ready() -> void: dialogic.timeline_ended.connect(_on_dialogic_timeline_ended) - base_music_player.name = "Music" - add_child(base_music_player) - - base_sound_player.name = "Sound" - add_child(base_sound_player) + audio_node.name = "Audio" + add_child(audio_node) + sound_node.name = "Sound" + add_child(sound_node) - current_music_player.resize(max_channels) +## Updates the background audio. Will fade out previous audio. Can optionally synchronise the start time to the current position of another audio channel. +func update_audio(channel_name: String, path := "", volume := 0.0, audio_bus := "", fade_time := 0.0, loop := true, sync_channel := "") -> void: + if not dialogic.current_state_info.has('audio'): + dialogic.current_state_info['audio'] = {} -## Updates the background music. Will fade out previous music. -func update_music(path := "", volume := 0.0, audio_bus := "", fade_time := 0.0, loop := true, channel_id := 0) -> void: + if path: + dialogic.current_state_info['audio'][channel_name] = {'path':path, 'volume':volume, 'audio_bus':audio_bus, 'loop':loop, 'channel':channel_name} + audio_started.emit(dialogic.current_state_info['audio'][channel_name]) + else: + dialogic.current_state_info['audio'].erase(channel_name) - if channel_id > max_channels: - printerr("\tChannel ID (%s) higher than Max Music Channels (%s)" % [channel_id, max_channels]) - dialogic.print_debug_moment() + if not has_audio(channel_name) and path.is_empty(): return - if not dialogic.current_state_info.has('music'): - dialogic.current_state_info['music'] = {} - - dialogic.current_state_info['music'][channel_id] = {'path':path, 'volume':volume, 'audio_bus':audio_bus, 'loop':loop, 'channel':channel_id} - music_started.emit(dialogic.current_state_info['music'][channel_id]) - var fader: Tween = null - if is_instance_valid(current_music_player[channel_id]) and current_music_player[channel_id].playing or !path.is_empty(): + if has_audio(channel_name) or fade_time > 0.0: fader = create_tween() var prev_node: Node = null - if is_instance_valid(current_music_player[channel_id]) and current_music_player[channel_id].playing: - prev_node = current_music_player[channel_id] + if has_audio(channel_name): + prev_node = current_audio_player[channel_name] fader.tween_method(interpolate_volume_linearly.bind(prev_node), db_to_linear(prev_node.volume_db),0.0,fade_time) if path: - current_music_player[channel_id] = base_music_player.duplicate() - add_child(current_music_player[channel_id]) - current_music_player[channel_id].stream = load(path) - current_music_player[channel_id].volume_db = volume + var new_player := AudioStreamPlayer.new() + audio_node.add_child(new_player) + new_player.stream = load(path) + new_player.volume_db = linear_to_db(0.0) if fade_time > 0.0 else volume if audio_bus: - current_music_player[channel_id].bus = audio_bus - if not current_music_player[channel_id].stream is AudioStreamWAV: - if "loop" in current_music_player[channel_id].stream: - current_music_player[channel_id].stream.loop = loop - elif "loop_mode" in current_music_player[channel_id].stream: - if loop: - current_music_player[channel_id].stream.loop_mode = AudioStreamWAV.LOOP_FORWARD - else: - current_music_player[channel_id].stream.loop_mode = AudioStreamWAV.LOOP_DISABLED - - current_music_player[channel_id].play(0) - fader.parallel().tween_method(interpolate_volume_linearly.bind(current_music_player[channel_id]), 0.0, db_to_linear(volume),fade_time) + new_player.bus = audio_bus + + if "loop" in new_player.stream: + new_player.stream.loop = loop + elif "loop_mode" in new_player.stream: + if loop: + new_player.stream.loop_mode = AudioStreamWAV.LOOP_FORWARD + new_player.stream.loop_begin = 0 + new_player.stream.loop_end = new_player.stream.mix_rate * new_player.stream.get_length() + else: + new_player.stream.loop_mode = AudioStreamWAV.LOOP_DISABLED + + if sync_channel and has_audio(sync_channel): + var play_position = current_audio_player[sync_channel].get_playback_position() + new_player.play(play_position) + + # TODO Remove this once https://github.com/godotengine/godot/issues/18878 is fixed + if new_player.stream.format == AudioStreamWAV.FORMAT_IMA_ADPCM: + printerr("[Dialogic] WAV files using Ima-ADPCM compression cannot be synced. Reimport the file using a different compression mode.") + dialogic.print_debug_moment() + else: + new_player.play() + new_player.finished.connect(_on_audio_finished.bind(new_player, channel_name, path)) + if fade_time > 0.0: + fader.parallel().tween_method(interpolate_volume_linearly.bind(new_player), 0.0, db_to_linear(volume), fade_time) + + current_audio_player[channel_name] = new_player if prev_node: fader.tween_callback(prev_node.queue_free) -## Whether music is playing. -func has_music(channel_id := 0) -> bool: - return !dialogic.current_state_info.get('music', {}).get(channel_id, {}).get('path', '').is_empty() +## Whether audio is playing for this [param channel_name]. +func has_audio(channel_name: String) -> bool: + return (current_audio_player.has(channel_name) + and is_instance_valid(current_audio_player[channel_name]) + and current_audio_player[channel_name].is_playing()) + + +## Stops audio on all channels (does not affect one-shot sounds) with optional fade time. +func stop_all_audio(fade := 0.0) -> void: + for channel_name in current_audio_player.keys(): + update_audio(channel_name, '', 0.0, '', fade) ## Plays a given sound file. func play_sound(path: String, volume := 0.0, audio_bus := "", loop := false) -> void: - if base_sound_player != null and !path.is_empty(): - sound_started.emit({'path':path, 'volume':volume, 'audio_bus':audio_bus, 'loop':loop}) + if !path.is_empty(): + audio_started.emit({'path':path, 'volume':volume, 'audio_bus':audio_bus, 'loop':loop, 'channel':''}) - var new_sound_node := base_sound_player.duplicate() - new_sound_node.name += "Sound" - new_sound_node.stream = load(path) + var new_player := AudioStreamPlayer.new() + new_player.stream = load(path) - if "loop" in new_sound_node.stream: - new_sound_node.stream.loop = loop - elif "loop_mode" in new_sound_node.stream: + if "loop" in new_player.stream: + new_player.stream.loop = loop + elif "loop_mode" in new_player.stream: if loop: - new_sound_node.stream.loop_mode = AudioStreamWAV.LOOP_FORWARD + new_player.stream.loop_mode = AudioStreamWAV.LOOP_FORWARD + new_player.stream.loop_begin = 0 + new_player.stream.loop_end = new_player.stream.mix_rate * new_player.stream.get_length() else: - new_sound_node.stream.loop_mode = AudioStreamWAV.LOOP_DISABLED + new_player.stream.loop_mode = AudioStreamWAV.LOOP_DISABLED - new_sound_node.volume_db = volume + new_player.volume_db = volume if audio_bus: - new_sound_node.bus = audio_bus + new_player.bus = audio_bus - add_child(new_sound_node) - new_sound_node.play() - new_sound_node.finished.connect(new_sound_node.queue_free) + sound_node.add_child(new_player) + new_player.play() + new_player.finished.connect(new_player.queue_free) -## Stops all audio. +## Stops all one-shot sounds. func stop_all_sounds() -> void: - for node in get_children(): - if node == base_sound_player: - continue - if "Sound" in node.name: - node.queue_free() + for node in sound_node.get_children(): + node.queue_free() ## Converts a linear loudness value to decibel and sets that volume to @@ -207,13 +201,48 @@ func interpolate_volume_linearly(value: float, node: Node) -> void: ## Returns whether the currently playing audio resource is the same as this -## event's [param resource_path], for [param channel_id]. -func is_music_playing_resource(resource_path: String, channel_id := 0) -> bool: - var is_playing_resource: bool = (current_music_player.size() > channel_id - and is_instance_valid(current_music_player[channel_id]) - and current_music_player[channel_id].is_playing() - and current_music_player[channel_id].stream.resource_path == resource_path) - - return is_playing_resource +## event's [param resource_path], for [param channel_name]. +func is_audio_playing_resource(resource_path: String, channel_name: String) -> bool: + return (has_audio(channel_name) + and current_audio_player[channel_name].stream.resource_path == resource_path) + + +func _on_audio_finished(player: AudioStreamPlayer, channel_name: String, path: String) -> void: + if current_audio_player.has(channel_name) and current_audio_player[channel_name] == player: + current_audio_player.erase(channel_name) + player.queue_free() + if dialogic.current_state_info.get('audio', {}).get(channel_name, {}).get('path', '') == path: + dialogic.current_state_info['audio'].erase(channel_name) + +#endregion + + +#region Pre Alpha 17 Conversion + +func _convert_state_info() -> void: + var info: Dictionary = dialogic.current_state_info.get("music", {}) + if info.is_empty(): + return + + var new_info := {} + if info.has('path'): + # Pre Alpha 16 Save Data Conversion + new_info['music'] = info + else: + # Pre Alpha 17 Save Data Conversion + for channel_id in info.keys(): + var channel_name = "music" + if channel_id > 0: + channel_name += str(channel_id + 1) + if not info[channel_id].is_empty(): + new_info[channel_name] = { + 'path': info[channel_id].path, + 'volume': info[channel_id].volume, + 'audio_bus': info[channel_id].audio_bus, + 'loop': info[channel_id].loop, + 'channel': channel_name, + } + dialogic.current_state_info['audio'] = new_info + dialogic.current_state_info.erase('music') #endregion diff --git a/addons/dialogic/Modules/Clear/event_clear.gd b/addons/dialogic/Modules/Clear/event_clear.gd index d1fa84805..d514db73c 100644 --- a/addons/dialogic/Modules/Clear/event_clear.gd +++ b/addons/dialogic/Modules/Clear/event_clear.gd @@ -44,9 +44,8 @@ func _execute() -> void: if step_by_step: await dialogic.get_tree().create_timer(final_time).timeout if clear_music and dialogic.has_subsystem('Audio'): - for channel_id in dialogic.Audio.max_channels: - if dialogic.Audio.has_music(channel_id): - dialogic.Audio.update_music('', 0.0, "", final_time, channel_id) + dialogic.Audio.stop_all_audio(final_time) + dialogic.Audio.stop_all_sounds() if step_by_step: await dialogic.get_tree().create_timer(final_time).timeout if clear_style and dialogic.has_subsystem('Styles'): @@ -109,6 +108,6 @@ func build_event_editor() -> void: add_body_edit('clear_textbox', ValueType.BOOL_BUTTON, {'left_text':'Clear:', 'icon':load("res://addons/dialogic/Modules/Clear/clear_textbox.svg"), 'tooltip':'Clear Textbox'}) add_body_edit('clear_portraits', ValueType.BOOL_BUTTON, {'icon':load("res://addons/dialogic/Modules/Clear/clear_characters.svg"), 'tooltip':'Clear Portraits'}) add_body_edit('clear_background', ValueType.BOOL_BUTTON, {'icon':load("res://addons/dialogic/Modules/Clear/clear_background.svg"), 'tooltip':'Clear Background'}) - add_body_edit('clear_music', ValueType.BOOL_BUTTON, {'icon':load("res://addons/dialogic/Modules/Clear/clear_music.svg"), 'tooltip':'Clear Music'}) + add_body_edit('clear_music', ValueType.BOOL_BUTTON, {'icon':load("res://addons/dialogic/Modules/Clear/clear_music.svg"), 'tooltip':'Clear Audio'}) add_body_edit('clear_style', ValueType.BOOL_BUTTON, {'icon':load("res://addons/dialogic/Modules/Clear/clear_style.svg"), 'tooltip':'Clear Style'}) add_body_edit('clear_portrait_positions', ValueType.BOOL_BUTTON, {'icon':load("res://addons/dialogic/Modules/Clear/clear_positions.svg"), 'tooltip':'Clear Portrait Positions'})