UI/ScoreScreen.razor
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "ScoreScreen.razor.scss" )]
<root style="opacity: @(Stage?.FadeAlpha ?? 0f)" onclick=@(() => Stage?.Confirm())>
@{ if ( Stage is null ) return; }
<PixelTooltip @ref="Tooltip"></PixelTooltip>
@if ( Settings.Current.ShowTimer )
{
<RunTimer [email protected] ShowMilliseconds=@true />
}
@{ var sp = Project( Stage.ScoreWorldPos ); }
@* The run's character floats just above the final score (glow behind it, like the UI badges);
a paired character (the twins) composes like the picker's twins icon. *@
@{ var runCharacter = Stage.RunCharacter; }
<div class="score-character @(runCharacter.Partner is null ? "" : "paired")" style="@ScoreCharacterStyle( sp.x, sp.y, runCharacter )">
<div class="icon-glow"></div>
@if ( runCharacter.Partner is CharacterDef runPartner )
{
<div class="sprite rear" style="background-image:url( @runPartner.PreviewImage );"></div>
}
<div class="sprite front" style="background-image:url( @runCharacter.PreviewImage );"></div>
</div>
<label class="score @(Stage.Victory ? "victory-score" : "non-victory")" style="left: @(sp.x)px; top: @(sp.y)px;">@Stage.Score</label>
@{ var bannerPos = Project( new Vector2( 120f, 120f ) ); }
@* Outcome banner one line above the TIME BONUS slot: VICTORY! on a win, YOU DIED on a loss. *@
@if ( Stage.ShowVictory )
{
<label class="outcome victory" style="left: @(bannerPos.x)px; top: @(bannerPos.y - 55f)px;">VICTORY!</label>
}
else if ( Stage.ShowDeath )
{
<label class="outcome died" style="left: @(bannerPos.x)px; top: @(bannerPos.y - 55f)px;">YOU DIED</label>
}
@if ( !string.IsNullOrEmpty( Stage.Banner ) )
{
<label class="banner" style="left: @(bannerPos.x)px; top: @(bannerPos.y)px;">@Stage.Banner</label>
}
@* Coin count-out: on a win one line below the TIME BONUS slot (mirroring VICTORY!'s line above
it); on a loss there is no time bonus, so the coins take the empty centre slot under YOU DIED. *@
@if ( !string.IsNullOrEmpty( Stage.CoinBanner ) )
{
<label class="banner coins" style="left: @(bannerPos.x)px; top: @(bannerPos.y + (Stage.Victory ? 55f : 0f))px;">@Stage.CoinBanner</label>
}
@foreach ( var f in Stage.Floaters )
{
var p = Project( f.World );
var alpha = Math.Clamp( 1f - f.Age / 0.9f, 0f, 1f );
<label class="floater" style="left: @(p.x)px; top: @(p.y)px; color: @f.Color.Hex; opacity: @alpha;">@f.Text</label>
}
@if ( Stage.Finished )
{
@* Daily runs always show the day's attempt state above the buttons (debug runs read the
display-only debug counter while daily_debug_nav is on). Null on non-daily runs. The line
hugs the top of the button list rather than sitting at a fixed height, so it can't drift up
into the tally's lower block row (which reaches down to 234px on a 4+ block layout). *@
@if ( Stage.DailyAttemptsText is string attempts )
{
<label class="attempts" style="bottom: @(AttemptsBottom)px;">@attempts</label>
}
<div class="buttons">
@* List wrapper is position:relative so the "smiling cube" selector (absolutely
positioned) can slide vertically between the rows — same selector the menus use. *@
<div class="list">
<div class="selector"
style="top: @(Stage.ButtonSelectorPos * ROW_PITCH + SELECTOR_INSET)px; background-position: @(-Stage.ButtonSelectorFrame * 100)% 0%;"></div>
@for ( int i = 0; i < Stage.Buttons.Count; i++ )
{
var idx = i;
var selected = Stage.SelectedButton == idx;
<div class="button @(selected ? "selected" : "")"
onmouseover=@(() => Stage.SelectButton( idx ))
onclick=@((PanelEvent e) => { e.StopPropagation(); Stage.ActivateButton( idx ); })>
<label class="text">@Label( Stage.Buttons[idx] )</label>
</div>
}
</div>
</div>
@* Shortcuts out of the finished tally, in the play square's bottom corners so they stay clear
of the RESTART / CONTINUE lane. Right: replay / leaderboard — both remember this tally
(ScoreStage.Snapshot), so BACK from the board — or exiting the replay — drops us straight
back here with the count-out already played out. Left: the level's Steam Workshop page
(workshop levels only). Clicks are stopped: the root's own onclick would otherwise fire the
selected button. *@
<div class="foot-buttons">
@* Inner box is the 1080-wide play square, so the corners hug the arena's edges rather
than drifting out into the letterbox on wide screens. *@
<div class="inner">
<div class="left">
@if ( Stage.WorkshopItemUrl is string workshopUrl )
{
<WorkshopLinkButton Url=@workshopUrl OnHover=@Tip( "Steam Workshop page" )></WorkshopLinkButton>
}
</div>
<div class="right">
@if ( Stage.CanWatchReplay )
{
<button class="square-btn i-replay" onclick=@((PanelEvent e) => { e.StopPropagation(); Stage.WatchReplay(); })
onmouseover=@Tip( "Watch this run" )>
<div class="img"></div>
</button>
}
@if ( Stage.CanViewLeaderboard )
{
<button class="square-btn i-trophy" onclick=@((PanelEvent e) => { e.StopPropagation(); Stage.ViewLeaderboard(); })
onmouseover=@Tip( "View full leaderboard" )>
<div class="img"></div>
</button>
}
</div>
</div>
</div>
}
</root>
@code
{
public ScoreStage Stage { get; set; }
// The run-character icon above the score: its sprite box (1080-ref px, scaled by the character's
// preview proportion) and how far its centre sits above the score's centre.
const float SCORE_CHAR_SIZE = 72f;
const float SCORE_CHAR_LIFT = 75f;
private static string ScoreCharacterStyle( float x, float y, CharacterDef character )
{
float size = SCORE_CHAR_SIZE * character.PreviewScale;
return FormattableString.Invariant(
$"left:{x - size / 2f:0.###}px;top:{y - SCORE_CHAR_LIFT - size / 2f:0.###}px;width:{size:0.###}px;height:{size:0.###}px;" );
}
// Vertical distance (1080-ref px) between button rows. MUST match the SCSS:
// .button height + .list gap. The selector's top = ButtonSelectorPos * ROW_PITCH + inset.
const float ROW_PITCH = 80f;
// Vertically centre the (48px) cube against the (64px) row.
const float SELECTOR_INSET = 8f;
// Button block's bottom offset and the list's inter-row gap. MUST match the SCSS.
const float BUTTONS_BOTTOM = 50f;
const float LIST_GAP = 16f;
// Gap between the top of the button list and the daily attempts line above it.
const float ATTEMPTS_GAP = 6f;
// The daily attempts line sits just above the button list, wherever that list's top lands (N
// rows tall = N * ROW_PITCH minus the trailing gap), instead of at a fixed height: the clear
// band above a two-button list is only ~40px, since the tally's lower block row reaches down to
// 234px on a 4+ block layout.
private float AttemptsBottom
=> BUTTONS_BOTTOM + Stage.Buttons.Count * ROW_PITCH - LIST_GAP + ATTEMPTS_GAP;
// Hover tooltip layer for the footer's icon buttons — same mechanism as the other screens: the
// hovered panel is remembered on mouseover and re-validated each frame (see LevelEditorHud).
private PixelTooltip Tooltip { get; set; }
private Panel _tipPanel;
private string _tipText;
private Action<PanelEvent> Tip( string text )
=> e => { _tipPanel = e.This; _tipText = text; };
protected override void OnUpdate()
{
if ( _tipPanel.IsValid() && _tipPanel.HasHovered && !string.IsNullOrEmpty( _tipText ) ) Tooltip?.Show( _tipText );
else Tooltip?.Hide();
}
private static string Label( ScoreStage.ScoreButton b ) => b switch
{
ScoreStage.ScoreButton.Restart => "RESTART",
_ => "CONTINUE",
};
// Project a world position (240-unit play space, centred at 120,120) into 1080-reference UI px.
private (float x, float y) Project( Vector2 world )
{
float px = 1080f / Arena.HEIGHT;
float uiW = 1080f * (Screen.Width / (float)Screen.Height);
float x = uiW / 2f + (world.x - Arena.WIDTH / 2f) * px;
float y = (Arena.HEIGHT - world.y) * px;
return (x, y);
}
protected override int BuildHash()
{
if ( Stage is null ) return 0;
float ageSum = 0f;
foreach ( var f in Stage.Floaters ) ageSum += f.Age;
return System.HashCode.Combine(
System.HashCode.Combine(
Stage.Score,
Settings.Current.ShowTimer,
(int)Stage.GameTime,
Stage.Finished,
Stage.Banner,
System.HashCode.Combine( Stage.ShowVictory, Stage.ShowDeath ),
Stage.Floaters.Count,
(int)(ageSum * 20f) ),
System.HashCode.Combine(
(int)(Stage.FadeAlpha * 20f),
Stage.SelectedButton,
(int)(Stage.ButtonSelectorPos * 100f),
Stage.ButtonSelectorFrame,
Stage.DailyAttemptsText,
Stage.CoinBanner,
Stage.CanWatchReplay,
System.HashCode.Combine( Stage.CanViewLeaderboard, Stage.WorkshopItemUrl ) ) );
}
}