UI/LocalReplaysScreen.razor
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@inherits VirtualListPanel
@namespace BlockParty
@attribute [StyleSheet( "LocalReplaysScreen.razor.scss" )]
<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
@{ if ( Stage is null ) return; }
@* Header row is the same width as the list (the centred content square), so the title sits centred
and the IMPORT button pins to the square's right edge — not out in the letterbox. The old footer
IMPORT is gone — the footer holds only the MAIN MENU button. *@
<PixelTooltip @ref="Tooltip"></PixelTooltip>
<div class="header-row">
<label class="header">LOCAL REPLAYS</label>
<div class="actions">
<button class="favorite-filter @(_favoritesOnly ? "active" : "")" onclick=@ToggleFavoritesOnly
onmouseover=@Tip( () => LocalReplays.FavoritesOnlyFilter
? "Showing favorites only"
: "Showing all replays" )>
<div class="icon">@(_favoritesOnly ? "favorite" : "favorite_border")</div>
</button>
<button class="import-button" onclick=@OpenImport
onmouseover=@Tip( "Add replay from pasted share code" )>
<label class="text">IMPORT</label>
<div class="icon">file_download</div>
</button>
</div>
</div>
<div class="list">
@{
// Visible window + row placement come from VirtualListPanel.
int first = FirstVisible;
int last = LastVisible;
}
@if ( _entries.Count == 0 )
{
<label class="info">@(_favoritesOnly ? "NO FAVORITES YET" : "NO REPLAYS YET")</label>
}
else
{
@for ( int i = first; i < last; i++ )
{
var entry = _entries[i];
var run = entry.Run;
var idx = i;
var top = RowTop( i );
bool win = run.Victory;
bool canReplay = run.CanReplay;
<div class="row @(i % 2 == 1 ? "even" : "") @(i == Selected ? "selected" : "")" style="top: @(top)px;"
onmouseover=@(() => SelectRow( idx )) onclick=@(() => SelectRow( idx ))>
<SteamAvatar [email protected] Size=@(50) OnHover=@Tip( "Steam profile" )></SteamAvatar>
<LiteralLabel class=@(entry.SteamId == (long)Game.SteamId ? "name own" : "name") Text=@NameFor( entry ) />
<div class="level-col" onmouseover=@Tip( () => RowLevelTip( idx ) )>
@if ( run.Level is not null )
{
<LevelNodePreview [email protected] CompactTag=@true StaticThumbnail=@true></LevelNodePreview>
}
</div>
<label class="date">@FormatDate( entry.RecordedAt )</label>
<label class="dur">@FormatTime( run.TimeSeconds )</label>
<div class="char-col">
<div class="icon-glow"></div>
@if ( run.Character.Partner is CharacterDef partner )
{
<div class="character rear" style="@CharacterPreviewStyle( partner )"></div>
}
<div class="character @(run.Character.Partner is null ? "" : "front")" style="@CharacterPreviewStyle( run.Character )"></div>
</div>
<div class="blocks-col">
<BlockTally Blocks=@(run.DecodeBlocks()) />
</div>
<label class="score @(!win ? "non-victory" : "")">@run.FinalScore</label>
@if ( canReplay )
{
<button class="replay" onclick=@(() => StartReplay( entry ))
onmouseover=@Tip( "Watch replay" )>▶</button>
}
<button class="favorite @(entry.IsFavorite ? "active" : "")" onclick=@(() => ToggleFavorite( entry ))
onmouseover=@Tip( () => RowFavoriteTip( idx ) )>
<div class="icon">@(entry.IsFavorite ? "favorite" : "favorite_border")</div>
</button>
<button class="delete" onclick=@(() => DeleteEntry( entry ))
onmouseover=@Tip( "Delete replay" )>X</button>
</div>
}
@if ( ShowScrollbar )
{
<div class="scrollbar" style="top: @(ThumbTop)px; height: @(ThumbHeight)px;"></div>
}
@* Hidden while the MAIN MENU button is focused; its own cube shows in the footer instead. *@
@if ( !BackSelected )
{
<div class="selector" style="top: @(SelectorTop)px; background-position: @(-SelectorFrame * 100)% 0%;"></div>
}
}
</div>
@* <label class="hint">SPACE: WATCH REPLAY · W/S: SELECT · ESC: BACK</label> *@
<div class="footer">
@* MAIN MENU joins the W/S cycle as the BACK pseudo-item (see VirtualListPanel.HasBackItem):
it takes the selector cube + highlight when focused; the cube keeps a fixed lane so the
button doesn't shift. *@
<div class="selector @(BackSelected ? "" : "hidden")" style="background-position: @(-SelectorFrame * 100)% 0%;"></div>
<button class="back @(BackSelected ? "selected" : "")" onmouseover=@SelectBack onclick=@(() => { Audio.PlaySfx( SfxType.MenuStart ); Stage.GoBack(); })>MAIN MENU</button>
</div>
@if ( _showImport )
{
<ReplayCodeDialog Title="IMPORT REPLAY CODE" ConfirmLabel="IMPORT" Error=@_importError
OnConfirm=@((string code) => ConfirmImport( code )) OnCancel=@CloseImport />
}
</root>
@code
{
public LocalReplaysStage Stage { get; set; }
public bool ImportOpen => _showImport;
private static string CharacterPreviewStyle( CharacterDef character )
{
float size = 40f * character.PreviewScale;
float offset = (40f - size) * 0.5f;
return FormattableString.Invariant(
$"left:{offset:0.###}px;top:{offset:0.###}px;width:{size:0.###}px;height:{size:0.###}px;background-image:url( {character.PreviewImage} );" );
}
private List<LocalReplayEntry> _entries = new();
private bool _showImport;
private string _importError = "";
private bool _favoritesOnly => LocalReplays.FavoritesOnlyFilter;
// ── hover tooltips (same mechanism as LevelEditorHud, which documents it in full) ─────────────────
private PixelTooltip Tooltip { get; set; }
private Panel _tipPanel;
private Func<string> _tipText;
/// <summary>Fixed tooltip text.</summary>
private Action<PanelEvent> Tip( string text )
=> e => { _tipPanel = e.This; _tipText = () => text; };
/// <summary>Tooltip text that can change while the control stays hovered, resolved per frame: clicking
/// a hovered toggle re-attaches this handler without firing a fresh mouseover, so a snapshot would
/// keep describing the state just left.</summary>
private Action<PanelEvent> Tip( Func<string> text )
=> e => { _tipPanel = e.This; _tipText = text; };
/// <summary>Row favourite tip, resolved from the ROW INDEX: toggling a favourite (or a delete) can
/// drop the row and shift the list, and razor reuses the button panel for whatever entry lands in
/// this slot next — so a captured entry would describe the wrong run.</summary>
private string RowFavoriteTip( int index )
=> index >= 0 && index < _entries.Count
? ( _entries[index].IsFavorite ? "Remove from favorites" : "Mark as favorite" )
: null;
// Resolve from the current row, just like favorite tips: scrolling/filtering/deleting can
// reuse the hovered panel for another replay. Unnamed stages intentionally have no tooltip.
private string RowLevelTip( int index )
{
string name = index >= 0 && index < _entries.Count ? _entries[index].Run?.Level?.Name : null;
return string.IsNullOrWhiteSpace( name ) ? null : name;
}
protected override void OnUpdate()
{
base.OnUpdate();
string text = _tipPanel.IsValid() && _tipPanel.HasHovered ? _tipText?.Invoke() : null;
if ( string.IsNullOrEmpty( text ) ) Tooltip?.Hide();
else Tooltip?.Show( text );
}
// Row count for the virtual-list base (scroll/selection/selector all live in VirtualListPanel).
protected override int ItemCount => _entries.Count;
// The footer MAIN MENU button joins the W/S selection cycle as a pseudo-item (index -1): it is
// focused only when there are no replay rows, and wraps around above the first row / below the last.
protected override bool HasBackItem => true;
// Freeze W/S list navigation while the import dialog owns input, so the selection doesn't move
// behind the modal (the dialog is capturing the keystrokes for the code field).
protected override void HandleNavigation( float dt )
{
if ( _showImport )
return;
base.HandleNavigation( dt );
}
protected override void OnStart()
{
base.OnStart();
RefreshEntries();
if ( _entries.Count > 0 )
{
// Reopen focused on the row we just watched when returning from a replay; SelectInitial
// clamps, so a now-shorter list (a deleted run) still lands on a valid row.
int initial = Stage?.ReturnSelection ?? -1;
SelectInitial( initial >= 0 ? initial : 0 );
}
else
SelectBackInitial();
}
private void RefreshEntries()
{
// Only show runs that are actually replayable on the current build (CanReplay checks the
// recorded SimVersion against the supported range); stale-version runs would just desync. They
// stay in the archive though — a version gate that revives their version shows them again.
_entries = LocalReplays.All
.Where( e => e.Run?.CanReplay == true && (!_favoritesOnly || e.IsFavorite) )
.ToList(); // newest first
}
// Launch the selected run's replay; tagged as the local player so the HUD shows "you".
private void StartReplay( LocalReplayEntry entry )
{
if ( entry?.Run is null || !entry.Run.CanReplay )
return;
Audio.PlaySfx( SfxType.MenuStart );
// Remember this row so exiting the replay reopens the archive with it focused.
int index = _entries.IndexOf( entry );
GameManager.Instance?.StartReplay( entry.Run, new ReplaySubmitter( entry.SteamId, NameFor( entry ), entry.CountryCode ?? "", entry.RecordedAt ), fromLocalReplays: true, returnSelectionIndex: index );
}
private void ToggleFavoritesOnly()
{
Audio.PlaySfx( SfxType.MenuBlip );
LocalReplays.FavoritesOnlyFilter = !LocalReplays.FavoritesOnlyFilter;
RefreshEntries();
if ( _entries.Count > 0 )
ResetToTop();
else
SelectBackInitial();
StateHasChanged();
}
private void ToggleFavorite( LocalReplayEntry entry )
{
Audio.PlaySfx( SfxType.MenuBlip );
int index = _entries.IndexOf( entry );
LocalReplays.ToggleFavorite( entry );
if ( _favoritesOnly && index >= 0 && !entry.IsFavorite )
{
_entries.RemoveAt( index );
ClampSelectionAfterRemoval();
}
StateHasChanged();
}
// Permanently delete one archived run from disk and drop it from the visible list, keeping the
// selection on a valid row.
private void DeleteEntry( LocalReplayEntry entry )
{
int index = _entries.IndexOf( entry );
if ( index < 0 )
return;
Audio.PlaySfx( SfxType.MenuBlip );
LocalReplays.Remove( entry );
_entries.RemoveAt( index );
ClampSelectionAfterRemoval();
StateHasChanged();
}
private void OpenImport()
{
Audio.PlaySfx( SfxType.MenuStart );
_showImport = true;
_importError = "";
StateHasChanged();
}
private void CloseImport()
{
_showImport = false;
_importError = "";
StateHasChanged();
}
private void ConfirmImport( string code )
{
if ( !LocalReplays.TryImportCode( code ) )
{
_importError = "INVALID REPLAY CODE";
StateHasChanged();
return;
}
RefreshEntries();
if ( _entries.Count > 0 )
ResetToTop();
else
SelectBackInitial();
CloseImport();
}
public void LaunchSelected()
{
if ( BackSelected )
{
Stage?.GoBack();
return;
}
if ( Selected < 0 || Selected >= _entries.Count )
return;
StartReplay( _entries[Selected] );
}
// dd MMM HH:mm, e.g. "28 Jun 14:05". Local time.
private static string FormatDate( DateTimeOffset t ) => t.LocalDateTime.ToString( "dd MMM HH:mm" );
private static string NameFor( LocalReplayEntry entry ) =>
!string.IsNullOrWhiteSpace( entry.DisplayName ) ? entry.DisplayName : new Friend( entry.SteamId ).Name;
private static string FormatTime( float seconds )
{
if ( seconds < 0f ) seconds = 0f;
int total = (int)MathF.Floor( seconds );
return $"{total / 60:00}:{total % 60:00}";
}
protected override int BuildHash() => System.HashCode.Combine(
_entries.Count,
(int)ScrollPos,
Selected,
System.HashCode.Combine( (int)(SelectorPos * 8f), SelectorFrame ),
System.HashCode.Combine( (int)((Stage?.FadeAlpha ?? 0f) * 20f), _showImport, _importError, _favoritesOnly, LocalReplays.Version ) );
}