Ui/Hud.razor

The main UI component for the game, responsible for rendering the Heads-Up Display and managing various menu screens based on the current game state. It handles rendering elements for the start menu, lobby, match, pause, and settings.

Networking
@namespace CitySim
@using CitySim.DesignSystem
@using Sandbox.UI
@using CitySim.Match
@using CitySim.Punchboard
@inherits PanelComponent

<root class="@(City?.Screens.Top is GameScreen.Menu or GameScreen.GameSetup or GameScreen.ContractPick || City?.Screens.Top == GameScreen.Settings && City?.State == GameState.Menu ? "menu-screen" : "")">
	@if (City is not null)
	{
		@if (City.State == GameState.Playing && City.Match?.Phase == MatchPhase.Bid && !MarketPeeking) { <div class="market-backdrop"></div> }
		@if (City.State == GameState.End && !City.EndInspect) { <div class="end-backdrop"></div> }
		<div class="safe-area">
			@if (City.Screens.BaseScreen==GameScreen.Lobby) { <LobbyPanel City=@City /> }
			else if (City.State == GameState.Menu && City.Screens.Top!=GameScreen.GameSetup)
			{
				<div class="center-stage">
					<div class="menu start-menu" style=@($"max-width: {PlayerPreferences.SurfacePixels(1420)};")><PlotterSurface Kind=@(PbPanelKind.Raised) Corner=@true><ChildContent><div class="start-layout"><div class="menu-content start-content">
						<div class="start-brand"><div class="start-logo"></div><div class="start-tagline">@(GameText.Get("ui.hud.one_city_plenty_of_plans"))</div></div>
						<div class="start-sections">
							<div class="start-section solo-section">
								<div class="start-section-title">@(GameText.Get("ui.hud.single_player"))</div>
								<div class="start-description">@(GameText.Get("ui.hud.build_a_city_with_ai_rivals"))</div>
								<PlotterButton CssClass="primary start-play" Text=@(GameText.Get("ui.hud.play")) Clicked=@(() => { City.OpenGameSetup(); }) />
							</div>
							<div class="start-section multiplayer-section">
								<div class="start-section-title">@(GameText.Get("ui.hud.multiplayer"))</div>
								<PlotterButton CssClass="start-host" Text=@(GameText.Get("ui.hud.host_game")) Kind=@(PbButtonKind.Secondary) Clicked=@(() => { City.Session.Host(); }) />
								<div class="start-join">
									<div class="start-field-label">@(GameText.Get("ui.hud.join_a_game"))</div>
									<TextEntry @ref=JoinEntry Placeholder=@(GameText.Get("ui.hud.host_steam_id_or_address")) MaxLength=@(180) onsubmit=@JoinLobby />
									<PlotterButton CssClass="start-host" Text=@(GameText.Get("ui.hud.join")) Kind=@(PbButtonKind.Secondary) Clicked=@JoinLobby />
								</div>
								@if (City.Session?.Status is { } status) { <div class="start-status">@status</div> }
							</div>
						</div>
						<div class="start-footer">
							<PlotterButton Text=@(GameText.Get("ui.hud.settings")) Kind=@(PbButtonKind.Ghost) Size=@(PbSize.Small) Clicked=@(() => { City.OpenSettings(); }) />
							<PlotterButton Text=@(GameText.Get("ui.hud.sandbox")) Kind=@(PbButtonKind.Ghost) Size=@(PbSize.Small) Clicked=@(() => { City.RequestStart(GameMode.Sandbox); }) />
						</div>
					</div><div class="start-city"><div class="start-city-label">@(GameText.Get("ui.hud.one_shared_city"))</div></div></div></ChildContent></PlotterSurface></div>
				</div>
			}
			else
			{
				@if (City.State == GameState.Playing && City.Match?.Phase != MatchPhase.Lobby && City.Screens.Top != GameScreen.ContractPick) {
					<PlotterButton CssClass="pause-action global-action" Text=@(GameText.Get("ui.hud.pause")) Kind=@(PbButtonKind.Tertiary) Size=@(PbSize.Small) Clicked=@(() => { City.TogglePause(); }) />
					<GoalsPanel City=@City />
					@if (City.Match?.Phase != MatchPhase.Bid) { <div class="top-row">
						<div class="right-column"><ResourcePanel City=@City />@if (City.Match?.Players.Count>1) { <ScoreboardPanel City=@City /> }</div>
					</div><RoundBar City=@City /> }
				}
				<div class="board-space"></div>
				@if (City.State == GameState.Playing && City.Match?.Phase != MatchPhase.Lobby && City.Screens.Top != GameScreen.ContractPick && City.Match?.Phase == MatchPhase.Bid) { <BidPanel City=@City Peeking=@MarketPeeking TogglePeek=@ToggleMarketPeek /> }
				@if (City.State == GameState.Playing && City.Match?.Phase != MatchPhase.Lobby && City.Screens.Top != GameScreen.ContractPick && City.Match?.Phase != MatchPhase.Bid) { <HandBar City=@City /> }
				@if (City.Mode==GameMode.Match && City.Match!=null && City.Match.Phase != MatchPhase.Bid && City.Screens.Top==GameScreen.Match && !PlayerPreferences.Current.HintsDismissed) { <OnboardingPanel City=@City /> }
				@if (City.Screens.Top == GameScreen.ContractPick) { <ContractPickPanel City=@City /> }
				@if (City.State == GameState.End)
				{
					<EndPanel City=@City />
				}
				@if (City.Screens.Top == GameScreen.Pause)
				{
					<div class="overlay"><div class="menu" style=@($"max-width: {PlayerPreferences.SurfacePixels(1080)};")><PlotterSurface Title=@(GameText.Get("ui.hud.pause_2")) Kind=@(PbPanelKind.Raised)><ChildContent><div class="menu-content">
						<div class="title">@(GameText.Get("ui.hud.take_a_breather"))</div>@if (City.Multiplayer) { <div class="subtitle">@(GameText.Get("ui.hud.the_multiplayer_clock_keeps_running"))</div> }
						<PlotterButton Text=@(GameText.Get("ui.hud.resume")) Clicked=@(() => { City.TogglePause(); }) />
						<PlotterButton Text=@(GameText.Get("ui.hud.settings")) Kind=@(PbButtonKind.Secondary) Clicked=@(() => { City.OpenSettings(); }) />
						<PlotterButton Text=@(GameText.Get("ui.end_panel.main_menu")) Kind=@(PbButtonKind.Ghost) Clicked=@(() => { City.RequestMenu(); }) />
					</div></ChildContent></PlotterSurface></div></div>
				}
			}
			@if (City.Screens.Top==GameScreen.Recovery)
			{
				<div class="overlay"><div class="menu" style=@($"max-width: {PlayerPreferences.SurfacePixels(1080)};")><PlotterSurface Title=@(GameText.Get("ui.hud.match_recovery")) Kind=@(PbPanelKind.Raised)><ChildContent><div class="menu-content"><div class="title">@(GameText.Get("ui.hud.could_not_restore_the_match"))</div><div class="subtitle">@(GameText.Plain(City.Session.RecoveryError))</div><PlotterButton Text=@(GameText.Get("ui.contract_pick_panel.leave_match")) Clicked=@(() => { City.RequestMenu(); }) /></div></ChildContent></PlotterSurface></div></div>
			}
		</div>
		<BoardOverlay City=@City /><FeedbackPanel City=@City />
		@if (City.Screens.Top==GameScreen.Settings) { <SettingsPanel City=@City /> }
		@if (City.Screens.Top==GameScreen.GameSetup) { <GameSetupPanel City=@City /> }
	}
