The end-of-match UI component displays final scores, standings, and detailed scoring breakdowns. It handles state transitions for different game endings, including standard completion and inspection modes.
@namespace CitySim
@using CitySim.DesignSystem
@using CitySim.Match
@using CitySim.Punchboard
@inherits MatchPanel
<root class="end-stage @(City.EndInspect ? "inspecting" : "end-results")">
@if (City.EndInspect) {
<div class="return-results"><PlotterButton CssClass="primary" Text=@(GameText.Get("ui.end_panel.back_to_results")) Clicked=@(() => City.SetEndInspect(false)) /></div>
}
else if (score != null) {
<div class="pb-end-layout" style=@($"max-width: {PlayerPreferences.SurfacePixels(1440)};")><PlotterSurface Title=@(GameText.Get("ui.end_panel.final_scores")) Kind=@(PbPanelKind.Raised) Fill=@true><ChildContent>
<div class="pb-end-content">
<div class="pb-end-heading"><div><div class="pb-end-title">@Outcome</div><div class="end-subtitle">@Subtitle</div></div>
@if (!Complete) { <PlotterButton CssClass="primary" Text=@(GameText.Get("ui.end_panel.skip_reveal")) Kind=@(PbButtonKind.Secondary) Clicked=@Skip /> }</div>
<div class="end-standings">@foreach (var player in OrderedPlayers) {
<PlotterButton CssClass=@($"standing {(player.Player == selected ? "chosen" : "")} {(Complete && score.Winners.Contains(player.Player) ? "winner" : "")}") Kind=@(PbButtonKind.Ghost) Clicked=@(() => Select(player.Player))>
<ChildContent><div class="standing-content"><div class="standing-name">@(GameText.Plain($"{(Complete && score.Winners.Contains(player.Player) ? "🏆 " : "")}{player.Name}{(player.Player == CityGame.Me ? GameText.Get("ui.end_panel.you") : "")}"))</div><div class="standing-total">@(GameText.Get("ui.end_panel.score_vp", ShownScore(player)))</div><div class="standing-detail">@(Complete ? GameText.Get("ui.end_panel.spare_coins", player.Coins) : GameText.Get("ui.end_panel.vp", player.Lines.Where(l=>Category(l)==beat).Sum(l=>l.Vp), BeatName))</div></div></ChildContent>
</PlotterButton>
}</div>
<div class="end-breakdown"><div class="breakdown-heading">@(GameText.Plain($"{SelectedPlayer?.Name} · {(Complete ? GameText.Get("ui.end_panel.score_breakdown") : BeatName)}"))</div>
<div class="end-score-scroll">
@if (SelectedPlayer is { } chosen) {
@foreach (var line in VisibleLines(chosen).Where(l=>l.Vp!=0 || !Complete)) {
<div class="end-score-row"><div class="end-score-copy"><div class="score-reason">@(GameText.Plain(line.Reason))</div>@if (!string.IsNullOrEmpty(line.Detail)) { <div class="score-explanation">@(GameText.Plain(line.Detail))</div> }</div><div class="score-amount @(line.Vp < 0 ? "loss" : "")">@(GameText.Get("ui.end_panel.signed_vp", line.Vp))</div></div>
}
@if (Complete && chosen.Lines.Any(l=>l.Vp==0)) {
<PlotterButton Text=@(showZeros ? GameText.Get("ui.end_panel.hide_unscored_awards") : GameText.Get("ui.end_panel.show_unscored_awards")) Size=@(PbSize.Small) Kind=@(PbButtonKind.Ghost) Clicked=@(() => { showZeros=!showZeros; Refresh(); }) />
@if (showZeros) { @foreach (var line in chosen.Lines.Where(l=>l.Vp==0)) {<div class="end-score-row"><div class="end-score-copy"><div>@(GameText.Plain(line.Reason))</div><div class="score-explanation">@(GameText.Plain(line.Detail))</div></div><div class="score-amount">@(GameText.Get("ui.end_panel.0_vp"))</div></div> } }
}
}
</div>
</div>
<div class="pb-end-actions">
@if (Complete) {
@if (!City.Multiplayer) { <PlotterButton CssClass="primary" Text=@(GameText.Get("ui.end_panel.play_again")) Clicked=@(() => City.RequestStart(GameMode.Match)) /> }
else if (City.Session.IsHost) { <PlotterButton CssClass="primary" Text=@(GameText.Get("ui.end_panel.back_to_lobby")) Clicked=@(() => City.Session.Rematch()) /> }
else { <div>@(GameText.Get("ui.end_panel.waiting_for_the_host_to_return_to_the_lobby"))</div> }
<PlotterButton Text=@(GameText.Get("ui.end_panel.inspect_city")) Kind=@(PbButtonKind.Secondary) Clicked=@(() => City.SetEndInspect(true)) />
}
<PlotterButton Text=@(City.Multiplayer ? GameText.Get("ui.contract_pick_panel.leave_match") : GameText.Get("ui.end_panel.main_menu")) Kind=@(PbButtonKind.Ghost) Clicked=@(() => City.RequestMenu()) />
</div>
</div></ChildContent></PlotterSurface></div>
}
</root>
@code {
EndScore score;
Guid selected;
int beat;
float elapsed;
bool celebrated, showZeros;
readonly Dictionary<Guid,float> counters = new();
static readonly string[] Competitions = { "#rules.final_scoring.longest_road", "#rules.final_scoring.biggest_park_owner", "#rules.final_scoring.full_house" };
static int ContractBeat => Competitions.Length+2;
static int CoinBeat => Competitions.Length+3;
static int CompleteBeat => Competitions.Length+4;
bool Complete => beat >= CompleteBeat;
PlayerScore SelectedPlayer => score?.Players.FirstOrDefault(p=>p.Player==selected);
IEnumerable<PlayerScore> OrderedPlayers => Complete ? score.Players : City.Match.Players.Keys.Select(id=>score.Players.First(p=>p.Player==id));
string BeatName => beat==0 ? GameText.Get("ui.end_panel.earned_during_the_match") : beat==1 ? GameText.Get("ui.end_panel.buildings_penalties") : beat>=2 && beat<ContractBeat ? GameText.Plain(Competitions[beat-2]) : beat==ContractBeat ? GameText.Get("ui.end_panel.secret_contracts") : beat==CoinBeat ? GameText.Get("rules.final_scoring.spare_coins") : GameText.Get("ui.end_panel.final_standings");
string Outcome => !Complete ? beat==0 ? GameText.Get("ui.end_panel.rounds_complete", City.Match.Settings.Rounds) : BeatName : score.Winners.Count>1 ? GameText.Get("ui.end_panel.a_shared_victory") : score.Winners.Contains(CityGame.Me) ? GameText.Get("ui.end_panel.you_win") : GameText.Get("ui.end_panel.wins", score.Players.First(p=>score.Winners.Contains(p.Player)).Name);
string Subtitle {
get {
if (!Complete) return beat==0 ? GameText.Get("ui.end_panel.the_city_is_built_let_s_count_the_scores") : GameText.Get("ui.end_panel.adding_each_player_s_final_bonuses");
var own=score.Players.FirstOrDefault(p=>p.Player==CityGame.Me);
var winner=score.Players.First();
var tie=score.Players.Any(p=>!score.Winners.Contains(p.Player) && p.TotalVp==winner.TotalVp);
var place=own==null ? GameText.Get("ui.end_panel.final_standings") : GameText.Get("ui.end_panel.your_score_vp_place", own.TotalVp, 1+score.Players.Count(p=>p.TotalVp>own.TotalVp || p.TotalVp==own.TotalVp && p.Coins>own.Coins));
return place+(tie ? GameText.Get("ui.end_panel.spare_coins_decided_the_vp_tie") : score.Winners.Count>1 ? GameText.Get("ui.end_panel.equal_vp_and_spare_coins") : "");
}
}
static int Category(ScoreLine line) {
if (line.Reason=="#rules.final_scoring.earned_during_match" || line.Reason.StartsWith("#rules.final_scoring.public_goal")) return 0;
var competition=Array.IndexOf(Competitions,line.Reason); if (competition>=0) return competition+2;
if (line.SecretContract) return ContractBeat;
return line.Reason=="#rules.final_scoring.spare_coins" ? CoinBeat : 1;
}
IEnumerable<ScoreLine> VisibleLines(PlayerScore p) => p.Lines.Where(l=>Complete || Category(l)<=beat);
int Target(PlayerScore p) => VisibleLines(p).Sum(l=>l.Vp);
int ShownScore(PlayerScore p) => (int)MathF.Round(counters.GetValueOrDefault(p.Player,Target(p)));
protected override void Subscribe() { if (City!=null) City.UiChanged+=Refresh; if (BoundMatch!=null) BoundMatch.Events.MatchScored+=Reset; Reset(BoundMatch?.FinalScore); }
protected override void Unsubscribe() { if (City!=null) City.UiChanged-=Refresh; if (BoundMatch!=null) BoundMatch.Events.MatchScored-=Reset; }
void Reset(EndScore result) {
score=result; beat=0; elapsed=0; celebrated=false; showZeros=false; counters.Clear();
selected=score?.Players.FirstOrDefault(p=>p.Player==CityGame.Me)?.Player ?? score?.Players.FirstOrDefault()?.Player ?? Guid.Empty;
if (score!=null) foreach (var p in score.Players) counters[p.Player]=Target(p);
Refresh();
}
void Select(Guid id) { selected=id; showZeros=false; Refresh(); }
void Finish() { if (celebrated) return; celebrated=true; City.Feedback.CelebrateEnd(score.Winners.Contains(CityGame.Me)); City.Scene.GetAllComponents<Hud>().FirstOrDefault()?.Refocus(); }
void Skip() { beat=CompleteBeat; foreach (var p in score.Players) counters[p.Player]=p.TotalVp; Finish(); Refresh(); }
public override void Tick() {
base.Tick(); if (score==null) return;
elapsed+=Time.Delta;
var next=Math.Min(CompleteBeat,(int)(elapsed/1.1f));
if (!Complete && next!=beat) { beat=next; if (Complete) Finish(); else City.Feedback.EndRevealBeat(beat==ContractBeat); Refresh(); }
foreach (var p in score.Players) {
var before=ShownScore(p); var target=Target(p);
counters[p.Player]=Complete ? target : counters[p.Player]+(target-counters[p.Player])*(1-MathF.Exp(-12*Time.Delta));
if (before!=ShownScore(p)) Refresh();
}
if (!City.Feedback.ReduceFlashing) {
var phaseAge=Complete ? elapsed-CompleteBeat*1.1f : elapsed%1.1f;
var t=Math.Clamp(phaseAge/0.35f,0,1);
foreach (var standing in Descendants.Where(p=>p.HasClass("standing")))
standing.Style.Set("transform",FormattableString.Invariant($"scale({1+0.035f*MathF.Sin(t*MathF.PI):0.###})"));
}
}
}