things/Unit.Status.cs

Unit status management for a game Unit. Tracks status flags (burning, frozen, poisoned, etc.), applies and removes UnitStatus objects, spawns and tears down visual effects and plays local SFX via Manager, and exposes RPCs for status changes and chained VFX management.

NetworkingFile AccessNative Interop
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 );
	}
}