</root>

@code {
	CityGame City => CityGame.Instance;
	CityGame bound;
	string renderedLanguage;
	int renderedUiScale = -1;
	GameScreen focusedScreen = (GameScreen)(-1);
	int focusedOpponentCount;
	MatchPhase? focusedPhase;
	bool focusPending;
	int lastControlIndex;
	TextEntry JoinEntry { get; set; }
	void JoinLobby() => City.Session.Join(JoinEntry?.Text);
	public static bool PointerOverUi { get; private set; }
	public static bool UiControlFocused { get; private set; }
	public static bool ControlsMode { get; private set; }
	public static bool MarketPeeking { get; private set; }
	void ToggleMarketPeek()
	{
		if (City?.Screens.Top != GameScreen.Match || City.Match?.Phase != MatchPhase.Bid) return;
		if (!MarketPeeking) City.PinLastInteractionHighlight(); else City.UnpinInteractionHighlight();
		MarketPeeking = !MarketPeeking;
		ControlsMode = false;
		FocusBoard();
		if (!MarketPeeking) focusPending = true;
		Refresh();
	}
	void ToggleControls() { ControlsMode=!ControlsMode; if (ControlsMode) focusPending=true; else FocusBoard(); Refresh(); }
	float HudScale => PlayerPreferences.UiScale;
	void ApplyScale() { var screen=Components.Get<ScreenPanel>(); if (screen.IsValid() && screen.Scale != HudScale) screen.Scale=HudScale; }
	protected override void OnStart() { ApplyScale(); Bind(); }
	void Bind() { if (bound == City) return; if (bound != null) bound.UiChanged -= Refresh; bound = City; if (bound != null) bound.UiChanged += Refresh; Refresh(); }
	void Refresh() => StateHasChanged();
	public void Refocus() { focusPending=true; }
	protected override void OnUpdate()
	{
		if (renderedLanguage != GameText.LanguageCode || renderedUiScale != PlayerPreferences.Current.UiScale) { renderedLanguage = GameText.LanguageCode; renderedUiScale = PlayerPreferences.Current.UiScale; Refresh(); }
		// Reapply after hotload as well as startup so an existing scene cannot retain the old oversized HUD.
		ApplyScale();
		if (bound == null) Bind();
		if (City?.Match?.Phase != MatchPhase.Bid && MarketPeeking) { MarketPeeking=false; City?.UnpinInteractionHighlight(true); Refresh(); }
		if (City?.Screens.Top==GameScreen.Match && City.Match?.Phase==MatchPhase.Bid && Input.Pressed("ToggleMarket")) { ToggleMarketPeek(); Input.Clear("ToggleMarket"); }
		if (City?.Screens.Top!=GameScreen.Match && ControlsMode) { ControlsMode=false; Refresh(); }
		if (City?.Screens.Top==GameScreen.Match && Input.Pressed("InspectControls")) { ToggleControls(); Input.Clear("InspectControls"); }
		PointerOverUi = (City?.Screens.Top is GameScreen.Menu or GameScreen.GameSetup or GameScreen.Lobby or GameScreen.ContractPick or GameScreen.Pause or GameScreen.Settings or GameScreen.Recovery) || (City?.Screens.Top==GameScreen.End && !City.EndInspect) || (Panel?.Descendants.Any(p => p.HasHovered && (IsControl(p) || p.HasClass("goals-panel") || p.HasClass("hand-slot"))) ?? false);
		UiControlFocused = Panel?.Descendants.Any(p=>p.HasFocus && IsControl(p) && !p.HasClass("hand-card")) ?? false;
		if (Input.Pressed("PauseGame")) City?.TogglePause();
		if (City?.EndInspect==true && Input.Pressed("CancelCard")) { City.SetEndInspect(false); Input.Clear("CancelCard"); }
		if (Input.Pressed("Goals") && City?.Screens.Top is GameScreen.Match or GameScreen.Goals) City.ToggleGoals();
		if (Input.Pressed("DismissHint") && City?.Screens.Top==GameScreen.Match && !PlayerPreferences.Current.HintsDismissed) City.DismissHints();
		if (Input.Pressed("CancelCard") && City?.Screens.Top==GameScreen.ContractPick) { City.TogglePause(); Input.Clear("CancelCard"); }
		if (City?.Screens.Top == GameScreen.Match && !ControlsMode && !MarketPeeking && Input.Pressed("ReadyRound")) City?.Ready();
		if (focusPending) FocusScreen();
		NavigateMenus();
	}
	protected override void OnTreeBuilt()
	{
		if (City != null && (focusedScreen != City.Screens.Top || focusedPhase != City.Match?.Phase || City.Screens.Top==GameScreen.GameSetup && focusedOpponentCount!=City.GameSetup.OpponentCount)) focusPending = true;
	}
	void FocusScreen()
	{
		if (City == null) return;
		focusedOpponentCount=City.GameSetup.OpponentCount;
		var buttons = Panel.Descendants.Where(IsControl).ToList();
		bool InOverlay(Panel panel) { for (var p = panel.Parent; p != null; p = p.Parent) if (p.HasClass("overlay")) return true; return false; }
		var overlay = City.Screens.Top is GameScreen.Pause or GameScreen.Settings or GameScreen.GameSetup or GameScreen.End or GameScreen.ContractPick or GameScreen.Recovery;
		bool InGoals(Panel panel) { for (var p = panel.Parent; p != null; p = p.Parent) if (p.HasClass("goals-panel")) return true; return false; }
		bool InBid(Panel panel) { for (var p = panel.Parent; p != null; p = p.Parent) if (p.HasClass("bid-stage")) return true; return false; }
		var eligible = buttons.Where(b => EnabledControl(b) && (overlay ? InOverlay(b) : City.Screens.Top == GameScreen.Goals ? InGoals(b) : City.Match?.Phase == MatchPhase.Bid && !ControlsMode ? InBid(b) || b.HasClass("global-action") : (!InGoals(b) || b.HasClass("goals-trigger")))).ToList();
		foreach (var b in buttons) { b.AcceptsFocus = eligible.Contains(b); b.TabIndex = b.AcceptsFocus ? eligible.IndexOf(b)+1 : -1; }
		if (City.Screens.Top == GameScreen.Match && City.Match?.Phase != MatchPhase.Bid && !ControlsMode)
		{
			FocusBoard(); focusedScreen = City.Screens.Top; focusedPhase = City.Match?.Phase; focusPending = false;
			return;
		}
		var initial = City.Screens.Top == GameScreen.Match && !ControlsMode ? eligible.FirstOrDefault(b => b.HasClass("hand-card")) : eligible.FirstOrDefault(b => b.HasClass("primary"));
		initial ??= eligible.FirstOrDefault();
		if (initial == null) return;
		initial.Focus(); focusedScreen = City.Screens.Top; focusedPhase = City.Match?.Phase; focusPending = false;
	}
	public void FocusBoard() { foreach (var p in Panel.Descendants.Where(p => p.HasFocus)) p.Blur(); UiControlFocused=false; }
	static bool IsControl(Panel p) => p is Button or TextEntry or PbButton or PlotterButton or StandingRow or PbCard or PlotterCard or PlotterCardReference or PbToggle or PbSlot || p.HasClass("tag-chip") || p.HasClass("interaction-chip") || p.HasClass("effect-icon");
	static int PanelDepth(Panel panel) { var depth=0; for (var p=panel.Parent; p!=null; p=p.Parent) depth++; return depth; }
	static bool EnabledControl(Panel p) => !p.IsDeleting && p.IsVisible && p switch { PbButton b => !b.Disabled, PlotterButton b => !b.Disabled, PbCard c => !c.Disabled, PlotterCard c => !c.Disabled, PbSlot s => !s.Empty, _ => !p.HasClass("disabled") };
	void NavigateMenus()
	{
		if (City?.Screens.Top is not (GameScreen.Menu or GameScreen.GameSetup or GameScreen.Pause or GameScreen.Settings or GameScreen.Lobby or GameScreen.End or GameScreen.Recovery or GameScreen.Goals) && !(City?.Screens.Top==GameScreen.Match && ControlsMode)) return;
		if (ControlsMode && City.Screens.Top==GameScreen.Match && Input.Pressed("CancelCard")) { ToggleControls(); Input.Clear("CancelCard"); return; }
		var controls=Panel.Descendants.Where(p=>IsControl(p) && p.AcceptsFocus && EnabledControl(p)).OrderBy(p=>p.TabIndex).ToList();
		// HasFocus also applies to ancestors. Route confirmation to the deepest focused control.
		var focused=controls.Where(p=>p.HasFocus).OrderByDescending(PanelDepth).FirstOrDefault();
		if (ControlsMode && controls.Count>0 && focused==null) { lastControlIndex=Math.Clamp(lastControlIndex,0,controls.Count-1); controls[lastControlIndex].Focus(); return; }
		if (ControlsMode && focused!=null) lastControlIndex=controls.IndexOf(focused);
		if (focused is TextEntry)
		{
			if (Input.Pressed("CancelCard")) { focused.Blur(); controls.FirstOrDefault(p=>p is not TextEntry)?.Focus(); Input.Clear("CancelCard"); }
			return;
		}
		var direction=Input.Pressed("CursorDown") ? 1 : Input.Pressed("CursorUp") ? -1 : 0;
		if (direction!=0 && controls.Count>0) { var index=controls.IndexOf(focused); lastControlIndex=(index+direction+controls.Count)%controls.Count; controls[lastControlIndex].Focus(); }
		if (Input.Pressed("ConfirmCard"))
		{
			if (focused is PbButton button) button.Activate();
			else if (focused is PlotterButton plotterButton) plotterButton.Activate();
			else if (focused is StandingRow standing) standing.Activate();
			else if (focused is PbToggle toggle) toggle.ValueChanged?.Invoke(!toggle.Value);
			else if (focused is PbCard card) card.Clicked?.Invoke();
			else if (focused is PlotterCard plotterCard) plotterCard.Activate();
			else if (focused is PbSlot slot && !slot.Empty) slot.Inspected?.Invoke();
			Input.Clear("ConfirmCard");
		}
		if (Input.Pressed("CancelCard") && City.Screens.Top!=GameScreen.Menu) { City.TogglePause(); Input.Clear("CancelCard"); }
	}
	protected override void OnDestroy() { if (bound != null) bound.UiChanged -= Refresh; PointerOverUi = false; UiControlFocused=false; ControlsMode=false; MarketPeeking=false; }
}