Stages/ScoreStage.cs
using System;
using System.Collections.Generic;
namespace BlockParty;
/// <summary>
/// Score-tally screen shown after a run (CONTINUE leads back to the map, or to the daily hub
/// for a daily run). Port of the original
/// <c>ScoreStage</c>: the five blocks are laid out and their points are counted out one
/// side at a time (with floating "+N" popups), then a time bonus if every block was maxed.
///
/// The original's grey→colour reveal used runtime palette swaps; our block art is baked into
/// PNGs, so we bake the four "tally" tint levels (grey → faint teal → stronger teal → the
/// block's own phase-2 colours, eyes shut throughout) and step a block up a level as its turn
/// begins and as each phase of side buttons is completed. Coroutines are replaced by a
/// precomputed list of timed steps driven from <see cref="Tick"/> (same pattern as the block
/// eye-cycle), and Confirm fast-forwards.
///
/// World-space block sprites are drawn under the stage Root; the running score, floating
/// popups, and the time-bonus banner are Razor (<see cref="ScoreScreen"/>), positioned by
/// projecting world coords into the 1080-reference UI space.
///
/// A run that unlocked a character (a first win with them) also owes the player a reveal: the
/// first RESTART / CONTINUE is queued behind the shared "YOU UNLOCKED" overlay instead of leaving
/// immediately — see <see cref="DeferForUnlock"/> and <see cref="UnlockReveal"/>.
/// </summary>
public sealed class ScoreStage : MenuStageBase
{
// Original ScoreStage timing.
private const float DELAY_BEFORE_BLOCKS = 0.10f;
private const float DELAY_BETWEEN_SIDES = 0.10f;
private const float DELAY_BEFORE_PHASES = 0.18f;
private const float DELAY_AFTER_BLOCKS = 0.05f;
private const float TIME_BONUS_TICK = 0.05f;
// On a win, hold on the VICTORY! banner + block bounce before the TIME BONUS line drops in.
private const float DELAY_VICTORY_HOLD = 0.45f;
// One coin counted out per tick — slower than the time bonus so short coin runs still read.
private const float COIN_TICK = 0.08f;
private const float FLOATER_LIFE = 0.9f;
private const float FLOATER_RISE = 22f; // logical px / sec
private const float FLOATER_DISTANCE = 30f; // offset from block centre toward the side
private static readonly Vector2 SCORE_WORLD_POS = new Vector2( Arena.WIDTH * 0.5f, Arena.HEIGHT * 0.88f );
public sealed class Floater
{
public Vector2 World;
public string Text;
public Color Color;
public float Age;
}
private readonly List<BlockResult> _blocks;
private readonly List<BlockResult> _tallyBlocks;
// Precise (un-rounded) elapsed game time in seconds; floored for the visible bonus, its
// sub-second remainder feeds the leaderboard tiebreaker (see OnTallyComplete).
private readonly float _gameTime;
private readonly RunContext _runContext;
private readonly bool _firstTutorialVictory;
private readonly bool _isDebugRun;
private readonly int _coins;
private readonly bool _levelWasBeatenOnEntry;
// This run's recorded payload (the local replay banked at submit time), so the footer's replay
// button can watch the run that was just played. Null when the run wasn't recorded.
private readonly RunData _runData;
// Set when this tally is being REBUILT on the way back from the leaderboard / this run's replay:
// the whole count-out is flushed silently in OnEnter so the player lands on the finished screen.
private readonly bool _skipAnimation;
// --- end-of-tally buttons (RESTART / CONTINUE) --------------------------------------
// Once the tally finishes we show a row of text buttons, driven by the same "smiling cube"
// selector the menus use. Most normal runs offer RESTART + CONTINUE; a first-time victory starts
// on CONTINUE, while losses and victories on previously beaten levels start on RESTART. Daily runs
// and the first Tutorial victory show CONTINUE only.
public enum ScoreButton { Restart, Continue }
private readonly List<ScoreButton> _buttons = new();
private readonly MenuSelectorAnim _buttonSelector = new();
private int _selectedButton;
/// <summary>Buttons shown at the bottom once the tally finishes (read by the Razor screen).</summary>
public IReadOnlyList<ScoreButton> Buttons => _buttons;
/// <summary>Index of the currently selected button.</summary>
public int SelectedButton => _selectedButton;
/// <summary>Selector slide position in button-index units (read by the Razor screen).</summary>
public float ButtonSelectorPos => _buttonSelector.Pos;
/// <summary>Selector sprite-sheet frame (0..6), read by the Razor screen.</summary>
public int ButtonSelectorFrame => _buttonSelector.Frame;
private readonly List<TallyDisplay> _displays = new();
private readonly List<Vector2> _positions = new();
private struct Step { public float Wait; public Action Act; }
private readonly List<Step> _steps = new();
private int _cursor;
private float _stepTimer;
private bool _fast;
// Fast-forward (pressing Confirm) flushes every remaining step in a single frame, which
// would otherwise fire all the queued tally sounds at once into a deafening pile. Cap how
// many tally sfx may play once fast-forwarding so the skip gives a single blip of feedback.
private const int FAST_SFX_BUDGET = 1;
private int _fastSfxBudget = FAST_SFX_BUDGET;
// State the Razor screen reads.
public int Score { get; private set; }
public bool Finished { get; private set; }
public string Banner { get; private set; }
/// <summary>The coin count-out line ("COINS: +n"), shown under <see cref="Banner"/> once the
/// coin group starts; empty for coin-less runs.</summary>
public string CoinBanner { get; private set; }
/// <summary>Elapsed scored run time in seconds. Stops when the run entered its game-over beat.</summary>
public float GameTime => _gameTime;
/// <summary>Whether this run completed every block.</summary>
public bool Victory { get; }
// True once a winning tally reveals the VICTORY! banner; the screen draws it one line above
// where the TIME BONUS banner appears.
public bool ShowVictory { get; private set; }
// Loss counterpart: true once a failed tally reveals the YOU DIED banner in the same slot.
public bool ShowDeath { get; private set; }
public IReadOnlyList<Floater> Floaters => _floaters;
public Vector2 ScoreWorldPos => SCORE_WORLD_POS;
/// <summary>The character this run was played as (null/unknown → Original), for the icon the
/// screen shows above the score.</summary>
public CharacterDef RunCharacter => Characters.Get( _runContext.CharacterId );
private readonly List<Floater> _floaters = new();
private ScoreScreen _screen;
public ScoreStage( GameManager manager, List<BlockResult> blocks, float gameTime, RunContext runContext = default,
bool firstTutorialVictory = false,
bool levelWasBeatenOnEntry = false, bool isDebugRun = false, int coinsCollected = 0,
RunData runData = null, bool skipAnimation = false ) : base( manager )
{
_blocks = blocks;
_tallyBlocks = blocks.FindAll( block => block.ScoreStartPhase < Block.NUM_PHASES - 1 );
_gameTime = gameTime;
_runContext = runContext == default ? RunContext.Normal : runContext;
_firstTutorialVictory = firstTutorialVictory;
_isDebugRun = isDebugRun;
_coins = coinsCollected;
_levelWasBeatenOnEntry = levelWasBeatenOnEntry;
_runData = runData;
_skipAnimation = skipAnimation;
// The first Tutorial victory omits restart. A daily run offers it only while the day is still
// today AND has attempts remaining (the restart
// consumes one; a DEBUG daily restarts freely) — DoRestart re-checks both live, so a score
// screen held across UTC midnight can't relaunch onto the closed day's board.
bool dailyCanRestart = _runContext.IsDaily && (_isDebugRun || DailyRestartAllowed( _runContext ));
if ( (!_runContext.IsDaily || dailyCanRestart) && !_firstTutorialVictory )
_buttons.Add( ScoreButton.Restart );
_buttons.Add( ScoreButton.Continue );
Victory = ScoreCalc.AllMaxPhase( _blocks );
if ( Victory && !levelWasBeatenOnEntry )
_selectedButton = _buttons.IndexOf( ScoreButton.Continue );
}
/// <summary>The tally sits on the dark transition fill grown over the game on game-over, so the
/// wipe square appears to persist as the background rather than revealing the playfield.</summary>
public override bool CoveredBackground => true;
protected override void OnEnter()
{
// The run's score + replay were already submitted by GameStage the moment the run was decided
// (the game-over beat ending, or an earlier restart/home bail after a death) — see
// GameStage.SubmitRunOnce. This stage is now purely the visual tally; it never submits.
var go = CreateUiRoot();
_screen = go.Components.Create<ScoreScreen>();
_screen.Stage = this;
BuildLayout();
BuildSteps();
_stepTimer = _steps.Count > 0 ? _steps[0].Wait : 0f;
if ( _steps.Count == 0 )
OnTallyComplete();
// Coming back from the leaderboard / this run's replay: play the whole tally out instantly and
// silently (no sfx budget, no leftover floaters) so we land exactly where we left — finished,
// with RESTART / CONTINUE waiting.
if ( _skipAnimation )
{
_fast = true;
_fastSfxBudget = 0;
AdvanceTally( 0f );
_floaters.Clear();
return;
}
Audio.PlaySfx( SfxType.EnterGame, Arena.Center );
}
// --- layout -------------------------------------------------------------------------
// The row shapes are driven by the TALLIED count (_tallyBlocks, not the level's block count — a
// block a level authored as already complete scores nothing and gets no row), so e.g. a six-block
// level whose four pins start maxed lays out as two. One or two blocks form a compact upper row,
// three blocks arc around the banner, and 4+ split over two rows. Five-block rows use the full
// arena width so their 40-unit sprites cannot overlap. The score always occupies the top lane.
private void BuildLayout()
{
float w = Arena.WIDTH, h = Arena.HEIGHT;
int count = _tallyBlocks.Count;
void AddRow( int rowCount, float y )
{
float edge = rowCount >= 5 ? 0.10f : 0.20f;
for ( int i = 0; i < rowCount; i++ )
{
float t = rowCount == 1 ? 0.5f : MathX.Lerp( edge, 1f - edge, i / (float)(rowCount - 1) );
_positions.Add( new Vector2( w * t, y ) );
}
}
if ( count == 1 )
{
_positions.Add( new Vector2( w * 0.50f, h * 0.68f ) );
}
else if ( count == 3 )
{
_positions.Add( new Vector2( w * 0.20f, h * 0.50f ) );
_positions.Add( new Vector2( w * 0.50f, h * 0.70f ) );
_positions.Add( new Vector2( w * 0.80f, h * 0.50f ) );
}
else if ( count == 2 )
{
_positions.Add( new Vector2( w * 0.38f, h * 0.68f ) );
_positions.Add( new Vector2( w * 0.62f, h * 0.68f ) );
}
else
{
AddRow( count / 2, h * 0.30f );
AddRow( count - count / 2, h * 0.70f );
}
for ( int i = 0; i < _tallyBlocks.Count; i++ )
{
// Blocks stay in their faded grey, eyes-closed look for the whole tally; only the
// side buttons being counted light up (handled by PressSide / SetSidesOut).
_displays.Add( new TallyDisplay( this, _tallyBlocks[i], _positions[i] ) );
}
}
// --- tally sequence -----------------------------------------------------------------
private void AddStep( float wait, Action act ) => _steps.Add( new Step { Wait = wait, Act = act } );
private void BuildSteps()
{
for ( int i = 0; i < _displays.Count; i++ )
{
int bi = i;
var br = _tallyBlocks[i];
// Begin this block's tally with a blip and a quick scale bounce so it's clear which block
// the sound refers to — otherwise a block with no sides pressed gives no visual cue at all.
// The body stays in its dim grey "asleep" look the whole time; progression is shown by the
// lit side buttons and, once a phase is completed, the block's real face (SetPhaseFace) —
// not by recolouring the body.
AddStep( DELAY_BEFORE_BLOCKS, () =>
{
TallySfx( SfxType.MenuBlip, _positions[bi] );
_displays[bi].StartBounce();
} );
for ( int phase = 0; phase <= br.Phase && phase < Block.NUM_PHASES; phase++ )
{
int ph = phase;
// Sides scored this phase: all four if the phase was fully completed, else the
// sides actually pressed when the run ended (shared with the submitted score via
// ScoreCalc.ScoredSides so the tally and the leaderboard number can't diverge).
var sides = ScoreCalc.ScoredSides( br, phase );
AddStep( DELAY_BEFORE_PHASES, () =>
{
// Pop the buttons back out for the new phase only if something was pressed in it.
// If the block reached phase 1 but pressed nothing, the four buttons that took it
// up stay depressed (so the progress reads instead of the buttons looking reset).
if ( ph == 1 && br.ScoreStartPhase < 1 && sides.Count > 0 ) _displays[bi].SetSidesOut( 1 );
} );
if ( phase == 1 )
AddStep( DELAY_BETWEEN_SIDES, null );
for ( int j = 0; j < sides.Count; j++ )
{
int jj = j;
var dir = sides[j];
bool completes = ScoreCalc.CompletesPhase( br, ph, jj, sides.Count );
AddStep( DELAY_BETWEEN_SIDES, () =>
{
if ( ph < Block.NUM_PHASES - 1 )
_displays[bi].PressSide( dir, ph );
int amount = ScoreCalc.SideAmount( ph, completes );
Score += amount;
AddFloater( _positions[bi], dir, amount, ph, completes );
TallySfx( CompleteSfx( ph, completes ), _positions[bi] );
// Completing a phase reveals the block's real face for the phase it just
// reached: phase-0 done -> phase-1 face; phase-1 done -> phase-2 face + max sides.
if ( completes )
{
if ( ph == 0 )
_displays[bi].SetPhaseFace( 1 );
else if ( ph == 1 )
{
_displays[bi].SetPhaseFace( 2 );
_displays[bi].SetSidesOut( 2 ); // all sides go to "max" yellow
}
}
} );
}
}
if ( br.Phase >= Block.NUM_PHASES - 1 )
AddStep( 0f, () => _displays[bi].SetSidesOut( 2 ) );
AddStep( DELAY_AFTER_BLOCKS, null );
}
// Time bonus only on a full win.
if ( Victory )
{
int bonus = ScoreCalc.TimeBonus( _gameTime );
// Win moment: once every block's sides are tallied, reveal VICTORY! (one line above
// where TIME BONUS will land), flip every block to its death face — the expression they
// wear in the live all-max win throes — and give them a quick celebratory scale bounce.
AddStep( DELAY_BEFORE_PHASES, () =>
{
ShowVictory = true;
TallySfx( SfxType.BlockPhaseReachMax, Arena.Center );
foreach ( var d in _displays )
{
d.SetDeathFace();
d.StartBounce();
}
} );
// Hold on the bounce, then drop in the TIME BONUS line under the VICTORY! banner.
AddStep( DELAY_VICTORY_HOLD, () => Banner = $"TIME BONUS: +{bonus}" );
int remaining = bonus;
while ( remaining > 0 )
{
int inc = Math.Max( (int)MathF.Floor( remaining * 0.25f ), 1 );
remaining -= inc;
int add = inc;
AddStep( TIME_BONUS_TICK, () =>
{
Score += add;
TallySfx( SfxType.AddScoreTimeBonus, Arena.Center );
} );
}
}
else
{
// Loss moment: same beat as the VICTORY! reveal, but a quiet YOU DIED in its place.
AddStep( DELAY_BEFORE_PHASES, () => ShowDeath = true );
}
// Coins, counted out one per step so the count AND the contribution visibly rise with the
// total. Appended last on purpose: after the time bonus on a victory, straight after the
// block sides otherwise. The final Score must equal ScoreCalc.Compute(blocks, time, coins) —
// the submitted value — which is why the per-coin add is the same COIN_SCORE constant.
if ( _coins > 0 )
{
AddStep( DELAY_VICTORY_HOLD, () =>
{
CoinBanner = "COINS: +0";
TallySfx( SfxType.MenuBlip, ScoreWorldPos );
} );
for ( int i = 1; i <= _coins; i++ )
{
int ci = i;
AddStep( COIN_TICK, () =>
{
Score += ScoreCalc.COIN_SCORE;
CoinBanner = $"COINS: +{ci}";
TallySfx( SfxType.CoinTally, ScoreWorldPos );
} );
}
}
}
private static SfxType CompleteSfx( int phase, bool completes )
{
if ( phase == 0 ) return completes ? SfxType.AddScorePhase0 : SfxType.AddScoreFinishPhase0;
return completes ? SfxType.AddScorePhase1 : SfxType.AddScoreFinishPhase1;
}
// Plays a tally sound, but once the player has fast-forwarded the remaining steps all run
// in one frame; allow only FAST_SFX_BUDGET of them through so they don't stack into noise.
private void TallySfx( SfxType type, Vector2 pos )
{
if ( _fast )
{
if ( _fastSfxBudget <= 0 ) return;
_fastSfxBudget--;
}
Audio.PlaySfx( type, pos );
}
private void AddFloater( Vector2 blockPos, Direction dir, int amount, int phase, bool completes )
{
_floaters.Add( new Floater
{
World = blockPos + Globals.GetVectorForDirection( dir ) * FLOATER_DISTANCE,
Text = $"+{amount}",
Color = FloaterColor( phase, completes ),
Age = 0f,
} );
}
private static Color FloaterColor( int phase, bool completes )
{
// Mirrors the original TallyScore swatches.
if ( phase == 0 )
return completes ? Rgb( 155, 165, 255 ) : Rgb( 115, 120, 208 ); // lighter blue / blue
// Phase-2 sides: the +20s take the reddish tint (matching the red buttons) and the final
// +40 takes the yellowish tint (matching the final max side button).
return completes ? Rgb( 221, 221, 153 ) : Rgb( 200, 90, 85 ); // yellow / red
}
private static Color Rgb( int r, int g, int b ) => new Color( r / 255f, g / 255f, b / 255f );
// --- update -------------------------------------------------------------------------
public override void Tick( float dt )
{
base.Tick( dt );
// The unlock reveal owns all input while it's up (GameManager eats the frame's menu edges for
// it, but the Restart key below is read straight from the engine), and the exit it was handed
// fires when it finishes fading. Keep the tally's cosmetics moving behind it.
if ( Manager.UnlockReveal.IsActive )
{
AgeFloaters( dt );
foreach ( var d in _displays )
d.TickBounce( dt );
return;
}
// The Restart key re-rolls a fresh run at any point during the score stage, matching the
// in-run R: dailies accept it while restartable (today + attempts remaining; debug freely).
// Results which intentionally offer Continue only ignore it.
if ( !IsFadingOut && !_firstTutorialVictory
&& (!_runContext.IsDaily || _isDebugRun || DailyRestartAllowed( _runContext ))
&& Input.Pressed( "Restart" ) )
{
DoRestart();
return;
}
if ( InputState.ConfirmJust )
{
Confirm();
if ( Finished ) return;
}
// Once the tally finishes, Up/Down move the selector between the bottom buttons.
if ( Finished && !IsFadingOut )
{
if ( InputState.NavDownJust ) NavigateButton( 1 );
if ( InputState.NavUpJust ) NavigateButton( -1 );
}
AdvanceTally( dt );
AgeFloaters( dt );
foreach ( var d in _displays )
d.TickBounce( dt );
_buttonSelector.Tick( dt, _selectedButton );
}
// --- unlock reveal --------------------------------------------------------------------
/// <summary>Pay off any "YOU UNLOCKED" reveal this run owes before leaving. Returns true when
/// <paramref name="exit"/> was handed to the reveal (the caller must then do nothing) — it runs
/// once the player has dismissed the overlay and it has faded out. False when nothing is owed and
/// the caller should leave as normal. The queue is persisted, so a reveal skipped here (the player
/// bailed out of the won run before the tally) is still delivered on the next menu screen.</summary>
private bool DeferForUnlock( Action exit )
// The IsActive arm is belt-and-braces: a reveal already on screen has its own queued exit, so
// swallow this one rather than leaving the tally out from under a live overlay.
=> Manager.UnlockReveal.IsActive || Manager.UnlockReveal.ShowPending( exit );
/// <summary>Fast-forward the tally, or activate the selected button once it has finished.</summary>
public void Confirm()
{
// A click landing while the reveal is up belongs to the reveal. Its own overlay swallows the
// click before this ever runs; this is the backstop.
if ( Manager.UnlockReveal.IsActive )
{
Manager.UnlockReveal.Dismiss();
return;
}
if ( Finished )
{
ActivateSelectedButton();
return;
}
if ( !_fast )
// One crisp "skip" blip as the player confirms, pitched up so it reads as a
// fast-forward rather than another tally tick. The flush's own sfx are throttled.
Audio.PlaySfx( SfxType.MenuBlip, Arena.Center, pitch: 1.4f );
_fast = true;
}
// --- end-of-tally button navigation -------------------------------------------------
/// <summary>Keyboard/pad navigation between the bottom buttons: move if possible, else wobble.</summary>
private void NavigateButton( int delta )
{
_selectedButton = MenuNav.Step( _selectedButton, delta, _buttons.Count, _buttonSelector );
}
/// <summary>Called by the screen on hover to sync the selected button (mirrors the menu screens).</summary>
public void SelectButton( int index )
{
int next = Math.Clamp( index, 0, _buttons.Count - 1 );
if ( next == _selectedButton )
return;
_selectedButton = next;
_buttonSelector.PlayMove();
Audio.PlaySfx( SfxType.MenuBlip );
}
/// <summary>Called by the screen on click: select the clicked button, then activate it.</summary>
public void ActivateButton( int index )
{
SelectButton( index );
ActivateSelectedButton();
}
private void ActivateSelectedButton()
{
if ( IsFadingOut )
return;
_buttonSelector.PlayWobble();
if ( _buttons[_selectedButton] == ScoreButton.Restart )
DoRestart();
else
Continue();
}
/// <summary>Whether a non-debug daily run may relaunch: the day must still be TODAY (live check —
/// see <see cref="GameManager.StartDailyChallenge"/>'s matching backstop) with attempts remaining.</summary>
private static bool DailyRestartAllowed( RunContext context )
=> context.DailyId == DailyChallenge.TodayId
&& DailyChallengeProgress.HasAttemptsRemaining( context.DailyId, DailyLevels.Get( context.DailyId )?.MaxAttempts );
/// <summary>Attempts line shown on a daily run's tally ("2/5 ATTEMPTS LEFT", or "UNLIMITED
/// ATTEMPTS" on a day with no limit); null on non-daily runs. With daily_debug_nav on this reads
/// the display-only debug counter instead of real progress (see
/// <see cref="DailyChallengeProgress.DisplayedAttemptsUsed"/>).</summary>
public string DailyAttemptsText
{
get
{
if ( !_runContext.IsDaily )
return null;
if ( DailyLevels.Get( _runContext.DailyId )?.MaxAttempts is not int max )
return "UNLIMITED ATTEMPTS";
int left = Math.Max( 0, max - DailyChallengeProgress.DisplayedAttemptsUsed( _runContext.DailyId ) );
return $"{left}/{max} ATTEMPTS LEFT";
}
}
/// <summary>Re-roll a fresh run on the same level. The run's score was already banked by
/// GameStage, so this just starts a new game. A daily restart re-guards and consumes one of the
/// day's attempts (see <see cref="GameManager.StartDailyChallenge"/>); a DEBUG daily restarts freely.</summary>
private void DoRestart()
{
if ( _firstTutorialVictory )
return;
if ( _runContext.IsDaily )
{
if ( !_isDebugRun && !DailyRestartAllowed( _runContext ) )
return;
// Show the unlock first; this same method re-runs (guards and all) after the fade.
if ( DeferForUnlock( DoRestart ) )
return;
Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
if ( _isDebugRun )
Manager.StartDailyChallengeDebug( _runContext.DailyId );
else
Manager.StartDailyChallenge( _runContext.DailyId );
return;
}
if ( DeferForUnlock( DoRestart ) )
return;
Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
// Carry the run's character forward so a per-run override (e.g. a repro ConCmd's forced
// character) survives a score-screen restart, matching the in-run R restart path.
Manager.StartLevel( _runContext.LevelId, _runContext.CharacterId );
}
/// <summary>Leave the tally: a daily run returns to the daily hub; a workshop run returns to the
/// workshop browser; a normal run returns straight to the level-select map it was launched from
/// (win or loss — the map's footer trophy button reaches the full leaderboard).</summary>
private void Continue()
{
if ( DeferForUnlock( Continue ) )
return;
Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
FadeToStage( _runContext.IsDaily
? new DailyChallengeStage( Manager, _runContext.DailyId )
: _runContext.Level?.IsWorkshop == true
? new WorkshopStage( Manager )
: new LevelSelectStage( Manager ) );
}
// --- footer shortcuts (leaderboard / this run's replay) -------------------------------
/// <summary>Snapshot of everything this tally was built from, so a screen opened from it can
/// rebuild it (already counted out) when the player backs out.</summary>
public ScoreReturn Snapshot() => new ScoreReturn
{
Blocks = _blocks,
GameTime = _gameTime,
Context = _runContext,
FirstTutorialVictory = _firstTutorialVictory,
LevelWasBeatenOnEntry = _levelWasBeatenOnEntry,
IsDebugRun = _isDebugRun,
Coins = _coins,
RunData = _runData,
};
/// <summary>Whether the footer's trophy button is offered: this run has a real board behind it
/// (daily runs read their board on the daily page, debug runs never submit).</summary>
public bool CanViewLeaderboard => Finished && !_runContext.IsDaily && !_isDebugRun;
/// <summary>Whether the footer's replay button is offered — the run we just played was recorded.</summary>
public bool CanWatchReplay => Finished && _runData?.CanReplay == true;
/// <summary>The Steam Workshop page of the level just played (footer thumbs-up link); null unless
/// this was a workshop level and the tally has finished.</summary>
public string WorkshopItemUrl => Finished ? WorkshopLevels.ItemPageUrlFor( _runContext.Level?.Id ) : null;
/// <summary>Trophy button: open this level's full board. BACK there returns to this tally.</summary>
public void ViewLeaderboard()
{
if ( IsFadingOut || !CanViewLeaderboard )
return;
if ( DeferForUnlock( ViewLeaderboard ) )
return;
Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
FadeToStage( new HighscoreStage( Manager, levelId: _runContext.Level?.Id, returnScore: Snapshot() ) );
}
/// <summary>Replay button: watch the run that was just played (its recorded payload, attributed to
/// us). Exiting the replay returns to this tally.</summary>
public void WatchReplay()
{
if ( IsFadingOut || !CanWatchReplay )
return;
if ( DeferForUnlock( WatchReplay ) )
return;
Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
long steamId = (long)Game.SteamId;
Manager.StartReplay( _runData, new ReplaySubmitter( steamId, new Friend( steamId ).Name, "", DateTimeOffset.UtcNow ),
returnScore: Snapshot(), returnToScore: true );
}
private void AdvanceTally( float dt )
{
if ( _cursor >= _steps.Count ) return;
_stepTimer -= _fast ? 1000f : dt;
while ( _cursor < _steps.Count && _stepTimer <= 0f )
{
_steps[_cursor].Act?.Invoke();
_cursor++;
_stepTimer += _cursor < _steps.Count ? _steps[_cursor].Wait : 0f;
}
if ( _cursor >= _steps.Count && !Finished )
OnTallyComplete();
}
private void OnTallyComplete()
{
Finished = true;
}
private void AgeFloaters( float dt )
{
for ( int i = _floaters.Count - 1; i >= 0; i-- )
{
var f = _floaters[i];
f.Age += dt;
f.World = new Vector2( f.World.x, f.World.y + FLOATER_RISE * dt );
if ( f.Age >= FLOATER_LIFE )
_floaters.RemoveAt( i );
}
}
// --- one block's display sprites ----------------------------------------------------
public sealed class TallyDisplay
{
private readonly GameObject _go;
private readonly SpriteRenderer _face, _mouth, _eyes, _eyebrows;
private readonly Dictionary<Direction, SpriteRenderer> _sides = new();
private static readonly Vector2 SIZE = new Vector2( 40, 40 );
// Quick scale "punch" played on the victory reveal: snap up to BOUNCE_PEAK over BOUNCE_UP,
// then ease back to 1x over BOUNCE_DOWN. _bounceTime < 0 means no bounce is running.
private const float BOUNCE_PEAK = 1.15f;
private const float BOUNCE_UP = 0.05f;
private const float BOUNCE_DOWN = 0.10f;
private float _bounceTime = -1f;
public TallyDisplay( ScoreStage stage, BlockResult br, Vector2 pos )
{
var go = stage.CreateChild( $"Tally_{br.Type}" );
_go = go;
go.WorldPosition = new Vector3( MathF.Round( pos.x ), MathF.Round( pos.y ), Globals.DepthToZ( Globals.DEPTH_BLOCK ) );
// Start in the faded "asleep" look: flat, dim greyscale phase-0 shapes with closed eyes
// (baked tally_* anims). The fade is baked into the grey art and fully opaque, so the
// lit/coloured pressed buttons read against it without any background bleeding through.
_face = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_face", childOrder: 0 );
_mouth = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_mouth", childOrder: 2 );
_eyes = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_eyes", childOrder: 3 );
_eyebrows = SpriteLayer.Add( go, br.SpritePath, SIZE, "tally_eyebrows", childOrder: 4 );
foreach ( var d in Globals.GetAllDirections() )
_sides[d] = SpriteLayer.Add( go, "sprites/sides.sprite", SIZE, $"out0dark_{Globals.GetStringForDirection( d )}", childOrder: 1 );
// Authored starting progress the run began with: open on the phase it was authored at with
// its pre-pressed buttons already down, so the tally only counts out what the player earned.
// A block authored as already complete gets no display at all (see ScoreStage._tallyBlocks),
// so ScoreStartPhase here is always below max phase.
if ( br.ScoreStartPhase > 0 )
{
SetPhaseFace( br.ScoreStartPhase );
SetSidesOut( br.ScoreStartPhase );
}
foreach ( var d in Globals.GetAllDirections() )
if ( br.WasPressedAtScoreStart( d ) ) PressSide( d, br.ScoreStartPhase );
}
/// <summary>Pop every side out for the given phase (0/1) or max (2) — plays the pop-out
/// animation so the replay mirrors the live block's buttons.</summary>
public void SetSidesOut( int phase )
{
string add = phase >= 2 ? "maxadd" : phase == 1 ? "out1darkadd" : "out0darkadd";
foreach ( var d in Globals.GetAllDirections() )
_sides[d].PlayAnimation( $"{add}_{Globals.GetStringForDirection( d )}" );
}
public void PressSide( Direction dir, int phase )
{
string state = phase == 0 ? "pressed0" : "pressed1";
_sides[dir].PlayAnimation( $"{state}_{Globals.GetStringForDirection( dir )}" );
}
/// <summary>Reveal the block's real, full-colour face for a phase it has reached (replacing
/// the faded grey tally art). The eyes use a fixed forward ("down") gaze so the look matches
/// the static row graphic (sprites/ui/blocks/{type}_p{phase}.png).</summary>
public void SetPhaseFace( int phase )
{
_face.PlayAnimation( $"face_{phase}" );
_mouth.PlayAnimation( $"mouth_{phase}" );
_eyes.PlayAnimation( $"eyes_down_{phase}" );
_eyebrows.PlayAnimation( $"eyebrows_{phase}" );
}
/// <summary>Switch to the death face shown once a winning tally completes: the maxed
/// (phase-2) face with the dead eyes/mouth/brows (mirrors <see cref="Block.Die"/>).</summary>
public void SetDeathFace()
{
_face.PlayAnimation( "face_2" );
_eyes.PlayAnimation( "eyes_dead" );
_mouth.PlayAnimation( "mouth_dead" );
_eyebrows.PlayAnimation( "eyebrows_dead" );
}
/// <summary>Kick off the quick victory scale punch (see BOUNCE_* constants).</summary>
public void StartBounce() => _bounceTime = 0f;
/// <summary>Advance the scale punch; a no-op once the bounce has finished (or never started).</summary>
public void TickBounce( float dt )
{
if ( _bounceTime < 0f ) return;
_bounceTime += dt;
float scale;
if ( _bounceTime < BOUNCE_UP )
scale = MathX.Lerp( 1f, BOUNCE_PEAK, _bounceTime / BOUNCE_UP );
else if ( _bounceTime < BOUNCE_UP + BOUNCE_DOWN )
scale = MathX.Lerp( BOUNCE_PEAK, 1f, (_bounceTime - BOUNCE_UP) / BOUNCE_DOWN );
else
{
scale = 1f;
_bounceTime = -1f; // done
}
_go.LocalScale = scale;
}
}
}
/// <summary>
/// Everything a finished <see cref="ScoreStage"/> was built from. Carried by the screens the tally's
/// footer buttons open (the full leaderboard, and this run's replay) so their BACK rebuilds the tally
/// with its count-out already played out — the player lands back on RESTART / CONTINUE.
/// </summary>
public sealed class ScoreReturn
{
public List<BlockResult> Blocks;
public float GameTime;
public RunContext Context;
public bool FirstTutorialVictory;
public bool LevelWasBeatenOnEntry;
public bool IsDebugRun;
public int Coins;
public RunData RunData;
public ScoreStage Create( GameManager manager ) => new ScoreStage( manager, Blocks, GameTime, Context,
FirstTutorialVictory, LevelWasBeatenOnEntry, IsDebugRun, Coins, RunData, skipAnimation: true );
}