things/Bullet.cs

Bullet entity class used in the game, implements bullet behaviour and interactions. Manages bullet stats, visuals, movement (steering, random movement, homing, returning, arcs), collision handling with enemies, players, obstacles and special effects like piercing, bouncing, splash, explosion and lens buffs.

NetworkingFile Access
using System;
using Sandbox;

public enum BulletType { Normal, Armor, FrozenShard, Punch }
public enum BulletStat
{
	Damage, Force, AddTempWeight, Lifetime, NumPiercing, NumBouncing, CriticalChance, CriticalMultiplier, 
	ApplyFire, ApplyFreeze, ApplyPoison, 
	BulletSpread, BulletInaccuracy, BulletLifetime,
	GrowDamageAmount, DistanceDamageAmount, HealTeammateAmount, MoveRandomly, HomingRadius, IsReturning, SplashDamagePercent, BounceDamageIncrease, BounceResetLifetime, 
	CanHitShooter, FriendlyFire, BounceTarget, ArcHeight, OverflowPercent, IsArmorBullet, StartFromGround, NumGroundHops,
	NumPunchExtraHits, IsForcePunch, AimAtCursorProgress, ForceRandomDir, Explosive, LifestealPercent,
}

public class Bullet : Thing
{
	[Property] public ModelRenderer Model { get; set; }

	public Dictionary<BulletStat, float> Stats = new();

	public Player Shooter { get; set; }

	public TimeSince TimeSinceInitialSpawn { get; protected set; } // not restarted when bouncing etc
	private bool _hasFinishedFadingIn;
	public const float FADE_IN_TIME = 0.1f;

	private TimeSince _timeSinceBounce = 99f;

	public float BaseZPos { get; set; }

	public List<Thing> HitThings { get; private set; } = new();

	public BulletHomingDetector HomingDetector { get; set; }
	public bool HasHomed { get; set; }

	private bool _hasReturned;
	private TimeSince _timeSinceSteer;

	private bool _hasDoneFirstUpdate;

	public int StartingNumPierce { get; set; }
	public int StartingNumBounce { get; set; }

	private bool _sizeDirty;

	private TimeSince _timeSinceUpdateDamage;
	private const float UPDATE_DAMAGE_INTERVAL = 0.2f;

	private bool _shouldMoveRandomly;
	private TimeSince _timeSinceRandomMove;
	private float _randomMoveDelay;

	private const float SPLASH_RADIUS = 65f;

	[Property, Hide] public bool ShowPierce { get; set; }
	[Property, Hide] public bool ShowSplash { get; set; }
	[Property, Hide] public bool ShowBounce { get; set; }
	[Property, Hide] public BulletType BulletType { get; set; }
	[Property, Hide] public Color Color { get; set; } = Color.White;

	public bool IsLensBuffed { get; set; }

	private bool _isRemoved;

	private bool _shouldAimAtCursor;

	public Vector2 LastPos2D { get; private set; }

	protected override void OnAwake()
	{
		base.OnAwake();

		Model.Tint = Color.White.WithAlpha( 0f );

		if ( IsProxy )
			return;
	}

	protected override void OnStart()
	{
		base.OnStart();

		Radius = 5f;

		Transform.ClearInterpolation();

		TimeSinceInitialSpawn = 0f;

		if ( IsProxy )
			return;

		CollideWithTags.Add( "enemy" );
		CollideWithTags.Add( "orbiter_shield_enemy" );
		CollideWithTags.Add( "obstacle" );

		_timeSinceUpdateDamage = UPDATE_DAMAGE_INTERVAL * 2f;
		_sizeDirty = true;
		_timeSinceRandomMove = 0f;
		_randomMoveDelay = Game.Random.Float( 0.025f, 0.15f );

		LastPos2D = Position2D;
	}

	public void Init()
	{
		DetermineSize();

		StartingNumPierce = (int)Stats[BulletStat.NumPiercing];
		StartingNumBounce = (int)Stats[BulletStat.NumBouncing];
		ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;

		_shouldMoveRandomly = Stats[BulletStat.MoveRandomly] > 0f;
		_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;

		ShowPierce = Stats[BulletStat.NumPiercing] > 0f;
		ShowSplash = Stats[BulletStat.SplashDamagePercent] > 0f || Stats[BulletStat.Explosive] > 0f;
		ShowBounce = Stats[BulletStat.NumBouncing] > 0f;

		Vector3 colorVec = new Vector3( 1f, 1f, 1f );
		int numColors = 1;

		//if ( Stats[BulletStat.HomingRadius] > 0f )
		//colorVec += new Vector3( 0.6f, 0.6f, 0f );

		if ( Stats[BulletStat.ApplyFire] > 0f )
		{
			colorVec += new Vector3( 10f, 0f, 0f );
			numColors++;
		}

		if ( Stats[BulletStat.ApplyFreeze] > 0f )
		{
			colorVec += new Vector3( 2f, 2f, 9f );
			numColors++;
		}

		if ( Stats[BulletStat.ApplyPoison] > 0f )
		{
			colorVec += new Vector3( 0f, 10f, 0f );
			numColors++;
		}

		if ( Stats[BulletStat.HealTeammateAmount] > 0f )
		{
			colorVec += new Vector3( 0f, 10f, 0f );
			numColors++;
		}

		if ( numColors > 1 )
		{
			colorVec = (colorVec / numColors).Normal;
			Color = new Color( colorVec.x, colorVec.y, colorVec.z );
		}

		if ( BulletType == BulletType.Punch )
		{
			//Color = Color.WithAlpha( 0.5f );
			Model.Enabled = false;
		}
	}

