Editor/StateMachineView.cs
using Editor;
using System.Collections.Generic;
using System;
using System.Linq;
using Editor.NodeEditor;
using Facepunch.ActionGraphs;
using System.IO.Compression;
using System.IO;
using System.Text;
using Sandbox.Utility;
using Sandbox.UI;
namespace Sandbox.States.Editor;
public interface IContextMenuSource
{
void OnContextMenu( ContextMenuEvent e );
}
public interface IDeletable
{
void Delete();
}
public interface IDoubleClickable
{
void DoubleClick();
}
public class StateMachineView : GraphicsView
{
private static Dictionary<Guid, StateMachineView> AllViews { get; } = new Dictionary<Guid, StateMachineView>();
public static StateMachineView Open( StateMachineComponent stateMachine )
{
var guid = stateMachine.Id;
if ( !AllViews.TryGetValue( guid, out var inst ) || !inst.IsValid )
{
var window = StateMachineEditorWindow.AllWindows.LastOrDefault( x => x.IsValid )
?? new StateMachineEditorWindow();
AllViews[guid] = inst = window.Open( stateMachine );
}
inst.Window?.Show();
inst.Window?.Focus();
inst.Show();
inst.Focus();
inst.Window?.DockManager.RaiseDock( inst.Name );
return inst;
}
public StateMachineComponent StateMachine { get; }
public StateMachineEditorWindow Window { get; }
GraphView.SelectionBox? _selectionBox;
private bool _dragging;
private Vector2 _lastMouseScenePosition;
private Vector2 _lastCenter;
private Vector2 _lastScale;
private readonly Dictionary<State, StateItem> _stateItems = new();
private readonly Dictionary<Transition, TransitionItem> _transitionItems = new();
private readonly Dictionary<UnorderedPair<int>, List<TransitionItem>> _neighboringTransitions = new( EqualityComparer<UnorderedPair<int>>.Default );
private TransitionItem? _transitionPreview;
private bool _wasDraggingTransition;
private string? _lastEditName;
private readonly Stack<(string Name, string Json)> _undoStack = new();
private readonly Stack<(string Name, string Json)> _redoStack = new();
public float GridSize => 32f;
private string ViewCookie => $"statemachine.{StateMachine.Id}";
public StateMachineView( StateMachineComponent stateMachine, StateMachineEditorWindow window )
: base( null )
{
StateMachine = stateMachine;
Window = window;
Name = $"View:{stateMachine.Id}";
WindowTitle = $"{stateMachine.Scene.Name} - {stateMachine.GameObject.Name}";
SetBackgroundImage( "toolimages:/grapheditor/grapheditorbackgroundpattern_shader.png" );
Antialiasing = true;
TextAntialiasing = true;
BilinearFiltering = true;
SceneRect = new Rect( -100000, -100000, 200000, 200000 );
HorizontalScrollbar = ScrollbarMode.Off;
VerticalScrollbar = ScrollbarMode.Off;
MouseTracking = true;
UpdateItems();
PushHistoryInternal( "Initial" );
}
protected override void OnFocus( FocusChangeReason reason )
{
base.OnFocus( reason );
Window.OnFocusView( this );
}
protected override void OnClosed()
{
base.OnClosed();
Window.OnRemoveView( this );
if ( AllViews.TryGetValue( StateMachine.Id, out var view ) && view == this )
{
AllViews.Remove( StateMachine.Id );
}
}
protected override void OnWheel( WheelEvent e )
{
Zoom( e.Delta > 0 ? 1.1f : 0.90f, e.Position );
e.Accept();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( e.IsDoubleClick )
{
if ( GetItemAt( ToScene( e.LocalPosition ) ) is IDoubleClickable target )
{
target.DoubleClick();
e.Accepted = true;
return;
}
}
if ( e.MiddleMouseButton )
{
e.Accepted = true;
return;
}
if ( e.RightMouseButton )
{
e.Accepted = GetItemAt( ToScene( e.LocalPosition ) ) is null;
return;
}
if ( e.LeftMouseButton )
{
_dragging = true;
}
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
_selectionBox?.Destroy();
_selectionBox = null;
_dragging = false;
if ( _stateItems.Values.Any( x => x.HasMoved ) )
{
LogEdit( "State Moved" );
foreach ( var stateItem in _stateItems.Values )
{
stateItem.HasMoved = false;
}
}
if ( _transitionPreview?.Target is { } target )
{
LogEdit( "Transition Added" );
var transition = _transitionPreview.Source.State.AddTransition( target.State );
if ( _transitionPreview.Transition is { } copy )
{
transition.CopyFrom( copy );
}
AddTransitionItem( transition );
}
if ( _transitionPreview is not null )
{
_transitionPreview?.Destroy();
_transitionPreview = null;
_wasDraggingTransition = true;
e.Accepted = true;
UpdateTransitionNeighbors();
}
}
protected override void OnMouseMove( MouseEvent e )
{
var scenePos = ToScene( e.LocalPosition );
if ( _dragging && e.ButtonState.HasFlag( MouseButtons.Left ) && !_transitionPreview.IsValid() )
{
if ( !_selectionBox.IsValid() && !SelectedItems.Any( x => x.IsValid() && x.Movable ) && !Items.Any( x => x.Hovered ) )
{
Add( _selectionBox = new GraphView.SelectionBox( scenePos, this ) );
}
if ( _selectionBox != null )
{
_selectionBox.EndScene = scenePos;
}
}
else if ( _dragging )
{
_selectionBox?.Destroy();
_selectionBox = null;
_dragging = false;
}
if ( e.ButtonState.HasFlag( MouseButtons.Middle ) ) // or space down?
{
var delta = scenePos - _lastMouseScenePosition;
Translate( delta );
e.Accepted = true;
Cursor = CursorShape.ClosedHand;
}
else
{
Cursor = CursorShape.None;
}
if ( _transitionPreview.IsValid() )
{
var oldTarget = _transitionPreview.Target;
_transitionPreview.TargetPosition = scenePos;
if ( GetStateItemAt( scenePos ) is { } newTarget && newTarget != _transitionPreview.Source )
{
_transitionPreview.Target = newTarget;
}
else
{
_transitionPreview.Target = null;
}
if ( oldTarget != _transitionPreview.Target )
{
UpdateTransitionNeighbors();
}
_transitionPreview.Layout();
}
e.Accepted = true;
_lastMouseScenePosition = ToScene( e.LocalPosition );
}
private StateItem? GetStateItemAt( Vector2 scenePos )
{
return GetItemAt( scenePos ) switch
{
StateItem stateItem => stateItem,
StateLabel stateLabel => stateLabel.State,
_ => null
};
}
protected override void OnContextMenu( ContextMenuEvent e )
{
if ( _wasDraggingTransition )
{
return;
}
var menu = new global::Editor.Menu { DeleteOnClose = true };
var scenePos = ToScene( e.LocalPosition );
if ( GetItemAt( scenePos ) is IContextMenuSource source )
{
source.OnContextMenu( e );
if ( e.Accepted ) return;
}
e.Accepted = true;
menu.AddHeading( "Create State" );
menu.AddLineEdit( "Name", autoFocus: true, onSubmit: name =>
{
LogEdit( "State Added" );
using var _ = StateMachine.Scene.Push();
var state = StateMachine.AddState();
state.Name = name ?? "Unnamed";
state.EditorPosition = scenePos.SnapToGrid( GridSize ) - 64f;
if ( !StateMachine.InitialState.IsValid() )
{
StateMachine.InitialState = state;
}
AddStateItem( state );
} );
menu.OpenAtCursor( true );
}
[EditorEvent.Frame]
private void OnFrame()
{
SaveViewCookie();
if ( _lastEditName is not null )
{
StateMachine.Scene.EditLog( _lastEditName, StateMachine );
PushHistoryInternal( _lastEditName );
_lastEditName = null;
}
_wasDraggingTransition = false;
var needsUpdate = false;
foreach ( var (state, item) in _stateItems )
{
if ( !state.IsValid )
{
needsUpdate = true;
break;
}
}
foreach ( var (transition, item) in _transitionItems )
{
if ( !transition.IsValid )
{
needsUpdate = true;
break;
}
}
if ( needsUpdate )
{
UpdateItems();
}
foreach ( var item in _stateItems.Values )
{
item.Frame();
}
foreach ( var item in _transitionItems.Values )
{
item.Frame();
}
}
[Shortcut( "Reset View", "Home", ShortcutType.Window )]
private void OnResetView()
{
var defaultView = GetDefaultView();
_lastScale = Scale = defaultView.Scale;
_lastCenter = Center = defaultView.Center;
}
private void SaveViewCookie()
{
var center = Center;
var scale = Scale;
if ( _lastCenter == center && _lastScale == scale )
{
return;
}
if ( ViewCookie is { } viewCookie )
{
if ( _lastCenter != center )
{
EditorCookie.Set( $"{viewCookie}.view.center", center );
}
if ( _lastScale != scale )
{
EditorCookie.Set( $"{viewCookie}.view.scale", scale );
}
}
_lastCenter = center;
_lastScale = scale;
}
private void RestoreViewFromCookie()
{
if ( ViewCookie is not { } cookieName ) return;
var defaultView = GetDefaultView();
Scale = EditorCookie.Get( $"{cookieName}.view.scale", defaultView.Scale );
Center = EditorCookie.Get( $"{cookieName}.view.center", defaultView.Center );
}
private (Vector2 Center, Vector2 Scale) GetDefaultView()
{
if ( _stateItems.Count == 0 )
{
return (Vector2.Zero, Vector2.One);
}
var allBounds = _stateItems.Values
.Select( x => new Rect( x.Position, x.Size ) )
.ToArray();
var bounds = allBounds[0];
foreach ( var rect in allBounds.Skip( 1 ) )
{
bounds.Add( rect );
}
// TODO: resize to fit
return (bounds.Center, Vector2.One);
}
private readonly struct UnorderedPair<T> : IEquatable<UnorderedPair<T>>
where T : IEquatable<T>
{
public T A { get; }
public T B { get; }
public UnorderedPair( T a, T b )
{
A = a;
B = b;
}
public bool Equals( UnorderedPair<T> other )
{
return A.Equals( other.A ) && B.Equals( other.B ) || A.Equals( other.B ) && B.Equals( other.A );
}
public override int GetHashCode()
{
return A.GetHashCode() ^ B.GetHashCode();
}
}
public void UpdateItems()
{
ItemHelper<State, StateItem>.Update( this, StateMachine.States, _stateItems, AddStateItem );
var transitionsChanged = ItemHelper<Transition, TransitionItem>.Update( this, StateMachine.States.SelectMany( x => x.Transitions ), _transitionItems, AddTransitionItem );
if ( transitionsChanged )
{
UpdateTransitionNeighbors();
}
RestoreViewFromCookie();
}
private void UpdateTransitionNeighbors()
{
_neighboringTransitions.Clear();
foreach ( var item in Items.OfType<TransitionItem>().Where( x => x.Target is not null ) )
{
var key = new UnorderedPair<int>( item.Source.State.Id, item.Target!.State.Id );
if ( !_neighboringTransitions.TryGetValue( key, out var list ) )
{
_neighboringTransitions[key] = list = new List<TransitionItem>();
}
list.Add( item );
}
foreach ( var list in _neighboringTransitions.Values )
{
list.Sort();
foreach ( var item in list )
{
item.Layout();
}
}
}
private void AddStateItem( State state )
{
var item = new StateItem( this, state );
_stateItems.Add( state, item );
Add( item );
}
private void AddTransitionItem( Transition transition )
{
var source = GetStateItem( transition.Source );
var target = GetStateItem( transition.Target );
if ( source is null || target is null ) return;
var item = new TransitionItem( transition, source, target );
_transitionItems.Add( transition, item );
Add( item );
}
public StateItem? GetStateItem( State state )
{
return _stateItems!.GetValueOrDefault( state );
}
public TransitionItem? GetTransitionItem( Transition transition )
{
return _transitionItems!.GetValueOrDefault( transition );
}
public (int Index, int Count) GetTransitionPosition( TransitionItem item )
{
if ( item.Target is null )
{
return (0, 1);
}
var key = new UnorderedPair<int>( item.Source.State.Id, item.Target.State.Id );
if ( !_neighboringTransitions.TryGetValue( key, out var list ) )
{
return (0, 1);
}
return (list.IndexOf( item ), list.Count);
}
public void StartCreatingTransition( StateItem source, Transition? copy = null )
{
DeselectAll();
_transitionPreview?.Destroy();
_transitionPreview = new TransitionItem( copy, source, null )
{
TargetPosition = source.Center
};
_transitionPreview.Layout();
Add( _transitionPreview );
}
public void DeselectAll()
{
foreach ( var item in SelectedItems.Where( x => x.IsValid ).ToArray() )
{
item.Selected = false;
}
}
public void SelectAll()
{
foreach ( var item in Items.Where( x => x.Selectable ) )
{
item.Selected = true;
}
}
private IDisposable PushSerializationScope()
{
var sceneScope = StateMachine.Scene.Push();
var targetScope = ActionGraph.PushTarget( InputDefinition.Target( typeof(GameObject), StateMachine.GameObject ) );
return new DisposeAction( () =>
{
targetScope.Dispose();
sceneScope.Dispose();
} );
}
public void LogEdit( string name )
{
_lastEditName ??= name;
}
private void PushHistoryInternal( string name )
{
using var scope = PushSerializationScope();
try
{
var serialized = StateMachine.SerializeAll();
if ( _undoStack.TryPeek( out var prev ) && string.Equals( prev.Json, serialized, StringComparison.Ordinal ) )
{
return;
}
_redoStack.Clear();
_undoStack.Push( (name, serialized) );
}
catch ( Exception e )
{
Log.Error( e );
}
}
public void CutSelection()
{
CopySelection();
DeleteSelection();
}
private const string ClipboardPrefix = "fsm:";
private (IReadOnlyList<State> States, IReadOnlyList<Transition> Transitions) GetSelectionForCopy()
{
var states = SelectedItems
.Where( x => x.IsValid )
.OfType<StateItem>()
.Select( x => x.State )
.ToArray();
var transitions = SelectedItems
.Where( x => x.IsValid )
.OfType<TransitionItem>()
.Where( x => x.Transition != null )
.Select( x => x.Transition! )
.ToArray();
return (states, transitions);
}
public void CopySelection()
{
using var scope = PushSerializationScope();
var selection = GetSelectionForCopy();
if ( selection.States.Count == 0 ) return;
using var ms = new MemoryStream();
using ( var zs = new GZipStream( ms, CompressionMode.Compress ) )
{
var data = Encoding.UTF8.GetBytes( StateMachine.Serialize( selection.States, selection.Transitions ) );
zs.Write( data, 0, data.Length );
}
EditorUtility.Clipboard.Copy( $"{ClipboardPrefix}{Convert.ToBase64String( ms.ToArray() )}" );
}
public void DeleteSelection()
{
LogEdit( "Delete Selection" );
var deletable = SelectedItems
.Where( x => x.IsValid )
.OfType<IDeletable>()
.ToArray();
foreach ( var item in deletable )
{
item.Delete();
}
}
public void FlipSelection()
{
LogEdit( "Flip Selection" );
var flippable = SelectedItems
.Where( x => x.IsValid )
.OfType<TransitionItem>()
.Where( x => x is { Transition: not null, IsPreview: false } )
.Select( x => x.Transition! )
.ToArray();
var flipped = new HashSet<Transition>();
foreach ( var item in flippable )
{
var copy = item.Target.AddTransition( item.Source );
copy.CopyFrom( item );
item.Remove();
flipped.Add( copy );
}
UpdateItems();
foreach ( var item in _transitionItems.Values )
{
item.Selected = item.Transition is not null && flipped.Contains( item.Transition );
}
}
private void PostDuplicate( IReadOnlyList<State> states, IReadOnlyList<Transition> transitions, Vector2 offset )
{
foreach ( var state in states )
{
state.EditorPosition += offset;
}
UpdateItems();
DeselectAll();
foreach ( var state in states )
{
GetStateItem( state )!.Selected = true;
}
foreach ( var transition in transitions )
{
GetTransitionItem( transition )!.Selected = true;
}
}
public void DuplicateSelection()
{
using var scope = PushSerializationScope();
var selection = GetSelectionForCopy();
// TODO: duplicate transitions only?
if ( selection.States.Count == 0 ) return;
var serialized = StateMachine.Serialize( selection.States, selection.Transitions );
var duplicated = StateMachine.DeserializeInsert( serialized );
PostDuplicate( duplicated.States, duplicated.Transitions, GridSize );
}
public void PasteSelection()
{
using var scope = PushSerializationScope();
var buffer = EditorUtility.Clipboard.Paste();
if ( string.IsNullOrWhiteSpace( buffer ) ) return;
if ( !buffer.StartsWith( ClipboardPrefix ) ) return;
buffer = buffer[ClipboardPrefix.Length..];
byte[] decompressedData;
try
{
using var ms = new MemoryStream( Convert.FromBase64String( buffer ) );
using var zs = new GZipStream( ms, CompressionMode.Decompress );
using var outStream = new MemoryStream();
zs.CopyTo( outStream );
decompressedData = outStream.ToArray();
}
catch
{
Log.Warning( "Paste is not valid base64" );
return;
}
try
{
LogEdit( "Paste" );
var decompressed = Encoding.UTF8.GetString( decompressedData );
var duplicated = StateMachine.DeserializeInsert( decompressed );
if ( !duplicated.States.Any() )
return;
// using var undoScope = UndoScope( "Paste Selection" );
var averagePos = new Vector2(
duplicated.States.Average( x => x.EditorPosition.x ),
duplicated.States.Average( x => x.EditorPosition.y ) );
var offset = (_lastMouseScenePosition - averagePos).SnapToGrid( GridSize );
PostDuplicate( duplicated.States, duplicated.Transitions, offset );
}
catch ( Exception e )
{
Log.Warning( $"Paste is not valid json: {e}" );
}
}
public void Undo()
{
if ( _undoStack.Count <= 1 ) return;
_redoStack.Push( _undoStack.Pop() );
RestoreFromUndoStack();
}
public void Redo()
{
if ( !_redoStack.TryPop( out var item ) ) return;
_undoStack.Push( item );
RestoreFromUndoStack();
}
private void RestoreFromUndoStack()
{
using var scope = PushSerializationScope();
StateMachine.DeserializeAll( _undoStack.Peek().Json );
UpdateItems();
}
private static class ItemHelper<TSource, TItem>
where TSource : notnull
where TItem : GraphicsItem
{
[ThreadStatic] private static HashSet<TSource>? SourceSet;
[ThreadStatic] private static List<TSource>? ToRemove;
public static bool Update( GraphicsView view, IEnumerable<TSource> source, Dictionary<TSource, TItem> dict, Action<TSource> add )
{
SourceSet ??= new HashSet<TSource>();
SourceSet.Clear();
ToRemove ??= new List<TSource>();
ToRemove.Clear();
var changed = false;
foreach ( var component in source )
{
SourceSet.Add( component );
}
foreach ( var (state, item) in dict )
{
if ( !SourceSet.Contains( state ) )
{
item.Destroy();
ToRemove.Add( state );
changed = true;
}
}
foreach ( var removed in ToRemove )
{
dict.Remove( removed );
}
foreach ( var component in SourceSet )
{
if ( !dict.ContainsKey( component ) )
{
add( component );
changed = true;
}
}
return changed;
}
}
}