Files
test/Assets/PerfectWorld/Scripts/Utils/Editor/FolderToAddressablesWindow.cs
2026-04-15 17:35:01 +07:00

389 lines
13 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 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);
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 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; we also fold in remaining group entries
// (e.g. assets already addressable in this group) so their addresses get refreshed.
var guidsToProcess = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
foreach (var g in AssetDatabase.FindAssets("", new[] { parentPath }))
guidsToProcess.Add(g);
if (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;
}
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;
}
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 (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 (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