	void FirstUpdate()
	{
		if ( BulletType == BulletType.Normal )
			RefreshBodyGroups();

		//if ( BulletType != BulletType.Punch )
		//	Model.Tint = Color;

		if ( IsProxy )
			return;

		if ( Stats[BulletStat.HomingRadius] > 0f )
		{
			var detectorGo = GameObject.Clone( "prefabs/bullet_homing_detector.prefab", new CloneConfig { Parent = GameObject, StartEnabled = true } );
			HomingDetector = detectorGo.GetComponent<BulletHomingDetector>();
			HomingDetector.Radius = Stats[BulletStat.HomingRadius];
			HomingDetector.SphereCollider.Radius = Stats[BulletStat.HomingRadius];
			HomingDetector.Bullet = this;
		}

		if ( Stats[BulletStat.CanHitShooter] > 0f || Stats[BulletStat.FriendlyFire] > 0f || Stats[BulletStat.HealTeammateAmount] > 0f )
			CollideWithTags.Add( "player" );

		if ( BulletType == BulletType.Armor )
		{
			Model.Model = Manager.Instance.ArmorBulletModel;
			SetDirection( Velocity.Normal );
			_sizeDirty = true;

			// todo: needs to change color or appearance for pierce, fire, etc?
		}
		else if ( BulletType == BulletType.FrozenShard )
		{
			Model.Model = Manager.Instance.FrozenShardBulletModel;
			_sizeDirty = true;

			// todo: needs to change color or appearance for pierce, fire, etc?
		}

		_hasDoneFirstUpdate = true;
	}

	void RefreshBodyGroups()
	{
		if ( ShowPierce )
			Model.SetBodyGroup( 2, 1 );
		else
			Model.SetBodyGroup( 2, 0 );

		if ( ShowSplash )
			Model.SetBodyGroup( 1, 1 );
		else 
			Model.SetBodyGroup( 1, 0 );

		if ( ShowBounce )
			Model.SetBodyGroup( 0, 1 );
		else
			Model.SetBodyGroup( 0, 0 );
	}

	void DetermineSize()
	{
		var damage = Stats[BulletStat.Damage];
		var scale = damage < 30f
			? Utils.Map( damage, 0f, 30f, 0.4f, 2.25f, EasingType.QuadOut )
			: Utils.Map( damage, 30f, 150f, 2.25f, 3.5f, EasingType.QuadIn );

		Radius = 5f * scale;

		float scaleModifier = 1f;
		if ( BulletType == BulletType.Armor ) // todo: armor sphereCollider is small, since the model is too large and is scaled down
			scaleModifier = Utils.Map( damage, 0f, 5f, 0.2f, 0.13f );
		else if ( BulletType == BulletType.FrozenShard )
			scaleModifier = 0.92f;
		//else if ( BulletType == BulletType.Punch )
		//	scaleModifier = 3f;

		WorldScale = new Vector3( scale * scaleModifier );
		
		//_pfakeshadow.Set("Size", 9f * Scale);

		_sizeDirty = false;
		_timeSinceUpdateDamage = 0f;
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		if ( !_hasDoneFirstUpdate )
			FirstUpdate();

		if( BulletType != BulletType.Punch )
		{
			if ( !_hasFinishedFadingIn )
			{
				if ( TimeSinceInitialSpawn < FADE_IN_TIME )
				{
					Model.Tint = Color.WithAlpha( Utils.Map( TimeSinceInitialSpawn, 0f, FADE_IN_TIME, 0f, 1f ) );
				}
				else
				{
					Model.Tint = Color;
					_hasFinishedFadingIn = true;
				}
			}
		}

		if ( IsProxy )
			return;

		//var sphereCollider = Collider as SphereCollider;
		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"{sphereCollider.Radius}", new global::Transform( WorldPosition ) );

