Compare commits

...

20 Commits

Author SHA1 Message Date
1d856fd937 Replicated the weapon flying tick setup using resources
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 5m42s
2026-04-07 16:32:26 +02:00
cc7cb90041 Moving to Godot 4.6.2
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 22s
Create tag and build when new code gets to main / Export (push) Successful in 5m26s
2026-04-04 12:29:15 +02:00
7a787a36d6 Moved the exploding sword forge object from the code only hardcoded stuff to the resource based stuff
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Failing after 3m55s
2026-04-04 12:06:48 +02:00
bfa1f251dd Replaced the entire mana usage and inhibition with the provided forge resources 2026-04-03 16:35:15 +02:00
673368a200 Added rider plugin and turned Empowered Action into a forge-resources-managed ability 2026-04-03 15:33:46 +02:00
c1108e96d7 moving further through forge godot-available resources and interfaces
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 25s
Create tag and build when new code gets to main / Export (push) Failing after 3h11m2s
2026-04-01 15:53:38 +02:00
15cb80d045 Using provided ForgeManager singleton and forge_data resource for tags 2026-04-01 15:07:28 +02:00
1d298b3080 more encapsulated effect application 2026-04-01 10:00:22 +02:00
42ff38f39b yow it's working or wat
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Has been cancelled
2026-03-29 17:30:14 +02:00
dafb0c96cc fix manabar cue issue 2026-03-28 18:40:03 +01:00
ef454e9502 Trying custom execution periodic data 2026-03-28 18:20:47 +01:00
cc70fb361b WIP: integrating forge systems into the game, now trying periodic abilities 2026-03-28 11:43:34 +01:00
7bf19868e7 Setup the base for abilities and events
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Successful in 5m6s
2026-03-22 16:28:57 +01:00
d1f83525b1 updating mana through cues 2026-03-18 16:59:52 +01:00
4bcbda9690 fix: inputs were eaten by a tutorial text because of node ordering
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Successful in 5m12s
Create tag and build when new code gets to main / ReleaseName (push) Successful in 3s
Create tag and build when new code gets to main / Release (push) Successful in 13m52s
2026-03-18 11:10:06 +01:00
e51ef5a517 probably fixed stuttering of the camera and weapon animations 2026-03-18 11:02:08 +01:00
50de6abb5d mana bar 2026-03-15 21:26:59 +01:00
95616f61fc Implemented mana regeneration
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 40s
Create tag and build when new code gets to main / Export (push) Successful in 5m18s
2026-03-11 16:29:09 +01:00
b15a4fef95 empowered action as a forge ability 2026-03-11 15:56:17 +01:00
14d29d68bb Setup empowered action as a Forge ability 2026-03-10 09:22:39 +01:00
202 changed files with 15038 additions and 299 deletions

3
.gitignore vendored
View File

@@ -15,10 +15,13 @@
# Imported translations (automatically generated from CSV files)
*.translation
docs/legal/
.output.txt
*.suo
*.user
*.csproj.old*
_ReSharper.*
*.DotSettings.user
bin

View File

@@ -1,4 +1,4 @@
<Project Sdk="Godot.NET.Sdk/4.6.0">
<Project Sdk="Godot.NET.Sdk/4.6.2">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
@@ -126,6 +126,7 @@
<ItemGroup>
<Folder Include="addons\" />
<Folder Include="tests\" />
<Folder Include="tools\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RustyOptions" Version="0.10.1" />

View File

@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeEditing/SuppressNullableWarningFix/Enabled/@EntryValue">False</s:Boolean></wpf:ResourceDictionary>

View File

@@ -3,6 +3,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Gamesmiths.Forge" Version="0.2.0" />
<PackageReference Include="Gamesmiths.Forge" Version="0.3.0" />
</ItemGroup>
</Project>

View File

@@ -1,11 +1,16 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Diagnostics;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Editor;
using Gamesmiths.Forge.Godot.Editor.Attributes;
using Gamesmiths.Forge.Godot.Editor.Cues;
using Gamesmiths.Forge.Godot.Editor.Statescript;
using Gamesmiths.Forge.Godot.Editor.Tags;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot;
@@ -14,32 +19,25 @@ namespace Gamesmiths.Forge.Godot;
public partial class ForgePluginLoader : EditorPlugin
{
private const string AutoloadPath = "uid://ba8fquhtwu5mu";
private const string PluginScenePath = "uid://pjscvogl6jak";
private EditorDock? _editorDock;
private PanelContainer? _dockedScene;
private TagsEditorDock? _tagsEditorDock;
private TagContainerInspectorPlugin? _tagContainerInspectorPlugin;
private TagInspectorPlugin? _tagInspectorPlugin;
private AttributeSetInspectorPlugin? _attributeSetInspectorPlugin;
private CueHandlerInspectorPlugin? _cueHandlerInspectorPlugin;
private AttributeEditorPlugin? _attributeEditorPlugin;
private SharedVariableSetInspectorPlugin? _sharedVariableSetInspectorPlugin;
private StatescriptGraphEditorDock? _statescriptGraphEditorDock;
private EditorFileSystem? _fileSystem;
private Callable _resourcesReimportedCallable;
public override void _EnterTree()
{
PackedScene pluginScene = ResourceLoader.Load<PackedScene>(PluginScenePath);
EnsureForgeDataExists();
_editorDock = new EditorDock
{
Title = "Forge",
DockIcon = GD.Load<Texture2D>("uid://cu6ncpuumjo20"),
DefaultSlot = EditorDock.DockSlot.RightUl,
};
_dockedScene = (PanelContainer)pluginScene.Instantiate();
_dockedScene.GetNode<TagsEditor>("%Tags").IsPluginInstance = true;
_editorDock.AddChild(_dockedScene);
AddDock(_editorDock);
_tagsEditorDock = new TagsEditorDock();
AddDock(_tagsEditorDock);
_tagContainerInspectorPlugin = new TagContainerInspectorPlugin();
AddInspectorPlugin(_tagContainerInspectorPlugin);
@@ -51,32 +49,89 @@ public partial class ForgePluginLoader : EditorPlugin
AddInspectorPlugin(_cueHandlerInspectorPlugin);
_attributeEditorPlugin = new AttributeEditorPlugin();
AddInspectorPlugin(_attributeEditorPlugin);
_sharedVariableSetInspectorPlugin = new SharedVariableSetInspectorPlugin();
_sharedVariableSetInspectorPlugin.SetUndoRedo(GetUndoRedo());
AddInspectorPlugin(_sharedVariableSetInspectorPlugin);
_statescriptGraphEditorDock = new StatescriptGraphEditorDock();
_statescriptGraphEditorDock.SetUndoRedo(GetUndoRedo());
AddDock(_statescriptGraphEditorDock);
AddToolMenuItem("Repair assets tags", new Callable(this, MethodName.CallAssetRepairTool));
_fileSystem = EditorInterface.Singleton.GetResourceFilesystem();
_resourcesReimportedCallable = new Callable(this, nameof(OnResourcesReimported));
_fileSystem.Connect(EditorFileSystem.SignalName.ResourcesReimported, _resourcesReimportedCallable);
Validation.Enabled = true;
}
public override void _ExitTree()
{
Debug.Assert(_editorDock is not null, $"{nameof(_editorDock)} should have been initialized on _Ready().");
Debug.Assert(_dockedScene is not null, $"{nameof(_dockedScene)} should have been initialized on _Ready().");
Debug.Assert(
_tagsEditorDock is not null,
$"{nameof(_tagsEditorDock)} should have been initialized on _Ready().");
Debug.Assert(
_statescriptGraphEditorDock is not null,
$"{nameof(_statescriptGraphEditorDock)} should have been initialized on _Ready().");
RemoveDock(_editorDock);
_editorDock.QueueFree();
_dockedScene.Free();
if (_fileSystem?.IsConnected(EditorFileSystem.SignalName.ResourcesReimported, _resourcesReimportedCallable)
== true)
{
_fileSystem.Disconnect(EditorFileSystem.SignalName.ResourcesReimported, _resourcesReimportedCallable);
}
RemoveDock(_tagsEditorDock);
_tagsEditorDock.Free();
RemoveInspectorPlugin(_tagContainerInspectorPlugin);
RemoveInspectorPlugin(_tagInspectorPlugin);
RemoveInspectorPlugin(_attributeSetInspectorPlugin);
RemoveInspectorPlugin(_cueHandlerInspectorPlugin);
RemoveInspectorPlugin(_attributeEditorPlugin);
RemoveInspectorPlugin(_sharedVariableSetInspectorPlugin);
RemoveDock(_statescriptGraphEditorDock);
_statescriptGraphEditorDock.Free();
RemoveToolMenuItem("Repair assets tags");
}
public override bool _Handles(GodotObject @object)
{
return @object is StatescriptGraph;
}
public override void _Edit(GodotObject? @object)
{
if (@object is StatescriptGraph graph && _statescriptGraphEditorDock is not null)
{
_statescriptGraphEditorDock.OpenGraph(graph);
}
}
public override void _MakeVisible(bool visible)
{
if (_statescriptGraphEditorDock is null)
{
return;
}
if (visible)
{
_statescriptGraphEditorDock.Open();
}
_statescriptGraphEditorDock.Visible = visible;
}
public override void _EnablePlugin()
{
base._EnablePlugin();
EnsureForgeDataExists();
var config = ProjectSettings.LoadResourcePack(AutoloadPath);
if (config)
@@ -101,9 +156,119 @@ public partial class ForgePluginLoader : EditorPlugin
}
}
public override void _SaveExternalData()
{
_statescriptGraphEditorDock?.SaveAllOpenGraphs();
}
public override string _GetPluginName()
{
return "Forge";
}
public override void _GetWindowLayout(ConfigFile configuration)
{
if (_statescriptGraphEditorDock is null)
{
return;
}
var paths = _statescriptGraphEditorDock.GetOpenResourcePaths();
if (paths.Length == 0)
{
return;
}
configuration.SetValue("Forge", "open_tabs", string.Join(";", paths));
configuration.SetValue("Forge", "active_tab", _statescriptGraphEditorDock.GetActiveTabIndex());
var varStates = _statescriptGraphEditorDock.GetVariablesPanelStates();
configuration.SetValue("Forge", "variables_states", string.Join(";", varStates));
}
public override void _SetWindowLayout(ConfigFile configuration)
{
if (_statescriptGraphEditorDock is null)
{
return;
}
Variant tabsValue = configuration.GetValue("Forge", "open_tabs", string.Empty);
Variant active = configuration.GetValue("Forge", "active_tab", -1);
var tabsString = tabsValue.AsString();
if (string.IsNullOrEmpty(tabsString))
{
return;
}
var paths = tabsString.Split(';', StringSplitOptions.RemoveEmptyEntries);
var activeIndex = active.AsInt32();
bool[]? variablesStates = null;
Variant varStatesValue = configuration.GetValue("Forge", "variables_states", string.Empty);
var varString = varStatesValue.AsString();
if (!string.IsNullOrEmpty(varString))
{
var parts = varString.Split(';');
variablesStates = new bool[parts.Length];
for (var i = 0; i < parts.Length; i++)
{
variablesStates[i] = bool.TryParse(parts[i], out var v) && v;
}
}
_statescriptGraphEditorDock.RestoreFromPaths(paths, activeIndex, variablesStates);
}
private static void EnsureForgeDataExists()
{
if (ResourceLoader.Exists(ForgeData.ForgeDataResourcePath))
{
return;
}
var forgeData = new ForgeData();
Error error = ResourceSaver.Save(forgeData, ForgeData.ForgeDataResourcePath);
if (error != Error.Ok)
{
GD.PrintErr($"Failed to create ForgeData resource: {error}");
return;
}
EditorInterface.Singleton.GetResourceFilesystem().Scan();
GD.Print("Created default ForgeData resource at ", ForgeData.ForgeDataResourcePath);
}
private static void CallAssetRepairTool()
{
AssetRepairTool.RepairAllAssetsTags();
}
private void OnResourcesReimported(string[] resources)
{
foreach (var path in resources)
{
if (!ResourceLoader.Exists(path))
{
continue;
}
var fileType = EditorInterface.Singleton.GetResourceFilesystem().GetFileType(path);
if (fileType != "StatescriptGraph" && fileType != "Resource")
{
continue;
}
Resource resource = ResourceLoader.Load(path);
if (resource is StatescriptGraph graph)
{
_statescriptGraphEditorDock?.OpenGraph(graph);
}
}
}
}
#endif

View File

