Scoring utility for the game. Calculates per-block side-press scores, time bonus and an overall run score, and provides helpers used by the animated tally to determine which sides are counted and whether a phase-completing side gets the completion bonus.
using System.Collections.Generic;
namespace BlockParty;
/// <summary>
/// Faithful port of the scoring rules the original <c>ScoreStage</c> tallied up. Pulled out
/// into one place so the number we submit to the leaderboard is guaranteed to match what the
/// (upcoming) animated tally screen will count out.
///
/// Per block, for every phase it completed during play you score each newly pressed side (with a
/// bigger bonus for the side that completed the phase); for the phase it was on when the game ended
/// you score newly pressed sides so far. Authored starting phases and sides award nothing. If
/// <em>every</em> block reached max phase (a win), a time bonus is added that decays from 250 down to
/// 0 over ten minutes.
/// </summary>
public static class ScoreCalc
{
// Mirrors ScoreStage's SCORE_PHASE_* constants.
private const int PHASE_0_SIDE = 10;
private const int PHASE_0_COMPLETE = 20;
private const int PHASE_1_SIDE = 20;
private const int PHASE_1_COMPLETE = 40;
private const int MAX_TIME_BONUS = 250;
private const float TIME_BONUS_SECONDS = 600f;
/// <summary>Points per collected <see cref="Coin"/>.</summary>
public const int COIN_SCORE = 1;
/// <summary>Points awarded for one side-press: <paramref name="phase"/> 0/1, and whether it's
/// the 4th (phase-completing) side. Phase 2 (max) sides award nothing.</summary>
public static int SideAmount( int phase, bool completes )
{
if ( phase == 0 ) return completes ? PHASE_0_COMPLETE : PHASE_0_SIDE;
if ( phase == 1 ) return completes ? PHASE_1_COMPLETE : PHASE_1_SIDE;
return 0;
}
/// <summary>
/// Continuous (un-rounded) time bonus: decays linearly from <see cref="MAX_TIME_BONUS"/> to 0
/// over <see cref="TIME_BONUS_SECONDS"/>. The integer part is the bonus the player sees tallied
/// up (<see cref="TimeBonus"/>). The leaderboard tiebreaker is handled separately by
/// <see cref="TimeTiebreaker"/> (this bonus clamps to 0 past ten minutes, so it can't break ties
/// between slower winning runs).
/// </summary>
public static float PreciseTimeBonus( float gameTime ) =>
Utils.Map( gameTime, 0f, TIME_BONUS_SECONDS, MAX_TIME_BONUS, 0f, true, EasingType.Linear );
/// <summary>Visible/tallied time bonus awarded only on a full win (every block at max phase):
/// the integer part of the continuous bonus.</summary>
public static int TimeBonus( float gameTime ) =>
(int)MathF.Floor( PreciseTimeBonus( gameTime ) );
/// <summary>
/// Strictly-decreasing leaderboard tiebreaker in (0, 0.5], added onto every submitted score (a
/// double). It never changes the displayed integer score (always below 1), but lets a faster run
/// outrank a slower one with the identical visible score — for losses as well as wins.
///
/// We use 1/(2 + t) rather than the fractional part of <see cref="PreciseTimeBonus"/> on purpose:
/// the time bonus clamps to 0 past <see cref="TIME_BONUS_SECONDS"/> (ten minutes), so two wins both
/// slower than that would have produced fraction 0 and tied exactly — the faster one would NOT have
/// won. 1/(2 + t) keeps decreasing for ALL t ≥ 0, so it never saturates.
///
/// Monotonicity of the submitted value (= integer score + this): the integer score's clamped time
/// bonus is non-increasing in t and this term is strictly decreasing in (0, 1), so their sum is
/// strictly decreasing in t — a faster victory always yields a strictly larger value (the board
/// sorts descending). Resolution is far above double precision: even at t = 600s the slope is
/// ~2.8e-6/s (≫ the ~1e-12 double epsilon at score magnitudes of a few thousand), and the value
/// pipeline is double end-to-end (Stats.SetValue / Board2.Entry.Value), so the fraction survives
/// the round-trip.
///
/// COUPLING: the boards' display-side tamper checks (<see cref="Leaderboard.IsDailyEntryConsistent"/>
/// / <see cref="Leaderboard.IsLevelEntryConsistent"/>) recompute "score + this" from each entry's
/// payload and hide entries that disagree. Changing this formula therefore requires a
/// LEADERBOARD_VERSION bump (which changing submitted values needs anyway), or every honest entry
/// on the still-live boards fails the check and vanishes.
/// </summary>
public static double TimeTiebreaker( float gameTime ) =>
1.0 / ( 2.0 + Math.Max( 0f, gameTime ) );
public static bool AllMaxPhase( IReadOnlyList<BlockResult> blocks )
{
if ( blocks.Count == 0 ) return false;
foreach ( var b in blocks )
if ( b.Phase < Block.NUM_PHASES - 1 ) return false;
return true;
}
// All four sides as a shared immutable list. ScoredSides' result is held across the animated
// tally's steps (captured in its step closures), so it must be a heap collection — the span
// Globals hands out can't be stored — and the read-only wrapper keeps the shared instance
// unwritable (no cast back to the array).
private static readonly IReadOnlyList<Direction> ALL_SIDES = System.Array.AsReadOnly(
new[] { Direction.Left, Direction.Right, Direction.Up, Direction.Down } );
/// <summary>
/// The side-directions that score in <paramref name="phase"/> for <paramref name="block"/>: all
/// newly earned sides if the block advanced PAST this phase (it was fully completed), otherwise only
/// newly pressed sides in its final phase. Authored starting phases and pre-pressed sides are excluded.
/// This "sides per phase" rule is the single source shared
/// by <see cref="Compute(IReadOnlyList{BlockResult}, float)"/> (which sums their <see cref="SideAmount"/>) and the animated tally
/// (<c>ScoreStage.BuildSteps</c>, which counts them out one at a time), so the submitted score and
/// the on-screen tally can never disagree.
/// </summary>
public static IReadOnlyList<Direction> ScoredSides( BlockResult block, int phase )
{
if ( phase < block.ScoreStartPhase )
return System.Array.Empty<Direction>();
bool excludeStartingSides = phase == block.ScoreStartPhase;
if ( block.Phase > phase && !excludeStartingSides )
return ALL_SIDES;
var sides = new List<Direction>();
foreach ( var dir in Globals.GetAllDirections() )
{
bool pressed = block.Phase > phase || block.IsPressed( dir );
if ( pressed && (!excludeStartingSides || !block.WasPressedAtScoreStart( dir )) )
sides.Add( dir );
}
return sides;
}
/// <summary>Whether this scored side is the newly earned side that completed its phase.</summary>
public static bool CompletesPhase( BlockResult block, int phase, int sideIndex, int scoredSideCount ) =>
block.Phase > phase && scoredSideCount > 0 && sideIndex == scoredSideCount - 1;
/// <param name="blocks">The final state of every block in the run.</param>
/// <param name="gameTime">Elapsed gameplay time in seconds (used for the win time bonus).</param>
public static int Compute( IReadOnlyList<BlockResult> blocks, float gameTime )
{
int score = 0;
foreach ( var block in blocks )
{
for ( int phase = 0; phase <= block.Phase; phase++ )
{
// The sides that score in this phase (all four if fully completed, else the sides
// pressed in the final phase) — the same rule the animated tally counts out.
var sides = ScoredSides( block, phase );
for ( int j = 0; j < sides.Count; j++ )
score += SideAmount( phase, CompletesPhase( block, phase, j, sides.Count ) );
}
}
if ( AllMaxPhase( blocks ) )
score += TimeBonus( gameTime );
return score;
}
/// <param name="blocks">The final state of every block in the run.</param>
/// <param name="gameTime">Elapsed gameplay time in seconds (used for the win time bonus).</param>
/// <param name="coinsCollected">Coins picked up during the run (<see cref="GameStage.CoinsCollected"/>);
/// each is worth <see cref="COIN_SCORE"/>, counted out by the tally after the blocks/time bonus.</param>
public static int Compute( IReadOnlyList<BlockResult> blocks, float gameTime, int coinsCollected ) =>
Compute( blocks, gameTime ) + coinsCollected * COIN_SCORE;
}