Entities/Blocks/LaneBlock.cs

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.

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