Entities/Abilities/BlinkerAbility.cs
using System;
namespace BlockParty;
/// <summary>Directional double-tap blink with a brief aim-and-charge phase and floor recharge.</summary>
public sealed class BlinkerAbility : PlayerAbility
{
private const float TAP_RELEASE_TIME = 0.1f;
private const float SECOND_TAP_TIME = 0.09f;
private const float DIAGONAL_GRACE_TIME = 0.1f;
private const float PREPARE_TIME = 1.2f;
private const float MIN_DISTANCE = 5f;
private const float MAX_DISTANCE = 140f;
private const float PREPARE_MOTION_FACTOR = 0.18f;
private const float PREPARE_DAMPING = 5f;
// Flat low buzz for the whole wind-up: the prepare is a held, unresolved state, so it reads as
// pressure rather than as progress (the shake below carries the ramp visually).
private const float PREPARE_HAPTIC_STRENGTH = 0.13f;
private const float PREPARE_SHAKE_START_STRENGTH = 2f;
private const float PREPARE_SHAKE_FINISH_STRENGTH = 4f;
private const int AIM_CHORD_FRAMES = 2;
private const float PORTAL_LIFETIME = 0.28f;
private const float PORTAL_HIGHLIGHT_LIFETIME = 0.18f;
private const int PORTAL_SIZE = 20;
private const int PORTAL_HIGHLIGHT_SIZE = 11;
private const int ARRIVAL_HITSTOP_FRAMES = 7;
private const float INDICATOR_HORIZONTAL_OFFSET = 5.5f;
private const float INDICATOR_HEIGHT_FROM_FEET = 10f;
private const float INDICATOR_FOLLOW_SPEED = 24f;
private const float INDICATOR_FADE_TIME = 0.14f;
private const float INDICATOR_RENDER_EXTENT = 1.5f;
private static readonly Color INDICATOR_COLOR = new( 0.82f, 0.22f, 1f );
/// <summary>Fixed speed applied in the secondary input direction after arriving. Goes out through
/// Player.ApplyLaunchVelocity, so this is a true speed in every direction — not capped down to the
/// walk speed with the remainder shunted into a channel steering can't reach.</summary>
public float PostBlinkForce { get; set; } = 180f;
private readonly DirectionalDoubleTapGesture _blinkGesture = new(
TAP_RELEASE_TIME, SECOND_TAP_TIME, DIAGONAL_GRACE_TIME );
private bool _charged = true;
private bool _preparing;
private float _prepareTimer;
private Vector2 _blinkDirection;
private bool _aimArmed;
private int _aimChordFrames;
private Vector2 _pendingForceDirection;
private bool _blinkPending;
private Vector2 _pendingBlinkDirection;
private Vector2 _pendingBlinkForceDirection;
private float _pendingBlinkDistance;
private GameObject _indicatorRoot;
private Entity2D _indicatorAnchor;
private SpriteRenderer _indicator;
private readonly SpriteRenderer[] _indicatorOutline = new SpriteRenderer[4];
private Vector2 _indicatorPosition;
private float _indicatorFade = 1f;
private float _indicatorTime;
public override void OnSpawn( Player player )
{
_indicatorRoot = player.Stage.CreateChild( "BlinkReadyIndicator" );
_indicatorAnchor = _indicatorRoot.Components.Create<Entity2D>();
_indicator = SpriteLayer.Add( _indicatorRoot, "sprites/pixel.sprite", Vector2.One, "idle" );
_indicator.Opaque = false;
_indicator.AlphaCutoff = 0f;
_indicator.Color = INDICATOR_COLOR;
_indicatorOutline[0] = CreateIndicatorOutlinePixel( new Vector2( -1f, 0f ) );
_indicatorOutline[1] = CreateIndicatorOutlinePixel( new Vector2( 1f, 0f ) );
_indicatorOutline[2] = CreateIndicatorOutlinePixel( new Vector2( 0f, -1f ) );
_indicatorOutline[3] = CreateIndicatorOutlinePixel( new Vector2( 0f, 1f ) );
_indicatorPosition = GetIndicatorTarget( player );
UpdateIndicatorTransform();
}
public override void OnRemoved( Player player )
{
_preparing = false;
_blinkPending = false;
player.ClearShakeOffset();
_indicatorRoot?.Destroy();
_indicatorRoot = null;
_indicatorAnchor = null;
_indicator = null;
for ( int i = 0; i < _indicatorOutline.Length; i++ )
_indicatorOutline[i] = null;
}
public override void OnDeath( Player player )
{
SetIndicatorEnabled( false );
}
public override void OnReplayPresentationRebuilt( Player player )
{
_indicatorPosition = GetIndicatorTarget( player );
_indicatorFade = _charged ? 1f : 0f;
SetIndicatorEnabled( _charged && !player.IsDead );
UpdateIndicatorTransform();
}
public override void PreTick( Player player, float dt )
{
if ( _preparing )
{
TickPrepare( player, dt );
return;
}
if ( player.IsTouchingFloorNow() && !_charged )
{
_charged = true;
ShowIndicator( player );
}
TickTapGesture( player, dt );
}
public override void PostTick( Player player, float dt )
{
bool blinkedThisTick = _blinkPending;
if ( _blinkPending )
{
_blinkPending = false;
Vector2 start = player.Position;
Vector2 destination = start + _pendingBlinkDirection * _pendingBlinkDistance;
player.ClearMotion();
player.TeleportTo( destination.x, destination.y );
SpawnEndpointBurst( player.Stage, start, false );
SpawnEndpointBurst( player.Stage, destination, true );
SpawnDestinationPortal( player.Stage, destination );
GhostRecorder.Emit( player, GhostFxKind.Blink, start, destination );
player.Stage.RequestHitStop( ARRIVAL_HITSTOP_FRAMES );
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.9f, 1.35f );
// Arrival jolt, scaled by how far the blink actually threw us (a barely-charged hop is a tap,
// a full-length one is a slam) and biased the way we went.
if ( player.DrivesHaptics )
{
float reach = Utils.Map( _pendingBlinkDistance, MIN_DISTANCE, MAX_DISTANCE, 0f, 1f, true, EasingType.Linear );
Haptics.Pulse(
Utils.Map( reach, 0f, 1f, 0.35f, 1f, true, EasingType.Linear ),
Utils.Map( reach, 0f, 1f, 0.1f, 0.2f, true, EasingType.Linear ),
_pendingBlinkDirection.x * 0.5f, Haptics.TONE_HEAVY );
}
player.ResolveBlinkDestination();
if ( !player.IsDead )
{
// Applied AFTER the resolve so the launch is the last word on speed — but ADDED to what
// the resolve left, not substituted for it. Materializing partway inside a solid earns a
// depth-scaled shove back out; wiping that turned a bad arrival into a limp one and cost
// the ejection its whole point. The Swapper never lost this because it launches BEFORE
// its teleport, so its eject stacks on top for free.
player.ApplyLaunchVelocity( _pendingBlinkForceDirection * PostBlinkForce,
keepExistingMotion: true );
// Rebuild contact flags at the arrival point (like Rewind/TwinDash) so the first
// post-blink tick doesn't reuse contacts captured at the departure spot.
player.RefreshContactsAfterTeleport();
}
// The indicator only FOLLOWS the player (exponential lerp), and the arrival hit-stop freezes
// the sim before it can catch up — so a recharge on landing/pressing at the destination would
// gather its motes back at the departure spot and fly them across the arena. Snap it here, at
// the resolved arrival position, for the same reason the contacts above are rebuilt.
_indicatorPosition = GetIndicatorTarget( player );
UpdateIndicatorTransform();
}
// Pressing a block side recharges the blink, like touching the floor does. Same exclusions as
// that recharge (which PreTick's early-return keeps off the wind-up ticks): a press landed
// while PREPARING — or on the very tick the charge is spent, since the prepare's damped drift
// and any block driving into the planted player still press faces — would refund the blink
// before it even finishes, and a row of unpressed faces would then chain blinks indefinitely.
if ( player.PressedBlockSideThisTick && !_charged && !_preparing && !blinkedThisTick && !player.IsDead )
{
_charged = true;
ShowIndicator( player );
}
UpdateIndicator( player, dt );
}
private void TickTapGesture( Player player, float dt )
{
if ( _blinkGesture.TryTrigger( player, dt, out Vector2 direction ) )
{
if ( _charged )
BeginPrepare( player, direction );
else
Audio.PlaySfx( SfxType.Error, player.Position, 0.45f, 1.2f );
}
}
private void BeginPrepare( Player player, Vector2 blinkDirection )
{
_charged = false;
HideIndicator( player );
_preparing = true;
_prepareTimer = 0f;
_blinkDirection = blinkDirection;
_aimArmed = false;
_aimChordFrames = 0;
_pendingForceDirection = Vector2.Zero;
player.ConsumeDirectionalInputForTick();
ApplyPrepareEffect( player, Arena.STEP );
SpawnPrepareMotes( player.Stage, player.Position );
GhostRecorder.Emit( player, GhostFxKind.BlinkPrepare, player.Position );
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.35f, 1.65f );
}
private void TickPrepare( Player player, float dt )
{
var input = player.InputSource;
_prepareTimer = Math.Min( PREPARE_TIME, _prepareTimer + dt );
player.ConsumeDirectionalInputForTick();
ApplyPrepareEffect( player, dt );
bool anyHeld = input.Left || input.Right || input.Up || input.Down;
if ( !_aimArmed )
{
_aimArmed = !anyHeld;
}
else
{
bool anyJust = input.LeftJust || input.RightJust || input.UpJust || input.DownJust;
if ( anyJust )
{
Vector2 direction = ReadInputDirection( input );
if ( direction.Length > 0f ) _pendingForceDirection = direction;
_aimChordFrames = AIM_CHORD_FRAMES;
}
else if ( _aimChordFrames > 0 )
{
Vector2 direction = ReadInputDirection( input );
if ( direction.Length > 0f ) _pendingForceDirection = direction;
_aimChordFrames--;
if ( _aimChordFrames == 0 && _pendingForceDirection.Length > 0f )
{
FireBlink( player, _pendingForceDirection );
return;
}
}
}
// A secondary input tapped in the final ticks may still be mid-chord when the timer expires, so
// _pendingForceDirection wins outright. Failing that, fall back to whatever is simply HELD.
//
// The press that picks the launch direction is also the press that ENDS the wind-up (charge =
// _prepareTimer / PREPARE_TIME), so a full-distance blink used to mean pressing nothing at all —
// which fired with a zero force direction, a dead stop. The most natural input there is,
// "double-tap right and keep holding right", produced exactly that: never releasing means the
// aim never arms, so the branch above never runs and nothing was ever recorded.
//
// Reading the held keys here covers that: a direction held straight through the wind-up now
// pre-commits its launch and still charges to full. It does NOT decouple the general case — a key
// pressed after the aim arms still trips the chord and fires early, so "release, then hold left"
// remains a short blink. Separating those two needs a tap-vs-hold discriminator, which is a
// bigger change than this fallback.
//
// This can't short-circuit the _aimArmed gate: that gate stops the double-tap's own still-held
// keys from reading as an aim and firing at charge ~0, and this only runs once the timer is
// already spent (charge == 1).
if ( _prepareTimer >= PREPARE_TIME )
{
FireBlink( player, _pendingForceDirection.Length > 0f
? _pendingForceDirection
: ReadInputDirection( input ) );
}
}
private void ApplyPrepareEffect( Player player, float dt )
{
int frame = (int)MathF.Floor( _prepareTimer * 60f ) & 3;
Vector2 shake = frame switch
{
0 => new Vector2( 0.35f, 0.10f ),
1 => new Vector2( -0.30f, -0.15f ),
2 => new Vector2( 0.20f, -0.30f ),
_ => new Vector2( -0.15f, 0.30f ),
};
float progress = Math.Clamp( _prepareTimer / PREPARE_TIME, 0f, 1f );
shake *= Utils.Map( progress, 0f, 1f, PREPARE_SHAKE_START_STRENGTH, PREPARE_SHAKE_FINISH_STRENGTH, true, EasingType.Linear );
player.SetBlinkPrepareEffect( PREPARE_MOTION_FACTOR, MathF.Exp( -PREPARE_DAMPING * dt ), shake );
// Asserted from the shared prepare-effect step so it covers the wind-up's opening tick too, and
// so any exit from the prepare (fire, death, form change) simply stops feeding it.
if ( player.DrivesHaptics )
Haptics.Sustain( HapticChannel.BlinkPrepare, PREPARE_HAPTIC_STRENGTH, 0f, Haptics.TONE_CRISP );
}
private void FireBlink( Player player, Vector2 forceDirection )
{
float charge = Math.Clamp( _prepareTimer / PREPARE_TIME, 0f, 1f );
_preparing = false;
// Cut the wind-up buzz now rather than letting it go stale, so it doesn't bleed into the blink
// impulse PostTick fires a moment later.
Haptics.ReleaseSustain( HapticChannel.BlinkPrepare );
player.ClearShakeOffset();
_blinkPending = true;
_pendingBlinkDirection = Utils.Normalized( _blinkDirection );
_pendingBlinkForceDirection = Utils.Normalized( forceDirection );
_pendingBlinkDistance = Utils.Map( charge, 0f, 1f, MIN_DISTANCE, MAX_DISTANCE, true, EasingType.Linear );
}
private void UpdateIndicator( Player player, float dt )
{
_indicatorTime += dt;
Vector2 target = GetIndicatorTarget( player );
float follow = 1f - MathF.Exp( -INDICATOR_FOLLOW_SPEED * dt );
_indicatorPosition = Vector2.Lerp( _indicatorPosition, target, follow );
if ( _charged )
_indicatorFade = Math.Min( 1f, _indicatorFade + dt / INDICATOR_FADE_TIME );
UpdateIndicatorTransform();
if ( _indicator is null || !_indicator.Enabled ) return;
float centerPulse = 0.5f + 0.5f * MathF.Sin( _indicatorTime * 9f );
Color centerColor = Color.Lerp(
new Color( 0.68f, 0.04f, 1f ),
new Color( 1f, 0.38f, 1f ), centerPulse );
_indicator.Color = centerColor.WithAlpha( (0.7f + 0.29f * centerPulse) * _indicatorFade );
float outlinePulse = 0.5f + 0.5f * MathF.Sin( _indicatorTime * 7.2f + 0.8f );
Color outlineColor = Color.Lerp( Color.Black, centerColor, outlinePulse )
.WithAlpha( (1f - 0.12f * outlinePulse) * _indicatorFade );
for ( int i = 0; i < _indicatorOutline.Length; i++ )
if ( _indicatorOutline[i] is not null ) _indicatorOutline[i].Color = outlineColor;
}
private Vector2 GetIndicatorTarget( Player player )
{
float facingSign = player.BodySpriteRenderer?.FlipHorizontal == true ? -1f : 1f;
float gravitySign = player.GravityReversed ? -1f : 1f;
float feetY = player.Y - gravitySign * player.Height * 0.5f;
float wobbleX = MathF.Sin( _indicatorTime * (MathF.PI * 2f / 0.83f) ) * 0.8f;
float wobbleY = MathF.Sin( _indicatorTime * (MathF.PI * 2f / 1.17f) + 1.1f ) * 0.8f;
return new Vector2(
player.X - facingSign * INDICATOR_HORIZONTAL_OFFSET + wobbleX,
feetY + gravitySign * INDICATOR_HEIGHT_FROM_FEET + wobbleY );
}
private void UpdateIndicatorTransform()
{
if ( _indicatorRoot is null ) return;
Vector2 center = SpriteLayer.PixelAlignedCenter( _indicatorPosition, Vector2.One );
center = new Vector2(
Math.Clamp( center.x, Arena.WALL_SIZE + INDICATOR_RENDER_EXTENT,
Arena.WIDTH - Arena.WALL_SIZE - INDICATOR_RENDER_EXTENT ),
Math.Clamp( center.y, Arena.WALL_SIZE + INDICATOR_RENDER_EXTENT,
Arena.HEIGHT - Arena.WALL_SIZE - INDICATOR_RENDER_EXTENT ) );
if ( _indicatorAnchor is not null ) _indicatorAnchor.Pos = center;
_indicatorRoot.WorldPosition = new Vector3( center.x, center.y,
Globals.DepthToZ( Globals.DEPTH_ABILITY_INDICATOR ) );
}
private SpriteRenderer CreateIndicatorOutlinePixel( Vector2 offset )
{
SpriteRenderer pixel = SpriteLayer.Add( _indicatorRoot, "sprites/pixel.sprite", Vector2.One, "idle", -1 );
pixel.Opaque = false;
pixel.AlphaCutoff = 0f;
pixel.Color = Color.Black.WithAlpha( 0.8f );
pixel.GameObject.LocalPosition = new Vector3( offset.x, offset.y, -SpriteLayer.LAYER_Z_STEP );
return pixel;
}
private void SetIndicatorEnabled( bool enabled )
{
if ( _indicator is not null ) _indicator.Enabled = enabled;
for ( int i = 0; i < _indicatorOutline.Length; i++ )
if ( _indicatorOutline[i] is not null ) _indicatorOutline[i].Enabled = enabled;
}
private void HideIndicator( Player player )
{
SpawnIndicatorDisperse( player, _indicatorAnchor?.Position ?? _indicatorPosition );
_indicatorFade = 0f;
SetIndicatorEnabled( false );
}
private void ShowIndicator( Player player )
{
_indicatorFade = 0f;
SetIndicatorEnabled( true );
SpawnIndicatorGather( player, _indicatorAnchor?.Position ?? _indicatorPosition, _indicatorAnchor );
}
private static void SpawnIndicatorDisperse( Player player, Vector2 position )
{
int count = Rng.CosmeticInt( 8, 12 );
int requiredBlack = Rng.CosmeticInt( 0, count );
int requiredColor = (requiredBlack + Rng.CosmeticInt( 1, count )) % count;
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * i / count
+ Rng.CosmeticFloat( -0.18f, 0.18f );
Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
bool black = i == requiredBlack || (i != requiredColor && Rng.CosmeticValue() < 0.28f);
Color color = IndicatorTransitionColor( black, 0.72f, 1f );
Particle particle = player.Stage.AddColoredParticle( position,
direction * Rng.CosmeticFloat( 55f, 95f ),
0.88f, color, Rng.CosmeticFloat( 0.1f, 0.17f ), 1,
Globals.DEPTH_ABILITY_INDICATOR, translucent: true );
particle.Depth = Globals.DEPTH_ABILITY_INDICATOR;
particle.ClampToArenaBounds();
}
}
private static void SpawnIndicatorGather( Player player, Vector2 position, Entity2D indicatorAnchor )
{
int count = Rng.CosmeticInt( 8, 12 );
int requiredBlack = Rng.CosmeticInt( 0, count );
int requiredColor = (requiredBlack + Rng.CosmeticInt( 1, count )) % count;
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * (i + 0.5f) / count
+ Rng.CosmeticFloat( -0.18f, 0.18f );
Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
float radius = Rng.CosmeticFloat( 5f, 8f );
bool black = i == requiredBlack || (i != requiredColor && Rng.CosmeticValue() < 0.28f);
Color color = IndicatorTransitionColor( black, 0.65f, 0.95f );
TargetedParticle particle = player.Stage.AddColoredTargetedParticle(
position + direction * radius, Vector2.Zero, 1f, color,
Rng.CosmeticFloat( 0.1f, 0.16f ), 1, Globals.DEPTH_ABILITY_INDICATOR,
Vector2.Zero, 0f, indicatorAnchor, translucent: true );
particle.Depth = Globals.DEPTH_ABILITY_INDICATOR;
particle.ClampToArenaBounds();
particle.LerpToTarget( Rng.CosmeticFloat( 26f, 34f ) );
}
}
private static Color IndicatorTransitionColor( bool black, float minAlpha, float maxAlpha )
{
if ( black ) return Color.Black;
float shade = Rng.CosmeticValue();
Color color = Rng.CosmeticInt( 0, 3 ) switch
{
0 => Color.Lerp( new Color( 0.62f, 0.02f, 1f ), new Color( 0.9f, 0.16f, 1f ), shade ),
1 => Color.Lerp( new Color( 0.9f, 0.1f, 0.8f ), new Color( 1f, 0.42f, 1f ), shade ),
_ => Color.Lerp( new Color( 0.36f, 0.12f, 1f ), new Color( 0.7f, 0.35f, 1f ), shade ),
};
return color.WithAlpha( Rng.CosmeticFloat( minAlpha, maxAlpha ) );
}
private static Vector2 ReadInputDirection( IPlayerInput input )
{
// OR in the latched edges alongside held state: a press+release inside one sim step (a fast
// flick, or any tap made during a hit-stop freeze — frozen steps preserve edges but not held
// state) reads Just with nothing held, and would otherwise be dropped. The double-tap
// recognizer already honors these sub-step taps (justPressed && !held); the aim must too.
// On chord-countdown ticks the edges are always clear (anyJust was false), so this only
// changes the just-pressed tick.
float x = ((input.Right || input.RightJust) ? 1f : 0f)
- ((input.Left || input.LeftJust) ? 1f : 0f);
float y = ((input.Up || input.UpJust) ? 1f : 0f)
- ((input.Down || input.DownJust) ? 1f : 0f);
return new Vector2( x, y );
}
/// <summary>Replay a recorded blink visual for the tutorial ghost: the same particles the live
/// ability spawns, tinted down to the ghost's opacity — no audio, hit-stop or haptics.</summary>
internal static void PlayGhostFx( GameStage stage, GhostFx fx, float alpha )
{
switch ( fx.Kind )
{
case GhostFxKind.BlinkPrepare:
SpawnPrepareMotes( stage, fx.A, alpha );
break;
case GhostFxKind.Blink:
SpawnEndpointBurst( stage, fx.A, false, alpha );
SpawnEndpointBurst( stage, fx.B, true, alpha );
SpawnDestinationPortal( stage, fx.B, alpha );
break;
}
}
private static void Tint( Particle particle, float alpha )
{
if ( alpha < 1f ) particle.FadeTint( alpha );
}
private static void SpawnEndpointBurst( GameStage stage, Vector2 position, bool portalRing, float alpha = 1f )
{
int count = portalRing ? 18 : 10;
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * i / count;
Vector2 radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
Vector2 velocity = radial * Rng.CosmeticFloat( portalRing ? 55f : 30f, portalRing ? 145f : 90f );
ParticleKind kind = (i & 1) == 0 ? ParticleKind.BlinkBurst0 : ParticleKind.BlinkBurst1;
Tint( stage.AddParticle( position, velocity, Rng.CosmeticFloat( 0.90f, 0.95f ), 0f, kind,
Rng.CosmeticFloat( 0.22f, 0.45f ), Rng.CosmeticInt( 2, 6 ) ), alpha );
}
}
private static void SpawnPrepareMotes( GameStage stage, Vector2 center, float alpha = 1f )
{
const int count = 6;
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * (i + 0.5f) / count;
Vector2 radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
Vector2 position = center + radial * Rng.CosmeticFloat( 5f, 8f );
Vector2 velocity = radial * -Rng.CosmeticFloat( 16f, 28f );
ParticleKind kind = i % 3 == 0 ? ParticleKind.BlinkBurst1 : ParticleKind.BlinkBurst0;
Tint( stage.AddParticle( position, velocity, 0.92f, 0f, kind,
Rng.CosmeticFloat( 0.18f, 0.30f ), Rng.CosmeticInt( 2, 4 ) ), alpha );
}
}
private static void SpawnDestinationPortal( GameStage stage, Vector2 destination, float alpha = 1f )
{
Tint( stage.AddPortalParticle( destination, PORTAL_LIFETIME, PORTAL_SIZE, ParticleKind.BlinkBurst0, Globals.DEPTH_PORTAL ), alpha );
Tint( stage.AddPortalParticle( destination, PORTAL_HIGHLIGHT_LIFETIME, PORTAL_HIGHLIGHT_SIZE, ParticleKind.BlinkBurst1, Globals.DEPTH_PORTAL_HIGHLIGHT ), alpha );
}
}