ui/PerkChoice.razor

A Blazor-style Sandbox UI panel (PerkChoice) that renders a perk card with icon, title, description, rarity, cost and interactive hover/slot behavior. It reads player state and perk metadata to compute visuals, hover state, and a build hash for UI invalidation.

Reflection
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PerkChoice.razor.scss")]

@{
	var player = ViewedPlayer.IsValid() ? ViewedPlayer : Manager.Instance.LocalPlayer;
	var isReadOnlyChoice = IsReadOnlyChoice( player );
	var isViewedPlayerChoiceHovered = IsViewedPlayerChoiceHovered( player );
	if ( PerkType == null )
	{
		return;
	}
	var playerPerkLevel = player.IsValid() ? player.GetDisplayedPerkLevel( PerkType ) : 0;
	var perkLevel = IsChoice
		? playerPerkLevel + 1
		: TooltipLevel;

	var attrib = PerkType.GetAttribute<PerkAttribute>();
	var rarity = attrib.Rarity;
	var curse = attrib.Curse;

	CurseWrongPerkInfo.WrongPerkInfo? wrongInfo = null;
	if ( IsChoice && !IsUnknown && player.IsValid() )
		wrongInfo = player.GetDisplayedPerkChoiceWrongInfo( Slot );

	var displayNameType = (wrongInfo?.Aspect == CurseWrongPerkInfo.WrongAspect.Name) ? wrongInfo.Value.WrongType?.TargetType ?? PerkType.TargetType : PerkType.TargetType;
	var displayDescType = (wrongInfo?.Aspect == CurseWrongPerkInfo.WrongAspect.Description) ? wrongInfo.Value.WrongType?.TargetType ?? PerkType.TargetType : PerkType.TargetType;
	var displayIconType = (wrongInfo?.Aspect == CurseWrongPerkInfo.WrongAspect.Icon) ? wrongInfo.Value.WrongType?.TargetType ?? PerkType.TargetType : PerkType.TargetType;

	var parentRotAngle = OnlyShowIcon && player.IsValid()
		? -6f + ((player.Level + player.NumPerkPointsAvailable + player.NumRerollAvailable + Slot * 3) % 6) * 2f
		: 0f;

	var iconRotAngle = (OnlyShowIcon || !player.IsValid())
		? 0f
		: Utils.Map( (player.PerkRandomRotationSeed + player.NumRerollAvailable + Slot * 3) % 6, 0, 5, 3f, 9f, EasingType.SineOut );

	if ( player.IsValid() && (player.PerkRandomRotationSeed + player.NumRerollAvailable + Slot) % 4 != 0 )
		iconRotAngle *= -1f;

	var curseRarityHpCostType = TypeLibrary.GetType( typeof( CurseRarityHpCost ) );
	bool hasCurseRarityHpCost = IsChoice && !curse && player.IsValid() && player.HasDisplayedPerk( curseRarityHpCostType );
	int hpCost = hasCurseRarityHpCost ? CurseRarityHpCost.GetHpCostForRarity( rarity ) : 0;
	var displayTitle = IsChoice
		? player.GetDisplayedPerkChoiceName( Slot, TypeLibrary.GetType( displayNameType ), IsUnknown )
		: (Perk.GetName( displayNameType ) ?? "");
	var displayDescription = IsChoice
		? player.GetDisplayedPerkChoiceDescription( Slot, TypeLibrary.GetType( displayDescType ), perkLevel, isChoice: true, IsUnknown, rarity )
		: (ShowUpgradeDescription && perkLevel > 1 ? Perk.GetUpgradeDescription( displayDescType, perkLevel ) : null)
			?? Perk.GetDescription( displayDescType, perkLevel ) ?? "";
	var iconPathOverride = IsChoice
		? player.GetDisplayedPerkChoiceIconPath( Slot, TypeLibrary.GetType( displayIconType ), IsUnknown )
		: null;
}

