Entities/Blocks/BlockHunter.cs
namespace BlockParty;

/// <summary>
/// Hunter block — a stalking pursuer. Phase 0 is the neutral random wander every block shares. From
/// phase 1 on it hunts:
///
///  • After each stop it turns toward the player, ranking the four axis directions by how much they
///    close the straight-line distance and taking the best non-blocked one (see <see cref="PickHuntDirection"/>).
///  • Crucially it doesn't commit to a full lane like other blocks: while charging toward the player it
///    BRAKES the instant it draws level with them on its travel axis (a soft stop — no slam), then
///    re-aims down the other axis and charges again. The result is a menacing staircase pursuit that
///    corners toward you instead of trundling past to the far wall. It only brakes when re-aiming would
///    actually give it a useful, unblocked turn toward you — if the way it wants to turn is already
///    walled off it keeps charging (braking there would just stall it against a wall).
///  • Brake points are landed on EXACTLY: each tick's movement is clamped to the computed stop (see
///    <see cref="ClampToTurnBrakePoint"/>), so even a PERFECTLY FLUSH turn — a corridor mouth exactly
///    the block's width, the standard junction of tunnel levels like maze-1 — can be taken. Crossing
///    such an opening mid-leg also brakes a boxed-in fallback leg, letting the stalk corner down
///    tunnels it could never line up with by per-tick sampling.
///
/// Phase 2 escalates the same behaviour: faster acceleration/top speed and a much shorter recovery pause.
/// When neither direct pursuit lane is open, it also searches the traversable arena for a route around
/// obstacles and other blocks, follows that route to its first corner, then re-plans. Collision,
/// side-pressing and phasing are all the default block behaviour.
/// </summary>
public sealed class BlockHunter : Block
{
	// Whether the current heading was chosen to close on the player (vs a boxed-in fallback). Only a
	// pursuit leg brakes mid-lane; a fallback leg runs until it slams something (see TickStalk) —
	// unless the movement clamp lands it exactly on a genuine turn opening (see _alignedOnBrakePoint).
	bool _pursuing;
	bool _followingPath;
	bool _reachedPathWaypoint;
	Vector2 _pathWaypoint;

	// Set by ClampToTurnBrakePoint when this tick's movement was cut short to land EXACTLY on a
	// computed brake point (the level point, a window far edge, or a flush corridor mouth). TickStalk
	// consumes it and brakes there — bypassing the level-point wait logic, and (uniquely) allowed on
	// fallback legs, which otherwise never stalk-brake.
	bool _alignedOnBrakePoint;
	// An aligned landing that failed TickStalk's live verify (pixel-grid contact the analytic sweep
	// can't see) suppresses the clamp for one tick so the block keeps moving instead of freezing in
	// place re-clamping to the same rejected spot.
	bool _alignedBrakeFailed;
	// Where the current leg began (captured at re-pick) — gates the per-tick early-brake fall-through
	// on real progress, see STALK_BRAKE_MIN_LEG_TRAVEL.
	Vector2 _legStartPos;
	// The turn a stalk brake stopped FOR. Honored by the very next re-pick (then cleared) ahead of the
	// distance ranking: the ranking alone prefers whichever axis closes more raw distance, and at a
	// corridor mouth that's often the horizontal run back across a walled platform — so the block
	// ping-pongs between a platform's two mouths forever instead of ascending through one.
	Direction _brakeTurnIntent;

	// Phase-2 pathfinding runs only when the two direct pursuit lanes fail. Blocks tick sequentially and
	// each search completes synchronously, so all Hunters share one workspace instead of allocating three
	// large-object-heap arrays per instance. The queue is reused to reconstruct the path after BFS ends.
	const int PATH_NODE_COUNT = Arena.WIDTH * Arena.HEIGHT;
	static readonly bool[] _pathBlocked = new bool[PATH_NODE_COUNT];
	static readonly int[] _pathParent = new int[PATH_NODE_COUNT];
	static readonly int[] _pathQueue = new int[PATH_NODE_COUNT];

	// Scratch for the per-tick turn-lane sweep (see FindTurnBrakePoint). Shared for the same reason as
	// the pathfinding workspace above: blocks tick sequentially and each sweep completes within the call.
	static readonly List<(float lo, float hi)> _laneBlocked = new();
	static int CompareLaneLo( (float lo, float hi) a, (float lo, float hi) b ) => a.lo.CompareTo( b.lo ); // named, not a static lambda: hotload-safe

	const float BRAKE_SHAKE_STRENGTH = 0.008f; // skid lurch on a brake (half the slam SHAKE_STRENGTH)
	const float BRAKE_DUST_SPEED = 30f;        // how fast the little skid puff scatters

	// A direction only counts as "pursuing" when it closes at least this much distance — a degenerate
	// half-pixel gain isn't worth a brake (and used to cause brake-oscillation right next to the player).
	const float MIN_PURSUE_DIST = 2f;
	// A fallback leg should have at least this much running room before it grinds something; a direction
	// with less is only taken when nothing better exists (see PickHuntDirection).
	const float MIN_FALLBACK_RUN = 12f;
	// How close (along the travel axis) the block must be before it will take an EARLY brake — dropping
	// on the approach side of the level point when the level-point drop will be blocked (player tucked
	// into a valley). Keeps a distant clear column from triggering a premature, silly-looking stop.
	const float EARLY_BRAKE_RANGE = 60f;
	// A clear cross-axis window no wider than this reads as a corridor mouth (a flush or near-flush
	// junction). It's the only turn a tunnel level will ever offer, so it's taken at ANY range — wide
	// windows stay subject to EARLY_BRAKE_RANGE so distant open ground doesn't cause premature stops.
	const float FLUSH_TURN_WINDOW_MAX = 2f;
	// The per-tick early-brake fall-through (window closing at/behind us) only fires after the leg has
	// really moved this far. At leg start (speed ≈ 0, 1px lookahead) a junction mouth reads as "turn
	// now or never", and braking at zero distance re-litigates the pick that just started the leg —
	// ping-ponging the re-pick between the two axes forever. Windows genuinely AHEAD are exempt: the
	// movement clamp lands on those exactly regardless of leg progress.
	const float STALK_BRAKE_MIN_LEG_TRAVEL = 2f;

