Entities/Abilities/DirectionalDoubleTapGesture.cs
using System;
namespace BlockParty;
/// <summary>Recognizes press-release-press cardinal and diagonal chords, including held perpendicular modifiers.</summary>
internal sealed class DirectionalDoubleTapGesture
{
private const float DEFAULT_TAP_RELEASE_TIME = 0.1f;
private const float DEFAULT_SECOND_TAP_TIME = 0.07f;
private const float DEFAULT_DIAGONAL_GRACE_TIME = 0.1f;
private float _tapReleaseTime;
private float _secondTapTime;
private float _diagonalGraceTime;
private Vector2 _tapDirection;
private Vector2 _pendingSecondDirection;
// First-tap direction abandoned when the second press was perpendicular-only (tap Right, press Up).
// Kept for one grace window so the repeat key arriving a frame late (Right) still completes the
// up-right diagonal instead of the gesture being lost.
private Vector2 _lateFirstDirection;
private float _lateTimer;
private int _tapTriggerMask;
private int _tapPhase;
private float _tapTimer;
private float _gapTimer;
public DirectionalDoubleTapGesture(
float tapReleaseTime = DEFAULT_TAP_RELEASE_TIME,
float secondTapTime = DEFAULT_SECOND_TAP_TIME,
float diagonalGraceTime = DEFAULT_DIAGONAL_GRACE_TIME )
{
Configure( tapReleaseTime, secondTapTime, diagonalGraceTime );
}
/// <summary>A <paramref name="diagonalGraceTime"/> of 0 means "never wait": a second tap fires on the
/// press, using whatever keys are down at that instant. Anything above 0 holds a CARDINAL second tap
/// for that long first, so a perpendicular key arriving a frame or two late still folds into the same
/// chord — the difference between an 8-way gesture you can actually hit and one that needs both keys
/// on the same frame. Costs that much input latency on every cardinal trigger, so only abilities that
/// really want diagonals should pay it.</summary>
public void Configure( float tapReleaseTime, float secondTapTime, float diagonalGraceTime = DEFAULT_DIAGONAL_GRACE_TIME )
{
_tapReleaseTime = tapReleaseTime;
_secondTapTime = secondTapTime;
_diagonalGraceTime = diagonalGraceTime;
}
public bool TryTrigger( Player player, float dt, out Vector2 direction,
bool allowHorizontal = true, bool allowUp = true, bool allowDown = true )
{
IPlayerInput input = player.InputSource;
direction = Vector2.Zero;
int heldMask = ReadHeldMask( input, allowHorizontal, allowUp, allowDown );
int justMask = player.ShouldIgnoreWallJumpDoubleTapPress
? 0
: ReadJustMask( input, allowHorizontal, allowUp, allowDown );
Vector2 chordDirection = DirectionFromMasks( heldMask, justMask );
if ( _tapPhase == 3 )
{
_gapTimer += dt;
if ( chordDirection.LengthSquared > 0f )
{
Vector2 rolled = AddRolledDirection( _pendingSecondDirection, chordDirection );
if ( IsCompatibleSecondTap( _tapDirection, rolled ) )
_pendingSecondDirection = rolled;
}
if ( IsDiagonal( _pendingSecondDirection ) || _gapTimer >= _diagonalGraceTime )
{
direction = _pendingSecondDirection;
Reset();
return true;
}
return false;
}
if ( _tapPhase == 1 )
{
_tapTimer += dt;
if ( _tapTimer > _tapReleaseTime )
Reset();
}
else if ( _tapPhase == 2 )
{
_gapTimer += dt;
if ( _gapTimer > _secondTapTime )
Reset();
}
if ( _lateFirstDirection.LengthSquared > 0f )
{
_lateTimer += dt;
if ( _lateTimer >= _diagonalGraceTime )
_lateFirstDirection = Vector2.Zero;
}
if ( justMask != 0 && chordDirection.LengthSquared > 0f )
{
Vector2 abandonedFirst = Vector2.Zero;
if ( _tapPhase == 2 && IsCompatibleSecondTap( _tapDirection, chordDirection ) )
{
// KNOWN: _tapDirection includes held modifiers, so hold Up, tap Right, release both, re-press Up
// (all within ~170ms) reads as a repeat and fires Up. Deliberately unfixed: a proper fix needs a
// parallel tapped-only direction (the trigger mask alone cancels on opposing presses).
if ( IsRepeatOfFirstTap( _tapDirection, chordDirection ) )
{
_pendingSecondDirection = chordDirection;
if ( IsDiagonal( chordDirection ) || _diagonalGraceTime <= 0f )
{
direction = chordDirection;
Reset();
return true;
}
_tapPhase = 3;
_gapTimer = 0f;
return false;
}
// Perpendicular-only press (tap Right, press Up) is a direction change, not a double tap:
// restart as a fresh first tap in the new direction, remembering the old one for a late repeat key.
abandonedFirst = _tapDirection;
}
if ( _tapPhase == 1 )
{
_tapTriggerMask |= justMask;
_tapDirection = AddRolledDirection( _tapDirection, chordDirection );
if ( _lateFirstDirection.LengthSquared > 0f && IsDiagonal( _tapDirection )
&& IsCompatibleSecondTap( _lateFirstDirection, _tapDirection )
&& IsRepeatOfFirstTap( _lateFirstDirection, _tapDirection ) )
{
direction = _tapDirection;
Reset();
return true;
}
}
else
{
_tapDirection = chordDirection;
_tapTriggerMask = justMask;
_tapPhase = 1;
_tapTimer = 0f;
_gapTimer = 0f;
_lateFirstDirection = abandonedFirst;
_lateTimer = 0f;
}
}
if ( _tapPhase == 1 && (_tapTriggerMask & heldMask) == 0 )
{
_tapPhase = 2;
_gapTimer = 0f;
}
return false;
}
public void Reset()
{
_tapDirection = Vector2.Zero;
_pendingSecondDirection = Vector2.Zero;
_lateFirstDirection = Vector2.Zero;
_lateTimer = 0f;
_tapTriggerMask = 0;
_tapPhase = 0;
_tapTimer = 0f;
_gapTimer = 0f;
}
private static int ReadHeldMask( IPlayerInput input, bool allowHorizontal, bool allowUp, bool allowDown )
{
return (allowHorizontal && input.Left ? 1 << 0 : 0)
| (allowHorizontal && input.Right ? 1 << 1 : 0)
| (allowUp && input.Up ? 1 << 2 : 0)
| (allowDown && input.Down ? 1 << 3 : 0);
}
private static int ReadJustMask( IPlayerInput input, bool allowHorizontal, bool allowUp, bool allowDown )
{
return (allowHorizontal && input.LeftJust ? 1 << 0 : 0)
| (allowHorizontal && input.RightJust ? 1 << 1 : 0)
| (allowUp && input.UpJust ? 1 << 2 : 0)
| (allowDown && input.DownJust ? 1 << 3 : 0);
}
private static Vector2 DirectionFromMasks( int heldMask, int justMask )
{
float x = ResolveAxis( heldMask, justMask, 1 << 1, 1 << 0 );
float y = ResolveAxis( heldMask, justMask, 1 << 2, 1 << 3 );
Vector2 direction = new( x, y );
return direction.LengthSquared > 0f ? Utils.Normalized( direction ) : Vector2.Zero;
}
/// <summary>Resolves one axis to -1/0/+1. A FRESH PRESS wins its axis outright, even against the
/// opposite key still being held — otherwise double-tapping away from the direction you're currently
/// running (tap left while right is down) cancels to zero and the gesture never starts at all. Only
/// when neither side was just pressed do the held bits decide, and opposing held bits cancel.</summary>
private static float ResolveAxis( int heldMask, int justMask, int positiveBit, int negativeBit )
{
bool justPositive = (justMask & positiveBit) != 0;
bool justNegative = (justMask & negativeBit) != 0;
if ( justPositive != justNegative ) return justPositive ? 1f : -1f;
bool heldPositive = (heldMask & positiveBit) != 0;
bool heldNegative = (heldMask & negativeBit) != 0;
if ( heldPositive != heldNegative ) return heldPositive ? 1f : -1f;
return 0f;
}
/// <summary>No axis opposes the first tap. Perpendicular components are allowed (they're the diagonal modifiers).</summary>
private static bool IsCompatibleSecondTap( Vector2 first, Vector2 second )
{
if ( first.x != 0f && second.x != 0f && MathF.Sign( first.x ) != MathF.Sign( second.x ) ) return false;
if ( first.y != 0f && second.y != 0f && MathF.Sign( first.y ) != MathF.Sign( second.y ) ) return false;
return true;
}
/// <summary>The second press repeats at least one direction of the first tap. Without this, tap Right then
/// tap Up counts as a double tap of Up (nothing opposes), so quick direction changes fire abilities.</summary>
private static bool IsRepeatOfFirstTap( Vector2 first, Vector2 second )
{
if ( first.x != 0f && second.x != 0f && MathF.Sign( first.x ) == MathF.Sign( second.x ) ) return true;
if ( first.y != 0f && second.y != 0f && MathF.Sign( first.y ) == MathF.Sign( second.y ) ) return true;
return false;
}
private static Vector2 AddRolledDirection( Vector2 pending, Vector2 held )
{
float x = pending.x;
float y = pending.y;
if ( held.x != 0f && (x == 0f || MathF.Sign( x ) == MathF.Sign( held.x )) ) x = held.x;
if ( held.y != 0f && (y == 0f || MathF.Sign( y ) == MathF.Sign( held.y )) ) y = held.y;
return Utils.Normalized( new Vector2( x, y ) );
}
private static bool IsDiagonal( Vector2 direction ) => direction.x != 0f && direction.y != 0f;
}