389 lines
13 KiB
C#
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
|