Entities/Blocks/BlockTeleport.cs
namespace BlockParty;

/// <summary>
/// Teleport block — on a hard SLAM (phase 1+) it vanishes and instantly reappears somewhere else in the
/// level. It scans for a spot where its full 40×40 body fits (inside the walls, clear of every other
/// block and obstacle) and jumps there in a single frame, puffing a block-shaped cloud of smoke at BOTH
/// the spot it left and the spot it arrives at.
///
/// It re-materialises MOSTLY TRANSPARENT and fades in over <see cref="FADE_IN_TIME"/>. While it is fading
/// in it is "phasing" (<see cref="Block.PhasingIn"/>): it does NOT collide with the player and the squid
/// laser passes straight through it (other blocks still collide with it, so it stays a solid obstacle to
/// them), and it does not try to move — it sits frozen at the destination until the fade completes. The
/// instant it is fully solid again, if the player is caught FULLY inside it, the player is crushed.
///
/// <list type="bullet">
/// <item>Phase 1: teleports to a random spot that fits.</item>
/// <item>Phase 2: always tries to teleport directly on top of the player first (so a stationary player
/// is crushed when it finishes fading in), falling back to a random spot if it can't fit there.</item>
/// </list>
///
/// Determinism: the destination is drawn from the AUTHORITATIVE Rng (it changes gameplay), the fade
/// timing + the crush test are pure functions of the fixed step and positions, and nothing reads
/// Time.Delta — so replays reproduce it exactly. The smoke clouds are cosmetic (cosmetic Rng only).
/// </summary>
public sealed class BlockTeleport : Block
{
	// --- timing / look --------------------------------------------------------------------------
	const float FADE_IN_TIME = 1.0f;    // seconds to fade from near-invisible to fully solid
	const float START_ALPHA = 0.15f;    // "mostly transparent" the instant it reappears

	// --- placement search -----------------------------------------------------------------------
	const int MAX_PLACEMENT_TRIES = 80; // random-fit attempts before giving up (then it just doesn't jump)

	// When trying to land ON the player (phase 2) but the exact spot is blocked, probe these small
	// offsets (in order, so it stays deterministic) so it still comes down on/around them.
	static readonly Vector2[] PLAYER_PROBE_OFFSETS =
	{
		new( 6, 0 ), new( -6, 0 ), new( 0, 6 ), new( 0, -6 ),
		new( 12, 0 ), new( -12, 0 ), new( 0, 12 ), new( 0, -12 ),
		new( 10, 10 ), new( -10, 10 ), new( 10, -10 ), new( -10, -10 ),
	};

	float _fadeTimer;
	protected override bool CanActivateTimedAbility => !PhasingIn;

	// ----------------------------------------------------------------------------------------
	public override void Tick( float dt )
	{
		// base.Tick handles the eye-cycle, side buttons, shake and (when not stopped) movement. While
		// phasing we leave IsStopped = true (set in ImpactEffects and never cleared until the fade
		// finishes), so base.Tick suppresses movement and the block simply sits and animates.
		base.Tick( dt );

		if ( PhasingIn )
			TickPhaseIn( dt );
	}

	// ----------------------------------------------------------------------------------------
	public override void Impact( Block otherBlock )
	{
		ImpactEffects( otherBlock );
		if ( IsDead ) return;

		// A genuine slam once awake teleports us. On success we DON'T start the usual eye-cycle: the
		// fade-in owns the pause, and we resume (blink → pick a new direction → go) only once solid.
		if ( Phase >= 1 && SlammedThisTick && !PhasingIn && TryTeleport() )
			return;

		CloseEyesAndPickNewDirection();
	}

