Files
sbox-public/game/editor/ActionGraph/Code/ActionPlug.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

917 lines
22 KiB
C#

using Editor.NodeEditor;
using Facepunch.ActionGraphs;
using Sandbox.ActionGraphs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using static Editor.NodeEditor.GraphView;
using DisplayInfo = Facepunch.ActionGraphs.DisplayInfo;
namespace Editor.ActionGraphs;
public interface IActionPlug : IPlug
{
Type LastType { get; set; }
int Index { get; }
}
public abstract class ActionPlug<T> : IActionPlug where T : Node.IParameter
{
private readonly T _defaultParam;
public EditorNode Node { get; }
public T Parameter => Source.TryGetValue( Identifier, out var param ) ? param : _defaultParam;
public int Index { get; set; }
public virtual bool IsArrayElement => false;
public IReadOnlyDictionary<string, T> Source { get; }
public Type LastType { get; set; }
INode IPlug.Node => Node;
public string Identifier { get; }
public virtual Type Type => Parameter.Type;
public string Title => Parameter.Node == Node.Node
? Parameter.Display.Title
: GetAccessorNodeTitle( Parameter.Node );
private static string GetAccessorNodeTitle( Node node )
{
var title = node.DisplayInfo.Title;
if ( title.LastIndexOf( '→' ) is var index and not -1 ) title = title[(index + 1)..].TrimStart();
if ( title.StartsWith( "Get " ) || title.StartsWith( "Set " ) ) title = title[4..];
return title;
}
public Sandbox.DisplayInfo DisplayInfo => new()
{
Name = !IsArrayElement && Index == 0
? Title
: $"{Title}[{Index}]",
Description = Parameter.Node.Parent != null
? Parameter.Node.DisplayInfo.Description
: Parameter.Display.Description,
Group = GetGroupName()
};
private string GetGroupName()
{
var node = Parameter.Node;
string groupName = null;
while ( node.Parent is { } parent && node != Node.Node )
{
var parentOutputTitle = parent == Node.Node
? node.Inputs.Values.First( x => x.Link is not null )
.Link!.Source.Display.Title
: GetAccessorNodeTitle( parent );
if ( groupName is null )
{
groupName = parentOutputTitle;
}
else
{
groupName = $"{parentOutputTitle} > {groupName}";
}
node = parent;
}
return groupName;
}
protected ActionPlug( EditorNode node, IReadOnlyDictionary<string, T> source, string identifier, int index )
{
Node = node;
Source = source;
Identifier = identifier;
Index = index;
LastType = Type;
_defaultParam = Parameter;
}
public virtual ValueEditor CreateEditor( NodeUI node, Plug plug )
{
return null;
}
public virtual Menu CreateContextMenu( NodeUI node, Plug plug )
{
return null;
}
protected Menu CreateDisplayInfoMenu( Menu menu, Action<DisplayInfo> changed )
{
var display = Parameter.Display;
var edit = menu.AddMenu( "Edit", "edit" );
edit.AddLineEdit( "Title", value: display.Title,
onChange: value => changed( display with { Title = value } ) );
edit.AddLineEdit( "Description", value: display.Description,
onChange: value => changed( display with { Description = value } ) );
return menu;
}
public virtual void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e )
{
}
public abstract bool ShowConnection { get; }
public bool ShowLabel => Node.HasTitleBar && !InTitleBar;
public bool AllowStretch => false;
public bool InTitleBar => Node.HasTitleBar && (Parameter.Name == "_signal" || Node.Inputs.Count <= 1 && Node.Outputs.Count <= 1);
public virtual bool IsReachable => Node.IsReachable;
public string ErrorMessage => string.Join( Environment.NewLine, Parameter.GetMessages()
.Where( x => x.IsError )
.Select( x => x.Value ) );
public override string ToString()
{
return $"{Node.Identifier}.{Identifier}";
}
}
public static class NodeOutputExtensions
{
public static string GetLabel( this Node.Output output )
{
if ( output.Node.Definition == EditorNodeLibrary.Input
&& output.Node.ActionGraph.Inputs.TryGetValue( output.Name, out var inputDef ) )
{
// Hack so that references to special hidden inputs will stay connected on copy / paste.
if ( inputDef.IsTarget )
{
return "__this";
}
if ( inputDef.Type == typeof( CancellationToken ) )
{
return "__ct";
}
}
return output.Node.UserData[ActionOutputPlug.LabelsKey]?[output.Name]?.GetValue<string>();
}
public static string GetNiceLabel( this Node.Output output )
{
if ( output.Node.Definition == EditorNodeLibrary.Input
&& output.Node.ActionGraph.Inputs.TryGetValue( output.Name, out var inputDef ) )
{
if ( inputDef.IsTarget )
{
return "This";
}
if ( inputDef.Type == typeof( CancellationToken ) )
{
return "Cancellation Token";
}
}
return output.GetLabel();
}
}
public class ActionOutputPlug : ActionPlug<Node.Output>, IPlugOut
{
public const string LabelsKey = "Labels";
public string Label
{
get => Parameter.GetLabel();
set
{
var labels = Parameter.Node.UserData[LabelsKey]?.AsObject();
if ( value is null )
{
labels?.Remove( Identifier );
if ( labels is { Count: 0 } )
{
Parameter.Node.UserData.Remove( LabelsKey );
}
return;
}
if ( labels is null )
{
Parameter.Node.UserData[LabelsKey] = labels = new JsonObject();
}
labels[Identifier] = value;
}
}
public override bool ShowConnection => Label is null;
public override bool IsReachable => base.IsReachable && (Parameter.Node == Node.Node || Parameter.IsLinked);
public ActionOutputPlug( EditorNode node, IReadOnlyDictionary<string, Node.Output> source, string identifier, int index ) : base( node, source, identifier, index )
{
}
public override ValueEditor CreateEditor( NodeUI node, Plug plug )
{
if ( Parameter.Node.Definition == EditorNodeLibrary.NoOperation )
{
return null;
}
return new DefaultOutputEditor( node, (PlugOut)plug );
}
public override Menu CreateContextMenu( NodeUI node, Plug plug )
{
if ( Parameter.Node.Definition == EditorNodeLibrary.NoOperation )
{
return null;
}
var editorActionGraph = (EditorActionGraph)node.Graph.Graph;
var actionGraph = editorActionGraph!.Graph;
var type = Type;
var menu = new Menu( plug.Title );
menu.AddLineEdit( "Label", Label,
autoFocus: true,
onSubmit: value =>
{
node.Graph?.PushUndo( "Set Label" );
Label = string.IsNullOrWhiteSpace( value ) ? null : value;
(node.Node as EditorNode)?.MarkDirty();
node.Graph?.PushRedo();
} );
var agNode = Parameter.Node;
if ( agNode.Definition == EditorNodeLibrary.Input )
{
CreateDisplayInfoMenu( menu, value =>
{
agNode.ActionGraph.SetParameters(
agNode.ActionGraph.Inputs.Values
.Select( x => x.Name == Parameter.Name ? x with { Display = value } : x ).ToArray(),
agNode.ActionGraph.Outputs.Values.ToArray() );
agNode.MarkDirty();
node.MarkNodeChanged();
} );
}
if ( Parameter.Node.Parent is not null && !Parameter.IsUsed() )
{
menu.AddOption( "Hide", "visibility_off", () =>
{
node.Graph?.PushUndo( "Hide Expanded output" );
if ( !Parameter.IsAnyExpandedOutputVisible() )
{
Parameter.Node.Remove();
}
else
{
Parameter.Node.SetVisible( false );
}
(node.Node as EditorNode)?.MarkDirty();
node.Graph?.PushRedo();
} );
}
if ( Parameter.IsLinked && Parameter.IsUsed() )
{
menu.AddOption( "Clear", "clear", () =>
{
node.Graph?.PushUndo( "Clear Links" );
foreach ( var link in Parameter.Links.ToArray() )
{
if ( link.Target.Node.Parent != null )
{
continue;
}
link.Remove();
}
(node.Node as EditorNode)?.MarkDirty();
node.Graph?.PushRedo();
} );
}
menu.AddSeparator();
if ( !Parameter.IsSignal && editorActionGraph.CanModifyParameters )
{
var createOutputMenu = menu.AddMenu( "Create Graph Output", "add_box" );
createOutputMenu.AboutToShow += () =>
{
createOutputMenu.Clear();
createOutputMenu.AddLineEdit( "Name", autoFocus: true, onSubmit: value =>
{
if ( string.IsNullOrEmpty( value ) ) return;
node.Graph.PushUndo( "Create Graph Output" );
var name = actionGraph.GetNextOutputName();
var output = new OutputDefinition( name, Parameter.Type, 0, new DisplayInfo( value ) );
var hadOutputNde = actionGraph.PrimaryOutputNode is not null;
actionGraph.SetParameters( actionGraph.Inputs.Values.ToArray(), actionGraph.Outputs.Values.With( output ) );
actionGraph.AddRequiredNodes();
actionGraph.PrimaryOutputNode!.UpdateParameters();
actionGraph.PrimaryOutputNode.Inputs[name].SetLink( Parameter );
if ( !hadOutputNde )
{
var editorOutputNode = new EditorNode( editorActionGraph, actionGraph.PrimaryOutputNode );
editorActionGraph.AddNode( editorOutputNode );
node.Graph.BuildFromNodes( new[] { editorOutputNode }, true );
}
editorActionGraph.MarkDirty( Parameter.Node );
editorActionGraph.MarkDirty( actionGraph.PrimaryOutputNode );
node.Graph.PushRedo();
} );
};
}
if ( editorActionGraph.CanModifyParameters && Parameter.Node.Definition == EditorNodeLibrary.Input && Parameter.Name.StartsWith( "_in" ) )
{
menu.AddOption( "Remove Graph Input", "delete", () =>
{
foreach ( var link in Parameter.Links.ToArray() )
{
if ( link.IsArrayElement )
{
link.Target.SetLink( new Constant( null ), link.ArrayIndex );
}
else
{
link.Target.ClearLinks();
}
}
var input = actionGraph.Inputs.Values.First( x => x.Name == Parameter.Name );
actionGraph.SetParameters(
actionGraph.Inputs.Values.Without( input ),
actionGraph.Outputs.Values.ToArray() );
actionGraph.InputNode!.UpdateParameters();
editorActionGraph.MarkDirty( actionGraph.InputNode );
} );
}
EditorEvent.Run( PopulateOutputPlugMenuEvent.EventName,
new PopulateOutputPlugMenuEvent( (ActionGraphView)node.Graph, this, menu ) );
if ( !Parameter.IsSignal )
{
menu.AddSeparator();
PopulateExpandedOutputMenu( this, node, menu );
}
return menu;
}
public override void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e )
{
e.Accepted = true;
var visibleChildren = Parameter
.GetExpandedOutputs( true )
.Where( x => x != Parameter )
.Where( x => !x.IsUsed() )
.ToArray();
if ( visibleChildren.Length > 0 )
{
foreach ( var visibleChild in visibleChildren )
{
if ( !visibleChild.IsAnyExpandedOutputVisible() )
{
visibleChild.Node.Remove();
}
else
{
visibleChild.Node.SetVisible( false );
}
}
}
else
{
var nodeTypes = ((ActionGraphView)node.Graph).GetExpansionOptions( this )
.ToArray();
var autoExpanded = nodeTypes
.Where( x => x.Inner.AutoExpand == true )
.ToArray();
if ( autoExpanded.Length > 0 )
{
nodeTypes = autoExpanded;
}
foreach ( var nodeType in nodeTypes )
{
Expand( nodeType.Inner, node );
}
}
}
private static Node FindExpandedOutput( Node.Output output, LibraryNodeType nodeType )
{
return output.Node.Children
.Where( x => x.Definition == nodeType.Definition )
.Where( x => x.Inputs.Values.FirstOrDefault( y => y.IsTarget )?.Link?.Source is { } source && source == output )
.Where( x =>
nodeType.Properties.All( y =>
x.Properties.TryGetValue( y.Key, out var property ) && property.Value == y.Value ) )
.FirstOrDefault( x =>
nodeType.Inputs.All( y =>
x.Inputs.TryGetValue( y.Key, out var input ) && input.Value == y.Value ) );
}
private void Expand( LibraryNodeType type, NodeUI nodeUi )
{
var child = FindExpandedOutput( Parameter, type );
nodeUi.Graph?.PushUndo( "Show Expanded output" );
if ( child != null )
{
child.SetVisible( true );
}
else
{
child = type.CreateNode( (EditorActionGraph)nodeUi.Graph!.Graph, Parameter.Node ).Node;
child.Inputs.Values.FirstOrDefault( x => x.IsTarget )?.SetLink( Parameter );
}
Node.MarkDirty();
nodeUi.Graph?.PushRedo();
}
private static void PopulateExpandedOutputMenu( ActionOutputPlug plug, NodeUI nodeUi, Menu menu )
{
var nodeTypes = ((ActionGraphView)nodeUi.Graph)
.GetExpansionOptions( plug )
.ToArray();
PopulateNodeMenu( menu, nodeTypes, null, type =>
{
plug.Expand( ((SelectedOutputNodeType)type).Inner, nodeUi );
} );
}
}
public class ActionInputPlug : ActionPlug<Node.Input>, IPlugIn
{
public ActionInputPlug( EditorNode node, IReadOnlyDictionary<string, Node.Input> source, string identifier, int index ) : base( node, source, identifier, index )
{
}
public Link InputLink => IsArrayElement
? Index >= 0 && Index < Parameter.LinkArray!.Count ? Parameter.LinkArray[Index] : null
: Parameter.Link;
public Node InputNestedNode
{
get
{
var link = InputLink;
if ( link is not { Source.Node: { Parent: { } parent } node } )
{
return null;
}
if ( parent != Node.Node )
{
return null;
}
return node;
}
}
public override Type Type => InputLink?.Source.Type ?? (Parameter.LinkArray is not null ? Parameter.ElementType : Parameter.Type);
public IPlugOut ConnectedOutput
{
get
{
if ( IsArrayElement )
{
if ( Parameter.LinkArray is not { } links )
{
return null;
}
if ( Index < 0 || Index >= links.Count )
{
return null;
}
return links[Index] is { Source: { } output }
? Node.Graph.FindNode( output.Node )?.Outputs[output]
: null;
}
else
{
if ( Index is 0 && Parameter.Link is { Source: { } output } )
{
return Node.Graph.FindNode( output.Node )?.Outputs[output];
}
}
return null;
}
set
{
if ( value is null )
{
if ( !Parameter.IsLinked )
{
return;
}
if ( Index == 0 && Parameter.LinkArray?.Count == 1 || !Parameter.IsArray )
{
Parameter.ClearLinks();
}
else
{
var defaultValue = Parameter.ElementType.IsValueType
? Activator.CreateInstance( Parameter.ElementType )
: null;
Parameter.SetLink( new Constant( defaultValue ), Index );
}
Node.MarkDirty();
return;
}
if ( value is not ActionPlug<Node.Output> { Parameter: { } output } )
{
return;
}
if ( !Parameter.IsArray || value.Type.IsArray && Index is 0 && Parameter.LinkArray is null )
{
Parameter.SetLink( output );
Node.MarkDirty();
return;
}
if ( Index < (Parameter.LinkArray?.Count ?? 0) )
{
Parameter.SetLink( output, Index );
}
else
{
Parameter.InsertLink( output, Index );
Node.MarkDirty();
}
}
}
public override bool ShowConnection => true;
public override bool IsArrayElement => Parameter is { LinkArray: not null };
public override ValueEditor CreateEditor( NodeUI node, Plug plug )
{
if ( Node.Definition == EditorNodeLibrary.NoOperation )
{
return null;
}
return new DefaultInputEditor( node, (PlugIn)plug );
}
public override Menu CreateContextMenu( NodeUI node, Plug plug )
{
if ( Node.Definition == EditorNodeLibrary.NoOperation )
{
return null;
}
var editorActionGraph = (EditorActionGraph)node.Graph.Graph;
var actionGraph = editorActionGraph!.Graph;
var type = Parameter.LinkArray is not null ? Parameter.ElementType : Parameter.Type;
var matchingVariables = Parameter.Node.ActionGraph.Variables.Values
.Where( x => !Parameter.IsSignal && x.Type.IsAssignableToExtended( type ) )
.ToArray();
var matchingLabeledOutputs = Parameter.Node.ActionGraph.Nodes.Values
.SelectMany( x => x.Outputs.Values )
.Where( x => x.GetLabel() is { } )
.Where( x => x.IsSignal == Parameter.IsSignal && (Parameter.IsSignal || x.Type.IsAssignableToExtended( type )) )
.ToArray();
var matchingComponents = editorActionGraph.AvailableComponentTypes
.Where( x => !Parameter.IsSignal && x.IsAssignableToExtended( type ) )
.ToArray();
var menu = new Menu( plug.Title );
var agNode = Parameter.Node;
if ( agNode.Definition == EditorNodeLibrary.Output )
{
CreateDisplayInfoMenu( menu, value =>
{
agNode.ActionGraph.SetParameters(
agNode.ActionGraph.Inputs.Values.ToArray(),
agNode.ActionGraph.Outputs.Values
.Select( x => x.Name == Parameter.Name ? x with { Display = value } : x ).ToArray() );
agNode.MarkDirty();
} );
}
if ( Parameter.IsLinked && (!IsArrayElement || Index < Parameter.LinkArray!.Count) )
{
menu.AddOption( "Clear", "clear", () =>
{
node.Graph?.PushUndo( "Clear Link" );
if ( IsArrayElement && Index <= Parameter.LinkArray!.Count )
{
var defaultValue = Parameter.ElementType.IsValueType
? Activator.CreateInstance( Parameter.ElementType )
: null;
Parameter.SetLink( new Constant( defaultValue ), Index );
}
else
{
Parameter.ClearLinks();
}
(node.Node as EditorNode)?.MarkDirty();
node.Graph?.PushRedo();
} );
}
if ( IsArrayElement && Index < Parameter.LinkArray!.Count )
{
menu.AddOption( "Remove", "remove", () =>
{
node.Graph?.PushUndo( "Remove Link" );
Parameter.LinkArray[Index].Remove();
(node.Node as EditorNode)?.MarkDirty();
node.Graph?.PushRedo();
} );
}
if ( IsArrayElement )
{
menu.AddOption( "Remove All", "playlist_remove", () =>
{
node.Graph?.PushUndo( "Remove All Links" );
Parameter.ClearLinks();
(node.Node as EditorNode)?.MarkDirty();
node.Graph?.PushRedo();
} );
}
menu.AddSeparator();
if ( !Parameter.IsSignal )
{
var createVariableMenu = menu.AddMenu( "Create Variable", "add_box" );
createVariableMenu.AboutToShow += () =>
{
createVariableMenu.Clear();
createVariableMenu.AddLineEdit( "Name", autoFocus: true, onSubmit: value =>
{
if ( string.IsNullOrEmpty( value ) ) return;
node.Graph?.PushUndo( "Create Variable" );
var variable = actionGraph.AddVariable( value, type );
Parameter.SetLink( variable );
node.Graph?.PushRedo();
} );
};
if ( editorActionGraph.CanModifyParameters )
{
var createInputMenu = menu.AddMenu( "Create Graph Input", "add_box" );
createInputMenu.AboutToShow += () =>
{
createInputMenu.Clear();
createInputMenu.AddLineEdit( "Name", autoFocus: true, onSubmit: value =>
{
if ( string.IsNullOrEmpty( value ) ) return;
node.Graph.PushUndo( "Create Graph Input" );
var name = actionGraph.GetNextInputName();
var input = new InputDefinition( name, Parameter.Type, InputFlags.Required, new DisplayInfo( value ) );
actionGraph.SetParameters( actionGraph.Inputs.Values.With( input ), actionGraph.Outputs.Values.ToArray() );
actionGraph.InputNode!.UpdateParameters();
Parameter.SetLink( actionGraph.InputNode.Outputs[name] );
editorActionGraph.MarkDirty( actionGraph.InputNode );
node.Graph.PushRedo();
} );
};
}
}
if ( editorActionGraph.CanModifyParameters && Parameter.Node.Definition == EditorNodeLibrary.Output && Parameter.Name.StartsWith( "_out" ) )
{
menu.AddOption( "Remove Graph Output", "delete", () =>
{
Parameter.ClearLinks();
var output = actionGraph.Outputs.Values.First( x => x.Name == Parameter.Name );
actionGraph.SetParameters( actionGraph.Inputs.Values.ToArray(),
actionGraph.Outputs.Values.Without( output ) );
actionGraph.PrimaryOutputNode!.UpdateParameters();
editorActionGraph.MarkDirty( actionGraph.PrimaryOutputNode );
} );
}
if ( matchingVariables.Length > 0 )
{
var varMenu = menu.AddMenu( "Use Variable", "inbox" );
foreach ( var variable in matchingVariables )
{
varMenu.AddOption( variable.Name, "inbox", () =>
{
node.Graph?.PushUndo( "Create Variable Link" );
SetLink( node, variable );
node.Graph?.PushRedo();
} );
}
}
if ( matchingLabeledOutputs.Length > 0 )
{
var labelMenu = menu.AddMenu( "Use Labeled Output", "link" );
foreach ( var output in matchingLabeledOutputs )
{
labelMenu.AddOption( output.GetNiceLabel(), "link", () =>
{
node.Graph?.PushUndo( "Create Labeled Output Link" );
SetLink( node, output );
node.Graph?.PushRedo();
} );
}
}
if ( matchingComponents.Length > 0 )
{
var componentMenu = menu.AddMenu( "Use Component", "category" );
foreach ( var componentType in matchingComponents )
{
var typeDesc = TypeLibrary.GetType( componentType );
componentMenu.AddOption( typeDesc.Title, typeDesc.Icon ?? "category", () =>
{
node.Graph?.PushUndo( "Create Component Link" );
var nodeType = new LocalTargetNodeType( new ComponentNodeType( typeDesc ) );
var getNode = nodeType.CreateNode( actionGraph, agNode );
SetLink( node, getNode.Outputs.Result );
node.Graph?.PushRedo();
} );
}
}
EditorEvent.Run( PopulateInputPlugMenuEvent.EventName,
new PopulateInputPlugMenuEvent( (ActionGraphView)node.Graph, this, menu ) );
return menu;
}
private void SetLink( NodeUI node, ILinkSource source )
{
if ( IsArrayElement )
{
if ( Index < Parameter.LinkArray!.Count )
{
Parameter.SetLink( source, Index );
}
else
{
Parameter.InsertLink( source, Index );
}
}
else
{
Parameter.SetLink( source );
}
(node.Node as EditorNode)?.MarkDirty();
}
public void GoToSource( NodeUI node, Plug plug )
{
if ( ConnectedOutput is ActionOutputPlug connectedOutput )
{
node.Graph.SelectNode( connectedOutput.Node );
node.Graph.CenterOnSelection();
return;
}
var eventArgs = new GoToPlugSourceEvent( (ActionGraphView)node.Graph, this );
EditorEvent.Run( GoToPlugSourceEvent.EventName, eventArgs );
if ( eventArgs.Handled )
{
return;
}
node.Graph.SelectNode( Node );
if ( node.Graph is ActionGraphView actionGraphView )
{
actionGraphView.FocusOnInput( Parameter, IsArrayElement ? Index : null );
}
}
public override void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e )
{
e.Accepted = true;
GoToSource( node, plug );
}
public float? GetHandleOffset( string name )
{
return InputLink?.UserData[name]?.GetValue<float>();
}
public void SetHandleOffset( string name, float? value )
{
if ( InputLink is { } link )
{
if ( value is null ) link.UserData.Remove( name );
else link.UserData[name] = value;
}
}
}