Entities/SwapperProjectile.cs
namespace BlockParty;
/// <summary>A slower magical bolt that moves its owning Swapper portal to the first solid it hits. Collision
/// is swept like the Gunner's <see cref="Bullet"/> (see <see cref="ProjectileCollision"/>): the earliest surface
/// on each step wins, and the first step starts back at the caster's centre so a solid hugging them is hit.</summary>
public sealed class SwapperProjectile : Entity2D
{
private const int HITBOX_SIZE = 3;
private const float MAX_LIFETIME = 8f;
private const float TRAIL_INTERVAL = 0.05f;
private const int IMPACT_PARTICLES = 18;
private const int DEPARTURE_PARTICLES = 12;
private static readonly Color OuterColor = new( 72 / 255f, 205 / 255f, 218 / 255f, 0.58f );
private static readonly Color CoreColor = new( 180 / 255f, 248 / 255f, 1f, 0.95f );
private static readonly Color AccentColor = new( 1f, 0.82f, 0.38f, 0.9f );
private Vector2 _direction;
private Portal _portal;
private int _impactHitStopFrames;
private float _portalSurfacePadding;
private float _age;
private float _trailTimer;
private float _visualPhase;
private SpriteRenderer _outerSprite;
private SpriteRenderer _coreSprite;
private SpriteRenderer _accentSprite;
// The caster's centre at fire time; the first step sweeps from here (the muzzle spawns clear of the body).
private Vector2 _sweepOrigin;
private bool _sweepFromOrigin;
public GameStage Stage { get; set; }
public Portal TargetPortal => _portal;
public void Setup( Vector2 direction, Vector2 sweepOrigin, Portal portal, float speed, int impactHitStopFrames, float portalSurfacePadding )
{
_direction = Utils.Normalized( direction );
_sweepOrigin = sweepOrigin;
_sweepFromOrigin = true;
_portal = portal;
_impactHitStopFrames = Math.Max( 0, impactHitStopFrames );
_portalSurfacePadding = Math.Max( 0f, portalSurfacePadding );
Velocity = _direction * Math.Max( 1f, speed );
Size = new Vector2( HITBOX_SIZE, HITBOX_SIZE );
}
public void CreateVisuals()
{
_outerSprite = AddLayer( new Vector2( 7f, 7f ), OuterColor, 0 );
_coreSprite = AddLayer( new Vector2( 4f, 4f ), CoreColor, 1 );
_accentSprite = AddLayer( new Vector2( 2f, 2f ), AccentColor, 2 );
}
public override void Tick( float dt )
{
_age += dt;
if ( _age > MAX_LIFETIME || _portal is null )
{
Dead = true;
return;
}
_visualPhase += dt * 4f;
_visualPhase -= MathF.Floor( _visualPhase );
float pulse = 0.5f - 0.5f * MathF.Cos( _visualPhase * MathF.Tau );
_outerSprite.Size = new Vector2( 6.25f + pulse, 6.25f + pulse );
_coreSprite.Size = new Vector2( 3.5f + pulse * 0.5f, 3.5f + pulse * 0.5f );
_accentSprite.Color = Color.Lerp( AccentColor, CoreColor, pulse );
Vector2 previousPos = _sweepFromOrigin ? _sweepOrigin : Pos;
_sweepFromOrigin = false;
Pos += Velocity * dt;
TickTrail( dt );
// The earliest solid on this step's path wins: a block behind an obstacle is never the anchor.
if ( !ProjectileCollision.TryFindFirstHit( this, Stage, previousPos, _direction, out ProjectileCollision.Hit hit ) ) return;
if ( hit.Kind == ProjectileCollision.HitKind.Block )
Impact( hit.Point, anchorBlock: hit.Block );
else
Impact( hit.Point, hit.Side );
}
private SpriteRenderer AddLayer( Vector2 size, Color color, int childOrder )
{
SpriteRenderer sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", size, "idle", childOrder );
sprite.Color = color;
sprite.Opaque = false;
sprite.AlphaCutoff = 0f;
return sprite;
}
private void TickTrail( float dt )
{
_trailTimer += dt;
while ( _trailTimer >= TRAIL_INTERVAL )
{
_trailTimer -= TRAIL_INTERVAL;
Vector2 perpendicular = new( -_direction.y, _direction.x );
Vector2 position = Pos - _direction * Rng.CosmeticFloat( 1.5f, 3.5f )
+ perpendicular * Rng.CosmeticFloat( -1.2f, 1.2f );
Vector2 velocity = -_direction * Rng.CosmeticFloat( 5f, 16f )
+ perpendicular * Rng.CosmeticFloat( -7f, 7f );
Color color = Rng.CosmeticValue() < 0.72f ? OuterColor : AccentColor.WithAlpha( 0.55f );
Stage.AddColoredParticle( position, velocity, 0.9f, color,
Rng.CosmeticFloat( 0.18f, 0.3f ), Rng.CosmeticInt( 1, 3 ), Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
private void Impact( Vector2 impact, Direction surfaceFace = Direction.None, Block anchorBlock = null )
{
Pos = impact;
SpawnPortalDepartureParticles( _portal.Pos );
if ( anchorBlock is not null )
_portal.MoveToBlock( impact, anchorBlock );
else if ( surfaceFace == Direction.None )
_portal.MoveTo( impact );
else
_portal.MoveToSurface( impact, surfaceFace, _portalSurfacePadding );
Stage.RequestHitStop( _impactHitStopFrames );
SpawnImpactParticles( impact );
Audio.PlaySfx( SfxType.TurnVisible, new Vector3( impact.x, impact.y, 0f ), 0.75f, 1.25f );
Dead = true;
}
private void SpawnPortalDepartureParticles( Vector2 portalPosition )
{
for ( int i = 0; i < DEPARTURE_PARTICLES; i++ )
{
float angle = (float)i / DEPARTURE_PARTICLES * MathF.Tau;
Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
Color color = (i & 2) == 0 ? OuterColor.WithAlpha( 0.75f ) : CoreColor.WithAlpha( 0.82f );
Stage.AddColoredParticle( portalPosition + outward * Rng.CosmeticFloat( 6f, 10f ),
-outward * Rng.CosmeticFloat( 24f, 58f ), Rng.CosmeticFloat( 0.84f, 0.92f ), color,
Rng.CosmeticFloat( 0.18f, 0.32f ), Rng.CosmeticInt( 1, 4 ), Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
private void SpawnImpactParticles( Vector2 impact )
{
for ( int i = 0; i < IMPACT_PARTICLES; i++ )
{
float angle = (i + Rng.CosmeticFloat( -0.25f, 0.25f )) / IMPACT_PARTICLES * MathF.Tau;
Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
Color color = (i & 3) == 0 ? AccentColor : ((i & 1) == 0 ? CoreColor : OuterColor.WithAlpha( 0.82f ));
Stage.AddColoredParticle( impact + outward * Rng.CosmeticFloat( 0f, 2f ),
outward * Rng.CosmeticFloat( 45f, 125f ), Rng.CosmeticFloat( 0.82f, 0.92f ), color,
Rng.CosmeticFloat( 0.2f, 0.42f ), Rng.CosmeticInt( 2, 5 ), Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
}