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