UI/TitleScreen.razor
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "TitleScreen.razor.scss" )]
<root style="opacity: @(Stage?.FadeAlpha ?? 0f)">
@{ if ( Stage is null ) return; }
@* Title is drawn from the original CP437 bitmap font (Assets/fonts/font_big.png) to match
the source game's "BLOCK PARTY" exactly; every other bit of UI text is a normal TTF label. *@
<div class="title">
<div class="line">
@foreach ( var ch in "BLOCK" )
{
<div class="glyph" style="@GlyphStyle( ch )"></div>
}
</div>
<div class="line">
@foreach ( var ch in "PARTY" )
{
<div class="glyph" style="@GlyphStyle( ch )"></div>
}
</div>
</div>
@* The options are a bottom-aligned "mountain" of square icon buttons — blocks resting on a
floor, sized by importance and peaking at the centre: PLAY (hero) dead-centre, DAILY CHALLENGE
and LEADERBOARDS flanking it at the same size, WORKSHOP/REPLAYS outside those, and
OPTIONS/QUIT as the matching tertiary bookends on the wings. Rendered in
TitleStage.DisplayOrder, whose two sides mirror in width so PLAY sits exactly on the
centreline. Icon art is 1 image px = 1 ref px on every tier so the pixels read the same
size on all buttons. Selection (keyboard or
hover) lights the button up; the label under the row names it. *@
<div class="options">
@foreach ( var displayIdx in TitleStage.DisplayOrder )
{
var idx = displayIdx;
var selected = Stage.SelectedIndex == idx;
@* "Today" marker on DAILY CHALLENGE while today's daily is unattempted (local file
check, see TitleStage.DailyUnattempted): one cell of the calendar icon's date grid
blinks red (.today in the SCSS), walking one dot per day. Stays up while selected
too — the mark is part of the icon, not a selection treatment; the label separately
shimmers gold while DAILY is selected. *@
var unattempted = idx == TitleStage.DailyOptionIndex && Stage.DailyUnattempted;
@* Group gaps between the wings and the central play cluster: {OPTIONS, EDITOR}
{DAILY, PLAY, LEADERBOARDS, REPLAYS} {QUIT}. QUIT keeps its own island: the gap is
dead space (margins don't take pointer events), so a click that overshoots REPLAYS
lands on nothing instead of exiting the game. Both breaks are the same width, so the
wings sit the same distance from their neighbour and the sides stay mirrored. *@
var sectionStart = idx == TitleStage.WorkshopOptionIndex || idx == TitleStage.QuitOptionIndex;
<div class="option @TierClass( idx ) @IconClass( idx ) @(selected ? "selected" : "") @(unattempted ? "marked" : "") @(sectionStart ? "section-start" : "")"
onmouseover=@(() => Stage.SelectOption( idx ))
onclick=@(() => { Stage.SelectOption( idx ); Stage.Activate(); })>
<div class="img">
@if ( unattempted )
{
@* The marked date walks the calendar one dot per day (TitleStage.DailyMarkCell,
row-major) — see MarkStyle for the coords. Whole-string style expression like
GlyphStyle, NOT literal-plus-@() fragments, which s&box's style attribute
doesn't parse. *@
<div class="today" style="@MarkStyle()"></div>
}
</div>
</div>
}
</div>
@* Names the selected button (the buttons themselves are icon-only). QUIT keeps its muted-red
exit cue here; the daily-unattempted shimmer lands here while DAILY is selected. *@
<label class="option-label @(Stage.SelectedIndex == TitleStage.QuitOptionIndex ? "quit" : "") @(Stage.SelectedIndex == TitleStage.DailyOptionIndex && Stage.DailyUnattempted ? "flash" : "")">
@Stage.Options[Stage.SelectedIndex]
</label>
@* Editor-only shortcut into the level editor, tucked in the bottom-left letterbox — same
square/geometry/behaviour as the level select screen's editor button. Dev aid: only shown
when running in the s&box editor, absent in release builds. *@
@if ( Game.IsEditor )
{
<button class="square-btn i-edit editor-button" onclick=@(() => Stage.ClickEditor())>
<div class="img"></div>
</button>
}
</root>
@code
{
public TitleStage Stage { get; set; }
// Size tier per option — keep in sync with the .option tier rules in TitleScreen.razor.scss.
static string TierClass( int idx ) => idx switch
{
TitleStage.PlayOptionIndex => "hero",
TitleStage.DailyOptionIndex or TitleStage.LeaderboardsOptionIndex => "primary",
TitleStage.LocalReplaysOptionIndex or TitleStage.WorkshopOptionIndex
or TitleStage.EditorOptionIndex => "secondary",
_ => "tertiary",
};
// Icon per option (mapped to Assets/ui/icons in the SCSS). EDITOR/OPTIONS reuse the letterbox
// buttons' pencil/gear art — same 40px resolution tier.
static string IconClass( int idx ) => idx switch
{
TitleStage.PlayOptionIndex => "i-play",
TitleStage.DailyOptionIndex => "i-daily",
TitleStage.LeaderboardsOptionIndex => "i-trophy",
TitleStage.LocalReplaysOptionIndex => "i-replay",
TitleStage.EditorOptionIndex => "i-edit",
TitleStage.SettingsOptionIndex => "i-gear",
TitleStage.QuitOptionIndex => "i-quit",
TitleStage.WorkshopOptionIndex => "i-workshop",
_ => "",
};
// Position of the daily calendar's "today" mark: the 8px square centred on this day's date dot
// (TitleStage.DailyMarkCell, row-major over the 4x4 grid). Icon art is 1 image px = 1 ref px,
// so these are PNG pixel coords — dots are 4px chunks at x 20/32/44/56, y 32/42/52/62, and the
// mark starts 2px up-left of its dot. Always emits BOTH properties: s&box never clears absent
// inline properties on reused panels.
static string MarkStyle()
{
int col = TitleStage.DailyMarkCell % 4;
int row = TitleStage.DailyMarkCell / 4;
return $"left:{18 + col * 12}px;top:{30 + row * 10}px;";
}
// font_big.png is a 16x16 grid of CP437 glyphs. We size the atlas to 16x the glyph div
// (1600%) so one cell exactly fills the div, then shift by whole cells to the character's
// (col,row). Percentages (not px) are deliberate: s&box scales background *sizes* by
// ScaleToScreen but NOT background *positions* (see engine ImageRect.Calculate), so px
// units desync on any non-1080 display — percentages keep both in the same space.
static string GlyphStyle( char c )
{
int col = c % 16;
int row = c / 16;
return $"background-size:1600% 1600%;background-position:-{col * 100}% -{row * 100}%;";
}
protected override int BuildHash() => System.HashCode.Combine(
Stage?.SelectedIndex ?? 0,
Stage?.DailyUnattempted ?? false,
TitleStage.DailyMarkCell, // moves the mark if the menu sits open across UTC midnight
(int)((Stage?.FadeAlpha ?? 0f) * 20f) );
}