Game/UnlockReveal.cs

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.

File Access
using System;

namespace BlockParty;

/// <summary>
/// The "YOU UNLOCKED &lt;character&gt;" 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 );
	}
}