	protected override void OnSetup()
	{
		// Phase 0 stays at the default block profile; phase 1 accelerates harder ("a bit more speed"),
		// phase 2 is faster still. High acceleration matters more than top speed here because the stalk
		// makes short legs — the block lunges in quick bursts rather than long cruises.
		// (Base defaults: accel {50,55,60}, maxSpeed {220,230,240}.)
		_accelerations = new[] { 50f, 90f, 150f };
		_maxSpeeds = new[] { 220f, 245f, 300f };
	}

	// Eager recovery: shorter eyes-shut pause than a normal block so the stalk stays snappy; phase 2
	// re-aims and lunges again almost immediately. (Base range is 0.28–0.60s; see Block.EyesStayClosedTime.)
	protected override float EyesStayClosedTime() => Phase switch
	{
		2 => Rng.Float( 0.03f, 0.09f ),
		1 => Rng.Float( 0.18f, 0.34f ),
		_ => base.EyesStayClosedTime(),
	};

	public override void Tick( float dt )
	{
		base.Tick( dt );
		TickStalk( dt );
	}

	protected override Vector2 GetMovementDelta( float dt )
	{
		Vector2 movement = base.GetMovementDelta( dt );
		if ( Phase < 2 || !_followingPath ) return ClampToTurnBrakePoint( movement );

		Vector2 travel = Globals.GetVectorForDirection( MoveDirection );
		float remaining = Vector2.Dot( _pathWaypoint - Pos, travel );
		float step = Vector2.Dot( movement, travel );
		if ( remaining > step ) return movement;

		// Stop the shared movement pipeline at the exact corner so collision resolution and
		// StepDisplacement both observe the distance actually travelled this tick.
		_reachedPathWaypoint = true;
		return travel * MathF.Max( remaining, 0f );
	}

