UI/ComboCounter.razor
@using Sandbox;
@using Sandbox.UI;
@using SquishyTerry.Scoring;
@namespace SquishyTerry
@inherits PanelComponent
@*
The combo, shown as a vertical segment bar on the right of the screen. The count comes from the
ScoreKeeper (the same numbers the leaderboards record).
Each hit lights the next segment (bottom to top) with a quick upward sweep and a white/yellow
flash. Once every segment is lit the bar is maxed: it stays full and the bar and the count cycle
through the full rainbow, one colour per segment/character, scrolling every 100 ms. Every tenth hit is a
milestone that flashes the whole bar and gives it a small shake. As the combo window runs out the
lit segments heat to orange/red and the top one pulses; a last-moment save sweeps yellow up the
whole bar. When the combo ends the bar holds for a moment, then dims and empties.
*@
<root>
<div class="swap">
@* The bar view (count + segments). It collapses away at x25 and comes back once a combo has ended. *@
<div class="stack" style="transform: translateX( @(MeterShake.ToString( "0.#" ))px ) scaleY( @((1f - 0.35f * SwapEase).ToString( "0.###" )) ); opacity: @((1f - SwapEase).ToString( "0.##" ));">
@* The count above the bar: "x" + number, punching on every hit. *@
<div class="count" style="transform: scale( @(NumberScale.ToString( "0.###" )) ); opacity: @(CountOpacity.ToString( "0.##" ));">
@if ( _catchT < AgainTime )
{
@* "SAVED!" (rescued mid-countdown) or "AGAIN!" (new combo) in rainbow letters for a moment. *@
var again = _flashText;
@for ( int i = 0; i < again.Length; i++ )
{
<label class="combo-count rain" style="color: @Css( RainbowAt( i ) ); font-size: 26px;">@again[i]</label>
}
}
else if ( Maxed )
{
@* Maxed: every character its own rainbow colour, scrolling every 100 ms. *@
var text = "x" + DisplayCount;
@for ( int i = 0; i < text.Length; i++ )
{
<label class="@(i == 0 ? "combo-x" : "combo-count") rain" style="color: @Css( RainbowAt( i ) ); @(i == 0 ? "" : $"font-size: {CountSize}px;")">@text[i]</label>
}
}
else
{
<label class="combo-x" style="background-image: @XGradient;">x</label>
<label class="combo-count" style="background-image: @NumberFill; font-size: @(CountSize)px;">@DisplayCount</label>
}
</div>
<div class="meter-edge">
<div class="meter">
@* Drawn top to bottom, but segment 0 is the bottom one: the bar fills upward. *@
@for ( int row = 0; row < Segments; row++ )
{
<div class="segment" style="@SegmentStyle( Segments - 1 - row )"></div>
}
</div>
</div>
</div>
@* x25+: the big count and the combo title in rainbow letters, swapped in smoothly. *@
@if ( SwapEase > 0.01f )
{
var count = "x" + DisplayCount;
var title = TitleFor( DisplayCount );
<div class="big" style="opacity: @(SwapEase.ToString( "0.##" )); transform: scale( @(((0.8f + 0.2f * SwapEase) * NumberScale).ToString( "0.###" )) );">
<div class="big-count">
@for ( int i = 0; i < count.Length; i++ )
{
<label style="color: @Css( RainbowAt( i ) );">@count[i]</label>
}
</div>
<div class="big-title">
@for ( int i = 0; i < title.Length; i++ )
{
<label style="color: @Css( RainbowAt( i + count.Length ) );">@title[i]</label>
}
</div>
</div>
}
</div>
</root>
@code
{
/// <summary>Where the combo comes from; found automatically when left empty.</summary>
[Property] public ScoreKeeper Keeper { get; set; }
/// <summary>Editor preview: shows this combo count (0 = off, play normally).</summary>
[Property] public int Preview { get; set; }
float ComboWindow => Keeper?.ComboWindow ?? 2.5f;
// --- Timing (seconds). Hit feedback lives in the 80-180 ms range. ---
const float FlashTime = 0.2f; // a newly lit segment's white/yellow flash
const float MilestoneTime = 0.28f; // whole-bar flash + shake on x10, x20, ...
// The end-of-combo countdown timeline is shared with the ScoreKeeper (see ComboDrain).
const int Segments = ComboDrain.Segments;
const float FreezeTime = ComboDrain.FreezeTime;
// --- State ---
int _count;
int _prevCount;
int _shown; // count the bar shows (held while ending)
float _sinceLast = 999f;
float _time;
float _sweepT = 99f, _sweepDur = 0.12f;
int _sweepTo;
bool _clutch;
float _milestoneT = 99f;
float _endT = -1f;
readonly float[] _segFlash = new float[Segments];
bool Ending => _endT >= 0f;
/// <summary>0..1 of the combo window left (1 right after a hit).</summary>
float TimeLeft => _count > 0 ? (1f - _sinceLast / ComboWindow).Clamp( 0f, 1f ) : 0f;
/// <summary>0 while there's plenty of time, rising to 1 as the combo is about to expire.</summary>
float Urgency => _count > 0 && !Ending ? ((0.45f - TimeLeft) / 0.45f).Clamp( 0f, 1f ) : 0f;
/// <summary>How long the whole end sequence takes (the shared countdown).</summary>
float EndDuration => ComboDrain.Duration( _shown );
/// <summary>0..1 through the drain (for the count label).</summary>
float EndFade => Ending ? ((_endT - FreezeTime) / System.MathF.Max( EndDuration - FreezeTime, 0.01f )).Clamp( 0f, 1f ) : 0f;
// --- Comeback: starting a new combo while the old one drains ---
const float CatchTime = 0.18f; // remaining segments slam down into the new first one
const float AgainTime = 0.45f; // "AGAIN!" shows in place of the count
float _catchT = 99f;
int _catchFrom;
bool Catching => _catchT < CatchTime;
/// <summary>Top of the collapsing column during a comeback (segments below it are still lit).</summary>
float CatchTop
{
get
{
float p = _catchT / CatchTime;
return _catchFrom * (1f - p * p); // accelerating fall
}
}
/// <summary>Lit segments that haven't fully dropped out yet.</summary>
int StillStanding
{
get
{
int n = 0;
for ( int k = 0; k < LitSegments; k++ ) if ( Dropped( k ) < 1f ) n++;
return n;
}
}
/// <summary>0..1 how far segment k has dropped out: the top lit one goes first, then down the bar.</summary>
float Dropped( int k ) => Ending ? ComboDrain.Dropped( _shown, k, _endT ) : 0f;
// --- Swap to the title card at x25+ ---
const int SwapAt = 25;
const float SwapTime = 0.3f; // seconds for the crossfade
float _swap; // 0 = bar, 1 = title card
/// <summary>Smoothstep of the swap, for the crossfade.</summary>
float SwapEase => _swap * _swap * (3f - 2f * _swap);
record Tier( int From, string Title );
static readonly Tier[] Titles =
{
new( 25, "ULTRA SQUISH" ), new( 30, "SAVAGE" ), new( 35, "UNSTOPPABLE" ), new( 40, "GODLIKE" ),
new( 50, "LEGENDARY" ), new( 65, "SQUISHPOCALYPSE" ), new( 80, "BEYOND SQUISH" ),
new( 100, "TERRY'S NIGHTMARE" ), new( 120, "SQUISH GOD" ), new( 150, "HANDS OF STEEL" ),
new( 175, "FACE MELTER" ), new( 200, "SQUISHTASTROPHE" ), new( 250, "CERTIFIED SQUISHER" ),
new( 300, "OMEGA SQUISH" ), new( 350, "INFINITE HANDS" ), new( 400, "COSMIC SQUISH" ),
new( 450, "THE SQUISHENING" ), new( 500, "FINAL FORM" ),
// Past "final" form, for the truly unhinged.
new( 600, "SQUISH ASCENDED" ), new( 700, "TERRYGEDDON" ), new( 800, "ELASTIC ENTITY" ),
new( 900, "RUBBER DEITY" ), new( 1000, "THE THOUSAND SQUISH" ), new( 1250, "SQUISH SINGULARITY" ),
new( 1500, "TERRY WEEPS" ), new( 1750, "NO FACE LEFT" ), new( 2000, "SQUISHVERSE" ),
new( 2500, "MULTIVERSAL SQUISHER" ), new( 3000, "SQUISH EMPEROR" ), new( 3500, "BEYOND COMPREHENSION" ),
new( 4000, "THE FACE BENDER" ), new( 4500, "ALMOST INFINITE" ), new( 5000, "SQUISH ETERNAL" ),
};
static string TitleFor( int count )
{
if ( count < Titles[0].From ) return "";
string title = Titles[0].Title;
foreach ( var t in Titles ) if ( count >= t.From ) title = t.Title;
return title;
}
/// <summary>The bar is maxed out once the combo fills every segment; it stays full (and goes wild) from then on.</summary>
bool Maxed => _shown >= Segments;
int LitSegments => System.Math.Clamp( _shown, 0, Segments );
protected override void OnUpdate()
{
Keeper ??= Scene.GetAllComponents<ScoreKeeper>().FirstOrDefault();
float dt = RealTime.Delta;
_time += dt;
int count;
float since;
if ( Preview > 0 ) { count = Preview; since = ComboWindow * 0.3f; }
else
{
// While the keeper's combo is counting down it's over as far as the HUD is concerned
// (it plays the countdown); a squish in that window rescues it.
bool draining = Keeper?.IsDraining ?? false;
count = draining ? 0 : Keeper?.Combo ?? 0;
since = Keeper?.SinceLastSquish ?? 999f;
}
if ( Keeper != _subscribed )
{
if ( _subscribed is not null ) _subscribed.Rescued -= OnRescued;
if ( Keeper is not null ) Keeper.Rescued += OnRescued;
_subscribed = Keeper;
}
float leftBefore = TimeLeft;
if ( count > _prevCount ) OnHit( count, leftBefore, fresh: _prevCount == 0 || Ending );
else if ( count < _prevCount )
{
if ( count == 0 ) _endT = 0f;
else OnHit( count, 1f, fresh: true ); // dropped and restarted in the same frame
}
_prevCount = count;
_count = count;
_sinceLast = since;
if ( _count > 0 ) { _endT = -1f; _shown = _count; }
else if ( Ending )
{
// Follow the keeper's countdown clock exactly, so what a rescue keeps is what's shown.
_endT = Preview <= 0 && (Keeper?.IsDraining ?? false) ? Keeper.DrainElapsed : _endT + dt;
if ( _endT >= EndDuration ) { _endT = -1f; _shown = 0; _swap = 0f; }
}
// Crossfade between the bar and the title card. When a x25+ combo ends the card counts down to
// a full bar, then hands back to the bar, which carries on counting down to zero.
bool cardUp = _count >= SwapAt || (Ending && _shown >= SwapAt && DisplayCount > Segments);
float swapTarget = cardUp ? 1f : 0f;
_swap = swapTarget > _swap ? System.MathF.Min( swapTarget, _swap + dt / SwapTime ) : System.MathF.Max( swapTarget, _swap - dt / SwapTime );
_sweepT += dt;
_milestoneT += dt;
_hitT += dt;
_catchT += dt;
// Each step of the drain countdown gives the number a small tick.
int shownNow = DisplayCount;
if ( Ending && shownNow < _lastDisplay ) { _hitT = 0f; _hitAmp = 0.08f; }
_lastDisplay = shownNow;
for ( int k = 0; k < Segments; k++ ) _segFlash[k] = System.MathF.Max( 0f, _segFlash[k] - dt / FlashTime );
_rainTime += dt;
}
ScoreKeeper _subscribed;
bool _rescuePending;
string _flashText = "AGAIN!";
void OnRescued( int combo ) => _rescuePending = true;
protected override void OnDestroy()
{
if ( _subscribed is not null ) _subscribed.Rescued -= OnRescued;
}
void OnHit( int count, float leftBefore, bool fresh )
{
// Rescued mid-countdown: the combo carries on from where it had counted down to.
if ( _rescuePending )
{
_rescuePending = false;
_endT = -1f;
_flashText = "SAVED!";
_catchFrom = 0;
_catchT = 0f;
_hitT = 0f;
_hitAmp = 0.35f;
_clutch = true; // yellow sweep up the whole bar
int litNow = System.Math.Min( count, Segments );
for ( int k = 0; k < litNow; k++ ) _segFlash[k] = 1f;
_sweepTo = Segments - 1;
_sweepDur = 0.15f;
_sweepT = 0f;
return;
}
// A new combo after the last one fully ran out: the standing segments collapse into it.
_flashText = "AGAIN!";
if ( fresh && Ending && StillStanding > 1 )
{
_catchFrom = StillStanding;
_catchT = 0f;
}
if ( fresh ) _endT = -1f;
_hitT = 0f;
_hitAmp = count >= 10 ? 0.25f : count >= 5 ? 0.2f : 0.15f;
if ( _catchT == 0f ) _hitAmp = 0.3f;
// A save in the last quarter of the window is a "clutch" refresh: yellow sweep up the whole bar.
_clutch = !fresh && leftBefore < 0.25f;
// Light the next segment with a quick upward sweep (faster as the combo grows).
int lit = System.Math.Min( count, Segments );
_segFlash[lit - 1] = 1f;
_sweepTo = _clutch ? Segments - 1 : lit - 1;
_sweepDur = _clutch ? 0.15f : count >= 5 ? 0.08f : 0.12f;
_sweepT = 0f;
// Big milestones (x25, x50, then every x100) drop the game into slow motion for a moment.
if ( !fresh && (count == 25 || count == 50 || count % 100 == 0) ) SlowMo.Trigger();
// Milestones: x10, x20, x30 ... flash the whole bar and shake it a little.
if ( count % 10 == 0 )
{
_milestoneT = 0f;
for ( int k = 0; k < Segments; k++ ) _segFlash[k] = 1f;
}
}
// --- Count text ---
float _hitT = 99f, _hitAmp;
/// <summary>Quick ease-out punch (~70 ms up, ~120 ms back), plus a soft heartbeat in the final second.</summary>
float NumberScale
{
get
{
float t = _hitT, punch = 0f;
if ( t < 0.07f ) { float x = t / 0.07f; punch = 1f - (1f - x) * (1f - x); }
else if ( t < 0.19f ) { float x = (t - 0.07f) / 0.12f; punch = 1f - x * x * (3f - 2f * x); }
float beat = 0f;
if ( _count > 0 && !Ending && ComboWindow - _sinceLast < 1f )
{
float ph = _time * 1.6f % 1f;
float a = (ph - 0.1f) / 0.05f, b = (ph - 0.3f) / 0.05f;
beat = System.MathF.Exp( -a * a ) + 0.6f * System.MathF.Exp( -b * b );
}
return 1f + punch * _hitAmp + 0.035f * beat;
}
}
/// <summary>Hidden with no combo; fades out with the bar when a combo ends.</summary>
/// <summary>
/// The number shown above the bar. While a combo drains it counts down in step with the segments
/// dropping out (proportionally for combos past a full bar), reaching 0 with the last one.
/// </summary>
int DisplayCount => Ending ? ComboDrain.ValueAt( _shown, _endT ) : _shown;
/// <summary>Hidden with no combo; stays up while counting down, then fades once it hits 0.</summary>
float CountOpacity => _shown <= 0 ? 0f : Ending && DisplayCount <= 0 ? 0f : 1f;
int _lastDisplay;
int CountSize => DisplayCount >= 100 ? 30 : 36;
/// <summary>Yellow at the top blending to deeper orange; at x10+ a subtle animated yellow/pink.</summary>
string NumberFill
{
get
{
var yellow = ToColor( "#ffe45c" );
if ( _shown < 10 )
return $"linear-gradient( to bottom, {Css( Color.Lerp( yellow, Color.White, 0.55f ) )} 0%, {Css( yellow )} 45%, {Css( ToColor( "#ff9a2e" ) )} 100% )";
float a = 0.5f + 0.5f * System.MathF.Sin( _time * 2.6f );
var pink = ToColor( "#ff5bbd" );
var top = Color.Lerp( Color.Lerp( yellow, Color.White, 0.55f ), Color.Lerp( pink, Color.White, 0.5f ), a );
var mid = Color.Lerp( yellow, pink, a * 0.6f );
var bottom = Color.Lerp( Color.Lerp( pink, yellow, 0.35f ), ToColor( "#ff8a3c" ), a );
return $"linear-gradient( to bottom, {Css( top )} 0%, {Css( mid )} 55%, {Css( bottom )} 100% )";
}
}
/// <summary>The "x": darker for the top 80% blending into a lighter bottom 20%.</summary>
static readonly string XGradient = BuildXGradient();
static string BuildXGradient()
{
var c = ToColor( "#e73bd8" );
return $"linear-gradient( to bottom, {Css( Color.Lerp( c, Color.Black, 0.45f ) )} 0%, {Css( Color.Lerp( c, Color.Black, 0.2f ) )} 80%, {Css( Color.Lerp( c, Color.White, 0.6f ) )} 100% )";
}
float MeterShake => _milestoneT < 0.3f ? System.MathF.Sin( _milestoneT * 80f ) * 5f * (1f - _milestoneT / 0.3f) : 0f;
// --- Segments ---
static readonly Color EmptySegment = Color.Parse( "#2a2433" ) ?? Color.Black;
// Fill colours, bottom to top: lime → yellow → orange → pink → magenta → violet.
static readonly Color[] Stops =
{
Color.Parse( "#9be83b" ) ?? Color.White, Color.Parse( "#f2ea3b" ) ?? Color.White,
Color.Parse( "#ffb03f" ) ?? Color.White, Color.Parse( "#ff6a8f" ) ?? Color.White,
Color.Parse( "#f24fdc" ) ?? Color.White, Color.Parse( "#8a55ff" ) ?? Color.White,
};
static Color BaseColor( int k )
{
float t = k / (Segments - 1f) * (Stops.Length - 1);
int i = System.Math.Min( (int)t, Stops.Length - 2 );
return Color.Lerp( Stops[i], Stops[i + 1], t - i );
}
string SegmentStyle( int k )
{
int lit = LitSegments;
bool on = k < lit;
// Maxed: the whole bar cycles through the rainbow, one colour per segment, scrolling every
// 100 ms, with a strobe-like brightness kick on each step.
Color c = on ? (Maxed ? Color.Lerp( RainbowAt( k ), Color.White, 0.25f * MaxedKick ) : BaseColor( k )) : EmptySegment;
// Urgency: lit segments heat toward orange/red; the top one pulses softly.
float u = Urgency;
if ( on && u > 0f )
{
var heat = Color.Lerp( ToColor( "#ff9a3c" ), ToColor( "#ff3b3b" ), u );
c = Color.Lerp( c, heat, (Maxed ? 0.5f : 0.85f) * u );
if ( k == lit - 1 ) c = Color.Lerp( c, Color.White, 0.3f * u * (0.5f + 0.5f * System.MathF.Sin( _time * (6f + 8f * u) )) );
}
// The sweep: a bright band runs up to the new segment (yellow on a clutch save).
if ( _sweepT < _sweepDur && k <= _sweepTo )
{
float pos = _sweepT / _sweepDur * (_sweepTo + 1);
float d = k + 0.5f - pos;
float band = System.MathF.Exp( -d * d / 1.2f );
c = Color.Lerp( c, _clutch ? ToColor( "#ffe45c" ) : Color.Lerp( ToColor( "#fff3a8" ), Color.White, 0.5f ), 0.65f * band );
}
// A newly lit segment flashes white, cooling through yellow into its colour.
float f = _segFlash[k];
if ( f > 0f ) c = Color.Lerp( c, Color.Lerp( ToColor( "#ffe45c" ), Color.White, f ), 0.9f * f );
// Combo over: desaturate and dim back to empty.
// Combo over: lit segments drop out one at a time from the top, each popping white then
// shrinking away (a maxed bar keeps cycling rainbow while it drains).
float e = on ? Dropped( k ) : 0f;
if ( e > 0f )
c = e < 0.4f ? Color.Lerp( c, Color.White, e / 0.4f ) : Color.Lerp( Color.White, EmptySegment, (e - 0.4f) / 0.6f );
float squeeze = e > 0.4f ? 1f - 0.5f * (e - 0.4f) / 0.6f : 1f;
// Comeback: segments still standing when a new combo started slam down into the first one.
float catchLit = Catching && k > 0 && k < CatchTop ? 1f : 0f;
if ( catchLit > 0f ) c = Color.Lerp( BaseColor( k ), ToColor( "#ffe45c" ), 0.6f );
float glow = on || catchLit > 0f ? (4f + 12f * f + (Maxed ? 10f : 0f) + 10f * catchLit) * (1f - e) : 0f;
string rgb = $"{(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)}";
return $"background-color: rgb( {rgb} ); transform: skewX( -12deg ) scaleX( {squeeze:0.##} ); box-shadow: 0px 0px {glow:0.#}px rgba( {rgb}, {(on || catchLit > 0f ? 0.5f * (1f - e) : 0f):0.##} );";
}
// --- Colour helpers ---
static Color ParseRgb( string rgb )
{
var parts = rgb.Replace( "rgb(", "" ).Replace( ")", "" ).Split( ',' );
return new Color( int.Parse( parts[0].Trim() ) / 255f, int.Parse( parts[1].Trim() ) / 255f, int.Parse( parts[2].Trim() ) / 255f );
}
static Color ToColor( string css ) => css.StartsWith( "rgb" ) ? ParseRgb( css ) : (Color.Parse( css ) ?? Color.White);
static string Css( Color c ) => $"rgb( {(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)} )";
// Maxed rainbow: each segment / character a distinct colour from the full rainbow; the pattern
// scrolls one step every 100 ms (like the "rain" name effect).
static readonly Color[] Rainbow =
{
Color.Parse( "#FF3333" ) ?? Color.White, // red
Color.Parse( "#FF8A1F" ) ?? Color.White, // orange
Color.Parse( "#FFFF00" ) ?? Color.White, // yellow
Color.Parse( "#00FF00" ) ?? Color.White, // green
Color.Parse( "#00FFFF" ) ?? Color.White, // cyan
Color.Parse( "#3355FF" ) ?? Color.White, // blue
Color.Parse( "#8A2BFF" ) ?? Color.White, // violet
Color.Parse( "#FF33CC" ) ?? Color.White, // magenta
};
const float RainStepMs = 100f;
float _rainTime;
long RainStep => (long)(_rainTime * 1000f / RainStepMs);
Color RainbowAt( int i ) => Rainbow[(int)((RainStep + i) % Rainbow.Length)];
/// <summary>1 right as the pattern steps, fading over the 100 ms step: a subtle strobe.</summary>
float MaxedKick => 1f - (_rainTime * 1000f / RainStepMs) % 1f;
// The border turns continuously, so rebuild every frame.
protected override int BuildHash()
{
// Idle (no combo, nothing animating): don't rebuild the panel every frame.
bool animating = _shown > 0 || Ending || _catchT < AgainTime || _swap > 0f || _milestoneT < 0.3f;
for ( int k = 0; k < Segments && !animating; k++ ) animating = _segFlash[k] > 0f;
return animating ? System.HashCode.Combine( (int)(_time * 60f), _shown, DisplayCount ) : 0;
}
}