@@ -2,7 +2,7 @@
Forge for Godot is an Unreal GAS-like gameplay framework for the Godot Engine.
It integrates the [Forge Gameplay System](https://github.com/gamesmiths-guild/forge) into Godot, providing a robust, data-driven foundation for gameplay features such as attributes, effects, gameplay tags, abilities, events, and cues, fully aligned with Godots node, resource, and editor workflows.
It integrates the [Forge Gameplay System](https://github.com/gamesmiths-guild/forge) into Godot, providing a robust, data-driven foundation for gameplay features such as attributes, effects, gameplay tags, abilities, events, cues, and visual ability scripting through Statescript, fully aligned with Godots node, resource, and editor workflows.
This plugin enables you to:
@@ -12,6 +12,7 @@ This plugin enables you to:
- Create hierarchical gameplay tags using the built-in Tags Editor.
- Trigger visual and audio feedback with the Cues system.
- Create player skills, attacks, or behaviors, with support for custom logic, costs, cooldowns, and triggers.
- Build ability behaviors visually with the Statescript graph editor, or implement custom behaviors in C#.
## Features
@@ -21,7 +22,8 @@ This plugin enables you to:
- **Abilities System**: Feature-complete ability system, supporting grant/removal, custom behaviors, triggers, cooldowns, and costs.
- **Events System**: Gameplay event bus supporting event-driven logic, subscriptions, and triggers.
- **Cues System**: Visual/audio feedback layer; decouples presentation from game logic.
- **Editor Extensions**: Custom inspector elements and tag editor with Godot integration.
- **Statescript**: Visual state-based scripting system for implementing ability behaviors with a built-in graph editor.
- **Editor Extensions**: Custom inspector elements, tag editor, and Statescript graph editor with Godot integration.
- **Custom Nodes**: Includes nodes like `ForgeEntity`, `ForgeAttributeSet`, `EffectArea2D`, and more.
## Installation
@@ -51,6 +53,7 @@ This plugin enables you to:
## Documentation
Full documentation, examples, and advanced usage are available in the [Forge for Godot GitHub repository](https://github.com/gamesmiths-guild/forge-godot).
For Statescript documentation, see the [Statescript guide](https://github.com/gamesmiths-guild/forge-godot/blob/main/docs/statescript/README.md).
For technical details about core systems, see the [Forge Gameplay System documentation](https://github.com/gamesmiths-guild/forge/blob/main/docs/README.md).
## License

View File

@@ -8,7 +8,7 @@ public partial class ForgeBootstrap : Node
{
public override void _Ready()
{
ForgeData pluginData = ResourceLoader.Load<ForgeData>("uid://8j4xg16o3qnl");
ForgeData pluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
_ = new ForgeManagers(pluginData);
}
}

View File

@@ -8,6 +8,8 @@ namespace Gamesmiths.Forge.Godot.Core;
[Tool]
public partial class ForgeData : Resource
{
public const string ForgeDataResourcePath = "res://forge/forge_data.tres";
[Export]
public Array<string> RegisteredTags { get; set; } = [];
}

View File

@@ -0,0 +1,352 @@
// Copyright © Gamesmiths Guild.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Gamesmiths.Forge.Statescript.Nodes;
using Godot;
using ForgeNode = Gamesmiths.Forge.Statescript.Node;
using GodotVariant = Godot.Variant;
namespace Gamesmiths.Forge.Godot.Core;
/// <summary>
/// Builds a runtime <see cref="Graph"/> from a serialized <see cref="StatescriptGraph"/> resource.
/// Resolves concrete node types from the Forge DLL and other assemblies using reflection and recreates all connections.
/// </summary>
public static class StatescriptGraphBuilder
{
/// <summary>
/// Builds a runtime <see cref="Graph"/> from the given <see cref="StatescriptGraph"/> resource.
/// </summary>
/// <param name="graphResource">The serialized graph resource.</param>
/// <returns>A fully constructed runtime graph ready for execution.</returns>
/// <exception cref="InvalidOperationException">Thrown when a node type cannot be resolved or instantiated.
/// </exception>
public static Graph Build(StatescriptGraph graphResource)
{
var graph = new Graph();
var nodeMap = new Dictionary<string, ForgeNode>();
foreach (StatescriptNode nodeResource in graphResource.Nodes)
{
switch (nodeResource.NodeType)
{
case StatescriptNodeType.Entry:
nodeMap[nodeResource.NodeId] = graph.EntryNode;
break;
case StatescriptNodeType.Exit:
var exitNode = new ExitNode();
graph.AddNode(exitNode);
nodeMap[nodeResource.NodeId] = exitNode;
break;
default:
ForgeNode runtimeNode = InstantiateNode(nodeResource);
graph.AddNode(runtimeNode);
nodeMap[nodeResource.NodeId] = runtimeNode;
break;
}
}
foreach (StatescriptConnection connectionResource in graphResource.Connections)
{
if (!nodeMap.TryGetValue(connectionResource.FromNode, out ForgeNode? fromNode))
{
GD.PushWarning(
$"Statescript: Connection references unknown source node '{connectionResource.FromNode}'.");
continue;
}
if (!nodeMap.TryGetValue(connectionResource.ToNode, out ForgeNode? toNode))
{
GD.PushWarning(
$"Statescript: Connection references unknown target node '{connectionResource.ToNode}'.");
continue;
}
var outputPortIndex = connectionResource.OutputPort;
var inputPortIndex = connectionResource.InputPort;
if (outputPortIndex < 0 || outputPortIndex >= fromNode.OutputPorts.Length)
{
GD.PushWarning(
$"Statescript: Output port index {outputPortIndex} out of range on node " +
$"'{connectionResource.FromNode}'.");
continue;
}
if (inputPortIndex < 0 || inputPortIndex >= toNode.InputPorts.Length)
{
GD.PushWarning(
$"Statescript: Input port index {inputPortIndex} out of range on node " +
$"'{connectionResource.ToNode}'.");
continue;
}
var connection = new Connection(
fromNode.OutputPorts[outputPortIndex],
toNode.InputPorts[inputPortIndex]);
graph.AddConnection(connection);
}
RegisterGraphVariables(graph, graphResource);
BindNodeProperties(graph, graphResource, nodeMap);
ValidateActivationDataProviders(graphResource);
return graph;
}
private static void RegisterGraphVariables(Graph graph, StatescriptGraph graphResource)
{
foreach (StatescriptGraphVariable variable in graphResource.Variables)
{
if (string.IsNullOrEmpty(variable.VariableName))
{
continue;
}
Type clrType = StatescriptVariableTypeConverter.ToSystemType(variable.VariableType);
if (variable.IsArray)
{
var initialValues = new Variant128[variable.InitialArrayValues.Count];
for (var i = 0; i < variable.InitialArrayValues.Count; i++)
{
initialValues[i] = StatescriptVariableTypeConverter.GodotVariantToForge(
variable.InitialArrayValues[i],
variable.VariableType);
}
graph.VariableDefinitions.ArrayVariableDefinitions.Add(
new ArrayVariableDefinition(
new StringKey(variable.VariableName),
initialValues,
clrType));
}
else
{
Variant128 initialValue = StatescriptVariableTypeConverter.GodotVariantToForge(
variable.InitialValue,
variable.VariableType);
graph.VariableDefinitions.VariableDefinitions.Add(
new VariableDefinition(
new StringKey(variable.VariableName),
initialValue,
clrType));
}
}
}
private static void BindNodeProperties(
Graph graph,
StatescriptGraph graphResource,
Dictionary<string, ForgeNode> nodeMap)
{
foreach (StatescriptNode nodeResource in graphResource.Nodes)
{
if (!nodeMap.TryGetValue(nodeResource.NodeId, out ForgeNode? runtimeNode))
{
continue;
}
foreach (StatescriptNodeProperty binding in nodeResource.PropertyBindings)
{
if (binding.Resolver is null)
{
continue;
}
var index = (byte)binding.PropertyIndex;
if (binding.Direction == StatescriptPropertyDirection.Input)
{
if (index >= runtimeNode.InputProperties.Length)
{
GD.PushWarning(
$"Statescript: Input property index {index} out of range on node " +
$"'{nodeResource.NodeId}'.");
continue;
}
binding.Resolver.BindInput(graph, runtimeNode, nodeResource.NodeId, index);
}
else
{
if (index >= runtimeNode.OutputVariables.Length)
{
GD.PushWarning(
$"Statescript: Output variable index {index} out of range on node " +
$"'{nodeResource.NodeId}'.");
continue;
}
binding.Resolver.BindOutput(runtimeNode, index);
}
}
}
}
private static void ValidateActivationDataProviders(StatescriptGraph graphResource)
{
string? firstProvider = null;
foreach (StatescriptNode node in graphResource.Nodes)
{
foreach (StatescriptNodeProperty binding in node.PropertyBindings)
{
if (binding.Resolver is ActivationDataResolverResource { ProviderClassName.Length: > 0 } resolver)
{
if (firstProvider is null)
{
firstProvider = resolver.ProviderClassName;
}
else if (resolver.ProviderClassName != firstProvider)
{
GD.PushError(
"Statescript: Graph uses multiple activation data providers " +
$"('{firstProvider}' and '{resolver.ProviderClassName}'). " +
"A graph supports only one activation data provider at a time. " +
"Combine the data into a single provider.");
}
}
}
}
}
private static ForgeNode InstantiateNode(StatescriptNode nodeResource)
{
if (string.IsNullOrEmpty(nodeResource.RuntimeTypeName))
{
throw new InvalidOperationException(
$"Node '{nodeResource.NodeId}' of type {nodeResource.NodeType} has no RuntimeTypeName set.");
}
Type? nodeType = ResolveType(nodeResource.RuntimeTypeName);
if (nodeType is null)
{
throw new InvalidOperationException(
$"Could not resolve runtime type '{nodeResource.RuntimeTypeName}' for node " +
$"'{nodeResource.NodeId}'.");
}
ConstructorInfo[] constructors = nodeType.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
if (constructors.Length == 0)
{
return (ForgeNode)Activator.CreateInstance(nodeType)!;
}
ConstructorInfo constructor = constructors.OrderByDescending(x => x.GetParameters().Length).First();
ParameterInfo[] parameters = constructor.GetParameters();
var args = new object[parameters.Length];
for (var i = 0; i < parameters.Length; i++)
{
ParameterInfo param = parameters[i];
var paramName = param.Name ?? string.Empty;
if (nodeResource.CustomData.TryGetValue(paramName, out GodotVariant value))
{
args[i] = ConvertParameter(value, param.ParameterType);
}
else
{
args[i] = GetDefaultValue(param.ParameterType);
}
}
return (ForgeNode)constructor.Invoke(args);
}
private static Type? ResolveType(string typeName)
{
var type = Type.GetType(typeName);
if (type is not null)
{
return type;
}
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
type = assembly.GetType(typeName);
if (type is not null)
{
return type;
}
}
return null;
}
private static object ConvertParameter(GodotVariant value, Type targetType)
{
if (targetType == typeof(StringKey))
{
return new StringKey(value.AsString());
}
if (targetType == typeof(string))
{
return value.AsString();
}
if (targetType == typeof(int))
{
return value.AsInt32();
}
if (targetType == typeof(float))
{
return value.AsSingle();
}
if (targetType == typeof(double))
{
return value.AsDouble();
}
if (targetType == typeof(bool))
{
return value.AsBool();
}
if (targetType == typeof(long))
{
return value.AsInt64();
}
return Convert.ChangeType(value.AsString(), targetType, CultureInfo.InvariantCulture);
}
private static object GetDefaultValue(Type type)
{
if (type == typeof(StringKey))
{
return new StringKey("_default_");
}
if (type == typeof(string))
{
return string.Empty;
}
if (type.IsValueType)
{
return Activator.CreateInstance(type)!;
}
return null!;
}
}

View File

@@ -0,0 +1 @@
uid://btkf3jeisyh8j

View File

@@ -0,0 +1,86 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Statescript;
using GodotPlane = Godot.Plane;
using GodotQuaternion = Godot.Quaternion;
using GodotVector2 = Godot.Vector2;
using GodotVector3 = Godot.Vector3;
using GodotVector4 = Godot.Vector4;
using SysPlane = System.Numerics.Plane;
using SysQuaternion = System.Numerics.Quaternion;
using SysVector2 = System.Numerics.Vector2;
using SysVector3 = System.Numerics.Vector3;
using SysVector4 = System.Numerics.Vector4;
namespace Gamesmiths.Forge.Godot.Core;
/// <summary>
/// Extension methods for <see cref="Variables"/> that provide seamless support for Godot types. These methods
/// automatically convert Godot math types (e.g., <see cref="GodotVector3"/>) to their System.Numerics equivalents
/// before storing them in the variable bag.
/// </summary>
/// <remarks>
/// Use these overloads in data binder delegates (e.g., when implementing
/// <see cref="Resources.IActivationDataProvider.CreateBehavior"/>) to avoid manual Godot-to-System.Numerics
/// conversions.
/// </remarks>
public static class VariablesExtensions
{
/// <summary>
/// Sets a variable from a <see cref="GodotVector2"/> value, converting it to <see cref="SysVector2"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Vector2 value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotVector2 value)
{
variables.SetVariant(name, new Variant128(new SysVector2(value.X, value.Y)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotVector3"/> value, converting it to <see cref="SysVector3"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Vector3 value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotVector3 value)
{
variables.SetVariant(name, new Variant128(new SysVector3(value.X, value.Y, value.Z)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotVector4"/> value, converting it to <see cref="SysVector4"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Vector4 value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotVector4 value)
{
variables.SetVariant(name, new Variant128(new SysVector4(value.X, value.Y, value.Z, value.W)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotPlane"/> value, converting it to <see cref="SysPlane"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Plane value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotPlane value)
{
variables.SetVariant(
name,
new Variant128(new SysPlane(value.Normal.X, value.Normal.Y, value.Normal.Z, value.D)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotQuaternion"/> value, converting it to <see cref="SysQuaternion"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Quaternion value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotQuaternion value)
{
variables.SetVariant(name, new Variant128(new SysQuaternion(value.X, value.Y, value.Z, value.W)));
}
}

View File

@@ -0,0 +1 @@
uid://tkifxnyfxgrp

View File

@@ -1,7 +1,7 @@
[gd_resource type="Resource" load_steps=2 format=3 uid="uid://8j4xg16o3qnl"]
[gd_resource type="Resource" format=3 uid="uid://8j4xg16o3qnl"]
[ext_resource type="Script" uid="uid://bq4vlbfx00hea" path="res://addons/forge/core/ForgeData.cs" id="1_x0pne"]
[resource]
script = ExtResource("1_x0pne")
RegisteredTags = Array[String](["effect.fire", "effect.wet", "cue.floating.text", "cue.vfx.fire", "cue.vfx.wet", "cue.vfx.regen", "cooldown.enemy.attack", "set_by_caller.damage", "event.damage", "cooldown", "cooldown.skill.projectile", "cooldown.skill.shield", "cooldown.skill.dash", "movement.block", "immunity.damage", "effect.mana_shield", "cue.vfx.shield", "event.damage.taken", "event.damage.dealt", "event", "set_by_caller", "trait.flammable", "trait.healable", "trait.damageable", "trait.wettable", "cue.vfx.reflect", "cue.vfx", "cooldown.skill", "cooldown.skill.reflect"])
RegisteredTags = Array[String](["effect.fire", "effect.wet", "cue.floating.text", "cue.vfx.fire", "cue.vfx.wet", "cue.vfx.regen", "cooldown.enemy.attack", "set_by_caller.damage", "event.damage", "cooldown", "cooldown.skill.projectile", "cooldown.skill.shield", "cooldown.skill.dash", "movement.block", "immunity.damage", "effect.mana_shield", "cue.vfx.shield", "event.damage.taken", "event.damage.dealt", "event", "set_by_caller", "trait.flammable", "trait.healable", "trait.damageable", "trait.wettable", "cue.vfx.reflect", "cue.vfx", "cooldown.skill", "cooldown.skill.reflect", "test"])

View File

@@ -17,7 +17,7 @@ public partial class AssetRepairTool : EditorPlugin
{
public static void RepairAllAssetsTags()
{
ForgeData pluginData = ResourceLoader.Load<ForgeData>("uid://8j4xg16o3qnl");
ForgeData pluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
var tagsManager = new TagsManager([.. pluginData.RegisteredTags]);
List<string> scenes = GetScenePaths("res://");

View File

@@ -19,11 +19,9 @@ internal static class EditorUtils
{
var options = new List<string>();
// Get all types in the current assembly
Type[] allTypes = Assembly.GetExecutingAssembly().GetTypes();
// Find all types that subclass AttributeSet
foreach (Type attributeSetType in allTypes.Where(x => x.IsSubclassOf(typeof(AttributeSet))))
foreach (Type attributeSetType in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(x => x.IsSubclassOf(typeof(AttributeSet))))
{
options.Add(attributeSetType.Name);
}
@@ -43,10 +41,9 @@ internal static class EditorUtils
return [];
}
var asm = Assembly.GetExecutingAssembly();
Type? type = Array.Find(
asm.GetTypes(),
x => x.IsSubclassOf(typeof(AttributeSet)) && x.Name == attributeSet);
Type? type = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.FirstOrDefault(x => x.IsSubclassOf(typeof(AttributeSet)) && x.Name == attributeSet);
if (type is null)
{

View File

@@ -6,7 +6,7 @@ using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeEditorProperty : EditorProperty
public partial class AttributeEditorProperty : EditorProperty, ISerializationListener
{
private const int ButtonSize = 26;
private const int PopupSize = 300;
@@ -15,17 +15,15 @@ public partial class AttributeEditorProperty : EditorProperty
public override void _Ready()
{
Texture2D dropdownIcon = EditorInterface.Singleton
.GetEditorTheme()
.GetIcon("GuiDropdown", "EditorIcons");
Texture2D dropdownIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("GuiDropdown", "EditorIcons");
var hbox = new HBoxContainer();
var hBox = new HBoxContainer();
_label = new Label { Text = "None", SizeFlagsHorizontal = SizeFlags.ExpandFill };
var button = new Button { Icon = dropdownIcon, CustomMinimumSize = new Vector2(ButtonSize, 0) };
hbox.AddChild(_label);
hbox.AddChild(button);
AddChild(hbox);
hBox.AddChild(_label);
hBox.AddChild(button);
AddChild(hBox);
var popup = new Popup { Size = new Vector2I(PopupSize, PopupSize) };
var tree = new Tree
@@ -78,6 +76,20 @@ public partial class AttributeEditorProperty : EditorProperty
_label.Text = string.IsNullOrEmpty(value) ? "None" : value;
}
public void OnBeforeSerialize()
{
for (var i = GetChildCount() - 1; i >= 0; i--)
{
Node child = GetChild(i);
RemoveChild(child);
child.Free();
}
}
public void OnAfterDeserialize()
{
}
private static void BuildAttributeTree(Tree tree)
{
TreeItem root = tree.CreateItem();

View File

@@ -12,7 +12,7 @@ using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeSetClassEditorProperty : EditorProperty
public partial class AttributeSetClassEditorProperty : EditorProperty, ISerializationListener
{
private OptionButton _optionButton = null!;
@@ -32,26 +32,40 @@ public partial class AttributeSetClassEditorProperty : EditorProperty
var className = _optionButton.GetItemText((int)x);
EmitChanged(GetEditedProperty(), className);
GodotObject obj = GetEditedObject();
if (obj is not null)
GodotObject @object = GetEditedObject();
if (@object is not null)
{
var dict = new Dictionary<string, AttributeValues>();
var dictionary = new Dictionary<string, AttributeValues>();
var assembly = Assembly.GetAssembly(typeof(ForgeAttributeSet));
Type? targetType = System.Array.Find(assembly?.GetTypes() ?? [], x => x.Name == className);
if (targetType is not null)
{
System.Collections.Generic.IEnumerable<PropertyInfo> attrProps = targetType
System.Collections.Generic.IEnumerable<PropertyInfo> attributeProperties = targetType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(x => x.PropertyType == typeof(EntityAttribute));
foreach (PropertyInfo? pi in attrProps)
foreach (var propertyName in attributeProperties.Select(x => x.Name))
{
dict[pi.Name] = new AttributeValues(0, 0, int.MaxValue);
if (@object is not ForgeAttributeSet forgeAttributeSet)
{
dictionary[propertyName] = new AttributeValues(0, 0, int.MaxValue);
continue;
}
AttributeSet? attributeSet = forgeAttributeSet.GetAttributeSet();
if (attributeSet is null)
{
dictionary[propertyName] = new AttributeValues(0, 0, int.MaxValue);
continue;
}
EntityAttribute key = attributeSet.AttributesMap[className + "." + propertyName];
dictionary[propertyName] = new AttributeValues(key.CurrentValue, key.Min, key.Max);
}
}
EmitChanged("InitialAttributeValues", dict);
EmitChanged("InitialAttributeValues", dictionary);
}
};
}
@@ -70,5 +84,19 @@ public partial class AttributeSetClassEditorProperty : EditorProperty
}
}
}
public void OnBeforeSerialize()
{
for (var i = GetChildCount() - 1; i >= 0; i--)
{
Node child = GetChild(i);
RemoveChild(child);
child.Free();
}
}
public void OnAfterDeserialize()
{
}
}
#endif

View File

@@ -13,7 +13,7 @@ using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeSetValuesEditorProperty : EditorProperty
public partial class AttributeSetValuesEditorProperty : EditorProperty, ISerializationListener
{
public override void _Ready()
{
@@ -94,6 +94,24 @@ public partial class AttributeSetValuesEditorProperty : EditorProperty
}
}
public void OnBeforeSerialize()
{
VBoxContainer? attributesRoot = GetNodeOrNull<VBoxContainer>("AttributesRoot");
if (attributesRoot is not null)
{
for (var i = attributesRoot.GetChildCount() - 1; i >= 0; i--)
{
Node child = attributesRoot.GetChild(i);
attributesRoot.RemoveChild(child);
child.Free();
}
}
}
public void OnAfterDeserialize()
{
}
private static PanelContainer AttributeHeader(string text)
{
var headerPanel = new PanelContainer
@@ -124,17 +142,17 @@ public partial class AttributeSetValuesEditorProperty : EditorProperty
private static HBoxContainer AttributeFieldRow(string label, SpinBox spinBox)
{
var hbox = new HBoxContainer();
var hBox = new HBoxContainer();
hbox.AddChild(new Label
hBox.AddChild(new Label
{
Text = label,
CustomMinimumSize = new Vector2(80, 0),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
});
hbox.AddChild(spinBox);
return hbox;
hBox.AddChild(spinBox);
return hBox;
}
private static SpinBox CreateSpinBox(int min, int max, int value)
@@ -143,8 +161,9 @@ public partial class AttributeSetValuesEditorProperty : EditorProperty
{
MinValue = min,
MaxValue = max,
Value = value,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SelectAllOnFocus = true,
Value = value,
};
}

View File

@@ -5,7 +5,7 @@ using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeValues : Resource
public partial class AttributeValues : RefCounted
{
[Export]
public int Default { get; set; }

View File

@@ -13,16 +13,8 @@ public partial class CueHandlerInspectorPlugin : EditorInspectorPlugin
public override bool _CanHandle(GodotObject @object)
{
// Find out if its an implementation of CueHandler without having to add [Tool] attribute to them.
try
if (@object?.GetScript().As<CSharpScript>() is CSharpScript script)
{
if (@object.GetScript().As<CSharpScript>() is not { }) return false;
}
catch (Exception e)
{
return false;
}
var script = @object.GetScript().As<CSharpScript>();
StringName className = script.GetGlobalName();
Type baseType = typeof(ForgeCueHandler);
@@ -36,6 +28,9 @@ public partial class CueHandlerInspectorPlugin : EditorInspectorPlugin
return implementationType is not null;
}
return false;
}
public override bool _ParseProperty(
GodotObject @object,
Variant.Type type,

View File

@@ -47,7 +47,7 @@ public partial class CueKeyEditorProperty : EditorProperty
AddChild(popup);
ForgeData pluginData = ResourceLoader.Load<ForgeData>("uid://8j4xg16o3qnl");
ForgeData pluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
var tagsManager = new TagsManager([.. pluginData.RegisteredTags]);
TreeItem root = tree.CreateItem();
BuildTreeRecursively(tree, root, tagsManager.RootNode);

View File

@@ -0,0 +1,279 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Base class for custom node property editors. Implementations override the default input-property / output-variable
/// sections rendered by <see cref="StatescriptGraphNode"/> for specific node types. Analogous to Godot's
/// <c>EditorInspectorPlugin</c> pattern.
/// </summary>
/// <remarks>
/// <para>
/// If a <see cref="CustomNodeEditor"/> is registered for a node's <c>RuntimeTypeName</c>, its
/// <see cref="BuildPropertySections"/> method is called instead of the default property rendering. The base class
/// provides helper methods that mirror the default behavior so that custom editors can reuse them selectively.
/// </para>
/// <para>
/// Because this class extends <see cref="RefCounted"/>, signal handlers defined on subclasses can be connected
/// directly to Godot signals (e.g. <c>dropdown.ItemSelected += OnItemSelected</c>) without needing wrapper nodes
/// or workarounds for serialization.
/// </para>
/// </remarks>
[Tool]
internal abstract partial class CustomNodeEditor : RefCounted
{
private StatescriptGraphNode? _graphNode;
private StatescriptGraph? _graph;
private StatescriptNode? _nodeResource;
private Dictionary<PropertySlotKey, NodeEditorProperty>? _activeResolverEditors;
/// <summary>
/// Gets the runtime type name this editor handles (e.g.,
/// <c>"Gamesmiths.Forge.Statescript.Nodes.Action.SetVariableNode"</c>).
/// </summary>
public abstract string HandledRuntimeTypeName { get; }
/// <summary>
/// Builds the custom input-property and output-variable sections for the node.
/// </summary>
/// <param name="typeInfo">Discovered metadata about the node type.</param>
public abstract void BuildPropertySections(StatescriptNodeDiscovery.NodeTypeInfo typeInfo);
/// <summary>
/// Gets the input property section color.
/// </summary>
protected static Color InputPropertyColor { get; } = new(0x61afefff);
/// <summary>
/// Gets the output variable section color.
/// </summary>
protected static Color OutputVariableColor { get; } = new(0xe5c07bff);
/// <summary>
/// Gets the active resolver editors dictionary.
/// </summary>
protected Dictionary<PropertySlotKey, NodeEditorProperty> ActiveResolverEditors => _activeResolverEditors!;
/// <summary>
/// Gets the owning graph resource.
/// </summary>
protected StatescriptGraph Graph => _graph!;
/// <summary>
/// Gets the node resource.
/// </summary>
protected StatescriptNode NodeResource => _nodeResource!;
/// <summary>
/// Gets the undo/redo manager, if available.
/// </summary>
protected EditorUndoRedoManager? UndoRedo => _graphNode?.GetUndoRedo();
/// <summary>
/// Stores references needed by helper methods. Called once after the instance is created.
/// </summary>
/// <param name="graphNode">The graph node this editor is bound to.</param>
/// <param name="graph">The graph resource this node belongs to.</param>
/// <param name="nodeResource">The node resource being edited.</param>
/// <param name="activeResolverEditors">A dictionary of active resolver editors.</param>
internal void Bind(
StatescriptGraphNode graphNode,
StatescriptGraph graph,
StatescriptNode nodeResource,
Dictionary<PropertySlotKey, NodeEditorProperty> activeResolverEditors)
{
_graphNode = graphNode;
_graph = graph;
_nodeResource = nodeResource;
_activeResolverEditors = activeResolverEditors;
}
/// <summary>
/// Clears all references stored by <see cref="Bind"/>. Called before the owning graph node is freed or serialized
/// to prevent accessing disposed objects.
/// </summary>
internal virtual void Unbind()
{
_graphNode = null;
_graph = null;
_nodeResource = null;
_activeResolverEditors = null;
}
/// <summary>
/// Clears all children from a container control.
/// </summary>
/// <param name="container">The container control to clear.</param>
protected static void ClearContainer(Control container)
{
foreach (Node child in container.GetChildren())
{
container.RemoveChild(child);
child.Free();
}
}
/// <summary>
/// Adds a foldable section divider to the graph node.
/// </summary>
/// <param name="sectionTitle">Title displayed on the divider.</param>
/// <param name="color">Color of the divider.</param>
/// <param name="foldKey">Key used to persist the fold state.</param>
/// <param name="folded">Initial fold state.</param>
protected FoldableContainer AddPropertySectionDivider(
string sectionTitle,
Color color,
string foldKey,
bool folded)
{
return _graphNode!.AddPropertySectionDividerInternal(sectionTitle, color, foldKey, folded);
}
/// <summary>
/// Renders a standard input-property row (resolver dropdown + editor UI).
/// </summary>
/// <param name="propInfo">Metadata about the input property.</param>
/// <param name="index">Index of the input property.</param>
/// <param name="container">Container to add the input property row to.</param>
protected void AddInputPropertyRow(
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
int index,
Control container)
{
_graphNode!.AddInputPropertyRowInternal(propInfo, index, container);
}
/// <summary>
/// Renders a standard output-variable row (variable dropdown).
/// </summary>
/// <param name="varInfo">Metadata about the output variable.</param>
/// <param name="index">Index of the output variable.</param>
/// <param name="container">Container to add the output variable row to.</param>
protected void AddOutputVariableRow(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
FoldableContainer container)
{
_graphNode!.AddOutputVariableRowInternal(varInfo, index, container);
}
/// <summary>
/// Gets the persisted fold state for a given key.
/// </summary>
/// <param name="key">The key used to persist the fold state.</param>
protected bool GetFoldState(string key)
{
return _graphNode!.GetFoldStateInternal(key);
}
/// <summary>
/// Finds an existing property binding by direction and index.
/// </summary>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
protected StatescriptNodeProperty? FindBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return _graphNode!.FindBindingInternal(direction, propertyIndex);
}
/// <summary>
/// Ensures a property binding exists for the given direction and index, creating one if needed.
/// </summary>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
protected StatescriptNodeProperty EnsureBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return _graphNode!.EnsureBindingInternal(direction, propertyIndex);
}
/// <summary>
/// Removes a property binding by direction and index.
/// </summary>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
protected void RemoveBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
_graphNode!.RemoveBindingInternal(direction, propertyIndex);
}
/// <summary>
/// Shows a resolver editor inside the given container.
/// </summary>
/// <param name="factory">A factory function to create the resolver editor.</param>
/// <param name="existingBinding">The existing binding, if any.</param>
/// <param name="expectedType">The expected type for the resolver editor.</param>
/// <param name="container">The container to add the resolver editor to.</param>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
/// <param name="isArray">Whether the input expects an array of values.</param>
protected void ShowResolverEditorUI(
Func<NodeEditorProperty> factory,
StatescriptNodeProperty? existingBinding,
Type expectedType,
VBoxContainer container,
StatescriptPropertyDirection direction,
int propertyIndex,
bool isArray = false)
{
_graphNode!.ShowResolverEditorUIInternal(
factory,
existingBinding,
expectedType,
container,
direction,
propertyIndex,
isArray);
}
/// <summary>
/// Requests the owning graph node to recalculate its size.
/// </summary>
protected void ResetSize()
{
_graphNode!.ResetSize();
}
/// <summary>
/// Raises the <see cref="StatescriptGraphNode.PropertyBindingChanged"/> event.
/// </summary>
protected void RaisePropertyBindingChanged()
{
_graphNode!.RaisePropertyBindingChangedInternal();
}
/// <summary>
/// Records an undo/redo action for changing a resolver binding, then rebuilds the node.
/// </summary>
/// <param name="direction">The direction of the property.</param>
/// <param name="propertyIndex">The index of the property.</param>
/// <param name="oldResolver">The previous resolver resource.</param>
/// <param name="newResolver">The new resolver resource.</param>
/// <param name="actionName">The name for the undo/redo action.</param>
protected void RecordResolverBindingChange(
StatescriptPropertyDirection direction,
int propertyIndex,
StatescriptResolverResource? oldResolver,
StatescriptResolverResource? newResolver,
string actionName = "Change Node Property")
{
_graphNode!.RecordResolverBindingChangeInternal(
direction,
propertyIndex,
oldResolver,
newResolver,
actionName);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://f47pprjqcskr

View File

@@ -0,0 +1,52 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Registry of <see cref="CustomNodeEditor"/> implementations. Custom node editors are discovered automatically via
/// reflection. Any concrete subclass of <see cref="CustomNodeEditor"/> in the executing assembly is registered and
/// overrides the default property rendering for its handled node type.
/// </summary>
internal static class CustomNodeEditorRegistry
{
private static readonly Dictionary<string, Func<CustomNodeEditor>> _factories = [];
static CustomNodeEditorRegistry()
{
Type[] allTypes = Assembly.GetExecutingAssembly().GetTypes();
foreach (Type type in allTypes.Where(
x => x.IsSubclassOf(typeof(CustomNodeEditor)) && !x.IsAbstract))
{
Type captured = type;
using var temp = (CustomNodeEditor)Activator.CreateInstance(captured)!;
_factories[temp.HandledRuntimeTypeName] = () => (CustomNodeEditor)Activator.CreateInstance(captured)!;
}
}
/// <summary>
/// Tries to create a new custom node editor for the given runtime type name.
/// </summary>
/// <param name="runtimeTypeName">The runtime type name of the node.</param>
/// <param name="editor">The newly created editor, or <see langword="null"/> if none is registered.</param>
/// <returns><see langword="true"/> if a custom editor was created.</returns>
public static bool TryCreate(string runtimeTypeName, [NotNullWhen(true)] out CustomNodeEditor? editor)
{
if (_factories.TryGetValue(runtimeTypeName, out Func<CustomNodeEditor>? factory))
{
editor = factory();
return true;
}
editor = null;
return false;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dk4rjrm6ky3rd

View File

@@ -0,0 +1,78 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Base class for all Statescript property resolver editor controls. Extends <see cref="PanelContainer"/> so it can be
/// added directly to the graph node UI and participates in the Godot scene tree (lifecycle, disposal, etc.).
/// </summary>
[Tool]
internal abstract partial class NodeEditorProperty : PanelContainer
{
/// <summary>
/// Gets the display name shown in the resolver type dropdown (e.g., "Variable", "Constant", "Attribute").
/// </summary>
public abstract string DisplayName { get; }
/// <summary>
/// Gets the resolver type identifier string used for matching against serialized resources.
/// </summary>
public abstract string ResolverTypeId { get; }
/// <summary>
/// Checks whether this resolver is compatible with the given expected type.
/// </summary>
/// <param name="expectedType">The type expected by the node's input property.</param>
/// <returns><see langword="true"/> if this resolver can provide a value of the expected type.</returns>
public abstract bool IsCompatibleWith(Type expectedType);
/// <summary>
/// Initializes the resolver editor UI. Called once after the control is created.
/// </summary>
/// <param name="graph">The current graph resource (for accessing variables, etc.).</param>
/// <param name="property">The existing property binding to restore state from, or null for a new binding.</param>
/// <param name="expectedType">The type expected by the node's input property.</param>
/// <param name="onChanged">Callback invoked when the resolver configuration changes.</param>
/// <param name="isArray">Whether the input expects an array of values.</param>
public abstract void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray);
/// <summary>
/// Writes the current resolver configuration to the given property binding resource.
/// </summary>
/// <param name="property">The property binding to write to.</param>
public abstract void SaveTo(StatescriptNodeProperty property);
/// <summary>
/// Raised when the editor's layout size has changed (e.g. nested resolver swap, foldable toggle) so that the owning
/// <see cref="GraphNode"/> can call <see cref="Control.ResetSize"/>.
/// </summary>
public event Action? LayoutSizeChanged;
/// <summary>
/// Clears all delegate fields to prevent serialization issues during hot-reload. Called before the editor is
/// serialized or freed.
/// </summary>
public virtual void ClearCallbacks()
{
LayoutSizeChanged = null;
}
/// <summary>
/// Notifies listeners that the editor layout has changed size.
/// </summary>
protected void RaiseLayoutSizeChanged()
{
LayoutSizeChanged?.Invoke();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://djb18x1m1rukn

View File

@@ -0,0 +1,798 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Custom <see cref="EditorProperty"/> that renders the <see cref="ForgeSharedVariableSet.Variables"/> array using the
/// same polished value-editor controls as the graph variable panel.
/// </summary>
[Tool]
internal sealed partial class SharedVariableSetEditorProperty : EditorProperty, ISerializationListener
{
private static readonly Color _variableColor = new(0xe5c07bff);
private readonly HashSet<string> _expandedArrays = [];
private EditorUndoRedoManager? _undoRedo;
private VBoxContainer? _root;
private VBoxContainer? _variableList;
private Button? _addButton;
private AcceptDialog? _creationDialog;
private LineEdit? _newNameEdit;
private OptionButton? _newTypeDropdown;
private CheckBox? _newArrayToggle;
private Texture2D? _addIcon;
private Texture2D? _removeIcon;
/// <summary>
/// Sets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <param name="undoRedo">The undo/redo manager from the editor plugin.</param>
public void SetUndoRedo(EditorUndoRedoManager? undoRedo)
{
_undoRedo = undoRedo;
}
public override void _Ready()
{
_addIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Add", "EditorIcons");
_removeIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Remove", "EditorIcons");
var backgroundPanel = new PanelContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
var panelStyle = new StyleBoxFlat
{
BgColor = EditorInterface.Singleton.GetEditorTheme().GetColor("base_color", "Editor"),
ContentMarginLeft = 6,
ContentMarginRight = 6,
ContentMarginTop = 4,
ContentMarginBottom = 4,
CornerRadiusTopLeft = 3,
CornerRadiusTopRight = 3,
CornerRadiusBottomLeft = 3,
CornerRadiusBottomRight = 3,
};
backgroundPanel.AddThemeStyleboxOverride("panel", panelStyle);
AddChild(backgroundPanel);
SetBottomEditor(backgroundPanel);
_root = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
backgroundPanel.AddChild(_root);
var headerHBox = new HBoxContainer();
_root.AddChild(headerHBox);
_addButton = new Button
{
Text = "Add Variable",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
_addButton.Pressed += OnAddPressed;
headerHBox.AddChild(_addButton);
_root.AddChild(new HSeparator());
_variableList = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_root.AddChild(_variableList);
}
public override void _UpdateProperty()
{
RebuildList();
}
public void OnBeforeSerialize()
{
if (_addButton is not null)
{
_addButton.Pressed -= OnAddPressed;
}
ClearVariableList();
_creationDialog?.Free();
_creationDialog = null;
_newNameEdit = null;
_newTypeDropdown = null;
_newArrayToggle = null;
}
public void OnAfterDeserialize()
{
if (_addButton is not null)
{
_addButton.Pressed += OnAddPressed;
}
RebuildList();
}
private Array<ForgeSharedVariableDefinition> GetDefinitions()
{
GodotObject obj = GetEditedObject();
string propertyName = GetEditedProperty();
Variant value = obj.Get(propertyName);
return value.AsGodotArray<ForgeSharedVariableDefinition>() ?? [];
}
private void NotifyChanged()
{
if (GetEditedObject() is Resource resource)
{
resource.EmitChanged();
}
}
private void RebuildList()
{
if (_variableList is null)
{
return;
}
// Defer the actual rebuild so that any in-progress signal emission (e.g. a button Pressed handler that
// triggered an add/remove) finishes before we free the emitting nodes.
CallDeferred(MethodName.RebuildListDeferred);
}
private void RebuildListDeferred()
{
if (_variableList is null)
{
return;
}
ClearVariableList();
Array<ForgeSharedVariableDefinition> definitions = GetDefinitions();
for (var i = 0; i < definitions.Count; i++)
{
AddVariableRow(definitions, i);
}
}
private void ClearVariableList()
{
if (_variableList is null)
{
return;
}
foreach (Node child in _variableList.GetChildren())
{
_variableList.RemoveChild(child);
child.Free();
}
}
private void AddVariableRow(Array<ForgeSharedVariableDefinition> definitions, int index)
{
if (_variableList is null || index < 0 || index >= definitions.Count)
{
return;
}
ForgeSharedVariableDefinition def = definitions[index];
var rowContainer = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_variableList.AddChild(rowContainer);
var headerRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
rowContainer.AddChild(headerRow);
var nameLabel = new Label
{
Text = def.VariableName,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
nameLabel.AddThemeColorOverride("font_color", _variableColor);
nameLabel.AddThemeFontOverride(
"font",
EditorInterface.Singleton.GetEditorTheme().GetFont("bold", "EditorFonts"));
headerRow.AddChild(nameLabel);
var typeLabel = new Label
{
Text = $"({StatescriptVariableTypeConverter.GetDisplayName(def.VariableType)}"
+ (def.IsArray ? "[])" : ")"),
};
typeLabel.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.6f));
headerRow.AddChild(typeLabel);
var capturedIndex = index;
var deleteButton = new Button
{
Icon = _removeIcon,
Flat = true,
TooltipText = "Remove Variable",
CustomMinimumSize = new Vector2(28, 28),
};
deleteButton.Pressed += () => OnDeletePressed(capturedIndex);
headerRow.AddChild(deleteButton);
if (!def.IsArray)
{
Control valueEditor = CreateValueEditor(def);
rowContainer.AddChild(valueEditor);
}
else
{
VBoxContainer arrayEditor = CreateArrayValueEditor(def);
rowContainer.AddChild(arrayEditor);
}
rowContainer.AddChild(new HSeparator());
}
private Control CreateValueEditor(ForgeSharedVariableDefinition def)
{
if (def.VariableType == StatescriptVariableType.Bool)
{
var hBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
hBox.AddChild(StatescriptEditorControls.CreateBoolEditor(
def.InitialValue.AsBool(),
x => SetVariableValue(def, Variant.From(x))));
return hBox;
}
if (StatescriptEditorControls.IsIntegerType(def.VariableType)
|| StatescriptEditorControls.IsFloatType(def.VariableType))
{
var hBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
EditorSpinSlider spin = StatescriptEditorControls.CreateNumericSpinSlider(
def.VariableType,
def.InitialValue.AsDouble(),
onChanged: x =>
{
Variant newValue = StatescriptEditorControls.IsIntegerType(def.VariableType)
? Variant.From((long)x)
: Variant.From(x);
SetVariableValue(def, newValue);
});
hBox.AddChild(spin);
return hBox;
}
if (StatescriptEditorControls.IsVectorType(def.VariableType))
{
return StatescriptEditorControls.CreateVectorEditor(
def.VariableType,
x => StatescriptEditorControls.GetVectorComponent(
def.InitialValue,
def.VariableType,
x),
onChanged: x =>
{
Variant newValue = StatescriptEditorControls.BuildVectorVariant(
def.VariableType,
x);
SetVariableValue(def, newValue);
});
}
var fallback = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
fallback.AddChild(new Label { Text = def.VariableType.ToString() });
return fallback;
}
private VBoxContainer CreateArrayValueEditor(ForgeSharedVariableDefinition def)
{
var vBox = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
var headerRow = new HBoxContainer();
vBox.AddChild(headerRow);
var isExpanded = _expandedArrays.Contains(def.VariableName);
var elementsContainer = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
Visible = isExpanded,
};
var toggleButton = new Button
{
Text = $"Array (size {def.InitialArrayValues.Count})",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
ToggleMode = true,
ButtonPressed = isExpanded,
};
toggleButton.Toggled += x =>
{
elementsContainer.Visible = x;
var wasExpanded = !x;
if (x)
{
_expandedArrays.Add(def.VariableName);
}
else
{
_expandedArrays.Remove(def.VariableName);
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Toggle Array Expand");
_undoRedo.AddDoMethod(
this,
MethodName.DoSetArrayExpanded,
def.VariableName,
x);
_undoRedo.AddUndoMethod(
this,
MethodName.DoSetArrayExpanded,
def.VariableName,
wasExpanded);
_undoRedo.CommitAction(false);
}
};
headerRow.AddChild(toggleButton);
var addElementButton = new Button
{
Icon = _addIcon,
Flat = true,
TooltipText = "Add Element",
CustomMinimumSize = new Vector2(24, 24),
};
addElementButton.Pressed += () =>
{
Variant defaultValue =
StatescriptVariableTypeConverter.CreateDefaultGodotVariant(def.VariableType);
AddArrayElement(def, defaultValue);
};
headerRow.AddChild(addElementButton);
vBox.AddChild(elementsContainer);
for (var i = 0; i < def.InitialArrayValues.Count; i++)
{
var capturedIndex = i;
if (def.VariableType == StatescriptVariableType.Bool)
{
var elementRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementsContainer.AddChild(elementRow);
elementRow.AddChild(new Label { Text = $"[{i}]" });
elementRow.AddChild(StatescriptEditorControls.CreateBoolEditor(
def.InitialArrayValues[i].AsBool(),
x => SetArrayElementValue(def, capturedIndex, Variant.From(x))));
AddArrayElementRemoveButton(elementRow, def, capturedIndex);
}
else if (StatescriptEditorControls.IsVectorType(def.VariableType))
{
var elementVBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementsContainer.AddChild(elementVBox);
var labelRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementVBox.AddChild(labelRow);
labelRow.AddChild(new Label
{
Text = $"[{i}]",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
});
AddArrayElementRemoveButton(labelRow, def, capturedIndex);
VBoxContainer vectorEditor = StatescriptEditorControls.CreateVectorEditor(
def.VariableType,
x => StatescriptEditorControls.GetVectorComponent(
def.InitialArrayValues[capturedIndex],
def.VariableType,
x),
x =>
{
Variant newValue = StatescriptEditorControls.BuildVectorVariant(
def.VariableType,
x);
SetArrayElementValue(def, capturedIndex, newValue);
});
elementVBox.AddChild(vectorEditor);
}
else
{
var elementRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementsContainer.AddChild(elementRow);
elementRow.AddChild(new Label { Text = $"[{i}]" });
EditorSpinSlider elementSpin = StatescriptEditorControls.CreateNumericSpinSlider(
def.VariableType,
def.InitialArrayValues[i].AsDouble(),
onChanged: x =>
{
Variant newValue = StatescriptEditorControls.IsIntegerType(def.VariableType)
? Variant.From((long)x)
: Variant.From(x);
SetArrayElementValue(def, capturedIndex, newValue);
});
elementRow.AddChild(elementSpin);
AddArrayElementRemoveButton(elementRow, def, capturedIndex);
}
}
return vBox;
}
private void AddArrayElementRemoveButton(
HBoxContainer row,
ForgeSharedVariableDefinition def,
int elementIndex)
{
var removeElementButton = new Button
{
Icon = _removeIcon,
Flat = true,
CustomMinimumSize = new Vector2(24, 24),
};
removeElementButton.Pressed += () => RemoveArrayElement(def, elementIndex);
row.AddChild(removeElementButton);
}
private void SetVariableValue(ForgeSharedVariableDefinition def, Variant newValue)
{
Variant oldValue = def.InitialValue;
def.InitialValue = newValue;
NotifyChanged();
if (_undoRedo is not null)
{
_undoRedo.CreateAction($"Change Shared Variable '{def.VariableName}'");
_undoRedo.AddDoMethod(this, MethodName.ApplyVariableValue, def, newValue);
_undoRedo.AddUndoMethod(this, MethodName.ApplyVariableValue, def, oldValue);
_undoRedo.CommitAction(false);
}
}
private void SetArrayElementValue(ForgeSharedVariableDefinition def, int index, Variant newValue)
{
Variant oldValue = def.InitialArrayValues[index];
def.InitialArrayValues[index] = newValue;
NotifyChanged();
if (_undoRedo is not null)
{
_undoRedo.CreateAction($"Change Shared Variable '{def.VariableName}' Element [{index}]");
_undoRedo.AddDoMethod(this, MethodName.ApplyArrayElementValue, def, index, newValue);
_undoRedo.AddUndoMethod(this, MethodName.ApplyArrayElementValue, def, index, oldValue);
_undoRedo.CommitAction(false);
}
}
private void AddArrayElement(ForgeSharedVariableDefinition def, Variant value)
{
var wasExpanded = _expandedArrays.Contains(def.VariableName);
if (_undoRedo is not null)
{
_undoRedo.CreateAction($"Add Element to '{def.VariableName}'");
_undoRedo.AddDoMethod(this, MethodName.DoAddArrayElement, def, value);
_undoRedo.AddUndoMethod(this, MethodName.UndoAddArrayElement, def, wasExpanded);
_undoRedo.CommitAction();
}
else
{
DoAddArrayElement(def, value);
}
}
private void RemoveArrayElement(ForgeSharedVariableDefinition def, int index)
{
if (index < 0 || index >= def.InitialArrayValues.Count)
{
return;
}
Variant oldValue = def.InitialArrayValues[index];
if (_undoRedo is not null)
{
_undoRedo.CreateAction($"Remove Element [{index}] from '{def.VariableName}'");
_undoRedo.AddDoMethod(this, MethodName.DoRemoveArrayElement, def, index);
_undoRedo.AddUndoMethod(this, MethodName.UndoRemoveArrayElement, def, index, oldValue);
_undoRedo.CommitAction();
}
else
{
DoRemoveArrayElement(def, index);
}
}
private void OnAddPressed()
{
ShowCreationDialog();
}
private void ShowCreationDialog()
{
_creationDialog?.QueueFree();
_creationDialog = new AcceptDialog
{
Title = "Add Shared Variable",
Size = new Vector2I(300, 160),
Exclusive = true,
};
var vBox = new VBoxContainer();
_creationDialog.AddChild(vBox);
var nameRow = new HBoxContainer();
vBox.AddChild(nameRow);
nameRow.AddChild(new Label { Text = "Name:", CustomMinimumSize = new Vector2(60, 0) });
_newNameEdit = new LineEdit
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
PlaceholderText = "variable name",
};
nameRow.AddChild(_newNameEdit);
var typeRow = new HBoxContainer();
vBox.AddChild(typeRow);
typeRow.AddChild(new Label { Text = "Type:", CustomMinimumSize = new Vector2(60, 0) });
_newTypeDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
foreach (StatescriptVariableType variableType in StatescriptVariableTypeConverter.GetAllTypes())
{
_newTypeDropdown.AddItem(
StatescriptVariableTypeConverter.GetDisplayName(variableType),
(int)variableType);
}
typeRow.AddChild(_newTypeDropdown);
var arrayRow = new HBoxContainer();
vBox.AddChild(arrayRow);
arrayRow.AddChild(new Label { Text = "Array:", CustomMinimumSize = new Vector2(60, 0) });
_newArrayToggle = new CheckBox();
arrayRow.AddChild(_newArrayToggle);
_creationDialog.Confirmed += OnCreationConfirmed;
_creationDialog.Canceled += OnCreationCanceled;
EditorInterface.Singleton.PopupDialogCentered(_creationDialog);
}
private void OnCreationConfirmed()
{
if (_newNameEdit is null || _newTypeDropdown is null || _newArrayToggle is null)
{
return;
}
var name = _newNameEdit.Text.Trim();
if (string.IsNullOrEmpty(name))
{
return;
}
var variableType = (StatescriptVariableType)_newTypeDropdown.GetItemId(_newTypeDropdown.Selected);
var newDef = new ForgeSharedVariableDefinition
{
VariableName = name,
VariableType = variableType,
IsArray = _newArrayToggle.ButtonPressed,
InitialValue = StatescriptVariableTypeConverter.CreateDefaultGodotVariant(variableType),
};
Array<ForgeSharedVariableDefinition> definitions = GetDefinitions();
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Add Shared Variable");
_undoRedo.AddDoMethod(this, MethodName.DoAddVariable, definitions, newDef);
_undoRedo.AddUndoMethod(this, MethodName.UndoAddVariable, definitions, newDef);
_undoRedo.CommitAction();
}
else
{
DoAddVariable(definitions, newDef);
}
CleanupCreationDialog();
}
private void OnCreationCanceled()
{
CleanupCreationDialog();
}
private void CleanupCreationDialog()
{
_creationDialog?.QueueFree();
_creationDialog = null;
_newNameEdit = null;
_newTypeDropdown = null;
_newArrayToggle = null;
}
private void OnDeletePressed(int index)
{
Array<ForgeSharedVariableDefinition> definitions = GetDefinitions();
if (index < 0 || index >= definitions.Count)
{
return;
}
ForgeSharedVariableDefinition variable = definitions[index];
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Remove Shared Variable");
_undoRedo.AddDoMethod(this, MethodName.DoRemoveVariable, definitions, variable, index);
_undoRedo.AddUndoMethod(this, MethodName.UndoRemoveVariable, definitions, variable, index);
_undoRedo.CommitAction();
}
else
{
DoRemoveVariable(definitions, index);
}
}
private void ApplyVariableValue(ForgeSharedVariableDefinition def, Variant value)
{
def.InitialValue = value;
NotifyChanged();
RebuildList();
}
private void ApplyArrayElementValue(ForgeSharedVariableDefinition def, int index, Variant value)
{
def.InitialArrayValues[index] = value;
NotifyChanged();
RebuildList();
}
private void DoAddVariable(Array<ForgeSharedVariableDefinition> definitions, ForgeSharedVariableDefinition def)
{
definitions.Add(def);
NotifyChanged();
RebuildList();
}
private void UndoAddVariable(Array<ForgeSharedVariableDefinition> definitions, ForgeSharedVariableDefinition def)
{
definitions.Remove(def);
NotifyChanged();
RebuildList();
}
private void DoRemoveVariable(
Array<ForgeSharedVariableDefinition> definitions,
int index)
{
definitions.RemoveAt(index);
NotifyChanged();
RebuildList();
}
private void UndoRemoveVariable(
Array<ForgeSharedVariableDefinition> definitions,
ForgeSharedVariableDefinition sharedVariableDefinition,
int index)
{
if (index >= definitions.Count)
{
definitions.Add(sharedVariableDefinition);
}
else
{
definitions.Insert(index, sharedVariableDefinition);
}
NotifyChanged();
RebuildList();
}
private void DoAddArrayElement(ForgeSharedVariableDefinition sharedVariableDefinition, Variant value)
{
sharedVariableDefinition.InitialArrayValues.Add(value);
_expandedArrays.Add(sharedVariableDefinition.VariableName);
NotifyChanged();
RebuildList();
}
private void UndoAddArrayElement(ForgeSharedVariableDefinition sharedVariableDefinition, bool wasExpanded)
{
if (sharedVariableDefinition.InitialArrayValues.Count > 0)
{
sharedVariableDefinition.InitialArrayValues.RemoveAt(sharedVariableDefinition.InitialArrayValues.Count - 1);
}
if (!wasExpanded)
{
_expandedArrays.Remove(sharedVariableDefinition.VariableName);
}
NotifyChanged();
RebuildList();
}
private void DoRemoveArrayElement(ForgeSharedVariableDefinition sharedVariableDefinition, int index)
{
sharedVariableDefinition.InitialArrayValues.RemoveAt(index);
NotifyChanged();
RebuildList();
}
private void UndoRemoveArrayElement(
ForgeSharedVariableDefinition sharedVariableDefinition,
int index,
Variant value)
{
if (index >= sharedVariableDefinition.InitialArrayValues.Count)
{
sharedVariableDefinition.InitialArrayValues.Add(value);
}
else
{
sharedVariableDefinition.InitialArrayValues.Insert(index, value);
}
NotifyChanged();
RebuildList();
}
private void DoSetArrayExpanded(string variableName, bool expanded)
{
if (expanded)
{
_expandedArrays.Add(variableName);
}
else
{
_expandedArrays.Remove(variableName);
}
RebuildList();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://co05oybb4l5fp

View File

@@ -0,0 +1,53 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Inspector plugin that replaces the default <see cref="ForgeSharedVariableSet.Variables"/> array editor with a
/// polished UI matching the graph variable panel style.
/// </summary>
public partial class SharedVariableSetInspectorPlugin : EditorInspectorPlugin
{
private EditorUndoRedoManager? _undoRedo;
/// <summary>
/// Sets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <param name="undoRedo">The undo/redo manager from the editor plugin.</param>
public void SetUndoRedo(EditorUndoRedoManager undoRedo)
{
_undoRedo = undoRedo;
}
/// <inheritdoc/>
public override bool _CanHandle(GodotObject @object)
{
return @object is ForgeSharedVariableSet;
}
/// <inheritdoc/>
public override bool _ParseProperty(
GodotObject @object,
Variant.Type type,
string name,
PropertyHint hintType,
string hintString,
PropertyUsageFlags usageFlags,
bool wide)
{
if (name != "Variables")
{
return false;
}
var editorProperty = new SharedVariableSetEditorProperty();
editorProperty.SetUndoRedo(_undoRedo);
AddPropertyEditor(name, editorProperty);
return true;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://bi7wqecgc87xl

View File

@@ -0,0 +1,425 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Statescript.Nodes;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// A popup dialog for adding Statescript nodes to a graph. Features a search bar, categorized tree view, description
/// panel, and Create/Cancel buttons.
/// </summary>
[Tool]
internal sealed partial class StatescriptAddNodeDialog : ConfirmationDialog, ISerializationListener
{
private const int DialogWidth = 244;
private const int DialogHeight = 400;
private static readonly string _exitNodeDescription = new ExitNode().Description;
private LineEdit? _searchBar;
private MenuButton? _expandCollapseButton;
private PopupMenu? _expandCollapsePopup;
private Tree? _tree;
private Label? _descriptionHeader;
private RichTextLabel? _descriptionLabel;
private bool _isFiltering;
/// <summary>
/// Raised when the user confirms node creation. The first argument is the selected
/// <see cref="StatescriptNodeDiscovery.NodeTypeInfo"/> (null for Exit node), the second is the
/// <see cref="StatescriptNodeType"/>, and the third is the graph-local position to place the node.
/// </summary>
public event Action<StatescriptNodeDiscovery.NodeTypeInfo?, StatescriptNodeType, Vector2>? NodeCreationRequested;
/// <summary>
/// Gets or sets the graph-local position where the new node should be placed.
/// </summary>
public Vector2 SpawnPosition { get; set; }
public StatescriptAddNodeDialog()
{
Title = "Add Statescript Node";
Exclusive = true;
Unresizable = false;
MinSize = new Vector2I(DialogWidth, DialogHeight);
Size = new Vector2I(DialogWidth, DialogHeight);
OkButtonText = "Create";
}
public override void _Ready()
{
base._Ready();
Transient = true;
TransientToFocused = true;
BuildUI();
PopulateTree();
GetOkButton().Disabled = true;
Confirmed += OnConfirmed;
Canceled += OnCanceled;
}
public override void _ExitTree()
{
base._ExitTree();
DisconnectSignals();
}
public void OnBeforeSerialize()
{
DisconnectSignals();
NodeCreationRequested = null;
}
public void OnAfterDeserialize()
{
ConnectSignals();
}
/// <summary>
/// Shows the dialog at the specified screen position, resets search and selection state.
/// </summary>
/// <param name="spawnPosition">The graph-local position where the node should be created.</param>
/// <param name="screenPosition">The screen position to show the dialog at.</param>
public void ShowAtPosition(Vector2 spawnPosition, Vector2I screenPosition)
{
SpawnPosition = spawnPosition;
if (_isFiltering)
{
_searchBar?.Clear();
PopulateTree();
}
else
{
_searchBar?.Clear();
}
_tree?.DeselectAll();
GetOkButton().Disabled = true;
UpdateDescription(null);
Position = screenPosition;
Size = new Vector2I(DialogWidth, DialogHeight);
Popup();
_searchBar?.GrabFocus();
}
private static void SetAllCollapsed(TreeItem root, bool collapsed)
{
TreeItem? child = root.GetFirstChild();
while (child is not null)
{
child.Collapsed = collapsed;
SetAllCollapsed(child, collapsed);
child = child.GetNext();
}
}
private void BuildUI()
{
var vBox = new VBoxContainer
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
AddChild(vBox);
var searchHBox = new HBoxContainer();
vBox.AddChild(searchHBox);
_searchBar = new LineEdit
{
PlaceholderText = "Search...",
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
ClearButtonEnabled = true,
RightIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Search", "EditorIcons"),
};
_searchBar.TextChanged += OnSearchTextChanged;
searchHBox.AddChild(_searchBar);
_expandCollapseButton = new MenuButton
{
Flat = true,
Icon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Tools", "EditorIcons"),
TooltipText = "Options",
};
_expandCollapsePopup = _expandCollapseButton.GetPopup();
_expandCollapsePopup.AddItem("Expand All", 0);
_expandCollapsePopup.AddItem("Collapse All", 1);
_expandCollapsePopup.IdPressed += OnExpandCollapseMenuPressed;
searchHBox.AddChild(_expandCollapseButton);
_tree = new Tree
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
HideRoot = true,
SelectMode = Tree.SelectModeEnum.Single,
};
_tree.ItemSelected += OnTreeItemSelected;
_tree.ItemActivated += OnTreeItemActivated;
vBox.AddChild(_tree);
_descriptionHeader = new Label
{
Text = "Description:",
};
vBox.AddChild(_descriptionHeader);
_descriptionLabel = new RichTextLabel
{
BbcodeEnabled = true,
ScrollActive = true,
CustomMinimumSize = new Vector2(0, 70),
};
vBox.AddChild(_descriptionLabel);
}
private void PopulateTree(string filter = "")
{
if (_tree is null)
{
return;
}
_isFiltering = !string.IsNullOrWhiteSpace(filter);
_tree.Clear();
TreeItem root = _tree.CreateItem();
IReadOnlyList<StatescriptNodeDiscovery.NodeTypeInfo> discoveredTypes =
StatescriptNodeDiscovery.GetDiscoveredNodeTypes();
var filterLower = filter.ToLowerInvariant();
TreeItem? actionCategory = null;
TreeItem? conditionCategory = null;
TreeItem? stateCategory = null;
foreach (StatescriptNodeDiscovery.NodeTypeInfo typeInfo in discoveredTypes)
{
if (_isFiltering && !typeInfo.DisplayName.Contains(filterLower, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TreeItem categoryItem;
switch (typeInfo.NodeType)
{
case StatescriptNodeType.Action:
actionCategory ??= CreateCategoryItem(root, "Action");
categoryItem = actionCategory;
break;
case StatescriptNodeType.Condition:
conditionCategory ??= CreateCategoryItem(root, "Condition");
categoryItem = conditionCategory;
break;
case StatescriptNodeType.State:
stateCategory ??= CreateCategoryItem(root, "State");
categoryItem = stateCategory;
break;
default:
continue;
}
TreeItem item = _tree.CreateItem(categoryItem);
item.SetText(0, typeInfo.DisplayName);
item.SetMetadata(0, typeInfo.RuntimeTypeName);
}
if (!_isFiltering || "exit".Contains(filterLower, StringComparison.OrdinalIgnoreCase)
|| "exit node".Contains(filterLower, StringComparison.OrdinalIgnoreCase))
{
TreeItem exitItem = _tree.CreateItem(root);
exitItem.SetText(0, "Exit");
exitItem.SetMetadata(0, "__exit__");
}
SetAllCollapsed(root, !_isFiltering);
UpdateDescription(null);
}
private TreeItem CreateCategoryItem(TreeItem parent, string name)
{
TreeItem item = _tree!.CreateItem(parent);
item.SetText(0, name);
item.SetSelectable(0, false);
return item;
}
private void OnSearchTextChanged(string newText)
{
PopulateTree(newText);
GetOkButton().Disabled = true;
}
private void OnExpandCollapseMenuPressed(long id)
{
if (_tree is null)
{
return;
}
TreeItem? root = _tree.GetRoot();
if (root is null)
{
return;
}
SetAllCollapsed(root, id != 0);
}
private void OnTreeItemSelected()
{
if (_tree is null)
{
return;
}
TreeItem? selected = _tree.GetSelected();
if (selected?.IsSelectable(0) != true)
{
GetOkButton().Disabled = true;
UpdateDescription(null);
return;
}
GetOkButton().Disabled = false;
var metadata = selected.GetMetadata(0).AsString();
UpdateDescription(metadata);
}
private void OnTreeItemActivated()
{
if (_tree?.GetSelected() is not null && !GetOkButton().Disabled)
{
OnConfirmed();
Hide();
}
}
private void OnConfirmed()
{
if (_tree is null)
{
return;
}
TreeItem? selected = _tree.GetSelected();
if (selected?.IsSelectable(0) != true)
{
return;
}
var metadata = selected.GetMetadata(0).AsString();
if (metadata == "__exit__")
{
NodeCreationRequested?.Invoke(null, StatescriptNodeType.Exit, SpawnPosition);
}
else
{
StatescriptNodeDiscovery.NodeTypeInfo? typeInfo =
StatescriptNodeDiscovery.FindByRuntimeTypeName(metadata);
if (typeInfo is not null)
{
NodeCreationRequested?.Invoke(typeInfo, typeInfo.NodeType, SpawnPosition);
}
}
}
private void OnCanceled()
{
// Method intentionally left blank, no action needed on cancel.
}
private void UpdateDescription(string? runtimeTypeName)
{
if (_descriptionLabel is null)
{
return;
}
if (runtimeTypeName is null)
{
_descriptionLabel.Text = string.Empty;
return;
}
if (runtimeTypeName == "__exit__")
{
_descriptionLabel.Text = _exitNodeDescription;
return;
}
StatescriptNodeDiscovery.NodeTypeInfo? typeInfo =
StatescriptNodeDiscovery.FindByRuntimeTypeName(runtimeTypeName);
_descriptionLabel.Text = typeInfo?.Description ?? string.Empty;
}
private void DisconnectSignals()
{
Confirmed -= OnConfirmed;
Canceled -= OnCanceled;
if (_searchBar is not null)
{
_searchBar.TextChanged -= OnSearchTextChanged;
}
if (_expandCollapsePopup is not null)
{
_expandCollapsePopup.IdPressed -= OnExpandCollapseMenuPressed;
}
if (_tree is not null)
{
_tree.ItemSelected -= OnTreeItemSelected;
_tree.ItemActivated -= OnTreeItemActivated;
}
}
private void ConnectSignals()
{
Confirmed += OnConfirmed;
Canceled += OnCanceled;
if (_searchBar is not null)
{
_searchBar.TextChanged += OnSearchTextChanged;
}
if (_expandCollapsePopup is not null)
{
_expandCollapsePopup.IdPressed += OnExpandCollapseMenuPressed;
}
if (_tree is not null)
{
_tree.ItemSelected += OnTreeItemSelected;
_tree.ItemActivated += OnTreeItemActivated;
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dcwnu7ebs2h1c

View File

@@ -0,0 +1,157 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Signal handler helpers used by <see cref="StatescriptEditorControls"/> to avoid lambdas on Godot signals.
/// Each handler is a <see cref="Node"/> so it can be parented to its owning control and freed automatically.
/// </summary>
internal static partial class StatescriptEditorControls
{
/// <summary>
/// Handles <see cref="BaseButton.Toggled"/> for boolean editors, forwarding to an <see cref="Action{T}"/>.
/// </summary>
[Tool]
internal sealed partial class BoolSignalHandler : Node
{
public Action<bool>? OnChanged { get; set; }
public void HandleToggled(bool pressed)
{
OnChanged?.Invoke(pressed);
}
}
/// <summary>
/// Handles <see cref="EditorSpinSlider"/> signals (<c>ValueChanged</c>, <c>Grabbed</c>, <c>Ungrabbed</c>,
/// <c>FocusExited</c>) for numeric editors with drag-commit semantics.
/// </summary>
[Tool]
internal sealed partial class NumericSpinHandler : Node
{
private readonly EditorSpinSlider _spin;
private bool _isDragging;
public Action<double>? OnChanged { get; set; }
public NumericSpinHandler()
{
_spin = null!;
}
public NumericSpinHandler(EditorSpinSlider spin)
{
_spin = spin;
}
public void HandleValueChanged(double value)
{
if (!_isDragging)
{
OnChanged?.Invoke(value);
}
}
public void HandleGrabbed()
{
_isDragging = true;
}
public void HandleUngrabbed()
{
_isDragging = false;
OnChanged?.Invoke(_spin.Value);
}
public void HandleFocusExited()
{
_isDragging = false;
}
}
/// <summary>
/// Holds the shared state (values array, drag flag, callback) for a multi-component vector editor.
/// </summary>
[Tool]
internal sealed partial class VectorComponentHandler : Node
{
private readonly double[] _values;
public Action<double[]>? OnChanged { get; set; }
public bool IsDragging { get; set; }
public VectorComponentHandler()
{
_values = [];
}
public VectorComponentHandler(double[] values)
{
_values = values;
}
public void SetValue(int index, double value)
{
_values[index] = value;
}
public void RaiseChanged()
{
OnChanged?.Invoke(_values);
}
}
/// <summary>
/// Handles <see cref="EditorSpinSlider"/> signals for a single component of a vector editor.
/// Forwards to the shared <see cref="VectorComponentHandler"/>.
/// </summary>
[Tool]
internal sealed partial class VectorSpinHandler : Node
{
private readonly VectorComponentHandler _parent;
private readonly int _componentIndex;
public VectorSpinHandler()
{
_parent = null!;
}
public VectorSpinHandler(VectorComponentHandler parent, int componentIndex)
{
_parent = parent;
_componentIndex = componentIndex;
}
public void HandleValueChanged(double value)
{
_parent.SetValue(_componentIndex, value);
if (!_parent.IsDragging)
{
_parent.RaiseChanged();
}
}
public void HandleGrabbed()
{
_parent.IsDragging = true;
}
public void HandleUngrabbed()
{
_parent.IsDragging = false;
_parent.RaiseChanged();
}
public void HandleFocusExited()
{
_parent.IsDragging = false;
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cssljh632gdln

View File

@@ -0,0 +1,421 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Shared factory methods for creating value-editor controls used by both the variable panel and resolver editors.
/// </summary>
internal static partial class StatescriptEditorControls
{
private static readonly Color _axisXColor = new(0.96f, 0.37f, 0.37f);
private static readonly Color _axisYColor = new(0.54f, 0.83f, 0.01f);
private static readonly Color _axisZColor = new(0.33f, 0.55f, 0.96f);
private static readonly Color _axisWColor = new(0.66f, 0.66f, 0.66f);
private static StyleBox? _cachedPanelStyle;
/// <summary>
/// Returns <see langword="true"/> for integer-like variable types.
/// </summary>
/// <param name="type">The variable type to check.</param>
public static bool IsIntegerType(StatescriptVariableType type)
{
return type is StatescriptVariableType.Int or StatescriptVariableType.UInt
or StatescriptVariableType.Long or StatescriptVariableType.ULong
or StatescriptVariableType.Short or StatescriptVariableType.UShort
or StatescriptVariableType.Byte or StatescriptVariableType.SByte
or StatescriptVariableType.Char;
}
/// <summary>
/// Returns <see langword="true"/> for floating-point variable types.
/// </summary>
/// <param name="type">The variable type to check.</param>
public static bool IsFloatType(StatescriptVariableType type)
{
return type is StatescriptVariableType.Float or StatescriptVariableType.Double
or StatescriptVariableType.Decimal;
}
/// <summary>
/// Returns <see langword="true"/> for multi-component vector/quaternion/plane variable types.
/// </summary>
/// <param name="type">The variable type to check.</param>
public static bool IsVectorType(StatescriptVariableType type)
{
return type is StatescriptVariableType.Vector2 or StatescriptVariableType.Vector3
or StatescriptVariableType.Vector4 or StatescriptVariableType.Plane
or StatescriptVariableType.Quaternion;
}
/// <summary>
/// Creates a <see cref="PanelContainer"/> wrapping a <see cref="CheckBox"/> for boolean editing.
/// </summary>
/// <param name="value">The initial value of the boolean.</param>
/// <param name="onChanged">An action invoked on value change.</param>
/// <returns>A <see cref="PanelContainer"/> containing a <see cref="CheckBox"/>.</returns>
public static PanelContainer CreateBoolEditor(bool value, Action<bool> onChanged)
{
var container = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
container.AddThemeStyleboxOverride("panel", GetPanelStyle());
var checkButton = new CheckBox
{
Text = "On",
ButtonPressed = value,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
var handler = new BoolSignalHandler { OnChanged = onChanged };
checkButton.AddChild(handler);
checkButton.Toggled += handler.HandleToggled;
container.AddChild(checkButton);
return container;
}
/// <summary>
/// Creates an <see cref="EditorSpinSlider"/> configured for the given numeric variable type.
/// </summary>
/// <param name="type">The type of the numeric variable.</param>
/// <param name="value">The initial value of the numeric variable.</param>
/// <param name="onChanged">An action invoked on value change.</param>
/// <returns>An <see cref="EditorSpinSlider"/> configured for the specified numeric variable type.</returns>
public static EditorSpinSlider CreateNumericSpinSlider(
StatescriptVariableType type,
double value,
Action<double>? onChanged = null)
{
NumericConfig config = GetNumericConfig(type);
var spin = new EditorSpinSlider
{
Step = config.Step,
Rounded = config.IsInteger,
EditingInteger = config.IsInteger,
MinValue = config.MinValue,
MaxValue = config.MaxValue,
AllowGreater = config.AllowBeyondRange,
AllowLesser = config.AllowBeyondRange,
ControlState = config.IsInteger
? EditorSpinSlider.ControlStateEnum.Default
: EditorSpinSlider.ControlStateEnum.Hide,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
Value = value,
};
var handler = new NumericSpinHandler(spin) { OnChanged = onChanged };
spin.AddChild(handler);
if (onChanged is not null)
{
spin.ValueChanged += handler.HandleValueChanged;
}
spin.Grabbed += handler.HandleGrabbed;
spin.Ungrabbed += handler.HandleUngrabbed;
spin.FocusExited += handler.HandleFocusExited;
return spin;
}
/// <summary>
/// Creates a panel with a row of labelled <see cref="EditorSpinSlider"/> controls for editing a vector value.
/// </summary>
/// <param name="type">The type of the vector/quaternion/plane.</param>
/// <param name="getComponent">A function to retrieve the value of a specific component.</param>
/// <param name="onChanged">An action to invoke when any component value changes.</param>
/// <returns>A <see cref="VBoxContainer"/> containing the vector editor controls.</returns>
public static VBoxContainer CreateVectorEditor(
StatescriptVariableType type,
Func<int, double> getComponent,
Action<double[]>? onChanged)
{
var componentCount = GetVectorComponentCount(type);
var labels = GetVectorComponentLabels(type);
var vBox = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
var row = new HBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
row.AddThemeConstantOverride("separation", 0);
var panelContainer = new PanelContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
panelContainer.AddThemeStyleboxOverride("panel", GetPanelStyle());
vBox.AddChild(panelContainer);
panelContainer.AddChild(row);
var values = new double[componentCount];
var handler = new VectorComponentHandler(values) { OnChanged = onChanged };
vBox.AddChild(handler);
for (var i = 0; i < componentCount; i++)
{
values[i] = getComponent(i);
var spin = new EditorSpinSlider
{
Label = labels[i],
Step = 0.001,
Rounded = false,
EditingInteger = false,
AllowGreater = true,
AllowLesser = true,
Flat = false,
ControlState = EditorSpinSlider.ControlStateEnum.Hide,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsStretchRatio = 1,
CustomMinimumSize = new Vector2(71, 0),
Value = values[i],
};
spin.AddThemeColorOverride("label_color", GetComponentColor(i));
var componentHandler = new VectorSpinHandler(handler, i);
spin.AddChild(componentHandler);
spin.ValueChanged += componentHandler.HandleValueChanged;
spin.Grabbed += componentHandler.HandleGrabbed;
spin.Ungrabbed += componentHandler.HandleUngrabbed;
spin.FocusExited += componentHandler.HandleFocusExited;
row.AddChild(spin);
}
return vBox;
}
/// <summary>
/// Reads a single component from a vector/quaternion/plane variant.
/// </summary>
/// <param name="value">The variant containing the vector/quaternion/plane value.</param>
/// <param name="type">The type of the vector/quaternion/plane.</param>
/// <param name="index">The index of the component to retrieve.</param>
/// <exception cref="NotImplementedException">Exception thrown if the provided type is not a vector/quaternion/plane
/// type.</exception>
public static double GetVectorComponent(Variant value, StatescriptVariableType type, int index)
{
return type switch
{
StatescriptVariableType.Vector2 => index == 0
? value.AsVector2().X
: value.AsVector2().Y,
StatescriptVariableType.Vector3 => index switch
{
0 => value.AsVector3().X,
1 => value.AsVector3().Y,
_ => value.AsVector3().Z,
},
StatescriptVariableType.Vector4 => index switch
{
0 => value.AsVector4().X,
1 => value.AsVector4().Y,
2 => value.AsVector4().Z,
_ => value.AsVector4().W,
},
StatescriptVariableType.Plane => index switch
{
0 => value.AsPlane().Normal.X,
1 => value.AsPlane().Normal.Y,
2 => value.AsPlane().Normal.Z,
_ => value.AsPlane().D,
},
StatescriptVariableType.Quaternion => index switch
{
0 => value.AsQuaternion().X,
1 => value.AsQuaternion().Y,
2 => value.AsQuaternion().Z,
_ => value.AsQuaternion().W,
},
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => 0,
};
}
/// <summary>
/// Builds a Godot <see cref="Variant"/> from a component array for the given vector/quaternion/plane type.
/// </summary>
/// <param name="type">The type of the vector/quaternion/plane.</param>
/// <param name="values">The array of component values.</param>
/// <returns>A <see cref="Variant"/> representing the vector/quaternion/plane.</returns>
/// <exception cref="NotImplementedException">Exception thrown if the provided type is not a vector/quaternion/plane
/// type.</exception>
public static Variant BuildVectorVariant(StatescriptVariableType type, double[] values)
{
return type switch
{
StatescriptVariableType.Vector2 => Variant.From(
new Vector2((float)values[0], (float)values[1])),
StatescriptVariableType.Vector3 => Variant.From(
new Vector3(
(float)values[0],
(float)values[1],
(float)values[2])),
StatescriptVariableType.Vector4 => Variant.From(
new Vector4(
(float)values[0],
(float)values[1],
(float)values[2],
(float)values[3])),
StatescriptVariableType.Plane => Variant.From(
new Plane(
new Vector3(
(float)values[0],
(float)values[1],
(float)values[2]),
(float)values[3])),
StatescriptVariableType.Quaternion => Variant.From(
new Quaternion(
(float)values[0],
(float)values[1],
(float)values[2],
(float)values[3])),
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => Variant.From(0),
};
}
private static int GetVectorComponentCount(StatescriptVariableType type)
{
return type switch
{
StatescriptVariableType.Vector2 => 2,
StatescriptVariableType.Vector3 => 3,
StatescriptVariableType.Vector4 => 4,
StatescriptVariableType.Plane => 4,
StatescriptVariableType.Quaternion => 4,
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => 4,
};
}
private static string[] GetVectorComponentLabels(StatescriptVariableType type)
{
return type switch
{
StatescriptVariableType.Vector2 => ["x", "y"],
StatescriptVariableType.Vector3 => ["x", "y", "z"],
StatescriptVariableType.Plane => ["x", "y", "z", "d"],
StatescriptVariableType.Vector4 => ["x", "y", "z", "w"],
StatescriptVariableType.Quaternion => ["x", "y", "z", "w"],
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => ["x", "y", "z", "w"],
};
}
private static Color GetComponentColor(int index)
{
return index switch
{
0 => _axisXColor,
1 => _axisYColor,
2 => _axisZColor,
_ => _axisWColor,
};
}
private static NumericConfig GetNumericConfig(StatescriptVariableType type)
{
return type switch
{
StatescriptVariableType.Byte => new NumericConfig(byte.MinValue, byte.MaxValue, 1, true, false),
StatescriptVariableType.SByte => new NumericConfig(sbyte.MinValue, sbyte.MaxValue, 1, true, false),
StatescriptVariableType.Char => new NumericConfig(char.MinValue, char.MaxValue, 1, true, false),
StatescriptVariableType.Short => new NumericConfig(short.MinValue, short.MaxValue, 1, true, false),
StatescriptVariableType.UShort => new NumericConfig(ushort.MinValue, ushort.MaxValue, 1, true, false),
StatescriptVariableType.Int => new NumericConfig(int.MinValue, int.MaxValue, 1, true, false),
StatescriptVariableType.UInt => new NumericConfig(uint.MinValue, uint.MaxValue, 1, true, false),
// Godot's interface starts acting weird if we try to use the full range of long/ulong, so we clamp to +/-
// 9e18 which should be sufficient for most use cases.
StatescriptVariableType.Long => new NumericConfig(-9e18, 9e18, 1, true, false),
StatescriptVariableType.ULong => new NumericConfig(0, 9e18, 1, true, false),
StatescriptVariableType.Float => new NumericConfig(-1e10, 1e10, 0.001, false, true),
StatescriptVariableType.Double => new NumericConfig(-1e10, 1e10, 0.001, false, true),
StatescriptVariableType.Decimal => new NumericConfig(-1e10, 1e10, 0.001, false, true),
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Vector2 => throw new NotImplementedException(),
StatescriptVariableType.Vector3 => throw new NotImplementedException(),
StatescriptVariableType.Vector4 => throw new NotImplementedException(),
StatescriptVariableType.Plane => throw new NotImplementedException(),
StatescriptVariableType.Quaternion => throw new NotImplementedException(),
_ => new NumericConfig(-1e10, 1e10, 0.001, false, true),
};
}
private readonly record struct NumericConfig(
double MinValue,
double MaxValue,
double Step,
bool IsInteger,
bool AllowBeyondRange);
private static StyleBox GetPanelStyle()
{
if (_cachedPanelStyle is null || !GodotObject.IsInstanceValid(_cachedPanelStyle))
{
Control baseControl = EditorInterface.Singleton.GetBaseControl();
_cachedPanelStyle = (StyleBox)baseControl.GetThemeStylebox("normal", "LineEdit").Duplicate();
_cachedPanelStyle.SetContentMarginAll(0);
}
return _cachedPanelStyle;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dpoji1y5vib4o

View File

@@ -0,0 +1,328 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphEditorDock
{
private void OnFileMenuIdPressed(long id)
{
switch ((int)id)
{
case 0:
ShowNewStatescriptDialog();
break;
case 1:
ShowLoadStatescriptDialog();
break;
case 2:
OnSavePressed();
break;
case 3:
ShowSaveAsDialog();
break;
case 4:
CloseCurrentTab();
break;
}
}
private void ShowNewStatescriptDialog()
{
_newStatescriptDialog?.QueueFree();
_newStatescriptDialog = new AcceptDialog
{
Title = "Create Statescript",
Size = new Vector2I(400, 140),
Exclusive = true,
};
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
var pathRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(pathRow);
pathRow.AddChild(new Label { Text = "Path:", CustomMinimumSize = new Vector2(50, 0) });
_newStatescriptPathEdit = new LineEdit
{
Text = "res://new_statescript.tres",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
pathRow.AddChild(_newStatescriptPathEdit);
_newStatescriptDialog.AddChild(vBox);
_newStatescriptDialog.Confirmed += OnNewStatescriptConfirmed;
AddChild(_newStatescriptDialog);
_newStatescriptDialog.PopupCentered();
}
private void OnNewStatescriptConfirmed()
{
if (_newStatescriptPathEdit is null)
{
return;
}
var path = _newStatescriptPathEdit.Text.Trim();
if (string.IsNullOrEmpty(path))
{
return;
}
if (!path.EndsWith(".tres", System.StringComparison.OrdinalIgnoreCase))
{
path += ".tres";
}
var graph = new StatescriptGraph();
graph.EnsureEntryNode();
graph.StatescriptName = path.GetFile().GetBaseName();
ResourceSaver.Save(graph, path);
EditorInterface.Singleton.GetResourceFilesystem().Scan();
graph = ResourceLoader.Load<StatescriptGraph>(path);
if (graph is not null)
{
OpenGraph(graph);
}
_newStatescriptDialog?.QueueFree();
_newStatescriptDialog = null;
}
private void ShowLoadStatescriptDialog()
{
var dialog = new EditorFileDialog
{
FileMode = FileDialog.FileModeEnum.OpenFile,
Title = "Load Statescript File",
Access = FileDialog.AccessEnum.Resources,
};
dialog.AddFilter("*.tres;StatescriptGraph");
dialog.FileSelected += path =>
{
Resource? graph = ResourceLoader.Load(path);
if (graph is StatescriptGraph statescriptGraph)
{
OpenGraph(statescriptGraph);
}
else
{
GD.PushWarning($"Failed to load StatescriptGraph from: {path}");
}
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(700, 500));
}
private void ShowSaveAsDialog()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null)
{
return;
}
var dialog = new EditorFileDialog
{
FileMode = FileDialog.FileModeEnum.SaveFile,
Title = "Save Statescript As",
Access = FileDialog.AccessEnum.Resources,
};
dialog.AddFilter("*.tres", "Godot Resource");
dialog.FileSelected += path =>
{
if (_graphEdit is not null)
{
graph.ScrollOffset = _graphEdit.ScrollOffset;
graph.Zoom = _graphEdit.Zoom;
SyncVisualNodePositionsToGraph();
SyncConnectionsToCurrentGraph();
}
ResourceSaver.Save(graph, path);
EditorInterface.Singleton.GetResourceFilesystem().Scan();
GD.Print($"Statescript graph saved as: {path}");
StatescriptGraph? savedGraph = ResourceLoader.Load<StatescriptGraph>(path);
if (savedGraph is not null)
{
OpenGraph(savedGraph);
}
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(700, 500));
}
private void OnSavePressed()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
graph.ScrollOffset = _graphEdit.ScrollOffset;
graph.Zoom = _graphEdit.Zoom;
SyncVisualNodePositionsToGraph();
SyncConnectionsToCurrentGraph();
if (string.IsNullOrEmpty(graph.ResourcePath))
{
ShowSaveAsDialog();
return;
}
SaveGraphResource(graph);
GD.Print($"Statescript graph saved: {graph.ResourcePath}");
}
private void OnGraphEditPopupRequest(Vector2 atPosition)
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null)
{
return;
}
ClearPendingConnection();
Vector2 graphPosition = (_graphEdit.ScrollOffset + atPosition) / _graphEdit.Zoom;
var screenPosition = (Vector2I)(_graphEdit.GetScreenPosition() + atPosition);
_addNodeDialog.ShowAtPosition(graphPosition, screenPosition);
}
private void OnConnectionToEmpty(StringName fromNode, long fromPort, Vector2 releasePosition)
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null)
{
return;
}
_pendingConnectionNode = fromNode;
_pendingConnectionPort = (int)fromPort;
_pendingConnectionIsOutput = true;
Vector2 graphPosition = (_graphEdit.ScrollOffset + releasePosition) / _graphEdit.Zoom;
var screenPosition = (Vector2I)(_graphEdit.GetScreenPosition() + releasePosition);
_addNodeDialog.ShowAtPosition(graphPosition, screenPosition);
}
private void OnConnectionFromEmpty(StringName toNode, long toPort, Vector2 releasePosition)
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null)
{
return;
}
_pendingConnectionNode = toNode;
_pendingConnectionPort = (int)toPort;
_pendingConnectionIsOutput = false;
Vector2 graphPosition = (_graphEdit.ScrollOffset + releasePosition) / _graphEdit.Zoom;
var screenPosition = (Vector2I)(_graphEdit.GetScreenPosition() + releasePosition);
_addNodeDialog.ShowAtPosition(graphPosition, screenPosition);
}
private void OnDialogNodeCreationRequested(
StatescriptNodeDiscovery.NodeTypeInfo? typeInfo,
StatescriptNodeType nodeType,
Vector2 position)
{
string newNodeId;
if (typeInfo is not null)
{
newNodeId = AddNodeAtPosition(nodeType, typeInfo.DisplayName, typeInfo.RuntimeTypeName, position);
}
else
{
newNodeId = AddNodeAtPosition(StatescriptNodeType.Exit, "Exit", string.Empty, position);
}
if (_pendingConnectionNode is not null && _graphEdit is not null)
{
if (_pendingConnectionIsOutput)
{
var inputPort = FindFirstEnabledInputPort(newNodeId);
if (inputPort >= 0)
{
OnConnectionRequest(
_pendingConnectionNode,
_pendingConnectionPort,
newNodeId,
inputPort);
}
}
else
{
var outputPort = FindFirstEnabledOutputPort(newNodeId);
if (outputPort >= 0)
{
OnConnectionRequest(
newNodeId,
outputPort,
_pendingConnectionNode,
_pendingConnectionPort);
}
}
ClearPendingConnection();
}
}
private void OnDialogCanceled()
{
ClearPendingConnection();
}
private void ClearPendingConnection()
{
_pendingConnectionNode = null;
_pendingConnectionPort = 0;
_pendingConnectionIsOutput = false;
}
private void OnAddNodeButtonPressed()
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null || _addNodeButton is null)
{
return;
}
ClearPendingConnection();
var screenPosition = (Vector2I)(_addNodeButton.GetScreenPosition() + new Vector2(0, _addNodeButton.Size.Y));
Vector2 centerPosition = (_graphEdit.ScrollOffset + (_graphEdit.Size / 2)) / _graphEdit.Zoom;
_addNodeDialog.ShowAtPosition(centerPosition, screenPosition);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://drxix8xbwpfin

View File

@@ -0,0 +1,493 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System.Collections.Generic;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
using GodotCollections = Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphEditorDock
{
private static bool WouldCreateLoop(
StatescriptGraph graphResource,
string fromNodeId,
int fromPort,
string toNodeId,
int toPort)
{
var tempConnection = new StatescriptConnection
{
FromNode = fromNodeId,
OutputPort = fromPort,
ToNode = toNodeId,
InputPort = toPort,
};
graphResource.Connections.Add(tempConnection);
try
{
StatescriptGraphBuilder.Build(graphResource);
}
catch (ValidationException)
{
return true;
}
finally
{
graphResource.Connections.Remove(tempConnection);
}
return false;
}
private void OnConnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
if (WouldCreateLoop(graph, fromNode.ToString(), (int)fromPort, toNode.ToString(), (int)toPort))
{
ShowLoopWarningDialog();
return;
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Connect Statescript Nodes", customContext: graph);
_undoRedo.AddDoMethod(
this,
MethodName.DoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.AddUndoMethod(
this,
MethodName.UndoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.CommitAction();
}
else
{
DoConnect(fromNode.ToString(), (int)fromPort, toNode.ToString(), (int)toPort);
}
}
private void DoConnect(string fromNode, int fromPort, string toNode, int toPort)
{
_graphEdit?.ConnectNode(fromNode, fromPort, toNode, toPort);
SyncConnectionsToCurrentGraph();
}
private void UndoConnect(string fromNode, int fromPort, string toNode, int toPort)
{
_graphEdit?.DisconnectNode(fromNode, fromPort, toNode, toPort);
SyncConnectionsToCurrentGraph();
}
private void OnDisconnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Disconnect Statescript Nodes", customContext: graph);
_undoRedo.AddDoMethod(
this,
MethodName.UndoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.AddUndoMethod(
this,
MethodName.DoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.CommitAction();
}
else
{
_graphEdit.DisconnectNode(fromNode, (int)fromPort, toNode, (int)toPort);
SyncConnectionsToCurrentGraph();
}
}
private void OnDeleteNodesRequest(GodotCollections.Array<StringName> deletedNodes)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
foreach (StringName nodeName in deletedNodes)
{
Node? child = _graphEdit.GetNodeOrNull(nodeName.ToString());
if (child is not StatescriptGraphNode graphNode)
{
continue;
}
if (graphNode.NodeResource?.NodeType == StatescriptNodeType.Entry)
{
GD.PushWarning("Cannot delete the Entry statescriptNode.");
continue;
}
if (graphNode.NodeResource is null)
{
continue;
}
var affectedConnections = new List<StatescriptConnection>();
foreach (GodotCollections.Dictionary connection in _graphEdit.GetConnectionList())
{
StringName from = connection["from_node"].AsStringName();
StringName to = connection["to_node"].AsStringName();
if (from == nodeName || to == nodeName)
{
affectedConnections.Add(new StatescriptConnection
{
FromNode = connection["from_node"].AsString(),
OutputPort = connection["from_port"].AsInt32(),
ToNode = connection["to_node"].AsString(),
InputPort = connection["to_port"].AsInt32(),
});
}
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Delete Statescript Node", customContext: graph);
_undoRedo.AddDoMethod(
this,
MethodName.DoDeleteNode,
graph,
graphNode.NodeResource,
new GodotCollections.Array<StatescriptConnection>(affectedConnections));
_undoRedo.AddUndoMethod(
this,
MethodName.UndoDeleteNode,
graph,
graphNode.NodeResource,
new GodotCollections.Array<StatescriptConnection>(affectedConnections));
_undoRedo.CommitAction();
}
else
{
DoDeleteNode(
graph,
graphNode.NodeResource,
[.. affectedConnections]);
}
}
}
private void DoDeleteNode(
StatescriptGraph graph,
StatescriptNode nodeResource,
GodotCollections.Array<StatescriptConnection> affectedConnections)
{
if (_graphEdit is not null && CurrentGraph == graph)
{
foreach (StatescriptConnection connection in affectedConnections)
{
_graphEdit.DisconnectNode(
connection.FromNode,
connection.OutputPort,
connection.ToNode,
connection.InputPort);
}
Node? child = _graphEdit.GetNodeOrNull(nodeResource.NodeId);
child?.QueueFree();
}
graph.Nodes.Remove(nodeResource);
SyncConnectionsToCurrentGraph();
}
private void UndoDeleteNode(
StatescriptGraph graph,
StatescriptNode nodeResource,
GodotCollections.Array<StatescriptConnection> affectedConnections)
{
graph.Nodes.Add(nodeResource);
graph.Connections.AddRange(affectedConnections);
if (CurrentGraph == graph)
{
LoadGraphIntoEditor(graph);
}
}
private void OnBeginNodeMove()
{
if (_graphEdit is null)
{
return;
}
_preMovePositions.Clear();
foreach (Node child in _graphEdit.GetChildren())
{
if (child is StatescriptGraphNode { Selected: true } sgn)
{
_preMovePositions[sgn.Name] = sgn.PositionOffset;
}
}
}
private void OnEndNodeMove()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null || _preMovePositions.Count == 0)
{
return;
}
var movedNodes = new GodotCollections.Dictionary<StringName, Vector2>();
var oldPositions = new GodotCollections.Dictionary<StringName, Vector2>();
foreach (Node child in _graphEdit.GetChildren())
{
if (child is not StatescriptGraphNode sgn || !_preMovePositions.TryGetValue(sgn.Name, out Vector2 oldPos))
{
continue;
}
Vector2 newPos = sgn.PositionOffset;
if (oldPos != newPos)
{
movedNodes[sgn.Name] = newPos;
oldPositions[sgn.Name] = oldPos;
}
}
_preMovePositions.Clear();
if (movedNodes.Count == 0)
{
return;
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Move Statescript Node(s)", customContext: graph);
_undoRedo.AddDoMethod(this, MethodName.DoMoveNodes, graph, movedNodes);
_undoRedo.AddUndoMethod(this, MethodName.DoMoveNodes, graph, oldPositions);
_undoRedo.CommitAction(false);
}
SyncNodePositionsToResource(graph, movedNodes);
}
private void DoMoveNodes(
StatescriptGraph graph,
GodotCollections.Dictionary<StringName, Vector2> positions)
{
foreach (StatescriptNode node in graph.Nodes)
{
if (positions.TryGetValue(node.NodeId, out Vector2 pos))
{
node.PositionOffset = pos;
}
}
if (CurrentGraph == graph && _graphEdit is not null)
{
foreach (Node child in _graphEdit.GetChildren())
{
if (child is StatescriptGraphNode sgn && positions.TryGetValue(sgn.Name, out Vector2 pos))
{
sgn.PositionOffset = pos;
}
}
}
}
private string AddNodeAtPosition(
StatescriptNodeType nodeType,
string title,
string runtimeTypeName,
Vector2 position)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return string.Empty;
}
var nodeId = $"node_{_nextNodeId++}";
var nodeResource = new StatescriptNode
{
NodeId = nodeId,
Title = title,
NodeType = nodeType,
RuntimeTypeName = runtimeTypeName,
PositionOffset = position,
};
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Add Statescript Node", customContext: graph);
_undoRedo.AddDoMethod(this, MethodName.DoAddNode, graph, nodeResource);
_undoRedo.AddUndoMethod(this, MethodName.UndoAddNode, graph, nodeResource);
_undoRedo.CommitAction();
}
else
{
DoAddNode(graph, nodeResource);
}
return nodeId;
}
private void DoAddNode(StatescriptGraph graph, StatescriptNode nodeResource)
{
graph.Nodes.Add(nodeResource);
if (CurrentGraph == graph && _graphEdit is not null)
{
var graphNode = new StatescriptGraphNode();
_graphEdit.AddChild(graphNode);
graphNode.Initialize(nodeResource, graph);
graphNode.SetUndoRedo(_undoRedo);
}
}
private void UndoAddNode(StatescriptGraph graph, StatescriptNode nodeResource)
{
graph.Nodes.Remove(nodeResource);
if (CurrentGraph == graph)
{
LoadGraphIntoEditor(graph);
}
}
private void DuplicateSelectedNodes()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
var selectedNodes = new List<StatescriptGraphNode>();
foreach (Node child in _graphEdit.GetChildren())
{
if (child is StatescriptGraphNode { Selected: true } statescriptNode
&& statescriptNode.NodeResource is not null
&& statescriptNode.NodeResource.NodeType != StatescriptNodeType.Entry)
{
selectedNodes.Add(statescriptNode);
}
}
if (selectedNodes.Count == 0)
{
return;
}
foreach (StatescriptGraphNode sgn in selectedNodes)
{
sgn.Selected = false;
}
var duplicatedIds = new Dictionary<string, string>();
const float offset = 40f;
foreach (StatescriptGraphNode sgn in selectedNodes)
{
StatescriptNode original = sgn.NodeResource!;
var newNodeId = $"node_{_nextNodeId++}";
duplicatedIds[original.NodeId] = newNodeId;
var duplicated = new StatescriptNode
{
NodeId = newNodeId,
Title = original.Title,
NodeType = original.NodeType,
RuntimeTypeName = original.RuntimeTypeName,
PositionOffset = original.PositionOffset + new Vector2(offset, offset),
};
foreach (KeyValuePair<string, Variant> kvp in original.CustomData)
{
duplicated.CustomData[kvp.Key] = kvp.Value;
}
foreach (StatescriptNodeProperty binding in original.PropertyBindings)
{
var newBinding = new StatescriptNodeProperty
{
Direction = binding.Direction,
PropertyIndex = binding.PropertyIndex,
Resolver = binding.Resolver is not null
? (StatescriptResolverResource)binding.Resolver.Duplicate(true)
: null,
};
duplicated.PropertyBindings.Add(newBinding);
}
graph.Nodes.Add(duplicated);
var graphNode = new StatescriptGraphNode();
_graphEdit.AddChild(graphNode);
graphNode.Initialize(duplicated, graph);
graphNode.Selected = true;
}
foreach (StatescriptConnection connection in graph.Connections)
{
if (duplicatedIds.TryGetValue(connection.FromNode, out var newFrom)
&& duplicatedIds.TryGetValue(connection.ToNode, out var newTo))
{
_graphEdit.ConnectNode(newFrom, connection.OutputPort, newTo, connection.InputPort);
}
}
SyncConnectionsToCurrentGraph();
}
private void ShowLoopWarningDialog()
{
var dialog = new AcceptDialog
{
Title = "Connection Rejected",
DialogText = "This connection would create a loop in the graph, which is not allowed.",
Exclusive = true,
};
dialog.Confirmed += dialog.QueueFree;
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://c0pse6qnrsdg0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://1mt1aejs15yr

View File

@@ -0,0 +1,149 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphNode
{
private bool ReferencesVariable(string variableName)
{
if (NodeResource is null)
{
return false;
}
foreach (StatescriptNodeProperty binding in NodeResource.PropertyBindings)
{
if (binding.Resolver is VariableResolverResource varRes
&& varRes.VariableName == variableName)
{
return true;
}
}
return false;
}
private void ApplyHighlightBorder()
{
if (_isHighlighted)
{
if (GetThemeStylebox("panel") is not StyleBoxFlat baseStyle)
{
return;
}
var highlightStyle = (StyleBoxFlat)baseStyle.Duplicate();
highlightStyle.BorderColor = _highlightColor;
highlightStyle.BorderWidthTop = 2;
highlightStyle.BorderWidthBottom = 2;
highlightStyle.BorderWidthLeft = 2;
highlightStyle.BorderWidthRight = 2;
highlightStyle.BgColor = baseStyle.BgColor.Lerp(_highlightColor, 0.15f);
AddThemeStyleboxOverride("panel", highlightStyle);
AddThemeStyleboxOverride("panel_selected", highlightStyle);
}
else
{
RemoveThemeStyleboxOverride("panel");
RemoveThemeStyleboxOverride("panel_selected");
ApplyBottomPadding();
}
}
private void UpdateChildHighlights()
{
UpdateHighlightsRecursive(this);
}
private void UpdateHighlightsRecursive(Node parent)
{
foreach (Node child in parent.GetChildren())
{
if (child is OptionButton optionButton)
{
HighlightOptionButtonIfMatches(optionButton);
}
else if (child is Label label)
{
HighlightLabelIfMatches(label);
}
UpdateHighlightsRecursive(child);
}
}
private void HighlightOptionButtonIfMatches(OptionButton dropdown)
{
if (!dropdown.HasMeta("is_variable_dropdown"))
{
return;
}
if (string.IsNullOrEmpty(_highlightedVariableName))
{
dropdown.RemoveThemeStyleboxOverride("normal");
return;
}
var selectedIdx = dropdown.Selected;
if (selectedIdx < 0)
{
dropdown.RemoveThemeStyleboxOverride("normal");
return;
}
var selectedText = dropdown.GetItemText(selectedIdx);
if (selectedText == _highlightedVariableName)
{
if (dropdown.GetThemeStylebox("normal") is not StyleBoxFlat baseStyle)
{
return;
}
var highlightStyle = (StyleBoxFlat)baseStyle.Duplicate();
highlightStyle.BgColor = baseStyle.BgColor.Lerp(_highlightColor, 0.25f);
dropdown.AddThemeStyleboxOverride("normal", highlightStyle);
}
else
{
dropdown.RemoveThemeStyleboxOverride("normal");
}
}
private void HighlightLabelIfMatches(Label label)
{
if (string.IsNullOrEmpty(_highlightedVariableName))
{
if (label.HasMeta("is_highlight_colored"))
{
label.RemoveThemeColorOverride("font_color");
label.RemoveMeta("is_highlight_colored");
}
return;
}
if (label.Text == _highlightedVariableName)
{
label.AddThemeColorOverride("font_color", _highlightColor);
label.SetMeta("is_highlight_colored", true);
}
else if (label.HasMeta("is_highlight_colored"))
{
label.RemoveThemeColorOverride("font_color");
label.RemoveMeta("is_highlight_colored");
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dmp3vltauax62

View File

@@ -0,0 +1,221 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphNode
{
private void SetupNodeByType(StatescriptNodeType nodeType)
{
switch (nodeType)
{
case StatescriptNodeType.Entry:
SetupEntryNode();
break;
case StatescriptNodeType.Exit:
SetupExitNode();
break;
case StatescriptNodeType.Action:
SetupActionNode();
break;
case StatescriptNodeType.Condition:
SetupConditionNode();
break;
case StatescriptNodeType.State:
SetupStateNode();
break;
}
}
private void SetupEntryNode()
{
CustomMinimumSize = new Vector2(100, 0);
var label = new Label { Text = "Start" };
AddChild(label);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
ApplyTitleBarColor(_entryColor);
}
private void SetupExitNode()
{
CustomMinimumSize = new Vector2(100, 0);
var label = new Label { Text = "End" };
AddChild(label);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
ApplyTitleBarColor(_exitColor);
}
private void SetupActionNode()
{
var label = new Label { Text = "Execute" };
AddChild(label);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
ApplyTitleBarColor(_actionColor);
}
private void SetupConditionNode()
{
var hBox = new HBoxContainer();
hBox.AddThemeConstantOverride("separation", 16);
AddChild(hBox);
var inputLabel = new Label { Text = "Condition" };
hBox.AddChild(inputLabel);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
var trueLabel = new Label
{
Text = "True",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox.AddChild(trueLabel);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
var falseLabel = new Label
{
Text = "False",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(falseLabel);
SetSlotEnabledRight(1, true);
SetSlotColorRight(1, _eventColor);
ApplyTitleBarColor(_conditionColor);
}
private void SetupStateNode()
{
var hBox1 = new HBoxContainer();
hBox1.AddThemeConstantOverride("separation", 16);
AddChild(hBox1);
var inputLabel = new Label { Text = "Begin" };
hBox1.AddChild(inputLabel);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
var activateLabel = new Label
{
Text = "OnActivate",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox1.AddChild(activateLabel);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
var hBox2 = new HBoxContainer();
hBox2.AddThemeConstantOverride("separation", 16);
AddChild(hBox2);
var abortLabel = new Label { Text = "Abort" };
hBox2.AddChild(abortLabel);
SetSlotEnabledLeft(1, true);
SetSlotColorLeft(1, _eventColor);
var deactivateLabel = new Label
{
Text = "OnDeactivate",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox2.AddChild(deactivateLabel);
SetSlotEnabledRight(1, true);
SetSlotColorRight(1, _eventColor);
var abortOutputLabel = new Label
{
Text = "OnAbort",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(abortOutputLabel);
SetSlotEnabledRight(2, true);
SetSlotColorRight(2, _eventColor);
var subgraphLabel = new Label
{
Text = "Subgraph",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(subgraphLabel);
SetSlotEnabledRight(3, true);
SetSlotColorRight(3, _subgraphColor);
ApplyTitleBarColor(_stateColor);
}
private void ClearSlots()
{
foreach (Node child in GetChildren())
{
RemoveChild(child);
child.Free();
}
}
private void ApplyTitleBarColor(Color color)
{
var titleBarStyleBox = new StyleBoxFlat
{
BgColor = color,
ContentMarginLeft = 12,
ContentMarginRight = 12,
ContentMarginTop = 6,
ContentMarginBottom = 6,
CornerRadiusTopLeft = 4,
CornerRadiusTopRight = 4,
};
AddThemeStyleboxOverride("titlebar", titleBarStyleBox);
var selectedTitleBarStyleBox = (StyleBoxFlat)titleBarStyleBox.Duplicate();
selectedTitleBarStyleBox.BgColor = color.Lightened(0.2f);
AddThemeStyleboxOverride("titlebar_selected", selectedTitleBarStyleBox);
}
private void ApplyBottomPadding()
{
StyleBox? existing = GetThemeStylebox("panel");
if (existing is not null)
{
var panelStyle = (StyleBox)existing.Duplicate();
panelStyle.ContentMarginBottom = 10;
AddThemeStyleboxOverride("panel", panelStyle);
}
StyleBox? selectedExisting = GetThemeStylebox("panel_selected");
if (selectedExisting is not null)
{
var selectedPanelStyle = (StyleBox)selectedExisting.Duplicate();
selectedPanelStyle.ContentMarginBottom = 10;
AddThemeStyleboxOverride("panel_selected", selectedPanelStyle);
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://civ3te4ediqxn

View File

@@ -0,0 +1,380 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphNode
{
private readonly Dictionary<PropertySlotKey, InputPropertyContext> _inputPropertyContexts = [];
private void AddInputPropertyRow(
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
int index,
Control sectionContainer)
{
if (NodeResource is null)
{
return;
}
var container = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
sectionContainer.AddChild(container);
var headerRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
container.AddChild(headerRow);
var nameLabel = new Label
{
Text = propInfo.Label,
CustomMinimumSize = new Vector2(60, 0),
};
nameLabel.AddThemeColorOverride("font_color", _inputPropertyColor);
headerRow.AddChild(nameLabel);
List<Func<NodeEditorProperty>> resolverFactories =
StatescriptResolverRegistry.GetCompatibleFactories(propInfo.ExpectedType);
if (resolverFactories.Count == 0)
{
var errorLabel = new Label
{
Text = "No compatible resolvers.",
};
errorLabel.AddThemeColorOverride("font_color", Colors.Red);
headerRow.AddChild(errorLabel);
return;
}
var resolverDropdown = new OptionButton
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(80, 0),
};
foreach (Func<NodeEditorProperty> factory in resolverFactories)
{
using NodeEditorProperty temp = factory();
resolverDropdown.AddItem(temp.DisplayName);
}
StatescriptNodeProperty? binding = FindBinding(StatescriptPropertyDirection.Input, index);
var selectedIndex = 0;
if (binding?.Resolver is not null)
{
for (var i = 0; i < resolverFactories.Count; i++)
{
using NodeEditorProperty temp = resolverFactories[i]();
if (temp.ResolverTypeId == GetResolverTypeId(binding.Resolver))
{
selectedIndex = i;
break;
}
}
}
else
{
for (var i = 0; i < resolverFactories.Count; i++)
{
using NodeEditorProperty temp = resolverFactories[i]();
if (temp.ResolverTypeId == "Variant")
{
selectedIndex = i;
break;
}
}
}
resolverDropdown.Selected = selectedIndex;
headerRow.AddChild(resolverDropdown);
var editorContainer = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
container.AddChild(editorContainer);
var key = new PropertySlotKey(StatescriptPropertyDirection.Input, index);
_inputPropertyContexts[key] = new InputPropertyContext(resolverFactories, propInfo, editorContainer);
ShowResolverEditorUI(
resolverFactories[selectedIndex],
binding,
propInfo.ExpectedType,
editorContainer,
StatescriptPropertyDirection.Input,
index,
propInfo.IsArray);
var capturedIndex = index;
resolverDropdown.ItemSelected += selectedItem => OnInputResolverDropdownItemSelected(selectedItem, capturedIndex);
}
private void OnInputResolverDropdownItemSelected(long x, int index)
{
var key = new PropertySlotKey(StatescriptPropertyDirection.Input, index);
if (!_inputPropertyContexts.TryGetValue(key, out InputPropertyContext? ctx))
{
return;
}
var oldResolver = FindBinding(StatescriptPropertyDirection.Input, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
if (_activeResolverEditors.TryGetValue(key, out NodeEditorProperty? old))
{
_activeResolverEditors.Remove(key);
}
ClearContainer(ctx.EditorContainer);
if (NodeResource is null)
{
return;
}
ShowResolverEditorUI(
ctx.ResolverFactories[(int)x],
null,
ctx.PropInfo.ExpectedType,
ctx.EditorContainer,
StatescriptPropertyDirection.Input,
index,
ctx.PropInfo.IsArray);
if (_activeResolverEditors.TryGetValue(key, out NodeEditorProperty? editor))
{
SaveResolverEditor(editor, StatescriptPropertyDirection.Input, index);
}
StatescriptNodeProperty? updated = FindBinding(StatescriptPropertyDirection.Input, index);
var newResolver = updated?.Resolver?.Duplicate() as StatescriptResolverResource;
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Change Resolver Type", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Input,
index,
newResolver ?? new StatescriptResolverResource());
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Input,
index,
oldResolver ?? new StatescriptResolverResource());
_undoRedo.CommitAction(false);
}
PropertyBindingChanged?.Invoke();
ResetSize();
}
private void AddOutputVariableRow(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
FoldableContainer sectionContainer)
{
if (NodeResource is null || _graph is null)
{
return;
}
var hBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
sectionContainer.AddChild(hBox);
var nameLabel = new Label
{
Text = varInfo.Label,
CustomMinimumSize = new Vector2(60, 0),
};
nameLabel.AddThemeColorOverride("font_color", _outputVariableColor);
hBox.AddChild(nameLabel);
var variableDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
variableDropdown.SetMeta("is_variable_dropdown", true);
variableDropdown.SetMeta("output_index", index);
foreach (StatescriptGraphVariable v in _graph.Variables)
{
variableDropdown.AddItem(v.VariableName);
}
StatescriptNodeProperty? binding = FindBinding(StatescriptPropertyDirection.Output, index);
var selectedIndex = 0;
if (binding?.Resolver is VariableResolverResource varRes
&& !string.IsNullOrEmpty(varRes.VariableName))
{
for (var i = 0; i < _graph.Variables.Count; i++)
{
if (_graph.Variables[i].VariableName == varRes.VariableName)
{
selectedIndex = i;
break;
}
}
}
if (_graph.Variables.Count > 0)
{
variableDropdown.Selected = selectedIndex;
if (binding is null)
{
var variableName = _graph.Variables[selectedIndex].VariableName;
EnsureBinding(StatescriptPropertyDirection.Output, index).Resolver =
new VariableResolverResource { VariableName = variableName };
}
}
var capturedIndex = index;
variableDropdown.ItemSelected += selectedItem => OnOutputVariableDropdownItemSelected(selectedItem, capturedIndex);
hBox.AddChild(variableDropdown);
}
private void OnOutputVariableDropdownItemSelected(long x, int index)
{
if (NodeResource is null || _graph is null)
{
return;
}
var oldResolver = FindBinding(StatescriptPropertyDirection.Output, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var variableName = _graph.Variables[(int)x].VariableName;
var newResolver = new VariableResolverResource { VariableName = variableName };
EnsureBinding(StatescriptPropertyDirection.Output, index).Resolver = newResolver;
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Change Output Variable", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Output,
index,
(StatescriptResolverResource)newResolver.Duplicate());
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Output,
index,
oldResolver ?? new StatescriptResolverResource());
_undoRedo.CommitAction(false);
}
PropertyBindingChanged?.Invoke();
}
private void ShowResolverEditorUI(
Func<NodeEditorProperty> factory,
StatescriptNodeProperty? existingBinding,
Type expectedType,
VBoxContainer container,
StatescriptPropertyDirection direction,
int propertyIndex,
bool isArray = false)
{
if (_graph is null)
{
return;
}
NodeEditorProperty resolverEditor = factory();
var key = new PropertySlotKey(direction, propertyIndex);
resolverEditor.Setup(
_graph,
existingBinding,
expectedType,
() => SaveResolverEditorWithUndo(resolverEditor, direction, propertyIndex),
isArray);
resolverEditor.LayoutSizeChanged += ResetSize;
container.AddChild(resolverEditor);
_activeResolverEditors[key] = resolverEditor;
}
private void SaveResolverEditorWithUndo(
NodeEditorProperty resolverEditor,
StatescriptPropertyDirection direction,
int propertyIndex)
{
if (NodeResource is null)
{
return;
}
StatescriptNodeProperty? existing = FindBinding(direction, propertyIndex);
var oldResolver = existing?.Resolver?.Duplicate() as StatescriptResolverResource;
SaveResolverEditor(resolverEditor, direction, propertyIndex);
StatescriptNodeProperty? updated = FindBinding(direction, propertyIndex);
var newResolver = updated?.Resolver?.Duplicate() as StatescriptResolverResource;
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Change Node Property", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
newResolver ?? new StatescriptResolverResource());
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
oldResolver ?? new StatescriptResolverResource());
_undoRedo.CommitAction(false);
}
PropertyBindingChanged?.Invoke();
}
private void SaveResolverEditor(
NodeEditorProperty resolverEditor,
StatescriptPropertyDirection direction,
int propertyIndex)
{
if (NodeResource is null)
{
return;
}
StatescriptNodeProperty binding = EnsureBinding(direction, propertyIndex);
resolverEditor.SaveTo(binding);
}
private sealed class InputPropertyContext(
List<Func<NodeEditorProperty>> resolverFactories,
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
VBoxContainer editorContainer)
{
public List<Func<NodeEditorProperty>> ResolverFactories { get; } = resolverFactories;
public StatescriptNodeDiscovery.InputPropertyInfo PropInfo { get; } = propInfo;
public VBoxContainer EditorContainer { get; } = editorContainer;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://b8iw3e8i3f0w8

View File

@@ -0,0 +1,628 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Visual GraphNode representation for a single Statescript node in the editor.
/// Supports both built-in node types (Entry/Exit) and dynamically discovered concrete types.
/// </summary>
[Tool]
public partial class StatescriptGraphNode : GraphNode, ISerializationListener
{
private const string FoldInputKey = "_fold_input";
private const string FoldOutputKey = "_fold_output";
private const string CustomWidthKey = "_custom_width";
private static readonly Color _entryColor = new(0x2a4a8dff);
private static readonly Color _exitColor = new(0x8a549aff);
private static readonly Color _actionColor = new(0x3a7856ff);
private static readonly Color _conditionColor = new(0x99811fff);
private static readonly Color _stateColor = new(0xa52c38ff);
private static readonly Color _eventColor = new(0xabb2bfff);
private static readonly Color _subgraphColor = new(0xc678ddff);
private static readonly Color _inputPropertyColor = new(0x61afefff);
private static readonly Color _outputVariableColor = new(0xe5c07bff);
private static readonly Color _highlightColor = new(0x56b6c2ff);
private readonly Dictionary<PropertySlotKey, NodeEditorProperty> _activeResolverEditors = [];
private readonly Dictionary<FoldableContainer, string> _foldableKeys = [];
private StatescriptNodeDiscovery.NodeTypeInfo? _typeInfo;
private StatescriptGraph? _graph;
private EditorUndoRedoManager? _undoRedo;
private CustomNodeEditor? _activeCustomEditor;
private bool _resizeConnected;
private float _widthBeforeResize;
private string? _highlightedVariableName;
private bool _isHighlighted;
/// <summary>
/// Raised when a property binding has been modified in the UI.
/// </summary>
public event Action? PropertyBindingChanged;
/// <summary>
/// Gets the underlying node resource.
/// </summary>
public StatescriptNode? NodeResource { get; private set; }
/// <summary>
/// Sets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <param name="undoRedo">The undo/redo manager from the editor plugin.</param>
public void SetUndoRedo(EditorUndoRedoManager? undoRedo)
{
_undoRedo = undoRedo;
}
/// <summary>
/// Gets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <returns>The undo/redo manager, or null if not set.</returns>
public EditorUndoRedoManager? GetUndoRedo()
{
return _undoRedo;
}
/// <summary>
/// Updates the highlight state based on the given variable name.
/// </summary>
/// <param name="variableName">The variable name to highlight, or null to clear.</param>
public void SetHighlightedVariable(string? variableName)
{
_highlightedVariableName = variableName;
_isHighlighted = !string.IsNullOrEmpty(variableName) && ReferencesVariable(variableName!);
ApplyHighlightBorder();
UpdateChildHighlights();
}
/// <summary>
/// Initializes this visual node from a resource, optionally within the context of a graph.
/// </summary>
/// <param name="resource">The node resource to display.</param>
/// <param name="graph">The owning graph resource (needed for variable dropdowns).</param>
public void Initialize(StatescriptNode resource, StatescriptGraph? graph = null)
{
NodeResource = resource;
_graph = graph;
_activeResolverEditors.Clear();
_foldableKeys.Clear();
Name = resource.NodeId;
Title = resource.Title;
PositionOffset = resource.PositionOffset;
CustomMinimumSize = new Vector2(240, 0);
Resizable = true;
RestoreCustomWidth();
if (!_resizeConnected)
{
_widthBeforeResize = CustomMinimumSize.X;
ResizeRequest += OnResizeRequest;
ResizeEnd += OnResizeEnd;
_resizeConnected = true;
}
ClearSlots();
if (resource.NodeType is StatescriptNodeType.Entry or StatescriptNodeType.Exit
|| string.IsNullOrEmpty(resource.RuntimeTypeName))
{
SetupNodeByType(resource.NodeType);
ApplyBottomPadding();
return;
}
_typeInfo = StatescriptNodeDiscovery.FindByRuntimeTypeName(resource.RuntimeTypeName);
if (_typeInfo is not null)
{
SetupFromTypeInfo(_typeInfo);
}
else
{
SetupNodeByType(resource.NodeType);
}
ApplyBottomPadding();
}
public void OnBeforeSerialize()
{
_inputPropertyContexts.Clear();
_foldableKeys.Clear();
_activeCustomEditor?.Unbind();
_activeCustomEditor = null;
foreach (KeyValuePair<PropertySlotKey, NodeEditorProperty> kvp in
_activeResolverEditors.Where(kvp => IsInstanceValid(kvp.Value)))
{
kvp.Value.ClearCallbacks();
}
_activeResolverEditors.Clear();
PropertyBindingChanged = null;
}
public void OnAfterDeserialize()
{
}
internal FoldableContainer AddPropertySectionDividerInternal(
string sectionTitle,
Color color,
string foldKey,
bool folded)
{
return AddPropertySectionDivider(sectionTitle, color, foldKey, folded);
}
internal void AddInputPropertyRowInternal(
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
int index,
Control container)
{
AddInputPropertyRow(propInfo, index, container);
}
internal void AddOutputVariableRowInternal(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
FoldableContainer container)
{
AddOutputVariableRow(varInfo, index, container);
}
internal bool GetFoldStateInternal(string key)
{
return GetFoldState(key);
}
internal StatescriptNodeProperty? FindBindingInternal(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return FindBinding(direction, propertyIndex);
}
internal StatescriptNodeProperty EnsureBindingInternal(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return EnsureBinding(direction, propertyIndex);
}
internal void RemoveBindingInternal(
StatescriptPropertyDirection direction,
int propertyIndex)
{
RemoveBinding(direction, propertyIndex);
}
internal void RecordResolverBindingChangeInternal(
StatescriptPropertyDirection direction,
int propertyIndex,
StatescriptResolverResource? oldResolver,
StatescriptResolverResource? newResolver,
string actionName)
{
if (_undoRedo is null)
{
return;
}
_undoRedo.CreateAction(actionName, customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
newResolver ?? new StatescriptResolverResource());
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
oldResolver ?? new StatescriptResolverResource());
_undoRedo.CommitAction(false);
}
internal void ShowResolverEditorUIInternal(
Func<NodeEditorProperty> factory,
StatescriptNodeProperty? existingBinding,
Type expectedType,
VBoxContainer container,
StatescriptPropertyDirection direction,
int propertyIndex,
bool isArray = false)
{
ShowResolverEditorUI(factory, existingBinding, expectedType, container, direction, propertyIndex, isArray);
}
internal void RaisePropertyBindingChangedInternal()
{
PropertyBindingChanged?.Invoke();
}
private static string GetResolverTypeId(StatescriptResolverResource resolver)
{
return resolver.ResolverTypeId;
}
private static void ClearContainer(Control container)
{
foreach (Node child in container.GetChildren())
{
container.RemoveChild(child);
child.Free();
}
}
private void SetupFromTypeInfo(StatescriptNodeDiscovery.NodeTypeInfo typeInfo)
{
var maxSlots = Math.Max(typeInfo.InputPortLabels.Length, typeInfo.OutputPortLabels.Length);
for (var slot = 0; slot < maxSlots; slot++)
{
var hBox = new HBoxContainer();
hBox.AddThemeConstantOverride("separation", 16);
AddChild(hBox);
if (slot < typeInfo.InputPortLabels.Length)
{
var inputLabel = new Label
{
Text = typeInfo.InputPortLabels[slot],
};
hBox.AddChild(inputLabel);
SetSlotEnabledLeft(slot, true);
SetSlotColorLeft(slot, _eventColor);
}
else
{
var spacer = new Control();
hBox.AddChild(spacer);
}
if (slot < typeInfo.OutputPortLabels.Length)
{
var outputLabel = new Label
{
Text = typeInfo.OutputPortLabels[slot],
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox.AddChild(outputLabel);
SetSlotEnabledRight(slot, true);
Color portColor = typeInfo.IsSubgraphPort[slot] ? _subgraphColor : _eventColor;
SetSlotColorRight(slot, portColor);
}
}
if (CustomNodeEditorRegistry.TryCreate(typeInfo.RuntimeTypeName, out CustomNodeEditor? customEditor))
{
Debug.Assert(_graph is not null, "Graph context is required for custom node editors.");
Debug.Assert(NodeResource is not null, "Node resource is required for custom node editors.");
_activeCustomEditor = customEditor;
customEditor.Bind(this, _graph, NodeResource, _activeResolverEditors);
customEditor.BuildPropertySections(typeInfo);
}
else
{
_activeCustomEditor = null;
BuildDefaultPropertySections(typeInfo);
}
Color titleColor = typeInfo.NodeType switch
{
StatescriptNodeType.Action => _actionColor,
StatescriptNodeType.Condition => _conditionColor,
StatescriptNodeType.State => _stateColor,
StatescriptNodeType.Entry => _entryColor,
StatescriptNodeType.Exit => _exitColor,
_ => _entryColor,
};
ApplyTitleBarColor(titleColor);
}
private void BuildDefaultPropertySections(StatescriptNodeDiscovery.NodeTypeInfo typeInfo)
{
if (typeInfo.InputPropertiesInfo.Length > 0)
{
var folded = GetFoldState(FoldInputKey);
FoldableContainer inputContainer = AddPropertySectionDivider(
"Input Properties",
_inputPropertyColor,
FoldInputKey,
folded);
for (var i = 0; i < typeInfo.InputPropertiesInfo.Length; i++)
{
AddInputPropertyRow(typeInfo.InputPropertiesInfo[i], i, inputContainer);
}
}
if (typeInfo.OutputVariablesInfo.Length > 0)
{
var folded = GetFoldState(FoldOutputKey);
FoldableContainer outputContainer = AddPropertySectionDivider(
"Output Variables",
_outputVariableColor,
FoldOutputKey,
folded);
for (var i = 0; i < typeInfo.OutputVariablesInfo.Length; i++)
{
AddOutputVariableRow(typeInfo.OutputVariablesInfo[i], i, outputContainer);
}
}
}
private FoldableContainer AddPropertySectionDivider(
string sectionTitle,
Color color,
string foldKey,
bool folded)
{
var divider = new HSeparator { CustomMinimumSize = new Vector2(0, 4) };
AddChild(divider);
var sectionContainer = new FoldableContainer
{
Title = sectionTitle,
Folded = folded,
};
sectionContainer.AddThemeColorOverride("font_color", color);
_foldableKeys[sectionContainer] = foldKey;
sectionContainer.FoldingChanged += OnSectionFoldingChanged;
AddChild(sectionContainer);
return sectionContainer;
}
private void OnSectionFoldingChanged(bool isFolded)
{
foreach (KeyValuePair<FoldableContainer, string> kvp in _foldableKeys.Where(kvp => IsInstanceValid(kvp.Key)))
{
var stored = GetFoldState(kvp.Value);
if (kvp.Key.Folded != stored)
{
SetFoldStateWithUndo(kvp.Value, kvp.Key.Folded);
}
}
ResetSize();
}
private bool GetFoldState(string key)
{
if (NodeResource is not null && NodeResource.CustomData.TryGetValue(key, out Variant value))
{
return value.AsBool();
}
return false;
}
private void SetFoldState(string key, bool folded)
{
if (NodeResource is null)
{
return;
}
NodeResource.CustomData[key] = Variant.From(folded);
}
private void SetFoldStateWithUndo(string key, bool folded)
{
if (NodeResource is null)
{
return;
}
var oldFolded = GetFoldState(key);
if (oldFolded == folded)
{
return;
}
SetFoldState(key, folded);
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Toggle Fold", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyFoldState,
key,
folded);
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyFoldState,
key,
oldFolded);
_undoRedo.CommitAction(false);
}
}
private void ApplyFoldState(string key, bool folded)
{
SetFoldState(key, folded);
RebuildNode();
}
private void OnResizeRequest(Vector2 newMinSize)
{
CustomMinimumSize = new Vector2(newMinSize.X, 0);
Size = new Vector2(newMinSize.X, 0);
SaveCustomWidth(newMinSize.X);
}
private void OnResizeEnd(Vector2 newSize)
{
var newWidth = CustomMinimumSize.X;
if (_undoRedo is not null && NodeResource is not null
&& !Mathf.IsEqualApprox(_widthBeforeResize, newWidth))
{
var oldWidth = _widthBeforeResize;
_undoRedo.CreateAction("Resize Node", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyCustomWidth,
newWidth);
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyCustomWidth,
oldWidth);
_undoRedo.CommitAction(false);
}
_widthBeforeResize = newWidth;
}
private void ApplyCustomWidth(float width)
{
CustomMinimumSize = new Vector2(width, 0);
Size = new Vector2(width, 0);
SaveCustomWidth(width);
}
private void RestoreCustomWidth()
{
if (NodeResource is not null
&& NodeResource.CustomData.TryGetValue(CustomWidthKey, out Variant value))
{
var width = (float)value.AsDouble();
if (width > 0)
{
CustomMinimumSize = new Vector2(width, 0);
}
}
}
private void SaveCustomWidth(float width)
{
if (NodeResource is null)
{
return;
}
NodeResource.CustomData[CustomWidthKey] = Variant.From(width);
}
private void ApplyResolverBinding(
int directionInt,
int propertyIndex,
StatescriptResolverResource resolver)
{
if (NodeResource is null)
{
return;
}
var direction = (StatescriptPropertyDirection)directionInt;
StatescriptNodeProperty binding = EnsureBinding(direction, propertyIndex);
binding.Resolver = resolver;
RebuildNode();
}
private void RebuildNode()
{
if (NodeResource is null)
{
return;
}
EditorUndoRedoManager? savedUndoRedo = _undoRedo;
Initialize(NodeResource, _graph);
_undoRedo = savedUndoRedo;
Size = new Vector2(Size.X, 0);
}
private StatescriptNodeProperty? FindBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
if (NodeResource is null)
{
return null;
}
foreach (StatescriptNodeProperty binding in NodeResource.PropertyBindings)
{
if (binding.Direction == direction && binding.PropertyIndex == propertyIndex)
{
return binding;
}
}
return null;
}
private StatescriptNodeProperty EnsureBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
StatescriptNodeProperty? binding = FindBinding(direction, propertyIndex);
if (binding is null)
{
binding = new StatescriptNodeProperty
{
Direction = direction,
PropertyIndex = propertyIndex,
};
NodeResource!.PropertyBindings.Add(binding);
}
return binding;
}
private void RemoveBinding(StatescriptPropertyDirection direction, int propertyIndex)
{
if (NodeResource is null)
{
return;
}
for (var i = NodeResource.PropertyBindings.Count - 1; i >= 0; i--)
{
StatescriptNodeProperty binding = NodeResource.PropertyBindings[i];
if (binding.Direction == direction && binding.PropertyIndex == propertyIndex)
{
NodeResource.PropertyBindings.RemoveAt(i);
}
}
}
}
/// <summary>
/// Identifies a property binding slot by direction and index.
/// </summary>
/// <param name="Direction">The direction of the property (input or output).</param>
/// <param name="PropertyIndex">The index of the property within its direction.</param>
internal readonly record struct PropertySlotKey(StatescriptPropertyDirection Direction, int PropertyIndex);
#endif

View File

@@ -0,0 +1 @@
uid://cgb5kncrbsgb4

View File

@@ -0,0 +1,540 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Statescript;
using Gamesmiths.Forge.Statescript.Nodes;
using Gamesmiths.Forge.Statescript.Ports;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Discovers concrete Statescript node types from loaded assemblies using reflection.
/// </summary>
/// <remarks>
/// Provides port layout information for the editor without requiring node instantiation.
/// </remarks>
internal static class StatescriptNodeDiscovery
{
private static List<NodeTypeInfo>? _cachedNodeTypes;
/// <summary>
/// Gets all discovered concrete node types. Results are cached after first discovery.
/// </summary>
/// <returns>A read-only list of node type info.</returns>
internal static IReadOnlyList<NodeTypeInfo> GetDiscoveredNodeTypes()
{
_cachedNodeTypes ??= DiscoverNodeTypes();
return _cachedNodeTypes;
}
/// <summary>
/// Clears the cached discovery results, forcing re-discovery on next access.
/// </summary>
internal static void InvalidateCache()
{
_cachedNodeTypes = null;
}
/// <summary>
/// Finds the <see cref="NodeTypeInfo"/> for the given runtime type name.
/// </summary>
/// <param name="runtimeTypeName">The full type name stored in the resource.</param>
/// <returns>The matching node type info, or null if not found.</returns>
internal static NodeTypeInfo? FindByRuntimeTypeName(string runtimeTypeName)
{
IReadOnlyList<NodeTypeInfo> types = GetDiscoveredNodeTypes();
for (var i = 0; i < types.Count; i++)
{
if (types[i].RuntimeTypeName == runtimeTypeName)
{
return types[i];
}
}
return null;
}
private static List<NodeTypeInfo> DiscoverNodeTypes()
{
var results = new List<NodeTypeInfo>();
Type actionNodeType = typeof(ActionNode);
Type conditionNodeType = typeof(ConditionNode);
Type stateNodeOpenType = typeof(StateNode<>);
// Scan all loaded assemblies for concrete node types.
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(x => x is not null).ToArray()!;
}
foreach (Type type in types)
{
if (type.IsAbstract || type.IsGenericTypeDefinition)
{
continue;
}
// Skip the built-in Entry/Exit nodes — they are handled separately.
if (type == typeof(EntryNode) || type == typeof(ExitNode))
{
continue;
}
if (actionNodeType.IsAssignableFrom(type))
{
results.Add(BuildNodeTypeInfo(type, StatescriptNodeType.Action));
}
else if (conditionNodeType.IsAssignableFrom(type))
{
results.Add(BuildNodeTypeInfo(type, StatescriptNodeType.Condition));
}
else if (IsConcreteStateNode(type, stateNodeOpenType))
{
results.Add(BuildNodeTypeInfo(type, StatescriptNodeType.State));
}
}
}
results.Sort((a, b) => string.CompareOrdinal(a.DisplayName, b.DisplayName));
return results;
}
private static bool IsConcreteStateNode(Type type, Type stateNodeOpenType)
{
Type? current = type.BaseType;
while (current is not null)
{
if (current.IsGenericType && current.GetGenericTypeDefinition() == stateNodeOpenType)
{
return true;
}
current = current.BaseType;
}
return false;
}
private static NodeTypeInfo BuildNodeTypeInfo(Type type, StatescriptNodeType nodeType)
{
var displayName = FormatDisplayName(type.Name);
var runtimeTypeName = type.FullName!;
// Get constructor parameter names.
var constructorParamNames = GetConstructorParameterNames(type);
// Determine ports and description by instantiating a temporary node.
string[] inputLabels;
string[] outputLabels;
bool[] isSubgraph;
string description;
InputPropertyInfo[] inputPropertiesInfo;
OutputVariableInfo[] outputVariablesInfo;
try
{
Node tempNode = CreateTemporaryNode(type);
inputLabels = GetInputPortLabels(tempNode, nodeType);
outputLabels = GetOutputPortLabels(tempNode, nodeType);
isSubgraph = GetSubgraphFlags(tempNode);
description = tempNode.Description;
inputPropertiesInfo = GetInputPropertiesInfo(tempNode);
outputVariablesInfo = GetOutputVariablesInfo(tempNode);
}
catch
{
// Fallback to default port layout based on base type.
PortLayout[] portLayouts = GetDefaultPortLayout(nodeType);
inputLabels = [.. portLayouts.Select(x => x.InputLabel)];
outputLabels = [.. portLayouts.Select(x => x.OutputLabel)];
isSubgraph = [.. portLayouts.Select(x => x.IsSubgraph)];
description = $"{displayName} node.";
inputPropertiesInfo = [];
outputVariablesInfo = [];
}
return new NodeTypeInfo(
displayName,
runtimeTypeName,
nodeType,
inputLabels,
outputLabels,
isSubgraph,
constructorParamNames,
description,
inputPropertiesInfo,
outputVariablesInfo);
}
private static Node CreateTemporaryNode(Type type)
{
// Try to find the primary constructor or the one with the fewest parameters.
ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
if (constructors.Length == 0)
{
return (Node)Activator.CreateInstance(type)!;
}
// Sort by parameter count, prefer the fewest.
ConstructorInfo constructor = constructors.OrderBy(x => x.GetParameters().Length).First();
ParameterInfo[] parameters = constructor.GetParameters();
var args = new object[parameters.Length];
for (var i = 0; i < parameters.Length; i++)
{
Type paramType = parameters[i].ParameterType;
if (paramType == typeof(Forge.Core.StringKey))
{
args[i] = new Forge.Core.StringKey("_placeholder_");
}
else if (paramType == typeof(string))
{
args[i] = string.Empty;
}
else if (paramType.IsValueType)
{
args[i] = Activator.CreateInstance(paramType)!;
}
else
{
args[i] = null!;
}
}
return (Node)constructor.Invoke(args);
}
private static string[] GetConstructorParameterNames(Type type)
{
ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
if (constructors.Length == 0)
{
return [];
}
// Use the constructor with the most parameters (primary constructor).
ConstructorInfo constructor = constructors.OrderByDescending(x => x.GetParameters().Length).First();
return [.. constructor.GetParameters().Select(x => x.Name ?? string.Empty)];
}
private static string[] GetInputPortLabels(Node node, StatescriptNodeType nodeType)
{
var count = node.InputPorts.Length;
var labels = new string[count];
switch (nodeType)
{
case StatescriptNodeType.Action:
if (count >= 1)
{
labels[0] = "Execute";
}
break;
case StatescriptNodeType.Condition:
if (count >= 1)
{
labels[0] = "Condition";
}
break;
case StatescriptNodeType.State:
if (count >= 1)
{
labels[0] = "Begin";
}
if (count >= 2)
{
labels[1] = "Abort";
}
for (var i = 2; i < count; i++)
{
labels[i] = $"Input {i}";
}
break;
default:
for (var i = 0; i < count; i++)
{
labels[i] = $"Input {i}";
}
break;
}
return labels;
}
private static string[] GetOutputPortLabels(Node node, StatescriptNodeType nodeType)
{
var count = node.OutputPorts.Length;
var labels = new string[count];
switch (nodeType)
{
case StatescriptNodeType.Action:
if (count >= 1)
{
labels[0] = "Done";
}
break;
case StatescriptNodeType.Condition:
if (count >= 1)
{
labels[0] = "True";
}
if (count >= 2)
{
labels[1] = "False";
}
break;
case StatescriptNodeType.State:
if (count >= 1)
{
labels[0] = "OnActivate";
}
if (count >= 2)
{
labels[1] = "OnDeactivate";
}
if (count >= 3)
{
labels[2] = "OnAbort";
}
if (count >= 4)
{
labels[3] = "Subgraph";
}
for (var i = 4; i < count; i++)
{
labels[i] = $"Event {i}";
}
break;
default:
for (var i = 0; i < count; i++)
{
labels[i] = $"Output {i}";
}
break;
}
return labels;
}
private static bool[] GetSubgraphFlags(Node node)
{
var count = node.OutputPorts.Length;
var flags = new bool[count];
for (var i = 0; i < count; i++)
{
flags[i] = node.OutputPorts[i] is SubgraphPort;
}
return flags;
}
private static InputPropertyInfo[] GetInputPropertiesInfo(Node node)
{
var propertiesInfo = new InputPropertyInfo[node.InputProperties.Length];
for (var i = 0; i < node.InputProperties.Length; i++)
{
propertiesInfo[i] = new InputPropertyInfo(
node.InputProperties[i].Label,
node.InputProperties[i].ExpectedType);
}
return propertiesInfo;
}
private static OutputVariableInfo[] GetOutputVariablesInfo(Node node)
{
var variablesInfo = new OutputVariableInfo[node.OutputVariables.Length];
for (var i = 0; i < node.OutputVariables.Length; i++)
{
variablesInfo[i] = new OutputVariableInfo(
node.OutputVariables[i].Label,
node.OutputVariables[i].ValueType,
node.OutputVariables[i].Scope);
}
return variablesInfo;
}
private static PortLayout[] GetDefaultPortLayout(
StatescriptNodeType nodeType)
{
return nodeType switch
{
StatescriptNodeType.Action => [new PortLayout("Execute", "Done", false)],
StatescriptNodeType.Condition => [
new PortLayout("Condition", "True", false),
new PortLayout(string.Empty, "False", false)],
StatescriptNodeType.State => [
new PortLayout("Begin", "OnActivate", false),
new PortLayout("Abort", "OnDeactivate", false),
new PortLayout(string.Empty, "OnAbort", false),
new PortLayout(string.Empty, "Subgraph", true)],
StatescriptNodeType.Entry => throw new NotImplementedException(),
StatescriptNodeType.Exit => throw new NotImplementedException(),
_ => [new PortLayout("Input", "Output", false)],
};
}
private static string FormatDisplayName(string typeName)
{
// Remove common suffixes.
if (typeName.EndsWith("Node", StringComparison.Ordinal))
{
typeName = typeName[..^4];
}
// Insert spaces before capital letters for camelCase names.
var result = new System.Text.StringBuilder();
for (var i = 0; i < typeName.Length; i++)
{
if (i > 0 && char.IsUpper(typeName[i]) && !char.IsUpper(typeName[i - 1]))
{
result.Append(' ');
}
result.Append(typeName[i]);
}
return result.ToString();
}
/// <summary>
/// Describes a discovered concrete node type and its port layout.
/// </summary>
internal sealed class NodeTypeInfo
{
/// <summary>
/// Gets the display name for this node type (e.g., "Timer", "Set Variable", "Expression").
/// </summary>
public string DisplayName { get; }
/// <summary>
/// Gets the CLR type name used for serialization (typically the type's full name).
/// </summary>
public string RuntimeTypeName { get; }
/// <summary>
/// Gets the node category (Action, Condition, State).
/// </summary>
public StatescriptNodeType NodeType { get; }
/// <summary>
/// Gets the input port labels for this node type.
/// </summary>
public string[] InputPortLabels { get; }
/// <summary>
/// Gets the output port labels for this node type.
/// </summary>
public string[] OutputPortLabels { get; }
/// <summary>
/// Gets whether each output port is a subgraph port.
/// </summary>
public bool[] IsSubgraphPort { get; }
/// <summary>
/// Gets the constructor parameter names for this node type.
/// </summary>
public string[] ConstructorParameterNames { get; }
/// <summary>
/// Gets a brief description for this node type, shown in the Add Node dialog.
/// Read from the <see cref="Node.Description"/> property at discovery time.
/// </summary>
public string Description { get; }
/// <summary>
/// Gets the input property declarations for this node type.
/// </summary>
public InputPropertyInfo[] InputPropertiesInfo { get; }
/// <summary>
/// Gets the output variable declarations for this node type.
/// </summary>
public OutputVariableInfo[] OutputVariablesInfo { get; }
public NodeTypeInfo(
string displayName,
string runtimeTypeName,
StatescriptNodeType nodeType,
string[] inputPortLabels,
string[] outputPortLabels,
bool[] isSubgraphPort,
string[] constructorParameterNames,
string description,
InputPropertyInfo[] inputPropertiesInfo,
OutputVariableInfo[] outputVariablesInfo)
{
DisplayName = displayName;
RuntimeTypeName = runtimeTypeName;
NodeType = nodeType;
InputPortLabels = inputPortLabels;
OutputPortLabels = outputPortLabels;
IsSubgraphPort = isSubgraphPort;
ConstructorParameterNames = constructorParameterNames;
Description = description;
InputPropertiesInfo = inputPropertiesInfo;
OutputVariablesInfo = outputVariablesInfo;
}
}
/// <summary>
/// Describes an input property declared by a node type.
/// </summary>
/// <param name="Label">The human-readable label for this input property.</param>
/// <param name="ExpectedType">The type the node expects to read.</param>
/// <param name="IsArray">Whether the input expects an array of values.</param>
internal readonly record struct InputPropertyInfo(string Label, Type ExpectedType, bool IsArray = false);
/// <summary>
/// Describes an output variable declared by a node type.
/// </summary>
/// <param name="Label">The human-readable label for this output variable.</param>
/// <param name="ValueType">The type the node writes.</param>
/// <param name="Scope">The default scope for this output variable.</param>
internal readonly record struct OutputVariableInfo(string Label, Type ValueType, VariableScope Scope);
private record struct PortLayout(string InputLabel, string OutputLabel, bool IsSubgraph);
}
#endif

View File

@@ -0,0 +1 @@
uid://cb2mf4xojoxal

View File

@@ -0,0 +1,54 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Registry of available <see cref="NodeEditorProperty"/> implementations. Resolver editors are discovered
/// automatically via reflection. Any concrete subclass of <see cref="NodeEditorProperty"/> in the executing assembly is
/// registered and becomes available in node input property dropdowns.
/// </summary>
internal static class StatescriptResolverRegistry
{
private static readonly List<Func<NodeEditorProperty>> _factories = [];
static StatescriptResolverRegistry()
{
Type[] allTypes = Assembly.GetExecutingAssembly().GetTypes();
foreach (Type type in allTypes.Where(
x => x.IsSubclassOf(typeof(NodeEditorProperty)) && !x.IsAbstract))
{
Type captured = type;
_factories.Add(() => (NodeEditorProperty)Activator.CreateInstance(captured)!);
}
}
/// <summary>
/// Gets factory functions for all resolver editors compatible with the given expected type.
/// </summary>
/// <param name="expectedType">The type expected by the node input property.</param>
/// <returns>A list of compatible resolver editor factories.</returns>
public static List<Func<NodeEditorProperty>> GetCompatibleFactories(Type expectedType)
{
var result = new List<Func<NodeEditorProperty>>();
foreach (Func<NodeEditorProperty> factory in _factories)
{
using NodeEditorProperty temp = factory();
if (temp.IsCompatibleWith(expectedType))
{
result.Add(factory);
}
}
return result;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://bq3g4cbysmedf

View File

@@ -0,0 +1,153 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
internal sealed partial class StatescriptVariablePanel
{
private static void SetArrayElementValue(StatescriptGraphVariable variable, int index, Variant newValue)
{
variable.InitialArrayValues[index] = newValue;
variable.EmitChanged();
}
private void SetVariableValue(StatescriptGraphVariable variable, Variant newValue)
{
Variant oldValue = variable.InitialValue;
variable.InitialValue = newValue;
variable.EmitChanged();
if (_undoRedo is not null)
{
_undoRedo.CreateAction(
$"Change Variable '{variable.VariableName}'",
customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyVariableValue,
variable,
newValue);
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyVariableValue,
variable,
oldValue);
_undoRedo.CommitAction(false);
}
}
private void ApplyVariableValue(StatescriptGraphVariable variable, Variant value)
{
variable.InitialValue = value;
variable.EmitChanged();
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
private void DoAddVariable(StatescriptGraph graph, StatescriptGraphVariable variable)
{
graph.Variables.Add(variable);
RebuildList();
VariablesChanged?.Invoke();
}
private void UndoAddVariable(StatescriptGraph graph, StatescriptGraphVariable variable)
{
graph.Variables.Remove(variable);
RebuildList();
VariablesChanged?.Invoke();
VariableUndoRedoPerformed?.Invoke();
}
private void DoRemoveVariable(StatescriptGraph graph, StatescriptGraphVariable variable, int index)
{
graph.Variables.RemoveAt(index);
ClearReferencesToVariable(variable.VariableName);
RebuildList();
VariablesChanged?.Invoke();
}
private void UndoRemoveVariable(StatescriptGraph graph, StatescriptGraphVariable variable, int index)
{
if (index >= graph.Variables.Count)
{
graph.Variables.Add(variable);
}
else
{
graph.Variables.Insert(index, variable);
}
RebuildList();
VariablesChanged?.Invoke();
VariableUndoRedoPerformed?.Invoke();
}
private void DoAddArrayElement(StatescriptGraphVariable variable, Variant value)
{
variable.InitialArrayValues.Add(value);
variable.EmitChanged();
_expandedArrays.Add(variable.VariableName);
SaveExpandedArrayState();
RebuildList();
}
private void UndoAddArrayElement(StatescriptGraphVariable variable)
{
if (variable.InitialArrayValues.Count > 0)
{
variable.InitialArrayValues.RemoveAt(variable.InitialArrayValues.Count - 1);
variable.EmitChanged();
}
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
private void DoRemoveArrayElement(StatescriptGraphVariable variable, int index)
{
variable.InitialArrayValues.RemoveAt(index);
variable.EmitChanged();
RebuildList();
}
private void UndoRemoveArrayElement(StatescriptGraphVariable variable, int index, Variant value)
{
if (index >= variable.InitialArrayValues.Count)
{
variable.InitialArrayValues.Add(value);
}
else
{
variable.InitialArrayValues.Insert(index, value);
}
variable.EmitChanged();
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
private void DoSetArrayExpanded(string variableName, bool expanded)
{
if (expanded)
{
_expandedArrays.Add(variableName);
}
else
{
_expandedArrays.Remove(variableName);
}
SaveExpandedArrayState();
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cbrse4fxsk87x

View File

@@ -0,0 +1,281 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
internal sealed partial class StatescriptVariablePanel
{
private Control CreateScalarValueEditor(StatescriptGraphVariable variable)
{
if (variable.VariableType == StatescriptVariableType.Bool)
{
var hBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
hBox.AddChild(StatescriptEditorControls.CreateBoolEditor(
variable.InitialValue.AsBool(),
x => SetVariableValue(variable, Variant.From(x))));
return hBox;
}
if (StatescriptEditorControls.IsIntegerType(variable.VariableType)
|| StatescriptEditorControls.IsFloatType(variable.VariableType))
{
var hBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
EditorSpinSlider spin = StatescriptEditorControls.CreateNumericSpinSlider(
variable.VariableType,
variable.InitialValue.AsDouble(),
onChanged: x =>
{
Variant newValue = StatescriptEditorControls.IsIntegerType(variable.VariableType)
? Variant.From((long)x)
: Variant.From(x);
SetVariableValue(variable, newValue);
});
hBox.AddChild(spin);
return hBox;
}
if (StatescriptEditorControls.IsVectorType(variable.VariableType))
{
return StatescriptEditorControls.CreateVectorEditor(
variable.VariableType,
x => StatescriptEditorControls.GetVectorComponent(
variable.InitialValue,
variable.VariableType,
x),
onChanged: x =>
{
Variant newValue = StatescriptEditorControls.BuildVectorVariant(
variable.VariableType,
x);
SetVariableValue(variable, newValue);
});
}
var fallback = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
fallback.AddChild(new Label { Text = variable.VariableType.ToString() });
return fallback;
}
private VBoxContainer CreateArrayValueEditor(StatescriptGraphVariable variable)
{
var vBox = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
var headerRow = new HBoxContainer();
vBox.AddChild(headerRow);
var isExpanded = _expandedArrays.Contains(variable.VariableName);
var elementsContainer = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
Visible = isExpanded,
};
var toggleButton = new Button
{
Text = $"Array (size {variable.InitialArrayValues.Count})",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
ToggleMode = true,
ButtonPressed = isExpanded,
};
toggleButton.Toggled += x =>
{
elementsContainer.Visible = x;
var wasExpanded = !x;
if (x)
{
_expandedArrays.Add(variable.VariableName);
}
else
{
_expandedArrays.Remove(variable.VariableName);
}
SaveExpandedArrayState();
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Toggle Array Expand", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.DoSetArrayExpanded,
variable.VariableName,
x);
_undoRedo.AddUndoMethod(
this,
MethodName.DoSetArrayExpanded,
variable.VariableName,
wasExpanded);
_undoRedo.CommitAction(false);
}
};
headerRow.AddChild(toggleButton);
var addElementButton = new Button
{
Icon = _addIcon,
Flat = true,
TooltipText = "Add Element",
CustomMinimumSize = new Vector2(24, 24),
};
addElementButton.Pressed += () =>
{
Variant defaultValue =
StatescriptVariableTypeConverter.CreateDefaultGodotVariant(variable.VariableType);
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Add Array Element", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.DoAddArrayElement,
variable,
defaultValue);
_undoRedo.AddUndoMethod(
this,
MethodName.UndoAddArrayElement,
variable);
_undoRedo.CommitAction();
}
else
{
DoAddArrayElement(variable, defaultValue);
}
};
headerRow.AddChild(addElementButton);
vBox.AddChild(elementsContainer);
for (var i = 0; i < variable.InitialArrayValues.Count; i++)
{
var capturedIndex = i;
if (variable.VariableType == StatescriptVariableType.Bool)
{
var elementRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementsContainer.AddChild(elementRow);
elementRow.AddChild(new Label { Text = $"[{i}]" });
elementRow.AddChild(StatescriptEditorControls.CreateBoolEditor(
variable.InitialArrayValues[i].AsBool(),
x => SetArrayElementValue(
variable,
capturedIndex,
Variant.From(x))));
AddArrayElementRemoveButton(elementRow, variable, capturedIndex);
}
else if (StatescriptEditorControls.IsVectorType(variable.VariableType))
{
var elementVBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementsContainer.AddChild(elementVBox);
var labelRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementVBox.AddChild(labelRow);
labelRow.AddChild(new Label
{
Text = $"[{i}]",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
});
AddArrayElementRemoveButton(labelRow, variable, capturedIndex);
VBoxContainer vectorEditor = StatescriptEditorControls.CreateVectorEditor(
variable.VariableType,
x => StatescriptEditorControls.GetVectorComponent(
variable.InitialArrayValues[capturedIndex],
variable.VariableType,
x),
x =>
{
Variant newValue = StatescriptEditorControls.BuildVectorVariant(
variable.VariableType,
x);
SetArrayElementValue(variable, capturedIndex, newValue);
});
elementVBox.AddChild(vectorEditor);
}
else
{
var elementRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementsContainer.AddChild(elementRow);
elementRow.AddChild(new Label { Text = $"[{i}]" });
EditorSpinSlider elementSpin = StatescriptEditorControls.CreateNumericSpinSlider(
variable.VariableType,
variable.InitialArrayValues[i].AsDouble(),
onChanged: x =>
{
Variant newValue = StatescriptEditorControls.IsIntegerType(variable.VariableType)
? Variant.From((long)x)
: Variant.From(x);
SetArrayElementValue(variable, capturedIndex, newValue);
});
elementRow.AddChild(elementSpin);
AddArrayElementRemoveButton(elementRow, variable, capturedIndex);
}
}
return vBox;
}
private void AddArrayElementRemoveButton(
HBoxContainer row,
StatescriptGraphVariable variable,
int elementIndex)
{
var removeElementButton = new Button
{
Icon = _removeIcon,
Flat = true,
CustomMinimumSize = new Vector2(24, 24),
};
removeElementButton.Pressed += () =>
{
if (_undoRedo is not null)
{
Variant removedValue = variable.InitialArrayValues[elementIndex];
_undoRedo.CreateAction("Remove Array Element", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.DoRemoveArrayElement,
variable,
elementIndex);
_undoRedo.AddUndoMethod(
this,
MethodName.UndoRemoveArrayElement,
variable,
elementIndex,
removedValue);
_undoRedo.CommitAction();
}
else
{
DoRemoveArrayElement(variable, elementIndex);
}
};
row.AddChild(removeElementButton);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://bhgni65dto1ul

View File

@@ -0,0 +1,529 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Right-side panel for editing graph variables. Variables are created with a name and type via a creation dialog.
/// Once created, only the initial value can be edited. To change name or type, delete and recreate the variable.
/// </summary>
[Tool]
internal sealed partial class StatescriptVariablePanel : VBoxContainer, ISerializationListener
{
private const string ExpandedArraysMetaKey = "_expanded_arrays";
private static readonly Color _variableColor = new(0xe5c07bff);
private static readonly Color _highlightColor = new(0x56b6c2ff);
private readonly HashSet<string> _expandedArrays = [];
private StatescriptGraph? _graph;
private VBoxContainer? _variableList;
private Button? _addButton;
private Window? _creationDialog;
private LineEdit? _newNameEdit;
private OptionButton? _newTypeDropdown;
private CheckBox? _newArrayToggle;
private Texture2D? _addIcon;
private Texture2D? _removeIcon;
private EditorUndoRedoManager? _undoRedo;
private string? _selectedVariableName;
/// <summary>
/// Raised when any variable is added, removed, or its value changes.
/// </summary>
public event Action? VariablesChanged;
/// <summary>
/// Raised when an undo/redo action modifies the variable panel, so the dock can auto-expand it.
/// </summary>
public event Action? VariableUndoRedoPerformed;
/// <summary>
/// Raised when the user selects or deselects a variable for highlighting.
/// </summary>
public event Action<string?>? VariableHighlightChanged;
public override void _Ready()
{
base._Ready();
_addIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Add", "EditorIcons");
_removeIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Remove", "EditorIcons");
SizeFlagsVertical = SizeFlags.ExpandFill;
CustomMinimumSize = new Vector2(360, 0);
var headerHBox = new HBoxContainer();
AddChild(headerHBox);
var titleLabel = new Label
{
Text = "Graph Variables",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
headerHBox.AddChild(titleLabel);
_addButton = new Button
{
Icon = _addIcon,
Flat = true,
TooltipText = "Add Variable",
CustomMinimumSize = new Vector2(28, 28),
};
_addButton.Pressed += OnAddPressed;
headerHBox.AddChild(_addButton);
var separator = new HSeparator();
AddChild(separator);
var scrollContainer = new ScrollContainer
{
SizeFlagsVertical = SizeFlags.ExpandFill,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(scrollContainer);
_variableList = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
scrollContainer.AddChild(_variableList);
}
public override void _ExitTree()
{
base._ExitTree();
if (_addButton is not null)
{
_addButton.Pressed -= OnAddPressed;
}
_creationDialog?.QueueFree();
_creationDialog = null;
_newNameEdit = null;
_newTypeDropdown = null;
_newArrayToggle = null;
}
public void OnBeforeSerialize()
{
if (_addButton is not null)
{
_addButton.Pressed -= OnAddPressed;
}
if (_variableList is not null)
{
foreach (Node child in _variableList.GetChildren())
{
_variableList.RemoveChild(child);
child.Free();
}
}
_creationDialog?.Free();
_creationDialog = null;
_newNameEdit = null;
_newTypeDropdown = null;
_newArrayToggle = null;
}
public void OnAfterDeserialize()
{
if (_addButton is not null)
{
_addButton.Pressed += OnAddPressed;
}
RebuildList();
}
/// <summary>
/// Sets the graph to display variables for.
/// </summary>
/// <param name="graph">The graph resource, or null to clear.</param>
public void SetGraph(StatescriptGraph? graph)
{
_graph = graph;
LoadExpandedArrayState();
RebuildList();
}
/// <summary>
/// Sets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <param name="undoRedo">The undo/redo manager from the editor plugin.</param>
public void SetUndoRedo(EditorUndoRedoManager undoRedo)
{
_undoRedo = undoRedo;
}
/// <summary>
/// Rebuilds the variable list UI from the current graph.
/// </summary>
public void RebuildList()
{
if (_variableList is null)
{
return;
}
foreach (Node child in _variableList.GetChildren())
{
_variableList.RemoveChild(child);
child.Free();
}
if (_graph is null)
{
return;
}
for (var i = 0; i < _graph.Variables.Count; i++)
{
AddVariableRow(_graph.Variables[i], i);
}
}
private void SaveExpandedArrayState()
{
if (_graph is null)
{
return;
}
var packed = new string[_expandedArrays.Count];
_expandedArrays.CopyTo(packed);
_graph.SetMeta(ExpandedArraysMetaKey, Variant.From(packed));
}
private void LoadExpandedArrayState()
{
_expandedArrays.Clear();
if (_graph?.HasMeta(ExpandedArraysMetaKey) != true)
{
return;
}
Variant meta = _graph.GetMeta(ExpandedArraysMetaKey);
if (meta.VariantType == Variant.Type.PackedStringArray)
{
foreach (var name in meta.AsStringArray())
{
_expandedArrays.Add(name);
}
}
}
private void AddVariableRow(StatescriptGraphVariable variable, int index)
{
if (_variableList is null)
{
return;
}
var rowContainer = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
_variableList.AddChild(rowContainer);
var headerRow = new HBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
rowContainer.AddChild(headerRow);
var isSelected = _selectedVariableName == variable.VariableName;
var nameButton = new Button
{
Text = variable.VariableName,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
Flat = true,
ToggleMode = true,
ButtonPressed = isSelected,
Alignment = HorizontalAlignment.Left,
};
Color buttonColor = isSelected ? _highlightColor : _variableColor;
nameButton.AddThemeColorOverride("font_color", buttonColor);
nameButton.AddThemeColorOverride("font_pressed_color", _highlightColor);
nameButton.AddThemeColorOverride("font_hover_color", buttonColor.Lightened(0.2f));
nameButton.AddThemeColorOverride("font_hover_pressed_color", _highlightColor.Lightened(0.2f));
nameButton.AddThemeFontOverride(
"font",
EditorInterface.Singleton.GetEditorTheme().GetFont("bold", "EditorFonts"));
nameButton.Toggled += pressed =>
{
if (pressed)
{
_selectedVariableName = variable.VariableName;
}
else if (_selectedVariableName == variable.VariableName)
{
_selectedVariableName = null;
}
RebuildList();
VariableHighlightChanged?.Invoke(_selectedVariableName);
};
headerRow.AddChild(nameButton);
var typeLabel = new Label
{
Text = $"({StatescriptVariableTypeConverter.GetDisplayName(variable.VariableType)}"
+ (variable.IsArray ? "[])" : ")"),
};
typeLabel.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.6f));
headerRow.AddChild(typeLabel);
var capturedIndex = index;
var deleteButton = new Button
{
Icon = _removeIcon,
Flat = true,
TooltipText = "Remove Variable",
CustomMinimumSize = new Vector2(28, 28),
};
deleteButton.Pressed += () => OnDeletePressed(capturedIndex);
headerRow.AddChild(deleteButton);
if (!variable.IsArray)
{
Control valueEditor = CreateScalarValueEditor(variable);
rowContainer.AddChild(valueEditor);
}
else
{
VBoxContainer arrayEditor = CreateArrayValueEditor(variable);
rowContainer.AddChild(arrayEditor);
}
rowContainer.AddChild(new HSeparator());
}
private void OnAddPressed()
{
if (_graph is null)
{
return;
}
ShowCreationDialog();
}
private void ShowCreationDialog()
{
_creationDialog?.QueueFree();
_creationDialog = new AcceptDialog
{
Title = "Add Variable",
Size = new Vector2I(300, 160),
Exclusive = true,
};
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
var nameRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(nameRow);
nameRow.AddChild(new Label { Text = "Name:", CustomMinimumSize = new Vector2(60, 0) });
_newNameEdit = new LineEdit
{
Text = GenerateUniqueName(),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
nameRow.AddChild(_newNameEdit);
var typeRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(typeRow);
typeRow.AddChild(new Label { Text = "Type:", CustomMinimumSize = new Vector2(60, 0) });
_newTypeDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
StatescriptVariableType[] allTypes = StatescriptVariableTypeConverter.GetAllTypes();
for (var t = 0; t < allTypes.Length; t++)
{
_newTypeDropdown.AddItem(StatescriptVariableTypeConverter.GetDisplayName(allTypes[t]), t);
}
_newTypeDropdown.Selected = (int)StatescriptVariableType.Int;
typeRow.AddChild(_newTypeDropdown);
var arrayRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(arrayRow);
arrayRow.AddChild(new Label { Text = "Array:", CustomMinimumSize = new Vector2(60, 0) });
_newArrayToggle = new CheckBox();
arrayRow.AddChild(_newArrayToggle);
_creationDialog.AddChild(vBox);
((AcceptDialog)_creationDialog).Confirmed += OnCreationConfirmed;
AddChild(_creationDialog);
_creationDialog.PopupCentered();
}
private void OnCreationConfirmed()
{
if (_graph is null || _newNameEdit is null || _newTypeDropdown is null || _newArrayToggle is null)
{
return;
}
var name = _newNameEdit.Text.Trim();
if (string.IsNullOrEmpty(name) || HasVariableNamed(name))
{
return;
}
var selectedIndex = _newTypeDropdown.Selected;
if (selectedIndex < 0)
{
return;
}
var selectedId = _newTypeDropdown.GetItemId(selectedIndex);
var varType = (StatescriptVariableType)selectedId;
var newVariable = new StatescriptGraphVariable
{
VariableName = name,
VariableType = varType,
IsArray = _newArrayToggle.ButtonPressed,
InitialValue = StatescriptVariableTypeConverter.CreateDefaultGodotVariant(varType),
};
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Add Graph Variable", customContext: _graph);
_undoRedo.AddDoMethod(this, MethodName.DoAddVariable, _graph!, newVariable);
_undoRedo.AddUndoMethod(this, MethodName.UndoAddVariable, _graph!, newVariable);
_undoRedo.CommitAction();
}
else
{
DoAddVariable(_graph, newVariable);
}
_creationDialog?.QueueFree();
_creationDialog = null;
_newNameEdit = null;
_newTypeDropdown = null;
_newArrayToggle = null;
}
private void OnDeletePressed(int index)
{
if (_graph is null || index < 0 || index >= _graph.Variables.Count)
{
return;
}
StatescriptGraphVariable variable = _graph.Variables[index];
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Remove Graph Variable", customContext: _graph);
_undoRedo.AddDoMethod(this, MethodName.DoRemoveVariable, _graph!, variable, index);
_undoRedo.AddUndoMethod(this, MethodName.UndoRemoveVariable, _graph!, variable, index);
_undoRedo.CommitAction();
}
else
{
DoRemoveVariable(_graph, variable, index);
}
}
private void ClearReferencesToVariable(string variableName)
{
if (_graph is null)
{
return;
}
foreach (StatescriptNode node in _graph.Nodes)
{
foreach (StatescriptNodeProperty binding in node.PropertyBindings)
{
if (binding.Resolver is VariableResolverResource varRes
&& varRes.VariableName == variableName)
{
varRes.VariableName = string.Empty;
}
}
}
}
private string GenerateUniqueName()
{
if (_graph is null)
{
return "variable";
}
const string baseName = "variable";
var counter = 1;
var name = baseName;
while (HasVariableNamed(name))
{
name = $"{baseName}_{counter++}";
}
return name;
}
private bool HasVariableNamed(string name)
{
if (_graph is null)
{
return false;
}
foreach (StatescriptGraphVariable variable in _graph.Variables)
{
if (variable.VariableName == name)
{
return true;
}
}
return false;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dax3ghnqv8jet

View File

@@ -0,0 +1,825 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using Gamesmiths.Forge.Godot.Resources;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.NodeEditors;
/// <summary>
/// Custom node editor for the <c>SetVariableNode</c>. Dynamically filters the Input (value resolver) based on the
/// selected target variable's type. Supports both Graph and Shared variable scopes.
/// </summary>
[Tool]
internal sealed partial class SetVariableNodeEditor : CustomNodeEditor
{
private const string FoldInputKey = "_fold_input";
private const string FoldOutputKey = "_fold_output";
private const string ScopeKey = "_output_scope";
private readonly List<string> _setPaths = [];
private readonly List<string> _variableNames = [];
private StatescriptVariableType? _resolvedType;
private bool _resolvedIsArray;
private StatescriptNodeDiscovery.NodeTypeInfo? _cachedTypeInfo;
private VBoxContainer? _cachedInputEditorContainer;
private VBoxContainer? _cachedTargetContainer;
private int _cachedOutputIndex;
private bool _isSharedScope;
private OptionButton? _setDropdown;
private OptionButton? _sharedVarDropdown;
private string _selectedSetPath = string.Empty;
private string _selectedSharedVarName = string.Empty;
private StatescriptVariableType _selectedSharedVarType = StatescriptVariableType.Int;
/// <inheritdoc/>
public override string HandledRuntimeTypeName => "Gamesmiths.Forge.Statescript.Nodes.Action.SetVariableNode";
/// <inheritdoc/>
public override void BuildPropertySections(StatescriptNodeDiscovery.NodeTypeInfo typeInfo)
{
_cachedTypeInfo = typeInfo;
var inputFolded = GetFoldState(FoldInputKey);
FoldableContainer inputContainer = AddPropertySectionDivider(
"Input Properties",
InputPropertyColor,
FoldInputKey,
inputFolded);
var inputEditorContainer = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
_cachedInputEditorContainer = inputEditorContainer;
inputContainer.AddChild(inputEditorContainer);
var outputFolded = GetFoldState(FoldOutputKey);
FoldableContainer outputContainer = AddPropertySectionDivider(
"Output Variables",
OutputVariableColor,
FoldOutputKey,
outputFolded);
_resolvedType = null;
_resolvedIsArray = false;
StatescriptNodeProperty? outputBinding = FindBinding(StatescriptPropertyDirection.Output, 0);
_isSharedScope = outputBinding?.Resolver is SharedVariableResolverResource;
if (outputBinding is null
&& NodeResource.CustomData.TryGetValue(ScopeKey, out Variant scopeValue))
{
_isSharedScope = scopeValue.AsInt32() == (int)VariableScope.Shared;
}
ResolveTypeFromBinding(outputBinding);
if (typeInfo.OutputVariablesInfo.Length > 0)
{
AddTargetVariableRow(
typeInfo.OutputVariablesInfo[0],
0,
outputContainer);
}
if (typeInfo.InputPropertiesInfo.Length > 0)
{
RebuildInputUI(typeInfo.InputPropertiesInfo[0], inputEditorContainer);
}
}
/// <inheritdoc/>
internal override void Unbind()
{
base.Unbind();
_cachedTypeInfo = null;
_cachedInputEditorContainer = null;
_cachedTargetContainer = null;
_setDropdown = null;
_sharedVarDropdown = null;
}
private static List<string> FindAllSharedVariableSetPaths()
{
var results = new List<string>();
EditorFileSystemDirectory root = EditorInterface.Singleton.GetResourceFilesystem().GetFilesystem();
ScanFilesystemDirectory(root, results);
return results;
}
private static void ScanFilesystemDirectory(EditorFileSystemDirectory dir, List<string> results)
{
for (var i = 0; i < dir.GetFileCount(); i++)
{
var path = dir.GetFilePath(i);
if (!path.EndsWith(".tres", StringComparison.InvariantCultureIgnoreCase)
&& !path.EndsWith(".res", StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
Resource resource = ResourceLoader.Load(path);
if (resource is ForgeSharedVariableSet)
{
results.Add(path);
}
}
for (var i = 0; i < dir.GetSubdirCount(); i++)
{
ScanFilesystemDirectory(dir.GetSubdir(i), results);
}
}
private void ResolveTypeFromBinding(StatescriptNodeProperty? outputBinding)
{
_resolvedType = null;
_resolvedIsArray = false;
if (outputBinding?.Resolver is VariableResolverResource varRes
&& !string.IsNullOrEmpty(varRes.VariableName))
{
foreach (StatescriptGraphVariable v in Graph.Variables)
{
if (v.VariableName == varRes.VariableName)
{
_resolvedType = v.VariableType;
_resolvedIsArray = v.IsArray;
return;
}
}
}
if (outputBinding?.Resolver is SharedVariableResolverResource sharedRes
&& !string.IsNullOrEmpty(sharedRes.VariableName))
{
_selectedSetPath = sharedRes.SharedVariableSetPath;
_selectedSharedVarName = sharedRes.VariableName;
_selectedSharedVarType = sharedRes.VariableType;
_resolvedType = sharedRes.VariableType;
_resolvedIsArray = false;
}
}
private void AddTargetVariableRow(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
FoldableContainer sectionContainer)
{
_cachedOutputIndex = index;
var outerVBox = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
sectionContainer.AddChild(outerVBox);
// Scope toggle row.
var scopeRow = new HBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
outerVBox.AddChild(scopeRow);
var scopeLabel = new Label
{
Text = "Scope",
CustomMinimumSize = new Vector2(60, 0),
};
scopeLabel.AddThemeColorOverride("font_color", OutputVariableColor);
scopeRow.AddChild(scopeLabel);
var graphButton = new CheckBox
{
Text = "Graph",
ButtonPressed = !_isSharedScope,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
var sharedButton = new CheckBox
{
Text = "Shared",
ButtonPressed = _isSharedScope,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
var buttonGroup = new ButtonGroup();
graphButton.ButtonGroup = buttonGroup;
sharedButton.ButtonGroup = buttonGroup;
scopeRow.AddChild(graphButton);
scopeRow.AddChild(sharedButton);
// Target variable container (rebuilt when scope changes).
var targetContainer = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
_cachedTargetContainer = targetContainer;
outerVBox.AddChild(targetContainer);
RebuildTargetUI(varInfo, index, targetContainer);
graphButton.Pressed += () => OnScopeChanged(false, varInfo, index);
sharedButton.Pressed += () => OnScopeChanged(true, varInfo, index);
}
private void OnScopeChanged(
bool isShared,
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index)
{
if (_isSharedScope == isShared)
{
return;
}
var oldResolver = FindBinding(StatescriptPropertyDirection.Output, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var oldInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
_isSharedScope = isShared;
NodeResource.CustomData[ScopeKey] = Variant.From(isShared ? (int)VariableScope.Shared : (int)VariableScope.Graph);
// Clear the output binding since scope changed.
RemoveBinding(StatescriptPropertyDirection.Output, index);
_resolvedType = null;
_resolvedIsArray = false;
// Reset shared variable state when switching away.
if (!isShared)
{
_selectedSetPath = string.Empty;
_selectedSharedVarName = string.Empty;
_selectedSharedVarType = StatescriptVariableType.Int;
}
if (_cachedTargetContainer is not null)
{
ClearContainer(_cachedTargetContainer);
RebuildTargetUI(varInfo, index, _cachedTargetContainer);
}
// Clear and rebuild input since type changed.
RemoveBinding(StatescriptPropertyDirection.Input, 0);
ActiveResolverEditors.Remove(new PropertySlotKey(StatescriptPropertyDirection.Input, 0));
if (_cachedTypeInfo is not null
&& _cachedInputEditorContainer is not null
&& _cachedTypeInfo.InputPropertiesInfo.Length > 0)
{
RebuildInputUI(_cachedTypeInfo.InputPropertiesInfo[0], _cachedInputEditorContainer);
}
var newResolver = FindBinding(StatescriptPropertyDirection.Output, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var newInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
RecordResolverBindingChange(
StatescriptPropertyDirection.Output,
index,
oldResolver,
newResolver,
"Change Variable Scope");
RecordResolverBindingChange(
StatescriptPropertyDirection.Input,
0,
oldInputResolver,
newInputResolver,
"Change Variable Scope Input");
RaisePropertyBindingChanged();
ResetSize();
}
private void RebuildTargetUI(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
VBoxContainer container)
{
if (_isSharedScope)
{
BuildSharedVariableUI(varInfo, container);
}
else
{
BuildGraphVariableUI(varInfo, index, container);
}
}
private void BuildGraphVariableUI(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
VBoxContainer container)
{
var hBox = new HBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
container.AddChild(hBox);
var nameLabel = new Label
{
Text = varInfo.Label,
CustomMinimumSize = new Vector2(60, 0),
};
nameLabel.AddThemeColorOverride("font_color", OutputVariableColor);
hBox.AddChild(nameLabel);
var dropdown = new OptionButton
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
dropdown.SetMeta("is_variable_dropdown", true);
dropdown.AddItem("(None)");
foreach (StatescriptGraphVariable v in Graph.Variables)
{
dropdown.AddItem(v.VariableName);
}
StatescriptNodeProperty? binding = FindBinding(StatescriptPropertyDirection.Output, index);
var selectedIndex = 0;
if (binding?.Resolver is VariableResolverResource varRes
&& !string.IsNullOrEmpty(varRes.VariableName))
{
for (var i = 0; i < Graph.Variables.Count; i++)
{
if (Graph.Variables[i].VariableName == varRes.VariableName)
{
selectedIndex = i + 1;
break;
}
}
}
dropdown.Selected = selectedIndex;
if (selectedIndex == 0)
{
RemoveBinding(StatescriptPropertyDirection.Output, index);
}
dropdown.ItemSelected += OnTargetVariableDropdownItemSelected;
hBox.AddChild(dropdown);
}
private void BuildSharedVariableUI(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
VBoxContainer container)
{
var nameLabel = new Label
{
Text = varInfo.Label,
CustomMinimumSize = new Vector2(60, 0),
};
nameLabel.AddThemeColorOverride("font_color", OutputVariableColor);
container.AddChild(nameLabel);
// Set dropdown row.
var setRow = new HBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
container.AddChild(setRow);
setRow.AddChild(new Label
{
Text = "Set:",
CustomMinimumSize = new Vector2(60, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_setDropdown = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
PopulateSetDropdown();
setRow.AddChild(_setDropdown);
// Variable dropdown row.
var varRow = new HBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
container.AddChild(varRow);
varRow.AddChild(new Label
{
Text = "Var:",
CustomMinimumSize = new Vector2(60, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_sharedVarDropdown = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
PopulateSharedVariableDropdown();
varRow.AddChild(_sharedVarDropdown);
_setDropdown.ItemSelected += OnSharedSetDropdownItemSelected;
_sharedVarDropdown.ItemSelected += OnSharedVariableDropdownItemSelected;
}
private void PopulateSetDropdown()
{
if (_setDropdown is null)
{
return;
}
_setDropdown.Clear();
_setPaths.Clear();
_setDropdown.AddItem("(None)");
_setPaths.Add(string.Empty);
foreach (var path in FindAllSharedVariableSetPaths())
{
var displayName = path[(path.LastIndexOf('/') + 1)..];
if (displayName.EndsWith(".tres", StringComparison.OrdinalIgnoreCase))
{
displayName = displayName[..^5];
}
_setDropdown.AddItem(displayName);
_setPaths.Add(path);
}
// Restore selection.
for (var i = 0; i < _setPaths.Count; i++)
{
if (_setPaths[i] == _selectedSetPath)
{
_setDropdown.Selected = i;
return;
}
}
_setDropdown.Selected = 0;
_selectedSetPath = string.Empty;
}
private void PopulateSharedVariableDropdown()
{
if (_sharedVarDropdown is null)
{
return;
}
_sharedVarDropdown.Clear();
_variableNames.Clear();
_sharedVarDropdown.AddItem("(None)");
_variableNames.Add(string.Empty);
if (!string.IsNullOrEmpty(_selectedSetPath) && ResourceLoader.Exists(_selectedSetPath))
{
ForgeSharedVariableSet? set = ResourceLoader.Load<ForgeSharedVariableSet>(_selectedSetPath);
if (set is not null)
{
foreach (var variableName in set.Variables.Select(x => x.VariableName))
{
if (string.IsNullOrEmpty(variableName))
{
continue;
}
_sharedVarDropdown.AddItem(variableName);
_variableNames.Add(variableName);
}
}
}
// Restore selection.
for (var i = 0; i < _variableNames.Count; i++)
{
if (_variableNames[i] == _selectedSharedVarName)
{
_sharedVarDropdown.Selected = i;
return;
}
}
_sharedVarDropdown.Selected = 0;
_selectedSharedVarName = string.Empty;
}
private void OnSharedSetDropdownItemSelected(long x)
{
if (_setDropdown is null)
{
return;
}
var idx = _setDropdown.Selected;
var oldResolver = FindBinding(StatescriptPropertyDirection.Output, _cachedOutputIndex)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var oldInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
_selectedSetPath = idx >= 0 && idx < _setPaths.Count ? _setPaths[idx] : string.Empty;
_selectedSharedVarName = string.Empty;
_selectedSharedVarType = StatescriptVariableType.Int;
PopulateSharedVariableDropdown();
UpdateSharedOutputBinding();
var newResolver = FindBinding(StatescriptPropertyDirection.Output, _cachedOutputIndex)?.Resolver?.Duplicate()
as StatescriptResolverResource;
StatescriptVariableType? previousType = _resolvedType;
_resolvedType = null;
_resolvedIsArray = false;
if (previousType != _resolvedType)
{
RemoveBinding(StatescriptPropertyDirection.Input, 0);
ActiveResolverEditors.Remove(new PropertySlotKey(StatescriptPropertyDirection.Input, 0));
}
if (_cachedTypeInfo is not null
&& _cachedInputEditorContainer is not null
&& _cachedTypeInfo.InputPropertiesInfo.Length > 0)
{
RebuildInputUI(_cachedTypeInfo.InputPropertiesInfo[0], _cachedInputEditorContainer);
}
var newInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
RecordResolverBindingChange(
StatescriptPropertyDirection.Output,
_cachedOutputIndex,
oldResolver,
newResolver,
"Change Shared Variable Set");
if (previousType != _resolvedType)
{
RecordResolverBindingChange(
StatescriptPropertyDirection.Input,
0,
oldInputResolver,
newInputResolver,
"Change Shared Variable Set Input");
}
RaisePropertyBindingChanged();
ResetSize();
}
private void OnSharedVariableDropdownItemSelected(long x)
{
if (_sharedVarDropdown is null)
{
return;
}
var idx = _sharedVarDropdown.Selected;
var oldResolver = FindBinding(StatescriptPropertyDirection.Output, _cachedOutputIndex)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var oldInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
StatescriptVariableType? previousType = _resolvedType;
var previousIsArray = _resolvedIsArray;
if (idx >= 0 && idx < _variableNames.Count)
{
_selectedSharedVarName = _variableNames[idx];
ResolveSharedVariableType();
}
else
{
_selectedSharedVarName = string.Empty;
_selectedSharedVarType = StatescriptVariableType.Int;
}
UpdateSharedOutputBinding();
if (!string.IsNullOrEmpty(_selectedSharedVarName))
{
_resolvedType = _selectedSharedVarType;
_resolvedIsArray = false;
}
else
{
_resolvedType = null;
_resolvedIsArray = false;
}
if (previousType != _resolvedType || previousIsArray != _resolvedIsArray)
{
RemoveBinding(StatescriptPropertyDirection.Input, 0);
ActiveResolverEditors.Remove(new PropertySlotKey(StatescriptPropertyDirection.Input, 0));
}
if (_cachedTypeInfo is not null
&& _cachedInputEditorContainer is not null
&& _cachedTypeInfo.InputPropertiesInfo.Length > 0)
{
RebuildInputUI(_cachedTypeInfo.InputPropertiesInfo[0], _cachedInputEditorContainer);
}
var newResolver = FindBinding(StatescriptPropertyDirection.Output, _cachedOutputIndex)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var newInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
RecordResolverBindingChange(
StatescriptPropertyDirection.Output,
_cachedOutputIndex,
oldResolver,
newResolver,
"Change Shared Target Variable");
if (previousType != _resolvedType || previousIsArray != _resolvedIsArray)
{
RecordResolverBindingChange(
StatescriptPropertyDirection.Input,
0,
oldInputResolver,
newInputResolver,
"Change Shared Target Variable Input");
}
RaisePropertyBindingChanged();
ResetSize();
}
private void UpdateSharedOutputBinding()
{
if (string.IsNullOrEmpty(_selectedSharedVarName) || string.IsNullOrEmpty(_selectedSetPath))
{
RemoveBinding(StatescriptPropertyDirection.Output, _cachedOutputIndex);
return;
}
EnsureBinding(StatescriptPropertyDirection.Output, _cachedOutputIndex).Resolver =
new SharedVariableResolverResource
{
SharedVariableSetPath = _selectedSetPath,
VariableName = _selectedSharedVarName,
VariableType = _selectedSharedVarType,
};
}
private void ResolveSharedVariableType()
{
if (string.IsNullOrEmpty(_selectedSetPath)
|| string.IsNullOrEmpty(_selectedSharedVarName)
|| !ResourceLoader.Exists(_selectedSetPath))
{
_selectedSharedVarType = StatescriptVariableType.Int;
return;
}
ForgeSharedVariableSet? set = ResourceLoader.Load<ForgeSharedVariableSet>(_selectedSetPath);
if (set is null)
{
_selectedSharedVarType = StatescriptVariableType.Int;
return;
}
foreach (ForgeSharedVariableDefinition def in set.Variables)
{
if (def.VariableName == _selectedSharedVarName)
{
_selectedSharedVarType = def.VariableType;
return;
}
}
_selectedSharedVarType = StatescriptVariableType.Int;
}
private void OnTargetVariableDropdownItemSelected(long x)
{
if (_cachedTypeInfo is null || _cachedInputEditorContainer is null)
{
return;
}
var index = _cachedOutputIndex;
var variableIndex = (int)x - 1;
StatescriptVariableType? previousType = _resolvedType;
var previousIsArray = _resolvedIsArray;
var oldOutputResolver = FindBinding(StatescriptPropertyDirection.Output, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var oldInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
if (variableIndex < 0)
{
RemoveBinding(StatescriptPropertyDirection.Output, index);
_resolvedType = null;
_resolvedIsArray = false;
}
else
{
var variableName = Graph.Variables[variableIndex].VariableName;
EnsureBinding(StatescriptPropertyDirection.Output, index).Resolver =
new VariableResolverResource { VariableName = variableName };
_resolvedType = Graph.Variables[variableIndex].VariableType;
_resolvedIsArray = Graph.Variables[variableIndex].IsArray;
}
if (previousType != _resolvedType || previousIsArray != _resolvedIsArray)
{
RemoveBinding(StatescriptPropertyDirection.Input, 0);
var inputKey = new PropertySlotKey(StatescriptPropertyDirection.Input, 0);
ActiveResolverEditors.Remove(inputKey);
}
if (_cachedTypeInfo.InputPropertiesInfo.Length > 0)
{
RebuildInputUI(_cachedTypeInfo.InputPropertiesInfo[0], _cachedInputEditorContainer);
}
var newOutputResolver = FindBinding(StatescriptPropertyDirection.Output, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
var newInputResolver = FindBinding(StatescriptPropertyDirection.Input, 0)?.Resolver?.Duplicate()
as StatescriptResolverResource;
RecordResolverBindingChange(
StatescriptPropertyDirection.Output,
index,
oldOutputResolver,
newOutputResolver,
"Change Target Variable");
if (previousType != _resolvedType || previousIsArray != _resolvedIsArray)
{
RecordResolverBindingChange(
StatescriptPropertyDirection.Input,
0,
oldInputResolver,
newInputResolver,
"Change Target Variable Input");
}
RaisePropertyBindingChanged();
ResetSize();
}
private void RebuildInputUI(
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
VBoxContainer container)
{
ClearContainer(container);
if (_resolvedType is null)
{
var placeholder = new Label
{
Text = "Select target variable first",
HorizontalAlignment = HorizontalAlignment.Center,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
placeholder.AddThemeColorOverride("font_color", new Color(1, 1, 1, 0.4f));
container.AddChild(placeholder);
ResetSize();
return;
}
Type resolvedClrType = StatescriptVariableTypeConverter.ToSystemType(_resolvedType.Value);
AddInputPropertyRow(
new StatescriptNodeDiscovery.InputPropertyInfo(propInfo.Label, resolvedClrType, _resolvedIsArray),
0,
container);
ResetSize();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://us4bxyl7143x

View File

@@ -0,0 +1,366 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using Gamesmiths.Forge.Godot.Resources;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor that binds a node input property to an activation data field. Uses a two-step selection: first
/// select the <see cref="IActivationDataProvider"/> implementation, then select a compatible field from that provider.
/// Providers are discovered via reflection.
/// </summary>
/// <remarks>
/// A graph supports only one activation data provider. Once any other node in the graph references a provider, the
/// provider dropdown is locked to that provider. The user only needs to clear the bindings on other nodes to unlock
/// the dropdown.
/// </remarks>
[Tool]
internal sealed partial class ActivationDataResolverEditor : NodeEditorProperty
{
private readonly List<string> _providerClassNames = [];
private readonly List<string> _fieldNames = [];
private StatescriptGraph? _graph;
private StatescriptNodeProperty? _currentProperty;
private OptionButton? _providerDropdown;
private OptionButton? _fieldDropdown;
private Action? _onChanged;
private Type _expectedType = typeof(Variant128);
private string _selectedProviderClassName = string.Empty;
private string _selectedFieldName = string.Empty;
private StatescriptVariableType _selectedFieldType = StatescriptVariableType.Int;
/// <inheritdoc/>
public override string DisplayName => "Activation Data";
/// <inheritdoc/>
public override string ResolverTypeId => "ActivationData";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return true;
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
_onChanged = onChanged;
_expectedType = expectedType;
_graph = graph;
_currentProperty = property;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
AddChild(vBox);
if (property?.Resolver is ActivationDataResolverResource activationRes)
{
_selectedProviderClassName = activationRes.ProviderClassName;
_selectedFieldName = activationRes.FieldName;
_selectedFieldType = activationRes.FieldType;
}
var providerRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(providerRow);
providerRow.AddChild(new Label
{
Text = "Provider:",
CustomMinimumSize = new Vector2(75, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_providerDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
PopulateProviderDropdown();
providerRow.AddChild(_providerDropdown);
// Re-scan the graph each time the dropdown opens to pick up changes from other editors.
_providerDropdown.GetPopup().AboutToPopup += PopulateProviderDropdown;
var fieldRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(fieldRow);
fieldRow.AddChild(new Label
{
Text = "Field:",
CustomMinimumSize = new Vector2(75, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_fieldDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
PopulateFieldDropdown();
fieldRow.AddChild(_fieldDropdown);
_providerDropdown.ItemSelected += OnProviderDropdownItemSelected;
_fieldDropdown.ItemSelected += OnFieldDropdownItemSelected;
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
property.Resolver = new ActivationDataResolverResource
{
ProviderClassName = _selectedProviderClassName,
FieldName = _selectedFieldName,
FieldType = _selectedFieldType,
};
}
/// <inheritdoc/>
public override void ClearCallbacks()
{
base.ClearCallbacks();
_onChanged = null;
}
private static string FindExistingProvider(StatescriptGraph graph, StatescriptNodeProperty? currentProperty)
{
foreach (StatescriptNode node in graph.Nodes)
{
foreach (StatescriptNodeProperty binding in node.PropertyBindings)
{
// Skip the property we're currently editing — the user should be free to change it.
if (ReferenceEquals(binding, currentProperty))
{
continue;
}
if (binding.Resolver is ActivationDataResolverResource { ProviderClassName.Length: > 0 } resolver)
{
return resolver.ProviderClassName;
}
}
}
return string.Empty;
}
private static IActivationDataProvider? InstantiateProvider(string className)
{
if (string.IsNullOrEmpty(className))
{
return null;
}
Type? type = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.FirstOrDefault(
x => typeof(IActivationDataProvider).IsAssignableFrom(x)
&& !x.IsAbstract
&& !x.IsInterface
&& x.Name == className);
if (type is null)
{
return null;
}
return Activator.CreateInstance(type) as IActivationDataProvider;
}
private void OnProviderDropdownItemSelected(long index)
{
if (_providerDropdown is null)
{
return;
}
var idx = _providerDropdown.Selected;
_selectedProviderClassName = idx >= 0 && idx < _providerClassNames.Count
? _providerClassNames[idx]
: string.Empty;
_selectedFieldName = string.Empty;
_selectedFieldType = StatescriptVariableType.Int;
PopulateFieldDropdown();
_onChanged?.Invoke();
}
private void OnFieldDropdownItemSelected(long index)
{
if (_fieldDropdown is null)
{
return;
}
var dropdownIndex = _fieldDropdown.Selected;
if (dropdownIndex >= 0 && dropdownIndex < _fieldNames.Count)
{
_selectedFieldName = _fieldNames[dropdownIndex];
if (!string.IsNullOrEmpty(_selectedFieldName))
{
ResolveFieldType();
}
else
{
_selectedFieldType = StatescriptVariableType.Int;
}
}
else
{
_selectedFieldName = string.Empty;
_selectedFieldType = StatescriptVariableType.Int;
}
_onChanged?.Invoke();
}
private void PopulateProviderDropdown()
{
if (_providerDropdown is null)
{
return;
}
_providerDropdown.Clear();
_providerClassNames.Clear();
// Always add a (None) option to allow deselecting.
_providerDropdown.AddItem("(None)");
_providerClassNames.Add(string.Empty);
// Re-scan the graph each time to pick up changes from other editors.
var graphLockedProvider = _graph is not null
? FindExistingProvider(_graph, _currentProperty)
: string.Empty;
if (!string.IsNullOrEmpty(graphLockedProvider))
{
// Another node already uses a provider: only show that one (plus None).
_providerDropdown.AddItem(graphLockedProvider);
_providerClassNames.Add(graphLockedProvider);
}
else
{
foreach (var name in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(x => typeof(IActivationDataProvider).IsAssignableFrom(x)
&& !x.IsAbstract
&& !x.IsInterface)
.Select(x => x.Name))
{
_providerDropdown.AddItem(name);
_providerClassNames.Add(name);
}
}
// Restore selection.
if (!string.IsNullOrEmpty(_selectedProviderClassName))
{
for (var i = 0; i < _providerClassNames.Count; i++)
{
if (_providerClassNames[i] == _selectedProviderClassName)
{
_providerDropdown.Selected = i;
return;
}
}
}
// Default to (None).
_providerDropdown.Selected = 0;
_selectedProviderClassName = string.Empty;
}
private void PopulateFieldDropdown()
{
if (_fieldDropdown is null)
{
return;
}
_fieldDropdown.Clear();
_fieldNames.Clear();
// Always add a (None) option.
_fieldDropdown.AddItem("(None)");
_fieldNames.Add(string.Empty);
IActivationDataProvider? provider = InstantiateProvider(_selectedProviderClassName);
if (provider is not null)
{
foreach (ForgeActivationDataField field in provider.GetFields())
{
if (string.IsNullOrEmpty(field.FieldName))
{
continue;
}
if (_expectedType != typeof(Variant128)
&& !StatescriptVariableTypeConverter.IsCompatible(_expectedType, field.FieldType))
{
continue;
}
_fieldDropdown.AddItem(field.FieldName);
_fieldNames.Add(field.FieldName);
}
}
// Restore selection.
if (!string.IsNullOrEmpty(_selectedFieldName))
{
for (var i = 0; i < _fieldNames.Count; i++)
{
if (_fieldNames[i] == _selectedFieldName)
{
_fieldDropdown.Selected = i;
return;
}
}
}
// Default to (None).
_fieldDropdown.Selected = 0;
_selectedFieldName = string.Empty;
}
private void ResolveFieldType()
{
if (string.IsNullOrEmpty(_selectedProviderClassName) || string.IsNullOrEmpty(_selectedFieldName))
{
_selectedFieldType = StatescriptVariableType.Int;
return;
}
IActivationDataProvider? provider = InstantiateProvider(_selectedProviderClassName);
if (provider is null)
{
_selectedFieldType = StatescriptVariableType.Int;
return;
}
foreach (ForgeActivationDataField field in provider.GetFields())
{
if (field.FieldName == _selectedFieldName)
{
_selectedFieldType = field.FieldType;
return;
}
}
_selectedFieldType = StatescriptVariableType.Int;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cvegkmbda17em

View File

@@ -0,0 +1,197 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor that reads a value from a Forge entity attribute. Shows attribute set and attribute dropdowns.
/// </summary>
[Tool]
internal sealed partial class AttributeResolverEditor : NodeEditorProperty
{
private OptionButton? _setDropdown;
private OptionButton? _attributeDropdown;
private string _selectedSetClass = string.Empty;
private string _selectedAttribute = string.Empty;
private Action? _onChanged;
/// <inheritdoc/>
public override string DisplayName => "Attribute";
/// <inheritdoc/>
public override string ResolverTypeId => "Attribute";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return expectedType == typeof(int) || expectedType == typeof(Variant128);
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
_onChanged = onChanged;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
AddChild(vBox);
if (property?.Resolver is AttributeResolverResource attrRes)
{
_selectedSetClass = attrRes.AttributeSetClass;
_selectedAttribute = attrRes.AttributeName;
}
var setRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(setRow);
setRow.AddChild(new Label
{
Text = "Set:",
CustomMinimumSize = new Vector2(45, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_setDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
PopulateSetDropdown();
setRow.AddChild(_setDropdown);
var attrRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(attrRow);
attrRow.AddChild(new Label
{
Text = "Attr:",
CustomMinimumSize = new Vector2(45, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_attributeDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
PopulateAttributeDropdown();
attrRow.AddChild(_attributeDropdown);
_setDropdown.ItemSelected += OnSetDropdownItemSelected;
_attributeDropdown.ItemSelected += OnAttributeDropdownItemSelected;
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
property.Resolver = new AttributeResolverResource
{
AttributeSetClass = _selectedSetClass,
AttributeName = _selectedAttribute,
};
}
/// <inheritdoc/>
public override void ClearCallbacks()
{
base.ClearCallbacks();
_onChanged = null;
}
private void OnSetDropdownItemSelected(long index)
{
if (_setDropdown is null)
{
return;
}
_selectedSetClass = _setDropdown.GetItemText(_setDropdown.Selected);
_selectedAttribute = string.Empty;
PopulateAttributeDropdown();
_onChanged?.Invoke();
}
private void OnAttributeDropdownItemSelected(long index)
{
if (_attributeDropdown is null)
{
return;
}
_selectedAttribute = _attributeDropdown.GetItemText(_attributeDropdown.Selected);
_onChanged?.Invoke();
}
private void PopulateSetDropdown()
{
if (_setDropdown is null)
{
return;
}
_setDropdown.Clear();
foreach (var option in EditorUtils.GetAttributeSetOptions())
{
_setDropdown.AddItem(option);
}
// Restore selection.
if (!string.IsNullOrEmpty(_selectedSetClass))
{
for (var i = 0; i < _setDropdown.GetItemCount(); i++)
{
if (_setDropdown.GetItemText(i) == _selectedSetClass)
{
_setDropdown.Selected = i;
return;
}
}
}
// Default to first if available.
if (_setDropdown.GetItemCount() > 0)
{
_setDropdown.Selected = 0;
_selectedSetClass = _setDropdown.GetItemText(0);
}
}
private void PopulateAttributeDropdown()
{
if (_attributeDropdown is null)
{
return;
}
_attributeDropdown.Clear();
foreach (var option in EditorUtils.GetAttributeOptions(_selectedSetClass))
{
_attributeDropdown.AddItem(option);
}
if (!string.IsNullOrEmpty(_selectedAttribute))
{
for (var i = 0; i < _attributeDropdown.GetItemCount(); i++)
{
if (_attributeDropdown.GetItemText(i) == _selectedAttribute)
{
_attributeDropdown.Selected = i;
return;
}
}
}
if (_attributeDropdown.GetItemCount() > 0)
{
_attributeDropdown.Selected = 0;
_selectedAttribute = _attributeDropdown.GetItemText(0);
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://ciagvn5l8gnbq

View File

@@ -0,0 +1,286 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript.Properties;
using Godot;
using ForgeVariant128 = Gamesmiths.Forge.Statescript.Variant128;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor that compares two nested numeric resolvers and produces a boolean result. Supports nesting any
/// numeric-compatible resolver as left/right operands, enabling powerful comparisons like "Attribute &gt; Constant".
/// </summary>
[Tool]
internal sealed partial class ComparisonResolverEditor : NodeEditorProperty
{
private StatescriptGraph? _graph;
private Action? _onChanged;
private OptionButton? _operationDropdown;
private VBoxContainer? _leftContainer;
private VBoxContainer? _rightContainer;
private OptionButton? _leftResolverDropdown;
private OptionButton? _rightResolverDropdown;
private NodeEditorProperty? _leftEditor;
private NodeEditorProperty? _rightEditor;
private List<Func<NodeEditorProperty>> _numericFactories = [];
private ComparisonOperation _operation;
private VBoxContainer? _leftEditorContainer;
private VBoxContainer? _rightEditorContainer;
/// <inheritdoc/>
public override string DisplayName => "Comparison";
/// <inheritdoc/>
public override string ResolverTypeId => "Comparison";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return expectedType == typeof(bool) || expectedType == typeof(ForgeVariant128);
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
_graph = graph;
_onChanged = onChanged;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
AddChild(vBox);
_numericFactories = StatescriptResolverRegistry.GetCompatibleFactories(typeof(ForgeVariant128));
_numericFactories.RemoveAll(x =>
{
using NodeEditorProperty temp = x();
return temp.ResolverTypeId == "Comparison";
});
var comparisonResolver = property?.Resolver as ComparisonResolverResource;
if (comparisonResolver is not null)
{
_operation = comparisonResolver.Operation;
}
var leftFoldable = new FoldableContainer { Title = "Left:" };
leftFoldable.FoldingChanged += OnFoldingChanged;
vBox.AddChild(leftFoldable);
_leftContainer = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
leftFoldable.AddChild(_leftContainer);
_leftEditorContainer = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_leftResolverDropdown = CreateResolverDropdownControl(comparisonResolver?.Left);
_leftContainer.AddChild(_leftResolverDropdown);
_leftContainer.AddChild(_leftEditorContainer);
ShowNestedEditor(
GetSelectedIndex(comparisonResolver?.Left),
comparisonResolver?.Left,
_leftEditorContainer,
x => _leftEditor = x);
_leftResolverDropdown.ItemSelected += OnLeftResolverDropdownItemSelected;
var opRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(opRow);
opRow.AddChild(new Label { Text = "Op:" });
_operationDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_operationDropdown.AddItem("==");
_operationDropdown.AddItem("!=");
_operationDropdown.AddItem("<");
_operationDropdown.AddItem("<=");
_operationDropdown.AddItem(">");
_operationDropdown.AddItem(">=");
_operationDropdown.Selected = (int)_operation;
_operationDropdown.ItemSelected += OnOperationDropdownItemSelected;
opRow.AddChild(_operationDropdown);
var rightFoldable = new FoldableContainer { Title = "Right:" };
rightFoldable.FoldingChanged += OnFoldingChanged;
vBox.AddChild(rightFoldable);
_rightContainer = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
rightFoldable.AddChild(_rightContainer);
_rightEditorContainer = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_rightResolverDropdown = CreateResolverDropdownControl(comparisonResolver?.Right);
_rightContainer.AddChild(_rightResolverDropdown);
_rightContainer.AddChild(_rightEditorContainer);
ShowNestedEditor(
GetSelectedIndex(comparisonResolver?.Right),
comparisonResolver?.Right,
_rightEditorContainer,
x => _rightEditor = x);
_rightResolverDropdown.ItemSelected += OnRightResolverDropdownItemSelected;
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
var comparisonResolver = new ComparisonResolverResource { Operation = _operation };
if (_leftEditor is not null)
{
var leftProperty = new StatescriptNodeProperty();
_leftEditor.SaveTo(leftProperty);
comparisonResolver.Left = leftProperty.Resolver;
}
if (_rightEditor is not null)
{
var rightProperty = new StatescriptNodeProperty();
_rightEditor.SaveTo(rightProperty);
comparisonResolver.Right = rightProperty.Resolver;
}
property.Resolver = comparisonResolver;
}
/// <inheritdoc/>
public override void ClearCallbacks()
{
base.ClearCallbacks();
_onChanged = null;
_leftEditor?.ClearCallbacks();
_rightEditor?.ClearCallbacks();
}
private void OnFoldingChanged(bool isFolded)
{
RaiseLayoutSizeChanged();
}
private void OnOperationDropdownItemSelected(long x)
{
_operation = (ComparisonOperation)(int)x;
_onChanged?.Invoke();
}
private void OnLeftResolverDropdownItemSelected(long x)
{
HandleResolverDropdownChanged((int)x, _leftEditorContainer, editor => _leftEditor = editor);
}
private void OnRightResolverDropdownItemSelected(long x)
{
HandleResolverDropdownChanged((int)x, _rightEditorContainer, editor => _rightEditor = editor);
}
private void HandleResolverDropdownChanged(
int selectedIndex,
VBoxContainer? editorContainer,
Action<NodeEditorProperty?> setEditor)
{
if (editorContainer is null)
{
return;
}
foreach (Node child in editorContainer.GetChildren())
{
editorContainer.RemoveChild(child);
child.Free();
}
setEditor(null);
ShowNestedEditor(selectedIndex, null, editorContainer, setEditor);
_onChanged?.Invoke();
RaiseLayoutSizeChanged();
}
private int GetSelectedIndex(StatescriptResolverResource? existingResolver)
{
var selectedIndex = 0;
if (existingResolver is not null)
{
var existingTypeId = existingResolver.ResolverTypeId;
for (var i = 0; i < _numericFactories.Count; i++)
{
using NodeEditorProperty temp = _numericFactories[i]();
if (temp.ResolverTypeId == existingTypeId)
{
selectedIndex = i;
break;
}
}
}
return selectedIndex;
}
private OptionButton CreateResolverDropdownControl(StatescriptResolverResource? existingResolver)
{
var dropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
foreach (Func<NodeEditorProperty> factory in _numericFactories)
{
using NodeEditorProperty temp = factory();
dropdown.AddItem(temp.DisplayName);
}
dropdown.Selected = GetSelectedIndex(existingResolver);
return dropdown;
}
private void ShowNestedEditor(
int factoryIndex,
StatescriptResolverResource? existingResolver,
VBoxContainer container,
Action<NodeEditorProperty?> setEditor)
{
if (_graph is null || factoryIndex < 0 || factoryIndex >= _numericFactories.Count)
{
return;
}
NodeEditorProperty editor = _numericFactories[factoryIndex]();
StatescriptNodeProperty? tempProperty = null;
if (existingResolver is not null)
{
tempProperty = new StatescriptNodeProperty { Resolver = existingResolver };
}
editor.Setup(_graph, tempProperty, typeof(ForgeVariant128), OnNestedEditorChanged, false);
editor.LayoutSizeChanged += RaiseLayoutSizeChanged;
container.AddChild(editor);
setEditor(editor);
}
private void OnNestedEditorChanged()
{
_onChanged?.Invoke();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://c8uywbj8s8brq

View File

@@ -0,0 +1,57 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor for the ability activation magnitude. No configuration is needed, it simply reads the magnitude from
/// the <see cref="Abilities.AbilityBehaviorContext"/> at runtime. Only compatible with <see langword="float"/> inputs.
/// </summary>
[Tool]
internal sealed partial class MagnitudeResolverEditor : NodeEditorProperty
{
/// <inheritdoc/>
public override string DisplayName => "Magnitude";
/// <inheritdoc/>
public override string ResolverTypeId => "Magnitude";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return expectedType == typeof(float) || expectedType == typeof(Variant128);
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
SizeFlagsHorizontal = SizeFlags.ExpandFill;
var label = new Label
{
Text = "Ability Magnitude",
HorizontalAlignment = HorizontalAlignment.Center,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(label);
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
property.Resolver = new MagnitudeResolverResource();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cdsw31atjur88

View File

@@ -0,0 +1,319 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor that binds a node input property to a shared variable on the owning entity. Uses a two-step
/// selection: first select the <see cref="ForgeSharedVariableSet"/> resource, then select a compatible variable from
/// that set. At runtime the value is read from the entity's <see cref="GraphContext.SharedVariables"/> bag.
/// </summary>
[Tool]
internal sealed partial class SharedVariableResolverEditor : NodeEditorProperty
{
private readonly List<string> _setPaths = [];
private readonly List<string> _setDisplayNames = [];
private readonly List<string> _variableNames = [];
private OptionButton? _setDropdown;
private OptionButton? _variableDropdown;
private Action? _onChanged;
private Type _expectedType = typeof(Variant128);
private string _selectedSetPath = string.Empty;
private string _selectedVariableName = string.Empty;
private StatescriptVariableType _selectedVariableType = StatescriptVariableType.Int;
/// <inheritdoc/>
public override string DisplayName => "Shared Variable";
/// <inheritdoc/>
public override string ResolverTypeId => "SharedVariable";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return true;
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
_onChanged = onChanged;
_expectedType = expectedType;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
AddChild(vBox);
if (property?.Resolver is SharedVariableResolverResource sharedRes)
{
_selectedSetPath = sharedRes.SharedVariableSetPath;
_selectedVariableName = sharedRes.VariableName;
_selectedVariableType = sharedRes.VariableType;
}
var setRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(setRow);
setRow.AddChild(new Label
{
Text = "Set:",
CustomMinimumSize = new Vector2(45, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_setDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
PopulateSetDropdown();
setRow.AddChild(_setDropdown);
var varRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(varRow);
varRow.AddChild(new Label
{
Text = "Var:",
CustomMinimumSize = new Vector2(45, 0),
HorizontalAlignment = HorizontalAlignment.Right,
});
_variableDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
PopulateVariableDropdown();
varRow.AddChild(_variableDropdown);
_setDropdown.ItemSelected += OnSetDropdownItemSelected;
_variableDropdown.ItemSelected += OnVariableDropdownItemSelected;
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
property.Resolver = new SharedVariableResolverResource
{
SharedVariableSetPath = _selectedSetPath,
VariableName = _selectedVariableName,
VariableType = _selectedVariableType,
};
}
/// <inheritdoc/>
public override void ClearCallbacks()
{
base.ClearCallbacks();
_onChanged = null;
}
private static List<string> FindAllSharedVariableSetPaths()
{
var results = new List<string>();
EditorFileSystemDirectory root = EditorInterface.Singleton.GetResourceFilesystem().GetFilesystem();
ScanFilesystemDirectory(root, results);
return results;
}
private static void ScanFilesystemDirectory(EditorFileSystemDirectory dir, List<string> results)
{
for (var i = 0; i < dir.GetFileCount(); i++)
{
var path = dir.GetFilePath(i);
if (!path.EndsWith(".tres", StringComparison.InvariantCultureIgnoreCase)
&& !path.EndsWith(".res", StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
Resource resource = ResourceLoader.Load(path);
if (resource is ForgeSharedVariableSet)
{
results.Add(path);
}
}
for (var i = 0; i < dir.GetSubdirCount(); i++)
{
ScanFilesystemDirectory(dir.GetSubdir(i), results);
}
}
private void OnSetDropdownItemSelected(long index)
{
if (_setDropdown is null)
{
return;
}
var idx = _setDropdown.Selected;
_selectedSetPath = idx >= 0 && idx < _setPaths.Count ? _setPaths[idx] : string.Empty;
_selectedVariableName = string.Empty;
_selectedVariableType = StatescriptVariableType.Int;
PopulateVariableDropdown();
_onChanged?.Invoke();
}
private void OnVariableDropdownItemSelected(long index)
{
if (_variableDropdown is null)
{
return;
}
var idx = _variableDropdown.Selected;
if (idx >= 0 && idx < _variableNames.Count)
{
_selectedVariableName = _variableNames[idx];
ResolveVariableType();
}
else
{
_selectedVariableName = string.Empty;
_selectedVariableType = StatescriptVariableType.Int;
}
_onChanged?.Invoke();
}
private void PopulateSetDropdown()
{
if (_setDropdown is null)
{
return;
}
_setDropdown.Clear();
_setPaths.Clear();
_setDisplayNames.Clear();
_setDropdown.AddItem("(None)");
_setPaths.Add(string.Empty);
_setDisplayNames.Add("(None)");
foreach (var path in FindAllSharedVariableSetPaths())
{
var displayName = path[(path.LastIndexOf('/') + 1)..];
if (displayName.EndsWith(".tres", StringComparison.OrdinalIgnoreCase))
{
displayName = displayName[..^5];
}
_setDropdown.AddItem(displayName);
_setPaths.Add(path);
_setDisplayNames.Add(displayName);
}
// Restore selection.
for (var i = 0; i < _setPaths.Count; i++)
{
if (_setPaths[i] == _selectedSetPath)
{
_setDropdown.Selected = i;
return;
}
}
_setDropdown.Selected = 0;
_selectedSetPath = string.Empty;
}
private void PopulateVariableDropdown()
{
if (_variableDropdown is null)
{
return;
}
_variableDropdown.Clear();
_variableNames.Clear();
_variableDropdown.AddItem("(None)");
_variableNames.Add(string.Empty);
if (!string.IsNullOrEmpty(_selectedSetPath) && ResourceLoader.Exists(_selectedSetPath))
{
ForgeSharedVariableSet? set = ResourceLoader.Load<ForgeSharedVariableSet>(_selectedSetPath);
if (set is not null)
{
foreach (ForgeSharedVariableDefinition def in set.Variables)
{
if (string.IsNullOrEmpty(def.VariableName))
{
continue;
}
if (_expectedType != typeof(Variant128)
&& !StatescriptVariableTypeConverter.IsCompatible(_expectedType, def.VariableType))
{
continue;
}
var label = $"{def.VariableName}";
_variableDropdown.AddItem(label);
_variableNames.Add(def.VariableName);
}
}
}
// Restore selection.
for (var i = 0; i < _variableNames.Count; i++)
{
if (_variableNames[i] == _selectedVariableName)
{
_variableDropdown.Selected = i;
return;
}
}
_variableDropdown.Selected = 0;
_selectedVariableName = string.Empty;
}
private void ResolveVariableType()
{
if (string.IsNullOrEmpty(_selectedSetPath)
|| string.IsNullOrEmpty(_selectedVariableName)
|| !ResourceLoader.Exists(_selectedSetPath))
{
_selectedVariableType = StatescriptVariableType.Int;
return;
}
ForgeSharedVariableSet? set = ResourceLoader.Load<ForgeSharedVariableSet>(_selectedSetPath);
if (set is null)
{
_selectedVariableType = StatescriptVariableType.Int;
return;
}
foreach (ForgeSharedVariableDefinition def in set.Variables)
{
if (def.VariableName == _selectedVariableName)
{
_selectedVariableType = def.VariableType;
return;
}
}
_selectedVariableType = StatescriptVariableType.Int;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://55ynvr5cbscp

View File

@@ -0,0 +1,198 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Gamesmiths.Forge.Tags;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor that selects a single tag. Reuses the tag tree UI pattern from <c>TagEditorProperty</c>.
/// </summary>
[Tool]
internal sealed partial class TagResolverEditor : NodeEditorProperty
{
private readonly Dictionary<TreeItem, TagNode> _treeItemToNode = [];
private Button? _tagButton;
private ScrollContainer? _scroll;
private Tree? _tree;
private string _selectedTag = string.Empty;
private Texture2D? _checkedIcon;
private Texture2D? _uncheckedIcon;
private Action? _onChanged;
/// <inheritdoc/>
public override string DisplayName => "Tag";
/// <inheritdoc/>
public override string ResolverTypeId => "Tag";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return expectedType == typeof(bool) || expectedType == typeof(Variant128);
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
_onChanged = onChanged;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
AddChild(vBox);
// Restore from existing binding.
if (property?.Resolver is TagResolverResource tagRes)
{
_selectedTag = tagRes.Tag;
}
_checkedIcon = EditorInterface.Singleton
.GetEditorTheme()
.GetIcon("GuiRadioChecked", "EditorIcons");
_uncheckedIcon = EditorInterface.Singleton
.GetEditorTheme()
.GetIcon("GuiRadioUnchecked", "EditorIcons");
_tagButton = new Button
{
ToggleMode = true,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
Text = string.IsNullOrEmpty(_selectedTag) ? "(select tag)" : _selectedTag,
};
_tagButton.Toggled += OnTagButtonToggled;
vBox.AddChild(_tagButton);
_scroll = new ScrollContainer
{
Visible = false,
CustomMinimumSize = new Vector2(0, 180),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
};
_tree = new Tree
{
HideRoot = true,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
};
_scroll.AddChild(_tree);
vBox.AddChild(_scroll);
_tree.ButtonClicked += OnTreeButtonClicked;
RebuildTree();
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
property.Resolver = new TagResolverResource
{
Tag = _selectedTag,
};
}
/// <inheritdoc/>
public override void ClearCallbacks()
{
base.ClearCallbacks();
_onChanged = null;
}
private void OnTagButtonToggled(bool toggled)
{
if (_scroll is not null)
{
_scroll.Visible = toggled;
RaiseLayoutSizeChanged();
}
}
private void OnTreeButtonClicked(TreeItem item, long column, long id, long mouseButton)
{
if (mouseButton != 1 || id != 0)
{
return;
}
if (!_treeItemToNode.TryGetValue(item, out TagNode? tagNode))
{
return;
}
Forge.Core.StringKey newValue = tagNode.CompleteTagKey;
if (newValue == _selectedTag)
{
newValue = string.Empty;
}
_selectedTag = newValue;
if (_tagButton is not null)
{
_tagButton.Text = string.IsNullOrEmpty(_selectedTag) ? "(select tag)" : _selectedTag;
}
RebuildTree();
_onChanged?.Invoke();
}
private void RebuildTree()
{
if (_tree is null || _checkedIcon is null || _uncheckedIcon is null)
{
return;
}
_tree.Clear();
_treeItemToNode.Clear();
TreeItem root = _tree.CreateItem();
ForgeData forgePluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
var tagsManager = new TagsManager([.. forgePluginData.RegisteredTags]);
BuildTreeRecursive(root, tagsManager.RootNode);
}
private void BuildTreeRecursive(TreeItem parent, TagNode node)
{
if (_tree is null || _checkedIcon is null || _uncheckedIcon is null)
{
return;
}
foreach (TagNode child in node.ChildTags)
{
TreeItem item = _tree.CreateItem(parent);
item.SetText(0, child.TagKey);
var selected = _selectedTag == child.CompleteTagKey;
item.AddButton(0, selected ? _checkedIcon : _uncheckedIcon);
_treeItemToNode[item] = child;
BuildTreeRecursive(item, child);
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://drl073r6x5m16

View File

@@ -0,0 +1,149 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor that binds an input property to a graph variable. Only variables whose type is compatible with the
/// expected type are shown in the dropdown.
/// </summary>
[Tool]
internal sealed partial class VariableResolverEditor : NodeEditorProperty
{
private readonly List<string> _variableNames = [];
private OptionButton? _dropdown;
private string _selectedVariableName = string.Empty;
private Action? _onChanged;
/// <inheritdoc/>
public override string DisplayName => "Variable";
/// <inheritdoc/>
public override string ResolverTypeId => "Variable";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return true;
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
_onChanged = onChanged;
SizeFlagsHorizontal = SizeFlags.ExpandFill;
CustomMinimumSize = new Vector2(200, 25);
_dropdown = new OptionButton
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(100, 0),
};
_dropdown.SetMeta("is_variable_dropdown", true);
PopulateDropdown(graph, expectedType);
if (property?.Resolver is VariableResolverResource varRes
&& !string.IsNullOrEmpty(varRes.VariableName))
{
_selectedVariableName = varRes.VariableName;
SelectByName(varRes.VariableName);
}
_dropdown.ItemSelected += OnDropdownItemSelected;
AddChild(_dropdown);
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
property.Resolver = new VariableResolverResource
{
VariableName = _selectedVariableName,
};
}
/// <inheritdoc/>
public override void ClearCallbacks()
{
base.ClearCallbacks();
_onChanged = null;
}
private void OnDropdownItemSelected(long index)
{
if (_dropdown is null)
{
return;
}
var idx = _dropdown.Selected;
_selectedVariableName = idx >= 0 && idx < _variableNames.Count ? _variableNames[idx] : string.Empty;
_onChanged?.Invoke();
}
private void PopulateDropdown(StatescriptGraph graph, Type expectedType)
{
if (_dropdown is null)
{
return;
}
_dropdown.Clear();
_variableNames.Clear();
_dropdown.AddItem("(None)");
_variableNames.Add(string.Empty);
foreach (StatescriptGraphVariable variable in graph.Variables)
{
if (string.IsNullOrEmpty(variable.VariableName))
{
continue;
}
if (!StatescriptVariableTypeConverter.IsCompatible(expectedType, variable.VariableType))
{
continue;
}
_dropdown.AddItem(variable.VariableName);
_variableNames.Add(variable.VariableName);
}
}
private void SelectByName(string name)
{
if (_dropdown is null || string.IsNullOrEmpty(name))
{
return;
}
for (var i = 0; i < _variableNames.Count; i++)
{
if (_variableNames[i] == name)
{
_dropdown.Selected = i;
return;
}
}
_selectedVariableName = string.Empty;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cs7v6x0xv1a3k

View File

@@ -0,0 +1,388 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Godot;
using Godot.Collections;
using GodotVariant = Godot.Variant;
using GodotVector2 = Godot.Vector2;
namespace Gamesmiths.Forge.Godot.Editor.Statescript.Resolvers;
/// <summary>
/// Resolver editor that holds a constant (inline) value. The user edits the value directly in the node.
/// </summary>
[Tool]
internal sealed partial class VariantResolverEditor : NodeEditorProperty
{
private StatescriptVariableType _valueType;
private bool _isArray;
private bool _isArrayExpanded;
private GodotVariant _currentValue;
private Array<GodotVariant> _arrayValues = [];
private Action? _onChanged;
private Button? _toggleButton;
private VBoxContainer? _elementsContainer;
/// <inheritdoc/>
public override string DisplayName => "Constant";
/// <inheritdoc/>
public override string ResolverTypeId => "Variant";
/// <inheritdoc/>
public override bool IsCompatibleWith(Type expectedType)
{
return true;
}
/// <inheritdoc/>
public override void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray)
{
_isArray = isArray;
_onChanged = onChanged;
if (!StatescriptVariableTypeConverter.TryFromSystemType(expectedType, out _valueType))
{
_valueType = StatescriptVariableType.Int;
}
if (property?.Resolver is VariantResolverResource variantRes)
{
_valueType = variantRes.ValueType;
if (_isArray)
{
_arrayValues = [.. variantRes.ArrayValues];
_isArrayExpanded = variantRes.IsArrayExpanded;
}
else
{
_currentValue = variantRes.Value;
}
}
else if (_isArray)
{
_arrayValues = [];
}
else
{
_currentValue = StatescriptVariableTypeConverter.CreateDefaultGodotVariant(_valueType);
}
CustomMinimumSize = new GodotVector2(200, 40);
if (_isArray)
{
VBoxContainer arrayEditor = CreateArrayEditor();
AddChild(arrayEditor);
}
else
{
Control valueEditor = CreateValueEditor();
AddChild(valueEditor);
}
}
/// <inheritdoc/>
public override void SaveTo(StatescriptNodeProperty property)
{
if (_isArray)
{
property.Resolver = new VariantResolverResource
{
ValueType = _valueType,
IsArray = true,
ArrayValues = [.. _arrayValues],
IsArrayExpanded = _isArrayExpanded,
};
}
else
{
property.Resolver = new VariantResolverResource
{
Value = _currentValue,
ValueType = _valueType,
};
}
}
/// <inheritdoc/>
public override void ClearCallbacks()
{
base.ClearCallbacks();
_onChanged = null;
}
private Control CreateValueEditor()
{
if (_valueType == StatescriptVariableType.Bool)
{
return StatescriptEditorControls.CreateBoolEditor(_currentValue.AsBool(), OnBoolValueChanged);
}
if (StatescriptEditorControls.IsIntegerType(_valueType)
|| StatescriptEditorControls.IsFloatType(_valueType))
{
return StatescriptEditorControls.CreateNumericSpinSlider(
_valueType,
_currentValue.AsDouble(),
OnNumericValueChanged);
}
if (StatescriptEditorControls.IsVectorType(_valueType))
{
return StatescriptEditorControls.CreateVectorEditor(
_valueType,
x => StatescriptEditorControls.GetVectorComponent(_currentValue, _valueType, x),
OnVectorValueChanged);
}
return new Label { Text = _valueType.ToString() };
}
private void OnBoolValueChanged(bool x)
{
_currentValue = GodotVariant.From(x);
_onChanged?.Invoke();
}
private void OnNumericValueChanged(double x)
{
_currentValue = GodotVariant.From(x);
_onChanged?.Invoke();
}
private void OnVectorValueChanged(double[] x)
{
_currentValue = StatescriptEditorControls.BuildVectorVariant(_valueType, x);
_onChanged?.Invoke();
}
private VBoxContainer CreateArrayEditor()
{
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_elementsContainer = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
Visible = _isArrayExpanded,
};
var headerRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(headerRow);
_toggleButton = new Button
{
Text = $"Array (size {_arrayValues.Count})",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
ToggleMode = true,
ButtonPressed = _isArrayExpanded,
};
_toggleButton.Toggled += OnArrayToggled;
headerRow.AddChild(_toggleButton);
Texture2D addIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Add", "EditorIcons");
var addButton = new Button
{
Icon = addIcon,
Flat = true,
TooltipText = "Add Element",
CustomMinimumSize = new GodotVector2(24, 24),
};
addButton.Pressed += OnAddElementPressed;
headerRow.AddChild(addButton);
vBox.AddChild(_elementsContainer);
RebuildArrayElements();
return vBox;
}
private void OnArrayToggled(bool toggled)
{
if (_elementsContainer is not null)
{
_elementsContainer.Visible = toggled;
}
_isArrayExpanded = toggled;
_onChanged?.Invoke();
RaiseLayoutSizeChanged();
}
private void OnAddElementPressed()
{
GodotVariant defaultValue = StatescriptVariableTypeConverter.CreateDefaultGodotVariant(_valueType);
_arrayValues.Add(defaultValue);
_onChanged?.Invoke();
if (_elementsContainer is not null)
{
_elementsContainer.Visible = true;
}
_isArrayExpanded = true;
RebuildArrayElements();
RaiseLayoutSizeChanged();
}
private void RebuildArrayElements()
{
if (_elementsContainer is null || _toggleButton is null)
{
return;
}
foreach (Node child in _elementsContainer.GetChildren())
{
_elementsContainer.RemoveChild(child);
child.Free();
}
_toggleButton.Text = $"Array (size {_arrayValues.Count})";
Texture2D removeIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Remove", "EditorIcons");
for (var i = 0; i < _arrayValues.Count; i++)
{
var capturedIndex = i;
if (StatescriptEditorControls.IsVectorType(_valueType))
{
var elementVBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_elementsContainer.AddChild(elementVBox);
var labelRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
elementVBox.AddChild(labelRow);
labelRow.AddChild(new Label
{
Text = $"[{i}]",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
});
AddArrayRemoveButton(labelRow, removeIcon, capturedIndex);
VBoxContainer vectorEditor = StatescriptEditorControls.CreateVectorEditor(
_valueType,
x =>
{
return StatescriptEditorControls.GetVectorComponent(
_arrayValues[capturedIndex],
_valueType,
x);
},
x =>
{
_arrayValues[capturedIndex] =
StatescriptEditorControls.BuildVectorVariant(_valueType, x);
_onChanged?.Invoke();
});
elementVBox.AddChild(vectorEditor);
}
else
{
var elementRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
_elementsContainer.AddChild(elementRow);
elementRow.AddChild(new Label { Text = $"[{i}]" });
if (_valueType == StatescriptVariableType.Bool)
{
elementRow.AddChild(StatescriptEditorControls.CreateBoolEditor(
_arrayValues[capturedIndex].AsBool(),
x =>
{
_arrayValues[capturedIndex] = GodotVariant.From(x);
_onChanged?.Invoke();
}));
}
else
{
EditorSpinSlider spin = StatescriptEditorControls.CreateNumericSpinSlider(
_valueType,
_arrayValues[capturedIndex].AsDouble(),
x =>
{
_arrayValues[capturedIndex] = GodotVariant.From(x);
_onChanged?.Invoke();
});
elementRow.AddChild(spin);
}
AddArrayRemoveButton(elementRow, removeIcon, capturedIndex);
}
}
}
private void AddArrayRemoveButton(
HBoxContainer row,
Texture2D removeIcon,
int elementIndex)
{
var removeButton = new Button
{
Icon = removeIcon,
Flat = true,
TooltipText = "Remove Element",
CustomMinimumSize = new GodotVector2(24, 24),
};
var handler = new ArrayRemoveHandler(this, elementIndex);
removeButton.AddChild(handler);
removeButton.Pressed += handler.HandlePressed;
row.AddChild(removeButton);
}
private void OnRemoveElement(int elementIndex)
{
_arrayValues.RemoveAt(elementIndex);
_onChanged?.Invoke();
RebuildArrayElements();
RaiseLayoutSizeChanged();
}
/// <summary>
/// Godot-compatible signal handler for array element remove buttons. Holds the element index and a reference to the
/// owning editor so the <c>Pressed</c> signal can be handled without a lambda.
/// </summary>
[Tool]
private sealed partial class ArrayRemoveHandler : Node
{
private readonly VariantResolverEditor _editor;
private readonly int _elementIndex;
public ArrayRemoveHandler()
{
_editor = null!;
}
public ArrayRemoveHandler(VariantResolverEditor editor, int elementIndex)
{
_editor = editor;
_elementIndex = elementIndex;
}
public void HandlePressed()
{
_editor.OnRemoveElement(_elementIndex);
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dv2fk6v67mt3u

View File

@@ -11,7 +11,7 @@ using GodotStringArray = Godot.Collections.Array<string>;
namespace Gamesmiths.Forge.Godot.Editor.Tags;
[Tool]
public partial class TagContainerEditorProperty : EditorProperty
public partial class TagContainerEditorProperty : EditorProperty, ISerializationListener
{
private readonly Dictionary<TreeItem, TagNode> _treeItemToNode = [];
@@ -84,6 +84,20 @@ public partial class TagContainerEditorProperty : EditorProperty
RebuildTree();
}
public void OnBeforeSerialize()
{
for (var i = GetChildCount() - 1; i >= 0; i--)
{
Node child = GetChild(i);
RemoveChild(child);
child.Free();
}
}
public void OnAfterDeserialize()
{
}
private void RebuildTree()
{
_tree.Clear();
@@ -95,7 +109,7 @@ public partial class TagContainerEditorProperty : EditorProperty
TreeItem root = _tree.CreateItem();
ForgeData forgePluginData =
ResourceLoader.Load<ForgeData>("uid://8j4xg16o3qnl");
ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
var tagsManager =
new TagsManager([.. forgePluginData.RegisteredTags]);

View File

@@ -9,7 +9,7 @@ using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Tags;
[Tool]
public partial class TagEditorProperty : EditorProperty
public partial class TagEditorProperty : EditorProperty, ISerializationListener
{
private readonly Dictionary<TreeItem, TagNode> _treeItemToNode = [];
@@ -80,6 +80,20 @@ public partial class TagEditorProperty : EditorProperty
RebuildTree();
}
public void OnBeforeSerialize()
{
for (var i = GetChildCount() - 1; i >= 0; i--)
{
Node child = GetChild(i);
RemoveChild(child);
child.Free();
}
}
public void OnAfterDeserialize()
{
}
private void RebuildTree()
{
_tree.Clear();
@@ -91,7 +105,7 @@ public partial class TagEditorProperty : EditorProperty
TreeItem root = _tree.CreateItem();
ForgeData forgePluginData =
ResourceLoader.Load<ForgeData>("uid://8j4xg16o3qnl");
ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
var tagsManager =
new TagsManager([.. forgePluginData.RegisteredTags]);

View File

@@ -0,0 +1,257 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Tags;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Tags;
/// <summary>
/// Editor dock for managing gameplay tags.
/// </summary>
[Tool]
public partial class TagsEditorDock : EditorDock, ISerializationListener
{
private readonly Dictionary<TreeItem, TagNode> _treeItemToNode = [];
private TagsManager _tagsManager = null!;
private ForgeData? _forgePluginData;
private Tree? _tree;
private LineEdit? _tagNameTextField;
private Button? _addTagButton;
private Texture2D? _addIcon;
private Texture2D? _removeIcon;
public TagsEditorDock()
{
Title = "Tags";
DockIcon = GD.Load<Texture2D>("uid://cu6ncpuumjo20");
DefaultSlot = DockSlot.RightUl;
}
public override void _Ready()
{
base._Ready();
_forgePluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
_tagsManager = new TagsManager([.. _forgePluginData.RegisteredTags]);
_addIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Add", "EditorIcons");
_removeIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Remove", "EditorIcons");
BuildUI();
ConstructTagTree();
_tree!.ButtonClicked += TreeButtonClicked;
_addTagButton!.Pressed += AddTagButton_Pressed;
}
public void OnBeforeSerialize()
{
if (_tree is not null)
{
_tree.ButtonClicked -= TreeButtonClicked;
}
if (_addTagButton is not null)
{
_addTagButton.Pressed -= AddTagButton_Pressed;
}
}
public void OnAfterDeserialize()
{
EnsureInitialized();
if (_tree is not null)
{
_tree.ButtonClicked += TreeButtonClicked;
}
if (_addTagButton is not null)
{
_addTagButton.Pressed += AddTagButton_Pressed;
}
_tagsManager = new TagsManager([.. _forgePluginData.RegisteredTags]);
ReconstructTreeNode();
}
private void BuildUI()
{
var vBox = new VBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
};
AddChild(vBox);
var hBox = new HBoxContainer
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
vBox.AddChild(hBox);
var label = new Label
{
Text = "Tag Name:",
};
hBox.AddChild(label);
_tagNameTextField = new LineEdit
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox.AddChild(_tagNameTextField);
_addTagButton = new Button
{
Text = "Add Tag",
};
hBox.AddChild(_addTagButton);
_tree = new Tree
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
};
vBox.AddChild(_tree);
}
private void AddTagButton_Pressed()
{
EnsureInitialized();
Debug.Assert(
_forgePluginData.RegisteredTags is not null,
$"{_forgePluginData.RegisteredTags} should have been initialized by the Forge plugin.");
if (!Tag.IsValidKey(_tagNameTextField.Text, out var _, out var fixedTag))
{
_tagNameTextField.Text = fixedTag;
}
if (_forgePluginData.RegisteredTags.Contains(_tagNameTextField.Text))
{
GD.PushWarning($"Tag [{_tagNameTextField.Text}] is already present in the manager.");
return;
}
_forgePluginData.RegisteredTags.Add(_tagNameTextField.Text);
ResourceSaver.Save(_forgePluginData);
ReconstructTreeNode();
}
private void ReconstructTreeNode()
{
EnsureInitialized();
Debug.Assert(
_forgePluginData.RegisteredTags is not null,
$"{_forgePluginData.RegisteredTags} should have been initialized by the Forge plugin.");
_tagsManager.DestroyTagTree();
_tagsManager = new TagsManager([.. _forgePluginData.RegisteredTags]);
_tree.Clear();
ConstructTagTree();
}
private void ConstructTagTree()
{
EnsureInitialized();
TreeItem rootTreeNode = _tree.CreateItem();
_tree.HideRoot = true;
if (_tagsManager.RootNode.ChildTags.Count == 0)
{
TreeItem childTreeNode = _tree.CreateItem(rootTreeNode);
childTreeNode.SetText(0, "No tag has been registered yet.");
childTreeNode.SetCustomColor(0, Color.FromHtml("EED202"));
return;
}
BuildTreeRecursively(_tree, rootTreeNode, _tagsManager.RootNode);
}
private void BuildTreeRecursively(Tree tree, TreeItem currentTreeItem, TagNode currentNode)
{
foreach (TagNode childTagNode in currentNode.ChildTags)
{
TreeItem childTreeNode = tree.CreateItem(currentTreeItem);
childTreeNode.SetText(0, childTagNode.TagKey);
childTreeNode.AddButton(0, _addIcon);
childTreeNode.AddButton(0, _removeIcon);
_treeItemToNode.Add(childTreeNode, childTagNode);
BuildTreeRecursively(tree, childTreeNode, childTagNode);
}
}
private void TreeButtonClicked(TreeItem item, long column, long id, long mouseButtonIndex)
{
EnsureInitialized();
Debug.Assert(
_forgePluginData.RegisteredTags is not null,
$"{_forgePluginData.RegisteredTags} should have been initialized by the Forge plugin.");
if (mouseButtonIndex == 1)
{
if (id == 0)
{
_tagNameTextField.Text = $"{_treeItemToNode[item].CompleteTagKey}.";
_tagNameTextField.GrabFocus();
_tagNameTextField.CaretColumn = _tagNameTextField.Text.Length;
}
if (id == 1)
{
TagNode selectedTag = _treeItemToNode[item];
for (var i = _forgePluginData.RegisteredTags.Count - 1; i >= 0; i--)
{
var tag = _forgePluginData.RegisteredTags[i];
if (string.Equals(tag, selectedTag.CompleteTagKey, StringComparison.OrdinalIgnoreCase) ||
tag.StartsWith(selectedTag.CompleteTagKey + ".", StringComparison.InvariantCultureIgnoreCase))
{
_forgePluginData.RegisteredTags.Remove(tag);
}
}
if (selectedTag.ParentTagNode is not null
&& !_forgePluginData.RegisteredTags.Contains(selectedTag.ParentTagNode.CompleteTagKey))
{
_forgePluginData.RegisteredTags.Add(selectedTag.ParentTagNode.CompleteTagKey);
}
ResourceSaver.Save(_forgePluginData);
ReconstructTreeNode();
}
}
}
[MemberNotNull(nameof(_tree), nameof(_tagNameTextField), nameof(_forgePluginData))]
private void EnsureInitialized()
{
Debug.Assert(_tree is not null, $"{_tree} should have been initialized on _Ready().");
Debug.Assert(_tagNameTextField is not null, $"{_tagNameTextField} should have been initialized on _Ready().");
Debug.Assert(_forgePluginData is not null, $"{_forgePluginData} should have been initialized on _Ready().");
}
}
#endif

View File

@@ -0,0 +1 @@
uid://drgjhyxk7rkgg

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
version="1.1"
id="svg1"
sodipodi:docname="Statescript.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="77.4375"
inkscape:cx="7.9935432"
inkscape:cy="8"
inkscape:window-width="2560"
inkscape:window-height="1417"
inkscape:window-x="3432"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="m 11,10 a 1,1 0 0 0 -1,1 v 3 a 1,1 0 0 0 1,1 h 3 a 1,1 0 0 0 1,-1 v -3 a 1,1 0 0 0 -1,-1 z"
id="path5"
style="fill:#8eef97;fill-opacity:1" />
<path
d="M 7,8.883 V 10 A 2,2 0 0 1 6.732,11 L 9,12.117 V 11 a 2,2 0 0 1 0.268,-1 z"
id="path4"
style="fill:#d2d3d5;fill-opacity:1" />
<path
d="M 2,5 A 1,1 0 0 0 1,6 v 4 a 1,1 0 0 0 1,1 H 5 A 1,1 0 0 0 6,10 V 6 A 1,1 0 0 0 5,5 Z"
id="path3"
style="fill:#8da5f3;fill-opacity:1" />
<path
d="M 6.732,5 A 2,2 0 0 1 7,6 V 7.117 L 9.268,6 A 2,2 0 0 1 9,5 V 3.883 Z"
id="path2"
style="fill:#d2d3d5;fill-opacity:1" />
<path
d="m 11,1 a 1,1 0 0 0 -1,1 v 3 a 1,1 0 0 0 1,1 h 3 A 1,1 0 0 0 15,5 V 2 A 1,1 0 0 0 14,1 Z"
id="path1"
style="fill:#fc7f7f;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6yrjb46fluw3"
path="res://.godot/imported/Statescript.svg-09aa700bcff07f99651a8110f8eff0a6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/forge/icons/Statescript.svg"
dest_files=["res://.godot/imported/Statescript.svg-09aa700bcff07f99651a8110f8eff0a6.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View File

@@ -49,9 +49,11 @@
<path
fill="#5fff97"
d="m 7.9265537,9.7974173 -2,-4 -2,4 z"
id="path1-1" />
id="path1-1"
style="fill:#8eef97;fill-opacity:1" />
<path
fill="#ff5f5f"
d="m 11.926554,5.7974173 -2.0000003,4 -2,-4 z"
id="path2" />
id="path2"
style="fill:#fc7f7f;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -24,20 +24,20 @@
inkscape:zoom="38.71875"
inkscape:cx="9.4527845"
inkscape:cy="11.867635"
inkscape:window-width="3408"
inkscape:window-width="2560"
inkscape:window-height="1417"
inkscape:window-x="24"
inkscape:window-y="723"
inkscape:window-x="3432"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
fill="#5fff97"
d="M 7.9265536,11.734463 3.9895075,3.8603713 0.05246155,11.734463 Z"
id="path1-1"
style="stroke-width:1.96852" />
style="stroke-width:1.96852;fill:#8eef97;fill-opacity:1" />
<path
fill="#ff5f5f"
d="M 15.800646,3.8603713 11.8636,11.734463 7.9265536,3.8603713 Z"
id="path2"
style="stroke-width:1.96852" />
style="stroke-width:1.96852;fill:#fc7f7f;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -7,7 +7,9 @@ using Gamesmiths.Forge.Effects;
using Gamesmiths.Forge.Events;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Resources;
using Gamesmiths.Forge.Statescript;
using Godot;
using Node = Godot.Node;
namespace Gamesmiths.Forge.Godot.Nodes;
@@ -18,6 +20,9 @@ public partial class ForgeEntity : Node, IForgeEntity
[Export]
public ForgeTagContainer BaseTags { get; set; } = new();
[Export]
public ForgeSharedVariableSet? SharedVariableDefinitions { get; set; }
public EntityAttributes Attributes { get; set; } = null!;
public EntityTags Tags { get; set; } = null!;
@@ -28,6 +33,8 @@ public partial class ForgeEntity : Node, IForgeEntity
public EventManager Events { get; set; } = null!;
public Variables SharedVariables { get; set; } = null!;
public override void _Ready()
{
base._Ready();
@@ -36,6 +43,9 @@ public partial class ForgeEntity : Node, IForgeEntity
EffectsManager = new EffectsManager(this, ForgeManagers.Instance.CuesManager);
Abilities = new EntityAbilities(this);
Events = new EventManager();
SharedVariables = new Variables();
SharedVariableDefinitions?.PopulateVariables(SharedVariables);
List<AttributeSet> attributeSetList = [];
@@ -63,5 +73,6 @@ public partial class ForgeEntity : Node, IForgeEntity
base._Process(delta);
EffectsManager.UpdateEffects(delta);
Abilities.UpdateAbilities(delta);
}
}

View File

@@ -3,5 +3,5 @@
name="Forge Gameplay System"
description="A plugin for managing Gameplay Tags and Status Effects."
author="Gamesmiths Guild"
version="0.2.0"
version="0.3.0"
script="ForgePluginLoader.cs"

View File

@@ -0,0 +1,14 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Godot.Resources.Statescript;
namespace Gamesmiths.Forge.Godot.Resources;
/// <summary>
/// Describes a single field exposed by an <see cref="IActivationDataProvider"/>. Each field defines a name and type
/// that graph nodes can bind to via the Activation Data resolver.
/// </summary>
/// <param name="FieldName">The name of this data field. This name is used as the graph variable name at runtime.
/// </param>
/// <param name="FieldType">The type of this data field.</param>
public readonly record struct ForgeActivationDataField(string FieldName, StatescriptVariableType FieldType);

View File

@@ -0,0 +1 @@
uid://cv4mcpd3ifglu

View File

@@ -37,7 +37,7 @@ public partial class ForgeEffectData : Resource
private LevelComparison _levelOverridePolicy;
[Export]
public string Name { get; set; } = string.Empty;
public string Name { get; set; } = "New Effect";
[Export]
public bool SnapshotLevel { get; set; } = true;

View File

@@ -0,0 +1,47 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Resources;
/// <summary>
/// Resource representing a single shared variable definition for an entity, including name, type, and initial value.
/// </summary>
[Tool]
[GlobalClass]
public partial class ForgeSharedVariableDefinition : Resource
{
/// <summary>
/// Gets or sets the name of this shared variable.
/// </summary>
[Export]
public string VariableName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the type of this shared variable.
/// </summary>
[Export]
public StatescriptVariableType VariableType { get; set; } = StatescriptVariableType.Int;
/// <summary>
/// Gets or sets a value indicating whether this is an array variable.
/// </summary>
[Export]
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the initial value of this shared variable, stored as a Godot variant.
/// For non-array variables, this is a single value. Ignored when <see cref="IsArray"/> is true.
/// </summary>
[Export]
public Variant InitialValue { get; set; }
/// <summary>
/// Gets or sets the initial values for array variables.
/// Each element is stored as a Godot variant. Only used when <see cref="IsArray"/> is true.
/// </summary>
[Export]
public Array<Variant> InitialArrayValues { get; set; } = [];
}

View File

@@ -0,0 +1 @@
uid://347pr45ke8ns

View File

@@ -0,0 +1,65 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Statescript;
using Godot;
using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Resources;
/// <summary>
/// Resource containing a collection of shared variable definitions for an entity. Assign this to a
/// <see cref="Nodes.ForgeEntity"/> (or custom <see cref="IForgeEntity"/> implementation) to define which shared
/// variables the entity exposes at runtime.
/// </summary>
[Tool]
[GlobalClass]
[Icon("uid://cu6ncpuumjo20")]
public partial class ForgeSharedVariableSet : Resource
{
/// <summary>
/// Gets or sets the shared variable definitions.
/// </summary>
[Export]
public Array<ForgeSharedVariableDefinition> Variables { get; set; } = [];
/// <summary>
/// Populates a <see cref="Variables"/> bag with all the definitions in this set, using each variable's name and
/// initial value.
/// </summary>
/// <param name="target">The <see cref="Variables"/> instance to populate.</param>
public void PopulateVariables(Variables target)
{
foreach (ForgeSharedVariableDefinition definition in Variables)
{
if (string.IsNullOrEmpty(definition.VariableName))
{
continue;
}
var key = new StringKey(definition.VariableName);
if (definition.IsArray)
{
var initialValues = new Variant128[definition.InitialArrayValues.Count];
for (var i = 0; i < definition.InitialArrayValues.Count; i++)
{
initialValues[i] = StatescriptVariableTypeConverter.GodotVariantToForge(
definition.InitialArrayValues[i],
definition.VariableType);
}
target.DefineArrayVariable(key, initialValues);
}
else
{
Variant128 value = StatescriptVariableTypeConverter.GodotVariantToForge(
definition.InitialValue,
definition.VariableType);
target.DefineVariable(key, value);
}
}
}
}

View File

@@ -0,0 +1 @@
uid://dghkdxg314p05

View File

@@ -0,0 +1,40 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Abilities;
using Gamesmiths.Forge.Statescript;
namespace Gamesmiths.Forge.Godot.Resources;
/// <summary>
/// Interface that describes the fields available in a custom activation data type. Users implement this once per data
/// type to declare which fields the graph editor can bind to, and to provide the runtime behavior that maps the data
/// into graph variables.
/// </summary>
/// <remarks>
/// <para>Implementations must define <see cref="GetFields"/> to declare the available fields and their types, and
/// <see cref="CreateBehavior"/> to produce a <see cref="GraphAbilityBehavior{TData}"/> with the appropriate data binder
/// that writes matching values into the graph's <see cref="Variables"/>.</para>
/// <para>The provider is discovered automatically via reflection. Simply implement this interface and the class will
/// appear in the Activation Data resolver's provider dropdown in the graph editor.</para>
/// <para>A graph supports only one activation data provider at a time. If nodes in a graph already reference a
/// provider, the editor restricts subsequent nodes to the same provider. To use different activation data, define a
/// combined data type with all required fields.</para>
/// </remarks>
public interface IActivationDataProvider
{
/// <summary>
/// Returns the fields exposed by this activation data provider. Each entry defines a field name and type that graph
/// nodes can read at runtime through the Activation Data resolver.
/// </summary>
/// <returns>An array of field definitions.</returns>
ForgeActivationDataField[] GetFields();
/// <summary>
/// Creates an <see cref="IAbilityBehavior"/> (typically a <see cref="GraphAbilityBehavior{TData}"/>) for the given
/// graph. The returned behavior's data binder must write each declared field into the graph's
/// <see cref="Variables"/> using matching names so that the Activation Data resolver can read them at runtime.
/// </summary>
/// <param name="graph">The runtime graph to execute.</param>
/// <returns>An ability behavior that accepts the custom data type and maps it to graph variables.</returns>
IAbilityBehavior CreateBehavior(Graph graph);
}

View File

@@ -0,0 +1 @@
uid://ct5621o4fxwry

View File

@@ -19,7 +19,7 @@ public partial class ForgeAbilityData : Resource
private AbilityInstancingPolicy _instancingPolicy;
[Export]
public string Name { get; set; } = string.Empty;
public string Name { get; set; } = "New Ability";
[Export]
public AbilityInstancingPolicy InstancingPolicy

View File

@@ -0,0 +1,102 @@
// Copyright © Gamesmiths Guild.
using System;
using System.Linq;
using Gamesmiths.Forge.Abilities;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Resources.Abilities;
/// <summary>
/// A <see cref="ForgeAbilityBehavior"/> implementation that creates a <see cref="GraphAbilityBehavior"/> from a
/// serialized <see cref="StatescriptGraph"/> resource. The graph is built once and cached, then shared across all
/// ability instances using the Flyweight pattern. Each <see cref="GraphAbilityBehavior"/> creates its own
/// <see cref="GraphProcessor"/> with independent <see cref="GraphContext"/> state.
/// </summary>
/// <remarks>
/// If any node in the graph uses an <see cref="ActivationDataResolverResource"/>, the behavior automatically detects
/// the associated <see cref="IActivationDataProvider"/> implementation and produces a
/// <see cref="GraphAbilityBehavior{TData}"/> with a data binder that maps activation data fields into graph variables.
/// When no activation data resolver is present, a plain <see cref="GraphAbilityBehavior"/> (without data
/// support) is created.
/// </remarks>
[Tool]
[GlobalClass]
[Icon("uid://b6yrjb46fluw3")]
public partial class StatescriptAbilityBehavior : ForgeAbilityBehavior
{
private Graph? _cachedGraph;
private IActivationDataProvider? _cachedProvider;
private bool _providerResolved;
/// <summary>
/// Gets or sets the Statescript graph resource that defines the ability's behavior.
/// </summary>
[Export]
public StatescriptGraph? Statescript { get; set; }
/// <inheritdoc/>
public override IAbilityBehavior GetBehavior()
{
if (Statescript is null)
{
GD.PushError("StatescriptAbilityBehavior: Statescript is null.");
throw new InvalidOperationException("StatescriptAbilityBehavior requires a valid Statescript assigned.");
}
_cachedGraph ??= StatescriptGraphBuilder.Build(Statescript);
if (!_providerResolved)
{
_cachedProvider = FindActivationDataProvider(Statescript);
_providerResolved = true;
}
if (_cachedProvider is not null)
{
return _cachedProvider.CreateBehavior(_cachedGraph);
}
return new GraphAbilityBehavior(_cachedGraph);
}
private static IActivationDataProvider? FindActivationDataProvider(StatescriptGraph graph)
{
foreach (StatescriptNode node in graph.Nodes)
{
foreach (StatescriptNodeProperty binding in node.PropertyBindings)
{
if (binding.Resolver is ActivationDataResolverResource { ProviderClassName.Length: > 0 } resolver)
{
return InstantiateProvider(resolver.ProviderClassName);
}
}
}
return null;
}
private static IActivationDataProvider? InstantiateProvider(string className)
{
Type? type = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.FirstOrDefault(
x => typeof(IActivationDataProvider).IsAssignableFrom(x)
&& !x.IsAbstract
&& !x.IsInterface
&& x.Name == className);
if (type is null)
{
return null;
}
return Activator.CreateInstance(type) as IActivationDataProvider;
}
}

Some files were not shown because too many files have changed in this diff Show More