	/// <summary>While charging toward the player, brake where turning toward them (the closing direction
	/// on the cross axis) is actually possible:
	///
	///  • Normally at the LEVEL point — drawn level with the player on our travel axis, continuing would
	///    only increase the distance — provided the turn is genuinely open: not immediately touching
	///    anything, and its whole lane clear to its own brake point. If it isn't (our body still
	///    half-overhangs the obstacle they're hiding beside), keep charging until it clears and take the
	///    drop on the FAR side.
	///  • EARLY, on the approach side, when the drop is clear HERE but will be BLOCKED at the level point
	///    — the player tucked into a valley narrower than our drop can reach past (e.g. pressed against
	///    the valley wall we're approaching over): the level-point column overlaps that wall forever, so
	///    holding out for it sails past the whole valley. We ride the clear window to its FAR edge (every
	///    pixel closes distance — braking on entry would stop short of the player) and brake on the last
	///    clear column, just before the next tick's travel would exit it. Range-limited so a distant clear
	///    column doesn't cause a premature stop.
	///
	/// Only runs on a pursuit leg (a boxed-in fallback leg runs to its wall as normal) — EXCEPT when
	/// the movement clamp landed the block exactly on a computed brake point this tick (see
	/// <see cref="ClampToTurnBrakePoint"/>): that landing is a verified full-lane turn opening, so it
	/// brakes any leg, and skips the level-point wait logic (the clamp already chose the stop).</summary>
	void TickStalk( float dt )
	{
		// Consume the movement clamp's "landed exactly on a brake point" flag before ANY early-out so
		// it can never go stale and fire on a later, unrelated tick.
		bool aligned = _alignedOnBrakePoint;
		_alignedOnBrakePoint = false;

		// A CW/CCW hunter is a plain cycling block: no stalk braking. Cycle modes own the whole
		// direction pick (Block.PickCycleDirection — GetNewDirection/PickHuntDirection never run), so
		// a brake here would pause the charge at every corridor mouth only for the re-pick to resume
		// the same heading, and _brakeTurnIntent would be set but never consumed. No shipped level
		// authors this combination, but the player-facing editor can.
		if ( IsCycleTurnMode ) return;

		// Only while freely charging: !IsStopped means eyes open and moving (the whole eye-cycle holds
		// IsStopped true). Phase 1+ and a real player only.
		if ( Phase < 1 || IsStopped || IsDead ) return;

		if ( Phase >= 2 && _followingPath )
		{
			if ( _reachedPathWaypoint )
			{
				_followingPath = false;
				_reachedPathWaypoint = false;
				EmitBrakeEffects();
				SoftStopAndRepick();
			}
			return;
		}

		// A fallback leg normally runs until it slams something — but an aligned landing means the
		// movement clamp found a genuine, full-lane opening toward the player mid-leg (a maze junction
		// crossed at speed), which is exactly when a stalker should turn.
		if ( !_pursuing && !aligned ) return;

		var player = Stage?.ClosestTargetablePlayer( Position );
		if ( player == null ) return;

		Vector2 dirVec = Globals.GetVectorForDirection( MoveDirection );
		Vector2 delta = player.Pos - Pos;
		// How far the player is still AHEAD along our travel axis. Positive while closing; zero/negative
		// means we've drawn level and any further travel recedes.
		float along = Vector2.Dot( delta, dirVec );

		// The turn we want is the closing direction on the CROSS axis. Nothing to do if turning gains
		// nothing (player basically on our line) or the turn isn't open right here.
		bool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;
		Direction turn = horizontal
			? (delta.y >= 0f ? Direction.Up : Direction.Down)
			: (delta.x >= 0f ? Direction.Right : Direction.Left);
		float turnDist = Vector2.Dot( delta, Globals.GetVectorForDirection( turn ) );

		if ( turnDist < MIN_PURSUE_DIST ) return;
		if ( IsColliding( turn ) || !IsPursuitLaneClear( turn, turnDist ) )
		{
			// An aligned landing the live checks reject (pixel-grid contact the analytic sweep can't
			// see) must not be re-clamped to next tick — that would freeze the block on the spot.
			_alignedBrakeFailed = aligned;
			return; // blocked here — keep charging
		}

		if ( along > 0f && !aligned )
		{
			// Still closing, and the turn is open HERE. Hold out for the level point if the drop will
			// still be open there; but if it won't be (valley case), the approach-side clear window we're
			// in is our chance. The level point sits `along` ahead on our travel axis (turnDist is
			// unchanged there since the turn is perpendicular).
			if ( along > EARLY_BRAKE_RANGE ) return; // too far out to justify an early stop
			Vector2 levelPos = Pos + dirVec * along;
			if ( IsPursuitLaneClear( turn, turnDist, levelPos ) ) return; // level drop will be open — wait for it

			// Ride the clear window to its FAR edge — every pixel of it closes distance, so braking on
			// entry would stop short of the player. Brake on the last clear column: only when the next
			// tick's travel would carry us out of the window (or past the level point, which we know is
			// blocked).
			float step = _moveSpeed * dt + 1f;
			if ( step < along && IsPursuitLaneClear( turn, turnDist, Pos + dirVec * step ) )
				return; // still clear a tick ahead — keep closing

			// The window closes at/behind us — but only brake once this leg has genuinely moved.
			// Braking at zero distance re-litigates the pick that just started the leg, and at a
			// junction whose both corridors close on the player that ping-pongs the re-pick between
			// the two axes forever (the pick can never re-choose the current heading). A window
			// strictly AHEAD is unaffected: ClampToTurnBrakePoint lands on it exactly either way.
			if ( (Pos - _legStartPos).LengthSquared < STALK_BRAKE_MIN_LEG_TRAVEL * STALK_BRAKE_MIN_LEG_TRAVEL )
				return;
		}

		_brakeTurnIntent = turn; // this brake exists to take this turn — honored by the next re-pick
		EmitBrakeEffects();
		SoftStopAndRepick();
	}

	/// <summary>Movement-side half of the stalk: cut this tick's travel so the block lands EXACTLY on
	/// the spot TickStalk wants to brake on, instead of sampling past it. Per-tick positions are
	/// quantized by speed, so on its own TickStalk can only brake where a tick happens to land — and a
	/// PERFECTLY FLUSH turn (a corridor mouth exactly the block's width, the standard junction of
	/// tunnel levels like maze-1) is a single exact coordinate no sampled position ever hits. Instead
	/// the turn lane is swept analytically (see <see cref="FindTurnBrakePoint"/>) and the movement is
	/// clamped to the chosen brake point; the landing is flagged via <see cref="_alignedOnBrakePoint"/>
	/// so TickStalk re-verifies and brakes there. Runs on FALLBACK legs too: a fallback leg can't stalk
	/// per-tick (its turn was blocked at pick time), but a genuine full-lane opening toward the player
	/// crossed mid-leg — a maze junction — is exactly when a stalker should turn.</summary>
	// Cycle modes route every re-pick through Block.PickCycleDirection, bypassing this class's
	// GetNewDirection/PickHuntDirection entirely — the stalk-brake machinery has no consumer.
	bool IsCycleTurnMode => TurnMode is TurnMode.Clockwise or TurnMode.CounterClockwise;

	Vector2 ClampToTurnBrakePoint( Vector2 movement )
	{
		if ( Phase < 1 || IsDead || IsCycleTurnMode ) return movement;
		if ( _alignedBrakeFailed ) { _alignedBrakeFailed = false; return movement; }

		var player = Stage?.ClosestTargetablePlayer( Position );
		if ( player == null ) return movement;

		Vector2 travel = Globals.GetVectorForDirection( MoveDirection );
		float step = Vector2.Dot( movement, travel );
		if ( step <= 0f ) return movement;

		Vector2 delta = player.Pos - Pos;
		float along = Vector2.Dot( delta, travel );

		// Same turn selection as TickStalk: the closing direction on the cross axis.
		bool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;
		Direction turn = horizontal
			? (delta.y >= 0f ? Direction.Up : Direction.Down)
			: (delta.x >= 0f ? Direction.Right : Direction.Left);
		float turnDist = Vector2.Dot( delta, Globals.GetVectorForDirection( turn ) );

		// Only clamp for a turn whose lane actually extends past our own footprint (this subsumes the
		// MIN_PURSUE_DIST check). When the player's center-line is within our cross-extent the charge
		// we're already on reaches them without any turn — and the sweep's 1px minimum lane would read
		// the corridor we're travelling inside as a wall of degenerate "windows", braking the block a
		// hair sideways at every junction and ping-ponging its re-picks between the two axes.
		float halfTurnExtent = horizontal ? Height / 2f : Width / 2f;
		if ( turnDist - halfTurnExtent < 1f ) return movement;

		float target = FindTurnBrakePoint( travel, turn, step, along, turnDist );
		if ( target < 0f ) return movement;

		_alignedOnBrakePoint = true;
		return travel * target;
	}

