Weapons/Projectile.cs
/// <summary>
/// Physics projectile with gravity, drag, bouncing, and explosion support.
/// Configure via properties and add to a prefab; spawn with Launch().
/// </summary>
[Title( "Projectile" ), Icon( "sports_volleyball" )]
public sealed class Projectile : Component
{
// -------------------------------------------------------------------------
// Properties (configure in prefab inspector)
// -------------------------------------------------------------------------
[Property, Category( "General" ), ResourceType( "sound" )] public string ActiveSoundPath { get; set; }
[Property, Category( "General" )] public Vector2 InitialForce { get; set; }
[Property, Category( "General" )] public float Gravity { get; set; } = 0f;
[Property, Category( "General" )] public float Radius { get; set; } = 8f;
[Property, Category( "General" )] public float Lifetime { get; set; } = 5f;
[Property, Category( "Explosion" )] public bool ExplodeOnDeath { get; set; }
[Property, Category( "Explosion" )] public bool ExplodeOnImpact { get; set; }
[Property, Category( "Explosion" )] public List<string> ExplodeHitTags { get; set; } = new() { "player" };
[Property, Category( "Explosion" )] public float ExplosionRadius { get; set; } = 512f;
[Property, Category( "Explosion" )] public float ExplosionDamage { get; set; } = 100f;
[Property, Category( "Explosion" ), ResourceType( "vpcf" )] public string ExplosionParticlePath { get; set; }
[Property, Category( "Explosion" ), ResourceType( "sound" )] public string ExplosionSoundPath { get; set; }
[Property, Category( "Explosion" ), Range( 0, 1 )] public float SelfDamageScale { get; set; } = 1f;
[Property, Category( "Explosion" )] public Curve ExplosionDamageFalloff { get; set; }
[Property, Category( "Explosion" )] public bool NoDeleteOnExplode { get; set; } = false;
[Property, Category( "Bounce" ), Range( 0, 1 )] public float Bounciness { get; set; }
[Property, Category( "Bounce" ), ResourceType( "sound" )] public string BounceSoundPath { get; set; }
[Property, Category( "Bounce" ), Range( 0, 1000 )] public float BounceSoundMinVelocity { get; set; } = 300f;
[Property, Category( "Drag" )] public float DragScale { get; set; } = 0;
[Property, Category( "Data" )] public WeaponData Data { get; set; }
// -------------------------------------------------------------------------
// Runtime state (set by Launch)
// -------------------------------------------------------------------------
public PlayerPawn Owner { get; set; }
public ProjectileSimulator Simulator { get; set; }
private Vector3 _velocity;
private float _gravityModifier;
private TimeUntil _destroyTime;
private bool _hasExploded;
private SoundHandle _activeSound;
// -------------------------------------------------------------------------
// Launch
// -------------------------------------------------------------------------
/// <summary>
/// Call after cloning the prefab to set the owner, position it at the aim ray, and start simulation.
/// </summary>
public void Launch( PlayerPawn owner, ProjectileSimulator simulator = null )
{
Owner = owner;
Simulator = simulator;
if ( Data != null )
{
ExplosionDamage = Data.ExplosionDamage;
ExplosionRadius = Data.ExplosionRadius;
Lifetime = Data.Lifetime;
InitialForce = new Vector2( Data.InitialForceForward, Data.InitialForceUp );
if ( !string.IsNullOrEmpty( Data.ProjectileActiveSound ) ) ActiveSoundPath = Data.ProjectileActiveSound;
if ( !string.IsNullOrEmpty( Data.ExplosionParticle ) ) ExplosionParticlePath = Data.ExplosionParticle;
if ( !string.IsNullOrEmpty( Data.ExplosionSound ) ) ExplosionSoundPath = Data.ExplosionSound;
}
_destroyTime = Lifetime;
Simulator?.Add( this );
var aimRay = Owner?.AimRay ?? new Ray( WorldPosition, WorldRotation.Forward );
var tr = Scene.Trace.Ray( aimRay, 50f )
.IgnoreGameObject( Owner?.GameObject )
.Run();
WorldPosition = tr.EndPosition;
_velocity = aimRay.Forward * InitialForce.x + Vector3.Up * InitialForce.y;
WorldRotation = Rotation.LookAt( _velocity.Normal );
CreateEffects();
}
private void CreateEffects()
{
if ( !string.IsNullOrEmpty( ActiveSoundPath ) )
{
_activeSound = Sound.Play( ActiveSoundPath, WorldPosition );
if ( _activeSound != null )
{
_activeSound.Volume = 0.75f;
_activeSound.Pitch = 1.5f;
}
}
}
// -------------------------------------------------------------------------
// Simulate (called by ProjectileSimulator or fixed update)
// -------------------------------------------------------------------------
protected override void OnFixedUpdate()
{
if ( Simulator != null ) return; // simulated by ProjectileSimulator instead
if ( IsProxy ) return; // only host simulates networked projectiles
Simulate();
}
public void Simulate()
{
// Drag
var drag = CalculateDrag( _velocity ) * DragScale;
_velocity += drag * Time.Delta;
WorldRotation = Rotation.LookAt( _velocity.Normal );
var newPosition = GetTargetPosition();
var trace = Scene.Trace.Ray( WorldPosition, newPosition )
.Size( Radius )
.IgnoreGameObject( GameObject )
.IgnoreGameObject( Owner?.GameObject )
.WithAnyTags( "solid", "player" )
.Run();
// Sync sound position
if ( _activeSound != null ) _activeSound.Position = trace.EndPosition;
WorldPosition = trace.EndPosition;
if ( _destroyTime )
{
if ( ExplodeOnDeath ) Explode();
Destroy();
return;
}
if ( trace.Hit || trace.StartedSolid )
{
if ( ExplodeOnImpact )
{
Explode();
Destroy();
return;
}
var hitTags = trace.Tags;
if ( hitTags != null && ExplodeHitTags.Any( t => hitTags.Contains( t ) ) )
{
Explode();
if ( !NoDeleteOnExplode )
Destroy();
return;
}
if ( Bounciness > 0f )
{
var reflect = Vector3.Reflect( _velocity.Normal, trace.Normal );
_gravityModifier = 0f;
_velocity = reflect * _velocity.Length * Bounciness;
if ( !string.IsNullOrEmpty( BounceSoundPath ) && _velocity.Length > BounceSoundMinVelocity )
Sound.Play( BounceSoundPath, WorldPosition );
}
}
}
private Vector3 GetTargetPosition()
{
var newPos = WorldPosition + _velocity * Time.Delta;
_gravityModifier += Gravity;
newPos -= new Vector3( 0f, 0f, _gravityModifier * Time.Delta );
return newPos;
}
private static readonly float _airDensity = 1.204f * 0.0164f;
private static Vector3 CalculateDrag( Vector3 velocity )
{
var dragForce = 0.1f * _airDensity * velocity.LengthSquared * 0.5f;
return -dragForce * velocity.Normal;
}
// -------------------------------------------------------------------------
// Explosion
// -------------------------------------------------------------------------
public void Explode()
{
if ( _hasExploded ) return;
_hasExploded = true;
// Broadcast visual effects to all clients
BroadcastExplosionEffects( WorldPosition );
// Damage is always run on host (projectiles are only spawned/simulated on host)
var hits = Scene.FindInPhysics( new Sphere( WorldPosition, ExplosionRadius ) );
// First pass: check if any enemy was caught in the blast
bool hitEnemy = false;
foreach ( var go in hits )
{
var pawn = go.Components.Get<PlayerPawn>( FindMode.EnabledInSelfAndDescendants );
if ( pawn == null || !pawn.IsAlive || pawn == Owner ) continue;
var dist = Vector3.DistanceBetween( WorldPosition, pawn.WorldPosition );
if ( dist > ExplosionRadius ) continue;
var losTr = Scene.Trace.Ray( WorldPosition, pawn.WorldPosition ).WithTag( "world" ).Run();
if ( losTr.Hit ) continue;
hitEnemy = true;
break;
}
foreach ( var go in hits )
{
var pawn = go.Components.Get<PlayerPawn>( FindMode.EnabledInSelfAndDescendants );
if ( pawn == null || !pawn.IsAlive ) continue;
var dist = Vector3.DistanceBetween( WorldPosition, pawn.WorldPosition );
if ( dist > ExplosionRadius ) continue;
var losTr = Scene.Trace.Ray( WorldPosition, pawn.WorldPosition )
.WithTag( "world" )
.Run();
if ( losTr.Hit ) continue;
// Skip self-damage if the explosion also caught an enemy
if ( pawn == Owner && hitEnemy ) continue;
var distanceMul = ExplosionDamageFalloff.Evaluate( dist / ExplosionRadius );
var dmg = ExplosionDamage * distanceMul;
if ( pawn == Owner ) dmg *= SelfDamageScale;
pawn.TakeDamage( dmg, Owner );
var rb = go.Components.Get<Rigidbody>( FindMode.EnabledInSelfAndDescendants );
if ( rb != null )
{
var forceDir = (pawn.WorldPosition - WorldPosition).Normal;
rb.ApplyImpulse( forceDir * distanceMul * rb.PhysicsBody.Mass );
}
}
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void BroadcastExplosionEffects( Vector3 position )
{
if ( !string.IsNullOrEmpty( ExplosionSoundPath ) )
Sound.Play( ExplosionSoundPath, position );
if ( !string.IsNullOrEmpty( ExplosionParticlePath ) )
{
var prefabFile = ResourceLibrary.Get<PrefabFile>( ExplosionParticlePath );
if ( prefabFile != null )
SceneUtility.GetPrefabScene( prefabFile )?.Clone( new CloneConfig
{
Transform = new Transform( position ),
StartEnabled = true
} );
}
}
// -------------------------------------------------------------------------
// Cleanup
// -------------------------------------------------------------------------
private void Destroy()
{
_activeSound?.Stop();
Simulator?.Remove( this );
GameObject.Destroy();
}
protected override void OnDestroy()
{
_activeSound?.Stop();
Simulator?.Remove( this );
}
}