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