Abstract LaneBlock component for wind/magnet blocks. It computes per-cardinal lane geometry, applies forces to players and projectiles, emits cosmetic particles, and renders a run-length translucent overlay that stops at blockers.
namespace BlockParty;
/// <summary>
/// Shared base for the "lane force" blocks (<see cref="BlockWind"/> pushes, <see cref="BlockMagnet"/>
/// pulls). While its eyes are open (phase 1+) the block projects a block-width lane of force along
/// one or more cardinal directions, affecting the player and projectiles (fireballs, teardrops) but
/// NOT blocks or lasers. Phase 1 drives a single lane (direction chosen by the subclass via
/// <see cref="GetPhase1Direction"/>); phase 2 drives four lanes outward in every cardinal direction.
///
/// The whole lane is a uniform tunnel: every entity anywhere in the unblocked lane gets the SAME
/// force (no falloff with distance). Strength (force, overlay alpha, particle rate) tracks
/// <see cref="Block.EyesOpenAmount"/>, so it lerps down to zero as the block closes its eyes on an
/// impact and back up as it re-opens — no extra timer, the eye-cycle drives it.
///
/// A lane is stopped by any block / arena wall / obstacle. Blocking is resolved PER cross-column (an
/// analytic axis-aligned ray at each cross offset — see <see cref="Reach"/>), so an obstacle covering
/// only part of the 40px width leaves the unblocked slice still active: the force skips shadowed
/// entities, the translucent overlay is drawn as run-length rects that stop at each column's blocker,
/// and particles spawn only within reach.
///
/// Rendering: the lane shows a faint semi-transparent overlay (a pooled set of pixel-sprite rects)
/// sitting just below the particle layer — above the background, under all blocks/players/entities —
/// with dust particles sprinkled throughout the whole tunnel on top of it.
///
/// Determinism: the force + overlay are pure functions of positions (no Rng). The particles are
/// cosmetic and go through <see cref="GameStage.AddWindParticle"/> (cosmetic Rng + non-colliding), so
/// emitting them every tick never advances the authoritative sim Rng.
///
/// Subclasses supply only what actually differs between wind and magnet: the phase-1 lane direction,
/// the force/particle direction sign, which player impulse channel to feed, the particle kind and how
/// far a particle travels before it dies, and the overlay tint. Everything geometric is shared here.
/// </summary>
public abstract class LaneBlock : Block
{
// Player push/pull: fed into the player's dedicated decaying force channel (subclass picks which via
// ApplyPlayerForce); the channel caps the resulting speed, so this accel just needs to overcome the
// channel's decay to reach the cap.
protected const float LANE_PLAYER_ACCEL = 250.0f;
// Projectile force: added straight to the projectile's velocity, with the along-lane component capped
// so a sustained lane nudges/redirects rather than accelerating it without bound.
protected const float LANE_PROJ_ACCEL = 120.0f;
protected const float LANE_PROJ_TERMINAL = 48.0f;
// Translucent lane overlay. Alpha fades smoothly with strength (the sprite's alpha-cutoff is zeroed in
// CreateOverlaySprite, so any alpha renders).
protected const int OVERLAY_POOL = 48; // 4 lanes × generous run-length segments; RenderOverlay degrades gracefully (drops trailing rects) if ever exceeded
protected const float OVERLAY_GROUP_TOLERANCE = 0.75f; // columns within this reach delta merge into one rect
protected const float MIN_OVERLAY_REACH = 0.5f; // don't draw a sliver where a blocker sits on the face
// Particle stream (cosmetic), sprinkled throughout the whole tunnel. Lane particles fly straight (they
// do NOT bounce — that reads as noise) and die at the blocker or by their short lifetime.
protected const float LANE_PARTICLE_SPEED = 150.0f; // px/s along the lane
protected const float LANE_PARTICLE_PERP_JITTER = 14.0f;
protected const float EMIT_PER_SECOND = 26.0f; // particles per second per lane at full strength
protected const float MIN_PARTICLE_REACH = 4.0f; // below this the column is blocked at the face — don't spawn
protected const float PARTICLE_LIFETIME_MIN = 0.22f;
protected const float PARTICLE_LIFETIME_MAX = 0.50f;
// Per-lane fractional emission accumulator (cosmetic pacing).
readonly Dictionary<Direction, float> _emitAccum = new();
// Pooled overlay rects (pixel sprites tinted translucent).
readonly List<SpriteRenderer> _overlay = new();
// ---- subclass hooks: the ONLY things that differ between wind and magnet -------------------------
/// <summary>The single lane direction driven at phase 1 (wind: <see cref="Block.MoveDirection"/>;
/// magnet: the opposite side, so it reaches out one way and drags things back toward itself).</summary>
protected abstract Direction GetPhase1Direction();
/// <summary>Unit direction the force (and the particles) travel for this lane. Wind blows OUTWARD
/// (<c>lane.D</c>); magnet pulls INWARD toward the face (<c>-lane.D</c>).</summary>
protected abstract Vector2 GetForceDirection( in Lane lane );
/// <summary>Feed the per-tick impulse into the player's dedicated channel for this block type
/// (wind channel vs magnet channel — they decay/accumulate differently).</summary>
protected abstract void ApplyPlayerForce( Player player, Vector2 impulse );
/// <summary>The cosmetic particle kind (dust vs magnet dust) emitted in the lane.</summary>
protected abstract ParticleKind ParticleKind { get; }
/// <summary>Distance a particle spawned at <paramref name="along"/> (its offset from the face) must
/// travel before it hits the end of its path — used to cap its lifetime so it dies AT the blocker/
/// block instead of shooting through. Wind flies outward so it's the remaining reach
/// (<c>reach - along</c>); magnet flies inward to the face so it's just <c>along</c>.</summary>
protected abstract float GetParticleTravel( float reach, float along );
/// <summary>The overlay tint at the given 0..1 strength (colour + strength-scaled alpha).</summary>
protected abstract Color GetOverlayColor( float strength );
// -------------------------------------------------------------------------------------------------
/// <summary>Lane frame: the block's face + along/cross axes for one lane direction.</summary>
protected readonly struct Lane
{
public readonly Direction Dir;
public readonly Vector2 D; // unit direction vector (outward, away from the block)
public readonly bool Horizontal; // along axis is X (else Y)
public readonly float Sign; // +1 if along increases outward, -1 otherwise
public readonly float FaceA; // along-axis coord of the block's face
public readonly float FaceC; // cross-axis coord of the lane centre
public readonly float HalfW; // half the lane width
public Lane( Direction dir, Vector2 face, float halfW )
{
Dir = dir;
D = Globals.GetVectorForDirection( dir );
Horizontal = dir == Direction.Left || dir == Direction.Right;
Sign = (dir == Direction.Right || dir == Direction.Up) ? 1f : -1f;
FaceA = Horizontal ? face.x : face.y;
FaceC = Horizontal ? face.y : face.x;
HalfW = halfW;
}
}
/// <summary>An ENCLOSED lane block never re-aims: its lane direction is the whole point of boxing it
/// in (an authored fixed lane in the level layout), so every enclosed eye cycle keeps the spawn
/// direction instead of swinging the lane toward the player. Non-enclosed lane blocks re-pick
/// normally on impact like any other block.</summary>
protected override Direction GetEnclosedGazeDirection() => MoveDirection;
/// <summary>While enclosed at phase 1+, the authored ability timings loop the lane on/off: eyes stay
/// open (lane on) for the Interval, shut (lane off) for the ClosedTime, forever — and ambient
/// enclosed blinks are suppressed so that authored loop is the only lull cadence.</summary>
protected override bool HasEnclosedEyeLoop => true;
public override void CreateVisuals()
{
base.CreateVisuals();
// Stage-level overlay quads (not parented to this moving block) so they render like the
// playfield/backdrop rects — we drive their world position/size/colour directly each tick.
for ( int i = 0; i < OVERLAY_POOL; i++ )
_overlay.Add( Stage.CreateOverlaySprite() );
}
public override void DestroyStageVisuals()
{
base.DestroyStageVisuals();
DestroyOverlayPool( _overlay );
}
public override void Tick( float dt )
{
base.Tick( dt );
float strength = Phase >= 1 ? EyesOpenAmount : 0f;
int overlayUsed = 0;
if ( !IsDead && strength > 0f )
{
if ( Phase == 1 )
{
overlayUsed = TickLane( GetPhase1Direction(), strength, dt, overlayUsed );
}
else // phase 2: all four cardinals at once
{
foreach ( var dir in Globals.GetAllDirections() )
overlayUsed = TickLane( dir, strength, dt, overlayUsed );
}
}
// Hide any overlay rects not used this tick.
for ( int i = overlayUsed; i < _overlay.Count; i++ )
_overlay[i].Enabled = false;
}
/// <summary>Apply force to everything in one lane, stream its particles, and draw its overlay. Returns
/// the next free overlay-pool index. <paramref name="s"/> is the 0..1 strength (eye-open amount).</summary>
int TickLane( Direction dir, float s, float dt, int overlayIdx )
{
var lane = new Lane( dir, Position + Globals.GetVectorForDirection( dir ) * (Width / 2f), Width / 2f );
ApplyForce( lane, s, dt );
EmitParticles( lane, s, dt );
return RenderOverlay( lane, s, overlayIdx );
}
// ------------------------------------------------------------------------------------------
/// <summary>Distance from the face along the lane to the nearest blocker (arena wall / block /
/// obstacle) for the single cross-column at absolute cross coord <paramref name="cc"/>. Tracing
/// each column independently is what makes partial blocking work.</summary>
float Reach( in Lane lane, float cc )
{
float wall = lane.Dir switch
{
Direction.Right => (Arena.WIDTH - Arena.WALL_SIZE) - lane.FaceA,
Direction.Left => lane.FaceA - Arena.WALL_SIZE,
Direction.Up => (Arena.HEIGHT - Arena.WALL_SIZE) - lane.FaceA,
_ => lane.FaceA - Arena.WALL_SIZE, // Down
};
float best = MathF.Max( 0f, wall );
foreach ( Block b in Stage.GetBlocks() )
{
if ( b == this ) continue;
best = ConsiderBlocker( lane, cc, b.GetRect(), best );
}
foreach ( Entity2D ob in Stage.GetSolidObstacles() )
best = ConsiderBlocker( lane, cc, ob.GetRect(), best );
return best;
}
static float ConsiderBlocker( in Lane lane, float cc, RectF r, float best )
{
float rc0 = lane.Horizontal ? r.Bottom : r.Left; // rect span on the cross axis
float rc1 = lane.Horizontal ? r.Top : r.Right;
if ( cc < rc0 || cc > rc1 ) return best; // this column misses the rect
float ra0 = lane.Horizontal ? r.Left : r.Bottom; // rect span on the along axis
float ra1 = lane.Horizontal ? r.Right : r.Top;
// Distances (outward from the face) to the rect's near and far edges.
float nearAhead = lane.Sign > 0f ? (ra0 - lane.FaceA) : (lane.FaceA - ra1);
float farAhead = lane.Sign > 0f ? (ra1 - lane.FaceA) : (lane.FaceA - ra0);
// Entirely behind the face (opposite the lane direction) → not in this lane, ignore it. Without
// this, a blocker behind/beside the block (e.g. an obstacle ABOVE a downward lane) was clamped to
// a zero-reach "face block" and wrongly cut the lane.
if ( farAhead <= 0f ) return best;
float ahead = MathF.Max( 0f, nearAhead ); // straddling the face (near behind, far ahead) blocks at 0
return MathF.Min( best, ahead );
}
// ------------------------------------------------------------------------------------------
void ApplyForce( in Lane lane, float s, float dt )
{
var dir = GetForceDirection( lane );
foreach ( var player in Stage.LivingPlayers )
if ( InLane( lane, player.X, player.Y ) )
ApplyPlayerForce( player, dir * (LANE_PLAYER_ACCEL * s * dt) );
// Summoner impostors are player bodies too, so the lane pushes them as well.
foreach ( var imp in Stage.Impostors )
if ( !imp.IsDead && InLane( lane, imp.X, imp.Y ) )
ApplyPlayerForce( imp, dir * (LANE_PLAYER_ACCEL * s * dt) );
foreach ( Fireball f in Stage.GetFireballs() )
if ( InLane( lane, f.X, f.Y ) ) ApplyProjectileForce( f, dir, s, dt );
foreach ( Teardrop t in Stage.GetTeardrops() )
if ( !t.HasSplashed && InLane( lane, t.X, t.Y ) ) ApplyProjectileForce( t, dir, s, dt );
}
/// <summary>Is the point inside the unblocked lane? Uniform across the whole tunnel — the only
/// distance dependence is the per-column reach (so a blocker's shadow is excluded).</summary>
bool InLane( in Lane lane, float x, float y )
{
float ea = lane.Horizontal ? x : y;
float ec = lane.Horizontal ? y : x;
if ( MathF.Abs( ec - lane.FaceC ) > lane.HalfW ) return false; // outside the lane width
float ahead = lane.Sign * (ea - lane.FaceA);
if ( ahead <= 0f ) return false; // behind the face
return ahead <= Reach( lane, ec ); // not shadowed by a blocker
}
static void ApplyProjectileForce( Entity2D e, Vector2 d, float s, float dt )
{
e.Velocity += d * (LANE_PROJ_ACCEL * s * dt);
float vAlong = e.Velocity.x * d.x + e.Velocity.y * d.y;
if ( vAlong > LANE_PROJ_TERMINAL )
e.Velocity -= d * (vAlong - LANE_PROJ_TERMINAL); // cap only the along-lane component
}
// ------------------------------------------------------------------------------------------
void EmitParticles( in Lane lane, float s, float dt )
{
if ( !_emitAccum.TryGetValue( lane.Dir, out float accum ) ) accum = 0f;
accum += EMIT_PER_SECOND * s * dt;
while ( accum >= 1f )
{
accum -= 1f;
EmitParticle( lane );
}
_emitAccum[lane.Dir] = accum;
}
void EmitParticle( in Lane lane )
{
float cc = lane.FaceC + Rng.CosmeticFloat( -lane.HalfW, lane.HalfW ); // random cross offset
float reach = Reach( lane, cc );
if ( reach < MIN_PARTICLE_REACH ) return; // column blocked at the face
float along = Rng.CosmeticFloat( 0f, reach ); // anywhere along the tunnel
float a = lane.FaceA + lane.Sign * along;
Vector2 pos = lane.Horizontal ? new Vector2( a, cc ) : new Vector2( cc, a );
Vector2 perp = lane.Horizontal ? new Vector2( 0f, 1f ) : new Vector2( 1f, 0f );
Vector2 vel = GetForceDirection( lane ) * LANE_PARTICLE_SPEED + perp * Rng.CosmeticFloat( -LANE_PARTICLE_PERP_JITTER, LANE_PARTICLE_PERP_JITTER );
// Lane particles fly dead straight (no bouncing), so cap the life at the along-lane travel time to
// the end of their path — they vanish AT the blocker/block instead of shooting through it.
float life = MathF.Min( Rng.CosmeticFloat( PARTICLE_LIFETIME_MIN, PARTICLE_LIFETIME_MAX ), GetParticleTravel( reach, along ) / LANE_PARTICLE_SPEED );
var p = Stage.AddWindParticle( pos, vel, decel: 1f, gravity: 0f, ParticleKind, life, Rng.CosmeticInt( 1, 3 ) );
// Ride laterally with the moving lane: the lane sweeps sideways as the block moves, so a straight-
// flying particle would trail behind it (most visible on phase-2's perpendicular lanes) or coast out
// the side when the block slams to a halt. Track the block's LIVE cross-lane velocity each tick
// (perp = the lane's cross axis) — matching the sweep while moving and snapping to zero drift the
// instant it stops — leaving the along-lane flow (and the life-cap above) untouched.
p.SetLaneDrift( this, perp );
}
// ------------------------------------------------------------------------------------------
/// <summary>Draw the lane's translucent overlay as run-length rects across the width: consecutive
/// cross-columns with (nearly) equal reach merge into one rect that stops at their blocker, so a
/// partially-blocked lane renders its true stepped shape. Returns the next free pool index.</summary>
int RenderOverlay( in Lane lane, float s, int idx )
{
int width = (int)(lane.HalfW * 2f);
float baseC = lane.FaceC - lane.HalfW;
int i = 0;
while ( i < width && idx < _overlay.Count )
{
float refReach = Reach( lane, baseC + i + 0.5f );
float minReach = refReach;
int j = i + 1;
while ( j < width )
{
float rj = Reach( lane, baseC + j + 0.5f );
if ( MathF.Abs( rj - refReach ) > OVERLAY_GROUP_TOLERANCE ) break;
if ( rj < minReach ) minReach = rj;
j++;
}
if ( minReach > MIN_OVERLAY_REACH )
PlaceOverlay( _overlay[idx++], lane, baseC + i, baseC + j, minReach, s );
i = j;
}
return idx;
}
void PlaceOverlay( SpriteRenderer sr, in Lane lane, float crossStart, float crossEnd, float reach, float s )
{
// Round the rect's EDGES (not its centre) to the pixel grid, then derive centre/size from the
// rounded edges. Adjacent run-length segments share a cross boundary (segment A's crossEnd ==
// segment B's crossStart), so rounding that shared coord gives BOTH the same integer edge and
// they tile seamlessly. Rounding the centre + an unrounded (often half-integer) width instead
// let the two segments disagree by ±1px at the seam as the block slid horizontally and FaceC
// went sub-pixel → the flickering single-pixel gaps between lane sections. (Same "snap the
// corner, not the centre" rule as odd-sized entity rendering.)
float c0 = MathF.Round( crossStart );
float c1 = MathF.Round( crossEnd );
float a0 = MathF.Round( lane.FaceA ); // near edge (the block face)
float a1 = MathF.Round( lane.FaceA + lane.Sign * reach ); // far edge (the blocker / wall)
float crossMid = (c0 + c1) * 0.5f;
float alongMid = (a0 + a1) * 0.5f;
float crossSize = c1 - c0;
float alongSize = MathF.Abs( a1 - a0 );
// Position is NOT re-rounded: with integer edges the centre may be a half-pixel, but centre ±
// size/2 lands back exactly on the integer edges c0/c1 (and a0/a1) — re-rounding it would push
// the edges back off the grid and re-introduce the seam.
Vector2 worldCenter = lane.Horizontal ? new Vector2( alongMid, crossMid ) : new Vector2( crossMid, alongMid );
Vector2 size = lane.Horizontal ? new Vector2( alongSize, crossSize ) : new Vector2( crossSize, alongSize );
sr.Enabled = true;
sr.Size = size;
sr.Color = GetOverlayColor( s ); // fades smoothly with the lane strength
// Place in world just below the particle layer: above the background, under all entities.
// (Stage-level sprite, so set its world position directly.)
// StageIndex gives this block a unique Z sub-offset so its lane doesn't z-fight with another
// Wind/Magnet block's overlapping lane.
float overlayWorldZ = Globals.DepthToZ( Globals.DEPTH_LANE_OVERLAY, StageIndex );
sr.GameObject.WorldPosition = new Vector3( worldCenter.x, worldCenter.y, overlayWorldZ );
}
}