<root class="@(isReadOnlyChoice ? "spectator-readonly" : "") @(isViewedPlayerChoiceHovered ? "remote-hovered" : "")" style="transform: rotate(@(parentRotAngle)deg) scale(@(player.IsValid() && player.IsBanishMode ? 0.96 : 1)); @(OnlyShowIcon ? "width:64px; height:64px;" : "width:250px; height: 130px;") ">
	@if ( !OnlyShowIcon )
	{
		<div class="bg_color" style="background-color:@RarityColorFade( PerkManager.GetCardRarityColor( rarity, curse ) ).Rgba;"> </div>
		<div class="frame"> </div>
	}

	<div class="top_row" style="@(OnlyShowIcon ? "" : "pointer-events: none;")">
		<PerkIconStatic style="transform: rotate(@(OnlyShowIcon ? 0 : iconRotAngle)deg) translateY(@(OnlyShowIcon ? 0 : -12)px) translateX(@(iconRotAngle <= 0f ? 0 : Utils.Map(iconRotAngle, 3f, 8f, -2f, -6f))px);" PerkType=@PerkType IconOverrideType=@(wrongInfo?.Aspect == CurseWrongPerkInfo.WrongAspect.Icon ? wrongInfo.Value.WrongType : null) IconPathOverride=@iconPathOverride Level=@perkLevel ProgressLevel=@perkLevel NoTips=@(!OnlyShowIcon) IsChoice=@IsChoice IsUnknown=@IsUnknown />

		@if ( !OnlyShowIcon )
		{
			<div class="top_text">
				<label class="title_label">@displayTitle</label>
				<label class="rarity_label" style="color:@PerkManager.GetFontRarityColor( rarity, curse ).Rgba;">@PerkManager.GetCardRarityName( rarity, curse )</label>
			</div>
		}
	</div>

	@if ( !OnlyShowIcon )
	{
		var lineHeight = Perk.GetDescriptionLineHeight( displayDescType );
		var fontSize = Perk.GetDescriptionFontSize( displayDescType );
		var imageSize = Perk.GetDescriptionImageSize( displayDescType );
		var descriptionStyle = "";
		if ( lineHeight.HasValue ) descriptionStyle += $"line-height: {lineHeight.Value}px; ";
		if ( fontSize.HasValue ) descriptionStyle += $"font-size: {fontSize.Value}px;";

		<div class="middle">
			<RichText class="description" style="@descriptionStyle" Text=@displayDescription ImageSize=@imageSize />
		</div>

		@if ( hpCost > 0 )
		{
			<div class="hp_cost_label_container">
				<label class="hp_cost_label">-@(hpCost) hp</label>
			</div>
		}

		@if ( InfoRows != null && InfoRows.Count > 0 )
		{
			<div class="info_rows_container">
				@foreach ( var row in InfoRows )
				{
					<div class="info_row"><label>@row</label></div>
				}
			</div>
		}
	}

	@if ( player.IsValid() && player.IsBanishMode && TooltipLevel == 0 )
	{
		float fontSize = MathX.FloorToInt( 200f + Utils.FastSin( RealTime.Now * 8f ) * 8f ) * (OnlyShowIcon ? 0.6f : 1f);
		<div class="banish_x" style="font-size: @(fontSize)px;">X</div>
	}

	@if ( Slot >= 0 && Slot <= 8 )
	{
		<InputHint class="inputbutton @(OnlyShowIcon ? "icon-only" : "")" Button=@($"Slot{(Slot + 1)}") />
	}
	else if ( TooltipLevel > 0 && Slot == -1 )
	{
		<div class="level_indicator" style="color:@PerkManager.GetFontRarityColor( rarity, curse ).Rgba;">
			<label>@TooltipLevel</label><label style="opacity: 0.4;">/</label><label>@PerkManager.GetMaxLevelForRarity( rarity )</label>
		</div>
	}

	@if ( player.IsValid() && player.RealTimeSinceBanishChoice < 0.5f && Slot == player.BanishChoiceIndex )
	{
		<panel class="banish_overlay" style="opacity:@(Utils.Map( player.RealTimeSinceBanishChoice, 0f, 0.5f, 1f, 0f, EasingType.SineOut ));"></panel>
	}
</root>