	/// <summary>Where along this tick's travel (0..step) should the block stop to take the cross-axis
	/// turn toward the player? Returns -1 when nowhere this tick. Mirrors TickStalk's per-tick brake
	/// rules, computed on exact geometry so brake spots BETWEEN sampled positions are hit:
	///
	///  • the LEVEL POINT, when it's inside this tick's travel and the turn is open there;
	///  • the FAR EDGE of a clear window before the level point when the level drop is blocked (the
	///    valley early-brake) — range-limited for wide windows, but a flush window
	///    (≤ <see cref="FLUSH_TURN_WINDOW_MAX"/>, a corridor mouth) is taken at ANY range, since a
	///    tunnel level never offers anything wider;
	///  • past the level point, the FIRST clear coordinate reachable (the far-side drop).
	///
	/// All spans use the same strict open-interval overlap as <see cref="IsLaneClear"/>, so a lane that
	/// merely touches a wall is CLEAR and the flush coordinate between two junction walls survives as a
	/// zero-width window. Edges are taken straight off solid rects in absolute coordinates, so for
	/// pixel-aligned geometry the clamped landing is exactly flush and TickStalk's live re-verify
	/// agrees with this sweep.</summary>
	float FindTurnBrakePoint( Vector2 travel, Direction turn, float step, float along, float turnDist )
	{
		Vector2 turnVec = Globals.GetVectorForDirection( turn );
		float posV = Vector2.Dot( Pos, travel );   // signed travel-axis coordinate
		float posT = Vector2.Dot( Pos, turnVec );  // signed turn-axis coordinate
		bool horizontal = travel.y == 0f;
		float halfV = horizontal ? Width / 2f : Height / 2f;
		float halfT = horizontal ? Height / 2f : Width / 2f;

		// The lane the turn needs: the block's own extent plus its pursuit run toward the player's
		// center-line (same shortening as IsPursuitLaneClear), at least 1px so a solid resting flush
		// against the turn face still registers (the analytic mirror of TickStalk's IsColliding probe).
		float laneLo = posT - halfT;
		float laneHi = posT + halfT + MathF.Max( turnDist - halfT, 1f );
		float reach = posV + step;   // travel-coord at the end of an unclamped tick
		float level = posV + along;  // travel-coord where we'd draw level with the player

		_laneBlocked.Clear();
		foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
			AddLaneBlocker( ob.GetRect(), travel, turnVec, laneLo, laneHi, halfV, posV, reach );
		foreach ( Block b in Stage.GetBlocks() )
			if ( b != this )
				AddLaneBlocker( b.GetRect(), travel, turnVec, laneLo, laneHi, halfV, posV, reach );

		// The level point itself, when reachable and open, is always the preferred stop.
		if ( along > 0f && along <= step && IsTravelCoordClear( level ) )
			return along;

		if ( _laneBlocked.Count > 1 )
			_laneBlocked.Sort( CompareLaneLo );

		bool? levelOpen = null; // lazy: "would the drop be open at the level point?"
		bool LevelOpen()
		{
			levelOpen ??= along <= step
				? IsTravelCoordClear( level )
				: IsPursuitLaneClear( turn, turnDist, Pos + travel * along );
			return levelOpen.Value;
		}

		// Walk the clear windows between blocked spans. Both are OPEN intervals, so the single
		// coordinate between two touching spans survives as a zero-width window — the flush mouth.
		//
		// Every brake point must lie STRICTLY AHEAD (> posV). The spot we're standing on was already
		// weighed by the pick that started this leg (and per-tick TickStalk covers it on pursuit legs);
		// braking there at zero distance re-litigates that choice every leg start. At a junction whose
		// BOTH corridors close on the player (T/crossroads, player diagonal) each axis sees a flush
		// window exactly at s=0, so zero-distance brakes ping-pong the re-pick between the two axes
		// forever — close eyes, open one way, repeat the other way, never moving.
		float prevHi = float.NegativeInfinity;
		for ( int i = 0; i <= _laneBlocked.Count; i++ )
		{
			float w0 = prevHi;
			float w1 = i < _laneBlocked.Count ? _laneBlocked[i].lo : float.PositiveInfinity;
			if ( i < _laneBlocked.Count )
				prevHi = MathF.Max( prevHi, _laneBlocked[i].hi );
			if ( w1 < w0 ) continue; // spans overlap — no window between them

			if ( along > 0f && w1 <= level )
			{
				// Approaching the level point: an early window is only taken when the level drop is
				// blocked (valley/maze case), riding it to its FAR edge — and a wide one only near the
				// player, while a flush corridor mouth is taken at any range.
				if ( w1 <= posV || w1 > reach ) continue;
				if ( LevelOpen() ) continue; // the level stop is available — hold out for it
				if ( w1 - w0 > FLUSH_TURN_WINDOW_MAX && level - w1 > EARLY_BRAKE_RANGE ) continue;
				return w1 - posV;
			}

			if ( along <= 0f || w0 >= level )
			{
				// At or past the level point every further pixel recedes: stop on the window's first
				// clear coordinate — provided it's genuinely ahead of us.
				if ( w0 <= posV || w0 > reach ) continue;
				return w0 - posV;
			}
		}

		return -1f;
	}

