Entities/Abilities/SwarmAbility.cs
using System.Collections.Generic;
namespace BlockParty;
/// <summary>Maintains up to five shared-input copies and moves player authority to a surviving body
/// whenever the current authoritative body dies.</summary>
public sealed class SwarmAbility : PlayerAbility
{
internal const int DEATH_HITSTOP_FRAMES = 4;
internal const int MAX_BODIES = 7;
// Budding slows as the swarm grows: the first copy arrives at the base interval, the last one at
// the max, so an early death still recovers quickly but a full swarm can't be sustained cheaply.
private const float SPAWN_INTERVAL = 3.85f;
private const float SPAWN_INTERVAL_MAX = 7.5f;
private const float FAILED_SPAWN_RETRY = 0.5f;
// Spawn presentation: a short freeze to land the "pop", plus TurnVisible pitched well above the
// mimic revert's neutral playback so the bud reads as its own cue. Swap in a dedicated SfxType
// here if the swarm ever gets bespoke audio.
private const int SPAWN_HITSTOP_FRAMES = 3;
private const float SPAWN_SFX_VOLUME = 0.7f;
private const float SPAWN_SFX_PITCH = 1.35f;
private const float SPAWN_ANCHOR_SHAKE_STRENGTH = 2f;
private const int SPAWN_ANCHOR_SHAKE_FRAMES = 6;
private readonly List<Player> _copies = new( MAX_BODIES - 1 );
private float _spawnTimer;
public override void OnSpawn( Player player )
{
_spawnTimer = CurrentSpawnInterval;
}
/// <summary>Spawn delay for the next copy, ramped from the base interval (one body alive) to the max
/// (one slot left to fill).</summary>
private float CurrentSpawnInterval
{
get
{
int bodies = _copies.Count + 1;
float t = (bodies - 1) / (float)(MAX_BODIES - 2);
return MathX.Lerp( SPAWN_INTERVAL, SPAWN_INTERVAL_MAX, t );
}
}
public override void OnRemoved( Player player )
{
// OnRemoved only fires on a mid-run form change (a transformed Mimic reverting), so each live
// copy leaves with the same cloud poof the mimic transform itself uses — the swarm visibly
// dissolves rather than vanishing frame-to-frame. The retire itself stays silent.
foreach ( Player copy in _copies )
{
if ( copy is null || copy.IsDead ) continue;
player.Stage.SpawnMimicCloudBurst( copy.Pos, playSound: false, compact: true );
copy.RetireSwarmCloneSilently();
}
_copies.Clear();
}
public override bool TryPreventDeath( Player player )
{
RemoveDeadCopies();
if ( _copies.Count == 0 )
{
if ( Player.SwarmTracing ) Player.TraceSwarm( "SAVE REFUSED (no live copies) — real death" );
return false;
}
int index = Rng.Int( 0, _copies.Count );
Player copy = _copies[index];
if ( Player.SwarmTracing )
Player.TraceSwarm( $"SAVE picked {copy.SwarmLabel} (index {index} of {_copies.Count}): "
+ string.Join( ", ", _copies.ConvertAll( c => $"{c.SwarmLabel}@({c.X:0.0},{c.Y:0.0})" ) ) );
player.PreserveDeathAnimationAtCurrentPosition();
player.AdoptSwarmCloneState( copy );
copy.RetireSwarmCloneSilently();
_copies.RemoveAt( index );
player.FinishRewindDeathRecovery();
player.Stage.RequestHitStop( DEATH_HITSTOP_FRAMES );
_spawnTimer = CurrentSpawnInterval;
return true;
}
public override void PostTick( Player player, float dt )
{
// The run is decided but the game-over beat still ticks the winner. TickImpostors expires every
// copy the moment the run ends, so a spawn here would materialize a body only to instantly pop it.
if ( player.Stage.IsGameOver ) return;
RemoveDeadCopies();
if ( _copies.Count >= MAX_BODIES - 1 )
{
_spawnTimer = CurrentSpawnInterval;
return;
}
// Losing bodies shortens the wait right away instead of stranding the timer at the longer
// interval that was set while the swarm was larger.
_spawnTimer = MathF.Min( _spawnTimer, CurrentSpawnInterval );
_spawnTimer -= dt;
if ( _spawnTimer > 0f ) return;
Player copy = player.Stage.SpawnSwarmClone( player, out Player anchor );
if ( copy is null )
{
_spawnTimer = FAILED_SPAWN_RETRY;
return;
}
_copies.Add( copy );
if ( Player.SwarmTracing )
Player.TraceSwarm( $"SPAWN {copy.SwarmLabel}@({copy.X:0.0},{copy.Y:0.0}) off anchor {anchor.SwarmLabel}@({anchor.X:0.0},{anchor.Y:0.0})"
+ $" (copies now {_copies.Count})" );
_spawnTimer = CurrentSpawnInterval;
anchor.ShakeRandom( SPAWN_ANCHOR_SHAKE_STRENGTH, SPAWN_ANCHOR_SHAKE_FRAMES );
Audio.PlaySfx( SfxType.TurnVisible, copy.Position, SPAWN_SFX_VOLUME, SPAWN_SFX_PITCH );
player.Stage.SpawnMimicCloudBurst( copy.Pos, playSound: false, compact: true, color: Color.Black );
player.Stage.RequestHitStop( SPAWN_HITSTOP_FRAMES );
if ( player.DrivesHaptics ) Haptics.Pulse( 0.35f, 0.09f, 0f, Haptics.TONE_NEUTRAL );
}
private void RemoveDeadCopies()
{
_copies.RemoveAll( copy => copy is null || copy.IsDead );
}
}