Entities/Player.GunnerForces.cs

Part of the Player entity that handles Gunner-related forces and recoil. It computes and applies bullet repel and gun kickback impulses, consolidates multiple velocity channels into base velocity on an axis, and chooses between explosion and glass-fallback impulses when appropriate.

Native Interop
using System;

namespace BlockParty;

public sealed partial class Player
{
	/// <summary>Apply a Gunner bullet's close-range repel. Sticky glue tears before the impulse is
	/// applied, reducing the force to 80% on phase 1 and 60% on phase 2 and opening the normal same-face
	/// re-grab grace window. Motion on the repel axis is consolidated into base velocity so an older,
	/// non-decaying jump velocity cannot resurface after a temporary opposing force fades.</summary>
	public bool ApplyBulletRepel( Vector2 dir, float strength )
		=> ApplyBulletRepel( dir, strength, 0f, out _ );

	/// <summary>Resolve a glass shot against its vertical fallback, comparing the actual impulses
	/// after sticky attenuation and opposing-momentum cancellation. Apply only the winning impulse.</summary>
	internal bool ApplyBulletRepel( Vector2 dir, float strength, float fallbackLift, out bool usedExplosion )
	{
		usedExplosion = true;
		if ( IsDead || IsHardened || IsTwinDashing || IsSquashed || strength <= 0f ) return false;

		if ( IsStuck )
		{
			float stickyFactor = TearStickyGlueForRepel();
			strength *= stickyFactor;
			fallbackLift *= stickyFactor;
		}

		DetachPlatform();
		if ( fallbackLift != 0f )
		{
			Vector2 explosionDir = Utils.Normalized( dir );
			Vector2 kickDir = new( 0f, MathF.Sign( fallbackLift ) );
			Vector2 explosionImpulse = GetGunImpulse( GetConsolidatedGunVelocity( explosionDir ), explosionDir,
				strength, BULLET_REPEL_COUNTER_MOMENTUM_FACTOR );
			Vector2 kickImpulse = GetGunImpulse( GetConsolidatedGunVelocity( kickDir ), kickDir,
				MathF.Abs( fallbackLift ), BULLET_REPEL_COUNTER_MOMENTUM_FACTOR );
			// Both candidates start from the same total vertical velocity. Comparing their vertical
			// impulses therefore compares the resulting lift, including momentum in every gun channel.
			if ( explosionImpulse.y * kickDir.y < kickImpulse.y * kickDir.y )
			{
				dir = kickDir;
				strength = MathF.Abs( fallbackLift );
				usedExplosion = false;
			}
		}
		ApplyGunDirectionalVelocity( dir, strength, BULLET_REPEL_COUNTER_MOMENTUM_FACTOR );
		return true;
	}

	/// <summary>A recoil impulse opposite the Gunner's shot. Grounded vertical recoil is suppressed.
	/// From v49, shots into supporting glass queue a vertical fallback on the bullet: a real impact
	/// wins if it supplies at least as much lift, otherwise the fallback fires on impact or once the
	/// bullet clears the nearby area.
	/// This follows gravity, including shots up into a glass ceiling in reverse gravity.
	/// Suppressed vertical recoil redirects the full force along any remaining horizontal axis.
	/// The recoil axis is consolidated into base velocity rather than a separately decaying channel.
	///
	/// <para><paramref name="counterMomentumFactor"/> adds the component of the player's full integrated
	/// movement opposing recoil. At 1, recoil first cancels that opposing component, then leaves the
	/// complete base kick; movement perpendicular to recoil is unchanged.</para></summary>
	public void ApplyGunKickback( Vector2 shotDirection, float force, float counterMomentumFactor, Bullet bullet )
	{
		if ( IsDead || force <= 0f ) return;

		Vector2 baseKick = -Utils.Normalized( shotDirection ) * force;
		if ( OnFloor )
		{
			bullet.RecordGroundedShotForEditor( this );
			// Contact flags predate this shot. Re-probe so a block moving in front of the glass
			// cannot grant a glass fallback from a stale supporting-surface sample.
			if ( Stage.SimVersion >= Sim.GUNNER_GLASS_RECOIL && baseKick.y * GravitySign > 0f
				&& IsPlayerColliding( X, Y - GravitySign, EffectiveGravityDirection, out var floor )
				&& floor is Obstacle { IsGlass: true } glass )
				bullet.QueueGlassKickback( this, glass, baseKick.y );
			baseKick.y = 0f;
		}
		if ( baseKick == Vector2.Zero ) return;

		ApplyGunDirectionalVelocity( baseKick, force, counterMomentumFactor );
	}