	/// <summary>If <paramref name="solid"/> pokes into the turn lane (strict overlap on the turn axis —
	/// touching is clear, matching RectF.Intersects), record the OPEN span of travel-axis coordinates
	/// where the block's body would overlap it. Spans that can't affect this tick are dropped.</summary>
	static void AddLaneBlocker( RectF solid, Vector2 travel, Vector2 turnVec,
		float laneLo, float laneHi, float halfV, float posV, float reach )
	{
		Vector2 center = new Vector2( (solid.Left + solid.Right) * 0.5f, (solid.Bottom + solid.Top) * 0.5f );
		float centerT = Vector2.Dot( center, turnVec );
		float halfSolidT = turnVec.y == 0f ? solid.Width * 0.5f : solid.Height * 0.5f;
		if ( centerT + halfSolidT <= laneLo || centerT - halfSolidT >= laneHi ) return;

		float centerV = Vector2.Dot( center, travel );
		float halfSolidV = travel.y == 0f ? solid.Width * 0.5f : solid.Height * 0.5f;
		float lo = centerV - (halfV + halfSolidV);
		float hi = centerV + (halfV + halfSolidV);
		if ( hi <= posV - 1f || lo >= reach + 1f ) return;
		_laneBlocked.Add( (lo, hi) );
	}

	/// <summary>Is this travel-axis coordinate outside every blocked span collected for the turn lane?
	/// Spans are open, so an exactly-flush coordinate (touching walls on both sides) is clear.</summary>
	static bool IsTravelCoordClear( float coord )
	{
		foreach ( (float lo, float hi) in _laneBlocked )
			if ( lo < coord && coord < hi )
				return false;
		return true;
	}

	/// <summary>A subtle skid tell when the block brakes to change course: a small lurch shake plus a
	/// little puff of dust off the leading edge. Deterministic sim event (a function of block/player
	/// positions), so it draws from the authoritative Rng exactly like the slam dust in ImpactEffects and
	/// reproduces on replay.</summary>
	void EmitBrakeEffects()
	{
		Vector2 dirVec = Globals.GetVectorForDirection( MoveDirection );
		Vector2 perp = new Vector2( -dirVec.y, dirVec.x ); // unit perpendicular to travel

		// Small lurch backward against the direction of travel (subtler than a real slam).
		AddShake( -dirVec * _moveSpeed * BRAKE_SHAKE_STRENGTH );

		// A few dust motes scattering off the leading edge — the block digging in to change course.
		Vector2 edge = Position + dirVec * (Width / 2f);
		int n = Rng.Int( 2, 4 );
		for ( int i = 0; i < n; i++ )
		{
			Vector2 ppos = edge + perp * Rng.Float( -Height / 3f, Height / 3f );
			Vector2 vel = perp * (Rng.Float( -1f, 1f ) * BRAKE_DUST_SPEED)   // scatter sideways
				- dirVec * Rng.Float( 0f, BRAKE_DUST_SPEED * 0.5f );          // and a touch backward
			Stage.AddParticle( ppos, vel, Rng.Float( 0.88f, 0.94f ), Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust, Rng.Float( 0.25f, 0.5f ), Rng.Int( 2, 4 ) );
		}
	}

