UI/GameUi.razor

Razor UI component that routes which top-level game panels are shown: main menu, HUD, pause, settings, and game-over. It tracks a GameSession reference, exposes static Current for console commands, logs hovered panels for debugging, and gates input when settings is open.

Networking
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits PanelComponent

@*
	The screen router. Everything the player sees that is not the arena itself routes through
	here: the main menu, the live HUD, the pause and game-over overlays, and settings.

	Settings is deliberately not a GameState - GameState is a simulation concept (what the run
	is doing) and "is the settings screen open" is a presentation concept layered on top of it,
	reachable from both the main menu and pause without disturbing either. Closing it returns
	to whichever of the two was underneath, because this class never changed which one that was.
*@

<root class="game-ui">

	@if ( Session is null )
	{
		<div class="warning">No GameSession component found in the scene.</div>
	}
	else if ( settingsOpen )
	{
		<SettingsPanel Session=@Session CloseRequested=@CloseSettings></SettingsPanel>
	}
	else if ( Session.State == GameState.MainMenu )
	{
		<MainMenuPanel Session=@Session SettingsRequested=@OpenSettings></MainMenuPanel>
	}
	else
	{
		<HudPanel Session=@Session></HudPanel>

		@if ( Session.State == GameState.Paused )
		{
			<PausePanel Session=@Session SettingsRequested=@OpenSettings></PausePanel>
		}
		else if ( Session.State == GameState.GameOver && ShowGameOverCard )
		{
			<GameOverPanel Session=@Session></GameOverPanel>
		}
	}

</root>

@code
{
	[Property] public GameSession Session { get; set; }

	private bool settingsOpen;

	/// <summary>
	/// The live router, so the console command below can reach it. Set in
	/// <see cref="OnEnabled"/> rather than <c>OnStart</c>, which runs once per lifetime and
	/// would leave this null forever on a component that was disabled and re-enabled.
	/// </summary>
	public static GameUi Current { get; private set; }

	protected override void OnEnabled() => Current = this;

	protected override void OnDisabled()
	{
		// Cleared on the way out, or a panel disabled while settings was up would leave the
		// session ignoring the keyboard with no screen on top to explain why.
		SetSettingsOpen( false );

		if ( Current == this ) Current = null;
	}

	protected override void OnStart()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
	}

	/// <summary>
	/// Logs what the cursor is actually over, so "the buttons do not work" can be diagnosed
	/// rather than guessed at.
	/// <para>
	/// Hover is the honest test: a panel only gets <see cref="Panel.HasHovered"/> if the engine's
	/// hit-testing reached it, which means the cursor is live, the pointer-events chain lets the
	/// event through, and the panel is where it looks. A screenshot can show none of that. Turn
	/// it on with <c>cg_hoverlog 1</c> and move the cursor over a button.
	/// </para>
	/// </summary>
	private static bool logHover;

	private int seenHoverCount = -1;

	[ConCmd( "cg_hoverlog" )]
	public static void HoverLog( int enable )
	{
		logHover = enable != 0;

		Log.Info( logHover
			? "cg_hoverlog: on - move the cursor over a button and watch the console."
			: "cg_hoverlog: off." );
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		if ( !logHover || Panel is null ) return;

		var hovered = 0;

		foreach ( var panel in Panel.Descendants )
		{
			if ( panel.HasHovered ) hovered++;
		}

		// Only on a change, or this floods the console every frame.
		if ( hovered == seenHoverCount ) return;

		seenHoverCount = hovered;

		Log.Info( $"cg_hoverlog: hovering {hovered} panel(s) at {Mouse.Position}, " +
			$"cursor active={Mouse.Active}" );
	}

	private void OpenSettings() => SetSettingsOpen( true );

	private void CloseSettings() => SetSettingsOpen( false );

	/// <summary>
	/// Shows or hides settings, and tells the session to stop reading the keyboard while it is
	/// up. Both happen here so the screen and the input gate cannot disagree.
	/// </summary>
	private void SetSettingsOpen( bool open )
	{
		settingsOpen = open;

		if ( Session is not null ) Session.ModalOpen = open;
	}

	/// <summary>
	/// Opens or closes the settings screen from the console: <c>cg_ui_settings 1</c>. Whether
	/// settings is up is presentation state rather than a <see cref="GameState"/>, so there is
	/// otherwise no way to reach this screen without a mouse - which is what automated
	/// verification has.
	/// <para>
	/// It lives in this panel's own code block rather than in <c>DebugCommands</c> because the
	/// headless build does not compile Razor, and a reference to this type from there would
	/// break it.
	/// </para>
	/// </summary>
	[ConCmd( "cg_ui_settings" )]
	public static void ShowSettings( int open )
	{
		if ( Current is null )
		{
			Log.Info( "cg_ui_settings: no GameUi in the scene. Is play mode started?" );
			return;
		}

		Current.SetSettingsOpen( open != 0 );
		Log.Info( $"cg_ui_settings: settings screen is {(Current.settingsOpen ? "open" : "closed")}." );
	}

	/// <summary>
	/// The game-over card is held back for a beat so the death animation is not covered by a
	/// panel before it has played. The run is already over and every key still works, so this
	/// delays only the picture of the news, never the player's ability to act on it.
	/// </summary>
	private bool ShowGameOverCard => Session is not null
		&& Session.StateAge >= GameConfig.GameOverCardDelay;

	/// <summary>
	/// Only what decides which screen is up. The values each screen actually shows - the
	/// score, the records, the settings sliders - live in that screen's own BuildHash, so
	/// this does not rebuild the whole tree every time one of them changes.
	/// <para>
	/// <see cref="ShowGameOverCard"/> is folded in as the boolean rather than as the age it
	/// derives from, so the tree rebuilds once when the card is due instead of every frame
	/// while it is pending.
	/// </para>
	/// </summary>
	protected override int BuildHash() => System.HashCode.Combine(
		Session?.State, settingsOpen, ShowGameOverCard );
}