Entities/Abilities/RewindAbility.cs
using System.Collections.Generic;
namespace BlockParty;
/// <summary>Shows the player's personal movement state three and six seconds ago, and restores the
/// three-second state while preserving an older timeline when one exists.</summary>
public sealed class RewindAbility : PlayerAbility
{
private const int ECHO_INTERVAL_FRAMES = 180;
private const int MAX_HISTORY_FRAMES = ECHO_INTERVAL_FRAMES * 2;
private const int INPUT_WINDOW_FRAMES = 4;
private const int NEVER = int.MaxValue / 2;
private const int HITSTOP_FRAMES = 6;
private const int ECHO_REPEL_HITSTOP_FRAMES = 11;
private const int ECHO_REPEL_COOLDOWN_FRAMES = 7; // min interval between repels; keep >= 0.1s of sim
// The repel hit-stop only re-arms after this long with no repel at all, so chain-bouncing
// between echoes shoves without freezing (no getting stuck ping-ponging in slow-mo).
private const int ECHO_REPEL_HITSTOP_REARM_FRAMES = Arena.TICK_RATE / 2;
private const int ECHO_REPEL_SPAWN_GRACE_FRAMES = 30;
private const int ECHO_REPEL_FLASH_REMAINING_FRAMES = 5;
private const int ECHO_REPEL_SHAKE_REMAINING_FRAMES = 7;
private const int ECHO_MARKER_FOCUS_FRAMES = 8;
private const int DEATH_REWIND_HITSTOP_FRAMES = HITSTOP_FRAMES + 2;
private const float RECENT_ECHO_ALPHA = 0.8f;
private const float OLDER_ECHO_ALPHA = 0.6f;
private const float RECENT_ECHO_REPEL_STRENGTH = 420f;
private const float OLDER_ECHO_REPEL_STRENGTH = RECENT_ECHO_REPEL_STRENGTH; // let's keep both the same, previously had * (0.4f / 0.65f);
private const float ECHO_FADE_TIME = 0.24f;
private const int ECHO_APPEAR_PARTICLES = 6;
private const float INVALID_ECHO_ALPHA_FACTOR = 0.65f;
private const float RECENT_INVALID_ECHO_ALPHA = 0.65f * INVALID_ECHO_ALPHA_FACTOR;
private const float OLDER_INVALID_ECHO_ALPHA = 0.4f * INVALID_ECHO_ALPHA_FACTOR;
private const int ECHO_READY_HITSTOP_FRAMES = 4;
private const int ECHO_READY_RING_PARTICLES = 12;
private const int ECHO_READY_SPARK_PARTICLES = 8;
private const int DEATH_RECOVERY_SHAKE_FRAMES = 8;
private const float DASH_MARKER_PULSE_SPEED = 32f;
private static readonly Color ECHO_COOLDOWN_FILL_COLOR = Color.White.WithAlpha( 0.42f );
private static readonly Color DASH_MARKER_PALE_BLUE = new( 0.62f, 0.82f, 1f );
private static readonly Color DASH_MARKER_BRIGHT_BLUE = new( 0.24f, 0.42f, 1f );
private static readonly Vector2 DEATH_RECOVERY_SHAKE_AMPLITUDE = new( 1.5f, 0.75f );
/// <summary>Whether the all-four-direction gesture can spend an echo to rewind manually.</summary>
public bool CanManuallyTeleportToEchoes { get; set; } = false;
/// <summary>How long a replacement echo remains invalid after reappearing.</summary>
public float DeathEchoValidityCooldown { get; set; } = 25f;
private readonly List<Player.RewindState> _history = new( MAX_HISTORY_FRAMES );
private GameObject _recentEchoRoot;
private GameObject _olderEchoRoot;
private GameObject _markerBarRoot;
private GameObject _markerTipRoot;
private SpriteRenderer _recentEcho;
private SpriteRenderer _olderEcho;
private SpriteRenderer _markerBar;
private SpriteRenderer _markerTip;
private SpriteFillOverlay _recentEchoCooldownOverlay;
private SpriteFillOverlay _olderEchoCooldownOverlay;
private PlayerSpriteOutline _recentEchoCooldownOutline;
private PlayerSpriteOutline _olderEchoCooldownOutline;
private string _recentEchoAnimation;
private string _olderEchoAnimation;
private int _sinceLeft = NEVER;
private int _sinceRight = NEVER;
private int _sinceUp = NEVER;
private int _sinceDown = NEVER;
private bool _rewindPending;
private bool _recentEchoReady;
private bool _olderEchoReady;
private float _recentEchoFade;
private float _olderEchoFade;
private int _recentEchoAgeFrames;
private int _olderEchoAgeFrames;
private int _recentEchoFlashFrames;
private int _olderEchoFlashFrames;
private int _recentEchoShakeFrames;
private int _olderEchoShakeFrames;
private int _echoRepelCooldownFrames;
private int _framesSinceEchoRepel = NEVER;
private int _echoMarkerFocusFrames;
private float _echoMarkerPulseTime;
private bool _echoMarkerReady;
private int _pendingDeathEchoCooldowns;
private float _recentEchoValidityCooldown;
private float _olderEchoValidityCooldown;
private bool _olderEchoBlockedByRecentCooldown;
private int _olderEchoSpawnDelayFrames;
private bool RecentEchoValid => _recentEchoReady && _recentEchoValidityCooldown <= 0f;
public override void OnSpawn( Player player )
{
var character = player.Character ?? Characters.Original;
_recentEcho = CreateEcho( player, character, out _recentEchoRoot );
_olderEcho = CreateEcho( player, character, out _olderEchoRoot );
_recentEchoCooldownOverlay = CreateEchoCooldownOverlay( _recentEchoRoot, _recentEcho );
_olderEchoCooldownOverlay = CreateEchoCooldownOverlay( _olderEchoRoot, _olderEcho );
_recentEchoCooldownOutline = CreateEchoCooldownOutline( _recentEchoRoot, _recentEcho );
_olderEchoCooldownOutline = CreateEchoCooldownOutline( _olderEchoRoot, _olderEcho );
_markerBar = CreateMarkerPart( player, "RewindControlMarkerBar", new Vector2( 5f, 1f ), out _markerBarRoot );
_markerTip = CreateMarkerPart( player, "RewindControlMarkerTip", new Vector2( 1f, 1f ), out _markerTipRoot );
}
public override void OnRemoved( Player player )
{
_recentEchoRoot?.Destroy();
_olderEchoRoot?.Destroy();
_markerBarRoot?.Destroy();
_markerTipRoot?.Destroy();
_recentEchoRoot = null;
_olderEchoRoot = null;
_markerBarRoot = null;
_markerTipRoot = null;
_recentEcho = null;
_olderEcho = null;
_recentEchoCooldownOverlay = null;
_olderEchoCooldownOverlay = null;
_recentEchoCooldownOutline = null;
_olderEchoCooldownOutline = null;
_markerBar = null;
_markerTip = null;
_history.Clear();
}
public override bool TryPreventDeath( Player player )
{
int destinationIndex = _history.Count - ECHO_INTERVAL_FRAMES;
if ( player.IsImpostor || !RecentEchoValid || destinationIndex < 0 ) return false;
Vector2 source = player.Pos;
Player.RewindState destination = _history[destinationIndex];
player.PreserveDeathAnimationAtCurrentPosition();
ConsumeRecentEcho( player, destinationIndex );
QueueDeathEchoCooldown();
player.RestoreRewindState( destination );
player.Stage.RequestHitStop( DEATH_REWIND_HITSTOP_FRAMES );
SpawnBurst( player, source );
SpawnBurst( player, player.Pos );
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.9f, 0.72f );
// This may kill recursively. The consumed echo is already gone, so a fatal destination can
// consume the promoted older echo; if none remains, the nested death commits normally.
player.ResolveTeleportPenetration();
if ( !player.IsDead )
{
player.RefreshContactsAfterTeleport();
player.ResetShockwaveHitsAfterRecovery();
player.Shake( DEATH_RECOVERY_SHAKE_AMPLITUDE, DEATH_RECOVERY_SHAKE_FRAMES );
player.StartScaleBounce();
player.FinishRewindDeathRecovery();
}
UpdateEchoMarker( player );
return true;
}
public override void OnDeath( Player player )
{
HideEchoMarker();
}
public override void OnDeathAnimationFinished( Player player )
{
if ( _recentEcho is not null ) _recentEcho.Enabled = false;
if ( _olderEcho is not null ) _olderEcho.Enabled = false;
HideEchoMarker();
}
public override void PreTick( Player player, float dt )
{
if ( !CanManuallyTeleportToEchoes || !RecentEchoValid || _history.Count < ECHO_INTERVAL_FRAMES )
{
_rewindPending = false;
_sinceLeft = _sinceRight = _sinceUp = _sinceDown = NEVER;
return;
}
AgeAndCaptureInputs( player.InputSource );
_rewindPending = _sinceLeft < INPUT_WINDOW_FRAMES
&& _sinceRight < INPUT_WINDOW_FRAMES
&& _sinceUp < INPUT_WINDOW_FRAMES
&& _sinceDown < INPUT_WINDOW_FRAMES;
if ( _rewindPending )
player.ConsumeDirectionalInputForTick();
}
public override void PostTick( Player player, float dt )
{
if ( _echoRepelCooldownFrames > 0 ) _echoRepelCooldownFrames--;
if ( _framesSinceEchoRepel < NEVER ) _framesSinceEchoRepel++;
_echoMarkerPulseTime += dt;
if ( _rewindPending )
{
_rewindPending = false;
Vector2 source = player.Pos;
int destinationIndex = _history.Count - ECHO_INTERVAL_FRAMES;
Player.RewindState destination = _history[destinationIndex];
ConsumeRecentEcho( player, destinationIndex );
player.RestoreRewindState( destination );
player.Stage.RequestHitStop( HITSTOP_FRAMES );
SpawnBurst( player, source );
SpawnBurst( player, player.Pos );
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.9f, 0.78f );
// May kill recursively (see TryPreventDeath). The echo is consumed BEFORE this resolve so a
// fatal destination chains straight to the promoted older echo — consuming afterward would
// let the nested recovery restore this same fatal state and die at it a second time.
player.ResolveTeleportPenetration();
player.RefreshContactsAfterTeleport();
if ( player.IsDead && _recentEcho is not null ) _recentEcho.Enabled = false;
UpdateEchoMarker( player );
return;
}
if ( _history.Count == MAX_HISTORY_FRAMES )
_history.RemoveAt( 0 );
_history.Add( player.CaptureRewindState() );
UpdateEchoes( player, dt );
TryRepelFromEchoes( player );
UpdateEchoMarker( player );
}
private void QueueDeathEchoCooldown()
{
_pendingDeathEchoCooldowns = Math.Min( 2, _pendingDeathEchoCooldowns + 1 );
}
private void ConsumeRecentEcho( Player player, int destinationIndex )
{
_sinceLeft = _sinceRight = _sinceUp = _sinceDown = NEVER;
_olderEchoBlockedByRecentCooldown = false;
_olderEchoSpawnDelayFrames = 0;
if ( _olderEchoReady && _history.Count == MAX_HISTORY_FRAMES )
{
// Keep only the path older than the destination. The older echo is promoted to recent.
_history.RemoveRange( destinationIndex, _history.Count - destinationIndex );
(_recentEchoRoot, _olderEchoRoot) = (_olderEchoRoot, _recentEchoRoot);
(_recentEcho, _olderEcho) = (_olderEcho, _recentEcho);
(_recentEchoCooldownOverlay, _olderEchoCooldownOverlay) =
(_olderEchoCooldownOverlay, _recentEchoCooldownOverlay);
(_recentEchoCooldownOutline, _olderEchoCooldownOutline) =
(_olderEchoCooldownOutline, _recentEchoCooldownOutline);
(_recentEchoAnimation, _olderEchoAnimation) = (_olderEchoAnimation, _recentEchoAnimation);
(_recentEchoValidityCooldown, _olderEchoValidityCooldown) =
(_olderEchoValidityCooldown, _recentEchoValidityCooldown);
_recentEchoReady = _olderEchoReady;
_recentEchoFade = _olderEchoFade;
// A fresh age, NOT the older echo's: the player teleports on a rewind, so the repel spawn
// grace must re-arm for the new player/echo configuration or a destination overlapping the
// promoted echo repels on the very next tick, stepping on the rewind's own hit-stop.
_recentEchoAgeFrames = 0;
_recentEchoFlashFrames = _olderEchoFlashFrames;
_recentEchoShakeFrames = _olderEchoShakeFrames;
UpdateEcho( player, 0f, _recentEchoRoot, _recentEcho, 0,
RECENT_ECHO_ALPHA, RECENT_INVALID_ECHO_ALPHA, 0.65f, 1,
ref _recentEchoReady, ref _recentEchoFade, ref _recentEchoAgeFrames,
ref _recentEchoFlashFrames, ref _recentEchoShakeFrames, ref _recentEchoAnimation,
ref _recentEchoValidityCooldown, _recentEchoCooldownOverlay, _recentEchoCooldownOutline );
}
else
{
_history.Clear();
_recentEchoReady = false;
_recentEchoFade = 0f;
_recentEchoAgeFrames = 0;
_recentEchoFlashFrames = 0;
_recentEchoShakeFrames = 0;
_recentEchoValidityCooldown = 0f;
if ( _recentEcho is not null ) _recentEcho.Enabled = false;
}
_olderEchoReady = false;
_olderEchoFade = 0f;
_olderEchoAgeFrames = 0;
_olderEchoFlashFrames = 0;
_olderEchoShakeFrames = 0;
_olderEchoValidityCooldown = 0f;
if ( _olderEcho is not null ) _olderEcho.Enabled = false;
}
private void UpdateEchoMarker( Player player )
{
bool visible = !player.IsDead;
bool ready = RecentEchoValid;
if ( ready && !_echoMarkerReady )
_echoMarkerFocusFrames = ECHO_MARKER_FOCUS_FRAMES;
_echoMarkerReady = ready;
if ( _markerBar is not null ) _markerBar.Enabled = visible;
if ( _markerTip is not null ) _markerTip.Enabled = visible;
if ( !visible )
{
_echoMarkerFocusFrames = 0;
return;
}
float lift = _echoMarkerFocusFrames > 0 ? 1f : 0f;
float z = Globals.DepthToZ( Globals.DEPTH_PARTICLE_1 );
Vector2 barCenter = SpriteLayer.PixelAlignedCenter(
new Vector2( player.X, player.Top + 4f + lift ), _markerBar.Size );
_markerBarRoot.WorldPosition = new Vector3( barCenter.x, barCenter.y, z );
_markerTipRoot.WorldPosition = new Vector3( barCenter.x, barCenter.y - 1f, z );
float dashPulse = 0.5f + 0.5f * MathF.Sin( _echoMarkerPulseTime * DASH_MARKER_PULSE_SPEED );
Color dashColor = Color.Lerp( DASH_MARKER_PALE_BLUE, DASH_MARKER_BRIGHT_BLUE, dashPulse ).WithAlpha( 1f );
Color color = _echoMarkerFocusFrames > 0
? Color.Yellow
: player.DashCharged ? dashColor : Color.White;
_markerBar.Color = color;
_markerTip.Color = color;
if ( _echoMarkerFocusFrames > 0 ) _echoMarkerFocusFrames--;
}
private void HideEchoMarker()
{
_echoMarkerReady = false;
_echoMarkerFocusFrames = 0;
if ( _markerBar is not null ) _markerBar.Enabled = false;
if ( _markerTip is not null ) _markerTip.Enabled = false;
}
private void TryRepelFromEchoes( Player player )
{
if ( player.IsDead || _echoRepelCooldownFrames > 0 ) return;
if ( TryRepelFromEcho( player, _history.Count - ECHO_INTERVAL_FRAMES, RECENT_ECHO_REPEL_STRENGTH,
_recentEchoAgeFrames, _recentEchoValidityCooldown, _recentEchoRoot, _recentEcho,
ref _recentEchoFlashFrames, ref _recentEchoShakeFrames )
|| TryRepelFromEcho( player, _history.Count - MAX_HISTORY_FRAMES, OLDER_ECHO_REPEL_STRENGTH,
_olderEchoAgeFrames, _olderEchoValidityCooldown, _olderEchoRoot, _olderEcho,
ref _olderEchoFlashFrames, ref _olderEchoShakeFrames ) )
{
// Every repel resets the re-arm clock, so only an isolated repel earns the freeze.
bool hitStopArmed = _framesSinceEchoRepel >= ECHO_REPEL_HITSTOP_REARM_FRAMES;
_framesSinceEchoRepel = 0;
_echoRepelCooldownFrames = ECHO_REPEL_COOLDOWN_FRAMES;
player.RefreshDashCharge();
if ( hitStopArmed ) player.Stage.RequestHitStop( ECHO_REPEL_HITSTOP_FRAMES );
Audio.PlaySfx( SfxType.FireballBounce, player.Position, 0.9f, 1.2f );
// Running into your own past shoves hard; the cooldown above keeps it from machine-gunning.
if ( player.DrivesHaptics ) Haptics.Pulse( 0.85f, 0.16f, 0f, Haptics.TONE_HEAVY );
}
}
private bool TryRepelFromEcho( Player player, int stateIndex, float strength, int ageFrames, float validityCooldown,
GameObject echoRoot, SpriteRenderer echo, ref int flashFrames, ref int shakeFrames )
{
if ( stateIndex < 0 || validityCooldown > 0f || ageFrames < ECHO_REPEL_SPAWN_GRACE_FRAMES ) return false;
Player.RewindState state = _history[stateIndex];
if ( !player.GetRect().Intersects( player.GetRect( state.Position.x, state.Position.y ) ) )
return false;
Vector2 away = player.Pos - state.Position;
if ( away == Vector2.Zero ) away = player.Velocity - state.Velocity;
if ( away == Vector2.Zero ) away = new Vector2( 0f, 1f );
player.ApplyRewindEchoRepel( away, strength );
flashFrames = ECHO_REPEL_FLASH_REMAINING_FRAMES;
shakeFrames = ECHO_REPEL_SHAKE_REMAINING_FRAMES;
if ( echo is not null ) echo.Color = Color.Black;
if ( echoRoot is not null ) echoRoot.WorldPosition += new Vector3( 1.5f, 0f, 0f );
SpawnEchoRepelParticles( player, state.Position, away );
return true;
}
private void UpdateEchoes( Player player, float dt )
{
int olderStateIndex = ResolveOlderEchoStateIndex();
UpdateEcho( player, dt, _recentEchoRoot, _recentEcho, _history.Count - ECHO_INTERVAL_FRAMES,
RECENT_ECHO_ALPHA, RECENT_INVALID_ECHO_ALPHA, 0.65f, 1,
ref _recentEchoReady, ref _recentEchoFade, ref _recentEchoAgeFrames,
ref _recentEchoFlashFrames, ref _recentEchoShakeFrames, ref _recentEchoAnimation,
ref _recentEchoValidityCooldown, _recentEchoCooldownOverlay, _recentEchoCooldownOutline );
UpdateEcho( player, dt, _olderEchoRoot, _olderEcho, olderStateIndex,
OLDER_ECHO_ALPHA, OLDER_INVALID_ECHO_ALPHA, 0.4f, 2,
ref _olderEchoReady, ref _olderEchoFade, ref _olderEchoAgeFrames,
ref _olderEchoFlashFrames, ref _olderEchoShakeFrames, ref _olderEchoAnimation,
ref _olderEchoValidityCooldown, _olderEchoCooldownOverlay, _olderEchoCooldownOutline );
}
private int ResolveOlderEchoStateIndex()
{
int stateIndex = _history.Count - MAX_HISTORY_FRAMES;
if ( stateIndex < 0 || _olderEchoReady ) return stateIndex;
// Never expose a usable older echo behind an unusable recent one. Once the recent echo becomes
// valid, rebuild the normal three-second spacing before revealing the older slot.
if ( _recentEchoReady && _recentEchoValidityCooldown > 0f )
{
_olderEchoBlockedByRecentCooldown = true;
_olderEchoSpawnDelayFrames = ECHO_INTERVAL_FRAMES;
return -1;
}
if ( !_olderEchoBlockedByRecentCooldown ) return stateIndex;
if ( !RecentEchoValid ) return -1;
if ( _olderEchoSpawnDelayFrames > 0 )
{
_olderEchoSpawnDelayFrames--;
if ( _olderEchoSpawnDelayFrames > 0 ) return -1;
}
_olderEchoBlockedByRecentCooldown = false;
return stateIndex;
}
private void UpdateEcho( Player player, float dt, GameObject echoRoot, SpriteRenderer echo,
int stateIndex, float maxAlpha, float invalidMaxAlpha, float cooldownOverlayBaseAlpha, int depthSteps,
ref bool ready, ref float fade,
ref int ageFrames, ref int flashFrames, ref int shakeFrames, ref string animation,
ref float validityCooldown, SpriteFillOverlay cooldownOverlay, PlayerSpriteOutline cooldownOutline )
{
if ( echoRoot is null || echo is null ) return;
if ( stateIndex < 0 )
{
echo.Enabled = false;
if ( cooldownOverlay is not null ) cooldownOverlay.Fill = 0f;
if ( cooldownOutline is not null ) cooldownOutline.Tint = Color.Transparent;
ready = false;
ageFrames = 0;
flashFrames = 0;
shakeFrames = 0;
validityCooldown = 0f;
return;
}
Player.RewindState state = _history[stateIndex];
if ( !ready )
{
ready = true;
fade = 0f;
ageFrames = 0;
flashFrames = 0;
shakeFrames = 0;
if ( _pendingDeathEchoCooldowns > 0 )
{
_pendingDeathEchoCooldowns--;
validityCooldown = Math.Max( 0f, DeathEchoValidityCooldown );
}
else
{
validityCooldown = 0f;
}
SpawnEchoAppearParticles( player, state.Position );
}
bool wasInvalid = validityCooldown > 0f;
validityCooldown = Math.Max( 0f, validityCooldown - dt );
if ( wasInvalid && validityCooldown <= 0f )
{
SpawnEchoReadyEffect( player, state.Position );
player.Stage.RequestHitStop( ECHO_READY_HITSTOP_FRAMES );
Audio.PlaySfx( SfxType.TurnVisible, new Vector3( state.Position.x, state.Position.y, 0f ), 0.65f, 1.35f );
}
if ( ageFrames < int.MaxValue ) ageFrames++;
fade = Math.Min( ECHO_FADE_TIME, fade + dt );
echo.Enabled = true;
float normalAlpha = maxAlpha * (fade / ECHO_FADE_TIME);
bool valid = validityCooldown <= 0f;
float cooldownDuration = Math.Max( 0.001f, DeathEchoValidityCooldown );
float cooldownProgress = valid ? 0f : 1f - validityCooldown / cooldownDuration;
if ( cooldownOverlay is not null )
{
cooldownOverlay.Fill = cooldownProgress;
cooldownOverlay.Tint = ECHO_COOLDOWN_FILL_COLOR.WithAlpha(
ECHO_COOLDOWN_FILL_COLOR.a * cooldownOverlayBaseAlpha );
}
if ( cooldownOutline is not null )
{
float invalidAlpha = invalidMaxAlpha * (fade / ECHO_FADE_TIME);
cooldownOutline.Tint = valid ? Color.Transparent : Color.Black.WithAlpha( invalidAlpha );
}
echo.Color = flashFrames > 0 && flashFrames % 2 == 0
? Color.Black
: valid
? Color.White.WithAlpha( normalAlpha )
: Color.Black.WithAlpha( invalidMaxAlpha * (fade / ECHO_FADE_TIME) );
if ( flashFrames > 0 ) flashFrames--;
if ( animation != state.Animation )
{
animation = state.Animation;
echo.PlayAnimation( state.Animation );
}
echo.FlipHorizontal = state.FlipHorizontal;
echo.FlipVertical = state.FlipVertical;
RectF echoRect = player.GetPixelRect( state.Position.x, state.Position.y );
Vector2 echoCenter = new( (echoRect.Left + echoRect.Right) * 0.5f, (echoRect.Bottom + echoRect.Top) * 0.5f );
Vector2 shakeOffset = EchoShakeOffset( shakeFrames );
echoCenter = SpriteLayer.PixelAlignedCenter( echoCenter + shakeOffset, echo.Size );
echoRoot.WorldPosition = new Vector3( echoCenter.x, echoCenter.y,
Globals.DepthToZ( player.Depth ) - SpriteLayer.LAYER_Z_STEP * depthSteps );
if ( shakeFrames > 0 ) shakeFrames--;
}
private static Vector2 EchoShakeOffset( int frames )
{
return frames switch
{
7 => new Vector2( -1.5f, 0.5f ),
6 => new Vector2( 1.25f, -0.5f ),
5 => new Vector2( -1.25f, 0f ),
4 => new Vector2( 1f, 0.5f ),
3 => new Vector2( -1f, -0.5f ),
2 => new Vector2( 0.5f, 0f ),
1 => new Vector2( -0.5f, 0f ),
_ => Vector2.Zero,
};
}
private static SpriteRenderer CreateEcho( Player player, CharacterDef character, out GameObject root )
{
root = player.Stage.CreateChild( "RewindEcho" );
SpriteRenderer echo = SpriteLayer.Add( root, character.SpritePath, character.ArtSize, "idle" );
echo.Opaque = false;
echo.AlphaCutoff = 0f;
echo.Color = Color.White.WithAlpha( 0f );
echo.Enabled = false;
return echo;
}
private static SpriteFillOverlay CreateEchoCooldownOverlay( GameObject root, SpriteRenderer echo )
{
SpriteFillOverlay overlay = root.Components.Create<SpriteFillOverlay>();
overlay.Setup( echo, 0f, ECHO_COOLDOWN_FILL_COLOR );
return overlay;
}
private static PlayerSpriteOutline CreateEchoCooldownOutline( GameObject root, SpriteRenderer echo )
{
PlayerSpriteOutline outline = root.Components.Create<PlayerSpriteOutline>();
outline.Setup( echo );
outline.Tint = Color.Transparent;
return outline;
}
private static SpriteRenderer CreateMarkerPart( Player player, string name, Vector2 size, out GameObject root )
{
root = player.Stage.CreateChild( name );
SpriteRenderer marker = SpriteLayer.Add( root, "sprites/pixel.sprite", size, "idle" );
marker.Opaque = false; // marker parts pulse at 0.78-0.9 alpha
marker.AlphaCutoff = 0f;
marker.Color = Color.White;
marker.Enabled = false;
return marker;
}
private static void SpawnEchoAppearParticles( Player player, Vector2 position )
{
for ( int i = 0; i < ECHO_APPEAR_PARTICLES; i++ )
{
Vector2 velocity = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) )
* Rng.CosmeticFloat( 14f, 42f );
ParticleKind kind = i % 2 == 0 ? ParticleKind.RewindBurst0 : ParticleKind.RewindBurst1;
player.Stage.AddParticle( position, velocity, Rng.CosmeticFloat( 0.9f, 0.96f ), 0f, kind,
Rng.CosmeticFloat( 0.18f, 0.3f ), Rng.CosmeticInt( 1, 3 ) );
}
}
private static void SpawnEchoReadyEffect( Player player, Vector2 position )
{
for ( int i = 0; i < ECHO_READY_RING_PARTICLES; i++ )
{
float angle = MathF.PI * 2f * (i + 0.5f) / ECHO_READY_RING_PARTICLES;
Vector2 outward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
Vector2 tangent = new( -outward.y, outward.x );
Vector2 velocity = -outward * Rng.CosmeticFloat( 32f, 48f )
+ tangent * Rng.CosmeticFloat( -8f, 8f );
Color color = (i & 1) == 0
? Color.White.WithAlpha( 0.9f )
: new Color( 1f, 0.32f, 0.62f, 0.82f );
player.Stage.AddColoredParticle( position + outward * Rng.CosmeticFloat( 7f, 9f ), velocity,
0.88f, color, Rng.CosmeticFloat( 0.24f, 0.34f ), i % 3 == 0 ? 2 : 1,
Globals.DEPTH_PARTICLE_1, translucent: true );
}
for ( int i = 0; i < ECHO_READY_SPARK_PARTICLES; i++ )
{
Vector2 offset = new( Rng.CosmeticFloat( -4f, 4f ), Rng.CosmeticFloat( -5f, 3f ) );
Vector2 velocity = new( Rng.CosmeticFloat( -18f, 18f ), Rng.CosmeticFloat( 45f, 90f ) );
Color color = (i & 1) == 0
? Color.White.WithAlpha( 0.78f )
: new Color( 1f, 0.55f, 0.76f, 0.78f );
player.Stage.AddColoredParticle( position + offset, velocity, 0.9f, color,
Rng.CosmeticFloat( 0.28f, 0.44f ), i % 3 == 0 ? 2 : 1,
Globals.DEPTH_PARTICLE_1, translucent: true );
}
}
private static void SpawnEchoRepelParticles( Player player, Vector2 position, Vector2 away )
{
Vector2 direction = Utils.Normalized( away );
for ( int i = 0; i < 20; i++ )
{
Vector2 scatter = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) )
* Rng.CosmeticFloat( 35f, 85f );
Vector2 velocity = direction * Rng.CosmeticFloat( 80f, 190f ) + scatter;
ParticleKind kind = i % 2 == 0 ? ParticleKind.RewindBurst0 : ParticleKind.RewindBurst1;
player.Stage.AddParticle( position, velocity, Rng.CosmeticFloat( 0.82f, 0.92f ), 0f, kind,
Rng.CosmeticFloat( 0.28f, 0.5f ), Rng.CosmeticInt( 3, 7 ) );
}
}
private static void SpawnBurst( Player player, Vector2 position )
{
for ( int i = 0; i < 12; i++ )
{
Vector2 velocity = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) )
* Rng.CosmeticFloat( 45f, 155f );
ParticleKind kind = i % 2 == 0 ? ParticleKind.RewindBurst0 : ParticleKind.RewindBurst1;
player.Stage.AddParticle( position, velocity, Rng.CosmeticFloat( 0.88f, 0.96f ), 0f, kind,
Rng.CosmeticFloat( 0.22f, 0.45f ), Rng.CosmeticInt( 2, 6 ) );
}
}
private void AgeAndCaptureInputs( IPlayerInput input )
{
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;
}
}