	/// <summary>Turn toward the player: honor the turn the last stalk brake stopped for
	/// (<see cref="_brakeTurnIntent"/>) when it's still a valid pursuit, then rank the four axis
	/// directions by how much each closes the straight-line distance to the player (best → worst).
	/// Prefer the best direction whose whole lane is
	/// clear all the way to where it would brake (see <see cref="IsLaneClear"/>) — so it won't aim down a
	/// path an obstacle juts into. If no clear pursuit lane exists, fall back to the best non-walled
	/// direction so it never wedges. Returns <see cref="Direction.None"/> only when fully boxed in.
	/// <paramref name="pursuing"/> is true only for a genuine, lane-clear closing direction.</summary>
	Direction PickHuntDirection( out bool pursuing )
	{
		pursuing = false;
		_followingPath = false;
		_reachedPathWaypoint = false;
		_alignedBrakeFailed = false;
		Direction intent = _brakeTurnIntent;
		_brakeTurnIntent = Direction.None;
		var player = Stage?.ClosestTargetablePlayer( Position );
		if ( player == null ) return Direction.None;

		Vector2 delta = player.Pos - Pos;

		// A stalk brake pulls up FOR a specific turn (we stopped exactly where that turn is open).
		// Honor it before the distance ranking, re-verifying against the live world: from a corridor
		// mouth the other axis often still closes MORE raw distance (the far mouth of a walled
		// platform), so ranking alone re-picks the horizontal run and ping-pongs between the two
		// mouths without ever taking the opening we braked for.
		if ( intent != Direction.None && intent != MoveDirection && !IsColliding( intent )
			&& IsNewDirectionAllowed( MoveDirection, intent ) )
		{
			float intentAlong = Vector2.Dot( delta, Globals.GetVectorForDirection( intent ) );
			if ( intentAlong >= MIN_PURSUE_DIST && IsPursuitLaneClear( intent, intentAlong ) )
			{
				pursuing = true;
				return intent;
			}
		}

		// The closing (toward-player) direction on each axis. +X = right, +Y = up (see Globals).
		Direction horiz = delta.x >= 0f ? Direction.Right : Direction.Left;
		Direction vert = delta.y >= 0f ? Direction.Up : Direction.Down;

		// Prefer the axis with the greater distance to cover; the two receding directions rank last, so
		// this is a full best→worst ordering of all four directions.
		bool horizFirst = MathF.Abs( delta.x ) >= MathF.Abs( delta.y );
		Direction first = horizFirst ? horiz : vert;
		Direction second = horizFirst ? vert : horiz;

		Direction[] ranked =
		{
			first,
			second,
			Globals.GetOppositeDirection( second ),
			Globals.GetOppositeDirection( first ),
		};

		Direction fallback = Direction.None;      // best non-walled dir with real running room
		Direction lastResort = Direction.None;    // best non-walled dir at all (may grind immediately)
		foreach ( var dir in ranked )
		{
			if ( dir == MoveDirection || IsColliding( dir ) || !IsNewDirectionAllowed( MoveDirection, dir ) )
				continue; // current heading, or immediately walled — never a candidate

			// A pursuit direction meaningfully closes distance; take the best one whose lane is clear all
			// the way to the brake point (so an obstacle part-way down the lane rules it out) AND whose
			// closing turn can actually open somewhere along the leg (see PursuitCanCorner) — a clear
			// corridor under the player's platform stalks nothing.
			float along = Vector2.Dot( delta, Globals.GetVectorForDirection( dir ) );
			if ( along >= MIN_PURSUE_DIST && IsPursuitLaneClear( dir, along ) && PursuitCanCorner( dir, delta, along ) )
			{
				pursuing = true;
				return dir;
			}

			// Otherwise a fallback (runs to its wall, doesn't stalk) — but prefer one with actual running
			// room over one that grinds an obstacle a pixel away (the "immediately stopped" pick).
			if ( fallback == Direction.None && IsLaneClear( dir, MIN_FALLBACK_RUN, includeWalls: true ) )
				fallback = dir;
			if ( lastResort == Direction.None )
				lastResort = dir;
		}

		// Phase 1 deliberately keeps the simpler direct stalk. At phase 2, use a shortest cardinal route
		// when level geometry or another block cuts off both player-closing lanes. The waypoint is the
		// route's first corner, where TickStalk brakes and asks for a fresh route against moving blockers.
		if ( Phase >= 2 && TryFindPathToPlayer( player, out Direction pathDirection, out Vector2 waypoint )
			&& IsNewDirectionAllowed( MoveDirection, pathDirection ) )
		{
			_followingPath = true;
			_pathWaypoint = waypoint;
			return pathDirection;
		}

		return fallback != Direction.None ? fallback : lastResort; // Direction.None if fully boxed in
	}

	/// <summary>Can a pursuit leg in <paramref name="dir"/> actually corner toward the player somewhere
	/// along its run? A lane can be clear to the player's center-line while a solid seals the closing
	/// turn for the leg's entire length — player on top of a long platform, hunter charging beneath it:
	/// the leg draws level, sails past and slams without ever stalking anything. Worse, such a leg
	/// outranks the phase-2 route search at every re-pick (including the corner of a route it's mid-way
	/// through following), so the hunter shuttles under the platform forever instead of going around.
	/// Sweeping the turn lane over the whole free run (<see cref="FindTurnBrakePoint"/> with the run as
	/// the tick step) exposes the doomed leg so the pick can demote it.</summary>
	bool PursuitCanCorner( Direction dir, Vector2 delta, float along )
	{
		bool horizontal = dir == Direction.Left || dir == Direction.Right;
		Direction turn = horizontal
			? (delta.y >= 0f ? Direction.Up : Direction.Down)
			: (delta.x >= 0f ? Direction.Right : Direction.Left);
		float turnDist = Vector2.Dot( delta, Globals.GetVectorForDirection( turn ) );

		// Player's center-line inside our own cross extent: the charge reaches them with no turn at
		// all (same guard as ClampToTurnBrakePoint, and for the same reason — the sweep's 1px minimum
		// lane would misread the corridor we'd travel inside as a wall of degenerate windows).
		float halfTurnExtent = horizontal ? Height / 2f : Width / 2f;
		if ( turnDist - halfTurnExtent < 1f ) return true;

		Vector2 travel = Globals.GetVectorForDirection( dir );
		return FindTurnBrakePoint( travel, turn, FreeRunDistance( dir ), along, turnDist ) >= 0f;
	}

	/// <summary>How far the block's body can travel in <paramref name="dir"/> before contacting the
	/// arena wall, an interior obstacle or another block — the full length of the leg a pick would start.</summary>
	float FreeRunDistance( Direction dir )
	{
		RectF r = GetRect();
		float run = dir switch
		{
			Direction.Left => r.Left - Arena.WALL_SIZE,
			Direction.Right => Arena.WIDTH - Arena.WALL_SIZE - r.Right,
			Direction.Down => r.Bottom - Arena.WALL_SIZE,
			_ => Arena.HEIGHT - Arena.WALL_SIZE - r.Top, // Up
		};

		foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
			run = MathF.Min( run, RunToSolid( r, ob.GetRect(), dir ) );
		foreach ( Block b in Stage.GetBlocks() )
			if ( b != this )
				run = MathF.Min( run, RunToSolid( r, b.GetRect(), dir ) );
		return MathF.Max( run, 0f );
	}

