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 ) );
		}
	}
}