Entities/Abilities/TwinDashAbility.cs
namespace BlockParty;
/// <summary>Twin1's fixed-distance, committed directional dash.</summary>
public sealed class TwinDashAbility : PlayerAbility
{
private const float TAP_RELEASE_TIME = 0.1f;
private const float SECOND_TAP_TIME = 0.07f;
private const float DIAGONAL_GRACE_TIME = 0.05f;
private const float DASH_DISTANCE = 72f;
private const float DASH_DURATION = 0.9f;
private const float COOLDOWN_TIME = 6f;
private const float DASH_SHAKE_STRENGTH = 5f;
private const float DASH_HAPTIC_STRENGTH = 0.28f;
private static readonly Color COOLDOWN_EYE_TINT = new( 0.86f, 0.18f, 0.18f, 0.82f );
// Short diagonal grace: a cardinal second press waits up to ~3 frames for a perpendicular key so
// diagonals don't need both keys on the same tick. Kept well under the Blinker's 0.1s because this
// committed, fixed-distance move feels the latency on every cardinal dash.
private readonly DirectionalDoubleTapGesture _dashGesture = new( TAP_RELEASE_TIME, SECOND_TAP_TIME, DIAGONAL_GRACE_TIME );
private bool _active;
private bool _movementBlocked;
private float _dashTimer;
private float _distanceRemaining;
private float _cooldownTimer;
private Vector2 _dashDirection;
private TwinAbilityEyeTint _eyeTint;
public override void OnSpawn( Player player )
{
var tintObject = new GameObject( true, "twin dash cooldown eye tint" );
tintObject.SetParent( player.GameObject );
tintObject.LocalPosition = Vector3.Zero;
_eyeTint = tintObject.Components.Create<TwinAbilityEyeTint>();
_eyeTint.Setup( player, adjustTwinOneWalk: true );
}
/// <summary>A mid-run character change (e.g. a Mimic form timing out) can remove the module while a
/// dash is still flying through solids, so the removal must resolve the landing like a natural dash
/// end — otherwise the player is silently left embedded in whatever the dash was inside. This runs
/// BEFORE the new form's rules install, deliberately: a Mimic whose Twin1 form expires inside a block
/// dies here under Twin1's crush rules. Squashing instead would strand it forever inside a block
/// that never moves, ruining the run.</summary>
public override void OnRemoved( Player player )
{
bool wasDashing = _active;
CancelDash( player );
if ( wasDashing ) player.FinishTwinDash();
_eyeTint?.GameObject?.Destroy();
_eyeTint = null;
}
public override void OnDeath( Player player )
{
CancelDash( player );
if ( _eyeTint is not null ) _eyeTint.Tint = Color.Transparent;
}
public override void OnAnimationChanged( Player player, string animation )
{
_eyeTint?.SetAnimation( animation );
}
public override void PreTick( Player player, float dt )
{
if ( _active )
{
player.ConsumeDirectionalInputForTick();
player.ClearMotion();
_dashTimer += dt;
ApplyDashShake( player );
// Held for the flight and biased the way we're travelling. Asserted per dash tick, so a
// cancel, a death or a mid-dash form change all end it without a matching stop call.
if ( player.DrivesHaptics )
Haptics.Sustain( HapticChannel.TwinDash, DASH_HAPTIC_STRENGTH, _dashDirection.x * 0.5f );
if ( !_movementBlocked && _distanceRemaining > 0.001f )
{
float distance = Math.Min( _distanceRemaining, DASH_DISTANCE / DASH_DURATION * dt );
bool moved = player.MoveByTwinDash( _dashDirection * distance );
if ( moved ) _distanceRemaining -= distance;
else _movementBlocked = true;
}
if ( _dashTimer >= DASH_DURATION ) EndDash( player );
return;
}
if ( _cooldownTimer > 0f )
{
if ( _eyeTint is not null ) _eyeTint.Tint = COOLDOWN_EYE_TINT;
ResetGesture();
_cooldownTimer -= dt;
if ( _cooldownTimer <= 0f )
{
_cooldownTimer = 0f;
if ( _eyeTint is not null ) _eyeTint.Tint = Color.Transparent;
PlayReadyEffects( player );
}
return;
}
if ( _dashGesture.TryTrigger( player, dt, out Vector2 direction )
&& player.CanStartTwinDash( direction ) )
StartDash( player, direction );
}
private void StartDash( Player player, Vector2 direction )
{
_active = true;
_movementBlocked = false;
_dashTimer = 0f;
_distanceRemaining = DASH_DISTANCE;
_dashDirection = Utils.Normalized( direction );
player.SetTwinDashing( true );
player.ConsumeDirectionalInputForTick();
ApplyDashShake( player );
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.42f, 1.45f );
SpawnTransitionParticles( player, starting: true );
}
private void EndDash( Player player )
{
_active = false;
_movementBlocked = false;
_dashTimer = 0f;
_distanceRemaining = 0f;
_cooldownTimer = COOLDOWN_TIME;
if ( _eyeTint is not null ) _eyeTint.Tint = COOLDOWN_EYE_TINT;
player.ClearShakeOffset();
player.FinishTwinDash();
if ( player.IsDead ) return;
player.StartScaleBounce();
SpawnTransitionParticles( player, starting: false );
}
private void CancelDash( Player player )
{
_active = false;
_movementBlocked = false;
_dashTimer = 0f;
_distanceRemaining = 0f;
player.ClearShakeOffset();
player.SetTwinDashing( false );
}
private void ApplyDashShake( Player player )
{
int frame = (int)MathF.Floor( _dashTimer * 60f ) & 3;
Vector2 offset = frame switch
{
0 => new Vector2( 0.18f, 0.06f ),
1 => new Vector2( -0.14f, -0.08f ),
2 => new Vector2( 0.10f, -0.14f ),
_ => new Vector2( -0.16f, 0.10f ),
};
player.SetShakeOffset( offset * DASH_SHAKE_STRENGTH );
}
private void ResetGesture()
{
_dashGesture.Reset();
}
private static void PlayReadyEffects( Player player )
{
Audio.PlaySfx( SfxType.TurnVisible, player.Position, 0.25f, 1.55f );
player.StartScaleBounce();
for ( int i = 0; i < 8; i++ )
{
float angle = MathF.PI * 2f * i / 8f;
Vector2 direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
ParticleKind kind = (i & 1) == 0 ? ParticleKind.RewindBurst0 : ParticleKind.RewindBurst1;
player.Stage.AddParticle( player.Position + direction * 3f, direction * 30f, 0.88f, 0f,
kind, 0.3f, 4 );
}
}
private static void SpawnTransitionParticles( Player player, bool starting )
{
const int count = 8;
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * (i + (starting ? 0.5f : 0f)) / count;
Vector2 direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
ParticleKind kind = starting
? ((i & 1) == 0 ? ParticleKind.SwapBurst0 : ParticleKind.SwapBurst1)
: ((i & 1) == 0 ? ParticleKind.MirrorBurst0 : ParticleKind.MirrorBurst1);
float speed = starting ? 24f : 38f;
player.Stage.AddParticle( player.Position + direction * 2f, direction * speed, 0.9f, 0f,
kind, starting ? 0.22f : 0.28f, starting ? 2 : 3 );
}
}
}