things/Unit.Status.cs

Unit status management for Units. Stores active status effects, provides RPCs to add/remove statuses (burning, frozen, poison, fear, shock, shield, stun, mark, invincible, chained), updates status objects each tick, and creates/destroys VFX and plays SFX for state changes.

NetworkingFile Access
using System;
using Sandbox;
using Sandbox.Diagnostics;

public partial class Unit
{
	public Dictionary<TypeDescription, UnitStatus> UnitStatuses = new();

	public bool IsBurning { get; set; }
	private GameObject _burningVfx;
	public bool IsFrozen { get; set; }
	public TimeSince TimeSinceFrozen { get; set; }
	private GameObject _frozenVfx;
	public bool IsPoisoned { get; set; }
	private GameObject _poisonVfx;
	public bool IsFearful { get; set; }
	private GameObject _fearVfx;
	public bool IsShocked { get; set; }
	private GameObject _shockVfx;
	public bool IsShielded { get; set; }
	private GameObject _shieldVfx;
	public bool IsStunned { get; set; }
	private GameObject _stunnedVfx;
	public bool IsInvincible { get; set; }
	public bool IsMarked { get; set; }
	private GameObject _markedVfx;
	public bool IsChained { get; set; }
	public Dictionary<int, UnitChainedVfx> _chainedVfxs;

	public virtual bool CanBeStunned => !IsDying && !IsInanimate;

	void HandleStatuses( float dt )
	{
		Assert.True( !IsProxy );

		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
		{
			if ( i >= UnitStatuses.Count )
				continue;

			var status = UnitStatuses.Values.ElementAt( i );

			if ( IsDying )
				continue;

			if ( status.ShouldUpdate )
				status.Update( dt );
		}
	}

	[Rpc.Owner]
	public void Ignite( Player playerSource, Enemy enemySource, EnemyType enemyType, float damage, float lifetime, float spreadChance, bool canStack )
	{
		if ( IsDying )
			return;

		if ( this is Player player && player.IsInvincible )
			return;

		bool wasAlreadyBurning = IsBurning;

		var fire = AddUnitStatus<UnitStatusFire>();
		fire.PlayerSource = playerSource;
		fire.EnemySource = enemySource;
		fire.EnemyType = enemyType;

		//if ( wasAlreadyBurning && player.Stats[PlayerStat.FireDmgStack] > 0f )
		//	fire.AddDamageStack( player.Stats[PlayerStat.FireDamage], player.Stats[PlayerStat.FireLifetime] );
		//else
		//	fire.Damage = player.Stats[PlayerStat.FireDamage] * player.GetDamageMultiplier();

		if ( wasAlreadyBurning && canStack )
			fire.AddDamageStack( damage, lifetime );
		else
			fire.SetStartingDamage( damage );

		//fire.Lifetime = player.Stats[PlayerStat.FireLifetime];
		//fire.SpreadChance = player.Stats[PlayerStat.FireSpreadChance];

		fire.Lifetime = lifetime;
		fire.SpreadChance = spreadChance;

		if ( playerSource.IsValid() && this is Enemy enemy )
			playerSource.IgniteEnemy( enemy );
	}

	[Rpc.Owner]
	public void Freeze( Player playerSource, Enemy enemySource, float timeScale, float lifetime, bool playSfx = true )
	{
		if ( IsDying || IsFrozen )
			return;

		var frozen = AddUnitStatus<UnitStatusFreeze>();
		frozen.SetValues( timeScale, lifetime );
		frozen.PlayerSource = playerSource;
		frozen.EnemySource = enemySource;

		if ( playerSource.IsValid() && this is Enemy enemy )
			playerSource.FreezeEnemy( enemy );

		if ( playSfx )
			Manager.Instance.PlaySfxNearbyRpc( "frozen_02", Position2D, pitch: Game.Random.Float( 1.2f, 1.35f ), volume: 1.1f, maxDist: 250f );
	}

