Entities/Bullet.cs
namespace BlockParty;
/// <summary>
/// The Gunner character's projectile. Fired by <see cref="GunnerAbility"/> when the player double-taps
/// a direction (and has ammo). Unlike the dragon's <see cref="Fireball"/> a bullet is entirely the
/// player's tool: it never kills the player, flies in a single fixed eight-way direction at a constant
/// speed, and is NOT affected by wind / magnet lanes (it lives in its own <see cref="GameStage.Bullets"/>
/// list, which the lane blocks never iterate). It passes through and kills Summoner impostors, then
/// continues until it hits a block, an interior obstacle, or an arena wall and dies. Collision is SWEPT
/// (see <see cref="ProjectileCollision"/>): each step tests the whole segment travelled and strikes the
/// earliest surface on it, and the very first step starts back at the shooter's centre rather than the
/// muzzle, so a thin wall hugging the player is hit instead of skipped.
///
/// On impact:
/// <list type="bullet">
/// <item>A BLOCK is shaken along the bullet's travel direction, and the face the relative sweep entered
/// is pressed if it wasn't already — the same scoring press the player would do by shoving that side.</item>
/// <item>Any impact that lands close to the player REPELS the player away from the impact point (a
/// short-range knockback / "bullet-jump" mobility tool), scaled by how close it was. Every impact also
/// repels nearby surviving hostile impostors and puffs a cosmetic ring of sparks at the repel radius,
/// so the size of that zone is readable.</item>
/// </list>
///
/// Deterministic / replay-safe: motion is a pure function of the (recorded) fire direction and the
/// fixed step; the block press it triggers is sim state, and any spawned particles draw only from the
/// cosmetic RNG stream. So a Gunner run reproduces exactly on input-replay.
/// </summary>
public sealed class Bullet : Entity2D
{
public GameStage Stage { get; set; }
/// <summary>The normalized direction this bullet travels (set at spawn, never changes).</summary>
public Vector2 TravelDirection => _dirVec;
private Vector2 _dirVec;
private SpriteRenderer _sprite;
// Relatively-fast: quicker than a fireball (which crawls at ~15-33 px/s) so it reads as a shot.
private const float SPEED = 320f;
private const int SIZE = 6;
// A hit "very close" to the player repels them. Proximity is measured from the player's centre to
// the bullet's impact point; within REPEL_RANGE the push scales from full (point-blank) to zero.
private const float REPEL_RANGE = 20f;
private const float REPEL_MAX_STRENGTH = 220f;
private const float REPEL_MIN_STRENGTH = 80f;
private const float IMPOSTOR_REPEL_MULTIPLIER = 3f;
// The impact ring: sparks puffed roughly ON the repel radius at every impact (whether or not the
// player was close enough to be pushed), so the size of the repel zone is readable at a glance.
private const int RING_PARTICLES = 10;
private const float RING_DRIFT_SPEED = 14f; // gentle outward drift so the ring "breathes" as it fades
// How hard a struck block is shaken away from the incoming bullet (added on the travel axis).
private const float BLOCK_SHAKE_STRENGTH = 4f;
// Safety cap: the arena is bounded so a straight bullet always reaches the opposite wall, but this
// guarantees a bullet can never leak if some future geometry ever left a gap. Dies silently.
private const float MAX_LIFETIME = 8f;
private float _age;
// The shooter's centre at fire time. The muzzle spawns clear of the body, so the first step sweeps
// from here to cover the gap; consumed on that step.
private Vector2 _sweepOrigin;
private bool _sweepFromOrigin;
public void Setup( Vector2 direction, Vector2 sweepOrigin )
{
_dirVec = Utils.Normalized( direction );
Velocity = _dirVec * SPEED;
Size = new Vector2( SIZE, SIZE );
_sweepOrigin = sweepOrigin;
_sweepFromOrigin = true;
}
public void CreateVisuals()
{
_sprite = SpriteLayer.Add( GameObject, "sprites/bullet.sprite", Size, "fly" );
}
public override void Tick( float dt )
{
_age += dt;
if ( _age > MAX_LIFETIME ) { Dead = true; return; }
// The first step's sweep starts at the shooter's centre (the muzzle sits clear of the body).
Vector2 previousPos = _sweepFromOrigin ? _sweepOrigin : Pos;
_sweepFromOrigin = false;
Pos += Velocity * dt;
if ( Stage is null ) return;
// The earliest solid on this step's path (block, obstacle or arena wall), if any.
bool hitSolid = ProjectileCollision.TryFindFirstHit( this, Stage, previousPos, _dirVec, out ProjectileCollision.Hit hit );
// --- SUMMONER IMPOSTOR ---------------------------------------------------------------
// Impostors are hostile player bodies, not solids. Kill every body crossed this step BEFORE the
// solid (a clone cannot shield anything behind it, but a wall does shield a clone behind it).
foreach ( Player impostor in Stage.Impostors )
{
if ( impostor.IsDead ) continue;
if ( !ProjectileCollision.TrySweep( this, previousPos, impostor.GetRect(), Vector2.Zero, out float time, out _ ) ) continue;
if ( hitSolid && time > hit.Time ) continue;
impostor.KilledByBullet( previousPos );
}
if ( !hitSolid ) return;
if ( hit.Kind == ProjectileCollision.HitKind.Block )
{
HitBlock( hit.Block, hit.Side );
RepelNearbyBodies( hit.Point );
Audio.PlaySfx( SfxType.BlockImpact, Position, 0.7f );
}
else
{
// Interior obstacle or arena wall.
RepelNearbyBodies( hit.Point );
Audio.PlaySfx( SfxType.FireballDissipate, Position, 0.7f );
}
Dead = true;
}
/// <summary>Shake the block along the bullet's travel direction and press the face entered by the
/// bullet's motion relative to the block.</summary>
private void HitBlock( Block block, Direction hitSide )
{
// Shove the block along the bullet's travel direction (away from where the bullet came from).
block.AddShake( _dirVec * BLOCK_SHAKE_STRENGTH );
// A moving block can enter the bullet from another axis, so use the face selected by the relative
// sweep. PressSide itself no-ops if that side is already pressed / spiked / at max phase — but it
// cannot take None (the sides are keyed by the four real directions), which the sweep yields in
// the degenerate zero-relative-motion case, so there's no face to press.
if ( hitSide != Direction.None )
block.PressSide( hitSide );
}
/// <summary>Repel players and surviving hostile impostors away from <paramref name="impact"/> when the impact is close enough,
/// scaled by proximity. The repel consolidates motion on its axis into player velocity, and tears
/// players out of sticky glue at reduced phase-dependent force, so it doubles as a short-range
/// mobility launch without leaving an older opposing velocity hidden underneath a decaying force.
/// Always draws the repel-radius ring first, so the zone reads even on a whiffed (far) impact.</summary>
private void RepelNearbyBodies( Vector2 impact )
{
SpawnRepelRing( impact );
if ( Stage is null ) return;
foreach ( var player in Stage.LivingPlayers )
TryRepelBody( player, impact, 1f );
foreach ( var impostor in Stage.Impostors )
if ( !impostor.IsDead && !impostor.IsSwarmClone )
TryRepelBody( impostor, impact, IMPOSTOR_REPEL_MULTIPLIER );
}
private void TryRepelBody( Player player, Vector2 impact, float strengthMultiplier )
{
Vector2 away = player.Pos - impact;
float dist = away.Length;
if ( dist > REPEL_RANGE ) return;
float strength = Utils.Map( dist, 0f, REPEL_RANGE, REPEL_MAX_STRENGTH, REPEL_MIN_STRENGTH, true, EasingType.Linear );
// Degenerate case (impact exactly on the player's centre): push straight back along the shot.
if ( away == Vector2.Zero ) away = -_dirVec;
player.ApplyBulletRepel( away, strength * strengthMultiplier );
}
/// <summary>A cosmetic one-shot ring of sparks roughly AT <see cref="REPEL_RANGE"/> around the
/// impact point: evenly spaced around the circle (with a little angular/radial jitter so it reads
/// organic, not geometric), drifting gently outward as they fade. Cosmetic RNG only.</summary>
private void SpawnRepelRing( Vector2 impact )
{
if ( Stage is null ) return;
for ( int i = 0; i < RING_PARTICLES; i++ )
{
float angle = (i + Rng.CosmeticFloat( -0.3f, 0.3f )) / RING_PARTICLES * MathF.Tau;
Vector2 outward = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
Vector2 pos = impact + outward * (REPEL_RANGE + Rng.CosmeticFloat( -2f, 1f ));
ParticleKind kind = Rng.CosmeticFloat( 0f, 1f ) < 0.5f ? ParticleKind.GunSpark0 : ParticleKind.GunSpark1;
Stage.AddParticle( pos, outward * Rng.CosmeticFloat( RING_DRIFT_SPEED * 0.5f, RING_DRIFT_SPEED ),
Rng.CosmeticFloat( 0.88f, 0.94f ), 0f, kind,
Rng.CosmeticFloat( 0.2f, 0.35f ), Rng.CosmeticInt( 1, 3 ) );
}
}
}