Skip to content

Instantly share code, notes, and snippets.

@adamgit
Created February 14, 2022 16:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save adamgit/fdaeba46788d611956a7008483773fda to your computer and use it in GitHub Desktop.
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.
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 + '/';
}
}
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