	[Rpc.Owner]
	public void Poison( Player playerSource, Enemy enemySource, EnemyType enemyType, float damage, float finishDmgPercent, float dieSpreadChance, float radiusMultiplier, bool flammable, float tickTimeModifier = 1f, int hitsToRemove = 1 )
	{
		if ( IsDying )
			return;

		if ( this is Player player && player.IsInvincible )
			return;

		var poison = AddUnitStatus<UnitStatusPoison>();
		poison.PlayerSource = playerSource;
		poison.EnemySource = enemySource;
		poison.EnemyType = enemyType;
		poison.SetValues( damage, finishDmgPercent, dieSpreadChance, radiusMultiplier, flammable, tickTimeModifier, hitsToRemove );

		if ( playerSource.IsValid() && this is Enemy enemy )
			playerSource.PoisonEnemy( enemy );
	}

	[Rpc.Owner]
	public void Fear( Player playerSource, Enemy enemySource, float lifetime )
	{
		if ( IsDying || IsInanimate )
			return;

		var fear = AddUnitStatus<UnitStatusFear>();
		fear.PlayerSource = playerSource;
		fear.EnemySource = enemySource;
		fear.Lifetime = lifetime;

		if ( playerSource.IsValid() && this is Enemy fearEnemy )
			playerSource.FearEnemy( fearEnemy );

		OnFear();
	}

	public virtual void OnFear()
	{

	}

	[Rpc.Owner]
	public void Shock( Player playerSource, Enemy enemySource, float damage, int currSpreadCount, int spreadLimit )
	{
		if ( IsDying || IsShocked )
			return;

		if ( this is Player player && player.IsInvincible )
			return;

		var shock = AddUnitStatus<UnitStatusShock>();
		shock.PlayerSource = playerSource;
		shock.EnemySource = enemySource;
		shock.Damage = damage;
		shock.CurrSpreadCount = currSpreadCount; // todo: count down instead?
		shock.SpreadLimit = spreadLimit;
	}

	[Rpc.Owner]
	public void GainShield( float breakDmg = 0f )
	{
		if ( IsDying || IsShielded )
			return;

		var shield = AddUnitStatus<UnitStatusShield>();

		if( breakDmg > 0f )
			shield.EnemyAoeDamage = breakDmg;

		OnGainShield();
	}

	public virtual void OnGainShield()
	{

	}

	[Rpc.Owner]
	public void Stun( Player playerSource, Enemy enemySource, float lifetime )
	{
		if ( !CanBeStunned )
			return;

		var stun = AddUnitStatus<UnitStatusStun>();
		stun.PlayerSource = playerSource;
		stun.EnemySource = enemySource;
		stun.Lifetime = lifetime;

		//ShakeRpc( startStrength: 1f, endStrength: 0.5f, lifetime );
	}

	public virtual void OnStun()
	{

	}

	[Rpc.Owner]
	public void Mark( Player playerSource, float damage )
	{
		if ( IsDying )
			return;

		var marked = AddUnitStatus<UnitStatusMarked>();
		marked.PlayerSource = playerSource;
		marked.Damage = damage;

		// todo: vfx
	}

	[Rpc.Owner]
	public void UnMark()
	{
		if ( IsDying )
			return;

		RemoveUnitStatus<UnitStatusMarked>();

		// todo: vfx
	}

	public virtual void OnStunFinish()
	{

	}


	[Rpc.Owner]
	public void Punched( Player playerSource )
	{
		if ( IsDying )
			return;

		var punched = AddUnitStatus<UnitStatusPunched>();
		punched.PlayerSource = playerSource;
	}

	[Rpc.Owner]
	public void BecomeInvincible( float lifetime = 0f )
	{
		if ( IsDying )
			return;

		if( IsInvincible )
		{
			var existing = GetUnitStatus<UnitStatusInvincible>();

			if( existing.Lifetime > 0f )
			{
				var timeRemaining = existing.Lifetime - existing.ElapsedTime;
				if ( lifetime > timeRemaining )
				{
					existing.Lifetime = lifetime;
					existing.ElapsedTime = 0f;
				}
			}

			return;
		}

		var invincible = AddUnitStatus<UnitStatusInvincible>();
		invincible.Lifetime = lifetime;
	}

