#if UNITY_EDITOR using FishNet.Configuring; using FishNet.Managing.Object; using FishNet.Object; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; using UnityDebug = UnityEngine.Debug; namespace FishNet.Editing.PrefabCollectionGenerator { internal sealed class Generator : AssetPostprocessor { public Generator() { if (!_subscribed) { _subscribed = true; EditorApplication.update += OnEditorUpdate; } } ~Generator() { if (_subscribed) { _subscribed = false; EditorApplication.update -= OnEditorUpdate; } } #region Types. private struct SpecifiedFolder { public string Path; public bool Recursive; public SpecifiedFolder(string path, bool recursive) { Path = path; Recursive = recursive; } } #endregion #region Public. /// /// True to ignore post process changes. /// public static bool IgnorePostProcess = false; #endregion #region Private. /// /// Last asset to import when there was only one imported asset and no other changes. /// private static string _lastSingleImportedAsset = string.Empty; /// /// Cached DefaultPrefabObjects reference. /// private static DefaultPrefabObjects _cachedDefaultPrefabs; /// /// True to refresh prefabs next update. /// private static bool _retryRefreshDefaultPrefabs; /// /// True if already subscribed to EditorApplication.Update. /// private static bool _subscribed; /// /// True if ran once since editor started. /// [System.NonSerialized] private static bool _ranOnce; /// /// Last paths of updated nobs during a changed update. /// [System.NonSerialized] private static List _lastUpdatedNamePaths = new List(); /// /// Last frame changed was updated. /// [System.NonSerialized] private static int _lastUpdatedFrame = -1; /// /// Length of assets strings during the last update. /// [System.NonSerialized] private static int _lastUpdatedLengths = -1; #endregion public static string[] GetPrefabFiles(string startingPath, HashSet excludedPaths, bool recursive) { //Opportunity to exit early if there are no excluded paths. if (excludedPaths.Count == 0) { string[] strResults = Directory.GetFiles(startingPath, "*.prefab", SearchOption.AllDirectories); return strResults; } //starting path is excluded. if (excludedPaths.Contains(startingPath)) return new string[0]; //Folders remaining to be iterated. List enumeratedCollection = new List() { startingPath }; //Only check other directories if recursive. if (recursive) { //Find all folders which aren't excluded. for (int i = 0; i < enumeratedCollection.Count; i++) { string[] allFolders = Directory.GetDirectories(enumeratedCollection[i], "*", SearchOption.TopDirectoryOnly); for (int z = 0; z < allFolders.Length; z++) { string current = allFolders[z]; //Not excluded. if (!excludedPaths.Contains(current)) enumeratedCollection.Add(current); } } } //Valid prefab files. List results = new List(); //Build files from folders. int count = enumeratedCollection.Count; for (int i = 0; i < count; i++) { string[] r = Directory.GetFiles(enumeratedCollection[i], "*.prefab", SearchOption.TopDirectoryOnly); results.AddRange(r); } return results.ToArray(); } /// /// Removes paths which may overlap each other, such as sub directories. /// private static void RemoveOverlappingFolders(List folders) { for (int z = 0; z < folders.Count; z++) { for (int i = 0; i < folders.Count; i++) { //Do not check against self. if (i == z) continue; //Duplicate. if (folders[z].Path.Equals(folders[i].Path, System.StringComparison.OrdinalIgnoreCase)) { UnityDebug.LogError($"The same path is specified multiple times in the DefaultPrefabGenerator settings. Remove the duplicate to clear this error."); folders.RemoveAt(i); break; } /* We are checking if i can be within * z. This is only possible if i is longer * than z. */ if (folders[i].Path.Length < folders[z].Path.Length) continue; /* Do not need to check if not recursive. * Only recursive needs to be checked because * a shorter recursive path could contain * a longer path. */ if (!folders[z].Recursive) continue; //Compare paths. string zPath = GetPathWithSeparator(folders[z].Path); string iPath = zPath.Substring(0, zPath.Length); //If paths match. if (iPath.Equals(zPath, System.StringComparison.OrdinalIgnoreCase)) { UnityDebug.LogError($"Path {folders[i].Path} is included within recursive path {folders[z].Path}. Remove path {folders[i].Path} to clear this error."); folders.RemoveAt(i); break; } } } string GetPathWithSeparator(string txt) { return txt.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; } } /// /// Returns a message to attach to logs if objects were dirtied. /// private static string GetDirtiedMessage(PrefabGeneratorConfigurations settings, bool dirtied) { if (!settings.SaveChanges && dirtied) return " One or more NetworkObjects were dirtied. Please save your project."; else return string.Empty; } /// /// Updates prefabs by using only changed information. /// public static void GenerateChanged(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths, PrefabGeneratorConfigurations settings = null) { if (settings == null) settings = Configuration.Configurations.PrefabGenerator; if (!settings.Enabled) return; bool log = settings.LogToConsole; Stopwatch sw = (log) ? Stopwatch.StartNew() : null; DefaultPrefabObjects prefabCollection = GetDefaultPrefabObjects(settings); //No need to error if nto found, GetDefaultPrefabObjects will. if (prefabCollection == null) return; int assetsLength = (importedAssets.Length + deletedAssets.Length + movedAssets.Length + movedFromAssetPaths.Length); List changedNobPaths = new List(); System.Type goType = typeof(UnityEngine.GameObject); IterateAssetCollection(importedAssets); IterateAssetCollection(movedAssets); //True if dirtied by changes. bool dirtied; //First remove null entries. int startCount = prefabCollection.GetObjectCount(); prefabCollection.RemoveNull(); dirtied = (prefabCollection.GetObjectCount() != startCount); //First index which new objects will be added to. int firstAddIndex = (prefabCollection.GetObjectCount() - 1); //Iterates strings adding prefabs to collection. void IterateAssetCollection(string[] c) { foreach (string item in c) { System.Type assetType = AssetDatabase.GetMainAssetTypeAtPath(item); if (assetType != goType) continue; NetworkObject nob = AssetDatabase.LoadAssetAtPath(item); if (nob != null) { changedNobPaths.Add(item); prefabCollection.AddObject(nob, true); dirtied = true; } } } //To prevent out of range. if (firstAddIndex < 0 || firstAddIndex >= prefabCollection.GetObjectCount()) firstAddIndex = 0; dirtied |= prefabCollection.SetAssetPathHashes(firstAddIndex); if (log && dirtied) UnityDebug.Log($"Default prefab generator updated prefabs in {sw.ElapsedMilliseconds}ms.{GetDirtiedMessage(settings, dirtied)}"); //Check for redundancy. int frameCount = Time.frameCount; int changedCount = changedNobPaths.Count; if (frameCount == _lastUpdatedFrame && assetsLength == _lastUpdatedLengths && (changedCount == _lastUpdatedNamePaths.Count) && changedCount > 0) { bool allMatch = true; for (int i = 0; i < changedCount; i++) { if (changedNobPaths[i] != _lastUpdatedNamePaths[i]) { allMatch = false; break; } } /* If the import results are the same as the last attempt, on the same frame * then there is likely an issue saving the assets. */ if (allMatch) { //Unset dirtied to prevent a save. dirtied = false; //Log this no matter what, it's critical. UnityDebug.LogError($"Default prefab generator had a problem saving one or more assets. " + $"This usually occurs when the assets cannot be saved due to missing scripts or serialization errors. " + $"Please see above any prefabs which could not save any make corrections."); } } //Set last values. _lastUpdatedFrame = Time.frameCount; _lastUpdatedNamePaths = changedNobPaths; _lastUpdatedLengths = assetsLength; EditorUtility.SetDirty(prefabCollection); if (dirtied && settings.SaveChanges) AssetDatabase.SaveAssets(); } /// /// Generates prefabs by iterating all files within settings parameters. /// public static void GenerateFull(PrefabGeneratorConfigurations settings = null, bool forced = false) { if (settings == null) settings = Configuration.Configurations.PrefabGenerator; if (!forced && !settings.Enabled) return; bool log = settings.LogToConsole; Stopwatch sw = (log) ? Stopwatch.StartNew() : null; List foundNobs = new List(); HashSet excludedPaths = new HashSet(settings.ExcludedFolders); //If searching the entire project. if (settings.SearchScope == (int)SearchScopeType.EntireProject) { foreach (string path in GetPrefabFiles("Assets", excludedPaths, true)) { NetworkObject nob = AssetDatabase.LoadAssetAtPath(path); if (nob != null) foundNobs.Add(nob); } } //Specific folders. else if (settings.SearchScope == (int)SearchScopeType.SpecificFolders) { List folders = GetSpecifiedFolders(settings.IncludedFolders.ToList()); RemoveOverlappingFolders(folders); foreach (SpecifiedFolder sf in folders) { //If specified folder doesn't exist then continue. if (!Directory.Exists(sf.Path)) continue; foreach (string path in GetPrefabFiles(sf.Path, excludedPaths, sf.Recursive)) { NetworkObject nob = AssetDatabase.LoadAssetAtPath(path); if (nob != null) foundNobs.Add(nob); } } } //Unhandled. else { UnityDebug.LogError($"{settings.SearchScope} is not handled; default prefabs will not generator properly."); } DefaultPrefabObjects prefabCollection = GetDefaultPrefabObjects(settings); //No need to error if not found, GetDefaultPrefabObjects will throw. if (prefabCollection == null) return; //Clear and add built list. prefabCollection.Clear(); prefabCollection.AddObjects(foundNobs, false); bool dirtied = prefabCollection.SetAssetPathHashes(0); int newCount = prefabCollection.GetObjectCount(); if (log) { string dirtiedMessage = (newCount > 0) ? GetDirtiedMessage(settings, dirtied) : string.Empty; UnityDebug.Log($"Default prefab generator found {newCount} prefabs in {sw.ElapsedMilliseconds}ms.{dirtiedMessage}"); } //Only set dirty if and save if prefabs were found. if (newCount > 0) { EditorUtility.SetDirty(prefabCollection); if (settings.SaveChanges) AssetDatabase.SaveAssets(); } } /// /// Iterates folders building them into SpecifiedFolders. /// private static List GetSpecifiedFolders(List folders) { List results = new List(); //Remove astericks. foreach (string path in folders) { int pLength = path.Length; if (pLength == 0) continue; bool recursive; string p; //If the last character indicates resursive. if (path.Substring(pLength - 1, 1) == "*") { p = path.Substring(0, pLength - 1); recursive = true; } else { p = path; recursive = false; } results.Add(new SpecifiedFolder(p, recursive)); } return results; } /// /// Returns the DefaultPrefabObjects file. /// private static DefaultPrefabObjects GetDefaultPrefabObjects(PrefabGeneratorConfigurations settings = null) { if (settings == null) settings = Configuration.Configurations.PrefabGenerator; //Load the prefab collection string defaultPrefabsPath = settings.DefaultPrefabObjectsPath; defaultPrefabsPath = defaultPrefabsPath.Replace(@"\", "/"); string fullDefaultPrefabsPath = (defaultPrefabsPath.Length > 0) ? Path.GetFullPath(defaultPrefabsPath) : string.Empty; //If cached prefabs is not the same path as assetPath. if (_cachedDefaultPrefabs != null) { string unityAssetPath = AssetDatabase.GetAssetPath(_cachedDefaultPrefabs); string fullCachedPath = (unityAssetPath.Length > 0) ? Path.GetFullPath(unityAssetPath) : string.Empty; if (fullCachedPath != fullDefaultPrefabsPath) _cachedDefaultPrefabs = null; } //If cached is null try to get it. if (_cachedDefaultPrefabs == null) { //Only try to load it if file exist. if (File.Exists(fullDefaultPrefabsPath)) { _cachedDefaultPrefabs = AssetDatabase.LoadAssetAtPath(defaultPrefabsPath); if (_cachedDefaultPrefabs == null) { //If already retried then throw an error. if (_retryRefreshDefaultPrefabs) { UnityDebug.LogError("DefaultPrefabObjects file exists but it could not be loaded by Unity. Use the Fish-Networking menu to Refresh Default Prefabs."); } else { UnityDebug.Log("DefaultPrefabObjects file exists but it could not be loaded by Unity. Trying to reload the file next frame."); _retryRefreshDefaultPrefabs = true; } return null; } } } if (_cachedDefaultPrefabs == null) { string fullPath = Path.GetFullPath(defaultPrefabsPath); UnityDebug.Log($"Creating a new DefaultPrefabsObject at {fullPath}."); string directory = Path.GetDirectoryName(fullPath); if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); AssetDatabase.Refresh(); } _cachedDefaultPrefabs = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(_cachedDefaultPrefabs, defaultPrefabsPath); AssetDatabase.SaveAssets(); } if (_cachedDefaultPrefabs != null && _retryRefreshDefaultPrefabs) UnityDebug.Log("DefaultPrefabObjects found on the second iteration."); return _cachedDefaultPrefabs; } /// /// Called every frame the editor updates. /// private static void OnEditorUpdate() { if (!_retryRefreshDefaultPrefabs) return; GenerateFull(); _retryRefreshDefaultPrefabs = false; } /// /// Called by Unity when assets are modified. /// private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { if (Application.isPlaying) return; //If retrying next frame don't bother updating, next frame will do a full refresh. if (_retryRefreshDefaultPrefabs) return; //Post process is being ignored. Could be temporary or user has disabled this feature. if (IgnorePostProcess) return; /* Don't iterate if updating or compiling as that could cause an infinite loop * due to the prefabs being generated during an update, which causes the update * to start over, which causes the generator to run again, which... you get the idea. */ if (EditorApplication.isCompiling) return; DefaultPrefabObjects prefabCollection = GetDefaultPrefabObjects(); if (prefabCollection == null) return; PrefabGeneratorConfigurations settings = Configuration.Configurations.PrefabGenerator; if (prefabCollection.GetObjectCount() == 0) { //If there are no prefabs then do a full rebuild. Odds of there being none are pretty much nill. GenerateFull(settings); } else { int totalChanges = importedAssets.Length + deletedAssets.Length + movedAssets.Length + movedFromAssetPaths.Length; //Nothing has changed. This shouldn't occur but unity is funny so we're going to check anyway. if (totalChanges == 0) return; //normalizes path. string dpoPath = Path.GetFullPath(settings.DefaultPrefabObjectsPath); //If total changes is 1 and the only changed file is the default prefab collection then do nothing. if (totalChanges == 1) { //Do not need to check movedFromAssetPaths because that's not possible for this check. if ((importedAssets.Length == 1 && Path.GetFullPath(importedAssets[0]) == dpoPath) || (deletedAssets.Length == 1 && Path.GetFullPath(deletedAssets[0]) == dpoPath) || (movedAssets.Length == 1 && Path.GetFullPath(movedAssets[0]) == dpoPath)) return; /* If the only change is an import then check if the imported file * is the same as the last, and if so check into returning early. * For some reason occasionally when files are saved unity runs postprocess * multiple times on the same file. */ string imported = (importedAssets.Length == 1) ? importedAssets[0] : null; if (imported != null && imported == _lastSingleImportedAsset) { //If here then the file is the same. Make sure it's already in the collection before returning. System.Type assetType = AssetDatabase.GetMainAssetTypeAtPath(imported); //Not a gameObject, no reason to continue. if (assetType != typeof(GameObject)) return; NetworkObject nob = AssetDatabase.LoadAssetAtPath(imported); //If is a networked object. if (nob != null) { //Already added! if (prefabCollection.Prefabs.Contains(nob)) return; } } else if (imported != null) { _lastSingleImportedAsset = imported; } } bool fullRebuild = settings.FullRebuild; /* If updating FN. This needs to be done a better way. * Parsing the actual version file would be better. * I'll get to it next release. */ if (!_ranOnce) { _ranOnce = true; fullRebuild = true; } else { CheckForVersionFile(importedAssets); CheckForVersionFile(deletedAssets); CheckForVersionFile(movedAssets); CheckForVersionFile(movedFromAssetPaths); } /* See if any of the changed files are the version file. * A new version file suggests an update. Granted, this could occur if * other assets imported a new version file as well but better * safe than sorry. */ void CheckForVersionFile(string[] arr) { string targetText = "VERSION.txt".ToLower(); int targetLength = targetText.Length; for (int i = 0; i < arr.Length; i++) { string item = arr[i]; int itemLength = item.Length; if (itemLength < targetLength) continue; item = item.ToLower(); int startIndex = (itemLength - targetLength); if (item.Substring(startIndex, targetLength) == targetText) { fullRebuild = true; return; } } } if (fullRebuild) GenerateFull(settings); else GenerateChanged(importedAssets, deletedAssets, movedAssets, movedFromAssetPaths, settings); } } } } #endif