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();
	}
}