Entities/Abilities/MimicAbility.cs
using System.Collections.Generic;
using System.Linq;
namespace BlockParty;
/// <summary>Persistent run-identity controller for Mimic's temporary character forms.</summary>
public sealed class MimicAbility : PlayerAbility
{
private enum MimicState
{
Waiting,
Choosing,
Transformed,
Finished,
}
private const float FIRST_WAIT = 8f;
private const float REPEAT_WAIT = 8f;
private const float TRANSFORMED_TIME = 15f;
// The last stretch before a choice / a return drips a sparse, quickening trickle of specks and
// swells a rising tone (Swapper's hover oscillator, re-pitched) so the swap never lands
// unannounced. Cosmetic stream only — never touches the sim.
private const float WARN_TIME = 3f;
private const float WARN_INTERVAL_START = 0.22f;
private const float WARN_INTERVAL_END = 0.05f;
private const float WARN_TONE_VOLUME = 0.3f;
private const float WARN_TONE_PITCH_START = 1.0f;
private const float WARN_TONE_PITCH_END = 2.1f;
// Calibrated with the eased 0.15x scale to keep the choice near 2.75 wall-clock seconds.
private const int CHOICE_TICKS = 30;
// Nine fixed ticks land at about 0.4 wall seconds through the 0.2s ease into 0.15x slow motion.
private const int CHOICE_INPUT_LOCK_TICKS = 9;
private const float CHOICE_SCALE = 0.15f;
private const float CHOICE_HAPTIC_STRENGTH = 0.16f;
private const float CHOICE_EASE_IN = 0.2f;
private const float CHOICE_EASE_OUT = 0.2f;
private const float RETURN_TIME_SCALE = 0.5f;
private const float RETURN_TOTAL_TIME = 0.5f;
private const float RETURN_EASE_TIME_IN = 0.05f;
private const float RETURN_EASE_TIME_OUT = 0.2f;
private const float PREVIEW_OFFSET = 11f;
// After a crush-proof squash pops back, guarantee at least this long before the next choice
// begins (the squash itself pauses the whole ability: Player skips ability ticks while flat).
private const float UNSQUASH_MIN_WAIT = 1f;
private readonly HashSet<string> _chosenIds = new();
private MimicState _state;
private float _timer;
private float _warnTimer;
private int _choiceTicks;
private CharacterDef _leftChoice;
private CharacterDef _rightChoice;
private SpriteRenderer _leftPreview;
private SpriteRenderer _rightPreview;
private MimicChoicePulse _leftPulse;
private MimicChoicePulse _rightPulse;
private GameplayTimeScale.Handle _choiceSlowMotion;
private GameplayTimeScale.Handle _returnSlowMotion;
private SoundHandle _warnTone;
public override bool PersistsAcrossCharacterChanges => true;
public override void OnSpawn( Player player )
{
_state = MimicState.Waiting;
_timer = 0f;
}
public override void OnRemoved( Player player ) => Finish( player );
public override void OnDeath( Player player ) => Finish( player );
public override void OnReplayPresentationRebuilt( Player player )
{
_choiceSlowMotion = null;
_returnSlowMotion = null;
// Dropped, not carried: the next PreTick re-creates it at the level the rebuilt timer implies.
StopWarnTone();
if ( _state == MimicState.Choosing )
{
_choiceSlowMotion = GameManager.Instance?.GameplayTime.Acquire(
CHOICE_SCALE, 0f, CHOICE_EASE_OUT, GameplayTimeEasing.SmoothStep );
ApplyChoiceShake( player );
ApplyChoiceAnimation( player );
SetChoiceFadeInProgress( (float)_choiceTicks / CHOICE_INPUT_LOCK_TICKS );
}
}
public override void PreTick( Player player, float dt )
{
if ( _state == MimicState.Finished ) return;
if ( player.Stage.IsGameOver )
{
Finish( player );
return;
}
switch ( _state )
{
case MimicState.Waiting:
_timer += dt;
float wait = _chosenIds.Count == 0 ? FIRST_WAIT : REPEAT_WAIT;
TickWarning( player, wait - _timer, dt, toChoice: true );
if ( _timer >= wait )
BeginChoice( player );
break;
case MimicState.Choosing:
TickChoice( player );
break;
case MimicState.Transformed:
_timer += dt;
TickWarning( player, TRANSFORMED_TIME - _timer, dt, toChoice: false );
if ( _timer >= TRANSFORMED_TIME )
ReturnToMimic( player );
break;
}
}
public override void PostTick( Player player, float dt )
{
if ( _state != MimicState.Choosing ) return;
ApplyChoiceAnimation( player );
UpdatePreviews( player );
// Held for the whole pick. The choice runs the sim at CHOICE_SCALE, so these asserts are ~7x
// further apart in real time than usual — Haptics.Sustain measures staleness in sim steps for
// exactly this case, and the channel stays solid rather than stuttering.
if ( player.DrivesHaptics )
Haptics.Sustain( HapticChannel.MimicChoice, CHOICE_HAPTIC_STRENGTH );
}
/// <summary>A crush mid-choice cancels the choice cleanly: the previews and the held slow-motion
/// can't ride out a squash of unknown length (the crusher itself would crawl at choice speed),
/// so drop back to Waiting and re-roll a fresh pick after the pop's grace period.</summary>
public override void OnSquashed( Player player )
{
// No PreTick while flat, so a live warn tone would hang at its last level; PreTick re-arms it.
StopWarnTone();
if ( _state != MimicState.Choosing ) return;
DestroyPreviews();
GameManager.Instance?.GameplayTime.Release( _choiceSlowMotion );
_choiceSlowMotion = null;
_state = MimicState.Waiting;
_timer = 0f;
}
/// <summary>The squash paused this ability wholesale (no PreTick while flat); on the pop,
/// guarantee at least a beat of control before the next transformation kicks off.</summary>
public override void OnUnsquashed( Player player )
{
if ( _state != MimicState.Waiting ) return;
float wait = _chosenIds.Count == 0 ? FIRST_WAIT : REPEAT_WAIT;
_timer = MathF.Min( _timer, wait - UNSQUASH_MIN_WAIT );
}
private void BeginChoice( Player player )
{
StopWarnTone();
PickChoices();
_state = MimicState.Choosing;
_choiceTicks = 0;
_leftPreview = player.Stage.CreateMimicChoiceSprite( _leftChoice, out _leftPulse );
_rightPreview = player.Stage.CreateMimicChoiceSprite( _rightChoice, out _rightPulse );
UpdatePreviews( player );
ApplyChoiceShake( player );
_choiceSlowMotion = GameManager.Instance?.GameplayTime.Acquire(
CHOICE_SCALE, CHOICE_EASE_IN, CHOICE_EASE_OUT, GameplayTimeEasing.SmoothStep );
Audio.PlaySfx( SfxType.Glitter0, player.Position, 0.7f, 1.15f );
player.Stage.SpawnMimicChoiceSparkles( player.Pos );
player.ConsumeDirectionalInputForTick();
}
private void TickChoice( Player player )
{
var input = player.InputSource;
player.ConsumeDirectionalInputForTick();
ApplyChoiceShake( player );
UpdatePreviews( player );
if ( _choiceTicks < CHOICE_INPUT_LOCK_TICKS )
{
_choiceTicks++;
return;
}
// A latched Just edge is always a fresh press: edges are cleared after every sim step (the lock
// ticks included), so a direction held into or through the lock can never fire one, and a
// release + re-press that lands entirely between two slow-motion steps still registers.
if ( input.LeftJust )
{
Choose( player, _leftChoice );
return;
}
if ( input.RightJust )
{
Choose( player, _rightChoice );
return;
}
_choiceTicks++;
if ( _choiceTicks >= CHOICE_TICKS )
Choose( player, TimeoutChoice( player.InputSource ) );
}
/// <summary>A side held at the deadline counts as intent even though a held key never fires a choose
/// edge; only a neutral (or both-held) deadline falls back to a random pick.</summary>
private CharacterDef TimeoutChoice( IPlayerInput input )
{
if ( input.Left && !input.Right ) return _leftChoice;
if ( input.Right && !input.Left ) return _rightChoice;
return Rng.Int( 0, 2 ) == 0 ? _leftChoice : _rightChoice;
}
private void Choose( Player player, CharacterDef choice )
{
_chosenIds.Add( choice.Id );
player.ClearShakeOffset();
Audio.PlaySfx( SfxType.TurnInvisible, player.Position, 0.8f, 1.2f );
player.Stage.SpawnMimicCloudBurst( player.Pos, playSound: false, compact: true );
DestroyPreviews();
GameManager.Instance?.GameplayTime.Release( _choiceSlowMotion );
player.Character = choice;
_state = MimicState.Transformed;
_timer = 0f;
}
private void ReturnToMimic( Player player )
{
StopWarnTone();
Audio.PlaySfx( SfxType.TurnVisible, player.Position, 0.8f, 1f );
player.Stage.SpawnMimicCloudBurst( player.Pos, playSound: false, compact: true );
player.Character = Characters.Mimic;
_state = MimicState.Waiting;
_timer = 0f;
_returnSlowMotion = GameManager.Instance?.GameplayTime.OneShot(
RETURN_TIME_SCALE,
RETURN_TOTAL_TIME - RETURN_EASE_TIME_IN - RETURN_EASE_TIME_OUT,
RETURN_EASE_TIME_IN,
RETURN_EASE_TIME_OUT,
GameplayTimeEasing.SmoothStep );
}
/// <summary>Drive the pre-swap cues once the deadline is within WARN_TIME: the tone swells from
/// silence while climbing in pitch, and the speck gaps shrink. Outside the window the tone is off
/// and the pacing timer pinned at 0 so no backlog is banked.</summary>
private void TickWarning( Player player, float remaining, float dt, bool toChoice )
{
if ( remaining > WARN_TIME )
{
_warnTimer = 0f;
StopWarnTone();
return;
}
float progress = 1f - Math.Clamp( remaining / WARN_TIME, 0f, 1f );
UpdateWarnTone( progress );
_warnTimer -= dt;
if ( _warnTimer > 0f ) return;
_warnTimer += MathX.Lerp( WARN_INTERVAL_START, WARN_INTERVAL_END, progress );
EmitWarnParticle( player, toChoice );
}
private void UpdateWarnTone( float progress )
{
if ( _warnTone is null || !_warnTone.IsValid )
_warnTone = Audio.PlaySwapperHoverTone( 0f );
// Eased volume keeps the onset inaudible; pitch climbs linearly so the rise reads as a countdown.
float volume = WARN_TONE_VOLUME * progress * progress;
float pitch = MathX.Lerp( WARN_TONE_PITCH_START, WARN_TONE_PITCH_END, progress );
Audio.SetLoopingSfxProperties( _warnTone, volume, pitch );
}
private void StopWarnTone()
{
Audio.StopLoopingSfx( _warnTone );
_warnTone = null;
}
/// <summary>One speck drifting slowly off the body in a random direction (no gravity bias, so it
/// reads the same under reverse/sideways gravity). Choice-bound: the choice sparkle's glints;
/// return-bound: the return puff's lavender cloud, so each cue previews the swap it precedes.</summary>
private static void EmitWarnParticle( Player player, bool toChoice )
{
var offset = new Vector2(
Rng.CosmeticFloat( -Player.COLLISION_SIZE.x * 0.5f, Player.COLLISION_SIZE.x * 0.5f ),
Rng.CosmeticFloat( -Player.COLLISION_SIZE.y * 0.5f, Player.COLLISION_SIZE.y * 0.5f ) );
float angle = Rng.CosmeticFloat( 0f, MathF.PI * 2f );
var vel = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * Rng.CosmeticFloat( 5f, 10f );
float lifetime = Rng.CosmeticFloat( 0.3f, 0.5f );
if ( toChoice )
{
ParticleKind kind = Rng.CosmeticInt( 0, 3 ) == 0 ? ParticleKind.MimicWow0 : ParticleKind.MimicWow1;
player.Stage.AddFieldParticle( player.Position + offset, vel, 0.94f, 0f, kind, lifetime, 1 );
}
else
{
ParticleKind kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
player.Stage.AddMimicCloud( player.Position + offset, vel, kind, lifetime, Rng.CosmeticInt( 1, 3 ) );
}
}
private void PickChoices()
{
List<CharacterDef> all = Characters.All
.Where( character => character.Id != Characters.Mimic.Id && character.Partner is null )
.ToList();
// A runtime form change replaces only this Player, so each twin is a solo Mimic form.
all.Add( Characters.Twins );
all.Add( Characters.TwinTwo );
List<CharacterDef> unseen = all.Where( character => !_chosenIds.Contains( character.Id ) ).ToList();
if ( unseen.Count >= 2 )
{
_leftChoice = TakeRandom( unseen );
_rightChoice = TakeRandom( unseen );
return;
}
if ( unseen.Count == 1 )
{
CharacterDef unseenChoice = unseen[0];
List<CharacterDef> previous = all.Where( character => character.Id != unseenChoice.Id ).ToList();
CharacterDef previousChoice = TakeRandom( previous );
if ( Rng.Int( 0, 2 ) == 0 )
{
_leftChoice = unseenChoice;
_rightChoice = previousChoice;
}
else
{
_leftChoice = previousChoice;
_rightChoice = unseenChoice;
}
return;
}
_leftChoice = TakeRandom( all );
all.Remove( _leftChoice );
_rightChoice = TakeRandom( all );
}
private static CharacterDef TakeRandom( List<CharacterDef> choices )
{
int index = Rng.Int( 0, choices.Count );
CharacterDef choice = choices[index];
choices.RemoveAt( index );
return choice;
}
private void UpdatePreviews( Player player )
{
SetPreviewPosition( _leftPreview, player.Pos + new Vector2( -PREVIEW_OFFSET, 0f ), player.GravityReversed );
SetPreviewPosition( _rightPreview, player.Pos + new Vector2( PREVIEW_OFFSET, 0f ), player.GravityReversed );
}
private static void SetPreviewPosition( SpriteRenderer preview, Vector2 position, bool flipVertical )
{
if ( preview is null ) return;
preview.FlipVertical = flipVertical;
preview.GameObject.WorldPosition = new Vector3(
MathF.Round( position.x ),
MathF.Round( position.y ),
Globals.DepthToZ( Globals.DEPTH_MIMIC_CHOICE ) );
}
private void ApplyChoiceShake( Player player )
{
Vector2 shake = 2f * ((_choiceTicks & 3) 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 ),
});
player.SetShakeOffset( shake );
}
private void ApplyChoiceAnimation( Player player )
{
int frame = _choiceTicks / 2 % 4;
var sprite = player.BodySpriteRenderer;
sprite.PlayAnimation( $"transform_{frame}" );
sprite.FlipHorizontal = _choiceTicks / 8 % 2 == 1;
}
private void SetChoiceFadeInProgress( float progress )
{
_leftPulse?.SetFadeInProgress( progress );
_rightPulse?.SetFadeInProgress( progress );
}
private void Finish( Player player )
{
player.ClearShakeOffset();
DestroyPreviews();
StopWarnTone();
GameManager.Instance?.GameplayTime.Cancel( _choiceSlowMotion );
GameManager.Instance?.GameplayTime.Cancel( _returnSlowMotion );
_choiceSlowMotion = null;
_returnSlowMotion = null;
_state = MimicState.Finished;
}
private void DestroyPreviews()
{
GameStage.DestroyOverlaySprite( _leftPreview );
GameStage.DestroyOverlaySprite( _rightPreview );
_leftPreview = null;
_rightPreview = null;
_leftPulse = null;
_rightPulse = null;
}
}