perks/Perk.cs

Base Perk class used by the game. It stores registration metadata (name, image, descriptions, tooltip, layout hints) for perk subclasses, provides lookup and rich-text helper methods, manages perk state (level, timing, display/animation properties), and declares many virtual callback methods that concrete perks override.

Reflection
#nullable enable
using System;
using System.Numerics;
using System.Reflection;
using System.Text.RegularExpressions;
using Sandbox;

public enum Rarity { None, Common, Uncommon, Rare, Epic, Mythic, Legendary, Unique }
public enum MultiplayerMode { Always, OnlySingleplayer, OnlyMultiplayer }
public enum PerkCategory { AfterFirstPerk, Fire, Freeze, FireAndFreeze, Poison, Fear, SelfDmg, Dodge, Explosion, BulletBounce, BulletPierce, Armor, Shield, Radiation, Landmine, Aoe, NumProjectiles, 
	Bomb, Blink, Boomerang, Punch, OneHpLeft, OneHpLeftAndDodge, Jump, Artillery, OrbiterBlade, ArcBullets, }
public abstract class Perk : IStatModifier, IPlayerCallbacks
{
	private static readonly Dictionary<Type, string> _names = new();
	private static readonly Dictionary<string, string> _namesByFullName = new();
	private static readonly Dictionary<Type, string> _imagePaths = new();
	private static readonly Dictionary<string, string> _imagePathsByFullName = new();
	private static readonly Dictionary<Type, Func<int, string>> _descriptions = new();
	private static readonly Dictionary<string, Func<int, string>> _descriptionsByFullName = new();
	private static readonly Dictionary<Type, Func<int, string>> _upgradeDescriptions = new();
	private static readonly Dictionary<string, Func<int, string>> _upgradeDescriptionsByFullName = new();
	private static readonly Dictionary<Type, string> _tooltipInfos = new();  // Optional Tooltip
	private static readonly Dictionary<string, string> _tooltipInfosByFullName = new();
	private static readonly Dictionary<Type, float> _descriptionLineHeights = new();  // Optional line height
	private static readonly Dictionary<string, float> _descriptionLineHeightsByFullName = new();
	private static readonly Dictionary<Type, float> _descriptionFontSizes = new();  // Optional font size
	private static readonly Dictionary<string, float> _descriptionFontSizesByFullName = new();
	private static readonly Dictionary<Type, float> _descriptionImageSizes = new();  // Optional image size
	private static readonly Dictionary<string, float> _descriptionImageSizesByFullName = new();
	private static readonly HashSet<Type> _registeredTypes = new();

	protected static void Register<T>(
		string name,
		string imagePath,
		Func<int, string> description,
		Func<int, string>? upgradeDescription = null,  // Optional
		string? tooltipInfo = null,  // Optional
		float? descriptionLineHeight = null,  // Optional
		float? descriptionFontSize = null,  // Optional
		float? descriptionImageSize = null  // Optional
	) where T : Perk
	{
		var fullName = typeof( T ).FullName ?? typeof( T ).Name;
		_names[typeof( T )] = name;
		_namesByFullName[fullName] = name;
		_imagePaths[typeof( T )] = imagePath;
		_imagePathsByFullName[fullName] = imagePath;
		_descriptions[typeof( T )] = description;
		_descriptionsByFullName[fullName] = description;

		if ( upgradeDescription != null )  // Only store it if provided
		{
			_upgradeDescriptions[typeof( T )] = upgradeDescription;
			_upgradeDescriptionsByFullName[fullName] = upgradeDescription;
		}

		if ( tooltipInfo != null )  // Only store it if provided
		{
			_tooltipInfos[typeof( T )] = tooltipInfo;
			_tooltipInfosByFullName[fullName] = tooltipInfo;
		}

		if ( descriptionLineHeight != null )  // Only store it if provided
		{
			_descriptionLineHeights[typeof( T )] = descriptionLineHeight.Value;
			_descriptionLineHeightsByFullName[fullName] = descriptionLineHeight.Value;
		}

		if ( descriptionFontSize != null )  // Only store it if provided
		{
			_descriptionFontSizes[typeof( T )] = descriptionFontSize.Value;
			_descriptionFontSizesByFullName[fullName] = descriptionFontSize.Value;
		}

		if ( descriptionImageSize != null )  // Only store it if provided
		{
			_descriptionImageSizes[typeof( T )] = descriptionImageSize.Value;
			_descriptionImageSizesByFullName[fullName] = descriptionImageSize.Value;
		}
	}

	private static string GetLookupKey( Type type )
	{
		return type?.FullName ?? type?.Name ?? string.Empty;
	}

