Game/TwinsController.cs
namespace BlockParty;

/// <summary>Owns deterministic control routing and presentation for a paired player identity.</summary>
public sealed class TwinsController
{
	private const int FLASH_FRAMES = 8;

	private readonly IReadOnlyList<Player> _players;
	private readonly SpriteRenderer _markerBar;
	private readonly SpriteRenderer _markerTip;
	private bool _hadDirectionalInput;
	private int _zeroInputFrames;
	private int _flashFrames;
	private bool _stepStarted;
	private bool _suppressAtNextPreTick;

	public int ActiveIndex { get; private set; }
	public Player ActivePlayer => ActiveIndex >= 0 && ActiveIndex < _players.Count ? _players[ActiveIndex] : null;
	/// <summary>Consecutive neutral input frames required after a fresh release. Values below 1 act as 1.</summary>
	public int ZeroInputFramesToSwitch { get; set; } = 12;

	public TwinsController( GameStage stage, IReadOnlyList<Player> players )
	{
		_players = players;
		ActiveIndex = 0;

		var barObject = stage.CreateChild( "TwinControlMarkerBar" );
		_markerBar = SpriteLayer.Add( barObject, "sprites/pixel.sprite", new Vector2( 5f, 1f ), "idle" );
		_markerBar.Opaque = false;   // marker alpha animates with input state
		_markerBar.AlphaCutoff = 0f;
		_markerBar.Color = Color.White;

		var tipObject = stage.CreateChild( "TwinControlMarkerTip" );
		_markerTip = SpriteLayer.Add( tipObject, "sprites/pixel.sprite", new Vector2( 1f, 1f ), "idle" );
		_markerTip.Opaque = false;
		_markerTip.AlphaCutoff = 0f;
		_markerTip.Color = Color.White;

		RouteInput( HasDirectionalInput() );
		SyncPresentation();
	}

	/// <summary>Runs once before either twin ticks, so the gesture cannot be observed twice.</summary>
	public void PreTick()
	{
		_stepStarted = true;
		EnsureLivingActive();
		if ( _flashFrames > 0 ) _flashFrames--;
		if ( _suppressAtNextPreTick )
		{
			_suppressAtNextPreTick = false;
			SuppressInputForCurrentTick();
			ResetSwitchState();
			return;
		}
		bool hasDirectionalInput = HasDirectionalInput();
		RouteInput( hasDirectionalInput );

		if ( LivingCount() < 2 )
		{
			ResetSwitchState();
			return;
		}

		bool switchRequested = UpdateSwitchCountdown( hasDirectionalInput );
		if ( !switchRequested ) return;

		int next = FindLivingOtherThan( ActiveIndex, allowHardened: false );
		if ( next >= 0 )
		{
			ActiveIndex = next;
			ActivePlayer.StartScaleBounce();
			PlayControlSwitchSfx();
			SuppressInputForCurrentTick();
			_flashFrames = FLASH_FRAMES;
		}
		ResetSwitchState();
	}

	public void EndStep() => _stepStarted = false;

	public void PlayerDied( Player player )
	{
		if ( !ReferenceEquals( player, ActivePlayer ) ) return;

		int next = FindLivingOtherThan( ActiveIndex, allowHardened: true );
		if ( next >= 0 )
		{
			ActiveIndex = next;
			ActivePlayer.StartScaleBounce();
			PlayControlSwitchSfx();
			SuppressInputForCurrentTick();
			_suppressAtNextPreTick = !_stepStarted;
			_flashFrames = FLASH_FRAMES;
		}
	}

	public void PlayerHardened( Player player )
	{
		if ( !ReferenceEquals( player, ActivePlayer ) ) return;

		int next = FindLivingOtherThan( ActiveIndex, allowHardened: false );
		if ( next < 0 ) return;

		ActiveIndex = next;
		ActivePlayer.StartScaleBounce();
		PlayControlSwitchSfx();
		SuppressInputForCurrentTick();
		_flashFrames = FLASH_FRAMES;
		ResetSwitchState();
	}

