Stages/TitleStage.cs
namespace BlockParty;
/// <summary>
/// Title screen: the game's entry point. Port of the original <c>TitleStage</c> menu —
/// "BLOCK PARTY" with PLAY / DAILY CHALLENGE / LEADERBOARDS / REPLAYS / EDITOR / OPTIONS / QUIT.
/// The title name is drawn from the original CP437 bitmap font (see <see cref="TitleScreen"/>);
/// the options are a horizontal row of square icon buttons sized by importance, peaking at the
/// centred PLAY (see <see cref="DisplayOrder"/>), with a text label under the row naming the selection.
/// Navigation logic lives here; the panel is a thin view bound to <c>SelectedIndex</c> /
/// <c>FadeAlpha</c>.
/// </summary>
public sealed class TitleStage : MenuStageBase
{
public readonly string[] Options = { "PLAY", "DAILY CHALLENGE", "LEADERBOARDS", "REPLAYS", "EDITOR", "OPTIONS", "QUIT", "WORKSHOP & EDITOR" };
public int SelectedIndex { get; private set; }
public const int PlayOptionIndex = 0;
public const int DailyOptionIndex = 1;
public const int LeaderboardsOptionIndex = 2;
public const int LocalReplaysOptionIndex = 3;
public const int EditorOptionIndex = 4;
public const int SettingsOptionIndex = 5;
public const int QuitOptionIndex = 6;
public const int WorkshopOptionIndex = 7;
/// <summary>Left-to-right on-screen button order: PLAY (the largest) dead centre,
/// WORKSHOP+DAILY at its left hand, LEADERBOARDS/REPLAYS at its right, OPTIONS alone on the
/// far-left wing and QUIT alone on the far-right one. EDITOR left this row — it lives on the
/// workshop screen now (creations are made to be published). The two sides mirror in total width
/// (360px each around PLAY) and in tier: DAILY/LEADERBOARDS both primary, OPTIONS/QUIT tertiary
/// with the same 24px group gap (see the SCSS), so the hero sits on the exact centreline.
/// Keyboard nav and the razor's render loop both walk THIS order, not the Options index
/// order.</summary>
public static readonly int[] DisplayOrder =
{
SettingsOptionIndex,
WorkshopOptionIndex, DailyOptionIndex, PlayOptionIndex, LeaderboardsOptionIndex, LocalReplaysOptionIndex,
QuitOptionIndex,
};
/// <summary>Whether today's daily challenge has never been launched — LOCAL attempts file only
/// (one launched run counts, even with attempts left). Drives the button's attention flash.</summary>
public bool DailyUnattempted => !DailyChallengeProgress.IsAttempted( DailyChallenge.TodayId );
/// <summary>Which of the calendar icon's 16 date dots carries today's unattempted mark: row-major
/// cell (top-left → bottom-right), advancing one per day and wrapping, so the mark walks the whole
/// calendar over 16 days. Derived from <see cref="DailyChallenge.TodayId"/> so it uses the daily's
/// own UTC day boundary and every player sees the mark on the same dot. Day count comes from
/// Ticks — the sandbox whitelist doesn't allow DateOnly.</summary>
public static int DailyMarkCell => DailyChallenge.TryParseId( DailyChallenge.TodayId, out var date )
? (int)(date.Ticks / System.TimeSpan.TicksPerDay % 16)
: 0;
// Hold-to-repeat nav, shared cadence with the lists/options. The options are a horizontal row
// of image buttons (no selector cube), so the primary axis is A/D — see Tick.
private readonly NavRepeater _nav = new();
private TitleScreen _screen;
public TitleStage( GameManager manager ) : base( manager )
{
}
protected override void OnEnter()
{
Audio.PlayMusic();
SpawnBackgroundBlocks();
var go = CreateUiRoot();
_screen = go.Components.Create<TitleScreen>();
_screen.Stage = this;
}
public override void Tick( float dt )
{
base.Tick( dt );
// Ignore navigation once we've committed to a stage change (matches original's exit lock).
if ( !IsFadingOut )
{
// The buttons are a left-to-right row: A/D is the natural axis, but W/S keeps working
// (old muscle memory from the text list) — up = previous, down = next. Both hold-to-repeat.
int step = _nav.Tick( dt, InputState.Left || InputState.NavUp, InputState.Right || InputState.NavDown );
if ( step != 0 )
{
Navigate( step );
// Landed on (or tapped against) the end of the row: stop the held repeat so holding
// toward no button doesn't keep re-firing the nav blip (same rule as the level map's
// dead ends). A release or reversal re-arms it.
int pos = Array.IndexOf( DisplayOrder, SelectedIndex );
if ( pos + step < 0 || pos + step >= DisplayOrder.Length )
_nav.SuppressRepeat();
}
if ( InputState.ConfirmJust ) Activate();
}
}
/// <summary>Keyboard/pad navigation: step through the on-screen (DisplayOrder) positions, not
/// the Options indices — left/right must match what the eye sees. Blips either way; no cube.</summary>
private void Navigate( int delta )
{
int pos = Array.IndexOf( DisplayOrder, SelectedIndex );
SelectedIndex = DisplayOrder[MenuNav.Step( pos, delta, DisplayOrder.Length )];
}
/// <summary>Called by the panel on hover/click to sync the highlighted option.</summary>
public void SelectOption( int index )
{
// Ignore hover once we've committed to a stage change (same exit lock as keyboard nav in
// Tick) — mousing across rows during the fade-out would move the highlight and play blips.
if ( IsFadingOut )
return;
int next = Math.Clamp( index, 0, Options.Length - 1 );
if ( next == SelectedIndex )
return;
SelectedIndex = next;
Audio.PlaySfx( SfxType.MenuBlip );
}
/// <summary>Activate the current option (Confirm key or click).</summary>
public void Activate()
{
if ( IsFadingOut )
return;
Audio.PlaySfx( SfxType.MenuStart );
Haptics.MenuConfirm();
switch ( SelectedIndex )
{
case PlayOptionIndex: FadeToStage( new LevelSelectStage( Manager ) { FadeInMusic = true } ); break;
case DailyOptionIndex: FadeToStage( new DailyChallengeStage( Manager ) ); break;
case LeaderboardsOptionIndex: FadeToStage( new HighscoreStage( Manager ) ); break;
case LocalReplaysOptionIndex: FadeToStage( new LocalReplaysStage( Manager ) ); break;
case EditorOptionIndex: LevelEditorStage.OpenLevelEditor(); break;
case SettingsOptionIndex: FadeToStage( new OptionsStage( Manager ) ); break;
case QuitOptionIndex: Game.Close(); break;
case WorkshopOptionIndex: FadeToStage( new WorkshopStage( Manager ) ); break;
}
}
/// <summary>Editor button (bottom-left letterbox, editor-only): open the level editor.
/// Locked during the exit wipe like every other input path.</summary>
public void ClickEditor()
{
if ( IsFadingOut )
return;
Audio.PlaySfx( SfxType.MenuStart, 0.7f );
LevelEditorStage.OpenLevelEditor( "" );
}
}