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.
@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; }
}