Entities/Abilities/GunnerAbility.cs
using System;
namespace BlockParty;
/// <summary>
/// The Gunner character's kit: a small ammo reserve reloaded by crouching, and a DOUBLE-TAP fire
/// scheme — tap a direction twice quickly (press, release, press: the dash's gesture, with the
/// dash's timing) to fire one round that way. Every key keeps its normal movement meaning (a
/// double-tap Up still jumps, a double-tap Left still steps left — the shot simply fires alongside,
/// with a small recoil kick opposite the shot). The Gunner deliberately has no dash (see
/// Characters), so the horizontal double-tap is unambiguous.
///
/// <list type="bullet">
/// <item><b>Double-tap a cardinal or diagonal chord</b>: fire one round in that WORLD direction. A
/// perpendicular direction may be held as a modifier, pressed simultaneously, or rolled into either
/// chord. With ammo, spend a round + spawn a <see cref="Bullet"/>; empty, a harmless dry-fire spark cough.</item>
/// <item><b>Hold crouch (the toward-floor key, see <see cref="Player.CrouchHeld"/>) on a reload
/// surface, not full</b>: reload one round per <see cref="RELOAD_TIME"/> while moving at half speed.
/// Valid surfaces are the current floor, sticky glue, or a ledge grabbed while crouch was already
/// held and remains held. A hold is never a tap — a tap must RELEASE within the tap window — so
/// crouching can't accidentally fire, and tap-tap-hold fires then reloads.</item>
/// </list>
///
/// Runs in <see cref="PreTick"/> (fixed step, before the player moves). Deterministic / replay-safe:
/// reads only the recorded <see cref="InputState"/> and the player's fixed-step state, advances its
/// timers on the tick <c>dt</c>, and draws only from the cosmetic RNG stream for the sparks. Firing +
/// reloading are pure functions of the recorded input, so a Gunner run reproduces exactly on replay.
/// </summary>
public sealed class GunnerAbility : PlayerAbility
{
public const int MAX_AMMO = 3;
// Hold crouch on a valid surface (not full) this long to gain one round.
private const float RELOAD_TIME = 0.4f;
private const float RELOAD_MOVEMENT_FACTOR = 0.5f;
private const float RELOAD_SHAKE_HORIZONTAL_MAX = 0.18f;
private const float RELOAD_SHAKE_VERTICAL_MIN = 0.8f;
private const float RELOAD_SHAKE_VERTICAL_MAX = 1.1f;
private const int RELOAD_SHAKE_FRAMES = 4;
private const int SHOT_HITSTOP_FRAMES = 7;
private const float AIRBORNE_DOWN_FRICTION_FACTOR = 1.05f;
private const float DRY_FIRE_SHAKE_STRENGTH = 1.1f;
private const int DRY_FIRE_SHAKE_FRAMES = 4;
// How long the shoot pose lingers after a shot. Visual only — a tap-fire is instantaneous, and the
// pose override resets every tick, so without this linger the pose would never be seen.
private const float SHOOT_POSE_TIME = 0.22f;
// How far in front of the player's centre a bullet / its muzzle sparks spawn (clears the body). The
// bullet's first collision step sweeps back from the player's centre, so a solid inside this gap is hit.
private const float MUZZLE_OFFSET = 8f;
// Recoil opposite the shot (live rounds only — a dry fire has nothing to recoil). It consolidates
// motion on the recoil axis into the player's base velocity; the floor-normal component is suppressed
// while grounded so shooting downward cannot pop the player off the floor.
private const float KICKBACK_FORCE = 150f;
// Cancel the full component of total movement opposing recoil before applying the base kick.
private const float KICKBACK_COUNTER_MOMENTUM_FACTOR = 1f;
private int _ammo = MAX_AMMO;
private float _reloadTimer;
private readonly DirectionalDoubleTapGesture _fireGesture = new();
// Lingering shoot-pose state (see SHOOT_POSE_TIME).
private Vector2 _poseDirection;
private float _poseTimer;
private bool _poseEmpty;
/// <summary>Current rounds in reserve (for any future HUD / debug read-out).</summary>
public int Ammo => _ammo;
public override void PreTick( Player player, float dt )
{
if ( player.CrouchHeld && player.IsGunnerAirborne )
player.SetGunnerAirFrictionFactor( AIRBORNE_DOWN_FRICTION_FACTOR );
TickTapGesture( player, dt );
TickReload( player, dt );
// Last, so a fresh shot's pose wins over the reload pose for its brief linger.
TickPose( player, dt );
}
// ----------------------------------------------------------------------------------------
/// <summary>Advance the raw-world-direction chord detector. Newly pressed keys define which parts
/// of the first chord must be released; already-held perpendicular keys remain valid modifiers.
/// A diagonal first chord grants the second chord a short roll window, matching Grappler.</summary>
private void TickTapGesture( Player player, float dt )
{
if ( _fireGesture.TryTrigger( player, dt, out Vector2 direction ) )
Fire( player, direction );
}
// ----------------------------------------------------------------------------------------
/// <summary>Reload while CROUCHING on a valid surface. Releasing crouch, leaving the surface, or
/// reaching full ammo immediately ends reloading and resets progress.</summary>
private void TickReload( Player player, float dt )
{
if ( !player.CrouchHeld || !player.IsGunnerReloadSupported || _ammo >= MAX_AMMO )
{
_reloadTimer = 0f;
return;
}
player.SetGunnerReloadMovementFactor( RELOAD_MOVEMENT_FACTOR );
_reloadTimer += dt;
if ( _reloadTimer < RELOAD_TIME )
{
player.SetGunnerPose( PlayerAnimType.GunReload, Direction.None );
return;
}
_reloadTimer -= RELOAD_TIME;
_ammo = Math.Min( _ammo + 1, MAX_AMMO );
Vector2 reloadShake = new(
Rng.CosmeticFloat( -RELOAD_SHAKE_HORIZONTAL_MAX, RELOAD_SHAKE_HORIZONTAL_MAX ),
Rng.CosmeticFloat( RELOAD_SHAKE_VERTICAL_MIN, RELOAD_SHAKE_VERTICAL_MAX ) );
player.Shake( reloadShake, RELOAD_SHAKE_FRAMES );
// A crisp mechanical "chamber" cue + a small puff of sparks at the player's feet.
Audio.PlaySfx( SfxType.SpikesRetract, player.Position, volume: 1.15f, pitch: Rng.CosmeticFloat( 1.1f, 1.3f ) );
if ( player.DrivesHaptics ) Haptics.Pulse( 0.3f, 0.05f, 0f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
for ( int i = 0; i < 4; i++ )
{
Vector2 vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( 0.2f, 1f ) ) * Rng.CosmeticFloat( 20f, 45f );
player.Stage.AddParticle( new Vector2( player.X, player.Bottom + 1 ), vel, Rng.CosmeticFloat( 0.9f, 0.95f ),
Globals.GRAVITY_STR_DUST, ParticleKind.GunSpark1, Rng.CosmeticFloat( 0.2f, 0.35f ), Rng.CosmeticInt( 2, 4 ) );
}
if ( _ammo >= MAX_AMMO )
{
_reloadTimer = 0f;
player.SetGunnerReloadMovementFactor( 1f );
return;
}
player.SetGunnerPose( PlayerAnimType.GunReload, Direction.None );
}
// ----------------------------------------------------------------------------------------
/// <summary>Keep the shoot pose up for a beat after a tap-fire (visual only; the override is
/// re-set each tick while the linger runs).</summary>
private void TickPose( Player player, float dt )
{
if ( _poseTimer <= 0f ) return;
_poseTimer -= dt;
bool vertical = _poseDirection.x == 0f;
PlayerAnimType pose = _poseEmpty
? (vertical
? (_poseDirection.y > 0f ? PlayerAnimType.GunShootUpEmpty : PlayerAnimType.GunShootDownEmpty)
: PlayerAnimType.GunShootSideEmpty)
: (vertical
? (_poseDirection.y > 0f ? PlayerAnimType.GunShootUp : PlayerAnimType.GunShootDown)
: PlayerAnimType.GunShootSide);
Direction face = _poseDirection.x < 0f ? Direction.Left
: _poseDirection.x > 0f ? Direction.Right
: Direction.None;
player.SetGunnerPose( pose, face );
}
// ----------------------------------------------------------------------------------------
/// <summary>Fire in <paramref name="direction"/>: spend a round + spawn a bullet if any ammo, otherwise a
/// harmless spray of sparks (a dry fire). Either way the shoot pose lingers briefly.</summary>
private void Fire( Player player, Vector2 direction )
{
_poseDirection = direction;
_poseTimer = SHOOT_POSE_TIME;
_poseEmpty = _ammo <= 0;
Vector2 muzzle = player.Pos + direction * MUZZLE_OFFSET;
if ( _ammo > 0 )
{
_ammo--;
player.Stage.AddBullet( muzzle, direction, sweepOrigin: player.Pos );
player.ApplyGunKickback( direction, KICKBACK_FORCE, KICKBACK_COUNTER_MOMENTUM_FACTOR );
Audio.PlaySfx( SfxType.FireballShoot, player.Position, 0.8f );
// The heaviest one-shot in the kit bar death: a live round is the Gunner's whole point, and
// the recoil it applies is real. A dry fire deliberately gets nothing.
if ( player.DrivesHaptics ) Haptics.Pulse( 0.85f, 0.13f, direction.x * 0.5f, Haptics.TONE_HEAVY );
SpawnMuzzle( player, muzzle, direction, live: true );
player.Stage.RequestHitStop( SHOT_HITSTOP_FRAMES );
}
else
{
player.Shake( direction * DRY_FIRE_SHAKE_STRENGTH, DRY_FIRE_SHAKE_FRAMES );
Audio.PlaySfx( SfxType.FireballDissipate, player.Position, 0.5f, 0.8f );
SpawnMuzzle( player, muzzle, direction, live: false );
}
}
/// <summary>Cosmetic muzzle spray in the fire direction — brighter/faster on a live shot, a weak
/// cough on a dry fire. Cosmetic RNG only.</summary>
private static void SpawnMuzzle( Player player, Vector2 muzzle, Vector2 direction, bool live )
{
Vector2 perp = new Vector2( -direction.y, direction.x );
int count = live ? 6 : 3;
float speedMin = live ? 60f : 20f;
float speedMax = live ? 140f : 55f;
for ( int i = 0; i < count; i++ )
{
Vector2 vel = direction * Rng.CosmeticFloat( speedMin, speedMax )
+ perp * Rng.CosmeticFloat( -35f, 35f );
ParticleKind kind = Rng.CosmeticFloat( 0f, 1f ) < 0.5f ? ParticleKind.GunSpark0 : ParticleKind.GunSpark1;
player.Stage.AddParticle( muzzle, vel, Rng.CosmeticFloat( 0.86f, 0.93f ), 0f, kind,
Rng.CosmeticFloat( 0.15f, live ? 0.35f : 0.25f ), Rng.CosmeticInt( 2, live ? 5 : 4 ) );
}
}
}