		//Gizmo.Draw.Color = Color.White;
		//Gizmo.Draw.Text( $"{Stats[BulletStat.NumBouncing]}/{StartingNumBounce}", new global::Transform( WorldPosition + new Vector3( 0f, 0f, -5f ) ) );

		//Gizmo.Draw.LineCircle( WorldPosition, Vector3.Up, Radius );

		if ( Stats[BulletStat.DistanceDamageAmount] > 0f )
		{
			var dist = (Position2D - LastPos2D).Length;
			Stats[BulletStat.Damage] += Stats[BulletStat.DistanceDamageAmount] * Utils.Unit2Meter * dist;
			_sizeDirty = true;
		}

		LastPos2D = Position2D;

		if ( Math.Abs( Stats[BulletStat.GrowDamageAmount] ) > 0f )
		{
			Stats[BulletStat.Damage] += Stats[BulletStat.GrowDamageAmount] * Time.Delta;
			_sizeDirty = true;

			if ( Stats[BulletStat.Damage] <= 0f )
			{
				Remove();
				return;
			}
		}

		if ( _sizeDirty && _timeSinceUpdateDamage > UPDATE_DAMAGE_INTERVAL )
			DetermineSize();

		if( Shooter.IsValid() && Shooter.Stats[PlayerStat.BulletSteering] > 0f )
			HandleSteering();

		if ( _shouldMoveRandomly )
			HandleRandomMovement();

		if ( Stats[BulletStat.IsReturning] > 0f )
			HandleReturning();

		if ( _shouldAimAtCursor )
			HandleAimAtCursor();

		var lifetime = Stats[BulletStat.Lifetime];

		float zPos;
		if( BulletType == BulletType.Punch )
		{
			zPos = BaseZPos;
		}
		else if( Stats[BulletStat.ArcHeight] > 0f )
		{
			var startZPos = Stats[BulletStat.StartFromGround] > 0f ? 0f : BaseZPos;

			zPos = TimeSinceSpawn < lifetime * 0.5f
				? Utils.Map( TimeSinceSpawn, 0f, lifetime * 0.5f, startZPos, Stats[BulletStat.ArcHeight], EasingType.QuadOut )
				: Utils.Map( TimeSinceSpawn, lifetime * 0.5f, lifetime, Stats[BulletStat.ArcHeight], 0f, EasingType.QuadIn );

			WorldRotation = Rotation.From( Utils.Map(TimeSinceSpawn, 0f, lifetime, -65f, 65f ), WorldRotation.Yaw(), 0f );
		}
		else if( Stats[BulletStat.StartFromGround] > 0f )
		{
			zPos = Utils.MapReturn( TimeSinceSpawn, 0f, lifetime, 0f, BaseZPos, EasingType.QuadOut );

			WorldRotation = Rotation.From( Utils.Map( TimeSinceSpawn, 0f, lifetime, -45f, 45f ), WorldRotation.Yaw(), 0f );
		}
		else
		{
			zPos = Utils.Map( TimeSinceSpawn, 0f, lifetime, BaseZPos, 0f, EasingType.QuartIn );

			if ( BulletType != BulletType.Armor )
				WorldRotation = Rotation.From( Utils.Map( TimeSinceSpawn, 0f, lifetime, 0f, 25f, EasingType.QuartIn ), WorldRotation.Yaw(), 0f );
		}

		if ( Manager.Instance.IsWindActive )
			Velocity += Manager.Instance.GlobalWindForce * 2f * Time.Delta; // todo: less affected by wind if larger

		WorldPosition = (WorldPosition + (Vector3)Velocity * Time.Delta).WithZ( zPos );

