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