From 845c21edb271a8f0df97150a139925978a09e395 Mon Sep 17 00:00:00 2001 From: sliptrixx <37605842+sliptrixx@users.noreply.github.com> Date: Sun, 13 Aug 2023 23:09:24 +0530 Subject: [PATCH] Adding Support for [StaticAccess] Attribute (#5) - Created the attribute that users can add to access this system - Added option to disable this feature using defines - Added code for automatic code generation - Added option to pause static access generation - Added info about [StaticAccess] to readme --- Editor/DefineRegistrant.cs | 23 +- Editor/StaticAccess.cs | 306 ++++++++++++++++++ Editor/StaticAccess.cs.meta | 11 + README.md | 23 +- Scripts/Attributes/StaticAccessAttribute.cs | 46 +++ .../Attributes/StaticAccessAttribute.cs.meta | 11 + package.json | 2 +- 7 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 Editor/StaticAccess.cs create mode 100644 Editor/StaticAccess.cs.meta create mode 100644 Scripts/Attributes/StaticAccessAttribute.cs create mode 100644 Scripts/Attributes/StaticAccessAttribute.cs.meta diff --git a/Editor/DefineRegistrant.cs b/Editor/DefineRegistrant.cs index 30411ff..b93ee7d 100644 --- a/Editor/DefineRegistrant.cs +++ b/Editor/DefineRegistrant.cs @@ -13,6 +13,8 @@ namespace Hibzz.Singletons.Editor /// internal class DefineRegistrant { + const string Category = "Hibzz.Singletons"; + [RegisterDefine] static DefineRegistrationData RegisterDisableScriptableObjectCreator() { @@ -20,7 +22,8 @@ static DefineRegistrationData RegisterDisableScriptableObjectCreator() data.Define = "DISABLE_SCRIPTABLE_SINGLETON_CREATOR"; data.DisplayName = "Disable Scriptable Singleton Creator"; - data.Category = "Hibzz.Singletons"; + data.Category = Category; + data.EnableByDefault = false; data.Description = "The scriptable singleton creator functionality " + "lets users mark any ScriptableSingleton class with an attribute " + "called `CreateScriptableSingletonAsset`. When the user presses " + @@ -28,7 +31,25 @@ static DefineRegistrationData RegisterDisableScriptableObjectCreator() "will create any missing assets for those singletons in the " + "\"Resources/Singletons\" folder. \n\n" + "Installing this define will disable this feature."; + + return data; + } + + [RegisterDefine] + static DefineRegistrationData RegisterDisablePublicStaticAccessor() + { + DefineRegistrationData data = new DefineRegistrationData(); + + data.Define = "DISABLE_SINGLETON_AUTO_PUBLIC_STATIC_ACCESSOR"; + data.DisplayName = "Disable Public Static Accessor"; + data.Category = Category; data.EnableByDefault = false; + data.Description = "The singletons package offers a system to " + + "automatically generate public static accessors for items " + + "inside a partial singleton class when marked with a " + + "[StaticAccess] attribute. This is done using dynamic code " + + "generation.\n\n " + + "Installing this define will disable this feature."; return data; } diff --git a/Editor/StaticAccess.cs b/Editor/StaticAccess.cs new file mode 100644 index 0000000..0439dee --- /dev/null +++ b/Editor/StaticAccess.cs @@ -0,0 +1,306 @@ +#if !DISABLE_SINGLETON_AUTO_PUBLIC_STATIC_ACCESSOR + +using System; +using System.IO; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace Hibzz.Singletons.Editor +{ + public class StaticAccess : AssetPostprocessor + { + const string STATIC_ACCESS_PAUSE_KEY = "Hibzz/Singletons/Pause Automatic Code Generation"; + + // gets a notification after all asset has been imported + private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, + string[] movedAssets, string[] movedFromAssetPaths) + { + // if the user wants to pause the automatic code generation of this system, dont proceed + if(Menu.GetChecked(STATIC_ACCESS_PAUSE_KEY)) { return; } + + // flag that indicates if code has been generated + bool codeGenerated = false; + + // generate code for each imported asset if it's valid + foreach (var importedAsset in importedAssets) + { + if (TryGenerateCode(importedAsset)) + { + codeGenerated = true; + } + } + + // reload assembly if code was generated (or removed) + if (codeGenerated) + { + AssetDatabase.Refresh(); + } + } + + /// + /// Tries to generate any singleton related code for the given asset + /// + /// The path to the script asset to analyze and generate code for + /// Was some code generated for the given asset? + private static bool TryGenerateCode(string assetPath) + { + // if the changed asset is not a script file, we don't care + if (!assetPath.EndsWith(".cs")) { return false; } + + // Issue: For now this is okay, but in the future this decision will cause issues, + // for example what happens the user generates a singleton script that has the static + // access attribute in it? The system will see that the file name contains the common + // convention of .generated.cs so it'll ignore it. Alternatively we generate files + // that end with .staticaccess.generated.cs but that's too long. We'll cross that + // bridge when the time comes, but just jotting this down so it's easier to understand + // the issue later. + + // if the generated script file is a generated script file, we don't care either + if (assetPath.EndsWith(".generated.cs")) { return false; } + + // load the asset from the given path as a monoscript + var monoScript = AssetDatabase.LoadAssetAtPath(assetPath); + if (monoScript is null) { return false; } + + // make sure that they one of the variants of the singleton classes provided + var classType = monoScript.GetClass(); + if (classType is null) { return false; } + if (!classType.IsASingletonVariant()) { return false; } + + // don't generate code for abstract classes + if (classType.IsAbstract) { return false; } + + // whether we generate some new content or not, we are going to need the path to the + // expected generated asset... at the end of this process we either create a new one, + // or check and delete an existing one + string generatedAssetPath = assetPath; + generatedAssetPath = generatedAssetPath.Substring(0, generatedAssetPath.LastIndexOf(".cs")); + generatedAssetPath += ".generated.cs"; + + // Now we need to generate the script file + if (GetGeneratedCode(classType, out string code)) + { + File.WriteAllText(generatedAssetPath, code); + } + else + { + // no code was generated, so remove any existing generated file + if(!File.Exists(generatedAssetPath)) { return false; } + File.Delete(generatedAssetPath); + } + + return true; + } + + /// + /// Get the generated code from the given type + /// + /// The type to analyze + /// The generated code as a string + /// Was code generated as a string + private static bool GetGeneratedCode(Type classType, out string code) + { + bool wasCodeGenerated = false; // flag indicating whether some code was generated or not + string classContent = ""; // store what the contents of the class file is + + // loop through all instanced members of the class and if it's members do contain the + // attribute [StaticAccess] then we make add a public static accessor for that member + var members = classType.GetMembers(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (var member in members) + { + // early return if no [StaticAccess] attribute found + var staticAccessAttribute = member.GetCustomAttribute(); + if (staticAccessAttribute is null) { continue; } + + // figure out the variable name + var variableName = staticAccessAttribute.VariableName; + if (variableName is null) + { + // since no specific variable name is given, it must be auto generated + variableName = member.Name; + + // a naming scheme is specified in the attribute declaration, which requires an + // underscore for the auto generation to work + if (!variableName.Contains("_")) + { + Debug.LogWarning($"Skipped generating static accessor for {classType.Name}.{member.Name}: " + + $"The member name must contain some leading underscore for name to be autogenerated. " + + $"Please pass a string as a variable name to [StaticAccess(varname)] if you don't want " + + $"the name to be autogenerated.\n"); + + continue; + } + + // remove anything before the first underscore and capitalize the first character + variableName = variableName.Substring(variableName.IndexOf('_') + 1); + variableName = char.ToUpper(variableName[0]) + variableName.Substring(1); + } + + + // based on the member type, the public accessor's syntax varies + if (member.MemberType == MemberTypes.Field) + { + var fieldInfo = member as FieldInfo; + classContent += $"public static {fieldInfo.FieldType.FullName} {variableName} => Instance.{member.Name};\n"; + } + else if (member.MemberType == MemberTypes.Property) + { + var propertyInfo = member as PropertyInfo; + classContent += $"public static {propertyInfo.PropertyType.FullName} {variableName} => Instance.{member.Name};\n"; + } + else if (member.MemberType == MemberTypes.Method) + { + var methodInfo = member as MethodInfo; + + // stores any formal/actual parameters + string formalParameters = ""; + string actualParameters = ""; + + // decipher what the formal and actual parameters of this function are (if any) + var parameters = methodInfo.GetParameters(); + foreach(var parameter in parameters) + { + formalParameters += $"{parameter.ParameterType} {parameter.Name}, "; + actualParameters += $"{parameter.Name}, "; + } + + // remove the trailing comma character from the parameters + if(parameters.Length > 0) + { + formalParameters = formalParameters.Substring(0, formalParameters.LastIndexOf(',')); + actualParameters = actualParameters.Substring(0, actualParameters.LastIndexOf(',')); + } + + // string that all together + classContent += $"public static {methodInfo.ReturnType.FullName} {variableName}({formalParameters}) => Instance.{member.Name}({actualParameters});\n"; + } + + // update the flag letting the system know that some valid code was indeed generated + wasCodeGenerated = true; + } + + // when no code was generated, either because there were no [StaticAccess] attributes + // or the user wanted to have an autogenerated variable name without a leading + // underscore, we want to perform an early exit + if(!wasCodeGenerated) + { + code = ""; + return false; + } + + // stores the generated code for just the class + string classCode = ""; + + // classes can either be public or internal, and if there's no access modifier present, + // it'll be considered to be internal + if (classType.IsPublic) + { + classCode += "public "; + } + + // add the class defenition + classCode += $"partial class {classType.Name} : {classType.GetSingletonBaseName()} \n" + + "{ \n" + + $"{classContent}" + + "} \n" ; + + // the overall code that's generated needs to be put together + code = "// this code is automatically generated by the Hibzz.Singletons package\n\n"; + code += "using Hibzz.Singletons;\n\n"; + if (!string.IsNullOrWhiteSpace(classType.Namespace)) + { + code += $"namespace {classType.Namespace} \n" + + "{ \n" + + $"{classCode}" + + "} \n"; + } + else + { + code += classCode; + } + + return true; // code was indeed generated + } + + [MenuItem(STATIC_ACCESS_PAUSE_KEY)] + private static void OnStaticAccessPauseMenuPressed() + { + // this should toggle the Automatic Code Generation + Menu.SetChecked(STATIC_ACCESS_PAUSE_KEY, !Menu.GetChecked(STATIC_ACCESS_PAUSE_KEY)); + } + } + + internal static class TypeExtensions + { + /// + /// Alternative version of that supports raw generic types (generic types without + /// any type parameters). + /// + /// The base type class for which the check is made. + /// To type to determine for whether it derives from . + /// + /// Credits to Denis Doomen (https://github.com/dennisdoomen) + /// + public static bool IsSubclassOfRawGeneric(this Type toCheck, Type baseType) + { + while (toCheck != typeof(object)) + { + Type cur = toCheck.IsGenericType ? toCheck.GetGenericTypeDefinition() : toCheck; + if (baseType == cur) + { + return true; + } + + toCheck = toCheck.BaseType; + } + + return false; + } + + /// + /// Get the base type as a string for a singleton class + /// + /// The type of a class that inherits one of the singleton variants + /// A string representation of the base type of the singleton class + public static string GetSingletonBaseName(this Type type) + { + // we are working with the generic base type + + // when we get the name of a generic base type, c# reflection systems would give us + // something of a compiled form... like Singleton`1 but that's not useful for code + // generation + + // This is not a general purpose function, it's internal and is very specific to + // singletons package... Although it's possible to inherit a class that inherits the + // Singleton base class, it's not practical. None of the current class member can be + // accessed using the instance property + + // So, we are cutting corners and creating something very specific for the singletons + // package that follows the pattern of base + + // Issue: When in the future we decide to fix the inheritance issue (if it can be fixed), + // we need to revisit this function to be general purpose + + string generic_base = type.BaseType.Name; + generic_base = generic_base.Substring(0, generic_base.IndexOf('`')); + + return $"{generic_base}<{type.Name}>"; + } + + /// + /// check if the given type is on off the singleton variants + /// + /// The type to check + /// Is the given type on off the variants of the singleton + public static bool IsASingletonVariant(this Type type) + { + if (type.IsSubclassOfRawGeneric(typeof(Singleton<>))) { return true; } + if (type.IsSubclassOfRawGeneric(typeof(ScriptableSingleton<>))) { return true; } + + return false; + } + } +} + +#endif diff --git a/Editor/StaticAccess.cs.meta b/Editor/StaticAccess.cs.meta new file mode 100644 index 0000000..d4a55e6 --- /dev/null +++ b/Editor/StaticAccess.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00e98f7fdbde32543a16f3d72deafd68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 554c104..f76eeb6 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,27 @@ Debug.Log($"Number of Live NPC's in scene: {_currentNPCCount}");
### Scriptable Singletons -This package includes support for `ScriptableSingleton`s. +This package includes support for `ScriptableSingleton`s. These are Singletons that exists as a ScriptableObject asset in the Resources folder. This allows a Scriptable Singleton to be accessed from anywhere in the project without having to be in the scene. -Scriptable Singleton objects can be automatically created when the class is tagged with the `CreateScriptableSingletonAsset` attribute and the "Create Scriptable Singleton Asset" menu item is selected from the "Hibzz/Singletons" menu. +Moreover, these objects can be automatically created when the class is tagged with the `[CreateScriptableSingletonAsset]` attribute and the "Create Scriptable Singleton Asset" menu item is selected from the "Hibzz/Singletons" menu. + +
+ +### [StaticAccess] Attribute +The Singletons package comes with a powerful tool for accessing instanced members of a Singleton class without the `Instance` field. This is done by tagging the member with a `[StaticAccess]` attribute. + +```csharp +// defining the singleton +public partial class AIManager : Singleton +{ + [StaticAccess] int _liveNPCCount; +} + +// Accessing the member +var npcCount = AIManager.LiveNPCCount; +``` + +
Learn more about this package in the [documentation](https://docs.hibzz.games/singletons/getting-started/). @@ -46,4 +64,3 @@ Learn more about this package in the [documentation](https://docs.hibzz.games/si If you have any questions or want to contribute, feel free to join the [Discord server](https://discord.gg/YXdJ8cZngB) or [Twitter](https://twitter.com/hibzzgames). I'm always looking for feedback and ways to improve this tool. Thanks! Additionally, you can support the development of these open-source projects via [GitHub Sponsors](https://github.com/sponsors/sliptrixx) and gain early access to the projects. - diff --git a/Scripts/Attributes/StaticAccessAttribute.cs b/Scripts/Attributes/StaticAccessAttribute.cs new file mode 100644 index 0000000..2c3ede1 --- /dev/null +++ b/Scripts/Attributes/StaticAccessAttribute.cs @@ -0,0 +1,46 @@ +#if !DISABLE_SINGLETON_AUTO_PUBLIC_STATIC_ACCESSOR + +using System; + +namespace Hibzz.Singletons +{ + public class StaticAccessAttribute : Attribute + { + public string VariableName { get; protected set; } = null; + + /// + /// Exposes contents inside a partial singleton class through a public + /// static interface using the instance property + /// + /// + /// Without a variable name passed to the attribute, the system will + /// attempt to autogenerate the variable name. Currently the naming + /// schemes available are as follows: + /// + /// + /// Must start with an underscore: For example, _health will have a new public static accessor Health + /// + /// + /// Must contain an underscore: For example, p_mana will have a new public static accessor Mana + /// + /// + /// + /// If things aren't working as expected, please file a bug report in GitHub Issues + /// + public StaticAccessAttribute() { } + + /// + /// Exposes contents inside a partial singleton class through a public static accessor + /// + /// + /// The variable name to set for the new public accessor. + /// Make sure that the variable name is unique + /// + public StaticAccessAttribute(string variableName) + { + VariableName = variableName; + } + } +} + +#endif diff --git a/Scripts/Attributes/StaticAccessAttribute.cs.meta b/Scripts/Attributes/StaticAccessAttribute.cs.meta new file mode 100644 index 0000000..0e224b8 --- /dev/null +++ b/Scripts/Attributes/StaticAccessAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d1be4f7a59f001a45aca27ab3fbdb34a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index 1dfee82..34697a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.hibzz.singletons", - "version": "1.3.1", + "version": "1.4.0", "displayName": "hibzz.singletons", "description": "A library of singletons for Unity", "author": {