UI/ReplayScrubBar.razor
@using System
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "ReplayScrubBar.razor.scss" )]
@* Scrubbable replay timeline. Reads as a thin progress bar (the fill tracks the committed playback
position); click or drag the head to SEEK, which re-simulates the deterministic run to the chosen
frame and holds it paused there.
Direction matters (the sim is forward-only):
- FORWARD drag -> seek live, with per-step SFX (the scrubbing-audio feel).
- BACKWARD drag -> deferred: the scene stays on the committed frame and only the head previews the
target; on release we seek once (silently) to where the head was dropped.
- click (mouse-down jump) -> seek immediately, silent (no burst of layered SFX).
The forward/backward decision is made in exact frame units (not the quantised progress fraction) so
a forward click can't round into a phantom backward commit on release.
Self-styled because s&box stylesheets don't cascade into a child component's internals. *@
<root class="replay-scrub">
<PixelTooltip @ref="ReactionTooltip"></PixelTooltip>
<div class="bar">
<div class="fill" style="width: @(Css( FillFraction ))%;"></div>
</div>
@foreach ( var marker in ReactionMarkers )
{
<div class="reaction-tick @(marker.Count > 1 ? "cluster" : "") @(marker.Count >= 8 ? "busy" : "")"
style="left: @(Css( marker.Fraction ))%; height: @(marker.Height)px;"></div>
}
<div class="head" style="left: @(Css( HeadFraction ))%;"></div>
</root>
@code
{
// True between mouse-down and mouse-up; while set, the head previews _previewFraction.
bool _dragging;
// Where the head is shown during a drag (the cursor position along the bar, [0,1]).
float _previewFraction;
// Set while the drag is sitting behind the committed frame (a backward seek deferred to release).
bool _pendingBackward;
// Keep a marker click snapped through hand jitter. Once a real horizontal drag
// starts, it stays a drag even if the pointer returns to the press position.
bool _holdingMarkerClick;
float _markerPressX;
float Progress => GameManager.Instance?.ReplayProgress ?? 0f;
int CurrentFrame => GameManager.Instance?.ReplayFrame ?? 0;
int Length => GameManager.Instance?.ReplayLength ?? 0;
PixelTooltip ReactionTooltip { get; set; }
ReplayReactions _markerSource;
int _markerRevision = -1, _markerLength, _markerSlices;
System.Collections.Generic.IReadOnlyList<ReplayReactionTimeline.Marker> _markers = Array.Empty<ReplayReactionTimeline.Marker>();
int MarkerSlices => Math.Clamp( (int)((Box.Right - Box.Left) * ScaleFromScreen / 8f), 1, 128 );
System.Collections.Generic.IReadOnlyList<ReplayReactionTimeline.Marker> ReactionMarkers
{
get
{
var source = GameManager.Instance?.CurrentReplayReactions;
int revision = source?.Revision ?? -1;
int slices = MarkerSlices;
if ( source != _markerSource || revision != _markerRevision || Length != _markerLength || slices != _markerSlices )
{
_markerSource = source; _markerRevision = revision; _markerLength = Length; _markerSlices = slices;
_markers = source?.HasSnapshot == true
? ReplayReactionTimeline.Build( source.OrderedEntries, Length, slices )
: Array.Empty<ReplayReactionTimeline.Marker>();
}
return _markers;
}
}
static string ReactionTime( int frame )
{
int tenths = (int)MathF.Round( frame * 10f / Arena.TICK_RATE );
return FormattableString.Invariant( $"{tenths / 600}:{tenths % 600 / 10:00}.{tenths % 10}" );
}
void UpdateReactionTooltip()
{
if ( HasHovered && !_dragging )
{
float fraction = ScreenPosToFraction( Mouse.Position );
if ( MarkerAt( fraction ) is { } marker )
{
string time = ReactionTime( marker.FirstFrame );
if ( marker.LastFrame != marker.FirstFrame ) time += $" – {ReactionTime( marker.LastFrame )}";
ReactionTooltip?.Show( $"{marker.Count} {(marker.Count == 1 ? "reaction" : "reactions")} - {time}" );
return;
}
}
ReactionTooltip?.Hide();
}
ReplayReactionTimeline.Marker? MarkerAt( float fraction ) => ReplayReactionTimeline.Nearest(
ReactionMarkers, fraction, 4f / MathF.Max( 1f, (Box.Right - Box.Left) * ScaleFromScreen ) );
// Fill always shows the committed playback position. The head follows the cursor while dragging
// (so a deferred backward drag visibly previews its target), otherwise it sits on the fill.
float FillFraction => Progress;
float HeadFraction => _dragging ? _previewFraction : Progress;
// Plain decimal, invariant culture — a tiny position otherwise stringifies in scientific notation
// (e.g. 3e-08), which the stylesheet parser rejects, and a comma-decimal locale would emit "0,5".
static string Css( float fraction )
=> (Math.Clamp( fraction, 0f, 1f ) * 100f).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );
// Screen pixel x -> normalised position along the bar [0,1]. Same math as PixelSlider; the bar
// spans the panel's full width so the panel's own box maps directly onto the timeline.
float ScreenPosToFraction( Vector2 pos )
=> MathX.LerpInverse( pos.x, Box.Left, Box.Right, true );
// Map a bar fraction to a frame, capped at Length-1: scrubbing must never land on the very last
// frame, since reaching it re-sims into the run's game-over (THE END + the determinism self-check).
// The viewer reaches the genuine end by letting playback resume and play it out.
int FractionToFrame( float f )
{
int frame = (int)MathF.Round( Math.Clamp( f, 0f, 1f ) * Length );
return Math.Clamp( frame, 0, Math.Max( 0, Length - 1 ) );
}
// Seek toward the cursor: forward (target at/ahead of the committed frame) seeks live now; backward
// is deferred to release so dragging the head left doesn't re-sim the scene every move.
void SeekToCursor( bool withSfx, bool snapToMarker = false )
{
var mgr = GameManager.Instance;
if ( mgr is null ) return;
_previewFraction = ScreenPosToFraction( Mouse.Position );
int target = FractionToFrame( _previewFraction );
if ( snapToMarker && MarkerAt( _previewFraction ) is { } marker )
{
_holdingMarkerClick = true;
target = marker.SeekFrame( Length );
_previewFraction = Length > 0 ? target / (float)Length : 0f;
}
if ( target >= mgr.ReplayFrame )
{
mgr.SeekReplayToFrame( target, playSfx: withSfx );
_pendingBackward = false;
}
else
{
_pendingBackward = true;
}
}
protected override void OnMouseDown( MousePanelEvent e )
{
base.OnMouseDown( e );
if ( e.MouseButton != MouseButtons.Left ) return; // left-only, like CustomPatternGrid
var mgr = GameManager.Instance;
if ( mgr is null ) return;
_dragging = true;
_pendingBackward = false;
_holdingMarkerClick = false;
_markerPressX = Mouse.Position.x;
mgr.BeginReplayScrub();
SeekToCursor( withSfx: false, snapToMarker: true ); // clicks snap to reactions; dragging stays continuous
e.StopPropagation();
}
// Drag: only while the LEFT press that armed _dragging still holds this panel active. Move
// events are buttonless (engine creates them with "none"), so the button check lives in
// OnMouseDown and _dragging carries it — a right press also sets Active and would drag otherwise.
protected override void OnMouseMove( MousePanelEvent e )
{
base.OnMouseMove( e );
if ( !_dragging || !HasActive ) return;
if ( _holdingMarkerClick && MathF.Abs( Mouse.Position.x - _markerPressX ) * ScaleFromScreen < 4f )
{
e.StopPropagation();
return;
}
_holdingMarkerClick = false;
SeekToCursor( withSfx: true ); // forward drag-moves keep their per-step audio
e.StopPropagation();
}
protected override void OnMouseUp( MousePanelEvent e )
{
base.OnMouseUp( e );
if ( e.MouseButton != MouseButtons.Left || !_dragging ) return;
EndDrag();
e.StopPropagation();
}
// Release fallback for when OnMouseUp isn't delivered (button released away from this small bar).
// The HUD keeps a full-screen catcher up during the scrub (GameManager.IsScrubbingTimeline), so the
// release is always processed somewhere and the active pseudo-class clears — once it has, and we were
// dragging, the drag is over wherever the cursor ended up.
public override void Tick()
{
base.Tick();
if ( _dragging && !HasActive )
EndDrag();
UpdateReactionTooltip();
}
void EndDrag()
{
_dragging = false;
_holdingMarkerClick = false;
var mgr = GameManager.Instance;
if ( mgr is null ) return;
// Commit a deferred backward seek to wherever the head was dropped (silent — one jump).
if ( _pendingBackward )
mgr.SeekReplayToFrame( FractionToFrame( _previewFraction ), playSfx: false );
_pendingBackward = false;
mgr.EndReplayScrub();
}
// Repaint as playback advances and as the head is dragged, quantised so it doesn't rebuild every
// single frame (matches the host HUD's progress bucketing).
protected override int BuildHash()
=> System.HashCode.Combine( (int)(Progress * 500f), _dragging, (int)(_previewFraction * 500f),
GameManager.Instance?.CurrentReplayReactions, GameManager.Instance?.CurrentReplayReactions?.Revision ?? -1,
Length, MarkerSlices );
}