		if ( TimeSinceSpawn > lifetime )
		{
			HitGround();
			return;
		}
	}

	void HandleSteering()
	{
		if ( _timeSinceSteer < 0.075f )
			return;

		if( Stats[BulletStat.IsReturning] > 0f )
		{
			var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
			if ( lifetimeProgress > 0.5f && lifetimeProgress < 0.75f )
				return;
		}

		if ( _timeSinceBounce < 0.15f )
			return;

		//if ( _shouldMoveRandomly && _timeSinceRandomMove < 0.05f )
		//	return;

		var targetDir = Shooter.FacingDir;
		//var targetDir = (Manager.Instance.MouseWorldPos - Position2D).Normal;
		SetDirection( Utils.DynamicEaseTo( Velocity.Normal, targetDir, Utils.Map( TimeSinceSpawn, 0f, 0.1f, 0f, (_shouldMoveRandomly || HasHomed) ? 0.1f : 0.5f, EasingType.QuadIn ), _timeSinceSteer ) );
		//SetDirection( targetDir );

		_timeSinceSteer = 0f;
	}

	void HandleRandomMovement()
	{
		if( _timeSinceRandomMove > _randomMoveDelay )
		{
			var newDir = Utils.RotateVector( Velocity, Game.Random.Float( 10f, 45f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f) ).Normal;
			SetDirection( newDir );
			_timeSinceRandomMove = 0f;
			_randomMoveDelay = Game.Random.Float( 0.1f, 0.3f );
		}
	}

	void HandleReturning()
	{
		var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
		if ( !_hasReturned && lifetimeProgress > 0.5f )
		{
			if ( Shooter.IsValid() )
			{
				Vector2 dir = (Shooter.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (Shooter.Position2D - Position2D).Normal
					: Utils.GetRandomVector();

				SetDirection( dir );
			}
			else
			{
				SetDirection( -Velocity.Normal );
			}

			_hasReturned = true;
			HitThings.Clear();

			if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
				HomingDetector.Refresh();

			_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;
		}
	}

	void HandleAimAtCursor()
	{
		var lifetimeProgress = Utils.Map( TimeSinceSpawn, 0f, Stats[BulletStat.Lifetime], 0f, 1f );
		if ( lifetimeProgress > Stats[BulletStat.AimAtCursorProgress] ) // todo: if you get LazyBullets perk, it feels like it takes too long to activate. activate on whichever comes first, lifetime progress or elapsed time threshold?
		{
			var aimWorldPos = Input.UsingController && Shooter.IsValid() ? Shooter.Position2D + Shooter.AimDir * 1000f : Manager.Instance.MouseWorldPos;
			var targetDir = (aimWorldPos - Position2D).Normal;
			SetDirection( targetDir );

			_shouldAimAtCursor = false;
		}
	}

	public override void Colliding( Thing other, float percent, float dt )
	{
		base.Colliding( other, percent, dt );

		if ( _isRemoved || !Shooter.IsValid() || HitThings.Contains( other ) )
			return;

		bool didHit = false;
		bool hitObstacle = false;
		float dmg = Stats[BulletStat.Damage];
		float dmgUsed = 0f;

		if ( other is Enemy enemy )
		{
			//if ( enemy.IsDying || (enemy.IsSpawning && enemy.SpawnProgress < 0.7f) )
			if ( enemy.IsSpawning && enemy.SpawnProgress < 0.7f )
				return;

			if ( Stats[BulletStat.ArcHeight] > 0f && TimeSinceSpawn < Stats[BulletStat.Lifetime] * 0.25f )
				return;

			didHit = true;

			bool isCrit = CheckCrit( ref dmg );

			dmgUsed = enemy.IsInvincible ? dmg : Math.Min( enemy.Health, dmg );
			bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;

			if( !enemy.IsInvincible )
			{
				if ( Stats[BulletStat.ApplyFire] > 0f )
					enemy.Ignite( playerSource: Shooter, enemySource: null, enemyType: EnemyType.None, Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance], Shooter.Stats[PlayerStat.FireDmgStack] > 0f );

				if ( Stats[BulletStat.ApplyFreeze] > 0f )
					enemy.Freeze( playerSource: Shooter, enemySource: null, Shooter.Stats[PlayerStat.FreezeTimeScale], Shooter.Stats[PlayerStat.FreezeLifetime] );

				if ( Stats[BulletStat.ApplyPoison] > 0f && !enemy.IsInanimate )
				{
					enemy.Poison(
						Shooter,
						enemySource: null,
						enemyType: EnemyType.None,
						Shooter.Stats[PlayerStat.PoisonDamage],
						Shooter.Stats[PlayerStat.PoisonFinishDamagePercent],
						Shooter.Stats[PlayerStat.PoisonDieSpreadChance],
						Shooter.Stats[PlayerStat.RadiusMultiplier],
						Shooter.Stats[PlayerStat.PoisonFlammable] > 0f,
						Shooter.Stats[PlayerStat.PoisonTickTimeModifier],
						(int)Shooter.Stats[PlayerStat.PoisonNumHitsToRemove]
					);
				}
			}

			Vector2 dir = Velocity.Normal;

			float damageDealt = overflow ? dmgUsed : dmg;

			// todo: overflow damage doesn't include additional dmg to enemy...

			var shouldFlinch =  damageDealt < enemy.MaxHealth * 0.05f ? false : true;

			var forceFactor = Stats[BulletStat.Damage] < 5f
				? Utils.Map( Stats[BulletStat.Damage], 0f, 5f, 0f, 1f )
				: Utils.Map( Stats[BulletStat.Damage], 5f, 100f, 1f, 4f );

			var forceDir = Stats[BulletStat.ForceRandomDir] > 0f ? (Game.Random.Float( 0f, 1f ) < 0.4f ? Utils.GetRandomVectorInCone( -dir, 180f ) : Utils.GetRandomVector()) : dir;
			var force = forceDir * Stats[BulletStat.Force] * forceFactor;

			if ( Manager.Instance.LaunchEnemies && !enemy.IsInTheAir && !enemy.IsSpawning )
			{
				var targetPos = enemy.Position2D + Utils.GetRandomVector() * Game.Random.Float( 80f, 350f );

				enemy.JumpRpc( Manager.Instance.ClampPosToBounds( targetPos ), height: Game.Random.Float( 80f, 120f ), lifetime: Game.Random.Float( 1.1f, 1.5f ) );
			}

			bool shouldDmgEnemy = true;
			if( Stats[BulletStat.Explosive] > 0f )
			{
				int numPierce = (int)Stats[BulletStat.NumPiercing];
				int numBounce = (int)Stats[BulletStat.NumBouncing];
				if ( numPierce == 0 && numBounce == 0 )
					shouldDmgEnemy = false; // it should only apply explosion damage, not direct hit damage
			}

			if( shouldDmgEnemy )
			{
				var damageType = BulletType == BulletType.Punch ? DamageType.Punch : DamageType.Bullet;
				enemy.DamageRpc( damageDealt, Shooter, damageType, WorldPosition, force, isCrit, shouldFlinch );

				if( Stats[BulletStat.LifestealPercent] > 0f && Shooter.IsValid() && !Shooter.IsDead )
				{
					float healAmount = damageDealt * (Stats[BulletStat.LifestealPercent]);
					Shooter.Heal( healAmount );
				}
			}

			if ( Stats[BulletStat.IsForcePunch] > 0f )
				enemy.Punched( Shooter );
		}
		else if ( other is Player player )
		{
			if ( Stats[BulletStat.HealTeammateAmount] > 0f && player != Shooter && player.Health < player.GetSyncStat(PlayerStat.MaxHp) )
			{
				didHit = true;

				// todo: sfx
				player.HealRpc( Stats[BulletStat.HealTeammateAmount], otherPlayerHealer: Shooter );
			}
			else if ( TimeSinceSpawn > 0.1f && ( ( Stats[BulletStat.CanHitShooter] > 0f && player == Shooter && !player.IsProxy) || (player != Shooter && player.GetSyncStat(PlayerStat.TakeFriendlyDmg) > 0f) ) )
			{
				if ( Stats[BulletStat.ApplyFire] > 0f )
					player.Ignite( Shooter, enemySource: null, enemyType: EnemyType.None, Shooter.Stats[PlayerStat.FireDamage], Shooter.Stats[PlayerStat.FireLifetime], Shooter.Stats[PlayerStat.FireSpreadChance], Shooter.Stats[PlayerStat.FireDmgStack] > 0f );

				if ( Stats[BulletStat.ApplyFreeze] > 0f )
					player.Freeze( playerSource: Shooter, enemySource: null, Shooter.Stats[PlayerStat.FreezeTimeScale], Shooter.Stats[PlayerStat.FreezeLifetime] );

				if ( Stats[BulletStat.ApplyPoison] > 0f )
				{
					player.Poison(
						Shooter,
						enemySource: null,
						enemyType: EnemyType.None,
						Shooter.Stats[PlayerStat.PoisonDamage],
						Shooter.Stats[PlayerStat.PoisonFinishDamagePercent],
						Shooter.Stats[PlayerStat.PoisonDieSpreadChance],
						Shooter.Stats[PlayerStat.RadiusMultiplier],
						Shooter.Stats[PlayerStat.PoisonFlammable] > 0f,
						Shooter.Stats[PlayerStat.PoisonTickTimeModifier],
						(int)Shooter.Stats[PlayerStat.PoisonNumHitsToRemove]
					);
				}

				Vector2 dir = (player.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (player.Position2D - Position2D).Normal
					: Utils.GetRandomVector();

				var forceFactor = Stats[BulletStat.Damage] < 5f
				? Utils.Map( Stats[BulletStat.Damage], 0f, 5f, 0f, 1f )
				: Utils.Map( Stats[BulletStat.Damage], 5f, 100f, 1f, 4f );

				//if ( Stats[BulletStat.Force] > 0f )
				//	player.AddVelocity( dir * Stats[BulletStat.Force] * (1f / 2f) ); //(1f / player.Weight);
				var force = Stats[BulletStat.Force] * (1f / player.Weight) * forceFactor;

				didHit = true;
				dmgUsed = Math.Min( player.Health, dmg );
				bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;

				bool isCrit = Game.Random.Float( 0f, 1f ) < Stats[BulletStat.CriticalChance];
				float damage = (overflow ? dmgUsed : dmg) * (isCrit ? Stats[BulletStat.CriticalMultiplier] : 1f);

				var hitPos = player.Position2D - dir * player.Radius;
				var isSelfInflicted = Stats[BulletStat.CanHitShooter] > 0f && player == Shooter;

				var damageFlags = PlayerDamageFlags.None;
				if ( isSelfInflicted )
					damageFlags |= PlayerDamageFlags.SelfInflicted;

				player.DamageRpc( damage, DamageType.Bullet, hitPos, dir, upwardAmount: 0f, force, ragdollForce: force * 0.1f, enemySource: null, enemyType: EnemyType.None, damageFlags: damageFlags );
			}
		}
		else if ( other is OrbiterShieldEnemy orbiterShieldEnemy )
		{
			if ( orbiterShieldEnemy.IsActive )
			{
				orbiterShieldEnemy.Block( Position2D );

				var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
				Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );

				Remove();
			}
		}
		else if( other is Obstacle obstacle )
		{
			didHit = true;

			var normal = (Position2D - obstacle.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
				? (Position2D - obstacle.Position2D).Normal
				: Utils.GetRandomVector();

			Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition, normal, Color.White );

			obstacle.PlayHitSfxRpc( WorldPosition );

			hitObstacle = true;
		}

		if ( didHit )
		{
			int numPierce = (int)Stats[BulletStat.NumPiercing];
			int numBounce = (int)Stats[BulletStat.NumBouncing];

			if ( numPierce > 0 && !hitObstacle )
			{
				Pierce( other );
			}
			else
			{
				if ( numBounce > 0 )
				{
					Bounce( other );
				}
				else
				{
					if ( Stats[BulletStat.SplashDamagePercent] > 0f )
						Splash( except: other as Enemy );

					if ( Stats[BulletStat.Explosive] > 0f )
						Explode();

					bool overflow = Stats[BulletStat.OverflowPercent] > 0f && dmgUsed < dmg;
					if ( overflow )
					{
						Stats[BulletStat.Damage] = (dmg - dmgUsed) * Stats[BulletStat.OverflowPercent];
						_sizeDirty = true;

						HitThings.Add( other );
					}
					else
					{
						if ( BulletType == BulletType.Punch && (int)Stats[BulletStat.NumPunchExtraHits] > 0f )
						{
							Punch( other );
							return;
						}

						//Manager.Instance.SpawnBulletImpactParticles( WorldPosition, -Velocity, Color.White );
						Remove();
					}
				}
			}
		}
	}

	bool CheckCrit( ref float dmg )
	{
		bool playCritSfx = false;
		float sfxPitch = 0.85f;

		bool isCrit = false;
		var critChance = Stats[BulletStat.CriticalChance];
		while ( true )
		{
			if ( Game.Random.Float( 0f, 1f ) < critChance )
			{
				if ( Shooter.IsValid() && Shooter.Stats[PlayerStat.CritStreak] > 0f && Shooter.Stats[PlayerStat.CritStreakDmgAmount] > 0f )
					dmg *= (1f + Shooter.Stats[PlayerStat.CritStreak] * Shooter.Stats[PlayerStat.CritStreakDmgAmount]);

				dmg *= Math.Max( Stats[BulletStat.CriticalMultiplier], 0f );

				isCrit = true;
				playCritSfx = true;
				sfxPitch *= 1.2f;

				if ( Shooter.IsValid() && Shooter.Stats[PlayerStat.CritMultipleChance] > 0f )
				{
					critChance *= Shooter.Stats[PlayerStat.CritMultipleChance];
					continue;
				}
			}

			break;
		}

		if( playCritSfx )
			Manager.Instance.PlaySfxNearbyRpc( "crit2", Position2D, pitch: sfxPitch * Game.Random.Float(1.2f, 1.3f), volume: 0.75f, maxDist: 300f );

		return isCrit;
	}

	void Punch( Thing other )
	{
		Stats[BulletStat.NumPunchExtraHits] -= 1f;
		HitThings.Add( other );
	}

	void Pierce( Thing other )
	{
		Stats[BulletStat.NumPiercing] -= 1f;
		HitThings.Add( other );

		if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
			HomingDetector.Refresh();

		Shooter?.BulletPierce( this, other );

		if( (int)Stats[BulletStat.NumPiercing] <= 0 )
		{
			ShowPierce = false;

			if ( BulletType == BulletType.Normal )
				RefreshBodyGroups();
		}

		if ( Stats[BulletStat.AimAtCursorProgress] > 0f )
		{
			_shouldAimAtCursor = true;
			Stats[BulletStat.AimAtCursorProgress] = Game.Random.Float( 0.2f, 0.4f );
		}
	}

	void Bounce( Thing other )
	{
		Vector2 dir = (Position2D - other.Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
					? (Position2D - other.Position2D).Normal
					: Utils.GetRandomVector();

		if ( Stats[BulletStat.BounceTarget] > 0f )
			dir = GetBounceTargetDir( dir, other );

		SetDirection( dir );

		ApplyBounceEffects();

		HitThings.Add( other );

		Shooter?.BulletBounce( this, other );
	}

	void ApplyBounceEffects( bool outOfBounds = false )
	{
		Stats[BulletStat.NumBouncing] -= 1f;
		ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;
		HitThings.Clear();

		if ( Stats[BulletStat.BounceDamageIncrease] > 0f )
		{
			Stats[BulletStat.Damage] *= (1f + Stats[BulletStat.BounceDamageIncrease]);
			_sizeDirty = true;
		}

		_timeSinceBounce = 0f;

		if ( Stats[BulletStat.BounceResetLifetime] > 0f )
			TimeSinceSpawn = 0f;

		if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
			HomingDetector.Refresh();

		_hasReturned = false;

		if ( (int)Stats[BulletStat.NumBouncing] <= 0 )
		{
			ShowBounce = false;

			if ( BulletType == BulletType.Normal )
				RefreshBodyGroups();
		}

		if( Stats[BulletStat.ArcHeight] > 0f && !outOfBounds )
		{
			TimeSinceSpawn = 0f;
			BaseZPos = WorldPosition.z;
		}

		if ( Stats[BulletStat.AimAtCursorProgress] > 0f )
		{
			_shouldAimAtCursor = true;
			Stats[BulletStat.AimAtCursorProgress] = Game.Random.Float( 0.2f, 0.4f );
		}
	}

	public void SetupArc( float arcHeight, float extraBounces )
	{
		Stats[BulletStat.ArcHeight] = arcHeight;
		Stats[BulletStat.NumBouncing] += extraBounces;
		if ( Stats[BulletStat.NumBouncing] > 0f )
		{
			ShowBounce = true;
			ShouldCheckBounds = true;
		}
		StartingNumBounce = (int)Stats[BulletStat.NumBouncing];
	}

	Vector2 GetBounceTargetDir( Vector2 dir, Thing other )
	{
		var closestUnit = Manager.Instance.GetClosestEnemy( Position2D, onlyCountsAsKill: false, except: other );
		if ( closestUnit.IsValid() )
		{
			dir = (closestUnit.Position2D - Position2D).LengthSquared > Manager.TOUCH_DIST_REQUIRED_SQR
				? (closestUnit.Position2D - Position2D).Normal
				: Utils.GetRandomVector();
		}

		return dir;
	}

	// bullet doesn't ShouldCheckBounds unless it has bounces
	protected override void OnOutOfBounds( Direction direction )
	{
		base.OnOutOfBounds( direction );

		Vector2 dir = Velocity.Normal;

		if ( Stats[BulletStat.BounceTarget] > 0f )
			dir = GetBounceTargetDir( dir, other: null );

		SetDirection( dir );

		ApplyBounceEffects( outOfBounds: true );

		var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.4f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
		Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition, dir, Color.White, scaleMultiplier );

		Manager.Instance.PlaySfxNearbyRpc( "bullet.impact", Position2D, pitch: Game.Random.Float( 1.3f, 1.4f ), volume: 0.8f, maxDist: 250f );

		// todo: when enabled, PerkBulletBounceCopy seems to trigger way too often
		//Shooter?.BulletBounce( this, other: null );
	}

	public void Restart()
	{
		TimeSinceSpawn = 0f;
		_timeSinceBounce = 99f;
		HitThings.Clear();
		Stats[BulletStat.NumPiercing] = StartingNumPierce;
		Stats[BulletStat.NumBouncing] = StartingNumBounce;
		ShouldCheckBounds = Stats[BulletStat.NumBouncing] > 0f;

		if ( Stats[BulletStat.HomingRadius] > 0f && HomingDetector.IsValid() )
			HomingDetector.Refresh();

		_hasReturned = false;

		_shouldAimAtCursor = Stats[BulletStat.AimAtCursorProgress] > 0f;
	}

	public void SetDirection( Vector2 dir )
	{
		Velocity = dir * Velocity.Length;

		if( BulletType == BulletType.Armor )
		{
			WorldRotation = Rotation.From( -90f, -Utils.GetAngleDegreesFromVector( dir ), 0f );
			return;
		}

		WorldRotation = Rotation.From( 0f, -Utils.GetAngleDegreesFromVector( dir ), 0f );
	}

	public void ApplyHoming( Vector2 dir )
	{
		SetDirection( dir );
		HitThings.Clear();
		_timeSinceRandomMove = 0f;
	}

	public void Splash( Enemy except = null )
	{
		// todo: sfx *****

		float damage = Stats[BulletStat.Damage] * Stats[BulletStat.SplashDamagePercent];
		float radius = SPLASH_RADIUS * (Shooter?.Stats[PlayerStat.RadiusMultiplier] ?? 1f);

		//SplashDamageEffect( radius );
		Manager.Instance.SpawnRingRpc( Position2D, radius, new Color( 0.4f, 0.4f, 1f, 0.55f ), lifetime: Game.Random.Float(0.15f, 0.25f), path: "ring_spiky_2" );

		//Gizmo.Draw.Color = new Color( 1f, 0f, 1f, 1f );
		//Gizmo.Draw.LineSphere( WorldPosition, radius );

		//Manager.Instance.SpawnRing( Position2D, radius, Game.Random.Float( 0.2f, 0.3f ), new Color( 0.3f, 0.3f, 1f, 0.6f ) );

		Manager.Instance.DamageNearbyEnemies( Position2D, radius, damage, Stats[BulletStat.Force], DamageType.BulletSplash, Shooter, except );
	}

	[Rpc.Broadcast]
	public void RemoveRpc()
	{
		if ( IsProxy )
			return;

		GameObject.Destroy();
	}

	public void Remove()
	{
		_isRemoved = true;
		GameObject.Destroy();
	}

	[Rpc.Broadcast]
	public void LensBuff( float multiplier )
	{
		if ( IsLensBuffed )
			return;

		IsLensBuffed = true;

		var outline = GetComponent<HighlightOutline>( includeDisabled: true );
		if ( outline.IsValid() )
			outline.Enabled = true;

		//RenderColor = Color.Lerp( RenderColor, Color.FromBytes( 255, 30, 90, 255 ), 0.6f );

		//var glow = Components.GetOrCreate<Glow>();
		//glow.Width = 0.2f;
		//glow.Color = Color.FromBytes( 120, 0, 70, 255 );

		Manager.Instance.PlaySfxNearby( "lens", Position2D, pitch: Game.Random.Float( 1.1f, 1.25f ), volume: 0.4f, maxDist: 250f );

		if ( IsProxy )
			return;

		Stats[BulletStat.Damage] *= multiplier;
		_sizeDirty = true;
	}

	void HitGround()
	{
		// arc bullets might be able to bounce on ground
		if ( Stats[BulletStat.ArcHeight] > 0f && Stats[BulletStat.NumBouncing] > 0f && Shooter.IsValid() && Shooter.Stats[PlayerStat.ArcBulletsBounceGround] > 0f )
		{
			Stats[BulletStat.StartFromGround] = 1f;

			if ( Stats[BulletStat.BounceTarget] > 0f )
			{
				var dir = GetBounceTargetDir( Velocity.Normal, other: null );
				SetDirection( dir );
			}

			if ( BulletType != BulletType.Punch )
			{
				var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
				Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );
			}

			ApplyBounceEffects();
			Shooter?.BulletBounce( this, other: null );

			//Manager.Instance.PlaySfxNearbyRpc( "bullet.impact", Position2D, pitch: Game.Random.Float( 1.6f, 1.7f ), volume: 0.5f, maxDist: 200f );

			return;
		}

		if ( BulletType != BulletType.Punch )
		{
			var scaleMultiplier = Utils.Map( Stats[BulletStat.Damage], 1f, 5f, 0.5f, 1f, EasingType.Linear ) * Utils.Map( Stats[BulletStat.Damage], 5f, 30f, 1f, 1.5f, EasingType.Linear );
			Manager.Instance.SpawnBulletImpactParticlesRpc( WorldPosition.WithZ( 10f ), Vector3.Up, Color.White, scaleMultiplier );
		}

		if ( Shooter.IsValid() && BulletType != BulletType.Punch )
			Shooter.BulletHitGround( this );

		if ( Stats[BulletStat.SplashDamagePercent] > 0f )
			Splash();

		if ( Stats[BulletStat.Explosive] > 0f )
			Explode();

		Remove();
	}

	void Explode()
	{
		var damage = Stats[BulletStat.Damage];
		var radius = 75f
			* Utils.Map( damage, 1f, 5f, 0.4f, 1f )
			* Utils.Map( damage, 5f, 30f, 1f, 1.3f )
			* (Shooter.IsValid() ? Shooter.Stats[PlayerStat.RadiusMultiplier] * Shooter.Stats[PlayerStat.ExplosionSizeMultiplier] : 1f);
		
		Manager.Instance.CreateExplosionRpc( (Vector2)WorldPosition, radius, damage, repelRadius: radius * 1.15f, repelForce: damage * 20f, playerSource: Shooter, enemySource: null, enemyType: EnemyType.None, Color.Red );
	}
}