UI helper that manages showing a "YOU UNLOCKED <character>" overlay. It tracks the current CharacterDef being revealed, enforces a short undismissable lockout, handles dismiss input, fades out over FADE_TIME, marks the unlock consumed, and invokes an optional callback when all pending reveals are shown.
using System;
namespace BlockParty;
/// <summary>
/// The "YOU UNLOCKED <character>" reveal. Owned by <see cref="GameManager"/> and drawn by the
/// persistent <see cref="UnlockRevealOverlay"/> screen panel, so it can sit over ANY stage and
/// outlive the one that started it.
///
/// Reveals are owed, not fired-and-forgotten: <see cref="CharacterProgress.Unlock"/> queues the
/// character (persisted — see <c>GameSettings.PendingUnlockRevealIds</c>) and it stays queued until
/// actually shown and dismissed. Normally the score tally pays that debt on its way out
/// (<see cref="ScoreStage"/> hands us the exit it was about to take, and we run it once the reveal
/// has faded); if the player instead abandons a won run — or quits the game — before the tally, the
/// next menu screen shows it (see <c>GameManager.TickUnlockReveal</c>).
///
/// Dismissing plays a sound and fades the overlay out over <see cref="FADE_TIME"/> rather than
/// cutting it, and only then hands control back.
/// </summary>
public sealed class UnlockReveal
{
/// <summary>How long the overlay takes to fade away once dismissed.</summary>
public const float FADE_TIME = 1.2f;
// The reveal interrupts whatever the player was doing — a Space held to pick CONTINUE, a click
// aimed at the map — so it refuses to be dismissed for a beat after appearing. Without this the
// press that triggered the reveal (or the next mash of it) throws it away before it's been read.
private const float DISMISS_LOCKOUT = 0.35f;
/// <summary>The character currently being revealed, or null when nothing is on screen.</summary>
public CharacterDef Character { get; private set; }
/// <summary>True while the overlay is up (including its fade-out). While this holds, the frame's
/// menu input is consumed by the reveal and the screen behind it must not act on anything.</summary>
public bool IsActive => Character is not null;
// Seconds into the dismissal fade; negative while the overlay is still held at full opacity.
private float _fade = -1f;
// Seconds the current character has been on screen (see DISMISS_LOCKOUT).
private float _age;
private Action _onFinished;
/// <summary>Overlay opacity: 1 until dismissed, then eased to 0 over <see cref="FADE_TIME"/>.</summary>
public float Alpha => _fade < 0f ? 1f : Math.Clamp( 1f - _fade / FADE_TIME, 0f, 1f );
/// <summary>Start revealing the oldest character still owed one. Returns false (having done
/// nothing) when nothing is owed or a reveal is already up — callers use that to mean "carry on
/// as normal". <paramref name="onFinished"/> runs once EVERY owed reveal has been dismissed and
/// faded out, which is how the score tally defers the exit the player asked for.</summary>
public bool ShowPending( Action onFinished = null )
{
if ( IsActive || CharacterProgress.PeekPendingReveal() is not CharacterDef character )
return false;
Begin( character );
_onFinished = onFinished;
return true;
}
/// <summary>Dismiss the reveal: mark it seen, play the confirm sound and start the fade-out.
/// Ignored while already fading, or for <see cref="DISMISS_LOCKOUT"/> after it appears. Driven by
/// any dismiss input and by clicking the overlay.</summary>
public void Dismiss()
{
if ( !IsActive || _fade >= 0f || _age < DISMISS_LOCKOUT )
return;
// Consumed the moment the player acknowledges it (not when the fade ends), so quitting during
// those two seconds doesn't re-announce the same character on the next launch.
CharacterProgress.ConsumePendingReveal( Character.Id );
_fade = 0f;
Audio.PlaySfx( SfxType.MenuStart, Arena.Center );
}
/// <summary>Advance the reveal one rendered frame. The caller consumes the frame's input edges
/// afterwards so the stage underneath can't act on the same press.</summary>
public void Tick( float dt )
{
if ( !IsActive )
return;
_age += dt;
if ( _fade < 0f )
{
if ( InputState.PopupDismissJust )
Dismiss(); // self-guards on the lockout, so an early press is ignored, not queued
return;
}
_fade += dt;
if ( _fade < FADE_TIME )
return;
// Faded out. More owed (two wins abandoned back to back)? Reveal them one at a time before
// releasing the caller, so the queued exit still happens exactly once, at the very end.
if ( CharacterProgress.PeekPendingReveal() is CharacterDef next )
{
Begin( next );
return;
}
Character = null;
_fade = -1f;
var finished = _onFinished;
_onFinished = null;
finished?.Invoke();
}
private void Begin( CharacterDef character )
{
Character = character;
_fade = -1f;
_age = 0f;
// The same flourish the tally's VICTORY! reveal uses, so an unlock lands as part of that beat.
Audio.PlaySfx( SfxType.BlockPhaseReachMax, Arena.Center );
}
}