Editor/WeaponImporter/Tool/UI/Timeline/TimelineLanes.cs
using Editor;
using Sandbox;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Setup;
namespace WeaponImporter.Tool.UI;
/// <summary>
/// Hand contact and event lanes of the selected action. Locked contact is a green block,
/// released is a gap, blends are drawn as ramps. Right-click a lane to add a key or event at
/// the playhead, drag markers to move them, right-click a marker to edit or delete it, and
/// double-click a contact block to make the hand follow a weapon bone.
/// </summary>
public sealed class TimelineLanes : Widget
{
public const float LabelWidth = 84f;
private const float LaneHeight = 18f;
private const float LaneGap = 4f;
private const float Pad = 6f;
private readonly ImporterController _c;
private float _playhead;
private object _drag;
private bool _dragMoved;
public TimelineLanes( Widget parent, ImporterController controller ) : base( parent )
{
_c = controller;
FixedHeight = 3 * LaneHeight + 2 * LaneGap + 4;
MouseTracking = true;
ToolTip = LanesTip;
}
public void SetPlayhead( float t )
{
if ( MathF.Abs( _playhead - t ) < 1e-4f )
return;
_playhead = t;
Update();
}
private AnimationRole Role => _c.Role;
private float Seconds => MathF.Max( 0.05f, _c.RoleSeconds( Role ) );
private float TrackLeft => LabelWidth + Pad;
private float TrackWidth => MathF.Max( 1f, Width - TrackLeft - Pad );
private float X( float t ) => TrackLeft + Math.Clamp( t, 0f, 1f ) * TrackWidth;
private float T( float x ) => Math.Clamp( (x - TrackLeft) / TrackWidth, 0f, 1f );
private static float LaneTop( int lane ) => 2 + lane * (LaneHeight + LaneGap);
private static Rect LaneRect( int lane, float left, float width ) => new( left, LaneTop( lane ), width, LaneHeight );
/// <summary>Lane 0 = left hand, 1 = right hand, 2 = events.</summary>
private static int LaneAt( float y ) => Math.Clamp( (int)((y - 2) / (LaneHeight + LaneGap)), 0, 2 );
private ContactTrack Track( bool create = false )
{
var setup = _c.Setup;
if ( setup is null )
return null;
if ( setup.Contacts.TryGetValue( Role, out var track ) )
return track;
if ( !create )
return null;
track = new ContactTrack();
setup.Contacts[Role] = track;
return track;
}
private IEnumerable<WeaponEvent> Events => _c.Setup?.Events.Where( e => e.Role == Role ) ?? Enumerable.Empty<WeaponEvent>();
// ------------------------------------------------------------------ paint
protected override void OnPaint()
{
Paint.Antialiasing = true;
string[] labels = { "Left hand", "Right hand", "Events" };
for ( var lane = 0; lane < 3; lane++ )
{
Paint.SetDefaultFont( 8 );
Paint.SetPen( Theme.TextLight );
Paint.DrawText( new Rect( 0, LaneTop( lane ), LabelWidth - 24, LaneHeight ), labels[lane], TextFlag.LeftCenter );
if ( lane < 2 && _c.Setup is not null )
DrawHandToggle( lane );
Paint.ClearPen();
Paint.SetBrush( Theme.WindowBackground );
Paint.DrawRect( LaneRect( lane, TrackLeft, TrackWidth ), 3 );
}
if ( _c.Setup is null )
return;
var track = Track();
DrawContacts( track, Side.Left, 0 );
DrawContacts( track, Side.Right, 1 );
DrawEvents();
var x = X( _playhead );
Paint.SetPen( Theme.Text.WithAlpha( .85f ), 1 );
Paint.DrawLine( new Vector2( x, 0 ), new Vector2( x, Height ) );
}
/// <summary>The "on weapon / own animation" switch at the end of a hand lane's label.</summary>
private static Rect ToggleRect( int lane ) => new( LabelWidth - 22, LaneTop( lane ), LaneHeight, LaneHeight );
private static Side LaneSide( int lane ) => lane == 0 ? Side.Left : Side.Right;
private void DrawHandToggle( int lane )
{
var released = _c.IsHandReleased( Role, LaneSide( lane ) );
var rect = ToggleRect( lane );
var hover = rect.IsInside( _mouse );
Paint.SetPen( released ? UiStyle.ButtonEdge : Theme.Green.WithAlpha( .7f ), 1 );
Paint.SetBrush( released ? ( hover ? Color.Lerp( UiStyle.ButtonFill, Color.White, .06f ) : UiStyle.ButtonFill ) : Theme.Green.WithAlpha( hover ? .3f : .18f ) );
Paint.DrawRect( rect.Shrink( .5f ), UiStyle.Radius );
Paint.SetPen( released ? Theme.TextLight : Theme.Green );
Paint.DrawIcon( rect, released ? "do_not_touch" : "pan_tool", 12, TextFlag.Center );
}
private Vector2 _mouse = new( -1, -1 );
private void DrawContacts( ContactTrack track, Side side, int lane )
{
var rect = LaneRect( lane, TrackLeft, TrackWidth );
if ( _c.IsHandReleased( Role, side ) )
{
// Own animation for the whole action: an empty, dimmed lane.
Paint.SetPen( Theme.TextLight.WithAlpha( .35f ), 1, PenStyle.Dash );
Paint.ClearBrush();
Paint.DrawRect( rect.Shrink( .5f ), 3 );
Paint.SetPen( Theme.TextLight );
Paint.SetDefaultFont( 7 );
Paint.DrawText( rect.Shrink( 8, 0 ), "own animation", TextFlag.LeftCenter );
return;
}
var inactive = side == Side.Left && !(_c.Setup.UseSupportHand && _c.Setup.Support is not null);
var color = inactive ? Theme.TextLight : Theme.Green;
var seconds = Seconds;
// Weight curve as a filled area: full = locked, empty = released, slopes = blends.
var steps = Math.Max( 2, (int)(rect.Width / 3) );
var points = new List<Vector2> { new( rect.Left, rect.Bottom - 2 ) };
for ( var i = 0; i <= steps; i++ )
{
var t = i / (float)steps;
var w = track?.Weight( side, t, seconds ) ?? 1f;
points.Add( new Vector2( rect.Left + t * rect.Width, rect.Bottom - 2 - w * (rect.Height - 5) ) );
}
points.Add( new Vector2( rect.Right, rect.Bottom - 2 ) );
Paint.SetPen( color.WithAlpha( .9f ), 1 );
Paint.SetBrush( color.WithAlpha( inactive ? .08f : .22f ) );
Paint.DrawPolygon( points.ToArray() );
if ( track is null )
return;
foreach ( var key in track.Keys.Where( k => k.Hand == side ) )
{
var kx = X( key.Time );
var locked = key.State == ContactState.Locked;
var c = locked ? Theme.Green : Theme.TextLight;
if ( ReferenceEquals( _drag, key ) )
c = Theme.Primary;
DrawDiamond( new Vector2( kx, rect.Center.y ), 5f, c );
if ( locked && !string.IsNullOrEmpty( key.Follow ) )
{
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont( 7 );
Paint.DrawText( new Rect( kx + 7, rect.Top, 140, rect.Height ), "follows " + key.Follow, TextFlag.LeftCenter );
}
}
}
private void DrawEvents()
{
var rect = LaneRect( 2, TrackLeft, TrackWidth );
foreach ( var e in Events )
{
var ex = X( e.Time );
var marker = new Rect( ex - 8, rect.Top + 1, 16, 16 );
Paint.ClearPen();
Paint.SetBrush( ReferenceEquals( _drag, e ) ? Theme.Primary : Theme.Blue.WithAlpha( e.Manual ? .9f : .6f ) );
Paint.DrawRect( marker, 8 );
Paint.SetPen( Color.White );
Paint.DrawIcon( marker, EventIcon( e.Kind ), 11 );
}
}
private static void DrawDiamond( Vector2 c, float r, Color color )
{
Paint.SetPen( Theme.WindowBackground, 1 );
Paint.SetBrush( color );
Paint.DrawPolygon( new[] { new Vector2( c.x, c.y - r ), new Vector2( c.x + r, c.y ), new Vector2( c.x, c.y + r ), new Vector2( c.x - r, c.y ) } );
}
public static string EventIcon( WeaponEventKind kind ) => kind switch
{
WeaponEventKind.Fire => "local_fire_department",
WeaponEventKind.MuzzleFlash => "flare",
WeaponEventKind.ShellEject => "eject",
WeaponEventKind.MagazineDetach => "file_download",
WeaponEventKind.MagazineInsert => "file_upload",
WeaponEventKind.BoltPull => "arrow_back",
WeaponEventKind.BoltRelease => "arrow_forward",
WeaponEventKind.ReloadComplete => "check_circle",
_ => "circle",
};
// ------------------------------------------------------------------ hit testing
private object HitMarker( Vector2 p )
{
if ( _c.Setup is null )
return null;
var lane = LaneAt( p.y );
if ( lane == 2 )
return Events.OrderBy( e => MathF.Abs( X( e.Time ) - p.x ) ).FirstOrDefault( e => MathF.Abs( X( e.Time ) - p.x ) <= 8 );
var side = lane == 0 ? Side.Left : Side.Right;
return Track()?.Keys.Where( k => k.Hand == side ).OrderBy( k => MathF.Abs( X( k.Time ) - p.x ) ).FirstOrDefault( k => MathF.Abs( X( k.Time ) - p.x ) <= 6 );
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
if ( _drag is not null && (e.ButtonState & MouseButtons.Left) != 0 )
{
var t = T( e.LocalPosition.x );
if ( _drag is ContactKey key )
key.Time = t;
else if ( _drag is WeaponEvent ev )
{
ev.Time = t;
ev.Manual = true;
}
_dragMoved = true;
Update();
return;
}
var hit = HitMarker( e.LocalPosition );
_mouse = e.LocalPosition;
Cursor = hit is not null ? CursorShape.SizeH : CursorShape.Finger;
ToolTip = HandToggleSideAt( e.LocalPosition ) is { } side
? $"{(side == Side.Left ? "Left" : "Right")} hand during {AnimationRoles.Label( Role )}: {(_c.IsHandReleased( Role, side ) ? "plays its own animation" : "on the weapon")}. Click to switch."
: LanesTip;
Update();
}
private const string LanesTip = "Right-click a lane to add a key or an event at the playhead · drag markers to move them · double-click a hand block to follow a weapon bone · the hand buttons switch a hand between the weapon and its own animation";
private int? HandToggleAt( Vector2 p )
{
for ( var lane = 0; lane < 2; lane++ )
if ( ToggleRect( lane ).IsInside( p ) )
return lane;
return null;
}
private Side? HandToggleSideAt( Vector2 p ) => HandToggleAt( p ) is { } lane ? LaneSide( lane ) : null;
protected override void OnMouseLeave()
{
_mouse = new Vector2( -1, -1 );
Update();
}
protected override void OnMousePress( MouseEvent e )
{
if ( _c.Setup is null )
return;
if ( e.LeftMouseButton && HandToggleSideAt( e.LocalPosition ) is { } toggled )
{
_c.ToggleHand( Role, toggled );
e.Accepted = true;
return;
}
var hit = HitMarker( e.LocalPosition );
if ( e.LeftMouseButton )
{
if ( e.IsDoubleClick && hit is null && LaneAt( e.LocalPosition.y ) < 2 )
{
FollowMenu( LaneAt( e.LocalPosition.y ) == 0 ? Side.Left : Side.Right, T( e.LocalPosition.x ) );
e.Accepted = true;
return;
}
if ( hit is not null )
{
_drag = hit;
_dragMoved = false;
}
else if ( e.LocalPosition.x >= TrackLeft )
_c.Seek( T( e.LocalPosition.x ) );
e.Accepted = true;
}
else if ( e.RightMouseButton )
{
if ( hit is ContactKey key )
KeyMenu( key );
else if ( hit is WeaponEvent ev )
EventMenu( ev );
else
LaneMenu( LaneAt( e.LocalPosition.y ) );
e.Accepted = true;
}
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
if ( _drag is not null )
{
_drag = null;
if ( _dragMoved )
Commit( "Moved on the timeline." );
Update();
}
}
private void Commit( string message )
{
var track = Track();
track?.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
// Edited by hand: the importer no longer re-plans this action.
if ( track is not null )
track.Generated = false;
_c.Session?.Revalidate();
_c.MarkChanged();
_c.SetStatus( message, Theme.Green );
Update();
}
// ------------------------------------------------------------------ menus
private void LaneMenu( int lane )
{
var t = _c.Time;
var menu = new Menu( this );
if ( lane == 2 )
{
menu.AddHeading( $"Add event at {t * Seconds:0.00} s" );
foreach ( var kind in Enum.GetValues<WeaponEventKind>() )
{
var k = kind;
menu.AddOption( WeaponEvent.Label( kind ), EventIcon( kind ), () =>
{
_c.Setup.Events.Add( new WeaponEvent { Role = Role, Kind = k, Time = t, Manual = true } );
Commit( $"{WeaponEvent.Label( k )} added." );
} );
}
}
else
{
var side = lane == 0 ? Side.Left : Side.Right;
menu.AddHeading( $"{(side == Side.Left ? "Left" : "Right")} hand at {t * Seconds:0.00} s" );
foreach ( var blend in new[] { 0f, 0.1f, 0.2f, 0.35f } )
{
var b = blend;
menu.AddOption( $"Lock here{(b > 0 ? $" · blend {b:0.##} s" : " · instant")}", "lock", () => AddKey( side, t, ContactState.Locked, b ) );
}
menu.AddSeparator();
foreach ( var blend in new[] { 0f, 0.1f, 0.2f, 0.35f } )
{
var b = blend;
menu.AddOption( $"Release here{(b > 0 ? $" · blend {b:0.##} s" : " · instant")}", "lock_open", () => AddKey( side, t, ContactState.Released, b ) );
}
}
menu.OpenAtCursor();
}
private void AddKey( Side side, float t, ContactState state, float blend )
{
Track( create: true ).Keys.Add( new ContactKey { Hand = side, Time = t, State = state, Blend = blend } );
Commit( $"{(side == Side.Left ? "Left" : "Right")} hand {(state == ContactState.Locked ? "locks" : "lets go")} at {t * Seconds:0.00} s." );
}
private void KeyMenu( ContactKey key )
{
var menu = new Menu( this );
menu.AddHeading( $"{(key.Hand == Side.Left ? "Left" : "Right")} hand · {(key.State == ContactState.Locked ? "lock" : "release")} at {key.Time * Seconds:0.00} s" );
menu.AddOption( key.State == ContactState.Locked ? "Make it a release" : "Make it a lock", key.State == ContactState.Locked ? "lock_open" : "lock", () =>
{
key.State = key.State == ContactState.Locked ? ContactState.Released : ContactState.Locked;
Commit( "Key changed." );
} );
var blend = menu.AddMenu( $"Blend ({key.Blend:0.##} s)", "timeline" );
foreach ( var b in new[] { 0f, 0.05f, 0.1f, 0.15f, 0.2f, 0.35f, 0.5f } )
{
var v = b;
blend.AddOption( $"{v:0.##} s", MathF.Abs( key.Blend - v ) < 1e-3f ? "check" : null, () =>
{
key.Blend = v;
Commit( "Blend changed." );
} );
}
if ( key.State == ContactState.Locked )
AddFollowOptions( menu.AddMenu( "Follow bone", "link" ), key );
menu.AddOption( "Move to playhead", "vertical_align_center", () =>
{
key.Time = _c.Time;
Commit( "Key moved." );
} );
menu.AddSeparator();
menu.AddOption( "Delete key", "delete", () =>
{
Track()?.Keys.Remove( key );
Commit( "Key deleted." );
} );
menu.OpenAtCursor();
}
private void EventMenu( WeaponEvent ev )
{
var menu = new Menu( this );
menu.AddHeading( $"{WeaponEvent.Label( ev.Kind )} at {ev.Time * Seconds:0.00} s" );
var kinds = menu.AddMenu( "Change kind", "swap_horiz" );
foreach ( var kind in Enum.GetValues<WeaponEventKind>() )
{
var k = kind;
kinds.AddOption( WeaponEvent.Label( kind ), EventIcon( kind ), () =>
{
ev.Kind = k;
ev.Manual = true;
Commit( "Event changed." );
} );
}
menu.AddOption( "Move to playhead", "vertical_align_center", () =>
{
ev.Time = _c.Time;
ev.Manual = true;
Commit( "Event moved." );
} );
menu.AddSeparator();
menu.AddOption( "Delete event", "delete", () =>
{
_c.Setup.Events.Remove( ev );
Commit( "Event deleted." );
} );
menu.OpenAtCursor();
}
/// <summary>Double-click on a contact block: choose the weapon bone the hand follows while locked.</summary>
private void FollowMenu( Side side, float t )
{
var track = Track( create: true );
if ( track.Weight( side, t, Seconds ) < 0.5f )
{
_c.SetStatus( "The hand is released there; double-click where it holds the weapon.", Theme.TextLight );
return;
}
var key = track.Keys.Where( k => k.Hand == side && k.State == ContactState.Locked && k.Time <= t ).OrderByDescending( k => k.Time ).FirstOrDefault();
if ( key is null )
{
// Locked from the start: an explicit lock key at 0 carries the follow bone.
key = new ContactKey { Hand = side, Time = 0f, State = ContactState.Locked, Blend = 0f };
track.Keys.Add( key );
}
var menu = new Menu( this );
menu.AddHeading( $"{(side == Side.Left ? "Left" : "Right")} hand follows" );
AddFollowOptions( menu, key );
menu.OpenAtCursor();
}
private void AddFollowOptions( Menu menu, ContactKey key )
{
menu.AddOption( "The weapon (no bone)", string.IsNullOrEmpty( key.Follow ) ? "check" : "block", () =>
{
key.Follow = "";
Commit( "The hand follows the weapon." );
} );
var analysis = _c.Analysis;
if ( analysis is null )
return;
var partBones = _c.Setup.Parts.Where( p => p.Bone.Length > 0 ).ToDictionary( p => p.Bone, p => WeaponStep.Nice( p.Kind ) );
foreach ( var bone in analysis.Asset.Skeleton.Bones.OrderByDescending( b => partBones.ContainsKey( b.Name ) ) )
{
var name = bone.Name;
var label = partBones.TryGetValue( name, out var part ) ? $"{name} ({part})" : name;
menu.AddOption( label, key.Follow == name ? "check" : "linear_scale", () =>
{
key.Follow = name;
Commit( $"The hand follows {name}." );
} );
}
}
}