perks/PerkFrostNova.cs

A legendary perk component that triggers a Frost Nova when the player stands still long enough. It spawns particle indicators, tracks readiness and cooldown, expands a visual indicator while the player is still, and on trigger freezes nearby enemies and plays effects.

NetworkingFile Access
using System;
using Sandbox;

[Perk( Rarity.Legendary, locked: true, alwaysOfferDebug: false, IncludedCategories = new[] { PerkCategory.Aoe, PerkCategory.Freeze })]
public class PerkFrostNova : Perk
{
	private enum Mod { TriggerTime, Cooldown };

	private bool _isReady;
	private float _stillTimer;
	private float _cooldownTimer;

	private const float FREEZE_RADIUS = 210f;
	private const float DISPLAY_RADIUS = 50f;

	private ParticleEffect _particleEffect;
	private ParticleSpriteRenderer _particleRenderer;

	private ParticleEffect _particleEffectBg;
	private ParticleSpriteRenderer _particleRendererBg;

	private float _bgCurrOpacity;


	static PerkFrostNova()
	{
		Register<PerkFrostNova>(
			name: "Frost Nova",
			imagePath: "textures/icons/vector/frost_nova.png",
			description: level => $"Stop moving for {GetValue( level, Mod.TriggerTime )}s to\nfreeze nearby enemies\n(cooldown: {GetValue( level, Mod.Cooldown )}s)",
			upgradeDescription: level => $"Stop moving for {GetValue( level - 1, Mod.TriggerTime )}→{GetValue( level, Mod.TriggerTime )}s to\nfreeze nearby enemies\n(cooldown: {GetValue( level - 1, Mod.Cooldown )}→{GetValue( level, Mod.Cooldown )}s)"
		);
	}

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

		ShouldUpdate = true;

		var particleGo = GameObject.Clone( "prefabs/effects/frost_nova_indicator.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
		particleGo.LocalPosition = new Vector3( 0f, 0f, 4f );
		_particleEffect = particleGo.GetComponent<ParticleEffect>();
		_particleRenderer = particleGo.GetComponent<ParticleSpriteRenderer>();
		_particleRenderer.Scale = 0f;

		var particleBgGo = GameObject.Clone( "prefabs/effects/frost_nova_indicator_bg.prefab", new CloneConfig { StartEnabled = true, Parent = Player.GameObject } );
		particleBgGo.LocalPosition = new Vector3( 0f, 0f, 3.5f );
		_particleEffectBg = particleBgGo.GetComponent<ParticleEffect>();
		_particleRendererBg = particleBgGo.GetComponent<ParticleSpriteRenderer>();
		_particleRendererBg.Scale = DISPLAY_RADIUS * Player.LocalScale.x;

		HighlightColor = new Color( 0.7f, 0.7f, 1f );
		HighlightDuration = 0.25f;
		HighlightOpacity = 4f;
	}

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

		_isReady = true;
		_stillTimer = 0f;
		_bgCurrOpacity = 0f;
	}

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

		
	}

	private static float GetValue( int level, Mod mod, bool isPercent = false )
	{
		switch ( mod )
		{
			case Mod.TriggerTime:
			default:
				return 1.5f - 0.5f * level;
			case Mod.Cooldown:
				return 45f - 15f * level;
		}
	}

	public override void Update( float dt )
	{
		base.Update( dt );

		var displayRadius = DISPLAY_RADIUS * Player.LocalScale.x;

		if ( _isReady )
		{
			if ( Player.IsMoving )
			{
				_stillTimer = 0f;
			}
			else
			{
				_stillTimer += dt;
				if ( _stillTimer > GetValue( Level, Mod.TriggerTime ) )
				{
					Shoot();
					_stillTimer = 0f;
				}
			}

			float progress = Utils.Map( _stillTimer, 0f, GetValue( Level, Mod.TriggerTime ), 0f, 1f );
			_particleRenderer.Scale = progress * displayRadius;
			_particleEffect.Alpha = Utils.Map( progress, 0f, 1f, 0f, 1f, EasingType.QuadOut );
		}
		else
		{
			_cooldownTimer += dt;
			if ( _cooldownTimer > GetValue( Level, Mod.Cooldown ) )
			{
				_isReady = true;
				Highlight();
			}

			_particleRenderer.Scale = 0f;
			_particleEffect.Alpha = 0f;
		}

		float bgTargetOpacity = !_isReady ? 0f : (Player.IsMoving ? 0.1f : 0.4f);
		_bgCurrOpacity = Utils.DynamicEaseTo( _bgCurrOpacity, bgTargetOpacity, 0.2f, dt );

		_particleRendererBg.Scale = displayRadius;
		_particleEffectBg.Alpha = _bgCurrOpacity;

		DisplayText = _isReady ? " " : $"{MathX.CeilToInt( GetValue( Level, Mod.Cooldown ) - _cooldownTimer )}";
		DisplayCooldown = _isReady ? 0f : Utils.Map( _cooldownTimer, 0f, GetValue( Level, Mod.Cooldown ), 1f, 0f );

		//Utils.DrawCircle(Player.Position2D, GetRadius(visual: false), 20, 0f, Color.Blue);
	}

	void Shoot()
	{
		_isReady = false;
		_cooldownTimer = 0f;
		
		Manager.Instance.SpawnRingRpc( Player.Position2D, GetRadius(visual: true), new Color( 0.3f, 0.3f, 1f, 0.3f ), lifetime: 0.35f, path: "ring_spiky" );

		Manager.Instance.PlaySfxNearbyRpc( "frozen", Player.Position2D, pitch: Game.Random.Float( 1.3f, 1.35f ), volume: 0.8f, maxDist: 350f );

		var pos = Player.Position2D;
		var radius = GetRadius( visual: false );

		var traceResults = Player.Scene.Trace.Sphere( radius, pos, pos ).WithAnyTags( "enemy" ).HitTriggersOnly().RunAll().ToList();
		foreach ( var tr in traceResults )
		{
			var gameObject = tr.GameObject;
			var enemy = gameObject.GetComponent<Enemy>();
			if ( enemy.IsDying || enemy.IsInTheAir || (enemy.IsSpawning && !enemy.AlmostFinishedSpawning) )
				continue;

			Vector2 dir = (enemy.Position2D - pos).Normal;
			var hitPos = enemy.Position2D;

			enemy.Freeze( playerSource: Player, enemySource: null, Player.Stats[PlayerStat.FreezeTimeScale], Player.Stats[PlayerStat.FreezeLifetime], playSfx: false );
		}
	}

	float GetRadius( bool visual = false )
	{
		return FREEZE_RADIUS * Player.Stats[PlayerStat.RadiusMultiplier] * (visual ? 1.1f : 1f);
	}

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

		_particleEffect.Alpha = 0f;
		_particleEffectBg.Alpha = 0f;
	}

	public override void Remove( bool restart = false )
	{
		base.Remove( restart );

		if ( _particleEffect != null )
			_particleEffect.Destroy();

		if ( _particleEffectBg != null )
			_particleEffectBg.Destroy();
	}
}