Ui/EndPanel.razor

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.###})"));
  }
 }
}