Files
MovementTests/addons/forge/editor/statescript/StatescriptGraphNode.cs
Minimata 1d856fd937
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
Replicated the weapon flying tick setup using resources
2026-04-07 16:32:26 +02:00

629 lines
16 KiB
C#

// 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