class_name SfxConfigPanel extends Control # signal config_changed(node_name: String, property: String, value) # 面板相关 @onready var scroll_container: ScrollContainer = %ScrollContainer @onready var vbox_container: VBoxContainer = %VBoxContainer @onready var reset_button: Button = %ResetButton @onready var import_button: Button = %ImportButton @onready var export_button: Button = %ExportButton # 音频预览播放器 @onready var preview_player: AudioStreamPlayer = %AudioStreamPlayer # 文件对话框 @onready var file_dialog: FileDialog = %FileDialog # 数据存储 var sfx_nodes: Array[Node] = [] var config_data: Dictionary = {} var current_scene_name: String = "" func _ready(): file_dialog.file_selected.connect(_on_file_selected) reset_button.pressed.connect(_on_reset_pressed) import_button.pressed.connect(_on_import_pressed) export_button.pressed.connect(_on_export_pressed) # 初始加载 refresh_sfx_list(SceneManager.get_ground()) # 1. 检测 current_scene 下 Sfx 节点 func refresh_sfx_list(ground: Ground2D, headless := false): sfx_nodes.clear() current_scene_name = GroundLoader.get_ground_scene_readable_name(ground.scene_name) find_sfx_nodes(ground) load_config() if not headless: refresh_ui() var ignore_class_list = ["Portal2D", "Interactable2D", "Note2D", "Inspectable2D", "Pickable2D", "Npc2D", "Ambush2D"] func find_sfx_nodes(node: Node): var script = node.get_script() if script: if script.get_global_name() in ignore_class_list: return # if node is Sfx or node is VibeSfx: if node is Sfx: if node.has_method("play") and node.get("volume_db") != null and node.get("stream") != null: sfx_nodes.append(node) # 递归检查子节点 for child in node.get_children(): find_sfx_nodes(child) func clear_ui(): for child in vbox_container.get_children(): child.queue_free() # 2. 在面板生成对应条目,双向绑定 func create_ui_items(): for sfx_node in sfx_nodes: create_sfx_item(sfx_node) func refresh_ui(): clear_ui() create_ui_items() func _build_sfx_name(sfx: Sfx) -> String: var parent_name = "" var parent = sfx.get_parent() while not parent_name and parent: if parent and parent.get_script() and parent.get_script().get_global_name() in ignore_class_list: parent_name = parent.name else: parent = parent.get_parent() if parent_name: return parent_name + " > " + sfx.name else: return sfx.name func create_sfx_item(sfx_node: Node): var item_container = VBoxContainer.new() item_container.add_theme_constant_override("separation", 8) vbox_container.add_child(item_container) # 节点名称标签 var name_label = Label.new() name_label.text = _build_sfx_name(sfx_node) name_label.add_theme_font_size_override("font_size", 14) item_container.add_child(name_label) var controls_hbox = HBoxContainer.new() controls_hbox.add_theme_constant_override("separation", 10) item_container.add_child(controls_hbox) # 音量滑块 var volume_vbox = VBoxContainer.new() controls_hbox.add_child(volume_vbox) var volume_title_hbox = HBoxContainer.new() volume_vbox.add_child(volume_title_hbox) var volume_label = Label.new() volume_label.text = "音量 (dB)" volume_title_hbox.add_child(volume_label) # 重置音量按钮 var reset_volumn_button = Button.new() reset_volumn_button.text = "重置音量" reset_volumn_button.pressed.connect(reset_volumn.bind(sfx_node)) volume_title_hbox.add_child(reset_volumn_button) var volume_slider_hbox = HBoxContainer.new() volume_vbox.add_child(volume_slider_hbox) var volume_slider = HSlider.new() volume_slider.min_value = -60.0 volume_slider.max_value = 20.0 volume_slider.step = 0.1 volume_slider.value = sfx_node.volume_db volume_slider.custom_minimum_size.x = 200 volume_slider_hbox.add_child(volume_slider) var volume_value_label = Label.new() volume_value_label.text = str(snapped(sfx_node.volume_db, 0.1)) volume_value_label.custom_minimum_size.x = 50 volume_slider_hbox.add_child(volume_value_label) # 双向绑定音量 volume_slider.value_changed.connect(func(value): sfx_node.volume_db = value volume_value_label.text = str(snapped(value, 0.1)) save_node_config(sfx_node.get_path(), "volume_db", value) ) # Stream 信息和控制 var stream_vbox = VBoxContainer.new() controls_hbox.add_child(stream_vbox) var stream_label = Label.new() stream_label.text = "音频文件" stream_vbox.add_child(stream_label) var stream_hbox = HBoxContainer.new() stream_vbox.add_child(stream_hbox) var stream_name_label = Label.new() stream_name_label.custom_minimum_size.x = 150 _set_stream_name_label(sfx_node, stream_name_label) stream_hbox.add_child(stream_name_label) # 预览播放按钮 var preview_button = Button.new() preview_button.text = "▶" preview_button.custom_minimum_size = Vector2(30, 30) preview_button.pressed.connect(preview_audio.bind(sfx_node)) stream_hbox.add_child(preview_button) # 恢复默认按钮 if sfx_node.stream_was_replaced(): var reset_btn = Button.new() reset_btn.text = "恢复默认音频" reset_btn.custom_minimum_size = Vector2(30, 30) reset_btn.pressed.connect(reset_audio.bind(sfx_node)) stream_hbox.add_child(reset_btn) # 3. 上传文件按钮 var upload_button = Button.new() upload_button.text = "上传音频" upload_button.pressed.connect(open_file_dialog.bind(sfx_node, stream_name_label)) stream_hbox.add_child(upload_button) # 分割线 var separator = HSeparator.new() item_container.add_child(separator) # 存储节点引用用于后续操作 item_container.set_meta("sfx_node", sfx_node) item_container.set_meta("stream_label", stream_name_label) func _set_stream_name_label(sfx_node:Sfx, stream_name_label:Label) -> void: if sfx_node.stream: stream_name_label.text = sfx_node.stream.resource_path.get_file() else: stream_name_label.text = "无音频文件" func reset_volumn(sfx_node: Sfx): sfx_node.volume_db = sfx_node.default_db save_config() refresh_ui() func preview_audio(sfx_node: Sfx): if sfx_node.stream: preview_player.stream = sfx_node.stream preview_player.volume_db = sfx_node.volume_db preview_player.play() func reset_audio(sfx_node: Sfx): sfx_node.reset_original_stream() config_data.erase(sfx_node.get_path()) save_config() refresh_ui() var current_upload_node: Node var current_stream_label: Label func open_file_dialog(sfx_node: Node, stream_label: Label): current_upload_node = sfx_node current_stream_label = stream_label file_dialog.popup_centered() func _on_file_selected(path: String): if not current_upload_node: return copy_and_load_audio_file(path, current_upload_node, current_stream_label) # 3. 复制文件并加载 func copy_and_load_audio_file(source_path: String, sfx_node: Node, stream_label: Label): var file_name = source_path.get_file() var audio_dir = "user://audio/" + current_scene_name + "/" var target_path = audio_dir + file_name # 确保目录存在 DirAccess.open("user://").make_dir_recursive("audio/" + current_scene_name) # 复制文件 var source_file = FileAccess.open(source_path, FileAccess.READ) if not source_file: push_error("无法读取源文件: " + source_path) return var target_file = FileAccess.open(target_path, FileAccess.WRITE) if not target_file: push_error("无法创建目标文件: " + target_path) source_file.close() return target_file.store_buffer(source_file.get_buffer(source_file.get_length())) source_file.close() target_file.close() # 加载新的音频流 var new_stream = AudioLoader.new().loadfile(target_path) if new_stream: sfx_node.replace_stream(new_stream) stream_label.text = file_name save_node_config(sfx_node.get_path(), "stream", target_path) print("音频文件已更新: ", sfx_node.name, " -> ", file_name) else: push_error("无法加载音频文件: " + target_path) refresh_ui() # 4. 配置保存与加载 func save_node_config(node_path: String, property: String, value): if not config_data.has(node_path): config_data[node_path] = {} config_data[node_path][property] = value save_config() func save_config(): var config_dir = "user://audio/" + current_scene_name + "/" DirAccess.open("user://").make_dir_recursive("audio/" + current_scene_name) var config_path = config_dir + "audio_config.dat" var file = FileAccess.open(config_path, FileAccess.WRITE) if file: file.store_string(var_to_str(config_data)) file.close() func load_config(): var config_path = "user://audio/" + current_scene_name + "/audio_config.dat" var file = FileAccess.open(config_path, FileAccess.READ) if file: var config_str = file.get_as_text() file.close() config_data = str_to_var(config_str) if not config_data: config_data = {} apply_config() else: config_data = {} func apply_config(): for sfx_node in sfx_nodes: var sfx_path = sfx_node.get_path() if config_data.has(sfx_path): var node_config = config_data[sfx_path] if node_config.has("volume_db"): sfx_node.volume_db = node_config["volume_db"] if node_config.has("stream"): var stream_path = node_config["stream"] if FileAccess.file_exists(stream_path): var new_stream = AudioLoader.new().loadfile(stream_path) # var new_stream = load(stream_path) if new_stream: sfx_node.replace_stream(new_stream) func _on_reset_pressed() -> void: for sfx_node in sfx_nodes: sfx_node.reset_original_stream() var config_file_path = "user://audio/" + current_scene_name + "/" + "/audio_config.dat" if FileAccess.file_exists(config_file_path): DirAccess.remove_absolute(config_file_path) SceneManager.enter_main_scene() # 5. Import/Export 功能 func _on_import_pressed(): # 打开文件对话框选择导入文件夹或配置文件 var import_dialog = FileDialog.new() import_dialog.size = Vector2(1400, 800) import_dialog.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS import_dialog.content_scale_factor = 3 import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR import_dialog.access = FileDialog.ACCESS_FILESYSTEM import_dialog.dir_selected.connect(_on_import_folder_selected) add_child(import_dialog) import_dialog.popup_centered() func _on_import_folder_selected(folder_path: String): var config_file_path = folder_path + "/audio_config.dat" if not FileAccess.file_exists(config_file_path): push_error("所选文件夹中没有找到 audio_config.dat 配置文件") return # 确保目标目录存在 var target_dir = "user://audio/" + current_scene_name + "/" DirAccess.open("user://").make_dir_recursive("audio/" + current_scene_name) # 复制配置文件 copy_file(config_file_path, target_dir + "audio_config.dat") # 复制所有音频文件 var dir = DirAccess.open(folder_path) if dir: dir.list_dir_begin() var file_name = dir.get_next() while file_name != "": if not dir.current_is_dir(): var extension = file_name.get_extension().to_lower() if extension in ["wav", "ogg", "mp3"]: var source_path = folder_path + "/" + file_name var target_path = target_dir + file_name copy_file(source_path, target_path) file_name = dir.get_next() dir.list_dir_end() # 重新加载配置 refresh_sfx_list(SceneManager.get_ground()) print("配置和音频文件已导入") func copy_file(source_path: String, target_path: String) -> bool: var source_file = FileAccess.open(source_path, FileAccess.READ) if not source_file: push_error("无法读取文件: " + source_path) return false var target_file = FileAccess.open(target_path, FileAccess.WRITE) if not target_file: push_error("无法创建文件: " + target_path) source_file.close() return false target_file.store_buffer(source_file.get_buffer(source_file.get_length())) source_file.close() target_file.close() return true func _on_export_pressed(): var desktop_path = OS.get_system_dir(OS.SYSTEM_DIR_DESKTOP) var export_folder = desktop_path + "/" + current_scene_name + "_audio_export" var config_path = "user://audio/" + current_scene_name + "/audio_config.dat" # 创建导出文件夹 var dir = DirAccess.open(desktop_path) if not dir: push_error("无法访问桌面目录") return # 如果文件夹已存在,先删除 if dir.dir_exists(export_folder): remove_directory_recursive(export_folder) dir.make_dir(current_scene_name + "_audio_export") var exported_files = [] var failed_files = [] # 1. 导出配置文件 if FileAccess.file_exists(config_path): var source_file = FileAccess.open(config_path, FileAccess.READ) var target_file = FileAccess.open(export_folder + "/audio_config.dat", FileAccess.WRITE) if source_file and target_file: target_file.store_buffer(source_file.get_buffer(source_file.get_length())) source_file.close() target_file.close() exported_files.append("audio_config.dat") else: failed_files.append("audio_config.dat") push_error("配置文件导出失败") else: push_error("没有找到配置文件") return # 2. 导出所有关联的音频文件 var audio_dir = "user://audio/" + current_scene_name + "/" # 从配置数据中收集所有音频文件路径 var audio_files_to_export = [] # 收集配置文件中引用的音频文件 for node_name in config_data.keys(): var node_config = config_data[node_name] if node_config.has("stream"): var stream_path = node_config["stream"] if stream_path.begins_with("user://audio/" + current_scene_name + "/"): audio_files_to_export.append(stream_path) # 收集当前目录下的所有音频文件 var audio_dir_access = DirAccess.open(audio_dir) if audio_dir_access: audio_dir_access.list_dir_begin() var file_name = audio_dir_access.get_next() while file_name != "": if not audio_dir_access.current_is_dir(): var full_path = audio_dir + file_name var extension = file_name.get_extension().to_lower() # 检查是否是音频文件 if extension in ["wav", "ogg", "mp3"]: if not full_path in audio_files_to_export: audio_files_to_export.append(full_path) file_name = audio_dir_access.get_next() audio_dir_access.list_dir_end() # 导出音频文件 for audio_path in audio_files_to_export: if FileAccess.file_exists(audio_path): var file_name = audio_path.get_file() var source_file = FileAccess.open(audio_path, FileAccess.READ) var target_file = FileAccess.open(export_folder + "/" + file_name, FileAccess.WRITE) if source_file and target_file: target_file.store_buffer(source_file.get_buffer(source_file.get_length())) source_file.close() target_file.close() exported_files.append(file_name) else: failed_files.append(file_name) if source_file: source_file.close() if target_file: target_file.close() else: failed_files.append(audio_path.get_file() + " (文件不存在)") # 3. 创建导出说明文件 create_export_readme(export_folder, exported_files, failed_files) # 显示导出结果 var message = "导出完成!\n导出位置: " + export_folder + "\n" message += "成功导出 " + str(exported_files.size()) + " 个文件" if failed_files.size() > 0: message += "\n失败 " + str(failed_files.size()) + " 个文件: " + str(failed_files) print(message) # 可选:打开导出文件夹 OS.shell_open(export_folder) func create_export_readme(export_folder: String, exported_files: Array, failed_files: Array): var readme_path = export_folder + "/README.txt" var readme_file = FileAccess.open(readme_path, FileAccess.WRITE) if readme_file: var content = "音效配置导出说明\n" content += "===================\n\n" content += "场景名称: " + current_scene_name + "\n" content += "导出时间: " + Time.get_datetime_string_from_system() + "\n\n" content += "导出文件清单:\n" content += "-----------------\n" for file_name in exported_files: content += "✓ " + file_name + "\n" if failed_files.size() > 0: content += "\n失败文件:\n" content += "-----------------\n" for file_name in failed_files: content += "✗ " + file_name + "\n" content += "\n使用说明:\n" content += "-----------------\n" content += "1. audio_config.dat 是配置文件,包含音量和文件关联信息\n" content += "2. 其他 .wav/.ogg/.mp3 文件是对应的音频资源\n" content += "3. 导入时请将所有文件放在同一目录下\n" readme_file.store_string(content) readme_file.close() func remove_directory_recursive(path: String): var dir = DirAccess.open(path) if dir: dir.list_dir_begin() var file_name = dir.get_next() while file_name != "": var full_path = path + "/" + file_name if dir.current_is_dir(): remove_directory_recursive(full_path) else: dir.remove(file_name) file_name = dir.get_next() dir.list_dir_end() dir.remove(path)