UI/GameHud.razor
@using Sandbox
@using Sandbox.UI
@inherits PanelComponent
@namespace BlockParty
@attribute [StyleSheet( "GameHud.razor.scss" )]
@* In-game HUD: a gear button sitting in the letterbox outside the play square. Pressing it opens
the options overlay, which covers the arena and freezes the sim (see GameStage.BlocksSimulation)
while the player adjusts volume — without ducking the music. *@
<root>
@{ if ( Stage is null ) return; }
<PixelTooltip @ref="Tooltip"></PixelTooltip>
@if ( !Stage.IsReplay && Settings.Current.ShowTimer && !(GameManager.Instance?.IsGifPreviewActive ?? false) )
{
<RunTimer [email protected] [email protected] />
}
@if ( Stage.IsTest )
{
// Editor test run: a square exit button at the bottom-right of the LEFT letterbox (same spot as
// the editor's Test Play button) that returns to the editor. Back = BACKSPACE also exits.
<button class="square-btn i-close test-exit-button" onclick=@Click( () => Stage.ExitTest(), SfxType.MenuStart )
onmouseover=@Tip( "Back to editor (Backspace)" )>
<div class="img"></div>
</button>
}
@if ( Stage.IsReplay )
{
// Watching a recorded run: show a transport panel in the left letterbox (outside the play
// square): a speed slider, the restart/pause/back controls, and a progress read-out. The
// options gear still appears (top-right) so volume can be adjusted, same as live play.
var mgr = GameManager.Instance;
float speedT = mgr?.ReplaySpeedT ?? 0.5f;
float speed = mgr?.ReplaySpeed ?? 1f;
bool paused = mgr?.ReplayPaused ?? false;
bool finished = mgr?.ReplayFinished ?? false;
bool desynced = mgr?.ReplayDesynced ?? false;
bool hijacked = mgr?.IsHijacked ?? false;
bool hijackArmed = mgr?.HijackArmed ?? false;
var who = mgr?.ReplaySubmitterInfo ?? default;
// Locally-bundled flags live under Assets/sprites/flags, keyed by the lowercase ISO code.
string flagCode = string.IsNullOrEmpty( who.CountryCode ) ? "" : who.CountryCode.ToLower();
// While the GIF-export overlay is open the replay is shrunk into the top of the screen and the
// overlay owns the controls, so the normal replay HUD is hidden (it would overlap the preview).
// Driven by the GameManager flag (not a HUD-local one) so it stays correct across the stage
// rebuilds a backward replay seek triggers — the HUD is recreated, the flag persists.
@if ( !(mgr?.IsGifPreviewActive ?? false) )
{
@if ( mgr?.IsScrubbingTimeline ?? false )
{
// Full-screen transparent catcher, present only while the timeline is being scrubbed. It
// gives the cursor an interactive panel everywhere, so the mouse-release is processed even
// when it lands over the arena/letterbox (which have no panel) and the drag ends cleanly.
<div class="scrub-catcher"></div>
}
var replayLevel = Stage.Level;
var replayLevelName = replayLevel?.Name ?? "";
var replayLevelTag = string.IsNullOrEmpty( replayLevel?.Id ) ? "" : ( LevelMap.Find( replayLevel.Id )?.Tag ?? "" );
<div class="replay-dock">
<div class="replay-panel">
@* The level being watched. Its map tag (if any) shows before the name in a muted colour,
mirroring the level-select header. Hidden entirely when the level has no name. *@
@if ( !string.IsNullOrEmpty( replayLevelName ) )
{
<div class="replay-level">
@if ( replayLevelTag != "" )
{
<label class="tag">@replayLevelTag</label>
}
<label class="name">@replayLevelName</label>
</div>
}
@if ( who.SteamId != 0 )
{
<div class="submitter">
<SteamAvatar [email protected] Size=@(48) OnHover=@Tip( "Steam profile" )></SteamAvatar>
<div class="who">
<div class="nameline">
<LiteralLabel class="name" [email protected] />
@if ( FlagSheet.Has( flagCode ) )
{
@* 28x20 must match .flag's width/height in the scss *@
<div class="flag" style="@FlagSheet.Style( flagCode, 28, 20 )"
onmouseover=@Tip( () => FlagSheet.CountryName( GameManager.Instance?.ReplaySubmitterInfo.CountryCode ) )></div>
}
</div>
<label class="meta">@FormatDate( who.SubmittedAt )</label>
</div>
</div>
}
<div class="replay-view-counts">
<div class="counts-row">
@if ( mgr?.CurrentReplayViews?.Viewers is double viewers && mgr?.CurrentReplayViews?.Views is double views )
{
<div class="count" onmouseover=@Tip( "Unique Viewers" )><div class="icon">people</div><label>@viewers.ToString( "N0" )</label></div>
<div class="count" onmouseover=@Tip( "Views" )><div class="icon">visibility</div><label>@views.ToString( "N0" )</label></div>
}
@if ( mgr?.CurrentReplayReactions is { HasSnapshot: true } reactions && reactions.Entries.Count > 0 )
{
<div class="count reaction-count" onmouseover=@Tip( "Reactions" )><div class="icon">add_reaction</div><label>@reactions.Entries.Count.ToString( "N0" )</label></div>
}
</div>
</div>
<div class="title-row">
<label class="title @(hijacked ? "hijack" : "")">@(hijacked ? "● HIJACKED" : finished ? "■ FINISHED" : "▶ REPLAY")</label>
@if ( hijacked )
{
<label class="hijack-note">PRACTICE RUN</label>
}
</div>
@if ( hijacked )
{
// Live practice: the transport (speed/pause/scrub/restart) is hidden because those keys
// are now gameplay. STOP returns to normal replay, paused on the hijack frame.
<button class="ctl stop" onclick=@Click( () => StopHijack(), SfxType.MenuStart )>
<div class="icon">stop</div>
<label>STOP</label>
</button>
}
else
{
<div class="speed-head">
<label class="caption">SPEED</label>
<label class="value @(speed < 0.999f ? "slow" : speed > 1.001f ? "fast" : "")">@FormatSpeed( speed )</label>
</div>
<PixelSlider class="speed-slider" Compact=@(true) Glow=@(true) Value=@speedT Min=@(0f) Max=@(1f) Step=@(0f)
OnValueChanged=@( (float v) => OnSpeedSlider( v ) )></PixelSlider>
<div class="ends">
<label>0.1x</label>
<label>1x</label>
<label>8x</label>
</div>
<button class="ctl" onclick=@Click( () => RestartReplay() )>
<div class="icon">replay</div>
<label>RESTART</label>
</button>
<button class="ctl @(finished ? "disabled" : "")" onclick=@(() => TogglePause())
onmouseover=@Tip( () => (GameManager.Instance?.ReplayFinished ?? false)
? "Replay finished" : "Pause or resume replay" )>
<div class="icon">@(paused && !finished ? "play_arrow" : "pause")</div>
<label>@(paused && !finished ? "RESUME" : "PAUSE")</label>
</button>
<ReplayScrubBar class="timeline"></ReplayScrubBar>
<div class="clock-row">
<label class="clock">@FormatTime( mgr?.ReplayTimeSeconds ?? 0f ) / @FormatTime( mgr?.ReplayTotalSeconds ?? 0f )</label>
@if ( paused )
{
<label class="frame">Frame @(mgr?.ReplayFrame ?? 0)</label>
}
</div>
@if ( !string.IsNullOrEmpty( mgr?.CurrentReplayReactions?.Status ) )
{
<label class="reaction-status">@mgr.CurrentReplayReactions.Status</label>
}
}
</div>
@* Keep the disabled button available for its explanation after the recorded run ends. *@
@if ( !hijacked && !(mgr?.IsPlacingReaction ?? false) )
{
<button class="hijack-btn @(finished || Stage.IsGameOver ? "disabled" : "")" onclick=@(() => Hijack())
onmouseover=@Tip( () => Stage.IsGameOver || (GameManager.Instance?.ReplayFinished ?? false)
? "Run has already ended"
: "Take control of replay to practice" )>
<div class="icon">sports_esports</div>
<label>HIJACK</label>
</button>
}
@if ( mgr?.IsPlacingReaction == true )
{
<ReplayReactionPicker Stage=@Stage></ReplayReactionPicker>
}
else if ( mgr?.CanReact == true )
{
<button class="hijack-btn react-btn" onclick=@Click( () => mgr.BeginReaction(), SfxType.MenuStart )
onmouseover=@Tip( "Place your reaction at the current time" )>
<div class="icon">add_reaction</div><label>REACT</label>
</button>
}
</div>
// Per-tick input readout (bottom-left), arrow-key layout: U on top, L/D/R below. A square
// lights "on" while its direction is held; it also flashes "just" on the single tick the press
// fires (the rising edge), so frame-precise taps that drive jump/dash/wall-kick are visible.
<div class="input-display">
<div class="row top">
<div class="key @(KeyClass( InputState.Up, InputState.UpJust ))">▲</div>
</div>
<div class="row bottom">
<div class="key @(KeyClass( InputState.Left, InputState.LeftJust ))">◀</div>
<div class="key @(KeyClass( InputState.Down, InputState.DownJust ))">▼</div>
<div class="key @(KeyClass( InputState.Right, InputState.RightJust ))">▶</div>
</div>
</div>
@if ( finished && !hijackArmed && !OverlayOwnsScreen )
{
// Centred over the arena (the play square fills screen height, centred), shown when a
// replay has played out. Purely cosmetic — the transport panel still drives restart/back.
// Only for a normal (non-hijacked) finish; a hijacked practice run shows the hint below.
<div class="end-overlay">
<label class="end-text">THE END</label>
@if ( desynced )
{
// The end card shows UNDER the retro quantize (WantsQuantize stays on at replay end),
// which mushes warning-sized text — so like the tutorial popup this renders on the 240
// grid: 81px pixel font (one art pixel per cell), box + line indents snapped to whole
// cells in code. "THE END" itself is coarse enough at 200px to survive as-is.
<div class="desync-warning" style=@DesyncWarningStyle()>
@foreach ( var line in DesyncWarningLines )
{
<div class="line"><label style=@GridLineStyle( DesyncWarningLines, line )>@line</label></div>
}
</div>
}
</div>
}
@if ( finished && hijackArmed && !OverlayOwnsScreen )
{
// A hijacked practice run ended: no "THE END" card — just a hint on how to retry. Shows UNDER
// the retro quantize (WantsQuantize stays on at replay end), which mushes ordinary-sized text —
// so like the desync warning it renders on the 240 grid: 81px pixel font (one art pixel per
// cell), box + line indents snapped to whole cells in code.
<div class="end-overlay practice">
<div class="practice-hint" style=@PracticeHintStyle()>
@foreach ( var line in PracticeHintLines )
{
<div class="line"><label style=@GridLineStyle( PracticeHintLines, line )>@line</label></div>
}
</div>
</div>
}
} @* end !IsGifPreviewActive (replay HUD hidden while the GIF overlay is open) *@
}
@if ( Stage.MenuOpen )
{
@* Clicking the dimmed backdrop (anywhere outside the panel) closes the overlay; the panel
swallows its own clicks so interactions inside it never bubble up and dismiss the menu.
Mirror the BACK button's close SFX so a click-away feels the same. *@
<div class="overlay" onclick=@(() => { Audio.PlaySfx( SfxType.MenuStart ); Stage.CloseMenu(); })>
<div class="panel" onclick=@((PanelEvent e) => e.StopPropagation())>
@* Drifting blocks redrawn in UI space (the arena behind is frozen); sits behind the
labels so it mirrors the main-menu options screen's moving background. *@
<OptionsBackdrop></OptionsBackdrop>
<OptionsPanel [email protected] Dimmed=@true></OptionsPanel>
</div>
</div>
}
@* The help panel drops this row on the same branch the options overlay uses, rather than emptying it
out button by button. Dropping the WHOLE row makes it vanish on the frame the overlay opens, the
way the gear's does; deleting only the children leaves the (now icon-less) button frames drawing
for one more frame — and this row sits ABOVE the help overlay, so unlike the options overlay's
full-screen dim there's nothing to hide them behind. The row comes back INTACT for the auto-dismiss
fade: the HUD reads as normal again while the dim fades out, with the "?" pulsing (if the nudge is
still owed) to show where the help it just closed can be found. *@
else if ( !Stage.IsCharacterHelpVisible && !Stage.LeaveConfirmOpen && !(GameManager.Instance?.IsGifPreviewActive ?? false) )
{
<div class="top-buttons @(Stage.ShowCharacterHelp ? "help-open" : "")" style="width: @(RightHudWidth())px;">
@if ( Stage.IsReplay && !(GameManager.Instance?.IsHijacked ?? false) )
{
<button class="square-btn i-share hud-button" onclick=@Click( () => CopyReplayCode(), SfxType.MenuStart )
onmouseover=@Tip( "Copy replay share code to clipboard" )>
<div class="img"></div>
</button>
<button class="square-btn i-import hud-button" onclick=@Click( () => OpenReplayImport(), SfxType.MenuStart )
onmouseover=@Tip( "Watch replay from pasted share code" )>
<div class="img"></div>
</button>
<button class="square-btn i-gif hud-button" onclick=@Click( () => OpenGifExport(), SfxType.MenuStart )
onmouseover=@Tip( "Export replay section as GIF" )>
<div class="img"></div>
</button>
}
@{
// The "?" wears the character it will explain: the current form for a Mimic, and both
// twins for a Twins run — but only the worn twin when a Mimic is wearing one, since
// that's the single body its help page teaches (mirrors CharacterHelpShowsPartner).
bool helpEnabled = Stage.HelpDef is not null;
CharacterDef buttonCharacter = Stage.HelpCharacter;
CharacterDef buttonPartner = Stage.IsMimicFormHelp ? null : buttonCharacter?.Partner;
float buttonViewportScale = CharacterHelpViewportScale( buttonCharacter, buttonPartner );
}
<button class="square-btn i-help hud-button help @(helpEnabled ? (Stage.ShouldPulseHelpButton ? "pulsing" : "") : "disabled")"
onclick=@(() => { if ( Stage.HelpDef is not null ) Stage.OpenCharacterHelp(); })
onmouseover=@Tip( () => Stage?.HelpDef is not null
? $"How {Stage?.HelpTitle ?? "this character"} plays" : "This character has no help page" )>
<div class="img"></div>
@if ( buttonCharacter is not null )
{
<div class="character-icon-clip">
<div class="character-icon @(buttonPartner is null ? "single" : "paired")"
style=@HelpButtonIconStyle( buttonCharacter, buttonViewportScale )>
<div class="icon-glow"></div>
@if ( buttonPartner is not null )
{
<div class="sprite-layer rear" style=@CharacterSpriteLayerStyle( buttonPartner, HELP_BUTTON_ICON_WIDTH, HELP_BUTTON_ICON_HEIGHT, buttonViewportScale )></div>
}
<div class="sprite-layer front" style=@CharacterSpriteLayerStyle( buttonCharacter, HELP_BUTTON_ICON_WIDTH, HELP_BUTTON_ICON_HEIGHT, buttonViewportScale )></div>
</div>
</div>
}
</button>
<button class="square-btn i-gear hud-button gear" onclick=@(() => Stage.OpenMenu())
onmouseover=@Tip( "Options" )>
<div class="img"></div>
</button>
@if ( Stage.IsReplay && !(GameManager.Instance?.IsHijacked ?? false) )
{
bool saved = GameManager.Instance?.CurrentReplayIsLocal ?? false;
bool favorite = GameManager.Instance?.CurrentReplayIsFavorite ?? false;
@if ( saved )
{
<button class="square-btn @(favorite ? "i-heart" : "i-heart-outline") hud-button replay-favorite @(favorite ? "active" : "")"
onclick=@Click( () => ToggleReplayFavorite() )
onmouseover=@Tip( () => (GameManager.Instance?.CurrentReplayIsFavorite ?? false)
? "Remove from favorites" : "Mark as favorite" )>
<div class="img"></div>
</button>
<button class="square-btn i-check hud-button replay-save saved" onclick=@Click( () => DeleteSavedReplay() )
onmouseover=@Tip( "Saved to local replays — click to remove" )>
<div class="img"></div>
<label class="caption">SAVED</label>
</button>
}
else
{
<button class="square-btn i-import hud-button replay-save" onclick=@Click( () => SaveReplay(), SfxType.MenuStart )
onmouseover=@Tip( "Save to local replays" )>
<div class="img"></div>
<label class="caption">SAVE</label>
</button>
}
}
</div>
}
@if ( Stage.LeaveConfirmOpen )
{
@* Leaving a limited-attempt daily mid-run: the attempt was spent at launch, so ask first. Reuses
the options overlay's dim; a click on the dim keeps playing (the safe choice) and the dialog
swallows its own clicks. .selected mirrors keyboard/controller focus (GameStage.TickMenu). *@
<div class="overlay leave-confirm" onclick=@(() => Stage.CancelLeave())>
<div class="dialog" onclick=@((PanelEvent e) => e.StopPropagation())>
<label class="title">LEAVE DAILY RUN?</label>
@if ( Stage.LeaveConfirmAttemptsText is string attemptsText )
{
<label class="attempts">@attemptsText</label>
}
@* .controller: the focused button also gets a ring — with a pad there's no cursor to show
where focus sits, while a mouse user already sees it under the pointer. *@
<div class="buttons @(InputState.GamepadIsPrimary ? "controller" : "")">
<button class="stay @(Stage.LeaveConfirmSelection == 0 ? "selected" : "")"
onclick=@(() => Stage.CancelLeave())
onmouseover=@(() => Stage.SetLeaveConfirmSelection( 0 ))>KEEP PLAYING</button>
<button class="leave @(Stage.LeaveConfirmSelection == 1 ? "selected" : "")"
onclick=@(() => Stage.ConfirmLeave())
onmouseover=@(() => Stage.SetLeaveConfirmSelection( 1 ))>LEAVE</button>
</div>
</div>
</div>
}
@if ( Stage.ShowCharacterHelp )
{
CharacterDef helpCharacter = Stage.CharacterHelpCharacter ?? Stage.RunCharacter;
CharacterHelpDef help = Stage.CharacterHelpDef ?? helpCharacter.Help;
CharacterDef helpPartner = Stage.CharacterHelpShowsPartner ? helpCharacter.Partner : null;
float helpViewportScale = CharacterHelpViewportScale( helpCharacter, helpPartner );
<div class="character-help-overlay">
<div class="dismiss-layer @(Stage.IsCharacterHelpFading ? "fading" : "")"
onclick=@(() => Stage.DismissCharacterHelp())>
<div class="arena-backdrop" style=@ArenaSquareBackdropStyle()></div>
<div class="help-panel" onclick=@((PanelEvent e) => e.StopPropagation())>
<div class="help-header">
<div class="help-character @(helpPartner is null ? "single" : "paired")"
style=@CharacterHelpViewportStyle( helpViewportScale )>
<div class="icon-glow"></div>
@if ( helpPartner is not null )
{
<div class="sprite-layer rear" style=@CharacterHelpLayerStyle( helpPartner, helpViewportScale )></div>
}
<div class="sprite-layer front" style=@CharacterHelpLayerStyle( helpCharacter, helpViewportScale )></div>
</div>
<label class="character-name">@(Stage.CharacterHelpTitle ?? helpCharacter.Name)</label>
</div>
<div class="ability-list">
@foreach ( CharacterHelpRow row in help.Rows )
{
<div class="ability-row">
<label class="ability-name">@row.Name</label>
<div class="instruction">
@if ( row.ShowChargeJumpSequence )
{
int chargeStep = Stage.CharacterHelpAnimationStep;
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpChargeIllustrationStyle( helpCharacter, chargeStep )></div>
</div>
<label class="direction-prefix">@row.DirectionPrefix</label>
<div class="direction-keys @(row.CompactDirections ? "compact" : "") terminal">
@foreach ( Direction direction in CharacterHelpChargeDirections( chargeStep ) )
{
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
}
</div>
}
else if ( row.ShowGravityFlipSequence )
{
bool gravityFlipped = CharacterHelpGravityIsFlipped( Stage.CharacterHelpAnimationStep );
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpGravityFlipIllustrationStyle( helpCharacter, row, gravityFlipped )></div>
</div>
<div class="direction-keys terminal">
<div class="direction-key @CharacterHelpDirectionClass( gravityFlipped ? Direction.Down : Direction.Up )"></div>
</div>
}
else if ( row.ShowHorizontalDashSequence )
{
bool dashLeft = CharacterHelpAlternatingState( Stage.CharacterHelpAnimationStep );
Direction dashDirection = dashLeft ? Direction.Left : Direction.Right;
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpHorizontalDashIllustrationStyle( helpCharacter, row, dashLeft )></div>
</div>
<div class="direction-keys terminal">
<div class="direction-key @CharacterHelpDirectionClass( dashDirection )"></div>
@if ( InputState.GamepadIsPrimary )
{
<label class="button-key x">X</label>
}
else
{
<div class="direction-key @CharacterHelpDirectionClass( dashDirection )"></div>
}
</div>
}
else if ( row.ShowLongJumpSequence )
{
int longJumpStep = Stage.CharacterHelpAnimationStep % 6;
bool longJumpLeft = longJumpStep >= 3;
bool longJumpAirborne = longJumpStep % 3 == 2;
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpLongJumpIllustrationStyle(
helpCharacter, row, longJumpLeft, longJumpAirborne )></div>
</div>
@* Controller: the jump half of the chord is the A face button, not the Up key. *@
<div class="direction-keys compact terminal">
<div class="direction-key @CharacterHelpDirectionClass( longJumpLeft ? Direction.Left : Direction.Right )"></div>
<div class="direction-key down"></div>
@if ( InputState.GamepadIsPrimary )
{
<label class="button-key a">A</label>
}
else
{
<div class="direction-key up"></div>
}
</div>
}
else if ( row.ShowDashSequence )
{
int dashState = CharacterHelpThreadState( Stage.CharacterHelpAnimationStep );
IReadOnlyList<Direction> dashChord = CharacterHelpThreadChordForState( dashState );
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpDashIllustrationStyle(
helpCharacter, row, dashState )></div>
</div>
<div class="thread-controls">
@for ( int chordIndex = 0; chordIndex < CharacterHelpChordRepeats; chordIndex++ )
{
<div class="direction-keys compact terminal thread-chord">
@foreach ( Direction direction in dashChord )
{
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
}
</div>
}
@if ( InputState.GamepadIsPrimary )
{
<div class="direction-keys terminal thread-chord">
<label class="button-key x">X</label>
</div>
}
</div>
}
else if ( row.ShowWallKickSequence )
{
bool alternateSide = CharacterHelpAlternatingState( Stage.CharacterHelpAnimationStep );
Direction sideDirection = alternateSide ? Direction.Left : Direction.Right;
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpWallKickIllustrationStyle( helpCharacter, row, alternateSide )></div>
</div>
<div class="direction-keys compact terminal">
<div class="direction-key up"></div>
<div class="direction-key @CharacterHelpDirectionClass( sideDirection )"></div>
</div>
}
else if ( row.ShowTeleportSequence )
{
int teleportState = CharacterHelpTeleportState( Stage.CharacterHelpAnimationStep );
IReadOnlyList<Direction> teleportOrigin = CharacterHelpTeleportOrigin( teleportState );
IReadOnlyList<Direction> teleportForce = CharacterHelpTeleportForce( teleportState );
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpTeleportIllustrationStyle(
helpCharacter, teleportState, Stage.CharacterHelpFastAnimationStep )></div>
</div>
<div class="teleport-controls">
<div class="teleport-origin-chords">
@for ( int chordIndex = 0; chordIndex < CharacterHelpChordRepeats; chordIndex++ )
{
<div class="direction-keys terminal teleport-origin-chord @(teleportOrigin.Count > 1 ? "compact" : "")">
@foreach ( Direction direction in teleportOrigin )
{
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
}
</div>
}
@if ( InputState.GamepadIsPrimary )
{
<div class="direction-keys terminal teleport-origin-chord">
<label class="button-key x">X</label>
</div>
}
</div>
<label class="direction-separator comma">,</label>
<div class="direction-keys terminal @(teleportForce.Count > 1 ? "compact" : "")">
@foreach ( Direction direction in teleportForce )
{
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
}
</div>
</div>
}
else if ( row.ShowThreadInputSequence )
{
IReadOnlyList<Direction> threadChord = CharacterHelpThreadChord( Stage.CharacterHelpAnimationStep );
<div class="thread-controls">
@for ( int chordIndex = 0; chordIndex < CharacterHelpChordRepeats; chordIndex++ )
{
<div class="direction-keys compact terminal thread-chord">
@foreach ( Direction direction in threadChord )
{
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
}
</div>
}
@if ( InputState.GamepadIsPrimary )
{
<div class="direction-keys terminal thread-chord">
<label class="button-key x">X</label>
</div>
}
</div>
}
else if ( row.ShowPortalShotSequence )
{
IReadOnlyList<Direction> portalShotChord = CharacterHelpThreadChord( Stage.CharacterHelpAnimationStep );
<div class="row-illustration">
<div class="pose" style=@CharacterHelpRowIllustrationStyle(
helpCharacter, row, Stage.CharacterHelpAnimationStep )></div>
</div>
<div class="thread-controls">
@for ( int chordIndex = 0; chordIndex < CharacterHelpChordRepeats; chordIndex++ )
{
<div class="direction-keys compact terminal thread-chord">
@foreach ( Direction direction in portalShotChord )
{
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
}
</div>
}
@if ( InputState.GamepadIsPrimary )
{
<div class="direction-keys terminal thread-chord">
<label class="button-key x">X</label>
</div>
}
</div>
}
else if ( row.ShowGunnerShootSequence )
{
int gunnerShootState = CharacterHelpThreadState( Stage.CharacterHelpAnimationStep );
IReadOnlyList<Direction> gunnerShootChord = CharacterHelpThreadChordForState( gunnerShootState );
<div class="row-illustration">
@if ( row.IllustrationGlow )
{
<div class="icon-glow"></div>
}
<div class="pose" style=@CharacterHelpGunnerShootIllustrationStyle(
helpCharacter, gunnerShootState )></div>
</div>
<div class="thread-controls">
@for ( int chordIndex = 0; chordIndex < CharacterHelpChordRepeats; chordIndex++ )
{
<div class="direction-keys compact terminal thread-chord">
@foreach ( Direction direction in gunnerShootChord )
{
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
}
</div>
}
@if ( InputState.GamepadIsPrimary )
{
<div class="direction-keys terminal thread-chord">
<label class="button-key x">X</label>
</div>
}
</div>
}
else if ( row.ShowClimberWallSequence )
{
bool climbingUp = !CharacterHelpAlternatingState( Stage.CharacterHelpAnimationStep );
Direction wallDirection = climbingUp ? Direction.Right : Direction.Left;
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpClimberWallIllustrationStyle(
helpCharacter, climbingUp )></div>
</div>
<label class="direction-prefix">@row.DirectionPrefix</label>
<div class="direction-keys compact terminal">
<div class="direction-key up"></div>
<div class="direction-key @CharacterHelpDirectionClass( wallDirection )"></div>
</div>
}
else if ( row.ShowClimberCeilingSequence )
{
int ceilingState = CharacterHelpClimberCeilingState( Stage.CharacterHelpAnimationStep );
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpClimberCeilingIllustrationStyle(
helpCharacter, Stage.CharacterHelpAnimationStep, ceilingState )></div>
</div>
<label class="direction-prefix">@row.DirectionPrefix</label>
<div class="direction-keys @(ceilingState == 0 ? "" : "compact") terminal">
<div class="direction-key up"></div>
@if ( ceilingState > 0 )
{
<div class="direction-key @CharacterHelpDirectionClass( ceilingState == 1 ? Direction.Right : Direction.Left )"></div>
}
</div>
}
else if ( row.ShowClimberFallSequence )
{
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpClimberFallIllustrationStyle(
helpCharacter, Stage.CharacterHelpFastAnimationStep )></div>
</div>
}
else if ( row.ShowRewindExplosionSequence )
{
<div class="row-character-sprites">
<div class="row-character-preview" style=@CharacterHelpRowPreviewSizeStyle( helpCharacter )>
<div class="icon-glow"></div>
<div class="row-character-sprite" style=@CharacterHelpRowPreviewImageStyle(
"sprites/ui/characters/player_rewind/explode_1.png" )></div>
</div>
<div class="row-character-preview" style=@CharacterHelpRowPreviewSizeStyle( helpCharacter )>
<div class="icon-glow"></div>
<div class="row-character-sprite" style=@CharacterHelpRewindExplosionIllustrationStyle(
Stage.CharacterHelpFastAnimationStep )></div>
</div>
</div>
}
else if ( row.ShowShifterGravityGripSequence )
{
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpShifterIllustrationStyle(
helpCharacter, Stage.CharacterHelpFastAnimationStep )></div>
</div>
}
else if ( row.ShowTwinPhaseDashSequence )
{
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpTwinPhaseDashIllustrationStyle(
helpCharacter, Stage.CharacterHelpFastAnimationStep )></div>
</div>
}
else if ( row.ShowMimicTransformSequence )
{
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpMimicTransformIllustrationStyle(
helpCharacter, Stage.CharacterHelpFastAnimationStep )></div>
</div>
}
else if ( row.ShowMimicSquashSequence )
{
<div class="row-illustration">
<div class="icon-glow"></div>
<div class="pose" style=@CharacterHelpMimicSquashIllustrationStyle(
helpCharacter, Stage.CharacterHelpFastAnimationStep )></div>
</div>
}
else if ( !string.IsNullOrEmpty( row.IllustrationImage ) )
{
<div class="row-illustration">
@if ( row.IllustrationGlow )
{
<div class="icon-glow"></div>
}
<div class="pose @(string.IsNullOrEmpty( row.IllustrationAlternateImage ) ? "" : "alternating primary")" style=@CharacterHelpRowIllustrationStyle(
helpCharacter, row, Stage.CharacterHelpAnimationStep )></div>
@if ( !string.IsNullOrEmpty( row.IllustrationAlternateImage ) )
{
<div class="pose alternating alternate" style=@CharacterHelpRowIllustrationStyle(
helpCharacter, row, Stage.CharacterHelpAnimationStep, alternate: true )></div>
}
</div>
}
@if ( !row.ShowChargeJumpSequence && !row.ShowGravityFlipSequence && !row.ShowHorizontalDashSequence && !row.ShowWallKickSequence && !row.ShowTeleportSequence && !row.ShowThreadInputSequence && !row.ShowPortalShotSequence && !row.ShowGunnerShootSequence && !row.ShowClimberWallSequence && !row.ShowClimberCeilingSequence && !row.ShowClimberFallSequence && !string.IsNullOrEmpty( row.DirectionPrefix ) )
{
<label class="direction-prefix">@row.DirectionPrefix</label>
}
@if ( !row.ShowChargeJumpSequence && !row.ShowGravityFlipSequence && !row.ShowHorizontalDashSequence && !row.ShowWallKickSequence && !row.ShowTeleportSequence && !row.ShowThreadInputSequence && !row.ShowPortalShotSequence && !row.ShowGunnerShootSequence && !row.ShowClimberWallSequence && !row.ShowClimberCeilingSequence && !row.ShowClimberFallSequence && row.Directions.Count > 0 )
{
@* Controller glyph swaps: an all-four-directions row (Swapper's portal swap and
friends) becomes the single Y face button; a double-tap row written as two
identical directions becomes one arrow + the X face button. *@
@if ( InputState.GamepadIsPrimary && CharacterHelpIsAllFourDirections( row.Directions ) )
{
<div class="direction-keys @(CharacterHelpDirectionsAreTerminal( row ) ? "terminal" : "")">
<label class="button-key y">Y</label>
</div>
}
else if ( InputState.GamepadIsPrimary && CharacterHelpIsDoubleTap( row ) )
{
<div class="direction-keys @(CharacterHelpDirectionsAreTerminal( row ) ? "terminal" : "")">
<div class="direction-key @CharacterHelpDirectionClass( row.Directions[0] )"></div>
<label class="button-key x">X</label>
</div>
}
else
{
<div class="direction-keys @(row.CompactDirections ? "compact" : "") @(CharacterHelpDirectionsAreTerminal( row ) ? "terminal" : "")">
@for ( int directionIndex = 0; directionIndex < row.Directions.Count; directionIndex++ )
{
Direction direction = row.Directions[directionIndex];
<div class="direction-key @CharacterHelpDirectionClass( direction )"></div>
@if ( directionIndex == row.DirectionSeparatorAfterIndex && !string.IsNullOrEmpty( row.DirectionSeparator ) )
{
<label class="direction-separator @(row.DirectionSeparator == "," ? "comma" : "")">@row.DirectionSeparator</label>
}
}
</div>
}
}
@{ var gestureNote = CharacterHelpGestureNote( row ); }
@if ( !string.IsNullOrEmpty( gestureNote.Action ) )
{
<div class="gesture-note">
<label>@gestureNote.Text</label>
<label class="gesture-alternative">(or</label>
<Image class="gesture-glyph" [email protected]( Input.GetButtonOrigin( gestureNote.Action, true ), InputGlyphSize.Small, true ) />
<label>)</label>
@if ( row.ShowTeleportSequence )
{
<label>, then tap again</label>
}
</div>
}
@if ( row.CharacterPreviewCount > 0 || row.CharacterPreviewImages.Count > 0 )
{
<div class="row-character-sprites @(row.CompactCharacterPreviews ? "compact" : "") @(row.OverlapCharacterPreviews ? "overlap" : "")">
@for ( int previewIndex = 0; previewIndex < row.CharacterPreviewCount; previewIndex++ )
{
<div class="row-character-preview" style=@CharacterHelpRowPreviewSizeStyle( helpCharacter )>
<div class="icon-glow"></div>
<div class="row-character-sprite" style=@CharacterHelpRowPreviewImageStyle( helpCharacter.PreviewImage )></div>
</div>
}
@for ( int imageIndex = 0; imageIndex < row.CharacterPreviewImages.Count; imageIndex++ )
{
string previewImage = row.CharacterPreviewImages[imageIndex];
<div class="row-character-preview" style=@CharacterHelpRowPreviewSizeStyle( helpCharacter )>
<div class="icon-glow"></div>
<div class="row-character-sprite" style=@CharacterHelpRowPreviewImageStyle(
previewImage, CharacterHelpRowPreviewOpacity( row, imageIndex ),
CharacterHelpRowPreviewYOffset( row, imageIndex ) )></div>
@if ( imageIndex == row.CharacterPreviewIndicatorIndex || imageIndex == row.CharacterPreviewAlternateIndicatorIndex )
{
<div class="control-marker @CharacterHelpControlMarkerClass( row, imageIndex )">
<div class="bar"></div>
<div class="tip"></div>
</div>
}
</div>
}
</div>
}
@if ( !string.IsNullOrEmpty( row.Text ) )
{
<label class="hint @(row.ExtraTextTopPadding ? "extra-top-padding" : "")">@row.Text</label>
}
</div>
</div>
}
</div>
</div>
</div>
</div>
}
@* Hold-to-restart overlay: during an ACTIVE live run R must be held for a beat (post-death R stays
instant — see GameManager's hold-to-restart block). The game keeps simulating underneath; this
just dims the arena square and fills a small progress bar. Both fade off RestartHoldAlpha, driven
per-frame by the manager, so a release fades back out from exactly the current level. Hidden
while a sim-freezing overlay owns the screen (the frozen mid-fade dim would sit over the menu). *@
@{ float restartHoldAlpha = GameManager.Instance?.RestartHoldAlpha ?? 0f; }
@if ( restartHoldAlpha > 0f && !OverlayOwnsScreen )
{
float restartHoldProgress = GameManager.Instance?.RestartHoldProgress ?? 0f;
<div class="restart-hold-overlay" style=@RestartHoldOverlayStyle( restartHoldAlpha )>
<div class="dim" style=@ArenaSquareBackdropStyle()></div>
<label class="caption" style=@RestartHoldTextStyle()>@HOLD_TEXT</label>
<div class="bar" style=@RestartHoldBarStyle()>
<div class="inner" style=@RestartHoldInnerStyle()>
<div class="fill" style=@RestartHoldFillStyle( restartHoldProgress )></div>
</div>
</div>
</div>
}
@* Restart button, sitting immediately left of the home button. Offered only after the player has
died — in a live run (daily only while attempts remain), or in a hijacked practice run, where it
mirrors R's retry-from-the-takeover-point (R works any time during a hijack; the button appears on
death purely as a visual reminder). The icon is a counter-clockwise "replay" arrow with an R inside. *@
@if ( (Stage.ShowRestartButton || Stage.ShowHijackRestartButton) && !OverlayOwnsScreen && !(GameManager.Instance?.IsGifPreviewActive ?? false) )
{
<button class="square-btn i-restart restart-button" onclick=@Click( () => RestartRun(), SfxType.MenuStart )
onmouseover=@Tip( () => Stage?.ShowHijackRestartButton == true
? "Retry from hijack point (R)"
: "Restart (R)" )>
<div class="img"></div>
<div class="letter">R</div>
</button>
}
@* Bottom-right square button that abandons the current run/replay and returns to the title
screen. Shown during live play and while watching a replay; hidden while an in-game overlay
(options / character help) or the GIF-export overlay owns the screen. *@
@if ( !OverlayOwnsScreen && !(GameManager.Instance?.IsGifPreviewActive ?? false) )
{
<button class="square-btn i-home quit-button" onclick=@Click( () => QuitToMenu(), SfxType.MenuStart )
onmouseover=@Tip( () => Stage?.IsReplay == true
? "Return to main menu"
: "Return to main menu" )>
<div class="img"></div>
</button>
}
@* Square back button, immediately left of the home button. Returns to the screen that launched the
run/replay (level editor, daily hub, or level-select map) rather than all the way to the title. In
a replay it replaces the old transport BACK control. Shown/hidden on the home-button gate. *@
@if ( !OverlayOwnsScreen && !(GameManager.Instance?.IsGifPreviewActive ?? false) )
{
<button class="square-btn i-back back-button" onclick=@Click( () => BackToMenu(), SfxType.MenuStart )
onmouseover=@Tip( "Back (Backspace)" )>
<div class="img"></div>
</button>
}
@* Square reload-characters button in the bottom-right of the LEFT letterbox: reloads character
art/tuning and the level map without restarting. Shown only while running in the editor and not
in replays. In a test run it sits above the exit button (the "above-exit" spot); otherwise the
corner is free so it drops to the very bottom. *@
@if ( Game.IsEditor && !Stage.IsReplay && !OverlayOwnsScreen && !(GameManager.Instance?.IsGifPreviewActive ?? false) )
{
<button class="square-btn i-reload reload-characters-button @(Stage.IsTest ? "above-exit" : "")"
onclick=@Click( () => { Sandbox.ConsoleSystem.Run( "reload_characters" ); Sandbox.ConsoleSystem.Run( "reload_map" ); }, SfxType.MenuStart )
onmouseover=@Tip( "Re-read the character defs and level map from disk" )>
<div class="img"></div>
</button>
}
@if ( _showReplayImport )
{
<ReplayCodeDialog Title="WATCH REPLAY CODE" ConfirmLabel="WATCH" Error=@_replayImportError
OnConfirm=@((string code) => ConfirmReplayImport( code )) OnCancel=@CloseReplayImport />
}
@if ( !Stage.ShowCharacterHelp && Stage.ShowTutorialPrompt )
{
@* No click-to-dismiss: the mouse button is just another hold source — press-and-hold anywhere
on the popup/backdrop fills the same bar as the keys (released = snaps empty). *@
<div class="tutorial-instruction-dismiss @(Stage.IsTutorialPromptDismissing ? "dismissing" : "")"
onmousedown=@(() => Stage.SetTutorialPromptPointerHeld( true ))
onmouseup=@(() => Stage.SetTutorialPromptPointerHeld( false ))>
<div class="backdrop" style=@ArenaSquareBackdropStyle()></div>
<div class="hold-bar" style=@TutorialHoldBarStyle()>
<div class="inner" style=@TutorialHoldInnerStyle()>
<div class="fill" style=@TutorialHoldFillStyle()></div>
</div>
</div>
<div class="tutorial-instruction" style=@TutorialPopupStyle()>
@foreach ( var line in (Stage.TutorialPromptText ?? "").Split( '\n' ) )
{
<div class="line"><label style=@TutorialLineStyle( line )>@line</label></div>
}
</div>
</div>
}
@if ( Stage.IsReplay )
{
<ReplayReactionOverlay Stage=@Stage></ReplayReactionOverlay>
}
</root>
@code
{
public GameStage Stage { get; set; }
private bool _showReplayImport;
private string _replayImportError = "";
// True while a full-screen in-game overlay owns the screen and input — the options menu or the
// character-help overlay. Both freeze the sim (GameStage.BlocksSimulation) and swallow clicks, so
// every bit of HUD chrome that would otherwise sit there visible-but-dead hides for EITHER of them
// rather than just for the options menu. Two deliberate exceptions, both matching what the options
// overlay already does: the left-letterbox replay transport dock and the input read-out stay put
// (they're status, not controls), and the help overlay keeps its own "?" button up top so the
// auto-dismiss flash has something to point at.
private bool OverlayOwnsScreen => (Stage?.MenuOpen ?? false) || (Stage?.ShowCharacterHelp ?? false) || (Stage?.LeaveConfirmOpen ?? false);
// Wraps a button action with a UI click sound so every HUD button gives audible feedback. Returns
// the wrapped Action so it drops straight into onclick=@Click(...). Small controls use the short
// MenuBlip (default); primary/confirm actions pass MenuStart. Full volume — these HUD buttons are
// intentionally as loud as the options overlay's open sound (quieter 0.7 felt inaudible in-run).
private static System.Action Click( System.Action a, SfxType sfx = SfxType.MenuBlip )
=> () => { Audio.PlaySfx( sfx ); a?.Invoke(); };
// ── hover tooltips (same mechanism as LevelEditorHud) ────────────────────────────────────────────
// Controls carry onmouseover=@Tip( "..." ), which records the control and its text; the per-frame
// poll below just validates the stored panel is still hovered, so no mouseout handler is needed.
private PixelTooltip Tooltip { get; set; }
private Panel _tipPanel;
private System.Func<string> _tipText;
/// <summary>Fixed tooltip text.</summary>
private System.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 instead
/// of snapshotted on hover: mouseover only fires when the hovered panel changes, so clicking a
/// hovered toggle (the favourite heart) re-attaches this handler without re-running it and a
/// snapshot would keep describing the state just left. Any non-literal tip belongs here — and it must
/// read live state, never a build-time local from the markup, or the capture reintroduces the same
/// staleness.</summary>
private System.Action<PanelEvent> Tip( System.Func<string> text )
=> e => { _tipPanel = e.This; _tipText = text; };
protected override void OnUpdate()
{
GameManager.Instance?.CurrentReplayReactions?.TickStatus();
string text = _tipPanel.IsValid() && _tipPanel.HasHovered ? _tipText?.Invoke() : null;
if ( string.IsNullOrEmpty( text ) ) Tooltip?.Hide();
else Tooltip?.Show( text );
}
private void OnSpeedSlider( float t ) => GameManager.Instance?.SetReplaySpeedSlider( t );
private void RestartReplay() => GameManager.Instance?.RestartReplay();
private void TogglePause()
{
var manager = GameManager.Instance;
if ( manager is null || manager.ReplayFinished || manager.IsTransitioning || manager.IsHijacked ) return;
Audio.PlaySfx( SfxType.MenuBlip );
manager.ToggleReplayPause();
}
// Request* rather than the direct leaves: a live, limited-attempt daily run gets the leave-confirm
// prompt first (GameStage.TryOpenLeaveConfirm); everything else leaves at once as before.
private void QuitToMenu() => GameManager.Instance?.RequestQuitToMenu();
private void BackToMenu() => GameManager.Instance?.RequestBackToMenu();
// The restart button doubles for hijacked practice runs, where it mirrors R (retry from the
// takeover point) instead of the live-run restart.
private void RestartRun()
{
var mgr = GameManager.Instance;
if ( Stage?.ShowHijackRestartButton == true ) mgr?.RestartHijack();
else mgr?.RestartRun();
}
private void Hijack()
{
var manager = GameManager.Instance;
if ( manager?.CanHijack != true || Stage.IsGameOver || manager.IsTransitioning ) return;
Audio.PlaySfx( SfxType.MenuStart );
manager.HijackReplay();
}
private void StopHijack() => GameManager.Instance?.StopHijack();
private void CopyReplayCode() => GameManager.Instance?.CopyCurrentReplayCode();
private void SaveReplay() => GameManager.Instance?.SaveCurrentReplay();
private void DeleteSavedReplay() => GameManager.Instance?.DeleteCurrentReplayLocal();
private void ToggleReplayFavorite() => GameManager.Instance?.ToggleCurrentReplayFavorite();
private void OpenReplayImport()
{
_showReplayImport = true;
_replayImportError = "";
// Fade the retro quantize out while the dialog is up (see GameStage.WantsQuantize) — a pasted
// code is small text that turns to mush on the 240 grid.
if ( Stage is not null )
Stage.ReplayImportOpen = true;
if ( GameManager.Instance is { ReplayPaused: false } mgr )
mgr.ToggleReplayPause();
StateHasChanged();
}
private void CloseReplayImport()
{
_showReplayImport = false;
_replayImportError = "";
if ( Stage is not null )
Stage.ReplayImportOpen = false;
StateHasChanged();
}
// Open the GIF-export overlay: shrink the replay into the top of the screen (paused) so the overlay's
// controls fit below it. The overlay itself is a persistent GameManager-owned panel that shows/hides
// off IsGifPreviewActive; it closes itself (EndGifPreview) via its own Cancel/Done buttons.
private void OpenGifExport()
{
GameManager.Instance?.BeginGifPreview();
StateHasChanged();
}
private void ConfirmReplayImport( string code )
{
if ( GameManager.Instance?.TryStartReplayCode( code ) == true )
{
CloseReplayImport();
return;
}
_replayImportError = "INVALID REPLAY CODE";
StateHasChanged();
}
// Quantise the continuously-changing replay state so the HUD repaints smoothly without rebuilding
// every single frame.
protected override int BuildHash()
{
var mgr = GameManager.Instance;
bool replay = Stage?.IsReplay ?? false;
int progress = replay ? (int)((mgr?.ReplayProgress ?? 0f) * 500f) : 0;
int speedT = replay ? (int)((mgr?.ReplaySpeedT ?? 0.5f) * 500f) : 0;
bool paused = replay && (mgr?.ReplayPaused ?? false);
bool finished = replay && (mgr?.ReplayFinished ?? false);
bool desynced = replay && (mgr?.ReplayDesynced ?? false);
bool scrubbing = replay && (mgr?.IsScrubbingTimeline ?? false);
bool hijacked = replay && (mgr?.IsHijacked ?? false);
bool hijackArmed = replay && (mgr?.HijackArmed ?? false);
bool saved = replay && (mgr?.CurrentReplayIsLocal ?? false);
bool favorite = replay && (mgr?.CurrentReplayIsFavorite ?? false);
// Hijack disables once the recorded run enters its game-over beat — repaint at that point.
bool gameOver = replay && (Stage?.IsGameOver ?? false);
long submitter = replay ? (mgr?.ReplaySubmitterInfo.SteamId ?? 0L) : 0L;
// While paused we show (and single-step) the exact frame, so fold it in to repaint every tick.
// Left out during normal playback so the quantised `progress` bucketing keeps fast playback cheap.
int frame = (replay && paused) ? (mgr?.ReplayFrame ?? 0) : 0;
// Held bits in the low nibble, the single-tick "just" edges in the high nibble, so a one-frame
// edge flash always changes the hash and repaints (it would otherwise share a bucket with held).
int input = replay
? ((InputState.Left ? 1 : 0) | (InputState.Right ? 2 : 0) | (InputState.Up ? 4 : 0) | (InputState.Down ? 8 : 0)
| (InputState.LeftJust ? 16 : 0) | (InputState.RightJust ? 32 : 0) | (InputState.UpJust ? 64 : 0) | (InputState.DownJust ? 128 : 0))
: 0;
bool showTimer = !replay && Settings.Current.ShowTimer;
bool liveGameOver = showTimer && (Stage?.IsGameOver ?? false);
int runSeconds = showTimer ? (int)(Stage?.GameTime ?? 0f) : 0;
// Several pieces position/size themselves from the screen aspect in code: the always-visible
// top-buttons row (RightHudWidth) plus the tutorial popup, character-help overlay, desync
// warning and practice retry hint (TutorialPopupStyle / ArenaSquareBackdropStyle /
// GridBoxStyle). Fold the aspect in UNCONDITIONALLY or a window resize leaves them pinned to
// the old letterbox edge (it used to be gated to the overlays, leaving the button row stale in
// a plain live run). Constant outside a resize, so it costs no extra rebuilds.
int screenAspect = (int)(Screen.Width * 1000f / Screen.Height) * 37;
// Hold-to-restart overlay: alpha and fill animate continuously while R is held (and through the
// release fade-out), so quantise both in to repaint across the whole gesture.
int restartHold = (int)((mgr?.RestartHoldAlpha ?? 0f) * 255f) * 256 + (int)((mgr?.RestartHoldProgress ?? 0f) * 255f);
// Tutorial popup hold-to-dismiss bar: the fill animates while a key is held, so quantise it in.
int tutorialHold = (Stage?.ShowTutorialPrompt ?? false) ? (int)(Stage.TutorialPromptHoldProgress * 255f) : 0;
// A Mimic run's "?" button follows the current form (Stage.HelpCharacter), so fold its identity in
// or the button's enabled/disabled state and tooltip stay on whoever the player was last repaint.
int helpCharacterId = (Stage?.HelpCharacter?.Id ?? "").GetHashCode() * 33461;
// The character-help overlay swaps keyboard patterns for controller face-button glyphs off
// this flag, so a device change must repaint.
bool usingController = InputState.GamepadIsPrimary;
// Async counts must repaint even when playback is paused or finished.
input = System.HashCode.Combine( input, replay ? (mgr?.CurrentReplayViews?.Revision ?? 0) : 0,
mgr?.CanReact ?? false, mgr?.CurrentReplayReactions?.Revision ?? 0, mgr?.IsPlacingReaction ?? false );
return System.HashCode.Combine( Stage?.MenuOpen ?? false, replay, progress, speedT, paused, finished, submitter, input ) ^ ((Stage?.ShowRestartButton ?? false) ? 1607 : 0) ^ ((Stage?.ShowHijackRestartButton ?? false) ? 26339 : 0) ^ (desynced ? 7919 : 0) ^ frame ^ (scrubbing ? 6271 : 0) ^ (hijacked ? 5503 : 0) ^ (hijackArmed ? 4099 : 0) ^ (gameOver ? 2333 : 0) ^ (_showReplayImport ? 31337 : 0) ^ _replayImportError.GetHashCode() ^ ((mgr?.IsGifPreviewActive ?? false) ? 24007 : 0) ^ (showTimer ? 18191 : 0) ^ (liveGameOver ? 19441 : 0) ^ runSeconds ^ LocalReplays.Version ^ (saved ? 10103 : 0) ^ (favorite ? 12011 : 0) ^ ((Stage?.TutorialPromptRevision ?? 0) * 28657) ^ ((Stage?.CharacterHelpRevision ?? 0) * 30047) ^ ((Stage?.CharacterHelpFastAnimationStep ?? 0) * 30763) ^ screenAspect ^ (restartHold * 49157) ^ (tutorialHold * 52667) ^ helpCharacterId ^ (usingController ? 36373 : 0) ^ ((Stage?.LeaveConfirmRevision ?? 0) * 40009);
}
// The tutorial popup shows DURING live gameplay, i.e. under the retro shader's 240-grid quantize
// (unlike every other popup/menu, which fades the quantize out — see StageBase.WantsQuantize). So
// it must be pixel perfect on the virtual grid or its text turns to mush: every metric is a whole
// number of grid cells (1 cell = 4.5px in this 1080-ref space) and the box is positioned in code
// on exact cell boundaries — flex centring would land it on arbitrary sub-cell offsets. At 81px,
// m6x11plus (64 font-units per art pixel, 1152/em) renders each art pixel as exactly one cell.
private const int POPUP_BORDER_CELLS = 2; // keep in sync with .tutorial-instruction (9px border)
private const int POPUP_PAD_H_CELLS = 4; // 18px horizontal padding
private const int POPUP_PAD_V_CELLS = 2; // 9px vertical padding
private const int POPUP_LINE_CELLS = 18; // the 81px em: one line box = 18 cells
// Hold-to-dismiss bar floating ABOVE the popup box (not inside its border): frame/trough/fill
// built exactly like the hold-to-restart bar, same 72x10-cell proportions, centred on the grid.
private const int POPUP_BAR_W_CELLS = 72; // outer frame width, incl. the 1-cell frame
private const int POPUP_BAR_H_CELLS = 10;
private const int POPUP_BAR_FRAME_CELLS = 1;
private const int POPUP_BAR_GAP_CELLS = 3; // gap between the bar's bottom and the popup's top edge
// m6x11plus advance widths in art pixels (= grid cells at 81px), transcribed from the font's hmtx
// table. Anything outside these groups (letters, digits, most symbols) advances 7.
private static int GlyphCells( char c ) => c switch
{
'!' or '\'' or ',' or '.' or ':' or ';' or 'i' or 'l' or '|' => 3,
' ' or '(' or ')' or '[' or ']' or 'j' => 5,
'"' or '*' or '<' or '>' or '{' or '}' => 6,
'#' or 'Q' or 'X' or 'x' => 8,
'M' or 'W' or 'm' or 'w' => 9,
'&' or '@' => 10,
_ => 7,
};
private const float CELL = 4.5f; // 1080-ref px per grid cell (1080 / 240)
private static int TextCells( string s )
{
int w = 0;
foreach ( char c in s ) w += GlyphCells( c );
return w;
}
// The prompt renders one <label> per '\n'-separated line, each its own 18-cell line box.
private string[] TutorialPromptLines => (Stage?.TutorialPromptText ?? "").Split( '\n' );
// Widest prompt line in cells — the popup box, the per-line indents and the hold bar all key off it.
private int TutorialPopupTextCells()
{
int textCells = 0;
foreach ( var line in TutorialPromptLines )
textCells = System.Math.Max( textCells, TextCells( line ) );
return textCells;
}
// The popup box's height and top row on the 240 grid — shared by TutorialPopupStyle and the hold
// bar hanging above it.
private int TutorialPopupHeightCells() => POPUP_LINE_CELLS * TutorialPromptLines.Length
+ 2 * (POPUP_PAD_V_CELLS + POPUP_BORDER_CELLS);
private int TutorialPopupTopCells() => (240 - TutorialPopupHeightCells()) / 2;
// Absolute position + size for the tutorial popup, snapped to the 240 grid. The grid's rows span
// the full screen height (240 of them) and its columns are anchored to the centred arena square
// (see RetroArcadePostProcess.ComputeRegion), so cell (0,0) sits at the square's top-left. The
// box fits the WIDEST line; TutorialLineStyle centres the shorter ones inside it.
private string TutorialPopupStyle()
{
int wCells = TutorialPopupTextCells() + 2 * (POPUP_PAD_H_CELLS + POPUP_BORDER_CELLS);
int hCells = TutorialPopupHeightCells();
int leftCells = (240 - wCells) / 2;
int topCells = TutorialPopupTopCells();
float squareLeft = (1080f * (Screen.Width / (float)Screen.Height) - 1080f) * 0.5f;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"left:{squareLeft + leftCells * CELL:0.###}px;top:{topCells * CELL:0.###}px;width:{wCells * CELL:0.###}px;height:{hCells * CELL:0.###}px;" );
}
// The hold bar floats above the popup box, centred on the grid — every rect placed in absolute
// px like the restart bar (no flex, no CSS borders), so nothing can drift off whole cells.
private string TutorialHoldBarStyle()
{
int leftCells = (240 - POPUP_BAR_W_CELLS) / 2;
int topCells = TutorialPopupTopCells() - POPUP_BAR_GAP_CELLS - POPUP_BAR_H_CELLS;
float squareLeft = (1080f * (Screen.Width / (float)Screen.Height) - 1080f) * 0.5f;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"left:{squareLeft + leftCells * CELL:0.###}px;top:{topCells * CELL:0.###}px;width:{POPUP_BAR_W_CELLS * CELL:0.###}px;height:{POPUP_BAR_H_CELLS * CELL:0.###}px;" );
}
private static string TutorialHoldInnerStyle()
=> string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"left:{POPUP_BAR_FRAME_CELLS * CELL:0.###}px;top:{POPUP_BAR_FRAME_CELLS * CELL:0.###}px;"
+ $"width:{(POPUP_BAR_W_CELLS - 2 * POPUP_BAR_FRAME_CELLS) * CELL:0.###}px;height:{(POPUP_BAR_H_CELLS - 2 * POPUP_BAR_FRAME_CELLS) * CELL:0.###}px;" );
// Like the restart bar's fill: snapped to whole cells so it ticks up chunkily under the quantize.
private string TutorialHoldFillStyle()
{
int innerCells = POPUP_BAR_W_CELLS - 2 * POPUP_BAR_FRAME_CELLS;
int fillCells = (int)MathF.Round( Math.Clamp( Stage?.TutorialPromptHoldProgress ?? 0f, 0f, 1f ) * innerCells );
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"width:{fillCells * CELL:0.###}px;" );
}
// Shared by the tutorial popup and the character-help overlay: the dim behind either covers ONLY
// the 240px game screen (the centred arena square), not the letterbox — same square-left maths as
// TutorialPopupStyle, so its edges land exactly on the square. That matters most for the tutorial,
// which shows UNDER the retro quantize (see StageBase.WantsQuantize): the square is on a grid-cell
// boundary by construction, and a mid-cell edge would quantize into a half-dimmed column.
private static string ArenaSquareBackdropStyle()
{
float squareLeft = (1080f * (Screen.Width / (float)Screen.Height) - 1080f) * 0.5f;
return string.Create( System.Globalization.CultureInfo.InvariantCulture, $"left:{squareLeft:0.###}px;" );
}
// --- hold-to-restart overlay -----------------------------------------------------------
// Shows DURING live gameplay, i.e. under the retro 240-grid quantize — so like the tutorial popup
// the bar's geometry is snapped onto whole grid cells in code (flex centring would land it between
// cells and quantize its edges into half-dim rows). Frame / trough / fill are three nested plain
// rects (no CSS borders), each placed in absolute px, so there's no box-model ambiguity either.
private const int HOLD_BAR_W_CELLS = 72; // outer frame width, incl. the 1-cell frame
private const int HOLD_BAR_H_CELLS = 10;
private const int HOLD_BAR_FRAME_CELLS = 1;
private const int HOLD_BAR_TOP_CELLS = 150; // lower-centre of the square, clear of the player's eyeline
// Caption above the bar; fades with the overlay. Like the tutorial popup it renders under the
// quantize, so it's the 81px grid-em pixel font (one art pixel per cell) placed on whole cells —
// any other size or a flex-centred offset would mush the glyphs.
private const string HOLD_TEXT = "RESTARTING...";
private const int HOLD_TEXT_TOP_CELLS = HOLD_BAR_TOP_CELLS - POPUP_LINE_CELLS - 1; // one line + a 1-cell gap above the bar
private string RestartHoldTextStyle()
{
int leftCells = (240 - TextCells( HOLD_TEXT )) / 2;
float squareLeft = (1080f * (Screen.Width / (float)Screen.Height) - 1080f) * 0.5f;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"left:{squareLeft + leftCells * CELL:0.###}px;top:{HOLD_TEXT_TOP_CELLS * CELL:0.###}px;" );
}
// The dim and the bar fade together off the manager-driven alpha (no CSS animation, so a release
// mid-fade reverses from exactly where it was).
private static string RestartHoldOverlayStyle( float alpha )
=> string.Create( System.Globalization.CultureInfo.InvariantCulture, $"opacity:{alpha:0.###};" );
private static string RestartHoldBarStyle()
{
int leftCells = (240 - HOLD_BAR_W_CELLS) / 2;
float squareLeft = (1080f * (Screen.Width / (float)Screen.Height) - 1080f) * 0.5f;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"left:{squareLeft + leftCells * CELL:0.###}px;top:{HOLD_BAR_TOP_CELLS * CELL:0.###}px;width:{HOLD_BAR_W_CELLS * CELL:0.###}px;height:{HOLD_BAR_H_CELLS * CELL:0.###}px;" );
}
private static string RestartHoldInnerStyle()
=> string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"left:{HOLD_BAR_FRAME_CELLS * CELL:0.###}px;top:{HOLD_BAR_FRAME_CELLS * CELL:0.###}px;"
+ $"width:{(HOLD_BAR_W_CELLS - 2 * HOLD_BAR_FRAME_CELLS) * CELL:0.###}px;height:{(HOLD_BAR_H_CELLS - 2 * HOLD_BAR_FRAME_CELLS) * CELL:0.###}px;" );
// The fill snaps to whole cells too, so it ticks up chunkily instead of smearing under the quantize.
private static string RestartHoldFillStyle( float progress )
{
int innerCells = HOLD_BAR_W_CELLS - 2 * HOLD_BAR_FRAME_CELLS;
int fillCells = (int)MathF.Round( Math.Clamp( progress, 0f, 1f ) * innerCells );
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"width:{fillCells * CELL:0.###}px;" );
}
// Centre each line inside the box on a WHOLE-cell offset — text-align centring would halve odd
// width differences onto half-cell offsets and mush that line's glyphs.
private static string GridLineStyle( string[] lines, string line )
{
int maxCells = 0;
foreach ( var l in lines )
maxCells = System.Math.Max( maxCells, TextCells( l ) );
int indentCells = (maxCells - TextCells( line )) / 2;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"margin-left:{indentCells * CELL:0.###}px;" );
}
private string TutorialLineStyle( string line ) => GridLineStyle( TutorialPromptLines, line );
private static readonly string[] DesyncWarningLines = { "REPLAY DESYNCED" };
// Row the desync warning starts on: just below "THE END" (200px em centred on row 120 spans
// roughly rows 98–142).
private const int DESYNC_TOP_CELLS = 150;
// Retry hint for a finished hijacked practice run, vertically centred on the grid (no end card
// above it, unlike the desync warning).
private static readonly string[] PracticeHintLines = { "PRESS SPACE TO RETRY" };
// Same grid-snapping as TutorialPopupStyle, minus border/padding: box fits the widest line,
// centred horizontally on whole cells, top pinned to the given row. Shared by the desync warning
// and the practice retry hint — both render under the end-of-replay quantize.
private static string GridBoxStyle( string[] lines, int topCells )
{
int wCells = 0;
foreach ( var line in lines )
wCells = System.Math.Max( wCells, TextCells( line ) );
int hCells = POPUP_LINE_CELLS * lines.Length;
int leftCells = (240 - wCells) / 2;
float squareLeft = (1080f * (Screen.Width / (float)Screen.Height) - 1080f) * 0.5f;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"left:{squareLeft + leftCells * CELL:0.###}px;top:{topCells * CELL:0.###}px;width:{wCells * CELL:0.###}px;height:{hCells * CELL:0.###}px;" );
}
private string DesyncWarningStyle() => GridBoxStyle( DesyncWarningLines, DESYNC_TOP_CELLS );
private string PracticeHintStyle()
=> GridBoxStyle( PracticeHintLines, (240 - POPUP_LINE_CELLS * PracticeHintLines.Length) / 2 );
// Class for an input-display key: "on" while held (or its sub-tick edge), plus "just" on the exact
// tick the rising edge fires. Because on = held||just, a just-press is always also "on".
private static string KeyClass( bool held, bool just )
{
if ( just ) return "on just";
return held ? "on" : "";
}
private static string FormatSpeed( float speed )
{
// Whole multipliers render as "3x"; fractional as "0.25x".
return speed == System.MathF.Floor( speed ) ? $"{speed:0}x" : $"{speed:0.##}x";
}
private static string FormatTime( float seconds )
{
int total = (int)seconds;
return $"{total / 60}:{total % 60:00}";
}
private static string RightHudWidth()
{
float uiW = 1080f * (Screen.Width / (float)Screen.Height);
float rightLetterbox = MathF.Max( 0f, (uiW - 1080f) * 0.5f );
float width = MathF.Max( 72f, rightLetterbox - 80f );
return width.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );
}
private const float HELP_ICON_WIDTH = 84f;
private const float HELP_ICON_HEIGHT = 92f;
private const float HELP_ROW_ICON_WIDTH = 54f;
private const float HELP_ROW_ICON_HEIGHT = 60f;
// The letterbox "?" button's corner icon: 4x the baseline 10x12 art so the common case lands
// on whole pixels; larger characters scale up from the button's lower-left anchor.
private const float HELP_BUTTON_ICON_WIDTH = 40f;
private const float HELP_BUTTON_ICON_HEIGHT = 48f;
private const float HELP_BUTTON_ICON_INSET = 0f;
// Viewport size plus its corner anchor, nudged by the character's HelpButtonIconOffset (+y down,
// so it SUBTRACTS from the bottom inset). Always emits left/bottom so reused panels never keep a
// previous character's anchor.
private static string HelpButtonIconStyle( CharacterDef character, float viewportScale )
{
Vector2 offset = character.HelpButtonIconOffset;
return CharacterSpriteViewportStyle( HELP_BUTTON_ICON_WIDTH, HELP_BUTTON_ICON_HEIGHT, viewportScale )
+ $"left:{HelpPx( HELP_BUTTON_ICON_INSET + offset.x )};bottom:{HelpPx( HELP_BUTTON_ICON_INSET + offset.y )};";
}
private static float CharacterHelpViewportScale( CharacterDef character, CharacterDef partner )
=> partner is not null
? MathF.Max( character.PreviewScale, partner.PreviewScale )
: character?.PreviewScale ?? 1f;
private static string CharacterHelpViewportStyle( float viewportScale )
=> CharacterSpriteViewportStyle( HELP_ICON_WIDTH, HELP_ICON_HEIGHT, viewportScale );
private static string CharacterHelpLayerStyle( CharacterDef character, float viewportScale )
=> CharacterSpriteLayerStyle( character, HELP_ICON_WIDTH, HELP_ICON_HEIGHT, viewportScale );
// Glow-behind-sprite stack (same structure as the character picker): a viewport sized for the
// largest body shown, each sprite layer centred in it at its own PreviewScale.
private static string CharacterSpriteViewportStyle( float baseWidth, float baseHeight, float viewportScale )
=> $"width:{HelpPx( baseWidth * viewportScale )};height:{HelpPx( baseHeight * viewportScale )};";
private static string CharacterSpriteLayerStyle( CharacterDef character, float baseWidth, float baseHeight, float viewportScale )
{
float width = baseWidth * character.PreviewScale;
float height = baseHeight * character.PreviewScale;
float left = (baseWidth * viewportScale - width) * 0.5f;
float top = (baseHeight * viewportScale - height) * 0.5f;
return $"left:{HelpPx( left )};top:{HelpPx( top )};width:{HelpPx( width )};height:{HelpPx( height )};"
+ $"background-image:url( {character.PreviewImage} );";
}
private static string CharacterHelpRowPreviewSizeStyle( CharacterDef character )
=> $"width:{HelpPx( HELP_ROW_ICON_WIDTH * character.PreviewScale )};"
+ $"height:{HelpPx( HELP_ROW_ICON_HEIGHT * character.PreviewScale )};";
private static string CharacterHelpRowPreviewImageStyle( string image, float opacity = 1f, float yOffset = 0f )
=> string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );opacity:{opacity:0.###};top:{HelpPx( yOffset )};" );
private static float CharacterHelpRowPreviewOpacity( CharacterHelpRow row, int imageIndex )
=> imageIndex < row.CharacterPreviewOpacities.Count
? Math.Clamp( row.CharacterPreviewOpacities[imageIndex], 0f, 1f )
: 1f;
private static float CharacterHelpRowPreviewYOffset( CharacterHelpRow row, int imageIndex )
=> imageIndex < row.CharacterPreviewYOffsets.Count
? row.CharacterPreviewYOffsets[imageIndex]
: 0f;
private static string CharacterHelpControlMarkerClass( CharacterHelpRow row, int imageIndex )
{
if ( row.CharacterPreviewAlternateIndicatorIndex < 0 ) return "";
return imageIndex == row.CharacterPreviewIndicatorIndex ? "alternating primary" : "alternating alternate";
}
private static string CharacterHelpRowIllustrationStyle( CharacterDef character, CharacterHelpRow row, int animationStep, bool alternate = false )
{
string image = alternate ? row.IllustrationAlternateImage : row.IllustrationImage;
float scaleX = character.PreviewScale * (row.IllustrationFlipHorizontal ? -1f : 1f);
int rotation = row.CycleIllustrationQuarterTurns ? animationStep % 4 * 90 : 0;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );transform:rotate({rotation}deg) scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static bool CharacterHelpGravityIsFlipped( int animationStep )
=> CharacterHelpAlternatingState( animationStep );
private static bool CharacterHelpAlternatingState( int animationStep )
=> animationStep / 3 % 2 == 1;
private static string CharacterHelpGravityFlipIllustrationStyle( CharacterDef character, CharacterHelpRow row, bool flipped )
{
float scaleY = character.PreviewScale * (flipped ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {row.IllustrationImage} );transform:scale({character.PreviewScale:0.###},{scaleY:0.###});" );
}
private static string CharacterHelpHorizontalDashIllustrationStyle( CharacterDef character, CharacterHelpRow row, bool flipped )
{
float scaleX = character.PreviewScale * (flipped ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {row.IllustrationImage} );transform:scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static string CharacterHelpLongJumpIllustrationStyle( CharacterDef character, CharacterHelpRow row, bool facesLeft, bool airborne )
{
string image = airborne ? row.IllustrationAlternateImage : row.IllustrationImage;
float scaleX = character.PreviewScale * (facesLeft ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );transform:scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static string CharacterHelpDashIllustrationStyle( CharacterDef character, CharacterHelpRow row, int state )
{
bool facesLeft = state is 1 or 5 or 7;
bool downward = state is 3 or 6 or 7;
string image = downward && !string.IsNullOrEmpty( row.IllustrationAlternateImage )
? row.IllustrationAlternateImage
: row.IllustrationImage;
float scaleX = character.PreviewScale * (facesLeft ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );transform:scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static string CharacterHelpWallKickIllustrationStyle( CharacterDef character, CharacterHelpRow row, bool alternateSide )
{
bool flipped = row.IllustrationFlipHorizontal != alternateSide;
float scaleX = character.PreviewScale * (flipped ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {row.IllustrationImage} );transform:scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static int CharacterHelpTeleportState( int animationStep )
=> animationStep / 2 % 64;
private static IReadOnlyList<Direction> CharacterHelpTeleportOrigin( int state )
=> CharacterHelpThreadChordForState( state / 8 );
private static IReadOnlyList<Direction> CharacterHelpTeleportForce( int state )
=> CharacterHelpThreadChordForState( state % 8 );
private static string CharacterHelpTeleportIllustrationStyle( CharacterDef character, int state, int animationStep )
{
int origin = state / 8;
string image = origin switch
{
2 or 4 or 5 => "sprites/ui/characters/player_blinker/player_air_up_active.png",
3 or 6 or 7 => "sprites/ui/characters/player_blinker/player_air_down.png",
_ => "sprites/ui/characters/player_blinker/player_air_up.png",
};
Vector2 shake = CharacterHelpSubtleShake( animationStep );
shake *= 2f;
float scaleX = character.PreviewScale * (origin is 1 or 5 or 7 ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );transform:translate({shake.x:0.###}px {shake.y:0.###}px) scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static int CharacterHelpThreadState( int animationStep )
=> animationStep / 3 % 8;
private static IReadOnlyList<Direction> CharacterHelpThreadChord( int animationStep )
=> CharacterHelpThreadChordForState( CharacterHelpThreadState( animationStep ) );
private static IReadOnlyList<Direction> CharacterHelpThreadChordForState( int state ) => state switch
{
0 => new[] { Direction.Right },
1 => new[] { Direction.Left },
2 => new[] { Direction.Up },
3 => new[] { Direction.Down },
4 => new[] { Direction.Right, Direction.Up },
5 => new[] { Direction.Left, Direction.Up },
6 => new[] { Direction.Right, Direction.Down },
_ => new[] { Direction.Left, Direction.Down },
};
private static string CharacterHelpGunnerShootIllustrationStyle( CharacterDef character, int state )
{
string image = state switch
{
2 => "sprites/ui/characters/player_gunner/gun_shoot_up_1.png",
3 => "sprites/ui/characters/player_gunner/gun_shoot_down_1.png",
_ => "sprites/ui/characters/player_gunner/gun_shoot_side_1.png",
};
bool facesLeft = state is 1 or 5 or 7;
float scaleX = character.PreviewScale * (facesLeft ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );transform:scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static string CharacterHelpClimberWallIllustrationStyle( CharacterDef character, bool climbingUp )
{
string image = climbingUp
? "sprites/ui/characters/player_climber/player_wall_up.png"
: "sprites/ui/characters/player_climber/player_wall_down.png";
float scaleX = character.PreviewScale * (climbingUp ? 1f : -1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );transform:scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static int CharacterHelpClimberCeilingState( int animationStep )
=> animationStep * 2 / 12 % 3;
private static string CharacterHelpClimberCeilingIllustrationStyle( CharacterDef character, int animationStep, int state )
{
int frame = state == 0 ? 0 : animationStep * 2 / 3 % 4;
float scaleX = character.PreviewScale * (state == 2 ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( sprites/ui/characters/player_climber/player_ceiling_cling_{frame}.png );transform:scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static string CharacterHelpClimberFallIllustrationStyle( CharacterDef character, int animationStep )
{
int cycleStep = animationStep % 12;
string image = cycleStep < 7
? "sprites/ui/characters/player_climber/player_air_fall_fast.png"
: $"sprites/ui/characters/player_climber/explode_{cycleStep - 7}.png";
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( {image} );transform:scale({character.PreviewScale:0.###});" );
}
private static string CharacterHelpRewindExplosionIllustrationStyle( int animationStep )
{
int frame = animationStep / 2 % 5;
return $"background-image:url( sprites/ui/characters/player_rewind/explode_{frame}.png );opacity:0.6;";
}
private static string CharacterHelpShifterIllustrationStyle( CharacterDef character, int animationStep )
{
int frame = animationStep % 4;
// Hold each quarter-turn for 8 steps, not 7: only a divisor of the step counter's wrap
// (CHARACTER_HELP_ANIMATION_WRAP) whose quotient is a multiple of 4 keeps the rotation continuous
// across the wrap. With 7 the last segment was short and the pose snapped back a quarter-turn.
int rotation = animationStep / 8 % 4 * 90;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( sprites/ui/characters/player_shifter/player_walk_{frame}.png );transform:rotate({rotation}deg) scale({character.PreviewScale:0.###});" );
}
private static string CharacterHelpTwinPhaseDashIllustrationStyle( CharacterDef character, int animationStep )
{
Vector2 shake = (animationStep % 4) switch
{
0 => new Vector2( -1f, -0.5f ),
1 => new Vector2( 1f, 0.5f ),
2 => new Vector2( 0.5f, -1f ),
_ => new Vector2( -0.5f, 1f ),
};
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( sprites/ui/characters/player_twin_1/player_air_up.png );opacity:0.6;transform:translate({shake.x:0.###}px {shake.y:0.###}px) scale({character.PreviewScale:0.###});" );
}
private static string CharacterHelpMimicTransformIllustrationStyle( CharacterDef character, int animationStep )
{
int frame = animationStep / 2 % 4;
Vector2 shake = CharacterHelpSubtleShake( animationStep );
float scaleX = character.PreviewScale * (animationStep / 8 % 2 == 1 ? -1f : 1f);
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( sprites/ui/characters/player_mimic/transform_{frame}.png );transform:translate({shake.x:0.###}px {shake.y:0.###}px) scale({scaleX:0.###},{character.PreviewScale:0.###});" );
}
private static string CharacterHelpMimicSquashIllustrationStyle( CharacterDef character, int animationStep )
{
int sequenceStep = animationStep / 2 % 16;
bool horizontal = sequenceStep < 7;
int axisStep = horizontal ? sequenceStep : sequenceStep - 7;
int lastFrame = horizontal ? 3 : 4;
int frame = axisStep <= lastFrame ? axisStep : lastFrame * 2 - axisStep;
string axis = horizontal ? "h" : "v";
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( sprites/ui/characters/player_mimic/squish_{axis}_{frame}.png );transform:scale({character.PreviewScale:0.###});" );
}
private static Vector2 CharacterHelpSubtleShake( int animationStep ) => (animationStep % 8) switch
{
0 => new Vector2( -1f, 0.5f ),
1 => new Vector2( 0.5f, -1f ),
2 => new Vector2( 1f, 0f ),
3 => new Vector2( -0.5f, 1f ),
4 => new Vector2( -1f, -0.5f ),
5 => new Vector2( 0f, 1f ),
6 => new Vector2( 1f, -0.5f ),
_ => new Vector2( 0f, -1f ),
};
private static string CharacterHelpChargeIllustrationStyle( CharacterDef character, int step )
{
int hint = step / 6;
int frame = Math.Min( step % 6, 4 );
string pose = (hint % 3) switch
{
0 => "charge_left",
2 => "charge_right",
_ => "charge",
};
int rotation = hint / 3 * 90;
return string.Create( System.Globalization.CultureInfo.InvariantCulture,
$"background-image:url( sprites/ui/characters/player_spring/player_{pose}_{frame}.png );transform:rotate({rotation}deg) scale({character.PreviewScale:0.###});" );
}
private static IReadOnlyList<Direction> CharacterHelpChargeDirections( int step )
{
int hint = step / 6;
int aim = hint % 3;
int rotation = hint / 3;
Direction surface = RotateCharacterHelpDirection( Direction.Down, rotation );
if ( aim == 1 ) return new[] { surface };
Direction tangent = RotateCharacterHelpDirection( aim == 0 ? Direction.Left : Direction.Right, rotation );
return new[] { surface, tangent };
}
private static Direction RotateCharacterHelpDirection( Direction direction, int quarterTurns )
{
Direction[] clockwise = { Direction.Up, Direction.Right, Direction.Down, Direction.Left };
int index = Array.IndexOf( clockwise, direction );
return clockwise[(index + quarterTurns) % clockwise.Length];
}
private static string CharacterHelpDirectionClass( Direction direction ) => direction switch
{
Direction.Right => "right",
Direction.Down => "down",
Direction.Left => "left",
_ => "up",
};
// Controller help variant: double-tap patterns render one cycling chord + the X face button
// (which fires the scripted double tap) instead of the keyboard's chord-chord pair.
private static int CharacterHelpChordRepeats => InputState.GamepadIsPrimary ? 1 : 2;
/// <summary>Faded note trailing the input pattern that names the keyboard gesture (chord vs
/// double tap). Keyboard only: the controller variants swap in the X/Y face buttons, which
/// are single presses and would make the note wrong.</summary>
private static (string Text, string Action) CharacterHelpGestureNote( CharacterHelpRow row )
{
if ( InputState.GamepadIsPrimary ) return default;
if ( row.ShowTeleportSequence || row.ShowDashSequence || row.ShowHorizontalDashSequence || row.ShowThreadInputSequence
|| row.ShowPortalShotSequence || row.ShowGunnerShootSequence || CharacterHelpIsDoubleTap( row ) )
return ("Double tap", "DoubleTap");
if ( CharacterHelpIsAllFourDirections( row.Directions ) ) return ("Press all simultaneously", "AllDirections");
return default;
}
/// <summary>An all-four-directions gesture row (Swapper portal swap etc.) — on controller it
/// renders as the single Y face button, which presses all four at once.</summary>
private static bool CharacterHelpIsAllFourDirections( IReadOnlyList<Direction> directions )
{
if ( directions.Count != 4 ) return false;
bool left = false, right = false, up = false, down = false;
foreach ( Direction direction in directions )
{
left |= direction == Direction.Left;
right |= direction == Direction.Right;
up |= direction == Direction.Up;
down |= direction == Direction.Down;
}
return left && right && up && down;
}
/// <summary>A generic row written as two identical directions is the double-tap display
/// convention (e.g. the twins' phase dash) — on controller it renders as one arrow + X.</summary>
private static bool CharacterHelpIsDoubleTap( CharacterHelpRow row )
=> row.Directions.Count == 2 && row.Directions[0] == row.Directions[1]
&& string.IsNullOrEmpty( row.DirectionSeparator );
private static bool CharacterHelpDirectionsAreTerminal( CharacterHelpRow row )
=> row.CharacterPreviewCount == 0 && row.CharacterPreviewImages.Count == 0 && string.IsNullOrEmpty( row.Text );
private static string HelpPx( float value )
=> value.ToString( "0.##", System.Globalization.CultureInfo.InvariantCulture ) + "px";
private static readonly string[] MonthAbbr =
{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
// Submission date as "7 Jun 2026". Built from the DateTimeOffset's own components (no culture-
// dependent format string, which the sbox whitelist is fussy about). Empty for an unset (default)
// timestamp.
private static string FormatDate( System.DateTimeOffset when )
{
if ( when == default )
return "";
return $"{when.Day} {MonthAbbr[when.Month - 1]} {when.Year}";
}
}