A Particle subclass that steers a non-colliding particle toward a target point or a target entity. It supports direct acceleration toward a moving anchor, optional settling at the target (stop when close), and an exponential lerp mode that moves the particle toward the target without using velocity.
namespace BlockParty;
/// <summary>
/// A <see cref="Particle"/> that steers toward a target point each step (port of the original
/// <c>TargetedParticle</c>). Used for Spikey's "spike send" burst: little squares stream out of
/// its third eye and accelerate onto the side it's grafting spikes to. Non-colliding.
///
/// When anchored to an entity the target tracks that entity (the offset is captured at spawn),
/// so the particles follow the rammed block as it drifts; with no anchor (arena walls) the
/// target is a fixed world point.
/// </summary>
public sealed class TargetedParticle : Particle
{
private Entity2D _anchor; // null => _targetOffset is an absolute world point
private Vector2 _targetOffset;
private float _targetAcceleration;
private bool _settlesAtTarget;
private bool _settled;
private float _settleDistance;
private bool _lerpsToTarget;
private float _targetLerpSpeed;
public void SetupTarget( Vector2 targetOffset, float targetAcceleration, Entity2D anchor )
{
_targetOffset = targetOffset;
_targetAcceleration = targetAcceleration;
_anchor = anchor;
_collides = false;
}
/// <summary>Stop at the target instead of repeatedly overshooting it. Opt-in so the original
/// Spikey and Sticky send particles retain their existing motion.
///
/// Also switches OFF wall/obstacle bouncing, because a settling particle is drawing a line to one
/// specific point and must be free to reach it. Spikey/Sticky hop a few px to an adjacent block
/// face so nothing is ever in the way, but the Slicer's charge stream crosses the WHOLE arena —
/// and the beam it's charging ignores interior obstacles, so its particles have to as well.
/// Left bouncing, a deflection flipped the particle away from its target, which the "passed it"
/// test below then read as arrival: the stream visibly dead-ended on obstacle faces and arena
/// walls instead of landing on the line.</summary>
public void SettleAtTarget( float settleDistance = 2f )
{
_settlesAtTarget = true;
_settleDistance = settleDistance;
_bounces = false;
}
/// <summary>Move directly toward the live target with exponential interpolation instead of
/// velocity or acceleration, so a short cosmetic convergence cannot overshoot its destination.</summary>
public void LerpToTarget( float speed )
{
_lerpsToTarget = true;
_targetLerpSpeed = speed;
Velocity = Vector2.Zero;
_bounces = false;
}
public override void Tick( float dt )
{
base.Tick( dt );
Vector2 targetPos = (_anchor is not null) ? _anchor.Position + _targetOffset : _targetOffset;
if ( _lerpsToTarget )
{
float amount = 1f - MathF.Exp( -_targetLerpSpeed * dt );
Pos = Vector2.Lerp( Pos, targetPos, amount );
if ( _clampsToArenaBounds ) ClampInsideArena();
Velocity = Vector2.Zero;
return;
}
if ( _settled )
{
Pos = targetPos;
Velocity = Vector2.Zero;
return;
}
Vector2 toTarget = targetPos - Position;
float distance = toTarget.Length;
float speedTowardTarget = distance > 0.001f ? Vector2.Dot( Velocity, toTarget / distance ) : 0f;
// "Passed it" has to mean passed it CLOSELY: moving away from the target while still within the
// last step's reach of it (if we really flew through it, we can't be further off than one step).
// Ungated, ANY velocity reversal — a wall/obstacle bounce, a gravity turn — counted as arrival
// and teleported the particle to its target from wherever it happened to be.
float step = Velocity.Length * dt;
bool passedTarget = speedTowardTarget < 0f && distance <= step + _settleDistance;
if ( _settlesAtTarget && (distance <= _settleDistance || speedTowardTarget * dt >= distance || passedTarget) )
{
Pos = targetPos;
Velocity = Vector2.Zero;
_settled = true;
return;
}
Velocity += Utils.Normalized( toTarget ) * _targetAcceleration * dt;
}
}