Replicated the weapon flying tick setup using resources
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 5m42s

This commit is contained in:
2026-04-07 16:32:26 +02:00
parent cc7cb90041
commit 1d856fd937
145 changed files with 12943 additions and 109 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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