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 );
	}
}