Ui/BidPanel.razor

The component displays the auction bidding panel UI for a match. It manages player status, auction lot selection, and displays timers and auction summaries during the bidding phase.

@namespace CitySim
@using CitySim.DesignSystem
@using CitySim.Cards
@using CitySim.Match
@using CitySim.Punchboard
@inherits MatchPanel

<root class="bid-stage @(Peeking ? "peeking" : "")">
	@if (View is { } view)
	{
		@if (Peeking) { <div class="peek-toolbar">@if (City.HasInteractionHighlight) { <div class="peek-status">@City.HighlightStatus</div> }<PlotterButton CssClass="return-market" Text=@(BoundMatch.PhaseEndsAt >= 0 && !view.Resolving ? GameText.Get("ui.bid_panel.market_s", Seconds) : GameText.Get("ui.bid_panel.market")) Clicked=@TogglePeek /></div> }
		else { <div class="auction-sheet" style=@($"max-width: {PlayerPreferences.SurfacePixels(1900)}; height: {PlayerPreferences.SurfacePixels(820)};")><PlotterSurface Title=@(GameText.Get("ui.bid_panel.market_progress", view.Round, BoundMatch.Settings.Rounds)) Kind=@(PbPanelKind.Raised) Fill=@true><ChildContent><div class="bid-window">
			<div class="bid-heading"><div class="market-heading-copy"><div class="bid-title">@(GameText.Get("ui.bid_panel.choose_next_build", view.Number, view.Cap))</div></div><div class="heading-actions"><div class="bid-budget">@($"🪙 {view.AvailableCoins}")</div><PlotterButton Text=@(GameText.Get("ui.bid_panel.view_city")) Kind=@(PbButtonKind.Ghost) Size=@(PbSize.Small) Clicked=@TogglePeek /></div></div>
			<div class="auction-table">
				<div class="player-rail"><div class="rail-title">@(GameText.Get("ui.bid_panel.at_the_table"))</div>
				@for (var i=0; i<view.Players.Count; i++) { var p=view.Players[i]; var seat=i; <div class="ready-pip seat-@seat"><div class="seat-token" style="background-color: @Pb.Rgba(PlayerColours.For(BoundMatch,p.Player))">@(seat+1)</div><div class="player-name">@(GameText.Plain(p.Name))</div><div class="player-status">@(!p.Eligible ? "—" : p.Ready ? p.HasBid ? "✓" : "—" : "○")</div></div> }
				<div class="rail-legend"><div>@(GameText.Get("ui.bid_panel.nbsp_choosing"))</div><div>@(GameText.Get("ui.bid_panel.nbsp_purchased"))</div><div>@(GameText.Get("ui.bid_panel.nbsp_passed_sitting_out"))</div></div></div>
				<div class="auction-lots">@foreach (var bid in view.Lots) { <AuctionLotPanel City=@City Auction=@view Bid=@bid SkipReveal=@(skipRevealMarket == view.Id) Winner=@Winner(bid.Lot.Index) Selected=@(bid.Lot.Index == selectedLot) Picked=@((int index) => SelectLot(index)) /> }</div>
			</div>
			<div class="bid-footer">
			@if (view.Resolving) { <div class="auction-summary">@Summary</div> }
			else if (!view.Eligible) { @if (BoundMatch.PhaseEndsAt >= 0) { <div class="auction-timer @(Seconds <= 3 ? "urgent" : "")">@(GameText.Get("ui.bid_panel.seconds", Seconds))</div> } <div class="auction-summary">@(GameText.Get("ui.bid_panel.sitting_out"))</div> }
			else {
				@if (BoundMatch.PhaseEndsAt < 0 && view.Ready) { <PlotterButton Text=@(GameText.Get("ui.bid_panel.hurry_up")) Kind=@(PbButtonKind.Secondary) Clicked=@(() => { City.HurryAuction(view.Id); }) /> }
				else if (BoundMatch.PhaseEndsAt >= 0) { <div class="auction-timer @(Seconds <= 3 ? "urgent" : "")">@(GameText.Get("ui.bid_panel.seconds", Seconds))</div> }
				<PlotterButton Text=@(GameText.Get("ui.bid_panel.pass")) [email protected] Clicked=@(() => { City.Ready(); }) />
			}
			</div>
		</div></ChildContent></PlotterSurface></div> }
	}
</root>

