diff --git a/.gitignore b/.gitignore index 66c6ad40..d2aa32dd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ # Imported translations (automatically generated from CSV files) *.translation +docs/legal/ + .output.txt *.suo diff --git a/addons/forge/Forge.props b/addons/forge/Forge.props index 6ed69133..6aec39f0 100644 --- a/addons/forge/Forge.props +++ b/addons/forge/Forge.props @@ -3,6 +3,7 @@ enable - + + diff --git a/addons/forge/ForgePluginLoader.cs b/addons/forge/ForgePluginLoader.cs index a25e602a..2d3155da 100644 --- a/addons/forge/ForgePluginLoader.cs +++ b/addons/forge/ForgePluginLoader.cs @@ -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(PluginScenePath); + EnsureForgeDataExists(); - _editorDock = new EditorDock - { - Title = "Forge", - DockIcon = GD.Load("uid://cu6ncpuumjo20"), - DefaultSlot = EditorDock.DockSlot.RightUl, - }; - - _dockedScene = (PanelContainer)pluginScene.Instantiate(); - _dockedScene.GetNode("%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 diff --git a/addons/forge/README.md b/addons/forge/README.md index 2dacbfc3..5005100e 100644 --- a/addons/forge/README.md +++ b/addons/forge/README.md @@ -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 Godot’s 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 Godot’s 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 diff --git a/addons/forge/core/ForgeBootstrap.cs b/addons/forge/core/ForgeBootstrap.cs index eb2a52c7..28640fb6 100644 --- a/addons/forge/core/ForgeBootstrap.cs +++ b/addons/forge/core/ForgeBootstrap.cs @@ -8,7 +8,7 @@ public partial class ForgeBootstrap : Node { public override void _Ready() { - ForgeData pluginData = ResourceLoader.Load("uid://8j4xg16o3qnl"); + ForgeData pluginData = ResourceLoader.Load(ForgeData.ForgeDataResourcePath); _ = new ForgeManagers(pluginData); } } diff --git a/addons/forge/core/ForgeData.cs b/addons/forge/core/ForgeData.cs index 7b8261db..9e14a5bf 100644 --- a/addons/forge/core/ForgeData.cs +++ b/addons/forge/core/ForgeData.cs @@ -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 RegisteredTags { get; set; } = []; } diff --git a/addons/forge/core/StatescriptGraphBuilder.cs b/addons/forge/core/StatescriptGraphBuilder.cs new file mode 100644 index 00000000..17c29eee --- /dev/null +++ b/addons/forge/core/StatescriptGraphBuilder.cs @@ -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; + +/// +/// Builds a runtime from a serialized resource. +/// Resolves concrete node types from the Forge DLL and other assemblies using reflection and recreates all connections. +/// +public static class StatescriptGraphBuilder +{ + /// + /// Builds a runtime from the given resource. + /// + /// The serialized graph resource. + /// A fully constructed runtime graph ready for execution. + /// Thrown when a node type cannot be resolved or instantiated. + /// + public static Graph Build(StatescriptGraph graphResource) + { + var graph = new Graph(); + + var nodeMap = new Dictionary(); + + 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 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!; + } +} diff --git a/addons/forge/core/StatescriptGraphBuilder.cs.uid b/addons/forge/core/StatescriptGraphBuilder.cs.uid new file mode 100644 index 00000000..c5c1a88e --- /dev/null +++ b/addons/forge/core/StatescriptGraphBuilder.cs.uid @@ -0,0 +1 @@ +uid://btkf3jeisyh8j diff --git a/addons/forge/core/VariablesExtensions.cs b/addons/forge/core/VariablesExtensions.cs new file mode 100644 index 00000000..0c8dc2ff --- /dev/null +++ b/addons/forge/core/VariablesExtensions.cs @@ -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; + +/// +/// Extension methods for that provide seamless support for Godot types. These methods +/// automatically convert Godot math types (e.g., ) to their System.Numerics equivalents +/// before storing them in the variable bag. +/// +/// +/// Use these overloads in data binder delegates (e.g., when implementing +/// ) to avoid manual Godot-to-System.Numerics +/// conversions. +/// +public static class VariablesExtensions +{ + /// + /// Sets a variable from a value, converting it to . + /// + /// The variables bag. + /// The name of the variable to set. + /// The Godot Vector2 value to store. + public static void SetGodotVar(this Variables variables, StringKey name, GodotVector2 value) + { + variables.SetVariant(name, new Variant128(new SysVector2(value.X, value.Y))); + } + + /// + /// Sets a variable from a value, converting it to . + /// + /// The variables bag. + /// The name of the variable to set. + /// The Godot Vector3 value to store. + public static void SetGodotVar(this Variables variables, StringKey name, GodotVector3 value) + { + variables.SetVariant(name, new Variant128(new SysVector3(value.X, value.Y, value.Z))); + } + + /// + /// Sets a variable from a value, converting it to . + /// + /// The variables bag. + /// The name of the variable to set. + /// The Godot Vector4 value to store. + 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))); + } + + /// + /// Sets a variable from a value, converting it to . + /// + /// The variables bag. + /// The name of the variable to set. + /// The Godot Plane value to store. + 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))); + } + + /// + /// Sets a variable from a value, converting it to . + /// + /// The variables bag. + /// The name of the variable to set. + /// The Godot Quaternion value to store. + 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))); + } +} diff --git a/addons/forge/core/VariablesExtensions.cs.uid b/addons/forge/core/VariablesExtensions.cs.uid new file mode 100644 index 00000000..bcc0fddb --- /dev/null +++ b/addons/forge/core/VariablesExtensions.cs.uid @@ -0,0 +1 @@ +uid://tkifxnyfxgrp diff --git a/addons/forge/core/forge_data.tres b/addons/forge/core/forge_data.tres index cb5d8c84..84f8999f 100644 --- a/addons/forge/core/forge_data.tres +++ b/addons/forge/core/forge_data.tres @@ -4,4 +4,4 @@ [resource] script = ExtResource("1_x0pne") -RegisteredTags = Array[String](["character.player", "character.enemy", "weapon", "status.stunned", "status.burning", "status.frozen", "abilities.weapon.land", "abilities.weapon.flying", "abilities.weapon.left", "events.combat.damage", "events.combat.hit", "events.weapon.flyingTick", "events.weapon.startedFlying", "events.weapon.stoppedFlying", "events.weapon.handToFlying", "events.weapon.flyingToHand", "events.weapon.plantedToHand", "events.weapon.plantedToFlying", "events.weapon.planted", "cooldown.empoweredAction", "cooldown.empoweredSwordThrow", "cues.resources.mana", "events.player.empowered_action_used", "character.player.mana", "character.player.mana.regen", "character.player.mana.regen.inhibited"]) +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"]) diff --git a/addons/forge/editor/AssetRepairTool.cs b/addons/forge/editor/AssetRepairTool.cs index 46682f3d..f9ca01f5 100644 --- a/addons/forge/editor/AssetRepairTool.cs +++ b/addons/forge/editor/AssetRepairTool.cs @@ -17,7 +17,7 @@ public partial class AssetRepairTool : EditorPlugin { public static void RepairAllAssetsTags() { - ForgeData pluginData = ResourceLoader.Load("uid://8j4xg16o3qnl"); + ForgeData pluginData = ResourceLoader.Load(ForgeData.ForgeDataResourcePath); var tagsManager = new TagsManager([.. pluginData.RegisteredTags]); List scenes = GetScenePaths("res://"); diff --git a/addons/forge/editor/EditorUtils.cs b/addons/forge/editor/EditorUtils.cs index 94cef20d..6d03036d 100644 --- a/addons/forge/editor/EditorUtils.cs +++ b/addons/forge/editor/EditorUtils.cs @@ -19,11 +19,9 @@ internal static class EditorUtils { var options = new List(); - // 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) { diff --git a/addons/forge/editor/attributes/AttributeEditorProperty.cs b/addons/forge/editor/attributes/AttributeEditorProperty.cs index dbdf791f..02998b54 100644 --- a/addons/forge/editor/attributes/AttributeEditorProperty.cs +++ b/addons/forge/editor/attributes/AttributeEditorProperty.cs @@ -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(); diff --git a/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs b/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs index fbda0d75..b08e9788 100644 --- a/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs +++ b/addons/forge/editor/attributes/AttributeSetClassEditorProperty.cs @@ -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(); + var dictionary = new Dictionary(); 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 attrProps = targetType + System.Collections.Generic.IEnumerable 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 diff --git a/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs b/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs index f8a61ec9..b06b1b63 100644 --- a/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs +++ b/addons/forge/editor/attributes/AttributeSetValuesEditorProperty.cs @@ -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("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, }; } diff --git a/addons/forge/editor/attributes/AttributeValues.cs b/addons/forge/editor/attributes/AttributeValues.cs index 6944827a..db673da7 100644 --- a/addons/forge/editor/attributes/AttributeValues.cs +++ b/addons/forge/editor/attributes/AttributeValues.cs @@ -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; } diff --git a/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs b/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs index 389ff983..246e2f65 100644 --- a/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs +++ b/addons/forge/editor/cues/CueHandlerInspectorPlugin.cs @@ -13,27 +13,22 @@ 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() is CSharpScript script) { - if (@object.GetScript().As() is not { }) return false; - } - catch (Exception e) - { - return false; + StringName className = script.GetGlobalName(); + + Type baseType = typeof(ForgeCueHandler); + System.Reflection.Assembly assembly = baseType.Assembly; + + Type? implementationType = + Array.Find(assembly.GetTypes(), x => + x.Name == className && + baseType.IsAssignableFrom(x)); + + return implementationType is not null; } - var script = @object.GetScript().As(); - StringName className = script.GetGlobalName(); - - Type baseType = typeof(ForgeCueHandler); - System.Reflection.Assembly assembly = baseType.Assembly; - - Type? implementationType = - Array.Find(assembly.GetTypes(), x => - x.Name == className && - baseType.IsAssignableFrom(x)); - - return implementationType is not null; + return false; } public override bool _ParseProperty( diff --git a/addons/forge/editor/cues/CueKeyEditorProperty.cs b/addons/forge/editor/cues/CueKeyEditorProperty.cs index 8681f14c..a8c151e1 100644 --- a/addons/forge/editor/cues/CueKeyEditorProperty.cs +++ b/addons/forge/editor/cues/CueKeyEditorProperty.cs @@ -47,7 +47,7 @@ public partial class CueKeyEditorProperty : EditorProperty AddChild(popup); - ForgeData pluginData = ResourceLoader.Load("uid://8j4xg16o3qnl"); + ForgeData pluginData = ResourceLoader.Load(ForgeData.ForgeDataResourcePath); var tagsManager = new TagsManager([.. pluginData.RegisteredTags]); TreeItem root = tree.CreateItem(); BuildTreeRecursively(tree, root, tagsManager.RootNode); diff --git a/addons/forge/editor/statescript/CustomNodeEditor.cs b/addons/forge/editor/statescript/CustomNodeEditor.cs new file mode 100644 index 00000000..794a4358 --- /dev/null +++ b/addons/forge/editor/statescript/CustomNodeEditor.cs @@ -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; + +/// +/// Base class for custom node property editors. Implementations override the default input-property / output-variable +/// sections rendered by for specific node types. Analogous to Godot's +/// EditorInspectorPlugin pattern. +/// +/// +/// +/// If a is registered for a node's RuntimeTypeName, its +/// 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. +/// +/// +/// Because this class extends , signal handlers defined on subclasses can be connected +/// directly to Godot signals (e.g. dropdown.ItemSelected += OnItemSelected) without needing wrapper nodes +/// or workarounds for serialization. +/// +/// +[Tool] +internal abstract partial class CustomNodeEditor : RefCounted +{ + private StatescriptGraphNode? _graphNode; + private StatescriptGraph? _graph; + private StatescriptNode? _nodeResource; + private Dictionary? _activeResolverEditors; + + /// + /// Gets the runtime type name this editor handles (e.g., + /// "Gamesmiths.Forge.Statescript.Nodes.Action.SetVariableNode"). + /// + public abstract string HandledRuntimeTypeName { get; } + + /// + /// Builds the custom input-property and output-variable sections for the node. + /// + /// Discovered metadata about the node type. + public abstract void BuildPropertySections(StatescriptNodeDiscovery.NodeTypeInfo typeInfo); + + /// + /// Gets the input property section color. + /// + protected static Color InputPropertyColor { get; } = new(0x61afefff); + + /// + /// Gets the output variable section color. + /// + protected static Color OutputVariableColor { get; } = new(0xe5c07bff); + + /// + /// Gets the active resolver editors dictionary. + /// + protected Dictionary ActiveResolverEditors => _activeResolverEditors!; + + /// + /// Gets the owning graph resource. + /// + protected StatescriptGraph Graph => _graph!; + + /// + /// Gets the node resource. + /// + protected StatescriptNode NodeResource => _nodeResource!; + + /// + /// Gets the undo/redo manager, if available. + /// + protected EditorUndoRedoManager? UndoRedo => _graphNode?.GetUndoRedo(); + + /// + /// Stores references needed by helper methods. Called once after the instance is created. + /// + /// The graph node this editor is bound to. + /// The graph resource this node belongs to. + /// The node resource being edited. + /// A dictionary of active resolver editors. + internal void Bind( + StatescriptGraphNode graphNode, + StatescriptGraph graph, + StatescriptNode nodeResource, + Dictionary activeResolverEditors) + { + _graphNode = graphNode; + _graph = graph; + _nodeResource = nodeResource; + _activeResolverEditors = activeResolverEditors; + } + + /// + /// Clears all references stored by . Called before the owning graph node is freed or serialized + /// to prevent accessing disposed objects. + /// + internal virtual void Unbind() + { + _graphNode = null; + _graph = null; + _nodeResource = null; + _activeResolverEditors = null; + } + + /// + /// Clears all children from a container control. + /// + /// The container control to clear. + protected static void ClearContainer(Control container) + { + foreach (Node child in container.GetChildren()) + { + container.RemoveChild(child); + child.Free(); + } + } + + /// + /// Adds a foldable section divider to the graph node. + /// + /// Title displayed on the divider. + /// Color of the divider. + /// Key used to persist the fold state. + /// Initial fold state. + protected FoldableContainer AddPropertySectionDivider( + string sectionTitle, + Color color, + string foldKey, + bool folded) + { + return _graphNode!.AddPropertySectionDividerInternal(sectionTitle, color, foldKey, folded); + } + + /// + /// Renders a standard input-property row (resolver dropdown + editor UI). + /// + /// Metadata about the input property. + /// Index of the input property. + /// Container to add the input property row to. + protected void AddInputPropertyRow( + StatescriptNodeDiscovery.InputPropertyInfo propInfo, + int index, + Control container) + { + _graphNode!.AddInputPropertyRowInternal(propInfo, index, container); + } + + /// + /// Renders a standard output-variable row (variable dropdown). + /// + /// Metadata about the output variable. + /// Index of the output variable. + /// Container to add the output variable row to. + protected void AddOutputVariableRow( + StatescriptNodeDiscovery.OutputVariableInfo varInfo, + int index, + FoldableContainer container) + { + _graphNode!.AddOutputVariableRowInternal(varInfo, index, container); + } + + /// + /// Gets the persisted fold state for a given key. + /// + /// The key used to persist the fold state. + protected bool GetFoldState(string key) + { + return _graphNode!.GetFoldStateInternal(key); + } + + /// + /// Finds an existing property binding by direction and index. + /// + /// The direction of the property (input or output). + /// The index of the property. + protected StatescriptNodeProperty? FindBinding( + StatescriptPropertyDirection direction, + int propertyIndex) + { + return _graphNode!.FindBindingInternal(direction, propertyIndex); + } + + /// + /// Ensures a property binding exists for the given direction and index, creating one if needed. + /// + /// The direction of the property (input or output). + /// The index of the property. + protected StatescriptNodeProperty EnsureBinding( + StatescriptPropertyDirection direction, + int propertyIndex) + { + return _graphNode!.EnsureBindingInternal(direction, propertyIndex); + } + + /// + /// Removes a property binding by direction and index. + /// + /// The direction of the property (input or output). + /// The index of the property. + protected void RemoveBinding( + StatescriptPropertyDirection direction, + int propertyIndex) + { + _graphNode!.RemoveBindingInternal(direction, propertyIndex); + } + + /// + /// Shows a resolver editor inside the given container. + /// + /// A factory function to create the resolver editor. + /// The existing binding, if any. + /// The expected type for the resolver editor. + /// The container to add the resolver editor to. + /// The direction of the property (input or output). + /// The index of the property. + /// Whether the input expects an array of values. + protected void ShowResolverEditorUI( + Func factory, + StatescriptNodeProperty? existingBinding, + Type expectedType, + VBoxContainer container, + StatescriptPropertyDirection direction, + int propertyIndex, + bool isArray = false) + { + _graphNode!.ShowResolverEditorUIInternal( + factory, + existingBinding, + expectedType, + container, + direction, + propertyIndex, + isArray); + } + + /// + /// Requests the owning graph node to recalculate its size. + /// + protected void ResetSize() + { + _graphNode!.ResetSize(); + } + + /// + /// Raises the event. + /// + protected void RaisePropertyBindingChanged() + { + _graphNode!.RaisePropertyBindingChangedInternal(); + } + + /// + /// Records an undo/redo action for changing a resolver binding, then rebuilds the node. + /// + /// The direction of the property. + /// The index of the property. + /// The previous resolver resource. + /// The new resolver resource. + /// The name for the undo/redo action. + protected void RecordResolverBindingChange( + StatescriptPropertyDirection direction, + int propertyIndex, + StatescriptResolverResource? oldResolver, + StatescriptResolverResource? newResolver, + string actionName = "Change Node Property") + { + _graphNode!.RecordResolverBindingChangeInternal( + direction, + propertyIndex, + oldResolver, + newResolver, + actionName); + } +} +#endif diff --git a/addons/forge/editor/statescript/CustomNodeEditor.cs.uid b/addons/forge/editor/statescript/CustomNodeEditor.cs.uid new file mode 100644 index 00000000..a1d111b1 --- /dev/null +++ b/addons/forge/editor/statescript/CustomNodeEditor.cs.uid @@ -0,0 +1 @@ +uid://f47pprjqcskr diff --git a/addons/forge/editor/statescript/CustomNodeEditorRegistry.cs b/addons/forge/editor/statescript/CustomNodeEditorRegistry.cs new file mode 100644 index 00000000..ad392106 --- /dev/null +++ b/addons/forge/editor/statescript/CustomNodeEditorRegistry.cs @@ -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; + +/// +/// Registry of implementations. Custom node editors are discovered automatically via +/// reflection. Any concrete subclass of in the executing assembly is registered and +/// overrides the default property rendering for its handled node type. +/// +internal static class CustomNodeEditorRegistry +{ + private static readonly Dictionary> _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)!; + } + } + + /// + /// Tries to create a new custom node editor for the given runtime type name. + /// + /// The runtime type name of the node. + /// The newly created editor, or if none is registered. + /// if a custom editor was created. + public static bool TryCreate(string runtimeTypeName, [NotNullWhen(true)] out CustomNodeEditor? editor) + { + if (_factories.TryGetValue(runtimeTypeName, out Func? factory)) + { + editor = factory(); + return true; + } + + editor = null; + return false; + } +} +#endif diff --git a/addons/forge/editor/statescript/CustomNodeEditorRegistry.cs.uid b/addons/forge/editor/statescript/CustomNodeEditorRegistry.cs.uid new file mode 100644 index 00000000..f3feb9d3 --- /dev/null +++ b/addons/forge/editor/statescript/CustomNodeEditorRegistry.cs.uid @@ -0,0 +1 @@ +uid://dk4rjrm6ky3rd diff --git a/addons/forge/editor/statescript/NodeEditorProperty.cs b/addons/forge/editor/statescript/NodeEditorProperty.cs new file mode 100644 index 00000000..f98ad087 --- /dev/null +++ b/addons/forge/editor/statescript/NodeEditorProperty.cs @@ -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; + +/// +/// Base class for all Statescript property resolver editor controls. Extends so it can be +/// added directly to the graph node UI and participates in the Godot scene tree (lifecycle, disposal, etc.). +/// +[Tool] +internal abstract partial class NodeEditorProperty : PanelContainer +{ + /// + /// Gets the display name shown in the resolver type dropdown (e.g., "Variable", "Constant", "Attribute"). + /// + public abstract string DisplayName { get; } + + /// + /// Gets the resolver type identifier string used for matching against serialized resources. + /// + public abstract string ResolverTypeId { get; } + + /// + /// Checks whether this resolver is compatible with the given expected type. + /// + /// The type expected by the node's input property. + /// if this resolver can provide a value of the expected type. + public abstract bool IsCompatibleWith(Type expectedType); + + /// + /// Initializes the resolver editor UI. Called once after the control is created. + /// + /// The current graph resource (for accessing variables, etc.). + /// The existing property binding to restore state from, or null for a new binding. + /// The type expected by the node's input property. + /// Callback invoked when the resolver configuration changes. + /// Whether the input expects an array of values. + public abstract void Setup( + StatescriptGraph graph, + StatescriptNodeProperty? property, + Type expectedType, + Action onChanged, + bool isArray); + + /// + /// Writes the current resolver configuration to the given property binding resource. + /// + /// The property binding to write to. + public abstract void SaveTo(StatescriptNodeProperty property); + + /// + /// Raised when the editor's layout size has changed (e.g. nested resolver swap, foldable toggle) so that the owning + /// can call . + /// + public event Action? LayoutSizeChanged; + + /// + /// Clears all delegate fields to prevent serialization issues during hot-reload. Called before the editor is + /// serialized or freed. + /// + public virtual void ClearCallbacks() + { + LayoutSizeChanged = null; + } + + /// + /// Notifies listeners that the editor layout has changed size. + /// + protected void RaiseLayoutSizeChanged() + { + LayoutSizeChanged?.Invoke(); + } +} +#endif diff --git a/addons/forge/editor/statescript/NodeEditorProperty.cs.uid b/addons/forge/editor/statescript/NodeEditorProperty.cs.uid new file mode 100644 index 00000000..869fcc28 --- /dev/null +++ b/addons/forge/editor/statescript/NodeEditorProperty.cs.uid @@ -0,0 +1 @@ +uid://djb18x1m1rukn diff --git a/addons/forge/editor/statescript/SharedVariableSetEditorProperty.cs b/addons/forge/editor/statescript/SharedVariableSetEditorProperty.cs new file mode 100644 index 00000000..e67c1142 --- /dev/null +++ b/addons/forge/editor/statescript/SharedVariableSetEditorProperty.cs @@ -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; + +/// +/// Custom that renders the array using the +/// same polished value-editor controls as the graph variable panel. +/// +[Tool] +internal sealed partial class SharedVariableSetEditorProperty : EditorProperty, ISerializationListener +{ + private static readonly Color _variableColor = new(0xe5c07bff); + + private readonly HashSet _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; + + /// + /// Sets the used for undo/redo support. + /// + /// The undo/redo manager from the editor plugin. + 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 GetDefinitions() + { + GodotObject obj = GetEditedObject(); + string propertyName = GetEditedProperty(); + Variant value = obj.Get(propertyName); + + return value.AsGodotArray() ?? []; + } + + 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 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 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 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 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 definitions, ForgeSharedVariableDefinition def) + { + definitions.Add(def); + NotifyChanged(); + RebuildList(); + } + + private void UndoAddVariable(Array definitions, ForgeSharedVariableDefinition def) + { + definitions.Remove(def); + NotifyChanged(); + RebuildList(); + } + + private void DoRemoveVariable( + Array definitions, + int index) + { + definitions.RemoveAt(index); + NotifyChanged(); + RebuildList(); + } + + private void UndoRemoveVariable( + Array 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 diff --git a/addons/forge/editor/statescript/SharedVariableSetEditorProperty.cs.uid b/addons/forge/editor/statescript/SharedVariableSetEditorProperty.cs.uid new file mode 100644 index 00000000..da061d38 --- /dev/null +++ b/addons/forge/editor/statescript/SharedVariableSetEditorProperty.cs.uid @@ -0,0 +1 @@ +uid://co05oybb4l5fp diff --git a/addons/forge/editor/statescript/SharedVariableSetInspectorPlugin.cs b/addons/forge/editor/statescript/SharedVariableSetInspectorPlugin.cs new file mode 100644 index 00000000..6dd51955 --- /dev/null +++ b/addons/forge/editor/statescript/SharedVariableSetInspectorPlugin.cs @@ -0,0 +1,53 @@ +// Copyright © Gamesmiths Guild. + +#if TOOLS +using Gamesmiths.Forge.Godot.Resources; +using Godot; + +namespace Gamesmiths.Forge.Godot.Editor.Statescript; + +/// +/// Inspector plugin that replaces the default array editor with a +/// polished UI matching the graph variable panel style. +/// +public partial class SharedVariableSetInspectorPlugin : EditorInspectorPlugin +{ + private EditorUndoRedoManager? _undoRedo; + + /// + /// Sets the used for undo/redo support. + /// + /// The undo/redo manager from the editor plugin. + public void SetUndoRedo(EditorUndoRedoManager undoRedo) + { + _undoRedo = undoRedo; + } + + /// + public override bool _CanHandle(GodotObject @object) + { + return @object is ForgeSharedVariableSet; + } + + /// + 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 diff --git a/addons/forge/editor/statescript/SharedVariableSetInspectorPlugin.cs.uid b/addons/forge/editor/statescript/SharedVariableSetInspectorPlugin.cs.uid new file mode 100644 index 00000000..8b9b769a --- /dev/null +++ b/addons/forge/editor/statescript/SharedVariableSetInspectorPlugin.cs.uid @@ -0,0 +1 @@ +uid://bi7wqecgc87xl diff --git a/addons/forge/editor/statescript/StatescriptAddNodeDialog.cs b/addons/forge/editor/statescript/StatescriptAddNodeDialog.cs new file mode 100644 index 00000000..fec130c4 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptAddNodeDialog.cs @@ -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; + +/// +/// A popup dialog for adding Statescript nodes to a graph. Features a search bar, categorized tree view, description +/// panel, and Create/Cancel buttons. +/// +[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; + + /// + /// Raised when the user confirms node creation. The first argument is the selected + /// (null for Exit node), the second is the + /// , and the third is the graph-local position to place the node. + /// + public event Action? NodeCreationRequested; + + /// + /// Gets or sets the graph-local position where the new node should be placed. + /// + 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(); + } + + /// + /// Shows the dialog at the specified screen position, resets search and selection state. + /// + /// The graph-local position where the node should be created. + /// The screen position to show the dialog at. + 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 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 diff --git a/addons/forge/editor/statescript/StatescriptAddNodeDialog.cs.uid b/addons/forge/editor/statescript/StatescriptAddNodeDialog.cs.uid new file mode 100644 index 00000000..e2f3db56 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptAddNodeDialog.cs.uid @@ -0,0 +1 @@ +uid://dcwnu7ebs2h1c diff --git a/addons/forge/editor/statescript/StatescriptEditorControls.SignalHandlers.cs b/addons/forge/editor/statescript/StatescriptEditorControls.SignalHandlers.cs new file mode 100644 index 00000000..adcc3670 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptEditorControls.SignalHandlers.cs @@ -0,0 +1,157 @@ +// Copyright © Gamesmiths Guild. + +#if TOOLS +using System; +using Godot; + +namespace Gamesmiths.Forge.Godot.Editor.Statescript; + +/// +/// Signal handler helpers used by to avoid lambdas on Godot signals. +/// Each handler is a so it can be parented to its owning control and freed automatically. +/// +internal static partial class StatescriptEditorControls +{ + /// + /// Handles for boolean editors, forwarding to an . + /// + [Tool] + internal sealed partial class BoolSignalHandler : Node + { + public Action? OnChanged { get; set; } + + public void HandleToggled(bool pressed) + { + OnChanged?.Invoke(pressed); + } + } + + /// + /// Handles signals (ValueChanged, Grabbed, Ungrabbed, + /// FocusExited) for numeric editors with drag-commit semantics. + /// + [Tool] + internal sealed partial class NumericSpinHandler : Node + { + private readonly EditorSpinSlider _spin; + private bool _isDragging; + + public Action? 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; + } + } + + /// + /// Holds the shared state (values array, drag flag, callback) for a multi-component vector editor. + /// + [Tool] + internal sealed partial class VectorComponentHandler : Node + { + private readonly double[] _values; + + public Action? 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); + } + } + + /// + /// Handles signals for a single component of a vector editor. + /// Forwards to the shared . + /// + [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 diff --git a/addons/forge/editor/statescript/StatescriptEditorControls.SignalHandlers.cs.uid b/addons/forge/editor/statescript/StatescriptEditorControls.SignalHandlers.cs.uid new file mode 100644 index 00000000..0581a268 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptEditorControls.SignalHandlers.cs.uid @@ -0,0 +1 @@ +uid://cssljh632gdln diff --git a/addons/forge/editor/statescript/StatescriptEditorControls.cs b/addons/forge/editor/statescript/StatescriptEditorControls.cs new file mode 100644 index 00000000..28b45abe --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptEditorControls.cs @@ -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; + +/// +/// Shared factory methods for creating value-editor controls used by both the variable panel and resolver editors. +/// +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; + + /// + /// Returns for integer-like variable types. + /// + /// The variable type to check. + 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; + } + + /// + /// Returns for floating-point variable types. + /// + /// The variable type to check. + public static bool IsFloatType(StatescriptVariableType type) + { + return type is StatescriptVariableType.Float or StatescriptVariableType.Double + or StatescriptVariableType.Decimal; + } + + /// + /// Returns for multi-component vector/quaternion/plane variable types. + /// + /// The variable type to check. + public static bool IsVectorType(StatescriptVariableType type) + { + return type is StatescriptVariableType.Vector2 or StatescriptVariableType.Vector3 + or StatescriptVariableType.Vector4 or StatescriptVariableType.Plane + or StatescriptVariableType.Quaternion; + } + + /// + /// Creates a wrapping a for boolean editing. + /// + /// The initial value of the boolean. + /// An action invoked on value change. + /// A containing a . + public static PanelContainer CreateBoolEditor(bool value, Action 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; + } + + /// + /// Creates an configured for the given numeric variable type. + /// + /// The type of the numeric variable. + /// The initial value of the numeric variable. + /// An action invoked on value change. + /// An configured for the specified numeric variable type. + public static EditorSpinSlider CreateNumericSpinSlider( + StatescriptVariableType type, + double value, + Action? 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; + } + + /// + /// Creates a panel with a row of labelled controls for editing a vector value. + /// + /// The type of the vector/quaternion/plane. + /// A function to retrieve the value of a specific component. + /// An action to invoke when any component value changes. + /// A containing the vector editor controls. + public static VBoxContainer CreateVectorEditor( + StatescriptVariableType type, + Func getComponent, + Action? 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; + } + + /// + /// Reads a single component from a vector/quaternion/plane variant. + /// + /// The variant containing the vector/quaternion/plane value. + /// The type of the vector/quaternion/plane. + /// The index of the component to retrieve. + /// Exception thrown if the provided type is not a vector/quaternion/plane + /// type. + 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, + }; + } + + /// + /// Builds a Godot from a component array for the given vector/quaternion/plane type. + /// + /// The type of the vector/quaternion/plane. + /// The array of component values. + /// A representing the vector/quaternion/plane. + /// Exception thrown if the provided type is not a vector/quaternion/plane + /// type. + 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 diff --git a/addons/forge/editor/statescript/StatescriptEditorControls.cs.uid b/addons/forge/editor/statescript/StatescriptEditorControls.cs.uid new file mode 100644 index 00000000..a1bf3272 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptEditorControls.cs.uid @@ -0,0 +1 @@ +uid://dpoji1y5vib4o diff --git a/addons/forge/editor/statescript/StatescriptGraphEditorDock.DialogHandlers.cs b/addons/forge/editor/statescript/StatescriptGraphEditorDock.DialogHandlers.cs new file mode 100644 index 00000000..2f966d22 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphEditorDock.DialogHandlers.cs @@ -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(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(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 diff --git a/addons/forge/editor/statescript/StatescriptGraphEditorDock.DialogHandlers.cs.uid b/addons/forge/editor/statescript/StatescriptGraphEditorDock.DialogHandlers.cs.uid new file mode 100644 index 00000000..2b253caf --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphEditorDock.DialogHandlers.cs.uid @@ -0,0 +1 @@ +uid://drxix8xbwpfin diff --git a/addons/forge/editor/statescript/StatescriptGraphEditorDock.GraphOperations.cs b/addons/forge/editor/statescript/StatescriptGraphEditorDock.GraphOperations.cs new file mode 100644 index 00000000..f63cb3a7 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphEditorDock.GraphOperations.cs @@ -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 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(); + 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(affectedConnections)); + _undoRedo.AddUndoMethod( + this, + MethodName.UndoDeleteNode, + graph, + graphNode.NodeResource, + new GodotCollections.Array(affectedConnections)); + _undoRedo.CommitAction(); + } + else + { + DoDeleteNode( + graph, + graphNode.NodeResource, + [.. affectedConnections]); + } + } + } + + private void DoDeleteNode( + StatescriptGraph graph, + StatescriptNode nodeResource, + GodotCollections.Array 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 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(); + var oldPositions = new GodotCollections.Dictionary(); + + 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 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(); + + 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(); + 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 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 diff --git a/addons/forge/editor/statescript/StatescriptGraphEditorDock.GraphOperations.cs.uid b/addons/forge/editor/statescript/StatescriptGraphEditorDock.GraphOperations.cs.uid new file mode 100644 index 00000000..634d79ff --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphEditorDock.GraphOperations.cs.uid @@ -0,0 +1 @@ +uid://c0pse6qnrsdg0 diff --git a/addons/forge/editor/statescript/StatescriptGraphEditorDock.cs b/addons/forge/editor/statescript/StatescriptGraphEditorDock.cs new file mode 100644 index 00000000..68598c09 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphEditorDock.cs @@ -0,0 +1,1420 @@ +// Copyright © Gamesmiths Guild. + +#if TOOLS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Gamesmiths.Forge.Godot.Resources.Statescript; +using Godot; +using GodotCollections = Godot.Collections; + +namespace Gamesmiths.Forge.Godot.Editor.Statescript; + +/// +/// Main editor panel for Statescript graphs. Supports editing multiple graphs via tabs. +/// Designed to be shown in the bottom panel area of the Godot editor. +/// +[Tool] +public partial class StatescriptGraphEditorDock : EditorDock, ISerializationListener +{ + private readonly List _openTabs = []; + private readonly Dictionary _preMovePositions = []; + + private PanelContainer? _tabBarBackground; + private TabBar? _tabBar; + private PanelContainer? _contentPanel; + private GraphEdit? _graphEdit; + private Label? _emptyLabel; + private Button? _addNodeButton; + private StatescriptAddNodeDialog? _addNodeDialog; + private StatescriptVariablePanel? _variablePanel; + private HSplitContainer? _splitContainer; + + private MenuButton? _fileMenuButton; + private PopupMenu? _fileMenuPopup; + private Button? _variablesToggleButton; + private Button? _onlineDocsButton; + + private AcceptDialog? _newStatescriptDialog; + private LineEdit? _newStatescriptPathEdit; + + private EditorUndoRedoManager? _undoRedo; + + private int _nextNodeId; + private bool _isLoadingGraph; + + private string? _pendingConnectionNode; + private int _pendingConnectionPort; + private bool _pendingConnectionIsOutput; + + private EditorFileSystem? _fileSystem; + private Callable _filesystemChangedCallable; + + private string[]? _serializedTabPaths; + private int _serializedActiveTab = -1; + private bool[]? _serializedVariablesStates; + private string[]? _serializedConnections; + private int[]? _serializedConnectionCounts; + + /// + /// Gets the currently active graph resource, if any. + /// + public StatescriptGraph? CurrentGraph => + _openTabs.Count > 0 && _tabBar is not null && _tabBar.CurrentTab < _openTabs.Count + ? _openTabs[_tabBar.CurrentTab].GraphResource + : null; + + public StatescriptGraphEditorDock() + { + Title = "Statescript"; + DefaultSlot = DockSlot.Bottom; + DockIcon = GD.Load("uid://b6yrjb46fluw3"); + + AvailableLayouts = DockLayout.Horizontal | DockLayout.Floating; + } + + public override void _Ready() + { + base._Ready(); + + StyleBox bottomPanelStyleBox = EditorInterface.Singleton.GetBaseControl() + .GetThemeStylebox("BottomPanel", "EditorStyles"); + + AddThemeConstantOverride("margin_top", -(int)bottomPanelStyleBox.ContentMarginTop); + AddThemeConstantOverride("margin_left", -(int)bottomPanelStyleBox.ContentMarginLeft); + AddThemeConstantOverride("margin_right", -(int)bottomPanelStyleBox.ContentMarginRight); + + BuildUI(); + UpdateVisibility(); + + _fileSystem = EditorInterface.Singleton.GetResourceFilesystem(); + _filesystemChangedCallable = new Callable(this, nameof(OnFilesystemChanged)); + + _fileSystem.Connect(EditorFileSystem.SignalName.ResourcesReimported, _filesystemChangedCallable); + } + + public override void _ExitTree() + { + base._ExitTree(); + + ClearGraphEditor(); + _openTabs.Clear(); + + if (_fileSystem?.IsConnected(EditorFileSystem.SignalName.ResourcesReimported, _filesystemChangedCallable) + == true) + { + _fileSystem.Disconnect(EditorFileSystem.SignalName.ResourcesReimported, _filesystemChangedCallable); + } + + DisconnectUISignals(); + } + + public void OnBeforeSerialize() + { + if (_fileSystem?.IsConnected(EditorFileSystem.SignalName.ResourcesReimported, _filesystemChangedCallable) + == true) + { + _fileSystem.Disconnect(EditorFileSystem.SignalName.ResourcesReimported, _filesystemChangedCallable); + } + + _serializedTabPaths = GetOpenResourcePaths(); + _serializedActiveTab = GetActiveTabIndex(); + _serializedVariablesStates = GetVariablesPanelStates(); + + SyncVisualNodePositionsToGraph(); + SyncConnectionsToCurrentGraph(); + + if (CurrentGraph is not null && _graphEdit is not null) + { + CurrentGraph.ScrollOffset = _graphEdit.ScrollOffset; + CurrentGraph.Zoom = _graphEdit.Zoom; + } + + var allConnections = new List(); + _serializedConnectionCounts = new int[_openTabs.Count]; + for (var i = 0; i < _openTabs.Count; i++) + { + StatescriptGraph graph = _openTabs[i].GraphResource; + var count = 0; + foreach (StatescriptConnection c in graph.Connections) + { + allConnections.Add($"{c.FromNode},{c.OutputPort},{c.ToNode},{c.InputPort}"); + count++; + } + + _serializedConnectionCounts[i] = count; + } + + _serializedConnections = [.. allConnections]; + + DisconnectUISignals(); + ClearGraphEditor(); + + if (_tabBar is not null) + { + while (_tabBar.GetTabCount() > 0) + { + _tabBar.RemoveTab(0); + } + } + + _openTabs.Clear(); + } + + public void OnAfterDeserialize() + { + _filesystemChangedCallable = new Callable(this, nameof(OnFilesystemChanged)); + + if (_fileSystem?. + IsConnected(EditorFileSystem.SignalName.ResourcesReimported, _filesystemChangedCallable) == false) + { + _fileSystem.Connect(EditorFileSystem.SignalName.ResourcesReimported, _filesystemChangedCallable); + } + + ConnectUISignals(); + + if (_serializedTabPaths?.Length > 0) + { + _ = RestoreTabsDeferred(); + } + } + + public override void _Notification(int what) + { + base._Notification(what); + + if (what == NotificationThemeChanged) + { + UpdateTheme(); + } + } + + /// + /// Sets the used for undo/redo support. + /// + /// The undo/redo manager from the editor plugin. + public void SetUndoRedo(EditorUndoRedoManager undoRedo) + { + _undoRedo = undoRedo; + } + + /// + /// Opens a graph resource for editing. If already open, switches to its tab. + /// + /// The graph resource to edit. + public void OpenGraph(StatescriptGraph graph) + { + if (_tabBar is null || _graphEdit is null) + { + return; + } + + for (var i = 0; i < _openTabs.Count; i++) + { + if (_openTabs[i].GraphResource == graph || (!string.IsNullOrEmpty(graph.ResourcePath) + && _openTabs[i].ResourcePath == graph.ResourcePath)) + { + _tabBar.CurrentTab = i; + return; + } + } + + graph.EnsureEntryNode(); + + var tab = new GraphTab(graph); + _openTabs.Add(tab); + + _tabBar.AddTab(graph.StatescriptName); + _tabBar.CurrentTab = _openTabs.Count - 1; + + LoadGraphIntoEditor(graph); + UpdateVisibility(); + } + + /// + /// Closes the currently active graph tab. + /// + public void CloseCurrentTab() + { + if (_tabBar is null || _openTabs.Count == 0) + { + return; + } + + var currentTab = _tabBar.CurrentTab; + if (currentTab < 0 || currentTab >= _openTabs.Count) + { + return; + } + + CloseTabByIndex(currentTab); + } + + /// + /// Returns the resource paths of all open tabs for state persistence. + /// + /// An array of resource paths. + public string[] GetOpenResourcePaths() + { + return [.. _openTabs.Select(x => x.ResourcePath)]; + } + + /// + /// Returns the currently active tab index. + /// + /// The active tab index, or -1 if no tabs are open. + public int GetActiveTabIndex() + { + return _tabBar?.CurrentTab ?? -1; + } + + /// + /// Returns per-tab variables panel visibility states. + /// + /// for tabs with the variables panel open, otherwise. + /// + public bool[] GetVariablesPanelStates() + { + return [.. _openTabs.Select(x => x.VariablesPanelOpen)]; + } + + /// + /// Saves all open graphs that have a resource path. Called by the plugin's _SaveExternalData + /// so that Ctrl+S persists statescript graphs alongside scenes. + /// + public void SaveAllOpenGraphs() + { + if (_graphEdit is null) + { + return; + } + + SyncVisualNodePositionsToGraph(); + SyncConnectionsToCurrentGraph(); + + if (CurrentGraph is not null) + { + CurrentGraph.ScrollOffset = _graphEdit.ScrollOffset; + CurrentGraph.Zoom = _graphEdit.Zoom; + } + + foreach (StatescriptGraph graph in _openTabs.Select(x => x.GraphResource)) + { + if (string.IsNullOrEmpty(graph.ResourcePath)) + { + continue; + } + + SaveGraphResource(graph); + } + } + + /// + /// Restores tabs from paths and active index, used by EditorPlugin _SetWindowLayout. + /// + /// The resource paths of the tabs to restore. + /// The index of the tab to make active. + /// The visibility states of the variables panel for each tab. + public void RestoreFromPaths(string[] paths, int activeIndex, bool[]? variablesStates = null) + { + if (_tabBar is null || _graphEdit is null) + { + return; + } + + _isLoadingGraph = true; + + _openTabs.Clear(); + while (_tabBar.GetTabCount() > 0) + { + _tabBar.RemoveTab(0); + } + + var skippedTabs = 0; + for (var i = 0; i < paths.Length; i++) + { + var path = paths[i]; + + StatescriptGraph? graph = LoadGraphFromPath(path); + if (graph is null) + { + skippedTabs++; + continue; + } + + graph.EnsureEntryNode(); + var tab = new GraphTab(graph); + + var currentTab = i - skippedTabs; + if (variablesStates is not null && currentTab < variablesStates.Length) + { + tab.VariablesPanelOpen = variablesStates[currentTab]; + } + + _openTabs.Add(tab); + _tabBar.AddTab(graph.StatescriptName); + } + + _isLoadingGraph = false; + + if (activeIndex >= 0 && activeIndex < _openTabs.Count) + { + _tabBar.CurrentTab = activeIndex; + LoadGraphIntoEditor(_openTabs[activeIndex].GraphResource); + ApplyVariablesPanelState(activeIndex); + } + + UpdateVisibility(); + } + + private static void SyncNodePositionsToResource( + StatescriptGraph graph, + GodotCollections.Dictionary positions) + { + foreach (StatescriptNode node in graph.Nodes) + { + if (positions.TryGetValue(node.NodeId, out Vector2 pos)) + { + node.PositionOffset = pos; + } + } + } + + private static void OnOnlineDocsPressed() + { + OS.ShellOpen("https://github.com/gamesmiths-guild/forge-godot/tree/main/docs"); + } + + private static string GetBaseFilePath(string resourcePath) + { + var separatorIndex = resourcePath.IndexOf("::", StringComparison.Ordinal); + return separatorIndex >= 0 ? resourcePath[..separatorIndex] : resourcePath; + } + + private static bool IsSubResourcePath(string resourcePath) + { + return resourcePath.Contains("::", StringComparison.Ordinal); + } + + private static StatescriptGraph? LoadGraphFromPath(string path) + { + if (IsSubResourcePath(path)) + { + var basePath = GetBaseFilePath(path); + if (!ResourceLoader.Exists(basePath)) + { + return null; + } + + Resource? parentResource = ResourceLoader.Load(basePath); + if (parentResource is null) + { + return null; + } + + return FindSubResourceGraph(parentResource, path); + } + + if (!ResourceLoader.Exists(path)) + { + return null; + } + + return ResourceLoader.Load(path); + } + + private static StatescriptGraph? FindSubResourceGraph(Resource parentResource, string subResourcePath) + { + foreach (var propertyName in parentResource.GetPropertyList() + .Select(p => p["name"].AsString())) + { + Variant value = parentResource.Get(propertyName); + if (value.Obj is StatescriptGraph graph && graph.ResourcePath == subResourcePath) + { + return graph; + } + + if (value.Obj is Resource nestedResource) + { + StatescriptGraph? found = FindSubResourceInNested(nestedResource, subResourcePath); + if (found is not null) + { + return found; + } + } + } + + return null; + } + + private static StatescriptGraph? FindSubResourceInNested(Resource resource, string subResourcePath) + { + if (resource is StatescriptGraph graph && graph.ResourcePath == subResourcePath) + { + return graph; + } + + foreach (var propertyName in resource.GetPropertyList() + .Select(p => p["name"].AsString())) + { + Variant value = resource.Get(propertyName); + if (value.Obj is StatescriptGraph nestedGraph && nestedGraph.ResourcePath == subResourcePath) + { + return nestedGraph; + } + + if (value.Obj is Resource nestedResource && nestedResource != resource) + { + StatescriptGraph? found = FindSubResourceInNested(nestedResource, subResourcePath); + if (found is not null) + { + return found; + } + } + } + + return null; + } + + private static void SaveGraphResource(StatescriptGraph graph) + { + var path = graph.ResourcePath; + + if (IsSubResourcePath(path)) + { + var basePath = GetBaseFilePath(path); + Resource? parentResource = ResourceLoader.Load(basePath); + if (parentResource is not null) + { + ResourceSaver.Save(parentResource); + } + } + else + { + ResourceSaver.Save(graph); + } + } + + private async Task RestoreTabsDeferred() + { + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); + + if (_serializedTabPaths is null || _serializedTabPaths.Length == 0) + { + return; + } + + var paths = _serializedTabPaths; + var activeTab = _serializedActiveTab; + var varStates = _serializedVariablesStates; + var savedConnections = _serializedConnections; + var connectionCounts = _serializedConnectionCounts; + + _serializedTabPaths = null; + _serializedActiveTab = -1; + _serializedVariablesStates = null; + _serializedConnections = null; + _serializedConnectionCounts = null; + + if (_tabBar is null || _graphEdit is null) + { + return; + } + + _isLoadingGraph = true; + + _openTabs.Clear(); + while (_tabBar.GetTabCount() > 0) + { + _tabBar.RemoveTab(0); + } + + var skippedTabs = 0; + for (var i = 0; i < paths.Length; i++) + { + if (!ResourceLoader.Exists(paths[i])) + { + skippedTabs++; + continue; + } + + StatescriptGraph? graph = ResourceLoader.Load(paths[i]); + if (graph is null) + { + skippedTabs++; + continue; + } + + graph.EnsureEntryNode(); + var tab = new GraphTab(graph); + + var currentTab = i - skippedTabs; + if (varStates is not null && currentTab < varStates.Length) + { + tab.VariablesPanelOpen = varStates[currentTab]; + } + + _openTabs.Add(tab); + _tabBar.AddTab(graph.StatescriptName); + } + + _isLoadingGraph = false; + + if (savedConnections is not null && connectionCounts is not null) + { + var offset = 0; + for (var i = 0; i < _openTabs.Count && i < connectionCounts.Length; i++) + { + StatescriptGraph graph = _openTabs[i].GraphResource; + graph.Connections.Clear(); + + for (var j = 0; j < connectionCounts[i] && offset < savedConnections.Length; j++, offset++) + { + var parts = savedConnections[offset].Split(','); + if (parts.Length != 4 + || !int.TryParse(parts[1], out var outPort) + || !int.TryParse(parts[3], out var inPort)) + { + continue; + } + + graph.Connections.Add(new StatescriptConnection + { + FromNode = parts[0], + OutputPort = outPort, + ToNode = parts[2], + InputPort = inPort, + }); + } + } + } + + if (activeTab >= 0 && activeTab < _openTabs.Count) + { + _tabBar.CurrentTab = activeTab; + LoadGraphIntoEditor(_openTabs[activeTab].GraphResource); + ApplyVariablesPanelState(activeTab); + } + + UpdateVisibility(); + } + + private void CloseTabByIndex(int tabIndex) + { + if (_tabBar is null || tabIndex < 0 || tabIndex >= _openTabs.Count) + { + return; + } + + _openTabs.RemoveAt(tabIndex); + _tabBar.RemoveTab(tabIndex); + + if (_openTabs.Count > 0) + { + var newTab = Mathf.Min(tabIndex, _openTabs.Count - 1); + _tabBar.CurrentTab = newTab; + LoadGraphIntoEditor(_openTabs[newTab].GraphResource); + ApplyVariablesPanelState(newTab); + } + else + { + ClearGraphEditor(); + } + + UpdateVisibility(); + } + + private void BuildUI() + { + var vBox = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + + AddChild(vBox); + + _tabBarBackground = new PanelContainer(); + vBox.AddChild(_tabBarBackground); + + var tabBarHBox = new HBoxContainer + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + + _tabBarBackground.AddChild(tabBarHBox); + + _tabBar = new TabBar + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + TabCloseDisplayPolicy = TabBar.CloseButtonDisplayPolicy.ShowActiveOnly, + DragToRearrangeEnabled = true, + }; + + _tabBar.TabChanged += OnTabChanged; + _tabBar.TabClosePressed += OnTabClosePressed; + tabBarHBox.AddChild(_tabBar); + + _contentPanel = new PanelContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + + vBox.AddChild(_contentPanel); + + _splitContainer = new HSplitContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + + _contentPanel.AddChild(_splitContainer); + + _graphEdit = new GraphEdit + { + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + ShowZoomLabel = true, + RightDisconnects = true, + GridPattern = GraphEdit.GridPatternEnum.Dots, + }; + + _graphEdit.ConnectionRequest += OnConnectionRequest; + _graphEdit.DisconnectionRequest += OnDisconnectionRequest; + _graphEdit.DeleteNodesRequest += OnDeleteNodesRequest; + _graphEdit.BeginNodeMove += OnBeginNodeMove; + _graphEdit.EndNodeMove += OnEndNodeMove; + _graphEdit.PopupRequest += OnGraphEditPopupRequest; + _graphEdit.ConnectionToEmpty += OnConnectionToEmpty; + _graphEdit.ConnectionFromEmpty += OnConnectionFromEmpty; + _graphEdit.GuiInput += OnGraphEditGuiInput; + _splitContainer.AddChild(_graphEdit); + + _variablePanel = new StatescriptVariablePanel + { + Visible = true, + }; + + _variablePanel.VariablesChanged += OnGraphVariablesChanged; + _variablePanel.VariableUndoRedoPerformed += OnVariableUndoRedoPerformed; + _variablePanel.VariableHighlightChanged += OnVariableHighlightChanged; + _splitContainer.AddChild(_variablePanel); + + if (_undoRedo is not null) + { + _variablePanel.SetUndoRedo(_undoRedo); + } + + HBoxContainer menuHBox = _graphEdit.GetMenuHBox(); + + menuHBox.SizeFlagsHorizontal = SizeFlags.ExpandFill; + var parent = (PanelContainer)menuHBox.GetParent(); + parent.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide, LayoutPresetMode.Minsize, 10); + + _fileMenuButton = new MenuButton + { + Text = "File", + Flat = false, + SwitchOnHover = true, + ThemeTypeVariation = "FlatMenuButton", + }; + + _fileMenuPopup = _fileMenuButton.GetPopup(); +#pragma warning disable RCS1130, S3265 // Bitwise operation on enum without Flags attribute + _fileMenuPopup.AddItem("New Statescript...", 0, Key.N | (Key)KeyModifierMask.MaskCtrl); + _fileMenuPopup.AddItem("Load Statescript File...", 1, Key.O | (Key)KeyModifierMask.MaskCtrl); + _fileMenuPopup.AddSeparator(); + _fileMenuPopup.AddItem("Save", 2, Key.S | (Key)KeyModifierMask.MaskCtrl | (Key)KeyModifierMask.MaskAlt); + _fileMenuPopup.AddItem("Save As...", 3); + _fileMenuPopup.AddSeparator(); + _fileMenuPopup.AddItem("Close", 4, Key.W | (Key)KeyModifierMask.MaskCtrl); +#pragma warning restore RCS1130, S3265 // Bitwise operation on enum without Flags attribute + _fileMenuPopup.IdPressed += OnFileMenuIdPressed; + + menuHBox.AddChild(_fileMenuButton); + menuHBox.MoveChild(_fileMenuButton, 0); + + var separator1 = new VSeparator(); + menuHBox.AddChild(separator1); + menuHBox.MoveChild(separator1, 1); + + _addNodeButton = new Button + { + Text = "Add Node...", + ThemeTypeVariation = "FlatButton", + }; + + _addNodeButton.Pressed += OnAddNodeButtonPressed; + + menuHBox.AddChild(_addNodeButton); + menuHBox.MoveChild(_addNodeButton, 2); + + var separator2 = new VSeparator(); + menuHBox.AddChild(separator2); + + _variablesToggleButton = new Button + { + Text = "Variables", + ToggleMode = true, + ThemeTypeVariation = "FlatButton", + Icon = EditorInterface.Singleton.GetEditorTheme().GetIcon("SubViewport", "EditorIcons"), + }; + + _variablesToggleButton.Toggled += OnVariablesToggled; + menuHBox.AddChild(_variablesToggleButton); + + var spacer = new Control + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + + menuHBox.AddChild(spacer); + + _onlineDocsButton = new Button + { + Text = "Online Docs", + ThemeTypeVariation = "FlatButton", + Icon = EditorInterface.Singleton.GetEditorTheme().GetIcon("ExternalLink", "EditorIcons"), + }; + + _onlineDocsButton.Pressed += OnOnlineDocsPressed; + menuHBox.AddChild(_onlineDocsButton); + + _emptyLabel = new Label + { + Text = "Select a Statescript resource to begin editing.", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + SizeFlagsVertical = SizeFlags.ExpandFill, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + + _emptyLabel.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.6f)); + _contentPanel.AddChild(_emptyLabel); + + _addNodeDialog = new StatescriptAddNodeDialog(); + _addNodeDialog.NodeCreationRequested += OnDialogNodeCreationRequested; + _addNodeDialog.Canceled += OnDialogCanceled; + AddChild(_addNodeDialog); + + UpdateTheme(); + } + + private void UpdateTheme() + { + if (_tabBarBackground is null || _contentPanel is null) + { + return; + } + + Control baseControl = EditorInterface.Singleton.GetBaseControl(); + + StyleBox tabBarStyle = baseControl.GetThemeStylebox("tabbar_background", "TabContainer"); + _tabBarBackground.AddThemeStyleboxOverride("panel", tabBarStyle); + + StyleBox panelStyle = baseControl.GetThemeStylebox("panel", "TabContainer"); + _contentPanel.AddThemeStyleboxOverride("panel", panelStyle); + } + + private void UpdateVisibility() + { + var hasOpenGraph = _openTabs.Count > 0; + + if (_splitContainer is not null) + { + _splitContainer.Visible = hasOpenGraph; + } + + if (_tabBarBackground is not null) + { + _tabBarBackground.Visible = hasOpenGraph; + } + + if (_emptyLabel is not null) + { + _emptyLabel.Visible = !hasOpenGraph; + } + + if (!hasOpenGraph) + { + if (_variablePanel is not null) + { + _variablePanel.Visible = false; + } + + _variablesToggleButton?.SetPressedNoSignal(false); + } + } + + private void LoadGraphIntoEditor(StatescriptGraph graph) + { + if (_graphEdit is null) + { + return; + } + + var wasLoading = _isLoadingGraph; + _isLoadingGraph = true; + + ClearGraphEditor(); + + _graphEdit.Zoom = graph.Zoom; + + UpdateNextNodeId(graph); + + foreach (StatescriptNode nodeResource in graph.Nodes) + { + var graphNode = new StatescriptGraphNode(); + _graphEdit.AddChild(graphNode); + graphNode.Initialize(nodeResource, graph); + graphNode.SetUndoRedo(_undoRedo); + } + + foreach (StatescriptConnection connection in graph.Connections) + { + _graphEdit.ConnectNode( + connection.FromNode, + connection.OutputPort, + connection.ToNode, + connection.InputPort); + } + + _isLoadingGraph = wasLoading; + + _ = ApplyScrollNextFrame(graph.ScrollOffset); + } + + private async Task ApplyScrollNextFrame(Vector2 offset) + { + await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); + + if (_graphEdit is not null) + { + _graphEdit.ScrollOffset = offset; + } + } + + private void ClearGraphEditor() + { + if (_graphEdit is null) + { + return; + } + + _graphEdit.ClearConnections(); + + var toRemove = new List(); + toRemove.AddRange(_graphEdit.GetChildren().Where(x => x is GraphNode)); + + foreach (Node node in toRemove) + { + if (node is StatescriptGraphNode graphNode) + { + graphNode.OnBeforeSerialize(); + } + + _graphEdit.RemoveChild(node); + node.Free(); + } + } + + private void UpdateNextNodeId(StatescriptGraph graph) + { + var maxId = 0; + foreach (var nodeId in graph.Nodes.Select(x => x.NodeId)) + { + if (nodeId.StartsWith("node_", StringComparison.InvariantCultureIgnoreCase) + && int.TryParse(nodeId["node_".Length..], out var id) + && id >= maxId) + { + maxId = id + 1; + } + } + + if (maxId > _nextNodeId) + { + _nextNodeId = maxId; + } + } + + private void RefreshTabTitles() + { + if (_tabBar is null) + { + return; + } + + for (var i = 0; i < _openTabs.Count; i++) + { + _tabBar.SetTabTitle(i, _openTabs[i].GraphResource.StatescriptName); + } + } + + private void SaveGraphStateByIndex(int tabIndex) + { + if (tabIndex < 0 || tabIndex >= _openTabs.Count || _graphEdit is null) + { + return; + } + + StatescriptGraph graph = _openTabs[tabIndex].GraphResource; + + graph.ScrollOffset = _graphEdit.ScrollOffset; + graph.Zoom = _graphEdit.Zoom; + + SyncVisualNodePositionsToGraph(); + SyncConnectionsToGraph(graph); + } + + private void SaveOutgoingTabState(int newTabIndex) + { + if (_graphEdit is null || _openTabs.Count <= 1) + { + return; + } + + StatescriptGraphNode? firstNode = null; + foreach (Node child in _graphEdit.GetChildren()) + { + if (child is StatescriptGraphNode statescriptNode) + { + firstNode = statescriptNode; + break; + } + } + + if (firstNode?.NodeResource is null) + { + return; + } + + for (var i = 0; i < _openTabs.Count; i++) + { + if (i == newTabIndex) + { + continue; + } + + StatescriptGraph graph = _openTabs[i].GraphResource; + foreach (StatescriptNode node in graph.Nodes) + { + if (node == firstNode.NodeResource) + { + SaveGraphStateByIndex(i); + + if (_variablePanel is not null) + { + _openTabs[i].VariablesPanelOpen = _variablePanel.Visible; + } + + return; + } + } + } + } + + private void OnTabChanged(long tab) + { + if (_isLoadingGraph) + { + return; + } + + if (tab >= 0 && tab < _openTabs.Count) + { + SaveOutgoingTabState((int)tab); + LoadGraphIntoEditor(_openTabs[(int)tab].GraphResource); + + ApplyVariablesPanelState((int)tab); + } + } + + private void OnTabClosePressed(long tab) + { + if (tab >= 0 && tab < _openTabs.Count) + { + SaveOutgoingTabState(-1); + CloseTabByIndex((int)tab); + } + } + + private void SyncConnectionsToGraph(StatescriptGraph graph) + { + if (_graphEdit is null || _isLoadingGraph) + { + return; + } + + graph.Connections.Clear(); + foreach (GodotCollections.Dictionary connection in _graphEdit.GetConnectionList()) + { + var connectionResource = new StatescriptConnection + { + FromNode = connection["from_node"].AsString(), + OutputPort = connection["from_port"].AsInt32(), + ToNode = connection["to_node"].AsString(), + InputPort = connection["to_port"].AsInt32(), + }; + + graph.Connections.Add(connectionResource); + } + } + + private void SyncConnectionsToCurrentGraph() + { + StatescriptGraph? graph = CurrentGraph; + if (graph is not null) + { + SyncConnectionsToGraph(graph); + } + } + + private void SyncVisualNodePositionsToGraph() + { + if (_graphEdit is null) + { + return; + } + + foreach (Node child in _graphEdit.GetChildren()) + { + if (child is not StatescriptGraphNode sgn || sgn.NodeResource is null) + { + continue; + } + + sgn.NodeResource.PositionOffset = sgn.PositionOffset; + } + } + + private void OnVariablesToggled(bool pressed) + { + if (_variablePanel is null || _tabBar is null || _openTabs.Count == 0) + { + return; + } + + _variablePanel.Visible = pressed; + + var current = _tabBar.CurrentTab; + if (current >= 0 && current < _openTabs.Count) + { + _openTabs[current].VariablesPanelOpen = pressed; + } + + if (pressed) + { + StatescriptGraph? graph = CurrentGraph; + if (graph is not null) + { + _variablePanel.SetGraph(graph); + } + } + } + + private void OnGraphVariablesChanged() + { + StatescriptGraph? graph = CurrentGraph; + if (graph is null) + { + return; + } + + LoadGraphIntoEditor(graph); + } + + private void OnVariableUndoRedoPerformed() + { + EnsureVariablesPanelVisible(); + } + + private void OnVariableHighlightChanged(string? variableName) + { + if (_graphEdit is null) + { + return; + } + + foreach (Node child in _graphEdit.GetChildren()) + { + if (child is StatescriptGraphNode graphNode) + { + graphNode.SetHighlightedVariable(variableName); + } + } + } + + private void EnsureVariablesPanelVisible() + { + if (_variablePanel is null || _variablesToggleButton is null || _openTabs.Count == 0) + { + return; + } + + if (_variablePanel.Visible) + { + return; + } + + _variablePanel.Visible = true; + _variablesToggleButton.SetPressedNoSignal(true); + + var current = _tabBar?.CurrentTab ?? -1; + if (current >= 0 && current < _openTabs.Count) + { + _openTabs[current].VariablesPanelOpen = true; + } + + StatescriptGraph? graph = CurrentGraph; + if (graph is not null) + { + _variablePanel.SetGraph(graph); + } + } + + private void OnFilesystemChanged() + { + for (var i = 0; i < _openTabs.Count; i++) + { + _openTabs[i].UpdateCachedPathIfMissing(); + } + + for (var i = _openTabs.Count - 1; i >= 0; i--) + { + var path = _openTabs[i].ResourcePath; + + if (string.IsNullOrEmpty(path)) + { + continue; + } + + var filePath = GetBaseFilePath(path); + if (!FileAccess.FileExists(filePath)) + { + CloseTabByIndex(i); + } + } + + RefreshTabTitles(); + } + + private void ApplyVariablesPanelState(int tabIndex) + { + if (_variablePanel is null || _variablesToggleButton is null + || tabIndex < 0 || tabIndex >= _openTabs.Count) + { + return; + } + + var shouldShow = _openTabs[tabIndex].VariablesPanelOpen; + _variablePanel.Visible = shouldShow; + _variablesToggleButton.SetPressedNoSignal(shouldShow); + + if (shouldShow) + { + _variablePanel.SetGraph(_openTabs[tabIndex].GraphResource); + } + } + + private void OnGraphEditGuiInput(InputEvent @event) + { + if (@event is InputEventKey { Pressed: true, Keycode: Key.D, CtrlPressed: true }) + { + DuplicateSelectedNodes(); + GetViewport().SetInputAsHandled(); + } + } + + private void DisconnectUISignals() + { + if (_tabBar is not null) + { + _tabBar.TabChanged -= OnTabChanged; + _tabBar.TabClosePressed -= OnTabClosePressed; + } + + if (_graphEdit is not null) + { + _graphEdit.ConnectionRequest -= OnConnectionRequest; + _graphEdit.DisconnectionRequest -= OnDisconnectionRequest; + _graphEdit.DeleteNodesRequest -= OnDeleteNodesRequest; + _graphEdit.BeginNodeMove -= OnBeginNodeMove; + _graphEdit.EndNodeMove -= OnEndNodeMove; + _graphEdit.PopupRequest -= OnGraphEditPopupRequest; + _graphEdit.ConnectionToEmpty -= OnConnectionToEmpty; + _graphEdit.ConnectionFromEmpty -= OnConnectionFromEmpty; + _graphEdit.GuiInput -= OnGraphEditGuiInput; + } + + if (_fileMenuPopup is not null) + { + _fileMenuPopup.IdPressed -= OnFileMenuIdPressed; + } + + if (_addNodeButton is not null) + { + _addNodeButton.Pressed -= OnAddNodeButtonPressed; + } + + if (_variablesToggleButton is not null) + { + _variablesToggleButton.Toggled -= OnVariablesToggled; + } + + if (_onlineDocsButton is not null) + { + _onlineDocsButton.Pressed -= OnOnlineDocsPressed; + } + + if (_addNodeDialog is not null) + { + _addNodeDialog.Canceled -= OnDialogCanceled; + _addNodeDialog.NodeCreationRequested -= OnDialogNodeCreationRequested; + } + + if (_variablePanel is not null) + { + _variablePanel.VariablesChanged -= OnGraphVariablesChanged; + _variablePanel.VariableUndoRedoPerformed -= OnVariableUndoRedoPerformed; + _variablePanel.VariableHighlightChanged -= OnVariableHighlightChanged; + } + } + + private void ConnectUISignals() + { + if (_tabBar is not null) + { + _tabBar.TabChanged += OnTabChanged; + _tabBar.TabClosePressed += OnTabClosePressed; + } + + if (_graphEdit is not null) + { + _graphEdit.ConnectionRequest += OnConnectionRequest; + _graphEdit.DisconnectionRequest += OnDisconnectionRequest; + _graphEdit.DeleteNodesRequest += OnDeleteNodesRequest; + _graphEdit.BeginNodeMove += OnBeginNodeMove; + _graphEdit.EndNodeMove += OnEndNodeMove; + _graphEdit.PopupRequest += OnGraphEditPopupRequest; + _graphEdit.ConnectionToEmpty += OnConnectionToEmpty; + _graphEdit.ConnectionFromEmpty += OnConnectionFromEmpty; + _graphEdit.GuiInput += OnGraphEditGuiInput; + } + + if (_fileMenuPopup is not null) + { + _fileMenuPopup.IdPressed += OnFileMenuIdPressed; + } + + if (_addNodeButton is not null) + { + _addNodeButton.Pressed += OnAddNodeButtonPressed; + } + + if (_variablesToggleButton is not null) + { + _variablesToggleButton.Toggled += OnVariablesToggled; + } + + if (_onlineDocsButton is not null) + { + _onlineDocsButton.Pressed += OnOnlineDocsPressed; + } + + if (_addNodeDialog is not null) + { + _addNodeDialog.Canceled += OnDialogCanceled; + _addNodeDialog.NodeCreationRequested += OnDialogNodeCreationRequested; + } + + if (_variablePanel is not null) + { + _variablePanel.VariablesChanged += OnGraphVariablesChanged; + _variablePanel.VariableUndoRedoPerformed += OnVariableUndoRedoPerformed; + _variablePanel.VariableHighlightChanged += OnVariableHighlightChanged; + } + } + + private int FindFirstEnabledInputPort(string nodeId) + { + if (_graphEdit is null) + { + return -1; + } + + Node? child = _graphEdit.GetNodeOrNull(nodeId); + if (child is not GraphNode graphNode) + { + return -1; + } + + for (var i = 0; i < graphNode.GetChildCount(); i++) + { + if (graphNode.IsSlotEnabledLeft(i)) + { + return i; + } + } + + return -1; + } + + private int FindFirstEnabledOutputPort(string nodeId) + { + if (_graphEdit is null) + { + return -1; + } + + Node? child = _graphEdit.GetNodeOrNull(nodeId); + if (child is not GraphNode graphNode) + { + return -1; + } + + for (var i = 0; i < graphNode.GetChildCount(); i++) + { + if (graphNode.IsSlotEnabledRight(i)) + { + return i; + } + } + + return -1; + } + + private sealed class GraphTab + { + private string _cachedPath; + + public StatescriptGraph GraphResource { get; } + + public string ResourcePath => !string.IsNullOrEmpty(GraphResource?.ResourcePath) + ? GraphResource.ResourcePath + : _cachedPath; + + public bool VariablesPanelOpen { get; set; } + + public GraphTab(StatescriptGraph graphResource) + { + GraphResource = graphResource; + _cachedPath = graphResource?.ResourcePath ?? string.Empty; + } + + public void UpdateCachedPathIfMissing() + { + if (GraphResource is null) + { + return; + } + + if (!string.IsNullOrEmpty(GraphResource.ResourcePath)) + { + _cachedPath = GraphResource.ResourcePath; + } + } + } +} +#endif diff --git a/addons/forge/editor/statescript/StatescriptGraphEditorDock.cs.uid b/addons/forge/editor/statescript/StatescriptGraphEditorDock.cs.uid new file mode 100644 index 00000000..6048a03e --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphEditorDock.cs.uid @@ -0,0 +1 @@ +uid://1mt1aejs15yr diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.Highlighting.cs b/addons/forge/editor/statescript/StatescriptGraphNode.Highlighting.cs new file mode 100644 index 00000000..1f88cae1 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.Highlighting.cs @@ -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 diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.Highlighting.cs.uid b/addons/forge/editor/statescript/StatescriptGraphNode.Highlighting.cs.uid new file mode 100644 index 00000000..8d9de5cc --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.Highlighting.cs.uid @@ -0,0 +1 @@ +uid://dmp3vltauax62 diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.NodeSetup.cs b/addons/forge/editor/statescript/StatescriptGraphNode.NodeSetup.cs new file mode 100644 index 00000000..53baab87 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.NodeSetup.cs @@ -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 diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.NodeSetup.cs.uid b/addons/forge/editor/statescript/StatescriptGraphNode.NodeSetup.cs.uid new file mode 100644 index 00000000..5dcfb18d --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.NodeSetup.cs.uid @@ -0,0 +1 @@ +uid://civ3te4ediqxn diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.PropertyEditors.cs b/addons/forge/editor/statescript/StatescriptGraphNode.PropertyEditors.cs new file mode 100644 index 00000000..40a41457 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.PropertyEditors.cs @@ -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 _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> 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 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 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> resolverFactories, + StatescriptNodeDiscovery.InputPropertyInfo propInfo, + VBoxContainer editorContainer) + { + public List> ResolverFactories { get; } = resolverFactories; + + public StatescriptNodeDiscovery.InputPropertyInfo PropInfo { get; } = propInfo; + + public VBoxContainer EditorContainer { get; } = editorContainer; + } +} +#endif diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.PropertyEditors.cs.uid b/addons/forge/editor/statescript/StatescriptGraphNode.PropertyEditors.cs.uid new file mode 100644 index 00000000..a3a15ae3 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.PropertyEditors.cs.uid @@ -0,0 +1 @@ +uid://b8iw3e8i3f0w8 diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.cs b/addons/forge/editor/statescript/StatescriptGraphNode.cs new file mode 100644 index 00000000..82723fb5 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.cs @@ -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; + +/// +/// Visual GraphNode representation for a single Statescript node in the editor. +/// Supports both built-in node types (Entry/Exit) and dynamically discovered concrete types. +/// +[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 _activeResolverEditors = []; + private readonly Dictionary _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; + + /// + /// Raised when a property binding has been modified in the UI. + /// + public event Action? PropertyBindingChanged; + + /// + /// Gets the underlying node resource. + /// + public StatescriptNode? NodeResource { get; private set; } + + /// + /// Sets the used for undo/redo support. + /// + /// The undo/redo manager from the editor plugin. + public void SetUndoRedo(EditorUndoRedoManager? undoRedo) + { + _undoRedo = undoRedo; + } + + /// + /// Gets the used for undo/redo support. + /// + /// The undo/redo manager, or null if not set. + public EditorUndoRedoManager? GetUndoRedo() + { + return _undoRedo; + } + + /// + /// Updates the highlight state based on the given variable name. + /// + /// The variable name to highlight, or null to clear. + public void SetHighlightedVariable(string? variableName) + { + _highlightedVariableName = variableName; + _isHighlighted = !string.IsNullOrEmpty(variableName) && ReferencesVariable(variableName!); + ApplyHighlightBorder(); + UpdateChildHighlights(); + } + + /// + /// Initializes this visual node from a resource, optionally within the context of a graph. + /// + /// The node resource to display. + /// The owning graph resource (needed for variable dropdowns). + 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 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 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 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); + } + } + } +} + +/// +/// Identifies a property binding slot by direction and index. +/// +/// The direction of the property (input or output). +/// The index of the property within its direction. +internal readonly record struct PropertySlotKey(StatescriptPropertyDirection Direction, int PropertyIndex); +#endif diff --git a/addons/forge/editor/statescript/StatescriptGraphNode.cs.uid b/addons/forge/editor/statescript/StatescriptGraphNode.cs.uid new file mode 100644 index 00000000..2cb386a4 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptGraphNode.cs.uid @@ -0,0 +1 @@ +uid://cgb5kncrbsgb4 diff --git a/addons/forge/editor/statescript/StatescriptNodeDiscovery.cs b/addons/forge/editor/statescript/StatescriptNodeDiscovery.cs new file mode 100644 index 00000000..584880f2 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptNodeDiscovery.cs @@ -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; + +/// +/// Discovers concrete Statescript node types from loaded assemblies using reflection. +/// +/// +/// Provides port layout information for the editor without requiring node instantiation. +/// +internal static class StatescriptNodeDiscovery +{ + private static List? _cachedNodeTypes; + + /// + /// Gets all discovered concrete node types. Results are cached after first discovery. + /// + /// A read-only list of node type info. + internal static IReadOnlyList GetDiscoveredNodeTypes() + { + _cachedNodeTypes ??= DiscoverNodeTypes(); + return _cachedNodeTypes; + } + + /// + /// Clears the cached discovery results, forcing re-discovery on next access. + /// + internal static void InvalidateCache() + { + _cachedNodeTypes = null; + } + + /// + /// Finds the for the given runtime type name. + /// + /// The full type name stored in the resource. + /// The matching node type info, or null if not found. + internal static NodeTypeInfo? FindByRuntimeTypeName(string runtimeTypeName) + { + IReadOnlyList types = GetDiscoveredNodeTypes(); + + for (var i = 0; i < types.Count; i++) + { + if (types[i].RuntimeTypeName == runtimeTypeName) + { + return types[i]; + } + } + + return null; + } + + private static List DiscoverNodeTypes() + { + var results = new List(); + + 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(); + } + + /// + /// Describes a discovered concrete node type and its port layout. + /// + internal sealed class NodeTypeInfo + { + /// + /// Gets the display name for this node type (e.g., "Timer", "Set Variable", "Expression"). + /// + public string DisplayName { get; } + + /// + /// Gets the CLR type name used for serialization (typically the type's full name). + /// + public string RuntimeTypeName { get; } + + /// + /// Gets the node category (Action, Condition, State). + /// + public StatescriptNodeType NodeType { get; } + + /// + /// Gets the input port labels for this node type. + /// + public string[] InputPortLabels { get; } + + /// + /// Gets the output port labels for this node type. + /// + public string[] OutputPortLabels { get; } + + /// + /// Gets whether each output port is a subgraph port. + /// + public bool[] IsSubgraphPort { get; } + + /// + /// Gets the constructor parameter names for this node type. + /// + public string[] ConstructorParameterNames { get; } + + /// + /// Gets a brief description for this node type, shown in the Add Node dialog. + /// Read from the property at discovery time. + /// + public string Description { get; } + + /// + /// Gets the input property declarations for this node type. + /// + public InputPropertyInfo[] InputPropertiesInfo { get; } + + /// + /// Gets the output variable declarations for this node type. + /// + 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; + } + } + + /// + /// Describes an input property declared by a node type. + /// + /// The human-readable label for this input property. + /// The type the node expects to read. + /// Whether the input expects an array of values. + internal readonly record struct InputPropertyInfo(string Label, Type ExpectedType, bool IsArray = false); + + /// + /// Describes an output variable declared by a node type. + /// + /// The human-readable label for this output variable. + /// The type the node writes. + /// The default scope for this output variable. + internal readonly record struct OutputVariableInfo(string Label, Type ValueType, VariableScope Scope); + + private record struct PortLayout(string InputLabel, string OutputLabel, bool IsSubgraph); +} +#endif diff --git a/addons/forge/editor/statescript/StatescriptNodeDiscovery.cs.uid b/addons/forge/editor/statescript/StatescriptNodeDiscovery.cs.uid new file mode 100644 index 00000000..e84bca54 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptNodeDiscovery.cs.uid @@ -0,0 +1 @@ +uid://cb2mf4xojoxal diff --git a/addons/forge/editor/statescript/StatescriptResolverRegistry.cs b/addons/forge/editor/statescript/StatescriptResolverRegistry.cs new file mode 100644 index 00000000..3356c610 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptResolverRegistry.cs @@ -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; + +/// +/// Registry of available implementations. Resolver editors are discovered +/// automatically via reflection. Any concrete subclass of in the executing assembly is +/// registered and becomes available in node input property dropdowns. +/// +internal static class StatescriptResolverRegistry +{ + private static readonly List> _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)!); + } + } + + /// + /// Gets factory functions for all resolver editors compatible with the given expected type. + /// + /// The type expected by the node input property. + /// A list of compatible resolver editor factories. + public static List> GetCompatibleFactories(Type expectedType) + { + var result = new List>(); + + foreach (Func factory in _factories) + { + using NodeEditorProperty temp = factory(); + + if (temp.IsCompatibleWith(expectedType)) + { + result.Add(factory); + } + } + + return result; + } +} +#endif diff --git a/addons/forge/editor/statescript/StatescriptResolverRegistry.cs.uid b/addons/forge/editor/statescript/StatescriptResolverRegistry.cs.uid new file mode 100644 index 00000000..aba396a6 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptResolverRegistry.cs.uid @@ -0,0 +1 @@ +uid://bq3g4cbysmedf diff --git a/addons/forge/editor/statescript/StatescriptVariablePanel.UndoRedoCallbacks.cs b/addons/forge/editor/statescript/StatescriptVariablePanel.UndoRedoCallbacks.cs new file mode 100644 index 00000000..ca5eff9b --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptVariablePanel.UndoRedoCallbacks.cs @@ -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 diff --git a/addons/forge/editor/statescript/StatescriptVariablePanel.UndoRedoCallbacks.cs.uid b/addons/forge/editor/statescript/StatescriptVariablePanel.UndoRedoCallbacks.cs.uid new file mode 100644 index 00000000..d4c6d984 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptVariablePanel.UndoRedoCallbacks.cs.uid @@ -0,0 +1 @@ +uid://cbrse4fxsk87x diff --git a/addons/forge/editor/statescript/StatescriptVariablePanel.ValueEditors.cs b/addons/forge/editor/statescript/StatescriptVariablePanel.ValueEditors.cs new file mode 100644 index 00000000..f4c39ce8 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptVariablePanel.ValueEditors.cs @@ -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 diff --git a/addons/forge/editor/statescript/StatescriptVariablePanel.ValueEditors.cs.uid b/addons/forge/editor/statescript/StatescriptVariablePanel.ValueEditors.cs.uid new file mode 100644 index 00000000..7655aa56 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptVariablePanel.ValueEditors.cs.uid @@ -0,0 +1 @@ +uid://bhgni65dto1ul diff --git a/addons/forge/editor/statescript/StatescriptVariablePanel.cs b/addons/forge/editor/statescript/StatescriptVariablePanel.cs new file mode 100644 index 00000000..7f4b1264 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptVariablePanel.cs @@ -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; + +/// +/// 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. +/// +[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 _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; + + /// + /// Raised when any variable is added, removed, or its value changes. + /// + public event Action? VariablesChanged; + + /// + /// Raised when an undo/redo action modifies the variable panel, so the dock can auto-expand it. + /// + public event Action? VariableUndoRedoPerformed; + + /// + /// Raised when the user selects or deselects a variable for highlighting. + /// + public event Action? 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(); + } + + /// + /// Sets the graph to display variables for. + /// + /// The graph resource, or null to clear. + public void SetGraph(StatescriptGraph? graph) + { + _graph = graph; + LoadExpandedArrayState(); + RebuildList(); + } + + /// + /// Sets the used for undo/redo support. + /// + /// The undo/redo manager from the editor plugin. + public void SetUndoRedo(EditorUndoRedoManager undoRedo) + { + _undoRedo = undoRedo; + } + + /// + /// Rebuilds the variable list UI from the current graph. + /// + 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 diff --git a/addons/forge/editor/statescript/StatescriptVariablePanel.cs.uid b/addons/forge/editor/statescript/StatescriptVariablePanel.cs.uid new file mode 100644 index 00000000..fa28b586 --- /dev/null +++ b/addons/forge/editor/statescript/StatescriptVariablePanel.cs.uid @@ -0,0 +1 @@ +uid://dax3ghnqv8jet diff --git a/addons/forge/editor/statescript/node_editors/SetVariableNodeEditor.cs b/addons/forge/editor/statescript/node_editors/SetVariableNodeEditor.cs new file mode 100644 index 00000000..c828bf5f --- /dev/null +++ b/addons/forge/editor/statescript/node_editors/SetVariableNodeEditor.cs @@ -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; + +/// +/// Custom node editor for the SetVariableNode. Dynamically filters the Input (value resolver) based on the +/// selected target variable's type. Supports both Graph and Shared variable scopes. +/// +[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 _setPaths = []; + private readonly List _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; + + /// + public override string HandledRuntimeTypeName => "Gamesmiths.Forge.Statescript.Nodes.Action.SetVariableNode"; + + /// + 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); + } + } + + /// + internal override void Unbind() + { + base.Unbind(); + _cachedTypeInfo = null; + _cachedInputEditorContainer = null; + _cachedTargetContainer = null; + _setDropdown = null; + _sharedVarDropdown = null; + } + + private static List FindAllSharedVariableSetPaths() + { + var results = new List(); + EditorFileSystemDirectory root = EditorInterface.Singleton.GetResourceFilesystem().GetFilesystem(); + ScanFilesystemDirectory(root, results); + return results; + } + + private static void ScanFilesystemDirectory(EditorFileSystemDirectory dir, List 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(_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(_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 diff --git a/addons/forge/editor/statescript/node_editors/SetVariableNodeEditor.cs.uid b/addons/forge/editor/statescript/node_editors/SetVariableNodeEditor.cs.uid new file mode 100644 index 00000000..80b6046b --- /dev/null +++ b/addons/forge/editor/statescript/node_editors/SetVariableNodeEditor.cs.uid @@ -0,0 +1 @@ +uid://us4bxyl7143x diff --git a/addons/forge/editor/statescript/resolvers/ActivationDataResolverEditor.cs b/addons/forge/editor/statescript/resolvers/ActivationDataResolverEditor.cs new file mode 100644 index 00000000..dce3644b --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/ActivationDataResolverEditor.cs @@ -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; + +/// +/// Resolver editor that binds a node input property to an activation data field. Uses a two-step selection: first +/// select the implementation, then select a compatible field from that provider. +/// Providers are discovered via reflection. +/// +/// +/// 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. +/// +[Tool] +internal sealed partial class ActivationDataResolverEditor : NodeEditorProperty +{ + private readonly List _providerClassNames = []; + private readonly List _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; + + /// + public override string DisplayName => "Activation Data"; + + /// + public override string ResolverTypeId => "ActivationData"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return true; + } + + /// + 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; + } + + /// + public override void SaveTo(StatescriptNodeProperty property) + { + property.Resolver = new ActivationDataResolverResource + { + ProviderClassName = _selectedProviderClassName, + FieldName = _selectedFieldName, + FieldType = _selectedFieldType, + }; + } + + /// + 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 diff --git a/addons/forge/editor/statescript/resolvers/ActivationDataResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/ActivationDataResolverEditor.cs.uid new file mode 100644 index 00000000..6372b653 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/ActivationDataResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://cvegkmbda17em diff --git a/addons/forge/editor/statescript/resolvers/AttributeResolverEditor.cs b/addons/forge/editor/statescript/resolvers/AttributeResolverEditor.cs new file mode 100644 index 00000000..e8dec67a --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/AttributeResolverEditor.cs @@ -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; + +/// +/// Resolver editor that reads a value from a Forge entity attribute. Shows attribute set and attribute dropdowns. +/// +[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; + + /// + public override string DisplayName => "Attribute"; + + /// + public override string ResolverTypeId => "Attribute"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return expectedType == typeof(int) || expectedType == typeof(Variant128); + } + + /// + 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; + } + + /// + public override void SaveTo(StatescriptNodeProperty property) + { + property.Resolver = new AttributeResolverResource + { + AttributeSetClass = _selectedSetClass, + AttributeName = _selectedAttribute, + }; + } + + /// + 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 diff --git a/addons/forge/editor/statescript/resolvers/AttributeResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/AttributeResolverEditor.cs.uid new file mode 100644 index 00000000..a86d9037 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/AttributeResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://ciagvn5l8gnbq diff --git a/addons/forge/editor/statescript/resolvers/ComparisonResolverEditor.cs b/addons/forge/editor/statescript/resolvers/ComparisonResolverEditor.cs new file mode 100644 index 00000000..434accac --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/ComparisonResolverEditor.cs @@ -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; + +/// +/// 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 > Constant". +/// +[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> _numericFactories = []; + private ComparisonOperation _operation; + + private VBoxContainer? _leftEditorContainer; + private VBoxContainer? _rightEditorContainer; + + /// + public override string DisplayName => "Comparison"; + + /// + public override string ResolverTypeId => "Comparison"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return expectedType == typeof(bool) || expectedType == typeof(ForgeVariant128); + } + + /// + 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; + } + + /// + 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; + } + + /// + 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 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 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 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 diff --git a/addons/forge/editor/statescript/resolvers/ComparisonResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/ComparisonResolverEditor.cs.uid new file mode 100644 index 00000000..6147d538 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/ComparisonResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://c8uywbj8s8brq diff --git a/addons/forge/editor/statescript/resolvers/MagnitudeResolverEditor.cs b/addons/forge/editor/statescript/resolvers/MagnitudeResolverEditor.cs new file mode 100644 index 00000000..ea50b7aa --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/MagnitudeResolverEditor.cs @@ -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; + +/// +/// Resolver editor for the ability activation magnitude. No configuration is needed, it simply reads the magnitude from +/// the at runtime. Only compatible with inputs. +/// +[Tool] +internal sealed partial class MagnitudeResolverEditor : NodeEditorProperty +{ + /// + public override string DisplayName => "Magnitude"; + + /// + public override string ResolverTypeId => "Magnitude"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return expectedType == typeof(float) || expectedType == typeof(Variant128); + } + + /// + 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); + } + + /// + public override void SaveTo(StatescriptNodeProperty property) + { + property.Resolver = new MagnitudeResolverResource(); + } +} +#endif diff --git a/addons/forge/editor/statescript/resolvers/MagnitudeResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/MagnitudeResolverEditor.cs.uid new file mode 100644 index 00000000..c00d9c62 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/MagnitudeResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://cdsw31atjur88 diff --git a/addons/forge/editor/statescript/resolvers/SharedVariableResolverEditor.cs b/addons/forge/editor/statescript/resolvers/SharedVariableResolverEditor.cs new file mode 100644 index 00000000..74e9beb7 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/SharedVariableResolverEditor.cs @@ -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; + +/// +/// Resolver editor that binds a node input property to a shared variable on the owning entity. Uses a two-step +/// selection: first select the resource, then select a compatible variable from +/// that set. At runtime the value is read from the entity's bag. +/// +[Tool] +internal sealed partial class SharedVariableResolverEditor : NodeEditorProperty +{ + private readonly List _setPaths = []; + private readonly List _setDisplayNames = []; + private readonly List _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; + + /// + public override string DisplayName => "Shared Variable"; + + /// + public override string ResolverTypeId => "SharedVariable"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return true; + } + + /// + 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; + } + + /// + public override void SaveTo(StatescriptNodeProperty property) + { + property.Resolver = new SharedVariableResolverResource + { + SharedVariableSetPath = _selectedSetPath, + VariableName = _selectedVariableName, + VariableType = _selectedVariableType, + }; + } + + /// + public override void ClearCallbacks() + { + base.ClearCallbacks(); + _onChanged = null; + } + + private static List FindAllSharedVariableSetPaths() + { + var results = new List(); + EditorFileSystemDirectory root = EditorInterface.Singleton.GetResourceFilesystem().GetFilesystem(); + ScanFilesystemDirectory(root, results); + return results; + } + + private static void ScanFilesystemDirectory(EditorFileSystemDirectory dir, List 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(_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(_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 diff --git a/addons/forge/editor/statescript/resolvers/SharedVariableResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/SharedVariableResolverEditor.cs.uid new file mode 100644 index 00000000..b3a5fb94 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/SharedVariableResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://55ynvr5cbscp diff --git a/addons/forge/editor/statescript/resolvers/TagResolverEditor.cs b/addons/forge/editor/statescript/resolvers/TagResolverEditor.cs new file mode 100644 index 00000000..654dbf17 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/TagResolverEditor.cs @@ -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; + +/// +/// Resolver editor that selects a single tag. Reuses the tag tree UI pattern from TagEditorProperty. +/// +[Tool] +internal sealed partial class TagResolverEditor : NodeEditorProperty +{ + private readonly Dictionary _treeItemToNode = []; + + private Button? _tagButton; + private ScrollContainer? _scroll; + private Tree? _tree; + private string _selectedTag = string.Empty; + private Texture2D? _checkedIcon; + private Texture2D? _uncheckedIcon; + private Action? _onChanged; + + /// + public override string DisplayName => "Tag"; + + /// + public override string ResolverTypeId => "Tag"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return expectedType == typeof(bool) || expectedType == typeof(Variant128); + } + + /// + 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(); + } + + /// + public override void SaveTo(StatescriptNodeProperty property) + { + property.Resolver = new TagResolverResource + { + Tag = _selectedTag, + }; + } + + /// + 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.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 diff --git a/addons/forge/editor/statescript/resolvers/TagResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/TagResolverEditor.cs.uid new file mode 100644 index 00000000..70be35c9 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/TagResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://drl073r6x5m16 diff --git a/addons/forge/editor/statescript/resolvers/VariableResolverEditor.cs b/addons/forge/editor/statescript/resolvers/VariableResolverEditor.cs new file mode 100644 index 00000000..811dccd1 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/VariableResolverEditor.cs @@ -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; + +/// +/// 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. +/// +[Tool] +internal sealed partial class VariableResolverEditor : NodeEditorProperty +{ + private readonly List _variableNames = []; + + private OptionButton? _dropdown; + private string _selectedVariableName = string.Empty; + private Action? _onChanged; + + /// + public override string DisplayName => "Variable"; + + /// + public override string ResolverTypeId => "Variable"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return true; + } + + /// + 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); + } + + /// + public override void SaveTo(StatescriptNodeProperty property) + { + property.Resolver = new VariableResolverResource + { + VariableName = _selectedVariableName, + }; + } + + /// + 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 diff --git a/addons/forge/editor/statescript/resolvers/VariableResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/VariableResolverEditor.cs.uid new file mode 100644 index 00000000..9e7763ca --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/VariableResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://cs7v6x0xv1a3k diff --git a/addons/forge/editor/statescript/resolvers/VariantResolverEditor.cs b/addons/forge/editor/statescript/resolvers/VariantResolverEditor.cs new file mode 100644 index 00000000..eb3031fc --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/VariantResolverEditor.cs @@ -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; + +/// +/// Resolver editor that holds a constant (inline) value. The user edits the value directly in the node. +/// +[Tool] +internal sealed partial class VariantResolverEditor : NodeEditorProperty +{ + private StatescriptVariableType _valueType; + private bool _isArray; + private bool _isArrayExpanded; + private GodotVariant _currentValue; + private Array _arrayValues = []; + private Action? _onChanged; + + private Button? _toggleButton; + private VBoxContainer? _elementsContainer; + + /// + public override string DisplayName => "Constant"; + + /// + public override string ResolverTypeId => "Variant"; + + /// + public override bool IsCompatibleWith(Type expectedType) + { + return true; + } + + /// + 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); + } + } + + /// + 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, + }; + } + } + + /// + 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(); + } + + /// + /// Godot-compatible signal handler for array element remove buttons. Holds the element index and a reference to the + /// owning editor so the Pressed signal can be handled without a lambda. + /// + [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 diff --git a/addons/forge/editor/statescript/resolvers/VariantResolverEditor.cs.uid b/addons/forge/editor/statescript/resolvers/VariantResolverEditor.cs.uid new file mode 100644 index 00000000..d2512343 --- /dev/null +++ b/addons/forge/editor/statescript/resolvers/VariantResolverEditor.cs.uid @@ -0,0 +1 @@ +uid://dv2fk6v67mt3u diff --git a/addons/forge/editor/tags/TagContainerEditorProperty.cs b/addons/forge/editor/tags/TagContainerEditorProperty.cs index e2ab253f..a16dfa78 100644 --- a/addons/forge/editor/tags/TagContainerEditorProperty.cs +++ b/addons/forge/editor/tags/TagContainerEditorProperty.cs @@ -11,7 +11,7 @@ using GodotStringArray = Godot.Collections.Array; namespace Gamesmiths.Forge.Godot.Editor.Tags; [Tool] -public partial class TagContainerEditorProperty : EditorProperty +public partial class TagContainerEditorProperty : EditorProperty, ISerializationListener { private readonly Dictionary _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("uid://8j4xg16o3qnl"); + ResourceLoader.Load(ForgeData.ForgeDataResourcePath); var tagsManager = new TagsManager([.. forgePluginData.RegisteredTags]); diff --git a/addons/forge/editor/tags/TagEditorProperty.cs b/addons/forge/editor/tags/TagEditorProperty.cs index 05edd2de..f971b438 100644 --- a/addons/forge/editor/tags/TagEditorProperty.cs +++ b/addons/forge/editor/tags/TagEditorProperty.cs @@ -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 _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("uid://8j4xg16o3qnl"); + ResourceLoader.Load(ForgeData.ForgeDataResourcePath); var tagsManager = new TagsManager([.. forgePluginData.RegisteredTags]); diff --git a/addons/forge/editor/tags/TagsEditorDock.cs b/addons/forge/editor/tags/TagsEditorDock.cs new file mode 100644 index 00000000..b8fa641e --- /dev/null +++ b/addons/forge/editor/tags/TagsEditorDock.cs @@ -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; + +/// +/// Editor dock for managing gameplay tags. +/// +[Tool] +public partial class TagsEditorDock : EditorDock, ISerializationListener +{ + private readonly Dictionary _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("uid://cu6ncpuumjo20"); + DefaultSlot = DockSlot.RightUl; + } + + public override void _Ready() + { + base._Ready(); + + _forgePluginData = ResourceLoader.Load(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 diff --git a/addons/forge/editor/tags/TagsEditorDock.cs.uid b/addons/forge/editor/tags/TagsEditorDock.cs.uid new file mode 100644 index 00000000..99714e5e --- /dev/null +++ b/addons/forge/editor/tags/TagsEditorDock.cs.uid @@ -0,0 +1 @@ +uid://drgjhyxk7rkgg diff --git a/addons/forge/icons/Statescript.svg b/addons/forge/icons/Statescript.svg new file mode 100644 index 00000000..8e437e6a --- /dev/null +++ b/addons/forge/icons/Statescript.svg @@ -0,0 +1,53 @@ + + + + + + + + + + diff --git a/addons/forge/icons/Statescript.svg.import b/addons/forge/icons/Statescript.svg.import new file mode 100644 index 00000000..db5a6923 --- /dev/null +++ b/addons/forge/icons/Statescript.svg.import @@ -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 diff --git a/addons/forge/icons/modifier.svg b/addons/forge/icons/modifier.svg index 83cc50f3..29064496 100644 --- a/addons/forge/icons/modifier.svg +++ b/addons/forge/icons/modifier.svg @@ -49,9 +49,11 @@ + id="path1-1" + style="fill:#8eef97;fill-opacity:1" /> + id="path2" + style="fill:#fc7f7f;fill-opacity:1" /> diff --git a/addons/forge/icons/modifier_magnitude.svg b/addons/forge/icons/modifier_magnitude.svg index 969f59b3..ff1c87b5 100644 --- a/addons/forge/icons/modifier_magnitude.svg +++ b/addons/forge/icons/modifier_magnitude.svg @@ -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" /> + style="stroke-width:1.96852;fill:#8eef97;fill-opacity:1" /> + style="stroke-width:1.96852;fill:#fc7f7f;fill-opacity:1" /> diff --git a/addons/forge/nodes/ForgeEntity.cs b/addons/forge/nodes/ForgeEntity.cs index 6f638655..a79bbc90 100644 --- a/addons/forge/nodes/ForgeEntity.cs +++ b/addons/forge/nodes/ForgeEntity.cs @@ -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 attributeSetList = []; @@ -63,5 +73,6 @@ public partial class ForgeEntity : Node, IForgeEntity base._Process(delta); EffectsManager.UpdateEffects(delta); + Abilities.UpdateAbilities(delta); } } diff --git a/addons/forge/plugin.cfg b/addons/forge/plugin.cfg index 350cb4d1..99617e33 100644 --- a/addons/forge/plugin.cfg +++ b/addons/forge/plugin.cfg @@ -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" diff --git a/addons/forge/resources/ForgeActivationDataField.cs b/addons/forge/resources/ForgeActivationDataField.cs new file mode 100644 index 00000000..9c769523 --- /dev/null +++ b/addons/forge/resources/ForgeActivationDataField.cs @@ -0,0 +1,14 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Godot.Resources.Statescript; + +namespace Gamesmiths.Forge.Godot.Resources; + +/// +/// Describes a single field exposed by an . Each field defines a name and type +/// that graph nodes can bind to via the Activation Data resolver. +/// +/// The name of this data field. This name is used as the graph variable name at runtime. +/// +/// The type of this data field. +public readonly record struct ForgeActivationDataField(string FieldName, StatescriptVariableType FieldType); diff --git a/addons/forge/resources/ForgeActivationDataField.cs.uid b/addons/forge/resources/ForgeActivationDataField.cs.uid new file mode 100644 index 00000000..40d5de6d --- /dev/null +++ b/addons/forge/resources/ForgeActivationDataField.cs.uid @@ -0,0 +1 @@ +uid://cv4mcpd3ifglu diff --git a/addons/forge/resources/ForgeEffectData.cs b/addons/forge/resources/ForgeEffectData.cs index eebd6467..a59a4d63 100644 --- a/addons/forge/resources/ForgeEffectData.cs +++ b/addons/forge/resources/ForgeEffectData.cs @@ -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; diff --git a/addons/forge/resources/ForgeSharedVariableDefinition.cs b/addons/forge/resources/ForgeSharedVariableDefinition.cs new file mode 100644 index 00000000..fa32c1cf --- /dev/null +++ b/addons/forge/resources/ForgeSharedVariableDefinition.cs @@ -0,0 +1,47 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Godot.Resources.Statescript; +using Godot; +using Godot.Collections; + +namespace Gamesmiths.Forge.Godot.Resources; + +/// +/// Resource representing a single shared variable definition for an entity, including name, type, and initial value. +/// +[Tool] +[GlobalClass] +public partial class ForgeSharedVariableDefinition : Resource +{ + /// + /// Gets or sets the name of this shared variable. + /// + [Export] + public string VariableName { get; set; } = string.Empty; + + /// + /// Gets or sets the type of this shared variable. + /// + [Export] + public StatescriptVariableType VariableType { get; set; } = StatescriptVariableType.Int; + + /// + /// Gets or sets a value indicating whether this is an array variable. + /// + [Export] + public bool IsArray { get; set; } + + /// + /// 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 is true. + /// + [Export] + public Variant InitialValue { get; set; } + + /// + /// Gets or sets the initial values for array variables. + /// Each element is stored as a Godot variant. Only used when is true. + /// + [Export] + public Array InitialArrayValues { get; set; } = []; +} diff --git a/addons/forge/resources/ForgeSharedVariableDefinition.cs.uid b/addons/forge/resources/ForgeSharedVariableDefinition.cs.uid new file mode 100644 index 00000000..fff6e9f0 --- /dev/null +++ b/addons/forge/resources/ForgeSharedVariableDefinition.cs.uid @@ -0,0 +1 @@ +uid://347pr45ke8ns diff --git a/addons/forge/resources/ForgeSharedVariableSet.cs b/addons/forge/resources/ForgeSharedVariableSet.cs new file mode 100644 index 00000000..8691b4c2 --- /dev/null +++ b/addons/forge/resources/ForgeSharedVariableSet.cs @@ -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; + +/// +/// Resource containing a collection of shared variable definitions for an entity. Assign this to a +/// (or custom implementation) to define which shared +/// variables the entity exposes at runtime. +/// +[Tool] +[GlobalClass] +[Icon("uid://cu6ncpuumjo20")] +public partial class ForgeSharedVariableSet : Resource +{ + /// + /// Gets or sets the shared variable definitions. + /// + [Export] + public Array Variables { get; set; } = []; + + /// + /// Populates a bag with all the definitions in this set, using each variable's name and + /// initial value. + /// + /// The instance to populate. + 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); + } + } + } +} diff --git a/addons/forge/resources/ForgeSharedVariableSet.cs.uid b/addons/forge/resources/ForgeSharedVariableSet.cs.uid new file mode 100644 index 00000000..320d9d85 --- /dev/null +++ b/addons/forge/resources/ForgeSharedVariableSet.cs.uid @@ -0,0 +1 @@ +uid://dghkdxg314p05 diff --git a/addons/forge/resources/IActivationDataProvider.cs b/addons/forge/resources/IActivationDataProvider.cs new file mode 100644 index 00000000..90db4bf9 --- /dev/null +++ b/addons/forge/resources/IActivationDataProvider.cs @@ -0,0 +1,40 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Abilities; +using Gamesmiths.Forge.Statescript; + +namespace Gamesmiths.Forge.Godot.Resources; + +/// +/// 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. +/// +/// +/// Implementations must define to declare the available fields and their types, and +/// to produce a with the appropriate data binder +/// that writes matching values into the graph's . +/// 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. +/// 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. +/// +public interface IActivationDataProvider +{ + /// + /// 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. + /// + /// An array of field definitions. + ForgeActivationDataField[] GetFields(); + + /// + /// Creates an (typically a ) for the given + /// graph. The returned behavior's data binder must write each declared field into the graph's + /// using matching names so that the Activation Data resolver can read them at runtime. + /// + /// The runtime graph to execute. + /// An ability behavior that accepts the custom data type and maps it to graph variables. + IAbilityBehavior CreateBehavior(Graph graph); +} diff --git a/addons/forge/resources/IActivationDataProvider.cs.uid b/addons/forge/resources/IActivationDataProvider.cs.uid new file mode 100644 index 00000000..73e76894 --- /dev/null +++ b/addons/forge/resources/IActivationDataProvider.cs.uid @@ -0,0 +1 @@ +uid://ct5621o4fxwry diff --git a/addons/forge/resources/abilities/ForgeAbilityData.cs b/addons/forge/resources/abilities/ForgeAbilityData.cs index 414788ae..24fa8e84 100644 --- a/addons/forge/resources/abilities/ForgeAbilityData.cs +++ b/addons/forge/resources/abilities/ForgeAbilityData.cs @@ -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 diff --git a/addons/forge/resources/abilities/StatescriptAbilityBehavior.cs b/addons/forge/resources/abilities/StatescriptAbilityBehavior.cs new file mode 100644 index 00000000..49b4c604 --- /dev/null +++ b/addons/forge/resources/abilities/StatescriptAbilityBehavior.cs @@ -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; + +/// +/// A implementation that creates a from a +/// serialized resource. The graph is built once and cached, then shared across all +/// ability instances using the Flyweight pattern. Each creates its own +/// with independent state. +/// +/// +/// If any node in the graph uses an , the behavior automatically detects +/// the associated implementation and produces a +/// with a data binder that maps activation data fields into graph variables. +/// When no activation data resolver is present, a plain (without data +/// support) is created. +/// +[Tool] +[GlobalClass] +[Icon("uid://b6yrjb46fluw3")] +public partial class StatescriptAbilityBehavior : ForgeAbilityBehavior +{ + private Graph? _cachedGraph; + + private IActivationDataProvider? _cachedProvider; + + private bool _providerResolved; + + /// + /// Gets or sets the Statescript graph resource that defines the ability's behavior. + /// + [Export] + public StatescriptGraph? Statescript { get; set; } + + /// + 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; + } +} diff --git a/addons/forge/resources/abilities/StatescriptAbilityBehavior.cs.uid b/addons/forge/resources/abilities/StatescriptAbilityBehavior.cs.uid new file mode 100644 index 00000000..2adf8152 --- /dev/null +++ b/addons/forge/resources/abilities/StatescriptAbilityBehavior.cs.uid @@ -0,0 +1 @@ +uid://dapjjbxrv801t diff --git a/addons/forge/resources/statescript/StatescriptConnection.cs b/addons/forge/resources/statescript/StatescriptConnection.cs new file mode 100644 index 00000000..fc6ce9e4 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptConnection.cs @@ -0,0 +1,36 @@ +// Copyright © Gamesmiths Guild. + +using Godot; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Represents a connection between two nodes in the Statescript graph. +/// +[Tool] +public partial class StatescriptConnection : Resource +{ + /// + /// Gets or sets the source node id. + /// + [Export] + public string FromNode { get; set; } = string.Empty; + + /// + /// Gets or sets the source port index. + /// + [Export] + public int OutputPort { get; set; } + + /// + /// Gets or sets the destination node id. + /// + [Export] + public string ToNode { get; set; } = string.Empty; + + /// + /// Gets or sets the destination port index. + /// + [Export] + public int InputPort { get; set; } +} diff --git a/addons/forge/resources/statescript/StatescriptConnection.cs.uid b/addons/forge/resources/statescript/StatescriptConnection.cs.uid new file mode 100644 index 00000000..c3e7b37b --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptConnection.cs.uid @@ -0,0 +1 @@ +uid://c3w8w5eh6qxub diff --git a/addons/forge/resources/statescript/StatescriptGraph.cs b/addons/forge/resources/statescript/StatescriptGraph.cs new file mode 100644 index 00000000..9d412610 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptGraph.cs @@ -0,0 +1,75 @@ +// Copyright © Gamesmiths Guild. + +using Godot; +using Godot.Collections; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Resource representing a complete Statescript graph. Contains all nodes and their connections. +/// +[Tool] +[GlobalClass] +[Icon("uid://b6yrjb46fluw3")] +public partial class StatescriptGraph : Resource +{ + /// + /// Gets or sets the display name for this graph. + /// + [Export] + public string StatescriptName { get; set; } = "New Statescript"; + + /// + /// Gets or sets the nodes in this graph. + /// + [Export] + public Array Nodes { get; set; } = []; + + /// + /// Gets or sets the connections between nodes in this graph. + /// + [Export] + public Array Connections { get; set; } = []; + + /// + /// Gets or sets the graph variable definitions. + /// + [Export] + public Array Variables { get; set; } = []; + + /// + /// Gets or sets the scroll offset of the graph editor when this graph was last saved. + /// + [Export] + public Vector2 ScrollOffset { get; set; } + + /// + /// Gets or sets the zoom level of the graph editor when this graph was last saved. + /// + [Export] + public float Zoom { get; set; } = 1.0f; + + /// + /// Ensures the graph has an Entry node. Called when the graph is first created or loaded. + /// + public void EnsureEntryNode() + { + foreach (StatescriptNode node in Nodes) + { + if (node.NodeType == StatescriptNodeType.Entry) + { + return; + } + } + + var entryNode = new StatescriptNode + { + NodeId = "entry", + Title = "Entry", + NodeType = StatescriptNodeType.Entry, + PositionOffset = new Vector2(100, 200), + }; + + Nodes.Add(entryNode); + } +} diff --git a/addons/forge/resources/statescript/StatescriptGraph.cs.uid b/addons/forge/resources/statescript/StatescriptGraph.cs.uid new file mode 100644 index 00000000..f5b40253 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptGraph.cs.uid @@ -0,0 +1 @@ +uid://fj6yp4h5mkl6 diff --git a/addons/forge/resources/statescript/StatescriptGraphVariable.cs b/addons/forge/resources/statescript/StatescriptGraphVariable.cs new file mode 100644 index 00000000..ee57ee7b --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptGraphVariable.cs @@ -0,0 +1,46 @@ +// Copyright © Gamesmiths Guild. + +using Godot; +using Godot.Collections; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Resource representing a single graph variable definition, including name, type, initial value, and whether it is an +/// array variable. +/// +[Tool] +public partial class StatescriptGraphVariable : Resource +{ + /// + /// Gets or sets the name of this variable. + /// + [Export] + public string VariableName { get; set; } = string.Empty; + + /// + /// Gets or sets the type of this variable. + /// + [Export] + public StatescriptVariableType VariableType { get; set; } = StatescriptVariableType.Int; + + /// + /// Gets or sets a value indicating whether this is an array variable. + /// + [Export] + public bool IsArray { get; set; } + + /// + /// Gets or sets the initial value of this variable, stored as a Godot variant. + /// For non-array variables, this is a single value. Ignored when is true. + /// + [Export] + public Variant InitialValue { get; set; } + + /// + /// Gets or sets the initial values for array variables. + /// Each element is stored as a Godot variant. Only used when is true. + /// + [Export] + public Array InitialArrayValues { get; set; } = []; +} diff --git a/addons/forge/resources/statescript/StatescriptGraphVariable.cs.uid b/addons/forge/resources/statescript/StatescriptGraphVariable.cs.uid new file mode 100644 index 00000000..a5d2ff09 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptGraphVariable.cs.uid @@ -0,0 +1 @@ +uid://doh71xlxr3vqs diff --git a/addons/forge/resources/statescript/StatescriptNode.cs b/addons/forge/resources/statescript/StatescriptNode.cs new file mode 100644 index 00000000..7ba2c604 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptNode.cs @@ -0,0 +1,91 @@ +// Copyright © Gamesmiths Guild. + +using Godot; +using Godot.Collections; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// The type of a Statescript node. +/// +public enum StatescriptNodeType +{ + /// + /// Entry node: single output port. One per graph, cannot be removed. Color: Blue. + /// + Entry = 0, + + /// + /// Exit node: single input port. Optional, can have multiple. Color: Blue. + /// + Exit = 1, + + /// + /// Action node: one input, one output. Executes an instant action. Color: Green. + /// + Action = 2, + + /// + /// Condition node: one input, two outputs (true/false). Color: Yellow. + /// + Condition = 3, + + /// + /// State node: two inputs (input/abort), multiple outputs (OnActivate, OnDeactivate, OnAbort, Subgraph, + + /// custom). Color: Red. + /// + State = 4, +} + +/// +/// Resource representing a single node within a Statescript graph. +/// +[Tool] +public partial class StatescriptNode : Resource +{ + /// + /// Gets or sets the unique identifier for this node within the graph. + /// + [Export] + public string NodeId { get; set; } = string.Empty; + + /// + /// Gets or sets the display title for this node. + /// + [Export] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the type of this node. + /// + [Export] + public StatescriptNodeType NodeType { get; set; } + + /// + /// Gets or sets the fully qualified runtime type name of the concrete node class from the Forge library. + /// Empty for Entry and Exit nodes which are handled specially. + /// + [Export] + public string RuntimeTypeName { get; set; } = string.Empty; + + /// + /// Gets or sets the position of this node in the graph editor. + /// + [Export] + public Vector2 PositionOffset { get; set; } + + /// + /// Gets or sets additional custom data for extended node implementations. + /// + /// + /// Keys are constructor parameter names; values are the serialized parameter values. + /// + [Export] + public Dictionary CustomData { get; set; } = []; + + /// + /// Gets or sets the property bindings for this node's input properties and output variables. + /// + [Export] + public Array PropertyBindings { get; set; } = []; +} diff --git a/addons/forge/resources/statescript/StatescriptNode.cs.uid b/addons/forge/resources/statescript/StatescriptNode.cs.uid new file mode 100644 index 00000000..319fec43 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptNode.cs.uid @@ -0,0 +1 @@ +uid://6gpuevsxrj1i diff --git a/addons/forge/resources/statescript/StatescriptNodeProperty.cs b/addons/forge/resources/statescript/StatescriptNodeProperty.cs new file mode 100644 index 00000000..a4d01c63 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptNodeProperty.cs @@ -0,0 +1,30 @@ +// Copyright © Gamesmiths Guild. + +using Godot; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Resource representing a single node property binding. Stores the resolver resource reference. +/// +[Tool] +public partial class StatescriptNodeProperty : Resource +{ + /// + /// Gets or sets the direction of this property (input or output). + /// + [Export] + public StatescriptPropertyDirection Direction { get; set; } + + /// + /// Gets or sets the index of this property in the node's InputProperties or OutputVariables array. + /// + [Export] + public int PropertyIndex { get; set; } + + /// + /// Gets or sets the resolver resource for this property. + /// + [Export] + public StatescriptResolverResource? Resolver { get; set; } +} diff --git a/addons/forge/resources/statescript/StatescriptNodeProperty.cs.uid b/addons/forge/resources/statescript/StatescriptNodeProperty.cs.uid new file mode 100644 index 00000000..5c35e620 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptNodeProperty.cs.uid @@ -0,0 +1 @@ +uid://dlo2ir5oqf48y diff --git a/addons/forge/resources/statescript/StatescriptPropertyDirection.cs b/addons/forge/resources/statescript/StatescriptPropertyDirection.cs new file mode 100644 index 00000000..09cc8d9f --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptPropertyDirection.cs @@ -0,0 +1,19 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Indicates the direction of a node property binding. +/// +public enum StatescriptPropertyDirection +{ + /// + /// An input property that feeds a value into the node. + /// + Input = 0, + + /// + /// An output variable that the node writes a value to. + /// + Output = 1, +} diff --git a/addons/forge/resources/statescript/StatescriptPropertyDirection.cs.uid b/addons/forge/resources/statescript/StatescriptPropertyDirection.cs.uid new file mode 100644 index 00000000..5f023e19 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptPropertyDirection.cs.uid @@ -0,0 +1 @@ +uid://cxxeqnxesm87m diff --git a/addons/forge/resources/statescript/StatescriptResolverResource.cs b/addons/forge/resources/statescript/StatescriptResolverResource.cs new file mode 100644 index 00000000..ed6ad849 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptResolverResource.cs @@ -0,0 +1,65 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Base resource for all Statescript property resolvers. Each resolver type derives from this and implements the +/// binding methods to wire serialized editor data into the core runtime graph. +/// +/// +/// Subclasses must override to return a unique string that matches the corresponding +/// editor's ResolverTypeId. This enables automatic discovery and matching without hardcoded registrations. +/// +[Tool] +[GlobalClass] +public partial class StatescriptResolverResource : Resource +{ + /// + /// Gets the unique type identifier for this resolver, used to match serialized resources to their corresponding + /// editor. Subclasses must override this to return a non-empty string that matches their editor's + /// ResolverTypeId. + /// + public virtual string ResolverTypeId => string.Empty; + + /// + /// Binds this resolver as an input property on a runtime node. Implementations should register any necessary + /// variable or property definitions on the graph and call with the appropriate + /// name. + /// + /// The runtime graph being built. + /// The runtime node to bind the input on. + /// The serialized node identifier, used for generating unique property names. + /// The zero-based index of the input property to bind. + public virtual void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + } + + /// + /// Binds this resolver as an output variable on a runtime node. Implementations should call + /// with the appropriate variable name. + /// + /// The runtime node to bind the output on. + /// The zero-based index of the output variable to bind. + public virtual void BindOutput(ForgeNode runtimeNode, byte index) + { + } + + /// + /// Creates an from this resolver resource. Used when this resolver appears as a + /// nested operand inside another resolver (e.g., left/right side of a comparison). + /// + /// The runtime graph being built, used for looking up variable type information. + /// The property resolver instance, or a default zero-value resolver if the resource is not configured. + /// + public virtual IPropertyResolver BuildResolver(Graph graph) + { + return new VariantResolver(default, typeof(int)); + } +} diff --git a/addons/forge/resources/statescript/StatescriptResolverResource.cs.uid b/addons/forge/resources/statescript/StatescriptResolverResource.cs.uid new file mode 100644 index 00000000..d6c0f682 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptResolverResource.cs.uid @@ -0,0 +1 @@ +uid://bmlwmm5o5prqv diff --git a/addons/forge/resources/statescript/StatescriptVariableType.cs b/addons/forge/resources/statescript/StatescriptVariableType.cs new file mode 100644 index 00000000..d6a4554a --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptVariableType.cs @@ -0,0 +1,66 @@ +// Copyright © Gamesmiths Guild. + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Enumerates the supported variable types for Statescript graph variables and node properties. +/// Maps directly to the types supported by Forge's . +/// +public enum StatescriptVariableType +{ + /// Boolean value. + Bool = 0, + + /// Unsigned 8-bit integer. + Byte = 1, + + /// Signed 8-bit integer. + SByte = 2, + + /// Unicode character. +#pragma warning disable CA1720 // Identifier contains type name + Char = 3, + + /// 128-bit decimal. + Decimal = 4, + + /// 64-bit floating point. + Double = 5, + + /// 32-bit floating point. + Float = 6, + + /// Signed 32-bit integer. + Int = 7, + + /// Unsigned 32-bit integer. + UInt = 8, + + /// Signed 64-bit integer. + Long = 9, + + /// Unsigned 64-bit integer. + ULong = 10, + + /// Signed 16-bit integer. + Short = 11, + + /// Unsigned 16-bit integer. + UShort = 12, +#pragma warning restore CA1720 // Identifier contains type name + + /// 2D vector (float x, float y). + Vector2 = 13, + + /// 3D vector (float x, float y, float z). + Vector3 = 14, + + /// 4D vector (float x, float y, float z, float w). + Vector4 = 15, + + /// Plane (normal + distance). + Plane = 16, + + /// Quaternion rotation. + Quaternion = 17, +} diff --git a/addons/forge/resources/statescript/StatescriptVariableType.cs.uid b/addons/forge/resources/statescript/StatescriptVariableType.cs.uid new file mode 100644 index 00000000..ef24e9e6 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptVariableType.cs.uid @@ -0,0 +1 @@ +uid://cl2gru8twj1mx diff --git a/addons/forge/resources/statescript/StatescriptVariableTypeConverter.cs b/addons/forge/resources/statescript/StatescriptVariableTypeConverter.cs new file mode 100644 index 00000000..5b639b05 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptVariableTypeConverter.cs @@ -0,0 +1,204 @@ +// Copyright © Gamesmiths Guild. + +using System; +using System.Collections.Generic; +using Gamesmiths.Forge.Statescript; +using GodotPlane = Godot.Plane; +using GodotQuaternion = Godot.Quaternion; +using GodotVariant = Godot.Variant; +using GodotVector2 = Godot.Vector2; +using GodotVector3 = Godot.Vector3; +using GodotVector4 = Godot.Vector4; +using SysVector2 = System.Numerics.Vector2; +using SysVector3 = System.Numerics.Vector3; +using SysVector4 = System.Numerics.Vector4; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript; + +/// +/// Provides conversion utilities between , , and Godot +/// for use in the editor and at graph build time. +/// +public static class StatescriptVariableTypeConverter +{ + private static readonly Dictionary _typeMap = new() + { + [StatescriptVariableType.Bool] = typeof(bool), + [StatescriptVariableType.Byte] = typeof(byte), + [StatescriptVariableType.SByte] = typeof(sbyte), + [StatescriptVariableType.Char] = typeof(char), + [StatescriptVariableType.Decimal] = typeof(decimal), + [StatescriptVariableType.Double] = typeof(double), + [StatescriptVariableType.Float] = typeof(float), + [StatescriptVariableType.Int] = typeof(int), + [StatescriptVariableType.UInt] = typeof(uint), + [StatescriptVariableType.Long] = typeof(long), + [StatescriptVariableType.ULong] = typeof(ulong), + [StatescriptVariableType.Short] = typeof(short), + [StatescriptVariableType.UShort] = typeof(ushort), + [StatescriptVariableType.Vector2] = typeof(SysVector2), + [StatescriptVariableType.Vector3] = typeof(SysVector3), + [StatescriptVariableType.Vector4] = typeof(SysVector4), + [StatescriptVariableType.Plane] = typeof(System.Numerics.Plane), + [StatescriptVariableType.Quaternion] = typeof(System.Numerics.Quaternion), + }; + + private static readonly Dictionary _reverseTypeMap = []; + + static StatescriptVariableTypeConverter() + { + foreach (KeyValuePair kvp in _typeMap) + { + _reverseTypeMap[kvp.Value] = kvp.Key; + } + } + + /// + /// Gets all supported variable type values. + /// + /// All values of . + public static StatescriptVariableType[] GetAllTypes() + { + return (StatescriptVariableType[])Enum.GetValues(typeof(StatescriptVariableType)); + } + + /// + /// Converts a to the corresponding . + /// + /// The variable type enum value. + /// The corresponding CLR type. + public static Type ToSystemType(StatescriptVariableType variableType) + { + return _typeMap[variableType]; + } + + /// + /// Tries to find the for the given . + /// + /// The CLR type to look up. + /// The corresponding variable type if found. + /// if a matching variable type was found. + public static bool TryFromSystemType(Type type, out StatescriptVariableType variableType) + { + return _reverseTypeMap.TryGetValue(type, out variableType); + } + + /// + /// Checks whether the given is compatible with the specified variable type. + /// + /// + /// For (wildcard type), all types are compatible. Otherwise, strict type matching is used + /// with no implicit numeric conversions. + /// + /// The expected type from the node declaration. + /// The variable type to check. + /// if the types are compatible. + public static bool IsCompatible(Type expectedType, StatescriptVariableType variableType) + { + if (expectedType == typeof(Variant128)) + { + return true; + } + + Type actualType = ToSystemType(variableType); + return expectedType == actualType; + } + + /// + /// Creates a default for the given variable type. + /// + /// The variable type. + /// A Godot variant containing the default value. + public static GodotVariant CreateDefaultGodotVariant(StatescriptVariableType variableType) + { + return variableType switch + { + StatescriptVariableType.Bool => GodotVariant.From(false), + StatescriptVariableType.Byte => GodotVariant.From(0), + StatescriptVariableType.SByte => GodotVariant.From(0), + StatescriptVariableType.Char => GodotVariant.From(0), + StatescriptVariableType.Decimal => GodotVariant.From(0.0), + StatescriptVariableType.Double => GodotVariant.From(0.0), + StatescriptVariableType.Float => GodotVariant.From(0.0f), + StatescriptVariableType.Int => GodotVariant.From(0), + StatescriptVariableType.UInt => GodotVariant.From(0), + StatescriptVariableType.Long => GodotVariant.From(0L), + StatescriptVariableType.ULong => GodotVariant.From(0L), + StatescriptVariableType.Short => GodotVariant.From(0), + StatescriptVariableType.UShort => GodotVariant.From(0), + StatescriptVariableType.Vector2 => GodotVariant.From(GodotVector2.Zero), + StatescriptVariableType.Vector3 => GodotVariant.From(GodotVector3.Zero), + StatescriptVariableType.Vector4 => GodotVariant.From(GodotVector4.Zero), + StatescriptVariableType.Plane => GodotVariant.From(new GodotPlane(0, 1, 0, 0)), + StatescriptVariableType.Quaternion => GodotVariant.From(GodotQuaternion.Identity), + _ => GodotVariant.From(0), + }; + } + + /// + /// Converts a Godot variant value to a Forge based on the variable type. + /// + /// The Godot variant value. + /// The variable type that determines interpretation. + /// The corresponding . + public static Variant128 GodotVariantToForge(GodotVariant godotValue, StatescriptVariableType variableType) + { + return variableType switch + { + StatescriptVariableType.Bool => new Variant128(godotValue.AsBool()), + StatescriptVariableType.Byte => new Variant128((byte)godotValue.AsInt32()), + StatescriptVariableType.SByte => new Variant128((sbyte)godotValue.AsInt32()), + StatescriptVariableType.Char => new Variant128((char)godotValue.AsInt32()), + StatescriptVariableType.Decimal => new Variant128((decimal)godotValue.AsDouble()), + StatescriptVariableType.Double => new Variant128(godotValue.AsDouble()), + StatescriptVariableType.Float => new Variant128(godotValue.AsSingle()), + StatescriptVariableType.Int => new Variant128(godotValue.AsInt32()), + StatescriptVariableType.UInt => new Variant128((uint)godotValue.AsInt64()), + StatescriptVariableType.Long => new Variant128(godotValue.AsInt64()), + StatescriptVariableType.ULong => new Variant128((ulong)godotValue.AsInt64()), + StatescriptVariableType.Short => new Variant128((short)godotValue.AsInt32()), + StatescriptVariableType.UShort => new Variant128((ushort)godotValue.AsInt32()), + StatescriptVariableType.Vector2 => ToForgeVector2(godotValue.AsVector2()), + StatescriptVariableType.Vector3 => ToForgeVector3(godotValue.AsVector3()), + StatescriptVariableType.Vector4 => ToForgeVector4(godotValue.AsVector4()), + StatescriptVariableType.Plane => ToForgePlane(godotValue.AsPlane()), + StatescriptVariableType.Quaternion => ToForgeQuaternion(godotValue.AsQuaternion()), + _ => default, + }; + } + + /// + /// Gets the display name for a variable type. + /// + /// The variable type. + /// A human-readable name for the type. + public static string GetDisplayName(StatescriptVariableType variableType) + { + return variableType.ToString(); + } + + private static Variant128 ToForgeVector2(GodotVector2 v) + { + return new Variant128(new SysVector2(v.X, v.Y)); + } + + private static Variant128 ToForgeVector3(GodotVector3 v) + { + return new Variant128(new SysVector3(v.X, v.Y, v.Z)); + } + + private static Variant128 ToForgeVector4(GodotVector4 v) + { + return new Variant128(new SysVector4(v.X, v.Y, v.Z, v.W)); + } + + private static Variant128 ToForgePlane(GodotPlane p) + { + return new Variant128(new System.Numerics.Plane(p.Normal.X, p.Normal.Y, p.Normal.Z, p.D)); + } + + private static Variant128 ToForgeQuaternion(GodotQuaternion q) + { + return new Variant128(new System.Numerics.Quaternion(q.X, q.Y, q.Z, q.W)); + } +} diff --git a/addons/forge/resources/statescript/StatescriptVariableTypeConverter.cs.uid b/addons/forge/resources/statescript/StatescriptVariableTypeConverter.cs.uid new file mode 100644 index 00000000..a7b31625 --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptVariableTypeConverter.cs.uid @@ -0,0 +1 @@ +uid://dsewjg4nt7kao diff --git a/addons/forge/resources/statescript/StatescriptVariableTypeHelper.cs.uid b/addons/forge/resources/statescript/StatescriptVariableTypeHelper.cs.uid new file mode 100644 index 00000000..41af60ba --- /dev/null +++ b/addons/forge/resources/statescript/StatescriptVariableTypeHelper.cs.uid @@ -0,0 +1 @@ +uid://cssjv42gccy3n diff --git a/addons/forge/resources/statescript/resolvers/ActivationDataResolverResource.cs b/addons/forge/resources/statescript/resolvers/ActivationDataResolverResource.cs new file mode 100644 index 00000000..70049950 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/ActivationDataResolverResource.cs @@ -0,0 +1,105 @@ +// Copyright © Gamesmiths Guild. + +using System; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that binds a node property to a field declared by an . +/// +/// +/// At build time the resolver defines a graph variable for the field so that the data binder can write to it, and binds +/// the node input to that variable. At runtime the value is read from the graph's variables after the data binder has +/// populated them. +/// +[Tool] +[GlobalClass] +public partial class ActivationDataResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "ActivationData"; + + /// + /// Gets or sets the class name of the implementation that declares the field. + /// + [Export] + public string ProviderClassName { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the activation data field to bind to. + /// + [Export] + public string FieldName { get; set; } = string.Empty; + + /// + /// Gets or sets the expected type of the activation data field. + /// + [Export] + public StatescriptVariableType FieldType { get; set; } = StatescriptVariableType.Int; + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + if (string.IsNullOrEmpty(ProviderClassName)) + { + GD.PushError( + $"Statescript: Activation Data resolver on node '{nodeId}' (input {index}) " + + "has no provider selected. Select a provider and field in the graph editor."); + return; + } + + if (string.IsNullOrEmpty(FieldName)) + { + GD.PushError( + $"Statescript: Activation Data resolver on node '{nodeId}' (input {index}) " + + $"has provider '{ProviderClassName}' but no field selected. " + + "Select a field in the graph editor."); + return; + } + + Type clrType = StatescriptVariableTypeConverter.ToSystemType(FieldType); + var variableName = new StringKey(FieldName); + + // Define the variable so the data binder's SetVar call succeeds at runtime. + // Check if the variable is already defined to avoid duplicates when multiple nodes bind the same field. + var alreadyDefined = false; + foreach (VariableDefinition existing in graph.VariableDefinitions.VariableDefinitions) + { + if (existing.Name == variableName) + { + alreadyDefined = true; + break; + } + } + + if (!alreadyDefined) + { + graph.VariableDefinitions.VariableDefinitions.Add( + new VariableDefinition(variableName, default, clrType)); + } + + runtimeNode.BindInput(index, variableName); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + if (string.IsNullOrEmpty(ProviderClassName) || string.IsNullOrEmpty(FieldName)) + { + GD.PushError( + "Statescript: Activation Data resolver has incomplete configuration " + + $"(provider: '{ProviderClassName}', field: '{FieldName}'). " + + "The resolver will return a default value."); + return new VariantResolver(default, typeof(int)); + } + + Type clrType = StatescriptVariableTypeConverter.ToSystemType(FieldType); + return new VariableResolver(new StringKey(FieldName), clrType); + } +} diff --git a/addons/forge/resources/statescript/resolvers/ActivationDataResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/ActivationDataResolverResource.cs.uid new file mode 100644 index 00000000..ef5c117b --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/ActivationDataResolverResource.cs.uid @@ -0,0 +1 @@ +uid://dcshceitj3lc diff --git a/addons/forge/resources/statescript/resolvers/AttributeResolverResource.cs b/addons/forge/resources/statescript/resolvers/AttributeResolverResource.cs new file mode 100644 index 00000000..35a6bdc4 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/AttributeResolverResource.cs @@ -0,0 +1,56 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that reads a value from a Forge entity attribute at runtime. +/// +[Tool] +[GlobalClass] +public partial class AttributeResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "Attribute"; + + /// + /// Gets or sets the attribute set class name. + /// + [Export] + public string AttributeSetClass { get; set; } = string.Empty; + + /// + /// Gets or sets the attribute name within the attribute set. + /// + [Export] + public string AttributeName { get; set; } = string.Empty; + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + if (string.IsNullOrEmpty(AttributeSetClass) || string.IsNullOrEmpty(AttributeName)) + { + return; + } + + var attributeKey = new StringKey($"{AttributeSetClass}.{AttributeName}"); + var propertyName = new StringKey($"__attr_{nodeId}_{index}"); + + graph.VariableDefinitions.DefineProperty(propertyName, new AttributeResolver(attributeKey)); + + runtimeNode.BindInput(index, propertyName); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + var attributeKey = new StringKey($"{AttributeSetClass}.{AttributeName}"); + return new AttributeResolver(attributeKey); + } +} diff --git a/addons/forge/resources/statescript/resolvers/AttributeResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/AttributeResolverResource.cs.uid new file mode 100644 index 00000000..af864253 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/AttributeResolverResource.cs.uid @@ -0,0 +1 @@ +uid://cyiritjnyt65r diff --git a/addons/forge/resources/statescript/resolvers/ComparisonResolverResource.cs b/addons/forge/resources/statescript/resolvers/ComparisonResolverResource.cs new file mode 100644 index 00000000..e6540bcc --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/ComparisonResolverResource.cs @@ -0,0 +1,64 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that compares two nested numeric resolvers and produces a boolean result. +/// +[Tool] +[GlobalClass] +public partial class ComparisonResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "Comparison"; + + /// + /// Gets or sets the left-hand operand resolver. + /// + [Export] + public StatescriptResolverResource? Left { get; set; } + + /// + /// Gets or sets the comparison operation. + /// + [Export] + public ComparisonOperation Operation { get; set; } + + /// + /// Gets or sets the right-hand operand resolver. + /// + [Export] + public StatescriptResolverResource? Right { get; set; } + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + IPropertyResolver comparisonResolver = BuildResolver(graph); + var propertyName = new StringKey($"__cmp_{nodeId}_{index}"); + + graph.VariableDefinitions.DefineProperty(propertyName, comparisonResolver); + + runtimeNode.BindInput(index, propertyName); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + IPropertyResolver leftResolver = Left?.BuildResolver(graph) + ?? new VariantResolver(default, typeof(int)); + + IPropertyResolver rightResolver = Right?.BuildResolver(graph) + ?? new VariantResolver(default, typeof(int)); + + var operation = (ComparisonOperation)(byte)Operation; + + return new ComparisonResolver(leftResolver, operation, rightResolver); + } +} diff --git a/addons/forge/resources/statescript/resolvers/ComparisonResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/ComparisonResolverResource.cs.uid new file mode 100644 index 00000000..fcc09043 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/ComparisonResolverResource.cs.uid @@ -0,0 +1 @@ +uid://c3hb4x0majsfa diff --git a/addons/forge/resources/statescript/resolvers/MagnitudeResolverResource.cs b/addons/forge/resources/statescript/resolvers/MagnitudeResolverResource.cs new file mode 100644 index 00000000..23f7d398 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/MagnitudeResolverResource.cs @@ -0,0 +1,38 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that reads the ability activation magnitude from the +/// at runtime. Produces a value. +/// +[Tool] +[GlobalClass] +public partial class MagnitudeResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "Magnitude"; + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + var propertyName = new StringKey($"__mag_{nodeId}_{index}"); + + graph.VariableDefinitions.DefineProperty(propertyName, new MagnitudeResolver()); + + runtimeNode.BindInput(index, propertyName); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + return new MagnitudeResolver(); + } +} diff --git a/addons/forge/resources/statescript/resolvers/MagnitudeResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/MagnitudeResolverResource.cs.uid new file mode 100644 index 00000000..cb3edd77 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/MagnitudeResolverResource.cs.uid @@ -0,0 +1 @@ +uid://88e4ahqgwac6 diff --git a/addons/forge/resources/statescript/resolvers/SharedVariableResolverResource.cs b/addons/forge/resources/statescript/resolvers/SharedVariableResolverResource.cs new file mode 100644 index 00000000..513e44dd --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/SharedVariableResolverResource.cs @@ -0,0 +1,83 @@ +// Copyright © Gamesmiths Guild. + +using System; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that binds a node property to an entity's shared variable by name. At runtime the value is read +/// from the bag, which is populated from the entity's +/// . +/// +[Tool] +[GlobalClass] +public partial class SharedVariableResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "SharedVariable"; + + /// + /// Gets or sets the resource path of the that defines the variable. + /// + [Export] + public string SharedVariableSetPath { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the shared variable to bind to. + /// + [Export] + public string VariableName { get; set; } = string.Empty; + + /// + /// Gets or sets the expected type of the shared variable. + /// + [Export] + public StatescriptVariableType VariableType { get; set; } = StatescriptVariableType.Int; + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + if (string.IsNullOrEmpty(VariableName)) + { + return; + } + + Type clrType = StatescriptVariableTypeConverter.ToSystemType(VariableType); + var propertyName = new StringKey($"__shared_{nodeId}_{index}"); + + graph.VariableDefinitions.DefineProperty( + propertyName, + new SharedVariableResolver(new StringKey(VariableName), clrType)); + + runtimeNode.BindInput(index, propertyName); + } + + /// + public override void BindOutput(ForgeNode runtimeNode, byte index) + { + if (string.IsNullOrEmpty(VariableName)) + { + return; + } + + runtimeNode.BindOutput(index, new StringKey(VariableName), VariableScope.Shared); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + if (string.IsNullOrEmpty(VariableName)) + { + return new VariantResolver(default, typeof(int)); + } + + Type clrType = StatescriptVariableTypeConverter.ToSystemType(VariableType); + return new SharedVariableResolver(new StringKey(VariableName), clrType); + } +} diff --git a/addons/forge/resources/statescript/resolvers/SharedVariableResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/SharedVariableResolverResource.cs.uid new file mode 100644 index 00000000..87e3595a --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/SharedVariableResolverResource.cs.uid @@ -0,0 +1 @@ +uid://v0ukqap4vj40 diff --git a/addons/forge/resources/statescript/resolvers/TagResolverResource.cs b/addons/forge/resources/statescript/resolvers/TagResolverResource.cs new file mode 100644 index 00000000..315ddfcf --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/TagResolverResource.cs @@ -0,0 +1,56 @@ +// Copyright © Gamesmiths Guild. + +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Godot.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that checks whether the owner entity has a given tag, resolving to a boolean value at runtime. +/// +[Tool] +[GlobalClass] +public partial class TagResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "Tag"; + + /// + /// Gets or sets the tag string to check for (e.g., "Status.Burning"). + /// + [Export] + public string Tag { get; set; } = string.Empty; + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + if (string.IsNullOrEmpty(Tag)) + { + return; + } + + var tag = Tags.Tag.RequestTag(ForgeManagers.Instance.TagsManager, Tag); + var propertyName = new StringKey($"__tag_{nodeId}_{index}"); + + graph.VariableDefinitions.DefineProperty(propertyName, new TagResolver(tag)); + + runtimeNode.BindInput(index, propertyName); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + if (string.IsNullOrEmpty(Tag)) + { + return new VariantResolver(new Variant128(false), typeof(bool)); + } + + var tag = Tags.Tag.RequestTag(ForgeManagers.Instance.TagsManager, Tag); + return new TagResolver(tag); + } +} diff --git a/addons/forge/resources/statescript/resolvers/TagResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/TagResolverResource.cs.uid new file mode 100644 index 00000000..f9107a9a --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/TagResolverResource.cs.uid @@ -0,0 +1 @@ +uid://bmkcmsbydtode diff --git a/addons/forge/resources/statescript/resolvers/VariableResolverResource.cs b/addons/forge/resources/statescript/resolvers/VariableResolverResource.cs new file mode 100644 index 00000000..593aaa06 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/VariableResolverResource.cs @@ -0,0 +1,85 @@ +// Copyright © Gamesmiths Guild. + +using System; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that binds a node property to a graph variable by name. +/// +[Tool] +[GlobalClass] +public partial class VariableResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "Variable"; + + /// + /// Gets or sets the name of the graph variable to bind to. + /// + [Export] + public string VariableName { get; set; } = string.Empty; + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + if (string.IsNullOrEmpty(VariableName)) + { + return; + } + + runtimeNode.BindInput(index, new StringKey(VariableName)); + } + + /// + public override void BindOutput(ForgeNode runtimeNode, byte index) + { + if (string.IsNullOrEmpty(VariableName)) + { + return; + } + + runtimeNode.BindOutput(index, new StringKey(VariableName)); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + if (string.IsNullOrEmpty(VariableName)) + { + return new VariantResolver(default, typeof(int)); + } + + Type? variableType = FindGraphVariableType(graph, VariableName); + return new VariableResolver(new StringKey(VariableName), variableType ?? typeof(int)); + } + + private static Type? FindGraphVariableType(Graph graph, string variableName) + { + var key = new StringKey(variableName); + + foreach (VariableDefinition def in graph.VariableDefinitions.VariableDefinitions) + { + if (def.Name == key) + { + return def.ValueType; + } + } + + foreach (ArrayVariableDefinition definition in graph.VariableDefinitions.ArrayVariableDefinitions) + { + if (definition.Name == key) + { + return definition.ElementType; + } + } + + return null; + } +} diff --git a/addons/forge/resources/statescript/resolvers/VariableResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/VariableResolverResource.cs.uid new file mode 100644 index 00000000..da603676 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/VariableResolverResource.cs.uid @@ -0,0 +1 @@ +uid://bso385kpryjl diff --git a/addons/forge/resources/statescript/resolvers/VariantResolverResource.cs b/addons/forge/resources/statescript/resolvers/VariantResolverResource.cs new file mode 100644 index 00000000..07b503e4 --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/VariantResolverResource.cs @@ -0,0 +1,91 @@ +// Copyright © Gamesmiths Guild. + +using System; +using Gamesmiths.Forge.Core; +using Gamesmiths.Forge.Statescript; +using Gamesmiths.Forge.Statescript.Properties; +using Godot; +using Godot.Collections; + +using ForgeNode = Gamesmiths.Forge.Statescript.Node; + +namespace Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers; + +/// +/// Resolver resource that holds a constant (inline) value for a node property. +/// +[Tool] +[GlobalClass] +public partial class VariantResolverResource : StatescriptResolverResource +{ + /// + public override string ResolverTypeId => "Variant"; + + /// + /// Gets or sets the constant value. Used when is . + /// + [Export] + public Variant Value { get; set; } + + /// + /// Gets or sets the type interpretation for the value. + /// + [Export] + public StatescriptVariableType ValueType { get; set; } = StatescriptVariableType.Int; + + /// + /// Gets or sets a value indicating whether this resolver holds an array of values. + /// + [Export] + public bool IsArray { get; set; } + + /// + /// Gets or sets the array values. Used when is . + /// + [Export] + public Array ArrayValues { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the array section is expanded in the editor. + /// + [Export] + public bool IsArrayExpanded { get; set; } + + /// + public override void BindInput(Graph graph, ForgeNode runtimeNode, string nodeId, byte index) + { + var propertyName = new StringKey($"__const_{nodeId}_{index}"); + + if (IsArray) + { + var values = new Variant128[ArrayValues.Count]; + for (var i = 0; i < ArrayValues.Count; i++) + { + values[i] = StatescriptVariableTypeConverter.GodotVariantToForge(ArrayValues[i], ValueType); + } + + Type clrType = StatescriptVariableTypeConverter.ToSystemType(ValueType); + + graph.VariableDefinitions.ArrayVariableDefinitions.Add( + new ArrayVariableDefinition(propertyName, values, clrType)); + } + else + { + Variant128 value = StatescriptVariableTypeConverter.GodotVariantToForge(Value, ValueType); + Type clrType = StatescriptVariableTypeConverter.ToSystemType(ValueType); + + graph.VariableDefinitions.PropertyDefinitions.Add( + new PropertyDefinition(propertyName, new VariantResolver(value, clrType))); + } + + runtimeNode.BindInput(index, propertyName); + } + + /// + public override IPropertyResolver BuildResolver(Graph graph) + { + Variant128 value = StatescriptVariableTypeConverter.GodotVariantToForge(Value, ValueType); + Type clrType = StatescriptVariableTypeConverter.ToSystemType(ValueType); + return new VariantResolver(value, clrType); + } +} diff --git a/addons/forge/resources/statescript/resolvers/VariantResolverResource.cs.uid b/addons/forge/resources/statescript/resolvers/VariantResolverResource.cs.uid new file mode 100644 index 00000000..59406ecd --- /dev/null +++ b/addons/forge/resources/statescript/resolvers/VariantResolverResource.cs.uid @@ -0,0 +1 @@ +uid://db2rt0n3jtytf diff --git a/forge/calculators/ForgeRaiseEventTagExecution.cs b/forge/calculators/ForgeRaiseEventTagExecution.cs index 274e9965..4840a9aa 100644 --- a/forge/calculators/ForgeRaiseEventTagExecution.cs +++ b/forge/calculators/ForgeRaiseEventTagExecution.cs @@ -16,13 +16,19 @@ using Movementtests.systems; namespace Movementtests.tools.calculators; -public class FlyingWeaponExecution(TagContainer eventTags) : CustomExecution +public class RaiseEventTagExecution(TagContainer eventTags) : CustomExecution { public override ModifierEvaluatedData[] EvaluateExecution(Effect effect, IForgeEntity target, EffectEvaluatedData? effectEvaluatedData) { GD.Print("Custom execution executed"); var owner = effect.Ownership.Owner; if (owner == null) return []; + + GD.Print(eventTags.Tags.Count); + foreach (var tag in eventTags.Tags) + { + GD.Print(tag); + } owner.Events.Raise(new EventData { @@ -43,6 +49,6 @@ public partial class ForgeRaiseEventTagExecution : ForgeCustomExecution public override CustomExecution GetExecutionClass() { - return new FlyingWeaponExecution(EventTags.GetTagContainer()); + return new RaiseEventTagExecution(EventTags.GetTagContainer()); } } \ No newline at end of file diff --git a/forge/forge_data.tres b/forge/forge_data.tres index 621cd8ae..9b9ba880 100644 --- a/forge/forge_data.tres +++ b/forge/forge_data.tres @@ -4,4 +4,4 @@ [resource] script = ExtResource("1_l686n") -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", "event.player.empowered_action_used", "event.damage.taken", "event.damage.dealt", "set_by_caller", "trait.flammable", "trait.healable", "trait.damageable", "trait.wettable", "cue.vfx.reflect", "cue.vfx", "cooldown.skill", "cooldown.skill.reflect"]) +RegisteredTags = Array[String](["character.player", "character.enemy", "weapon", "status.stunned", "status.burning", "status.frozen", "abilities.weapon.land", "abilities.weapon.flying", "abilities.weapon.left", "events.combat.damage", "events.combat.hit", "events.weapon.flyingTick", "events.weapon.startedFlying", "events.weapon.stoppedFlying", "events.weapon.handToFlying", "events.weapon.flyingToHand", "events.weapon.plantedToHand", "events.weapon.plantedToFlying", "events.weapon.planted", "cooldown.empoweredAction", "cooldown.empoweredSwordThrow", "cues.resources.mana", "events.player.empowered_action_used", "cues.resources.mana.inhibited"]) diff --git a/scenes/enemies/Enemy.cs b/scenes/enemies/Enemy.cs index f02e327d..b98d9e42 100644 --- a/scenes/enemies/Enemy.cs +++ b/scenes/enemies/Enemy.cs @@ -4,6 +4,7 @@ using Gamesmiths.Forge.Effects; using Gamesmiths.Forge.Events; using Gamesmiths.Forge.Godot.Core; using Gamesmiths.Forge.Godot.Nodes; +using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Tags; using Godot; using Movementtests.interfaces; @@ -11,6 +12,7 @@ using Movementtests.scenes.enemies; using Movementtests.scenes.player_controller.scripts; using Movementtests.systems; using Movementtests.tools; +using Node = Godot.Node; [GlobalClass, Icon("res://assets/ui/IconGodotNode/node_3D/icon_beetle.png")] public partial class Enemy : CharacterBody3D, @@ -91,7 +93,9 @@ public partial class Enemy : CharacterBody3D, get => _forgeEntity.Events; set => _forgeEntity.Events = value; } - + + public Variables SharedVariables { get; } + // Private stuff private Area3D _damageBox = null!; internal Node3D _target = null!; diff --git a/scenes/player_controller/components/weapon/WeaponSystem.cs b/scenes/player_controller/components/weapon/WeaponSystem.cs index 4cbda2b5..a82322dd 100644 --- a/scenes/player_controller/components/weapon/WeaponSystem.cs +++ b/scenes/player_controller/components/weapon/WeaponSystem.cs @@ -14,6 +14,7 @@ using Gamesmiths.Forge.Godot.Core; using Gamesmiths.Forge.Godot.Nodes; using Gamesmiths.Forge.Godot.Resources; using Gamesmiths.Forge.Godot.Resources.Abilities; +using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Tags; using Godot; using GodotStateCharts; @@ -24,6 +25,7 @@ using Movementtests.scenes.player_controller.components.weapon; using Movementtests.systems.damage; using Movementtests.tools; using Movementtests.tools.calculators; +using Node = Godot.Node; namespace Movementtests.systems; @@ -54,7 +56,8 @@ public partial class WeaponSystem : RigidBody3D, IDamageDealer, IForgeEntity public EffectsManager EffectsManager { get; set; } = null!; public EntityAbilities Abilities { get; set; } = null!; public EventManager Events { get; set; } = null!; - + public Variables SharedVariables { get; } + private StateChart _weaponState = null!; public StateChartState InHandState = null!; public StateChartState FlyingState = null!; @@ -162,7 +165,7 @@ public partial class WeaponSystem : RigidBody3D, IDamageDealer, IForgeEntity Events = new(); // TODO: Waiting on bug resolve - // _weaponFlyingAbility = Abilities.GrantAbilityPermanently(FlyingTickAbility.GetAbilityData(), 1, LevelComparison.None, this); + _weaponFlyingAbility = Abilities.GrantAbilityPermanently(FlyingTickAbility.GetAbilityData(), 1, LevelComparison.None, this); foreach (var ability in WeaponAbilities) { @@ -257,7 +260,7 @@ public partial class WeaponSystem : RigidBody3D, IDamageDealer, IForgeEntity Events.Subscribe(WeaponStoppedFlyingEventTag, data => { // TODO: Waiting on bug resolve - // _weaponFlyingAbility.Cancel(); + _weaponFlyingAbility.Cancel(); }); } diff --git a/scenes/player_controller/components/weapon/weapon.tscn b/scenes/player_controller/components/weapon/weapon.tscn index 8b63e764..dd5c18ac 100644 --- a/scenes/player_controller/components/weapon/weapon.tscn +++ b/scenes/player_controller/components/weapon/weapon.tscn @@ -7,6 +7,7 @@ [ext_resource type="Script" uid="uid://couw105c3bde4" path="res://addons/godot_state_charts/state_chart.gd" id="3_5owyf"] [ext_resource type="Resource" uid="uid://busdbvfi3jiic" path="res://scenes/player_controller/resources/forge/exploding_sword_weapon_left.tres" id="3_7bruw"] [ext_resource type="ArrayMesh" uid="uid://cho5fixitrbds" path="res://assets/meshes/swords/resources/sword23.tres" id="3_svc06"] +[ext_resource type="Resource" uid="uid://bl0mng4kl1xy8" path="res://scenes/player_controller/resources/forge/exploding_sword_weapon_flight.tres" id="4_2wsgo"] [ext_resource type="Resource" uid="uid://btnnpqann3ktp" path="res://scenes/player_controller/resources/forge/weapon_flying_tick_ability.tres" id="4_7bruw"] [ext_resource type="Script" uid="uid://ccovd5i0wr3kk" path="res://addons/forge/editor/attributes/AttributeValues.cs" id="4_q6xv7"] [ext_resource type="Script" uid="uid://jk2jm1g6q853" path="res://addons/godot_state_charts/compound_state.gd" id="4_svc06"] @@ -59,7 +60,7 @@ continuous_cd = true contact_monitor = true max_contacts_reported = 1 script = ExtResource("1_csqwk") -WeaponAbilities = [ExtResource("2_pgbtr"), ExtResource("3_7bruw")] +WeaponAbilities = [ExtResource("2_pgbtr"), ExtResource("3_7bruw"), ExtResource("4_2wsgo")] FlyingTickAbility = ExtResource("4_7bruw") RDamage = SubResource("Resource_jpdh0") diff --git a/scenes/player_controller/resources/forge/inhibit_mana_regen_temporarily.tres b/scenes/player_controller/resources/forge/inhibit_mana_regen_temporarily.tres index 7af5f2cd..9dd1a139 100644 --- a/scenes/player_controller/resources/forge/inhibit_mana_regen_temporarily.tres +++ b/scenes/player_controller/resources/forge/inhibit_mana_regen_temporarily.tres @@ -9,7 +9,7 @@ [sub_resource type="Resource" id="Resource_gi65x"] script = ExtResource("1_bi1d8") -ContainerTags = Array[String](["character.player.mana.regen.inhibited"]) +ContainerTags = Array[String](["character.player.mana.regen.inhibited", "cues.resources.mana.inhibited"]) metadata/_custom_type_script = "uid://cw525n4mjqgw0" [sub_resource type="Resource" id="Resource_bi1d8"] diff --git a/scenes/player_controller/resources/forge/mana_regeneration.tres b/scenes/player_controller/resources/forge/mana_regeneration.tres index 0b0f8e9e..e4a7909c 100644 --- a/scenes/player_controller/resources/forge/mana_regeneration.tres +++ b/scenes/player_controller/resources/forge/mana_regeneration.tres @@ -10,7 +10,7 @@ [sub_resource type="Resource" id="Resource_5yygy"] script = ExtResource("1_q8tml") -ContainerTags = Array[String](["character.player.mana.regen.inhibited"]) +ContainerTags = Array[String](["character.player.mana.regen.inhibited", "cues.resources.mana.inhibited"]) metadata/_custom_type_script = "uid://cw525n4mjqgw0" [sub_resource type="Resource" id="Resource_ncjx6"] diff --git a/scenes/player_controller/resources/forge/raise_flying_tick_event.tres b/scenes/player_controller/resources/forge/raise_flying_tick_event.tres index 15c43f29..ef33886c 100644 --- a/scenes/player_controller/resources/forge/raise_flying_tick_event.tres +++ b/scenes/player_controller/resources/forge/raise_flying_tick_event.tres @@ -1,12 +1,9 @@ [gd_resource type="Resource" script_class="ForgeRaiseEventTagExecution" format=3 uid="uid://oe2suroa1klj"] -[ext_resource type="Script" uid="uid://cw525n4mjqgw0" path="res://addons/forge/resources/ForgeTagContainer.cs" id="1_iqjlm"] -[ext_resource type="Script" path="res://forge/calculators/ForgeRaiseEventTagExecution.cs" id="2_am2ak"] - -[sub_resource type="Resource" id="Resource_sxbq4"] -script = ExtResource("1_iqjlm") -ContainerTags = Array[String](["events.weapon.flyingtick"]) +[ext_resource type="Resource" uid="uid://x7vtcobi7s4r" path="res://scenes/player_controller/resources/forge/weapon_flyingtick_tagcontainer.tres" id="1_ce5fv"] +[ext_resource type="Script" uid="uid://br7ut4lbau66w" path="res://forge/calculators/ForgeRaiseEventTagExecution.cs" id="2_am2ak"] [resource] script = ExtResource("2_am2ak") +EventTags = ExtResource("1_ce5fv") metadata/_custom_type_script = "uid://br7ut4lbau66w" diff --git a/scenes/player_controller/resources/forge/weapon_flying_tick_ability.tres b/scenes/player_controller/resources/forge/weapon_flying_tick_ability.tres index e75155bd..a83a29b0 100644 --- a/scenes/player_controller/resources/forge/weapon_flying_tick_ability.tres +++ b/scenes/player_controller/resources/forge/weapon_flying_tick_ability.tres @@ -23,6 +23,7 @@ BaseValue = 1 [sub_resource type="Resource" id="Resource_esyoj"] script = ExtResource("4_e2sm2") +Name = "Call Flying Tick Event Periodically" Modifiers = [] Components = [] Executions = Array[Object]([ExtResource("1_pdt6v")]) diff --git a/scenes/player_controller/resources/forge/weapon_flyingtick_tagcontainer.tres b/scenes/player_controller/resources/forge/weapon_flyingtick_tagcontainer.tres new file mode 100644 index 00000000..2fc2f4c7 --- /dev/null +++ b/scenes/player_controller/resources/forge/weapon_flyingtick_tagcontainer.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="ForgeTagContainer" format=3 uid="uid://x7vtcobi7s4r"] + +[ext_resource type="Script" uid="uid://cw525n4mjqgw0" path="res://addons/forge/resources/ForgeTagContainer.cs" id="1_nlohk"] + +[resource] +script = ExtResource("1_nlohk") +ContainerTags = Array[String](["events.weapon.flyingtick"]) diff --git a/scenes/player_controller/scripts/PlayerController.cs b/scenes/player_controller/scripts/PlayerController.cs index 8f209f83..4726835b 100644 --- a/scenes/player_controller/scripts/PlayerController.cs +++ b/scenes/player_controller/scripts/PlayerController.cs @@ -15,6 +15,7 @@ using Gamesmiths.Forge.Godot.Core; using Gamesmiths.Forge.Godot.Nodes; using Gamesmiths.Forge.Godot.Resources; using Gamesmiths.Forge.Godot.Resources.Abilities; +using Gamesmiths.Forge.Statescript; using Gamesmiths.Forge.Tags; using Godot; @@ -29,6 +30,7 @@ using Movementtests.tools; using Movementtests.forge.abilities; using Movementtests.tools.calculators; using RustyOptions; +using Node = Godot.Node; public record struct EmpoweredActionPayload; @@ -103,6 +105,7 @@ public partial class PlayerController : CharacterBody3D, public EffectsManager EffectsManager { get; set; } = null!; public EntityAbilities Abilities { get; set; } = null!; public EventManager Events { get; set; } = null!; + public Variables SharedVariables { get; } // Inspector stuff [Export] public Marker3D TutorialWeaponTarget = null!; @@ -763,7 +766,6 @@ public partial class PlayerController : CharacterBody3D, effectComponents: [leftGrantComponent]); EffectsManager.ApplyEffect(new Effect(leftGrantEffect, new EffectOwnership(this, this))); } - // GetTree().CreateTimer(5).Timeout += () => WeaponSystem.GrantNewAbilityForWeaponFly(weaponLandAbility); // Forge events var weaponLeftToken = WeaponSystem.Events.Subscribe(WeaponSystem.WeaponStartedFlyingEventTag, OnWeaponLeft); var weaponLandedToken = WeaponSystem.Events.Subscribe(WeaponSystem.WeaponStoppedFlyingEventTag, OnWeaponLanded);