	// ----------------------------------------------------------------------------------------
	bool TryTeleport()
	{
		if ( !PickDestination( out Vector2 dest ) )
			return false; // nowhere to go this slam — fall back to a normal impact

		Vector2 origin = Pos;
		SpawnTeleportClouds( origin );   // smoke where we left…
		Pos = dest;
		SpawnTeleportClouds( dest );      // …and where we arrive
		// The jump is instant, not travel — keep it out of StepDisplacement so a bullet overlapping the
		// arrival spot doesn't sweep the whole jump as flown motion (see Block.StepDisplacement).
		ExcludeJumpFromStepDisplacement( dest - origin );

		// Re-materialise mostly transparent and start fading in. IsStopped is already true (ImpactEffects),
		// so we sit frozen at the destination for the whole fade.
		PhasingIn = true;
		_fadeTimer = FADE_IN_TIME;
		SetLayersTranslucent( true );
		SetLayersAlpha( START_ALPHA );
		return true;
	}

	void TickPhaseIn( float dt )
	{
		_fadeTimer -= dt;
		float remaining = Math.Clamp( _fadeTimer / FADE_IN_TIME, 0f, 1f );
		SetLayersAlpha( START_ALPHA + (1f - START_ALPHA) * (1f - remaining) );

		if ( _fadeTimer <= 0f )
			CompletePhaseIn();
	}

	void CompletePhaseIn()
	{
		PhasingIn = false;
		BecameSolidThisTick = true;
		SetLayersAlpha( 1f );
		SetLayersTranslucent( false );

		// The instant it is fully solid again, any player body caught FULLY inside it is crushed — the real
		// player AND any Summoner impostors (unless we died mid-fade, e.g. the win sequence started — then
		// don't hand out a death during the victory hold).
		if ( !IsDead )
		{
			RectF br = GetRect();
			if ( Stage is not null )
			{
				foreach ( var player in Stage.LivingPlayers )
					CrushIfInside( player, br );
				foreach ( var imp in Stage.Impostors )
					CrushIfInside( imp, br );
			}
		}

		// Now that it's solid, resume normal behaviour: blink, pick a fresh direction, and move off.
		CloseEyesAndPickNewDirection();

		// A body fully inside the block's rect (no part poking out) is crushed when the block turns solid.
		static void CrushIfInside( Player body, RectF br )
		{
			if ( body is null || body.IsDead ) return;
			RectF pr = body.GetRect();
			if ( pr.Left >= br.Left && pr.Right <= br.Right && pr.Bottom >= br.Bottom && pr.Top <= br.Top )
				body.KillByCrush( false );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Choose where to jump. Phase 2 prefers landing on the player (so a full fade-in crushes
	/// a stationary player); otherwise (and as the fallback) a random spot that fits.</summary>
	bool PickDestination( out Vector2 dest )
	{
		if ( Phase >= 2 )
		{
			var player = Stage?.ClosestLivingPlayer( Position );
			if ( player != null && !player.IsDead && TryFitOnPlayer( player, out dest ) )
				return true;
		}
		return PickRandomFit( out dest );
	}

	bool TryFitOnPlayer( Player player, out Vector2 dest )
	{
		// A grounded player's centre is only a few px off their support, so the centred spot sinks our
		// body into whatever they stand on. Per axis, try the player centre plus our edge aligned with
		// each matching player edge (the flush-with-a-support placements); the full 3×3 grid covers
		// corners, where BOTH axes need edge-aligning at once (floor + nearby wall). Every combination
		// fully contains the player, so the fade-in crush connects. Centre-first order, then the probes.
		RectF pr = player.GetRect();
		Span<float> xs = stackalloc float[] { player.Pos.x, pr.Left + Width / 2f, pr.Right - Width / 2f };
		Span<float> ys = stackalloc float[] { player.Pos.y, pr.Bottom + Height / 2f, pr.Top - Height / 2f };
		for ( int yi = 0; yi < 3; yi++ )
			for ( int xi = 0; xi < 3; xi++ )
				if ( TryAt( new Vector2( xs[xi], ys[yi] ), out dest ) ) return true;

		foreach ( var off in PLAYER_PROBE_OFFSETS )
			if ( TryAt( player.Pos + off, out dest ) ) return true;

		dest = default;
		return false;

		bool TryAt( Vector2 raw, out Vector2 p )
		{
			p = ClampCenter( raw );
			return FitsAt( p.x, p.y );
		}
	}

	bool PickRandomFit( out Vector2 dest )
	{
		float half = Width / 2f;
		int minX = (int)(Arena.WALL_SIZE + half), maxX = (int)(Arena.WIDTH - Arena.WALL_SIZE - half);
		int minY = (int)(Arena.WALL_SIZE + half), maxY = (int)(Arena.HEIGHT - Arena.WALL_SIZE - half);

		for ( int i = 0; i < MAX_PLACEMENT_TRIES; i++ )
		{
			// Rng.Int upper bound is EXCLUSIVE, so +1 to include the max.
			float x = Rng.Int( minX, maxX + 1 );
			float y = Rng.Int( minY, maxY + 1 );
			if ( FitsAt( x, y ) ) { dest = new Vector2( x, y ); return true; }
		}

		dest = default;
		return false;
	}

	/// <summary>Clamp a would-be centre so the whole 40×40 body stays inside the walls, snapped to the
	/// integer pixel grid the blocks live on.</summary>
	Vector2 ClampCenter( Vector2 c )
	{
		float half = Width / 2f;
		float minX = Arena.WALL_SIZE + half, maxX = Arena.WIDTH - Arena.WALL_SIZE - half;
		float minY = Arena.WALL_SIZE + half, maxY = Arena.HEIGHT - Arena.WALL_SIZE - half;
		return new Vector2(
			Math.Clamp( MathF.Round( c.x ), minX, maxX ),
			Math.Clamp( MathF.Round( c.y ), minY, maxY ) );
	}

	/// <summary>Would the block fit with its centre at (<paramref name="cx"/>, <paramref name="cy"/>) —
	/// inside the walls and not overlapping any other block or obstacle (pixel-grid test, like the
	/// block-vs-block collision; flush contact is allowed)?</summary>
	bool FitsAt( float cx, float cy )
	{
		RectF r = GetPixelRect( cx, cy );
		if ( r.Left < Arena.WALL_SIZE || r.Right > Arena.WIDTH - Arena.WALL_SIZE
			|| r.Bottom < Arena.WALL_SIZE || r.Top > Arena.HEIGHT - Arena.WALL_SIZE )
			return false;

		foreach ( Block b in Stage.GetBlocks() )
		{
			if ( b == this ) continue;
			if ( Overlaps( r, b.GetPixelRect( b.X, b.Y ) ) ) return false;
		}
		foreach ( Entity2D o in Stage.GetBlockSolidObstacles() )
		{
			if ( Overlaps( r, o.GetPixelRect( o.X, o.Y ) ) ) return false;
		}
		return true;
	}

	// Strict overlap (matches Block.PenetratesOnPixelGrid): flush edges don't count as overlapping.
	static bool Overlaps( RectF a, RectF b )
		=> a.Right > b.Left && a.Left < b.Right && a.Top > b.Bottom && a.Bottom < b.Top;

	// ----------------------------------------------------------------------------------------
	/// <summary>Puff a block-shaped burst of smoke (same clouds the mimic transform uses) spread over the
	/// 40×40 footprint centred on <paramref name="center"/>. Cosmetic — cosmetic Rng only.</summary>
	void SpawnTeleportClouds( Vector2 center )
	{
		Audio.PlaySfx( SfxType.TurnInvisible, center, 0.6f, 1.0f );

		const int n = 26;
		float half = Width / 2f;
		for ( int i = 0; i < n; i++ )
		{
			var kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
			float px = Rng.CosmeticFloat( -half, half );
			float py = Rng.CosmeticFloat( -half, half );
			Vector2 pos = center + new Vector2( px, py );
			// Gentle outward drift from the centre plus a little jitter, so it billows out block-shaped.
			Vector2 vel = new Vector2( px, py ) * Rng.CosmeticFloat( 1.2f, 2.4f )
				+ new Vector2( Rng.CosmeticFloat( -6f, 6f ), Rng.CosmeticFloat( -2f, 14f ) );
			Stage.AddMimicCloud( pos, vel, kind, Rng.CosmeticFloat( 0.45f, 0.8f ), Rng.CosmeticInt( 6, 12 ) );
		}
	}
}