Entities/Abilities/SwapPortalAbility.cs
using System;
namespace BlockParty;
/// <summary>
/// The Swapper character's signature move. On spawn it drops a persistent <see cref="Portal"/> at the
/// player's start position. Whenever the player presses ALL FOUR directional inputs within a short
/// leniency window (each of Left/Right/Up/Down has its "just pressed" edge set within the last
/// <see cref="SWAP_INPUT_WINDOW_FRAMES"/> ticks — they needn't all land on the exact same frame), the
/// player and the portal trade places: the player teleports to where the portal was, and the portal
/// moves to where the player was.
///
/// Double-tapping a cardinal or diagonal direction fires a slower magical projectile using Gunner's
/// chord rules. Its first block, obstacle, or arena-wall impact moves the portal there, separating it
/// from obstacle and wall faces while leaving any velocity stored for the next swap unchanged.
/// Shooting and swapping share one cooldown. A live-frame blue outline around the player eases in as
/// the cooldown recharges, then becomes fully opaque and pulses once either action is ready again.
///
/// A swap fires a hit-stop whose length scales with how long it's been since the LAST swap — a swap
/// after a long gap gets the full freeze for weight, while rapid back-to-back swaps get little or no
/// freeze so spamming doesn't stutter the game to a crawl.
///
/// After the teleport, the landing is resolved by <see cref="Player.ResolveTeleportPenetration"/>: landing
/// with the hitbox FULLY inside a solid (block or interior obstacle) is an instant death, while a partial
/// overlap ejects the player out the nearest face with a boost proportional to how deep they were — so a
/// precise swap out of a solid's edge is a rewarding launch, but swapping straight into one is fatal, and
/// so is materializing in spikes (a deadly exit face kills instead of launching).
///
/// Deterministic / replay-safe: reads only the recorded <see cref="InputState"/> edges + the player's
/// fixed-step state, advances its timer on the tick <c>dt</c>, and draws only from the cosmetic RNG
/// stream for the endpoint bursts. The portal's position is sim state that changes only on these
/// (recorded) swaps, so a replay reproduces it exactly.
/// </summary>
public sealed class SwapPortalAbility : PlayerAbility
{
/// <summary>Whether swaps exchange motion with the portal instead of clearing it.</summary>
public bool EnableStoredVelocity { get; set; } = false;
/// <summary>Multiplier applied to the velocity bank when it is released by a swap.</summary>
public float StoredVelocityScale { get; set; } = 2.1f;
/// <summary>Whether the portal displays the direction and strength of its stored velocity.</summary>
public bool ShowStoredVelocityPointer { get; set; } = true;
/// <summary>Pointer offset from the portal at the weakest visible stored velocity, in game pixels.</summary>
public float StoredVelocityPointerMinOffset { get; set; } = 1.5f;
/// <summary>Pointer offset from the portal at full stored-velocity magnitude, in game pixels.</summary>
public float StoredVelocityPointerOffset { get; set; } = 4f;
/// <summary>First endpoint of the portal pointer's color pulse.</summary>
public Color StoredVelocityPointerColorA { get; set; } = new( 0.38f, 0.86f, 0.95f );
/// <summary>Second endpoint of the portal pointer's color pulse.</summary>
public Color StoredVelocityPointerColorB { get; set; } = new( 0.28f, 0.62f, 0.9f );
/// <summary>Pointer opacity at the weakest visible stored velocity.</summary>
public float StoredVelocityPointerMinOpacity { get; set; } = 0.2f;
/// <summary>Pointer opacity at full stored-velocity magnitude.</summary>
public float StoredVelocityPointerMaxOpacity { get; set; } = 0.95f;
/// <summary>Exponent shaping how quickly pointer opacity approaches its maximum as velocity grows.</summary>
public float StoredVelocityPointerOpacityPower { get; set; } = 1.8f;
/// <summary>Pointer color-pulse frequency in cycles per second.</summary>
public float StoredVelocityPointerPulseSpeed { get; set; } = 0.45f;
/// <summary>Forward/back pointer travel at the weakest visible stored velocity, in game pixels.</summary>
public float StoredVelocityPointerMinOscillationDistance { get; set; } = 0.25f;
/// <summary>Forward/back pointer travel at full stored-velocity magnitude, in game pixels.</summary>
public float StoredVelocityPointerMaxOscillationDistance { get; set; } = 0.8f;
/// <summary>Pointer oscillation frequency at the weakest visible stored velocity, in cycles per second.</summary>
public float StoredVelocityPointerMinOscillationSpeed { get; set; } = 0.45f;
/// <summary>Pointer oscillation frequency at full stored-velocity magnitude, in cycles per second.</summary>
public float StoredVelocityPointerMaxOscillationSpeed { get; set; } = 0.9f;
/// <summary>Projectile travel speed in game pixels per second.</summary>
public float ProjectileSpeed { get; set; } = 180f;
/// <summary>Hit-stop frames requested immediately after a projectile is fired.</summary>
public int ProjectileFireHitStopFrames { get; set; } = 2;
/// <summary>Hit-stop frames requested when a projectile relocates the portal.</summary>
public int ProjectileImpactHitStopFrames { get; set; } = 4;
/// <summary>Extra clearance between a relocated portal and the wall or obstacle it struck.</summary>
public float ProjectilePortalSurfacePadding { get; set; } = 0f;
/// <summary>Shared lockout after either swapping or firing, in fixed simulation seconds.</summary>
public float CooldownDuration { get; set; } = 2.1f;
/// <summary>Whether the live player sprite is surrounded by the cooldown-status outline.</summary>
public bool ShowCooldownOutline { get; set; } = true;
/// <summary>Tint used by the cooldown-status outline.</summary>
public Color CooldownOutlineColor { get; set; } = new( 0.28f, 0.7f, 1f );
/// <summary>Opacity approached during recharge before readiness snaps to the ready pulse.</summary>
public float CooldownOutlineRechargeOpacity { get; set; } = 0.5f;
/// <summary>Lowest opacity used while the fully charged outline pulses.</summary>
public float CooldownOutlineReadyMinOpacity { get; set; } = 0.8f;
/// <summary>Opacity used at the ready transition and at the top of the fully charged pulse.</summary>
public float CooldownOutlineReadyMaxOpacity { get; set; } = 1f;
/// <summary>Fully charged outline-pulse frequency in cycles per second.</summary>
public float CooldownOutlinePulseSpeed { get; set; } = 1.5f;
/// <summary>Number of inward-moving particles emitted when the shared cooldown becomes ready.</summary>
public int CooldownReadyParticleCount { get; set; } = 8;
/// <summary>Inward speed of cooldown-ready particles in game pixels per second.</summary>
public float CooldownReadyParticleSpeed { get; set; } = 22f;
/// <summary>Lifetime of cooldown-ready particles in seconds.</summary>
public float CooldownReadyParticleLifetime { get; set; } = 0.28f;
/// <summary>Volume of the sound played when the shared cooldown becomes ready.</summary>
public float CooldownReadySfxVolume { get; set; } = 0.35f;
/// <summary>Pitch of the sound played when the shared cooldown becomes ready.</summary>
public float CooldownReadySfxPitch { get; set; } = 1.5f;
/// <summary>Number of outline fragments shed when a projectile consumes readiness.</summary>
public int ProjectileOutlineDisperseParticleCount { get; set; } = 12;
/// <summary>Outward speed of projectile outline fragments in game pixels per second.</summary>
public float ProjectileOutlineDisperseSpeed { get; set; } = 42f;
/// <summary>Lifetime of projectile outline fragments in seconds.</summary>
public float ProjectileOutlineDisperseLifetime { get; set; } = 0.3f;
private static bool _traceEnabled;
[ConCmd( "swapper_trace" )]
private static void ToggleTrace()
{
if ( !Game.IsEditor ) return;
_traceEnabled = !_traceEnabled;
Log.Info( $"[swapper] trace {(_traceEnabled ? "ON" : "OFF")}" );
}
// Hit-stop scaling. framesSinceSwap 0 -> the swap was this/last tick -> ~no freeze; at or beyond
// FULL_RECOVER_FRAMES -> the full MAX_HITSTOP_FRAMES freeze. A few frames apart -> a short freeze.
private const int MAX_HITSTOP_FRAMES = 8;
private const int FULL_RECOVER_FRAMES = 40; // ~0.67s at 60fps to recover the full hit-stop
private const int HOVER_TRAIL_INTERVAL_FRAMES = 3;
private const float HOVER_HAPTIC_STRENGTH = 0.12f;
// Leniency window (in fixed ticks) for the all-inputs trigger: the swap fires once every one of the
// four directions has had a "just pressed" edge within the last this-many ticks — so they don't
// have to all land on the exact same frame. 1 == must be the same frame; higher == more forgiving.
private const int SWAP_INPUT_WINDOW_FRAMES = 4;
// Endpoint burst: a two-tone spray of cyan particles at BOTH ends of the swap.
private const int BURST_COUNT_MAIN = 10; // SwapBurst0 (cyan)
private const int BURST_COUNT_HIGHLIGHT = 5; // SwapBurst1 (lighter cyan), layered on top
private const float PROJECTILE_MUZZLE_OFFSET = 8f;
private Portal _portal;
private int _framesSinceSwap = int.MaxValue / 2; // start "long ago" so the first swap gets full hit-stop
// Ticks since each direction was last "just pressed" (0 = this tick). Start "long ago" so a fresh
// game doesn't fire until the player actually presses. Reset back to "long ago" after each swap so a
// new swap needs four fresh presses rather than re-firing off the same ones.
private const int NEVER = int.MaxValue / 2;
private int _sinceLeft = NEVER, _sinceRight = NEVER, _sinceUp = NEVER, _sinceDown = NEVER;
private bool _swapPending;
private Vector2 _storedVelocity;
private int _swapCount;
private readonly DirectionalDoubleTapGesture _projectileGesture = new();
private PlayerSpriteOutline _cooldownOutline;
private SwapperStaffIndicator _staffIndicator;
private TwinAbilityEyeTint _eyeTint;
private float _cooldownTimer;
private float _cooldownPulsePhase;
private float _staffPulsePhase;
private int _previousHoverFrames;
private bool _hoverUsedSinceRecharge;
private int _hoverTrailFrame;
private SoundHandle _hoverTone;
private static readonly Color StaffFullBright = new( 0.32f, 0.58f, 1f );
private static readonly Color StaffFullDark = new( 0.08f, 0.25f, 0.82f );
private static readonly Color StaffEmptyBlack = new( 0f, 0f, 0f );
private static readonly Color StaffEmptyRed = new( 0.3f, 0.015f, 0.025f );
private static readonly Color StaffBody = LevelCosmeticsRandomizer.GammaToLinear(
new Color( 204f / 255f, 241f / 255f, 70f / 255f ) );
private static readonly Color EmptyHoverEyeTint = new( 0.72f, 0.06f, 0.07f, 0.95f );
public override void OnSpawn( Player player )
{
_storedVelocity = Vector2.Zero;
_swapCount = 0;
_cooldownTimer = 0f;
_cooldownPulsePhase = 0f;
_staffPulsePhase = 0f;
_previousHoverFrames = player.GetHoverFramesRemaining();
_hoverUsedSinceRecharge = false;
_hoverTrailFrame = 0;
_hoverTone = null;
_projectileGesture.Reset();
if ( _traceEnabled )
Log.Info( $"[swapper] spawn bank=(0.00,0.00) scale={StoredVelocityScale:0.00}" );
// Drop the portal at the spawn point. player.Pos is set before CreateVisuals (which builds the
// ability modules), so it's already the spawn location here.
_portal = player.Stage.AddPortal( player.Pos );
var outlineObject = new GameObject( true, "swapper cooldown outline" );
outlineObject.SetParent( player.GameObject );
outlineObject.LocalPosition = Vector3.Zero;
_cooldownOutline = outlineObject.Components.Create<PlayerSpriteOutline>();
_cooldownOutline.Setup( player );
var staffObject = new GameObject( true, "swapper staff hover indicator" );
staffObject.SetParent( player.GameObject );
staffObject.LocalPosition = Vector3.Zero;
_staffIndicator = staffObject.Components.Create<SwapperStaffIndicator>();
_staffIndicator.Setup( player );
var tintObject = new GameObject( true, "swapper hover eye tint" );
tintObject.SetParent( player.GameObject );
tintObject.LocalPosition = Vector3.Zero;
_eyeTint = tintObject.Components.Create<TwinAbilityEyeTint>();
_eyeTint.Setup( player, adjustTwinOneWalk: false, eyeLeft: 6, eyeTop: 4,
eyeWidth: 4, eyeHeight: 3, tintDarkPixels: true );
UpdatePortalPointer();
UpdateCooldownOutline();
UpdateStaffIndicator( player, 0f );
}
public override void OnRemoved( Player player )
{
_projectileGesture.Reset();
_cooldownOutline?.GameObject?.Destroy();
_cooldownOutline = null;
_staffIndicator?.GameObject?.Destroy();
_staffIndicator = null;
_eyeTint?.GameObject?.Destroy();
_eyeTint = null;
StopHoverTone();
player.Stage.RemoveSwapperProjectiles( _portal );
player.Stage.RemovePortal( _portal );
_portal = null;
}
public override void OnDeath( Player player )
{
if ( _cooldownOutline is not null )
_cooldownOutline.Enabled = false;
if ( _staffIndicator is not null )
_staffIndicator.Enabled = false;
if ( _eyeTint is not null )
_eyeTint.Tint = Color.Transparent;
StopHoverTone();
}
public override void OnAnimationChanged( Player player, string animation )
{
_staffIndicator?.SetAnimation( animation );
_eyeTint?.SetAnimation( animation );
}
public override void OnReplayPresentationRebuilt( Player player )
{
_previousHoverFrames = player.GetHoverFramesRemaining();
_hoverUsedSinceRecharge = _previousHoverFrames < player.GetHoverFramesMaximum();
_hoverTrailFrame = 0;
StopHoverTone();
UpdateStaffIndicator( player, 0f );
}
public override void PreTick( Player player, float dt )
{
UpdatePortalPointer();
TickCooldown( player, dt );
UpdateCooldownOutline();
if ( _cooldownTimer > 0f )
{
_swapPending = false;
ResetAbilityGestures();
return;
}
var input = player.InputSource;
// Age the per-direction press timers, then stamp any that were just pressed this tick to 0.
if ( _sinceLeft < NEVER ) _sinceLeft++;
if ( _sinceRight < NEVER ) _sinceRight++;
if ( _sinceUp < NEVER ) _sinceUp++;
if ( _sinceDown < NEVER ) _sinceDown++;
if ( input.LeftJust ) _sinceLeft = 0;
if ( input.RightJust ) _sinceRight = 0;
if ( input.UpJust ) _sinceUp = 0;
if ( input.DownJust ) _sinceDown = 0;
// Trigger: all four directions pressed within the leniency window (not necessarily the same frame).
_swapPending = _portal is not null
&& _sinceLeft < SWAP_INPUT_WINDOW_FRAMES
&& _sinceRight < SWAP_INPUT_WINDOW_FRAMES
&& _sinceUp < SWAP_INPUT_WINDOW_FRAMES
&& _sinceDown < SWAP_INPUT_WINDOW_FRAMES;
if ( _swapPending )
{
_projectileGesture.Reset();
player.ConsumeDirectionalInputForTick();
}
else if ( _projectileGesture.TryTrigger( player, dt, out Vector2 projectileDirection ) )
{
FireProjectile( player, projectileDirection );
}
}
public override void PostTick( Player player, float dt )
{
UpdateStaffIndicator( player, dt );
if ( _framesSinceSwap < int.MaxValue / 2 )
_framesSinceSwap++;
if ( !_swapPending || _portal is null )
return;
_swapPending = false;
// Blocks moved earlier this tick but Portals tick after Players, so a block-anchored portal's
// Pos is still last tick's sync — land the swap on the block's current position (where the
// portal renders this frame) instead of one block-step behind it.
_portal.UpdateAnchoredPosition();
Vector2 playerPos = player.Pos;
Vector2 portalPos = _portal.Pos;
Vector2 velocityToApply = EnableStoredVelocity ? _storedVelocity * StoredVelocityScale : Vector2.Zero;
_swapCount++;
if ( _traceEnabled )
{
Log.Info( $"[swapper #{_swapCount}] begin from={V( playerPos )} to={V( portalPos )} "
+ $"velocityBank={(EnableStoredVelocity ? "on" : "off")} bank={V( _storedVelocity )} "
+ $"scale={StoredVelocityScale:0.00} apply={V( velocityToApply )} "
+ player.DescribeMotionForTrace() );
}
if ( EnableStoredVelocity )
{
_storedVelocity = player.ExchangeStoredVelocity( _storedVelocity, StoredVelocityScale );
if ( _traceEnabled )
{
Log.Info( $"[swapper #{_swapCount}] exchanged captured={V( _storedVelocity )} "
+ $"applied={V( velocityToApply )} {player.DescribeMotionForTrace()}" );
}
}
else
{
_storedVelocity = Vector2.Zero;
player.ClearMotion();
}
// Swap the two positions.
player.TeleportTo( portalPos.x, portalPos.y );
_portal.MoveTo( playerPos );
UpdatePortalPointer();
// Hit-stop scaled by time since the last swap (rapid re-swaps get little/none).
int frames = (int)MathF.Round( Utils.Map( _framesSinceSwap, 0f, FULL_RECOVER_FRAMES, 0f, MAX_HITSTOP_FRAMES, true, EasingType.Linear ) );
if ( frames > 0 )
player.Stage.RequestHitStop( frames );
// A burst at each end of the swap: where the player arrived and where the portal arrived.
SpawnBurst( player, portalPos ); // player's new spot
SpawnBurst( player, playerPos ); // portal's new spot
// Warpy phase cue (reuse the turn-invisible sound), pitched up so it's distinct from the wrap.
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.85f, 1.2f );
// Centred: a swap trades two places at once, so there is no side for it to come from.
if ( player.DrivesHaptics ) Haptics.Pulse( 0.55f, 0.12f, 0f, Haptics.TONE_NEUTRAL );
// Resolve the landing: instant death if fully inside a block, else eject + boost by depth.
player.ResolveTeleportPenetration();
// Rebuild contact flags at the arrival point (like Rewind/TwinDash): otherwise the first
// post-swap tick runs against contacts captured at the departure spot — a mid-air arrival
// still flagged grounded lets floor friction bite the swap launch.
player.RefreshContactsAfterTeleport();
player.RefreshHoverFrames();
if ( _traceEnabled )
{
Log.Info( $"[swapper #{_swapCount}] landed pos={V( player.Pos )} dead={player.IsDead} "
+ player.DescribeMotionForTrace() );
}
_framesSinceSwap = 0;
StartCooldown();
UpdateCooldownOutline();
// Consume the presses so the next swap needs four fresh ones (not the same edges next frame).
ResetAbilityGestures();
}
private void UpdateStaffIndicator( Player player, float dt )
{
int maximum = Math.Max( 1, player.GetHoverFramesMaximum() );
int remaining = Math.Clamp( player.GetHoverFramesRemaining(), 0, maximum );
float fraction = remaining / (float)maximum;
if ( _eyeTint is not null )
_eyeTint.Tint = remaining == 0 ? EmptyHoverEyeTint : Color.Transparent;
float cyclesPerSecond = remaining == maximum ? 10f : remaining == 0 ? 0.65f : 1.2f + 4.8f * fraction;
_staffPulsePhase = (_staffPulsePhase + dt * cyclesPerSecond * MathF.Tau) % MathF.Tau;
if ( _staffIndicator is not null )
{
if ( remaining == maximum )
_staffIndicator.Tint = MathF.Sin( _staffPulsePhase ) >= 0f ? StaffFullBright : StaffFullDark;
else if ( remaining == 0 )
_staffIndicator.Tint = StaffBody;
else
{
Color dark = Color.Lerp( new Color( 0.018f, 0.002f, 0.004f ), StaffFullDark, fraction );
Color bright = Color.Lerp( StaffEmptyRed, StaffFullBright, fraction );
float pulse = 0.5f + 0.5f * MathF.Sin( _staffPulsePhase );
_staffIndicator.Tint = Color.Lerp( dark, bright, pulse );
}
}
bool hovering = remaining < _previousHoverFrames;
UpdateHoverTone( hovering, fraction );
// Light crisp hum under the hover tone, flat for however long the staff holds us up.
if ( hovering && player.DrivesHaptics )
Haptics.Sustain( HapticChannel.SwapperHover, HOVER_HAPTIC_STRENGTH, 0f, Haptics.TONE_CRISP );
UpdateHoverTrail( player, hovering );
if ( hovering )
{
_hoverUsedSinceRecharge = true;
if ( remaining == 0 )
SpawnHoverDepletedParticles( player );
}
else if ( remaining > _previousHoverFrames )
{
if ( _hoverUsedSinceRecharge && player.OnFloor )
SpawnHoverRechargeParticles( player, remaining - _previousHoverFrames, maximum );
_hoverUsedSinceRecharge = false;
}
_previousHoverFrames = remaining;
}
private void UpdateHoverTrail( Player player, bool hovering )
{
if ( !hovering )
{
_hoverTrailFrame = 0;
return;
}
if ( _hoverTrailFrame == 0 )
SpawnHoverTrailParticle( player );
_hoverTrailFrame = (_hoverTrailFrame + 1) % HOVER_TRAIL_INTERVAL_FRAMES;
}
private void SpawnHoverTrailParticle( Player player )
{
if ( !TryGetStaffPosition( player, out Vector2 position ) ) return;
Color color = _staffIndicator?.Tint ?? StaffFullBright;
Vector2 velocity = new(
Rng.CosmeticFloat( -32f, 32f ),
Rng.CosmeticFloat( -150f, -105f ) );
Particle particle = player.Stage.AddPhysicsParticle( position, velocity, decel: 0.985f, gravity: -90f,
color.WithAlpha( 0.86f ), lifetime:Rng.CosmeticFloat( 0.35f, 0.75f ), size: 1 );
particle.SetMotion( ParticleMotion.Feather, Rng.CosmeticFloat( 0f, MathF.Tau ) );
particle.SetCollision( collidesWithBlocks: true, ParticleSolidResponse.Bounce, restitution: 0.62f );
particle.EnableFadeOut();
}
private void UpdateHoverTone( bool hovering, float remainingFraction )
{
const float volume = 0.25f;
if ( !hovering )
{
StopHoverTone();
return;
}
if ( _hoverTone is null || !_hoverTone.IsValid )
_hoverTone = Audio.PlaySwapperHoverTone( volume );
float pitch = 0.88f + remainingFraction * 0.2f;
Audio.SetLoopingSfxProperties( _hoverTone, volume, pitch );
}
private void StopHoverTone()
{
Audio.StopLoopingSfx( _hoverTone );
_hoverTone = null;
}
private void SpawnHoverDepletedParticles( Player player )
{
Audio.PlaySfx( SfxType.SpringChargeLost, player.Position, volume: 0.22f, pitch: 0.85f );
if ( !TryGetStaffPosition( player, out Vector2 position ) ) return;
for ( int i = 0; i < 4; i++ )
{
float angle = MathF.Tau * (i + Rng.CosmeticFloat( -0.12f, 0.12f )) / 4f;
Vector2 velocity = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) )
* Rng.CosmeticFloat( 10f, 24f );
Color color = Color.Lerp( StaffEmptyRed, new Color( 0.12f, 0f, 0.01f ), i / 3f ).WithAlpha( 0.68f );
player.Stage.AddColoredParticle( position, velocity, 0.84f, color,
Rng.CosmeticFloat( 0.16f, 0.28f ), 3, Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
private void SpawnHoverRechargeParticles( Player player, int recoveredFrames, int maximum )
{
float intensity = Utils.Map( recoveredFrames, 0f, maximum, 0f, 1f, true, EasingType.CubicEaseIn );
Audio.PlaySfx( SfxType.SpringChargeThreshold, player.Position,
volume: 0.18f + intensity * 0.12f, pitch: 1.25f + intensity * 0.15f );
if ( !TryGetStaffPosition( player, out Vector2 position ) ) return;
int count = 1 + (int)MathF.Round( intensity * 6f );
float speed = 8f + intensity * 24f;
Color darkBlue = new Color( 0.08f, 0.25f, 0.82f );
for ( int i = 0; i < count; i++ )
{
float angle = MathF.Tau * (i + Rng.CosmeticFloat( -0.2f, 0.2f )) / count;
Vector2 direction = new( MathF.Cos( angle ), MathF.Sin( angle ) );
Vector2 velocity = direction * Rng.CosmeticFloat( speed * 0.65f, speed );
Color color = Color.Lerp( darkBlue, StaffFullBright, Rng.CosmeticValue() ).WithAlpha( 0.3f + intensity * 0.5f );
player.Stage.AddColoredParticle( position, velocity, 0.86f, color,
0.14f + intensity * Rng.CosmeticFloat( 0.1f, 0.2f ), 2, Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
private bool TryGetStaffPosition( Player player, out Vector2 position )
{
if ( _staffIndicator is not null && _staffIndicator.TryGetMarkerWorldPosition( out position ) )
return true;
position = player.Position;
return false;
}
private void TickCooldown( Player player, float dt )
{
if ( _cooldownTimer > 0f )
{
_cooldownTimer = Math.Max( 0f, _cooldownTimer - dt );
if ( _cooldownTimer <= 0f )
{
_cooldownPulsePhase = 0f;
Audio.PlaySfx( SfxType.TurnVisible, player.Position,
Math.Max( 0f, CooldownReadySfxVolume ), Math.Max( 0.01f, CooldownReadySfxPitch ) );
SpawnCooldownReadyParticles( player );
}
return;
}
_cooldownPulsePhase += dt * Math.Max( 0f, CooldownOutlinePulseSpeed ) * MathF.PI * 2f;
}
private void StartCooldown()
{
_cooldownTimer = Math.Max( 0f, CooldownDuration );
_cooldownPulsePhase = 0f;
ResetAbilityGestures();
}
private void ResetAbilityGestures()
{
_sinceLeft = _sinceRight = _sinceUp = _sinceDown = NEVER;
_projectileGesture.Reset();
}
private void UpdateCooldownOutline()
{
if ( _cooldownOutline is null ) return;
float opacity;
if ( _cooldownTimer > 0f )
{
float duration = Math.Max( 0.001f, CooldownDuration );
float progress = 1f - Math.Clamp( _cooldownTimer / duration, 0f, 1f );
opacity = Utils.Map( progress, 0f, 1f, 0f,
Math.Clamp( CooldownOutlineRechargeOpacity, 0f, 1f ), true, EasingType.CubicEaseIn );
}
else
{
float minOpacity = Math.Clamp( CooldownOutlineReadyMinOpacity, 0f, 1f );
float maxOpacity = Math.Clamp( CooldownOutlineReadyMaxOpacity, minOpacity, 1f );
float pulse = 0.5f + 0.5f * MathF.Cos( _cooldownPulsePhase );
opacity = minOpacity + (maxOpacity - minOpacity) * pulse;
}
_cooldownOutline.Tint = CooldownOutlineColor.WithAlpha( ShowCooldownOutline ? opacity : 0f );
}
private void UpdatePortalPointer()
{
_portal?.SetStoredVelocityPointer(
_storedVelocity * StoredVelocityScale,
EnableStoredVelocity && ShowStoredVelocityPointer,
new PortalPointerSettings(
StoredVelocityPointerMinOffset,
StoredVelocityPointerOffset,
StoredVelocityPointerColorA,
StoredVelocityPointerColorB,
StoredVelocityPointerMinOpacity,
StoredVelocityPointerMaxOpacity,
StoredVelocityPointerOpacityPower,
StoredVelocityPointerPulseSpeed,
StoredVelocityPointerMinOscillationDistance,
StoredVelocityPointerMaxOscillationDistance,
StoredVelocityPointerMinOscillationSpeed,
StoredVelocityPointerMaxOscillationSpeed
)
);
}
private static string V( Vector2 value ) => $"({value.x:0.00},{value.y:0.00})";
private void SpawnCooldownReadyParticles( Player player )
{
int count = Math.Max( 0, CooldownReadyParticleCount );
float speed = Math.Max( 0f, CooldownReadyParticleSpeed );
float lifetime = Math.Max( 0.01f, CooldownReadyParticleLifetime );
Color highlight = Color.Lerp( CooldownOutlineColor, Color.White, 0.55f );
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * (i + 0.5f) / Math.Max( 1, count );
Vector2 inward = new( -MathF.Cos( angle ), -MathF.Sin( angle ) );
Vector2 position = player.Pos - inward * Rng.CosmeticFloat( 6.5f, 8f );
Vector2 tangent = new( -inward.y, inward.x );
Vector2 velocity = inward * speed + tangent * Rng.CosmeticFloat( -5f, 5f );
Color color = ((i & 1) == 0 ? CooldownOutlineColor : highlight).WithAlpha( 0.88f );
player.Stage.AddColoredParticle( position, velocity, 0.84f, color,
lifetime * Rng.CosmeticFloat( 0.85f, 1.15f ), (i % 3) == 0 ? 2 : 1,
Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
private void SpawnProjectileOutlineDisperseParticles( Player player )
{
int count = Math.Max( 0, ProjectileOutlineDisperseParticleCount );
float speed = Math.Max( 0f, ProjectileOutlineDisperseSpeed );
float lifetime = Math.Max( 0.01f, ProjectileOutlineDisperseLifetime );
SpriteRenderer body = player.BodySpriteRenderer;
Texture texture = body?.Texture;
if ( count == 0 || body is null || texture is null || texture == Texture.Transparent ) return;
Vector3 worldPosition = body.GameObject.WorldPosition;
Vector3 worldScale = body.GameObject.WorldScale;
float aspect = texture.Width / (float)Math.Max( 1, texture.Height );
float halfWidth = MathF.Abs( body.Size.x * worldScale.x * MathF.Min( aspect, 1f ) ) * 0.5f + 1f;
float halfHeight = MathF.Abs( body.Size.y * worldScale.y / MathF.Max( aspect, 1f ) ) * 0.5f + 1f;
Vector2 center = new( worldPosition.x, worldPosition.y );
Color highlight = Color.Lerp( CooldownOutlineColor, Color.White, 0.45f );
for ( int i = 0; i < count; i++ )
{
float angle = MathF.PI * 2f * (i + Rng.CosmeticFloat( -0.15f, 0.15f )) / Math.Max( 1, count );
Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
float edgeDistance = Math.Min(
halfWidth / Math.Max( 0.001f, MathF.Abs( outward.x ) ),
halfHeight / Math.Max( 0.001f, MathF.Abs( outward.y ) ) );
Vector2 tangent = new( -outward.y, outward.x );
Vector2 velocity = outward * Rng.CosmeticFloat( speed * 0.8f, speed * 1.2f )
+ tangent * Rng.CosmeticFloat( -7f, 7f );
Color color = ((i & 1) == 0 ? CooldownOutlineColor : highlight).WithAlpha( 0.78f );
player.Stage.AddColoredParticle( center + outward * edgeDistance, velocity, 0.88f, color,
lifetime * Rng.CosmeticFloat( 0.8f, 1.2f ), 1, Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
private void FireProjectile( Player player, Vector2 direction )
{
Vector2 muzzle = player.Pos + direction * PROJECTILE_MUZZLE_OFFSET;
player.Stage.AddSwapperProjectile( muzzle, direction, sweepOrigin: player.Pos, _portal, ProjectileSpeed,
ProjectileImpactHitStopFrames, ProjectilePortalSurfacePadding );
player.Stage.RequestHitStop( Math.Max( 0, ProjectileFireHitStopFrames ) );
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.55f, 1.4f );
if ( player.DrivesHaptics )
Haptics.Pulse( 0.4f, 0.08f, direction.x * 0.5f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
Vector2 perpendicular = new( -direction.y, direction.x );
for ( int i = 0; i < 6; i++ )
{
Vector2 velocity = direction * Rng.CosmeticFloat( 35f, 90f )
+ perpendicular * Rng.CosmeticFloat( -28f, 28f );
Color color = (i & 2) == 0
? new Color( 72 / 255f, 205 / 255f, 218 / 255f, 0.7f )
: new Color( 1f, 0.82f, 0.38f, 0.72f );
player.Stage.AddColoredParticle( muzzle, velocity, 0.86f, color,
Rng.CosmeticFloat( 0.14f, 0.26f ), Rng.CosmeticInt( 1, 4 ), Globals.DEPTH_PARTICLE_1, translucent: true );
}
SpawnProjectileOutlineDisperseParticles( player );
StartCooldown();
UpdateCooldownOutline();
}
/// <summary>Spray a two-tone cyan burst at <paramref name="pos"/> (a swap endpoint). Cosmetic only,
/// so it draws from the cosmetic RNG stream (never the authoritative sim stream).</summary>
private static void SpawnBurst( Player player, Vector2 pos )
{
for ( int i = 0; i < BURST_COUNT_MAIN; i++ )
{
var vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 60f, 200f );
player.Stage.AddParticle( pos, vel, Rng.CosmeticFloat( 0.9f, 0.95f ), 0f, ParticleKind.SwapBurst0, Rng.CosmeticFloat( 0.25f, 0.5f ), Rng.CosmeticInt( 3, 7 ) );
}
for ( int i = 0; i < BURST_COUNT_HIGHLIGHT; i++ )
{
var vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 40f, 140f );
player.Stage.AddParticle( pos, vel, Rng.CosmeticFloat( 0.9f, 0.95f ), 0f, ParticleKind.SwapBurst1, Rng.CosmeticFloat( 0.2f, 0.4f ), Rng.CosmeticInt( 2, 5 ) );
}
}
}