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