Files
test/Assets/PerfectWorld/Scripts/Utils/Editor/FolderToAddressablesWindow.cs
T
vuong dinh hoang 8ba1f5fa6c for TiT
2026-05-06 15:15:45 +07:00

414 lines
14 KiB
C#

#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
namespace BrewMonster
{
/// <summary>
/// Assign a parent folder, then register all assets under it into an Addressables group named after that folder.
/// Addresses are relative to the parent folder, without the file extension (e.g. .png, .wav). Text files and
/// extensionless files and excluded extensions (e.g. .pk) are skipped; existing addressable entries for those are removed.
/// </summary>
public class FolderToAddressablesWindow : EditorWindow
{
private const string MenuPath = "Tools/Addressable/Folder To Addressables…";
private static readonly HashSet<string> TextExtensions = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase)
{
".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".ini", ".cfg", ".html", ".htm",
};
/// <summary>Extensions we never mark as Addressable (project-specific binary / sidecar types).</summary>
private static readonly HashSet<string> NeverAddressableExtensions = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase)
{
".pk", ".sfk", ".sw",
};
private DefaultAsset _parentFolder;
private bool _includeFolderName;
private bool _overrideExisting;
private Vector2 _scroll;
private string _log = "";
[MenuItem(MenuPath)]
public static void Open()
{
var w = GetWindow<FolderToAddressablesWindow>(false, "Folder To Addressables", true);
w.minSize = new Vector2(420, 280);
w.Show();
}
private void OnGUI()
{
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("Folder To Addressables", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Group name = last segment of the folder path. Two different paths with the same folder name share one group. " +
"Address keys omit file extensions (e.g. Sub/Sprite not Sub/Sprite.png). " +
"If nothing updates, ensure the target group is not Read Only in the Addressables Groups window. " +
"Files with no extension (e.g. raw data), .pk, and other excluded types are not made addressable.",
MessageType.Info);
EditorGUILayout.Space(4);
var newFolder = (DefaultAsset)EditorGUILayout.ObjectField(
"Parent folder", _parentFolder, typeof(DefaultAsset), false);
if (newFolder != _parentFolder)
{
_parentFolder = newFolder;
_log = "";
}
string parentPath = _parentFolder != null ? AssetDatabase.GetAssetPath(_parentFolder) : null;
bool validFolder = !string.IsNullOrEmpty(parentPath) && AssetDatabase.IsValidFolder(parentPath);
if (_parentFolder != null && !validFolder)
{
EditorGUILayout.HelpBox("Assign a folder from the Project window (not a file).", MessageType.Warning);
}
string groupName = validFolder ? Path.GetFileName(parentPath.TrimEnd('/')) : "";
EditorGUILayout.LabelField("Addressables group", string.IsNullOrEmpty(groupName) ? "—" : groupName);
_includeFolderName = EditorGUILayout.Toggle(
new GUIContent(
"Include folder name in address",
"When on, the dropped folder name is prefixed to every address (e.g. gfx/a/b instead of a/b)."),
_includeFolderName);
_overrideExisting = EditorGUILayout.Toggle(
new GUIContent(
"Override existing addressables",
"When on, all assets under the folder are moved to this group and addresses are set. When off, assets that already have an Addressables entry are skipped."),
_overrideExisting);
EditorGUILayout.Space(8);
EditorGUI.BeginDisabledGroup(!validFolder);
if (GUILayout.Button("Start", GUILayout.Height(28)))
{
Run(parentPath, groupName);
}
EditorGUI.EndDisabledGroup();
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("Log", EditorStyles.boldLabel);
_scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.ExpandHeight(true));
EditorGUILayout.TextArea(string.IsNullOrEmpty(_log) ? "(empty)" : _log, EditorStyles.textArea, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
}
private void Run(string parentPath, string groupName)
{
_log = "";
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null)
{
AppendLog("ERROR: Addressable settings not found. Install/configure the Addressables package.");
EditorUtility.DisplayDialog("Folder To Addressables", "Addressable settings not found.", "OK");
return;
}
if (string.IsNullOrEmpty(groupName))
{
AppendLog("ERROR: Could not derive group name from folder path.");
return;
}
var group = GetOrCreateGroup(settings, groupName);
if (group == null)
{
AppendLog($"ERROR: Could not find or create group: {groupName}");
EditorUtility.DisplayDialog("Folder To Addressables", $"Could not find or create group: {groupName}", "OK");
return;
}
if (group.ReadOnly)
{
AppendLog(
"ERROR: The target Addressables group is Read Only. Unity will not apply CreateOrMoveEntry/SetAddress. " +
"Open Window > Asset Management > Addressables > Groups, select the group, and disable Read Only in the Inspector.");
EditorUtility.DisplayDialog(
"Folder To Addressables",
$"Group \"{groupName}\" is Read Only. Disable Read Only on that group, then run this tool again.",
"OK");
return;
}
parentPath = parentPath.Replace('\\', '/').TrimEnd('/');
string parentPrefix = parentPath + "/";
int processed = 0;
int skippedText = 0;
int skippedNoExtension = 0;
int skippedExcludedExtension = 0;
int removedSkippedAddressable = 0;
int skippedOther = 0;
int skippedAlreadyAddressable = 0;
int errors = 0;
// --- Pass 1: Remove blocked entries already in this group ---
// AssetDatabase.FindAssets does NOT return extensionless files, so they would never reach
// the removal logic in the main loop. We clean them up here by iterating group.entries directly.
// Snapshot entries first to avoid modifying the collection while iterating.
if (group.entries != null)
{
var snapshot = new List<AddressableAssetEntry>(group.entries);
foreach (var existing in snapshot)
{
if (existing == null) continue;
string ap = existing.AssetPath.Replace('\\', '/');
if (!ap.StartsWith(parentPrefix, System.StringComparison.OrdinalIgnoreCase)) continue;
if (AssetDatabase.IsValidFolder(ap)) continue;
if (ShouldSkipExtensionless(ap) || ShouldSkipNeverAddressableExtension(ap))
{
if (settings.RemoveAssetEntry(existing.guid, false))
{
removedSkippedAddressable++;
AppendLog($"REMOVED (blocked type): {ap}");
}
}
}
}
// --- Pass 2: Collect processable GUIDs ---
// FindAssets returns recognized asset files. When overriding, also fold in group entries
// so assets already in this group get their addresses refreshed.
var guidsToProcess = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
foreach (var g in AssetDatabase.FindAssets("", new[] { parentPath }))
guidsToProcess.Add(g);
if (_overrideExisting && group.entries != null)
{
foreach (var existing in group.entries)
{
if (existing == null) continue;
string ap = existing.AssetPath;
if (string.IsNullOrEmpty(ap)) continue;
ap = ap.Replace('\\', '/');
if (AssetDatabase.IsValidFolder(ap)) continue;
if (!ap.StartsWith(parentPrefix, System.StringComparison.OrdinalIgnoreCase)) continue;
guidsToProcess.Add(existing.guid);
}
}
foreach (string guid in guidsToProcess)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(assetPath))
{
skippedOther++;
continue;
}
assetPath = assetPath.Replace('\\', '/');
if (AssetDatabase.IsValidFolder(assetPath))
{
skippedOther++;
continue;
}
// Require path under parent folder (avoid matching sibling folders e.g. .../A vs .../AB).
if (!assetPath.StartsWith(parentPrefix, System.StringComparison.OrdinalIgnoreCase))
{
skippedOther++;
continue;
}
if (ShouldSkipAsText(assetPath))
{
skippedText++;
continue;
}
if (ShouldSkipNeverAddressableExtension(assetPath))
{
TryRemoveAddressableIfPresent(settings, guid, ref removedSkippedAddressable);
skippedExcludedExtension++;
continue;
}
if (ShouldSkipExtensionless(assetPath))
{
TryRemoveAddressableIfPresent(settings, guid, ref removedSkippedAddressable);
skippedNoExtension++;
continue;
}
string relative = GetRelativeAddress(parentPath, assetPath);
if (string.IsNullOrEmpty(relative))
{
skippedOther++;
continue;
}
if (!_overrideExisting && settings.FindAssetEntry(guid) != null)
{
skippedAlreadyAddressable++;
continue;
}
try
{
var existingEntry = settings.FindAssetEntry(guid);
if (existingEntry != null && existingEntry.parentGroup != null && existingEntry.parentGroup.ReadOnly)
{
AppendLog($"ERROR: Cannot move or edit (source group is Read Only): {assetPath}");
errors++;
continue;
}
var entry = settings.CreateOrMoveEntry(guid, group, false, false);
if (entry == null)
{
AppendLog($"ERROR: CreateOrMoveEntry failed: {assetPath}");
errors++;
continue;
}
if (_includeFolderName)
relative = groupName + "/" + relative;
string newAddress = ToAddressWithoutExtension(relative);
entry.SetAddress(newAddress, false);
processed++;
}
catch (System.Exception ex)
{
AppendLog($"ERROR: {assetPath} — {ex.Message}");
errors++;
}
}
if (processed > 0 || removedSkippedAddressable > 0)
{
settings.SetDirty(AddressableAssetSettings.ModificationEvent.EntryModified, null, true);
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
}
AppendLog("");
AppendLog(
$"Done. Processed: {processed}, removed (skipped types): {removedSkippedAddressable}, " +
$"skipped (already addressable): {skippedAlreadyAddressable}, " +
$"skipped (text): {skippedText}, skipped (excluded ext): {skippedExcludedExtension}, " +
$"skipped (no extension): {skippedNoExtension}, skipped (other): {skippedOther}, errors: {errors}");
EditorUtility.DisplayDialog(
"Folder To Addressables",
$"Processed: {processed}\nRemoved addressable (skipped types): {removedSkippedAddressable}\n" +
$"Skipped (already addressable): {skippedAlreadyAddressable}\n" +
$"Skipped (text): {skippedText}\nSkipped (excluded ext, e.g. .pk): {skippedExcludedExtension}\n" +
$"Skipped (no extension): {skippedNoExtension}\nSkipped (other): {skippedOther}\nErrors: {errors}\nGroup: {groupName}",
"OK");
}
private static bool ShouldSkipAsText(string assetPath)
{
var mainType = AssetDatabase.GetMainAssetTypeAtPath(assetPath);
if (mainType == typeof(TextAsset))
return true;
string ext = Path.GetExtension(assetPath);
return !string.IsNullOrEmpty(ext) && TextExtensions.Contains(ext);
}
private static bool ShouldSkipNeverAddressableExtension(string assetPath)
{
string ext = Path.GetExtension(assetPath);
return !string.IsNullOrEmpty(ext) && NeverAddressableExtensions.Contains(ext);
}
/// <summary>
/// Skip generic/extensionless files (e.g. raw audio bytes) that show as plain assets in the Project window.
/// </summary>
private static bool ShouldSkipExtensionless(string assetPath)
{
return string.IsNullOrEmpty(Path.GetExtension(assetPath));
}
private static void TryRemoveAddressableIfPresent(AddressableAssetSettings settings, string guid, ref int removedCount)
{
if (settings.FindAssetEntry(guid) != null && settings.RemoveAssetEntry(guid, false))
removedCount++;
}
private static string GetRelativeAddress(string parentPath, string assetPath)
{
assetPath = assetPath.Replace('\\', '/');
parentPath = parentPath.Replace('\\', '/').TrimEnd('/');
if (string.Equals(assetPath, parentPath, System.StringComparison.OrdinalIgnoreCase))
return null;
string prefix = parentPath + "/";
if (!assetPath.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase))
return null;
return assetPath.Substring(prefix.Length);
}
/// <summary>
/// Address key: relative path with the final file extension removed (.png, .wav, .prefab, etc.).
/// </summary>
private static string ToAddressWithoutExtension(string relativePath)
{
if (string.IsNullOrEmpty(relativePath))
return relativePath;
relativePath = relativePath.Replace('\\', '/');
int lastSlash = relativePath.LastIndexOf('/');
string filePart = lastSlash >= 0 ? relativePath.Substring(lastSlash + 1) : relativePath;
string dirPart = lastSlash >= 0 ? relativePath.Substring(0, lastSlash) : "";
string fileNoExt = Path.GetFileNameWithoutExtension(filePart);
if (string.IsNullOrEmpty(fileNoExt))
fileNoExt = filePart;
if (string.IsNullOrEmpty(dirPart))
return fileNoExt;
return dirPart + "/" + fileNoExt;
}
private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName)
{
if (settings == null || string.IsNullOrEmpty(groupName))
return null;
AddressableAssetGroup group = settings.FindGroup(groupName);
if (group != null)
return group;
try
{
group = settings.CreateGroup(groupName, false, false, true, null);
if (group != null)
{
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
}
return group;
}
catch
{
return null;
}
}
private void AppendLog(string line)
{
if (string.IsNullOrEmpty(_log))
_log = line;
else
_log += "\n" + line;
Debug.Log($"[Folder To Addressables] {line}");
Repaint();
}
}
}
#endif