	[Rpc.Owner]
	public void Chain( Unit anchorUnit, Vector2 chainPos, float chainLength, float lifetime )
	{
		if ( IsDying )
			return;

		if ( this is Player player && player.IsInvincible )
			return;

		OnChain();

		var chained = AddUnitStatus<UnitStatusChained>();
		chained.AddChain( anchorUnit, chainPos, chainLength, lifetime );
	}

	public virtual void OnChain()
	{

	}

	public TStatus AddUnitStatus<TStatus>()
		where TStatus : UnitStatus
	{
		Assert.True( !IsProxy );

		var type = TypeLibrary.GetType<TStatus>();

		if ( UnitStatuses.TryGetValue( type, out var status ) )
		{
			status.Refresh();
			return (TStatus)status;
		}
		else
		{
			status = type.Create<UnitStatus>();
			UnitStatuses.Add( type, status );
			status.Init( this );
			return (TStatus)status;
		}
	}

	public void RemoveUnitStatus<TStatus>( TStatus status )
		where TStatus : UnitStatus
	{
		Assert.True( !IsProxy );

		if ( UnitStatuses.Remove( TypeLibrary.GetType<TStatus>(), out var existing ) )
		{
			Assert.AreEqual( existing, status );
			status.OnRemove();
		}
	}

	public void RemoveUnitStatus<TStatus>()
		where TStatus : UnitStatus
	{
		Assert.True( !IsProxy );

		if ( UnitStatuses.Remove( TypeLibrary.GetType<TStatus>(), out var status ) )
		{
			status.OnRemove();
		}
	}

	public void RemoveAllUnitStatuses()
	{
		for ( int i = UnitStatuses.Count - 1; i >= 0; i-- )
		{
			var type = UnitStatuses.Keys.ElementAt( i );
			var status = UnitStatuses.Values.ElementAt( i );
			status.OnRemove( playEffects: false );
			UnitStatuses.Remove( type );
		}

		UnitStatuses.Clear();

		IsBurning = false;
		IsFrozen = false;
		IsPoisoned = false;
		IsFearful = false;
		IsShocked = false;
		IsShielded = false;
		IsStunned = false;
		IsInvincible = false;
	}

	public TStatus GetUnitStatus<TStatus>()
		where TStatus : UnitStatus
	{
		Assert.True( !IsProxy );

		return UnitStatuses.TryGetValue( TypeLibrary.GetType<TStatus>(), out var status )
			? (TStatus)status
			: null;
	}

	public bool HasUnitStatus<TStatus>( TStatus status )
		where TStatus : UnitStatus
	{
		return UnitStatuses.TryGetValue( TypeLibrary.GetType<TStatus>(), out var existing ) && existing == status;
	}

	public bool HasUnitStatus<TStatus>()
		where TStatus : UnitStatus
	{
		return UnitStatuses.ContainsKey( TypeLibrary.GetType<TStatus>() );
	}