@code {
	public Player ViewedPlayer { get; set; }
	public TypeDescription PerkType { get; set; }
	public int Slot { get; set; }
	public bool OnlyShowIcon { get; set; }
	public bool IsUnknown { get; set; }
	public bool IsChoice { get; set; }
	public int TooltipLevel { get; set; }
	public bool ShowUpgradeDescription { get; set; }
	public List<string> InfoRows { get; set; }

	private bool IsReadOnlyChoice( Player player )
	{
		return IsChoice && player.IsValid() && player != Manager.Instance.LocalPlayer;
	}

	private bool IsViewedPlayerChoiceHovered( Player player )
	{
		return IsChoice && player.IsValid() && player.HoveredPerkChoiceSlot == Slot;
	}

	protected override void OnMouseOver( MousePanelEvent e )
	{
		base.OnMouseOver( e );

		var player = ViewedPlayer.IsValid() ? ViewedPlayer : Manager.Instance.LocalPlayer;
		var isReadOnlyChoice = IsReadOnlyChoice( player );

		if ( IsChoice && player.IsValid() && !isReadOnlyChoice )
			player.HoveredPerkChoiceSlot = Slot;

		if ( !OnlyShowIcon || PerkType == null || IsUnknown )
			return;

		var playerPerkLevel = player.IsValid() ? player.GetDisplayedPerkLevel( PerkType ) : 0;
		var perkLevel = IsChoice ? playerPerkLevel + 1 : TooltipLevel;

		Manager.Instance.HoveredPerkType = PerkType;
		Manager.Instance.HoveredPerkPanel = this;
		Manager.Instance.HoveredPerkLevel = perkLevel;
		Manager.Instance.HoveredPerkViewedPlayer = ViewedPlayer.IsValid() ? ViewedPlayer : null;
		Manager.Instance.HoveredPerkChoiceSlot = IsChoice ? Slot : -1;
		Manager.Instance.IsHoveredPerkBanished = false;
		Manager.Instance.IsHoveredPerkAChoice = IsChoice;
		Manager.Instance.IsHoveredPerkHidden = false;
	}

	protected override void OnMouseOut( MousePanelEvent e )
	{
		base.OnMouseOut( e );

		var player = ViewedPlayer.IsValid() ? ViewedPlayer : Manager.Instance.LocalPlayer;
		if ( IsChoice && player.IsValid() && !IsReadOnlyChoice( player ) && player.HoveredPerkChoiceSlot == Slot )
			player.HoveredPerkChoiceSlot = -1;

		if ( Manager.Instance.HoveredPerkPanel != this )
			return;

		Manager.Instance.HoveredPerkType = null;
		Manager.Instance.HoveredPerkPanel = null;
		Manager.Instance.HoveredPerkViewedPlayer = null;
		Manager.Instance.HoveredPerkChoiceSlot = -1;
		Manager.Instance.IsHoveredPerkBanished = false;
		Manager.Instance.IsHoveredPerkAChoice = false;
		Manager.Instance.IsHoveredPerkHidden = false;
	}

	public override void OnDeleted()
	{
		base.OnDeleted();

		var player = ViewedPlayer.IsValid() ? ViewedPlayer : Manager.Instance.LocalPlayer;
		if ( IsChoice && player.IsValid() && !IsReadOnlyChoice( player ) && player.HoveredPerkChoiceSlot == Slot )
			player.HoveredPerkChoiceSlot = -1;

		if ( Manager.Instance.HoveredPerkPanel != this )
			return;

		Manager.Instance.HoveredPerkType = null;
		Manager.Instance.HoveredPerkPanel = null;
		Manager.Instance.HoveredPerkViewedPlayer = null;
		Manager.Instance.HoveredPerkChoiceSlot = -1;
		Manager.Instance.IsHoveredPerkBanished = false;
		Manager.Instance.IsHoveredPerkAChoice = false;
		Manager.Instance.IsHoveredPerkHidden = false;
	}

	protected override int BuildHash()
	{
		var player = ViewedPlayer.IsValid() ? ViewedPlayer : Manager.Instance.LocalPlayer;
		if ( !player.IsValid() )
			return HashCode.Combine( OnlyShowIcon );

		var banishHash = player.RealTimeSinceBanishChoice.Relative < 0.5f ? player.RealTimeSinceBanishChoice.Relative : 0f;
		var modeHash = player.IsBanishMode ? RealTime.Now : 0f;
		var hasCurseRarityHpCostHash = player.HasDisplayedPerk( TypeLibrary.GetType( typeof( CurseRarityHpCost ) ) );
		var wrongInfo = player.GetDisplayedPerkChoiceWrongInfo( Slot );

		return HashCode.Combine(
			PerkType?.Identity ?? -1,
			OnlyShowIcon,
			IsUnknown,
			IsChoice,
			TooltipLevel,
			HashCode.Combine(
				player.HoveredPerkChoiceSlot == Slot,
				wrongInfo?.WrongType?.Identity ?? -1,
				wrongInfo?.Aspect ?? (CurseWrongPerkInfo.WrongAspect)(-1),
				banishHash,
				modeHash,
				hasCurseRarityHpCostHash,
				player.PerkChoiceHash
			)
		);
	}

	private Color RarityColorFade( Color color )
	{
		return Color.Lerp( Color.Black, color, 0.5f ).WithAlpha( 0.95f );
	}
}