Troubles with EditorProperty for InspectorPlugin that handles an Array of Variant arguments

Godot Version

v4.6.3.stable.mono.official [7d41c59c4]

tl;dr

Attempting to create an inspector plugin that handles an array of “arguments”. Having trouble creating said plugin, both in the visual layer and serialization layer.

Background

I am creating a resource that uses Reflection to create pure C# objects. My intention is to have code that is completely engine agnostic for a few reasons. Having a generic resource in which I can select assembly, type, constructor, and arguments to instantiate objects would be incredibly useful. I’ve already got the first three working, having trouble with arguments.

My current solution has been to create a new plugin, using _ParseProperty to create my own EditorProperty for each element:

Code Snippet
    GodotObject @object, 
    Variant.Type type,
    string name, 
    PropertyHint hintType, 
    string hintString,
    PropertyUsageFlags usageFlags, 
    bool wide)
{
    ReflectionResource resource = @object as ReflectionResource;

    const bool addToEnd = false;
    if (name == nameof(ReflectionResource.AssemblyName))
    {
        AddPropertyEditor(
            name, 
            new AssemblyEditorProperty(resource), 
            addToEnd, 
            "Assembly");
        return true;
    }

    if (name == nameof(ReflectionResource.TypeName))
    {
        AddPropertyEditor(
            name, 
            new TypeEditorProperty(resource),
            addToEnd,
            "Type");
        return true;
    }

    if (name == nameof(ReflectionResource.ConstructorSignature))
    {
        AddPropertyEditor(
            name, 
            new ConstructorEditorProperty(resource),
            addToEnd,
            "Constructor");
        return true;
    }

    if (name == nameof(ReflectionResource.FirstArgument))
    {
        const int argumentIndex = 0;
        if (argumentIndex >= resource.ConstructorSignature.Count)
        {
            return true;
        }

// Repeat for parameters 2-4.

    if (name == nameof(ReflectionResource.FifthArgument))
    {
        const int argumentIndex = 4;
        if (argumentIndex >= resource.ConstructorSignature.Count)
        {
            return true;
        }

        ParameterData parameter = new(resource.ConstructorSignature[argumentIndex]);
        PropertyHint propertyHint = ReflectionUtility.GetPropertyHint(parameter.VariantType);
        return base._ParseProperty(@object, parameter.VariantType, name, propertyHint, hintString, usageFlags, wide);
    }

    return false;
}

Problem

This works technically, but there’s some things I dislike:

  • Cannot enforce variant type. The user needs to select the right variant for the argument, in the picture above, that’d be a boolean.
  • I have no control over label name.
  • I have to set up arguments concretely. Right now 5 arguments is the maximum.

Explrored Solutions

Below are some things I’ve tried to mitigate the above problems:

Argument Array - Per Element Custom PropertyEditor

My first pass at this was to have a Godot.Array property on ReflectionResource instead of typing out each argument property. For each argument, I’d create a new EditorProperty:

Code Snippet
#if TOOLS
using Godot;

namespace Flantastico.Godot.ACE.Presenter.Core.Reflection;

internal partial class ArgumentEditorProperty : EditorProperty
{
    private readonly ReflectionResource _resource;
    private readonly int _parameterIndex;

    private bool _isUpdating;
    private EditorProperty _editorProperty;
    private Variant _currentValue;

    public ArgumentEditorProperty(ReflectionResource resource, int parameterIndex) 
    {
        _resource = resource;
        _parameterIndex = parameterIndex;

        ArgumentData parameter = new(resource.ConstructorSignature[_parameterIndex]);
        Variant variant = resource.GetArgumentByIndex(_parameterIndex);

        _editorProperty = EditorInspector.InstantiatePropertyEditor(
            resource,
            parameter.VariantType,
            resource.ResourceName, 
            PropertyHint.None, 
            string.Empty, 
            (uint)PropertyUsageFlags.None);
       
        AddChild(_editorProperty);
        AddFocusable(_editorProperty);

        _editorProperty.PropertyChanged += OnPropertyChanged;
        RefreshArgument();
    }

    public override void _UpdateProperty()
    {
        var editedProperty = GetEditedProperty();
        var editedValue = GetEditedObject().Get(editedProperty);
        if (IsMatchingVariant(editedValue, _currentValue))
        {
            return;
        }

        _isUpdating = true;
        _currentValue = editedValue;
        RefreshArgument();
        _isUpdating = false;
    }

    private void RefreshArgument()
    {
        _editorProperty.UpdateProperty();
    }

    private void OnPropertyChanged(StringName property, Variant value, StringName field, bool changing)
    {
        if (_isUpdating)
        {
            return;
        }

        _currentValue = value;
        EmitChanged(nameof(ReflectionResource.FirstArgument), _currentValue);
    }

    private static bool IsMatchingVariant(Variant variantA,  Variant variantB)
    {
        if (variantA.Obj == null || variantB.Obj == null)
        {
            return false;
        }

        if (variantA.VariantType != variantB.VariantType)
        {
            return false;
        }

        if (!variantA.Obj.Equals(variantB.Obj))
        {
            return false;
        }

        return true;
    }
}
#endif

This EditorProperty would be added as a child of a VBoxContainer, which was added as a control within the plugin. Problem with this solution was that it was having trouble serializing; whenever I’d make a change to an argument in the inspector, it’d revert to the default value. Otherwise, it worked great.

Argument Array - Per Element Default PropertyEditor

When the above didn’t work, I decided to move the code that was in the constructor out to the plugin, using AddPropertyEditor instead of AddCustomControl. This also didn’t work. Unfortunately, don’t have code left over to show but I can try to recreate if folks feel like this is a viable solution.

Argument Per Property

Essentially what I have now; each argument is it’s own Variant property.

Question

I’ve given a few solutions, so I guess my question is which is the best one that has the least amount of downsides. Here’s a general list of inquiries:

  • How does one use AddCustomControl while still serializing properties? I never got that to work; I always had to use AddEditorProperty.
  • Is it possible for an EditorProperty to handle a single element of an Array or is it an all or nothing type of deal?
  • How do I create the default EditorProperty for a Variant? I only found out how to use EditorInspector.InstantiatePropertyEditor, but this one doesn’t seem to allow me enforce a Variant.Type unless I’m wrong.
  • If I am force to have each argument be a separate property, how do I enforce Variant.Type and override the shown text.
  • Tangential, but is there a way to call Godot Inspector’s default property label formatter? I.E. I have argument isDeactivatedImmediately and want to change it to Is Deactivated Immediately as the inspector does with variables.

Thanks in advance for your help! Let me know if you need more clarifications. Also, feel free to give solutions in gdScript. I’ll try to then figure out how to translate to C#.

This is a fascinating exercise. I’m a huge fan of Reflection. It’s one of the things I love most about Ruby as a language, along with Duck Typing. I’ve implemented reflection in Java, but never C#.

I don’t have any code for you, but maybe I can help you think through the problem a bit.

Have you tried making this Resource in GDScript? The C# implementation of Variant is a struct at its heart and it creates an interface, but isn’t really a Variant the way it is in GDScript, because C#'s duck typing support is different. If the Resource has to stay in C#, this is likely a limitation you’ll have to accept - at least for now. At which point I’d circle back around when C# support through GDExtension is finished.

Do you mean “Assembly”, “Type”, “Constructor”, etc? Because I see you defining them in your code. What labels are you referring to?

Why? Why does each index have to be a const? Why can’t you use a for loop the size of the array of arguments? What happens?

Are you saying that you cannot use AddCustomControl while in the middle of a constructor? You never got it to work inside EditorInspectorPlugin ?

Are you trying to change things after enabling the plugin? Because you can’t do that. You need to disable and re-enable the plugin to make changes, then most likely you will need to reload the project.

EditorProperty is a Container.
image
So it can handle one or more things, depending on how you pass them. If you want an EditorProperrty to only hold one Array item, only pass it one Array item.

No idea, but we go back to the fact that you’re trying to shoehorn something in here that C# doesn’t really know how to handle. I recommend trying to use GDScript to do this or switch to GDExtension and use C++ instead of C#.

Need more info.

In GDScript, if you pass a variable as snake_case, capitalize() is automatically run on it. In your example, you are using camelCase, which in Godot C# indicates a private or local variable. Changing it to PascalCase, i.e. IsDeactivatedImmediately would make it a public variable and therefore probably cause it to be converted for you. I’m not sure though. Naming is very important in Godot however.

You don’t really need to use a plugin for what you want to do. You can use Object._get(), Object._set(), and Object._get_property_list() instead.

Example
@tool
extends Node


# The data that will be serialized to disk. This will contain the parameter values the user adds.
@export_storage var _data: Dictionary
# The selected method
var _selected_method: String = METHODS.keys()[0]


func _get(property: StringName) -> Variant:
	if property == "method":
		# Property is "method" so we return the _selected_method
		return _selected_method

	if property.begins_with("params/"):
		# Property begins with "params/" so we need to find out the parameter value
		var param = property.split("/")[1]
		if _data.has(param):
			# If the data has it already we return it
			return _data.get(param)
		else:
			# If not, we will get the default value from our METHODS dictionary
			var params = METHODS.get(_selected_method).get("params")
			var def = null
			for p in params:
				if param == p.name:
					def = p.value
					break
			return _data.get_or_add(param, def)

	return null


func _set(property: StringName, value: Variant) -> bool:
	if property == "method":
		# If we are setting the "method" then:
		_selected_method = value
		# we will clear the _data dictionary
		_data.clear()
		# And notify that the property list has changed so the new parameters are shown in the inspector
		notify_property_list_changed()
		return true

	if property.begins_with("params/"):
		# If it's a parameter then we just set the new value in our _data dictionary
		var param = property.split("/")[1]
		_data.set(param, value)
		return true

	return false


func _get_property_list() -> Array[Dictionary]:
	var props: Array[Dictionary]

	# We append a new inspector entry with the name "method" that will be shown as an enum with our method names
	props.append({
		"name": "method",
		"type": TYPE_STRING,
		"hint": PROPERTY_HINT_ENUM,
		"hint_string": ",".join(METHODS.keys()),
		"usage": PROPERTY_USAGE_DEFAULT # The property will be serialized to disk as "method"
	})


	# We get the parameters for the selected method and:
	var params = METHODS.get(_selected_method).get("params", [])

	for param in params:
		# for each one we will get the type using the value
		var type = typeof(param.get("value", null))
		# we will get the name of the param
		var param_name = param.get("name", "unknown")
		# and set its usage as only editor (won't be serialized to disk)
		# because we are already serializing the _data property
		var usage = PROPERTY_USAGE_EDITOR
		if type == TYPE_NIL:
			# if the type is Nil then it will be shown as a variant editor
			usage |= PROPERTY_USAGE_NIL_IS_VARIANT

		props.append({
			"name": "params/"+param_name, # params/ is the group
			"type": type,
			"usage": usage
		})

	return props


# mock Dictionary with some methods
const METHODS = {
	"play_animation": {
		"params": [
			{
				"name": "animation",
				"value": "",
			},
			{
				"name": "backwards",
				"value": false,
			}
		]
	},
	"add_animation_frame": {
		"params": [
			{
				"name": "animation",
				"value": "",
			},
			{
				"name": "time",
				"value": 0.0,
			},
			{
				"name": "path",
				"value": NodePath(),
			},
			{
				"name": "value",
				"value": null,
			},
		]
	}
}

Thanks for the reply! Let me go one by one. :slight_smile: I also see other replies so I’ll reply to those separately.

Have you tried making this Resource in GDScript? […]

No I have not. Mostly because I prefer C# and the rest of the plugin is written in C#. Still, if it’s not supported, perhaps I do need to jump over.

Do you mean “Assembly”, “Type”, “Constructor”, etc?

No, I mean the label for arguments. For those you mentioned I was able to set the label because I’m using AddEditorProperty and that function has a label argument. However, if I use base._ParseProperty() I can’t seem to set my own label (or at least I haven’t been able to figure how. I use base._ParseProperty() because it and EditorInspector.InstantiatePropertyEditor() are the only ways I’ve found to make the default PropertyEditor for a variant.

Why? Why does each index have to be a const? Why can’t you use a for loop the size of the array of arguments? What happens?

I did do an array on my other two explored solutions. I could not get it to work, hence why I simplified. Even in the non-array version, I could not get it to work (i.e. it reverted whenever the inspector lost focus).

Are you saying that you cannot use AddCustomControl while in the middle of a constructor? You never got it to work inside EditorInspectorPlugin?

I got AddCustomControl to work in showing up in the inspector, but anytime I made a change in the inspector it would revert on unfocusing the inspector.

So it can handle one or more things, depending on how you pass them.

Not sure I understand your point. Probably I wasn’t clear. I wanted to make either a PropertyEditor or CustomControl to show each element from an Array property, modify any element in that Array in the inspector, then serialize the change back to the Array property.

No idea, but we go back to the fact that you’re trying to shoehorn something in here that C# doesn’t really know how to handle.

Fair enough, but ya don’t know what ya don’t know, y’know?

Need more info.

So for the example above, the user needs to manually determine the variant type.

See how the First Argument property has a pencil to choose the variant type? I was looking for a way to set the variant type such that that’s already done automatically and enforced. I already have written up a System.Type to Variant.Type function, so given a C# argument I could find the appropriate variant to serialize the data.

In GDScript, if you pass a variable as snake_case, capitalize() is automatically run on it. In your example, you are using camelCase, which in Godot C# indicates a private or local variable.

Well, it is a local variable. It’s the argument of a constructor. By the sounds of it, the formatter isn’t exposed in C#.

Thanks again for all your suggestions! I might retry this in GDScript in the future.

Ah! I forgot to put this as part of my explored solutions. I actually got it working this way. Here’s the old code.

AssemblyResource
using Godot;
using Godot.Collections;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

using ParameterDictionary = Godot.Collections.Dictionary<string, Godot.Variant>;

namespace Flantastico.Godot.ACE.Presenter.Core;

/// <summary>
/// Abstract class that provides a flexible way to select a class type, its constructor, 
/// and constructor parameters through the Godot editor.
/// </summary>
[GlobalClass, Tool]
public abstract partial class AssemblyResource : Resource
{


    protected abstract Type BaseType { get; }

    public const string TypePropertyName = "Type";
    public const string AssemblyPropertyName = "Assembly";
    public const string ConstructorPropertyName = "Constructor";
    public const string ParametersPropertyName = "Parameters";

    // Serialized Data
    private string _targetAssemblyName = string.Empty;
    private string _targetTypeName = string.Empty;
    private int _constructorIndex = 0;
    private ParameterDictionary _constructorParameters = [];

    // Runtime Data
    private Assembly _targetAssembly;
    private Type _targetType;
    private ConstructorInfo _selectedConstructor;
    private ParameterInfo[] _selectedParameters;

    public AssemblyResource()
    {
        Assembly defaultAssembly = GetTargetAssemblies(BaseType).First();
        _targetAssemblyName = defaultAssembly.GetName().Name;

        Type defautlType = GetImplementingTypes(BaseType, defaultAssembly).First();
        _targetTypeName = defautlType.Name;

        ResourceName = _targetTypeName;
        NotifyPropertyListChanged();
    }

    public override Array<Dictionary> _GetPropertyList()
    {
        UpdateRuntimeData();
        Array<Dictionary> properties = [];

        properties.Add(new Dictionary
        {
            { "name", AssemblyPropertyName },
            { "type", (int)Variant.Type.String },
            { "hint", (int)PropertyHint.Enum },
            { "hint_string", GetTargetAssemblyHintString(BaseType) }
        });

        properties.Add(new Dictionary
        {
            { "name", TypePropertyName },
            { "type", (int)Variant.Type.String },
            { "hint", (int)PropertyHint.Enum },
            { "hint_string", GetTargetTypeHintString(BaseType, _targetAssembly) }
        });

        if (_selectedConstructor != null)
        {
            properties.Add(new Dictionary
            {
                { "name", ConstructorPropertyName },
                { "type", (int)Variant.Type.Int },
                { "hint", (int)PropertyHint.Enum },
                { "hint_string", GetConstructorHintString(_targetType) },
            });
        }

        if (_selectedParameters != null)
        {
            for (int i = 0; i < _selectedParameters.Length; i++)
            {
                ParameterInfo parameter = _selectedParameters[i];
                properties.Add(new Dictionary
                {
                    { "name", GetParameterDictionaryKeyString(i, parameter) },
                    { "type", (int)GetDefaultVariantType(parameter.ParameterType) },
                });
            }
        }

        return properties;
    }

    public override Variant _Get(StringName property)
    {
        string propertyName = property.ToString();
        if (propertyName == AssemblyPropertyName)
        {
            return _targetAssemblyName;
        }

        if (propertyName == TypePropertyName)
        {
            return _targetTypeName;
        }

        if (propertyName == ConstructorPropertyName)
        {
            return _constructorIndex;
        }

        if (propertyName.StartsWith(ParametersPropertyName))
        {
            if (_constructorParameters.TryGetValue(propertyName, out Variant value))
            {
                return value;
            }
            else
            {
                return new Variant();
            }
        }

        return default;
    }

    public override bool _Set(StringName property, Variant value)
    {
        string propertyString = property.ToString();

        if (propertyString == AssemblyPropertyName)
        {
            _targetAssemblyName = (string)value;

            Assembly assembly = GetAssemblyByName(_targetAssemblyName);
            Type type = GetImplementingTypes(BaseType, assembly).FirstOrDefault();
            _targetTypeName = type?.Name ?? "";
            
            NotifyPropertyListChanged();
            return true;
        }

        if (propertyString == TypePropertyName)
        {
            _targetTypeName = (string)value;
            _constructorIndex = 0;
            ResourceName = _targetTypeName;
            NotifyPropertyListChanged();
            return true;
        }

        if (propertyString == ConstructorPropertyName)
        {
            _constructorIndex = (int)value;
            NotifyPropertyListChanged();
            return true;
        }

        if (propertyString.StartsWith(ParametersPropertyName))
        {
            _constructorParameters[propertyString] = value;
            return true;
        }

        return false;
    }   

    private void UpdateAssemblyRuntimeData()
    {
        _targetAssembly = null;
        if (string.IsNullOrEmpty(_targetAssemblyName))
        {
            return;
        }

        Assembly newAssembly = GetAssemblyByName(_targetAssemblyName);
        if (newAssembly == null)
        {
            return;
        }
        _targetAssembly = newAssembly;
    }
    private void UpdateRuntimeData()
    {
        _targetAssembly = null;
        _targetType = null;
        _selectedConstructor = null;
        _selectedParameters = null;

        if (string.IsNullOrEmpty(_targetAssemblyName))
        {
            return;
        }

        _targetAssembly = GetAssemblyByName(_targetAssemblyName);
        if (_targetAssembly == null)
        {
            return;
        }

        if (string.IsNullOrEmpty(_targetTypeName))
        {
            return;
        }

        _targetType = GetTypeByName(_targetTypeName, _targetAssembly);
        if (_targetType == null)
        {
            return;
        }

        ConstructorInfo[] constructorArray = _targetType.GetConstructors();
        if (constructorArray.Length == 0)
        {
            return;
        }

        if (_constructorIndex < 0)
        {
            _constructorIndex = 0;
        }
        else if (_constructorIndex >= constructorArray.Length)
        {
            _constructorIndex = constructorArray.Length - 1;
        }

        _selectedConstructor = constructorArray[_constructorIndex];
        _selectedParameters = _selectedConstructor.GetParameters();

        for (int i = 0; i < _selectedParameters.Length; i++)
        {
            ParameterInfo currentParameter = _selectedParameters[i];
            string key = GetParameterDictionaryKeyString(i, currentParameter);
            Variant variant = Variant.From(GetDefaultForType(currentParameter.ParameterType));

            if (!_constructorParameters.ContainsKey(key))
            {
                _constructorParameters.Add(key, variant);
            }
        }
    }

    // Type Reflection Helpers
    private static Type GetTypeByName(string typeName, Assembly assembly)
    {
        return assembly.GetTypes()
            .Where(type => type.Name == typeName)
            .FirstOrDefault();
    }
    private static Assembly GetAssemblyByName(string assemblyName)
    {
        return AppDomain.CurrentDomain.GetAssemblies()
            .Where(assembly => assembly.GetName().Name == assemblyName)
            .FirstOrDefault();
    }
    private static Type[] GetImplementingTypes(Type baseType, Assembly assembly)
    {
        Type[] implementingTypes = assembly.GetTypes()
            .Where(type => baseType.IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)
            .ToArray();

        return implementingTypes;
    }
    private static IEnumerable<Assembly> GetTargetAssemblies(Type baseType)
    {
        Assembly[] assemblyArray = AppDomain.CurrentDomain.GetAssemblies();
        List<Assembly> targetAssemblyList = new(assemblyArray.Length);
        foreach (Assembly assembly in assemblyArray)
        {
            bool hasImplementingTypes = assembly.GetTypes()
               .Any(type => baseType.IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract);

            if (hasImplementingTypes)
            {
                targetAssemblyList.Add(assembly);
            }
        }

        return targetAssemblyList;
    }

    // Property String Generators
    private static string GetParameterDictionaryKeyString(int constructorIndex, ParameterInfo parameter)
    {
        return $"{ParametersPropertyName}/{parameter.Name}";
    }
    private static string GetTargetAssemblyHintString(Type baseType)
    {
        IEnumerable<Assembly> assemblyArray = GetTargetAssemblies(baseType);
        List<string> targetAssemblyList = new(assemblyArray.Count());
        foreach (Assembly assembly in assemblyArray)
        {
            bool hasImplementingTypes = assembly.GetTypes()
               .Any(type => baseType.IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract);

            if (hasImplementingTypes)
            {
                targetAssemblyList.Add(assembly.GetName().Name);
            }
        }
         
        return string.Join(",", targetAssemblyList);
    }
    private static string GetTargetTypeHintString(Type baseType, Assembly assembly)
    {
        Type[] implementableTypeArray = GetImplementingTypes(baseType, assembly);
        var implementingTypeNames = string.Join(",", implementableTypeArray.Select(t => t.Name));
        return implementingTypeNames;
    }
    private static string GetConstructorHintString(Type type)
    {
        ConstructorInfo[] constructorArray = type.GetConstructors();
        System.Collections.Generic.List<string> constructorSignatureList = new();
        for (int i = 0; i < constructorArray.Length; ++i)
        {
            ParameterInfo[] parameterInfoArray = constructorArray[i].GetParameters();
            if (parameterInfoArray.Length > 0)
            {
                // Using semicolon (;) to separate parameters since hint string uses commas (,) to separate enum options.
                string parameterString = string.Join("; ", parameterInfoArray.Select(p => $"{p.ParameterType.Name} {p.Name}"));
                constructorSignatureList.Add($"({parameterString})");
            }
            else
            {
                constructorSignatureList.Add("( Empty )");
            }          
        }

        return string.Join(",", constructorSignatureList);
    }

    // Variant Type Conversion
    private static Variant GetDefaultForType(Type t)
    {
        if (t == typeof(int)) return 0;
        if (t == typeof(float) || t == typeof(double)) return 0.0f;
        if (t == typeof(string)) return "";
        if (t == typeof(bool)) return false;
        if (t == typeof(Vector2)) return new Vector2();
        if (t == typeof(Vector3)) return new Vector3();
        if (t == typeof(Color)) return new Color();
        if (t == typeof(NodePath)) return new NodePath();
        return "";
    }
    private static Variant.Type GetDefaultVariantType(Type type)
    {
        if (type == typeof(int))
            return Variant.Type.Int;
        if (type == typeof(float) || type == typeof(double))
            return Variant.Type.Float;
        if (type == typeof(string))
            return Variant.Type.String;
        if (type == typeof(bool))
            return Variant.Type.Bool;
        if (type == typeof(System.Numerics.Vector2))
            return Variant.Type.Vector2;
        if (type == typeof(System.Numerics.Vector3))
            return Variant.Type.Vector3;

        return Variant.Type.Object;
    }
    private static object GetObjectType(Variant variant)
    {
        switch(variant.VariantType)
        {
            case Variant.Type.Float:
                return variant.AsSingle();
            default:
                return variant.Obj;
        }
    }

    // Instance Creation
    protected object Create()
    {
        UpdateRuntimeData();

        object[] parameters = new object[_selectedParameters.Length];
        for (int i = 0; i < _selectedParameters.Length; i++)
        {
            ParameterInfo parameterInfo = _selectedParameters[i];
            string key = GetParameterDictionaryKeyString(i, parameterInfo);
            if (_constructorParameters.TryGetValue(key, out Variant variant))
            {
                parameters[i] = GetObjectType(variant);
            }
            else
            {
                parameters[i] = GetDefaultForType(parameterInfo.ParameterType);
            }
        }
        return _selectedConstructor.Invoke(parameters);
    }
}

I ended up moving over to Inspector Plugin for a few reasons.

  • Wanted an opportunity to learn the plugin system.
  • Wanted to decouple what was essentially a view class (InspectorPlugin) from what is a model class (Resource)
  • I wanted to make the code more maintainable; each EditorProperty is less than 100 lines while using Object._get(), Object._set(), and Object._get_property_list() takes about 400 lines. Granted, I didn’t clean up my code so there’s probably some clean up opportunity.
  • I could not get hint_string for PropertyHint.ResourceType to work.

That last one is especially important and would love more insight. You see, on one of my C# classes I have the following constructor

public GodotAbilityLogic(Resource godotScript)
  {
      _godotScript = godotScript;
  }

In which I wanted to enforce the resource that could be used to be an abstract class AbstractAbilityLogic that would extend Resource in GDScript, as to allow a user to define ability logic in GDScript if they’d like. GodotAbilityLogic would then work as a bridge between my engine agnostic system and Godot. So I’d have in as my "hint" be PropertyHint.ResourceType and "hint_string" be "AbstractAbilityLogic" However, the inspector, it seemingly ignores the hint string and accepts any Resource.


So I assumed that either that either:

  • I’m somehow setting up the "hint" or "hint_string" wrong,
  • or perhaps if a limitation with property hints when the property is nested (i.e. "params/"+param_name).

Would love to hear your suggestions either way! If I can’t figure out a pure plugin solution I’ll have to bite the bullet and have the arguments be set in the Resource.

Also, I’m quite new here, so is it possible to mark more than one post as the solution? I feel like @mrcdk is the correct solution for most people’s needs, but it’d be cool if I can figure this out in C# and also mark that as a solution (if I ever figure it out).

That’s cool! The plugin system in Godot is quite powerful, but I don’t think this specific use-case is the most ideal one to start learning about it. Inspector plugins aren’t the most straightforward ones to grasp and need a bunch of plumbing to get them working correctly.

Did you set its type as TYPE_OBJECT?

	props.append({
		"name": "my_resource",
		"type": TYPE_OBJECT,
		"hint": PROPERTY_HINT_RESOURCE_TYPE,
		"hint_string": "MyResource",
		"usage": PROPERTY_USAGE_DEFAULT 
	})

No, only one post can be marked as the solution.

I’ll try it out again and post on here later code whether the property hint works or fails.

As I rework back to use _get_property_list(), I have found one limitation that the plugin didn’t have. Since I have more control over how the properties present themselves, I was able to make a dropdown that would return an integer and then use that data to serialize the constructor as an Array<Dictionary>.

Constructor Editor Property
internal partial class ConstructorEditorProperty : EditorProperty
{
    private readonly ReflectionResource _resource;
    private OptionButton _constructorOptionButton = new OptionButton();
    private Array<Array<Dictionary>> _constructorOptionArray = [];

    private Array<Dictionary> _currentConstructor;
    private bool _isUpdating = false;

    public ConstructorEditorProperty(ReflectionResource resource)
    {
        _resource = resource;
        _currentConstructor = resource.ConstructorArray;

        AddChild(_constructorOptionButton);
        AddFocusable(_constructorOptionButton);

        RefreshConstructorNameOptions();
        _constructorOptionButton.ItemSelected += OnConstructorIndexOptionSelected;
    }

    public override void _UpdateProperty()
    {
        var editedProperty = GetEditedProperty();
        var editedConstructor = (Array<Dictionary>)GetEditedObject().Get(editedProperty);
        if (ReflectionUtility.IsMatchingConstructor(editedConstructor, _currentConstructor))
        {
            return;
        }

        _isUpdating = true;
        _currentConstructor = editedConstructor;
        RefreshConstructorNameOptions();
        _isUpdating = false;
    }

    private void OnConstructorIndexOptionSelected(long _)
    {
        if (_isUpdating)
        {
            return;
        }

        int index = _constructorOptionButton.Selected;
        _currentConstructor = _constructorOptionArray[index];
        EmitChanged(nameof(ReflectionResource.ConstructorArray), _currentConstructor);
    }

    private void RefreshConstructorNameOptions()
    {
        _constructorOptionButton.Select(-1);
        _constructorOptionButton.Clear();
        _constructorOptionArray.Clear();

        Assembly assembly = ReflectionUtility.GetAssemblyByName(_resource._assemblyName);
        Type type = ReflectionUtility.GetTypeByName(_resource.TypeName, assembly);

        int constructorIndex = 0;
        foreach (ConstructorInfo constructor in type.GetConstructors())
        {
            Array<Dictionary> availableConstructor = [];
            Array constructorOption = [];
            int parameterIndex = 0;
            foreach (ParameterInfo parameter in constructor.GetParameters())
            {
                ArgumentData parameterVariant = new(parameter, parameterIndex);
                availableConstructor.Add(parameterVariant.AsDictionary());
                constructorOption.Add($"{parameter.ParameterType.Name} {parameter.Name}");
                parameterIndex++;
            }

            var constructorOptionText = "Empty";
            if (constructorOption.Count > 0)
            {
                constructorOptionText = string.Join(", ", constructorOption);
            }

            _constructorOptionButton.AddItem($"({constructorOptionText})", constructorIndex);
            _constructorOptionArray.Add(availableConstructor);

            if (ReflectionUtility.IsMatchingConstructor(availableConstructor, _currentConstructor))
            {
                _constructorOptionButton.Select(constructorIndex);
            }
            constructorIndex++;
        }

        if (_constructorOptionButton.Selected == -1)
        {
            _constructorOptionButton.Select(0);
        }
    }
}

The problem then with _get_property_list() is that I’m forced to use types int, string, or StringName for dropdowns using PropertyHint.Enum. Originally, I got it working by serializing the index of the constructor, rather than all of the constructor info within Array<Dictionary>. However, this would produce a pretty big edge case that if a new constructor was made in the class, the index may be wrong in the future. Hence why I am attempting to serialize the full constructor data to find it again regardless of position. So I guess next question/steps:

  • Is it possible to have both plugin and _get_property_list() work side by side? Might be able to get away with assembly, type and constructor be in the former and parameters be in the latter.
  • Is it possible to use any other property hint to have a dropdown?
  • Is there a way to serialize and hide a property? I could bring back the indexed constructor version to handle _set() changes but validate the property to ensure that the index matches the serialized constructor.

What you serialize to disk and what you show in the inspector can be totally different things. You can check the example I posted above where the parameters are only shown in the inspector and their values are all serialized into one dictionary.

Yes. You can mix both.

No, I don’t think so.

You can mark the property with @export_storage or you can change the usage flags to PROPERTY_USAGE_STORAGE in _get_property_list()

Sweet I’ll try these and post back. I am going to continue to try to have everything in _get_property_list(), but if it’s still not what I want I’ll have to look into splitting between it and plugin code.

Well! This took way longer than expected, but at a point I am pretty happy with it. May make a post elsewhere in case someone else would like to nab this system. Anyways, here’s some key points:

Reflection Resource

Our resource now has a few helpers it calls to keep code more maintainable. Each of the helper’s TrySet() also checks if a property above has updated so that it can grab the default.

ReflectionResource

using Flantastico.Godot.ACE.Presenter.Core.Reflection;
using Godot;
using Godot.Collections;
using System;

namespace Flantastico.Godot.ACE.Presenter.Core.Reflection;

///
/// Abstract class that provides a flexible way to select a class type, its constructor,
/// and constructor parameters through the Godot editor.
///
[GlobalClass, Tool]
public abstract partial class ReflectionResource : Resource
{
public abstract Type BaseType { get; }

public string AssemblyName = string.Empty;
public string TypeName = string.Empty;
public int ConstructorIndex = 0;
public Array<Dictionary> ConstructorArray = [];
public Dictionary Parameters = [];

private readonly AssemblyPropertyHelper _assemblyPropertyHelper;
private readonly TypePropertyHelper _typePropertyHelper;
private readonly ConstructorPropertyHelper _constructorPropertyHelper;
private readonly ParameterPropertyHelper _parameterPropertyHelper;

public ReflectionResource()
{
    _assemblyPropertyHelper = new(this);
    _typePropertyHelper = new(this);
    _constructorPropertyHelper = new(this);
    _parameterPropertyHelper = new(this);
}

public override Array<Dictionary> _GetPropertyList()
{
    Array<Dictionary> properties = [];

    _assemblyPropertyHelper.AddToPropertyList(properties);
    _typePropertyHelper.AddToPropertyList(properties);
    _constructorPropertyHelper.AddToPropertyList(properties);
    _parameterPropertyHelper.AddToPropertyList(properties);

    return properties;
}

public override Variant _Get(StringName property)
{
    if (_assemblyPropertyHelper.TryGet(property, out Variant assemblyVariant))
    {
        return assemblyVariant;
    }
    else if (_typePropertyHelper.TryGet(property, out Variant typeVariant))
    {
        return typeVariant;
    }
    else if (_constructorPropertyHelper.TryGet(property, out Variant constructorVariant))
    {
        return constructorVariant;
    }
    else if (_parameterPropertyHelper.TryGet(property, out Variant parameterVariant))
    {
        return parameterVariant;
    }

    return default;
}

public override bool _Set(StringName property, Variant value)
{
    _assemblyPropertyHelper.TrySet(property, value, out bool isAssemblySet);
    _typePropertyHelper.TrySet(property, value, out bool isTypeSet);
    _constructorPropertyHelper.TrySet(property, value, out bool isConstructorSet);
    _parameterPropertyHelper.TrySet(property, value, out bool isParameterSet);

    return isAssemblySet || isTypeSet || isConstructorSet || isParameterSet;
}

// Instance Creation
protected object Create()
{
    return default;
}

}

Constructor Property Helper

Gonna skip assembly and type helpers since those are pretty simple and go straight for the constructor. @export_storage was the key! Now I use an index for saving which constructor we want, but we validate that the index still matches the expected constructor. This allows adding or removing constructors without fear of your resources breaking. The actual data of the constructor is serialized in a property marked with @export_storage to hide it.

ConstructorPrpertyHelper
using Godot;
using Godot.Collections;
using System;
using System.Linq;
using System.Reflection;


namespace Flantastico.Godot.ACE.Presenter.Core.Reflection;

internal class ConstructorPropertyHelper
{
    private readonly ReflectionResource _resource;

    public const string ArrayPropertyName = "ConstructorArray";
    public const string IndexPropertyName = "Constructor";

    private ConstructorDefinition[] _availableContructorDefinitionArray;

    public ConstructorPropertyHelper(ReflectionResource resource)
    {
        _resource = resource;
        _availableContructorDefinitionArray = GetAvailableConstructorDefinitions(resource);
    }

    internal bool TryGet(StringName property, out Variant variant)
    {
        if (property == IndexPropertyName)
        {
            if (!IsValidIndexForSelectedConstructor(_resource.ConstructorIndex))
            {
                _resource.ConstructorIndex = GetValidIndexForSelectedConstructor();
            }

            variant = _resource.ConstructorIndex;
            return true;
        }
        else if (property == ArrayPropertyName)
        {
            variant = _resource.ConstructorArray;
            return true;
        }

        variant = default;
        return false;
    }

    internal bool TrySet(StringName property, Variant value, out bool isSet)
    {
        if (property == AssemblyPropertyHelper.PropertyName)
        {
            _availableContructorDefinitionArray = GetAvailableConstructorDefinitions(_resource);
            _resource.ConstructorArray = GetDefaultConstructorArray(_resource);
            _resource.NotifyPropertyListChanged();
        }
        else if (property == TypePropertyHelper.PropertyName)
        {
            _availableContructorDefinitionArray = GetAvailableConstructorDefinitions(_resource);
            _resource.ConstructorArray = GetDefaultConstructorArray(_resource);
            _resource.NotifyPropertyListChanged();
        }
        else if (property == ConstructorPropertyHelper.IndexPropertyName)
        {
            _resource.ConstructorIndex = value.AsInt32();
            _resource.ConstructorArray = _availableContructorDefinitionArray[_resource.ConstructorIndex].AsConstructorArray();
            isSet = true;
            return true;
        }
        else if (property == ConstructorPropertyHelper.ArrayPropertyName)
        {
            _resource.ConstructorArray = value.AsGodotArray<Dictionary>();
            _resource.ConstructorIndex = GetValidIndexForSelectedConstructor();
            isSet = true;
            return true;
        }

        isSet = false;
        return false;
    }

    internal void AddToPropertyList(in Array<Dictionary> propertyList)
    {
        Assembly assembly = ReflectionObjectFinder.FindAssemblyByName(_resource.AssemblyName);
        Type type = ReflectionObjectFinder.GetTypeByName(_resource.TypeName, assembly);

        propertyList.Add(new Dictionary
        {
            { "name", IndexPropertyName },
            { "type", (int)Variant.Type.Int },
            { "hint", (int)PropertyHint.Enum },
            { "hint_string", GetConstructorHintString(type) },
        });

        propertyList.Add(new Dictionary
        {
            { "name", ArrayPropertyName },
            { "type", (int)Variant.Type.Array },
            { "hint", (int)PropertyHint.ArrayType },
            { "hint_string", "Dictionary" },
            { "usage", (int)PropertyUsageFlags.Storage }
        });
    }

    internal static Array<Dictionary> GetDefaultConstructorArray(ReflectionResource resource)
    {
        Assembly assembly = ReflectionObjectFinder.FindAssemblyByName(resource.AssemblyName);
        Type type = ReflectionObjectFinder.GetTypeByName(resource.TypeName, assembly);

        Array<Dictionary> constructorArray = new Array<Dictionary>();
        if (resource.BaseType.IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)
        {
            ConstructorInfo constructorInfo = type.GetConstructors()[0];
            ParameterInfo[] parameterInfoArray = constructorInfo.GetParameters();
            for (int i = 0; i < parameterInfoArray.Length; ++i)
            {
                ParameterInfo parameterInfo = parameterInfoArray[i];
                ParameterDefinition argumentData = new(parameterInfo);
                constructorArray.Add(argumentData.AsDictionary());
            }
        }

        return constructorArray;
    }

    private static string GetConstructorHintString(Type type)
    {
        ConstructorInfo[] constructorArray = type.GetConstructors();
        System.Collections.Generic.List<string> constructorSignatureList = new();
        for (int i = 0; i < constructorArray.Length; ++i)
        {
            ParameterInfo[] parameterInfoArray = constructorArray[i].GetParameters();
            if (parameterInfoArray.Length > 0)
            {
                // Using semicolon (;) to separate parameters since hint string uses commas (,) to separate enum options.
                string parameterString = string.Join("; ", parameterInfoArray.Select(p => $"{p.ParameterType.Name} {p.Name}"));
                constructorSignatureList.Add($"({parameterString})");
            }
            else
            {
                constructorSignatureList.Add("( Empty )");
            }
        }

        return string.Join(",", constructorSignatureList);
    }

    private static ConstructorDefinition[] GetAvailableConstructorDefinitions(ReflectionResource resource)
    {
        Assembly assembly = ReflectionObjectFinder.FindAssemblyByName(resource.AssemblyName);
        Type type = ReflectionObjectFinder.GetTypeByName(resource.TypeName, assembly);
        if (type == null)
        {
            type = TypePropertyHelper.GetDefaultType(resource);
        }

        ConstructorInfo[] availableConstructorInfoArray = type.GetConstructors();
        ConstructorDefinition[] availableConstructorDefinitionArray = new ConstructorDefinition[availableConstructorInfoArray.Length];
        
        for (int i = 0; i < availableConstructorInfoArray.Length; ++i)
        {
            ConstructorDefinition constructorDefinition = new(availableConstructorInfoArray[i]);
            availableConstructorDefinitionArray[i] = constructorDefinition;
        }

        return availableConstructorDefinitionArray;
    }

    private bool IsValidIndexForSelectedConstructor(int index)
    {
        if (index >= _availableContructorDefinitionArray.Length)
        {
            return false;
        }

        return _availableContructorDefinitionArray[index].Equals(_resource.ConstructorArray);
    }

    private int GetValidIndexForSelectedConstructor()
    {
        _availableContructorDefinitionArray = GetAvailableConstructorDefinitions(_resource);
        for (int i = 0; i < _availableContructorDefinitionArray.Length; ++i)
        {
            if (_availableContructorDefinitionArray[i].Equals(_resource.ConstructorArray))
            {
                return i;
            }
        }

        return 0;
    }
}

Parameter Property Helper

Don’t know why the "hint_string” wasn’t working before, but I got it running. I’ll chalk it up to something erroneous I did. Which left me the question; how do I pipe the necessary data from the ParameterInfo to the "hint_string”. You see, the reason I was focusing so much on this is that while I wish to work mostly in C#, I want to keep a bridge open for others to expand the system in GDScript. So I created the bridge class and it works great, but there was now way to filter for the GDScript class from C# unless I use GD.Load(), which requires a hard set path. Thought about it for a while, but ended up deciding that this is a limitation and decided to just make a dummy class that has the exact same name as the GDScript, but isn’t a [GlobalClass]. I then just get the name of this type and use that as a filter and voila!

ParameterPropertyHelper.cs
using Godot;
using Godot.Collections;

namespace  Flantastico.Godot.ACE.Presenter.Core.Reflection;

internal class ParameterPropertyHelper
{
    private readonly ReflectionResource _resource;
    public const string PropertyName = "Parameters";

    public ParameterPropertyHelper(ReflectionResource resource)
    {
        _resource = resource;
    }

    internal bool TryGet(StringName property, out Variant variant)
    {
        string propertyName = property;
        if (propertyName.StartsWith($"{PropertyName}/"))
        {
            string parameterName = propertyName.Split("/")[1];
            if (_resource.Parameters.ContainsKey(parameterName))
            {
                variant = _resource.Parameters[parameterName];
            }
            else
            {
                variant = GetDefaultVariantForParameterName(parameterName);
                _resource.Parameters.Add(parameterName, variant);
            }

            return true;
        }

        variant = new Variant();
        return false;
    }

    internal bool TrySet(StringName property, Variant value, out bool isSet)
    {
        string propertyName = property.ToString();
        if (propertyName.StartsWith($"{PropertyName}/"))
        {
            string parameterName = propertyName.Split("/")[1];
            if (_resource.Parameters.ContainsKey(parameterName))
            {
                _resource.Parameters[parameterName] = value;
            }
            else
            {
                _resource.Parameters.Add(parameterName, value);
            }

            isSet = true;
            return true;
        }
        else if (property == AssemblyPropertyHelper.PropertyName)
        {
            _resource.Parameters.Clear();
            _resource.NotifyPropertyListChanged();
        }
        else if (property == TypePropertyHelper.PropertyName)
        {
            _resource.Parameters.Clear();
            _resource.NotifyPropertyListChanged();
        }
        else if (property == ConstructorPropertyHelper.IndexPropertyName)
        {
            _resource.Parameters.Clear();
            _resource.NotifyPropertyListChanged();
        }

        isSet = false;
        return false;
    }

    internal void AddToPropertyList(in Array<Dictionary> propertyList)
    {
        foreach (Dictionary parameterDictionary in _resource.ConstructorArray)
        {
            ParameterDefinition parameterDefinition = new(parameterDictionary);
            Dictionary propertyDictionary = new Dictionary
            {
                { "name", $"{PropertyName}/{parameterDefinition.Name}" },
                { "type", (int)parameterDefinition.VariantType },
            };

            PropertyUsageFlags usageFlags = PropertyUsageFlags.Editor;
            PropertyHint hint = PropertyHint.None;
            string hintString = string.Empty;

            if (parameterDefinition.VariantType == Variant.Type.Nil)
            {
                usageFlags |= PropertyUsageFlags.NilIsVariant;
            }
            else if (parameterDefinition.SystemType.IsAssignableTo(typeof(Resource)))
            {
                usageFlags = PropertyUsageFlags.Default;
                hint = PropertyHint.ResourceType;
                hintString = parameterDefinition.SystemType.Name;
            }

            propertyDictionary.Add("hint", (int)hint);
            propertyDictionary.Add("hint_string", hintString);
            propertyDictionary.Add("usage", (int)usageFlags);

            propertyList.Add(propertyDictionary);
        }
    }

    private Variant GetDefaultVariantForParameterName(string parameterName)
    {
        foreach (Dictionary constructorDictionary in _resource.ConstructorArray)
        {
            ParameterDefinition parameterDefinition = new(constructorDictionary);
            if (parameterDefinition.Name == parameterName)
            {
                return parameterDefinition.DefaultValue;
            }
        }

        return default;
    }
}
GodotAbilityTrigger.cs (GDScript Bridge Class)
using ACE.Godot.Scripts.ACE.Presenter.Core;
using Flantastico.Flanware.ACE.Application.Ability.Instance;
using Flantastico.Flanware.ACE.Application.Ability.Trigger;
using Flantastico.Flanware.ACE.Application.Core.Abstractions;
using Flantastico.Godot.ACE.Presenter.Ability.Trigger.Resources;
using Godot;
using System;

namespace Flantastico.Godot.ACE.Presenter.Ability.Trigger;

/// <summary>
/// Allows the implementation of <see cref="IAbilityTrigger"/> through a GD Script.
/// The GD Script must extend <see cref="Resource"/> and have an "on_trigger" signal, 
/// an "activate" method, and a "deactivate" method. These will be enforced if inheriting
/// from "AbstractAbilityTrigger" as defined in "ability_trigger_resource.gd".
/// For an example, see "process_ability_trigger.gd".
/// </summary>
public class GodotAbilityTrigger : IAbilityTrigger
{
    private const string _onTriggerSignalName = "on_trigger";
    private const string _activateMethodName = "activate";
    private const string _deactivateMethodName = "deactivate";

    public event Action<IAbility> OnTrigger;

    private IAbility _ability = new Flanware.ACE.Application.Ability.Instance.Ability();
    private readonly Resource _godotScript;

    public GodotAbilityTrigger(GodotAbilityTriggerResource resource)
    {
        _godotScript = resource;
    }

    public void Activate(IAbility ability, IAceReferences references)
    {
        _ability = ability;
        AceReferencesVariant referencesVariant = new AceReferencesVariant(references);
        Variant referencesDictionary = referencesVariant.Get();

        _godotScript.Connect(_onTriggerSignalName, Callable.From(InvokeOnTriggerEvent));
        _godotScript.Call(_activateMethodName, referencesDictionary);
    }

    public void Deactivate()
    {
        _godotScript.Call(_deactivateMethodName);
        _godotScript.Disconnect(_onTriggerSignalName, Callable.From(InvokeOnTriggerEvent));
    }

    private void InvokeOnTriggerEvent()
    {
        OnTrigger?.Invoke(_ability);
    }
}
GodotAbilityTriggerResource.cs (Dummy Class)
using Godot;

namespace Flantastico.Godot.ACE.Presenter.Ability.Trigger.Resources
{
    public partial class GodotAbilityTriggerResource : Resource
    {

    }
}
godot_ability_trigger_resource.gd
@abstract
class_name GodotAbilityTriggerResource
extends Resource

signal on_trigger

@abstract func activate(references: Variant)
@abstract func deactivate()

Godot_v4.6.3-stable_mono_win64_nZ94sZ1Q61

Future Work

  • Cacheing Assembly and Type searches to reduce Linq operations.
  • Write up some comments.
  • Bring back actually instantiating objects.

So yeah, thank you so much @mrcdk ! Gonna mark your post as the solution given that it’s the most straightforward solution. Gonna post this at some point in the forum though. Know which category would be best?