Files
agame/addons/global_asset_manager/asset_manager.gd
T
2026-10-05 14:57:41 +08:00

1521 lines
52 KiB
GDScript

@tool
@icon("res://icon.svg")
@static_unload
class_name AssetManager
extends Control
signal asset_selected(path: String)
signal db_updated
enum AssetType { MODEL_3D, IMAGE_2D, AUDIO, SHADER, UNKNOWN }
const ALL_3D_FORMATS: PackedStringArray = ["glb", "gltf", "fbx"]
const ALL_2D_FORMATS: PackedStringArray = ["png", "jpg", "jpeg", "webp"]
const ALL_AUDIO_FORMATS: PackedStringArray = ["ogg", "mp3", "wav"]
const DB_FILE_PATH: String = "user://asset_database.dat"
# Image formats Godot can import, used to find textures a glTF references under a different extension
const GLTF_TEXTURE_FORMATS: PackedStringArray = ["png", "jpg", "jpeg", "webp", "tga", "bmp", "exr", "hdr", "dds", "ktx", "svg"]
# How far above a glTF's folder to look for dependencies that aren't where the file says they are
const GLTF_SEARCH_PARENT_LEVELS: int = 2
const GLTF_SEARCH_MAX_FILES: int = 5000
# Folder (inside the model import folder) that holds dependencies shared between models
const GLTF_SHARED_DIR_NAME: String = "_shared"
# How many frames to keep retrying the post-export filesystem scan while the editor is busy
const SCAN_RETRY_MAX_FRAMES: int = 3600
var all_known_tags: Array[String] = []
var current_selected_path: String = ""
var db: Dictionary = {
"assets": {},
"folders": [],
"settings": {}
}
var folders_root: TreeItem
var tags_root: TreeItem
var _active_filter_tags: Array[String] = []
var _blank_placeholder_tex: ImageTexture
var _current_asset_type: AssetType = AssetType.UNKNOWN
var _current_filter_folder: String = ""
var _current_page: int = 0
var _folder_context_path: String = ""
var _grid_icon_size: int = 40
var _grid_population_version: int = 0
var _is_grid_view: bool = false
var _items_per_page: int = 100
var _light_mode_idx: int = 0
var _loaded_3d_node: Node3D = null
var _preview_controller: PreviewController = PreviewController.new()
var _search_query: String = ""
var _selection_update_pending: bool = false
var _tag_display_limit: int = 20
var _tag_to_delete: String = ""
var _thumbnail_cache: Dictionary = {}
var _max_button_text_length: int = 30
var _thumbnail_queue: Array[Dictionary] = []
var _is_processing_thumbnails: bool = false
@onready var asset_grid: ItemList = $MarginContainer/MainSplit/ContentSplit/CenterPanel/AssetGrid
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
@onready var available_tags_flow_container: HFlowContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/AvailableTagsScroll/AvailableTagsFlowContainer
@onready var file_name_label: Label = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/FileNameLabel
@onready var folder_dialog: FileDialog = $FolderDialog
@onready var light_mode_btn: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox/LightModeBtn
@onready var nav_tree: Tree = $MarginContainer/MainSplit/Sidebar/NavigationTree
@onready var next_page_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer/NextPageButton
@onready var open_external_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/OpenExternalButton
@onready var open_location_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/OpenLocationButton
@onready var page_label: Label = $MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer/PageLabel
@onready var prev_page_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/PaginationContainer/PrevPageButton
@onready var preview_2d_rect: TextureRect = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/TextureRect
@onready var preview_3d_pivot: Node3D = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer/SubViewport/ModelPivot
@onready var preview_3d_toolbar: MarginContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar
@onready var preview_3d_viewport: SubViewport = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/SubViewportContainer/SubViewport
@onready var preview_panel_container: PanelContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer
@onready var replay_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/ReplayButton
@onready var reset_origin_btn: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox/ResetOriginBtn
@onready var reset_zoom_btn: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/PreviewContainer/ViewportToolbar/HBox/ResetZoomBtn
@onready var scan_button: Button = $MarginContainer/MainSplit/Sidebar/ScanButton
@onready var search_input: LineEdit = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/SearchInput
@onready var send_to_project_button: Button = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/ActionButtons/SendToProjectButton
@onready var settings_button: Button = $MarginContainer/MainSplit/Sidebar/SettingsButton
@onready var settings_dialog: AcceptDialog = $SettingsDialog
@onready var tag_context_menu: PopupMenu = $TagContextMenu
@onready var tag_flow_container: HFlowContainer = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/CurrentTagsScroll/TagFlowContainer
@onready var tag_input_field: LineEdit = $MarginContainer/MainSplit/ContentSplit/PreviewPanel/DetailsPanel/TagInputField
@onready var view_mode_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/ViewModeButton
@onready var zoom_in_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/ZoomInButton
@onready var zoom_out_button: Button = $MarginContainer/MainSplit/ContentSplit/CenterPanel/SearchHBox/ZoomOutButton
func _ready() -> void:
if Engine.is_editor_hint() and get_parent() == get_tree().root:
return
var clean_env := Environment.new()
clean_env.background_mode = Environment.BG_COLOR
clean_env.background_color = Color(0.18, 0.18, 0.18)
clean_env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
clean_env.ambient_light_color = Color(1.0, 1.0, 1.0)
clean_env.ambient_light_energy = 0.4
clean_env.tonemap_mode = Environment.TONE_MAPPER_LINEAR
clean_env.glow_enabled = false
var isolated_world := World3D.new()
isolated_world.environment = clean_env
preview_3d_viewport.world_3d = isolated_world
reset_origin_btn.pressed.connect(func() -> void: _preview_controller.reset_origin())
reset_zoom_btn.pressed.connect(func() -> void: _preview_controller.reset_zoom())
light_mode_btn.pressed.connect(_on_light_mode_pressed)
preview_2d_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
asset_grid.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
preview_panel_container.clip_contents = true
preview_panel_container.gui_input.connect(_on_preview_gui_input)
preview_3d_viewport.get_parent().mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_2d_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
_preview_controller.pivot = preview_3d_pivot
_preview_controller.camera = preview_3d_viewport.get_node("Camera3D")
_preview_controller.texture_rect = preview_2d_rect
scan_button.pressed.connect(_on_scan_button_pressed)
settings_button.pressed.connect(_on_settings_button_pressed)
folder_dialog.dir_selected.connect(_on_folder_selected)
nav_tree.item_selected.connect(_on_nav_tree_item_selected)
nav_tree.item_edited.connect(_on_nav_tree_item_edited)
nav_tree.item_mouse_selected.connect(_on_nav_tree_item_mouse_selected)
search_input.text_changed.connect(_on_search_text_changed)
asset_grid.multi_selected.connect(_on_asset_grid_multi_selected)
asset_grid.item_selected.connect(_on_asset_grid_item_selected)
prev_page_button.pressed.connect(_on_prev_page_pressed)
next_page_button.pressed.connect(_on_next_page_pressed)
tag_input_field.text_changed.connect(_on_tag_input_text_changed)
tag_input_field.text_submitted.connect(_on_tag_input_submitted)
replay_button.pressed.connect(_on_replay_button_pressed)
open_external_button.pressed.connect(_on_open_external_pressed)
open_location_button.pressed.connect(_on_open_location_pressed)
send_to_project_button.pressed.connect(_on_send_to_project_pressed)
tag_context_menu.id_pressed.connect(_on_tag_context_menu_id_pressed)
audio_player.finished.connect(_on_audio_finished)
settings_dialog.settings_changed.connect(_on_settings_changed)
settings_dialog.database_cleared.connect(_on_database_cleared)
zoom_out_button.pressed.connect(_on_zoom_out_pressed)
zoom_in_button.pressed.connect(_on_zoom_in_pressed)
var blank_img := Image.create_empty(1, 1, false, Image.FORMAT_RGBA8)
_blank_placeholder_tex = ImageTexture.create_from_image(blank_img)
view_mode_button.pressed.connect(_on_view_mode_toggled)
_apply_view_mode_settings()
_load_database()
settings_dialog.setup(db)
_update_audio_volume()
_rebuild_nav_tree()
func add_tag_to_selected_assets(tag_text: String) -> void:
var clean_tag: String = tag_text.strip_edges().to_lower().replace(" ", "_")
if clean_tag.is_empty():
return
var selected_items := asset_grid.get_selected_items()
if selected_items.is_empty():
return
var modified := false
for idx in selected_items:
var path: String = asset_grid.get_item_metadata(idx)
if not db["assets"].has(path):
db["assets"][path] = {"tags": []}
var tags: Array = db["assets"][path]["tags"]
if not tags.has(clean_tag):
tags.append(clean_tag)
modified = true
if modified:
_save_database()
call_deferred("_update_tags_tree")
_update_tag_ui()
func load_asset_preview(file_path: String) -> void:
current_selected_path = file_path
_current_asset_type = _determine_asset_type(file_path.get_extension())
file_name_label.text = file_path.get_file()
_display_preview()
_update_tag_ui()
func remove_tag_from_selected_assets(tag_text: String) -> void:
var selected_items := asset_grid.get_selected_items()
if selected_items.is_empty():
return
var modified := false
for idx in selected_items:
var path: String = asset_grid.get_item_metadata(idx)
if db["assets"].has(path):
var tags: Array = db["assets"][path].get("tags", [])
if tags.has(tag_text):
tags.erase(tag_text)
modified = true
if modified:
_save_database()
call_deferred("_update_tags_tree")
_update_tag_ui()
if _active_filter_tags.has(tag_text):
_populate_asset_grid()
_handle_selection_change()
func scan_directory(path: String) -> void:
if not db["folders"].has(path):
db["folders"].append(path)
var dir := DirAccess.open(path)
if dir:
_recursive_scan(dir, path, path)
_save_database()
_rebuild_nav_tree()
db_updated.emit()
_select_folder_in_tree(path)
else:
push_error("Failed to open directory: ", path)
func _apply_view_mode_settings() -> void:
zoom_out_button.visible = _is_grid_view
zoom_in_button.visible = _is_grid_view
if _is_grid_view:
asset_grid.max_columns = 0
asset_grid.same_column_width = true
asset_grid.fixed_column_width = _grid_icon_size + 16
asset_grid.icon_mode = ItemList.ICON_MODE_TOP
asset_grid.fixed_icon_size = Vector2i(_grid_icon_size, _grid_icon_size)
asset_grid.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
asset_grid.add_theme_constant_override("h_separation", 12)
asset_grid.add_theme_constant_override("v_separation", 12)
else:
asset_grid.max_columns = 1
asset_grid.same_column_width = false
asset_grid.fixed_column_width = 0
asset_grid.icon_mode = ItemList.ICON_MODE_LEFT
asset_grid.fixed_icon_size = Vector2i(24, 24)
asset_grid.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
asset_grid.add_theme_constant_override("h_separation", 8)
asset_grid.add_theme_constant_override("v_separation", 4)
func _center_and_scale_3d_model(node: Node3D) -> void:
var meshes: Array[MeshInstance3D] = []
_find_meshes_recursive(node, meshes)
if meshes.is_empty():
return
var aabb := AABB()
var first := true
for mi in meshes:
var mi_aabb := mi.get_aabb()
var xform := node.global_transform.affine_inverse() * mi.global_transform
var xformed_aabb := xform * mi_aabb
if first:
aabb = xformed_aabb
first = false
else:
aabb = aabb.merge(xformed_aabb)
var max_size: float = maxf(aabb.size.x, maxf(aabb.size.y, aabb.size.z))
if max_size > 0:
var target_scale: float = 2.0 / max_size
node.scale = Vector3(target_scale, target_scale, target_scale)
var center_offset: Vector3 = aabb.get_center() * node.scale
node.position = -center_offset
func _clear_current_preview() -> void:
preview_2d_rect.texture = null
audio_player.stop()
audio_player.stream = null
replay_button.text = "Replay"
_preview_controller.reset_views()
if is_instance_valid(_loaded_3d_node):
_loaded_3d_node.queue_free()
_loaded_3d_node = null
func _convert_24bit_to_16bit(data24: PackedByteArray) -> PackedByteArray:
var num_samples: int = data24.size() / 3
var data16 := PackedByteArray()
data16.resize(num_samples * 2)
var j: int = 0
for i in range(0, num_samples * 3, 3):
data16[j] = data24[i + 1]
data16[j + 1] = data24[i + 2]
j += 2
return data16
func _convert_32bit_float_to_16bit(data32: PackedByteArray) -> PackedByteArray:
var num_samples: int = data32.size() / 4
var data16 := PackedByteArray()
data16.resize(num_samples * 2)
var j: int = 0
var offset: int = 0
for i in range(num_samples):
var f: float = data32.decode_float(offset)
offset += 4
var int_val: int = int(clampf(f, -1.0, 1.0) * 32767.0)
data16[j] = int_val & 0xFF
data16[j + 1] = (int_val >> 8) & 0xFF
j += 2
return data16
func _convert_importer_meshes(node: Node) -> Node:
if not is_instance_valid(node):
return node
var new_node: Node = node
if node.get_class() == "ImporterMeshInstance3D":
var mi := MeshInstance3D.new()
mi.name = node.name
if node is Node3D:
mi.transform = node.transform
var importer_mesh: Variant = node.get("mesh")
if importer_mesh != null and importer_mesh.has_method("get_mesh"):
mi.mesh = importer_mesh.get_mesh()
var skin: Variant = node.get("skin")
if skin != null:
mi.skin = skin
var skeleton_path: Variant = node.get("skeleton_path")
if skeleton_path != null:
mi.skeleton = skeleton_path
var children := node.get_children()
for child in children:
node.remove_child(child)
mi.add_child(child)
var parent := node.get_parent()
if parent != null:
parent.add_child(mi)
node.queue_free()
else:
node.free()
new_node = mi
var current_children := new_node.get_children()
for child in current_children:
_convert_importer_meshes(child)
return new_node
func _deferred_selection_update() -> void:
_selection_update_pending = false
var selected := asset_grid.get_selected_items()
var has_selection := selected.size() > 0
var is_single := selected.size() == 1
open_location_button.disabled = not is_single
send_to_project_button.disabled = not has_selection
open_external_button.disabled = not is_single
if selected.size() == 0:
current_selected_path = ""
file_name_label.text = "No asset selected"
_clear_current_preview()
tag_input_field.editable = false
_update_tag_ui()
elif selected.size() == 1:
var path: String = asset_grid.get_item_metadata(selected[0])
tag_input_field.editable = true
load_asset_preview(path)
else:
current_selected_path = ""
file_name_label.text = str(selected.size()) + " assets selected"
_clear_current_preview()
preview_2d_rect.visible = false
preview_3d_viewport.get_parent().visible = false
replay_button.visible = false
tag_input_field.editable = true
_update_tag_ui()
func _delete_tag_globally(tag_text: String) -> void:
var modified := false
for path: String in db["assets"].keys():
var tags: Array = db["assets"][path].get("tags", [])
if tags.has(tag_text):
tags.erase(tag_text)
modified = true
if _active_filter_tags.has(tag_text):
_active_filter_tags.erase(tag_text)
if modified:
_save_database()
call_deferred("_update_tags_tree")
_populate_asset_grid()
_handle_selection_change()
_update_tag_ui()
func _determine_asset_type(extension: String) -> AssetType:
var ext: String = extension.to_lower()
var active_formats: Array = db["settings"].get("active_formats", [])
if not active_formats.has(ext):
return AssetType.UNKNOWN
if ext in ALL_3D_FORMATS:
return AssetType.MODEL_3D
elif ext in ALL_2D_FORMATS:
return AssetType.IMAGE_2D
elif ext in ALL_AUDIO_FORMATS:
return AssetType.AUDIO
return AssetType.UNKNOWN
func _display_preview() -> void:
preview_3d_viewport.get_parent().visible = _current_asset_type == AssetType.MODEL_3D
preview_3d_toolbar.visible = _current_asset_type == AssetType.MODEL_3D
preview_2d_rect.visible = _current_asset_type == AssetType.IMAGE_2D
replay_button.visible = _current_asset_type == AssetType.AUDIO
_clear_current_preview()
if _current_asset_type == AssetType.MODEL_3D:
_load_3d_model(current_selected_path)
elif _current_asset_type == AssetType.IMAGE_2D:
var img: Image = Image.load_from_file(current_selected_path)
if img:
preview_2d_rect.texture = ImageTexture.create_from_image(img)
elif _current_asset_type == AssetType.AUDIO:
_load_audio(current_selected_path)
# Copies a .gltf and the external buffers and textures it references into dest_dir.
# Dependencies are written before the .gltf, and the .gltf is written once with its final uris,
# so an editor scan that is already running never imports it while its textures are missing.
func _export_gltf(gltf_path: String, dest_dir: String) -> Error:
var dest_gltf := dest_dir.path_join(gltf_path.get_file())
var json_text := FileAccess.get_file_as_string(gltf_path)
var data: Variant = JSON.parse_string(json_text)
if not data is Dictionary:
push_warning("Could not parse glTF file for dependencies: ", gltf_path)
return DirAccess.copy_absolute(gltf_path, dest_gltf)
var src_dir := gltf_path.get_base_dir()
# Raw (still URI-encoded) uri strings, as written in the file
var raw_uris: Array[String] = []
var image_uris: Array[String] = []
for key in ["buffers", "images"]:
for entry in data.get(key, []):
if entry is Dictionary and entry.has("uri"):
var raw_uri: String = entry["uri"]
# Embedded data needs no copying
if raw_uri.begins_with("data:"):
continue
if not raw_uris.has(raw_uri):
raw_uris.append(raw_uri)
if key == "images":
image_uris.append(raw_uri)
var uri_replacements := {}
var search_index := {}
for raw_uri in raw_uris:
var rel_path := raw_uri.uri_decode().simplify_path()
var src_file := rel_path if rel_path.is_absolute_path() else src_dir.path_join(rel_path).simplify_path()
var substituted := false
if not FileAccess.file_exists(src_file):
# Exported asset packs often reference textures that live in another folder or use another format
var found_rel_path := _find_gltf_dependency(src_dir, rel_path.get_file(), image_uris.has(raw_uri), search_index)
if found_rel_path.is_empty():
push_warning("Missing glTF dependency: ", src_file)
continue
rel_path = found_rel_path
var found_file := src_dir.path_join(rel_path).simplify_path()
print("Global Asset Manager: ", gltf_path.get_file(), " references missing file ", src_file, ", using ", found_file, " instead.")
src_file = found_file
substituted = true
var is_external := rel_path.is_absolute_path() or rel_path.begins_with("..")
var dest_rel_path := rel_path
if is_external:
# Files outside the glTF's folder are usually shared by other models in the same pack
dest_rel_path = _get_shared_dependency_path(src_file, dest_dir)
var dest_file := dest_dir.path_join(dest_rel_path)
# An identical shared file was already imported with another model
var already_shared := is_external and FileAccess.file_exists(dest_file)
if not already_shared:
if not DirAccess.dir_exists_absolute(dest_file.get_base_dir()):
DirAccess.make_dir_recursive_absolute(dest_file.get_base_dir())
if DirAccess.copy_absolute(src_file, dest_file) != OK:
push_error("Failed to copy glTF dependency: ", src_file)
continue
if is_external or substituted:
var encoded_segments := PackedStringArray()
for segment in dest_rel_path.split("/"):
encoded_segments.append(segment.uri_encode())
uri_replacements[raw_uri] = "/".join(encoded_segments)
if uri_replacements.is_empty():
return DirAccess.copy_absolute(gltf_path, dest_gltf)
# Rewrite the uris so the .gltf points at the relocated files
var dest_text := json_text
var unresolved: Array[String] = []
for raw_uri in uri_replacements:
var new_uri: String = uri_replacements[raw_uri]
var quoted := JSON.stringify(raw_uri)
# JSON writers may escape forward slashes
var quoted_escaped := quoted.replace("/", "\\/")
if dest_text.contains(quoted):
dest_text = dest_text.replace(quoted, JSON.stringify(new_uri))
elif dest_text.contains(quoted_escaped):
dest_text = dest_text.replace(quoted_escaped, JSON.stringify(new_uri))
else:
unresolved.append(raw_uri)
var file := FileAccess.open(dest_gltf, FileAccess.WRITE)
if not file:
return FileAccess.get_open_error()
file.store_string(dest_text)
file.close()
var shared_path := dest_dir.path_join(GLTF_SHARED_DIR_NAME)
if unresolved.is_empty():
print("Global Asset Manager: files referenced outside the folder of ", gltf_path.get_file(), " are in ", shared_path, " and the glTF was updated to use them.")
else:
push_warning("Global Asset Manager: files referenced outside the folder of ", gltf_path.get_file(), " are in ", shared_path,
", but these references in the glTF could not be updated: ", ", ".join(unresolved),
". You may need to reconnect them manually in Godot.")
return OK
# Returns where src_file belongs in the shared folder, relative to dest_dir.
# Reuses an identical file already there; a different file with the same name gets a content-hash suffix.
func _get_shared_dependency_path(src_file: String, dest_dir: String) -> String:
var file_name := src_file.get_file()
var shared_path := GLTF_SHARED_DIR_NAME.path_join(file_name)
var existing_file := dest_dir.path_join(shared_path)
if not FileAccess.file_exists(existing_file):
return shared_path
var src_md5 := FileAccess.get_md5(src_file)
if FileAccess.get_md5(existing_file) == src_md5:
return shared_path
var hashed_name := file_name.get_basename() + "_" + src_md5.left(8)
if not file_name.get_extension().is_empty():
hashed_name += "." + file_name.get_extension()
return GLTF_SHARED_DIR_NAME.path_join(hashed_name)
# Returns the path of a file matching file_name, relative to src_dir, or "" if none is found.
# Searches src_dir and then its parents; image references also match other image extensions.
func _find_gltf_dependency(src_dir: String, file_name: String, is_image: bool, search_index: Dictionary) -> String:
var wanted_name := file_name.get_basename().to_lower()
var wanted_ext := file_name.get_extension().to_lower()
var root := src_dir
for level in GLTF_SEARCH_PARENT_LEVELS + 1:
if not search_index.has(level):
var files: Array[String] = []
_collect_files_recursive(root, "", files)
search_index[level] = files
var match_path := ""
for rel_file: String in search_index[level]:
if rel_file.get_file().get_basename().to_lower() != wanted_name:
continue
var ext := rel_file.get_extension().to_lower()
if ext == wanted_ext:
match_path = rel_file
break
if is_image and match_path.is_empty() and GLTF_TEXTURE_FORMATS.has(ext):
match_path = rel_file
if not match_path.is_empty():
return "../".repeat(level) + match_path
var parent := root.get_base_dir()
if parent == root:
break
root = parent
return ""
func _collect_files_recursive(dir_path: String, rel_dir: String, result: Array[String]) -> void:
var dir := DirAccess.open(dir_path.path_join(rel_dir))
if not dir:
return
for file_name in dir.get_files():
if result.size() >= GLTF_SEARCH_MAX_FILES:
return
if file_name.get_extension() != "import":
result.append(rel_dir.path_join(file_name))
for sub_dir in dir.get_directories():
if not sub_dir.begins_with("."):
_collect_files_recursive(dir_path, rel_dir.path_join(sub_dir), result)
func _export_selected_to_project() -> void:
var selected_items := asset_grid.get_selected_items()
var export_count := 0
var tags_modified := false
for idx in selected_items:
var path: String = asset_grid.get_item_metadata(idx)
if db["assets"].has(path):
var asset_type: int = db["assets"][path].get("type", AssetType.UNKNOWN)
var dest_dir := "res://assets/misc"
match asset_type:
AssetType.MODEL_3D: dest_dir = db["settings"].get("import_path_3d", "res://assets/models")
AssetType.IMAGE_2D: dest_dir = db["settings"].get("import_path_2d", "res://assets/images")
AssetType.AUDIO: dest_dir = db["settings"].get("import_path_audio", "res://assets/audio")
AssetType.SHADER: dest_dir = db["settings"].get("import_path_shader", "res://assets/shaders")
if not DirAccess.dir_exists_absolute(dest_dir):
DirAccess.make_dir_recursive_absolute(dest_dir)
var err: Error
# .gltf files reference external buffers (.bin) and textures that must be copied too
if path.get_extension().to_lower() == "gltf":
err = _export_gltf(path, dest_dir)
else:
err = DirAccess.copy_absolute(path, dest_dir + "/" + path.get_file())
if err == OK:
export_count += 1
# Automatically apply the "imported" tag
var tags: Array = db["assets"][path].get("tags", [])
if not tags.has("imported"):
tags.append("imported")
db["assets"][path]["tags"] = tags
tags_modified = true
else:
push_error("Failed to copy file: ", path)
if export_count > 0:
if tags_modified:
_save_database()
call_deferred("_update_tags_tree")
_update_tag_ui()
if Engine.is_editor_hint():
_scan_when_idle()
var original_text := send_to_project_button.text
send_to_project_button.text = "Exported " + str(export_count) + " file(s)!"
get_tree().create_timer(2.0).timeout.connect(func() -> void: send_to_project_button.text = original_text)
# scan() is silently ignored while the editor is already scanning (including the frames after a
# scan's thread ends but before its results are applied), and that scan may have started before the
# exported files were written. Retry until a scan of our own actually starts.
func _scan_when_idle() -> void:
var fs := EditorInterface.get_resource_filesystem()
for attempt in SCAN_RETRY_MAX_FRAMES:
if not fs.is_scanning() and not fs.is_importing():
fs.scan()
if fs.is_scanning():
return
await get_tree().process_frame
push_warning("Global Asset Manager: the editor stayed busy, so exported files will be imported on its next filesystem scan.")
func _filter_by_tag(tag_text: String) -> void:
if not _active_filter_tags.has(tag_text):
_active_filter_tags.append(tag_text)
_current_page = 0
_populate_asset_grid()
_handle_selection_change()
call_deferred("_update_tags_tree")
func _find_meshes_recursive(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_find_meshes_recursive(child, result)
func _get_placeholder_icon(asset_type: int) -> Texture2D:
if not Engine.is_editor_hint():
return _blank_placeholder_tex
var icon_name := "File"
match asset_type:
AssetType.MODEL_3D: icon_name = "MeshInstance3D"
AssetType.AUDIO: icon_name = "AudioStreamPlayer"
AssetType.SHADER: icon_name = "Shader"
var theme := EditorInterface.get_editor_theme()
if theme and theme.has_icon(icon_name, "EditorIcons"):
return theme.get_icon(icon_name, "EditorIcons")
return _blank_placeholder_tex
func _get_shared_tags() -> Array[String]:
var selected := asset_grid.get_selected_items()
if selected.is_empty():
return []
var path0: String = asset_grid.get_item_metadata(selected[0])
if not db["assets"].has(path0):
return []
var shared_tags: Array = db["assets"][path0].get("tags", []).duplicate()
for i in range(1, selected.size()):
var path: String = asset_grid.get_item_metadata(selected[i])
if db["assets"].has(path):
var current_tags: Array = db["assets"][path].get("tags", [])
var intersection: Array = []
for tag in shared_tags:
if current_tags.has(tag):
intersection.append(tag)
shared_tags = intersection
else:
return []
var result: Array[String] = []
result.assign(shared_tags)
return result
func _queue_thumbnail(path: String, item_idx: int, version: int) -> void:
_thumbnail_queue.append({"path": path, "idx": item_idx, "version": version})
if not _is_processing_thumbnails:
_process_thumbnail_queue()
func _process_thumbnail_queue() -> void:
_is_processing_thumbnails = true
while _thumbnail_queue.size() > 0:
var req: Dictionary = _thumbnail_queue.pop_front()
# Abort if the user typed a new search or changed folders while this was loading
if req["version"] != _grid_population_version:
continue
var path: String = req["path"]
var item_idx: int = req["idx"]
if _thumbnail_cache.has(path):
if item_idx < asset_grid.item_count and asset_grid.get_item_metadata(item_idx) == path:
asset_grid.set_item_icon(item_idx, _thumbnail_cache[path])
continue
var img := Image.load_from_file(path)
if img:
var size := img.get_size()
var max_dim := maxf(size.x, size.y)
if max_dim > 64:
var scale := 64.0 / max_dim
img.resize(int(size.x * scale), int(size.y * scale), Image.INTERPOLATE_NEAREST)
var tex := ImageTexture.create_from_image(img)
_thumbnail_cache[path] = tex
if req["version"] == _grid_population_version and item_idx < asset_grid.item_count and asset_grid.get_item_metadata(item_idx) == path:
asset_grid.set_item_icon(item_idx, tex)
await get_tree().process_frame
_is_processing_thumbnails = false
func _get_type_tag(type: AssetType) -> String:
match type:
AssetType.MODEL_3D: return "3d_model"
AssetType.IMAGE_2D: return "2d_image"
AssetType.AUDIO: return "audio"
AssetType.SHADER: return "shader"
_: return "unknown"
func _handle_selection_change() -> void:
if _selection_update_pending:
return
_selection_update_pending = true
call_deferred("_deferred_selection_update")
func _load_3d_model(path: String) -> void:
var ext := path.get_extension().to_lower()
var state := GLTFState.new()
# The editor default extracts embedded textures next to the source file and imports them,
# which writes into the asset library; keep them in memory for the preview instead
state.handle_binary_image = GLTFState.HANDLE_BINARY_EMBED_AS_UNCOMPRESSED
var err: int = FAILED
if ext == "fbx":
var fbx := FBXDocument.new()
err = fbx.append_from_file(path, state)
if err == OK:
_loaded_3d_node = fbx.generate_scene(state)
else:
var gltf := GLTFDocument.new()
err = gltf.append_from_file(path, state)
if err == OK:
_loaded_3d_node = gltf.generate_scene(state)
if err == OK and _loaded_3d_node:
preview_3d_pivot.add_child(_loaded_3d_node)
_loaded_3d_node = _convert_importer_meshes(_loaded_3d_node)
_center_and_scale_3d_model(_loaded_3d_node)
else:
file_name_label.text += " (Failed to Parse 3D Model)"
func _load_audio(path: String) -> void:
var ext := path.get_extension().to_lower()
if ext == "wav":
audio_player.stream = _load_wav_from_file(path)
if audio_player.stream:
audio_player.play()
replay_button.text = "Stop"
return
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return
var buffer := file.get_buffer(file.get_length())
if ext == "ogg":
var stream := AudioStreamOggVorbis.load_from_buffer(buffer)
audio_player.stream = stream
audio_player.play()
replay_button.text = "Stop"
elif ext == "mp3":
var stream := AudioStreamMP3.new()
stream.data = buffer
audio_player.stream = stream
audio_player.play()
replay_button.text = "Stop"
func _load_database() -> void:
if FileAccess.file_exists(DB_FILE_PATH):
# Load the fast binary format
var file := FileAccess.open(DB_FILE_PATH, FileAccess.READ)
if file:
var data: Variant = file.get_var()
if data is Dictionary:
if data.has("assets"): db["assets"] = data["assets"]
if data.has("settings"): db["settings"] = data["settings"]
if data.has("folders"):
var parsed_folders: Variant = data["folders"]
if parsed_folders is Array:
db["folders"] = parsed_folders
elif parsed_folders is Dictionary:
db["folders"] = parsed_folders.keys()
elif FileAccess.file_exists("user://asset_database.json"):
# Legacy Migration: If the old JSON exists, load it and upgrade it to binary
var file := FileAccess.open("user://asset_database.json", FileAccess.READ)
var json_string := file.get_as_text()
var parsed: Variant = JSON.parse_string(json_string)
if parsed and parsed is Dictionary:
if parsed.has("assets"): db["assets"] = parsed["assets"]
if parsed.has("settings"): db["settings"] = parsed["settings"]
if parsed.has("folders"):
var parsed_folders: Variant = parsed["folders"]
if parsed_folders is Array:
db["folders"] = parsed_folders
elif parsed_folders is Dictionary:
db["folders"] = parsed_folders.keys()
_save_database() # Instantly saves the data into the new .dat format
# Default Settings Checks
if not db["settings"].has("grid_icon_size"):
db["settings"]["grid_icon_size"] = 40
if not db["settings"].has("wav_preview_length"):
db["settings"]["wav_preview_length"] = 10
_grid_icon_size = db["settings"]["grid_icon_size"]
func _load_wav_from_file(path: String) -> AudioStreamWAV:
var file := FileAccess.open(path, FileAccess.READ)
if not file: return null
var stream := AudioStreamWAV.new()
var riff := file.get_buffer(4).get_string_from_ascii()
if riff != "RIFF": return null
file.get_32()
var wave := file.get_buffer(4).get_string_from_ascii()
if wave != "WAVE": return null
var format_found := false
var data_found := false
var format_tag: int = 0
var bits_per_sample: int = 0
var sample_rate: int = 0
var channels: int = 0
while file.get_position() < file.get_length() and not data_found:
var chunk_id := file.get_buffer(4).get_string_from_ascii()
if chunk_id.length() < 4:
break
var chunk_size := file.get_32()
var next_chunk_pos := file.get_position() + chunk_size + (chunk_size % 2)
if chunk_id == "fmt ":
format_tag = file.get_16()
channels = file.get_16()
sample_rate = file.get_32()
file.get_32()
file.get_16()
bits_per_sample = file.get_16()
stream.mix_rate = sample_rate
stream.stereo = (channels == 2)
if format_tag == 1:
if bits_per_sample == 8:
stream.format = AudioStreamWAV.FORMAT_8_BITS
elif bits_per_sample == 16 or bits_per_sample == 24:
stream.format = AudioStreamWAV.FORMAT_16_BITS
else:
push_error("Unsupported WAV bit depth: ", bits_per_sample, " in ", path)
return null
elif format_tag == 3 and bits_per_sample == 32:
stream.format = AudioStreamWAV.FORMAT_16_BITS
else:
push_error("Unsupported WAV format tag: ", format_tag, " in ", path)
return null
format_found = true
elif chunk_id == "data":
var preview_length: int = db["settings"].get("wav_preview_length", 10)
var bytes_per_sec: int = sample_rate * channels * (bits_per_sample / 8)
var max_preview_bytes: int = bytes_per_sec * preview_length
var bytes_to_read: int = mini(chunk_size, max_preview_bytes)
var raw_data := file.get_buffer(bytes_to_read)
if format_tag == 1 and bits_per_sample == 24:
stream.data = _convert_24bit_to_16bit(raw_data)
elif format_tag == 3 and bits_per_sample == 32:
stream.data = _convert_32bit_float_to_16bit(raw_data)
else:
stream.data = raw_data
data_found = true
file.seek(next_chunk_pos)
if not format_found or not data_found:
push_error("Could not find valid fmt/data chunks in WAV: ", path)
return null
return stream
func _on_asset_grid_item_selected(_index: int) -> void:
_handle_selection_change()
func _on_asset_grid_multi_selected(_index: int, _selected: bool) -> void:
_handle_selection_change()
func _on_audio_finished() -> void:
replay_button.text = "Replay"
func _on_database_cleared() -> void:
db["assets"].clear()
db["folders"].clear()
_save_database()
all_known_tags.clear()
_active_filter_tags.clear()
_current_filter_folder = ""
_search_query = ""
search_input.clear()
_rebuild_nav_tree()
_populate_asset_grid()
_handle_selection_change()
func _on_folder_selected(path: String) -> void:
scan_directory(path)
func _on_light_mode_pressed() -> void:
_light_mode_idx = (_light_mode_idx + 1) % 3
match _light_mode_idx:
0:
light_mode_btn.text = "Lit"
preview_3d_viewport.debug_draw = Viewport.DEBUG_DRAW_DISABLED
1:
light_mode_btn.text = "Unlit"
preview_3d_viewport.debug_draw = Viewport.DEBUG_DRAW_UNSHADED
2:
light_mode_btn.text = "Wireframe"
preview_3d_viewport.debug_draw = Viewport.DEBUG_DRAW_WIREFRAME
func _on_nav_tree_item_edited() -> void:
var item := nav_tree.get_edited()
var meta: Variant = item.get_metadata(0)
if meta and meta is Dictionary and meta.get("type") == "tag":
var tag_val: String = meta.get("value", "")
var is_checked: bool = item.is_checked(0)
if is_checked and not _active_filter_tags.has(tag_val):
_active_filter_tags.append(tag_val)
elif not is_checked and _active_filter_tags.has(tag_val):
_active_filter_tags.erase(tag_val)
_current_page = 0
_populate_asset_grid()
_handle_selection_change()
call_deferred("_update_tags_tree")
func _on_nav_tree_item_mouse_selected(position: Vector2, mouse_button_index: int) -> void:
if mouse_button_index == MOUSE_BUTTON_RIGHT:
var item := nav_tree.get_item_at_position(position)
if item:
var meta: Variant = item.get_metadata(0)
if meta and meta is Dictionary:
tag_context_menu.clear()
if meta.get("type") == "tag":
item.deselect(0)
_select_folder_in_tree(_current_filter_folder)
_tag_to_delete = meta.get("value", "")
tag_context_menu.add_item("Delete Tag Globally", 0)
tag_context_menu.position = DisplayServer.mouse_get_position()
tag_context_menu.popup()
elif meta.get("type") == "folder":
item.select(0)
_folder_context_path = meta.get("path", "")
tag_context_menu.add_item("Rescan Folder", 1)
tag_context_menu.add_item("Remove Folder", 2)
tag_context_menu.position = DisplayServer.mouse_get_position()
tag_context_menu.popup()
func _on_nav_tree_item_selected() -> void:
var selected := nav_tree.get_selected()
if not selected:
return
var meta: Variant = selected.get_metadata(0)
if meta and meta is Dictionary:
if meta.get("type") == "folder":
var new_path: String = meta.get("path", "")
if _current_filter_folder == new_path:
return
_current_filter_folder = new_path
_current_page = 0
_tag_display_limit = 20
_populate_asset_grid()
_handle_selection_change()
call_deferred("_update_tags_tree")
elif meta.get("type") == "folder_root":
if _current_filter_folder == "":
return
_current_filter_folder = ""
_current_page = 0
_tag_display_limit = 20
_populate_asset_grid()
_handle_selection_change()
call_deferred("_update_tags_tree")
elif meta.get("type") == "load_more":
_tag_display_limit += 20
selected.deselect(0)
_select_folder_in_tree(_current_filter_folder)
call_deferred("_update_tags_tree")
elif meta.get("type") == "tag":
selected.deselect(0)
_select_folder_in_tree(_current_filter_folder)
else:
_select_folder_in_tree(_current_filter_folder)
func _on_next_page_pressed() -> void:
_current_page += 1
_populate_asset_grid()
_handle_selection_change()
func _on_open_external_pressed() -> void:
if not current_selected_path.is_empty():
OS.shell_open(current_selected_path)
func _on_open_location_pressed() -> void:
var selected_items := asset_grid.get_selected_items()
for idx in selected_items:
var path: String = asset_grid.get_item_metadata(idx)
OS.shell_show_in_file_manager(path)
func _on_prev_page_pressed() -> void:
_current_page -= 1
_populate_asset_grid()
_handle_selection_change()
func _on_preview_gui_input(event: InputEvent) -> void:
if _current_asset_type == AssetType.MODEL_3D:
_preview_controller.handle_3d_input(event)
elif _current_asset_type == AssetType.IMAGE_2D:
_preview_controller.handle_2d_input(event)
func _on_replay_button_pressed() -> void:
if audio_player.playing:
audio_player.stop()
replay_button.text = "Replay"
elif audio_player.stream:
audio_player.play()
replay_button.text = "Stop"
func _on_scan_button_pressed() -> void:
folder_dialog.popup_centered()
func _on_search_text_changed(new_text: String) -> void:
_search_query = new_text
_current_page = 0
_populate_asset_grid()
_handle_selection_change()
func _on_send_to_project_pressed() -> void:
_export_selected_to_project()
func _on_settings_button_pressed() -> void:
settings_dialog.popup_centered()
func _on_settings_changed() -> void:
_update_audio_volume()
_populate_asset_grid()
func _on_tag_context_menu_id_pressed(id: int) -> void:
if id == 0 and not _tag_to_delete.is_empty():
_delete_tag_globally(_tag_to_delete)
elif id == 1 and not _folder_context_path.is_empty():
_rescan_folder(_folder_context_path)
elif id == 2 and not _folder_context_path.is_empty():
_remove_folder(_folder_context_path)
func _on_tag_input_submitted(new_text: String) -> void:
tag_input_field.clear()
add_tag_to_selected_assets(new_text)
_update_tag_ui()
func _on_tag_input_text_changed(_new_text: String) -> void:
_update_tag_ui()
func _on_view_mode_toggled() -> void:
_is_grid_view = not _is_grid_view
view_mode_button.text = "View: Grid" if _is_grid_view else "View: List"
_apply_view_mode_settings()
_populate_asset_grid()
func _on_zoom_in_pressed() -> void:
_grid_icon_size = mini(256, _grid_icon_size + 8)
db["settings"]["grid_icon_size"] = _grid_icon_size
_save_database()
_apply_view_mode_settings()
func _on_zoom_out_pressed() -> void:
_grid_icon_size = maxi(16, _grid_icon_size - 8)
db["settings"]["grid_icon_size"] = _grid_icon_size
_save_database()
_apply_view_mode_settings()
func _populate_asset_grid() -> void:
asset_grid.clear()
_grid_population_version += 1
var current_version := _grid_population_version
var matching_paths: Array[String] = []
var is_exact_search := false
var actual_search_query := _search_query
if _search_query.length() >= 2 and _search_query.begins_with("\"") and _search_query.ends_with("\""):
is_exact_search = true
actual_search_query = _search_query.substr(1, _search_query.length() - 2)
for path: String in db["assets"].keys():
var data: Dictionary = db["assets"][path]
var should_add := true
if not _current_filter_folder.is_empty() and not path.begins_with(_current_filter_folder):
should_add = false
if should_add and not _active_filter_tags.is_empty():
var asset_tags: Array = data.get("tags", [])
for t in _active_filter_tags:
if not asset_tags.has(t):
should_add = false
break
if should_add and not actual_search_query.is_empty():
var filename := path.get_file()
if is_exact_search:
if filename.findn(actual_search_query) == -1:
should_add = false
else:
if not actual_search_query.is_subsequence_ofn(filename):
should_add = false
if should_add:
matching_paths.append(path)
if not actual_search_query.is_empty():
matching_paths.sort_custom(func(a: String, b: String) -> bool:
var file_a := a.get_file()
var file_b := b.get_file()
var exact_a := file_a.findn(actual_search_query) != -1
var exact_b := file_b.findn(actual_search_query) != -1
if exact_a != exact_b:
return exact_a
return file_a.nocasecmp_to(file_b) < 0
)
else:
matching_paths.sort_custom(func(a: String, b: String) -> bool:
return a.get_file().nocasecmp_to(b.get_file()) < 0
)
var total_pages: int = maxi(1, ceili(matching_paths.size() / float(_items_per_page)))
_current_page = clampi(_current_page, 0, total_pages - 1)
page_label.text = "Page " + str(_current_page + 1) + " of " + str(total_pages)
prev_page_button.disabled = _current_page == 0
next_page_button.disabled = _current_page >= total_pages - 1
var start_idx: int = _current_page * _items_per_page
var end_idx: int = mini(start_idx + _items_per_page, matching_paths.size())
var items_added := 0
for i in range(start_idx, end_idx):
if current_version != _grid_population_version:
return
var path := matching_paths[i]
var filename := path.get_file()
var asset_type: int = db["assets"][path].get("type", AssetType.UNKNOWN)
var item_icon := _get_placeholder_icon(asset_type)
var idx := asset_grid.add_item(filename, item_icon)
asset_grid.set_item_metadata(idx, path)
asset_grid.set_item_tooltip(idx, path)
if asset_type == AssetType.IMAGE_2D:
_queue_thumbnail(path, idx, current_version)
items_added += 1
if items_added % 200 == 0:
await get_tree().process_frame
func _rebuild_nav_tree() -> void:
nav_tree.clear()
var root := nav_tree.create_item()
folders_root = nav_tree.create_item(root)
folders_root.set_text(0, "Scanned Folders")
folders_root.set_metadata(0, {"type": "folder_root"})
for folder: String in db["folders"]:
var f_item := nav_tree.create_item(folders_root)
f_item.set_text(0, folder.get_file())
f_item.set_metadata(0, {"type": "folder", "path": folder})
tags_root = nav_tree.create_item(root)
tags_root.set_text(0, "Tags")
_update_tags_tree()
_select_folder_in_tree(_current_filter_folder)
func _recursive_scan(dir: DirAccess, current_path: String, root_path: String) -> void:
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if dir.current_is_dir():
if file_name != "." and file_name != "..":
var next_dir_path := current_path + "/" + file_name
var next_dir := DirAccess.open(next_dir_path)
if next_dir:
_recursive_scan(next_dir, next_dir_path, root_path)
else:
var ext := file_name.get_extension().to_lower()
var full_path := current_path + "/" + file_name
var type := _determine_asset_type(ext)
if type != AssetType.UNKNOWN:
if not db["assets"].has(full_path):
var base_tag := _get_type_tag(type)
var initial_tags: Array[String] = []
initial_tags.append(base_tag)
var root_folder_name := root_path.get_file().strip_edges().to_lower().replace(" ", "_")
if not root_folder_name.is_empty() and not initial_tags.has(root_folder_name):
initial_tags.append(root_folder_name)
var relative_dir := current_path.trim_prefix(root_path)
if relative_dir.begins_with("/"):
relative_dir = relative_dir.substr(1)
if not relative_dir.is_empty():
var folder_parts := relative_dir.split("/")
for part in folder_parts:
var clean_part := part.strip_edges().to_lower().replace(" ", "_")
if not clean_part.is_empty() and not initial_tags.has(clean_part):
initial_tags.append(clean_part)
db["assets"][full_path] = {
"tags": initial_tags,
"type": type
}
file_name = dir.get_next()
dir.list_dir_end()
func _remove_folder(folder_path: String) -> void:
if db["folders"].has(folder_path):
db["folders"].erase(folder_path)
var paths_to_remove: Array[String] = []
for asset_path in db["assets"].keys():
if asset_path.begins_with(folder_path):
paths_to_remove.append(asset_path)
for p in paths_to_remove:
db["assets"].erase(p)
if _current_filter_folder == folder_path:
_current_filter_folder = ""
_save_database()
_rebuild_nav_tree()
_populate_asset_grid()
_handle_selection_change()
func _rescan_folder(folder_path: String) -> void:
var dir := DirAccess.open(folder_path)
if dir:
_recursive_scan(dir, folder_path, folder_path)
var paths_to_remove: Array[String] = []
for asset_path in db["assets"].keys():
if asset_path.begins_with(folder_path):
if not FileAccess.file_exists(asset_path):
paths_to_remove.append(asset_path)
for p in paths_to_remove:
db["assets"].erase(p)
_save_database()
_rebuild_nav_tree()
_populate_asset_grid()
db_updated.emit()
func _save_database() -> void:
var file := FileAccess.open(DB_FILE_PATH, FileAccess.WRITE)
if file:
file.store_var(db)
func _select_folder_in_tree(folder_path: String) -> void:
var root := nav_tree.get_root()
if not root or root.get_child_count() == 0:
return
var target_root := root.get_child(0)
if folder_path.is_empty():
target_root.select(0)
return
for i in range(target_root.get_child_count()):
var item := target_root.get_child(i)
var meta: Variant = item.get_metadata(0)
if meta and meta is Dictionary and meta.get("path") == folder_path:
item.select(0)
nav_tree.scroll_to_item(item)
return
target_root.select(0)
_current_filter_folder = ""
func _update_audio_volume() -> void:
var val: float = db["settings"].get("volume", 50.0)
if val <= 0:
audio_player.volume_db = -80.0
else:
audio_player.volume_db = linear_to_db(val / 100.0)
func _update_tag_ui() -> void:
for child in tag_flow_container.get_children():
child.queue_free()
for child in available_tags_flow_container.get_children():
child.queue_free()
var selected_items := asset_grid.get_selected_items()
if selected_items.is_empty():
return
var shared_tags: Array[String] = _get_shared_tags()
for tag in shared_tags:
var btn := Button.new()
if tag.length() > _max_button_text_length:
btn.text = tag.substr(0, _max_button_text_length) + "..." + " (x)"
else:
btn.text = tag + " (x)"
var captured_tag := tag
btn.pressed.connect(func() -> void: remove_tag_from_selected_assets(captured_tag))
btn.gui_input.connect(func(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
_filter_by_tag(captured_tag)
)
btn.tooltip_text = tag + "\nLeft-Click: Remove tag\nRight-Click: Filter by tag"
tag_flow_container.add_child(btn)
var search_text := tag_input_field.text.strip_edges().to_lower().replace(" ", "_")
var available_tags: Array[String] = []
for tag in all_known_tags:
if not shared_tags.has(tag):
if search_text.is_empty() or search_text.is_subsequence_ofn(tag):
available_tags.append(tag)
if not search_text.is_empty():
available_tags.sort_custom(func(a: String, b: String) -> bool:
var exact_a := a.findn(search_text) != -1
var exact_b := b.findn(search_text) != -1
if exact_a != exact_b:
return exact_a
return a.nocasecmp_to(b) < 0
)
for tag in available_tags:
var btn := Button.new()
if tag.length() > _max_button_text_length:
btn.text = tag.substr(0, _max_button_text_length) + "..." + " (+)"
else:
btn.text = tag + " (+)"
var captured_tag := tag
btn.pressed.connect(func() -> void: add_tag_to_selected_assets(captured_tag))
btn.tooltip_text = tag
available_tags_flow_container.add_child(btn)
func _update_tags_tree() -> void:
if not is_instance_valid(tags_root):
return
var child := tags_root.get_first_child()
while child:
var next := child.get_next()
child.free()
child = next
all_known_tags.clear()
var tag_counts := {}
for path: String in db["assets"].keys():
var data: Dictionary = db["assets"][path]
var should_add := true
if not _current_filter_folder.is_empty() and not path.begins_with(_current_filter_folder):
should_add = false
if should_add and not _active_filter_tags.is_empty():
var asset_tags: Array = data.get("tags", [])
for t in _active_filter_tags:
if not asset_tags.has(t):
should_add = false
break
if should_add:
var asset_tags: Array = data.get("tags", [])
for tag: String in asset_tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
for asset_data: Dictionary in db["assets"].values():
for tag: String in asset_data.get("tags", []):
if not all_known_tags.has(tag):
all_known_tags.append(tag)
var display_tags: Array[String] = []
for tag in tag_counts.keys():
display_tags.append(tag)
for active_tag in _active_filter_tags:
if not display_tags.has(active_tag):
display_tags.append(active_tag)
tag_counts[active_tag] = 0
var unselected_tags: Array[String] = []
for tag in display_tags:
if not _active_filter_tags.has(tag):
unselected_tags.append(tag)
unselected_tags.sort_custom(func(a: String, b: String) -> bool:
if tag_counts.get(a, 0) == tag_counts.get(b, 0):
return a < b
return tag_counts.get(a, 0) > tag_counts.get(b, 0)
)
var final_ordered_tags: Array[String] = []
final_ordered_tags.append_array(_active_filter_tags)
final_ordered_tags.append_array(unselected_tags)
var count := 0
for tag in final_ordered_tags:
if count >= _tag_display_limit:
var more_item := nav_tree.create_item(tags_root)
more_item.set_text(0, "Load More (+20)...")
more_item.set_metadata(0, {"type": "load_more"})
break
var t_item := nav_tree.create_item(tags_root)
t_item.set_cell_mode(0, TreeItem.CELL_MODE_CHECK)
var t_count: int = tag_counts.get(tag, 0)
t_item.set_text(0, "#" + tag + " (" + str(t_count) + ")")
t_item.set_editable(0, true)
t_item.set_checked(0, _active_filter_tags.has(tag))
t_item.set_metadata(0, {"type": "tag", "value": tag})
count += 1