Ui/DesignSystem/PlotterCard.razor

This is a UI component defined in Razor that represents a collectible card in the game. It displays card properties like name, rarity, cost, and rules, and handles mouse and button interactions for activation and discarding.

@namespace CitySim.DesignSystem
@using CitySim.Cards
@using CitySim.Match
@using CitySim.Punchboard
@using Sandbox.UI
@inherits Panel
<root class="plotter-card @CssClass rarity-@(Card?.Rarity.ToString().ToLowerInvariant() ?? "common") @(Compact ? "compact" : "") @(Selected ? "selected" : "") @(Disabled ? "disabled" : "") @(Matches ? "reference-match" : "")">
@if (Card != null) {
 <div class="plotter-card-paper"></div>@if (Card.Rarity == Rarity.Legendary) { <div class="plotter-card-foil-window"><div class="plotter-card-foil"></div></div> }<div class="plotter-card-rarity-frame"></div><div class="plotter-card-rarity-inner"></div>
 @if (!string.IsNullOrEmpty(HotKey)) { <div class="plotter-card-key">@HotKey</div> }
 @if (DisplayCost.HasValue) { <div class="plotter-card-cost">🪙 @DisplayCost.Value</div> }
 <div class="plotter-card-tags">@foreach (var tag in TagChip.Values(Card.Tags)) { <PlotterCardReference City=@City Label=@($"{TagChip.Icon(tag)} {GameText.EnumLabel(tag)}") Tag=@tag ReferenceChanged=@ReferenceChanged /> }</div>
 <div class="plotter-card-art"><PbIcon Icon=@(CardPresentation.Artwork(Card, Mature)) /></div>
 <div class="plotter-card-name">@(GameText.Plain(Card.Name))</div>
 @if (!BuildOnly && ActualBuildCost > 0) { <div class="plotter-card-build">@(GameText.Get("ui.plotter_card.to_build_total", ActualBuildCost, (DisplayCost ?? 0) + ActualBuildCost))</div> }
 <div class="plotter-card-rules">@foreach (var part in CardRuleText.Split(CardPresentation.Rules(Card))) {
  <div class="plotter-card-rule">@if (part.Label != null) { <PbRuleKeyword [email protected] Label=@(GameText.Plain(part.Label)) Help=@(GameText.Plain(part.Help)) /> }
  <div class="plotter-card-words"><CardRuleContent City=@City [email protected] ReferenceChanged=@ReferenceChanged TargetsChanged=@TargetsChanged /></div></div>
 }</div>
 @if (!string.IsNullOrEmpty(Requirement)) { <div class="plotter-card-requirement">@(GameText.Plain(Requirement))</div> }
 <div class="plotter-card-footer"><PbRarityMarker Rarity=@(PunchboardMapping.Rarity(Card.Rarity)) />
  @if (ShowDiscard) { <PlotterButton Label=@(GameText.Get("ui.plotter_card.discard")) Variant="quiet" Disabled=@(Disabled || DiscardDisabled) Clicked=@Discard /> }
  @if (!string.IsNullOrEmpty(CardPresentation.Limit(Card))) { <div class="plotter-card-limit">@CardPresentation.Limit(Card)</div> }
 </div>
}
</root>
@code {
 [Parameter] public string CssClass { get; set; }
 [Parameter] public CardDef Card { get; set; }
 [Parameter] public bool Mature { get; set; }
 [Parameter] public CityGame City { get; set; }
 [Parameter] public int? Cost { get; set; }
 [Parameter] public int? BuildCost { get; set; }
 [Parameter] public bool BuildOnly { get; set; }
 [Parameter] public bool Compact { get; set; }
 [Parameter] public bool Selected { get; set; }
 [Parameter] public bool Disabled { get; set; }
 [Parameter] public string HotKey { get; set; }
 [Parameter] public bool ShowDiscard { get; set; }
 [Parameter] public bool DiscardDisabled { get; set; }
 [Parameter] public bool SelectionSound { get; set; } = true;
 [Parameter] public Action Clicked { get; set; }
 [Parameter] public Action Discarded { get; set; }
 [Parameter] public Tag ReferenceTag { get; set; }
 [Parameter] public Action<Tag> ReferenceChanged { get; set; }
 [Parameter] public Action<IReadOnlyList<InteractionTarget>> TargetsChanged { get; set; }
 int ActualBuildCost => BuildCost ?? (City?.Match?.Settings ?? new MatchSettings()).BuildCost(Card);
 int? DisplayCost => BuildOnly ? (ActualBuildCost > 0 ? ActualBuildCost : null) : Cost ?? (City?.Match?.Settings ?? new MatchSettings()).Rarities.FirstOrDefault(r => r.Rarity == Card.Rarity)?.MinimumBid;
 string Requirement => CardPresentation.Requirement(Card);
 bool Matches => Card != null && ((ReferenceTag != Tag.None && Card.Tags.HasFlag(ReferenceTag)) || (City?.ReferenceTag is { } tag && tag != Tag.None && Card.Tags.HasFlag(tag)));
 protected override void OnParametersSet() { base.OnParametersSet(); AcceptsFocus = !Disabled && Clicked != null; }
 protected override void OnClick(MousePanelEvent e) { base.OnClick(e); if (!Descendants.OfType<PlotterButton>().Any(b =>b.HasHovered) && !Descendants.OfType<PlotterCardReference>().Any(b => b.HasHovered)) Activate(); }
 public override void OnButtonEvent(ButtonEvent e) { base.OnButtonEvent(e); if (HasFocus && !FocusedChild && e.Pressed && (e.Button == "enter" || e.Button == "space")) Activate(); }
 bool FocusedChild => Descendants.Any(p => (p is PlotterCardReference || p is PlotterButton) && p.HasFocus);
 float lastActivation = -100;
 public void Activate() { if (Disabled || Clicked == null || lastActivation == Time.Now) return; lastActivation = Time.Now; if (SelectionSound) TabletopAudio.Card(); Clicked(); }
 void Discard() { if (Disabled || DiscardDisabled || lastActivation == Time.Now) return; lastActivation = Time.Now; Discarded?.Invoke(); }
 static int foilSequence;
 readonly float foilOffset = (++foilSequence * 0.47f) % 2.4f;
 Panel foilPanel;
 float foilPhase = -1f;
 bool foilEngaged;
 float foilBoostAge = -1f;
 public override void Tick() {
  base.Tick(); SetClass("reference-match", Matches);
  if (Card?.Rarity != Rarity.Legendary) return;
  if (foilPanel == null || foilPanel.IsDeleting) foilPanel = Descendants.FirstOrDefault(p => p.HasClass("plotter-card-foil"));
  if (foilPanel == null) return;
  var engaged = HasHovered || HasFocus;
  var justEngaged = engaged && !foilEngaged;
  foilEngaged = engaged;
  if (PlayerPreferences.Current.ReduceFlashing) {
   foilBoostAge = -1f;
   foilPanel.Style.Set("transform", "translateX(0px)"); foilPanel.Style.Opacity = 0.55f;
   return;
  }
  if (foilPhase < 0f) foilPhase = foilOffset;
  if (justEngaged) {
   // The gap is invisible; an active pass stays in place and brightens smoothly.
   if (foilPhase >= 1.8f) foilPhase = 0f;
   foilBoostAge = 0f;
  }
  foilPhase += Time.Delta;
  if (foilPhase >= 2.4f) foilPhase %= 2.4f;
  if (foilPhase >= 1.8f) foilBoostAge = -1f;
  if (foilBoostAge >= 0f) foilBoostAge += Time.Delta;
  var t = Math.Clamp(foilPhase / 1.8f, 0f, 1f);
  var eased = t * t * t * (t * (t * 6f - 15f) + 10f);
  var envelope = MathF.Sin(t * MathF.PI);
  var boost = foilBoostAge < 0f ? 0f : Math.Clamp(foilBoostAge / 0.1f, 0f, 1f);
  boost = boost * boost * (3f - 2f * boost);
  foilPanel.Style.Set("transform", FormattableString.Invariant($"translateX({-350f + 700f * eased:0.##}px)"));
  foilPanel.Style.Opacity = envelope * envelope * (0.72f + 0.28f * boost);
 }
 protected override int BuildHash() => HashCode.Combine(GameText.LanguageCode, Mature, HashCode.Combine(Card, Cost, BuildCost, BuildOnly, Compact, Selected, Disabled, HashCode.Combine(HotKey, ShowDiscard, DiscardDisabled, ReferenceTag, CssClass)));
}