	/// <summary>Travel distance in <paramref name="dir"/> before <paramref name="body"/> hits
	/// <paramref name="solid"/>; +∞ when the solid isn't in the travel lane (strict cross overlap —
	/// touching is clear, matching <see cref="RectF.Intersects"/>) or isn't ahead.</summary>
	static float RunToSolid( RectF body, RectF solid, Direction dir )
	{
		bool horizontal = dir == Direction.Left || dir == Direction.Right;
		bool inLane = horizontal
			? solid.Bottom < body.Top && solid.Top > body.Bottom
			: solid.Left < body.Right && solid.Right > body.Left;
		if ( !inLane ) return float.PositiveInfinity;

		float gap = dir switch
		{
			Direction.Left => body.Left - solid.Right,
			Direction.Right => solid.Left - body.Right,
			Direction.Down => body.Bottom - solid.Top,
			_ => solid.Bottom - body.Top, // Up
		};
		return gap >= 0f ? gap : float.PositiveInfinity;
	}

	bool TryFindPathToPlayer( Player player, out Direction direction, out Vector2 waypoint )
	{
		direction = Direction.None;
		waypoint = Pos;

		int minX = (int)MathF.Ceiling( Arena.WALL_SIZE + Width / 2f );
		int maxX = (int)MathF.Floor( Arena.WIDTH - Arena.WALL_SIZE - Width / 2f );
		int minY = (int)MathF.Ceiling( Arena.WALL_SIZE + Height / 2f );
		int maxY = (int)MathF.Floor( Arena.HEIGHT - Arena.WALL_SIZE - Height / 2f );
		int startX = Math.Clamp( (int)MathF.Round( X ), minX, maxX );
		int startY = Math.Clamp( (int)MathF.Round( Y ), minY, maxY );
		int start = PathIndex( startX, startY );

		Array.Clear( _pathBlocked );
		foreach ( Entity2D obstacle in Stage.GetBlockSolidObstacles() )
			MarkPathBlocked( obstacle.GetRect(), minX, maxX, minY, maxY );
		foreach ( Block block in Stage.GetBlocks() )
			if ( block != this )
				MarkPathBlocked( block.GetRect(), minX, maxX, minY, maxY );

		// A transient shove can leave the rounded start overlapping a moving blocker by a fraction. The
		// real collision solver will separate it; allowing the start node lets the route search escape.
		_pathBlocked[start] = false;
		if ( !HasUnblockedPathGoal( player, minX, maxX, minY, maxY ) ) return false;

		Array.Fill( _pathParent, -1 );
		_pathParent[start] = start;
		int head = 0, tail = 0;
		_pathQueue[tail++] = start;
		int goal = -1;

		Vector2 targetDelta = player.Pos - new Vector2( startX, startY );
		Direction horizontal = targetDelta.x >= 0f ? Direction.Right : Direction.Left;
		Direction vertical = targetDelta.y >= 0f ? Direction.Up : Direction.Down;
		bool horizontalFirst = MathF.Abs( targetDelta.x ) >= MathF.Abs( targetDelta.y );
		Direction first = horizontalFirst ? horizontal : vertical;
		Direction second = horizontalFirst ? vertical : horizontal;
		Direction[] searchOrder =
		{
			first,
			second,
			Globals.GetOppositeDirection( second ),
			Globals.GetOppositeDirection( first ),
		};

		while ( head < tail )
		{
			int node = _pathQueue[head++];
			int nodeX = node % Arena.WIDTH;
			int nodeY = node / Arena.WIDTH;
			if ( PathNodeReachesPlayer( nodeX, nodeY, player ) )
			{
				goal = node;
				break;
			}

			foreach ( Direction candidate in searchOrder )
			{
				Vector2 step = Globals.GetVectorForDirection( candidate );
				int nextX = nodeX + (int)step.x;
				int nextY = nodeY + (int)step.y;
				if ( nextX < minX || nextX > maxX || nextY < minY || nextY > maxY ) continue;

				int next = PathIndex( nextX, nextY );
				if ( _pathBlocked[next] || _pathParent[next] != -1 ) continue;
				_pathParent[next] = node;
				_pathQueue[tail++] = next;
			}
		}

		if ( goal == -1 || goal == start ) return false;

		int pathLength = 0;
		for ( int node = goal; node != start; node = _pathParent[node] )
			_pathQueue[pathLength++] = node;

		int firstNode = _pathQueue[pathLength - 1];
		direction = DirectionBetweenPathNodes( start, firstNode );
		int corner = firstNode;
		for ( int i = pathLength - 2; i >= 0; i-- )
		{
			int next = _pathQueue[i];
			if ( DirectionBetweenPathNodes( corner, next ) != direction ) break;
			corner = next;
		}

		waypoint = new Vector2( corner % Arena.WIDTH, corner / Arena.WIDTH );
		return direction != Direction.None;
	}

	bool HasUnblockedPathGoal( Player player, int minX, int maxX, int minY, int maxY )
	{
		float reachX = Width / 2f + player.Width / 2f;
		float reachY = Height / 2f + player.Height / 2f;
		int goalMinX = Math.Max( minX, (int)MathF.Floor( player.X - reachX ) + 1 );
		int goalMaxX = Math.Min( maxX, (int)MathF.Ceiling( player.X + reachX ) - 1 );
		int goalMinY = Math.Max( minY, (int)MathF.Floor( player.Y - reachY ) + 1 );
		int goalMaxY = Math.Min( maxY, (int)MathF.Ceiling( player.Y + reachY ) - 1 );

		for ( int y = goalMinY; y <= goalMaxY; y++ )
			for ( int x = goalMinX; x <= goalMaxX; x++ )
				if ( !_pathBlocked[PathIndex( x, y )] && PathNodeReachesPlayer( x, y, player ) )
					return true;

		return false;
	}

