Editor UI panels for the weapon animator tool. Defines the ClipRackPanel (clip list, create/duplicate/import controls and clip properties UI), AnimationInspectorPanel (control binding, grip poses, constraints, tags, animgraph preview), AnimationTimelinePanel (playback and timeline toolbar) plus supporting toolbar and clip extension method.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Editor;
using Sandbox;
namespace SboxWeaponAnimator.Editor;
public sealed class ClipRackPanel : Widget
{
private readonly WeaponAnimatorController _controller;
private readonly ScrollArea _clipScroll;
private readonly Widget _clipCanvas;
private readonly ScrollArea _propertiesScroll;
private readonly Widget _propertiesCanvas;
private readonly Label _actionHint;
private readonly Dictionary<Guid, WeaponAnimatorButton> _clipButtons = [];
private readonly Dictionary<Guid, int> _propertyScrollByClip = [];
private string _clipListSignature = "";
private Guid _lastSelectedClipId;
public event Action<string, ValidationSeverity>? StatusChanged;
public ClipRackPanel(
WeaponAnimatorController controller,
Widget? parent = null,
bool showClipHeader = true ) : base( parent )
{
_controller = controller;
Layout = Layout.Column();
Layout.Margin = new Sandbox.UI.Margin( 8 );
Layout.Spacing = 6;
if ( showClipHeader )
Layout.Add( Header( "CLIP RACK", this ) );
_clipScroll = new ScrollArea( this )
{
MinimumSize = new Vector2( 200, 70 )
};
_clipCanvas = new Widget( _clipScroll );
_clipCanvas.Layout = Layout.Column();
_clipCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
_clipCanvas.Layout.Spacing = 2;
_clipScroll.Canvas = _clipCanvas;
Layout.Add( _clipScroll, 2 );
var actions = RigAuditPanel.Row( this );
actions.Layout.Add( WeaponAnimatorTheme.Button(
"Start",
"add_circle",
StartSelectedFromDefault,
actions,
true ), 1 );
actions.Layout.Add( WeaponAnimatorTheme.Button(
"Duplicate",
"content_copy",
ShowDuplicateMenu,
actions ), 1 );
actions.Layout.Add( WeaponAnimatorTheme.Button(
"Import",
"input",
ShowImportMenu,
actions ), 1 );
Layout.Add( actions );
_actionHint = WeaponAnimatorTheme.Label( "", this, true );
_actionHint.WordWrap = true;
Layout.Add( _actionHint );
_propertiesScroll = new ScrollArea( this ) { MinimumHeight = 80 };
_propertiesCanvas = new Widget( _propertiesScroll );
_propertiesCanvas.Layout = Layout.Column();
_propertiesCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
_propertiesCanvas.Layout.Spacing = 4;
_propertiesScroll.Canvas = _propertiesCanvas;
Layout.Add( _propertiesScroll, 1 );
var addCustom = WeaponAnimatorTheme.Button(
"Add custom clip",
"playlist_add",
AddCustomClip,
this );
Layout.Add( addCustom );
_controller.DocumentChanged += Rebuild;
Rebuild();
}
public override void OnDestroyed()
{
_controller.DocumentChanged -= Rebuild;
_controller.SelectionChanged -= Rebuild;
base.OnDestroyed();
}
private void Rebuild()
{
var clipScroll = _clipScroll.VerticalScrollbar.Value;
var selectedClipId = _controller.Document.Workspace.SelectedClipId;
var propertiesScroll = CapturePropertiesScroll( selectedClipId );
var clipSignature = ClipListSignature();
if ( _clipListSignature != clipSignature || _clipButtons.Count == 0 )
{
_clipCanvas.Layout.Clear( true );
_clipButtons.Clear();
AddClipGroup( "CORE", [
WeaponClipRole.Idle, WeaponClipRole.Deploy, WeaponClipRole.Fire,
WeaponClipRole.FireDry, WeaponClipRole.Reload, WeaponClipRole.ReloadEmpty,
WeaponClipRole.Holster
] );
AddClipGroup( "PRESENTATION", [
WeaponClipRole.Inspect, WeaponClipRole.Sprint, WeaponClipRole.Jump,
WeaponClipRole.Lower, WeaponClipRole.Ironsights
] );
AddClipGroup( "INTERACTION", [
WeaponClipRole.GrabStance, WeaponClipRole.GrabGestureOne,
WeaponClipRole.GrabGestureTwo, WeaponClipRole.GrabGestureThree,
WeaponClipRole.GrabGestureFour
] );
AddClipGroup( "INCREMENTAL", [
WeaponClipRole.ReloadEnter, WeaponClipRole.FirstShell,
WeaponClipRole.InsertShell, WeaponClipRole.ReloadExit
] );
var custom = _controller.Document.Clips
.Where( x => x.Role == WeaponClipRole.Custom )
.ToArray();
if ( custom.Length > 0 )
{
_clipCanvas.Layout.Add( Header( "CUSTOM", _clipCanvas ) );
foreach ( var clip in custom )
AddClipButton( clip );
}
_clipCanvas.Layout.AddStretchCell();
_clipListSignature = ClipListSignature();
_clipCanvas.UpdateGeometry();
_clipScroll.VerticalScrollbar.Value = clipScroll;
}
else
{
RefreshClipButtons();
}
_propertiesCanvas.Layout.Clear( true );
var selected = _controller.Document.GetSelectedClip();
_actionHint.Text = selected is null
? "Select a clip."
: selected.Readiness == ClipReadiness.NotStarted
? "Not started · choose Start, Duplicate, or Import."
: $"{selected.Readiness} · {selected.Duration:0.###} s at {selected.SampleRate:0.#} fps";
BuildClipProperties( selected );
_propertiesCanvas.UpdateGeometry();
_propertiesScroll.VerticalScrollbar.Value = propertiesScroll;
_lastSelectedClipId = selectedClipId;
}
private void AddClipGroup( string name, IEnumerable<WeaponClipRole> roles )
{
_clipCanvas.Layout.Add( Header( name, _clipCanvas ) );
foreach ( var role in roles )
{
var clip = _controller.Document.EnsureClip( role );
AddClipButton( clip );
}
}
private void BuildClipProperties( WeaponAnimationClip? clip )
{
if ( _propertiesCanvas is null || clip is null )
return;
_propertiesCanvas.Layout.Add( Header( "CLIP PROPERTIES", _propertiesCanvas ) );
if ( clip.Role == WeaponClipRole.Custom )
AddCustomClipProperties( clip );
var sequence = WeaponAnimatorTheme.Label(
$"Sequence: {WeaponAnimationNames.SequenceName( clip )}",
_propertiesCanvas,
true );
sequence.ToolTip = "Generated sequence name";
_propertiesCanvas.Layout.Add( sequence );
AddClipNumber(
"Duration",
clip.Duration,
value => _controller.Mutate( "Clip duration", _ =>
{
clip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );
clip.KeysClampToDuration();
} ) );
AddClipNumber(
"Sample rate",
clip.SampleRate,
value => _controller.Mutate(
"Clip sample rate",
_ => clip.SampleRate = Math.Clamp( value, 1, 240 ) ) );
_propertiesCanvas.Layout.Add( ClipChoice(
$"Readiness: {clip.Readiness}",
Enum.GetNames<ClipReadiness>(),
value => _controller.Mutate(
"Clip readiness",
_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );
_propertiesCanvas.Layout.Add( ClipChoice(
$"Interpolation: {DominantInterpolation( clip )}",
Enum.GetNames<TrackInterpolation>(),
value => _controller.Mutate( "Track interpolation", _ =>
{
var interpolation = Enum.Parse<TrackInterpolation>( value );
foreach ( var track in clip.Tracks )
track.Interpolation = interpolation;
} ) ) );
_propertiesCanvas.Layout.Add( Header( "TAGS", _propertiesCanvas ) );
var tagRow = RigAuditPanel.Row( _propertiesCanvas );
var name = new LineEdit( tagRow )
{
PlaceholderText = "Tag name",
FixedHeight = 27
};
name.SetStyles( WeaponAnimatorTheme.InputStyle );
tagRow.Layout.Add( name, 1 );
tagRow.Layout.Add( WeaponAnimatorTheme.Button(
"Point",
"add_location",
() => AddClipTag( name.Text, AnimationTagKind.Point ),
tagRow ) );
tagRow.Layout.Add( WeaponAnimatorTheme.Button(
"Range",
"linear_scale",
() => AddClipTag( name.Text, AnimationTagKind.Range ),
tagRow ) );
_propertiesCanvas.Layout.Add( tagRow );
foreach ( var tag in clip.Tags )
{
_propertiesCanvas.Layout.Add( WeaponAnimatorTheme.Label(
$"{tag.Name} {tag.StartTime:0.###}–{tag.EndTime:0.###}",
_propertiesCanvas,
true ) );
}
_propertiesCanvas.Layout.AddStretchCell();
}
private void AddCustomClipProperties( WeaponAnimationClip clip )
{
if ( _propertiesCanvas is null )
return;
var nameRow = RigAuditPanel.Row( _propertiesCanvas );
nameRow.Layout.Add( WeaponAnimatorTheme.Label( "Name", nameRow, true ) );
var name = new LineEdit( nameRow )
{
Text = clip.Name,
FixedHeight = 26
};
name.SetStyles( WeaponAnimatorTheme.InputStyle );
name.EditingFinished += () =>
{
var renamed = name.Text.Trim();
if ( string.IsNullOrWhiteSpace( renamed ) )
{
name.Text = clip.Name;
StatusChanged?.Invoke(
"A custom clip name cannot be empty.",
ValidationSeverity.Warning );
return;
}
_controller.RenameCustomClip( clip.Id, renamed );
};
nameRow.Layout.Add( name, 1 );
_propertiesCanvas.Layout.Add( nameRow );
var delete = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(
"Delete custom clip",
"delete",
() => RequestDeleteCustomClip( clip ),
_propertiesCanvas );
delete.Tint = WeaponAnimatorTheme.Coral * 0.38f;
_propertiesCanvas.Layout.Add( delete );
}
private void RequestDeleteCustomClip( WeaponAnimationClip clip )
{
Dialog.AskConfirm(
() => _controller.DeleteCustomClip( clip.Id ),
$"Delete the custom clip '{clip.Name}' and all of its keys, curves, tags, and visibility tracks?",
"Delete Custom Clip",
"Delete",
"Cancel" );
}
private void AddClipNumber( string label, float value, Action<float> changed )
{
if ( _propertiesCanvas is null )
return;
var row = RigAuditPanel.Row( _propertiesCanvas );
row.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );
var edit = new LineEdit( row )
{
Text = value.ToString( "0.###", CultureInfo.InvariantCulture ),
FixedWidth = 84,
FixedHeight = 26
};
edit.SetStyles( WeaponAnimatorTheme.InputStyle );
edit.EditingFinished += () =>
{
if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed )
&& WeaponAnimationMath.IsFinite( parsed ) )
changed( parsed );
};
row.Layout.Add( edit );
_propertiesCanvas.Layout.Add( row );
}
private Button ClipChoice(
string text,
IEnumerable<string> values,
Action<string> changed )
{
var button = new WeaponAnimatorButton( text, "expand_more", _propertiesCanvas )
{
Tint = WeaponAnimatorTheme.SurfaceRaised
};
button.Clicked = () =>
{
var menu = new Menu( button );
foreach ( var value in values )
{
var captured = value;
menu.AddOption( captured, null, () => changed( captured ) );
}
menu.OpenAt( button.ScreenRect.BottomLeft );
};
return button;
}
private void AddClipTag( string name, AnimationTagKind kind )
{
var clip = _controller.Document.GetSelectedClip();
if ( clip is null || string.IsNullOrWhiteSpace( name ) )
return;
_controller.Mutate( $"Add tag {name}", document =>
{
var start = document.Workspace.TimelineTime;
clip.Tags.Add( new AnimationTag
{
Name = name.Trim(),
Kind = kind,
StartTime = start,
EndTime = kind == AnimationTagKind.Range
? MathF.Min( start + 0.1f, clip.Duration )
: start
} );
} );
}
private static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>
clip.Tracks.GroupBy( x => x.Interpolation )
.OrderByDescending( x => x.Count() )
.Select( x => x.Key )
.FirstOrDefault();
private void AddClipButton( WeaponAnimationClip clip )
{
var button = new WeaponAnimatorButton( "", _clipCanvas )
{
Clicked = () => _controller.SelectClip( clip.Id )
};
ApplyClipButtonAppearance( button, clip );
_clipCanvas.Layout.Add( button );
_clipButtons[clip.Id] = button;
}
private void StartSelectedFromDefault()
{
var clip = _controller.Document.GetSelectedClip();
if ( clip is null )
return;
_controller.Mutate( $"Start {clip.Name}", document =>
{
document.Workspace.ClearWorkingPoses( clip.Id );
document.Workspace.TimelineViews.RemoveAll( x => x.ClipId == clip.Id );
document.Workspace.CurveViews.RemoveAll( x => x.ClipId == clip.Id );
clip.VisibilityTracks.Clear();
var skeleton = HostSkeletonBuilder.BuildCached( document );
if ( clip.Role == WeaponClipRole.Idle )
{
IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
return;
}
clip.Tracks.Clear();
clip.IsBindPoseSeed = false;
foreach ( var bone in skeleton.Bones )
{
var track = clip.EnsureTrack( bone.Name );
track.Kind = bone.IsWeaponBone ? RigControlKind.Weapon : RigControlKind.Arm;
var gripTransform = document.Binding.GripPoses
.FirstOrDefault( x => x.Id == document.Binding.DefaultGripPoseId )?
.Bones.FirstOrDefault( x => x.BoneName.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) )?
.LocalTransform;
WeaponAnimationMath.UpsertKey(
track,
0,
gripTransform ?? skeleton.GetBindLocal( bone ) );
}
clip.Readiness = clip.Role == WeaponClipRole.Idle
? ClipReadiness.Ready
: ClipReadiness.Draft;
} );
}
private void ShowDuplicateMenu()
{
var selected = _controller.Document.GetSelectedClip();
if ( selected is null )
return;
var menu = new Menu( this );
foreach ( var source in _controller.Document.Clips.Where( x =>
x.Id != selected.Id && x.Readiness != ClipReadiness.NotStarted ) )
{
var captured = source;
menu.AddOption( captured.Name, null, () => Duplicate( captured, selected ) );
}
menu.OpenAtCursor();
}
private void Duplicate( WeaponAnimationClip source, WeaponAnimationClip destination )
{
_controller.Mutate( $"Duplicate {source.Name}", _ =>
{
_controller.Document.Workspace.ClearWorkingPoses( destination.Id );
_controller.Document.Workspace.TimelineViews.RemoveAll( x =>
x.ClipId == destination.Id );
_controller.Document.Workspace.CurveViews.RemoveAll( x =>
x.ClipId == destination.Id );
var copy = Json.Deserialize<WeaponAnimationClip>( Json.Serialize( source ) )!;
destination.Duration = copy.Duration;
destination.SampleRate = copy.SampleRate;
destination.AllowSubframeKeys = copy.AllowSubframeKeys;
destination.IsBindPoseSeed = false;
destination.Tracks = copy.Tracks;
destination.VisibilityTracks = copy.VisibilityTracks;
destination.Constraints = copy.Constraints;
destination.Tags = copy.Tags;
destination.Readiness = ClipReadiness.Draft;
} );
}
private void ShowImportMenu()
{
var selected = _controller.Document.GetSelectedClip();
if ( selected is null )
return;
var sequences = SequenceImportService.GetSequences( _controller.Document );
if ( sequences.Count == 0 )
{
StatusChanged?.Invoke( "The source model exposes no importable sequences.", ValidationSeverity.Warning );
return;
}
var menu = new Menu( this );
foreach ( var sequence in sequences )
{
var captured = sequence;
menu.AddOption( captured, null, () =>
{
SequenceImportResult? result = null;
_controller.Mutate( $"Import {captured}", document =>
{
document.Workspace.ClearWorkingPoses( selected.Id );
document.Workspace.TimelineViews.RemoveAll( x =>
x.ClipId == selected.Id );
document.Workspace.CurveViews.RemoveAll( x =>
x.ClipId == selected.Id );
selected.IsBindPoseSeed = false;
result = SequenceImportService.Import( document, selected, captured );
} );
StatusChanged?.Invoke(
result?.Message ?? "Sequence import failed.",
result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
} );
}
menu.OpenAtCursor();
}
private void AddCustomClip()
{
_controller.Mutate( "Add custom clip", document =>
{
var count = document.Clips.Count( x => x.Role == WeaponClipRole.Custom ) + 1;
var clip = WeaponAnimationClip.Create( WeaponClipRole.Custom );
clip.Name = $"Custom {count}";
document.Clips.Add( clip );
WeaponAnimationNames.RepairCustomSequenceNames( document );
document.Workspace.SelectedClipId = clip.Id;
} );
}
private static Label Header( string text, Widget parent )
{
var label = WeaponAnimatorTheme.SectionLabel( text, parent );
label.FixedHeight = 22;
label.SetStyles(
"background-color: transparent; border: none; padding: 5px 0 0 0;" +
$"font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Muted.Hex};" );
return label;
}
private int CapturePropertiesScroll( Guid selectedClipId )
{
if ( _propertiesScroll is null )
return 0;
if ( _lastSelectedClipId != Guid.Empty )
_propertyScrollByClip[_lastSelectedClipId] =
_propertiesScroll.VerticalScrollbar.Value;
return _lastSelectedClipId == selectedClipId
? _propertiesScroll.VerticalScrollbar.Value
: _propertyScrollByClip.GetValueOrDefault( selectedClipId );
}
private string ClipListSignature() => string.Join(
"|",
_controller.Document.Clips.Select( x =>
$"{x.Id}:{x.Role}:{x.Name}:{x.Readiness}" ) );
private void RefreshClipButtons()
{
foreach ( var clip in _controller.Document.Clips )
{
if ( !_clipButtons.TryGetValue( clip.Id, out var button ) )
continue;
ApplyClipButtonAppearance( button, clip );
}
}
private void ApplyClipButtonAppearance(
WeaponAnimatorButton button,
WeaponAnimationClip clip )
{
var marker = clip.Readiness switch
{
ClipReadiness.NotStarted => "○",
ClipReadiness.Draft => "◐",
ClipReadiness.Ready => "●",
_ => "!"
};
button.Text = $"{marker} {clip.Name}";
button.Tint = clip.Id == _controller.Document.Workspace.SelectedClipId
? WeaponAnimatorTheme.Cyan * 0.42f
: clip.Readiness switch
{
ClipReadiness.Ready => WeaponAnimatorTheme.Green * 0.24f,
ClipReadiness.Warning => WeaponAnimatorTheme.Coral * 0.28f,
_ => WeaponAnimatorTheme.Surface
};
button.ToolTip = clip.Readiness.ToString();
}
internal ScrollArea ClipScroll => _clipScroll;
internal ScrollArea? PropertiesScroll => _propertiesScroll;
internal WeaponAnimatorButton? GetClipButton( Guid clipId ) =>
_clipButtons.GetValueOrDefault( clipId );
}
public sealed class AnimationInspectorPanel : Widget
{
private readonly WeaponAnimatorController _controller;
private readonly Widget _canvas;
private readonly bool _controlToolsOnly;
private readonly Dictionary<string, bool> _expandedSections = new( StringComparer.OrdinalIgnoreCase )
{
["binding"] = true,
["constraints"] = true,
["animgraph"] = false
};
public event Action<string, ValidationSeverity>? StatusChanged;
public AnimationInspectorPanel(
WeaponAnimatorController controller,
Widget? parent = null,
bool controlToolsOnly = false ) : base( parent )
{
_controller = controller;
_controlToolsOnly = controlToolsOnly;
Layout = Layout.Column();
Layout.Margin = 0;
var scroll = new ScrollArea( this );
_canvas = new Widget( scroll );
_canvas.Layout = Layout.Column();
_canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin( 10 );
_canvas.Layout.Spacing = 7;
scroll.Canvas = _canvas;
Layout.Add( scroll, 1 );
_controller.DocumentChanged += Rebuild;
_controller.SelectionChanged += Rebuild;
Rebuild();
}
public override void OnDestroyed()
{
_controller.DocumentChanged -= Rebuild;
_controller.SelectionChanged -= Rebuild;
base.OnDestroyed();
}
private void Rebuild()
{
_canvas?.Layout.Clear( true );
if ( _canvas is null )
return;
if ( !_controlToolsOnly )
{
_canvas.Layout.Add( Header( "CONTROL INSPECTOR" ) );
_canvas.Layout.Add( WeaponAnimatorTheme.Label( SelectionName(), _canvas ) );
}
var bindingCanvas = _controlToolsOnly
? AddCollapsibleSection( "BINDING + GRIP POSES", "binding" )
: _canvas;
var selectedControl = _controller.Document.Workspace.SelectedControl;
if ( !string.IsNullOrWhiteSpace( selectedControl ) )
{
var selectedTarget = ResolveControl( selectedControl );
if ( selectedTarget is not null
&& selectedControl is "@primary_hand" or "@support_hand" )
{
var instruction = WeaponAnimatorTheme.Label(
"Keep this hand selected. Choose its attachment bone from the menu below; "
+ "you do not need to select the weapon bone in the rig browser.",
bindingCanvas,
true );
instruction.WordWrap = true;
bindingCanvas.Layout.Add( instruction );
var weaponBones = HostSkeletonBuilder.BuildCached( _controller.Document )
.Bones
.Where( x => x.IsWeaponBone )
.Select( x => x.Name )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
bindingCanvas.Layout.Add( ChoiceButton(
"Attachment bone",
() => string.IsNullOrWhiteSpace( selectedTarget.AttachedBone )
? "weapon_root (recommended on bind)"
: selectedTarget.AttachedBone,
weaponBones.Prepend( "(world)" ),
value => _controller.Mutate( "Change hand attachment", document =>
{
HandAttachmentService.ChangeAttachment(
document,
selectedControl,
value == "(world)" ? "" : value );
} ),
bindingCanvas ) );
bindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(
selectedTarget.IsBound ? $"Unbind {selectedTarget.Name}" : $"Bind {selectedTarget.Name}",
selectedTarget.IsBound ? "link_off" : "link",
() => ToggleHandBinding( selectedControl ),
bindingCanvas,
!selectedTarget.IsBound ) );
}
}
var bindingRow = RigAuditPanel.Row( bindingCanvas );
bindingRow.Layout.Add( WeaponAnimatorTheme.Button(
_controller.Document.Binding.Configuration == GripConfiguration.TwoHanded
? "Two handed"
: "One handed",
"pan_tool",
ToggleGripConfiguration,
bindingRow ), 1 );
bindingRow.Layout.Add( WeaponAnimatorTheme.Button(
"Save grip pose",
"save",
SaveGripPose,
bindingRow,
true ), 1 );
bindingCanvas.Layout.Add( bindingRow );
bindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(
"Apply saved grip pose",
"front_hand",
ShowGripPoseMenu,
bindingCanvas ) );
var clip = _controller.Document.GetSelectedClip();
if ( clip is not null && !_controlToolsOnly )
{
_canvas.Layout.Add( Header( "CLIP PROPERTIES" ) );
_canvas.Layout.Add( NumericField(
"Duration (seconds)",
clip.Duration,
value => _controller.Mutate( "Clip duration", _ =>
{
clip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );
clip.KeysClampToDuration();
} ) ) );
_canvas.Layout.Add( NumericField(
"Sample rate",
clip.SampleRate,
value => _controller.Mutate( "Clip sample rate", _ =>
clip.SampleRate = Math.Clamp( value, 1, 240 ) ) ) );
_canvas.Layout.Add( ChoiceButton(
"Readiness",
() => clip.Readiness.ToString(),
Enum.GetNames<ClipReadiness>(),
value => _controller.Mutate(
"Clip readiness",
_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );
_canvas.Layout.Add( ChoiceButton(
"Interpolation",
() => DominantInterpolation( clip ).ToString(),
Enum.GetNames<TrackInterpolation>(),
value => _controller.Mutate( "Track interpolation", _ =>
{
var interpolation = Enum.Parse<TrackInterpolation>( value );
foreach ( var track in clip.Tracks )
track.Interpolation = interpolation;
} ) ) );
}
var constraintCanvas = _controlToolsOnly
? AddCollapsibleSection( "CONSTRAINTS", "constraints" )
: _canvas;
if ( !_controlToolsOnly )
constraintCanvas.Layout.Add( Header( "KEYING + CONSTRAINTS" ) );
if ( !_controlToolsOnly )
{
var toggles = RigAuditPanel.Row( constraintCanvas );
toggles.Layout.Add( ToggleButton(
"Auto-key",
_controller.Document.Workspace.AutoKey,
value => _controller.Mutate( "Auto-key", d => d.Workspace.AutoKey = value ),
toggles ), 1 );
toggles.Layout.Add( ToggleButton(
"Local gizmo",
_controller.Document.Workspace.LocalGizmos,
value => _controller.Mutate( "Gizmo space", d => d.Workspace.LocalGizmos = value ),
toggles ), 1 );
constraintCanvas.Layout.Add( toggles );
}
constraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(
"Constraint target",
"target",
ShowConstraintTargetMenu,
constraintCanvas ) );
constraintCanvas.Layout.Add( WeaponAnimatorTheme.Label(
string.IsNullOrWhiteSpace( _controller.Document.Workspace.ConstraintTargetBone )
? "No constraint target selected"
: _controller.Document.Workspace.ConstraintTargetBone,
constraintCanvas,
true ) );
constraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(
"Constrain selected control",
"link",
AddConstraint,
constraintCanvas ) );
if ( !_controlToolsOnly )
{
_canvas.Layout.Add( Header( "TAGS" ) );
var tagRow = RigAuditPanel.Row( _canvas );
var tagName = new LineEdit( tagRow )
{
PlaceholderText = "Tag name",
FixedHeight = 28
};
tagName.SetStyles( WeaponAnimatorTheme.InputStyle );
tagRow.Layout.Add( tagName, 1 );
tagRow.Layout.Add( WeaponAnimatorTheme.Button(
"Point",
"add_location",
() => AddTag( tagName.Text, AnimationTagKind.Point ),
tagRow ) );
tagRow.Layout.Add( WeaponAnimatorTheme.Button(
"Range",
"linear_scale",
() => AddTag( tagName.Text, AnimationTagKind.Range ),
tagRow ) );
_canvas.Layout.Add( tagRow );
if ( clip is not null )
{
foreach ( var tag in clip.Tags )
_canvas.Layout.Add( WeaponAnimatorTheme.Label(
$"{tag.Name} {tag.StartTime:0.###}–{tag.EndTime:0.###}",
_canvas,
true ) );
}
}
var graphCanvas = _controlToolsOnly
? AddCollapsibleSection( "ANIMGRAPH PREVIEW", "animgraph" )
: _canvas;
if ( !_controlToolsOnly )
graphCanvas.Layout.Add( Header( "ANIMGRAPH PREVIEW" ) );
var graphActions = new[]
{
("Fire", "b_attack", WeaponClipRole.Fire),
("Dry", "b_attack_dry", WeaponClipRole.FireDry),
("Reload", "b_reload", WeaponClipRole.Reload),
("Sprint", "b_sprint", WeaponClipRole.Sprint),
("Inspect", "b_inspect", WeaponClipRole.Inspect)
};
var graphRows = new[]
{
RigAuditPanel.Row( graphCanvas ),
RigAuditPanel.Row( graphCanvas )
};
for ( var index = 0; index < graphActions.Length; index++ )
{
var captured = graphActions[index];
var row = graphRows[index < 3 ? 0 : 1];
row.Layout.Add( WeaponAnimatorTheme.Button(
captured.Item1,
"play_arrow",
() => SimulateParameter( captured.Item2, captured.Item3 ),
row ), 1 );
}
graphCanvas.Layout.Add( graphRows[0] );
graphCanvas.Layout.Add( graphRows[1] );
graphCanvas.Layout.Add( NumericField(
"move_bob",
_controller.Document.Graph.PreviewFloats.GetValueOrDefault( "move_bob" ),
value => _controller.Mutate( "Preview move_bob", d =>
d.Graph.PreviewFloats["move_bob"] = Math.Clamp( value, 0, 1 ) ),
graphCanvas ) );
_canvas.Layout.AddStretchCell();
}
private Widget AddCollapsibleSection( string title, string id )
{
var expanded = _expandedSections.GetValueOrDefault( id );
var header = new WeaponAnimatorButton(
$"{(expanded ? "▾" : "▸")} {title}",
_canvas )
{
Clicked = () =>
{
_expandedSections[id] = !expanded;
Rebuild();
},
Tint = WeaponAnimatorTheme.SurfaceRaised
};
header.FixedHeight = 26;
_canvas.Layout.Add( header );
var body = new Widget( _canvas )
{
Visible = expanded,
Layout = Layout.Column()
};
body.Layout.Margin = new Sandbox.UI.Margin( 2, 2, 2, 5 );
body.Layout.Spacing = 6;
_canvas.Layout.Add( body );
return body;
}
private void ToggleHandBinding( string controlName )
{
var target = ResolveControl( controlName );
if ( target is null )
return;
if ( !target.IsBound
&& controlName == "@primary_hand"
&& _controller.Document.Calibration.GetAnchor( AnchorKind.Grip ) is null )
{
StatusChanged?.Invoke(
"Set the primary grip anchor in Calibrate before binding the primary hand.",
ValidationSeverity.Warning );
return;
}
_controller.Mutate(
target.IsBound ? $"Unbind {target.Name}" : $"Bind {target.Name}",
document =>
{
var bindingTarget = ResolveControl( controlName );
if ( bindingTarget is null )
return;
if ( !bindingTarget.IsBound && controlName == "@primary_hand" )
CalibrationBindingSeeder.SeedDefaultPrimaryHand( document );
bindingTarget.IsBound = !bindingTarget.IsBound;
bindingTarget.Reachable = true;
var checklistId = controlName == "@primary_hand"
? "primary_hand"
: "support_hand";
if ( bindingTarget.IsBound
&& !document.Binding.CompletedChecklistItems.Contains( checklistId ) )
document.Binding.CompletedChecklistItems.Add( checklistId );
} );
}
private void SaveGripPose()
{
var document = _controller.Document;
var skeleton = HostSkeletonBuilder.BuildCached( document );
var pose = AnimationPoseEvaluator.Evaluate(
document,
skeleton,
document.GetSelectedClip(),
document.Workspace.TimelineTime,
includeWorkingPose: true );
_controller.Mutate( "Save default grip pose", d =>
{
var grip = new GripPose
{
Name = $"Grip {d.Binding.GripPoses.Count + 1}",
Bones = skeleton.Bones
.Where( x => !x.IsWeaponBone
&& (x.Name.Contains( "finger_", StringComparison.OrdinalIgnoreCase )
|| x.Name.Contains( "clavicle_", StringComparison.OrdinalIgnoreCase )
|| x.Name.Contains( "hand_", StringComparison.OrdinalIgnoreCase )) )
.Select( x => new BonePose
{
BoneName = x.Name,
LocalTransform = pose.Local[x.Name]
} )
.ToList()
};
d.Binding.GripPoses.Add( grip );
d.Binding.DefaultGripPoseId = grip.Id;
d.Binding.CompletedChecklistItems.Add( "default_grip" );
} );
}
private void ToggleGripConfiguration()
{
_controller.Mutate( "Grip configuration", d =>
d.Binding.Configuration = d.Binding.Configuration == GripConfiguration.TwoHanded
? GripConfiguration.OneHanded
: GripConfiguration.TwoHanded );
}
private void ShowGripPoseMenu()
{
if ( _controller.Document.Binding.GripPoses.Count == 0 )
{
StatusChanged?.Invoke( "No reusable grip poses have been saved.", ValidationSeverity.Warning );
return;
}
var menu = new Menu( this );
foreach ( var grip in _controller.Document.Binding.GripPoses )
{
var captured = grip;
menu.AddOption( captured.Name, null, () => ApplyGripPose( captured ) );
}
menu.OpenAtCursor();
}
private void ApplyGripPose( GripPose pose )
{
var clip = _controller.Document.GetSelectedClip();
if ( clip is null )
return;
_controller.Mutate( $"Apply {pose.Name}", document =>
{
var time = document.Workspace.TimelineTime;
foreach ( var bone in pose.Bones )
{
var track = clip.EnsureTrack( bone.BoneName );
track.Kind = RigControlKind.Arm;
WeaponAnimationMath.UpsertKey( track, time, bone.LocalTransform );
}
clip.Readiness = clip.Role == WeaponClipRole.Idle
? ClipReadiness.Ready
: ClipReadiness.Draft;
document.Binding.DefaultGripPoseId = pose.Id;
} );
}
private void AddConstraint()
{
var clip = _controller.Document.GetSelectedClip();
var source = _controller.Document.Workspace.SelectedControl;
var target = _controller.Document.Workspace.ConstraintTargetBone;
if ( clip is null || string.IsNullOrWhiteSpace( source ) || string.IsNullOrWhiteSpace( target ) )
{
StatusChanged?.Invoke(
"Select an arm control and a weapon bone before adding a constraint.",
ValidationSeverity.Warning );
return;
}
_controller.Mutate( "Add timed constraint", _ => clip.Constraints.Add( new TimedConstraint
{
SourceControl = source,
TargetBone = target,
StartTime = _controller.Document.Workspace.TimelineTime,
EndTime = clip.Duration
} ) );
}
private void ShowConstraintTargetMenu()
{
var menu = new Menu( this );
var weaponBones = _controller.Document.Rig.RetainedBones()
.OrderBy( x => x.Name );
foreach ( var bone in weaponBones )
{
var captured = bone.Name;
menu.AddOption( captured, null, () => _controller.Mutate(
"Constraint target",
d => d.Workspace.ConstraintTargetBone = captured ) );
}
menu.OpenAtCursor();
}
private void AddTag( string name, AnimationTagKind kind )
{
var clip = _controller.Document.GetSelectedClip();
if ( clip is null || string.IsNullOrWhiteSpace( name ) )
return;
_controller.Mutate( $"Add tag {name}", document =>
{
var start = document.Workspace.TimelineTime;
clip.Tags.Add( new AnimationTag
{
Name = name.Trim(),
Kind = kind,
StartTime = start,
EndTime = kind == AnimationTagKind.Range
? MathF.Min( start + 0.1f, clip.Duration )
: start
} );
} );
}
private void SimulateParameter( string name, WeaponClipRole role )
{
var clip = _controller.Document.Clips.FirstOrDefault( x => x.Role == role );
if ( clip is null )
return;
_controller.Document.Graph.PreviewBools[name] = true;
_controller.SelectClip( clip.Id );
StatusChanged?.Invoke(
$"Simulating {name}=true with {(clip.Readiness == ClipReadiness.NotStarted ? "Idle fallback" : clip.Name)}.",
clip.Readiness == ClipReadiness.NotStarted ? ValidationSeverity.Warning : ValidationSeverity.Info );
}
private string SelectionName()
{
var workspace = _controller.Document.Workspace;
if ( !string.IsNullOrWhiteSpace( workspace.SelectedControl ) )
return workspace.SelectedControl.TrimStart( '@' ).Replace( '_', ' ' );
if ( !string.IsNullOrWhiteSpace( workspace.SelectedBone ) )
return workspace.SelectedBone;
return "No control selected";
}
private RigTarget? ResolveControl( string name ) => name switch
{
"@primary_hand" => _controller.Document.Binding.PrimaryHand,
"@support_hand" => _controller.Document.Binding.SupportHand,
"@primary_elbow" => _controller.Document.Binding.PrimaryElbowPole,
"@support_elbow" => _controller.Document.Binding.SupportElbowPole,
_ => null
};
private Widget NumericField(
string name,
float value,
Action<float> changed,
Widget? parent = null )
{
parent ??= _canvas;
var row = RigAuditPanel.Row( parent );
row.Layout.Add( WeaponAnimatorTheme.Label( name, row, true ), 1 );
var edit = new LineEdit( row )
{
Text = value.ToString( "0.###", CultureInfo.InvariantCulture ),
FixedHeight = 26,
FixedWidth = 86
};
edit.SetStyles( WeaponAnimatorTheme.InputStyle );
edit.EditingFinished += () =>
{
if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )
changed( parsed );
};
row.Layout.Add( edit );
return row;
}
private Button ChoiceButton(
string label,
Func<string> current,
IEnumerable<string> values,
Action<string> changed,
Widget? parent = null )
{
parent ??= _canvas;
var button = new WeaponAnimatorButton( $"{label}: {current()}", "expand_more", parent )
{
Tint = WeaponAnimatorTheme.SurfaceRaised
};
button.Clicked = () =>
{
var menu = new Menu( button );
foreach ( var value in values )
{
var captured = value;
menu.AddOption( captured, null, () =>
{
changed( captured );
button.Text = $"{label}: {current()}";
button.FitToContent();
} );
}
menu.OpenAt( button.ScreenRect.BottomLeft );
};
return button;
}
private static Button ToggleButton(
string text,
bool value,
Action<bool> changed,
Widget parent )
{
var button = new WeaponAnimatorButton( text, parent )
{
IsToggle = true,
IsChecked = value,
Tint = WeaponAnimatorTheme.SurfaceRaised
};
button.Toggled = () => changed( button.IsChecked );
return button;
}
private Label Header( string text )
{
return WeaponAnimatorTheme.SectionLabel( text, _canvas, topMargin: true );
}
private static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>
clip.Tracks.GroupBy( x => x.Interpolation )
.OrderByDescending( x => x.Count() )
.Select( x => x.Key )
.FirstOrDefault();
}
public sealed class AnimationTimelinePanel : Widget
{
private readonly WeaponAnimatorController _controller;
private readonly TimelineEditorCanvas _timeline;
private readonly TimelineControlToolbar _toolbar;
private readonly Label _timeLabel;
private readonly WeaponAnimatorButton _playButton;
private readonly WeaponAnimatorButton _curvesButton;
private readonly WeaponAnimatorButton _loopButton;
public AnimationTimelinePanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )
{
_controller = controller;
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
_toolbar = new TimelineControlToolbar( this );
var left = _toolbar.LeftSection;
left.Layout.Add( CompactAction( "Add key", "key", AddKey, left, true ) );
left.Layout.Add( CompactAction( "Copy", "content_copy", _controller.CopySelectedKeys, left ) );
left.Layout.Add( CompactAction( "Paste", "content_paste", _controller.PasteKeys, left ) );
left.Layout.Add( CompactAction( "Delete", "delete", _controller.DeleteSelectedKeys, left ) );
var reverse = CompactAction( "Reverse", "swap_horiz", _controller.ReverseKeys, left );
reverse.ToolTip =
"Reverse selected keys within their time range. With no selection, reverse the whole clip.";
left.Layout.Add( reverse );
_curvesButton = CompactAction(
"Curves",
"show_chart",
() => _controller.SetCurveEditorVisible(
!_controller.Document.Workspace.CurveEditorVisible ),
left );
_curvesButton.IsToggle = true;
left.Layout.Add( _curvesButton );
var player = _toolbar.CenterSection;
player.Layout.Spacing = 3;
player.Layout.Add( new Widget( player )
{
FixedWidth = 28,
MinimumWidth = 28,
FixedHeight = 26
} );
player.Layout.Add( PlayerButton( "first_page", "Jump to first frame", _controller.JumpToFirstFrame, player ) );
player.Layout.Add( PlayerButton( "skip_previous", "Previous frame", () => _controller.StepTimelineFrame( -1 ), player ) );
_playButton = PlayerButton( "play_arrow", "Play", _controller.TogglePlayback, player );
player.Layout.Add( _playButton );
player.Layout.Add( PlayerButton( "skip_next", "Next frame", () => _controller.StepTimelineFrame( 1 ), player ) );
player.Layout.Add( PlayerButton( "last_page", "Jump to last frame", _controller.JumpToLastFrame, player ) );
_loopButton = PlayerButton(
"repeat",
"Loop playback",
_controller.ToggleSelectedClipLoop,
player );
_loopButton.IsToggle = true;
_loopButton.Flat = true;
player.Layout.Add( _loopButton );
var right = _toolbar.RightSection;
right.Layout.AddStretchCell();
_timeLabel = WeaponAnimatorTheme.Label( "", right );
right.Layout.Add( _timeLabel );
_toolbar.FitSections();
Layout.Add( _toolbar );
_timeline = new TimelineEditorCanvas( controller, this );
Layout.Add( _timeline, 1 );
_controller.DocumentChanged += Refresh;
_controller.TimelineChanged += Refresh;
_controller.TimelineViewChanged += Refresh;
_controller.PlaybackChanged += Refresh;
_controller.ClipPlaybackSettingsChanged += Refresh;
Refresh();
}
public override void OnDestroyed()
{
_controller.DocumentChanged -= Refresh;
_controller.TimelineChanged -= Refresh;
_controller.TimelineViewChanged -= Refresh;
_controller.PlaybackChanged -= Refresh;
_controller.ClipPlaybackSettingsChanged -= Refresh;
base.OnDestroyed();
}
private void AddKey()
=> _controller.KeySelectedTransform();
private void Refresh()
{
var clip = _controller.Document.GetSelectedClip();
if ( clip is null )
_timeLabel.Text = "No clip";
else
{
var frame = TimelineInteraction.TimeToFrame(
_controller.Document.Workspace.TimelineTime,
clip.SampleRate );
var total = TimelineInteraction.LastFrame( clip );
_timeLabel.Text =
$"{_controller.Document.Workspace.TimelineTime:0.000}s · {frame:00} / {total:00}";
}
_playButton.Icon = _controller.IsPlaying ? "pause" : "play_arrow";
_playButton.ToolTip = _controller.IsPlaying ? "Pause" : "Play";
_loopButton.Enabled = clip is not null;
_loopButton.IsChecked = clip?.Loop == true;
_loopButton.Tint = clip?.Loop == true
? WeaponAnimatorTheme.Cyan
: WeaponAnimatorTheme.Muted;
_loopButton.ToolTip = clip?.Loop == true
? "Loop playback is enabled"
: "Loop playback";
var curves = _controller.Document.Workspace.CurveEditorVisible;
_curvesButton.IsChecked = curves;
_curvesButton.Text = curves ? "Keys" : "Curves";
_curvesButton.Icon = curves ? "view_timeline" : "show_chart";
_curvesButton.Tint = curves
? WeaponAnimatorTheme.Cyan * 0.65f
: WeaponAnimatorTheme.SurfaceRaised;
_curvesButton.ToolTip = curves
? "Return to the keyframe view"
: "Open the curve editor";
_curvesButton.FitToContent( true );
_toolbar.FitSections();
_timeline.Update();
}
private static WeaponAnimatorButton CompactAction(
string text,
string icon,
Action clicked,
Widget parent,
bool primary = false )
{
var button = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(
text,
icon,
clicked,
parent,
primary );
button.FixedHeight = 26;
button.FitToContent( true );
return button;
}
private static WeaponAnimatorButton PlayerButton(
string icon,
string tooltip,
Action clicked,
Widget parent )
{
var button = new WeaponAnimatorButton( "", icon, parent )
{
Clicked = clicked,
FixedWidth = 28,
FixedHeight = 26,
Tint = WeaponAnimatorTheme.SurfaceRaised,
ToolTip = tooltip
};
return button;
}
}
internal sealed class TimelineControlToolbar : Widget
{
public Widget LeftSection { get; }
public Widget CenterSection { get; }
public Widget RightSection { get; }
public TimelineControlToolbar( Widget? parent = null ) : base( parent )
{
FixedHeight = 34;
SetStyles( "background-color: rgb(24,27,30); border: none;" );
Layout = Layout.Row();
Layout.Margin = new Sandbox.UI.Margin( 7, 4, 7, 4 );
Layout.Spacing = 0;
LeftSection = Section( this );
CenterSection = Section( this );
RightSection = Section( this );
Layout.Add( LeftSection );
Layout.AddStretchCell();
Layout.Add( RightSection );
CenterSection.Raise();
}
public void FitSections()
{
LeftSection.FixedWidth = SectionWidth( LeftSection );
CenterSection.FixedWidth = SectionWidth( CenterSection );
PositionCenter();
}
protected override void OnResize()
{
base.OnResize();
PositionCenter();
}
private void PositionCenter()
{
CenterSection.Position = new Vector2(
CenteredLeft( Width, CenterSection.Width ),
MathF.Round( (Height - CenterSection.Height) * 0.5f ) );
CenterSection.Raise();
}
internal static float CenteredLeft( float toolbarWidth, float sectionWidth ) =>
MathF.Round( (toolbarWidth - sectionWidth) * 0.5f );
private static float SectionWidth( Widget section )
{
var children = section.Children.ToArray();
if ( children.Length == 0 )
return 0;
return children.Sum( x => x is WeaponAnimatorButton button
? string.IsNullOrWhiteSpace( button.Text )
? 28
: MathF.Ceiling( button.PreferredWidth )
: MathF.Max( x.MinimumWidth, 0 ) )
+ MathF.Max( children.Length - 1, 0 ) * section.Layout.Spacing;
}
private static Widget Section( Widget parent )
{
var section = new Widget( parent )
{
Layout = Layout.Row(),
FixedHeight = 26
};
section.SetStyles( "background-color: transparent; border: none;" );
section.Layout.Margin = 0;
section.Layout.Spacing = 4;
return section;
}
}
internal static class ClipExtensions
{
public static void KeysClampToDuration( this WeaponAnimationClip clip )
{
foreach ( var key in clip.Tracks.SelectMany( x => x.Keys ) )
key.Time = Math.Clamp( key.Time, 0, clip.Duration );
foreach ( var key in clip.VisibilityTracks.SelectMany( x => x.Keys ) )
key.Time = Math.Clamp( key.Time, 0, clip.Duration );
foreach ( var tag in clip.Tags )
{
tag.StartTime = Math.Clamp( tag.StartTime, 0, clip.Duration );
tag.EndTime = Math.Clamp( tag.EndTime, tag.StartTime, clip.Duration );
}
}
}