Created
February 14, 2022 16:08
-
-
Save adamgit/fdaeba46788d611956a7008483773fda to your computer and use it in GitHub Desktop.
A generic solution for storing and retrieving per-Project settings in Unity seamlessly whether in a Package, a Project, or a DLL.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections; | |
using System.Collections.Generic; | |
using System.IO; | |
using System.Linq; | |
using UnityEditor.Compilation; | |
using UnityEngine; | |
using Assembly = System.Reflection.Assembly; | |
public class PackageSettings : ScriptableObject | |
{ | |
public bool debugMessages = true;//false; | |
/// <summary> | |
/// C# can handle | |
/// slash and backslash seamlessly, but Unity's employees have never fixed their API's to handle this, they still | |
/// use their own proprietary (incomplete) path handling based on 'string' instead of using Microsoft's / .Net's built-in API. | |
/// | |
/// We often have to handle slashes ourselves. This method forces everything to the standard (POSIX/UNIX), undoing | |
/// Microsoft's mistake with DOS all those decades ago. | |
/// </summary> | |
/// <param name="path"></param> | |
/// <returns></returns> | |
protected string ConvertPathToCrossPlatformPath(string path) | |
{ | |
return path.Replace('\\', '/'); | |
} | |
/// <summary> | |
/// I don't know when Packages is writable or not; Unity staff themselves seem confused by this, and the Editor does | |
/// nothing to help the situation. In general: let's not trust anyone, and by default NEVER store ANYTHING in | |
/// Packages | |
/// </summary> | |
/// <returns>true by default, forcing all Settings files to go in the project's Assets folder</returns> | |
public virtual bool ForceDefaultPathAlwaysInAssetsNotPackages() | |
{ | |
return true; | |
} | |
public virtual string PreferredDefaultPath(bool debug = false) | |
{ | |
var asmName = Assembly.GetAssembly(GetType()).GetName().Name; | |
/** | |
* The Assembly might be an asmdef, or might be a DLL -- Unity has different API calls for each. | |
* | |
* NB: Unity's APIs call DLLs "PreCompiled Assemblies" | |
*/ | |
var pathOfAssemblyDefinition = CompilationPipeline.GetAssemblyDefinitionFilePathFromAssemblyName(asmName); | |
var pathOfAssemblyDLL = Path.GetDirectoryName(CompilationPipeline.GetPrecompiledAssemblyPathFromAssemblyName(asmName)); | |
if( debug ) Debug.Log( "assembly name = \""+asmName+"\", path of assemblyDefinition = \""+pathOfAssemblyDefinition+"\", path of DLL = \""+pathOfAssemblyDLL+"\"" ); | |
if (pathOfAssemblyDLL == null) | |
{ | |
/** Workaround for bug in Unity's APIs: some versions of UnityEditor require you to provide an incorrect Assembly Name for them to work */ | |
pathOfAssemblyDLL = Path.GetDirectoryName(CompilationPipeline.GetPrecompiledAssemblyPathFromAssemblyName(asmName+".dll")); | |
if( debugMessages ) Debug.Log("NB: used workaround for CompilationPipeline bug -- appended .dll to AssemblyName"); | |
} | |
if( debugMessages ) Debug.Log("Paths: assemblyDefinition = "+pathOfAssemblyDefinition+", DLL = "+pathOfAssemblyDLL); | |
var pathForAssembly = pathOfAssemblyDefinition ?? pathOfAssemblyDLL; | |
var folderOfAssembly = Path.GetDirectoryName(pathForAssembly); | |
/** | |
var asmFolder = Path.GetDirectoryName(CompilationPipeline.GetAssemblyDefinitionFilePathFromAssemblyName(asmName)); | |
var dllFolder = Path.GetDirectoryName(CompilationPipeline.GetPrecompiledAssemblyPathFromAssemblyName(asmName)); | |
var dllFolder2 = Path.GetDirectoryName(CompilationPipeline.GetPrecompiledAssemblyPathFromAssemblyName(asmName+".dll")); | |
var preName = CompilationPipeline.GetPrecompiledAssemblyNames(); | |
var asmNames = CompilationPipeline.GetAssemblies(); | |
Debug.Log("asmName: "+asmName+", path for asm = "+asmFolder+", path for precompiled-asm: = "+dllFolder+", 2 = "+dllFolder2); | |
Debug.Log("assemblies = "+string.Join(",\n",asmNames.Select(assembly => assembly.ToString()))); | |
Debug.Log("precompiled assemblies = "+string.Join(",\n",preName.Select(assembly => assembly.ToString()))); | |
*/ | |
if( debugMessages ) Debug.Log("Resolved final folder-of-assembly = \""+folderOfAssembly+"\" using asmdef? "+(null!= pathOfAssemblyDefinition)+", dll? "+(null!=pathOfAssemblyDLL)); | |
var asmdefPath = ConvertPathToCrossPlatformPath( folderOfAssembly ); | |
string kAssetsRootName = ConvertPathToCrossPlatformPath("Assets/"); // hardcoded by Unity, but not provided by an API | |
string kPackagesRootName = ConvertPathToCrossPlatformPath("Packages/"); // hardcoded by Unity, but not provided by an API | |
bool containsAssets = asmdefPath.Contains(kAssetsRootName); | |
bool containsPackages = asmdefPath.Contains(kPackagesRootName); | |
bool isAssets = containsAssets && (!containsPackages || asmdefPath.StartsWith(kAssetsRootName)); | |
bool isPackages = containsPackages && (!containsAssets || asmdefPath.StartsWith(kPackagesRootName)); | |
if( isAssets || ForceDefaultPathAlwaysInAssetsNotPackages() ) | |
{ | |
/** Prune everything before "Assets/" because Unity API's have never correctly implemented Path inputs */ | |
var projectRelativePath = isAssets | |
? asmdefPath.Substring(asmdefPath.IndexOf(kAssetsRootName) + kAssetsRootName.Length) + "/" | |
: asmdefPath.Substring(asmdefPath.IndexOf(kPackagesRootName) + kPackagesRootName.Length) + "/"; | |
var preferredRelativePath = PreferredDefaultPathInAssets( projectRelativePath, asmName ); | |
var preferredFilename = GetType().Name.ToString() + ".asset"; | |
if( debugMessages ) Debug.Log("PreferredDefaultPath for "+GetType()+": \""+kAssetsRootName+(projectRelativePath+preferredFilename)+"\""); | |
return kAssetsRootName+projectRelativePath + preferredFilename; | |
} | |
else if( isPackages ) | |
{ | |
if( debugMessages ) Debug.Log("asmName = \""+asmName+"\", asmPath = \""+asmdefPath+"\""); | |
/** Prune everything before "Assets/" because Unity API's have never correctly implemented Path inputs */ | |
var projectRelativePath = isAssets | |
? asmdefPath.Substring(asmdefPath.IndexOf(kAssetsRootName) + kAssetsRootName.Length) | |
: asmdefPath.Substring(asmdefPath.IndexOf(kPackagesRootName) + kPackagesRootName.Length); | |
var preferredRelativePath = PreferredDefaultPathInPackages( projectRelativePath, asmName ); | |
var preferredFilename = GetType().Name.ToString() + ".asset"; | |
if( debugMessages ) Debug.Log("PreferredDefaultPath for "+GetType()+": \""+kAssetsRootName+(projectRelativePath+preferredFilename)+"\""); | |
return kAssetsRootName+"/"+projectRelativePath + preferredFilename; | |
} | |
else | |
{ | |
throw new Exception("This project appears to have classes that are neither in \""+kAssetsRootName+"\" nor \""+kPackagesRootName+"\""); | |
} | |
} | |
public virtual string PreferredDefaultPathInAssets( string assemblySubPath, string assemblyName ) | |
{ | |
return assemblySubPath; | |
} | |
public virtual string PreferredDefaultPathInPackages( string assemblySubPath, string assemblyName ) | |
{ | |
return "Settings" + '/' + assemblyName + '/'; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections; | |
using System.Collections.Generic; | |
using System.IO; | |
using System.Linq; | |
using UnityEditor; | |
using UnityEditor.Compilation; | |
using UnityEngine; | |
using Assembly = System.Reflection.Assembly; | |
#pragma warning disable 642 // useless warning from Microsoft that's almost always incorrect | |
namespace CHANGEME | |
{ | |
/// <summary> | |
/// NB: customize this by changing the namespace, and by changing the first part of the folder name in"FilePath" attribute on the class, | |
/// and by changing the value of "uniquePackageKey". | |
/// ... all three of these places have the text "CHANGEME" initially. | |
/// | |
/// Wrapper class that fetches settings for the project, and stores an optional override (provided by the user in | |
/// Editor) for the settings-file location. | |
/// | |
/// In 2020+ we use a ScriptableSingleton, in 2018/2019/etc we use a PlayerPrefs custom key, to store the override | |
/// (if it's used). | |
/// | |
/// Any settings file to be loaded by this class needs to extend public class PackageSettings | |
/// | |
/// </summary> | |
#if UNITY_2020_1_OR_NEWER | |
[FilePath("CHANGEME/SettingsFileFetcher.foo", FilePathAttribute.Location.PreferencesFolder)] | |
public class SettingsFileFetcher : ScriptableSingleton<SettingsFileFetcher> | |
#else | |
public class SettingsFileFetcher | |
#endif | |
{ | |
public static string uniquePackageKey = "CHANGEME"; | |
public static string buildInfo = "" | |
#if UNITY_EDITOR | |
+" UNITY_EDITOR" | |
#endif | |
#if UNITY_2020_1_OR_NEWER | |
+" UNITY_2020_1_OR_NEWER" | |
#endif | |
; | |
#if UNITY_2020_1_OR_NEWER | |
public bool debugSettingsDiscovery = false; | |
#endif | |
/// <summary> | |
/// Unity's implementation of CreateAsset has been broken for years, crashes if the path hasn't been pre-created, | |
/// so we have to create it ourselves (Unity could and should auto-create this - it's a single bool flag on the | |
/// Microsoft APIs that they're supposed to be using internally.) | |
/// </summary> | |
/// <param name="assetPath"></param> | |
protected static void PreCreateAssetFolders(string assetPath) | |
{ | |
var pathElements = assetPath.Split('/'); | |
string compoundParentFolder = pathElements[0]; | |
for (int i = 1; i < pathElements.Length - 1 /** -1: don't create the filename as a folder!*/; i++) | |
{ | |
if (AssetDatabase.IsValidFolder(compoundParentFolder + "/" + pathElements[i])) | |
; | |
else | |
AssetDatabase.CreateFolder(compoundParentFolder, pathElements[i]); | |
compoundParentFolder += "/" + pathElements[i]; | |
} | |
} | |
public static T PackageSettings<T>( bool debugSettingsDiscovery = false ) where T : PackageSettings | |
{ | |
T @return; | |
if( debugSettingsDiscovery ) Debug.Log("i. Attempt user's preferred override-location"); | |
/** Use developer's preferred location if they've set one for this project */ | |
if(null != (@return = _SettingsReferenceFromProject<T>())) | |
return @return; | |
if( debugSettingsDiscovery ) Debug.Log("ii. Attempt to find single instance (user relocated and didn't update settings), and use it to set the override-location"); | |
/** Search for any SINGLE valid asset that matches on type; if only one found, set that as the new preferred location and use it */ | |
if(null != (@return = _SettingsReferenceSearchAssetDatabase<T>(out int numHitsFound))) | |
{ | |
_SetSettingsReferenceForProject<T>(@return); | |
return @return; | |
} | |
if( debugSettingsDiscovery ) Debug.Log("iii. Attempt to create new instance and save in asset's preferred default location"); | |
if(numHitsFound == 0) | |
{ | |
/** No valid file exists anywhere in the project => Create a new one at this asset's/package's preferred default location */ | |
@return = ScriptableObject.CreateInstance<T>(); | |
if( debugSettingsDiscovery ) Debug.Log("...Created instance = "+@return); | |
var preferredPath = @return.PreferredDefaultPath(debugSettingsDiscovery); | |
if( debugSettingsDiscovery ) Debug.Log("...Saving instance to preferred path: \""+preferredPath+"\"..."); | |
PreCreateAssetFolders( preferredPath ); // necessary because of Unity's poor API for CreateAsset | |
AssetDatabase.CreateAsset(@return, preferredPath); | |
AssetDatabase.SaveAssets(); | |
_SetSettingsReferenceForProject<T>(@return); | |
return @return; | |
} | |
return null; | |
} | |
private static List<T> _FindAssetsInAssetDatabase<T>( bool debugSettingsDiscovery = false ) where T : PackageSettings | |
{ | |
var settingsFileAssets = AssetDatabase.FindAssets("t:" + typeof(T).Name); | |
var foundObjects = new List<T>(); | |
for(int i = 0; i < settingsFileAssets.Length; i++) | |
{ | |
foundObjects.Add(AssetDatabase.LoadAssetAtPath<T>(AssetDatabase.GUIDToAssetPath(settingsFileAssets[i]))); | |
} | |
#if UNITY_2020_1_OR_NEWER | |
if( debugSettingsDiscovery ) Debug.Log("Found objects at: " + string.Join(",", foundObjects.Select(settings => AssetDatabase.GetAssetPath(settings)))); | |
#endif | |
return foundObjects; | |
} | |
private static void _SetSettingsReferenceForProject<T>(T newSettingsObject, bool debugSettingsDiscovery = false ) where T : PackageSettings | |
{ | |
if( debugSettingsDiscovery ) Debug.Log("Setting project-wide saved location for packagesettings '"+typeof(T)+"' = \""+AssetDatabase.GetAssetPath(newSettingsObject)); | |
string localPathToSettingsSO = AssetDatabase.GetAssetPath(newSettingsObject); | |
SettingsFileFetcher.overrideSettingsFilePath = localPathToSettingsSO; | |
} | |
private static T _SettingsReferenceSearchAssetDatabase<T>(out int numHits) where T : PackageSettings | |
{ | |
var assetsInAssetDatabase = _FindAssetsInAssetDatabase<T>(); | |
numHits = assetsInAssetDatabase.Count; | |
if(numHits == 1) | |
return assetsInAssetDatabase[0]; | |
else | |
return null; // more than one result, need the user to pick one - we cannot auto choose! | |
} | |
private static T _SettingsReferenceFromProject<T>() where T : PackageSettings | |
{ | |
string localPathToSettingsSO = SettingsFileFetcher.overrideSettingsFilePath; | |
/** Unity's API requires this start with "/Assets" and end with the fileextension */ | |
if(localPathToSettingsSO != null) | |
return AssetDatabase.LoadAssetAtPath<T>(localPathToSettingsSO); | |
else | |
return null; | |
} | |
public static string overrideSettingsFilePath | |
{ | |
get | |
{ | |
#if UNITY_2020_1_OR_NEWER | |
return SettingsFileFetcher.instance.overrideSettingsFilePath_2020; | |
#else | |
return SettingsFileFetcher.overrideSettingsFilePath_2019; | |
#endif | |
} | |
set | |
{ | |
#if UNITY_2020_1_OR_NEWER | |
SettingsFileFetcher.instance.overrideSettingsFilePath_2020 = value; | |
#else | |
SettingsFileFetcher.overrideSettingsFilePath_2019 = value; | |
#endif | |
} | |
} | |
#if UNITY_2020_1_OR_NEWER | |
[SerializeField] private string _overrideSettingsFilePath_2020; | |
public string overrideSettingsFilePath_2020 | |
{ | |
get | |
{ | |
/** User may have upgraded a 2019/2018 project to 2020+, so read it if 2020 value is null */ | |
if(_overrideSettingsFilePath_2020 == null && overrideSettingsFilePath_2019 != null) | |
{ | |
_overrideSettingsFilePath_2020 = overrideSettingsFilePath_2019; | |
/** And wipe the legacy value */ | |
overrideSettingsFilePath_2019 = null; | |
} | |
return _overrideSettingsFilePath_2020; | |
} | |
set | |
{ | |
_overrideSettingsFilePath_2020 = value; | |
Save(true); | |
} | |
} | |
#endif | |
private static string _keyForOverrideSettingsFilePath => uniquePackageKey + "." + "overrideSettingsPath"; | |
public static string overrideSettingsFilePath_2019 | |
{ | |
get { return PlayerPrefs.GetString(uniquePackageKey,null); } | |
set | |
{ | |
if( value == null ) | |
PlayerPrefs.DeleteKey(uniquePackageKey); | |
else | |
PlayerPrefs.SetString(uniquePackageKey,value); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment