Entities/Blocks/BlockSummoner.cs
namespace BlockParty;

/// <summary>
/// Summoner block — on a hard SLAM (phase 1+) it summons a hostile IMPOSTOR beside itself: an AI clone of
/// the player that chases the real player and kills them on contact. The impostor is a full <see cref="Player"/>
/// under the hood, so it obeys gravity, dies to spikes, can be crushed by blocks, is pushed by movement
/// forces (wind / magnet / stasis) and can wall-jump — but it dissipates on its own after a few seconds
/// (see <see cref="AiInputSource.Lifetime"/>).
///
/// <list type="bullet">
/// <item>Phase 0: dormant (no summon).</item>
/// <item>Phase 1+: each slam spawns one impostor.</item>
/// </list>
///
/// Phase 2 opens its portal faster and summons a stronger, differently coloured impostor.
///
/// Determinism: the spawn face is drawn from the authoritative Rng and the portal duration advances on the
/// fixed step; the impostor's motion is a pure function of positions + that step, so replays reproduce it exactly.
/// </summary>
public sealed class BlockSummoner : Block
{
	bool _summonPending;

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

		// A genuine SLAM (the loud impact ImpactEffects flags) arms a summon once the block is awake. The
		// portal opens from OnEyesClosed, once the new heading is known, so the spawn spot can steer clear
		// of the face the block is about to drive into.
		_summonPending = Phase >= 1 && SlammedThisTick;

		CloseEyesAndPickNewDirection();
	}

	// Eyes shut, MoveDirection is the heading the block resumes on when they re-open.
	protected override void OnEyesClosed()
	{
		if ( !_summonPending ) return;
		_summonPending = false;
		Stage.AddImpostorPortal( ChooseSpawnSpot(), Phase );
	}

	/// <summary>Pick where the impostor emerges: the four faces in a RANDOM order (authoritative Rng, so
	/// replays reproduce it), except the face the block is about to move into, which always goes LAST so
	/// the impostor isn't run over — that face is used only when every other side is blocked. A face is
	/// usable when the impostor's collision box fits in-bounds without overlapping another block or an
	/// obstacle and isn't spiked (emerging there is instant death). Nothing usable: spawn inside the block
	/// as a last resort — the player physics push it out.</summary>
	Vector2 ChooseSpawnSpot()
	{
		float hw = Player.COLLISION_SIZE.x / 2f;
		float hh = Player.COLLISION_SIZE.y / 2f;
		RectF me = GetRect();

		// Fisher-Yates over the four faces, then the heading face (if any) is moved to the back.
		Direction[] faces = { Direction.Up, Direction.Left, Direction.Right, Direction.Down };
		for ( int i = faces.Length - 1; i > 0; i-- )
		{
			int j = Rng.Int( 0, i + 1 );
			(faces[i], faces[j]) = (faces[j], faces[i]);
		}
		// Only a heading the block can actually travel counts: an ENCLOSED block's MoveDirection is its gaze
		// (aimed at the player), and demoting that face would always shove the spawn to the player's far side.
		int headingAt = IsColliding( MoveDirection ) ? -1 : Array.IndexOf( faces, MoveDirection );
		if ( headingAt >= 0 )
		{
			for ( int i = headingAt; i < faces.Length - 1; i++ ) faces[i] = faces[i + 1];
			faces[^1] = MoveDirection;
		}

		foreach ( var face in faces )
		{
			// Candidate flush against the face (touching, not overlapping — RectF.Intersects is exclusive at the edge).
			Vector2 center = face switch
			{
				Direction.Up => new Vector2( X, me.Top + hh ),
				Direction.Left => new Vector2( me.Left - hw, Y ),
				Direction.Right => new Vector2( me.Right + hw, Y ),
				_ => new Vector2( X, me.Bottom - hh ),
			};
			if ( IsClear( center, hw, hh ) && !HasSpikes( face ) )
				return center;
		}

		return Position;
	}

	/// <summary>Is the impostor's collision box (half-extents hw/hh) at <paramref name="center"/> clear of
	/// the arena walls, every other live block and everything solid to a PLAYER body? The impostor is a
	/// full Player, so the player-solid view is the right one: interior walls, GLASS panes and the Twin
	/// statue count; fences don't (a clone spawns between the bars just fine).</summary>
	bool IsClear( Vector2 center, float hw, float hh )
	{
		RectF rect = new RectF( center.x - hw, center.y - hh, center.x + hw, center.y + hh );

		if ( rect.Left < Arena.WALL_SIZE || rect.Right > Arena.WIDTH - Arena.WALL_SIZE
		  || rect.Bottom < Arena.WALL_SIZE || rect.Top > Arena.HEIGHT - Arena.WALL_SIZE )
			return false;

		foreach ( var b in Stage.GetBlocks() )
			if ( b != this && !b.IsDead && rect.Intersects( b.GetRect() ) )
				return false;

		foreach ( var ob in Stage.GetPlayerSolidObstacles() )
			if ( rect.Intersects( ob.GetRect() ) )
				return false;

		return true;
	}

}