	private static void EnsureRegistered( Type type )
	{
		if ( type == null || _registeredTypes.Contains( type ) )
			return;

		var typeDescription = TypeLibrary.GetType( type );
		if ( typeDescription == null )
			return;

		// Creating an instance ensures the perk's static Register<>() data exists on clients
		// that only know the perk by networked identity and have never owned the perk.
		typeDescription.Create<Perk>();
		_registeredTypes.Add( type );
	}

	public static string? GetName( Type type )
	{
		EnsureRegistered( type );
		return _names.TryGetValue( type, out var name )
			? name
			: (_namesByFullName.TryGetValue( GetLookupKey( type ), out name ) ? name : null);
	}

	public static string? GetImagePath( Type type )
	{
		EnsureRegistered( type );
		return _imagePaths.TryGetValue( type, out var imagePath )
			? imagePath
			: (_imagePathsByFullName.TryGetValue( GetLookupKey( type ), out imagePath ) ? imagePath : null);
	}

	public static string NormalizeLooseName( string? input )
	{
		if ( string.IsNullOrWhiteSpace( input ) )
			return "";

		input = input.ToLowerInvariant();
		return Regex.Replace( input, @"[^a-z0-9]", "" );
	}

	public static bool AreLooseNamesEqual( string? str1, string? str2 )
	{
		if ( string.IsNullOrWhiteSpace( str1 ) || string.IsNullOrWhiteSpace( str2 ) )
			return false;

		return NormalizeLooseName( str1 ).Equals( NormalizeLooseName( str2 ), StringComparison.Ordinal );
	}

	public static TypeDescription? FindTypeByLooseDisplayName( string? message, bool includeDisabled = false )
	{
		var normalizedMessage = NormalizeLooseName( message );
		if ( string.IsNullOrEmpty( normalizedMessage ) )
			return null;

		foreach ( var type in TypeLibrary.GetTypes<Perk>() )
		{
			var attrib = type.GetAttribute<PerkAttribute>();
			if ( attrib == null )
				continue;

			if ( !includeDisabled && attrib.Disabled )
				continue;

			var displayName = GetName( type.TargetType );
			if ( string.IsNullOrWhiteSpace( displayName ) )
				continue;

			if ( AreLooseNamesEqual( normalizedMessage, displayName ) )
				return type;
		}

		return null;
	}

	public static string GetRichTextToken( Type type, int? level = null )
	{
		if ( type == null )
			return "";

		return GetRichTextToken( type.Name, level );
	}

	public static string GetRichTextToken( TypeDescription type, int? level = null )
	{
		if ( type == null )
			return "";

		return GetRichTextToken( type.Name, level );
	}

	public static string GetRichTextToken( string perkTypeName, int? level = null )
	{
		if ( string.IsNullOrWhiteSpace( perkTypeName ) )
			return "";

		if ( level.HasValue && level.Value > 0 )
			return $"[perk:{perkTypeName}:{level.Value}]";

		return $"[perk:{perkTypeName}]";
	}

	public static string GetRichTextColorToken( string text, Color color )
	{
		if ( string.IsNullOrWhiteSpace( text ) )
			return "";

		return $"[color:{color.Hex}]{text}[/color]";
	}

	public static string GetRichTextRarityColorToken( string text, Rarity rarity, bool curse = false, float alpha = 1f )
	{
		var color = PerkManager.GetFontRarityColor( rarity, curse, alpha );
		return GetRichTextColorToken( text, color );
	}

	public static string GetRichTextNameToken( Type type )
	{
		if ( type == null )
			return "";

		var name = GetName( type ) ?? type.Name;
		var typeDescription = TypeLibrary.GetType( type );
		var perkAttribute = typeDescription?.GetAttribute<PerkAttribute>();

		if ( perkAttribute == null )
			return name;

		return GetRichTextRarityColorToken( name, perkAttribute.Rarity, perkAttribute.Curse );
	}

	public static string? GetDescription( Type type, int level )
	{
		EnsureRegistered( type );

		if ( !_descriptions.TryGetValue( type, out var description ) )
			_descriptionsByFullName.TryGetValue( GetLookupKey( type ), out description );

		if ( description != null )
		{
			try
			{
				return description( level );
			}
			catch ( Exception ex )
			{
				Log.Warning( $"Failed to get description for perk '{type.Name}' (level {level}): {ex.Message}" );
				return null;
			}
		}
		return null;
	}
	public static string? GetUpgradeDescription( Type type, int level )
	{
		EnsureRegistered( type );

		if ( !_upgradeDescriptions.TryGetValue( type, out var upgradeDescription ) )
			_upgradeDescriptionsByFullName.TryGetValue( GetLookupKey( type ), out upgradeDescription );

		if ( upgradeDescription != null )
		{
			try
			{
				return upgradeDescription( level );
			}
			catch ( Exception ex )
			{
				Log.Warning( $"Failed to get upgrade description for perk '{type.Name}' (level {level}): {ex.Message}" );
				return null;
			}
		}
		return null;
	}
	public static string? GetTooltipInfo( Type type )
	{
		EnsureRegistered( type );
		return _tooltipInfos.TryGetValue( type, out var tooltip )
			? tooltip
			: (_tooltipInfosByFullName.TryGetValue( GetLookupKey( type ), out tooltip ) ? tooltip : null);
	}

