Editor/WeaponImporter/Tool/UI/Steps/AnimationsStep.cs
using Editor;
using Sandbox;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Setup;
namespace WeaponImporter.Tool.UI;
/// <summary>Step 3: which clip plays for each action, and what the character plays in third person.</summary>
public sealed class AnimationsStep : StepPanel
{
public AnimationsStep( Widget parent, ImporterController controller ) : base( parent, controller )
{
controller.RoleChanged += MarkDirty;
}
public override string Title => "Animations";
protected override string StructureKey()
{
var s = C.Setup;
var weapon = string.Join( ",", s.WeaponAnimations.OrderBy( kv => kv.Key ).Select( kv => $"{kv.Key}={kv.Value.Clip}:{kv.Value.Manual}" ) );
var third = string.Join( ",", s.ThirdPerson.OrderBy( kv => kv.Key ).Select( kv => $"{kv.Key}={kv.Value.Source}:{kv.Value.Sequence}" ) );
return $"{weapon}|{third}|{C.BakedModelPath is null}|{C.Analysis.Asset.Clips.Count}";
}
protected override void Build()
{
var s = C.Setup;
var clips = C.Analysis.Asset.Clips;
var card = AddCard( "movie", "Animations", out var header, "Click a row to preview it" );
header.AddStretchCell();
var mapped = s.WeaponAnimations.Count( kv => !string.IsNullOrEmpty( kv.Value.Clip ) );
header.Add( new Pill( card, $"{mapped} / {clips.Count} CLIPS", Theme.TextLight, "Roles with a weapon clip / clips in the file" ) );
if ( clips.Count == 0 )
card.Layout.Add( UiStyle.Muted( new Label( "This model has no animations. The character's own animations still play for every action.", card ) { WordWrap = true }, small: true ) );
else if ( C.BakedModelPath is null && mapped > 0 )
card.Layout.Add( UiStyle.Muted( new Label( "Watch the weapon's own clips in the First person view; in third person they play on the weapon after Bake.", card ) { WordWrap = true }, small: true ) );
// Column captions, on the same grid as the rows below.
var captions = card.Layout.AddRow();
captions.Margin = new Sandbox.UI.Margin( 8, 0, 4, 0 );
captions.Spacing = UiStyle.RowSpacing;
captions.Add( new SectionCaption( card, "Action", RoleColumn ) );
captions.Add( new SectionCaption( card, "Weapon clip", 0 ), 1 );
captions.Add( new SectionCaption( card, "3rd", UiStyle.PillColumn + UiStyle.RowSpacing + UiStyle.ControlHeight, TextFlag.RightCenter ) );
foreach ( var role in AnimationRoles.All )
{
var row = card.Layout.Add( new RoleRow( card, C, role ) );
BuildRow( row, role );
Bind( () => row.Selected = C.Role == role );
}
BuildRetarget();
}
private void BuildRetarget()
{
var card = AddCard( "sync_alt", "Third-person animation", out _, "Bring your own character animations (FBX / BVH) for the third-person actions" );
if ( RetargeterBridge.IsInstalled )
{
card.Layout.Add( UiStyle.Muted( new Label( "Retarget a character animation onto the s&box character, then pick its sequence with the person button on a row.", card ) { WordWrap = true }, small: true ) );
var row = card.Layout.AddRow();
row.Add( UiStyle.Secondary( card, "Retarget third-person animation…", "sync_alt", Retarget, "Open the Humanoid Retargeter with animation files (FBX, BVH)" ) );
row.AddStretchCell();
}
else
{
var link = card.Layout.Add( new Label( $"Retargeting your own animations needs the <a href=\"{RetargeterBridge.InstallUrl}\" style=\"color: {Theme.Green.Hex};\">Humanoid Retargeter</a>. The character's animgraph works without it.", card ) { WordWrap = true, OpenExternalLinks = true, ToolTip = RetargeterBridge.InstallUrl } );
link.SetStyles( $"color: {Theme.TextLight.Hex}; font-size: 11px;" );
}
}
private void Retarget()
{
var path = EditorUtility.OpenFileDialog( "Animation to retarget", "Animations (*.fbx *.bvh)", null );
if ( string.IsNullOrEmpty( path ) )
return;
Safe( () =>
{
if ( RetargeterBridge.Open( new[] { path } ) )
C.SetStatus( $"Opened {System.IO.Path.GetFileName( path )} in the Humanoid Retargeter.", Theme.Green );
else
C.SetStatus( "The Humanoid Retargeter could not be opened.", Theme.Red );
} );
}
private void BuildRow( RoleRow row, AnimationRole role )
{
var s = C.Setup;
var layout = row.Layout;
var label = layout.Add( new Label( AnimationRoles.Label( role ), row ) { FixedWidth = RoleColumn, FixedHeight = UiStyle.ControlHeight, Alignment = TextFlag.LeftCenter } );
if ( C.Role == role )
UiStyle.Bold( label );
s.WeaponAnimations.TryGetValue( role, out var binding );
var hasClip = binding is not null && !string.IsNullOrEmpty( binding.Clip );
var fallback = !hasClip && AnimationRoles.Fallback( role ) is { } f && s.WeaponAnimations.TryGetValue( f, out var fb ) && !string.IsNullOrEmpty( fb.Clip ) ? f : (AnimationRole?)null;
var current = hasClip ? ShortClip( binding.Clip ) : fallback is { } fr ? $"uses {AnimationRoles.Label( fr )}" : "None";
layout.Add( new ClickField( row, current, "movie", hasClip ? Theme.Green : Theme.TextLight, () => ChangeWeaponClip( role ),
hasClip ? $"Weapon clip for {AnimationRoles.Label( role )}: {binding.Clip}. Click to change." : $"No weapon clip for {AnimationRoles.Label( role )}. Click to pick one." ) { Muted = !hasClip }, 1 );
if ( hasClip )
layout.Add( binding.Manual ? new Pill( row, "EDITED", Theme.Blue, "You picked this clip", column: true ) : new ConfidencePill( row, binding.Confidence, false, C.Analysis.AssignedAnimations.TryGetValue( role, out var guess ) ? guess.Reason : null ) );
else
layout.Add( new Pill( row, "", Theme.TextLight, column: true ) );
s.ThirdPerson.TryGetValue( role, out var tp );
tp ??= new CharacterAnimation();
var custom = tp.Source != CharacterAnimationSource.Graph;
var third = layout.Add( UiStyle.Icon( row, tp.Source == CharacterAnimationSource.None ? "person_off" : "accessibility_new", () => ChangeThirdPerson( role ),
$"Third person: {ThirdPersonText( role, tp )}{(tp.Manual ? " (set by you)" : "")}. Click to change." ) );
if ( custom )
third.Foreground = Theme.Blue;
}
private const float RoleColumn = 92f;
/// <summary>Clip names often carry the armature ("PistolArmature|Reload"); the part after the bar reads better.</summary>
private static string ShortClip( string clip )
{
var bar = clip.LastIndexOf( '|' );
return bar >= 0 && bar < clip.Length - 1 ? clip[(bar + 1)..] : clip;
}
private static string ThirdPersonText( AnimationRole role, CharacterAnimation tp )
{
if ( tp.Source == CharacterAnimationSource.Graph )
{
var trigger = AnimationRoles.GraphTrigger( role );
return trigger is null ? "Character animgraph" : $"Character animgraph · {trigger}";
}
if ( tp.Source == CharacterAnimationSource.Sequence )
return string.IsNullOrEmpty( tp.Model ) ? $"{tp.Sequence} · character" : $"{tp.Sequence} · {System.IO.Path.GetFileNameWithoutExtension( tp.Model )}";
return "None";
}
private void ChangeWeaponClip( AnimationRole role )
{
C.SelectRole( role );
var s = C.Setup;
s.WeaponAnimations.TryGetValue( role, out var binding );
// Search list: every clip in the file, the ones that look like this role first.
var items = new List<PickerItem>();
foreach ( var clip in C.Analysis.Asset.Clips )
{
var guess = C.Analysis.Animations.FirstOrDefault( g => g.Animation == clip.Name );
var suggested = guess is not null && guess.Role == role;
var looksLike = guess is not null && guess.Role != AnimationRole.Unknown ? $"{AnimationRoles.Label( guess.Role )}? · " : "";
items.Add( new PickerItem( clip.Name, clip.Name, $"{looksLike}{clip.Duration:0.00} s", suggested, binding?.Clip == clip.Name ) );
}
items.Add( new PickerItem( "", "None", "no weapon clip", Current: binding is null || string.IsNullOrEmpty( binding.Clip ) ) );
new SearchPicker( this, $"Weapon clip for {AnimationRoles.Label( role )}", items, name => SetClip( role, string.IsNullOrEmpty( name ) ? null : name ) ).Open();
}
private void SetClip( AnimationRole role, string clip )
{
Safe( () =>
{
var s = C.Setup;
if ( clip is null )
s.WeaponAnimations.Remove( role );
else
s.WeaponAnimations[role] = new AnimationBinding { Clip = clip, Confidence = 1f, Manual = true };
C.Session.Revalidate();
C.MarkChanged();
C.SelectRole( role );
C.SetStatus( clip is null ? $"{AnimationRoles.Label( role )} has no weapon clip now." : $"{AnimationRoles.Label( role )} plays {clip}.", Theme.Green );
} );
}
private void ChangeThirdPerson( AnimationRole role )
{
C.SelectRole( role );
var menu = new Menu( this );
menu.AddHeading( $"Third person · {AnimationRoles.Label( role )}" );
menu.AddOption( "Character animgraph", "account_tree", () => SetThirdPerson( role, new CharacterAnimation { Source = CharacterAnimationSource.Graph, Manual = true } ) );
AddCharacterSequences( menu.AddMenu( "Character sequence", "accessibility_new" ), role );
menu.AddOption( "Sequence from another model…", "folder_open", () => PickSequence( role ) );
menu.AddSeparator();
menu.AddOption( "None", "block", () => SetThirdPerson( role, new CharacterAnimation { Source = CharacterAnimationSource.None, Manual = true } ) );
menu.OpenAtCursor();
}
/// <summary>The character's own sequences, grouped by their first name part (there are hundreds).</summary>
private void AddCharacterSequences( Menu menu, AnimationRole role )
{
var model = Model.Load( C.Session.CharacterModel );
if ( model is null || model.IsError || model.AnimationCount == 0 )
{
menu.AddOption( "(no sequences)", "block", null ).Enabled = false;
return;
}
var names = model.AnimationNames.OrderBy( n => n, StringComparer.OrdinalIgnoreCase ).ToList();
var hint = AnimationRoles.Label( role ).ToLowerInvariant().Split( ' ' )[0];
var suggested = names.Where( n => n.Contains( hint, StringComparison.OrdinalIgnoreCase ) ).Take( 12 ).ToList();
foreach ( var n in suggested )
{
var name = n;
menu.AddOption( name, "star", () => SetThirdPerson( role, new CharacterAnimation { Source = CharacterAnimationSource.Sequence, Model = "", Sequence = name, Manual = true } ) );
}
if ( suggested.Count > 0 )
menu.AddSeparator();
foreach ( var group in names.GroupBy( n => n.Split( '_' )[0] ) )
{
var sub = group.Count() == 1 ? menu : menu.AddMenu( $"{group.Key} ({group.Count()})", "folder" );
foreach ( var n in group )
{
var name = n;
sub.AddOption( name, "movie", () => SetThirdPerson( role, new CharacterAnimation { Source = CharacterAnimationSource.Sequence, Model = "", Sequence = name, Manual = true } ) );
}
}
}
private void PickSequence( AnimationRole role )
{
var picker = AssetPicker.Create( this, AssetType.Model );
picker.Title = "Model with the sequence";
picker.OnAssetPicked = assets => Safe( () =>
{
var asset = assets?.FirstOrDefault();
if ( asset is null )
return;
var model = Model.Load( asset.Path );
if ( model is null || model.IsError || model.AnimationCount == 0 )
{
C.SetStatus( $"{asset.Name} has no sequences.", Theme.Yellow );
return;
}
var menu = new Menu( this );
menu.AddHeading( $"{asset.Name} · {model.AnimationCount} sequences" );
foreach ( var name in model.AnimationNames )
{
var n = name;
menu.AddOption( n, "movie", () => SetThirdPerson( role, new CharacterAnimation { Source = CharacterAnimationSource.Sequence, Model = asset.Path, Sequence = n, Manual = true } ) );
}
menu.OpenAtCursor();
} );
picker.Show();
}
private void SetThirdPerson( AnimationRole role, CharacterAnimation value )
{
Safe( () =>
{
C.Setup.ThirdPerson[role] = value;
C.MarkChanged();
C.SelectRole( role );
} );
}
public override void OnDestroyed()
{
C.RoleChanged -= MarkDirty;
base.OnDestroyed();
}
}
/// <summary>One action in the animation list; clicking it previews the action.</summary>
public sealed class RoleRow : Widget
{
private readonly ImporterController _c;
private readonly AnimationRole _role;
private bool _selected;
public RoleRow( Widget parent, ImporterController controller, AnimationRole role ) : base( parent )
{
_c = controller;
_role = role;
Layout = Layout.Row();
Layout.Margin = new Sandbox.UI.Margin( 8, 3, 4, 3 );
Layout.Spacing = UiStyle.RowSpacing;
MouseTracking = true;
Cursor = CursorShape.Finger;
ToolTip = $"Preview {AnimationRoles.Label( role )}";
}
public bool Selected
{
get => _selected;
set
{
if ( _selected == value )
return;
_selected = value;
Update();
}
}
protected override void OnMouseEnter() => Update();
protected override void OnMouseLeave() => Update();
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton )
{
_c.SelectRole( _role );
e.Accepted = true;
}
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
if ( _selected )
{
Paint.SetPen( Theme.Green.WithAlpha( .6f ), 1 );
Paint.SetBrush( Theme.Green.WithAlpha( .08f ) );
Paint.DrawRect( LocalRect.Shrink( .5f ), 4 );
Paint.ClearPen();
Paint.SetBrush( Theme.Green );
Paint.DrawRect( new Rect( 0, 6, 3, Height - 12 ), 1.5f );
}
else if ( Paint.HasMouseOver )
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.Lighten( .3f ) );
Paint.DrawRect( LocalRect, 4 );
}
}
}