Entities/Blocks/BlockWisp.cs

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.

File AccessNetworkingHttp CallsExternal DownloadProcess ExecutionReflectionNative InteropObfuscated CodeEncoded DataSelf Modifying CodeCredential Access
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 ) );
	}
}