	public static float? GetDescriptionLineHeight( Type type )
	{
		EnsureRegistered( type );
		return _descriptionLineHeights.TryGetValue( type, out var lineHeight )
			? lineHeight
			: (_descriptionLineHeightsByFullName.TryGetValue( GetLookupKey( type ), out lineHeight ) ? lineHeight : null);
	}

	public static float? GetDescriptionFontSize( Type type )
	{
		EnsureRegistered( type );
		return _descriptionFontSizes.TryGetValue( type, out var fontSize )
			? fontSize
			: (_descriptionFontSizesByFullName.TryGetValue( GetLookupKey( type ), out fontSize ) ? fontSize : null);
	}

	public static float? GetDescriptionImageSize( Type type )
	{
		EnsureRegistered( type );
		return _descriptionImageSizes.TryGetValue( type, out var imageSize )
			? imageSize
			: (_descriptionImageSizesByFullName.TryGetValue( GetLookupKey( type ), out imageSize ) ? imageSize : null);
	}

	public bool ShouldUpdate { get; protected set; }
	public virtual bool UpdateWhenDead => false;
	public Player Player { get; set; } = null!;
	public int Level { get; set; }
	public int MaxLevel { get; set; }
	public TimeSince ElapsedTime { get; protected set; }
	public RealTimeSince RealTimeSinceLevelUp { get; protected set; }
	public RealTimeSince RealTimeSinceLevelDown { get; protected set; }
	public string DisplayText { get; protected set; } = "";
	public Color DisplayTextColor { get; protected set; }
	public float DisplayTextOpacity { get; protected set; }
	public float DisplayCooldown { get; protected set; }
	public Color DisplayCooldownColor { get; protected set; }
	public TimeSince TimeSinceHighlight { get; set; }
	public RealTimeSince RealTimeSinceHighlight { get; set; }
	public float HighlightDuration { get; protected set; }
	public Color HighlightColor { get; protected set; }
	public float HighlightOpacity { get; protected set; }

	private float _iconAngleOffset;
	private float _iconScale;
	private bool _iconNeedsLerp;
	protected float IconLerpSpeed { get; set; } = 15f;

	public float IconAngleOffset
	{
		get => _iconAngleOffset;
		set
		{
			_iconAngleOffset = value;
			if ( MathF.Abs( value ) > 0.001f ) _iconNeedsLerp = true;
		}
	}

	public float IconScale
	{
		get => _iconScale;
		set
		{
			_iconScale = value;
			if ( MathF.Abs( value - 1f ) > 0.001f ) _iconNeedsLerp = true;
		}
	}

	public virtual List<PerkCategory>? IncludedPerkCategories =>
		GetType().GetCustomAttribute<PerkAttribute>()?.IncludedCategories?.ToList();

	public virtual float? DescriptionLineHeight => null;

	public float Importance { get; set; }
	public virtual float ImportanceMultiplier => 1f;
	public Rarity Rarity { get; set; }
	public bool IsCurse { get; set; }

	public Perk()
	{
		
	}

	// called only once, when player gets this perk
	public virtual void Start()
	{
		ElapsedTime = 0f;
		ShouldUpdate = false;
		DisplayTextColor = Color.White;
		DisplayTextOpacity = 2f;
		DisplayCooldownColor = new Color( 0f, 0f, 0f, 5f );
		TimeSinceHighlight = 999f;
		RealTimeSinceHighlight = 999f;
		RealTimeSinceLevelUp = 999f;
		RealTimeSinceLevelDown = 999f;
		HighlightColor = new Color( 1f, 1f, 1f );
		HighlightDuration = 0.25f;
		HighlightOpacity = 2f;
		_iconAngleOffset = 0f;
		_iconScale = 1f;
		_iconNeedsLerp = false;

		if ( IncludedPerkCategories != null )
		{
			foreach ( var category in IncludedPerkCategories )
				Player.IncludePerkCategory( category );
		}

		Importance = 0f;

		var attrib = TypeLibrary.GetType( GetType() ).GetAttribute<PerkAttribute>();
		Rarity = attrib.Rarity;
		IsCurse = attrib.Curse;
	}

	// called when player gets this perk or levels it up
	public virtual void Refresh()
	{
		
	}

