AI input source for an impostor clone player. Drives a Player by producing held and edge input bits each fixed step, implements chase heuristics (walk, jump, wall-jump, fast-fall), detour waypoint pathfinding on a coarse grid, stuck-escape behavior for sticky surfaces, lifetime expiry, and a celebratory wander mode.
namespace BlockParty;
/// <summary>
/// AI input + chase brain for Summoner impostors. Drives a
/// <see cref="Player"/> by supplying the eight gameplay input bits each fixed step, so the AI body
/// inherits ALL of the player's physics (gravity, wall-jump, crush/spike death, wind/magnet/stasis) for
/// free. The behaviour is a simple "chase the real player" heuristic: walk toward them horizontally, and
/// jump / wall-jump to climb toward them or over an obstacle in the way. A small coarse-grid search picks
/// a nearby detour waypoint when blocks or level geometry prevent a direct route. The brain PLANS in the impostor's
/// gravity frame (vertical offsets and "grounded" measured against its current floor — the ceiling inside
/// a reverse-gravity field) and publishes its Up/Down intents as physical keys through the same swap the
/// player's input accessors apply, so a clone navigates a flipped field as competently as level ground.
///
/// It also owns the clone's lifetime countdown (<see cref="Expired"/>). <see cref="Lifetime"/> is the
/// hostile-impostor default; callers can provide a shorter lifetime.
///
/// Deterministic / replay-safe: the decision is a function of the two bodies' positions, the impostor's
/// collision flags, the fixed step, and the AUTHORITATIVE Rng stream (a small "walk instead of jump"
/// chance so the chase reads less robotic) — no <c>Time.Delta</c> — so a replay reproduces every impostor
/// move exactly. Edges (the "just pressed" bits) are derived from the previous step's held bits, mirroring
/// how <see cref="InputState.Sample"/> latches them for the real player.
/// </summary>
public sealed class AiInputSource : IPlayerInput
{
// --- tuning ---------------------------------------------------------------------------------
public const float Lifetime = 8.0f; // seconds the impostor lives before it dissipates
const float MOVE_DEADZONE = 5.0f; // don't jitter left/right when roughly aligned
const float STUCK_TANGENT_DEADZONE = 5.0f; // stuck escape: chase along the face only when the
// target clearly leads somewhere, else nearest edge
const float JUMP_UP_MARGIN = 8.0f; // target must be at least this far above to jump up at it
const float CLIMB_HEIGHT_MARGIN = 26.0f; // target this far above (more than a single jump) → wall-climb
const float FAST_FALL_MARGIN = 24.0f; // hold Down to drop faster when the target is well below
const float JUMP_COOLDOWN = 0.30f; // min gap between jump attempts (also lets Up release so a
// fresh UpJust edge can fire — a held Up never re-triggers)
const int UP_HOLD_TICKS = 7; // ~0.12s of held Up per jump (a brief float for extra height)
const float JUMP_CHANCE = 0.7f; // chance to actually leap when the player is above & the way up is clear
const float WALK_PAUSE = 0.4f; // seconds to walk before reconsidering a declined jump
const int PATH_GRID_STEP = 2; // coarse enough to stay cheap, fine enough for the 8px-wide body
const float PATH_REPLAN_TIME = 0.30f; // moving blocks/targets only need occasional route refreshes
const float PATH_MOVE_DEADZONE = 1.0f;
const float PATH_WAYPOINT_REACH = 2.0f;
// Strictly less than the smallest body axis, so two player centers separated by even a zero-width
// solid cannot be mistaken for a reached goal. Surface-gravity hitbox rotation only swaps these axes.
static readonly float PATH_GOAL_REACH = MathF.Min( Player.COLLISION_SIZE.x, Player.COLLISION_SIZE.y );
// Searches complete synchronously while impostors tick sequentially, so every brain can share this workspace.
const int PATH_GRID_WIDTH = Arena.WIDTH / PATH_GRID_STEP;
const int PATH_GRID_HEIGHT = Arena.HEIGHT / PATH_GRID_STEP;
const int PATH_NODE_COUNT = PATH_GRID_WIDTH * PATH_GRID_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];
// --- input snapshot (what the player reads this step) ---------------------------------------
public bool Left { get; private set; }
public bool Right { get; private set; }
public bool Up { get; private set; }
public bool Down { get; private set; }
public bool LeftJust { get; private set; }
public bool RightJust { get; private set; }
public bool UpJust { get; private set; }
public bool DownJust { get; private set; }
// --- brain state ----------------------------------------------------------------------------
float _life;
bool _pLeft, _pRight, _pUp, _pDown; // previous held bits (for edge derivation)
int _upHold; // remaining ticks to keep Up held for the current jump
float _jumpCooldown; // remaining time before the next jump attempt is allowed
Direction _climbWall = Direction.None; // the wall we're currently wall-climbing (Left/Right), else None
float _celebrateTimer; // game-over celebration: time until the next random heading
int _celebrateDir; // game-over celebration: current wander heading (-1/0/1)
float _pathReplanTimer;
Vector2 _pathWaypoint;
bool _hasPathWaypoint;
Direction _pathClimbWall = Direction.None;
int _stuckEscapeDir; // committed tangent heading while glued (0 = free)
Direction _stuckEscapeFace = Direction.None; // the face that heading was committed against
public AiInputSource( float lifetime = Lifetime )
{
_life = Math.Max( 0f, lifetime );
}
/// <summary>True once the impostor has outlived its lifetime and should dissipate.</summary>
public bool Expired => _life <= 0f;
/// <summary>Advance the AI one fixed step: age the lifetime and choose this step's input to chase
/// <paramref name="target"/> (the real player's position).</summary>
public void Think( Player self, Vector2 target, float dt )
{
_life -= dt;
// Glued to a sticky block: the chase heuristics below assume free movement and just press into
// the face — escape along its tangent instead (see ThinkStuckEscape). Nav bookkeeping pauses
// with us; the replan timer picks the route back up on release.
if ( ThinkStuckEscape( self, target ) ) return;
_pathReplanTimer -= dt;
if ( _hasPathWaypoint && (_pathWaypoint - self.Pos).Length <= PATH_WAYPOINT_REACH )
{
_hasPathWaypoint = false;
_pathClimbWall = Direction.None;
_pathReplanTimer = 0f;
}
if ( _pathReplanTimer <= 0f )
{
_hasPathWaypoint = TryFindDetourWaypoint( self, target, out _pathWaypoint, out _pathClimbWall );
_pathReplanTimer = PATH_REPLAN_TIME;
}
Vector2 chaseTarget = _hasPathWaypoint ? _pathWaypoint : target;
float dx = chaseTarget.x - self.X;
// Vertical offset in the impostor's GRAVITY FRAME (positive = away from its current floor): inside
// a reverse-gravity field the impostor stands on the CEILING, so "above" — needs a jump to reach —
// means world-BELOW. Planning in the frame keeps every vertical margin/branch here unchanged; the
// publish step at the bottom maps the frame intents back to physical keys.
float dy = self.GravityReversed ? -(chaseTarget.y - self.Y) : (chaseTarget.y - self.Y);
bool grounded = self.OnFloor; // the surface gravity holds us against (the ceiling when reversed)
// Base horizontal intent: toward the target (deadzone stops left/right jitter when roughly aligned).
float moveDeadzone = _hasPathWaypoint ? PATH_MOVE_DEADZONE : MOVE_DEADZONE;
int hx = 0;
if ( dx < -moveDeadzone ) hx = -1;
else if ( dx > moveDeadzone ) hx = 1;
// Does the target sit more than a single jump above us? If so we can't just hop up to it — we climb.
bool needHeight = dy > CLIMB_HEIGHT_MARGIN;
// Drop the climb target the moment we no longer need height (landed, or the target dipped to/below
// us). Leaving it set would keep the fast-fall below suppressed even when we should be diving down.
if ( grounded || !needHeight ) _climbWall = Direction.None;
// WALL-CLIMB: to reach a target higher than one jump, hug a wall and repeatedly wall-jump up it. Each
// wall-jump throws us UP and AWAY from the wall, so we keep steering BACK toward that SAME wall (while
// holding Up) to re-touch it and jump again — exactly "hold up and move back into the wall". Once we're
// high enough (needHeight clears) we stop and head straight for the target.
if ( needHeight )
{
if ( !grounded )
{
Direction touching = self.CollidingLeft ? Direction.Left
: self.CollidingRight ? Direction.Right
: Direction.None;
if ( touching != Direction.None ) _climbWall = touching; // lock onto the wall we reach
}
if ( _climbWall == Direction.Left ) hx = -1; // press into / return to the climb wall
else if ( _climbWall == Direction.Right ) hx = 1;
else if ( _pathClimbWall == Direction.Left ) hx = -1;
else if ( _pathClimbWall == Direction.Right ) hx = 1;
else if ( hx == 0 && _hasPathWaypoint && dx != 0f ) hx = dx < 0f ? -1 : 1;
else if ( hx == 0 )
{
// No wall yet & aligned under the target: head for the wall the TARGET is flush against —
// a wall-hugging player may be partway up an interior pillar face, and "nearest arena side"
// points AWAY from a centered pillar, fighting the toward-target rule at the deadzone edge
// (hx flips sign every step: the impostor hops and spins in place instead of climbing).
// Only a target that isn't against anything falls back to the nearest arena side.
int wallSide = WallSideBeside( self, chaseTarget );
hx = wallSide != 0 ? wallSide : (self.X < Arena.WIDTH / 2f ? -1 : 1);
}
}
// Target BELOW us while we're against a wall (e.g. hugging the side of a moving block high
// above the player): don't wall-jump up it — peel off the wall so we DROP down past it. Without this the
// "blocked toward target" wall-jump below fires forever and we grind up into the block / ceiling.
bool targetBelow = dy < -JUMP_UP_MARGIN;
if ( targetBelow )
{
if ( grounded )
{
// A vertical-first descending route reaches its drop by walking along the second, horizontal
// leg. Its waypoint can share our X at the ledge lip, so the ordinary waypoint deadzone gives
// no input; preserve the planned horizontal leg until gravity can take over past the edge.
if ( _pathClimbWall == Direction.Left ) hx = -1;
else if ( _pathClimbWall == Direction.Right ) hx = 1;
}
else
{
if ( self.CollidingLeft ) { hx = 1; _upHold = 0; } // steer off the wall, stop holding Up
else if ( self.CollidingRight ) { hx = -1; _upHold = 0; }
}
}
bool left = hx < 0;
bool right = hx > 0;
bool up = false, down = false;
// "Blocked toward the target" = pressing into a wall/block on the side we're moving. That same press
// is what arms the player's wall-jump (hugging the wall), so pressing Up on this tick both climbs a
// wall and hops a ground obstacle. During a climb this fires every time we re-touch the climb wall.
bool blockedTowardTarget = (left && self.CollidingLeft) || (right && self.CollidingRight);
bool targetAbove = dy > JUMP_UP_MARGIN;
_jumpCooldown -= dt;
bool wantJump = false;
if ( _jumpCooldown <= 0f )
{
if ( grounded && blockedTowardTarget )
wantJump = true; // always hop over something blocking our path
else if ( grounded && targetAbove )
{
// Planned climbs jump immediately once the wall blocks us via the branch above. During the
// unobstructed approach, occasionally walk before reconsidering the jump.
if ( Rng.Value() < JUMP_CHANCE )
wantJump = true;
else
_jumpCooldown = WALK_PAUSE; // walk a moment before reconsidering
}
else if ( !grounded && blockedTowardTarget && targetAbove )
wantJump = true; // wall-jump off a wall ONLY to climb toward a target above us
}
if ( wantJump )
{
_upHold = UP_HOLD_TICKS;
_jumpCooldown = JUMP_COOLDOWN;
}
if ( _upHold > 0 )
{
up = true;
_upHold--;
}
// Fast-fall onto a target that's well below us while airborne — but never while wall-climbing (holding
// Down would fight the upward climb).
if ( !grounded && _climbWall == Direction.None && dy < -FAST_FALL_MARGIN )
down = true;
// The brain's up/down are GRAVITY-FRAME intents (up = jump / away from the floor, down = dive
// toward it). Publish them as PHYSICAL key bits: inside a reverse field the player-side semantic
// swap (Player.UpPressed reads physical Down there) would otherwise re-interpret them, so swapping
// here makes the two swaps cancel and the frame intent lands on the jump/fast-fall semantics
// exactly. Edges latch off the previous PHYSICAL bits, mirroring how InputState latches for the
// real player (whose physical keys are likewise what the recorder sees).
if ( self.GravityReversed ) (up, down) = (down, up);
PublishInput( left, right, up, down );
}
/// <summary>Escape input while glued to a sticky block face. The chase heuristics assume free
/// movement — glued to a SIDE face with a level target they press into the wall and issue no climb
/// input at all, dangling until the 4s stick timeout tears the body off. Only tangent travel works
/// in the goo (the jump/walljump is refused), so head along the face: on the first stuck tick pick
/// a heading — toward the chase target when it has a meaningful offset on that axis (keeps the
/// pursuit pressure, exits the near edge), else toward the NEAREST edge — then COMMIT to it. A
/// solid across the heading (the floor a grounded sticky block rests on, an obstacle lip we're
/// perched on) turns the crawl into a zero-net shove-and-fold — ApplyStuckMovement pushes the body
/// back and folds the displacement into the tangent offset, a frozen dangle all over again — so a
/// blocked heading flips to the other edge. The commitment is what makes the flip safe: re-deriving
/// the preference each tick would ping-pong between a blocked near edge and the open far one
/// forever. The side-face climb keys are PHYSICAL world directions (ApplyStuckMovement routes them
/// through GravitySign), so unlike the chase's frame intents these publish WITHOUT the
/// reverse-field swap; a top/bottom walk is plain Left/Right either way. Draws no Rng, so it serves
/// both the authoritative chase and the cosmetic celebration without touching either stream.</summary>
bool ThinkStuckEscape( Player self, Vector2? chase )
{
if ( !self.IsStuck )
{
_stuckEscapeDir = 0;
_stuckEscapeFace = Direction.None;
return false;
}
Direction face = self.StuckFace;
bool verticalFace = face is Direction.Left or Direction.Right;
// Fresh grab (or the glue handed us to a different face): choose the heading once.
if ( _stuckEscapeDir == 0 || face != _stuckEscapeFace )
{
float tangentSelf = verticalFace ? self.Y : self.X;
float tangentEdgeRef = verticalFace ? self.StuckBlockCenter.y : self.StuckBlockCenter.x;
float toChase = chase.HasValue ? (verticalFace ? chase.Value.y : chase.Value.x) - tangentSelf : 0f;
_stuckEscapeDir = MathF.Abs( toChase ) > STUCK_TANGENT_DEADZONE
? Math.Sign( toChase )
: (tangentSelf >= tangentEdgeRef ? 1 : -1);
_stuckEscapeFace = face;
}
// Heading walled off → commit to the other edge. (Both edges walled off = genuinely wedged;
// the flip then alternates to no effect and the 4s stick timeout is the way out.)
bool blocked = verticalFace
? (_stuckEscapeDir > 0 ? self.CollidingUp : self.CollidingDown)
: (_stuckEscapeDir > 0 ? self.CollidingRight : self.CollidingLeft);
if ( blocked ) _stuckEscapeDir = -_stuckEscapeDir;
_upHold = 0; // a queued jump hold is useless in the goo
_climbWall = Direction.None; // a release re-picks its climb wall from fresh contacts
if ( verticalFace ) PublishInput( left: false, right: false, up: _stuckEscapeDir > 0, down: _stuckEscapeDir < 0 );
else PublishInput( left: _stuckEscapeDir < 0, right: _stuckEscapeDir > 0, up: false, down: false );
return true;
}
/// <summary>Publish this step's held bits, latching the "just pressed" edges off the previous
/// step's bits (same model as InputState.Sample).</summary>
void PublishInput( bool left, bool right, bool up, bool down )
{
LeftJust = left && !_pLeft;
RightJust = right && !_pRight;
UpJust = up && !_pUp;
DownJust = down && !_pDown;
Left = left; Right = right; Up = up; Down = down;
_pLeft = left; _pRight = right; _pUp = up; _pDown = down;
}
/// <summary>Find a short obstacle-aware waypoint toward the target. A route with at most two straight
/// legs is just the ordinary direct chase, so only a route that needs a third leg produces a waypoint.
/// The waypoint ends the first two legs, giving the existing walk/jump/wall-jump heuristic a nearby
/// corner to pursue without trying to replace player physics with grid movement.</summary>
bool TryFindDetourWaypoint( Player self, Vector2 target, out Vector2 waypoint, out Direction climbWall )
{
waypoint = target;
climbWall = Direction.None;
if ( HasClearSameLevelRoute( self, target ) ) return false;
float halfWidth = self.Width / 2f;
float halfHeight = self.Height / 2f;
int minX = (int)MathF.Ceiling( (Arena.WALL_SIZE + halfWidth) / PATH_GRID_STEP );
int maxX = (int)MathF.Floor( (Arena.WIDTH - Arena.WALL_SIZE - halfWidth) / PATH_GRID_STEP );
int minY = (int)MathF.Ceiling( (Arena.WALL_SIZE + halfHeight) / PATH_GRID_STEP );
int maxY = (int)MathF.Floor( (Arena.HEIGHT - Arena.WALL_SIZE - halfHeight) / PATH_GRID_STEP );
int startX = Math.Clamp( (int)MathF.Round( self.X / PATH_GRID_STEP ), minX, maxX );
int startY = Math.Clamp( (int)MathF.Round( self.Y / PATH_GRID_STEP ), minY, maxY );
int start = PathIndex( startX, startY );
Array.Clear( _pathBlocked );
foreach ( Entity2D obstacle in self.Stage.GetPlayerSolidObstacles() )
MarkPathBlocked( obstacle.GetRect(), halfWidth, halfHeight, minX, maxX, minY, maxY );
foreach ( Block block in self.Stage.GetBlocks() )
if ( !block.IsDead && !block.PhasingIn )
MarkPathBlocked( block.GetRect(), halfWidth, halfHeight, minX, maxX, minY, maxY );
// Moving solids can overlap the rounded start cell by less than one grid step. Player collision
// resolution will separate the body, so allow the search to escape from that cell.
_pathBlocked[start] = false;
int targetX = Math.Clamp( (int)MathF.Round( target.x / PATH_GRID_STEP ), minX, maxX );
bool horizontalRouteBlocked = IsHorizontalPathBlocked( startX, startY, targetX );
Array.Fill( _pathParent, -1 );
_pathParent[start] = start;
int head = 0, tail = 0;
_pathQueue[tail++] = start;
int goal = -1;
Vector2 targetDelta = target - self.Pos;
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;
while ( head < tail )
{
int node = _pathQueue[head++];
int nodeX = node % PATH_GRID_WIDTH;
int nodeY = node / PATH_GRID_WIDTH;
if ( PathNodeReachesTarget( nodeX, nodeY, target ) )
{
goal = node;
break;
}
for ( int i = 0; i < 4; i++ )
{
Direction candidate = i switch
{
0 => first,
1 => second,
2 => Globals.GetOppositeDirection( second ),
_ => Globals.GetOppositeDirection( first ),
};
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;
Direction runDirection = Direction.None;
Direction firstRunDirection = Direction.None;
Direction secondRunDirection = Direction.None;
Direction thirdRunDirection = Direction.None;
int runCount = 0;
int previous = start;
int firstRunEnd = start;
int secondRunEnd = start;
for ( int i = pathLength - 1; i >= 0; i-- )
{
int node = _pathQueue[i];
Direction direction = DirectionBetweenPathNodes( previous, node );
if ( direction != runDirection )
{
runDirection = direction;
runCount++;
if ( runCount == 1 ) firstRunDirection = direction;
else if ( runCount == 2 ) secondRunDirection = direction;
else if ( runCount == 3 ) thirdRunDirection = direction;
}
if ( runCount <= 1 ) firstRunEnd = node;
if ( runCount <= 2 ) secondRunEnd = node;
previous = node;
}
// A platformer cannot execute a blocked Up/Down → Left/Right route by directly chasing the
// target: it must keep climbing until its whole body reaches the first corner above/below the
// obstacle. Keep the route's horizontal leg as an explicit wall-press direction: deriving it from
// waypoint X made the target move with each replan and created an attractor beside the obstacle.
if ( runCount == 2 && horizontalRouteBlocked
&& firstRunDirection is Direction.Up or Direction.Down
&& secondRunDirection is Direction.Left or Direction.Right )
{
waypoint = PathPosition( firstRunEnd );
waypoint.y += Globals.GetVectorForDirection( firstRunDirection ).y * JUMP_UP_MARGIN;
climbWall = secondRunDirection;
return true;
}
if ( runCount <= 2 ) return false;
waypoint = PathPosition( secondRunEnd );
Direction edgeTravel = Direction.None;
Direction edgeVertical = Direction.None;
if ( firstRunDirection is Direction.Left or Direction.Right
&& secondRunDirection is Direction.Up or Direction.Down )
{
edgeTravel = firstRunDirection;
edgeVertical = secondRunDirection;
}
else if ( secondRunDirection is Direction.Left or Direction.Right
&& thirdRunDirection is Direction.Up or Direction.Down )
{
edgeTravel = secondRunDirection;
edgeVertical = thirdRunDirection;
}
if ( edgeTravel != Direction.None )
{
// At a drop, move fully past the supporting corner. At a climb, press slightly back into the
// wall so the existing wall-hug and repeated wall-jump behavior takes over.
bool climbing = self.GravityReversed
? edgeVertical == Direction.Down
: edgeVertical == Direction.Up;
float horizontalSign = Globals.GetVectorForDirection( edgeTravel ).x;
float horizontalOffset = climbing ? -PATH_GRID_STEP : PATH_GRID_STEP;
waypoint.x += horizontalSign * horizontalOffset;
if ( climbing )
{
waypoint.y += Globals.GetVectorForDirection( edgeVertical ).y * JUMP_UP_MARGIN;
climbWall = edgeTravel;
}
}
return true;
}
/// <summary>Which side of <paramref name="point"/> has a solid face flush against a player body
/// standing there: -1 = left, 1 = right, 0 = neither. Steers a climb toward the wall a wall-hugging
/// target is holding onto — which may be an interior obstacle (or block) face, not an arena side.
/// Fences are excluded (players pass through them); glass is included (solid to players).</summary>
static int WallSideBeside( Player self, Vector2 point )
{
float halfWidth = Player.COLLISION_SIZE.x / 2f;
float halfHeight = Player.COLLISION_SIZE.y / 2f;
const float PROBE = 2f;
// End-trim the probe verticals like the player's own side-wall flag probes: hairline embedding
// in the support UNDERFOOT (glue float round-trip, a rising block catching the feet) or a corner
// graze would otherwise read the target's floor as a hugged side wall and steer the climb the
// wrong way. A genuinely hugged wall overlaps most of the body — and the climb itself keys off
// the trimmed CollidingLeft/Right flags, so an untrimmed match here could chase a wall the
// wall-jump can never arm on.
float trim = Player.COLLISION_SIZE.y * Player.WALL_PROBE_END_TRIM_FRAC;
RectF leftProbe = new RectF( point.x - halfWidth - PROBE, point.y - halfHeight + trim,
point.x - halfWidth, point.y + halfHeight - trim );
RectF rightProbe = new RectF( point.x + halfWidth, point.y - halfHeight + trim,
point.x + halfWidth + PROBE, point.y + halfHeight - trim );
if ( leftProbe.Left <= Arena.WALL_SIZE ) return -1;
if ( rightProbe.Right >= Arena.WIDTH - Arena.WALL_SIZE ) return 1;
foreach ( Entity2D obstacle in self.Stage.GetPlayerSolidObstacles() )
{
if ( leftProbe.Intersects( obstacle.GetRect() ) ) return -1;
if ( rightProbe.Intersects( obstacle.GetRect() ) ) return 1;
}
foreach ( Block block in self.Stage.GetBlocks() )
{
if ( block.IsDead || block.PhasingIn ) continue;
if ( leftProbe.Intersects( block.GetRect() ) ) return -1;
if ( rightProbe.Intersects( block.GetRect() ) ) return 1;
}
return 0;
}
bool HasClearSameLevelRoute( Player self, Vector2 target )
{
if ( !(self.CollidingDown || self.CollidingUp)
|| MathF.Abs( target.y - self.Y ) >= PATH_GRID_STEP ) return false;
// A standing center can round into the support's inflated grid row even though the body only
// touches its edge. Use the continuous swept body here so that support does not invent a climb.
float halfWidth = self.Width / 2f;
float halfHeight = self.Height / 2f;
RectF route = new RectF(
MathF.Min( self.X, target.x ) - halfWidth,
self.Y - halfHeight,
MathF.Max( self.X, target.x ) + halfWidth,
self.Y + halfHeight );
foreach ( Entity2D obstacle in self.Stage.GetPlayerSolidObstacles() )
if ( route.Intersects( obstacle.GetRect() ) ) return false;
foreach ( Block block in self.Stage.GetBlocks() )
if ( !block.IsDead && !block.PhasingIn && route.Intersects( block.GetRect() ) ) return false;
return true;
}
static bool IsHorizontalPathBlocked( int startX, int y, int targetX )
{
int step = Math.Sign( targetX - startX );
for ( int x = startX + step; step != 0 && x != targetX + step; x += step )
if ( _pathBlocked[PathIndex( x, y )] ) return true;
return false;
}
static void MarkPathBlocked( RectF solid, float halfWidth, float halfHeight,
int minX, int maxX, int minY, int maxY )
{
int left = Math.Max( minX, (int)MathF.Floor( (solid.Left - halfWidth) / PATH_GRID_STEP ) + 1 );
int right = Math.Min( maxX, (int)MathF.Ceiling( (solid.Right + halfWidth) / PATH_GRID_STEP ) - 1 );
int bottom = Math.Max( minY, (int)MathF.Floor( (solid.Bottom - halfHeight) / PATH_GRID_STEP ) + 1 );
int top = Math.Min( maxY, (int)MathF.Ceiling( (solid.Top + halfHeight) / PATH_GRID_STEP ) - 1 );
for ( int y = bottom; y <= top; y++ )
for ( int x = left; x <= right; x++ )
_pathBlocked[PathIndex( x, y )] = true;
}
static bool PathNodeReachesTarget( int x, int y, Vector2 target )
{
Vector2 position = new Vector2( x * PATH_GRID_STEP, y * PATH_GRID_STEP );
return MathF.Abs( position.x - target.x ) < PATH_GOAL_REACH
&& MathF.Abs( position.y - target.y ) < PATH_GOAL_REACH;
}
static int PathIndex( int x, int y ) => y * PATH_GRID_WIDTH + x;
static Vector2 PathPosition( int node )
=> new Vector2( node % PATH_GRID_WIDTH * PATH_GRID_STEP, node / PATH_GRID_WIDTH * PATH_GRID_STEP );
static Direction DirectionBetweenPathNodes( int from, int to )
{
int fromX = from % PATH_GRID_WIDTH, fromY = from / PATH_GRID_WIDTH;
int toX = to % PATH_GRID_WIDTH, toY = to / PATH_GRID_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>Game-over CELEBRATION input (used when the PLAYER died): the impostor gleefully hops around
/// at random instead of chasing. The run is already decided, so this is cosmetic — it draws from the
/// cosmetic Rng stream and never ages the lifetime.</summary>
public void Celebrate( Player self, float dt )
{
// Even the victory lap respects the goo: a celebrating body that hopped onto a sticky block
// walks itself off the nearest edge (no chase target), then gets back to partying.
if ( ThinkStuckEscape( self, null ) ) return;
bool grounded = self.OnFloor; // hop off whatever gravity holds us to (the ceiling in a reverse field)
// Wander: pick a fresh random horizontal heading every so often.
_celebrateTimer -= dt;
if ( _celebrateTimer <= 0f )
{
int r = Rng.CosmeticInt( 0, 3 ); // 0 = left, 1 = right, 2 = stand still
_celebrateDir = r == 0 ? -1 : (r == 1 ? 1 : 0);
_celebrateTimer = Rng.CosmeticFloat( 0.3f, 0.9f );
}
// Peel off a wall so we don't grind against it.
if ( self.CollidingLeft ) _celebrateDir = 1;
else if ( self.CollidingRight ) _celebrateDir = -1;
bool left = _celebrateDir < 0;
bool right = _celebrateDir > 0;
bool up = false, down = false;
// Excited hopping: jump most times we're grounded and off cooldown.
_jumpCooldown -= dt;
if ( grounded && _jumpCooldown <= 0f && Rng.CosmeticValue() < 0.85f )
{
_upHold = UP_HOLD_TICKS;
_jumpCooldown = Rng.CosmeticFloat( 0.15f, 0.5f );
}
if ( _upHold > 0 ) { up = true; _upHold--; }
// Frame → physical mapping, same as Think (a celebratory hop off a reverse-field ceiling needs
// the physical Down key there).
if ( self.GravityReversed ) (up, down) = (down, up);
PublishInput( left, right, up, down );
}
}