	void MarkPathBlocked( RectF solid, int minX, int maxX, int minY, int maxY )
	{
		// Expand the solid by the Hunter's half-extents: a grid point is then blocked exactly when a
		// Hunter centered there would overlap the original solid. Touching edges remain traversable.
		int left = Math.Max( minX, (int)MathF.Floor( solid.Left - Width / 2f ) + 1 );
		int right = Math.Min( maxX, (int)MathF.Ceiling( solid.Right + Width / 2f ) - 1 );
		int bottom = Math.Max( minY, (int)MathF.Floor( solid.Bottom - Height / 2f ) + 1 );
		int top = Math.Min( maxY, (int)MathF.Ceiling( solid.Top + Height / 2f ) - 1 );
		for ( int y = bottom; y <= top; y++ )
			for ( int x = left; x <= right; x++ )
				_pathBlocked[PathIndex( x, y )] = true;
	}

	bool PathNodeReachesPlayer( int x, int y, Player player )
	{
		return MathF.Abs( x - player.X ) < Width / 2f + player.Width / 2f
			&& MathF.Abs( y - player.Y ) < Height / 2f + player.Height / 2f;
	}

	static int PathIndex( int x, int y ) => y * Arena.WIDTH + x;

	static Direction DirectionBetweenPathNodes( int from, int to )
	{
		int fromX = from % Arena.WIDTH, fromY = from / Arena.WIDTH;
		int toX = to % Arena.WIDTH, toY = to / Arena.WIDTH;
		if ( toX < fromX ) return Direction.Left;
		if ( toX > fromX ) return Direction.Right;
		if ( toY < fromY ) return Direction.Down;
		if ( toY > fromY ) return Direction.Up;
		return Direction.None;
	}

	/// <summary>Lane check for a PURSUIT leg toward the player at center-to-center distance
	/// <paramref name="alongDist"/>: the block only needs to travel until its BODY reaches the player's
	/// center-line (its leading edge is half an extent past that already), so the required clear distance
	/// is the center-to-center distance minus the block's half-extent along the travel axis. Sweeping the
	/// full center-to-center distance would demand clearance half a block PAST the player — through the
	/// very platform they're standing on (or the wall they're hugging) — making every player who's
	/// standing on an obstacle read as unreachable and the hunter overshoot them.</summary>
	bool IsPursuitLaneClear( Direction dir, float alongDist, Vector2? from = null )
	{
		float halfExtent = (dir == Direction.Left || dir == Direction.Right) ? Width / 2f : Height / 2f;
		return IsLaneClear( dir, MathF.Max( alongDist - halfExtent, 0f ), includeWalls: false, from );
	}

	/// <summary>Is the block's whole body clear to travel <paramref name="distance"/> px in
	/// <paramref name="dir"/> without an interior obstacle (or another block) jutting into the lane? Tests
	/// the swept AABB — the block's rect grown by <paramref name="distance"/> along the travel axis — so a
	/// partial cross-axis overlap counts as blocked, not just something dead ahead. Arena walls are left
	/// to the normal collide-and-repick path by default, so a PURSUIT lane that simply runs to a wall
	/// isn't over-rejected; <paramref name="includeWalls"/> adds them for the fallback "running room"
	/// test, where a wall a couple of pixels away is just as much of an immediate stop as an obstacle.
	/// <paramref name="from"/> tests the lane from a hypothetical position instead of the current one
	/// (used by the stalk's "will the drop still be open when I draw level?" lookahead).</summary>
	bool IsLaneClear( Direction dir, float distance, bool includeWalls = false, Vector2? from = null )
	{
		RectF r = from is Vector2 f ? GetRect( f.x, f.y ) : GetRect();
		float left = r.Left, right = r.Right, bottom = r.Bottom, top = r.Top;
		switch ( dir )
		{
			case Direction.Left: left -= distance; break;
			case Direction.Right: right += distance; break;
			case Direction.Down: bottom -= distance; break;
			default: top += distance; break; // Up
		}
		RectF swept = new RectF( left, bottom, right, top );

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

		foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
			if ( swept.Intersects( ob.GetRect() ) )
				return false;

		foreach ( Block b in Stage.GetBlocks() )
			if ( b != this && swept.Intersects( b.GetRect() ) )
				return false;

		return true;
	}

	/// <summary>Hunt at phase 1+; phase 0 stays the neutral random wander every block shares, so the
	/// Hunter's personality reveals itself as it phases up (like every other block type).</summary>
	protected override Direction GetNewDirection()
	{
		_legStartPos = Pos; // every re-pick starts a fresh leg (Pos is the rest position here)

		if ( Phase < 1 || Stage?.ClosestTargetablePlayer( Position ) == null )
		{
			_pursuing = false;
			_followingPath = false;
			_reachedPathWaypoint = false;
			_brakeTurnIntent = Direction.None;
			return base.GetNewDirection();
		}

		Direction dir = PickHuntDirection( out _pursuing );
		return dir == Direction.None ? MoveDirection : dir; // boxed in — hold and try again next stop
	}
}