	public virtual void IncreaseLevel()
	{
		Level++;

		if ( Level == 1 )
			Start();

		Refresh();

		RealTimeSinceLevelUp = 0f;
	}


	public virtual void DecreaseLevel()
	{
		Level--;
		Refresh();

		RealTimeSinceLevelDown = 0f;
	}

	public void UpdateIcon( float dt )
	{
		if ( !_iconNeedsLerp ) return;

		_iconAngleOffset = MathX.Lerp( _iconAngleOffset, 0f, dt * IconLerpSpeed );
		_iconScale = MathX.Lerp( _iconScale, 1f, dt * IconLerpSpeed );

		if ( MathF.Abs( _iconAngleOffset ) < 0.001f && MathF.Abs( _iconScale - 1f ) < 0.001f )
		{
			_iconAngleOffset = 0f;
			_iconScale = 1f;
			_iconNeedsLerp = false;
		}
	}

	public virtual void Update( float dt )
	{

	}

	public virtual void Remove( bool restart = false )
	{
		if ( IncludedPerkCategories != null )
		{
			foreach ( var category in IncludedPerkCategories )
				Player.ExcludePerkCategory( category );
		}
	}

	public void Highlight()
	{
		TimeSinceHighlight = 0f;
		RealTimeSinceHighlight = 0f;
	}

	public virtual void Colliding( Thing other, float percent, float dt ) { }
	public virtual void OnRunStart() { }
	/// <summary>
	/// Before changing direction when blinking.
	/// </summary>
	/// <param name="dir"></param>
	public virtual void OnDashStartedEarly( Vector2 dir ) { } 
	public virtual void OnDashStarted( Vector2 dir ) { }
	public virtual void OnDashFinished( Vector2 dir ) { }
	public virtual void OnDashRecharged() { }
	public virtual void OnStartReload() { }
	public virtual void OnFinishReload() { }
	public virtual void OnShoot() { }
	public virtual void OnIgnite( Enemy enemy ) { }
	public virtual void OnFreeze( Enemy enemy ) { }
	public virtual void OnPoison( Enemy enemy ) { }
	public virtual void OnFear( Enemy enemy ) { }
	public virtual void OnKill( Enemy enemy, DamageType damageType, bool countsAsKill ) { }
	public virtual void OnDamageEnemy( Enemy enemy, float damage, DamageType damageType, Vector2 dir, bool isCrit ) { }
	public virtual void OnHurt( float amount, DamageType damageType, bool isSelfInflicted, Vector2 dir, Enemy enemySource, EnemyType enemyType, float previousHealth ) { } // actually lost HP
	public virtual void OnHit( float amount, DamageType damageType, bool isSelfInflicted, Vector2 dir, float force, Enemy enemySource, EnemyType enemyType, float previousHealth ) { } // includes armor lost
	public virtual void OnLoseArmor( float amount, DamageType damageType, bool isSelfInflicted, Vector2 dir, Enemy enemySource ) { }
	public virtual bool TryPreventDeath() => false;
	public virtual void OnDie() { }
	public virtual void OnRevive() { }
	public virtual void OnHeal( float amount ) { }
	public virtual void OnHealOther( float amount, Player other ) { }
	public virtual void OnGainXpCoin( float xp ) { }
	public virtual void OnGainHealthpack( float amount ) { }
	public virtual void OnPlayerLevelUp() { }
	public virtual void OnRerollBefore() { }
	public virtual void OnRerollAfter() { }
	public virtual void OnGainReroll( int amount ) { }
	public virtual void OnBanish() { }
	public virtual void OnGainBanish( int amount ) { }
	public virtual void OnGainShield() { }
	public virtual void OnLoseShield() { }
	public virtual void OnLand() { }
	public virtual void OnSayChat( string message ) { }
	public virtual void OnBulletHitGround( Bullet bullet ) { }
	public virtual void OnBulletPierce( Bullet bullet, Thing other ) { }
	public virtual void OnBulletBounce( Bullet bullet, Thing other ) { }
	public virtual void OnDodged( float damage, DamageType damageType, Vector2 dir, Enemy enemySource ) { }
	public virtual void OnSetMinute( int minute ) { }
	public virtual void OnAddPerkBefore( TypeDescription type ) { }
	public virtual void OnAddPerkAfter( TypeDescription type ) { }
	public virtual void OnRemovePerk( TypeDescription type ) { }
	public virtual void OnChoosePerk( TypeDescription type ) { }
	public virtual void OnPerkChoicesRefreshed( int numUniqueConsumed ) { }
	public virtual void OnPerkChoicesAssigned() { }
	public virtual void OnTeleport( Vector2 from, Vector2 to ) { }
}