@code {
	[Parameter] public bool Peeking { get; set; }
	[Parameter] public Action TogglePeek { get; set; }
	AuctionView View;
	AuctionResult result;
	int selectedLot, seenId = -1;
	readonly Dictionary<Guid,float> pipPop = new();
	bool focusLot, wasPeeking;
	int skipRevealMarket = -1;
	protected override void OnParametersSet()
	{
		if (wasPeeking && !Peeking) skipRevealMarket = View?.Id ?? -1;
		wasPeeking = Peeking;
		base.OnParametersSet();
	}
	int seconds = -1, lastHighlightCount = -1;
	float introAge = 1;
	int introRound = -1;
	int Seconds => Math.Max(0,(int)Math.Ceiling((BoundMatch?.PhaseEndsAt ?? 0)-City.Clock));
	AuctionWin Winner(int lot) => result?.AuctionId == View?.Id ? result.Wins.FirstOrDefault(w => w.Lot == lot) : null;
	string Summary => result?.Wins.FirstOrDefault(w => w.Player == CityGame.Me) is { } purchase ? GameText.Plain($"✓ {CardLibrary.Get(purchase.CardId).Name}") : GameText.Get("ui.bid_panel.passed");
	protected override void Subscribe() {
		if (BoundMatch != null) { BoundMatch.Events.LotsRevealed += Revealed; BoundMatch.Events.BidChanged += Changed; BoundMatch.Events.BidStatusChanged += Changed; BoundMatch.Events.PlayerReadyChanged += Changed; BoundMatch.Events.AuctionResolved += Resolved; BoundMatch.Events.PhaseChanged += Phase; }
		Read();
	}
	protected override void Unsubscribe() {
		if (BoundMatch != null) { BoundMatch.Events.LotsRevealed -= Revealed; BoundMatch.Events.BidChanged -= Changed; BoundMatch.Events.BidStatusChanged -= Changed; BoundMatch.Events.PlayerReadyChanged -= Changed; BoundMatch.Events.AuctionResolved -= Resolved; BoundMatch.Events.PhaseChanged -= Phase; }
	}
	void Read() { View = City?.OwnAuction; result=City?.LastAuction; if (View?.Id != seenId) { seenId = View?.Id ?? -1; selectedLot = 0; if (View != null && introRound != View.Round) { introRound = View.Round; introAge = 0; } } Refresh(); }
	void Revealed(int id) { result = null; focusLot = true; if (introRound != BoundMatch.Round) { introRound = BoundMatch.Round; introAge = 0; } Read(); }
	void SelectLot(int index) { selectedLot = index; Refresh(); }
	void Changed(Guid owner) { Read(); if (View?.Players.FirstOrDefault(p => p.Player == owner)?.Ready == true) pipPop[owner] = 0; }
	void Resolved(AuctionResult value) { result = value; Read(); }
	void Phase(PhaseChange value) => Read();
	public override void Tick()
	{
		base.Tick();
		if (Peeking && City != null && lastHighlightCount != City.HighlightedCells.Count) { lastHighlightCount = City.HighlightedCells.Count; Refresh(); }
		if (View != null && BoundMatch?.PhaseEndsAt >= 0 && seconds != Seconds) { seconds = Seconds; Refresh(); }
		if (introAge < 0.45f && Descendants.FirstOrDefault(p => p.HasClass("auction-sheet")) is { } sheet)
		{
			introAge = Math.Min(0.45f,introAge+Time.Delta);
			var t = introAge/0.45f; var offset = 1-MathF.Pow(1-t,3);
			sheet.Style.Set("transform",FormattableString.Invariant($"translateY({(1-offset)*25:0.###}px)"));
		}
		if (!Peeking && City?.Screens.Top == GameScreen.Match && focusLot && Descendants.OfType<AuctionLotPanel>().FirstOrDefault()?.Descendants.OfType<PlotterCard>().FirstOrDefault() is { } initial)
		{
			initial.AcceptsFocus = true; initial.Focus(); focusLot = false;
		}
		if (View != null)
		{
			var pips = Descendants.Where(p => p.HasClass("ready-pip")).ToArray();
			for (var i = 0; i < View.Players.Count && i < pips.Length; i++)
			{
				var owner = View.Players[i].Player;
				if (!pipPop.TryGetValue(owner,out var age)) continue;
				age += Time.Delta;
				var t = Math.Clamp(age/0.18f,0,1);
				pips[i].Style.Set("transform",FormattableString.Invariant($"scale({1+0.08f*MathF.Sin(t*MathF.PI):0.###})"));
				if (t >= 1) pipPop.Remove(owner); else pipPop[owner] = age;
			}
		}
		if (Peeking || View == null || Hud.ControlsMode || City.Screens.Top != GameScreen.Match || City.Match.Phase != MatchPhase.Bid || View.Resolving || View.Ready || !View.Eligible) return;
		var direction = (Input.Pressed("CursorRight") ? 1 : 0)-(Input.Pressed("CursorLeft") ? 1 : 0);
		if (direction != 0) { selectedLot = (selectedLot+direction+View.Lots.Count)%View.Lots.Count; Refresh(); City.Scene.GetAllComponents<Hud>().FirstOrDefault()?.FocusBoard(); }
		if (Input.Pressed("ConfirmCard")) City.BuyMarketCard(View.Id,selectedLot);
	}
}