Editor/RigControlEditor/RigStatusBar.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.Tools;
/// <summary>
/// The strip along the bottom of the window: what you're hovering on the left, the current
/// tutorial step on the right.
///
/// This is the answer to the documentation being overwhelming. The old approach put every
/// explanation on screen at once, before the reader had done anything or had any reason to care
/// about most of it - so it read as a wall and got skipped, which means it may as well not have
/// been written. Same words, delivered one at a time at the moment they're relevant, are
/// something people actually read. Modelled on the in-game PressApp's status bar.
/// </summary>
/// Derives from Editor.StatusBar rather than plain Widget because that's the only type
/// Window.StatusBar accepts, and Window.StatusBar is the only place a DockWindow will put a
/// full-width strip - the dock manager owns everything else in the client area.
internal sealed class RigStatusBar : Editor.StatusBar
{
/// <summary>Set by whatever the mouse is over. Static because the things that want to explain
/// themselves - bone dots in a gizmo viewport, keyframes on a custom-painted timeline - are
/// scattered across widgets that have no reference to this bar and shouldn't need one.</summary>
public static string Hint { get; private set; }
private static RigStatusBar _instance;
public static void Show( string hint )
{
if ( Hint == hint )
return;
Hint = hint;
_instance?.Update();
}
public static void Clear( string ifMatching = null )
{
if ( ifMatching is not null && Hint != ifMatching )
return;
Hint = null;
_instance?.Update();
}
public RigTutorial Tutorial { get; set; }
/// <summary>
/// A Button that reports itself to the status bar on hover.
///
/// s&box's Widget exposes OnMouseEnter/OnMouseLeave as virtuals, not as events, so there's no
/// way to attach a hint to a stock Button from outside - it has to be a subclass. Hence this:
/// every control built with it explains itself in the bar, instead of the bar only ever
/// describing the viewport (which was the complaint - most of the tool was silent).
/// </summary>
public sealed class HintButton : Button
{
private readonly string _hint;
public HintButton( string icon, string hint, Action clicked, Widget parent = null, string text = "" )
: base( text, icon, parent )
{
_hint = hint;
// Native tooltip as well as the status bar - the bar is easy to miss while your eyes
// are on the control you're hovering.
ToolTip = hint;
Clicked = clicked;
}
/// <summary>Tints it, for marking the one control in a row that isn't like the others.</summary>
public HintButton WithAccent( Color color )
{
SetStyles( $"color: {color.Hex}; font-weight: 600;" );
return this;
}
// Qualified - unqualified Show/Clear would bind to Widget's own Show(), which does
// something entirely different.
protected override void OnMouseEnter()
{
base.OnMouseEnter();
RigStatusBar.Show( _hint );
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
RigStatusBar.Clear( _hint );
}
}
public RigStatusBar( Widget parent ) : base( parent )
{
_instance = this;
FixedHeight = 24;
}
public override void OnDestroyed()
{
base.OnDestroyed();
if ( _instance == this )
_instance = null;
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceBackground );
Paint.DrawRect( LocalRect );
Paint.SetPen( Theme.WindowBackground );
Paint.DrawLine( new Vector2( 0f, 0f ), new Vector2( Width, 0f ) );
// HOVER HINTS ONLY. The tutorial used to share this strip and was unreadable in it - one
// line of text at the bottom of the window, competing with whatever the cursor was over,
// in a band the eye reads as chrome. It lives in its own dock now, where it can be looked
// at. This bar does the one thing a status bar is good at: saying what's under the cursor.
Paint.SetDefaultFont( 9 );
if ( !string.IsNullOrWhiteSpace( Hint ) )
{
Paint.SetPen( Theme.TextControl );
Paint.DrawText( new Rect( 12f, 0f, Width - 24f, Height ), Hint, TextFlag.LeftCenter );
return;
}
Paint.SetPen( Theme.TextControl.WithAlpha( 0.35f ) );
Paint.DrawText( new Rect( 12f, 0f, Width - 24f, Height ), "Ready", TextFlag.LeftCenter );
}
}
/// <summary>
/// A guided build of one real animation - a wave - rather than a description of what the buttons
/// do.
///
/// Steps advance by WATCHING THE DOCUMENT, not by the reader clicking "Next". You can't tick a
/// step off without having actually done it, so nobody arrives at the end having read six things
/// and animated nothing. The pose-to-pose order it walks through (extremes first, in-betweens
/// after) is the order that makes hand-keyed animation read, and doing it once in the right order
/// teaches more than a paragraph saying so.
/// </summary>
internal sealed class RigTutorial
{
/// <summary>A drawn glyph per step. Painted rather than an image asset so it ships with the
/// code, scales with the panel and follows the editor theme - and so the tool has no binary
/// art to keep in sync with anything.</summary>
public enum StepArt
{
Model,
Bone,
Rotate,
Keyframe,
Play
}
public sealed class Step
{
public string Instruction { get; init; }
/// <summary>The actions, one per bullet. Kept separate from the prose because they are
/// different things doing different jobs: this is what you DO, and it should be scannable
/// in a glance without reading a sentence.</summary>
public string[] Bullets { get; init; }
/// <summary>The why, in one or two lines under the bullets. Instructions tell you what to
/// press; this is the part that means you still know what you're doing afterwards.</summary>
public string Detail { get; init; }
public StepArt Art { get; init; }
/// <summary>Dock this step is about, so the panel can offer to open it. Answers "where is
/// that?", which is the question a written instruction always leaves behind.</summary>
public string Panel { get; init; }
/// <summary>True once the reader has actually done this.</summary>
public Func<RigAnimDocument, string, bool> IsDone { get; init; }
}
private readonly List<Step> _steps;
public RigTutorial()
{
_steps = new List<Step>
{
new()
{
Instruction = "To pose a bone, you first have to select it",
Bullets = new[]
{
"Click its dot in the viewport",
"Or click its name in the timeline's left column",
"Or click it in the bone tree in the BonesObject tab"
},
Detail = "All three do the same thing. The selected bone turns yellow and gets a gizmo you can drag. Select arm_upper_R - the right shoulder - to carry on.",
Art = StepArt.Bone,
IsDone = ( _, bone ) => !string.IsNullOrEmpty( bone )
},
new()
{
Instruction = "To reach for something, you need something to reach for",
Bullets = new[]
{
"In the BonesObject tab, add a Reference Prop",
"Pick models/lightswitch/lightswitch_plate.vmdl",
"Drag its green dot in the viewport, out in front of the hand"
},
Detail = "Reference props are shown in the viewport only - never keyed, never exported. Posing at a real object beats imagining where one would be.",
Art = StepArt.Model,
Panel = "BonesObject",
// Assigning a model isn't the step - PLACING it is. This used to tick the instant a
// model was picked, while the prop was still sitting at the origin inside the
// model's chest, so the reader was waved on to "pose at the switch" with nothing
// to pose at. A prop still at 0,0,0 has not been placed.
IsDone = ( anim, _ ) => anim?.ReferenceProps?.Any( p =>
p?.Model is not null && p.Position.Length > 1f ) ?? false
},
new()
{
Instruction = "Every action needs a pose to leave from and come back to",
Bullets = new[]
{
"Go to frame 0",
"Press K"
},
Detail = "That keys the pose the arm already has - no posing needed. You'll copy this exact key to the end of the clip later so the whole thing settles back.",
Art = StepArt.Keyframe,
Panel = "Timeline",
IsDone = ( anim, _ ) => KeyNear( anim, 0, 2 )
},
// THE BACK HALF USED TO BE SEVEN STEPS AND IS NOW THREE.
//
// It ran anticipation, reach, wrist, contact, overshoot, loop-back, timing - each with
// a paragraph explaining why the beat matters. Every one of those is true and none of
// them belong in a first run. The reader is trying to find out whether they can make
// the arm move at all, and seven principle-steps between them and a thing that plays
// reads as homework.
//
// So: reach, wrist, play. Enough to get something that looks decent, which is the only
// thing that earns the reader a second sitting. The craft beats can be taught to
// someone who already has a clip they like.
//
// THE ROTATION VALUES BELOW ARE NOT INVENTED - they are read out of
// Assets/animations/reach_and_flip_switch.riganim, which RigSampleBuilder generates by
// running this tool's own two-bone IK solver at a world-space target for the hand. So
// they are the pose the solver picked for THIS rig, converted from the stored
// quaternion to the pitch/yaw/roll the Inspector displays.
//
// If the model or the switch placement changes, rebuild the sample (Editor menu ->
// Marionette -> Rebuild Example Clip, or rig_build_sample) and re-read frames 14 and
// 17 from it. Do NOT hand-pick replacements: nobody knows which local axis of
// arm_upper_R is "forward" without testing, which is the same reason RigSampleBuilder
// solves for a target instead of authoring angles.
new()
{
Instruction = "Now the reach itself",
Bullets = new[]
{
$"Go to frame {ReachFrame}",
$"In the Inspector, set arm_upper_R Rotation to {Show( ReachUpper )}",
$"Then arm_lower_R to {Show( ReachLower )}"
},
Detail = "Those are pitch, yaw, roll. Shoulder first, elbow second - the elbow hangs off the shoulder.",
Art = StepArt.Rotate,
Panel = "Inspector",
// BOTH bones, at the named values. The old check was "any bone has any key past
// frame 10", which ticked the moment you keyed anything at all - including the
// shoulder alone, with the elbow still hanging where the bind pose left it. Being
// waved on from a half-finished pose is worse than no check, because the next step
// then builds on something that doesn't look like what the tutorial describes.
IsDone = ( anim, _ ) =>
PosedAt( anim, "arm_upper_R", ReachFrame, ReachUpper )
&& PosedAt( anim, "arm_lower_R", ReachFrame, ReachLower )
},
new()
{
Instruction = "Aim the wrist",
Bullets = new[]
{
$"Go to frame {WristFrame}",
$"Set hand_R Rotation to {Show( WristHand )}"
},
Detail = "The palm turns to face the switch. Until now the hand has just been dragged along by the arm.",
Art = StepArt.Rotate,
Panel = "Inspector",
IsDone = ( anim, _ ) => PosedAt( anim, "hand_R", WristFrame, WristHand )
},
new()
{
Instruction = "Find the timing",
Bullets = new[]
{
"Press Play",
"Drag the keys closer together",
"Play it again"
},
Detail = "Almost every first animation runs at half the speed it should.",
Art = StepArt.Play,
Panel = "Timeline",
IsDone = ( _, _ ) => false
}
};
}
// The poses the tutorial asks for, in one place, so the numbers printed in the bullets and the
// numbers checked by IsDone cannot drift apart. They were separate literals for about ten
// minutes and that was already one edit away from a step that can never be completed.
//
// Read out of the generated example clip - see the comment above the reach step.
private const int ReachFrame = 14;
private const int WristFrame = 17;
private static readonly Angles ReachUpper = new( -18f, 63f, 36f );
private static readonly Angles ReachLower = new( 41f, -32f, 4f );
private static readonly Angles WristHand = new( -15f, -24f, 6f );
/// <summary>How the values are written into the instructions - the same three numbers, in the
/// same order the Inspector shows them.</summary>
private static string Show( Angles a ) => $"{a.pitch:0.#}, {a.yaw:0.#}, {a.roll:0.#}";
/// <summary>
/// True when one named bone is keyed near a frame at (near enough) a named rotation.
///
/// Compared component-wise in Euler rather than as a quaternion angle, because this is
/// checking the literal thing the step asked for: that these three numbers were typed into
/// those three fields. A quaternion distance would also pass a rotation that LOOKS the same
/// but reads differently in the boxes, which is not what "put all the values in" means.
///
/// Five degrees of slack absorbs the rounding - the step prints whole numbers and the values
/// behind them have decimals - without being loose enough to pass a pose you eyeballed.
/// </summary>
private static bool PosedAt( RigAnimDocument anim, string bone, int frame, Angles target, float tolerance = 5f )
{
if ( anim?.FindTrack( bone ) is not { } track )
return false;
foreach ( var key in track.Keyframes )
{
if ( Math.Abs( key.Frame - frame ) > 2 )
continue;
var angles = key.Local.Rotation.Angles();
if ( Apart( angles.pitch, target.pitch ) <= tolerance
&& Apart( angles.yaw, target.yaw ) <= tolerance
&& Apart( angles.roll, target.roll ) <= tolerance )
return true;
}
return false;
}
/// <summary>Degrees between two angles the short way round, so -179 and 179 are two apart
/// rather than three hundred and fifty eight.</summary>
private static float Apart( float a, float b )
{
var d = MathF.Abs( a - b ) % 360f;
return d > 180f ? 360f - d : d;
}
/// <summary>Any bone keyed within tolerance of a frame - the reader shouldn't have to land on
/// an exact frame for the tutorial to notice they did the thing.</summary>
private static bool KeyNear( RigAnimDocument anim, int frame, int tolerance ) =>
anim?.BoneTracks.Any( t => t.Keyframes.Any( k => Math.Abs( k.Frame - frame ) <= tolerance ) ) ?? false;
/// <summary>
/// Whether the tutorial dock opens itself when the tool starts.
///
/// EditorCookie, so it survives restarts and lives nowhere near a document - which panels you
/// like seeing is a property of you, not of the clip you happen to have open. Same mechanism
/// the editor's own preview widgets use for their settings.
/// </summary>
public static bool OpenOnStartup
{
get => EditorCookie.Get( "marionette.tutorial.openonstartup", true );
set => EditorCookie.Set( "marionette.tutorial.openonstartup", value );
}
/// <summary>Starts inactive so the panel shows its start screen first. Nobody should be
/// dropped into step one of something they never asked for.</summary>
public bool Active { get; private set; }
public int CurrentIndex { get; private set; }
public int StepCount => _steps.Count;
public Step CurrentStep => Active && CurrentIndex < _steps.Count ? _steps[CurrentIndex] : null;
/// <summary>Any step by index, so the panel can list the whole run rather than only the one
/// you're on - seeing what's done and what's left is most of what makes it feel finishable.</summary>
public Step StepAt( int index ) => index >= 0 && index < _steps.Count ? _steps[index] : null;
public void Restart()
{
Active = true;
CurrentIndex = 0;
_furthest = 0;
}
public void Dismiss() => Active = false;
/// <summary>Advance past every step already satisfied. Loops rather than stepping once, so a
/// reader who does three things before looking down isn't left three steps behind.</summary>
/// <summary>The furthest step reached, so stepping back doesn't immediately snap forward again.
/// See Evaluate.</summary>
private int _furthest;
public bool CanGoBack => Active && CurrentIndex > 0;
public bool CanGoForward => Active && CurrentIndex < _steps.Count;
/// <summary>Step back one. Auto-advance stays out of the way until you catch up again.</summary>
public void Back()
{
if ( !CanGoBack )
return;
CurrentIndex--;
}
/// <summary>Skip forward without having done the step - some are worth reading and not
/// following, and a tutorial that can only be advanced by obeying it is a cage.</summary>
public void Forward()
{
if ( !CanGoForward )
return;
CurrentIndex++;
_furthest = Math.Max( _furthest, CurrentIndex );
}
public bool Evaluate( RigAnimDocument anim, string selectedBone )
{
if ( !Active )
return false;
// DON'T FIGHT A MANUAL REWIND. Steps tick off when their condition holds, and those
// conditions stay true once satisfied - a keyframe at frame 6 is still there afterwards.
// So stepping back would re-satisfy the step you just left and snap forward again the
// same frame, making the Back button look broken. While you're behind the furthest point
// reached, auto-advance stops entirely; it picks up again once you're back at the front.
if ( CurrentIndex < _furthest )
return false;
var moved = false;
while ( CurrentIndex < _steps.Count && _steps[CurrentIndex].IsDone( anim, selectedBone ) )
{
CurrentIndex++;
moved = true;
}
_furthest = Math.Max( _furthest, CurrentIndex );
return moved;
}
}