	Vector2 GetConsolidatedGunVelocity( Vector2 dir )
	{
		Vector2 velocity = Velocity;
		if ( dir.x != 0f ) velocity.x += ExtraVelX + _grappleReleaseVelocity.x + _shockwaveVel.x;
		if ( dir.y != 0f ) velocity.y += ExtraVelY + _grappleReleaseVelocity.y + _shockwaveVel.y;
		return velocity;
	}

	// Shared by glass-shot selection and actual application, so the comparison includes exactly
	// the same cancellation without consuming velocity channels or applying two impulses.
	Vector2 GetGunImpulse( Vector2 velocity, Vector2 dir, float strength, float counterMomentumFactor )
	{
		float baseAlong = Vector2.Dot( velocity, dir );
		float externalAlong = Vector2.Dot( FieldVelocity, dir );
		float opposingSpeed = Math.Max( 0f, -(baseAlong + externalAlong) );
		return dir * (strength + opposingSpeed * Math.Max( 0f, counterMomentumFactor ));
	}

	/// <summary>Collapse every player-owned component on one Gunner-force axis into base velocity, then
	/// apply the impulse. Channels are consumed AXIS-BY-AXIS — the whole x and/or y component of a
	/// channel, including retained grapple release momentum, folds into base velocity when the force
	/// touches that axis. On a cardinal axis this is
	/// exactly the along-component (identical to a vector projection); on a diagonal it also relocates
	/// the channel's perpendicular residue into base velocity rather than leaving a ROTATED remnant
	/// behind (a projection would smear a purely horizontal ExtraVel into a phantom vertical component
	/// with the wrong decay/cap semantics). Total velocity is conserved either way. Environmental
	/// velocity is not consumed, but is included when calculating how much owned velocity is needed to
	/// cancel it.</summary>
	void ApplyGunDirectionalVelocity( Vector2 dir, float strength, float counterMomentumFactor )
	{
		dir = Utils.Normalized( dir );
		if ( dir == Vector2.Zero || strength <= 0f ) return;

		Velocity = GetConsolidatedGunVelocity( dir );
		if ( dir.x != 0f )
		{
			ExtraVelX = 0f;
			_grappleReleaseVelocity = new Vector2( 0f, _grappleReleaseVelocity.y );
			_shockwaveVel = new Vector2( 0f, _shockwaveVel.y );
		}
		if ( dir.y != 0f )
		{
			ExtraVelY = 0f;
			_grappleReleaseVelocity = new Vector2( _grappleReleaseVelocity.x, 0f );
			_shockwaveVel = new Vector2( _shockwaveVel.x, 0f );
		}
		// _shockwaveStartMag is deliberately left as-is (matching the stasis damp): consuming part of
		// the channel must not RE-ARM the bounce gate. Resetting the reference to the smaller remainder
		// would make an already-decayed knockback read as 100% fresh, so firing a shot mid-flight could
		// revive wall ricochets the decay had already disqualified.
		if ( _shockwaveVel.Length <= SHOCKWAVE_STOP_THRESHOLD )
		{
			_shockwaveVel = Vector2.Zero;
			_shockwaveStartMag = 0f;
		}

		// With the owned channels folded in, the axis total is just base + environmental; cancel the
		// opposing part (per counterMomentumFactor) and stack the impulse on top.
		Velocity += GetGunImpulse( Velocity, dir, strength, counterMomentumFactor );
	}
}