A game entity class BlockWisp that implements a free-floating enemy block with phase-based movement and behaviors. It handles wandering, phase-1 fleeing from players, phase-2 sinusoidal weave/hunting, per-axis collision integration against the arena and other blocks, slam handling, facing/eye timing, and cosmetic trail particles.
namespace BlockParty;
/// <summary>
/// Wisp block — a skittish free-floater, the only block that isn't bound to cardinal lanes. Phase 0 is
/// the neutral cardinal wander every block shares; from phase 1 on it lifts off the grid entirely:
///
/// • It drifts semi-aimlessly in ANY direction — a smoothly re-rolled heading + speed it eases toward,
/// so the float reads dreamy rather than jittery. A gentle standing repulsion from the arena walls
/// keeps the idle drift out in the open.
/// • At phase 1, when the player gets too close it panics and RUNS: the drift blends into a straight
/// flee away from the player, faster than the player can sprint — in open space it can't be caught,
/// so you press its sides by herding it into a wall or corner where the flee has nowhere left to go
/// (the wall repulsion is deliberately weaker than a full-panic flee).
///
/// Phase 2 (max phase — nothing left to press, so it exists purely as a hazard) PARTIALLY INVERTS:
/// it never flees, it drifts faster with a much twitchier re-roll cadence and a layered sinusoidal
/// weave on both axes — erratic but flowing (lissajous-style curves, not noise) — and a slice of its
/// heading re-rolls aim roughly at the player, so the weave keeps carving through your space.
///
/// Movement integrates per-axis (X then Y each tick) against the same pixel-grid collision the lane
/// blocks use: a soft touch just kills that axis component so it slides along whatever it grazed, while
/// a hard hit (over the normal slam threshold) lands a real slam — dust, shake, sfx, and the standard
/// eye-close → re-pick → re-open pause — so the wisp still feeds every slam-keyed mechanic. Side
/// pressing and phasing are the default block behaviour throughout.
/// </summary>
public sealed class BlockWisp : Block
{
// Free-float velocity (px/s). This is the authoritative motion at phase 1+; Block.Velocity mirrors
// it every tick so crush checks / riding / sticky reads see the real diagonal motion.
Vector2 _vel;
Vector2 _wanderDir; // unit drift heading the float eases toward
float _wanderSpeed; // target drift speed
float _retargetTimer; // countdown to the next heading re-roll
float _oscT; // running clock for the phase-2 sinusoidal weave
float _oscPhaseX, _oscPhaseY;
bool _floatStarted; // seeds _vel from the cardinal Velocity on the first float tick
float _trailTimer; // cosmetic wisp-mote cadence
// Drift: lazy meander at phase 1, quick restless weave at phase 2.
const float WANDER_SPEED_MIN_P1 = 25f, WANDER_SPEED_MAX_P1 = 55f;
const float WANDER_SPEED_MIN_P2 = 60f, WANDER_SPEED_MAX_P2 = 105f;
const float RETARGET_MIN_P1 = 0.7f, RETARGET_MAX_P1 = 1.6f;
const float RETARGET_MIN_P2 = 0.35f, RETARGET_MAX_P2 = 0.8f;
// Flee (phase 1 only): kicks in inside the radius and ramps to full panic well before contact.
// Faster than the player's ~86 px/s sprint, so cornering (not chasing) is how you pin it.
// Phase 2 is already max phase — nothing left to press, so being catchable is irrelevant and it
// doesn't flee AT ALL. Instead it's a partial INVERSION: mostly the random weave, but a slice of
// its heading re-rolls aim at the player (see HUNT_CHANCE), so it keeps drifting menacingly
// through your space as a roaming hazard.
const float FLEE_RADIUS = 80f;
const float FLEE_SPEED = 115f;
const float MAX_FLOAT_SPEED_P2 = 175f; // speed cap for the phase-2 weave (phase 1 caps at FLEE_SPEED)
// Phase 2: fraction of heading re-rolls that aim at the player instead of a random direction.
const float HUNT_CHANCE = 0.3f;
const float HUNT_SPREAD = 0.5f; // ± radians around dead-on, so the hunt legs aren't laser-precise
// How hard the float steers toward its desired velocity (px/s²); a phase-1 panic steers harder.
const float ACCEL_P1 = 260f, ACCEL_P2 = 420f;
const float FLEE_ACCEL_P1 = 420f;
// Phase-2 weave: two incommensurate frequencies, one per axis, so the path never visibly repeats.
const float OSC_AMP = 55f;
const float OSC_FREQ_X = 3.1f, OSC_FREQ_Y = 4.3f;
const float FACING_MIN_OPEN_TIME = 0.35f;
const float FACING_EYES_CLOSED_TIME = 0.1f;
const float FACING_MIN_COMPONENT_SPEED = 12f;
const float FACING_AXIS_SWITCH_RATIO_P1 = 1.35f;
const float FACING_AXIS_SWITCH_RATIO_P2 = 1.6f;
// Standing wall repulsion on the drift. Much weaker than a full flee, so a panicking wisp can
// still be driven into a wall and pinned there.
const float WALL_AVOID_RANGE = 30f;
const float WALL_AVOID_STR = 70f;
protected override void OnSetup()
{
// Random weave phases so two wisps never oscillate in sync. Setup runs in deterministic sim
// order, so drawing from the authoritative Rng here is replay-safe.
_oscPhaseX = Rng.Float( 0f, MathF.PI * 2f );
_oscPhaseY = Rng.Float( 0f, MathF.PI * 2f );
}
// Quick recovery: the wisp is flighty, so its post-slam pause is short (base is 0.28–0.60s).
protected override float EyesStayClosedTime() => Phase switch
{
2 => Rng.Float( 0.08f, 0.16f ),
1 => Rng.Float( 0.16f, 0.30f ),
_ => base.EyesStayClosedTime(),
};
// ----------------------------------------------------------------------------------------
protected override void TickMovement( float dt )
{
if ( Phase < 1 )
{
_floatStarted = false;
base.TickMovement( dt );
return;
}
// The shared enclosed check intentionally ignores neighbouring blocks. A free-floating Wisp
// trapped between blocks would otherwise keep attempting diagonal movement and alternately
// unpenetrating from its side neighbours, carrying a rider left/right by a rendered pixel.
if ( IsFloatTrapped() )
{
Velocity = Vector2.Zero;
DecaySpeedLines( 0.5f );
RenderSpeedLines();
return;
}
_oscT += dt;
if ( IsStopped || IsDead )
{
_vel = Vector2.Zero;
DecaySpeedLines( 0.5f );
RenderSpeedLines();
return;
}
// Phase-up mid-charge: carry the cardinal momentum into the float so it doesn't snap.
if ( !_floatStarted )
{
_floatStarted = true;
_vel = Velocity;
RerollWander();
}
TickFloat( dt );
}
bool IsFloatTrapped()
{
// Resolve an existing penetration before treating the Wisp as stationary; probing one pixel
// from an overlap can report every direction blocked by the same neighbour.
if ( IsCollidingWithBlock( X, Y, out _ ) || IsCollidingWithObstacle( X, Y, out _ ) )
return false;
foreach ( Direction direction in Globals.GetAllDirections() )
if ( !IsColliding( direction ) ) return false;
return true;
}
void TickFloat( float dt )
{
bool p2 = Phase >= 2;
_retargetTimer -= dt;
if ( _retargetTimer <= 0f )
RerollWander();
// Desired velocity: drift heading, plus the phase-2 sinusoidal weave, plus wall repulsion.
Vector2 desired = _wanderDir * _wanderSpeed;
if ( p2 )
desired += new Vector2(
MathF.Sin( _oscT * OSC_FREQ_X + _oscPhaseX ),
MathF.Sin( _oscT * OSC_FREQ_Y + _oscPhaseY ) ) * OSC_AMP;
desired += WallRepulsion();
// Flee (phase 1 only — phase 2 never runs, see HUNT_CHANCE): inside the radius, blend the
// drift into a straight run away from the player. The blend hits full panic at ~45% of the
// radius, so a close approach always outranks the drift AND the wall repulsion (which is
// what lets the player pin it against a wall).
float accel = p2 ? ACCEL_P2 : ACCEL_P1;
float cap = p2 ? MAX_FLOAT_SPEED_P2 : FLEE_SPEED;
var player = Stage?.ClosestLivingPlayer( Position );
if ( !p2 && player != null )
{
Vector2 away = Pos - player.Pos;
float dist = away.Length;
if ( dist < FLEE_RADIUS )
{
Vector2 fleeDir = dist > 0.001f ? away / dist : _wanderDir;
float blend = Math.Clamp( (1f - dist / FLEE_RADIUS) * 1.8f, 0f, 1f );
Vector2 fleeTarget = fleeDir * cap;
desired += (fleeTarget - desired) * blend;
accel += (FLEE_ACCEL_P1 - accel) * blend;
}
}
// Ease the live velocity toward the desired one, then cap the speed.
Vector2 diff = desired - _vel;
float dm = diff.Length;
if ( dm > 0.0001f )
_vel += diff / dm * MathF.Min( accel * dt, dm );
float speed = _vel.Length;
if ( speed > cap )
_vel *= cap / speed;
// Integrate one axis at a time against the same pixel-grid collision the lane blocks use —
// diagonal motion, cardinal resolution.
MoveAxis( horizontal: true, dt );
MoveAxis( horizontal: false, dt );
if ( IsStopped ) return; // a hard axis hit slammed us this tick — stop cleanly here
Velocity = _vel;
_moveSpeed = _vel.Length;
UpdateFacing();
// Same end-of-move safety as the base mover: bounds clamp + push out of any obstacle another
// block's unpenetrate shoved us into. Unlike the base mover we must also resolve BLOCK overlap
// here: MoveAxis deliberately lets an already-penetrating wisp keep moving (so a shove can't
// freeze it), and if the other block is stopped, nothing else would ever separate the pair
// (base blocks resolve this in their forward-collision path, which the float doesn't run).
ClampToBounds( X, Y );
foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
Unpenetrate( X, Y, ob );
if ( IsCollidingWithBlock( X, Y, out Entity2D overlapped ) )
Unpenetrate( X, Y, overlapped );
// No speed lines while floating — the cardinal streaks read wrong on a diagonal path (they
// trail off the facing axis, not the true motion). Decay out any left over from phase 0;
// the mint trail motes are the float's motion tell instead.
DecaySpeedLines( 0.5f );
RenderSpeedLines();
TickTrail( dt );
}
// ----------------------------------------------------------------------------------------
/// <summary>Advance one axis of the float, stopping flush against whatever it hits. A soft touch
/// kills that axis component (the float slides along the surface); a hard hit lands a real slam.</summary>
void MoveAxis( bool horizontal, float dt )
{
if ( IsStopped ) return; // the other axis already slammed this tick
float v = horizontal ? _vel.x : _vel.y;
if ( v == 0f ) return;
float nx = X + (horizontal ? v * dt : 0f);
float ny = Y + (horizontal ? 0f : v * dt);
// Arena walls: clamp the axis and register the hit.
float half = horizontal ? Width / 2f : Height / 2f;
float lo = Arena.WALL_SIZE + half;
float hi = (horizontal ? Arena.WIDTH : Arena.HEIGHT) - Arena.WALL_SIZE - half;
float p = horizontal ? nx : ny;
bool wallHit = p < lo || p > hi;
if ( wallHit )
{
p = Math.Clamp( p, lo, hi );
if ( horizontal ) nx = p; else ny = p;
}
// Blocks + interior obstacles: refuse a move that creates a NEW penetration (if we're already
// overlapping — shoved by another block's unpenetrate — let the move through and the
// end-of-tick push-out resolve it rather than freezing in place).
if ( (IsCollidingWithBlock( nx, ny, out Entity2D hit ) || IsCollidingWithObstacle( nx, ny, out hit ))
&& !(IsCollidingWithBlock( X, Y, out _ ) || IsCollidingWithObstacle( X, Y, out _ )) )
{
Pos = new Vector2( nx, ny );
Unpenetrate( X, Y, hit ); // snap flush (min-translation ≈ this axis; the step is tiny)
OnAxisHit( horizontal, v, hit );
return;
}
Pos = new Vector2( nx, ny );
if ( wallHit )
OnAxisHit( horizontal, v, null );
}
void OnAxisHit( bool horizontal, float v, Entity2D hit )
{
float speed = MathF.Abs( v );
if ( speed > REQUIRED_COLLISION_SPEED )
{
// Hard hit: a real slam. Face the impact so the dust/shake/eyes read correctly, bank the
// axis speed for the effect scaling, and run the standard stop → eye-cycle → re-pick.
SetFacing( horizontal
? (v > 0f ? Direction.Right : Direction.Left)
: (v > 0f ? Direction.Up : Direction.Down) );
_moveSpeed = speed;
Velocity = _vel; // ImpactEffects banks this into PreImpactVelocity for the fling mechanics
PressFaceSlammedIntoStatue( hit ); // SetFacing above aimed MoveDirection at the impact
ImpactEffects( hit as Block );
CloseEyesAndPickNewDirection();
_vel = Vector2.Zero;
return;
}
// Soft touch: just stop drifting into it and keep floating along it.
if ( horizontal ) _vel.x = 0f; else _vel.y = 0f;
}
// ----------------------------------------------------------------------------------------
/// <summary>Re-roll the drift heading/speed and the time until the next re-roll. At phase 2 a
/// slice of the unbiased re-rolls (<see cref="HUNT_CHANCE"/>) aims roughly AT the player — the
/// partial inversion of the phase-1 flee that keeps the max-phase weave drifting through the
/// player's space instead of politely wandering elsewhere.</summary>
void RerollWander( float? biasAngle = null )
{
bool p2 = Phase >= 2;
float a;
var player = Stage?.ClosestLivingPlayer( Position );
if ( biasAngle is float bias )
a = bias + Rng.Float( -1.1f, 1.1f );
else if ( p2 && player != null && Rng.Value() < HUNT_CHANCE )
{
Vector2 to = player.Pos - Pos;
a = MathF.Atan2( to.y, to.x ) + Rng.Float( -HUNT_SPREAD, HUNT_SPREAD );
}
else
a = Rng.Float( 0f, MathF.PI * 2f );
_wanderDir = new Vector2( MathF.Cos( a ), MathF.Sin( a ) );
_wanderSpeed = p2 ? Rng.Float( WANDER_SPEED_MIN_P2, WANDER_SPEED_MAX_P2 )
: Rng.Float( WANDER_SPEED_MIN_P1, WANDER_SPEED_MAX_P1 );
_retargetTimer = p2 ? Rng.Float( RETARGET_MIN_P2, RETARGET_MAX_P2 )
: Rng.Float( RETARGET_MIN_P1, RETARGET_MAX_P1 );
}
/// <summary>Standing push away from any arena wall within range — keeps the idle drift out in the
/// open. Deliberately weaker than a full flee so the panic run can still be pinned against a wall.</summary>
Vector2 WallRepulsion()
{
Vector2 push = Vector2.Zero;
RectF r = GetRect();
float l = r.Left - Arena.WALL_SIZE;
float rt = (Arena.WIDTH - Arena.WALL_SIZE) - r.Right;
float b = r.Bottom - Arena.WALL_SIZE;
float t = (Arena.HEIGHT - Arena.WALL_SIZE) - r.Top;
if ( l < WALL_AVOID_RANGE ) push.x += (1f - l / WALL_AVOID_RANGE) * WALL_AVOID_STR;
if ( rt < WALL_AVOID_RANGE ) push.x -= (1f - rt / WALL_AVOID_RANGE) * WALL_AVOID_STR;
if ( b < WALL_AVOID_RANGE ) push.y += (1f - b / WALL_AVOID_RANGE) * WALL_AVOID_STR;
if ( t < WALL_AVOID_RANGE ) push.y -= (1f - t / WALL_AVOID_RANGE) * WALL_AVOID_STR;
return push;
}
/// <summary>Face the dominant axis of the float velocity, with hysteresis (the other axis must
/// clearly dominate before a blink is requested) so a near-45° drift doesn't flicker between
/// facings. The eye cycle bounds how often the visible direction can change.</summary>
void UpdateFacing()
{
float ax = MathF.Abs( _vel.x ), ay = MathF.Abs( _vel.y );
bool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;
float switchRatio = Phase >= 2 ? FACING_AXIS_SWITCH_RATIO_P2 : FACING_AXIS_SWITCH_RATIO_P1;
if ( horizontal && ay > ax * switchRatio ) horizontal = false;
else if ( !horizontal && ax > ay * switchRatio ) horizontal = true;
float facingSpeed = horizontal ? ax : ay;
if ( facingSpeed < FACING_MIN_COMPONENT_SPEED ) return;
Direction desiredFacing = horizontal
? (_vel.x >= 0f ? Direction.Right : Direction.Left)
: (_vel.y >= 0f ? Direction.Up : Direction.Down);
FaceAfterBlink( desiredFacing, FACING_MIN_OPEN_TIME, FACING_EYES_CLOSED_TIME );
}
/// <summary>Post-slam re-pick at phase 1+: a fresh drift heading biased away from whatever we just
/// slammed (we're facing it), returned as its dominant cardinal so the eyes open looking the right
/// way. Phase 0 keeps the shared random cardinal pick.</summary>
protected override Direction GetNewDirection()
{
if ( Phase < 1 )
return base.GetNewDirection();
Vector2 away = -Globals.GetVectorForDirection( MoveDirection );
RerollWander( MathF.Atan2( away.y, away.x ) );
Direction proposedDirection = MathF.Abs( _wanderDir.x ) >= MathF.Abs( _wanderDir.y )
? (_wanderDir.x >= 0f ? Direction.Right : Direction.Left)
: (_wanderDir.y >= 0f ? Direction.Up : Direction.Down);
Direction constrainedDirection = EnforceAlwaysTurn( MoveDirection, proposedDirection );
if ( constrainedDirection != proposedDirection )
{
Vector2 turn = Globals.GetVectorForDirection( constrainedDirection );
float turnAngle = MathF.Atan2( turn.y, turn.x ) + Rng.Float( -0.6f, 0.6f );
_wanderDir = new Vector2( MathF.Cos( turnAngle ), MathF.Sin( turnAngle ) );
}
return constrainedDirection;
}
// ----------------------------------------------------------------------------------------
/// <summary>Cosmetic tell: a faint stream of mint motes shed behind the float. Cosmetic-Rng only,
/// so the flourish can't perturb the sim.</summary>
void TickTrail( float dt )
{
if ( _moveSpeed < 15f ) return;
_trailTimer -= dt;
if ( _trailTimer > 0f ) return;
_trailTimer = Rng.CosmeticFloat( 0.05f, 0.10f );
var kind = Rng.CosmeticValue() < 0.65f ? ParticleKind.WispTrail0 : ParticleKind.WispTrail1;
Vector2 pos = Position + new Vector2(
Rng.CosmeticFloat( -Width * 0.35f, Width * 0.35f ),
Rng.CosmeticFloat( -Height * 0.35f, Height * 0.35f ) );
Vector2 vel = -_vel * 0.15f + new Vector2( Rng.CosmeticFloat( -8f, 8f ), Rng.CosmeticFloat( -8f, 8f ) );
Stage.AddParticle( pos, vel, Rng.CosmeticFloat( 0.90f, 0.95f ), 0f, kind,
Rng.CosmeticFloat( 0.3f, 0.6f ), Rng.CosmeticInt( 2, 4 ) );
}
}