Entities/Abilities/GrapplerAbility.cs
namespace BlockParty;
public enum GrapplerAmmoOverlayMode
{
BodyFill,
OrbitPixels,
}
/// <summary>Eight-way double-tap grapple with up to three extending hooks and three concurrent pull lines.</summary>
public sealed class GrapplerAbility : PlayerAbility
{
/// <summary>Selects the Grappler ammo presentation without changing ammo behavior.</summary>
public static GrapplerAmmoOverlayMode OverlayMode { get; set; } = GrapplerAmmoOverlayMode.OrbitPixels;
const float TAP_RELEASE_TIME = 0.16f;
const float SECOND_TAP_TIME = 0.14f;
const float DIAGONAL_GRACE_TIME = 0.1f;
const int CLEAR_INPUT_WINDOW_FRAMES = 4;
const int NEVER = int.MaxValue / 2;
const float START_OFFSET = 6f;
const float EXTEND_SPEED = 400f;
const float PULL_SPEED = 255f;
const float PULL_ACCELERATION = 0f; // disabled for now, but could be used to make the pull start slow and ramp up over time
const float PULL_ALONG_CONTROL_SPEED = 25f;
const float PULL_PERPENDICULAR_CONTROL_SPEED = 35f;
// Holding a pulling line's exact launch chord hauls harder; holding the exact opposite chord resists.
const float HELD_TOWARD_PULL_FACTOR = 1.3f;
const float HELD_AWAY_PULL_FACTOR = 0.75f;
const float RELEASE_MOMENTUM_FACTOR = 1.4f;
const float MAX_RELEASE_SPEED = 320f;
const float MAX_REACH = 500f;
const float FINISH_DISTANCE = 6f;
const float PULL_LIFETIME = 4f;
const float PULL_EXPIRY_BLINK_DURATION = 1.0f;
const float PULL_EXPIRY_BLINK_INTERVAL = 0.06f;
const float PULL_EXPIRY_DARKEN_AMOUNT = 0.5f;
const float PULL_HAPTIC_STRENGTH = 0.09f;
// Travel speed below which the hum stays silent. Sits in the gap between the two things it has to
// tell apart: self-steering tops out at PULL_PERPENDICULAR_CONTROL_SPEED (35), while even the
// slowest genuine reel — resisted (PULL_SPEED - 25) * 0.75 through a phase-2 stasis trail (* 0.3) —
// still makes ~51. So anything above this is the line hauling us, not us walking around on the end of it.
const float PULL_HAPTIC_MIN_SPEED = 44f;
const int MAX_EXTENSION_LINES = 3;
const int MAX_PULL_LINES = 3;
const float MAX_AMMO = 7f;
const float AMMO_RECHARGE_PER_SECOND = 0.45f;
const float NO_AMMO_SHAKE_STRENGTH = 1f;
const int NO_AMMO_SHAKE_FRAMES = 4;
const int CONTACT_PARTICLE_COUNT = 4;
const float BREAK_PARTICLE_SPACING = 5f;
const int MAX_BREAK_PARTICLES = 80;
static readonly Color EXTENDING_COLOR = new( 0.7f, 0.85f, 0.75f );
static readonly Color PULLING_COLOR = Color.White;
/// <summary>When false, the extending tip stays on its original world-space ray if the player moves.
/// When true, the whole unanchored line translates with the player as before.</summary>
public bool ExtensionEndpointFollowsPlayer { get; set; } = true;
sealed class ExtensionLine
{
public Vector2 Direction;
public Vector2 End;
public Vector2 Origin;
public float Distance;
public GrappleLineRenderer Renderer;
}
sealed class PullLine
{
public Vector2 Direction;
// Anchor entity (null for obstacles/arena walls). A mimic transform's swap re-points this to
// the replacement instance on the swap tick (see OnBlockReplaced).
public Entity2D AnchorEntity;
public Vector2 AnchorOffset;
// Where the hook last sat on a tangible anchor. A Teleport block's Pos jumps to its destination
// the instant it phases, so the break effect for a vanished anchor uses this instead.
public Vector2 LastAnchor;
public float Lifetime;
public GrappleLineRenderer Renderer;
}
readonly List<ExtensionLine> _extensionLines = new();
readonly List<PullLine> _pullLines = new();
// Reused iteration snapshots for the per-tick loops (Extend / TickPullLines mutate the live
// lists mid-loop); the cold destroy paths keep their throwaway ToArray copies.
readonly List<ExtensionLine> _extensionScratch = new();
readonly List<PullLine> _pullScratch = new();
readonly DirectionalDoubleTapGesture _grappleGesture = new(
TAP_RELEASE_TIME, SECOND_TAP_TIME, DIAGONAL_GRACE_TIME );
int _sinceLeft = NEVER;
int _sinceRight = NEVER;
int _sinceUp = NEVER;
int _sinceDown = NEVER;
bool _clearPending;
Vector2 _grappleMomentum;
bool _releasePending;
float _ammo;
PlayerAmmoOverlay _ammoOverlay;
GrapplerAmmoOrbitOverlay _orbitAmmoOverlay;
readonly List<Vector2> _liveLineDirections = new( MAX_EXTENSION_LINES + MAX_PULL_LINES );
public override void OnSpawn( Player player )
{
_ammo = MAX_AMMO;
var fillOverlayObject = new GameObject( true, "grappler fill ammo overlay" );
fillOverlayObject.SetParent( player.GameObject );
fillOverlayObject.LocalPosition = Vector3.Zero;
_ammoOverlay = fillOverlayObject.Components.Create<PlayerAmmoOverlay>();
_ammoOverlay.Setup( player, 1f );
var orbitOverlayObject = new GameObject( true, "grappler orbit ammo overlay" );
orbitOverlayObject.SetParent( player.GameObject );
orbitOverlayObject.LocalPosition = Vector3.Zero;
_orbitAmmoOverlay = orbitOverlayObject.Components.Create<GrapplerAmmoOrbitOverlay>();
_orbitAmmoOverlay.Setup( player, (int)MAX_AMMO );
UpdateAmmoOverlays();
}
public override void OnRemoved( Player player )
{
DestroyAllLines( player );
_ammoOverlay?.GameObject?.Destroy();
_ammoOverlay = null;
_orbitAmmoOverlay?.GameObject?.Destroy();
_orbitAmmoOverlay = null;
}
public override void OnAnimationChanged( Player player, string animation )
{
if ( animation is "squish_h" or "squish_v" or "explode" )
DestroyAllLines( player );
}
public override void OnDeath( Player player )
{
if ( _ammoOverlay is not null )
_ammoOverlay.Enabled = false;
if ( _orbitAmmoOverlay is not null )
_orbitAmmoOverlay.Enabled = false;
}
public override void PreTick( Player player, float dt )
{
_ammo = Math.Min( MAX_AMMO, _ammo + AMMO_RECHARGE_PER_SECOND * dt );
UpdateAmmoOverlays();
bool hasLines = _extensionLines.Count > 0 || _pullLines.Count > 0;
if ( hasLines )
{
AgeAndCaptureClearInputs( player.InputSource );
_clearPending = _sinceLeft < CLEAR_INPUT_WINDOW_FRAMES
&& _sinceRight < CLEAR_INPUT_WINDOW_FRAMES
&& _sinceUp < CLEAR_INPUT_WINDOW_FRAMES
&& _sinceDown < CLEAR_INPUT_WINDOW_FRAMES;
}
else
{
_clearPending = false;
ResetClearGesture();
}
if ( _clearPending )
{
player.ConsumeDirectionalInputForTick();
ResetGesture();
}
else
{
TickGesture( player, dt );
}
// Anchored lines own self-motion, but gesture recognition still reads the raw input snapshot so
// another line can be fired while these pull. External-force channels remain live.
if ( _pullLines.Count > 0 )
{
player.ConsumeDirectionalInputForTick();
player.ClearSelfMotion();
}
}
public override void PostTick( Player player, float dt )
{
if ( _clearPending )
{
_clearPending = false;
DestroyAllLines( player, preserveMomentum: true );
ResetClearGesture();
ApplyPendingRelease( player );
UpdateAmmoOverlays();
return;
}
if ( _pullLines.Count > 0 && player.IsStuck )
{
DestroyPullLines( player, preserveMomentum: false );
}
else if ( _pullLines.Count > 0 )
{
player.ClearSelfMotion();
TickPullLines( player, dt );
}
if ( player.IsDead ) return;
_extensionScratch.Clear();
_extensionScratch.AddRange( _extensionLines );
foreach ( ExtensionLine line in _extensionScratch )
Extend( player, line, dt );
UpdateLines( player );
ApplyPendingRelease( player );
UpdateAmmoOverlays();
}
void TickGesture( Player player, float dt )
{
if ( _grappleGesture.TryTrigger( player, dt, out Vector2 direction ) )
BeginGrapple( player, direction );
}
static Vector2 ReadDirection( IPlayerInput input )
{
float x = (input.Right ? 1f : 0f) - (input.Left ? 1f : 0f);
float y = (input.Up ? 1f : 0f) - (input.Down ? 1f : 0f);
Vector2 direction = new( x, y );
return direction.LengthSquared > 0f ? Utils.Normalized( direction ) : Vector2.Zero;
}
void BeginGrapple( Player player, Vector2 direction )
{
for ( int i = 0; i < _pullLines.Count; i++ )
{
PullLine line = _pullLines[i];
if ( !SameInputDirection( line.Direction, direction ) ) continue;
DestroyPullLine( player, line, completed: false );
return;
}
foreach ( ExtensionLine line in _extensionLines )
if ( SameInputDirection( line.Direction, direction ) )
return;
if ( _ammo < 1f )
{
player.Shake( direction * NO_AMMO_SHAKE_STRENGTH, NO_AMMO_SHAKE_FRAMES );
Audio.PlaySfx( SfxType.SpikesRetract, player.Position, 0.4f, 0.8f );
return;
}
_ammo -= 1f;
_orbitAmmoOverlay?.Spend( direction * START_OFFSET );
var lineObject = player.Stage.CreateChild( "GrappleLine" );
var renderer = lineObject.Components.Create<GrappleLineRenderer>();
_extensionLines.Add( new ExtensionLine
{
Direction = direction,
Distance = START_OFFSET,
Origin = player.Pos,
End = player.Pos + direction * START_OFFSET,
Renderer = renderer,
} );
if ( _extensionLines.Count > MAX_EXTENSION_LINES )
DestroyExtension( player, _extensionLines[0] );
UpdateAmmoOverlays();
Audio.PlaySfx( SfxType.SpikesSend, player.Position, 0.55f, Rng.CosmeticFloat( 1.4f, 1.6f ) );
}
static bool SameInputDirection( Vector2 first, Vector2 second )
=> MathF.Sign( first.x ) == MathF.Sign( second.x )
&& MathF.Sign( first.y ) == MathF.Sign( second.y );
void UpdateAmmoOverlays()
{
if ( _ammoOverlay is not null )
{
_ammoOverlay.Fill = _ammo / MAX_AMMO;
_ammoOverlay.Enabled = OverlayMode == GrapplerAmmoOverlayMode.BodyFill;
}
if ( _orbitAmmoOverlay is null ) return;
_liveLineDirections.Clear();
foreach ( ExtensionLine line in _extensionLines )
_liveLineDirections.Add( line.Direction );
foreach ( PullLine line in _pullLines )
_liveLineDirections.Add( line.Direction );
_orbitAmmoOverlay.SetState( (int)MathF.Floor( _ammo ), _liveLineDirections );
_orbitAmmoOverlay.Enabled = OverlayMode == GrapplerAmmoOverlayMode.OrbitPixels;
}
void AgeAndCaptureClearInputs( IPlayerInput input )
{
if ( _sinceLeft < NEVER ) _sinceLeft++;
if ( _sinceRight < NEVER ) _sinceRight++;
if ( _sinceUp < NEVER ) _sinceUp++;
if ( _sinceDown < NEVER ) _sinceDown++;
if ( input.LeftJust ) _sinceLeft = 0;
if ( input.RightJust ) _sinceRight = 0;
if ( input.UpJust ) _sinceUp = 0;
if ( input.DownJust ) _sinceDown = 0;
}
void ResetClearGesture()
{
_sinceLeft = _sinceRight = _sinceUp = _sinceDown = NEVER;
}
void Extend( Player player, ExtensionLine line, float dt )
{
line.Distance = MathF.Min( line.Distance + EXTEND_SPEED * dt, MAX_REACH );
Vector2 rayOrigin = ExtensionEndpointFollowsPlayer ? player.Pos : line.Origin;
Vector2 desiredEnd = rayOrigin + line.Direction * line.Distance;
var cast = new Line( rayOrigin, desiredEnd );
if ( FindNearestHit( player, cast, out Vector2 hit, out Entity2D entity ) )
{
// A hit already inside finish range would anchor and complete on the first pull tick with
// zero movement (the floor underfoot, a wall being hugged) — a silently wasted shot.
// Refund the ammo and fizzle the line instead of anchoring.
if ( (hit - player.Pos).Length <= FINISH_DISTANCE )
{
_ammo = Math.Min( MAX_AMMO, _ammo + 1f );
DestroyExtension( player, line );
return;
}
line.End = hit;
SpawnContactParticles( player, hit, line.Direction );
Audio.PlaySfx( SfxType.BlockImpact, hit, 0.5f, 1.35f );
player.DetachForGrapple();
player.ClearSelfMotion();
_extensionLines.Remove( line );
_pullLines.Add( new PullLine
{
Direction = line.Direction,
AnchorEntity = entity,
AnchorOffset = entity is null ? hit : hit - entity.Pos,
LastAnchor = hit,
Renderer = line.Renderer,
} );
if ( _pullLines.Count > MAX_PULL_LINES )
DestroyPullLine( player, _pullLines[0], completed: false );
return;
}
line.End = desiredEnd;
if ( line.Distance >= MAX_REACH )
DestroyExtension( player, line );
}
void TickPullLines( Player player, float dt )
{
Vector2 inputDirection = ReadDirection( player.InputSource );
Vector2 start = player.Pos;
// A stasis trail slows the reel-in: the pull moves the body directly, so the field's usual
// velocity damping never touches it — scale the pull (and steering) speed instead.
float stasisFactor = player.StasisGrappleFactor;
bool attemptedPull = false;
_pullScratch.Clear();
_pullScratch.AddRange( _pullLines );
foreach ( PullLine line in _pullScratch )
{
line.Lifetime += dt;
// A phasing Teleport block is intangible: the hook lets go the tick it vanishes, matching the
// player's own attachments (Player.cs) and the fresh-hook filter (FindNearestHit).
if ( line.Lifetime >= PULL_LIFETIME || AnchorGone( line ) )
{
DestroyPullLine( player, line, completed: false );
continue;
}
Vector2 anchor = AnchorPosition( line );
line.LastAnchor = anchor;
Vector2 delta = anchor - player.Pos;
float distance = delta.Length;
if ( distance <= FINISH_DISTANCE )
{
DestroyPullLine( player, line, completed: true );
continue;
}
Vector2 pullDirection = delta / distance;
float alongInput = Vector2.Dot( inputDirection, pullDirection );
Vector2 perpendicularInput = inputDirection - pullDirection * alongInput;
float pullSpeed = (PULL_SPEED + line.Lifetime * PULL_ACCELERATION
+ alongInput * PULL_ALONG_CONTROL_SPEED) * HeldPullFactor( inputDirection, line.Direction ) * stasisFactor;
float pullDistance = MathF.Min( pullSpeed * dt, distance );
attemptedPull = true;
player.MoveByGrapple(
pullDirection * pullDistance + perpendicularInput * (PULL_PERPENDICULAR_CONTROL_SPEED * stasisFactor * dt) );
if ( player.IsDead )
{
DestroyAllLines( player, preserveMomentum: false );
return;
}
if ( (anchor - player.Pos).Length <= FINISH_DISTANCE )
DestroyPullLine( player, line, completed: true );
}
if ( attemptedPull && dt > 0f )
{
_grappleMomentum = (player.Pos - start) / dt;
// Hum only while a line is actually HAULING us. Lines keep "pulling" every tick even when
// they get nowhere — two anchors on opposite sides pin the player between them — and while
// pinned the only motion left is the player's own perpendicular steering, which must not
// read as a pull either. The speed gate covers both (see PULL_HAPTIC_MIN_SPEED). Past it the
// level is deliberately FLAT: it marks that a line has hold of us, not how hard, so a second
// line or a longer accelerating reel never escalates it.
if ( player.DrivesHaptics && _grappleMomentum.Length >= PULL_HAPTIC_MIN_SPEED )
Haptics.Sustain( HapticChannel.GrapplePull, PULL_HAPTIC_STRENGTH, 0f, Haptics.TONE_HEAVY );
}
}
// Matched against the line's LAUNCH chord with the same 8-way test the double-tap destroy uses, not
// the live anchor bearing — so the keys that fired a line are the keys that speed it up, however far
// the player has since drifted. Partial chords (one key of a diagonal) leave the pull unchanged.
static float HeldPullFactor( Vector2 input, Vector2 lineDirection )
{
if ( input == Vector2.Zero ) return 1f;
if ( SameInputDirection( input, lineDirection ) ) return HELD_TOWARD_PULL_FACTOR;
if ( SameInputDirection( input, -lineDirection ) ) return HELD_AWAY_PULL_FACTOR;
return 1f;
}
static bool AnchorGone( PullLine line ) => line.AnchorEntity is Block block && (block.IsDead || block.PhasingIn);
// Where the hook physically is: a vanished anchor reports its last tangible spot, not the block's
// current Pos (a teleported block has already jumped to its destination).
static Vector2 HookPosition( PullLine line ) => AnchorGone( line ) ? line.LastAnchor : AnchorPosition( line );
static Vector2 AnchorPosition( PullLine line ) => line.AnchorEntity is null
? line.AnchorOffset
: line.AnchorEntity.Pos + line.AnchorOffset;
/// <summary>A mimic transform destroys the anchored block's instance and spawns its disguise at the
/// same spot (GameStage.TransformMimic) — the orphaned component's Pos freezes, so without this the
/// pull reels toward a phantom point. Re-anchor on the swap tick itself.</summary>
public override void OnBlockReplaced( Player player, Block old, Block replacement )
{
foreach ( PullLine line in _pullLines )
if ( ReferenceEquals( line.AnchorEntity, old ) )
line.AnchorEntity = replacement;
}
bool FindNearestHit( Player player, Line cast, out Vector2 nearest, out Entity2D nearestEntity )
{
nearest = Vector2.Zero;
nearestEntity = null;
float nearestDistance = float.MaxValue;
foreach ( Block block in player.Stage.GetBlocks() )
{
if ( block.IsDead || block.PhasingIn ) continue;
Consider( cast, block.GetRect(), block, ref nearest, ref nearestEntity, ref nearestDistance );
}
foreach ( Entity2D obstacle in player.Stage.GetSolidObstacles() )
Consider( cast, obstacle.GetRect(), obstacle, ref nearest, ref nearestEntity, ref nearestDistance );
Consider( cast, new RectF( 0, 0, Arena.WALL_SIZE, Arena.HEIGHT ), null, ref nearest, ref nearestEntity, ref nearestDistance );
Consider( cast, new RectF( Arena.WIDTH - Arena.WALL_SIZE, 0, Arena.WIDTH, Arena.HEIGHT ), null, ref nearest, ref nearestEntity, ref nearestDistance );
Consider( cast, new RectF( 0, 0, Arena.WIDTH, Arena.WALL_SIZE ), null, ref nearest, ref nearestEntity, ref nearestDistance );
Consider( cast, new RectF( 0, Arena.HEIGHT - Arena.WALL_SIZE, Arena.WIDTH, Arena.HEIGHT ), null, ref nearest, ref nearestEntity, ref nearestDistance );
return nearestDistance < float.MaxValue;
}
static void Consider( Line cast, RectF rect, Entity2D entity, ref Vector2 nearest, ref Entity2D nearestEntity, ref float nearestDistance )
{
if ( !cast.Intersects( rect, out Vector2 hit ) ) return;
float distance = (hit - cast.A).LengthSquared;
if ( distance >= nearestDistance ) return;
nearestDistance = distance;
nearest = hit;
nearestEntity = entity;
}
void UpdateLines( Player player )
{
foreach ( ExtensionLine line in _extensionLines )
UpdateLine( line.Renderer, player.Pos + line.Direction * START_OFFSET, line.End, EXTENDING_COLOR );
foreach ( PullLine line in _pullLines )
UpdateLine( line.Renderer, player.Pos + line.Direction * START_OFFSET, AnchorPosition( line ), PullLineColor( line ) );
}
static Color PullLineColor( PullLine line )
{
bool isExpiring = line.Lifetime >= PULL_LIFETIME - PULL_EXPIRY_BLINK_DURATION;
bool darkPhase = (int)(line.Lifetime / PULL_EXPIRY_BLINK_INTERVAL) % 2 == 1;
return isExpiring && darkPhase
? Color.Lerp( PULLING_COLOR, Color.Black, PULL_EXPIRY_DARKEN_AMOUNT )
: PULLING_COLOR;
}
static void UpdateLine( GrappleLineRenderer renderer, Vector2 start, Vector2 end, Color color )
{
if ( (end - start).LengthSquared <= 0.0001f )
{
renderer.Clear();
return;
}
renderer.SetLine( start, end, color, Globals.DEPTH_PLAYER );
}
void DestroyExtension( Player player, ExtensionLine line, bool playSound = true )
{
if ( !_extensionLines.Remove( line ) ) return;
SpawnBreakParticles( player, line.Direction, line.End, EXTENDING_COLOR );
if ( playSound ) PlayBreakSound( player.Pos );
line.Renderer?.GameObject?.Destroy();
}
void DestroyPullLine( Player player, PullLine line, bool completed, bool playSound = true, bool preserveMomentum = true )
{
if ( !_pullLines.Remove( line ) ) return;
Vector2 anchor = HookPosition( line );
if ( preserveMomentum )
QueueRelease();
SpawnBreakParticles( player, line.Direction, anchor, PULLING_COLOR );
if ( playSound )
{
if ( completed ) PlayCompleteSound( player.Pos );
else PlayBreakSound( player.Pos );
}
line.Renderer?.GameObject?.Destroy();
}
void DestroyPullLines( Player player, bool preserveMomentum )
{
bool playSound = true;
foreach ( PullLine line in _pullLines.ToArray() )
{
DestroyPullLine( player, line, completed: false, playSound, preserveMomentum );
playSound = false;
}
if ( !preserveMomentum )
DiscardRelease();
}
void DestroyAllLines( Player player, bool preserveMomentum = false )
{
bool playedSound = false;
foreach ( ExtensionLine line in _extensionLines.ToArray() )
{
DestroyExtension( player, line, playSound: !playedSound );
playedSound = true;
}
foreach ( PullLine line in _pullLines.ToArray() )
{
DestroyPullLine( player, line, completed: false, playSound: !playedSound, preserveMomentum );
playedSound = true;
}
if ( !preserveMomentum )
DiscardRelease();
}
void QueueRelease()
{
_releasePending = true;
}
void ApplyPendingRelease( Player player )
{
if ( !_releasePending || _pullLines.Count > 0 || player.IsDead ) return;
Vector2 releaseVelocity = ClampMagnitude( _grappleMomentum * RELEASE_MOMENTUM_FACTOR, MAX_RELEASE_SPEED );
DiscardRelease();
if ( releaseVelocity.LengthSquared > 0.0001f )
player.ApplyGrappleReleaseVelocity( releaseVelocity );
}
void DiscardRelease()
{
_releasePending = false;
_grappleMomentum = Vector2.Zero;
}
static Vector2 ClampMagnitude( Vector2 value, float maximum )
=> value.LengthSquared > maximum * maximum ? Utils.Normalized( value ) * maximum : value;
static void PlayBreakSound( Vector2 position )
=> Audio.PlaySfx( SfxType.SpikesRetract, position, 0.55f, 1.4f );
static void PlayCompleteSound( Vector2 position )
=> Audio.PlaySfx( SfxType.PlayerLand, position, 0.55f, 1.2f );
static void SpawnContactParticles( Player player, Vector2 hit, Vector2 direction )
{
Vector2 perpendicular = new( -direction.y, direction.x );
for ( int i = 0; i < CONTACT_PARTICLE_COUNT; i++ )
{
Vector2 position = hit - direction * Rng.CosmeticFloat( 0.1f, 0.75f );
Vector2 velocity = -direction * Rng.CosmeticFloat( 20f, 60f )
+ perpendicular * Rng.CosmeticFloat( -32f, 32f );
Color color = i == 0 ? PULLING_COLOR : EXTENDING_COLOR;
player.Stage.AddColoredParticle( position, velocity, Rng.CosmeticFloat( 0.84f, 0.9f ), color,
Rng.CosmeticFloat( 0.12f, 0.22f ), 1, Globals.DEPTH_PLAYER );
}
}
void SpawnBreakParticles( Player player, Vector2 direction, Vector2 end, Color color )
{
Vector2 start = player.Pos + direction * START_OFFSET;
Vector2 delta = end - start;
float length = delta.Length;
if ( length <= 0.01f ) return;
Vector2 along = delta / length;
Vector2 perpendicular = new( -along.y, along.x );
int count = Math.Clamp( (int)MathF.Ceiling( length / BREAK_PARTICLE_SPACING ), 2, MAX_BREAK_PARTICLES );
for ( int i = 0; i < count; i++ )
{
float distance = length * (i + 0.5f) / count;
Vector2 position = start + along * distance + perpendicular * Rng.CosmeticFloat( -0.35f, 0.35f );
Vector2 velocity = perpendicular * Rng.CosmeticFloat( -16f, 16f ) + along * Rng.CosmeticFloat( -5f, 5f );
player.Stage.AddColoredParticle( position, velocity, 0.82f, color,
Rng.CosmeticFloat( 0.12f, 0.24f ), Rng.CosmeticInt( 1, 3 ), Globals.DEPTH_PLAYER );
}
}
void ResetGesture()
{
_grappleGesture.Reset();
}
}