feat: aetherbind, first source generator

This commit is contained in:
redglow
2026-07-18 17:05:03 +02:00
parent 4ce99d9486
commit aae9c083d9
69 changed files with 210 additions and 156 deletions
@@ -0,0 +1,109 @@
#if TOOLS
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using Godot;
namespace GodotHostTest.AetherBind.Editor;
/// <summary>
/// Taken from https://github.com/godotengine/godot-proposals/issues/12294
/// </summary>
public static class GodotScriptPathCache
{
private static readonly ConcurrentDictionary<Type, string> TypeToPath = new();
private static readonly ConcurrentDictionary<string, Type> PathToType = new();
private static readonly Type GodotObject = typeof(GodotObject);
private static readonly Type ScriptPathAttribute = typeof(ScriptPathAttribute);
private static AssemblyLoadEventHandler? _assemblyLoadEventHandler;
private static void InitializeIfNeeded()
{
if (_assemblyLoadEventHandler != null) return;
Initialize();
AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly())!
.Unloading += _ => { Deinitialize(); };
}
private static void Initialize()
{
GD.Print($"Initializing {nameof(GodotScriptPathCache)}");
TypeToPath.Clear();
PathToType.Clear();
_assemblyLoadEventHandler = (sender, args) => CacheScriptsInAssembly(args.LoadedAssembly);
AppDomain.CurrentDomain.AssemblyLoad += _assemblyLoadEventHandler;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
CacheScriptsInAssembly(assembly);
return;
void CacheScriptsInAssembly(Assembly assembly)
{
foreach (var type in assembly.GetTypes()
.Where(t => t.IsClass)
.Where(t => t.IsSubclassOf(GodotObject)))
// get script path attribute
if (type.GetCustomAttributes(ScriptPathAttribute, false).FirstOrDefault() is ScriptPathAttribute
scriptPath)
{
TypeToPath[type] = scriptPath.Path;
PathToType[scriptPath.Path] = type;
}
}
}
private static void Deinitialize()
{
GD.Print($"Deinitializing {nameof(GodotScriptPathCache)}");
AppDomain.CurrentDomain.AssemblyLoad -= _assemblyLoadEventHandler;
_assemblyLoadEventHandler = null;
TypeToPath.Clear();
PathToType.Clear();
}
public static bool TryGetScriptPath(Type type, [MaybeNullWhen(false)] out string path)
{
InitializeIfNeeded();
return TypeToPath.TryGetValue(type, out path);
}
public static Script GetScriptFromType(Type type)
{
InitializeIfNeeded();
return TryGetScriptPath(type, out string path)
? GD.Load<Script>(path)
: throw new InvalidOperationException("Script path not found in cache.");
}
public static Script GetScriptFromType<T>()
{
InitializeIfNeeded();
return GetScriptFromType(typeof(T));
}
public static bool TryGetTypeFromPath(string path, [MaybeNullWhen(false)] out Type type)
{
InitializeIfNeeded();
return PathToType.TryGetValue(path, out type);
}
public static Type? GetCSharpScriptType(this GodotObject node)
{
if (node.GetScript().AsGodotObject() is CSharpScript script &&
TryGetTypeFromPath(script.ResourcePath, out var type))
return type;
return null;
}
public static bool IsServiceSource(this GodotObject node)
{
return node.GetScript().AsGodotObject() is CSharpScript script &&
TryGetTypeFromPath(script.ResourcePath, out var type) &&
type.IsAssignableTo(typeof(ServiceSource));
}
}
#endif
@@ -0,0 +1 @@
uid://brjpccpd5ta6x
+176
View File
@@ -0,0 +1,176 @@
#if TOOLS
using System.Linq;
using System.Reflection;
using Godot;
using OwofGames.AetherBind;
using Array = Godot.Collections.Array;
namespace GodotHostTest.AetherBind.Editor;
[Tool]
public partial class Plugin : EditorPlugin
{
/// <summary>
/// Amount of debounce before updating the injections
/// </summary>
private const double DebounceDelay = 0.1;
private const BindingFlags InjectBindingFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private ScopeInspectorPlugin? _plugin;
private SceneTreeTimer? _sceneTreeTimer;
private bool _treeChangesRegistered;
public override void _EnterTree()
{
GD.Print("Initializing Godot DI");
// add the inspector plugin to provide a better interface for the "scope" property
_plugin = new ScopeInspectorPlugin();
AddInspectorPlugin(_plugin);
// update injected nodes when the scene changes
SceneChanged += OnSceneChanged;
}
public override void _ExitTree()
{
GD.Print("Deinitializing Godot DI");
SceneChanged -= OnSceneChanged;
UnregisterTreeChanges();
RemoveInspectorPlugin(_plugin);
_plugin?.Dispose();
_plugin = null;
}
private void OnTreeChanged()
{
var root = EditorInterface.Singleton.GetEditedSceneRoot();
OnSceneChanged(root);
}
private void OnSceneChanged(Node? sceneRoot)
{
// register to tree changes, in case the registration wasn't possible before
RegisterTreeChanges();
// don't do anything if there's no scene root
if (sceneRoot == null) return;
// apply debounce
if (_sceneTreeTimer != null)
{
_sceneTreeTimer.SetTimeLeft(DebounceDelay);
return;
}
_sceneTreeTimer = GetTree().CreateTimer(DebounceDelay);
_sceneTreeTimer.Timeout += () => ActualOnSceneChanged(sceneRoot);
}
private void ActualOnSceneChanged(Node sceneRoot)
{
// mark debounce as completed
_sceneTreeTimer = null;
// check that the instance is still valid after debouncing (could have been disposed)
if (!IsInstanceValid(sceneRoot)) return;
var sceneNodes = sceneRoot.FindChildren("*").Where(node => node.Owner == sceneRoot).Append(sceneRoot).ToList();
GD.Print($"Updating injection for root {sceneRoot.Name}");
// look for the service source
var serviceSource = sceneNodes.Where(child => child.IsServiceSource())
.Single(out var failureReason);
switch (failureReason)
{
case EnumerableExtensions.SingleFailureReason.LessThanOne:
return;
case EnumerableExtensions.SingleFailureReason.MoreThanOne:
GD.PushWarning("Found more than one service source owned by the scene.");
return;
default:
{
if (!IsInstanceValid(serviceSource)) return;
// find all nodes that need injection
var nodesNeedingInjection = sceneNodes.Where(node =>
{
var type = node.GetCSharpScriptType();
return type != null && (
type.GetFields(InjectBindingFlags).Any(field =>
field.GetCustomAttributes(typeof(InjectAttribute), true).Length > 0) ||
type.GetMethods(InjectBindingFlags).Any(field =>
field.GetCustomAttributes(typeof(InjectAttribute), true).Length > 0)
);
}).ToList();
// add all nodes needed to the scope
var injectedNodes = serviceSource.Get(ServiceSource.PropertyName.InjectedNodes).AsGodotArray();
Array? newArray = null;
foreach (var node in nodesNeedingInjection
.Where(node => !injectedNodes.Contains(node)))
{
newArray ??= new Array(injectedNodes);
newArray.Add(node);
GD.Print($"Added node {node.Name} to scope {serviceSource.GetPath()}.");
}
// remove all nodes that are no longer needing injection
foreach (var node in injectedNodes
.Select(n => (Node?)n.AsGodotObject())
.Where(node => !nodesNeedingInjection.Contains(node)))
{
if (node == null) continue;
newArray ??= new Array(injectedNodes);
newArray.Remove(node);
GD.Print($"Removed node {node.Name} to scope {serviceSource.GetPath()}.");
}
// apply changes to the scene if necessary
if (newArray != null)
{
GD.Print("Apply changes.");
var undoRedo = GetUndoRedo();
undoRedo.CreateAction("Update injected nodes");
undoRedo.AddDoProperty(serviceSource, ServiceSource.PropertyName.InjectedNodes, newArray);
undoRedo.AddUndoProperty(serviceSource, ServiceSource.PropertyName.InjectedNodes,
new Array(injectedNodes));
undoRedo.CommitAction();
}
break;
}
}
}
/// <summary>
/// Attach to the TreeChanged event of the SceneTree if it's not already registered and if there's a scene tree to
/// attach to from the currently edited scene.
/// </summary>
private void RegisterTreeChanges()
{
if (_treeChangesRegistered) return;
var sceneRoot = EditorInterface.Singleton.GetEditedSceneRoot();
if (sceneRoot == null) return;
var tree = sceneRoot.GetTree();
tree.TreeChanged += OnTreeChanged;
_treeChangesRegistered = true;
}
private void UnregisterTreeChanges()
{
var sceneRoot = EditorInterface.Singleton.GetEditedSceneRoot();
if (!_treeChangesRegistered || sceneRoot == null) return;
var tree = sceneRoot.GetTree();
tree.TreeChanged -= OnTreeChanged;
SceneChanged -= OnSceneChanged;
}
}
#endif
+1
View File
@@ -0,0 +1 @@
uid://dtvb8nb6d2j4m
@@ -0,0 +1,30 @@
#if TOOLS
using Godot;
namespace GodotHostTest.AetherBind.Editor;
// it's inside ServiceSource so that it can safely access nameof(_serviceScope)
public partial class ScopeInspectorPlugin : EditorInspectorPlugin
{
public override bool _CanHandle(GodotObject @object)
{
var result = @object.IsServiceSource();
return result;
}
public override bool _ParseProperty(GodotObject @object, Variant.Type type, string name, PropertyHint hintType,
string hintString,
PropertyUsageFlags usageFlags, bool wide)
{
// We handle properties of type integer.
if (name != nameof(ServiceSource.PropertyName._serviceScope)) return false;
// Create an instance of the custom property editor and register
// it to a specific property path.
AddPropertyEditor(name, new ScopePickerEditor());
// Inform the editor to remove the default property editor for
// this property type.
return true;
}
}
#endif
@@ -0,0 +1 @@
uid://cl7d2h5juc2gh
@@ -0,0 +1,97 @@
#if TOOLS
using System;
using System.Linq;
using Godot;
namespace GodotHostTest.AetherBind.Editor;
public partial class ScopePickerEditor : EditorProperty
{
/// <summary>
/// The currently chosen type.
/// </summary>
private string _currentType;
/// <summary>
/// All the known fully qualified names for scopes.
/// </summary>
private string[] _fullyQualifiedNames;
/// <summary>
/// The "combo box" that picks a choice from the scopes.
/// </summary>
private OptionButton _optionButton = new();
/// <summary>
/// A guard against internal changes while the property is being updated.
/// </summary>
private bool _updating;
public ScopePickerEditor()
{
// save all the known fully qualified names
_fullyQualifiedNames = ScopeTypesAndNames.GetQualifiedNames().ToArray();
// fill the options button
for (var i = 0; i < _fullyQualifiedNames.Length; i++)
{
// root scope is always the first, and root scope = not scoped
_optionButton.AddItem(
i == 0
? "not a scope boundary"
: ScopeTypesAndNames.GetSimpleNameFromQualifiedName(_fullyQualifiedNames[i]), i);
}
// add the control as a direct child of EditorProperty node.
AddChild(_optionButton);
// make sure the control is able to retain the focus.
AddFocusable(_optionButton);
// initialize the starting value
_currentType = _fullyQualifiedNames[0];
// update the property when the button value changes
_optionButton.ItemSelected += OnOptionButtonItemSelected;
}
public override void _UpdateProperty()
{
// get the new value and immediately return if it's already the selected one.
var newValue = GetEditedObject().Get(GetEditedProperty()).AsString();
if (newValue == _currentType) return;
_updating = true;
try
{
var nameIndex = Array.IndexOf(_fullyQualifiedNames, newValue);
if (nameIndex < 0)
{
GD.PushWarning($"Could not find name {newValue} in the list of scope names, resetting to root scope");
_currentType = _fullyQualifiedNames[0];
EmitChanged(GetEditedProperty(), _currentType);
return;
}
_currentType = newValue;
UpdateControl();
}
finally
{
_updating = false;
}
}
private void OnOptionButtonItemSelected(long index)
{
_currentType = _fullyQualifiedNames[index];
UpdateControl();
EmitChanged(GetEditedProperty(), _currentType);
}
private void UpdateControl()
{
_optionButton.Selected = Array.IndexOf(_fullyQualifiedNames, _currentType);
}
}
#endif
@@ -0,0 +1 @@
uid://cy4jjk14my305