Stages/DailyChallengeStage.cs
namespace BlockParty;
/// <summary>Daily challenge hub: play today's fixed-seed run once, browse previous UTC days, and watch
/// leaderboard replays for the selected day.</summary>
public sealed class DailyChallengeStage : MenuStageBase
{
/// <summary>DEBUG: unlock paging into future (and pre-earliest) days to preview the generator
/// from the hub. Toggled by the daily_debug_nav ConCmd; never ships enabled.</summary>
public static bool DebugNavigation { get; set; }
public string DailyId { get; }
public bool LockedPickerAlreadyVisible { get; }
private DailyChallengeScreen _screen;
private string _pendingDayId;
private float _pendingDayTimer;
private const float LockedPickerExitTime = 0.14f;
public DailyChallengeStage( GameManager manager, string dailyId = null, bool lockedPickerAlreadyVisible = false ) : base( manager )
{
// With debug navigation on, any well-formed day id sticks (a ScoreStage return from a debug
// future-day run would otherwise silently snap to today).
DailyId = DebugNavigation && DailyChallenge.TryParseId( dailyId ?? "", out _ )
? dailyId
: DailyChallenge.ClampId( dailyId );
LockedPickerAlreadyVisible = lockedPickerAlreadyVisible;
}
public override bool CoveredBackground => true;
public bool IsToday => DailyId == DailyChallenge.TodayId;
public bool IsFuture => DailyChallenge.IsFuture( DailyId );
public bool CanGoPrevious => DebugNavigation || !DailyChallenge.IsBeforeEarliest( DailyChallenge.AddDays( DailyId, -1 ) );
public string DisplayDate => DailyChallenge.DisplayDate( DailyId );
public int Seed => DailyChallenge.SeedFor( DailyId );
/// <summary>The day's generated challenge.</summary>
public DailyDef Daily => DailyLevels.Get( DailyId );
protected override void OnEnter()
{
// Preview the displayed day's song (paging days re-enters the stage, so this covers both).
// Starting the run plays the same track, and Audio.PlayMusic keeps a playing song going when
// the path matches — so PLAY doesn't restart it. TitleStage restores the menu song on BACK.
Audio.PlayLevelMusic( Daily?.Level );
var go = CreateUiRoot();
_screen = go.Components.Create<DailyChallengeScreen>();
_screen.Stage = this;
if ( Game.IsEditor && Daily is DailyDef daily )
Log.Info( $"[BlockParty] daily_show {DailyId}: {DailyDebugCommands.Describe( daily )}" );
}
public override void Tick( float dt )
{
base.Tick( dt );
if ( _screen?.TrophyStandingsOpen == true )
{
if ( InputState.BackJust ) _screen.CloseTrophyStandings();
return;
}
if ( _pendingDayId is not null )
{
_pendingDayTimer -= dt;
if ( _pendingDayTimer <= 0f )
{
var next = _pendingDayId;
_pendingDayId = null;
FadeToStage( new DailyChallengeStage( Manager, next ) );
}
return;
}
// A/D always page the day; Confirm activates the focused zone (PLAY/WATCH, a row, or MAIN MENU
// — the screen owns that). W/S selection lives in the screen.
if ( InputState.LeftJust ) PreviousDay();
if ( InputState.RightJust ) NextDay();
if ( InputState.ConfirmJust ) _screen?.ActivateFocused();
if ( InputState.BackJust ) GoBack();
}
// Pitched-down selector-move blip played when A/D (or an arrow click) actually pages the day.
private const float DayNavBlipPitch = 0.8f;
public void PreviousDay()
{
if ( _pendingDayId is not null ) return;
var prev = DailyChallenge.AddDays( DailyId, -1 );
if ( !DebugNavigation && DailyChallenge.IsBeforeEarliest( prev ) )
{
Audio.PlaySfx( SfxType.Error, pitch: 0.7f ); // already at the earliest day
return;
}
Audio.PlaySfx( SfxType.MenuBlip, 1f, DayNavBlipPitch );
Haptics.MenuBlip();
ChangeDay( prev );
}
public void NextDay()
{
if ( _pendingDayId is not null ) return;
var next = DailyChallenge.AddDays( DailyId, 1 );
if ( !DebugNavigation && DailyChallenge.IsFuture( next ) )
{
Audio.PlaySfx( SfxType.Error, pitch: 0.75f ); // already at today (can't page into the future)
return;
}
Audio.PlaySfx( SfxType.MenuBlip, 1f, DayNavBlipPitch );
Haptics.MenuBlip();
ChangeDay( next );
}
private void ChangeDay( string nextId )
{
bool currentLocked = Daily?.Level.ForcedCharacterId is not null;
bool nextLocked = DailyLevels.Get( nextId )?.Level.ForcedCharacterId is not null;
if ( currentLocked && !nextLocked )
{
_screen?.BeginLockedPickerExit();
_pendingDayId = nextId;
_pendingDayTimer = LockedPickerExitTime;
return;
}
FadeToStage( new DailyChallengeStage( Manager, nextId,
lockedPickerAlreadyVisible: currentLocked && nextLocked ) );
}
public void Play()
{
if ( !IsToday || IsFuture )
return;
// Local attempt gate (offline / fast path): the day's generated limit vs the local counter.
var daily = Daily;
if ( daily is null || !DailyChallengeProgress.HasAttemptsRemaining( DailyId, daily.MaxAttempts ) )
return;
// On a SINGLE-attempt day the daily board stays the authoritative lock (it survives the
// player deleting the local file). On multi/unlimited-attempt days board membership just
// means "has a score", so it can't gate here.
if ( daily.MaxAttempts == 1 && _screen?.LocalPlayerOnBoard == true )
return;
Manager.StartDailyChallenge( DailyId );
}
/// <summary>DEBUG: play this day's generated level regardless of the date — never submits or
/// counts attempts, but banks a local replay (see <see cref="GameStage.IsDebugRun"/>). Only
/// reachable while <see cref="DebugNavigation"/> is on (daily_debug_nav).</summary>
public void DebugPlay()
{
if ( !DebugNavigation )
return;
Manager.StartDailyChallengeDebug( DailyId );
}
public void GoBack() => FadeToStage( new TitleStage( Manager ) );
}