	[Rpc.Broadcast]
	public void SetStatusBurning( bool burning )
	{
		if ( burning == IsBurning )
			return;

		IsBurning = burning;

		if ( burning )
		{
			_burningVfx = GameObject.Clone( "prefabs/effects/unit_status_fire.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (ParticleYPosOverride > 0f ? ParticleYPosOverride : 0.75f);
			_burningVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			var boxEmitter = _burningVfx.GetComponent<ParticleBoxEmitter>();
			boxEmitter.Size = new Vector3( Radius * 1.5f, Radius * 1.5f, Height * 0.25f );

			Manager.Instance.PlaySfxNearby( "burn", Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 280f );
		}
		else
		{
			if ( _burningVfx.IsValid() )
			{
				Manager.DestroyParticlesWhenFinished( _burningVfx );

				_burningVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStatusFrozen( bool frozen )
	{
		if ( frozen == IsFrozen )
			return;

		IsFrozen = frozen;

		if ( frozen )
		{
			TimeSinceFrozen = 0f;

			_frozenVfx = GameObject.Clone( "prefabs/effects/unit_status_freeze.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (ParticleYPosOverride > 0f ? ParticleYPosOverride : 0.95f);
			_frozenVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			var boxEmitter = _frozenVfx.GetComponent<ParticleBoxEmitter>();
			boxEmitter.Size = new Vector3( Radius * 1.6f, Radius * 1.6f, Height * 1.2f );
		}
		else
		{
			if ( _frozenVfx.IsValid() )
			{
				Manager.DestroyParticlesWhenFinished( _frozenVfx );

				_frozenVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStatusPoison( bool poisoned, bool playEffects = true )
	{
		if ( poisoned == IsPoisoned )
			return;

		IsPoisoned = poisoned;

		if ( poisoned )
		{
			_poisonVfx = GameObject.Clone( "prefabs/effects/unit_status_poison.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (ParticleYPosOverride > 0f ? ParticleYPosOverride : 0.85f);
			_poisonVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			var boxEmitter = _poisonVfx.GetComponent<ParticleBoxEmitter>();
			boxEmitter.Size = new Vector3( Radius * 2f, Radius * 2f, Height * 0.5f );

			Manager.Instance.PlaySfxNearby( "poisoned", Position2D, pitch: Game.Random.Float( 1.1f, 1.2f ), volume: 0.6f, maxDist: 350f );
		}
		else
		{
			if ( playEffects )
			{
				Manager.Instance.PlaySfxNearby( "splash", Position2D, pitch: Game.Random.Float( 1.2f, 1.3f ), volume: 0.95f, maxDist: 300f );
			}

			if ( _poisonVfx.IsValid() )
			{
				Manager.DestroyParticlesWhenFinished( _poisonVfx );

				_poisonVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStateFear( bool fearful )
	{
		if ( fearful == IsFearful )
			return;

		IsFearful = fearful;

		if ( fearful )
		{
			_fearVfx = GameObject.Clone( "prefabs/effects/unit_status_fear.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (ParticleYPosOverride > 0f ? ParticleYPosOverride : 0.85f);
			_fearVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			var boxEmitter = _fearVfx.GetComponent<ParticleBoxEmitter>();
			boxEmitter.Size = new Vector3( Radius * 2f, Radius * 2f, Height * 0.95f );

			Manager.Instance.PlaySfxNearby( "fear", Position2D, pitch: Game.Random.Float(0.95f, 1.05f), volume: 0.7f, maxDist: 350f );
		}
		else
		{
			if ( _fearVfx.IsValid() )
			{
				Manager.DestroyParticlesWhenFinished( _fearVfx );

				_fearVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStateMarked( bool marked )
	{
		if ( marked == IsMarked )
			return;

		IsMarked = marked;

		if ( marked )
		{
			_markedVfx = GameObject.Clone( "prefabs/effects/unit_status_marked.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (ParticleYPosOverride > 0f ? ParticleYPosOverride : 0.85f);
			_markedVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			var boxEmitter = _markedVfx.GetComponent<ParticleBoxEmitter>();
			boxEmitter.Size = new Vector3( Radius * 2f, Radius * 2f, Height * 0.95f );

			// todo: new sfx
			Manager.Instance.PlaySfxNearby( "evil_cast", Position2D, pitch: Game.Random.Float( 3.15f, 3.45f ), volume: 1.2f, maxDist: 350f );
		}
		else
		{
			if ( _markedVfx.IsValid() )
			{
				Manager.DestroyParticlesWhenFinished( _markedVfx );

				_markedVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStateShocked( bool shocked )
	{
		if ( shocked == IsShocked )
			return;

		IsShocked = shocked;

		if ( shocked )
		{
			_shockVfx = GameObject.Clone( "prefabs/effects/unit_status_shock.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (ParticleYPosOverride > 0f ? ParticleYPosOverride : 0.55f);
			_shockVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			var boxEmitter = _shockVfx.GetComponent<ParticleBoxEmitter>();
			boxEmitter.Size = new Vector3( Radius * 2f, Radius * 2f, Height * 0.65f );
		}
		else
		{
			if ( _shockVfx.IsValid() )
			{
				Manager.DestroyParticlesWhenFinished( _shockVfx );

				_shockVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStateShield( bool shielded, bool playEffects = true )
	{
		if ( shielded == IsShielded )
			return;

		IsShielded = shielded;

		if ( shielded )
		{
			_shieldVfx = GameObject.Clone( "prefabs/effects/unit_status_shield.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (ParticleYPosOverride > 0f ? ParticleYPosOverride : 0.7f);
			_shieldVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			Manager.Instance.PlaySfxNearby( "shield_gain", Position2D, pitch: Game.Random.Float( 1.2f, 1.25f ), volume: 0.75f, maxDist: 350f );
		}
		else
		{
			if( playEffects )
			{
				var pos = WorldPosition + new Vector3( 0f, 0f, Height * 0.7f );
				var shieldBreakGo = GameObject.Clone( "prefabs/effects/unit_status_shield_break.prefab", new CloneConfig { StartEnabled = true, Transform = new Transform( pos ) } );
				var particleRenderer = shieldBreakGo.GetComponent<ParticleSpriteRenderer>();
				particleRenderer.Scale = 3f * Radius;

				Manager.Instance.PlaySfxNearby( "shield_break", Position2D, pitch: Game.Random.Float( 0.95f, 1.05f ), volume: 1f, maxDist: 400f );
			}
			
			if ( _shieldVfx.IsValid() )
			{
				_shieldVfx.Destroy();
				_shieldVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStateStunned( bool stunned )
	{
		if ( stunned == IsStunned )
			return;

		IsStunned = stunned;

		if ( stunned )
		{
			_stunnedVfx = GameObject.Clone( "prefabs/effects/unit_status_stun.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } );
			var yPos = Height * (StunParticleYPosOverride > 0f ? StunParticleYPosOverride : 1.25f);
			_stunnedVfx.LocalPosition = new Vector3( 0f, 0f, yPos );

			OnStun();

			// todo: sfx
			//Manager.Instance.PlaySfxNearby( "fear", Position2D, pitch: Game.Random.Float( 5.35f, 5.55f ), volume: 0.5f, maxDist: 350f );

			var shakeStrength = 5f;
			Shake( shakeStrength, 0f, 0.33f, EasingType.SineOut );
		}
		else
		{
			OnStunFinish();

			if ( _stunnedVfx.IsValid() )
			{
				Manager.DestroyParticlesWhenFinished( _stunnedVfx );

				_stunnedVfx = null;
			}
		}
	}

	[Rpc.Broadcast]
	public void SetStateInvincible( bool invincible, bool playEffects = true )
	{
		if ( invincible == IsInvincible )
			return;

		IsInvincible = invincible;

		if ( invincible )
		{
			var outline = ModelRenderer.AddComponent<HighlightOutline>();
			outline.Color = Color.Yellow.WithAlpha( 0f );
			outline.InsideColor = Color.Yellow.WithAlpha( 0f );
			outline.Width = 0.15f;
			FadeInInvincibleOutline( outline );

			//Manager.Instance.PlaySfxNearby( "shield_gain", Position2D, pitch: Game.Random.Float( 1.8f, 1.85f ), volume: 0.6f, maxDist: 320f );
		}
		else
		{
			var outline = ModelRenderer.GetComponent<HighlightOutline>();
			if ( outline.IsValid() )
			{
				if ( playEffects )
				{
					FadeOutInvincibleOutline( outline );
				}
				else
				{
					outline.Destroy();
					outline = null;
				}
			}

			//if( playEffects )
			//{
			//	Manager.Instance.PlaySfxNearby( "shield_break", Position2D, pitch: Game.Random.Float( 1.35f, 1.45f ), volume: 0.5f, maxDist: 320f );
			//}
		}
	}

	async void FadeInInvincibleOutline( HighlightOutline outline )
	{
		const float DURATION = 0.1f;
		float elapsed = 0f;
		while ( elapsed < DURATION )
		{
			if ( !outline.IsValid() ) return;
			if ( !IsInvincible ) { outline.Destroy(); return; }
			elapsed += Time.Delta;
			float t = MathF.Min( elapsed / DURATION, 1f );
			outline.Color = Color.Yellow.WithAlpha( 0.4f * t );
			outline.InsideColor = Color.Yellow.WithAlpha( 0.05f * t );
			await Task.Frame();
		}
	}

	async void FadeOutInvincibleOutline( HighlightOutline outline )
	{
		const float DURATION = 0.1f;
		var startColor = outline.Color;
		var startInsideColor = outline.InsideColor;
		float elapsed = 0f;
		while ( elapsed < DURATION )
		{
			if ( !outline.IsValid() ) return;
			if ( IsInvincible ) { outline.Destroy(); return; }
			elapsed += Time.Delta;
			float t = MathF.Min( elapsed / DURATION, 1f );
			outline.Color = startColor.WithAlpha( startColor.a * (1f - t) );
			outline.InsideColor = startInsideColor.WithAlpha( startInsideColor.a * (1f - t) );
			await Task.Frame();
		}
		outline?.Destroy();
	}

	[Rpc.Broadcast]
	public void SetStatusChained( bool chained, bool playEffects = true )
	{
		if ( chained == IsChained )
			return;

		IsChained = chained;

		if ( chained )
		{
			//if ( _chainedVfxs == null )
			//	_chainedVfxs = new();

			//var chainedVfx = GameObject.Clone( "prefabs/effects/unit_status_chained.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } ).GetComponent<UnitChainedVfx>();
			//chainedVfx.ChainedUnit = this;
			//_chainedVfxs.Add( chainId, chainedVfx );

			//Manager.Instance.PlaySfxNearby( "poisoned", Position2D, pitch: Game.Random.Float( 1.1f, 1.2f ), volume: 0.6f, maxDist: 350f );
		}
		else
		{
			if ( playEffects )
			{
				//Manager.Instance.PlaySfxNearby( "splash", Position2D, pitch: Game.Random.Float( 1.2f, 1.3f ), volume: 0.95f, maxDist: 300f );
			}

			//if ( ChainedVfx.IsValid() )
			//{
			//	ChainedVfx.GameObject.Destroy();
			//	ChainedVfx = null;
			//}
		}
	}

	[Rpc.Broadcast]
	public void AddChainRpc( int chainId, float lifetime, float chainLength )
	{
		if ( _chainedVfxs == null )
			_chainedVfxs = new();

		var chainedVfx = GameObject.Clone( "prefabs/effects/unit_status_chained.prefab", new CloneConfig { StartEnabled = true, Parent = GameObject } ).GetComponent<UnitChainedVfx>();
		chainedVfx.ChainedUnit = this;
		chainedVfx.Lifetime = lifetime;
		chainedVfx.ChainLength = chainLength;

		_chainedVfxs.Add( chainId, chainedVfx );
	}

	[Rpc.Broadcast]
	public void RemoveChainRpc( int chainId )
	{
		if ( _chainedVfxs != null && _chainedVfxs.ContainsKey( chainId ) )
		{
			var chainedVfx = _chainedVfxs[chainId];
			if ( chainedVfx.IsValid() )
				chainedVfx.GameObject.Destroy();

			_chainedVfxs.Remove( chainId );
		}
	}


	[Rpc.Broadcast]
	public void SetChainAnchorPosRpc( int chainId, Vector2 pos )
	{
		var chainedVfx = _chainedVfxs[chainId];
		if ( chainedVfx.IsValid() )
			chainedVfx.SetAnchorPos( pos );
	}

	[Rpc.Broadcast]
	public void SetChainAnchorUnitRpc( int chainId, Unit unit )
	{
		var chainedVfx = _chainedVfxs[chainId];
		if ( chainedVfx.IsValid() )
			chainedVfx.SetAnchorUnit( unit );
	}
}