Bullet component for a projectile. Stores stats, handles movement (steering, homing, random movement, returning), collisions with enemies/players/obstacles, piercing/bouncing/splash/explosion logic, visual state (model, tint, size) and RPCs for removal and lens buff.
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 );
}
}