UI/CharacterPicker.razor
@using System
@using Sandbox
@using Sandbox.UI
@namespace BlockParty
@inherits Panel
@attribute [StyleSheet( "CharacterPicker.razor.scss" )]
<root class="@(LeaderboardMode ? "leaderboard" : "") @LockedBounceClass" style="@TriggerRootStyle()">
<button class="picker-trigger @(TriggerLocked ? "locked" : "") @LockedVisualsClass @LockedShakeClass" title="@(TriggerLocked ? "Character locked for this challenge" : "Choose character")" style="@TriggerStyle()" onclick=@Open onmouseover=@TriggerTip>
<div class="preview @(Current.Partner is null ? "single" : "paired") @(IsOverall ? "overall" : "")" style="@TriggerViewportStyle()">
@if ( !IsOverall && Current.Partner is CharacterDef currentPartner )
{
<div class="sprite-layer rear" style="@TriggerLayerStyle( currentPartner )"></div>
}
<div class="sprite-layer front" style="@TriggerLayerStyle( Current )"></div>
</div>
@if ( TriggerLocked )
{
<div class="trigger-lock"></div>
}
else if ( !LeaderboardMode && NewCharacterCount > 0 )
{
<div class="new-badge"><label>@NewCharacterCount</label></div>
}
</button>
@if ( LeaderboardMode && !IsOverall )
{
<button class="clear-selection" title="Show all characters" onclick=@ClearSelection onmouseover=@ClearTip>
<div class="icon">close</div>
</button>
}
@if ( _open )
{
<div class="overlay" style="@OverlayStyle()">
<div class="dismiss" onclick=@Close></div>
<div class="panel" style="@PanelStyle()">
<label class="title">@Title</label>
<div class="character-grid">
@for ( int row = 0; row < RowCount; row++ )
{
<div class="character-row" style="@CharacterRowStyle()">
@for ( int column = 0; column < Columns; column++ )
{
int index = row * Columns + column;
if ( index < Characters.All.Count )
{
var character = Characters.All[index];
bool unlocked = CharacterProgress.IsUnlocked( character.Id );
bool isNew = CharacterProgress.IsNew( character.Id );
<button class="character @(unlocked ? "unlocked" : "locked") @(character.Id == Selected.Id ? "selected" : "")" style="@CharacterButtonStyle()"
onclick=@(() => Pick( character )) onrightclick=@(() => ToggleLock( character ))>
<div class="character-sprite @(character.Partner is null ? "single" : "paired") @(isNew ? "new-character" : "")" style="@PreviewViewportStyle( GridPreviewWidth, GridPreviewHeight )">
@if ( unlocked )
{
<div class="icon-glow"></div>
}
@if ( character.Partner is CharacterDef partner )
{
<div class="sprite-layer rear" style="@PreviewLayerStyle( partner, GridPreviewWidth, GridPreviewHeight )"></div>
}
<div class="sprite-layer front" style="@PreviewLayerStyle( character, GridPreviewWidth, GridPreviewHeight )"></div>
</div>
@if ( unlocked )
{
<label class="character-name">@character.Name</label>
}
</button>
}
}
</div>
}
</div>
</div>
</div>
}
</root>
@code
{
private const int Columns = 4;
[Parameter] public Func<string> GetSelectedId { get; set; }
[Parameter] public Action<string> OnPick { get; set; }
[Parameter] public Action OnClear { get; set; }
/// <summary>Hover handlers for the trigger / clear-filter buttons, so a host can show its own
/// tooltip bubble (the layer belongs to the SCREEN — see LeaderboardRow.WatchTip). Null = none.</summary>
[Parameter] public Action<PanelEvent> TriggerTip { get; set; }
[Parameter] public Action<PanelEvent> ClearTip { get; set; }
[Parameter] public Action<bool> OnOpenChanged { get; set; }
[Parameter] public string Title { get; set; } = "WHO ARE YOU?";
[Parameter] public bool LeaderboardMode { get; set; }
[Parameter] public bool CenterOverlayOnScreen { get; set; }
[Parameter] public string DisplayCharacterId { get; set; }
[Parameter] public bool TriggerLocked { get; set; }
[Parameter] public bool LockedVisualsVisible { get; set; } = true;
[Parameter] public bool LockedEnterWithoutFade { get; set; }
[Parameter] public float ScreenBottom { get; set; } = 60f;
[Parameter] public float HostMarginRight { get; set; } = 600f;
private bool _open;
private int _lockedShakeVersion;
private int RowCount => (Characters.All.Count + Columns - 1) / Columns;
private string SelectedId => GetSelectedId is not null ? GetSelectedId() : Settings.Current.SelectedCharacterId;
private bool IsOverall => LeaderboardMode && string.IsNullOrEmpty( SelectedId );
private CharacterDef Selected => CharacterProgress.ResolveSelectable( SelectedId );
private CharacterDef Current => Characters.TryGet( DisplayCharacterId, out var display ) ? display : Selected;
private string LockedVisualsClass => !TriggerLocked || !LockedVisualsVisible
? ""
: LockedEnterWithoutFade ? "locked-visuals" : "locked-visuals locked-fade-in";
private string LockedBounceClass => TriggerLocked && LockedVisualsVisible ? "locked-entry-bounce" : "";
private string LockedShakeClass => !TriggerLocked || _lockedShakeVersion == 0
? ""
: _lockedShakeVersion % 2 == 0 ? "locked-shake-a" : "locked-shake-b";
private int NewCharacterCount => CharacterProgress.NewCharacterCount;
private const float GridPreviewWidth = 110f;
private const float GridPreviewHeight = 120f;
private const float TriggerVisiblePixels = 12f;
private float TriggerPreviewSize => LeaderboardMode ? 108f : 88f;
private float TriggerPixelSize => TriggerPreviewSize / MathF.Max( Player.ART_SIZE.x, Player.ART_SIZE.y );
private float TriggerViewportSize => TriggerVisiblePixels * TriggerPixelSize;
private float TriggerWidth => TriggerViewportSize + 12f;
private float MaxPreviewScale
{
get
{
float maximum = 1f;
foreach ( var character in Characters.All )
{
maximum = MathF.Max( maximum, character.PreviewScale );
if ( character.Partner is not null )
maximum = MathF.Max( maximum, character.Partner.PreviewScale );
}
return maximum;
}
}
private void Open()
{
if ( TriggerLocked )
{
_lockedShakeVersion++;
Audio.PlaySfx( SfxType.StickyBlocked );
return;
}
_open = true;
Audio.PlaySfx( SfxType.MenuStart );
if ( LeaderboardMode )
Haptics.MenuBlip();
OnOpenChanged?.Invoke( true );
}
private void Close()
{
_open = false;
OnOpenChanged?.Invoke( false );
}
public void RequestClose() => Close();
private void Pick( CharacterDef character )
{
if ( !CharacterProgress.IsUnlocked( character.Id ) )
{
Audio.PlaySfx( SfxType.StickyBlocked );
return;
}
SetSelection( character.Id );
Audio.PlaySfx( SfxType.MenuBlip );
Haptics.MenuBlip();
Close();
}
private void ClearSelection()
{
OnClear?.Invoke();
Audio.PlaySfx( SfxType.MenuBlip );
Haptics.MenuBlip();
}
private void ToggleLock( CharacterDef character )
{
if ( !Game.IsEditor )
return;
// if ( !CharacterProgress.ToggleUnlocked( character.Id ) )
// {
// Audio.PlaySfx( SfxType.StickyBlocked );
// return;
// }
// if ( !IsOverall && !CharacterProgress.IsUnlocked( SelectedId ) )
// SetSelection( CharacterProgress.ResolveSelectable( SelectedId ).Id );
// Audio.PlaySfx( SfxType.MenuBlip );
}
private void SetSelection( string characterId )
{
if ( OnPick is not null )
OnPick( characterId );
else
{
Settings.Current.SelectedCharacterId = characterId;
Settings.Save();
}
// Only a real play-selection consumes a character's "new" status; filtering the
// leaderboard by character must not clear the badge/glow nudge to try them.
if ( !LeaderboardMode )
CharacterProgress.MarkSelected( characterId );
}
private string TriggerRootStyle()
=> $"width:{Px( TriggerWidth )};height:{Px( TriggerWidth )};";
private string TriggerStyle()
=> $"width:{Px( TriggerWidth )};height:{Px( TriggerWidth )};";
private string TriggerViewportStyle()
=> $"width:{Px( TriggerViewportSize )};height:{Px( TriggerViewportSize )};";
private string TriggerLayerStyle( CharacterDef character )
{
float width = character.ArtSize.x * TriggerPixelSize;
float height = character.ArtSize.y * TriggerPixelSize;
return $"left:{Px( (TriggerViewportSize - width) * 0.5f )};top:{Px( (TriggerViewportSize - height) * 0.5f )};"
+ $"width:{Px( width )};height:{Px( height )};background-image:url( {character.PreviewImage} );";
}
private string PreviewViewportStyle( float baseWidth, float baseHeight )
=> $"width:{Px( baseWidth * MaxPreviewScale )};height:{Px( baseHeight * MaxPreviewScale )};";
private string PreviewLayerStyle( CharacterDef character, float baseWidth, float baseHeight )
{
float width = baseWidth * character.PreviewScale;
float height = baseHeight * character.PreviewScale;
float left = (baseWidth * MaxPreviewScale - width) * 0.5f;
float top = (baseHeight * MaxPreviewScale - height) * 0.5f;
return $"left:{Px( left )};top:{Px( top )};width:{Px( width )};height:{Px( height )};"
+ $"background-image:url( {character.PreviewImage} );";
}
private string CharacterButtonStyle()
{
float extraWidth = GridPreviewWidth * (MaxPreviewScale - 1f);
float extraHeight = GridPreviewHeight * (MaxPreviewScale - 1f);
return $"width:{Px( 118f + extraWidth )};height:{Px( 180f + extraHeight )};";
}
private string CharacterRowStyle()
{
float extraWidth = GridPreviewWidth * (MaxPreviewScale - 1f);
float extraHeight = GridPreviewHeight * (MaxPreviewScale - 1f);
return $"width:{Px( 620f + Columns * extraWidth )};height:{Px( 185f + extraHeight )};";
}
private string PanelStyle()
{
float extra = MaxPreviewScale - 1f;
float paddingY = MathF.Max( 16f, 42f - 78f * extra );
return $"height:{Px( 900f + RowCount * GridPreviewHeight * extra )};"
+ $"padding-top:{Px( paddingY )};padding-bottom:{Px( paddingY )};";
}
protected override int BuildHash()
=> HashCode.Combine( SelectedId, DisplayCharacterId, TriggerLocked, LockedVisualsVisible,
HashCode.Combine( LockedEnterWithoutFade, _lockedShakeVersion,
HashCode.Combine( _open, CharacterProgress.Version, Characters.All.Count,
HashCode.Combine( ScreenBottom, HostMarginRight, Title, LeaderboardMode, CenterOverlayOnScreen,
CenterOverlayOnScreen ? (int)Box.Rect.Left : 0,
CenterOverlayOnScreen ? (int)Box.Rect.Top : 0 ) ) ) );
private string OverlayStyle()
{
if ( !CenterOverlayOnScreen )
return $"bottom:{Px( -ScreenBottom )};left:calc( -50vw + {Px( HostMarginRight + TriggerWidth )} );";
float scale = ScaleFromScreen;
return $"left:{Px( -Box.Rect.Left * scale )};top:{Px( -Box.Rect.Top * scale )};";
}
private static string Px( float value ) => $"{value:0.##}px";
}