	public void SyncPresentation()
	{
		var player = ActivePlayer;
		bool visible = player is { IsDead: false } && LivingCount() > 1;
		_markerBar.Enabled = visible;
		_markerTip.Enabled = visible;
		if ( !visible ) return;

		float lift = _flashFrames > 0 ? 1f : 0f;
		float z = Globals.DepthToZ( Globals.DEPTH_PARTICLE_1 );
		float gravitySign = player.GravityReversed ? -1f : 1f;
		float headEdge = player.GravityReversed ? player.Bottom : player.Top;
		Vector2 barCenter = SpriteLayer.PixelAlignedCenter(
			new Vector2( player.X, headEdge + gravitySign * (4f + lift) ), _markerBar.Size );
		_markerBar.GameObject.WorldPosition = new Vector3( barCenter.x, barCenter.y, z );
		_markerTip.GameObject.WorldPosition = new Vector3( barCenter.x, barCenter.y - gravitySign, z );
		float alpha = _zeroInputFrames > 0
			? Math.Clamp( 1f - _zeroInputFrames / (float)Math.Max( 1, ZeroInputFramesToSwitch ), 0f, 1f )
			: 1f;
		Color color = (_flashFrames > 0 ? Color.Yellow : Color.White).WithAlpha( alpha );
		_markerBar.Color = color;
		_markerTip.Color = color;
	}

	private void EnsureLivingActive()
	{
		if ( ActivePlayer is { IsDead: false } ) return;
		int next = FindLivingOtherThan( -1, allowHardened: true );
		if ( next >= 0 ) ActiveIndex = next;
	}

	private void RouteInput( bool hasDirectionalInput )
	{
		for ( int i = 0; i < _players.Count; i++ )
		{
			bool active = i == ActiveIndex && !_players[i].IsDead;
			_players[i].SetPairedControlActive( active, hasDirectionalInput );
			_players[i].SetInputSource( active ? LiveInputSource.Instance : NullPlayerInput.Instance );
		}
	}

	private void SuppressInputForCurrentTick()
	{
		bool hasDirectionalInput = HasDirectionalInput();
		for ( int i = 0; i < _players.Count; i++ )
		{
			_players[i].SetPairedControlActive( i == ActiveIndex && !_players[i].IsDead, hasDirectionalInput );
			_players[i].SetInputSource( NullPlayerInput.Instance );
		}
	}

	private void PlayControlSwitchSfx()
	{
		// Dry click at the handover, one fixed pitch per twin (never random): the same switch always
		// sounds the same, and which twin took over is readable from the tone alone.
		Audio.PlaySfx( SfxType.TwinSwitch, ActivePlayer.Position, 0.6f, ActiveIndex == 0 ? 0.9f : 1.15f );
		// Quick crisp tick so a handover is felt as well as heard — it changes which body the stick
		// drives, which is worth confirming in the hand.
		Haptics.Pulse( 0.3f, 0.05f, 0f, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
	}

	private int FindLivingOtherThan( int excluded, bool allowHardened )
	{
		for ( int i = 0; i < _players.Count; i++ )
			if ( i != excluded && !_players[i].IsDead && (allowHardened || !_players[i].IsHardened) )
				return i;
		return -1;
	}

	private int LivingCount()
	{
		int count = 0;
		foreach ( var player in _players )
			if ( !player.IsDead ) count++;
		return count;
	}

	private static bool HasDirectionalInput()
	{
		return InputState.Left || InputState.Right || InputState.Up || InputState.Down
			|| InputState.LeftJust || InputState.RightJust || InputState.UpJust || InputState.DownJust;
	}

	private bool UpdateSwitchCountdown( bool hasDirectionalInput )
	{
		if ( hasDirectionalInput )
		{
			_hadDirectionalInput = true;
			_zeroInputFrames = 0;
			return false;
		}

		if ( !_hadDirectionalInput && _zeroInputFrames == 0 )
			return false;

		_hadDirectionalInput = false;
		_zeroInputFrames++;
		return _zeroInputFrames >= Math.Max( 1, ZeroInputFramesToSwitch );
	}

	private void ResetSwitchState()
	{
		_hadDirectionalInput = false;
		_zeroInputFrames = 0;
	}
}